At a glance

Random entry
Per-step region roll FUN_801D9E1C (field overlay) on entering a new 128-unit tile
Scripted entry
Field-VM op 3E FF <row>; the row is an index into the scene's own formation table
Formation cell
0x8007BD0C..0F, filled by the encounter confirm FUN_801DA51C
Initiative
Key at actor +0x16C, seeded from speed by FUN_801DA780; the max-key pick is FUN_801DABA4
Battle end
Signal DAT_8007BD71 = 0xFE raised at the action SM's 0x5A gate; cause word _DAT_8007BD2C (0 monsters down, 5 party down)
Results
Sequencer FUN_8004E568 (SCUS); the battle exits when its phase halfword ctx[+0x6CE] reaches 0x43
Port
World::tick → live_field_tick / live_battle_tick; kernels in engine-battle and engine-battle-vm

The loop in one picture

Field tickwalk, scripts Region rollnew 128-unit tile Scripted entryop 3E FF row Transition bracketshatter / fade intro Battle entryformation → 0x8007BD0C one round Command bandeach living member commits Execution bandhighest key acts keys spent: next round Results sequencerFUN_8004E568: pose, loot, hold, fade 0x5A gate Exit: game mode 2phase halfword reaches 0x43 Field reloadsurvived, or a scripted loss Title screen, cursor on Continueunscripted wipe: card-init mode 0x16
Random and scripted fights converge before the transition and are the same battle from there on. The exit is always the same store; what happens after a wipe is decided one game mode later.

Getting into a fight

Random. A scene whose MAN carries encounter regions rolls per step: a per-region rate counter, a formation-range pick, and a one-step anti-repeat (record layout on the encounter record page). The roll's trigger branch is destructive - it draws RNG, latches the anti-repeat and re-seeds its counter before returning - so a roll with nothing to receive it is a fight silently thrown away. The port keeps a five-phase bracket around it (idle, transition, triggered, battling, post-battle grace) and logs every way a roll can fail to become a battle.

Scripted. Boss fights enter through field-VM op 0x3E with a first operand of 0xFF (any value below 100 runs the same body; 100 and up is the door warp). The op points the system entity's encounter-record slot at formation row op1 of the scene's own table and requests the battle mode switch. There is no boss battle-id global: the candidate, DAT_8007B7FC, has readers and no writer anywhere on the disc - it is a debug forced-battle id retail never sets.

Boss rows sit outside every region's rollable slice, so only the op reaches them, and they carry a non-zero first header byte. On that predicate the confirm ORs bit 0x80 into the per-battle flags byte DAT_8007BD60, which selects the boss intro style and, at battle init, becomes the scripted-fight byte ctx[+0x287].

The two bands of a round

Two state machines hand a round back and forth. In the command band the flow SM walks every living party member through the command ring while the action SM idles. In the execution band the action SM dispatches every combatant, party and monster alike, by the max-key pick, and consumes each key as its owner is dispatched.

So a monster that won initiative still waits for the last party commit: initiative orders the execution band, it never lets anyone act before the prompt. The one exception is a rolled back attack, which arms the round straight from the intro timer; with the party's keys zeroed by the side lockout, only the monsters act.

Key seed
(speed + rand() % (speed/2 + 1) + 1) & 0xFFFF, stored in 16 bits with no ceiling, plus a wounded bonus that grows as HP falls, then halved under Slow
Pick
Highest unspent key over seats 1 to 6. The compare is mixed-sign: a key of 0x8000 or more never wins
Round end
No living actor holds a key: the action SM's 0xFF arm bumps the round index and returns the flow byte to 0x14
Round start
Actor sweep FUN_801D88CC, key re-seed if none is live, the per-round poison ticker FUN_801E752C on every round but the first

The pick loop carries no HP or occupancy test of its own, which is the root of a traced turn-order oddity: when every remaining key is zero the result is a draw over the seats walked.

The loop in the port

One per-frame World::tick drives the whole Field → Battle → Field round trip, and both play hosts arm it through one kernel, World::arm_live_loop: the scene label, the loop and player-battle flags, the Seru registry and the battle-music swap. Per frame:

  • Field tick. A step is the player crossing into a new 128-unit tile; each step drives one region roll. When the roll triggers, the formation is resolved against the formation table, the field actors are snapshotted, the battle actors are seeded from the formation and the monster catalog, and the mode flips to Battle. With a battle track configured, the field track is stashed and the battle theme cross-fades in.
  • Battle tick. Steps the action state machine plus the glue retail performs through its render and animation systems: art-strike and basic-strike damage, marking zero-HP combatants down so the wipe scan resolves, and three animation-retired gates the port retires on the frame their state is reached.
  • Return. World::finish_battle (below) restores the field; the scene host then re-runs the scene-entry script, as retail’s field reload does. That re-run is what dispatches post-battle beat records - a boss’s victory flags land from the scene’s own script bytes, not from engine code.

The loop flag gates only the field side, the random roll. Once the world is in battle, the battle tick always runs, because battle entry is not gated - a scripted fight or a world-map encounter flips the mode on its own - and a gated battle driver could strand a session mid-fight.

The six commands

  • Attack opens the target cursor and commits a physical strike through the action state machine.
  • Arts opens the per-press arts entry: one d-pad press per command until the AP pool cannot afford the next. The entered buffer becomes retail’s action queue - tokenised, learn-on-use checked, Miracle / Super finishes applied - and each swing, starter and art is its own staged clip whose hit events land the damage. The port’s older saved-chain list stays behind the Saved-chain Arts list toggle.
  • Magic lists the caster’s learned spells, MP-gated; the cast runs at the caster’s dispatch and folds damage, heals, cures, revives, timed buffs, captures and escapes.
  • Item opens the shared item-use session with ally and enemy rows; the cursor walks only the selected item’s side, as retail’s does.
  • Spirit raises the guard stance at the commit, so it covers every monster that acts first, and charges the gauge at the member’s dispatch (below).
  • Run stamps every party member and begins the round at once; each member’s dispatch rolls the escape. In a no-escape fight Run still commits and the roll fails - retail tests the no-escape byte only after the compare.

Status effects

Each actor carries its ailments as one packed halfword at +0x16E, mirrored for party slots into the character record.

StatusBitsEffect
Venom0x0001min(max_hp/32, 128) per round, never kills; combat rolls ×9/10
Toxic0x0002min(max_hp/16, 256) per round, never kills; rolls ×7/10
Stone0x0004cannot act or be damaged, counts as defeated, lasts the battle
Rotone of 0x0008 / 0x0010 / 0x0020one random limb unusable (its attack command is refused)
AI delegation0x0380, as a unitthe action pick is handed to the AI (Rage passive, charm)
Numb0x0400paralysis; clears on being hit
Sleep0x0800asleep; wakes when hit
Curse0x1000blocks Magic

The poison ticker subtracts against current - 1 before applying the cap, so a tick that would reach zero leaves the actor at 1 HP, and it draws no RNG. Bit 0x0400 has clearers all over the battle code and no retail setter: a word-level decode of the executable and every overlay finds only clears. Faint is not a bit at all - it is current HP of zero.

How the port enforces them

  • Once a round. The ticker runs at the round boundary, just before the keys re-seed, with the never-kill clamp.
  • Lost turns. An actor carrying Numb, Sleep, Stone or Faint loses its turn when it comes up. A party member whose raw status word carries Stone / Numb / Sleep committed no command, since the ring refuses it; its turn arms category 0, which the action seed sends straight to Done. It is never auto-armed as a strike.
  • On-hit strip. A landed melee hit clears the target’s delegation bits, Numb and Sleep (FUN_801EC3E4), unless the target is Stone; a Stone target takes no damage.
  • Magic blocked. A cursed or fainted monster caster falls back to a physical strike; a party caster in that state finds the Magic arm bouncing back to the command menu.
  • Confuse retargets a monster’s action to a random living member of the other side; a confused party member gets no command and loses its turn. Stone lifts when the party escapes: retail’s run band floors every 0-HP member at 1.

AP and the Spirit turn

The arts entry spends a per-turn budget. The port’s gauge grows one AP per ten levels - 4 AP at level 1, capped at 10 - and refills at every round start. Every art starter and art body costs one; system actions and the direction bytes are free. What the entry actually spends in retail is the battle actor’s action gauge, seeded at battle setup from the character’s live AGL (arts command gauge).

A Spirit turn extends that gauge in three steps, all inside the action state machine:

Seed armcategory 4 → 0x46 0x46 close-upstage extended gauge 0x47 / 0x48bar grows, plate Done bandpays into Spirit Next round boundarygauge = extended; next arts entry
The extended gauge is min(base × 7 / 5 + 8, 0x120). A Spirit turn raises the AP bar and its plate but never the readout bar an item turn raises, because the seed sends it past the item pre-arm. The Done band pays 8 into Spirit for every action and 0x20 for a Spirit turn, capped at 100.

The port draws the bar through the arts-entry chrome builders on both hosts, so the Spirit bar is the arts bar, one pixel per AP between its end pieces.

Monster AI

Two routines. The action picker FUN_801E9FD4 counts the live magic ids in the monster record and rolls rand % (1 + live_count): zero is a physical strike on a random party member, anything else picks that spell, checks the monster can afford it, and resolves the target from the spell's shape byte. A large switch on the monster id can then override the choice with bespoke scripted casts on cooldowns - each case is AI for one monster.

The target resolver FUN_801E7320 runs only for actors with the 0x380 delegation bits set. It expands the targeting class the picker left in actor +0x1DD: a living monster slot (rand % monster_count + 3 - pool slot 3 is the first monster whatever the party size), a living party slot, or an all-target code.

A data constraint hides in the cast path: after choosing magic the picker counts the rollable castable entries and divides by the count. A count of zero executes the divide-by-zero trap and the BIOS parks the machine. Retail data never has that shape; a rebuilt monster block must keep at least one rollable entry while its magic array is live.

Two gates the AI reads

  • The battle-mode byte ctx[+0x28A] gates multi-phase boss scripts. Its only writer is the action machine’s round-end arm, so it is a round counter, zero in the first round; advancing it walks a round-scripted boss to its next scripted cast.
  • The delegation bits 0x380 are not a monster flag. They are set only on party members whose record carries the Rage passive: the command ring skips that member, and at the round's start the auto assembler fills its turn with a random combo of its learned arts. A normal monster keeps them clear, so its scripted-cast cases fire.

The cooldown latches behind those scripted casts are cleared only at battle init, so a boss self-heals at most once per fight. In the port the picker’s generic core and the per-monster switch are separate (World::pick_monster_action, engine-battle::monster_ai); a scripted cast the active spell catalog cannot fold degrades to a physical strike.

Targeting stays retail by default. A single-target monster attack goes to a uniformly random living party member. The Smart monsters toggle redirects it to the lowest-HP member instead, after the faithful pick has been rolled in full, so the random stream is byte-identical either way (engine options).

Details: the charm battle softlock

The randomizer’s enemy-ally (“charm”) feature widens the monster-wipe scan’s down-mask from 0x4 to 0x384, so a living charmed monster counts as down and the player need not kill their own ally. That breaks an invariant the victory arm leans on: under the retail mask, a living acting actor at monster-wipe victory is always a party member, so the arm keeps the acting slot and reads its character id from the three-byte party roster to stage the win pose. When the charmed ally lands the last kill, the acting slot is a monster, the roster read runs past its three bytes, and the win-pose stream request is garbage - the battle wedges at the victory hand-off. Retail never reaches the state. The port’s victory arm re-picks whenever the acting slot is not a living party slot, uniformly among eligible slots, so it cannot spin.

How a battle ends

The battle does not exit on the frame the 0x5A gate raises the signal. The battle tick stops stepping the action SM and runs the results sequencer every frame instead:

Frame from the signalWhat happens
+0the gate sets the signal and cause
+0..+80CD loads (voice clip, jingle bank); camera frames the posing actor
+80results frame: pose staged, downed members floored at 1 HP, XP / gold / drop / level-ups applied, result and level-up windows raised
+80..+336hold
+336exit fade starts (a subtractive fade to black)
+402phase halfword reaches 0x43: game_mode = 2

The posing actor is the party leader, not whoever levelled: nothing in the battle overlay stores a seat into the pose slot, and a three-member capture shows seat 0 posing while another member takes the level. Escape runs a shorter arm with no results and the same exit gate.

Party wipe

The exit store is the same, wipe or no wipe. The fork lives one mode later, in the main-init scene setup FUN_8003AEB0: when the party-survived latch (DAT_8007BD60 bit 0x80) is clear and story flag 0 is clear, it switches to the card-init mode and the player lands on the title screen with the cursor on Continue. Retail's game over is a silent return to the title - no game-over art, no menu of its own. With story flag 0 set, the same wipe is a scripted loss and returns to the field standing, at 1 HP. The mode-18 “game over” overlay is a developer harness the retail path never enters.

The port can build a state retail cannot: a battle with no seated party, whose hollow slots read as dead. It therefore lets the party-wipe arm fire only with at least one seated member, while a monster wipe still resolves.

What the port does on the way out

World::finish_battle is the one exit, on both hosts:

  • HP and MP persist from the battle actors into the roster records, and back onto the restored field party.
  • The windows. A victory raises retail’s two framed result windows (level-up); a wipe raises the loss window and hands the title screen its Continue cursor.
  • Spoils. The formation’s XP is split equally among surviving members, its gold summed, and the one drop rolled once per enemy seat with a final 1-in-4 gate (battle formulas).
  • The exit’s party loop clears statuses (unless the arena bit is up) and stands 0-HP members up at 1; the story-flag return fold runs; the encounter grace starts.
  • The field camera re-seats on the player, as a scene entry does. Retail leaves through main init back into field mode, whose init resets the camera and re-centres the window on the saved player tile. The port keeps the scene resident, so it re-centres explicitly; without that the camera kept the focus it had pinned on the party’s battle seat until the player moved.

A battle left before its exit

Retail leaves a battle only through that path; the port’s hosts can leave one early - a scene picker, a debug seat, a test running several fights on one runtime. Two resets keep it out of the next fight. Battle entry clears the results sequencer, as retail’s battle init zeroes the end-of-battle marker; a surviving sequence would otherwise own the new fight from its first frame, with no round prompt. And a scene load or New Game abandons a live battle, a port-only path that drops the sequencer, sessions, effects and fade and restores the field snapshot without storing any outcome.

Seru capture

The killing-blow absorb is decided inside the hit resolver FUN_801EC3E4 at the moment a physical hit resolves: killing blows only, by a party attacker, on a monster whose record carries a Seru id. The chance is a percent byte in the monster record, plus a flat 30 points when the attacker holds the Magic Boost passive, rolled as rand() % 100 < chance. The roll is drawn on every Seru kill, before the check for whether the character already knows the spell. The grant lands in the same action: the Done band of the action SM reads the captured id, prepends the spell to the character's list and holds the learn banner.

Which hit rolls. The kill compare runs once per landing, on the hit that lands the combo’s accumulated total: a combo that crosses the target’s HP on its second hit rolls once, after every damage draw of the chain. The “already known” check also answers yes outright for a slot without its Ra-Seru and in a no-reward battle.

The capture spells and capture items are a separate mechanism. The port models them with a per-character capture-point accumulator that banks points for every eligible party member and teaches the spell when a threshold is crossed, with a banner that plays out over the field; those thresholds are engine-side approximations, while the killing-blow absorb above is retail’s own roll. The catch-rate byte is a randomizer target.

Port modules

Small modules the loop composes, each mirroring one retail piece:

ModuleWhat it isRetail anchor
engine-battle::encountertransition bracket and region rollFUN_801D9E1C
engine-core::battle_roundround sweep and the two bandsFUN_801D88CC
engine-battle::monster_aiper-monster scripted AIFUN_801E9FD4
engine-battle-vm::status_effectstyped statuses and the packed status wordFUN_801E752C
engine-battle::ap_gaugeper-slot AP budgetrecord +0xC9 / +0xCA
engine-battle::arts_command_inputper-press arts entryactor queue +0x1DF
engine-battle::battle_statsequipment, passive and status fold into battle statsFUN_80042558
engine-battle::seru_learningcapture-point accumulator and banner-
engine-battle::levelupXP curve and growth (level-up)FUN_801E9504
engine-menus::inventory_useitem-use session shared by the field menu and the Item armflow states 0x64, 0x5B / 0x5D
engine-audio::sfxcue ids to SPU one-shots, scheduled per tickdescriptor table DAT_8006F198
engine-menus::game_overtitle hand-off after a wipestores at 0x8003B5D4

Battle sound cues touch only the SPU and draw no random numbers, so battle determinism does not depend on audio; a missing bank, VAB or free voice drops the cue silently.

See also

The full write-up - encounter session phases, the port's host flags, the status-word writer inventory, the AP gauge, the charm softlock analysis and the port module map - is in docs/subsystems/battle-round-loop.md.