Отладка (headless-прогон и браузерная половина)
Матч VIMP прячет своё состояние сразу за тремя стенами: авторитетная половина крутится в Web Worker, сама симуляция — WASM, а между хостом и клиентом идёт бинарный кадр по WebRTC. Типичный отказ — не исключение, а чёрный холст, танк в стене или ключ снапшота, который молча не доезжает до рендера. Молчание и есть форма отказа.
Эта страница описывает отладочный контур, превращающий молчание в текст:
- headless-runner (
npm run sim), замыкающийхост → бинарный кадр → ClientCore → hot-буфер → сцена в JSONв одном Node-процессе, без браузера и без человека; - проверки инвариантов, называющие нарушенный контракт вместо тишины;
- дампы ядра (
debug_json) для багов физики; - детектор рассинхрона предикта (
take_divergence); - браузерная половина, записывающая живой матч в тот же формат сценария, — пойманный человеком баг прогоняется headless.
От игрового плагина ничего не требуется: всё приезжает через ABI-макросы (export_game_core_abi!/export_client_core_abi!) и через trait-методы с дефолтами, поведение в проде не меняется (ENGINE_API_VERSION не бампается).
Инвариант: vimp-engine/src/devtools/ и vimp-engine/bin/vimp-sim.js — только Node: они свободно пользуются node:-модулями и не должны быть достижимы из бандла приложения. Ничто из src/client/ и src/host/ их не импортирует; браузерная половина общается с раннером через записанные файлы сценариев, а не через импорты.
Проверка контракта (vimp-contract)
Раннер судит об игре по прогону. Половине молчаливых отказов матч для этого не нужен: они уже видны в конфигах, манифесте и форме двух половин плагина. vimp-contract читает ровно их — за секунду и без сборки:
npx vimp-contract --game . # из пакета игры
node vimp-engine/bin/vimp-contract.js --game ../vimp-tanks| Опция | Смысл |
|---|---|
--game <path> | каталог пакета игры (по умолчанию .) |
--json | машинный отчёт вместо текста |
--quiet | только нарушения и сводка |
--strict | предупреждения считаются ошибками |
Код выхода 0 — все правила pass или skip, 1 — есть хотя бы один отказ уровня error (под --strict к ним добавляются предупреждения). skip означает «входа нет» — нет package.json, нет dist/ — и никогда не маскирует нарушение, поэтому проверка полезна с первого коммита плагина, задолго до первой сборки. Правило, которое нашло файл, но не нашло нужных данных, пишет об этом в ноты отчёта, а не выдаёт pass (так устроен, в частности, пин vimp-engine-core). Прогон, в котором ни одно правило не нашло входа, завершается кодом 1: 32 skip — это опечатка в пути, а не справка о здоровье.
Проверка импортирует пакет игры. Обе половины плагина грузятся как ES-модули, то есть
vimp-contract --game <dir>исполняет код этого каталога в вашем процессе Node, включая операторы верхнего уровня. Направляйте её на свои игры и на репозитории, которым доверяете, — с той же осторожностью, с какой запускали бы тамnpm install.
Правила лежат по файлу на правило в vimp-engine/src/devtools/contract/rules/, каждое — чистая функция (ctx) => result. Контекст (loadContext.js) смешивает текст (package.json, vite.config.js, core/Cargo.toml) с живыми объектами плагина: обе половины импортируются как модули, поэтому gameConfig, authSchema и клиентский конфиг — те самые вычисленные структуры, которые увидит движок, а не разбор исходника.
| Группа | Что ловит |
|---|---|
| A1–A7 | упаковка: type/files, pixi.js и vimp-engine не в той секции зависимостей, стандартные скрипты, пути точек входа, обязательные опции Vite, crate-type/enhanced-determinism/устаревший пин vimp-engine-core, собранный манифест, объявленный repository (предупреждение — без него форма входа остаётся без ссылки на проект) |
| B1–B10 | host: форма плагина, тройка engineApi, четыре обязательных пути gameConfig (плюс предупреждение на каждое поле, оставленное на движковое умолчание), команды и наблюдатели, поля roomForm, которые хост молча выбрасывает, и некомпилируемый regExp, зарезервированный ключ панели t, чат-команды поверх движковых, коды системных сообщений поверх движковых текстов, зарезервированные имена голосований, вместимость респаунов против maxPlayers |
| C1–C10 | client: форма плагина и три хука, парты в entitiesOnCanvas, покрытие gameSets для каждого ключа снапшота и setId карты, сервисы, которые даёт движок или hooks.services() плагина (при объявленном хуке — предупреждение: вызвать его чекер не может), поле панели t/time, колонки статистики за пределами движковой раскладки, покрытые стилями плагина (предупреждение), наборы клавиш наблюдателя и игрока, bakers, тексты сообщений, схема авторизации (fieldsId, отсутствие ника, поле model) |
| D1–D3 | схема снапшота: уникальные id, классы hot/event против видов блоков, interp только на горячих f32, role: 'state' — u8 сразу за головой строки динамики и до optionalFrom |
| E1–E7 | ассеты и карты: пара webm + mp3 на звук, картинки карт в dist/, пустой реестр звуков (предупреждение), структура слоёной (2.5D) карты — ключи уровней, размерности гридов, перила внутри плиты, тайлы и направления рамп, рампа приводит на проезжую поверхность, край плиты либо закрыт перилами, либо падает на проходимую землю, уровни респаунов и physicsDynamic, levelHeight конечен и положителен, две рампы не делят клетку, тайлы рендер-слоёв уровня — и (E5) каждый тайл стены назван рендер-слоем, потому что радар рисует стены из слоя, который их перечисляет; (E6) корректность блока parts.sounds.spatial — это простой объект, ключи только известные (опечатку deepMerge молча кладёт в конфиг, а SoundManager молча игнорирует), mode/panningModel/distanceModel из закрытых списков, числа нужного знака и maxDistance > refDistance — сравнение идёт по разрешённым значениям, поэтому объявить одну дистанцию против движкового дефолта второй — тоже нарушение (рантайм откатит обе); (E7) поле карты game и каждое physicsDynamic[i].game — простой объект или отсутствует |
Запускать до npm run sim: быстрее, не требует собранного ядра, а находит ровно то, о чём раннер сообщил бы чёрным холстом десятью минутами позже. Вместе они закрывают чек-лист pitfalls.md — статическую половину здесь, рантаймовую в инвариантах ниже.
Контур в одной команде
npm run sim # встроенный смоук на фикстуре miniGame
npm run sim -- --scenario s.json # прогон сценария
npm run sim:replay s.json # то же короче (`--scenario` уже подставлен)
npm run sim:check # вердикт только в stdout, файлы не пишутсяОпции CLI (vimp-engine/bin/vimp-sim.js, он же bin vimp-sim пакета vimp-engine):
| Опция | Смысл |
|---|---|
--scenario <path> | JSON сценария; без неё идёт встроенный смоук (см. врезку ниже) |
--game <path> | каталог пакета игры или прямо dist/manifest.json |
--core <path> | node-сборка ядра игры, перекрывает entries.wasmNode; осмысленна только с --game (ядро фикстуры — обычный JS, поэтому --core в одиночку ничего не делает и говорит об этом) |
--out <dir> | корень отчётов (по умолчанию .debug) |
--no-write | печатать отчёт в stdout вместо записи файлов |
--determinism | прогнать сценарий дважды и сравнить потоки кадров (инвариант 12) |
--checkpoint-every <мс> | каждые <мс> времени матча сменить хост через контрольную точку (см. checkpointRestore ниже); перекрывает checkpointEvery сценария |
--help | подсказка |
Встроенный сценарий — смоук, а не аудит. Один участник заходит, удерживает клавишу, отпускает её. Идентификаторы, которыми он это делает — модель, играющая команда, имя клавиши, — берутся из вашего gameConfig (первая модель parts.models, первая команда teams, не равная spectatorTeam, первая клавиша playerKeys без type: 1 — у триггера up ничего не делает, и удерживать смоуку было бы нечего), поэтому он идёт на любом плагине; если игра не объявила ни того, ни другого, прогон скажет об этом, а не станет угадывать. Чего он вывести не может — смысла вашей игры: инвариант 2 (покрытие ключей — сценарий не знает, какие из них он обязан породить) и инвариант 9 (дрейф предикта, чьи пороги у каждой игры свои) пропускаются, о чём в stderr печатается предупреждение. Оба возвращаются, как только вы передадите --scenario, написанный под вашу игру.
Код возврата — это вердикт: 0, если не упал ни один инвариант, 1, если упал хотя бы один (имена нарушенных проверок печатаются в stderr). Именно это делает цикл автономным: правка → прогон → чтение текста → повтор.
На каком ядре идёт прогон
Порядок поиска в devtools/pluginLoader.js, по убыванию точности:
--game <путь>→ читается манифест,engineApiсверяется с этой сборкой движка, импортируютсяentries.host/entries.client, ядро берётся изentries.wasmNode(node-сборка WASM-ядра, по конвенцииdist/core-node/);--core <путь>→ перекрываетentries.wasmNode;- ничего → фикстура
miniGameиз поставки движка (vimp-engine/tests/fixtures/miniGame/), её ядра — обычный JS. Ради этого фолбэкаvimp-engine/package.json:filesпубликуетtests/fixtures— «чистка» этого списка молча сломаетnpm run simбез--game.
entries.wasmNode опционально. Игра без него просто не прогоняется headless на своём настоящем ядре — runner скажет об этом прямо, а не будет угадывать. Объявленный файл обязан существовать: манифест, указывающий на node-ядро, которого нет в опубликованном пакете, валится именно этой фразой, а не сырым ERR_MODULE_NOT_FOUND из резолвера модулей. Кладите глюe внутрь публикуемого dist/ — ignore-правила действуют и внутри каталогов из files. Обе половины плагина дополнительно сверяются с engineApi манифеста: это ловит пересобранный манифест рядом с отставшим dist/. См. plugin-api.md.
wasmNode — путь относительно манифеста, а не URL. Чтобы раннер работал из установленной копии игры, а не только из checkout'а, Node-обвязку нужно при сборке скопировать внутрь публикуемого dist/ (например, dist/core-node/) и направить wasmNode туда. Одного core/pkg-node в files недостаточно: npm применяет правила игнорирования и внутри каталогов из files, а выходной каталог wasm-pack обычно в git-ignore (он ещё и кладёт свой .gitignore с * — этот файл копировать нельзя). Оставьте package.json, который пишет wasm-pack: без него Node читает CommonJS-обвязку как ESM. Проверка в prepack, что файл действительно попал в tarball, превращает пропавшую обвязку в упавшую публикацию, а не в сбой раннера на чужой машине. createCore/ createClientCore игры обязаны принимать эту форму wasmUrl (plugin-api.md); у Node-сборки нет WASM-памяти, поэтому клиентская половина возвращает memory: null.
Ещё одно про --game: runner импортирует клиентский бандл игры, а его внешние зависимости обязаны резолвиться от места, где лежит пакет, — прежде всего pixi.js (peer-зависимость плагина). Установленная копия (node_modules/<пакет>) резолвит его от проекта, а тарбол, распакованный в произвольный каталог, — нет, и прогон падает с Cannot find package 'pixi.js'. Распаковывайте внутрь проекта с зависимостями либо ставьте пакет.
Формат сценария
{
"version": 1,
"seed": 3812,
"map": "arena",
"config": { "timers": { "networkSendRate": 1 } },
"participants": [{ "id": "p1", "name": "P1", "model": "m1" }],
"timeline": [
{ "tick": 0, "op": "join", "who": "p1", "team": "team1" },
{ "tick": 12, "op": "key", "who": "p1", "action": "down", "name": "forward" },
{ "tick": 30, "op": "chat", "who": "p1", "text": "/nr" }
],
"unusedSnapshotKeys": ["e1"],
"divergence": { "thresholds": [0.5, 0.5], "capacity": 64 },
"ticks": 1200,
"dumpTicks": [6, 1200]
}| Поле | Смысл |
|---|---|
version | обязательно 1 |
seed | uint32, уезжает в room.seed — тот же seed даёт тот же мир (по умолчанию 1) |
map | стартовая карта (по умолчанию — дефолт игры) |
game | { version } пакета игры, как его прислало бы лобби |
room | прочие переопределения комнаты (applyRoomOverrides) |
config | патч, вливаемый в собранный конфиг игры до создания ядра; таймеры — только внутри config.timers: ключ верхнего уровня правит конфиг игры и в таймеры не роутится |
participants | [{ id, name, model }], непустой; id — локальный для сценария идентификатор, на него ссылается who |
timeline | операции, при разборе сортируются по tick |
unusedSnapshotKeys | ключи схемы, которых этот сценарий заведомо не порождает (инвариант 2); "*" означает «этот сценарий вообще не судит покрытие ключей» и переводит инвариант 2 в skip — так делает встроенный сценарий на чужой игре |
divergence | пороги детектора предикта (и angles — компоненты, сравниваемые по окружности); {} — дефолты ядра, null — детектор выключен, и инвариант 9 пропускается |
ticks | сколько тиков прогонять (по умолчанию 600) |
dumpTicks | тики, на которых снимается срез сцены (по умолчанию — последний) |
checkpointEvery | мс времени матча между сменами хоста через контрольную точку (по умолчанию null — нет); форма шага checkpointRestore по времени |
Операции:
op | Поля | Действие |
|---|---|---|
join | who, team | участник заходит, под него создаётся настоящий ClientCore |
leave | who | участник выходит |
key | who, action (down/up), name | HostGame.updateKeys плюс тот же ввод применяется к клиентскому ядру участника |
aim | who, x, y, flags (по умолчанию 1) | ввод указателем: HostGame.updateKeys строкой 'seq:aim:x:y:flags' плюс та же точка применяется к клиентскому ядру участника |
chat | who, text | HostGame.pushMessage (включая чат-команды) |
vote | who, data | HostGame.parseVote |
checkpointRestore | — | смена хоста: см. ниже |
checkpointRestore — переживает ли игра смену хоста? Старый хост замораживается и отдаёт финальную контрольную точку; точка проходит боевой кодек (gzip и обратно), из неё на том же транспорте поднимается новый runtime (createHostRuntime с checkpoint), каждый виртуальный клиент возвращается в своё место через RESUME (со своим resumeKey — раннер выдаёт его на входе, как порт-машина лобби), и матч продолжается. Клиент, который не смог вернуться, валит прогон. Все инварианты обязаны остаться зелёными через смену — это главный инструмент автора проверить serialize/serializeState игры с gameConfig.migration.midRound ("migration": { "midRound": true } в config позволяет попробовать до включения флага в игре). В отчёте появляется раздел ## Host checkpoints — тик, режим (midRound / soft) и сжатый размер каждой смены; игра, включившая opt-in, но показывающая soft, потеряла дамп ядра (причина — в предупреждении [checkpoint]).
Два свойства стоит знать до того, как писать сценарий руками:
- Шаг тика не задаётся сценарием. Он берётся из конфига игры (
timers.timeStep): виртуальные часы прокручиваются ровно на шаг, и боевой самокорректирующийся циклTimerManagerделает ровно один тик. Runner крутит настоящий цикл, а не подменяет его. - Ввод применяется синхронно, очереди нет — поэтому
tickв таймлайне это граница тика, на которой ввод приземлился.op: keyнамеренно дублируется на клиентское ядро: без этого дрейфу предикта не с чем было бы сравниваться. - Виртуальный клиент проводится как браузерный.
VirtualClientповторяет то, что делает с ядромclient/main.js:{ name, model }участника уходит вhooks.onAuthпри создании,MAP_DATA— вset_map, кадры панели — вhooks.onPanel,KEYSET_DATA— вset_active;FIRST_SHOT_DATAприменяется немедленно (снапшот мира первого кадра), аCLEARснимает сущности и сбрасывает ядро — ровно как браузер на каждом рестарте раунда и смене карты. Без этого ядро крутилось бы в состоянии, которого в игре не бывает: без модели, с выключенным предиктом и со сценой, где живут уже снятые с холста сущности. Порядок при этом — тот же, что на проводе: записывающий транспорт публикует кадр в момент, когда боевой код отдаёт нагрузку сокету, поэтому составной отправитель приходит раньше вложенных в него вызовов (FIRST_SHOT_DATA→STAT→PANEL→KEYSET). НаsendPingответ отправляется сразу (updateRTT) — иначе хост честно кикнет участника по таймауту RTT посреди длинного прогона. - Сброс спавна доезжает до клиента с задержкой. Камера с
force_resetприходит через буфер интерполяции (interpolation.delay) и сбрасывает зажатые клавиши предиктора. Нажатие в первые ~delayмиллисекунд послеjoinпоэтому теряется на клиенте, хотя хост его держит, — начинайте ввод сценария на несколько десятков тиков позже join. - Раунд заканчивается, только когда погибает вся команда — таймаут раунда его лишь перезапускает, — поэтому сценарию, которому нужен зелёный инвариант 10, придётся реально кого-то убить: надёжны scripted-участники (команда бота) либо огонь по своим плюс взрывчатка в упор.
Инварианты
По итогам прогона runner выполняет 12 проверок (vimp-engine/src/devtools/invariants.js). Каждая — pass, fail или skip; skip означает «в этом прогоне нечего проверять» и никогда не маскирует нарушение. Каждое нарушение — строка с именем клиента, ключа и значения.
| № | Имя | Что ловит |
|---|---|---|
| 1 | finiteValues | NaN/Infinity в декодированных полях и hot-буфере |
| 2 | snapshotKeysUsed | ключ схемы не дал ни одной строки — «сущность не спавнится» либо несовпадение id ключа (намеренные случаи объявляются в unusedSnapshotKeys) |
| 3 | fieldWidths | число декодированных полей ≠ числу полей схемы — уехала позиционная привязка в interpolator.rs |
| 4 | frameFormat | байт версии кадра ≠ SNAPSHOT_FORMAT_VERSION либо падение decode_frame |
| 5 | hotLayout | обход hot-буфера потребил не ровно len float'ов — дрейф ширины записи или порядка групп |
| 6 | panelContract | объявленное поле panel не доезжает до клиентского конфига (значение приходит с именем undefined) |
| 7 | renderCoverage | живой ключ снапшота отсутствует в gameSets/entitiesOnCanvas — класс «чёрный холст» |
| 8 | keyBindings | playerKeys хоста ↔ keyset'ы клиента ↔ имена клавиш из сценария |
| 9 | predictionDrift | дрейф предикта выше порога (см. ниже) |
| 10 | roundLifecycle | раунд закончился, победитель назначен, респауны прошли, участники не утекли |
| 11 | actorLeak | players_data() расходится с активными участниками |
| 12 | determinism | два прогона одного сценария дают одинаковый поток кадров — кадры сравниваются по хешу, и хеши собираются только под --determinism (на длинном матче сам поток стоил бы мегабайтов отчёта) |
Инвариант 12 — самопроверка всего остального: он держится только потому, что время, таймеры и случайность хоста идут через подменяемый clock (см. game-config.md), а seed фиксируется сценарием.
Отчёт
Пишется в .debug/run-<метка времени>/ (.debug/ в .gitignore):
| Файл | Содержимое |
|---|---|
report.md | вердикт для человека/нейросети |
report.json | тот же прогон машинно, плюс snapshotSchema |
scene-<tick>.json | по файлу на снятый тик: сцена каждого клиента, камера, панель и дамп ядра |
Срезы сцены вынесены в отдельные файлы: они самые объёмные и читаются точечно; в report.json от них остаются только номера тиков в sceneTicks.
Секции report.md:
- шапка — id и источник игры, seed, шаг, длительность, карта, участники;
## Outgoing frames— счётчик по методамSocketManager;## Clients— по клиенту: сущности на холсте, ошибки декодирования, принятые порты;## Map changes;## Host checkpoints— смены хоста через контрольную точку (тик, режим, сжатый размер);## World (core dump)— тела, коллайдеры, карта, узлы нава, сущности spatial-сетки, состояние rng, аккумулятор фикс-шага последнего среза;## Prediction drift— реконсиляции, нарушения, максимум |Δ| по компонентам;## Invariants— ради чего всё и затевалось:✅/❌/⏭️, имя проверки, её заголовок и каждая строка нарушения.
Дампы ядра: debug_json()
Оба ядра отдают курированный дамп (сырой serde-вывод rapier нечитаем):
- игровое ядро —
GameCore.debug_json()(экспортируетсяexport_game_core_abi!, поэтому достаётся каждому плагину даром):bodies(tag,userData,translation,rotation,linvel,angvel,mass,bodyType,ccd),colliders(shape+halfExtents/radius,isSensor, группы столкновений/солвера в hex,parent),map(setId,step, размеры сетки, счётчики статики/динамики, респауны,dynamicLevels— уровень каждого динамического тела; теперь он меняется по ходу матча: тело, оставшееся без плиты, падает),nav(узлы/рёбра/шаг сетки),spatial(размер ячейки, заполненность по ячейкам),rng.state,step.accumulator; - клиентское ядро —
ClientCore.debug_json():myGameId,offset,hotLen,framesOutиinterpolator(глубина буфера,seqWindow,seq/serverTimeпоследнего кадра,offset,lastRenderTime).
Порядок записей детерминирован — дампы двух прогонов сравниваются дифом.
Из JS дамп доступен через GameCoreAdapter.debugJson(), HostGame.debugSnapshot() (мета хоста + дамп ядра) и VirtualClient.debug(). Все они отдают null, если ядро игры собрано против движка без этого метода, — старая сборка плагина прогон не роняет.
Детектор рассинхрона предикта
Расхождение клиентского предикта с авторитетным состоянием — классический «резиновый» баг, и в отдельно взятом кадре он не виден. Детектор сравнивает обе величины в момент прихода авторитетного кадра, непосредственно перед тем, как on_server_state затрёт предикт.
Два уровня, чтобы в базовом случае от плагина не требовалось ничего:
- уровень 0 — без правок плагина: камера
render_overlay()(для предсказывающего плагина это предсказанная позиция) противstate[0]/state[1]авторитетного player-блока. Это контракт: уровень 0 считает, что первые два компонента player-блока — мировые x/y. Если раскладка начинается с другого, реализуйтеpredicted_state()— иначе инвариант 9 будет врать. В записях уровня 0 компоненты названыx/y; на уровне 1 они адресуются индексом, потому что их смысл — игровой; - уровень 1 — один опциональный trait-метод
GameClientDef::predicted_state() -> Option<[f32; PLAYER_STATE_LEN]>(дефолтNone), сравнение покомпонентное; парный необязательныйreplayed_inputs() -> Option<(f64, f64, usize)>отдаёт окно истории ввода, переигранное последним реконсилем.
Настройка — опциональная секция divergence движкового клиентского конфига (в боевом конфиге её нет, и тогда путь кадра не меняется вовсе):
{ "thresholds": [0.5, 0.5, 0.06], "defaultThreshold": 1.0, "angles": [2], "capacity": 64 }thresholds задаются позиционно по player-блоку (его раскладка — игровая); недостающие компоненты берут defaultThreshold. Записи ложатся в кольцевой буфер на capacity элементов, вытесненные считаются как dropped. Запись кладётся, только если хотя бы один компонент вышел за свой порог, — иначе отчёт утонул бы в шуме.
angles — индексы компонентов-углов (по умолчанию пусто). Их разность приводится к (−π, π] до сравнения с порогом и попадания в отчёт: без этого одно направление по разные стороны разреза ±π — скажем, 3.1412 в предикте против −3.1416 в кадре — читается как дрейф ≈ 2π вместо 0.0004. Угадывать углы по значениям движок не берётся (настоящий разворот на ≈ 2π оказался бы замаскирован), поэтому их называет игра. Индекс за пределами player-блока не участвует — как и лишний порог.
Калибровка порогов — часть написания сценария. Сравнение имеет смысл, только если предикт и кадр описывают один и тот же момент:
- headless: один момент. Runner доставляет кадры состояния тика сразу после рендера этого тика (
render(now), затемpushFrame(bytes, now)), поэтому к сравнению реплика уже дошагала до времени кадра. Раньше отложенные кадры выпускают только сообщения, сбрасывающие или заменяющие мир клиента (sendClear,sendMap,sendFirstShot), — чтобы кадр старой карты не доехал после них; пинги, панель и прочие очередь не трогают. У тела, движущегося с постоянной скоростью, расхождение позиции ≈ 0 на любой скорости — порогам не нужно расти вместе с максимальной скоростью игры. - браузер: до одного рендер-кадра. Живые кадры приходят между рендер-кадрами, и реплика может ещё стоять на предыдущем рендер-тике — отставать от кадра на этот интервал (и не больше чем на один
timeStepфизики). Учитывайте это, читая дрейф, выгруженный из браузерной сессии.
Остаётся один источник ожидаемой разницы, и порог ниже него превращает проверку в шум:
- то, что реплика не симулирует. Коллизии, импульсы взрывов и телепорты бывают только авторитетными; каждый даёт одиночный крупный выброс, который следующая реконсиляция гасит. Держите их вне сценария, следящего за дрейфом, либо выключайте детектор для такого сценария (
"divergence": null).
Проверка нужна для дрейфа, который растёт: разошедшаяся формула ядра и реплики обратно не сходится.
Реализация уровня 1 (predicted_state) — самый ценный необязательный метод для игры с клиентским предсказанием: он превращает «движение иногда ощущается неправильным» в пронумерованную компоненту с дельтой.
ClientCore.take_divergence() (экспорт export_client_core_abi!) вычерпывает буфер и отдаёт:
{ "samples": 29, "violations": 0, "dropped": 0, "maxDelta": [0.0, 0.0],
"records": [{
"source": "predicted", "serverTime": 1234, "localNow": 5678,
"offset": 12.5, "inputSeq": 41,
"replayed": { "from": 5600, "to": 5678, "count": 7 },
"predicted": [...], "authoritative": [...], "delta": [...],
"thresholds": [...], "exceeded": [1]
}] }Существенно: сопоставление идёт по времени, а не по seq — предиктор переигрывает историю ввода от момента авторитетного состояния. Поэтому в записи есть serverTime, offset и окно переигранного ввода: именно они локализуют уехавшую формулу.
Браузерная половина
Headless-прогон не воспроизводит настоящий WebRTC, PixiJS и живой ввод человека. Браузерная половина захватывает всё это в тот же формат сценария.
Всё описанное ниже живёт только в dev: рекордер висит на gameConfig.isDevMode (выставляется из room.isDevMode, который client/main.js берёт из import.meta.env.DEV), и прод-бандл вырезает ветку сборкой.
window.__vimpDebug
Доступен в dev-сборке; рассчитан на вызов из DevTools или через Chrome MCP (javascript_tool, read_console_messages) — без участия человека в разборе.
| Вызов | Что делает |
|---|---|
dump({ save, note }) | мета хоста + дамп ядра (через Worker) рядом со сценой этого клиента; save: true ещё и отправляет его на dev-эндпоинт, если он есть |
startRecording() | начинает запись живого матча; false — комната поднята без dev-режима |
stopRecording({ save = true, note }) | останавливает запись и отдаёт { scenario, file }; по умолчанию ещё и отправляет сценарий на dev-эндпоинт — где его нет, передайте save: false |
divergence() | вычерпывает детектор рассинхрона этого клиентского ядра |
Молчаливого отказа нет: вкладка, не хостящая комнату, бросает исключение с текстом, а не возвращает null.
Рекордер
host/DebugRecorder.js (Worker-safe — только clock) пишет живой матч в формат сценария выше: seed, входы, каждый updateKeys/pushMessage/parseVote с номером тика плюс последовательность dt. Участники, вошедшие до старта записи, становятся операциями join нулевого тика — иначе реплей стартовал бы с пустой комнаты. Идентификаторы у сценария свои (p1, p2, …): gameId переиспользуется после выхода игрока и склеил бы двух разных людей в одну запись. Кап (maxOps/maxDtSamples) не молчит — переполнение уезжает в meta сценария.
На фасаде хоста: startRecording(), stopRecording(), isRecording, debugSnapshot(). Worker отдаёт их парой сообщений запрос/ответ (debug → debug_result с requestId), которую контроллер хоста заворачивает в промисы.
Логи хоста в консоли клиента
Worker изолирован от DevTools вкладки, поэтому отладочные события авторитетной половины уходят портом 12 (CONSOLE, SocketManager.sendConsole), а клиент печатает их как [vimp:debug][host] …. Всё, что логирует отладочный API, несёт тот же префикс [vimp:debug], поэтому Chrome MCP фильтрует консоль по шаблону вместо чтения всего потока.
Из браузера в headless
запись в браузере → stopRecording({ save: false }).scenario → JSON-файл → npm run sim:replay <файл>Записанный файл принимается npm run sim:replay без правок — тесты движка записывают матч фикстурным хостом и тут же прогоняют результат через runScenario, так что это проверяемый контракт, а не декларация.
Известный предел: реплей — это новый матч, а не продолжение. Рекордер стартует с tick = 0 и пишет только вход и ввод, но не состояние мира на этот момент (позиции, фаза раунда, счёт, ротация карт), а сам реплей крутится фиксированным timeStep, тогда как живой матч шёл с плавающим dt. Значит «баг, пойманный в браузере, догоняется sim:replay» верно для багов, воспроизводимых с начала матча. Начав запись на пятой минуте, вы получите реплей без той ситуации, которую видели. Снять предел можно снимком initialState в записи (естественный кандидат — контрольная точка хоста) — это отдельная задача.
Рекомендуемый порядок для нового плагина
- Собрать node-сборку ядра (
core/pkg-node/) и указать на неёentries.wasmNode. npm run sim -- --game <пакет> --scenario <сценарий>— устранить все нарушения инвариантов. Большинство багов «чёрный холст»/«ничего не спавнится» умирает здесь, текстом, без браузера.- Когда матч стабилен — добавить
--determinism. - И только потом открывать две вкладки ради ручного смоука; если там что-то не так,
startRecording()→stopRecording()→npm run sim:replayвозвращает случай на шаг 2.
← Предыдущая: Упаковка и сборка игрового плагина · Следующая: Ловушки и инварианты →