At a glance

Magic
none - operates on a decompressed scene MAN (header, three partition record-offset tables, record region, then the section chain)
Where on disc
Every scene bundle's LZS-compressed MAN, described by the scene_asset_table descriptor word (type<<24)|size
Stride
u24LE record-offset entries from 0x2B; the data region starts at 0x2B + 3 × total_records
Parser / editor
legaia_scene_formats::man_edit (apply_dest_edits, apply_text_edits, rebuild_man, validate)
Confidence
Confirmed - dispatch path pinned by a live PCSX-Redux trace plus a disc-wide corpus census; every rebuild is re-walked against the original (how we know)
Used by
Door randomizer, Translation packs

Why resize a record at all?

Two consumers force the problem:

  • The door randomizer. Its door shuffle re-points a scene exit at a different destination. Because the destination is an inline name, swapping town01 for a longer scene name grows the record; without the fixups below, every record after it - and the tables that index them - would decode as garbage.
  • Translation. A language pack's dialog line that runs longer than the English original grows its record the same way. Growth is budgeted by the MAN's on-disc footprint: the recompressed file must still fit its original slot.

A resize is safe because the doors are reached at runtime through an offset table the format already exposes, not through absolute addresses scattered in code. Fix the table and every door stays addressable.

Relocation surface

A mid-buffer insert or delete of delta bytes at a record needs four fixups. Everything before the edit stays put; everything after it shifts.

Highlighted cells are the bytes rebuild_man rewrites. data_region = 0x2B + 3 × total_records is derived and never moves. Fixup 4 lives outside the MAN, in the scene bundle's descriptor word.

FixupWhereRule
1. u24_at_28MAN[0x28..0x2B], u24LE, relative to data_regionThe section chain sits after the records, so it shifts by the total delta
2. Partition record-offset tablesMAN[0x2B..], u24LE entries relative to data_regionBump every entry whose record starts after the edit by delta. This table is the door dispatch index
3. Intra-record relative jumpsOps 0x26 / 0x42 / 0x4D / 0x4E (conditional sub-ops) / 0x70 in the edited recordRecompute the stored 16-bit delta when source and target straddle the edit; a jump wholly on one side is unaffected. The delta field sits at the jump's relative base, so the rewrite is op-agnostic. Deltas are signed: a high-bit delta is a backward jump, such as the 26 FE FF park loop
4. External descriptorscene_asset_table word (type<<24)|sizeThe decompressed size lives only here - rewrite it after recompressing

How a door is reached: the partition-2 table

The field VM is the interpreter that runs a scene's event bytecode. A MAN's records are grouped into three partitions; partition 2 holds the named event records, doors included. A 0x3F op carries its destination inline:

  • index is the destination-scene id handed to the warp packet - not a record selector.
  • name_len + name (a CDNAME symbol) are what make the record variable-length.
  • The trailer sets where the player appears and which way they face on arrival.

On a transition the field controller sets the VM bytecode base to man_base + data_region + partition2[slot] and runs that record by fall-through - flag sets, an effect, a yield, the 0x3F warp, its entry trailer, then a self-loop. Selection is by stable slot index, so the offset table is the door index and resizing a record is safe once the table is fixed.

The table is flat across the three partitions and the runtime resolver indexes it that way, but each partition prefixes its records with a different header, so a record's script start is not a function of the index alone (field VM).

Safety

  • No absolute PC exists. The field VM stores no absolute script address anywhere, so a same-record shift preserves every control-flow field. The halfword trailing 0x45 0xC0 (camera apply) looks like one but is a camera parameter - the arm passes it to the camera configure call and falls through four bytes - so the editor leaves it alone.
  • Validate. man_edit::validate re-parses and re-walks the rebuilt MAN and confirms each edited op still decodes as a 0x3F carrying the intended name.
  • Edit the carrier, not the scene name. An edit is keyed to the PROT entry the MAN actually lives in - a scene may have no MAN, a bundle MAN, or a bundle MAN plus a streaming variant. Every carrier is byte-unique, so two names resolving the same MAN is a framing bug, and editing under the wrong one would rewrite a neighbour's sectors.
  • Footprint. The recompressed MAN must fit the original asset's on-disc footprint (the gap to the next descriptor). A scene that cannot grow in place - the big overworld hubs, whose next asset is flush after the MAN - is skipped and reported rather than relocating the whole bundle.

Generalized interior-text growth

The same machinery generalizes from a door's destination name to any interior byte run - the dialog text a localization grows or shrinks. man_edit::apply_text_edits takes a set of TextEdit { offset, old_len, new_bytes } and rebuilds the MAN through the identical rebuild_man path. A dialog segment is a 0x1F <text> 0x00 run in the script stream - most often a bare segment the actor-dialog state machine parks on, otherwise the inline text of a 0x49 0x00 state-resume or a 0x4C E1 balloon. The decoder walks bare segments and the 0x27..0x2A picker jump tables as strides of the stream, so a record's walk crosses its dialogue, a grown run keeps the fall-through decode in sync, and a picker's i16 entries (each relative to its own offset) are relocated with the jumps. The walk starts at each partition's own script start.

Three invariants keep this safe:

  1. Record-region gate. Every edit must lie strictly before section 0, inside a partition record. Dialog is field-VM script, never a data section, so an edit that would touch the section chain (whose length prefixes this pass does not fix) is refused.
  2. Round-trip backstop. man_edit::text_edits_preserve_scripts(original, rebuilt) re-walks every record in both buffers and requires an identical instruction stream - same opcodes in order, every control-flow target resolving to the same instruction ordinal. A mis-relocated jump is caught; the caller drops the growth and falls back to same-size abbreviation for that scene.
  3. Text-site gate. man_edit::text_site classifies a run by where it sits on the record's walk: Segment (text an instruction carries - dialog), Operand (an instruction spans it, e.g. CC 1F 50 4B 00 reads like text but is a menu-control op) or Unreached. Operand runs are refused outright; unreached runs are written same-size only.

Budget = the MAN's on-disc footprint. Across the retail USA disc the scene-bundle entries are sector-aligned with zero padding and each compressed MAN already fills its footprint, so in-place growth fits only when the rewritten MAN recompresses no larger than the original - a small fraction of scenes. The rest are sector-crossers whose deficit is under one 2048-byte sector; supplying it is a disc-level relayout, which is what the PAL discs did at mastering (disc relayout, translation tooling).

The same question for a mesh pack

A scene's TMD pack poses the relocation question in its simplest form, and the answer runs the other way: nothing outside the pack holds a byte offset into it, so growing a member costs a rebuild of the pack entry and nothing else. The worked case is the Nivora Ravine duel scene's NPC mesh pack, whose 112 members tile its declared stream exactly - the first begins where the header ends and the last ends at the declared length, with no gap between any pair.

Every reference from outside is an index. The runtime mesh pool is simply the descriptor walk's registration order, one slot per member; a placement names a pool slot; a scene animation record names a record number; and the scene bundle's own descriptors carry offsets into the bundle, which is a different entry entirely. A byte scan across every entry on the disc agrees and sharpens it: each of the three Delilas members' offsets occurs exactly once, in the pack's own table, and the pack's declared length occurs nowhere outside the header that encodes it.

So the cost of growing a member is: rewrite the offset table from that member onward, rewrite the declared length, and keep the result inside the entry's sector footprint. The same pack carried inside a bundle adds two words in the carrying entry - that descriptor's decompressed size and the header's size sum - and the requirement that the recompressed stream still fits its span. The budget is the one the MAN faces above: retail's scene entries are sector-aligned with no padding, so a member that grows is paid for by another member shrinking.

How we know

Provenance: the VM, the warp packet, the resolver, a live trace and a corpus census
EvidenceAddress / sourceWhat it proves
Field VMFUN_801DE840The interpreter that executes the door record by fall-through; 0x3F is the named scene change
Warp packetFUN_8001FD44Receives the op's index as a destination-scene id, not as a record selector
Runtime record resolverFUN_8003C8F0Indexes records by partition slot through the record-offset tables
Live dispatch tracePCSX-Redux breakpoint trace of a door transition out of Drake CastleThe executing op's bytecode base minus the MAN base equals data_region + partition2[0] exactly
Corpus censusclean partition walk, disc-wide160 destination ops across 48 scenes, 153 in partition 2; 37 carry a straddling relative jump

Full page: docs/formats/man-relocation.md.

See also