Skip to content

Отладка (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 читает ровно их — за секунду и без сборки:

bash
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–B10host: форма плагина, тройка engineApi, четыре обязательных пути gameConfig (плюс предупреждение на каждое поле, оставленное на движковое умолчание), команды и наблюдатели, поля roomForm, которые хост молча выбрасывает, и некомпилируемый regExp, зарезервированный ключ панели t, чат-команды поверх движковых, коды системных сообщений поверх движковых текстов, зарезервированные имена голосований, вместимость респаунов против maxPlayers
C1–C10client: форма плагина и три хука, парты в 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 — статическую половину здесь, рантаймовую в инвариантах ниже.

Контур в одной команде ​

bash
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, по убыванию точности:

  1. --game <путь> → читается манифест, engineApi сверяется с этой сборкой движка, импортируются entries.host/entries.client, ядро берётся из entries.wasmNode (node-сборка WASM-ядра, по конвенции dist/core-node/);
  2. --core <путь> → перекрывает entries.wasmNode;
  3. ничего → фикстура 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'. Распаковывайте внутрь проекта с зависимостями либо ставьте пакет.

Формат сценария ​

json
{
  "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
seeduint32, уезжает в 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ПоляДействие
joinwho, teamучастник заходит, под него создаётся настоящий ClientCore
leavewhoучастник выходит
keywho, action (down/up), nameHostGame.updateKeys плюс тот же ввод применяется к клиентскому ядру участника
aimwho, x, y, flags (по умолчанию 1)ввод указателем: HostGame.updateKeys строкой 'seq:aim:x:y:flags' плюс та же точка применяется к клиентскому ядру участника
chatwho, textHostGame.pushMessage (включая чат-команды)
votewho, dataHostGame.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 означает «в этом прогоне нечего проверять» и никогда не маскирует нарушение. Каждое нарушение — строка с именем клиента, ключа и значения.

№ИмяЧто ловит
1finiteValuesNaN/Infinity в декодированных полях и hot-буфере
2snapshotKeysUsedключ схемы не дал ни одной строки — «сущность не спавнится» либо несовпадение id ключа (намеренные случаи объявляются в unusedSnapshotKeys)
3fieldWidthsчисло декодированных полей ≠ числу полей схемы — уехала позиционная привязка в interpolator.rs
4frameFormatбайт версии кадра ≠ SNAPSHOT_FORMAT_VERSION либо падение decode_frame
5hotLayoutобход hot-буфера потребил не ровно len float'ов — дрейф ширины записи или порядка групп
6panelContractобъявленное поле panel не доезжает до клиентского конфига (значение приходит с именем undefined)
7renderCoverageживой ключ снапшота отсутствует в gameSets/entitiesOnCanvas — класс «чёрный холст»
8keyBindingsplayerKeys хоста ↔ keyset'ы клиента ↔ имена клавиш из сценария
9predictionDriftдрейф предикта выше порога (см. ниже)
10roundLifecycleраунд закончился, победитель назначен, респауны прошли, участники не утекли
11actorLeakplayers_data() расходится с активными участниками
12determinismдва прогона одного сценария дают одинаковый поток кадров — кадры сравниваются по хешу, и хеши собираются только под --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 движкового клиентского конфига (в боевом конфиге её нет, и тогда путь кадра не меняется вовсе):

json
{ "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!) вычерпывает буфер и отдаёт:

json
{ "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 в записи (естественный кандидат — контрольная точка хоста) — это отдельная задача.

Рекомендуемый порядок для нового плагина ​

  1. Собрать node-сборку ядра (core/pkg-node/) и указать на неё entries.wasmNode.
  2. npm run sim -- --game <пакет> --scenario <сценарий> — устранить все нарушения инвариантов. Большинство багов «чёрный холст»/«ничего не спавнится» умирает здесь, текстом, без браузера.
  3. Когда матч стабилен — добавить --determinism.
  4. И только потом открывать две вкладки ради ручного смоука; если там что-то не так, startRecording() → stopRecording() → npm run sim:replay возвращает случай на шаг 2.

← Предыдущая: Упаковка и сборка игрового плагина · Следующая: Ловушки и инварианты →

vimp-engine 0.38.1 · vimp-engine-core 0.23.2 · create-vimp-game 0.5.4