Level-Up
Post-battle XP distribution, per-level stat gains, and banner display. Driven by engine-core::levelup::LevelUpTracker. The XP threshold table is from SCUS_942.54 at 0x8007123C (98 u16 increments). Stat growth comes from per-Seru structs loaded at runtime, not from a static table in the overlay.
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:
| Level | Cumulative XP to reach |
|---|---|
| 2 | 50 |
| 3 | 106 |
| 5 | 281 |
| 10 | 765 |
| 20 | 2780 |
| 50 | 17925 |
| 99 | 61444 |
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:
| 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). 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:
- Engine calls
World::apply_battle_xp(xp_reward). apply_battle_xpcallsLevelUpTracker::grant_xp(char_id, share)for each active party member.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_maxand restoreshp_cur=hp_max,mp_cur=mp_max.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.
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
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:
| Offset | Pre-event (mc4) | Post-event (mc5) | 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 |
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 capturedmagic_level_upoverlay returned negative for code-sidesb/shwrites 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 atengine_core::seru_stats:SeruStatGrant+SeruStatTable+LevelUpTracker::with_seru_rosterinstall a flat curve summed across the equipped Seru. Replace with aStatGrowthCurve::PerLevelvector when the runtime trace lands. - Battle actor field map
+0x14C–+0x176. Current HP at+0x14C, max HP at+0x14E;+0x150/152/154/156semantics 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_rewardto 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.