Battle formulas
How much a hit does, what a Tactical Art multiplies, what the defender's numbers subtract - and the smaller rolls around them: turn order, fleeing, MP costs, the RNG. Every formula is traced from the game's own code and mirrored in the from-scratch engine. The Offense Value / Defense Value shape follows ZetaPhoenix's community analysis, which corrected Meth962's earlier forum write-ups - see credits.
At a glance
- Melee kernel
FUN_801EC3E4in 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_800402F4inSCUS_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.
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:
| Command | Slot read | Fold |
|---|---|---|
| Left arm | slot 2 | atk[2] / 2 |
| Right arm | slot 3 | atk[3] / 2 |
| High / Low | slot 4 (footwear) | atk[4] / 2 |
| Art hit | slots 0-4 | (sum of all five) / 2 |
| Monsters | none | no 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
| Term | What it is |
|---|---|
Rnd(1..1.125) | x + rand() % (x/8 + 1) - between x and x + x/8. Drawn separately for attack and defense. |
| Power | The 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 / 256 | The attacker's current HP, shifted down. |
| Juggle | 1 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. |
| Angle | The 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. |
| Element | From 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 DEF | Defender UDF for a high-class strike, LDF for a low-class one. Party defenders do get their equipment's defence bytes here. |
| Distance | Accumulated 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 stance | Defense ×3 when the defender chose Spirit, or is fleeing while a party member wears Safe Escape. |
| Venom / Toxic | Scales 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.
| Hit | Base Offense | Rnd | Offense terms | Offense | Defense | Damage |
|---|---|---|---|---|---|---|
| 1 - Left arm | 188 + 98/2 = 237 | +7 → 244 | 244×20/16 = 305, HP 10, juggle 1 → 3, angle 0 | 318 | UDF 42 +1, distance +1 = 44 | 274 |
| 2 - Right arm | 188 + 100/2 = 238 | +10 → 248 | 248×20/16 = 310, HP 10, juggle 2 → 7 | 327 | UDF 42 +2 = 44 | 283 |
| 3 - Hyper Elbow | 188 + (98+86+100)/2 = 330 | +20 → 350 | 350×20/16 = 437, HP 10, juggle 1 → 5; sum 452, ×13/10 | 587 | LDF 49 +2 = 51 | 536 |
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 / rateticks 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.
| Defender | Flinch beat/rate | Window | Read as |
|---|---|---|---|
| Rogue | 1/2 | 2 ticks | effectively un-jugglable |
| Bee family | 5/2 | 10 ticks | |
| Gobu Gobu | 8/2 | 16 ticks | |
| Zeto | 16/2 | 32 ticks | |
| Caruban | 10/1 | 40 ticks | hit again within ~0.67 s and the counter climbs |
| Vahn (party) | 3/1 | 12 ticks | why 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
| Formula | Expression | Worked example |
|---|---|---|
| Accuracy / evasion | rand() % (acc + eva); hit when eva < roll - hit chance acc / (acc + eva) | acc 120 vs eva 40: hits 75% of the time |
| Turn order | initiative key = SPD + rand(0..SPD/2) + 1 per round; highest living key acts | SPD 60 draws 61..91 - a SPD 40 actor (41..61) can still edge a bad roll |
| MP cost | MP-half passive: base - (base >> 1); MP-quarter: base - (base >> 2); half wins when both are worn | a 5-MP spell costs 3 with MP-half (rounds up, not down); 40 → 30 with MP-quarter |
| Spirit super-art damage | target_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 flag | a fast, wounded party out-rolls a slow healthy pack most rounds; Chicken Heart / King bits tilt it further |
| Stat buff | working stat × 6/5, clamp 0xFFFF; each stat is a working/base halfword pair | ATK 300 → 360 for the fight |
| Seru-magic stat debuff | stat - 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 members | two survivors of a 400-EXP fight get 150 each |
| RNG | seed = seed × 1103515245 + 12345; return (seed >> 16) & 0x7FFF | range 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:
| Record | Actor pair | Stat | Consumed by |
|---|---|---|---|
+0x0C | +0x14C | HP | the applicator's HP delta |
+0x10 | +0x150 | MP | cast costs |
+0x0E | +0x154 | AGL | the action gauge the AI spends per action, re-derived each round; "Power Up" raises it |
+0x12 | +0x158 | ATK | attacker's offense |
+0x14 | +0x15C | UDF | defense vs high-class strikes |
+0x16 | +0x160 | LDF | defense vs low-class strikes |
+0x18 | +0x168 | INT | magical damage and magic defense; seeds accuracy / evasion |
+0x1A | +0x164 | SPD | the 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 element | Effect | lv 3-4 | lv 5-6 | lv 7-8 | lv 9 |
|---|---|---|---|---|---|
| 0 earth | DEF down - all four defence halfwords | 5% | 10% | 15% | 20% |
| 1 water | AGL down - the gauge base +0x156 only | 5% | 10% | 15% | 20% |
| 2 fire | ATK down | 5% | 10% | 15% | 20% |
| 3 wind | SPD down | 5% | 10% | 15% | 20% |
| 4 thunder | INT down | 5% | 10% | 15% | 20% |
| 5 light | cure class (party targets) | 1 | 2 | 3 | 4 |
| 6 dark | MP down - current MP +0x150 only | 5% | 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:
| Record | Field | Note |
|---|---|---|
+0x44 u16 | base gold | scaled, then committed to the wallet (clamp 99,999,999) |
+0x46 u16 | base EXP | ×3/4, split among living members |
+0x48 u8 | drop item id | 0 = no drop |
+0x49 u8 | drop 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_predamagefor 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, therand()%9+8floor, 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 / data | Address | What it proves | Dump |
|---|---|---|---|
| Melee kernel | FUN_801EC3E4 | The 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 arms | PTR_801CF4B4 table, arms 0x801ECBC4..0x801ECDE4 | Which slot each command reads, halved; the arms live in a listing gap and are read straight from overlay bytes. | overlay 0898 bytes |
| Battle loader | FUN_80053CB8 | ATK is seeded base, equipment folded only at swing time. | 80053cb8.txt |
| Reeling byte | FUN_80047430 | The only two writers of +0x1F7; the first-event-frame derivation. | 80047430.txt; live write-watch |
| Applicator | FUN_800402F4 | Selector dispatch; HP caps at DAT_8007655C; accuracy roll; buff ramps. | 800402f4.txt |
| MP-cost modifier | FUN_801E295C state 0x28 | Subtract-a-shift (rounds up); MP-half short-circuits MP-quarter. | overlay_0898_801e295c.txt |
| Escape roll | FUN_801E791C | Party / enemy score formulas, two draws, accessory bits. | overlay 0898 dump |
| Spoils | FUN_8004E568 | Gold / EXP scaling; record fields +0x44..+0x49; runtime write-watch on the wallet. | 8004e568.txt |
| Magic kernel + finisher | FUN_801DD0AC / FUN_801DDB30 | INT-driven roll; per-caster summon power table at 0x801F5468; finisher stages. | overlay 0898 dumps |
| RNG | FUN_80056798 | PsyQ 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 thelegaia-arttrigger 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.