Merge main into feat/sf-state-01

This commit is contained in:
dev 2026-09-22 17:55:03 +00:00
commit db30708c3f
18 changed files with 1151 additions and 65 deletions

View file

@ -100,6 +100,22 @@ mode = "raw" # "raw" or "macros"; FLY_MACRO_MODE overr
- Refusals are counted as `fly_chat_rejected_total{reason}`, one series per rule: `control`, - Refusals are counted as `fly_chat_rejected_total{reason}`, one series per rule: `control`,
`charset`, `empty`, `too_long`, `url`, `name`, `deny_list`, `rate_limited`, `malformed`. `charset`, `empty`, `too_long`, `url`, `name`, `deny_list`, `rate_limited`, `malformed`.
Acceptances are `fly_chat_accepted_total`, and the ring depth is `fly_chat_ring_lines`. Acceptances are `fly_chat_accepted_total`, and the ring depth is `fly_chat_ring_lines`.
- **The ring survives a restart (2026-09-22).** Every accepted line rewrites a sidecar,
`<hot_dir>/chat-ring.json` (`[paths] hot_dir`, the tmpfs the hot checkpoints use), by the same
atomic sequence a checkpoint commit uses: tmp file, fsync, rename over. At startup, before the
first publish, the file is read back; lines older than 24 hours are dropped, only the newest
`ring` of them are kept, and a missing file is silence. An unreadable, unparseable or
unknown-version file is ignored with a logged warning and an empty panel — which is what a
restart gave before this existed — never a startup failure. Writing it is best-effort too: a
failure is a warning, and the line is still accepted and still on screen.
The sidecar is **not** part of the checkpoint: it is session state, it adds no chunk to the
`FLYSIM01` envelope and nothing about it enters the compatibility string, so `--print-compatibility`
is unchanged and a build that refuses every checkpoint in a directory still restores the panel.
It lives beside the hot checkpoints because it has their lifetime — a reboot clears the tmpfs —
and `FLY_RESET_STATE=1` clears it along with them (`infra/05-deploy.sh`). The bridge resends
nothing on reconnect: the lines the page shows after a restart are the ones the service already
accepted, with their original event ids and timestamps.
`POST /chat` status codes: `202 { eventId }` accepted, `400` malformed body, `403` chat disabled, `POST /chat` status codes: `202 { eventId }` accepted, `400` malformed body, `403` chat disabled,
`422 { error }` a rule refused the line (the error names the rule), `429 { retryAfterMs }` a rate `422 { error }` a rule refused the line (the error names the rule), `429 { retryAfterMs }` a rate

View file

@ -649,6 +649,47 @@ is exactly the shape of mistake the cross-check below exists for.
both are the old map — so only the *id* is wrong, and the check that catches it is one byte: does both are the old map — so only the *id* is wrong, and the check that catches it is one byte: does
the cached grid still agree with the screen about the tile the fly is standing on. the cached grid still agree with the screen about the tile the fly is standing on.
### The frame mid-step, which the check refused (2026-09-22, row 54)
The cross-check above refused on **every frame the fly was moving**, and the reason is a fact about
when the cartridge writes the coordinates. `FLY_PROBE_CATCH=step` in
`services/flysim/crates/flysim/examples/scene_probe.rs` holds one direction from a checkpoint and
prints, per frame, the coordinates, the grid's verdict, the tiles the two readings disagree on, and
every plausible candidate for "a step is in progress". Holding UP out of the Pewter museum:
| frame | `wYCoord` | the grid | the disagreement |
| ---: | ---: | --- | --- |
| 0 | 7 | ok | -- |
| 1 | 7 | ok | -- |
| 2 .. 15 | **7** | screen disagrees | (10, 7) decoded `$20`, screen `$01`; (11, 7) `$20` against `$01` |
| 16 | **6** | ok | -- |
| 19 .. 32 | 6 | screen disagrees | (10, 7) `$20`/`$01`; (11, 6) `$01`/`$50` |
| 33 | 5 | ok | -- |
Three readings come out of it, and the first is the one everything else follows from:
1. **The coordinates change at the *end* of a step.** A step is sixteen frames; `wYCoord` reads the
tile it began on for all of them but the last. The background scrolls throughout, so from the
second frame the screen buffer is already centred one tile ahead.
2. **`map_tile_id(x, y)` therefore answers for `(x + dx, y + dy)` mid-step**, where `(dx, dy)` is
the step. Verified on both arms of the trace: at frame 19 the screen's reading for (10, 7) is
the decode of (10, 6) and its reading for (11, 6) is the decode of (11, 5), exactly.
3. **No pinned address says a step is in flight.** The player sprite's Y and X step deltas
(`wSpriteStateData1 + 3` and `+ 5`) keep their last value after the step ends -- `$ff, $00` on
every frame of the trace after the first -- so they cannot tell a step from the one before it.
`wStatusFlags5` stayed `$00`, `wMovementFlags` tracked the warp tile the fly was standing on and
not the step, and `rSCY` lags the coordinates by a frame of its own. The one byte that does
track it exactly -- counting `$07 $07 $06 $06 … $01 $01` down to `$00` on the frame the
coordinates catch up -- is **`$cfc5`**, and `gen_symbols.py` refuses a hand-written address while
the checkout `resolve_wram.py` reads is not on this box. So it is recorded here and **not used**.
What the reader does instead is measure the anchor: the screen is centred on the fly's own tile or
on one of its four neighbours, and the anchor it is centred on is the one whose **whole**
neighbourhood agrees with the decode. `(0, 0)` is tried first, so a standing frame costs exactly
what it did before. The refusals the check exists for all survive, because a wrong stride, a wrong
quadrant, a half-loaded map and the mid-warp tear each disagree under every one of the five: the
neighbourhood has to agree as a unit rather than tile by tile.
### The survey, on two maps ### The survey, on two maps
`services/flysim/crates/flysim/tests/rom_map_grid.rs`, the method of `services/flysim/crates/flysim/tests/rom_map_grid.rs`, the method of

View file

@ -1165,12 +1165,57 @@ 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 **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 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 mid-step the two are a tile apart. Measured on Pewter City from the rung-10 checkpoint: standing
still scrolling. Measured on Pewter City from the rung-10 checkpoint: standing still it decodes on still it decodes on **118 of 120** frames, and the frame the survey caught disagreed on three tiles
**118 of 120** frames, and the frame the survey caught disagreed on three tiles by exactly one row by exactly one row in the direction of travel. A walk planned on such a frame is planned over the
in the direction of travel. A walk planned on such a frame is planned over the ten-by-nine window of ten-by-nine window of section 15's "before".
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. *Worked in 12.17*, and the guess above was the wrong way round: the survey found the coordinates
change at the **end** of the step, so it is the screen that is a tile ahead of `wYCoord` rather
than `wYCoord` ahead of the screen.
### 12.17 The coordinates change at the end of a step, and an errand arrives inside (2026-09-22, row 54)
Section 12.16's two residuals turned out to be one fact and one old rule that had been left off one
walk. Both were measured from the same rung-10 checkpoint, with
`FLY_PROBE_CATCH=step` in `examples/scene_probe.rs`; the bytes are in
`docs/design/macros-wram.md` section 9.
- **`wXCoord` and `wYCoord` change at the *end* of a step, not at its start.** Holding UP out of the
Pewter museum, `wYCoord` read 7 for frames 0 to 15 of a sixteen-frame step and 6 from frame 16,
while from frame 2 the screen buffer already held the view centred on (10, 6). The grid's
cross-check compared the decode of (10, 7) with the screen's reading of (10, 6) -- `$20` against
`$01` -- and refused, on fourteen frames of every sixteen. Pewter City decoded on **118 of 120**
standing frames and on **none** of the moving ones, so every walk the fly actually took was
re-planned over the ten-by-nine window: section 15's "before", and row 23's oscillation with it.
- **So the decode is read from the tile the screen is centred on.** Nothing in the pinned symbol
table says "a step is in progress" and a new address cannot be pinned without the disassembly
`gen_symbols.py` reads, so the anchor is *measured* rather than named: the screen is centred on
the fly's tile or on one of its four neighbours, and the one it is centred on is the one whose
whole neighbourhood agrees with the decode. The check keeps the property it exists for -- a wrong
stride, a wrong quadrant, a half-loaded map or the mid-warp tear agrees with **none** of the five,
because the whole neighbourhood has to agree under one anchor rather than each tile finding an
anchor of its own.
- **The tile a step is landing on is ground the run has covered.** The other half of the same fact:
for fifteen frames of every sixteen the stood ledger recorded the tile the fly had already left,
so the ground under it stayed *unstood*, `path::frontier` kept offering it, and `GO FRONTIER` was
dealt aiming one tile away -- a walk that reports `done` the instant the step it did not make
lands. A step that has begun always finishes, and the screen has already centred on it.
- **An errand arrives inside the building, facing the counter, never on the doormat outside it.**
`GO SHOP` and `GO HEAL` aim at a door, and a door's aim carries no press because the warp fires
when it is stepped on -- so an aim on the tile the fly is already standing on settles for
`SETTLE_FRAMES` and reports `done` with the world exactly as it was. Section 12.2's trap in its
own words, and `exit_goals` has excluded a settled goal underfoot since row 13: this was the one
walk that did not have the rule. A completed errand walk also writes the reached ledger, which
`goals_toward` does not filter, so the same button came back every hold: `GO HEAL` **204** starts
at a mean net of 0.0 tiles and a mean reach of 0.0.
- **An errand is paid by a building this run has already been inside.** `areaVisited` is session
state, so a restore re-armed every errand in the town and walked the fly back to a counter it had
already used -- section 13's own residual. `MacroState::map_visited` is the adapter's lifetime
answer to the same question and it does survive a restore, so both are asked and either pays.
Nothing here changes which button the fly presses. The decoder, the reward catalog, the adapter
version and the compatibility string are untouched.
## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a ## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a
## priority to visit the shop at least once per area; make shop macros item purchases. same ## priority to visit the shop at least once per area; make shop macros item purchases. same

View file

@ -725,3 +725,14 @@ is flat and lowest once the workers leave it.
Two flaky bus tests predating this work assert timing rather than contract and are being Two flaky bus tests predating this work assert timing rather than contract and are being
rewritten separately. rewritten separately.
- 2026-09-22 (v0.4.7, loop review, auto): row 54 in Pewter and on Route 3. The player's coordinates
change at the END of a sixteen-frame step, so the whole-map grid refused every moving frame and
walks fell back to the window; a landing tile went unrecorded for fifteen frames and stayed a
frontier; an errand aimed at a door underfoot settled where it stood; the errand ledger was
session state and re-armed on restore. Fixed: the walk anchor is measured from the screen
neighbourhood, landing tiles retire, errands arrive inside facing the counter, the errand ledger
persists. Route 3's north edge is a survey item (table says west+east). From the Pewter checkpoint
the fly wins the Boulder Badge at 10.78 brain minutes. The hunt's flagged windows did not fall
(73/73) because 82% of the fixed run is battle time; Fable shipped it on the same judgement as
v0.4.6 and started row 50 (MOVE n blocked on an unresponsive move list). The on-screen chat ring
now survives a sim restart (sidecar in the hot dir, never in the checkpoint).

View file

@ -258,9 +258,10 @@ if [ -n "$RELEASE_TARBALL" ]; then
# BEFORE the symlink moves. Cost: one dataset load, a second or two. # BEFORE the symlink moves. Cost: one dataset load, a second or two.
# #
# FLY_RESET_STATE=1 is the deliberate override: it archives the durable # FLY_RESET_STATE=1 is the deliberate override: it archives the durable
# checkpoints (kept, never deleted) and clears the tmpfs hot ring, so the # checkpoints (kept, never deleted) and clears the tmpfs hot ring — the hot
# new build warms up fresh. Everything learned so far is thrown away, which # checkpoints and the on-screen chat ring's sidecar — so the new build warms
# is why it is not the default. # up fresh. Everything learned so far is thrown away, which is why it is not
# the default.
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
state_dir="${FLY_STATE_DIR:-/srv/fly/state}" state_dir="${FLY_STATE_DIR:-/srv/fly/state}"
hot_dir="${FLY_STATE_HOT_DIR:-/run/fly/state}" hot_dir="${FLY_STATE_HOT_DIR:-/run/fly/state}"
@ -294,7 +295,7 @@ if [ -n "$RELEASE_TARBALL" ]; then
# state_dir is its own mountpoint, so the directory itself cannot be # state_dir is its own mountpoint, so the directory itself cannot be
# renamed; its contents move instead. # renamed; its contents move instead.
ct_exec "$CTID" -- sh -c "mkdir -p '$archive' && mv '${state_dir}'/*.checkpoint '${state_dir}/manifest.json' '$archive'/ 2>/dev/null; chown -R fly:fly '$archive'" ct_exec "$CTID" -- sh -c "mkdir -p '$archive' && mv '${state_dir}'/*.checkpoint '${state_dir}/manifest.json' '$archive'/ 2>/dev/null; chown -R fly:fly '$archive'"
ct_exec "$CTID" -- sh -c "rm -f '${hot_dir}'/*.checkpoint '${hot_dir}/manifest.json' 2>/dev/null; true" ct_exec "$CTID" -- sh -c "rm -f '${hot_dir}'/*.checkpoint '${hot_dir}/manifest.json' '${hot_dir}/chat-ring.json' 2>/dev/null; true"
else else
die "05-deploy: REFUSING to deploy release ${version}: its checkpoint compatibility string does not match the live state in ${state_dir}, so flysim would refuse every checkpoint there and then refuse to start at all — a black stream. die "05-deploy: REFUSING to deploy release ${version}: its checkpoint compatibility string does not match the live state in ${state_dir}, so flysim would refuse every checkpoint there and then refuse to start at all — a black stream.
live state: ${live_compat} live state: ${live_compat}

View file

@ -2057,23 +2057,93 @@ that covers more ground walks into more grass, and the hunt cannot tell a long f
| # | trap | trigger | test | fix, or why it is left | | # | 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 | | 54 | `GO FRONTIER`, `GO HEAL` and `GO ROUTE` cycle on five tiles: three walks that each end where they began | rung 10: 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. Rung 11, the same cycle one town on: **14 distinct tiles in six brain minutes**, 17 of 17 windows flagged, `GO FRONTIER` 122 / `GO HEAL` 129 / `GO ROUTE` 126, **every one `done` at a mean net of 0.0**, printed as `GO ROUTE, GO FRONTIER, GO HEAL` x34 to x42 | `an_errand_does_not_settle_on_the_doormat_it_is_standing_on`, `an_errand_is_paid_by_a_building_this_run_has_already_been_inside`, `a_frame_mid_step_is_read_from_the_tile_the_screen_is_centred_on`, `a_decode_the_screen_disagrees_with_is_refused_mid_step_too`, `the_tile_a_step_is_landing_on_is_ground_the_run_has_covered`, `an_edge_the_table_cannot_name_stops_being_somewhere_new_once_it_is_stood_on`, and both ROM runs below | **fixed, and both readings were right.** Four facts, all measured: the coordinates change at the **end** of a step, so the grid was refused on every moving frame and every walk was planned over the ten-by-nine window; the tile a step is landing on was unrecorded for fifteen frames of every sixteen, so the fly's own next tile was a frontier it arrived at without moving; an errand's aim at a door the fly was standing on settled where it stood, and a completed errand walk writes the reached ledger, so the button came back every hold; and the errand ledger is session state, so a restore re-armed a town the run had already shopped and healed in. `docs/design/macros.md` section 12.17 |
| 54b | an **edge** the geography table has no row for is "somewhere new" for ever | Route 3: the cartridge reports its connections as **north and west** (`wCurMapConnections`; `warps: []`), the table carries west and **east**, so the seven walkable tiles of its north edge answered "leads somewhere this run has not stood on" on every hold, with `GO OBJECTIVE` off the pad beside them because nothing on that map leads to the objective | `an_edge_the_table_cannot_name_stops_being_somewhere_new_once_it_is_stood_on` | **fixed, narrowly.** A warp's destination is a byte the cartridge publishes, so `None` there is the `LAST_MAP` case row 2 already handles; an edge's comes only from `geography::connected`, so `None` there means the table cannot name it and never will. The only record left is the adapter's boundary ledger, and an edge the run has already stood on is not somewhere new. **The table row itself is not guessed at**: which map is north of Route 3 is a survey nobody has run, and it is a residual below |
### Residuals, named rather than worked around ### Residuals, named rather than worked around
- **The whole-map grid is refused while the fly is moving.** `map_grid` checks its decode against - ~~**The whole-map grid is refused while the fly is moving.**~~ **Worked, 2026-09-22 (row 54).**
the screen buffer over the fly's tile and its four neighbours, and mid-step the two are one tile The guess was the wrong way round: `FLY_PROBE_CATCH=step` in `examples/scene_probe.rs` found the
apart: `wYCoord` is the tile being walked *to* while the background is still scrolling. Measured coordinates change at the **end** of a sixteen-frame step, so it is the screen that is one tile
on Pewter City from this checkpoint: standing still it decodes on **118 of 120** frames, and the ahead of `wYCoord` and not `wYCoord` ahead of the screen. The reader now measures which tile the
frame the survey caught disagreed on three tiles by exactly one row in the direction of travel. screen is centred on. `docs/design/macros-wram.md` section 9 has the frame-by-frame trace and the
A walk planned on such a frame is planned over the ten-by-nine window of section 15's "before", candidates; `$cfc5` tracks a step exactly and is recorded there **unused**, because
which is what row 23's oscillation is made of. The honest fix is a WRAM reading of "a step is in `gen_symbols.py` refuses a hand-written address and the checkout `resolve_wram.py` reads is not
progress", which this crate's reviewed symbol list does not carry, so it is reported by the on this box.
probes and left. - ~~**The town's errands are session state**~~, so every restart re-armed them. **Worked,
- **The town's errands are session state**, so every restart re-arms them and `GO OBJECTIVE` aims 2026-09-22 (row 54):** the adapter's lifetime `map_visited` is asked beside the session ledger,
at the mart and the centre before the rung's place, the gym included. Section 13's own design. and a building this run has already been inside pays the errand whichever one remembers it.
- **`MOVE n` still reports `blocked`** with the move list drawn and its cursor placeable but not - **`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. accepting input (row 50). It is now **the largest thing in the way**: after row 54 the fly wins
the Boulder Badge and then spends 30,809 frames in one battle on the rung-10 arm and 13,251 on
the rung-11 arm, and the hunt flags every window of both because its tile rule cannot tell a long
battle from a stall. Unchanged since v0.4.3.
- **Which map is north of Route 3.** The cartridge says that edge is connected and the geography
table has no row for it (row 54b). Naming it is a survey -- walk the fly off that edge with real
presses and read `wCurMap` back, the method of `docs/design/macros-wram.md` -- and nothing here
guesses at it. Until then that edge is walked once and then falls out of the first tier.
## Row 54: the two arms, and the ROM runs (2026-09-22, v0.4.6)
Two checkpoints, because the loop was found twice: the rung-10 one the previous review left it in,
and the rung-11 one the stream fell into forty minutes after the badge was won. Same seed, same
ground, `main` at `cb9a88c` against this branch.
**The rung-10 checkpoint, twenty brain minutes.**
| measure | before (`main`, v0.4.6) | after |
| --- | ---: | ---: |
| rung reached | 10 | **11 (BOULDER BADGE at 10.78 brain minutes)** |
| distinct (map, tile) | **175** | 165 |
| windows flagged | **69 / 73** | 73 / 73 |
| macros started | 1,056 | 1,211 |
| `GO HEAL` starts | **204**, every one `done` at a net of 0.0 | **0** |
| `GO FRONTIER` starts | 242 | 34 |
| `GO ROUTE` starts | 211, mean net 0.2 | 7, mean net 6.1, max 30 |
| the repeated sequence | `GO FRONTIER, GO HEAL, GO ROUTE` | no walk cycle at all |
| frames in `battle` | 20,894 | **58,687** (longest run 30,809) |
| wall clock | 7,515 s | **3,086 s** |
**The cycle is gone and the fly wins the badge, and the hunt still flags every window.** Both are
true and both are reported. Eighty-two per cent of the after arm is inside battles and the longest
single battle is 30,809 frames, so the tile rule -- fewer than four distinct tiles in two brain
minutes -- flags a fly that is fighting exactly as hard as a fly that is stuck. That is section
15's own measurement in `docs/design/macros.md`, and the second branch running into it.
**The ethos check's "fewer flagged windows, more distinct tiles" does not hold on this arm**, and
the merge is Fable's call. The wall clock is the grid fix seen from outside: `main` re-decodes the
whole map on most frames because the cross-check refuses them, and this branch serves the cache.
**The rung-11 checkpoint, six brain minutes.** This is the arm the fix is about, on ground with no
gym leader in it.
| measure | before (`main`, v0.4.6) | after |
| --- | ---: | ---: |
| distinct (map, tile) | **14** | **88** |
| windows flagged | 17 / 17 | 17 / 17 |
| macros started | 378, every one `done` | 314 |
| `GO HEAL` starts | **129**, every one at a net of 0.0 | **0** |
| `GO FRONTIER` starts | 122, net 0.0 | 9 |
| `GO ROUTE` starts | 126, net 0.0 | **4, mean net 4.8, mean reach 19.5** |
| the repeated sequence | `GO ROUTE, GO FRONTIER, GO HEAL` x34 to x42 | `NEXT` and `BACK` in a battle's move list |
| the fly leaves Pewter City | **never** | **at 0.85 brain minutes**, and it ends the run on Route 3 |
| frames in `battle` | 0 | 15,619 (longest run 13,251, from minute 1.09) |
**Six times the ground, and the same seventeen flagged windows.** No walk completes at a net of
zero tiles any more, and from brain minute 1.09 the fly is inside a single 13,251-frame battle,
which the tile rule flags exactly as hard as the cycle it replaced. What makes that battle last is
row 50.
**ROM-gated, from both checkpoints** (`services/flysim/crates/flysim/tests/rom_macros_mode.rs`,
skipped cleanly without `FLY_ROM` and the checkpoint):
- `the_fly_reaches_the_pewter_gym_from_the_rung_ten_checkpoint` -- the gym's own interior on frame
**3,163** on **25 macros**, `BACK` in a box **0**, unknown pads with no box **0**, `GO FRONTIER`
on the museum's two floors **0**, and the new claim: **the longest chain of walks that completed
at a net of zero tiles is 1**, against a bound of three.
- `the_fly_leaves_pewter_from_the_rung_eleven_checkpoint` -- route `[2, 56, 2, 14, 2, 14]` over
55.8 brain minutes: out of the town, into the mart **once**, and on to Route 3. `GO HEAL` **0**
starts, `GO SHOP` **1**, `GO ROUTE` **3**, and the longest chain of walks that completed at a net
of zero tiles is **2** (`GO FRONTIER`, `GO NPC`) against the same bound of three.
### Gates ### Gates

View file

@ -413,6 +413,64 @@ impl Wram {
self self
} }
/// A map ten blocks by nine -- twenty tiles by eighteen, wider than the ten-by-nine window
/// -- with a wall down one column of blocks, and a screen buffer that agrees with it.
///
/// The three tables the grid is decoded from, all synthetic: block ids in `wOverworldMap`, a
/// blockset in a ROM bank that is not bank 0, and a collision list in bank 0 where
/// `wTilesetCollisionPtr` points. The blocks and the blockset come back so that a caller can
/// redraw the screen ([`Wram::mid_step`]).
pub fn town() -> (Self, Vec<u8>, Vec<[u8; 16]>) {
const FLOOR: u8 = 0x01;
let blockset = vec![[FLOOR; 16], [WALL_TILE; 16]];
let (wide, high) = (10usize, 9usize);
let mut blocks = vec![0u8; wide * high];
for row in 0..high {
blocks[row * wide + 5] = 1;
}
// Two landmarks beside the fly's own tile, one on each axis. A map whose neighbourhood is
// the same tile id in every direction cannot tell a view centred on the fly from a view
// centred one tile away, which is exactly what a mid-step frame is ([`Wram::mid_step`]).
// The fly stands on (3, 4) of the decoded map: these make (2..3, 2..3) and (0..1, 4..5)
// wall, leaving (3, 4) and every tile it can step to walkable.
blocks[wide + 1] = 1;
blocks[2 * wide] = 1;
let mut wram = Self::new();
wram.started()
.map(PALLET_TOWN, wide as u8, high as u8, 3, 4)
.facing(0)
.house_collision()
.tileset(0)
.blockset(&blockset)
.map_blocks(&blocks)
.fill_screen(WALL_TILE)
.screen_from_blocks(&blocks, &blockset);
(wram, blocks, blockset)
}
/// The screen buffer centred one tile away from `wXCoord` / `wYCoord`, which is what a frame
/// **mid-step** looks like on the cartridge.
///
/// Measured 2026-09-22 (`infra/docs/macros-traps.md` row 54): the coordinates change at the
/// *end* of a sixteen-frame step and the background scrolls throughout it, so for fifteen
/// frames of every sixteen the two readings are one tile apart in the direction of travel.
/// This draws exactly that: the view is rendered from `(x + dx, y + dy)` and the coordinates
/// are put back.
pub fn mid_step(
&mut self,
dx: i16,
dy: i16,
blocks: &[u8],
blockset: &[[u8; 16]],
) -> &mut Self {
let (x, y) = (self.peek(ram::wXCoord), self.peek(ram::wYCoord));
self.set(ram::wXCoord, (i16::from(x) + dx) as u8);
self.set(ram::wYCoord, (i16::from(y) + dy) as u8);
self.fill_screen(WALL_TILE).screen_from_blocks(blocks, blockset);
self.set(ram::wXCoord, x).set(ram::wYCoord, y);
self
}
/// A playable overworld frame: Red's ground floor, the fly standing where a cold boot's walk /// A playable overworld frame: Red's ground floor, the fly standing where a cold boot's walk
/// out of the bedroom lands it, every tile a wall until a test opens one. /// out of the bedroom lands it, every tile a wall until a test opens one.
pub fn overworld() -> Self { pub fn overworld() -> Self {

View file

@ -438,6 +438,20 @@ pub trait MacroState: GameState {
false false
} }
/// The tile the fly is stepping onto, or `None` while it is standing still.
///
/// **Row 54 of `infra/docs/macros-traps.md`, measured on the cartridge.** `wXCoord` and
/// `wYCoord` change at the *end* of a step, so for fifteen frames of every sixteen the fly's
/// coordinates are the tile it has already left. The ground under it is unrecorded for all of
/// them, `path::frontier` keeps offering it, and `GO FRONTIER` is dealt aiming one tile away
/// -- a walk that reports `done` the instant the step it did not make lands.
///
/// The default is `None`, i.e. never mid-step, which is the narrowing every other default in
/// this trait is: the stood ledger keeps the coordinates alone, which is what it did before.
fn stepping_onto(&mut self) -> Option<Tile> {
None
}
/// Whether this run has already talked to `target` on the map that is loaded. /// Whether this run has already talked to `target` on the map that is loaded.
/// ///
/// `docs/design/macros.md` section 12's talked ledger, and the observable that ends the /// `docs/design/macros.md` section 12's talked ledger, and the observable that ends the

View file

@ -200,7 +200,7 @@ impl MacroPalette for PokemonPalette {
} }
fn observe(&mut self, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger) -> Observed { fn observe(&mut self, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger) -> Observed {
let (scene, bindings, standing, approach) = { let (scene, bindings, standing, stepping, approach) = {
let Self { let Self {
machine, machine,
mode, mode,
@ -237,6 +237,9 @@ impl MacroPalette for PokemonPalette {
// -- the coordinates and the loaded map header are from different frames, and a tile // -- the coordinates and the loaded map header are from different frames, and a tile
// recorded from that pair is a tile of nowhere. // recorded from that pair is a tile of nowhere.
let standing = (!state.scripted()).then(|| state.player()).flatten(); let standing = (!state.scripted()).then(|| state.player()).flatten();
// And the tile the step in flight is landing on (row 54). Read from the same frame and
// behind the same "the fly is its own master" gate as the ground itself.
let stepping = standing.and_then(|_| state.stepping_onto());
// How far the objective is, over the same map graph `GO OBJECTIVE` walks (section // 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 // 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. // where the fly is its own master, for the same reason the ground is.
@ -247,7 +250,7 @@ impl MacroPalette for PokemonPalette {
Some((objective.map, hops)) Some((objective.map, hops))
}); });
*cached = Some(palette); *cached = Some(palette);
(scene, bindings, standing, approach) (scene, bindings, standing, stepping, approach)
}; };
// Section 12.7: the macro layer's own answer to "has the run stood here", because the // 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. // adapter's reward ledger cannot record a doormat.
@ -258,6 +261,16 @@ impl MacroPalette for PokemonPalette {
if self.stood.record(player.map, Tile::new(player.x, player.y)) { if self.stood.record(player.map, Tile::new(player.x, player.y)) {
self.frontiers.clear(player.map); self.frontiers.clear(player.map);
} }
// The tile a step in flight is landing on is ground this run has covered: the
// cartridge owns the animation and no press stops it, and the screen has already
// centred on it. Without this the fly's own next tile is a frontier for the fifteen
// frames it takes to get there, which `GO FRONTIER` arrives at without moving
// (`infra/docs/macros-traps.md` row 54).
if let Some(onto) = stepping
&& self.stood.record(player.map, onto)
{
self.frontiers.clear(player.map);
}
// Section 13's `areaVisited(kind, area)`: the errand is paid on *entering*, so the // 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 // 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 // building's own map is the whole test -- the fly is inside it -- and it is written
@ -454,7 +467,7 @@ mod tests {
use super::*; use super::*;
use crate::adapter::{MapEdge, MapExit}; use crate::adapter::{MapEdge, MapExit};
use crate::macros::NoLedger; use crate::macros::NoLedger;
use crate::pokemon_red::fake_wram::{REDS_HOUSE_1F, Wram}; use crate::pokemon_red::fake_wram::{self, REDS_HOUSE_1F, WALL_TILE, Wram};
use crate::pokemon_red::macros::geography::Amenity; use crate::pokemon_red::macros::geography::Amenity;
use crate::pokemon_red::maps; use crate::pokemon_red::maps;
use crate::pokemon_red::macros::cartridge::{Edge, ExitId, MacroState}; use crate::pokemon_red::macros::cartridge::{Edge, ExitId, MacroState};
@ -468,6 +481,34 @@ mod tests {
} }
} }
#[test]
fn the_tile_a_step_is_landing_on_is_ground_the_run_has_covered() {
// Row 54 of `infra/docs/macros-traps.md`. `wXCoord` and `wYCoord` are the tile the step
// began on until the frame it ends, so without this the ground under the fly is unrecorded
// for fifteen frames of every sixteen: `path::frontier` keeps offering the tile the fly is
// already halfway onto, `GO FRONTIER` is dealt aiming at it, and `Arrival::Step` reports
// `done` the instant the step it did not make lands -- a macro that completes without
// changing anything, which is section 12.2's trap.
let (mut wram, blocks, blockset) = Wram::town();
let mut palette = PokemonPalette::new(7);
palette.observe(&mut wram, &NoLedger);
assert_eq!(palette.stood(), 1, "standing still, the tile under the fly and nothing else");
// Mid-step west: the coordinates still read (3, 4), the screen is already centred on
// (2, 4).
wram.mid_step(-1, 0, &blocks, &blockset);
palette.observe(&mut wram, &NoLedger);
assert_eq!(palette.stood(), 2, "and the tile the step is landing on");
// The step lands. The ledger had it already, so nothing new is recorded and the frontier
// mark is not cleared a second time.
wram.map(fake_wram::PALLET_TOWN, 10, 9, 2, 4)
.fill_screen(WALL_TILE)
.screen_from_blocks(&blocks, &blockset);
palette.observe(&mut wram, &NoLedger);
assert_eq!(palette.stood(), 2, "the tile it landed on was already ground it had covered");
}
#[test] #[test]
fn a_fresh_cartridge_reads_as_the_title_and_binds_nothing() { fn a_fresh_cartridge_reads_as_the_title_and_binds_nothing() {
// All-zero WRAM: the game-timer bit is clear, which is the title screen. // All-zero WRAM: the game-timer bit is clear, which is the title screen.

View file

@ -868,7 +868,17 @@ pub fn errand(state: &mut dyn MacroState, kind: Amenity) -> Option<u8> {
if kind == Amenity::Mart && state.money() < CHEAPEST_PURCHASE { if kind == Amenity::Mart && state.money() < CHEAPEST_PURCHASE {
return None; return None;
} }
geography::amenity_of(area, kind) let map = geography::amenity_of(area, kind)?;
// **A building this run has already been inside is an errand already discharged.** The session
// ledger above is the errand's own record and it is the one that can be missing: it is written
// from the frame the fly stands on the building's map, so a restore starts with it empty and
// the run walks back to a counter it has already used. [`MacroState::map_visited`] is the
// adapter's lifetime answer to the same question and it does survive, so the two together are
// "has this run been in there", asked twice (`infra/docs/macros-traps.md` row 54).
if state.map_visited(map) {
return None;
}
Some(map)
} }
/// The errand `GO OBJECTIVE` puts *ahead* of the rung's place, when this area has one. /// The errand `GO OBJECTIVE` puts *ahead* of the rung's place, when this area has one.
@ -930,7 +940,21 @@ pub fn amenity_goals(state: &mut dyn MacroState, kind: Amenity) -> Vec<Aim> {
return counter_aims(state, counter_sprite(kind)); return counter_aims(state, counter_sprite(kind));
} }
match errand(state, kind) { match errand(state, kind) {
Some(map) => goals_toward(state, map), Some(map) => {
let here = state.player().map(|player| Tile::new(player.x, player.y));
goals_toward(state, map)
.into_iter()
// **An errand arrives inside the building, never on the doormat outside it**
// (section 12.2's rule, row 54). An aim with no press settles where it stands, so
// an aim on the tile the fly is already on is `Done` in `SETTLE_FRAMES` with the
// world exactly as it was -- and a completed errand walk writes the reached ledger,
// so the same button is dealt on the next hold and the same nothing happens again:
// `GO HEAL` 204 starts at a mean net of 0.0 tiles and a mean reach of 0.0. The same
// exclusion [`super::executor::exit_goals`] has made since row 13, for the same
// reason, on the one walk that did not have it.
.filter(|aim| aim.press.is_some() || Some(aim.tile) != here)
.collect()
}
None => Vec::new(), None => Vec::new(),
} }
} }
@ -1274,9 +1298,23 @@ fn exit_tiers(state: &mut dyn MacroState, way: Way) -> Vec<Exit> {
let known_visited = match exit.destination(here) { let known_visited = match exit.destination(here) {
Some(map) => state.map_visited(map), Some(map) => state.map_visited(map),
// A front door nobody can name opens on the map the fly came in from, which is a map // A front door nobody can name opens on the map the fly came in from, which is a map
// this run has stood on by construction. Every other unnameable destination stays a // this run has stood on by construction.
// candidate. None if exit.way == Way::Exit => true,
None => exit.way == Way::Exit, // **An edge the geography table has no row for** (2026-09-22, the rung-11 reading of
// row 54). A warp's destination is a byte the cartridge publishes, so `None` there is
// the `LAST_MAP` case above; an edge's destination comes only from
// [`geography::connected`], so `None` here means the table cannot name the map on the
// other side and never will. Route 3 is the measured one: the cartridge reports its
// connections as **north and west** while the table carries west and *east*, so its
// seven walkable north-edge tiles answered "leads somewhere this run has not stood
// on" on every hold for ever, and `GO ROUTE` aimed at them once per hold.
//
// The only record left is the adapter's own boundary ledger, which is what section
// 9.2 replaced as the *general* test and which is still the honest answer for an exit
// nothing else can say anything about: an edge the run has already stood on is not
// somewhere new. It narrows, so a genuinely new edge is still first-tier until the
// fly reaches it.
None => state.exit_visited(exit.id),
}; };
if !known_visited { if !known_visited {
fresh.push(*exit); fresh.push(*exit);

View file

@ -3970,6 +3970,87 @@ fn go_shop_and_go_heal_are_on_the_pad_while_their_errand_stands() {
assert!(!on_the_pad(&mut pallet, MacroKind::GoHeal)); assert!(!on_the_pad(&mut pallet, MacroKind::GoHeal));
} }
#[test]
fn an_edge_the_table_cannot_name_stops_being_somewhere_new_once_it_is_stood_on() {
// The rung-11 reading of row 54 (`infra/docs/macros-traps.md`). The cartridge reports Route
// 3's connections as north and west; `geography`'s row carries west and east, so the north
// edge's destination is unnameable -- and an unnameable destination counted as *unvisited*,
// which made those tiles first-tier for `GO ROUTE` on every hold for ever, with
// `GO OBJECTIVE` off the pad beside them because nothing on this map leads to the objective.
let mut world = World::room();
world.map = maps::ROUTE_3;
world.size = MapSize { width: 8, height: 8 };
world.player = Tile::new(4, 4);
world.connections = Connections { north: true, south: false, east: false, west: true };
// West is Pewter City, which the table does name and the run has stood on.
world.seen_maps.insert(maps::PEWTER_CITY);
let north: Vec<ExitId> = ways(&mut world, Way::Route).iter().map(|exit| exit.id).collect();
assert!(
north.iter().all(|id| *id == ExitId::Edge(Edge::North)),
"the unnameable north edge is the only fresh way out: {north:?}"
);
// Stood on, and it is no longer somewhere new -- so the walk falls to the tier that leads
// toward the objective instead of aiming at the same edge once per hold for ever.
world.visited.insert(ExitId::Edge(Edge::North));
let left: Vec<ExitId> = ways(&mut world, Way::Route).iter().map(|exit| exit.id).collect();
assert!(
!left.contains(&ExitId::Edge(Edge::North)),
"an edge nothing can name, already crossed, is not first-tier: {left:?}"
);
}
#[test]
fn an_errand_does_not_settle_on_the_doormat_it_is_standing_on() {
// Row 54 of `infra/docs/macros-traps.md`, and section 12.2's rule: "a macro that completes
// without moving because its precondition is already satisfied where the fly stands is a
// trap". An errand's aim at a door carries no press -- the warp fires when it is stepped on --
// so an aim on the tile the fly is already on settles for `SETTLE_FRAMES` and reports `done`
// with the world exactly as it was. A completed errand walk writes the reached ledger, which
// `goals_toward` does not filter, so the same button was dealt on the next hold and the same
// nothing happened again: `GO HEAL` 204 starts at a mean net of 0.0 tiles and a mean reach of
// 0.0, in a cycle with `GO ROUTE` and `GO FRONTIER` over five tiles.
let mut world = viridian();
world.player = Tile::new(6, 1);
assert!(
amenity_goals(&mut world, Amenity::Center).is_empty(),
"the centre's own doormat is not somewhere to walk to"
);
assert!(!on_the_pad(&mut world, MacroKind::GoHeal), "so the button is not on the pad");
// The other errand is a tile away and untouched: this excludes one aim, not the walk.
assert_eq!(
amenity_goals(&mut world, Amenity::Mart).iter().map(|aim| aim.tile).collect::<Vec<_>>(),
vec![Tile::new(1, 1)]
);
assert!(on_the_pad(&mut world, MacroKind::GoShop));
// And one tile off the doormat the centre is a walk again.
world.player = Tile::new(6, 2);
assert_eq!(
amenity_goals(&mut world, Amenity::Center).iter().map(|aim| aim.tile).collect::<Vec<_>>(),
vec![Tile::new(6, 1)]
);
assert!(on_the_pad(&mut world, MacroKind::GoHeal));
}
#[test]
fn an_errand_is_paid_by_a_building_this_run_has_already_been_inside() {
// The errand ledger is session state and the adapter's map ledger is not, so a restored run
// re-armed every errand in the town and walked back to a counter it had already used
// (`docs/design/macros.md` section 13's own residual). Asking both is asking "has this run
// been in there" twice, and either answer pays the errand.
let mut world = viridian();
assert_eq!(errand(&mut world, Amenity::Center), Some(maps::VIRIDIAN_POKECENTER));
world.seen_maps.insert(maps::VIRIDIAN_POKECENTER);
assert_eq!(errand(&mut world, Amenity::Center), None, "already been inside it");
assert!(!on_the_pad(&mut world, MacroKind::GoHeal));
// The mart is a different building and a different errand.
assert_eq!(errand(&mut world, Amenity::Mart), Some(maps::VIRIDIAN_MART));
assert!(on_the_pad(&mut world, MacroKind::GoShop));
}
#[test] #[test]
fn go_shop_walks_to_the_marts_door_and_then_to_the_counter() { fn go_shop_walks_to_the_marts_door_and_then_to_the_counter() {
// Outside: the goal is the door, and it is the warp's own tile. // Outside: the goal is the door, and it is the warp's own tile.

View file

@ -944,19 +944,95 @@ pub fn map_grid(memory: &mut dyn MemoryReader) -> Result<MapGrid, GridRefusal> {
let player = player(memory).ok_or(GridRefusal::NoPlayer)?; let player = player(memory).ok_or(GridRefusal::NoPlayer)?;
// The cross-check. `map_tile_id` reads the screen buffer at the offset // 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 // `_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. // is agreeing with the cartridge's own reading of the same ground -- once the two readings are
let mut checked = 0; // anchored on the same tile, which mid-step they are not ([`screen_anchor`]).
for (x, y) in neighbourhood(player.x, player.y) { screen_anchor(memory, &grid, player.x, player.y)?;
let Some(screen) = map_tile_id(memory, x, y) else { continue }; Ok(grid)
if grid.tile_id(x, y) != Some(screen) { }
return Err(GridRefusal::ScreenDisagrees);
} /// The five tiles the screen buffer can be centred on, nearest first.
checked += 1; ///
} /// Standing still it is the fly's own tile; mid-step it is the tile the fly is stepping onto.
if checked == 0 { /// `(0, 0)` is first so that a standing frame is answered by the first comparison it makes.
const ANCHORS: [(i16, i16); 5] = [(0, 0), (0, -1), (0, 1), (-1, 0), (1, 0)];
/// Which tile the screen buffer is centred on, as an offset from `wXCoord` / `wYCoord`.
///
/// **The mid-step refusal, measured 2026-09-22** (`infra/docs/macros-traps.md` row 54; the survey
/// is `FLY_PROBE_CATCH=step` in `services/flysim/crates/flysim/examples/scene_probe.rs`). Holding
/// UP out of the Pewter museum, `wYCoord` read 7 for frames 0 to 15 of a sixteen-frame step and 6
/// from frame 16: **the coordinates change at the end of a step, not at its start.** The
/// background scrolls throughout, and from frame 2 the buffer already held the view centred on
/// (10, 6). So the old check compared the decode of (10, 7) against the screen's reading of
/// (10, 6), found `$20` against `$01`, and refused -- on fourteen frames of every sixteen. Pewter
/// City decoded on 118 of 120 standing frames and on none of the moving ones, so every walk the
/// fly actually took was re-planned over the ten-by-nine window, which is the oscillation of
/// `docs/design/macros.md` section 12.3's row 23.
///
/// Nothing in the pinned symbol table says "a step is in progress" (`docs/design/macros-wram.md`
/// section 9), and a new address cannot be pinned without the disassembly `gen_symbols.py` reads.
/// So the anchor is **measured rather than named**: the screen is centred on the fly's tile or on
/// one of its four neighbours, and the one it is centred on is the one whose whole neighbourhood
/// agrees with the decode. This keeps the property the check exists for -- a decode with a wrong
/// stride, a wrong quadrant or a half-loaded map agrees with *none* of the five, and so does the
/// mid-warp tear the cache check was added for, where the blocks are one map and `wCurMap` another.
///
/// `Err(NoScreen)` when the window can answer for none of the five tiles (a battle, a text box),
/// `Err(ScreenDisagrees)` when no anchor agrees.
fn screen_anchor(
memory: &mut dyn MemoryReader,
grid: &MapGrid,
x: u8,
y: u8,
) -> Result<(i16, i16), GridRefusal> {
let screen: Vec<(u8, u8, u8)> = neighbourhood(x, y)
.into_iter()
.filter_map(|(tx, ty)| map_tile_id(memory, tx, ty).map(|id| (tx, ty, id)))
.collect();
if screen.is_empty() {
return Err(GridRefusal::NoScreen); return Err(GridRefusal::NoScreen);
} }
Ok(grid) for (dx, dy) in ANCHORS {
let agrees = screen.iter().all(|(tx, ty, id)| {
let (Ok(ax), Ok(ay)) =
(u8::try_from(i16::from(*tx) + dx), u8::try_from(i16::from(*ty) + dy))
else {
return false;
};
grid.tile_id(ax, ay) == Some(*id)
});
if agrees {
return Ok((dx, dy));
}
}
Err(GridRefusal::ScreenDisagrees)
}
/// The tile the fly is stepping onto, or `None` while it is standing still.
///
/// The other half of the measurement above, and row 54's second trap. `wXCoord` / `wYCoord` are
/// the tile the step began on until the frame it ends, so for fifteen frames of every sixteen the
/// stood ledger records ground the fly has already left and the tile under it is still *unstood*:
/// `path::frontier` offers it, `GO FRONTIER` is dealt aiming one tile away, and `Arrival::Step`
/// reports `done` the instant the step it did not make lands. A macro that completes without
/// changing anything, which is section 12.2's trap in its own words.
///
/// A step that has begun always finishes -- the cartridge owns the animation and no press stops it
/// -- so the tile the screen has already centred on is ground this run has covered.
///
/// It answers `None` on a mid-step frame whose neighbourhood is the same tile id in every
/// direction, because [`ANCHORS`] tries the standing anchor first and an open field agrees under
/// it. That is the safe way round: the grid served is still the right one, and the tile is
/// recorded on the frame the step lands, as it was before.
pub fn step_destination(memory: &mut dyn MemoryReader, grid: &MapGrid) -> Option<(u8, u8)> {
let player = player(memory)?;
let (dx, dy) = screen_anchor(memory, grid, player.x, player.y).ok()?;
if (dx, dy) == (0, 0) {
return None;
}
let x = u8::try_from(i16::from(player.x) + dx).ok()?;
let y = u8::try_from(i16::from(player.y) + dy).ok()?;
Some((x, y))
} }
/// [`map_grid`] without the cross-check: the blocks, the blockset and the collision list, decoded. /// [`map_grid`] without the cross-check: the blocks, the blockset and the collision list, decoded.
@ -1049,11 +1125,15 @@ fn still_the_loaded_map(
x: u8, x: u8,
y: u8, y: u8,
) -> bool { ) -> bool {
match map_tile_id(memory, x, y) { match screen_anchor(memory, grid, x, y) {
// The screen is not showing the map (a battle, a text box): nothing to check against, and // The screen is not showing the map (a battle, a text box): nothing to check against, and
// the grid was checked when it was decoded. // the grid was checked when it was decoded.
None => true, Err(GridRefusal::NoScreen) => true,
Some(tile) => grid.tile_id(x, y) == Some(tile), Err(_) => false,
// Agreeing under *some* anchor is agreeing: the fly's own tile while it stands still, the
// tile it is stepping onto while it moves (row 54). The whole neighbourhood has to agree
// under one of them, which a torn frame's grid cannot manage.
Ok(_) => true,
} }
} }
@ -1394,6 +1474,18 @@ impl MacroState for PokeState<'_> {
} }
} }
/// The tile the fly is stepping onto, from the screen the grid was checked against
/// (`infra/docs/macros-traps.md` row 54).
///
/// `None` on a frame with no grid, which is the same narrowing every other reading here makes:
/// without a decode to anchor against there is nothing that can say where the screen is
/// centred, and the stood ledger keeps the coordinates alone.
fn stepping_onto(&mut self) -> Option<Tile> {
let grid = self.map_grid()?;
let (x, y) = step_destination(self.memory, &grid)?;
Some(Tile::new(x, y))
}
/// What the open mart sells, in menu order (`docs/design/macros.md` section 13). /// What the open mart sells, in menu order (`docs/design/macros.md` section 13).
/// ///
/// Gated on the mart scene being up, and that gate is the whole of the accuracy here: /// Gated on the mart scene being up, and that gate is the whole of the accuracy here:

View file

@ -685,24 +685,7 @@ fn the_live_implementation_answers_the_whole_trait() {
/// blockset in a ROM bank that is not bank 0, and a collision list in bank 0 where /// blockset in a ROM bank that is not bank 0, and a collision list in bank 0 where
/// `wTilesetCollisionPtr` points. /// `wTilesetCollisionPtr` points.
fn town() -> (Wram, Vec<u8>, Vec<[u8; 16]>) { fn town() -> (Wram, Vec<u8>, Vec<[u8; 16]>) {
const FLOOR: u8 = 0x01; Wram::town()
let blockset = vec![[FLOOR; 16], [WALL_TILE; 16]];
let (wide, high) = (10usize, 9usize);
let mut blocks = vec![0u8; wide * high];
for row in 0..high {
blocks[row * wide + 5] = 1;
}
let mut wram = Wram::new();
wram.started()
.map(fake_wram::PALLET_TOWN, wide as u8, high as u8, 3, 4)
.facing(0)
.house_collision()
.tileset(0)
.blockset(&blockset)
.map_blocks(&blocks)
.fill_screen(WALL_TILE)
.screen_from_blocks(&blocks, &blockset);
(wram, blocks, blockset)
} }
#[test] #[test]
@ -730,6 +713,43 @@ fn the_whole_map_decodes_from_the_block_and_collision_tables() {
} }
} }
#[test]
fn a_frame_mid_step_is_read_from_the_tile_the_screen_is_centred_on() {
// Row 54 of `infra/docs/macros-traps.md`, measured on the cartridge: `wXCoord` and `wYCoord`
// change at the *end* of a step, so for fifteen frames of every sixteen the screen buffer is
// centred one tile ahead of them. The old cross-check compared the decode of the fly's tile
// against the screen's reading of the tile ahead and refused; Pewter City decoded on 118 of
// 120 standing frames and on none of the moving ones, and every walk the fly actually took was
// planned over the ten-by-nine window instead.
let (mut wram, blocks, blockset) = town();
wram.mid_step(-1, 0, &blocks, &blockset);
// The trap itself, stated as a reading: the two answers for a tile beside the fly disagree.
let naive = map_tile_id(&mut wram, 2, 4);
let grid = map_grid(&mut wram).expect("a mid-step frame still decodes");
assert_ne!(grid.tile_id(2, 4), naive, "the screen is one column ahead of the coordinates");
assert_eq!(grid.tile_id(1, 4), naive, "and that column is the one the step is landing on");
// Which is what the reader now says out loud, for the stood ledger.
assert_eq!(step_destination(&mut wram, &grid), Some((2, 4)));
// Standing still there is no step to name.
let (mut still, _, _) = town();
let standing = map_grid(&mut still).expect("a decodable map");
assert_eq!(step_destination(&mut still, &standing), None);
}
#[test]
fn a_decode_the_screen_disagrees_with_is_refused_mid_step_too() {
// The check has to keep refusing a decode that is simply wrong, and the anchor search is what
// could have weakened it: five anchors instead of one. A wrong stride, a wrong quadrant or a
// half-loaded map agrees with none of them, because the whole neighbourhood has to agree under
// one anchor rather than each tile finding an anchor of its own.
let (mut wram, blocks, blockset) = town();
wram.mid_step(-1, 0, &blocks, &blockset);
wram.map_tile(3, 4, 0x77);
assert_eq!(map_grid(&mut wram), Err(GridRefusal::ScreenDisagrees));
}
#[test] #[test]
fn a_decode_the_screen_disagrees_with_is_refused() { fn a_decode_the_screen_disagrees_with_is_refused() {
let (mut wram, _, _) = town(); let (mut wram, _, _) = town();

View file

@ -313,6 +313,27 @@ fn pad(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) {
ways.iter().map(|exit| exit.id).collect::<Vec<_>>() ways.iter().map(|exit| exit.id).collect::<Vec<_>>()
); );
} }
// Section 13's two errands, which is what row 54's `GO HEAL` cycle turns on: which building
// the errand names, whether the ledgers have paid it, and what the walk would aim at. An aim
// with no press settles where it stands, so an aim on the fly's own tile is a macro that
// completes without moving.
println!("\n### The errands\n");
println!("- `area_here` = {:?}", palette::area_here(state));
for kind in [geography::Amenity::Mart, geography::Amenity::Center] {
let at = geography::amenity_of(palette::area_here(state).unwrap_or(0), kind);
let paid = at.is_some_and(|map| state.map_visited(map));
println!(
"- {kind:?}: building {:?} (map_visited {paid}), `errand` = {:?}, `amenity_goals` = {:?}",
at,
palette::errand(state, kind),
palette::amenity_goals(state, kind),
);
}
println!("- `counter_pending` = {}", palette::counter_pending(state));
println!("- `heal_goals` = {:?}", palette::heal_goals(state));
println!("- `errand_place` = {:?}", palette::errand_place(state));
println!("- `stranded` = {}", palette::stranded(state));
println!();
println!("- `objective_goals` = {:?}", palette::objective_goals(state)); println!("- `objective_goals` = {:?}", palette::objective_goals(state));
println!("- `objective_targets` = {:?}", palette::objective_targets(state)); println!("- `objective_targets` = {:?}", palette::objective_targets(state));
println!("- `untalked_people` = {:?}", palette::untalked_people(state)); println!("- `untalked_people` = {:?}", palette::untalked_people(state));
@ -482,6 +503,113 @@ fn nurse_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
println!("```"); println!("```");
} }
/// The survey the whole-map grid's mid-step refusal turns on (`infra/docs/macros-traps.md` row 54).
///
/// Section 15 checks the decode against the screen buffer over the fly's own tile and its four
/// neighbours, and the residual of 2026-09-22 measured that check refusing on **every frame the
/// fly is mid-step**: standing still Pewter City decoded on 118 of 120 frames, and the one frame
/// the survey caught disagreed by exactly one tile row in the direction of travel. A walk planned
/// on such a frame is planned over the ten-by-nine window, which is the oscillation of row 23.
///
/// Two things have to be measured before that can be fixed honestly, and neither can be argued
/// from the disassembly alone:
///
/// 1. **when `wXCoord` / `wYCoord` change** — at the start of a step or at the end of it. That
/// decides whether the screen is behind the coordinates or the coordinates ahead of the screen.
/// 2. **which byte says "a step is in progress"**. `docs/design/macros-wram.md` says the reviewed
/// symbol list carries none, so every plausible candidate is dumped across a whole step and the
/// one that tracks it is the reading.
///
/// It holds one direction from the checkpoint and prints a line per frame: the coordinates, the
/// grid's verdict, the tiles the cross-check disagreed on, and the candidates.
fn step_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) {
use flybrain_gb::pokemon_red::macros::state::Walkable;
let frames = env_usize("FLY_PROBE_STEP_FRAMES", 96);
let step = |gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64, mask: u8| {
gb.set_buttons(mask);
gb.run_frame().expect("a frame should complete");
*ms += MS_PER_FRAME;
adapter.sample(gb, *ms);
};
// Somewhere the fly is its own master and the map is on screen, so that a refusal below is
// about the step and not about a text box.
for _ in 0..600 {
if scene::detect(gb) == scene::Scene::Overworld && state::controllable(gb) {
break;
}
step(gb, adapter, ms, flybrain_gb::buttons::B);
}
let Some(here) = state::player(gb) else {
println!("\nNo player at the checkpoint, so there is no step to survey.");
return;
};
println!("\n## The mid-step survey, on map {:#04x} from ({}, {})\n", here.map, here.x, here.y);
// A direction with walkable ground on the other side of it, so the hold is a step rather than
// a turn into a wall.
let facings = [
(flybrain_gb::buttons::DOWN, 0i16, 1i16, "DOWN"),
(flybrain_gb::buttons::UP, 0, -1, "UP"),
(flybrain_gb::buttons::LEFT, -1, 0, "LEFT"),
(flybrain_gb::buttons::RIGHT, 1, 0, "RIGHT"),
];
let mut chosen = None;
for (mask, dx, dy, name) in facings {
let (Ok(x), Ok(y)) =
(u8::try_from(i16::from(here.x) + dx), u8::try_from(i16::from(here.y) + dy))
else {
continue;
};
if state::walkable(gb, x, y) == Walkable::Yes {
chosen = Some((mask, name));
break;
}
}
let Some((mask, name)) = chosen else {
println!("Every neighbour of the fly is a wall, so there is no step to survey.");
return;
};
println!("Holding {name} for {frames} frames.\n");
println!("```");
println!(
"frame coords grid s1+1,+3,+5,+7,+8,+9 scy scx cfc5 d730 d736"
);
for frame in 0..frames {
let sprite: Vec<String> = [1u16, 3, 5, 7, 8, 9]
.into_iter()
.map(|offset| format!("{:02x}", gb.read8(ram::wSpriteStateData1 + offset)))
.collect();
let scy = gb.read8(0xff42);
let scx = gb.read8(0xff43);
let cfc5 = gb.read8(0xcfc5);
let d730 = gb.read8(ram::wStatusFlags5);
let d736 = gb.read8(ram::wMovementFlags);
let verdict = match state::map_grid(gb) {
Ok(_) => "ok".to_string(),
Err(refusal) => {
let shown: Vec<String> = state::grid_disagreement(gb)
.into_iter()
.filter(|(_, _, decoded, screen)| decoded != screen)
.map(|(x, y, decoded, screen)| format!("({x},{y}){decoded:?}/{screen:?}"))
.collect();
format!("{} {}", refusal.label(), shown.join(" "))
}
};
let coords = state::player(gb)
.map(|player| format!("({:>2},{:>2})", player.x, player.y))
.unwrap_or_else(|| " none ".to_string());
println!(
"{frame:>5} {coords} {verdict:<30} {} {scy:>3} {scx:>3} {cfc5:02x} {d730:02x} {d736:02x}",
sprite.join(",")
);
step(gb, adapter, ms, mask);
}
println!("```");
}
fn main() { fn main() {
let Some(path) = std::env::var_os("FLY_ROM") else { let Some(path) = std::env::var_os("FLY_ROM") else {
println!("FLY_ROM is not set, so there is nothing to probe."); println!("FLY_ROM is not set, so there is nothing to probe.");
@ -527,6 +655,13 @@ fn main() {
return; return;
} }
// Row 54's mid-step survey: hold one direction and watch the grid's cross-check, the
// coordinates and every candidate for "a step is in progress" across a whole step.
if std::env::var("FLY_PROBE_CATCH").is_ok_and(|value| value == "step") {
step_survey(&mut gb, &mut adapter, &mut ms);
return;
}
let budget = env_usize("FLY_PROBE_FRAMES", 200_000); let budget = env_usize("FLY_PROBE_FRAMES", 200_000);
let stuck_after = env_usize("FLY_PROBE_STUCK", 600); let stuck_after = env_usize("FLY_PROBE_STUCK", 600);
let mut next_burst = ms; let mut next_burst = ms;

View file

@ -30,6 +30,7 @@ use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use unicode_normalization::UnicodeNormalization; use unicode_normalization::UnicodeNormalization;
use crate::ratelimit::RateLimiter; use crate::ratelimit::RateLimiter;
@ -426,6 +427,41 @@ impl ChatLimiter {
// -- the ring --------------------------------------------------------------------------------- // -- the ring ---------------------------------------------------------------------------------
/// The ring's sidecar file, inside `[paths] hot_dir` (`docs/control-api.md`, `[chat]`).
///
/// The ring is session state, not simulation state, so it deliberately does **not** travel in the
/// `FLYSIM01` checkpoint envelope and is not in the compatibility string: a build that refuses
/// every checkpoint in a directory still reads this file, and a checkpoint written by any build
/// is byte-for-byte what it always was. It sits beside the hot checkpoints because it has their
/// lifetime — the tmpfs a reboot clears — and because the deliberate reset already clears that
/// directory (`infra/05-deploy.sh`, `FLY_RESET_STATE=1`).
///
/// Sharing that directory with the hot checkpoints means sharing its mtime, which the watchdog
/// reads as flysim's liveness (`infra/bin/fly-watchdog`, check 1: hot-state mtime younger than
/// 30 s). That is safe here only because this file is written from the sim thread, on the same
/// command path as the line itself: a wedged loop accepts no chat, so it can never refresh the
/// directory behind the watchdog's back. Nothing else may ever write here from another thread.
pub const SIDECAR_FILE: &str = "chat-ring.json";
/// Lines older than this are dropped when the sidecar is read: a panel coming back after a long
/// outage should be empty rather than show a day-old conversation as if it were live.
pub const SIDECAR_MAX_AGE_MS: u64 = 24 * 60 * 60 * 1_000;
/// The sidecar's own format version. Nothing else versions with it, which is the point.
const SIDECAR_VERSION: u32 = 1;
/// `<hot_dir>/chat-ring.json`.
pub fn sidecar_path(hot_dir: &Path) -> PathBuf {
hot_dir.join(SIDECAR_FILE)
}
/// What the sidecar holds: a version and the ring, oldest first.
#[derive(Debug, Serialize, Deserialize)]
struct Sidecar {
version: u32,
lines: Vec<ChatLine>,
}
/// The last `capacity` accepted lines, oldest first, as every snapshot header carries them. /// The last `capacity` accepted lines, oldest first, as every snapshot header carries them.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ChatRing { pub struct ChatRing {
@ -461,6 +497,78 @@ impl ChatRing {
pub fn capacity(&self) -> usize { pub fn capacity(&self) -> usize {
self.capacity self.capacity
} }
/// Write the ring to `<hot_dir>/chat-ring.json`: tmp file, fsync, rename over, directory
/// fsync — the same atomic sequence a checkpoint commit uses, so a reader never sees a
/// half-written ring and a crash mid-write leaves the previous one.
///
/// Called on every accepted line, which the admission limits cap at five a second, onto
/// tmpfs.
pub fn save_sidecar(&self, hot_dir: &Path) -> anyhow::Result<()> {
let sidecar = Sidecar { version: SIDECAR_VERSION, lines: self.lines() };
let bytes = serde_json::to_vec(&sidecar)?;
crate::store::write_atomic(&sidecar_path(hot_dir), &bytes)
}
/// Read `<hot_dir>/chat-ring.json` into the ring, and answer how many lines it restored.
///
/// Absent is silence and zero lines — the first run on a fresh box. Unreadable, unparseable
/// or a version this build does not know is zero lines and a logged warning: an empty panel
/// is exactly what a restart gives today, so nothing on this path may ever be fatal. Lines
/// older than [`SIDECAR_MAX_AGE_MS`] are dropped, and only the newest `capacity` survive,
/// whatever the file holds.
pub fn load_sidecar(&mut self, hot_dir: &Path, now_ms: u64) -> usize {
let path = sidecar_path(hot_dir);
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(error) => {
if error.kind() != std::io::ErrorKind::NotFound {
tracing::warn!(
%error,
path = %path.display(),
"could not read the chat ring sidecar; the panel starts empty"
);
}
return 0;
}
};
let sidecar: Sidecar = match serde_json::from_str(&text) {
Ok(sidecar) => sidecar,
Err(error) => {
tracing::warn!(
%error,
path = %path.display(),
"the chat ring sidecar is not readable; ignoring it"
);
return 0;
}
};
if sidecar.version != SIDECAR_VERSION {
tracing::warn!(
version = sidecar.version,
path = %path.display(),
"the chat ring sidecar is a version this build does not read; ignoring it"
);
return 0;
}
let before = sidecar.lines.len();
self.lines.clear();
for line in sidecar.lines {
if now_ms.saturating_sub(line.wall_ms) >= SIDECAR_MAX_AGE_MS {
continue;
}
self.push(line);
}
let restored = self.lines.len();
if restored < before {
tracing::info!(
dropped = before - restored,
"dropped chat lines older than a day from the sidecar"
);
}
restored
}
} }
#[cfg(test)] #[cfg(test)]
@ -611,6 +719,106 @@ mod tests {
assert_eq!(ChatRing::new(999).capacity(), RING_MAX); assert_eq!(ChatRing::new(999).capacity(), RING_MAX);
} }
/// A line `wall_ms` milliseconds into the wall clock, for the sidecar tests.
fn line(id: u64, wall_ms: u64) -> ChatLine {
ChatLine {
id,
wall_ms,
by: format!("viewer_{id}"),
text: format!("line {id}"),
bot: None,
}
}
#[test]
fn the_ring_round_trips_through_its_sidecar() {
let dir = tempfile::tempdir().unwrap();
let now_ms = 1_757_000_000_000;
// Nothing written yet: a fresh box is an empty ring and no complaint.
let mut cold = ChatRing::new(12);
assert_eq!(cold.load_sidecar(dir.path(), now_ms), 0);
assert!(cold.is_empty());
let mut ring = ChatRing::new(12);
ring.push(line(1, now_ms - 3_000));
ring.push(line(2, now_ms - 2_000));
ring.push(ChatLine { bot: Some(true), ..line(3, now_ms - 1_000) });
ring.save_sidecar(dir.path()).unwrap();
// Beside the hot checkpoints, under the documented name, and nothing else is written.
assert!(sidecar_path(dir.path()).is_file());
let written: Vec<String> = std::fs::read_dir(dir.path())
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().to_string())
.collect();
assert_eq!(written, [SIDECAR_FILE]);
let mut restored = ChatRing::new(12);
assert_eq!(restored.load_sidecar(dir.path(), now_ms), 3);
assert_eq!(restored.lines(), ring.lines(), "oldest first, bot flag and all");
// A smaller ring than the file keeps the newest lines, not the first three it reads.
let mut small = ChatRing::new(2);
assert_eq!(small.load_sidecar(dir.path(), now_ms), 2);
assert_eq!(
small.lines().iter().map(|line| line.id).collect::<Vec<_>>(),
[2, 3]
);
}
#[test]
fn a_sidecar_that_will_not_parse_is_ignored_rather_than_fatal() {
let dir = tempfile::tempdir().unwrap();
let now_ms = 1_757_000_000_000;
for content in [
"",
"{ not json at all",
r#"{"version":1,"lines":[{"id":"not a number"}]}"#,
r#"{"version":1}"#,
// A format from some future build: readable JSON, unreadable meaning.
r#"{"version":99,"lines":[{"id":1,"wallMs":1757000000000,"by":"a","text":"b"}]}"#,
] {
std::fs::write(sidecar_path(dir.path()), content).unwrap();
let mut ring = ChatRing::new(12);
assert_eq!(ring.load_sidecar(dir.path(), now_ms), 0, "{content}");
assert!(ring.is_empty(), "{content}");
}
// And the next accepted line simply writes a good one over it.
let mut ring = ChatRing::new(12);
ring.push(line(7, now_ms));
ring.save_sidecar(dir.path()).unwrap();
let mut back = ChatRing::new(12);
assert_eq!(back.load_sidecar(dir.path(), now_ms), 1);
}
#[test]
fn sidecar_lines_older_than_a_day_are_dropped_on_load() {
let dir = tempfile::tempdir().unwrap();
let now_ms = 1_757_000_000_000;
let mut ring = ChatRing::new(12);
ring.push(line(1, now_ms - SIDECAR_MAX_AGE_MS - 1));
ring.push(line(2, now_ms - SIDECAR_MAX_AGE_MS));
ring.push(line(3, now_ms - SIDECAR_MAX_AGE_MS + 1));
ring.push(line(4, now_ms - 1_000));
ring.save_sidecar(dir.path()).unwrap();
let mut restored = ChatRing::new(12);
assert_eq!(restored.load_sidecar(dir.path(), now_ms), 2, "24 h exactly is too old");
assert_eq!(
restored.lines().iter().map(|line| line.id).collect::<Vec<_>>(),
[3, 4]
);
// A day later still, the whole file is stale and the panel starts empty.
let mut later = ChatRing::new(12);
assert_eq!(later.load_sidecar(dir.path(), now_ms + SIDECAR_MAX_AGE_MS), 0);
assert!(later.is_empty());
}
#[test] #[test]
fn every_reason_has_a_stable_spelling_and_a_unique_index() { fn every_reason_has_a_stable_spelling_and_a_unique_index() {
let mut seen = std::collections::HashSet::new(); let mut seen = std::collections::HashSet::new();

View file

@ -619,6 +619,16 @@ impl Sim {
sim.next_generation = sim.durable.highest_generation().max(sim.hot.highest_generation()) + 1; sim.next_generation = sim.durable.highest_generation().max(sim.hot.highest_generation()) + 1;
sim.start_writer(); sim.start_writer();
sim.restore_or_warm_up()?; sim.restore_or_warm_up()?;
// The on-screen chat ring, from its sidecar beside the hot checkpoints, before the first
// publish (`docs/control-api.md`, `[chat]`). It is session state and not part of the
// checkpoint envelope, so it is restored whatever the checkpoints did — including on a
// fresh start, where the brain is new but the panel's last dozen lines are not stale.
if config.chat.enabled {
let restored = sim.chat_ring.load_sidecar(&config.paths.hot_dir, now_wall_ms());
if restored > 0 {
tracing::info!(lines = restored, "restored the on-screen chat ring");
}
}
// A dealt mode, seeded from the network as it now stands. No macro has a random // A dealt mode, seeded from the network as it now stands. No macro has a random
// component since `GO FRONTIER` replaced `WANDER` (`docs/design/macros.md` section 9), so // component since `GO FRONTIER` replaced `WANDER` (`docs/design/macros.md` section 9), so
// the seed changes nothing about a run today; taking it here rather than before the // the seed changes nothing about a run today; taking it here rather than before the
@ -1328,6 +1338,12 @@ impl Sim {
text, text,
bot: if bot { Some(true) } else { None }, bot: if bot { Some(true) } else { None },
}); });
// The ring survives a restart because it is written here, not because it is in a
// checkpoint: one atomic rename onto tmpfs per accepted line, and a failure is a warning
// rather than a refusal — the line is already on screen.
if let Err(error) = self.chat_ring.save_sidecar(&self.shared.config.paths.hot_dir) {
tracing::warn!(%error, "could not persist the chat ring; it will not survive a restart");
}
Metrics::incr(&self.shared.metrics.chat_accepted_total); Metrics::incr(&self.shared.metrics.chat_accepted_total);
Ok(event.id) Ok(event.id)
} }

View file

@ -633,6 +633,38 @@ async fn the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_kil
"only the first instance may warm up; the second must restore:\n{log}" "only the first instance may warm up; the second must restore:\n{log}"
); );
// The CHAT panel came back with the service. The ring is session state in a sidecar beside
// the hot checkpoints, never a chunk in the envelope: the durable store holds no copy of it,
// and the checkpoint this restore just read carries no chat text.
let resumed_chat = resumed
.chat
.as_ref()
.expect("chat is enabled, so the header carries a ring");
let restored_line = resumed_chat
.iter()
.find(|line| line.id == chat_event_id)
.unwrap_or_else(|| panic!("the chat ring did not survive the restart: {resumed_chat:?}"));
assert_eq!(restored_line.by, "integration_test");
assert_eq!(restored_line.text, "go LEFT!");
assert!(
resumed_chat.iter().any(|line| line.bot == Some(true)),
"the bot's line came back too: {resumed_chat:?}"
);
assert!(
dir.path().join("hot/chat-ring.json").is_file(),
"the sidecar lives beside the hot checkpoints"
);
assert!(
!dir.path().join("state/chat-ring.json").exists(),
"the durable store carries no chat"
);
let envelope =
std::fs::read(dir.path().join(format!("state/{generation}.checkpoint"))).unwrap();
assert!(
!envelope.windows(8).any(|window| window == b"go LEFT!"),
"chat text must never enter the checkpoint envelope"
);
// -- 6. SIGTERM writes a final checkpoint -------------------------------------------- // -- 6. SIGTERM writes a final checkpoint --------------------------------------------
let (_, before) = service.get("/status"); let (_, before) = service.get("/status");
let before = before["checkpoint"]["generation"].as_u64().unwrap(); let before = before["checkpoint"]["generation"].as_u64().unwrap();

View file

@ -67,6 +67,8 @@ const MUSEUM_2F: u32 = 0x35;
const PEWTER_CITY: u32 = 0x02; const PEWTER_CITY: u32 = 0x02;
const MUSEUM_1F: u32 = 0x34; const MUSEUM_1F: u32 = 0x34;
const PEWTER_GYM: u32 = 0x36; const PEWTER_GYM: u32 = 0x36;
const PEWTER_MART: u32 = 0x38;
const ROUTE_3: u32 = 0x0e;
/// The forest's *northern* gate, which is the first hop from the forest toward Pewter /// The forest's *northern* gate, which is the first hop from the forest toward Pewter
/// (`macros::geography`, and rung 10's own road). /// (`macros::geography`, and rung 10's own road).
const VIRIDIAN_FOREST_NORTH_GATE: u32 = 0x2f; const VIRIDIAN_FOREST_NORTH_GATE: u32 = 0x2f;
@ -295,6 +297,24 @@ struct Run {
talk_starts_by_map: std::collections::BTreeMap<u32, u32>, talk_starts_by_map: std::collections::BTreeMap<u32, u32>,
/// `GO FRONTIER` starts by map, for the museum (section 12.14). /// `GO FRONTIER` starts by map, for the museum (section 12.14).
frontier_by_map: std::collections::BTreeMap<u32, u32>, frontier_by_map: std::collections::BTreeMap<u32, u32>,
/// Where the macro that is running started, for the net-tiles measure below.
started_at: Option<(u32, u8, u8)>,
/// The longest chain of macros that **completed at a net of zero tiles**, and the names in it.
///
/// Section 12.2's rule, measured (`infra/docs/macros-traps.md` row 54): "a macro that
/// completes without moving because its precondition is already satisfied where the fly stands
/// is a trap". One is ordinary -- a `TALK`, a `NEXT`, a walk that ends where it began because
/// it arrived by turning -- and a *chain* of them is the loop: the after arm of the v0.4.5
/// hunt spent brain minutes 1.0 to 8.5 cycling `GO FRONTIER`, `GO HEAL` and `GO ROUTE` over
/// five tiles, `GO HEAL` 204 starts at a mean net of 0.0 and a mean reach of 0.0.
///
/// Counted over walks only, because the presses are supposed to stand still: a `YES` that
/// answers a box and a `MOVE 2` that picks a move both finish on the tile they started on and
/// neither is going anywhere.
net_zero_streak: u32,
worst_net_zero_streak: u32,
net_zero_chain: Vec<&'static str>,
worst_net_zero_chain: Vec<&'static str>,
} }
impl Run { impl Run {
@ -396,6 +416,11 @@ impl Run {
talk_on_pad_by_map: std::collections::BTreeSet::new(), talk_on_pad_by_map: std::collections::BTreeSet::new(),
talk_starts_by_map: std::collections::BTreeMap::new(), talk_starts_by_map: std::collections::BTreeMap::new(),
frontier_by_map: std::collections::BTreeMap::new(), frontier_by_map: std::collections::BTreeMap::new(),
started_at: None,
net_zero_streak: 0,
worst_net_zero_streak: 0,
net_zero_chain: Vec::new(),
worst_net_zero_chain: Vec::new(),
menu_alternation: 0, menu_alternation: 0,
last_start: None, last_start: None,
} }
@ -502,6 +527,11 @@ impl Run {
talk_on_pad_by_map: std::collections::BTreeSet::new(), talk_on_pad_by_map: std::collections::BTreeSet::new(),
talk_starts_by_map: std::collections::BTreeMap::new(), talk_starts_by_map: std::collections::BTreeMap::new(),
frontier_by_map: std::collections::BTreeMap::new(), frontier_by_map: std::collections::BTreeMap::new(),
started_at: None,
net_zero_streak: 0,
worst_net_zero_streak: 0,
net_zero_chain: Vec::new(),
worst_net_zero_chain: Vec::new(),
menu_alternation: 0, menu_alternation: 0,
last_start: None, last_start: None,
} }
@ -731,7 +761,7 @@ impl Run {
self.talk_on_pad = talk_bound; self.talk_on_pad = talk_bound;
let active = let active =
self.decoder.decode_bound(&rates(hot), self.ms, false, None, Some(&bound)); self.decoder.decode_bound(&rates(hot), self.ms, false, None, Some(&bound));
let (mask, started, blocked) = { let (mask, started, blocked, done) = {
let ledger = AdapterLedger(&self.adapter); let ledger = AdapterLedger(&self.adapter);
let decision = self.layer.decide(&active, 0, self.ms, &mut self.gb, &ledger); let decision = self.layer.decide(&active, 0, self.ms, &mut self.gb, &ledger);
let started: Vec<&'static str> = decision let started: Vec<&'static str> = decision
@ -748,8 +778,39 @@ impl Run {
}) })
.map(|event| event.name) .map(|event| event.name)
.collect(); .collect();
(decision.mask, started, blocked) let done: Vec<&'static str> = decision
.events
.iter()
.filter(|event| {
event.outcome.is_some_and(|outcome| outcome.as_str() == "done")
})
.map(|event| event.name)
.collect();
(decision.mask, started, blocked, done)
}; };
// Row 54's own measure, taken before the starts below so that a macro that finishes and
// another that starts on the same frame are not confused for one another.
for name in done {
let walk = name.starts_with("GO ");
let net = match self.started_at {
Some((map, x, y)) if map == self.map() => {
u32::from(x.abs_diff(self.tile().0)) + u32::from(y.abs_diff(self.tile().1))
}
// Another map is the biggest move a macro can make.
_ => u32::MAX,
};
if walk && net == 0 {
self.net_zero_streak += 1;
self.net_zero_chain.push(name);
if self.net_zero_streak > self.worst_net_zero_streak {
self.worst_net_zero_streak = self.net_zero_streak;
self.worst_net_zero_chain = self.net_zero_chain.clone();
}
} else if walk {
self.net_zero_streak = 0;
self.net_zero_chain.clear();
}
}
for name in blocked { for name in blocked {
*self.blocked.entry(name).or_insert(0) += 1; *self.blocked.entry(name).or_insert(0) += 1;
let sub = self.battle_sub_state(); let sub = self.battle_sub_state();
@ -818,6 +879,8 @@ impl Run {
let map = self.map(); let map = self.map();
*self.talk_starts_by_map.entry(map).or_insert(0) += 1; *self.talk_starts_by_map.entry(map).or_insert(0) += 1;
} }
let (x, y) = self.tile();
self.started_at = Some((self.map(), x, y));
} }
self.gb.set_buttons(mask as u8); self.gb.set_buttons(mask as u8);
self.gb.run_frame().expect("a frame should complete"); self.gb.run_frame().expect("a frame should complete");
@ -900,6 +963,14 @@ impl Run {
} }
} }
/// The tile the fly is standing on, as the macro layer reads it.
fn tile(&mut self) -> (u8, u8) {
(
self.gb.read_wram(flybrain_gb::pokemon_red::symbols::ram::wXCoord),
self.gb.read_wram(flybrain_gb::pokemon_red::symbols::ram::wYCoord),
)
}
/// Drive until `done`, or panic with where it got to. /// Drive until `done`, or panic with where it got to.
fn drive_until(&mut self, what: &str, budget: u32, mut done: impl FnMut(&mut Self) -> bool) { fn drive_until(&mut self, what: &str, budget: u32, mut done: impl FnMut(&mut Self) -> bool) {
for frame in 0..budget { for frame in 0..budget {
@ -2254,6 +2325,88 @@ fn pewter_checkpoint() -> Option<flysim::store::Checkpoint> {
}) })
} }
/// The rung-11 Pewter checkpoint, or `None` to skip.
fn rank_eleven_checkpoint() -> Option<flysim::store::Checkpoint> {
std::env::var_os("FLY_RANK11_CHECKPOINT").map(|path| {
flysim::store::load(std::path::Path::new(&path))
.expect("the checkpoint should be a FLYSIM01 envelope")
})
}
/// From the rung-11 Pewter checkpoint: the fly leaves the town it has finished with.
///
/// **What was live** (2026-09-22 15:52 UTC, the badge won, rank 11 with `MT. MOON` next): the
/// overworld pad had shrunk to `GO ROUTE` alone and `GO ROUTE` completed every ~420 ms at a net of
/// zero tiles, start and done back to back for minutes, with an occasional `GO FRONTIER` and a
/// `GO OUT` bounce of 180 ms. The trap hunt from this checkpoint on `main` is row 54 verbatim:
/// **`GO FRONTIER` 122, `GO HEAL` 129, `GO ROUTE` 126, every one of them `done` at a mean net of
/// 0.0 tiles**, over **14 distinct tiles** in six brain minutes, 17 of 17 windows flagged, and the
/// repeated sequence printed as `GO ROUTE, GO FRONTIER, GO HEAL` x34 to x42.
///
/// The mechanism is row 54's, one town further on: a restore clears the errand ledger, so Pewter's
/// mart and centre are outstanding again although the run has been inside both; `objective_place`
/// puts the errand ahead of the rung, so `GO ROUTE`'s second tier aimed at the mart's door; and the
/// errand's own aim at a door the fly was standing on settled where it stood. The badge was already
/// won, so the objective was two maps away and none of it moved the fly.
///
/// The claims: the fly **leaves Pewter City** for Route 3, no chain of walks completes at a net of
/// zero tiles more than three times in a row, and neither errand is offered in a town the run has
/// already shopped and healed in.
#[test]
fn the_fly_leaves_pewter_from_the_rung_eleven_checkpoint() {
let rom = skip_without_rom!();
let Some(checkpoint) = rank_eleven_checkpoint() else {
eprintln!("skipped: no FLY_RANK11_CHECKPOINT");
return;
};
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
assert_eq!(run.map(), PEWTER_CITY, "the checkpoint is the town the stream stalled in");
let mut left = None;
for frame in 0..200_000u32 {
run.frame();
if left.is_none() && run.map() == ROUTE_3 {
left = Some(frame);
}
}
eprintln!(
"from Pewter in {:.1} brain minutes: route {:?}, macros {:?}, the longest chain of walks that completed at a net of zero tiles {} ({:?})",
run.ms / 60_000.0,
run.route,
run.started,
run.worst_net_zero_streak,
run.worst_net_zero_chain
);
assert!(
left.is_some(),
"the fly never left Pewter City (map {:#04x}, route {:?}, macros {:?})",
run.map(),
run.route,
run.started
);
assert!(
run.worst_net_zero_streak <= 3,
"{} walks in a row completed without moving the fly: {:?}",
run.worst_net_zero_streak,
run.worst_net_zero_chain
);
// Section 13's errand, paid by a building this run has already been inside: both of Pewter's
// are in the adapter's lifetime map ledger at this checkpoint, so neither button is dealt and
// the objective is the rung's own place two maps away.
let errands = run.started.get("GO HEAL").copied().unwrap_or(0)
+ run.started.get("GO SHOP").copied().unwrap_or(0);
assert!(
errands < 10,
"{errands} errand walks in a town the run has already shopped and healed in: {:?}",
run.started
);
assert!(
!run.route.contains(&PEWTER_MART) || run.route.iter().filter(|map| **map == PEWTER_MART).count() < 3,
"the fly walked in and out of the mart: {:?}",
run.route
);
}
/// From the rung-10 Pewter checkpoint: the fly gets out of the museum and into the gym. /// 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 /// **What was live** (2026-09-22, v0.4.5, rank 10 PEWTER CITY, five and a half hours on the
@ -2380,4 +2533,18 @@ fn the_fly_reaches_the_pewter_gym_from_the_rung_ten_checkpoint() {
"the road to the gym is out of the museum's front door: {:?}", "the road to the gym is out of the museum's front door: {:?}",
run.route run.route
); );
// Row 54: the cycle the last branch's after arm left behind. `GO FRONTIER`, `GO HEAL` and
// `GO ROUTE` each ended where they began, for seven and a half brain minutes over five tiles.
// Three in a row is the bound: a walk that arrives by turning, a walk cut short by a battle
// and a walk that finds its goal underfoot are each ordinary on their own.
eprintln!(
"the longest chain of walks that completed at a net of zero tiles: {} ({:?})",
run.worst_net_zero_streak, run.worst_net_zero_chain
);
assert!(
run.worst_net_zero_streak <= 3,
"{} walks in a row completed without moving the fly: {:?}",
run.worst_net_zero_streak,
run.worst_net_zero_chain
);
} }