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 aedded3..01d556e 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 @@ -286,6 +286,15 @@ pub enum TargetKey { Thing(TalkTarget), /// A tile of the map to stand on. Tile(Tile), + /// One answer to the YES/NO box at a tile: the key of the reopened-prompt exclusion + /// (`docs/design/macros.md` section 12.12). + /// + /// Not a walk's target -- nothing is aimed at it -- but the same ledger and the same window, + /// because it is the same fact: an answer that changed nothing is an answer not worth making + /// again from this tile for a while. Keyed by the tile rather than by the person, because what + /// the box belongs to is whatever the fly is standing in front of, and the box is the only + /// thing on screen while it is open. + Answer { at: Tile, yes: bool }, } /// Which list the shared cursor belongs to right now. @@ -456,6 +465,20 @@ pub trait MacroState: GameState { false } + /// Whether the box on screen is the two-option YES/NO prompt rather than a plain text box. + /// + /// `pokemon_red::state::yes_no_prompt`: the border `DisplayTwoOptionMenu` draws plus the + /// cursor it parks inside it, surveyed on the cartridge (`docs/design/macros.md` section + /// 12.12). It is what tells the *one* frame of the nurse's conversation that is a choice from + /// the forty-five that are text, and the pad is dealt differently for it -- on a choice, an A + /// press *is* `YES`, so `NEXT` is the same press under another name (12.10). + /// + /// The default is `false`: a state that cannot answer has no choice open, which leaves the + /// dialog pad exactly what it has always been. + fn yes_no_prompt(&mut self) -> bool { + false + } + /// Whether `target` is inside its blocked-target exclusion window on the map that is loaded. /// /// Written when `GO ITEM`, `GO NPC`, `GO OBJECTIVE`, `GO OUT`, `GO WARP`, `GO ROUTE` or diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/scene.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/scene.rs index d720867..e09347a 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/scene.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/scene.rs @@ -104,7 +104,7 @@ pub fn why_unknown(memory: &mut dyn MemoryReader) -> String { let box_corners = state::dialog_corners(memory); format!( "started={} map={:?} party={} battle={} type={} font={:#04x} textbox={:#04x} \ - list={:#04x} cursor=({},{},{},{},{:#04x}) joy={} sim={} flags5={:#04x} \ + list={:#04x} cursor=({},{},{},{},{:#04x}) prompt={} joy={} sim={} flags5={:#04x} \ flags6={:#04x} move={:#04x} \ corners=({:#04x},{:#04x},{:#04x},{:#04x})", state::started(memory), @@ -120,6 +120,7 @@ pub fn why_unknown(memory: &mut dyn MemoryReader) -> String { memory.read8(ram::wCurrentMenuItem), memory.read8(ram::wMaxMenuItem), memory.read8(ram::wMenuWatchedKeys), + state::yes_no_prompt(memory), memory.read8(ram::wJoyIgnore), memory.read8(ram::wSimulatedJoypadStatesIndex), memory.read8(ram::wStatusFlags5), 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 da6167e..f2913c3 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs @@ -75,6 +75,17 @@ pub mod poke { /// `constants/menu_constants.asm` party menu types. pub const BATTLE_PARTY_MENU: u8 = 0x02; + /// The two-option YES/NO box, as surveyed on the cartridge (`infra/docs/macros-traps.md`, + /// row 41): the border `DisplayTwoOptionMenu` draws, and where it parks the cursor. + /// + /// Values rather than a symbol because `wTwoOptionMenuID` is not in the reviewed address list + /// and the box's geometry is what is on screen. Read from the rung-10 Pokemon Center + /// checkpoint, one raw A pulse at a time: a box at (11, 6)-(19, 11) with the cursor at + /// row 8, column 12, one item below the first, watching A and B. + pub const YES_NO_BOX: (u16, u16, u16, u16) = (11, 6, 19, 11); + pub const YES_NO_CURSOR_Y: u8 = 8; + pub const YES_NO_CURSOR_X: u8 = 12; + /// `constants/ram_constants.asm`: `wMiscFlags` bit 3. pub const BIT_USING_GENERIC_PC: u8 = 1 << 3; /// `wFontLoaded` bit 0. @@ -534,6 +545,36 @@ pub fn text_box(memory: &mut dyn MemoryReader) -> TextBox { TextBox { open, waiting: open && border_drawn(memory, 0, 12, 19, 17) } } +/// Whether the two-option YES/NO box is the thing on screen: a *choice*, not a plain text box. +/// +/// `docs/design/macros-wram.md` says there is no "a choice is open" flag, and there is not -- so +/// this is the same construction [`text_box`] makes for `waiting`: a WRAM flag plus the figure the +/// game draws. `DisplayTwoOptionMenu` draws its own little box in the top right and parks the +/// shared cursor inside it, and **both halves are needed**: the cursor bytes are not cleared when +/// the box closes, so at the rung-10 checkpoint every one of the nurse's forty-six text frames +/// reads `wTopMenuItemY` 8, `wTopMenuItemX` 12, `wMaxMenuItem` 1 and `wMenuWatchedKeys` `$03` +/// while the box itself is drawn on exactly one of them (`infra/docs/macros-traps.md`, row 41). +/// +/// **What it does not claim.** Red places a two-option menu where the script that asks for it +/// says, so a prompt drawn somewhere else reads `false` here and its dialog keeps the pad it has +/// always had. This is the box the nurse's "heal your POKeMON?" is drawn in, surveyed; it is not a +/// general answer to "is a choice open", and nothing in the palette treats it as one. +pub fn yes_no_prompt(memory: &mut dyn MemoryReader) -> bool { + if read(memory, ram::wFontLoaded) & poke::BIT_FONT_LOADED == 0 { + return false; + } + let cursor = cursor(memory); + if cursor.top_y != poke::YES_NO_CURSOR_Y + || cursor.top_x != poke::YES_NO_CURSOR_X + || cursor.max != 1 + || cursor.watched_keys != poke::pad::A | poke::pad::B + { + return false; + } + let (left, top, right, bottom) = poke::YES_NO_BOX; + border_drawn(memory, left, top, right, bottom) +} + /// The four screen tiles the dialogue box's `waiting` test reads, in the order `box_drawn` reads /// them: top-left, top-right, bottom-left, bottom-right of a box at (0, 12)-(19, 17). /// @@ -1269,6 +1310,10 @@ impl MacroState for PokeState<'_> { !controllable(self.memory) } + fn yes_no_prompt(&mut self) -> bool { + yes_no_prompt(self.memory) + } + /// The whole loaded map's walkability, from the cache when it is for this map /// (`docs/design/macros.md` section 15). /// diff --git a/services/flysim/crates/flysim/examples/scene_probe.rs b/services/flysim/crates/flysim/examples/scene_probe.rs index 09253ee..37c447b 100644 --- a/services/flysim/crates/flysim/examples/scene_probe.rs +++ b/services/flysim/crates/flysim/examples/scene_probe.rs @@ -329,6 +329,131 @@ fn battle_dump(gb: &mut Emulator, adapter: &PokemonRedReward, pad: &[String], la println!("- money: {}", state.money()); } +/// One line of the dialogue box, decoded through `constants/charmap.asm`. +/// +/// The survey below is about *which box* is open, and the only thing on screen that says so is the +/// text in it: `wTextBoxID` is `$01` for every ordinary `TX_FAR` box the nurse draws, so the id +/// cannot tell the welcome from the prompt from the closing line. The tiles can. +fn box_line(gb: &mut Emulator, y: u16) -> String { + (1..19u16) + .map(|x| match gb.read8(ram::wTileMap + y * 20 + x) { + 0x7f => ' ', + byte @ 0x80..=0x99 => (b'A' + (byte - 0x80)) as char, + byte @ 0xa0..=0xb9 => (b'a' + (byte - 0xa0)) as char, + 0xba => 'e', + 0xe3 => '-', + 0xe6 => '?', + 0xe7 => '!', + 0xe8 => '.', + 0xef => 'M', + 0xee => '\u{25bc}', + byte @ 0xf6..=0xff => (b'0' + (byte - 0xf6)) as char, + _ => '.', + }) + .collect::() + .trim_end() + .to_string() +} + +/// The top-right corner of the screen, where a two-option menu's own little box is drawn: the +/// tiles at (11..20, 6..11) reduced to which of them hold a text-box frame tile. +fn corner_box(gb: &mut Emulator) -> String { + let mut out = String::new(); + for y in 6..12u16 { + for x in 11..20u16 { + let byte = gb.read8(ram::wTileMap + y * 20 + x); + out.push(match byte { + 0x79 | 0x7b | 0x7d | 0x7e => '+', + 0x7a | 0x7c => '|', + 0x7f => '_', + _ => '.', + }); + } + out.push('/'); + } + out +} + +/// Everything that tells one of the nurse's boxes from another, on one line. +fn nurse_frame(gb: &mut Emulator) -> String { + let text = state::text_box(gb); + format!( + "{:?} open={} waiting={} cursor=({},{},{},{},{:#04x}) yesno={} | {} | {}", + scene::detect(gb), + text.open, + text.waiting, + gb.read8(ram::wTopMenuItemY), + gb.read8(ram::wTopMenuItemX), + gb.read8(ram::wCurrentMenuItem), + gb.read8(ram::wMaxMenuItem), + gb.read8(ram::wMenuWatchedKeys), + corner_box(gb), + box_line(gb, 14), + box_line(gb, 16), + ) +} + +/// The Pokémon Center nurse's whole conversation, box by box, with raw presses (row 41). +/// +/// The rung-10 loop of 2026-09-22 was `YES` 2,142 macro starts on one tile of map `0x3a`, so the +/// question the fix turns on is **which box each A press answers**. `wTextBoxID` cannot say -- +/// every box the nurse draws is `$01` -- and `docs/design/macros-wram.md` says outright that there +/// is no "a choice is open" flag, so the reading has to be surveyed: leave the box with B, then +/// pulse A and print every state the conversation passes through, with the two-option menu's own +/// geometry beside it. The party is printed first because `HEAL`'s precondition is the party and +/// the loop's premise is that it is already full. +fn nurse_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) { + use flybrain_gb::pokemon_red::macros::cartridge::MacroState; + + println!("\n## The party at the checkpoint\n"); + { + let ledger = AdapterLedger(adapter); + let mut poke = flybrain_gb::pokemon_red::state::PokeState::with_ledger(gb, &ledger); + let state: &mut dyn MacroState = &mut poke; + for mon in &state.party().mons { + println!( + "- slot {} species {:#04x} level {} hp {}/{} status {:?}", + mon.slot, mon.species, mon.level, mon.hp, mon.max_hp, mon.status + ); + } + println!( + "- `party_needs_rest` = {}, `party_rested` = {}", + flybrain_gb::pokemon_red::macros::palette::party_needs_rest(state), + flybrain_gb::pokemon_red::macros::palette::party_rested(state), + ); + } + + let pulse = |gb: &mut Emulator, adapter: &mut PokemonRedReward, mask: u8, ms: &mut f64| { + for phase in 0..16 { + gb.set_buttons(if phase < 8 { mask } else { 0 }); + gb.run_frame().expect("a frame should complete"); + *ms += MS_PER_FRAME; + adapter.sample(gb, *ms); + } + }; + + println!("\n## The nurse's conversation, one raw A pulse at a time\n"); + println!("- at the checkpoint: {}", nurse_frame(gb)); + for _ in 0..20 { + if scene::detect(gb) == scene::Scene::Overworld { + break; + } + pulse(gb, adapter, flybrain_gb::buttons::B, ms); + } + println!("- after B until the box closes: {}", nurse_frame(gb)); + println!("\n```"); + let mut last = String::new(); + for index in 0..env_usize("FLY_PROBE_PULSES", 120) { + pulse(gb, adapter, flybrain_gb::buttons::A, ms); + let now = nurse_frame(gb); + if now != last { + println!("A#{index:<3} {now}"); + last = now; + } + } + println!("```"); +} + fn main() { let Some(path) = std::env::var_os("FLY_ROM") else { println!("FLY_ROM is not set, so there is nothing to probe."); @@ -368,6 +493,12 @@ fn main() { dump(&mut gb, "At the checkpoint"); pad(&mut gb, &adapter, "The pad at the checkpoint"); + let catch_nurse = std::env::var("FLY_PROBE_CATCH").is_ok_and(|value| value == "nurse"); + if catch_nurse { + nurse_survey(&mut gb, &mut adapter, &mut ms); + return; + } + let budget = env_usize("FLY_PROBE_FRAMES", 200_000); let stuck_after = env_usize("FLY_PROBE_STUCK", 600); let mut next_burst = ms;