Shop
Buy / sell / quantity / confirm flow for town shops. Lives inside the menu overlay (same 129-function binary as the save screen, inn, and status screens). Clean-room session state: engine-core::shop::ShopSession. Buy-list render layout confirmed from FUN_801d5de0 (overlay_shop_save.bin).
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:
| Phase | State | Description |
|---|---|---|
| Buy list | ShopBuy | Shows available items + prices. Cursor selects an item. |
| Sell list | ShopSell | Shows player inventory. Cursor selects an item to sell. |
| Quantity | ShopQuantity | Numeric selector 1..9. Confirms how many to buy/sell. |
| Confirm | ShopConfirm | Yes / No prompt. Yes commits the transaction. |
| Exit | ShopExit | Clears 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/ShopSell → ShopQuantity → ShopConfirm → 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.
| Field | Type | Meaning |
|---|---|---|
inventory | ShopInventory | Items the shop offers for purchase |
pending_item_id | Option<u8> | Item selected at current sub-flow cursor |
pending_quantity | u8 | Quantity from ShopQuantity step |
pending_is_buying | bool | true = 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:
| Element | X offset (px) | Notes |
|---|---|---|
Cursor > | +0 | Selected row only |
| Item name | +20 (0x14) | func_0x80036888 |
| Price | +112 (0x70) | 6-digit field; func_0x80034b78 |
| Gold footer | +0 | Below 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 the0x20all-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.