Level-Up
Post-battle XP distribution, per-level stat gains, and banner display. Driven by engine-core::levelup::LevelUpTracker. The retail XP curve and per-character stat growth are now pinned: both are static SCUS_942.54 tables applied by the overlay level-up function FUN_801E9504 (called from the reward resolver FUN_8004E568). The earlier “XP table at 0x8007123C” and the sin-LUT slice the engine still ships are fabricated placeholders; the “Seru struct +0x74” growth reading stays falsified.
XP table
The retail XP-to-next-level curve is a static SCUS_942.54 table plus a scaling formula, applied by the level-up function FUN_801E9504 (overlay-resident; dumped as overlay_battle_action_801e9504, aliased into magic_level_up / magic_capture / muscle_dome). The reward resolver FUN_8004E568 calls it at 0x8004F34C (jal, arg = active-party slot − 1) after dividing the monster XP pool by the alive-party count. The source:
- Per-level XP-delta table
DAT_80076AF4(u16, referenced literally as&DAT_80076AF4at0x801E9588) - static SCUS data, below the0x801C0000overlay boundary and clear of the sin LUT. The current-level threshold is the running sumΣ DAT_80076AF4[0..level]. - Scaling formula (
0x801E95D0–0x801E9624):threshold = (sum × 9999999) / 0x140FEforlevel < 0x11, elsesum × 0x79; plus a per-character ± correction for slots 1/2 (Noa−, Gala+):threshold × 0x14 / divisor, the divisor ani16at0x80070A2C + level × 0x28- the pointer global_DAT_8007B81Cis constant across the whole save corpus, so the divisor table is staticSCUSdata (the head of the GTE sin LUT sampled at a0x28stride; divisors 125, 251, 376, … by level, shrinking the correction from ~16% at L1 toward ~0.5% mid-game). Parsed bylegaia_asset::level_up_tables::xp_correction_divisors_from_scus, applied byLevelUpTracker::threshold_for, installed at boot. - Level-up loop:
do…while (threshold ≤ record cumulative XP)(sltuat0x801E9714/0x801E9F70) bumps the record level and applies stat growth per crossed threshold, so one large award can advance several levels.
The only readers of DAT_80076AF4 in the corpus are the four aliases of FUN_801E9504, confirming it is the canonical curve. This supersedes the earlier sin-LUT reading: a prior pass mistook a 98-entry slice of the sin LUT at 0x80070A2C (sin[0x408..0x46A]: 50, 56, 62, …) for the XP table after an off-by-0x800 confusion. the engine now extracts the real curve at boot - legaia_asset::level_up_tables::xp_thresholds_from_scus reads DAT_80076AF4 + the formula from the user’s SCUS and BootSession installs it over LevelUpTracker::xp_table (byte-validated L2 = 365, L3 = 730 vs a captured retail level-up). engine_core::levelup::retail_xp_table()’s sin-LUT slice is now only the disc-less fallback. The placeholder cumulative totals (not retail):
| Level | Cumulative XP to reach (from level 1) |
|---|---|
| 2 | 50 |
| 3 | 106 |
| 5 | 312 |
| 10 | 949 |
| 20 | 3093 |
| 50 | 14655 |
| 99 | 34663 |
Stat gains
Per-character stat growth is also FUN_801E9504’s job, from static SCUS tables - the writer the earlier capture work couldn’t find is the victory-path applier, not the magic_level_up display overlay that was searched. Each level-up iteration grows 8 stats at record +0x6E4..+0x6F4 (= the +0x11C..+0x12D window via a constant 0x5C8 base offset) from two static tables:
- Growth curves at
DAT_800769CC(addiu s4,v0,0x69CC): 3 rows, stride0x62(=MAX_LEVEL−1), each a monotonic ramp (row 0 =0x50, 0x52, 0x54, …) settling to a0x40plateau at high levels. - A per-character parameter block at
DAT_80076918(addiu a0,a0,0x6918), stride0x3C, one record per Vahn / Noa / Gala. Each is 8 contiguous 6-byte sub-records{u16 start, u16 max, u8 jitter, u8 row}.startis the stat’s base value - validated against the new-game starting template: Gala matches on all 8 stats, Vahn/Noa on HP/MP/AGL (late-join retune).maxis the L99 ceiling,rowpicks a curve. (The leading0x00B4is Vahn’s HPstart= 180, not a length word.)
Per-level gain (disassembly 0x801E9758..0x801E97F8): 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. 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). The earlier “~4.8x overshoot” was an artifact of the unreliable multi-level corpus observations (their HP deltas are impossible under the validated ~38/level rate), not the formula. Parsed + checked by legaia_asset::level_up_tables::GrowthTables::char_params / level_gain_core (disc-gated test). Engine wiring (deterministic core - done, all 8 stats): StatGain carries HP/MP + the six battle stats; LevelUpTracker::with_growth_tables + BootSession install per-character curves from the user’s SCUS, replacing the flat 10/5 placeholder (a disc-gated boot test pins Noa’s L2→L3 core HP 37 / MP 6), and apply_to_record grows the record-side window then mirrors to live. Jitter (modeled, opt-in): 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 applier stat order, including the draw when jitter == 0 - applying the spread to the unfloored core (level_gain_core_raw) before the max(1,…) floor, exactly as FUN_801E9504. Off by default (zero draws ⇒ every replay/determinism oracle stays bit-identical); a bit-exact roll additionally needs the BIOS-rand state at that moment (runtime, not on disc). The slots-1/2 XP-threshold correction is also resolved + wired (see the curve list above). The “Seru struct +0x74” reading stays falsified (those reads are a 0x80808080 battle-state flag from FUN_800480D8).
The magic_level_up display overlay data section (0x801C0000–0x801FFFFF) holds UI display indices and animation tables, not the stat curves (which are in static SCUS, above). Key display addresses:
| Address | Content |
|---|---|
0x801F4B8C | 4-byte display row-ID table for magic slots |
0x801F4B98 | Magic-type name strings (Spirit / Defense / Meta / Terra / Ozma) |
0x801F4C28+ | Battle-result text strings (win / annihilated / escaped / …) |
0x801F5CF8, 0x801F5D90 | Binary animation tables for particle spawner FUN_80050ED4 |
0x801F6000+ | Live animation state globals (zero at rest) |
StatGain::default() uses placeholder flat rates (+10 HP / +5 MP per level for all characters). Retail varies growth per character via the DAT_80076918 parameter block; until the gain arithmetic above reconciles, don’t fabricate numbers - populate a measured curve via LevelUpTracker::with_stat_gains([StatGain; 4]), or extract the real DAT_800769CC curve at runtime.
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)bumpshp_max+mp_max, restoreshp_cur=hp_max,mp_cur=mp_max, and writesresult.new_levelback to the record's+0x100byte viaCharacterRecord::set_level.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.
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).
Fire Book I - captured write footprint
A pre/post save pair (battle command menu parked on Fire Book I → Fire Book I just used on Vahn) pins the per-character record write footprint of an in-battle Fire Book usage. The mednafen-state diff over Vahn's character record (0x80084708..+0x414) surfaces exactly one 3-byte region at +0x185..+0x188:
| Offset | Pre-event | Post-event | Read |
|---|---|---|---|
+0x185 | 0x01 | 0x02 | length-prefix byte (+1) |
+0x186 | 0x0C | 0x03 | first list entry - new entry inserted at front |
+0x187 | 0x00 | 0x0C | second list entry - pre-event entry shifted right |
Reader resolved. A grep across the captured menu overlays for any read at +0x185(reg) surfaces exactly one reader cluster at 0x801D4440..0x801D44A4 in the menu overlay function 0x801D33D8:
801d4440 lbu t2,0x185(t2) ; load count from char_rec[+0x185]
801d4454 lbu v0,0x185(t1)
801d445c slt v0,s6,v0 ; loop while s6 < count
801d4480 addu a0,t1,s6
801d4498 lbu v1,0x1(s2) ; spell-table[+1] = id
801d449c lbu v0,0x186(a0) ; load id from char_rec[+0x186 + s6]
801d44a4 beq v1,v0,... ; match against the spell-table
The structure is [u8 count at +0x185][u8 ids[N] at +0x186..]. The menu's spell-table at 0x801E472C is indexed by these IDs (stride 0x14; record[+0] = sort key, record[+1] = ID, record[+0xC] = name pointer). Display caps at 7 by slti v0,t2,0x7; the on-record array fits 16 bytes (the gap to the equipment-slot field at +0x196). The pre/post Fire Book I capture is a head-insert into this list - the menu's displayed-skill roster grew by one new entry. The values are skill-table indices, not action-queue constants; the earlier "0x03 = Attack" reading is moot.
Engines now read this through a typed accessor legaia_save::character::CharacterRecord::displayed_skills (DisplayedSkillList { count: u8, ids: [u8; 16] }); capture_observations::vahn_fire_book_use gains MENU_READER_ADDR (0x801D4440) and MENU_OVERLAY_FN (0x801D33D8) constants pointing at the resolved reader. No sb/sh writers to +0x185 exist in any captured overlay - the learn-write path is in an overlay we haven't dumped (likely the item-use battle event).
A disc-gated test fire_book_use_diff_pins_vahn_record_write in crates/mednafen/tests/real_saves.rs asserts exactly one record-internal region at the documented offset against the real save pair. Three new unit tests in legaia_save::character exercise the typed accessor's BEFORE/AFTER round-trip + the MAX_DISPLAYED_SKILLS clamp.
Captured per-character level-up footprint
Three per-character 4-level-jump observations have been captured from the mednafen save corpus. Each one is a settled pre→post diff over the character record window; the underlying captures are pre / mid / post save triplets at battle scene map01.
| Character | Slot | XP +0x004 (u16 LE) | HP_max | MP_max | SP_max |
|---|---|---|---|---|---|
| Vahn (legacy) | 0 | 365 → 730 | (+0x126 wrap, +38) | +8 | +8 |
| Noa | 1 | 102 → 336 | +32 | +6 | +40 |
| Gala | 2 | 140 → 394 | +44 | +8 | 0 |
Codified in engine_core::levelup::observations: vahn_4_level_jump (legacy historical fact, source saves rotated out of the active corpus), noa_4_level_jump, and gala_4_level_jump. LevelUpObservation::stat_deltas is an 18-byte window covering the persistent record stats at +0x11C..+0x12D (9 u16 LE values: HP_max, MP_max, per-stat cap = 100, six record-side stats). LevelUpObservation::record_stats_u16() lifts the window as [u16; 9].
Multi-frame phase split
The level-up event splits the character-record write across multiple frames. Noa’s triplet pins three phases:
| Phase | Window | Writes |
|---|---|---|
| Record write | pre → mid₁ | +0x11C..+0x12D (record stats), +0x004 (XP), +0x130 (rank counter +1) |
| Live copy | mid₁ → mid₂ | +0x104..+0x11B (HP_cur, MP_cur, six u16 live stats) |
| Settle | mid₂ → post | +0x106 / +0x10A / +0x10E (live HP_max / MP_max / SP_max settle) |
Gala’s level-up runs in two phases (record write, then live copy + settle collapsed into one frame). 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 with helpers read_record_stats / read_rank_counter / read_xp_u16. Disc-gated tests noa_level_up_triplet_pins_phase_split_and_settled_deltas + gala_level_up_triplet_pins_phase_split_and_settled_deltas in crates/mednafen/tests/real_saves.rs exercise both triplets end-to-end. The slot indices that hold each frame in the active corpus live in scripts/mednafen/scenarios.toml; they rotate as the corpus is re-captured for new investigations.
Per-character semantic findings
- Noa grants
+40SP_max at+0x10E(Seru-magic user; level-ups scale her Spirit gauge). - Gala grants
0SP_max across the entire triplet (physical Tactical Arts user; level-up leaves+0x10Euntouched). +0x120is a per-stat cap constant100, not SP_max. Pinned across every captured save and every character. The earlierCharacterRecord::stat_capaccessor reading+0x11Ais misnamed;+0x11Ais a live stat slot mutated on level-up.- Rank counter at
+0x130increments by+1per level-up event, independent oflevels_gained(4-level jumps still bump the byte by one).
Cross-character delta search (negative finding)
A grep across extracted/PROT.DAT for u8 sequences matching the observed Vahn / Noa / Gala stat-delta tuples surfaces a 128-byte stride table at PROT.DAT byte offset 0x033E9000. Inspection shows records with ramp-up-peak-ramp-down patterns (06 06 07 08 09 0A 0B 0C 0D 0E 0F 0F 0F 0E 0D 0C 0B 0A 09 08 07) characteristic of per-effect animation curves, not stat grants. Net: cross-character u8-pattern search does not surface the stat-grant table - because it is not in PROT.DAT at all. The grant tables are the static-SCUS pair DAT_800769CC / DAT_80076918 read by FUN_801E9504 (see Stat gains).
Open items
- Per-character stat grants - RESOLVED + validated + wired. The growth source is the static-SCUS pair
DAT_800769CC(3 progression curves, stride0x62) +DAT_80076918, read and applied byFUN_801E9504(see Stat gains). Fully decoded: the parameter block is per-character (stride0x3C), 8 contiguous 6-byte{u16 start, u16 max, u8 jitter, u8 row}sub-records,start= base stat validated against the new-game template (Gala matches all 8); per-level gain =max(1,(max−start)×curve[row][level−1]/0x24C0 + jitter)with the divisor confirmed as the curve normalizer. Validated byte-exact against a single-level Noa L2→L3 capture (the earlier "overshoot" was bad multi-level observations). Parsed + checked byGrowthTables::char_params/level_gain_core(disc-gated test). The earlier negative results scanned the wrong code (themagic_level_upoverlay is the display path; the+0x74reads are a0x80808080battle-state flag; the PROT.DAT0x033E9000cluster is animation-curve data). Engine wiring done (deterministic core, all 8 stats):LevelUpTracker::with_growth_tables+BootSessioninstall per-characterStatGrowthCurve::PerLevel(HP/MP + the six battle stats) from the user’s SCUS, replacing the flat placeholder;apply_to_recordgrows the record-side window then mirrors to live. The per-levelrand()jitter is also modeled as an opt-in layer (with_level_up_jitter+ faithfulBiosRandLCG, off by default so determinism oracles stay bit-identical). The slots-1/2 XP-threshold correction is resolved from the save corpus (constant pointer → static divisor table) and wired intoLevelUpTracker::threshold_for. +0x120stat-cap accessor renaming. Captures pin the per-stat cap constant 100 at+0x120(u16 LE). The existingCharacterRecord::stat_capreads+0x11A, which is a live stat slot. Tracked separately.- Battle actor field map
+0x14C–+0x176. Current HP at+0x14C, max HP at+0x14E;+0x150/152/154/156semantics not fully traced. - Real retail XP table source - resolved + ported. The curve is the static-SCUS table
DAT_80076AF4+ the scaling formula, read byFUN_801E9504(see XP table). The engine now extracts it at boot (legaia_asset::level_up_tables::xp_thresholds_from_scus→BootSession; disc-gated tests). Prior sweeps targeting0x8007123C/0x80070A3Cfound nothing because both are wrong addresses; those scanners are superseded. - Overlay display. Retail shows per-stat increments (STR, INT, VIT, …) with animated counters. Only HP/MP are tracked in the current port; other stats are handled by the per-character record's stat aggregator (
FUN_80042558).
Full reference
Full write-up at docs/subsystems/level-up.md. The values currently embedded in retail_xp_table() are a SCUS sin-LUT slice (virtual 0x80070A3C, file offset 0x61A3C) - fabricated XP, not retail; the real curve is DAT_80076AF4 + formula via FUN_801E9504 (see XP table). Applier dump: ghidra/scripts/funcs/overlay_battle_action_801e9504.txt. Source: crates/engine-core/src/levelup.rs.