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 +0x07 of 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
CaseLoads
6The befect_data bundle (PROT 0x369–0x36B)
0xEInitialises the runtime effect 2-pack wrapper via FUN_801DE914. Also fires for the field-VM op 0x3E warp/interact path on the system context.
0xFFDispatches 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:

IDAction
0x20Special move / capture (different sub-states)
0x28Action-menu cursor active (player still selecting)
0x35Magic — summon
0x47Spirit
0x50Martial-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.

OffsetTypeUse
+0x00u8 × 6Battle phase/state flags (01 01 01 00 00 00 while a turn resolves).
+0x06u8Monster-slot active action ID (0xFF if none queued).
+0x07u8Party-slot active action ID. The outer switch in FUN_801E295C keys on this.
+0x09u8Turn / phase counter.
+0x13u8Active-actor slot index — used to look up the actor pointer via (&DAT_801C9370)[ctx[0x13]].
+0x14..+0x1Bu8 × 8Per-action parameter bytes (target slot, sub-action, dir/elem, etc.).
+0x1Du8Action context flag — 0x03 for summon and capture; 0x00 otherwise.
+0x29..+0x2DstringActive spell/move icon glyph.
+0xA9..+0xECtextBattle dialog buffer.
+0x6D6..u8 × NAction state machine's PC offset / sub-state cursor.

Battle actor table

8-slot pointer table at 0x801C9370:

SlotRole
0..2Active party members (ordered by formation).
3..7Monster slots (up to 5 enemies per battle).

Selected combatant struct fields:

OffsetTypeUse
+0x07u8Per-actor state byte. Drives FUN_801E295C.
+0x1Fu8Hit-radius / size byte. Used by FUN_8004E2F0 (range).
+0x34 / +0x38i16Current world X / Z.
+0x3C / +0x40i16Previous-frame X / Z (for delta tracking).
+0x4A / +0x4Cu8 / int*Magic-slot count + list pointer.
+0x14C..+0x158u16HP / MP / SP triplets (cur/max).
+0x172..+0x174u16HP/MP secondary mirror.
+0x1DFu8Monster size byte (read from a monster record at +0x1F and stored here at init).
+0x1EF..+0x1F3u8Magic-resistance per element (5 elements).
+0x230u32Monster 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..0x158 and +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:

  1. Caps each character's stats at 0x3E7 (999, the in-game stat ceiling).
  2. ORs the character's "active abilities" 16-byte block at +0xF4..0x100 into a global 4×u32 bitmask at 0x80074358..0x80074368. This is the "currently-active accessory effects" register read by every other game system.
  3. For each character, calls FUN_800432BC / FUN_80042DBC to 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):

OffsetUse
+0x13Cu8 spell-list count
+0x13D..+0x160u8 spell IDs (variable-length; up to 36)
+0x161..+0x184u8 parallel spell-level / experience array
+0x196..+0x19Du8 equipment slot bytes (8 slots; weapon, armour, accessories)
+0x141-ishCharacter name string (used by FUN_80036044 0xC1 text-escape)
+0x2B0..+0x37FActive spell-slot array (stride 0x14)
+0xF4..0x100"Active abilities" 16-byte block — OR'd into global ability bitmask
+0x104..0x110HP / MP / SP triplets
+0x11AStat-cap field (clamped to 0x3E7)

Other notable helpers

FunctionRole
FUN_801D0748Battle 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_801D8DE8Hottest battle utility (3 KB / 77 incoming refs). Likely a per-actor utility that every state arm bottoms out into.
FUN_80048310 + FUN_800485BCWeapon / 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_80035EA8HP / 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.

KindSource byteDefault durationPer-turn effect
Burned14 turnsmax_hp / 16 HP tick damage
Shocked23 turns50% chance to skip turn
Poisoned3 (Other)6 turnscurrent_hp / 8 tick damage
Asleep43 turnsSkip until hit
Confused53 turnsRandom target
Silenced64 turnsBlock Magic actions
Stunned71 turnSkip one turn
Petrified8until curedSkip 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 rangeAP costNotes
0x00 Nothing0placeholder
0x01..=0x050system actions (Item / Magic / Attack / Spirit / Escape)
0x0C..=0x0F0direction bytes (free)
0x19 Regular Art Starter1
0x1A Special Art Starter1
0x1B..=0x321per-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-slot BattleStats through compute_battle_stats, and writes the resolved attack / UDF / LDF back into World::battle_attack / battle_defense_split so the strike resolver picks them up.
  • BattleRound::end(&mut world) ticks every actor's status, folds Burned / Poisoned tick damage into BattleActor::hp, and returns the count of actors that died from tick damage this round.
  • The returned BattleRound carries per-slot action_blocked / magic_blocked arrays 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_turn runs the queue through resolve_action_queue so Miracle / Super expansion happens on the engine side, before the SM sees a single byte.
  • AP gating. push_command consults the active party member's ApGauge before admitting the next byte — failure surfaces a typed OutOfAp error. pop_command / pop_chained_art refund the cost cleanly.
  • Turn lifecycle. Engines call begin_round at turn start (delegates to BattleRound::begin for AP reset + stat recompute) and end_round at turn end (delegates to BattleRound::end for tick damage drainage).
  • Per-slot buffers. Each party slot has its own command buffer + chained-art list; switching active_party_slot preserves 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::ApplyArtStrikepush_damage / push_heal (per-strike popup with a fade timer; frames_remaining ticks down each frame).
  • StatusEvent::TickDamage / Clearedsync_status (replaces the slot's icon list from the StatusEffectTracker).
  • BattleRound::begin / endsync_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 +0x9C0 table).

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 Command bytes 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 drains World::pending_battle_events into HUD popups + log + emitted SessionEvents.
  • RoundOutro — runs BattleRound::end for 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 labelSTR file
opdeeneMOV/MV1.STR
opstatiMOV/MV2.STR
opkoroutMOV/MV3.STR
opurudMOV/MV4.STR
opmap01MOV/MV5.STR
edteienMOV/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:

RangeBytes changedWhat it is
0x801CE808..0x801F3818~133 KBBattle overlay loaded into RAM (single contiguous region)
0x801C9370..0x801C9900~200–500 B8-slot battle actor pointer table; stride 0x60 per slot
0x80083000..0x80084000~600 BScene-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):

EventOffsetBefore → AfterInterpretation
mc7 → mc8 (magic-rank up)+0x9C0x09 → 0x0Amagic-rank counter
mc7 → mc8+0x1610x02 → 0x03spell_levels[0] +1
mc8 → mc9 (level-up, 4-level jump)+0x04..+0x060x016D → 0x02DAu16 LE XP delta (+365)
mc8 → mc9+0x10E0x3A → 0x42low byte of sp_max (Spirit, +8)
mc8 → mc9+0x11C..+0x12Csix per-byte +1..+4per-stat increments at byte stride 2
mc8 → mc9+0x1300x02 → 0x03rank 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's ElementMask declares as a weakness, floor-clamped at 1.
  • Buff / debuff — Power Up / Defense Up / Speed Up apply a Buff outcome with positive magnitude; Power Down emits a negative-magnitude debuff. Engines fold these into per-actor stat overlays for turns turns.
  • Capture & escape — Reseal returns a CaptureRoll outcome (engines roll the actual capture against monster HP) and Warp returns Escape.

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 emit CursorMoved { state: ItemPicker, … }; Cross enters Confirm; Circle bounces back.
  • Confirm — Yes/No prompt with a stat preview (preview_stats reflects 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.