Compare commits
No commits in common. "537cdd8ad955adba321bcc873736f9832b2c2b8e" and "56db91cd9b954403141aa7b9e8d59fdbeeb5bb44" have entirely different histories.
537cdd8ad9
...
56db91cd9b
24 changed files with 107 additions and 1308 deletions
|
|
@ -100,22 +100,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -649,47 +649,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -1165,57 +1165,12 @@ 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. 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.
|
||||
mid-step the two are a tile apart: `wYCoord` is the tile being walked *to* while the background is
|
||||
still scrolling. Measured on Pewter City from the rung-10 checkpoint: standing still it decodes on
|
||||
**118 of 120** frames, and the frame the survey caught disagreed on three tiles by exactly one row
|
||||
in the direction of travel. A walk planned on such a frame is planned over the ten-by-nine window of
|
||||
section 15's "before". Naming it needs a WRAM reading of "a step is in progress" that this crate's
|
||||
reviewed symbol list does not carry, so it is reported here and by the probes rather than guessed at.
|
||||
|
||||
## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a
|
||||
## priority to visit the shop at least once per area; make shop macros item purchases. same
|
||||
|
|
|
|||
|
|
@ -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`), `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) |
|
||||
| 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`) |
|
||||
| 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), `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) |
|
||||
| `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) |
|
||||
| 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: 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) |
|
||||
| `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) |
|
||||
| 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, and `tests/integration.rs::session_over_one_router` (both: 20 snapshot publications accepted by both subscriptions while the presentation consumer reads nothing) |
|
||||
| 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 |
|
||||
| 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; 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) |
|
||||
| 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) |
|
||||
| 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,30 +438,18 @@ 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. They belonged to the bus slice
|
||||
rather than to this one and were fixed there, in the same way and for the same reason:
|
||||
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:
|
||||
|
||||
- `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 printed
|
||||
2 failures in 40 standalone runs plus 1 in 12 full-suite runs. It prints
|
||||
"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. `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.
|
||||
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.
|
||||
|
||||
## Contradictions
|
||||
|
||||
|
|
|
|||
|
|
@ -725,14 +725,3 @@ 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).
|
||||
|
|
|
|||
|
|
@ -258,10 +258,9 @@ 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 — 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.
|
||||
# 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.
|
||||
# -----------------------------------------------------------------------
|
||||
state_dir="${FLY_STATE_DIR:-/srv/fly/state}"
|
||||
hot_dir="${FLY_STATE_HOT_DIR:-/run/fly/state}"
|
||||
|
|
@ -295,7 +294,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' '${hot_dir}/chat-ring.json' 2>/dev/null; true"
|
||||
ct_exec "$CTID" -- sh -c "rm -f '${hot_dir}'/*.checkpoint '${hot_dir}/manifest.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}
|
||||
|
|
|
|||
|
|
@ -2057,93 +2057,23 @@ 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 | 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 |
|
||||
| 54 | `GO FRONTIER`, `GO HEAL` and `GO ROUTE` cycle on five tiles: three walks that each end where they began | measured in the **after** arm only, brain minutes 1.0 to 8.5, `GO HEAL` **204** starts at a mean net of 0.0 tiles and a mean reach of 0.0, `GO ROUTE` 211 at a net of 0.2 | -- | **named, not worked, and it is the next brief.** Two readings fit and the hunt cannot separate them: the errand walking the fly in and out of a building whose door is underfoot (row 2's shape, with `GO HEAL` in `GO ROUTE`'s place), and the windowed walk oscillation of 12.3's row 23 -- which the whole-map grid was built to end and which is back **because the grid is refused on every frame the fly is mid-step** (below). Rows 1, 2b, 23, 24 and 41 were each found this way |
|
||||
|
||||
### Residuals, named rather than worked around
|
||||
|
||||
- ~~**The whole-map grid is refused while the fly is moving.**~~ **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.
|
||||
- **The whole-map grid is refused while the fly is moving.** `map_grid` checks its decode against
|
||||
the screen buffer over the fly's tile and its four neighbours, and mid-step the two are one tile
|
||||
apart: `wYCoord` is the tile being walked *to* while the background is still scrolling. Measured
|
||||
on Pewter City from this checkpoint: standing still it decodes on **118 of 120** frames, and the
|
||||
frame the survey caught disagreed on three tiles by exactly one row in the direction of travel.
|
||||
A walk planned on such a frame is planned over the ten-by-nine window of section 15's "before",
|
||||
which is what row 23's oscillation is made of. The honest fix is a WRAM reading of "a step is in
|
||||
progress", which this crate's reviewed symbol list does not carry, so it is reported by the
|
||||
probes and left.
|
||||
- **The town's errands are session state**, so every restart re-arms them and `GO OBJECTIVE` aims
|
||||
at the mart and the centre before the rung's place, the gym included. Section 13's own design.
|
||||
- **`MOVE n` still reports `blocked`** with the move list drawn and its cursor placeable but not
|
||||
accepting input (row 50). 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.
|
||||
accepting input (row 50): 42 of 65 in the after arm. Unchanged since v0.4.3.
|
||||
|
||||
### Gates
|
||||
|
||||
|
|
|
|||
|
|
@ -413,64 +413,6 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -438,20 +438,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ impl MacroPalette for PokemonPalette {
|
|||
}
|
||||
|
||||
fn observe(&mut self, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger) -> Observed {
|
||||
let (scene, bindings, standing, stepping, approach) = {
|
||||
let (scene, bindings, standing, approach) = {
|
||||
let Self {
|
||||
machine,
|
||||
mode,
|
||||
|
|
@ -237,9 +237,6 @@ 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.
|
||||
|
|
@ -250,7 +247,7 @@ impl MacroPalette for PokemonPalette {
|
|||
Some((objective.map, hops))
|
||||
});
|
||||
*cached = Some(palette);
|
||||
(scene, bindings, standing, stepping, approach)
|
||||
(scene, bindings, standing, approach)
|
||||
};
|
||||
// Section 12.7: the macro layer's own answer to "has the run stood here", because the
|
||||
// adapter's reward ledger cannot record a doormat.
|
||||
|
|
@ -261,16 +258,6 @@ 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
|
||||
|
|
@ -467,7 +454,7 @@ mod tests {
|
|||
use super::*;
|
||||
use crate::adapter::{MapEdge, MapExit};
|
||||
use crate::macros::NoLedger;
|
||||
use crate::pokemon_red::fake_wram::{self, REDS_HOUSE_1F, WALL_TILE, Wram};
|
||||
use crate::pokemon_red::fake_wram::{REDS_HOUSE_1F, Wram};
|
||||
use crate::pokemon_red::macros::geography::Amenity;
|
||||
use crate::pokemon_red::maps;
|
||||
use crate::pokemon_red::macros::cartridge::{Edge, ExitId, MacroState};
|
||||
|
|
@ -481,34 +468,6 @@ 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.
|
||||
|
|
|
|||
|
|
@ -868,17 +868,7 @@ pub fn errand(state: &mut dyn MacroState, kind: Amenity) -> Option<u8> {
|
|||
if kind == Amenity::Mart && state.money() < CHEAPEST_PURCHASE {
|
||||
return None;
|
||||
}
|
||||
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)
|
||||
geography::amenity_of(area, kind)
|
||||
}
|
||||
|
||||
/// The errand `GO OBJECTIVE` puts *ahead* of the rung's place, when this area has one.
|
||||
|
|
@ -940,21 +930,7 @@ 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) => {
|
||||
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()
|
||||
}
|
||||
Some(map) => goals_toward(state, map),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
|
@ -1298,23 +1274,9 @@ 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.
|
||||
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),
|
||||
// this run has stood on by construction. Every other unnameable destination stays a
|
||||
// candidate.
|
||||
None => exit.way == Way::Exit,
|
||||
};
|
||||
if !known_visited {
|
||||
fresh.push(*exit);
|
||||
|
|
|
|||
|
|
@ -3970,87 +3970,6 @@ 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.
|
||||
|
|
|
|||
|
|
@ -944,95 +944,19 @@ 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 -- 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() {
|
||||
// is agreeing with the cartridge's own reading of the same ground.
|
||||
let mut checked = 0;
|
||||
for (x, y) in neighbourhood(player.x, player.y) {
|
||||
let Some(screen) = map_tile_id(memory, x, y) else { continue };
|
||||
if grid.tile_id(x, y) != Some(screen) {
|
||||
return Err(GridRefusal::ScreenDisagrees);
|
||||
}
|
||||
checked += 1;
|
||||
}
|
||||
if checked == 0 {
|
||||
return Err(GridRefusal::NoScreen);
|
||||
}
|
||||
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))
|
||||
Ok(grid)
|
||||
}
|
||||
|
||||
/// [`map_grid`] without the cross-check: the blocks, the blockset and the collision list, decoded.
|
||||
|
|
@ -1125,15 +1049,11 @@ fn still_the_loaded_map(
|
|||
x: u8,
|
||||
y: u8,
|
||||
) -> bool {
|
||||
match screen_anchor(memory, grid, x, y) {
|
||||
match map_tile_id(memory, x, y) {
|
||||
// The screen is not showing the map (a battle, a text box): nothing to check against, and
|
||||
// the grid was checked when it was decoded.
|
||||
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,
|
||||
None => true,
|
||||
Some(tile) => grid.tile_id(x, y) == Some(tile),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1474,18 +1394,6 @@ 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:
|
||||
|
|
|
|||
|
|
@ -685,7 +685,24 @@ 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]>) {
|
||||
Wram::town()
|
||||
const FLOOR: u8 = 0x01;
|
||||
let blockset = vec![[FLOOR; 16], [WALL_TILE; 16]];
|
||||
let (wide, high) = (10usize, 9usize);
|
||||
let mut blocks = vec![0u8; wide * high];
|
||||
for row in 0..high {
|
||||
blocks[row * wide + 5] = 1;
|
||||
}
|
||||
let mut wram = Wram::new();
|
||||
wram.started()
|
||||
.map(fake_wram::PALLET_TOWN, wide as u8, high as u8, 3, 4)
|
||||
.facing(0)
|
||||
.house_collision()
|
||||
.tileset(0)
|
||||
.blockset(&blockset)
|
||||
.map_blocks(&blocks)
|
||||
.fill_screen(WALL_TILE)
|
||||
.screen_from_blocks(&blocks, &blockset);
|
||||
(wram, blocks, blockset)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -713,43 +730,6 @@ 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();
|
||||
|
|
|
|||
|
|
@ -99,17 +99,8 @@ 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 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.
|
||||
// The producer lets go of its own hold; the delivery keeps the bytes alive.
|
||||
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")?;
|
||||
|
|
|
|||
|
|
@ -179,9 +179,7 @@ async fn collection_waits_for_every_retained_owner(via: Via) {
|
|||
s.owners == 0 && s.sealed_artifacts == 0 && s.store_bytes == 0
|
||||
})
|
||||
.await;
|
||||
// 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;
|
||||
assert_eq!(e.files("sealed"), 0);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
|
@ -523,7 +521,7 @@ async fn disconnect_abandons_an_unsealed_writer(via: Via) {
|
|||
s.artifacts == 0 && s.store_bytes == 0 && s.owners == 0
|
||||
})
|
||||
.await;
|
||||
e.settle_files("staging", 0).await;
|
||||
assert_eq!(e.files("staging"), 0);
|
||||
}
|
||||
|
||||
/// An abrupt disconnect must release an explicit hold too, when it was the object's only root.
|
||||
|
|
@ -540,7 +538,7 @@ async fn disconnect_releases_an_explicit_hold(via: Via) {
|
|||
s.sealed_artifacts == 0 && s.owners == 0 && s.store_bytes == 0
|
||||
})
|
||||
.await;
|
||||
e.settle_files("sealed", 0).await;
|
||||
assert_eq!(e.files("sealed"), 0);
|
||||
}
|
||||
|
||||
/// A vanished subscriber must give up both a delivery it already holds and one still queued
|
||||
|
|
|
|||
|
|
@ -420,20 +420,14 @@ 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 ... 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.
|
||||
/// 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.
|
||||
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
|
||||
|
|
@ -442,66 +436,26 @@ 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 fifo = reader
|
||||
.subscribe(
|
||||
"t.replay-race",
|
||||
SubscriptionConfig::bounded().in_flight(1).replay(true),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut coalescing = viewer
|
||||
let mut sub = reader
|
||||
.subscribe(
|
||||
"t.replay-race",
|
||||
SubscriptionConfig::latest().in_flight(1).replay(true),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let racing = admin
|
||||
admin
|
||||
.publish("t.replay-race", obj(json!({"v": "new"})), &[])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
racing.subscribers, 2,
|
||||
"one publication, admitted behind both replays"
|
||||
);
|
||||
|
||||
let first = within("the replay arrives first", fifo.next())
|
||||
let first = within("the replay arrives first", sub.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", fifo.next())
|
||||
let second = within("the racing publish follows, not coalesced away", sub.next())
|
||||
.await
|
||||
.unwrap();
|
||||
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");
|
||||
}
|
||||
assert_eq!(second.payload()["v"], "new");
|
||||
}
|
||||
|
||||
/// bus-v1 section 7: clearing releases only the retained root; a later `replayLatest`
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
//! 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;
|
||||
|
||||
|
|
@ -153,31 +148,20 @@ 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, coalesced)
|
||||
seen
|
||||
});
|
||||
let recorder = e.client("recorder").await;
|
||||
let mut all = recorder
|
||||
|
|
@ -195,7 +179,6 @@ 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(
|
||||
|
|
@ -243,16 +226,8 @@ async fn session_over_one_router(via: Via) {
|
|||
)
|
||||
.await
|
||||
.unwrap();
|
||||
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;
|
||||
assert_eq!(receipt.topic_sequence, step);
|
||||
}
|
||||
// The publisher is finished, observably, so the renderer may start.
|
||||
release.send(()).unwrap();
|
||||
|
||||
let recorded = within("recorder", recording).await.unwrap();
|
||||
assert_eq!(
|
||||
|
|
@ -260,30 +235,17 @@ async fn session_over_one_router(via: Via) {
|
|||
(1..=STEPS).map(|s| (s, s)).collect::<Vec<_>>(),
|
||||
"the bounded recorder misses nothing"
|
||||
);
|
||||
let (presented, coalesced) = within("presenter", presenting).await.unwrap();
|
||||
let steps: Vec<u64> = presented.iter().map(|(s, _)| *s).collect();
|
||||
let presented = within("presenter", presenting).await.unwrap();
|
||||
assert_eq!(
|
||||
presented.last().unwrap().0,
|
||||
STEPS,
|
||||
"the slow consumer ends on the latest snapshot"
|
||||
);
|
||||
assert!(
|
||||
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"
|
||||
presented.len() < STEPS as usize,
|
||||
"the slow consumer skipped snapshots: {presented:?}"
|
||||
);
|
||||
assert!(presented.windows(2).all(|w| w[0].0 < w[1].0));
|
||||
|
||||
for t in agents {
|
||||
t.abort();
|
||||
|
|
|
|||
|
|
@ -266,14 +266,13 @@ async fn pending_connections_are_bounded_and_hello_expires() {
|
|||
let router = Router::new(config).unwrap();
|
||||
|
||||
let pending = router.connect_in_memory_as("first");
|
||||
// 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 {
|
||||
tokio::time::timeout(Duration::from_millis(20), async {
|
||||
while router.stats().connections != 1 {
|
||||
tokio::time::sleep(Duration::from_millis(1)).await;
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(router.stats().connections, 1);
|
||||
let refused = Client::connect(
|
||||
router.connect_in_memory_as("second"),
|
||||
|
|
@ -281,14 +280,7 @@ async fn pending_connections_are_bounded_and_hello_expires() {
|
|||
)
|
||||
.await;
|
||||
assert_eq!(refused.unwrap_err().code, ErrorCode::RouterLost);
|
||||
// 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;
|
||||
tokio::time::sleep(Duration::from_millis(80)).await;
|
||||
assert_eq!(router.stats().connections, 0, "pending Hello timed out");
|
||||
drop(pending);
|
||||
|
||||
|
|
|
|||
|
|
@ -313,27 +313,6 @@ 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));
|
||||
|
|
@ -503,113 +482,6 @@ 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.");
|
||||
|
|
@ -655,13 +527,6 @@ 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;
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ 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;
|
||||
|
|
@ -427,41 +426,6 @@ 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 {
|
||||
|
|
@ -497,78 +461,6 @@ 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)]
|
||||
|
|
@ -719,106 +611,6 @@ 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();
|
||||
|
|
|
|||
|
|
@ -619,16 +619,6 @@ 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
|
||||
|
|
@ -1338,12 +1328,6 @@ 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -633,38 +633,6 @@ 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();
|
||||
|
|
|
|||
|
|
@ -67,8 +67,6 @@ 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;
|
||||
|
|
@ -297,24 +295,6 @@ 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 {
|
||||
|
|
@ -416,11 +396,6 @@ 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,
|
||||
}
|
||||
|
|
@ -527,11 +502,6 @@ 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,
|
||||
}
|
||||
|
|
@ -761,7 +731,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, done) = {
|
||||
let (mask, started, blocked) = {
|
||||
let ledger = AdapterLedger(&self.adapter);
|
||||
let decision = self.layer.decide(&active, 0, self.ms, &mut self.gb, &ledger);
|
||||
let started: Vec<&'static str> = decision
|
||||
|
|
@ -778,39 +748,8 @@ impl Run {
|
|||
})
|
||||
.map(|event| event.name)
|
||||
.collect();
|
||||
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)
|
||||
(decision.mask, started, blocked)
|
||||
};
|
||||
// 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();
|
||||
|
|
@ -879,8 +818,6 @@ 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");
|
||||
|
|
@ -963,14 +900,6 @@ 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 {
|
||||
|
|
@ -2325,88 +2254,6 @@ 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
|
||||
|
|
@ -2533,18 +2380,4 @@ 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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue