From 4a1df7c3beecd213917522ec20073031dc26dd2e Mon Sep 17 00:00:00 2001 From: acamilo Date: Tue, 22 Sep 2026 19:56:38 +0000 Subject: [PATCH] macros: the mart's list byte says the counter is open, not which screen is up `wListMenuID` keeps PRICEDITEMLISTMENU for a whole mart visit: the clerk's text is printed from inside the mart's own routine and never goes through the display that clears it. So every frame of the Pewter mart read "the priced buy list", including the "Here you are! Thank you!" box the stream was looking at, whose leftover cursor bytes belong to a two-option box. Which screen is up is now read from the figure the game draws, the same construction the dialogue box's `waiting` test and the YES/NO prompt already make: a full-width box waiting for a press is the clerk (`ShopScreen::Talking`), the item window drawn is the buy list, and the item window blank is the counter menu. --- .../src/pokemon_red/macros/state.rs | 10 +++ .../flybrain-gb/src/pokemon_red/state.rs | 61 ++++++++++++++++++- .../src/pokemon_red/state/tests.rs | 43 ++++++++++++- 3 files changed, 112 insertions(+), 2 deletions(-) diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/state.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/state.rs index 63457b8..867a1b8 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/state.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/state.rs @@ -309,6 +309,16 @@ pub enum ShopScreen { Buying, /// The bag list, for selling. Selling, + /// The clerk is talking: the counter is open, and the thing on screen is a dialogue box + /// waiting for a press rather than a list waiting for a cursor. + /// + /// Row 55 of `infra/docs/macros-traps.md`. `wListMenuID` is not cleared while the mart prints + /// its own text -- "Here you are! Thank you!" goes through `PrintText` inside + /// `DisplayPokemartDialogue_` and not through `DisplayTextIDInit` -- so the byte that says + /// "the priced buy list" outlives the list by the whole of the clerk's conversation, while the + /// cursor bytes hold a two-option box's leftovers. A purchase started on one of those frames + /// navigates a list that is not there. + Talking, } /// A mart, when one is open. diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs index 0bd3e43..9c4705d 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs @@ -119,6 +119,21 @@ pub mod poke { /// One count byte, then the ids, then `$ff`, so at most fourteen items can be both counted /// and terminated inside the buffer. The real marts carry four to nine. pub const MART_LIST_BYTES: u8 = 16; + /// Where the mart's priced item window writes its first item name, and the letter range a + /// name starts with -- the figure [`super::mart_item_window_drawn`] reads. + /// + /// Screen coordinates rather than a symbol, for the reason [`YES_NO_BOX`] gives: what tells + /// the buy list from the counter menu is what is drawn, and the bytes that would name it are + /// not rewritten between the two. Surveyed in the Pewter mart from the live checkpoint + /// (`infra/docs/macros-traps.md` row 55): the counter menu left this cell at `HORIZONTAL`'s + /// neighbour `$7f`, and every frame the list was drawn held `P` of `POKE BALL` there, with the + /// shared cursor one column to its left. + pub const MART_NAME_ROW: u16 = 4; + pub const MART_NAME_COLUMN: u16 = 6; + /// Red's charmap: `$80`-`$99` are `A`-`Z`. + pub const CHAR_UPPER_A: u8 = 0x80; + pub const CHAR_UPPER_Z: u8 = 0x99; + /// `data/tilesets/tileset_headers.asm`: three counter tile ids per tileset, `-1` for none. pub const COUNTER_TILES: u16 = 3; /// The `-1` a tileset with fewer than three counter tiles pads its header with. @@ -648,6 +663,23 @@ pub fn submenu(memory: &mut dyn MemoryReader) -> bool { /// is the only user of that template in the game; the buy list is `PRICEDITEMLISTMENU` and the /// sell list is the bag's own `ITEMLISTMENU`, which is why selling is only recognised while the /// mart's choice is still the last template drawn. +/// +/// **`wListMenuID` says the counter is open, not which of its screens is up** (row 55 of +/// `infra/docs/macros-traps.md`, surveyed in the Pewter mart). The doc's claim that the byte is +/// "zeroed by `DisplayTextIDInit` at the start of every text display, so a stale value cannot +/// outlive one" holds for text the *overworld* displays and not for the mart's own: the clerk's +/// "Here you are! Thank you!" is printed from inside `DisplayPokemartDialogue_`, so `$cf94` keeps +/// `PRICEDITEMLISTMENU` across the whole visit. Measured on the cartridge from the live +/// checkpoint: every frame of a mart visit read `Buying`, the counter menu and the clerk's text +/// boxes included, and `wTextBoxID` on the counter menu reads `MONEY_BOX` rather than +/// `BUY_SELL_QUIT_MENU` because the money box is the last template drawn. +/// +/// So which screen is up is read from the figure the game draws, the same construction +/// [`text_box`]'s `waiting` and [`yes_no_prompt`] already make: +/// +/// - the full-width dialogue box drawn and waiting is the clerk, [`ShopScreen::Talking`]; +/// - otherwise the item window drawn is the buy list and the item window blank is the counter +/// menu ([`mart_item_window_drawn`]). pub fn shop(memory: &mut dyn MemoryReader) -> Option { if read(memory, ram::wFontLoaded) & poke::BIT_FONT_LOADED == 0 { return None; @@ -655,7 +687,15 @@ pub fn shop(memory: &mut dyn MemoryReader) -> Option { let cursor = cursor(memory); let list = read(memory, ram::wListMenuID); if list == poke::PRICED_ITEM_LIST_MENU { - return Some(Shop { screen: ShopScreen::Buying, cursor }); + if text_box(memory).waiting { + return Some(Shop { screen: ShopScreen::Talking, cursor }); + } + let screen = if mart_item_window_drawn(memory) { + ShopScreen::Buying + } else { + ShopScreen::BuySellQuit + }; + return Some(Shop { screen, cursor }); } if read(memory, ram::wTextBoxID) != poke::BUY_SELL_QUIT_MENU { return None; @@ -665,6 +705,25 @@ pub fn shop(memory: &mut dyn MemoryReader) -> Option { Some(Shop { screen, cursor }) } +/// Whether the mart's priced item window is the thing drawn over the counter menu. +/// +/// `DisplayListMenuID` writes the item names down a fixed column of the window +/// ([`poke::MART_NAME_COLUMN`], from [`poke::MART_NAME_ROW`]) with the shared cursor in the column +/// to their left, and leaves that cell blank while only the BUY / SELL / QUIT box is up. Every +/// item a mart sells has a name that starts with a letter, so the test is "is there a letter +/// there": measured on the cartridge, the cell held `$7f` on the counter menu and the first +/// letter of the counter's first item on every frame the list was drawn. +/// +/// A figure test rather than a byte, for the reason [`shop`] gives: the bytes that would name the +/// screen are not rewritten between the two, so they cannot tell them apart. +fn mart_item_window_drawn(memory: &mut dyn MemoryReader) -> bool { + let tile = read( + memory, + ram::wTileMap + poke::MART_NAME_ROW * poke::SCREEN_WIDTH + poke::MART_NAME_COLUMN, + ); + (poke::CHAR_UPPER_A..=poke::CHAR_UPPER_Z).contains(&tile) +} + /// The PC, when one is open. `ActivatePC` sets `wMiscFlags`' generic-PC bit and `LogOff` clears /// it, so it covers Bill's PC, the player's PC and Oak's alike. pub fn pc(memory: &mut dyn MemoryReader) -> Option { diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs index 463b488..cabcf56 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs @@ -371,7 +371,9 @@ fn a_mart_reports_which_of_its_screens_is_up() { wram.set(ram::wListMenuID, poke::ITEM_LIST_MENU); assert_eq!(shop(&mut wram).map(|shop| shop.screen), Some(ShopScreen::Selling)); - wram.set(ram::wListMenuID, poke::PRICED_ITEM_LIST_MENU); + // The priced list byte with the item window drawn is the buy list. + wram.set(ram::wListMenuID, poke::PRICED_ITEM_LIST_MENU) + .screen_tile(poke::MART_NAME_COLUMN, poke::MART_NAME_ROW, poke::CHAR_UPPER_A); assert_eq!(shop(&mut wram).map(|shop| shop.screen), Some(ShopScreen::Buying)); // The bag list on its own is the start menu's, not a mart's. @@ -381,6 +383,45 @@ fn a_mart_reports_which_of_its_screens_is_up() { assert!(shop(&mut wram).is_none()); } +/// Row 55: the byte that says "the priced buy list" outlives the list, so which screen is up is +/// read from what is drawn. +/// +/// Surveyed in the Pewter mart from the live checkpoint: `wListMenuID` held +/// `PRICEDITEMLISTMENU` on **every frame of the visit** -- the counter menu and the clerk's own +/// text boxes included -- because the mart prints its text from inside +/// `DisplayPokemartDialogue_` rather than through `DisplayTextIDInit`. The two things that do +/// change are the figure on screen: the full-width dialogue box for the clerk, and the item +/// window for the list. +#[test] +fn the_clerks_text_box_is_not_the_marts_buy_list() { + // The frame the stream looped on: the counter open, the priced-list byte stale, and the + // clerk's "Here you are! Thank you!" waiting for a press. + let mut wram = Wram::overworld(); + wram.set(ram::wListMenuID, poke::PRICED_ITEM_LIST_MENU) + .screen_tile(poke::MART_NAME_COLUMN, poke::MART_NAME_ROW, poke::CHAR_UPPER_A) + .dialogue_box(); + assert_eq!( + shop(&mut wram).map(|shop| shop.screen), + Some(ShopScreen::Talking), + "a dialogue box waiting for a press is the clerk, whatever the list byte says" + ); + + // The same counter with the box gone and the item window drawn: the buy list. + let mut wram = Wram::overworld(); + wram.set(ram::wFontLoaded, poke::BIT_FONT_LOADED) + .set(ram::wListMenuID, poke::PRICED_ITEM_LIST_MENU) + .screen_tile(poke::MART_NAME_COLUMN, poke::MART_NAME_ROW, poke::CHAR_UPPER_A); + assert_eq!(shop(&mut wram).map(|shop| shop.screen), Some(ShopScreen::Buying)); + + // And with the item window blank: the BUY / SELL / QUIT menu underneath it. + wram.screen_tile(poke::MART_NAME_COLUMN, poke::MART_NAME_ROW, poke::frame::HORIZONTAL); + assert_eq!( + shop(&mut wram).map(|shop| shop.screen), + Some(ShopScreen::BuySellQuit), + "no item name in the window means the list is not the thing on screen" + ); +} + #[test] fn a_pc_is_the_generic_pc_bit() { let mut wram = Wram::overworld();