Level-Up
The last enemy falls, the spoils window counts out EXP and gold, and sometimes a second window announces that someone's level went up. Underneath, the game splits the EXP among the survivors, compares each one against a threshold curve, and grows eight stats per level crossed. The numbers are tidy: the whole XP curve is a closed form, and every stat's growth is a normalised curve that lands each character exactly on their level-99 ceiling. Fully ported
At a glance
- Applier
FUN_801E9504in the battle overlay (PROT0898) - XP, stat growth and the level bump in one routine, called once per surviving party member.- Tables
- Three static tables in
SCUS_942.54: per-level XP deltas, three growth curves, and a per-character parameter block (addresses). - Record fields
- Cumulative XP
+0x004, stat window+0x11C..+0x12D, displayed level+0x130in the 0x414-byte character record. - Parser / engine
legaia_asset::level_up_tablesreads the tables from the user's executable at boot;engine-core::levelup::LevelUpTrackerapplies them.- Confidence
- Confirmed - disassembly, byte-exact against a single-level capture.
- Used by
- Battle, Status screen, ROM patcher (starting level, EXP scale).
From victory to banner
Two things surprise players: a level-up raises maximum HP and MP but does not heal you, and one big award can jump several levels at once. Dead party members get no XP.
XP curve
The per-level XP delta is ⌊n²/4⌋ + 1 (1, 2, 3, 5, 7, 10, …, 2402 over 98 levels); the base threshold for a level is the running sum of those deltas. The applier then scales that sum and, for Noa and Gala, corrects it.
| Step | Rule |
|---|---|
| Base sum | sum(level) = Σ delta[0..level] |
| Scale, level < 17 | threshold = sum × 9,999,999 / 0x140FE (about ×121.69) |
| Scale, level ≥ 17 | threshold = sum × 121 |
| Party-slot correction | Noa subtracts and Gala adds threshold × 20 / divisor(level); the divisor curve runs 125, 251, 376, … so the correction shrinks from ~16% at L1 to ~0.5% mid-game. |
| Loop | do { level++; grow stats } while (threshold ≤ XP) - several levels per award are possible. |
Worked example - the level-2 thresholds a New Game Status screen shows: Vahn and Terra 121, Noa 102, Gala 140. The base curve continues 365, 730, 1338, 2190, … 9,646,483 at level 99. The engine carries the closed-form curve in legaia_save::retail_xp_table() and re-reads the same values from the user's executable at boot, so the two cross-check each other.
Stat gains
Each level crossed grows eight stats - HP max, MP max and the six battle stats - from two tables: three growth curves (one byte per level, each ramp settling to a plateau) and a per-character block of eight {start, max, jitter, row} entries, one per stat.
| Term | Rule |
|---|---|
| Core gain | (max − start) × curve[row][level−1] / 0x24C0 |
| Jitter | + rand() % (2×jitter + 1) − jitter, one BIOS rand() per stat per level |
| Floor and caps | max(1, core + jitter); then HP ≤ 9999, MP ≤ 999, SP ≤ 280, others ≤ 999 |
Why 0x24C0 | Every curve sums to exactly 9408, so the gains accumulate to exactly max − start over 98 levels: each stat lands on its ceiling at level 99. |
Worked example - Noa, L2 to L3, HP: (4500 − 150) × 82 / 9408 = 37, jitter ±4; the capture shows +39. Every one of her eight deltas that battle (MP +5, stats +2/+4/+4/+3/+4/+3) sits inside its core ± jitter band. Two per-character facts from the captures: Noa also gains +40 max Spirit per level, Gala gains none.
What a level-up writes
| Offset | Field | Note |
|---|---|---|
+0x004 | Cumulative XP | The value the threshold loop compares against; the Status menu draws it as Experience. |
+0x11C..+0x12D | Record-side stat window | 9 u16: HP max, MP max, a constant 100 at +0x120, six stats. Grown first, then mirrored to the live window +0x104..+0x11B. |
+0x120 | Per-stat cap constant 100 | Constant across every captured save; not Spirit max. The runtime 999 clamp is a code constant. |
+0x130 | Displayed level | The byte the Status screen reads as LV and the "Level 99" cheat targets. Incremented once per level-up event, never re-derived from XP. |
A level-up is not a heal. The applier stores to eleven addresses and every one is a maximum, a stat or a level; current HP and MP appear nowhere in it, and its only call is the BIOS rand. The capture agrees: Noa enters her L2-to-L3 fight at 164/182 HP and settles at 164/221 - ceiling up, wound unchanged.
The port
World::apply_battle_xpsplits the reward among survivors (integer divide) and callsLevelUpTracker::grant_xpper slot; multi-level jumps collapse into oneLevelUpResult.apply_to_recordgrows the record window, mirrors it live, and leaves current HP / MP alone. The port keeps its own level byte at+0x100(always zero in retail) and hydrates the tracker from it on load.- Jitter is opt-in (
with_level_up_jitter(seed), a faithful BIOS-rand LCG) so replay and determinism oracles stay bit-identical; it is off by default. - The banner (
LevelUpBanner, 180 frames) and the spoils report are retail's two framed windows, drawn from geometry measured off a capture. Two known gaps: only the last character's banner is shown, and both windows draw over the returned field scene rather than over the battle.
How we know
| Function / data | Address | What it proves | Dump |
|---|---|---|---|
| Level-up applier | FUN_801E9504 | Threshold scaling, the do-while loop, the growth formula, the eleven stores (no current-HP write). | overlay_battle_action_801e9504.txt |
| Reward resolver | FUN_8004E568 | Calls the applier once per surviving member after dividing the EXP pool. | 8004e568.txt |
| XP delta table | DAT_80076AF4 | 98 u16 entries equal to ⌊n²/4⌋+1; the applier's aliases are its only readers. | SCUS data |
| Correction divisor | 0x80070A2C + level × 0x28 | The head of the GTE sine table sampled at stride 0x28 - a look-alike of an XP table, but only ever used as the Noa / Gala divisor. | SCUS data |
| Growth curves | DAT_800769CC | 3 rows, stride 0x62, each summing to 0x24C0. | SCUS data |
| Per-character block | DAT_80076918 | Stride 0x3C: 8 × {u16 start, u16 max, u8 jitter, u8 row} per Vahn / Noa / Gala; start matches the new-game template. | SCUS data |
| Single-level capture | Noa L2→L3 triplet | Byte-exact check of the growth formula; the three-phase write order (record, live copy, battle-end settle). | crates/mednafen/tests/real_saves.rs |
| Engine oracles | crates/engine-core/tests/growth_curve_disc.rs | Installed curve start values vs the new-game seed; Noa's L2→L3 core HP 37 / MP 6. | - |
Capture footprints and look-alikes
| Phase | Window | Writes |
|---|---|---|
| Record write | pre → mid₁ | +0x11C..+0x12D, +0x004, +0x130 (+1) |
| Live copy | mid₁ → mid₂ | +0x104..+0x11B (HP max, MP max, six live stats) |
| Settle | mid₂ → post | +0x106 / +0x10A / +0x10E - the battle-end resync, not a refill |
Multi-level captures (vahn_4_level_jump and siblings, codified in engine_core::levelup::observations) pin write regions but not per-level rates; the single-level Noa pair is the arithmetic oracle. A Fire Book I use writes one region, +0x185..+0x188: a count-prefixed list of displayed-skill ids read only by the menu's status renderer (CharacterRecord::displayed_skills). The Seru-struct +0x74 reads are the actor's colour word (FUN_800480D8 stamps 0x00808080), not a growth grant. The grant tables are not in PROT.DAT at all.
Battle-actor stat struct (runtime, distinct from the record)
DAT_801C9370[slot] points at the in-battle actor (8 slots: party 0..2, monsters 3..7). Party actors are initialised by FUN_80053cb8 from the record's live window, monsters by FUN_80054cb0 from the monster archive; both write the same layout. Stats are working / base pairs - the working copy takes buffs and damage, the +2 base copy serves un-buff and percentage math.
| Offset | Field |
|---|---|
+0x14C / +0x14E | HP current / max (party: live +0x106 / +0x104) |
+0x150 / +0x152 | MP current / max (party: live +0x10A / +0x108) |
+0x154 / +0x156 | AGL working / base |
+0x158 / +0x15A | ATK working / base |
+0x15C / +0x15E | UDF (physical defence) working / base, plus equip bonus |
+0x160 / +0x162 | LDF (magical defence) working / base, plus equip bonus |
+0x164 / +0x166 | SPD working / base, plus equip bonus |
+0x168 / +0x16A | INT working / base |
+0x16C | AI turn-eligibility gate (FUN_801E9FD4 skips the actor when 0) |
+0x16E | Side / element bit-field (record +0x12E; the charm hook ORs 0x380) |
+0x170 | Spirit gauge (party: live +0x10E; monster: constant 100) |
+0x172 / +0x174 | HP / MP battle-start snapshots |
+0x176 | Per-action transient, cleared on action-state entry |
Dumps: 80053cb8.txt, 80054cb0.txt, overlay_0898_801e9fd4.txt, overlay_0898_801e295c.txt.
Full write-up: docs/subsystems/level-up.md. Source: crates/engine-core/src/levelup.rs, crates/asset/src/level_up_tables.rs.