From 68228ef2a1b96494dcb6d192923a50d43a40083a Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 22 Sep 2026 03:32:35 +0000 Subject: [PATCH] gb: decode the whole current map into a walkability grid The walkable predicate answered for the ten-by-nine screen window and Unknown everywhere else. MapGrid is the same rule over every tile of the loaded map: block ids out of wOverworldMap, a blocks-to-tiles read of the tileset header s blockset through the new bank-aware ROM read, the tileset s collision list as before, and the TilePairCollisionsLand values as directed walls both ways. The reader checks itself before it answers: the decode is compared against the window predicate over the player s own neighbourhood, and a frame where the window can answer for none of it -- a battle, a text box, a frame mid-warp -- is refused, because wOverworldMap shares its bytes with the picture buffer. Every refusal is named (GridRefusal) and leaves the window predicate in charge. Cached per map id and size, so a map is decoded once on arrival rather than once per question, and owned beside the session ledgers: never checkpointed. --- .../flybrain-gb/src/pokemon_red/fake_wram.rs | 82 +++++- .../src/pokemon_red/macros/cartridge.rs | 19 +- .../src/pokemon_red/macros/driver.rs | 24 +- .../src/pokemon_red/macros/state.rs | 173 ++++++++++++ .../flybrain-gb/src/pokemon_red/mapgrid.rs | 246 ++++++++++++++++++ .../src/pokemon_red/mapgrid/tests.rs | 161 ++++++++++++ .../crates/flybrain-gb/src/pokemon_red/mod.rs | 8 + .../flybrain-gb/src/pokemon_red/state.rs | 214 ++++++++++++++- .../src/pokemon_red/state/tests.rs | 141 +++++++++- 9 files changed, 1051 insertions(+), 17 deletions(-) create mode 100644 services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid.rs create mode 100644 services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid/tests.rs diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs index 20daad3..02e58ac 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs @@ -32,12 +32,22 @@ pub const WALL_TILE: u8 = 0x60; pub struct Wram { bytes: Vec, + /// Fake cartridge banks, for the one read that needs one. + /// + /// A bank nothing has written answers `None`, which is what a seam with no cartridge behind + /// it answers and what the whole-map grid has to narrow on + /// (`docs/design/macros.md` section 15). + rom: std::collections::HashMap<(u8, u16), u8>, } impl MemoryReader for Wram { fn read8(&mut self, address: u16) -> u8 { self.bytes[address as usize] } + + fn read_rom(&mut self, bank: u8, address: u16) -> Option { + self.rom.get(&(bank, address)).copied() + } } impl Default for Wram { @@ -49,7 +59,7 @@ impl Default for Wram { impl Wram { /// All zero: the title screen, since nothing has set the game-timer bit. pub fn new() -> Self { - Self { bytes: vec![0; 0x1_0000] } + Self { bytes: vec![0; 0x1_0000], rom: std::collections::HashMap::new() } } pub fn set(&mut self, address: u16, value: u8) -> &mut Self { @@ -331,6 +341,76 @@ impl Wram { self } + + /// The ROM bank and address this fake keeps a tileset's blockset at. + /// + /// Any non-zero bank: the point of the number is that it is *not* bank 0, because a bank the + /// CPU bus does not have mapped is the whole reason the seam grew + /// [`MemoryReader::read_rom`] (`docs/design/macros.md` section 15). + pub const BLOCKSET_BANK: u8 = 0x11; + pub const BLOCKSET_BASE: u16 = 0x4000; + + /// Which tileset the loaded map uses, for the tile-pair collision lists. + pub fn tileset(&mut self, id: u8) -> &mut Self { + self.set(ram::wCurMapTileset, id) + } + + /// A tileset header's blockset, written where a cartridge keeps one: sixteen tile ids per + /// block, in block-id order, in a ROM bank that is not bank 0. + pub fn blockset(&mut self, blocks: &[[u8; 16]]) -> &mut Self { + for (id, block) in blocks.iter().enumerate() { + for (offset, tile) in block.iter().enumerate() { + let address = Self::BLOCKSET_BASE + (id * 16 + offset) as u16; + self.rom.insert((Self::BLOCKSET_BANK, address), *tile); + } + } + self.set(ram::wTilesetBank, Self::BLOCKSET_BANK) + .set(ram::wTilesetBlocksPtr, (Self::BLOCKSET_BASE & 0xff) as u8) + .set(ram::wTilesetBlocksPtr + 1, (Self::BLOCKSET_BASE >> 8) as u8) + } + + /// The loaded map's block ids, as `LoadTileBlockMap` leaves them in `wOverworldMap`: rows of + /// `wCurMapWidth + MAP_BORDER * 2` bytes with the map itself three rows and three columns in. + /// + /// `blocks` is row-major and `wCurMapWidth * wCurMapHeight` long; the border is left as + /// whatever it was, exactly as a map with no connections leaves it. + pub fn map_blocks(&mut self, blocks: &[u8]) -> &mut Self { + let width = u16::from(self.peek(ram::wCurMapWidth)); + let height = u16::from(self.peek(ram::wCurMapHeight)); + let border = crate::pokemon_red::mapgrid::MAP_BORDER as u16; + let stride = width + border * 2; + for row in 0..height { + for column in 0..width { + let index = usize::from(row * width + column); + let Some(block) = blocks.get(index) else { continue }; + self.set(ram::wOverworldMap + (row + border) * stride + column + border, *block); + } + } + self + } + + /// Write the screen buffer so that it agrees with the block data, tile for tile. + /// + /// The grid reader cross-checks its decode against the window predicate before it trusts it + /// ([`crate::pokemon_red::state::map_grid`]), and on a cartridge the two agree because they + /// are two readings of one map. This is that agreement in a fake: every map tile inside the + /// ten-by-nine window gets the tile id the blockset gives it, and the tiles outside it keep + /// whatever the screen held, which is what makes them `Unknown` to the window and answerable + /// only by the grid. + pub fn screen_from_blocks(&mut self, blocks: &[u8], blockset: &[[u8; 16]]) -> &mut Self { + let width = usize::from(self.peek(ram::wCurMapWidth)); + let height = usize::from(self.peek(ram::wCurMapHeight)); + for y in 0..height * 2 { + for x in 0..width * 2 { + let Some(block) = blocks.get((y / 2) * width + (x / 2)) else { continue }; + let Some(tiles) = blockset.get(usize::from(*block)) else { continue }; + let tile = tiles[(y % 2) * 2 * 4 + (x % 2) * 2]; + self.map_tile(x as u8, y as u8, tile); + } + } + self + } + /// A playable overworld frame: Red's ground floor, the fly standing where a cold boot's walk /// out of the bedroom lands it, every tile a wall until a test opens one. pub fn overworld() -> Self { diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs index 4f3f8a1..96e49cd 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs @@ -24,7 +24,7 @@ use crate::adapter::PlaceKind; use super::geography::Amenity; -use super::state::{Facing, GameState}; +use super::state::{Facing, GameState, MapGrid}; /// `constants/pokemon_data_constants.asm`: `PARTY_LENGTH`, how many Pokémon fit in the party. /// @@ -315,6 +315,23 @@ pub trait MacroState: GameState { false } + /// The whole loaded map's walkability, when the cartridge's tables can be decoded. + /// + /// `docs/design/macros.md` section 15, the operator 2026-09-22: "the frontier and warp macros + /// need to be map aware: A* over walkable tiles." [`GameState::walkable`] answers for the + /// ten-by-nine window of the screen buffer and [`super::state::Walkable::Unknown`] for + /// everything else, so before this every walk planned through guesses, re-planned at every + /// window edge, and `GO FRONTIER` aimed at whatever unstood ground happened to be on screen. + /// + /// The default is `None`, which is this trait's usual narrowing and here it is also the + /// documented fallback: [`super::path::route`] and [`super::path::frontier`] use the window + /// predicate when the grid is absent, exactly as they did before, and + /// [`crate::pokemon_red::state::GridRefusal`] is what says why it is absent on a frame the + /// reader could not decode. + fn map_grid(&mut self) -> Option> { + None + } + /// Whether this run has already been into `area`'s mart or Pokémon Center. /// /// Section 13's `areaVisited(kind, area)`: **one visit per area per run**, so the errand is diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs index 926afdb..6eca02f 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs @@ -16,6 +16,7 @@ use crate::macros::{ MacroPalette, Observed, Outcome, PaletteMode, RunLedger, SLOTS, SceneId, SlotBinding, Started, }; +use super::super::mapgrid::MapGrids; use super::super::state::PokeState; use super::cartridge::{Areas, MacroState, Pushed, Stood, Talked, Targets, Tile}; use super::geography; @@ -70,6 +71,14 @@ pub struct PokemonPalette { /// and walks the fly down on every frame it stands there until the Pokédex exists, and a map /// does not stop being like that ten brain minutes later. pushed: Pushed, + /// The decoded walkability of the map the fly is on (`docs/design/macros.md` section 15). + /// + /// Owned here beside the session ledgers because it is the same shape of thing: built from + /// the cartridge, valid for as long as the map is loaded, dropped on arrival somewhere else, + /// and never checkpointed -- a restored run decodes the map again on its first overworld + /// frame. It is a *cache* rather than a ledger: nothing about the run is in it, only what the + /// cartridge's own tables say about the ground. + grids: MapGrids, /// The brain clock of the frame being decided, from [`MacroPalette::clock`]. /// /// The blocked ledger is a *window*, so it needs the same clock the loop publishes rather @@ -93,6 +102,7 @@ impl PokemonPalette { stood: Stood::default(), areas: Areas::default(), pushed: Pushed::default(), + grids: MapGrids::default(), now_ms: 0.0, } } @@ -158,11 +168,13 @@ impl MacroPalette for PokemonPalette { fn observe(&mut self, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger) -> Observed { let (scene, bindings, standing) = { - let Self { machine, mode, palette: cached, talked, targets, stood, areas, pushed, .. } = - self; + let Self { + machine, mode, palette: cached, talked, targets, stood, areas, pushed, grids, .. + } = self; let mut state = PokeState::with_ledgers( memory, ledger, talked, targets, &*stood, &*areas, &*pushed, - ); + ) + .caching_grid(grids); // `GameState::scene` is `pokemon_red::scene::detect` over the same reader, so the // palette and the scene the feed reports cannot disagree about which frame they are // for. @@ -227,7 +239,8 @@ impl MacroPalette for PokemonPalette { &self.stood, &self.areas, &self.pushed, - ); + ) + .caching_grid(&mut self.grids); self.machine.start(&palette, slot, &mut state) }; let started = match begun { @@ -267,7 +280,8 @@ impl MacroPalette for PokemonPalette { &self.stood, &self.areas, &self.pushed, - ); + ) + .caching_grid(&mut self.grids); self.machine.step(&mut state) }; // A macro that just finished may have been the press that talked to something; the ledger diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/state.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/state.rs index 02b5695..a624d03 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/state.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/state.rs @@ -392,6 +392,179 @@ impl Walkable { } } +/// Every tile of one map, answered: `docs/design/macros.md` section 15. +/// +/// [`GameState::walkable`] answers about the ten-by-nine window of the screen buffer and +/// [`Walkable::Unknown`] elsewhere. This is the same predicate over the whole loaded map, decoded +/// from the block and collision tables the cartridge has loaded +/// ([`crate::pokemon_red::mapgrid`]), so a walk can be planned once instead of guessed at and +/// re-planned at every window edge. +/// +/// It carries three things and no policy at all: +/// +/// - the walkability of every tile, in map-tile coordinates -- the same unit the player's +/// coordinates, the warp table and the sign table are in; +/// - the tile id each answer came from, which is what the cross-check against the window +/// predicate compares and what the tile-pair rules are keyed on; +/// - **directed walls**: one step out of one tile in one direction that the cartridge refuses +/// although both tiles are passable. Pokered has two such rules and the tile-pair lists are the +/// one that can be read ahead of time; a ledge is already [`Walkable::No`] in the collision +/// list, and a person in the way is the sprite list's answer, not the ground's. +/// +/// Nothing here is a fact about the run: no ledger, no visit, no target. Those stay where they +/// are, session state in the executor layer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MapGrid { + map: u8, + width: u8, + height: u8, + /// Row-major, `width * height`, [`Walkable::Unknown`] until [`MapGrid::set`] says otherwise. + tiles: Vec, + /// Row-major screen tile id per map tile, `None` where the tile was never decoded. + ids: Vec>, + /// Row-major bitmask of the directions a step out of this tile is refused in + /// ([`MapGrid::wall`]). + walls: Vec, +} + +impl MapGrid { + /// An all-[`Walkable::Unknown`] grid of this size, for a decoder to fill in. + pub fn new(map: u8, width: u8, height: u8) -> Self { + let cells = usize::from(width) * usize::from(height); + Self { + map, + width, + height, + tiles: vec![Walkable::Unknown; cells], + ids: vec![None; cells], + walls: vec![0; cells], + } + } + + /// Which map this grid is of. A grid is only ever valid for the loaded map. + pub fn map(&self) -> u8 { + self.map + } + + pub fn width(&self) -> u8 { + self.width + } + + pub fn height(&self) -> u8 { + self.height + } + + fn index(&self, x: u8, y: u8) -> Option { + (x < self.width && y < self.height) + .then(|| usize::from(y) * usize::from(self.width) + usize::from(x)) + } + + /// Record a decoded tile: its screen tile id and what the collision list makes of it. + pub fn set(&mut self, x: u8, y: u8, tile: u8, walkable: Walkable) { + if let Some(index) = self.index(x, y) { + self.tiles[index] = walkable; + self.ids[index] = Some(tile); + } + } + + /// Record that the cartridge refuses the step out of `(x, y)` in `facing`. + pub fn wall(&mut self, x: u8, y: u8, facing: Facing) { + if let Some(index) = self.index(x, y) { + self.walls[index] |= wall_bit(facing); + } + } + + /// Whether the player could stand on this tile. Off the map is [`Walkable::No`], which is the + /// window predicate's own answer for it. + pub fn walkable(&self, x: u8, y: u8) -> Walkable { + match self.index(x, y) { + None => Walkable::No, + Some(index) => self.tiles[index], + } + } + + /// The screen tile id this tile's answer came from, or `None` for a tile never decoded. + pub fn tile_id(&self, x: u8, y: u8) -> Option { + self.index(x, y).and_then(|index| self.ids[index]) + } + + /// Whether the step out of `(x, y)` in `facing` is one the cartridge refuses. + pub fn walled(&self, x: u8, y: u8, facing: Facing) -> bool { + self.index(x, y).is_some_and(|index| self.walls[index] & wall_bit(facing) != 0) + } + + /// How many tiles of the map the player could stand on. + pub fn walkable_count(&self) -> usize { + self.tiles.iter().filter(|tile| tile.is_walkable()).count() + } + + /// How many tiles the map has that were never decoded, i.e. [`Walkable::Unknown`]. + pub fn unknown_count(&self) -> usize { + self.tiles.iter().filter(|tile| matches!(tile, Walkable::Unknown)).count() + } + + /// Every walkable tile, in row-major order, as `(x, y)`. + pub fn walkable_tiles(&self) -> Vec<(u8, u8)> { + let mut out = Vec::with_capacity(self.walkable_count()); + for y in 0..self.height { + for x in 0..self.width { + if self.walkable(x, y).is_walkable() { + out.push((x, y)); + } + } + } + out + } + + /// How many walkable tiles a walk from `(x, y)` could reach, the directed walls respected. + /// + /// The starting tile counts whether or not it is walkable, for the same reason the route + /// search treats it as passable: the fly is standing on it. What this number is *for* is + /// reading a stalled walk at a glance -- a fly on a route with 600 walkable tiles and 4 + /// reachable ones is fenced in, and no amount of re-planning is going to help it + /// (`docs/design/macros.md` section 15, `examples/scene_probe.rs`). + pub fn reachable_from(&self, x: u8, y: u8) -> usize { + if self.index(x, y).is_none() { + return 0; + } + let mut seen = vec![false; self.tiles.len()]; + let mut queue = std::collections::VecDeque::new(); + if let Some(index) = self.index(x, y) { + seen[index] = true; + } + queue.push_back((x, y)); + let mut count = 0; + while let Some((tx, ty)) = queue.pop_front() { + count += 1; + for facing in [Facing::Up, Facing::Down, Facing::Left, Facing::Right] { + if self.walled(tx, ty, facing) { + continue; + } + let (dx, dy) = facing.delta(); + let Ok(nx) = u8::try_from(i16::from(tx) + dx) else { continue }; + let Ok(ny) = u8::try_from(i16::from(ty) + dy) else { continue }; + let Some(index) = self.index(nx, ny) else { continue }; + if seen[index] || !self.tiles[index].is_walkable() { + continue; + } + seen[index] = true; + queue.push_back((nx, ny)); + } + } + count + } +} + +/// Which bit of a [`MapGrid`] wall mask a direction is. +fn wall_bit(facing: Facing) -> u8 { + match facing { + Facing::Up => 1, + Facing::Down => 2, + Facing::Left => 4, + Facing::Right => 8, + } +} + /// One entry of the current map's warp table: a door, a staircase, a cave mouth. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Warp { diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid.rs new file mode 100644 index 0000000..a812615 --- /dev/null +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid.rs @@ -0,0 +1,246 @@ +//! The whole current map's walkability, decoded from the tables the cartridge has loaded. +//! +//! `docs/design/macros.md` section 15, the operator 2026-09-22: "the frontier and warp macros need +//! to be map aware: A* over walkable tiles." [`super::state::walkable`] answers for the ten-by-nine +//! window of the screen buffer and [`super::macros::state::Walkable::Unknown`] for everything +//! else, which is honest and is also why every walk planned through guesses, re-planned at each +//! window edge, and called "frontier" whatever unstood ground happened to be on screen. +//! +//! This module is the same predicate over the whole map. Nothing about the *rule* changes -- a tile +//! is walkable when the current tileset's collision list holds its tile id, which is +//! `CheckTilePassable` -- what changes is where the tile id comes from: +//! +//! | what | where the cartridge keeps it | how it is read | +//! | --- | --- | --- | +//! | the map's blocks | `wOverworldMap`, one byte per 4x4-tile block, rows of `width + MAP_BORDER * 2` with the map three rows and three columns in (`LoadTileBlockMap`) | WRAM, through the ordinary reader | +//! | a block's tiles | the tileset header's blockset, 16 bytes per block id, four rows of four tile ids (`DrawTileBlock`) | ROM, through [`crate::adapter::MemoryReader::read_rom`], because the blockset is not in bank 0 | +//! | which tiles are passable | `wTilesetCollisionPtr`, a `$ff`-terminated list in bank 0 | WRAM pointer, ROM bank 0 read, exactly as the window predicate already did | +//! | which steps are refused between two passable tiles | `TilePairCollisionsLand`, keyed by `wCurMapTileset` | the table's values, quoted below | +//! +//! Two coordinate systems meet here and keeping them apart is the whole of the arithmetic. The +//! cartridge's *blocks* are 4x4 screen tiles; the player moves in *map tiles* of 2x2 screen tiles, +//! which is the unit `wXCoord`, the warp table and everything in `macros/` is in. So one block is +//! [`TILES_PER_BLOCK`] map tiles each way, and the screen tile a map tile's walkability is read +//! from is the top left of its 2x2 quadrant -- the same corner `_GetTileAndCoordsInFrontOfPlayer` +//! reads at screen `(8, 9)` for the tile the player stands on, which is what +//! [`super::state::map_grid`]'s cross-check against the window predicate proves on a cartridge. +//! +//! What it does **not** model is what it did not model before: sprites standing on ground (the +//! sprite list answers that, and [`super::macros::path::frontier`] reads it), warps that fire on +//! the step onto them, and scripts that push the fly off a tile (a session ledger answers that). + +use std::sync::Arc; + +use super::macros::state::{Facing, MapGrid, Walkable}; + +/// Screen tiles across and down one block: `BLOCK_WIDTH` and `BLOCK_HEIGHT` +/// (`constants/gfx_constants.asm`). +pub const BLOCK_TILES: usize = 4; + +/// Bytes one block takes in a tileset's blockset: its sixteen tile ids, four rows of four. +pub const BLOCK_BYTES: usize = BLOCK_TILES * BLOCK_TILES; + +/// Blocks of border `wOverworldMap` keeps on every side: `MAP_BORDER` +/// (`constants/map_data_constants.asm`), which is what lets the view centre on a map smaller than +/// the screen and what a map's own blocks are offset by. +pub const MAP_BORDER: usize = 3; + +/// Map tiles across one block. A block is four screen tiles and the player walks two at a time, +/// which is why `wCurMapWidth` in blocks is `map_size().width` in tiles divided by this. +pub const TILES_PER_BLOCK: u8 = 2; + +/// Tileset ids the tile-pair lists name (`constants/tileset_constants.asm`, counted in the order +/// that file declares them: OVERWORLD 0 … FOREST 3 … CAVERN 17). +pub mod tileset { + pub const FOREST: u8 = 3; + pub const CAVERN: u8 = 17; +} + +/// `TilePairCollisionsLand` at the pinned pokered commit, as `(tileset, one tile, the other)`. +/// +/// `data/tilesets/pair_collision_tile_ids.asm`. Values rather than an address, like the item +/// prices in [`super::macros::cartridge`]: what the cartridge keeps here is eleven triples, and +/// the file they came from is quoted so a wrong one is a review comment rather than a mystery. +/// +/// `CheckForTilePairCollisions` walks the list comparing the tile the player stands on against +/// *either* member of the pair and the tile in front against the other, so the refusal is +/// symmetric -- a forest's tree line refuses the step in both directions -- and each triple +/// becomes two directed walls. Both tiles are passable on their own, which is why nothing in the +/// collision list can predict it and why the walk used to learn it one refusal at a time +/// ([`super::macros::path::Refusal`]). +/// +/// `TilePairCollisionsWater` is deliberately absent: it is the list +/// `CheckForJumpingAndTilePairCollisions` uses while surfing, the fly has no Surf, and a rule for +/// a movement mode the palette cannot enter is not knowledge this grid should carry. +pub const TILE_PAIRS_LAND: [(u8, u8, u8); 11] = [ + (tileset::CAVERN, 0x20, 0x05), + (tileset::CAVERN, 0x41, 0x05), + (tileset::FOREST, 0x30, 0x2e), + (tileset::CAVERN, 0x2a, 0x05), + (tileset::CAVERN, 0x05, 0x21), + (tileset::FOREST, 0x52, 0x2e), + (tileset::FOREST, 0x55, 0x2e), + (tileset::FOREST, 0x56, 0x2e), + (tileset::FOREST, 0x20, 0x2e), + (tileset::FOREST, 0x5e, 0x2e), + (tileset::FOREST, 0x5f, 0x2e), +]; + +/// The tileset's two tables, as the decoder needs them. +/// +/// Gathered by [`super::state::map_grid`] from WRAM and ROM; separate from the decoding so that +/// the decoding is a pure function of bytes and can be tested against a made-up tileset. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Tileset { + /// `wCurMapTileset`: which tileset, for the tile-pair lists. + pub id: u8, + /// The blockset: sixteen tile ids per block id, in block-id order from zero. + pub blocks: Vec, + /// The `$ff`-terminated passable-tile list, terminator included or not. + pub passable: Vec, +} + +impl Tileset { + /// The screen tile id at `(column, row)` of block `block`, or `None` past the blockset. + fn tile(&self, block: u8, column: usize, row: usize) -> Option { + let offset = usize::from(block) * BLOCK_BYTES + row * BLOCK_TILES + column; + self.blocks.get(offset).copied() + } + + /// Whether the collision list holds `tile`, which is `CheckTilePassable` and nothing else. + fn passable(&self, tile: u8) -> bool { + for candidate in &self.passable { + if *candidate == TERMINATOR { + return false; + } + if *candidate == tile { + return true; + } + } + false + } +} + +/// The `$ff` that ends a collision list. +pub const TERMINATOR: u8 = 0xff; + +/// Decode the whole map into a [`MapGrid`]. +/// +/// `blocks` is the map's block ids, row-major, `width_blocks * height_blocks` of them, already +/// lifted out of `wOverworldMap`'s bordered rows. Every map tile gets one answer: +/// [`Walkable::Yes`] or [`Walkable::No`] from the collision list, and [`Walkable::Unknown`] only +/// for a tile whose block id is past the end of the blockset -- which is a table that was read +/// short rather than a tile the game is unsure about. +pub fn decode(map: u8, width_blocks: u8, height_blocks: u8, blocks: &[u8], tiles: &Tileset) -> MapGrid { + let width = width_blocks.saturating_mul(TILES_PER_BLOCK); + let height = height_blocks.saturating_mul(TILES_PER_BLOCK); + let mut grid = MapGrid::new(map, width, height); + for y in 0..height { + for x in 0..width { + let block_index = + usize::from(y / TILES_PER_BLOCK) * usize::from(width_blocks) + + usize::from(x / TILES_PER_BLOCK); + let Some(block) = blocks.get(block_index).copied() else { + continue; + }; + // The top left screen tile of the map tile's own 2x2 quadrant of the block, which is + // the corner every collision read in the game uses. + let column = usize::from(x % TILES_PER_BLOCK) * usize::from(TILES_PER_BLOCK); + let row = usize::from(y % TILES_PER_BLOCK) * usize::from(TILES_PER_BLOCK); + let Some(tile) = tiles.tile(block, column, row) else { + continue; + }; + grid.set(x, y, tile, if tiles.passable(tile) { Walkable::Yes } else { Walkable::No }); + } + } + add_pair_walls(&mut grid, tiles.id); + grid +} + +/// Turn every tile-pair collision the loaded tileset has into two directed walls. +fn add_pair_walls(grid: &mut MapGrid, tileset: u8) { + let pairs: Vec<(u8, u8)> = TILE_PAIRS_LAND + .iter() + .filter(|(id, _, _)| *id == tileset) + .map(|(_, one, other)| (*one, *other)) + .collect(); + if pairs.is_empty() { + return; + } + for y in 0..grid.height() { + for x in 0..grid.width() { + let Some(here) = grid.tile_id(x, y) else { continue }; + for facing in [Facing::Down, Facing::Right] { + let (dx, dy) = facing.delta(); + let Some(nx) = checked_step(x, dx) else { continue }; + let Some(ny) = checked_step(y, dy) else { continue }; + if nx >= grid.width() || ny >= grid.height() { + continue; + } + let Some(there) = grid.tile_id(nx, ny) else { continue }; + if pairs.iter().any(|(one, other)| { + (*one == here && *other == there) || (*one == there && *other == here) + }) { + grid.wall(x, y, facing); + grid.wall(nx, ny, opposite(facing)); + } + } + } + } +} + +fn checked_step(value: u8, delta: i16) -> Option { + u8::try_from(i16::from(value) + delta).ok() +} + +fn opposite(facing: Facing) -> Facing { + match facing { + Facing::Down => Facing::Up, + Facing::Up => Facing::Down, + Facing::Left => Facing::Right, + Facing::Right => Facing::Left, + } +} + +/// The decoded grid of the map that is loaded, kept for as long as it is the loaded one. +/// +/// One slot, keyed by map id and size: arriving on another map drops it, and so does a map whose +/// header reads a different size, because both mean the block data under it has been replaced. +/// A decode is a few thousand WRAM reads and a walk of the blockset, so it happens once per +/// arrival rather than once per plan -- and never per frame, which is what a precondition asking +/// for the frontier would otherwise cost. +/// +/// Session state like the other ledgers of `docs/design/macros.md` section 12: owned by +/// [`super::macros::driver::PokemonPalette`], never checkpointed, and rebuilt from the cartridge +/// on the first frame after a restore. +#[derive(Debug, Clone, Default)] +pub struct MapGrids { + current: Option>, +} + +impl MapGrids { + /// The cached grid for `map` at this size, or `None` when the cache is for somewhere else. + /// + /// Handed out behind an [`Arc`] so that a caller that asks once a frame -- a precondition + /// wanting to know whether the frontier is empty -- pays a refcount rather than a copy of the + /// map. + pub fn get(&self, map: u8, width: u8, height: u8) -> Option> { + self.current + .as_ref() + .filter(|grid| grid.map() == map && grid.width() == width && grid.height() == height) + .map(Arc::clone) + } + + /// Keep `grid`, dropping whatever map the cache held before. + pub fn store(&mut self, grid: MapGrid) -> Arc { + Arc::clone(self.current.insert(Arc::new(grid))) + } + + /// Which map the cache is holding, for a log line and the tests. + pub fn held(&self) -> Option { + self.current.as_ref().map(|grid| grid.map()) + } +} + +#[cfg(test)] +mod tests; diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid/tests.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid/tests.rs new file mode 100644 index 0000000..455ca32 --- /dev/null +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid/tests.rs @@ -0,0 +1,161 @@ +//! The decoder, against a made-up tileset. +//! +//! Every byte here is synthetic on purpose: a block table, a blockset and a collision list of this +//! module's own making, so a failure says which of the three readings is wrong rather than "the +//! cartridge disagrees". The cartridge half — that the decode matches real presses on Pallet Town +//! and Viridian Forest — is `tests/rom_map_grid.rs`. + +use super::*; +use crate::pokemon_red::macros::state::{Facing, Walkable}; + +/// A blockset with three blocks: all floor, all wall, and one whose four map-tile quadrants are +/// four different tile ids. +/// +/// A block is four screen tiles each way and a map tile is two, so the quadrants are the four +/// corners and the tile a map tile's walkability comes from is the top left of its own quadrant. +fn blockset() -> Vec { + let floor = [FLOOR; 16]; + let wall = [WALL; 16]; + let quadrants = [ + NORTH_WEST, 0x90, NORTH_EAST, 0x91, // + 0x92, 0x93, 0x94, 0x95, // + SOUTH_WEST, 0x96, SOUTH_EAST, 0x97, // + 0x98, 0x99, 0x9a, 0x9b, + ]; + let mut out = Vec::new(); + out.extend_from_slice(&floor); + out.extend_from_slice(&wall); + out.extend_from_slice(&quadrants); + out +} + +const FLOOR: u8 = 0x01; +const WALL: u8 = 0x60; +const NORTH_WEST: u8 = 0x11; +const NORTH_EAST: u8 = 0x12; +const SOUTH_WEST: u8 = 0x13; +const SOUTH_EAST: u8 = 0x14; + +/// Floor and the four quadrant tiles are passable; the wall tile is in no list. +fn tileset(id: u8) -> Tileset { + Tileset { + id, + blocks: blockset(), + passable: vec![FLOOR, NORTH_WEST, NORTH_EAST, SOUTH_WEST, SOUTH_EAST, TERMINATOR], + } +} + +#[test] +fn one_block_becomes_four_map_tiles_from_its_four_quadrants() { + let grid = decode(7, 1, 1, &[2], &tileset(0)); + assert_eq!((grid.map(), grid.width(), grid.height()), (7, 2, 2)); + assert_eq!(grid.tile_id(0, 0), Some(NORTH_WEST)); + assert_eq!(grid.tile_id(1, 0), Some(NORTH_EAST)); + assert_eq!(grid.tile_id(0, 1), Some(SOUTH_WEST)); + assert_eq!(grid.tile_id(1, 1), Some(SOUTH_EAST)); +} + +#[test] +fn the_collision_list_answers_every_tile_of_a_map_larger_than_the_window() { + // Ten blocks by nine is twenty tiles by eighteen: wider than the ten-by-nine window the + // screen buffer can answer for, which is the point of the whole grid. + let (wide, high) = (10u8, 9u8); + let mut blocks = vec![0u8; usize::from(wide) * usize::from(high)]; + // A wall down the middle column of blocks. + for row in 0..usize::from(high) { + blocks[row * usize::from(wide) + 5] = 1; + } + let grid = decode(0, wide, high, &blocks, &tileset(0)); + assert_eq!((grid.width(), grid.height()), (20, 18)); + assert_eq!(grid.walkable(0, 0), Walkable::Yes); + assert_eq!(grid.walkable(19, 17), Walkable::Yes, "the far corner, which no window reaches"); + assert_eq!(grid.walkable(10, 9), Walkable::No); + assert_eq!(grid.walkable(11, 9), Walkable::No); + assert_eq!(grid.unknown_count(), 0); + assert_eq!(grid.walkable_count(), 20 * 18 - 18 * 2); + // Off the map is not a tile to stand on, which is the window predicate's own answer for it. + assert_eq!(grid.walkable(20, 0), Walkable::No); + assert_eq!(grid.walkable(0, 18), Walkable::No); +} + +#[test] +fn a_tile_pair_collision_is_a_wall_in_both_directions_and_only_in_its_own_tileset() { + // `FOREST, $30, $2E`: two tiles that are each passable on their own and that the cartridge + // refuses the step between (`data/tilesets/pair_collision_tile_ids.asm`). + let (one, other) = (0x30, 0x2e); + let tiles = Tileset { + id: tileset::FOREST, + blocks: [[one; 16], [other; 16]].concat(), + passable: vec![one, other, TERMINATOR], + }; + let grid = decode(0, 2, 1, &[0, 1], &tiles); + assert_eq!(grid.walkable(1, 0), Walkable::Yes); + assert_eq!(grid.walkable(2, 0), Walkable::Yes); + assert!(grid.walled(1, 0, Facing::Right), "the step onto the other tile"); + assert!(grid.walled(2, 0, Facing::Left), "and the step back, which is the same rule"); + assert!(!grid.walled(0, 0, Facing::Right), "two tiles of the same id are not a pair"); + + // The same two tile ids in a tileset the list does not name are ordinary ground. + let elsewhere = Tileset { id: tileset::CAVERN, ..tiles }; + let grid = decode(0, 2, 1, &[0, 1], &elsewhere); + assert!(!grid.walled(1, 0, Facing::Right)); + assert!(!grid.walled(2, 0, Facing::Left)); +} + +#[test] +fn a_block_id_the_blockset_does_not_reach_stays_unknown() { + // A blockset read short — a header pointer near the end of its bank — is not a tile the game + // is unsure about, and the search prices `Unknown` as plausible ground rather than refusing + // it, so saying so is the honest answer. + let grid = decode(0, 2, 1, &[0, 9], &tileset(0)); + assert_eq!(grid.walkable(0, 0), Walkable::Yes); + assert_eq!(grid.walkable(2, 0), Walkable::Unknown); + assert_eq!(grid.tile_id(2, 0), None); + // Block 9 is one block, which is four map tiles: two rows of two. + assert_eq!(grid.unknown_count(), 4); +} + +#[test] +fn reachable_from_counts_what_a_walk_can_get_to_and_not_what_it_can_see() { + // Two floor blocks with a wall block between them: eight walkable tiles, four of them fenced + // off from the fly. This is the number that tells a stalled walk from a long one. + let grid = decode(0, 3, 1, &[0, 1, 0], &tileset(0)); + assert_eq!(grid.walkable_count(), 8); + assert_eq!(grid.reachable_from(0, 0), 4); + assert_eq!(grid.reachable_from(4, 0), 4); + + // With the wall gone, the whole row is one region. + let grid = decode(0, 3, 1, &[0, 0, 0], &tileset(0)); + assert_eq!(grid.reachable_from(0, 0), 12); +} + +#[test] +fn a_directed_wall_fences_a_region_off_for_the_reachable_count_too() { + let (one, other) = (0x30, 0x2e); + let tiles = Tileset { + id: tileset::FOREST, + blocks: [[one; 16], [other; 16]].concat(), + passable: vec![one, other, TERMINATOR], + }; + // A column of `$30` and a column of `$2e`, four tiles each, with the tile-pair rule between + // every pair of them: every tile is walkable and half of them are unreachable. + let grid = decode(0, 2, 1, &[0, 1], &tiles); + assert_eq!(grid.walkable_count(), 8); + assert_eq!(grid.reachable_from(0, 0), 4); +} + +#[test] +fn the_cache_holds_one_map_and_drops_it_on_arrival_somewhere_else() { + let mut grids = MapGrids::default(); + assert_eq!(grids.held(), None); + let stored = grids.store(decode(3, 2, 2, &[0, 0, 0, 0], &tileset(0))); + assert_eq!(grids.held(), Some(3)); + assert_eq!(grids.get(3, 4, 4).as_deref(), Some(&*stored)); + // The same map at another size is another map's block data under the same id, which is what a + // half-loaded header looks like. + assert!(grids.get(3, 8, 8).is_none()); + assert!(grids.get(4, 4, 4).is_none()); + grids.store(decode(4, 1, 1, &[0], &tileset(0))); + assert_eq!(grids.held(), Some(4)); + assert!(grids.get(3, 4, 4).is_none()); +} diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/mod.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/mod.rs index 428fec6..469d708 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/mod.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/mod.rs @@ -11,6 +11,7 @@ pub mod catalog; #[cfg(test)] pub(crate) mod fake_wram; pub mod macros; +pub mod mapgrid; pub mod maps; pub mod scene; pub mod state; @@ -343,6 +344,13 @@ impl MemoryReader for SampleCache<'_> { self.bytes.insert(address, value); value } + + /// Straight through, uncached: a ROM byte cannot change, so there is nothing + /// for a per-sample cache to save, and the caller that reads a blockset + /// (`docs/design/macros.md` section 15) caches the decoded map instead. + fn read_rom(&mut self, bank: u8, address: u16) -> Option { + self.source.read_rom(bank, address) + } } fn word(memory: &mut impl MemoryReader, address: u16) -> u32 { diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs index 025608d..56af83d 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs @@ -29,9 +29,10 @@ use super::macros::cartridge::{ 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, - MapSize, Mon, Move, Npc, Party, Pc, Player, Scene, Shop, ShopScreen, Sign, StartMenu, + MapGrid, MapSize, Mon, Move, Npc, Party, Pc, Player, Scene, Shop, ShopScreen, Sign, StartMenu, Status, TextBox, Walkable, Warp, }; use super::symbols::ram; @@ -775,12 +776,16 @@ pub fn npcs(memory: &mut dyn MemoryReader) -> Vec { npcs } -/// Whether the current tileset calls this tile id passable. +/// 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. -fn passable(memory: &mut dyn MemoryReader, tile: u8) -> Option { +/// 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> { let low = u16::from(read(memory, ram::wTilesetCollisionPtr)); let high = u16::from(read(memory, ram::wTilesetCollisionPtr + 1)); let base = high * 256 + low; @@ -792,16 +797,162 @@ fn passable(memory: &mut dyn MemoryReader, tile: u8) -> Option { } // 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 { - match read(memory, base + offset) { - 0xff => return Some(false), - found if found == tile => return Some(true), - _ => {} + 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 { + 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 { + 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; + 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); + } + // 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. + let mut checked = 0; + for (x, y) in neighbourhood(player.x, player.y) { + let Some(screen) = map_tile_id(memory, x, y) else { continue }; + if grid.tile_id(x, y) != Some(screen) { + return Err(GridRefusal::ScreenDisagrees); + } + checked += 1; + } + if checked == 0 { + return Err(GridRefusal::NoScreen); + } + Ok(grid) +} + +/// 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 @@ -918,6 +1069,16 @@ pub struct PokeState<'a> { /// 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>, } impl<'a> PokeState<'a> { @@ -935,6 +1096,7 @@ impl<'a> PokeState<'a> { stood: &NoStood, areas: &NoAreas, pushed: &NoPushed, + grids: None, } } @@ -948,6 +1110,7 @@ impl<'a> PokeState<'a> { stood: &NoStood, areas: &NoAreas, pushed: &NoPushed, + grids: None, } } @@ -965,7 +1128,18 @@ impl<'a> PokeState<'a> { areas: &'a dyn AreaLedger, pushed: &'a dyn PushedLedger, ) -> Self { - Self { memory, ledger, talk, targets, stood, areas, pushed } + Self { memory, ledger, talk, targets, stood, areas, pushed, grids: None } + } + + /// 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 } } @@ -1051,6 +1225,28 @@ impl MacroState for PokeState<'_> { !controllable(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> { + let size = map_size(self.memory)?; + let map = player(self.memory)?.map; + if let Some(grids) = self.grids.as_deref() + && let Some(grid) = grids.get(map, size.width, size.height) + { + 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)), + } + } + /// 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: diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs index a371e73..e6bc45e 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs @@ -5,7 +5,8 @@ //! passable-tile list walked out of ROM bank 0. use super::*; -use crate::pokemon_red::fake_wram::{REDS_HOUSE_1F, WALL_TILE, Wram}; +use crate::pokemon_red::fake_wram::{self, REDS_HOUSE_1F, WALL_TILE, Wram}; +use crate::pokemon_red::macros::cartridge::MacroState; use crate::pokemon_red::macros::state::{BattleKind, BattleMenu, ShopScreen, Sign}; /// A walkable tile id from `RedsHouse1_Coll`. @@ -603,3 +604,141 @@ fn the_live_implementation_answers_the_whole_trait() { assert_eq!(state.warps().len(), 1); assert!(state.connections().south); } + +/// A map ten blocks by nine — twenty tiles by eighteen, wider than the ten-by-nine window — with +/// a wall down one column of blocks, and a screen buffer that agrees with it. +/// +/// The three tables the grid is decoded from, all synthetic: block ids in `wOverworldMap`, a +/// blockset in a ROM bank that is not bank 0, and a collision list in bank 0 where +/// `wTilesetCollisionPtr` points. +fn town() -> (Wram, Vec, Vec<[u8; 16]>) { + const FLOOR: u8 = 0x01; + let blockset = vec![[FLOOR; 16], [WALL_TILE; 16]]; + let (wide, high) = (10usize, 9usize); + let mut blocks = vec![0u8; wide * high]; + for row in 0..high { + blocks[row * wide + 5] = 1; + } + let mut wram = Wram::new(); + wram.started() + .map(fake_wram::PALLET_TOWN, wide as u8, high as u8, 3, 4) + .facing(0) + .house_collision() + .tileset(0) + .blockset(&blockset) + .map_blocks(&blocks) + .fill_screen(WALL_TILE) + .screen_from_blocks(&blocks, &blockset); + (wram, blocks, blockset) +} + +#[test] +fn the_whole_map_decodes_from_the_block_and_collision_tables() { + let (mut wram, _, _) = town(); + let grid = map_grid(&mut wram).expect("a decodable map"); + assert_eq!((grid.width(), grid.height()), (20, 18)); + assert_eq!(grid.unknown_count(), 0); + // The far corner of the map, which the window predicate cannot answer for at all: the grid + // does, and that is the whole of section 15. + assert_eq!(walkable(&mut wram, 19, 17), Walkable::Unknown); + assert_eq!(grid.walkable(19, 17), Walkable::Yes); + // The wall column, again outside the window. + assert_eq!(grid.walkable(10, 17), Walkable::No); + assert_eq!(grid.walkable(11, 17), Walkable::No); + // Inside the window the two readings agree tile for tile, which is what the reader checks + // itself with before it trusts a decode. + for y in 0..18u8 { + for x in 0..20u8 { + if let Some(tile) = map_tile_id(&mut wram, x, y) { + assert_eq!(grid.tile_id(x, y), Some(tile), "({x}, {y})"); + assert_eq!(grid.walkable(x, y), walkable(&mut wram, x, y), "({x}, {y})"); + } + } + } +} + +#[test] +fn a_decode_the_screen_disagrees_with_is_refused() { + let (mut wram, _, _) = town(); + // One screen tile the block data does not account for: a wrong stride, a wrong quadrant or a + // half-loaded map all look like this, and all of them answer plausibly. + wram.map_tile(3, 4, 0x77); + assert_eq!(map_grid(&mut wram), Err(GridRefusal::ScreenDisagrees)); +} + +#[test] +fn without_a_cartridge_behind_the_seam_there_is_no_grid() { + let (mut wram, blocks, _) = town(); + // The same WRAM with no blockset in any bank: `read_rom` answers `None`, which is what every + // reader that is not the emulator answers, and the grid narrows to nothing rather than + // decoding the map out of whatever bytes were to hand. + let mut bare = Wram::new(); + bare.started() + .map(fake_wram::PALLET_TOWN, 10, 9, 3, 4) + .facing(0) + .house_collision() + .map_blocks(&blocks) + .fill_screen(WALL_TILE); + assert_eq!(map_grid(&mut bare), Err(GridRefusal::NoBlockset)); + // And the window predicate still answers, which is the fallback the whole thing rests on. + assert_eq!(walkable(&mut wram, 3, 4), Walkable::Yes); +} + +#[test] +fn a_frame_that_is_not_showing_the_map_has_no_grid_to_check() { + let (mut wram, _, _) = town(); + // `wOverworldMap` shares its bytes with the picture buffer (`ram/wram.asm`'s own union), so a + // battle is exactly when the blocks under it belong to somebody else. Nothing can be + // cross-checked then, and a decode nothing can check is refused. + wram.battle(1); + assert_eq!(map_grid(&mut wram), Err(GridRefusal::NoScreen)); + + let (mut wram, _, _) = town(); + wram.dialogue_box(); + assert_eq!(map_grid(&mut wram), Err(GridRefusal::NoScreen)); +} + +#[test] +fn a_frame_with_no_map_header_has_no_grid() { + let mut wram = Wram::new(); + wram.started(); + assert_eq!(map_grid(&mut wram), Err(GridRefusal::NoHeader)); + assert_eq!(GridRefusal::NoHeader.label(), "no map header"); +} + +#[test] +fn the_grid_is_decoded_once_per_map_and_dropped_on_arrival_somewhere_else() { + let (mut wram, blocks, blockset) = town(); + let mut grids = MapGrids::default(); + { + let mut state = PokeState::new(&mut wram).caching_grid(&mut grids); + let first = state.map_grid().expect("a decodable map"); + let again = state.map_grid().expect("the cached map"); + assert!(std::sync::Arc::ptr_eq(&first, &again), "the second question is the same grid"); + } + assert_eq!(grids.held(), Some(fake_wram::PALLET_TOWN)); + + // Walking through a door: another map id, so the cache is somebody else's and is dropped. + wram.map(fake_wram::OAKS_LAB, 10, 9, 3, 4) + .map_blocks(&blocks) + .screen_from_blocks(&blocks, &blockset); + { + let mut state = PokeState::new(&mut wram).caching_grid(&mut grids); + let grid = state.map_grid().expect("a decodable map"); + assert_eq!(grid.map(), fake_wram::OAKS_LAB); + } + assert_eq!(grids.held(), Some(fake_wram::OAKS_LAB)); +} + +#[test] +fn a_state_with_no_cache_still_answers_and_a_state_with_no_cartridge_answers_none() { + let (mut wram, _, _) = town(); + let mut state = PokeState::new(&mut wram); + assert!(state.map_grid().is_some(), "no cache is slower, not blinder"); + + let mut bare = Wram::overworld(); + let mut state = PokeState::new(&mut bare); + assert!(state.map_grid().is_none(), "no blockset, no grid"); + // Which is the frame the window predicate is for. + assert_eq!(state.walkable(3, 6), Walkable::No); +}