From 5f9237efcd376d590c99c787122f074c3a5fabc3 Mon Sep 17 00:00:00 2001 From: flybrain Date: Wed, 23 Sep 2026 00:37:44 +0000 Subject: [PATCH] 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. --- .../flybrain-gb/src/pokemon_red/fake_wram.rs | 23 +++++ .../flybrain-gb/src/pokemon_red/state.rs | 91 +++++++++++++++---- .../src/pokemon_red/state/tests.rs | 75 +++++++++++++++ 3 files changed, 173 insertions(+), 16 deletions(-) diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs index 316cbb0..cd4338f 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs @@ -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) 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 9ed92a1..ef62ff3 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs @@ -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`, 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 de7ae07..a877825 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 @@ -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();