Compare commits
21 commits
592c2640a3
...
928d66bd8c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
928d66bd8c | ||
|
|
4bd0a4b3fa | ||
|
|
f0cebd2574 | ||
|
|
520298e5c3 | ||
|
|
05c2552d78 | ||
|
|
09c44a1cde | ||
|
|
78fb86336e | ||
|
|
4b93f9dc7f | ||
|
|
ba581e44d3 | ||
|
|
9479681b9f | ||
|
|
b9e01dea5b | ||
|
|
c8de2e42e9 | ||
|
|
abd3e563a8 | ||
|
|
44a7011299 | ||
|
|
246e0c7d4c | ||
|
|
bcae388d99 | ||
|
|
1b06e6a6cb | ||
|
|
f7fa39afa7 | ||
|
|
68228ef2a1 | ||
|
|
d75b7320ea | ||
|
|
320b05ecee |
26 changed files with 3847 additions and 113 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
|
is not followed and the answer is `Unknown`, because banks 1 and up are whatever the last bank
|
||||||
switch left mapped. This is the only ROM read in the module and the reason it is allowed.
|
switch left mapped. This is the only ROM read in the module and the reason it is allowed.
|
||||||
|
|
||||||
|
**Section 9 is the same predicate over the whole map** (2026-09-22): the window below is what the
|
||||||
|
walks fall back to on a frame the map cannot be decoded, and no longer what they plan over.
|
||||||
|
|
||||||
Three things bound it, all reported as `Unknown` rather than guessed:
|
Three things bound it, all reported as `Unknown` rather than guessed:
|
||||||
|
|
||||||
1. **The window is ten tiles by nine and it follows the player**: `x - 4 ..= x + 5` and
|
1. **The window is ten tiles by nine and it follows the player**: `x - 4 ..= x + 5` and
|
||||||
|
|
@ -573,3 +576,92 @@ PP, type effectiveness applied from the ROM's type chart", and section 14 replac
|
||||||
one button per move slot: which move is used is the fly's choice and the mushroom body's to learn.
|
one button per move slot: which move is used is the fly's choice and the mushroom body's to learn.
|
||||||
Knowledge that nothing reads is not narrowed, it is deleted — `MacroState` is four methods
|
Knowledge that nothing reads is not narrowed, it is deleted — `MacroState` is four methods
|
||||||
shorter and `pokemon_red/state.rs` never needed them.
|
shorter and `pokemon_red/state.rs` never needed them.
|
||||||
|
|
||||||
|
## 9. The whole map, not the window (2026-09-22, `docs/design/macros.md` section 15)
|
||||||
|
|
||||||
|
The walkable predicate of section 2 answers about ten tiles by nine because that is how much map
|
||||||
|
the screen buffer holds. Every tile of the loaded map follows the same rule, decoded from the
|
||||||
|
tables the cartridge has loaded.
|
||||||
|
|
||||||
|
**Four more names through the same door.** `services/flysim/tools/gen_symbols.py`'s `EXTRA_RAM`
|
||||||
|
takes the table from 63 addresses to **67**, and nothing else in it moves — no event flag, no
|
||||||
|
milestone, no existing address. `flysim --print-compatibility` is byte-identical across the change:
|
||||||
|
648 bytes, `0d9bfde7…707fa`.
|
||||||
|
|
||||||
|
The prototype checkout `gen_symbols.py` reads is not on this box, so the four addresses were
|
||||||
|
resolved the way it would have resolved them, by a second tool that reads the disassembly directly:
|
||||||
|
`services/flysim/tools/resolve_wram.py` walks `ram/wram.asm` at the pinned commit with a byte
|
||||||
|
cursor that is **only ever live while it is anchored on an address `symbols.rs` already pins**, and
|
||||||
|
emits an address only when a pinned address *after* it agrees as well. It re-derives 40 of the 63
|
||||||
|
addresses the table already carries with no disagreement, and each of the four new ones is
|
||||||
|
bracketed by two of them. A declaration form it cannot size exactly kills the cursor rather than
|
||||||
|
being guessed at, so an unanchored region cannot produce a number at all.
|
||||||
|
|
||||||
|
| state | symbol | address | encoding | verified |
|
||||||
|
| --- | --- | ---: | --- | --- |
|
||||||
|
| the loaded map's blocks | `wOverworldMap` | `$c6e8` | one byte per 4x4-tile block. `LoadTileBlockMap` (`home/overworld.asm`) fills it from the map's own ROM bank as rows of `wCurMapWidth + MAP_BORDER * 2` bytes with the map itself `MAP_BORDER` = 3 rows and columns in, so the border can hold strips of the connected maps. The map's own blocks are therefore a WRAM read. | survey + ROM (Pallet Town and Viridian Forest, below), trace |
|
||||||
|
| which tileset | `wCurMapTileset` | `$d367` | tileset id (`constants/tileset_constants.asm`, `OVERWORLD` 0 … `FOREST` 3 … `CAVERN` 17). Keys the tile-pair lists, which is the only thing this work reads it for. | ROM, trace |
|
||||||
|
| the blockset's bank | `wTilesetBank` | `$d52b` | the tileset header's `db BANK(\1)` (`data/tilesets/tileset_headers.asm`). Not bank 0, which is the whole reason the seam grew a bank-aware read. | ROM (the overworld tileset's blockset reads from bank `$19`), trace |
|
||||||
|
| blocks to tiles | `wTilesetBlocksPtr` | `$d52c` | little-endian pointer, 16 bytes per block id, four rows of four screen tile ids. `DrawTileBlock` (`home/overworld.asm`) indexes it as `block * $10` and walks four rows of four, which pins the layout exactly. | ROM, trace |
|
||||||
|
|
||||||
|
### The one ROM read that needed a bank
|
||||||
|
|
||||||
|
`MemoryReader` gains `read_rom(bank, address) -> Option<u8>`, defaulted to `None`. The bus read
|
||||||
|
cannot reach the blockset — banks 1 and up are whatever the cartridge's last switch left mapped, and
|
||||||
|
the only way to change that would be to *write* the mapper's bank register, which the doctrine
|
||||||
|
forbids (`docs/design/macros.md` section 12: the joypad register is the only write). So the
|
||||||
|
emulator implements it over **the cartridge image the process already holds**: below `$4000` it is
|
||||||
|
bank 0 whatever the bank says, `$4000..$8000` is the banked window, and an offset past the end of
|
||||||
|
the image is `None`. Nothing is written, no bank is switched, and the emulator's state does not
|
||||||
|
move. Every other reader — the synthetic WRAM of the tests, the sim loop's stubs — keeps the
|
||||||
|
default, and `None` there means the grid narrows to the window predicate rather than decoding a map
|
||||||
|
out of whatever bytes were to hand.
|
||||||
|
|
||||||
|
### The corner, which was measured
|
||||||
|
|
||||||
|
A map tile is 2x2 screen tiles and `CheckTilePassable` matches **one** id, so a decode has to pick
|
||||||
|
the same one the cartridge picks. It is the **lower left** of the four. The upper left is the
|
||||||
|
plausible guess: the view is centred so that the player's own 2x2 begins at screen row 8 and
|
||||||
|
`_GetTileAndCoordsInFrontOfPlayer` reads `(8, 9)`, which is its lower half. Measured on the
|
||||||
|
cartridge rather than argued: Viridian Forest's (4, 32) reads `$23` on the screen, which is the
|
||||||
|
second row of its block, where the first row holds `$04`. On a town most quadrants hold one tile id
|
||||||
|
four times over, so an upper-left decode reads correctly there and falls apart in a forest — which
|
||||||
|
is exactly the shape of mistake the cross-check below exists for.
|
||||||
|
|
||||||
|
### Two gates, because a wrong decode answers plausibly
|
||||||
|
|
||||||
|
- **Against the screen, before the grid is trusted.** The decoded ids are compared with
|
||||||
|
`map_tile_id` over the fly's own tile and its four neighbours; a frame where the window can answer
|
||||||
|
for none of them is refused. `wOverworldMap` shares its bytes with the picture buffer
|
||||||
|
(`ram/wram.asm`'s own `UNION`), so a battle is precisely when the blocks under it are somebody
|
||||||
|
else's.
|
||||||
|
- **Against the screen again, whenever a cached grid is served.** A warp writes `wCurMap` before the
|
||||||
|
header and the blocks: measured on Oak's lab's doormat, where `wCurMap` reads `PALLET_TOWN` while
|
||||||
|
the header still reads the lab's ten-by-twelve. The decode and the screen agree on such a frame —
|
||||||
|
both are the old map — so only the *id* is wrong, and the check that catches it is one byte: does
|
||||||
|
the cached grid still agree with the screen about the tile the fly is standing on.
|
||||||
|
|
||||||
|
### The survey, on two maps
|
||||||
|
|
||||||
|
`services/flysim/crates/flysim/tests/rom_map_grid.rs`, the method of
|
||||||
|
`docs/design/room-escape.md` section 3: walk the map with real button presses on throwaway
|
||||||
|
emulators, 120 frames of held direction per step and twenty released frames before each state is
|
||||||
|
kept, and compare the grid against what the cartridge did.
|
||||||
|
|
||||||
|
| map | size | walkable | reachable | unknown | window tiles compared | tiles surveyed | refused presses | a sprite was in the way | a battle or a script answered |
|
||||||
|
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||||
|
| Pallet Town `$00` | 20x18 | 221 | 207 | 0 | 90, no disagreement | 120 | 58, all explained | 4 | 0 |
|
||||||
|
| Viridian Forest `$33` | 34x48 | 719 | 719 | 0 | 90, no disagreement | 120 | 74, all explained | 2 | 10 |
|
||||||
|
|
||||||
|
"All explained" is the assertion that matters: every press the cartridge refused is a tile the grid
|
||||||
|
calls a wall, a directed wall out of that tile, or a tile a sprite was standing on **in the frame
|
||||||
|
the press was made in** — Pallet Town's two villagers walk, so reading the sprite list from the
|
||||||
|
state the survey started in would not do. And every step the cartridge made is one the grid would
|
||||||
|
have planned. Both halves are needed: the first catches a decode that is too permissive, the second
|
||||||
|
one that is too strict.
|
||||||
|
|
||||||
|
Three things are still not modelled, and none of them is new: a sprite in the way (the sprite list
|
||||||
|
answers that, and the executor's per-step moved check covers the rest), a warp that fires on the
|
||||||
|
step onto it, and a script that pushes the fly off a tile (a session ledger answers that). The
|
||||||
|
water half of the tile-pair lists is deliberately absent: it is the list
|
||||||
|
`CheckForJumpingAndTilePairCollisions` uses while surfing, and the palette cannot surf.
|
||||||
|
|
|
||||||
|
|
@ -859,6 +859,61 @@ reproduction and the numbers; two macros, and section 12.2's one rule between th
|
||||||
|
|
||||||
The decoder, the reward catalog, the adapter version and the compatibility string are untouched.
|
The decoder, the reward catalog, the adapter version and the compatibility string are untouched.
|
||||||
|
|
||||||
|
### 12.10 No two buttons on a battle pad undo each other (2026-09-22, rung 9, seventy-one hours)
|
||||||
|
|
||||||
|
Thirty-five minutes after v0.4.2 deployed, the watchdog flagged the same rung again. Rank 9
|
||||||
|
(VIRIDIAN FOREST, next PEWTER CITY), seventy-one hours on it, and since the restart the macro
|
||||||
|
starts were `NEXT` **1264**, `BACK` **1241**, `THROW BALL` 5 and `GO WARP` 3 -- the event log
|
||||||
|
alternating `NEXT start/done, BACK start/done` every hold, on map 51.
|
||||||
|
|
||||||
|
Reproduced from the live checkpoint with the real cartridge and the real brain
|
||||||
|
(`infra/docs/macros-traps.md` has the numbers): twenty brain minutes, **71,673 frames of them all
|
||||||
|
in one battle**, 73 of 73 windows flagged, one distinct tile, and the two counts that name the
|
||||||
|
mechanism -- `BACK` **739 starts on the move list** and `NEXT` **739 on the top-level menu**.
|
||||||
|
|
||||||
|
- **The pair was split across two sub-states of one turn, which is why 12.9 did not catch it.**
|
||||||
|
`NEXT`'s script is one press of A. On a frame of battle text that A advances the text, which is
|
||||||
|
what the button is for. On the *top-level battle menu* the same A press **confirms whatever the
|
||||||
|
cursor is sitting on**, and the cursor sits on FIGHT, so `NEXT` opened the move list -- whose
|
||||||
|
`BACK` closed it again. Each button was legitimate where it stood: 12.9's rule is about `BACK`
|
||||||
|
and a list, and there *was* a list to leave. What is a trap is the **pair**: two buttons that
|
||||||
|
undo each other with nothing else changing, so the roll lands on one of them nearly every hold
|
||||||
|
and the turn never resolves. Section 12.2's rule, stated at the pad instead of at one macro:
|
||||||
|
**no pair of buttons on any battle pad may undo each other with nothing else changing.**
|
||||||
|
- **`NEXT` is the A that advances text, so it belongs only on a frame with no cursor accepting
|
||||||
|
input.** It is off every own-turn pad: the top-level menu, the move list, the party list and the
|
||||||
|
bag. The between-turns row keeps it alone, and the forced switch keeps it as row 8's backstop --
|
||||||
|
the one arm with a cursor and no `BACK` at all, because a forced switch cannot be cancelled.
|
||||||
|
- **`MOVE 1` is the backstop the top-level menu keeps.** `NEXT` was there for the turn where every
|
||||||
|
other button drops (row 7), and it was the wrong button for the job twice over: it does not end
|
||||||
|
a turn, and what it does instead is reopen the list `BACK` had just closed. `MOVE 1`'s
|
||||||
|
precondition over that menu is 12.8's -- "is there a move list to open", and FIGHT always opens,
|
||||||
|
because `CheckPlayerHasUsableMoves` sets Struggle without opening it -- so it is bound there
|
||||||
|
whatever the seam makes of `wBattleMon*`, and its script over that menu is "confirm FIGHT and
|
||||||
|
stop", which reads no move at all. A battler the seam cannot read is not a reason to take the
|
||||||
|
turn's one ending button away.
|
||||||
|
- **The bag is the fly's turn, and its pad is the bag's own answers.** `Battle::own_turn` answered
|
||||||
|
`false` for it, which was the last frame in the game with a cursor accepting input and no own
|
||||||
|
turn -- so it landed on the between-turns row, and that row's `NEXT` on an open bag is the A
|
||||||
|
press that *uses* whatever the cursor holds. The invariant is now whole: **a battle frame with a
|
||||||
|
cursor accepting input is the fly's turn**, the forced switch excepted because it has a pad of
|
||||||
|
its own. The bag's row is `ITEM` (use the thing) / `THROW BALL` / `BACK`; `CONFIRM` is gone from
|
||||||
|
it, because it was the same blind A press under another name, and both scripts already navigated
|
||||||
|
this list by reading its cursor (7.1 of `macros-wram.md`).
|
||||||
|
- **`wListMenuID` is not stale, which is why the bag reading stands.** It is zeroed by
|
||||||
|
`DisplayTextIDInit` at the start of every text display (`macros-wram.md`), so a battle's text
|
||||||
|
frames cannot inherit an `ITEMLISTMENU` from a bag the fly closed. Checked because a stale byte
|
||||||
|
there would have put the bag's pad on every frame of battle text.
|
||||||
|
- **What the harness now holds, rather than the pads alone.** The ROM-gated run from this
|
||||||
|
checkpoint asserts that no battle pad deals both buttons, that `NEXT` is on no frame with a
|
||||||
|
cursor accepting input, that the longest `NEXT`/`BACK` alternation is under four, and that every
|
||||||
|
battle it enters *ends* -- 25 of 25, worst 275 macros -- where v0.4.2 spent a whole hunt inside
|
||||||
|
one that never did. The battle rules are asked of the **scene the pad was dealt for** and not of
|
||||||
|
`wIsInBattle`: the `$ff` frame a lost battle passes through reads `Unknown`, whose pad is
|
||||||
|
`NEXT, BACK` by contract (row 9), and it is not under a battle's rules.
|
||||||
|
|
||||||
|
The decoder, the reward catalog, the adapter version and the compatibility string are untouched.
|
||||||
|
|
||||||
## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a
|
## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a
|
||||||
## priority to visit the shop at least once per area; make shop macros item purchases. same
|
## priority to visit the shop at least once per area; make shop macros item purchases. same
|
||||||
## for the Pokécenter. heal should be a macro.")
|
## for the Pokécenter. heal should be a macro.")
|
||||||
|
|
@ -936,12 +991,12 @@ observe is not a precondition, it is a guess.
|
||||||
| Menu (the start menu) | CLOSE, CONFIRM, BACK | unchanged. The cursor is the fly's to move with the **raw** D-pad, which still reaches the cartridge in macros mode; a SAVE or a POKéDEX button would be a macro per start-menu entry and is not asked for |
|
| Menu (the start menu) | CLOSE, CONFIRM, BACK | unchanged. The cursor is the fly's to move with the **raw** D-pad, which still reaches the cartridge in macros mode; a SAVE or a POKéDEX button would be a macro per start-menu entry and is not asked for |
|
||||||
| Menu (the bag, an elevator, the party list outside a battle) | CLOSE, CONFIRM, BACK | unchanged |
|
| Menu (the bag, an elevator, the party list outside a battle) | CLOSE, CONFIRM, BACK | unchanged |
|
||||||
| Unknown (the Pokédex, the trainer card, OPTION, a naming screen, a mid-warp frame) | NEXT, **BACK** | **BACK added** (row 9): B is what leaves the first three, and A leaves none of them |
|
| Unknown (the Pokédex, the trainer card, OPTION, a naming screen, a mid-warp frame) | NEXT, **BACK** | **BACK added** (row 9): B is what leaves the first three, and A leaves none of them |
|
||||||
| Battle, own turn, main menu | MOVE 1..4, SWITCH, ITEM, THROW BALL, RUN, NEXT | four move buttons for `ATTACK` (section 14); THROW BALL added, and gated on the species since 12.9; **RUN gated**, below. No `BACK`: the four entries are the answers to this menu |
|
| Battle, own turn, main menu | MOVE 1..4, SWITCH, ITEM, THROW BALL, RUN | four move buttons for `ATTACK` (section 14); THROW BALL added, and gated on the species since 12.9; **RUN gated**, below. No `BACK`: the four entries are the answers to this menu. **`NEXT` removed by 12.10** — an A press here confirms FIGHT and reopens the list the move list's `BACK` just closed, and `MOVE 1` is the backstop instead, bound here whatever the battler reads as |
|
||||||
| Battle, own turn, move list | MOVE 1..4, BACK | as above |
|
| Battle, own turn, move list | MOVE 1..4, BACK | as above |
|
||||||
| Battle, own turn, party list | SWITCH, BACK | unchanged |
|
| Battle, own turn, party list | SWITCH, BACK | unchanged |
|
||||||
| Battle, the bag | NEXT, **BACK** | **BACK added**, and the list now reports a *cursor* (`macros-wram.md` 7.1) — before this `ITEM` could open the bag and had nothing to read |
|
| Battle, own turn, the bag | ITEM, THROW BALL, **BACK** | the bag reports a *cursor* (`macros-wram.md` 7.1), and since **12.10** it is the own turn, because a cursor accepting input is one. Its pad is the list's own answers; `NEXT` and `CONFIRM` are both off it, being the same blind A press that *uses* whatever the cursor holds |
|
||||||
| Battle, forced switch | SWITCH, NEXT | unchanged (row 8) |
|
| Battle, forced switch | SWITCH, NEXT | unchanged (row 8). The one arm that keeps `NEXT` with a cursor up, because it cannot be cancelled and has no `BACK` to undo it |
|
||||||
| Battle, between turns | NEXT | `BACK` was added here for the bag (the row above reads as nobody’s turn) and **taken back out by section 12.9**: on a frame of battle text there is no list to leave, and a `BACK` that changes nothing is the trap of section 12.2 |
|
| Battle, between turns | NEXT | `BACK` was added here for the bag and **taken back out by 12.9**: on a frame of battle text there is no list to leave, and a `BACK` that changes nothing is the trap of section 12.2. Since **12.10** the bag is not on this row at all, so `NEXT` here is only ever the A that advances text |
|
||||||
| Shop | BUY POTION, BUY BALL, BUY ANTIDOTE, BUY REPEL, CONFIRM, LEAVE | two purchases to four; CONFIRM added |
|
| Shop | BUY POTION, BUY BALL, BUY ANTIDOTE, BUY REPEL, CONFIRM, LEAVE | two purchases to four; CONFIRM added |
|
||||||
| PC | **CONFIRM**, LEAVE | **CONFIRM added**: a list the fly opened is one it can answer rather than only close. Depositing and withdrawing are still not in the vocabulary (row 17) |
|
| PC | **CONFIRM**, LEAVE | **CONFIRM added**: a list the fly opened is one it can answer rather than only close. Depositing and withdrawing are still not in the vocabulary (row 17) |
|
||||||
| Title | nothing | unchanged, by contract: the readout's boot variant applies |
|
| Title | nothing | unchanged, by contract: the readout's boot variant applies |
|
||||||
|
|
@ -983,6 +1038,7 @@ measured where it cannot.
|
||||||
| a restore, before the first `observe` | not a cause: the loop calls `observe` once at the end of boot and once after a ratchet recovery, so the first frame is decided on a real palette |
|
| a restore, before the first `observe` | not a cause: the loop calls `observe` once at the end of boot and once after a ratchet recovery, so the first frame is decided on a real palette |
|
||||||
| the title screen, and raw mode | not an empty pad by contract: the readout's boot variant applies and no palette is dealt |
|
| the title screen, and raw mode | not an empty pad by contract: the readout's boot variant applies and no palette is dealt |
|
||||||
| a scene the detector cannot name | `Unknown` deals `NEXT` and `BACK`; a screen neither press leaves (the naming screen, row 14) is a genuine stall and still needs START, which is a contract change |
|
| a scene the detector cannot name | `Unknown` deals `NEXT` and `BACK`; a screen neither press leaves (the naming screen, row 14) is a genuine stall and still needs START, which is a contract change |
|
||||||
|
| the fly's own turn where the seam cannot read the battler, now that `NEXT` is off that row (12.10) | **fixed**: `MOVE 1` is bound over the top-level menu whatever `wBattleMon*` reads as, because FIGHT is one of that menu's four entries and always opens |
|
||||||
|
|
||||||
And because "closed where a macro can close it" is not "closed":
|
And because "closed where a macro can close it" is not "closed":
|
||||||
`game.padEmptyMs` publishes how long a **playable** scene has had nothing on the pad, in brain
|
`game.padEmptyMs` publishes how long a **playable** scene has had nothing on the pad, in brain
|
||||||
|
|
@ -1085,3 +1141,94 @@ rather than argued:
|
||||||
`LAYOUT.macroPalette` was `{x: 480, y: 816, w: 364, h: 212}` while the grid existed — one box
|
`LAYOUT.macroPalette` was `{x: 480, y: 816, w: 364, h: 212}` while the grid existed — one box
|
||||||
moved, the strip's top edge from 852 to 816 — and it is back to `{x: 480, y: 852, w: 364, h: 176}`.
|
moved, the strip's top edge from 852 to 816 — and it is back to `{x: 480, y: 852, w: 364, h: 176}`.
|
||||||
Nothing in the layout differs from main.
|
Nothing in the layout differs from main.
|
||||||
|
|
||||||
|
## 15. Map-aware walks: one plan over the whole map (the operator, 2026-09-22: "the frontier and
|
||||||
|
## warp macros need to be map aware: A* over walkable tiles.")
|
||||||
|
|
||||||
|
Section 4 has said "A* over the current map's walkable tiles" since the first draft, and it was
|
||||||
|
never that. The walkable predicate answers for the ten tiles by nine of the screen buffer and
|
||||||
|
`Walkable::Unknown` for everything else (`docs/design/macros-wram.md`), so every walk in the game
|
||||||
|
planned through guesses at eight times the price of a known tile, re-planned at every window edge,
|
||||||
|
and `GO FRONTIER` aimed at whatever unstood ground was on screen -- never the far side of a town,
|
||||||
|
which nothing could see.
|
||||||
|
|
||||||
|
What changes is where the tile ids come from. The rule does not change at all: a tile is walkable
|
||||||
|
when the current tileset's collision list holds its id, which is `CheckTilePassable`.
|
||||||
|
|
||||||
|
- **The whole map is decoded** from the tables the cartridge has already loaded: the block ids out
|
||||||
|
of `wOverworldMap`, the block-to-tiles blockset out of the tileset header, the collision list as
|
||||||
|
before, and the `TilePairCollisionsLand` pairs as **directed walls**. `MapGrid` is every tile of
|
||||||
|
the loaded map with those walls, and `docs/design/macros-wram.md` section 9 is the byte-level
|
||||||
|
evidence, the new addresses and the verification.
|
||||||
|
- **One read of a ROM bank was needed, so `MemoryReader` gained one method.** The blockset does
|
||||||
|
not live in bank 0, and the only way to reach another bank through the CPU bus would be to
|
||||||
|
*write* the mapper's bank register. `MemoryReader::read_rom(bank, address)` reads the cartridge image the process
|
||||||
|
already holds instead: the same bytes, addressed the way the disassembly addresses them, and no
|
||||||
|
write into a running game. The joypad register is still the only write (section 12).
|
||||||
|
- **`path::route` and `path::frontier` plan over the grid**, and the plan is the same A*: one step
|
||||||
|
per tile, the multi-goal heuristic, the committed route of section 12.3, re-planned only on a
|
||||||
|
refusal or a displacement. `GO WARP`, `GO OUT` and `GO ROUTE` route to their warp or connection
|
||||||
|
tile across the whole map; `GO OBJECTIVE` takes the exit that is the first hop; the frontier is
|
||||||
|
the nearest unstood walkable tile *anywhere on the map*, by the stood ledger of section 12.7.
|
||||||
|
- **The window stays as the fallback, and it says when.** A frame with no grid falls back to the
|
||||||
|
ten-by-nine reading exactly as before, and `GridRefusal` names which of the five reasons it is:
|
||||||
|
no map header, no player, no collision list, no blockset (which is what a reader with no
|
||||||
|
cartridge behind it answers), the map not on screen, or the decode disagreeing with the screen.
|
||||||
|
Nothing is guessed and nothing silently degrades.
|
||||||
|
- **The grid is checked against the screen before it is trusted, and again whenever it is served.**
|
||||||
|
The decode is compared with the window predicate over the tile the fly is standing on and its
|
||||||
|
four neighbours, and a frame where the window can answer for none of them is refused — the block
|
||||||
|
data shares its bytes with the picture buffer, so a battle is exactly when it belongs to somebody
|
||||||
|
else. Serving a cached grid re-checks the fly's own tile, because a warp writes the map id before
|
||||||
|
the header and the blocks: for a frame or two on a doormat, `wCurMap` is the map the fly is
|
||||||
|
arriving on and the blocks are still the map it is leaving.
|
||||||
|
- **Cached per map, decoded once on arrival**, dropped when the map or its size changes. Session
|
||||||
|
state beside the talked, blocked, reached, stood and errand ledgers; never checkpointed, so a
|
||||||
|
restored run decodes the map again on its first overworld frame.
|
||||||
|
- **The probes report it.** `examples/scene_probe.rs` prints the grid's size, its walkable count,
|
||||||
|
the count reachable from where the fly stands and the count never stood on, draws the ground with
|
||||||
|
the reading it used, and names the refusal when there is none; `examples/trap_hunt.rs` carries the
|
||||||
|
same line into every trace line and into the summary table. Three numbers read a stalled walk at
|
||||||
|
a glance: a fly with forty walkable tiles and four reachable ones is fenced in, and no amount of
|
||||||
|
re-planning will help it.
|
||||||
|
|
||||||
|
**Nothing about the choice moves.** The scene deals the same buttons, the readout presses them, and
|
||||||
|
what changed is what a chosen walk knows about the ground — which is where section 12 puts
|
||||||
|
knowledge. The decoder, the reward catalog, the adapter version and the compatibility string are
|
||||||
|
untouched: 648 bytes, `0d9bfde7…707fa`, byte-identical across the change.
|
||||||
|
|
||||||
|
Two things the cartridge settled rather than the design, both measured and both written up with
|
||||||
|
their bytes in `docs/design/macros-wram.md` section 9: the collision id of a map tile is the
|
||||||
|
**lower left** of its four screen tiles and not the upper left, and **a warp writes the map id
|
||||||
|
before the map**, which is what the per-serve check above is for.
|
||||||
|
|
||||||
|
### 15.1 The proof
|
||||||
|
|
||||||
|
- **Unit tests, no cartridge.** The decoder against a made-up tileset: a block's four quadrants,
|
||||||
|
a collision list over a map wider than the window, a tile-pair collision as a wall in both
|
||||||
|
directions and not in another tileset, a block id past the end of the blockset staying `Unknown`,
|
||||||
|
and the reachable count over a fenced region. The reader against synthetic WRAM with a synthetic
|
||||||
|
blockset in a synthetic bank: the whole map decoded, a screen that disagrees refused, a reader
|
||||||
|
with no cartridge refused, a battle frame refused, and the cache holding one map. The search over
|
||||||
|
a grid: one plan across a map larger than the window where the window's own plan walks into a
|
||||||
|
wall it cannot see, a frontier beyond the window where the window's frontier is empty, a
|
||||||
|
tile-pair wall planned around, and a connection whose walls are not goals.
|
||||||
|
- **ROM-gated, from the release container's own checkpoints** (`tests/rom_map_grid.rs`, `FLY_ROM`
|
||||||
|
plus a checkpoint, skipped cleanly without either). On **Pallet Town** — 20x18, 221 walkable
|
||||||
|
tiles, 207 of them reachable, none unknown — and on **Viridian Forest** — 34x48, 719 walkable,
|
||||||
|
all reachable, none unknown: the decode agrees with the window predicate on all ninety tiles the
|
||||||
|
window can answer for, and it agrees with a **survey of real presses** on every one of the first
|
||||||
|
120 tiles the walk can stand on (58 refused presses on the town, 74 in the forest, every one of
|
||||||
|
them a wall, a directed wall or a tile a sprite was standing on; 10 presses in the forest that
|
||||||
|
the cartridge answered with a battle, which say nothing about the ground either way).
|
||||||
|
`GO FRONTIER` in the forest planned to ground outside the window and walked there in 215 frames
|
||||||
|
(615 of its frontier tiles are outside the window); every way out of the forest is one plan away,
|
||||||
|
27 to 149 steps, with no guessed tile in any of them.
|
||||||
|
- **The trap hunt**, twenty brain minutes from the rung-9 checkpoint, against `main` at v0.4.2:
|
||||||
|
**286 distinct (map, tile) become 489**, the median flagged window holds 55 tiles instead of 16,
|
||||||
|
`GO FRONTIER` runs 64 walks worth up to twelve net tiles instead of four worth one, and one walk
|
||||||
|
spends its cap where six did. It costs battle: flagged windows go 61 to 70 and windows under four
|
||||||
|
tiles 5 to 14, every one of them a window spent inside a battle, because a fly that covers more
|
||||||
|
ground walks into more grass. `infra/docs/macros-traps.md` has both runs whole and says so at
|
||||||
|
length; this is the one measurement in this file where more ground and fewer flags do not both
|
||||||
|
hold.
|
||||||
|
|
|
||||||
|
|
@ -615,3 +615,21 @@ then purge; then the stale-doc pass.
|
||||||
the party already held. Fixed: BACK only where a list is open; THROW BALL skips held species.
|
the party already held. Fixed: BACK only where a list is open; THROW BALL skips held species.
|
||||||
Trap hunt: tiles 216 -> 286, listless BACK 175 -> 0, dialog frames 23,548 -> 0. Ethos check
|
Trap hunt: tiles 216 -> 286, listless BACK 175 -> 0, dialog frames 23,548 -> 0. Ethos check
|
||||||
held. Row 41 named: a nurse box answered YES 474 times on one tile.
|
held. Row 41 named: a nurse box answered YES 474 times on one tile.
|
||||||
|
- 2026-09-22 (for v0.4.3): map-aware walks merged (section 15): the whole loaded map is decoded
|
||||||
|
into a walkability grid from the map and tileset data (read-only, ROM bank reads over the
|
||||||
|
cartridge image), A* plans over the map, the frontier is the nearest unstood tile anywhere,
|
||||||
|
the window reader is the fallback. Verified against real presses on two maps. Trap hunt: 286 ->
|
||||||
|
489 tiles, timeouts 6 -> 1; flagged windows rose 61 -> 70, all battle windows (the battle pad
|
||||||
|
review in flight).
|
||||||
|
- 2026-09-22 (v0.4.3, loop review, auto): the watchdog flagged NEXT/BACK repeating 373 times in
|
||||||
|
ten brain minutes after v0.4.2. NEXT on the main battle menu confirmed FIGHT and opened the move
|
||||||
|
list, whose BACK closed it: a pair that undoes itself. NEXT is now off every pad with an
|
||||||
|
input-accepting cursor, MOVE 1 is the main menu backstop, the bag is the fly's turn. ROM test
|
||||||
|
fails on v0.4.2 and passes here. Trap hunt: 1 -> 260 tiles, windows under four tiles 73 -> 2.
|
||||||
|
Ships with map-aware walks. Ethos check held.
|
||||||
|
- 2026-09-22 (v0.4.3, loop review, auto): the watchdog flagged NEXT/BACK repeating 373 times in
|
||||||
|
ten brain minutes after v0.4.2. NEXT on the main battle menu confirmed FIGHT and opened the move
|
||||||
|
list, whose BACK closed it: a pair that undoes itself. NEXT is now off every pad with an
|
||||||
|
input-accepting cursor, MOVE 1 is the main menu backstop, the bag is the fly's turn. ROM test
|
||||||
|
fails on v0.4.2 and passes here. Trap hunt: 1 -> 260 tiles, windows under four tiles 73 -> 2.
|
||||||
|
Ships with map-aware walks. Ethos check held.
|
||||||
|
|
|
||||||
|
|
@ -1359,3 +1359,233 @@ Four things about it that are not improvements, recorded rather than buried.
|
||||||
- `infra/tests/lint.sh`: all checks passed, de-PII guard included.
|
- `infra/tests/lint.sh`: all checks passed, de-PII guard included.
|
||||||
- `--print-compatibility`: byte-identical to v0.4.1, 648 bytes, decoder / reward catalog / adapter
|
- `--print-compatibility`: byte-identical to v0.4.1, 648 bytes, decoder / reward catalog / adapter
|
||||||
version / roles untouched.
|
version / roles untouched.
|
||||||
|
|
||||||
|
## 2026-09-22, section 15: the walkable window becomes the whole map
|
||||||
|
|
||||||
|
The operator: "the frontier and warp macros need to be map aware: A* over walkable tiles."
|
||||||
|
`docs/design/macros.md` section 15 is the contract, `docs/design/macros-wram.md` section 9 the
|
||||||
|
bytes, and this is the measurement. Nothing about the *choice* moves: the scene deals the same
|
||||||
|
buttons and what changed is what a chosen walk knows about the ground.
|
||||||
|
|
||||||
|
**Row 35 has no source any more.** That row — "the screen-buffer tile read disagrees with itself on
|
||||||
|
a map smaller than the screen", worked around by taking a counter's reach from the map id — was a
|
||||||
|
fact about reading tiles out of a view that cannot centre on an 8x8 map. The grid reads the map's
|
||||||
|
own block data, so it answers the same way from every tile of every map. The counter rule is left
|
||||||
|
exactly as it is: this branch changes the ground, not the amenity, and a workaround that is no
|
||||||
|
longer needed is not the same thing as one that was wrong.
|
||||||
|
|
||||||
|
### The trap hunt, before and after
|
||||||
|
|
||||||
|
20 brain minutes, the hunt's own seed, 4 sweep threads, the same connectome, the same cartridge and
|
||||||
|
the same rung-9 checkpoint in both runs. "Before" is `main` at v0.4.2, which is the branch's own
|
||||||
|
merge base, so the two rows differ by this work and nothing else:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
FLY_ROM=".../Pokemon Red (U) [S][BF].gb" FLY_MACRO_BRAIN=data/fafb-v783 \
|
||||||
|
FLY_TRAP_CHECKPOINT=.local/checkpoints/release-rank9-20260922T0254.checkpoint \
|
||||||
|
FLY_TRAP_MINUTES=20 FLY_TRAP_MODE=macros cargo run --release -p flysim --example trap_hunt
|
||||||
|
```
|
||||||
|
|
||||||
|
| measure | before (`main`, v0.4.2) | after (this branch) |
|
||||||
|
| --- | ---: | ---: |
|
||||||
|
| distinct (map, tile) over 20 brain minutes | 286 | **489** |
|
||||||
|
| median distinct tiles in a flagged window | 16 | **55** |
|
||||||
|
| most tiles in any one window | 94 | **180** |
|
||||||
|
| macros started | 830 | 912 |
|
||||||
|
| done / blocked / timeout / refused | 726 / 97 / 6 / 0 | 820 / 90 / **1** / 8 |
|
||||||
|
| windows flagged | 61 of 73 | 70 of 73 |
|
||||||
|
| windows under 4 distinct tiles | 5 | 14 |
|
||||||
|
| frames in a battle (longest run) | 48,756 (9,549) | 50,413 (13,411) |
|
||||||
|
| frames in the overworld | 22,149 | 18,972 |
|
||||||
|
| `GO FRONTIER` done / timeout / mean net / max net tiles | 4 / 1 / 0.5 / 1 | **64** / 0 / 1.6 / **12** |
|
||||||
|
| `GO WARP` done / timeout / mean frames / max net | 14 / 2 / 403 / 30 | 4 / **0** / 751 / 26 |
|
||||||
|
| `GO ROUTE` done / timeout / mean frames / max net | 9 / 2 / 483 / 22 | 11 / 1 / 410 / 8 |
|
||||||
|
| `GO OBJECTIVE` done / timeout / max net | 3 / 1 / 8 | 5 / **0** / **42** |
|
||||||
|
| wall seconds on the development box | 1,390 | 932 |
|
||||||
|
|
||||||
|
**The ground is the measure and the ground moved.** 286 distinct tiles became 489; the median
|
||||||
|
flagged window holds 55 of them instead of 16 and the widest holds 180 instead of 94. `GO FRONTIER`
|
||||||
|
went from four walks worth a net tile each to sixty-four worth up to twelve, `GO OBJECTIVE`'s best
|
||||||
|
walk from eight net tiles to forty-two, and the only walk that spent its cap is one `GO ROUTE` that
|
||||||
|
gained forty-two tiles while doing it. Nothing timed out that used to arrive.
|
||||||
|
|
||||||
|
**The cost, named rather than buried: the flag count went the wrong way**, 61 windows to 70, and
|
||||||
|
the windows holding fewer than four tiles went 5 to 14. Every one of those is a battle. The fly
|
||||||
|
covers more ground, so it walks into more grass and more trainers: battle frames 48,756 to 50,413,
|
||||||
|
the longest single battle 9,549 frames to 13,411, overworld frames 22,149 down to 18,972. A
|
||||||
|
two-minute window spent inside one battle is a window with one tile in it, and the worst window of
|
||||||
|
the after-run is 83 macros on one tile with **no repeated sequence at all** — which is a battle,
|
||||||
|
not a loop. This file has recorded battle text as check 10's known false-positive shape since its
|
||||||
|
first section, and the `NEXT` x12 to x14 runs that flag 43 of the 70 windows are exactly it.
|
||||||
|
|
||||||
|
So the two halves of the usual reading disagree here for the first time, and this is the honest
|
||||||
|
statement of it: **more ground, more battle, more flags.** What the flag counts cannot show and the
|
||||||
|
tile counts can is that the fly is walking across maps instead of round the tile it is standing on.
|
||||||
|
|
||||||
|
### Gates
|
||||||
|
|
||||||
|
`cargo test --workspace`, `cargo clippy --all-targets` and `infra/tests/lint.sh` on the development
|
||||||
|
box. `flysim --print-compatibility` is byte-identical across the change: 648 bytes,
|
||||||
|
`0d9bfde7…707fa`, so the live checkpoint carries over.
|
||||||
|
## 2026-09-22, rows 42 and 43: the pair that undid itself
|
||||||
|
|
||||||
|
Thirty-five minutes after v0.4.2 deployed, the release watchdog flagged the same rung. Rank 9
|
||||||
|
(VIRIDIAN FOREST, next PEWTER CITY), seventy-one hours on it, and since the restart the macro
|
||||||
|
starts were `NEXT` **1264**, `BACK` **1241**, `THROW BALL` 5 and `GO WARP` 3, with the event log
|
||||||
|
alternating
|
||||||
|
|
||||||
|
```
|
||||||
|
NEXT start, NEXT done, BACK start, BACK done
|
||||||
|
```
|
||||||
|
|
||||||
|
every hold on map 51. It is the residual the previous review named and left -- "a `NEXT, BACK`
|
||||||
|
2-cycle x75 closes the run inside one battle... worth watching" -- at full scale, and the reason
|
||||||
|
that review's rule did not catch it is that **each of the two buttons was legitimate where it
|
||||||
|
stood**. `docs/design/macros.md` section 12.10 is the contract this closed against.
|
||||||
|
|
||||||
|
### The mechanism, measured
|
||||||
|
|
||||||
|
Twenty brain minutes from the release container's own rung-9 checkpoint, real cartridge, real
|
||||||
|
brain as the readout. The whole run -- **71,673 frames, all of them `battle`** -- was one battle,
|
||||||
|
on one tile, with 73 of 73 windows flagged and every window reading `NEXT, BACK` x74 or x75. The
|
||||||
|
two rows of the hunt's own sub-state table are the mechanism:
|
||||||
|
|
||||||
|
| macro start | battle sub-state | n |
|
||||||
|
| --- | --- | ---: |
|
||||||
|
| `BACK` | move list | **739** |
|
||||||
|
| `NEXT` | **main menu** | **739** |
|
||||||
|
| `NEXT` | move list, no cursor | 8 |
|
||||||
|
| `BACK` | party list | 1 |
|
||||||
|
| `MOVE 1` | main menu | 1 |
|
||||||
|
| `THROW BALL` | main menu | 1 |
|
||||||
|
|
||||||
|
1. **`NEXT` on the top-level battle menu is not the press that advances text.** `NEXT`'s script is
|
||||||
|
one press of A. Between turns that A advances the text box, which is the button's whole
|
||||||
|
purpose. On the top-level menu the same A press **confirms whatever the cursor sits on**, and
|
||||||
|
the cursor sits on FIGHT -- so `NEXT` *opened the move list*. It was put there as row 7's
|
||||||
|
backstop for a turn where every other button drops out, and it is the wrong button for that
|
||||||
|
job twice over: it does not end a turn, and what it does instead is reopen a list.
|
||||||
|
2. **`BACK` on the move list closed it again**, which is 12.9's contract and correct: backing out
|
||||||
|
of a list is one of exactly two answers to one. So neither rule was broken and the loop was a
|
||||||
|
*pair*: main menu -> `NEXT` -> move list -> `BACK` -> main menu, 739 times each, at 2.4 macros
|
||||||
|
per brain second, with the turn never resolving and the battle never ending. Section 12.2's
|
||||||
|
rule restated at the pad: **no pair of buttons on any battle pad may undo each other with
|
||||||
|
nothing else changing.**
|
||||||
|
3. **The bag was the last frame in the game with a cursor accepting input and no own turn.**
|
||||||
|
`Battle::own_turn` answered `false` for it (12.6 had no observable for the bag at all; 13.1 gave
|
||||||
|
it a cursor), so it landed on the between-turns row -- whose `NEXT` on an open bag is the A
|
||||||
|
press that *uses* whatever the cursor holds. Not what the live loop was made of, and the same
|
||||||
|
defect: an A press dealt where A does not advance text.
|
||||||
|
|
||||||
|
### What changed, all of it inside the macros
|
||||||
|
|
||||||
|
- **`NEXT` is off every own-turn pad**: the top-level menu, the move list, the party list and the
|
||||||
|
bag. It stays alone on the between-turns row and stays as row 8's backstop on the forced switch,
|
||||||
|
which is the one arm with a cursor up and no `BACK` at all, because it cannot be cancelled.
|
||||||
|
- **`MOVE 1` is the top-level menu's backstop instead**, and it is bound there whatever the seam
|
||||||
|
makes of `wBattleMon*`: the question over that menu is 12.8's "is there a move list to open",
|
||||||
|
FIGHT always opens, and `MOVE 1`'s script over that menu is "confirm FIGHT and stop", which reads
|
||||||
|
no move at all. A battler the seam cannot read is not a reason to take the turn's one ending
|
||||||
|
button away. Over an **open list** the per-slot PP rule is unchanged (row 30a, row 34).
|
||||||
|
- **The bag is the fly's turn** (`state::battle`), and its pad is the bag's own three answers:
|
||||||
|
`ITEM` (use what the cursor is on), `THROW BALL`, `BACK`. `CONFIRM` is gone from it -- the same
|
||||||
|
blind A press under another name -- and both scripts already navigated this list by reading its
|
||||||
|
cursor (`macros-wram.md` 7.1). The invariant is now whole: **a battle frame with a cursor
|
||||||
|
accepting input is the fly's turn**, the forced switch excepted because it has a pad of its own.
|
||||||
|
- **`wListMenuID` was checked for staleness rather than assumed sound**: it is zeroed by
|
||||||
|
`DisplayTextIDInit` at the start of every text display (`macros-wram.md`), so a battle's text
|
||||||
|
frames cannot inherit an `ITEMLISTMENU` from a bag the fly closed, and the bag reading stands.
|
||||||
|
- **The harness asks the scene the pad was dealt for, not `wIsInBattle`.** The `$ff` frame a lost
|
||||||
|
battle passes through reads `Unknown`, whose pad is `NEXT, BACK` by contract (row 9); the first
|
||||||
|
version of the new assertion accused that row of a battle rule it is not under.
|
||||||
|
|
||||||
|
| # | trap | trigger | test | fix, or why it is left |
|
||||||
|
| ---: | --- | --- | --- | --- |
|
||||||
|
| 42 | two buttons on a battle pad undo each other with nothing else changing: `NEXT` on the top-level menu opens the move list, `BACK` on the move list closes it | every wild battle, every turn -- 739 starts each in twenty brain minutes, one battle, 73 of 73 windows flagged | `no_battle_pad_holds_both_next_and_back`, `the_battle_row_is_the_move_buttons_switch_item_and_never_next`, `each_battle_menu_deals_its_own_pad`, `the_battles_turns_advance_from_the_rung_nine_forest_checkpoint` (ROM-gated: it **fails on v0.4.2** with `NEXT` dealt on `battle/main`) | **fixed**: `NEXT` is off every pad with a cursor accepting input, and `MOVE 1` is the top-level menu's backstop, bound there whatever the battler reads as. `NEXT` keeps the between-turns row and the forced switch |
|
||||||
|
| 43 | the battle bag reads as nobody's turn, so its pad is the between-turns `NEXT` -- an A press that *uses* what the cursor holds | choosing ITEM in a battle; rare, because `ITEM` needs a potion and a hurt Pokemon | `a_battle_frame_with_a_cursor_accepting_input_is_the_flys_turn`, `the_battle_bag_is_the_flys_turn_and_deals_its_own_two_uses`, `back_is_on_a_battle_pad_only_where_a_list_is_open` | **fixed**: `own_turn` is true for the bag, and its pad is `ITEM` / `THROW BALL` / `BACK`. This is row 30c closed -- it was left in 12.6 for want of an observable, given a cursor in 13.1, and given the right *turn* here |
|
||||||
|
|
||||||
|
### The ROM-gated run, before and after
|
||||||
|
|
||||||
|
Same test, same checkpoint, sixty-seven brain minutes of the game-blind rotation, `v0.4.2`
|
||||||
|
(`592c264`) against this branch.
|
||||||
|
|
||||||
|
| measure | before (v0.4.2) | after |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| verdict | **FAILED**: "`NEXT` was on the pad while a battle menu was accepting input" | **passed** |
|
||||||
|
| `NEXT` dealt on | `battle/between-turns`, **`battle/main`**, `battle/moves-unplaceable` | `battle/between-turns`, `battle/moves-unplaceable` |
|
||||||
|
| `BACK` dealt on | `battle/moves`, `battle/party` | `battle/bag`, `battle/moves`, `battle/party` |
|
||||||
|
| maps visited | **[51]** -- never left the forest | 51, 0, 12, 1, 41, 44, 42, 13, 50 (nine) |
|
||||||
|
| battles entered / ended | 14 / 15 | 26 / 25 |
|
||||||
|
| worst battle, in macros | not measurable: the run never left the fly's own turn | **275** |
|
||||||
|
| longest `NEXT`/`BACK` alternation | (the assertion fires first) | **1** |
|
||||||
|
| `MOVE 1..4` starts | 96 | **718** |
|
||||||
|
| `NEXT` / `BACK` starts | 1235 / 1315 of 3944 | 526 / 313 of 2090 |
|
||||||
|
| own-turn frames | 20,914 | 143,180 |
|
||||||
|
| `GO OBJECTIVE` on an overworld pad | map 51 only | nine maps |
|
||||||
|
|
||||||
|
### The trap hunt, before and after
|
||||||
|
|
||||||
|
Twenty brain minutes, seed 20260917, 4 sweep threads, the same connectome and the same cartridge,
|
||||||
|
from the release container's rung-9 checkpoint, **driven by the brain** rather than by
|
||||||
|
`FLY_TRAP_STUB`: this branch adds no macro type, so the thirty-one `macro_<type>` populations are
|
||||||
|
the same function in both arms and `--print-compatibility` is byte-identical at 648 bytes. The two
|
||||||
|
arms differ in the macro code and in nothing else.
|
||||||
|
|
||||||
|
| measure | before (v0.4.2) | after |
|
||||||
|
| --- | ---: | ---: |
|
||||||
|
| distinct (map, tile) | **1** | **260** |
|
||||||
|
| windows flagged | 73/73 | **68/73** |
|
||||||
|
| windows under 4 tiles | **73** | **2** |
|
||||||
|
| longest repeated sequence in a window | **`NEXT, BACK` x75** | `NEXT` x20 |
|
||||||
|
| macros started | 1489 | 797 |
|
||||||
|
| frames in `battle` | **71,673** (one battle, the whole run) | 50,600 |
|
||||||
|
| frames in `overworld` | **0** | **20,394** |
|
||||||
|
| `NEXT` starts on the main menu | **739** | **0** |
|
||||||
|
| `BACK` starts on the move list | 739 | 263 |
|
||||||
|
| `MOVE n` starts | **1** | **113** |
|
||||||
|
| `THROW BALL` starts | 1 | 63 |
|
||||||
|
| `RUN` starts | 0 | 7 |
|
||||||
|
|
||||||
|
The before arm never left the battle it resumed in and never left the tile it stood on. The after
|
||||||
|
arm fought fifty thousand frames of battle *and* walked twenty thousand frames of overworld across
|
||||||
|
260 tiles.
|
||||||
|
|
||||||
|
Raw reports: `hunt-before-20260922T0459.md` and `hunt-after-20260922T0459.md` in the coordination
|
||||||
|
state's `runs/` directory.
|
||||||
|
|
||||||
|
### Residuals, named rather than worked around
|
||||||
|
|
||||||
|
- **`NEXT` on a move list whose cursor the seam cannot place is now the largest source of it** --
|
||||||
|
142 of the after arm's 248 `NEXT` starts, over 8,687 frames. That frame is *correctly* not the
|
||||||
|
fly's turn (row 30b: `MoveSelectionMenu`'s coordinates appear before the engine has copied the
|
||||||
|
active Pokemon into `wBattleMon*`), so `NEXT` there is the between-turns press and it advances.
|
||||||
|
It is not a 2-cycle -- the ROM-gated run measures the longest `NEXT`/`BACK` alternation at
|
||||||
|
**1** -- but it is the same unplaceable cursor 12.6 named, and the honest fix is still a WRAM
|
||||||
|
reading rather than a pad change.
|
||||||
|
- **`BACK` is 326 of 797 starts**, all of them over an open list: 263 on the move list and 63 on a
|
||||||
|
party list. Row 34's contract, and where it leads is a menu with `MOVE 1` on it.
|
||||||
|
- **The 68 windows still flagged are the sequence rule's dominance arm, not a cycle.** They read
|
||||||
|
`NEXT` x11 to x20 or `BACK` x11 to x14 over windows holding **5 to 86** distinct tiles, where
|
||||||
|
every window of the before arm read `NEXT, BACK` x74 on **one** tile. Only two windows of the
|
||||||
|
after arm are under four tiles, against seventy-three before. A window spent in a long battle is
|
||||||
|
one tile by construction, which is the known false-positive shape of the tile rule and why this
|
||||||
|
row is proved on the pad and sub-state tables as well.
|
||||||
|
- **Row 41 is not reproduced here and is not closed.** The after arm's text boxes are 140 frames
|
||||||
|
at (6, 30) of map 0x33 and smaller counts on seven other tiles, with no Pokemon Center in the
|
||||||
|
run at all; the nurse's `YES` loop needs its own checkpoint to measure.
|
||||||
|
- **The road north is still reported rather than asserted.** The ROM-gated run leaves the forest
|
||||||
|
and reaches maps 50, 13, 12, 1, 41, 44, 42 and 0 with `GO OBJECTIVE` on the overworld pad of all
|
||||||
|
nine, but it does not reach map 47 -- the forest's north gate -- in sixty-seven brain minutes of
|
||||||
|
the game-blind rotation. Row 40's note stands.
|
||||||
|
|
||||||
|
### Gates
|
||||||
|
|
||||||
|
- `cargo test --workspace` with `FLY_ROM` set: green except
|
||||||
|
`flysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed`,
|
||||||
|
which fails identically on v0.4.2 on this box with the same assertion (a debug build of the
|
||||||
|
service does not finish booting inside the test's window here). Pre-existing and unrelated to
|
||||||
|
the macro layer.
|
||||||
|
- `cargo clippy --all-targets`: clean.
|
||||||
|
- `infra/tests/lint.sh`: all checks passed, de-PII guard included.
|
||||||
|
- `--print-compatibility`: **648 bytes, sha256 `0d9bfde7...707fa`** -- byte-identical to v0.4.1 and
|
||||||
|
v0.4.2. Decoder, reward catalog, adapter version and roles untouched.
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,37 @@ use crate::ratchet::RecoveryPolicy;
|
||||||
/// equivalent of the prototype's `MemoryReader` interface.
|
/// equivalent of the prototype's `MemoryReader` interface.
|
||||||
pub trait MemoryReader {
|
pub trait MemoryReader {
|
||||||
fn read8(&mut self, address: u16) -> u8;
|
fn read8(&mut self, address: u16) -> u8;
|
||||||
|
|
||||||
|
/// One byte of a ROM bank, by bank number rather than off the CPU bus.
|
||||||
|
///
|
||||||
|
/// [`MemoryReader::read8`] reads the bus, where banks 1 and up are whichever
|
||||||
|
/// bank the cartridge's last switch left mapped -- so a table in bank 3 is
|
||||||
|
/// unreadable through it, and the only way to make it readable would be to
|
||||||
|
/// *write* the mapper's bank register. The joypad is the only write this
|
||||||
|
/// workspace makes into a running game (`docs/design/macros.md` section 12),
|
||||||
|
/// so this reads the cartridge image the process already holds instead: the
|
||||||
|
/// same bytes, addressed the way the disassembly addresses them.
|
||||||
|
///
|
||||||
|
/// `address` is a CPU address: below `$4000` it is bank 0 whatever `bank`
|
||||||
|
/// says, and `$4000..$8000` is the banked window. Anything else, and any
|
||||||
|
/// offset past the end of the image, is `None`.
|
||||||
|
///
|
||||||
|
/// The default is `None`: a reader with no cartridge behind it cannot answer,
|
||||||
|
/// and every caller of this is written to narrow rather than guess when it
|
||||||
|
/// does not (`docs/design/macros-wram.md`, the whole-map grid).
|
||||||
|
fn read_rom(&mut self, _bank: u8, _address: u16) -> Option<u8> {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryReader for &mut dyn MemoryReader {
|
impl MemoryReader for &mut dyn MemoryReader {
|
||||||
fn read8(&mut self, address: u16) -> u8 {
|
fn read8(&mut self, address: u16) -> u8 {
|
||||||
(**self).read8(address)
|
(**self).read8(address)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_rom(&mut self, bank: u8, address: u16) -> Option<u8> {
|
||||||
|
(**self).read_rom(bank, address)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One reward payout in one frame.
|
/// One reward payout in one frame.
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@ pub const CPU_TICKS_PER_SECOND: u64 = 4_194_304;
|
||||||
/// so there is no `NEW_FRAME` event to wait for yet.
|
/// so there is no `NEW_FRAME` event to wait for yet.
|
||||||
const MAX_FRAME_ATTEMPTS: u32 = 120;
|
const MAX_FRAME_ATTEMPTS: u32 = 120;
|
||||||
|
|
||||||
|
/// Bytes in one ROM bank (`$4000`), which is how [`Emulator::read_rom_bank`]
|
||||||
|
/// turns a bank number and a CPU address into an offset in the cartridge image.
|
||||||
|
const ROM_BANK_BYTES: usize = 0x4000;
|
||||||
|
|
||||||
/// Audio frequency flysim runs the emulator at. binjgb resamples internally to
|
/// Audio frequency flysim runs the emulator at. binjgb resamples internally to
|
||||||
/// whatever is requested, so this is only a default.
|
/// whatever is requested, so this is only a default.
|
||||||
pub const DEFAULT_AUDIO_FREQUENCY: u32 = 48_000;
|
pub const DEFAULT_AUDIO_FREQUENCY: u32 = 48_000;
|
||||||
|
|
@ -135,6 +139,13 @@ impl FrameCache {
|
||||||
/// thread. It is deliberately not `Sync`.
|
/// thread. It is deliberately not `Sync`.
|
||||||
pub struct Emulator {
|
pub struct Emulator {
|
||||||
gb: *mut ffi::FlyGb,
|
gb: *mut ffi::FlyGb,
|
||||||
|
/// The cartridge image, for [`MemoryReader::read_rom`].
|
||||||
|
///
|
||||||
|
/// The shim owns its own padded copy behind the handle and does not hand it
|
||||||
|
/// back, so this is a second one. It is read-only from here: nothing in this
|
||||||
|
/// workspace writes a ROM byte, and the bank-addressed read is the only
|
||||||
|
/// reason it is kept.
|
||||||
|
rom: std::sync::Arc<[u8]>,
|
||||||
rom_sha256: [u8; 32],
|
rom_sha256: [u8; 32],
|
||||||
audio_frequency: u32,
|
audio_frequency: u32,
|
||||||
/// Raw binjgb samples drained since the last [`Emulator::take_audio`].
|
/// Raw binjgb samples drained since the last [`Emulator::take_audio`].
|
||||||
|
|
@ -167,6 +178,7 @@ impl Emulator {
|
||||||
debug_assert_eq!(unsafe { ffi::fly_gb_frame_buffer_size() }, FRAMEBUFFER_LEN);
|
debug_assert_eq!(unsafe { ffi::fly_gb_frame_buffer_size() }, FRAMEBUFFER_LEN);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
gb,
|
gb,
|
||||||
|
rom: rom.into(),
|
||||||
rom_sha256: Sha256::digest(rom).into(),
|
rom_sha256: Sha256::digest(rom).into(),
|
||||||
audio_frequency,
|
audio_frequency,
|
||||||
pending_audio: Vec::new(),
|
pending_audio: Vec::new(),
|
||||||
|
|
@ -242,6 +254,25 @@ impl Emulator {
|
||||||
unsafe { ffi::fly_gb_read_mem(self.gb, address) }
|
unsafe { ffi::fly_gb_read_mem(self.gb, address) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One byte of ROM bank `bank`, from the cartridge image rather than the bus.
|
||||||
|
///
|
||||||
|
/// `address` is a CPU address: `$0000..$4000` is bank 0 whatever `bank` says
|
||||||
|
/// (that is what "always mapped" means) and `$4000..$8000` is the banked
|
||||||
|
/// window. `None` for any other address and for an offset past the end of the
|
||||||
|
/// image, which is what a bank a smaller cartridge does not have reads as.
|
||||||
|
/// No bank register is written and the emulator's state does not move: this
|
||||||
|
/// is a read of bytes the process already owns.
|
||||||
|
pub fn read_rom_bank(&self, bank: u8, address: u16) -> Option<u8> {
|
||||||
|
let offset = match address {
|
||||||
|
0x0000..=0x3fff => usize::from(address),
|
||||||
|
0x4000..=0x7fff => {
|
||||||
|
usize::from(bank) * ROM_BANK_BYTES + usize::from(address) - ROM_BANK_BYTES
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
self.rom.get(offset).copied()
|
||||||
|
}
|
||||||
|
|
||||||
/// Sample rate of the raw buffer, as binjgb configured it.
|
/// Sample rate of the raw buffer, as binjgb configured it.
|
||||||
pub fn audio_frequency(&self) -> u32 {
|
pub fn audio_frequency(&self) -> u32 {
|
||||||
self.audio_frequency
|
self.audio_frequency
|
||||||
|
|
@ -364,6 +395,10 @@ impl MemoryReader for Emulator {
|
||||||
fn read8(&mut self, address: u16) -> u8 {
|
fn read8(&mut self, address: u16) -> u8 {
|
||||||
self.read_wram(address)
|
self.read_wram(address)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_rom(&mut self, bank: u8, address: u16) -> Option<u8> {
|
||||||
|
self.read_rom_bank(bank, address)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -32,12 +32,22 @@ pub const WALL_TILE: u8 = 0x60;
|
||||||
|
|
||||||
pub struct Wram {
|
pub struct Wram {
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
|
/// Fake cartridge banks, for the one read that needs one.
|
||||||
|
///
|
||||||
|
/// A bank nothing has written answers `None`, which is what a seam with no cartridge behind
|
||||||
|
/// it answers and what the whole-map grid has to narrow on
|
||||||
|
/// (`docs/design/macros.md` section 15).
|
||||||
|
rom: std::collections::HashMap<(u8, u16), u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryReader for Wram {
|
impl MemoryReader for Wram {
|
||||||
fn read8(&mut self, address: u16) -> u8 {
|
fn read8(&mut self, address: u16) -> u8 {
|
||||||
self.bytes[address as usize]
|
self.bytes[address as usize]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_rom(&mut self, bank: u8, address: u16) -> Option<u8> {
|
||||||
|
self.rom.get(&(bank, address)).copied()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Wram {
|
impl Default for Wram {
|
||||||
|
|
@ -49,7 +59,7 @@ impl Default for Wram {
|
||||||
impl Wram {
|
impl Wram {
|
||||||
/// All zero: the title screen, since nothing has set the game-timer bit.
|
/// All zero: the title screen, since nothing has set the game-timer bit.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self { bytes: vec![0; 0x1_0000] }
|
Self { bytes: vec![0; 0x1_0000], rom: std::collections::HashMap::new() }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set(&mut self, address: u16, value: u8) -> &mut Self {
|
pub fn set(&mut self, address: u16, value: u8) -> &mut Self {
|
||||||
|
|
@ -331,6 +341,78 @@ impl Wram {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// The ROM bank and address this fake keeps a tileset's blockset at.
|
||||||
|
///
|
||||||
|
/// Any non-zero bank: the point of the number is that it is *not* bank 0, because a bank the
|
||||||
|
/// CPU bus does not have mapped is the whole reason the seam grew
|
||||||
|
/// [`MemoryReader::read_rom`] (`docs/design/macros.md` section 15).
|
||||||
|
pub const BLOCKSET_BANK: u8 = 0x11;
|
||||||
|
pub const BLOCKSET_BASE: u16 = 0x4000;
|
||||||
|
|
||||||
|
/// Which tileset the loaded map uses, for the tile-pair collision lists.
|
||||||
|
pub fn tileset(&mut self, id: u8) -> &mut Self {
|
||||||
|
self.set(ram::wCurMapTileset, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tileset header's blockset, written where a cartridge keeps one: sixteen tile ids per
|
||||||
|
/// block, in block-id order, in a ROM bank that is not bank 0.
|
||||||
|
pub fn blockset(&mut self, blocks: &[[u8; 16]]) -> &mut Self {
|
||||||
|
for (id, block) in blocks.iter().enumerate() {
|
||||||
|
for (offset, tile) in block.iter().enumerate() {
|
||||||
|
let address = Self::BLOCKSET_BASE + (id * 16 + offset) as u16;
|
||||||
|
self.rom.insert((Self::BLOCKSET_BANK, address), *tile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.set(ram::wTilesetBank, Self::BLOCKSET_BANK)
|
||||||
|
.set(ram::wTilesetBlocksPtr, (Self::BLOCKSET_BASE & 0xff) as u8)
|
||||||
|
.set(ram::wTilesetBlocksPtr + 1, (Self::BLOCKSET_BASE >> 8) as u8)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The loaded map's block ids, as `LoadTileBlockMap` leaves them in `wOverworldMap`: rows of
|
||||||
|
/// `wCurMapWidth + MAP_BORDER * 2` bytes with the map itself three rows and three columns in.
|
||||||
|
///
|
||||||
|
/// `blocks` is row-major and `wCurMapWidth * wCurMapHeight` long; the border is left as
|
||||||
|
/// whatever it was, exactly as a map with no connections leaves it.
|
||||||
|
pub fn map_blocks(&mut self, blocks: &[u8]) -> &mut Self {
|
||||||
|
let width = u16::from(self.peek(ram::wCurMapWidth));
|
||||||
|
let height = u16::from(self.peek(ram::wCurMapHeight));
|
||||||
|
let border = crate::pokemon_red::mapgrid::MAP_BORDER as u16;
|
||||||
|
let stride = width + border * 2;
|
||||||
|
for row in 0..height {
|
||||||
|
for column in 0..width {
|
||||||
|
let index = usize::from(row * width + column);
|
||||||
|
let Some(block) = blocks.get(index) else { continue };
|
||||||
|
self.set(ram::wOverworldMap + (row + border) * stride + column + border, *block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the screen buffer so that it agrees with the block data, tile for tile.
|
||||||
|
///
|
||||||
|
/// The grid reader cross-checks its decode against the window predicate before it trusts it
|
||||||
|
/// ([`crate::pokemon_red::state::map_grid`]), and on a cartridge the two agree because they
|
||||||
|
/// are two readings of one map. This is that agreement in a fake: every map tile inside the
|
||||||
|
/// ten-by-nine window gets the tile id the blockset gives it, and the tiles outside it keep
|
||||||
|
/// whatever the screen held, which is what makes them `Unknown` to the window and answerable
|
||||||
|
/// only by the grid.
|
||||||
|
pub fn screen_from_blocks(&mut self, blocks: &[u8], blockset: &[[u8; 16]]) -> &mut Self {
|
||||||
|
let width = usize::from(self.peek(ram::wCurMapWidth));
|
||||||
|
let height = usize::from(self.peek(ram::wCurMapHeight));
|
||||||
|
for y in 0..height * 2 {
|
||||||
|
for x in 0..width * 2 {
|
||||||
|
let Some(block) = blocks.get((y / 2) * width + (x / 2)) else { continue };
|
||||||
|
let Some(tiles) = blockset.get(usize::from(*block)) else { continue };
|
||||||
|
// The lower-left tile of the map tile's own quadrant, which is the one the
|
||||||
|
// cartridge's collision read uses (`mapgrid::ANCHOR_ROW`).
|
||||||
|
let tile = tiles[((y % 2) * 2 + 1) * 4 + (x % 2) * 2];
|
||||||
|
self.map_tile(x as u8, y as u8, tile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// A playable overworld frame: Red's ground floor, the fly standing where a cold boot's walk
|
/// A playable overworld frame: Red's ground floor, the fly standing where a cold boot's walk
|
||||||
/// out of the bedroom lands it, every tile a wall until a test opens one.
|
/// out of the bedroom lands it, every tile a wall until a test opens one.
|
||||||
pub fn overworld() -> Self {
|
pub fn overworld() -> Self {
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
use crate::adapter::PlaceKind;
|
use crate::adapter::PlaceKind;
|
||||||
|
|
||||||
use super::geography::Amenity;
|
use super::geography::Amenity;
|
||||||
use super::state::{Facing, GameState};
|
use super::state::{Facing, GameState, MapGrid};
|
||||||
|
|
||||||
/// `constants/pokemon_data_constants.asm`: `PARTY_LENGTH`, how many Pokémon fit in the party.
|
/// `constants/pokemon_data_constants.asm`: `PARTY_LENGTH`, how many Pokémon fit in the party.
|
||||||
///
|
///
|
||||||
|
|
@ -315,6 +315,23 @@ pub trait MacroState: GameState {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The whole loaded map's walkability, when the cartridge's tables can be decoded.
|
||||||
|
///
|
||||||
|
/// `docs/design/macros.md` section 15, the operator 2026-09-22: "the frontier and warp macros
|
||||||
|
/// need to be map aware: A* over walkable tiles." [`GameState::walkable`] answers for the
|
||||||
|
/// ten-by-nine window of the screen buffer and [`super::state::Walkable::Unknown`] for
|
||||||
|
/// everything else, so before this every walk planned through guesses, re-planned at every
|
||||||
|
/// window edge, and `GO FRONTIER` aimed at whatever unstood ground happened to be on screen.
|
||||||
|
///
|
||||||
|
/// The default is `None`, which is this trait's usual narrowing and here it is also the
|
||||||
|
/// documented fallback: [`super::path::route`] and [`super::path::frontier`] use the window
|
||||||
|
/// predicate when the grid is absent, exactly as they did before, and
|
||||||
|
/// [`crate::pokemon_red::state::GridRefusal`] is what says why it is absent on a frame the
|
||||||
|
/// reader could not decode.
|
||||||
|
fn map_grid(&mut self) -> Option<std::sync::Arc<MapGrid>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether this run has already been into `area`'s mart or Pokémon Center.
|
/// Whether this run has already been into `area`'s mart or Pokémon Center.
|
||||||
///
|
///
|
||||||
/// Section 13's `areaVisited(kind, area)`: **one visit per area per run**, so the errand is
|
/// Section 13's `areaVisited(kind, area)`: **one visit per area per run**, so the errand is
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ use crate::macros::{
|
||||||
MacroPalette, Observed, Outcome, PaletteMode, RunLedger, SLOTS, SceneId, SlotBinding, Started,
|
MacroPalette, Observed, Outcome, PaletteMode, RunLedger, SLOTS, SceneId, SlotBinding, Started,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use super::super::mapgrid::MapGrids;
|
||||||
use super::super::state::PokeState;
|
use super::super::state::PokeState;
|
||||||
use super::cartridge::{Areas, MacroState, Pushed, Stood, Talked, Targets, Tile};
|
use super::cartridge::{Areas, MacroState, Pushed, Stood, Talked, Targets, Tile};
|
||||||
use super::geography;
|
use super::geography;
|
||||||
|
|
@ -70,6 +71,14 @@ pub struct PokemonPalette {
|
||||||
/// and walks the fly down on every frame it stands there until the Pokédex exists, and a map
|
/// and walks the fly down on every frame it stands there until the Pokédex exists, and a map
|
||||||
/// does not stop being like that ten brain minutes later.
|
/// does not stop being like that ten brain minutes later.
|
||||||
pushed: Pushed,
|
pushed: Pushed,
|
||||||
|
/// The decoded walkability of the map the fly is on (`docs/design/macros.md` section 15).
|
||||||
|
///
|
||||||
|
/// Owned here beside the session ledgers because it is the same shape of thing: built from
|
||||||
|
/// the cartridge, valid for as long as the map is loaded, dropped on arrival somewhere else,
|
||||||
|
/// and never checkpointed -- a restored run decodes the map again on its first overworld
|
||||||
|
/// frame. It is a *cache* rather than a ledger: nothing about the run is in it, only what the
|
||||||
|
/// cartridge's own tables say about the ground.
|
||||||
|
grids: MapGrids,
|
||||||
/// The brain clock of the frame being decided, from [`MacroPalette::clock`].
|
/// The brain clock of the frame being decided, from [`MacroPalette::clock`].
|
||||||
///
|
///
|
||||||
/// The blocked ledger is a *window*, so it needs the same clock the loop publishes rather
|
/// The blocked ledger is a *window*, so it needs the same clock the loop publishes rather
|
||||||
|
|
@ -93,6 +102,7 @@ impl PokemonPalette {
|
||||||
stood: Stood::default(),
|
stood: Stood::default(),
|
||||||
areas: Areas::default(),
|
areas: Areas::default(),
|
||||||
pushed: Pushed::default(),
|
pushed: Pushed::default(),
|
||||||
|
grids: MapGrids::default(),
|
||||||
now_ms: 0.0,
|
now_ms: 0.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -158,11 +168,13 @@ impl MacroPalette for PokemonPalette {
|
||||||
|
|
||||||
fn observe(&mut self, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger) -> Observed {
|
fn observe(&mut self, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger) -> Observed {
|
||||||
let (scene, bindings, standing) = {
|
let (scene, bindings, standing) = {
|
||||||
let Self { machine, mode, palette: cached, talked, targets, stood, areas, pushed, .. } =
|
let Self {
|
||||||
self;
|
machine, mode, palette: cached, talked, targets, stood, areas, pushed, grids, ..
|
||||||
|
} = self;
|
||||||
let mut state = PokeState::with_ledgers(
|
let mut state = PokeState::with_ledgers(
|
||||||
memory, ledger, talked, targets, &*stood, &*areas, &*pushed,
|
memory, ledger, talked, targets, &*stood, &*areas, &*pushed,
|
||||||
);
|
)
|
||||||
|
.caching_grid(grids);
|
||||||
// `GameState::scene` is `pokemon_red::scene::detect` over the same reader, so the
|
// `GameState::scene` is `pokemon_red::scene::detect` over the same reader, so the
|
||||||
// palette and the scene the feed reports cannot disagree about which frame they are
|
// palette and the scene the feed reports cannot disagree about which frame they are
|
||||||
// for.
|
// for.
|
||||||
|
|
@ -227,7 +239,8 @@ impl MacroPalette for PokemonPalette {
|
||||||
&self.stood,
|
&self.stood,
|
||||||
&self.areas,
|
&self.areas,
|
||||||
&self.pushed,
|
&self.pushed,
|
||||||
);
|
)
|
||||||
|
.caching_grid(&mut self.grids);
|
||||||
self.machine.start(&palette, slot, &mut state)
|
self.machine.start(&palette, slot, &mut state)
|
||||||
};
|
};
|
||||||
let started = match begun {
|
let started = match begun {
|
||||||
|
|
@ -267,7 +280,8 @@ impl MacroPalette for PokemonPalette {
|
||||||
&self.stood,
|
&self.stood,
|
||||||
&self.areas,
|
&self.areas,
|
||||||
&self.pushed,
|
&self.pushed,
|
||||||
);
|
)
|
||||||
|
.caching_grid(&mut self.grids);
|
||||||
self.machine.step(&mut state)
|
self.machine.step(&mut state)
|
||||||
};
|
};
|
||||||
// A macro that just finished may have been the press that talked to something; the ledger
|
// A macro that just finished may have been the press that talked to something; the ledger
|
||||||
|
|
|
||||||
|
|
@ -504,35 +504,44 @@ pub fn scene_set(scene: Scene, state: &mut dyn MacroState) -> Vec<MacroKind> {
|
||||||
// `SWITCH` needs a Pokémon to switch *to* and a forced switch with none leaves a menu the
|
// `SWITCH` needs a Pokémon to switch *to* and a forced switch with none leaves a menu the
|
||||||
// game will not let the fly cancel and a pad with nothing on it (row 8).
|
// game will not let the fly cancel and a pad with nothing on it (row 8).
|
||||||
Scene::Battle { forced_switch: true, .. } => vec![Switch, Next],
|
Scene::Battle { forced_switch: true, .. } => vec![Switch, Next],
|
||||||
// The fly's turn, by which menu of it is accepting input (section 12.6). `NEXT` is on none
|
// The fly's turn, by which menu of it is accepting input (section 12.6). **`NEXT` is on
|
||||||
// of the two lists: an A press on whatever the cursor happens to be sitting on is what the
|
// no own-turn pad at all** (section 12.10): it is the A that advances *text*, and on a menu
|
||||||
// live loop was made of.
|
// that is accepting input the same A press opens or confirms whatever the cursor happens to
|
||||||
|
// be sitting on, which is never one of the answers to that menu.
|
||||||
|
//
|
||||||
|
// Live on rung 9 after v0.4.2, 71 hours in Viridian Forest: `NEXT` 1264 macro starts and
|
||||||
|
// `BACK` 1241, the log alternating `NEXT start/done, BACK start/done` every hold. `NEXT` on
|
||||||
|
// the *top-level* menu pressed A on FIGHT and opened the move list; `BACK` on the *move
|
||||||
|
// list* closed it again; neither spent a turn, and the two of them were half the pad
|
||||||
|
// between them. Two buttons that undo each other with nothing else changing are section
|
||||||
|
// 12.2's trap spread over two sub-states of one turn.
|
||||||
Scene::Battle { own_turn: true, .. } => match battle_menu(state) {
|
Scene::Battle { own_turn: true, .. } => match battle_menu(state) {
|
||||||
BattleMenu::Moves { .. } => vec![Move1, Move2, Move3, Move4, Back],
|
BattleMenu::Moves { .. } => vec![Move1, Move2, Move3, Move4, Back],
|
||||||
BattleMenu::Party { .. } => vec![Switch, Back],
|
BattleMenu::Party { .. } => vec![Switch, Back],
|
||||||
// The bag reads as nobody's turn (`state::battle`), so it cannot land here; the arm
|
// The bag, which is the fly's turn since 12.10. Its three answers: use the thing the
|
||||||
// exists because `own_turn` is a boolean and this match is over the menu.
|
// cursor is on (`ITEM`), throw the ball (`THROW BALL`), or leave the list (`BACK`).
|
||||||
BattleMenu::Bag { .. } => vec![Confirm, Back],
|
// `CONFIRM` was an A press on whatever the cursor held, which is the same press
|
||||||
|
// `NEXT` was and reads nothing; the two scripts navigate the list's own cursor.
|
||||||
|
BattleMenu::Bag { .. } => vec![Item, ThrowBall, Back],
|
||||||
|
// `None` cannot land here -- `own_turn` is false for it -- and the arm exists because
|
||||||
|
// `own_turn` is a boolean and this match is over the menu. `MOVE 1` is what keeps this
|
||||||
|
// pad from ever being one the fly cannot end the turn from: FIGHT is one of these four
|
||||||
|
// entries and it always opens (section 12.8), which is the backstop `NEXT` was.
|
||||||
BattleMenu::Main { .. } | BattleMenu::None => {
|
BattleMenu::Main { .. } | BattleMenu::None => {
|
||||||
vec![Move1, Move2, Move3, Move4, Switch, Item, ThrowBall, Run, Next]
|
vec![Move1, Move2, Move3, Move4, Switch, Item, ThrowBall, Run]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// 2026-09-16 hotfix (live deadlock on Route 1): battle text between turns waits for a
|
// 2026-09-16 hotfix (live deadlock on Route 1): battle text between turns waits for a
|
||||||
// press exactly like a dialog. Section 13.1 added `BACK` here for the bag a battle's ITEM
|
// press exactly like a dialog.
|
||||||
// entry opens, which is none of `BattleMenu`'s three and reads as nobody's turn -- and
|
|
||||||
// section 12.9 narrows it back to that list.
|
|
||||||
//
|
//
|
||||||
// **`BACK` is a button only where there is a list to leave.** Live on rung 9, 69 hours in
|
// **One button, and it is the A that advances text.** Section 13.1 added `BACK` here for
|
||||||
// Viridian Forest: `BACK` was 135 of 183 macro starts since the restart, `RUN blocked,
|
// the bag, because a bag read as nobody's turn; 12.9 narrowed it back to the bag alone;
|
||||||
// BACK start, BACK done` over and over. Between turns there is nothing open to back out
|
// 12.10 moves the bag to the own turn where its cursor says it belongs, so nothing is open
|
||||||
// of, so the B press changes nothing the `NEXT` beside it does not, the macro completes on
|
// on this row any more and nothing but `NEXT` is on it. A frame that reaches here has no
|
||||||
// the tile it started on in a handful of frames, and the roll lands on it most holds while
|
// cursor accepting input -- text, an animation, a turn resolving -- so there is nothing to
|
||||||
// the turn does not move. That is section 12.2's trap exactly: a macro that completes
|
// back out of (`BACK` would be 12.2's trap, the rung-9 loop of 12.9) and nothing for an A
|
||||||
// without moving because its precondition is already satisfied where the fly stands.
|
// press to open (`NEXT` here cannot be the press that opened a menu, which is 12.10).
|
||||||
Scene::Battle { .. } => match battle_menu(state) {
|
Scene::Battle { .. } => vec![Next],
|
||||||
BattleMenu::Bag { .. } => vec![Next, Back],
|
|
||||||
_ => vec![Next],
|
|
||||||
},
|
|
||||||
// Section 13: the shop's buttons are the four purchases, plus the two answers any list has.
|
// Section 13: the shop's buttons are the four purchases, plus the two answers any list has.
|
||||||
Scene::Shop => vec![BuyPotion, BuyBall, BuyAntidote, BuyRepel, Confirm, Leave],
|
Scene::Shop => vec![BuyPotion, BuyBall, BuyAntidote, BuyRepel, Confirm, Leave],
|
||||||
// Section 13.1: `CONFIRM` as well as `LEAVE`, so a PC the fly opened is a list it can
|
// Section 13.1: `CONFIRM` as well as `LEAVE`, so a PC the fly opened is a list it can
|
||||||
|
|
@ -1579,7 +1588,18 @@ pub const fn move_index(kind: MacroKind) -> Option<u8> {
|
||||||
/// one at all -- which nothing in the game reaches -- is the one case that still answers `false`.
|
/// one at all -- which nothing in the game reaches -- is the one case that still answers `false`.
|
||||||
pub fn move_slot_bound(state: &mut dyn MacroState, kind: MacroKind) -> bool {
|
pub fn move_slot_bound(state: &mut dyn MacroState, kind: MacroKind) -> bool {
|
||||||
let Some(index) = move_index(kind) else { return false };
|
let Some(index) = move_index(kind) else { return false };
|
||||||
let Some(own) = state.battle().and_then(|battle| battle.own) else { return false };
|
let Some(battle) = state.battle() else { return false };
|
||||||
|
// Over the **top-level menu** the question is section 12.8's -- "is there a move list to
|
||||||
|
// open" -- and FIGHT always opens: what is behind it is the cartridge's business, because
|
||||||
|
// `CheckPlayerHasUsableMoves` prints "has no moves left!" and sets Struggle without opening
|
||||||
|
// the list. `MOVE 1`'s script over that menu is "confirm FIGHT and stop" and reads no move at
|
||||||
|
// all, so the button is bound there whatever the seam can make of the battler. That is the
|
||||||
|
// backstop `NEXT` used to be on this row (section 12.10): the own turn's main menu always has
|
||||||
|
// a button that ends the turn, and it is never one that merely reopens a list.
|
||||||
|
if index == 0 && matches!(battle.menu, BattleMenu::Main { .. }) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let Some(own) = battle.own else { return false };
|
||||||
let holds = |slot: usize| -> Option<&Move> {
|
let holds = |slot: usize| -> Option<&Move> {
|
||||||
own.moves.get(slot).and_then(|entry| entry.as_ref()).filter(|entry| entry.id != 0)
|
own.moves.get(slot).and_then(|entry| entry.as_ref()).filter(|entry| entry.id != 0)
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,16 +13,29 @@
|
||||||
//! minimum Manhattan distance to any goal. That stays admissible because a step costs one and
|
//! minimum Manhattan distance to any goal. That stays admissible because a step costs one and
|
||||||
//! moves one tile.
|
//! moves one tile.
|
||||||
//!
|
//!
|
||||||
//! **The walkable predicate has a window.** Agent A's [`Walkable::Unknown`] is load-bearing: the
|
//! **It plans over the whole map when the map can be decoded** (`docs/design/macros.md` section
|
||||||
//! tile ids a walkability test needs live in the screen buffer, so only the tiles around the
|
//! 15, the operator 2026-09-22: "the frontier and warp macros need to be map aware: A* over
|
||||||
//! player can be answered at all. An unknown tile is *expensive* to path through rather than
|
//! walkable tiles"). [`MacroState::map_grid`] is every tile of the loaded map, walkability and
|
||||||
//! forbidden ([`UNKNOWN_STEP`]): a known-walkable way round is always preferred, and the search
|
//! directed walls, decoded from the block and collision tables the cartridge has loaded
|
||||||
//! steps into the unknown only when nothing known gets any closer. That is what a map edge six
|
//! ([`crate::pokemon_red::mapgrid`]). With it, one plan crosses a town: `GO WARP` routes to its
|
||||||
//! tiles away needs — it is off the screen by definition, so a search that refused every unknown
|
//! warp tile, `GO OUT` and `GO ROUTE` to their door or connection tile, and the frontier is the
|
||||||
//! tile could not plan a single step toward Route 1 from the middle of Pallet Town, which is half
|
//! nearest unstood ground *anywhere on the map* rather than the nearest on screen.
|
||||||
//! of why the fly never took it (`infra/docs/macros-bench.md`, 2026-09-16). The guess is cheap and
|
//!
|
||||||
//! bounded: [`super::executor`] re-plans after every tile with a per-step check that the player
|
//! **Without it, the window is still the fallback.** Agent A's [`Walkable::Unknown`] is
|
||||||
//! moved, and three failed steps abort as `Blocked`.
|
//! load-bearing on a frame the grid cannot be decoded — no cartridge behind the seam, a battle or
|
||||||
|
//! a text box over the map, a header that is not loaded
|
||||||
|
//! ([`crate::pokemon_red::state::GridRefusal`] says which): the tile ids a walkability test needs
|
||||||
|
//! live in the screen buffer then, so only the tiles around the player can be answered at all. An
|
||||||
|
//! unknown tile is *expensive* to path through rather than forbidden ([`UNKNOWN_STEP`]): a
|
||||||
|
//! known-walkable way round is always preferred, and the search steps into the unknown only when
|
||||||
|
//! nothing known gets any closer. That is what a map edge six tiles away needed — it is off the
|
||||||
|
//! screen by definition, so a search that refused every unknown tile could not plan a single step
|
||||||
|
//! toward Route 1 from the middle of Pallet Town, which was half of why the fly never took it
|
||||||
|
//! (`infra/docs/macros-bench.md`, 2026-09-16).
|
||||||
|
//!
|
||||||
|
//! Either way the walk is re-planned only when the ground says so — a refusal, or the player not
|
||||||
|
//! where the plan expects it — which is [`super::executor`]'s committed route and not this
|
||||||
|
//! module's business.
|
||||||
//!
|
//!
|
||||||
//! The search still has its second answer for a goal it cannot reach at all: when no goal is
|
//! The search still has its second answer for a goal it cannot reach at all: when no goal is
|
||||||
//! reachable, it returns the route to the reachable tile that gets closest to one.
|
//! reachable, it returns the route to the reachable tile that gets closest to one.
|
||||||
|
|
@ -34,7 +47,24 @@ use super::cartridge::{
|
||||||
Edge, ExitId, LAST_MAP, MacroState, TalkTarget, Tile, destination_outdoors, outdoors,
|
Edge, ExitId, LAST_MAP, MacroState, TalkTarget, Tile, destination_outdoors, outdoors,
|
||||||
};
|
};
|
||||||
use super::geography;
|
use super::geography;
|
||||||
use super::state::{Facing, Walkable};
|
use super::state::{Facing, MapGrid, Walkable};
|
||||||
|
|
||||||
|
/// Whether the player could stand on a tile of the current map: the grid's answer, or the
|
||||||
|
/// window's when there is no grid.
|
||||||
|
///
|
||||||
|
/// One place asks the question so that the search, the exit list and the frontier cannot disagree
|
||||||
|
/// about which reading they are on (`docs/design/macros.md` section 15).
|
||||||
|
fn walkable_at(
|
||||||
|
state: &mut dyn MacroState,
|
||||||
|
grid: Option<&MapGrid>,
|
||||||
|
x: u8,
|
||||||
|
y: u8,
|
||||||
|
) -> Walkable {
|
||||||
|
match grid {
|
||||||
|
Some(grid) => grid.walkable(x, y),
|
||||||
|
None => state.walkable(x, y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What one step onto a tile the walkable predicate cannot answer for costs.
|
/// What one step onto a tile the walkable predicate cannot answer for costs.
|
||||||
///
|
///
|
||||||
|
|
@ -156,6 +186,10 @@ pub fn route_avoiding(
|
||||||
}
|
}
|
||||||
let player = state.player()?;
|
let player = state.player()?;
|
||||||
let size = state.map_size()?;
|
let size = state.map_size()?;
|
||||||
|
// One decode per plan, from the cache the sim loop keeps: with a grid the search is over the
|
||||||
|
// whole map, without one it is over the ten-by-nine window as it always was.
|
||||||
|
let grid = state.map_grid();
|
||||||
|
let grid = grid.as_deref();
|
||||||
let start = Tile::new(player.x, player.y);
|
let start = Tile::new(player.x, player.y);
|
||||||
if let Some(goal) = goals.iter().position(|tile| *tile == start) {
|
if let Some(goal) = goals.iter().position(|tile| *tile == start) {
|
||||||
return Some(Route { goal: Some(goal), steps: Vec::new() });
|
return Some(Route { goal: Some(goal), steps: Vec::new() });
|
||||||
|
|
@ -191,6 +225,12 @@ pub fn route_avoiding(
|
||||||
if refused.contains(&(tile, facing)) {
|
if refused.contains(&(tile, facing)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// A step the *tables* refuse: a tile-pair collision, which is passable ground on both
|
||||||
|
// sides and a wall between them (`mapgrid::TILE_PAIRS_LAND`). The walk used to learn
|
||||||
|
// each of these by spending a step on it; with the grid the first plan goes round.
|
||||||
|
if grid.is_some_and(|grid| grid.walled(tile.x, tile.y, facing)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// **A tile the cartridge pushes the fly off is not a tile to walk through**, either
|
// **A tile the cartridge pushes the fly off is not a tile to walk through**, either
|
||||||
// (row 37 of `infra/docs/macros-traps.md`). Excluding it as a *goal* was half the fix
|
// (row 37 of `infra/docs/macros-traps.md`). Excluding it as a *goal* was half the fix
|
||||||
// and the measurement said so: Viridian City's (19, 9) went from 53,266 text-box
|
// and the measurement said so: Viridian City's (19, 9) went from 53,266 text-box
|
||||||
|
|
@ -200,10 +240,11 @@ pub fn route_avoiding(
|
||||||
if next != start && state.pushed_tile(next.x, next.y) {
|
if next != start && state.pushed_tile(next.x, next.y) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let step = match state.walkable(next.x, next.y) {
|
let step = match walkable_at(state, grid, next.x, next.y) {
|
||||||
_ if next == start => 1,
|
_ if next == start => 1,
|
||||||
Walkable::Yes => 1,
|
Walkable::Yes => 1,
|
||||||
// Off the screen buffer: plausible ground, priced so that anything known beats it.
|
// Off the screen buffer, or a block the blockset was read short of: plausible
|
||||||
|
// ground, priced so that anything known beats it.
|
||||||
Walkable::Unknown => UNKNOWN_STEP,
|
Walkable::Unknown => UNKNOWN_STEP,
|
||||||
Walkable::No => continue,
|
Walkable::No => continue,
|
||||||
};
|
};
|
||||||
|
|
@ -334,6 +375,8 @@ pub fn target_at(state: &mut dyn MacroState, tile: Tile) -> Option<TalkTarget> {
|
||||||
pub fn exits(state: &mut dyn MacroState) -> Vec<Exit> {
|
pub fn exits(state: &mut dyn MacroState) -> Vec<Exit> {
|
||||||
let Some(size) = state.map_size() else { return Vec::new() };
|
let Some(size) = state.map_size() else { return Vec::new() };
|
||||||
let Some(player) = state.player() else { return Vec::new() };
|
let Some(player) = state.player() else { return Vec::new() };
|
||||||
|
let grid = state.map_grid();
|
||||||
|
let grid = grid.as_deref();
|
||||||
let here_outdoors = outdoors(player.map);
|
let here_outdoors = outdoors(player.map);
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for (index, warp) in state.warps().iter().enumerate() {
|
for (index, warp) in state.warps().iter().enumerate() {
|
||||||
|
|
@ -373,13 +416,16 @@ pub fn exits(state: &mut dyn MacroState) -> Vec<Exit> {
|
||||||
let way = if here_outdoors { Way::Route } else { Way::Exit };
|
let way = if here_outdoors { Way::Route } else { Way::Exit };
|
||||||
let into = geography::connected(player.map, edge);
|
let into = geography::connected(player.map, edge);
|
||||||
for tile in edge_tiles(facing, size.width, size.height) {
|
for tile in edge_tiles(facing, size.width, size.height) {
|
||||||
// Not `== Yes`: the walkable predicate's window is the screen, so the far edge of an
|
// Not `== Yes`: without a grid the walkable predicate's window is the screen, so the
|
||||||
// outdoor map reads `Unknown` from anywhere but next to it, and filtering on `Yes`
|
// far edge of an outdoor map reads `Unknown` from anywhere but next to it, and
|
||||||
// left a town's connections out of the exit list entirely -- which is half of why
|
// filtering on `Yes` left a town's connections out of the exit list entirely -- which
|
||||||
// nothing could ever aim at Route 1 from the middle of Pallet Town. An unknown tile is
|
// is half of why nothing could ever aim at Route 1 from the middle of Pallet Town. An
|
||||||
// a goal worth walking towards; the route search's own approach answer handles a goal
|
// unknown tile is a goal worth walking towards; the route search's own approach answer
|
||||||
// it cannot reach yet, and a tile that turns out to be a wall costs one blocked walk.
|
// handles a goal it cannot reach yet, and a tile that turns out to be a wall costs one
|
||||||
if state.walkable(tile.x, tile.y) != Walkable::No {
|
// blocked walk. With a grid the answer is `Yes` or `No` for every edge tile of the map
|
||||||
|
// and this rejects the walls, which is what lets one plan reach the right end of a
|
||||||
|
// connection instead of the nearest of twenty tiles along it.
|
||||||
|
if walkable_at(state, grid, tile.x, tile.y) != Walkable::No {
|
||||||
out.push(Exit { id: ExitId::Edge(edge), tile, press: Some(facing), way, into });
|
out.push(Exit { id: ExitId::Edge(edge), tile, press: Some(facing), way, into });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -396,9 +442,12 @@ pub fn exits(state: &mut dyn MacroState) -> Vec<Exit> {
|
||||||
/// Each answer is a tile to stand on paired with the direction the new ground lies in, which is
|
/// Each answer is a tile to stand on paired with the direction the new ground lies in, which is
|
||||||
/// the same shape `GO NPC` and `GO ITEM` use -- and the same press, which in the overworld walks
|
/// the same shape `GO NPC` and `GO ITEM` use -- and the same press, which in the overworld walks
|
||||||
/// onto the tile when it is walkable, so the frontier the fly is looking at becomes ground it has
|
/// onto the tile when it is walkable, so the frontier the fly is looking at becomes ground it has
|
||||||
/// stood on. Both tiles have to be walkable: an unreachable one is not ground, and the walkable
|
/// stood on. Both tiles have to be walkable: an unreachable one is not ground.
|
||||||
/// predicate's window means the answer is always local to the player, which is what makes the
|
///
|
||||||
/// re-plan after every tile do the work of a long walk.
|
/// **With a grid this is the whole map** (`docs/design/macros.md` section 15): the nearest unstood
|
||||||
|
/// walkable tile anywhere on it, which is what the operator asked for and what the route search
|
||||||
|
/// then plans one walk to. Without a grid it is what it always was -- the ten-by-nine window, so
|
||||||
|
/// the answer is local to the player and the long walk is done by re-planning.
|
||||||
///
|
///
|
||||||
/// Deduplicated by the tile to stand on, in tile order, so the choice between two equally near
|
/// Deduplicated by the tile to stand on, in tile order, so the choice between two equally near
|
||||||
/// frontiers does not depend on iteration order.
|
/// frontiers does not depend on iteration order.
|
||||||
|
|
@ -415,13 +464,15 @@ pub fn exits(state: &mut dyn MacroState) -> Vec<Exit> {
|
||||||
pub fn frontier(state: &mut dyn MacroState) -> Vec<(Tile, Facing)> {
|
pub fn frontier(state: &mut dyn MacroState) -> Vec<(Tile, Facing)> {
|
||||||
let Some(size) = state.map_size() else { return Vec::new() };
|
let Some(size) = state.map_size() else { return Vec::new() };
|
||||||
let Some(player) = state.player() else { return Vec::new() };
|
let Some(player) = state.player() else { return Vec::new() };
|
||||||
|
let grid = state.map_grid();
|
||||||
|
let grid = grid.as_deref();
|
||||||
let here = Tile::new(player.x, player.y);
|
let here = Tile::new(player.x, player.y);
|
||||||
let held: Vec<Tile> = state.npcs().iter().map(|npc| Tile::new(npc.x, npc.y)).collect();
|
let held: Vec<Tile> = state.npcs().iter().map(|npc| Tile::new(npc.x, npc.y)).collect();
|
||||||
let mut out: Vec<(Tile, Facing)> = Vec::new();
|
let mut out: Vec<(Tile, Facing)> = Vec::new();
|
||||||
for y in 0..size.height {
|
for y in 0..size.height {
|
||||||
for x in 0..size.width {
|
for x in 0..size.width {
|
||||||
let tile = Tile::new(x, y);
|
let tile = Tile::new(x, y);
|
||||||
if tile != here && state.walkable(x, y) != Walkable::Yes {
|
if tile != here && walkable_at(state, grid, x, y) != Walkable::Yes {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if tile != here && held.contains(&tile) {
|
if tile != here && held.contains(&tile) {
|
||||||
|
|
@ -432,7 +483,12 @@ pub fn frontier(state: &mut dyn MacroState) -> Vec<(Tile, Facing)> {
|
||||||
if next.x >= size.width || next.y >= size.height || next == here {
|
if next.x >= size.width || next.y >= size.height || next == here {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if state.walkable(next.x, next.y) != Walkable::Yes {
|
if walkable_at(state, grid, next.x, next.y) != Walkable::Yes {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A step the tables refuse is not a way onto that ground, so the tile it leads to
|
||||||
|
// is not this tile's frontier -- somebody else's, if anything reaches it.
|
||||||
|
if grid.is_some_and(|grid| grid.walled(tile.x, tile.y, facing)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if held.contains(&next) {
|
if held.contains(&next) {
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,8 @@ pub enum Scene {
|
||||||
Dialog,
|
Dialog,
|
||||||
/// Start menu or one of its submenus, outside battle.
|
/// Start menu or one of its submenus, outside battle.
|
||||||
Menu,
|
Menu,
|
||||||
/// In a battle. `own_turn` is the top-level FIGHT/PKMN/ITEM/RUN menu waiting for input;
|
/// In a battle. `own_turn` is any battle menu waiting for input -- the top-level
|
||||||
|
/// FIGHT/PKMN/ITEM/RUN one, the move list, the party list or the bag;
|
||||||
/// `forced_switch` is the party list the game opens when the active Pokémon has fainted, which
|
/// `forced_switch` is the party list the game opens when the active Pokémon has fainted, which
|
||||||
/// cannot be backed out of.
|
/// cannot be backed out of.
|
||||||
Battle { own_turn: bool, forced_switch: bool },
|
Battle { own_turn: bool, forced_switch: bool },
|
||||||
|
|
@ -234,10 +235,10 @@ pub enum BattleMenu {
|
||||||
/// The bag, opened from a battle's ITEM entry: `wListMenuID` is `ITEMLISTMENU`.
|
/// The bag, opened from a battle's ITEM entry: `wListMenuID` is `ITEMLISTMENU`.
|
||||||
///
|
///
|
||||||
/// Not one of the three `docs/design/macros.md` section 12.6 named, and the gap was
|
/// Not one of the three `docs/design/macros.md` section 12.6 named, and the gap was
|
||||||
/// observable: the bag list is not the top-level menu, so `own_turn` is false and the frame
|
/// observable twice over. First the list needed a *cursor* the scripts can read, which is what
|
||||||
/// reads as a battle between turns, whose pad is `NEXT` and `BACK` -- and `NEXT` was an A
|
/// `ITEM` and section 14's `THROW BALL` navigate by (2026-09-17). Then it needed to be the
|
||||||
/// press on whatever the cursor held. What the list needed is a *cursor* the scripts can read,
|
/// fly's *turn*: a bag reading as nobody's turn landed on the between-turns row, whose `NEXT`
|
||||||
/// which is what `ITEM` and section 14's `THROW BALL` navigate by (2026-09-17).
|
/// is the A that advances text and on an open bag is the A that uses an item (12.10).
|
||||||
Bag { cursor: u8, count: u8 },
|
Bag { cursor: u8, count: u8 },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -245,8 +246,9 @@ pub enum BattleMenu {
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Battle {
|
pub struct Battle {
|
||||||
pub kind: BattleKind,
|
pub kind: BattleKind,
|
||||||
/// A battle menu is open and waiting for the fly: the top-level one, the move list, or the
|
/// A battle menu is open and waiting for the fly: the top-level one, the move list, the bag,
|
||||||
/// party list outside a forced switch (`pokemon_red::state::battle`).
|
/// or the party list outside a forced switch (`pokemon_red::state::battle`). Every frame with a
|
||||||
|
/// cursor accepting input is one of these, which is section 12.10's invariant.
|
||||||
pub own_turn: bool,
|
pub own_turn: bool,
|
||||||
/// The party list is open because the active Pokémon fainted; it cannot be cancelled.
|
/// The party list is open because the active Pokémon fainted; it cannot be cancelled.
|
||||||
pub forced_switch: bool,
|
pub forced_switch: bool,
|
||||||
|
|
@ -392,6 +394,179 @@ impl Walkable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every tile of one map, answered: `docs/design/macros.md` section 15.
|
||||||
|
///
|
||||||
|
/// [`GameState::walkable`] answers about the ten-by-nine window of the screen buffer and
|
||||||
|
/// [`Walkable::Unknown`] elsewhere. This is the same predicate over the whole loaded map, decoded
|
||||||
|
/// from the block and collision tables the cartridge has loaded
|
||||||
|
/// ([`crate::pokemon_red::mapgrid`]), so a walk can be planned once instead of guessed at and
|
||||||
|
/// re-planned at every window edge.
|
||||||
|
///
|
||||||
|
/// It carries three things and no policy at all:
|
||||||
|
///
|
||||||
|
/// - the walkability of every tile, in map-tile coordinates -- the same unit the player's
|
||||||
|
/// coordinates, the warp table and the sign table are in;
|
||||||
|
/// - the tile id each answer came from, which is what the cross-check against the window
|
||||||
|
/// predicate compares and what the tile-pair rules are keyed on;
|
||||||
|
/// - **directed walls**: one step out of one tile in one direction that the cartridge refuses
|
||||||
|
/// although both tiles are passable. Pokered has two such rules and the tile-pair lists are the
|
||||||
|
/// one that can be read ahead of time; a ledge is already [`Walkable::No`] in the collision
|
||||||
|
/// list, and a person in the way is the sprite list's answer, not the ground's.
|
||||||
|
///
|
||||||
|
/// Nothing here is a fact about the run: no ledger, no visit, no target. Those stay where they
|
||||||
|
/// are, session state in the executor layer.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct MapGrid {
|
||||||
|
map: u8,
|
||||||
|
width: u8,
|
||||||
|
height: u8,
|
||||||
|
/// Row-major, `width * height`, [`Walkable::Unknown`] until [`MapGrid::set`] says otherwise.
|
||||||
|
tiles: Vec<Walkable>,
|
||||||
|
/// Row-major screen tile id per map tile, `None` where the tile was never decoded.
|
||||||
|
ids: Vec<Option<u8>>,
|
||||||
|
/// Row-major bitmask of the directions a step out of this tile is refused in
|
||||||
|
/// ([`MapGrid::wall`]).
|
||||||
|
walls: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MapGrid {
|
||||||
|
/// An all-[`Walkable::Unknown`] grid of this size, for a decoder to fill in.
|
||||||
|
pub fn new(map: u8, width: u8, height: u8) -> Self {
|
||||||
|
let cells = usize::from(width) * usize::from(height);
|
||||||
|
Self {
|
||||||
|
map,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
tiles: vec![Walkable::Unknown; cells],
|
||||||
|
ids: vec![None; cells],
|
||||||
|
walls: vec![0; cells],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which map this grid is of. A grid is only ever valid for the loaded map.
|
||||||
|
pub fn map(&self) -> u8 {
|
||||||
|
self.map
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn width(&self) -> u8 {
|
||||||
|
self.width
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn height(&self) -> u8 {
|
||||||
|
self.height
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index(&self, x: u8, y: u8) -> Option<usize> {
|
||||||
|
(x < self.width && y < self.height)
|
||||||
|
.then(|| usize::from(y) * usize::from(self.width) + usize::from(x))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a decoded tile: its screen tile id and what the collision list makes of it.
|
||||||
|
pub fn set(&mut self, x: u8, y: u8, tile: u8, walkable: Walkable) {
|
||||||
|
if let Some(index) = self.index(x, y) {
|
||||||
|
self.tiles[index] = walkable;
|
||||||
|
self.ids[index] = Some(tile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record that the cartridge refuses the step out of `(x, y)` in `facing`.
|
||||||
|
pub fn wall(&mut self, x: u8, y: u8, facing: Facing) {
|
||||||
|
if let Some(index) = self.index(x, y) {
|
||||||
|
self.walls[index] |= wall_bit(facing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the player could stand on this tile. Off the map is [`Walkable::No`], which is the
|
||||||
|
/// window predicate's own answer for it.
|
||||||
|
pub fn walkable(&self, x: u8, y: u8) -> Walkable {
|
||||||
|
match self.index(x, y) {
|
||||||
|
None => Walkable::No,
|
||||||
|
Some(index) => self.tiles[index],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The screen tile id this tile's answer came from, or `None` for a tile never decoded.
|
||||||
|
pub fn tile_id(&self, x: u8, y: u8) -> Option<u8> {
|
||||||
|
self.index(x, y).and_then(|index| self.ids[index])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the step out of `(x, y)` in `facing` is one the cartridge refuses.
|
||||||
|
pub fn walled(&self, x: u8, y: u8, facing: Facing) -> bool {
|
||||||
|
self.index(x, y).is_some_and(|index| self.walls[index] & wall_bit(facing) != 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many tiles of the map the player could stand on.
|
||||||
|
pub fn walkable_count(&self) -> usize {
|
||||||
|
self.tiles.iter().filter(|tile| tile.is_walkable()).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many tiles the map has that were never decoded, i.e. [`Walkable::Unknown`].
|
||||||
|
pub fn unknown_count(&self) -> usize {
|
||||||
|
self.tiles.iter().filter(|tile| matches!(tile, Walkable::Unknown)).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every walkable tile, in row-major order, as `(x, y)`.
|
||||||
|
pub fn walkable_tiles(&self) -> Vec<(u8, u8)> {
|
||||||
|
let mut out = Vec::with_capacity(self.walkable_count());
|
||||||
|
for y in 0..self.height {
|
||||||
|
for x in 0..self.width {
|
||||||
|
if self.walkable(x, y).is_walkable() {
|
||||||
|
out.push((x, y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many walkable tiles a walk from `(x, y)` could reach, the directed walls respected.
|
||||||
|
///
|
||||||
|
/// The starting tile counts whether or not it is walkable, for the same reason the route
|
||||||
|
/// search treats it as passable: the fly is standing on it. What this number is *for* is
|
||||||
|
/// reading a stalled walk at a glance -- a fly on a route with 600 walkable tiles and 4
|
||||||
|
/// reachable ones is fenced in, and no amount of re-planning is going to help it
|
||||||
|
/// (`docs/design/macros.md` section 15, `examples/scene_probe.rs`).
|
||||||
|
pub fn reachable_from(&self, x: u8, y: u8) -> usize {
|
||||||
|
if self.index(x, y).is_none() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let mut seen = vec![false; self.tiles.len()];
|
||||||
|
let mut queue = std::collections::VecDeque::new();
|
||||||
|
if let Some(index) = self.index(x, y) {
|
||||||
|
seen[index] = true;
|
||||||
|
}
|
||||||
|
queue.push_back((x, y));
|
||||||
|
let mut count = 0;
|
||||||
|
while let Some((tx, ty)) = queue.pop_front() {
|
||||||
|
count += 1;
|
||||||
|
for facing in [Facing::Up, Facing::Down, Facing::Left, Facing::Right] {
|
||||||
|
if self.walled(tx, ty, facing) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (dx, dy) = facing.delta();
|
||||||
|
let Ok(nx) = u8::try_from(i16::from(tx) + dx) else { continue };
|
||||||
|
let Ok(ny) = u8::try_from(i16::from(ty) + dy) else { continue };
|
||||||
|
let Some(index) = self.index(nx, ny) else { continue };
|
||||||
|
if seen[index] || !self.tiles[index].is_walkable() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen[index] = true;
|
||||||
|
queue.push_back((nx, ny));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which bit of a [`MapGrid`] wall mask a direction is.
|
||||||
|
fn wall_bit(facing: Facing) -> u8 {
|
||||||
|
match facing {
|
||||||
|
Facing::Up => 1,
|
||||||
|
Facing::Down => 2,
|
||||||
|
Facing::Left => 4,
|
||||||
|
Facing::Right => 8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One entry of the current map's warp table: a door, a staircase, a cave mouth.
|
/// One entry of the current map's warp table: a door, a staircase, a cave mouth.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct Warp {
|
pub struct Warp {
|
||||||
|
|
|
||||||
|
|
@ -924,17 +924,22 @@ fn the_menu_row_is_close_confirm_back() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_battle_row_is_the_move_buttons_switch_item_and_the_backstop() {
|
fn the_battle_row_is_the_move_buttons_switch_item_and_never_next() {
|
||||||
let mut world = World::battle();
|
let mut world = World::battle();
|
||||||
world.bag = vec![(item::POTION, 1)];
|
world.bag = vec![(item::POTION, 1)];
|
||||||
let scene = world.scene();
|
let scene = world.scene();
|
||||||
// Section 13.1: `RUN` is bound only in a wild battle the fly is *losing*, and `World::battle`
|
// Section 13.1: `RUN` is bound only in a wild battle the fly is *losing*, and `World::battle`
|
||||||
// has an eighteen-of-twenty Pokémon on the bench -- something healthier to send in, so the
|
// has an eighteen-of-twenty Pokémon on the bench -- something healthier to send in, so the
|
||||||
// fight is still worth having and the slot is empty.
|
// fight is still worth having and the slot is empty.
|
||||||
|
//
|
||||||
|
// Section 12.10: **`NEXT` is gone from this row.** It was the backstop for a turn where every
|
||||||
|
// other button dropped, and it was an A press on the cursor -- which sits on FIGHT, so it
|
||||||
|
// opened the move list, whose `BACK` closed it again: `NEXT` 1264 starts and `BACK` 1241 on
|
||||||
|
// rung 9 after v0.4.2. `MOVE 1` is the backstop now, and it ends the turn.
|
||||||
let palette = Palette::for_scene(scene, &mut world);
|
let palette = Palette::for_scene(scene, &mut world);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
names(&palette),
|
names(&palette),
|
||||||
["NEXT", "MOVE 1", "MOVE 2", "MOVE 3", "SWITCH", "ITEM"],
|
["MOVE 1", "MOVE 2", "MOVE 3", "SWITCH", "ITEM"],
|
||||||
"three moves with PP, an empty fourth slot, a healthy bench and a potion"
|
"three moves with PP, an empty fourth slot, a healthy bench and a potion"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -943,15 +948,17 @@ fn the_battle_row_is_the_move_buttons_switch_item_and_the_backstop() {
|
||||||
let scene = world.scene();
|
let scene = world.scene();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
names(&Palette::for_scene(scene, &mut world)),
|
names(&Palette::for_scene(scene, &mut world)),
|
||||||
["NEXT", "MOVE 1", "MOVE 2", "MOVE 3", "ITEM", "RUN"]
|
["MOVE 1", "MOVE 2", "MOVE 3", "ITEM", "RUN"]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_move_button_is_bound_by_its_own_slots_pp_and_move_one_carries_struggle() {
|
fn a_move_button_is_bound_by_its_own_slots_pp_and_move_one_carries_struggle() {
|
||||||
// Section 14: one button per move slot. `World::battle`'s Pokémon has three moves and an empty
|
// Section 14: one button per move slot, asked **over the open move list**, which is where a
|
||||||
// fourth slot, so three buttons are on the pad and the fourth never is.
|
// slot is a thing to aim at. `World::battle`'s Pokémon has three moves and an empty fourth
|
||||||
|
// slot, so three buttons are on the list's pad and the fourth never is.
|
||||||
let mut world = World::battle();
|
let mut world = World::battle();
|
||||||
|
world.list = List::Moves(3);
|
||||||
assert!(move_slot_bound(&mut world, MacroKind::Move1));
|
assert!(move_slot_bound(&mut world, MacroKind::Move1));
|
||||||
assert!(move_slot_bound(&mut world, MacroKind::Move2));
|
assert!(move_slot_bound(&mut world, MacroKind::Move2));
|
||||||
assert!(move_slot_bound(&mut world, MacroKind::Move3));
|
assert!(move_slot_bound(&mut world, MacroKind::Move3));
|
||||||
|
|
@ -975,12 +982,25 @@ fn a_move_button_is_bound_by_its_own_slots_pp_and_move_one_carries_struggle() {
|
||||||
.is_some()
|
.is_some()
|
||||||
);
|
);
|
||||||
|
|
||||||
// A Pokémon with no move in slot one at all is the one case that answers no. Nothing in the
|
// A Pokémon with no move in slot one at all answers no over the list: there is no slot to aim
|
||||||
// game reaches it, and inventing a press for it is what this crate does not do.
|
// at, and inventing a press for it is what this crate does not do.
|
||||||
world.mons[0] = mon(0, 4, 20, &[]);
|
world.mons[0] = mon(0, 4, 20, &[]);
|
||||||
assert!(!move_slot_bound(&mut world, MacroKind::Move1));
|
assert!(!move_slot_bound(&mut world, MacroKind::Move1));
|
||||||
let scene = world.scene();
|
let scene = world.scene();
|
||||||
assert_eq!(Palette::for_scene(scene, &mut world).slot(MacroId(MacroKind::Move1.slot())), None);
|
assert_eq!(Palette::for_scene(scene, &mut world).slot(MacroId(MacroKind::Move1.slot())), None);
|
||||||
|
|
||||||
|
// Over the **top-level menu** the question is a different one -- section 12.8's "is there a
|
||||||
|
// move list to open" -- and FIGHT always opens, so `MOVE 1` is bound there whatever the seam
|
||||||
|
// makes of the battler. That is what carries the turn now that `NEXT` is off this row
|
||||||
|
// (section 12.10): the script over this menu is "confirm FIGHT and stop" and reads no move.
|
||||||
|
world.list = List::BattleMain;
|
||||||
|
assert!(move_slot_bound(&mut world, MacroKind::Move1), "FIGHT is always pressable");
|
||||||
|
assert!(!move_slot_bound(&mut world, MacroKind::Move2), "and it is MOVE 1 that carries it");
|
||||||
|
world.active = None;
|
||||||
|
assert!(
|
||||||
|
move_slot_bound(&mut world, MacroKind::Move1),
|
||||||
|
"a battler the seam cannot read is not a reason to take the turn's one button away"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1097,16 +1117,21 @@ fn a_battle_frame_that_is_not_the_players_turn_binds_next_to_advance_its_text()
|
||||||
world.list = List::None;
|
world.list = List::None;
|
||||||
let scene = world.scene();
|
let scene = world.scene();
|
||||||
// Section 12.9: `NEXT` alone. Section 13.1 put `BACK` here for the bag a battle's ITEM entry
|
// Section 12.9: `NEXT` alone. Section 13.1 put `BACK` here for the bag a battle's ITEM entry
|
||||||
// opens, which reads as nobody's turn -- but on a frame of text there is no list to leave, and
|
// opens, which read as nobody's turn -- but on a frame of text there is no list to leave, and
|
||||||
// a `BACK` that changes nothing is the trap of section 12.2 (live, rung 9: 135 of 183 macro
|
// a `BACK` that changes nothing is the trap of section 12.2 (live, rung 9: 135 of 183 macro
|
||||||
// starts).
|
// starts).
|
||||||
let palette = Palette::for_scene(scene, &mut world);
|
let palette = Palette::for_scene(scene, &mut world);
|
||||||
assert_eq!(names(&palette), ["NEXT"]);
|
assert_eq!(names(&palette), ["NEXT"]);
|
||||||
|
|
||||||
// The bag is the one sub-state on this arm with a list open in it, and it keeps `BACK`.
|
// Section 12.10: the bag does not land on this arm any more. It is a cursor accepting input,
|
||||||
|
// so it is the fly's turn, and its `NEXT` would have been the A that *uses* what the cursor
|
||||||
|
// holds rather than the A that advances text. Nothing is open here, so nothing but `NEXT` is.
|
||||||
world.list = List::BattleBag;
|
world.list = List::BattleBag;
|
||||||
|
world.battle = Some((BattleKind::Wild, true, false));
|
||||||
|
world.scene = Scene::Battle { own_turn: true, forced_switch: false };
|
||||||
|
world.bag = vec![(item::POTION, 1)];
|
||||||
let palette = Palette::for_scene(world.scene(), &mut world);
|
let palette = Palette::for_scene(world.scene(), &mut world);
|
||||||
assert_eq!(names(&palette), ["NEXT", "BACK"]);
|
assert_eq!(names(&palette), ["BACK", "ITEM"], "a hurt Pokémon, a potion, and no ball");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -2356,10 +2381,11 @@ fn the_battle_plan_attacks_first_and_switches_only_under_a_quarter() {
|
||||||
let mut healthy = World::battle();
|
let mut healthy = World::battle();
|
||||||
healthy.mons[0] = mon(0, 20, 20, &[(33, 30)]);
|
healthy.mons[0] = mon(0, 20, 20, &[(33, 30)]);
|
||||||
assert!(!plan::failing(&mut healthy));
|
assert!(!plan::failing(&mut healthy));
|
||||||
// `NEXT` is unconditional: a battle frame always has a press that advances it, which is what
|
// `MOVE 1` is what keeps the own-turn pad from being empty when every other button drops out:
|
||||||
// keeps the own-turn pad from being empty when every other button drops out (2026-09-17).
|
// FIGHT is one of this menu's four entries and it always opens (12.8), where the `NEXT` that
|
||||||
// `ITEM` needs a potion and low HP, so it is absent here; this Pokémon has one move.
|
// used to carry the job merely reopened the list `BACK` had just closed (12.10). `ITEM` needs
|
||||||
assert_eq!(plan(&mut healthy), ["NEXT", "MOVE 1", "SWITCH"]);
|
// a potion and low HP, so it is absent here; this Pokémon has one move.
|
||||||
|
assert_eq!(plan(&mut healthy), ["MOVE 1", "SWITCH"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -2372,7 +2398,7 @@ fn the_battle_plan_runs_from_a_wild_battle_only_when_the_whole_party_is_weak() {
|
||||||
// the next hit ends the battle, which is the state section 9 puts `RUN` in.
|
// the next hit ends the battle, which is the state section 9 puts `RUN` in.
|
||||||
world.mons = vec![mon(0, 4, 20, &[(33, 30)]), mon(1, 3, 20, &[(33, 30)])];
|
world.mons = vec![mon(0, 4, 20, &[(33, 30)]), mon(1, 3, 20, &[(33, 30)])];
|
||||||
assert!(plan::party_weak(&mut world));
|
assert!(plan::party_weak(&mut world));
|
||||||
assert_eq!(plan(&mut world), ["NEXT", "MOVE 1", "SWITCH", "RUN"]);
|
assert_eq!(plan(&mut world), ["MOVE 1", "SWITCH", "RUN"]);
|
||||||
|
|
||||||
// A trainer battle has no RUN at all, in the plan or in the palette.
|
// A trainer battle has no RUN at all, in the plan or in the palette.
|
||||||
world.battle = Some((BattleKind::Trainer, true, false));
|
world.battle = Some((BattleKind::Trainer, true, false));
|
||||||
|
|
@ -3244,7 +3270,11 @@ fn each_battle_menu_deals_its_own_pad() {
|
||||||
let pad = names(&plan::plan_for(main.scene(), &mut main));
|
let pad = names(&plan::plan_for(main.scene(), &mut main));
|
||||||
assert!(pad.contains(&"MOVE 1"), "{pad:?}");
|
assert!(pad.contains(&"MOVE 1"), "{pad:?}");
|
||||||
assert!(pad.contains(&"SWITCH"), "{pad:?}");
|
assert!(pad.contains(&"SWITCH"), "{pad:?}");
|
||||||
assert!(pad.contains(&"NEXT"), "the top-level menu keeps its backstop: {pad:?}");
|
// Section 12.10: the top-level menu is a list accepting input too, so `NEXT` is off it as
|
||||||
|
// well. Its backstop is `MOVE 1`, which is bound here whatever the battler reads as, because
|
||||||
|
// FIGHT always opens (12.8) -- and unlike `NEXT` it ends the turn instead of opening the list
|
||||||
|
// that `BACK` closes again.
|
||||||
|
assert!(!pad.contains(&"NEXT"), "never NEXT on a menu accepting input: {pad:?}");
|
||||||
// `ITEM` and `RUN` have their own preconditions -- a potion, and a party with nothing healthy
|
// `ITEM` and `RUN` have their own preconditions -- a potion, and a party with nothing healthy
|
||||||
// left -- and this fixture satisfies neither; row 7 covers the turn where all four drop.
|
// left -- and this fixture satisfies neither; row 7 covers the turn where all four drop.
|
||||||
|
|
||||||
|
|
@ -3321,11 +3351,8 @@ fn back_is_on_a_battle_pad_only_where_a_list_is_open() {
|
||||||
let mut party = World::battle();
|
let mut party = World::battle();
|
||||||
party.list = List::BattleParty;
|
party.list = List::BattleParty;
|
||||||
with_back(&mut party);
|
with_back(&mut party);
|
||||||
// The bag reads as nobody's turn (`state::battle`), so it lands on the between-turns arm --
|
// The bag, which is the fly's own turn since section 12.10 because its cursor accepts input.
|
||||||
// and it is the one sub-state there with a list open in it.
|
|
||||||
let mut bag = World::battle();
|
let mut bag = World::battle();
|
||||||
bag.scene = Scene::Battle { own_turn: false, forced_switch: false };
|
|
||||||
bag.battle = Some((BattleKind::Wild, false, false));
|
|
||||||
bag.list = List::BattleBag;
|
bag.list = List::BattleBag;
|
||||||
with_back(&mut bag);
|
with_back(&mut bag);
|
||||||
|
|
||||||
|
|
@ -3344,6 +3371,93 @@ fn back_is_on_a_battle_pad_only_where_a_list_is_open() {
|
||||||
without(&mut forced);
|
without(&mut forced);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Section 12.10: **no battle pad deals a pair of buttons that undo each other.**
|
||||||
|
///
|
||||||
|
/// Live on rung 9 after v0.4.2, 71 hours in Viridian Forest: `NEXT` 1264 macro starts, `BACK`
|
||||||
|
/// 1241, and the event log alternating `NEXT start/done, BACK start/done` every hold on map 51.
|
||||||
|
/// The pair was split across two sub-states of one turn -- `NEXT` on the top-level menu was an A
|
||||||
|
/// press on FIGHT, which opened the move list, and `BACK` on the move list closed it again -- so
|
||||||
|
/// neither the pad rule of 12.9 nor the "no `BACK` without a list" rule caught it: both buttons
|
||||||
|
/// were legitimate where they stood, and between them they were a 2-cycle that never spent a turn.
|
||||||
|
///
|
||||||
|
/// The rule that closes it is about the pair rather than about either button: `NEXT` is the A that
|
||||||
|
/// advances **text**, so it belongs only on a frame with no cursor accepting input, and `BACK` is
|
||||||
|
/// the B that leaves a **list**, so it belongs only on a frame that has one. The two conditions are
|
||||||
|
/// exclusive, so no pad can hold both -- and the forced switch, which keeps `NEXT` as row 8's
|
||||||
|
/// backstop, is the one arm with a cursor and no `BACK` at all, because it cannot be cancelled.
|
||||||
|
#[test]
|
||||||
|
fn no_battle_pad_holds_both_next_and_back() {
|
||||||
|
// Every battle sub-state the seam can report, on a turn where every precondition is satisfied
|
||||||
|
// (a potion, a ball, a hurt Pokémon, a bench) and on one where none is.
|
||||||
|
let sub_states = [
|
||||||
|
(Scene::Battle { own_turn: true, forced_switch: false }, true, false, List::BattleMain),
|
||||||
|
(Scene::Battle { own_turn: true, forced_switch: false }, true, false, List::Moves(3)),
|
||||||
|
(Scene::Battle { own_turn: true, forced_switch: false }, true, false, List::BattleParty),
|
||||||
|
(Scene::Battle { own_turn: true, forced_switch: false }, true, false, List::BattleBag),
|
||||||
|
(Scene::Battle { own_turn: false, forced_switch: false }, false, false, List::None),
|
||||||
|
(Scene::Battle { own_turn: false, forced_switch: true }, false, true, List::BattleParty),
|
||||||
|
];
|
||||||
|
for stocked in [false, true] {
|
||||||
|
for (scene, own_turn, forced, list) in sub_states {
|
||||||
|
let mut world = World::battle();
|
||||||
|
world.scene = scene;
|
||||||
|
world.battle = Some((BattleKind::Wild, own_turn, forced));
|
||||||
|
world.list = list;
|
||||||
|
if stocked {
|
||||||
|
world.bag = vec![(item::POTION, 1), (item::POKE_BALL, 3)];
|
||||||
|
world.enemy = Some(EnemyMon { species: 0x99, level: 3, hp: 5, max_hp: 11 });
|
||||||
|
}
|
||||||
|
let pad = names(&plan::plan_for(world.scene(), &mut world));
|
||||||
|
let next = pad.contains(&"NEXT");
|
||||||
|
let back = pad.contains(&"BACK");
|
||||||
|
assert!(
|
||||||
|
!(next && back),
|
||||||
|
"{list:?} (stocked {stocked}) deals a pair that undoes itself: {pad:?}"
|
||||||
|
);
|
||||||
|
// And the pair is not the only way to waste a hold: a pad of one button that cannot
|
||||||
|
// end the turn is the shape row 34 had, so every sub-state is checked for having one.
|
||||||
|
assert!(
|
||||||
|
!pad.is_empty(),
|
||||||
|
"{list:?} (stocked {stocked}) deals nothing: {pad:?}"
|
||||||
|
);
|
||||||
|
// `NEXT` is on a frame with no cursor accepting input, or on the forced switch that
|
||||||
|
// has no other answer (row 8). Nowhere else.
|
||||||
|
if next {
|
||||||
|
assert!(
|
||||||
|
forced || list == List::None,
|
||||||
|
"NEXT on a menu accepting input: {list:?} deals {pad:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Section 12.10: the bag inside a battle is the fly's turn, and its pad is the bag's answers.
|
||||||
|
///
|
||||||
|
/// `NEXT` on an open bag is the A press that **uses** whatever the cursor is sitting on, which is
|
||||||
|
/// not one of the answers to a list, and `CONFIRM` beside `BACK` was the same press by another
|
||||||
|
/// name. The two scripts that navigate this list by reading its cursor are `ITEM` and
|
||||||
|
/// `THROW BALL`, and they are what the row deals.
|
||||||
|
#[test]
|
||||||
|
fn the_battle_bag_is_the_flys_turn_and_deals_its_own_two_uses() {
|
||||||
|
let mut world = World::battle();
|
||||||
|
world.list = List::BattleBag;
|
||||||
|
world.bag = vec![(item::POTION, 2), (item::POKE_BALL, 4)];
|
||||||
|
world.enemy = Some(EnemyMon { species: 0x99, level: 3, hp: 5, max_hp: 11 });
|
||||||
|
// `World::battle` is a wild battle with the active Pokémon on 4 of 20, so `ITEM`'s two facts
|
||||||
|
// hold and `THROW BALL`'s four do: a wild battle, a ball, room in a party of three, and an
|
||||||
|
// enemy species the party does not hold.
|
||||||
|
assert_eq!(
|
||||||
|
names(&plan::plan_for(world.scene(), &mut world)),
|
||||||
|
["BACK", "ITEM", "THROW BALL"]
|
||||||
|
);
|
||||||
|
// Nothing in the bag: leaving the list is the press, and there is no `NEXT` to use a thing
|
||||||
|
// that is not there.
|
||||||
|
world.bag.clear();
|
||||||
|
let pad = names(&plan::plan_for(world.scene(), &mut world));
|
||||||
|
assert_eq!(pad, ["BACK"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_spent_slot_is_off_the_pad_and_a_slot_with_pp_is_on_it_over_an_open_list() {
|
fn a_spent_slot_is_off_the_pad_and_a_slot_with_pp_is_on_it_over_an_open_list() {
|
||||||
// The live state row 34 came from: slot 0 is TACKLE with 0 of 40 PP and the cursor is on it.
|
// The live state row 34 came from: slot 0 is TACKLE with 0 of 40 PP and the cursor is on it.
|
||||||
|
|
@ -3823,6 +3937,15 @@ fn no_playable_scene_and_no_sub_state_deals_an_empty_pad() {
|
||||||
cornered.mons = vec![mon(0, 20, 20, &[])];
|
cornered.mons = vec![mon(0, 20, 20, &[])];
|
||||||
cornered.battle = Some((BattleKind::Trainer, true, false));
|
cornered.battle = Some((BattleKind::Trainer, true, false));
|
||||||
worst(&mut cornered);
|
worst(&mut cornered);
|
||||||
|
|
||||||
|
// And every battle sub-state of that same cornered turn, which since section 12.10 includes
|
||||||
|
// the bag: an empty bag deals `BACK` and nothing else, which is row 34a's answer -- there is
|
||||||
|
// nothing to choose, so leaving the list is the press, and what it leaves to is a menu with
|
||||||
|
// `MOVE 1` on it.
|
||||||
|
for list in [List::BattleMain, List::Moves(1), List::BattleParty, List::BattleBag] {
|
||||||
|
cornered.list = list;
|
||||||
|
worst(&mut cornered);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Section 13.1's pad-empty rule: an outdoor map with every ledger against it still offers a walk.
|
/// Section 13.1's pad-empty rule: an outdoor map with every ledger against it still offers a walk.
|
||||||
|
|
@ -4110,4 +4233,5 @@ fn a_scripted_push_back_records_the_tile_it_happened_on() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod map_aware;
|
||||||
mod shop_purchase;
|
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)]
|
#[cfg(test)]
|
||||||
pub(crate) mod fake_wram;
|
pub(crate) mod fake_wram;
|
||||||
pub mod macros;
|
pub mod macros;
|
||||||
|
pub mod mapgrid;
|
||||||
pub mod maps;
|
pub mod maps;
|
||||||
pub mod scene;
|
pub mod scene;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
@ -343,6 +344,13 @@ impl MemoryReader for SampleCache<'_> {
|
||||||
self.bytes.insert(address, value);
|
self.bytes.insert(address, value);
|
||||||
value
|
value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Straight through, uncached: a ROM byte cannot change, so there is nothing
|
||||||
|
/// for a per-sample cache to save, and the caller that reads a blockset
|
||||||
|
/// (`docs/design/macros.md` section 15) caches the decoded map instead.
|
||||||
|
fn read_rom(&mut self, bank: u8, address: u16) -> Option<u8> {
|
||||||
|
self.source.read_rom(bank, address)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn word(memory: &mut impl MemoryReader, address: u16) -> u32 {
|
fn word(memory: &mut impl MemoryReader, address: u16) -> u32 {
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,10 @@ use super::macros::cartridge::{
|
||||||
Objective, PushedLedger, StoodLedger, TalkLedger, TalkTarget, TargetKey, TargetLedger, Tile,
|
Objective, PushedLedger, StoodLedger, TalkLedger, TalkTarget, TargetKey, TargetLedger, Tile,
|
||||||
};
|
};
|
||||||
use super::macros::geography::Amenity;
|
use super::macros::geography::Amenity;
|
||||||
|
use super::mapgrid::{self, MapGrids};
|
||||||
use super::macros::state::{
|
use super::macros::state::{
|
||||||
BagItem, Battle, BattleKind, BattleMenu, Connections, Cursor, EnemyMon, Facing, GameState,
|
BagItem, Battle, BattleKind, BattleMenu, Connections, Cursor, EnemyMon, Facing, GameState,
|
||||||
MapSize, Mon, Move, Npc, Party, Pc, Player, Scene, Shop, ShopScreen, Sign, StartMenu,
|
MapGrid, MapSize, Mon, Move, Npc, Party, Pc, Player, Scene, Shop, ShopScreen, Sign, StartMenu,
|
||||||
Status, TextBox, Walkable, Warp,
|
Status, TextBox, Walkable, Warp,
|
||||||
};
|
};
|
||||||
use super::symbols::ram;
|
use super::symbols::ram;
|
||||||
|
|
@ -459,11 +460,13 @@ pub fn battle(memory: &mut dyn MemoryReader) -> Option<Battle> {
|
||||||
// frame is between turns, which is what it was before this change.
|
// frame is between turns, which is what it was before this change.
|
||||||
BattleMenu::Moves { cursor, .. } => cursor.is_some(),
|
BattleMenu::Moves { cursor, .. } => cursor.is_some(),
|
||||||
BattleMenu::Party { .. } => !forced_switch,
|
BattleMenu::Party { .. } => !forced_switch,
|
||||||
// The bag is a list the fly opened *during* its turn, but it is not the turn's own menu:
|
// The bag is a list the fly opened *during* its turn, and it is a menu cursor accepting
|
||||||
// the pad that belongs to it is the list's two answers, which is what the between-turns
|
// input, so by the rule above it is the fly's turn (2026-09-22, section 12.10). Reading it
|
||||||
// row deals (`NEXT`, `BACK`). Reporting it as the own turn would put the move buttons on
|
// as nobody's turn put it on the between-turns row, whose one button is the `NEXT` that
|
||||||
// a screen they cannot press.
|
// advances *text* -- and on an open bag that same A press *uses* whatever the cursor
|
||||||
BattleMenu::Bag { .. } => false,
|
// happens to be sitting on. The pad that belongs to a bag is the bag's own three answers,
|
||||||
|
// `ITEM`, `THROW BALL` and `BACK`, which is what `palette::scene_set` deals here now.
|
||||||
|
BattleMenu::Bag { .. } => true,
|
||||||
BattleMenu::None => false,
|
BattleMenu::None => false,
|
||||||
};
|
};
|
||||||
Some(Battle {
|
Some(Battle {
|
||||||
|
|
@ -775,12 +778,16 @@ pub fn npcs(memory: &mut dyn MemoryReader) -> Vec<Npc> {
|
||||||
npcs
|
npcs
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the current tileset calls this tile id passable.
|
/// The current tileset's list of passable tile ids, terminator included.
|
||||||
///
|
///
|
||||||
/// `CheckTilePassable` walks the list at `wTilesetCollisionPtr` — a little-endian pointer into the
|
/// `CheckTilePassable` walks the list at `wTilesetCollisionPtr` — a little-endian pointer into the
|
||||||
/// collision tables, which all live in ROM bank 0 at this commit and so are always mapped — until
|
/// collision tables, which all live in ROM bank 0 at this commit and so are always mapped — until
|
||||||
/// it matches or hits `$ff`. `None` means the pointer is not one this module will follow.
|
/// it matches or hits `$ff`. `None` means the pointer is not one this module will follow, or the
|
||||||
fn passable(memory: &mut dyn MemoryReader, tile: u8) -> Option<bool> {
|
/// list is not terminated inside the bound below.
|
||||||
|
///
|
||||||
|
/// One read of the list serves both callers: [`walkable`] asks about one tile of the window, and
|
||||||
|
/// [`map_grid`] asks about every tile of the map, and neither is allowed its own copy of the rule.
|
||||||
|
fn collision_list(memory: &mut dyn MemoryReader) -> Option<Vec<u8>> {
|
||||||
let low = u16::from(read(memory, ram::wTilesetCollisionPtr));
|
let low = u16::from(read(memory, ram::wTilesetCollisionPtr));
|
||||||
let high = u16::from(read(memory, ram::wTilesetCollisionPtr + 1));
|
let high = u16::from(read(memory, ram::wTilesetCollisionPtr + 1));
|
||||||
let base = high * 256 + low;
|
let base = high * 256 + low;
|
||||||
|
|
@ -792,16 +799,198 @@ fn passable(memory: &mut dyn MemoryReader, tile: u8) -> Option<bool> {
|
||||||
}
|
}
|
||||||
// No collision list in the game is longer than this; the bound is what stops a bad pointer
|
// No collision list in the game is longer than this; the bound is what stops a bad pointer
|
||||||
// from walking the cartridge.
|
// from walking the cartridge.
|
||||||
|
let mut list = Vec::new();
|
||||||
for offset in 0..64u16 {
|
for offset in 0..64u16 {
|
||||||
match read(memory, base + offset) {
|
let byte = read(memory, base + offset);
|
||||||
0xff => return Some(false),
|
list.push(byte);
|
||||||
found if found == tile => return Some(true),
|
if byte == mapgrid::TERMINATOR {
|
||||||
_ => {}
|
return Some(list);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the current tileset calls this tile id passable.
|
||||||
|
///
|
||||||
|
/// [`collision_list`]'s own walk, and `CheckTilePassable`'s: match or `$ff`, whichever comes
|
||||||
|
/// first. `None` means the list could not be read at all.
|
||||||
|
fn passable(memory: &mut dyn MemoryReader, tile: u8) -> Option<bool> {
|
||||||
|
let list = collision_list(memory)?;
|
||||||
|
for candidate in list {
|
||||||
|
if candidate == mapgrid::TERMINATOR {
|
||||||
|
return Some(false);
|
||||||
|
}
|
||||||
|
if candidate == tile {
|
||||||
|
return Some(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a whole-map grid could not be decoded on this frame.
|
||||||
|
///
|
||||||
|
/// `docs/design/macros.md` section 15 asks the fallback to *say when*, so every way out of
|
||||||
|
/// [`map_grid`] is named rather than being one `None`. Each one leaves the window predicate in
|
||||||
|
/// charge, which is what the walks did before the grid existed.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum GridRefusal {
|
||||||
|
/// The map header is not loaded, or its size is out of range (`map_size` said `None`).
|
||||||
|
NoHeader,
|
||||||
|
/// The player's coordinates are not readable, so nothing can be cross-checked.
|
||||||
|
NoPlayer,
|
||||||
|
/// The tileset's collision list could not be followed ([`collision_list`]).
|
||||||
|
NoCollisionList,
|
||||||
|
/// The blockset could not be read: the seam has no cartridge behind it
|
||||||
|
/// ([`MemoryReader::read_rom`] answered `None`), or the header's pointer runs off the image.
|
||||||
|
NoBlockset,
|
||||||
|
/// The screen is not showing the map — a battle, a text box, a frame mid-warp — so there is
|
||||||
|
/// nothing to check the decode against, and `wOverworldMap` shares its bytes with the picture
|
||||||
|
/// buffer (`ram/wram.asm`'s own union), which is exactly when it must not be trusted.
|
||||||
|
NoScreen,
|
||||||
|
/// The decode and the screen buffer disagree about a tile the window can answer for. A wrong
|
||||||
|
/// stride, a wrong quadrant or a half-loaded map all land here, and all of them answer
|
||||||
|
/// plausibly, which is why this check is not optional.
|
||||||
|
ScreenDisagrees,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GridRefusal {
|
||||||
|
/// A short label for a log line and the probes.
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
GridRefusal::NoHeader => "no map header",
|
||||||
|
GridRefusal::NoPlayer => "no player",
|
||||||
|
GridRefusal::NoCollisionList => "no collision list",
|
||||||
|
GridRefusal::NoBlockset => "no blockset",
|
||||||
|
GridRefusal::NoScreen => "map not on screen",
|
||||||
|
GridRefusal::ScreenDisagrees => "screen disagrees",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole loaded map's walkability, decoded from the tables the cartridge has loaded.
|
||||||
|
///
|
||||||
|
/// `docs/design/macros.md` section 15. The rule is [`walkable`]'s rule — the tileset's collision
|
||||||
|
/// list — and what this adds is the tile id of every tile of the map rather than of the ten-by-nine
|
||||||
|
/// window:
|
||||||
|
///
|
||||||
|
/// - the map's **blocks** come from `wOverworldMap`, which `LoadTileBlockMap` fills from the map's
|
||||||
|
/// own ROM bank as rows of `wCurMapWidth + MAP_BORDER * 2` bytes with the map itself three rows
|
||||||
|
/// and three columns in. That is WRAM, so it needs no bank at all.
|
||||||
|
/// - a block's **tiles** come from the tileset header's blockset, sixteen bytes per block id
|
||||||
|
/// (`DrawTileBlock`). That is ROM, and not bank 0, so it is the one read that goes through
|
||||||
|
/// [`MemoryReader::read_rom`] — the cartridge image as the process already holds it, because the
|
||||||
|
/// alternative would be *writing* the mapper's bank register and the joypad is the only write
|
||||||
|
/// this workspace makes into a running game.
|
||||||
|
/// - the **tile-pair** refusals come from the values of `TilePairCollisionsLand`, keyed by
|
||||||
|
/// `wCurMapTileset`, and become directed walls ([`mapgrid::TILE_PAIRS_LAND`]).
|
||||||
|
///
|
||||||
|
/// The last thing it does is check itself: the decoded tile ids are compared against
|
||||||
|
/// [`map_tile_id`] for the player's own tile and its four neighbours, every one the window can
|
||||||
|
/// answer for. A frame where the window can answer for none of them is refused
|
||||||
|
/// ([`GridRefusal::NoScreen`]) rather than trusted, because `wOverworldMap` shares its bytes with
|
||||||
|
/// the picture buffer and a battle is exactly when the blocks under it are somebody else's.
|
||||||
|
pub fn map_grid(memory: &mut dyn MemoryReader) -> Result<MapGrid, GridRefusal> {
|
||||||
|
let size = map_size(memory).ok_or(GridRefusal::NoHeader)?;
|
||||||
|
let player = player(memory).ok_or(GridRefusal::NoPlayer)?;
|
||||||
|
let passable = collision_list(memory).ok_or(GridRefusal::NoCollisionList)?;
|
||||||
|
let width_blocks = read(memory, ram::wCurMapWidth);
|
||||||
|
let height_blocks = read(memory, ram::wCurMapHeight);
|
||||||
|
let stride = u16::from(width_blocks) + (mapgrid::MAP_BORDER as u16) * 2;
|
||||||
|
let border = mapgrid::MAP_BORDER as u16;
|
||||||
|
// The map plus its border has to fit in `wOverworldMap`, which every real map does. One that
|
||||||
|
// does not is a header caught mid-load, and reading past the buffer would be reading somebody
|
||||||
|
// else's WRAM.
|
||||||
|
if usize::from(stride) * (usize::from(height_blocks) + mapgrid::MAP_BORDER * 2)
|
||||||
|
> mapgrid::OVERWORLD_MAP_BYTES
|
||||||
|
{
|
||||||
|
return Err(GridRefusal::NoHeader);
|
||||||
|
}
|
||||||
|
let mut blocks = Vec::with_capacity(usize::from(width_blocks) * usize::from(height_blocks));
|
||||||
|
for row in 0..u16::from(height_blocks) {
|
||||||
|
for column in 0..u16::from(width_blocks) {
|
||||||
|
blocks.push(read(memory, ram::wOverworldMap + (row + border) * stride + column + border));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Only as much of the blockset as this map's blocks index into: a tileset has up to 256 of
|
||||||
|
// them and a room uses a dozen, and a read that stops at the highest block id used is a read
|
||||||
|
// that cannot run off the end of a bank for tiles nothing asks about.
|
||||||
|
let highest = blocks.iter().copied().max().unwrap_or(0);
|
||||||
|
let bank = read(memory, ram::wTilesetBank);
|
||||||
|
let base = u16::from(read(memory, ram::wTilesetBlocksPtr))
|
||||||
|
+ u16::from(read(memory, ram::wTilesetBlocksPtr + 1)) * 256;
|
||||||
|
let wanted = (usize::from(highest) + 1) * mapgrid::BLOCK_BYTES;
|
||||||
|
let mut blockset = Vec::with_capacity(wanted);
|
||||||
|
for offset in 0..wanted {
|
||||||
|
let address = base.checked_add(u16::try_from(offset).map_err(|_| GridRefusal::NoBlockset)?);
|
||||||
|
let byte = address
|
||||||
|
.and_then(|address| memory.read_rom(bank, address))
|
||||||
|
.ok_or(GridRefusal::NoBlockset)?;
|
||||||
|
blockset.push(byte);
|
||||||
|
}
|
||||||
|
let tiles = mapgrid::Tileset { id: read(memory, ram::wCurMapTileset), blocks: blockset, passable };
|
||||||
|
let grid = mapgrid::decode(player.map, width_blocks, height_blocks, &blocks, &tiles);
|
||||||
|
if grid.width() != size.width || grid.height() != size.height {
|
||||||
|
return Err(GridRefusal::NoHeader);
|
||||||
|
}
|
||||||
|
// The cross-check. `map_tile_id` reads the screen buffer at the offset
|
||||||
|
// `_GetTileAndCoordsInFrontOfPlayer` uses, so agreeing with it on the tiles it can answer for
|
||||||
|
// is agreeing with the cartridge's own reading of the same ground.
|
||||||
|
let mut checked = 0;
|
||||||
|
for (x, y) in neighbourhood(player.x, player.y) {
|
||||||
|
let Some(screen) = map_tile_id(memory, x, y) else { continue };
|
||||||
|
if grid.tile_id(x, y) != Some(screen) {
|
||||||
|
return Err(GridRefusal::ScreenDisagrees);
|
||||||
|
}
|
||||||
|
checked += 1;
|
||||||
|
}
|
||||||
|
if checked == 0 {
|
||||||
|
return Err(GridRefusal::NoScreen);
|
||||||
|
}
|
||||||
|
Ok(grid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a cached grid is still the map that is loaded, checked from the tile the fly is on.
|
||||||
|
///
|
||||||
|
/// The map id, the map header and the block data are written by different parts of a warp, so
|
||||||
|
/// there is a frame or two on the way through a door where `wCurMap` is the map the fly is
|
||||||
|
/// arriving on and the header and the blocks are still the map it is leaving: the decode agrees
|
||||||
|
/// with the screen (both are the old map) and is filed under the new id. Measured on the
|
||||||
|
/// cartridge — the fly on Oak's lab doormat with `wCurMap` already reading `PALLET_TOWN` and the
|
||||||
|
/// header still the lab's ten-by-twelve (`tests/rom_map_grid.rs`).
|
||||||
|
///
|
||||||
|
/// Nothing in WRAM says "the map has finished loading", so the cache asks the cheapest question
|
||||||
|
/// that can tell: does the grid still agree with the screen about the tile the fly is standing on?
|
||||||
|
/// One byte, once per question. A grid that does not is dropped and decoded again, so a torn
|
||||||
|
/// frame's grid lives exactly as long as the tear does — and through it the cartridge is walking
|
||||||
|
/// the fly, which is not a frame any macro plans on.
|
||||||
|
fn still_the_loaded_map(
|
||||||
|
memory: &mut dyn MemoryReader,
|
||||||
|
grid: &MapGrid,
|
||||||
|
x: u8,
|
||||||
|
y: u8,
|
||||||
|
) -> bool {
|
||||||
|
match map_tile_id(memory, x, y) {
|
||||||
|
// The screen is not showing the map (a battle, a text box): nothing to check against, and
|
||||||
|
// the grid was checked when it was decoded.
|
||||||
|
None => true,
|
||||||
|
Some(tile) => grid.tile_id(x, y) == Some(tile),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The player's own tile and its four neighbours, which is every tile the window is certain to be
|
||||||
|
/// able to answer for from where the fly is standing.
|
||||||
|
fn neighbourhood(x: u8, y: u8) -> Vec<(u8, u8)> {
|
||||||
|
let mut out = vec![(x, y)];
|
||||||
|
for (dx, dy) in [(0i16, 1i16), (0, -1), (-1, 0), (1, 0)] {
|
||||||
|
if let (Ok(nx), Ok(ny)) =
|
||||||
|
(u8::try_from(i16::from(x) + dx), u8::try_from(i16::from(y) + dy))
|
||||||
|
{
|
||||||
|
out.push((nx, ny));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the player could stand on this tile of the current map.
|
/// Whether the player could stand on this tile of the current map.
|
||||||
///
|
///
|
||||||
/// Mirrors `CheckTilePassable`: the tile id comes out of the screen buffer at the offset
|
/// Mirrors `CheckTilePassable`: the tile id comes out of the screen buffer at the offset
|
||||||
|
|
@ -918,6 +1107,16 @@ pub struct PokeState<'a> {
|
||||||
/// Tiles the cartridge has pushed the fly off ([`MacroState::pushed_tile`]). Session state
|
/// Tiles the cartridge has pushed the fly off ([`MacroState::pushed_tile`]). Session state
|
||||||
/// beside the four above, owned by the same type (`infra/docs/macros-traps.md` row 37).
|
/// beside the four above, owned by the same type (`infra/docs/macros-traps.md` row 37).
|
||||||
pushed: &'a dyn PushedLedger,
|
pushed: &'a dyn PushedLedger,
|
||||||
|
/// Where the decoded map grid is kept between frames ([`MacroState::map_grid`],
|
||||||
|
/// `docs/design/macros.md` section 15).
|
||||||
|
///
|
||||||
|
/// Mutable, unlike every ledger above, because this is the one thing the state *computes*
|
||||||
|
/// rather than looks up: a decode is a few thousand reads and a walk of the blockset, and it
|
||||||
|
/// is valid for as long as the map is loaded. Without a cache every caller decodes again,
|
||||||
|
/// which is correct and is what the tests do; the sim loop passes one
|
||||||
|
/// ([`PokeState::caching_grid`]) so that a precondition asking for the frontier costs a
|
||||||
|
/// refcount instead of a map.
|
||||||
|
grids: Option<&'a mut MapGrids>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> PokeState<'a> {
|
impl<'a> PokeState<'a> {
|
||||||
|
|
@ -935,6 +1134,7 @@ impl<'a> PokeState<'a> {
|
||||||
stood: &NoStood,
|
stood: &NoStood,
|
||||||
areas: &NoAreas,
|
areas: &NoAreas,
|
||||||
pushed: &NoPushed,
|
pushed: &NoPushed,
|
||||||
|
grids: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -948,6 +1148,7 @@ impl<'a> PokeState<'a> {
|
||||||
stood: &NoStood,
|
stood: &NoStood,
|
||||||
areas: &NoAreas,
|
areas: &NoAreas,
|
||||||
pushed: &NoPushed,
|
pushed: &NoPushed,
|
||||||
|
grids: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -965,7 +1166,18 @@ impl<'a> PokeState<'a> {
|
||||||
areas: &'a dyn AreaLedger,
|
areas: &'a dyn AreaLedger,
|
||||||
pushed: &'a dyn PushedLedger,
|
pushed: &'a dyn PushedLedger,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self { memory, ledger, talk, targets, stood, areas, pushed }
|
Self { memory, ledger, talk, targets, stood, areas, pushed, grids: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep the decoded map grid in `grids` instead of decoding it per question.
|
||||||
|
///
|
||||||
|
/// The cache is keyed by map id and size and holds one map, so arriving somewhere else drops
|
||||||
|
/// it (`docs/design/macros.md` section 15). Session state: it is owned by
|
||||||
|
/// [`super::macros::driver::PokemonPalette`], never checkpointed, and rebuilt from the
|
||||||
|
/// cartridge on the first overworld frame after a restore.
|
||||||
|
pub fn caching_grid(mut self, grids: &'a mut MapGrids) -> Self {
|
||||||
|
self.grids = Some(grids);
|
||||||
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1051,6 +1263,29 @@ impl MacroState for PokeState<'_> {
|
||||||
!controllable(self.memory)
|
!controllable(self.memory)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The whole loaded map's walkability, from the cache when it is for this map
|
||||||
|
/// (`docs/design/macros.md` section 15).
|
||||||
|
///
|
||||||
|
/// `None` is the honest answer on every frame [`map_grid`] refuses — no cartridge behind the
|
||||||
|
/// seam, a battle or a text box over the map, a header that is not loaded — and every caller
|
||||||
|
/// falls back to the ten-by-nine window predicate then, which is what all of them did before
|
||||||
|
/// this existed. [`GridRefusal`] names which, for the probes.
|
||||||
|
fn map_grid(&mut self) -> Option<std::sync::Arc<MapGrid>> {
|
||||||
|
let player = player(self.memory)?;
|
||||||
|
let size = map_size(self.memory)?;
|
||||||
|
if let Some(grids) = self.grids.as_deref()
|
||||||
|
&& let Some(grid) = grids.get(player.map, size.width, size.height)
|
||||||
|
&& still_the_loaded_map(self.memory, &grid, player.x, player.y)
|
||||||
|
{
|
||||||
|
return Some(grid);
|
||||||
|
}
|
||||||
|
let grid = map_grid(self.memory).ok()?;
|
||||||
|
match self.grids.as_deref_mut() {
|
||||||
|
Some(grids) => Some(grids.store(grid)),
|
||||||
|
None => Some(std::sync::Arc::new(grid)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What the open mart sells, in menu order (`docs/design/macros.md` section 13).
|
/// What the open mart sells, in menu order (`docs/design/macros.md` section 13).
|
||||||
///
|
///
|
||||||
/// Gated on the mart scene being up, and that gate is the whole of the accuracy here:
|
/// Gated on the mart scene being up, and that gate is the whole of the accuracy here:
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@
|
||||||
//! passable-tile list walked out of ROM bank 0.
|
//! passable-tile list walked out of ROM bank 0.
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::pokemon_red::fake_wram::{REDS_HOUSE_1F, WALL_TILE, Wram};
|
use crate::pokemon_red::fake_wram::{self, REDS_HOUSE_1F, WALL_TILE, Wram};
|
||||||
|
use crate::pokemon_red::macros::cartridge::MacroState;
|
||||||
use crate::pokemon_red::macros::state::{BattleKind, BattleMenu, ShopScreen, Sign};
|
use crate::pokemon_red::macros::state::{BattleKind, BattleMenu, ShopScreen, Sign};
|
||||||
|
|
||||||
/// A walkable tile id from `RedsHouse1_Coll`.
|
/// A walkable tile id from `RedsHouse1_Coll`.
|
||||||
|
|
@ -248,6 +249,79 @@ fn a_forced_switch_is_the_party_list_that_cannot_be_cancelled() {
|
||||||
assert!(cursor(&mut wram).cancellable());
|
assert!(cursor(&mut wram).cancellable());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Section 12.10: **a battle frame with a cursor accepting input is the fly's turn.**
|
||||||
|
///
|
||||||
|
/// The four menus a battle waits on are the top-level one, the move list, the party list and the
|
||||||
|
/// bag, and each of them is the game asking the player to choose. `own_turn` answered `false` for
|
||||||
|
/// the bag, which put it on the between-turns row whose one button is the `NEXT` that advances
|
||||||
|
/// text -- and on an open bag that same A press uses whatever the cursor holds.
|
||||||
|
///
|
||||||
|
/// The two frames that are correctly *not* the fly's turn are here too: a move list whose cursor
|
||||||
|
/// the seam cannot place (row 30b, a battle's opening frames) and a frame with no menu at all.
|
||||||
|
#[test]
|
||||||
|
fn a_battle_frame_with_a_cursor_accepting_input_is_the_flys_turn() {
|
||||||
|
let battler = |wram: &mut Wram| {
|
||||||
|
wram.party_mon(0, 4, 7, 14, 22, 0, &[(10, 35)]);
|
||||||
|
wram.party_mon(1, 16, 8, 24, 24, 0, &[(33, 35)]);
|
||||||
|
wram.battle_mon(0, 4, 7, 14, 22, 0, &[(10, 35), (45, 40), (33, 30)])
|
||||||
|
.enemy_mon(19, 3, 5, 11)
|
||||||
|
.battle(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The top-level menu.
|
||||||
|
let mut wram = Wram::overworld();
|
||||||
|
battler(&mut wram);
|
||||||
|
wram.battle_menu(false, 0);
|
||||||
|
assert!(battle(&mut wram).unwrap().own_turn, "the top-level menu");
|
||||||
|
|
||||||
|
// The move list, with a cursor the seam can place.
|
||||||
|
let mut wram = Wram::overworld();
|
||||||
|
battler(&mut wram);
|
||||||
|
wram.move_menu(1, 3);
|
||||||
|
assert!(battle(&mut wram).unwrap().own_turn, "the move list");
|
||||||
|
|
||||||
|
// The party list, chosen rather than forced.
|
||||||
|
let mut wram = Wram::overworld();
|
||||||
|
battler(&mut wram);
|
||||||
|
wram.party_list(1, false);
|
||||||
|
let fight = battle(&mut wram).unwrap();
|
||||||
|
assert!(!fight.forced_switch);
|
||||||
|
assert!(fight.own_turn, "the party list outside a forced switch");
|
||||||
|
|
||||||
|
// The bag, which `DisplayListMenuID` opens from the menu's ITEM entry. It is the one menu that
|
||||||
|
// read as nobody's turn, and it is what section 12.10 is about.
|
||||||
|
let mut wram = Wram::overworld();
|
||||||
|
battler(&mut wram);
|
||||||
|
wram.bag(&[(crate::pokemon_red::macros::cartridge::item::POTION, 2)]).set(ram::wListMenuID, poke::ITEM_LIST_MENU);
|
||||||
|
let fight = battle(&mut wram).unwrap();
|
||||||
|
assert_eq!(fight.menu, BattleMenu::Bag { cursor: 0, count: 1 });
|
||||||
|
assert!(fight.own_turn, "the battle bag is a cursor accepting input");
|
||||||
|
|
||||||
|
// And the two frames that are not a choice. A move list whose cursor cannot be placed is a
|
||||||
|
// battle's opening frames (row 30b), and no menu at all is text, an animation or a turn
|
||||||
|
// resolving.
|
||||||
|
let mut wram = Wram::overworld();
|
||||||
|
battler(&mut wram);
|
||||||
|
wram.move_menu(0, 3).set(ram::wCurrentMenuItem, 0);
|
||||||
|
let fight = battle(&mut wram).unwrap();
|
||||||
|
assert_eq!(fight.menu, BattleMenu::Moves { cursor: None, count: 3 });
|
||||||
|
assert!(!fight.own_turn, "a cursor the seam cannot place is not accepting input");
|
||||||
|
|
||||||
|
let mut wram = Wram::overworld();
|
||||||
|
battler(&mut wram);
|
||||||
|
assert_eq!(battle(&mut wram).unwrap().menu, BattleMenu::None);
|
||||||
|
assert!(!battle(&mut wram).unwrap().own_turn, "no menu, no turn");
|
||||||
|
|
||||||
|
// A forced switch is a cursor accepting input and it is *not* the own turn, because it has a
|
||||||
|
// pad of its own: the exception 12.6 named, kept here so the invariant reads honestly.
|
||||||
|
let mut wram = Wram::overworld();
|
||||||
|
battler(&mut wram);
|
||||||
|
wram.party_list(1, true);
|
||||||
|
let fight = battle(&mut wram).unwrap();
|
||||||
|
assert!(fight.forced_switch);
|
||||||
|
assert!(!fight.own_turn, "a forced switch has its own pad");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_text_box_is_open_from_the_font_flag_and_waiting_from_the_box() {
|
fn a_text_box_is_open_from_the_font_flag_and_waiting_from_the_box() {
|
||||||
let mut wram = Wram::overworld();
|
let mut wram = Wram::overworld();
|
||||||
|
|
@ -603,3 +677,141 @@ fn the_live_implementation_answers_the_whole_trait() {
|
||||||
assert_eq!(state.warps().len(), 1);
|
assert_eq!(state.warps().len(), 1);
|
||||||
assert!(state.connections().south);
|
assert!(state.connections().south);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A map ten blocks by nine — twenty tiles by eighteen, wider than the ten-by-nine window — with
|
||||||
|
/// a wall down one column of blocks, and a screen buffer that agrees with it.
|
||||||
|
///
|
||||||
|
/// The three tables the grid is decoded from, all synthetic: block ids in `wOverworldMap`, a
|
||||||
|
/// blockset in a ROM bank that is not bank 0, and a collision list in bank 0 where
|
||||||
|
/// `wTilesetCollisionPtr` points.
|
||||||
|
fn town() -> (Wram, Vec<u8>, Vec<[u8; 16]>) {
|
||||||
|
const FLOOR: u8 = 0x01;
|
||||||
|
let blockset = vec![[FLOOR; 16], [WALL_TILE; 16]];
|
||||||
|
let (wide, high) = (10usize, 9usize);
|
||||||
|
let mut blocks = vec![0u8; wide * high];
|
||||||
|
for row in 0..high {
|
||||||
|
blocks[row * wide + 5] = 1;
|
||||||
|
}
|
||||||
|
let mut wram = Wram::new();
|
||||||
|
wram.started()
|
||||||
|
.map(fake_wram::PALLET_TOWN, wide as u8, high as u8, 3, 4)
|
||||||
|
.facing(0)
|
||||||
|
.house_collision()
|
||||||
|
.tileset(0)
|
||||||
|
.blockset(&blockset)
|
||||||
|
.map_blocks(&blocks)
|
||||||
|
.fill_screen(WALL_TILE)
|
||||||
|
.screen_from_blocks(&blocks, &blockset);
|
||||||
|
(wram, blocks, blockset)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_whole_map_decodes_from_the_block_and_collision_tables() {
|
||||||
|
let (mut wram, _, _) = town();
|
||||||
|
let grid = map_grid(&mut wram).expect("a decodable map");
|
||||||
|
assert_eq!((grid.width(), grid.height()), (20, 18));
|
||||||
|
assert_eq!(grid.unknown_count(), 0);
|
||||||
|
// The far corner of the map, which the window predicate cannot answer for at all: the grid
|
||||||
|
// does, and that is the whole of section 15.
|
||||||
|
assert_eq!(walkable(&mut wram, 19, 17), Walkable::Unknown);
|
||||||
|
assert_eq!(grid.walkable(19, 17), Walkable::Yes);
|
||||||
|
// The wall column, again outside the window.
|
||||||
|
assert_eq!(grid.walkable(10, 17), Walkable::No);
|
||||||
|
assert_eq!(grid.walkable(11, 17), Walkable::No);
|
||||||
|
// Inside the window the two readings agree tile for tile, which is what the reader checks
|
||||||
|
// itself with before it trusts a decode.
|
||||||
|
for y in 0..18u8 {
|
||||||
|
for x in 0..20u8 {
|
||||||
|
if let Some(tile) = map_tile_id(&mut wram, x, y) {
|
||||||
|
assert_eq!(grid.tile_id(x, y), Some(tile), "({x}, {y})");
|
||||||
|
assert_eq!(grid.walkable(x, y), walkable(&mut wram, x, y), "({x}, {y})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_decode_the_screen_disagrees_with_is_refused() {
|
||||||
|
let (mut wram, _, _) = town();
|
||||||
|
// One screen tile the block data does not account for: a wrong stride, a wrong quadrant or a
|
||||||
|
// half-loaded map all look like this, and all of them answer plausibly.
|
||||||
|
wram.map_tile(3, 4, 0x77);
|
||||||
|
assert_eq!(map_grid(&mut wram), Err(GridRefusal::ScreenDisagrees));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn without_a_cartridge_behind_the_seam_there_is_no_grid() {
|
||||||
|
let (mut wram, blocks, _) = town();
|
||||||
|
// The same WRAM with no blockset in any bank: `read_rom` answers `None`, which is what every
|
||||||
|
// reader that is not the emulator answers, and the grid narrows to nothing rather than
|
||||||
|
// decoding the map out of whatever bytes were to hand.
|
||||||
|
let mut bare = Wram::new();
|
||||||
|
bare.started()
|
||||||
|
.map(fake_wram::PALLET_TOWN, 10, 9, 3, 4)
|
||||||
|
.facing(0)
|
||||||
|
.house_collision()
|
||||||
|
.map_blocks(&blocks)
|
||||||
|
.fill_screen(WALL_TILE);
|
||||||
|
assert_eq!(map_grid(&mut bare), Err(GridRefusal::NoBlockset));
|
||||||
|
// And the window predicate still answers, which is the fallback the whole thing rests on.
|
||||||
|
assert_eq!(walkable(&mut wram, 3, 4), Walkable::Yes);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_frame_that_is_not_showing_the_map_has_no_grid_to_check() {
|
||||||
|
let (mut wram, _, _) = town();
|
||||||
|
// `wOverworldMap` shares its bytes with the picture buffer (`ram/wram.asm`'s own union), so a
|
||||||
|
// battle is exactly when the blocks under it belong to somebody else. Nothing can be
|
||||||
|
// cross-checked then, and a decode nothing can check is refused.
|
||||||
|
wram.battle(1);
|
||||||
|
assert_eq!(map_grid(&mut wram), Err(GridRefusal::NoScreen));
|
||||||
|
|
||||||
|
let (mut wram, _, _) = town();
|
||||||
|
wram.dialogue_box();
|
||||||
|
assert_eq!(map_grid(&mut wram), Err(GridRefusal::NoScreen));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_frame_with_no_map_header_has_no_grid() {
|
||||||
|
let mut wram = Wram::new();
|
||||||
|
wram.started();
|
||||||
|
assert_eq!(map_grid(&mut wram), Err(GridRefusal::NoHeader));
|
||||||
|
assert_eq!(GridRefusal::NoHeader.label(), "no map header");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_grid_is_decoded_once_per_map_and_dropped_on_arrival_somewhere_else() {
|
||||||
|
let (mut wram, blocks, blockset) = town();
|
||||||
|
let mut grids = MapGrids::default();
|
||||||
|
{
|
||||||
|
let mut state = PokeState::new(&mut wram).caching_grid(&mut grids);
|
||||||
|
let first = state.map_grid().expect("a decodable map");
|
||||||
|
let again = state.map_grid().expect("the cached map");
|
||||||
|
assert!(std::sync::Arc::ptr_eq(&first, &again), "the second question is the same grid");
|
||||||
|
}
|
||||||
|
assert_eq!(grids.held(), Some(fake_wram::PALLET_TOWN));
|
||||||
|
|
||||||
|
// Walking through a door: another map id, so the cache is somebody else's and is dropped.
|
||||||
|
wram.map(fake_wram::OAKS_LAB, 10, 9, 3, 4)
|
||||||
|
.map_blocks(&blocks)
|
||||||
|
.screen_from_blocks(&blocks, &blockset);
|
||||||
|
{
|
||||||
|
let mut state = PokeState::new(&mut wram).caching_grid(&mut grids);
|
||||||
|
let grid = state.map_grid().expect("a decodable map");
|
||||||
|
assert_eq!(grid.map(), fake_wram::OAKS_LAB);
|
||||||
|
}
|
||||||
|
assert_eq!(grids.held(), Some(fake_wram::OAKS_LAB));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_state_with_no_cache_still_answers_and_a_state_with_no_cartridge_answers_none() {
|
||||||
|
let (mut wram, _, _) = town();
|
||||||
|
let mut state = PokeState::new(&mut wram);
|
||||||
|
assert!(state.map_grid().is_some(), "no cache is slower, not blinder");
|
||||||
|
|
||||||
|
let mut bare = Wram::overworld();
|
||||||
|
let mut state = PokeState::new(&mut bare);
|
||||||
|
assert!(state.map_grid().is_none(), "no blockset, no grid");
|
||||||
|
// Which is the frame the window predicate is for.
|
||||||
|
assert_eq!(state.walkable(3, 6), Walkable::No);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ pub mod ram {
|
||||||
pub const wSpriteStateData1: u16 = 0xc100; // 49408
|
pub const wSpriteStateData1: u16 = 0xc100; // 49408
|
||||||
pub const wSpriteStateData2: u16 = 0xc200; // 49664
|
pub const wSpriteStateData2: u16 = 0xc200; // 49664
|
||||||
pub const wTileMap: u16 = 0xc3a0; // 50080
|
pub const wTileMap: u16 = 0xc3a0; // 50080
|
||||||
|
pub const wOverworldMap: u16 = 0xc6e8; // 50920
|
||||||
pub const wTopMenuItemY: u16 = 0xcc24; // 52260
|
pub const wTopMenuItemY: u16 = 0xcc24; // 52260
|
||||||
pub const wTopMenuItemX: u16 = 0xcc25; // 52261
|
pub const wTopMenuItemX: u16 = 0xcc25; // 52261
|
||||||
pub const wCurrentMenuItem: u16 = 0xcc26; // 52262
|
pub const wCurrentMenuItem: u16 = 0xcc26; // 52262
|
||||||
|
|
@ -59,6 +60,7 @@ pub mod ram {
|
||||||
pub const wCurMap: u16 = 0xd35e; // 54110
|
pub const wCurMap: u16 = 0xd35e; // 54110
|
||||||
pub const wYCoord: u16 = 0xd361; // 54113
|
pub const wYCoord: u16 = 0xd361; // 54113
|
||||||
pub const wXCoord: u16 = 0xd362; // 54114
|
pub const wXCoord: u16 = 0xd362; // 54114
|
||||||
|
pub const wCurMapTileset: u16 = 0xd367; // 54119
|
||||||
pub const wCurMapHeight: u16 = 0xd368; // 54120
|
pub const wCurMapHeight: u16 = 0xd368; // 54120
|
||||||
pub const wCurMapWidth: u16 = 0xd369; // 54121
|
pub const wCurMapWidth: u16 = 0xd369; // 54121
|
||||||
pub const wCurMapConnections: u16 = 0xd370; // 54128
|
pub const wCurMapConnections: u16 = 0xd370; // 54128
|
||||||
|
|
@ -68,6 +70,8 @@ pub mod ram {
|
||||||
pub const wSignCoords: u16 = 0xd4b1; // 54449
|
pub const wSignCoords: u16 = 0xd4b1; // 54449
|
||||||
pub const wSignTextIDs: u16 = 0xd4d1; // 54481
|
pub const wSignTextIDs: u16 = 0xd4d1; // 54481
|
||||||
pub const wNumSprites: u16 = 0xd4e1; // 54497
|
pub const wNumSprites: u16 = 0xd4e1; // 54497
|
||||||
|
pub const wTilesetBank: u16 = 0xd52b; // 54571
|
||||||
|
pub const wTilesetBlocksPtr: u16 = 0xd52c; // 54572
|
||||||
pub const wTilesetCollisionPtr: u16 = 0xd530; // 54576
|
pub const wTilesetCollisionPtr: u16 = 0xd530; // 54576
|
||||||
pub const wTilesetTalkingOverTiles: u16 = 0xd532; // 54578
|
pub const wTilesetTalkingOverTiles: u16 = 0xd532; // 54578
|
||||||
pub const wNumHoFTeams: u16 = 0xd5a2; // 54690
|
pub const wNumHoFTeams: u16 = 0xd5a2; // 54690
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,10 @@ fn pad(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) {
|
||||||
use flybrain_gb::pokemon_red::macros::{geography, palette, path, plan};
|
use flybrain_gb::pokemon_red::macros::{geography, palette, path, plan};
|
||||||
use flybrain_gb::pokemon_red::macros::state::Walkable;
|
use flybrain_gb::pokemon_red::macros::state::Walkable;
|
||||||
|
|
||||||
|
// Why the grid could not be decoded, read before the state borrows the emulator: it is the
|
||||||
|
// same call `MacroState::map_grid` makes, and the only reading that can say *which* of section
|
||||||
|
// 15's refusals a frame is.
|
||||||
|
let refusal = flybrain_gb::pokemon_red::state::map_grid(gb).err();
|
||||||
let ledger = AdapterLedger(adapter);
|
let ledger = AdapterLedger(adapter);
|
||||||
let mut poke = flybrain_gb::pokemon_red::state::PokeState::with_ledger(gb, &ledger);
|
let mut poke = flybrain_gb::pokemon_red::state::PokeState::with_ledger(gb, &ledger);
|
||||||
let state: &mut dyn MacroState = &mut poke;
|
let state: &mut dyn MacroState = &mut poke;
|
||||||
|
|
@ -182,16 +186,52 @@ fn pad(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) {
|
||||||
let names: Vec<&str> =
|
let names: Vec<&str> =
|
||||||
plan.slots.iter().flatten().map(|spec| spec.name).collect();
|
plan.slots.iter().flatten().map(|spec| spec.name).collect();
|
||||||
println!("- the pad: {names:?}");
|
println!("- the pad: {names:?}");
|
||||||
|
// The whole-map grid (`docs/design/macros.md` section 15), which is what the walks plan over
|
||||||
|
// now. Three numbers read a stalled walk: how much of the map is ground, how much of that the
|
||||||
|
// fly can actually get to from where it stands, and how much of *that* it has never stood on.
|
||||||
|
// A fly with 600 walkable tiles and 4 reachable ones is fenced in and no re-plan will help.
|
||||||
|
match state.map_grid() {
|
||||||
|
None => println!(
|
||||||
|
"- the map grid: none ({})",
|
||||||
|
refusal.map_or("unknown", |refusal| refusal.label())
|
||||||
|
),
|
||||||
|
Some(grid) => {
|
||||||
|
let unstood = grid
|
||||||
|
.walkable_tiles()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(x, y)| !state.tile_visited(*x, *y))
|
||||||
|
.count();
|
||||||
|
println!(
|
||||||
|
"- the map grid: {}x{} walkable {} reachable {} unstood {} unknown {}",
|
||||||
|
grid.width(),
|
||||||
|
grid.height(),
|
||||||
|
grid.walkable_count(),
|
||||||
|
grid.reachable_from(player.x, player.y),
|
||||||
|
unstood,
|
||||||
|
grid.unknown_count()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
// The `v` column is the *adapter's* lifetime exploration ledger and nothing else. The
|
// The `v` column is the *adapter's* lifetime exploration ledger and nothing else. The
|
||||||
// session's own stood ledger (`docs/design/macros.md` section 12.7) is owned by the driver's
|
// session's own stood ledger (`docs/design/macros.md` section 12.7) is owned by the driver's
|
||||||
// palette, which this probe does not reach into, so a doormat the running fly has already
|
// palette, which this probe does not reach into, so a doormat the running fly has already
|
||||||
// marked still prints as unrecorded here. That is the point of the column: it shows what the
|
// marked still prints as unrecorded here. That is the point of the column: it shows what the
|
||||||
// reward ledger can and cannot answer.
|
// reward ledger can and cannot answer.
|
||||||
println!("\n### The ground (`v` = the adapter's lifetime ledger only)\n\n```");
|
// The grid's reading of the ground where there is one, the window's otherwise, said out loud
|
||||||
|
// so the map below cannot be mistaken for the other reading.
|
||||||
|
let grid = state.map_grid();
|
||||||
|
println!(
|
||||||
|
"\n### The ground, as the {} reads it (`v` = the adapter's lifetime ledger only)\n\n```",
|
||||||
|
if grid.is_some() { "map grid" } else { "ten-by-nine window" }
|
||||||
|
);
|
||||||
for y in 0..size.height {
|
for y in 0..size.height {
|
||||||
let row: Vec<String> = (0..size.width)
|
let row: Vec<String> = (0..size.width)
|
||||||
.map(|x| {
|
.map(|x| {
|
||||||
let walk = match state.walkable(x, y) {
|
let answer = match grid.as_deref() {
|
||||||
|
Some(grid) => grid.walkable(x, y),
|
||||||
|
None => state.walkable(x, y),
|
||||||
|
};
|
||||||
|
let walk = match answer {
|
||||||
Walkable::Yes => '.',
|
Walkable::Yes => '.',
|
||||||
Walkable::No => '#',
|
Walkable::No => '#',
|
||||||
Walkable::Unknown => '?',
|
Walkable::Unknown => '?',
|
||||||
|
|
@ -208,7 +248,11 @@ fn pad(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) {
|
||||||
let mut n = 0;
|
let mut n = 0;
|
||||||
for y in 0..size.height {
|
for y in 0..size.height {
|
||||||
for x in 0..size.width {
|
for x in 0..size.width {
|
||||||
if state.walkable(x, y) == Walkable::Yes && !state.tile_visited(x, y) {
|
let answer = match grid.as_deref() {
|
||||||
|
Some(grid) => grid.walkable(x, y),
|
||||||
|
None => state.walkable(x, y),
|
||||||
|
};
|
||||||
|
if answer == Walkable::Yes && !state.tile_visited(x, y) {
|
||||||
n += 1;
|
n += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,12 @@ struct Trace {
|
||||||
/// Every input of the detector's disputed branch on the last frame
|
/// Every input of the detector's disputed branch on the last frame
|
||||||
/// (`pokemon_red::scene::why_unknown`).
|
/// (`pokemon_red::scene::why_unknown`).
|
||||||
ended_why: String,
|
ended_why: String,
|
||||||
|
/// The whole-map grid on the last frame, as [`grid_line`] reads it
|
||||||
|
/// (`docs/design/macros.md` section 15): how much of the map is ground, how much of it the fly
|
||||||
|
/// could reach from where it stopped, and how much of that it had never stood on. A hunt that
|
||||||
|
/// ends with reachable far below walkable ended fenced in, which no amount of re-planning was
|
||||||
|
/// ever going to fix.
|
||||||
|
ended_grid: String,
|
||||||
/// How the dialog branch's two halves agreed, per frame: `wFontLoaded`'s bit against the four
|
/// How the dialog branch's two halves agreed, per frame: `wFontLoaded`'s bit against the four
|
||||||
/// corners and against the whole `TextBoxBorder` (`pokemon_red::state::dialog_border`).
|
/// corners and against the whole `TextBoxBorder` (`pokemon_red::state::dialog_border`).
|
||||||
///
|
///
|
||||||
|
|
@ -379,6 +385,7 @@ fn run(
|
||||||
ended_in: ("", String::new()),
|
ended_in: ("", String::new()),
|
||||||
longest_scene: BTreeMap::new(),
|
longest_scene: BTreeMap::new(),
|
||||||
ended_why: String::new(),
|
ended_why: String::new(),
|
||||||
|
ended_grid: String::new(),
|
||||||
font_corners_border: 0,
|
font_corners_border: 0,
|
||||||
font_corners_no_border: 0,
|
font_corners_no_border: 0,
|
||||||
font_no_corners: 0,
|
font_no_corners: 0,
|
||||||
|
|
@ -544,6 +551,9 @@ fn run(
|
||||||
next_trace = ms + trace_every_ms;
|
next_trace = ms + trace_every_ms;
|
||||||
let scene = macros.as_ref().map_or("", MacroLayer::scene_name);
|
let scene = macros.as_ref().map_or("", MacroLayer::scene_name);
|
||||||
use flybrain_gb::pokemon_red::macros::cartridge::{MacroState, Tile};
|
use flybrain_gb::pokemon_red::macros::cartridge::{MacroState, Tile};
|
||||||
|
// Read before the state borrows the emulator: this is the same call the state makes,
|
||||||
|
// and the only one that can say *which* refusal a frame is.
|
||||||
|
let refusal = flybrain_gb::pokemon_red::state::map_grid(&mut emulator).err();
|
||||||
let mut state = flybrain_gb::pokemon_red::state::PokeState::new(&mut emulator);
|
let mut state = flybrain_gb::pokemon_red::state::PokeState::new(&mut emulator);
|
||||||
let state: &mut dyn MacroState = &mut state;
|
let state: &mut dyn MacroState = &mut state;
|
||||||
let player = state.player();
|
let player = state.player();
|
||||||
|
|
@ -551,9 +561,10 @@ fn run(
|
||||||
let ahead = Tile::new(player.x, player.y).step(player.facing)?;
|
let ahead = Tile::new(player.x, player.y).step(player.facing)?;
|
||||||
flybrain_gb::pokemon_red::macros::path::target_at(state, ahead)
|
flybrain_gb::pokemon_red::macros::path::target_at(state, ahead)
|
||||||
});
|
});
|
||||||
|
let ground = grid_line(state, player, refusal);
|
||||||
let why = flybrain_gb::pokemon_red::scene::why_unknown(&mut emulator);
|
let why = flybrain_gb::pokemon_red::scene::why_unknown(&mut emulator);
|
||||||
println!(
|
println!(
|
||||||
"trace {:7.2} min scene={scene:<9} player={player:?} ahead={ahead:?}\n {why}",
|
"trace {:7.2} min scene={scene:<9} player={player:?} ahead={ahead:?}\n {why}\n {ground}",
|
||||||
(ms - began_ms) / MINUTE_MS
|
(ms - began_ms) / MINUTE_MS
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -624,6 +635,14 @@ fn run(
|
||||||
adapter.mode().to_string(),
|
adapter.mode().to_string(),
|
||||||
);
|
);
|
||||||
trace.ended_why = flybrain_gb::pokemon_red::scene::why_unknown(&mut emulator);
|
trace.ended_why = flybrain_gb::pokemon_red::scene::why_unknown(&mut emulator);
|
||||||
|
trace.ended_grid = {
|
||||||
|
use flybrain_gb::pokemon_red::macros::cartridge::MacroState;
|
||||||
|
let refusal = flybrain_gb::pokemon_red::state::map_grid(&mut emulator).err();
|
||||||
|
let mut state = flybrain_gb::pokemon_red::state::PokeState::new(&mut emulator);
|
||||||
|
let state: &mut dyn MacroState = &mut state;
|
||||||
|
let player = state.player();
|
||||||
|
grid_line(state, player, refusal)
|
||||||
|
};
|
||||||
trace.ended_ms = agent.network.ms;
|
trace.ended_ms = agent.network.ms;
|
||||||
trace.wall_seconds = began_wall.elapsed().as_secs_f64();
|
trace.wall_seconds = began_wall.elapsed().as_secs_f64();
|
||||||
trace
|
trace
|
||||||
|
|
@ -722,6 +741,42 @@ fn walk_report(trace: &Trace) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The whole-map grid in one line: what a stalled walk looks like from outside.
|
||||||
|
///
|
||||||
|
/// `docs/design/macros.md` section 15. Walkable is how much of the map is ground, reachable is
|
||||||
|
/// how much of that the fly can get to from where it is standing (the directed walls respected),
|
||||||
|
/// and unstood is how much of *that* this run has never been on -- which is the frontier's own
|
||||||
|
/// candidate pool. A walk that cannot finish is one of three shapes and these numbers tell them
|
||||||
|
/// apart: fenced in (reachable far below walkable), nothing left to explore (unstood zero), or no
|
||||||
|
/// grid at all, in which case the walks are back on the ten-by-nine window and the reason is
|
||||||
|
/// named.
|
||||||
|
fn grid_line(
|
||||||
|
state: &mut dyn flybrain_gb::pokemon_red::macros::cartridge::MacroState,
|
||||||
|
player: Option<flybrain_gb::pokemon_red::macros::state::Player>,
|
||||||
|
refusal: Option<flybrain_gb::pokemon_red::state::GridRefusal>,
|
||||||
|
) -> String {
|
||||||
|
let Some(player) = player else { return "grid: no player".to_string() };
|
||||||
|
let Some(grid) = state.map_grid() else {
|
||||||
|
// Which of section 15's refusals this frame is, rather than a bare "none": a walk that is
|
||||||
|
// on the window reading should say why it is.
|
||||||
|
return format!("grid: none ({})", refusal.map_or("unknown", |refusal| refusal.label()));
|
||||||
|
};
|
||||||
|
let unstood = grid
|
||||||
|
.walkable_tiles()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(x, y)| !state.tile_visited(*x, *y))
|
||||||
|
.count();
|
||||||
|
format!(
|
||||||
|
"grid map={:#04x} {}x{} walkable={} reachable={} unstood={}",
|
||||||
|
grid.map(),
|
||||||
|
grid.width(),
|
||||||
|
grid.height(),
|
||||||
|
grid.walkable_count(),
|
||||||
|
grid.reachable_from(player.x, player.y),
|
||||||
|
unstood
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let Some(path) = std::env::var_os("FLY_ROM") else {
|
let Some(path) = std::env::var_os("FLY_ROM") else {
|
||||||
println!(
|
println!(
|
||||||
|
|
@ -796,6 +851,7 @@ fn main() {
|
||||||
println!("| --- | ---: |");
|
println!("| --- | ---: |");
|
||||||
println!("| rung reached | {} |", trace.rungs.iter().map(|(rank, ..)| *rank).max().unwrap_or(0));
|
println!("| rung reached | {} |", trace.rungs.iter().map(|(rank, ..)| *rank).max().unwrap_or(0));
|
||||||
println!("| distinct (map, tile) | {} |", ground.len());
|
println!("| distinct (map, tile) | {} |", ground.len());
|
||||||
|
println!("| the map at the end | {} |", trace.ended_grid);
|
||||||
println!("| macros started | {} |", trace.starts.len());
|
println!("| macros started | {} |", trace.starts.len());
|
||||||
for (outcome, count) in &trace.outcomes {
|
for (outcome, count) in &trace.outcomes {
|
||||||
println!("| {outcome} | {count} |");
|
println!("| {outcome} | {count} |");
|
||||||
|
|
|
||||||
|
|
@ -178,8 +178,40 @@ struct Run {
|
||||||
/// The rung-9 trap: between turns there is nothing to back out of, so the press completes
|
/// The rung-9 trap: between turns there is nothing to back out of, so the press completes
|
||||||
/// where the fly stands and the turn does not move. `false` is the assertion.
|
/// where the fly stands and the turn does not move. `false` is the assertion.
|
||||||
back_without_a_list: bool,
|
back_without_a_list: bool,
|
||||||
|
/// Where a `BACK` or a `NEXT` on a battle frame was dealt, as `scene/sub-state`.
|
||||||
|
///
|
||||||
|
/// The bool above says a rule broke; this says on which frame, which is the difference
|
||||||
|
/// between a pad rule to fix and a scene the detector cannot name.
|
||||||
|
battle_back_where: std::collections::BTreeSet<String>,
|
||||||
|
battle_next_where: std::collections::BTreeSet<String>,
|
||||||
/// Whether `THROW BALL` ever started at a species the party already held (section 12.9).
|
/// Whether `THROW BALL` ever started at a species the party already held (section 12.9).
|
||||||
threw_at_a_held_species: bool,
|
threw_at_a_held_species: bool,
|
||||||
|
/// Whether one battle pad ever held both `NEXT` and `BACK` (section 12.10).
|
||||||
|
///
|
||||||
|
/// The pair is the trap: `NEXT` is the A that advances text, `BACK` is the B that leaves a
|
||||||
|
/// list, and a pad with both has two buttons that undo each other with nothing else changing.
|
||||||
|
next_and_back_on_one_pad: bool,
|
||||||
|
/// Whether `NEXT` was ever on the pad while a battle menu was accepting input.
|
||||||
|
///
|
||||||
|
/// The live v0.4.2 shape: `NEXT` on the top-level menu was an A press on FIGHT, so it opened
|
||||||
|
/// the move list that `BACK` closed again -- 1264 starts against 1241 in 71 hours.
|
||||||
|
next_on_a_menu_accepting_input: bool,
|
||||||
|
/// The longest chain of battle macro starts that alternated `NEXT`, `BACK`, `NEXT`, `BACK`.
|
||||||
|
///
|
||||||
|
/// Two is an accident of the rotation; the live log did it for seventy-one hours. Only the
|
||||||
|
/// previous battle start has to be kept to measure the chain.
|
||||||
|
longest_next_back_alternation: u32,
|
||||||
|
alternation: u32,
|
||||||
|
last_battle_start: Option<&'static str>,
|
||||||
|
/// Macros started inside the battle that is running, and the worst any *finished* battle cost.
|
||||||
|
///
|
||||||
|
/// The claim the rung-9 loop breaks is that a battle **ends**, and that it ends on a bounded
|
||||||
|
/// number of macros rather than on however many holds the 2-cycle takes to fall out of.
|
||||||
|
macros_this_battle: u32,
|
||||||
|
worst_battle_macros: u32,
|
||||||
|
battles_entered: u32,
|
||||||
|
battles_ended: u32,
|
||||||
|
was_in_battle: bool,
|
||||||
/// Maps on whose *overworld* pad `GO OBJECTIVE` was ever bound.
|
/// Maps on whose *overworld* pad `GO OBJECTIVE` was ever bound.
|
||||||
///
|
///
|
||||||
/// The objective is the road out: on rung 9 in the forest it has to be there, or the only way
|
/// The objective is the road out: on rung 9 in the forest it has to be there, or the only way
|
||||||
|
|
@ -256,7 +288,19 @@ impl Run {
|
||||||
move_button_on_battle_pad: false,
|
move_button_on_battle_pad: false,
|
||||||
battle_pad: None,
|
battle_pad: None,
|
||||||
back_without_a_list: false,
|
back_without_a_list: false,
|
||||||
|
battle_back_where: std::collections::BTreeSet::new(),
|
||||||
|
battle_next_where: std::collections::BTreeSet::new(),
|
||||||
threw_at_a_held_species: false,
|
threw_at_a_held_species: false,
|
||||||
|
next_and_back_on_one_pad: false,
|
||||||
|
next_on_a_menu_accepting_input: false,
|
||||||
|
longest_next_back_alternation: 0,
|
||||||
|
alternation: 0,
|
||||||
|
last_battle_start: None,
|
||||||
|
macros_this_battle: 0,
|
||||||
|
worst_battle_macros: 0,
|
||||||
|
battles_entered: 0,
|
||||||
|
battles_ended: 0,
|
||||||
|
was_in_battle: false,
|
||||||
objective_on_pad: std::collections::BTreeSet::new(),
|
objective_on_pad: std::collections::BTreeSet::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -332,7 +376,19 @@ impl Run {
|
||||||
move_button_on_battle_pad: false,
|
move_button_on_battle_pad: false,
|
||||||
battle_pad: None,
|
battle_pad: None,
|
||||||
back_without_a_list: false,
|
back_without_a_list: false,
|
||||||
|
battle_back_where: std::collections::BTreeSet::new(),
|
||||||
|
battle_next_where: std::collections::BTreeSet::new(),
|
||||||
threw_at_a_held_species: false,
|
threw_at_a_held_species: false,
|
||||||
|
next_and_back_on_one_pad: false,
|
||||||
|
next_on_a_menu_accepting_input: false,
|
||||||
|
longest_next_back_alternation: 0,
|
||||||
|
alternation: 0,
|
||||||
|
last_battle_start: None,
|
||||||
|
macros_this_battle: 0,
|
||||||
|
worst_battle_macros: 0,
|
||||||
|
battles_entered: 0,
|
||||||
|
battles_ended: 0,
|
||||||
|
was_in_battle: false,
|
||||||
objective_on_pad: std::collections::BTreeSet::new(),
|
objective_on_pad: std::collections::BTreeSet::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -365,6 +421,37 @@ impl Run {
|
||||||
///
|
///
|
||||||
/// Section 12.9's question, through the same seam the macros read: `BACK` is a button where
|
/// Section 12.9's question, through the same seam the macros read: `BACK` is a button where
|
||||||
/// there is a list to leave and nowhere else.
|
/// there is a list to leave and nowhere else.
|
||||||
|
/// Whether the pad on this frame is a **battle** pad.
|
||||||
|
///
|
||||||
|
/// The scene the palette was dealt for, not `wIsInBattle`. The two differ on the `$ff` frame a
|
||||||
|
/// lost battle passes through and on a Safari or tutorial battle, where `state::battle` reads
|
||||||
|
/// nothing and `scene::detect` answers `Unknown` -- whose pad is `NEXT, BACK` by contract
|
||||||
|
/// (section 12.2, row 9: B is what leaves the Pokédex, the trainer card and OPTION). Asking
|
||||||
|
/// the cartridge byte instead accused that row of a battle rule it is not under.
|
||||||
|
fn battle_pad(&self) -> bool {
|
||||||
|
self.layer.scene_name() == "battle" || self.layer.scene_name() == "battle-switch"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The battle sub-state the seam reports, as a word, for the record and the failure message.
|
||||||
|
fn battle_sub_state(&mut self) -> &'static str {
|
||||||
|
use flybrain_gb::pokemon_red::macros::state::{BattleMenu, GameState};
|
||||||
|
let ledger = AdapterLedger(&self.adapter);
|
||||||
|
let mut state =
|
||||||
|
flybrain_gb::pokemon_red::state::PokeState::with_ledger(&mut self.gb, &ledger);
|
||||||
|
match state.battle() {
|
||||||
|
None => "no-battle",
|
||||||
|
Some(battle) => match battle.menu {
|
||||||
|
BattleMenu::Main { .. } => "main",
|
||||||
|
BattleMenu::Moves { cursor: Some(_), .. } => "moves",
|
||||||
|
BattleMenu::Moves { cursor: None, .. } => "moves-unplaceable",
|
||||||
|
BattleMenu::Party { .. } if battle.forced_switch => "party-forced",
|
||||||
|
BattleMenu::Party { .. } => "party",
|
||||||
|
BattleMenu::Bag { .. } => "bag",
|
||||||
|
BattleMenu::None => "between-turns",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn battle_list_open(&mut self) -> bool {
|
fn battle_list_open(&mut self) -> bool {
|
||||||
use flybrain_gb::pokemon_red::macros::state::{BattleMenu, GameState};
|
use flybrain_gb::pokemon_red::macros::state::{BattleMenu, GameState};
|
||||||
let ledger = AdapterLedger(&self.adapter);
|
let ledger = AdapterLedger(&self.adapter);
|
||||||
|
|
@ -483,9 +570,30 @@ impl Run {
|
||||||
}
|
}
|
||||||
// Section 12.9, on the cartridge: `BACK` belongs to a list. A battle frame with no list
|
// Section 12.9, on the cartridge: `BACK` belongs to a list. A battle frame with no list
|
||||||
// accepting input and `BACK` on the pad is the rung-9 trap itself.
|
// accepting input and `BACK` on the pad is the rung-9 trap itself.
|
||||||
if self.in_battle() != 0 && !self.battle_list_open() {
|
if self.battle_pad() {
|
||||||
self.back_without_a_list |=
|
let back = bound.iter().any(|channel| channel.as_str() == "macro_back");
|
||||||
bound.iter().any(|channel| channel.as_str() == "macro_back");
|
if back {
|
||||||
|
let where_ = format!("{}/{}", self.layer.scene_name(), self.battle_sub_state());
|
||||||
|
self.battle_back_where.insert(where_);
|
||||||
|
}
|
||||||
|
if back && !self.battle_list_open() {
|
||||||
|
self.back_without_a_list = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Section 12.10, on the cartridge: no battle pad holds a pair that undoes itself, and
|
||||||
|
// `NEXT` is on no frame with a cursor accepting input. The second is the stronger of the
|
||||||
|
// two -- the live pair was split across two sub-states, so no single pad held both.
|
||||||
|
if self.battle_pad() {
|
||||||
|
let next = bound.iter().any(|channel| channel.as_str() == "macro_next");
|
||||||
|
let back = bound.iter().any(|channel| channel.as_str() == "macro_back");
|
||||||
|
self.next_and_back_on_one_pad |= next && back;
|
||||||
|
if next {
|
||||||
|
let where_ = format!("{}/{}", self.layer.scene_name(), self.battle_sub_state());
|
||||||
|
self.battle_next_where.insert(where_);
|
||||||
|
}
|
||||||
|
if next && self.own_turn() {
|
||||||
|
self.next_on_a_menu_accepting_input = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// A facing window opens when `TALK`'s channel joins the pad and closes when it leaves.
|
// A facing window opens when `TALK`'s channel joins the pad and closes when it leaves.
|
||||||
let talk_bound = bound.iter().any(|channel| channel.ends_with("talk"));
|
let talk_bound = bound.iter().any(|channel| channel.ends_with("talk"));
|
||||||
|
|
@ -506,11 +614,32 @@ impl Run {
|
||||||
.collect();
|
.collect();
|
||||||
(decision.mask, started)
|
(decision.mask, started)
|
||||||
};
|
};
|
||||||
|
let in_battle_now = self.in_battle() != 0;
|
||||||
|
let on_a_battle_pad = self.battle_pad();
|
||||||
for name in started {
|
for name in started {
|
||||||
// Section 12.9's other half: a ball is never thrown at a species the party holds.
|
// Section 12.9's other half: a ball is never thrown at a species the party holds.
|
||||||
if name == "THROW BALL" && self.enemy_species_in_party() {
|
if name == "THROW BALL" && self.enemy_species_in_party() {
|
||||||
self.threw_at_a_held_species = true;
|
self.threw_at_a_held_species = true;
|
||||||
}
|
}
|
||||||
|
// Section 12.10's own signature, as the live event log printed it: `NEXT start/done,
|
||||||
|
// BACK start/done`, every hold, for seventy-one hours. Measured as the longest chain
|
||||||
|
// of consecutive battle starts drawn from those two alone and strictly alternating.
|
||||||
|
if in_battle_now {
|
||||||
|
self.macros_this_battle += 1;
|
||||||
|
}
|
||||||
|
if on_a_battle_pad {
|
||||||
|
let two = name == "NEXT" || name == "BACK";
|
||||||
|
self.alternation = match self.last_battle_start {
|
||||||
|
Some(last) if two && (last == "NEXT" || last == "BACK") && last != name => {
|
||||||
|
self.alternation.max(1) + 1
|
||||||
|
}
|
||||||
|
_ if two => 1,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
self.longest_next_back_alternation =
|
||||||
|
self.longest_next_back_alternation.max(self.alternation);
|
||||||
|
self.last_battle_start = Some(name);
|
||||||
|
}
|
||||||
*self.started.entry(name).or_insert(0) += 1;
|
*self.started.entry(name).or_insert(0) += 1;
|
||||||
}
|
}
|
||||||
self.gb.set_buttons(mask as u8);
|
self.gb.set_buttons(mask as u8);
|
||||||
|
|
@ -522,6 +651,24 @@ impl Run {
|
||||||
let ledger = AdapterLedger(&self.adapter);
|
let ledger = AdapterLedger(&self.adapter);
|
||||||
let _ = self.layer.observe(&mut self.gb, &ledger, ms);
|
let _ = self.layer.observe(&mut self.gb, &ledger, ms);
|
||||||
}
|
}
|
||||||
|
// Battle boundaries, after the frame: what a battle cost in macros, and whether it ended.
|
||||||
|
let now_in_battle = self.in_battle() != 0;
|
||||||
|
match (self.was_in_battle, now_in_battle) {
|
||||||
|
(false, true) => {
|
||||||
|
self.battles_entered += 1;
|
||||||
|
self.macros_this_battle = 0;
|
||||||
|
self.last_battle_start = None;
|
||||||
|
self.alternation = 0;
|
||||||
|
}
|
||||||
|
(true, false) => {
|
||||||
|
self.battles_ended += 1;
|
||||||
|
self.worst_battle_macros =
|
||||||
|
self.worst_battle_macros.max(self.macros_this_battle);
|
||||||
|
self.macros_this_battle = 0;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
self.was_in_battle = now_in_battle;
|
||||||
let map = self.map();
|
let map = self.map();
|
||||||
if self.route.last() != Some(&map) && map != u32::MAX {
|
if self.route.last() != Some(&map) && map != u32::MAX {
|
||||||
self.route.push(map);
|
self.route.push(map);
|
||||||
|
|
@ -744,7 +891,8 @@ fn forest_checkpoint() -> Option<flysim::store::Checkpoint> {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// From the rung-9 forest checkpoint: the turns advance, and `BACK` is never a battle's whole pad.
|
/// From the rung-9 forest checkpoint: the turns advance, the battles end, and no two buttons on a
|
||||||
|
/// battle pad undo each other.
|
||||||
///
|
///
|
||||||
/// **What was live** (2026-09-22, `infra/docs/macros-traps.md`): rank 9, VIRIDIAN FOREST, 69 hours
|
/// **What was live** (2026-09-22, `infra/docs/macros-traps.md`): rank 9, VIRIDIAN FOREST, 69 hours
|
||||||
/// on the rung, the ratchet's three attempts spent, and since the restart the macro starts were
|
/// on the rung, the ratchet's three attempts spent, and since the restart the macro starts were
|
||||||
|
|
@ -754,9 +902,18 @@ fn forest_checkpoint() -> Option<flysim::store::Checkpoint> {
|
||||||
/// `THROW BALL` spent balls on the species already in the party, each catch opening a nickname
|
/// `THROW BALL` spent balls on the species already in the party, each catch opening a nickname
|
||||||
/// screen the pad cannot leave.
|
/// screen the pad cannot leave.
|
||||||
///
|
///
|
||||||
/// The claim is about the *turn*, not about the fight: that a battle from this state ends, that the
|
/// **What was live again** (2026-09-22, thirty-five minutes after v0.4.2): the same rung, the same
|
||||||
/// fly's own presses are what ends it, and that neither of the two traps is on the pad any more.
|
/// forest, and the macro starts since the restart were `NEXT` 1264, `BACK` 1241, `THROW BALL` 5 and
|
||||||
/// Which move it picks and whether it wins are the fly's.
|
/// `GO WARP` 3, the event log alternating `NEXT start/done, BACK start/done` every hold. The pair
|
||||||
|
/// was split across two sub-states of one turn, so 12.9's rule held and the loop survived it:
|
||||||
|
/// `NEXT` on the top-level menu was an A press on FIGHT, which **opened** the move list, and `BACK`
|
||||||
|
/// on the move list **closed** it again. Section 12.10 takes `NEXT` off every pad with a cursor
|
||||||
|
/// accepting input and gives the bag its own; `MOVE 1` is the backstop the top-level menu keeps.
|
||||||
|
///
|
||||||
|
/// The claim is about the *turn*, not about the fight: that a battle from this state ends, that it
|
||||||
|
/// ends on a bounded number of macros, that the fly's own presses are what ends it, and that none
|
||||||
|
/// of the three traps is on the pad any more. Which move it picks and whether it wins are the
|
||||||
|
/// fly's.
|
||||||
///
|
///
|
||||||
/// ```sh
|
/// ```sh
|
||||||
/// FLY_ROM=/path/to/pokemon-red.gb \
|
/// FLY_ROM=/path/to/pokemon-red.gb \
|
||||||
|
|
@ -813,9 +970,34 @@ fn the_battles_turns_advance_from_the_rung_nine_forest_checkpoint() {
|
||||||
run.objective_on_pad
|
run.objective_on_pad
|
||||||
);
|
);
|
||||||
|
|
||||||
// The two traps, as assertions on the cartridge.
|
// The traps, as assertions on the cartridge.
|
||||||
assert!(!run.back_without_a_list, "`BACK` was on a battle pad with no list open");
|
eprintln!(
|
||||||
|
"`BACK` in a battle was dealt on {:?}; `NEXT` on {:?}",
|
||||||
|
run.battle_back_where, run.battle_next_where
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!run.back_without_a_list,
|
||||||
|
"`BACK` was on a battle pad with no list open: {:?}",
|
||||||
|
run.battle_back_where
|
||||||
|
);
|
||||||
assert!(!run.threw_at_a_held_species, "a ball was thrown at a species the party holds");
|
assert!(!run.threw_at_a_held_species, "a ball was thrown at a species the party holds");
|
||||||
|
// Section 12.10, the two halves of it.
|
||||||
|
assert!(
|
||||||
|
!run.next_and_back_on_one_pad,
|
||||||
|
"a battle pad held both `NEXT` and `BACK`: two buttons that undo each other"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!run.next_on_a_menu_accepting_input,
|
||||||
|
"`NEXT` was on the pad while a battle menu was accepting input, where A opens rather \
|
||||||
|
than advances"
|
||||||
|
);
|
||||||
|
// And the shape the log had, rather than only the pads it came from. Two in a row is the
|
||||||
|
// rotation happening to deal the pair; the live run did it for seventy-one hours.
|
||||||
|
assert!(
|
||||||
|
run.longest_next_back_alternation < 4,
|
||||||
|
"`NEXT`/`BACK` alternated {} times in a row",
|
||||||
|
run.longest_next_back_alternation
|
||||||
|
);
|
||||||
|
|
||||||
// The turn moves: the fly's own battle presses happen, and a battle this run entered or
|
// The turn moves: the fly's own battle presses happen, and a battle this run entered or
|
||||||
// resumed finishes.
|
// resumed finishes.
|
||||||
|
|
@ -828,6 +1010,33 @@ fn the_battles_turns_advance_from_the_rung_nine_forest_checkpoint() {
|
||||||
.sum();
|
.sum();
|
||||||
assert!(battle_presses > 0, "no move and no ball: {:?}", run.started);
|
assert!(battle_presses > 0, "no move and no ball: {:?}", run.started);
|
||||||
assert!(ended > 0, "no battle ever ended: {battles} entered");
|
assert!(ended > 0, "no battle ever ended: {battles} entered");
|
||||||
|
// **Every battle that started, finished**, and each one on a bounded number of macros. That is
|
||||||
|
// the claim the 2-cycle breaks, and the way it breaks it is the opposite of a slow fight: the
|
||||||
|
// battle never leaves the fly's own turn at all, so the count grows with the *run*. On v0.4.2
|
||||||
|
// from this same checkpoint the trap hunt spent all twenty of its brain minutes -- 71,673
|
||||||
|
// frames, 1,489 macros, 73 of 73 windows flagged -- inside **one** battle that never ended,
|
||||||
|
// with `BACK` 739 starts on the move list and `NEXT` 739 on the top-level menu. Four hundred
|
||||||
|
// is generous against the 275 this run's worst battle measured and far under an unbounded
|
||||||
|
// cycle.
|
||||||
|
assert!(
|
||||||
|
run.battles_ended + 1 >= run.battles_entered,
|
||||||
|
"{} battles entered and only {} left: a battle was entered and never got out",
|
||||||
|
run.battles_entered,
|
||||||
|
run.battles_ended
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
run.worst_battle_macros > 0 && run.worst_battle_macros < 400,
|
||||||
|
"the worst battle cost {} macros over {} that ended",
|
||||||
|
run.worst_battle_macros,
|
||||||
|
run.battles_ended
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
"battles: {} entered, {} ended, worst {} macros; longest NEXT/BACK alternation {}",
|
||||||
|
run.battles_entered,
|
||||||
|
run.battles_ended,
|
||||||
|
run.worst_battle_macros,
|
||||||
|
run.longest_next_back_alternation
|
||||||
|
);
|
||||||
// `BACK` is still pressed, and that is the contract rather than a residual: over the move list
|
// `BACK` is still pressed, and that is the contract rather than a residual: over the move list
|
||||||
// and over a one-Pokemon party list it is one of the two answers a list has, and where it
|
// and over a one-Pokemon party list it is one of the two answers a list has, and where it
|
||||||
// leads is a menu with the move buttons on it (row 34). Its share is *reported* -- under this
|
// leads is a menu with the move buttons on it (row 34). Its share is *reported* -- under this
|
||||||
|
|
|
||||||
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
|
# standing behind a desk can be talked to at all: none of the four tiles around either of
|
||||||
# them is walkable.
|
# them is walkable.
|
||||||
'wTilesetTalkingOverTiles',
|
'wTilesetTalkingOverTiles',
|
||||||
|
# The whole-map walkability grid (docs/design/macros.md section 15).
|
||||||
|
#
|
||||||
|
# LoadTileBlockMap copies the loaded map out of its ROM bank into wOverworldMap
|
||||||
|
# as one byte per 4x4-tile block, in rows of wCurMapWidth + MAP_BORDER * 2 with
|
||||||
|
# the map itself three rows and three columns in, so the blocks of the current
|
||||||
|
# map are a WRAM read rather than a ROM one. wCurMapTileset keys the tile-pair
|
||||||
|
# collision lists (CheckForTilePairCollisions). wTilesetBank and
|
||||||
|
# wTilesetBlocksPtr are the tileset header's blockset: 16 bytes per block id,
|
||||||
|
# four rows of four tile ids, which DrawTileBlock indexes exactly that way --
|
||||||
|
# and it is not in bank 0, which is why the memory seam grew a bank-aware ROM
|
||||||
|
# read for it. All four are resolved the same way every other name here is;
|
||||||
|
# services/flysim/tools/resolve_wram.py is the second reading of them, from
|
||||||
|
# ram/wram.asm at this commit, and it re-derives 40 of the addresses this table
|
||||||
|
# already carries before it emits one of these four.
|
||||||
|
'wOverworldMap',
|
||||||
|
'wCurMapTileset',
|
||||||
|
'wTilesetBank',
|
||||||
|
'wTilesetBlocksPtr',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
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