At a glance

Melee kernel
FUN_801EC3E4 in the battle overlay (PROT 0898) - every direction-command and Art hit
Magic kernel
FUN_801DD0AC - Seru magic, monster specials, boss casts (INT-driven, separate finisher)
Applicator
FUN_800402F4 in SCUS_942.54 - the selector dispatch every HP / status / buff change funnels through
RNG
Standard PsyQ rand(): seed × 1103515245 + 12345, top 15 bits
Engine mirror
crates/engine-vm/src/battle_formulas.rs - pure functions, retail RNG draw order
Confidence
Confirmed against the disassembly, hit by hit (how we know)
  • Your attack is base ATK (the unequipped number) plus half the gear the command uses, folded at swing time - never at battle load.
  • An Art multiplies by 1.3 (1.4 with War Soul) and takes the element factor twice.
  • Damage = Offense − Defense, capped at 9999 - with an underdog rewrite instead of a floor-at-1 when the defense would win outright.
How one physical hit is computed: base offense, offense roll, multipliers and defense roll meet in the damage subtraction, then the finisher applies the guard halve, the 9999 cap and the spirit gauge fill Base Offense base ATK + one slot / 2 Offense roll x Rnd x Power/16 + HP/256 +juggle +angle Multipliers Art x1.3 (War Soul 1.4) element x1.04 / x0.96 Defense roll UDF or LDF x Rnd + distance; Spirit x3 Damage Offense - Defense underdog rewrite Finisher guard halve, cap 9999 spirit gauge fill
The attacker's side folds one equipment slot at half strength at swing time, rolls, scales by the move's power, then takes the Art and element multipliers; the defender's side rolls too; the difference is the number that pops.

Physical damage - Offense Value and Defense Value

One routine, two rolls, subtracted. Integer arithmetic throughout; Rnd(1..1.125) is shorthand for x + rand() % (x/8 + 1).

Base Offense = Base ATK + Equipment ATK / 2

Offense = [ Base Offense * Rnd(1..1.125) * Power / 16
          + Current HP / 256
          + Base Offense * Juggle / 64
          + Base Offense * Angle / 65536 ]
          * Arts Modifier * Element * (Element again for an Art)

Defense = Base DEF * Rnd(1..1.125) + Base DEF * Distance / 1024

Damage  = Offense - Defense                 (cap 9999; underdog rewrite below)

Base Offense: base ATK plus half of one slot

Base ATK is the character's ATK with nothing equipped - the battle loader copies it into the actor with no equipment folded in (it folds UDF / LDF / SPD, and skips ATK). The menu's ATK figure is base plus all gear, so the two never match. The equipment half is added at swing time, per command:

CommandSlot readFold
Left armslot 2atk[2] / 2
Right armslot 3atk[3] / 2
High / Lowslot 4 (footwear)atk[4] / 2
Art hitslots 0-4(sum of all five) / 2
Monstersnoneno fold

The arms are keyed by slot, not item type: Vahn and Gala carry the weapon in slot 2 and the Ra-Seru in slot 3; Noa carries Terra in slot 2 and her claws in slot 3, so hers are swapped. The Art arm sums body and head too - those normally carry no ATK, so in practice it is weapon + Ra-Seru + footwear.

Term by term

TermWhat it is
Rnd(1..1.125)x + rand() % (x/8 + 1) - between x and x + x/8. Drawn separately for attack and defense.
PowerThe move's power tier: 20 for a normal direction hit; each Art strike carries its own from [12, 18, 20, 22, 28]. Applied as × Power / 16.
Current HP / 256The attacker's current HP, shifted down.
Juggle1 on a chain's first hit, +1 per further hit that lands while the defender is still reeling; back to 1 otherwise, and always 1 against a block. Applied as × ATK / 64. See the juggle window.
AngleThe folded facing difference between attacker and defender, 0 face-on; only a chain's first hit carries it. Applied as × ATK / 65536.
Arts Modifier×13/10 for an Art; ×14/10 with the War Soul accessory (Arts Power passive); ×1 for a plain hit.
ElementFrom the 8×8 matchup matrix: same element 96/100, opposed pairs (fire-water, earth-wind, light-dark) 104/100, else 100/100. Applied once per hit and a second time inside the Art arm.
Base DEFDefender UDF for a high-class strike, LDF for a low-class one. Party defenders do get their equipment's defence bytes here.
DistanceAccumulated per step of the attacker's walk-in and zeroed once the first hit lands; × DEF / 1024. Measured ~30 for a straight walk-in from the starting line.
Spirit stanceDefense ×3 when the defender chose Spirit, or is fleeing while a party member wears Safe Escape.
Venom / ToxicScales a side ×9/10 (Venom) or ×7/10 (Toxic) - the attacker's status on Offense, the defender's on Defense.

Worked example - Vahn vs Evil Fly

ZetaPhoenix's playtest: Vahn at base ATK 188, weapon ATK 98, footwear ATK 86, Ra-Seru ATK 100, HP 2747; an Evil Fly with UDF 42 / LDF 49, neutral element. The combo is Left arm, Right arm, then Hyper Elbow (a 20-power, LDF-targeting Art); the random draws are the observed ones.

HitBase OffenseRndOffense termsOffenseDefenseDamage
1 - Left arm188 + 98/2 = 237+7 → 244244×20/16 = 305, HP 10, juggle 1 → 3, angle 0318UDF 42 +1, distance +1 = 44274
2 - Right arm188 + 100/2 = 238+10 → 248248×20/16 = 310, HP 10, juggle 2 → 7327UDF 42 +2 = 44283
3 - Hyper Elbow188 + (98+86+100)/2 = 330+20 → 350350×20/16 = 437, HP 10, juggle 1 → 5; sum 452, ×13/10587LDF 49 +2 = 51536

Total 274 + 283 + 536 = 1086, matching the in-game figure. The second hit's juggle 2 is why it out-damages the first from a near-identical base, and the Art takes all three gear ATKs at half and then the ×1.3 - which is where most of an Art's advantage comes from.

The underdog floor

When Offense does not clear the defense, the routine does not floor the hit at 1 - it rebuilds the attack on top of the defense: Defense + (3/4 × Offense + rand() % (Offense/4 + 1)) × Power / 64 plus the juggle terms, re-applies a reduced Art multiplier (×11/10, or ×12/10 with War Soul) and the element pass, and finally a chip floor guarantees a few points (rand()%3 + 3 plain, rand()%4 + 5 for an Art). The "minimum damage" against a heavily-armoured enemy is therefore a fraction of your own roll, not a constant. A petrified defender takes nothing; the whole hit caps at 9999.

The juggle window - what makes a monster "juggleable"

Every hit adds BaseOffense × juggle / 64. The counter climbs by one for each hit that lands while the defender is still reeling from the last, and snaps back to 1 if you miss the window. The bonus is modest - the fourth hit of a juggle carries an extra ~6% - but it is the term that rewards a tight string. Three facts define it:

  • The window is a property of the reaction clip, not the monster's stats: it is open while the defender's current clip is before its first authored event frame. Two bytes of the light-flinch animation entry - a beat and a rate - set its length: window = 4 × beat / rate ticks at 60 Hz in normal play.
  • It restarts on every hit that lands inside it, so what matters is the gap between consecutive hits, never the length of the string.
  • An Art's slow-motion halves or quarters everyone's play speed, which doubles or quadruples every window while it runs - part of why Arts chain so well.
DefenderFlinch beat/rateWindowRead as
Rogue1/22 tickseffectively un-jugglable
Bee family5/210 ticks
Gobu Gobu8/216 ticks
Zeto16/232 ticks
Caruban10/140 tickshit again within ~0.67 s and the counter climbs
Vahn (party)3/112 tickswhy enemy swings, 24+ ticks apart, never juggle him

Party members have the same mechanic - the kernel reads the defender's reeling byte whichever side it is on. legaia-patcher monster-stats prints the juggle column for every monster.

Deep dive: how the reeling byte works

Two writers on the whole disc. Scanning SCUS_942.54 and every overlay image for a store to actor +0x1F7 finds exactly two, four bytes apart, both in the per-frame anim tick FUN_80047430 (0x80047E50 stores 0, 0x80047E54 stores 1); the battle overlay only reads it. A live write-watch across three battle states sees no other writer.

What it stores. Every tick, after advancing the actor's clip cursor: frame = cursor >> 4; byte = (frame < record[+0x10]) ? 1 : 0 - where record[+0x10] is the clip's first event frame, the same list that marks an attack's contact frame. The byte is re-derived each tick from whichever clip the actor is in, not a loaded timer.

On a hit the defender's light flinch (tag 2) is staged with the cursor at 0 and the byte goes up. Each tick the cursor gains speed × rate / 2 sixteenths of a frame (speed = actor +0x21D, 8 in normal play, dropped to 4 / 2 on everyone by Art slow-motion; rate = the entry's +0x78). Hence window = 32 × beat / (speed × rate). A hit inside the window grows the counter and restarts the flinch; a blocking defender is never juggled. Set sites: 0x801ECA20..0x801ECA80.

Live checks. Against Gilium (f8/2) the counter reads 2, 3, 4 on hits 8-12 ticks apart during an Art (window doubled to 32 by slow-motion), 1 on every hit after the drop; Vahn's own f3/1 = 12-tick window measured three times against Gobu Gobu's three hits 24-26 ticks apart, counter 1 each time. Side effects of "whichever clip the actor is in": an actor struck during its own wind-up counts as juggled, and one Cort's beat lies past its 7-frame flinch, so its byte holds for the whole clip.

History: the port once seeded attack from the menu aggregate

The port once seeded a party member's attack from the menu aggregate (base plus every item's full ATK), over-stating Vahn's first hit above from 237 to 472 before the roll. It seeds the base and folds the halved slot per command (World::battle.equip_atk, arms_weapon_atk_fold); physical_predamage ports the roll pair, the Art and element passes, the status scales, the underdog rewrite and the chip floor, drawing rand() in retail order.

The smaller formulas

FormulaExpressionWorked example
Accuracy / evasionrand() % (acc + eva); hit when eva < roll - hit chance acc / (acc + eva)acc 120 vs eva 40: hits 75% of the time
Turn orderinitiative key = SPD + rand(0..SPD/2) + 1 per round; highest living key actsSPD 60 draws 61..91 - a SPD 40 actor (41..61) can still edge a bad roll
MP costMP-half passive: base - (base >> 1); MP-quarter: base - (base >> 2); half wins when both are worna 5-MP spell costs 3 with MP-half (rounds up, not down); 40 → 30 with MP-quarter
Spirit super-art damagetarget_HP × 7 / 5 + 8, cap 288 (large arts) / 100 (small)a 150-HP target: 218, under the cap
Escape (Run)party score = Σ (SPD×3)/2 + missingHP/16; enemy score = Σ SPD + missingHP/32; one rand each; caught iff roll_p < roll_e or the no-escape flaga fast, wounded party out-rolls a slow healthy pack most rounds; Chicken Heart / King bits tilt it further
Stat buffworking stat × 6/5, clamp 0xFFFF; each stat is a working/base halfword pairATK 300 → 360 for the fight
Seru-magic stat debuffstat - stat × pct / 100 per hit, pct = 5 / 10 / 15 / 20 by magic-level band 3-4 / 5-6 / 7-8 / 9; the stat is picked by the summon's element (below)two level-5 Gimard hits: ATK 300 → 270 → 243
Victory goldΣ gold/2 over dead enemies, ×1.25 with the gold passive, then halved; lone enemy floor((gold/2)/2)Gimard (record gold 60) credits exactly +15
Victory EXPΣ exp × 3/4, split evenly among living memberstwo survivors of a 400-EXP fight get 150 each
RNGseed = seed × 1103515245 + 12345; return (seed >> 16) & 0x7FFFrange 0..32767 - the standard PsyQ rand()

Actor stats and the monster record

The per-actor stat block runs +0x14C..+0x16A, each stat a pair of halfwords (working value, base). The loader copies each monster-record halfword into it; the names come from the formulas that consume each slot:

RecordActor pairStatConsumed by
+0x0C+0x14CHPthe applicator's HP delta
+0x10+0x150MPcast costs
+0x0E+0x154AGLthe action gauge the AI spends per action, re-derived each round; "Power Up" raises it
+0x12+0x158ATKattacker's offense
+0x14+0x15CUDFdefense vs high-class strikes
+0x16+0x160LDFdefense vs low-class strikes
+0x18+0x168INTmagical damage and magic defense; seeds accuracy / evasion
+0x1A+0x164SPDthe initiative key above

The single "Defense Up" buff raising UDF and LDF together confirms they are two facets of one defense. The parser exposes all six via legaia_asset::monster_archive::MonsterRecord; the enemy table renders them per enemy, along with each monster's castable spell list and AGL costs (decoded from the record's spell-offset array - entry head byte is the spell id, +0x74 the AGL cost the AI checks and spends).

Seru-magic side-effects

A levelled Seru spell does more than damage: the stager FUN_801F3D3C, called once per cast from inside the spell's own summon module, stages a secondary effect keyed on the summoned creature's element (the slot-7 record's +0x1D, the byte the affinity scale reads) and scaled by the caster's magic level for the spell (record +0x161). It writes a percent to 0x801F6960 and a banner string to 0x800775B4; the damage finisher FUN_801DDB30 then shaves stat × pct / 100 off the target on every hit. The table is 0x801F6870 (PROT 0898 file 0x28058), parsed by legaia_asset::seru_side_effect.

Summon elementEffectlv 3-4lv 5-6lv 7-8lv 9
0 earthDEF down - all four defence halfwords5%10%15%20%
1 waterAGL down - the gauge base +0x156 only5%10%15%20%
2 fireATK down5%10%15%20%
3 windSPD down5%10%15%20%
4 thunderINT down5%10%15%20%
5 lightcure class (party targets)1234
6 darkMP down - current MP +0x150 only5%10%15%20%

The gates, in order: level below 3 stages nothing. With the scripted-fight flag ctx[+0x287] set (a formation row's non-zero header byte - every boss), four casts in five are suppressed unless the spell is light or affinity[summon][first enemy] ≥ 101 (thunder against the four base elements, or the opposite pair). Then, for earth / fire / wind / thunder / dark in a scripted fight and for water always, the target's base stat halfword is compared with the raw record and the effect is dropped when they differ. Because the scripted boost profile has already raised ATK, UDF/LDF and INT, those three debuffs can never land on a boss; SPD and AGL land once per battle - literally once: the first cast that passes moves the value the compare reads, and every later cast of that element prints "No effect." for the rest of the fight; MP-down compares a value the hit never moves and lands every time. A random encounter runs neither the roll nor the compare (water aside), so everything lands and stacks. There is no per-monster immunity field - the record's +0x24..+0x43 is zero across the roster. The enemy table renders the verdict per enemy; the "No effect." banner is the return-from-fade pass FUN_801F3C34 announcing a suppressed or already-landed effect.

Engine: engine-vm::seru_side_effect::{stage_side_effect, apply_hit} both run in the live loop - the stager once per player Seru cast at the engine's single cast fold, the finisher switch per damaged target inside it. Three pieces of live state carry them: the base stat halfwords (written equal to the working halves at battle entry, the way the record copy does, and untouched afterwards except by a debuff), the scripted-fight flag derived from the formation's header byte, and the enemy stat seed picking its boost profile from that same flag - so a random encounter really does install the ×7/4 defence profile. A host with no disc installs no table and the stager returns before its one rand() draw.

Victory spoils

EXP / gold / drop are inline in each monster record; the spoils routine walks the dead enemies and applies the scaling in the table above:

RecordFieldNote
+0x44 u16base goldscaled, then committed to the wallet (clamp 99,999,999)
+0x46 u16base EXP×3/4, split among living members
+0x48 u8drop item id0 = no drop
+0x49 u8drop chance %one rand()%100 < chance per enemy (+30 with Items Up); at most one item, then a 1-in-4 gate

The drop is one item per battle, not one per enemy. Every enemy costs one rand() % 100 against its chance (plus 30 when a living member carries Items Up), and the last enemy whose roll wins supplies the item - a captured one cannot. Then, unless some enemy has a 100% chance or the bonus applies, a further rand() & 3 must come up zero or the drop is lost: a lone 10% enemy drops 2.5% of the time. The item is not granted when 99 are already held.

Runtime-confirmed by write-watchpoint (Gimard credits exactly +15 gold) and cross-checked against the curated game-data tables (Gimard drops Healing Leaf at 10%). The engine wires the scaling into World::apply_battle_loot / apply_battle_xp, so the credited reward is the scaled amount, not the raw record sum.

The special-battle word _DAT_8007BAC0 is not the scripted-fight flag - a boss row leaves it alone. It is non-zero in an arena leg and in the two Ra-Seru-forbidden fights (the Rim Elm ambush, first monster 0xAF), and any bit of it zeroes the gold and each member's EXP, skips the drop walk and the steal, makes the Seru absorb answer "known", skips spell XP and drops a monster flee the roll granted. The same word opens no result window at the end, ends the battle as a party wipe once the leader has all three limbs rotted, and in an arena leg hands a Run round's first turn to the leader and records the leg as run; its 0x100 bit forces the party's flee. The engine reads all of these through World::special_battle_word.

Engine port

crates/engine-vm/src/battle_formulas.rs ports every formula above as pure functions, drawing rand() in retail call order so the RNG stream matches. Live in the battle loop:

  • Melee: physical_predamage + the per-command equipment fold; accuracy rolls; MP-saver accessories.
  • Magic: the faithful summon kernel for player Seru-magic and arts_physical_predamage for monster specials, seeded by the move-power table.
  • Element affinity in both directions - and by slot, not spell: a player Seru cast attacks as the summoned creature, scaling by matrix[creature element][target element]; a monster's element reads record-direct.
  • Finisher: damage_finish - elemental-guard halving, guard halve, the rand()%9+8 floor, the 9999 cap, spirit-gauge accrual.
  • Spoils and escape: the reward scaling and the Run roll.

Every disc-seeded path is gated so an uninstalled table reproduces the placeholder baseline bit-identically - disc-free battles stay deterministic.

Deep dive: the applicator, and what stays open

The applicator FUN_800402F4 (~1,976 instructions) is a selector dispatch, switch(selector) { case 0..0x83 }, over four 8-pointer arrays covering each actor's HP / MP pairs. Selector 0 applies the pending HP delta against the per-party-slot damage-cap table; selector 9 is the accuracy roll; selectors 1..7 are the ×6/5 stat-buff ramps (each touching a working/base halfword pair). The table has 132 slots but only 15 distinct arms, and 116 of them - every selector from 0x0F to 0x83 bar one - are the function's own epilogue, the same place an out-of-range selector lands. The only body above 0x0E is a single call that ramps screen brightness. The arms that do work are all at or below 0x0E: status clear, an opposed-INT status roll, learning a Tactical Art, and discharging the Point Card counter as damage.

Open threads: the retail debuff factor (the port keeps debuffs additive); the interior semantics of the per-spell effect descriptor a monster spell's effect index resolves to; what the kernel does with a negative angle halfword (a strike from behind) - uncaptured; and two juggle loose ends (the four-slot list helper that no disc reaction entry exercises, and whether the parry guard window uses the same beat). See open RE threads.

How we know

Function / dataAddressWhat it provesDump
Melee kernelFUN_801EC3E4The whole physical pipeline: roll pair, power tiers at 0x801F64EC, juggle / angle terms, Art ×13/10, element matrix at 0x801F53E8, underdog rewrite, chip floor.overlay_0898_801ec3e4.txt
Equipment fold armsPTR_801CF4B4 table, arms 0x801ECBC4..0x801ECDE4Which slot each command reads, halved; the arms live in a listing gap and are read straight from overlay bytes.overlay 0898 bytes
Battle loaderFUN_80053CB8ATK is seeded base, equipment folded only at swing time.80053cb8.txt
Reeling byteFUN_80047430The only two writers of +0x1F7; the first-event-frame derivation.80047430.txt; live write-watch
ApplicatorFUN_800402F4Selector dispatch; HP caps at DAT_8007655C; accuracy roll; buff ramps.800402f4.txt
MP-cost modifierFUN_801E295C state 0x28Subtract-a-shift (rounds up); MP-half short-circuits MP-quarter.overlay_0898_801e295c.txt
Escape rollFUN_801E791CParty / enemy score formulas, two draws, accessory bits.overlay 0898 dump
SpoilsFUN_8004E568Gold / EXP scaling; record fields +0x44..+0x49; runtime write-watch on the wallet.8004e568.txt
Magic kernel + finisherFUN_801DD0AC / FUN_801DDB30INT-driven roll; per-caster summon power table at 0x801F5468; finisher stages.overlay 0898 dumps
RNGFUN_80056798PsyQ LCG constants; 15-bit output.80056798.txt

Each claim of the community analysis is checked against the disassembly; every term is confirmed, with one correction (the random source is 15-bit, immaterial after the modulo) and two open magnitudes (the negative-angle behaviour; the walk-in distance is runtime state, not a constant). The full claim-by-claim table, register-level stage list and address appendix - plus the summon kernel, spell-XP accrual and poison ticker in full - are in docs/subsystems/battle-formulas.md.

Credits and sources

  • ZetaPhoenix - the Offense Value / Defense Value shape, the per-command equipment halving, the Rnd(1..1.125) reading, the juggle / angle / distance terms, the juggleability question (his guess - the state of the damage animation - was right) and the worked example, all measured against the running game and checked here against the disassembly. His Legaia Arts Data spreadsheet is what the legaia-art trigger tables are validated against.
  • Meth962 - the original forum analyses the above corrects: thread 800 and thread 941 (Wayback Machine); his 100% walkthrough grounds the curated enemy tables.

See also