Первый день закончился 11 октября в 20:39 на 134-м коммите. С тех пор их стало на 206 больше: 13 в тот же вечер, 149 двенадцатого октября и 44 тринадцатого. Последний пришёл в 19:46, пока я писал этот текст. День ещё не закончился, так что пост доходит только до этого коммита.

Первый день ушёл на фундамент: генератор случайных чисел, дерево сидов, таблицы данных и форматы файлов. Во второй и третий на этом фундаменте строили. Теперь по репозиторию можно понять, откуда комнаты уровня берут случайные числа, какие монстры могут быть на уровне и почему, считается ли для дропа ваш друг на другом конце карты и как на самом деле работает magic find. Несколько правил сообщества для 1.14d оказались неверными, но в одном случае ошиблись мы, а сообщество было право.

И случилось то, чего я не ожидал. В первый день агент научился играть. Во второй и третий немало играл уже я сам, потому что там, где это было важно, у агента не получалось.

Вечер первого дня

Ветка с дропом, на которой закончился прошлый пост, в 20:39 была ещё не влита. До слияния в 22:12 в ней прошла ещё одна сессия.

Четыре сессии дали 97 вызовов раскрытия, и все воспроизвелись. Напомню: вызов раскрытия — это один проход игры от убитого монстра до списка выпавших предметов. «Воспроизвёлся» значит, что наша модель на Python с теми же входными данными взяла те же числа и получила те же предметы.

Пятая сессия пошла дальше. Агент взял сильного варвара из первого дня, поднял его инструментом сохранений до 70-го уровня, повесил War Cry на правую кнопку мыши и поправил в сохранении байт прогресса, чтобы игра запускала его на Nightmare с титулом «Slayer». Потом он охотился тем же циклом с наведением курсора, только теперь с War Cry и с автоматическим перезапуском после смерти. Вейпоинт в лагере на Nightmare агент нашёл «по двум синим огням».

Девятнадцатый запуск этой сессии вёл не агент. В записи эксперимента сказано: «владелец играл на дисплее харнесса, пока gdb писал лог». Владелец — это я. Моя охота дала 113 вызовов, и воспроизвелись они так же, как агентские. Итого по ветке: 328 вызовов раскрытия, воспроизведены все 328.

Ещё одна мелочь из этого запуска. В правилах дропа есть ветка noRatio, и до неё ничего не доходило. Сессия выяснила почему: все 31 класс монстров с noRatio — существа, которых призывает игрок, и treasure class нет ни у одного из них. На стандартных таблицах эта ветка не выполняется никогда.

В 22:13 появились четыре новых плана, в 22:21 я их одобрил, а в 23:17 пришли последние коммиты дня: модель и логгер для k у сида комнаты.

Число k у сида комнаты

Это была дыра, оставшаяся с первого дня. Между созданием комнаты и её активацией сид комнаты продвигается сколько-то раз. В запусках первого дня выходило от 50 до 376, и мы примерно понимали, откуда эти взятия, но только по коду.

Теперь k воспроизводится. В эксперименте было шесть входов в игру: сиды 12345 и 777 в городе первого акта, Blood Moor на сиде 12345, город второго акта и два прохода по лабиринтам на сиде 1 — Den of Evil и Catacombs со второго по четвёртый уровень, куда добирались через вейпоинт. Лабиринты проходили вручную, частично играл я. Результат: предсказаны все 520 активаций комнат, 8 776 проверок, 0 расхождений, причём и для серверной, и для клиентской копии. Ревью потребовало не брать один кусок из лога, а смоделировать его. После этого проверок стало 11 208, расхождений по-прежнему ноль.

Разброс k оказался шире, чем в первый день: от 0 до 388. В городе второго акта комната берёт от 0 до 93 чисел, в Blood Moor — от 144 до 376.

Выяснилось, что k складывается из двух частей. Сначала в комнатах на открытой местности игра выбирает подстановки — небольшие пресеты из таблицы LvlSub, которые она накладывает поверх местности. Потом она выбирает тайлы. Если у тайла больше одного кандидата и сумма их редкостей больше нуля, игра берёт одно число и выбирает тайл по редкости. Если сумма нулевая, число не берётся. Всего за эксперимент 39 652 выбора взяли число, а ещё 14 154 обошлись без него, хотя кандидаты были. Статический анализ 657 функций показал, что числа берут только четыре из них, и все четыре — из сида комнаты.

Тут не подтвердилось несколько наших предположений. Одно из них касается границ комнат. В коде есть отдельный путь для тайлов с битом 2, которым помечены пограничные тайлы, и по коду он выглядел как редкий случай. В запусках бит 2 стоит на каждой границе комнаты, так что «редкий» путь оказался самым обычным. На этом пути игра заново выбирает пограничный тайл и берёт для этого число из сида соседней комнаты, а не своей. Таких повторных выборов в логах 354. Выходит, сид комнаты сдвигается и после её активации: из него берут числа соседи.

flowchart TB
create["комната создана"] --> sub["бросок: какие подстановки LvlSub применить"]
sub --> reseed["сид комнаты инициализирован заново"]
reseed --> ksub["числа на подстановки (только открытая местность)"]
ksub --> pick{"следующий тайл: сумма редкостей больше 0?"}
pick -->|да| draw["одно число из сида этой комнаты"]
pick -->|нет| nodraw["число не берётся"]
draw --> pick
nodraw --> pick
pick -->|все тайлы выбраны| act["комната активирована: взято k чисел"]
act -.->|позже| nb["сосед заново выбирает общий пограничный тайл: берёт число из сида этой комнаты"]

Чтение за концом файла

А теперь находка другого рода. Файл пресета Act1/Outdoors/Trees.ds1 — это подстановка номер 11. Игра читает его на 12 байт дальше конца файла, и в эти байты попадает то, что в этот момент лежит в куче. В 26 загрузках из 27 там были нули. Один раз — мусор.

В логе плана это сказано прямо: «так что k может зависеть от неинициализированной памяти». В той единственной загрузке с мусором ни одно взятие от мусорных байтов не зависело, так что все наблюдённые k всё равно воспроизвелись. Но что-то в спецификации написать надо, и там так: реимплементация заполняет эти байты нулями. Это [C] (подтверждено запусками), потому что нули воспроизводят каждое взятие, которое мы видели. То, что оригинал читает неинициализированную кучу, — [I] (выведено только из кода).

Значит, при другом содержимом кучи тот же сид в принципе мог бы дать другое k.

Как строится уровень

Когда с k разобрались, взялись за общее устройство генератора уровней. В одном эксперименте уровни создавались 149 раз, собирались 32 раза (4 лабиринта, 12 пресетов, 16 открытых зон), комнат было создано 1 858, экземпляров пресетов — 960. Итог: 14 545 проверок, 0 расхождений.

Главный сюрприз: сид уровня инициализируется дважды. DRLG_LevelCreate берёт его из base + id уровня, а потом LevelInit инициализирует его ещё раз тем же значением. Всё, что успели взять после первой инициализации, пропадает. В спецификации об этом есть предупреждение: «модель, которая продолжает с числа, взятого в PresetLevelCreate, отстаёт на одно число на каждом уровне из пресетов».

Потом разобрались, какой файл пресета загружает уровень. У записи пресета есть поле Files, и игра выбирает файл через Range(сид уровня, Files). Но Files — просто граница диапазона, с количеством имён файлов в записи оно совпадать не обязано. В 81 записи и не совпадает. А Range(1) всё равно берёт число, хотя вернуть может только 0. Все 960 экземпляров загрузили тот файл, который мы предсказали.

flowchart LR
base["base + id уровня"] --> c1["DRLG_LevelCreate: Init(base + id)"]
c1 --> lost["взятые здесь числа пропадают"]
base --> c2["LevelInit: снова Init(base + id)"]
c2 --> files["Range(сид уровня, Files): Files — граница, а не число файлов"]
files --> ds1["файл DS1, который загружает уровень"]
ds1 --> rooms["комнаты, у каждой своё k"]

В запусках с k сиды комнат брались из лога. Здесь всё впервые выводится из одного сида карты. Den of Evil — уровень 8. На сиде карты 12345 его сид уровня — Init(015B2E7F). Лабиринт использует три определения пресетов. Для первого Range(1) выбирает CaveNSpec.ds1. Для второго Range(2) возвращает 1 и выбирает CaveWPre2.ds1. Дальше одна комната этого уровня берёт ровно 15 чисел, все на выбор тайлов пола. Модель генератора называет файл, а модель комнат воспроизводит тайлы именно этого файла и никакого другого.

Первый день закончился надеждой собрать кусок карты Diablo II только по спецификации и одному числу. Вот этот кусок, пока в виде чисел. Карты, нарисованной по сиду, ещё нет.

Для проверки я сам прошёл Catacombs волшебницей 50-го уровня: 6 608 проверок, ноль расхождений. Сначала пришлось поправить проверяющий скрипт. Игра заново строит выгруженный уровень и повторяет те же взятия, поэтому счётчики поворота лабиринта сбрасываются при каждом LevelInit.

Юниты пресетов и «вопреки названиям»

На третий день дошли до юнитов в пресетах. Файлы DS1 расставляют монстров, NPC и объекты: всего 16 372 юнита, из них 2 102 монстра, 165 NPC и 14 105 объектов. Предметов нет.

Большинство юнитов ставится всегда. Для десяти id игра случайно решает, оставить ли юнит. Четыре класса монстров остаются в одном случае из трёх. А ещё есть три объекта с именами place_group25, place_group50 и place_group75. В находке сказано, что они остаются в 3/4, 1/2 и 1/4 случаев, «вопреки названиям». Чаще всех появляется «25».

Для проверки взяли 27 логов по пяти актам и трём сложностям и ещё по одной игре на акт, сыгранной мной. Воспроизведены 2 210 списков юнитов в комнатах пресетов, 123 714 проверок, 0 расхождений. Про Nightmare и Hell в находке: «ничем не отличаются».

Была и сверка с сообществом. В первый день, когда выясняли, из какого сида берут числа юниты, вышло «оба раза были правы наполовину». На этот раз с кодом сверили три правила сообщества, и ошибались обе стороны. Про Populate источники говорят, что в комнатах со значением 0 монстры не появляются. В 1.14d такие комнаты всё равно получают монстров из пресета, и в находке написано: «для 1.14d источники неверны». С Logicals и PopPad вышло наоборот. Сначала мы решили, что игра PopPad вообще не читает. На самом деле читает, и модель теперь воспроизводит по нему 6 443 позиции игрока. Это в находке тоже записано: «находка была неверной».

Монстры

В первый день до монстров не дошли. Теперь есть спецификация пула монстров уровня, уже прошедшая ревью, и находки о том, как монстры расставляются по комнатам.

Пул — набор классов монстров, которые могут быть на уровне. Пулы для всех 136 уровней строятся один раз, при создании игры, из первого числа сида игры (если игра запущена с -seed, а все наши были запущены так). Ни сид карты, ни сиды уровней тут не участвуют. Монстры Blood Moor известны ещё до того, как появилась хоть одна комната.

Игра выбирает min(NumMon, 13, кандидаты) классов без повторов. Если у уровня стоит rangedspawn, первый выбор перебрасывается до 20 раз, пока не попадётся класс с дальней атакой. Из 200 таких первых выборов в 42 ушли все 20 перебросов, а дальнобойный класс так и не нашёлся. Классы с isSpawn 0 выкидываются уже после выбора, но выбор на них уже потрачен. На Normal читаются столбцы mon, на Nightmare и Hell — nmon.

Десять игр на сидах с 1 по 5 во всех пяти актах: совпали 1 557 из 1 557. На Nightmare и Hell сначала попадали, принудительно меняя байт сложности в памяти: у агента не было прав подменить сохранение на сохранение для Nightmare. После ревью каждое утверждение, основанное на этих запусках, получило оговорку. Потом семь обычных игр с настоящими сохранениями для Nightmare и Hell дали 1 483 из 1 483, и регионы на сиде 1 совпали с принудительными один в один.

Нашёлся ответ и про уровень монстра. Это Level из MonStats, кроме Nightmare и Hell в дополнении: там он берётся из Levels.MonLvlEx. Классические столбцы MonLvl1..3 не читаются вообще. Разница между вариантами видна на 61 созданном монстре. Например, в Jail Level 1 два класса получают уровни 41 и 71 в дополнении против 40 и 70 в классике.

Спавн монстров в комнатах и мёртвая ветка

Как монстры попадают в комнаты, пока известно только из кода, всё [I]. Но и в коде нашлись сюрпризы.

Каждая зона спавна получает несколько попыток в зависимости от размера. Попытка проходит, если число из сида игры по модулю 100 000 не больше min(MonDen, 10000). Дальше бросок типа решает, будет это обычный монстр или особый, то есть уникальный или чемпион.

И тут в коде есть ветка для чемпионов, которая не может выполниться никогда. Бросок типа возвращает 0 для особого монстра, 1 или 2. Потом проход спавна превращает каждую 1 в 2, поэтому ветка для 1, та самая, что создала бы группу чемпионов, не срабатывает. В находке: «мёртвый код в 1.14d. Последнее число броска всё равно берётся, и его значение ни на что не влияет». Чемпионы в игре, конечно, есть. Станет особый монстр чемпионом или уникальным, решается позже, по сиду самого босса: Range(100) < 20, где 20 берётся из первой строки MonUMod.

flowchart TB
att["попытка спавна"] --> den{"сид игры mod 100000 не больше min(MonDen, 10000)?"}
den -->|нет| skip["монстра нет"]
den -->|да| type["бросок типа: MonUMin / MonUMax"]
type -->|возвращает 0| special["особый монстр"]
type -->|"возвращает 2, или 1 превращается в 2"| normal["обычная группа"]
type -.->|"возврат 1: недостижим"| dead["ветка чемпиона: мёртвый код"]
special --> boss{"сид босса: Range(100) меньше 20?"}
boss -->|да| champ["группа чемпионов"]
boss -->|нет| uniq["уникальный со свитой"]

Сверку с сообществом закоммитили сегодня в 19:28, а в 19:43 она прошла ревью с исправлениями. Сверяли с двумя источниками: с Data File Guide от самой Blizzard и с Arreat Summit. С правилом MonUMax про особых монстров код расходится в трёх местах. Бросок делается для каждой попытки, прошедшей проверку плотности, а не раз на комнату. Шанс — 6 из 100, а не 5: в коде стоит cmp ecx,5; jle, то есть «меньше или равно». И лимит сравнивается не с числом удачных бросков, а с текущим счётчиком особых монстров. В него входят и чемпионы из группы, и когда они умирают, счётчик уменьшается.

С sparsePopulate код делает обратное тому, что написано в документации. По документации это значение — шанс, что группа не появится, а в коде группа появляется с вероятностью (v+1)/100. На практике это ни на что не влияет: единственный монстр с таким значением, evilhut, не входит ни в один пул.

Если источники и код совпадают, правило от этого в [C] не переходит. В обоих разделах со сверкой это сказано одинаково: «Совпадение статус не повышает». Совпавшее правило сообщества всё равно ждёт проверки запусками.

Две копии игры

Правило про группу осталось открытым с первого дня. Оба источника сообщества говорят, что члены группы считаются для дропа, только если они в пределах «двух экранов» от монстра. Код сравнивает id уровней, никакого расстояния там нет. Чтобы проверить это на практике, нужны два игрока в одной игре.

Первая проблема: вторая копия игры не запускается. Функция по адресу 0x004F5610 вызывает FindWindowA, находит уже открытое окно и завершается с сообщением «Only one copy of Diablo II may run at a time». Решение записано в ADR-0008: второй, отдельный префикс wine на своём скрытом дисплее. Хост работает на дисплее :7 с подключённым gdb, второй клиент — на :8 без отладчика, и клиенты соединяются по TCP/IP через 127.0.0.1. Меню и приглашение в группу прокликиваются файлами шагов, как и всё остальное в харнессе. Серверная часть TCP/IP-игры работает внутри процесса хоста, поэтому каждый вызов раскрытия попадает в его лог, кто бы ни убил монстра.

flowchart LR
subgraph host["дисплей :7, wineprefix"]
  gdb["логгер gdb"] --- h["Game.exe, хост: внутри работает сервер"]
end
subgraph join["дисплей :8, wineprefix2"]
  j["Game.exe, второй клиент без отладчика"]
end
j -->|"TCP/IP, 127.0.0.1, порт 4000"| h
h --> log["в лог попадает каждый вызов раскрытия, кто бы ни убил"]

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

Потом запуски. Вторым клиентом управлял агент, а во второй сессии хостом играл я. Случаи из записи эксперимента:

СлучайВызововСчётчик группы (party)
в группе, второй клиент на том же уровне442
в группе, второй клиент на другом уровне841
не в группе, тот же уровень41

В первом случае второй клиент был на расстоянии от 26 до 375 субтайлов, и 30 вызовов пришлись на 311 и больше. Экран — примерно 37 субтайлов в ширину, так что самое большое расстояние — 9,6 экрана по горизонтали и 3 по вертикали. В находке: «живой игрок из группы на уровне убийцы засчитывается полностью (party = 2) на расстоянии 311–375 субтайлов, далеко за пределами „двух экранов“». Правило по id уровня стало [C], а правило сообщества опровергнуто, теперь уже и запусками.

В первом логгере была ошибка: он читал уровень игрока по адресу room+0x58 «без перехода через +0x10», и в первой сессии все позиции получились −1. Проверяющий скрипт это показал, и ошибку исправили раньше, чем дошли до случаев, где позиции важны.

Чемпион

Второй открытый вопрос первого дня — монстр-чемпион: ни в одной сессии ни одного не убили. Волшебница на Nightmare под управлением скрипта попробовала, умерла через 9 вызовов, и среди 175 вызовов не нашлось ни одного чемпиона.

У агента не получалось, и за дело взялся я. Волшебница на молниях, собранная инструментом сохранений и поднятая до 80-го уровня, одна на Nightmare. 124 вызова раскрытия, все воспроизведены, и два из них — чемпионы: оба fallenshaman4, класс 61. Чемпион берёт treasure class из столбца для чемпионов. Здесь это был 698, Act 1 (N) Champ C, а потом по уровню монстра 44 дроп поднялся через 699 до 700, Act 2 (N) Champ B. Уникальный монстр того же класса взял вместо этого 783. Андариэль взяла свой квестовый класс 834 без повышения.

Правило для чемпионов теперь [C], но только в тех рамках, в которых его проверили: Nightmare, дополнение, одиночная игра, класс 61.

Саму спецификацию treasure classes закрыли и провели через ревью утром двенадцатого. Ревью нашло в ней одну ошибку: там было написано, что бросок качества возвращает коды от 2 до 7, а в логах того же запуска восемь раз встретился код 1.

Бросок качества и magic find

Код 1 означает низкое качество. Бросок качества — момент, когда предмет становится обычным, магическим, редким, сетовым или уникальным. В первый день он был только оракулом: логгер записывал сид до и после, а наша модель просто брала результат из лога. В плане 0010 его разобрали по коду.

Бросок идёт в фиксированном порядке: уникальный, сетовый, редкий (пропускается, если тип предмета редким быть не может), магический, превосходный и, наконец, обычный или низкокачественный. Для каждой проверки игра считает шанс по уровню предмета (ilvl) и уровню качества (qlvl):

c = (base - (ilvl - qlvl) / divisor) * 128;
// затем делится на коэффициент magic find, ограничивается снизу и уменьшается на c * ratio / 1024
if (c < 1) win();                       // число не берётся вообще
else if (RNG_RangeB(seed, c) < 128) win();

То есть один бросок берёт от нуля до шести чисел, а шанс меньше 1 выигрывает без единого числа. Реимплементация, которая всё равно возьмёт число, собьётся с последовательности сида, как в случае n < 1 у генератора в первый день.

flowchart LR
start["каждая проверка: шанс меньше 1 выигрывает без числа"] --> u["уникальный?"]
u -->|нет| s["сетовый?"]
s -->|нет| r["редкий? (пропуск, если тип не бывает редким)"]
r -->|нет| m["магический?"]
m -->|нет| sup["превосходный?"]
sup -->|нет| nl["обычный или низкокачественный"]
u -->|да| out["качество выбрано"]
s -->|да| out
r -->|да| out
m -->|да| out
sup -->|да| out

Magic find для уникальных, сетовых и редких работает с убывающей отдачей: F = 100 + mf·C/(mf+C), где C равно 250 для уникальных, 500 для сетовых и 600 для редких. При MF 100 это даёт 171, 183 и 185. Тут всё сходится с сообществом. Расходится другое:

  • MF до 10 включительно линейный. Прежде чем применять кривую, код проверяет mf + 100 > 110. При MF 10 формула сообщества даёт для уникальных 9, а 1.14d использует 10.
  • В классике дроп идёт по строкам дополнения. Один источник сообщества говорит, что классические игры используют строки Version 0 из ItemRatio. Но код дропа подставляет константу 100, так что и в классике берутся строки дополнения.

И снова всё это проверено на моей игре. Две сессии волшебницей на молниях с MF 100 от Harlequin Crest и War Traveler, без смены снаряжения и без наёмника — это было моё условие, чтобы никто, кроме неё, не убивал: 782 из 782 и 65 из 65 бросков. Если не подсказывать модели записанное значение и перебрать MF от −150 до 400, подходит только 100. Линейная модель меняет 34 броска. В одном из них предмет, который в игре выпал уникальным, получается низкокачественным.

Потом ещё два персонажа, собранные из файлов-рецептов специально для этого. Некромант с MF 10 от одного кольца, Wisp Projector, 954 убийства на Nightmare, играл я скелетами и Corpse Explosion: 574 из 574 бросков, и подходит только MF 10. 85 бросков пришлись на убийства его скелетов, и если убрать его MF, ломаются 27 из них. Значит, призванное существо использует magic find хозяина. И классическая волшебница, 459 убийств на Normal: все 52 броска, дошедшие до проверки, использовали строки дополнения. Строки Version 0 ломают 29 из них, включая оба классических уникальных предмета.

Всего воспроизведено 1 916 бросков.

Снова строка Expansion

В посте о первом дне я упоминал служебную строку «Expansion»: она есть в 30 таблицах .txt и выбрасывается из скомпилированного .bin. Сегодня она вернулась.

Первые убийства суперуникальных монстров пятого акта в этих сессиях — Siege Boss, Megaflow Rectifier, Threash Socket и Sharp Tooth Sayer — не сошлись: 18 вызовов раскрытия не воспроизвелись. Причина была в нашем собственном инструменте. В superuniques.txt есть служебная строка Expansion, которой не соответствует ни одна запись, а tc.py её посчитал. В итоге «каждый id суперуникального монстра от 42 читался на строку позже». В заметках о формате уже было написано, что этой строке запись не соответствует. Просто tc.py им не следовал.

Сохранения, графика и мелочи

У формата сохранений появился код. Запись, чтение и контрольная сумма (rotl32(sum, 1) + byte) привязаны к адресам в бинарнике, а семь запусков в харнессе дали 1 625 сравнений без расхождений. Save and Exit пишет файл дважды, побайтно одинаково. Неверная контрольная сумма и неверный размер дают при чтении разные коды, но заканчиваются одной и той же ошибкой. Новый сборщик сохранений перекодирует 21 образец байт в байт, и именно им собраны волшебница на молниях, некромант и классическая волшебница.

Разобрались и с квестовыми словами. В блоке Woo! по секции на каждую сложность. Бит 0 в четырёх словах отмечает переход во второй–пятый акты. Сервер проверяет только бит вейпоинта. Отсюда появилась команда d2s build acts_open, которая делает сохранение со всеми открытыми актами.

Графика предметов из первого дня превратилась в инструмент: индекс из 1 188 изображений, 1 175 отрисованных листов, и у 13 предметов графики нет вообще. Файлы цветовых сдвигов оказались восемью файлами по 21 таблице. Три таблицы, brown, gold и grey, побайтно одинаковые. А в коде видно, что «gold и brown никогда не используются». До пикселя доходит только grey.

Компилятор .txt-таблиц описан до последнего преобразователя ячеек: 28 таблиц, 555 659 ячеек, ни одной необъяснённой. Имена столбцов сравниваются без учёта регистра, а если заголовок повторяется, используется первый. Проверки входных данных нет никакой: у частей Халима в TMogMin стоит один пробел, и хранится он как −16.

Диаграммы

Двенадцатого в 14:06 я попросил добавить в спецификации больше диаграмм: когда я вижу картинку, мне проще понять. В соглашения это вошло как правило «Рисовать диаграмму всякий раз, когда у раздела есть структура», и за один проход добавилось 76 диаграмм Mermaid, ни один тег при этом не поменялся. В спецификации treasure classes вместо одной диаграммы стало 13, в спецификации сохранений — 14. Всего в документации в первый день было 14 диаграмм, а теперь их 100.

Для них в проверку на коммит добавили правило E9. Оно разбирает каждый блок Mermaid той версией Mermaid, что уже встроена в VS Code, с патчем, чтобы она работала без браузера. Когда правило появилось, оно разбирало 89 блоков примерно за 0,9 секунды, и скачивать ничего не нужно.

Как шла работа

Дирижёра по-прежнему нет. В ADR-0006 передача планов дирижёру пока значится как «рассматривается», и решение так и не принято. Роли передают работу друг другу вручную, как и написано в CLAUDE.md: «следующую роль вызывает пользователь». Роли всё те же четыре, что и в первый день.

По истории git видно, как выглядели дни. Двенадцатого коммиты начинаются в 08:04 и заканчиваются в 15:58, и в каждом часе между ними от 8 до 24 коммитов. Тринадцатого они опять начинаются в 08:04, а с 10:22 до 13:42 нет ни одного.

За два дня влили 35 веток. Каждый вердикт ревью, записанный в коммите, — «принять» или «принять с исправлениями»: 14 с исправлениями, 5 без. 17 коммитов — исправления по ревью. Ревью раз за разом ловит одно и то же: утверждение, которое на шаг шире своих доказательств. Например, [C] на ветке кода для пограничных тайлов, до которой не дошёл ни один лог. Или значение флага в цитате, которое не совпало с сырой строкой лога. После этого случая появилась новая привычка: «сверять процитированные значения из лога с сырой строкой лога, а не только с итогами проверяющего скрипта».

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

По бэклогу видно, сколько хвостов оставляет каждая работа. В первый день в нём было 49 записей, из них 33 закрыты. Сейчас 143, закрыты 123, открыты 20.

Где проект сейчас

Цифры в сравнении с первым днём:

Первый деньСейчас
Спецификаций с ревью37
Не начато (из 28)2417
[C] / [I] в находках48 / 175240 / 455
Именованных адресов278626
Инструментов на Python2638
Закрытых планов514

Семь спецификаций, прошедших ревью: генератор случайных чисел, дерево сидов, таблицы данных, treasure classes, пул монстров, общее устройство генератора уровней и юниты пресетов. Формат сохранений остаётся черновиком, пока не готовы свойства предметов. Качество предметов, спавн монстров и строковые таблицы пока на стадии находок.

В находках доля подтверждённого выросла примерно с пятой части до трети. В спецификациях подтверждено уже больше половины.

Что сейчас открыто в репозитории. В находку о спавне монстров ещё нужно внести правки по ревью, а потом проверить её запусками, и это будет первое воспроизведение спавна монстра по сиду. Бросок качества прошёл ревью за три минуты до моей отсечки, тоже с исправлениями, а 183 из 485 классических вызовов пока останавливаются на «classic selection not modelled». И карту по сиду ещё предстоит нарисовать.

Работа идёт, но картина становится всё яснее.