What this is

A designed, shipped deliverable of the project, not a by-product of the reverse engineering. It is also the practical proof that the format work is right: you cannot re-pack a monster's LZS record and have the retail game boot it unless the format doc is correct down to the byte.

Reach for it when you want a fresh playthrough of a game you have finished, or when you want to check that a format the docs claim to understand really is understood.

It does not touch the clean-room engine - it edits the disc, and the retail game (or the port) plays the result. The one exception is the Seru-trading vendor, which embeds a config the engine reads because retail has no trade UI to hook.

The legaia-patcher CLI is the project's general disc-patching toolkit; the randomizer documented on this page is its largest feature family, and the same machinery carries the translation packs and the manual per-record edits (monster-block, in the modding guide). It turns a disc + seed into a portable PPF patch (the shareable deliverable - a small file carrying only the changed bytes, meaningless without the original disc) plus an optional patched image for local play. There is also a browser build that never uploads your disc. The crate ships only code; every test that needs real data is disc-gated, so CI runs without a disc.

What it can re-roll

  • Loot - monster item drops, treasure-chest contents, per-monster steal items, and an optional low-chance bonus equipment drop.
  • Fights - random-encounter formations, monster combat stats, special-attack power, the element-affinity matrix, and spell MP costs.
  • Economy - what town stores sell, and the casino prize exchange.
  • Navigation - scene-transition doors/exits, intra-town (house / interior) doors, and .MAP intra-scene teleports.
  • The party - Tactical-Arts button combos, equipment stat bonuses, each character's favored weapon class, and the new game's starting items and level.
  • Additions retail has no table for, added as machine-code hooks: experience for running away, charming an enemy onto your side, shiny Seru, and Seru trading.

Why this needs three new capabilities

Most editable values live inside a Legaia LZS stream (the game's own compression format) that the asset dispatcher decompresses at load. Changing one is therefore decompress → mutate → recompress → write-back, which needed three pieces the preservation track never had (it only ever read the disc):

  1. An LZS encoder - legaia_lzs::compress. The retail game ships only a decoder (FUN_8001A55C - Ghidra's name for the routine at that address in the game executable); there was no way to produce a stream it accepts. LZS compression →
  2. Mode 2/2352 sector write-back - legaia_iso::write. Overwriting a sector's 2048-byte user payload also requires recomputing its 4-byte EDC and 276-byte P/Q ECC, or the sector reads as corrupt. PSX disc geometry →
  3. A disc bridge - legaia_patcher::disc::DiscPatcher, which ties the editing primitives to the sector write-back through the PROT.DAT TOC.

Editing model: same-size in place, except scene-transition doors

Drops / encounters / chests / steals / house doors (the door-warp tile shuffle) overwrite bytes in place and never change a byte count, so no LBA (sector address), PROT.DAT table-of-contents entry, or ISO 9660 directory record ever moves. The patch stays a pure byte-overwrite (plus EDC/ECC recompute) with no cascading offset shifts. It works because the targets either fit a fixed slot with slack or re-pack tightly enough to fit their original footprint. The monster battle_data archive (PROT entry 867) gives each monster a fixed 0x14000-byte slot laid out [u32 decompressed_size][LZS stream]; a drop edit leaves the decoded record length unchanged and the re-packed stream is re-emitted zero-padded back to 0x14000. A scene MAN (the per-scene script + data container that holds a town's encounters, chests, doors, and dialogue), by contrast, is packed with no compressed slack - so the LZS re-packer's one-step lazy matching earns its keep: it packs tightly enough that a re-packed MAN fits its exact original span (every scene MAN but one), which is what makes the encounter and chest edits possible. The rare stream that still overflows is skipped (left unchanged) rather than aborting the run.

Scene-transition doors are the one exception. A scene-transition destination carries its target scene's name inline, so re-pointing a door at a differently-named scene changes the record's byte length. That is made safe by a small MAN relocation engine: it rebuilds the decompressed MAN, fixes every internal offset the resize disturbs (the partition record-offset tables - which double as the door dispatch index - the section-0 offset, and any intra-record jump deltas that straddle the edit), recompresses, and rewrites the descriptor's decompressed-size word. The disc image's total size never changes either way.

The patch chain

A PROT-entry-relative edit maps to a disc byte range:

disc image (2352-byte sectors)
  -> ISO 9660: PROT.DAT lives at disc sector prot_lba
    -> PROT TOC: entry N starts at start_lba[N] * 2048 bytes into PROT.DAT
      -> asset: an edit at offset_in_entry bytes into the entry

so a PROT-entry-relative offset becomes the PROT.DAT-logical offset start_lba[N] * 2048 + offset_in_entry, which legaia_iso::write::patch_file_logical turns into physical-sector writes plus EDC/ECC re-encode. DiscPatcher::patch_prot_entry is the generic entry point; patch_monster_slot / monster_slot are the battle_data helpers.

EDC/ECC: not game-specific

The error-correction math is the generic CD-ROM scheme from ECMA-130 / the Yellow Book - the same EDC (CRC, reversed polynomial 0xD8018001) and Reed-Solomon P/Q ECC (over GF(28), generator 0x11D) every PSX disc and mastering tool uses. The 4-byte header is treated as zero per the Form 1 convention, so parity is independent of the sector's MSF address. It embeds no game bytes. The decisive correctness check is the disc-gated test that re-encodes real PROT.DAT sectors and reproduces their stored EDC/ECC bit-for-bit.

Crate modules

ModuleRole
rngVersion-stable SplitMix64. A published seed always reproduces a run; the first output for seed 0 is pinned by a test.
itemsValid item-id pool from the SCUS item-name table, so a randomized drop is always a real item.
dropsDrop-table planner. Shuffle redistributes the existing drops (preserves the economy); Random draws from the pool. Deterministic in (drops, pool, seed, mode).
equipmentEquipment-as-enemy-drops. equipment_pool classifies gear ids by matching the curated public weapon/armor/accessory names against the disc's own item-name table (no Sony bytes; ~150 ids). plan_equipment_drops turns every monster's drop into a rare random weapon/armor/accessory; the chance is the rarer of the gear's price tier and the enemy's EXP tier (early 3% / mid 2% / late 1%). The retail roll is integer rand() % 100, so the requested late-game 0.5% floors to 1%.
monsterRe-pack a monster slot: decompress → in-place mutate → recompress → zero-pad to 0x14000. set_drop is the drop wrapper.
encounterSceneEncounters: locate a scene bundle's MAN in a PROT entry, shuffle its formation monster ids (per-scene, or from a kingdom / world pool via --encounter-scope), recompress.
kingdomKingdomMap: partition the PROT scene namespace into Drake / Sebucus / Karisto from CDNAME.TXT (anchored on the map01/map02 overworlds). Feeds the kingdom-scoped encounter pool.
chestgive_item_sites opcode-aware-walks the MAN interaction scripts for field-VM GIVE_ITEM (op 0x39) sites; SceneChests bundles them with the decoded MAN for an inline-id rewrite.
shopSceneShops: a town merchant's stock is inline in the scene MAN field-VM script - field-VM op 0x49 (STATE_RESUME, the _DAT_8007B450 menu-register driver) carries [u8 count][item ids][ASCII name\0] (pinned from a live Rim Elm Variety Store capture). Located by an opcode-aware MAN walk reaching op 0x49 in real script flow; item-id bytes rewritten same-size and the MAN recompressed like chests.
casinoCasinoExchange: the casino prize list is a static overlay table (DAT_801e4518) - PROT entry 899, raw, file offset 0x15D00, four 0x60-byte blocks of 8-byte [u16 id][u16 story-gate][u32 coin-price] records. It debits casino coins (_DAT_800845A4), not gold - which is how it's told apart from a gold shop. Whole-record shuffle/random, same-size raw (no LZS).
stealStealEdits: read the static SCUS_942.54 steal table (DAT_80077828, per-monster [chance, item]) and emit same-size item-byte patches - the Evil God Icon steal item changes, the chance is preserved.
monster_statsBattle-tuning: redistribute every enemy's combat stats column-wise across the battle_data archive (PROT 867) - HP / MP / ATK / DEF↑ / DEF↓ / INT / SPD. Shuffle permutes each stat column (multiset preserved); Random draws from it. The AGL action gauge (+0x0E) untouched; the early tutorial enemies and the story bosses are pinned to their disc stats so a fresh game or mandatory fight can't be soft-locked; each slot re-packed via monster::repack_slot back to 0x14000 (skipped if too tight).
move_powerBattle-tuning: redistribute the +0x00 power halfword of the move-power table (0x801F4F5C, PROT 0898) - enemy special-attacks + Seru-magic, not party Tactical Arts. Only populated records participate; the other 24 record bytes are untouched. Same-size raw PROT-0898 edit.
element_affinityBattle-tuning: shuffle the 8×8 element-affinity matrix (matrix[attacker][defender], PROT 0898; damage-scale percentages). Shuffle permutes the 64 cells (multiset preserved); Random draws from them. Per-character element assignment + summon-power rows left untouched. Same-size raw PROT-0898 edit.
spell_costBattle-tuning: redistribute the +3 MP-cost byte of named, costed spells in the SCUS spell table (DAT_800754C8). Free / unnamed internal-tier spells never participate; names + target shapes untouched. Same-size SCUS patch via patch_named_file.
doorSceneDoors: enumerate a scene's field-VM 0x3F named-scene-change ops (partition-2 MAN records) and re-point them through the variable-length man_edit relocation engine - the only randomizer that resizes an asset. The whole destination descriptor (scene + entry tile + facing) moves as one unit.
house_doorSceneHouseDoors: intra-town (house/interior) doors are the field VM's cross-context player MOVE_TO (0xA3 0xF8 xb zb) in named partition-0 records paired by an entry/exit (IN/OUT) naming convention. Per-scene class-preserving shuffle (entries among entries, exits among exits; NPCs never move). Same-size 2-byte edit.
starting_items / starting_bagNew-game starting inventory. There's no static table - the new-game data-init FUN_80034A6C code-builds the bag (vanilla: Healing Leaf 0x77 ×5), so this rewrites the seed code at the reclaimable 40-byte region 0x80034b04 (the seed + a redundant inline zero-loop both callers already memset over). Random consumables (0x77..=0x8e) plus the forced convenience items, one packed halfword store per slot - a same-size code patch (cap 7 slots, 5 with all-warps). Anything past that cap is granted by splicing a guarded run of silent GIVE_ITEM ops (0x39) into the opening scene town01's entry script (the starting_bag module, via man_edit::apply_insertions), so the explicit convenience items plus the full random fill all land. --start-with id[:count],… seeds explicit picks - unlike the random fill (consumable pool only) it takes any item id, including accessories, additive like the convenience toggles.
starting_levelNew-game starting level (party-wide). The displayed level is the byte at +0x130 (boot-confirmed - not derived from experience at a New Game; +0x100 is zero in retail), and the seed routine's record-init loop stamps it on every roster slot. This sets +0x130 = N (keeping magic rank +0x131 at 1) for the whole party, recomputes each growth-capable character's stat template (Vahn / Noa / Gala) from the disc's own growth curves so level + stats stay coherent (the 4th slot, Terra, has no growth curve), and seeds each of those characters' cumulative-experience cell +0x0 to the level's XP-band midpoint and next-level threshold +0x4 to reach(N+1) for an exact readout. Default 10, range 2..=14 (the XP seeds are single 16-bit immediates).
unusedCurated “unused content” the opt-in toggles re-introduce: UNUSED_ENEMY_IDS ("Comm" id 78 + the Evil Bat clones 176/177/178, added to a scene's encounter Random pool) and UNUSED_ITEM_IDS (Something Good 0x6B + the unnamed accessory 0xFD, added to the random-fill item pool).
item_nameNameInjection: name the otherwise-blank accessory 0xFD “Seru Bell” - a same-size SCUS patch that writes the string into preserved rodata padding (0x8007AB40, pinned for the US build - NOT a zero region that's boot-cleared scratch; verified by its flanking rodata surviving file→RAM) and repoints only 0xFD's name pointer (the other empty-name ids stay blank).
discDiscPatcher: own a mutable disc, locate PROT.DAT + read its TOC, apply same-size PROT-entry edits via the sector write-back.
applyOrchestration the CLI drives: randomize_drops / inject_equipment_bonus_drop / inject_flee_exp / inject_enemy_ally / inject_shiny_seru / inject_trade_full / randomize_encounters_full (encounters + the optional solo-strong pass) / randomize_chests / randomize_shops / randomize_casino / randomize_steals / randomize_arts / randomize_doors / randomize_house_doors / randomize_starting_items / apply_starting_bag / apply_starting_level / randomize_monster_stats / randomize_move_powers / randomize_element_affinity / randomize_spell_costs / randomize_equip_bonuses / randomize_equip_masks / randomize_weapon_specialty, each returning an apply report (changes + any stream too tight to re-pack / scene too big to grow in place).
ppfPPF 3.0 patch writer/reader (diff_runs / write_ppf3 / apply_ppf3). The portable, shareable deliverable - it carries only deltas the user already owns.

CLI

legaia-patcher drops     --input DISC.bin                       # read-only: monster drops
legaia-patcher chests    --input DISC.bin                       # read-only: chest contents
legaia-patcher steals    --input DISC.bin                       # read-only: steal items
legaia-patcher doors     --input DISC.bin                       # read-only: scene transitions
legaia-patcher house-doors --input DISC.bin                     # read-only: intra-town MOVE_TO targets
legaia-patcher starting-items --input DISC.bin                  # read-only: new-game starting bag
legaia-patcher shops     --input DISC.bin                       # read-only: what town stores sell
legaia-patcher casino    --input DISC.bin                       # read-only: casino prize exchange
legaia-patcher monster-stats --input DISC.bin                   # read-only: monster HP/MP/ATK/DEF/INT/SPD
legaia-patcher move-powers   --input DISC.bin                   # read-only: special-attack power table
legaia-patcher affinity      --input DISC.bin                   # read-only: element-affinity matrix
legaia-patcher spell-costs   --input DISC.bin                   # read-only: spell MP costs
legaia-patcher arts          --input DISC.bin                   # read-only: arts button combos
legaia-patcher equip-bonuses --input DISC.bin                   # read-only: equipment stat-bonus table
legaia-patcher weapon-specialty --input DISC.bin                # read-only: favored weapon class per character
legaia-patcher monster-block --input DISC.bin --id 10 --dump m10.bin      # dump one monster's decoded block
legaia-patcher monster-block --input DISC.bin --id 10 --write m10.bin \
    --output edited.bin --patch m10.ppf                       # re-pack an edited block onto a copy
legaia-patcher randomize --input DISC.bin --seed myrun --drops shuffle \
    --encounters shuffle --chests shuffle --shops shuffle --casino shuffle \
    --steals shuffle --doors shuffle --door-coupling coupled --starting-items 3 \
    --patch run.ppf --manifest run.toml
legaia-patcher randomize --input DISC.bin --seed gear --drops shuffle --equipment-drops  # +low-chance bonus gear drop
legaia-patcher randomize --input DISC.bin --seed flee --encounters shuffle --flee-exp   # +5% experience on a successful escape
legaia-patcher verify    --input DISC.bin --patch run.ppf       # apply + sanity-check

The mode flags each take shuffle / random / none; the rest are toggles or values. Every flag below belongs to legaia-patcher randomize:

FlagWhat it randomizes
--dropsMonster item drops (see Equipment drops for the additive bonus).
--encountersRandom-encounter formations. --encounter-scope widens the pool to scene (default) / kingdom / world; the solo-strong pass (cut-off --solo-strong-threshold, default 200%) is on by default, --no-solo-strong-encounters opts out. See Random encounters.
--chestsTreasure-chest contents (quest / key items kept static; override with --keep-static-items).
--shopsWhat town stores sell.
--casinoThe casino prize exchange (coin price + progression gate kept per prize).
--stealsPer-monster Evil God Icon steal items (chance preserved).
--artsThe button combo firing each Tactical Art (input count kept; Miracle Arts untouched). See Arts button combos.
--monster-statsEnemy combat stats, column-wise. See Monster combat stats.
--move-powerSpecial-attack power (enemy specials + Seru magic). See Special-attack power.
--element-affinityThe 8×8 element-affinity matrix. See Element-affinity matrix.
--spell-costSpell MP costs (named, costed spells only). See Spell MP costs.
--equip-bonusEquipment passive stat bonuses, within each slot category. See Equipment stat bonuses.
--equip-maskWho can equip each item (the equip-character mask, within each slot category). See Equip mask.
--weapon-specialtyToggle: permute which weapon class each character favors. See Weapon specialty.
--doorsScene-transition destinations. --door-coupling is coupled (default, bidirectional) or decoupled (one-way). See Doors.
--house-doorsIntra-town door warps, class-preserving (shuffle only). See House doors.
--equipment-dropsToggle: code hook granting one extra random equipment piece per battle on a low chance (--equipment-drop-chance, default 5) on top of the normal drop. See Equipment drops.
--flee-expToggle: code hook banking --flee-exp-pct% (default 5) of a fled fight's experience into the party. See Run-away EXP.
--enemy-allyToggle: code hook charming one enemy onto the party's side per battle at --enemy-ally-pct% (default 20); multi-enemy fights only. See Enemy ally.
--shiny-seruToggle: code hooks making a capturable enemy spawn shiny at --shiny-pct% (default 2) per battle - +35% stats, captured Seru +35% damage forever. See Shiny Seru.
--seru-tradeToggle: in-shop Seru-trading vendor (--seru-trade-offers caps offers per vendor, default 4). See Seru trading.
--starting-items NSeeds the new game with N random consumables (0 = vanilla Healing Leaf ×5); the first seven slots (five with --all-warps) go through the direct code seed, overflow via a silent GIVE_ITEM block injected into the opening scene.
--start-with id[:count],…Seeds explicit items on top - any id (consumable, equipment, or accessory), count default 1.
--starting-level NBegins the new game with the starting party (Vahn / Noa / Gala) at level N instead of 1 (0/1 = vanilla; range 2..=14).
--door-of-wind [N] / --incense [N]Add N Door of Wind / Incense to the starting bag (default 10 each).
--speed-chain [N] / --chicken-heart [N] / --good-luck-bell [N]Add those accessories to the starting bag (default 1 each).
--all-warpsPresets the visited-towns bitmask so Door of Wind teleports anywhere from the start.
--unused-enemies / --unused-itemsRe-introduce content the game never surfaces. See Unused content.
--keep-static-itemsOverrides the curated quest / key-item set kept static (comma list of ids, or "" to randomize all).
--patch / --output / --manifest / --dry-runWrite the PPF patch / an optional patched image copy / a shareable TOML run record (seed + options + change counts, no game bytes) / plan without writing.

The read-only subcommands in the listing above print the randomizable populations (with item / scene names off the disc's own SCUS + CDNAME tables) without writing anything - chests is where you audit the treasure pool, shops what each store sells, steals the Evil God Icon table, doors the scene-transition graph. The seed resolves from a number or a hashed string and is always printed, so a run reproduces exactly - the same seed yields a byte-identical patched image and PPF. verify applies a PPF to a copy of the user's disc and confirms the result still parses end to end - a recipient's check that a shared patch matches their own disc.

monster-block is the one manual-edit subcommand: --dump LZS-decodes a single monster's battle_data block (PROT 867) to a file for hex editing, and --write re-packs the edited block into its fixed 0x14000-byte slot on a copy of the disc, through the same patch path the stat randomizer uses. The walkthrough lives in the modding guide.

Equipment drops

--equipment-drops is genuinely additive: it grants one extra piece of equipment on a low per-battle chance, on top of the normal drop, which it never touches. A monster record has a single drop slot (+0x48 item / +0x49 chance), so no data edit can make a monster drop two things - so this feature instead patches the executable's reward routine. The battle-end reward routine FUN_8004E568 grants the normal drop via FUN_800421d4(item, 1), after which control joins at 0x8004f610; the randomizer overwrites the two instructions there with a j to an injected routine (and back) that rolls rand() % 100 < chance (default 5, via the battle RNG FUN_80056798), picks a random id from an embedded equipment table, and calls FUN_800421d4 to add it - then replays the two displaced instructions and returns. The join runs once per battle, so the roll fires once per battle. The routine + table live in the 1028-byte preserved rodata gap at 0x8007AB38 (all PSX RAM is executable); every write is a same-size in-place SCUS_942.54 edit, and the planner refuses an unrecognized build rather than corrupting it. Every gameplay preset of the in-browser patcher enables it; only “Vanilla” leaves it off.

The retail item id space is one flat table shared by consumables, key items, and equipment, with nothing flagging “this id is a weapon” in a single byte, so equipment ids are recovered by name: every weapon/armor/accessory in the curated public gamedata tables is matched case-insensitively against the disc's own item-name table to find its id (equipment_pool). The names ship in the repo; the ids come from the user's disc - no Sony bytes, and the join doubles as a cross-check of the curated tables against the executable. About 150 of the ~155 curated names resolve (a few character-default weapons + quest items don't match by name, harmless for a drop pool); the stray in-range consumable Honey is correctly excluded.

The drop rate is tiered, “both combined”: each piece is bucketed by its gamedata gold price (early ≤ 3700 G, mid ≤ 17000 G, late above - or unpriced quest gear) and each monster by its base EXP reward (early ≤ 600, mid ≤ 3000, late above), and the rate is the lower of the two tiers - a powerful weapon is rare even on a weak early enemy, and an early trinket is rare on a late boss. Tier rates are early 3%, mid 2%, late 1%. The requested late-game 0.5% is floored to 1%: the retail drop roll is integer rand() % 100 < chance (pinned in FUN_8004E568), so a sub-percent chance is unrepresentable.

Two traps in the MIPS injection features

The executable-hook features (equipment drop, run-away EXP, enemy ally, shiny Seru, Seru trading) all inject hand-assembled MIPS into dead space and detour into it with a two-word j routine + nop. Two traps bite there repeatedly.

The R3000 load-delay slot. A just-loaded register is not ready for the very next instruction - the value only lands one instruction later. An injected routine that reads a register in the instruction after its load reads the stale value; the shiny-Seru boost loop in particular cascades into garbage without honouring the slot. Every injected routine keeps a filler instruction (or reorders) between a load and its first use.

“Zero is not dead.” A run of zero bytes is safe to reuse for code only if no code reads it - being zero (and passing an assert_zero check) is not the same as being unused. This bit the injection work three times, each time on bytes that were zero: the victory mouth-override table (0x80077E80, read by FUN_8004C7B4) rendered a corrupted mouth; the move-power table (0x801F4F5C, records 4..8 zero) fed six move ids garbage damage; and the SsAPI sound tables (0x800794F0, read by FUN_8005d0b8) froze the Tetsu-tutorial Healing-Leaf banner. The fix picks regions that are all-zero in the clean image, constant-zero across states, outside every known table (the structural assert_not_in_tables guard over SCUS_TABLE_RANGES / OVERLAY_TABLE_RANGES), and read-watch-verified unreferenced on a live PCSX-Redux session - the part a static check can't prove. Routine entry VAs must be 4-byte aligned, since the j encoding drops the target's low 2 bits.

Random encounters

Formations live in the per-scene MAN asset (type 0x03, descriptor index 2 of a scene bundle), inside an LZS stream; each formation record is [3 reserved][u8 count 0..4][u8 ids...] (see encounter records). randomize_encounters locates the MAN straight from the PROT entry, rewrites the formation monster ids, recompresses, and writes the stream back. The id pool is per scene - only ids the scene already uses - so every swapped-in monster is one the scene loads; no missing model, no crash. Shuffle redistributes the existing ids (difficulty preserved); Random draws from the pool.

Bosses are protected. A scene's formation array mixes random encounters with scripted fights the field VM engages by explicit index - boss battles (the Rim Elm Tetsu tutorial, Cort, Songi, …) and story encounters. Only the genuinely random formations are touched: each region record names a [base, +count) formation slice and a rate_increment (added to the encounter counter inside its AABB), and a region with rate_increment == 0 never triggers, so it can reference a formation without ever rolling it. A formation is random iff some rate_increment > 0 region reaches it (the retail position-aware roll FUN_801D9E1C); formations reached only by rate-0 regions are left byte-identical. In town01 the rate-0 regions cover formations 2..=4 but the only rate>0 regions reach 0..=2, so Tetsu at index 4 is correctly left alone - and the Random pool is the random formations' ids only, so a roll never drops a boss into an ordinary encounter.

An explicit id guard backs the heuristic. The region-rate test classifies every story boss's formation as scripted with one exception: the early Gimard Seru-boss fight sits at a formation index a rate>0 region's range happens to span, so the heuristic alone would treat it as random - and a roll could then replace that mandatory tutorial fight (stranding a fresh save) or donate Gimard, a boss-tier enemy, into an ordinary early encounter. PROTECTED_FORMATION_IDS lists the ids that must never be a random encounter (Gimard); locate forces any formation holding one back to scripted, and such ids never enter a donor pool, so the fight ships exactly as authored regardless of the region layout. The first wild Piura are deliberately not listed - they are genuine random encounters. This mirrors the stat-side guard (monster_stats::PROTECTED_MONSTER_IDS, which also pins Gimard).

Pool scope (--encounter-scope). By default the pool is per scene, but randomize_encounters_scoped widens it: scene (each area's own monsters), kingdom (any monster in the scene's Drake / Sebucus / Karisto kingdom - late-game monsters can appear earlier in the same kingdom, but nothing ever crosses a kingdom boundary), or world (any monster on the disc, so a late-game Karisto monster can show up in the opening Drake caves). The kingdom partition is read from the disc's own CDNAME.TXT - the three overworlds (map01/map02/map03) are pinned anchors, so Sebucus begins at the first block after map01 and Karisto at the first after map02 - never a hardcoded scene list. The wider pools rely on the battle loader streaming a monster's archive slot on demand by id, so an out-of-area enemy still loads and renders. Under Random each scene draws independently from its scope pool; under Shuffle the scope-wide monster multiset is conserved (monsters move between scenes - and, for world, between kingdoms - while the overall census is unchanged), via a lock-and-reshuffle fixpoint so a re-pack skip never duplicates or drops a monster.

Solo strong fights (on by default; --no-solo-strong-encounters opts out). The wider pools can drop a late-game heavy hitter into an early area; faced as a pack of 2+ that is a soft-lock. randomize_encounters_full adds a final pass that forces any such fight to a single enemy. Each monster is scored by its combat-stat budget (monster_stats::combat_power - every stat but MP); each scene's baseline is the mean power of its original random monsters, read before randomizing - the area's authored difficulty, a stand-in for how strong the party is there. Any multi-monster random formation whose strongest member clears the threshold (--solo-strong-threshold, default 200% = twice the area's norm) is collapsed to that lone monster (keep the strongest, set count to 1, zero the rest - a same-size edit). It runs as a post-step over the already-randomized scenes, so it composes with every scope and mode; scripted / boss formations are never eligible. On by default for any CLI encounter run and in the web Balanced / Full Chaos presets.

Run-away EXP

--flee-exp banks a slice of a fight's experience into the party whenever they successfully run away - vanilla awards nothing for fleeing. Like the equipment drop, the flee path never reaches an experience grant, so there is no value to edit; this patches the executable instead.

The per-actor battle state machine FUN_801E295C (battle-action overlay, base 0x801CE818 = PROT entry 898) handles “Run” across states 0x64..0x66. State 0x66 is the successful-escape teardown, reached only when the run roll succeeds (a failed run goes 0x65 → 0x50 and the battle continues). Its handler begins at 0x801E5A10 (the fade-template setup); the randomizer overwrites the two instructions there with a j to an injected routine (and back) - a same-size raw edit of the overlay PROT entry, which maps linearly from its base. State 0x66 advances itself to the terminal 0x67, so it runs once per escape, and party HP was already floored to ≥ 1 a state earlier (the “escape restores a downed member” rule), so every member is alive at the grant. The routine sums the fled formation's listed experience (each live enemy record's EXP halfword at +0x46, the same field the victory-spoils routine reads), scales it to --flee-exp-pct% (default 5), and adds that to every party member's cumulative-experience cell (clamped to the 9,999,999 cap) - then replays the two displaced instructions and returns.

The grant is banked, not an immediate level-up: it only writes the experience cell (it never calls the level processor), so the experience shows in the status screen at once and the character levels up the next time a won battle tallies the accumulated total - small and side-effect-free during the escape fade. The routine lives in the same preserved rodata gap as the equipment-drop / name injections (0x8007AB38), at 0x8007AD00, clear of the equipment routine + its id table so both battle hooks coexist; every write is same-size in place, and the planner refuses an unrecognized build rather than corrupting it. On by default in the web Balanced / Full Chaos presets.

Enemy ally (charm)

--enemy-ally gives a per-battle chance (--enemy-ally-pct, default 20%) that one enemy starts the fight charmed onto the party's side, attacking the other enemies as an uncontrolled ally. A genuine fourth party member is infeasible (retail battles are hard-wired to three party slots), so this rides the game's own “AI-delegated” confuse/charm flag (+0x16E |= 0x380): a battle-setup code hook sets it on the frontmost enemy, and a one-word widen of the overlay victory check keeps the ally from counting as an enemy left to defeat. It fires only in multi-enemy fights - charming the lone enemy of an input-gated tutorial or a solo boss softlocks the scripted fight, so single-enemy battles are skipped.

The widen desyncs the victory arm's monster-wipe scan from the initiative scheduler (still mask 0x4), so a living charmed ally can be the acting actor at victory - and the win-pose staging then indexes the 3-byte party roster DAT_8007BD10 with a monster slot, out of bounds, arming a garbage archive request: the pinned cause of the charm battle hard-freeze. The charm_fix guard ships automatically with the feature: a single-word overlay detour at the victory-arm keep-branch (0x801E6690) into a small routine in the SCUS rodata gap that keeps the acting slot only when it is a living party slot and otherwise routes into retail's own bounded re-pick, so the roster read is always in range. It mirrors the engine's victory_pose_fixup, with the same known-build / dead-space guards as the other hooks.

Shiny Seru

--shiny-seru gives a per-battle chance (--shiny-pct, default 2%) that the frontmost capturable enemy spawns as a rare shiny variant: +35% combat stats and a translucent render - and the Seru captured from it deals +35% damage on every future cast, permanently. Cosmetics ride along: a translucent summon and a “+35% DMG!” cast caption one line below the native effect box. “Capturable” is an allowlist built at patch time from the disc's own monster names matching the player Seru-magic names; the persistent flag is a parallel per-spell byte inside the saved character record, so it survives a memory-card save. It applies to Seru captured after patching. Technically it is nine same-size code detours into SCUS_942.54 regions verified dead and outside every live indexed table.

Seru trading

--seru-trade adds an in-shop Seru-trading vendor that runs on real hardware: every merchant grows a fourth Buy / Sell / Trade / Quit row, opening a screen where a party member swaps a learned Seru-magic for a different one at a set level. Offers rotate on a play-time bucket schedule and are fully deterministic from the run's seed (the shared kernel legaia_asset::seru_trade; --seru-trade-offers caps how many trades a vendor lists at once). The feature is hand-assembled MIPS hosted entirely in the menu overlay's (PROT 0899) own reference-free dead region, so it touches no SCUS rodata gap and composes with every gap-based code feature.

Treasure chests

A chest gives its item via the field VM (the bytecode interpreter that runs each scene's event scripts) and its GIVE_ITEM opcode 0x39, encoded [0x39, item_id] - the item id is a single inline operand byte in the per-scene field-VM script bytecode, not a per-scene table (pinned in the dispatcher FUN_801DE840 case 0x39; the standalone FUN_801D71F0 add-item copy is dead/uncalled - see script VM). The give sites live in the MAN partition-1 interaction scripts (a chest is an interactable actor), almost always after the inline dialogue that announces the item ("There is a {item} in the treasure chest!"). Finding them safely needs a dialogue-skipping opcode-aware walk - a naive 0x39 byte scan would false-hit a literal 0x39 inside text. chest::give_item_sites walks each record's script with the Track-1 field-VM disassembler (legaia_asset::field_disasm); on a decode error at a 0x1F byte it skips the inline dialogue segment (to the 0x00 terminator, 0xC? bytes as 2-byte escapes) and resumes - the inter-segment control ops stay in sync, so it reaches the post-announcement give. Any other error stops the walk, and each record's walk is bounded to the next record's start so it can never mis-read a 0x39 data byte. Multi-0x39 runs are genuine multi-item gifts (a 10× consumable chest, the fishing starter kit, the Genesis-Tree Ra-Seru sets). Chest item ids are global inventory ids, so reassignment is global across every site (Shuffle preserves the multiset - a scene too tight to re-pack is excluded from the pool so its items stay put; Random draws from the item pool). On the retail disc this is 275 give sites across 50 scenes.

Display vs grant. A chest names its item in two independent bytes: the 0x39 give operand that adds the item to the bag, and a separate dialogue item-name token 0xC2 <id> that the announcement text renders ("There is a {item}…" / "{name} now has the {item}!"). Patching only the give operand grants the new item but leaves the message naming the old one - verified against a live RAM snapshot, where the loaded MAN held the patched 0x39 beside an unpatched 0xC2. Across the corpus, 0xC2 is the item-name escape (of every 0xC? dialogue escape in chest records only its argument matches the give operand; 241 of 275 sites carry one). So give_sites_and_display_tokens recovers each site's 0xC2 tokens (routed to the nearest give whose operand they name) and SceneChests::set_site rewrites the operand and those tokens together - flavor text stays in sync with the grant.

Keep-static items. Progression / quest / key items - door keys, garden-quest tools, letters, story books, one-off plot items - need to stay where the player expects them. The randomizer keeps the full quest-item set static by default, derived from the disc rather than a short hand-list: the item table prices quest / key / story items at 0 (the game's own “a shop never trades this” marker), so the default protected set is every named, unsellable item minus the chest-found equipment (the Ra-Seru gear + Astral Sword, which ship price-0 only because they're never sold but are real, randomizable gear). This covers every door / dungeon key, the egg / talisman / book collectibles, the fishing rods, the casino cards, and the internal Ra-Seru weapon-state template entries automatically - no manual list to keep in sync. Buyable items (priced > 0, e.g. the Silver Compass ambush-rate accessory) are intentionally left randomizable. A chest whose original item is in the set keeps it, the id is excluded from the shuffle multiset (so it can never move to another chest), and it is dropped from the random fill pool (so it can't be placed into an unrelated chest). The CLI flag --keep-static-items 0x9a,0x71,… overrides the set (or "" randomizes every chest); the read-only legaia-patcher chests listing is the place to audit the population and decide what to protect.

Town shops (what stores sell)

A gold merchant's stock is inline in the scene's field-VM script (the MAN), the same place chests and doors live - not a global table. Opening a shop is field-VM op 0x49 (STATE_RESUME), the multi-frame state machine that drives the menu-request register _DAT_8007B450. Its sub-op-0 inline payload, for a shop, is [u8 count][count× u8 item_id][ASCII name\0] followed by the shop's 0x1F dialogue (“Welcome!”, “Thank you!”). This was pinned from a live PCSX-Redux capture standing in the Rim Elm Variety Store - its 10 item ids match the curated shop table.

SceneShops finds sites by scanning the decompressed MAN for the op-0x49 sub-op-0 shop signature - not an opcode walk. A shop's 0x49 is often gated behind a dialogue confirm-picker ("Buy them?") whose option-jump table desyncs a linear disassembler before it reaches the op (Biron Monastery's Corey vendor is the case that exposed this), so a walk silently misses those shops. The scan doesn't care how the script reaches the op; false positives are ruled out by strict validation: the byte after the opcode must be 0x00 (sub-op 0 - this alone rejects almost every stray 0x49), a small non-zero count, every id non-zero, and a printable, letter-initial, 0x00-terminated shop name; the apply layer also passes a SCUS "id names a real item" mask so an id that names nothing can't anchor a false shop. randomize_shops reassigns the item-id bytes globally across every town shop (Shuffle redistributes the existing shop-item multiset, Random draws from the sellable pool), same-size, then recompresses each touched MAN like the chest path. On the retail disc this is 34 shops (the picker-gated vendors a walk used to miss, plus duplicate scene clusters and per-story-phase records).

No quest items; chest gear gets a price. The sellable pool is "items the game prices > 0" (read from the item table's per-record price - a u16 at record +2, base 0x80074368). Quest / key / story items all ship at price 0, so this keeps them out of shops automatically - no hand-maintained exclusion list. A handful of genuinely-equippable items are normally only found in chests and so also ship at price 0 (the Ra-Seru weapon/armor/shoe set + Astral Sword); randomize_shops first prices those (~28800–55000 gold, approximated from the nearest priced gear of the same type) with a same-size SCUS edit, so they're non-free and part of the sellable pool.

Casino prize exchange

The casino prize list (redeem coins for prizes) is a different mechanism from the gold town shops: it is a static table in the menu overlay's data segment (DAT_801e4518), and it debits the casino coin bank (_DAT_800845A4), not gold - which is how it's told apart from a gold merchant. It lives in PROT entry 899 (0899_xxx_dat, stored raw), file offset 0x15D00 (VA 0x801E4518 under the overlay data-segment load base 0x801CE818), as four 0x60-byte blocks of 8-byte [u16 item_id][u16 story-gate][u32 coin-price] records (the high-value prizes carry a non-zero gate that locks them behind casino progression). CasinoExchange shuffles / randoms the whole records (so a prize keeps its coin price and progression gate wherever it lands), a same-size raw edit with no LZS. The casino buy UI shares the same handlers as the town shops (FUN_801d5de0 / FUN_801dc1cc).

Steal items (Evil God Icon)

What the player steals from a monster (with the Evil God Icon equipped) is a per-monster entry in a static SCUS_942.54 table at DAT_80077828 - [steal_chance_pct, steal_item_id] per 1-based monster id, item at +id*2+1 (see steal table). It is not in the PROT 867 monster record, which is why a long-running search that scanned only the record came up empty; the table was pinned from a live player-steal RAM capture and verified byte-exact against the complete published steal table (item and chance) across every resolvable monster id.

Because it's a plain executable table, a steal edit is the simplest one: a single same-size byte overwrite of the item, applied straight to the SCUS file via DiscPatcher::patch_named_file (the non-PROT sibling of the PROT-entry patch). No LZS re-pack, no overflow, so nothing is ever skipped. randomize_steals reassigns the item for every stealable monster (Shuffle redistributes the existing steal-item multiset, Random draws from the valid item pool) and preserves each monster's steal chance - the item changes, the rate doesn't. On the retail disc 189 monsters are stealable; legaia-patcher steals lists the current table.

Monster combat stats

--monster-stats redistributes every enemy's combat stats across the battle_data archive (PROT entry 867). Each monster's record carries its stats as u16 halfwords at fixed offsets in the decoded block (HP +0x0C, MP +0x10, then ATK / DEF↑ / DEF↓ / INT / SPD; see battle-data pack). The randomizer works column-wise: it collects each stat field across the whole populated roster, then Shuffle permutes that column (a 1:1 reassignment, so the multiset of, say, every monster's HP is exactly preserved - the overall difficulty budget stays put, only which monster is tanky changes), while Random draws each cell from the column pool. The AGL action gauge (+0x0E) is left alone, since it gates the AI's action economy rather than player-facing difficulty. Each edit re-packs the monster's slot through the same decompress → edit → recompress path as the drop randomizer (monster::repack_slot); the decoded length is unchanged, so every slot keeps its 0x14000-byte footprint (a slot too tight to re-pack is skipped, as with drops). A set of scripted enemies is excluded from the pass - both as a source and a target - so each keeps its original stats and never donates them elsewhere: the early tutorial enemies (the Rim Elm sparring partner and the first wild Piura), whose fights are unwinnable-by-design or deliberately fragile, so a hard-hitting attack could soft-lock a fresh game; and the story bosses (Caruban, Zeto, Songi, Berserker, Tetsu, Dohati, Xain, the three Delilas, Gaza, Zora, Jette, Cort - every version), whose set-piece fights are tuned around scripted HP/phase triggers and whose extreme stats would wreck balance if leaked onto a trash mob. It is the stat-side companion to the boss protection the encounter randomizer already applies, and Shuffle still preserves the column multisets exactly (the pinned values are conserved in place). legaia-patcher monster-stats lists the current stats.

Special-attack power

--move-power redistributes the per-move power values in the battle-action overlay's move-power table (0x801F4F5C, PROT entry 898; see move-power). The damage kernel reads each 26-byte record's +0x00 halfword as the move's power roll modulus - this is the special-attack power space (enemy specials + Seru-magic), not party Tactical Arts, which take their power from the per-strike art-record byte. Only the +0x00 halfword moves, and only among populated records - empty records, including the index-0 sentinel the table self-identifies by, stay all-zero, so a power is never handed to an unused slot. The other 24 bytes of each record (strike geometry, phase timing, impact-effect / trail / sound cue, contact + launch effect lists) are untouched, so every move keeps its own animation and effects; only how hard it hits changes. PROT 0898 is stored raw, so the write is a same-size raw-entry edit. legaia-patcher move-powers lists the table, each entry tagged with the spell-table name of a move that resolves to it.

Element-affinity matrix

--element-affinity scrambles which element beats which. The battle-action overlay carries an 8×8 affinity matrix (matrix[attacker][defender], PROT entry 898; see the matrix description in move-power and battle formulas) whose cells are damage-scale percentages (100 neutral, > 100 weak, < 100 resist, 0 immune). Shuffle permutes the 64 cells (the multiset of scale percentages is preserved - the same number of weaknesses / resistances exists, just between different element pairs); Random draws each cell from that pool. Only the matrix moves; the per-character element assignment and the summon-power rows are left untouched, so the change is purely which element pairs interact. PROT 0898 is raw, so the write is same-size in place. legaia-patcher affinity prints the labeled grid.

Spell MP costs

--spell-cost redistributes MP costs across the named, costed spells in the static SCUS_942.54 spell table (DAT_800754C8, cost at record +3; see spell table). Shuffle permutes the cost column (the MP multiset is preserved - every cost still exists, on a different spell); Random draws each from that pool. Only the +3 byte moves, and only named, non-zero-cost spells participate, so free / internal enemy-tier entries never gain a cost and names / target shapes are untouched. The table is in SCUS_942.54, so the edit is a same-size in-place SCUS patch via patch_named_file (like steals). legaia-patcher spell-costs lists the table.

Equipment stat bonuses

--equip-bonus redistributes the passive stat tuples ([INT, ATK, UDF, LDF, SPD]) of the static SCUS_942.54 equipment bonus table (DAT_80074F68), within each slot category - a weapon's stats only ever land on another weapon, armor on armor - so the equip-character mask, accessory passive, and slot type stay welded to their row and the per-category power budget is kept. It edits bonus rows, not item ids (several items can share one record, so a per-id rewrite would double-edit a shared row), and rows no equippable item references are left untouched. A same-size in-place SCUS patch. legaia-patcher equip-bonuses lists the table, grouped by slot category, with the items that reference each row. The sibling --equip-mask pass edits the same table's disjoint +6 byte, so the two compose.

Equip mask (who can equip what)

--equip-mask randomizes the +6 equip-character mask of that same bonus table (1 Vahn, 2 Noa, 4 Gala, 7 = any) - who can wear each piece of gear. It moves only the +6 byte, disjoint from the stat pass, so the two compose: run both and a shuffled-stat sword also lands on a shuffled owner. The reassignment is within a slot category (+7 & 0x60), so Shuffle preserves each category's mask multiset - a character keeps exactly the same count of equippable weapons / body / head / footwear it had in retail (it can never be left with zero equippable gear in a slot); Random draws each row's mask from its category pool. Like the stat pass it edits bonus rows (not item ids) and skips rows no equippable item references, so a garbage row can't hand a real item an unequippable (zero) mask. The engine reads the same +6 byte (legaia_engine_core::equipment::DiscEquipInfo::can_equip), so a patched disc re-gates each character's equip picker. legaia-patcher equip-bonuses lists the current mask (V/N/G / any) beside each row.

Weapon specialty

--weapon-specialty (a toggle, not a mode) reassigns which weapon class each character favors (Vahn blades, Noa claws, Gala clubs/axes by default), permuting the three families as a seeded bijection so each class keeps exactly one specialist. An off-class weapon widens that character's Arms command in an arts combo, so fewer commands fit - and that cost is not a runtime class comparison but a per-(character, weapon) arm-cost byte baked into the player battle files, so the pass decompresses each weapon section, rewrites the byte for the new favored relationship, and re-compresses in place. The Astral Sword carries no family and stays always-wide. legaia-patcher weapon-specialty shows each character's current favored class.

Arts button combos

--arts reassigns the directional button combo that fires each Tactical Art. A combo lives in two files that must change together: the matcher records in each character's player battle file (an LZS-packed record rewritten in place) and the SCUS arts-name table's menu-arrow glyph string - patching only one desyncs the menu display from the actual trigger. Because the display strings are deduplicated across characters, the assignment permutes distinct combo strings within each length class: every art keeps its input count (a 4-input art stays 4 inputs), each character's combos stay unique, and the per-character Miracle Art is left untouched. legaia-patcher arts lists the current combos.

Reference Art records

Doors (scene transitions)

A field scene reaches another scene through the field-VM 0x3F named-scene-change op, which carries its destination inline: [i16 index][u8 name_len][name][entry_x][entry_z][dir]. These ops are partition-2 MAN records, reached at runtime through the partition-2 record-offset table - the controller sets the VM bytecode base to man_base + data_region + partition2[slot] and runs the record (pinned by a PCSX-Redux dispatch trace; see MAN relocation). On the retail disc there are 160 doors across 48 scenes; the overworld scenes (map01/map02/map03) are the hubs.

Because the destination name is variable length, randomize_doors is the only randomizer that resizes an asset: it rewrites the 0x3F op through the relocation engine, recompresses the MAN, and rewrites the descriptor's decompressed-size word. The whole destination descriptor (scene + entry tile + facing) moves as one unit, so a re-pointed door always lands you somewhere valid.

--door-coupling picks the connectivity. Coupled (bidirectional, default) re-pairs doors into two-way connections via a random involution - for matched doors A and B, A is sent to where B is reached from and vice versa, so walking through a door and turning around returns you the way you came; doors with no reverse partner (dead-end / one-way story warps) fall back to the one-way assignment and are reported. Decoupled (one-way) reassigns every door's destination independently, so going back through the destination's own doors is not guaranteed to return you. A scene whose rebuilt MAN can't grow within its on-disc footprint (the big overworld hubs, whose next asset sits flush after the MAN) is skipped - it keeps its original doors - and reported, rather than relocating the whole bundle.

House doors (intra-town)

Entering a house/interior within a town is not a scene change - it's an intra-scene reposition: the field VM teleports the player to an interior sub-area tile in the same scene (pinned at the instruction level by the probe.step.find_writer Lua primitive; the writer is the field-VM dispatcher FUN_801de840 case 0x23 - see PCSX-Redux automation). The door warp has a clean structural marker: it is the cross-context form 0xA3 0xF8 xb zb - opcode 0x23 | 0x80 dispatched into the player system channel 0xF8 ("make the player move"), while plain 0x23 moves the executing actor (NPC / prop / cutscene positioning). The carrying partition-0 records pair entries with exits by a fullwidth naming convention (IN/OUT, 入口/出口 gates, A/B elevator endpoints, optional digit suffixes); the live-captured Mei's-house warp is byte-for-byte the 0xA3 0xF8 0x61 0x36 in an IN record. On the retail disc the classifier finds 56 door warps (27 entries + 29 exits) across 12 scenes; towns absent from that census reach interiors through 0x3F scene-change doors (the standard door randomizer's population).

--house-doors shuffle does a per-scene, class-preserving shuffle: entry targets permute among entries, exit targets among exits - every exit still lands outside (no interior-to-interior softlock is constructible) and NPC/prop positions never move. A same-size 2-byte operand edit recompressed in place; the read-only house-doors listing shows the classified population per scene. Disc-gated oracles pin the exact per-scene census against the disc and drive the patched warp through the engine's field VM.

Shuffle eligibility = exactly one player warp per door record, with a real target. A door-named record carrying several player warps is riding choreography, not a door endpoint: the tower's multi-stop elevator-2 pair branches between floor tiles and interleaves (0, 0) sync repositions between waits - permuting those would corrupt the ride. Its warps are counted in the audit and left vanilla; the scene's seven single-warp elevator endpoint pairs stay in the pool.

Unused content

The game ships fully-formed content it never surfaces in normal play; two opt-in toggles bring it back. They are additive - a normal run never places them, so the disc stays vanilla unless you ask.

--unused-enemies re-introduces two cut enemies that no scene's encounter formation references: "Comm" (id 78, a complete standalone record - HP 2520, casts magic, exp 945) and the Evil Bat (monster ids 176/177/178, byte-identical clones of each other and of the in-use Evil Bat at id 140). The battle loader streams a monster's 0x14000 archive slot on demand keyed by its id - there is no per-scene monster preload list - so injecting one of these ids into a formation byte is enough to make it spawn and render; nothing else needs patching. The toggle adds the curated ids to each scene's encounter candidate pool. It only takes effect with --encounters random: a multiset-preserving shuffle can't introduce a new monster.

--unused-items adds two items to the random-fill pool the random drop / chest / steal modes draw from: “Something Good” (0x6B), a 50,000 G sell item the game never hands out (it is named, so the valid pool already accepts it - the toggle includes it for clarity); and the unnamed accessory (0xFD), an accessory-class slot whose name string is empty, so the valid pool excludes it - the toggle is what makes it obtainable. Because a blank name would read as an empty line in chests and menus, the toggle also names it “Seru Bell”: it writes the string into preserved rodata padding of SCUS_942.54 (0x8007AB40) and repoints only 0xFD's name pointer at it (a same-size patch, the same technique as the starting-item seed; the other ids that share the empty-string slot - 0x12/0x1A/0x52/0xB9 - stay blank). Picking the spot is subtle: the data segment's trailing zero-fill is .sbss scratch the game overwrites every frame, and even an always-zero region can be boot-cleared scratch that wipes the string to empty. The reliable test is the flanking bytes - the chosen 1028-byte gap at 0x8007AB38 is bordered by rodata constants that survive file→RAM byte-for-byte across diverse states, proving it is read-only padding the loader keeps. The accessory's documented effect is to make only Seru-class enemies appear in random encounters; because it is unobtainable in retail that effect is never exercised, so treat it as experimental.

Superseded readings / past pitfalls (do not re-walk)
  • Starting level: seeding only the lead character's XP cells leaves Noa with experience 0 and Gala a stale level-1 threshold - the XP/threshold seed must cover every growth-capable character, as the current pass does.
  • Chest walk: stopping the opcode walk at the first 0x1F dialogue byte silently misses the post-announcement give in most sites (every chest in a dialogue-first scene such as keikoku) - the walk must skip each dialogue segment and resume.
  • Name-string placement: the data segment's trailing zero-fill is .sbss scratch the game overwrites every frame - a string written there flickers/vanishes. Only rodata padding whose flanking bytes survive file→RAM is safe.

Tests

  • CI crates/lzs round-trips (decompress(compress(x)) == x) across literals, RLE, repeats, pseudorandom, >4 KB-window input, plus a compression-ratio guard; crates/iso write unit tests (encode idempotent, corrupting user data invalidates until re-encoded, ECC address-independent, seam-straddling patch keeps both sectors valid); crates/patcher planner determinism, surgical set_drop, and a synthetic-disc patch round-trip through the disc → ISO → PROT chain.
  • disc-gated the LZS encoder round-trips + compresses real monster records and container sections; the EDC/ECC encoder reproduces real PROT.DAT sectors bit-for-bit and a one-byte patch+restore round-trips a real sector exactly; a real monster's drop is patched onto a scratch copy of the disc, re-decoding off the patched image with neighbours untouched and sectors valid.
  • disc-gated a full-archive drop shuffle diffs into a PPF that reproduces the patched image (deterministic per seed); a whole-disc encounter shuffle re-decodes every patched scene MAN and asserts counts + monster-id multiset preserved, ids in-pool, sectors valid; a whole-disc chest shuffle asserts the 0x39 give-item site offsets are unchanged, the chest-item multiset is preserved, and sectors stay valid; a targeted keikoku-chest patch asserts the give operand and every announcement item-name token both carry the new id, re-decoded off the patched image; a whole-disc steal shuffle re-reads the patched SCUS_942.54 steal table and asserts the steal-item multiset is preserved, every steal chance byte is untouched, and the table sector stays valid; a whole-disc door shuffle (one-way + coupled) re-decodes every patched scene MAN and asserts the destination multiset preserved (clean shuffle) / names valid (with skips), sectors valid, image size unchanged; a whole-disc intra-town house-door shuffle asserts the per-scene per-class door-warp target multiset is preserved, sectors valid, image size unchanged (a sibling census test pins the exact per-scene entry/exit counts and the live-captured Mei's-house anchor against the disc); the bonus-equipment-drop injection asserts the patched SCUS_942.54 carries the j routine detour plus the hand-assembled routine + equipment-id table (replaying the two displaced instructions and returning), the edit is surgical, deterministic, and the build guard refuses a corrupted hook / non-dead routine region; and a town-shop + casino pass enumerates every shop (asserting the Rim Elm Variety Store + its 10 ids, names printable, ids named), a shop shuffle preserves the global shop-item multiset + per-shop counts/names, and a casino shuffle preserves the (item, coin-price) prize multiset + block counts - each byte-deterministic for a fixed seed. The man_edit relocation engine has its own CI unit tests (grow / shrink a name relocates the section + later records, a spanning jump's delta is fixed, the rebuilt MAN re-parses).
  • disc-gated runtime oracles in crates/engine-core close the loop the patch tests leave open - not just that a patched byte is written faithfully, but that a runtime reads it and grants the new item. The clean-room engine decodes straight from the patched disc bytes and runs the actual grant path, so it observes a patch a savestate would mask (the scene MAN / battle_data archive is resident in RAM the moment you're in the room / battle, so a state captured on a patched disc still serves the original from its cached RAM copy). The chest oracle patches one chest, re-decodes the MAN, drives the chest's inline interaction script through the real field VM, and asserts the runtime grants the patched id; the monster-drop oracle patches one monster's drop item, re-decodes the record, builds the engine catalog, drives a one-monster formation through the victory-spoils path, and asserts the runtime drops the patched id; the encounter oracle patches one scene formation's monster id, re-decodes the MAN, builds the encounter table from those bytes, forces that formation into a battle through the live-loop encounter path, and asserts the spawned enemy carries the patched id; the steal oracle patches one monster's steal item byte in the static SCUS_942.54 steal table, re-decodes the table, drives the engine steal-grant kernel, and asserts the runtime steals the patched id (chance untouched); the door oracle patches Rim Elm's exit to a differently-named scene, re-decodes the MAN, drives the patched 0x3F op through the real field VM, and asserts the runtime warps to the patched destination; the unused-enemy oracle runs the toggle path until it places an unused Evil Bat id at a formation slot, re-decodes off the patched image, forces that row into a battle, and asserts the spawned enemy carries an unused-enemy id; and the unused-item oracle asserts the engine item-name table resolves 0xFD to “Seru Bell” (the display side) then patches a monster's drop to 0xFD and asserts apply_battle_loot grants the unused accessory (the grant side); the shop + casino oracle patches a town-shop slot (scene MAN op 0x49) and a casino prize (PROT 899 table), re-decodes the patched stock, and drives World::buy_from_shop (the buy-grant kernel shared with the menu runtime's ShopConfirm commit) to assert the runtime sells the patched id - each with a non-vacuous baseline that grants (or spawns / warps to / sells) the original first. (The bonus equipment drop and the run-away EXP grant are injected executable hooks the clean-room engine can't run, so they have no runtime oracle - each is covered by the byte/disassembly checks above plus an emulator playtest.)

Disc-gated tests read LEGAIA_DISC_BIN; with it unset they skip and pass.

No-Sony-bytes hygiene

The crate never embeds, commits, or redistributes game bytes. A patched .bin contains Sony data and is never committed; the intended distribution form is a patcher tool + seed, and/or the PPF patch the CLI emits - a PPF carries only the deltas between the user's original disc and the patched one, so it is meaningless without the original image the user already owns.

See also