At a glance

Battle statefrom the library Retail: PCSX-Redux probe inject the cast at action state 0x0A, grab frames + per-vsync metadata Engine: corpus image child the state's fight, cast seeded, play-window recorder per tick Pair + score on the action clock and the module phase report segments, strip, cues
The driver scripts/ci/cast-sequence.py runs the three stages and caches each one until something it depends on changes. The scorer is legaia-engine cast-sequence-score (crates/parity); a disc-free test pins the pairing rule on synthetic recordings.

Running it

# first run: retail capture (an emulator run, a few minutes), engine seed, engine run, score
scripts/ci/cast-sequence.py --spell 0x86 --state party_basic_attack_vs_gobu_gobu

# after an engine change: re-run only the engine side and the score (seconds)
scripts/ci/cast-sequence.py --spell 0x86 --state party_basic_attack_vs_gobu_gobu --engine-only

# re-score cached recordings with another display-lag rule
scripts/ci/cast-sequence.py --spell 0x86 --state party_basic_attack_vs_gobu_gobu --score-only --retail-lag 4

Several casts go through cast-sequence-batch.py: the retail recordings side by side, then the engine runs one at a time. A retail recording is a headless emulator at normal speed, and two recordings of one cast are not byte-identical, so a residual smaller than two retail runs differ from each other is noise. The driver builds legaia-engine with the oracles feature, which the scorer and the recorder's frame shots need. Output lands under captures/cast-sequence/<state>_<spell>/, which is gitignored.

FlagEffect
--caster / --targetThe seat whose queue is rewritten and the seat it aims at. A caster seat of 3 or more is a monster.
--monster-pickWrite a monster's cast where its AI pick returns, so any battle state that reaches the monster's turn records any spell it owns a clip for.
--physicalFilm the strike a harvested state already holds instead of injecting a cast; --strike-deal stands the engine on retail's hand.
--hit-traceLog the rand() seed word every melee hit draws from on the retail side (breakpoint core), at the head of the strike kernel's hit body; --strike-deal then deals those words, blocked swings included.
--retail-stepsRun the engine on the recording's own frame steps, a disclosed harness seat that takes the frame's cost out of what is left.
--rand-at <state>:<phase>Hold the engine's rand() stream on retail's seed word under one cast key.
--rand-trackSeat every module-band pass on the rand() word retail's frame before it ended on, for an arm that draws on every pass.
--retail-partsLog every live move-VM part per vsync on the retail side, to read beside the engine's parts.csv.
--recapture / --reseed / --rerecordRedo one cached stage.

What each side records

  • Retail. The probe loads the state, taps Cross past any prompt, and on the caster's action state 0x0A writes the cast into its queue: past the command-flow validator, before 0x0C reads the category. Retail then pages the module in and runs it on its own bytes. From the cast begin (0x28) it grabs the displayed frame every few vsyncs and writes one metadata row per vsync: action state, module phase, acting seat, frame step, every seat's HP, the main loop's frame counter, the side-band token, the CD words and the rand() seed. Side files hold the caster's clip and pose, the camera and its tween table, and the summon creature.
  • Engine. The driver asks the corpus for the state's image child (the card-load resume, the battle row, the stage, the HUD bars, the seed), then re-runs it with the state's own drive and gate removed, the cast seeded and the recorder on. The child is tick-locked and deterministic, writes its frame cropped to the compared 320 × 224, and maps its seats back to retail's numbering. On the cast begin it seats the camera the state's own RAM holds (as the retail-compare seed read it), a harness seat that moves no game state, so the opening states score their own framing rather than wherever the child's camera happened to stand. The recording's first row is not that instant - the probe writes it after one vsync, which on a capture-band state can already hold a module pass - so it is only the fallback for a recording older than the report. The scroller rects a state carries (the phase of a texture an effect scrolls) are held only until the cast's first action pass, then left to roll: a shield Cort raised a turn earlier starts its sphere's bands where retail's stand.

Pairing frames on the game's clock

Frames pair on the action state machine's clock, never on wall time.

Retail 0x28 0x29 0x32 phase 1 phase 2 phase 3 0x51 Engine 0x28 0x29 phase 1 phase 2 phase 3 0x51 0x32 has no engine segment: a CD read the engine answers at once a segment is a run of one key; keys align by longest common subsequence
A retail frame k vsyncs into its segment, measured on the game clock, pairs with the engine frame k ticks into the aligned segment, clamped to that segment's last tick: an engine arm that ends early is compared at its last frame for the rest of retail's arm, which is what a viewer would see.
  • Display lag. A retail grab shows the frame built two game frames earlier, so it is keyed on the row two frame-counter bumps back; on an on-time frame that is 2 × step vsyncs. A frame that overran keeps its image up for every vsync it ran over, which a vsync rule would have scored against the wrong arm.
  • The game clock. Retail's frame driver picks a step from the slowest of the last sixteen frames, capped at 4, but a heavy effect frame can still cost 5 to 12 vsyncs. Its timers drain one step regardless, so on wall vsyncs a retail arm runs long while its game time does not. The probe's frame counter turns each run of one counter value into one frame of its step, and that clock is what the engine's lengths match: Gilium's summon band is 2423 wall vsyncs, 2240 on the game clock, and 2244 engine ticks.
  • The score. Each pair is scored with the corpus metric (within, the share of 8×8 blocks within 24 per channel). Pairs whose retail frame is a white flash or a fade are counted apart, and the cast's number is the mean over the rest.
  • The camera channel. The score writes camera.csv, every retail vsync paired with its engine tick, and cast-camera-rank.py ranks a directory of films by the camera error it holds for several rows, as pixels through the GTE projection: a raw column and a law column that allows one frame step of pacing.
  • The camera against the step. The camera files on both sides, read at the score's own pairs, are the camera's error frame by frame. Run beside --retail-steps, the two split it: what the seated run closes is the frame step's, what it leaves is the framing law's or the seat's.

The rand() stream

Retail draws every rand() from one seed word. The engine's world stream is the same generator, but it reaches a cast's arms after another number of draws, so an arm whose look is its draws plays another pattern: Gigaton Press picks each of its sixteen falling rocks by rand() % 4. --rand-at sets the engine's stream to the word retail held on the last vsync under one key, so the pass that leaves the key draws as retail's did; on a Gigaton Press capture the rocks' arm scores .770 with it and .649 without.

One held word deals one pass. Earthquake bursts a rock pair every frame off rand(), and retail's other passes draw tens of times where the engine draws a few, so --rand-track holds every band pass on the word retail's frame before it ended on. Van Saryu's Earthquake film goes from .808 to .943 and Scale Lizard's from .748 to .910; Death Game's cues from .800 to .970. It is a harness seat: a pass that draws in another order than retail's still parts (Fatal Decision drops .841 to .830).

The roll can decide the cast's sound as well as its look. Kiss of Death cues only on its hit leg: a film whose retail side missed recorded no cue, while the engine's own stream rolled a hit and played three. Held on retail's word with --rand-at, the engine misses too and plays none. That difference was the replay's stream, not the cast.

A monster's physical attack

--physical films the strike a harvested battle_*_strike state already holds. Neither side is steered: retail plays on from the state, and the engine child keeps the state's own pad drive and starts recording on its gate. Past the capture's instant nothing holds the two rounds together - the block roll, the next queue byte and later picks draw on each side's own stream - so --strike-deal stands the engine on retail's hand at the gate: a monster striker's queue, the approach terms, the striker's working ATK and the seed going into each hit, for a monster's strike or a party member's. Dealt that way, most strike recordings match retail cue for cue: block verdict, damage rolls, the flinch's impact pick and the times. With --hit-trace words the engine's block verdicts are retail's on every traced hit: Nova's five swings on Gala, Gaza's four Astral Slash beats and Vahn's Slash Kick. Since the state sits mid-glide wherever it was saved, a physical film seats retail’s first recorded camera row on its gate tick rather than letting the engine’s own round take the seat.

From the gate on, a strike film runs on the capture’s own frame-cost ring: a replay that reached the capture on the default step otherwise played the whole film at step two where retail’s full party runs three, and its camera yaw drifted two units a pass. A replayed cast keeps its pinned step, since its module arms run on their own step tables.

At the same gate every seat stands on the capture’s ground, its position and heading together: a striking monster re-faces each pass onto its target’s body, which the target’s heading turns, so a heading seated on a replay’s position swung the camera off retail’s.

The sound channel

Both sides also write one row per sound cue played: retail's from the cue ring sampled every vsync (an edge into "countdown zero, id not negative" is one cue), the engine's from the audio director's command record each tick. A retail cue is placed into engine time the way a frame is - its segment, its offset on the game clock - and matches the nearest unmatched engine cue of the same id within a tolerance; a second pass lists near misses as timing, the rest as missing or extra. CD-XA voice clips are not ring cues and are scored apart. The channel found, among others, the defeat cue the clip commit writes into the ring itself, a reacting seat the engine read as idle so Theeder's sweep struck it every pass, and Zenoir's opening hit landing on the wrong arm, and a Paralyzing Wave whose paralysed victim played a reaction (and its three cues) that retail's marking branch never stages.

The sampler has one blind spot: a cue pushed into the slot that held the same id a frame earlier reads as one cue, because the slot is cleared and refilled between two samples. Three homing landings of Kemaro's Lava Breath ring the impact cue three times, and the sampler counts two. A lone engine extra of that shape is checked against the --sfx-callers call log before it is read as an engine defect.

The level of the voice itself, rather than its timing, is measured against mednafen's audio output by the real-PCM cast channel; how a summon's voice waits for the CD drive is on the cast module page.

Waits the engine does not run

Some waits in a summon band are CD reads, which the engine's resident streams answer at once, so their segments part on length without any engine error. The pairing keys each segment on its own, so they do not shift the rest of the cast's pairs.

WaitWhat it pollsRetailEngine
State 0x32the read the cast trigger started in 0x29about 16 vsyncsone pass
The side-band armthe applier stage that streams the cast's slots of summon.dat28 to 30 vsyncs a slotheld for the same CD time
A stream-poll armthe creature's own reada few vsyncsone pass
Capture states 0x6E / 0x6Fthe queued read, then the module's own14..17 and 4..6 vsyncsheld 14 and 4 ticks

The capture band's two polls are held because neither idles: each pass walks a framing or pulls the shot depth in, so a tween opened two ticks early stands on another pose for its whole length.

CD-seek waits

A summon module seeks the CD drive to its voice clip on one arm and polls the seek on a later one. The seek arm blocks the whole game loop while the drive finishes the seek before it; the poll arm waits with the rest of the frame running. How long either takes is the drive's seek time, which varies across drives and across emulators. The engine keeps one model, fitted to mednafen, that holds the seek arm 23 to 36 frames and the poll arm up to 29 ticks; the PCSX-Redux films seek in 1 to 4 vsyncs. Paired strictly, that difference reads as engine error: Gimard's held frames put the rest of its arm 2 twenty-three ticks out of step.

The oracle accepts any wait inside a window instead: from no extra vsync up to the engine's own wait plus max(8, wait / 2) vsyncs. Inside it, the seek arm pairs on the engine's live ticks, retail frames of a wait arm the engine's drive answered sooner are counted apart, and each arm after the wait pairs on whichever of two clocks scores higher: its own arm clock, or the clock that ran on through the poll (a fade spawned before the wait stands that many ticks ahead in the engine). A retail wait outside the window, and every other segment, stays strict; --strict-drive turns the window off. The report states the wait, the window and the strict mean, so the tolerance is never silent. On the Seru films with drive arms the mean goes from .792 to .808; films with no drive arms are unchanged.

A monster cast from any battle state

A monster's AI pick and the use of its result run in the same pass, so no vsync hook can get between them. With --monster-pick the probe breaks just after the pick returns, where the queue it wrote is final and unread, and writes the cast there with the same two stores the AI's own cast arms make. Any battle state that reaches the monster's turn therefore records any spell the monster owns a clip for. The engine half hands the monster the round's first turn from the first prompt and stands each slot on the ground retail's earlier actions left. A monster target is re-keyed to the engine's slot numbering as the caster is: left on retail's number, Koru's self-cast Ultra Charge named no engine actor in a one-member party, and Vahn drew solid in front of Koru where retail ghosts him. A cast that wipes the party (Koru's Dead End Crisis on a lone Vahn) goes to the game-over flow with no Done band, so it has nothing to pair; filming it needs a state whose party outlives the cast.

Reading the report, and its limits

report.md opens with the mean, the minimum and the excluded counts, then the segment table (each key's retail length in wall vsyncs and on the game clock, the engine's in ticks, its pairs and mean), the worst pairs, the strip rows (retail | engine | |diff|) and the sound score. Lengths that part are a pacing defect; a one-tick engine segment against a long retail one is an arm the engine runs through without its countdown.

  • The fight is the state's. A state the corpus cannot image has no engine side.
  • The cast starts from the first prompt. Ground pairs and facings carry over (an earlier actor leaves its seat turned, and the range law measures a target by its turned body point: unturned, Gala stood out of Xain’s reach and Bloody Horns skipped its walk-in); status, HP already lost and the camera history an earlier action left do not, so the frames before the module owns the camera can part on framing alone.
  • Damage and rand() are not aligned unless --rand-at holds the stream on one key or --rand-track on every band pass, so popups and HP readouts can differ.
  • The target has to suit the spell. A heal aimed at a monster parks the module; the probe quits on a park and the driver reports it.
  • An item cast (the flutes) is not injectable, and a cast longer than the grab cap is cut unless the cap is raised.

See also