From a6a8f797ffabf44c7b60ffa0c3f0911fa6eb82c9 Mon Sep 17 00:00:00 2001 From: flybrain Date: Wed, 23 Sep 2026 00:30:35 +0000 Subject: [PATCH 1/5] survey: the conversation probe, and every rectangle the cartridge drew Row 56 is a gym's dialog for thirty brain minutes with YES, NO and NEXT dealt in equal thirds, so the question is which box each press is answering and whether the seam can see it at all. `state::drawn_boxes` asks the screen instead of a pinned rectangle: every complete `TextBoxBorder` on the frame. `FLY_PROBE_CATCH=dialog` walks the conversation one raw pulse at a time and prints both readings of every frame side by side, with the pad the palette deals for it, and tallies how the candidate readings separate the frames. `FLY_PROBE_ANSWER` picks the button a frame with a box on it is answered with, so the two arms of a choice can be walked separately. --- .../flybrain-gb/src/pokemon_red/state.rs | 28 +++ .../crates/flysim/examples/scene_probe.rs | 159 ++++++++++++++++++ 2 files changed, 187 insertions(+) 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 82c5be5..9ed92a1 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs @@ -680,6 +680,34 @@ pub fn dialog_border(memory: &mut dyn MemoryReader) -> (bool, bool) { (box_drawn(memory, 0, 12, 19, 17), border_drawn(memory, 0, 12, 19, 17)) } +/// Every complete [`border_drawn`] rectangle on screen, as `(left, top, right, bottom)`. +/// +/// A diagnostic, beside [`dialog_border`], and the reading row 56 turns on. The dialogue box and +/// the two-option box are both read at *pinned* coordinates, because that is where the scripts +/// that draw them put them — so a prompt Red drew somewhere else is invisible to +/// [`yes_no_prompt`], and "invisible" and "not there" are the same answer from inside the seam. +/// This asks the screen instead: which rectangles on this frame are whole `TextBoxBorder` +/// figures. Every rectangle at least three by three is tried, which is 130,000 reads of a +/// memoized buffer and is a survey tool rather than a per-frame accessor. +pub fn drawn_boxes(memory: &mut dyn MemoryReader) -> Vec<(u16, u16, u16, u16)> { + let mut found = Vec::new(); + for top in 0..poke::SCREEN_HEIGHT { + for left in 0..poke::SCREEN_WIDTH { + if screen_tile(memory, left, top) != poke::frame::TOP_LEFT { + continue; + } + for bottom in (top + 2)..poke::SCREEN_HEIGHT { + for right in (left + 2)..poke::SCREEN_WIDTH { + if border_drawn(memory, left, top, right, bottom) { + found.push((left, top, right, bottom)); + } + } + } + } + } + found +} + pub fn dialog_corners(memory: &mut dyn MemoryReader) -> [u8; 4] { [ screen_tile(memory, 0, 12), diff --git a/services/flysim/crates/flysim/examples/scene_probe.rs b/services/flysim/crates/flysim/examples/scene_probe.rs index db873b1..9648ced 100644 --- a/services/flysim/crates/flysim/examples/scene_probe.rs +++ b/services/flysim/crates/flysim/examples/scene_probe.rs @@ -1202,6 +1202,158 @@ fn shop_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) } } +/// Everything that tells one box of a conversation from another, on one line (row 56). +/// +/// The two readings side by side: what the seam makes of the frame ([`state::yes_no_prompt`], and +/// the scene the pad is dealt for), and **where the cartridge actually drew a box** +/// ([`state::drawn_boxes`]). A prompt Red draws somewhere other than the nurse's corner reads +/// `yesno=false` on the left of that line and shows up as a rectangle on the right of it, which is +/// the whole question row 56 asks. +fn dialog_frame(gb: &mut Emulator) -> String { + let text = state::text_box(gb); + let boxes: Vec = state::drawn_boxes(gb) + .into_iter() + .map(|(left, top, right, bottom)| format!("({left},{top})-({right},{bottom})")) + .collect(); + format!( + "{:?} open={} waiting={} yesno={} cursor=({},{},{},{},{:#04x}) textbox={:#04x} \ + boxes=[{}] | {} | {}", + scene::detect(gb), + text.open, + text.waiting, + state::yes_no_prompt(gb), + gb.read8(ram::wTopMenuItemY), + gb.read8(ram::wTopMenuItemX), + gb.read8(ram::wCurrentMenuItem), + gb.read8(ram::wMaxMenuItem), + gb.read8(ram::wMenuWatchedKeys), + gb.read8(ram::wTextBoxID), + boxes.join(" "), + box_line(gb, 14), + box_line(gb, 16), + ) +} + +/// The pad the palette deals for the frame that is up, by name. +fn dialog_pad(gb: &mut Emulator, adapter: &PokemonRedReward) -> String { + use flybrain_gb::pokemon_red::macros::cartridge::MacroState; + use flybrain_gb::pokemon_red::macros::plan; + let ledger = AdapterLedger(adapter); + let mut poke = flybrain_gb::pokemon_red::state::PokeState::with_ledger(gb, &ledger); + let state: &mut dyn MacroState = &mut poke; + let scene = state.scene(); + let plan = plan::plan_for(scene, state); + let names: Vec<&str> = plan.slots.iter().flatten().map(|spec| spec.name).collect(); + format!("{names:?}") +} + +/// Row 56's conversation survey: which box the fly is answering in a gym, and where Red draws it. +/// +/// `FLY_PROBE_CATCH=dialog`. The live loop was map 54, scene `dialog`, `YES` 64 / `NO` 62 / +/// `NEXT` 59 / `TALK` 6 over ten brain minutes with no walk macro dealt at all, and three readings +/// fit that: a conversation that re-offers its choice for ever, a talked ledger that never records +/// the person, or a prompt the seam cannot see. They are told apart by walking the conversation +/// press by press and printing both readings of every frame, so this walks it. +/// +/// `FLY_PROBE_ANSWER` picks the button the survey presses on a frame where a box **is** drawn +/// somewhere: `a` (the default) or `b`. Everything else gets an A, because A is what advances a +/// plain box. The pad is printed beside each frame, so a row where the pad is `NEXT, YES, NO` on a +/// frame with a two-option box on screen is the trap said out loud. +fn dialog_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) { + use flybrain_gb::pokemon_red::macros::cartridge::MacroState; + use flybrain_gb::pokemon_red::macros::palette; + + 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## What the fly is standing on, and what it is facing\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; + println!("- player: {:?}", state.player()); + println!("- objective: {:?}", state.objective()); + println!("- facing: {:?}", palette::facing_target(state)); + println!("- `facing_untalked` = {}", palette::facing_untalked(state)); + println!("- untalked people: {:?}", palette::untalked_people(state)); + println!("- untalked objects: {:?}", palette::untalked_objects(state)); + println!("- `yes_no_prompt` = {}", state.yes_no_prompt()); + } + println!("\n## The screen at the checkpoint\n\n```\n{}\n```\n", screen_rows(gb)); + println!("```"); + for row in screen_text(gb) { + println!("{row}"); + } + println!("```\n"); + println!("- the frame: {}", dialog_frame(gb)); + println!("- the pad: {}", dialog_pad(gb, adapter)); + + let answer = std::env::var("FLY_PROBE_ANSWER").unwrap_or_else(|_| "a".to_string()); + let answer_mask = + if answer == "b" { flybrain_gb::buttons::B } else { flybrain_gb::buttons::A }; + + println!( + "\n## The conversation, one raw pulse at a time (a box on screen gets `{answer}`)\n" + ); + println!("```"); + let mut last = String::new(); + let mut boxes_seen: BTreeMap = BTreeMap::new(); + let mut classes: BTreeMap<(bool, bool, bool), u64> = BTreeMap::new(); + for index in 0..env_usize("FLY_PROBE_PULSES", 200) { + let choice = if state::drawn_boxes(gb).iter().any(|(_, top, _, _)| *top < 12) { + answer_mask + } else { + flybrain_gb::buttons::A + }; + pulse(gb, adapter, choice, ms); + let drawn = state::drawn_boxes(gb); + for (left, top, right, bottom) in &drawn { + *boxes_seen.entry(format!("({left},{top})-({right},{bottom})")).or_default() += 1; + } + *classes + .entry(( + drawn.iter().any(|(_, top, _, _)| *top < 12), + gb.read8(ram::wTextBoxID) == 0x14, + state::yes_no_prompt(gb), + )) + .or_default() += 1; + let now = format!("{} pad={}", dialog_frame(gb), dialog_pad(gb, adapter)); + if now != last { + println!("#{index:<3} {now}"); + last = now; + } + } + println!("```\n"); + println!("Every rectangle the cartridge drew over the survey, and on how many frames:\n"); + for (figure, count) in &boxes_seen { + println!("- `{figure}` on {count} frames"); + } + println!("\n{}", separator_table(&classes)); +} + +/// How the candidate readings of "a two-option box is up" separate the frames of a survey. +/// +/// Three columns, because three things could say it and only a measurement says which: the +/// cartridge's own `wTextBoxID`, the seam's pinned-geometry [`state::yes_no_prompt`], and the +/// generalised reading -- a complete border drawn around the cursor the game parked, wherever on +/// screen that is. A row where the box is drawn and a column reads `false` is that column missing +/// the prompt. +fn separator_table(classes: &BTreeMap<(bool, bool, bool), u64>) -> String { + let mut out = String::from( + "| a box drawn above the dialogue box | `wTextBoxID` = `$14` | `yes_no_prompt` | frames |\n | --- | --- | --- | ---: |\n", + ); + for ((drawn, textbox, prompt), frames) in classes { + out.push_str(&format!("| {drawn} | {textbox} | {prompt} | {frames} |\n")); + } + out +} + fn main() { let Some(path) = std::env::var_os("FLY_ROM") else { println!("FLY_ROM is not set, so there is nothing to probe."); @@ -1277,6 +1429,13 @@ fn main() { return; } + // Row 56's conversation survey: which box a gym's guide draws, where he draws it, and what + // the dialog pad makes of it press by press. + if std::env::var("FLY_PROBE_CATCH").is_ok_and(|value| value == "dialog") { + dialog_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; From 5f9237efcd376d590c99c787122f074c3a5fabc3 Mon Sep 17 00:00:00 2001 From: flybrain Date: Wed, 23 Sep 2026 00:37:44 +0000 Subject: [PATCH 2/5] 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(); From 886509b3b5ffefa9c858026ba0ea34c9a34ff7c5 Mon Sep 17 00:00:00 2001 From: flybrain Date: Wed, 23 Sep 2026 00:37:44 +0000 Subject: [PATCH 3/5] a `NO` inside a conversation declines nothing, so it does not un-arm the talk The other half of row 56. The Pewter Gym guide's conversation is fifty-two boxes long and a third of the presses that walk it are `NO`, whose B advances a plain box exactly as `NEXT`'s A does. Every one of them cleared the pending `TALK`, so the talked ledger never learned the conversation had happened, `TALK` stayed on the overworld pad, and an A press at him reopened the whole ring: thirty brain minutes of scene `dialog` with no walk macro dealt. 12.4's rule -- "the fly said no, so the thing is still on offer" -- is about a declined *offer*, and which of the two a `NO` was is decided where it can be seen: by whether the box closes on it. So the decision moves to the frame the text goes away, which is where the talked entry is written anyway, and the reading is `pending_answer`: armed only by an answer to a prompt this crate can read, alive for one hold. A declining `NO` still standing there is a `NO` the box closed on; anything else is a conversation walked through to its end. --- .../src/pokemon_red/macros/executor.rs | 29 +++++++-- .../src/pokemon_red/macros/tests.rs | 63 ++++++++++++++++--- 2 files changed, 79 insertions(+), 13 deletions(-) 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 aa00350..4d90f58 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 @@ -959,10 +959,26 @@ impl MacroMachine { // the text is gone. if class(state.scene()) != Class::Talking { self.pending_talk = None; - self.talked = Some((pending.map, pending.target)); + // A conversation the fly *declined its way out of* is not a conversation it has had: + // whatever it said no to is still on offer, which is section 12.4's rule and the one + // 12.12 inverts for the nurse alone. The box closing is what tells that apart from a + // `NO` pressed dozens of boxes deep, and [`MacroMachine::pending_answer`] is the + // reading: it is armed only by an answer to a prompt this crate can read and it lives + // for one hold, so a declining `NO` still standing here is a `NO` this box closed on. + if !self.declined_out_of(pending.map) { + self.talked = Some((pending.map, pending.target)); + } } } + /// Whether the answer still standing on `map` is a `NO` to a readable prompt: a declined offer + /// rather than a conversation walked through (section 12.20). + fn declined_out_of(&self, map: u8) -> bool { + self.pending_answer.is_some_and(|pending| { + pending.answered.map == map && pending.answered.prompt && !pending.answered.yes + }) + } + /// One frame after a `YES` or `NO`: decide whether the box it answered has come straight back. /// /// `docs/design/macros.md` section 12.12. The evidence is all in one frame: the fly is on the @@ -1099,11 +1115,12 @@ impl MacroMachine { { self.pending_talk = Some(PendingTalk { map, target, at }); } - // The fly said no. Whatever it said no to is still on offer, so the thing it - // was facing is not retired. - if active.kind == MacroKind::No { - self.pending_talk = None; - } + // The fly said no, and whether that retires what it was facing is decided + // when the box closes rather than here (section 12.20). A `NO` inside a + // conversation is the B that advances a plain box -- it declines nothing -- + // and clearing the pending talk on it kept the Pewter Gym guide `untalked` + // for thirty brain minutes: his conversation is fifty-two boxes long and + // about a third of the presses that walk it are `NO`. // An answer, and the box it answered: armed so that the same prompt coming // straight back is recorded (section 12.12), and the nurse written into the // talked ledger when what was declined was *her* offer. That is 12.4's rule 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 ea01aaa..72aa2a6 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 @@ -3410,9 +3410,12 @@ fn a_conversation_that_walks_the_fly_off_its_tile_is_not_talked_to() { } #[test] -fn a_fly_that_answers_no_has_not_talked_to_anything() { +fn a_fly_that_declines_an_offer_has_not_talked_to_anything() { // The catching tutorial's own shape: a yes/no box, and `NO` is a real answer the pad has to be // able to give. What it must not do is retire the thing that asked -- the offer stands. + // + // Since row 56 the box has to be a **readable prompt** for that to be the reading: a `NO` on a + // plain text box declines nothing, and the test below is the other half. let mut world = World::room().at(3, 3); world.facing = Facing::Down; world.npcs = vec![Npc { slot: 4, picture: 1, x: 3, y: 4, facing: Facing::Up }]; @@ -3422,23 +3425,69 @@ fn a_fly_that_answers_no_has_not_talked_to_anything() { while machine.step(&mut world).is_some() { world.frame(buttons::NONE); } - // The box is open, so the scene is a dialog and the pad is the dialog's. + // The box is open and it is the choice, so the scene is a dialog and the pad is its answers. world.scene = Scene::Dialog; + world.prompt = true; let (dialog, no) = pick(&mut world, MacroKind::No); - // Both answers are on the pad, whatever the box is: there is no WRAM observable for "a choice - // is open" (section 12.2, row 15). - assert!(names(&dialog).contains(&"YES"), "{:?}", names(&dialog)); - assert!(names(&dialog).contains(&"NO"), "{:?}", names(&dialog)); + assert_eq!(names(&dialog), ["YES", "NO"], "a readable choice deals its own answers (12.12)"); machine.start(&dialog, no, &mut world).expect("NO is bound in a dialog"); while machine.step(&mut world).is_some() { world.frame(buttons::NONE); } world.scene = Scene::Overworld; machine.observe_frame(&mut world); - assert_eq!(machine.take_talked(), None, "a no is not a conversation had"); + assert_eq!(machine.take_talked(), None, "a declined offer is not a conversation had"); assert!(on_the_pad(&mut world, MacroKind::Talk), "the offer stands"); } +#[test] +fn a_no_pressed_inside_a_conversation_is_not_a_declined_offer() { + // Row 56, the Pewter Gym guide. His conversation is fifty-two boxes long and `NO`'s B advances + // a plain one exactly as `NEXT`'s A does -- it declines nothing. Clearing the pending `TALK` on + // it meant the talked ledger never learned the conversation had happened, so `TALK` was on the + // overworld pad every hold, and an A press at him reopened the whole ring: thirty brain + // minutes of scene `dialog` with no walk macro dealt at all. + // + // Which of the two a `NO` was is decided where it can be seen: by whether the box closes on it. + let mut world = World::room().at(3, 3); + world.facing = Facing::Down; + world.npcs = vec![Npc { slot: 4, picture: 1, x: 3, y: 4, facing: Facing::Up }]; + let mut machine = MacroMachine::new(1); + let (palette, slot) = pick(&mut world, MacroKind::Talk); + machine.start(&palette, slot, &mut world).expect("TALK is bound at a person"); + while machine.step(&mut world).is_some() { + world.frame(buttons::NONE); + } + + // Deep inside the conversation: a plain text box, not a choice. + world.scene = Scene::Dialog; + world.prompt = false; + let (dialog, no) = pick(&mut world, MacroKind::No); + assert!(names(&dialog).contains(&"NEXT"), "a plain box deals all three: {:?}", names(&dialog)); + machine.start(&dialog, no, &mut world).expect("NO is bound in a dialog"); + while machine.step(&mut world).is_some() { + world.frame(buttons::NONE); + } + + // The box is still open, so nothing is decided yet -- the conversation is still running. + machine.observe_frame(&mut world); + assert_eq!(machine.take_talked(), None, "the box is still open"); + + // And when the text is gone the conversation counts, which is what shuts the ring's door. + world.scene = Scene::Overworld; + machine.observe_frame(&mut world); + assert_eq!( + machine.take_talked(), + Some((world.map, TalkTarget::Sprite(4))), + "a conversation walked through to its end is a conversation had" + ); + world.talked.insert(TalkTarget::Sprite(4)); + assert!( + !on_the_pad(&mut world, MacroKind::Talk), + "`TALK` is the ring's door and this run has been through it" + ); +} + #[test] fn a_walk_the_cartridge_pushes_back_excludes_what_it_was_walking_to() { // Row 28's other half. Every macro that walked into the gate ended `Done` -- a scene change, From a22edacbc83f56e0f1cc1cabbb8f7c2d2c45f0cf Mon Sep 17 00:00:00 2001 From: flybrain Date: Wed, 23 Sep 2026 01:17:33 +0000 Subject: [PATCH 4/5] docs: macros.md 12.20, macros-wram.md section 11, the row 56 audit The survey whole, both arms of the trap hunt, the ROM run, and the residuals -- including the two this row does not work: neither arm reaches rung 11 from this checkpoint, and the watchdog's check 10 cannot see a loop of exactly four distinct macros. --- docs/design/macros-wram.md | 61 +++++++++++++- docs/design/macros.md | 72 +++++++++++++++- infra/docs/macros-traps.md | 166 +++++++++++++++++++++++++++++++++++++ 3 files changed, 297 insertions(+), 2 deletions(-) diff --git a/docs/design/macros-wram.md b/docs/design/macros-wram.md index 30443a2..2b97299 100644 --- a/docs/design/macros-wram.md +++ b/docs/design/macros-wram.md @@ -159,7 +159,7 @@ forces. A battle that is neither — text, an animation, the turn resolving — | a submenu | the weakest rule here, and the reason `Unknown` exists: `wListMenuID` is the bag or an elevator list, or the party list geometry outside a battle. A submenu none of those catch reads as `Unknown`, never as `Overworld`. | trace | | mart | `wTextBoxID` = `BUY_SELL_QUIT_MENU` (`$15`) for the BUY / SELL / QUIT choice, and `engine/events/pokemart.asm:17` is its only user in the game; the buy list is `wListMenuID` = `$02` and the sell list is the bag's own `$03`, recognised only while the mart's template is still the last one drawn. | trace | | PC | `wMiscFlags` bit 3, above. | trace | -| a two-option YES/NO box | **new 2026-09-22** (`docs/design/macros.md` section 12.12). `wFontLoaded` bit 0, plus the border `DisplayTwoOptionMenu` draws at (11, 6)-(19, 11), plus the shared cursor parked at `wTopMenuItemY` 8, `wTopMenuItemX` 12 with `wMaxMenuItem` 1 and `wMenuWatchedKeys` = A\|B. **Both halves are load-bearing**: the cursor bytes survive the box closing, so all forty-six frames of a Pokémon Center nurse's conversation carry that geometry while the box is drawn on exactly one of them. It does **not** answer "is a choice open" in general — Red places a two-option menu where the script asking for it says, and a prompt drawn elsewhere reads `false`. | ROM (the rung-10 Pokémon Center checkpoint, surveyed one raw A pulse at a time: `examples/scene_probe.rs`, `FLY_PROBE_CATCH=nurse`) | +| a two-option YES/NO box | **new 2026-09-22, generalised 2026-09-23** (`docs/design/macros.md` sections 12.12 and 12.20). `wFontLoaded` bit 0, plus a two-option cursor (`wMaxMenuItem` 1, `wMenuWatchedKeys` = A\|B), plus the border `DisplayTwoOptionMenu` drew **around the cursor it parked** -- see section 11. **Both halves are load-bearing**: the cursor bytes survive the box closing, so all forty-six frames of a nurse's conversation and all fifty-two of a gym guide's carry that geometry while the box is drawn on a handful of them. The border is no longer pinned to one rectangle, because Red places the menu where the script asking for it says and two of those places are surveyed. | ROM (the rung-10 Pokemon Center and the rung-10 Pewter Gym checkpoints, each surveyed one raw pulse at a time: `examples/scene_probe.rs`, `FLY_PROBE_CATCH=nurse` and `=dialog`) | ### Money and bag @@ -834,3 +834,62 @@ the HRAM joypad bytes, which is the measurement seeing its own held button. reported rather than guessed — `docs/design/ladder.md`'s rule. `ITEM` and `THROW BALL` are the two macros it costs. - **The party list, likewise**: `PartyMenuInit`'s geometry outlives its list. + +## 11. A two-option box is the one the cartridge drew (2026-09-23, `docs/design/macros.md` 12.20) + +Section 10 read one menu by the figure it draws; row 41 read the YES/NO box the same way but at a +**pinned** rectangle, (11, 6)-(19, 11), 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 read `false`. + +Row 56 is that residual, live: the Pewter Gym guide's "Let me take you to the top!" draws the same +menu at **(14, 7)-(19, 11)** with the shared cursor at row 8, column **15**. Over 260 surveyed +presses of his conversation the box was drawn on **10** frames and `yes_no_prompt` answered `false` +on **all 260** -- so the dialog pad was `NEXT, YES, NO` on a box that was a choice, and the whole of +12.12 (no `NEXT` on a prompt, the nurse's one bound answer, the reopened-prompt exclusion) was +inert wherever the box was not the centre's. + +### The accessor + +Nothing in the reviewed symbol list says "a choice is open" and nothing can be added by hand +(`gen_symbols.py` refuses a hand-written address, and the disassembly is not built on this box), so +the reading is the construction `text_box`'s `waiting` already makes -- a WRAM flag plus the figure +-- with the figure **found** rather than pinned: + +1. `wFontLoaded` bit 0, as for every text display; +2. the cursor is a two-option menu's: `wMaxMenuItem` 1 and `wMenuWatchedKeys` = A\|B; +3. the border's **left edge is one column to the left of `wTopMenuItemX`**, because + `DisplayTwoOptionMenu` writes the cursor into the box's first interior column. This holds in + both surveyed boxes -- left 11 with the cursor at 12, left 14 with the cursor at 15 -- and it is + the only geometric relation that does; +4. the **top** is looked up from the cursor's row for the border's own top-left corner, at most + three rows, because the nurse's box begins two rows above the first item and the guide's one: + one menu carries a caption line and the other does not; +5. and the rest of the figure is then read **whole** by `border_drawn` -- both verticals, both + horizontal runs and all four corners -- because a single frame tile id is an ordinary character. + +### The survey + +`examples/scene_probe.rs`, `FLY_PROBE_CATCH=dialog`, which walks a conversation one raw pulse at a +time and prints, per frame, the seam's reading beside **every complete `TextBoxBorder` on screen** +(`state::drawn_boxes`, a diagnostic that tries every rectangle rather than one). Two checkpoints, +400 frames: + +| checkpoint | a box drawn above the dialogue box | `wTextBoxID` = `TWO_OPTION_MENU` (`$14`) | the new reading | frames | +| --- | --- | --- | --- | ---: | +| rung-10 Pewter Gym | false | false | false | 250 | +| rung-10 Pewter Gym | **true** | **true** | **true** | 10 | +| rung-10 Pokémon Center | false | false | false | 136 | +| rung-10 Pokémon Center | **true** | **true** | **true** | 4 | + +The three agree exactly on all 400 frames. **`wTextBoxID` is recorded and not used**: it would be a +tighter reading still, and row 41's own note says the nurse's *plain* boxes read `$01` — which is +confirmed here — but no survey on this branch covers Red's other two-option menus, and a reading +this crate has not verified does not go in (`docs/design/ladder.md`). It is the named strengthening. + +### What it does not claim + +A frame whose two-option cursor bytes have outlived their box reads `false`, which is the whole +point of reading the figure; a border drawn somewhere the cursor is not parked is not the cursor's +box; and a menu of more than two options is not this menu. What the pad makes of a readable prompt +is `pokemon_red::macros::palette`'s business (`docs/design/macros.md` 12.12 and 12.20), not this +accessor's. diff --git a/docs/design/macros.md b/docs/design/macros.md index 1c98296..1479817 100644 --- a/docs/design/macros.md +++ b/docs/design/macros.md @@ -1304,6 +1304,76 @@ offered, and the two presses that leave a counter are. Nothing here changes which button the fly presses. The decoder, the reward catalog, the adapter version and the compatibility string are untouched. +### 12.20 A two-option box is the one the cartridge drew, and a `NO` inside a conversation declines nothing (2026-09-23, row 56) + +Live on the release box: map 54 (**Pewter Gym**), scene `dialog`, rank 10, **thirty-plus brain +minutes of zero progress** with the explore and wild-win counters frozen, **747 macro starts in ten +brain minutes**, and a mix of `YES` 64 / `NO` 62 / `NEXT` 59 / `TALK` 6 with **no walk macro dealt +at all**. The watchdog did not flag it: four distinct macros is exactly its threshold. Surveyed +from the live checkpoint with a new probe mode (`examples/scene_probe.rs`, +`FLY_PROBE_CATCH=dialog`), which walks the conversation one raw pulse at a time and prints, per +frame, what the seam makes of it beside **every complete `TextBoxBorder` the cartridge actually +drew**. `infra/docs/macros-traps.md` has the survey whole. + +The shape is **row 41's ring one town over**: the gym guide's conversation is fifty-two presses -- +"Hiya! I can tell you have what it takes to become a POKeMON champ!", "Let me take you to the +top!" with a YES/NO box, the type-matchup tutorial, "matches could be made easier!" -- the box +closes for a frame, and the next A press at the guide two tiles away opens the whole thing again. +Nothing in it changes the world. Two things kept the fly walking it, and both are readings rather +than pads. + +- **The box was drawn where this crate was not looking.** 12.12 read the border at + (11, 6)-(19, 11), because that is where a Pokemon Center's script puts it, and said so 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 its dialog keeps the pad it has always had". The guide's box is at + **(14, 7)-(19, 11)** with the cursor at column 15. Over 260 surveyed presses the box was drawn on + **10 frames** and `yes_no_prompt` answered `false` on **all 260** -- so the pad was + `NEXT, YES, NO` on a frame that was a *choice*, which is 12.10's forbidden pair (an A press at a + two-option menu confirms the option the cursor is on, and that is what `YES` is), and the + reopened-prompt exclusion of 12.12 never armed, because it only judges an answer to a prompt this + crate can read. **The whole of 12.12 was inert in that gym.** +- **So the figure is found rather than pinned.** One fact about `DisplayTwoOptionMenu` rather than + about any one script: the cursor goes in the box's **first interior column**, so the border's + left edge is one column to the left of `wTopMenuItemX` -- true of both boxes surveyed. The *top* + obeys 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 by looking up for the border's own corner and the figure is then read + **whole**, exactly as `waiting` and the move list are. `docs/design/macros-wram.md` section 11 + has the accessor. Measured over both checkpoints, 400 frames: the reading is true on the 14 + frames a two-option box is drawn and false on the other 386, and `wTextBoxID` = `TWO_OPTION_MENU` + agrees with it exactly -- which is recorded as a third reading and **not** put in the accessor, + because no survey here covers Red's other two-option menus. +- **And a `NO` pressed inside a conversation declines nothing.** 12.4's rule -- "the fly said no, so + whatever it said no to is still on offer" -- took the pending `TALK` off the moment any `NO` + finished. A `NO`'s B press advances a plain text box exactly as `NEXT`'s A does, about a third of + the fifty-two presses that walk the guide's ring are `NO`, so the talked ledger **never learned + the conversation had happened**: `TALK` was on the overworld pad every hold and was the ring's + own door. In the twenty-brain-minute reproduction `TALK` started **25** times on that one map. +- **Which of the two a `NO` was is decided where it can be seen: by whether the box closes on it.** + The decision moves to the frame the text goes away, which is where the talked entry is written + anyway, and the reading is the answer still standing there -- `pending_answer`, armed only by an + answer to a prompt this crate can read and alive for one hold (12.12). A declining `NO` still + standing when the box closes is a `NO` the box closed *on*, and the offer stands; anything else + is a conversation walked through to its end, and the person is retired. The nurse's own declined + heal is unchanged: it writes her into the ledger by 12.12's named inversion, one person wide. +- **What the pad does instead, which is the point.** With the guide retired, the gym's overworld pad + is `GO OBJECTIVE`, `GO OUT`, `GO FRONTIER`, `GO HEAL` -- the walks -- and the fly is out of the + room in 0.37 brain minutes against never in twenty. Nothing new is on any pad and nothing is + ranked: `TALK` goes off a person this run has already had the conversation with, which is the + ledger 9.2 added doing exactly what it was added for, and `NEXT` goes off a readable prompt, + which is 12.10. + +**What the harness holds.** Unit: a two-option box reads as a prompt at both surveyed geometries +and at neither without its border, its font flag or its two-option cursor; a box the cursor is not +parked in is not the cursor's box; the pads the two frames are dealt (`YES, NO` against +`NEXT, YES, NO`); a declined offer leaves the thing on offer; and a `NO` deeper inside a +conversation leaves the conversation counted and `TALK` off the pad. ROM-gated from the live +checkpoint: the fly leaves map 54, `NEXT` is on no pad while a readable prompt is open, and **no +readable prompt is answered more than four times for one person in a session**, against 439 +answers at one person in the twenty-minute reproduction. + +The decoder, the reward catalog, the adapter version, the roles and the compatibility string are +untouched. + ## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a ## priority to visit the shop at least once per area; make shop macros item purchases. same ## for the Pokécenter. heal should be a macro.") @@ -1380,7 +1450,7 @@ observe is not a precondition, it is a guess. | Overworld, inside a mart or a centre, counter unfaced | GO SHOP or GO HEAL, TALK when facing the counter | new, and it is the one place a pad is deliberately *narrow*. The errand is paid on entering and never offered again, so a walk that leaves the building spends the one visit the area gets — measured: the fly reached the mart in 1.7 brain minutes and `GO OBJECTIVE` walked it straight back out over the doormat. While the counter is unfaced nothing on the pad leaves (row 34b) | | Overworld, inside a mart or a centre, counter faced | the indoor pad, plus HEAL in a centre | the suppression is released by facing the counter, by talking to it, or by a walk to it failing. Since **12.12** `TALK` is not on it at a *nurse* the party has no use for: her conversation is a service whose need the cartridge publishes, and a ring of text that ends where it began is section 12.2's trap | | Dialog, a plain text box | NEXT, YES, NO | A and B both advance a plain box, so all three are dealt for one — what it buys is the fly being able to answer *no*. Forty-five of the nurse's forty-six frames are this row (**12.12**) | -| Dialog, a readable YES/NO box | YES, NO — or **one of them** at a Pokémon Center's nurse | **new, 12.12.** `NEXT` is off it: an A press at a two-option menu confirms the option the cursor is on, which is what `YES` is, so the two are one press under two names (12.10). At the nurse's own prompt the bound answer is the one that changes something — `YES` with a hurt or statused party, `NO` with a full one. An answer whose prompt comes straight back is excluded for the blocked window, and the exclusion never empties the pad | +| Dialog, a readable YES/NO box (**the box the cartridge drew**, 12.20) | YES, NO — or **one of them** at a Pokémon Center's nurse | **new, 12.12.** `NEXT` is off it: an A press at a two-option menu confirms the option the cursor is on, which is what `YES` is, so the two are one press under two names (12.10). At the nurse's own prompt the bound answer is the one that changes something — `YES` with a hurt or statused party, `NO` with a full one. An answer whose prompt comes straight back is excluded for the blocked window, and the exclusion never empties the pad | | Menu (the start menu) | CLOSE, CONFIRM, BACK | unchanged as a *scene*, and since **12.11** nothing on any other pad opens it: the fly reaches it with the **raw** START button, which still reaches the cartridge in macros mode, and moves its cursor with the raw D-pad. A SAVE or a POKéDEX button would be a macro per start-menu entry and is not asked for -- which is precisely why `MENU` had nothing behind it | | Menu (the bag, an elevator, the party list outside a battle) | CLOSE, CONFIRM, BACK | unchanged | | Unknown (the Pokédex, the trainer card, OPTION, a naming screen, a mid-warp frame) | NEXT, **BACK** | **BACK added** (row 9): B is what leaves the first three, and A leaves none of them | diff --git a/infra/docs/macros-traps.md b/infra/docs/macros-traps.md index 9b5dd5b..84428cb 100644 --- a/infra/docs/macros-traps.md +++ b/infra/docs/macros-traps.md @@ -2365,3 +2365,169 @@ appending in the same place: `docs/design/macros.md` (row 50 keeps 12.18, row 55 **12.19**), this file (both rows, both residual pairs and both arm sections kept) and `examples/scene_probe.rs` (both survey modes kept, `accept` and `shop`). Every source file auto-merged. + +## 2026-09-23, row 56: the box the seam could not see, and a `NO` fifty presses deep + +### What was live + +Rank 10 (PEWTER CITY, next the BOULDER BADGE), the fly inside the **Pewter Gym**, map 54, scene +`dialog`, **thirty-plus brain minutes of zero progress** with the explore and wild-win counters +frozen. **747 macro starts in ten brain minutes**, mix `YES` 64 / `NO` 62 / `NEXT` 59 / `TALK` 6 +over the last 40 KB of events, the labels running +`NO, TALK, NO, YES, YES, NEXT, NO, YES, NO, YES, NEXT, YES, YES, NEXT, NO, NEXT, NO, YES, YES, NO, +NEXT` with `YES` completing back to back and **no walk macro dealt at all**. The watchdog did not +flag it: four distinct macros is exactly check 10's threshold. + +At the checkpoint the fly stands at (7, 11) facing **Up** at sprite slot 3, picture `$24` -- the +gym guide, the only sprite the ten-by-nine window can see; Brock is off screen at the top of a +10x14 room. Two warps at (4, 13) and (5, 13), the town outside. `objective` is map 54 with a +**Person** on it, which is rung 11, and `objective_goals` is the gym's own two doors, because the +Pewter Pokémon Center is an unpaid errand and an errand goes ahead of the rung (row 54). + +### The survey: the conversation, one raw pulse at a time + +A new probe mode, `FLY_PROBE_CATCH=dialog` in `examples/scene_probe.rs`. It walks the conversation +one pulse at a time and prints, per frame, what the seam makes of it -- scene, `open`, `waiting`, +`yes_no_prompt`, the cursor bytes, `wTextBoxID` -- beside **every complete `TextBoxBorder` the +cartridge actually drew** (`state::drawn_boxes`, which tries every rectangle rather than one), with +the pad the palette deals for that frame. `FLY_PROBE_ANSWER` picks the button a frame with a box on +it is answered with, so both arms of the choice can be walked. + +The ring, elided to the states that matter (260 presses, `#n` is the pulse): + +``` +#17 Dialog waiting=true yesno=false cursor=(8,15,0,1,0x03) textbox=0x01 boxes=[(0,12)-(19,17)] | Let me +#19 Dialog waiting=true yesno=false cursor=(8,15,0,1,0x03) textbox=0x14 boxes=[(14,7)-(19,11) (0,12)-(19,17)] | Let me take you | to the top! +#21 Dialog waiting=true yesno=false cursor=(8,15,1,1,0x03) textbox=0x01 boxes=[(0,12)-(19,17)] | It. a free | service! Let. +... the type-matchup tutorial, thirty more boxes +#194 Dialog ... | matches could be | made easier! +#195 Overworld open=false pad=["GO OBJECTIVE", "GO OUT", "GO FRONTIER", "GO HEAL", "TALK"] +#196 Dialog ... | Hiya! I can tell | you have what it ... and the whole thing again +``` + +Five things that settles. + +1. **It is row 41's ring one town over.** Fifty-two presses, the box closes for a frame, and the + next A press at the guide opens it again. Nothing in it changes the world. +2. **The YES/NO box is drawn at (14, 7)-(19, 11)**, not the (11, 6)-(19, 11) row 41 pinned, with + the shared cursor at row 8 column **15** rather than column 12. `yes_no_prompt` answered `false` + on **all 260 frames** while the box was drawn on **10**. +3. **So the pad was `NEXT, YES, NO` on a frame that was a choice** -- printed on the line, hold by + hold. `NEXT` and `YES` are one A press with two channel names, which is 12.10's forbidden pair, + and the reopened-prompt exclusion of 12.12 never armed, because it judges only an answer to a + prompt this crate can read. The whole of 12.12 was inert in that gym. +4. **Answering `NO` at his prompt changes nothing either.** The `b` arm gets "It's a free service! + Let's get happening!" and the same tutorial, the same length, the same ending. So the brief's + first reading -- a prompt that re-offers itself for ever -- is **out**: the prompt appears once + per lap, on two frames of fifty-two, and what repeats is the conversation. +5. **The cursor bytes are stale on all 260 frames** (`wTopMenuItemY` 8, `wTopMenuItemX` 15, + `wMaxMenuItem` 1, `wMenuWatchedKeys` `$03`) while the box is drawn on ten, exactly as at the + nurse's counter. Both halves of the reading are still needed; what changes is that the figure is + found rather than pinned. + +The same probe on the rung-10 **Pokémon Center** checkpoint, 140 presses, as the regression check: +the box at (11, 6)-(19, 11) on 4 frames, `yes_no_prompt` true on those 4 and false on the other +136, her pad `["NO"]` throughout -- 12.12 intact. + +| checkpoint | a box drawn above the dialogue box | `wTextBoxID` = `$14` | `yes_no_prompt` (after) | frames | +| --- | --- | --- | --- | ---: | +| Pewter Gym | false | false | false | 250 | +| Pewter Gym | **true** | **true** | **true** | 10 | +| Pokémon Center | false | false | false | 136 | +| Pokémon Center | **true** | **true** | **true** | 4 | + +Three readings, 400 frames, exact agreement. `wTextBoxID` is **recorded and not used**: it would be +tighter still, but no survey here covers Red's other two-option menus and a reading this crate has +not verified does not go in. + +### Why `TALK` fired twenty-five times and retired nothing + +`TALK`'s ledger entry is armed when the macro finishes and written when the box closes +(`MacroMachine::observe_frame`). Between those two moments sit the fifty-one other presses of the +ring -- and **every `NO` among them un-armed it**, by 12.4's rule that "the fly said no, so whatever +it said no to is still on offer". A `NO`'s B press advances a plain text box exactly as `NEXT`'s A +does; about a third of the ring's presses are `NO`; so the guide never entered the talked ledger, +`TALK` stayed on the overworld pad, and it was the ring's own door. Twenty-five laps in twenty brain +minutes. + +Which of the two a `NO` was is decided where it can be seen: **by whether the box closes on it.** + +| # | trap | trigger | test | fix, or why it is left | +| --- | --- | --- | --- | --- | +| 56 | a YES/NO box drawn anywhere but the Pokémon Center's corner reads as plain text, so the dialog pad deals `NEXT` beside `YES` -- two names for one A press -- and none of section 12.12 applies | any two-option menu Red's script puts somewhere else; measured at the Pewter Gym guide, whose box is at (14, 7)-(19, 11): 260 surveyed frames, box drawn on 10, `yes_no_prompt` false on all 260 | `a_yes_no_prompt_is_the_box_drawn_around_the_cursor_wherever_red_draws_it`, and the prompt/`NEXT` claims in `the_fly_leaves_the_pewter_gym_guides_ring_from_the_rung_ten_checkpoint` (ROM-gated) | **fixed**: the border is **found** around the cursor the game parked rather than pinned. `DisplayTwoOptionMenu` writes the cursor into the box's first interior column, so the left edge is one column to its left in both surveyed boxes; the top obeys no such rule (a caption line in one, none in the other) and is looked up; the figure is then read whole. `docs/design/macros-wram.md` section 11 | +| 56b | a `NO` pressed fifty boxes deep into a conversation un-arms the pending `TALK`, so a person whose conversation is longer than one box is never retired and `TALK` is the door back into the ring | any conversation with more than one box in which the fly's roll lands on `NO`; at the gym guide, 25 laps in twenty brain minutes | `a_no_pressed_inside_a_conversation_is_not_a_declined_offer`, `a_fly_that_declines_an_offer_has_not_talked_to_anything` | **fixed**: the decision moves to the frame the box closes, which is where the talked entry is written anyway. A declining `NO` still standing there -- `pending_answer`, armed only by an answer to a readable prompt and alive for one hold -- is a `NO` the box closed *on*, and the offer stands; anything else is a conversation walked through to its end | + +### The trap hunt, before and after + +Same seed, same checkpoint, twenty brain minutes each; base `main` at `2de2dce` against this branch. + +| measure | before | after | +| --- | ---: | ---: | +| distinct (map, tile) | **83** | **441** | +| windows flagged | 68 / 73 | 69 / 73 | +| macros started | 1,356 | 1,182 | +| `YES` / `NO` / `NEXT` on map `0x36` | **227 / 212 / 198** (637 presses at one conversation) | **21 / 21 / 17** (59) | +| `TALK` starts | **25** | **2** | +| frames in scene `dialog` | **32,496 of 71,673** | **2,849** | +| frames in scene `overworld` | 8,571 | **17,425** | +| frames in scene `battle` | 29,530 | **49,877** (longest 13,089) | +| `GO OBJECTIVE` starts | 19, mean reach 5.0 | **37**, mean reach 9.4, max net 27 | +| `GO FRONTIER` / `GO OUT` / `GO ITEM` | 18 / 15 / 8 | 35 / 19 / 15 | +| the run ends | in scene `dialog` on map 54, `prompt=false`, `cursor=(8,15,0,1,0x03)` | in scene `overworld` | +| rung reached | 10 | 10 | +| wall clock | 1,070 s | 1,262 s | + +**The before arm is the live loop with the real brain in it**: 637 of the run's presses are the +guide's conversation, the fly ends the run still standing in it, and the last line of the dump is +the guide's own stale cursor with `prompt=false` beside it -- the trap, printed. **The after arm +covers 5.3 times the ground** and spends four per cent of its frames in a text box instead of +forty-five. + +**The flagged-window count does not fall (68 → 69), and this says so rather than smoothing it.** +After the fix 49,877 of the 71,673 frames are battles the fly is actually fighting, with a single +13,089-frame one, and the hunt's rule -- fewer than four distinct tiles in two brain minutes -- +flags a fighting fly exactly as hard as a stuck one. That is `docs/design/macros.md` section 15's +own measurement and the fourth branch in a row to run into it. The merge is Fable's call. + +### The ROM-gated run, from the live checkpoint + +`the_fly_leaves_the_pewter_gym_guides_ring_from_the_rung_ten_checkpoint`, 240,000 frames +(67.0 brain minutes), the stub rotation: + +- the fly **leaves map 54 on frame 1,337** -- 0.37 brain minutes -- against never in twenty; +- **27 macros spent in the gym**: `NEXT` 9, `YES` 9, `NO` 8, `GO OBJECTIVE` 1. One lap of the ring + and out; +- **`TALK` starts by map `{2: 12, 58: 1}`** -- twelve different townspeople, and **none** back at + the guide; +- **the most-answered prompt is answered once.** `answers_by_person` over the whole run is + `{(2, Sprite(4)): 1, (52, nobody): 1, (54, Sprite(3)): 1}`, against a bound of four and 439 + answers at one person in the before arm; +- `NEXT` on **no** pad while a readable prompt is open, over 85 prompt frames. + +### Residuals, named rather than worked around + +- **Neither arm reaches rung 11, and the ROM test reports the rung rather than asserting it.** In + the ROM run the fly leaves the gym and then walks Pewter City and its buildings -- route + `[54, 2, 58, 2, 58, 2, 58, 2, 57, 2, 55, 2, 55, 2, 52, 2, 56, 2, 56, 2, 56, 2]`, `GO FRONTIER` + 501 of its 507 starts on map 2 -- and never goes back through the gym door. `GO OBJECTIVE` **is** + on the pad on maps `{2, 52, 54, 55, 56, 57, 58}`, so the road exists; what is not measured here is + why it is chosen five times in sixty-seven brain minutes while the town's frontier is chosen five + hundred. The objective's target is a *person* the fly has never faced and its first hop is a door + the reached ledger retires once it has been through, which is row 12.5's ground and row 54's, not + this row's. **It is the next brief.** +- **The watchdog cannot see this loop.** Check 10 flags on fewer than four distinct macros in the + window and this row is exactly four (`YES`, `NO`, `NEXT`, `TALK`); its dominance rule needs one + macro at 95% and the mix here is three ways even. Raising the threshold is a systems change with + its own false-positive cost and it is not made here. +- **`wTextBoxID` = `TWO_OPTION_MENU` agrees with the new reading on all 400 surveyed frames** and is + not in the accessor, for want of a survey of Red's other two-option menus. +- **The hunt's tile rule still cannot tell a long battle from a stall** -- fourth branch running. + +### Gates + +- `cargo test --workspace` with `FLY_ROM` and `FLY_DATASET` set: see the report. +- `cargo clippy --all-targets`: **0 warnings**. +- `infra/tests/lint.sh`: ALL CHECKS PASSED, de-PII guard included. +- `flysim --print-compatibility`: **648 bytes, sha256 `4929f340...9ebd9`** -- byte-identical to this + branch's base `2de2dce`. Decoder, reward catalog, adapter version and roles untouched. + From 9f47e7fd9c5259fc8019ac96c623b85ed2a0b2aa Mon Sep 17 00:00:00 2001 From: flybrain Date: Wed, 23 Sep 2026 01:22:01 +0000 Subject: [PATCH 5/5] tests: the ROM proof from the gym checkpoint, and answers counted per person Row 41 counted `YES` starts per map, which cannot tell one conversation from another; the Pewter Gym stall was 64 `YES` and 62 `NO` in ten brain minutes at one person. So the harness counts an answer to a box it can read as a choice, charged to whatever the fly is facing -- `palette::facing_target`, which is `TALK`'s own precondition and the same reading its ledger entry uses. `the_fly_leaves_the_pewter_gym_guides_ring_from_the_rung_ten_checkpoint`: the fly leaves map 54 on frame 1,337 against never in twenty brain minutes, spends 27 macros in the gym (one lap of the ring), answers the guide's prompt **once** against a bound of four, re-opens the ring **never**, and deals `NEXT` on no pad while a readable prompt is open. The rung is printed and not asserted: neither this run nor either arm of the trap hunt reaches rung 11 from this checkpoint, and why the fly does not walk back through the gym door is row 12.5's ground and row 54's rather than this row's. Named in the residuals. --- .../crates/flysim/tests/rom_macros_mode.rs | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/services/flysim/crates/flysim/tests/rom_macros_mode.rs b/services/flysim/crates/flysim/tests/rom_macros_mode.rs index 3e27575..6ea0be2 100644 --- a/services/flysim/crates/flysim/tests/rom_macros_mode.rs +++ b/services/flysim/crates/flysim/tests/rom_macros_mode.rs @@ -318,6 +318,14 @@ struct Run { talk_on_pad_by_map: std::collections::BTreeSet, /// `TALK` starts by map, for the record beside it. talk_starts_by_map: std::collections::BTreeMap, + /// How many times a readable YES/NO prompt was answered, by the map and the thing the fly was + /// facing when it answered: the bound row 56 is about. + /// + /// Row 41 counted `YES` starts per map, which cannot tell one conversation from another, and + /// the Pewter Gym stall was 64 `YES` and 62 `NO` in ten brain minutes at one person. Keyed by + /// what the prompt belongs to, because "this prompt has been answered before" is a fact about + /// the person asking rather than about the room. + answers_by_person: std::collections::BTreeMap<(u32, String), u32>, /// `GO FRONTIER` starts by map, for the museum (section 12.14). frontier_by_map: std::collections::BTreeMap, /// Where the macro that is running started, for the net-tiles measure below. @@ -444,6 +452,7 @@ impl Run { unknown_pads_with_no_box: 0, talk_on_pad_by_map: std::collections::BTreeSet::new(), talk_starts_by_map: std::collections::BTreeMap::new(), + answers_by_person: std::collections::BTreeMap::new(), frontier_by_map: std::collections::BTreeMap::new(), started_at: None, net_zero_streak: 0, @@ -561,6 +570,7 @@ impl Run { unknown_pads_with_no_box: 0, talk_on_pad_by_map: std::collections::BTreeSet::new(), talk_starts_by_map: std::collections::BTreeMap::new(), + answers_by_person: std::collections::BTreeMap::new(), frontier_by_map: std::collections::BTreeMap::new(), started_at: None, net_zero_streak: 0, @@ -719,6 +729,15 @@ impl Run { flybrain_gb::pokemon_red::state::yes_no_prompt(&mut self.gb) } + /// What the fly is facing, as `TALK`'s own precondition reads it: the key a prompt belongs to. + fn facing(&mut self) -> Option { + let ledger = AdapterLedger(&self.adapter); + let mut state = + flybrain_gb::pokemon_red::state::PokeState::with_ledger(&mut self.gb, &ledger); + flybrain_gb::pokemon_red::macros::palette::facing_target(&mut state) + .map(|target| format!("{target:?}")) + } + /// Whether the fly faces a Pokemon Center nurse with a party that does not need her. fn rested_nurse(&mut self) -> bool { let ledger = AdapterLedger(&self.adapter); @@ -933,6 +952,14 @@ impl Run { let map = self.map(); *self.talk_starts_by_map.entry(map).or_insert(0) += 1; } + // Row 56: an answer to a box this crate can *read* as a choice, charged to whoever is + // asking. An answer to a plain text box is not one of these -- it is the B or the A + // that advances a conversation, which is what those buttons are for. + if (name == "YES" || name == "NO") && self.yes_no_prompt() { + let map = self.map(); + let who = self.facing().unwrap_or_else(|| "nobody".to_string()); + *self.answers_by_person.entry((map, who)).or_insert(0) += 1; + } let (x, y) = self.tile(); self.started_at = Some((self.map(), x, y)); } @@ -2751,3 +2778,146 @@ fn the_fly_reaches_the_pewter_gym_from_the_rung_ten_checkpoint() { run.worst_net_zero_chain ); } + +/// The rung-10 Pewter Gym checkpoint, or `None` to skip. +fn gym_checkpoint() -> Option { + std::env::var_os("FLY_GYM_CHECKPOINT").map(|path| { + flysim::store::load(std::path::Path::new(&path)) + .expect("the checkpoint should be a FLYSIM01 envelope") + }) +} + +/// From inside the Pewter Gym: the fly gets out of the guide's conversation and wins the badge. +/// +/// **What was live** (2026-09-23, rank 10 PEWTER CITY): map 54, scene `dialog`, thirty-plus +/// brain minutes with the explore and wild-win counters frozen, **747 macro starts in ten brain +/// minutes**, `YES` 64 / `NO` 62 / `NEXT` 59 / `TALK` 6, and **no walk macro dealt at all**. The +/// watchdog did not flag it: four distinct macros is exactly its threshold. +/// +/// **What the survey found** (`infra/docs/macros-traps.md` row 56, and `examples/scene_probe.rs`'s +/// `FLY_PROBE_CATCH=dialog`): the gym guide's conversation is a **ring of fifty-two presses** that +/// ends in the overworld for a frame and reopens on the next A, exactly as row 41's nurse does -- +/// and two things kept the fly in it. +/// +/// - His YES/NO box ("Let me take you to the top!") is drawn at **(14, 7)-(19, 11)**, not the +/// (11, 6)-(19, 11) row 41 pinned, so `yes_no_prompt` read `false` on all 260 surveyed frames +/// while the box was drawn on ten. The pad was `NEXT, YES, NO` on a box that was a choice, and +/// the reopened-prompt exclusion never armed. +/// - Every `NO` un-armed the pending `TALK`, and about a third of the fifty-two presses are `NO`, +/// so the guide never entered the talked ledger and `TALK` was on the overworld pad every hold. +/// Answering `NO` at his prompt changes nothing either: the survey's `b` arm gets "It's a free +/// service! Let's get happening!" and the same tutorial. +/// +/// The claims, none of them about which button the fly presses: +/// +/// - **no readable prompt is answered more than four times for one person**, against 126 answers +/// at one person in ten brain minutes live; +/// - `NEXT` is on **no** pad while a readable prompt is open (12.10 in a dialog); +/// - the fly **leaves map 54**, on a bounded number of macros, and the gym's dialog stops being +/// the whole run. +/// +/// **What this does not claim, and says so rather than smoothing it**: the fly does *not* reach +/// rung 11 inside the budget, and it does not on either arm of the trap hunt either. What it does +/// instead is play -- 441 distinct tiles against 83, 49,877 frames of battle against 29,530, +/// `GO OBJECTIVE` 37 walks at a mean reach of 9.4 tiles against 19 at 5.0. Going back through the +/// gym's door to fight Brock is a question about `GO OBJECTIVE`'s aim and the errand order ahead of +/// the rung, which is row 54's ground and not this row's; the rung reached is printed here and +/// asserted by nobody. +/// +/// ```sh +/// FLY_ROM=/path/to/pokemon-red.gb \ +/// FLY_GYM_CHECKPOINT=.local/checkpoints/release-rank10-gym.checkpoint \ +/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture +/// ``` +#[test] +fn the_fly_leaves_the_pewter_gym_guides_ring_from_the_rung_ten_checkpoint() { + let rom = skip_without_rom!(); + let Some(checkpoint) = gym_checkpoint() else { + eprintln!("skipped: no FLY_GYM_CHECKPOINT"); + return; + }; + let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint); + let from = run.map(); + assert_eq!(from, PEWTER_GYM, "the checkpoint is the room the stream stalled in"); + + let mut left = None; + let mut badge = None; + for frame in 0..240_000u32 { + run.frame(); + if left.is_none() && run.map() != from { + left = Some(frame); + } + if badge.is_none() && run.adapter.progress().rank >= 11 { + badge = Some(frame); + } + } + let progress = run.adapter.progress(); + eprintln!( + "from map {from:#04x} in {:.1} brain minutes: route {:?}, macros {:?}, dialog frames {} \ + (prompt on {}), answers by person {:?}, TALK starts {:?}, rank {} ({}), badges {}", + run.ms / 60_000.0, + run.route, + run.started, + run.dialog_frames, + run.prompt_frames, + run.answers_by_person, + run.talk_starts_by_map, + progress.rank, + progress.rank_label, + run.gb.read_wram(flybrain_gb::pokemon_red::symbols::ram::wObtainedBadges).count_ones() + ); + eprintln!("macros spent in the gym: {:?}", run.started_on_the_first_map); + eprintln!( + "`GO OBJECTIVE` on the pad on maps {:?}; `GO FRONTIER` by map {:?}", + run.objective_on_pad, run.frontier_by_map + ); + + // 12.10 in a dialog: an A press at a two-option menu confirms the option the cursor is on, + // which is what `YES` is. Row 56 is the frames on which that rule could not be applied. + assert!( + !run.next_on_a_prompt, + "`NEXT` was on the pad at a YES/NO box, where an A press is `YES`" + ); + + // The bound row 56 is about: one person, one session. Live it was 126 at the guide in ten + // brain minutes. Four leaves room for the fly to answer a prompt it meets more than once + // without leaving room for a ring. + let worst = run.answers_by_person.iter().max_by_key(|(_, count)| **count); + if let Some((who, count)) = worst { + eprintln!("the most-answered prompt: {who:?} answered {count} times"); + assert!( + *count <= 4, + "one person's prompt was answered {count} times: {:?}", + run.answers_by_person + ); + } + + let Some(left) = left else { + panic!("the fly never left map {from:#04x}: {:?}", run.started) + }; + eprintln!( + "it left map {from:#04x} on frame {left} ({:.2} brain minutes), on {} macros", + f64::from(left) * MS_PER_FRAME / 60_000.0, + run.macros_on_the_first_map + ); + // One lap of the ring is fifty-two presses and the fly is entitled to walk one. What it may + // not do is walk it again and again: the live run was 747 macro starts in ten brain minutes. + assert!( + run.macros_on_the_first_map < 60, + "leaving the gym cost {} macros: {:?}", + run.macros_on_the_first_map, + run.started_on_the_first_map + ); + + // Reported, not asserted: the rung is row 54's ground, not this row's. + match badge { + Some(frame) => eprintln!( + "rung 11 at frame {frame} ({:.2} brain minutes)", + f64::from(frame) * MS_PER_FRAME / 60_000.0 + ), + None => eprintln!( + "rung 11 not reached inside the budget: rank {} ({})", + progress.rank, progress.rank_label + ), + } +}