Battle subsystem
The battle overlay carries the battle scene loader, the per-actor action state machine, and the effect VM cluster. Loaded at RAM 0x801CE818 — the same load slot as the town overlay, so battle and town never coexist in memory.
How it works
Battles are a separate top-level game mode, and Legaia handles them by loading a dedicated battle overlay over the same RAM region the town overlay was using. The whole field state goes away; the battle context takes over. When the battle ends, the town overlay loads back in.
Inside the battle overlay there are three big systems running in parallel:
- The scene loader brings in character meshes, monster meshes, the active sound bank, and the visual-effect script archive. Same shape as other scene loaders — an 11-case state machine, dev/retail split, etc.
- The action state machine takes the player's selected action (attack, magic, summon, special, item) and runs it to completion across multiple frames. It's a state machine, not a bytecode VM: the outer dispatch keys on the action ID byte at
+0x07of the battle context, and the inner dispatch keys on a per-actor "sub-state" byte at+0x1DE. - The effect VM cluster handles per-effect spawn / render but doesn't drive actor decisions.
The move VM still runs in battle — it's how Tactical Arts directional inputs play out per-frame — but the action validator decides whether a queued action can fire in the first place, and the action state machine sequences "windup → execute → recover" stages around it. So battle-time animation actually involves all three layers cooperating.
Battle scene loader
- Function
FUN_800520F0- Shape
- 11-case state machine
| Case | Loads |
|---|---|
| 6 | The befect_data bundle (PROT 0x369–0x36B) |
| 0xE | Initialises the runtime effect 2-pack wrapper via FUN_801DE914. Also fires for the field-VM op 0x3E warp/interact path on the system context. |
| 0xFF | Dispatches the side-band streaming-effect handler 0x801F17F8 for summon.dat / readef.dat |
The asset-viewer's --bundle battle mode mirrors this loader's PROT 865–890 set so character meshes have the right CLUT bindings.
Battle action state machine
- Function
FUN_801E295C- Size
- 16 KB / 4099 instructions / 155 outgoing calls
- Outer key
switch((*_DAT_8007BD24)[7])— the active action ID byte at ctx+0x07- Inner key
switch(actor[+0x1DE])— the per-actor action sub-state
The action-execution dispatcher: takes the player's selected action and runs it to completion across multiple frames. _DAT_8007BD24 is a pointer to the active battle context struct; the pointer itself is resolved at battle entry (*_DAT_8007BD24 = 0x800EB654 for the captured battle).
Action IDs surfaced from save-state captures:
| ID | Action |
|---|---|
0x20 | Special move / capture (different sub-states) |
0x28 | Action-menu cursor active (player still selecting) |
0x35 | Magic — summon |
0x47 | Spirit |
0x50 | Martial-arts directional input mode |
The function reads battle actor pointers via (&DAT_801C9370)[ctx[0x13]] — resolves the active actor via ctx[0x13] (slot index), then indexes the 8-slot pointer table. It guards on _DAT_800846C0 != 2 (game-state check). The global pointer _DAT_8007BD24 plays the same role as the field-VM context pointer — this is a state machine, not a bytecode VM, but it shares the field VM's "context-pointer-as-VM-state" idiom.
This is distinct from:
- The field/event script VM (which doesn't run in battle).
- The effect VM cluster (which handles per-effect spawn/render but doesn't drive actor decisions).
- The move-table VM (which drives Tactical Arts inputs and per-action keyframe scheduling — a layer below this one).
Battle context struct
The active battle context lives at 0x800EB654 (resolved at battle entry; the global pointer at 0x8007BD24 is set to this address). 32-byte fixed prefix followed by a per-battle dialog/text buffer.
| Offset | Type | Use |
|---|---|---|
+0x00 | u8 × 6 | Battle phase/state flags (01 01 01 00 00 00 while a turn resolves). |
+0x06 | u8 | Monster-slot active action ID (0xFF if none queued). |
+0x07 | u8 | Party-slot active action ID. The outer switch in FUN_801E295C keys on this. |
+0x09 | u8 | Turn / phase counter. |
+0x13 | u8 | Active-actor slot index — used to look up the actor pointer via (&DAT_801C9370)[ctx[0x13]]. |
+0x14..+0x1B | u8 × 8 | Per-action parameter bytes (target slot, sub-action, dir/elem, etc.). |
+0x1D | u8 | Action context flag — 0x03 for summon and capture; 0x00 otherwise. |
+0x29..+0x2D | string | Active spell/move icon glyph. |
+0xA9..+0xEC | text | Battle dialog buffer. |
+0x6D6.. | u8 × N | Action state machine's PC offset / sub-state cursor. |
Battle actor table
8-slot pointer table at 0x801C9370:
| Slot | Role |
|---|---|
0..2 | Active party members (ordered by formation). |
3..7 | Monster slots (up to 5 enemies per battle). |
Selected combatant struct fields:
| Offset | Type | Use |
|---|---|---|
+0x07 | u8 | Per-actor state byte. Drives FUN_801E295C. |
+0x1F | u8 | Hit-radius / size byte. Used by FUN_8004E2F0 (range). |
+0x34 / +0x38 | i16 | Current world X / Z. |
+0x3C / +0x40 | i16 | Previous-frame X / Z (for delta tracking). |
+0x4A / +0x4C | u8 / int* | Magic-slot count + list pointer. |
+0x14C..+0x158 | u16 | HP / MP / SP triplets (cur/max). |
+0x172..+0x174 | u16 | HP/MP secondary mirror. |
+0x1DF | u8 | Monster size byte (read from a monster record at +0x1F and stored here at init). |
+0x1EF..+0x1F3 | u8 | Magic-resistance per element (5 elements). |
+0x230 | u32 | Monster XP / drop record. |
Range / line-of-sight
- Function
FUN_8004E2F0(actor_a_id, actor_b_id) -> i16 distance- Called from
- Per-actor state machine (5+ sites)
The canonical battle range check. Reads [DAT_801C9370 + id*4] for both actors, computes a euclidean distance from +0x34/+0x38 (or +0x3C/+0x40 for the b-actor), then sums the two +0x1F size bytes (party-member size table at 0x80078878, monster size byte read from the live actor) to get the hit radius.
Monster init
- Function
FUN_80054CB0- Called from
FUN_800542C8(secondary battle archive loader)
Populates a battle-actor at [DAT_801C9370 + (slot+3)*4] from a monster record:
- HP / MP / SP triplets at
+0x14C..0x158and+0x172..0x174. - Magic-resistance bytes at
+0x1EF..+0x1F3(5 elements; one nibble per element). - Walks the spell list at
+0x4C(count at+0x4A) and indexes into a per-element resistance table. - Final XP / drop record into
+0x230.
Stat aggregator
- Function
FUN_80042558- Called
- Per frame
Walks the 3 active party members (stride 0x414) and:
- Caps each character's stats at
0x3E7(999, the in-game stat ceiling). - ORs the character's "active abilities" 16-byte block at
+0xF4..0x100into a global 4×u32 bitmask at0x80074358..0x80074368. This is the "currently-active accessory effects" register read by every other game system. - For each character, calls
FUN_800432BC/FUN_80042DBCto add/remove temporary spells per the active spell-slot layout at+0x2B0.
The 4-u32 global ability bitmask is what tells the renderer to draw "auto-counter" / "regen" / "magic up" indicators and what tells the battle dispatcher to apply post-hit effects. The read-side primitive is FUN_800431D0(bit_id) -> bool.
Action validator
- Function
FUN_8003FB10
Decides whether a queued action can proceed for the active actor. Sub-dispatches on actor[+0x9A8] (the queued-action byte) into 16+ handler arms; each arm consults a mix of per-actor state, the current target's record at 0x80084708 + tgt*0x414, the global ability bitmask via FUN_800431D0, and the 0x8007BD10 actor-type table to gate the action with a 16-bit return code (action-OK, blocked, requires-target-flag, etc.).
Engine reimpl wires this between the move VM and the per-actor state machine FUN_801E295C.
Character record layout
Stride 0x414 bytes per character, base 0x80084708 (so character n lives at 0x80084708 + n*0x414):
| Offset | Use |
|---|---|
+0x13C | u8 spell-list count |
+0x13D..+0x160 | u8 spell IDs (variable-length; up to 36) |
+0x161..+0x184 | u8 parallel spell-level / experience array |
+0x196..+0x19D | u8 equipment slot bytes (8 slots; weapon, armour, accessories) |
+0x141-ish | Character name string (used by FUN_80036044 0xC1 text-escape) |
+0x2B0..+0x37F | Active spell-slot array (stride 0x14) |
+0xF4..0x100 | "Active abilities" 16-byte block — OR'd into global ability bitmask |
+0x104..0x110 | HP / MP / SP triplets |
+0x11A | Stat-cap field (clamped to 0x3E7) |
Other notable helpers
| Function | Role |
|---|---|
FUN_801D0748 | Battle main dispatcher (11 KB / 182 calls). The top of the per-frame battle loop. Routes through every active battle subsystem (rendering, AI, animation, hit detection). |
FUN_801D8DE8 | Hottest battle utility (3 KB / 77 incoming refs). Likely a per-actor utility that every state arm bottoms out into. |
FUN_80048310 + FUN_800485BC | Weapon / effect trail builder — visual-only quad-strip emitters for sword trails, dash plumes, particle ribbons. Iterates the 16-slot per-actor frame buffer at actor[+0x68], runs each vertex through GTE projection, drops textured-quad packets into the OT. |
FUN_800349EC / FUN_80035EA8 | HP / MP threshold UI classifiers. Return one of 2 (dead), 6 (low), 7 (warn), 9 (healthy); the dialog renderer keys text colour on the result. |
Inventory
Battle reads inventory through the same page-banked structure the field VM's op 0x3B SET_ITEM_COUNT writes: 16 entries × 16-bit per page × 0x414-byte stride. The page index is the high nibble of the slot byte; the entry index is the low nibble.
The page-banked inventory state lives in the 512-byte region at [0x80085718 .. 0x80085918) — adjacent to the fourth-flag-bank bitfield at DAT_80086D70 (see field VM → "fourth flag bank"). The field VM's op 0x4C sub-3 sub-2 zeros the entire region.
Status effects
Per-actor status conditions inflicted by enemy attacks or art enemy_effect bytes. The retail engine stores per-status timers and tick-damage values in the battle-actor struct around +0x130; the layout is per-flag and not captured in any single overlay dump.
| Kind | Source byte | Default duration | Per-turn effect |
|---|---|---|---|
| Burned | 1 | 4 turns | max_hp / 16 HP tick damage |
| Shocked | 2 | 3 turns | 50% chance to skip turn |
| Poisoned | 3 (Other) | 6 turns | current_hp / 8 tick damage |
| Asleep | 4 | 3 turns | Skip until hit |
| Confused | 5 | 3 turns | Random target |
| Silenced | 6 | 4 turns | Block Magic actions |
| Stunned | 7 | 1 turn | Skip one turn |
| Petrified | 8 | until cured | Skip turn entirely |
Implementation: crates/engine-vm/src/status_effects.rs. The per-tick StatusEvent stream feeds back into the engine's HUD pipeline; engines call World::tick_status_effects once per round and consume StatusEffectTracker::drain_events() for log lines.
AP / Spirit gauge
Each character has a per-turn AP budget that limits how many art commands they can chain. The retail engine reads this from the character record's +0xC9 (current_ap) and +0xCA (bonus_ap) bytes. Pressing the Spirit button during command input adds +5 AP exactly once per turn.
The base AP grows by 1 each 10-level milestone (level 1..9 → 4 AP, 10..19 → 5 AP, …, 60+ → 10 AP capped).
| Action constant range | AP cost | Notes |
|---|---|---|
0x00 Nothing | 0 | placeholder |
0x01..=0x05 | 0 | system actions (Item / Magic / Attack / Spirit / Escape) |
0x0C..=0x0F | 0 | direction bytes (free) |
0x19 Regular Art Starter | 1 | |
0x1A Special Art Starter | 1 | |
0x1B..=0x32 | 1 | per-character art body |
Implementation: crates/engine-core/src/ap_gauge.rs. The World carries a [ApGauge; 3] (one per party slot); engines call World::reset_party_ap at turn start.
Battle stat aggregator
Clean-room port of FUN_80042558. Walks the 8 equipment slots, sums modifiers into the actor's resolved attack / UDF / LDF / accuracy / evasion, ORs equipment ability bits into the global 4×u32 mask, then folds in status-effect modifiers (Burned reduces ATK by ~12.5%, Confused halves accuracy, Asleep / Stunned / Petrified zero evasion and block actions, Silenced / Petrified block Magic).
Implementation: crates/engine-core/src/battle_stats.rs. The pure function compute_battle_stats(record, table, statuses, modifiers) -> BattleStats is deterministic and side-effect-free — engines call it once per turn-start.
Item catalog
Typed catalogue of inventory items the battle / field menu consults. Each entry has an ItemEffect describing the side-effect (Heal / Cure / Revive / Stat-up / Spirit-up / Capture / Escape / Damage / KeyItem). The vanilla catalog ships 19 entries covering every category.
apply_effect(effect, &TargetSnapshot) -> ItemOutcome is the pure resolver. World::use_item(item_id, target_slot) wraps that resolver and folds the outcome back into world state — HP / MP gains capped at the actor's max values, StatusEffectTracker::cure / cure_all clears status, ApGauge::refund handles Spirit-restore items.
Implementation: crates/engine-core/src/items.rs; world wiring at World::use_item + World::set_character_max_mp.
Battle round lifecycle
Per-round bookkeeping that ties the pure-code subsystems above into a single BattleRound orchestrator:
BattleRound::begin(&mut world, &[StatRecord; 8], &EquipmentTable, &StatusModifiers)resets every party AP gauge, recomputes per-slotBattleStatsthroughcompute_battle_stats, and writes the resolved attack / UDF / LDF back intoWorld::battle_attack/battle_defense_splitso the strike resolver picks them up.BattleRound::end(&mut world)ticks every actor's status, folds Burned / Poisoned tick damage intoBattleActor::hp, and returns the count of actors that died from tick damage this round.- The returned
BattleRoundcarries per-slotaction_blocked/magic_blockedarrays the action validator filters command input against (Asleep / Stunned / Petrified actors lose action; Silenced / Petrified actors lose Magic).
Implementation: crates/engine-core/src/battle_round.rs.
Per-actor animation runtime
Centralised per-actor animation orchestration that wraps legaia_anm::AnimPlayer for the keyframe-driven case (the bulk of retail ANM data) and surfaces a Host::on_opaque_record hook for the kinds whose interpreter is overlay-resident. AnimRuntime::with_slots(N) manages a fixed pool indexed by actor id; play(actor, record, bone_count) classifies the record by header byte and routes into either the keyframe path or the opaque path with the same actor[+0x68] = 100 initial counter the retail dispatcher uses.
Per tick the runtime emits an AnimEvent stream (PoseUpdated / OpaqueTick / Finished / Replaced) so engines drive renderer / SFX side effects without polling per-actor state. When the overlay capture for the actor[+0x4C] dispatcher lands, the only change required is filling the Host::on_opaque_record body with the real per-kind dispatch.
Implementation: crates/engine-vm/src/anim_vm.rs.
Battle command runner
Sits between the player-input layer and the action state machine plus the BattleRound orchestrator. One BattleRunner per battle session; engines feed it raw player commands per turn and call its lifecycle methods to bracket each round.
- Per-turn input → action queue. Commands are accumulated until the player commits the turn;
commit_turnruns the queue throughresolve_action_queueso Miracle / Super expansion happens on the engine side, before the SM sees a single byte. - AP gating.
push_commandconsults the active party member'sApGaugebefore admitting the next byte — failure surfaces a typedOutOfAperror.pop_command/pop_chained_artrefund the cost cleanly. - Turn lifecycle. Engines call
begin_roundat turn start (delegates toBattleRound::beginfor AP reset + stat recompute) andend_roundat turn end (delegates toBattleRound::endfor tick damage drainage). - Per-slot buffers. Each party slot has its own command buffer + chained-art list; switching
active_party_slotpreserves the inactive slots' state for cross-character chaining.
The runner is the input → queue half of the battle pipeline; the SM tick itself runs through the existing step_battle loop. Implementation: crates/engine-core/src/battle_runner.rs.
Battle HUD model
Renderer-agnostic UI state for the in-battle screen. Holds per-slot HP / MP / AP / status-icon state plus a queue of damage popups and battle-event log lines. engine-render's battle_hud_draws_for turns one of these into a Vec<TextDraw> for the GPU pipeline; engines that render via a different path (web / terminal) read the same struct directly.
The HUD is fed by World events:
BattleEvent::ApplyArtStrike→push_damage/push_heal(per-strike popup with a fade timer;frames_remainingticks down each frame).StatusEvent::TickDamage/Cleared→sync_status(replaces the slot's icon list from theStatusEffectTracker).BattleRound::begin/end→sync_slot(refreshes HP / MP / AP per round).
Damage popups carry a 60-frame default lifetime and an alpha() helper for fade-out renders. The log column rings the most recent N entries (default 6, matching the retail scrolling-log column) and the renderer maps LogAccent variants to colours: white = neutral, blue = party, red = monster, yellow = highlight (crit / level up / status applied), green = heal / cure.
Implementation: crates/engine-core/src/battle_hud.rs; renderer side: battle_hud_draws_for + HudSlotView / HudPopupView / HudLogView in engine-render.
SFX bank + scheduler
Maps battle / field cue IDs (the kind byte the art-record HitCue / overlay scripts emit) to per-cue SfxEntry descriptors that describe how to fire a one-shot through the SPU. Engines populate the catalog at startup, then forward ScheduledCue-like requests through SfxScheduler which queues each request with its retail timing offset and dispatches when the per-frame tick reaches the firing frame.
- Cue
0x1A— generic "play sound" hit cue; the catalog typically maps this to per-strike weapon impact tones. - Cue
0x4C— hit-effect visual (no sound on its own; engines that fold the visual into a synced sound use this slot). - Cues
0x80..=0xFE— reserved per-character or per-art SFX IDs (the retail engine indexes from the per-actor+0x9C0table).
SfxBank::play_one_shot delegates to the existing VabBank::play_note for tone lookup, pitch math, and ADSR setup; the scheduler is a frame-driven queue that returns an SfxFireBatch per tick_frame call so engines can dispatch through the same VabBank they already wired for the BGM sequencer.
Implementation: crates/engine-audio/src/sfx.rs.
Inventory item-use session
State machine that drives the "open inventory → pick item → pick target → use it" flow shared between the field menu and the battle command menu. Engines own a single InventoryUseSession for the lifetime of the inventory screen; per-frame they push input events and drain InventoryUseEvents to forward into render / sound / world side-effects.
Browsing(item_cursor)
↓ Confirm ↑ Cancel
TargetSelect(target_cursor)
↓ Confirm ↑ Cancel
Done(ItemOutcome) ← terminal; engines re-construct the session for
the next inventory open
The session filters items by InventoryContext (battle vs field — usable_in_battle / usable_in_field from the catalog), validates target compatibility (Revive needs a dead target; everything else needs a live one), and folds the resolved ItemOutcome into the engine's world state via World::use_item. Implementation: crates/engine-core/src/inventory_use.rs.
Battle session orchestrator
BattleSession composes the runner, round, HUD, and status tracker into a single phase-driven SM. Engines drive a battle frame as session.tick(world, input); the session forwards admissible inputs to the runner, drains world events into the HUD, and runs the post-round bookkeeping (status tick, death check, wipe detection).
Idle → RoundIntro → CommandInput → Resolve
↓
RoundOutro → (Victory|Defeat|Escaped) terminal
|
→ loop back to RoundIntro
- RoundIntro — encounter banner; auto-advances after
intro_frames(default 60) or on Cross. - CommandInput — D-pad queues
Commandbytes on the active party slot's buffer; Square presses Spirit; Triangle cycles slots; Start commits. - Resolve — runner is
Committed; the action SM consumes the resolved queue. The session drainsWorld::pending_battle_eventsinto HUD popups + log + emittedSessionEvents. - RoundOutro — runs
BattleRound::endfor tick damage, re-syncs HUD, checks party / monster wipe and routes to a terminal phase or loops to RoundIntro.
The orchestrator is renderer-agnostic. Engines render session.hud via engine-render::battle_hud_draws_for — the HUD's slot_views / popup_views / log_views helpers produce plain-data rows that engines feed into the renderer's HudSlotView::from_plain constructor.
CommandInput sub-phase: target picker
During CommandInput the session has two sub-phases: SubPhase::CommandSelect (input drives the command queue) and SubPhase::TargetPick (input drives a TargetPickerSession cursor). Engines call BattleSession::open_target_picker(world, kind, actor_slot, pending_command, &mut events) when the player picks an action that needs a target; the session builds the picker's SlotState arrays from live BattleActor::hp values and forwards subsequent input to the picker until it resolves. Resolution surfaces as typed SessionEvent outcomes: TargetConfirmed { target_slot }, TargetSweepConfirmed, or TargetCancelled; sweep targets (AllEnemies / AllAllies / Self_) resolve immediately during open_target_picker. cancel_target_picker provides an explicit drop path for the buffered command.
Implementation: crates/engine-core/src/battle_session.rs. Smoke-tested via the new legaia-engine battle subcommand.
Target picker ↔ runner wiring
BattleSession::push_command_with_target(world, cmd, kind, actor_slot) is the top-level API engines drive when a command needs a target. The session charges AP up-front, opens the picker via open_target_picker_mut, and stashes the command in pending_target_command. When the picker resolves, maybe_close_picker_with_world writes the resolved slot to BattleActor::active_target (the field the action SM reads at strike time via host.actor(actor_slot).active_target) and admits the buffered command into the runner queue without re-charging AP. Sweep targets write the 0xFF sentinel; cancellation drops the buffered command without admitting it. BattleRunner::push_no_ap(slot, cmd) is the post-confirm admit path.
CDNAME → MV STR routing
engine_core::scene::cutscene_str_for(scene_label) -> Option<&'static str> resolves an op* / edteien CDNAME label to its paired MOV/MVn.STR filename. The disc carries 6 STR files (MV1.STR..MV6.STR); the heuristic mapping covers all 6:
| CDNAME label | STR file |
|---|---|
opdeene | MOV/MV1.STR |
opstati | MOV/MV2.STR |
opkorout | MOV/MV3.STR |
opurud | MOV/MV4.STR |
opmap01 | MOV/MV5.STR |
edteien | MOV/MV6.STR |
The remaining ed* scenes (edbylon, edbalden, edlast, edretoin, edkorout, edbubu, eddoman, edson, edstati3) are dialogue-actor-overlay driven and have no FMV. cutscene_label_for_str(filename) is the inverse (case-insensitive on basename). The exact retail mapping table lives in the cutscene overlay; once captured, the lookup function should consult the captured map. The legaia-engine play and play-window subcommands auto-resolve the STR file when the user passes --scene <op*|edteien> and the extracted root contains the matching MV file.
Encounter trigger — runtime memory layout
The mc1 (pre-encounter, walking map01) → mc2 (battle just initiated, same map01 scene) save pair pins the runtime memory layout of an encounter trigger. The mednafen-state diff over 0x801C0000..0x80200000 surfaces:
| Range | Bytes changed | What it is |
|---|---|---|
0x801CE808..0x801F3818 | ~133 KB | Battle overlay loaded into RAM (single contiguous region) |
0x801C9370..0x801C9900 | ~200–500 B | 8-slot battle actor pointer table; stride 0x60 per slot |
0x80083000..0x80084000 | ~600 B | Scene-bundle / sound-pool: encounter formation + BGM resolution |
The active scene-name table at 0x80084540 is identical between mc1 and mc2 — the battle is layered on top of the field scene rather than swapping it out. Codified in crates/engine-core::capture_observations::encounter_trigger; a disc-gated test in crates/mednafen/tests/real_saves.rs (encounter_trigger_diff_loads_battle_overlay) exercises the real save bytes.
Captured stat-growth observations
The mednafen-state diff toolkit over mc7..mc9 pins the per-byte footprint of a magic-rank-up + character-level-up event for Vahn (party slot 0). The deltas inside Vahn's character record at 0x80084708 (stride 0x414):
| Event | Offset | Before → After | Interpretation |
|---|---|---|---|
| mc7 → mc8 (magic-rank up) | +0x9C | 0x09 → 0x0A | magic-rank counter |
| mc7 → mc8 | +0x161 | 0x02 → 0x03 | spell_levels[0] +1 |
| mc8 → mc9 (level-up, 4-level jump) | +0x04..+0x06 | 0x016D → 0x02DA | u16 LE XP delta (+365) |
| mc8 → mc9 | +0x10E | 0x3A → 0x42 | low byte of sp_max (Spirit, +8) |
| mc8 → mc9 | +0x11C..+0x12C | six per-byte +1..+4 | per-stat increments at byte stride 2 |
| mc8 → mc9 | +0x130 | 0x02 → 0x03 | rank counter (+1) |
The retail per-Seru per-level lookup table that drives these increments is not in SCUS_942.54; the writer lives in the level-up overlay (already captured) and the table base is referenced through a pointer at Seru struct +0x74. A writer-search across the captured overlay is the next step toward a true per-character StatGrowthCurve::PerLevel vector. Engines populate one captured observation at a time via LevelUpTracker::with_observed_curve(slot, &observations::vahn_mc8_to_mc9()); LevelUpObservation::to_curve emits the per-level average inside the observed range and falls back to the default outside it. Implementation: crates/engine-core/src/levelup.rs.
Monster catalog & formation tables
Engine-side representation of the per-formation monster set the encounter-system trigger resolves into. MonsterDef carries id + name + HP / MP / ATK / UDF / LDF / accuracy / evasion / EXP yield / gold drop / optional drop-item id + drop-rate; MonsterCatalog is the id-keyed lookup. FormationDef holds 1..=4 FormationSlot entries (monster_id + level_offset) plus an optional display label; FormationTable is the formation-id-keyed lookup.
Vanilla constructors ship the early-game roster: ~20 monster entries spanning Goblin / Wolf / Bandit / Slime / Skeleton / Killer Bee / Lizard Man / Mole / Spike Mole / Stone Golem / Goblin King / Drake Wyrm and 14 formations covering single, pair, triple, and boss variants. default_early_encounter_table(scene) is a convenience constructor that produces a goblin-majority weighted table for outskirts-style scenes.
Engines install the table at boot via World::set_formation_table(table, catalog); the encounter session's EncounterRoll carries the resolved formation_id which the engine looks up against the table to materialise concrete monster slot definitions for the battle scene loader.
Implementation: crates/engine-core/src/monster_catalog.rs.
Spell catalog & cast resolver
Mirrors the shape of the existing item catalog: typed SpellDef entries keyed by spell id, plus a pure cast_spell(def, target_slot, &SpellSnapshot) -> SpellOutcome resolver. The catalog's vanilla() constructor pre-populates ~17 entries covering the canonical retail families:
- Healing — Heal (60), Heal All (60 each), Mega Heal (200), Refresh (CureAll), Vital (Revive 50%).
- Offensive elemental — Flame, Burning Heat, Aqua, Thunder Bolt, Wind, Ice, Crash. Damage formula:
caster_mag * base_power / 8 - target_mdef, multiplied 1.5x against an element the target'sElementMaskdeclares as a weakness, floor-clamped at 1. - Buff / debuff — Power Up / Defense Up / Speed Up apply a
Buffoutcome with positive magnitude; Power Down emits a negative-magnitude debuff. Engines fold these into per-actor stat overlays forturnsturns. - Capture & escape — Reseal returns a
CaptureRolloutcome (engines roll the actual capture against monster HP) and Warp returnsEscape.
The resolver's failure modes are typed: NotEnoughMp when the gauge can't cover the cost, DeadTarget when an offensive spell hits a corpse, NoChange when a heal targets full HP or revive targets an alive actor.
Implementation: crates/engine-core/src/spells.rs. SpellCatalog::mp_cost + is_capture mirror the existing BattleActionHost hooks so engines can install the catalog as the canonical source.
Equipment session
State machine for the field menu's Equip screen and the shop-buy-then-equip flow. Drives the player from "browse character → pick slot → pick item → confirm swap" to a final EquipOutcome; on commit the session writes the new equip id into the character's StatRecord and re-runs compute_battle_stats so the world's resolved attack / UDF / LDF stays in sync.
SlotPicker(cursor) → ItemPicker(slot, cursor) → Confirm(slot, item, Yes/No)
|
→ Done(Committed | Cancelled)
- SlotPicker — Up/Down cycles the 8 equipment slots; Cross opens the item picker; Circle exits with
Cancelled. - ItemPicker — filtered to items targeting the active slot; engines that wire real per-item slot data from disc override
items_for_slot. Cursor moves emitCursorMoved { state: ItemPicker, … }; Cross enters Confirm; Circle bounces back. - Confirm — Yes/No prompt with a stat preview (
preview_statsreflects what the post-swap stats would be). Cross-Yes commits; Cross-No or Circle returns to ItemPicker.
Commit is atomic: the new item id is written, the old item id (if any) is restored to inventory, and compute_battle_stats recomputes the resolved row through the equipment + active-status + status-modifiers pipeline. Implementation: crates/engine-core/src/equip_session.rs; smoke-tested via legaia-engine equip.