Compare commits

...

22 commits

Author SHA1 Message Date
acamilo
537cdd8ad9 Merge fix/flybus-conformance-rows: the conformance rows name the tests that now prove them
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-22 18:29:12 +00:00
dev
2cc8d0294d docs: the two sweep flakes are fixed, and one assertion that could not fail
bus-conformance.md still listed the example_demo root count and
session_over_one_router as not fixed here. Both are fixed on main now, so
the rows say what each test does instead: the example waits for the
producer's hold release before reading the counts, with its printed
output unchanged, and the integration renderer is held until the
publisher's twentieth receipt has returned, so its coalescing is forced
and the assertions are order, freshness, acceptance under a stalled
spectator and the replacement accounting, with the before and after
counts.

The delivery check in that test compared (step, sequence) against
(step, step), whose first element can never fail. It compares sequence
against step.
2026-09-22 18:28:54 +00:00
acamilo
882980823c Merge fix/flybus-coalescing-flake: the flybus suite asserts guarantees, not the machine's timing 2026-09-22 18:23:06 +00:00
acamilo
e76b3d1063 docs: v0.4.7 status 2026-09-22 17:46:03 +00:00
acamilo
f223702085 Merge fix/chat-ring-persist: the on-screen chat ring survives a sim restart via a hot-dir sidecar 2026-09-22 17:45:59 +00:00
acamilo
629aff8537 Merge fix/loop-row54: the walk anchor is measured from the screen, landing tiles retire, errands arrive inside, the errand ledger survives a restore 2026-09-22 17:45:59 +00:00
acamilo
ad632621c1 macros: say when the step reading narrows to None 2026-09-22 17:44:53 +00:00
acamilo
95d3d56d3f docs: the rung-eleven ROM run, measured 2026-09-22 17:24:27 +00:00
acamilo
bffbaca394 docs: row 54, the mid-step survey and both arms of the hunt
macros.md gains section 12.17; macros-wram.md section 9 gains the frame-by-frame trace of a step,
the three readings that come out of it and the one byte that tracks a step exactly and is recorded
unused; macros-traps.md closes rows 54 and 54b, strikes the two residuals they worked, and prints
both checkpoints' before and after whole -- including the arm where the ethos check's "fewer
flagged windows, more distinct tiles" does not hold, and why.
2026-09-22 17:19:25 +00:00
acamilo
d637b8f4c9 macros: a second ROM run, from the checkpoint the stream fell into after the badge
Forty minutes after the Boulder Badge the overworld pad had shrunk to `GO ROUTE` alone and it was
completing every 420 ms at a net of zero tiles. The trap hunt from that checkpoint on main is row
54 verbatim, one town further 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.

The run claims the fly leaves Pewter City for Route 3, that no chain of walks completes at a net of
zero tiles more than three times in a row, and that neither errand is offered in a town the run has
already shopped and healed in.
2026-09-22 17:19:25 +00:00
acamilo
c42255e199 macros: an edge the map graph cannot name stops being somewhere new once it is stood on
The rung-11 reading of row 54. Route 3's connections read north and west off the cartridge
(`wCurMapConnections`, and it has no warps at all) while the geography table carries west and east,
so the seven walkable tiles of its north edge had an unnameable destination -- and an unnameable
destination counted as unvisited, which made them first-tier for `GO ROUTE` on every hold for ever,
with `GO OBJECTIVE` off the pad beside them because nothing on that map leads to the objective.

A warp's destination is a byte the cartridge publishes, so `None` there is the `LAST_MAP` case
already handled. An edge's comes only from `geography::connected`, so `None` there means the table
cannot name the map on the other side and never will, and the only record left is the adapter's own
boundary ledger. It narrows: a genuinely new edge is still first-tier until the fly reaches it.

Which map is north of Route 3 is not guessed at here. That is a survey -- walk the fly off the edge
with real presses and read the map id back -- and it is recorded as a residual instead.
2026-09-22 17:19:25 +00:00
dev
9bed780cb9 flybus: wait for the pending-connection events instead of timing them
pending_connections_are_bounded_and_hello_expires gave the router 20 ms
to register a connection and then slept 80 ms past a 40 ms hello timeout
before reading the count once. Both are claims about how fast this box
is, not about the router: the first failed once in 20 whole-crate runs
beside four busy loops. Both now wait for the event they are about, under
the suite's ordinary ten-second bound, so the test still fails if a
pending connection never registers or a pending Hello never expires.
2026-09-22 17:12:46 +00:00
dev
2af1189ac3 Merge branch 'main' into fix/flybus-coalescing-flake 2026-09-22 17:05:29 +00:00
dev
f2dc5d434a flybus: assert the latest-mode guarantee, not the machine's timing
session_over_one_router asserted that a latest subscriber must drop
snapshots. bus-v1 section 7 says a latest subscription replaces an
undelivered value; it lets a consumer miss values, it does not oblige it
to. Under contention the publisher was slow enough that the renderer kept
up, saw all twenty snapshots and the test failed on conforming behaviour:
16 of 40 runs beside four busy loops, and 9 of 20 whole-crate runs.

The renderer is now held until the publisher's completion is observed
rather than until a timer expires, so the coalescing is forced instead of
raced for: the subscription keeps the one delivery in flight and one
replaceable queued value, and the renderer receives snapshots 1 and 20 of
20. The assertions are the guarantees that hold -- what arrives is in
publication order, the last value is the latest published, the stalled
spectator never refuses a publication or drops out of the fan-out, and
every snapshot the renderer missed is counted as a replacement to the
publisher at admission and to the renderer on delivery, so nothing is
lost silently.

Three more tests in the crate asserted the same kind of race:

- latest_replay_is_ordered_ahead_of_a_racing_publish demanded the
  non-coalesced outcome of a race section 7 allows either way ("bounded
  mode preserves that order, while latest mode may coalesce it"). It now
  puts one racing publication to a bounded and a latest subscription at
  once: bounded must deliver the replay and then the publication, and the
  latest branch is chosen by that publication's own replaced count.

- collection_waits_for_every_retained_owner, and the two disconnect
  cleanup tests beside it, read the store directory for the unlink that
  follows the registry update outside the router lock. They use
  settle_files, like every other unlink check in the suite.

- The demo example printed a root count taken before the producer's own
  release had reached the router, so the line the guide quotes was a
  race. It waits for the release, the same way it already waits for
  collection; the printed output is unchanged.

Nothing under flybus/src is touched: no routing defect was found. The
conformance rows for the credit/queue split, the replaced count, the
replay ordering and the latest spectator that cannot refuse a publication
now cite what each rewritten test actually varies.
2026-09-22 17:05:25 +00:00
acamilo
533cf82ac8 infra(05-deploy): FLY_RESET_STATE=1 clears the chat ring too
The deliberate reset already clears the tmpfs hot ring; the on-screen chat
ring's sidecar lives there now, so it goes with it.
2026-09-22 16:26:29 +00:00
acamilo
403038aa42 docs(control-api): the chat ring's sidecar, in the [chat] section
Dated 2026-09-22: where the file lives, what is dropped on load, what an
unreadable one does, that it is not in the checkpoint envelope or the
compatibility string, that FLY_RESET_STATE=1 clears it, and that the bridge
resends nothing on reconnect.
2026-09-22 16:26:29 +00:00
acamilo
01088d9479 feat(flysim): the on-screen chat ring survives a restart
Six deploys in a day left the CHAT panel empty every time, because the ring
was in-memory session state and nothing on disk held it.

Every accepted line now rewrites <hot_dir>/chat-ring.json through the same
atomic sequence a checkpoint commit uses (tmp, fsync, rename, directory
fsync), and Sim::boot reads it back before the first publish. Lines older
than 24 h are dropped on load, only the newest [chat] ring of them are kept,
and an absent, unreadable, unparseable or unknown-version file is an empty
panel and a logged warning rather than a startup failure -- which is exactly
what a restart gave before this existed.

The sidecar is deliberately not part of the checkpoint: no new chunk in the
FLYSIM01 envelope, nothing added to the compatibility string, so
--print-compatibility is byte-identical and a build that refuses every
checkpoint in a directory still brings the panel back. It sits beside the hot
checkpoints because it has their lifetime, and it is written from the sim
thread on the command path, so it cannot refresh that directory's mtime --
the watchdog's liveness signal -- while the loop is wedged.

Tests: the ring round-trips through the sidecar, a corrupt or future-version
file is ignored, lines older than a day are dropped, and the integration
test's SIGKILL restart now proves the panel comes back with the lines it had,
that the durable store holds no copy, and that no chat text is in the
envelope.
2026-09-22 16:26:28 +00:00
acamilo
8ef5dc321d macros: the Pewter run bounds the walks that complete without moving
Section 12.2's rule as a number on the cartridge: the longest chain of `GO ...` macros that
completed at a net of zero tiles. One is ordinary -- a walk that arrives by turning, a walk cut
short by a battle, a walk that finds its goal underfoot -- and a chain of them is the loop the
v0.4.5 after arm spent brain minutes 1.0 to 8.5 in. Three is the bound.
2026-09-22 14:57:34 +00:00
acamilo
611fe9e108 macros: an errand arrives inside the building, and a building already entered has none
Two rules for section 13's errands, both from row 54's `GO HEAL`: 204 starts at a mean net of 0.0
tiles and a mean reach of 0.0, cycling with `GO ROUTE` and `GO FRONTIER` over five tiles for seven
and a half brain minutes.

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 standing 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.
`exit_goals` has excluded a settled goal underfoot since row 13; this is the one walk that did not
have the rule.

And the errand ledger 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. `map_visited` is the adapter's lifetime answer to
the same question and it does survive, so both are asked and either pays the errand.
2026-09-22 14:57:34 +00:00
acamilo
895fb52cda macros: the tile a step is landing on is ground the run has covered
The other half of the same measurement. `wXCoord` and `wYCoord` are the tile the step began on
until the frame it ends, so the stood ledger recorded ground the fly had already left and the tile
under it stayed unstood: `path::frontier` kept offering it, `GO FRONTIER` was dealt aiming one tile
away, and `Arrival::Step` reported `done` the instant the step it did not make landed. A macro that
completes without changing anything, which is section 12.2's trap.

A step that has begun always finishes -- the cartridge owns the animation and no press stops it --
and the screen has already centred on the tile, so it is ground this run has covered. It clears the
map's frontier mark on the same rule the coordinates do: only ground never stood on before.
2026-09-22 14:57:34 +00:00
acamilo
31f35c6f2f macros: the whole-map grid is read from the tile the screen is centred on
Measured on the cartridge from the rung-10 checkpoint: `wXCoord` and `wYCoord` change at the *end*
of a sixteen-frame step while the background scrolls throughout it, so for fifteen frames of every
sixteen the screen buffer is centred one tile ahead of the coordinates. The cross-check compared
the decode of the fly's own tile with 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, so every walk the fly
actually took was re-planned over the ten-by-nine window, which is the oscillation of row 23.

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. A decode with a wrong stride, a wrong
quadrant or a half-loaded map agrees with none of the five, and neither does the mid-warp tear the
per-serve check was added for, so both refusals stand.

`state::step_destination` names the tile the step is landing on, for the ledger the next commit
writes. The town fixture gains two landmarks beside the fly, because a neighbourhood that is the
same tile id in every direction cannot tell one anchor from another.
2026-09-22 14:57:34 +00:00
acamilo
ed1d321b02 probes: a mid-step survey, and the errands on the pad dump
`FLY_PROBE_CATCH=step` holds one direction from a checkpoint and prints, per frame, the
coordinates, the grid's verdict, the tiles the cross-check disagreed on and every candidate for
"a step is in progress". It is what row 54 turns on: whether the coordinates change at the start
of a step or at its end.

The pad dump gains section 13's two errands -- which building each names, whether the ledgers have
paid it, and what the walk would aim at -- because an aim with no press settles where it stands.
2026-09-22 14:57:33 +00:00
24 changed files with 1308 additions and 107 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`,
`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`.
- **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,
`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
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
`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
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.
mid-step the two are a tile apart. 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".
*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
## priority to visit the shop at least once per area; make shop macros item purchases. same

View file

@ -153,15 +153,15 @@ and once over a Unix socket, so `tests/rpc.rs::request_reply_roundtrip` means
| `latest`: one queued value, replacing only an undelivered one; replacement releases that entry's roots; delivered or in-use messages are never reclaimed early; maxQueued is exactly 1 | conforms | `router/state.rs::{op_publish (latest branch), op_subscribe}` | `tests/pubsub.rs::latest_coalesces_only_undelivered_values` (both), `tests/conformance_artifacts.rs::latest_mode_holds_at_most_two_roots_delivered_plus_queued` (both) |
| `bounded`: FIFO, no coalescing or silent loss; when capacity is unavailable, reject with `BACKPRESSURE` before admitting any delivery | conforms | `router/state.rs::op_publish` (pre-checks every subscriber) | `tests/pubsub.rs::bounded_fifo_and_atomic_backpressure` (both), `tests/conformance_routing.rs::bounded_overflow_rolls_back_all_artifact_roots` (both) |
| maxInFlight credits return only on `delivery.consumed`, not on socket write completion | conforms | `router/state.rs::release_owner` (credit returned when the owner is released) | `tests/pubsub.rs::credits_return_only_on_consume` (both), `tests/conformance_routing.rs::bounded_credit_waits_for_every_extracted_artifact` (both) |
| A latest subscriber with all credits in use still has one replaceable queued value | conforms | `router/state.rs::{Sub::queue, dispatch_topic}` (queue and credits are separate) | `tests/bus_acceptance.rs::both_transports_produce_equivalent_behaviour_traces` (events 21 and 24: `publish replaced=1`, then `latest seq=3 replaced=1`) |
| A latest subscriber with all credits in use still has one replaceable queued value | conforms | `router/state.rs::{Sub::queue, dispatch_topic}` (queue and credits are separate) | `tests/bus_acceptance.rs::both_transports_produce_equivalent_behaviour_traces` (events 21 and 24: `publish replaced=1`, then `latest seq=3 replaced=1`), `tests/integration.rs::session_over_one_router` (both: a renderer held for the whole run keeps one delivery in flight and one replaceable value, and receives snapshots 1 and 20 of 20) |
| Atomic subscriber/retention snapshot at admission; validate and reserve every queue entry and owner budget before accepting | conforms: one mutex, validate-then-mutate | `router/state.rs::op_publish` | `tests/artifacts.rs::failed_admission_is_atomic` (both) |
| A bounded overflow rejects the whole publish: no partial fan-out, no retained-latest update | conforms | `router/state.rs::op_publish` | `tests/conformance_routing.rs::bounded_overflow_rolls_back_all_artifact_roots` (both) |
| On acceptance, one `topicSequence` and roots for every delivery and the optional retained value | conforms; a refused publication spends no sequence number | `router/state.rs::op_publish` (`t.sequence += 1` after the checks) | `tests/pubsub.rs::bounded_fifo_and_atomic_backpressure` (both) |
| Different topics have no total ordering; multiple publishers follow router acceptance order | conforms: per-topic sequence only | `router/state.rs::Topic::sequence` | `tests/pubsub.rs::retained_replay_clear_delete_and_incarnations` (both) |
| The publication reply counts accepted subscriptions and replaced queue entries, not consumers that processed data | conforms | `router/state.rs::op_publish` reply | `tests/pubsub.rs::latest_coalesces_only_undelivered_values` (both) |
| `replaced` on a delivery reports how many undelivered messages were coalesced since that subscription's preceding delivery | conforms | `router/state.rs::{Sub::replaced, dispatch_topic}` (taken at dispatch) | `tests/pubsub.rs::latest_coalesces_only_undelivered_values` (both) |
| `replaced` on a delivery reports how many undelivered messages were coalesced since that subscription's preceding delivery | conforms | `router/state.rs::{Sub::replaced, dispatch_topic}` (taken at dispatch) | `tests/pubsub.rs::latest_coalesces_only_undelivered_values` (both), `tests/integration.rs::session_over_one_router` (both: the 18 replacements the publisher was told about at admission are the same 18 the renderer is told about on delivery, and are exactly the snapshots it did not receive) |
| Optional `retained:latest` holds one last message and its artifacts independent of subscribers | conforms | `router/state.rs::op_publish` (retain branch) | `tests/conformance_artifacts.rs::retained_topic_value_holds_a_root_independent_of_subscribers` (both) |
| `replayLatest` enqueues the retained value before subsequent accepted publications; bounded preserves the order, latest may coalesce it | conforms | `router/state.rs::op_subscribe` (replay is enqueued under the subscribe lock) | `tests/conformance_routing.rs::latest_replay_is_ordered_ahead_of_a_racing_publish` (both), `tests/pubsub.rs::retained_replay_clear_delete_and_incarnations` (both) |
| `replayLatest` enqueues the retained value before subsequent accepted publications; bounded preserves the order, latest may coalesce it | conforms | `router/state.rs::op_subscribe` (replay is enqueued under the subscribe lock) | `tests/conformance_routing.rs::latest_replay_is_ordered_ahead_of_a_racing_publish` (both: one racing publication to a bounded and a latest subscription at once; bounded must deliver replay then publication, and the latest branch is chosen by that publication's own `replaced` count, never by which side of the race the dispatcher won), `tests/pubsub.rs::retained_replay_clear_delete_and_incarnations` (both) |
| Replay uses the original topicSequence, a fresh deliveryId and explicit roots | conforms | `router/state.rs::op_subscribe` (`add_roots`, the same `Arc<TopicMsg>`) | `tests/pubsub.rs::retained_replay_clear_delete_and_incarnations` (both) |
| Without retention, a zero-subscriber publication retains no ownership after admission | conforms | `router/state.rs::op_publish` | `tests/pubsub.rs::zero_subscriber_publish_retains_nothing` (both) |
| Clearing a topic releases only its retained root, not active consumers | conforms | `router/state.rs::op_clear` | `tests/conformance_routing.rs::cleared_topic_gives_no_replay_until_a_fresh_publish` (both) |
@ -260,7 +260,7 @@ and once over a Unix socket, so `tests/rpc.rs::request_reply_roundtrip` means
| The thirteen transport error codes exist with those names | conforms | `error.rs::ErrorCode` | `tests/wire.rs::body_errors_keep_the_connection` (both) |
| Three more codes: `CONFLICT`, `NO_TOPIC`, `ARTIFACT_MISMATCH` | deviates-allowed: "Transport errors **include** ..." is not an exhaustive list, and each names a refusal the draft requires but leaves unnamed. Recorded as an amendment in bus-v1 section 12 | `error.rs::ErrorCode` | `tests/pubsub.rs::subscription_and_topic_validation` (both), `tests/artifacts.rs::seal_checks_length_and_digest` (both) |
| Before admission report `not-dispatched`; once dispatch might have occurred report `dispatched` or `unknown` conservatively | conforms | `error.rs::BusError::new` (not-dispatched by default), `router/state.rs` dispatched notices, `client/reactor.rs::fail_all` (unknown) | `tests/rpc.rs::{cancellation_states, service_disconnect_fails_calls}` (both), `tests/sol_review_races.rs::writer_failure_terminates_reader_and_pending_work` |
| Bounded subscriptions can reject a publication; latest spectators cannot hold a session transaction indefinitely | conforms: a latest subscriber never causes `BACKPRESSURE` | `router/state.rs::op_publish` (the latest branch skips every capacity check) | `tests/bus_acceptance.rs::a_latest_subscriber_never_refuses_a_publication` (both: 100 publications of 60 KB into one unconsumed slot, six times the bounded pool, none refused, 98 coalesced), with `tests/pubsub.rs::{bounded_fifo_and_atomic_backpressure, saturated_subscriber_does_not_block_control}` (both) for the bounded half |
| Bounded subscriptions can reject a publication; latest spectators cannot hold a session transaction indefinitely | conforms: a latest subscriber never causes `BACKPRESSURE` | `router/state.rs::op_publish` (the latest branch skips every capacity check) | `tests/bus_acceptance.rs::a_latest_subscriber_never_refuses_a_publication` (both: 100 publications of 60 KB into one unconsumed slot, six times the bounded pool, none refused, 98 coalesced), with `tests/pubsub.rs::{bounded_fifo_and_atomic_backpressure, saturated_subscriber_does_not_block_control}` (both) for the bounded half, and `tests/integration.rs::session_over_one_router` (both: 20 snapshot publications accepted by both subscriptions while the presentation consumer reads nothing) |
| Sustained pinned-artifact quota exhaustion is surfaced as pressure, not solved by freeing live data | conforms: `QUOTA_EXCEEDED`, never eviction | `router/state.rs::{op_allocate, op_seal}` | `tests/artifacts.rs::quotas_are_enforced` (both) |
| Session and application policies choose disconnect, pause or fail; the router does not know which | conforms by absence | `router/state.rs` | `tests/pubsub.rs::saturated_subscriber_does_not_block_control` (both) |
@ -284,7 +284,7 @@ and once over a Unix socket, so `tests/rpc.rs::request_reply_roundtrip` means
| 3. Artifacts: allocate/seal/read; publication before seal fails; fan-out owns one object; the last consumer releases; a retained extracted frame survives a message drop | conforms | `tests/artifacts.rs` (28), `tests/conformance_artifacts.rs` (36) |
| 4. Faults: sender drops after admission, consumer dies mid-read, reply lost, queued frame replaced, subscription closes with in-use deliveries, router restarts, old release arrives; no double-free, use-after-reuse, unbounded tombstones or hidden replay | conforms | `tests/conformance_routing.rs::{caller_disconnect_detaches_dispatched_call_but_service_keeps_serving, subscriber_disconnect_releases_queued_and_delivered_artifacts_but_not_retention}`, `tests/artifacts.rs::{release_ids_are_watermarked, router_restart_invalidates_old_handles}`, `tests/bus_acceptance.rs::{a_lost_result_leaks_no_roots_and_the_endpoint_cache_still_replays, disconnect_releases_logical_ownership_without_mutating_open_bytes}`, `tests/sol_rereview_regressions.rs` (11) |
| 5. RPC cache: an endpoint retains an artifact-bearing result, the original caller consumes it, a domain retry still returns valid bytes, eviction drops the last hold | conforms | `tests/rpc.rs::endpoint_cache_replays_artifact_results` (both), `tests/bus_acceptance.rs::a_retransmission_repeats_the_domain_request_under_a_fresh_call_id` (both) |
| 6. Integration: two parallel fake agents, a complete-batch environment RPC, committed snapshot publication and a deliberately slow presentation consumer on one router | conforms | `tests/integration.rs::session_over_one_router` (both) |
| 6. Integration: two parallel fake agents, a complete-batch environment RPC, committed snapshot publication and a deliberately slow presentation consumer on one router | conforms; the consumer is slow by construction, held until the publisher's completion is observed, so its coalescing is forced rather than raced for | `tests/integration.rs::session_over_one_router` (both) |
| 7. Performance: 640x480x60 with three consumers, one delayed; p50/p95/p99 RPC latency, router CPU, copy and readback cost separately, RSS, store live and peak bytes, outstanding roots, collection lag, queue lengths, for one, two and four agents | conforms | `tests/perf.rs::frames_at_60hz_with_three_consumers` (`--ignored`); numbers below |
| The first executable example: a counter RPC, a pub/sub observer and a frame artifact held past message consumption, in one small Rust program, no game or browser | conforms | `examples/demo.rs` (`cargo run -p flybus --example demo`), asserted by `tests/example_demo.rs::the_example_shows_a_counter_rpc_an_observer_and_a_held_frame` |
@ -438,18 +438,30 @@ poll-until-the-router-settles the rest of the file already uses for router-side
no sleep, no timing constant, and the assertion now has the precondition its contract sentence
names. **360 runs after the fix, 0 failures** (240 debug, 120 release).
Two other intermittent failures were seen in the same sweep and are **not** fixed here, since
they belong to the bus slice rather than to this one:
Two other intermittent failures were seen in the same sweep. They belonged to the bus slice
rather than to this one and were fixed there, in the same way and for the same reason:
- `tests/example_demo.rs::the_example_shows_a_counter_rpc_an_observer_and_a_held_frame`,
2 failures in 40 standalone runs plus 1 in 12 full-suite runs. It prints
2 failures in 40 standalone runs plus 1 in 12 full-suite runs. It printed
"while the frame is held: 1 artifact(s), 2 root(s)" instead of 1 root: the producer's hold
release is queued on the control lane and had not been applied when the example read the
counts. The same shape of gap, in the guide deliverable's printed output.
- `tests/integration.rs::unix_socket::session_over_one_router`, 1 failure in 12 full-suite
runs and 0 in 40 standalone runs, at the assertion that the deliberately slow consumer
skipped snapshots. Under load it kept up, so the assertion is a timing claim about the
machine.
counts. `examples/demo.rs` now waits for that release before reading the counts, the same
bounded poll it already used for collection eight lines below, so the line the guide quotes
is an observation rather than a race. The printed output is unchanged.
- `tests/integration.rs::session_over_one_router`, 1 failure in 12 full-suite runs and 0 in 40
standalone runs, at the assertion that the deliberately slow consumer skipped snapshots.
Under load it kept up, so the assertion was a timing claim about the machine: section 7
permits a latest subscriber to miss values, it does not oblige it to. The renderer is now
held until the publisher's twentieth receipt has returned -- the publisher's completion
observed, not timed -- so the coalescing is forced by construction, and the test asserts the
guarantees that do hold: each delivery carries the frame of the snapshot it announces,
deliveries arrive in publication order, the last value received is the latest published, the
renderer receives snapshots 1 and 20 of 20, both subscriptions accept all twenty publications
while the spectator reads nothing, and the eighteen replacements reported to the publisher at
admission are the same eighteen reported to the renderer on delivery and are exactly the
snapshots it did not receive. 16 failures in 40 runs beside four busy loops before, 0 in 40
after; the whole crate went from 10 failed runs in 20 to 0, and the workspace suite from 2
in 5 to 0.
## Contradictions

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
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.
#
# FLY_RESET_STATE=1 is the deliberate override: it archives the durable
# checkpoints (kept, never deleted) and clears the tmpfs hot ring, so the
# new build warms up fresh. Everything learned so far is thrown away, which
# is why it is not the default.
# checkpoints (kept, never deleted) and clears the tmpfs hot ring — the hot
# checkpoints and the on-screen chat ring's sidecar — so the new build warms
# 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}"
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
# 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 "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
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}

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 |
| ---: | --- | --- | --- | --- |
| 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
- **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.
- ~~**The whole-map grid is refused while the fly is moving.**~~ **Worked, 2026-09-22 (row 54).**
The guess was the wrong way round: `FLY_PROBE_CATCH=step` in `examples/scene_probe.rs` found the
coordinates change at the **end** of a sixteen-frame step, so it is the screen that is one tile
ahead of `wYCoord` and not `wYCoord` ahead of the screen. The reader now measures which tile the
screen is centred on. `docs/design/macros-wram.md` section 9 has the frame-by-frame trace and the
candidates; `$cfc5` tracks a step exactly and is recorded there **unused**, because
`gen_symbols.py` refuses a hand-written address and the checkout `resolve_wram.py` reads is not
on this box.
- ~~**The town's errands are session state**~~, so every restart re-armed them. **Worked,
2026-09-22 (row 54):** the adapter's lifetime `map_visited` is asked beside the session ledger,
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
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

View file

@ -413,6 +413,64 @@ impl Wram {
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
/// out of the bedroom lands it, every tile a wall until a test opens one.
pub fn overworld() -> Self {

View file

@ -438,6 +438,20 @@ pub trait MacroState: GameState {
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.
///
/// `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 {
let (scene, bindings, standing, approach) = {
let (scene, bindings, standing, stepping, approach) = {
let Self {
machine,
mode,
@ -237,6 +237,9 @@ 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();
// 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
// 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.
@ -247,7 +250,7 @@ impl MacroPalette for PokemonPalette {
Some((objective.map, hops))
});
*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
// 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)) {
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
// 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
@ -454,7 +467,7 @@ mod tests {
use super::*;
use crate::adapter::{MapEdge, MapExit};
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::maps;
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]
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.

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 {
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.
@ -930,7 +940,21 @@ pub fn amenity_goals(state: &mut dyn MacroState, kind: Amenity) -> Vec<Aim> {
return counter_aims(state, counter_sprite(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(),
}
}
@ -1274,9 +1298,23 @@ fn exit_tiers(state: &mut dyn MacroState, way: Way) -> Vec<Exit> {
let known_visited = match exit.destination(here) {
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
// this run has stood on by construction. Every other unnameable destination stays a
// candidate.
None => exit.way == Way::Exit,
// this run has stood on by construction.
None if exit.way == Way::Exit => true,
// **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 {
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));
}
#[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]
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.

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)?;
// 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 {
// is agreeing with the cartridge's own reading of the same ground -- once the two readings are
// anchored on the same tile, which mid-step they are not ([`screen_anchor`]).
screen_anchor(memory, &grid, player.x, player.y)?;
Ok(grid)
}
/// The five tiles the screen buffer can be centred on, nearest first.
///
/// Standing still it is the fly's own tile; mid-step it is the tile the fly is stepping onto.
/// `(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);
}
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.
@ -1049,11 +1125,15 @@ fn still_the_loaded_map(
x: u8,
y: u8,
) -> 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 grid was checked when it was decoded.
None => true,
Some(tile) => grid.tile_id(x, y) == Some(tile),
Err(GridRefusal::NoScreen) => true,
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).
///
/// 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
/// `wTilesetCollisionPtr` points.
fn town() -> (Wram, Vec<u8>, Vec<[u8; 16]>) {
const FLOOR: u8 = 0x01;
let blockset = vec![[FLOOR; 16], [WALL_TILE; 16]];
let (wide, high) = (10usize, 9usize);
let mut blocks = vec![0u8; wide * high];
for row in 0..high {
blocks[row * wide + 5] = 1;
}
let mut wram = Wram::new();
wram.started()
.map(fake_wram::PALLET_TOWN, wide as u8, high as u8, 3, 4)
.facing(0)
.house_collision()
.tileset(0)
.blockset(&blockset)
.map_blocks(&blocks)
.fill_screen(WALL_TILE)
.screen_from_blocks(&blocks, &blockset);
(wram, blocks, blockset)
Wram::town()
}
#[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]
fn a_decode_the_screen_disagrees_with_is_refused() {
let (mut wram, _, _) = town();

View file

@ -99,8 +99,17 @@ pub async fn run() -> Result<Vec<String>, Box<dyn std::error::Error>> {
"published sequence {} to {} subscriber(s)",
receipt.topic_sequence, receipt.subscribers
));
// The producer lets go of its own hold; the delivery keeps the bytes alive.
// The producer lets go of its own hold; the delivery keeps the bytes alive. The release
// travels the control lane like any other operation, so the count below waits for it
// instead of reading a number that may still include it.
drop(frame);
let released = Instant::now() + Duration::from_secs(10);
while router.stats().artifact_roots > 1 {
if Instant::now() > released {
return Err("the producer's own hold was never released".into());
}
tokio::time::sleep(Duration::from_millis(1)).await;
}
let message = frames.next().await.ok_or("the subscription closed")?;
let image = message.artifact("frame")?;

View file

@ -179,7 +179,9 @@ async fn collection_waits_for_every_retained_owner(via: Via) {
s.owners == 0 && s.sealed_artifacts == 0 && s.store_bytes == 0
})
.await;
assert_eq!(e.files("sealed"), 0);
// The unlink follows the registry update, outside the router lock: wait for the file to
// go rather than assume the two happen together.
e.settle_files("sealed", 0).await;
}
// -------------------------------------------------------------------------------------------
@ -521,7 +523,7 @@ async fn disconnect_abandons_an_unsealed_writer(via: Via) {
s.artifacts == 0 && s.store_bytes == 0 && s.owners == 0
})
.await;
assert_eq!(e.files("staging"), 0);
e.settle_files("staging", 0).await;
}
/// An abrupt disconnect must release an explicit hold too, when it was the object's only root.
@ -538,7 +540,7 @@ async fn disconnect_releases_an_explicit_hold(via: Via) {
s.sealed_artifacts == 0 && s.owners == 0 && s.store_bytes == 0
})
.await;
assert_eq!(e.files("sealed"), 0);
e.settle_files("sealed", 0).await;
}
/// A vanished subscriber must give up both a delivery it already holds and one still queued

View file

@ -420,14 +420,20 @@ async fn bounded_overflow_rolls_back_all_artifact_roots(via: Via) {
}
/// bus-v1 section 7: "New subscriptions with replayLatest enqueue it before subsequent accepted
/// publications." A fresh `latest` subscription's replay claims its first in-flight credit
/// immediately (there is nothing else competing for it yet), so a publish accepted right after
/// subscribing must still be observed strictly after the replay, never ahead of or merged with
/// it: each keeps its own delivery.
/// publications ... bounded mode preserves that order, while latest mode may coalesce it before
/// delivery under the ordinary latest rule." The replay is enqueued under the subscribe lock, so
/// a publication admitted after `subscribe` returned is always behind it; what the two modes do
/// with that order is what differs, and one racing publication is put to both at once.
///
/// The bounded subscription must deliver both values, replay first. The latest subscription
/// either does the same or replaces the still-queued replay, and the router says which in the
/// racing publication's own `replaced` count rather than the test guessing from how fast the
/// dispatcher ran: what it may never do is reorder the two or lose the newer value.
async fn latest_replay_is_ordered_ahead_of_a_racing_publish(via: Via) {
let e = env(via).await;
let admin = e.client("admin").await;
let reader = e.client("reader").await;
let viewer = e.client("viewer").await;
admin
.declare_topic("t.replay-race", Retained::Latest)
.await
@ -436,26 +442,66 @@ async fn latest_replay_is_ordered_ahead_of_a_racing_publish(via: Via) {
.publish("t.replay-race", obj(json!({"v": "old"})), &[])
.await
.unwrap();
let mut sub = reader
let mut fifo = reader
.subscribe(
"t.replay-race",
SubscriptionConfig::bounded().in_flight(1).replay(true),
)
.await
.unwrap();
let mut coalescing = viewer
.subscribe(
"t.replay-race",
SubscriptionConfig::latest().in_flight(1).replay(true),
)
.await
.unwrap();
admin
let racing = admin
.publish("t.replay-race", obj(json!({"v": "new"})), &[])
.await
.unwrap();
let first = within("the replay arrives first", sub.next())
assert_eq!(
racing.subscribers, 2,
"one publication, admitted behind both replays"
);
let first = within("the replay arrives first", fifo.next())
.await
.unwrap();
assert_eq!(first.payload()["v"], "old");
drop(first); // the sole in-flight credit must return before the queued second value moves
let second = within("the racing publish follows, not coalesced away", sub.next())
let second = within("the racing publish follows, not coalesced away", fifo.next())
.await
.unwrap();
assert_eq!(second.payload()["v"], "new");
assert_eq!(
(second.payload()["v"].as_str(), second.replaced()),
(Some("new"), 0),
"bounded preserves the order and coalesces nothing"
);
let m = within("the latest subscription's first delivery", coalescing.next())
.await
.unwrap();
if racing.replaced == 1 {
assert_eq!(
(m.payload()["v"].as_str(), m.replaced()),
(Some("new"), 1),
"a replay still queued is replaced by the newer value, and the delivery says so"
);
drop(m);
quiet("nothing behind a coalesced replay", coalescing.next()).await;
} else {
assert_eq!(
(racing.replaced, m.payload()["v"].as_str(), m.replaced()),
(0, Some("old"), 0),
"a replay already in flight keeps its own delivery"
);
drop(m);
let after = within("the racing publish follows it", coalescing.next())
.await
.unwrap();
assert_eq!(after.payload()["v"], "new");
}
}
/// bus-v1 section 7: clearing releases only the retained root; a later `replayLatest`

View file

@ -1,6 +1,11 @@
//! bus-v1 section 11 item 6: two parallel fake agents, complete-batch environment RPC,
//! committed snapshot publication and a deliberately slow presentation consumer, all over one
//! router. Generic services only; nothing here knows what a brain or a game is.
//!
//! The presentation consumer is held until the publisher's own completion is observed, so the
//! latest subscription has to coalesce instead of happening to: section 7 lets a latest
//! subscriber miss values, it does not oblige it to, and a test that demands a miss it cannot
//! force is asserting how fast the machine is.
mod common;
@ -148,20 +153,31 @@ async fn session_over_one_router(via: Via) {
)
.await
.unwrap();
// A renderer that does not read a single snapshot until the publisher has finished every
// one of them. The hold ends on the publisher's observed completion, never on a timer.
let (release, held) = tokio::sync::oneshot::channel::<()>();
let presenting = tokio::spawn(async move {
held.await.unwrap();
let mut seen = Vec::new();
let mut coalesced = 0;
while let Some(m) = slow.next().await {
let (sequence, replaced) = (m.topic_sequence(), m.replaced());
let frame = m.artifact("frame").unwrap();
drop(m);
tokio::time::sleep(Duration::from_millis(25)).await; // a slow renderer
let bytes = frame.read_all().await.unwrap();
let step = bytes[0] as u64;
assert_eq!(
sequence, step,
"a delivery carries the frame of the snapshot it announces"
);
coalesced += replaced;
seen.push((step, frame.reference().artifact_id.clone()));
if step == STEPS {
break;
}
}
seen
(seen, coalesced)
});
let recorder = e.client("recorder").await;
let mut all = recorder
@ -179,6 +195,7 @@ async fn session_over_one_router(via: Via) {
seq
});
let mut replaced_at_admission = 0;
for step in 1..=STEPS {
let advanced = coordinator
.call_and_wait(
@ -226,8 +243,16 @@ async fn session_over_one_router(via: Via) {
)
.await
.unwrap();
assert_eq!(receipt.topic_sequence, step);
assert_eq!(
(receipt.topic_sequence, receipt.subscribers),
(step, 2),
"both subscriptions accept every publication: the stalled latest spectator neither \
refuses one nor drops out of the fan-out"
);
replaced_at_admission += receipt.replaced;
}
// The publisher is finished, observably, so the renderer may start.
release.send(()).unwrap();
let recorded = within("recorder", recording).await.unwrap();
assert_eq!(
@ -235,17 +260,30 @@ async fn session_over_one_router(via: Via) {
(1..=STEPS).map(|s| (s, s)).collect::<Vec<_>>(),
"the bounded recorder misses nothing"
);
let presented = within("presenter", presenting).await.unwrap();
assert_eq!(
presented.last().unwrap().0,
STEPS,
"the slow consumer ends on the latest snapshot"
);
let (presented, coalesced) = within("presenter", presenting).await.unwrap();
let steps: Vec<u64> = presented.iter().map(|(s, _)| *s).collect();
assert!(
presented.len() < STEPS as usize,
"the slow consumer skipped snapshots: {presented:?}"
steps.windows(2).all(|w| w[0] < w[1]),
"what a latest subscription does deliver arrives in publication order: {presented:?}"
);
assert_eq!(
steps.last().copied(),
Some(STEPS),
"the slow consumer ends on the latest snapshot: {presented:?}"
);
assert_eq!(
steps,
vec![1, STEPS],
"held for the whole run, the subscription keeps the one delivery already in flight and \
one replaceable queued value, so the renderer sees the first snapshot and the last, \
and the eighteen between them were coalesced: {presented:?}"
);
assert_eq!(
(coalesced, replaced_at_admission),
(STEPS - 2, STEPS - 2),
"every snapshot the renderer missed is counted as a replacement, to the publisher at \
admission and to the renderer on its next delivery: none is lost silently"
);
assert!(presented.windows(2).all(|w| w[0].0 < w[1].0));
for t in agents {
t.abort();

View file

@ -266,13 +266,14 @@ async fn pending_connections_are_bounded_and_hello_expires() {
let router = Router::new(config).unwrap();
let pending = router.connect_in_memory_as("first");
tokio::time::timeout(Duration::from_millis(20), async {
// Registration happens on the router's own task. How long that takes is this box's
// business; that it happens is the router's.
within("the pending connection occupies the only slot", async {
while router.stats().connections != 1 {
tokio::task::yield_now().await;
tokio::time::sleep(Duration::from_millis(1)).await;
}
})
.await
.unwrap();
.await;
assert_eq!(router.stats().connections, 1);
let refused = Client::connect(
router.connect_in_memory_as("second"),
@ -280,7 +281,14 @@ async fn pending_connections_are_bounded_and_hello_expires() {
)
.await;
assert_eq!(refused.unwrap_err().code, ErrorCode::RouterLost);
tokio::time::sleep(Duration::from_millis(80)).await;
// The 40 ms hello timeout expires on the router's clock: wait for the expiry to be
// observed rather than sleep past it and read the count once.
within("the pending Hello expires", async {
while router.stats().connections != 0 {
tokio::time::sleep(Duration::from_millis(1)).await;
}
})
.await;
assert_eq!(router.stats().connections, 0, "pending Hello timed out");
drop(pending);

View file

@ -313,6 +313,27 @@ fn pad(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) {
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_targets` = {:?}", palette::objective_targets(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!("```");
}
/// 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() {
let Some(path) = std::env::var_os("FLY_ROM") else {
println!("FLY_ROM is not set, so there is nothing to probe.");
@ -527,6 +655,13 @@ fn main() {
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 stuck_after = env_usize("FLY_PROBE_STUCK", 600);
let mut next_burst = ms;

View file

@ -30,6 +30,7 @@ use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use unicode_normalization::UnicodeNormalization;
use crate::ratelimit::RateLimiter;
@ -426,6 +427,41 @@ impl ChatLimiter {
// -- 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.
#[derive(Debug, Clone)]
pub struct ChatRing {
@ -461,6 +497,78 @@ impl ChatRing {
pub fn capacity(&self) -> usize {
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)]
@ -611,6 +719,106 @@ mod tests {
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]
fn every_reason_has_a_stable_spelling_and_a_unique_index() {
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.start_writer();
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
// 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
@ -1328,6 +1338,12 @@ impl Sim {
text,
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);
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}"
);
// 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 --------------------------------------------
let (_, before) = service.get("/status");
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 MUSEUM_1F: u32 = 0x34;
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
/// (`macros::geography`, and rung 10's own road).
const VIRIDIAN_FOREST_NORTH_GATE: u32 = 0x2f;
@ -295,6 +297,24 @@ struct Run {
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>,
/// 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 {
@ -396,6 +416,11 @@ impl Run {
talk_on_pad_by_map: std::collections::BTreeSet::new(),
talk_starts_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,
last_start: None,
}
@ -502,6 +527,11 @@ impl Run {
talk_on_pad_by_map: std::collections::BTreeSet::new(),
talk_starts_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,
last_start: None,
}
@ -731,7 +761,7 @@ impl Run {
self.talk_on_pad = talk_bound;
let active =
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 decision = self.layer.decide(&active, 0, self.ms, &mut self.gb, &ledger);
let started: Vec<&'static str> = decision
@ -748,8 +778,39 @@ impl Run {
})
.map(|event| event.name)
.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 {
*self.blocked.entry(name).or_insert(0) += 1;
let sub = self.battle_sub_state();
@ -818,6 +879,8 @@ impl Run {
let map = self.map();
*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.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.
fn drive_until(&mut self, what: &str, budget: u32, mut done: impl FnMut(&mut Self) -> bool) {
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.
///
/// **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: {:?}",
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
);
}