XP table

The 98 per-level XP increments stored at SCUS_942.54 0x8007123C (u16 LE, L1→2 first) run from 50 (L1→2) through 656 (L98→99). retail_xp_table() prefix-sums these into cumulative totals used by grant_xp. Selected milestones:

LevelCumulative XP to reach
250
3106
5281
10765
202780
5017925
9961444

Stat gains

Retail HP/MP growth does not come from a per-character per-level table in the overlay binary. Stat increments are sourced from per-Seru structs loaded from PROT entries at runtime. When a Seru gains a level the Seru struct field at +0x74 (HP grant) and sibling fields are applied to the battle actor’s stat block. The battle actor base lives at DAT_801C9370[slot] (8 slots: party 0–2, monsters 3–7); current HP is at +0x14C.

The level-up overlay data section (overlay_magic_level_up_full.bin, full 256 KB 0x801C0000–0x801FFFFF) was dumped. It contains UI display indices and animation tables, not stat increment arrays. Key data 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). Different characters (Vahn, Noa, Gala) have distinct curves in retail, derived from their respective Seru rosters. Override via LevelUpTracker::with_stat_gains([StatGain; 4]).

Level-up flow

After BattleEndCause::MonsterWipe:

  1. Engine calls World::apply_battle_xp(xp_reward).
  2. apply_battle_xp calls LevelUpTracker::grant_xp(char_id, share) for each active party member.
  3. 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 and restores hp_cur = hp_max, mp_cur = mp_max.
  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.

Fire Book I — captured write footprint

The mc4 (battle command menu parked on Fire Book I) mc5 (Fire Book I just used on Vahn) save pair 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-event (mc4)Post-event (mc5)Read
+0x1850x010x02length-prefix byte (+1)
+0x1860x0C0x03first list entry — new entry inserted at front
+0x1870x000x0Csecond list entry — pre-event entry shifted right

The byte values do not match retail learned-art constants (those occupy 0x1B..=0x32). Two consistent interpretations remain: (1) the cluster is a transient command-history buffer the item-use animation populated, with the actual learn write living outside the character record (e.g. global story-flag word at _DAT_1F800394); (2) the cluster is a per-character recent-action buffer the runtime pre-fills before the Fire Book animation. A reader-search through the captured battle-action overlay disambiguates. Until then the field is treated as pinned but uninterpreted — codified in engine_core::capture_observations::vahn_fire_book_use; disc-gated test fire_book_use_diff_pins_vahn_record_write in crates/mednafen/tests/real_saves.rs.

Open items

  • Per-Seru stat grants. The HP-grant field is at Seru struct +0x74; remaining grant fields (MP, STR, DEF, INT, LUCK) need tracing. Extraction requires a live Seru struct capture during a PROT entry load. Status: writer-search across the captured magic_level_up overlay returned negative for code-side sb/sh writes targeting the documented record offsets (+0x10E, +0x11C..+0x12C, +0x130, +0x161) — the per-Seru lookup table is read at runtime through a pointer set at scene-load time, not in any captured static body. Engine-side scaffold lives at engine_core::seru_stats: SeruStatGrant + SeruStatTable + LevelUpTracker::with_seru_roster install a flat curve summed across the equipped Seru. Replace with a StatGrowthCurve::PerLevel vector when the runtime trace lands.
  • Battle actor field map +0x14C–+0x176. Current HP at +0x14C, max HP at +0x14E; +0x150/152/154/156 semantics not fully traced.
  • XP share formula. Retail may divide the monster XP pool by active party size before the per-character check. The current port grants the full xp_reward to each member independently.
  • Overlay display. Retail shows per-stat increments (STR, INT, VIT, …) with animated counters. Only HP/MP are tracked in the current port.

Full reference

Complete table and provenance at docs/subsystems/level-up.md. XP table source: ghidra/scripts/funcs/ scan of SCUS_942.54 0x8007123C. Data section dump: ghidra/scripts/dump_levelup_data_section.py. Source: crates/engine-core/src/levelup.rs.