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 and inn use). 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.

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).

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 >+0Selected row only
Item name+20 (0x14)func_0x80036888
Price+112 (0x70)6-digit field; func_0x80034b78
Gold footer+0Below last row; 8-digit field via func_0x80034b78

Row colour (retail _DAT_8007b454 palette index): white = affordable; dim = unaffordable (price > gold) or held count > 98; blue = equipped-comparison flag set. engine-render::shop_draws_for implements these constants. 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.
  • Sell-item detail panel (FUN_801d5ae8) - shows item name, type description, and sell price (buy price ÷ 2) at y+43 (0x2b), with an icon at x+84.

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, asset type 0x03), 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 (earlier an "every id sellable" rule dropped every shop that carried the padding). Validated against the Rim Elm Variety Store's 10 known items and the disc-wide partition guard.

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 script stays suspended the way the name-entry overlay suspends it). 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.

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.
  • Mode-select panel. The Buy / Sell / Quit selector (FUN_801d4868) uses x+20 for text and the same 14 px line height as the item list.

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 v1 format.

Full reference

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

See also