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_asset::man_section, engine-core::encounter_record, engine-core::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.

OffsetSizeFieldMeaning
+0x001scripted-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.
+0x012reservedNot consumed (opcode operands, or scratch on a MAN row).
+0x031monster_count0..=4.
+0x04countmonster_ids[]Each id indexes the monster archive (PROT 867).
record +0 +1 +2 count id 0 id 1 id 2 id 3 not copied copied, count bytes formation cell slot 0 slot 1 slot 2 slot 3 0x8007BD0C..0F
The reader clears all four slots, then copies count ids; trailing slots stay zero.

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 roll
The region roller picks a MAN formation row from the player's current region and points +0x94 at it (flag bit 0x80000).
region_encounter
2
Scripted, by row
The interact op 3E FF <row> points +0x94 at MAN formation row row. This is how bosses are cued (garmel rows 8/9 = Songi / Zeto, rikuroa row 17 = Caruban).
World::trigger_scripted_battle
3
Scripted, inline
A halt-acquire opcode points +0x94 at its own bytecode, so [count][ids] are its trailing operand bytes (flag bit 0x400).
engine-vm field VM
4
Confirm + copy
The entity tick copies the ids into the formation cell, clears +0x94, and the battle loads.
World

Which shape a given fight uses is a per-scene bytecode fact. The engine mirrors the split: a scripted formation always overrides the region roll, which is what lets the Tetsu fight start in a town whose random rate is 0.

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.

Engine port. The bare arm op asks the host is_scripted_encounter_armed() and, only when armed, hands install_scripted_encounter() the bounded window [opcode][op1][op2][count][≤4 ids]. A successful install disarms (fire-once, matching the retail +0x94 clear).

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.

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. Engine mirror: region_encounter::EncounterRateSetting + EncounterRateModifiers.

Engine port of the roll

region_encounter keeps each region's tile box, rate and formation slice plus the condition partition. RegionEncounterTracker::select_group re-runs the walk each step from the live flag bank; on_step reduces the position to a 128-unit tile, subtracts the scaled rate, and rolls uniformly with the one-step anti-repeat and the 0x3CE counter reset. The no-trigger path consumes zero RNG (replay-safe). Overworld and field scenes both route through it; a MAN with no region section (towns) falls back to the mean-rate session.

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].

The engine keeps the bit as a property of the formation row: monster_catalog::FormationDef::header_flags feeds per_battle_flags().

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.

OffsetSizeFieldMeaning
+0x001formation_stride8 in every retail scene
+0x011condition_stride4
+0x021region_stride12
+0x031formation_count
+0x04count × 8formations[+0] scripted predicate, [+1..+2] reserved, [+3] count, [+4..] ids
next1 + count × 4conditions[u16 flag_id (0xFFFF = unconditional)][s16 region_count]
next1 + count × 12regions[+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_801C6EA0Carries the scene's banner place name; rest open.
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 globals at 0x8007B607..0x8007B627, which the camera builder folds 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.

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..7B61C anchor tile Xu8 → B61C focus tile X
+7u8 → B609 depth sweep(high byte of anchor X)u8 → B624 focus tile Z
+8u8 → B60A heading-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; the consumer of the mask-kind scratchpad side-write is Unknown.

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 no writer of the battle-id global exists in SCUS or any overlay; the global is probably vestigial (open thread). Port: encounter_record::expand_battle_id.

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

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 (disc-gated test town01_p1_arm_sites.rs; 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]. The engine installs the same row (World::install_man_formation(RIM_ELM_TRAINING_FORMATION_ID = 4)), so the scene's stats stand (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. The engine derives its carriers from the MAN (man_field_scripts::derive_field_carriers): the sparring partner becomes a ScriptedEncounter for formation 4, every other talk NPC a plain Npc. Engagement follows the dialogue-accept (interact op 0x3E opens it; 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 sections 1, 2, 4. Offsets and lengths are pinned in all 80 bundles; interior layouts beyond what is noted above are open.
  • Mid-armed capture. No save state catches an entity with +0x94 armed in the one-frame window between roll and copy.
  • Sparring dialogue Yes/No. The engine treats the accept as the dismiss; the fight is forced, so there is no decline path to gate.
History: readings this page replaced

Summary tables once restated the MAN header as "four s16 section offsets" and four sections; the traced walker gives three s16 counts, one u24, six sections. An earlier reading held that every record is inline in bytecode; the Rim Elm survey showed scripted fights select MAN rows by index. Catalogued on do-not-re-walk.

Files referencing this format

  • crates/engine-core::encounter_record - EncounterRecord, expand_battle_id, the Rim Elm carrier constants.
  • crates/engine-core::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.
  • crates/asset::man_section - MAN section walker + formation / region row decoders.
  • crates/patcher::encounter - the encounter randomizer.

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

See also