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_80076AF4 at 0x801E9588) - static SCUS data, below the 0x801C0000 overlay boundary and clear of the sin LUT. The current-level threshold is the running sum Σ DAT_80076AF4[0..level].
  • Scaling formula (0x801E95D00x801E9624): threshold = (sum × 9999999) / 0x140FE for level < 0x11, else sum × 0x79; plus a per-character ± correction for slots 1/2 (Noa−, Gala+): threshold × 0x14 / divisor, the divisor an i16 at 0x80070A2C + level × 0x28 - the pointer global _DAT_8007B81C is constant across the whole save corpus, so the divisor table is static SCUS data (the head of the GTE sin LUT sampled at a 0x28 stride; divisors 125, 251, 376, … by level, shrinking the correction from ~16% at L1 toward ~0.5% mid-game). Parsed by legaia_asset::level_up_tables::xp_correction_divisors_from_scus, applied by LevelUpTracker::threshold_for, installed at boot.
  • Level-up loop: do…while (threshold ≤ record cumulative XP) (sltu at 0x801E9714 / 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):

LevelCumulative XP to reach (from level 1)
250
3106
5312
10949
203093
5014655
9934663

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, stride 0x62 (= MAX_LEVEL−1), each a monotonic ramp (row 0 = 0x50, 0x52, 0x54, …) settling to a 0x40 plateau at high levels.
  • A per-character parameter block at DAT_80076918 (addiu a0,a0,0x6918), stride 0x3C, one record per Vahn / Noa / Gala. Each is 8 contiguous 6-byte sub-records {u16 start, u16 max, u8 jitter, u8 row}. start is 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). max is the L99 ceiling, row picks a curve. (The leading 0x00B4 is Vahn’s HP start = 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:

AddressContent
0x801F4B8C4-byte display row-ID table for magic slots
0x801F4B98Magic-type name strings (Spirit / Defense / Meta / Terra / Ozma)
0x801F4C28+Battle-result text strings (win / annihilated / escaped / …)
0x801F5CF8, 0x801F5D90Binary 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:

  1. Engine calls World::apply_battle_xp(xp_reward).
  2. apply_battle_xp enumerates surviving party members (slots whose BattleActor::hp > 0) and divides xp_reward equally among them (integer divide, remainder dropped). Dead members receive zero XP.
  3. For each surviving slot, calls LevelUpTracker::grant_xp(char_id, share); grant_xp accumulates XP and checks the retail threshold table. Multi-level jumps collapse into one LevelUpResult with summed HP/MP gains.
  4. LevelUpTracker::apply_to_record(result, record) bumps hp_max + mp_max, restores hp_cur = hp_max, mp_cur = mp_max, and writes result.new_level back to the record's +0x100 byte via CharacterRecord::set_level.
  5. BattleEvent::LevelUp { char_id, new_level, hp_gained, mp_gained } is pushed to World::battle_events.
  6. World::current_level_up_banner is 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.

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:

OffsetPre-eventPost-eventRead
+0x1850x010x02length-prefix byte (+1)
+0x1860x0C0x03first list entry - new entry inserted at front
+0x1870x000x0Csecond 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.

CharacterSlotXP +0x004 (u16 LE)HP_maxMP_maxSP_max
Vahn (legacy)0365 → 730(+0x126 wrap, +38)+8+8
Noa1102 → 336+32+6+40
Gala2140 → 394+44+80

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:

PhaseWindowWrites
Record writepre → mid₁+0x11C..+0x12D (record stats), +0x004 (XP), +0x130 (rank counter +1)
Live copymid₁ → mid₂+0x104..+0x11B (HP_cur, MP_cur, six u16 live stats)
Settlemid₂ → 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 +40 SP_max at +0x10E (Seru-magic user; level-ups scale her Spirit gauge).
  • Gala grants 0 SP_max across the entire triplet (physical Tactical Arts user; level-up leaves +0x10E untouched).
  • +0x120 is a per-stat cap constant 100, not SP_max. Pinned across every captured save and every character. The earlier CharacterRecord::stat_cap accessor reading +0x11A is misnamed; +0x11A is a live stat slot mutated on level-up.
  • Rank counter at +0x130 increments by +1 per level-up event, independent of levels_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, stride 0x62) + DAT_80076918, read and applied by FUN_801E9504 (see Stat gains). Fully decoded: the parameter block is per-character (stride 0x3C), 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 by GrowthTables::char_params / level_gain_core (disc-gated test). The earlier negative results scanned the wrong code (the magic_level_up overlay is the display path; the +0x74 reads are a 0x80808080 battle-state flag; the PROT.DAT 0x033E9000 cluster is animation-curve data). Engine wiring done (deterministic core, all 8 stats): LevelUpTracker::with_growth_tables + BootSession install per-character StatGrowthCurve::PerLevel (HP/MP + the six battle stats) from the user’s SCUS, replacing the flat placeholder; apply_to_record grows the record-side window then mirrors to live. The per-level rand() jitter is also modeled as an opt-in layer (with_level_up_jitter + faithful BiosRand LCG, 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 into LevelUpTracker::threshold_for.
  • +0x120 stat-cap accessor renaming. Captures pin the per-stat cap constant 100 at +0x120 (u16 LE). The existing CharacterRecord::stat_cap reads +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/156 semantics not fully traced.
  • Real retail XP table source - resolved + ported. The curve is the static-SCUS table DAT_80076AF4 + the scaling formula, read by FUN_801E9504 (see XP table). The engine now extracts it at boot (legaia_asset::level_up_tables::xp_thresholds_from_scusBootSession; disc-gated tests). Prior sweeps targeting 0x8007123C / 0x80070A3C found 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.

See also