Gate replay
A pad recording made on the port cannot be played blind on the original game for long. The two sides reach a door, or close a page of dialogue, a few frames apart; every later press then lands somewhere else, and the two runs part for good. The gate replay plays the port's own pad on retail one beat at a time and lines the two back up at every beat's end, so a disagreement stays inside the beat that caused it and can be measured there.
At a glance
- Question
- Given the same hands on the pad, where do the port and retail spend a different number of frames, stand in a different place, or draw a different number of random values?
- Engine side
- One line a session tick from
LEGAIA_DIAG_TICK_DIGEST(crates/engine-session/src/host_trace.rs), taken from a full-game ladder segment's pad pass - Retail side
scripts/pcsx-redux/autorun_gate_replay.luaunder PCSX-Redux, started from a retail save state- Report
scripts/ci/gate-replay.pycuts the beats, runs the probe and prints the table. It gates nothing, like the walk film- Output
captures/gate-replay/. It is read from retail RAM and stays out of the repository
LEGAIA_FGL_ONLY=tetsu_spar LEGAIA_FGL_TRACE=1 LEGAIA_FGL_PIN_RETAIL=1 \
LEGAIA_FGL_RNG_SEED=0x1a1193df LEGAIA_DIAG_TICK_DIGEST=/path/digest.txt \
XMODIFIERS=@im=none cargo test -p legaia-engine-shell --profile release-test \
--test integration full_game_ladder::part_b_ -- --nocapture --test-threads 1
scripts/ci/gate-replay.py s3_rimelm_freeroam --digest /path/digest.txt --until 3700
- Start both on one random stream. Read the state's seed word at
0x80009010and pass it asLEGAIA_FGL_RNG_SEED, or the draw columns compare two different deals. - Run the ladder as
full_game_ladder::part_b_. The digest then holds the seated and pad passes of the one segment, and its last session is the pad pass. The unfiltered name also runs part C and leaves gigabytes of sessions ahead of it. - A walk through encounter country parts at the first fight only one side rolls. To measure the field between fights, take the digest with
LEGAIA_FGL_NO_ENCOUNTERS=1and pass--no-encounters, which holds retail's setting byte0x8007B5F8at the value where the encounter routine skips its roll.
Beats and gates
A gate is a change of state both sides can see in their own memory: a script taking hold of the player, a battle opening, a dialogue page finishing its typing. A beat runs from one gate to the next. The engine's run is cut into beats first; retail then plays each beat's pad and the probe waits for the same gate before the next beat starts.
| Gate | On the engine | On retail |
|---|---|---|
engaged=0/1 | A script context holds the player | Bit 0x80000 of the player record's flags at +0x10 |
battle=0/1 | The world's mode is battle | The mode byte 0x8007B83C reads 0x15. The battle context pointer outlives the fight, so it says nothing |
scene=NAME | The active scene label | The scene name at 0x8007050C |
warp | The player moved over 300 units in one tick | The same, between two vsyncs |
page=N | The open box's window reaches state 0x19 | The pager state _DAT_801F2734 reaches 0x19; pages are counted, not timed |
pos=X/Z | A turn in a walk: the d-pad changes to another held direction, settled for 8 ticks, at least 96 units from the last turn | The player comes within 24 units of the spot, passes it, or is engaged first |
flow=XX:N, act=XX:N | The Nth entry into a battle command surface, or the action start | The same bytes, ctx[+0x06] and ctx[+0x07] |
- Walks are gated on position, not time. A retail walk and an engine walk start a pass of position apart, and a door or a ledge fires on where the player stands. Each turn is therefore a gate on the spot, and the next leg's pad starts where retail really is.
- Levels, not edges. Where a gate is a level (engaged, battle, scene), retail matches the level, so two gates the sides reach in the other order read as a zero-length beat and not a stall. Pages, prompts and action starts are ordinals, so a page only one side shows costs one beat and not the rest of the run.
- A door holds retail's engaged bit from its trigger to its landing, where the engine's word stays clear. A retail engaged interval that contains a warp is that hold and does not satisfy an
engaged=1gate. - Field gates are not cut inside a battle, where the field player record says nothing. An engaged run shorter than three ticks is a script pulse and is ignored (
--min-dwell). - The press that moves a battle surface closes its own beat. The engine's flow byte changes in the row of the press that caused it, so such a gate ends one tick later and keeps the press: it is what the beat repeats if it does not land.
Replaying a beat
Each beat's pad rows are timed from its opening gate. A press is held at least two ticks (--min-press), because a one-vsync press can fall between two of retail's game passes and be lost, and a widened press keeps one released tick after it so a run of pulses stays separate edges. What happens at the closing gate depends on who got there first:
| Case | What the probe does | Where it shows |
|---|---|---|
| Retail reaches the gate first | Ends the beat and drops its remaining rows | drop counts the Cross presses among them |
| Retail is late | Waits. A walking player keeps the beat's last direction held; an engaged player and a battle hold nothing | wait |
| A walk closed by a door or a talk comes up short | Steers to where the engine stood when the gate fired, then keeps pressing the last direction | synthetic_seek and synthetic_push rows; synth |
Retail is still waiting on an engaged beat or a battle prompt after --synth-after vsyncs | Gives a two-vsync Cross every 30 vsyncs until the gate comes. The usual cause is a page the replay's presses did not close | synthetic_cross rows; synth. A finding, not a repair |
| A battle press did not take | Repeats the beat's last logged press while the command flow sits on a byte that waits for input, at most --repulse-max times | synthetic_repulse rows; synth |
| The gate never comes | Stops after --max-wait vsyncs | Status timeout; everything after is unmeasured |
Synthetic input is input the engine's log did not supply. Every one is a row in gate_events.csv with the press and what it found, so a run that needed help says where. Nothing is pressed while the action state machine owns the frame, or on a flow byte no surface reads the pad on.
Reading the table
| Column | Meaning |
|---|---|
E, R, gap | The beat's length in engine ticks, in retail vsyncs, and R - E |
wait, drop, synth | Vsyncs retail waited past the pad, Cross presses dropped, synthetic inputs |
pos diff | Retail minus engine player x,z at the closing gate; - in a battle |
E rng, R rng | Draws of the rand() generator over the beat. A +Nj suffix counts values the generator cannot reach from the last one, which is a reseed or a stomp |
A beat with a small gap and a matching position says the two sides agree there, whatever came before it. That is what the resynchronisation buys. A second table, page waits, has a row per page gate: when each side's pager finished the page, their difference (which carries the drift of the beats before it) and the part this page added.
What the Rim Elm run shows
The first run replays the walk to Tetsu, the talk and the sparring fight from a Rim Elm free-roam state. With every surface gated, the fight replays to its end.
- Engaged holds. Retail holds the player's engaged bit across a door's whole trigger-to-landing run, and across the talk to Tetsu from its first page to its last.
- The fight's opening. Retail's command flow walks
0xFD,0x00,0x0A,0x0B,0x14,0x1Ebefore the first round prompt. The action state machine passes through0x5Aafter every action, between two actors of one round as well as at a round's end, so the prompt is gated on the command flow's0x1E. - One-vsync presses get lost. With one-tick presses two of the talk's page turns wait on a synthetic Cross and the first round prompt ignores six logged pulses; with two-tick presses the same turns close on the logged presses.
- The ring. Up in the command ring opens the item window on retail as on the engine. Both sides ignore the ring for about 16 ticks after entering it, and a mash merged into one solid hold gives retail a single edge that lands before the ring shows.
- Every surface holds the pad for its glide. The engine dispatches on the eighteenth tick after the flow byte moves and retail about two vsyncs later, on its two-vsync pass:
| Surface entered | Engine ticks to the next surface | Retail vsyncs to the next surface |
|---|---|---|
0x1E round prompt, 0x28 ring | 17, 18 | 20, 20 |
0x78, 0x5A, 0x6E | 18, 18, 18 | 20, 20, 20 |
0x3C window, 0x64 target confirm | 18, 18 | 20 to 22, 20 |
0x50 arts entry | 18 | 20 |
0xFE armed, to the action start | 18 | 20 |
- Where the actions differ. A round's actions run the same states on both sides with the same dwell except at the close of each:
0x51holds 62 ticks on the engine against 76 vsyncs on retail, and retail then walks0x5Awhere the engine walks0x00. The item band and the Spirit band are shorter on the engine. - The boxes that close themselves. Retail's first-round box and its sign-off box wait for a press and otherwise close on their own hold: with no Cross offered, retail spends 364 vsyncs in
0x14and 427 in0xC8before the fight closes. A press skips the long hold on both sides. - The hits are one kernel. The thirteen melee hits of this fight, fed through the port's
physical_rollswith retail's stats and retail's ownrand()draws, give retail's raw and guarded damage on every hit. The two sides' damage differs only where the rolls and swing counts differ, and those follow the random stream, which parts in the first round.