270 lines
13 KiB
Rust
270 lines
13 KiB
Rust
//! 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 **lower** left of its 2x2 quadrant ([`ANCHOR_ROW`]) -- the corner
|
|
//! `_GetTileAndCoordsInFrontOfPlayer` reads at screen `(8, 9)` for the tile the player stands on,
|
|
//! measured rather than argued and pinned by [`super::state::map_grid`]'s cross-check against the
|
|
//! window predicate 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;
|
|
|
|
/// Bytes `wOverworldMap` has for the loaded map: `ds 1300` (`ram/wram.asm`).
|
|
///
|
|
/// A map plus its border of [`MAP_BORDER`] blocks has to fit in this, and every real map does. A
|
|
/// header that says otherwise is a header read mid-load, which is why the bound is a refusal
|
|
/// rather than a clamp.
|
|
pub const OVERWORLD_MAP_BYTES: usize = 1300;
|
|
|
|
/// Which of a quadrant's two rows the collision read takes its tile id from: the lower one.
|
|
///
|
|
/// A map tile is a 2x2 patch of screen tiles and only one of the four is ever asked about, because
|
|
/// `CheckTilePassable` matches a single tile id. Which one is **measured, not derived**: the
|
|
/// decoded ids were compared against the screen buffer on the cartridge, tile by tile, and the
|
|
/// screen agrees with the lower-left tile of each quadrant and not the upper-left -- Viridian
|
|
/// Forest's (4, 32) reads `$23`, the second row of its block, where the first row holds `$04`
|
|
/// (`tests/rom_map_grid.rs`, which is the test that pins it).
|
|
///
|
|
/// That is the same corner `_GetTileAndCoordsInFrontOfPlayer` reads at screen `(8, 9)` for the
|
|
/// tile the player is standing on: the view is aligned so that the player's own 2x2 begins on
|
|
/// screen row 8, so row 9 is its lower half. An upper-left decode still answers, and answers
|
|
/// plausibly -- on the open ground of a town most quadrants hold one tile id four times over --
|
|
/// which is why the cross-check in [`super::state::map_grid`] is not optional.
|
|
const ANCHOR_ROW: usize = 1;
|
|
|
|
/// 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<u8>,
|
|
/// The `$ff`-terminated passable-tile list, terminator included or not.
|
|
pub passable: Vec<u8>,
|
|
}
|
|
|
|
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<u8> {
|
|
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 screen tile the collision read uses, inside the map tile's own 2x2 quadrant of
|
|
// the block: the **lower** left one ([`ANCHOR_ROW`]).
|
|
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) + ANCHOR_ROW;
|
|
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> {
|
|
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<Arc<MapGrid>>,
|
|
}
|
|
|
|
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<Arc<MapGrid>> {
|
|
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<MapGrid> {
|
|
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<u8> {
|
|
self.current.as_ref().map(|grid| grid.map())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|