Randomizer / disc patcher
Track-1-adjacent tooling that edits gameplay data on a user-supplied retail disc image — monster item drops, random-encounter formations, treasure-chest contents, per-monster steal items, scene-transition doors/exits, intra-town (house/interior) doors, and the new game's starting items — and writes the result back into the .bin. The legaia-rando CLI turns a disc + seed into a portable PPF patch (the shareable deliverable) plus an optional patched image for local play. It does not touch the clean-room engine. The crate ships only code; every test that needs real data is disc-gated, so CI runs without a disc.
Why this needs three new capabilities
Most editable values live inside a Legaia LZS stream 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):
- An LZS encoder —
legaia_lzs::compress. The retail game ships only a decoder (FUN_8001A55C); there was no way to produce a stream it accepts. LZS compression → - 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 → - A disc bridge —
legaia_rando::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 0x23 MOVE_TO tile shuffle) overwrite bytes in place and never change a byte count, so no LBA, PROT TOC, 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, 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
| Module | Role |
|---|---|
rng | Version-stable SplitMix64. A published seed always reproduces a run; the first output for seed 0 is pinned by a test. |
items | Valid item-id pool from the SCUS item-name table, so a randomized drop is always a real item. |
drops | Drop-table planner. Shuffle redistributes the existing drops (preserves the economy); Random draws from the pool. Deterministic in (drops, pool, seed, mode). |
monster | Re-pack a monster slot: decompress → in-place mutate → recompress → zero-pad to 0x14000. set_drop is the drop wrapper. |
encounter | SceneEncounters: locate a scene bundle's MAN in a PROT entry, shuffle its formation monster ids from the scene's own id pool (every monster stays scene-loaded), recompress. |
chest | give_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. |
steal | StealEdits: 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. |
door | SceneDoors: 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_door | SceneHouseDoors: intra-town (house/interior) doors are a field-VM 0x23 MOVE_TO to an interior tile within the same scene (intra-scene reposition). Per-scene, multiset-preserving shuffle of the non-sentinel MOVE_TO target tiles (same-size 2-byte edit). Shuffle-only + experimental — the op is shared with NPC/cutscene movement. |
starting_items | New-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). Up to 5 random consumables (0x77..=0x8e), one packed halfword store per slot — a same-size code patch. |
unused | Curated “unused content” the opt-in toggles re-introduce: UNUSED_ENEMY_IDS (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_name | NameInjection: 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). |
disc | DiscPatcher: own a mutable disc, locate PROT.DAT + read its TOC, apply same-size PROT-entry edits via the sector write-back. |
apply | Orchestration the CLI drives: randomize_drops / randomize_encounters / randomize_chests / randomize_steals / randomize_doors / randomize_house_doors / randomize_starting_items, each returning an apply report (changes + any stream too tight to re-pack / scene too big to grow in place). |
ppf | PPF 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-rando drops --input DISC.bin # read-only: monster drops
legaia-rando chests --input DISC.bin # read-only: chest contents
legaia-rando steals --input DISC.bin # read-only: steal items
legaia-rando doors --input DISC.bin # read-only: scene transitions
legaia-rando house-doors --input DISC.bin # read-only: intra-town MOVE_TO targets
legaia-rando starting-items --input DISC.bin # read-only: new-game starting bag
legaia-rando randomize --input DISC.bin --seed myrun --drops shuffle \
--encounters shuffle --chests shuffle --steals shuffle \
--doors shuffle --door-coupling coupled --starting-items 3 \
--patch run.ppf --manifest run.toml
legaia-rando verify --input DISC.bin --patch run.ppf # apply + sanity-check
--drops / --encounters / --chests / --steals / --doors each take shuffle / random / none; --door-coupling is coupled (default, bidirectional) or decoupled (one-way); --house-doors shuffle is the experimental intra-town MOVE_TO tile shuffle; --starting-items N seeds the new game with N random consumables (0 = vanilla Healing Leaf ×5, capped at 5); --unused-enemies and --unused-items re-introduce content the game never surfaces (see Unused content). The read-only drops, chests, steals, doors, house-doors, and starting-items subcommands 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, steals the Evil God Icon table, doors the scene-transition graph. --keep-static-items overrides the curated quest / key-item set kept static (default), accepting a comma list of ids or "" to randomize all. 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. --dry-run reports the plan without writing; --manifest writes a small TOML record of the seed + options + change counts (no game bytes — safe to share). 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.
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. On the retail disc this rewrites ~1150 formation ids across 51 scenes.
Treasure chests
A chest gives its item via the field-VM 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. (An earlier walk stopped at the first 0x1F, silently missing the post-text give in ~85% of sites — including every chest in a dialogue-first scene such as keikoku.) 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. A handful of chest items are progression / quest / key items the player needs in a known place. The randomizer keeps a curated default set static — Mary's Diary, Dark Stone, Fertilizer, Weed Hammer, Spring Salts, Silver Compass, and the Old Rod. 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 duplicated into an unrelated chest). The CLI flag --keep-static-items 0x9a,0x71,… overrides the set (or "" randomizes every chest); the read-only legaia-rando chests listing is the place to audit the population and decide what to protect.
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-rando steals lists the current table.
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 runs a 0x23 MOVE_TO op that 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). So the door "record" is the op's two operand bytes [0x23][xb][zb] (tile = byte & 0x7F). --house-doors shuffle does a per-scene, multiset-preserving shuffle of the non-sentinel MOVE_TO target tiles — every target stays a tile the scene already uses (no off-map placement), a same-size 2-byte operand edit recompressed in place. On the retail disc there are 220 shuffleable targets across 28 scenes.
Experimental — the op is shared. 0x23 MOVE_TO is also how NPC / cutscene scripts move actors, and there's no clean structural marker separating door warps from those, so the shuffle also scrambles some actor positions within each town. It is opt-in, shuffle-only (a random draw would place actors off-map), and excludes the (0x7F, 0x7F) "here" sentinel. The read-only house-doors listing shows the touched population per scene.
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 the Evil Bat, an enemy whose record lives in the battle_data archive (monster ids 176/177/178 are byte-identical clones of each other and of the in-use Evil Bat at id 140) but which no scene's encounter formation references. 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 (an early attempt there flickered), 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.
Tests
- CI
crates/lzsround-trips (decompress(compress(x)) == x) across literals, RLE, repeats, pseudorandom, >4 KB-window input, plus a compression-ratio guard;crates/isowriteunit tests (encode idempotent, corrupting user data invalidates until re-encoded, ECC address-independent, seam-straddling patch keeps both sectors valid);crates/randoplanner determinism, surgicalset_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
0x39give-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 patchedSCUS_942.54steal 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; and a whole-disc intra-town house-door shuffle asserts the per-scene0x23 MOVE_TOtarget-tile multiset is preserved, sectors valid, image size unchanged — each byte-deterministic for a fixed seed. Theman_editrelocation 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-coreclose 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_dataarchive 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 staticSCUS_942.54steal 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 patched0x3Fop 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 resolves0xFDto “Seru Bell” (the display side) then patches a monster's drop to0xFDand assertsapply_battle_lootgrants the unused accessory (the grant side) — each with a non-vacuous baseline that grants (or spawns / warps to) the original first.
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.