a two-option box is the one the cartridge drew, not the one row 41 pinned

Row 41 read the border at (11, 6)-(19, 11) because that is where the Pokemon
Center's script puts it, and named the limit in its own residual: "Red places a
two-option menu where the script asking for it says, so a prompt drawn elsewhere
reads false and keeps the pad it had". The Pewter Gym guide draws the same menu
at (14, 7)-(19, 11) with the cursor at column 15. Surveyed over 260 presses from
the live checkpoint: the box is drawn on 10 frames, `yes_no_prompt` answered
false on all 260, and `wTextBoxID` read `TWO_OPTION_MENU` on exactly the 10.

So the pad was `NEXT, YES, NO` on a box that was a choice -- two channels for
one A press, which is 12.10's forbidden pair -- and the reopened-prompt
exclusion never armed, because it only judges an answer to a prompt this crate
can read.

The screen half is now the border drawn **around the cursor the game parked in
it**. One fact about `DisplayTwoOptionMenu` rather than about any script: the
cursor goes in the box's first interior column, so the left edge is one column
to its left, in both boxes surveyed. The top is not fixed -- the nurse's box
begins two rows above the first item and the guide's one -- so the top is found
and the figure is then read whole, as `waiting` and the move list are.
This commit is contained in:
flybrain 2026-09-23 00:37:44 +00:00
parent a6a8f797ff
commit 5f9237efcd
3 changed files with 173 additions and 16 deletions

View file

@ -238,6 +238,29 @@ impl Wram {
self.set(ram::wFontLoaded, poke::BIT_FONT_LOADED).draw_box(0, 12, 19, 17)
}
/// The two-option YES/NO box where a Pokémon Center's script draws it (row 41): the box at
/// (11, 6)-(19, 11) over the dialogue box, with the cursor parked in its first interior column.
pub fn yes_no_prompt(&mut self) -> &mut Self {
let (left, top, right, bottom) = poke::YES_NO_BOX;
self.dialogue_box().draw_box(left, top, right, bottom).yes_no_cursor(poke::YES_NO_CURSOR_X)
}
/// The same menu where the **Pewter Gym guide's** script draws it (row 56): (14, 7)-(19, 11),
/// three columns over and one row shorter, cursor at column 15. This is the box the pinned
/// reading could not see, and the reason every frame of his conversation read as plain text.
pub fn gym_yes_no_prompt(&mut self) -> &mut Self {
let (left, top, right, bottom) = poke::GYM_GUIDE_YES_NO_BOX;
self.dialogue_box()
.draw_box(left, top, right, bottom)
.yes_no_cursor(poke::GYM_GUIDE_YES_NO_CURSOR_X)
}
/// The cursor bytes `DisplayTwoOptionMenu` parks and **nothing clears**, with no box drawn:
/// what every other frame of the conversation reads back (rows 41 and 56).
pub fn yes_no_cursor(&mut self, column: u8) -> &mut Self {
self.cursor(poke::YES_NO_CURSOR_Y, column, 0, 1, poke::pad::A | poke::pad::B)
}
/// The start menu, Pokédex entry included.
pub fn start_menu(&mut self) -> &mut Self {
self.set(ram::wFontLoaded, poke::BIT_FONT_LOADED)

View file

@ -87,6 +87,24 @@ pub mod poke {
pub const YES_NO_CURSOR_Y: u8 = 8;
pub const YES_NO_CURSOR_X: u8 = 12;
/// The Pewter Gym guide's two-option box, surveyed the same way (row 56): a *second* place the
/// same routine draws the same menu, which is what took the pinned rectangle above off its
/// pedestal.
pub const GYM_GUIDE_YES_NO_BOX: (u16, u16, u16, u16) = (14, 7, 19, 11);
pub const GYM_GUIDE_YES_NO_CURSOR_X: u8 = 15;
/// How far above the first item the two-option box's top edge is looked for (row 56).
///
/// `DisplayTwoOptionMenu` puts the cursor in the box's first interior *column*, so the left
/// edge is one column left of `wTopMenuItemX` in both boxes surveyed. The *top* is not fixed:
/// the nurse's is two rows above the first item and the gym guide's is one, because one menu
/// carries a caption line and the other does not. So the top is found rather than computed,
/// looking up at most this many rows for the border's own corner.
pub const TWO_OPTION_CAPTION_ROWS: u16 = 3;
/// And how far below the first item the bottom edge is looked for: two options and the border.
/// Both surveyed boxes end three rows below the first item.
pub const TWO_OPTION_BOX_ROWS: u16 = 4;
/// The move list's own box, and the junction tile in its top edge
/// (`infra/docs/macros-traps.md`, row 50).
///
@ -594,30 +612,71 @@ pub fn text_box(memory: &mut dyn MemoryReader) -> TextBox {
///
/// `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).
/// game draws. **Both halves are needed**: the cursor bytes are not cleared when the box closes,
/// so at the rung-10 Pokemon Center 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.
/// **The figure is found rather than pinned, since row 56.** Row 41 read one rectangle,
/// (11, 6)-(19, 11), because that is where the centre's script puts it, and named the limit in its
/// own residual: "Red places a two-option menu where the script asking for it says, so a prompt
/// drawn elsewhere reads `false` and keeps the pad it had". Row 56 is that residual, measured. The
/// Pewter Gym guide's "Let me take you to the top!" draws the same menu at
/// **(14, 7)-(19, 11)** with the cursor at column 15, so this read `false` on every frame of his
/// conversation: the pad was `NEXT, YES, NO` on a box that was a choice -- 12.10's forbidden pair,
/// because an A press at a two-option menu *is* `YES` -- and the reopened-prompt exclusion never
/// armed, because it only judges an answer to a prompt this crate can read. Surveyed over 260
/// presses (`examples/scene_probe.rs`, `FLY_PROBE_CATCH=dialog`): the box was drawn on 10 frames,
/// this answered `false` on all 260, and `wTextBoxID` read `TWO_OPTION_MENU` on exactly the 10.
///
/// So the screen half is now [`two_option_box_drawn`], which asks for the border **around the
/// cursor the game parked in it**, wherever on screen that is.
///
/// **What it still does not claim.** A frame with a two-option cursor and no border anywhere near
/// it reads `false`, which is the whole point of reading the figure; and a menu of two options that
/// is not a question about the world is still just a menu -- what the pad makes of a readable
/// prompt is [`super::macros::palette`]'s business, not this function's.
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
{
if 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)
two_option_box_drawn(memory, cursor.top_x, cursor.top_y)
}
/// Whether `DisplayTwoOptionMenu`'s own box is drawn around the cursor the game parked in it.
///
/// One fact about the routine rather than about any one script (row 56): the cursor goes in the
/// box's **first interior column**, so the border's left edge is one column to the left of
/// `wTopMenuItemX`. Both surveyed boxes satisfy it -- the nurse's left edge is 11 with the cursor
/// at 12, the gym guide's is 14 with the cursor at 15 -- and the *top* satisfies no such rule,
/// because the nurse's box begins two rows above the first item and the guide's one. So the top is
/// found: the nearest row above the cursor whose left column holds the border's top-left corner,
/// looking up at most [`poke::TWO_OPTION_CAPTION_ROWS`]. The rest of the figure is then read
/// **whole** by [`border_drawn`], exactly as `waiting` and the move list are, because a single
/// frame tile id is an ordinary character.
fn two_option_box_drawn(memory: &mut dyn MemoryReader, cursor_x: u8, cursor_y: u8) -> bool {
let Some(left) = u16::from(cursor_x).checked_sub(1) else {
return false;
};
let row = u16::from(cursor_y);
if row == 0 || left + 2 >= poke::SCREEN_WIDTH || row + 1 >= poke::SCREEN_HEIGHT {
return false;
}
let Some(top) = (row.saturating_sub(poke::TWO_OPTION_CAPTION_ROWS)..row)
.rev()
.find(|top| screen_tile(memory, left, *top) == poke::frame::TOP_LEFT)
else {
return false;
};
let last = (row + poke::TWO_OPTION_BOX_ROWS).min(poke::SCREEN_HEIGHT - 1);
((row + 1)..=last).any(|bottom| {
((left + 2)..poke::SCREEN_WIDTH)
.any(|right| border_drawn(memory, left, top, right, bottom))
})
}
/// Whether `MoveSelectionMenu`'s own box is the figure on screen (`infra/docs/macros-traps.md`,

View file

@ -429,6 +429,81 @@ fn a_text_box_is_open_from_the_font_flag_and_waiting_from_the_box() {
assert!(!text_box(&mut wram).waiting);
}
/// Row 56: a two-option box is the one Red drew, not the one row 41 pinned.
///
/// Row 41 read the border at (11, 6)-(19, 11) because that is where the Pokémon Center's script
/// puts it, and named the limit in its own residual. The Pewter Gym guide's "Let me take you to the
/// top!" draws the same menu at **(14, 7)-(19, 11)** with the cursor at column 15, so the pinned
/// reading answered `false` on all 260 frames of a surveyed conversation while the box was drawn on
/// ten of them (`examples/scene_probe.rs`, `FLY_PROBE_CATCH=dialog`). The pad was therefore
/// `NEXT, YES, NO` on a box that was a choice -- two channels for one A press, 12.10's forbidden
/// pair -- and the reopened-prompt exclusion never armed, because it only judges an answer to a
/// prompt this crate can read.
///
/// What is the same in both boxes is a fact about `DisplayTwoOptionMenu` rather than about a
/// script: the cursor goes in the box's first interior column. The top is not, so it is found.
#[test]
fn a_yes_no_prompt_is_the_box_drawn_around_the_cursor_wherever_red_draws_it() {
// The centre's own box, which row 41 surveyed: still a prompt.
let mut wram = Wram::overworld();
wram.yes_no_prompt();
assert!(yes_no_prompt(&mut wram), "the box at (11, 6)-(19, 11)");
// The gym guide's, three columns over and one row shorter. This is row 56.
let mut wram = Wram::overworld();
wram.gym_yes_no_prompt();
assert!(yes_no_prompt(&mut wram), "the box at (14, 7)-(19, 11)");
// Both halves stay load-bearing: the cursor bytes outlive the box on every other frame of the
// conversation, and a frame with no box drawn is not a choice.
let mut wram = Wram::overworld();
wram.dialogue_box().yes_no_cursor(poke::GYM_GUIDE_YES_NO_CURSOR_X);
assert!(!yes_no_prompt(&mut wram), "the cursor bytes with no box are not a prompt");
// The font flag gates it, exactly as it gates `waiting`.
let mut wram = Wram::overworld();
wram.gym_yes_no_prompt().set(ram::wFontLoaded, 0);
assert!(!yes_no_prompt(&mut wram), "no text display, no prompt");
// A box the cursor is not parked in is not the cursor's box: the left edge is one column left
// of the first item, and that is the whole of what ties the two together.
let mut wram = Wram::overworld();
wram.dialogue_box()
.draw_box(4, 7, 9, 11)
.yes_no_cursor(poke::GYM_GUIDE_YES_NO_CURSOR_X);
assert!(!yes_no_prompt(&mut wram), "a box somewhere else on the screen");
// And a menu of more than two options is not this menu, box or no box.
let mut wram = Wram::overworld();
wram.gym_yes_no_prompt().set(ram::wMaxMenuItem, 2);
assert!(!yes_no_prompt(&mut wram), "three options is not a two-option box");
// The pads the two frames are dealt: a readable choice is the choice's own answers and `NEXT`
// is off it (12.10 in a dialog), and a plain box of the same conversation keeps all three.
use crate::pokemon_red::macros::palette::{MacroKind, scene_set};
let pad = |wram: &mut Wram| {
let scene = crate::pokemon_red::scene::detect(wram);
let mut poke = PokeState::new(wram);
scene_set(scene, &mut poke)
};
let mut wram = Wram::overworld();
wram.gym_yes_no_prompt();
assert_eq!(
pad(&mut wram),
vec![MacroKind::Yes, MacroKind::No],
"the guide's box is a choice, so the pad is its answers"
);
let mut wram = Wram::overworld();
wram.dialogue_box().yes_no_cursor(poke::GYM_GUIDE_YES_NO_CURSOR_X);
assert_eq!(
pad(&mut wram),
vec![MacroKind::Next, MacroKind::Yes, MacroKind::No],
"a plain box of the same conversation"
);
}
#[test]
fn the_start_menu_counts_its_items() {
let mut wram = Wram::overworld();