From e2bf4c030588fe74e8525ffe576c3ffa694c77ca Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 22 Sep 2026 18:37:36 +0000 Subject: [PATCH] macros: the move list is the box on screen, not the cursor bytes it left behind Row 50: MOVE n reported blocked 890 times in 1,431 macros on the cartridge, every one of them on a frame the seam read as an open move list with a placeable cursor. MoveSelectionMenu writes wTopMenuItemY 12 and wTopMenuItemX 5 and nothing in the game clears them, exactly as the two-option box's geometry outlives its box. SelectMenuItem then decrements wCurrentMenuItem back to the 0-based slot on its way out, which lands straight back inside the one-based range the accessor reads. So the whole of a turn -- the text, the animation, the enemy's reply -- read as the fly's own turn on an open list, the pad dealt the four move buttons on it, and the cursor step pressed at a list nobody was reading until its budget ran out. The reading is the figure the menu draws, the same construction text_box's waiting and yes_no_prompt already make: a box at (4, 12) fourteen wide with a horizontal run over its top-left corner and the junction tile at (10, 12). Surveyed with one rollback pulse per battle frame over 3,102 frames at the rung-9 forest checkpoint: by the cursor bytes alone a real directional press was honoured on 264 of them, and by the cursor bytes and the box on 231 of 231. A frame whose list is not on screen is between turns, whose pad is the one NEXT that advances text. --- .../flybrain-gb/src/pokemon_red/fake_wram.rs | 31 ++++++- .../flybrain-gb/src/pokemon_red/state.rs | 74 ++++++++++++++- .../src/pokemon_red/state/tests.rs | 89 +++++++++++++++++++ 3 files changed, 190 insertions(+), 4 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 2bf4751..316cbb0 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 @@ -261,17 +261,42 @@ impl Wram { self.set(ram::wTextBoxID, poke::BATTLE_MENU_TEMPLATE).cursor(14, x, current, 1, keys) } - /// The move list, `MoveSelectionMenu`'s regular menu. `slot` is the 0-based move. + /// The move list, `MoveSelectionMenu`'s regular menu, open and accepting input. `slot` is the + /// 0-based move. + /// + /// Both halves, because since row 50 the seam reads both: the cursor bytes *and* the box the + /// menu draws. Use [`Self::move_menu_stale`] for the state a turn spends its text and animation + /// in, which is these bytes with no box on screen. pub fn move_menu(&mut self, slot: u8, moves: u8) -> &mut Self { + self.move_menu_stale(slot, moves).draw_move_list() + } + + /// The bytes `MoveSelectionMenu` wrote, with its box no longer on screen. + /// + /// Nothing in the game clears `wTopMenuItemY` / `wTopMenuItemX` / `wCurrentMenuItem`, so this + /// is what every frame of a turn's text, animation and reply reads back once a move has been + /// chosen (`infra/docs/macros-traps.md` row 50). Note that `SelectMenuItem` decrements + /// `wCurrentMenuItem` back to the 0-based slot on its way out, so `slot` here is one lower than + /// the slot the fly chose. + pub fn move_menu_stale(&mut self, slot: u8, moves: u8) -> &mut Self { self.set(ram::wNumMovesMinusOne, moves.saturating_sub(1)).cursor( - 12, - 5, + poke::MOVE_LIST_CURSOR_Y, + poke::MOVE_LIST_CURSOR_X, slot + 1, moves + 1, poke::pad::UP | poke::pad::DOWN | poke::pad::A, ) } + /// The figure `MoveSelectionMenu` draws: a box at (4, 12) with a horizontal run over its + /// top-left corner and the `┘` junction at (10, 12). + pub fn draw_move_list(&mut self) -> &mut Self { + let (left, top, right, bottom) = poke::MOVE_LIST_BOX; + self.draw_box(left, top, right, bottom) + .screen_tile(left, top, poke::frame::HORIZONTAL) + .screen_tile(poke::MOVE_LIST_JOIN, top, poke::frame::BOTTOM_RIGHT) + } + /// The party list. `forced` is the state `ChooseNextMon` leaves: A only, no way out. pub fn party_list(&mut self, current: u8, forced: bool) -> &mut Self { let count = self.peek(ram::wPartyCount).max(1); 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 0bd3e43..bdbbc6e 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,18 @@ pub mod poke { pub const YES_NO_CURSOR_Y: u8 = 8; pub const YES_NO_CURSOR_X: u8 = 12; + /// The move list's own box, and the junction tile in its top edge + /// (`infra/docs/macros-traps.md`, row 50). + /// + /// `MoveSelectionMenu`'s regular menu draws a `TextBoxBorder` at (4, 12) fourteen wide and + /// four tall, then writes a horizontal run over its top-left corner and a `┘` over (10, 12). + /// Values rather than symbols, like `YES_NO_BOX`: this is a figure on screen, not a byte. + pub const MOVE_LIST_BOX: (u16, u16, u16, u16) = (4, 12, 19, 17); + pub const MOVE_LIST_JOIN: u16 = 10; + /// Where `MoveSelectionMenu` parks the shared cursor: row 12, column 5. + pub const MOVE_LIST_CURSOR_Y: u8 = 12; + pub const MOVE_LIST_CURSOR_X: u8 = 5; + /// `constants/ram_constants.asm`: `wMiscFlags` bit 3. pub const BIT_USING_GENERIC_PC: u8 = 1 << 3; /// `wFontLoaded` bit 0. @@ -428,9 +440,22 @@ pub fn battle(memory: &mut dyn MemoryReader) -> Option { // round, so `ITEM` and `THROW BALL` opened the party list and `SWITCH` opened the bag. let column = if right { 2 } else { 0 }; BattleMenu::Main { cursor: column + cursor.current.min(1) } - } else if cursor.top_y == 12 && cursor.top_x == 5 { + } else if cursor.top_y == poke::MOVE_LIST_CURSOR_Y + && cursor.top_x == poke::MOVE_LIST_CURSOR_X + && move_list_drawn(memory) + { // MoveSelectionMenu's regular menu. Its list is one-based: `wCurrentMenuItem` is // `wPlayerMoveListIndex + 1` and `wMaxMenuItem` is the move count plus one. + // + // **Both halves are load-bearing** (row 50, 2026-09-22). The cursor bytes are written once + // and never cleared, so the geometry alone is true for the whole turn -- the text, the + // animation, the enemy's reply -- and `SelectMenuItem` decrements `wCurrentMenuItem` back + // to a 0-based slot as it leaves, which lands right back inside this accessor's one-based + // range. So a frame of battle text read as an open move list with a placeable cursor, the + // pad dealt `MOVE 1..4` on it, and the cursor step pressed at a list nobody was reading: + // `MOVE n` reported `blocked` 890 times in 1,431 macros. The box on screen is what says the + // list is up, and it is the same construction `text_box`'s `waiting` and `yes_no_prompt` + // already make. let count = read(memory, ram::wNumMovesMinusOne).saturating_add(1).min(4); let slot = cursor.current.checked_sub(1).filter(|slot| *slot < count); BattleMenu::Moves { cursor: slot, count } @@ -476,6 +501,10 @@ pub fn battle(memory: &mut dyn MemoryReader) -> Option { // still 0 rather than the one-based slot the menu keeps. Measured on the cartridge: a wild // Weedle's opening frame reads `Moves { cursor: None, count: 2 }` with `own: None`. That // frame is between turns, which is what it was before this change. + // + // A move list that is *not on screen* never reaches this arm at all since row 50: the menu + // above reads `None` for it, so the frame is between turns and its pad is the one `NEXT` + // that advances text (section 12.10). BattleMenu::Moves { cursor, .. } => cursor.is_some(), BattleMenu::Party { .. } => !forced_switch, // The bag is a list the fly opened *during* its turn, and it is a menu cursor accepting @@ -576,6 +605,49 @@ pub fn yes_no_prompt(memory: &mut dyn MemoryReader) -> bool { border_drawn(memory, left, top, right, bottom) } +/// Whether `MoveSelectionMenu`'s own box is the figure on screen (`infra/docs/macros-traps.md`, +/// row 50). +/// +/// The cursor bytes alone are not the move list. `wTopMenuItemY` 12 and `wTopMenuItemX` 5 are +/// written by `MoveSelectionMenu` and **nothing clears them**, exactly as the two-option box's +/// geometry outlives its box (`yes_no_prompt` above): the whole rest of the turn -- the text, the +/// animation, the damage, the enemy's reply -- reads back the same five bytes. Surveyed on the +/// cartridge over 1,878 battle frames at the rung-9 forest checkpoint, one rollback pulse per frame +/// (`examples/scene_probe.rs`, `FLY_PROBE_CATCH=accept`): with this box **not** drawn a real +/// directional press moved `wCurrentMenuItem` on 15 frames of 1,731, and with it drawn on 140 of +/// 147. The cursor geometry on its own is honoured on 155 of 1,878. +/// +/// The figure is `MoveSelectionMenu`'s regular menu and only it: a `TextBoxBorder` at (4, 12) +/// fourteen wide and four tall, with two tiles written over it afterwards -- the top-left corner +/// becomes a horizontal run and (10, 12) becomes the `┘` junction with the PP box above. The +/// mimic and relearn menus draw at row 7 and never reach a battle's own turn. Read whole, like +/// every other box in this module, because a single tile id is an ordinary character. +fn move_list_drawn(memory: &mut dyn MemoryReader) -> bool { + let (left, top, right, bottom) = poke::MOVE_LIST_BOX; + if screen_tile(memory, left, top) != poke::frame::HORIZONTAL + || screen_tile(memory, poke::MOVE_LIST_JOIN, top) != poke::frame::BOTTOM_RIGHT + || screen_tile(memory, right, top) != poke::frame::TOP_RIGHT + || screen_tile(memory, left, bottom) != poke::frame::BOTTOM_LEFT + || screen_tile(memory, right, bottom) != poke::frame::BOTTOM_RIGHT + { + return false; + } + for x in (poke::MOVE_LIST_JOIN + 1)..right { + if screen_tile(memory, x, top) != poke::frame::HORIZONTAL { + return false; + } + } + for x in (left + 1)..right { + if screen_tile(memory, x, bottom) != poke::frame::HORIZONTAL { + return false; + } + } + (top + 1..bottom).all(|y| { + screen_tile(memory, left, y) == poke::frame::VERTICAL + && screen_tile(memory, right, y) == poke::frame::VERTICAL + }) +} + /// The four screen tiles the dialogue box's `waiting` test reads, in the order `box_drawn` reads /// them: top-left, top-right, bottom-left, bottom-right of a box at (0, 12)-(19, 17). /// 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 463b488..61e5981 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 @@ -227,6 +227,95 @@ fn the_move_list_is_reported_zero_based() { assert_eq!(battle(&mut wram).unwrap().menu, BattleMenu::Moves { cursor: None, count: 3 }); } +/// Row 50: the move list is the box on screen, not the cursor bytes it left behind. +/// +/// `MoveSelectionMenu` writes `wTopMenuItemY` 12 and `wTopMenuItemX` 5 and **nothing in the game +/// clears them**, exactly as the two-option box's geometry outlives its box (`yes_no_prompt`). +/// `SelectMenuItem` then decrements `wCurrentMenuItem` back to the 0-based slot on its way out, +/// which lands straight back inside the one-based range this accessor reads -- so a frame of battle +/// text read as an open move list with a placeable cursor, the pad dealt `MOVE 1..4` on it, and the +/// cursor step pressed at a list nobody was reading until its budget ran out: `MOVE n` reported +/// `blocked` 890 times in 1,431 macros on the cartridge. +/// +/// Surveyed with one rollback pulse per battle frame (`examples/scene_probe.rs`, +/// `FLY_PROBE_CATCH=accept`, 3,102 frames at the rung-9 forest checkpoint): by the cursor bytes +/// alone a real directional press moved the cursor on 264 frames, and by the cursor bytes **and** +/// the box on screen on 231 of 231. +#[test] +fn a_move_list_is_the_box_on_screen_and_not_the_cursor_bytes_it_left_behind() { + let battler = |wram: &mut Wram| { + wram.party_mon(0, 4, 7, 14, 22, 0, &[(10, 35)]); + wram.battle_mon(0, 4, 7, 14, 22, 0, &[(10, 35), (45, 40), (33, 30)]) + .enemy_mon(19, 3, 5, 11) + .battle(1); + }; + + // The list drawn: the fly's own turn, on the slot the cursor is on. + let mut wram = Wram::overworld(); + battler(&mut wram); + wram.move_menu(2, 3); + let fight = battle(&mut wram).unwrap(); + assert_eq!(fight.menu, BattleMenu::Moves { cursor: Some(2), count: 3 }); + assert!(fight.own_turn, "a move list with its box on screen is the fly's turn"); + + // The same bytes with the box gone -- every frame of the turn's text, animation and reply. + // Not a move list at all, so not the own turn, so the pad is the between-turns `NEXT`. + let mut wram = Wram::overworld(); + battler(&mut wram); + wram.move_menu_stale(2, 3); + let fight = battle(&mut wram).unwrap(); + assert_eq!(fight.menu, BattleMenu::None, "the cursor bytes alone are not a move list"); + assert!(!fight.own_turn, "cursor bytes with no box are between turns"); + + // The exact shape row 50 was measured in: `SelectMenuItem` decrements on its way out, so the + // slot the fly chose reads back one lower and stays inside the one-based range for ever. + let mut wram = Wram::overworld(); + battler(&mut wram); + wram.move_menu(3, 3).set(ram::wCurrentMenuItem, 3); + assert!(battle(&mut wram).unwrap().own_turn, "with the box drawn this is a real slot"); + let mut wram = Wram::overworld(); + battler(&mut wram); + wram.move_menu_stale(3, 3).set(ram::wCurrentMenuItem, 3); + assert!(!battle(&mut wram).unwrap().own_turn, "the same byte, no box, no turn"); + + // Half a figure is not a box. The junction tile at (10, 12) is the one `MoveSelectionMenu` + // writes over its own border, and a run of horizontals there is an ordinary text box. + let mut wram = Wram::overworld(); + battler(&mut wram); + wram.move_menu(1, 3).screen_tile(poke::MOVE_LIST_JOIN, 12, poke::frame::HORIZONTAL); + assert_eq!(battle(&mut wram).unwrap().menu, BattleMenu::None, "the junction tile is read"); + + // And the pads the two frames are dealt, which is what row 50 costs: the four move buttons and + // `BACK` where a list is open (section 13.1), and the one `NEXT` that advances text where the + // turn is resolving (section 12.10). + 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(); + battler(&mut wram); + wram.move_menu(1, 3); + assert_eq!( + pad(&mut wram), + vec![ + MacroKind::Move1, + MacroKind::Move2, + MacroKind::Move3, + MacroKind::Move4, + MacroKind::Back + ], + "an open move list" + ); + + let mut wram = Wram::overworld(); + battler(&mut wram); + wram.move_menu_stale(1, 3); + assert_eq!(pad(&mut wram), vec![MacroKind::Next], "the same bytes with no box on screen"); +} + #[test] fn a_forced_switch_is_the_party_list_that_cannot_be_cancelled() { let mut wram = Wram::overworld();