Merge feat/map-aware-walks: whole-map walkability grid, A* over the map for every walk
This commit is contained in:
commit
78fb86336e
23 changed files with 3115 additions and 46 deletions
|
|
@ -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<u8>`, defaulted to `None`. The bus read
|
||||
cannot reach the blockset — banks 1 and up are whatever the cartridge's last switch left mapped, and
|
||||
the only way to change that would be to *write* the mapper's bank register, which the doctrine
|
||||
forbids (`docs/design/macros.md` section 12: the joypad register is the only write). So the
|
||||
emulator implements it over **the cartridge image the process already holds**: below `$4000` it is
|
||||
bank 0 whatever the bank says, `$4000..$8000` is the banked window, and an offset past the end of
|
||||
the image is `None`. Nothing is written, no bank is switched, and the emulator's state does not
|
||||
move. Every other reader — the synthetic WRAM of the tests, the sim loop's stubs — keeps the
|
||||
default, and `None` there means the grid narrows to the window predicate rather than decoding a map
|
||||
out of whatever bytes were to hand.
|
||||
|
||||
### The corner, which was measured
|
||||
|
||||
A map tile is 2x2 screen tiles and `CheckTilePassable` matches **one** id, so a decode has to pick
|
||||
the same one the cartridge picks. It is the **lower left** of the four. The upper left is the
|
||||
plausible guess: the view is centred so that the player's own 2x2 begins at screen row 8 and
|
||||
`_GetTileAndCoordsInFrontOfPlayer` reads `(8, 9)`, which is its lower half. Measured on the
|
||||
cartridge rather than argued: Viridian Forest's (4, 32) reads `$23` on the screen, which is the
|
||||
second row of its block, where the first row holds `$04`. On a town most quadrants hold one tile id
|
||||
four times over, so an upper-left decode reads correctly there and falls apart in a forest — which
|
||||
is exactly the shape of mistake the cross-check below exists for.
|
||||
|
||||
### Two gates, because a wrong decode answers plausibly
|
||||
|
||||
- **Against the screen, before the grid is trusted.** The decoded ids are compared with
|
||||
`map_tile_id` over the fly's own tile and its four neighbours; a frame where the window can answer
|
||||
for none of them is refused. `wOverworldMap` shares its bytes with the picture buffer
|
||||
(`ram/wram.asm`'s own `UNION`), so a battle is precisely when the blocks under it are somebody
|
||||
else's.
|
||||
- **Against the screen again, whenever a cached grid is served.** A warp writes `wCurMap` before the
|
||||
header and the blocks: measured on Oak's lab's doormat, where `wCurMap` reads `PALLET_TOWN` while
|
||||
the header still reads the lab's ten-by-twelve. The decode and the screen agree on such a frame —
|
||||
both are the old map — so only the *id* is wrong, and the check that catches it is one byte: does
|
||||
the cached grid still agree with the screen about the tile the fly is standing on.
|
||||
|
||||
### The survey, on two maps
|
||||
|
||||
`services/flysim/crates/flysim/tests/rom_map_grid.rs`, the method of
|
||||
`docs/design/room-escape.md` section 3: walk the map with real button presses on throwaway
|
||||
emulators, 120 frames of held direction per step and twenty released frames before each state is
|
||||
kept, and compare the grid against what the cartridge did.
|
||||
|
||||
| map | size | walkable | reachable | unknown | window tiles compared | tiles surveyed | refused presses | a sprite was in the way | a battle or a script answered |
|
||||
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| Pallet Town `$00` | 20x18 | 221 | 207 | 0 | 90, no disagreement | 120 | 58, all explained | 4 | 0 |
|
||||
| Viridian Forest `$33` | 34x48 | 719 | 719 | 0 | 90, no disagreement | 120 | 74, all explained | 2 | 10 |
|
||||
|
||||
"All explained" is the assertion that matters: every press the cartridge refused is a tile the grid
|
||||
calls a wall, a directed wall out of that tile, or a tile a sprite was standing on **in the frame
|
||||
the press was made in** — Pallet Town's two villagers walk, so reading the sprite list from the
|
||||
state the survey started in would not do. And every step the cartridge made is one the grid would
|
||||
have planned. Both halves are needed: the first catches a decode that is too permissive, the second
|
||||
one that is too strict.
|
||||
|
||||
Three things are still not modelled, and none of them is new: a sprite in the way (the sprite list
|
||||
answers that, and the executor's per-step moved check covers the rest), a warp that fires on the
|
||||
step onto it, and a script that pushes the fly off a tile (a session ledger answers that). The
|
||||
water half of the tile-pair lists is deliberately absent: it is the list
|
||||
`CheckForJumpingAndTilePairCollisions` uses while surfing, and the palette cannot surf.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<u8> {
|
||||
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<u8> {
|
||||
(**self).read_rom(bank, address)
|
||||
}
|
||||
}
|
||||
|
||||
/// One reward payout in one frame.
|
||||
|
|
|
|||
|
|
@ -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<u8> {
|
||||
let offset = match address {
|
||||
0x0000..=0x3fff => usize::from(address),
|
||||
0x4000..=0x7fff => {
|
||||
usize::from(bank) * ROM_BANK_BYTES + usize::from(address) - ROM_BANK_BYTES
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
self.rom.get(offset).copied()
|
||||
}
|
||||
|
||||
/// Sample rate of the raw buffer, as binjgb configured it.
|
||||
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<u8> {
|
||||
self.read_rom_bank(bank, address)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -32,12 +32,22 @@ pub const WALL_TILE: u8 = 0x60;
|
|||
|
||||
pub struct Wram {
|
||||
bytes: Vec<u8>,
|
||||
/// Fake cartridge banks, for the one read that needs one.
|
||||
///
|
||||
/// A bank nothing has written answers `None`, which is what a seam with no cartridge behind
|
||||
/// it answers and what the whole-map grid has to narrow on
|
||||
/// (`docs/design/macros.md` section 15).
|
||||
rom: std::collections::HashMap<(u8, u16), u8>,
|
||||
}
|
||||
|
||||
impl MemoryReader for Wram {
|
||||
fn read8(&mut self, address: u16) -> u8 {
|
||||
self.bytes[address as usize]
|
||||
}
|
||||
|
||||
fn read_rom(&mut self, bank: u8, address: u16) -> Option<u8> {
|
||||
self.rom.get(&(bank, address)).copied()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Wram {
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<std::sync::Arc<MapGrid>> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<TalkTarget> {
|
|||
pub fn exits(state: &mut dyn MacroState) -> Vec<Exit> {
|
||||
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<Exit> {
|
|||
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<Exit> {
|
|||
/// 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<Exit> {
|
|||
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<Tile> = 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) {
|
||||
|
|
|
|||
|
|
@ -392,6 +392,179 @@ impl Walkable {
|
|||
}
|
||||
}
|
||||
|
||||
/// Every tile of one map, answered: `docs/design/macros.md` section 15.
|
||||
///
|
||||
/// [`GameState::walkable`] answers about the ten-by-nine window of the screen buffer and
|
||||
/// [`Walkable::Unknown`] elsewhere. This is the same predicate over the whole loaded map, decoded
|
||||
/// from the block and collision tables the cartridge has loaded
|
||||
/// ([`crate::pokemon_red::mapgrid`]), so a walk can be planned once instead of guessed at and
|
||||
/// re-planned at every window edge.
|
||||
///
|
||||
/// It carries three things and no policy at all:
|
||||
///
|
||||
/// - the walkability of every tile, in map-tile coordinates -- the same unit the player's
|
||||
/// coordinates, the warp table and the sign table are in;
|
||||
/// - the tile id each answer came from, which is what the cross-check against the window
|
||||
/// predicate compares and what the tile-pair rules are keyed on;
|
||||
/// - **directed walls**: one step out of one tile in one direction that the cartridge refuses
|
||||
/// although both tiles are passable. Pokered has two such rules and the tile-pair lists are the
|
||||
/// one that can be read ahead of time; a ledge is already [`Walkable::No`] in the collision
|
||||
/// list, and a person in the way is the sprite list's answer, not the ground's.
|
||||
///
|
||||
/// Nothing here is a fact about the run: no ledger, no visit, no target. Those stay where they
|
||||
/// are, session state in the executor layer.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MapGrid {
|
||||
map: u8,
|
||||
width: u8,
|
||||
height: u8,
|
||||
/// Row-major, `width * height`, [`Walkable::Unknown`] until [`MapGrid::set`] says otherwise.
|
||||
tiles: Vec<Walkable>,
|
||||
/// Row-major screen tile id per map tile, `None` where the tile was never decoded.
|
||||
ids: Vec<Option<u8>>,
|
||||
/// Row-major bitmask of the directions a step out of this tile is refused in
|
||||
/// ([`MapGrid::wall`]).
|
||||
walls: Vec<u8>,
|
||||
}
|
||||
|
||||
impl MapGrid {
|
||||
/// An all-[`Walkable::Unknown`] grid of this size, for a decoder to fill in.
|
||||
pub fn new(map: u8, width: u8, height: u8) -> Self {
|
||||
let cells = usize::from(width) * usize::from(height);
|
||||
Self {
|
||||
map,
|
||||
width,
|
||||
height,
|
||||
tiles: vec![Walkable::Unknown; cells],
|
||||
ids: vec![None; cells],
|
||||
walls: vec![0; cells],
|
||||
}
|
||||
}
|
||||
|
||||
/// Which map this grid is of. A grid is only ever valid for the loaded map.
|
||||
pub fn map(&self) -> u8 {
|
||||
self.map
|
||||
}
|
||||
|
||||
pub fn width(&self) -> u8 {
|
||||
self.width
|
||||
}
|
||||
|
||||
pub fn height(&self) -> u8 {
|
||||
self.height
|
||||
}
|
||||
|
||||
fn index(&self, x: u8, y: u8) -> Option<usize> {
|
||||
(x < self.width && y < self.height)
|
||||
.then(|| usize::from(y) * usize::from(self.width) + usize::from(x))
|
||||
}
|
||||
|
||||
/// Record a decoded tile: its screen tile id and what the collision list makes of it.
|
||||
pub fn set(&mut self, x: u8, y: u8, tile: u8, walkable: Walkable) {
|
||||
if let Some(index) = self.index(x, y) {
|
||||
self.tiles[index] = walkable;
|
||||
self.ids[index] = Some(tile);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record that the cartridge refuses the step out of `(x, y)` in `facing`.
|
||||
pub fn wall(&mut self, x: u8, y: u8, facing: Facing) {
|
||||
if let Some(index) = self.index(x, y) {
|
||||
self.walls[index] |= wall_bit(facing);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the player could stand on this tile. Off the map is [`Walkable::No`], which is the
|
||||
/// window predicate's own answer for it.
|
||||
pub fn walkable(&self, x: u8, y: u8) -> Walkable {
|
||||
match self.index(x, y) {
|
||||
None => Walkable::No,
|
||||
Some(index) => self.tiles[index],
|
||||
}
|
||||
}
|
||||
|
||||
/// The screen tile id this tile's answer came from, or `None` for a tile never decoded.
|
||||
pub fn tile_id(&self, x: u8, y: u8) -> Option<u8> {
|
||||
self.index(x, y).and_then(|index| self.ids[index])
|
||||
}
|
||||
|
||||
/// Whether the step out of `(x, y)` in `facing` is one the cartridge refuses.
|
||||
pub fn walled(&self, x: u8, y: u8, facing: Facing) -> bool {
|
||||
self.index(x, y).is_some_and(|index| self.walls[index] & wall_bit(facing) != 0)
|
||||
}
|
||||
|
||||
/// How many tiles of the map the player could stand on.
|
||||
pub fn walkable_count(&self) -> usize {
|
||||
self.tiles.iter().filter(|tile| tile.is_walkable()).count()
|
||||
}
|
||||
|
||||
/// How many tiles the map has that were never decoded, i.e. [`Walkable::Unknown`].
|
||||
pub fn unknown_count(&self) -> usize {
|
||||
self.tiles.iter().filter(|tile| matches!(tile, Walkable::Unknown)).count()
|
||||
}
|
||||
|
||||
/// Every walkable tile, in row-major order, as `(x, y)`.
|
||||
pub fn walkable_tiles(&self) -> Vec<(u8, u8)> {
|
||||
let mut out = Vec::with_capacity(self.walkable_count());
|
||||
for y in 0..self.height {
|
||||
for x in 0..self.width {
|
||||
if self.walkable(x, y).is_walkable() {
|
||||
out.push((x, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// How many walkable tiles a walk from `(x, y)` could reach, the directed walls respected.
|
||||
///
|
||||
/// The starting tile counts whether or not it is walkable, for the same reason the route
|
||||
/// search treats it as passable: the fly is standing on it. What this number is *for* is
|
||||
/// reading a stalled walk at a glance -- a fly on a route with 600 walkable tiles and 4
|
||||
/// reachable ones is fenced in, and no amount of re-planning is going to help it
|
||||
/// (`docs/design/macros.md` section 15, `examples/scene_probe.rs`).
|
||||
pub fn reachable_from(&self, x: u8, y: u8) -> usize {
|
||||
if self.index(x, y).is_none() {
|
||||
return 0;
|
||||
}
|
||||
let mut seen = vec![false; self.tiles.len()];
|
||||
let mut queue = std::collections::VecDeque::new();
|
||||
if let Some(index) = self.index(x, y) {
|
||||
seen[index] = true;
|
||||
}
|
||||
queue.push_back((x, y));
|
||||
let mut count = 0;
|
||||
while let Some((tx, ty)) = queue.pop_front() {
|
||||
count += 1;
|
||||
for facing in [Facing::Up, Facing::Down, Facing::Left, Facing::Right] {
|
||||
if self.walled(tx, ty, facing) {
|
||||
continue;
|
||||
}
|
||||
let (dx, dy) = facing.delta();
|
||||
let Ok(nx) = u8::try_from(i16::from(tx) + dx) else { continue };
|
||||
let Ok(ny) = u8::try_from(i16::from(ty) + dy) else { continue };
|
||||
let Some(index) = self.index(nx, ny) else { continue };
|
||||
if seen[index] || !self.tiles[index].is_walkable() {
|
||||
continue;
|
||||
}
|
||||
seen[index] = true;
|
||||
queue.push_back((nx, ny));
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
}
|
||||
|
||||
/// Which bit of a [`MapGrid`] wall mask a direction is.
|
||||
fn wall_bit(facing: Facing) -> u8 {
|
||||
match facing {
|
||||
Facing::Up => 1,
|
||||
Facing::Down => 2,
|
||||
Facing::Left => 4,
|
||||
Facing::Right => 8,
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry of the current map's warp table: a door, a staircase, a cave mouth.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Warp {
|
||||
|
|
|
|||
|
|
@ -4110,4 +4110,5 @@ fn a_scripted_push_back_records_the_tile_it_happened_on() {
|
|||
);
|
||||
}
|
||||
|
||||
mod map_aware;
|
||||
mod shop_purchase;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,344 @@
|
|||
//! Walks planned over the whole map, and the same walks with the window as the only reading.
|
||||
//!
|
||||
//! `docs/design/macros.md` section 15. [`super::super::path`] has two readings of the ground now
|
||||
//! and the interesting tests are the ones that tell them apart: a map bigger than the ten-by-nine
|
||||
//! window, a frontier on the far side of it, and a wall the collision list cannot predict. The
|
||||
//! fake here is deliberately not [`super::World`] — it implements the seam and nothing else, so a
|
||||
//! failure is about the search rather than about a script's frames.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::pokemon_red::macros::cartridge::{Edge, ExitId, MacroState, Tile};
|
||||
use crate::pokemon_red::macros::path::{self, Way};
|
||||
use crate::pokemon_red::macros::state::{
|
||||
BagItem, Battle, Connections, Facing, GameState, MapGrid, MapSize, Mon, Npc, Party, Pc, Player,
|
||||
Scene, Shop, Sign, StartMenu, TextBox, Walkable, Warp,
|
||||
};
|
||||
|
||||
/// A passable tile id and a wall tile id, for a grid built by hand.
|
||||
const FLOOR: u8 = 0x01;
|
||||
const WALL: u8 = 0x60;
|
||||
|
||||
/// The ground, and nothing else: the seam's questions about a map and the fly standing on it.
|
||||
struct Ground {
|
||||
map: u8,
|
||||
size: MapSize,
|
||||
player: Tile,
|
||||
/// Tiles that are not walkable; everything else inside `size` is.
|
||||
walls: BTreeSet<Tile>,
|
||||
/// Steps the cartridge refuses although both tiles are passable, as the grid records them.
|
||||
pair_walls: Vec<(Tile, Facing)>,
|
||||
/// Ground the run has stood on, which is what makes a tile not a frontier.
|
||||
stood: BTreeSet<Tile>,
|
||||
warps: Vec<Warp>,
|
||||
connections: Connections,
|
||||
npcs: Vec<Npc>,
|
||||
/// Whether the whole map is decoded, or only the window can answer.
|
||||
decoded: bool,
|
||||
}
|
||||
|
||||
impl Ground {
|
||||
/// A map `wide` by `high` tiles with the fly at `player` and every tile walkable.
|
||||
fn new(wide: u8, high: u8, player: (u8, u8)) -> Self {
|
||||
Self {
|
||||
map: 0,
|
||||
size: MapSize { width: wide, height: high },
|
||||
player: Tile::new(player.0, player.1),
|
||||
walls: BTreeSet::new(),
|
||||
pair_walls: Vec::new(),
|
||||
stood: BTreeSet::new(),
|
||||
warps: Vec::new(),
|
||||
connections: Connections::default(),
|
||||
npcs: Vec::new(),
|
||||
decoded: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn wall(mut self, x: u8, y: u8) -> Self {
|
||||
self.walls.insert(Tile::new(x, y));
|
||||
self
|
||||
}
|
||||
|
||||
/// A column of wall with one gap in it, which is the shape that tells the two readings apart.
|
||||
fn wall_column(mut self, x: u8, gap: u8) -> Self {
|
||||
for y in 0..self.size.height {
|
||||
if y != gap {
|
||||
self.walls.insert(Tile::new(x, y));
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Everything within `distance` of the fly counts as stood on, which is what a fly that has
|
||||
/// been walking around one corner of a route has.
|
||||
fn stood_around(mut self, distance: u32) -> Self {
|
||||
for y in 0..self.size.height {
|
||||
for x in 0..self.size.width {
|
||||
let tile = Tile::new(x, y);
|
||||
if tile.distance(self.player) <= distance {
|
||||
self.stood.insert(tile);
|
||||
}
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// The window reading only: what every walk had before section 15.
|
||||
fn window_only(mut self) -> Self {
|
||||
self.decoded = false;
|
||||
self
|
||||
}
|
||||
|
||||
fn pair_wall(mut self, from: (u8, u8), facing: Facing) -> Self {
|
||||
self.pair_walls.push((Tile::new(from.0, from.1), facing));
|
||||
self
|
||||
}
|
||||
|
||||
fn connected(mut self, connections: Connections) -> Self {
|
||||
self.connections = connections;
|
||||
self
|
||||
}
|
||||
|
||||
/// The ten-by-nine window agent A's predicate can answer for, which moves with the fly.
|
||||
fn in_window(&self, x: u8, y: u8) -> bool {
|
||||
let dx = i32::from(x) - i32::from(self.player.x);
|
||||
let dy = i32::from(y) - i32::from(self.player.y);
|
||||
(-4..=5).contains(&dx) && (-4..=4).contains(&dy)
|
||||
}
|
||||
|
||||
fn walkable_tile(&self, x: u8, y: u8) -> bool {
|
||||
x < self.size.width && y < self.size.height && !self.walls.contains(&Tile::new(x, y))
|
||||
}
|
||||
}
|
||||
|
||||
impl GameState for Ground {
|
||||
fn scene(&mut self) -> Scene {
|
||||
Scene::Overworld
|
||||
}
|
||||
|
||||
fn player(&mut self) -> Option<Player> {
|
||||
Some(Player { map: self.map, x: self.player.x, y: self.player.y, facing: Facing::Down })
|
||||
}
|
||||
|
||||
fn map_size(&mut self) -> Option<MapSize> {
|
||||
Some(self.size)
|
||||
}
|
||||
|
||||
fn party(&mut self) -> Party {
|
||||
Party { mons: Vec::<Mon>::new(), active: None }
|
||||
}
|
||||
|
||||
fn battle(&mut self) -> Option<Battle> {
|
||||
None
|
||||
}
|
||||
|
||||
fn text_box(&mut self) -> TextBox {
|
||||
TextBox { open: false, waiting: false }
|
||||
}
|
||||
|
||||
fn start_menu(&mut self) -> Option<StartMenu> {
|
||||
None
|
||||
}
|
||||
|
||||
fn shop(&mut self) -> Option<Shop> {
|
||||
None
|
||||
}
|
||||
|
||||
fn pc(&mut self) -> Option<Pc> {
|
||||
None
|
||||
}
|
||||
|
||||
fn money(&mut self) -> u32 {
|
||||
0
|
||||
}
|
||||
|
||||
fn bag(&mut self) -> Vec<BagItem> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn npcs(&mut self) -> Vec<Npc> {
|
||||
self.npcs.clone()
|
||||
}
|
||||
|
||||
fn signs(&mut self) -> Vec<Sign> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Agent A's predicate: the window, and `Unknown` outside it.
|
||||
fn walkable(&mut self, x: u8, y: u8) -> Walkable {
|
||||
if x >= self.size.width || y >= self.size.height {
|
||||
return Walkable::No;
|
||||
}
|
||||
if !self.in_window(x, y) {
|
||||
return Walkable::Unknown;
|
||||
}
|
||||
if self.walkable_tile(x, y) { Walkable::Yes } else { Walkable::No }
|
||||
}
|
||||
|
||||
fn warps(&mut self) -> Vec<Warp> {
|
||||
self.warps.clone()
|
||||
}
|
||||
|
||||
fn connections(&mut self) -> Connections {
|
||||
self.connections
|
||||
}
|
||||
}
|
||||
|
||||
impl MacroState for Ground {
|
||||
fn map_grid(&mut self) -> Option<Arc<MapGrid>> {
|
||||
if !self.decoded {
|
||||
return None;
|
||||
}
|
||||
let mut grid = MapGrid::new(self.map, self.size.width, self.size.height);
|
||||
for y in 0..self.size.height {
|
||||
for x in 0..self.size.width {
|
||||
let walkable = self.walkable_tile(x, y);
|
||||
grid.set(
|
||||
x,
|
||||
y,
|
||||
if walkable { FLOOR } else { WALL },
|
||||
if walkable { Walkable::Yes } else { Walkable::No },
|
||||
);
|
||||
}
|
||||
}
|
||||
for (tile, facing) in &self.pair_walls {
|
||||
grid.wall(tile.x, tile.y, *facing);
|
||||
}
|
||||
Some(Arc::new(grid))
|
||||
}
|
||||
|
||||
fn tile_visited(&mut self, x: u8, y: u8) -> bool {
|
||||
self.stood.contains(&Tile::new(x, y))
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk a route's presses and report where they land and whether every tile was walkable.
|
||||
fn walk(ground: &Ground, from: Tile, steps: &[Facing]) -> (Tile, bool) {
|
||||
let mut at = from;
|
||||
let mut clean = true;
|
||||
for facing in steps {
|
||||
let Some(next) = at.step(*facing) else {
|
||||
clean = false;
|
||||
break;
|
||||
};
|
||||
if !ground.walkable_tile(next.x, next.y) {
|
||||
clean = false;
|
||||
}
|
||||
at = next;
|
||||
}
|
||||
(at, clean)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_plan_crosses_a_map_larger_than_the_window() {
|
||||
// Twenty by eighteen — Pallet Town's size — with a wall down the middle and one gap in it,
|
||||
// which is where a plan has to go and is off the screen from where the fly starts.
|
||||
let start = (2, 2);
|
||||
let goal = Tile::new(18, 16);
|
||||
let mut ground = Ground::new(20, 18, start).wall_column(10, 1);
|
||||
let route = path::route(&mut ground, &[goal]).expect("a route across the map");
|
||||
assert_eq!(route.goal, Some(0), "the goal itself, not an approach to it");
|
||||
let (landed, clean) = walk(&ground, Tile::new(start.0, start.1), &route.steps);
|
||||
assert_eq!(landed, goal);
|
||||
assert!(clean, "every tile of the plan is walkable ground");
|
||||
// The gap is at the top, so the plan is longer than the Manhattan distance and knows it.
|
||||
assert!(route.steps.len() > Tile::new(start.0, start.1).distance(goal) as usize);
|
||||
|
||||
// The same map with only the window to read: the plan sets off through a wall it cannot see.
|
||||
let mut blind = Ground::new(20, 18, start).wall_column(10, 1).window_only();
|
||||
let route = path::route(&mut blind, &[goal]).expect("a route through the unknown");
|
||||
let (_, clean) = walk(&blind, Tile::new(start.0, start.1), &route.steps);
|
||||
assert!(!clean, "the window cannot see the wall, so the plan walks into it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_frontier_is_the_nearest_unstood_tile_anywhere_on_the_map() {
|
||||
// A fly that has covered the ground around it. Nine tiles is the furthest corner of the
|
||||
// ten-by-nine window (five across and four down), so every tile the window can answer for has
|
||||
// been stood on and the nearest new ground is the first tile outside it.
|
||||
let mut ground = Ground::new(20, 18, (3, 3)).stood_around(9);
|
||||
let frontier = path::frontier(&mut ground);
|
||||
assert!(!frontier.is_empty(), "the rest of the map is still new ground");
|
||||
let (tile, facing) = frontier
|
||||
.iter()
|
||||
.copied()
|
||||
.min_by_key(|(tile, _)| tile.distance(Tile::new(3, 3)))
|
||||
.expect("a nearest frontier");
|
||||
let new_ground = tile.step(facing).expect("the ground it faces");
|
||||
assert!(!ground.tile_visited(new_ground.x, new_ground.y));
|
||||
assert_eq!(tile.distance(Tile::new(3, 3)), 9, "the near side of the unstood ground");
|
||||
// And a route to it, in one plan.
|
||||
let goals: Vec<Tile> = frontier.iter().map(|(tile, _)| *tile).collect();
|
||||
let route = path::route(&mut ground, &goals).expect("a route to the frontier");
|
||||
assert!(route.goal.is_some());
|
||||
|
||||
// The window reading has nothing to offer here at all, which is the pad the operator saw
|
||||
// hanging: every tile it can answer for has been stood on.
|
||||
let mut blind = Ground::new(20, 18, (3, 3)).stood_around(9).window_only();
|
||||
assert!(path::frontier(&mut blind).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_step_the_tables_refuse_is_planned_around_rather_than_walked_into() {
|
||||
// A tile-pair collision: both tiles passable, the step between them refused
|
||||
// (`data/tilesets/pair_collision_tile_ids.asm`). The wall is the only way east on its row, so
|
||||
// a search that did not know about it would plan straight through.
|
||||
let goal = Tile::new(3, 0);
|
||||
let mut ground = Ground::new(4, 3, (1, 0))
|
||||
.pair_wall((1, 0), Facing::Right)
|
||||
.pair_wall((2, 0), Facing::Left);
|
||||
let route = path::route(&mut ground, &[goal]).expect("a route round the pair wall");
|
||||
assert_eq!(route.steps.first(), Some(&Facing::Down), "round it, not through it");
|
||||
let (landed, clean) = walk(&ground, Tile::new(1, 0), &route.steps);
|
||||
assert_eq!(landed, goal);
|
||||
assert!(clean);
|
||||
assert!(!route.steps.is_empty());
|
||||
|
||||
// Without the pair rule the same map is a straight line east.
|
||||
let mut open = Ground::new(4, 3, (1, 0));
|
||||
let route = path::route(&mut open, &[goal]).expect("a route east");
|
||||
assert_eq!(route.steps, vec![Facing::Right, Facing::Right]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_connection_on_the_far_edge_is_a_goal_and_the_walls_along_it_are_not() {
|
||||
// A route's north edge, off the screen from where the fly stands, with two walkable tiles on
|
||||
// it. With the map decoded the exit list is those two tiles and not the whole row.
|
||||
let mut ground = Ground::new(20, 18, (3, 16))
|
||||
.connected(Connections { north: true, south: false, east: false, west: false });
|
||||
for x in 0..20u8 {
|
||||
if x != 7 && x != 8 {
|
||||
ground = ground.wall(x, 0);
|
||||
}
|
||||
}
|
||||
let exits = path::exits(&mut ground);
|
||||
let north: Vec<ExitId> = exits
|
||||
.iter()
|
||||
.filter(|exit| exit.id == ExitId::Edge(Edge::North))
|
||||
.map(|exit| exit.id)
|
||||
.collect();
|
||||
assert_eq!(north.len(), 2, "the two tiles a step north can be taken from");
|
||||
let goals: Vec<Tile> = exits
|
||||
.iter()
|
||||
.filter(|exit| exit.way == Way::Route && exit.id == ExitId::Edge(Edge::North))
|
||||
.map(|exit| exit.tile)
|
||||
.collect();
|
||||
assert!(goals.iter().all(|tile| tile.y == 0 && (tile.x == 7 || tile.x == 8)));
|
||||
let route = path::route(&mut ground, &goals).expect("a route to the connection");
|
||||
assert!(route.goal.is_some());
|
||||
let (landed, clean) = walk(&ground, Tile::new(3, 16), &route.steps);
|
||||
assert!(goals.contains(&landed));
|
||||
assert!(clean, "one plan, across sixteen tiles of map, every tile of it known ground");
|
||||
|
||||
// With only the window, every tile of that edge is `Unknown` and so every one of them is an
|
||||
// exit, walls included: the search's own approach answer is what used to carry the walk.
|
||||
let mut blind = Ground::new(20, 18, (3, 16))
|
||||
.connected(Connections { north: true, south: false, east: false, west: false })
|
||||
.window_only();
|
||||
let count = path::exits(&mut blind)
|
||||
.iter()
|
||||
.filter(|exit| exit.id == ExitId::Edge(Edge::North))
|
||||
.count();
|
||||
assert_eq!(count, 20);
|
||||
}
|
||||
270
services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid.rs
Normal file
270
services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid.rs
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
//! The whole current map's walkability, decoded from the tables the cartridge has loaded.
|
||||
//!
|
||||
//! `docs/design/macros.md` section 15, the operator 2026-09-22: "the frontier and warp macros need
|
||||
//! to be map aware: A* over walkable tiles." [`super::state::walkable`] answers for the ten-by-nine
|
||||
//! window of the screen buffer and [`super::macros::state::Walkable::Unknown`] for everything
|
||||
//! else, which is honest and is also why every walk planned through guesses, re-planned at each
|
||||
//! window edge, and called "frontier" whatever unstood ground happened to be on screen.
|
||||
//!
|
||||
//! This module is the same predicate over the whole map. Nothing about the *rule* changes -- a tile
|
||||
//! is walkable when the current tileset's collision list holds its tile id, which is
|
||||
//! `CheckTilePassable` -- what changes is where the tile id comes from:
|
||||
//!
|
||||
//! | what | where the cartridge keeps it | how it is read |
|
||||
//! | --- | --- | --- |
|
||||
//! | the map's blocks | `wOverworldMap`, one byte per 4x4-tile block, rows of `width + MAP_BORDER * 2` with the map three rows and three columns in (`LoadTileBlockMap`) | WRAM, through the ordinary reader |
|
||||
//! | a block's tiles | the tileset header's blockset, 16 bytes per block id, four rows of four tile ids (`DrawTileBlock`) | ROM, through [`crate::adapter::MemoryReader::read_rom`], because the blockset is not in bank 0 |
|
||||
//! | which tiles are passable | `wTilesetCollisionPtr`, a `$ff`-terminated list in bank 0 | WRAM pointer, ROM bank 0 read, exactly as the window predicate already did |
|
||||
//! | which steps are refused between two passable tiles | `TilePairCollisionsLand`, keyed by `wCurMapTileset` | the table's values, quoted below |
|
||||
//!
|
||||
//! Two coordinate systems meet here and keeping them apart is the whole of the arithmetic. The
|
||||
//! cartridge's *blocks* are 4x4 screen tiles; the player moves in *map tiles* of 2x2 screen tiles,
|
||||
//! which is the unit `wXCoord`, the warp table and everything in `macros/` is in. So one block is
|
||||
//! [`TILES_PER_BLOCK`] map tiles each way, and the screen tile a map tile's walkability is read
|
||||
//! from is the **lower** left of its 2x2 quadrant ([`ANCHOR_ROW`]) -- the corner
|
||||
//! `_GetTileAndCoordsInFrontOfPlayer` reads at screen `(8, 9)` for the tile the player stands on,
|
||||
//! measured rather than argued and pinned by [`super::state::map_grid`]'s cross-check against the
|
||||
//! window predicate on a cartridge.
|
||||
//!
|
||||
//! What it does **not** model is what it did not model before: sprites standing on ground (the
|
||||
//! sprite list answers that, and [`super::macros::path::frontier`] reads it), warps that fire on
|
||||
//! the step onto them, and scripts that push the fly off a tile (a session ledger answers that).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::macros::state::{Facing, MapGrid, Walkable};
|
||||
|
||||
/// Screen tiles across and down one block: `BLOCK_WIDTH` and `BLOCK_HEIGHT`
|
||||
/// (`constants/gfx_constants.asm`).
|
||||
pub const BLOCK_TILES: usize = 4;
|
||||
|
||||
/// Bytes one block takes in a tileset's blockset: its sixteen tile ids, four rows of four.
|
||||
pub const BLOCK_BYTES: usize = BLOCK_TILES * BLOCK_TILES;
|
||||
|
||||
/// Blocks of border `wOverworldMap` keeps on every side: `MAP_BORDER`
|
||||
/// (`constants/map_data_constants.asm`), which is what lets the view centre on a map smaller than
|
||||
/// the screen and what a map's own blocks are offset by.
|
||||
pub const MAP_BORDER: usize = 3;
|
||||
|
||||
/// Map tiles across one block. A block is four screen tiles and the player walks two at a time,
|
||||
/// which is why `wCurMapWidth` in blocks is `map_size().width` in tiles divided by this.
|
||||
pub const TILES_PER_BLOCK: u8 = 2;
|
||||
|
||||
/// Bytes `wOverworldMap` has for the loaded map: `ds 1300` (`ram/wram.asm`).
|
||||
///
|
||||
/// A map plus its border of [`MAP_BORDER`] blocks has to fit in this, and every real map does. A
|
||||
/// header that says otherwise is a header read mid-load, which is why the bound is a refusal
|
||||
/// rather than a clamp.
|
||||
pub const OVERWORLD_MAP_BYTES: usize = 1300;
|
||||
|
||||
/// Which of a quadrant's two rows the collision read takes its tile id from: the lower one.
|
||||
///
|
||||
/// A map tile is a 2x2 patch of screen tiles and only one of the four is ever asked about, because
|
||||
/// `CheckTilePassable` matches a single tile id. Which one is **measured, not derived**: the
|
||||
/// decoded ids were compared against the screen buffer on the cartridge, tile by tile, and the
|
||||
/// screen agrees with the lower-left tile of each quadrant and not the upper-left -- Viridian
|
||||
/// Forest's (4, 32) reads `$23`, the second row of its block, where the first row holds `$04`
|
||||
/// (`tests/rom_map_grid.rs`, which is the test that pins it).
|
||||
///
|
||||
/// That is the same corner `_GetTileAndCoordsInFrontOfPlayer` reads at screen `(8, 9)` for the
|
||||
/// tile the player is standing on: the view is aligned so that the player's own 2x2 begins on
|
||||
/// screen row 8, so row 9 is its lower half. An upper-left decode still answers, and answers
|
||||
/// plausibly -- on the open ground of a town most quadrants hold one tile id four times over --
|
||||
/// which is why the cross-check in [`super::state::map_grid`] is not optional.
|
||||
const ANCHOR_ROW: usize = 1;
|
||||
|
||||
/// Tileset ids the tile-pair lists name (`constants/tileset_constants.asm`, counted in the order
|
||||
/// that file declares them: OVERWORLD 0 … FOREST 3 … CAVERN 17).
|
||||
pub mod tileset {
|
||||
pub const FOREST: u8 = 3;
|
||||
pub const CAVERN: u8 = 17;
|
||||
}
|
||||
|
||||
/// `TilePairCollisionsLand` at the pinned pokered commit, as `(tileset, one tile, the other)`.
|
||||
///
|
||||
/// `data/tilesets/pair_collision_tile_ids.asm`. Values rather than an address, like the item
|
||||
/// prices in [`super::macros::cartridge`]: what the cartridge keeps here is eleven triples, and
|
||||
/// the file they came from is quoted so a wrong one is a review comment rather than a mystery.
|
||||
///
|
||||
/// `CheckForTilePairCollisions` walks the list comparing the tile the player stands on against
|
||||
/// *either* member of the pair and the tile in front against the other, so the refusal is
|
||||
/// symmetric -- a forest's tree line refuses the step in both directions -- and each triple
|
||||
/// becomes two directed walls. Both tiles are passable on their own, which is why nothing in the
|
||||
/// collision list can predict it and why the walk used to learn it one refusal at a time
|
||||
/// ([`super::macros::path::Refusal`]).
|
||||
///
|
||||
/// `TilePairCollisionsWater` is deliberately absent: it is the list
|
||||
/// `CheckForJumpingAndTilePairCollisions` uses while surfing, the fly has no Surf, and a rule for
|
||||
/// a movement mode the palette cannot enter is not knowledge this grid should carry.
|
||||
pub const TILE_PAIRS_LAND: [(u8, u8, u8); 11] = [
|
||||
(tileset::CAVERN, 0x20, 0x05),
|
||||
(tileset::CAVERN, 0x41, 0x05),
|
||||
(tileset::FOREST, 0x30, 0x2e),
|
||||
(tileset::CAVERN, 0x2a, 0x05),
|
||||
(tileset::CAVERN, 0x05, 0x21),
|
||||
(tileset::FOREST, 0x52, 0x2e),
|
||||
(tileset::FOREST, 0x55, 0x2e),
|
||||
(tileset::FOREST, 0x56, 0x2e),
|
||||
(tileset::FOREST, 0x20, 0x2e),
|
||||
(tileset::FOREST, 0x5e, 0x2e),
|
||||
(tileset::FOREST, 0x5f, 0x2e),
|
||||
];
|
||||
|
||||
/// The tileset's two tables, as the decoder needs them.
|
||||
///
|
||||
/// Gathered by [`super::state::map_grid`] from WRAM and ROM; separate from the decoding so that
|
||||
/// the decoding is a pure function of bytes and can be tested against a made-up tileset.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Tileset {
|
||||
/// `wCurMapTileset`: which tileset, for the tile-pair lists.
|
||||
pub id: u8,
|
||||
/// The blockset: sixteen tile ids per block id, in block-id order from zero.
|
||||
pub blocks: Vec<u8>,
|
||||
/// The `$ff`-terminated passable-tile list, terminator included or not.
|
||||
pub passable: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Tileset {
|
||||
/// The screen tile id at `(column, row)` of block `block`, or `None` past the blockset.
|
||||
fn tile(&self, block: u8, column: usize, row: usize) -> Option<u8> {
|
||||
let offset = usize::from(block) * BLOCK_BYTES + row * BLOCK_TILES + column;
|
||||
self.blocks.get(offset).copied()
|
||||
}
|
||||
|
||||
/// Whether the collision list holds `tile`, which is `CheckTilePassable` and nothing else.
|
||||
fn passable(&self, tile: u8) -> bool {
|
||||
for candidate in &self.passable {
|
||||
if *candidate == TERMINATOR {
|
||||
return false;
|
||||
}
|
||||
if *candidate == tile {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// The `$ff` that ends a collision list.
|
||||
pub const TERMINATOR: u8 = 0xff;
|
||||
|
||||
/// Decode the whole map into a [`MapGrid`].
|
||||
///
|
||||
/// `blocks` is the map's block ids, row-major, `width_blocks * height_blocks` of them, already
|
||||
/// lifted out of `wOverworldMap`'s bordered rows. Every map tile gets one answer:
|
||||
/// [`Walkable::Yes`] or [`Walkable::No`] from the collision list, and [`Walkable::Unknown`] only
|
||||
/// for a tile whose block id is past the end of the blockset -- which is a table that was read
|
||||
/// short rather than a tile the game is unsure about.
|
||||
pub fn decode(map: u8, width_blocks: u8, height_blocks: u8, blocks: &[u8], tiles: &Tileset) -> MapGrid {
|
||||
let width = width_blocks.saturating_mul(TILES_PER_BLOCK);
|
||||
let height = height_blocks.saturating_mul(TILES_PER_BLOCK);
|
||||
let mut grid = MapGrid::new(map, width, height);
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let block_index =
|
||||
usize::from(y / TILES_PER_BLOCK) * usize::from(width_blocks)
|
||||
+ usize::from(x / TILES_PER_BLOCK);
|
||||
let Some(block) = blocks.get(block_index).copied() else {
|
||||
continue;
|
||||
};
|
||||
// The screen tile the collision read uses, inside the map tile's own 2x2 quadrant of
|
||||
// the block: the **lower** left one ([`ANCHOR_ROW`]).
|
||||
let column = usize::from(x % TILES_PER_BLOCK) * usize::from(TILES_PER_BLOCK);
|
||||
let row = usize::from(y % TILES_PER_BLOCK) * usize::from(TILES_PER_BLOCK) + ANCHOR_ROW;
|
||||
let Some(tile) = tiles.tile(block, column, row) else {
|
||||
continue;
|
||||
};
|
||||
grid.set(x, y, tile, if tiles.passable(tile) { Walkable::Yes } else { Walkable::No });
|
||||
}
|
||||
}
|
||||
add_pair_walls(&mut grid, tiles.id);
|
||||
grid
|
||||
}
|
||||
|
||||
/// Turn every tile-pair collision the loaded tileset has into two directed walls.
|
||||
fn add_pair_walls(grid: &mut MapGrid, tileset: u8) {
|
||||
let pairs: Vec<(u8, u8)> = TILE_PAIRS_LAND
|
||||
.iter()
|
||||
.filter(|(id, _, _)| *id == tileset)
|
||||
.map(|(_, one, other)| (*one, *other))
|
||||
.collect();
|
||||
if pairs.is_empty() {
|
||||
return;
|
||||
}
|
||||
for y in 0..grid.height() {
|
||||
for x in 0..grid.width() {
|
||||
let Some(here) = grid.tile_id(x, y) else { continue };
|
||||
for facing in [Facing::Down, Facing::Right] {
|
||||
let (dx, dy) = facing.delta();
|
||||
let Some(nx) = checked_step(x, dx) else { continue };
|
||||
let Some(ny) = checked_step(y, dy) else { continue };
|
||||
if nx >= grid.width() || ny >= grid.height() {
|
||||
continue;
|
||||
}
|
||||
let Some(there) = grid.tile_id(nx, ny) else { continue };
|
||||
if pairs.iter().any(|(one, other)| {
|
||||
(*one == here && *other == there) || (*one == there && *other == here)
|
||||
}) {
|
||||
grid.wall(x, y, facing);
|
||||
grid.wall(nx, ny, opposite(facing));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn checked_step(value: u8, delta: i16) -> Option<u8> {
|
||||
u8::try_from(i16::from(value) + delta).ok()
|
||||
}
|
||||
|
||||
fn opposite(facing: Facing) -> Facing {
|
||||
match facing {
|
||||
Facing::Down => Facing::Up,
|
||||
Facing::Up => Facing::Down,
|
||||
Facing::Left => Facing::Right,
|
||||
Facing::Right => Facing::Left,
|
||||
}
|
||||
}
|
||||
|
||||
/// The decoded grid of the map that is loaded, kept for as long as it is the loaded one.
|
||||
///
|
||||
/// One slot, keyed by map id and size: arriving on another map drops it, and so does a map whose
|
||||
/// header reads a different size, because both mean the block data under it has been replaced.
|
||||
/// A decode is a few thousand WRAM reads and a walk of the blockset, so it happens once per
|
||||
/// arrival rather than once per plan -- and never per frame, which is what a precondition asking
|
||||
/// for the frontier would otherwise cost.
|
||||
///
|
||||
/// Session state like the other ledgers of `docs/design/macros.md` section 12: owned by
|
||||
/// [`super::macros::driver::PokemonPalette`], never checkpointed, and rebuilt from the cartridge
|
||||
/// on the first frame after a restore.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MapGrids {
|
||||
current: Option<Arc<MapGrid>>,
|
||||
}
|
||||
|
||||
impl MapGrids {
|
||||
/// The cached grid for `map` at this size, or `None` when the cache is for somewhere else.
|
||||
///
|
||||
/// Handed out behind an [`Arc`] so that a caller that asks once a frame -- a precondition
|
||||
/// wanting to know whether the frontier is empty -- pays a refcount rather than a copy of the
|
||||
/// map.
|
||||
pub fn get(&self, map: u8, width: u8, height: u8) -> Option<Arc<MapGrid>> {
|
||||
self.current
|
||||
.as_ref()
|
||||
.filter(|grid| grid.map() == map && grid.width() == width && grid.height() == height)
|
||||
.map(Arc::clone)
|
||||
}
|
||||
|
||||
/// Keep `grid`, dropping whatever map the cache held before.
|
||||
pub fn store(&mut self, grid: MapGrid) -> Arc<MapGrid> {
|
||||
Arc::clone(self.current.insert(Arc::new(grid)))
|
||||
}
|
||||
|
||||
/// Which map the cache is holding, for a log line and the tests.
|
||||
pub fn held(&self) -> Option<u8> {
|
||||
self.current.as_ref().map(|grid| grid.map())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
//! The decoder, against a made-up tileset.
|
||||
//!
|
||||
//! Every byte here is synthetic on purpose: a block table, a blockset and a collision list of this
|
||||
//! module's own making, so a failure says which of the three readings is wrong rather than "the
|
||||
//! cartridge disagrees". The cartridge half — that the decode matches real presses on Pallet Town
|
||||
//! and Viridian Forest — is `tests/rom_map_grid.rs`.
|
||||
|
||||
use super::*;
|
||||
use crate::pokemon_red::macros::state::{Facing, Walkable};
|
||||
|
||||
/// A blockset with three blocks: all floor, all wall, and one whose four map-tile quadrants are
|
||||
/// four different tile ids.
|
||||
///
|
||||
/// A block is four screen tiles each way and a map tile is two, so the quadrants are the four
|
||||
/// corners and the tile a map tile's walkability comes from is the lower left of its own quadrant
|
||||
/// ([`ANCHOR_ROW`], which the cartridge settled).
|
||||
fn blockset() -> Vec<u8> {
|
||||
let floor = [FLOOR; 16];
|
||||
let wall = [WALL; 16];
|
||||
// The four ids sit on the rows the decode reads -- the *lower* row of each 2x2 quadrant
|
||||
// (`ANCHOR_ROW`) -- and the rows it does not read hold ids that would be wrong answers.
|
||||
let quadrants = [
|
||||
0x90, 0x91, 0x92, 0x93, //
|
||||
NORTH_WEST, 0x94, NORTH_EAST, 0x95, //
|
||||
0x96, 0x97, 0x98, 0x99, //
|
||||
SOUTH_WEST, 0x9a, SOUTH_EAST, 0x9b,
|
||||
];
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&floor);
|
||||
out.extend_from_slice(&wall);
|
||||
out.extend_from_slice(&quadrants);
|
||||
out
|
||||
}
|
||||
|
||||
const FLOOR: u8 = 0x01;
|
||||
const WALL: u8 = 0x60;
|
||||
const NORTH_WEST: u8 = 0x11;
|
||||
const NORTH_EAST: u8 = 0x12;
|
||||
const SOUTH_WEST: u8 = 0x13;
|
||||
const SOUTH_EAST: u8 = 0x14;
|
||||
|
||||
/// Floor and the four quadrant tiles are passable; the wall tile is in no list.
|
||||
fn tileset(id: u8) -> Tileset {
|
||||
Tileset {
|
||||
id,
|
||||
blocks: blockset(),
|
||||
passable: vec![FLOOR, NORTH_WEST, NORTH_EAST, SOUTH_WEST, SOUTH_EAST, TERMINATOR],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_block_becomes_four_map_tiles_from_its_four_quadrants() {
|
||||
let grid = decode(7, 1, 1, &[2], &tileset(0));
|
||||
assert_eq!((grid.map(), grid.width(), grid.height()), (7, 2, 2));
|
||||
assert_eq!(grid.tile_id(0, 0), Some(NORTH_WEST));
|
||||
assert_eq!(grid.tile_id(1, 0), Some(NORTH_EAST));
|
||||
assert_eq!(grid.tile_id(0, 1), Some(SOUTH_WEST));
|
||||
assert_eq!(grid.tile_id(1, 1), Some(SOUTH_EAST));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_collision_list_answers_every_tile_of_a_map_larger_than_the_window() {
|
||||
// Ten blocks by nine is twenty tiles by eighteen: wider than the ten-by-nine window the
|
||||
// screen buffer can answer for, which is the point of the whole grid.
|
||||
let (wide, high) = (10u8, 9u8);
|
||||
let mut blocks = vec![0u8; usize::from(wide) * usize::from(high)];
|
||||
// A wall down the middle column of blocks.
|
||||
for row in 0..usize::from(high) {
|
||||
blocks[row * usize::from(wide) + 5] = 1;
|
||||
}
|
||||
let grid = decode(0, wide, high, &blocks, &tileset(0));
|
||||
assert_eq!((grid.width(), grid.height()), (20, 18));
|
||||
assert_eq!(grid.walkable(0, 0), Walkable::Yes);
|
||||
assert_eq!(grid.walkable(19, 17), Walkable::Yes, "the far corner, which no window reaches");
|
||||
assert_eq!(grid.walkable(10, 9), Walkable::No);
|
||||
assert_eq!(grid.walkable(11, 9), Walkable::No);
|
||||
assert_eq!(grid.unknown_count(), 0);
|
||||
assert_eq!(grid.walkable_count(), 20 * 18 - 18 * 2);
|
||||
// Off the map is not a tile to stand on, which is the window predicate's own answer for it.
|
||||
assert_eq!(grid.walkable(20, 0), Walkable::No);
|
||||
assert_eq!(grid.walkable(0, 18), Walkable::No);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tile_pair_collision_is_a_wall_in_both_directions_and_only_in_its_own_tileset() {
|
||||
// `FOREST, $30, $2E`: two tiles that are each passable on their own and that the cartridge
|
||||
// refuses the step between (`data/tilesets/pair_collision_tile_ids.asm`).
|
||||
let (one, other) = (0x30, 0x2e);
|
||||
let tiles = Tileset {
|
||||
id: tileset::FOREST,
|
||||
blocks: [[one; 16], [other; 16]].concat(),
|
||||
passable: vec![one, other, TERMINATOR],
|
||||
};
|
||||
let grid = decode(0, 2, 1, &[0, 1], &tiles);
|
||||
assert_eq!(grid.walkable(1, 0), Walkable::Yes);
|
||||
assert_eq!(grid.walkable(2, 0), Walkable::Yes);
|
||||
assert!(grid.walled(1, 0, Facing::Right), "the step onto the other tile");
|
||||
assert!(grid.walled(2, 0, Facing::Left), "and the step back, which is the same rule");
|
||||
assert!(!grid.walled(0, 0, Facing::Right), "two tiles of the same id are not a pair");
|
||||
|
||||
// The same two tile ids in a tileset the list does not name are ordinary ground.
|
||||
let elsewhere = Tileset { id: tileset::CAVERN, ..tiles };
|
||||
let grid = decode(0, 2, 1, &[0, 1], &elsewhere);
|
||||
assert!(!grid.walled(1, 0, Facing::Right));
|
||||
assert!(!grid.walled(2, 0, Facing::Left));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_id_the_blockset_does_not_reach_stays_unknown() {
|
||||
// A blockset read short — a header pointer near the end of its bank — is not a tile the game
|
||||
// is unsure about, and the search prices `Unknown` as plausible ground rather than refusing
|
||||
// it, so saying so is the honest answer.
|
||||
let grid = decode(0, 2, 1, &[0, 9], &tileset(0));
|
||||
assert_eq!(grid.walkable(0, 0), Walkable::Yes);
|
||||
assert_eq!(grid.walkable(2, 0), Walkable::Unknown);
|
||||
assert_eq!(grid.tile_id(2, 0), None);
|
||||
// Block 9 is one block, which is four map tiles: two rows of two.
|
||||
assert_eq!(grid.unknown_count(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reachable_from_counts_what_a_walk_can_get_to_and_not_what_it_can_see() {
|
||||
// Two floor blocks with a wall block between them: eight walkable tiles, four of them fenced
|
||||
// off from the fly. This is the number that tells a stalled walk from a long one.
|
||||
let grid = decode(0, 3, 1, &[0, 1, 0], &tileset(0));
|
||||
assert_eq!(grid.walkable_count(), 8);
|
||||
assert_eq!(grid.reachable_from(0, 0), 4);
|
||||
assert_eq!(grid.reachable_from(4, 0), 4);
|
||||
|
||||
// With the wall gone, the whole row is one region.
|
||||
let grid = decode(0, 3, 1, &[0, 0, 0], &tileset(0));
|
||||
assert_eq!(grid.reachable_from(0, 0), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_directed_wall_fences_a_region_off_for_the_reachable_count_too() {
|
||||
let (one, other) = (0x30, 0x2e);
|
||||
let tiles = Tileset {
|
||||
id: tileset::FOREST,
|
||||
blocks: [[one; 16], [other; 16]].concat(),
|
||||
passable: vec![one, other, TERMINATOR],
|
||||
};
|
||||
// A column of `$30` and a column of `$2e`, four tiles each, with the tile-pair rule between
|
||||
// every pair of them: every tile is walkable and half of them are unreachable.
|
||||
let grid = decode(0, 2, 1, &[0, 1], &tiles);
|
||||
assert_eq!(grid.walkable_count(), 8);
|
||||
assert_eq!(grid.reachable_from(0, 0), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cache_holds_one_map_and_drops_it_on_arrival_somewhere_else() {
|
||||
let mut grids = MapGrids::default();
|
||||
assert_eq!(grids.held(), None);
|
||||
let stored = grids.store(decode(3, 2, 2, &[0, 0, 0, 0], &tileset(0)));
|
||||
assert_eq!(grids.held(), Some(3));
|
||||
assert_eq!(grids.get(3, 4, 4).as_deref(), Some(&*stored));
|
||||
// The same map at another size is another map's block data under the same id, which is what a
|
||||
// half-loaded header looks like.
|
||||
assert!(grids.get(3, 8, 8).is_none());
|
||||
assert!(grids.get(4, 4, 4).is_none());
|
||||
grids.store(decode(4, 1, 1, &[0], &tileset(0)));
|
||||
assert_eq!(grids.held(), Some(4));
|
||||
assert!(grids.get(3, 4, 4).is_none());
|
||||
}
|
||||
|
|
@ -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<u8> {
|
||||
self.source.read_rom(bank, address)
|
||||
}
|
||||
}
|
||||
|
||||
fn word(memory: &mut impl MemoryReader, address: u16) -> u32 {
|
||||
|
|
|
|||
|
|
@ -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<Npc> {
|
|||
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<bool> {
|
||||
/// 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<Vec<u8>> {
|
||||
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<bool> {
|
|||
}
|
||||
// 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<bool> {
|
||||
let list = collision_list(memory)?;
|
||||
for candidate in list {
|
||||
if candidate == mapgrid::TERMINATOR {
|
||||
return Some(false);
|
||||
}
|
||||
if candidate == tile {
|
||||
return Some(true);
|
||||
}
|
||||
}
|
||||
Some(false)
|
||||
}
|
||||
|
||||
/// Why a whole-map grid could not be decoded on this frame.
|
||||
///
|
||||
/// `docs/design/macros.md` section 15 asks the fallback to *say when*, so every way out of
|
||||
/// [`map_grid`] is named rather than being one `None`. Each one leaves the window predicate in
|
||||
/// charge, which is what the walks did before the grid existed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GridRefusal {
|
||||
/// The map header is not loaded, or its size is out of range (`map_size` said `None`).
|
||||
NoHeader,
|
||||
/// The player's coordinates are not readable, so nothing can be cross-checked.
|
||||
NoPlayer,
|
||||
/// The tileset's collision list could not be followed ([`collision_list`]).
|
||||
NoCollisionList,
|
||||
/// The blockset could not be read: the seam has no cartridge behind it
|
||||
/// ([`MemoryReader::read_rom`] answered `None`), or the header's pointer runs off the image.
|
||||
NoBlockset,
|
||||
/// The screen is not showing the map — a battle, a text box, a frame mid-warp — so there is
|
||||
/// nothing to check the decode against, and `wOverworldMap` shares its bytes with the picture
|
||||
/// buffer (`ram/wram.asm`'s own union), which is exactly when it must not be trusted.
|
||||
NoScreen,
|
||||
/// The decode and the screen buffer disagree about a tile the window can answer for. A wrong
|
||||
/// stride, a wrong quadrant or a half-loaded map all land here, and all of them answer
|
||||
/// plausibly, which is why this check is not optional.
|
||||
ScreenDisagrees,
|
||||
}
|
||||
|
||||
impl GridRefusal {
|
||||
/// A short label for a log line and the probes.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
GridRefusal::NoHeader => "no map header",
|
||||
GridRefusal::NoPlayer => "no player",
|
||||
GridRefusal::NoCollisionList => "no collision list",
|
||||
GridRefusal::NoBlockset => "no blockset",
|
||||
GridRefusal::NoScreen => "map not on screen",
|
||||
GridRefusal::ScreenDisagrees => "screen disagrees",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole loaded map's walkability, decoded from the tables the cartridge has loaded.
|
||||
///
|
||||
/// `docs/design/macros.md` section 15. The rule is [`walkable`]'s rule — the tileset's collision
|
||||
/// list — and what this adds is the tile id of every tile of the map rather than of the ten-by-nine
|
||||
/// window:
|
||||
///
|
||||
/// - the map's **blocks** come from `wOverworldMap`, which `LoadTileBlockMap` fills from the map's
|
||||
/// own ROM bank as rows of `wCurMapWidth + MAP_BORDER * 2` bytes with the map itself three rows
|
||||
/// and three columns in. That is WRAM, so it needs no bank at all.
|
||||
/// - a block's **tiles** come from the tileset header's blockset, sixteen bytes per block id
|
||||
/// (`DrawTileBlock`). That is ROM, and not bank 0, so it is the one read that goes through
|
||||
/// [`MemoryReader::read_rom`] — the cartridge image as the process already holds it, because the
|
||||
/// alternative would be *writing* the mapper's bank register and the joypad is the only write
|
||||
/// this workspace makes into a running game.
|
||||
/// - the **tile-pair** refusals come from the values of `TilePairCollisionsLand`, keyed by
|
||||
/// `wCurMapTileset`, and become directed walls ([`mapgrid::TILE_PAIRS_LAND`]).
|
||||
///
|
||||
/// The last thing it does is check itself: the decoded tile ids are compared against
|
||||
/// [`map_tile_id`] for the player's own tile and its four neighbours, every one the window can
|
||||
/// answer for. A frame where the window can answer for none of them is refused
|
||||
/// ([`GridRefusal::NoScreen`]) rather than trusted, because `wOverworldMap` shares its bytes with
|
||||
/// the picture buffer and a battle is exactly when the blocks under it are somebody else's.
|
||||
pub fn map_grid(memory: &mut dyn MemoryReader) -> Result<MapGrid, GridRefusal> {
|
||||
let size = map_size(memory).ok_or(GridRefusal::NoHeader)?;
|
||||
let player = player(memory).ok_or(GridRefusal::NoPlayer)?;
|
||||
let passable = collision_list(memory).ok_or(GridRefusal::NoCollisionList)?;
|
||||
let width_blocks = read(memory, ram::wCurMapWidth);
|
||||
let height_blocks = read(memory, ram::wCurMapHeight);
|
||||
let stride = u16::from(width_blocks) + (mapgrid::MAP_BORDER as u16) * 2;
|
||||
let border = mapgrid::MAP_BORDER as u16;
|
||||
// The map plus its border has to fit in `wOverworldMap`, which every real map does. One that
|
||||
// does not is a header caught mid-load, and reading past the buffer would be reading somebody
|
||||
// else's WRAM.
|
||||
if usize::from(stride) * (usize::from(height_blocks) + mapgrid::MAP_BORDER * 2)
|
||||
> mapgrid::OVERWORLD_MAP_BYTES
|
||||
{
|
||||
return Err(GridRefusal::NoHeader);
|
||||
}
|
||||
let mut blocks = Vec::with_capacity(usize::from(width_blocks) * usize::from(height_blocks));
|
||||
for row in 0..u16::from(height_blocks) {
|
||||
for column in 0..u16::from(width_blocks) {
|
||||
blocks.push(read(memory, ram::wOverworldMap + (row + border) * stride + column + border));
|
||||
}
|
||||
}
|
||||
// Only as much of the blockset as this map's blocks index into: a tileset has up to 256 of
|
||||
// them and a room uses a dozen, and a read that stops at the highest block id used is a read
|
||||
// that cannot run off the end of a bank for tiles nothing asks about.
|
||||
let highest = blocks.iter().copied().max().unwrap_or(0);
|
||||
let bank = read(memory, ram::wTilesetBank);
|
||||
let base = u16::from(read(memory, ram::wTilesetBlocksPtr))
|
||||
+ u16::from(read(memory, ram::wTilesetBlocksPtr + 1)) * 256;
|
||||
let wanted = (usize::from(highest) + 1) * mapgrid::BLOCK_BYTES;
|
||||
let mut blockset = Vec::with_capacity(wanted);
|
||||
for offset in 0..wanted {
|
||||
let address = base.checked_add(u16::try_from(offset).map_err(|_| GridRefusal::NoBlockset)?);
|
||||
let byte = address
|
||||
.and_then(|address| memory.read_rom(bank, address))
|
||||
.ok_or(GridRefusal::NoBlockset)?;
|
||||
blockset.push(byte);
|
||||
}
|
||||
let tiles = mapgrid::Tileset { id: read(memory, ram::wCurMapTileset), blocks: blockset, passable };
|
||||
let grid = mapgrid::decode(player.map, width_blocks, height_blocks, &blocks, &tiles);
|
||||
if grid.width() != size.width || grid.height() != size.height {
|
||||
return Err(GridRefusal::NoHeader);
|
||||
}
|
||||
// The cross-check. `map_tile_id` reads the screen buffer at the offset
|
||||
// `_GetTileAndCoordsInFrontOfPlayer` uses, so agreeing with it on the tiles it can answer for
|
||||
// is agreeing with the cartridge's own reading of the same ground.
|
||||
let mut checked = 0;
|
||||
for (x, y) in neighbourhood(player.x, player.y) {
|
||||
let Some(screen) = map_tile_id(memory, x, y) else { continue };
|
||||
if grid.tile_id(x, y) != Some(screen) {
|
||||
return Err(GridRefusal::ScreenDisagrees);
|
||||
}
|
||||
checked += 1;
|
||||
}
|
||||
if checked == 0 {
|
||||
return Err(GridRefusal::NoScreen);
|
||||
}
|
||||
Ok(grid)
|
||||
}
|
||||
|
||||
/// Whether a cached grid is still the map that is loaded, checked from the tile the fly is on.
|
||||
///
|
||||
/// The map id, the map header and the block data are written by different parts of a warp, so
|
||||
/// there is a frame or two on the way through a door where `wCurMap` is the map the fly is
|
||||
/// arriving on and the header and the blocks are still the map it is leaving: the decode agrees
|
||||
/// with the screen (both are the old map) and is filed under the new id. Measured on the
|
||||
/// cartridge — the fly on Oak's lab doormat with `wCurMap` already reading `PALLET_TOWN` and the
|
||||
/// header still the lab's ten-by-twelve (`tests/rom_map_grid.rs`).
|
||||
///
|
||||
/// Nothing in WRAM says "the map has finished loading", so the cache asks the cheapest question
|
||||
/// that can tell: does the grid still agree with the screen about the tile the fly is standing on?
|
||||
/// One byte, once per question. A grid that does not is dropped and decoded again, so a torn
|
||||
/// frame's grid lives exactly as long as the tear does — and through it the cartridge is walking
|
||||
/// the fly, which is not a frame any macro plans on.
|
||||
fn still_the_loaded_map(
|
||||
memory: &mut dyn MemoryReader,
|
||||
grid: &MapGrid,
|
||||
x: u8,
|
||||
y: u8,
|
||||
) -> bool {
|
||||
match map_tile_id(memory, x, y) {
|
||||
// The screen is not showing the map (a battle, a text box): nothing to check against, and
|
||||
// the grid was checked when it was decoded.
|
||||
None => true,
|
||||
Some(tile) => grid.tile_id(x, y) == Some(tile),
|
||||
}
|
||||
}
|
||||
|
||||
/// The player's own tile and its four neighbours, which is every tile the window is certain to be
|
||||
/// able to answer for from where the fly is standing.
|
||||
fn neighbourhood(x: u8, y: u8) -> Vec<(u8, u8)> {
|
||||
let mut out = vec![(x, y)];
|
||||
for (dx, dy) in [(0i16, 1i16), (0, -1), (-1, 0), (1, 0)] {
|
||||
if let (Ok(nx), Ok(ny)) =
|
||||
(u8::try_from(i16::from(x) + dx), u8::try_from(i16::from(y) + dy))
|
||||
{
|
||||
out.push((nx, ny));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether the player could stand on this tile of the current map.
|
||||
///
|
||||
/// 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<std::sync::Arc<MapGrid>> {
|
||||
let player = player(self.memory)?;
|
||||
let size = map_size(self.memory)?;
|
||||
if let Some(grids) = self.grids.as_deref()
|
||||
&& let Some(grid) = grids.get(player.map, size.width, size.height)
|
||||
&& still_the_loaded_map(self.memory, &grid, player.x, player.y)
|
||||
{
|
||||
return Some(grid);
|
||||
}
|
||||
let grid = map_grid(self.memory).ok()?;
|
||||
match self.grids.as_deref_mut() {
|
||||
Some(grids) => Some(grids.store(grid)),
|
||||
None => Some(std::sync::Arc::new(grid)),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the open mart sells, in menu order (`docs/design/macros.md` section 13).
|
||||
///
|
||||
/// Gated on the mart scene being up, and that gate is the whole of the accuracy here:
|
||||
|
|
|
|||
|
|
@ -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<u8>, Vec<[u8; 16]>) {
|
||||
const FLOOR: u8 = 0x01;
|
||||
let blockset = vec![[FLOOR; 16], [WALL_TILE; 16]];
|
||||
let (wide, high) = (10usize, 9usize);
|
||||
let mut blocks = vec![0u8; wide * high];
|
||||
for row in 0..high {
|
||||
blocks[row * wide + 5] = 1;
|
||||
}
|
||||
let mut wram = Wram::new();
|
||||
wram.started()
|
||||
.map(fake_wram::PALLET_TOWN, wide as u8, high as u8, 3, 4)
|
||||
.facing(0)
|
||||
.house_collision()
|
||||
.tileset(0)
|
||||
.blockset(&blockset)
|
||||
.map_blocks(&blocks)
|
||||
.fill_screen(WALL_TILE)
|
||||
.screen_from_blocks(&blocks, &blockset);
|
||||
(wram, blocks, blockset)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_whole_map_decodes_from_the_block_and_collision_tables() {
|
||||
let (mut wram, _, _) = town();
|
||||
let grid = map_grid(&mut wram).expect("a decodable map");
|
||||
assert_eq!((grid.width(), grid.height()), (20, 18));
|
||||
assert_eq!(grid.unknown_count(), 0);
|
||||
// The far corner of the map, which the window predicate cannot answer for at all: the grid
|
||||
// does, and that is the whole of section 15.
|
||||
assert_eq!(walkable(&mut wram, 19, 17), Walkable::Unknown);
|
||||
assert_eq!(grid.walkable(19, 17), Walkable::Yes);
|
||||
// The wall column, again outside the window.
|
||||
assert_eq!(grid.walkable(10, 17), Walkable::No);
|
||||
assert_eq!(grid.walkable(11, 17), Walkable::No);
|
||||
// Inside the window the two readings agree tile for tile, which is what the reader checks
|
||||
// itself with before it trusts a decode.
|
||||
for y in 0..18u8 {
|
||||
for x in 0..20u8 {
|
||||
if let Some(tile) = map_tile_id(&mut wram, x, y) {
|
||||
assert_eq!(grid.tile_id(x, y), Some(tile), "({x}, {y})");
|
||||
assert_eq!(grid.walkable(x, y), walkable(&mut wram, x, y), "({x}, {y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_decode_the_screen_disagrees_with_is_refused() {
|
||||
let (mut wram, _, _) = town();
|
||||
// One screen tile the block data does not account for: a wrong stride, a wrong quadrant or a
|
||||
// half-loaded map all look like this, and all of them answer plausibly.
|
||||
wram.map_tile(3, 4, 0x77);
|
||||
assert_eq!(map_grid(&mut wram), Err(GridRefusal::ScreenDisagrees));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_cartridge_behind_the_seam_there_is_no_grid() {
|
||||
let (mut wram, blocks, _) = town();
|
||||
// The same WRAM with no blockset in any bank: `read_rom` answers `None`, which is what every
|
||||
// reader that is not the emulator answers, and the grid narrows to nothing rather than
|
||||
// decoding the map out of whatever bytes were to hand.
|
||||
let mut bare = Wram::new();
|
||||
bare.started()
|
||||
.map(fake_wram::PALLET_TOWN, 10, 9, 3, 4)
|
||||
.facing(0)
|
||||
.house_collision()
|
||||
.map_blocks(&blocks)
|
||||
.fill_screen(WALL_TILE);
|
||||
assert_eq!(map_grid(&mut bare), Err(GridRefusal::NoBlockset));
|
||||
// And the window predicate still answers, which is the fallback the whole thing rests on.
|
||||
assert_eq!(walkable(&mut wram, 3, 4), Walkable::Yes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_that_is_not_showing_the_map_has_no_grid_to_check() {
|
||||
let (mut wram, _, _) = town();
|
||||
// `wOverworldMap` shares its bytes with the picture buffer (`ram/wram.asm`'s own union), so a
|
||||
// battle is exactly when the blocks under it belong to somebody else. Nothing can be
|
||||
// cross-checked then, and a decode nothing can check is refused.
|
||||
wram.battle(1);
|
||||
assert_eq!(map_grid(&mut wram), Err(GridRefusal::NoScreen));
|
||||
|
||||
let (mut wram, _, _) = town();
|
||||
wram.dialogue_box();
|
||||
assert_eq!(map_grid(&mut wram), Err(GridRefusal::NoScreen));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_with_no_map_header_has_no_grid() {
|
||||
let mut wram = Wram::new();
|
||||
wram.started();
|
||||
assert_eq!(map_grid(&mut wram), Err(GridRefusal::NoHeader));
|
||||
assert_eq!(GridRefusal::NoHeader.label(), "no map header");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grid_is_decoded_once_per_map_and_dropped_on_arrival_somewhere_else() {
|
||||
let (mut wram, blocks, blockset) = town();
|
||||
let mut grids = MapGrids::default();
|
||||
{
|
||||
let mut state = PokeState::new(&mut wram).caching_grid(&mut grids);
|
||||
let first = state.map_grid().expect("a decodable map");
|
||||
let again = state.map_grid().expect("the cached map");
|
||||
assert!(std::sync::Arc::ptr_eq(&first, &again), "the second question is the same grid");
|
||||
}
|
||||
assert_eq!(grids.held(), Some(fake_wram::PALLET_TOWN));
|
||||
|
||||
// Walking through a door: another map id, so the cache is somebody else's and is dropped.
|
||||
wram.map(fake_wram::OAKS_LAB, 10, 9, 3, 4)
|
||||
.map_blocks(&blocks)
|
||||
.screen_from_blocks(&blocks, &blockset);
|
||||
{
|
||||
let mut state = PokeState::new(&mut wram).caching_grid(&mut grids);
|
||||
let grid = state.map_grid().expect("a decodable map");
|
||||
assert_eq!(grid.map(), fake_wram::OAKS_LAB);
|
||||
}
|
||||
assert_eq!(grids.held(), Some(fake_wram::OAKS_LAB));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_state_with_no_cache_still_answers_and_a_state_with_no_cartridge_answers_none() {
|
||||
let (mut wram, _, _) = town();
|
||||
let mut state = PokeState::new(&mut wram);
|
||||
assert!(state.map_grid().is_some(), "no cache is slower, not blinder");
|
||||
|
||||
let mut bare = Wram::overworld();
|
||||
let mut state = PokeState::new(&mut bare);
|
||||
assert!(state.map_grid().is_none(), "no blockset, no grid");
|
||||
// Which is the frame the window predicate is for.
|
||||
assert_eq!(state.walkable(3, 6), Walkable::No);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<String> = (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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<flybrain_gb::pokemon_red::macros::state::Player>,
|
||||
refusal: Option<flybrain_gb::pokemon_red::state::GridRefusal>,
|
||||
) -> String {
|
||||
let Some(player) = player else { return "grid: no player".to_string() };
|
||||
let Some(grid) = state.map_grid() else {
|
||||
// Which of section 15's refusals this frame is, rather than a bare "none": a walk that is
|
||||
// on the window reading should say why it is.
|
||||
return format!("grid: none ({})", refusal.map_or("unknown", |refusal| refusal.label()));
|
||||
};
|
||||
let unstood = grid
|
||||
.walkable_tiles()
|
||||
.into_iter()
|
||||
.filter(|(x, y)| !state.tile_visited(*x, *y))
|
||||
.count();
|
||||
format!(
|
||||
"grid map={:#04x} {}x{} walkable={} reachable={} unstood={}",
|
||||
grid.map(),
|
||||
grid.width(),
|
||||
grid.height(),
|
||||
grid.walkable_count(),
|
||||
grid.reachable_from(player.x, player.y),
|
||||
unstood
|
||||
)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
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} |");
|
||||
|
|
|
|||
733
services/flysim/crates/flysim/tests/rom_map_grid.rs
Normal file
733
services/flysim/crates/flysim/tests/rom_map_grid.rs
Normal file
|
|
@ -0,0 +1,733 @@
|
|||
//! The whole-map walkability grid against the cartridge (`docs/design/macros.md` section 15).
|
||||
//!
|
||||
//! Gated on `FLY_ROM` and on a `FLYSIM01` checkpoint, and skips cleanly without either. The
|
||||
//! checkpoints live outside the tree (`.local/` is not tracked) and are the release container's
|
||||
//! own states, pulled read-only:
|
||||
//!
|
||||
//! ```sh
|
||||
//! FLY_ROM="$HOME/…/Pokemon Red (U) [S][BF].gb" \
|
||||
//! FLY_GRID_CHECKPOINT=.local/checkpoints/rank9-viridian-forest.checkpoint \
|
||||
//! cargo test --release -p flysim --test rom_map_grid -- --nocapture
|
||||
//! ```
|
||||
//!
|
||||
//! ## What only the cartridge can answer
|
||||
//!
|
||||
//! The unit tests in `pokemon_red/mapgrid/tests.rs` decode a made-up tileset, and the ones in
|
||||
//! `pokemon_red/state/tests.rs` decode synthetic WRAM with a synthetic blockset in a synthetic
|
||||
//! bank. Neither can say that those are the bytes the *game* writes: a wrong border, a wrong
|
||||
//! stride or the wrong corner of a block all still answer, and answer plausibly. Three things here
|
||||
//! need a running cartridge:
|
||||
//!
|
||||
//! 1. **the decode against the window predicate**, on every tile of the map the ten-by-nine
|
||||
//! screen window can answer for — the same comparison the reader makes before it trusts
|
||||
//! itself, done over the whole window rather than the player's own neighbourhood;
|
||||
//! 2. **the decode against real presses** (the survey method of `docs/design/room-escape.md`
|
||||
//! section 3): every tile the survey can stand on is `Yes`, and every step it refuses is `No`,
|
||||
//! a directed wall, or a tile a sprite is standing on;
|
||||
//! 3. **the walks**: that `GO FRONTIER` aims at ground outside the window and reaches it, and that
|
||||
//! a way out of the map is one plan away rather than a re-plan at every window edge.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use flybrain_gb::macros::{MacroPalette, Started};
|
||||
use flybrain_gb::pokemon_red::macros::cartridge::{MacroState, Tile};
|
||||
use flybrain_gb::pokemon_red::macros::palette::Palette;
|
||||
use flybrain_gb::pokemon_red::macros::state::{Facing, MapGrid, Walkable};
|
||||
use flybrain_gb::pokemon_red::macros::{PokemonPalette, path};
|
||||
use flybrain_gb::pokemon_red::symbols::ram;
|
||||
use flybrain_gb::pokemon_red::{PokemonRedReward, scene, state};
|
||||
use flybrain_gb::{
|
||||
AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, buttons,
|
||||
};
|
||||
|
||||
/// One game frame at the Game Boy's real rate, for the adapter's brain clock.
|
||||
const MS_PER_FRAME: f64 = 1000.0 / 59.7275;
|
||||
|
||||
/// Frames a restored state is given before anything reads the screen buffer.
|
||||
///
|
||||
/// `wTileMap` is the current view only on a *running* machine: read straight after
|
||||
/// `import_state`, with no frame in between, it holds the view from wherever the state was taken
|
||||
/// (`docs/design/macros-wram.md`). Twenty is what `tests/rom_scene.rs` gives every restored state
|
||||
/// and what the survey below gives every one of its own.
|
||||
const SETTLE_FRAMES: u32 = 20;
|
||||
|
||||
/// The seed the macro tests run the executor with. Nothing here depends on it: the slot is chosen
|
||||
/// by name, not by a readout.
|
||||
const SEED: u32 = 20_260_922;
|
||||
|
||||
fn rom() -> Option<Vec<u8>> {
|
||||
let path = std::env::var_os("FLY_ROM")?;
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => Some(bytes),
|
||||
Err(error) => panic!("FLY_ROM is set to {path:?} but could not be read: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn checkpoint() -> Option<flysim::store::Checkpoint> {
|
||||
let path = std::env::var_os("FLY_GRID_CHECKPOINT")
|
||||
.or_else(|| std::env::var_os("FLY_TRAP_CHECKPOINT"))
|
||||
.or_else(|| std::env::var_os("FLY_MACRO_CHECKPOINT"))?;
|
||||
Some(
|
||||
flysim::store::load(std::path::Path::new(&path))
|
||||
.expect("the checkpoint should be a FLYSIM01 envelope"),
|
||||
)
|
||||
}
|
||||
|
||||
macro_rules! skip_without {
|
||||
() => {
|
||||
match (rom(), checkpoint()) {
|
||||
(Some(rom), Some(checkpoint)) => (rom, checkpoint),
|
||||
(None, _) => {
|
||||
eprintln!("skipped: FLY_ROM is not set");
|
||||
return;
|
||||
}
|
||||
(_, None) => {
|
||||
eprintln!("skipped: no FLY_GRID_CHECKPOINT / FLY_TRAP_CHECKPOINT");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn emulator(rom: &[u8]) -> Emulator {
|
||||
Emulator::new(rom, DEFAULT_AUDIO_FREQUENCY, DEFAULT_AUDIO_FRAMES)
|
||||
.expect("binjgb should accept the cartridge")
|
||||
}
|
||||
|
||||
/// A cartridge resumed from a checkpoint, with the reward ledger the checkpoint carried and the
|
||||
/// macro palette the sim loop runs.
|
||||
struct Game {
|
||||
gb: Emulator,
|
||||
adapter: PokemonRedReward,
|
||||
palette: PokemonPalette,
|
||||
ms: f64,
|
||||
}
|
||||
|
||||
impl Game {
|
||||
fn resume(rom: &[u8], checkpoint: &flysim::store::Checkpoint) -> Self {
|
||||
let mut gb = emulator(rom);
|
||||
let mut adapter = PokemonRedReward::new();
|
||||
gb.import_state(&checkpoint.runtime.emulator).expect("the checkpoint's emulator state");
|
||||
adapter.import_state(&checkpoint.runtime.reward).expect("the checkpoint's reward ledger");
|
||||
let mut game =
|
||||
Self { gb, adapter, palette: PokemonPalette::new(SEED), ms: 0.0 };
|
||||
game.settle_overworld();
|
||||
if let Some(target) = std::env::var("FLY_GRID_TO_MAP")
|
||||
.ok()
|
||||
.and_then(|value| u8::from_str_radix(value.trim_start_matches("0x"), 16).ok())
|
||||
{
|
||||
game.reach_map(target);
|
||||
}
|
||||
game
|
||||
}
|
||||
|
||||
/// Drive the cartridge with raw presses until the fly is standing on `target`.
|
||||
///
|
||||
/// The test's knowledge of the game, not the fly's, and the same device
|
||||
/// `tests/rom_scene.rs`'s biased walk is: it decides which state gets *produced* and nothing
|
||||
/// it does is asserted. What it is for is the second map of the survey
|
||||
/// (`docs/design/macros.md` section 15 asks for two): no checkpoint in `.local/` is standing
|
||||
/// on Pallet Town, and Oak's lab is one door south of it.
|
||||
///
|
||||
/// A biased random walk rather than the macros, deliberately: driving the state under test
|
||||
/// into place with the macros under test is circular, and a walk needs no map knowledge. The
|
||||
/// cycle is `tests/rom_scene.rs`'s — a direction, then B, which closes whatever a stray press
|
||||
/// opened.
|
||||
fn reach_map(&mut self, target: u8) {
|
||||
let mut seed = u64::from(SEED);
|
||||
let toward = if target < self.map() { buttons::DOWN } else { buttons::UP };
|
||||
let mut arrived: Option<Tile> = None;
|
||||
let mut walked = 0;
|
||||
for frame in 0..90_000u32 {
|
||||
if self.map() == target && walked > 0 {
|
||||
eprintln!(
|
||||
"reached map {target:#04x} after {frame} frames, {walked} tiles into it, at \
|
||||
{:?}",
|
||||
self.tile()
|
||||
);
|
||||
self.settle_overworld();
|
||||
return;
|
||||
}
|
||||
// A few tiles *into* the map, not the doormat: a warp writes the map id before the
|
||||
// header and the blocks, so the arrival frame itself is the torn one
|
||||
// (`pokemon_red::state::still_the_loaded_map`).
|
||||
if self.map() == target && self.tile() != arrived.unwrap_or(Tile::new(255, 255)) {
|
||||
match arrived {
|
||||
None => arrived = Some(self.tile()),
|
||||
Some(_) => walked += 1,
|
||||
}
|
||||
}
|
||||
seed = seed
|
||||
.wrapping_mul(6_364_136_223_846_793_005)
|
||||
.wrapping_add(1_442_695_040_888_963_407);
|
||||
let random = [buttons::UP, buttons::DOWN, buttons::LEFT, buttons::RIGHT]
|
||||
[((seed >> 33) % 4) as usize];
|
||||
// Half biased, half random: the random half is what gets the walk around the
|
||||
// furniture the bias walks it into.
|
||||
let step = if (seed >> 41).is_multiple_of(2) { toward } else { random };
|
||||
let mask = match frame % 48 {
|
||||
0..=7 | 24..=31 => step,
|
||||
12..=15 => buttons::B,
|
||||
_ => buttons::NONE,
|
||||
};
|
||||
self.frame(mask);
|
||||
}
|
||||
assert_eq!(self.map(), target, "FLY_GRID_TO_MAP: never reached map {target:#04x}");
|
||||
}
|
||||
|
||||
/// Settle a restored state until the cartridge is showing the overworld, pressing nothing.
|
||||
///
|
||||
/// Two reasons, both measured. `wTileMap` is the current view only on a *running* machine, so
|
||||
/// every restored state needs frames before anything reads the screen
|
||||
/// (`docs/design/macros-wram.md`); and a checkpoint is a frame of a live run, which can be
|
||||
/// mid-step, mid-warp or inside a script that is walking the fly — Viridian City's own
|
||||
/// checkpoint reads `Scene::Unknown` and its screen buffer is a tile out of step with its
|
||||
/// coordinates, which is exactly the frame [`state::map_grid`] refuses rather than decodes.
|
||||
/// A grid is a question about the overworld, so the test asks it there.
|
||||
fn settle_overworld(&mut self) {
|
||||
for _ in 0..1_200 {
|
||||
self.frame(buttons::NONE);
|
||||
if matches!(scene::detect(&mut self.gb), scene::Scene::Overworld) {
|
||||
self.settle(SETTLE_FRAMES);
|
||||
return;
|
||||
}
|
||||
}
|
||||
eprintln!("the restored state never settled into the overworld");
|
||||
}
|
||||
|
||||
/// Run frames with nothing held, sampling the adapter and observing the palette as the sim
|
||||
/// loop does.
|
||||
fn settle(&mut self, frames: u32) {
|
||||
for _ in 0..frames {
|
||||
self.frame(buttons::NONE);
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame with these buttons held, then the adapter's sample and the palette's observation
|
||||
/// — the sim loop's order.
|
||||
fn frame(&mut self, mask: u8) {
|
||||
self.gb.set_buttons(mask);
|
||||
self.gb.run_frame().expect("a frame should complete");
|
||||
self.ms += MS_PER_FRAME;
|
||||
self.adapter.sample(&mut self.gb, self.ms);
|
||||
let ledger = AdapterLedger(&self.adapter);
|
||||
self.palette.clock(self.ms);
|
||||
let _ = self.palette.observe(&mut self.gb, &ledger);
|
||||
}
|
||||
|
||||
fn map(&mut self) -> u8 {
|
||||
self.gb.read_wram(ram::wCurMap)
|
||||
}
|
||||
|
||||
fn tile(&mut self) -> Tile {
|
||||
Tile::new(self.gb.read_wram(ram::wXCoord), self.gb.read_wram(ram::wYCoord))
|
||||
}
|
||||
|
||||
/// The decoded grid, or the reason there is none.
|
||||
fn grid(&mut self) -> Result<MapGrid, state::GridRefusal> {
|
||||
state::map_grid(&mut self.gb)
|
||||
}
|
||||
|
||||
/// Run `body` with the state the macros read, over this game's ledgers.
|
||||
fn with_state<T>(&mut self, body: impl FnOnce(&mut dyn MacroState) -> T) -> T {
|
||||
let ledger = AdapterLedger(&self.adapter);
|
||||
let mut poke = state::PokeState::with_ledger(&mut self.gb, &ledger);
|
||||
body(&mut poke)
|
||||
}
|
||||
|
||||
/// The slot this scene binds `name` to, or `None` when the button is not on the pad.
|
||||
fn slot(&mut self, name: &str) -> Option<u8> {
|
||||
self.with_state(|state| {
|
||||
let scene = state.scene();
|
||||
let palette = Palette::for_scene(scene, state);
|
||||
palette
|
||||
.slots
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, spec)| spec.is_some_and(|spec| spec.name == name))
|
||||
.map(|(slot, _)| slot as u8)
|
||||
})
|
||||
}
|
||||
|
||||
/// Start `name` and run it to its outcome, returning the outcome's label and the frames spent.
|
||||
///
|
||||
/// The slot is chosen by name rather than by a readout, which is what makes this a test of the
|
||||
/// macro instead of a test of the decoder: `docs/design/macros.md` section 12 has the fly
|
||||
/// choosing, and what is under test here is what the chosen walk does.
|
||||
fn run_macro(&mut self, name: &str) -> Option<(String, u32)> {
|
||||
let slot = self.slot(name)?;
|
||||
let ledger = AdapterLedger(&self.adapter);
|
||||
match self.palette.start(slot, &mut self.gb, &ledger) {
|
||||
Started::Refused { reason, .. } => Some((format!("refused: {reason}"), 0)),
|
||||
Started::Running(_) => {
|
||||
let mut frames = 0;
|
||||
loop {
|
||||
let ledger = AdapterLedger(&self.adapter);
|
||||
let mask = self.palette.step(&mut self.gb, &ledger);
|
||||
match mask {
|
||||
None => break,
|
||||
Some(mask) => {
|
||||
self.frame(mask);
|
||||
frames += 1;
|
||||
}
|
||||
}
|
||||
if frames > 4_000 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let outcome = self
|
||||
.palette
|
||||
.take_finished()
|
||||
.map(|(_, outcome)| format!("{outcome:?}"))
|
||||
.unwrap_or_else(|| "none".to_string());
|
||||
Some((outcome, frames))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a tile is inside the ten-by-nine window the screen buffer can answer for.
|
||||
fn in_window(player: Tile, x: u8, y: u8) -> bool {
|
||||
let dx = i32::from(x) - i32::from(player.x);
|
||||
let dy = i32::from(y) - i32::from(player.y);
|
||||
(-4..=5).contains(&dx) && (-4..=4).contains(&dy)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_decoded_grid_agrees_with_the_window_on_every_tile_the_window_can_answer() {
|
||||
let (rom, checkpoint) = skip_without!();
|
||||
let mut game = Game::resume(&rom, &checkpoint);
|
||||
let map = game.map();
|
||||
let here = game.tile();
|
||||
let grid = match game.grid() {
|
||||
Ok(grid) => grid,
|
||||
Err(refusal) => {
|
||||
eprintln!("skipped: no grid on map {map:#04x} ({})", refusal.label());
|
||||
return;
|
||||
}
|
||||
};
|
||||
eprintln!(
|
||||
"map {map:#04x} {}x{} at {here:?}: walkable {}, reachable {}, unknown {}",
|
||||
grid.width(),
|
||||
grid.height(),
|
||||
grid.walkable_count(),
|
||||
grid.reachable_from(here.x, here.y),
|
||||
grid.unknown_count()
|
||||
);
|
||||
assert_eq!(grid.map(), map);
|
||||
assert_eq!(grid.unknown_count(), 0, "every block of the map is in the blockset");
|
||||
|
||||
let mut checked = 0;
|
||||
let mut disagreements = Vec::new();
|
||||
for y in 0..grid.height() {
|
||||
for x in 0..grid.width() {
|
||||
let Some(tile) = state::map_tile_id(&mut game.gb, x, y) else { continue };
|
||||
let window = state::walkable(&mut game.gb, x, y);
|
||||
checked += 1;
|
||||
if grid.tile_id(x, y) != Some(tile) || grid.walkable(x, y) != window {
|
||||
disagreements.push((x, y, tile, grid.tile_id(x, y), window, grid.walkable(x, y)));
|
||||
}
|
||||
assert!(in_window(here, x, y), "the window answered outside its own window");
|
||||
}
|
||||
}
|
||||
eprintln!("{checked} tiles the window can answer for, {} disagreements", disagreements.len());
|
||||
assert!(disagreements.is_empty(), "{disagreements:?}");
|
||||
assert!(checked >= 40, "the window should answer for most of its ninety tiles: {checked}");
|
||||
}
|
||||
|
||||
/// The survey: walk the map with real presses on throwaway emulators, tile by tile.
|
||||
///
|
||||
/// `docs/design/room-escape.md` section 3's method, and the one `tests/rom_scene.rs` used to pin
|
||||
/// the window predicate's screen origin. Each tile keeps the state the walk arrived on, so every
|
||||
/// press is made from the tile it belongs to rather than from wherever a long walk ended up.
|
||||
///
|
||||
/// Bounded by `FLY_GRID_SURVEY_TILES` (default [`SURVEY_TILES`]) because a whole forest is
|
||||
/// 700-odd tiles and four presses each, and the claim is about the *rule*, not about coverage: a
|
||||
/// wrong corner, a wrong stride or a wrong border disagrees within a dozen tiles.
|
||||
type Survey = (
|
||||
BTreeMap<Tile, Vec<u8>>,
|
||||
BTreeSet<(Tile, &'static str)>,
|
||||
BTreeSet<(Tile, &'static str)>,
|
||||
BTreeSet<(Tile, &'static str)>,
|
||||
BTreeSet<(Tile, &'static str)>,
|
||||
);
|
||||
|
||||
/// Tiles the survey stands on before it stops, unless `FLY_GRID_SURVEY_TILES` says otherwise.
|
||||
const SURVEY_TILES: usize = 120;
|
||||
|
||||
/// The four presses, with the direction each one is and the tile offset it aims at.
|
||||
const PRESSES: [(&str, u8, Facing); 4] = [
|
||||
("up", buttons::UP, Facing::Up),
|
||||
("down", buttons::DOWN, Facing::Down),
|
||||
("left", buttons::LEFT, Facing::Left),
|
||||
("right", buttons::RIGHT, Facing::Right),
|
||||
];
|
||||
|
||||
fn survey(rom: &[u8], start: &[u8], budget: usize) -> Survey {
|
||||
let read = |probe: &mut Emulator| {
|
||||
(
|
||||
probe.read_wram(ram::wCurMap),
|
||||
Tile::new(probe.read_wram(ram::wXCoord), probe.read_wram(ram::wYCoord)),
|
||||
)
|
||||
};
|
||||
// 120 frames of held direction, then a release and twenty frames to settle, which are the two
|
||||
// numbers `docs/design/macros-wram.md` measured: a press the player is not already facing
|
||||
// turns first and steps second (53 frames from one staircase), and a state exported with a
|
||||
// button held does not respond to that button after the import.
|
||||
let step = |probe: &mut Emulator, mask: u8| {
|
||||
let before = read(probe);
|
||||
probe.set_buttons(mask);
|
||||
let mut moved = false;
|
||||
for _ in 0..120 {
|
||||
probe.run_frame().expect("a frame should complete");
|
||||
if read(probe) != before {
|
||||
moved = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
probe.set_buttons(buttons::NONE);
|
||||
for _ in 0..SETTLE_FRAMES {
|
||||
probe.run_frame().expect("a frame should complete");
|
||||
}
|
||||
(read(probe), moved)
|
||||
};
|
||||
let restore = |state: &[u8]| {
|
||||
let mut probe = emulator(rom);
|
||||
probe.import_state(state).expect("a surveyed state should import");
|
||||
probe
|
||||
};
|
||||
|
||||
let mut first = restore(start);
|
||||
for _ in 0..SETTLE_FRAMES {
|
||||
first.run_frame().expect("a frame should complete");
|
||||
}
|
||||
let (map, here) = read(&mut first);
|
||||
let mut states: BTreeMap<Tile, Vec<u8>> = BTreeMap::new();
|
||||
let mut refused: BTreeSet<(Tile, &'static str)> = BTreeSet::new();
|
||||
let mut left: BTreeSet<(Tile, &'static str)> = BTreeSet::new();
|
||||
let mut interrupted: BTreeSet<(Tile, &'static str)> = BTreeSet::new();
|
||||
let mut blocked_by_sprite: BTreeSet<(Tile, &'static str)> = BTreeSet::new();
|
||||
states.insert(here, first.export_state().expect("a settled state should export"));
|
||||
let mut queue = std::collections::VecDeque::from([here]);
|
||||
while let Some(tile) = queue.pop_front() {
|
||||
if states.len() >= budget {
|
||||
break;
|
||||
}
|
||||
for (name, mask, _) in PRESSES {
|
||||
let mut probe = restore(&states[&tile]);
|
||||
// A press the cartridge answers with something other than a step -- a wild encounter
|
||||
// in the grass, a bug catcher's line of sight, a script that takes the joypad -- says
|
||||
// nothing about the ground either way, so the survey records it as its own category
|
||||
// rather than as a wall. Viridian Forest is full of them: three tiles of the first
|
||||
// hundred and twenty started a battle in three directions each.
|
||||
if !matches!(scene::detect(&mut probe), scene::Scene::Overworld) {
|
||||
interrupted.insert((tile, name));
|
||||
continue;
|
||||
}
|
||||
let ((there, at), moved) = step(&mut probe, mask);
|
||||
if !matches!(scene::detect(&mut probe), scene::Scene::Overworld) {
|
||||
interrupted.insert((tile, name));
|
||||
continue;
|
||||
}
|
||||
if !moved {
|
||||
// **A person in the way is not a wall.** The collision table knows nothing about
|
||||
// sprites and says so (`docs/design/macros-wram.md`), and Pallet Town's two
|
||||
// villagers walk: whether one was standing on the target tile has to be read from
|
||||
// the frame the press was made in, not from the state the survey started in.
|
||||
let facing = PRESSES
|
||||
.iter()
|
||||
.find(|(press, _, _)| *press == name)
|
||||
.map(|(_, _, facing)| *facing)
|
||||
.expect("a known press");
|
||||
let ahead = tile.step(facing);
|
||||
let sprite = ahead.is_some_and(|ahead| {
|
||||
state::npcs(&mut probe).iter().any(|npc| (npc.x, npc.y) == (ahead.x, ahead.y))
|
||||
});
|
||||
if sprite {
|
||||
blocked_by_sprite.insert((tile, name));
|
||||
} else {
|
||||
refused.insert((tile, name));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if there != map {
|
||||
// A warp: the press left the map, so it says nothing about the ground here.
|
||||
left.insert((tile, name));
|
||||
continue;
|
||||
}
|
||||
if let std::collections::btree_map::Entry::Vacant(entry) = states.entry(at) {
|
||||
entry.insert(probe.export_state().expect("state export"));
|
||||
queue.push_back(at);
|
||||
}
|
||||
}
|
||||
}
|
||||
(states, refused, left, interrupted, blocked_by_sprite)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_decoded_grid_matches_a_survey_with_real_presses() {
|
||||
let (rom, checkpoint) = skip_without!();
|
||||
let mut game = Game::resume(&rom, &checkpoint);
|
||||
let map = game.map();
|
||||
let grid = match game.grid() {
|
||||
Ok(grid) => grid,
|
||||
Err(refusal) => {
|
||||
eprintln!("skipped: no grid on map {map:#04x} ({})", refusal.label());
|
||||
return;
|
||||
}
|
||||
};
|
||||
let npcs: BTreeSet<Tile> = game
|
||||
.with_state(|state| state.npcs().iter().map(|npc| Tile::new(npc.x, npc.y)).collect());
|
||||
let budget = std::env::var("FLY_GRID_SURVEY_TILES")
|
||||
.ok()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(SURVEY_TILES);
|
||||
let start = game.gb.export_state().expect("the settled state should export");
|
||||
let (stood, refused, left, interrupted, sprites) = survey(&rom, &start, budget);
|
||||
eprintln!(
|
||||
"survey of map {map:#04x}: {} tiles stood on, {} refused presses, {} presses that left \
|
||||
the map, {} the cartridge answered with a battle or a script, {} a sprite was standing \
|
||||
in the way of",
|
||||
stood.len(),
|
||||
refused.len(),
|
||||
left.len(),
|
||||
interrupted.len(),
|
||||
sprites.len()
|
||||
);
|
||||
assert!(stood.len() > 8, "the survey barely moved: {} tiles", stood.len());
|
||||
|
||||
// Every tile the survey stood on is ground the grid calls walkable. This is the half that
|
||||
// catches a decode that is too *strict* — a wall where the game lets the fly stand.
|
||||
let mut wrong_walls = Vec::new();
|
||||
for tile in stood.keys() {
|
||||
if grid.walkable(tile.x, tile.y) != Walkable::Yes {
|
||||
wrong_walls.push((*tile, grid.walkable(tile.x, tile.y), grid.tile_id(tile.x, tile.y)));
|
||||
}
|
||||
}
|
||||
assert!(wrong_walls.is_empty(), "tiles the survey stood on that the grid calls walls: {wrong_walls:?}");
|
||||
|
||||
// Every press the cartridge refused is a wall in the grid, a directed wall out of that tile,
|
||||
// or a tile a sprite is standing on — the three things a refusal can be
|
||||
// (`docs/design/macros.md` section 15). This is the half that catches a decode that is too
|
||||
// *permissive*.
|
||||
let mut unexplained = Vec::new();
|
||||
for (tile, name) in &refused {
|
||||
let (_, _, facing) = PRESSES.iter().find(|(press, _, _)| press == name).copied().unwrap();
|
||||
let ahead = tile.step(facing);
|
||||
let explained = match ahead {
|
||||
None => true,
|
||||
Some(ahead) => {
|
||||
ahead.x >= grid.width()
|
||||
|| ahead.y >= grid.height()
|
||||
|| grid.walkable(ahead.x, ahead.y) != Walkable::Yes
|
||||
|| grid.walled(tile.x, tile.y, facing)
|
||||
|| npcs.contains(&ahead)
|
||||
}
|
||||
};
|
||||
if !explained {
|
||||
unexplained.push((
|
||||
*tile,
|
||||
grid.tile_id(tile.x, tile.y),
|
||||
*name,
|
||||
ahead,
|
||||
ahead.map(|at| grid.tile_id(at.x, at.y)),
|
||||
grid.walled(tile.x, tile.y, facing),
|
||||
));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
unexplained.is_empty(),
|
||||
"presses the cartridge refused that the grid calls open ground: {unexplained:?}"
|
||||
);
|
||||
|
||||
// And every step that *worked* is one the grid would have planned: walkable, not walled.
|
||||
let mut wrongly_walled = Vec::new();
|
||||
for tile in stood.keys() {
|
||||
for (name, _, facing) in PRESSES {
|
||||
if refused.contains(&(*tile, name))
|
||||
|| left.contains(&(*tile, name))
|
||||
|| interrupted.contains(&(*tile, name))
|
||||
|| sprites.contains(&(*tile, name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(ahead) = tile.step(facing) else { continue };
|
||||
if !stood.contains_key(&ahead) {
|
||||
continue;
|
||||
}
|
||||
if grid.walkable(ahead.x, ahead.y) != Walkable::Yes
|
||||
|| grid.walled(tile.x, tile.y, facing)
|
||||
{
|
||||
wrongly_walled.push((*tile, name, ahead));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
wrongly_walled.is_empty(),
|
||||
"steps the cartridge made that the grid calls walls: {wrongly_walled:?}"
|
||||
);
|
||||
eprintln!(
|
||||
"the grid agrees with every one of {} stood tiles and {} refused presses on map {map:#04x}",
|
||||
stood.len(),
|
||||
refused.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_frontier_aims_outside_the_window_and_walks_there() {
|
||||
let (rom, checkpoint) = skip_without!();
|
||||
let mut game = Game::resume(&rom, &checkpoint);
|
||||
let map = game.map();
|
||||
let Ok(grid) = game.grid() else {
|
||||
eprintln!("skipped: no grid on map {map:#04x}");
|
||||
return;
|
||||
};
|
||||
|
||||
// **The plan.** Every frontier tile the ten-by-nine window cannot answer for is ground the
|
||||
// fly could not have aimed at before section 15, and a route to the nearest of them is a
|
||||
// plan that crosses the window with no guesses in it.
|
||||
let (here, outside, plan) = game.with_state(|state| {
|
||||
let here = state.player().map(|player| Tile::new(player.x, player.y)).expect("a player");
|
||||
let outside: Vec<Tile> = path::frontier(state)
|
||||
.into_iter()
|
||||
.map(|(tile, _)| tile)
|
||||
.filter(|tile| !in_window(here, tile.x, tile.y))
|
||||
.collect();
|
||||
let plan = path::route(state, &outside);
|
||||
(here, outside, plan)
|
||||
});
|
||||
eprintln!(
|
||||
"map {map:#04x} at {here:?}: {} frontier tiles outside the window, plan {:?} steps",
|
||||
outside.len(),
|
||||
plan.as_ref().map(|route| route.steps.len())
|
||||
);
|
||||
if outside.is_empty() {
|
||||
eprintln!("skipped: every frontier tile on this map is inside the window");
|
||||
return;
|
||||
}
|
||||
let plan = plan.expect("a route to the frontier beyond the window");
|
||||
assert!(plan.goal.is_some(), "the plan only approaches the frontier");
|
||||
let mut at = here;
|
||||
for facing in &plan.steps {
|
||||
at = at.step(*facing).expect("a step inside the map");
|
||||
assert_eq!(
|
||||
grid.walkable(at.x, at.y),
|
||||
Walkable::Yes,
|
||||
"the plan walks through {at:?}, which the grid does not call ground"
|
||||
);
|
||||
}
|
||||
assert!(outside.contains(&at), "the plan ends on {at:?}, which is not one of its goals");
|
||||
assert!(
|
||||
!in_window(here, at.x, at.y),
|
||||
"the plan ends on {at:?}, which the window could have answered for anyway"
|
||||
);
|
||||
|
||||
// **The walk.** The fly's own choice is not being simulated here — the slot is started by
|
||||
// name — but everything after that is the shipping executor: one plan, the per-step moved
|
||||
// check, the three-failure rule, the frame cap. What is asserted is that some hold of
|
||||
// `GO FRONTIER` ends on ground that was outside the window when the hold began, which is the
|
||||
// whole of the operator's ask.
|
||||
let mut left_the_window = None;
|
||||
let mut left_the_map = None;
|
||||
let mut done = 0;
|
||||
for hold in 0..12 {
|
||||
let began = game.tile();
|
||||
let Some((outcome, frames)) = game.run_macro("GO FRONTIER") else {
|
||||
eprintln!("GO FRONTIER left the pad after {hold} holds");
|
||||
break;
|
||||
};
|
||||
let landed = game.tile();
|
||||
if outcome == "Done" {
|
||||
done += 1;
|
||||
}
|
||||
eprintln!("hold {hold}: {began:?} -> {landed:?} in {frames} frames, {outcome}");
|
||||
if !in_window(began, landed.x, landed.y) {
|
||||
left_the_window = Some((hold, began, landed, frames));
|
||||
break;
|
||||
}
|
||||
if game.map() != map {
|
||||
left_the_map = Some((hold, game.map()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(done > 0, "no hold of GO FRONTIER finished");
|
||||
match (left_the_window, left_the_map) {
|
||||
(Some((hold, began, landed, frames)), _) => eprintln!(
|
||||
"GO FRONTIER walked out of its own window on hold {hold}: {began:?} -> {landed:?} in \
|
||||
{frames} frames"
|
||||
),
|
||||
// A doormat is unstood ground and the frontier is allowed to aim at it
|
||||
// (`docs/design/macros.md` section 12.7: the reward ledger can never record a warp tile),
|
||||
// so a near frontier that is a door takes the fly off the map before the far one is
|
||||
// reached. That is the frontier's own rule rather than the grid's doing, and the plan
|
||||
// above is what this test is for; the arrival is asserted on a map with no door next to
|
||||
// the fly.
|
||||
(None, Some((hold, map))) => eprintln!(
|
||||
"GO FRONTIER stepped onto a warp tile on hold {hold} and left for map {map:#04x} \
|
||||
before it left its window"
|
||||
),
|
||||
(None, None) => panic!("no hold of GO FRONTIER left the window it started in"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_way_out_of_the_map_is_one_plan_away() {
|
||||
let (rom, checkpoint) = skip_without!();
|
||||
let mut game = Game::resume(&rom, &checkpoint);
|
||||
let map = game.map();
|
||||
let Ok(grid) = game.grid() else {
|
||||
eprintln!("skipped: no grid on map {map:#04x}");
|
||||
return;
|
||||
};
|
||||
let (here, plans) = game.with_state(|state| {
|
||||
let here = state.player().map(|player| Tile::new(player.x, player.y)).expect("a player");
|
||||
let exits = path::exits(state);
|
||||
let mut plans = Vec::new();
|
||||
for exit in &exits {
|
||||
if let Some(route) = path::route(state, &[exit.tile]) {
|
||||
plans.push((exit.id, exit.tile, exit.way, route.goal.is_some(), route.steps.clone()));
|
||||
}
|
||||
}
|
||||
(here, plans)
|
||||
});
|
||||
assert!(!plans.is_empty(), "a map with no way out at all");
|
||||
let mut crossed = 0;
|
||||
let mut reached_one = false;
|
||||
for (id, tile, way, reached, steps) in &plans {
|
||||
// Every tile of the plan is ground the grid calls walkable: a plan with no guesses in it,
|
||||
// which is what "one plan per walk" needs to mean.
|
||||
let mut at = here;
|
||||
let mut guesses = 0;
|
||||
for facing in steps {
|
||||
at = at.step(*facing).expect("a step inside the map");
|
||||
if grid.walkable(at.x, at.y) != Walkable::Yes {
|
||||
guesses += 1;
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"{id:?} at {tile:?} ({way:?}): {} steps, reached {reached}, {guesses} tiles the grid \
|
||||
does not call ground",
|
||||
steps.len()
|
||||
);
|
||||
assert_eq!(guesses, 0, "the plan to {tile:?} walks through {guesses} tiles of not-ground");
|
||||
if !*reached {
|
||||
// An exit tile the map fences off from where the fly stands: the grid *knows* it
|
||||
// cannot be reached, which is the honest answer and is what the closest-approach route
|
||||
// is for. Pallet Town's north-west corner is one — walkable ground behind a fence.
|
||||
eprintln!(" (that one only approaches: {} of {} tiles reachable)", 0, steps.len());
|
||||
continue;
|
||||
}
|
||||
reached_one = true;
|
||||
assert_eq!(at, *tile, "the plan to {tile:?} ends on {at:?}");
|
||||
if steps.len() > 9 {
|
||||
crossed += 1;
|
||||
}
|
||||
}
|
||||
assert!(reached_one, "no way out of map {map:#04x} can be reached at all");
|
||||
assert!(
|
||||
crossed > 0,
|
||||
"no way out of map {map:#04x} is further than the window, so this checkpoint cannot show \
|
||||
the difference"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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',
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
402
services/flysim/tools/resolve_wram.py
Normal file
402
services/flysim/tools/resolve_wram.py
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
"""Resolve WRAM symbol addresses out of a pret/pokered checkout, and verify the pinned ones.
|
||||
|
||||
`gen_symbols.py` owns `symbols.rs` and takes its addresses from the prototype's
|
||||
already-generated table; it refuses a hand-written address. Section 15 of
|
||||
`docs/design/macros.md` needs four symbols that table does not carry
|
||||
(`wOverworldMap`, `wCurMapTileset`, `wTilesetBank`, `wTilesetBlocksPtr`), and a
|
||||
hand-computed address is exactly what neither script will take. So this tool
|
||||
does the same job the disassembly's own `.sym` would, from `ram/wram.asm`:
|
||||
|
||||
* it walks the file in order, keeping a byte cursor;
|
||||
* the cursor is **only ever live while it is anchored**: it is set from a symbol
|
||||
`symbols.rs` already pins, and any declaration form this tool cannot evaluate
|
||||
exactly kills it until the next pinned symbol revives it. An unanchored region
|
||||
can therefore not produce a number at all, rather than producing a wrong one;
|
||||
* every pinned symbol it reaches while live is checked against `symbols.rs`, and
|
||||
a single disagreement is a failure with no output. A resolved symbol is only
|
||||
reported when the run also re-derived the *next* pinned symbol after it, so
|
||||
each answer is bracketed by two addresses the table already carries.
|
||||
|
||||
Usage (read-only; `--emit` rewrites the `ram` block of symbols.rs in place):
|
||||
|
||||
python3 services/flysim/tools/resolve_wram.py --pokered <checkout> [--emit]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
SYMBOLS = (
|
||||
Path(__file__).resolve().parents[1] / 'crates/flybrain-gb/src/pokemon_red/symbols.rs'
|
||||
)
|
||||
|
||||
#: The symbols this run is for, with why the macro layer needs each one. Every
|
||||
#: one is checked to be bracketed by two pinned addresses before it is emitted.
|
||||
WANTED = {
|
||||
# The current map's block ids, as LoadTileBlockMap copies them out of the
|
||||
# map's ROM bank: rows of (width + MAP_BORDER * 2) bytes, the map itself
|
||||
# offset by three rows and three columns. This is what makes a whole-map
|
||||
# walkability grid a WRAM read rather than a ROM one.
|
||||
'wOverworldMap': 'the loaded map, one byte per 4x4-tile block',
|
||||
# Which tileset the loaded map uses: the tile-pair collision lists are keyed
|
||||
# by it (CheckForTilePairCollisions).
|
||||
'wCurMapTileset': 'the loaded map tileset id',
|
||||
# The tileset header's blockset: a bank byte and a little-endian pointer at
|
||||
# 16 bytes per block, four rows of four tile ids (DrawTileBlock). The bank is
|
||||
# not bank 0, so this is the read the memory seam grew a bank for.
|
||||
'wTilesetBank': 'the ROM bank the blockset lives in',
|
||||
'wTilesetBlocksPtr': 'blocks to tiles, 16 bytes per block',
|
||||
}
|
||||
|
||||
|
||||
def pinned(text: str) -> dict[str, int]:
|
||||
"""Every address `symbols.rs` carries today, by symbol name."""
|
||||
return {
|
||||
name: int(value, 16)
|
||||
for name, value in re.findall(r'pub const (w\w+): u16 = 0x([0-9a-f]{4});', text)
|
||||
}
|
||||
|
||||
|
||||
def constants(root: Path) -> dict[str, int]:
|
||||
"""Every `DEF NAME EQU <expression>` the declarations below need.
|
||||
|
||||
Resolved by repeated passes rather than in one, because the decomp defines
|
||||
constants in terms of each other (`SURROUNDING_WIDTH EQU SCREEN_BLOCK_WIDTH *
|
||||
BLOCK_WIDTH`). A name whose expression never becomes evaluable is simply left
|
||||
out, which kills the cursor at any declaration that uses it.
|
||||
"""
|
||||
pending: dict[str, str] = {}
|
||||
sources = sorted((root / 'constants').glob('*.asm')) + sorted(
|
||||
(root / 'constants').glob('*.inc')
|
||||
)
|
||||
for path in sources:
|
||||
for name, value in re.findall(
|
||||
r'^\s*(?:DEF|def)\s+(\w+)\s+(?:EQU|equ)\s+([^;\n]+)', path.read_text(), re.M
|
||||
):
|
||||
pending.setdefault(name, value.strip())
|
||||
out: dict[str, int] = {}
|
||||
while pending:
|
||||
progressed = False
|
||||
for name in list(pending):
|
||||
try:
|
||||
out[name] = size_of(pending[name], out)
|
||||
except Unevaluable:
|
||||
continue
|
||||
del pending[name]
|
||||
progressed = True
|
||||
if not progressed:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def number(token: str) -> int:
|
||||
token = token.strip()
|
||||
if token.startswith('$'):
|
||||
return int(token[1:], 16)
|
||||
if token.startswith('%'):
|
||||
return int(token[1:], 2)
|
||||
return int(token, 10)
|
||||
|
||||
|
||||
class Unevaluable(Exception):
|
||||
"""A declaration this tool will not guess the size of."""
|
||||
|
||||
|
||||
def size_of(expression: str, known: dict[str, int]) -> int:
|
||||
"""Bytes in a `ds`/`EQU` expression: the decomp's own arithmetic, nothing else.
|
||||
|
||||
`$`/`%` literals and the constants resolved so far are substituted, the
|
||||
`tiles` unit is a factor of sixteen, and what is left must be plain
|
||||
arithmetic over integers -- so a name this tool has not resolved, a function
|
||||
call or anything else raises [`Unevaluable`] instead of becoming a guess.
|
||||
"""
|
||||
expression = expression.split(';')[0].strip()
|
||||
if not expression:
|
||||
raise Unevaluable('empty')
|
||||
scale = 1
|
||||
if expression.endswith('tiles'):
|
||||
expression = expression[: -len('tiles')].strip()
|
||||
scale = 16 # one 8x8 2bpp tile is 16 bytes
|
||||
def substitute(match: re.Match[str]) -> str:
|
||||
token = match.group(0)
|
||||
if token[0] in '$%':
|
||||
return str(number(token))
|
||||
if token in known:
|
||||
return str(known[token])
|
||||
raise Unevaluable(token)
|
||||
substituted = re.sub(r'\$[0-9A-Fa-f_]+|%[01_]+|[A-Za-z_]\w*', substitute, expression)
|
||||
if not re.fullmatch(r'[\d\s()+\-*/]+', substituted):
|
||||
raise Unevaluable(expression)
|
||||
try:
|
||||
value = eval(substituted, {'__builtins__': {}}, {}) # arithmetic only, checked above
|
||||
except (SyntaxError, ZeroDivisionError, TypeError) as error:
|
||||
raise Unevaluable(expression) from error
|
||||
if not isinstance(value, int):
|
||||
raise Unevaluable(expression)
|
||||
return value * scale
|
||||
|
||||
|
||||
def macro_sizes(root: Path, known: dict[str, int]) -> dict[str, int]:
|
||||
"""Sizes of the RAM struct macros, counted from their own declarations."""
|
||||
out: dict[str, int] = {}
|
||||
for path in sorted((root / 'macros').glob('*.asm')):
|
||||
text = path.read_text()
|
||||
for match in re.finditer(r'^MACRO\??\s+(\w+)\n(.*?)^ENDM', text, re.M | re.S):
|
||||
name, body = match.group(1), match.group(2)
|
||||
total = 0
|
||||
for line in body.splitlines():
|
||||
line = line.split(';')[0].strip()
|
||||
# A struct macro labels each field with its argument
|
||||
# (`\\1YCoord:: db`), so the label is stripped and the
|
||||
# declaration after it is what reserves the bytes.
|
||||
line = re.sub(r'^[\\\w{}:.\d]+::\s*', '', line)
|
||||
if not line or line.startswith(('IF', 'ELSE', 'ENDC', 'ASSERT')):
|
||||
continue
|
||||
if re.match(r'^\w+::?$', line):
|
||||
continue
|
||||
if line.startswith('db'):
|
||||
total += max(1, len([part for part in line[2:].split(',') if part.strip()]))
|
||||
elif line.startswith('dw'):
|
||||
total += 2 * max(1, len([p for p in line[2:].split(',') if p.strip()]))
|
||||
elif line.startswith('ds '):
|
||||
try:
|
||||
total += size_of(line[3:], known)
|
||||
except Unevaluable:
|
||||
total = None
|
||||
break
|
||||
else:
|
||||
total = None
|
||||
break
|
||||
if total is not None:
|
||||
out[name] = total
|
||||
return out
|
||||
|
||||
|
||||
def walk(
|
||||
root: Path, table: dict[str, int], known: dict[str, int], verbose: bool = False
|
||||
) -> tuple[dict[str, int], list[str], int]:
|
||||
"""Resolve every symbol of wram.asm the anchored cursor can reach exactly."""
|
||||
macros = macro_sizes(root, known)
|
||||
lines = (root / 'ram/wram.asm').read_text().splitlines()
|
||||
cursor: int | None = None
|
||||
resolved: dict[str, int] = {}
|
||||
# Symbols counted since the last pinned anchor, held back until a pinned
|
||||
# address after them agrees.
|
||||
pending_run: dict[str, int] = {}
|
||||
order: list[str] = []
|
||||
checked = 0
|
||||
problems: list[str] = []
|
||||
lost: list[str] = []
|
||||
# UNION frames: (start address, widest branch so far, whether a branch was
|
||||
# unevaluable). A frame whose start is unknown, or any one of whose branches this
|
||||
# tool could not size, poisons the whole union: what the section advances by is
|
||||
# the widest branch, so one branch it cannot measure means it cannot measure any.
|
||||
unions: list[tuple[int | None, int, bool]] = []
|
||||
index = 0
|
||||
while index < len(lines):
|
||||
raw = lines[index]
|
||||
index += 1
|
||||
line = raw.split(';')[0].strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith('SECTION'):
|
||||
# A section's address comes from the linker, not the source. WRAM0
|
||||
# sections are packed in declaration order, so the cursor carries on
|
||||
# -- and the next address `symbols.rs` pins is what tests that: any
|
||||
# padding the linker inserted would land as a MISMATCH and this tool
|
||||
# would emit nothing.
|
||||
continue
|
||||
if line == 'UNION':
|
||||
unions.append((cursor, 0, False))
|
||||
continue
|
||||
if line == 'NEXTU':
|
||||
if not unions:
|
||||
cursor = None
|
||||
continue
|
||||
start, widest, poisoned = unions.pop()
|
||||
poisoned = poisoned or start is None or cursor is None
|
||||
if not poisoned:
|
||||
widest = max(widest, cursor - start)
|
||||
unions.append((start, widest, poisoned))
|
||||
cursor = start
|
||||
continue
|
||||
if line == 'ENDU':
|
||||
if not unions:
|
||||
cursor = None
|
||||
continue
|
||||
start, widest, poisoned = unions.pop()
|
||||
if poisoned or start is None or cursor is None:
|
||||
cursor = None
|
||||
continue
|
||||
cursor = start + max(widest, cursor - start)
|
||||
continue
|
||||
if line.startswith(('FOR ', 'REPT ')):
|
||||
# Evaluate the body only when every line of it has a known size.
|
||||
head = line.split(None, 1)[1]
|
||||
# `REPT n`, `FOR v, stop` and `FOR v, start, stop`: rgbasm's own
|
||||
# three forms, and the third iterates stop - start times.
|
||||
arguments = [part.strip() for part in head.split(',')]
|
||||
count_token = arguments[-1]
|
||||
start_token = arguments[-2] if line.startswith('FOR ') and len(arguments) == 3 else None
|
||||
body: list[str] = []
|
||||
depth = 1
|
||||
while index < len(lines):
|
||||
inner = lines[index].split(';')[0].strip()
|
||||
index += 1
|
||||
if inner.startswith(('FOR ', 'REPT ')):
|
||||
depth += 1
|
||||
if inner == 'ENDR':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
break
|
||||
body.append(inner)
|
||||
try:
|
||||
count = size_of(count_token, known)
|
||||
if start_token is not None:
|
||||
count -= size_of(start_token, known)
|
||||
per = 0
|
||||
for inner in body:
|
||||
per += declaration_size(inner, known, macros)
|
||||
if cursor is not None:
|
||||
cursor += count * per
|
||||
except Unevaluable:
|
||||
if cursor is not None:
|
||||
lost.append(f'line {index}: {line}')
|
||||
cursor = None
|
||||
pending_run.clear()
|
||||
continue
|
||||
label = re.match(r'^(w\w+)::', line)
|
||||
if label is not None:
|
||||
name = label.group(1)
|
||||
if name in table:
|
||||
if cursor is not None and cursor != table[name]:
|
||||
problems.append(
|
||||
f'{name}: wram.asm gives ${cursor:04x}, symbols.rs pins ${table[name]:04x}'
|
||||
)
|
||||
pending_run.clear()
|
||||
elif cursor is not None:
|
||||
checked += 1
|
||||
# Everything counted since the last anchor is now bracketed
|
||||
# by two addresses the table already carries.
|
||||
resolved.update(pending_run)
|
||||
order.extend(pending_run)
|
||||
pending_run.clear()
|
||||
else:
|
||||
if verbose:
|
||||
lost.append(f'cold anchor at {name} (line {index})')
|
||||
pending_run.clear()
|
||||
cursor = table[name]
|
||||
elif cursor is not None:
|
||||
pending_run[name] = cursor
|
||||
line = line[label.end() :].strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.endswith('::') or re.fullmatch(r'\.\w+', line):
|
||||
continue
|
||||
try:
|
||||
if cursor is not None:
|
||||
cursor += declaration_size(line, known, macros)
|
||||
except Unevaluable:
|
||||
if cursor is not None:
|
||||
lost.append(f'line {index}: {line}')
|
||||
cursor = None
|
||||
pending_run.clear()
|
||||
if verbose:
|
||||
for entry in lost:
|
||||
print(f'UNEVALUABLE {entry}')
|
||||
return resolved, problems, checked
|
||||
|
||||
|
||||
def declaration_size(line: str, known: dict[str, int], macros: dict[str, int]) -> int:
|
||||
"""Bytes one declaration line reserves, or [`Unevaluable`]."""
|
||||
line = line.split(';')[0].strip()
|
||||
if not line or line.endswith('::') or line.startswith(('ENDSECTION', 'ASSERT', 'ENDR')):
|
||||
return 0
|
||||
# Any label, including the `{02d:n}` interpolations a FOR body labels its
|
||||
# iterations with; what reserves the bytes is the declaration after it.
|
||||
line = re.sub(r'^[\\\w{}:.\d]+::\s*', '', line)
|
||||
if not line:
|
||||
return 0
|
||||
if line == 'db':
|
||||
return 1
|
||||
if line == 'dw':
|
||||
return 2
|
||||
if line.startswith('db '):
|
||||
return max(1, len([part for part in line[3:].split(',') if part.strip()]))
|
||||
if line.startswith('dw '):
|
||||
return 2 * max(1, len([part for part in line[3:].split(',') if part.strip()]))
|
||||
if line.startswith('ds '):
|
||||
return size_of(line[3:], known)
|
||||
if line.startswith('flag_array '):
|
||||
return math.ceil(size_of(line[len('flag_array ') :], known) / 8)
|
||||
head = line.split(None, 1)[0]
|
||||
if head in macros:
|
||||
return macros[head]
|
||||
raise Unevaluable(line)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--pokered', type=Path, required=True)
|
||||
parser.add_argument('--verbose', action='store_true', help='name every declaration it will not size')
|
||||
parser.add_argument('--emit', action='store_true', help='write the new addresses into symbols.rs')
|
||||
args = parser.parse_args()
|
||||
|
||||
text = SYMBOLS.read_text()
|
||||
commit = re.search(r'POKERED_COMMIT: &str = "([0-9a-f]{40})"', text)
|
||||
if commit is None:
|
||||
raise SystemExit('symbols.rs carries no POKERED_COMMIT')
|
||||
head = (args.pokered / '.git/HEAD').read_text().strip()
|
||||
if head.startswith('ref:'):
|
||||
head = (args.pokered / '.git' / head.split()[1]).read_text().strip()
|
||||
if head != commit.group(1):
|
||||
raise SystemExit(
|
||||
f'checkout is at {head}, symbols.rs pins {commit.group(1)}: two revisions of the '
|
||||
'disassembly renumber RAM relative to each other'
|
||||
)
|
||||
|
||||
table = pinned(text)
|
||||
known = constants(args.pokered)
|
||||
resolved, problems, checked = walk(args.pokered, table, known, args.verbose)
|
||||
if problems:
|
||||
for problem in problems:
|
||||
print(f'MISMATCH {problem}')
|
||||
raise SystemExit('the walk disagrees with symbols.rs; nothing emitted')
|
||||
print(f'{checked} of {len(table)} pinned addresses re-derived from wram.asm, no disagreement')
|
||||
|
||||
missing = [name for name in WANTED if name not in resolved]
|
||||
if missing:
|
||||
raise SystemExit(f'unanchored, so not resolved: {", ".join(missing)}')
|
||||
for name in WANTED:
|
||||
print(f'{name} = ${resolved[name]:04x} ({WANTED[name]})')
|
||||
|
||||
if not args.emit:
|
||||
return
|
||||
block = re.search(r'(pub mod ram \{\n)(.*?)(\n\}\n)', text, re.S)
|
||||
if block is None:
|
||||
raise SystemExit('no ram block in symbols.rs')
|
||||
rows = []
|
||||
for row in block.group(2).splitlines():
|
||||
name = re.match(r'\s*pub const (w\w+): u16 = 0x([0-9a-f]{4});', row)
|
||||
if name is None:
|
||||
continue
|
||||
rows.append((int(name.group(2), 16), name.group(1)))
|
||||
for name in WANTED:
|
||||
if name not in table:
|
||||
rows.append((resolved[name], name))
|
||||
rows = sorted(set(rows))
|
||||
width = max(len(name) for _, name in rows)
|
||||
body = '\n'.join(
|
||||
f' pub const {name}: u16 = 0x{address:04x};'.ljust(width + 31)
|
||||
+ f'// {address}'
|
||||
for address, name in rows
|
||||
)
|
||||
SYMBOLS.write_text(text[: block.start(2)] + body + text[block.end(2) :])
|
||||
print(f'symbols.rs: {len(rows)} addresses written')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Loading…
Add table
Reference in a new issue