From 99a6a08bad6cfa106e5ac3490ae8826711d5c79c Mon Sep 17 00:00:00 2001 From: acamilo Date: Tue, 22 Sep 2026 12:43:41 +0000 Subject: [PATCH] probes: say which tile the map decode and the screen disagree about The rung-10 review needed to tell "the whole-map grid is wrong on this map" from "this frame was mid-step", and the refusal label cannot: both read "screen disagrees". So the decode is split from its own cross-check, the disagreement is readable tile by tile, and the map survey stands still for a while and counts how many of those frames decode. On Pewter City, measured from the rung-10 checkpoint: standing still it decodes on 118 of 120 frames, and the frame the survey caught disagrees on three tiles by exactly one tile row in the direction the fly was walking. The map is fine; the reading is refused while the fly is moving. --- .../flybrain-gb/src/pokemon_red/state.rs | 60 ++++++++++++++----- .../crates/flysim/examples/scene_probe.rs | 47 +++++++++++++++ 2 files changed, 93 insertions(+), 14 deletions(-) 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 f2913c3..03e2e23 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs @@ -937,6 +937,33 @@ impl GridRefusal { /// ([`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 { + // 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. + 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) +} + +/// [`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 { 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)?; @@ -979,23 +1006,28 @@ pub fn map_grid(memory: &mut dyn MemoryReader) -> Result { 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 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, Option)> { + 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 diff --git a/services/flysim/crates/flysim/examples/scene_probe.rs b/services/flysim/crates/flysim/examples/scene_probe.rs index 37c447b..d33beeb 100644 --- a/services/flysim/crates/flysim/examples/scene_probe.rs +++ b/services/flysim/crates/flysim/examples/scene_probe.rs @@ -31,6 +31,8 @@ //! | `FLY_PROBE_FRAMES` | 200000 | frames to drive before giving up | //! | `FLY_PROBE_STUCK` | 600 | consecutive frames in one non-overworld scene that count as stuck | +use std::collections::BTreeMap; + use flybrain_core::decoder::PopulationDecoder; use flybrain_core::decoder::gameboy::gameboy_decoder_config_with_macros; use flybrain_core::ordered::NumberMap; @@ -161,6 +163,16 @@ fn pad(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) { // same call `MacroState::map_grid` makes, and the only reading that can say *which* of section // 15's refusals a frame is. let refusal = flybrain_gb::pokemon_red::state::map_grid(gb).err(); + // Which tile the decode and the screen disagree about, when that is the refusal. The label + // alone cannot tell "the grid is wrong on this map" from "this frame was mid-warp", and the + // two want opposite fixes (`docs/design/macros.md` section 15). Read here, beside the refusal + // itself, because everything below holds a borrow of the emulator. + let disagreement = match refusal { + Some(flybrain_gb::pokemon_red::state::GridRefusal::ScreenDisagrees) => { + flybrain_gb::pokemon_red::state::grid_disagreement(gb) + } + _ => Vec::new(), + }; let ledger = AdapterLedger(adapter); let mut poke = flybrain_gb::pokemon_red::state::PokeState::with_ledger(gb, &ledger); let state: &mut dyn MacroState = &mut poke; @@ -212,6 +224,22 @@ fn pad(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) { ); } } + // Which tile the decode and the screen disagree about, when that is the refusal. The label + // alone cannot tell "the grid is wrong on this map" from "this frame was mid-warp", and the + // two want opposite fixes (`docs/design/macros.md` section 15). + if !disagreement.is_empty() { + println!("- the decode against the screen, tile by tile:"); + for (x, y, decoded, screen) in disagreement { + println!( + " - ({x:2}, {y:2}) decoded {decoded:?} screen {screen:?}{}", + if decoded.is_some() && screen.is_some() && decoded != screen { + " <- the disagreement" + } else { + "" + } + ); + } + } // The `v` column is the *adapter's* lifetime exploration ledger and nothing else. The // session's own stood ledger (`docs/design/macros.md` section 12.7) is owned by the driver's // palette, which this probe does not reach into, so a doormat the running fly has already @@ -710,6 +738,25 @@ fn main() { && surveyed >= 60 { println!("\nThe fly reached map {map:#04x} at frame {frame}."); + // Stand still for a while and count how many of those frames the whole-map grid can be + // decoded on. A walking fly is mid-step on most frames -- `wYCoord` is the tile it is + // walking *to* while the background is still scrolling -- and the screen buffer the + // cross-check reads is the one that is a tile behind, so "is the grid refused on this + // map" and "is the grid refused while the fly is moving" are different questions with + // different fixes (`docs/design/macros.md` section 15). + let mut refusals: BTreeMap<&'static str, usize> = BTreeMap::new(); + for _ in 0..env_usize("FLY_PROBE_SETTLE", 120) { + gb.set_buttons(flybrain_gb::buttons::NONE); + gb.run_frame().expect("a frame should complete"); + ms += MS_PER_FRAME; + adapter.sample(&mut gb, ms); + let label = match flybrain_gb::pokemon_red::state::map_grid(&mut gb) { + Ok(_) => "decoded", + Err(refusal) => refusal.label(), + }; + *refusals.entry(label).or_insert(0) += 1; + } + println!("\nStanding still on it, frame by frame: {refusals:?}"); cartridge(&mut gb, &adapter, "The save on the surveyed map"); pad(&mut gb, &adapter, "The pad on the surveyed map"); return;