Каждый год на новогодних каникулах у меня просыпается ностальгия: хочется перепройти что‑то из детства, типа «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: «Плоть слаба!»

Хорошие новости, ребята! Я придумал, как запускать вас без Windows
Хорошие новости, ребята! Я придумал, как запускать вас без Windows

Таким образом у нас есть три задачи:

  • Выбрать «голову» — нашу 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.

В далеком 2011 году мы с друзьями делали браузерную 3D гоночку на Flash, вот и моделька пригодилась :)
В далеком 2011 году мы с друзьями делали браузерную 3D гоночку на Flash, вот и моделька пригодилась :)

Тогда я наивно подумал: «Ну, раз 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, но и правильно переставить каналы.

Отладочное side-by-side сравнение демки, отрисовывающей модель и UI текстуры в BottleShip и Windows
Отладочное side‑by‑side сравнение демки, отрисовывающей модель и UI текстуры в BottleShip и Windows

Довольно быстро выяснилось, что неправильный формат текстур — далеко не главная проблема. 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

Забавно, что несмотря на все описанные проблемы, рендеринг системного шрифта в атлас на загрузке заработал с первой попытки. А ведь игра через GDI рисует его на загрузке в атлас и потом рисует из него
Сколько воспоминаний в этом кадре. Иронично, что при всех последующих мучениях с UE1 системный шрифт в меню заработал сразу: движок собирает его через GDI в атлас и потом использует как текстуру.

После 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.

~12-летний я в 2002 году для фанфика по Гарри Поттеру https://honeyduke.com/hp/articles/0181.shtml
~12-летний я в 2002 году для фанфика по Гарри Поттеру https://honeyduke.com/hp/articles/0181.shtml

Эпилог. Полка с банками

Ты в браузере, Макс. Судя по текстурам - это снова кошмар.
Ты в браузере, Макс. Судя по текстурам — это снова кошмар.

За эти полгода я запустил еще много классных игр: Ядерный Титбит, 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. И теперь, когда релиз наконец‑то состоялся, можно с чистой совестью пойти поиграть в Гарри Поттера

Ты волшебник, Гарри.