gb: the collision tile is the lower left of a map tile s quadrant

Measured on the cartridge, not derived: a map tile is 2x2 screen tiles and
CheckTilePassable matches one id, and the screen agrees with the lower-left tile
of the quadrant. Viridian Forest s (4, 32) reads 3, the second row of its
block, where the first row holds bash4. On open ground most quadrants hold one id
four times over, so the upper-left guess reads correctly on a town and falls
apart in a forest -- which is why the decode is cross-checked against the screen
before it is trusted.
This commit is contained in:
claude 2026-09-22 04:10:28 +00:00
parent f7fa39afa7
commit 1b06e6a6cb
3 changed files with 28 additions and 8 deletions

View file

@ -404,7 +404,9 @@ impl Wram {
for x in 0..width * 2 { for x in 0..width * 2 {
let Some(block) = blocks.get((y / 2) * width + (x / 2)) else { continue }; let Some(block) = blocks.get((y / 2) * width + (x / 2)) else { continue };
let Some(tiles) = blockset.get(usize::from(*block)) else { continue }; let Some(tiles) = blockset.get(usize::from(*block)) else { continue };
let tile = tiles[(y % 2) * 2 * 4 + (x % 2) * 2]; // The lower-left tile of the map tile's own quadrant, which is the one the
// cartridge's collision read uses (`mapgrid::ANCHOR_ROW`).
let tile = tiles[((y % 2) * 2 + 1) * 4 + (x % 2) * 2];
self.map_tile(x as u8, y as u8, tile); self.map_tile(x as u8, y as u8, tile);
} }
} }

View file

@ -49,6 +49,22 @@ pub const MAP_BORDER: usize = 3;
/// which is why `wCurMapWidth` in blocks is `map_size().width` in tiles divided by this. /// which is why `wCurMapWidth` in blocks is `map_size().width` in tiles divided by this.
pub const TILES_PER_BLOCK: u8 = 2; pub const TILES_PER_BLOCK: u8 = 2;
/// 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 /// 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). /// that file declares them: OVERWORLD 0 … FOREST 3 … CAVERN 17).
pub mod tileset { pub mod tileset {
@ -143,10 +159,10 @@ pub fn decode(map: u8, width_blocks: u8, height_blocks: u8, blocks: &[u8], tiles
let Some(block) = blocks.get(block_index).copied() else { let Some(block) = blocks.get(block_index).copied() else {
continue; continue;
}; };
// The top left screen tile of the map tile's own 2x2 quadrant of the block, which is // The screen tile the collision read uses, inside the map tile's own 2x2 quadrant of
// the corner every collision read in the game uses. // the block: the **lower** left one ([`ANCHOR_ROW`]).
let column = usize::from(x % TILES_PER_BLOCK) * usize::from(TILES_PER_BLOCK); 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 row = usize::from(y % TILES_PER_BLOCK) * usize::from(TILES_PER_BLOCK) + ANCHOR_ROW;
let Some(tile) = tiles.tile(block, column, row) else { let Some(tile) = tiles.tile(block, column, row) else {
continue; continue;
}; };

View file

@ -16,11 +16,13 @@ use crate::pokemon_red::macros::state::{Facing, Walkable};
fn blockset() -> Vec<u8> { fn blockset() -> Vec<u8> {
let floor = [FLOOR; 16]; let floor = [FLOOR; 16];
let wall = [WALL; 16]; let wall = [WALL; 16];
// The four ids sit on the rows the decode reads -- the *lower* row of each 2x2 quadrant
// (`ANCHOR_ROW`) -- and the rows it does not read hold ids that would be wrong answers.
let quadrants = [ let quadrants = [
NORTH_WEST, 0x90, NORTH_EAST, 0x91, // 0x90, 0x91, 0x92, 0x93, //
0x92, 0x93, 0x94, 0x95, // NORTH_WEST, 0x94, NORTH_EAST, 0x95, //
SOUTH_WEST, 0x96, SOUTH_EAST, 0x97, // 0x96, 0x97, 0x98, 0x99, //
0x98, 0x99, 0x9a, 0x9b, SOUTH_WEST, 0x9a, SOUTH_EAST, 0x9b,
]; ];
let mut out = Vec::new(); let mut out = Vec::new();
out.extend_from_slice(&floor); out.extend_from_slice(&floor);