Per-character save record Confirmed
Open the status screen and everything you see about Vahn - level, HP, the eight stats, his
learned magic, what he is wearing - is one block of RAM: a 0x414-byte record per party member,
four of them side by side. Level-ups write into it, equipping writes into it, and saving to a memory card copies
it out verbatim. Its field offsets are pinned by an unusually good ground-truth source: a GameShark cheat
labelled "Max ATK" that pokes one address is a free field label for that offset.
At a glance
- In the game
- the status / equip / magic screens, level-ups, and every memory-card save
- Magic / marker
- none - a fixed RAM layout; the save block is a verbatim dump of it
- Lives in
- RAM
0x80084708 + slot × 0x414(slots 0..3); on the card atblock + 0x5C8 + n × 0x414 - Size / stride
0x414bytes per record, 4 contiguous records- Parser
legaia_save::CharacterRecord(typed accessors + round-trip);Party::from_retail_sc_block- Confidence
- Confirmed for every offset in the layout table - each cross-referenced against at least two of: a Ghidra-dumped function, a captured save-state observation, a labelled GameShark cheat
- Used by
- level-up, field menu, save screen, battle formulas
The record and where it lives
0x414 bytes. Solid regions are mapped; dashed regions are unmapped or partially mapped.| Slot | Base | Owner |
|---|---|---|
| 0 | 0x80084708 | Vahn |
| 1 | 0x80084B1C | Noa |
| 2 | 0x80084F30 | Gala |
| 3 | 0x80085344 | Terra (the New Game template's 4th roster entry; never a savable battle-party member) |
Slot 3's footprint runs into the global story-flag bitmap and the inventory, so only its leading fields are exclusive; the tail aliases the globals. Benign - Terra is never saved as an active member.
On the memory card
The save block dumps this region verbatim: record n sits at block + 0x5C8 + n×0x414.
"Verbatim" is true of the bytes, not the whole block: editing a record also invalidates the additive checksum at
block + 0x1FFC, which retail compares before accepting the save - a stale word captions the slot
"Damaged data." and the record never reaches RAM. legaia_save::card::restamp_sc_block_checksum is the
fix-up; see the save screen.
Layout
Stats come in pairs: the _live copies are the effective values the aggregator rebuilds every frame
(equipment and buffs applied), while the _record copies are the base stats the level-up system
maintains. The quoted names are the GameShark cheat labels that pinned each offset.
| Offset | Size | Field | Meaning |
|---|---|---|---|
+0x000 | 4 | xp_cumulative | cumulative experience - "Max Exp" target; what the level-up applier compares against the threshold |
+0x004 | 4 | xp_next_threshold | next-level XP threshold - the status screen's "next" readout (note); seeded per character to reach(L2) |
+0x008 | 8 | header tail | unmapped |
+0x010 | 228 | stat block | partially mapped via the per-frame stat aggregator |
+0x0F4 | 16 | ability_bits | OR'd into the shared ability bitfield by the per-frame aggregator (accessory passives) |
+0x104 | 2 | hp_max_live | effective max, aggregator-rebuilt |
+0x106 | 2 | hp_curr_live | "Infinite HP"; walk-regen bumps it, clamping at +0x104 |
+0x108 / +0x10A | 2+2 | mp_max_live / mp_curr_live | "Max MP"; each pair is (max, cur) |
+0x10C / +0x10E | 2+2 | sp_max_live / sp_curr_live | "100 AP" |
+0x110 | 2 | agl_live | "Max AGL" |
+0x112 | 2 | atk_live | "Max ATK" |
+0x114 | 2 | udf_live | "Max UDF" |
+0x116 | 2 | ldf_live | "Max LDF" |
+0x118 | 2 | spd_live | "Max SPD" |
+0x11A | 2 | int_live | "Max INT"; clamped at 0x3E7 |
+0x11C / +0x11E | 2+2 | hp_max_record / mp_max_record | "Max HP" / "Max MP" record copies |
+0x120 | 2 | stat_cap_constant | always 100 in captured saves (note) |
+0x122 .. +0x12C | 6×2 | agl/atk/udf/ldf/spd/int_record | record copies, same order as the live block |
+0x12E | 2 | battle_status_flags | the packed ailment word, mirrored from the battle actor (note) |
+0x130 | 1 | level_displayed | the displayed character level - "Level 99" target (note) |
+0x131 | 1 | magic-rank candidate | unconfirmed; the new-game seed inits it to 1. +0x132..+0x13B unmapped |
+0x13C | 1 | learned_spell_count | length byte of the id list below - "Magic Slot Activator" writes 0x24 here (note) |
+0x13D / +0x149 / +0x155 | 3×12 | magic_group_0..2 | "Magic Modifier 1..12" - three copies of the slot |
+0x161 | 16 | summon_levels | "All Summons Level 9" - one byte per summon id (note); +0x179..+0x184 unmapped |
+0x185 | 1 | displayed_skill_count | menu-overlay art roster count |
+0x186 | 16 | displayed_skill_ids | "Has all Arts"; head insert on item-use learn. Fifteen arts per character, so the 16th slot +0x195 is never reached - the patcher's --show-super-arts keeps its performed-Super mask there |
+0x196 | 1 | armor_id | "Armor Modifier" / "Best Equipment" |
+0x197 | 1 | head_gear_id | "Head Gear Modifier" |
+0x198 | 1 | weapon_id | "Weapon Modifier" |
+0x199 | 1 | accessory_or_seru_lock | "Activate Meta/Terra/Ozma at Lv9" - the Seru lock byte the menu reads to gate summon access |
+0x19A | 1 | leg_gear_id | "Leg Gear Modifier" |
+0x19B .. +0x19D | 3 | accessory_1..3_id | "Accessory N Modifier"; +0x19E..+0x2A6 unmapped |
+0x2A7 | 9 | display name | NUL-padded |
+0x2B0 | 14×0x14 | active_spell_slots[14] | runtime active-spell slots (active_spell_slot() accessor); +0x380..+0x413 unmapped |
Field notes
+0x12E is the battle status word
The halfword mirrors the battle actor's packed ailment flags, copied verbatim every frame a party actor
ticks - the record carries the live in-battle condition. The battle HUD reads the same halfword through a
second base (details in How we know); the per-bit map is on the
accessory-passive page, engine mirror
engine-vm::status_effects::display_flags.
+0x130 is the displayed character level
The status screen reads it as "LV" directly; the shown level is not re-derived from XP. The
level-up applier bumps it once per level-up event, so it can momentarily lag after a rare
multi-level jump. The accessor CharacterRecord::magic_rank() reads this byte - the level byte
under a legacy name; level() reads +0x100, always zero in retail.
+0x000 is cumulative XP; +0x004 is the next-level threshold
+0x000 is the "Experience" readout and the value the applier compares; +0x004 is
the "next" readout, rewritten on every level change. The starting-level randomizer seeds +0x000,
not +0x004.
+0x13C is the learned-spell count - which is why the cheat works
The database calls it "Magic Slot Activator", but two menu-overlay readers treat it as a plain length byte
for the id list at +0x13D. "Activating" the list is just declaring it 36 entries long. Treat it as
a count when writing: a value past the real id run displays uninitialised slots rather than granting spells.
+0x161..+0x178 is the summon-level array, not spell levels
One byte per summon id, stamped 0x09 by the All Summons Level 9 cheat. Any per-spell levels sit
in the magic-group window at +0x13D..+0x160. Accessor summon_levels(); the older
SpellList::levels is kept for compatibility only.
+0x120 is the record-stat cap constant
Always 100: the hard ceiling record-side stats clamp against, rewritten by the level-up
overlay on every level-up. The live copies then clamp at 0x3E7, so equipment and buffs can push
past it up to 999 in battle. Accessors stat_cap() / set_stat_cap().
Evidence for each note
- Status word.
FUN_80047430copies battle actor+0x16Everbatim (lhu 0x16e(actor)thenshat0x80047680and0x80048040). The HUD's status element selectorFUN_8002C2E4indexes0x80084140 + slot × 0x414; since0x80084140 + 0x5C8 == 0x80084708, its+0x6F6is this+0x12E, its+0x6CEis+0x106, its+0x6F8is+0x130. - Level byte. Boot-confirmed via the starting-level randomizer: a New Game record with level-10 XP, stats and threshold but
+0x130 == 1displays LV 1; setting+0x130 = 10displays LV 10. The captured Noa / Gala four-level jumps bumped the byte once. - XP vs threshold. A randomized ROM that wrote a level-10 XP value into
+0x004showed "next level: 11195" while experience stayed 0. The u16 that appeared to move by an XP-shaped delta at+0x004in the level-up triplets is the low half of the threshold word, which the applier rewrites on every level change. - Spell count. The per-character list screen
FUN_801DA2A0reads+0x13Cas the Ra-Seru-gated learned-spell count before walking the ids; the window-7 character prompt substitutesrecord[+0x13D + sel](field menu).
History: superseded readings
- "
+0x130is Magic Rank, not character level" - reconciled the per-event+1tick with the Level 99 cheat the wrong way round; falsified by the starting-level boot test. If a magic-rank byte exists it is the unconfirmed+0x131. - "The level-up logic reads
+0x004as cumulative XP" - conflated the threshold the applier reads with the XP it compares against. - "
+0x161..+0x184is a parallel spell-level array" - it is the 16-summon level array.
The falsified-reading catalogue is do-not-re-walk.
How we know
Each offset is cross-referenced against at least two of three independent sources:
| Source | Anchor | What it proves |
|---|---|---|
| Per-frame stat aggregator | FUN_80042558 | live-stat block layout, the 0x3E7 live clamp |
| Ability-bitmap reader | FUN_800431D0 | +0x0F4 ability bits OR'd into 0x80074358 |
| Spell-list pop | FUN_80042DBC | the +0x13C count / +0x13D id-list pairing |
| Level-up applier | FUN_801E9504 (overlay) | record-stat writes, threshold rewrite, level byte tick, +0x120 constant |
| Ailment mirror | FUN_80047430, HUD selector FUN_8002C2E4 | +0x12E status word and the second HUD base |
| Save-state captures | capture_observations::char_level_up, levelup::observations::{noa,gala}_4_level_jump | which bytes move on a level-up event |
| GameShark database | data/cheats/ | a labelled (addr, value) write per named field |
Cheat-database citation table (address per field)
Vahn's addresses; Noa (+0x414) and Gala (+0x828) shift each by one stride.
| Cheat | Address (Vahn) | Offset | Field |
|---|---|---|---|
| 100 AP | 0x80084816 | +0x10E | sp_curr_live |
| Max HP | 0x8008480C / 0E / 0x80084824 | +0x104 / +0x106 / +0x11C | hp live max / cur / record |
| Max MP | 0x80084810 / 12 / 0x80084826 | +0x108 / +0x10A / +0x11E | mp live max / cur / record |
| Max AGL .. Max INT | 0x80084818..22 / 0x8008482A..34 | +0x110..+0x11A / +0x122..+0x12C | live / record stat pairs |
| Level 99 | 0x80084838 | +0x130 | level_displayed |
| Magic Slot Activator | 0x80084844 | +0x13C | learned_spell_count |
| Magic Modifier 1 | 0x80084845 / 51 / 5D | +0x13D / +0x149 / +0x155 | three magic-group copies |
| All Summons Level 9 | 0x80084869..86 | +0x161..+0x178 | summon_levels |
| Has all Arts | 0x8008488D..9A | +0x185..+0x192 | displayed_skill_count + ids |
| Best Equipment | 0x8008489E / A0 / A2 | +0x196 / +0x198 / +0x19A | armor / weapon / leg gear |
| Activate Meta at Lv9 | 0x800848A1 | +0x199 | accessory_or_seru_lock |
| Accessory 1 Modifier | 0x800848A3 | +0x19B | accessory_1_id |
| Max Exp | 0x80084708 / 0A | +0x000 / +0x002 | xp_cumulative (combined u32) |
Source of record: docs/formats/save-record.md.