Level-Up
How characters grow: the post-battle XP split, the per-level stat gains, and the "LEVEL UP!" banner. The numbers turn out to be tidy - the entire XP curve is the closed form
⌊n²/4⌋ + 1 per level, and every stat's growth is a normalised curve that lands each character exactly on their level-99 ceiling. Retail applies XP, stat growth, and the
level bump in one function, FUN_801E9504 (Ghidra's name for the traced routine at that address; it lives in a battle overlay - code loaded into RAM on demand - but reads two static tables
baked into SCUS_942.54, the game's main executable). The clean-room engine extracts both tables from the user's own executable at boot and drives them through
engine-core::levelup::LevelUpTracker.
XP curve Confirmed
The retail XP-to-next-level curve is a static SCUS_942.54 table plus a scaling formula, applied by FUN_801E9504 (the victory-path level-up applier; overlay-resident, dumped as overlay_battle_action_801e9504). The battle reward resolver FUN_8004E568 calls it once per surviving party member after dividing the monster XP pool by the alive-party count.
- Per-level XP-delta table
DAT_80076AF4(u16 entries; static SCUS data, below the0x801C0000overlay boundary). The 98 entries are exactly the closed formdelta(n) = ⌊n²/4⌋ + 1(1, 2, 3, 5, 7, 10, …, 2402), so the whole curve is derivable arithmetic. The current-level threshold is the running sumΣ DAT_80076AF4[0..level]. - Scaling formula:
threshold = (sum × 9 999 999) / 0x140FEforlevel < 0x11, elsesum × 0x79. - Per-character correction for party slots 1 / 2 (Noa subtracts, Gala adds):
(threshold × 0x14) / divisor, where the divisor is ani16read at0x80070A2C + level × 0x28- a static SCUS table (the head of the GTE sin LUT sampled at a0x28stride, doubling as a divisor curve: 125, 251, 376, … so the correction shrinks from ~16% at L1 toward ~0.5% mid-game). - Level-up loop: a
do … while (threshold ≤ cumulative XP)bumps the record level and applies one round of stat growth per crossed threshold, so one large award can advance several levels.
The only readers of DAT_80076AF4 in the corpus are the aliases of FUN_801E9504, confirming it as the canonical curve. The engine ships it twice, cross-validating itself: legaia_save::RETAIL_XP_CUMULATIVE / retail_xp_table() carry the derived base curve (121, 365, 730, 1338, 2190, …, 9 646 483 - closed-form deltas plus the applier arithmetic, no table bytes copied), and legaia_asset::level_up_tables::xp_thresholds_from_scus (+ xp_correction_divisors_from_scus) re-reads the same values from the user's SCUS_942.54 at boot (BootSession → LevelUpTracker::xp_table / threshold_for). Empirically validated against the record next-level-threshold word (+0x4) across the save-state library, including the Noa/Gala corrections (New Game: Vahn/Terra 121, Noa 102, Gala 140 - the Status menu draws +0x0/+0x4 verbatim as Experience / Next Level; L99 carries 0).
Stat gains Confirmed
Per-character stat growth is also FUN_801E9504's job, from two static SCUS tables. Each level-up iteration grows eight stats (HP, MP, then the six battle stats) in the record window +0x11C..+0x12D:
- Growth curves at
DAT_800769CC: 3 rows, stride0x62(= 98 =MAX_LEVEL−1), each a monotonic ramp settling to a0x40plateau at high levels. - Per-character parameter block at
DAT_80076918: stride0x3C, one record per Vahn / Noa / Gala, each 8 contiguous 6-byte sub-records{u16 start, u16 max, u8 jitter, u8 row}.startis the stat's level-1 base (validated against the new-game starting template - Gala matches on all 8 stats),maxthe level-99 ceiling,rowselects a curve,jitterthe spread.
Per-level gain per stat: max(1, (max−start) × curve[row][level−1] / 0x24C0 + rand() % (2×jitter+1) − jitter), then caps (HP ≤ 9999, MP ≤ 999, SP ≤ 0x118). The divisor 0x24C0 is the curve normalizer: each of the 3 curves sums to exactly 0x24C0, so the per-level term accumulates to exactly (max−start) over all 98 levels, landing each stat on its max at L99. The formula is validated byte-exact against a single-level capture (Noa, growth slot 1, L2→L3: HP +39, MP +5, six stats +2/+4/+4/+3/+4/+3 - every delta within the core ± jitter band; HP core = (4500−150)×82/9408 = 37, jitter half-range 4). Parser + check: legaia_asset::level_up_tables::GrowthTables::char_params / level_gain_core (disc-gated test).
Per-character semantics pinned from the capture corpus: Noa gains +40 SP_max per level at +0x10E (Seru-magic user), Gala gains 0 SP_max (physical Tactical Arts user), and the +0x120 u16 field is a per-stat cap constant 100, not SP_max.
Engine wiring Confirmed
The deterministic core is wired, all 8 stats: StatGain carries HP/MP plus the six battle stats, LevelUpTracker::with_growth_tables builds per-character StatGrowthCurve::PerLevel from the parsed SCUS tables, and BootSession installs it from the user's SCUS_942.54 at boot alongside the XP curve. apply_to_record grows the record-side stat window then mirrors it into the live window, matching the applier's write-then-mirror. Disc-gated tests pin the port: a boot test and 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). StatGain::default() (flat +10 HP / +5 MP) survives only as the disc-less fallback; seru_stats.rs is the legacy Seru-roster convenience path. No per-level character stat table exists in crates/gamedata, so the ground truth is the new-game seed plus the single-level capture.
Jitter is modeled as an opt-in layer. LevelUpTracker::with_level_up_jitter(seed) seeds a faithful PSX BIOS-rand LCG (BiosRand: seed = seed×0x41C6_4E6D + 0x3039; (seed>>16)&0x7FFF) and draws one rand() per stat per level in the applier's stat order - including the draw when jitter == 0 - applying the spread to the unfloored core before the max(1,…) floor, exactly as FUN_801E9504. It is off by default so every replay / determinism oracle stays bit-identical; a bit-exact reproduction of a specific retail roll would additionally need the BIOS-rand state at that moment (runtime, not on disc).
Character-record fields
| Offset | Field |
|---|---|
+0x004 | Cumulative XP (the value the threshold loop compares against). |
+0x11C..+0x12D | Record-side stat window (9 u16: HP_max, MP_max, the 100 cap at +0x120, six stats). Grown by the applier, then mirrored to the live window +0x104..+0x11B. |
+0x120 | Per-stat cap constant 100 (u16) - constant across every captured save and character. |
+0x130 | Displayed character level. The byte the status screen reads as "LV" and the Level 99 GameShark code targets - boot-confirmed: the shown level is read from +0x130 directly, not re-derived from XP (the new-game seed writes it). The retail applier maintains it by incrementing +1 per level-up event, so it can momentarily lag the XP-derived level after a rare multi-level grant; for single-level play it equals the level. Whether the adjacent +0x131 is a separate magic-rank byte is unconfirmed. |
The engine port tracks its own level at +0x100 (always zero in retail, where the live byte is +0x130) - self-consistent for the port's own LGSF saves, a deliberate divergence from the retail byte rather than a mirror of it.
Battle-actor stat struct Confirmed
The in-battle actor is a runtime struct distinct from the character record: DAT_801C9370[slot] (slot*4) is a pointer to it, 8 slots (party 0..2, monsters 3..7). It is not the char-record +0xF4..+0x13D window the ability aggregator FUN_80042558 builds. Party actors are stat-initialised by FUN_80053cb8 (copies the char-record live-stat window +0x104..+0x11A), monsters by FUN_80054cb0 (copies the monster-archive record); both write the identical +0x14C..+0x176 layout. Stats use working / base pairs - an even-offset working copy that buffs and damage mutate, plus a +2 base copy the applier keeps for un-buff / percentage math.
| Offset | Field |
|---|---|
+0x14C / +0x14E | HP current / HP max (party: char live +0x106 / +0x104; monster HP) |
+0x150 / +0x152 | MP current / MP max (party: char live +0x10A / +0x108; monster MP) |
+0x154 / +0x156 | AGL working / base (char live +0x110) |
+0x158 / +0x15A | ATK working / base (char live +0x112) |
+0x15C / +0x15E | UDF (physical def) working / base (char live +0x114 + equip-def bonus) |
+0x160 / +0x162 | LDF (magical def) working / base (char live +0x116 + equip bonus) |
+0x164 / +0x166 | SPD working / base (char live +0x118 + equip bonus) |
+0x168 / +0x16A | INT working / base (char live +0x11A) |
+0x16C | AI turn-eligibility gate (FUN_801E9FD4 skips actor when 0) |
+0x16E | Side / element bit-field (char +0x12E; the charm hook sets | 0x380; FUN_801E7320 retarget reads it) |
+0x170 | Spirit gauge / SP (party: char live +0x10E; monster: const 100) |
+0x172 / +0x174 | HP / MP battle-start snapshots (copies of +0x14C / +0x150) |
+0x176 | Per-action transient, cleared to 0 on action-state entry (0x801E490C) |
Provenance: ghidra/scripts/funcs/80053cb8.txt (party init), .../80054cb0.txt (monster init), .../overlay_0898_801e9fd4.txt (AI reads), .../overlay_0898_801e295c.txt (action SM).
Level-up flow
After BattleEndCause::MonsterWipe:
- Engine calls
World::apply_battle_xp(xp_reward). apply_battle_xpenumerates surviving party members (slots whoseBattleActor::hp > 0) and dividesxp_rewardequally among them (integer divide, remainder dropped). Dead members receive zero XP.- For each surviving slot, calls
LevelUpTracker::grant_xp(char_id, share);grant_xpaccumulates XP and checks the retail threshold table. Multi-level jumps collapse into oneLevelUpResultwith summed HP/MP gains. LevelUpTracker::apply_to_record(result, record)grows the record-side stat window, mirrors it to live, and writesresult.new_levelto the record's+0x100byte viaCharacterRecord::set_level. Current HP / MP are deliberately untouched — see below.BattleEvent::LevelUp { char_id, new_level, hp_gained, mp_gained }is pushed toWorld::battle_events.World::current_level_up_banneris set for the last character who levelled up.
Hydration on load. World::load_full syncs LevelUpTracker::level[] from each loaded character record's +0x100 byte. Without this, a reloaded party would keep the tracker's default 1 per slot even when the saved records hold the party at level 30; the next XP grant would then roll the party back to level 1 + N.
A level-up is not a heal
FUN_801E9504 stores to exactly eleven addresses, and every one is a maximum, a stat, or a level: the record window's hp_max / mp_max (+0x6E4 / +0x6E6, capped 0x270F / 0x3E7), the six battle stats at +0x6EA..+0x6F4, the displayed-level byte +0x6F8 (= +0x130) with its actor-table mirror, and two globals. Current HP (+0x106) and current MP (+0x10A) appear nowhere in it, and the routine's only jal is the BIOS rand, so no helper writes them either.
The captures agree, and they are the decisive half, because those cells are written near a level-up — by the battle-end resync, not by a grant. The single-level noa_levelup_* triplet reads Noa at 164/182 HP and 16/16 MP going into the fight and 164/221 HP / 16/21 MP once the L2→L3 level-up has settled: both maxima move, both currents stand still. Crossing a threshold mid-dungeon raises the ceiling and leaves you exactly as hurt as you were.
Level-up banner
LevelUpBanner carries char_id, new_level, hp_gained, mp_gained, and frames_remaining (default 180 = 3 s at 60 Hz). World::tick decrements the counter and clears the banner at zero.
level_up_draws_for(banner, world) in engine-render returns two text draw calls: a yellow “LEVEL UP! (char N → Lv M)” line and a green “HP +X MP +Y” line. Wired into PlayWindowApp::build_text_overlay at anchor (8, 60).
Capture provenance
The formulas above are grounded in mednafen save-state captures; the details live in disclosures because they are provenance, not behaviour.
Per-character level-up write footprints (phase split)
Three per-character multi-level observations are codified in engine_core::levelup::observations (vahn_4_level_jump / noa_4_level_jump / gala_4_level_jump); each is a settled pre→post diff over the character record window at battle scene map01. The level-up event splits the record write across frames - Noa's triplet pins three phases:
| Phase | Window | Writes |
|---|---|---|
| Record write | pre → mid₁ | +0x11C..+0x12D (record stats), +0x004 (XP), +0x130 (displayed level +1) |
| Live copy | mid₁ → mid₂ | +0x104..+0x11B (HP_max, MP_max, six u16 live stats) |
| Settle | mid₂ → post | +0x106 / +0x10A / +0x10E (live HP_cur / MP_cur / AP_cur settle - the battle-end resync, not a refill) |
Gala's runs in two phases (live copy + settle collapsed). Per-character record bases (Vahn 0x80084708, Noa 0x80084B1C, Gala 0x80084F30, slot 3 0x80085344, stride 0x414) are documented in engine_core::capture_observations::char_level_up; disc-gated tests in crates/mednafen/tests/real_saves.rs exercise both triplets. NB the multi-level captures are unreliable for per-level growth rates (their stated HP deltas are impossible under the validated ~38/level rate); the single-level Noa L2→L3 pair is the arithmetic oracle. Slot indices rotate with the corpus (scripts/scenarios.toml).
Fire Book I write footprint (displayed-skills list) + negative table search
Fire Book I. A pre/post save pair (Fire Book I used on Vahn in battle) pins exactly one record-internal write region at +0x185..+0x188: a count-prefixed list [u8 count at +0x185][u8 ids[N] at +0x186..] growing by a head-insert. The only reader is a menu-overlay cluster at 0x801D4440..0x801D44A4 (inside 0x801D33D8) that matches the ids against the menu spell table at 0x801E472C (stride 0x14) - so the values are skill-table indices (the menu's displayed-skill roster), not action-queue constants. Typed accessor: legaia_save::character::CharacterRecord::displayed_skills. No sb/sh writer to +0x185 exists in any captured overlay - the learn-write path is in an undumped overlay. Disc-gated test: fire_book_use_diff_pins_vahn_record_write.
Negative finding. A grep across PROT.DAT for u8 sequences matching the observed stat-delta tuples surfaces only per-effect animation curves (ramp-up-peak-ramp-down byte runs at offset 0x033E9000) - the grant tables are not in PROT.DAT at all; they are the static-SCUS pair DAT_800769CC / DAT_80076918 above.
Two look-alikes to avoid: the 98-entry slice at 0x80070A2C looks like an XP table but is GTE sin-LUT data (the applier samples it only as the correction divisor above) - the real L2 threshold is 121, as a New Game Status screen shows; and the Seru-struct +0x74 reads are the actor’s colour word, which FUN_800480D8 stamps with the 24-bit mid-grey 0x00808080 under the mask 0x00FFFFFF - not a growth grant, and not a 0x80808080 flag. Full history: docs/subsystems/level-up.md.
Open items
+0x120stat-cap field. Captures pin the per-stat cap constant 100 at+0x120(u16 LE);+0x11Ais the live INT stat (mutated by level-up, typed asLiveStats::int). Thelegaia_save::character::CharacterRecord::stat_capaccessor reads/writes+0x120- the same field asRecordStats::cap_constant(aliasing pinned bycharacter::tests::stat_cap_aliases_record_cap_constant). The runtime999clamp inFUN_80042558is a code constant (legaia_save::STAT_CAP), not this record field.- Overlay display. Retail shows per-stat increments (STR, INT, VIT, …) with animated counters; the port renders only the HP/MP banner lines.
Full reference
Full write-up at docs/subsystems/level-up.md. Applier dump: ghidra/scripts/funcs/overlay_battle_action_801e9504.txt. Source: crates/engine-core/src/levelup.rs, parser crates/asset/src/level_up_tables.rs.