flybrain/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs
dev 9501a5a17c macros: the survey numbers in the accessor are the corrected pulse's
The first pass of the press survey counted 187 refusals that were its own held
button: JoypadLowSensitivity acts on a key's edge, so a direction the fly was
already holding produced no press. With the pulse releasing first, the reading
is 264 honoured of 3,102 by the cursor bytes alone and 231 of 231 by the bytes
and the box.
2026-09-22 19:09:42 +00:00

1690 lines
79 KiB
Rust

//! Typed accessors over Pokémon Red's WRAM, for the macro palette.
//!
//! `docs/design/macros.md` section 8 is the contract and `docs/design/macros-wram.md` is the
//! table: every symbol, its address at the pinned pokered commit, its encoding, and how it was
//! verified. This module turns those bytes into the types in
//! [`crate::pokemon_red::macros::state`], and nothing else: it decides nothing, presses nothing
//! and caches nothing beyond the reader it was handed.
//!
//! Every accessor exists twice on purpose. The free functions take a `&mut dyn MemoryReader`, so
//! [`crate::pokemon_red::scene::detect`] and the tests can call one without building anything;
//! [`PokeState`] wraps a reader and implements [`GameState`] on top of the same functions, which
//! is what agent B's executor holds. There is one implementation of each rule.
//!
//! ## What is *not* here
//!
//! Anything that needs the cartridge's ROM tables: the type chart, base powers, item prices,
//! species names. "Best damaging move with type effectiveness" (`macros.md` section 3) is the
//! executor's, and it needs data this module cannot reach — a `MemoryReader` reads the CPU bus,
//! where ROM banks 1 and up are whatever the last bank switch left mapped. The one ROM read this
//! module does make is safe for exactly that reason: the tileset collision lists all live in bank
//! 0, which is always mapped. See "The walkable predicate and its window" in
//! `docs/design/macros-wram.md`.
use crate::adapter::{MapEdge, MapExit, MapTile, MemoryReader};
use crate::macros::{RunLedger, NoLedger};
use super::macros::cartridge::{
AreaLedger, Edge, ExitId, FrontierLedger, MacroState, NoAreas, NoFrontiers, NoPushed, NoStood,
NoTalk, NoTargets, Objective, PushedLedger, StoodLedger, TalkLedger, TalkTarget, TargetKey,
TargetLedger, Tile,
};
use super::macros::geography::Amenity;
use super::mapgrid::{self, MapGrids};
use super::macros::state::{
BagItem, Battle, BattleKind, BattleMenu, Connections, Cursor, EnemyMon, Facing, GameState,
MapGrid, MapSize, Mon, Move, Npc, Party, Pc, Player, Scene, Shop, ShopScreen, Sign, StartMenu,
Status, TextBox, Walkable, Warp,
};
use super::symbols::ram;
/// Constants from the disassembly at [`super::symbols::POKERED_COMMIT`]. Values, not addresses:
/// `gen_symbols.py` owns the addresses and refuses to be hand-edited, but these are `EQU`s and
/// `const`s that no symbol table carries, so each one names its file.
pub mod poke {
/// `constants/hardware.inc`.
pub mod pad {
pub const A: u8 = 1 << 0;
pub const B: u8 = 1 << 1;
pub const SELECT: u8 = 1 << 2;
pub const START: u8 = 1 << 3;
pub const RIGHT: u8 = 1 << 4;
pub const LEFT: u8 = 1 << 5;
pub const UP: u8 = 1 << 6;
pub const DOWN: u8 = 1 << 7;
}
/// `constants/charmap.asm`: the text box frame, which is how a drawn box is recognised.
pub mod frame {
pub const TOP_LEFT: u8 = 0x79;
pub const HORIZONTAL: u8 = 0x7a;
pub const TOP_RIGHT: u8 = 0x7b;
pub const VERTICAL: u8 = 0x7c;
pub const BOTTOM_LEFT: u8 = 0x7d;
pub const BOTTOM_RIGHT: u8 = 0x7e;
}
/// `constants/menu_constants.asm` text box ids.
pub const BATTLE_MENU_TEMPLATE: u8 = 0x0b;
pub const BUY_SELL_QUIT_MENU: u8 = 0x15;
/// `constants/list_constants.asm` list menu ids.
pub const PRICED_ITEM_LIST_MENU: u8 = 0x02;
pub const ITEM_LIST_MENU: u8 = 0x03;
pub const SPECIAL_LIST_MENU: u8 = 0x04;
/// `constants/menu_constants.asm` party menu types.
pub const BATTLE_PARTY_MENU: u8 = 0x02;
/// The two-option YES/NO box, as surveyed on the cartridge (`infra/docs/macros-traps.md`,
/// row 41): the border `DisplayTwoOptionMenu` draws, and where it parks the cursor.
///
/// Values rather than a symbol because `wTwoOptionMenuID` is not in the reviewed address list
/// and the box's geometry is what is on screen. Read from the rung-10 Pokemon Center
/// checkpoint, one raw A pulse at a time: a box at (11, 6)-(19, 11) with the cursor at
/// row 8, column 12, one item below the first, watching A and B.
pub const YES_NO_BOX: (u16, u16, u16, u16) = (11, 6, 19, 11);
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.
pub const BIT_FONT_LOADED: u8 = 1 << 0;
/// `wStatusFlags6` bit 0, set once when the game starts and never cleared.
pub const BIT_GAME_TIMER_COUNTING: u8 = 1 << 0;
/// `wStatusFlags5` bits 0, 5 and 7: scripted NPC movement, joypad disabled, scripted movement.
pub const SCRIPTED_STATUS5: u8 = 0xa1;
/// `wStatusFlags6` bits 2, 3, 4 and 6: fly, dungeon and escape warps in flight.
pub const SCRIPTED_STATUS6: u8 = 0x5c;
/// `wMovementFlags` bits 6 and 7: a ledge hop and a spin tile. The door bits (0, 1, 2) are
/// deliberately *not* here: standing on a doormat is an ordinary overworld state, and it is the
/// one `docs/design/room-escape.md` cares most about.
pub const SCRIPTED_MOVEMENT: u8 = 0xc0;
/// `constants/battle_constants.asm`: the non-volatile status byte.
pub const SLP_MASK: u8 = 0b111;
pub const PSN: u8 = 3;
pub const BRN: u8 = 4;
pub const FRZ: u8 = 5;
pub const PAR: u8 = 6;
/// `constants/pokemon_data_constants.asm`: `PARTYMON_STRUCT_LENGTH`.
pub const PARTY_MON_BYTES: u16 = 0x2c;
/// `constants/menu_constants.asm`: `BAG_ITEM_CAPACITY`.
pub const BAG_CAPACITY: u8 = 20;
/// `ram/wram.asm`: `wItemList:: ds 16`, which bounds the open mart's inventory.
///
/// One count byte, then the ids, then `$ff`, so at most fourteen items can be both counted
/// and terminated inside the buffer. The real marts carry four to nine.
pub const MART_LIST_BYTES: u8 = 16;
/// `data/tilesets/tileset_headers.asm`: three counter tile ids per tileset, `-1` for none.
pub const COUNTER_TILES: u16 = 3;
/// The `-1` a tileset with fewer than three counter tiles pads its header with.
pub const NO_COUNTER_TILE: u8 = 0xff;
/// `constants/sprite_constants.asm`: the two people who stand behind a counter.
///
/// Picture ids, which is what `wSpriteStateData1`'s byte 0 holds -- the same numbering
/// `FIRST_STILL_SPRITE` is compared against, so a clerk and a nurse are identifiable from the
/// sprite table alone and nothing has to guess which person a shop's person is.
pub const SPRITE_CLERK: u8 = 0x26;
pub const SPRITE_NURSE: u8 = 0x29;
/// `constants/map_data_constants.asm`: `MAX_WARP_EVENTS`.
pub const MAX_WARPS: u8 = 32;
/// `constants/map_data_constants.asm`: `MAX_BG_EVENTS`, which bounds the sign table.
pub const MAX_SIGNS: u8 = 16;
/// `constants/map_object_constants.asm`: `NUM_SPRITESTATEDATA_STRUCTS` and the struct length.
pub const SPRITE_SLOTS: u8 = 16;
pub const SPRITE_BYTES: u16 = 16;
/// `MACRO object_event` stores map coordinates plus four.
pub const SPRITE_COORD_BIAS: u8 = 4;
/// `constants/map_data_constants.asm`: `wCurMapConnections` bits.
pub const CONNECTION_EAST: u8 = 1;
pub const CONNECTION_WEST: u8 = 2;
pub const CONNECTION_SOUTH: u8 = 4;
pub const CONNECTION_NORTH: u8 = 8;
/// The highest real map id (`constants/map_constants.asm` ends at `$f7`), which is the same
/// bound the reward adapter uses to reject a half-loaded frame.
pub const MAX_MAP_ID: u8 = 0xf7;
/// The screen's tile buffer is `SCREEN_WIDTH` x `SCREEN_HEIGHT`
/// (`constants/gfx_constants.asm`).
pub const SCREEN_WIDTH: u16 = 20;
pub const SCREEN_HEIGHT: u16 = 18;
/// Where the player's own tile sits in that buffer. `_GetTileAndCoordsInFrontOfPlayer` reads
/// `(8, 9)` for the tile it stands on and `(8, 11)`, `(8, 7)`, `(6, 9)`, `(10, 9)` for the
/// four neighbours, so one map tile is two screen tiles in each axis and the player is at
/// this fixed point.
pub const PLAYER_SCREEN_X: i32 = 8;
pub const PLAYER_SCREEN_Y: i32 = 9;
}
fn read(memory: &mut dyn MemoryReader, address: u16) -> u8 {
memory.read8(address)
}
/// A big-endian 16-bit quantity, which is how the cartridge stores HP.
fn word_be(memory: &mut dyn MemoryReader, address: u16) -> u16 {
u16::from(read(memory, address)) * 256 + u16::from(read(memory, address + 1))
}
/// One byte of the screen's tile buffer.
fn screen_tile(memory: &mut dyn MemoryReader, x: u16, y: u16) -> u8 {
if x >= poke::SCREEN_WIDTH || y >= poke::SCREEN_HEIGHT {
return 0;
}
read(memory, ram::wTileMap + y * poke::SCREEN_WIDTH + x)
}
/// Whether the *whole* `TextBoxBorder` is drawn: the four corners, the horizontal runs along the
/// top and bottom rows, and the vertical runs down both sides.
///
/// `TextBoxBorder` (`home/text_box.asm`) draws exactly this — `$79` then `$7a` x width then `$7b`,
/// `$7c` down each side, `$7d` then `$7a` x width then `$7e` — so a real box satisfies all of it
/// and four map tiles that happen to hold frame ids satisfy only [`box_drawn`]. The corners are
/// 4 bytes out of a 2·(width+height) byte figure; this is the rest of it, and it is what tells a
/// text box from a map.
fn border_drawn(
memory: &mut dyn MemoryReader,
left: u16,
top: u16,
right: u16,
bottom: u16,
) -> bool {
if !box_drawn(memory, left, top, right, bottom) {
return false;
}
for x in (left + 1)..right {
if screen_tile(memory, x, top) != poke::frame::HORIZONTAL
|| screen_tile(memory, x, bottom) != poke::frame::HORIZONTAL
{
return false;
}
}
for y in (top + 1)..bottom {
if screen_tile(memory, left, y) != poke::frame::VERTICAL
|| screen_tile(memory, right, y) != poke::frame::VERTICAL
{
return false;
}
}
true
}
/// Whether a `TextBoxBorder` box is drawn with these corners. Four corners rather than one,
/// because a map tile can legitimately hold the frame's tile id and two of them cannot.
fn box_drawn(memory: &mut dyn MemoryReader, left: u16, top: u16, right: u16, bottom: u16) -> bool {
screen_tile(memory, left, top) == poke::frame::TOP_LEFT
&& 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
}
/// Whether the game has started at all: `wStatusFlags6`'s game-timer bit, which `MainMenu` sets
/// for a new game and for a continue and nothing ever clears. False on the title screen, through
/// the intro and on the naming screens.
///
/// This is the same gate the reward adapter calls `active`, so [`Scene::Title`] and the adapter's
/// `BOOT` mode agree by construction.
pub fn started(memory: &mut dyn MemoryReader) -> bool {
read(memory, ram::wStatusFlags6) & poke::BIT_GAME_TIMER_COUNTING != 0
}
/// Whether the player's buttons reach the player: no ignored joypad, no simulated input, no
/// scripted movement, no warp in flight, not mid-ledge-hop.
///
/// The masks are the reward adapter's own scripted gate, minus the door bits — see
/// [`poke::SCRIPTED_MOVEMENT`].
pub fn controllable(memory: &mut dyn MemoryReader) -> bool {
read(memory, ram::wJoyIgnore) == 0
&& read(memory, ram::wSimulatedJoypadStatesIndex) == 0
&& read(memory, ram::wStatusFlags5) & poke::SCRIPTED_STATUS5 == 0
&& read(memory, ram::wStatusFlags6) & poke::SCRIPTED_STATUS6 == 0
&& read(memory, ram::wMovementFlags) & poke::SCRIPTED_MOVEMENT == 0
}
/// The current map's size in walkable tiles, or `None` on a frame whose map header is not loaded.
///
/// `wCurMapWidth` and `wCurMapHeight` are in blocks and one block is two tiles each way, which is
/// the conversion the reward adapter already makes.
pub fn map_size(memory: &mut dyn MemoryReader) -> Option<MapSize> {
if read(memory, ram::wCurMap) > poke::MAX_MAP_ID {
return None;
}
let width = u16::from(read(memory, ram::wCurMapWidth)) * 2;
let height = u16::from(read(memory, ram::wCurMapHeight)) * 2;
if width == 0 || height == 0 || width > 255 || height > 255 {
return None;
}
Some(MapSize { width: width as u8, height: height as u8 })
}
fn facing_from(byte: u8) -> Facing {
// `constants/sprite_data_constants.asm`: SPRITE_FACING_DOWN/UP/LEFT/RIGHT.
match byte & 0x0c {
0x04 => Facing::Up,
0x08 => Facing::Left,
0x0c => Facing::Right,
_ => Facing::Down,
}
}
/// Where the player is standing, or `None` when the coordinates are not inside a loaded map.
pub fn player(memory: &mut dyn MemoryReader) -> Option<Player> {
let size = map_size(memory)?;
let map = read(memory, ram::wCurMap);
let x = read(memory, ram::wXCoord);
let y = read(memory, ram::wYCoord);
if x >= size.width || y >= size.height {
return None;
}
// Sprite slot 0 is the player; byte 9 of its wSpriteStateData1 struct is its facing.
let facing = facing_from(read(memory, ram::wSpriteStateData1 + 9));
Some(Player { map, x, y, facing })
}
fn status_from(byte: u8) -> Status {
if byte & poke::SLP_MASK != 0 {
return Status::Sleep(byte & poke::SLP_MASK);
}
if byte & (1 << poke::PSN) != 0 {
return Status::Poison;
}
if byte & (1 << poke::BRN) != 0 {
return Status::Burn;
}
if byte & (1 << poke::FRZ) != 0 {
return Status::Freeze;
}
if byte & (1 << poke::PAR) != 0 {
return Status::Paralysis;
}
Status::Healthy
}
/// One `party_struct`'s four move slots and their PP.
///
/// The PP byte packs remaining PP in bits 0 to 5 and the number of PP Ups in bits 6 and 7
/// (`data/moves/moves.asm` and `AddPP`), so neither can be read without the other.
fn moves_at(memory: &mut dyn MemoryReader, ids: u16, pps: u16) -> [Option<Move>; 4] {
let mut moves = [None; 4];
for (slot, move_slot) in moves.iter_mut().enumerate() {
let id = read(memory, ids + slot as u16);
if id == 0 {
continue;
}
let pp = read(memory, pps + slot as u16);
*move_slot = Some(Move { id, pp: pp & 0x3f, pp_up: pp >> 6 });
}
moves
}
/// The player's party, in slot order, empty before the starter or on an unreadable frame.
///
/// `active` is filled only during a battle, from `wPlayerMonNumber`.
///
/// `wPartyCount` leads the structs it counts: `AddPartyMon` writes the count and then fills the
/// 44 bytes over the following frames, so a member can read back with species 0 and no HP. That
/// is reported rather than hidden — nothing here can tell a half-written member from a real one
/// without inventing a rule — and both the trait's documentation and
/// `docs/design/macros-wram.md` say so.
pub fn party(memory: &mut dyn MemoryReader) -> Party {
let count = read(memory, ram::wPartyCount);
if count > 6 {
return Party::default();
}
let mut mons = Vec::with_capacity(count as usize);
for slot in 0..count {
let base = ram::wPartyMon1 + u16::from(slot) * poke::PARTY_MON_BYTES;
mons.push(Mon {
slot,
species: read(memory, base),
level: read(memory, base + 33),
hp: word_be(memory, base + 1),
max_hp: word_be(memory, base + 34),
status: status_from(read(memory, base + 4)),
moves: moves_at(memory, base + 8, base + 29),
});
}
let active = in_battle(memory)
.is_some()
.then(|| read(memory, ram::wPlayerMonNumber))
.filter(|slot| *slot < count);
Party { mons, active }
}
/// Which kind of battle is running, or `None`.
///
/// `wIsInBattle` is 0 outside a battle, 1 for a wild Pokémon, 2 for a trainer and `$ff` on the
/// frame a battle is lost. The Safari Zone and the old man's tutorial (`wBattleType`) have their
/// own menus, so they are not battles this module claims to understand.
fn in_battle(memory: &mut dyn MemoryReader) -> Option<BattleKind> {
if read(memory, ram::wBattleType) != 0 {
return None;
}
match read(memory, ram::wIsInBattle) {
1 => Some(BattleKind::Wild),
2 => Some(BattleKind::Trainer),
_ => None,
}
}
/// `HandleMenuInput`'s state, whichever menu is up.
pub fn cursor(memory: &mut dyn MemoryReader) -> Cursor {
Cursor {
current: read(memory, ram::wCurrentMenuItem),
max: read(memory, ram::wMaxMenuItem),
top_y: read(memory, ram::wTopMenuItemY),
top_x: read(memory, ram::wTopMenuItemX),
watched_keys: read(memory, ram::wMenuWatchedKeys),
}
}
/// Whether the party list is the menu that is up.
///
/// `PartyMenuInit` is the only thing in the game that puts the first item at row 1, column 0 with
/// a maximum of `wPartyCount - 1`, and it watches A and B — or A alone, when
/// `wForcePlayerToChooseMon` said the player may not back out.
fn party_list(memory: &mut dyn MemoryReader) -> bool {
let cursor = cursor(memory);
let count = read(memory, ram::wPartyCount);
count > 0
&& count <= 6
&& cursor.top_y == 1
&& cursor.top_x == 0
&& cursor.max == count - 1
&& (cursor.watched_keys == poke::pad::A | poke::pad::B
|| cursor.watched_keys == poke::pad::A)
}
/// The battle, when one is running.
pub fn battle(memory: &mut dyn MemoryReader) -> Option<Battle> {
let kind = in_battle(memory)?;
let cursor = cursor(memory);
let text_box_id = read(memory, ram::wTextBoxID);
// The top-level menu: DisplayBattleMenu draws BATTLE_MENU_TEMPLATE, then parks the cursor at
// row 14 in the left column (x 9, watching RIGHT and A) or the right column (x 15, watching
// LEFT and A) with one item per column.
let left = cursor.top_x == 9 && cursor.watched_keys == poke::pad::RIGHT | poke::pad::A;
let right = cursor.top_x == 15 && cursor.watched_keys == poke::pad::LEFT | poke::pad::A;
let main = text_box_id == poke::BATTLE_MENU_TEMPLATE
&& cursor.top_y == 14
&& cursor.max == 1
&& (left || right);
let menu = if main {
// **FIGHT and ITEM are the left column, PKMN and RUN the right.** The screen reads
// `FIGHT PKMN` over `ITEM RUN` and the game's index is by column: `wCurrentMenuItem` is
// the row inside the column the cursor is in, and `.rightColumn` adds two to it on
// selection -- so the order is FIGHT, ITEM, PKMN, RUN. Surveyed on the cartridge
// 2026-09-22 (`infra/docs/macros-traps.md`): A at `wTopMenuItemX` 15 with
// `wCurrentMenuItem` 0 opens the **party** list and the game then writes
// `wCurrentMenuItem` 2. `macros::cartridge::battle_entry` had this pair the other way
// 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 == 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 }
} else if party_list(memory) {
BattleMenu::Party { cursor: cursor.current }
} else if read(memory, ram::wListMenuID) == poke::ITEM_LIST_MENU {
// The bag, opened from the battle menu's ITEM entry. `DisplayListMenuID` keeps its
// position in the same shared cursor every other menu uses, and the entry count is the
// bag's own, so the scripts that reach into it (`ITEM`, `THROW BALL`) navigate by reading
// rather than by counting presses -- which is what section 4 requires of them and what
// they could not do while this read as no list at all.
let count = bag(memory).len().min(usize::from(poke::BAG_CAPACITY));
BattleMenu::Bag { cursor: cursor.current, count: u8::try_from(count).unwrap_or(0) }
} else {
BattleMenu::None
};
// A forced switch is the party list that `ChooseNextMon` opens: it is the only battle path
// that sets BATTLE_PARTY_MENU, where choosing PKMN from the menu above sets
// NORMAL_PARTY_MENU.
let forced_switch = matches!(menu, BattleMenu::Party { .. })
&& read(memory, ram::wPartyMenuTypeOrMessageID) == poke::BATTLE_PARTY_MENU;
let own = own_mon(memory);
// **The fly's turn is any menu of the battle that is accepting input**, not only the top-level
// one (2026-09-17, live: an hour and forty-one minutes in Viridian Forest).
//
// `own_turn` used to be `Main` alone, so a frame with the move list open read as "between
// turns" — and the between-turns pad is one `NEXT`, an A press on whatever the cursor happens
// to be sitting on. The cursor sits on TACKLE, TACKLE was out of PP, the game said so, the box
// closed, the list came back, and `NEXT` pressed A again: `NEXT start`/`NEXT done` every
// 268 brain milliseconds for over an hour, with a Kakuna in front of it and Bulbasaur at 8/28.
//
// A list that is accepting input is the game waiting for the player to choose, which is what a
// turn *is*. The party list is the exception that proves it: opened by `ChooseNextMon` after a
// faint it cannot be cancelled and is a forced switch, which has its own pad; opened by
// choosing PKMN it is an ordinary part of the fly's turn.
let own_turn = match menu {
BattleMenu::Main { .. } => true,
// A move list whose cursor the seam cannot place is not a list accepting input: the
// coordinates `MoveSelectionMenu` uses appear on the first frames of a battle, before the
// engine has copied the active Pokémon into `wBattleMon*` and while `wCurrentMenuItem` is
// 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
// input, so by the rule above it is the fly's turn (2026-09-22, section 12.10). Reading it
// as nobody's turn put it on the between-turns row, whose one button is the `NEXT` that
// advances *text* -- and on an open bag that same A press *uses* whatever the cursor
// happens to be sitting on. The pad that belongs to a bag is the bag's own three answers,
// `ITEM`, `THROW BALL` and `BACK`, which is what `palette::scene_set` deals here now.
BattleMenu::Bag { .. } => true,
BattleMenu::None => false,
};
Some(Battle {
kind,
own_turn,
forced_switch,
menu,
own,
enemy: enemy_mon(memory),
})
}
/// The Pokémon that is out, read from the battle engine's own copy — the copy it damages.
fn own_mon(memory: &mut dyn MemoryReader) -> Option<Mon> {
let species = read(memory, ram::wBattleMonSpecies);
if species == 0 {
return None;
}
Some(Mon {
slot: read(memory, ram::wPlayerMonNumber),
species,
level: read(memory, ram::wBattleMonLevel),
hp: word_be(memory, ram::wBattleMonHP),
max_hp: word_be(memory, ram::wBattleMonMaxHP),
status: status_from(read(memory, ram::wBattleMonStatus)),
moves: moves_at(memory, ram::wBattleMonMoves, ram::wBattleMonPP),
})
}
fn enemy_mon(memory: &mut dyn MemoryReader) -> Option<EnemyMon> {
let species = read(memory, ram::wEnemyMonSpecies);
if species == 0 {
return None;
}
Some(EnemyMon {
species,
level: read(memory, ram::wEnemyMonLevel),
hp: word_be(memory, ram::wEnemyMonHP),
max_hp: word_be(memory, ram::wEnemyMonMaxHP),
})
}
/// Whether a text box is open, and whether the bottom-of-screen dialogue box is the one drawn.
///
/// `open` is `wFontLoaded`'s bit 0, which `DisplayTextIDInit` sets for every text display — the
/// start menu included — and `CloseTextDisplay` clears. It is the WRAM half, and it is the gate:
/// `waiting` is never true without it.
///
/// `waiting` is the box itself: `DisplayTextIDInit` draws a border at screen (0, 12) spanning the
/// full width, rows 12 to 17, for any text id but the start menu's. It is checked as the **whole**
/// `TextBoxBorder` — corners, both horizontal runs and both vertical runs — and not as the four
/// corners alone, because the frame's tile ids are ordinary map tiles in the overworld tilesets and
/// the four corner positions do hold them: **315 frames of 43,004** in the 2026-09-17 reproduction
/// had all four corners drawn with the font flag clear (`infra/docs/macros-traps.md`). Nothing came
/// of it there, because `open` was false on every one of those frames — but four bytes is a thin
/// thing to hang a scene on when the figure the game draws is seventy-six.
pub fn text_box(memory: &mut dyn MemoryReader) -> TextBox {
let open = read(memory, ram::wFontLoaded) & poke::BIT_FONT_LOADED != 0;
TextBox { open, waiting: open && border_drawn(memory, 0, 12, 19, 17) }
}
/// Whether the two-option YES/NO box is the thing on screen: a *choice*, not a plain text box.
///
/// `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).
///
/// **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.
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
{
return false;
}
let (left, top, right, bottom) = poke::YES_NO_BOX;
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 3,102 battle frames at the rung-9 forest checkpoint, one rollback pulse per frame
/// (`examples/scene_probe.rs`, `FLY_PROBE_CATCH=accept`): by the cursor geometry alone a real
/// directional press moved `wCurrentMenuItem` on **264** of them, and by the geometry **and** this
/// box on **231 of 231**. With the box not drawn, 33 of 2,871 -- and those thirty-three are frames
/// where the pulse's own thirty were long enough for the cartridge to open something by itself.
///
/// 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).
///
/// Exposed for the diagnostics ([`super::scene::why_unknown`], `examples/scene_probe.rs`), because
/// they are the one input of the dialog branch that is not a WRAM flag: a map tile can hold a
/// frame tile id, and what tells a real box from four map tiles that look like one is these four
/// bytes read beside `wFontLoaded`.
/// How much of the dialogue box's border is actually on screen: `(corners, whole border)`.
///
/// The two answers the diagnostics compare. `corners` is the test [`text_box`]'s `waiting` has
/// always made; `whole` is [`border_drawn`]. A frame with `corners` and not `whole` is four map
/// tiles wearing a text box's clothes.
pub fn dialog_border(memory: &mut dyn MemoryReader) -> (bool, bool) {
(box_drawn(memory, 0, 12, 19, 17), border_drawn(memory, 0, 12, 19, 17))
}
pub fn dialog_corners(memory: &mut dyn MemoryReader) -> [u8; 4] {
[
screen_tile(memory, 0, 12),
screen_tile(memory, 19, 12),
screen_tile(memory, 0, 17),
screen_tile(memory, 19, 17),
]
}
/// The start menu, when it is open.
///
/// `DrawStartMenu` puts a box at screen (10, 0) — fourteen rows tall with the Pokédex entry,
/// twelve without — and parks the cursor at row 2, column 11. Both are checked: the geometry alone
/// survives the menu closing, and a frame of the map alone can hold a frame tile id.
pub fn start_menu(memory: &mut dyn MemoryReader) -> Option<StartMenu> {
if read(memory, ram::wFontLoaded) & poke::BIT_FONT_LOADED == 0 {
return None;
}
let cursor = cursor(memory);
if cursor.top_y != 2 || cursor.top_x != 11 {
return None;
}
// With the Pokédex the border's `b` is $0e, without it $0c, and TextBoxBorder's bottom row is
// `b + 1` below the top: row 15 or row 13. The whole border rather than its corners, for the
// reason [`text_box`] gives.
if !border_drawn(memory, 10, 0, 19, 15) && !border_drawn(memory, 10, 0, 19, 13) {
return None;
}
// `DrawStartMenu` stores the item *count* in wMaxMenuItem rather than the highest index: 7
// with the Pokédex entry, 6 without, and `DisplayStartMenu` wraps at 6 and 5 respectively.
if cursor.max != 6 && cursor.max != 7 {
return None;
}
Some(StartMenu { cursor, items: cursor.max })
}
/// Whether one of the start menu's submenus is on screen: the bag list, an elevator's floor list,
/// or the party list outside a battle.
///
/// This is the weakest rule in the module and it is why [`Scene::Unknown`] exists: pokered has no
/// "a submenu is open" flag, so a submenu the three tests below do not recognise reads as
/// `Unknown`, which the doctrine treats as advance-only. It never reads as `Overworld`.
pub fn submenu(memory: &mut dyn MemoryReader) -> bool {
if read(memory, ram::wFontLoaded) & poke::BIT_FONT_LOADED == 0 {
return false;
}
let list = read(memory, ram::wListMenuID);
list == poke::ITEM_LIST_MENU || list == poke::SPECIAL_LIST_MENU || party_list(memory)
}
/// The mart, when one is open.
///
/// `DisplayPokemartDialogue_` draws `BUY_SELL_QUIT_MENU` for the BUY / SELL / QUIT choice, and it
/// is the only user of that template in the game; the buy list is `PRICEDITEMLISTMENU` and the
/// sell list is the bag's own `ITEMLISTMENU`, which is why selling is only recognised while the
/// mart's choice is still the last template drawn.
pub fn shop(memory: &mut dyn MemoryReader) -> Option<Shop> {
if read(memory, ram::wFontLoaded) & poke::BIT_FONT_LOADED == 0 {
return None;
}
let cursor = cursor(memory);
let list = read(memory, ram::wListMenuID);
if list == poke::PRICED_ITEM_LIST_MENU {
return Some(Shop { screen: ShopScreen::Buying, cursor });
}
if read(memory, ram::wTextBoxID) != poke::BUY_SELL_QUIT_MENU {
return None;
}
let screen =
if list == poke::ITEM_LIST_MENU { ShopScreen::Selling } else { ShopScreen::BuySellQuit };
Some(Shop { screen, cursor })
}
/// The PC, when one is open. `ActivatePC` sets `wMiscFlags`' generic-PC bit and `LogOff` clears
/// it, so it covers Bill's PC, the player's PC and Oak's alike.
pub fn pc(memory: &mut dyn MemoryReader) -> Option<Pc> {
if read(memory, ram::wMiscFlags) & poke::BIT_USING_GENERIC_PC == 0 {
return None;
}
Some(Pc { cursor: cursor(memory) })
}
/// Money, in whole units. Three bytes of big-endian BCD, two digits each.
pub fn money(memory: &mut dyn MemoryReader) -> u32 {
let mut total = 0u32;
for offset in 0..3u16 {
let byte = read(memory, ram::wPlayerMoney + offset);
let high = u32::from(byte >> 4);
let low = u32::from(byte & 0x0f);
// A nibble above 9 is not BCD: the cartridge cannot produce one here, and a half-written
// frame should not turn into a plausible number.
if high > 9 || low > 9 {
return 0;
}
total = total * 100 + high * 10 + low;
}
total
}
/// The bag, in bag order. `wNumBagItems` counts `(id, quantity)` pairs, capped at
/// `BAG_ITEM_CAPACITY`, and the list ends with `$ff`.
pub fn bag(memory: &mut dyn MemoryReader) -> Vec<BagItem> {
let count = read(memory, ram::wNumBagItems);
if count > poke::BAG_CAPACITY {
return Vec::new();
}
let mut items = Vec::with_capacity(count as usize);
for index in 0..u16::from(count) {
let id = read(memory, ram::wBagItems + index * 2);
if id == 0xff {
break;
}
items.push(BagItem { id, count: read(memory, ram::wBagItems + index * 2 + 1) });
}
items
}
/// What the open mart sells, in menu order.
///
/// `LoadItemList` (`home/text_script.asm`) copies the clerk's `script_mart` list into `wItemList`
/// the moment the counter opens -- a count byte, the item ids, then `$ff` -- and
/// `DisplayPokemartDialogue_` points the buy list's `wListPointer` at the same buffer, so an
/// item's **position in this list is its cursor index** in `PRICEDITEMLISTMENU`. That is the whole
/// reason a purchase can be navigated by reading the cursor rather than by counting presses.
///
/// The terminator wins over the count, exactly as it does for the bag ([`bag`]): a count that
/// disagrees with a `$ff` is a half-written buffer, and the shorter answer is the safe one. The
/// buffer is sixteen bytes, so nothing past it is read whatever either says.
///
/// Empty when no mart is open -- the buffer is not cleared between visits, so the honest reading
/// is gated on the mart scene being up, and [`PokeState`]'s `shop_stock` is where that gate is.
pub fn shop_stock(memory: &mut dyn MemoryReader) -> Vec<u8> {
let count = read(memory, ram::wItemList);
if count == 0 || count >= poke::MART_LIST_BYTES {
return Vec::new();
}
let mut out = Vec::with_capacity(count as usize);
for index in 1..=u16::from(count) {
let id = read(memory, ram::wItemList + index);
if id == 0xff || id == 0 {
break;
}
out.push(id);
}
out
}
/// The current tileset's three counter tile ids, `$ff` where the header has none.
///
/// `data/tilesets/tileset_headers.asm` gives Mart and Pokecenter `$18`, `$19`, `$1e`; the
/// overworld and an ordinary house have none at all.
pub fn counter_tiles(memory: &mut dyn MemoryReader) -> [u8; 3] {
let mut out = [poke::NO_COUNTER_TILE; 3];
for index in 0..poke::COUNTER_TILES {
out[index as usize] = read(memory, ram::wTilesetTalkingOverTiles + index);
}
out
}
/// The screen-buffer tile id of a tile of the current map, or `None` where it cannot be read.
///
/// The same window and the same origin [`walkable`] uses, and the same three bounds: off the map,
/// off the ten-by-nine window that moves with the player, or a screen holding a battle or a text
/// box instead of the map. Split out of `walkable` so that "is this tile passable" and "is this
/// tile a counter" read one byte by one rule rather than two.
pub fn map_tile_id(memory: &mut dyn MemoryReader, x: u8, y: u8) -> Option<u8> {
let size = map_size(memory)?;
if x >= size.width || y >= size.height {
return None;
}
if in_battle(memory).is_some() || read(memory, ram::wFontLoaded) & poke::BIT_FONT_LOADED != 0 {
return None;
}
let player = player(memory)?;
let screen_x = poke::PLAYER_SCREEN_X + 2 * (i32::from(x) - i32::from(player.x));
let screen_y = poke::PLAYER_SCREEN_Y + 2 * (i32::from(y) - i32::from(player.y));
if !(0..poke::SCREEN_WIDTH as i32).contains(&screen_x)
|| !(0..poke::SCREEN_HEIGHT as i32).contains(&screen_y)
{
return None;
}
Some(screen_tile(memory, screen_x as u16, screen_y as u16))
}
/// Whether a tile of the current map is one the game lets the player talk *over*.
///
/// `IsSpriteOrSignInFrontOfPlayer`'s `.extendRangeOverCounter` branch: when the tile in front of
/// the player is one of the tileset's three counter tiles it doubles the talking range from `$10`
/// to `$20` pixels, i.e. from one tile to two. That branch is the only reason a mart clerk or a
/// Pokémon Center nurse can be spoken to at all -- both stand behind a desk, so none of the four
/// tiles around either of them is walkable, and a macro that only knew how to stand beside a
/// person could never reach one (`docs/design/macros.md` section 13).
///
/// `false` for a tile that cannot be read, which narrows: the approach falls back to the four
/// adjacent tiles, which is what every other person on the map needs anyway.
pub fn counter_tile(memory: &mut dyn MemoryReader, x: u8, y: u8) -> bool {
let Some(tile) = map_tile_id(memory, x, y) else { return false };
counter_tiles(memory)
.iter()
.any(|counter| *counter != poke::NO_COUNTER_TILE && *counter == tile)
}
/// Visible NPC sprites on the current map, by sprite slot.
///
/// Slot 0 is the player and is skipped. A slot with picture id 0, or with `$ff` in its image
/// index — which is what `LoadMapSpriteData` writes into the slots the map does not use — is not
/// on screen. Map coordinates are stored plus four, because `MACRO object_event` emits them that
/// way.
pub fn npcs(memory: &mut dyn MemoryReader) -> Vec<Npc> {
let count = read(memory, ram::wNumSprites).min(poke::SPRITE_SLOTS - 1);
let mut npcs = Vec::with_capacity(count as usize);
for slot in 1..=count {
let data1 = ram::wSpriteStateData1 + u16::from(slot) * poke::SPRITE_BYTES;
let data2 = ram::wSpriteStateData2 + u16::from(slot) * poke::SPRITE_BYTES;
let picture = read(memory, data1);
if picture == 0 || read(memory, data1 + 2) == 0xff {
continue;
}
let y = read(memory, data2 + 4);
let x = read(memory, data2 + 5);
if y < poke::SPRITE_COORD_BIAS || x < poke::SPRITE_COORD_BIAS {
continue;
}
npcs.push(Npc {
slot,
picture,
x: x - poke::SPRITE_COORD_BIAS,
y: y - poke::SPRITE_COORD_BIAS,
facing: facing_from(read(memory, data1 + 9)),
});
}
npcs
}
/// The current tileset's list of passable tile ids, terminator included.
///
/// `CheckTilePassable` walks the list at `wTilesetCollisionPtr` — a little-endian pointer into the
/// collision tables, which all live in ROM bank 0 at this commit and so are always mapped — until
/// it matches or hits `$ff`. `None` means the pointer is not one this module will follow, or the
/// list is not terminated inside the bound below.
///
/// One read of the list serves both callers: [`walkable`] asks about one tile of the window, and
/// [`map_grid`] asks about every tile of the map, and neither is allowed its own copy of the rule.
fn collision_list(memory: &mut dyn MemoryReader) -> Option<Vec<u8>> {
let low = u16::from(read(memory, ram::wTilesetCollisionPtr));
let high = u16::from(read(memory, ram::wTilesetCollisionPtr + 1));
let base = high * 256 + low;
// Bank 0 only, and not the interrupt vectors: every `*_Coll` label at this commit resolves
// inside 00:1700..00:1800. A pointer outside bank 0 would read whichever bank happens to be
// mapped, which is not an answer.
if !(0x0100..0x4000).contains(&base) {
return None;
}
// No collision list in the game is longer than this; the bound is what stops a bad pointer
// from walking the cartridge.
let mut list = Vec::new();
for offset in 0..64u16 {
let byte = read(memory, base + offset);
list.push(byte);
if byte == mapgrid::TERMINATOR {
return Some(list);
}
}
None
}
/// Whether the current tileset calls this tile id passable.
///
/// [`collision_list`]'s own walk, and `CheckTilePassable`'s: match or `$ff`, whichever comes
/// first. `None` means the list could not be read at all.
fn passable(memory: &mut dyn MemoryReader, tile: u8) -> Option<bool> {
let list = collision_list(memory)?;
for candidate in list {
if candidate == mapgrid::TERMINATOR {
return Some(false);
}
if candidate == tile {
return Some(true);
}
}
Some(false)
}
/// Why a whole-map grid could not be decoded on this frame.
///
/// `docs/design/macros.md` section 15 asks the fallback to *say when*, so every way out of
/// [`map_grid`] is named rather than being one `None`. Each one leaves the window predicate in
/// charge, which is what the walks did before the grid existed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GridRefusal {
/// The map header is not loaded, or its size is out of range (`map_size` said `None`).
NoHeader,
/// The player's coordinates are not readable, so nothing can be cross-checked.
NoPlayer,
/// The tileset's collision list could not be followed ([`collision_list`]).
NoCollisionList,
/// The blockset could not be read: the seam has no cartridge behind it
/// ([`MemoryReader::read_rom`] answered `None`), or the header's pointer runs off the image.
NoBlockset,
/// The screen is not showing the map — a battle, a text box, a frame mid-warp — so there is
/// nothing to check the decode against, and `wOverworldMap` shares its bytes with the picture
/// buffer (`ram/wram.asm`'s own union), which is exactly when it must not be trusted.
NoScreen,
/// The decode and the screen buffer disagree about a tile the window can answer for. A wrong
/// stride, a wrong quadrant or a half-loaded map all land here, and all of them answer
/// plausibly, which is why this check is not optional.
ScreenDisagrees,
}
impl GridRefusal {
/// A short label for a log line and the probes.
pub fn label(self) -> &'static str {
match self {
GridRefusal::NoHeader => "no map header",
GridRefusal::NoPlayer => "no player",
GridRefusal::NoCollisionList => "no collision list",
GridRefusal::NoBlockset => "no blockset",
GridRefusal::NoScreen => "map not on screen",
GridRefusal::ScreenDisagrees => "screen disagrees",
}
}
}
/// The whole loaded map's walkability, decoded from the tables the cartridge has loaded.
///
/// `docs/design/macros.md` section 15. The rule is [`walkable`]'s rule — the tileset's collision
/// list — and what this adds is the tile id of every tile of the map rather than of the ten-by-nine
/// window:
///
/// - the map's **blocks** come from `wOverworldMap`, which `LoadTileBlockMap` fills from the map's
/// own ROM bank as rows of `wCurMapWidth + MAP_BORDER * 2` bytes with the map itself three rows
/// and three columns in. That is WRAM, so it needs no bank at all.
/// - a block's **tiles** come from the tileset header's blockset, sixteen bytes per block id
/// (`DrawTileBlock`). That is ROM, and not bank 0, so it is the one read that goes through
/// [`MemoryReader::read_rom`] — the cartridge image as the process already holds it, because the
/// alternative would be *writing* the mapper's bank register and the joypad is the only write
/// this workspace makes into a running game.
/// - the **tile-pair** refusals come from the values of `TilePairCollisionsLand`, keyed by
/// `wCurMapTileset`, and become directed walls ([`mapgrid::TILE_PAIRS_LAND`]).
///
/// The last thing it does is check itself: the decoded tile ids are compared against
/// [`map_tile_id`] for the player's own tile and its four neighbours, every one the window can
/// answer for. A frame where the window can answer for none of them is refused
/// ([`GridRefusal::NoScreen`]) rather than trusted, because `wOverworldMap` shares its bytes with
/// the picture buffer and a battle is exactly when the blocks under it are somebody else's.
pub fn map_grid(memory: &mut dyn MemoryReader) -> Result<MapGrid, GridRefusal> {
// The decode first, so a frame with no header answers `NoHeader` rather than whatever the
// player's coordinates happen to read as: the refusals are in the order they are checked.
let grid = map_grid_decode(memory)?;
let player = player(memory).ok_or(GridRefusal::NoPlayer)?;
// The cross-check. `map_tile_id` reads the screen buffer at the offset
// `_GetTileAndCoordsInFrontOfPlayer` uses, so agreeing with it on the tiles it can answer for
// is agreeing with the cartridge's own reading of the same ground -- once the two readings are
// anchored on the same tile, which mid-step they are not ([`screen_anchor`]).
screen_anchor(memory, &grid, player.x, player.y)?;
Ok(grid)
}
/// The five tiles the screen buffer can be centred on, nearest first.
///
/// Standing still it is the fly's own tile; mid-step it is the tile the fly is stepping onto.
/// `(0, 0)` is first so that a standing frame is answered by the first comparison it makes.
const ANCHORS: [(i16, i16); 5] = [(0, 0), (0, -1), (0, 1), (-1, 0), (1, 0)];
/// Which tile the screen buffer is centred on, as an offset from `wXCoord` / `wYCoord`.
///
/// **The mid-step refusal, measured 2026-09-22** (`infra/docs/macros-traps.md` row 54; the survey
/// is `FLY_PROBE_CATCH=step` in `services/flysim/crates/flysim/examples/scene_probe.rs`). Holding
/// UP out of the Pewter museum, `wYCoord` read 7 for frames 0 to 15 of a sixteen-frame step and 6
/// from frame 16: **the coordinates change at the end of a step, not at its start.** The
/// background scrolls throughout, and from frame 2 the buffer already held the view centred on
/// (10, 6). So the old check compared the decode of (10, 7) against the screen's reading of
/// (10, 6), found `$20` against `$01`, and refused -- on fourteen frames of every sixteen. Pewter
/// City decoded on 118 of 120 standing frames and on none of the moving ones, so every walk the
/// fly actually took was re-planned over the ten-by-nine window, which is the oscillation of
/// `docs/design/macros.md` section 12.3's row 23.
///
/// Nothing in the pinned symbol table says "a step is in progress" (`docs/design/macros-wram.md`
/// section 9), and a new address cannot be pinned without the disassembly `gen_symbols.py` reads.
/// So the anchor is **measured rather than named**: the screen is centred on the fly's tile or on
/// one of its four neighbours, and the one it is centred on is the one whose whole neighbourhood
/// agrees with the decode. This keeps the property the check exists for -- a decode with a wrong
/// stride, a wrong quadrant or a half-loaded map agrees with *none* of the five, and so does the
/// mid-warp tear the cache check was added for, where the blocks are one map and `wCurMap` another.
///
/// `Err(NoScreen)` when the window can answer for none of the five tiles (a battle, a text box),
/// `Err(ScreenDisagrees)` when no anchor agrees.
fn screen_anchor(
memory: &mut dyn MemoryReader,
grid: &MapGrid,
x: u8,
y: u8,
) -> Result<(i16, i16), GridRefusal> {
let screen: Vec<(u8, u8, u8)> = neighbourhood(x, y)
.into_iter()
.filter_map(|(tx, ty)| map_tile_id(memory, tx, ty).map(|id| (tx, ty, id)))
.collect();
if screen.is_empty() {
return Err(GridRefusal::NoScreen);
}
for (dx, dy) in ANCHORS {
let agrees = screen.iter().all(|(tx, ty, id)| {
let (Ok(ax), Ok(ay)) =
(u8::try_from(i16::from(*tx) + dx), u8::try_from(i16::from(*ty) + dy))
else {
return false;
};
grid.tile_id(ax, ay) == Some(*id)
});
if agrees {
return Ok((dx, dy));
}
}
Err(GridRefusal::ScreenDisagrees)
}
/// The tile the fly is stepping onto, or `None` while it is standing still.
///
/// The other half of the measurement above, and row 54's second trap. `wXCoord` / `wYCoord` are
/// the tile the step began on until the frame it ends, so for fifteen frames of every sixteen the
/// stood ledger records ground the fly has already left and the tile under it is still *unstood*:
/// `path::frontier` offers it, `GO FRONTIER` is dealt aiming one tile away, and `Arrival::Step`
/// reports `done` the instant the step it did not make lands. A macro that completes without
/// changing anything, which is section 12.2's trap in its own words.
///
/// A step that has begun always finishes -- the cartridge owns the animation and no press stops it
/// -- so the tile the screen has already centred on is ground this run has covered.
///
/// It answers `None` on a mid-step frame whose neighbourhood is the same tile id in every
/// direction, because [`ANCHORS`] tries the standing anchor first and an open field agrees under
/// it. That is the safe way round: the grid served is still the right one, and the tile is
/// recorded on the frame the step lands, as it was before.
pub fn step_destination(memory: &mut dyn MemoryReader, grid: &MapGrid) -> Option<(u8, u8)> {
let player = player(memory)?;
let (dx, dy) = screen_anchor(memory, grid, player.x, player.y).ok()?;
if (dx, dy) == (0, 0) {
return None;
}
let x = u8::try_from(i16::from(player.x) + dx).ok()?;
let y = u8::try_from(i16::from(player.y) + dy).ok()?;
Some((x, y))
}
/// [`map_grid`] without the cross-check: the blocks, the blockset and the collision list, decoded.
///
/// Split out so [`grid_disagreement`] can say what the decode answered on a frame the check
/// refused. Nothing outside this module and the probes may use it: a grid that has not been
/// checked against the screen is exactly the reading section 15 refuses to trust.
fn map_grid_decode(memory: &mut dyn MemoryReader) -> Result<MapGrid, GridRefusal> {
let size = map_size(memory).ok_or(GridRefusal::NoHeader)?;
let player = player(memory).ok_or(GridRefusal::NoPlayer)?;
let passable = collision_list(memory).ok_or(GridRefusal::NoCollisionList)?;
let width_blocks = read(memory, ram::wCurMapWidth);
let height_blocks = read(memory, ram::wCurMapHeight);
let stride = u16::from(width_blocks) + (mapgrid::MAP_BORDER as u16) * 2;
let border = mapgrid::MAP_BORDER as u16;
// The map plus its border has to fit in `wOverworldMap`, which every real map does. One that
// does not is a header caught mid-load, and reading past the buffer would be reading somebody
// else's WRAM.
if usize::from(stride) * (usize::from(height_blocks) + mapgrid::MAP_BORDER * 2)
> mapgrid::OVERWORLD_MAP_BYTES
{
return Err(GridRefusal::NoHeader);
}
let mut blocks = Vec::with_capacity(usize::from(width_blocks) * usize::from(height_blocks));
for row in 0..u16::from(height_blocks) {
for column in 0..u16::from(width_blocks) {
blocks.push(read(memory, ram::wOverworldMap + (row + border) * stride + column + border));
}
}
// Only as much of the blockset as this map's blocks index into: a tileset has up to 256 of
// them and a room uses a dozen, and a read that stops at the highest block id used is a read
// that cannot run off the end of a bank for tiles nothing asks about.
let highest = blocks.iter().copied().max().unwrap_or(0);
let bank = read(memory, ram::wTilesetBank);
let base = u16::from(read(memory, ram::wTilesetBlocksPtr))
+ u16::from(read(memory, ram::wTilesetBlocksPtr + 1)) * 256;
let wanted = (usize::from(highest) + 1) * mapgrid::BLOCK_BYTES;
let mut blockset = Vec::with_capacity(wanted);
for offset in 0..wanted {
let address = base.checked_add(u16::try_from(offset).map_err(|_| GridRefusal::NoBlockset)?);
let byte = address
.and_then(|address| memory.read_rom(bank, address))
.ok_or(GridRefusal::NoBlockset)?;
blockset.push(byte);
}
let tiles = mapgrid::Tileset { id: read(memory, ram::wCurMapTileset), blocks: blockset, passable };
let grid = mapgrid::decode(player.map, width_blocks, height_blocks, &blocks, &tiles);
if grid.width() != size.width || grid.height() != size.height {
return Err(GridRefusal::NoHeader);
}
Ok(grid)
}
/// The decode and the screen, tile by tile, for the five tiles [`map_grid`] cross-checks.
///
/// The diagnostic half of [`GridRefusal::ScreenDisagrees`]: the refusal says the two readings
/// disagree and this says *where* and *by how much*, which is the difference between "the grid is
/// off on this map" and "this frame was mid-warp". `(x, y, decoded, screen)`, with `None` for a
/// tile either reading cannot answer for. It decodes the map a second time rather than being
/// folded into [`map_grid`], because the check's job on the hot path is to refuse and this is only
/// ever asked by a probe.
pub fn grid_disagreement(memory: &mut dyn MemoryReader) -> Vec<(u8, u8, Option<u8>, Option<u8>)> {
let Some(player) = player(memory) else { return Vec::new() };
let grid = map_grid_decode(memory).ok();
neighbourhood(player.x, player.y)
.into_iter()
.map(|(x, y)| {
(x, y, grid.as_ref().and_then(|grid| grid.tile_id(x, y)), map_tile_id(memory, x, y))
})
.collect()
}
/// Whether a cached grid is still the map that is loaded, checked from the tile the fly is on.
///
/// The map id, the map header and the block data are written by different parts of a warp, so
/// there is a frame or two on the way through a door where `wCurMap` is the map the fly is
/// arriving on and the header and the blocks are still the map it is leaving: the decode agrees
/// with the screen (both are the old map) and is filed under the new id. Measured on the
/// cartridge — the fly on Oak's lab doormat with `wCurMap` already reading `PALLET_TOWN` and the
/// header still the lab's ten-by-twelve (`tests/rom_map_grid.rs`).
///
/// Nothing in WRAM says "the map has finished loading", so the cache asks the cheapest question
/// that can tell: does the grid still agree with the screen about the tile the fly is standing on?
/// One byte, once per question. A grid that does not is dropped and decoded again, so a torn
/// frame's grid lives exactly as long as the tear does — and through it the cartridge is walking
/// the fly, which is not a frame any macro plans on.
fn still_the_loaded_map(
memory: &mut dyn MemoryReader,
grid: &MapGrid,
x: u8,
y: u8,
) -> bool {
match screen_anchor(memory, grid, x, y) {
// The screen is not showing the map (a battle, a text box): nothing to check against, and
// the grid was checked when it was decoded.
Err(GridRefusal::NoScreen) => true,
Err(_) => false,
// Agreeing under *some* anchor is agreeing: the fly's own tile while it stands still, the
// tile it is stepping onto while it moves (row 54). The whole neighbourhood has to agree
// under one of them, which a torn frame's grid cannot manage.
Ok(_) => true,
}
}
/// The player's own tile and its four neighbours, which is every tile the window is certain to be
/// able to answer for from where the fly is standing.
fn neighbourhood(x: u8, y: u8) -> Vec<(u8, u8)> {
let mut out = vec![(x, y)];
for (dx, dy) in [(0i16, 1i16), (0, -1), (-1, 0), (1, 0)] {
if let (Ok(nx), Ok(ny)) =
(u8::try_from(i16::from(x) + dx), u8::try_from(i16::from(y) + dy))
{
out.push((nx, ny));
}
}
out
}
/// Whether the player could stand on this tile of the current map.
///
/// Mirrors `CheckTilePassable`: the tile id comes out of the screen buffer at the offset
/// `_GetTileAndCoordsInFrontOfPlayer` would use, and is looked up in the current tileset's
/// passable list. Two things bound it, both reported as [`Walkable::Unknown`] rather than guessed:
///
/// - **the window.** The screen buffer holds ten tiles by nine and the player is always at the
/// middle of it — `wOverworldMap` carries three blocks of border around the real map precisely
/// so that the view can centre even on a map smaller than the screen — so only
/// `x - 4 ..= x + 5` and `y - 4 ..= y + 4` can be answered at all, and the window moves with the
/// player. A house is answerable whole from the middle of it and only in part from a corner; a
/// town or a route never is. An A* over this has to treat `Unknown` as impassable and re-plan as
/// it moves, which is what the per-step check in `docs/design/macros.md` section 4 already
/// requires of it.
/// - **the screen.** While a text box or a battle is up, the buffer holds the box, not the map.
///
/// What it does *not* model, and what the executor's per-step "did the player move" check is for
/// (`docs/design/macros.md` section 4): NPCs standing in the way — [`npcs`] reports those
/// separately — ledges, the tile-pair rules that stop a player walking from water to land, and
/// warps that fire the instant they are stepped on.
pub fn walkable(memory: &mut dyn MemoryReader, x: u8, y: u8) -> Walkable {
let Some(size) = map_size(memory) else {
return Walkable::Unknown;
};
if x >= size.width || y >= size.height {
// Off the map is not a tile to stand on. The tiles a connection leads through are the
// map's own edge rows, which are inside it.
return Walkable::No;
}
let Some(tile) = map_tile_id(memory, x, y) else {
return Walkable::Unknown;
};
match passable(memory, tile) {
Some(true) => Walkable::Yes,
Some(false) => Walkable::No,
None => Walkable::Unknown,
}
}
/// The current map's warp table: `wNumberOfWarps` entries of four bytes, `Y, X, warp id, map id`.
pub fn warps(memory: &mut dyn MemoryReader) -> Vec<Warp> {
let count = read(memory, ram::wNumberOfWarps).min(poke::MAX_WARPS);
let mut warps = Vec::with_capacity(count as usize);
for index in 0..u16::from(count) {
let entry = ram::wWarpEntries + index * 4;
warps.push(Warp {
y: read(memory, entry),
x: read(memory, entry + 1),
destination_warp: read(memory, entry + 2),
destination_map: read(memory, entry + 3),
});
}
warps
}
/// The current map's signs: `wNumSigns` entries of `wSignCoords` as `Y, X`, with `wSignTextIDs`
/// parallel to them.
///
/// `MACRO bg_event x, y, text` emits `db \2, \1, \3`, so Y comes first and -- unlike
/// `object_event`, whose coordinates are stored plus four -- there is **no bias**: the loader in
/// `home/overworld.asm` copies the two bytes straight across, and
/// `IsSpriteOrSignInFrontOfPlayer` compares them against the coordinates
/// `GetTileAndCoordsInFrontOfPlayer` returns. A sign's coordinates are therefore in the same tile
/// space as `wXCoord` and `wYCoord` with no conversion, which is true of the warp table too and
/// is exactly what is *not* true of the sprite slots.
pub fn signs(memory: &mut dyn MemoryReader) -> Vec<Sign> {
let count = read(memory, ram::wNumSigns).min(poke::MAX_SIGNS);
let mut signs = Vec::with_capacity(count as usize);
for index in 0..u16::from(count) {
let coords = ram::wSignCoords + index * 2;
signs.push(Sign {
y: read(memory, coords),
x: read(memory, coords + 1),
text_id: read(memory, ram::wSignTextIDs + index),
});
}
signs
}
/// Which of the current map's edges lead to another map, from `wCurMapConnections`.
pub fn connections(memory: &mut dyn MemoryReader) -> Connections {
let bits = read(memory, ram::wCurMapConnections);
Connections {
north: bits & poke::CONNECTION_NORTH != 0,
south: bits & poke::CONNECTION_SOUTH != 0,
east: bits & poke::CONNECTION_EAST != 0,
west: bits & poke::CONNECTION_WEST != 0,
}
}
/// A [`GameState`] over live WRAM.
///
/// Holds the reader and the exploration ledger, and nothing else: every call reads through,
/// because the reader underneath memoizes per frame and a second cache here could only go stale.
pub struct PokeState<'a> {
memory: &'a mut dyn MemoryReader,
/// Which ledger this run has already been through ([`MacroState::exit_visited`]).
ledger: &'a dyn RunLedger,
/// What this session has already talked to ([`MacroState::talked`]). Not the adapter's: it
/// belongs to the executor layer and never reaches the checkpoint.
talk: &'a dyn TalkLedger,
/// Which targets are excluded and which have been reached ([`MacroState::blocked`] and
/// [`MacroState::reached`]). Session state beside the talked ledger, and owned by the same
/// type.
targets: &'a dyn TargetLedger,
/// The ground this session has watched the fly stand on ([`MacroState::tile_visited`]'s
/// second half). Session state beside the two above, owned by the same type, and the answer
/// the adapter's reward ledger cannot give for a warp tile.
stood: &'a dyn StoodLedger,
/// Which of each area's errands this run has discharged ([`MacroState::area_visited`]).
/// Session state beside the three above, owned by the same type
/// (`docs/design/macros.md` section 13).
areas: &'a dyn AreaLedger,
/// Tiles the cartridge has pushed the fly off ([`MacroState::pushed_tile`]). Session state
/// beside the four above, owned by the same type (`infra/docs/macros-traps.md` row 37).
pushed: &'a dyn PushedLedger,
/// Where the decoded map grid is kept between frames ([`MacroState::map_grid`],
/// `docs/design/macros.md` section 15).
///
/// Mutable, unlike every ledger above, because this is the one thing the state *computes*
/// rather than looks up: a decode is a few thousand reads and a walk of the blockset, and it
/// is valid for as long as the map is loaded. Without a cache every caller decodes again,
/// which is correct and is what the tests do; the sim loop passes one
/// ([`PokeState::caching_grid`]) so that a precondition asking for the frontier costs a
/// refcount instead of a map.
grids: Option<&'a mut MapGrids>,
/// Which maps have proved their frontier unreachable (`docs/design/macros.md` section
/// 12.14). A builder rather than a constructor parameter, exactly as the grid cache is: the
/// sim loop passes one and everything else narrows to "nothing proved".
frontiers: &'a dyn FrontierLedger,
}
impl<'a> PokeState<'a> {
/// A state over WRAM alone, with an empty exploration ledger.
///
/// Every exit reads as unvisited, which is what a fresh run is. Callers that have the
/// adapter's ledger should use [`PokeState::with_ledger`], and everything the sim loop builds
/// does.
pub fn new(memory: &'a mut dyn MemoryReader) -> Self {
Self {
memory,
ledger: &NoLedger,
talk: &NoTalk,
targets: &NoTargets,
stood: &NoStood,
areas: &NoAreas,
pushed: &NoPushed,
grids: None,
frontiers: &NoFrontiers,
}
}
/// A state over WRAM and the adapter's exploration ledger, with nothing talked to yet.
pub fn with_ledger(memory: &'a mut dyn MemoryReader, ledger: &'a dyn RunLedger) -> Self {
Self {
memory,
ledger,
talk: &NoTalk,
targets: &NoTargets,
stood: &NoStood,
areas: &NoAreas,
pushed: &NoPushed,
grids: None,
frontiers: &NoFrontiers,
}
}
/// A state over WRAM, the adapter's exploration ledger and the session's own three.
///
/// What the sim loop builds: [`super::macros::driver::PokemonPalette`] owns the talked and
/// target halves and passes them in on every call, exactly as the adapter owns the
/// exploration half.
pub fn with_ledgers(
memory: &'a mut dyn MemoryReader,
ledger: &'a dyn RunLedger,
talk: &'a dyn TalkLedger,
targets: &'a dyn TargetLedger,
stood: &'a dyn StoodLedger,
areas: &'a dyn AreaLedger,
pushed: &'a dyn PushedLedger,
) -> Self {
Self { memory, ledger, talk, targets, stood, areas, pushed, grids: None, frontiers: &NoFrontiers }
}
/// Keep the decoded map grid in `grids` instead of decoding it per question.
///
/// The cache is keyed by map id and size and holds one map, so arriving somewhere else drops
/// it (`docs/design/macros.md` section 15). Session state: it is owned by
/// [`super::macros::driver::PokemonPalette`], never checkpointed, and rebuilt from the
/// cartridge on the first overworld frame after a restore.
pub fn caching_grid(mut self, grids: &'a mut MapGrids) -> Self {
self.grids = Some(grids);
self
}
/// Answer [`MacroState::frontier_exhausted`] from `frontiers` instead of "nothing proved".
///
/// A builder for the same reason the grid cache is one: it is the sim loop's own session
/// state ([`super::macros::driver::PokemonPalette`]) and every other caller -- the tests, the
/// probes, the ROM harnesses -- wants the narrowing.
pub fn with_frontiers(mut self, frontiers: &'a dyn FrontierLedger) -> Self {
self.frontiers = frontiers;
self
}
}
impl GameState for PokeState<'_> {
fn scene(&mut self) -> Scene {
super::scene::detect(self.memory)
}
fn player(&mut self) -> Option<Player> {
player(self.memory)
}
fn map_size(&mut self) -> Option<MapSize> {
map_size(self.memory)
}
fn party(&mut self) -> Party {
party(self.memory)
}
fn battle(&mut self) -> Option<Battle> {
battle(self.memory)
}
fn text_box(&mut self) -> TextBox {
text_box(self.memory)
}
fn start_menu(&mut self) -> Option<StartMenu> {
start_menu(self.memory)
}
fn shop(&mut self) -> Option<Shop> {
shop(self.memory)
}
fn pc(&mut self) -> Option<Pc> {
pc(self.memory)
}
fn money(&mut self) -> u32 {
money(self.memory)
}
fn bag(&mut self) -> Vec<BagItem> {
bag(self.memory)
}
fn npcs(&mut self) -> Vec<Npc> {
npcs(self.memory)
}
fn signs(&mut self) -> Vec<Sign> {
signs(self.memory)
}
fn walkable(&mut self, x: u8, y: u8) -> Walkable {
walkable(self.memory, x, y)
}
fn warps(&mut self) -> Vec<Warp> {
warps(self.memory)
}
fn connections(&mut self) -> Connections {
connections(self.memory)
}
}
/// The cartridge tables on their defaults, and the exploration ledger wired through.
///
/// `pokemon_red/macros/cartridge.rs` defaults every [`MacroState`] method and every default
/// *narrows* what the palette offers, so the executor runs over live WRAM with no overrides at
/// all and each one turned on later widens it without changing a signature. Two are still on
/// their defaults — the move table and chart for `ATTACK`, the mart's stock for `BUY POTION` —
/// because both live in ROM banks this module may not reach.
///
/// `exit_visited` is the one that is wired: the adapter's `boundary` ledger, through
/// [`crate::macros::RunLedger`], which is what makes `GO EXIT` aim at the ledger this run has not
/// taken rather than at the nearest door (`docs/design/macros.md` section 3).
impl MacroState for PokeState<'_> {
fn scripted(&mut self) -> bool {
!controllable(self.memory)
}
fn text_open(&mut self) -> bool {
text_box(self.memory).open
}
fn frontier_exhausted(&mut self) -> bool {
player(self.memory).is_some_and(|player| self.frontiers.frontier_exhausted(player.map))
}
fn yes_no_prompt(&mut self) -> bool {
yes_no_prompt(self.memory)
}
/// The whole loaded map's walkability, from the cache when it is for this map
/// (`docs/design/macros.md` section 15).
///
/// `None` is the honest answer on every frame [`map_grid`] refuses — no cartridge behind the
/// seam, a battle or a text box over the map, a header that is not loaded — and every caller
/// falls back to the ten-by-nine window predicate then, which is what all of them did before
/// this existed. [`GridRefusal`] names which, for the probes.
fn map_grid(&mut self) -> Option<std::sync::Arc<MapGrid>> {
let player = player(self.memory)?;
let size = map_size(self.memory)?;
if let Some(grids) = self.grids.as_deref()
&& let Some(grid) = grids.get(player.map, size.width, size.height)
&& still_the_loaded_map(self.memory, &grid, player.x, player.y)
{
return Some(grid);
}
let grid = map_grid(self.memory).ok()?;
match self.grids.as_deref_mut() {
Some(grids) => Some(grids.store(grid)),
None => Some(std::sync::Arc::new(grid)),
}
}
/// The tile the fly is stepping onto, from the screen the grid was checked against
/// (`infra/docs/macros-traps.md` row 54).
///
/// `None` on a frame with no grid, which is the same narrowing every other reading here makes:
/// without a decode to anchor against there is nothing that can say where the screen is
/// centred, and the stood ledger keeps the coordinates alone.
fn stepping_onto(&mut self) -> Option<Tile> {
let grid = self.map_grid()?;
let (x, y) = step_destination(self.memory, &grid)?;
Some(Tile::new(x, y))
}
/// What the open mart sells, in menu order (`docs/design/macros.md` section 13).
///
/// Gated on the mart scene being up, and that gate is the whole of the accuracy here:
/// `wItemList` is a scratch buffer that `LoadItemList` fills when a counter opens and nobody
/// clears afterwards, so off the mart screen it holds whatever the last list was -- a previous
/// mart's stock, or a `MonsterNames` list from a battle. Answering `[]` everywhere else is the
/// narrowing this trait's defaults all are: the four purchase buttons are unbound off the
/// counter, which is where they could not be pressed anyway.
fn shop_stock(&mut self) -> Vec<u8> {
if shop(self.memory).is_none() {
return Vec::new();
}
shop_stock(self.memory)
}
/// Whether a tile of the loaded map is one the game lets the player talk over: a counter.
fn counter_tile(&mut self, x: u8, y: u8) -> bool {
counter_tile(self.memory, x, y)
}
/// Whether this run has already been into `area`'s mart or Pokémon Center (section 13).
fn area_visited(&mut self, kind: Amenity, area: u8) -> bool {
self.areas.visited(kind, area)
}
/// Whether the cartridge has pushed the fly off this tile of the loaded map (row 37).
fn pushed_tile(&mut self, x: u8, y: u8) -> bool {
let Some(map) = player(self.memory).map(|player| player.map) else { return false };
self.pushed.pushed(map, Tile::new(x, y))
}
fn exit_visited(&mut self, exit: ExitId) -> bool {
let Some(map) = player(self.memory).map(|player| player.map) else {
// No loaded map, so no exit of it to have visited. The palette is not offering
// `GO EXIT` on such a frame anyway: `path::ledger` needs a map size first.
return false;
};
let asked = match exit {
// A warp is named by its index into `warps()` on the palette's side and by its tile on
// the ledger's, because the ledger is lifetime state and a warp index is only stable
// while the map is loaded. An index past the end of the table is not an exit at all.
ExitId::Warp(index) => {
let warps = warps(self.memory);
let Some(warp) = warps.get(usize::from(index)) else {
return false;
};
MapExit::Warp { map, x: warp.x, y: warp.y }
}
ExitId::Edge(edge) => MapExit::Edge {
map,
edge: match edge {
Edge::North => MapEdge::North,
Edge::South => MapEdge::South,
Edge::East => MapEdge::East,
Edge::West => MapEdge::West,
},
},
};
self.ledger.exit_visited(asked)
}
/// Whether this run has stood on this tile of the loaded map (`GO FRONTIER`).
///
/// The coordinates are the loaded map's, so the map id comes from WRAM rather than from the
/// caller: a tile is only ever asked about while its map is the one on screen.
fn tile_visited(&mut self, x: u8, y: u8) -> bool {
let Some(map) = player(self.memory).map(|player| player.map) else { return false };
// Two ledgers, OR'd, because the first one has a hole the macros cannot ask it to close.
// The adapter's is the `exploration` payout's own ledger and its gate rejects every frame
// with `wMovementFlags`' door and warp bits set, so a *doormat* — walkable, standable
// ground the fly is free to stand on all day — can never be recorded in it. That left
// every warp tile in the game a permanent frontier, which is the four hours the fly spent
// in Viridian Forest's south gate on 2026-09-17 (`infra/docs/macros-traps.md` row 32).
// [`StoodLedger`] is the macro layer's own answer to its own question, and it can only
// ever shrink the frontier.
self.ledger.tile_visited(MapTile { map, x, y })
|| self.stood.stood(map, Tile::new(x, y))
}
/// Whether this run has ever been on `map` (`GO ROUTE`'s unvisited interior).
fn map_visited(&mut self, map: u8) -> bool {
self.ledger.map_visited(map)
}
/// Whether this session has talked to `target` on the map that is loaded.
fn talked(&mut self, target: TalkTarget) -> bool {
let Some(map) = player(self.memory).map(|player| player.map) else { return false };
self.talk.talked(map, target)
}
/// Whether `target` is still inside its blocked-target window on the map that is loaded.
///
/// Keyed by the loaded map for the same reason the talked ledger is: a warp index and a
/// sprite slot only mean anything while their map is on screen.
fn blocked(&mut self, target: TargetKey) -> bool {
let Some(map) = player(self.memory).map(|player| player.map) else { return false };
self.targets.blocked(map, target)
}
/// Whether `GO ITEM` or `GO NPC` has already reached `target` on the map that is loaded.
fn reached(&mut self, target: TargetKey) -> bool {
let Some(map) = player(self.memory).map(|player| player.map) else { return false };
self.targets.reached(map, target)
}
/// Where the ladder's next unreached rung is (`GO OBJECTIVE`).
///
/// The adapter's [`crate::adapter::MapPlace`] in the executor's own types, which is the only
/// conversion this seam needs: nothing under `macros/` names an adapter type, and the place is
/// a place rather than a route — the macro still has to find its own way there.
fn objective(&mut self) -> Option<Objective> {
let place = self.ledger.objective()?;
Some(Objective {
map: place.map,
tile: place.tile.map(|(x, y)| Tile::new(x, y)),
warp: place.warp,
edge: place.edge.map(|edge| match edge {
MapEdge::North => Edge::North,
MapEdge::South => Edge::South,
MapEdge::East => Edge::East,
MapEdge::West => Edge::West,
}),
target: place.target,
})
}
}
#[cfg(test)]
mod tests;