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 — basic damage. Subtracts attacker stat from defender stat, capped at the per-party-slot ceiling table at DAT_8007655C.
  • Selector 9 — accuracy / evasion roll. roll = rand() % (caster_acc + target_eva); hit when target_eva < roll. Standard JRPG-flat-roll model.
  • Selectors 1..7 — stat-buff ramps, multiplying actor_record + 0x158..+0x16A by 6/5 with a 0xFFFF clamp.

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"
    cost = base / 2;
else if (character_record.ability_bits & 0x10)   // "MP-quarter"
    cost = base / 4;
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.

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)

Unit tests pin the documented formulas as fixtures — a future runtime trace can add comparison cases without touching the formula bodies.

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.
  • Stat-field semantics inside +0x158..+0x16A. The buff order in selectors 1..7 implies an ATK / DEF / MAG / SPR / AGL / LUCK ordering, but mapping each halfword precisely needs a save-state diff before / after a known buff.
  • Ability-bit catalogue. The bitfield at +0xF4 has the documented MP-half / MP-quarter / HP-cap / MP-cap bits plus the 0x10 / 0x20 impact-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.