macros: the whole-map grid is read from the tile the screen is centred on

Measured on the cartridge from the rung-10 checkpoint: `wXCoord` and `wYCoord` change at the *end*
of a sixteen-frame step while the background scrolls throughout it, so for fifteen frames of every
sixteen the screen buffer is centred one tile ahead of the coordinates. The cross-check compared
the decode of the fly's own tile with the screen's reading of the tile ahead and refused. 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 row 23.

Nothing in the pinned symbol table says "a step is in progress" 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. A decode with a wrong stride, a wrong
quadrant or a half-loaded map agrees with none of the five, and neither does the mid-warp tear the
per-serve check was added for, so both refusals stand.

`state::step_destination` names the tile the step is landing on, for the ledger the next commit
writes. The town fixture gains two landmarks beside the fly, because a neighbourhood that is the
same tile id in every direction cannot tell one anchor from another.
This commit is contained in:
acamilo 2026-09-22 14:57:34 +00:00
parent ed1d321b02
commit 31f35c6f2f
3 changed files with 197 additions and 32 deletions

View file

@ -413,6 +413,64 @@ impl Wram {
self
}
/// 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. The blocks and the blockset come back so that a caller can
/// redraw the screen ([`Wram::mid_step`]).
pub fn town() -> (Self, Vec<u8>, 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;
}
// Two landmarks beside the fly's own tile, one on each axis. A map whose neighbourhood is
// the same tile id in every direction cannot tell a view centred on the fly from a view
// centred one tile away, which is exactly what a mid-step frame is ([`Wram::mid_step`]).
// The fly stands on (3, 4) of the decoded map: these make (2..3, 2..3) and (0..1, 4..5)
// wall, leaving (3, 4) and every tile it can step to walkable.
blocks[wide + 1] = 1;
blocks[2 * wide] = 1;
let mut wram = Self::new();
wram.started()
.map(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)
}
/// The screen buffer centred one tile away from `wXCoord` / `wYCoord`, which is what a frame
/// **mid-step** looks like on the cartridge.
///
/// Measured 2026-09-22 (`infra/docs/macros-traps.md` row 54): the coordinates change at the
/// *end* of a sixteen-frame step and the background scrolls throughout it, so for fifteen
/// frames of every sixteen the two readings are one tile apart in the direction of travel.
/// This draws exactly that: the view is rendered from `(x + dx, y + dy)` and the coordinates
/// are put back.
pub fn mid_step(
&mut self,
dx: i16,
dy: i16,
blocks: &[u8],
blockset: &[[u8; 16]],
) -> &mut Self {
let (x, y) = (self.peek(ram::wXCoord), self.peek(ram::wYCoord));
self.set(ram::wXCoord, (i16::from(x) + dx) as u8);
self.set(ram::wYCoord, (i16::from(y) + dy) as u8);
self.fill_screen(WALL_TILE).screen_from_blocks(blocks, blockset);
self.set(ram::wXCoord, x).set(ram::wYCoord, y);
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 {

View file

@ -944,19 +944,90 @@ pub fn map_grid(memory: &mut dyn MemoryReader) -> Result<MapGrid, GridRefusal> {
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.
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 {
// 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);
}
Ok(grid)
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.
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.
@ -1049,11 +1120,15 @@ fn still_the_loaded_map(
x: u8,
y: u8,
) -> bool {
match map_tile_id(memory, x, y) {
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.
None => true,
Some(tile) => grid.tile_id(x, y) == Some(tile),
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,
}
}
@ -1394,6 +1469,18 @@ impl MacroState for PokeState<'_> {
}
}
/// 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:

View file

@ -685,24 +685,7 @@ fn the_live_implementation_answers_the_whole_trait() {
/// blockset in a ROM bank that is not bank 0, and a collision list in bank 0 where
/// `wTilesetCollisionPtr` points.
fn town() -> (Wram, Vec<u8>, 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)
Wram::town()
}
#[test]
@ -730,6 +713,43 @@ fn the_whole_map_decodes_from_the_block_and_collision_tables() {
}
}
#[test]
fn a_frame_mid_step_is_read_from_the_tile_the_screen_is_centred_on() {
// Row 54 of `infra/docs/macros-traps.md`, measured on the cartridge: `wXCoord` and `wYCoord`
// change at the *end* of a step, so for fifteen frames of every sixteen the screen buffer is
// centred one tile ahead of them. The old cross-check compared the decode of the fly's tile
// against the screen's reading of the tile ahead and refused; Pewter City decoded on 118 of
// 120 standing frames and on none of the moving ones, and every walk the fly actually took was
// planned over the ten-by-nine window instead.
let (mut wram, blocks, blockset) = town();
wram.mid_step(-1, 0, &blocks, &blockset);
// The trap itself, stated as a reading: the two answers for a tile beside the fly disagree.
let naive = map_tile_id(&mut wram, 2, 4);
let grid = map_grid(&mut wram).expect("a mid-step frame still decodes");
assert_ne!(grid.tile_id(2, 4), naive, "the screen is one column ahead of the coordinates");
assert_eq!(grid.tile_id(1, 4), naive, "and that column is the one the step is landing on");
// Which is what the reader now says out loud, for the stood ledger.
assert_eq!(step_destination(&mut wram, &grid), Some((2, 4)));
// Standing still there is no step to name.
let (mut still, _, _) = town();
let standing = map_grid(&mut still).expect("a decodable map");
assert_eq!(step_destination(&mut still, &standing), None);
}
#[test]
fn a_decode_the_screen_disagrees_with_is_refused_mid_step_too() {
// The check has to keep refusing a decode that is simply wrong, and the anchor search is what
// could have weakened it: five anchors instead of one. A wrong stride, a wrong quadrant or a
// half-loaded map agrees with none of them, because the whole neighbourhood has to agree under
// one anchor rather than each tile finding an anchor of its own.
let (mut wram, blocks, blockset) = town();
wram.mid_step(-1, 0, &blocks, &blockset);
wram.map_tile(3, 4, 0x77);
assert_eq!(map_grid(&mut wram), Err(GridRefusal::ScreenDisagrees));
}
#[test]
fn a_decode_the_screen_disagrees_with_is_refused() {
let (mut wram, _, _) = town();