diff --git a/docs/design/macros-wram.md b/docs/design/macros-wram.md index 95de865..5d5918f 100644 --- a/docs/design/macros-wram.md +++ b/docs/design/macros-wram.md @@ -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 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: 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. Knowledge that nothing reads is not narrowed, it is deleted — `MacroState` is four methods 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`, 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. diff --git a/docs/design/macros.md b/docs/design/macros.md index ef0d405..e54f8a5 100644 --- a/docs/design/macros.md +++ b/docs/design/macros.md @@ -1085,3 +1085,94 @@ rather than argued: `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}`. 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. diff --git a/infra/docs/macros-traps.md b/infra/docs/macros-traps.md index 9e5918e..86ac5e0 100644 --- a/infra/docs/macros-traps.md +++ b/infra/docs/macros-traps.md @@ -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. - `--print-compatibility`: byte-identical to v0.4.1, 648 bytes, decoder / reward catalog / adapter 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. diff --git a/services/flysim/crates/flybrain-gb/src/adapter.rs b/services/flysim/crates/flybrain-gb/src/adapter.rs index fb3f28a..15d8456 100644 --- a/services/flysim/crates/flybrain-gb/src/adapter.rs +++ b/services/flysim/crates/flybrain-gb/src/adapter.rs @@ -20,12 +20,37 @@ use crate::ratchet::RecoveryPolicy; /// equivalent of the prototype's `MemoryReader` interface. pub trait MemoryReader { 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 { + None + } } impl MemoryReader for &mut dyn MemoryReader { fn read8(&mut self, address: u16) -> u8 { (**self).read8(address) } + + fn read_rom(&mut self, bank: u8, address: u16) -> Option { + (**self).read_rom(bank, address) + } } /// One reward payout in one frame. diff --git a/services/flysim/crates/flybrain-gb/src/emulator.rs b/services/flysim/crates/flybrain-gb/src/emulator.rs index 1b6f7eb..dd9b3e3 100644 --- a/services/flysim/crates/flybrain-gb/src/emulator.rs +++ b/services/flysim/crates/flybrain-gb/src/emulator.rs @@ -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. 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 /// whatever is requested, so this is only a default. pub const DEFAULT_AUDIO_FREQUENCY: u32 = 48_000; @@ -135,6 +139,13 @@ impl FrameCache { /// thread. It is deliberately not `Sync`. pub struct Emulator { 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], audio_frequency: u32, /// 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); Ok(Self { gb, + rom: rom.into(), rom_sha256: Sha256::digest(rom).into(), audio_frequency, pending_audio: Vec::new(), @@ -242,6 +254,25 @@ impl Emulator { 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 { + 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. pub fn audio_frequency(&self) -> u32 { self.audio_frequency @@ -364,6 +395,10 @@ impl MemoryReader for Emulator { fn read8(&mut self, address: u16) -> u8 { self.read_wram(address) } + + fn read_rom(&mut self, bank: u8, address: u16) -> Option { + self.read_rom_bank(bank, address) + } } #[cfg(test)] diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs index 20daad3..96c4724 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs @@ -32,12 +32,22 @@ pub const WALL_TILE: u8 = 0x60; pub struct Wram { bytes: Vec, + /// 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 { fn read8(&mut self, address: u16) -> u8 { self.bytes[address as usize] } + + fn read_rom(&mut self, bank: u8, address: u16) -> Option { + self.rom.get(&(bank, address)).copied() + } } impl Default for Wram { @@ -49,7 +59,7 @@ impl Default for Wram { impl Wram { /// All zero: the title screen, since nothing has set the game-timer bit. 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 { @@ -331,6 +341,78 @@ impl Wram { 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 /// out of the bedroom lands it, every tile a wall until a test opens one. pub fn overworld() -> Self { diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs index 4f3f8a1..96e49cd 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs @@ -24,7 +24,7 @@ use crate::adapter::PlaceKind; 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. /// @@ -315,6 +315,23 @@ pub trait MacroState: GameState { 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> { + None + } + /// 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 diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs index 926afdb..6eca02f 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs @@ -16,6 +16,7 @@ use crate::macros::{ MacroPalette, Observed, Outcome, PaletteMode, RunLedger, SLOTS, SceneId, SlotBinding, Started, }; +use super::super::mapgrid::MapGrids; use super::super::state::PokeState; use super::cartridge::{Areas, MacroState, Pushed, Stood, Talked, Targets, Tile}; 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 /// does not stop being like that ten brain minutes later. 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 blocked ledger is a *window*, so it needs the same clock the loop publishes rather @@ -93,6 +102,7 @@ impl PokemonPalette { stood: Stood::default(), areas: Areas::default(), pushed: Pushed::default(), + grids: MapGrids::default(), now_ms: 0.0, } } @@ -158,11 +168,13 @@ impl MacroPalette for PokemonPalette { fn observe(&mut self, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger) -> Observed { let (scene, bindings, standing) = { - let Self { machine, mode, palette: cached, talked, targets, stood, areas, pushed, .. } = - self; + let Self { + machine, mode, palette: cached, talked, targets, stood, areas, pushed, grids, .. + } = self; let mut state = PokeState::with_ledgers( memory, ledger, talked, targets, &*stood, &*areas, &*pushed, - ); + ) + .caching_grid(grids); // `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 // for. @@ -227,7 +239,8 @@ impl MacroPalette for PokemonPalette { &self.stood, &self.areas, &self.pushed, - ); + ) + .caching_grid(&mut self.grids); self.machine.start(&palette, slot, &mut state) }; let started = match begun { @@ -267,7 +280,8 @@ impl MacroPalette for PokemonPalette { &self.stood, &self.areas, &self.pushed, - ); + ) + .caching_grid(&mut self.grids); self.machine.step(&mut state) }; // A macro that just finished may have been the press that talked to something; the ledger diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/path.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/path.rs index f5fffa6..29d19e0 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/path.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/path.rs @@ -13,16 +13,29 @@ //! minimum Manhattan distance to any goal. That stays admissible because a step costs one and //! moves one tile. //! -//! **The walkable predicate has a window.** Agent A's [`Walkable::Unknown`] is load-bearing: the -//! tile ids a walkability test needs live in the screen buffer, 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 needs — 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 is half -//! 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 -//! moved, and three failed steps abort as `Blocked`. +//! **It plans over the whole map when the map 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"). [`MacroState::map_grid`] is every tile of the loaded map, walkability and +//! directed walls, decoded from the block and collision tables the cartridge has loaded +//! ([`crate::pokemon_red::mapgrid`]). With it, one plan crosses a town: `GO WARP` routes to its +//! warp tile, `GO OUT` and `GO ROUTE` to their door or connection tile, and the frontier is the +//! nearest unstood ground *anywhere on the map* rather than the nearest on screen. +//! +//! **Without it, the window is still the fallback.** Agent A's [`Walkable::Unknown`] is +//! 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 //! 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, }; 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. /// @@ -156,6 +186,10 @@ pub fn route_avoiding( } let player = state.player()?; 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); if let Some(goal) = goals.iter().position(|tile| *tile == start) { return Some(Route { goal: Some(goal), steps: Vec::new() }); @@ -191,6 +225,12 @@ pub fn route_avoiding( if refused.contains(&(tile, facing)) { 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 // (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 @@ -200,10 +240,11 @@ pub fn route_avoiding( if next != start && state.pushed_tile(next.x, next.y) { 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, 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::No => continue, }; @@ -334,6 +375,8 @@ pub fn target_at(state: &mut dyn MacroState, tile: Tile) -> Option { pub fn exits(state: &mut dyn MacroState) -> Vec { let Some(size) = state.map_size() 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 mut out = Vec::new(); for (index, warp) in state.warps().iter().enumerate() { @@ -373,13 +416,16 @@ pub fn exits(state: &mut dyn MacroState) -> Vec { let way = if here_outdoors { Way::Route } else { Way::Exit }; let into = geography::connected(player.map, edge); 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 - // outdoor map reads `Unknown` from anywhere but next to it, and filtering on `Yes` - // left a town's connections out of the exit list entirely -- which is half of why - // nothing could ever aim at Route 1 from the middle of Pallet Town. An unknown tile is - // a goal worth walking towards; the route search's own approach answer handles a goal - // it cannot reach yet, and a tile that turns out to be a wall costs one blocked walk. - if state.walkable(tile.x, tile.y) != Walkable::No { + // Not `== Yes`: without a grid the walkable predicate's window is the screen, so the + // far edge of an outdoor map reads `Unknown` from anywhere but next to it, and + // filtering on `Yes` left a town's connections out of the exit list entirely -- which + // is half of why nothing could ever aim at Route 1 from the middle of Pallet Town. An + // unknown tile is a goal worth walking towards; the route search's own approach answer + // handles a goal it cannot reach yet, and a tile that turns out to be a wall costs one + // 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 }); } } @@ -396,9 +442,12 @@ pub fn exits(state: &mut dyn MacroState) -> Vec { /// 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 /// 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 -/// 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. +/// stood on. Both tiles have to be walkable: an unreachable one is not ground. +/// +/// **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 /// frontiers does not depend on iteration order. @@ -415,13 +464,15 @@ pub fn exits(state: &mut dyn MacroState) -> Vec { pub fn frontier(state: &mut dyn MacroState) -> Vec<(Tile, Facing)> { let Some(size) = state.map_size() 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 held: Vec = state.npcs().iter().map(|npc| Tile::new(npc.x, npc.y)).collect(); let mut out: Vec<(Tile, Facing)> = Vec::new(); for y in 0..size.height { for x in 0..size.width { 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; } 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 { 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; } if held.contains(&next) { diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/state.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/state.rs index 02b5695..a624d03 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/state.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/state.rs @@ -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, + /// Row-major screen tile id per map tile, `None` where the tile was never decoded. + ids: Vec>, + /// Row-major bitmask of the directions a step out of this tile is refused in + /// ([`MapGrid::wall`]). + walls: Vec, +} + +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 { + (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 { + 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. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Warp { diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs index e2a44ce..b284906 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs @@ -4110,4 +4110,5 @@ fn a_scripted_push_back_records_the_tile_it_happened_on() { ); } +mod map_aware; mod shop_purchase; diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests/map_aware.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests/map_aware.rs new file mode 100644 index 0000000..1257e99 --- /dev/null +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests/map_aware.rs @@ -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, + /// 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, + warps: Vec, + connections: Connections, + npcs: Vec, + /// 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 { + Some(Player { map: self.map, x: self.player.x, y: self.player.y, facing: Facing::Down }) + } + + fn map_size(&mut self) -> Option { + Some(self.size) + } + + fn party(&mut self) -> Party { + Party { mons: Vec::::new(), active: None } + } + + fn battle(&mut self) -> Option { + None + } + + fn text_box(&mut self) -> TextBox { + TextBox { open: false, waiting: false } + } + + fn start_menu(&mut self) -> Option { + None + } + + fn shop(&mut self) -> Option { + None + } + + fn pc(&mut self) -> Option { + None + } + + fn money(&mut self) -> u32 { + 0 + } + + fn bag(&mut self) -> Vec { + Vec::new() + } + + fn npcs(&mut self) -> Vec { + self.npcs.clone() + } + + fn signs(&mut self) -> Vec { + 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 { + self.warps.clone() + } + + fn connections(&mut self) -> Connections { + self.connections + } +} + +impl MacroState for Ground { + fn map_grid(&mut self) -> Option> { + 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 = 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 = 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 = 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); +} diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid.rs new file mode 100644 index 0000000..d13e630 --- /dev/null +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid.rs @@ -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, + /// The `$ff`-terminated passable-tile list, terminator included or not. + pub passable: Vec, +} + +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 { + 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::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>, +} + +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> { + 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 { + 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 { + self.current.as_ref().map(|grid| grid.map()) + } +} + +#[cfg(test)] +mod tests; diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid/tests.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid/tests.rs new file mode 100644 index 0000000..4ce210a --- /dev/null +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid/tests.rs @@ -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 { + 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()); +} diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/mod.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/mod.rs index 428fec6..469d708 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/mod.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/mod.rs @@ -11,6 +11,7 @@ pub mod catalog; #[cfg(test)] pub(crate) mod fake_wram; pub mod macros; +pub mod mapgrid; pub mod maps; pub mod scene; pub mod state; @@ -343,6 +344,13 @@ impl MemoryReader for SampleCache<'_> { self.bytes.insert(address, 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 { + self.source.read_rom(bank, address) + } } fn word(memory: &mut impl MemoryReader, address: u16) -> u32 { diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs index 025608d..fb56825 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs @@ -29,9 +29,10 @@ use super::macros::cartridge::{ Objective, PushedLedger, StoodLedger, TalkLedger, TalkTarget, TargetKey, TargetLedger, Tile, }; use super::macros::geography::Amenity; +use super::mapgrid::{self, MapGrids}; use super::macros::state::{ 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, }; use super::symbols::ram; @@ -775,12 +776,16 @@ pub fn npcs(memory: &mut dyn MemoryReader) -> Vec { 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 /// 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. -fn passable(memory: &mut dyn MemoryReader, tile: u8) -> Option { +/// it matches or hits `$ff`. `None` means the pointer is not one this module will follow, or the +/// 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> { let low = u16::from(read(memory, ram::wTilesetCollisionPtr)); let high = u16::from(read(memory, ram::wTilesetCollisionPtr + 1)); let base = high * 256 + low; @@ -792,16 +797,198 @@ fn passable(memory: &mut dyn MemoryReader, tile: u8) -> Option { } // No collision list in the game is longer than this; the bound is what stops a bad pointer // from walking the cartridge. + let mut list = Vec::new(); for offset in 0..64u16 { - match read(memory, base + offset) { - 0xff => return Some(false), - found if found == tile => return Some(true), - _ => {} + let byte = read(memory, base + offset); + list.push(byte); + if byte == mapgrid::TERMINATOR { + return Some(list); } } 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 { + 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 { + 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. /// /// 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 /// beside the four above, owned by the same type (`infra/docs/macros-traps.md` row 37). 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> { @@ -935,6 +1132,7 @@ impl<'a> PokeState<'a> { stood: &NoStood, areas: &NoAreas, pushed: &NoPushed, + grids: None, } } @@ -948,6 +1146,7 @@ impl<'a> PokeState<'a> { stood: &NoStood, areas: &NoAreas, pushed: &NoPushed, + grids: None, } } @@ -965,7 +1164,18 @@ impl<'a> PokeState<'a> { areas: &'a dyn AreaLedger, pushed: &'a dyn PushedLedger, ) -> 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) } + /// 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> { + 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). /// /// Gated on the mart scene being up, and that gate is the whole of the accuracy here: diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs index a371e73..e6bc45e 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs @@ -5,7 +5,8 @@ //! passable-tile list walked out of ROM bank 0. 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}; /// 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!(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, 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); +} diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs index 3427f62..30119fa 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs @@ -16,6 +16,7 @@ pub mod ram { pub const wSpriteStateData1: u16 = 0xc100; // 49408 pub const wSpriteStateData2: u16 = 0xc200; // 49664 pub const wTileMap: u16 = 0xc3a0; // 50080 + pub const wOverworldMap: u16 = 0xc6e8; // 50920 pub const wTopMenuItemY: u16 = 0xcc24; // 52260 pub const wTopMenuItemX: u16 = 0xcc25; // 52261 pub const wCurrentMenuItem: u16 = 0xcc26; // 52262 @@ -59,6 +60,7 @@ pub mod ram { pub const wCurMap: u16 = 0xd35e; // 54110 pub const wYCoord: u16 = 0xd361; // 54113 pub const wXCoord: u16 = 0xd362; // 54114 + pub const wCurMapTileset: u16 = 0xd367; // 54119 pub const wCurMapHeight: u16 = 0xd368; // 54120 pub const wCurMapWidth: u16 = 0xd369; // 54121 pub const wCurMapConnections: u16 = 0xd370; // 54128 @@ -68,6 +70,8 @@ pub mod ram { pub const wSignCoords: u16 = 0xd4b1; // 54449 pub const wSignTextIDs: u16 = 0xd4d1; // 54481 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 wTilesetTalkingOverTiles: u16 = 0xd532; // 54578 pub const wNumHoFTeams: u16 = 0xd5a2; // 54690 diff --git a/services/flysim/crates/flysim/examples/scene_probe.rs b/services/flysim/crates/flysim/examples/scene_probe.rs index e6785b6..09253ee 100644 --- a/services/flysim/crates/flysim/examples/scene_probe.rs +++ b/services/flysim/crates/flysim/examples/scene_probe.rs @@ -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::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 mut poke = flybrain_gb::pokemon_red::state::PokeState::with_ledger(gb, &ledger); let state: &mut dyn MacroState = &mut poke; @@ -182,16 +186,52 @@ fn pad(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) { let names: Vec<&str> = plan.slots.iter().flatten().map(|spec| spec.name).collect(); 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 // 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 // marked still prints as unrecorded here. That is the point of the column: it shows what the // 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 { let row: Vec = (0..size.width) .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::No => '#', Walkable::Unknown => '?', @@ -208,7 +248,11 @@ fn pad(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) { let mut n = 0; for y in 0..size.height { 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; } } diff --git a/services/flysim/crates/flysim/examples/trap_hunt.rs b/services/flysim/crates/flysim/examples/trap_hunt.rs index df3c983..e202e8b 100644 --- a/services/flysim/crates/flysim/examples/trap_hunt.rs +++ b/services/flysim/crates/flysim/examples/trap_hunt.rs @@ -205,6 +205,12 @@ struct Trace { /// Every input of the detector's disputed branch on the last frame /// (`pokemon_red::scene::why_unknown`). 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 /// corners and against the whole `TextBoxBorder` (`pokemon_red::state::dialog_border`). /// @@ -379,6 +385,7 @@ fn run( ended_in: ("", String::new()), longest_scene: BTreeMap::new(), ended_why: String::new(), + ended_grid: String::new(), font_corners_border: 0, font_corners_no_border: 0, font_no_corners: 0, @@ -544,6 +551,9 @@ fn run( next_trace = ms + trace_every_ms; let scene = macros.as_ref().map_or("", MacroLayer::scene_name); 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 state: &mut dyn MacroState = &mut state; let player = state.player(); @@ -551,9 +561,10 @@ fn run( let ahead = Tile::new(player.x, player.y).step(player.facing)?; 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); 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 ); } @@ -624,6 +635,14 @@ fn run( adapter.mode().to_string(), ); 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.wall_seconds = began_wall.elapsed().as_secs_f64(); 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, + refusal: Option, +) -> 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() { let Some(path) = std::env::var_os("FLY_ROM") else { println!( @@ -796,6 +851,7 @@ fn main() { println!("| --- | ---: |"); println!("| rung reached | {} |", trace.rungs.iter().map(|(rank, ..)| *rank).max().unwrap_or(0)); println!("| distinct (map, tile) | {} |", ground.len()); + println!("| the map at the end | {} |", trace.ended_grid); println!("| macros started | {} |", trace.starts.len()); for (outcome, count) in &trace.outcomes { println!("| {outcome} | {count} |"); diff --git a/services/flysim/crates/flysim/tests/rom_map_grid.rs b/services/flysim/crates/flysim/tests/rom_map_grid.rs new file mode 100644 index 0000000..5d1d930 --- /dev/null +++ b/services/flysim/crates/flysim/tests/rom_map_grid.rs @@ -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> { + 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 { + 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 = 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 { + state::map_grid(&mut self.gb) + } + + /// Run `body` with the state the macros read, over this game's ledgers. + fn with_state(&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 { + 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>, + 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> = 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 = 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 = 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" + ); +} + diff --git a/services/flysim/tools/gen_symbols.py b/services/flysim/tools/gen_symbols.py index 60e5ab5..71ce56c 100644 --- a/services/flysim/tools/gen_symbols.py +++ b/services/flysim/tools/gen_symbols.py @@ -182,6 +182,24 @@ EXTRA_RAM = ( # standing behind a desk can be talked to at all: none of the four tiles around either of # them is walkable. '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', ) diff --git a/services/flysim/tools/resolve_wram.py b/services/flysim/tools/resolve_wram.py new file mode 100644 index 0000000..4e67b06 --- /dev/null +++ b/services/flysim/tools/resolve_wram.py @@ -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 [--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 ` 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()