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]; id 0 = free; stacks cap at 99
Window
Three halfwords gp[+0x2D2] start, gp[+0x2D4] end, gp[+0x2D6] span - one writer
Accessors
Five SCUS_942.54 helpers (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
ItemBag over retail's own 256-slot array, window included; the accessor family is ported once in legaia_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.

Party of 2+window [0, 256) the whole bag Vahn alonewindow [0, 128) the early-game bag Anyone else alonewindow [128, 256) an isolated pocket 0 127 128 255 inside the window: add, consume and merge all work outside: still in RAM, invisible to every helper (one row = 16 slots)
One array, three access rules. The helpers only ever look between the window's 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:

+0item idu8, 0 = free
+1countu8, cap 99
+2 .. +0x1FFslots 1..255same shape

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_801D8734 in PROT 0899) zeroes the selected slot’s id and count with two sb of 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 within 0x400 tries - the only reader that reaches the key-item band without going through the menu window.
  • The census is closed, not sampled. The array sits 0xA640 above gp, so no gp-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.
HelperWhat it does
addfind a matching id, else the first free slot in the window
consumedecrement by id; zeroes an emptied id in place, never compacts
find / consume by slotfind a stack's slot by item id; decrement a stack addressed by slot index, freeing the slot at zero
normalizere-installs the window, merges duplicate stacks (cap 99), pulls occupied slots down into holes
window setupthe 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 membersStory flag 20First roster memberWindow installed
0--none - the previous window stays
1set-[0, 256)
1clearVahn[0, 128)
1clearanyone else[128, 256)
2 or morenot testednot 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. end is 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 ruleRetail
uniform draw over 256 slots, budget 0x400, one rand() per rejection0x801F77C4 loop
accept only id ≠ 0, count ≠ 0 and a non-zero shop price0x801F7884..0x801F78A4
re-draw while slot < window start when the second party member is character 40x801F77E8, floor *(i16*)0x8007B5EA
the removal scans only the window and misses outside itFUN_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

DivisionWhere it livesWhat it is
The window halvesthe three gp halfwordsRuntime bounds over one shared array, keyed on party composition. Storage is never partitioned - only access is.
Per-character geareach character record, equip bytes +0x196..+0x19DWorn weapon, armor and Goods are not in the bag. Equipping refunds the displaced item through the add helper.
Menu pagesconsumables 0x77..0x8E, equipment below, books and key items aboveThe 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 is class − 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’s tier byte 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) spends min(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 state 0x3E tests for item 0xFE (0x801E4080) and forces the selector to 0x0E (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

FunctionAddressWhat it proves
window setupFUN_8004313Csole SCUS writer of gp[+0x2D2 / +0x2D4 / +0x2D6]; 11 callers; branches on party count (0x80084594), story flag 20 (FUN_8003CE64(0x14)), first roster byte (0x80084598)
addFUN_800421D4id store precedes the bound check; count store guarded
consumeFUN_80042310zeroes the emptied id in place
find / consume by slotFUN_80042EE0 / FUN_80043048find-slot-by-id, and the by-index consume the port's Use, Throw Out and sell list take
normalizeFUN_800423E0calls window setup first; occupancy = id ≠ 0
item-use applierFUN_800402F4class dispatch; arts-book arm 0x80041FB4, Point Card arm 0x8004209C
new-game seedFUN_80034A6Cwrites slot 0 = (0x77 Healing Leaf, ×5)
battle rewardsFUN_8004E568loot grants go through the add helper
menu overlay sweepPROT 0899, every functionone direct array write, the Throw Out confirm’s sb zero pair (FUN_801D8734); every other inventory operation goes via the helpers
live readthree-member mid-game statewindow (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.

See also