Merge fix/loop-20260922T1217: readable YES/NO prompts deal their answers, the nurse enters the talked ledger, a prompt that reopens unchanged is excluded
This commit is contained in:
commit
d2e2d2e51d
17 changed files with 1307 additions and 36 deletions
|
|
@ -125,7 +125,18 @@ condition the plan wanted, at the cost of no new state.
|
|||
## Recovery budgets
|
||||
|
||||
Attempts per rung stay 3; lifetime budget scales with the ladder (36 instead of 12). Stall window
|
||||
unchanged (120 s without new exploration, 180 s since last recovery). The ratchet's rank bound is
|
||||
unchanged (120 s without new exploration, 180 s since last recovery).
|
||||
|
||||
**Amended 2026-09-22 (rung 10).** "New exploration" is the adapter's lifetime tile ledger, so a map
|
||||
entered for the first time resets the window -- a new map is a map's worth of tiles nobody has stood
|
||||
on -- and a map *re-entered* does not. Two "Stuck" rollbacks fired inside half an hour on a fly that
|
||||
was walking a town it had already covered toward the rung's own door, and both were this rule
|
||||
working. The window now takes a second signal beside the tile count: **the fly being nearer its
|
||||
objective, in map hops, than this run has ever been** (`docs/design/macros.md` section 12.15). The
|
||||
ratchet treats it exactly as it treats new exploration -- it restarts the window and does nothing
|
||||
else -- it can fire at most once per step of the road, and the ratchet itself knows no more about
|
||||
what it means than it knows what a tile is. Nothing else about the budgets, the triggers or the
|
||||
checkpointed state changes. The ratchet's rank bound is
|
||||
now the running adapter's ladder length, passed in rather than a constant, because the bound belongs
|
||||
to the adapter: Pokémon's ladder is 38 rungs and the platformer's is 16
|
||||
(`docs/design/platformer.md` §3). Only the *budgets* are per-game, from the adapter's
|
||||
|
|
|
|||
|
|
@ -1080,6 +1080,98 @@ while a prompt is readable, and `TALK` is on no pad at a rested nurse.
|
|||
The decoder, the reward catalog, the adapter version, the roles and the compatibility string are
|
||||
untouched.
|
||||
|
||||
### 12.13 `UNKNOWN` is two states, and one of them has nothing to press (2026-09-22, rung 10, five and a half hours)
|
||||
|
||||
Live on v0.4.5, rank 10, the fly inside the Pewter museum with rung 11's BOULDER BADGE two doors
|
||||
away: since the 11:30 restart the macro starts were `GO FRONTIER` **1,235**, `BACK` **678**,
|
||||
`GO OBJECTIVE` 267, `GO OUT` 242, `YES` 169, and the previous review had already named the shape --
|
||||
`BACK` pressed 189 times "in a text box" on map `0x02`, surrounded by `GO OBJECTIVE` and
|
||||
`GO FRONTIER`. `infra/docs/macros-traps.md` has the reproduction; three things were wrong and all
|
||||
three are here, and the first of them is not a text box at all.
|
||||
|
||||
- **`BACK` was dealt by `Scene::Unknown`, on frames with nothing drawn.** `BACK` is on no overworld
|
||||
pad and on no dialog pad, so every one of those presses came from `Unknown` — and `Unknown` holds
|
||||
two states under one name. One is a screen this crate cannot name: the Pokédex, the trainer card,
|
||||
OPTION, where A and B are what leave it and 13.1 put them there on purpose. The other is a frame
|
||||
of the **overworld** where the buttons are not reaching the player — a warp in flight, a scripted
|
||||
push-back, the museum guide walking the fly through the door — which `scene::detect` calls
|
||||
`Unknown` because its overworld branch needs `controllable`. On the second, `NEXT` and `BACK` are
|
||||
an A and a B pressed into somebody else's script: they change nothing, they complete where the fly
|
||||
stands, and that is 12.2's trap with no box to advance. So the pad is dealt on **whether a box is
|
||||
drawn** (`wFontLoaded`, the reading `text_box` already makes), and a scripted overworld frame is an
|
||||
empty pad the fly waits out. Nothing else can wait it out: the cartridge gives the buttons back by
|
||||
itself, which is the difference between this and every other empty pad 13.1 enumerates.
|
||||
|
||||
### 12.14 A frontier no walk can reach is a fact about the map (2026-09-22, the same run)
|
||||
|
||||
The museum's ground floor is 98 walkable tiles, **62** of them reachable from the door and **39**
|
||||
never stood on — and almost all of those 39 are behind the admission desk. `GO FRONTIER` aims at
|
||||
ground the run has not stood on, the route search cannot reach any of it, so the macro refuses
|
||||
`no route`, presses nothing and writes every goal to the blocked ledger (12.1). That ledger is a
|
||||
**ten brain minute window**: it lapsed, all of it was a candidate again, and the refusal happened
|
||||
again, once per hold, for hours.
|
||||
|
||||
A window is the right shape for a target somebody is standing in front of and the wrong shape for
|
||||
ground the map has fenced off. So the refusal is remembered **per map** instead, with no window,
|
||||
beside the pushed-tile ledger of row 37 — and it is *cleared* by the one event that can change the
|
||||
answer: the fly standing somewhere on that map it has not stood on before, because a door opened, a
|
||||
script carried it through, or somebody moved out of a doorway. Re-entering the map clears nothing;
|
||||
that was the loop the window made. Session state like every other ledger, never checkpointed.
|
||||
|
||||
### 12.15 The ratchet's stall window cannot see a fly walking a road it has already covered
|
||||
|
||||
Two "Stuck" rollbacks fired on this rung inside half an hour, attempts 0 → 2, each one putting the
|
||||
fly back where it had started. Both were the ratchet working exactly to contract: its stall window
|
||||
is restarted by *exploration* — `progress.unique_locations`, ground the run has never stood on
|
||||
(`docs/design/ladder.md`, and row 22) — and 120 brain seconds of safe overworld samples without one
|
||||
new tile is a stall by definition. Entering a map for the first time already counts, because a new
|
||||
map is a map's worth of tiles nobody has stood on; **re-entering** one does not, and two museum
|
||||
floors and a covered town are exactly that.
|
||||
|
||||
What is plainly progress and is not ground: **being nearer the objective than this run has ever
|
||||
been**, counted in hops over the same map graph `GO OBJECTIVE` walks (`geography::hops`). The macro
|
||||
layer answers it, the sim loop passes it to the ratchet beside the coverage figure, and the ratchet
|
||||
treats it exactly as it treats a rise in coverage — it restarts the window and does nothing else: no
|
||||
budget spent, no snapshot taken, no trigger skipped. It can fire at most once per step of the road,
|
||||
and nothing in the macro layer reads it back: no macro is ranked by it and no button is bound on it.
|
||||
|
||||
The checkpointed ratchet state does not move. The signal is a level on one sample, not a counter, so
|
||||
there is nothing to serialise and nothing to drift across a restore.
|
||||
|
||||
### 12.16 What is still in the way of the badge, measured rather than fixed
|
||||
|
||||
With 12.13, 12.14 and the museum's two rows on the map graph, the ROM-gated run from the live
|
||||
checkpoint reaches the gym's own interior on **15 macros** — against never, in five and a half live
|
||||
hours. Two things it then does are worth naming, because neither is a bug and both cost the rung:
|
||||
|
||||
- **the town's errands come first, and they are session state.** Section 13 puts an unvisited mart
|
||||
or Pokémon Center ahead of the rung's place for every map in that area, and the gym is in Pewter's
|
||||
area like everything else. The ledger does not survive a restart, so the 11:30 restart re-armed
|
||||
both errands and `GO OBJECTIVE` aimed at them before the leader. They are paid once and the run
|
||||
goes on; the cost is minutes, not hours.
|
||||
- **`GO FRONTIER` is still most of the run** — 1,247 starts in 55 brain minutes, 649 of them in
|
||||
Pewter City itself. There the frontier is genuinely reachable, one tile at a time, because the
|
||||
whole-map grid is refused on a walking fly (below) and the windowed frontier is the nearest
|
||||
unstood tile on screen. It is covering ground rather than standing still, which is why it is a
|
||||
residual and not a trap.
|
||||
|
||||
**The trap hunt does not improve.** Distinct tiles 193 -> 175 and flagged windows 59 -> 69, with
|
||||
`BACK` in a text box 295 -> 0 and battle frames 6,948 -> 20,894. What the old cycle is replaced by
|
||||
is a new one on the same five tiles -- `GO FRONTIER`, `GO HEAL`, `GO ROUTE`, x42, for seven and a
|
||||
half brain minutes -- and then four brain minutes inside one battle, which the hunt's tile rule
|
||||
flags as hard as it flags a stall. `infra/docs/macros-traps.md` has both arms whole and row 54 is
|
||||
the next brief. The ethos check's "the trap hunt improves" does not hold for this branch; the
|
||||
ROM-gated run does, and both are reported rather than one of them.
|
||||
|
||||
**The whole-map grid is refused while the fly is moving.** `pokemon_red::state::map_grid` checks its
|
||||
decode against the screen buffer over the fly's own tile and its four neighbours, and on a frame
|
||||
mid-step the two are a tile apart: `wYCoord` is the tile being walked *to* while the background is
|
||||
still scrolling. Measured on Pewter City from the rung-10 checkpoint: standing still it decodes on
|
||||
**118 of 120** frames, and the frame the survey caught disagreed on three tiles by exactly one row
|
||||
in the direction of travel. A walk planned on such a frame is planned over the ten-by-nine window of
|
||||
section 15's "before". Naming it needs a WRAM reading of "a step is in progress" that this crate's
|
||||
reviewed symbol list does not carry, so it is reported here and by the probes rather than guessed at.
|
||||
|
||||
## 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.")
|
||||
|
|
@ -1210,6 +1302,7 @@ measured where it cannot.
|
|||
| a restore, before the first `observe` | not a cause: the loop calls `observe` once at the end of boot and once after a ratchet recovery, so the first frame is decided on a real palette |
|
||||
| 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 |
|
||||
| an `Unknown` frame that is the **overworld with the cartridge driving** -- a warp in flight, a scripted push-back, a guide walking the fly through a door | **empty on purpose** (12.13). There is no box to advance and no screen to leave, so an A or a B press is a press into somebody else's script: it changes nothing and completes where the fly stands. This is the one empty pad that ends itself -- the cartridge gives the buttons back within a few frames -- and `game.padEmptyMs` reports it like any other |
|
||||
| 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":
|
||||
|
|
|
|||
|
|
@ -1941,3 +1941,148 @@ checkpoint's own half-finished conversation plus the one `NO` that answered the
|
|||
- `infra/tests/lint.sh`: all checks passed, de-PII guard included.
|
||||
- `--print-compatibility`: **648 bytes, sha256 `0d9bfde7...707fa`** -- byte-identical to v0.4.1
|
||||
through v0.4.4. Decoder, reward catalog, adapter version and roles untouched.
|
||||
|
||||
## 2026-09-22, rows 51 to 53: the road the museum had no row for, and a `BACK` with no box under it
|
||||
|
||||
Flagged live by the watchdog within the hour of v0.4.5 going out: rank 10 (PEWTER CITY) for five
|
||||
and a half hours, the objective rung 11's BOULDER BADGE whose place is the gym leader in map
|
||||
`0x36`, and since the 11:30 restart the macro starts were `GO FRONTIER` **1,235**, `BACK` **678**,
|
||||
`GO OBJECTIVE` 267, `GO OUT` 242, `YES` 169. Between 11:46 and 12:17 the ratchet fired **two**
|
||||
"Stuck: rolled back to PEWTER CITY" recoveries, attempts 0 -> 2. The fly was on map 52, the Pewter
|
||||
museum's ground floor. This is the residual the row-41 review named: `BACK` 189 times "in a text
|
||||
box" on map `0x02`, surrounded by `GO OBJECTIVE` and `GO FRONTIER`, with `GO FRONTIER` runs of 112.
|
||||
|
||||
`docs/design/macros.md` sections 12.13 to 12.16 are the design; this is the reproduction, the
|
||||
surveys and the before/after.
|
||||
|
||||
### Where the fly was standing
|
||||
|
||||
`examples/scene_probe.rs` from the live checkpoint:
|
||||
|
||||
- map `0x34` (52), **20x8**, the player at **(10, 7)** facing up -- standing on the museum's own
|
||||
front doormat, one of four `LAST_MAP` warps on the south wall;
|
||||
- the scene reads **`Unknown`**, not `Dialog`: `font=0x00` and **no text box at all**
|
||||
(`corners=(0x10,0x10,0x10,0x10)`), with `flags5=0x24` -- the cartridge holding the joypad. The
|
||||
pad is **`NEXT`, `BACK`**;
|
||||
- the objective is map `0x36` and `next_hop(Region { map: 52 }, 0x36)` answers **`None`**, because
|
||||
`neighbours(52)` is **empty**: the museum has no row on the map graph. So
|
||||
`objective_goals` is `[]`, `GO OBJECTIVE` is off the pad, and so are `GO SHOP` and `GO HEAL`;
|
||||
- the map grid decodes: **98 walkable tiles, 62 reachable from the door, 39 never stood on** --
|
||||
and `frontier_aims` offers **40** of them, almost all behind the admission desk on the east side.
|
||||
|
||||
### The three mechanisms, and none of them is the text box
|
||||
|
||||
1. **`BACK` was never in a text box.** It is on no overworld pad and on no dialog pad, so every one
|
||||
of the 678 was dealt by `Scene::Unknown` -- which is two states under one name. A screen this
|
||||
crate cannot name (the Pokedex, the trainer card, OPTION) is one, and the hunt's own attribution
|
||||
counts `unknown` frames as "a text box" (`dialog_map`), which is what made the residual read the
|
||||
way it did. The other is a frame of the **overworld** with the cartridge driving: `detect`'s
|
||||
overworld branch needs `controllable`, and a warp in flight, a scripted push-back or a guide
|
||||
walking the fly through a door all fail it. `NEXT` and `BACK` there are an A and a B pressed into
|
||||
somebody else's script.
|
||||
2. **The museum is not on the map graph**, which the row-41 review named as a residual and left:
|
||||
"adding the row would give the fly the road to the gym from indoors". Both rows come from the
|
||||
cartridge's own warp table rather than from counting: Pewter City names `0x34` at (14, 7) and at
|
||||
(19, 5) -- the museum's two doors -- and `0x34` names `0x35` at (7, 7), the staircase.
|
||||
3. **The museum's frontier is unreachable and the blocked ledger is a window.** A `GO FRONTIER`
|
||||
that can reach none of its goals refuses `no route` and writes all forty tiles to the ledger;
|
||||
ten brain minutes later they are candidates again and it refuses again, once per hold.
|
||||
|
||||
And the ratchet's two rollbacks were the ratchet working to contract: its stall window is restarted
|
||||
by *exploration*, and two museum floors and a town the run had already covered earn none. Entering
|
||||
a map for the first time does count -- a new map is a map's worth of tiles nobody has stood on --
|
||||
which is the half of the 2026-09-17 rule that already held and is now pinned by a test of its own.
|
||||
|
||||
| # | trap | trigger | test | fix, or why it is left |
|
||||
| ---: | --- | --- | --- | --- |
|
||||
| 51 | `Scene::Unknown` deals `NEXT` and `BACK` on a frame of the overworld the cartridge is driving, where an A and a B press are presses into a script | every warp, every scripted push-back, every guide that walks the fly somewhere -- `BACK` 678 starts in 47 live minutes, 189 of them on map `0x02` | `an_unknown_frame_with_no_box_on_it_deals_nothing`, `an_unknown_scene_is_dialog_with_advance_only`, `the_fly_reaches_the_pewter_gym_from_the_rung_ten_checkpoint` (ROM-gated: `BACK` in a box 0, unknown pads with no box 0) | **fixed**: the pad is dealt on whether a box is drawn. A scripted overworld frame is an empty pad the fly waits out, and it is the one empty pad that ends itself -- the cartridge gives the buttons back within a few frames |
|
||||
| 52 | a building with no row on the map graph has no neighbours, so from inside it there is no road to the objective and no errand either | the whole rung-10 stall: `GO OBJECTIVE`, `GO SHOP` and `GO HEAL` all off the pad on maps `0x34` and `0x35` | `the_museums_two_floors_know_the_road_to_the_gym`, `the_hop_count_is_the_road_measured_rather_than_named`, the ROM run below | **fixed**: two rows, both surveyed from the cartridge's warp table. The upper floor has no *area* -- no front door of its own, like a bedroom -- so it offers no errand, and its road out is the staircase |
|
||||
| 53 | a frontier the route search cannot reach is excluded by a ten brain minute window, so it comes back every ten minutes for ever | `GO FRONTIER` 1,235 starts in 47 minutes over two museum floors and a covered town; 39 unstood tiles on map `0x34`, almost all behind the admission desk | `a_frontier_no_walk_can_reach_takes_go_frontier_off_the_pad_and_keeps_it_off`, `a_frontier_mark_is_the_stood_ledgers_to_clear`, `standing_on_new_ground_is_what_clears_a_maps_frontier_mark` | **fixed**: the refusal is remembered per map with no window, beside the pushed-tile ledger of row 37, and cleared by the fly standing on ground of that map it had not stood on before -- the only event that can change which tiles it can reach |
|
||||
|
||||
### The ROM-gated run, from the live checkpoint
|
||||
|
||||
`FLY_PEWTER_CHECKPOINT`, macros mode, the stub rotation, 200,000 frames (55.8 brain minutes):
|
||||
|
||||
| measure | value |
|
||||
| --- | --- |
|
||||
| the checkpoint's map | `0x34`, the museum's ground floor, on its doormat |
|
||||
| reached **map `0x36`**, the gym's interior | **frame 2,423, on 15 macros** |
|
||||
| `TALK` on the pad inside the gym | yes -- and 15 `TALK` starts on map `0x36` |
|
||||
| `GO OBJECTIVE` bound on the gym's own pad | yes |
|
||||
| `BACK` on a `dialog` or `unknown` frame | **0** (189 in the before hunt, on map `0x02` alone) |
|
||||
| pads dealt on an `unknown` frame with nothing drawn | **0** |
|
||||
| `GO FRONTIER` starts on the museum's two floors | **0** (1,235 live over those floors and the town) |
|
||||
| `GO FRONTIER` starts overall | 1,247 -- 649 of them in Pewter City itself |
|
||||
| route | `0x34` -> Pewter City -> the gym, then the town's shops and houses |
|
||||
| the rank at the end | 10 (PEWTER CITY), **0 badges**: the road is open, the badge is not won |
|
||||
|
||||
The fly is out of the museum and into the gym in the first minute, and then spends the run in
|
||||
Pewter City's buildings: the town's errands are session state and the 11:30 restart re-armed both,
|
||||
so `GO OBJECTIVE` aims at the mart and the centre before the leader (section 13, and `BUY
|
||||
ANTIDOTE` 3, `BUY BALL` 2, `HEAL` 1 in the same run say it was paid).
|
||||
|
||||
### The trap hunt, before and after — and it does not improve
|
||||
|
||||
Twenty brain minutes, seed 20260917, 4 sweep threads, the same connectome and the same cartridge,
|
||||
from the release container's own rung-10 Pewter checkpoint, **driven by the brain**.
|
||||
|
||||
| measure | before (v0.4.5) | after |
|
||||
| --- | ---: | ---: |
|
||||
| distinct (map, tile) | **193** | 175 |
|
||||
| windows flagged | **59/73** | 69/73 |
|
||||
| macros started | 1283 | 1056 |
|
||||
| `BACK` starts | **334** | 133 |
|
||||
| `BACK` in a text box on map `0x02` | **291** | **0** |
|
||||
| `BACK` on any text box | 295 | **0** |
|
||||
| `GO FRONTIER` starts | 531 | 242 |
|
||||
| `GO HEAL` starts | 0 | **204** |
|
||||
| frames in `overworld` | 43,797 | 30,861 |
|
||||
| frames in `unknown` | 16,595 | 13,990 |
|
||||
| frames in `battle` | 6,948 | **20,894** |
|
||||
| recoveries | 0 | 0 |
|
||||
| the repeated sequence | `BACK, GO FRONTIER, GO FRONTIER, GO OBJECTIVE` x16 and `GO OUT, BACK, GO FRONTIER, GO FRONTIER` x37 | `GO FRONTIER, GO HEAL, GO ROUTE` x42 |
|
||||
|
||||
**The before arm is the live loop whole**: the two four-macro cycles it flags are the two the
|
||||
watchdog's own log named, `BACK` is 291 of them in a text box on map `0x02`, and the fly ends the
|
||||
run having covered 193 tiles of a 879-tile town.
|
||||
|
||||
**The after arm does not improve on either of the hunt's two measures, and this says so.** `BACK`
|
||||
in a box goes 295 -> 0, which is row 51 closed; the museum is left in the first window and never
|
||||
returned to, which is rows 52 and 53. What replaces the old cycle is a *new* one on the same five
|
||||
tiles -- `GO FRONTIER, GO HEAL, GO ROUTE` x42 from brain minute 1.0 to 8.5 -- and after that the
|
||||
fly covers ground (up to 103 tiles in a window) and spends the last four brain minutes inside one
|
||||
battle, which the hunt's tile rule flags as hard as it flags a loop. Battle frames go **6,948 ->
|
||||
20,894**, and section 15 of `docs/design/macros.md` measured the same trade the same way: a fly
|
||||
that covers more ground walks into more grass, and the hunt cannot tell a long fight from a stall.
|
||||
|
||||
| # | trap | trigger | test | fix, or why it is left |
|
||||
| ---: | --- | --- | --- | --- |
|
||||
| 54 | `GO FRONTIER`, `GO HEAL` and `GO ROUTE` cycle on five tiles: three walks that each end where they began | measured in the **after** arm only, brain minutes 1.0 to 8.5, `GO HEAL` **204** starts at a mean net of 0.0 tiles and a mean reach of 0.0, `GO ROUTE` 211 at a net of 0.2 | -- | **named, not worked, and it is the next brief.** Two readings fit and the hunt cannot separate them: the errand walking the fly in and out of a building whose door is underfoot (row 2's shape, with `GO HEAL` in `GO ROUTE`'s place), and the windowed walk oscillation of 12.3's row 23 -- which the whole-map grid was built to end and which is back **because the grid is refused on every frame the fly is mid-step** (below). Rows 1, 2b, 23, 24 and 41 were each found this way |
|
||||
|
||||
### Residuals, named rather than worked around
|
||||
|
||||
- **The whole-map grid is refused while the fly is moving.** `map_grid` checks its decode against
|
||||
the screen buffer over the fly's tile and its four neighbours, and mid-step the two are one tile
|
||||
apart: `wYCoord` is the tile being walked *to* while the background is still scrolling. Measured
|
||||
on Pewter City from this checkpoint: standing still it decodes on **118 of 120** frames, and the
|
||||
frame the survey caught disagreed on three tiles by exactly one row in the direction of travel.
|
||||
A walk planned on such a frame is planned over the ten-by-nine window of section 15's "before",
|
||||
which is what row 23's oscillation is made of. The honest fix is a WRAM reading of "a step is in
|
||||
progress", which this crate's reviewed symbol list does not carry, so it is reported by the
|
||||
probes and left.
|
||||
- **The town's errands are session state**, so every restart re-arms them and `GO OBJECTIVE` aims
|
||||
at the mart and the centre before the rung's place, the gym included. Section 13's own design.
|
||||
- **`MOVE n` still reports `blocked`** with the move list drawn and its cursor placeable but not
|
||||
accepting input (row 50): 42 of 65 in the after arm. Unchanged since v0.4.3.
|
||||
|
||||
### Gates
|
||||
|
||||
- `cargo test --workspace` with `FLY_ROM` set: green except
|
||||
`flysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed`,
|
||||
which is the known debug-build boot failure on this box -- the service took 49.2 s to its first
|
||||
healthy `/healthz` and `/status` still read the booting header (`milestone.total` 1, the
|
||||
placeholder `simloop.rs` uses before the adapter exists). Pre-existing and unrelated.
|
||||
- `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
|
||||
through v0.4.5. Decoder, reward catalog, adapter version and roles untouched.
|
||||
|
|
|
|||
|
|
@ -264,6 +264,27 @@ pub trait MacroPalette: Send {
|
|||
/// Give up on whatever is running, because the sim loop is rolling the game back. The
|
||||
/// abandoned macro is reported by the next [`MacroPalette::take_finished`].
|
||||
fn cancel(&mut self);
|
||||
|
||||
/// Whether the last [`MacroPalette::observe`] saw the fly **nearer its objective than it has
|
||||
/// been** since that objective was set, measured in map hops.
|
||||
///
|
||||
/// The ratchet's stall window is reset by exploration -- one new tile
|
||||
/// (`docs/design/ladder.md`, the 2026-09-17 progress rule) -- and a fly crossing a town it
|
||||
/// has already covered to reach the rung's own door earns no new ground while it does it.
|
||||
/// That is the rung-10 stall of 2026-09-22 in one line: two "Stuck" rollbacks inside half an
|
||||
/// hour, both of them on a fly that was walking, both of them landing it back where it had
|
||||
/// started. Getting nearer the objective than this run has ever been is the other thing that
|
||||
/// is plainly progress, and it is a *level* rather than a counter so nothing is checkpointed
|
||||
/// and nothing can drift: it is true on the frame the distance falls and false after.
|
||||
///
|
||||
/// Read by the sim loop and by nothing else. No macro is ranked by it, no button is bound on
|
||||
/// it and it presses nothing (`docs/design/macros.md` section 12): it is the loop's own
|
||||
/// answer to "is this run getting somewhere".
|
||||
///
|
||||
/// The default is `false`, which is a palette with no objective to be nearer to.
|
||||
fn nearer_the_objective(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Every macro channel a game's palette can ever bind, in the contract's own order, or empty for
|
||||
|
|
|
|||
|
|
@ -364,6 +364,14 @@ pub trait MacroState: GameState {
|
|||
false
|
||||
}
|
||||
|
||||
/// Whether a `GO FRONTIER` on this map has already proved its frontier unreachable.
|
||||
///
|
||||
/// [`FrontierLedger`] is the evidence and the measurement. The default is `false`: a state
|
||||
/// that cannot answer has proved nothing, which is a fresh session.
|
||||
fn frontier_exhausted(&mut self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether the cartridge has pushed the fly off this tile of the loaded map.
|
||||
///
|
||||
/// [`PushedLedger`] is the evidence and the measurement. The default is `false`: a state that
|
||||
|
|
@ -465,6 +473,22 @@ pub trait MacroState: GameState {
|
|||
false
|
||||
}
|
||||
|
||||
/// Whether a text box is open at all: `wFontLoaded`'s bit, and nothing drawn.
|
||||
///
|
||||
/// The one thing that tells a screen with words on it from a frame of the overworld the
|
||||
/// cartridge happens to be driving, and [`super::palette::scene_set`] deals
|
||||
/// [`Scene::Unknown`]'s pad on it (**section 12.13**). `Unknown` is two different states
|
||||
/// wearing one name: a screen this crate cannot name -- the Pokedex, the trainer card,
|
||||
/// OPTION -- where `NEXT` and `BACK` are the A and B that leave it; and a *scripted* overworld
|
||||
/// frame, where `scene::detect` falls through to `Unknown` because the buttons are not
|
||||
/// reaching the player, and where an A or a B press is a press into somebody else's script.
|
||||
///
|
||||
/// The default is `false`, which narrows: with no reading, `Unknown` deals nothing and the
|
||||
/// fly waits, which is what the doctrine says a scene with nothing to press does.
|
||||
fn text_open(&mut self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether the box on screen is the two-option YES/NO prompt rather than a plain text box.
|
||||
///
|
||||
/// `pokemon_red::state::yes_no_prompt`: the border `DisplayTwoOptionMenu` draws plus the
|
||||
|
|
@ -612,9 +636,13 @@ impl StoodLedger for NoStood {
|
|||
pub struct Stood(std::collections::BTreeSet<(u8, Tile)>);
|
||||
|
||||
impl Stood {
|
||||
/// Record the tile the fly is standing on. Idempotent.
|
||||
pub fn record(&mut self, map: u8, tile: Tile) {
|
||||
self.0.insert((map, tile));
|
||||
/// Record the tile the fly is standing on, and say whether it is ground this run had not
|
||||
/// stood on before. Idempotent.
|
||||
///
|
||||
/// The answer is what clears a map's frontier mark ([`Frontiers`], section 12.14): new
|
||||
/// ground under the fly is the one thing that can have changed which tiles it can reach.
|
||||
pub fn record(&mut self, map: u8, tile: Tile) -> bool {
|
||||
self.0.insert((map, tile))
|
||||
}
|
||||
|
||||
/// How much ground this session has watched, for a log line and the tests.
|
||||
|
|
@ -633,6 +661,75 @@ impl StoodLedger for Stood {
|
|||
}
|
||||
}
|
||||
|
||||
/// Maps whose frontier the run has proved it cannot reach any more.
|
||||
///
|
||||
/// **The rung-10 museum and Pewter City, 2026-09-22.** `GO FRONTIER` ran 1,235 times in 47
|
||||
/// minutes over two museum floors and a town whose walkable ground the fly had already covered.
|
||||
/// The ground it was aiming at was real -- the museum's exhibit hall behind the admission desk,
|
||||
/// the far side of a fence -- and unreachable: 98 walkable tiles on map `0x34`, 62 of them
|
||||
/// reachable from the door, 39 never stood on and all but a handful of those behind the desk.
|
||||
/// A walk that can reach none of its goals refuses `no route` and writes every one of them to
|
||||
/// the blocked ledger ([`Targets`]), which is a **window**: ten brain minutes later all forty
|
||||
/// tiles were candidates again, the button was back on the pad, and the refusal happened again.
|
||||
/// A window is right for a target a person is standing in front of and wrong for ground the map
|
||||
/// has fenced off.
|
||||
///
|
||||
/// So the refusal is remembered per map instead. It is written when a `GO FRONTIER` refuses for
|
||||
/// want of a route -- the measured fact "from here, no unstood tile of this map can be walked
|
||||
/// to" -- and it is cleared the moment the fly **stands somewhere on that map it has not stood
|
||||
/// before**, because that is the only thing that can have changed the answer: a door opened, a
|
||||
/// script carried the fly through, somebody moved out of a doorway. Not on re-entering the map,
|
||||
/// which is the loop the window made.
|
||||
///
|
||||
/// Session state beside [`Talked`], [`Stood`], [`Areas`] and [`Pushed`], never checkpointed: a
|
||||
/// restored run tries the frontier once more, which is the honest answer for a ledger that did
|
||||
/// not survive.
|
||||
pub trait FrontierLedger {
|
||||
/// Whether a `GO FRONTIER` has proved this map's remaining frontier unreachable.
|
||||
fn frontier_exhausted(&self, map: u8) -> bool;
|
||||
}
|
||||
|
||||
/// A ledger that has proved nothing: every map's frontier is still worth a walk.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct NoFrontiers;
|
||||
|
||||
impl FrontierLedger for NoFrontiers {
|
||||
fn frontier_exhausted(&self, _map: u8) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// The session's own record of which maps have nothing left to walk to.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Frontiers(std::collections::BTreeSet<u8>);
|
||||
|
||||
impl Frontiers {
|
||||
/// Record that `map`'s frontier could not be reached. Idempotent.
|
||||
pub fn record(&mut self, map: u8) {
|
||||
self.0.insert(map);
|
||||
}
|
||||
|
||||
/// Forget `map`'s mark, because the run has just stood somewhere on it that it had not.
|
||||
pub fn clear(&mut self, map: u8) {
|
||||
self.0.remove(&map);
|
||||
}
|
||||
|
||||
/// How many maps are marked, for a log line and the tests.
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl FrontierLedger for Frontiers {
|
||||
fn frontier_exhausted(&self, map: u8) -> bool {
|
||||
self.0.contains(&map)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tiles the cartridge has pushed the fly off, as the macros ask it.
|
||||
///
|
||||
/// **The Viridian private-property tile** (2026-09-17, `infra/docs/macros-traps.md` row 37).
|
||||
|
|
|
|||
|
|
@ -18,10 +18,10 @@ use crate::macros::{
|
|||
|
||||
use super::super::mapgrid::MapGrids;
|
||||
use super::super::state::PokeState;
|
||||
use super::cartridge::{Areas, MacroState, Pushed, Stood, Talked, Targets, Tile};
|
||||
use super::cartridge::{Areas, Frontiers, MacroState, Pushed, Stood, Talked, Targets, Tile};
|
||||
use super::geography;
|
||||
use super::executor::{MacroAbort, MacroMachine, Refusal};
|
||||
use super::palette::{MacroId, Palette};
|
||||
use super::palette::{self, MacroId, Palette};
|
||||
use super::plan;
|
||||
use super::state::{GameState, Scene};
|
||||
|
||||
|
|
@ -64,6 +64,15 @@ pub struct PokemonPalette {
|
|||
/// centre's own map -- which is the moment the errand is discharged. A purchase or a heal marks
|
||||
/// nothing: what the errand asked for was the visit. Session state, not checkpointed.
|
||||
areas: Areas,
|
||||
/// Maps whose frontier a `GO FRONTIER` has proved unreachable (`docs/design/macros.md`
|
||||
/// section 12.14).
|
||||
///
|
||||
/// Owned here beside the other session ledgers and written from the same two places they are:
|
||||
/// the machine's refusal on one side and the frame the fly is standing on new ground on the
|
||||
/// other. Like [`Pushed`] it has no window -- ground the map has fenced off is still fenced
|
||||
/// off ten brain minutes later -- and unlike it, it is *cleared*, by the only event that can
|
||||
/// change the answer.
|
||||
frontiers: Frontiers,
|
||||
/// Tiles the cartridge pushes the fly off (`infra/docs/macros-traps.md` row 37).
|
||||
///
|
||||
/// Owned here beside the other session ledgers and for the same reason. Unlike the target
|
||||
|
|
@ -79,6 +88,17 @@ pub struct PokemonPalette {
|
|||
/// 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 fewest map hops between the fly and its objective this run has managed, and which
|
||||
/// objective that was (`docs/design/macros.md` section 12.15).
|
||||
///
|
||||
/// Session state beside the ledgers and never checkpointed, and unlike them it is not a fact
|
||||
/// about the map at all: it is the one reading the *sim loop* takes from the macro layer, for
|
||||
/// the ratchet's stall window. The objective is carried with the number because the ladder's
|
||||
/// next rung changes as the run climbs, and "nearer" means nothing across two different
|
||||
/// places.
|
||||
nearest: Option<(u8, u32)>,
|
||||
/// Whether the last `observe` was the frame that number fell on.
|
||||
nearer: bool,
|
||||
/// 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
|
||||
|
|
@ -101,8 +121,11 @@ impl PokemonPalette {
|
|||
targets: Targets::new(),
|
||||
stood: Stood::default(),
|
||||
areas: Areas::default(),
|
||||
frontiers: Frontiers::default(),
|
||||
pushed: Pushed::default(),
|
||||
grids: MapGrids::default(),
|
||||
nearest: None,
|
||||
nearer: false,
|
||||
now_ms: 0.0,
|
||||
}
|
||||
}
|
||||
|
|
@ -137,6 +160,11 @@ impl PokemonPalette {
|
|||
self.pushed.len()
|
||||
}
|
||||
|
||||
/// How many maps have proved their frontier unreachable, for a log line and the tests.
|
||||
pub fn exhausted(&self) -> usize {
|
||||
self.frontiers.len()
|
||||
}
|
||||
|
||||
/// Take whatever the machine's last finished macro earned into the session's ledgers.
|
||||
fn record_talk(&mut self) {
|
||||
if let Some((map, target)) = self.machine.take_talked() {
|
||||
|
|
@ -154,6 +182,11 @@ impl PokemonPalette {
|
|||
if let Some((map, tile)) = self.machine.take_pushed() {
|
||||
self.pushed.record(map, tile);
|
||||
}
|
||||
// A frontier the walk could not reach any of: a fact about this map's ground, with no
|
||||
// window on it (section 12.14).
|
||||
if let Some(map) = self.machine.take_exhausted() {
|
||||
self.frontiers.record(map);
|
||||
}
|
||||
if let Some((map, target, closer)) = self.machine.take_timeout() {
|
||||
self.targets.record_timeout(map, target, closer);
|
||||
}
|
||||
|
|
@ -167,14 +200,25 @@ impl MacroPalette for PokemonPalette {
|
|||
}
|
||||
|
||||
fn observe(&mut self, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger) -> Observed {
|
||||
let (scene, bindings, standing) = {
|
||||
let (scene, bindings, standing, approach) = {
|
||||
let Self {
|
||||
machine, mode, palette: cached, talked, targets, stood, areas, pushed, grids, ..
|
||||
machine,
|
||||
mode,
|
||||
palette: cached,
|
||||
talked,
|
||||
targets,
|
||||
stood,
|
||||
areas,
|
||||
frontiers,
|
||||
pushed,
|
||||
grids,
|
||||
..
|
||||
} = self;
|
||||
let mut state = PokeState::with_ledgers(
|
||||
memory, ledger, talked, targets, &*stood, &*areas, &*pushed,
|
||||
)
|
||||
.caching_grid(grids);
|
||||
.caching_grid(grids)
|
||||
.with_frontiers(&*frontiers);
|
||||
// `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.
|
||||
|
|
@ -193,13 +237,27 @@ impl MacroPalette for PokemonPalette {
|
|||
// -- the coordinates and the loaded map header are from different frames, and a tile
|
||||
// recorded from that pair is a tile of nowhere.
|
||||
let standing = (!state.scripted()).then(|| state.player()).flatten();
|
||||
// How far the objective is, over the same map graph `GO OBJECTIVE` walks (section
|
||||
// 12.15). Read from the same frame and the same state everything else is, and only
|
||||
// where the fly is its own master, for the same reason the ground is.
|
||||
let approach = standing.and_then(|player| {
|
||||
let objective = palette::objective_place(&mut state)?;
|
||||
let hops =
|
||||
geography::hops(geography::region_at(player.map, player.y), objective.map)?;
|
||||
Some((objective.map, hops))
|
||||
});
|
||||
*cached = Some(palette);
|
||||
(scene, bindings, standing)
|
||||
(scene, bindings, standing, approach)
|
||||
};
|
||||
// Section 12.7: the macro layer's own answer to "has the run stood here", because the
|
||||
// adapter's reward ledger cannot record a doormat.
|
||||
if let Some(player) = standing {
|
||||
self.stood.record(player.map, Tile::new(player.x, player.y));
|
||||
// New ground under the fly is the one thing that can change which tiles of this map
|
||||
// it can reach, so it is what clears the map's frontier mark (section 12.14). A tile
|
||||
// the ledger already had changes nothing and clears nothing.
|
||||
if self.stood.record(player.map, Tile::new(player.x, player.y)) {
|
||||
self.frontiers.clear(player.map);
|
||||
}
|
||||
// Section 13's `areaVisited(kind, area)`: the errand is paid on *entering*, so the
|
||||
// ledger is written from the same frame that records the ground. Standing on the
|
||||
// building's own map is the whole test -- the fly is inside it -- and it is written
|
||||
|
|
@ -212,6 +270,22 @@ impl MacroPalette for PokemonPalette {
|
|||
self.areas.record(kind, area);
|
||||
}
|
||||
}
|
||||
// Section 12.15: nearer the objective than this run has ever been, which is the other
|
||||
// thing that is plainly progress and which the ratchet's stall window cannot see in the
|
||||
// exploration ledger. A level, true on the frame the number falls and false after, so
|
||||
// there is nothing to checkpoint and nothing to drift. A different objective starts the
|
||||
// measurement again: the ladder's next rung moves as the run climbs and "nearer" means
|
||||
// nothing across two different places.
|
||||
self.nearer = match (self.nearest, approach) {
|
||||
(_, None) => false,
|
||||
(None, Some(_)) => false,
|
||||
(Some((was, best)), Some((map, hops))) => map == was && hops < best,
|
||||
};
|
||||
self.nearest = match (self.nearest, approach) {
|
||||
(_, None) => self.nearest,
|
||||
(Some((was, best)), Some((map, hops))) if map == was => Some((map, best.min(hops))),
|
||||
(_, Some(now)) => Some(now),
|
||||
};
|
||||
// The talked entry `observe_frame` may just have earned, into the ledger the next frame
|
||||
// reads.
|
||||
self.record_talk();
|
||||
|
|
@ -240,7 +314,8 @@ impl MacroPalette for PokemonPalette {
|
|||
&self.areas,
|
||||
&self.pushed,
|
||||
)
|
||||
.caching_grid(&mut self.grids);
|
||||
.caching_grid(&mut self.grids)
|
||||
.with_frontiers(&self.frontiers);
|
||||
self.machine.start(&palette, slot, &mut state)
|
||||
};
|
||||
let started = match begun {
|
||||
|
|
@ -281,7 +356,8 @@ impl MacroPalette for PokemonPalette {
|
|||
&self.areas,
|
||||
&self.pushed,
|
||||
)
|
||||
.caching_grid(&mut self.grids);
|
||||
.caching_grid(&mut self.grids)
|
||||
.with_frontiers(&self.frontiers);
|
||||
self.machine.step(&mut state)
|
||||
};
|
||||
// A macro that just finished may have been the press that talked to something; the ledger
|
||||
|
|
@ -305,6 +381,10 @@ impl MacroPalette for PokemonPalette {
|
|||
Some((name, outcome(abort)))
|
||||
}
|
||||
|
||||
fn nearer_the_objective(&self) -> bool {
|
||||
self.nearer
|
||||
}
|
||||
|
||||
fn cancel(&mut self) {
|
||||
self.machine.cancel();
|
||||
// A cancelled macro talked to nothing, and a stale entry would silence a person for the
|
||||
|
|
@ -317,8 +397,10 @@ impl MacroPalette for PokemonPalette {
|
|||
while self.machine.take_blocked().is_some() {}
|
||||
let _ = self.machine.take_timeout();
|
||||
let _ = self.machine.take_reached();
|
||||
// A rollback is not the map pushing the fly anywhere.
|
||||
// A rollback is not the map pushing the fly anywhere, nor its frontier going out of
|
||||
// reach: the fly is about to be standing somewhere else.
|
||||
let _ = self.machine.take_pushed();
|
||||
let _ = self.machine.take_exhausted();
|
||||
// The cached palette was dealt for a frame that is being thrown away. Dropping it makes
|
||||
// the next `start` before the next `observe` a nameless refusal, which presses nothing
|
||||
// and reports nothing, rather than a named refusal against a scene that no longer exists.
|
||||
|
|
@ -373,6 +455,8 @@ mod tests {
|
|||
use crate::adapter::{MapEdge, MapExit};
|
||||
use crate::macros::NoLedger;
|
||||
use crate::pokemon_red::fake_wram::{REDS_HOUSE_1F, Wram};
|
||||
use crate::pokemon_red::macros::geography::Amenity;
|
||||
use crate::pokemon_red::maps;
|
||||
use crate::pokemon_red::macros::cartridge::{Edge, ExitId, MacroState};
|
||||
|
||||
/// A ledger with one exit in it, for the wiring test below.
|
||||
|
|
@ -401,6 +485,95 @@ mod tests {
|
|||
assert_eq!(palette.take_finished(), None);
|
||||
}
|
||||
|
||||
/// A ledger whose objective is one map, for the approach reading below.
|
||||
struct Bound(u8);
|
||||
|
||||
impl RunLedger for Bound {
|
||||
fn exit_visited(&self, _exit: MapExit) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn objective(&self) -> Option<crate::adapter::MapPlace> {
|
||||
Some(crate::adapter::MapPlace {
|
||||
map: self.0,
|
||||
tile: None,
|
||||
warp: None,
|
||||
edge: None,
|
||||
target: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_objective_getting_nearer_is_read_once_per_step_of_the_road() {
|
||||
// Section 12.15, the rung-10 stall. The ratchet's stall window is reset by ground never
|
||||
// stood on, and a fly walking a road it has already covered earns none -- so this is the
|
||||
// other reading, and what it has to be is a *level* that is true on the frame the hop
|
||||
// count falls and false on every frame after it. Pewter's own museum is the road: the
|
||||
// upper floor is three hops from the gym, the ground floor two, the town one.
|
||||
let mut wram = Wram::new();
|
||||
wram.started().map(maps::PEWTER_MUSEUM_2F, 4, 4, 3, 6).facing(0).house_collision();
|
||||
let mut palette = PokemonPalette::new(7);
|
||||
// Both of Pewter's errands discharged, so the objective is the rung's own place for the
|
||||
// whole test: section 13 puts an unvisited mart or centre *ahead* of it, and that is a
|
||||
// different place to be near.
|
||||
palette.areas.record(Amenity::Mart, maps::PEWTER_CITY);
|
||||
palette.areas.record(Amenity::Center, maps::PEWTER_CITY);
|
||||
let ledger = Bound(maps::PEWTER_GYM);
|
||||
|
||||
palette.observe(&mut wram, &ledger);
|
||||
assert!(!palette.nearer_the_objective(), "the first reading is a measurement, not a step");
|
||||
palette.observe(&mut wram, &ledger);
|
||||
assert!(!palette.nearer_the_objective(), "standing still is not nearer");
|
||||
|
||||
wram.map(maps::PEWTER_MUSEUM_1F, 4, 4, 3, 6);
|
||||
palette.observe(&mut wram, &ledger);
|
||||
assert!(palette.nearer_the_objective(), "down the stairs is one hop nearer");
|
||||
palette.observe(&mut wram, &ledger);
|
||||
assert!(!palette.nearer_the_objective(), "and it is read once, not held");
|
||||
|
||||
// Back upstairs is not progress, and it does not undo the number either: the measurement
|
||||
// is the best this run has managed, so walking the road twice pays once.
|
||||
wram.map(maps::PEWTER_MUSEUM_2F, 4, 4, 3, 6);
|
||||
palette.observe(&mut wram, &ledger);
|
||||
assert!(!palette.nearer_the_objective());
|
||||
wram.map(maps::PEWTER_MUSEUM_1F, 4, 4, 3, 6);
|
||||
palette.observe(&mut wram, &ledger);
|
||||
assert!(!palette.nearer_the_objective(), "ground already gained is not gained again");
|
||||
|
||||
wram.map(maps::PEWTER_CITY, 4, 4, 3, 6);
|
||||
palette.observe(&mut wram, &ledger);
|
||||
assert!(palette.nearer_the_objective(), "out of the front door is nearer still");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standing_on_new_ground_is_what_clears_a_maps_frontier_mark() {
|
||||
// Section 12.14's other half, wired: the mark is written by a `GO FRONTIER` that could
|
||||
// reach none of its goals and cleared by the fly standing somewhere on that map it had
|
||||
// not stood on before -- the only event that can change which tiles it can reach. A tile
|
||||
// the stood ledger already has changes nothing, which is what keeps the mark from being
|
||||
// cleared by the fly pacing the ground it has covered.
|
||||
let mut wram = Wram::overworld();
|
||||
let mut palette = PokemonPalette::new(7);
|
||||
palette.frontiers.record(REDS_HOUSE_1F);
|
||||
assert_eq!(palette.exhausted(), 1);
|
||||
|
||||
palette.observe(&mut wram, &NoLedger);
|
||||
assert_eq!(palette.exhausted(), 0, "the first frame is new ground, so it clears");
|
||||
|
||||
palette.frontiers.record(REDS_HOUSE_1F);
|
||||
palette.observe(&mut wram, &NoLedger);
|
||||
assert_eq!(
|
||||
palette.exhausted(),
|
||||
1,
|
||||
"standing on the same tile again is not new ground and clears nothing"
|
||||
);
|
||||
|
||||
wram.map(REDS_HOUSE_1F, 4, 4, 3, 5);
|
||||
palette.observe(&mut wram, &NoLedger);
|
||||
assert_eq!(palette.exhausted(), 0, "a tile the run had not stood on clears it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_bound_slot_carries_a_name_and_a_gloss() {
|
||||
let mut wram = Wram::overworld();
|
||||
|
|
|
|||
|
|
@ -575,6 +575,14 @@ pub struct MacroMachine {
|
|||
/// that finds no route to *any* of its goals has failed at all of them, and each is a key.
|
||||
blocked: Vec<(u8, TargetKey)>,
|
||||
reached: Option<(u8, TargetKey)>,
|
||||
/// A map whose frontier a `GO FRONTIER` has just proved unreachable, waiting to be taken
|
||||
/// into the session's ledger ([`super::cartridge::Frontiers`], section 12.14).
|
||||
///
|
||||
/// The same shape as `pushed_tile` and for the same reason: it is a fact about the *ground*
|
||||
/// rather than about a target, so the blocked ledger's ten-minute window is the wrong home
|
||||
/// for it -- the window is what brought forty unreachable museum tiles back every ten
|
||||
/// minutes, once per hold, for hours.
|
||||
exhausted: Option<u8>,
|
||||
/// A tile the cartridge pushed the fly off, waiting to be taken into the session's ledger.
|
||||
///
|
||||
/// Row 37 of `infra/docs/macros-traps.md`: a scripted push-back is a fact about the *ground*,
|
||||
|
|
@ -651,6 +659,7 @@ impl MacroMachine {
|
|||
outcome: None,
|
||||
blocked: Vec::new(),
|
||||
reached: None,
|
||||
exhausted: None,
|
||||
pushed_tile: None,
|
||||
timed_out: None,
|
||||
resume: VecDeque::new(),
|
||||
|
|
@ -695,6 +704,13 @@ impl MacroMachine {
|
|||
// could not reach is what empties the list and takes the button off the pad.
|
||||
if let Some(map) = state.player().map(|player| player.map) {
|
||||
self.blocked.extend(unreachable.into_iter().map(|key| (map, key)));
|
||||
// And for the frontier, the same fact one level up: every tile of this map the
|
||||
// run has not stood on is unreachable from where the fly is standing. The
|
||||
// blocked ledger is a window and this is not -- ground the map has fenced off is
|
||||
// still fenced off ten brain minutes later (section 12.14).
|
||||
if spec.kind == MacroKind::GoFrontier {
|
||||
self.exhausted = Some(map);
|
||||
}
|
||||
}
|
||||
return refuse(self, Refusal::NoRoute);
|
||||
};
|
||||
|
|
@ -857,6 +873,11 @@ impl MacroMachine {
|
|||
self.reached.take()
|
||||
}
|
||||
|
||||
/// The map a `no route` from `GO FRONTIER` earned, taken rather than read (section 12.14).
|
||||
pub fn take_exhausted(&mut self) -> Option<u8> {
|
||||
self.exhausted.take()
|
||||
}
|
||||
|
||||
/// The tile a scripted push-back earned, taken rather than read (row 37).
|
||||
pub fn take_pushed(&mut self) -> Option<(u8, Tile)> {
|
||||
self.pushed_tile.take()
|
||||
|
|
@ -883,8 +904,10 @@ impl MacroMachine {
|
|||
// that so a cancelled queue cannot leak into the next macro's finish.
|
||||
self.blocked.clear();
|
||||
self.reached = None;
|
||||
// A rollback is not the map pushing the fly anywhere.
|
||||
// A rollback is not the map pushing the fly anywhere, and it is not the frontier being
|
||||
// out of reach either: the fly is about to be somewhere else entirely.
|
||||
self.pushed_tile = None;
|
||||
self.exhausted = None;
|
||||
self.timed_out = None;
|
||||
// A rollback puts the fly somewhere else on the map, so every suspended route is a route
|
||||
// from a tile it is no longer standing on. `take_resume` would refuse them one at a time;
|
||||
|
|
|
|||
|
|
@ -112,6 +112,14 @@ const LINKS: &[(u8, u8)] = &[
|
|||
(maps::VIRIDIAN_FOREST_NORTH_GATE, maps::ROUTE_2),
|
||||
(maps::VIRIDIAN_FOREST_NORTH_GATE, maps::VIRIDIAN_FOREST),
|
||||
(maps::PEWTER_GYM, maps::PEWTER_CITY),
|
||||
// The museum, both floors. It is on the graph for the same reason the gym is: the rung-10
|
||||
// loop of 2026-09-22 spent its hours on these two maps with `GO OBJECTIVE` off the pad
|
||||
// entirely, because a map with no row here has no neighbours, so `next_hop` answers nothing
|
||||
// and `area_of` answers nothing -- no road to the gym from indoors, and no errand either.
|
||||
// Both rows are the cartridge's own warp table, surveyed from the rung-10 checkpoint: Pewter
|
||||
// City names `$34` at (14, 7) and (19, 5), and `$34` names `$35` at (7, 7).
|
||||
(maps::PEWTER_MUSEUM_1F, maps::PEWTER_CITY),
|
||||
(maps::PEWTER_MUSEUM_2F, maps::PEWTER_MUSEUM_1F),
|
||||
(maps::PEWTER_MART, maps::PEWTER_CITY),
|
||||
(maps::PEWTER_POKECENTER, maps::PEWTER_CITY),
|
||||
(maps::MT_MOON_1F, maps::ROUTE_3),
|
||||
|
|
@ -333,6 +341,39 @@ pub fn next_hop(from: Region, to: u8) -> Option<u8> {
|
|||
None
|
||||
}
|
||||
|
||||
/// How many hops the shortest known route from `from` to the map `to` takes, or `None` when none
|
||||
/// is known.
|
||||
///
|
||||
/// [`next_hop`]'s own breadth-first walk, counting instead of naming: `Some(0)` when the fly is
|
||||
/// already on `to`, `Some(1)` for a door out of this map into it, and `None` for a map the table
|
||||
/// cannot route to -- which is the same "nothing is guessed" [`next_hop`] answers with.
|
||||
///
|
||||
/// What it is *for* is the ratchet (`docs/design/ladder.md`, the 2026-09-17 progress rule): the
|
||||
/// stall window is reset by exploration, and a fly crossing a town it has already covered to
|
||||
/// reach the rung's own door earns no new ground while it does it. "Nearer the objective than
|
||||
/// this run has ever been" is the other thing that is plainly progress, and it is this number
|
||||
/// falling. Nothing about the *choice* reads it: no macro is ranked by it and no button is bound
|
||||
/// on it.
|
||||
pub fn hops(from: Region, to: u8) -> Option<u32> {
|
||||
if from.map == to {
|
||||
return Some(0);
|
||||
}
|
||||
let mut seen: HashSet<Region> = HashSet::from([from]);
|
||||
let mut queue: VecDeque<(Region, u32)> = VecDeque::new();
|
||||
queue.push_back((from, 0));
|
||||
while let Some((region, depth)) = queue.pop_front() {
|
||||
if region.map == to {
|
||||
return Some(depth);
|
||||
}
|
||||
for next in region_neighbours(region) {
|
||||
if seen.insert(next) {
|
||||
queue.push_back((next, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A building an area has at most one of, and which this run may not have been into yet.
|
||||
///
|
||||
/// `docs/design/macros.md` section 13: the two errands. A kind rather than two parallel tables
|
||||
|
|
@ -551,6 +592,68 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_museums_two_floors_know_the_road_to_the_gym() {
|
||||
// The rung-10 loop of 2026-09-22: five and a half hours on `PEWTER_CITY` with the
|
||||
// objective two doors away, and inside the museum `GO OBJECTIVE` was off the pad because
|
||||
// the map had no row here at all. Both floors, because the fly spent the run on both.
|
||||
let at = |map: u8| Region::whole(map);
|
||||
assert_eq!(
|
||||
next_hop(at(maps::PEWTER_MUSEUM_1F), maps::PEWTER_GYM),
|
||||
Some(maps::PEWTER_CITY),
|
||||
"the way to the gym from the museum's ground floor is out of its front door"
|
||||
);
|
||||
assert_eq!(
|
||||
next_hop(at(maps::PEWTER_MUSEUM_2F), maps::PEWTER_GYM),
|
||||
Some(maps::PEWTER_MUSEUM_1F),
|
||||
"and from the upper floor it is the staircase"
|
||||
);
|
||||
assert_eq!(next_hop(at(maps::PEWTER_CITY), maps::PEWTER_GYM), Some(maps::PEWTER_GYM));
|
||||
// The other direction, which is what `GO OBJECTIVE` asks when the errand is the museum's
|
||||
// own town: the museum is one hop from Pewter City and two from its upper floor.
|
||||
assert_eq!(
|
||||
next_hop(at(maps::PEWTER_CITY), maps::PEWTER_MUSEUM_2F),
|
||||
Some(maps::PEWTER_MUSEUM_1F)
|
||||
);
|
||||
// And the area, which is what puts the town's errands on the pad indoors. The upper
|
||||
// floor has no area, exactly as `REDS_HOUSE_2F` has none: a floor with no front door of
|
||||
// its own is not "in" anywhere, so it offers no errand -- and the road out is still the
|
||||
// staircase above, which is what the fly needs there.
|
||||
assert_eq!(area_of(maps::PEWTER_MUSEUM_1F), Some(maps::PEWTER_CITY));
|
||||
assert_eq!(area_of(maps::PEWTER_MUSEUM_2F), None);
|
||||
// The museum is neither a mart nor a centre, so it is nobody's errand.
|
||||
assert_eq!(amenity_at(maps::PEWTER_MUSEUM_1F), None);
|
||||
assert_eq!(amenity_at(maps::PEWTER_MUSEUM_2F), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hop_count_is_the_road_measured_rather_than_named() {
|
||||
let at = |map: u8| Region::whole(map);
|
||||
assert_eq!(hops(at(maps::PEWTER_CITY), maps::PEWTER_CITY), Some(0), "already there");
|
||||
assert_eq!(hops(at(maps::PEWTER_CITY), maps::PEWTER_GYM), Some(1), "one door");
|
||||
assert_eq!(hops(at(maps::PEWTER_MUSEUM_1F), maps::PEWTER_GYM), Some(2));
|
||||
assert_eq!(hops(at(maps::PEWTER_MUSEUM_2F), maps::PEWTER_GYM), Some(3));
|
||||
// The count agrees with the hop by hop answer, which is the thing it has to: walking the
|
||||
// road one `next_hop` at a time takes exactly this many steps.
|
||||
let mut here = at(maps::PEWTER_MUSEUM_2F);
|
||||
let mut steps = 0;
|
||||
while let Some(hop) = next_hop(here, maps::PEWTER_GYM) {
|
||||
here = Region::whole(hop);
|
||||
steps += 1;
|
||||
assert!(steps < 10, "the road to the gym does not wander");
|
||||
}
|
||||
assert_eq!(here.map, maps::PEWTER_GYM);
|
||||
assert_eq!(steps, 3);
|
||||
// A split map is measured from the piece the fly is standing in, exactly as `next_hop` is.
|
||||
assert_eq!(hops(region_at(maps::ROUTE_2, 11), maps::PEWTER_CITY), Some(1));
|
||||
// Five from the south half, because the belt of trees between the halves needs CUT and
|
||||
// the road is the forest: the south gate, the forest, the north gate, Route 2's north
|
||||
// half, Pewter.
|
||||
assert_eq!(hops(region_at(maps::ROUTE_2, 43), maps::PEWTER_CITY), Some(5));
|
||||
// And nothing is guessed.
|
||||
assert_eq!(hops(at(maps::PALLET_TOWN), 0xf0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maps_area_is_its_town_indoors_and_out() {
|
||||
// Outdoors a map is its own area, including a route -- which has no amenity row, so it
|
||||
|
|
|
|||
|
|
@ -476,7 +476,26 @@ pub fn scene_set(scene: Scene, state: &mut dyn MacroState) -> Vec<MacroKind> {
|
|||
// Section 2 treats `Unknown` like a dialog -- advance only -- and `BACK` is the other half
|
||||
// of advancing, because `Unknown` is also where the Pokédex, the trainer card and OPTION
|
||||
// land (`docs/design/macros-wram.md`) and B is what leaves all three.
|
||||
Scene::Unknown => vec![Next, Back],
|
||||
//
|
||||
// **Only while there is something on screen with words in it** (section 12.13, the
|
||||
// rung-10 Pewter loop). `Unknown` is the detector's residue and it holds two states, not
|
||||
// one: a screen this crate cannot name, where A and B are what leave it, and a frame of
|
||||
// the *overworld* where the cartridge is driving -- a warp in flight, a scripted
|
||||
// push-back, the museum guide walking the fly through the door -- which
|
||||
// `scene::detect` calls `Unknown` because the buttons are not reaching the player. On the
|
||||
// second, `NEXT` and `BACK` are an A and a B pressed into somebody else's script: they
|
||||
// change nothing, they complete where the fly stands, and they are section 12.2's trap
|
||||
// with no text box to advance. Measured live on rung 10: `BACK` **678** macro starts in
|
||||
// 47 minutes, 189 of them on map `0x02` with no box on screen at all. So the pad is
|
||||
// empty there and the fly waits, which is the doctrine's own answer for a scene with
|
||||
// nothing sensible to press -- and the cartridge gives the buttons back by itself.
|
||||
Scene::Unknown => {
|
||||
if state.text_open() {
|
||||
vec![Next, Back]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
// `NEXT`, `YES`, `NO` for a plain box -- A and B both advance one, and what the three
|
||||
// buy is the fly being *able* to answer no. On the one box that **is** a choice, the pad
|
||||
// is the choice's own answers: section 12.12.
|
||||
|
|
@ -1369,6 +1388,16 @@ pub fn frontier_aims(state: &mut dyn MacroState) -> Vec<(Tile, Facing)> {
|
|||
if counter_pending(state) {
|
||||
return Vec::new();
|
||||
}
|
||||
// **A map whose frontier this run has already proved it cannot reach** (section 12.14, the
|
||||
// rung-10 museum). The unstood tiles are still there and still unstood -- the exhibit hall
|
||||
// behind the admission desk, the far side of a fence -- and a walk that could not reach any
|
||||
// of them refuses `no route`, writes them all to the blocked ledger and comes back ten brain
|
||||
// minutes later when the window lapses, for ever. The mark has no window; it is cleared by
|
||||
// the fly standing somewhere on this map it had not stood before, which is the only thing
|
||||
// that can have changed the answer.
|
||||
if state.frontier_exhausted() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut local: Vec<(Tile, Facing)> = Vec::new();
|
||||
for (tile, facing) in path::frontier(state) {
|
||||
// A tile the blocked ledger is resting, or one a script pushes the fly off (row 37).
|
||||
|
|
|
|||
|
|
@ -115,6 +115,11 @@ struct World {
|
|||
areas: BTreeSet<(Amenity, u8)>,
|
||||
/// Tiles the cartridge pushes the fly off (`infra/docs/macros-traps.md` row 37).
|
||||
pushes: BTreeSet<Tile>,
|
||||
/// Maps a `GO FRONTIER` has proved it cannot reach the frontier of (section 12.14).
|
||||
///
|
||||
/// Written by [`drive`] from the machine, exactly as `PokemonPalette` writes it in the sim
|
||||
/// loop, so a test sees the ledger the next decision would see.
|
||||
exhausted: BTreeSet<u8>,
|
||||
visited: BTreeSet<ExitId>,
|
||||
/// Tiles of this map the run has stood on, for `GO FRONTIER` and the plan's "untalked" test.
|
||||
stood: BTreeSet<Tile>,
|
||||
|
|
@ -164,6 +169,13 @@ struct World {
|
|||
/// A frame at which the cartridge heals the party, which is what a Pokémon Center does while
|
||||
/// its text box is open (`docs/design/macros.md` section 13).
|
||||
heal_at: Option<u32>,
|
||||
/// Whether a text box is drawn on a scene that is not [`Scene::Dialog`]
|
||||
/// ([`MacroState::text_open`]).
|
||||
///
|
||||
/// `Dialog` *is* an open box, so the reading is true there by construction; the field is for
|
||||
/// `Unknown`, which holds both a screen with words on it -- the Pokedex, the trainer card,
|
||||
/// OPTION -- and a frame of the overworld the cartridge is driving (section 12.13).
|
||||
box_open: bool,
|
||||
/// Whether the two-option YES/NO box is the thing on screen ([`MacroState::yes_no_prompt`]).
|
||||
///
|
||||
/// A field rather than a shape of the `list`, because on the cartridge it is a *drawn box*
|
||||
|
|
@ -225,6 +237,7 @@ impl World {
|
|||
counters: BTreeSet::new(),
|
||||
areas: BTreeSet::new(),
|
||||
pushes: BTreeSet::new(),
|
||||
exhausted: BTreeSet::new(),
|
||||
stock: Vec::new(),
|
||||
visited: BTreeSet::new(),
|
||||
stood: BTreeSet::new(),
|
||||
|
|
@ -243,6 +256,7 @@ impl World {
|
|||
pending: None,
|
||||
switch: None,
|
||||
heal_at: None,
|
||||
box_open: false,
|
||||
prompt: false,
|
||||
scripted: false,
|
||||
scripted_at: None,
|
||||
|
|
@ -658,6 +672,17 @@ impl MacroState for World {
|
|||
self.scripted
|
||||
}
|
||||
|
||||
/// `wFontLoaded` is set for every dialogue box, which is what `Dialog` is; on `Unknown` the
|
||||
/// fixture has to say, because that is the reading that tells a screen from a scripted
|
||||
/// overworld frame (section 12.13).
|
||||
fn text_open(&mut self) -> bool {
|
||||
self.scene == Scene::Dialog || self.box_open
|
||||
}
|
||||
|
||||
fn frontier_exhausted(&mut self) -> bool {
|
||||
self.exhausted.contains(&self.map)
|
||||
}
|
||||
|
||||
/// A drawn box is what the reading rests on, so a prompt cannot be open with no box open:
|
||||
/// `pokemon_red::state::yes_no_prompt` gates on `wFontLoaded` before it looks at the tiles.
|
||||
fn yes_no_prompt(&mut self) -> bool {
|
||||
|
|
@ -758,6 +783,9 @@ fn drive(
|
|||
while let Some((map, target)) = machine.take_blocked() {
|
||||
world.targets.record_blocked(map, target);
|
||||
}
|
||||
if let Some(map) = machine.take_exhausted() {
|
||||
world.exhausted.insert(map);
|
||||
}
|
||||
return Err(refused);
|
||||
}
|
||||
// A walk's cap is its plan's, so the bound here is the ceiling on any macro plus slack.
|
||||
|
|
@ -779,6 +807,9 @@ fn drive(
|
|||
while let Some((map, target)) = machine.take_blocked() {
|
||||
world.targets.record_blocked(map, target);
|
||||
}
|
||||
if let Some(map) = machine.take_exhausted() {
|
||||
world.exhausted.insert(map);
|
||||
}
|
||||
if let Some((map, target, closer)) = machine.take_timeout() {
|
||||
world.targets.record_timeout(map, target, closer);
|
||||
}
|
||||
|
|
@ -990,12 +1021,31 @@ fn the_dialog_row_is_next_yes_and_no() {
|
|||
#[test]
|
||||
fn an_unknown_scene_is_dialog_with_advance_only() {
|
||||
let mut world = World::room();
|
||||
world.box_open = true;
|
||||
let palette = Palette::for_scene(Scene::Unknown, &mut world);
|
||||
// Row 9 of `infra/docs/macros-traps.md`, closed by section 13.1: B is what leaves the Pokédex,
|
||||
// the trainer card and OPTION, and all three read `Unknown`.
|
||||
// the trainer card and OPTION, and all three read `Unknown` with a box drawn.
|
||||
assert_eq!(names(&palette), ["NEXT", "BACK"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_frame_with_no_box_on_it_deals_nothing() {
|
||||
// Section 12.13, the rung-10 Pewter loop. The other half of `Unknown` is the overworld with
|
||||
// the cartridge driving -- a warp in flight, a push-back, the museum guide walking the fly in
|
||||
// -- where `scene::detect` falls through because the buttons are not reaching the player.
|
||||
// There is no box to advance and no screen to leave, so `NEXT` and `BACK` are an A and a B
|
||||
// pressed into somebody else's script: they change nothing and they complete where the fly
|
||||
// stands, which is section 12.2's trap. The pad is empty and the fly waits.
|
||||
let mut world = World::room();
|
||||
world.scripted = true;
|
||||
assert!(!world.text_open());
|
||||
let palette = Palette::for_scene(Scene::Unknown, &mut world);
|
||||
assert_eq!(palette.bound(), 0, "an A and a B into a script are not buttons");
|
||||
// And the moment the cartridge draws something, both are back.
|
||||
world.box_open = true;
|
||||
assert_eq!(names(&Palette::for_scene(Scene::Unknown, &mut world)), ["NEXT", "BACK"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_menu_row_is_close_confirm_back() {
|
||||
let mut world = World::room();
|
||||
|
|
@ -2525,6 +2575,7 @@ fn a_forced_switch_plans_one_entry_and_the_other_scenes_plan_their_one_move() {
|
|||
] {
|
||||
let mut world = World::room();
|
||||
world.scene = scene;
|
||||
world.box_open = scene == Scene::Unknown;
|
||||
assert_eq!(plan(&mut world), entries, "{}", scene.label());
|
||||
}
|
||||
|
||||
|
|
@ -3139,6 +3190,10 @@ fn no_playable_scene_deals_an_empty_pad() {
|
|||
] {
|
||||
let mut world = World::room();
|
||||
world.scene = scene;
|
||||
// `Unknown` is dealt on what is drawn (section 12.13): a screen with words on it has
|
||||
// `NEXT` and `BACK`, and a scripted overworld frame is the one deliberate empty pad,
|
||||
// which `an_unknown_frame_with_no_box_on_it_deals_nothing` is about.
|
||||
world.box_open = scene == Scene::Unknown;
|
||||
world.battle = matches!(scene, Scene::Battle { .. })
|
||||
.then_some((BattleKind::Wild, false, false));
|
||||
assert!(
|
||||
|
|
@ -3154,6 +3209,109 @@ fn no_playable_scene_deals_an_empty_pad() {
|
|||
assert_eq!(plan::plan_for(Scene::Title, &mut title).bound(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_badges_rung_resolves_to_the_person_standing_in_the_gym() {
|
||||
// Rung 11 is BOULDER BADGE and `docs/design/ladder.md` gives its place as a *person* in the
|
||||
// Pewter gym (12.5's `PlaceKind`). What the rung-10 loop needed was the two halves of that
|
||||
// working together: the road into map `0x36` (`geography`, the museum rows and the gym's
|
||||
// own), and, once inside, the objective naming somebody to walk to rather than a map to be
|
||||
// on. This is the second half, on the map the rung is earned on.
|
||||
let mut world = World::room();
|
||||
world.map = maps::PEWTER_GYM;
|
||||
// Both of Pewter's errands discharged. Section 13 puts an unvisited mart or centre *ahead*
|
||||
// of the rung's place, and the gym is inside Pewter's area like everything else in the town,
|
||||
// so until they are paid the objective is a building and not the leader. That is the errand
|
||||
// working, and it is why the ROM run below spends its first minutes in the town's shops.
|
||||
world.areas.insert((Amenity::Mart, maps::PEWTER_CITY));
|
||||
world.areas.insert((Amenity::Center, maps::PEWTER_CITY));
|
||||
world.npcs = vec![Npc { slot: 1, picture: 0x05, x: 4, y: 2, facing: Facing::Down }];
|
||||
world.objective = Some(Objective {
|
||||
map: maps::PEWTER_GYM,
|
||||
tile: None,
|
||||
warp: None,
|
||||
edge: None,
|
||||
target: Some(PlaceKind::Person),
|
||||
});
|
||||
let targets = super::palette::objective_targets(&mut world);
|
||||
assert_eq!(targets.len(), 1, "the person the rung names: {targets:?}");
|
||||
assert_eq!(targets[0].0, Tile::new(4, 2));
|
||||
// And the walk aims at standing beside them and turning to face them, which is `GO NPC`'s own
|
||||
// arrival and all `GO OBJECTIVE` ever promises: the press is `TALK`'s and the fly's.
|
||||
let goals: Vec<Tile> = objective_goals(&mut world).into_iter().map(|aim| aim.tile).collect();
|
||||
assert!(goals.contains(&Tile::new(4, 3)), "a tile beside them: {goals:?}");
|
||||
assert!(goals.iter().all(|tile| tile.distance(Tile::new(4, 2)) == 1), "{goals:?}");
|
||||
// The room the rung is in is not left while the thing that earns it is standing in it (12.5).
|
||||
assert!(ways(&mut world, Way::Exit).is_empty(), "the gym's door is not a candidate yet");
|
||||
// Talked to, and the objective has nothing left here: the button leaves the pad and the ways
|
||||
// out come back, which is what carries the run on to the next rung.
|
||||
world.talked.insert(targets[0].1);
|
||||
assert!(super::palette::objective_targets(&mut world).is_empty());
|
||||
assert!(objective_goals(&mut world).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frontier_no_walk_can_reach_takes_go_frontier_off_the_pad_and_keeps_it_off() {
|
||||
// Section 12.14, the rung-10 museum. The sealed pocket below is map `0x34` in miniature: the
|
||||
// unstood ground is real and it is fenced off, so `GO FRONTIER` refuses `no route` and writes
|
||||
// every tile it could not reach to the blocked ledger -- which is a *window*. Before this the
|
||||
// window lapsed after ten brain minutes and all of it was a candidate again: 1,235 starts in
|
||||
// 47 minutes over two museum floors and a town. The mark has no window.
|
||||
let mut world = World::room();
|
||||
for y in 0..8 {
|
||||
for x in 0..8 {
|
||||
world.stood.insert(Tile::new(x, y));
|
||||
}
|
||||
}
|
||||
world.stood.remove(&Tile::new(7, 7));
|
||||
world.walls.insert(Tile::new(6, 7));
|
||||
world.walls.insert(Tile::new(6, 6));
|
||||
world.walls.insert(Tile::new(7, 5));
|
||||
world.player = Tile::new(0, 0);
|
||||
assert!(!super::palette::frontier_aims(&mut world).is_empty(), "the pocket is a frontier");
|
||||
assert!(precondition(MacroKind::GoFrontier, &mut world), "so the button is on the pad");
|
||||
|
||||
let refused = run(&mut world, MacroKind::GoFrontier).expect_err("the pocket is sealed");
|
||||
assert_eq!(refused.reason, Refusal::NoRoute);
|
||||
assert!(world.exhausted.contains(&world.map), "the map is marked: {:?}", world.exhausted);
|
||||
assert!(super::palette::frontier_aims(&mut world).is_empty(), "nothing left to aim at");
|
||||
assert!(!precondition(MacroKind::GoFrontier, &mut world), "and the button is off the pad");
|
||||
|
||||
// Ten brain minutes later the blocked window has lapsed and every tile of the pocket is a
|
||||
// candidate again -- and the button is still off the pad, because the ground has not moved.
|
||||
world.targets.clock(11.0 * 60_000.0);
|
||||
assert!(!world.targets.blocked(world.map, TargetKey::Tile(Tile::new(7, 6))));
|
||||
assert!(
|
||||
super::palette::frontier_aims(&mut world).is_empty(),
|
||||
"the mark is not a window"
|
||||
);
|
||||
// A map the fly walks to instead is untouched: the mark is one map's.
|
||||
world.map = 0x35;
|
||||
assert!(!super::palette::frontier_aims(&mut world).is_empty(), "another map is its own");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frontier_mark_is_the_stood_ledgers_to_clear() {
|
||||
// The two halves of the rule as the types have them: the stood ledger answers "this is
|
||||
// ground the run had not stood on", which is the only event that can change which tiles the
|
||||
// fly can reach, and that answer is what clears the map's mark.
|
||||
let mut stood = super::cartridge::Stood::default();
|
||||
assert!(stood.record(2, Tile::new(4, 4)), "the first time is new ground");
|
||||
assert!(!stood.record(2, Tile::new(4, 4)), "the second time is not");
|
||||
assert!(stood.record(0x34, Tile::new(4, 4)), "and a tile is a tile of one map");
|
||||
|
||||
use super::cartridge::FrontierLedger;
|
||||
let mut frontiers = super::cartridge::Frontiers::default();
|
||||
assert!(!frontiers.frontier_exhausted(0x34));
|
||||
frontiers.record(0x34);
|
||||
frontiers.record(0x34);
|
||||
assert!(frontiers.frontier_exhausted(0x34), "idempotent");
|
||||
assert!(!frontiers.frontier_exhausted(0x35), "and one map's");
|
||||
assert_eq!(frontiers.len(), 1);
|
||||
frontiers.clear(0x34);
|
||||
assert!(!frontiers.frontier_exhausted(0x34));
|
||||
assert!(frontiers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_walk_that_could_only_get_closer_now_excludes_what_it_cannot_reach() {
|
||||
// Two readings of the same pocket, in the order they were measured
|
||||
|
|
@ -4087,6 +4245,9 @@ fn no_playable_scene_and_no_sub_state_deals_an_empty_pad() {
|
|||
] {
|
||||
let mut world = World::room();
|
||||
world.scene = scene;
|
||||
// See the sweep in `no_playable_scene_deals_an_empty_pad`: `Unknown` with nothing drawn
|
||||
// on it is the overworld being driven by the cartridge, and its pad is empty by design.
|
||||
world.box_open = scene == Scene::Unknown;
|
||||
world.mons.clear();
|
||||
worst(&mut world);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,13 @@
|
|||
//! `PEWTER_MART` `$38` and `PEWTER_POKECENTER` `$3a` sit in the five ids between `PEWTER_GYM`
|
||||
//! `$36` and `MT_MOON_1F` `$3b`; `CERULEAN_POKECENTER` `$40` and `CERULEAN_MART` `$43` sit around
|
||||
//! `CERULEAN_GYM` `$41`. [`super::tests`] pins each against its neighbour rather than on its own.
|
||||
//!
|
||||
//! **2026-09-22, the rung-10 museum.** `PEWTER_MUSEUM_1F` `$34` and `PEWTER_MUSEUM_2F` `$35` are
|
||||
//! the two ids between `VIRIDIAN_FOREST` `$33` and `PEWTER_GYM` `$36`, and both are confirmed by
|
||||
//! the cartridge rather than by counting: Pewter City's warp table names `$34` at (14, 7) and at
|
||||
//! (19, 5) -- the museum's two doors -- and `$34`'s own warp at (7, 7) names `$35`, which is the
|
||||
//! staircase. Surveyed from the release container's rung-10 checkpoint
|
||||
//! (`infra/docs/macros-traps.md`, 2026-09-22).
|
||||
|
||||
// Outdoors: `$00`..`$24`.
|
||||
pub const PALLET_TOWN: u8 = 0x00;
|
||||
|
|
@ -68,6 +75,8 @@ pub const VIRIDIAN_GYM: u8 = 0x2d;
|
|||
pub const VIRIDIAN_FOREST_NORTH_GATE: u8 = 0x2f;
|
||||
pub const VIRIDIAN_FOREST_SOUTH_GATE: u8 = 0x32;
|
||||
pub const VIRIDIAN_FOREST: u8 = 0x33;
|
||||
pub const PEWTER_MUSEUM_1F: u8 = 0x34;
|
||||
pub const PEWTER_MUSEUM_2F: u8 = 0x35;
|
||||
pub const PEWTER_GYM: u8 = 0x36;
|
||||
pub const PEWTER_MART: u8 = 0x38;
|
||||
pub const PEWTER_POKECENTER: u8 = 0x3a;
|
||||
|
|
|
|||
|
|
@ -25,8 +25,9 @@ use crate::adapter::{MapEdge, MapExit, MapTile, MemoryReader};
|
|||
use crate::macros::{RunLedger, NoLedger};
|
||||
|
||||
use super::macros::cartridge::{
|
||||
AreaLedger, Edge, ExitId, MacroState, NoAreas, NoPushed, NoStood, NoTalk, NoTargets,
|
||||
Objective, PushedLedger, StoodLedger, TalkLedger, TalkTarget, TargetKey, TargetLedger, Tile,
|
||||
AreaLedger, Edge, ExitId, FrontierLedger, MacroState, NoAreas, NoFrontiers, NoPushed, NoStood,
|
||||
NoTalk, NoTargets, Objective, PushedLedger, StoodLedger, TalkLedger, TalkTarget, TargetKey,
|
||||
TargetLedger, Tile,
|
||||
};
|
||||
use super::macros::geography::Amenity;
|
||||
use super::mapgrid::{self, MapGrids};
|
||||
|
|
@ -937,6 +938,33 @@ impl GridRefusal {
|
|||
/// ([`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> {
|
||||
// The decode first, so a frame with no header answers `NoHeader` rather than whatever the
|
||||
// player's coordinates happen to read as: the refusals are in the order they are checked.
|
||||
let grid = map_grid_decode(memory)?;
|
||||
let player = player(memory).ok_or(GridRefusal::NoPlayer)?;
|
||||
// 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)
|
||||
}
|
||||
|
||||
/// [`map_grid`] without the cross-check: the blocks, the blockset and the collision list, decoded.
|
||||
///
|
||||
/// Split out so [`grid_disagreement`] can say what the decode answered on a frame the check
|
||||
/// refused. Nothing outside this module and the probes may use it: a grid that has not been
|
||||
/// checked against the screen is exactly the reading section 15 refuses to trust.
|
||||
fn map_grid_decode(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)?;
|
||||
|
|
@ -979,23 +1007,28 @@ pub fn map_grid(memory: &mut dyn MemoryReader) -> Result<MapGrid, GridRefusal> {
|
|||
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)
|
||||
}
|
||||
|
||||
/// The decode and the screen, tile by tile, for the five tiles [`map_grid`] cross-checks.
|
||||
///
|
||||
/// The diagnostic half of [`GridRefusal::ScreenDisagrees`]: the refusal says the two readings
|
||||
/// disagree and this says *where* and *by how much*, which is the difference between "the grid is
|
||||
/// off on this map" and "this frame was mid-warp". `(x, y, decoded, screen)`, with `None` for a
|
||||
/// tile either reading cannot answer for. It decodes the map a second time rather than being
|
||||
/// folded into [`map_grid`], because the check's job on the hot path is to refuse and this is only
|
||||
/// ever asked by a probe.
|
||||
pub fn grid_disagreement(memory: &mut dyn MemoryReader) -> Vec<(u8, u8, Option<u8>, Option<u8>)> {
|
||||
let Some(player) = player(memory) else { return Vec::new() };
|
||||
let grid = map_grid_decode(memory).ok();
|
||||
neighbourhood(player.x, player.y)
|
||||
.into_iter()
|
||||
.map(|(x, y)| {
|
||||
(x, y, grid.as_ref().and_then(|grid| grid.tile_id(x, y)), map_tile_id(memory, x, y))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
|
@ -1164,6 +1197,10 @@ pub struct PokeState<'a> {
|
|||
/// ([`PokeState::caching_grid`]) so that a precondition asking for the frontier costs a
|
||||
/// refcount instead of a map.
|
||||
grids: Option<&'a mut MapGrids>,
|
||||
/// Which maps have proved their frontier unreachable (`docs/design/macros.md` section
|
||||
/// 12.14). A builder rather than a constructor parameter, exactly as the grid cache is: the
|
||||
/// sim loop passes one and everything else narrows to "nothing proved".
|
||||
frontiers: &'a dyn FrontierLedger,
|
||||
}
|
||||
|
||||
impl<'a> PokeState<'a> {
|
||||
|
|
@ -1182,6 +1219,7 @@ impl<'a> PokeState<'a> {
|
|||
areas: &NoAreas,
|
||||
pushed: &NoPushed,
|
||||
grids: None,
|
||||
frontiers: &NoFrontiers,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1196,6 +1234,7 @@ impl<'a> PokeState<'a> {
|
|||
areas: &NoAreas,
|
||||
pushed: &NoPushed,
|
||||
grids: None,
|
||||
frontiers: &NoFrontiers,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1213,7 +1252,7 @@ impl<'a> PokeState<'a> {
|
|||
areas: &'a dyn AreaLedger,
|
||||
pushed: &'a dyn PushedLedger,
|
||||
) -> Self {
|
||||
Self { memory, ledger, talk, targets, stood, areas, pushed, grids: None }
|
||||
Self { memory, ledger, talk, targets, stood, areas, pushed, grids: None, frontiers: &NoFrontiers }
|
||||
}
|
||||
|
||||
/// Keep the decoded map grid in `grids` instead of decoding it per question.
|
||||
|
|
@ -1226,6 +1265,16 @@ impl<'a> PokeState<'a> {
|
|||
self.grids = Some(grids);
|
||||
self
|
||||
}
|
||||
|
||||
/// Answer [`MacroState::frontier_exhausted`] from `frontiers` instead of "nothing proved".
|
||||
///
|
||||
/// A builder for the same reason the grid cache is one: it is the sim loop's own session
|
||||
/// state ([`super::macros::driver::PokemonPalette`]) and every other caller -- the tests, the
|
||||
/// probes, the ROM harnesses -- wants the narrowing.
|
||||
pub fn with_frontiers(mut self, frontiers: &'a dyn FrontierLedger) -> Self {
|
||||
self.frontiers = frontiers;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl GameState for PokeState<'_> {
|
||||
|
|
@ -1310,6 +1359,14 @@ impl MacroState for PokeState<'_> {
|
|||
!controllable(self.memory)
|
||||
}
|
||||
|
||||
fn text_open(&mut self) -> bool {
|
||||
text_box(self.memory).open
|
||||
}
|
||||
|
||||
fn frontier_exhausted(&mut self) -> bool {
|
||||
player(self.memory).is_some_and(|player| self.frontiers.frontier_exhausted(player.map))
|
||||
}
|
||||
|
||||
fn yes_no_prompt(&mut self) -> bool {
|
||||
yes_no_prompt(self.memory)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -186,6 +186,44 @@ impl Ratchet {
|
|||
game_over: bool,
|
||||
capture: F,
|
||||
) -> bool
|
||||
where
|
||||
F: FnOnce() -> Snapshot,
|
||||
{
|
||||
self.observe_with_progress(safe, rank, coverage, now, game_over, false, capture)
|
||||
}
|
||||
|
||||
/// [`Ratchet::observe_with_game_over`] plus a progress signal the coverage figure cannot
|
||||
/// carry.
|
||||
///
|
||||
/// `progressed` is the caller saying "something happened on this sample that is plainly
|
||||
/// progress, and it is not a number that only grows". The stall window is restarted by it
|
||||
/// exactly as a rise in `coverage` restarts it, and nothing else changes: no budget is
|
||||
/// spent, no snapshot is taken and no trigger is skipped.
|
||||
///
|
||||
/// It exists because coverage is the *only* progress the window could see, and coverage is
|
||||
/// ground never stood on. A fly crossing a town it has already covered to reach the rung's
|
||||
/// own door is getting somewhere and earns none. Measured on rung 10, 2026-09-22: two
|
||||
/// "Stuck" rollbacks inside half an hour, both on a fly that was walking, each one landing
|
||||
/// it back where it had started. What the Pokemon loop passes here is "nearer the objective,
|
||||
/// in map hops, than this run has ever been" (`docs/design/macros.md` section 12.15), which
|
||||
/// can only fire as many times as the road is long.
|
||||
///
|
||||
/// Nothing in this module knows what the signal means, which is the same bargain `coverage`
|
||||
/// is: the ladder, the map graph and the objective all belong to the adapter.
|
||||
// One argument past clippy's seven, and the argument is the point: this is the whole of one
|
||||
// sample. A struct for it would be a type that exists only to be destructured at the one
|
||||
// call site the sim loop has, which is the same idiom `decoder.rs` and `lif.rs` take here.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn observe_with_progress<F>(
|
||||
&mut self,
|
||||
safe: bool,
|
||||
rank: u64,
|
||||
coverage: u64,
|
||||
now: u64,
|
||||
game_over: bool,
|
||||
progressed: bool,
|
||||
capture: F,
|
||||
) -> bool
|
||||
where
|
||||
F: FnOnce() -> Snapshot,
|
||||
{
|
||||
|
|
@ -200,7 +238,7 @@ impl Ratchet {
|
|||
} else {
|
||||
self.unsafe_since = None;
|
||||
}
|
||||
if coverage > self.state.coverage {
|
||||
if coverage > self.state.coverage || progressed {
|
||||
self.state.last_progress = now;
|
||||
}
|
||||
self.state.coverage = self.state.coverage.max(coverage);
|
||||
|
|
@ -458,4 +496,51 @@ mod tests {
|
|||
assert!(!ratchet.observe(true, 1, 2, 230_000, || snapshot(1)));
|
||||
assert!(ratchet.observe(true, 1, 2, 239_001, || snapshot(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_map_the_run_has_never_stood_on_is_coverage_and_resets_the_window() {
|
||||
// The half of the 2026-09-17 rule that already held, pinned because the other half is
|
||||
// new: entering a map for the first time is ground never stood on, so it arrives here as
|
||||
// a rise in `coverage` and needs nothing of its own. Re-entering a map the run has
|
||||
// covered is not, which is exactly the rung-10 museum and why the signal below exists.
|
||||
let mut ratchet = Ratchet::new();
|
||||
ratchet.observe(true, 1, 100, 0, || snapshot(1));
|
||||
// A new map: eighty tiles nobody had stood on.
|
||||
assert!(!ratchet.observe(true, 1, 180, 100_000, || snapshot(1)));
|
||||
// Walking it again for two minutes adds none, and the window ages from the arrival.
|
||||
assert!(!ratchet.observe(true, 1, 180, 219_000, || snapshot(1)));
|
||||
assert!(ratchet.observe(true, 1, 180, 220_001, || snapshot(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn getting_nearer_the_objective_resets_the_stall_window_without_spending_anything() {
|
||||
// The rung-10 rollbacks of 2026-09-22: a fly walking a town it has already covered
|
||||
// toward the rung's own door earns no coverage while it does it, and the window aged out
|
||||
// twice in half an hour. A caller's progress signal restarts the window exactly as
|
||||
// coverage does -- and does nothing else.
|
||||
let mut ratchet = Ratchet::new();
|
||||
ratchet.observe(true, 1, 100, 0, || snapshot(1));
|
||||
// 119 s of walking ground the run has covered, and then one step nearer the gym.
|
||||
assert!(!ratchet.observe_with_progress(true, 1, 100, 119_000, false, true, || snapshot(1)));
|
||||
let state = ratchet.state;
|
||||
assert_eq!(state.last_progress, 119_000);
|
||||
assert_eq!((state.recoveries, state.attempts), (0, 0), "progress spends no budget");
|
||||
// The window now runs from there rather than from the start.
|
||||
assert!(!ratchet.observe(true, 1, 100, 238_000, || snapshot(1)));
|
||||
assert!(ratchet.observe(true, 1, 100, 239_001, || snapshot(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_progress_signal_cannot_conjure_a_recovery_or_skip_a_trigger() {
|
||||
// It restarts the window; it is not a trigger and it is not a snapshot. With nothing
|
||||
// archived there is nothing to recover to, and an unsafe sample still never recovers.
|
||||
let mut ratchet = Ratchet::new();
|
||||
assert!(!ratchet.observe_with_progress(true, 0, 0, 0, false, true, || snapshot(1)));
|
||||
assert!(ratchet.snapshot.is_none(), "progress captures nothing");
|
||||
let mut armed = Ratchet::new();
|
||||
armed.observe(true, 1, 1, 0, || snapshot(1));
|
||||
// Unsafe, stalled, and told that something progressed: still no recovery, because an
|
||||
// unsafe sample would restore on top of whatever made it unsafe.
|
||||
assert!(!armed.observe_with_progress(false, 1, 1, 400_000, false, true, || snapshot(1)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@
|
|||
//! | `FLY_PROBE_FRAMES` | 200000 | frames to drive before giving up |
|
||||
//! | `FLY_PROBE_STUCK` | 600 | consecutive frames in one non-overworld scene that count as stuck |
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use flybrain_core::decoder::PopulationDecoder;
|
||||
use flybrain_core::decoder::gameboy::gameboy_decoder_config_with_macros;
|
||||
use flybrain_core::ordered::NumberMap;
|
||||
|
|
@ -161,6 +163,16 @@ fn pad(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) {
|
|||
// 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();
|
||||
// Which tile the decode and the screen disagree about, when that is the refusal. The label
|
||||
// alone cannot tell "the grid is wrong on this map" from "this frame was mid-warp", and the
|
||||
// two want opposite fixes (`docs/design/macros.md` section 15). Read here, beside the refusal
|
||||
// itself, because everything below holds a borrow of the emulator.
|
||||
let disagreement = match refusal {
|
||||
Some(flybrain_gb::pokemon_red::state::GridRefusal::ScreenDisagrees) => {
|
||||
flybrain_gb::pokemon_red::state::grid_disagreement(gb)
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let ledger = AdapterLedger(adapter);
|
||||
let mut poke = flybrain_gb::pokemon_red::state::PokeState::with_ledger(gb, &ledger);
|
||||
let state: &mut dyn MacroState = &mut poke;
|
||||
|
|
@ -212,6 +224,22 @@ fn pad(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) {
|
|||
);
|
||||
}
|
||||
}
|
||||
// Which tile the decode and the screen disagree about, when that is the refusal. The label
|
||||
// alone cannot tell "the grid is wrong on this map" from "this frame was mid-warp", and the
|
||||
// two want opposite fixes (`docs/design/macros.md` section 15).
|
||||
if !disagreement.is_empty() {
|
||||
println!("- the decode against the screen, tile by tile:");
|
||||
for (x, y, decoded, screen) in disagreement {
|
||||
println!(
|
||||
" - ({x:2}, {y:2}) decoded {decoded:?} screen {screen:?}{}",
|
||||
if decoded.is_some() && screen.is_some() && decoded != screen {
|
||||
" <- the disagreement"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
// 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
|
||||
|
|
@ -710,6 +738,25 @@ fn main() {
|
|||
&& surveyed >= 60
|
||||
{
|
||||
println!("\nThe fly reached map {map:#04x} at frame {frame}.");
|
||||
// Stand still for a while and count how many of those frames the whole-map grid can be
|
||||
// decoded on. A walking fly is mid-step on most frames -- `wYCoord` is the tile it is
|
||||
// walking *to* while the background is still scrolling -- and the screen buffer the
|
||||
// cross-check reads is the one that is a tile behind, so "is the grid refused on this
|
||||
// map" and "is the grid refused while the fly is moving" are different questions with
|
||||
// different fixes (`docs/design/macros.md` section 15).
|
||||
let mut refusals: BTreeMap<&'static str, usize> = BTreeMap::new();
|
||||
for _ in 0..env_usize("FLY_PROBE_SETTLE", 120) {
|
||||
gb.set_buttons(flybrain_gb::buttons::NONE);
|
||||
gb.run_frame().expect("a frame should complete");
|
||||
ms += MS_PER_FRAME;
|
||||
adapter.sample(&mut gb, ms);
|
||||
let label = match flybrain_gb::pokemon_red::state::map_grid(&mut gb) {
|
||||
Ok(_) => "decoded",
|
||||
Err(refusal) => refusal.label(),
|
||||
};
|
||||
*refusals.entry(label).or_insert(0) += 1;
|
||||
}
|
||||
println!("\nStanding still on it, frame by frame: {refusals:?}");
|
||||
cartridge(&mut gb, &adapter, "The save on the surveyed map");
|
||||
pad(&mut gb, &adapter, "The pad on the surveyed map");
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -500,6 +500,15 @@ impl MacroLayer {
|
|||
/// The sim loop passes this to [`flybrain_core::decoder::PopulationDecoder::decode_bound`] on
|
||||
/// the same frame, which is why it is the layer's to answer: the bindings are what the last
|
||||
/// `observe` dealt, and nothing else in the loop knows them.
|
||||
/// Whether this frame saw the fly nearer its objective than the run has managed before.
|
||||
///
|
||||
/// Straight through from the palette ([`MacroPalette::nearer_the_objective`]), for the
|
||||
/// ratchet's stall window and for nothing else: the macro layer neither reads it back nor
|
||||
/// presses anything because of it.
|
||||
pub fn nearer_the_objective(&self) -> bool {
|
||||
self.palette.nearer_the_objective()
|
||||
}
|
||||
|
||||
pub fn bound_channels(&self) -> Vec<String> {
|
||||
self.bindings
|
||||
.iter()
|
||||
|
|
|
|||
|
|
@ -1054,12 +1054,20 @@ impl Sim {
|
|||
} else {
|
||||
None
|
||||
};
|
||||
let recover = self.ratchet.observe_with_game_over(
|
||||
// The stall window's second progress signal (`docs/design/ladder.md`, the 2026-09-17
|
||||
// rule as amended 2026-09-22): coverage is ground never stood on, and a fly crossing a
|
||||
// town it has already covered to reach the rung's own door earns none of it while it is
|
||||
// plainly getting somewhere. The macro layer answers with the map graph it already walks
|
||||
// (`docs/design/macros.md` section 12.15); in raw mode there is no layer and no
|
||||
// objective, and the answer is false.
|
||||
let nearer = self.macros.as_ref().is_some_and(MacroLayer::nearer_the_objective);
|
||||
let recover = self.ratchet.observe_with_progress(
|
||||
safe,
|
||||
u64::from(progress.rank),
|
||||
progress.unique_locations as u64,
|
||||
ms as u64,
|
||||
self.adapter.game_over(),
|
||||
nearer,
|
||||
|| captured.expect("the ratchet only captures when a snapshot was prepared"),
|
||||
);
|
||||
if recover {
|
||||
|
|
|
|||
|
|
@ -63,6 +63,10 @@ const VIRIDIAN_FOREST: u32 = 0x33;
|
|||
const PEWTER_POKECENTER: u32 = 0x3a;
|
||||
/// The upper floor of the Pewter museum, which is the building the rung-10 stall was inside.
|
||||
const MUSEUM_2F: u32 = 0x35;
|
||||
/// Pewter City and its two museum floors and gym: the rung-10 loop's whole world.
|
||||
const PEWTER_CITY: u32 = 0x02;
|
||||
const MUSEUM_1F: u32 = 0x34;
|
||||
const PEWTER_GYM: u32 = 0x36;
|
||||
/// The forest's *northern* gate, which is the first hop from the forest toward Pewter
|
||||
/// (`macros::geography`, and rung 10's own road).
|
||||
const VIRIDIAN_FOREST_NORTH_GATE: u32 = 0x2f;
|
||||
|
|
@ -266,6 +270,31 @@ struct Run {
|
|||
///
|
||||
/// The door into the ring (section 12.12). `false` is the claim.
|
||||
talk_at_a_rested_nurse: bool,
|
||||
/// `BACK` starts on a frame the scene detector called `dialog` or `unknown` (section 12.13).
|
||||
///
|
||||
/// The same attribution `examples/trap_hunt.rs` makes, so the number is comparable with the
|
||||
/// residual it comes from: **189** `BACK` in a text box on map `0x02` in twenty brain
|
||||
/// minutes, and 678 starts in the 47 live minutes. `BACK` is on no overworld and no dialog
|
||||
/// pad, so every one of them was dealt by `Unknown`.
|
||||
back_in_a_box: u32,
|
||||
/// Frames where the scene was `unknown`, nothing was drawn, and the pad was **not** empty.
|
||||
///
|
||||
/// Section 12.13's claim, and it is zero: a frame of the overworld the cartridge is driving
|
||||
/// has nothing an A or a B press can do, so it deals nothing and the fly waits.
|
||||
unknown_pads_with_no_box: u32,
|
||||
/// Maps on whose overworld pad `TALK` was ever bound: where the fly stood facing somebody
|
||||
/// this run has not talked to.
|
||||
///
|
||||
/// Rung 11 is BOULDER BADGE and its place is a *person* in map `0x36`, so "the fly reaches
|
||||
/// the leader" is this map being in the set: the fly inside the gym, in front of one of its
|
||||
/// people, with the press that talks to them on the pad. Whether it presses is the fly's
|
||||
/// (section 12). Read off the shipping pad rather than recomputed, so it cannot disagree
|
||||
/// with what the fly was actually offered.
|
||||
talk_on_pad_by_map: std::collections::BTreeSet<u32>,
|
||||
/// `TALK` starts by map, for the record beside it.
|
||||
talk_starts_by_map: std::collections::BTreeMap<u32, u32>,
|
||||
/// `GO FRONTIER` starts by map, for the museum (section 12.14).
|
||||
frontier_by_map: std::collections::BTreeMap<u32, u32>,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
|
|
@ -362,6 +391,11 @@ impl Run {
|
|||
prompt_frames: 0,
|
||||
next_on_a_prompt: false,
|
||||
talk_at_a_rested_nurse: false,
|
||||
back_in_a_box: 0,
|
||||
unknown_pads_with_no_box: 0,
|
||||
talk_on_pad_by_map: std::collections::BTreeSet::new(),
|
||||
talk_starts_by_map: std::collections::BTreeMap::new(),
|
||||
frontier_by_map: std::collections::BTreeMap::new(),
|
||||
menu_alternation: 0,
|
||||
last_start: None,
|
||||
}
|
||||
|
|
@ -463,6 +497,11 @@ impl Run {
|
|||
prompt_frames: 0,
|
||||
next_on_a_prompt: false,
|
||||
talk_at_a_rested_nurse: false,
|
||||
back_in_a_box: 0,
|
||||
unknown_pads_with_no_box: 0,
|
||||
talk_on_pad_by_map: std::collections::BTreeSet::new(),
|
||||
talk_starts_by_map: std::collections::BTreeMap::new(),
|
||||
frontier_by_map: std::collections::BTreeMap::new(),
|
||||
menu_alternation: 0,
|
||||
last_start: None,
|
||||
}
|
||||
|
|
@ -766,6 +805,19 @@ impl Run {
|
|||
*self.started_on_the_first_map.entry(name).or_insert(0) += 1;
|
||||
}
|
||||
*self.started.entry(name).or_insert(0) += 1;
|
||||
// Section 12.13: which of the two `Unknown`s a `BACK` was dealt on, attributed
|
||||
// exactly as the trap hunt attributes it so the numbers can be compared.
|
||||
if name == "BACK" && matches!(self.layer.scene_name(), "dialog" | "unknown") {
|
||||
self.back_in_a_box += 1;
|
||||
}
|
||||
if name == "GO FRONTIER" {
|
||||
let map = self.map();
|
||||
*self.frontier_by_map.entry(map).or_insert(0) += 1;
|
||||
}
|
||||
if name == "TALK" {
|
||||
let map = self.map();
|
||||
*self.talk_starts_by_map.entry(map).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
self.gb.set_buttons(mask as u8);
|
||||
self.gb.run_frame().expect("a frame should complete");
|
||||
|
|
@ -810,6 +862,13 @@ impl Run {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Section 12.13's claim, per frame: an `Unknown` with nothing drawn deals nothing.
|
||||
if self.layer.scene_name() == "unknown"
|
||||
&& !flybrain_gb::pokemon_red::state::text_box(&mut self.gb).open
|
||||
&& !self.layer.bound_channels().is_empty()
|
||||
{
|
||||
self.unknown_pads_with_no_box += 1;
|
||||
}
|
||||
if self.layer.scene_name() == "overworld"
|
||||
&& self.rested_nurse()
|
||||
&& self.layer.bound_channels().iter().any(|channel| channel.as_str() == "macro_talk")
|
||||
|
|
@ -827,6 +886,11 @@ impl Run {
|
|||
if dealt.iter().any(|channel| channel.as_str() == "macro_menu") {
|
||||
self.menu_on_pad.insert(map);
|
||||
}
|
||||
// Standing in front of somebody this run has not talked to, which on the gym's own
|
||||
// map is standing in front of one of the people rung 11's place names.
|
||||
if dealt.iter().any(|channel| channel.as_str() == "macro_talk") {
|
||||
self.talk_on_pad_by_map.insert(map);
|
||||
}
|
||||
if dealt.is_empty() {
|
||||
self.empty_overworld_pads.insert(map);
|
||||
}
|
||||
|
|
@ -2181,3 +2245,139 @@ fn the_fly_leaves_the_pokemon_center_from_the_rung_ten_checkpoint() {
|
|||
run.macros_on_the_first_map
|
||||
);
|
||||
}
|
||||
|
||||
/// The rung-10 Pewter checkpoint, or `None` to skip.
|
||||
fn pewter_checkpoint() -> Option<flysim::store::Checkpoint> {
|
||||
std::env::var_os("FLY_PEWTER_CHECKPOINT").map(|path| {
|
||||
flysim::store::load(std::path::Path::new(&path))
|
||||
.expect("the checkpoint should be a FLYSIM01 envelope")
|
||||
})
|
||||
}
|
||||
|
||||
/// From the rung-10 Pewter checkpoint: the fly gets out of the museum and into the gym.
|
||||
///
|
||||
/// **What was live** (2026-09-22, v0.4.5, rank 10 PEWTER CITY, five and a half hours on the
|
||||
/// rung): the fly on **map 0x34**, the museum's ground floor, and since the 11:30 restart the
|
||||
/// macro starts were `GO FRONTIER` **1,235**, `BACK` **678**, `GO OBJECTIVE` 267, `GO OUT` 242,
|
||||
/// `YES` 169 -- with the objective, rung 11's BOULDER BADGE, two doors away in map `0x36`. Two
|
||||
/// "Stuck" rollbacks fired inside the last half hour and put the fly back where it started.
|
||||
///
|
||||
/// **What the survey found** (`infra/docs/macros-traps.md`, 2026-09-22, and
|
||||
/// `examples/scene_probe.rs`): three things, none of them the text box the brief expected.
|
||||
///
|
||||
/// - `BACK` was not in a text box at all. It is on no overworld and no dialog pad, so every one
|
||||
/// of those 678 was `Scene::Unknown` -- which is *also* what a frame of the overworld reads as
|
||||
/// while the cartridge drives the fly through a door. An A and a B press into somebody else's
|
||||
/// script (section 12.13).
|
||||
/// - the museum had **no row on the map graph**, so from inside it `next_hop` answered nothing
|
||||
/// and `GO OBJECTIVE` had no goal: the road to the gym did not exist indoors (12.7's rule, and
|
||||
/// the residual the previous review named).
|
||||
/// - the museum's frontier is 39 tiles the run has never stood on, of which almost all are
|
||||
/// behind the admission desk: unreachable, refused `no route`, excluded for ten brain minutes
|
||||
/// by the blocked ledger and then offered again, once per hold (section 12.14).
|
||||
///
|
||||
/// The claims, none of them about which button the fly presses:
|
||||
///
|
||||
/// - the fly reaches **map 0x36**, the gym's own interior, on a bounded number of macros;
|
||||
/// - it stands **facing the thing the rung names** while it is there, which is all `GO OBJECTIVE`
|
||||
/// ever promises (12.5);
|
||||
/// - `BACK` on a `dialog` or `unknown` frame stays **under five**, against 189 in the hunt;
|
||||
/// - no frame deals a pad on an `Unknown` with nothing drawn on it.
|
||||
///
|
||||
/// ```sh
|
||||
/// FLY_ROM=/path/to/pokemon-red.gb \
|
||||
/// FLY_PEWTER_CHECKPOINT=.local/checkpoints/release-rank10-pewter.checkpoint \
|
||||
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture
|
||||
/// ```
|
||||
#[test]
|
||||
fn the_fly_reaches_the_pewter_gym_from_the_rung_ten_checkpoint() {
|
||||
let rom = skip_without_rom!();
|
||||
let Some(checkpoint) = pewter_checkpoint() else {
|
||||
eprintln!("skipped: no FLY_PEWTER_CHECKPOINT");
|
||||
return;
|
||||
};
|
||||
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
|
||||
let from = run.map();
|
||||
assert_eq!(from, MUSEUM_1F, "the checkpoint is the room the stream stalled in");
|
||||
|
||||
let mut reached = None;
|
||||
let mut macros_to_the_gym = 0;
|
||||
for frame in 0..200_000u32 {
|
||||
run.frame();
|
||||
if reached.is_none() {
|
||||
macros_to_the_gym = run.started.values().sum::<u32>();
|
||||
if run.map() == PEWTER_GYM {
|
||||
reached = Some(frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"from map {from:#04x} in {:.1} brain minutes: route {:?}, macros {:?}, GO FRONTIER by \
|
||||
map {:?}, TALK on the pad on {:?}, TALK starts {:?}, BACK in a box {}, unknown pads \
|
||||
with no box {}",
|
||||
run.ms / 60_000.0,
|
||||
run.route,
|
||||
run.started,
|
||||
run.frontier_by_map,
|
||||
run.talk_on_pad_by_map,
|
||||
run.talk_starts_by_map,
|
||||
run.back_in_a_box,
|
||||
run.unknown_pads_with_no_box
|
||||
);
|
||||
|
||||
let Some(frame) = reached else {
|
||||
panic!(
|
||||
"the fly never reached the gym (map {:#04x}, route {:?}, macros {:?})",
|
||||
run.map(),
|
||||
run.route,
|
||||
run.started
|
||||
)
|
||||
};
|
||||
let progress = run.adapter.progress();
|
||||
eprintln!(
|
||||
"it reached map {PEWTER_GYM:#04x} on frame {frame}, on {macros_to_the_gym} macros; \
|
||||
the run ends on rank {} ({}) with {} badges",
|
||||
progress.rank,
|
||||
progress.rank_label,
|
||||
run.gb.read_wram(flybrain_gb::pokemon_red::symbols::ram::wObtainedBadges).count_ones()
|
||||
);
|
||||
assert!(
|
||||
macros_to_the_gym < 600,
|
||||
"reaching the gym cost {macros_to_the_gym} macros: {:?}",
|
||||
run.started
|
||||
);
|
||||
assert!(
|
||||
run.talk_on_pad_by_map.contains(&PEWTER_GYM),
|
||||
"the fly never stood in front of one of the gym's people: {:?}",
|
||||
run.talk_on_pad_by_map
|
||||
);
|
||||
assert!(
|
||||
run.objective_on_pad.contains(&PEWTER_GYM),
|
||||
"`GO OBJECTIVE` had nothing to aim at inside the gym: {:?}",
|
||||
run.objective_on_pad
|
||||
);
|
||||
// Section 12.13, against the 189 the hunt measured on map 0x02 alone.
|
||||
assert!(
|
||||
run.back_in_a_box < 5,
|
||||
"`BACK` was dealt {} times on a dialog or unknown frame",
|
||||
run.back_in_a_box
|
||||
);
|
||||
assert_eq!(
|
||||
run.unknown_pads_with_no_box, 0,
|
||||
"a pad was dealt on an unknown frame with nothing drawn on it"
|
||||
);
|
||||
// Section 12.14: the museum's frontier is proved unreachable once and then left alone. The
|
||||
// live run started GO FRONTIER 1,235 times over these two floors and a town.
|
||||
let museum: u32 = run.frontier_by_map.get(&MUSEUM_1F).copied().unwrap_or(0)
|
||||
+ run.frontier_by_map.get(&MUSEUM_2F).copied().unwrap_or(0);
|
||||
assert!(
|
||||
museum < 40,
|
||||
"`GO FRONTIER` started {museum} times on the museum's floors: {:?}",
|
||||
run.frontier_by_map
|
||||
);
|
||||
assert!(
|
||||
run.route.contains(&PEWTER_CITY),
|
||||
"the road to the gym is out of the museum's front door: {:?}",
|
||||
run.route
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue