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.
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. - In the port
legaia_game_tables::level_up_tablesreads the tables from the user's executable at boot;engine-battle::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
- Victorythe end-of-action gate sees no living monster
- 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
- Thresholdeach survivor’s cumulative XP is compared against the scaled curve, looping while it is crossed
- Growthper level crossed: HP max, MP max and six stats grow along the character’s curve, with jitter
- RecordXP, next threshold and the level byte are written - the three fields the Status screen draws
- Windowsthe spoils window, with one level-up line above it naming everyone who levelled
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.
| 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 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.
| Term | Rule |
|---|---|
| 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 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. |
| AGL is the exception | Its 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
| 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 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 / 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). | save-state triplet |
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_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.
| 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-battle/src/levelup.rs, crates/game-tables/src/level_up_tables.rs.