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