11 октября в 10:21 я сделал первый коммит в новом репозитории, diablo-2-recompilation. В 20:39 — сто тридцать четвёртый. За это время репозиторий узнал, как устроен генератор случайных чисел Diablo II, как одно число из командной строки превращается во все карты, комнаты и монстров игры и как умирающий монстр решает, что из него выпадет. Ещё он научился читать почти все форматы файлов, которые есть в игре, и отрисовал из них три миллиона картинок — куда больше, чем я просил.

Это дневник разработчика за тот день. Первый день того, на что уйдёт ещё очень много дней.

Сначала — почему именно эта игра. В Diablo II я провёл сотни часов, и это одна из моих самых любимых игр. Моя сестра была директором местной радиостанции, и каждый день после школы я шёл туда и до позднего вечера один играл в Diablo. Я до сих пор считаю, что по предметам, по тому, как растёт сложность монстров, по реиграбельности, вариативности и, главное, по балансу с ней никто так и не сравнялся. Поэтому очень интересно наконец понять, как именно всё это работает.

Что это за проект

Diablo II: Lord of Destruction, версия 1.14d, последний классический релиз для ПК. Цель — описать игру так точно, чтобы по описанию её можно было собрать заново и получить ту же самую игру: тот же предмет из того же монстра, то же подземелье из того же сида. Примерно, как работает дроп, сообщество расписало много лет назад. Этому проекту нужен точный порядок вызовов генератора, точная целочисленная арифметика и точные обращения к таблицам.

Начинаем с трёх областей, потому что именно они в игре держатся на случайности:

  1. Создание предметов. Treasure classes, броски качества, выбор префиксов и суффиксов, уникальные и сетовые вещи, сокеты.
  2. Появление монстров. Какие монстры могут быть на уровне, стаи и миньоны, чемпионы и уникальные монстры.
  3. Генерация уровней. Игра собирает карты из готовых кусков, лабиринтов и генераторов открытой местности, и каждый из них берёт случайные числа.

Главное решение записано в README жирным: на выходе документация, а не код. В git не попадают ни файлы игры, ни декомпилированный код. В репозитории лежат спецификации в Markdown, инструменты для чтения игровых файлов и список именованных адресов исполняемого файла. Кому нужны данные игры, тому нужна своя легально купленная копия.

Спецификации при этом пишутся в расчёте на агентов. Я хочу, чтобы будущая сессия могла взять docs/specs/rng/ и реализовать по ней генератор, ни разу не открыв бинарник. От этого зависит, как они написаны: каждое предложение о том, что игра что-то делает, должно говорить, откуда мы это знаем.

Откуда мы знаем

Это правило появилось в первом же коммите, до всякого реверс-инжиниринга, и на нём построено всё, что было дальше.

Бинарник ровно один. Game.exe 1.14d, его SHA-256 записан в README. В 1.14d Blizzard влила все старые DLL в исполняемый файл, так что вся игра — это один файл, и каждый адрес в каждом документе относится к нему, база образа 0x400000.

Первой настоящей работой было доказать, что файл оригинальный. Тут повезло: у исполняемого файла есть подпись Authenticode. Небольшой скрипт на Python без зависимостей проходит все пять шагов проверки: пересчитывает хеш файла, проверяет цепочку подписи до DigiCert и сверяет контрольную сумму PE. Вердикт в логе: «VERDICT: UNMODIFIED since signing». Почему этого достаточно, объясняет одна строка из описи: «Любое изменение байта вне таблицы сертификатов сломало бы шаг 1, значит, ничто в установке этот бинарник не патчило».

У архивов с данными игры, MPQ, подписи нет вообще. Поэтому доказательством стало сравнение: каждый файл сверили с версией 1.14b. В d2data совпали 10 811 файлов, в d2exp — 9 767, в patch_d2 — 156. Отличается один файл, data/local/use.

Дальше теги. У каждого утверждения о поведении игры есть якорь — адрес в бинарнике, столбец таблицы данных или номер эксперимента — и один из трёх тегов уверенности:

ТегЧто значит
[C]подтверждено: мы прочитали код и его подтверждает что-то независимое: второй путь в коде, данные, запуск настоящей игры или точное воспроизведение
[I]выведено: код прочитан, независимого подтверждения пока нет
[L]зацепка: документация сообщества или догадка без якоря. В спецификации допустима только в разделе Open questions

То есть прочитать код для [C] мало. С ним должно согласиться что-то за пределами декомпилятора. К концу дня в находках было 48 [C] против 175 [I], и для первого дня это честное соотношение: большую часть прочитанного ещё не проверили.

Последнее правило — то, у которого есть юридический вес. Документацию сообщества — именно документацию, не код — можно использовать как зацепку, и каждая зацепка записывается в docs/sources.md. Чужой декомпилированный или восстановленный код игры мы не читаем, не копируем и не пересказываем. Есть проекты, которые восстановили исходники игры, — этот проект в них не заглядывает. Это правило ещё всплывёт ниже.

Утро

Утро шло медленно, и в git это видно: два коммита в десять, ни одного в одиннадцать, два в двенадцать. За это время распаковали установку, посчитали хеши всех 2 114 файлов и занесли их в опись, проверили подпись и поставили инструменты: Ghidra, отладчик, библиотеку для архивов MPQ и wine.

В 10:32 из Ritmolux, моего музыкального визуализатора, почти без изменений переехали хуки, которые следят за чистотой коммитов. Они запрещают git add -A и похожие команды, всё из workspace/ и всё с расширениями игровых файлов, любой push и переписывание истории. Хук на игровые данные здесь важнее, чем был в Ritmolux. Один неосторожный git add . — и игра опубликована, а с хуком такую ошибку случайно не сделаешь.

К 13:00 двенадцать эталонных MPQ были выгружены и слиты в одно дерево: 30 488 файлов, 968 МБ. У MPQ есть порядок приоритета: файл из архива с патчем перекрывает такой же файл из базовой игры. Так перекрыты 394 файла, у 317 из них другое содержимое, а 77 — побайтно те же файлы, просто выпущенные ещё раз.

Одна мелочь с этого шага. Утилита, которая достаёт файлы из MPQ по списку имён, понимает только обратные слэши, а с прямыми она «молча находит 0 записей». Ни ошибки, ни файлов. Вот поэтому каждый шаг в этом проекте обязан печатать количество, а количества потом сверяются.

В 13:25 появился воспроизводимый проект Ghidra: один скрипт собирает его из закреплённого бинарника, другой заново накатывает все имена, которые мы дали функциям, из CSV-файла. Сам проект Ghidra можно выбросить в любой момент. Единственный источник истины по именованным адресам — CSV, re/symbols.csv. К концу дня в нём было 278 строк.

Генератор случайных чисел

Через десять минут, в 13:35, — первая находка: генератор случайных чисел. На нём стоит всё остальное в проекте, поэтому с него и начали.

Сид в Diablo II занимает 64 бита: два 32-битных слова, low и high. Один шаг выглядит так:

uint64_t t = (uint64_t)low * 0x6AC690C5 + high;
low  = (uint32_t)t;
high = (uint32_t)(t >> 32);

Это было известно и раньше. Формула лежала в наших собственных заметках как гипотеза, и для 1.14d она подтвердилась. Двух вещей в заметках не было, обе нашлись в коде.

Первая: когда игра создаёт сид из числа v, она ставит low = v и high = 666. Всегда 666. Есть и второй инициализатор, он ставит low = 1 и high = 666. Так что каждый сид в игре начинается со старшего слова 666. Очень интересно и прямо в духе игры.

Вторая: функция диапазона, «дай число меньше n», возвращает 0 и не делает шаг, если n меньше единицы. Если реимплементация всё-таки сделает шаг, все следующие случайные числа будут другими, и дальше игра пойдёт совсем в другую сторону.

Дальше неожиданное. Функций генератора в бинарнике всего шесть: шаг, две по-разному скомпилированные копии одной и той же функции диапазона, диапазон для степени двойки и два инициализатора. Но почти везде компилятор встроил генератор прямо в код. Множитель 0x6AC690C5 встречается в коде 852 раза, и каждое такое место — это место, где игра берёт случайное число.

Поиск констант в Ghidra сначала нашёл 841. Остальные одиннадцать сидели в коде, который Ghidra вообще не дизассемблировала: девять callback-функций, которые выбирают переходы между уровнями. На них ссылаются только указатели из таблицы данных, напрямую их никто не вызывает. Поиск по сырым байтам нашёл все 852, а когда эти функции определили, каждое совпадение оказалось инструкцией внутри функции. Так что их 852, а не 841. А в спецификации теперь сказано, что встроенная копия генератора может обходиться без проверки n < 1, если n — константа, и что для каждого места вызова нужно указывать, какой из вариантов там стоит.

Как проверить генератор случайных чисел, не запуская игру? Запустить код самой игры. Один из скриптов Ghidra выполняет функцию из бинарника в эмуляторе p-code, то есть настоящий машинный код RNG_Range работает на входах, которые выбрали мы. Эталонную модель на Python, 305 строк на одной стандартной библиотеке, сравнили с ним на 5 000 случаев для каждой функции, из них 112 граничных. Все шесть функций совпали 5 000 из 5 000. Это и есть [C]: код прочитан, и сам этот код согласен с нашей моделью на выбранных нами входах.

Если запустить игру без -seed, сид получается из GetTickCount() плюс time(): их прогоняют через три раунда другого, гораздо более старого генератора, x * 0x19660D + 0x3C6EF35F, и обрезают до 31 бита. Одно следствие записано в спецификации: «сид 0 задать нельзя: запуск с -seed 0 идёт по пути через часы».

Дерево сидов

Генератор — это только половина дела. Вторая половина — из какого сида берётся каждое число. У партии в Diablo II не один сид, а сотни: у игры, у каждого акта, каждого уровня, каждой комнаты, каждого монстра. И все они получаются друг из друга.

Правило оказалось простым. Дочерний сид создаётся как init(step(parent).low): родителя один раз продвигают, берут младшее слово и инициализируют из него новый сид. Значит, каждый дочерний сид обходится родителю ровно в одно число. Сверху вниз:

-seed N
 ├─ сид карты = N, сид игры = {N, 666}
 ├─ сид акта = init(сид карты), один шаг → сохраняется, одинаковый для всех актов
 │   └─ сид уровня = init(сохранённое значение акта + id уровня)
 │       └─ сид комнаты = step(сид уровня)
 │           └─ сид активной комнаты: заново init, потом k чисел, потом ещё одно
 └─ серверные юниты (монстры, предметы, NPC) берут числа из сида игры

Две детали отсюда.

Сид уровня — это сумма, а не очередное число из генератора: значение акта плюс id уровня. Поэтому неважно, в каком порядке создаются уровни: у уровня 17 всегда один и тот же сид. А раз сумма 32-битная, она переполняется: с сидом 34844169 значение акта равно 0xFFFFFF87, и уровень 134 получает сид 0xD. Этот сид попал в список экспериментов специально.

Каждый акт существует дважды, серверная копия и клиентская, и обе строятся из одного сида. Клиентская — из сида карты, который сервер присылает клиенту в сообщении. Diablo II — клиент-серверная игра даже в одиночном режиме.

Из сида второго акта потом берутся ещё два числа: они выбирают две разные гробницы Тал Раши из семи (уровни с 66 по 72). С сидом 12345 это Tomb 7 и Tomb 6. Какая из них настоящая и зачем нужен второй выбор — пока открытый вопрос.

Запуск настоящей игры

Чтение кода даёт [I]. Чтобы дерево сидов стало [C], нужна была живая игра и отладчик, который следит за сидами.

На это ушла вторая половина дня. Игра работает под wine. У меня ptrace_scope равен 1, то есть процесс может отлаживать только своих потомков. Поэтому attach через winedbg упал с ошибкой 5, а запуск через winedbg завис. Понижать ptrace_scope не стали: это ослабляет защиту всей машины. Логирующую DLL, внедрённую в игру, оставили на потом. Что сработало, записано в ADR-0003 (ADR — короткий документ о том, что решили и почему): небольшой лаунчер запускает wine game.exe -seed N на скрытом дисплее Xwayland, а потом сам превращается в gdb, оставаясь тем же процессом. Для ядра отладчик оказывается предком игры, и подключиться ему разрешено. Ещё один скрипт прокликивает меню через XTEST, так что игру никому трогать не нужно.

В ADR есть и такая фраза: «При первом тесте случайно создали персонажа». Первая версия не использовала скрытый дисплей, и окно игры поймало случайный ввод на моём рабочем столе. Я видел это своими глазами, и это было как магия.

В 16:00 дерево сидов подтвердилось на живой игре, а к 16:33 спецификацию переписали по результатам запусков. Восемь запусков: сид 12345 дважды, 777, 12345 со старта во втором, третьем, четвёртом и пятом актах и 34844169 в пятом акте — ради переполнения. Каждый сид, который видел отладчик, сравнили с предсказанием модели: 3 353 из 3 353. Вторая серия добавила сиды серверных и клиентских юнитов и путь через часы, и общая проверка сошлась на 5 954 из 5 954. Два запуска с сидом 12345 совпали строка в строку.

Заодно запуски исправили ошибку. Сначала считалось, что юниты берут числа из сида игры. Потом, после чтения кода, — что из сида своей комнаты. Теперь в находке написано: «Оба раза были правы наполовину». Серверные юниты берут числа из сида игры, клиентские — из комнаты. Это видно, только если смотреть на обе стороны, а обе стороны сразу показывает только запуск.

Одна дыра здесь всё же остаётся. Между созданием комнаты и её активацией сид комнаты продвигается сколько-то раз — это k в дереве выше. В запусках он был от 50 до 376. Вечером выяснили, из чего он складывается (подстановки в комнатах на открытой местности плюс броски редкости для каждого тайла), но это пока только чтение кода, всё [I]. Точно воспроизвести k — следующий большой кусок генерации уровней.

Таблицы данных

Правила игры живут в таблицах. Их 97, с названиями вроде TreasureClassEx, MonStats, Levels, UniqueItems. Моддеры знают их как .txt-файлы, таблицы с табуляцией. Для большинства из них в игре лежит и .txt, и скомпилированный .bin.

Отсюда первый вопрос: что игра на самом деле загружает? Ответ — .bin. В бинарнике есть глобальный флаг «загружать бинарные таблицы», он инициализирован единицей, и никто никогда в него не пишет: тридцать обращений, и все — cmp. Так что .txt в архивах лежат для моддеров, а сама игра их не читает.

Это определило работу. К 15:05 был готов декодер формата .bin и 24 раскладки записей, три из них частичные. Каждая раскодированная ячейка сверяется с .txt: в LvlPrest 25 093 ячейки и ноль расхождений, в UniqueItems 7 236, тоже ноль. Во всех таблицах вместе одно расхождение, и оно объяснено, необъяснённых нет.

Заметки о формате из этой части читаются как список ловушек для тех, кто работает с .txt. «Заголовок с опечаткой молча игнорируется». «Поэтому ячейка с одним пробелом компилируется в −16, которое хранится как 0xF0 в u8». В 30 таблицах в .txt есть служебная строка «Expansion», а в .bin её нет. И из формата сохранений: «Рунное слово Steel хранится под id 159, но в Runes.txt это строка 18». Если собирать игру по текстовым файлам, получится немного другая игра, и никто этого не заметит.

Гонка форматов

В 14:19 закоммитили четыре новых плана, и я одобрил их все одной фразой: «approve all plans». Ими занимались весь остаток дня. Один был про форматы файлов, без которых не обойтись генератору уровней и коду предметов: DS1 (готовые карты), DT1 (тайлы), строковые таблицы .tbl и D2S, файл сохранения.

Позже я попросил простыми словами, без планов, рассказать, как идут дела: сколько процентов сделано и получится ли вытащить картинки и тайлы. Ответ на второй вопрос стал ADR-0005 в 18:12. Форматы отображения, спрайты и анимации, в спецификацию не входят: она про логику игры. Но их можно делать только как инструменты — парсеры и рендереры, а картинки складывать в рабочую папку, которая в git не попадает. Главная фраза ADR: «отрисованная картинка, которая выглядит правильно, … доказательством не является». Тайл, который выглядит как надо, ничего не говорит о том, правильно ли поняты байты.

Доказательством вместо этого служит побайтовый учёт: каждый байт каждого файла объяснён, ничего лишнего не осталось. Итоги к 20:00:

ФорматФайлыРезультат
DS1, готовые карты2 273 из 2 276разобраны до последнего байта; оставшиеся 3 битые и не используются
DT1, тайлы257 из 26316 456 тайлов, все байты учтены; 6 старых v4.1 не используются
DC6, спрайты1 68124 532 кадра, 0 ошибок, 0 лишних байтов
DCC, анимации21 7173 305 132 кадра, 0 лишних битов
D2S, сохранения23228 предметов, 0 лишних битов, контрольная сумма совпала в 23 из 23

А потом отрисовали всё: 3 348 445 PNG, 15 ГБ в рабочей папке. Код, который пишет PNG, — 44 строки на Python со стандартной библиотекой. Это и есть три миллиона картинок из первого абзаца. Я спрашивал про тайлы, а получил каждую анимацию во всех направлениях.

Побайтовый учёт ловил настоящие ошибки. Первый парсер DC6 доверял полю «следующий блок» в заголовке и падал на 281 файле. В документе было написано, что в тайле 256 пикселей, а на самом деле сумма давала 512. Синие полосы на отрисованных картах с рекой оказались скрытыми клетками коллизий с выставленным битом 31. В одной находке говорилось, что персонажей классической версии нельзя собрать из частей, и это тоже было неправдой: их графика лежала в архиве, который ещё не успели слить.

Для декодера DCC сделали ещё и мутационную проверку. Инструмент нарочно ломает в декодере одно правило за раз и снова прогоняет весь корпус. Семь из десяти сломанных правил валят от 78 456 до 119 437 направлений из 119 448 в тестовом наборе, так что эти семь данные действительно закрепляют. Оставшиеся три не валят ни одного: неверный вариант раскодируется так же чисто. Выбрать между ними по байтам нельзя, поэтому эти три остаются [L], и в документе так и написано.

Проверка сохранения в игре

Формат сохранений подтвердила сама игра. Сохранение, в котором сила поменяна с 30 на 45 и пересчитана контрольная сумма, загружается. Меняем один байт без пересчёта — игра файл не принимает. Значит, алгоритм контрольной суммы (циклический сдвиг влево на один и сложение) верный.

Потом вейпоинты. В сохранении выставили биты вейпоинтов 0, 2, 6 и 8, харнесс загрузил его и открыл меню вейпоинта в лагере. Доступными в меню оказались ровно Stony Field, Jail Level 1 и Catacombs Level 2. После перехода на автокарте было написано «Catacombs Level 2». Теперь так можно проверять генератор уровней дальше первого города: правим сохранение, чтобы у персонажа был нужный вейпоинт, запускаем игру и переходим туда.

Агент, который вспомнил

Лучшая история дня случилась в 19:11, и из-за неё появился ADR-0007.

DCC — формат анимаций, битовый поток с несколькими сжатыми потоками внутри. Агент сдал рабочий декодер: все файлы, все кадры, ноль лишних битов. Но в отчёте была одна строка о том, откуда взялось знание формата: агент работал по памяти — по тому, что помнил из описания известного автора из сообщества. Это описание и было указано в docs/sources.md как источник.

На рецензии источник открыли. Указанный PDF оказался руководством пользователя, о битовом потоке DCC там ничего нет. Указанный URL вёл на страницу скачивания исходников на C. Второй документ, на который ссылается сам этот PDF, не нашёлся ни за три поиска, ни за две загрузки, а в одной сводке поиска было сказано, что к нему прилагался пример декодера — ровно то, что проекту читать нельзя. Вердикт архитектора: «подозрительное происхождение». Собственная правка агента начинается словами: «Оба неверны».

Получается, агент собрал правильный декодер из знаний, которые не смог привязать ни к одному документу. Он «вспомнил» формат. Память не подвела, корпус это доказывает. Откуда она — неизвестно. Это могло быть текстовое описание, которое можно, или один из существующих декодеров этого формата на C, который нельзя. Сказать не может никто, включая саму модель. Декодер оставили, потому что байты подтверждают большую его часть. Убрали его заявление о том, что у него есть источник.

ADR-0007 говорит прямо: «Наши дорожки — языковые модели, и они могут „вспомнить“ формат из обучающих данных, не зная, откуда это воспоминание». И: «Теперь это случилось дважды». Второй случай — ссылка на документацию редактора уровней, которую тоже никто не открывал.

Теперь правило такое: зацепка считается, только если её загрузили в этой же сессии и это записано. Вспомненная зацепка — не зацепка. Дальше была перепроверка. По формату DS1 из 14 ссылок на документацию сообщества 11 оказались вспомненными, а не прочитанными, и их понизили до «зацепки нет; выведено из данных». Документ по DCC теперь не ссылается ни на один источник из сообщества. Большинство его правил закреплено байтами и мутационной проверкой. Четыре — нет: документа по ним нет, а данные выбрать не позволяют, поэтому они остаются [L], самым слабым тегом.

В других моих проектах агент, который помнит, как что-то устроено, просто полезен. Здесь это риск загрязнения, который по результату не виден. Декодер был правильный. Неправдой была только запись об источнике, и поймала её только рецензия, которая этот источник действительно открыла.

Treasure classes и спорное правило

Со стороны предметов дошли до сердцевины дропа: как игра раскрывает treasure class, когда умирает монстр. Treasure class — это взвешенный список в TreasureClassEx, где каждая запись либо предмет, либо другой treasure class, и раскрытие спускается по этому дереву, беря случайные числа. Каждое число берётся из собственного сида умирающего монстра, вперемешку с бросками качества.

Один настоящий пример из запусков, о которых ниже. В Cold Plains умер уникальный монстр, его treasure class — Act 1 Unique A. У этого класса Picks = -3, а отрицательное число значит «без случайного выбора, просто пройти записи по порядку», поэтому три броска игра сделала, не взяв ни одного числа. Броски спустились через Act 1 Uitem A, где лежат коэффициенты, из-за которых вещи с уникальных монстров чаще бывают магическими и лучше, потом через Act 1 Equip A, потом в weap3, группу оружия по уровню, и ещё в класс с зельями. Из одной смерти вышло пять предметов. Один такой проход, от мёртвого монстра до списка предметов, ниже я называю вызовом раскрытия.

Ещё есть NoDrop, вес варианта «ничего не выпало», который уменьшается, когда игроков больше. Формулу сообщество знает давно. Интересно, как считаются игроки. Оба источника сообщества, указанные для этого правила, говорят, что члены группы учитываются, только если они в пределах «двух экранов» от монстра. В коде 1.14d никакой проверки расстояния нет. Там сравниваются id уровней: член группы учитывается, если он на том же уровне, что и убийца. Если код делает то, что в нём написано, то игрок на другом конце того же уровня учитывается, а игрок, который стоит рядом с вами, но по ту сторону границы уровня, — нет.

Утверждение сообщества в находке теперь помечено как противоречащее коду, и этот случай обязателен для фазы с запуском игры. Чтение кода — это всё ещё только [I], и план требует увидеть это в игре, прежде чем записывать в спецификацию.

ADR-0004, от 18:05, — про границы всего этого. Посреди своих бросков код дропа вызывает бросок качества (обычный, магический, редкий, сетовый, уникальный), а это отдельная подсистема, которую мы ещё не описали. Оба берут числа из одного сида, так что дроп не воспроизвести, если не знать, сколько чисел забрал бросок качества. Поэтому логгер записывает сид прямо перед броском качества и сразу после него. Модель обязана точно предсказать «до», а дальше продолжает с записанного «после». Главное здесь — проверка: «Модель, которая только подхватывает записанные состояния и ничего не проверяет, ничего не доказывает».

Как агент научился играть в Diablo II

Чтобы сверить модель дропа с игрой, кто-то должен убивать монстров. Здесь этому научился сам агент. Эта фаза началась вечером в отдельной ветке, ветку пока не влили, так что в main этой части нет.

Начал он с того, что в харнессе уже было. Меню проходятся по файлу шагов: клики в координатах игрового окна 800×600 с комментариями вроде «пустой фон: первый клик только активирует окно», потом SINGLE PLAYER, потом OK на экране выбора персонажа. До вейпоинта в лагере ведёт другой файл шагов — в обход Варрива и костра, а в заголовке предупреждение: «NPC бродят, так что если на последнем скриншоте нет меню, сделай снимок, найди каменную площадку и кликни по ней вручную». «Вручную» тут значит, что агент смотрит на скриншот и отправляет один клик.

Первая сессия пошла самым прямолинейным путём. Скрипт вывел варвара первого уровня из Rogue Encampment на северо-восток, в Blood Moor. Дальше агент делал скриншоты, находил на них монстров и атаковал каждого одним кликом в его координаты. Ещё до этого первая попытка остановилась на самом первом дропе: скрипт отладчика читал 32-битный указатель игры как 64-битный. Вторая попытка сработала: 4 вызова раскрытия, все воспроизведены. Три из них — Fallen, из которых ничего не выпало.

Четырёх убийств слишком мало. Поэтому во второй сессии поменялись две вещи.

Во-первых, персонаж. Харнесс завершает игру через wineserver -k, поэтому игра ни разу не сохранялась, и файл персонажа был 335-байтной заготовкой. Агент один раз зашёл в игру без отладчика и вышел через Save and Exit Game, чтобы получить полноценное сохранение. Потом инструментом для сохранений из работы над форматами выставил силу, ловкость и живучесть в 1 000, а здоровье в 8 000, пересчитал контрольную сумму и сохранил копию оригинала. А в городе набрал /players 8, чтобы для NoDrop игра считала восемь игроков.

Во-вторых, охота. Агент написал себе цикл, который ищет монстров наведением курсора. Он водит курсор по сетке точек на скрытом дисплее и после каждого шага читает несколько пикселей вверху по центру экрана — там Diablo II показывает тёмно-красную полоску с именем монстра под курсором. Есть полоска — зажимает левую кнопку на 1,2 секунды. Нет — зажимает кнопку ходьбы и идёт дальше. Ни одного действия человека, никакого заранее написанного сценария боя. Повторить убийства клик в клик нельзя, но это и не нужно: каждый вызов раскрытия записан в лог вместе со своими входными данными.

Это дало ещё 10 вызовов. Для третьей и четвёртой сессий агент снова взялся за сохранение: включил все вейпоинты первого акта, дошёл до площадки (дважды мешали NPC, и он кликал по ней вручную) и перешёл. В третьей сессии агент охотился у вейпоинта Cold Plains около 18 минут и набрал 31 вызов, в том числе от уникального монстра. Иногда цикл упирался в стены. В четвёртой он ушёл на Black Marsh минут на 17 и набрал 52.

За четыре сессии: записано 97 вызовов, Python-модель воспроизвела все 97, в том числе от трёх уникальных монстров. Чемпиона ни там, ни там не нашлось, а для случая с группой в харнессе нужны два клиента, так что оба пункта пока открыты.

Я смотрел, как он играет, и это было безумие — что это вообще работает. Тот же харнесс, который днём подтвердил дерево сидов, к вечеру умел зайти в игру, сделать себе неубиваемого персонажа, пройти через вейпоинты и охотиться. Всё это собралось из кусков, которые уже были сделаны за день, — формат сохранений, проверка вейпоинтов, драйвер кликов.

Как была устроена работа

Работа поделена на четыре роли: архитектор планирует и рецензирует, архивист занимается файлами и таблицами, реверсер — Ghidra и отладчиком, а автору спецификаций в инструкции прямо написано «никогда не заполнять пробел правдоподобным поведением». Каждую работу рецензирует свежий агент, который её не делал. Все записанные за день вердикты — «принять с правками», и из 111 коммитов в main, не считая коммитов слияния, 21 — правки по рецензиям. 256 пикселей, которых оказалось 512, и вспомненный источник поймали именно так.

До 18:00 работа шла в основном по одной роли за раз, а между ролями передавал я. Потом я спросил, есть ли смысл подключить сюда дирижёра — программу, которая без присмотра выполняет мои планы в Ritmolux и в боте расходов (прошлый пост как раз про день с ним). Ответ был: пока нет. Дирижёр безопасен ровно настолько, насколько надёжна механическая проверка между его сессиями, а здесь «неверный [C], который влили в docs/specs/, не прочитав, попадёт в спецификацию для реимплементации». Ложное утверждение в Markdown-файле тесты не поймают.

Поэтому сначала построили проверку якорей — ADR-0006 и план 0007. Это скрипт на Python, который проверяет каждый якорь: адрес функции есть в symbols.csv с тем же именем и видом, источник записан, у номера эксперимента есть запись, ссылки на таблицы и форматы открываются, [L] в спецификации нет нигде, кроме открытых вопросов, а число тегов в заголовке документа совпадает с телом. Она запускается на каждом коммите, который трогает docs/ или re/, и работает 0,64 секунды. Когда прототип запустили впервые, все 322 якоря нашлись и ничего не было сломано, поэтому проверка сразу стала строгой, без поблажек для старого. Правдиво ли утверждение, она судить не может, только есть ли у него доказательство, и сама об этом говорит.

А в 18:32 я написал: «create worktrees and orchestrate, we have a lot of tokens and window resets in 28 min, lets do as much as we can» — заводи worktree и дирижируй, токенов много, окно лимита сбросится через 28 минут, давай сделаем сколько успеем. По графику коммитов это видно: 37 коммитов в шесть вечера, 47 в семь и 15 веток, влитых с 18:53 до 20:39. До этого самый загруженный час дал 10. Сколько всего может идти одновременно, определяли вещи, которые есть в единственном экземпляре: харнесс для игры, проект Ghidra, общие файлы. Проверка якорей при коммите читает всё рабочее дерево, поэтому однажды недописанная ссылка одного агента заблокировала коммит другого. Решение записано в заметках памяти: в каждый worktree пишет только один агент.

Что на самом деле готово

В карте исследования 28 строк. К концу первого дня у трёх из них есть отрецензированные спецификации: ядро генератора, дерево сидов и таблицы данных. По treasure classes идут находки, у генерации уровней есть одобренный план, а 24 строки не начаты. Качество предметов, аффиксы, уникальные вещи, наборы монстров для уровней, стаи, генератор лабиринтов — всё это впереди.

README самого репозитория уже устарел. В его списке статуса четыре пункта не отмечены, хотя все четыре были сделаны к 13:54. В таблице инструментов их 5, а в папке 26 скриптов на Python.

Что дальше, уже записано. Фазу с дропом вольют и доделают случаи с чемпионом и группой. Потом k сида комнаты, то есть воспроизведение комнат уровня по сиду. Тогда проект впервые соберёт кусок карты Diablo II только по спецификации и одному числу.

Очень жду, что мы найдём дальше.