Battle formulas
Damage, MP-cost, accuracy, and RNG arithmetic kernels used by the battle action state machine. The central damage-application primitive is FUN_800402F4; the engine-side mirror lives in crates/engine-vm/src/battle_formulas.rs.
Damage application primitive
FUN_800402F4 (~7,904 bytes / 1,976 instructions, dumped as ghidra/scripts/funcs/800402f4.txt) is a selector dispatch: switch(selector) { case 0..0x83 ... }. Each case is one “damage / status / stat-modify kind.” The function fills four local 8-pointer arrays (one per actor slot) over +0x14C (HP), +0x14E, +0x150 (MP), +0x152 before the selector switch.
The most common selectors:
- Selector 0 - the HP applicator: applies the pending HP delta (
actor[+0x14E] - actor[+0x14C]within the target), capped at the per-party-slot ceiling table atDAT_8007655C. The attack-vs-defense hit value is computed earlier (see below) and staged into the actor first. - Selector 9 - accuracy / evasion roll.
roll = rand() % (caster_acc + target_eva); hit whentarget_eva < roll(hit probabilitycaster_acc / (caster_acc + target_eva)). Standard JRPG-flat-roll model. Wired into the engine's live battle loop (arts/spell and basic-attack strike paths) - each actor's accuracy/evasion is its AGL-derived stat; the roll engages only when the attacker's accuracy is seeded, so an unseeded synthetic attacker auto-hits and consumes no RNG. - Selectors 1..7 - stat-buff ramps, multiplying the actor stat block by
6/5with a0xFFFFclamp. Each stat is a working/base pair of halfwords, so a buff touches two halfwords per stat. Wired into the live loop: a stat-up buff ramps the live per-slot scalar by +20% (buff_ramp) and records the exact delta for precise revert; debuffs stay additive (retail debuff factor not yet pinned). Buffs consume no RNG.
Actor stat block & monster record mapping
The per-actor stat block runs +0x14C..+0x16A, each stat stored as a pair of adjacent halfwords (working value at the lower offset, base at +2). For enemies, FUN_80054CB0 copies each monster-record halfword into this block; the names come from the slots' consumers in the damage and accuracy formulas:
| Record | Actor pair | Stat | How named |
|---|---|---|---|
+0x0C | +0x14C/+0x14E | HP | direct |
+0x10 | +0x150/+0x152 | MP | direct |
+0x0E | +0x154/+0x156 | AGL | agility / action gauge - spent per action, reset each round; the "Power Up" buff raises it ("agility increased!") |
+0x12 | +0x158/+0x15A | ATK | attacker's offense in the damage routine |
+0x14 | +0x15C/+0x15E | DEF↑ | defender defense, branch A |
+0x16 | +0x160/+0x162 | DEF↓ | defender defense, branch B |
+0x18 | +0x168/+0x16A | INT | magical damage / magic defense (summon/arts kernel) + accuracy/evasion seed (selector 9); the bestiary INT column (Meth962: INT affects magical damage and defense against magic) |
+0x1A | +0x164/+0x166 | SPD | turn-order initiative seed (+0x16C = SPD + rand); "Speed Up" buff |
Physical damage (overlay_battle_action_801ec3e4) reads the attacker's ATK (+0x158) and the target's defense - +0x15C (DEF↑) when the attack move index satisfies (move - 0xC) % 10 < 5, else +0x160 (DEF↓). The single “Defense Up” buff raising both +0x15C and +0x160 together confirms they are two facets of one defense. SPD (+0x164) seeds the per-turn initiative key +0x16C = SPD + rand(0..SPD/2) + 1, which the next-actor selector recompute_battle_order (FUN_801daba4, ported as World::next_combatant_by_initiative) reads to pick the highest-key living actor each turn; AGL (+0x154) is the agility / action gauge the enemy AI spends to act each round (deducting each action's +0x74 cost) and re-derives from base each round. The parser exposes all six via legaia_asset::monster_archive::MonsterRecord::{attack, defense_high, defense_low, intelligence, speed, agility}; the enemy table renders them per enemy.
Spell list (record +0x4C)
record +0x4A (u8) is the spell count; record +0x4C is an array of that many u32 block-relative offsets, each pointing at a spell entry inside the same decoded monster block. The battle loader FUN_800542C8 fixes every offset to an absolute pointer at battle init (record[+0x4C + i*4] += block_base, exactly like name_offset), initialises a +0x88 self-pointer to entry+0x8C, and resolves each entry's +0x04/+0x08 effect indices (see below). Each spell entry's head:
Each entry's +0x04/+0x08 are 1-based indices (0 = none), not direct pointers, into the per-block effect-offset table that immediately follows the spell-offset array (table word base magic_count + 0x13). The loader resolves index → offset → pointer: entry[+0x04] = block[(index + magic_count + 0x12)*4] + block_base. The resolved offset lands on a short per-spell effect/animation descriptor (a small fixed record, not a TMD and not "the same geometry as the monster's own +0x04"). Decoded from disc by MonsterSpell::effect_offset / aux_offset; 289 of 1811 spell entries carry an effect index, 24 an aux index. The descriptor's interior field semantics are still open (its runtime consumer is the cast/effect path, not the AI picker).
| Entry offset | Field | Meaning |
|---|---|---|
+0x00 u8 | spell/action id | Category selector: ids 2,3,4,5,0x0B are elemental resist/affinity markers (FUN_80054CB0 writes the slot index into actor +0x1EF..+0x1F3); 0x0C..0x1F are offensive castable spells; 0x23 ('#') is a special category. |
+0x74 u8 | AGL (action) cost | The AI picker rolls a spell only when cost != 0xFF and current AGL (+0x154) ≥ cost, then subtracts it. |
Real-data check: Gimard (id 10, AGL 60) has 9 slots - the affinity prefix 0,1,2,4,5,0x0B (cost 0), two castable spells 0x0D @ 28 and 0x0F @ 32 (both ≤ 60), and the 0x23 special. Hornet (id 61, AGL 88) has 0x0C @ 88 and 0x13 @ 88. Across every populated record the decoded list length equals the declared count and no offset escapes the block. The MonsterRecord::spells field (MonsterSpell { id, agl_cost, offset, effect_offset, aux_offset }, with is_castable()) exposes this; the enemy table renders the castable set with AGL cost.
Victory spoils (rewards)
EXP / gold / drop are inline in each monster record at +0x44..+0x49. The spoils function FUN_8004E568 walks the dead enemies via the per-enemy record-pointer table at 0x801C9348 (populated by the loader FUN_800542C8):
| Record | Field | Formula |
|---|---|---|
+0x44 u16 | base gold | Σ(gold>>1) over dead enemies, ×1.25 if a living party member has ability bit 0x10000, then halved. Lone enemy: floor((gold>>1)/2). |
+0x46 u16 | base EXP | Σ(exp) then ×3/4, split evenly among living party members. |
+0x48 u8 | drop item id | 0 = no drop. |
+0x49 u8 | drop chance % | per dead enemy, rand()%100 < chance grants the item (win banner at actor +0xA9 + inventory). |
Gold commits to 0x8008459C (clamp 99,999,999); EXP via the generic FUN_80026018 (accumulator 0x80084440 → party XP bank 0x800845A4). Runtime-confirmed: Gimard (+0x44=60) credited exactly +15 gold via a write-watchpoint on 0x8008459C; drop ids cross-check against legaia-gamedata (Gimard +0x48=119 @ 10% drops Healing Leaf). The enemy table renders EXP / Gold / Drop per enemy.
The engine applies this scaling (victory_gold_per_monster / victory_gold_finalize / victory_exp_per_member, wired into World::apply_battle_loot / apply_battle_xp), so the credited reward is the scaled amount, not the raw record sum. The per-battle no-gold flag (_DAT_8007BAC0, certain scripted fights) is the one remaining unmodelled gold gate.
Spirit damage formula
Hard-coded per Spirit super-art: damage = ((target_HP * 7) / 5) + 8, capped at 0x120 (288) for the larger arts and 100 for the smaller ones. This is the one place the engine reproduces a non-obvious arithmetic path; everything else is selector-dispatch driven.
MP cost & ability-bit modifiers
base_mp_cost = spell_table[spell_id].mp_cost;
if (character_record.ability_bits & 0x20) // "MP-half": shave 50%
cost = base - (base >> 1);
else if (character_record.ability_bits & 0x10) // "MP-quarter": shave 25%
cost = base - (base >> 2);
else
cost = base;
The character record's ability bitfield is the 4-byte field at +0xF4 (record stride 0x414, base 0x80084708). The engine port resolves the modifier via battle_formulas::MpCostModifier::from_ability_flags and applies it through one shared helper in both the state-machine cast and the live player-driven cast.
Dump-confirmed. The modifier subtracts a right-shifted copy of the cost - it is not a floor-divide. So MP-half rounds up on odd costs (a 5-MP spell costs 3, not 2), and "MP-quarter" shaves only a quarter off (pay 3/4: 40→30), it does not make the cost a quarter. When both bits are set, MP-half (0x20) wins - the andi 0x20; bne test short-circuits before 0x10 is reached. Verified at the state-0x28 block of FUN_801E295C (0x801E3D0C; the same block recurs in state 0x3C at 0x801E4568). See open RE threads.
RNG primitive
FUN_80056798 is the in-game RNG - standard PsyQ rand() pattern: seed = seed * 1103515245 + 12345; return (seed >> 16) & 0x7FFF. Range 0..32767. The engine seeds it from the boot timer; for deterministic playback, the engine port must seed from the same source.
Engine port
crates/engine-vm/src/battle_formulas.rs ports the formulas above as pure functions:
spirit_damage(target_hp, cap)mp_cost_after_ability_bits(base, modifier)accuracy_roll(caster_acc, target_eva, rng_seed)psyq_rand_step(seed)buff_ramp(value)damage_cap_for_party_slot(caps, slot)summon_predamage+ parts (FUN_801DD0ACsummon branch)arts_attacker_roll/arts_bonus_roll/arts_physical_predamage(FUN_801DD0ACarts/physical branch, seeded by the0x801F4F5Cmove-power table)apply_element_affinity(FUN_801DD864scale stage; the 8×8 matrix at0x801F53E8+ the per-character element table at0x801F5480are parsed off the disc bylegaia_asset::element_affinity). The stage resolves each side's element by the actor's battle slot, not the spell: a party member (slot < 3) uses the per-character table, while any other slot (≥ 3, including the slot-7 summon body) reads the element record-direct from the monster record+0x1D- via the per-enemy record-pointer table0x801C9348[slot-3](lbu …,0x1d(record)), not a copied live-actor field. So a player Seru-magic cast attacks as the summoned creature and scales bymatrix[summon-creature element][target element]- neither the caster character's element nor the spell's own element.damage_finish/spirit_gauge_fill(FUN_801DDB30finisher's closed-form stages: equipment elemental-resistance halving, guard halve,rand%9+8no-damage floor, summon power-% scale, 9999 cap, and the spirit-gauge accrual; the finisher's state-mutating tail - popup accumulator, AI revenge table, MP drain, per-element stat-debuff switch - stays in the live battle context. The live basic-attack damage can route throughdamage_finishbehind theWorld::use_damage_finishgate (--damage-finishplay-window flag); off by default, and with equipment resistance / guard not yet modelled it currently contributes only the 9999 cap + no-damage floor, drawing its one RNG only on a zeroed hit so the default call-count is unchanged)victory_gold_per_monster/victory_gold_finalize/victory_exp_per_member(FUN_8004E568reward scaling)
Unit tests pin the documented formulas as fixtures - a future runtime trace can add comparison cases without touching the formula bodies. accuracy_roll and mp_cost_after_ability_bits are now also applied in the live battle loop (engine-core::world::battle), not just defined - attacks roll to hit and MP-saver accessories reduce cast cost. arts_physical_predamage is wired for monster special-attacks: the move-power table loads onto World::move_power and a damaging monster cast's magnitude rolls through the kernel seeded by that move's power (World::enemy_move_predamage), gated on the table being installed so disc-free battles keep their placeholder + RNG stream. The element-affinity scale is wired in both directions: a monster special attack scales by matrix[enemy_element][party_member_element] (enemy element from the monster record +0x1D, defender from the party member's element table), and a player Seru-magic cast scales by matrix[summon-creature element][target element] - the attacker element resolved off the summon creature (the slot-7 summon body's namesake battle_data record, World::summon_attacker_element), the defender by slot (World::battle_slot_element). The enemy scale is applied inside the roll, before the conditional bonus-arm threshold (matching retail's scale→bonus order), so a non-neutral value can shift the lazy bonus draw; the player Seru-magic cast now rolls the faithful summon kernel (World::player_summon_predamage, the FUN_801dd0ac summon branch): summon-body HP/AGL seeded from the namesake creature's battle_data record, the caster's AGL doubled, the affinity percent applied inside the roll, the caster's per-spell magic-power byte from the character record's spell list (ids +0x13D, levels +0x161, the retail 32-entry search), and the closed-form finisher - the lazily-drawn rand()%9+8 floor, the per-caster summon power-percent (a newly decoded 3×8 table at 0x801F5468: each caster summons their own element at 100% and their opposed element weakest - Vahn water 40, Noa earth 40, Gala dark 60), and the 9999 cap. RNG draws follow retail call order (attacker + defender eager, bonus arm + floor lazy). Both directions are gated so an unresolved creature / uninstalled table reproduces the placeholder baseline bit-identically (magnitude + RNG stream), keeping disc-free battles deterministic.
What's still open
- Selectors
0x10..=0x83. Beyond the status / buff / damage cases, these handle stat-up animations, status-clear, queue-end markers, and multi-target item slots. Mostly read-only ramps that don't affect game balance. - The monster record is fully decoded - all six stat halfwords, the element id (
+0x1D), the reward fields (EXP / gold / drop at+0x44..+0x49), and the spell-offset list (+0x4C, see above). The spell entries'+0x04/+0x08effect indices now resolve through the per-block effect-offset table to the per-spell effect descriptor (MonsterSpell::effect_offset/aux_offset); what stays open is only that descriptor's interior field semantics. - Ability-bit catalogue. The bitfield at
+0xF4has the documented MP-half / MP-quarter / HP-cap / MP-cap bits plus the0x10/0x20impact-step modifiers. The full per-character map comes out of save-data inspection.
Full reference
The full doc with provenance citations is at docs/subsystems/battle-formulas.md.