diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs index 83d6776..614ac97 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs @@ -103,6 +103,19 @@ pub const CHEAPEST_PURCHASE: u32 = { least }; +/// Rows of a mart's priced buy list the shared cursor can sit on. +/// +/// Three, measured rather than derived (`infra/docs/macros-traps.md` row 55): on the live list in +/// the Pewter mart the cursor walked `0, 1, 2` under DOWN and then stopped moving while the +/// *window scrolled* under it, so the fourth drawn row is a look-ahead the cursor never occupies. +/// The absolute position of the item the cursor is on is that index plus `wListScrollOffset`, +/// which is not in the reviewed address list and cannot be pinned without the disassembly +/// `gen_symbols.py` reads -- so an item past the third of a counter's stock has no index this seam +/// can name, and a purchase aimed at one is a button whose script gives up before it presses +/// anything. [`super::palette::stock_index`] is where the rule is applied, once, for both the pad +/// and the plan. +pub const MART_CURSOR_ROWS: usize = 3; + /// Cursor indices of the battle menu, as `state::BattleMenu::Main` documents them: "0 FIGHT, /// 1 PKMN, 2 ITEM, 3 RUN". pub mod battle_entry { diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/executor.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/executor.rs index f7a6827..aa00350 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/executor.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/executor.rs @@ -34,7 +34,7 @@ use super::palette::{ facing_target, listing, move_index, move_list, nurse_prompt, objective_goals, party_rested, potion_slot, precondition, - shop_screen, throw_slot, untalked_objects, untalked_people, ways, + shop_screen, stock_index, throw_slot, untalked_objects, untalked_people, ways, }; use super::path::{self, Route, Way}; use super::state::{Facing, Scene, ShopScreen}; @@ -2098,10 +2098,11 @@ fn approach(state: &mut dyn MacroState, targets: &[(Tile, TalkTarget)]) -> Vec Vec Option> { - let index = state.shop_stock().iter().position(|stocked| *stocked == want)?; - let index = u8::try_from(index).ok()?; + let index = stock_index(state, want)?; let mut steps = Vec::new(); if shop_screen(state)? == ShopScreen::BuySellQuit { // BUY is the counter menu's first entry. diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/palette.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/palette.rs index 6778b70..227504f 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/palette.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/palette.rs @@ -9,7 +9,7 @@ //! B 5. use super::cartridge::{ - CHEAPEST_PURCHASE, FACINGS, opposite, + CHEAPEST_PURCHASE, FACINGS, MART_CURSOR_ROWS, opposite, ExitId, ListKind, Listing, MacroState, Objective, PARTY_CAPACITY, PURCHASES, TalkTarget, TargetKey, Tile, item, outdoors, }; @@ -908,19 +908,52 @@ pub fn objective_place(state: &mut dyn MacroState) -> Option { errand_place(state).or_else(|| state.objective()) } -/// Whether the open mart stocks `item` and the money on hand covers it. +/// Whether the open mart stocks `item` within reach of its cursor, and the money on hand covers +/// it. /// -/// Both halves, and the stock half is the one that surprises: Viridian's counter sells POKE BALL, -/// ANTIDOTE, PARLYZ HEAL and BURN HEAL and **no Potion at all** (`data/items/marts.asm` at the -/// pinned commit), so `BUY POTION` is correctly off the pad in the first mart the fly ever walks -/// into. That is the precondition working, not a gap. +/// Three halves now, and each one has been the answer at a different counter. +/// +/// - **Stock.** Viridian's counter sells POKE BALL, ANTIDOTE, PARLYZ HEAL and BURN HEAL and **no +/// Potion at all** (`data/items/marts.asm` at the pinned commit), so `BUY POTION` is correctly +/// off the pad in the first mart the fly ever walks into. That is the precondition working, not +/// a gap. +/// - **Money**, which is section 13's "money allows at least one". +/// - **Reach**, which is row 55. A purchase is navigated by *reading the cursor*, and a mart's +/// buy list scrolls: the cursor sits on rows `0, 1, 2` and the window moves under it, so the +/// absolute position of the item the cursor is on is the index plus a scroll offset this seam +/// cannot read ([`super::cartridge::MART_CURSOR_ROWS`]). Pewter's counter carries seven items and +/// ANTIDOTE is its fourth, so `BUY ANTIDOTE` there is a button whose script gives up before it +/// presses anything -- 747 starts and 747 `blocked` in ten brain minutes, none of them pressing +/// a button and none of them changing a byte. A macro that cannot run is not on the pad, so the +/// first three of a counter's stock are what the four purchases are bound on, and the rest wait +/// for `wListScrollOffset` to be a pinned address. +/// +/// The clerk's own text box is the fourth thing this refuses, and it refuses it through the seam +/// rather than here: [`ShopScreen::Talking`] is not one of the two screens a purchase can start +/// from (`docs/design/macros-wram.md` section 7, row 55). fn affordable(state: &mut dyn MacroState, item: u8, cost: u32) -> bool { - // Sell cursors index the bag, not the counter's stock - state + // Sell cursors index the bag, not the counter's stock; and while the clerk is talking there is + // no list up at all. + if !state .shop() .is_some_and(|shop| matches!(shop.screen, ShopScreen::BuySellQuit | ShopScreen::Buying)) - && state.money() >= cost - && state.shop_stock().contains(&item) + { + return false; + } + state.money() >= cost && stock_index(state, item).is_some() +} + +/// The cursor index of `item` in the open counter's stock, when the cursor can reach it. +/// +/// One accessor rather than the same `position` in the precondition and in the script, so the +/// button and the plan cannot disagree about which items a mart can sell the fly (row 6's rule: +/// the cheap question and the real one have to be the same question when they are the same fact). +pub fn stock_index(state: &mut dyn MacroState, item: u8) -> Option { + let index = state.shop_stock().iter().position(|stocked| *stocked == item)?; + if index >= MART_CURSOR_ROWS { + return None; + } + u8::try_from(index).ok() } /// What `GO SHOP` or `GO HEAL` walks to, or empty when there is nothing to walk to. @@ -1932,6 +1965,15 @@ pub fn listing(state: &mut dyn MacroState) -> Option { }); } if let Some(shop) = state.shop() { + // **The clerk talking is not a list.** Row 55: `wListMenuID` keeps `PRICEDITEMLISTMENU` + // across the mart's own text, and the cursor bytes left behind belong to a two-option box + // -- so a cursor step that read this would take its length and its direction from a menu + // that is not on screen, which is section 12.11's rule in the one scene it had not + // reached. Reporting nothing makes the step *wait*, pressing nothing, exactly as it waits + // for a list that has not drawn yet. + if shop.screen == ShopScreen::Talking { + return None; + } return Some(Listing { kind: ListKind::Shop, current: shop.cursor.current, diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs index 53e9359..ea01aaa 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs @@ -1308,14 +1308,18 @@ fn the_four_purchases_are_bound_by_the_counters_own_stock() { "Viridian stocks no Potion and no Repel" ); - // Cerulean's, which stocks both of the other two. + // Cerulean's, which stocks both of the other two -- and whose fourth entry is out of the + // cursor's reach, exactly as Pewter's Antidote is (row 55): the list scrolls, so only the + // first three of a counter's stock have an index this seam can aim at. world.stock = vec![item::POKE_BALL, item::POTION, item::REPEL, item::ANTIDOTE]; assert_eq!( names(&Palette::for_scene(Scene::Shop, &mut world)), - ["CONFIRM", "BUY POTION", "BUY BALL", "BUY ANTIDOTE", "BUY REPEL", "LEAVE"] + ["CONFIRM", "BUY POTION", "BUY BALL", "BUY REPEL", "LEAVE"] ); - // Money is the other half, per item: 150 buys an Antidote and nothing else. + // Money is the other half, per item: 150 buys an Antidote and nothing else, at a counter + // whose Antidote the cursor can reach. + world.stock = vec![item::POKE_BALL, item::ANTIDOTE, 15, 12]; world.money = 150; assert_eq!( names(&Palette::for_scene(Scene::Shop, &mut world)), diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests/shop_purchase.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests/shop_purchase.rs index 0088c81..d18812d 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests/shop_purchase.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests/shop_purchase.rs @@ -41,3 +41,92 @@ fn a_purchase_dealt_before_entering_sell_is_refused_without_a_press() { assert!(world.pulses.is_empty()); assert!(machine.running().is_none()); } + +/// Row 55: a purchase the counter's cursor cannot reach is not on the pad. +/// +/// **What was live** (2026-09-22 19:02 UTC, the Pewter mart, ten brain minutes): `BUY ANTIDOTE +/// start` / `BUY ANTIDOTE blocked` every 0.8 s, 747 repeats of a sequence of one, nothing else +/// starting. Surveyed on the cartridge: the counter stocks seven items and ANTIDOTE is its +/// **fourth**, while the buy list's cursor walks rows `0, 1, 2` and then scrolls the window under +/// itself -- so the absolute index the script aimed at was above the list's own max and the step +/// returned `Blocked` on its first frame, with no button pressed and no byte changed. The offset +/// that would name the scrolled position is not a pinned address, so the fourth item and after +/// have no index this seam can aim at, and a macro that cannot run is not on the pad. +#[test] +fn a_purchase_past_the_cursors_reach_is_not_on_the_pad() { + let mut world = World::room(); + world.scene = Scene::Shop; + world.list = List::Shop(ShopScreen::Buying); + world.cursor_max = 2; + // Pewter's counter, in menu order, as `shop_stock` read it off the cartridge at the live + // checkpoint: POKE BALL, POTION, ESCAPE ROPE, ANTIDOTE, BURN HEAL, AWAKENING, PARLYZ HEAL. + // The four ids the palette has no constant for are the ones no button buys. + world.stock = vec![item::POKE_BALL, item::POTION, 29, item::ANTIDOTE, 12, 14, 15]; + world.money = 9_999; + assert_eq!( + names(&Palette::for_scene(Scene::Shop, &mut world)), + ["CONFIRM", "BUY POTION", "BUY BALL", "LEAVE"], + "the Antidote is Pewter's fourth item and the cursor cannot reach it" + ); + // The live wallet, where the Antidote was the only thing money allowed: the pad is the two + // presses that get out of the counter, and it is not empty. + world.money = 104; + assert_eq!( + names(&Palette::for_scene(Scene::Shop, &mut world)), + ["CONFIRM", "LEAVE"], + "nothing in reach is affordable, so nothing is dealt but the ways out" + ); + assert!(world.pulses.is_empty()); +} + +/// Row 55, the other half: the clerk talking is not a list, so no purchase starts on it. +/// +/// `wListMenuID` keeps `PRICEDITEMLISTMENU` across the mart's own text +/// (`docs/design/macros-wram.md` section 7), and the cursor bytes left behind belong to a +/// two-option box. A purchase dealt on such a frame navigates a menu that is not on screen, which +/// is section 12.11's rule in the one scene it had not reached -- so the seam names the screen and +/// the palette deals the presses that *do* advance it. +#[test] +fn the_clerks_own_text_box_deals_no_purchase_and_reports_no_list() { + let mut world = World::room(); + world.scene = Scene::Shop; + world.list = List::Shop(ShopScreen::Talking); + world.cursor_max = 1; + world.stock = vec![item::POKE_BALL, item::POTION, item::ANTIDOTE]; + world.money = 9_999; + + for kind in [ + MacroKind::BuyPotion, + MacroKind::BuyBall, + MacroKind::BuyAntidote, + MacroKind::BuyRepel, + ] { + assert!(!precondition(kind, &mut world), "{kind:?} has no list to navigate"); + } + assert_eq!(names(&Palette::for_scene(Scene::Shop, &mut world)), ["CONFIRM", "LEAVE"]); + assert!(listing(&mut world).is_none(), "nothing is accepting list input"); + assert!(world.pulses.is_empty()); +} + +/// And a purchase that was dealt before the counter changed under it presses nothing. +/// +/// The same shape as `a_purchase_dealt_before_entering_sell_is_refused_without_a_press`, for the +/// reach: the script asks [`stock_index`] again and refuses, rather than aiming a cursor step at +/// an index the list cannot hold and reporting `blocked` a frame later. +#[test] +fn a_purchase_out_of_reach_by_the_time_it_starts_is_refused_without_a_press() { + let mut world = World::room(); + world.scene = Scene::Shop; + world.list = List::Shop(ShopScreen::Buying); + world.cursor_max = 2; + world.stock = vec![item::ANTIDOTE, item::POKE_BALL]; + world.money = 9_999; + let (palette, slot) = pick(&mut world, MacroKind::BuyAntidote); + // The counter the fly is standing at turns out to be Pewter's, where the Antidote is fourth. + world.stock = vec![item::POKE_BALL, item::POTION, 29, item::ANTIDOTE]; + + let mut machine = MacroMachine::new(1); + assert!(machine.start(&palette, slot, &mut world).is_err()); + assert!(world.pulses.is_empty()); + assert!(machine.running().is_none()); +}