Replay: deterministic record & playback #4

Merged
mlangkabel merged 7 commits from feature/replay into master 2026-07-01 19:20:09 +00:00
Owner

Summary

Adds deterministic record/playback for a run. Recording captures (seed, config hash, ordered tick-tagged commands) and re-simulates on playback — no state snapshots. DotaFactory.exe --replay <file> re-plays a recorded run view-only with manual speed/pause.

Implemented phase-by-phase per docs/replay_design.md:

  • Phase 0 — Determinism foundation. FNV-1a Hasher, RNG fingerprint, per-subsystem/ECS computeStateChecksum, and a 2000-tick double-run determinism test.
  • Phase 1 — Command model + chokepoint. Command base + derived types, CommandManager (FIFO drain once per frame), and the single Simulation::apply mutation path. UI rewired to enqueue commands instead of mutating the sim directly.
  • Phase 2 — Recording. Line-oriented .replay file (header + tick-tagged commands + periodic/after-command RNG checksums), config hashing, ReplayRecorder.
  • Phase 3 — Playback. parseCommand, ReplayReader, ReplayPlayer (applies commands at their exact recorded tick, verifies checksums in file order, reports the first desync tick), --replay CLI, and a REPLAY/desync overlay.
  • Phase 4 — Closing tests + polish. Full-verb round-trip, desync detection, and a ~2400-tick through-combat equivalence test (byte-identical final state, no desync).

Compile-enforced command chokepoint

The Simulation player-action mutators (tryPlaceBuilding, demolish, applySchematicChoice) and the mutable subsystem accessors (buildingsMutable()/beltsMutable(); only the const buildings()/belts() stay public) are now private. Production's only handle to a subsystem is through Simulation, and every production accessor call is a const query — so gameplay is structurally locked out of any command-bypassing mutation path. Such code now fails to compile instead of silently desyncing replays.

Tests reach the private mutators through SimulationTestAccess (a friend struct under src/test, off the lib/ui/app include path), so they keep calling the real mutators and keep their return values. The BuildingSystem subsystem mutators stay public (unit-tested via a bare subsystem; unreachable from production anyway).

Verification

  • All targets build: lib, app, balancing, tests.
  • Headless suite green: 354 cases / 3418 assertions.
  • Live GUI replay verified: recorded a run and replayed it via --replay — reproduces correctly.

Notes

  • In replay mode the recipe/layout dialogs still open — this is intentional: it lets you inspect the player's choices during playback. Input that would mutate the sim is inert (no-op enqueue).
  • Deferred by design (out of scope): state snapshots, save/load, backward-seek/scrubbing, cross-platform float hardening, and expanding the file checksum beyond the RNG stream.

🤖 Generated with Claude Code

## Summary Adds deterministic record/playback for a run. Recording captures `(seed, config hash, ordered tick-tagged commands)` and re-simulates on playback — no state snapshots. `DotaFactory.exe --replay <file>` re-plays a recorded run view-only with manual speed/pause. Implemented phase-by-phase per `docs/replay_design.md`: - **Phase 0 — Determinism foundation.** FNV-1a `Hasher`, RNG fingerprint, per-subsystem/ECS `computeStateChecksum`, and a 2000-tick double-run determinism test. - **Phase 1 — Command model + chokepoint.** `Command` base + derived types, `CommandManager` (FIFO drain once per frame), and the single `Simulation::apply` mutation path. UI rewired to enqueue commands instead of mutating the sim directly. - **Phase 2 — Recording.** Line-oriented `.replay` file (header + tick-tagged commands + periodic/after-command RNG checksums), config hashing, `ReplayRecorder`. - **Phase 3 — Playback.** `parseCommand`, `ReplayReader`, `ReplayPlayer` (applies commands at their exact recorded tick, verifies checksums in file order, reports the first desync tick), `--replay` CLI, and a REPLAY/desync overlay. - **Phase 4 — Closing tests + polish.** Full-verb round-trip, desync detection, and a ~2400-tick through-combat equivalence test (byte-identical final state, no desync). ## Compile-enforced command chokepoint The `Simulation` player-action mutators (`tryPlaceBuilding`, `demolish`, `applySchematicChoice`) and the mutable subsystem accessors (`buildingsMutable()`/`beltsMutable()`; only the const `buildings()`/`belts()` stay public) are now **private**. Production's only handle to a subsystem is through `Simulation`, and every production accessor call is a const query — so gameplay is structurally locked out of any command-bypassing mutation path. Such code now fails to compile instead of silently desyncing replays. Tests reach the private mutators through `SimulationTestAccess` (a `friend struct` under `src/test`, off the lib/ui/app include path), so they keep calling the real mutators and keep their return values. The `BuildingSystem` subsystem mutators stay public (unit-tested via a bare subsystem; unreachable from production anyway). ## Verification - All targets build: `lib`, `app`, `balancing`, `tests`. - Headless suite green: **354 cases / 3418 assertions**. - **Live GUI replay verified**: recorded a run and replayed it via `--replay` — reproduces correctly. ## Notes - In replay mode the recipe/layout dialogs still open — this is intentional: it lets you inspect the player's choices during playback. Input that would mutate the sim is inert (no-op enqueue). - Deferred by design (out of scope): state snapshots, save/load, backward-seek/scrubbing, cross-platform float hardening, and expanding the file checksum beyond the RNG stream. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
mlangkabel added 7 commits 2026-07-01 19:15:07 +00:00
Captures the deterministic command-replay design (seed + config hash +
tick-tagged command log, re-simulated on playback), the command-chokepoint
and timing model, determinism/checksum strategy, file format, and a
sequenced Phase 0-4 implementation plan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DUsFgd2Ga6pmLz8giS8WUn
Introduces the state-checksum machinery the replay feature rests on, and a
test that proves the simulation is deterministic for a given seed/binary.

- StateChecksum: FNV-1a Hasher (bit-pattern float hashing, -0 normalization,
  length-tagged strings) plus a portable mt19937 state fingerprint.
- BeltSystem/BuildingSystem: appendChecksum(Hasher&) folding transport and
  building/site/occupancy state in deterministic (sorted/insertion) order.
- Simulation: rngFingerprint() (cheap, for the future file checksum) and
  computeStateChecksum() (full state: RNG, scalars, wave/schematic/unlock
  state, subsystems, and ECS position/health/facing/body/scrap/identity).
- DeterminismTest: Hasher unit tests, RNG-fingerprint tests, and a double-run
  test asserting identical per-tick full-state checksums from one seed; plus a
  different-seed divergence guard.

Exit criteria met: double-run determinism test passes; full suite green
(334 cases / 3346 assertions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DUsFgd2Ga6pmLz8giS8WUn
Reshapes every UI-driven sim mutation to flow through one path so it can be
recorded and replayed later, with behaviour unchanged.

- Command model (lib): Command base + derived types (PlaceBuilding, Demolish,
  RotateInPlace, SetRecipe, SetShipLayout, Set[Site]SplitterFilters,
  ClearBeltTiles, ApplySchematicChoice, Reset), each with a playerId for the
  future-multiplayer shape. PlaceBuilding is atomic (carries optional
  recipe/layout/filters) because deferred commands never return the new
  BuildingId to the caller.
- CommandManager (lib): FIFO queue holding a Simulation&; enqueue + drain.
- Simulation::apply(const Command&): the single chokepoint, dispatching by
  kind to the existing mutators. Mutators stay public (enforced by convention,
  not compile-time, so the test suite keeps driving the sim directly).
- Timing: GameWorldView owns the CommandManager and drains it once per frame in
  onFrame, before the tick batch (runs at 0x too, so build-while-paused is
  preserved). A drained Reset triggers the view reset.
- UI fan-in: GameWorldView enqueues its own input directly; MainWindow and
  SelectedBuildingPanel emit CommandRequestedEvent, which GameWorldView
  subscribes to and enqueues. No UI site mutates the sim directly anymore.
- CommandTest: asserts apply(...) yields byte-identical state to the direct
  mutator path, and that CommandManager drains FIFO through apply.

Full suite green (338 cases / 3353 assertions); determinism double-run still
passes. Design doc updated with the atomic-PlaceBuilding and convention-
enforcement decisions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DUsFgd2Ga6pmLz8giS8WUn
Hooks a recorder into the command chokepoint so each run is written to disk as
it plays, ready for playback in Phase 3.

- ReplayRecorder (lib): line-oriented append-friendly file — keyed header
  (version, build, seed, config_hash, timestamp), then '---', then tick-tagged
  command lines interleaved with "# checksum <tick> <hex>" RNG fingerprints.
  Each line is flushed so a crash leaves a valid partial file. Config hash is a
  64-bit FNV over the config dir's *.toml files; build tag is __DATE__/__TIME__.
- CommandSerializer (lib): per-command text (length-prefixed variable parts;
  ship layouts and splitter filters serialized inline). Reset is a file
  boundary, never a stream entry.
- CommandManager owns an optional ReplayRecorder: drain() records each applied
  command + a post-apply checksum; a drained Reset rolls to a new file;
  recordTickCheckpoint() (called per tick from onFrame) writes a checksum every
  30 ticks.
- Random seed generated in main and on restart (std::random_device); Simulation
  retains it via getSeed() for the header.
- GameWorldView attaches the recorder at construction (first file + tick-0
  checksum); replays land in <data>/replays named <timestamp>_<seed>.replay.

ReplayRecorderTest covers serialization, file well-formedness, file rolling,
and the CommandManager drain->record integration. Full suite green
(346 cases / 3377 assertions); app, tests, and balancing all build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DUsFgd2Ga6pmLz8giS8WUn
Adds view-only playback: re-simulate from the recorded seed + commands and
verify the RNG checksums.

- ReplayReader (lib): parses a replay file into header + an ordered stream of
  command/checksum entries. CommandSerializer gains the inverse parseCommand
  (round-trips every verb; rejects malformed input).
- ReplayPlayer (lib): the playback driver. Applies each command at its exact
  recorded tick and verifies checksums in file order (start() handles tick 0;
  advanceTo(tick) handles each tick after sim.tick()). Independent of
  replay-time speed/pause; reports the first desync tick.
- CommandManager replay mode: enqueue() becomes a no-op so live input is
  ignored while the recorded stream drives application.
- main.cpp: --replay <file> reads + validates (warns on version/config-hash
  mismatch), seeds the sim from the header, and threads the replay through
  MainWindow to GameWorldView.
- GameWorldView: drives the player in onFrame (manual speed/pause kept,
  forward-only), gates the schematic-choices and game-over polls, and draws a
  "REPLAY" tag plus a passive "Replay ended" / "Desync at tick N" overlay.
- computeReplayConfigHash factored out of ReplayRecorder for reuse by main.

ReplayPlaybackTest records a scripted run, reads it back, replays it, and
asserts no desync + byte-identical final state -- including the
periodic-checksum-then-command ordering at a shared tick. Full suite green
(350 cases / 3396 assertions); app, tests, and balancing all build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DUsFgd2Ga6pmLz8giS8WUn
- Round-trip: every command verb serialize->parse->re-serialize is identical;
  malformed input is rejected.
- Equivalence: add a long ~2400-tick run through waves/combat that records,
  reads back, and replays to a byte-identical final state with no desync
  (alongside the existing short scenario).
- Desync detection: corrupting one recorded checksum makes ReplayPlayer report
  the exact desync tick.
- Reset boundary: a Reset drained through CommandManager rolls the recorder to
  a new file named by the new seed.
- Polish: the end-of-replay / desync overlay dims the world behind the message.

Record + playback is now functionally complete and covered by headless tests.
Full suite green (354 cases / 3418 assertions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DUsFgd2Ga6pmLz8giS8WUn
Privatize the Simulation player-action mutators (tryPlaceBuilding, demolish,
applySchematicChoice) and the mutable subsystem accessors (now buildingsMutable/
beltsMutable; only the const buildings()/belts() stay public). Production's only
handle to a subsystem is through Simulation, and every production buildings()/
belts() call is a const query, so this fully locks gameplay out of any
command-bypassing mutation path -- such code now fails to compile instead of
silently desyncing replays.

Tests reach the private mutators through SimulationTestAccess, a friend struct
under src/test (off the lib/ui/app include path), so they keep calling the real
mutators and keep their return values -- no id-by-position recovery needed. The
BuildingSystem subsystem mutators stay public (BuildingTest unit-tests a bare
subsystem; unreachable from production anyway).

Upgrades the Phase 1 "convention only" decision to structural enforcement.
All targets build; 354 cases / 3418 assertions pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DUsFgd2Ga6pmLz8giS8WUn
mlangkabel merged commit d74ba5bfad into master 2026-07-01 19:20:09 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mlangkabel/dota_factory#4