Flow

The retail engine enters the shop from the field-VM WARP / shop-trigger opcode. The menu overlay dispatches on a sub-screen ID (pointer table at 0x801E4F40, the same table the save screen uses). The five phases are:

PhaseStateDescription
Buy listShopBuyShows available items + prices. Cursor selects an item.
Sell listShopSellShows player inventory. Cursor selects an item to sell.
QuantityShopQuantityNumeric selector 1..9. Confirms how many to buy/sell.
ConfirmShopConfirmYes / No prompt. Yes commits the transaction.
ExitShopExitClears session, returns to field.

On ShopConfirm slot 0 (Yes): try_buy deducts gold and credits inventory; try_sell credits gold and decrements inventory. Sell price is max(buy_price / 2, 1); items not in the shop's buy list sell for 1 gold.

Point Card accrual (retail). The retail buy commit FUN_801db7f4 also credits the Point Card counter _DAT_800845B4 (u32) before the gold debit: when the party holds item 0xFE (the Point Card - inventory-has check func_0x80042f4c(0xFE)), it adds price / 20 per unit bought, capped at 9,999,999. Sell transactions never accrue. Not yet mirrored in the engine's buy_from_shop. (see ghidra/scripts/funcs/overlay_shop_save_801db7f4.txt)

The menu state machine (engine-vm::menu) owns the per-screen transition graph - commit_route on Cross, back_route on Triangle - while the MenuHost commit hooks apply only the side effects. Cross steps ShopBuy/ShopSellShopQuantityShopConfirm → back to ShopBuy (so the player keeps shopping after each confirm); Triangle backs up one screen, and from the list it routes through the auto-advancing ShopExit teardown screen. ShopExit fires its one-shot session-clear on entry, holds for the render fade (transient_hold_frames), then drops to the menu’s Closing state. The same routing drives the inn (InnConfirm Yes → transient InnSleep fade → close; No → close).

Gold-shop stock source

A gold town merchant's stock is not an overlay data table - it lives inline in the scene's field-VM script (the MAN, each scene's script + data container, asset type 0x03; the field/event VM is the interpreter that runs it), as field-VM op 0x49 (STATE_RESUME) sub-op 0 carrying [count][item_ids][name]. The count over-counts the purchasable stock by a trailing run of unsellable, price-0 template ids (the Ra-Seru Meta $N placeholders 0x01/0x02/0x03, or a lone 0x03) the on-screen shop skips. The shared scanner legaia_asset::shop_stock (a byte-scan, robust to the dialogue-picker jump tables a linear walk desyncs on) locates these records; legaia_engine_core::shop_catalog pairs them with item prices to build a priced ShopInventory, parked on World::scene_shops per scene.

Buy prices are the u16 at item record +2 in the SCUS_942.54 item table - the same field the gold-debiting buy handler FUN_801db380 reads (_DAT_8008459C -= price[id]). A price of 0 marks a quest / key / found-only / internal item the game never sells, so the price table doubles as a sellable mask (price > 0). The mask does double duty in the scan: a record must lead with a sellable item (rejecting non-shop 0x49 payloads), and the trailing unsellable template-id padding (the Ra-Seru Meta $N slots, which are named but priced 0) is trimmed out of the stock. Across the disc every shop partitions cleanly - a leading priced run then an unsellable tail (≤3 ids), never interleaved - so the engine and randomizer both surface exactly the real stock, and the whole gold-shop population decodes. Validated against the Rim Elm Variety Store's 10 known items and the disc-wide partition guard.

Scanner trap: requiring every id in the record to be sellable rejects nearly all shops, because most op-0x49 records carry the unsellable template-id padding. The sellable-mask partition above is the correct rule.

Live trigger: opening a merchant in-game is the field VM's own op 0x49 sub-0. On the arm edge the VM hands the host the instruction bytes (FieldHost::op49_menu_request); World::try_arm_field_shop runs the sellable-mask record validation directly on those bytes and, on a match, stages a priced session on World::pending_field_shop and arms the op-0x49 tristate (the opcode's Idle / Armed / Done state machine - the script stays suspended in Armed until the host finishes, the same host-suspension idiom the town01 opening's name-entry overlay uses). The host drains it, drives the buy/sell UI, and calls World::finish_field_shop on close - flipping the tristate Armed → Done so the VM resumes past the merchant op.

The casino / prize-exchange table at 0x801E4518 is a different thing: its buy handler debits _DAT_800845A4 (the casino coin bank), not party gold, so it is the casino exchange (already parsed by the randomizer's CasinoExchange). The prize-exchange UI is a menu-overlay session like the gold shop: a save inside the ticket-counter prize shop holds game_mode 0x17 (the CARD/menu pair, same as the pause menu) with menu overlay PROT 0899 resident in slot A - while talking to the counter attendant the game is still field mode 3 under the field overlay.

ShopSession

ShopSession holds the cursor and pending-transaction state for one open shop visit. Installed on MenuRuntime by open_shop(ShopSession) before menu entry.

FieldTypeMeaning
inventoryShopInventoryItems the shop offers for purchase
pending_item_idOption<u8>Item selected at current sub-flow cursor
pending_quantityu8Quantity from ShopQuantity step
pending_is_buyingbooltrue = buy, false = sell

Key methods: select_buy_item(cursor), select_sell_item(cursor, items), set_quantity(slot) (quantity = slot + 1), try_buy(world_money), try_sell(held_count).

Render layout

Traced from FUN_801d5de0 (overlay_shop_save.bin). The buy list iterates up to 8 visible rows (scroll managed by _DAT_8007bb98 / _DAT_8007bb90), each row drawn at a fixed 14 px (0x0E) vertical stride:

ElementX offset (px)Notes
Cursor+0Hand sprite; gated by _DAT_8007BB98
Item name+20 (0x14)func_0x80036888
Price+112 (0x70)6-digit field; func_0x80034b78

There is no gold footer in this window - the purse is its own window, and the stock renderer reads the party gold only to decide a row's ink.

Row colour (retail _DAT_8007b454 palette index) is decided by three tests run in order, each overwriting the previous verdict rather than yielding to it: a full stack (held count not < 0x63) greys the row; a non-zero “already owned / restricted” marker at stock record +2 then re-inks it to the accent pen even though the stack is full; and finally an unaffordable price (gold < price) greys it again even though the marker set the accent. Ported as engine-core::shop::shop_stock_row_ink; both hosts feed the ink into engine-ui::shop_draws_for through ShopRow::ink. Cost prompt and Yes/No cursor render in legaia-engine play-window when MenuState::ShopConfirm is active.

The other shop sub-screens reuse the same line metrics:

  • Quantity selector (FUN_801d5510) - same 14 px line height; shows “Have N [item]” + “How many will you buy?” + a quantity×price line at y+34 (0x22) from the panel top. The running total's digit-field width comes from the magnitude of the unit price, not of the total (4..7 columns off 99/999/9999), which is what keeps it right-aligned as the quantity climbs.
  • Sell-item detail panel (FUN_801d5ae8) - item name (record +4) at the content origin, description (+8) at y+0xE, price row at y+0x2B: label at x+0x24, currency glyph at x+0x54, and the sell price - exactly buy_price >> 1 - as a 5-digit field at x+0x64. A zero buy price replaces the row with “Cannot sell” at x+0x50. Below it the accessory passive's name (y+0x45) and description (y+0x55) print, each re-deriving the passive index through the same two-table chain rather than caching it: item record +0 == 1 reads equipment record +5, anything else item-effect record +3, and >= 0x40 is the no-passive sentinel. The body is gated on a positive staged id, but the 0x90 x 0x28 shade box at (x, y+0x45) draws either way. Both hosts draw this window for the sell list, in place of the buy-side info window - the two overlap and print the same name/description head, so drawing both would double that text rather than add a panel. One measured surprise: every equipment bonus record on the disc carries the 0x40 sentinel in +5, so the equipment arm of that two-table chain never fires on an unmodified disc and every passive line the window prints in practice comes from the item-effect arm.
  • Mode-select panel (FUN_801d4868) - the Buy / Sell / Quit selector; fully decoded below.

Mode-select panel (Buy / Sell / Quit)

The mode selector is menu-overlay window 0x2A in the window-descriptor table at 0x801E4738 (see field menu): content rect (x 42, y 46, w 80, h 38), renderer VA 0x801D4868. Like every window content renderer it receives the live window struct and reads its content origin from +0xa / +0xc (WX / WY); the 9-slice frame is caller-drawn.

FUN_801d4868 (see ghidra/scripts/funcs/overlay_shop_save_801d4868.txt) draws three rows through the shared string primitive func_0x80036888(str, 0, 0, x, y):

RowString (overlay rodata)XY
Buy0x801CEB94WX + 20 (0x14)WY
Sell0x801CEB9CWX + 20WY + 14 (0x0E)
Quit0x801CEBA4WX + 20WY + 28 (0x1C)

Same 20 px text indent and 14 px line height as the buy list; the strings sit at an 8-byte stride with a leading control byte. The CLUT-staging global _DAT_8007B454 (read only by the string primitive - see field menu) is set to 7 (normal white) on entry; before the Sell row the function scans the inventory id/count pair array at 0x80085958 (DAT_80084140 + 0x1818, slot bounds _DAT_8007B5EA .. _DAT_8007B5EC - the array pinned in cheats) and, when no slot has both a non-zero id and a non-zero count, clears the global to 0 so Sell renders dim when the bag is empty. The scan sits between the Buy draw and the Sell draw and nothing restores the global afterwards, so an empty bag greys Sell and Quit together - Buy is the only row that is always white. Ported as engine-core::shop::shop_root_command_rows.

After each row the cursor sprite func_0x8002b994(0, mode, WX, rowY) (the 16x16 bobbing menu cursor, drawn at the window origin X - the same “+0” cursor column as the buy list) is gated on the picker cursor word DAT_801E46BC:

  • low 12 bits - selected row index (0 Buy / 1 Sell / 2 Quit); the cursor draws only on the matching row;
  • bit 0x1000 - blink phase; the sprite mode argument is the inverted bit (1 = animated frame, 0 = static);
  • bit 0x2000 - parked/unfocused presentation: the row-index gate is bypassed and every row gets a mode-4/0 draw keyed to the blink bit;
  • bit 0x4000 - cursor suppressed entirely.

Input lives in the picker dispatcher FUN_801dafd4 (its sub-state var is DAT_801E46AC): the cursor clamp is a literal li a1,0x3 at 0x801DB098 (rows 0..2); on confirm, row 2 runs the Quit action at 0x801DB0D0 (sound cue + session exit) and rows 0/1 fall through to the buy/sell check at 0x801DB0E8. The shop's window choreography is actor-VM widget scripts interpreted by FUN_801d6628 over the window table: the open script DAT_801E4E38 slides in windows 0x21 (vendor name) / 0x2A (this picker) / 0x20 (gold) / 0x28 / 0x22, and the Sell transition's close script DAT_801E4E54 slides away 0x28 / 0x2A / 0x22 while keeping the gold + vendor-name plates. (These instruction/descriptor words are byte-verified by the randomizer's seru-trading vendor, which patches exactly these seams - cursor clamp, a detour after the Quit text draw, and the window record's height field - to grow the panel to four rows; see crates/patcher/src/seru_overlay/consts.rs and randomizer.)

Open items

  • On-disc item-effect amounts. The per-item effect descriptor table (DAT_800752C0: effect class + tier + all-party / field / battle usability) is decoded and drives the engine: the field/battle usability flags gate the item menu, and the 0x20 all-party flag fans a party-wide restorative (Healing Bloom / Fruit) across every living ally in one use. What is still unpinned is the literal restore amount - the (class, tier) -> 200/800/... mapping is a switch in the overlay-resident apply handler (it is not _DAT_8006F198, the SFX descriptor table), so the engine keeps the curated walkthrough amounts for those.
  • Quantity cap. Retail caps held count at 98 before dimming; the current port allows unlimited stacking.

Relationship to legaia_save

Gold is stored at _DAT_8008459C in retail RAM and in World::money in the engine. Inventory is a HashMap<u8, u8> (item_id → count) in World::inventory. SaveFile / SaveExt round-trips both through the LGSF v2 format.

Full reference

Complete flow tables and provenance at docs/subsystems/shop.md. Source: crates/engine-core/src/shop.rs.

See also