Merge feat/map-aware-walks: whole-map walkability grid, A* over the map for every walk

This commit is contained in:
acamilo 2026-09-22 05:16:57 +00:00
commit 78fb86336e
23 changed files with 3115 additions and 46 deletions

View file

@ -205,6 +205,9 @@ current tileset's list of passable tiles, walking it until it matches or hits `$
is not followed and the answer is `Unknown`, because banks 1 and up are whatever the last bank is not followed and the answer is `Unknown`, because banks 1 and up are whatever the last bank
switch left mapped. This is the only ROM read in the module and the reason it is allowed. switch left mapped. This is the only ROM read in the module and the reason it is allowed.
**Section 9 is the same predicate over the whole map** (2026-09-22): the window below is what the
walks fall back to on a frame the map cannot be decoded, and no longer what they plan over.
Three things bound it, all reported as `Unknown` rather than guessed: Three things bound it, all reported as `Unknown` rather than guessed:
1. **The window is ten tiles by nine and it follows the player**: `x - 4 ..= x + 5` and 1. **The window is ten tiles by nine and it follows the player**: `x - 4 ..= x + 5` and
@ -573,3 +576,92 @@ PP, type effectiveness applied from the ROM's type chart", and section 14 replac
one button per move slot: which move is used is the fly's choice and the mushroom body's to learn. one button per move slot: which move is used is the fly's choice and the mushroom body's to learn.
Knowledge that nothing reads is not narrowed, it is deleted — `MacroState` is four methods Knowledge that nothing reads is not narrowed, it is deleted — `MacroState` is four methods
shorter and `pokemon_red/state.rs` never needed them. shorter and `pokemon_red/state.rs` never needed them.
## 9. The whole map, not the window (2026-09-22, `docs/design/macros.md` section 15)
The walkable predicate of section 2 answers about ten tiles by nine because that is how much map
the screen buffer holds. Every tile of the loaded map follows the same rule, decoded from the
tables the cartridge has loaded.
**Four more names through the same door.** `services/flysim/tools/gen_symbols.py`'s `EXTRA_RAM`
takes the table from 63 addresses to **67**, and nothing else in it moves — no event flag, no
milestone, no existing address. `flysim --print-compatibility` is byte-identical across the change:
648 bytes, `0d9bfde7…707fa`.
The prototype checkout `gen_symbols.py` reads is not on this box, so the four addresses were
resolved the way it would have resolved them, by a second tool that reads the disassembly directly:
`services/flysim/tools/resolve_wram.py` walks `ram/wram.asm` at the pinned commit with a byte
cursor that is **only ever live while it is anchored on an address `symbols.rs` already pins**, and
emits an address only when a pinned address *after* it agrees as well. It re-derives 40 of the 63
addresses the table already carries with no disagreement, and each of the four new ones is
bracketed by two of them. A declaration form it cannot size exactly kills the cursor rather than
being guessed at, so an unanchored region cannot produce a number at all.
| state | symbol | address | encoding | verified |
| --- | --- | ---: | --- | --- |
| the loaded map's blocks | `wOverworldMap` | `$c6e8` | one byte per 4x4-tile block. `LoadTileBlockMap` (`home/overworld.asm`) fills it from the map's own ROM bank as rows of `wCurMapWidth + MAP_BORDER * 2` bytes with the map itself `MAP_BORDER` = 3 rows and columns in, so the border can hold strips of the connected maps. The map's own blocks are therefore a WRAM read. | survey + ROM (Pallet Town and Viridian Forest, below), trace |
| which tileset | `wCurMapTileset` | `$d367` | tileset id (`constants/tileset_constants.asm`, `OVERWORLD` 0 … `FOREST` 3 … `CAVERN` 17). Keys the tile-pair lists, which is the only thing this work reads it for. | ROM, trace |
| the blockset's bank | `wTilesetBank` | `$d52b` | the tileset header's `db BANK(\1)` (`data/tilesets/tileset_headers.asm`). Not bank 0, which is the whole reason the seam grew a bank-aware read. | ROM (the overworld tileset's blockset reads from bank `$19`), trace |
| blocks to tiles | `wTilesetBlocksPtr` | `$d52c` | little-endian pointer, 16 bytes per block id, four rows of four screen tile ids. `DrawTileBlock` (`home/overworld.asm`) indexes it as `block * $10` and walks four rows of four, which pins the layout exactly. | ROM, trace |
### The one ROM read that needed a bank
`MemoryReader` gains `read_rom(bank, address) -> Option<u8>`, defaulted to `None`. The bus read
cannot reach the blockset — banks 1 and up are whatever the cartridge's last switch left mapped, and
the only way to change that would be to *write* the mapper's bank register, which the doctrine
forbids (`docs/design/macros.md` section 12: the joypad register is the only write). So the
emulator implements it over **the cartridge image the process already holds**: below `$4000` it is
bank 0 whatever the bank says, `$4000..$8000` is the banked window, and an offset past the end of
the image is `None`. Nothing is written, no bank is switched, and the emulator's state does not
move. Every other reader — the synthetic WRAM of the tests, the sim loop's stubs — keeps the
default, and `None` there means the grid narrows to the window predicate rather than decoding a map
out of whatever bytes were to hand.
### The corner, which was measured
A map tile is 2x2 screen tiles and `CheckTilePassable` matches **one** id, so a decode has to pick
the same one the cartridge picks. It is the **lower left** of the four. The upper left is the
plausible guess: the view is centred so that the player's own 2x2 begins at screen row 8 and
`_GetTileAndCoordsInFrontOfPlayer` reads `(8, 9)`, which is its lower half. Measured on the
cartridge rather than argued: Viridian Forest's (4, 32) reads `$23` on the screen, which is the
second row of its block, where the first row holds `$04`. On a town most quadrants hold one tile id
four times over, so an upper-left decode reads correctly there and falls apart in a forest — which
is exactly the shape of mistake the cross-check below exists for.
### Two gates, because a wrong decode answers plausibly
- **Against the screen, before the grid is trusted.** The decoded ids are compared with
`map_tile_id` over the fly's own tile and its four neighbours; a frame where the window can answer
for none of them is refused. `wOverworldMap` shares its bytes with the picture buffer
(`ram/wram.asm`'s own `UNION`), so a battle is precisely when the blocks under it are somebody
else's.
- **Against the screen again, whenever a cached grid is served.** A warp writes `wCurMap` before the
header and the blocks: measured on Oak's lab's doormat, where `wCurMap` reads `PALLET_TOWN` while
the header still reads the lab's ten-by-twelve. The decode and the screen agree on such a frame —
both are the old map — so only the *id* is wrong, and the check that catches it is one byte: does
the cached grid still agree with the screen about the tile the fly is standing on.
### The survey, on two maps
`services/flysim/crates/flysim/tests/rom_map_grid.rs`, the method of
`docs/design/room-escape.md` section 3: walk the map with real button presses on throwaway
emulators, 120 frames of held direction per step and twenty released frames before each state is
kept, and compare the grid against what the cartridge did.
| map | size | walkable | reachable | unknown | window tiles compared | tiles surveyed | refused presses | a sprite was in the way | a battle or a script answered |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| Pallet Town `$00` | 20x18 | 221 | 207 | 0 | 90, no disagreement | 120 | 58, all explained | 4 | 0 |
| Viridian Forest `$33` | 34x48 | 719 | 719 | 0 | 90, no disagreement | 120 | 74, all explained | 2 | 10 |
"All explained" is the assertion that matters: every press the cartridge refused is a tile the grid
calls a wall, a directed wall out of that tile, or a tile a sprite was standing on **in the frame
the press was made in** — Pallet Town's two villagers walk, so reading the sprite list from the
state the survey started in would not do. And every step the cartridge made is one the grid would
have planned. Both halves are needed: the first catches a decode that is too permissive, the second
one that is too strict.
Three things are still not modelled, and none of them is new: a sprite in the way (the sprite list
answers that, and the executor's per-step moved check covers the rest), a warp that fires on the
step onto it, and a script that pushes the fly off a tile (a session ledger answers that). The
water half of the tile-pair lists is deliberately absent: it is the list
`CheckForJumpingAndTilePairCollisions` uses while surfing, and the palette cannot surf.

View file

@ -1085,3 +1085,94 @@ rather than argued:
`LAYOUT.macroPalette` was `{x: 480, y: 816, w: 364, h: 212}` while the grid existed — one box `LAYOUT.macroPalette` was `{x: 480, y: 816, w: 364, h: 212}` while the grid existed — one box
moved, the strip's top edge from 852 to 816 — and it is back to `{x: 480, y: 852, w: 364, h: 176}`. moved, the strip's top edge from 852 to 816 — and it is back to `{x: 480, y: 852, w: 364, h: 176}`.
Nothing in the layout differs from main. Nothing in the layout differs from main.
## 15. Map-aware walks: one plan over the whole map (the operator, 2026-09-22: "the frontier and
## warp macros need to be map aware: A* over walkable tiles.")
Section 4 has said "A* over the current map's walkable tiles" since the first draft, and it was
never that. The walkable predicate answers for the ten tiles by nine of the screen buffer and
`Walkable::Unknown` for everything else (`docs/design/macros-wram.md`), so every walk in the game
planned through guesses at eight times the price of a known tile, re-planned at every window edge,
and `GO FRONTIER` aimed at whatever unstood ground was on screen -- never the far side of a town,
which nothing could see.
What changes is where the tile ids come from. The rule does not change at all: a tile is walkable
when the current tileset's collision list holds its id, which is `CheckTilePassable`.
- **The whole map is decoded** from the tables the cartridge has already loaded: the block ids out
of `wOverworldMap`, the block-to-tiles blockset out of the tileset header, the collision list as
before, and the `TilePairCollisionsLand` pairs as **directed walls**. `MapGrid` is every tile of
the loaded map with those walls, and `docs/design/macros-wram.md` section 9 is the byte-level
evidence, the new addresses and the verification.
- **One read of a ROM bank was needed, so `MemoryReader` gained one method.** The blockset does
not live in bank 0, and the only way to reach another bank through the CPU bus would be to
*write* the mapper's bank register. `MemoryReader::read_rom(bank, address)` reads the cartridge image the process
already holds instead: the same bytes, addressed the way the disassembly addresses them, and no
write into a running game. The joypad register is still the only write (section 12).
- **`path::route` and `path::frontier` plan over the grid**, and the plan is the same A*: one step
per tile, the multi-goal heuristic, the committed route of section 12.3, re-planned only on a
refusal or a displacement. `GO WARP`, `GO OUT` and `GO ROUTE` route to their warp or connection
tile across the whole map; `GO OBJECTIVE` takes the exit that is the first hop; the frontier is
the nearest unstood walkable tile *anywhere on the map*, by the stood ledger of section 12.7.
- **The window stays as the fallback, and it says when.** A frame with no grid falls back to the
ten-by-nine reading exactly as before, and `GridRefusal` names which of the five reasons it is:
no map header, no player, no collision list, no blockset (which is what a reader with no
cartridge behind it answers), the map not on screen, or the decode disagreeing with the screen.
Nothing is guessed and nothing silently degrades.
- **The grid is checked against the screen before it is trusted, and again whenever it is served.**
The decode is compared with the window predicate over the tile the fly is standing on and its
four neighbours, and a frame where the window can answer for none of them is refused — the block
data shares its bytes with the picture buffer, so a battle is exactly when it belongs to somebody
else. Serving a cached grid re-checks the fly's own tile, because a warp writes the map id before
the header and the blocks: for a frame or two on a doormat, `wCurMap` is the map the fly is
arriving on and the blocks are still the map it is leaving.
- **Cached per map, decoded once on arrival**, dropped when the map or its size changes. Session
state beside the talked, blocked, reached, stood and errand ledgers; never checkpointed, so a
restored run decodes the map again on its first overworld frame.
- **The probes report it.** `examples/scene_probe.rs` prints the grid's size, its walkable count,
the count reachable from where the fly stands and the count never stood on, draws the ground with
the reading it used, and names the refusal when there is none; `examples/trap_hunt.rs` carries the
same line into every trace line and into the summary table. Three numbers read a stalled walk at
a glance: a fly with forty walkable tiles and four reachable ones is fenced in, and no amount of
re-planning will help it.
**Nothing about the choice moves.** The scene deals the same buttons, the readout presses them, and
what changed is what a chosen walk knows about the ground — which is where section 12 puts
knowledge. The decoder, the reward catalog, the adapter version and the compatibility string are
untouched: 648 bytes, `0d9bfde7…707fa`, byte-identical across the change.
Two things the cartridge settled rather than the design, both measured and both written up with
their bytes in `docs/design/macros-wram.md` section 9: the collision id of a map tile is the
**lower left** of its four screen tiles and not the upper left, and **a warp writes the map id
before the map**, which is what the per-serve check above is for.
### 15.1 The proof
- **Unit tests, no cartridge.** The decoder against a made-up tileset: a block's four quadrants,
a collision list over a map wider than the window, a tile-pair collision as a wall in both
directions and not in another tileset, a block id past the end of the blockset staying `Unknown`,
and the reachable count over a fenced region. The reader against synthetic WRAM with a synthetic
blockset in a synthetic bank: the whole map decoded, a screen that disagrees refused, a reader
with no cartridge refused, a battle frame refused, and the cache holding one map. The search over
a grid: one plan across a map larger than the window where the window's own plan walks into a
wall it cannot see, a frontier beyond the window where the window's frontier is empty, a
tile-pair wall planned around, and a connection whose walls are not goals.
- **ROM-gated, from the release container's own checkpoints** (`tests/rom_map_grid.rs`, `FLY_ROM`
plus a checkpoint, skipped cleanly without either). On **Pallet Town** — 20x18, 221 walkable
tiles, 207 of them reachable, none unknown — and on **Viridian Forest** — 34x48, 719 walkable,
all reachable, none unknown: the decode agrees with the window predicate on all ninety tiles the
window can answer for, and it agrees with a **survey of real presses** on every one of the first
120 tiles the walk can stand on (58 refused presses on the town, 74 in the forest, every one of
them a wall, a directed wall or a tile a sprite was standing on; 10 presses in the forest that
the cartridge answered with a battle, which say nothing about the ground either way).
`GO FRONTIER` in the forest planned to ground outside the window and walked there in 215 frames
(615 of its frontier tiles are outside the window); every way out of the forest is one plan away,
27 to 149 steps, with no guessed tile in any of them.
- **The trap hunt**, twenty brain minutes from the rung-9 checkpoint, against `main` at v0.4.2:
**286 distinct (map, tile) become 489**, the median flagged window holds 55 tiles instead of 16,
`GO FRONTIER` runs 64 walks worth up to twelve net tiles instead of four worth one, and one walk
spends its cap where six did. It costs battle: flagged windows go 61 to 70 and windows under four
tiles 5 to 14, every one of them a window spent inside a battle, because a fly that covers more
ground walks into more grass. `infra/docs/macros-traps.md` has both runs whole and says so at
length; this is the one measurement in this file where more ground and fewer flags do not both
hold.

View file

@ -1359,3 +1359,71 @@ Four things about it that are not improvements, recorded rather than buried.
- `infra/tests/lint.sh`: all checks passed, de-PII guard included. - `infra/tests/lint.sh`: all checks passed, de-PII guard included.
- `--print-compatibility`: byte-identical to v0.4.1, 648 bytes, decoder / reward catalog / adapter - `--print-compatibility`: byte-identical to v0.4.1, 648 bytes, decoder / reward catalog / adapter
version / roles untouched. version / roles untouched.
## 2026-09-22, section 15: the walkable window becomes the whole map
The operator: "the frontier and warp macros need to be map aware: A* over walkable tiles."
`docs/design/macros.md` section 15 is the contract, `docs/design/macros-wram.md` section 9 the
bytes, and this is the measurement. Nothing about the *choice* moves: the scene deals the same
buttons and what changed is what a chosen walk knows about the ground.
**Row 35 has no source any more.** That row — "the screen-buffer tile read disagrees with itself on
a map smaller than the screen", worked around by taking a counter's reach from the map id — was a
fact about reading tiles out of a view that cannot centre on an 8x8 map. The grid reads the map's
own block data, so it answers the same way from every tile of every map. The counter rule is left
exactly as it is: this branch changes the ground, not the amenity, and a workaround that is no
longer needed is not the same thing as one that was wrong.
### The trap hunt, before and after
20 brain minutes, the hunt's own seed, 4 sweep threads, the same connectome, the same cartridge and
the same rung-9 checkpoint in both runs. "Before" is `main` at v0.4.2, which is the branch's own
merge base, so the two rows differ by this work and nothing else:
```sh
FLY_ROM=".../Pokemon Red (U) [S][BF].gb" FLY_MACRO_BRAIN=data/fafb-v783 \
FLY_TRAP_CHECKPOINT=.local/checkpoints/release-rank9-20260922T0254.checkpoint \
FLY_TRAP_MINUTES=20 FLY_TRAP_MODE=macros cargo run --release -p flysim --example trap_hunt
```
| measure | before (`main`, v0.4.2) | after (this branch) |
| --- | ---: | ---: |
| distinct (map, tile) over 20 brain minutes | 286 | **489** |
| median distinct tiles in a flagged window | 16 | **55** |
| most tiles in any one window | 94 | **180** |
| macros started | 830 | 912 |
| done / blocked / timeout / refused | 726 / 97 / 6 / 0 | 820 / 90 / **1** / 8 |
| windows flagged | 61 of 73 | 70 of 73 |
| windows under 4 distinct tiles | 5 | 14 |
| frames in a battle (longest run) | 48,756 (9,549) | 50,413 (13,411) |
| frames in the overworld | 22,149 | 18,972 |
| `GO FRONTIER` done / timeout / mean net / max net tiles | 4 / 1 / 0.5 / 1 | **64** / 0 / 1.6 / **12** |
| `GO WARP` done / timeout / mean frames / max net | 14 / 2 / 403 / 30 | 4 / **0** / 751 / 26 |
| `GO ROUTE` done / timeout / mean frames / max net | 9 / 2 / 483 / 22 | 11 / 1 / 410 / 8 |
| `GO OBJECTIVE` done / timeout / max net | 3 / 1 / 8 | 5 / **0** / **42** |
| wall seconds on the development box | 1,390 | 932 |
**The ground is the measure and the ground moved.** 286 distinct tiles became 489; the median
flagged window holds 55 of them instead of 16 and the widest holds 180 instead of 94. `GO FRONTIER`
went from four walks worth a net tile each to sixty-four worth up to twelve, `GO OBJECTIVE`'s best
walk from eight net tiles to forty-two, and the only walk that spent its cap is one `GO ROUTE` that
gained forty-two tiles while doing it. Nothing timed out that used to arrive.
**The cost, named rather than buried: the flag count went the wrong way**, 61 windows to 70, and
the windows holding fewer than four tiles went 5 to 14. Every one of those is a battle. The fly
covers more ground, so it walks into more grass and more trainers: battle frames 48,756 to 50,413,
the longest single battle 9,549 frames to 13,411, overworld frames 22,149 down to 18,972. A
two-minute window spent inside one battle is a window with one tile in it, and the worst window of
the after-run is 83 macros on one tile with **no repeated sequence at all** — which is a battle,
not a loop. This file has recorded battle text as check 10's known false-positive shape since its
first section, and the `NEXT` x12 to x14 runs that flag 43 of the 70 windows are exactly it.
So the two halves of the usual reading disagree here for the first time, and this is the honest
statement of it: **more ground, more battle, more flags.** What the flag counts cannot show and the
tile counts can is that the fly is walking across maps instead of round the tile it is standing on.
### Gates
`cargo test --workspace`, `cargo clippy --all-targets` and `infra/tests/lint.sh` on the development
box. `flysim --print-compatibility` is byte-identical across the change: 648 bytes,
`0d9bfde7…707fa`, so the live checkpoint carries over.

View file

@ -20,12 +20,37 @@ use crate::ratchet::RecoveryPolicy;
/// equivalent of the prototype's `MemoryReader` interface. /// equivalent of the prototype's `MemoryReader` interface.
pub trait MemoryReader { pub trait MemoryReader {
fn read8(&mut self, address: u16) -> u8; fn read8(&mut self, address: u16) -> u8;
/// One byte of a ROM bank, by bank number rather than off the CPU bus.
///
/// [`MemoryReader::read8`] reads the bus, where banks 1 and up are whichever
/// bank the cartridge's last switch left mapped -- so a table in bank 3 is
/// unreadable through it, and the only way to make it readable would be to
/// *write* the mapper's bank register. The joypad is the only write this
/// workspace makes into a running game (`docs/design/macros.md` section 12),
/// so this reads the cartridge image the process already holds instead: the
/// same bytes, addressed the way the disassembly addresses them.
///
/// `address` is a CPU address: below `$4000` it is bank 0 whatever `bank`
/// says, and `$4000..$8000` is the banked window. Anything else, and any
/// offset past the end of the image, is `None`.
///
/// The default is `None`: a reader with no cartridge behind it cannot answer,
/// and every caller of this is written to narrow rather than guess when it
/// does not (`docs/design/macros-wram.md`, the whole-map grid).
fn read_rom(&mut self, _bank: u8, _address: u16) -> Option<u8> {
None
}
} }
impl MemoryReader for &mut dyn MemoryReader { impl MemoryReader for &mut dyn MemoryReader {
fn read8(&mut self, address: u16) -> u8 { fn read8(&mut self, address: u16) -> u8 {
(**self).read8(address) (**self).read8(address)
} }
fn read_rom(&mut self, bank: u8, address: u16) -> Option<u8> {
(**self).read_rom(bank, address)
}
} }
/// One reward payout in one frame. /// One reward payout in one frame.

View file

@ -26,6 +26,10 @@ pub const CPU_TICKS_PER_SECOND: u64 = 4_194_304;
/// so there is no `NEW_FRAME` event to wait for yet. /// so there is no `NEW_FRAME` event to wait for yet.
const MAX_FRAME_ATTEMPTS: u32 = 120; const MAX_FRAME_ATTEMPTS: u32 = 120;
/// Bytes in one ROM bank (`$4000`), which is how [`Emulator::read_rom_bank`]
/// turns a bank number and a CPU address into an offset in the cartridge image.
const ROM_BANK_BYTES: usize = 0x4000;
/// Audio frequency flysim runs the emulator at. binjgb resamples internally to /// Audio frequency flysim runs the emulator at. binjgb resamples internally to
/// whatever is requested, so this is only a default. /// whatever is requested, so this is only a default.
pub const DEFAULT_AUDIO_FREQUENCY: u32 = 48_000; pub const DEFAULT_AUDIO_FREQUENCY: u32 = 48_000;
@ -135,6 +139,13 @@ impl FrameCache {
/// thread. It is deliberately not `Sync`. /// thread. It is deliberately not `Sync`.
pub struct Emulator { pub struct Emulator {
gb: *mut ffi::FlyGb, gb: *mut ffi::FlyGb,
/// The cartridge image, for [`MemoryReader::read_rom`].
///
/// The shim owns its own padded copy behind the handle and does not hand it
/// back, so this is a second one. It is read-only from here: nothing in this
/// workspace writes a ROM byte, and the bank-addressed read is the only
/// reason it is kept.
rom: std::sync::Arc<[u8]>,
rom_sha256: [u8; 32], rom_sha256: [u8; 32],
audio_frequency: u32, audio_frequency: u32,
/// Raw binjgb samples drained since the last [`Emulator::take_audio`]. /// Raw binjgb samples drained since the last [`Emulator::take_audio`].
@ -167,6 +178,7 @@ impl Emulator {
debug_assert_eq!(unsafe { ffi::fly_gb_frame_buffer_size() }, FRAMEBUFFER_LEN); debug_assert_eq!(unsafe { ffi::fly_gb_frame_buffer_size() }, FRAMEBUFFER_LEN);
Ok(Self { Ok(Self {
gb, gb,
rom: rom.into(),
rom_sha256: Sha256::digest(rom).into(), rom_sha256: Sha256::digest(rom).into(),
audio_frequency, audio_frequency,
pending_audio: Vec::new(), pending_audio: Vec::new(),
@ -242,6 +254,25 @@ impl Emulator {
unsafe { ffi::fly_gb_read_mem(self.gb, address) } unsafe { ffi::fly_gb_read_mem(self.gb, address) }
} }
/// One byte of ROM bank `bank`, from the cartridge image rather than the bus.
///
/// `address` is a CPU address: `$0000..$4000` is bank 0 whatever `bank` says
/// (that is what "always mapped" means) and `$4000..$8000` is the banked
/// window. `None` for any other address and for an offset past the end of the
/// image, which is what a bank a smaller cartridge does not have reads as.
/// No bank register is written and the emulator's state does not move: this
/// is a read of bytes the process already owns.
pub fn read_rom_bank(&self, bank: u8, address: u16) -> Option<u8> {
let offset = match address {
0x0000..=0x3fff => usize::from(address),
0x4000..=0x7fff => {
usize::from(bank) * ROM_BANK_BYTES + usize::from(address) - ROM_BANK_BYTES
}
_ => return None,
};
self.rom.get(offset).copied()
}
/// Sample rate of the raw buffer, as binjgb configured it. /// Sample rate of the raw buffer, as binjgb configured it.
pub fn audio_frequency(&self) -> u32 { pub fn audio_frequency(&self) -> u32 {
self.audio_frequency self.audio_frequency
@ -364,6 +395,10 @@ impl MemoryReader for Emulator {
fn read8(&mut self, address: u16) -> u8 { fn read8(&mut self, address: u16) -> u8 {
self.read_wram(address) self.read_wram(address)
} }
fn read_rom(&mut self, bank: u8, address: u16) -> Option<u8> {
self.read_rom_bank(bank, address)
}
} }
#[cfg(test)] #[cfg(test)]

View file

@ -32,12 +32,22 @@ pub const WALL_TILE: u8 = 0x60;
pub struct Wram { pub struct Wram {
bytes: Vec<u8>, bytes: Vec<u8>,
/// 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 { impl MemoryReader for Wram {
fn read8(&mut self, address: u16) -> u8 { fn read8(&mut self, address: u16) -> u8 {
self.bytes[address as usize] self.bytes[address as usize]
} }
fn read_rom(&mut self, bank: u8, address: u16) -> Option<u8> {
self.rom.get(&(bank, address)).copied()
}
} }
impl Default for Wram { impl Default for Wram {
@ -49,7 +59,7 @@ impl Default for Wram {
impl Wram { impl Wram {
/// All zero: the title screen, since nothing has set the game-timer bit. /// All zero: the title screen, since nothing has set the game-timer bit.
pub fn new() -> Self { 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 { pub fn set(&mut self, address: u16, value: u8) -> &mut Self {
@ -331,6 +341,78 @@ impl Wram {
self 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 };
// 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
}
/// A playable overworld frame: Red's ground floor, the fly standing where a cold boot's walk /// 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. /// out of the bedroom lands it, every tile a wall until a test opens one.
pub fn overworld() -> Self { pub fn overworld() -> Self {

View file

@ -24,7 +24,7 @@
use crate::adapter::PlaceKind; use crate::adapter::PlaceKind;
use super::geography::Amenity; 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. /// `constants/pokemon_data_constants.asm`: `PARTY_LENGTH`, how many Pokémon fit in the party.
/// ///
@ -315,6 +315,23 @@ pub trait MacroState: GameState {
false 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<std::sync::Arc<MapGrid>> {
None
}
/// Whether this run has already been into `area`'s mart or Pokémon Center. /// 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 /// Section 13's `areaVisited(kind, area)`: **one visit per area per run**, so the errand is

View file

@ -16,6 +16,7 @@ use crate::macros::{
MacroPalette, Observed, Outcome, PaletteMode, RunLedger, SLOTS, SceneId, SlotBinding, Started, MacroPalette, Observed, Outcome, PaletteMode, RunLedger, SLOTS, SceneId, SlotBinding, Started,
}; };
use super::super::mapgrid::MapGrids;
use super::super::state::PokeState; use super::super::state::PokeState;
use super::cartridge::{Areas, MacroState, Pushed, Stood, Talked, Targets, Tile}; use super::cartridge::{Areas, MacroState, Pushed, Stood, Talked, Targets, Tile};
use super::geography; 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 /// 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. /// does not stop being like that ten brain minutes later.
pushed: Pushed, 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 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 /// 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(), stood: Stood::default(),
areas: Areas::default(), areas: Areas::default(),
pushed: Pushed::default(), pushed: Pushed::default(),
grids: MapGrids::default(),
now_ms: 0.0, now_ms: 0.0,
} }
} }
@ -158,11 +168,13 @@ impl MacroPalette for PokemonPalette {
fn observe(&mut self, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger) -> Observed { fn observe(&mut self, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger) -> Observed {
let (scene, bindings, standing) = { let (scene, bindings, standing) = {
let Self { machine, mode, palette: cached, talked, targets, stood, areas, pushed, .. } = let Self {
self; machine, mode, palette: cached, talked, targets, stood, areas, pushed, grids, ..
} = self;
let mut state = PokeState::with_ledgers( let mut state = PokeState::with_ledgers(
memory, ledger, talked, targets, &*stood, &*areas, &*pushed, memory, ledger, talked, targets, &*stood, &*areas, &*pushed,
); )
.caching_grid(grids);
// `GameState::scene` is `pokemon_red::scene::detect` over the same reader, so the // `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 // palette and the scene the feed reports cannot disagree about which frame they are
// for. // for.
@ -227,7 +239,8 @@ impl MacroPalette for PokemonPalette {
&self.stood, &self.stood,
&self.areas, &self.areas,
&self.pushed, &self.pushed,
); )
.caching_grid(&mut self.grids);
self.machine.start(&palette, slot, &mut state) self.machine.start(&palette, slot, &mut state)
}; };
let started = match begun { let started = match begun {
@ -267,7 +280,8 @@ impl MacroPalette for PokemonPalette {
&self.stood, &self.stood,
&self.areas, &self.areas,
&self.pushed, &self.pushed,
); )
.caching_grid(&mut self.grids);
self.machine.step(&mut state) self.machine.step(&mut state)
}; };
// A macro that just finished may have been the press that talked to something; the ledger // A macro that just finished may have been the press that talked to something; the ledger

View file

@ -13,16 +13,29 @@
//! minimum Manhattan distance to any goal. That stays admissible because a step costs one and //! minimum Manhattan distance to any goal. That stays admissible because a step costs one and
//! moves one tile. //! moves one tile.
//! //!
//! **The walkable predicate has a window.** Agent A's [`Walkable::Unknown`] is load-bearing: the //! **It plans over the whole map when the map can be decoded** (`docs/design/macros.md` section
//! tile ids a walkability test needs live in the screen buffer, so only the tiles around the //! 15, the operator 2026-09-22: "the frontier and warp macros need to be map aware: A* over
//! player can be answered at all. An unknown tile is *expensive* to path through rather than //! walkable tiles"). [`MacroState::map_grid`] is every tile of the loaded map, walkability and
//! forbidden ([`UNKNOWN_STEP`]): a known-walkable way round is always preferred, and the search //! directed walls, decoded from the block and collision tables the cartridge has loaded
//! steps into the unknown only when nothing known gets any closer. That is what a map edge six //! ([`crate::pokemon_red::mapgrid`]). With it, one plan crosses a town: `GO WARP` routes to its
//! tiles away needs — it is off the screen by definition, so a search that refused every unknown //! warp tile, `GO OUT` and `GO ROUTE` to their door or connection tile, and the frontier is the
//! tile could not plan a single step toward Route 1 from the middle of Pallet Town, which is half //! nearest unstood ground *anywhere on the map* rather than the nearest on screen.
//! of why the fly never took it (`infra/docs/macros-bench.md`, 2026-09-16). The guess is cheap and //!
//! bounded: [`super::executor`] re-plans after every tile with a per-step check that the player //! **Without it, the window is still the fallback.** Agent A's [`Walkable::Unknown`] is
//! moved, and three failed steps abort as `Blocked`. //! load-bearing on a frame the grid cannot be decoded — no cartridge behind the seam, a battle or
//! a text box over the map, a header that is not loaded
//! ([`crate::pokemon_red::state::GridRefusal`] says which): the tile ids a walkability test needs
//! live in the screen buffer then, so only the tiles around the player can be answered at all. An
//! unknown tile is *expensive* to path through rather than forbidden ([`UNKNOWN_STEP`]): a
//! known-walkable way round is always preferred, and the search steps into the unknown only when
//! nothing known gets any closer. That is what a map edge six tiles away needed — it is off the
//! screen by definition, so a search that refused every unknown tile could not plan a single step
//! toward Route 1 from the middle of Pallet Town, which was half of why the fly never took it
//! (`infra/docs/macros-bench.md`, 2026-09-16).
//!
//! Either way the walk is re-planned only when the ground says so — a refusal, or the player not
//! where the plan expects it — which is [`super::executor`]'s committed route and not this
//! module's business.
//! //!
//! The search still has its second answer for a goal it cannot reach at all: when no goal is //! The search still has its second answer for a goal it cannot reach at all: when no goal is
//! reachable, it returns the route to the reachable tile that gets closest to one. //! reachable, it returns the route to the reachable tile that gets closest to one.
@ -34,7 +47,24 @@ use super::cartridge::{
Edge, ExitId, LAST_MAP, MacroState, TalkTarget, Tile, destination_outdoors, outdoors, Edge, ExitId, LAST_MAP, MacroState, TalkTarget, Tile, destination_outdoors, outdoors,
}; };
use super::geography; use super::geography;
use super::state::{Facing, Walkable}; use super::state::{Facing, MapGrid, Walkable};
/// Whether the player could stand on a tile of the current map: the grid's answer, or the
/// window's when there is no grid.
///
/// One place asks the question so that the search, the exit list and the frontier cannot disagree
/// about which reading they are on (`docs/design/macros.md` section 15).
fn walkable_at(
state: &mut dyn MacroState,
grid: Option<&MapGrid>,
x: u8,
y: u8,
) -> Walkable {
match grid {
Some(grid) => grid.walkable(x, y),
None => state.walkable(x, y),
}
}
/// What one step onto a tile the walkable predicate cannot answer for costs. /// What one step onto a tile the walkable predicate cannot answer for costs.
/// ///
@ -156,6 +186,10 @@ pub fn route_avoiding(
} }
let player = state.player()?; let player = state.player()?;
let size = state.map_size()?; let size = state.map_size()?;
// One decode per plan, from the cache the sim loop keeps: with a grid the search is over the
// whole map, without one it is over the ten-by-nine window as it always was.
let grid = state.map_grid();
let grid = grid.as_deref();
let start = Tile::new(player.x, player.y); let start = Tile::new(player.x, player.y);
if let Some(goal) = goals.iter().position(|tile| *tile == start) { if let Some(goal) = goals.iter().position(|tile| *tile == start) {
return Some(Route { goal: Some(goal), steps: Vec::new() }); return Some(Route { goal: Some(goal), steps: Vec::new() });
@ -191,6 +225,12 @@ pub fn route_avoiding(
if refused.contains(&(tile, facing)) { if refused.contains(&(tile, facing)) {
continue; continue;
} }
// A step the *tables* refuse: a tile-pair collision, which is passable ground on both
// sides and a wall between them (`mapgrid::TILE_PAIRS_LAND`). The walk used to learn
// each of these by spending a step on it; with the grid the first plan goes round.
if grid.is_some_and(|grid| grid.walled(tile.x, tile.y, facing)) {
continue;
}
// **A tile the cartridge pushes the fly off is not a tile to walk through**, either // **A tile the cartridge pushes the fly off is not a tile to walk through**, either
// (row 37 of `infra/docs/macros-traps.md`). Excluding it as a *goal* was half the fix // (row 37 of `infra/docs/macros-traps.md`). Excluding it as a *goal* was half the fix
// and the measurement said so: Viridian City's (19, 9) went from 53,266 text-box // and the measurement said so: Viridian City's (19, 9) went from 53,266 text-box
@ -200,10 +240,11 @@ pub fn route_avoiding(
if next != start && state.pushed_tile(next.x, next.y) { if next != start && state.pushed_tile(next.x, next.y) {
continue; continue;
} }
let step = match state.walkable(next.x, next.y) { let step = match walkable_at(state, grid, next.x, next.y) {
_ if next == start => 1, _ if next == start => 1,
Walkable::Yes => 1, Walkable::Yes => 1,
// Off the screen buffer: plausible ground, priced so that anything known beats it. // Off the screen buffer, or a block the blockset was read short of: plausible
// ground, priced so that anything known beats it.
Walkable::Unknown => UNKNOWN_STEP, Walkable::Unknown => UNKNOWN_STEP,
Walkable::No => continue, Walkable::No => continue,
}; };
@ -334,6 +375,8 @@ pub fn target_at(state: &mut dyn MacroState, tile: Tile) -> Option<TalkTarget> {
pub fn exits(state: &mut dyn MacroState) -> Vec<Exit> { pub fn exits(state: &mut dyn MacroState) -> Vec<Exit> {
let Some(size) = state.map_size() else { return Vec::new() }; let Some(size) = state.map_size() else { return Vec::new() };
let Some(player) = state.player() else { return Vec::new() }; let Some(player) = state.player() else { return Vec::new() };
let grid = state.map_grid();
let grid = grid.as_deref();
let here_outdoors = outdoors(player.map); let here_outdoors = outdoors(player.map);
let mut out = Vec::new(); let mut out = Vec::new();
for (index, warp) in state.warps().iter().enumerate() { for (index, warp) in state.warps().iter().enumerate() {
@ -373,13 +416,16 @@ pub fn exits(state: &mut dyn MacroState) -> Vec<Exit> {
let way = if here_outdoors { Way::Route } else { Way::Exit }; let way = if here_outdoors { Way::Route } else { Way::Exit };
let into = geography::connected(player.map, edge); let into = geography::connected(player.map, edge);
for tile in edge_tiles(facing, size.width, size.height) { for tile in edge_tiles(facing, size.width, size.height) {
// Not `== Yes`: the walkable predicate's window is the screen, so the far edge of an // Not `== Yes`: without a grid the walkable predicate's window is the screen, so the
// outdoor map reads `Unknown` from anywhere but next to it, and filtering on `Yes` // far edge of an outdoor map reads `Unknown` from anywhere but next to it, and
// left a town's connections out of the exit list entirely -- which is half of why // filtering on `Yes` left a town's connections out of the exit list entirely -- which
// nothing could ever aim at Route 1 from the middle of Pallet Town. An unknown tile is // is half of why nothing could ever aim at Route 1 from the middle of Pallet Town. An
// a goal worth walking towards; the route search's own approach answer handles a goal // unknown tile is a goal worth walking towards; the route search's own approach answer
// it cannot reach yet, and a tile that turns out to be a wall costs one blocked walk. // handles a goal it cannot reach yet, and a tile that turns out to be a wall costs one
if state.walkable(tile.x, tile.y) != Walkable::No { // blocked walk. With a grid the answer is `Yes` or `No` for every edge tile of the map
// and this rejects the walls, which is what lets one plan reach the right end of a
// connection instead of the nearest of twenty tiles along it.
if walkable_at(state, grid, tile.x, tile.y) != Walkable::No {
out.push(Exit { id: ExitId::Edge(edge), tile, press: Some(facing), way, into }); out.push(Exit { id: ExitId::Edge(edge), tile, press: Some(facing), way, into });
} }
} }
@ -396,9 +442,12 @@ pub fn exits(state: &mut dyn MacroState) -> Vec<Exit> {
/// Each answer is a tile to stand on paired with the direction the new ground lies in, which is /// Each answer is a tile to stand on paired with the direction the new ground lies in, which is
/// the same shape `GO NPC` and `GO ITEM` use -- and the same press, which in the overworld walks /// the same shape `GO NPC` and `GO ITEM` use -- and the same press, which in the overworld walks
/// onto the tile when it is walkable, so the frontier the fly is looking at becomes ground it has /// onto the tile when it is walkable, so the frontier the fly is looking at becomes ground it has
/// stood on. Both tiles have to be walkable: an unreachable one is not ground, and the walkable /// stood on. Both tiles have to be walkable: an unreachable one is not ground.
/// predicate's window means the answer is always local to the player, which is what makes the ///
/// re-plan after every tile do the work of a long walk. /// **With a grid this is the whole map** (`docs/design/macros.md` section 15): the nearest unstood
/// walkable tile anywhere on it, which is what the operator asked for and what the route search
/// then plans one walk to. Without a grid it is what it always was -- the ten-by-nine window, so
/// the answer is local to the player and the long walk is done by re-planning.
/// ///
/// Deduplicated by the tile to stand on, in tile order, so the choice between two equally near /// Deduplicated by the tile to stand on, in tile order, so the choice between two equally near
/// frontiers does not depend on iteration order. /// frontiers does not depend on iteration order.
@ -415,13 +464,15 @@ pub fn exits(state: &mut dyn MacroState) -> Vec<Exit> {
pub fn frontier(state: &mut dyn MacroState) -> Vec<(Tile, Facing)> { pub fn frontier(state: &mut dyn MacroState) -> Vec<(Tile, Facing)> {
let Some(size) = state.map_size() else { return Vec::new() }; let Some(size) = state.map_size() else { return Vec::new() };
let Some(player) = state.player() else { return Vec::new() }; let Some(player) = state.player() else { return Vec::new() };
let grid = state.map_grid();
let grid = grid.as_deref();
let here = Tile::new(player.x, player.y); let here = Tile::new(player.x, player.y);
let held: Vec<Tile> = state.npcs().iter().map(|npc| Tile::new(npc.x, npc.y)).collect(); let held: Vec<Tile> = state.npcs().iter().map(|npc| Tile::new(npc.x, npc.y)).collect();
let mut out: Vec<(Tile, Facing)> = Vec::new(); let mut out: Vec<(Tile, Facing)> = Vec::new();
for y in 0..size.height { for y in 0..size.height {
for x in 0..size.width { for x in 0..size.width {
let tile = Tile::new(x, y); let tile = Tile::new(x, y);
if tile != here && state.walkable(x, y) != Walkable::Yes { if tile != here && walkable_at(state, grid, x, y) != Walkable::Yes {
continue; continue;
} }
if tile != here && held.contains(&tile) { if tile != here && held.contains(&tile) {
@ -432,7 +483,12 @@ pub fn frontier(state: &mut dyn MacroState) -> Vec<(Tile, Facing)> {
if next.x >= size.width || next.y >= size.height || next == here { if next.x >= size.width || next.y >= size.height || next == here {
continue; continue;
} }
if state.walkable(next.x, next.y) != Walkable::Yes { if walkable_at(state, grid, next.x, next.y) != Walkable::Yes {
continue;
}
// A step the tables refuse is not a way onto that ground, so the tile it leads to
// is not this tile's frontier -- somebody else's, if anything reaches it.
if grid.is_some_and(|grid| grid.walled(tile.x, tile.y, facing)) {
continue; continue;
} }
if held.contains(&next) { if held.contains(&next) {

View file

@ -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<Walkable>,
/// Row-major screen tile id per map tile, `None` where the tile was never decoded.
ids: Vec<Option<u8>>,
/// Row-major bitmask of the directions a step out of this tile is refused in
/// ([`MapGrid::wall`]).
walls: Vec<u8>,
}
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<usize> {
(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<u8> {
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. /// One entry of the current map's warp table: a door, a staircase, a cave mouth.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Warp { pub struct Warp {

View file

@ -4110,4 +4110,5 @@ fn a_scripted_push_back_records_the_tile_it_happened_on() {
); );
} }
mod map_aware;
mod shop_purchase; mod shop_purchase;

View file

@ -0,0 +1,344 @@
//! Walks planned over the whole map, and the same walks with the window as the only reading.
//!
//! `docs/design/macros.md` section 15. [`super::super::path`] has two readings of the ground now
//! and the interesting tests are the ones that tell them apart: a map bigger than the ten-by-nine
//! window, a frontier on the far side of it, and a wall the collision list cannot predict. The
//! fake here is deliberately not [`super::World`] — it implements the seam and nothing else, so a
//! failure is about the search rather than about a script's frames.
use std::collections::BTreeSet;
use std::sync::Arc;
use crate::pokemon_red::macros::cartridge::{Edge, ExitId, MacroState, Tile};
use crate::pokemon_red::macros::path::{self, Way};
use crate::pokemon_red::macros::state::{
BagItem, Battle, Connections, Facing, GameState, MapGrid, MapSize, Mon, Npc, Party, Pc, Player,
Scene, Shop, Sign, StartMenu, TextBox, Walkable, Warp,
};
/// A passable tile id and a wall tile id, for a grid built by hand.
const FLOOR: u8 = 0x01;
const WALL: u8 = 0x60;
/// The ground, and nothing else: the seam's questions about a map and the fly standing on it.
struct Ground {
map: u8,
size: MapSize,
player: Tile,
/// Tiles that are not walkable; everything else inside `size` is.
walls: BTreeSet<Tile>,
/// Steps the cartridge refuses although both tiles are passable, as the grid records them.
pair_walls: Vec<(Tile, Facing)>,
/// Ground the run has stood on, which is what makes a tile not a frontier.
stood: BTreeSet<Tile>,
warps: Vec<Warp>,
connections: Connections,
npcs: Vec<Npc>,
/// Whether the whole map is decoded, or only the window can answer.
decoded: bool,
}
impl Ground {
/// A map `wide` by `high` tiles with the fly at `player` and every tile walkable.
fn new(wide: u8, high: u8, player: (u8, u8)) -> Self {
Self {
map: 0,
size: MapSize { width: wide, height: high },
player: Tile::new(player.0, player.1),
walls: BTreeSet::new(),
pair_walls: Vec::new(),
stood: BTreeSet::new(),
warps: Vec::new(),
connections: Connections::default(),
npcs: Vec::new(),
decoded: true,
}
}
fn wall(mut self, x: u8, y: u8) -> Self {
self.walls.insert(Tile::new(x, y));
self
}
/// A column of wall with one gap in it, which is the shape that tells the two readings apart.
fn wall_column(mut self, x: u8, gap: u8) -> Self {
for y in 0..self.size.height {
if y != gap {
self.walls.insert(Tile::new(x, y));
}
}
self
}
/// Everything within `distance` of the fly counts as stood on, which is what a fly that has
/// been walking around one corner of a route has.
fn stood_around(mut self, distance: u32) -> Self {
for y in 0..self.size.height {
for x in 0..self.size.width {
let tile = Tile::new(x, y);
if tile.distance(self.player) <= distance {
self.stood.insert(tile);
}
}
}
self
}
/// The window reading only: what every walk had before section 15.
fn window_only(mut self) -> Self {
self.decoded = false;
self
}
fn pair_wall(mut self, from: (u8, u8), facing: Facing) -> Self {
self.pair_walls.push((Tile::new(from.0, from.1), facing));
self
}
fn connected(mut self, connections: Connections) -> Self {
self.connections = connections;
self
}
/// The ten-by-nine window agent A's predicate can answer for, which moves with the fly.
fn in_window(&self, x: u8, y: u8) -> bool {
let dx = i32::from(x) - i32::from(self.player.x);
let dy = i32::from(y) - i32::from(self.player.y);
(-4..=5).contains(&dx) && (-4..=4).contains(&dy)
}
fn walkable_tile(&self, x: u8, y: u8) -> bool {
x < self.size.width && y < self.size.height && !self.walls.contains(&Tile::new(x, y))
}
}
impl GameState for Ground {
fn scene(&mut self) -> Scene {
Scene::Overworld
}
fn player(&mut self) -> Option<Player> {
Some(Player { map: self.map, x: self.player.x, y: self.player.y, facing: Facing::Down })
}
fn map_size(&mut self) -> Option<MapSize> {
Some(self.size)
}
fn party(&mut self) -> Party {
Party { mons: Vec::<Mon>::new(), active: None }
}
fn battle(&mut self) -> Option<Battle> {
None
}
fn text_box(&mut self) -> TextBox {
TextBox { open: false, waiting: false }
}
fn start_menu(&mut self) -> Option<StartMenu> {
None
}
fn shop(&mut self) -> Option<Shop> {
None
}
fn pc(&mut self) -> Option<Pc> {
None
}
fn money(&mut self) -> u32 {
0
}
fn bag(&mut self) -> Vec<BagItem> {
Vec::new()
}
fn npcs(&mut self) -> Vec<Npc> {
self.npcs.clone()
}
fn signs(&mut self) -> Vec<Sign> {
Vec::new()
}
/// Agent A's predicate: the window, and `Unknown` outside it.
fn walkable(&mut self, x: u8, y: u8) -> Walkable {
if x >= self.size.width || y >= self.size.height {
return Walkable::No;
}
if !self.in_window(x, y) {
return Walkable::Unknown;
}
if self.walkable_tile(x, y) { Walkable::Yes } else { Walkable::No }
}
fn warps(&mut self) -> Vec<Warp> {
self.warps.clone()
}
fn connections(&mut self) -> Connections {
self.connections
}
}
impl MacroState for Ground {
fn map_grid(&mut self) -> Option<Arc<MapGrid>> {
if !self.decoded {
return None;
}
let mut grid = MapGrid::new(self.map, self.size.width, self.size.height);
for y in 0..self.size.height {
for x in 0..self.size.width {
let walkable = self.walkable_tile(x, y);
grid.set(
x,
y,
if walkable { FLOOR } else { WALL },
if walkable { Walkable::Yes } else { Walkable::No },
);
}
}
for (tile, facing) in &self.pair_walls {
grid.wall(tile.x, tile.y, *facing);
}
Some(Arc::new(grid))
}
fn tile_visited(&mut self, x: u8, y: u8) -> bool {
self.stood.contains(&Tile::new(x, y))
}
}
/// Walk a route's presses and report where they land and whether every tile was walkable.
fn walk(ground: &Ground, from: Tile, steps: &[Facing]) -> (Tile, bool) {
let mut at = from;
let mut clean = true;
for facing in steps {
let Some(next) = at.step(*facing) else {
clean = false;
break;
};
if !ground.walkable_tile(next.x, next.y) {
clean = false;
}
at = next;
}
(at, clean)
}
#[test]
fn one_plan_crosses_a_map_larger_than_the_window() {
// Twenty by eighteen — Pallet Town's size — with a wall down the middle and one gap in it,
// which is where a plan has to go and is off the screen from where the fly starts.
let start = (2, 2);
let goal = Tile::new(18, 16);
let mut ground = Ground::new(20, 18, start).wall_column(10, 1);
let route = path::route(&mut ground, &[goal]).expect("a route across the map");
assert_eq!(route.goal, Some(0), "the goal itself, not an approach to it");
let (landed, clean) = walk(&ground, Tile::new(start.0, start.1), &route.steps);
assert_eq!(landed, goal);
assert!(clean, "every tile of the plan is walkable ground");
// The gap is at the top, so the plan is longer than the Manhattan distance and knows it.
assert!(route.steps.len() > Tile::new(start.0, start.1).distance(goal) as usize);
// The same map with only the window to read: the plan sets off through a wall it cannot see.
let mut blind = Ground::new(20, 18, start).wall_column(10, 1).window_only();
let route = path::route(&mut blind, &[goal]).expect("a route through the unknown");
let (_, clean) = walk(&blind, Tile::new(start.0, start.1), &route.steps);
assert!(!clean, "the window cannot see the wall, so the plan walks into it");
}
#[test]
fn the_frontier_is_the_nearest_unstood_tile_anywhere_on_the_map() {
// A fly that has covered the ground around it. Nine tiles is the furthest corner of the
// ten-by-nine window (five across and four down), so every tile the window can answer for has
// been stood on and the nearest new ground is the first tile outside it.
let mut ground = Ground::new(20, 18, (3, 3)).stood_around(9);
let frontier = path::frontier(&mut ground);
assert!(!frontier.is_empty(), "the rest of the map is still new ground");
let (tile, facing) = frontier
.iter()
.copied()
.min_by_key(|(tile, _)| tile.distance(Tile::new(3, 3)))
.expect("a nearest frontier");
let new_ground = tile.step(facing).expect("the ground it faces");
assert!(!ground.tile_visited(new_ground.x, new_ground.y));
assert_eq!(tile.distance(Tile::new(3, 3)), 9, "the near side of the unstood ground");
// And a route to it, in one plan.
let goals: Vec<Tile> = frontier.iter().map(|(tile, _)| *tile).collect();
let route = path::route(&mut ground, &goals).expect("a route to the frontier");
assert!(route.goal.is_some());
// The window reading has nothing to offer here at all, which is the pad the operator saw
// hanging: every tile it can answer for has been stood on.
let mut blind = Ground::new(20, 18, (3, 3)).stood_around(9).window_only();
assert!(path::frontier(&mut blind).is_empty());
}
#[test]
fn a_step_the_tables_refuse_is_planned_around_rather_than_walked_into() {
// A tile-pair collision: both tiles passable, the step between them refused
// (`data/tilesets/pair_collision_tile_ids.asm`). The wall is the only way east on its row, so
// a search that did not know about it would plan straight through.
let goal = Tile::new(3, 0);
let mut ground = Ground::new(4, 3, (1, 0))
.pair_wall((1, 0), Facing::Right)
.pair_wall((2, 0), Facing::Left);
let route = path::route(&mut ground, &[goal]).expect("a route round the pair wall");
assert_eq!(route.steps.first(), Some(&Facing::Down), "round it, not through it");
let (landed, clean) = walk(&ground, Tile::new(1, 0), &route.steps);
assert_eq!(landed, goal);
assert!(clean);
assert!(!route.steps.is_empty());
// Without the pair rule the same map is a straight line east.
let mut open = Ground::new(4, 3, (1, 0));
let route = path::route(&mut open, &[goal]).expect("a route east");
assert_eq!(route.steps, vec![Facing::Right, Facing::Right]);
}
#[test]
fn a_connection_on_the_far_edge_is_a_goal_and_the_walls_along_it_are_not() {
// A route's north edge, off the screen from where the fly stands, with two walkable tiles on
// it. With the map decoded the exit list is those two tiles and not the whole row.
let mut ground = Ground::new(20, 18, (3, 16))
.connected(Connections { north: true, south: false, east: false, west: false });
for x in 0..20u8 {
if x != 7 && x != 8 {
ground = ground.wall(x, 0);
}
}
let exits = path::exits(&mut ground);
let north: Vec<ExitId> = exits
.iter()
.filter(|exit| exit.id == ExitId::Edge(Edge::North))
.map(|exit| exit.id)
.collect();
assert_eq!(north.len(), 2, "the two tiles a step north can be taken from");
let goals: Vec<Tile> = exits
.iter()
.filter(|exit| exit.way == Way::Route && exit.id == ExitId::Edge(Edge::North))
.map(|exit| exit.tile)
.collect();
assert!(goals.iter().all(|tile| tile.y == 0 && (tile.x == 7 || tile.x == 8)));
let route = path::route(&mut ground, &goals).expect("a route to the connection");
assert!(route.goal.is_some());
let (landed, clean) = walk(&ground, Tile::new(3, 16), &route.steps);
assert!(goals.contains(&landed));
assert!(clean, "one plan, across sixteen tiles of map, every tile of it known ground");
// With only the window, every tile of that edge is `Unknown` and so every one of them is an
// exit, walls included: the search's own approach answer is what used to carry the walk.
let mut blind = Ground::new(20, 18, (3, 16))
.connected(Connections { north: true, south: false, east: false, west: false })
.window_only();
let count = path::exits(&mut blind)
.iter()
.filter(|exit| exit.id == ExitId::Edge(Edge::North))
.count();
assert_eq!(count, 20);
}

View file

@ -0,0 +1,270 @@
//! 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;

View file

@ -0,0 +1,164 @@
//! 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 lower left of its own quadrant
/// ([`ANCHOR_ROW`], which the cartridge settled).
fn blockset() -> Vec<u8> {
let floor = [FLOOR; 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 = [
0x90, 0x91, 0x92, 0x93, //
NORTH_WEST, 0x94, NORTH_EAST, 0x95, //
0x96, 0x97, 0x98, 0x99, //
SOUTH_WEST, 0x9a, SOUTH_EAST, 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());
}

View file

@ -11,6 +11,7 @@ pub mod catalog;
#[cfg(test)] #[cfg(test)]
pub(crate) mod fake_wram; pub(crate) mod fake_wram;
pub mod macros; pub mod macros;
pub mod mapgrid;
pub mod maps; pub mod maps;
pub mod scene; pub mod scene;
pub mod state; pub mod state;
@ -343,6 +344,13 @@ impl MemoryReader for SampleCache<'_> {
self.bytes.insert(address, value); self.bytes.insert(address, value);
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<u8> {
self.source.read_rom(bank, address)
}
} }
fn word(memory: &mut impl MemoryReader, address: u16) -> u32 { fn word(memory: &mut impl MemoryReader, address: u16) -> u32 {

View file

@ -29,9 +29,10 @@ use super::macros::cartridge::{
Objective, PushedLedger, StoodLedger, TalkLedger, TalkTarget, TargetKey, TargetLedger, Tile, Objective, PushedLedger, StoodLedger, TalkLedger, TalkTarget, TargetKey, TargetLedger, Tile,
}; };
use super::macros::geography::Amenity; use super::macros::geography::Amenity;
use super::mapgrid::{self, MapGrids};
use super::macros::state::{ use super::macros::state::{
BagItem, Battle, BattleKind, BattleMenu, Connections, Cursor, EnemyMon, Facing, GameState, 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, Status, TextBox, Walkable, Warp,
}; };
use super::symbols::ram; use super::symbols::ram;
@ -775,12 +776,16 @@ pub fn npcs(memory: &mut dyn MemoryReader) -> Vec<Npc> {
npcs 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 /// `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 /// 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. /// it matches or hits `$ff`. `None` means the pointer is not one this module will follow, or the
fn passable(memory: &mut dyn MemoryReader, tile: u8) -> Option<bool> { /// 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<Vec<u8>> {
let low = u16::from(read(memory, ram::wTilesetCollisionPtr)); let low = u16::from(read(memory, ram::wTilesetCollisionPtr));
let high = u16::from(read(memory, ram::wTilesetCollisionPtr + 1)); let high = u16::from(read(memory, ram::wTilesetCollisionPtr + 1));
let base = high * 256 + low; let base = high * 256 + low;
@ -792,16 +797,198 @@ fn passable(memory: &mut dyn MemoryReader, tile: u8) -> Option<bool> {
} }
// No collision list in the game is longer than this; the bound is what stops a bad pointer // No collision list in the game is longer than this; the bound is what stops a bad pointer
// from walking the cartridge. // from walking the cartridge.
let mut list = Vec::new();
for offset in 0..64u16 { for offset in 0..64u16 {
match read(memory, base + offset) { let byte = read(memory, base + offset);
0xff => return Some(false), list.push(byte);
found if found == tile => return Some(true), if byte == mapgrid::TERMINATOR {
_ => {} return Some(list);
} }
} }
None 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<bool> {
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<MapGrid, GridRefusal> {
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;
// The map plus its border has to fit in `wOverworldMap`, which every real map does. One that
// does not is a header caught mid-load, and reading past the buffer would be reading somebody
// else's WRAM.
if usize::from(stride) * (usize::from(height_blocks) + mapgrid::MAP_BORDER * 2)
> mapgrid::OVERWORLD_MAP_BYTES
{
return Err(GridRefusal::NoHeader);
}
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)
}
/// 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
/// there is a frame or two on the way through a door where `wCurMap` is the map the fly is
/// arriving on and the header and the blocks are still the map it is leaving: the decode agrees
/// with the screen (both are the old map) and is filed under the new id. Measured on the
/// cartridge — the fly on Oak's lab doormat with `wCurMap` already reading `PALLET_TOWN` and the
/// header still the lab's ten-by-twelve (`tests/rom_map_grid.rs`).
///
/// Nothing in WRAM says "the map has finished loading", so the cache asks the cheapest question
/// that can tell: does the grid still agree with the screen about the tile the fly is standing on?
/// One byte, once per question. A grid that does not is dropped and decoded again, so a torn
/// frame's grid lives exactly as long as the tear does — and through it the cartridge is walking
/// the fly, which is not a frame any macro plans on.
fn still_the_loaded_map(
memory: &mut dyn MemoryReader,
grid: &MapGrid,
x: u8,
y: u8,
) -> bool {
match map_tile_id(memory, 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),
}
}
/// 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. /// 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 /// Mirrors `CheckTilePassable`: the tile id comes out of the screen buffer at the offset
@ -918,6 +1105,16 @@ pub struct PokeState<'a> {
/// Tiles the cartridge has pushed the fly off ([`MacroState::pushed_tile`]). Session state /// 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). /// beside the four above, owned by the same type (`infra/docs/macros-traps.md` row 37).
pushed: &'a dyn PushedLedger, 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> { impl<'a> PokeState<'a> {
@ -935,6 +1132,7 @@ impl<'a> PokeState<'a> {
stood: &NoStood, stood: &NoStood,
areas: &NoAreas, areas: &NoAreas,
pushed: &NoPushed, pushed: &NoPushed,
grids: None,
} }
} }
@ -948,6 +1146,7 @@ impl<'a> PokeState<'a> {
stood: &NoStood, stood: &NoStood,
areas: &NoAreas, areas: &NoAreas,
pushed: &NoPushed, pushed: &NoPushed,
grids: None,
} }
} }
@ -965,7 +1164,18 @@ impl<'a> PokeState<'a> {
areas: &'a dyn AreaLedger, areas: &'a dyn AreaLedger,
pushed: &'a dyn PushedLedger, pushed: &'a dyn PushedLedger,
) -> Self { ) -> 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 +1261,29 @@ impl MacroState for PokeState<'_> {
!controllable(self.memory) !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<std::sync::Arc<MapGrid>> {
let player = player(self.memory)?;
let size = map_size(self.memory)?;
if let Some(grids) = self.grids.as_deref()
&& let Some(grid) = grids.get(player.map, size.width, size.height)
&& still_the_loaded_map(self.memory, &grid, player.x, player.y)
{
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). /// 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: /// Gated on the mart scene being up, and that gate is the whole of the accuracy here:

View file

@ -5,7 +5,8 @@
//! passable-tile list walked out of ROM bank 0. //! passable-tile list walked out of ROM bank 0.
use super::*; 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}; use crate::pokemon_red::macros::state::{BattleKind, BattleMenu, ShopScreen, Sign};
/// A walkable tile id from `RedsHouse1_Coll`. /// 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_eq!(state.warps().len(), 1);
assert!(state.connections().south); 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<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)
}
#[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);
}

View file

@ -16,6 +16,7 @@ pub mod ram {
pub const wSpriteStateData1: u16 = 0xc100; // 49408 pub const wSpriteStateData1: u16 = 0xc100; // 49408
pub const wSpriteStateData2: u16 = 0xc200; // 49664 pub const wSpriteStateData2: u16 = 0xc200; // 49664
pub const wTileMap: u16 = 0xc3a0; // 50080 pub const wTileMap: u16 = 0xc3a0; // 50080
pub const wOverworldMap: u16 = 0xc6e8; // 50920
pub const wTopMenuItemY: u16 = 0xcc24; // 52260 pub const wTopMenuItemY: u16 = 0xcc24; // 52260
pub const wTopMenuItemX: u16 = 0xcc25; // 52261 pub const wTopMenuItemX: u16 = 0xcc25; // 52261
pub const wCurrentMenuItem: u16 = 0xcc26; // 52262 pub const wCurrentMenuItem: u16 = 0xcc26; // 52262
@ -59,6 +60,7 @@ pub mod ram {
pub const wCurMap: u16 = 0xd35e; // 54110 pub const wCurMap: u16 = 0xd35e; // 54110
pub const wYCoord: u16 = 0xd361; // 54113 pub const wYCoord: u16 = 0xd361; // 54113
pub const wXCoord: u16 = 0xd362; // 54114 pub const wXCoord: u16 = 0xd362; // 54114
pub const wCurMapTileset: u16 = 0xd367; // 54119
pub const wCurMapHeight: u16 = 0xd368; // 54120 pub const wCurMapHeight: u16 = 0xd368; // 54120
pub const wCurMapWidth: u16 = 0xd369; // 54121 pub const wCurMapWidth: u16 = 0xd369; // 54121
pub const wCurMapConnections: u16 = 0xd370; // 54128 pub const wCurMapConnections: u16 = 0xd370; // 54128
@ -68,6 +70,8 @@ pub mod ram {
pub const wSignCoords: u16 = 0xd4b1; // 54449 pub const wSignCoords: u16 = 0xd4b1; // 54449
pub const wSignTextIDs: u16 = 0xd4d1; // 54481 pub const wSignTextIDs: u16 = 0xd4d1; // 54481
pub const wNumSprites: u16 = 0xd4e1; // 54497 pub const wNumSprites: u16 = 0xd4e1; // 54497
pub const wTilesetBank: u16 = 0xd52b; // 54571
pub const wTilesetBlocksPtr: u16 = 0xd52c; // 54572
pub const wTilesetCollisionPtr: u16 = 0xd530; // 54576 pub const wTilesetCollisionPtr: u16 = 0xd530; // 54576
pub const wTilesetTalkingOverTiles: u16 = 0xd532; // 54578 pub const wTilesetTalkingOverTiles: u16 = 0xd532; // 54578
pub const wNumHoFTeams: u16 = 0xd5a2; // 54690 pub const wNumHoFTeams: u16 = 0xd5a2; // 54690

View file

@ -157,6 +157,10 @@ fn pad(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) {
use flybrain_gb::pokemon_red::macros::{geography, palette, path, plan}; use flybrain_gb::pokemon_red::macros::{geography, palette, path, plan};
use flybrain_gb::pokemon_red::macros::state::Walkable; use flybrain_gb::pokemon_red::macros::state::Walkable;
// Why the grid could not be decoded, read before the state borrows the emulator: it is the
// 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();
let ledger = AdapterLedger(adapter); let ledger = AdapterLedger(adapter);
let mut poke = flybrain_gb::pokemon_red::state::PokeState::with_ledger(gb, &ledger); let mut poke = flybrain_gb::pokemon_red::state::PokeState::with_ledger(gb, &ledger);
let state: &mut dyn MacroState = &mut poke; let state: &mut dyn MacroState = &mut poke;
@ -182,16 +186,52 @@ fn pad(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) {
let names: Vec<&str> = let names: Vec<&str> =
plan.slots.iter().flatten().map(|spec| spec.name).collect(); plan.slots.iter().flatten().map(|spec| spec.name).collect();
println!("- the pad: {names:?}"); println!("- the pad: {names:?}");
// The whole-map grid (`docs/design/macros.md` section 15), which is what the walks plan over
// now. Three numbers read a stalled walk: how much of the map is ground, how much of that the
// fly can actually get to from where it stands, and how much of *that* it has never stood on.
// A fly with 600 walkable tiles and 4 reachable ones is fenced in and no re-plan will help.
match state.map_grid() {
None => println!(
"- the map grid: none ({})",
refusal.map_or("unknown", |refusal| refusal.label())
),
Some(grid) => {
let unstood = grid
.walkable_tiles()
.into_iter()
.filter(|(x, y)| !state.tile_visited(*x, *y))
.count();
println!(
"- the map grid: {}x{} walkable {} reachable {} unstood {} unknown {}",
grid.width(),
grid.height(),
grid.walkable_count(),
grid.reachable_from(player.x, player.y),
unstood,
grid.unknown_count()
);
}
}
// The `v` column is the *adapter's* lifetime exploration ledger and nothing else. The // 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 // 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 // palette, which this probe does not reach into, so a doormat the running fly has already
// marked still prints as unrecorded here. That is the point of the column: it shows what the // marked still prints as unrecorded here. That is the point of the column: it shows what the
// reward ledger can and cannot answer. // reward ledger can and cannot answer.
println!("\n### The ground (`v` = the adapter's lifetime ledger only)\n\n```"); // The grid's reading of the ground where there is one, the window's otherwise, said out loud
// so the map below cannot be mistaken for the other reading.
let grid = state.map_grid();
println!(
"\n### The ground, as the {} reads it (`v` = the adapter's lifetime ledger only)\n\n```",
if grid.is_some() { "map grid" } else { "ten-by-nine window" }
);
for y in 0..size.height { for y in 0..size.height {
let row: Vec<String> = (0..size.width) let row: Vec<String> = (0..size.width)
.map(|x| { .map(|x| {
let walk = match state.walkable(x, y) { let answer = match grid.as_deref() {
Some(grid) => grid.walkable(x, y),
None => state.walkable(x, y),
};
let walk = match answer {
Walkable::Yes => '.', Walkable::Yes => '.',
Walkable::No => '#', Walkable::No => '#',
Walkable::Unknown => '?', Walkable::Unknown => '?',
@ -208,7 +248,11 @@ fn pad(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) {
let mut n = 0; let mut n = 0;
for y in 0..size.height { for y in 0..size.height {
for x in 0..size.width { for x in 0..size.width {
if state.walkable(x, y) == Walkable::Yes && !state.tile_visited(x, y) { let answer = match grid.as_deref() {
Some(grid) => grid.walkable(x, y),
None => state.walkable(x, y),
};
if answer == Walkable::Yes && !state.tile_visited(x, y) {
n += 1; n += 1;
} }
} }

View file

@ -205,6 +205,12 @@ struct Trace {
/// Every input of the detector's disputed branch on the last frame /// Every input of the detector's disputed branch on the last frame
/// (`pokemon_red::scene::why_unknown`). /// (`pokemon_red::scene::why_unknown`).
ended_why: String, ended_why: String,
/// The whole-map grid on the last frame, as [`grid_line`] reads it
/// (`docs/design/macros.md` section 15): how much of the map is ground, how much of it the fly
/// could reach from where it stopped, and how much of that it had never stood on. A hunt that
/// ends with reachable far below walkable ended fenced in, which no amount of re-planning was
/// ever going to fix.
ended_grid: String,
/// How the dialog branch's two halves agreed, per frame: `wFontLoaded`'s bit against the four /// How the dialog branch's two halves agreed, per frame: `wFontLoaded`'s bit against the four
/// corners and against the whole `TextBoxBorder` (`pokemon_red::state::dialog_border`). /// corners and against the whole `TextBoxBorder` (`pokemon_red::state::dialog_border`).
/// ///
@ -379,6 +385,7 @@ fn run(
ended_in: ("", String::new()), ended_in: ("", String::new()),
longest_scene: BTreeMap::new(), longest_scene: BTreeMap::new(),
ended_why: String::new(), ended_why: String::new(),
ended_grid: String::new(),
font_corners_border: 0, font_corners_border: 0,
font_corners_no_border: 0, font_corners_no_border: 0,
font_no_corners: 0, font_no_corners: 0,
@ -544,6 +551,9 @@ fn run(
next_trace = ms + trace_every_ms; next_trace = ms + trace_every_ms;
let scene = macros.as_ref().map_or("", MacroLayer::scene_name); let scene = macros.as_ref().map_or("", MacroLayer::scene_name);
use flybrain_gb::pokemon_red::macros::cartridge::{MacroState, Tile}; use flybrain_gb::pokemon_red::macros::cartridge::{MacroState, Tile};
// Read before the state borrows the emulator: this is the same call the state makes,
// and the only one that can say *which* refusal a frame is.
let refusal = flybrain_gb::pokemon_red::state::map_grid(&mut emulator).err();
let mut state = flybrain_gb::pokemon_red::state::PokeState::new(&mut emulator); let mut state = flybrain_gb::pokemon_red::state::PokeState::new(&mut emulator);
let state: &mut dyn MacroState = &mut state; let state: &mut dyn MacroState = &mut state;
let player = state.player(); let player = state.player();
@ -551,9 +561,10 @@ fn run(
let ahead = Tile::new(player.x, player.y).step(player.facing)?; let ahead = Tile::new(player.x, player.y).step(player.facing)?;
flybrain_gb::pokemon_red::macros::path::target_at(state, ahead) flybrain_gb::pokemon_red::macros::path::target_at(state, ahead)
}); });
let ground = grid_line(state, player, refusal);
let why = flybrain_gb::pokemon_red::scene::why_unknown(&mut emulator); let why = flybrain_gb::pokemon_red::scene::why_unknown(&mut emulator);
println!( println!(
"trace {:7.2} min scene={scene:<9} player={player:?} ahead={ahead:?}\n {why}", "trace {:7.2} min scene={scene:<9} player={player:?} ahead={ahead:?}\n {why}\n {ground}",
(ms - began_ms) / MINUTE_MS (ms - began_ms) / MINUTE_MS
); );
} }
@ -624,6 +635,14 @@ fn run(
adapter.mode().to_string(), adapter.mode().to_string(),
); );
trace.ended_why = flybrain_gb::pokemon_red::scene::why_unknown(&mut emulator); trace.ended_why = flybrain_gb::pokemon_red::scene::why_unknown(&mut emulator);
trace.ended_grid = {
use flybrain_gb::pokemon_red::macros::cartridge::MacroState;
let refusal = flybrain_gb::pokemon_red::state::map_grid(&mut emulator).err();
let mut state = flybrain_gb::pokemon_red::state::PokeState::new(&mut emulator);
let state: &mut dyn MacroState = &mut state;
let player = state.player();
grid_line(state, player, refusal)
};
trace.ended_ms = agent.network.ms; trace.ended_ms = agent.network.ms;
trace.wall_seconds = began_wall.elapsed().as_secs_f64(); trace.wall_seconds = began_wall.elapsed().as_secs_f64();
trace trace
@ -722,6 +741,42 @@ fn walk_report(trace: &Trace) {
} }
} }
/// The whole-map grid in one line: what a stalled walk looks like from outside.
///
/// `docs/design/macros.md` section 15. Walkable is how much of the map is ground, reachable is
/// how much of that the fly can get to from where it is standing (the directed walls respected),
/// and unstood is how much of *that* this run has never been on -- which is the frontier's own
/// candidate pool. A walk that cannot finish is one of three shapes and these numbers tell them
/// apart: fenced in (reachable far below walkable), nothing left to explore (unstood zero), or no
/// grid at all, in which case the walks are back on the ten-by-nine window and the reason is
/// named.
fn grid_line(
state: &mut dyn flybrain_gb::pokemon_red::macros::cartridge::MacroState,
player: Option<flybrain_gb::pokemon_red::macros::state::Player>,
refusal: Option<flybrain_gb::pokemon_red::state::GridRefusal>,
) -> String {
let Some(player) = player else { return "grid: no player".to_string() };
let Some(grid) = state.map_grid() else {
// Which of section 15's refusals this frame is, rather than a bare "none": a walk that is
// on the window reading should say why it is.
return format!("grid: none ({})", refusal.map_or("unknown", |refusal| refusal.label()));
};
let unstood = grid
.walkable_tiles()
.into_iter()
.filter(|(x, y)| !state.tile_visited(*x, *y))
.count();
format!(
"grid map={:#04x} {}x{} walkable={} reachable={} unstood={}",
grid.map(),
grid.width(),
grid.height(),
grid.walkable_count(),
grid.reachable_from(player.x, player.y),
unstood
)
}
fn main() { fn main() {
let Some(path) = std::env::var_os("FLY_ROM") else { let Some(path) = std::env::var_os("FLY_ROM") else {
println!( println!(
@ -796,6 +851,7 @@ fn main() {
println!("| --- | ---: |"); println!("| --- | ---: |");
println!("| rung reached | {} |", trace.rungs.iter().map(|(rank, ..)| *rank).max().unwrap_or(0)); println!("| rung reached | {} |", trace.rungs.iter().map(|(rank, ..)| *rank).max().unwrap_or(0));
println!("| distinct (map, tile) | {} |", ground.len()); println!("| distinct (map, tile) | {} |", ground.len());
println!("| the map at the end | {} |", trace.ended_grid);
println!("| macros started | {} |", trace.starts.len()); println!("| macros started | {} |", trace.starts.len());
for (outcome, count) in &trace.outcomes { for (outcome, count) in &trace.outcomes {
println!("| {outcome} | {count} |"); println!("| {outcome} | {count} |");

View file

@ -0,0 +1,733 @@
//! The whole-map walkability grid against the cartridge (`docs/design/macros.md` section 15).
//!
//! Gated on `FLY_ROM` and on a `FLYSIM01` checkpoint, and skips cleanly without either. The
//! checkpoints live outside the tree (`.local/` is not tracked) and are the release container's
//! own states, pulled read-only:
//!
//! ```sh
//! FLY_ROM="$HOME/…/Pokemon Red (U) [S][BF].gb" \
//! FLY_GRID_CHECKPOINT=.local/checkpoints/rank9-viridian-forest.checkpoint \
//! cargo test --release -p flysim --test rom_map_grid -- --nocapture
//! ```
//!
//! ## What only the cartridge can answer
//!
//! The unit tests in `pokemon_red/mapgrid/tests.rs` decode a made-up tileset, and the ones in
//! `pokemon_red/state/tests.rs` decode synthetic WRAM with a synthetic blockset in a synthetic
//! bank. Neither can say that those are the bytes the *game* writes: a wrong border, a wrong
//! stride or the wrong corner of a block all still answer, and answer plausibly. Three things here
//! need a running cartridge:
//!
//! 1. **the decode against the window predicate**, on every tile of the map the ten-by-nine
//! screen window can answer for — the same comparison the reader makes before it trusts
//! itself, done over the whole window rather than the player's own neighbourhood;
//! 2. **the decode against real presses** (the survey method of `docs/design/room-escape.md`
//! section 3): every tile the survey can stand on is `Yes`, and every step it refuses is `No`,
//! a directed wall, or a tile a sprite is standing on;
//! 3. **the walks**: that `GO FRONTIER` aims at ground outside the window and reaches it, and that
//! a way out of the map is one plan away rather than a re-plan at every window edge.
use std::collections::{BTreeMap, BTreeSet};
use flybrain_gb::macros::{MacroPalette, Started};
use flybrain_gb::pokemon_red::macros::cartridge::{MacroState, Tile};
use flybrain_gb::pokemon_red::macros::palette::Palette;
use flybrain_gb::pokemon_red::macros::state::{Facing, MapGrid, Walkable};
use flybrain_gb::pokemon_red::macros::{PokemonPalette, path};
use flybrain_gb::pokemon_red::symbols::ram;
use flybrain_gb::pokemon_red::{PokemonRedReward, scene, state};
use flybrain_gb::{
AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, buttons,
};
/// One game frame at the Game Boy's real rate, for the adapter's brain clock.
const MS_PER_FRAME: f64 = 1000.0 / 59.7275;
/// Frames a restored state is given before anything reads the screen buffer.
///
/// `wTileMap` is the current view only on a *running* machine: read straight after
/// `import_state`, with no frame in between, it holds the view from wherever the state was taken
/// (`docs/design/macros-wram.md`). Twenty is what `tests/rom_scene.rs` gives every restored state
/// and what the survey below gives every one of its own.
const SETTLE_FRAMES: u32 = 20;
/// The seed the macro tests run the executor with. Nothing here depends on it: the slot is chosen
/// by name, not by a readout.
const SEED: u32 = 20_260_922;
fn rom() -> Option<Vec<u8>> {
let path = std::env::var_os("FLY_ROM")?;
match std::fs::read(&path) {
Ok(bytes) => Some(bytes),
Err(error) => panic!("FLY_ROM is set to {path:?} but could not be read: {error}"),
}
}
fn checkpoint() -> Option<flysim::store::Checkpoint> {
let path = std::env::var_os("FLY_GRID_CHECKPOINT")
.or_else(|| std::env::var_os("FLY_TRAP_CHECKPOINT"))
.or_else(|| std::env::var_os("FLY_MACRO_CHECKPOINT"))?;
Some(
flysim::store::load(std::path::Path::new(&path))
.expect("the checkpoint should be a FLYSIM01 envelope"),
)
}
macro_rules! skip_without {
() => {
match (rom(), checkpoint()) {
(Some(rom), Some(checkpoint)) => (rom, checkpoint),
(None, _) => {
eprintln!("skipped: FLY_ROM is not set");
return;
}
(_, None) => {
eprintln!("skipped: no FLY_GRID_CHECKPOINT / FLY_TRAP_CHECKPOINT");
return;
}
}
};
}
fn emulator(rom: &[u8]) -> Emulator {
Emulator::new(rom, DEFAULT_AUDIO_FREQUENCY, DEFAULT_AUDIO_FRAMES)
.expect("binjgb should accept the cartridge")
}
/// A cartridge resumed from a checkpoint, with the reward ledger the checkpoint carried and the
/// macro palette the sim loop runs.
struct Game {
gb: Emulator,
adapter: PokemonRedReward,
palette: PokemonPalette,
ms: f64,
}
impl Game {
fn resume(rom: &[u8], checkpoint: &flysim::store::Checkpoint) -> Self {
let mut gb = emulator(rom);
let mut adapter = PokemonRedReward::new();
gb.import_state(&checkpoint.runtime.emulator).expect("the checkpoint's emulator state");
adapter.import_state(&checkpoint.runtime.reward).expect("the checkpoint's reward ledger");
let mut game =
Self { gb, adapter, palette: PokemonPalette::new(SEED), ms: 0.0 };
game.settle_overworld();
if let Some(target) = std::env::var("FLY_GRID_TO_MAP")
.ok()
.and_then(|value| u8::from_str_radix(value.trim_start_matches("0x"), 16).ok())
{
game.reach_map(target);
}
game
}
/// Drive the cartridge with raw presses until the fly is standing on `target`.
///
/// The test's knowledge of the game, not the fly's, and the same device
/// `tests/rom_scene.rs`'s biased walk is: it decides which state gets *produced* and nothing
/// it does is asserted. What it is for is the second map of the survey
/// (`docs/design/macros.md` section 15 asks for two): no checkpoint in `.local/` is standing
/// on Pallet Town, and Oak's lab is one door south of it.
///
/// A biased random walk rather than the macros, deliberately: driving the state under test
/// into place with the macros under test is circular, and a walk needs no map knowledge. The
/// cycle is `tests/rom_scene.rs`'s — a direction, then B, which closes whatever a stray press
/// opened.
fn reach_map(&mut self, target: u8) {
let mut seed = u64::from(SEED);
let toward = if target < self.map() { buttons::DOWN } else { buttons::UP };
let mut arrived: Option<Tile> = None;
let mut walked = 0;
for frame in 0..90_000u32 {
if self.map() == target && walked > 0 {
eprintln!(
"reached map {target:#04x} after {frame} frames, {walked} tiles into it, at \
{:?}",
self.tile()
);
self.settle_overworld();
return;
}
// A few tiles *into* the map, not the doormat: a warp writes the map id before the
// header and the blocks, so the arrival frame itself is the torn one
// (`pokemon_red::state::still_the_loaded_map`).
if self.map() == target && self.tile() != arrived.unwrap_or(Tile::new(255, 255)) {
match arrived {
None => arrived = Some(self.tile()),
Some(_) => walked += 1,
}
}
seed = seed
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
let random = [buttons::UP, buttons::DOWN, buttons::LEFT, buttons::RIGHT]
[((seed >> 33) % 4) as usize];
// Half biased, half random: the random half is what gets the walk around the
// furniture the bias walks it into.
let step = if (seed >> 41).is_multiple_of(2) { toward } else { random };
let mask = match frame % 48 {
0..=7 | 24..=31 => step,
12..=15 => buttons::B,
_ => buttons::NONE,
};
self.frame(mask);
}
assert_eq!(self.map(), target, "FLY_GRID_TO_MAP: never reached map {target:#04x}");
}
/// Settle a restored state until the cartridge is showing the overworld, pressing nothing.
///
/// Two reasons, both measured. `wTileMap` is the current view only on a *running* machine, so
/// every restored state needs frames before anything reads the screen
/// (`docs/design/macros-wram.md`); and a checkpoint is a frame of a live run, which can be
/// mid-step, mid-warp or inside a script that is walking the fly — Viridian City's own
/// checkpoint reads `Scene::Unknown` and its screen buffer is a tile out of step with its
/// coordinates, which is exactly the frame [`state::map_grid`] refuses rather than decodes.
/// A grid is a question about the overworld, so the test asks it there.
fn settle_overworld(&mut self) {
for _ in 0..1_200 {
self.frame(buttons::NONE);
if matches!(scene::detect(&mut self.gb), scene::Scene::Overworld) {
self.settle(SETTLE_FRAMES);
return;
}
}
eprintln!("the restored state never settled into the overworld");
}
/// Run frames with nothing held, sampling the adapter and observing the palette as the sim
/// loop does.
fn settle(&mut self, frames: u32) {
for _ in 0..frames {
self.frame(buttons::NONE);
}
}
/// One frame with these buttons held, then the adapter's sample and the palette's observation
/// — the sim loop's order.
fn frame(&mut self, mask: u8) {
self.gb.set_buttons(mask);
self.gb.run_frame().expect("a frame should complete");
self.ms += MS_PER_FRAME;
self.adapter.sample(&mut self.gb, self.ms);
let ledger = AdapterLedger(&self.adapter);
self.palette.clock(self.ms);
let _ = self.palette.observe(&mut self.gb, &ledger);
}
fn map(&mut self) -> u8 {
self.gb.read_wram(ram::wCurMap)
}
fn tile(&mut self) -> Tile {
Tile::new(self.gb.read_wram(ram::wXCoord), self.gb.read_wram(ram::wYCoord))
}
/// The decoded grid, or the reason there is none.
fn grid(&mut self) -> Result<MapGrid, state::GridRefusal> {
state::map_grid(&mut self.gb)
}
/// Run `body` with the state the macros read, over this game's ledgers.
fn with_state<T>(&mut self, body: impl FnOnce(&mut dyn MacroState) -> T) -> T {
let ledger = AdapterLedger(&self.adapter);
let mut poke = state::PokeState::with_ledger(&mut self.gb, &ledger);
body(&mut poke)
}
/// The slot this scene binds `name` to, or `None` when the button is not on the pad.
fn slot(&mut self, name: &str) -> Option<u8> {
self.with_state(|state| {
let scene = state.scene();
let palette = Palette::for_scene(scene, state);
palette
.slots
.iter()
.enumerate()
.find(|(_, spec)| spec.is_some_and(|spec| spec.name == name))
.map(|(slot, _)| slot as u8)
})
}
/// Start `name` and run it to its outcome, returning the outcome's label and the frames spent.
///
/// The slot is chosen by name rather than by a readout, which is what makes this a test of the
/// macro instead of a test of the decoder: `docs/design/macros.md` section 12 has the fly
/// choosing, and what is under test here is what the chosen walk does.
fn run_macro(&mut self, name: &str) -> Option<(String, u32)> {
let slot = self.slot(name)?;
let ledger = AdapterLedger(&self.adapter);
match self.palette.start(slot, &mut self.gb, &ledger) {
Started::Refused { reason, .. } => Some((format!("refused: {reason}"), 0)),
Started::Running(_) => {
let mut frames = 0;
loop {
let ledger = AdapterLedger(&self.adapter);
let mask = self.palette.step(&mut self.gb, &ledger);
match mask {
None => break,
Some(mask) => {
self.frame(mask);
frames += 1;
}
}
if frames > 4_000 {
break;
}
}
let outcome = self
.palette
.take_finished()
.map(|(_, outcome)| format!("{outcome:?}"))
.unwrap_or_else(|| "none".to_string());
Some((outcome, frames))
}
}
}
}
/// Whether a tile is inside the ten-by-nine window the screen buffer can answer for.
fn in_window(player: Tile, x: u8, y: u8) -> bool {
let dx = i32::from(x) - i32::from(player.x);
let dy = i32::from(y) - i32::from(player.y);
(-4..=5).contains(&dx) && (-4..=4).contains(&dy)
}
#[test]
fn the_decoded_grid_agrees_with_the_window_on_every_tile_the_window_can_answer() {
let (rom, checkpoint) = skip_without!();
let mut game = Game::resume(&rom, &checkpoint);
let map = game.map();
let here = game.tile();
let grid = match game.grid() {
Ok(grid) => grid,
Err(refusal) => {
eprintln!("skipped: no grid on map {map:#04x} ({})", refusal.label());
return;
}
};
eprintln!(
"map {map:#04x} {}x{} at {here:?}: walkable {}, reachable {}, unknown {}",
grid.width(),
grid.height(),
grid.walkable_count(),
grid.reachable_from(here.x, here.y),
grid.unknown_count()
);
assert_eq!(grid.map(), map);
assert_eq!(grid.unknown_count(), 0, "every block of the map is in the blockset");
let mut checked = 0;
let mut disagreements = Vec::new();
for y in 0..grid.height() {
for x in 0..grid.width() {
let Some(tile) = state::map_tile_id(&mut game.gb, x, y) else { continue };
let window = state::walkable(&mut game.gb, x, y);
checked += 1;
if grid.tile_id(x, y) != Some(tile) || grid.walkable(x, y) != window {
disagreements.push((x, y, tile, grid.tile_id(x, y), window, grid.walkable(x, y)));
}
assert!(in_window(here, x, y), "the window answered outside its own window");
}
}
eprintln!("{checked} tiles the window can answer for, {} disagreements", disagreements.len());
assert!(disagreements.is_empty(), "{disagreements:?}");
assert!(checked >= 40, "the window should answer for most of its ninety tiles: {checked}");
}
/// The survey: walk the map with real presses on throwaway emulators, tile by tile.
///
/// `docs/design/room-escape.md` section 3's method, and the one `tests/rom_scene.rs` used to pin
/// the window predicate's screen origin. Each tile keeps the state the walk arrived on, so every
/// press is made from the tile it belongs to rather than from wherever a long walk ended up.
///
/// Bounded by `FLY_GRID_SURVEY_TILES` (default [`SURVEY_TILES`]) because a whole forest is
/// 700-odd tiles and four presses each, and the claim is about the *rule*, not about coverage: a
/// wrong corner, a wrong stride or a wrong border disagrees within a dozen tiles.
type Survey = (
BTreeMap<Tile, Vec<u8>>,
BTreeSet<(Tile, &'static str)>,
BTreeSet<(Tile, &'static str)>,
BTreeSet<(Tile, &'static str)>,
BTreeSet<(Tile, &'static str)>,
);
/// Tiles the survey stands on before it stops, unless `FLY_GRID_SURVEY_TILES` says otherwise.
const SURVEY_TILES: usize = 120;
/// The four presses, with the direction each one is and the tile offset it aims at.
const PRESSES: [(&str, u8, Facing); 4] = [
("up", buttons::UP, Facing::Up),
("down", buttons::DOWN, Facing::Down),
("left", buttons::LEFT, Facing::Left),
("right", buttons::RIGHT, Facing::Right),
];
fn survey(rom: &[u8], start: &[u8], budget: usize) -> Survey {
let read = |probe: &mut Emulator| {
(
probe.read_wram(ram::wCurMap),
Tile::new(probe.read_wram(ram::wXCoord), probe.read_wram(ram::wYCoord)),
)
};
// 120 frames of held direction, then a release and twenty frames to settle, which are the two
// numbers `docs/design/macros-wram.md` measured: a press the player is not already facing
// turns first and steps second (53 frames from one staircase), and a state exported with a
// button held does not respond to that button after the import.
let step = |probe: &mut Emulator, mask: u8| {
let before = read(probe);
probe.set_buttons(mask);
let mut moved = false;
for _ in 0..120 {
probe.run_frame().expect("a frame should complete");
if read(probe) != before {
moved = true;
break;
}
}
probe.set_buttons(buttons::NONE);
for _ in 0..SETTLE_FRAMES {
probe.run_frame().expect("a frame should complete");
}
(read(probe), moved)
};
let restore = |state: &[u8]| {
let mut probe = emulator(rom);
probe.import_state(state).expect("a surveyed state should import");
probe
};
let mut first = restore(start);
for _ in 0..SETTLE_FRAMES {
first.run_frame().expect("a frame should complete");
}
let (map, here) = read(&mut first);
let mut states: BTreeMap<Tile, Vec<u8>> = BTreeMap::new();
let mut refused: BTreeSet<(Tile, &'static str)> = BTreeSet::new();
let mut left: BTreeSet<(Tile, &'static str)> = BTreeSet::new();
let mut interrupted: BTreeSet<(Tile, &'static str)> = BTreeSet::new();
let mut blocked_by_sprite: BTreeSet<(Tile, &'static str)> = BTreeSet::new();
states.insert(here, first.export_state().expect("a settled state should export"));
let mut queue = std::collections::VecDeque::from([here]);
while let Some(tile) = queue.pop_front() {
if states.len() >= budget {
break;
}
for (name, mask, _) in PRESSES {
let mut probe = restore(&states[&tile]);
// A press the cartridge answers with something other than a step -- a wild encounter
// in the grass, a bug catcher's line of sight, a script that takes the joypad -- says
// nothing about the ground either way, so the survey records it as its own category
// rather than as a wall. Viridian Forest is full of them: three tiles of the first
// hundred and twenty started a battle in three directions each.
if !matches!(scene::detect(&mut probe), scene::Scene::Overworld) {
interrupted.insert((tile, name));
continue;
}
let ((there, at), moved) = step(&mut probe, mask);
if !matches!(scene::detect(&mut probe), scene::Scene::Overworld) {
interrupted.insert((tile, name));
continue;
}
if !moved {
// **A person in the way is not a wall.** The collision table knows nothing about
// sprites and says so (`docs/design/macros-wram.md`), and Pallet Town's two
// villagers walk: whether one was standing on the target tile has to be read from
// the frame the press was made in, not from the state the survey started in.
let facing = PRESSES
.iter()
.find(|(press, _, _)| *press == name)
.map(|(_, _, facing)| *facing)
.expect("a known press");
let ahead = tile.step(facing);
let sprite = ahead.is_some_and(|ahead| {
state::npcs(&mut probe).iter().any(|npc| (npc.x, npc.y) == (ahead.x, ahead.y))
});
if sprite {
blocked_by_sprite.insert((tile, name));
} else {
refused.insert((tile, name));
}
continue;
}
if there != map {
// A warp: the press left the map, so it says nothing about the ground here.
left.insert((tile, name));
continue;
}
if let std::collections::btree_map::Entry::Vacant(entry) = states.entry(at) {
entry.insert(probe.export_state().expect("state export"));
queue.push_back(at);
}
}
}
(states, refused, left, interrupted, blocked_by_sprite)
}
#[test]
fn the_decoded_grid_matches_a_survey_with_real_presses() {
let (rom, checkpoint) = skip_without!();
let mut game = Game::resume(&rom, &checkpoint);
let map = game.map();
let grid = match game.grid() {
Ok(grid) => grid,
Err(refusal) => {
eprintln!("skipped: no grid on map {map:#04x} ({})", refusal.label());
return;
}
};
let npcs: BTreeSet<Tile> = game
.with_state(|state| state.npcs().iter().map(|npc| Tile::new(npc.x, npc.y)).collect());
let budget = std::env::var("FLY_GRID_SURVEY_TILES")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(SURVEY_TILES);
let start = game.gb.export_state().expect("the settled state should export");
let (stood, refused, left, interrupted, sprites) = survey(&rom, &start, budget);
eprintln!(
"survey of map {map:#04x}: {} tiles stood on, {} refused presses, {} presses that left \
the map, {} the cartridge answered with a battle or a script, {} a sprite was standing \
in the way of",
stood.len(),
refused.len(),
left.len(),
interrupted.len(),
sprites.len()
);
assert!(stood.len() > 8, "the survey barely moved: {} tiles", stood.len());
// Every tile the survey stood on is ground the grid calls walkable. This is the half that
// catches a decode that is too *strict* — a wall where the game lets the fly stand.
let mut wrong_walls = Vec::new();
for tile in stood.keys() {
if grid.walkable(tile.x, tile.y) != Walkable::Yes {
wrong_walls.push((*tile, grid.walkable(tile.x, tile.y), grid.tile_id(tile.x, tile.y)));
}
}
assert!(wrong_walls.is_empty(), "tiles the survey stood on that the grid calls walls: {wrong_walls:?}");
// Every press the cartridge refused is a wall in the grid, a directed wall out of that tile,
// or a tile a sprite is standing on — the three things a refusal can be
// (`docs/design/macros.md` section 15). This is the half that catches a decode that is too
// *permissive*.
let mut unexplained = Vec::new();
for (tile, name) in &refused {
let (_, _, facing) = PRESSES.iter().find(|(press, _, _)| press == name).copied().unwrap();
let ahead = tile.step(facing);
let explained = match ahead {
None => true,
Some(ahead) => {
ahead.x >= grid.width()
|| ahead.y >= grid.height()
|| grid.walkable(ahead.x, ahead.y) != Walkable::Yes
|| grid.walled(tile.x, tile.y, facing)
|| npcs.contains(&ahead)
}
};
if !explained {
unexplained.push((
*tile,
grid.tile_id(tile.x, tile.y),
*name,
ahead,
ahead.map(|at| grid.tile_id(at.x, at.y)),
grid.walled(tile.x, tile.y, facing),
));
}
}
assert!(
unexplained.is_empty(),
"presses the cartridge refused that the grid calls open ground: {unexplained:?}"
);
// And every step that *worked* is one the grid would have planned: walkable, not walled.
let mut wrongly_walled = Vec::new();
for tile in stood.keys() {
for (name, _, facing) in PRESSES {
if refused.contains(&(*tile, name))
|| left.contains(&(*tile, name))
|| interrupted.contains(&(*tile, name))
|| sprites.contains(&(*tile, name))
{
continue;
}
let Some(ahead) = tile.step(facing) else { continue };
if !stood.contains_key(&ahead) {
continue;
}
if grid.walkable(ahead.x, ahead.y) != Walkable::Yes
|| grid.walled(tile.x, tile.y, facing)
{
wrongly_walled.push((*tile, name, ahead));
}
}
}
assert!(
wrongly_walled.is_empty(),
"steps the cartridge made that the grid calls walls: {wrongly_walled:?}"
);
eprintln!(
"the grid agrees with every one of {} stood tiles and {} refused presses on map {map:#04x}",
stood.len(),
refused.len()
);
}
#[test]
fn go_frontier_aims_outside_the_window_and_walks_there() {
let (rom, checkpoint) = skip_without!();
let mut game = Game::resume(&rom, &checkpoint);
let map = game.map();
let Ok(grid) = game.grid() else {
eprintln!("skipped: no grid on map {map:#04x}");
return;
};
// **The plan.** Every frontier tile the ten-by-nine window cannot answer for is ground the
// fly could not have aimed at before section 15, and a route to the nearest of them is a
// plan that crosses the window with no guesses in it.
let (here, outside, plan) = game.with_state(|state| {
let here = state.player().map(|player| Tile::new(player.x, player.y)).expect("a player");
let outside: Vec<Tile> = path::frontier(state)
.into_iter()
.map(|(tile, _)| tile)
.filter(|tile| !in_window(here, tile.x, tile.y))
.collect();
let plan = path::route(state, &outside);
(here, outside, plan)
});
eprintln!(
"map {map:#04x} at {here:?}: {} frontier tiles outside the window, plan {:?} steps",
outside.len(),
plan.as_ref().map(|route| route.steps.len())
);
if outside.is_empty() {
eprintln!("skipped: every frontier tile on this map is inside the window");
return;
}
let plan = plan.expect("a route to the frontier beyond the window");
assert!(plan.goal.is_some(), "the plan only approaches the frontier");
let mut at = here;
for facing in &plan.steps {
at = at.step(*facing).expect("a step inside the map");
assert_eq!(
grid.walkable(at.x, at.y),
Walkable::Yes,
"the plan walks through {at:?}, which the grid does not call ground"
);
}
assert!(outside.contains(&at), "the plan ends on {at:?}, which is not one of its goals");
assert!(
!in_window(here, at.x, at.y),
"the plan ends on {at:?}, which the window could have answered for anyway"
);
// **The walk.** The fly's own choice is not being simulated here — the slot is started by
// name — but everything after that is the shipping executor: one plan, the per-step moved
// check, the three-failure rule, the frame cap. What is asserted is that some hold of
// `GO FRONTIER` ends on ground that was outside the window when the hold began, which is the
// whole of the operator's ask.
let mut left_the_window = None;
let mut left_the_map = None;
let mut done = 0;
for hold in 0..12 {
let began = game.tile();
let Some((outcome, frames)) = game.run_macro("GO FRONTIER") else {
eprintln!("GO FRONTIER left the pad after {hold} holds");
break;
};
let landed = game.tile();
if outcome == "Done" {
done += 1;
}
eprintln!("hold {hold}: {began:?} -> {landed:?} in {frames} frames, {outcome}");
if !in_window(began, landed.x, landed.y) {
left_the_window = Some((hold, began, landed, frames));
break;
}
if game.map() != map {
left_the_map = Some((hold, game.map()));
break;
}
}
assert!(done > 0, "no hold of GO FRONTIER finished");
match (left_the_window, left_the_map) {
(Some((hold, began, landed, frames)), _) => eprintln!(
"GO FRONTIER walked out of its own window on hold {hold}: {began:?} -> {landed:?} in \
{frames} frames"
),
// A doormat is unstood ground and the frontier is allowed to aim at it
// (`docs/design/macros.md` section 12.7: the reward ledger can never record a warp tile),
// so a near frontier that is a door takes the fly off the map before the far one is
// reached. That is the frontier's own rule rather than the grid's doing, and the plan
// above is what this test is for; the arrival is asserted on a map with no door next to
// the fly.
(None, Some((hold, map))) => eprintln!(
"GO FRONTIER stepped onto a warp tile on hold {hold} and left for map {map:#04x} \
before it left its window"
),
(None, None) => panic!("no hold of GO FRONTIER left the window it started in"),
}
}
#[test]
fn a_way_out_of_the_map_is_one_plan_away() {
let (rom, checkpoint) = skip_without!();
let mut game = Game::resume(&rom, &checkpoint);
let map = game.map();
let Ok(grid) = game.grid() else {
eprintln!("skipped: no grid on map {map:#04x}");
return;
};
let (here, plans) = game.with_state(|state| {
let here = state.player().map(|player| Tile::new(player.x, player.y)).expect("a player");
let exits = path::exits(state);
let mut plans = Vec::new();
for exit in &exits {
if let Some(route) = path::route(state, &[exit.tile]) {
plans.push((exit.id, exit.tile, exit.way, route.goal.is_some(), route.steps.clone()));
}
}
(here, plans)
});
assert!(!plans.is_empty(), "a map with no way out at all");
let mut crossed = 0;
let mut reached_one = false;
for (id, tile, way, reached, steps) in &plans {
// Every tile of the plan is ground the grid calls walkable: a plan with no guesses in it,
// which is what "one plan per walk" needs to mean.
let mut at = here;
let mut guesses = 0;
for facing in steps {
at = at.step(*facing).expect("a step inside the map");
if grid.walkable(at.x, at.y) != Walkable::Yes {
guesses += 1;
}
}
eprintln!(
"{id:?} at {tile:?} ({way:?}): {} steps, reached {reached}, {guesses} tiles the grid \
does not call ground",
steps.len()
);
assert_eq!(guesses, 0, "the plan to {tile:?} walks through {guesses} tiles of not-ground");
if !*reached {
// An exit tile the map fences off from where the fly stands: the grid *knows* it
// cannot be reached, which is the honest answer and is what the closest-approach route
// is for. Pallet Town's north-west corner is one — walkable ground behind a fence.
eprintln!(" (that one only approaches: {} of {} tiles reachable)", 0, steps.len());
continue;
}
reached_one = true;
assert_eq!(at, *tile, "the plan to {tile:?} ends on {at:?}");
if steps.len() > 9 {
crossed += 1;
}
}
assert!(reached_one, "no way out of map {map:#04x} can be reached at all");
assert!(
crossed > 0,
"no way out of map {map:#04x} is further than the window, so this checkpoint cannot show \
the difference"
);
}

View file

@ -182,6 +182,24 @@ EXTRA_RAM = (
# standing behind a desk can be talked to at all: none of the four tiles around either of # standing behind a desk can be talked to at all: none of the four tiles around either of
# them is walkable. # them is walkable.
'wTilesetTalkingOverTiles', 'wTilesetTalkingOverTiles',
# The whole-map walkability grid (docs/design/macros.md section 15).
#
# LoadTileBlockMap copies the loaded map out of its ROM bank into wOverworldMap
# as one byte per 4x4-tile block, in rows of wCurMapWidth + MAP_BORDER * 2 with
# the map itself three rows and three columns in, so the blocks of the current
# map are a WRAM read rather than a ROM one. wCurMapTileset keys the tile-pair
# collision lists (CheckForTilePairCollisions). wTilesetBank and
# wTilesetBlocksPtr are the tileset header's blockset: 16 bytes per block id,
# four rows of four tile ids, which DrawTileBlock indexes exactly that way --
# and it is not in bank 0, which is why the memory seam grew a bank-aware ROM
# read for it. All four are resolved the same way every other name here is;
# services/flysim/tools/resolve_wram.py is the second reading of them, from
# ram/wram.asm at this commit, and it re-derives 40 of the addresses this table
# already carries before it emits one of these four.
'wOverworldMap',
'wCurMapTileset',
'wTilesetBank',
'wTilesetBlocksPtr',
) )

View file

@ -0,0 +1,402 @@
"""Resolve WRAM symbol addresses out of a pret/pokered checkout, and verify the pinned ones.
`gen_symbols.py` owns `symbols.rs` and takes its addresses from the prototype's
already-generated table; it refuses a hand-written address. Section 15 of
`docs/design/macros.md` needs four symbols that table does not carry
(`wOverworldMap`, `wCurMapTileset`, `wTilesetBank`, `wTilesetBlocksPtr`), and a
hand-computed address is exactly what neither script will take. So this tool
does the same job the disassembly's own `.sym` would, from `ram/wram.asm`:
* it walks the file in order, keeping a byte cursor;
* the cursor is **only ever live while it is anchored**: it is set from a symbol
`symbols.rs` already pins, and any declaration form this tool cannot evaluate
exactly kills it until the next pinned symbol revives it. An unanchored region
can therefore not produce a number at all, rather than producing a wrong one;
* every pinned symbol it reaches while live is checked against `symbols.rs`, and
a single disagreement is a failure with no output. A resolved symbol is only
reported when the run also re-derived the *next* pinned symbol after it, so
each answer is bracketed by two addresses the table already carries.
Usage (read-only; `--emit` rewrites the `ram` block of symbols.rs in place):
python3 services/flysim/tools/resolve_wram.py --pokered <checkout> [--emit]
"""
from __future__ import annotations
import argparse
import math
import re
from pathlib import Path
SYMBOLS = (
Path(__file__).resolve().parents[1] / 'crates/flybrain-gb/src/pokemon_red/symbols.rs'
)
#: The symbols this run is for, with why the macro layer needs each one. Every
#: one is checked to be bracketed by two pinned addresses before it is emitted.
WANTED = {
# The current map's block ids, as LoadTileBlockMap copies them out of the
# map's ROM bank: rows of (width + MAP_BORDER * 2) bytes, the map itself
# offset by three rows and three columns. This is what makes a whole-map
# walkability grid a WRAM read rather than a ROM one.
'wOverworldMap': 'the loaded map, one byte per 4x4-tile block',
# Which tileset the loaded map uses: the tile-pair collision lists are keyed
# by it (CheckForTilePairCollisions).
'wCurMapTileset': 'the loaded map tileset id',
# The tileset header's blockset: a bank byte and a little-endian pointer at
# 16 bytes per block, four rows of four tile ids (DrawTileBlock). The bank is
# not bank 0, so this is the read the memory seam grew a bank for.
'wTilesetBank': 'the ROM bank the blockset lives in',
'wTilesetBlocksPtr': 'blocks to tiles, 16 bytes per block',
}
def pinned(text: str) -> dict[str, int]:
"""Every address `symbols.rs` carries today, by symbol name."""
return {
name: int(value, 16)
for name, value in re.findall(r'pub const (w\w+): u16 = 0x([0-9a-f]{4});', text)
}
def constants(root: Path) -> dict[str, int]:
"""Every `DEF NAME EQU <expression>` the declarations below need.
Resolved by repeated passes rather than in one, because the decomp defines
constants in terms of each other (`SURROUNDING_WIDTH EQU SCREEN_BLOCK_WIDTH *
BLOCK_WIDTH`). A name whose expression never becomes evaluable is simply left
out, which kills the cursor at any declaration that uses it.
"""
pending: dict[str, str] = {}
sources = sorted((root / 'constants').glob('*.asm')) + sorted(
(root / 'constants').glob('*.inc')
)
for path in sources:
for name, value in re.findall(
r'^\s*(?:DEF|def)\s+(\w+)\s+(?:EQU|equ)\s+([^;\n]+)', path.read_text(), re.M
):
pending.setdefault(name, value.strip())
out: dict[str, int] = {}
while pending:
progressed = False
for name in list(pending):
try:
out[name] = size_of(pending[name], out)
except Unevaluable:
continue
del pending[name]
progressed = True
if not progressed:
break
return out
def number(token: str) -> int:
token = token.strip()
if token.startswith('$'):
return int(token[1:], 16)
if token.startswith('%'):
return int(token[1:], 2)
return int(token, 10)
class Unevaluable(Exception):
"""A declaration this tool will not guess the size of."""
def size_of(expression: str, known: dict[str, int]) -> int:
"""Bytes in a `ds`/`EQU` expression: the decomp's own arithmetic, nothing else.
`$`/`%` literals and the constants resolved so far are substituted, the
`tiles` unit is a factor of sixteen, and what is left must be plain
arithmetic over integers -- so a name this tool has not resolved, a function
call or anything else raises [`Unevaluable`] instead of becoming a guess.
"""
expression = expression.split(';')[0].strip()
if not expression:
raise Unevaluable('empty')
scale = 1
if expression.endswith('tiles'):
expression = expression[: -len('tiles')].strip()
scale = 16 # one 8x8 2bpp tile is 16 bytes
def substitute(match: re.Match[str]) -> str:
token = match.group(0)
if token[0] in '$%':
return str(number(token))
if token in known:
return str(known[token])
raise Unevaluable(token)
substituted = re.sub(r'\$[0-9A-Fa-f_]+|%[01_]+|[A-Za-z_]\w*', substitute, expression)
if not re.fullmatch(r'[\d\s()+\-*/]+', substituted):
raise Unevaluable(expression)
try:
value = eval(substituted, {'__builtins__': {}}, {}) # arithmetic only, checked above
except (SyntaxError, ZeroDivisionError, TypeError) as error:
raise Unevaluable(expression) from error
if not isinstance(value, int):
raise Unevaluable(expression)
return value * scale
def macro_sizes(root: Path, known: dict[str, int]) -> dict[str, int]:
"""Sizes of the RAM struct macros, counted from their own declarations."""
out: dict[str, int] = {}
for path in sorted((root / 'macros').glob('*.asm')):
text = path.read_text()
for match in re.finditer(r'^MACRO\??\s+(\w+)\n(.*?)^ENDM', text, re.M | re.S):
name, body = match.group(1), match.group(2)
total = 0
for line in body.splitlines():
line = line.split(';')[0].strip()
# A struct macro labels each field with its argument
# (`\\1YCoord:: db`), so the label is stripped and the
# declaration after it is what reserves the bytes.
line = re.sub(r'^[\\\w{}:.\d]+::\s*', '', line)
if not line or line.startswith(('IF', 'ELSE', 'ENDC', 'ASSERT')):
continue
if re.match(r'^\w+::?$', line):
continue
if line.startswith('db'):
total += max(1, len([part for part in line[2:].split(',') if part.strip()]))
elif line.startswith('dw'):
total += 2 * max(1, len([p for p in line[2:].split(',') if p.strip()]))
elif line.startswith('ds '):
try:
total += size_of(line[3:], known)
except Unevaluable:
total = None
break
else:
total = None
break
if total is not None:
out[name] = total
return out
def walk(
root: Path, table: dict[str, int], known: dict[str, int], verbose: bool = False
) -> tuple[dict[str, int], list[str], int]:
"""Resolve every symbol of wram.asm the anchored cursor can reach exactly."""
macros = macro_sizes(root, known)
lines = (root / 'ram/wram.asm').read_text().splitlines()
cursor: int | None = None
resolved: dict[str, int] = {}
# Symbols counted since the last pinned anchor, held back until a pinned
# address after them agrees.
pending_run: dict[str, int] = {}
order: list[str] = []
checked = 0
problems: list[str] = []
lost: list[str] = []
# UNION frames: (start address, widest branch so far, whether a branch was
# unevaluable). A frame whose start is unknown, or any one of whose branches this
# tool could not size, poisons the whole union: what the section advances by is
# the widest branch, so one branch it cannot measure means it cannot measure any.
unions: list[tuple[int | None, int, bool]] = []
index = 0
while index < len(lines):
raw = lines[index]
index += 1
line = raw.split(';')[0].strip()
if not line:
continue
if line.startswith('SECTION'):
# A section's address comes from the linker, not the source. WRAM0
# sections are packed in declaration order, so the cursor carries on
# -- and the next address `symbols.rs` pins is what tests that: any
# padding the linker inserted would land as a MISMATCH and this tool
# would emit nothing.
continue
if line == 'UNION':
unions.append((cursor, 0, False))
continue
if line == 'NEXTU':
if not unions:
cursor = None
continue
start, widest, poisoned = unions.pop()
poisoned = poisoned or start is None or cursor is None
if not poisoned:
widest = max(widest, cursor - start)
unions.append((start, widest, poisoned))
cursor = start
continue
if line == 'ENDU':
if not unions:
cursor = None
continue
start, widest, poisoned = unions.pop()
if poisoned or start is None or cursor is None:
cursor = None
continue
cursor = start + max(widest, cursor - start)
continue
if line.startswith(('FOR ', 'REPT ')):
# Evaluate the body only when every line of it has a known size.
head = line.split(None, 1)[1]
# `REPT n`, `FOR v, stop` and `FOR v, start, stop`: rgbasm's own
# three forms, and the third iterates stop - start times.
arguments = [part.strip() for part in head.split(',')]
count_token = arguments[-1]
start_token = arguments[-2] if line.startswith('FOR ') and len(arguments) == 3 else None
body: list[str] = []
depth = 1
while index < len(lines):
inner = lines[index].split(';')[0].strip()
index += 1
if inner.startswith(('FOR ', 'REPT ')):
depth += 1
if inner == 'ENDR':
depth -= 1
if depth == 0:
break
body.append(inner)
try:
count = size_of(count_token, known)
if start_token is not None:
count -= size_of(start_token, known)
per = 0
for inner in body:
per += declaration_size(inner, known, macros)
if cursor is not None:
cursor += count * per
except Unevaluable:
if cursor is not None:
lost.append(f'line {index}: {line}')
cursor = None
pending_run.clear()
continue
label = re.match(r'^(w\w+)::', line)
if label is not None:
name = label.group(1)
if name in table:
if cursor is not None and cursor != table[name]:
problems.append(
f'{name}: wram.asm gives ${cursor:04x}, symbols.rs pins ${table[name]:04x}'
)
pending_run.clear()
elif cursor is not None:
checked += 1
# Everything counted since the last anchor is now bracketed
# by two addresses the table already carries.
resolved.update(pending_run)
order.extend(pending_run)
pending_run.clear()
else:
if verbose:
lost.append(f'cold anchor at {name} (line {index})')
pending_run.clear()
cursor = table[name]
elif cursor is not None:
pending_run[name] = cursor
line = line[label.end() :].strip()
if not line:
continue
if line.endswith('::') or re.fullmatch(r'\.\w+', line):
continue
try:
if cursor is not None:
cursor += declaration_size(line, known, macros)
except Unevaluable:
if cursor is not None:
lost.append(f'line {index}: {line}')
cursor = None
pending_run.clear()
if verbose:
for entry in lost:
print(f'UNEVALUABLE {entry}')
return resolved, problems, checked
def declaration_size(line: str, known: dict[str, int], macros: dict[str, int]) -> int:
"""Bytes one declaration line reserves, or [`Unevaluable`]."""
line = line.split(';')[0].strip()
if not line or line.endswith('::') or line.startswith(('ENDSECTION', 'ASSERT', 'ENDR')):
return 0
# Any label, including the `{02d:n}` interpolations a FOR body labels its
# iterations with; what reserves the bytes is the declaration after it.
line = re.sub(r'^[\\\w{}:.\d]+::\s*', '', line)
if not line:
return 0
if line == 'db':
return 1
if line == 'dw':
return 2
if line.startswith('db '):
return max(1, len([part for part in line[3:].split(',') if part.strip()]))
if line.startswith('dw '):
return 2 * max(1, len([part for part in line[3:].split(',') if part.strip()]))
if line.startswith('ds '):
return size_of(line[3:], known)
if line.startswith('flag_array '):
return math.ceil(size_of(line[len('flag_array ') :], known) / 8)
head = line.split(None, 1)[0]
if head in macros:
return macros[head]
raise Unevaluable(line)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--pokered', type=Path, required=True)
parser.add_argument('--verbose', action='store_true', help='name every declaration it will not size')
parser.add_argument('--emit', action='store_true', help='write the new addresses into symbols.rs')
args = parser.parse_args()
text = SYMBOLS.read_text()
commit = re.search(r'POKERED_COMMIT: &str = "([0-9a-f]{40})"', text)
if commit is None:
raise SystemExit('symbols.rs carries no POKERED_COMMIT')
head = (args.pokered / '.git/HEAD').read_text().strip()
if head.startswith('ref:'):
head = (args.pokered / '.git' / head.split()[1]).read_text().strip()
if head != commit.group(1):
raise SystemExit(
f'checkout is at {head}, symbols.rs pins {commit.group(1)}: two revisions of the '
'disassembly renumber RAM relative to each other'
)
table = pinned(text)
known = constants(args.pokered)
resolved, problems, checked = walk(args.pokered, table, known, args.verbose)
if problems:
for problem in problems:
print(f'MISMATCH {problem}')
raise SystemExit('the walk disagrees with symbols.rs; nothing emitted')
print(f'{checked} of {len(table)} pinned addresses re-derived from wram.asm, no disagreement')
missing = [name for name in WANTED if name not in resolved]
if missing:
raise SystemExit(f'unanchored, so not resolved: {", ".join(missing)}')
for name in WANTED:
print(f'{name} = ${resolved[name]:04x} ({WANTED[name]})')
if not args.emit:
return
block = re.search(r'(pub mod ram \{\n)(.*?)(\n\}\n)', text, re.S)
if block is None:
raise SystemExit('no ram block in symbols.rs')
rows = []
for row in block.group(2).splitlines():
name = re.match(r'\s*pub const (w\w+): u16 = 0x([0-9a-f]{4});', row)
if name is None:
continue
rows.append((int(name.group(2), 16), name.group(1)))
for name in WANTED:
if name not in table:
rows.append((resolved[name], name))
rows = sorted(set(rows))
width = max(len(name) for _, name in rows)
body = '\n'.join(
f' pub const {name}: u16 = 0x{address:04x};'.ljust(width + 31)
+ f'// {address}'
for address, name in rows
)
SYMBOLS.write_text(text[: block.start(2)] + body + text[block.end(2) :])
print(f'symbols.rs: {len(rows)} addresses written')
if __name__ == '__main__':
main()