В эмуляторах приставок сохранение в любой момент есть лет двадцать. В играх для ПК его нет: игра там не коробка с понятным состоянием, а живой процесс с потоками, файлами, звуком и видеокартой. Пару недель я делал ⌘S и ⌘Z для Windows-игр, которые идут на Mac через слой совместимости и Rosetta. Рассказываю, как устроен откат памяти чужого процесса, какие ловушки Rosetta стоили мне зависаний и почему самым сложным оказался не GPU, а семафоры Mono.
Почему это вообще сложно
Эмулятор приставки знает о своей машине всё: память, регистры процессора, состояние видеочипа. Сохранить их в файл и прочитать обратно — дело техники. Игра для ПК так не устроена. Её состояние размазано: часть в памяти процесса, часть в ядре (позиции чтения файлов, открытые дескрипторы), часть в драйвере видеокарты, часть в звуковой системе. На Linux есть CRIU, который умеет замораживать и поднимать процессы, но это про контейнеры и серверы, а не про игру, которая прямо сейчас рисует 100 кадров в секунду.
У меня задача ещё веселее. Игра собрана под Windows и x86-64. На Mac с M-процессором она идёт через слой совместимости (Windows API поверх macOS), инструкции переводит Rosetta, а Direct3D 11 превращается в Metal. То есть процесс игры — это бутерброд из кода Windows, кода macOS и транслятора между ними.
Цель была скромная: нажал ⌘Z — и игра вернулась на несколько секунд назад, как будто ничего не было. Без поддержки со стороны самой игры.
Идея: не сохранять игру, а откатывать память
Сохранять состояние «по-честному» нельзя: я не знаю, что внутри у конкретной игры. Зато я могу скопировать всю её память, а потом вернуть копию на место. Если вместе с памятью вернуть и регистры всех потоков, процесс окажется ровно в той точке, где был снят снимок.
Устроено так:
В процесс игры подгружается моя библиотека (
DYLD_INSERT_LIBRARIES). У неё свой поток и канал для команд.Снимок. Каждый поток игры получает сигнал (я взял
SIGEMT, им почти никто не пользуется) и «паркуется» в обработчике: сохраняет свои регистры из кадра сигнала и ждёт на личном стеке. Пока все стоят, память копируется. Копия дешёвая:mach_vm_remapс флагом копирования даёт copy-on-write, настоящие страницы копируются только когда игра в них пишет.Откат. Потоки снова паркуются, память копируется обратно. Потом каждый поток по очереди шлёт сигнал сам себе и подменяет в его кадре регистры на сохранённые. Обычный
sigreturn— и поток оказывается там, где был в момент снимка.
На синтетическом тесте это заработало почти сразу: снимок 102 МБ за 1,4 мс, откат за 1,1 мс. Я обрадовался. Дальше были две недели подробностей.

Rosetta: пять способов зависнуть
Игра — x86-64, значит, каждый её поток живёт под Rosetta. Каждый пункт ниже — отдельное зависание, которое я ловил часами.
thread_set_stateна остановленном переведённом потоке ждёт участия самого потока. Поток стоит — вызов висит вечно. Поэтому регистры потокам никто снаружи не ставит: они делают это сами, через сигналы.Нельзя останавливать поток
com.apple.rosetta.exceptionserver. Он обслуживает те самыеthread_get_state/thread_set_state.Нельзя вернуться через кадр сигнала, восстановленный из снимка. Rosetta помнит каждую доставку сигнала, а той доставки уже нет. Итог — «unexpectedly got a signal during sigreturn» и зависание. Только свежий сигнал.
Нельзя освобождать память (
mach_vm_deallocate), пока потоки запаркованы поверх только что восстановленной памяти. Освобождать — позже, когда все уже бегут.Главный поток процесса (это цикл событий macOS, а не код игры) нельзя возвращать в восстановленное ожидание
mach_msg: взаимная блокировка внутри Rosetta. Его я паркую на время копирования, но в прошлое не отправляю.
И, конечно, саму память Rosetta (её кэши переводов и замки) откатывать нельзя: следующий же ulock_wait вернёт EOWNERDEAD.
Что нельзя откатывать
Первая версия откатывала всё, что принадлежит процессу. Это была ошибка, и вот почему.
В процессе игры живёт не только игра. Там же malloc системы, Core Audio, Metal, libdispatch. Их состояние связано с ядром и драйверами, которые в прошлое не уходят. Служебные данные malloc лежат в памяти, помеченной как общая, и мой снимок их пропускал: блоки кучи уезжали в прошлое, а их учёт оставался в будущем — SIGABRT в tiny_malloc_from_free_list. Самое неприятное: побайтовое сравнение восстановленных блоков показывало ноль отличий. Правильные байты в каждом блоке не значат, что весь аллокатор согласован.
С Core Audio было ещё смешнее. Его аллокатор держит блоки в безымянной анонимной памяти, и я откатывал их вместе с игрой. Через пару секунд — ловушка где-то в caulk::alloc.
Правило в итоге такое: откатывается только память Windows-программы. Перед каждым снимком я спрашиваю карту памяти у самого Windows-слоя (NtQueryVirtualMemory: всё, что не MEM_FREE) и копирую только её плюс стеки Windows-потоков. Нативная куча, библиотеки macOS и их потоки остаются в настоящем. Снимок тестовой программы сжался со 167 до 28 МБ.
Вторая половина правила — где парковать поток. Если Windows-поток остановился внутри нативной библиотеки (malloc, Core Audio, Metal), после отката он вернётся «внутрь» вызова, держа чужие блокировки. Поэтому Windows-поток паркуется только в коде Windows или в системном вызове, сделанном оттуда. Если сигнал застал его в другом месте, обработчик просто выходит, и через 2 мс сигнал приходит снова.
Мир снаружи памяти
Память откатилась, а мир вокруг — нет. По очереди вылезло вот что.
Часы. Игра считает время кадра по монотонным часам. После отката первый кадр длится столько, сколько прошло с момента снимка, и физика прыгает. Без поправки худший кадр после третьего отката был 7,7 секунды. Теперь на снимке запоминается время игры, а при откате монотонные часы процесса сдвигаются на разницу. Худший кадр — около 120 мс.
Файлы. Позиция чтения файла живёт в ядре, а не в памяти процесса. Игра откатилась, а дескриптор уже прочитан дальше: тест насчитал 53 тысячи несовпадений. Теперь на снимке запоминаются смещения файлов, открытых только на чтение, и при откате возвращаются, если номер дескриптора всё ещё указывает на тот же файл (сверяю dev и inode). Файлы на запись не трогаю: это логи и сохранения игры, их откатывать нельзя.
Дескрипторы, закрытые в будущем. Игра закрыла файл после снимка. Откат — в памяти он снова открыт, а на стороне системы его уже нет, а номер, возможно, выдан другому объекту. Решение: пока есть снимок, закрытие откладывается и происходит по-настоящему, только когда ни один из хранимых снимков не может этот дескриптор вернуть.
Поток посреди запроса. Если поток запарковался между отправкой запроса к серверу слоя совместимости и чтением ответа, после отката он ждёт ответ, который уже прочитан в отброшенном будущем. На время таких запросов сигнал парковки теперь заблокирован.
Звук. Здесь две истории. Звуковой поток, закрытый после снимка, после отката нужно воскресить: закрытие откладывается, а снимок помнит, какие потоки играли. Вторая история хуже: после первого же снимка звук портился навсегда — 70–90 провалов и до 66 % нулей в записи. Я перебрал всё: копирование памяти, копирование GPU, перехваты, часы. Виноват оказался сам сигнал парковки, попавший в поток реального времени Core Audio (com.apple.audio.IOThread.client). Такие потоки теперь не трогаю вовсе — они нативные, откатывать их всё равно не нужно.
Видеокарта
Direct3D 11 в моём случае переводит в Metal открытый проект DXMT. Это важно: в его код можно встроиться. У закрытого переводчика Apple (D3DMetal) такой возможности нет, поэтому перемотка работает только там, где игра идёт через DXMT.
Снимок делается не в случайный момент, а на границе кадра: дождаться, пока процессор отдал всю работу видеокарте, скопировать буферы Metal, видимые процессору. С событиями Metal есть тонкость: их счётчик нельзя уменьшить. Поэтому после отката логические номера Windows-стороны переводятся в новую физическую шкалу.
Самый поучительный баг был здесь. Примерно в 3 прогонах из 12 после нескольких откатов видеокарта зависала по тайм-ауту. Первая команда после отката ждала значение события 7086, хотя снимок был сделан на 2063. Значит, что-то в памяти осталось «в будущем». Нашлось: под Rosetta нижняя память Windows-процесса (ниже 0x7ffe0000) видна в vmmap как общая (SM=SHM), хотя на деле она частная. Моё правило «общую память не откатывать» выбрасывало её из снимка, а там жил объект DXMT со счётчиком команд. После правки — 6 серий по 10 откатов без единой ошибки.
Первая настоящая игра
Among Us, главное меню: 8 снимков по 30–105 мс, 3 отката по 33–39 мс, игра рисует дальше. Это был хороший день.
Потом была Lethal Company (Unity, Mono, DirectX 11). Она нашла всё, что не нашли тесты.
Загрузки. Откат в середину загрузки уровня ронял игру или вешал отрисовку. Теперь, если потоки
Loading.*заметно заняты, 15 секунд после этого ⌘Z отказывается, и снимки в это окно тоже не берутся. В игре это видно как «Во время загрузки перемотка недоступна».Выгруженные библиотеки. Адреса падений оказались началами модулей. Если после снимка игра выгрузила DLL или секцию памяти, к этому снимку возвращаться нельзя — берётся более новый.
Новые потоки. Голосовой чат игры создаёт потоки после каждого снимка. Поток узнавался как «виндовый» только после первой парковки, а до этого считался системным. Снимок шёл без него, а откат его не возвращал. Теперь я перехватываю
pthread_createиз Windows-слоя: так рождается каждый Windows-поток, и такие потоки обязаны запарковаться.Семафоры Mono. Это было «зависание после отката», которое я ловил дольше всего. Mono создаёт и закрывает тысячи семафоров — около 4000 закрытий в секунду. Очередь отложенных закрытий на 4096 мест переполнялась за секунду, и дальше закрывалось всё подряд, включая то, что снимок знал. После отката Mono закрывал такой дескриптор второй раз: «CloseHandle failed with error 6», обработчик падений Unity останавливает все 72 потока и ждёт. Игра стоит. Решение оказалось простым: объект, созданный после последнего снимка, ни один снимок не знает — его можно закрыть сразу. Очередь нужна только для старых.
После этих правок стенд «снимок → 2 секунды ходьбы → откат» прошёл 40 кругов из 40. До них игра вставала на 30-м и 33-м круге, а один раз упала на 5-м.
macOS 27 подкинула ещё одно
На новой системе после отката снова начались падения в Mono. Причина — JIT. Rosetta в macOS 27 держит страницы кода, который программа пишет сама, закрытыми на запись (r-x), чтобы замечать их перезапись. Мой снимок брал только память с правом записи, и этот код в него не попадал. После отката получался код из будущего и данные из прошлого.
Теперь в снимок попадает и частная исполняемая память, которой разрешена запись. Возвращаю её постранично обычной записью и только изменившиеся страницы, без mprotect: Rosetta видит запись и сама сбрасывает перевод. Тест с JIT до правки находил 159 расхождений, после — ни одного.
Сколько это стоит
Первый замер в Lethal Company расстроил: с перемоткой на 17 % меньше кадров. Бисекция показала, что дорого не копирование, а учёт. Каждое закрытие дескриптора, пока существует снимок, проходило по таблице отложенных и делало 1–2 запроса к серверу («это не файл сохранения?»). А в DXMT каждый retain/release любого объекта шёл линейным поиском по списку из ~3000 буферов. Тип файла теперь решается один раз при открытии, «закрыт дважды» проверяется по битовой карте, буферы — по хешу. Итог: 96 кадров без перемотки против 90 с ней, то есть −6 %.
Сама пауза снимка в игре — около 90 мс. Из них ~38 мс уходит на обход карты памяти: ~3900 регионов, около 10 мкс на вызов mach_vm_region_recurse под Rosetta. Отдельный урок на синтетике: полная форма этого вызова считает резидентные страницы, то есть проходит каждую страницу многогигабайтной кучи — 117 мс на 730 регионов. Короткая форма (VM_REGION_SUBMAP_SHORT_INFO_64) даёт всё нужное за 2–3 мс.
Автоснимки идут раз в 10 секунд, хранится до 6 штук. Можно выбрать «только ⌘S» — тогда пауз нет совсем, точку сохраняешь сам.
Чего перемотка не умеет
Работает только для игр на DXMT, то есть DirectX 10–11. Не D3DMetal, не OpenGL.
Онлайн-игры откатывать бессмысленно: сервер в прошлое не уходит. В CS2 и играх с античитом она выключается сама.
Во время загрузок недоступна.
В одну точку можно вернуться один раз. Второй откат в тот же снимок ронял игру в трёх случаях из трёх.
Дескрипторы и блоки памяти, созданные после снимка, после отката утекают — понемногу на каждый откат.
Редкие падения остаются. Поэтому это бета.
Итого
Откат живого процесса оказался возможным, если смириться с тем, что откатывается не всё. Прошлое — это память Windows-программы и регистры её потоков. Настоящее — ядро, драйверы, Rosetta, Core Audio и всё нативное. Почти все баги, которые я ловил, — это граница между ними, проведённая не там.
Перемотка живёт в Uncork — приложении для Windows-игр на Mac, которое я делаю; там она в бете. Если вы делали checkpoint/restore для процессов с живой видеокартой или знаете, как это решено на других платформах, — расскажите в комментариях, мне правда интересно.

