Retail comparison corpus
How far is the port from retail, and where? The retail comparison corpus answers that for every captured moment at once. For each retail save state in the library that the engine can be put into, it seeds the engine into the same situation, lets it settle, and scores what the engine shows against what retail showed - channel by channel, state by state - then ranks the states worst first and holds the scores to a ratcheted baseline. It is the breadth sibling of the single-mechanism oracles: those pin one subsystem exactly, this one measures the whole port at every moment someone captured.
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.
- 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 thelegaia-engine retail-comparesubcommand- Ratchet
retail_compare_corpus(disc-gated test) againstscripts/ci/retail-compare-baseline.json- scores and classes only, no pixels, no RAM
Running it
scripts/ci/retail-compare.py --filter town01| Flag | Does |
|---|---|
| (none) | the state channels; report under captures/retail-compare/ |
--images | adds the image channel - renders real frames, so it needs a display |
--check | asserts the baseline |
--bless | folds a reviewed rise into the baseline |
--filter a,b | only 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
camerachannel at 1 beside a bad image is a rendering question; - a
camerachannel far off on a state whose label names a cutscene is script progress; - a
bgmmiss 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:
| Class | Retail condition | Seeded |
|---|---|---|
field | mode 0x03, a non-overworld scene | yes |
world_map | mode 0x03 on a kingdom overworld | yes |
battle | mode 0x14 / 0x15 | yes |
menu | mode 0x17 | the pause-menu screens |
field_init | mode 0x02, scene mid-load | no |
minigame | mode 0x19 | no |
cutscene | mode 0x1A / 0x1B (STR playback) | no |
other | anything else | no |
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
| Observable | Address |
|---|---|
| scene label / loaded scene | 0x8007050C / 0x80084540 |
| game mode | 0x8007B83C |
player (X, footing, Z) | *0x8007C364 + 0x14/0x16/0x18 |
camera pitch / yaw, GTE H | 0x8007B790 / 0x8007B792, 0x8007B6F4 |
| camera eye / focus | 0x800840B8..C0 / 0x80089118, 0x80089120 |
| actor headings | actor + 0x26 |
| BGM track word | 0x8007BAC8 |
| fog-pool gate | 0x8007B854 (field ambient effects) |
| visible-tile window | scratchpad 0x1F8003E8..EB (encounter) |
| party / flags / bag / gold | the live game-state window at 0x80084140 |
| displayed frame | VRAM + 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
- 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.
- 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. - 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.
- 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.
- 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:
- Enter the fight with the captured formation, party, stage variant and map id - a Muscle Dome leg stands in
koin1while its map id names the arena data file, whose backdrop the fight is drawn on. - Pin the random stream at the entry, searched over, since the capture holds the stream at the capture rather than at the entry.
- Stand the combatants on their captured ground positions and headings, taking back pushes the capture already holds.
- Land the HUD glides and each body’s animation keyframe the capture had already reached.
- Take back hits already landed this action, rewards already granted and turns already spent.
- 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 channel | Score |
|---|---|
enemies | fraction of retail monster seats the engine seated with the same id, in order |
enemy_hp / battle_party | fraction of equal HP / max HP / MP / max MP fields; the maxima are the real check, since current values are seeded |
phase | 1 when the engine’s command-flow state matches retail’s - and for a driven action, the same action-SM state on the same seat |
bgm | retail’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.
| Channel | Score |
|---|---|
scene / mode | the engine landed in retail’s scene, in the class’s mode |
position | player (X, Z) after settling |
footing | engine floor sample at retail’s (X, Z) against retail’s footing |
camera | mean of eight parts: pitch, yaw, H, the three eye words and two focus words |
facing | headings of the player and every placement standing on its retail seat |
npcs | where each scripted placement stands, or whether one side parks it off-map |
bgm / fog_gate | the track-select word and the ambient-particle gate equal retail’s |
view_window | the four visible-tile window bytes |
party / flags / inventory | roster fields; story-flag bits; bag slots and gold |
image | fraction 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 × 8blocks 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:
| Shape | Indicates |
|---|---|
| camera exact, frame smeared by stretched planes or one flat colour | a 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 filled | the visible-tile window; see renderer |
| an effect missing in the engine frame | an actor or effect the fresh entry does not spawn, or one the port does not draw |
| camera and dialogue off together, phase gate not met | a script record the gate could not bring the engine to |
| flag bits one apart inside the region-selector band | the entry script’s one-hot region selector, re-evaluated at the seat |
| a town label over the overworld’s camera and track | a door caught before the town’s field init ran |
| retail’s camera focus far from its player | a 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 frame | the 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 4A 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.
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_WANTnames 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 E2stores from a field state and saves states inside the movie.