macros: read the nurse's YES/NO box, and survey her conversation

Row 41 is the Pokemon Center nurse: 2,142 YES starts on one tile of map
0x3a with a party at 70/70. The survey that names it is a new probe mode
(FLY_PROBE_CATCH=nurse) that leaves the box with B and then pulses A,
printing every state the conversation passes through with the two-option
menu's own geometry beside it. It is a ring of forty-six presses and the
box is a choice on exactly one of them.

So the reading: wFontLoaded plus the border DisplayTwoOptionMenu draws,
plus the cursor it parks inside it. Both halves are needed -- the cursor
bytes survive the box closing, so all forty-six frames carry the
geometry. It claims that box and not "a choice is open" in general.

TargetKey gains Answer { at, yes } for the reopened-prompt exclusion.
This commit is contained in:
acamilo 2026-09-22 10:38:52 +00:00
parent 96677119ad
commit 86f0e7018d
4 changed files with 201 additions and 1 deletions

View file

@ -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

View file

@ -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),

View file

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

View file

@ -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::<String>()
.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;