Перемотка в Windows-играх на Mac: откат памяти живого процесса

Сохранение в любой момент давно стало привычной функцией эмуляторов приставок: они контролируют память, регистры процессора и состояние видеочипа. В обычной компьютерной игре всё устроено сложнее. Её состояние распределено между памятью процесса, потоками, файловыми дескрипторами, звуковой системой, графическим драйвером и ядром операционной системы.
Разработчик проекта Uncork попытался реализовать для Windows-игр на Mac аналог сочетания ⌘S и ⌘Z. Речь идёт о программах, собранных под Windows и x86-64, которые запускаются на Mac с Apple Silicon через слой совместимости, Rosetta и перевод Direct3D 11 в Metal. Целью был откат игры на несколько секунд без поддержки со стороны самого игрового движка.
Снимок памяти вместо полноценного сохранения
Поскольку внутреннее состояние каждой игры неизвестно, сохранять его на уровне игровых объектов невозможно. Вместо этого система делает снимок памяти процесса и регистров его потоков. При восстановлении память возвращается к прежнему состоянию, а потоки продолжают выполнение с сохранённых инструкций.
В процесс загружается специальная библиотека через DYLD_INSERT_LIBRARIES. Она создаёт собственный поток управления и принимает команды. Для создания снимка потоки игры получают сигнал SIGEMT, сохраняют регистры из сигнального кадра и останавливаются на личных стеках. Пока потоки припаркованы, память копируется с помощью mach_vm_remap в режиме copy-on-write: страницы физически дублируются только после записи.
При откате потоки снова останавливаются, а память возвращается из снимка. Регистры восстанавливаются не через старый сигнальный кадр, а с помощью нового сигнала, который поток отправляет самому себе. Это позволяет корректно пройти через sigreturn. В синтетическом тесте снимок 102 МБ занимал 1,4 мс, а восстановление — 1,1 мс.
Почему Rosetta постоянно ломала восстановление
Первые результаты оказались обманчивыми: на реальной игре выяснилось, что Rosetta хранит собственное состояние, которое нельзя бездумно включать в откат. Например, вызов thread_set_state для остановленного переведённого потока мог ждать участия самого потока бесконечно. Поэтому внешняя установка регистров была исключена, а восстановление перенесено внутрь самих потоков.
Нельзя было останавливать поток com.apple.rosetta.exceptionserver, обслуживающий операции с состоянием потоков. Нельзя было возвращаться через сигнальный кадр, существовавший в момент снимка: Rosetta отслеживает доставку сигналов и воспринимала такой возврат как ошибку. Также пришлось отказаться от отката памяти Rosetta, её кэшей переводов и внутренних блокировок. Иначе следующий вызов ожидания мог завершиться ошибкой EOWNERDEAD.
Отдельной проблемой стал главный поток процесса, обслуживающий цикл событий macOS. Возврат такого потока в прежнее ожидание mach_msg приводил к взаимной блокировке внутри Rosetta. Его останавливали на время копирования, но не возвращали в прошлое состояние.
Откатывается только память Windows-программы
Попытка сохранять всю память процесса привела к повреждению системных компонентов. Внутри одного процесса работают malloc, Core Audio, Metal, libdispatch и библиотеки macOS. Их память связана с ядром и драйверами, которые сами по себе не откатываются. Даже если отдельные блоки возвращены побайтно без ошибок, внутренние структуры аллокатора могут оказаться несогласованными.
В итоговой версии система сохраняет только память Windows-слоя и стеки Windows-потоков. Области определяются через карту памяти Windows-программы с помощью NtQueryVirtualMemory. Нативная куча, библиотеки macOS и их потоки остаются в текущем состоянии. Для тестовой программы это уменьшило размер снимка со 167 до 28 МБ.
Поток разрешается парковать только внутри Windows-кода или системного вызова, сделанного из него. Если сигнал застал поток внутри malloc, Core Audio или Metal, обработчик не фиксирует его и повторяет попытку примерно через 2 мс. Иначе после отката поток мог бы вернуться в нативную библиотеку с уже чужими блокировками.
Файлы, часы, звук и графика
Одного состояния памяти недостаточно. Игра использует монотонные часы, а их показания после отката продолжали идти вперёд. Без коррекции худший кадр после третьего отката занимал 7,7 секунды. Теперь снимок хранит игровое время, а при восстановлении монотонные часы процесса сдвигаются на нужную величину. В результате худший кадр удалось сократить примерно до 120 мс.
Позиции чтения файлов находятся в ядре, поэтому их тоже приходится сохранять отдельно. Для файлов, открытых только на чтение, запоминаются смещения и проверяется, что дескриптор всё ещё относится к тому же объекту по dev и inode. Файлы, открытые на запись, не откатываются: среди них могут быть журналы и игровые сохранения. Закрытие дескрипторов после создания снимка временно откладывается, чтобы после возврата в прошлое объект ещё существовал.
Сигналы нельзя отправлять потокам реального времени Core Audio: они повреждали звук, вызывая от 70 до 90 провалов и до 66% нулевых значений в записи. Такие нативные потоки теперь полностью исключены из механизма парковки. С графикой ситуация сложнее: технология работает с открытым переводчиком DXMT, который преобразует Direct3D 11 в Metal. Закрытый D3DMetal не предоставляет точек для встраивания, поэтому с ним перемотка не поддерживается.
Снимок создаётся на границе кадра, когда процессор уже передал работу видеокарте. Буферы Metal, видимые процессору, также копируются. После отката логические номера событий Windows-стороны переводятся в новую физическую шкалу, поскольку счётчик Metal нельзя уменьшить. Одна из ошибок была связана с нижней памятью Windows-процесса: Rosetta показывала её как общую, хотя фактически она была частной. После включения этой области в снимок удалось провести шесть серий по десять откатов без зависаний GPU.
Проверка на играх и проблемы Mono
В Among Us система сделала восемь снимков за 30–105 мс и три отката за 33–39 мс, продолжив отрисовку после каждого восстановления. Более сложным испытанием стала Lethal Company на Unity, Mono и DirectX 11. Во время загрузки уровней откат мог приводить к падению или зависанию отрисовки, поэтому при активных потоках Loading.* снимки временно запрещаются, а команда отката недоступна в течение 15 секунд.
Игра также выгружала библиотеки и создавала новые потоки голосового чата уже после создания снимка. Для решения проблемы перехватывается pthread_create внутри Windows-слоя, чтобы каждый новый Windows-поток был известен механизму отката. Возврат к снимку, после которого DLL была выгружена, запрещается: выбирается более свежая точка.
Самой трудной оказалась работа с семафорами Mono. Движок создавал и закрывал около 4000 семафоров в секунду, из-за чего очередь отложенных закрытий на 4096 элементов быстро переполнялась. После восстановления Mono пытался закрыть уже закрытый дескриптор, а обработчик Unity останавливал все 72 потока. Решение состояло в разделении объектов: созданные после последнего снимка можно закрывать сразу, а откладываются только те, которые известны сохранённым состояниям. После этого тест из 40 циклов «снимок — две секунды работы — откат» прошёл без ошибок.
Производительность и ограничения
На новой версии macOS отдельную проблему создали JIT-страницы, которые Rosetta помечает как исполняемые и защищённые от записи. Их не было в первоначальных снимках, поэтому после отката код оказывался из будущего, а данные — из прошлого. После добавления частной исполняемой памяти и постраничного восстановления число расхождений в тесте сократилось со 159 до нуля.
Первоначально перемотка уменьшала частоту кадров на 17%. Основные затраты пришлись не на копирование, а на учёт дескрипторов и графических буферов. После оптимизации проверок результат составил 96 кадров без перемотки против 90 с ней, то есть около 6% потери производительности. Сам снимок в игре занимает примерно 90 мс, из которых около 38 мс приходится на обход примерно 3900 регионов памяти.
Автоматические снимки создаются раз в 10 секунд, хранится до шести точек. При выборе ручного режима, когда снимок делается только по ⌘S, паузы полностью исчезают. При этом технология остаётся экспериментальной: она предназначена для игр через DXMT и DirectX 10–11, не работает с D3DMetal и OpenGL, бесполезна в сетевых играх, отключается при использовании античита и недоступна во время загрузок. В одну точку можно вернуться только один раз, а память и дескрипторы, созданные после снимка, могут постепенно утекать.
Главный вывод проекта — откат живого процесса возможен, если чётко разделить его состояние. В прошлое отправляются память Windows-программы и регистры её потоков, тогда как ядро, драйверы, Rosetta, Core Audio и нативные библиотеки продолжают жить в настоящем. Практически все сложные ошибки возникали именно на границе между этими двумя мирами. Реализация в Uncork пока находится в бета-версии.


