How it works

Nothing here runs an emulator. A save state’s RAM holds every retail observable the corpus compares, and its VRAM holds the frame that was on the TV - the display registers say which rectangle - so retail’s side is read offline. The engine’s side is a real session seeded through the port’s own card-load path and ticked forward.

Library save statemednafen / PCSX-Redux Retail RAMscene, camera, party, flags Retail VRAMcropped displayed frame Seed the enginecard-load resume + seat Settle or run to phasescript / battle / menu gate Score each channel0..1 per channel Reportworst state first Ratchetno channel may drop observables
Retail is read offline from the state; the engine is a real session. A state the engine cannot be seeded into stays in the report with its reason, so the summary counts what the instrument cannot reach instead of dropping it.
Corpus and channels
parity::retail_compare - enumerate the library, read retail, seed the engine, score
Battle half
parity::retail_compare_battle - read the encounter out of a battle state, enter it, score the battle channels
Script phase
parity::retail_compare_script - read the running field contexts, run the engine to the same script phase
Frame channel
parity::retail_compare_image - crop retail’s frame, render the engine’s, compare
Driver
scripts/ci/retail-compare.py, over the legaia-engine retail-compare subcommand
Ratchet
retail_compare_corpus (disc-gated test) against scripts/ci/retail-compare-baseline.json - scores and classes only, no pixels, no RAM

Running it

scripts/ci/retail-compare.py --filter town01
FlagDoes
(none)the state channels; report under captures/retail-compare/
--imagesadds the image channel - renders real frames, so it needs a display
--checkasserts the baseline
--blessfolds a reviewed rise into the baseline
--filter a,bonly states whose label matches either

The driver builds legaia-engine first (--no-build skips it) and exits cleanly with a [skip] line when the save-state library or the extracted disc is missing. Both are gitignored, per-machine data: the library is your own captures of your own disc.

Reading the report

report.md opens with the channel means over the seeded states and the class counts, then lists every seeded state worst first, each channel with both sides’ values and, when the frame ran, a side-by-side image. The unseeded states close it, each with its reason; report.json carries the same rows for scripts.

The worst states are the product. For each, decide first whether the divergence is the instrument’s - a seeding gap - or the engine’s:

  • a camera channel at 1 beside a bad image is a rendering question;
  • a camera channel far off on a state whose label names a cutscene is script progress;
  • a bgm miss on an arrival state is capture timing - a door caught before the new scene’s field init ran.

Trace switches (LEGAIA_RC_CAM_TRACE, _POS_TRACE, _DRIVE_TRACE, _FX_TRACE, _SEED_TRACE) open a battle state up further: which component of a camera framing misses its endpoint and which input put it there, how the combatants got where they stand, and which seeding stream the battle search tried.

The side-by-side PNGs are retail pixels

The report directory holds frames from your disc. It stays under the gitignored captures/ tree and is never committed.

The ratchet

The baseline file holds, per state label, each measured channel’s score and the state’s class. The disc-gated test re-runs the corpus and fails when:

  • any state’s channel falls below its baselined score (beyond a JSON round-trip slack);
  • a baselined channel of a state the run did seed goes unmeasured;
  • a seedable state fails to seed, or no state seeds at all.

A rise is allowed and is folded in by a reviewed --bless, which merges: a state outside the filter or one the run could not seed keeps its baselined channels, and a run without a display keeps each image score. A channel that no longer applies to a re-classed state leaves the file rather than failing every later check.

States the manifest tags as made on a patched disc replay a modified executable, so their RAM and frame are not retail’s. They stay in the corpus - some are the only capture of their scene - but sit outside the headline means and the ratchet; a drop there prints as [patched-drift] for review. A difference on such a state is a lead to confirm against a retail capture, not a measured engine defect.

The corpus

Every scenario in scripts/scenarios.toml whose backup fingerprint resolves to a library save state is one corpus state. Each is classed by the game-mode word and the scene label:

ClassRetail conditionSeeded
fieldmode 0x03, a non-overworld sceneyes
world_mapmode 0x03 on a kingdom overworldyes
battlemode 0x14 / 0x15yes
menumode 0x17the pause-menu screens
field_initmode 0x02, scene mid-loadno
minigamemode 0x19no
cutscenemode 0x1A / 0x1B (STR playback)no
otheranything elseno

A field state is named by the scene it is running, which is not always its label. A walked door writes the destination label frames before the field init loads the block, so a capture in that window shows the outgoing scene under the incoming label; the corpus scores it as the scene actually loaded and records the label as pending.

Retail observables

ObservableAddress
scene label / loaded scene0x8007050C / 0x80084540
game mode0x8007B83C
player (X, footing, Z)*0x8007C364 + 0x14/0x16/0x18
camera pitch / yaw, GTE H0x8007B790 / 0x8007B792, 0x8007B6F4
camera eye / focus0x800840B8..C0 / 0x80089118, 0x80089120
actor headingsactor + 0x26
BGM track word0x8007BAC8
fog-pool gate0x8007B854 (field ambient effects)
visible-tile windowscratchpad 0x1F8003E8..EB (encounter)
party / flags / bag / goldthe live game-state window at 0x80084140
displayed frameVRAM + display registers

The live game-state window is byte for byte the front of a save block - the save composer copies exactly these bytes - so the corpus pads it to a block and lifts it through the same parse a memory-card load uses. Party, story flags, bag and gold therefore come out in the engine’s own schema on both sides. Both emulators’ states crop the displayed frame by one rule: display start and mode from the GPU’s last GP1 writes.

The seeding model

  1. Resume. The engine opens the state’s scene through its own card-load path over the lifted save: story flags first (retail’s order, so an entry script reads the saved story), then the scene entry, then party, flags, bag and gold.
  2. Seat. The player stands on retail’s (X, Z) with the floor-sampled height, as a warp landing. The walk-on dispatcher’s last-tile pair is stamped with the seat tile, because a player standing on a trigger tile has already fired it.
  3. Camera. The arrival snap composes from the state’s own camera parameter block - whichever region record was installed last, which is walk history a tile re-query cannot recover. A state saved mid-walk shows a frame two passes older than its RAM, so the image child stands the player one walk step behind the previous pass and takes the follow camera back two steps of its ease - also when the camera already stands on its target, since its focus is still the player.
  4. Frame step. The step retail was running at is rebuilt from its frame-time ring and handed over; it decides which script waits can complete.
  5. Settle. The session ticks a fixed window with no input - or, for a mid-script, battle or menu state, runs to that state’s phase.

The image channel is rendered by play-window, the real renderer, run as a child process seeded the same way (a scratch save resumed with --resume-save, then seated) and screenshotted at a fixed world tick. A --scene door entry is deliberately not used: it runs the scene’s arrival from the picker’s seat, so the frame would score the arrival rather than the scene.

What seeding does not carry

Each of these is an instrument limit, not an engine verdict:

  • Script progress outside a running record the phase gate reaches.
  • Actor state - NPC positions, animation phases, open doors, live effects - is whatever the engine’s own entry produces.
  • Timing. Retail’s frame is one instant; the engine’s is a fixed tick after entry. Some history is held through the warm-up instead of run as game time - the field VM’s script counters and the BIOS rand() seed, so every warm-up draw is the one retail would make next; the image child holds the same counters up to its capture, so the frame it draws and the channels the seed scores stand on one clock - and a few phases are aligned explicitly: the field HUD’s idle countdown and the palette the ambient colour cyclers write, the angle a turning ambient part (noaru’s ice floor) has reached since the entry, and the text balloon - a place-name banner a door left up - that the displayed frame drew. Ambient animation and water palette walks cannot be phase-aligned.

Mid-script states

A field capture taken while a spawned script record runs holds that record’s context on retail’s actor lists: its script base, program counter, record index and wait accumulator, plus the cutscene camera mover’s progress. Such a state is not sampled a fixed window after entry. The engine runs until its own context for the same record - matched by the record’s first bytes - holds retail’s PC with at least retail’s wait and at most retail’s glide left, or has just executed the op retail is parked on.

Dialogue is met on the frame the capture shows: a box that has typed its page and waits for the press, or - for a capture caught mid-typing - the same row with the reveal counter at least the capture’s. A gate the run never meets within its deadline falls back to the settle-window sample, and the script detail says which happened.

A placed object the player is engaged with is met the same way. Retail’s touched object runs its record as a script context like a talk; the engine keeps the object’s idle body on its own channel and runs the touch on the inline runner, and since the seat stands the player where retail’s stood - so no walking contact will post the touch - a gate found only on an object channel touches the prop whose record carries the capture’s head bytes. The Sol Tower lift capture is one: caught in the lift’s arrival test after the player walked onto the platform, it was never met in the deadline until the seed touched the lift.

Placements a finished cutscene parked are seated too. A beat that ends by sending an actor to the off-map park seat leaves it there with the heading its last walk gave it, and the seed stands it on retail’s park coordinates rather than wherever the engine’s record would place it. The same goes for effect sprites an emitter picks at random: the Sol Tower steam emitter spawns one of eight sheet records each cycle, so the engine might hold neither of the two sheets the capture drew; a record with no live sheet is spawned fresh from its stager and seated like the others, and the steam band across the stair rail lands where retail’s frame has it.

Battle states

A battle capture carries its whole encounter in RAM: party and monster counts, the command-flow byte and action-state cursor, the monster ids, the scripted-fight bit, the present party and every combatant’s HP and MP. The seed replays the fight from a running field, as retail entered it, and then places it at the capture’s phase. Each step exists because leaving it out moves a channel for a reason that is not a port defect:

  1. Enter the fight with the captured formation, party, stage variant and map id - a Muscle Dome leg stands in koin1 while its map id names the arena data file, whose backdrop the fight is drawn on.
  2. Pin the random stream at the entry, searched over, since the capture holds the stream at the capture rather than at the entry.
  3. Stand the combatants on their captured ground positions and headings, taking back pushes the capture already holds.
  4. Land the HUD glides and each body’s animation keyframe the capture had already reached.
  5. Take back hits already landed this action, rewards already granted and turns already spent.
  6. Place the phase - drive the engine’s own command surfaces, one press every other tick, to the captured command-flow state, action state and acting seat; a replayed cast runs to its gate.

The settle stops where retail’s scripts stood. Before the fight is entered the seed runs the field’s scripts for a while, and a few things retail had not run by the capture must stay unrun. A scene’s one-hot region selector is held at the save’s bits through the settle, since retail’s last region pass ran before a cutscene walked the player elsewhere (nilboa, nivora). The selector arm the settle took still ran, so its view-window write is undone with the bit: where the save holds a bit the settle did not raise, the zone camera takes the window that bit’s own arm applies (cort_evolved_approach_cutscene). A fight the scene’s system script spawned ends the settle on the pass retail’s script parked on (town0b’s Gimard raid). A flag a fight’s record raised a cutscene before its entry op is held back as a latch (korb3’s Gaza), and a Muscle Dome leg is entered as the arena enters it, with the course pick’s flags as its latches. Each is a harness seat: it changes what the comparison stands on, not the game code, and each moved its states’ flags channel to 1.

A monster’s strike capture also places where its walk began. The walk is a straight line onto the target, and the action’s yaw counter counts its passes from the Attack arm’s rung, so the start lies on the line from the target through the attacker’s captured strike position, as far back as the walk entry’s speed covers in the counter units retail ran. Left on the replay’s ground, the attacker came in from wherever earlier rounds put it, and the post-strike two-shot, whose yaw is the counter less the attacker’s heading, filmed the strike from the wrong side.

A monster still walking when the capture was cut moves its own framing as it goes: the post-strike two-shot looks at the midpoint of the attacker and its target, re-aimed every pass, so the frame on screen was chasing a point half a walk step behind the one the RAM holds. The camera is walked back against that earlier point, and the replay, standing the walker where the screen drew it, checks its own framing against it too. King Frog’s strike frame came onto retail’s with it; the warriors’ frames still show their far scenery a few pixels off with the floor aligned, a yaw residual this does not explain.

A party attack captured after its string, in the Done band, places its walk too. The counter’s span from the rung covers the walk and the string; a first run measures the string, the span is turned into frames at the rate the frame-time history shows, and what is left is the walk, laid back along the line the string’s pushes left from where the target stands. The replay had stood the attacker near the end of its string, so it arrived at once where retail walked about a second.

A monster spell captured inside its capture band also stands the stage’s darkening on the capture’s word. The backdrop dims a fixed amount every frame from the band’s entry, and retail waits longer before entering the band than the engine does, so with the other two clocks held to retail’s the engine’s cave had already gone darker; the frame on screen takes the weight the displayed frame drew. A monster’s Spirit or Item band (a class-0 Heal routed to state 0x3C) seats that ambient base the same way: 0x3C raises the dimming latch and 0x3D holds until the caster’s idle ends, so how far the base has dimmed by the gate is the caster’s idle phase at the seed - a harness seat, not a ramp the engine runs wrong.

Of those clocks the yaw counter is the one a monster spell can always trust. It starts from the action’s seed, while the close-up accumulator only restarts when the caster commits a clip, and a monster entering its capture band commits none: its accumulator still counts from wherever its idle loop last wrapped, often before the action began. When a capture’s accumulator has run longer than its counter, the drive holds the band on the counter alone. Gated on both, the Mage’s Void Accessories capture let the engine’s camera finish a turn retail’s was still making, which brought the second monster into shot as a large, dark see-through body: the near-camera ghost retail also draws, just outside its own frame.

The yaw counter is also pinned outright on an attack whose rung both sides share. An attack’s counter climbs from the Attack arm’s rung, and the only later reset is the swing re-seed, which only a party seat committing an entry flagged for it can fire. When none of the acting seat’s entries carries the flag, the engine’s counter and retail’s are one clock read at different instants, so retail’s word is taken however far apart they stand. Gala’s ten-hit combo before the evolved Cort’s Doomsday ran longer in retail, at a coarser frame step, than in the replay; pinned, the end-of-action shot turns to retail’s angle. An emulator replay of the capture’s own run shows where the time went: every clip of the combo lasts what the engine’s does, but retail’s Gala starts her action far back and walks for about a second before her first swing, where the replay stands her near the spot the combo ends on.

An art capture is also placed by which clip its last commit installed. A Hyper Art’s starter and the art itself play in the same animation slot, so the clip number and the strike cursor alone cannot tell them apart once the art is queued; the drive also waits for the clip id the capture latched. The Muscle Dome’s Skeleton capture sits on the first frame of Vahn’s Hyper Art, and without that gate the replay stopped a whole starter clip early.

The same commit opens the art’s name label, and a capture taken on it shows a frame older than the commit: the image child closes the label there. A melee string’s HIT / TOTAL readout opens only when its total lands on the target’s HP, not on every hit, and a string longer than the eight-entry damage ring is still replayed hit for hit to the captured total.

A monster’s turn replays the rest of its round. The turn pick only ever takes the highest unspent key, so the acting monster held the round’s highest key when it was picked, and the capture’s keys are the remainder of that round: zero for every combatant that has already acted, live for each still to come. The seed stands every combatant on its captured key and the actor one above them all. Left to the engine’s own initiative roll, a monster whose swing the captured HP already holds struck a second time ahead of the caster, and two monsters the engine had rolled above a caster acted first. The HUD’s HP readout is where it shows: a party member reads the captured HP, not what the extra swing left.

A monster’s strike is replayed with the party on Spirit. Every monster-strike capture in the library holds a round with the whole party on Spirit and each monster on full HP - the party sat the fight out. A drive whose party swings instead knocks down monsters the capture holds standing. The replay’s earlier rounds still hand the monsters their turns, and those can land an ailment the captured fight never took (a replayed Medusa petrified Vahn a round early), so the re-seat also strips every ailment the captured status word does not carry and gives back HP and MP the replayed rounds dealt. What the Spirit rounds still leave apart is the camera’s own history: its glide reaches the strike framing from wherever the replayed rounds left it.

Once the band reaches the spell’s own module, the module moves the camera itself: a shot it builds once and lets walk. A capture taken while such a shot is still in flight is lined up on that shot’s own path rather than on the action camera’s, which is what put the Lippian’s Kiss of Death capture on retail’s camera exactly.

Battle channelScore
enemiesfraction of retail monster seats the engine seated with the same id, in order
enemy_hp / battle_partyfraction of equal HP / max HP / MP / max MP fields; the maxima are the real check, since current values are seeded
phase1 when the engine’s command-flow state matches retail’s - and for a driven action, the same action-SM state on the same seat
bgmretail’s track word against the field track the engine will resume

Fields a capture probe wrote into RAM after battle init, and fields on a patched-disc state, are listed in the detail but left unscored.

The shot an earlier actor left

A pre-cast capture parks in 0x0A behind whoever acted before the caster, and the camera still frames that actor, landed. The drive cannot always reproduce that last actor, so when the seat reads a spent key the steer token stands the camera on the framing law over the capture’s inputs: category, target, style and depth, on the captured ground. The score still tests the engine’s case 6 and case 8: no pose is read from retail but the orbit’s yaw clock.

Movie states

An STR movie (modes 0x1A / 0x1B) has nothing to seed: the frame on screen is the file’s, whatever the story, so a movie-class state scores the image channel alone. Retail’s frame reads 24-bit when the dispatch slot’s +0x04 colour flag says so. The engine’s is the frame the stream word DAT_801CAD90 names, through psx_mdec, centred in the 240-line display. The word runs ahead of the buffer on screen by at most three frames; the best is taken and named in the detail.

Minigame states

A minigame capture names its minigame by the door warp’s sub-id; the seed lands the save in the departure scene and takes the same warp. Only a phase the warp itself reaches is seeded:

  • The slot machine at rest on a door entry, scored on mode, scene, the machine’s state word, balance and coin bank, and the frame (box-filtered from the machine’s 640-wide video mode). The strips are not history: the machine’s generator starts on a literal, so the frame needs no seat.
  • The fishing venue’s idle shore, seeded on the departure scene the mode-24 init backed up.
  • The dance’s count-in, sampled at the capture’s own banner counter or GO! accumulator.
  • The contest hub’s first visit, sampled at the capture’s level or hold.
  • The Baka Fighter’s title card.

What stays unseeded: a decided spin, whose reel stops are rand() history after entry, and a played phase (a Baka Fighter round, a cast’s flight and bite rolls, the chart’s presses, the hub’s legs).

Channels

Each channel scores in [0, 1]; a state’s score is the mean of its measured channels. Distances score 1 inside a tolerance and fall linearly to 0 at a cut-off, so a near miss and a far one read differently.

ChannelScore
scene / modethe engine landed in retail’s scene, in the class’s mode
positionplayer (X, Z) after settling
footingengine floor sample at retail’s (X, Z) against retail’s footing
cameramean of eight parts: pitch, yaw, H, the three eye words and two focus words
facingheadings of the player and every placement standing on its retail seat
npcswhere each scripted placement stands, or whether one side parks it off-map
bgm / fog_gatethe track-select word and the ambient-particle gate equal retail’s
view_windowthe four visible-tile window bytes
party / flags / inventoryroster fields; story-flag bits; bag slots and gold
imagefraction of 8 × 8 blocks within tolerance (below)

party, flags and inventory are seeded straight from retail, so on their own they check the seeding and whatever the settle window changed - an entry script that grants an item, a flag the save round trip does not carry.

The image channel

Retail draws a 320 × 224 area, so the compared frame is the top 224 rows of the display crop. The port keeps a 320 × 240 logical screen whose rows line up with retail’s draw rows, rendered at an integer scale and box-filtered back down.

within (the score)
the fraction of 8 × 8 blocks whose mean colour is within a fixed tolerance of retail’s on every channel
mae
mean absolute error per channel per pixel, for reference

Block means rather than pixels: the engine rasterises at its own resolution with its own filtering, so a per-pixel test would mostly score dither and sub-pixel edges. A block test moves on a wrong camera, missing or extra geometry, wrong palettes and wrong overlays. A retail frame dark enough to be a fade is not scored - a dark engine frame would match it and say nothing.

The child runs at retail framing with every enhancement off: retail camera distance, no enhanced lighting, no ground fog, no see-through-walls fade, no entry pulse, no photosensitivity slew. It is also tick-locked - exactly one world tick per redraw - so a fogged scene’s frame does not move with machine load.

Divergence shapes

A low score has a shape, and the shape says where to look:

ShapeIndicates
camera exact, frame smeared by stretched planes or one flat coloura mesh retail does not draw there - back faces of a sky dome, a never-spawned actor at the origin, a prop at the wrong scale
engine frame black where retail shows a floor or backdrop, every other channel exact (noaru_field_card_walk)a mesh-bearing ambient part seated off its anchor: noaru’s ice floor rides the camera focus through move-VM ext 0x24; see move VM overlay extension
retail frame black beyond a rectangle, the engine’s filledthe visible-tile window; see renderer
an effect missing in the engine framean actor or effect the fresh entry does not spawn, or one the port does not draw
camera and dialogue off together, phase gate not meta script record the gate could not bring the engine to
flag bits one apart inside the region-selector bandthe entry script’s one-hot region selector, re-evaluated at the seat
a town label over the overworld’s camera and tracka door caught before the town’s field init ran
retail’s camera focus far from its playera focus left behind by a probe poke or a movement-locked script; the seed lands retail’s focus
a translucent near-camera body brighter in the engine framethe near-camera ghost a frame or two off the displayed one, not a blend defect

The full doc works through each shape against named states, including the ones that turned out to be capture artefacts rather than engine defects.

The cast-sequence oracle

The corpus scores one frozen moment per state. A cast - a spell, a summon or a monster’s special - is a sequence, so its oracle records the whole thing on both sides: a PCSX-Redux probe on retail and the play-window recorder on the engine, paired on the action machine’s clock and the cast module’s phase, then scored frame by frame.

A still cannot see a cast. A high image score on a *_mid_cast state says that one frame matches and nothing about the order, timing or length of the cast around it: an arm that runs late, a record seated all at once and a missing camera arm can all sit in frames no state holds. On most summons the state’s one instant is the white flash, which the metric cannot see under. Do not read a still’s score as proof of a cast; record it.

scripts/ci/cast-sequence.py --spell 0x86 --state party_basic_attack_vs_gobu_gobu scripts/ci/cast-sequence.py --spell 0x86 --state party_basic_attack_vs_gobu_gobu --engine-only scripts/ci/cast-sequence-batch.py casts.txt --jobs 4

A retail recording is a headless emulator at 1x (--window opens one), holds no display lock, and several run side by side through cast-sequence-batch.py; the engine runs follow one at a time. Each stage is reused until something it depends on changes, so an --engine-only with nothing changed goes straight to the score. Two recordings of one cast are not byte-identical, in a window or not, because the emulator’s SPU runs on wall-clock time and moves the game’s adaptive frame step, so a residual smaller than two retail runs differ from each other is noise. What holds across runs: the action-state and module-phase path, the HP columns, the sound cues and the game-frame count to within one.

Beside the frames it scores sound. Each side writes one row per sound cue played. On retail the probe samples the four-slot cue ring every vsync - the cue ids and their countdowns - and counts a cue when a slot reaches zero with its id live and the drainer’s gate open; a cue that came due behind a closed gate was not heard and is not scored. On the engine the recorder reads the audio director’s due cues each tick, through an output with no device. Both ids are indices into the same descriptor table, so they compare directly.

Retail cueid, segment, offset Matchedsame id within 6 ticks Timing misssame id within 120 Missing / extraone side only second pass the rest score = 2 × matched / (retail cues + engine cues) a separate id score compares the two multisets of ids, whatever the time
A retail cue is placed into engine time the way a frame is: its segment, and the same offset into the aligned engine segment. The tolerance is a command-line knob.

A missing cue is traced back with a second probe mode that breakpoints the cue producers, the battle router and the dispatcher and logs who called each. That is how a missing Zenoir cue was found to come from the victim’s animation track: the summon’s module drops the victim’s clip, and the engine had applied the drop to the creature. With that put right the cue still does not sound, and why is not yet traced. One static cue near the end of every summon arrives through none of the four logged entries, so it is a ring store made inline somewhere not yet located.

Capture coverage

The corpus’s headline is only as broad as the moments it holds. scripts/ci/capture-coverage.py takes the denominator from the disc: of every scene, monster, enemy attack, player cast, cast module and screen the disc defines, does a retail state show it. Its output is a ranked worklist of what to capture next. Three probes fill it, each a valid run of the game, and state-making runs can take run_probe.sh --fast --turbo, since any valid run is as good as another.

  • The battle harvest walks until an encounter, plays each round with Spirit and saves a state when a monster’s action sits mid-action. LEGAIA_WANT names formations by monster id: the probe points the live region table at the scene’s row holding them and lets retail’s own roll pick it. A heal-class spell runs its own flow, so it counts as well.
  • The door hop crosses a chain of doors by holding the player on each door’s walk-on tile. The door’s own record runs with its gates and flag writes, so a door the story has not opened refuses and ends the run, unless the scenario names the flags it pokes.
  • The movie probe makes the op 4C E2 stores from a field state and saves states inside the movie.

See also