Encounter record format Confirmed
Walk a few steps in a dungeon and the screen shatters into a fight; talk to the sparring partner in Rim Elm and the same thing happens on purpose. Random fights and scripted bosses both reach the battle engine through one tiny structure - the encounter record: three header bytes, a monster count, and up to four monster ids. Whatever arms a fight points a field actor at one of these records, and one reader copies the ids into the formation cell the battle loader reads.
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 +
countbytes; 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.
| Offset | Size | Field | Meaning |
|---|---|---|---|
+0x00 | 1 | scripted-fight predicate | Non-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. |
+0x01 | 2 | reserved | Not consumed (opcode operands, or scratch on a MAN row). |
+0x03 | 1 | monster_count | 0..=4. |
+0x04 | count | monster_ids[] | Each id indexes the monster archive (PROT 867). |
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.
+0x94 at it (flag bit 0x80000).region_encounter3E 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+0x94 at its own bytecode, so [count][ids] are its trailing operand bytes (flag bit 0x400).engine-vm field VM+0x94, and the battle loads.WorldWhich 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.
| Opcode | Install line | PC advance | Notes |
|---|---|---|---|
0x37 / 0x41 | 0x801DEEDC / 0x801DEF08 | +3 | Bare arm. Second install on param_3 if the target is the player context. |
0x38 | 0x801DEFA0 / 0x801DF038 | +3 | Same clause; first branch reads a halfword table at 0x80073F04 into actor[+0x26]. |
0x43 (sub-op 0/1/A/B) | 0x801DF3FC | +3 | Movement target set from operands; arms when the actor arrives. |
0x47 | 0x801E1C38 | +3 | |
0x4C | 0x801E1F44 / 0x801E21C0 / … | +3 | One 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 to0x3CEminus a random0..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)
| Value | Effect |
|---|---|
0 | No roll at all. |
1 | Rate used as-is - the value retail runs with. |
2 | Rate ×4. |
3 | Rate ÷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 0x80 | Effect when set |
|---|---|
| Battle-intro style selector | SpinUpParticles instead of the TileShatter default. |
| Intro transition phase 0 | Battle-start cue 0x4D replaces the plain 0x1F (cutscene). |
| Enemy stat-boost profile | Picks 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.
| Offset | Size | Field | Meaning |
|---|---|---|---|
+0x00 | 1 | formation_stride | 8 in every retail scene |
+0x01 | 1 | condition_stride | 4 |
+0x02 | 1 | region_stride | 12 |
+0x03 | 1 | formation_count | |
+0x04 | count × 8 | formations | [+0] scripted predicate, [+1..+2] reserved, [+3] count, [+4..] ids |
| next | 1 + count × 4 | conditions | [u16 flag_id (0xFFFF = unconditional)][s16 region_count] |
| next | 1 + count × 12 | 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.
| Offset | Size | Field | Meaning |
|---|---|---|---|
+0x00 | 2 | status_flags | bit 0x400 hints world-map bulk terrain (map01/02/03); byte 1 low bit → DAT_8007B6A8 |
+0x02 | 32 | depth_lut (16 × s16) | written negated to GTE scratch for the per-scene fog / depth cue |
+0x22 | 2 | N0 | partition-0 record count |
+0x24 | 2 | N1 | partition-1 record count - the NPC / actor placement list |
+0x26 | 2 | N2 | partition-2 record count |
+0x28 | 3 | u24_28 | offset of section 0, relative to records-end |
+0x2B | 3 × (N0+N1+N2) | records | u24 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.
| Section | Install target | Role |
|---|---|---|
| 0 | _DAT_801C6EA4[+0x20] | Encounter / formation tables. |
| 1 | _DAT_801C6EA4[+0x00] | Scripted-motion VM bytecode (motion VM). |
| 2 | _DAT_801C6EA0 | Carries the scene's banner place name; rest open. |
| 3 | _DAT_801C6EA4[+0x04] | Camera-region table. |
| 4 | DAT_80073ED8 | Open; byte +3 copied to DAT_80073EDC, zero detaches the pointer. |
| 5 | DAT_80073EE0 | Terminator (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.
| offset | sweep split (modes 1/2/4) | mode 3 (look-at anchor) | mode 5 (fixed shot) |
|---|---|---|---|
+5 | u8 → B607 mode | same | same |
+6 | u8 → B608 pitch sweep | s16 +6..7 → B61C anchor tile X | u8 → B61C focus tile X |
+7 | u8 → B609 depth sweep | (high byte of anchor X) | u8 → B624 focus tile Z |
+8 | u8 → B60A heading-pitch coupling | u8 → B60A | s8 → B620 height offset |
+9 | u8 → B60B dy + ease damping | u8 → B60B | u8 → B60B |
+10..11 | s16 → B610 base yaw | s16 → B620 anchor height | s16 → B610 yaw |
+12..13 | s16 → B60C base pitch | s16 → B624 anchor tile Z | s16 → B60C pitch |
+14..15 | s16 → B614 eye depth | s16 → B614 eye-depth bias | s16 → B614 eye depth |
+16..17 | s16 → B618 GTE H | s16 → B618 GTE H | s16 → 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 id | Resulting 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's3E FF <row>op. - Caruban (
0x49, Mt. Rikuroa) - the rikuroa P1[3] boss-stager placement (gated on first-visit flag0x142,3E FF 11→ row 17), run on approach byWorld::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
| Function | Address | What it proves | Dump |
|---|---|---|---|
| Entity tick (reader) | FUN_801DA51C, body 0x801DA620..78 | Zero four slots, copy record[+4..+4+count] from actor[+0x94]; predicate at 0x801DA5F8..1C ORs 0x80 into 0x8007BD60 | funcs/overlay_*_801da51c.txt |
| Random roll | FUN_801D9E1C | Condition walk at 0x801D9F30..D8; modifiers at 0x801DA1B8..200; install with flag 0x80000; counter reset 0x3CE | funcs/overlay_*_801d9e1c.txt |
| Field-VM dispatcher | FUN_801DE840, install at 0x801DEEDC..EC | Inline arm pattern + flag 0x400 | funcs/801de840.txt |
| Battle-id expander | FUN_8005567C (via FUN_80055B6C) | Cell shape per id band | funcs/8005567c.txt |
| MAN walker | FUN_8003AEB0, header trace 0x8003B04C..120 | Header counts, u24 offset, six-section chain, control-block install | funcs/8003aeb0.txt |
| Camera-region loader | FUN_801DBC20, query FUN_801DBA20, builder FUN_801DAB90 | Section-3 record split into 0x8007B607..27 | funcs/overlay_fishing_801dbc20.txt |
| Debug-menu writer | FUN_801EA9B0 case 4 | Only static writer of the rate setting byte | funcs/overlay_*_801ea9b0.txt |
| Flag consumers | FUN_801CE8CC, FUN_801CF5BC, FUN_80054CB0 | Intro style, start cue, stat-boost profile off bit 0x80 | per-function dumps |
| Runtime captures | map01 / suimon / town01 states | Cell values 04 04 00 00 = map01 formation 3; 4F 00 00 00 = town01 row 4; cell persists until the next arm | scenario 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
+0x94armed 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.