At a glance

Applier
FUN_801E9504 in the battle overlay (PROT 0898) - 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 +0x130 in the 0x414-byte character record.
In the port
legaia_game_tables::level_up_tables reads the tables from the user's executable at boot; engine-battle::levelup::LevelUpTracker applies 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

  1. Victorythe end-of-action gate sees no living monster
  2. Spoilsthe EXP pool is scaled by 3/4 and ceiling-divided among the members standing and not Stoned (the rest take nothing and leave the divisor); gold is halved twice; then the level-ups, then the drop
  3. Thresholdeach survivor’s cumulative XP is compared against the scaled curve, looping while it is crossed
  4. Growthper level crossed: HP max, MP max and six stats grow along the character’s curve, with jitter
  5. RecordXP, next threshold and the level byte are written - the three fields the Status screen draws
  6. Windowsthe spoils window, with one level-up line above it naming everyone who levelled
start, maxper character, per stat growth curveone byte per level (max - start) x curve[L] / 0x24C0+ jitter, at least 1 stat += gaincapped level 99lands on max once per level crossed every curve sums to 0x24C0 over 98 levels
Why every stat ends exactly on its ceiling: the curve is normalised, so 98 level-ups hand out exactly max − start before jitter. The jitter averages out but is not corrected for.

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.

StepRule
Base sumsum(level) = Σ delta[0..level]
Scale, level < 17threshold = sum × 9,999,999 / 0x140FE (about ×121.69)
Scale, level ≥ 17threshold = sum × 121
Party-slot correctionNoa 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.
Loopdo { 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 as legaia_save::RETAIL_XP_CUMULATIVE (exposed as engine-battle::levelup::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.

TermRule
Core gain(max − start) × curve[row][level−1] / 0x24C0 (/ 0x18C0 for AGL)
Jitter+ rand() % (2×jitter + 1) − jitter, one BIOS rand() per stat per level
Floor and capsmax(1, core + jitter); then HP ≤ 9999, MP ≤ 999, SP ≤ 280, others ≤ 999
Why 0x24C0Every 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.
AGL is the exceptionIts block divides by 0x18C0 (6336), not 0x24C0, so AGL accumulates about one and a half times its span and hits its cap long before level 99. Three retail readings of Vahn's AGL at levels 6 / 11 / 16 match the 0x18C0 core exactly and miss the 0x24C0 one by 5 / 10 / 15.

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

OffsetFieldNote
+0x004Cumulative XPThe value the threshold loop compares against; the Status menu draws it as Experience.
+0x11C..+0x12DRecord-side stat window9 u16: HP max, MP max, a constant 100 at +0x120, six stats. Grown first, then mirrored to the live window +0x104..+0x11B.
+0x120Per-stat cap constant 100Constant across every captured save; not Spirit max. The runtime 999 clamp is a code constant.
+0x130Displayed levelThe 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 ceiling that grows is the base one: the stat aggregator then adds the equipment’s percent boosts on top, so a Max HP +25% accessory is never folded into the base by a level-up.

In the port

legaia_game_tables::level_up_tables reads the three tables from the player’s own executable at boot, and engine-battle::levelup::LevelUpTracker applies them: the XP share is the 3/4-scaled pool ceiling-divided among survivors (battle_formulas::victory_exp_per_member), a multi-level jump collapses into one result, the first character to level takes the banner and any others queue behind it, and the record’s maxima grow while current HP and MP stay where they were. Terra is credited her share and never levels: the applier returns at once for her record, which the tracker mirrors. The jitter runs on the world’s rand() stream: World::credit_character_xp lends it to the tracker for the grant, since retail’s rand() has one seed, and a disc-gated differential runs FUN_801E9504 off the disc on the same stream and holds the record and the draw count equal. A tracker with no stream lent applies only the deterministic core and draws nothing. The spoils and level-up windows are retail’s two framed windows, drawn from geometry measured off a capture (engine-ui). The EXP share and the gold in the spoils sentence are number records, not text: the number draw FUN_8003563C appends each to the window's text actor, right-aligned in a five-cell field, and the actor-list draw renders it in the menu numeral cells (8 px a digit, leading zeros blank), so the figures are in the numeral face rather than the dialog font; the out-of-battle level-up banner is composed in engine-screens for both hosts.

How we know

Function / dataAddressWhat it provesDump
Level-up applierFUN_801E9504Threshold scaling, the do-while loop, the growth formula, the eleven stores (no current-HP write).overlay_battle_action_801e9504.txt
Reward resolverFUN_8004E568Calls the applier once per surviving member after dividing the EXP pool.8004e568.txt
XP delta tableDAT_80076AF498 u16 entries equal to ⌊n²/4⌋+1; the applier's aliases are its only readers.SCUS data
Correction divisor0x80070A2C + level × 0x28The 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 curvesDAT_800769CC3 rows, stride 0x62, each summing to 0x24C0.SCUS data
Per-character blockDAT_80076918Stride 0x3C: 8 × {u16 start, u16 max, u8 jitter, u8 row} per Vahn / Noa / Gala; start matches the new-game template.SCUS data
Single-level captureNoa L2→L3 tripletByte-exact check of the growth formula; the three-phase write order (record, live copy, battle-end settle).save-state triplet
Capture footprints and look-alikes
PhaseWindowWrites
Record writepre → mid₁+0x11C..+0x12D, +0x004, +0x130 (+1)
Live copymid₁ → mid₂+0x104..+0x11B (HP max, MP max, six live stats)
Settlemid₂ → post+0x106 / +0x10A / +0x10E - the battle-end resync, not a refill

Multi-level captures (vahn_4_level_jump and siblings, codified in engine_battle::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.

OffsetField
+0x14C / +0x14EHP current / max (party: live +0x106 / +0x104)
+0x150 / +0x152MP current / max (party: live +0x10A / +0x108)
+0x154 / +0x156AGL working / base
+0x158 / +0x15AATK working / base
+0x15C / +0x15EUDF (physical defence) working / base, plus equip bonus
+0x160 / +0x162LDF (magical defence) working / base, plus equip bonus
+0x164 / +0x166SPD working / base, plus equip bonus
+0x168 / +0x16AINT working / base
+0x16CAI turn-eligibility gate (FUN_801E9FD4 skips the actor when 0)
+0x16ESide / element bit-field (record +0x12E; the charm hook ORs 0x380)
+0x170Spirit gauge (party: live +0x10E; monster: constant 100)
+0x172 / +0x174HP / MP battle-start snapshots
+0x176Per-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-battle/src/levelup.rs, crates/game-tables/src/level_up_tables.rs.

See also