Inventory - one bag, one active window
Legaia keeps the whole party’s items in a single 256-slot bag. Every read and write of that bag is fenced by an active window that shrinks to one 128-slot half whenever a character travels alone. That one rule explains the “divided” inventory players notice during solo segments - and why the folklore “72-slot” limit is a display page, not a bound.
At a glance
- Where
- Main RAM
0x80085958; save block offset+0x1818(rides verbatim into every memory-card save) - Size
- 256 slots × 2 bytes:
[item id][count]; id0= free; stacks cap at 99 - Window
- Three halfwords
gp[+0x2D2]start,gp[+0x2D4]end,gp[+0x2D6]span - one writer - Accessors
- Five
SCUS_942.54helpers (add / consume by id / find a slot / consume by slot / normalize); 125 references disc-wide across six images, and only two of them - both cast modules - skip the window - In the port
ItemBagover retail's own 256-slot array, window included; the accessor family is ported once inlegaia_save::retail_inventory- Confidence
- Confirmed - disassembly of every helper plus a live three-member window read
- Used by
- Field menu, Shop, Field script VM, Battle item use
What players see
Open the menu and the bag looks like a handful of tabbed pages. Play a segment where one character is alone and the bag seems cut in half: items the party had are gone, and what the loner picks up never shows up once everyone regroups. Both impressions come from one array and one window. The pages are views; the halves are access rules; nothing is ever physically moved.
start and end; nothing is ever moved between the halves. A lone character with story flag 20 set gets the whole bag.Storage and access
Each slot is two bytes, and the array is a plain run of them inside the live game-state block, so it rides verbatim into every memory-card save:
Chests and scripts (the field VM’s GIVE_ITEM op), battle rewards, shops and the equip swap-back all change the bag through one small helper family, passing item ids or helper-returned slot numbers. The field, battle and menu overlays also read slots directly. There is no raw-index sort or swap primitive anywhere in retail.
- One direct write. The pause menu’s Throw Out confirm (
FUN_801D8734in PROT 0899) zeroes the selected slot’s id and count with twosbof its own (0x801D88FC/0x801D8910). - Two readers ignore the window. Both are capture-class cast modules: PROT 0941 (Steal / Stone Circle) draws over the whole array and tests its pick only against the window-start halfword (
0x801F7828; full rules below); PROT 0954 (Fatal Decision) has no such test and samples all 256 slots, accepting the first whose id, count and item price are all non-zero within0x400tries - the only reader that reaches the key-item band without going through the menu window. - The census is closed, not sampled. The array sits
0xA640abovegp, so nogp-relative instruction can reach it; neither its base nor the block base appears as a literal word anywhere on the disc; and every one of the 125 accesses materialises the block base inline. Only two are absolute (the new-game seed’s slot-0 write); no instruction on the disc names an address inside the key-item band.
| Helper | What it does |
|---|---|
| add | find a matching id, else the first free slot in the window |
| consume | decrement by id; zeroes an emptied id in place, never compacts |
| find / consume by slot | find a stack's slot by item id; decrement a stack addressed by slot index, freeing the slot at zero |
| normalize | re-installs the window, merges duplicate stacks (cap 99), pulls occupied slots down into holes |
| window setup | the sole writer of the three window halfwords |
A new game seeds exactly one slot - five Healing Leaves - after pre-zeroing the whole range. Occupancy keys on the id alone, so a live id with a zero count survives compaction.
The active window
The helpers never see “256 slots”; they see [start, end). Window setup picks the bounds from the party roster - member count and the first roster id - every time normalize runs.
| Party members | Story flag 20 | First roster member | Window installed |
|---|---|---|---|
| 0 | - | - | none - the previous window stays |
| 1 | set | - | [0, 256) |
| 1 | clear | Vahn | [0, 128) |
| 1 | clear | anyone else | [128, 256) |
| 2 or more | not tested | not tested | [0, 256) |
- Consequence. A solo character has an isolated pocket of the bag: they can neither see nor spend the party’s items, and what they pick up alone never reaches the party inventory.
- Design intent. Vahn owns the lower half because the game opens with Vahn alone - the early-game bag simply is
[0, 128)until the party forms. Once two members are present the full 256 opens. - Bounds.
endis only ever 128 or 256. Slots outside the window exist in RAM but are invisible to add, consume, capacity and normalize alike.
Why slot order matters
Because the bag is never compacted behind the player’s back, holes are real, and two consumers address it by slot rather than by item:
- The pause item list. The selected row’s payload is a bag slot index. The list hides empty slots, and nothing compacts the bag when the menu opens (the normalize helper has one call site, a guarded field-VM arm), so the row number and the slot number part ways as soon as a hole sits above the selection. Throw Out zeroes the slot the row names.
- The enemy Steal. PROT 0941 is a rejection sampler over the physical array, with four rules (below). The price test is the field a shop prices a purchase from, so quest and found-only items - 96 of the 256 ids are unpriced - can never be stolen. The removal goes through the windowed consume helper: a steal that lands in the half the window does not cover announces an item the party keeps. That asymmetry is retail’s.
| Steal rule | Retail |
|---|---|
uniform draw over 256 slots, budget 0x400, one rand() per rejection | 0x801F77C4 loop |
accept only id ≠ 0, count ≠ 0 and a non-zero shop price | 0x801F7884..0x801F78A4 |
re-draw while slot < window start when the second party member is character 4 | 0x801F77E8, floor *(i16*)0x8007B5EA |
| the removal scans only the window and misses outside it | FUN_80042310, miss arm 0x80042374 |
Measured on a bag holed at slots 1, 3 and 6: the pause-list cursor stepped 0, 2, 4, 5, 7, 8 across six displayed rows and the normalize helper ran zero times. So removing by row ordinal is wrong as soon as a hole sits above the selection, and removing by item id is wrong as soon as one id occupies two slots.
Three “divisions” that are not the window
| Division | Where it lives | What it is |
|---|---|---|
| The window halves | the three gp halfwords | Runtime bounds over one shared array, keyed on party composition. Storage is never partitioned - only access is. |
| Per-character gear | each character record, equip bytes +0x196..+0x19D | Worn weapon, armor and Goods are not in the bag. Equipping refunds the displaced item through the add helper. |
| Menu pages | consumables 0x77..0x8E, equipment below, books and key items above | The pause menu’s tabs filter the one array by item-id band. The “72-slot” figure is the general-items page size. |
The add helper’s off-by-one
The add helper stores the item id before it bound-checks. On a completely full window the scan exits at i == end and the id byte lands one slot past the window; only the count store is guarded. This is the core of the arbitrary-code-execution reachability thread, which is settled: the write primitive is real and normal play cannot reach it (settled threads).
Details: reaching the leak, and the probe trap
The stray id lands at 0x80085958 + end·2: 0x80085A58 when end = 128, 0x80085B58 when end = 256. Demonstrating it needs a bag genuinely filled to end (256 slots with a multi-member party) with the hit shown to land past the guard. An execution probe at pc = 0x800422BC fires on every successful add, before the guard, so a hit there is not out-of-bounds evidence by itself. The two windows fail differently: the merge pass keys on the id byte, so at most 255 distinct ids can be live and the full 256-slot window always keeps a hole. The half windows are 128 slots, and the id space does not save them - the item-name table carries 250 non-empty names over its 256 ids, so 128 distinct live ids is arithmetically reachable; what bounds the half-window case is how much of that population is obtainable while a character travels alone - a progress bound, not a capacity one, and not measured.
History: the 72-slot ACE ceiling
The earlier ceiling “0x800859E8 = first key-item slot” assumed the 72-slot display page was the window. The accessors bound on end, which is only ever 128 or 256, so every out-of-bounds figure derived from a 72-slot bag is void. See do-not-re-walk.
Two use legs that are not a heal
The item-use applier FUN_800402F4 dispatches on the effect descriptor’s class byte, and two of its arms are not target effects.
- The Hyper-Art books (classes 11 / 12 / 13, the Fire / Wind / Thunder Books I..III, arm
0x80041FB4) write a character record’s displayed-skill list. The record written isclass − 11- roster slot 0, 1 or 2 from the descriptor alone - so the picked target is never read: a Fire Book used on Noa still teaches Vahn. The descriptor’stierbyte is the art id, not a book level, and the insert keeps the list ascending by id. The books are field-usable, not battle-usable. - The Point Card strike (class 14, arm
0x8004209C) spendsmin(bank, 9999)of the Point Card counter as HP damage. No item row resolves to class 14; the arm is reached by id instead - battle action state0x3Etests for item0xFE(0x801E4080) and forces the selector to0x0E(0x801E40C0). The purse is the one a shop buy credits.
In the port
The engine keeps the bag as an ItemBag over retail’s own 256-slot array: (id, count) pairs in slot order, holes included, with the active window installed over them. The slot arithmetic lives once in legaia_save::retail_inventory, so the preservation tooling and the running engine share each routine. Id-addressed consumers (menu pages, GIVE_ITEM, buy / sell, the battle Item arm) use a map-shaped adapter over the array.
- Rows carry slots. The pause list and bag rows carry their slot, and Use, Throw Out and the shop’s sell list remove through the by-slot consume, which zeroes the id in place and leaves the hole.
- Steal reproduces all four rules, including the draw-vs-removal asymmetry.
- Saves keep slot order. The engine save carries the whole array, holes intact, as an optional block beside the compact item list; a file without it seeds densely from the list. Lifting a retail save reads the whole
0x200-byte span, not the 72-slot page.
How we know
| Function | Address | What it proves |
|---|---|---|
| window setup | FUN_8004313C | sole SCUS writer of gp[+0x2D2 / +0x2D4 / +0x2D6]; 11 callers; branches on party count (0x80084594), story flag 20 (FUN_8003CE64(0x14)), first roster byte (0x80084598) |
| add | FUN_800421D4 | id store precedes the bound check; count store guarded |
| consume | FUN_80042310 | zeroes the emptied id in place |
| find / consume by slot | FUN_80042EE0 / FUN_80043048 | find-slot-by-id, and the by-index consume the port's Use, Throw Out and sell list take |
| normalize | FUN_800423E0 | calls window setup first; occupancy = id ≠ 0 |
| item-use applier | FUN_800402F4 | class dispatch; arts-book arm 0x80041FB4, Point Card arm 0x8004209C |
| new-game seed | FUN_80034A6C | writes slot 0 = (0x77 Healing Leaf, ×5) |
| battle rewards | FUN_8004E568 | loot grants go through the add helper |
| menu overlay sweep | PROT 0899, every function | one direct array write, the Throw Out confirm’s sb zero pair (FUN_801D8734); every other inventory operation goes via the helpers |
| live read | three-member mid-game state | window (0, 256, 256), 160 contiguous occupied slots - more than any 72-slot model allows |
Cheat-device names (Have 99 Items, Item Modifier) are third-party anchors on the same region; the Have 99 Items range covers 72 slots because that is the page it targeted. Per-cell detail: memory map.