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.
This commit is contained in:
dev 2026-09-22 18:37:36 +00:00
parent 2987b243f4
commit e2bf4c0305
3 changed files with 190 additions and 4 deletions

View file

@ -261,17 +261,42 @@ impl Wram {
self.set(ram::wTextBoxID, poke::BATTLE_MENU_TEMPLATE).cursor(14, x, current, 1, keys) 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 { 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( self.set(ram::wNumMovesMinusOne, moves.saturating_sub(1)).cursor(
12, poke::MOVE_LIST_CURSOR_Y,
5, poke::MOVE_LIST_CURSOR_X,
slot + 1, slot + 1,
moves + 1, moves + 1,
poke::pad::UP | poke::pad::DOWN | poke::pad::A, 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. /// 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 { pub fn party_list(&mut self, current: u8, forced: bool) -> &mut Self {
let count = self.peek(ram::wPartyCount).max(1); let count = self.peek(ram::wPartyCount).max(1);

View file

@ -87,6 +87,18 @@ pub mod poke {
pub const YES_NO_CURSOR_Y: u8 = 8; pub const YES_NO_CURSOR_Y: u8 = 8;
pub const YES_NO_CURSOR_X: u8 = 12; 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. /// `constants/ram_constants.asm`: `wMiscFlags` bit 3.
pub const BIT_USING_GENERIC_PC: u8 = 1 << 3; pub const BIT_USING_GENERIC_PC: u8 = 1 << 3;
/// `wFontLoaded` bit 0. /// `wFontLoaded` bit 0.
@ -428,9 +440,22 @@ pub fn battle(memory: &mut dyn MemoryReader) -> Option<Battle> {
// round, so `ITEM` and `THROW BALL` opened the party list and `SWITCH` opened the bag. // round, so `ITEM` and `THROW BALL` opened the party list and `SWITCH` opened the bag.
let column = if right { 2 } else { 0 }; let column = if right { 2 } else { 0 };
BattleMenu::Main { cursor: column + cursor.current.min(1) } 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 // MoveSelectionMenu's regular menu. Its list is one-based: `wCurrentMenuItem` is
// `wPlayerMoveListIndex + 1` and `wMaxMenuItem` is the move count plus one. // `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 count = read(memory, ram::wNumMovesMinusOne).saturating_add(1).min(4);
let slot = cursor.current.checked_sub(1).filter(|slot| *slot < count); let slot = cursor.current.checked_sub(1).filter(|slot| *slot < count);
BattleMenu::Moves { cursor: slot, count } BattleMenu::Moves { cursor: slot, count }
@ -476,6 +501,10 @@ pub fn battle(memory: &mut dyn MemoryReader) -> Option<Battle> {
// still 0 rather than the one-based slot the menu keeps. Measured on the cartridge: a wild // 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 // 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. // 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::Moves { cursor, .. } => cursor.is_some(),
BattleMenu::Party { .. } => !forced_switch, BattleMenu::Party { .. } => !forced_switch,
// The bag is a list the fly opened *during* its turn, and it is a menu cursor accepting // 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) 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 /// 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). /// them: top-left, top-right, bottom-left, bottom-right of a box at (0, 12)-(19, 17).
/// ///

View file

@ -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 }); 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] #[test]
fn a_forced_switch_is_the_party_list_that_cannot_be_cancelled() { fn a_forced_switch_is_the_party_list_that_cannot_be_cancelled() {
let mut wram = Wram::overworld(); let mut wram = Wram::overworld();