Skip to content

feat(yaml): инструмент load->save round-trip-теста для yaml-мира#3279

Merged
bylins merged 22 commits into
masterfrom
feat/yaml-roundtrip-test
May 17, 2026
Merged

feat(yaml): инструмент load->save round-trip-теста для yaml-мира#3279
bylins merged 22 commits into
masterfrom
feat/yaml-roundtrip-test

Conversation

@kvirund
Copy link
Copy Markdown
Collaborator

@kvirund kvirund commented May 16, 2026

Что и зачем

Добавляет диагностический CLI-флаг circle -S <out_dir> и обвязку для проверки save→reload-целостности yaml-мира.

Мотивация (выросло из обсуждения #3273 и PR #3277): эмиттер Koi8rYamlEmitter и save-методы YamlWorldDataSource пишут yaml, который потом сам же сервер должен уметь читать. Без round-trip-теста любая регрессия в save-коде не ловится — баг проявляется только когда кто-то отредактировал зону через OLC и сервер при следующем boot'е падает.

Что в PR

  1. circle -S <out_dir> (src/engine/core/comm.cpp + src/engine/db/db.{cpp,h})

    • Грузит мир штатным бэкендом (yaml/sqlite), вызывает Save* для каждой зоны в zone_table через свежий экземпляр того же бэкенда, указанный на out_dir, и выходит.
    • Save-экземпляр создаётся через ту же фабрику CreateYamlDataSource(out_dir), без расширения IWorldDataSourceSave* уже виртуальные.
    • ResaveWorld предварительно копирует world_config.yaml и dictionaries/ из world/ в out_dir: конструктор YamlWorldDataSource валится без world_config.yaml, а словари нужны для ReverseLookupEnum на save (через глобальный DictionaryManager).
    • Без -S поведение бинаря не меняется.
  2. tools/yaml_world_diff.py

    • Structural diff двух yaml-миров через ruamel.yaml. Сравнивает per-entity файлы как древо данных, игнорируя порядок ключей, комменты, string-vs-int форму чисел.
    • index.yaml пропускает — Save* их не пишет.
  3. tools/yaml_roundtrip_test.sh

    • circle -S world_v2yaml_world_diff world world_v2.
    • Дефолты: data_dir=build_yaml/full, binary=build_yaml_meson/circle.

Что инструмент уже нашёл

Прогон на текущем full-world (build_yaml/full/world):

Summary: 45180 file(s) with diffs (255593 total), 39046 parse error(s),
         45123 only in v1, 20000 only in v2.

Известные баги, на которые этот инструмент даёт повторяемое воспроизведение и которые надо чинить отдельными PR'ами:

  • yaml-cpp parse errors на 39 046 файлахKoi8rYamlEmitter пишет &Yfoo без кавычек. Уже фиксится в fix(yaml): эмиттер должен квотить скаляры с YAML-индикаторами в начале (#3273) #3277.
  • vnum: не записывается в SaveObjects/SaveMobs/SaveTriggers. Конвертер пишет; save опирается на имя файла. Не data loss, но расхождение.
  • short_desc подменяется на nominative на load (yaml_world_data_source.cpp:2022). Поле short_desc: из yaml-файла не используется при загрузке — реальный data loss.
  • Zone-команды сохраняются с rnum вместо vnum. MOB 0 99990 1 99990 1MOB 0 12912 1 39347 1. Тоже data loss: после save→load зональные ресеты ссылаются на другие сущности.
  • aliases обрезается на load: 'костяк скелет''скелет'.
  • enhanced/spells дедуплицируется на load: [78,78,78,78,78][78].
  • enhanced/resistances, race, skills, mob_type не пишутся обратно SaveMobs — после save→load теряются.
  • Trailing \n в literal block-скриптах триггеров — конвертер не пишет, эмиттер пишет (минорное косметическое).
  • DgAffectdgaffect — case в командах триггеров разъезжается между конвертером и save.
  • 45 123 файла «только в v1» — каталоги зон, не упомянутых в zones/index.yaml (мусор от старого формата). + 20 000 файлов «только в v2»zones/30000+/..., dungeon-зоны, которые BootWorld создаёт в памяти после load. Можно потом отфильтровать опцией скрипта.

Test plan

  • ninja circle собирается с -Dyaml=builtin -Dbuild_tests=true
  • circle -S world_v2 -d <data_dir> отрабатывает без ошибок (errors=0 в логе), exit 0
  • tools/yaml_roundtrip_test.sh build_yaml/full build_yaml_meson/circle выдаёт ожидаемый Summary с расхождениями
  • Без -S старые сценарии (-c, обычный запуск) не задеты
  • CI: тесты, прогон под ASAN — оставляю на CI

Дальнейшие шаги (вне этого PR)

После мерджа #3277:

  • открыть отдельные PR'ы на пункты 2–9 из списка выше; каждое расхождение в диффе становится регрессионным тестом, который мы добавляем как unit-тест по мере фиксов
  • опционально: добавить опции скрипта --only-shared-zones / --ignore-dungeons для уменьшения шума на full world

kvirund added 22 commits May 16, 2026 19:11
Добавляет `circle -S <out_dir>`: грузит мир штатным бэкендом (yaml/sqlite),
прогоняет SaveZone/SaveRooms/SaveObjects/SaveMobs/SaveTriggers для каждой
зоны в `zone_table` через свежий экземпляр того же бэкенда, указанный
на out_dir, и выходит. Аналог `-c` (syntax-check), но с фазой записи.

Используется для диагностики Koi8rYamlEmitter и других save-методов:
сравниваем v1 (исходный yaml) с v2 (re-emit-ом) — структурные расхождения
показывают баги в Save\* / Load\* код-пути.

Save-экземпляр строится через ту же фабрику `CreateYamlDataSource` (или
`CreateSqliteDataSource`), что и в `GameLoader::BootWorld`. Интерфейс
`IWorldDataSource` не расширяется — Save\*-методы уже виртуальные. Реюз
загруженного глобального состояния (`zone_table`, `world`, `obj_proto`)
обеспечен тем, что Save\*-методы их читают, а пути строят от `m_world_dir`
своего экземпляра.

Конструктор `YamlWorldDataSource` требует наличия `<dir>/world_config.yaml`,
поэтому `ResaveWorld` предварительно копирует `world_config.yaml` и
`dictionaries/` из загруженного мира в out_dir. `zones/index.yaml` и
per-zone index'ы Save\*-методы не пишут — сравнение их игнорирует
(см. `tools/yaml_world_diff.py`).

Без `-S` поведение бинаря не меняется.
`tools/yaml_world_diff.py` — structural diff двух yaml-миров через
ruamel.yaml: сравнивает per-entity файлы (zones/N/{mobs,objects,rooms,
triggers}/\*.yaml и zone.yaml) как древо данных, игнорируя порядок ключей,
комментарии и string-vs-int форму скаляров для чисел. `index.yaml`
пропускает: Save\*-методы C++ их не пишут.

`tools/yaml_roundtrip_test.sh` — обвязка: запускает `circle -S world_v2`
поверх <data_dir>/world, потом diff v1 vs v2. По умолчанию data_dir=
build_yaml/full, binary=build_yaml_meson/circle, переопределяются
позиционными аргументами.

Прогон на full world сейчас даёт большое число расхождений (vnum не
записывается, short_desc подменяется на nominative на load, zone-команды
сохраняются с rnum вместо vnum, и т.д.) — это известные баги Save\*/Load\*,
которые инструмент и должен ловить. Чинятся отдельными PR'ами.
dungeon-зоны (vnum >= kZoneStartDungeons, флаг under_construction) живут
только в памяти — `CreateBlankZoneDungeon` создаёт их после load,
персистенса для них на диске нет. Запись их через Save* плодила фантомные
файлы под `world_v2/zones/30000+`, которых нет в исходнике.

В round-trip-выводе теперь не будет 20k 'only in v2' расхождений
по этому источнику.
…ry-имя

В `obj_data.h`: m_short_description — это inventory-имя объекта
("когда в руках/инвентаре", соответствует второй строке legacy .obj),
m_description — длинное описание "когда лежит в комнате" (восьмая
строка legacy). Конвертер пишет восьмую строку легаси в yaml-поле
`short_desc:`. На load `set_description(GetText(root, "short_desc"))`
(line 2031) восстанавливает m_description корректно.

SaveObjects (line 3910) писал в `short_desc:` значение
`get_short_description()` — то есть inventory-имя, дублируя nominative.
В round-trip получалось: было "колобок румяный ему дала бабка батьку
дедку.", стало "колобок румяный".

Поправлено: пишем `get_description()` — то же значение, что load
ожидает увидеть в `short_desc:`. SaveMobs использует ту же конвенцию
(line 3328 пишет `player_data.long_descr` в `descriptions/short_desc`).
`mob.get_npc_name()` возвращает `short_descr_` — короткое имя моба
(совпадает с `names/nominative`). `mob.GetCharAliases()` возвращает
`name_` — полный список ключевых слов, ровно то, что LoadMobs читает
обратно (`mob.SetCharAliases(GetText(names, "aliases"))` на line 1557).

В round-trip aliases моба 100 ("скелет") было: "костяк скелет",
становилось: "скелет" — терялся ключ "костяк", по которому моба
ищут командой `убить костяк`.
Legacy парсер мобов (`boot_data_files.cpp:1393-1405`) на каждой строке
`Spell: N` увеличивает `SplMem[N]` и обновляет `caster_level` /
`mob_specials.have_spell`. Yaml-загрузчик вместо этого ставил
`SplKnw[N] = 1` (просто флаг "спелл известен"), терял мультипликативность
и не трогал caster_level / have_spell. После такого load моб
эффективно лишался запомненных спеллов — `stat` показывал `Spell: 78`
один раз вместо пятикратного, и `SplMem[78] == 0` оставлял мобу нечего
кастовать.

Поправлено в трёх местах:
- `LoadMobs::enhanced.spells`: на каждое появление spell_id увеличиваем
  SplMem (а не SplKnw), обновляем caster_level и have_spell как legacy.
- `SaveMobs` (has_enhanced detection): проверяем SplMem, не SplKnw.
- `SaveMobs` (spells output): выводим spell_id ровно SplMem[id] раз,
  чтобы при следующем load восстановился тот же SplMem.

SplKnw в src/ нигде больше не читается вне yaml/sqlite-загрузчиков —
рантайм-логика боя/кастов работает по SplMem.
Класс перенесён один в один в src/engine/db/koi8r_yaml_emitter.h без
изменения поведения, чтобы его можно было покрыть unit-тестами без
включения всего yaml_world_data_source.cpp.

Preparation для фикса #3273.
#3273)

Koi8rYamlEmitter::Value() пропускал `&`, `*`, `!` и `,` в списке
символов, требующих квотинга в начале значения. Из-за этого имена
объектов и мобов с цветовыми кодами вида `&Yкрепеньки&Wй пенёк&n`
сохранялись через OLC без кавычек:

    nominative: &Yкрепеньки&Wй пенёк&n

При следующей загрузке yaml-cpp интерпретировал `&Y`/`&W` как
декларации якорей на одном узле и валил boot фатальной ошибкой
`cannot assign multiple anchors to the same node`. Объект уходил в
error_count, и при первом таком фейле сервер аварийно завершался
(`FATAL: 1 object(s) failed to load. Aborting.`).

Симптом проявлялся только на ямл-мирах, которые в какой-то момент
ретёрнули через C++ OLC-сохранение: исходные ямл-миры, полученные через
`tools/converter/convert_to_yaml.py`, выходили без бага, потому что
ruamel.yaml автоматически квотит скаляры с лидирующими индикаторами
(`preserve_quotes=True`). Поэтому наши прогоны `run_load_tests.sh` на
свежесконвертированном мире проходили чисто, а на стрибоговом
зеркале `/home/stribog/lib/world` boot падал.

Тесты: `tests/koi8r.yaml.emitter.cpp` гоняет save -> reload через
yaml-cpp для скаляров, начинающихся с `&`, `*`, `!`, `,` и для
исходного кейса `&Yfoo &Wbar&n` из тикета. Без фикса три теста падают,
с фиксом — все девять зелёные.
SaveRooms на 2907-2912 пишет `vnum:` сразу после header-комментария.
SaveTriggers/SaveMobs/SaveObjects этого не делали — vnum в файле
присутствовал только в комментарии `# Mob #100`, а в полях восстановить
его при load нельзя (load берёт vnum из имени файла, что хрупко при
переименовании).

Python-конвертер пишет vnum явно во все четыре типа. Round-trip diff
показывал `/vnum: missing in v2` массово на тригерах/мобах/объектах.
Теперь все четыре Save* пишут vnum единообразно.
… attributes

Серия фиксов в SaveMobs для соответствия выходу Python-конвертера:

- `mob_type: E` — всегда (path сериализует только Enhanced-формат, как
  и конвертер).
- `race: <NpcRace>` — между `size` и `height`, как в конвертере.
- `attributes` — пропускаем только когда все шесть атрибутов равны
  дефолту 11 (LoadMobs ставит 11 безусловно, потом overrides из yaml).
  Раньше save писал attributes для каждого моба (str=11>0), даже для
  тех, у кого в исходнике их не было.
- `enhanced` блок — всегда пишется (раз mob_type=='E', блок не
  опциональный). Внутри `resistances`/`saves` теперь пишутся всегда
  даже как все-нулевые массивы — так же делает конвертер.

Закрывает массовые `/mob_type: missing in v2`, `/race: missing in v2`,
`/enhanced/resistances: missing in v2` и `/attributes: missing in v1`
в round-trip-диффе.
…ng space

`FlagData::tascii` дописывает в буфер через `strncat` после
`strlen(ascii)` — она не очищает целевой буфер. `special_buf`
объявлялся на стеке без инициализации, и tascii интерпретировал чужие
байты как уже-имеющуюся длину, дописывая к ней. В практике на цикле
по мобам это давало накопление: на v2 у моба 8 в `special_bitvector`
оказывалась строка `'f1 0 0 0 0 0 a0b0c0d0e0f0 l0A0B0b1c1m3B3C3 l0'`
вместо `'l0'`, потому что предыдущие мобы оставили мусор.

Поправлено: special_buf явно зануляется (`special_buf[0] = '\0'`) перед
tascii. Заодно убираю trailing " "/"0 " из вывода — Python-конвертер
пишет `f1`, а tascii выдаёт `f1 `, что давало string-mismatch
в каждом мобе с непустыми флагами.
`ResolveZoneCmdVnumArgsToRnums` (db.cpp:1626) на boot перезаписывает
`zone.cmd[].argN` из vnum (как лежат на диске) в rnum (как нужны
рантайму `MobMax`, `mob_index[]`, `world[]`). SaveZone выводил
их без обратной конверсии, поэтому в round-trip-диффе зональные
команды `MOB 0 99990 1 99990 1` превращались в `MOB 0 12912 1 39347 1`.
Это означает: после save->reload зональные ресеты ссылаются на других
мобов/комнаты — серьёзный data corruption.

Поправлено: в SaveZone введены lambda-обёртки mob_v/obj_v/room_v/trig_v,
которые делают обратный lookup через `mob_index[rnum].vnum`,
`obj_proto[rnum]->get_vnum()`, `world[rnum]->vnum`,
`trig_index[rnum]->vnum`. Применены ко всем командным типам, на которые
повлиял `ResolveZoneCmdVnumArgsToRnums` (M/O/G/E/P/D/R/T/V/Q/F).
YAML literal block с дефолтным chomping (`|`) добавляет одну новую
строку в конец содержимого после parse. Python-конвертер пишет блоки
скриптов с `|-` (strip), отбрасывая trailing \n. В round-trip-диффе
это давало `'wait 1\\n...\\nend' != 'wait 1\\n...\\nend\\n'` на
каждом trigger script.

Поправлено: Koi8rYamlEmitter в literal-режиме теперь:
- эмитит `|-` (strip) вместо `|`;
- срезает одиночный trailing '\n' из source-строки перед итерацией
  по линиям, чтобы избежать пустой последней линии в файле.

Существующие round-trip тесты эмиттера (9 шт.) проходят.
Yaml/legacy loader'ы оба нормализуют первое слово каждой команды
script'а через LOWER() (`world_data_source_base.cpp:58`,
`boot_data_files.cpp:333`) — это нужно для runtime-матчинга dg-script
команд через strncmp(). Конвертер же копировал текст script'ов из
.trg as-is, сохраняя оригинальный регистр (`DgAffect`).

После save мы пишем уже-lowercased `dgaffect` — на каждом script'е
с использованием `DgAffect` round-trip diff показывал расхождение
по регистру. Дополнительный эффект: после первого `circle -S` файлы
yaml-мира приходили в свою каноническую (lowercase) форму, и
последующие round-trip'ы становились идемпотентными — но при этом
файлы расходились с тем, что конвертер выдавал из легаси.

Поправлено: `parse_trg_file` теперь нормализует script через
`_normalize_script` сразу после чтения — все строки в выходном yaml
имеют lowercase первого токена, как и runtime ожидает.
…й arglist

`ParseTriggerScript` (world_data_source_base.cpp:48) и legacy loader
делают `utils::TrimRight(line)` на каждой строке script'а — конвертер
сохранял исходные trailing space-ы, что давало round-trip diff'ы
"set dmsg  " vs "set dmsg" на каждом тригере с такими пробелами.

Аналогично `SaveTriggers` (yaml_world_data_source.cpp:2842) опускает
поле `arglist:` для пустого arglist, а конвертер всегда писал `arglist: ''` —
тоже расхождение в каждом тригере без аргументов.

Поправлено в `_normalize_script`: rstrip + lowercase первого токена,
плюс в `trg_to_yaml` пропускаем пустой arglist.
|- without digit relies on auto-detection: parser determines content
indent from the first non-empty line. If that line begins with spaces
(part of the description text, e.g. text with leading whitespace),
the parser folds them into indent; the next line with less leading
whitespace then looks like end of block. Reloading such files fails
with 'expected <block end>, but found <scalar>'.

|-2 pins the indent to 2 spaces relative to parent, strip-chomping
stays. The emitter already writes content with parent_indent + 2, so
the digit is exact.
Предыдущий коммит 84523c5 анонсировал race в SaveMobs в commit-сообщении,
но фактически в diff'е этого не было — была только запись mob_type/
attributes/enhanced. Добавляю забытое `yaml.Key("race")` между
`size` и `height` — как раз там, где его пишет конвертер.
…ения

Python-конвертер пишет descriptions с | (clip, оставляет trailing newline),
а scripts с |- (strip). После load descriptions имеют '\n' в конце,
scripts — нет. Save до этого использовал |-2 для всех literal-полей и
стропил trailing \n — descriptions теряли newline и round-trip давал
'text\\n' != 'text' для каждого long_desc/short_desc.

Поправлено: emitter в literal-режиме смотрит на trailing \n value:
- clip (|2) если value заканчивается на \n,
- strip (|-2) если не заканчивается.

Indent indicator '2' оставлен в обоих случаях.
rstrip()-ing каждой строки script'а в конвертере звучал как
"симметрия с loader's utils::TrimRight". На самом деле в C++ обоих
loader'ах TrimRight идёт ПОСЛЕ проверки line.empty() — поэтому
строки из одних пробелов остаются в cmdlist как пустой cmd->cmd.
rstrip в конвертере их выбрасывает, и cmdlist сокращается на
"пустые" узлы. world checksum сдвигается на triggerах с такими
строками.

Lowercase первого токена оставляем — loader делает то же, memory state
не меняется (проверено: после lowercase-only world_checksum совпадает
до и после регенерации).

Trailing whitespace в скриптах придётся принять в round-trip-диффе как
известное косметическое расхождение; для канонизации нужно менять
loader, не конвертер.
Когда оба defines выключены, объявление `saver` шло в #else-ветке
с `return 1;` -- но цикл за блоком всё равно ссылался на saver,
ломая legacy-build (`run_load_tests.sh` это и поймал).

Поправлено: вся ResaveWorld-логика обёрнута в
`#if defined(HAVE_YAML) || defined(HAVE_SQLITE)`; в else-ветке
просто `return 1;`. saver и цикл существуют только когда есть
доступный бэкенд.

Заодно: миграция tools/run_load_tests.sh с cmake/make на meson/ninja.
cmake'овские опции (`-DENABLE_ADMIN_API=ON`, `-DHAVE_YAML=ON`,
`-DCMAKE_BUILD_TYPE=Debug`) превращены в meson-аналоги
(`-Dadmin_api=true`, `-Dyaml=builtin`, `-Dbuild_profile=debug`).
`setup_small_world` теперь зовёт `tools/meson/setup_world.py`
напрямую вместо переконфигурации cmake. `build_binary` использует
`meson setup` + `ninja`; разрешает $HOME/.local/bin для pip --user
установок.
`run_load_tests.sh --quick` нацелен на пару Legacy + YAML, но
ранее всё равно собирал sqlite-бинарь (NEED_SQLITE=1 потому что
FILTER_LOADER пуст). На системах без libsqlite3-dev меsoн-сборка
`-Dsqlite=auto` валится (subprojects/sqlite3.wrap у нас нет), и
quick-режим заканчивается до Legacy/YAML тестов.

Поправлено: добавлен флаг QUICK_SKIP_SQLITE; в quick-режиме он
выставлен в 1 и форсит NEED_SQLITE=0. Legacy и YAML builds + теста
теперь проходят на минимальной системе без sqlite3-dev.

Параллельно `build_sqlite` теперь использует `-Dsqlite=auto` (если
позже sqlite потребуется и libsqlite3-dev есть — сборка отработает
автоматически).
Главный фикс: SaveZone/SaveRooms/SaveMobs/SaveObjects/SaveTriggers теперь
ребилдят свой index.yaml из in-memory state. Раньше Save\* писали
per-entity файлы, но index\'ы не трогали — поэтому новые сущности от OLC
на следующий boot терялись (yaml-loader читает индексы; если нет — load
пропускает).

- WriteIndexYaml(filepath, top_key, vnums) - atomic write helper.
- RebuildPerZoneIndex(zone_vnum, sub) - собирает rel-нумы из глобальных
  prototypes и пишет zones/<vnum>/<sub>/index.yaml.
- SaveZone дополнительно ребилдит zones/index.yaml, отфильтровывая
  dungeon-зоны (vnum >= kZoneStartDungeons) - как legacy
  medit_save_to_disk (olc/medit.cpp:532).
- Пустая зона тоже получает index.yaml (mobs: [] и т.п.), иначе boot
  ругается 'Failed to load index'.
- Rooms-index пропускает rel==99 (virtual room
  AddVirtualRoomsToAllZones).

ResaveWorld: убран костыль копирования index.yaml из source; фильтр
изменён на zone.vnum >= kZoneStartDungeons (под_construction-флаг
носят и обычные prod-зоны 'в разработке' - skipped 15 зон даром).

Косметика эмиттера:
- значения с trailing whitespace квотятся (yaml strip\'ит у plain scalar);
- ручные literal-block emit\'ы для exit.description/ex_description.description
  переведены на yaml.Value(..., true) - clip/strip автоматически по
  trailing newline (раньше hardcoded |2 добавлял лишний \n);
- ex_description.keywords и exit.keywords через yaml.Value - фиксит
  keywords='-' (был bare '-' = sequence indicator);
- exit.keywords объединяет keyword + vkeyword через '|', обе формы
  round-trip\'ятся (save раньше дропал accusative).
@bylins bylins merged commit 30c0c43 into master May 17, 2026
20 checks passed
@bylins bylins deleted the feat/yaml-roundtrip-test branch May 17, 2026 05:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants