Engine determinism + scripted-input replay
Play the engine with the recorder armed and every button press lands in a small, human-readable .toml text file. Hand that file back to legaia-engine replay and the engine re-runs the session frame for frame - the same inputs always produce exactly the same game, down to the last random number. That guarantee makes bug reports reproducible and regressions catchable: a disc-free test replays a canned input file twice and asserts the two state traces are bit-identical, so any change that sneaks in non-determinism fails CI.
At a glance
- Format
j-replay-v1TOML - sparse pad-mask events + optional[[expected]]checkpoints; parser/writer inlegaia_engine_shell::replay- Record
legaia-engine record- aplay-windowsession with a pad-capture hook armed- Replay
legaia-engine replay- headless, disc-free; emits the per-frame mode trace as JSONL- Gate
crates/engine-shell/tests/determinism_j2.rs- runs in CI withoutLEGAIA_DISC_BIN- Ladders
- Critical-path replay + chapter-1 scene frontier, each against a ratcheted baseline in
scripts/replays/ - Key drift signal
rng_state- the PsyQ PRNG's running state, digested every frame
What this solves
- Reproduce a bug - record the session that shows it, attach the
.toml, and anyone can replay it. - Turn a session into a regression fixture - add
[[expected]]rows andreplay --strictfails the moment the engine stops reaching the same scene on the same frame. - Measure how far the engine can be played with the pad as the only actuator - the two ladders below score chapter 1 that way.
j-replay-v1 TOML: meta + sparse pad events + optional checkpointsreplay.rs
3Replaya synthetic World ticks once per frame; per-frame mode trace out as JSONLlegaia-engine replay
4Assert--strict vs [[expected]]; the gate runs the file twice and diffs the tracesdeterminism_j2.rs
Quick start
legaia-engine record --out my.replay.toml --disc "Legend of Legaia (USA).bin" --scene town01legaia-engine replay --input my.replay.toml --out trace.jsonllegaia-engine replay --input my.replay.toml --strictPlay in the window; Escape or closing the window flushes the file - a mid-session close still produces a usable replay. --strict exits non-zero on the first divergence from the file's [[expected]] rows; no disc is needed for replay.
File format (j-replay-v1)
A replay is plain TOML: a [meta] header, a sparse list of controller events (the PSX pad's buttons packed into one 16-bit mask; only frames where the mask changes are stored), and optional expected checkpoints.
[meta]
schema = "j-replay-v1"
scenario = "title_attract" # optional; resolves into scripts/scenarios.toml
rng_seed = 0xDEADC0DE # initial RNG seed (battle_formulas PsyQ PRNG)
frames = 600
[[event]]
frame = 42
pad = 0x4000 # Cross pressed
[[event]]
frame = 44
pad = 0x0000 # released
[[expected]] # optional regression fixture; compared by
frame = 600 # frame value, not slice index
scene_mode = "Field"
active_scene = "town01"
| Button | Mask | Button | Mask |
|---|---|---|---|
| Cross | 0x4000 | Up | 0x0010 |
| Circle | 0x2000 | Down | 0x0040 |
| Left | 0x0080 | ||
| Right | 0x0020 |
Pad bits match legaia_engine_core::input::PadButton::mask, stored as a plain u16 so the on-disk form stays byte-readable. The dense per-frame stream is reconstructed by ReplayFile::expand_pad_stream - the mask in slot N is the mask in force on frame N. ReplayFile::validate rejects schema mismatches, out-of-order events, and frame indices past meta.frames; writers emit events in frame-ascending order, readers don't sort.
The two subcommands
replay drives a synthetic World from the file and emits the per-frame mode trace as JSONL (same shape as legaia-engine mode-trace). Every argument is a flag - --input is not positional - and --out defaults to stdout. The driver mirrors the determinism-gate harness: World::new + an 8-slot actor pool, RNG seeded from meta.rng_seed, ticked once per replay frame.
record is a thin wrapper over play-window: assets come from --extracted-root (default extracted) unless --disc supersedes it; --no-audio, --world-map and --save-dir behave as on the host it wraps. Every keyboard transition that changes the pad mask is appended to a RecordLog; auto-repeat deduplication collapses identical-mask press streams; meta.frames reflects the actual session duration.
Interactive-toggle caveat. j-replay-v1 captures the pad stream only - the camera distance preset, left-mouse drag-orbit and the precise-movement toggle are not recorded. The defaults are safe: distance and orbit are pure render framing, and replays run with precise_movement off. A session recorded with precise movement ON, or a non-zero drag-orbit compass, is not replay-stable; keep the toggles at their defaults when capturing fixtures.
Determinism gate
determinism_j2.rs drives a synthetic World twice through the same ReplayFile and asserts the per-frame state-trace bytes are bit-identical. The digest covers frame, scene_mode, pad, rng_state (the single most important drift signal), money, party_hp_total and dialog_active. Three companion tests double-lock it: a different pad stream produces a different trace, a different RNG seed produces a different trace, and an [[expected]] fixture round-trips through ReplayFile::diff.
The replay format is a peer to the parity oracles: vram_oracle_e1 compares engine VRAM against retail captures, mode_trace_e3 compares engine (scene_mode, active_scene) per frame against retail snapshots, and determinism_j2 compares engine traces against themselves - the disc-free side of the stack. Recorded replays bind a scenario label in meta.scenario, pairing a session back to its retail starting state via scripts/scenarios.toml; the v0_1_playthrough oracle composes a disc-free determinism check with a disc-gated convergence check over a cold boot into a live field scene.
Critical-path replay: the game-denominated sibling
Every other progression oracle moves the player with seat_player_at_tile - a teleport that synthesises the tile crossing. That answers does the scene graph connect, and is structurally blind to locomotion speed, the collision probe, the walkability grid, and the camera-relative pad remap. crates/engine-shell/tests/critical_path_replay.rs drives the chapter-1 spine with the pad as the only actuator and scores how far it gets, against scripts/replays/critical_path_baseline.toml. Nothing is seated.
| Design rule | Why |
|---|---|
Waypoints from a BFS over the walkability grid, edges certified by field_dir_blocked, lattice pitch 32 | The probe reaches only ~47 units ahead, and a tile centre is 128t + 64, so the pitch must divide 64 or the planner can never stand on a doorway's centreline. |
| The goal is best-effort: route to the closest reachable node, press from there | A scene exit is a door, and a door tile reads as a wall. A leg succeeds on the transition firing, not on arrival. |
| Hazard tiles are unsafe to enter, never unsafe to occupy | Retail's dispatcher fires on a tile change; a planner that tests only the destination refuses every step out of the tile it starts inside. |
| A dungeon leg is scored by door, not by event | Every keikoku exit returns to map01; only the arrival coordinate (from the .MAP gate-1 triggers joined to partition-2 records) names which door fired. |
| Scripted sequences are drained by pulsing Cross (press 2, release 14) | The advance is edge-triggered - a held button pages once; a neutral pad drains a timeline but not a pager. |
| A stall is self-describing: tile, position, next waypoint, both wall arms separately | "The player stopped here" and "the engine says every direction is a wall" are different findings. |
The pad-inversion arithmetic, the door-identity clause and the baseline parser are covered by disc-free unit tests, so the file stays non-vacuous in CI where the ladder skips.
History: the first stall, and the hazard set that sealed a dungeon
The first run's stall - Rim Elm's south gate - found a defect every seated oracle is blind to, and its first two diagnoses were both wrong, which is the argument for self-describing stalls. On keikoku, a planner avoiding "trigger tiles within 12, seat tile included" reached one sub-cell; dropping the seat tile reached one chamber; avoiding only the arrival record's band reached the dungeon - the self-block and an over-wide radius were each seals in their own right. The rung's first draft also passed on a backed-out leg (aiming 47 tiles away, walking two, reporting a clean transition); LEGAIA_CPR_RUNG5_BACKOUT=1 re-aims the leg at its own entrance to demonstrate the rung now reads unclear.
Scene-frontier ladder: the breadth-denominated sibling
The critical-path ladder walks one route well; crates/engine-core/tests/chapter1_frontier_ladder.rs enumerates the whole chapter-1 reachable scene set and gives every member a verdict, against scripts/replays/chapter1_frontier_baseline.toml - because "the engine cannot get past the Ravine" and "no fixture drives past the Ravine" read identically until something scores the scenes one at a time. The set is the BFS closure of town01 over each scene's decoded 0x3F destinations, stopped at the kingdom boundary; scenes reached by the sibling 0x3E door warp are outside it. Six rungs per scene, ordered and cumulative:
| Rung | Verdict |
|---|---|
| 1 | the assets resolve |
| 2 | the MAN parses |
| 3 | SceneHost enters it |
| 4 | the entry script settles and hands control back |
| 5 | pad-only input displaces the player (scored against a released-pad control run) |
| 6 | an exit record fires and lands in the scene it names |
A scene whose script leaves on its own is marked not-applicable on the last two rather than failed. Failures are self-describing: a script park reports (pc, opcode) off the live field VM, a locomotion stall reports the tile. Two guards decide what the numbers mean: "control released" waits for spawned first-visit records too, not only cutscene timelines; and the locomotion rung needs a released-pad control, because without one "the player walked" and "a script moved the player" are the same measurement.
Where the frontier stops: the one-way scenes
Every scene in the closure loads, enters and settles; the remaining stops are all about doors. The five that once read as sealed (uru, urudre1..3, jouine) were the ladder measuring its own caps: the tile sweep stopped at 48 deduplicated tiles while uru's exit band is the 64th of its 118, the post-step budget was 24 ticks while one record alone waits 360 frames before its 0x3F, and watching only for a scene change could never see an FMV tail. With the caps removed the ladder now drives all five out through their .PCH-carried bands, each to the destination the disc names: uru to map03, urudre1 and urudre3 back to uru, urudre2 to map01, and jouine to town0e by way of movie 8. urudre2 was the last to fall, and what held it was not a cap: its exit is the tail of a 4703-byte dream cutscene, and about 0x670 bytes before that tail the record carries a camera-apply op the port read as an absolute jump to its operand. Retail's arm is a four-byte fall-through and the operand is the apply trigger, so a trigger of zero restarted the record and the conversation replayed forever. See the world-map page.