# Детерминированная запись и воспроизведение (record / replay)

Цель: **сделать игровые баги воспроизводимыми.** Баг-репорт должен превращаться
в один файл, который проигрывается тем же движком и приводит мир в то же
состояние.

Статус: план (фаза 5 [ROADMAP.md](ROADMAP)). Существующая JS-подсистема
описана в [highlevel/replay.md](highlevel/replay); этот документ задаёт
требования к записи на уровне движка и CLI.

---

## 1. CLI

```bash
r2d --game <каталог> --record bug.r2replay
r2d --game <каталог> --replay bug.r2replay
```

Замечание о текущем CLI: точка входа задаётся **каталогом** (`--game <каталог>`,
по умолчанию `game` → `<каталог>/main.js`); позиционный аргумент вида `game.js`
сегодня не поддерживается — неизвестная опция вызывает предупреждение
([main.c:577-597](https://github.com/Nikide/russiano2d/blob/main/src/main.c)).

**Реализовано (2026-10-07):** `--record <файл>` и `--replay <файл>`
([main.c](https://github.com/Nikide/russiano2d/blob/main/src/main.c), [replay.c](https://github.com/Nikide/russiano2d/blob/main/src/replay.c)). Запись снимает ввод
кадра («эффективное» состояние, которое увидела игра), воспроизведение
подставляет его как виртуальный ввод до начала кадра — тот же путь, которым
пользуется агентский протокол. Вместе с `--agent --headless --fixed-dt --seed`
это даёт воспроизводимый сценарий для теста.

---

## 2. Что должна захватывать запись

Как минимум, где применимо:

* версию движка;
* идентификатор игры/проекта;
* конфигурацию фиксированного шага;
* зерно ГПСЧ;
* события ввода;
* релевантную конфигурацию рантайма.

**Не сериализовать весь мир каждый кадр.** Запись — это вход в симуляцию, а не
снимок состояния (снимок — это `$.prefab`/`$.save`).

---

## 3. Детерминизм

Воспроизведение полезно, только если эквивалентное начальное состояние плюс
записанный вход дают эквивалентную симуляцию. Системы, вносящие
недетерминизм, должны быть **выявлены**.

Источники-кандидаты: настенные часы; неупорядоченный обход; случайные значения
вне движкового ГПСЧ; асинхронная загрузка ресурсов; платформенно-зависимая
арифметика с плавающей точкой.

Идеальная кросс-платформенная побитовая детерминированность **не требуется**
для первой реализации. Начинать с детерминированного воспроизведения в
пределах одной поддерживаемой конфигурации рантайма/платформы.

---

## 4. Проверка

Воспроизведение **должно** поддерживать необязательные чекпоинты:

```text
frame 120:
    #hero.position ~= [420, 200]
    #hero.health == 75
```

Хеширование выбранного состояния мира **может** использоваться для
автоматических регрессионных тестов.

---

## 5. Формат файла

Формат обязан быть:

* версионированным;
* распознаваемым вперёд (файл новой версии явно отвергается, а не читается
  «как получится»);
* с понятным отказом на несовместимую версию.

Непрозрачный формат без заголовка версии не допускается.

**Реализовано:** текстовый формат JSON-строк, по строке на кадр
([replay.c](https://github.com/Nikide/russiano2d/blob/main/src/replay.c)). Первая строка — версионированный заголовок:

```json
{"r2d_replay":1,"version":"0.1.15","game":"game","fixed_dt":0.0166666675,"seed":777}
{"f":0,"keys":[4,20],"mx":100.0,"my":200.0,"mb":0,"wheel":0.0}
{"f":1,"keys":[],"mx":100.0,"my":200.0,"mb":0,"wheel":0.0,"dx":12.0,"dy":-3.0}
```

* `r2d_replay` — версия формата; файл с другим числом отвергается целиком с
  понятной ошибкой (`replay: несовместимая версия записи (N, нужна 1)`);
* `version`, `game`, `fixed_dt`, `seed` — то, без чего воспроизведение не
  совпадёт (версия движка берётся из сборки, см. §9);
* кадр: номер, список удерживаемых скан-кодов, позиция мыши, маска кнопок и
  колесо. Клавиш в кадре — не больше 64 (больше одновременно не нажимают);
* `dx`/`dy` — относительное движение мыши за кадр (взгляд мышью в Re2D). Поля
  необязательны и пишутся только когда движение было: записи 2D-игр остаются
  побайтово прежними, а старые записи читаются как «движения не было»;
* чего в записи **нет**: касаний, геймпадов, текста IME и содержимого окон —
  честное ограничение первой версии, а не забывчивость.

Чекпоинтов (утверждений вида `frame 120: #hero.position ~= [420, 200]`) в
формате пока нет: состояние сверяет игра — `$.replay.verify` для ввода и
`$.test.near/equal` для мира ([TESTING.md](TESTING)).

---

## 6. Критерии приёмки

Записать тестовый сценарий: запуск сцены, перемещение игрока, действие,
продвижение на N кадров, остановка записи. Воспроизвести его. Важное
проверяемое состояние должно совпасть в задокументированных допусках.

---

## 7. Что уже есть в движке

| Возможность | Статус | Где |
|---|---|---|
| CLI `--record <файл>` | есть | [main.c](https://github.com/Nikide/russiano2d/blob/main/src/main.c), [replay.c](https://github.com/Nikide/russiano2d/blob/main/src/replay.c) — `r2d_replay_record` |
| CLI `--replay <файл>` | есть | там же — `r2d_replay_play`, подстановка через виртуальный ввод |
| Версионированный формат | есть | `{"r2d_replay":1, …}`, чужую версию отвергаем с объяснением |
| Версия движка, игра, шаг, зерно в заголовке | есть | `version`, `game`, `fixed_dt`, `seed` |
| Запись ввода по кадрам | есть (JS) | `$.replay.record(sampler, apply)` — [replay.js:276-357](https://github.com/Nikide/russiano2d/blob/main/src/highlevel/replay.js) |
| Воспроизведение | есть (JS) | `$.replay.play(apply?)` — [replay.js:151-169](https://github.com/Nikide/russiano2d/blob/main/src/highlevel/replay.js) |
| Формат | JSON-текст, не `.r2replay` | `{ header: {seed, dt, frame, frames}, frames: [[f, d], …] }` — [replay.js:201-206](https://github.com/Nikide/russiano2d/blob/main/src/highlevel/replay.js) |
| Заголовок с зерном и шагом | есть | `seed: engine.seed`, `dt: engine.fixedDt` — [replay.js:240-252](https://github.com/Nikide/russiano2d/blob/main/src/highlevel/replay.js) |
| Лимит записи и счётчик потерь | есть | `MAX_FRAMES = 36000`, `dropped()` — [replay.js:27](https://github.com/Nikide/russiano2d/blob/main/src/highlevel/replay.js), [:93-97](https://github.com/Nikide/russiano2d/blob/main/src/highlevel/replay.js) |
| Сравнение двух записей | есть | `compareReplays(a, b)` → `{ same, count, first }` — [replay.js:227-235](https://github.com/Nikide/russiano2d/blob/main/src/highlevel/replay.js) |
| Проверка ввода | есть | `verify(sampler)` — [replay.js:310-320](https://github.com/Nikide/russiano2d/blob/main/src/highlevel/replay.js) |
| Мир не сериализуется по кадрам | соблюдено | лимит кадра 4096 символов — [replay.js:28-29](https://github.com/Nikide/russiano2d/blob/main/src/highlevel/replay.js), [highlevel/replay.md](highlevel/replay) §3 |
| Детерминированный прогон | есть | `--fixed-dt` ([app.c:688-693](https://github.com/Nikide/russiano2d/blob/main/src/app.c)), `--seed` ([main.c:552-553](https://github.com/Nikide/russiano2d/blob/main/src/main.c)), фиксированный шаг физики 1/60 ([main.c:217-226](https://github.com/Nikide/russiano2d/blob/main/src/main.c)) |
| Управляемый ввод | есть | виртуальный ввод агента ([app.c:670-684](https://github.com/Nikide/russiano2d/blob/main/src/app.c)), команды `key`/`keys`/`touch`/`pad`/`mouse`/… |
| Тесты воспроизведения | есть | [tests/agent/highlevel_replay_test.py](https://github.com/Nikide/russiano2d/blob/main/tests/agent/highlevel_replay_test.py) (seed 777, fixed-dt 1/60), [tests/js/replay_test.mjs](https://github.com/Nikide/russiano2d/blob/main/tests/js/replay_test.mjs) |

**Чего нет:**

* CLI `--record` / `--replay` — опций нет в разборе аргументов
  ([main.c:529-580](https://github.com/Nikide/russiano2d/blob/main/src/main.c));
* версии формата: `load()` проверяет только `Array.isArray(obj.frames)`
  ([replay.js:122-125](https://github.com/Nikide/russiano2d/blob/main/src/highlevel/replay.js));
* версии движка, идентификатора игры и конфигурации рантайма в заголовке
  (игра может дописать своё через `start(extra)`, но это необязательно);
* чекпоинтов состояния и хеша мира;
* команд реплея в агентском протоколе — сценарий разыгрывается вручную
  ([highlevel/agent.md](highlevel/agent) §4);
* сериализации самого процесса (перед `play()` мир возвращает в начальное
  состояние сама игра).

---

## 8. Найденные источники недетерминизма

| Источник | Где | Комментарий |
|---|---|---|
| Часы кадра | [time.js:30](https://github.com/Nikide/russiano2d/blob/main/src/highlevel/time.js) → [app.c:695-700](https://github.com/Nikide/russiano2d/blob/main/src/app.c) | в `--fixed-dt` реальные часы не читаются ([app.c:688-693](https://github.com/Nikide/russiano2d/blob/main/src/app.c)) |
| `engine.now()` (wall clock) | [script.c:319-330](https://github.com/Nikide/russiano2d/blob/main/src/script.c) | используется профайлером; игровой логике не нужен |
| `Date.now()` в `$.task` | [task.js:48](https://github.com/Nikide/russiano2d/blob/main/src/highlevel/task.js), [:66](https://github.com/Nikide/russiano2d/blob/main/src/highlevel/task.js), [:106](https://github.com/Nikide/russiano2d/blob/main/src/highlevel/task.js) | можно подменить через `spec.now` |
| `Date.now()` в метаданных `$.save` | [save.js:443](https://github.com/Nikide/russiano2d/blob/main/src/highlevel/save.js) | метаданные, не логика |
| Задержка сети на `SDL_GetTicks()` | [net.c:274](https://github.com/Nikide/russiano2d/blob/main/src/net.c), [:382](https://github.com/Nikide/russiano2d/blob/main/src/net.c) | потери и джиттер детерминированы от сида, время отправки — нет |
| `Math.random()` в демо | `demos/shooter_witch/index.js` (27 вызовов) | демо невоспроизводимы; страж `tests/duplicate_keys_test.py` проверяет только `src/highlevel` |
| Несверка номеров кадров | [replay.js:151-169](https://github.com/Nikide/russiano2d/blob/main/src/highlevel/replay.js) | `tick()` отдаёт кадры по порядку записи, а не по `engine.frame` |

Обход в C недетерминированным не найден: циклы в `physics.c`/`render.c` идут по
индексам, движковый ГПСЧ в C не заводится.

---

## 9. Дефекты, закрытые перед записью версии в заголовок

1. **Версия движка в бинарнике отставала от проекта** — теперь строка одна на
   проект: `R2D_VERSION_STRING` приходит из CMake (`PROJECT_VERSION`,
   [src/CMakeLists.txt](https://github.com/Nikide/russiano2d/blob/main/src/CMakeLists.txt)), запасное значение —
   `"0.0.0-dev"` для нестандартных сборок ([r2d.h](https://github.com/Nikide/russiano2d/blob/main/src/r2d.h)). Раньше в
   коде была зашита `"0.1.0"` при проекте `0.1.14`, и эта строка шла в usage,
   заголовок окна, событие `ready` агента и User-Agent HTTP.
2. **Зерно в заголовке не совпадало с фактическим** — `--seed` по умолчанию
   равен `12345`, то есть `DEFAULT_SEED` из `$.random`
   ([main.c](https://github.com/Nikide/russiano2d/blob/main/src/main.c), [random.js](https://github.com/Nikide/russiano2d/blob/main/src/highlevel/random.js)). Раньше
   без `--seed` движок отдавал `engine.seed = 0`, а генератор работал от 12345.

Осталось сделать в самой записи: версию формата, идентификатор игры и чекпоинты
(§2, §5).

---

## 10. Ограничения (честно)

* Реплей воспроизводит ровно то, что записано: зависимость симуляции от
  чего-то вне записи (часы, сеть, невиртуальный ввод, `Math.random`) разойдётся
  молча ([highlevel/replay.md](highlevel/replay) §4).
* Предел записи — 36 000 кадров (10 минут при 60 к/с); дальше кадры
  учитываются в `dropped()`, а не теряются молча.
* Мир не восстанавливается автоматически: перед `play()` игра сама возвращает
  начальное состояние.
* Кросс-платформенная побитовая идентичность не обещается.

---

## 11. Связанные документы

[highlevel/replay.md](highlevel/replay) — текущее API `$.replay`,
[TESTING.md](TESTING) — как реплей используется в тестах,
[AGENT_API.md](AGENT_API) — виртуальный ввод и `step`,
[ROADMAP.md](ROADMAP) — фаза 5.
