At a glance

Where
section 0 of each scene's MAN (the scene's script-and-data bundle, descriptor 2 of its scene bundle), or inline in field-VM bytecode
Size / stride
4 + count bytes; MAN rows use a fixed 8-byte stride
Pointer slot
actor[+0x94] names the armed record; no magic word
Destination
formation cell 0x8007BD0C..0F (four monster-id slots)
Parser / engine
legaia_scene_formats::man_section, scene-formats::encounter_record, engine-battle::region_encounter
Confidence
Confirmed (record, reader, both arming paths); Inferred for how each inline opcode packs its three header bytes
Used by
battle loader, world-map entity tick, encounter randomizer

The record

A count followed by monster ids, with three bytes in front that the copy never reads. The reader zeroes the formation cell, then copies count ids; a count of 0 leaves the cell empty.

FieldMeaning
scripted-fight predicateNon-zero raises the "scripted fight" bit in the per-battle flags byte. On a MAN row it is authored per row; for an inline arm it is the install opcode itself. The raised bit is what makes a fight a "boss" fight: it picks the harder enemy stat-boost profile, blocks the escape roll, and gates the Seru-magic side-effects (see the enemy table).
reservedNot consumed (opcode operands, or scratch on a MAN row).
monster_count0..=4.
monster_ids[]Each id indexes the monster archive (PROT 867).

The reader clears all four slots of the formation cell at 0x8007BD0C..0F, then copies count ids into slots 0 upward; trailing slots stay zero. Bytes +0x01..+0x02 are never read, and +0x00 only feeds the flags byte.

How a fight gets armed

Nothing in the field VM is called "encounter". A record is armed by pointing an entity's +0x94 slot at it; the entity's state machine (the world-map / field entity tick) later reaches its confirm state and performs the copy, exactly once per arm.

  1. Random rollThe region roller picks a MAN formation row from the player's current region and points +0x94 at it (flag bit 0x80000).
  2. Scripted, by rowOp 3E FF <row> points +0x94 at MAN formation row row - how bosses are cued (garmel rows 8/9 = Songi / Zeto, rikuroa row 17 = Caruban).
  3. Scripted, inlineA halt-acquire opcode points +0x94 at its own bytecode, so [count][ids] are its trailing operand bytes (flag bit 0x400).
  4. Confirm + copyThe entity tick copies the ids into the formation cell, clears +0x94, and the battle loads.

Which shape a given fight uses is a per-scene bytecode fact. A scripted arm does not depend on the region roll at all, which is how the Tetsu fight starts in a town whose random rate is 0.

Scene MAN, section 0formation + condition + region tables Random roll FUN_801D9E1Cregion AABB + step counter Scripted armfield-VM op 3E FF row Inline armhalt-acquire ops 0x37 / 0x41 ... actor +0x94record pointer Entity tick FUN_801DA51Cconfirm state Flags byte 0x8007BD60stage id + bit 0x80 Formation cell 0x8007BD0C..0F row address, flag 0x80000 row address opcode pointer, flag 0x400 count + ids record +0 non-zero region +8 & 0x1F
Every arm ends in the same pointer slot, and one reader does the copy. The random roll writes the stage id into the flags byte; the scripted-fight bit 0x80 comes from the record's own first byte.
Inline install sites in the script VM

The field-VM dispatcher installs the pointer with one 5-instruction pattern: sw s0,0x94(s5), sh zero,0x54(s5), then ori bit 0x400 into actor[+0x10]. s0 is the current opcode pointer (bytecode + pc); s5 is the resolved target actor, often the player context _DAT_8007C364. Bit 7 of the opcode byte routes byte +1 through the system-channel resolver FUN_8003C83C.

OpcodeInstall linePC advanceNotes
0x37 / 0x410x801DEEDC / 0x801DEF08+3Bare arm. Second install on param_3 if the target is the player context.
0x380x801DEFA0 / 0x801DF038+3Same clause; first branch reads a halfword table at 0x80073F04 into actor[+0x26].
0x43 (sub-op 0/1/A/B)0x801DF3FC+3Movement target set from operands; arms when the actor arrives.
0x470x801E1C38+3
0x4C0x801E1F44 / 0x801E21C0 / …+3One install per inner sub-op.

Every install shares one gate: the actor already has a record or is the player context, and the armed flag is clear or the scene allows re-arm (*_DAT_801C6EA4[+8] != 0). Case 0x34 also writes actor[+0x94] but does not raise 0x400 and pairs with +0x9c/+0x9e zero-writes - a callback pattern, not an encounter arm.

The random roll

The region roller lives in the world-map overlay and runs once per movement update:

  • Walk the condition array to pick which slice of the scene's region table is live for the current story state.
  • Match the player's tile against each live region's bounding box; the hit supplies a per-step rate increment and a formation slice (base + count).
  • Scale the rate by the setting byte and four modifiers: High Encounter passive (Bad Luck Bell / Nemesis Gem) ×4, Low Encounter passive (Good Luck Bell / Evil Talisman) ÷2, and two system flags (×2 / ÷2).
  • Subtract the scaled rate from a step counter; at ≤ 0, two RNG draws pick a row in the slice, the roll installs it, and the counter resets to 0x3CE minus a random 0..0x1E6. A counter already at zero starts a battle on the next step even in a rate-0 region (the bgtz at 0x801DA218 falls through).

The roller leaves at its very top when the formation table's count byte is zero, or when bit 0x8000 of the transient flag word 0x1F800394 is set - a bit that is up only while a menu or op-0x49 submode is open (set and cleared by FUN_801F1278 / FUN_801F159C). It also returns without touching the counter on a zero second argument: apart from op 0x3E's region re-read, which passes a literal 0, that argument is the return of FUN_800243F0, which is 0 only on the frame the party's battle-file streaming finishes.

Condition groups

The region array is partitioned into consecutive groups, one per story state, and exactly one group is live. Each 4-byte condition is [u16 flag_id][s16 region_count]; the walk stops at the first set story flag or at flag_id == 0xFFFF (the unconditional default), skipping region_count regions per rejected group. Running off the end means "no encounter this step". A gated group is often a single whole-map rate 0 row - that is how a scene is silent in one story state and noisy in another. asset man --with-encounter prints regions under their group.

Encounter-rate setting byte (_DAT_8007B5F8)

ValueEffect
0No roll at all.
1Rate used as-is - the value retail runs with.
2Rate ×4.
3Rate ÷4.

Its only static writer is the world-map debug menu's ENCOUNT row, cycling 0 → 1 → 2 → 3.

In the port: engine-battle::region_encounter re-runs the condition walk each step and rolls with the same anti-repeat and counter reset; a step that does not trigger consumes no RNG.

The per-battle flags byte

One byte at 0x8007BD60 carries two things. The random roll overwrites the whole byte with the region's stage id (low 7 bits, read back by the backdrop picker). The confirm state then ORs bit 0x80 in when the armed record's +0 byte is non-zero - "this fight is scripted". The two writes never race: roll first, confirm after.

Consumer of bit 0x80Effect when set
Battle-intro style selectorSpinUpParticles instead of the TileShatter default.
Intro transition phase 0Battle-start cue 0x4D replaces the plain 0x1F (cutscene).
Enemy stat-boost profilePicks the boost profile via ctx[+0x287].
Summon instant-death / status resistIn a scripted fight, a monster whose record byte +0x20 is set shrugs off the outcome of the three summons that test it. +0x20 is not an immunity table - it is the double-width texture-page flag, reused as a "big model" proxy, and it is set on 37 of 186 records (enemy table).

Where the records live: MAN section 0

Each scene's MAN chains six sections; section 0 is the encounter section: a 4-byte header, then three count-prefixed arrays - formations (the records above), conditions, and regions.

A region row is twelve bytes:

FieldMeaning
formation_stride8 in every retail scene
condition_stride4
region_stride12
formation_count
formations[+0] scripted predicate, [+1..+2] reserved, [+3] count, [+4..] ids
conditions[u16 flag_id (0xFFFF = unconditional)][s16 region_count]
regions[+0..+3] tile box (x_min, y_min, x_max, y_max); [+4] rate; [+6] formation base; [+7] formation count; [+8..] battle-backdrop variant + extras

Random vs scripted rows

Boss rows sit in the same array as the random ones; what separates them is reachability. A formation is a random encounter only if some rate > 0 region reaches it. In town01 the only rate > 0 regions reach rows 0..=2, so the Tetsu row at index 4 is scripted-only. The encounter randomizer relies on this to leave boss fights untouched (legaia_patcher::encounter::random_formation_mask).

MAN header, section chain and the encounter control block

The MAN header is byte-exact across all 80 retail scene bundles. +0x22/+0x24/+0x26 are record counts and +0x28 is a u24.

OffsetSizeFieldMeaning
+0x002status_flagsbit 0x400 hints world-map bulk terrain (map01/02/03); byte 1 low bit → DAT_8007B6A8
+0x0232depth_lut (16 × s16)written negated to GTE scratch for the per-scene fog / depth cue
+0x222N0partition-0 record count
+0x242N1partition-1 record count - the NPC / actor placement list
+0x262N2partition-2 record count
+0x283u24_28offset of section 0, relative to records-end
+0x2B3 × (N0+N1+N2)recordsu24 offsets into the data region

Section 0 lives at records_end + u24_28; sections 1..=5 chain by a 3-byte length prefix ([u24 length][payload]). Section 5 is always a zero-length terminator. Resizing anything here is covered by MAN relocation.

SectionInstall targetRole
0_DAT_801C6EA4[+0x20]Encounter / formation tables.
1_DAT_801C6EA4[+0x00]Scripted-motion VM bytecode (motion VM).
2_DAT_801C6EA0The scene's display name, a NUL-terminated string (the entry banner and save-screen place name).
3_DAT_801C6EA4[+0x04]Camera-region table.
4DAT_80073ED8Open; byte +3 copied to DAT_80073EDC, zero detaches the pointer.
5DAT_80073EE0Terminator (kingdom MANs trail their world-map label table after it).

The MAN walker writes section 0's pointer into a 100-byte encounter control block at _DAT_801C6EA4: formation / condition / region table bases at +0x20/+0x24/+0x28 and their strides at +0x5D/+0x5E/+0x5F. Record i is at base + 1 + i * stride (the +1 skips the count byte).

Example - 0086_map01: N0=12, N1=9, N2=42; section 0 holds 37 formations, 4 conditions, 64 regions. The four conditions own 16 regions each - four story-state variants of one overworld layout differing only in rate and backdrop byte, each ending in its own rate 0 catch-all.

MAN section 3: the camera-region table

[u8 count] then count × 18-byte records; the per-tile query walks it first-match-wins, and the loader splits bytes[5..17] into the camera parameter block at 0x8007B607..0x8007B627, which the camera composer FUN_801DAB90 turns into the same staging slots the script-VM Camera Configure op 0x45 writes. A zone record is a per-region camera preset in the op-0x45 parameter space. The query is script-driven (FUN_801DE3E0 from the field-VM arms [4C 38], [4C 39], [4C C4 x z], plus op 0x45 LOAD's inline record); the arrival actor FUN_801DBE9C queries only on its dev-mode leg and in retail just re-pins the focus and snaps. Two more sites, neither of them a tile crossing: the player seat / warp path at 0x801D1FE8 runs the [4C 39] sequence in code, and the field per-frame update has a query at 0x801D17FC gated on scratchpad flag bit 22 (_DAT_1F800394 & 0x400000), which a script raises with op 0x2E operand 0x16 in eight of the disc's scenes. With the bit clear the frame only eases and clamps the focus, so the block holds while the player walks. The composed pose then reaches the live camera globals through the per-frame ease FUN_801DB510 (only on frames the player moved; step delta >> s + sign(delta), s from a 16-entry table indexed by B60B >> 4) or the snap FUN_801DB8EC.

Header bytes[0..5]: +0 kind (0 = anchor + player tile inside the scratch box, 1 = inclusive tile-bbox match, 2..=0x1F = region-type mask bit vs _DAT_8007B8F4); +1..+4 = tile bbox [minX, minZ, maxX, maxZ] (kind 1) or anchor (kind 0).

Payload bytes[5..17]: byte[5] is the mode byte; its high nibble selects the split (low nibble = strength). Mode 6 = keep the current camera.

offsetsweep split (modes 1/2/4)mode 3 (look-at anchor)mode 5 (fixed shot)
+5u8 → B607 modesamesame
+6u8 → B608 pitch sweeps16 +6..7 → B61C anchor tile Xu8 → B61C focus tile X
+7u8 → B609 depth sweep(high byte of anchor X)u8 → B624 focus tile Z
+8u8 → B60A floor-height pitch couplingu8 → B60As8 → B620 height offset
+9u8 → B60B dy + ease dampingu8 → B60Bu8 → B60B
+10..11s16 → B610 base yaws16 → B620 anchor heights16 → B610 yaw
+12..13s16 → B60C base pitchs16 → B624 anchor tile Zs16 → B60C pitch
+14..15s16 → B614 eye depths16 → B614 eye-depth biass16 → B614 eye depth
+16..17s16 → B618 GTE Hs16 → B618 GTE Hs16 → B618 GTE H

B6xx = 0x8007B6xx; angles are in the 4096 = 360° space, one tile = 0x80 world units. Query-miss defaults: B607 = 0x10, B608 = 0x10, B609 = 0x30, B60A = 0x51, B60B = 0x20, B610 = 0, B60C = 0x1B8, B614 = 0x4000, B618 = 0x300. The per-mode sweep formulas for each global are in the source doc. The byte splits and routing are Confirmed, and so is the mask-kind scratchpad side-write: the four signed bytes at 0x1F8003E8..EB are the camera's visible tile window in tile offsets from the camera tile, read by the slot-B decoration pass FUN_801F7088, the camera-focus clamp FUN_801DAA50, the ambient particle emitter FUN_801D6058 and the dev-menu Coord0..Coord3 rows. The mirror words 0x801F2778/7C/80/84 have no reader at all. The window is set per region, not per scene, and it is not cleared at a door: across a scene change it keeps the previous scene's values for 78 more vsyncs before the incoming scene re-stamps it, so a frame drawn in between uses the window of the scene the player has left.

The other formation source: the battle-id expander

The record path is one of two ways the cell gets filled. The other is a global battle id at DAT_8007B7FC, expanded at battle-init into a cell shape by id band:

Battle idResulting cell
0[4, 4, 4, 4], and the boss-transition arm DAT_8007B64A is cleared
bespoke band 0x07..0x09, 0x49..0x4D, 0x88..0x8B, 0xA2..0xFF[id, 0, 0, 0]; 0xA2/A3/A4 also seed DAT_8007BD10.. with 1 / 3 / 2
any other non-zero[id, id, id, 0]

The cell shape tells the paths apart: a plain id here gives [id, id, id, 0], a count-1 record gives [id, 0, 0, 0]. Every pinned retail fight - Tetsu, Zeto, Caruban - is on the record path, and a sweep of every addressing form across SCUS and all overlays finds readers of the battle-id global but no writer: it reads 0 in every capture, so retail battles all resolve through the record path and the expander is dev-harness residue (battle scene).

Boss attributions and the store order
  • Tetsu (0x4F, town01) - cell [0x4F,0,0,0], MAN formation row 4 (see the worked example).
  • Zeto (0x4B, garmel) - a live capture over three runs shows the entity tick storing [0x4B,0,0,0] at the launch tick while a write-watch on the battle-id global stays silent. "Boss id in the bespoke band ⇒ battle-id path" does not hold. The engine enters via the beat record's 3E FF <row> op.
  • Caruban (0x49, Mt. Rikuroa) - the rikuroa P1[3] boss-stager placement (gated on first-visit flag 0x142, 3E FF 11 → row 17), run on approach by World::run_boss_stager_record.

Store order in the expander: slot 3 cleared at 0x80055690, then slots 0/1/2 written at 0x80055698/A0/A8; battle id 0 writes four explicit stores at 0x80055788..A4. Ghidra's C reorders these and synthesises a DAT_8007bd0d = DAT_8007bd0e - read the disassembly.

History: earlier mid-cast Zeto states pinned the id but placed the fight in scene jou; the live forward-play settled the scene (garmel) and the mechanism. See do-not-re-walk.

How we know

The record, reader and both arming paths are read off the overlay disassembly and confirmed by save-state captures of the formation cell before and after a fight.

Provenance: functions and captures
FunctionAddressWhat it provesDump
Entity tick (reader)FUN_801DA51C, body 0x801DA620..78Zero four slots, copy record[+4..+4+count] from actor[+0x94]; predicate at 0x801DA5F8..1C ORs 0x80 into 0x8007BD60funcs/overlay_*_801da51c.txt
Random rollFUN_801D9E1CCondition walk at 0x801D9F30..D8; modifiers at 0x801DA1B8..200; install with flag 0x80000; counter reset 0x3CEfuncs/overlay_*_801d9e1c.txt
Field-VM dispatcherFUN_801DE840, install at 0x801DEEDC..ECInline arm pattern + flag 0x400funcs/801de840.txt
Battle-id expanderFUN_8005567C (via FUN_80055B6C)Cell shape per id bandfuncs/8005567c.txt
MAN walkerFUN_8003AEB0, header trace 0x8003B04C..120Header counts, u24 offset, six-section chain, control-block installfuncs/8003aeb0.txt
Camera-region loaderFUN_801DBC20, query FUN_801DBA20, builder FUN_801DAB90Section-3 record split into 0x8007B607..27funcs/overlay_fishing_801dbc20.txt
Debug-menu writerFUN_801EA9B0 case 4Only static writer of the rate setting bytefuncs/overlay_*_801ea9b0.txt
Flag consumersFUN_801CE8CC, FUN_801CF5BC, FUN_80054CB0Intro style, start cue, stat-boost profile off bit 0x80per-function dumps
Runtime capturesmap01 / suimon / town01 statesCell values 04 04 00 00 = map01 formation 3; 4F 00 00 00 = town01 row 4; cell persists until the next armscenario manifest

The battle-data selector at 0x8007BD11 (next to the cell) picks which battle PROT entry streams in: extraction 0869 when it equals the case-1 character index, otherwise 0875.

Deep dive

Worked example: the Rim Elm training fight

The opening battle in town01 is a scripted single-monster fight against archive id 0x4F (Tetsu). The cell reads 00 00 00 00 before the fight and 4F 00 00 00 from battle-load onward. The id is not an inline script literal - it is MAN formation index 4. An opcode-aware walk of town01's field-VM scripts finds no [1][0x4F] operand; every window a naive byte-scan "decodes" is a count=0 artifact of stepping into embedded dialog text (survey CLI legaia-engine man-scripts). The live "Come at me!" state holds the seven formation rows byte-identical to the engine's MAN parse, row 4 = [count 1][0x4F]. Installing that row gives the fight its retail stats (Tetsu's HP 999).

The carrier entity. town01 partition 1 holds exactly one placement at tile (76, 65) with model byte 0x6A whose interaction record carries a long multi-page dialog block - the sparring talk-menu and its battle branch. It is identified by its dialog block, not by opcode decoding: message text aliases field-VM opcodes, so linear disassembly desyncs inside it; the text is recovered structurally as 0x1F-lead / 0x00-terminated segments. Engagement follows the dialogue-accept (talking to the placement opens it - a button press, not an opcode; the dismiss 0x4C n5 sub-4 engages).

Open questions
  • Per-opcode header encoding. Each inline install opcode packs its first 3 bytes differently; a per-case decode is needed to read a window as "encounter from script X at PC Y".
  • MAN section 4. Its offset and length are pinned in all 80 bundles, but the interior layout behind DAT_80073ED8 is open. (Section 1 is the motion-VM script table, decoder legaia_scene_formats::man_motion; section 2 is the scene display name.)
  • Region bytes. Region byte +5, and the region bytes past +8.
  • Mid-armed capture. No save state catches an entity with +0x94 armed in the one-frame window between roll and copy.

In the port

  • crates/scene-formats::encounter_record - EncounterRecord, expand_battle_id, the Rim Elm carrier constants.
  • crates/engine-battle::region_encounter / encounter_man - the region roll and the MAN formation table.
  • crates/engine-vm - the field-VM dispatcher port writes the actor pointer slot.
  • legaia_scene_formats::man_section - MAN section walker + formation / region row decoders.
  • crates/patcher::encounter - the encounter randomizer.

Source of record: docs/formats/encounter.md.

See also