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:
parent
2987b243f4
commit
e2bf4c0305
3 changed files with 190 additions and 4 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue