Каждый год на новогодних каникулах у меня просыпается ностальгия: хочется перепройти что‑то из детства, типа «Harry Potter and the Philosopher’s Stone» от KnowWonder. И если с консольными играми всё просто (эмуляторы типа DuckStation или PCSX2 работают идеально), то с PC‑играми 32-битной эры всё сложно, особенно на Mac. Часто, пока ты добьешься запуска игры, уже пропадает всякое желание ее проходить.
В этот раз я решил проблему радикально: написал свою «Windows». В браузере. На TypeScript.
Усаживайтесь поудобнее, я расскажу вам, как я полгода дебажил чужие игры двадцатилетней давности и почему на это подсел.

Пролог. Голова в банке
Однажды вечером я спросил у ИИ: «Слушай, а можно ли к готовым эмуляторам типа boxedwine или v86 как‑то по‑быстрому прикрутить WebGPU?»
ИИ ответил: «Это будет медленно, слишком много оверхеда. Может, лучше сделать свой High Level эмулятор с нуля?»
Я немного растерялся: «Но подожди, это же почти как Wine с нуля написать! Его тридцать лет пилят всем миром!»
ИИ уверенно ответил: «Не боись, прорвемся. Для игр нужно гораздо меньшее подмножество системных функций, чем для тяжелых программ, так что всё будет нормально».
Я призадумался:
-
вообще разработка WinAPI ведь достаточно хорошо изученная задача, ИИ должен помочь реализовать все эти бесконечные
CreateWindowExправильно -
это может быть интересный тест того, насколько сложный проект вообще можно разработать сегодняшними моделями (все же не чистая трансляция с одного языка на другой)
-
ну и вообще, я наконец могу воплотить свое идеальное видение запускалки Windows игр! Мне всегда импонировала концепция ROM‑картриджей из мира консолей: вкинул файл — и погнали. А ещё меня всегда раздражало, как старые игры мусорят в реестре, прячут сейвы по всему диску и растягивают кривое 4:3 на весь ultrawide монитор.
Архитектура
Итак, ИИ предложил использовать High‑Level Emulation. В чём здесь идея?
Если совсем грубо, полный системный эмулятор притворяется компьютером, а High-Level Emulator — самой Windows.
В полном эмуляторе почти вся цепочка остаётся внутри гостевой x86-среды:
игра → WinAPI / DirectX → драйвер Windows → виртуальное устройство → эмулятор → браузер
Игра, системные библиотеки и драйверы — всё это x86-код, который v86 приходится переводить в WebAssembly, а браузеру затем — в машинный код хоста. Получается настоящая матрёшка: чтобы нарисовать один треугольник или воспроизвести звук, операция проходит через несколько вложенных машин.
HLE срезает почти всю середину:
игра → перехваченный вызов WinAPI / DirectX → TypeScript → WebGPU / Web Audio / OPFS
Оригинальный x86-код игры по-прежнему исполняется внутри v86. Но как только игра просит Windows нарисовать графику, прочитать файл или воспроизвести звук, эта работа выходит из низкоуровневой эмуляции и передаётся браузеру, где её выполняют нативные подсистемы хостовой системы.
В результате целевая архитектура BottleShip напоминает голову в банке, подключённую к огромному механическому телу. Внутри эмулятора остаётся игры — её оригинальный x86-код, логика и состояние. А всё тяжёлое и ресурсоёмкое выполняет браузер, используя нативные возможности хостовой системы.
Как говорят в Adeptus Mechanicus: «Плоть слаба!»
Таким образом у нас есть три задачи:
-
Выбрать «голову» — нашу x86-командомолотилку, способную исполнять оригинальный код игры.
-
Построить для неё экзоскелет — подобрать современные браузерные API, которые заменят соответствующие подсистемы старой Windows.
-
Настроить между ними достаточно быструю связь, чтобы игра не только запускалась, но и работала с приемлемой производительностью.
Голова
В качестве «головы» я выбрал v86 — достаточно зрелый браузерный эмулятор x86, значительная часть ядра которого написана на Rust. Его главное преимущество — встроенный JIT‑компилятор: вместо побайтовой интерпретации каждой инструкции v86 на лету переводит блоки машинного кода x86 в WebAssembly, который затем эффективно исполняется браузером.
Наличие готового JIT дало мне хороший фундамент: вместо того чтобы с нуля решать задачу быстрой трансляции x86, я мог сосредоточиться на Windows API и постепенно оптимизировать наиболее горячие пути уже внутри существующего компилятора.
Экзоскелет
-
OPFS заменяет диск и часть файловой подсистемы Windows;
-
WebGPU берёт на себя DirectDraw, Direct3D, OpenGL, Glide и конвертацию графических данных;
-
AudioWorklet и Web Audio заменяют звуковое устройство и аудиомикшер;
-
FFmpeg‑WASM декодирует видео в старых форматах типа Bink и Smacker;
-
остальные браузерные API заменяют привычную периферию и внешнее окружение Windows. Gamepad API даёт доступ к геймпадам, Pointer Lock — к относительному движению мыши, Fullscreen API — к полноэкранному режиму, а WebSocket и другие сетевые интерфейсы можно в перспективе использовать для сетевых функций. Даже системные шрифты необязательно растеризовывать самостоятельно: браузер уже умеет корректно парсить и отрисовывать TTF, поэтому интерфейс игры можно рендерить через Canvas, а затем загружать получившийся атлас в WebGPU. Ну не круто ли?
Транспорт: как игра общается с эмулятором
Осталась третья задача — связать голову с экзоскелетом. Игра ведь не знает, что она внутри банки: она честно вызывает CreateWindowEx, Direct3D или ReadFile, ожидая, что за ними стоит настоящая Windows. Наша задача — перехватить эти вызовы и подсунуть вместо системной реализации свою, на TypeScript.
Механизм такой: каждая функция WinAPI в памяти гостя — это крошечная заглушка (thunk): несколько байт x86-кода, которые кладут ID функции и выполняют инструкцию OUT на «магический» порт 0xB077. Для процессора это обычная запись в порт ввода-вывода, но наш v86 на этом порту специально останавливается: исполнение x86 замирает, управление передается в наш TypeScript.
Дальше диспетчер по ID понимает, какую функцию вызвали, читает аргументы прямо из памяти и стека гостя, выполняет нативную логику — рисует через WebGPU, читает файл из OPFS, что угодно, — кладёт результат обратно в регистры EAX/EDX и будит x86. Игра продолжает выполнение как ни в чём не бывало: для неё это выглядит как обычный возврат из системного вызова.
Примерно так это выглядит с x86 стороны:
B8 2A 01 00 00 mov eax, 0x12A ; ID функции (у каждой свой)
BA 77 B0 00 00 mov edx, 0xB077 ; тот самый «магический» порт
EF out dx, eax ; ← здесь исполнение x86 замирает
C2 10 00 ret 16 ; stdcall: снять со стека 4 аргумента
Обратите внимание на последнюю строчку: там не просто RET, а RET 16. Большинство функций WinAPI в 32-битной Windows используют соглашение о вызовах stdcall: после возврата именно вызываемая функция должна убрать свои аргументы со стека.
То есть заглушке нужно заранее знать, сколько байт занимают аргументы. Четыре 32-битных аргумента — RET 16, двенадцать (привет, CreateWindowExA) — RET 48. Чтобы сгенерировать такую заглушку, недостаточно знать имя функции — нужно знать её сигнатуру. Ошибись хотя бы на четыре байта, и после возврата ESP уедет. Игра может даже не упасть сразу: она продолжит работать с чуть-чуть сдвинутым стеком и развалится через десять кадров в совершенно другом месте.
Соглашения о вызовах
На практике пришлось иметь дело сразу с несколькими соглашениями.
У функций C-рантайма (printf, memcpy и компания) обычно используется cdecl. Здесь стек после вызова чистит уже вызывающая сторона, поэтому наша заглушка заканчивается обычным RET и ей вообще не нужно знать число аргументов. Это особенно удобно для функций вроде printf, где количество параметров заранее неизвестно и определяется строкой формата.
У методов C++ в старом MSVC используется thiscall: указатель this передаётся через ECX, остальные аргументы — через стек.
Для системных DLL число аргументов я беру из сгенерированной таблицы метаданных Win32. У C++ такой таблицы нет, зато нужная информация закодирована прямо в имени экспортируемой функции:
?Serialize@FArchive@@UAEXPAXH@Z
Эта абракадабра — декорированное имя функции в терминологии MSVC: компилятор кодирует в нём класс, соглашение о вызовах, тип возвращаемого значения и типы параметров . Генератор разбирает его и вычисляет размер аргументов на стеке. Для FArchive::Serialize(void*, int) это восемь байт, значит заглушка заканчивается RET 8.
А вот как это примерно выглядит с typescript стороны:
onPortWrite(functionId: number, cpu: CPU) {
const esp = cpu.reg32[4]; // на вершине стека — адрес возврата,
// сразу за ним по порядку лежат аргументы
const arg = (i: number) => dv.getUint32(esp + 4 + i * 4, true);
const stub = this.stubs.get(functionId)!; // → "user32:CreateWindowExA"
const result = handlers[stub.key](arg, mem);
cpu.reg32[0] = result >>> 0; // EAX — младшие 32 бита
cpu.reg32[2] = (result / 2 ** 32) | 0; // EDX — старшие
}
Для функций, которые ещё не реализованы, стоит другая ловушка — инструкция UD2. Она вызывает контролируемое падение, и вместо того чтобы громко крешнуться с непонятным поведением, эмулятор аккуратно сообщает: вот эта конкретная функция ещё не реализована.
Акт 1. Первые шаги и первая игра
Закатав рукава, я взялся за работу. Для начала решил собрать пару легковесных single-exe демок — проверить базовые вещи, прежде чем браться за настоящую игру. Сделал DX9-сцену со своей старой 3D-машинкой и пианинку на GDI для проверки клавиатуры и звука — и начал кодить вместе с ИИ-агентом.
В процессе подключения v86 выяснилось, что нам нужен собственный бутлоадер. x86-процессор после запуска находится в 16-битном real mode, а наш код рассчитывал на 32-битное окружение. Значит, нужно было вручную настроить таблицу дескрипторов, включить protected mode и передать управление 32-битному коду.
Я приготовился к большому головняку, однако агент выплюнул прямо в TypeScript массив байтов с машинным кодом и после пары итераций бутлоадер заработал.
Кажется, именно в этот момент я впервые по-настоящему понял, какие возможности сейчас открываются для реверс-инжиниринга и системного программирования. Ну а ещё через пару часов обе демки уже вели себя в браузере так же, как под Windows.
Тогда я наивно подумал: «Ну, раз DX9 завелся, он же наверняка обратно совместим со всеми старыми версиями, это будет легко...», скачал какую‑то старую демку Re‑Volt как промежуточный шаг на пути к запуску Harry Potter.
Boy oh boy, как же я ошибался...
Re-Volt
Поначалу прогресс по Re-Volt пошел достаточно быстро: игра не падала, музыка и звуки заработали, но вместо 3D-изображения на экране была непроглядная чернота, которая после еще некоторого количества колдовства превратилась в черные объекты.
Постепенно, обвешивая пайплайн логами и дампами поверхностей, я выяснил, что текстуры до рендера доходят неправильно. Тогда я нашёл исходники Re‑Volt и почувствовал себя очень умным: теперь-то для любого странного вызова можно будет посмотреть, что именно делает игра и чего ожидает от DirectX.
Когда текстуры заработали, я столкнулся со следующей проблемой — некорректная работа с прозрачностью.
Примерно здесь я понял, что исходники оказались не от той версии, которую я тестировал. Исходники использовали SetColorKey для прозрачности, а настоящая игра использовала формат текстур ARGB1555. Так что вместо ускорения исходники несколько раз отправили меня по ложному следу. После этого я полностью перешёл к реверс-инжинирингу конкретных EXE и DLL из тестового бандла в Ghidra.
Заодно я узнал — не представляю, как дожил до седых волос, не зная этого, — что BMP хранит цветовые каналы в порядке BGR. Поэтому перед загрузкой текстур в WebGPU данные нужно было не только распаковать из ARGB1555, но и правильно переставить каналы.
Довольно быстро выяснилось, что неправильный формат текстур — далеко не главная проблема. DirectX 7 оказался огромной и капризной стейт-машиной с кучей инвариантов. Главный вопрос, на котором я трижды обломал зубы: у кого прямо сейчас правильные пиксели — у CPU или у GPU?
DirectDraw разрешает игре в любой момент вызвать Lock() и писать в поверхность напрямую. GDI может нарисовать что-нибудь туда же. А потом всё это надо отрендерить на GPU.
Первая версия наивно держала пиксели на CPU и перезаливала текстуру на каждый чих. Вторая переехала на WebGPU — и утонула в багах синхронизации. В третьей появилась явная модель владения: у каждой поверхности есть authority — CPU или GPU, — версии и dirty-флаги. Конверсия форматов уехала в compute-шейдер: одна только распаковка RGB565 → RGBA ускорилась примерно в 4730 раз, со 141 мс до 0,03 мс. Позже пришлось добавить ещё и lease-модель: пока игра держит Lock(), ни один внутренний путь записи не имеет права трогать эту память.
Следом закономерно всплыла многопоточность. Не секрет, что планировщик — сердце любой ОС. Моё «сердце» в итоге разрослось примерно до шести тысяч строк и было переписано несколько раз — и, подозреваю, будет переписано ещё. Инварианты там всплывают неожиданно и не всегда детерминированно: часть багов, например, проявлялась только на моём макбуке.
Отдельная песня — время. Механизма понадобилось сразу два. Первый компенсирует виртуальное время: пока WinAPI-вызов исполняется на нашей стороне, x86 стоит и не генерирует «тики», поэтому прошедшее время приходится докручивать вручную. Второй — Frame Pacer — решает обратную задачу и не даёт игре рендерить быстрее дисплея: каждый вызов requestAnimationFrame() открывает игре следующий кадр, синхронизируя её с частотой монитора.
На то, чтобы Re-Volt нормально заработал, ушло около полутора месяцев. За это время я несколько раз всерьёз собирался сдаться и забросить проект. Но когда игра наконец поехала — причём благодаря отличной оптимизации самого Re-Volt уверенно держала 60 FPS, — я понял, что окончательно подсел.
Это ни с чем не сравнимое чувство: старая игра вдруг оживает в браузере, и тебя на несколько секунд переносит куда-то далеко в детство. А потом ты начинаешь биться за совместимость следующей игры — и всё начинается заново.
Акт 2. Турнир с Unreal
После Re‑Volt (и за компанию Diablo 2 с HOMM3) я решил взяться за следующий большой этап на пути к Harry Potter.
Дело в том, что Harry Potter была сделана на Unreal Engine 1, так что я решил начать его поддержку с демки Unreal Tournament 99. Здесь меня ждало залипалово еще на месяц‑полтора, к которому я оказался не готов.
DLL‑ад
До Unreal Engine мои игры были устроены просто: один exe, пара системных DLL. UT99 растоптал эту идиллию: сам UnrealTournament.exe — это крошечный лаунчер, а весь движок размазан по полутора десяткам собственных DLL — Core.dll, Engine.dll, Render.dll, Window.dll, D3DDrv.dll, Galaxy.dll... — которые импортируют друг друга во все стороны.
Это сломало сразу несколько наивных допущений моего PE‑загрузчика:
-
Граф зависимостей. нужен честный обход графа импортов с правильным порядком инициализации, поиском по путям (DLL search order — тоже отдельная спецификация!) и вызовом DllMain каждой библиотеки.
-
C++ через границы DLL. UE1 экспортирует не аккуратные C‑функции, а C++ классы целиком: запутанные имена вида
?Serialize@FArchive@@…, виртуальные таблицы, объекты, созданные в одной DLL и разрушаемые в другой. Пришлось учить загрузчик полноценным таблицам экспортов, а на каждый отсутствующий импорт ставить UD2-ловушку, чтобы игра падала с именем недостающей функции. -
Рантайм Visual C++ 6. Вместе с движком приехал весь зоопарк VC6: std::basic_string и компания из msvcp60, C++ исключения поверх SEH — с funclet’ами, которые надо исполнять на правильном стеке.
А что такое SEH?
Structured Exception Handling — механизм обработки исключений, встроенный в 32-битную Windows на уровне ABI. У каждого потока FS:[0] указывает на голову односвязного списка зарегистрированных обработчиков. Когда происходит деление на ноль, обращение к чужой памяти или программный throw, Windows формирует EXCEPTION_RECORD и CONTEXT и последовательно вызывает обработчики из этой цепочки, пока один из них не решит обработать исключение.
C++-исключения VC6 построены поверх того же механизма. Windows сама не ищет подходящий catch: она передаёт исключение обработчику рантайма компилятора, а уже тот по служебным таблицам определяет нужный catch, разматывает стек, вызывает деструкторы и передаёт управление в соответствующий funclet — отдельный огрызок функции, которому нужен доступ к кадру стека исходной функции.
Для эмулятора это означает, что мало просто поймать ошибку — нужно правдоподобно воспроизвести весь этот протокол. Поймать fault в v86, собрать гостевые EXCEPTION_RECORD и CONTEXT, найти цепочку по FS:[0], вызвать гостевые обработчики с правильным ABI и, если потребуется, корректно размотать стек и продолжить выполнение в catch. И всё это — не сломав стек, регистры и состояние потока. UE1 полагается на SEH постоянно: движок оборачивает в try/catch каждый тик и через этот же механизм показывает то самое окно Critical Error.
Короче, «поддержать UE1» на практике означало переписать загрузчик, доделать C++ рантайм и реализовать SEH — механизм, который в настоящей Windows является жирным куском ядра (у меня он сегодня занимает ~4000 строк).
Кривой sprintf
В Windows все строки живут в двух мирах: «узком» — однобайтовые char в локальной кодировке (это буква A в конце функций WinAPI: CreateWindowExA), и «широком» — двухбайтовые wchar_t (буква W: CreateWindowExW). Соответственно, у функций C-рантайма тоже есть две версии: у sprintf есть широкий близнец swprintf. Узкий форматтер у меня был нормальный. А вот в широком я float‑спецификаторы (%f, %e, %g) реализовать забыл: встретив их, он просто копировал спецификатор в вывод как обычный текст.
У UE1 TCHAR = wchar_t, так что SaveConfig() пишет конфиг строго через широкий путь. В итоге все float‑параметры сохранялись в.ini как литерал %f:
Brightness=%f
ScaleXYZ=%f
ScaleRUV=%f
А при следующей загрузке движок звал atof("%f") — и получал ноль. В итоге этот баг форматирования проявлялся в игре двумя совершенно разными симптомами:
-
Чёрный экран.
Brightness=%f → atof = 0.0. Драйвер UE1 строит из нулевой яркости полностью нулевую гамма‑таблицу — на реальном железе это затемняет экран целиком. -
Мёртвый курсор.
ScaleXYZиScaleRUV— это множители осей мыши. Сохранились как%f→ на загрузкеatof = 0→ каждое смещение мыши умножается на ноль → курсор намертво стоит в углу. И самое коварное: с дефолтным конфигом (там реальные числа) мышь работала, ломалось только после того, как ты один раз сохранил настройки и перезашёл.
Этот баг я починил, случайно заметив в сохраненном конфиге значение, подозрительно напоминающее криво отработавший printf.
Кривой GetProcAddress
UE1 лениво привязывает нативные функции через GetProcAddress(dll, "ИмяФункции"). Причём имя функции каждый раз собирается в одном и том же буфере на стеке: адрес не меняется, меняется только содержимое.
Мой кэш GetProcAddress был устроен ровно так, как делать не надо: ключом служил указатель на строку, а проверка кэша происходила до чтения самой строки. Поэтому после первого вызова любой следующий запрос с тем же буфером — уже с другим именем внутри — получал адрес первой функции.
Снаружи это выглядело как полный хаос: игра падала в разных местах, симптомы менялись от запуска к запуску, а точка креша не давала почти никаких зацепок. На всё это накладывалась ещё и не до конца детерминированная многопоточность. Я перебирал коммиты, сравнивал логи, разбирал место падения — и всё тщетно.
Настоящий баг находился на километр логов раньше, в самом GetProcAddress.
До корня проблемы я дошёл почти случайно: в одном из обсуждений агент мимоходом упомянул кэш адресов. И я подумал, что баг в этом механизме максимально логично объяснил бы всё происходящее. Через несколько минут гипотеза подтвердилась — я нашёл ошибку, из-за которой едва не забросил проект во второй раз.
Акт 3. Harry Potter
Наконец я добрался до главной игры, и здесь меня ждал еще ворох багов...
Баг во _ftol
Прямо на запуске зависал сплеш‑скрин без каких‑либо признаков жизни и крешей.
Виновником оказалась крошечная функция C-рантайма — _ftol, которую старый MSVC использует при преобразовании чисел с плавающей точкой в целые. По ABI 32-битного MSVC она возвращает 64-битное целое, разложенное на пару регистров: младшие 32 бита в EAX, старшие — в EDX. Наша реализация возвращала только 32-битное значение, ограниченное INT32_MAX (0x7fffffff), а EDX вообще не трогала.
Лаунчер Unreal Engine строит шкалу времени так: берёт RDTSC (64-битный счётчик тактов, тикающий миллиардами в секунду), умножает на константу и 2^32, и через _ftol превращает в целое, чтобы вычислить DeltaTime. Уже через полсекунды аптайма это произведение (≈ 1.9·10^11) переваливало за INT32_MAX — и наш _ftol начинал возвращать 0x7fffffff каждый кадр. А раз время не меняется: DeltaTime = (сейчас − тогда) = 0 и игра не продвигается вперед.
Баг в планировщике
Игра рандомно зависала, иногда через 10 секунд после запуска, иногда спустя 10–15 минут. Один из тех самых неприятных плавающих багов.
Причина оказалась в следующем: гостевые потоки игры делят один‑единственный x87-FPU внутри v86. А мой планировщик при переключении контекста сохранял регистры общего назначения и EIP, но не сохранял состояние FPU‑стека. Поток, вытесненный посреди вычисления с плавающей точкой, возобновлялся с FPU‑регистрами другого потока. Отсюда и редкий недетерминированный мусор во float'ах, приводивший к зависаниям.
Flippendo!
Итак, после ещё сотни багфиксов — лайтмапы, семантика DirectSound, оптимизации — игра наконец стала по-настоящему играбельной. И вот я стою в Хогвартсе, запущенном в браузере, и на миг мне снова 12.
Эпилог. Полка с банками
За эти полгода я запустил еще много классных игр: Ядерный Титбит, Need For Speed: Porsche Unleashed, Need For Speed: Underground, Max Payne, Discworld Noir... Уже более 14 тайтлов. И в целом, кажется, я преодолел самый тяжелый этап, когда каждая следующая игра доставала кучу новых слоев несовместимости, реализация которых ломала уже работающие игры. Каждый новый проект уже добавляется быстрее.
Графика выросла в целую ферму бэкендов: Glide, OpenGL, DirectDraw и Direct3D от 7-й до 9-й версии (включая поддержку ранних шейдеров). Чтобы всё это не захлёбывалось на смене тысяч мелких состояний старого конвейера, появился мега‑шейдер с батчингом: вместо того чтобы плодить по отдельному WebGPU‑пайплайну на каждую комбинацию тумана/альфа‑теста/освещения, он собирает совместимые команды в пачки и гоняет их через один универсальный шейдер, а параметры прокидываются юниформами.
Улучшилась и производительность v86. Появился relaxed‑FPU (и JIT‑путь под него): ради скорости математика считается в нативных 64-битных double вместо честной 80-битной эмуляции x87. В играх с тяжёлой математикой это даёт заметный прирост. А сам мост между гостевым x86 и хостовым JS оброс несколькими скоростными полосами: hypercall'ы для сверх‑частых системных вызовов (время, синхронизация, строки — читаются прямо из общей памяти без выхода в JS), write buffer (WBUF) и arena, которые складывают мелкие вызовы в кольцевой буфер, а затем обрабатывают их пачкой — вместо отдельного дорогого перехода на каждый вызов.
За эти полгода изменилась не только техническая начинка. Совсем другой стала сама отладка.
Раньше процесс выглядел как бесконечное исследование логов и копипаст их агенту. Сейчас у проекта есть собственный harness — прослойка, через которую ИИ‑агент сам управляет эмулятором и наблюдает за ним, имея доступ к интерактивному отладчику и всему состоянию эмулятора. Так что теперь агент сам воспроизводит проблему, снимает нужный лог и проверяет гипотезы. Мне остаётся меньше ручной работы, а цикл от бага до фикса стал заметно короче.
import { harness } from "../harness";
const result = await harness()
.streamLogs(["SYSTEM", "DDRAW", "USER32"])
.openWgb("G:/WGB/half-life.wgb") // загрузить бандл с игрой
.waitForControl("New game") // дождаться, пока меню отрисуется
.click("New game") // клик по подписи, как это сделал бы человек
.breakOnApi("ddraw:.*Surface7_Blt", { continuous: true }) // брейкпоинт на API
.waitForEvent("apiBreak") // ждём, пока игра туда придёт
.state(["cpu", "surfaces", "threads"])
.captureFrame({ dumpTargets: true }) // лог отрисовки одного кадра + PNG целей
.run();
Побочный эффект этого подхода: любой такой сценарий автоматически становится воспроизводимым регрессионным тестом. После большой пачки изменений можно отправить агента на ночь прогонять весь набор игр и проверять, что они по-прежнему запускаются и корректно выглядят.
Еще один показательный пример: допустим, в игре глитчит какая‑то поверхность. Раньше это означало часы ручного копания. Сейчас я могу буквально подойти к этой поверхности и попросить ИИ разобраться. Агент сам подключается к отладочной вкладке, снимает лог отрисовки одного конкретного кадра — все Blt, Lock/Unlock, смены состояний, загрузки текстур — и идёт по нему, сверяя результат с поведением настоящей Windows.
Оговорки
BottleShip не возник из вакуума. В его основе лежит v86 — без его JIT перформанс был бы ниже плинтуса. Рядом стоят Wine, DXVK, ReactOS, MSDN и тридцать лет реверс-инжиниринга Windows. Именно благодаря этому модели часто «знают», какие флаги возвращает GetDeviceCaps или в каком порядке DirectDraw вызывает коллбэки. Но до того, как это знание попало к ИИ, его годами добывали и документировали живые люди.
Это разработка с ИИ — со всеми плюсами и побочными эффектами. Некоторые из самых мерзких багов в статье породил именно агент: достаточно вспомнить кэш GetProcAddress. Но без ИИ я, скорее всего, возился бы с BottleShip годами — или бросил бы его на первом чёрном экране. И обратное тоже верно: без моего опыта проект не ушёл бы дальше первого треугольника. Архитектура, декомпозиция, ревью и отладка багов самого ИИ всё это время оставались на мне.
Производительности есть куда расти. Я убеждён, что опытный разработчик эмуляторов выжал бы из связки v86 + WebGPU заметно больше, чем ИИ‑агенты на нынешней стадии развития. Если вы из таких — вам будет где развернуться.
На полке есть место
Я буду рад контрибьюшену — и тут есть нюанс, делающий проект необычным. Open Source сейчас стонет от AI‑пулл‑реквестов: мейнтейнеры тонут в правдоподобном сгенерированном коде, который дороже проверить, чем написать. Но BottleShip, по‑моему, из тех проектов, которые от AI‑контрибьюшена могут наоборот выиграть.
Здесь у AI-кода есть редкое преимущество: результат можно проверять не только глазами по диффу. Каждый фикс привязывается к конкретной игре, воспроизводимому сценарию в harness и кадру до/после, а затем остаётся в регрессионном наборе. Поэтому агент может взять на себя большую часть черновой работы, а мейнтейнер получает проверяемое доказательство, что код действительно чинит игру и не ломает остальные.
А главное — то самое ощущение из первого акта никуда не девается: каждая следующая игра из детства, которую удаётся запустить, дает такой же прилив дофамина как первый запуск Re‑Volt. Предупреждаю честно: это аддиктивно. Мне хватило одного раза, чтобы залипнуть на полгода. Теперь ваша очередь.
Пощупать проект руками можно здесь: https://bottleship.happydog.games
Покопаться в коде можно тут: https://github.com/jenissimo/bottleship
Следить за новостями можно в моем Telegram канале: https://t.me/ai_madness_diary
P. S. И теперь, когда релиз наконец‑то состоялся, можно с чистой совестью пойти поиграть в Гарри Поттера
Ты волшебник, Гарри.
Автор: jenissimo
