Compare commits
25 commits
174dc7eabd
...
7784a9d172
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7784a9d172 | ||
|
|
6fd6d840dc | ||
|
|
0f510c93aa | ||
|
|
0ca5f000d6 | ||
|
|
21701579a3 | ||
|
|
fc7fdffa6c | ||
|
|
4b1559b45a | ||
|
|
2da7f688a8 | ||
|
|
ae3b15ab70 | ||
|
|
04657d3324 | ||
|
|
2b1a6c0dea | ||
|
|
28bd980e65 | ||
|
|
ddf0743395 | ||
|
|
1ba5c80053 | ||
|
|
3c614c87f4 | ||
|
|
d3f98ae4f1 | ||
|
|
3d9a08d0be | ||
|
|
34c7a56b25 | ||
|
|
d324ec825a | ||
|
|
d3fa7908ec | ||
|
|
acf7c2ebb8 | ||
|
|
3eb82d7144 | ||
|
|
5fd16536db | ||
|
|
484cc075cc | ||
|
|
552428a7be |
47 changed files with 3496 additions and 96 deletions
|
|
@ -131,6 +131,9 @@ publish. Pacing uses absolute deadlines at 1.0x by default; it never skips frame
|
||||||
snapshot at 30 Hz: a JSON header (status, rates, learning stats, game mode, milestone rank and
|
snapshot at 30 Hz: a JSON header (status, rates, learning stats, game mode, milestone rank and
|
||||||
total, sugar state, events, chat ring) followed by attachments: RGBA frame, f32 stereo 48 kHz
|
total, sugar state, events, chat ring) followed by attachments: RGBA frame, f32 stereo 48 kHz
|
||||||
audio (binjgb's unipolar u8 converted and DC-blocked), and a 17,407-byte spike bitset.
|
audio (binjgb's unipolar u8 converted and DC-blocked), and a 17,407-byte spike bitset.
|
||||||
|
flysim serves it itself by default; with `FLY_FEED_VIA=bus` it publishes each snapshot on an
|
||||||
|
embedded flybus router and the `fly-edge` process serves the same bytes
|
||||||
|
(`docs/design/flybus.md`, "Feed over the bus").
|
||||||
- Control API (`docs/control-api.md`): loopback HTTP :7401. `POST /stimulate` (sugar: a timed PAM
|
- Control API (`docs/control-api.md`): loopback HTTP :7401. `POST /stimulate` (sugar: a timed PAM
|
||||||
pulse, rate-limited server side), `POST /reward` (present, disabled by config), `POST /chat`
|
pulse, rate-limited server side), `POST /reward` (present, disabled by config), `POST /chat`
|
||||||
(sanitized, deny-listed, ring of 12), `/status`, `/checkpoint`, `/pause`, `/resume`,
|
(sanitized, deny-listed, ring of 12), `/status`, `/checkpoint`, `/pause`, `/resume`,
|
||||||
|
|
@ -159,8 +162,8 @@ sequenceDiagram
|
||||||
S->>S: every 5 s hot copy, every 300 s durable checkpoint
|
S->>S: every 5 s hot copy, every 300 s durable checkpoint
|
||||||
```
|
```
|
||||||
|
|
||||||
Where: `services/flysim/crates/flysim/src/{main,config,simloop,pacing,snapshot,feed,api,chat,store,eventlog,metrics}.rs`,
|
Where: `services/flysim/crates/flysim/src/{main,config,simloop,pacing,snapshot,feed,feedbus,api,chat,store,eventlog,metrics}.rs`,
|
||||||
`docs/design/flysim.md`.
|
`services/flysim/crates/fly-edge`, `docs/design/flysim.md`.
|
||||||
|
|
||||||
## 4. Stage page
|
## 4. Stage page
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
# flybus: the communications bus
|
# flybus: the communications bus
|
||||||
|
|
||||||
Status: **crate landed, nothing wired onto it**. Written 2026-09-22. Index only; the
|
Status: **crate landed; the feed rides it behind `FLY_FEED_VIA=bus`, off by default**.
|
||||||
|
Written 2026-09-22, amended 2026-09-23 (EDGE-01, below). Index only; the
|
||||||
authority for the API and the wire format is the crate's own
|
authority for the API and the wire format is the crate's own
|
||||||
[README](../../services/flysim/crates/flybus/README.md), and the audit of the crate against
|
[README](../../services/flysim/crates/flybus/README.md), and the audit of the crate against
|
||||||
the draft is the [conformance report](session-framework/bus-conformance.md).
|
the draft is the [conformance report](session-framework/bus-conformance.md).
|
||||||
|
|
@ -37,8 +38,8 @@ does not change any published contract by existing.
|
||||||
|
|
||||||
## Crate layout
|
## Crate layout
|
||||||
|
|
||||||
`services/flysim/crates/flybus`, a workspace member of the flysim workspace; no other crate
|
`services/flysim/crates/flybus`, a workspace member of the flysim workspace. `flysim` depends
|
||||||
depends on it yet.
|
on it for the feed publisher (`src/feedbus.rs`) and `fly-edge` for the subscriber.
|
||||||
|
|
||||||
| Module | Contents |
|
| Module | Contents |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|
@ -59,16 +60,131 @@ allocate/seal/read with quotas and router restarts, plus the conformance suites
|
||||||
|
|
||||||
## Wiring still pending
|
## Wiring still pending
|
||||||
|
|
||||||
- **flysim publisher.** Router startup inside the sim service, a store root under its
|
- ~~**flysim publisher.**~~ Done 2026-09-23 behind `FLY_FEED_VIA=bus`: see "Feed over the bus".
|
||||||
runtime directory, and snapshot publication as artifact plus header envelope.
|
|
||||||
- **flysim control services.** The control endpoints as RPC services with grants, so the
|
- **flysim control services.** The control endpoints as RPC services with grants, so the
|
||||||
"no button endpoint" structural guarantee is expressed as a grant table.
|
"no button endpoint" structural guarantee is expressed as a grant table.
|
||||||
- **Stage and bridge clients.** Both are TypeScript/Node; the crate is Rust only, so either
|
- **Stage and bridge clients.** Both are TypeScript/Node; the crate is Rust only, so either
|
||||||
a binding or a thin translating edge process is required before they leave the WebSocket
|
a binding or a thin translating edge process is required before they leave the WebSocket
|
||||||
and HTTP surfaces.
|
and HTTP surfaces. The operator chose the edge process (port decisions, 2026-09-23); for
|
||||||
- **Sizing.** `max_store_bytes`, `max_retained_bytes` and `max_latest_in_flight` need values
|
the feed it exists (`fly-edge`), and they keep the WebSocket contract unchanged. The
|
||||||
chosen for 1.2 MB frames at 30 to 60 Hz with a slow consumer, not the defaults.
|
control API (:7401) is the next slice and stays in flysim until then.
|
||||||
- **Lifecycle.** Orphaned store directories are cleaned only when a new router starts on the
|
- ~~**Sizing.**~~ Decided 2026-09-23: amendment "Feed sizing" below.
|
||||||
same root, so service restart order and the store root's location need a decision.
|
- ~~**Lifecycle.**~~ Decided 2026-09-23: amendment "Feed store lifecycle" below.
|
||||||
- **Migration order.** The feed is the cheaper first move; control should follow only once
|
- **Migration order.** The feed is the cheaper first move; control should follow only once
|
||||||
the bus carries the feed in production for a full session.
|
the bus carries the feed in production for a full session.
|
||||||
|
|
||||||
|
## Feed over the bus (2026-09-23, EDGE-01)
|
||||||
|
|
||||||
|
`feed.via` (`FLY_FEED_VIA`) picks who serves `ws://127.0.0.1:7400/feed`. `direct` is the
|
||||||
|
default and is the behaviour that predates the bus. With `bus`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
sim thread --watch<Snapshot>--> publisher task --flybus (in memory)--> Router
|
||||||
|
(unchanged) (flysim-bus runtime) | <bus_dir>/edge.sock
|
||||||
|
v (bound to "fly-edge")
|
||||||
|
fly-edge: Subscription -> watch -> flysim::feed :7400
|
||||||
|
```
|
||||||
|
|
||||||
|
- flysim does not bind `feed.bind`. It starts a `Router` on a runtime of its own (two
|
||||||
|
threads, `flysim-bus`), store root `<bus_dir>/store`, closed policy: `flysim` may declare
|
||||||
|
and publish `fly.feed.snapshots`, `fly-edge` may only subscribe to it, and the Unix socket
|
||||||
|
`<bus_dir>/edge.sock` is launcher-bound to `fly-edge`.
|
||||||
|
- The topic is `retained: latest`. Each publication is one snapshot: the attachments the
|
||||||
|
header lists as sealed artifacts named `frame` (`image/x-rgba`), `audio`
|
||||||
|
(`audio/x-f32le`), `spikes` (`application/x-spike-bitset`), and the header as the payload
|
||||||
|
`{"header": {...}}`. A header over 48 KiB of JSON goes as a `header` artifact instead, so
|
||||||
|
the 65,536-byte envelope limit can never make a snapshot unpublishable.
|
||||||
|
- The sim thread is untouched. The publisher reads the same `watch` slot the direct server
|
||||||
|
reads, so a slow bus skips snapshots the way a slow WebSocket client does, and nothing on
|
||||||
|
the bus can hold the loop's publish. `fly_bus_published_total` and
|
||||||
|
`fly_bus_publish_failures_total` count it.
|
||||||
|
- `fly-edge` subscribes `latest`, one in flight, with replay, rebuilds each `Snapshot` with
|
||||||
|
`feedbus::receive` and serves it with flysim's own `feed::router`. `hello`, `wants`,
|
||||||
|
drop-oldest, the idle header and the framing are therefore the same code, and the bytes are
|
||||||
|
the same bytes: `crates/fly-edge/tests/parity.rs` replays the committed stage fixtures
|
||||||
|
through both paths at once and requires byte-equal messages per client flavour.
|
||||||
|
- `fly_frames_sent_total` and `fly_feed_clients` move to the edge with the clients; it exports
|
||||||
|
them under the same names on `FLY_EDGE_METRICS_ADDR` (`127.0.0.1:9102` in
|
||||||
|
`infra/units/flyedge.service`), and watchdog check 2 follows `FLY_FEED_VIA` in `fly.env` to
|
||||||
|
them. flysim's own copies read 0 in bus mode; `/status` is otherwise unchanged. The edge also
|
||||||
|
exports `fly_edge_bus_connected`, `fly_edge_bus_lost_total`, `fly_edge_bind_failures_total`
|
||||||
|
(the bus answered but :7400 was taken, most likely by a flysim still in direct mode) and
|
||||||
|
`fly_edge_decode_failures_total`.
|
||||||
|
- Nothing about the fly changes: the readout, the reward catalog, the adapter version and the
|
||||||
|
compatibility string are byte-identical in both modes (`--print-compatibility`).
|
||||||
|
|
||||||
|
### Amendment 2026-09-23: feed sizing
|
||||||
|
|
||||||
|
Measured on the live fly (release build, the real cartridge): a running snapshot is a
|
||||||
|
**92,160-byte** frame (160x144 RGBA; not the 640x480 "1.2 MB" the pending list assumed), a
|
||||||
|
**17,407-byte** spike bitset (139,255 neurons), about **12,800 bytes** of audio at realtime
|
||||||
|
(1,600 stereo f32 frames per 30 Hz snapshot at 48 kHz) and a 2 to 3 KB header: **122,367
|
||||||
|
bytes** of artifacts, about 3.7 MB/s at 30 Hz. `flysim::feedbus::limits()`:
|
||||||
|
|
||||||
|
| Limit | Value | Why |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `max_clients` | 8 | connections, pending handshakes included: the in-process publisher and the edge's one socket seat |
|
||||||
|
| `max_subscriptions_per_client` | 4 | the edge needs 1; this is what bounds the worst case |
|
||||||
|
| `max_latest_in_flight` | 2 | the default; the edge asks for 1 |
|
||||||
|
| `max_artifact_bytes` | 4 MiB | ten seconds of audio that piled up behind a late publish |
|
||||||
|
| `max_store_bytes` | 32 MiB | tmpfs, so RAM; ten times the worst case below |
|
||||||
|
| `max_retained_bytes` | 8 MiB | one retained snapshot, plus a large header artifact |
|
||||||
|
| `max_owners_per_client` / reserved | 64 / 8 | three artifacts per delivery, a few deliveries |
|
||||||
|
| others | small counts | one topic, no services |
|
||||||
|
|
||||||
|
A `latest` subscriber that never consumes pins at most its queued slot plus its in-flight
|
||||||
|
credits (3 snapshots); the topic pins one retained value; the publisher holds one snapshot of
|
||||||
|
staging plus the sealed copy while sealing. Only one client can subscribe at all: the publisher
|
||||||
|
is in process, and `edge.sock` is launcher-bound to `fly-edge`, which the router admits once at
|
||||||
|
a time (a second connection is refused as already connected). The worst case is therefore that
|
||||||
|
one client holding all 4 subscriptions it may open, none consuming: 4 x 3 + 1 + 2 = **15
|
||||||
|
snapshots, about 1.8 MB**, and publication never waits on any of them (a latest subscriber is
|
||||||
|
never a reason to refuse a publication, bus-v1 section 9). `crates/fly-edge/tests/stall.rs`
|
||||||
|
measures exactly that seat: four hoarding subscriptions, a fifth refused, a second connection
|
||||||
|
refused, pacer lag 0, no publication refused. The 15 is an upper bound; the measured store
|
||||||
|
was 472,061 bytes (under 4 snapshots), because fan-out adds roots and never copies, so four
|
||||||
|
subscriptions stuck on the same publications pin the same artifacts.
|
||||||
|
|
||||||
|
### Amendment 2026-09-23: feed store lifecycle
|
||||||
|
|
||||||
|
- **Location.** `feed.bus_dir` (`FLY_BUS_DIR`), `/run/fly/bus` on the containers: tmpfs,
|
||||||
|
0700, owned by `fly`, created by tmpfiles and again by flysim. The store root is
|
||||||
|
`<bus_dir>/store`, the socket `<bus_dir>/edge.sock`. A reboot empties it.
|
||||||
|
- **Owner.** The router lives in flysim; its lifetime is flysim's. flysim removes a stale
|
||||||
|
socket file at start, and `Router::new` removes any store directory whose `flock` is free,
|
||||||
|
i.e. one a crashed flysim left behind. A clean stop removes its own directory. The edge owns
|
||||||
|
nothing on disk.
|
||||||
|
- **Order.** flysim first, the edge after it: `flyedge.service` is `After=` and
|
||||||
|
`Requires=flysim.service`, so an explicit stop or restart of flysim (the unstick rule's
|
||||||
|
restart included) takes the edge with it. A crash-restart of flysim needs nothing: the edge
|
||||||
|
sees the connection close, drops every WebSocket client, unbinds :7400 and reconnects every
|
||||||
|
500 ms, binding :7400 again only when the first snapshot of the new router arrives. To the
|
||||||
|
stage that is exactly a flysim restart in direct mode: refused, then back.
|
||||||
|
- **Default.** `flyedge.service` is in no target and `07-enable.sh` does not enable it;
|
||||||
|
`05-deploy.sh` writes `FLY_FEED_VIA=direct` unless the env file says otherwise, and refuses
|
||||||
|
anything but `direct` or `bus` (any case, written lowercased). The switch and the way back
|
||||||
|
are in the unit's header. The edge gets a cpuset drop-in on the page's CPUs with the other
|
||||||
|
units, so once enabled it never runs on flysim's.
|
||||||
|
- **Paths.** `feed.bus_dir` must be absolute and non-empty (checked in both modes), since
|
||||||
|
flysim and the edge each resolve it.
|
||||||
|
- **Migration order** is unchanged: the feed first; control only after the bus has carried
|
||||||
|
the feed in production for a full session.
|
||||||
|
|
||||||
|
### Known limits (review round 1, 2026-09-23)
|
||||||
|
|
||||||
|
Accepted for now and written down rather than fixed:
|
||||||
|
|
||||||
|
- **Feed counters off the container.** In bus mode flysim's `:9101` reports
|
||||||
|
`fly_feed_clients` and `fly_frames_sent_total` as 0, and the edge's copies are on loopback
|
||||||
|
`:9102` only. The watchdog follows `FLY_FEED_VIA`; anything that scrapes `:9101` from off
|
||||||
|
the container (the metrics dashboard) goes blind to the feed until it also scrapes the edge.
|
||||||
|
- **Store quota is per router, not per client.** Any client on `edge.sock` may allocate
|
||||||
|
artifacts up to the store cap; a hostile process running as the same user could fill the
|
||||||
|
store and make flysim's publications fail. The loop is unaffected (a refusal is counted, never
|
||||||
|
waited on), but the feed would stall. Same-user processes are inside the trust boundary
|
||||||
|
(crate README, "Limitations").
|
||||||
|
- **Rollback while in bus mode.** Rolling back to a release without `fly-edge` while `fly.env`
|
||||||
|
still says `bus` leaves no one on :7400, and check 2 then reads the edge's absent `:9102` and
|
||||||
|
escalates. Switch back to `direct` first (the unit header's way back), then roll back.
|
||||||
|
- **Old fixtures.** `cold-open`, `steady` and `big-moment` predate `game.scene` and cannot be a
|
||||||
|
Rust `FeedHeader`, so fixture parity covers `macros`, `shop`, `center` and `bigpad`.
|
||||||
|
|
|
||||||
|
|
@ -893,3 +893,41 @@ point of reading the figure; a border drawn somewhere the cursor is not parked i
|
||||||
box; and a menu of more than two options is not this menu. What the pad makes of a readable prompt
|
box; and a menu of more than two options is not this menu. What the pad makes of a readable prompt
|
||||||
is `pokemon_red::macros::palette`'s business (`docs/design/macros.md` 12.12 and 12.20), not this
|
is `pokemon_red::macros::palette`'s business (`docs/design/macros.md` 12.12 and 12.20), not this
|
||||||
accessor's.
|
accessor's.
|
||||||
|
|
||||||
|
## 12. The people off the screen, and a battle decided (2026-09-23, `docs/design/macros.md` 12.22)
|
||||||
|
|
||||||
|
Two readings row 58 added, both out of bytes the seam already had or a byte bracketed by two it had.
|
||||||
|
|
||||||
|
### `state::offscreen_npcs`
|
||||||
|
|
||||||
|
`CheckSpriteAvailability` (`engine/overworld/movement.asm`) writes `$ff` into
|
||||||
|
`SPRITESTATEDATA1_IMAGEINDEX` (offset 2) for a sprite that is a toggleable object switched off, that
|
||||||
|
is outside its window, or that stands on a text box's tiles. The window, for a sprite whose
|
||||||
|
`SPRITESTATEDATA2_MOVEMENTBYTE1` (offset 6) is `WALK` (`$fe`) or `STAY` (`$ff`), compares the
|
||||||
|
sprite's biased `MAPY` / `MAPX` (offsets 4 and 5) with `wYCoord` / `wXCoord`: drawn when equal, or
|
||||||
|
when the sprite's is greater by at most `SCREEN_HEIGHT / 2 - 1` (8) rows and
|
||||||
|
`SCREEN_WIDTH / 2 - 1` (9) columns. A scripted mover (movement byte below `WALK`) skips the test.
|
||||||
|
|
||||||
|
So a `$ff` sprite of the loaded map whose coordinates fall **outside** the window, and whose
|
||||||
|
movement byte is `WALK` or above, is reported with those coordinates: the cartridge would hide it
|
||||||
|
for being off the screen whatever else were true, and a sprite it is not updating does not move.
|
||||||
|
Everything else `$ff` is not reported. Measured from the row-58 checkpoint at the Pewter Gym's
|
||||||
|
doormat: BROCK at (4, 1) and the Jr. Trainer at (3, 6) reported, the guide at (7, 10) drawn.
|
||||||
|
|
||||||
|
What it cannot tell is a toggleable object switched off from one out of sight, because both are
|
||||||
|
`$ff` outside the window. Its one reader is the rung's own list of people; the ladder's person
|
||||||
|
places are Oak's lab and the gyms, and only the lab and Viridian Gym carry toggleable people
|
||||||
|
(`data/maps/toggleable_objects.asm`).
|
||||||
|
|
||||||
|
### `poke::CUR_OPPONENT`, `wCurOpponent`
|
||||||
|
|
||||||
|
Written when a battle is decided (`home/trainers.asm` for a trainer, the encounter check for a wild
|
||||||
|
one), cleared by `EndOfBattle` in the same block that clears `wIsInBattle`. Not in the generated
|
||||||
|
table: `ram/wram.asm` at the pinned commit declares `wIsInBattle:: db`,
|
||||||
|
`wPartyGainExpFlags:: flag_array PARTY_LENGTH` (one byte), `wCurOpponent:: db`,
|
||||||
|
`wBattleType:: db`, `wDamageMultipliers:: db`, `wGymLeaderNo:: db`, `wTrainerNo:: db`, and the
|
||||||
|
table's `wIsInBattle` (`$d057`), `wBattleType` (`$d05a`) and `wTrainerNo` (`$d05d`) are exactly
|
||||||
|
where that layout puts them, so the byte is `wBattleType - 1` = `$d059`, asserted against both
|
||||||
|
neighbours in `scene/tests.rs`. Measured on the cartridge in the Pewter Gym: `$00` in the
|
||||||
|
overworld, `$cd` (`OPP_JR_TRAINER_M`) from the last box of the trainer's challenge through the
|
||||||
|
219-frame transition and the battle. `controllable` reads it as zero.
|
||||||
|
|
|
||||||
|
|
@ -1445,6 +1445,66 @@ Nothing presses for the fly and nothing is ranked: one button leaves a pad it co
|
||||||
one wall moves to the tile that earned it, and one walk goes where it can. The decoder, the reward catalog, the adapter
|
one wall moves to the tile that earned it, and one walk goes where it can. The decoder, the reward catalog, the adapter
|
||||||
version, the roles and the compatibility string are untouched.
|
version, the roles and the compatibility string are untouched.
|
||||||
|
|
||||||
|
### 12.22 The rung's people are in the room when the screen does not show them (2026-09-23, row 58)
|
||||||
|
|
||||||
|
Live on v0.5.3, rank 10, for twenty-five minutes: `GO OBJECTIVE` into the Pewter Gym, `GO OUT`
|
||||||
|
straight back out, with `GO ITEM`, `GO FRONTIER`, `YES` and `NO` mixed in. Per ten brain minutes
|
||||||
|
about 93 `GO OUT`, 47 `GO OBJECTIVE`, 200 starts in all, every one `done`, **no reward event of any
|
||||||
|
kind**, the exploration count frozen at 1,892. Check 10 saw ten distinct names and said nothing.
|
||||||
|
Surveyed from the live checkpoint with the route probe (`FLY_PROBE_CATCH=route`,
|
||||||
|
`FLY_PROBE_CATCH_MAP=54`), which reads the room on the fly's Nth arrival.
|
||||||
|
|
||||||
|
- **The objective saw the room through the screen.** `objective_targets` read `npcs`, which is
|
||||||
|
what the cartridge *draws*, and `CheckSpriteAvailability` writes `$ff` into the image index of
|
||||||
|
every sprite outside a window of the player's coordinate. From the doormat at (4, 13) that window
|
||||||
|
holds the guide at (7, 10) and nobody else: BROCK at (4, 1) and the Jr. Trainer at (3, 6) are not
|
||||||
|
drawn. With the guide talked to (12.20), the rung's list was empty, so `GO OBJECTIVE` had nothing
|
||||||
|
to aim at inside and 12.5's rule -- the ways out are withheld while the rung's person is in the
|
||||||
|
room -- let `GO OUT` onto the pad. Outside, `GO OBJECTIVE` aimed at the gym's door. The pair
|
||||||
|
undoes itself in about a second, and nothing on either side of the door earns anything.
|
||||||
|
- **So the rung reads the people the cartridge hides only for being off the screen.** The window
|
||||||
|
is a function of `wYCoord`, `wXCoord` and the sprite's own biased coordinates, all already read,
|
||||||
|
so a sprite whose `$ff` falls outside it is one the cartridge would hide for that reason whatever
|
||||||
|
else were true, and a sprite the cartridge is not updating does not move
|
||||||
|
(`state::offscreen_npcs`). A `$ff` *inside* the window, or on a scripted mover, is not the
|
||||||
|
screen's and is not reported. Only the rung reads the list: a sprite outside the window may also
|
||||||
|
be a toggleable object switched off, which reads the same, so `GO NPC`, `TALK` and objects keep
|
||||||
|
what is drawn.
|
||||||
|
- **Facing any of the rung's people is the arrival.** 12.5 left out only the one ahead, which was
|
||||||
|
enough for one target; a gym names three, and in front of BROCK `GO OBJECTIVE` still had the
|
||||||
|
trainer to walk to. A fly facing a person the rung is waiting on has nothing left for a walk to
|
||||||
|
do, and `TALK` is the press.
|
||||||
|
|
||||||
|
Three frames the seam read as the fly's own were the cartridge's, and each wrote a ledger entry that
|
||||||
|
emptied the room again once the first fix let the fly into it:
|
||||||
|
|
||||||
|
- **A warp's tear.** `wCurMap` changes thirty-two frames before the header, the coordinates and the
|
||||||
|
warp table follow it, while the screen fades, and no joypad bit is set until the fade is over.
|
||||||
|
The seam read "map 54 at (16, 17)" -- Pewter City's doormat under the gym's id -- as an overworld
|
||||||
|
and dealt it a pad; a walk started there planned over the wrong map, and what it aimed at went
|
||||||
|
into the blocked ledger under the gym's id (live: `GO OUT` started and finished in 0.05 s). The
|
||||||
|
driver now reads a tear as the map byte having changed while the fly still stands on a warp of
|
||||||
|
the loaded table that leads to the map the byte names, deals it as `Unknown` with an empty pad,
|
||||||
|
and records no ground from it. Teleport pads -- Saffron Gym, two Silph Co. floors -- do not change
|
||||||
|
the map byte, so they are never a tear; a tear is bounded at ninety frames all the same.
|
||||||
|
- **A battle's transition.** Between a trainer's challenge closing and the battle screen there are
|
||||||
|
219 frames with every joypad and script bit clear. The pad was dealt, a walk toward BROCK pressed
|
||||||
|
into the animation and gave up after three refused steps -- BROCK blocked for ten brain minutes --
|
||||||
|
and the Jr. Trainer's conversation read as over, so the trainer the fly then lost to was
|
||||||
|
"talked to" for the session. `wCurOpponent` is set when a battle is decided and cleared by
|
||||||
|
`EndOfBattle` with `wIsInBattle`; it is not in the generated table and is derived as the byte
|
||||||
|
between two that are, both neighbours checked in a test (`macros-wram.md` section 12), and
|
||||||
|
`controllable` reads it.
|
||||||
|
- **A trainer walking up.** 12.4 reads a macro the cartridge ended by taking the joypad as a
|
||||||
|
refusal and wrote the target blocked and the tile pushed at once. A trainer who sees the fly
|
||||||
|
takes the joypad the same way. The entries now wait until the cartridge gives the joypad back:
|
||||||
|
in the overworld it was a refusal and is written as before; a battle teaches the ledgers nothing.
|
||||||
|
|
||||||
|
Nothing is ranked, nothing presses for the fly, and no button is added to any pad: `GO OBJECTIVE`
|
||||||
|
has a person to walk to where it had none, `GO OUT` is withheld by 12.5's own rule, and three
|
||||||
|
frames that were never the fly's deal nothing. The decoder, the reward catalog, the adapter
|
||||||
|
version, the roles and the compatibility string are untouched.
|
||||||
|
|
||||||
## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a
|
## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a
|
||||||
## priority to visit the shop at least once per area; make shop macros item purchases. same
|
## priority to visit the shop at least once per area; make shop macros item purchases. same
|
||||||
## for the Pokécenter. heal should be a macro.")
|
## for the Pokécenter. heal should be a macro.")
|
||||||
|
|
|
||||||
|
|
@ -815,3 +815,20 @@ median; a two-fly transition near 10 to 12 ms at the median in every execution m
|
||||||
`stalled` and `zero-progress`, still never acting. The GO OBJECTIVE / GO OUT ring at the gym
|
`stalled` and `zero-progress`, still never acting. The GO OBJECTIVE / GO OUT ring at the gym
|
||||||
door is row 58. Ethos check held. While it cooked the operator's new unstick rule kept the
|
door is row 58. Ethos check held. While it cooked the operator's new unstick rule kept the
|
||||||
stream moving with flysim restarts (loop-review.md).
|
stream moving with flysim restarts (loop-review.md).
|
||||||
|
- 2026-09-23 (v0.5.5, loop review, auto): row 58, the Pewter Gym door. GO OBJECTIVE walked in,
|
||||||
|
GO OUT walked out, for hours, with no reward; restarts bought twenty to forty minutes each.
|
||||||
|
Mechanism: the objective's people were read from the sprites the cartridge draws, and from the
|
||||||
|
doormat Brock and the Jr. Trainer are off screen, so with the guide talked the rung had nobody
|
||||||
|
and the gym dealt its ways out. Three moments the game holds the joypad (a warp's first frames,
|
||||||
|
a trainer's challenge before the battle screen, a trainer walking up) wrote the fly's ledgers.
|
||||||
|
Fix: the objective reads off-screen people by the cartridge's own availability rule; facing any
|
||||||
|
of them is arrival; a warp's tear deals an empty pad (at most 90 frames); `controllable` reads
|
||||||
|
`wCurOpponent`; ledger writes wait for the joypad to come back and drop if a battle starts.
|
||||||
|
ROM test: base 40 arrivals, 38 straight back out; branch reaches Brock. Route survey from the
|
||||||
|
live checkpoint, 33 brain minutes: GO OUT 1,118 -> 0, rung 10 -> 11, BOULDER BADGE. The trap
|
||||||
|
hunt's stub readout never walks the ring on either arm (flagged 17 -> 23, all one-tile windows
|
||||||
|
in the gym's dialogs and battles; tiles 329 -> 437), so the proof is the ROM test and the
|
||||||
|
survey: the ethos check held in spirit, recorded as a deviation from the hunt's letter. Check
|
||||||
|
10 gains `unrewarded` (100+ decisions, no reward, no new ground, two probes), still never
|
||||||
|
acting. Also shipped: EDGE-01, the feed over flybus behind FLY_FEED_VIA, off (`direct`)
|
||||||
|
everywhere. Next: row 59, Route 3's neighbours and Mt. Moon's doors in the geography table.
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,11 @@ fi
|
||||||
require_pve_host
|
require_pve_host
|
||||||
need pct
|
need pct
|
||||||
|
|
||||||
|
# Who serves the feed (docs/design/flybus.md): refused here, before section 1 flips
|
||||||
|
# /opt/fly/current, rather than half way through the deploy or at flysim's boot.
|
||||||
|
FLY_FEED_VIA_EFFECTIVE="$(feed_via_normalize "${FLY_FEED_VIA:-}")" \
|
||||||
|
|| die "05-deploy: FLY_FEED_VIA must be 'direct' or 'bus', got '${FLY_FEED_VIA}'"
|
||||||
|
|
||||||
# CHROMIUM_PROFILE is validated here, not left to the launcher: a typo or a
|
# CHROMIUM_PROFILE is validated here, not left to the launcher: a typo or a
|
||||||
# `vgl` on a container that never had VirtualGL installed would only show up as
|
# `vgl` on a container that never had VirtualGL installed would only show up as
|
||||||
# flystage refusing to start, i.e. a black stream, minutes after the deploy
|
# flystage refusing to start, i.e. a black stream, minutes after the deploy
|
||||||
|
|
@ -413,6 +418,7 @@ log "05-deploy: non-secret env files"
|
||||||
# never drift apart (see cpuset_partition's own header comment). They are
|
# never drift apart (see cpuset_partition's own header comment). They are
|
||||||
# assigned in section 0b, which needs them earlier than this for the
|
# assigned in section 0b, which needs them earlier than this for the
|
||||||
# deploy-time cpu pinning; nothing between here and there changes them.
|
# deploy-time cpu pinning; nothing between here and there changes them.
|
||||||
|
|
||||||
tmp_fly_env="$(mktemp)"
|
tmp_fly_env="$(mktemp)"
|
||||||
tmp_flypush_env="$(mktemp)"
|
tmp_flypush_env="$(mktemp)"
|
||||||
trap 'rm -f "$tmp_fly_env" "$tmp_flypush_env"' EXIT
|
trap 'rm -f "$tmp_fly_env" "$tmp_flypush_env"' EXIT
|
||||||
|
|
@ -499,6 +505,16 @@ trap 'rm -f "$tmp_fly_env" "$tmp_flypush_env"' EXIT
|
||||||
# "palette"/"plan" as "macros" with a warning, and refuses an unrecognised
|
# "palette"/"plan" as "macros" with a warning, and refuses an unrecognised
|
||||||
# value outright.
|
# value outright.
|
||||||
echo "FLY_MACRO_MODE=${FLY_MACRO_MODE:-raw}"
|
echo "FLY_MACRO_MODE=${FLY_MACRO_MODE:-raw}"
|
||||||
|
# Who serves the feed WebSocket (docs/design/flybus.md, "Feed over the
|
||||||
|
# bus"). "direct" is the default and is flysim binding :7400 itself, as
|
||||||
|
# every release before this knob. "bus" makes flysim publish on its
|
||||||
|
# embedded feed bus and leave :7400 to flyedge.service, which this script
|
||||||
|
# never enables: see that unit's header for the switch. Written
|
||||||
|
# unconditionally, like FLY_MACRO_MODE, so one grep says which a box runs.
|
||||||
|
# Watchdog check 2 reads this line to know whose /metrics carries the
|
||||||
|
# feed counters (flysim's :9101, or flyedge's loopback :9102).
|
||||||
|
# Validated and lowercased above (feed_via_normalize).
|
||||||
|
echo "FLY_FEED_VIA=${FLY_FEED_VIA_EFFECTIVE}"
|
||||||
# How long a macro leaves a target alone after a walk to it aborted
|
# How long a macro leaves a target alone after a walk to it aborted
|
||||||
# (macros.md section 12.1, the Viridian stall). Only written when it is set,
|
# (macros.md section 12.1, the Viridian stall). Only written when it is set,
|
||||||
# because the default lives in the crate and a box that has not tuned it
|
# because the default lives in the crate and a box that has not tuned it
|
||||||
|
|
@ -633,9 +649,11 @@ if [ -n "${CPUSET:-}" ]; then
|
||||||
"leaves cpuset.cpus.effective empty and the unit unstartable."
|
"leaves cpuset.cpus.effective empty and the unit unstartable."
|
||||||
else
|
else
|
||||||
read -r sim_cpus page_cpus encoder_cpus <<< "$(cpuset_partition "$CPUSET" "$RAYON_THREADS_EFFECTIVE" "$ENCODER_CORES_EFFECTIVE")"
|
read -r sim_cpus page_cpus encoder_cpus <<< "$(cpuset_partition "$CPUSET" "$RAYON_THREADS_EFFECTIVE" "$ENCODER_CORES_EFFECTIVE")"
|
||||||
log "05-deploy: cpuset partition — flysim=$sim_cpus, xvfb/flystage/flystage-web/pulse/mediamtx=$page_cpus, flycast=$encoder_cpus"
|
log "05-deploy: cpuset partition — flysim=$sim_cpus, xvfb/flystage/flystage-web/pulse/mediamtx/flyedge=$page_cpus, flycast=$encoder_cpus"
|
||||||
tmp_dropin="$(mktemp)"
|
tmp_dropin="$(mktemp)"
|
||||||
for u in flysim xvfb flystage flystage-web flycast pulse mediamtx; do
|
# flyedge is off by default, but its drop-in is written with the rest so that the day
|
||||||
|
# it is enabled it serves the page from the page's CPUs, never from flysim's.
|
||||||
|
for u in flysim xvfb flystage flystage-web flycast pulse mediamtx flyedge; do
|
||||||
case "$u" in
|
case "$u" in
|
||||||
flysim) cpus="$sim_cpus" ;;
|
flysim) cpus="$sim_cpus" ;;
|
||||||
flycast) cpus="$encoder_cpus" ;;
|
flycast) cpus="$encoder_cpus" ;;
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@ log_info() {
|
||||||
|
|
||||||
: "${FLY_CONTROL_URL:=http://127.0.0.1:7401}"
|
: "${FLY_CONTROL_URL:=http://127.0.0.1:7401}"
|
||||||
: "${FLY_METRICS_URL:=http://127.0.0.1:9101}"
|
: "${FLY_METRICS_URL:=http://127.0.0.1:9101}"
|
||||||
|
# flyedge's loopback /metrics (units/flyedge.service), read by check 2 when
|
||||||
|
# fly.env says FLY_FEED_VIA=bus.
|
||||||
|
: "${FLY_EDGE_METRICS_URL:=http://127.0.0.1:9102}"
|
||||||
: "${FLY_STATE_HOT:=/run/fly/state}"
|
: "${FLY_STATE_HOT:=/run/fly/state}"
|
||||||
: "${FLY_MEDIA_DIR:=/srv/fly/media}"
|
: "${FLY_MEDIA_DIR:=/srv/fly/media}"
|
||||||
: "${MEDIAMTX_API:=http://127.0.0.1:9997}"
|
: "${MEDIAMTX_API:=http://127.0.0.1:9997}"
|
||||||
|
|
@ -68,6 +71,7 @@ log_info() {
|
||||||
: "${WD_LOOP_DOMINANCE_PCT:=95}" # or one macro being this share of the window
|
: "${WD_LOOP_DOMINANCE_PCT:=95}" # or one macro being this share of the window
|
||||||
: "${WD_LOOP_MIN_EVENTS:=20}" # floor under the dominance rule (see check 10)
|
: "${WD_LOOP_MIN_EVENTS:=20}" # floor under the dominance rule (see check 10)
|
||||||
: "${WD_LOOP_STALL_PCT:=90}" # decisions that ended refused/blocked/timeout: this share is a stall (row 57)
|
: "${WD_LOOP_STALL_PCT:=90}" # decisions that ended refused/blocked/timeout: this share is a stall (row 57)
|
||||||
|
: "${WD_LOOP_BUSY_MIN:=100}" # this many decisions with no reward and no new ground, two probes running, is busy going nowhere (row 58)
|
||||||
: "${WD_LOOP_REPORT:=${WD_RUN_DIR}/loop.json}"
|
: "${WD_LOOP_REPORT:=${WD_RUN_DIR}/loop.json}"
|
||||||
|
|
||||||
mkdir -p "$WD_RUN_DIR" "$WD_STATE_DIR"
|
mkdir -p "$WD_RUN_DIR" "$WD_STATE_DIR"
|
||||||
|
|
@ -212,6 +216,11 @@ write_textfile_metrics() {
|
||||||
echo "# HELP fly_loop_done Macros that ended done in the last check-10 window (-1 before the first probe)."
|
echo "# HELP fly_loop_done Macros that ended done in the last check-10 window (-1 before the first probe)."
|
||||||
echo "# TYPE fly_loop_done gauge"
|
echo "# TYPE fly_loop_done gauge"
|
||||||
echo "fly_loop_done $(cat "${WD_RUN_DIR}/loop.done" 2>/dev/null || echo -1)"
|
echo "fly_loop_done $(cat "${WD_RUN_DIR}/loop.done" 2>/dev/null || echo -1)"
|
||||||
|
# Row 58: an undo pair diluted by other macros completes everything and
|
||||||
|
# earns nothing; the reward events in the window are what say so.
|
||||||
|
echo "# HELP fly_loop_rewards Reward events in the last check-10 window (-1 before the first probe)."
|
||||||
|
echo "# TYPE fly_loop_rewards gauge"
|
||||||
|
echo "fly_loop_rewards $(cat "${WD_RUN_DIR}/loop.rewards" 2>/dev/null || echo -1)"
|
||||||
echo "# HELP fly_places_delta Growth in game.uniqueLocations (the exploration count) since the previous check-10 probe. -1 when there is no previous probe to compare against."
|
echo "# HELP fly_places_delta Growth in game.uniqueLocations (the exploration count) since the previous check-10 probe. -1 when there is no previous probe to compare against."
|
||||||
echo "# TYPE fly_places_delta gauge"
|
echo "# TYPE fly_places_delta gauge"
|
||||||
echo "fly_places_delta $(cat "${WD_RUN_DIR}/loop.places_delta" 2>/dev/null || echo -1)"
|
echo "fly_places_delta $(cat "${WD_RUN_DIR}/loop.places_delta" 2>/dev/null || echo -1)"
|
||||||
|
|
@ -335,10 +344,32 @@ check_flysim() {
|
||||||
# read-only metrics listener (infra.md section 5; not superseded by the
|
# read-only metrics listener (infra.md section 5; not superseded by the
|
||||||
# feed/control contracts). Flat frames counter across two passes, or zero
|
# feed/control contracts). Flat frames counter across two passes, or zero
|
||||||
# clients, means the page is dead/frozen even though Chromium is alive.
|
# clients, means the page is dead/frozen even though Chromium is alive.
|
||||||
|
#
|
||||||
|
# The two counters belong to whoever serves the feed: flysim itself, or with
|
||||||
|
# FLY_FEED_VIA=bus in fly.env, flyedge (docs/design/flybus.md, "Feed over the
|
||||||
|
# bus"), which exports them under the same names. FLY_FEED_METRICS_URL in the
|
||||||
|
# watchdog's own environment overrides both.
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
feed_metrics_url() {
|
||||||
|
if [ -n "${FLY_FEED_METRICS_URL:-}" ]; then
|
||||||
|
echo "$FLY_FEED_METRICS_URL"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local via=""
|
||||||
|
if [ -f "$FLY_ENV_FILE" ]; then
|
||||||
|
# Lowercased: flysim reads the value case-insensitively, so `Bus` is bus mode.
|
||||||
|
via="$(awk -F= '/^FLY_FEED_VIA=/ { print $2; exit }' "$FLY_ENV_FILE" 2>/dev/null | tr -d ' \r"' | tr '[:upper:]' '[:lower:]' || true)"
|
||||||
|
fi
|
||||||
|
if [ "$via" = "bus" ]; then
|
||||||
|
echo "$FLY_EDGE_METRICS_URL"
|
||||||
|
else
|
||||||
|
echo "$FLY_METRICS_URL"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
check_flystage() {
|
check_flystage() {
|
||||||
local metrics frames clients ok=1
|
local metrics frames clients ok=1
|
||||||
metrics="$(curl -fsS "${FLY_METRICS_URL}/metrics" 2>/dev/null || true)"
|
metrics="$(curl -fsS "$(feed_metrics_url)/metrics" 2>/dev/null || true)"
|
||||||
if [ -z "$metrics" ]; then
|
if [ -z "$metrics" ]; then
|
||||||
ok=0
|
ok=0
|
||||||
else
|
else
|
||||||
|
|
@ -766,6 +797,16 @@ check_capture_freeze() {
|
||||||
# this probe AND the previous one (two probes, so a single
|
# this probe AND the previous one (two probes, so a single
|
||||||
# unlucky window never flags).
|
# unlucky window never flags).
|
||||||
#
|
#
|
||||||
|
# Row 58: `GO OBJECTIVE` into the Pewter Gym, `GO OUT` straight back out, for
|
||||||
|
# 25 minutes, with GO ITEM / GO FRONTIER / YES / NO mixed in — ten distinct
|
||||||
|
# names, every macro `done`, so neither the sequence rule (four names at
|
||||||
|
# most) nor zero-progress could fire, and the exploration count sat still.
|
||||||
|
# What the window did not have was a single reward event. So the stream also
|
||||||
|
# carries the `reward` events, and one more rule reads them, behind the same
|
||||||
|
# gate:
|
||||||
|
# unrewarded — WD_LOOP_BUSY_MIN+ decisions and no reward event in the
|
||||||
|
# window, on this probe AND the previous one.
|
||||||
|
#
|
||||||
# The tile rule is what separates a loop from a legitimately repeating
|
# The tile rule is what separates a loop from a legitimately repeating
|
||||||
# explorer (macros-traps.md: `GO FRONTIER` x19 over 93 tiles is a walk longer
|
# explorer (macros-traps.md: `GO FRONTIER` x19 over 93 tiles is a walk longer
|
||||||
# than the frame cap, not a trap), and the watchdog has no tile counter. The
|
# than the frame cap, not a trap), and the watchdog has no tile counter. The
|
||||||
|
|
@ -816,7 +857,7 @@ loop_status_fields() {
|
||||||
loop_macro_stream() {
|
loop_macro_stream() {
|
||||||
[ -f "$FLY_EVENT_LOG" ] || return 0
|
[ -f "$FLY_EVENT_LOG" ] || return 0
|
||||||
tail -n "$WD_LOOP_TAIL_LINES" "$FLY_EVENT_LOG" 2>/dev/null \
|
tail -n "$WD_LOOP_TAIL_LINES" "$FLY_EVENT_LOG" 2>/dev/null \
|
||||||
| jq -r 'fromjson? // empty | select(.kind == "macro") | "\(.brainMs)\t\(.label)"' -R 2>/dev/null \
|
| jq -r 'fromjson? // empty | select(.kind == "macro" or .kind == "reward") | "\(.brainMs)\t\(.label)\t\(.kind)"' -R 2>/dev/null \
|
||||||
|| true
|
|| true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -824,23 +865,28 @@ loop_macro_stream() {
|
||||||
#
|
#
|
||||||
# total US distinct US topCount US period US repeats US windowFrom US
|
# total US distinct US topCount US period US repeats US windowFrom US
|
||||||
# windowTo US topName US block US tail US decisions US refused US blocked US
|
# windowTo US topName US block US tail US decisions US refused US blocked US
|
||||||
# timeout US done
|
# timeout US done US rewards
|
||||||
#
|
#
|
||||||
# `total` counts start events inside the window, `decisions` starts plus
|
# `total` counts start events inside the window, `decisions` starts plus
|
||||||
# refusals (the sequence, `distinct` and `topCount` are over decisions), and
|
# refusals (the sequence, `distinct` and `topCount` are over decisions), and
|
||||||
# the last four count each outcome in the window. `period`/`repeats` describe
|
# the next four count each outcome in the window, and `rewards` the reward
|
||||||
|
# events in it (row 58). `period`/`repeats` describe
|
||||||
# the shortest repeating block at the END of the sequence (0/0 when nothing
|
# the shortest repeating block at the END of the sequence (0/0 when nothing
|
||||||
# repeats), `block` is that block comma-joined, and `tail` is the last 12
|
# repeats), `block` is that block comma-joined, and `tail` is the last 12
|
||||||
# names for context. The window ends at the newest macro event's own brain
|
# names for context. The window ends at the newest macro event's own brain
|
||||||
# clock, not at `now`: brain time is the only clock the event log carries.
|
# clock, not at `now`: brain time is the only clock the event log carries.
|
||||||
loop_analyze() {
|
loop_analyze() {
|
||||||
awk -F'\t' -v win="$WD_LOOP_WINDOW_MS" -v maxp="$WD_LOOP_MAX_PERIOD" '
|
awk -F'\t' -v win="$WD_LOOP_WINDOW_MS" -v maxp="$WD_LOOP_MAX_PERIOD" '
|
||||||
{ ms[NR] = $1 + 0; lbl[NR] = $2; n = NR }
|
# Reward lines are counted and nothing else: the window still ends at the
|
||||||
|
# newest macro event, as it always has.
|
||||||
|
$3 == "reward" { rms[++nr] = $1 + 0; next }
|
||||||
|
{ ms[++n] = $1 + 0; lbl[n] = $2 }
|
||||||
END {
|
END {
|
||||||
if (n == 0) { printf "0\0370\0370\0370\0370\0370\0370\037\037\037\0370\0370\0370\0370\0370\n"; exit }
|
if (n == 0) { printf "0\0370\0370\0370\0370\0370\0370\037\037\037\0370\0370\0370\0370\0370\0370\n"; exit }
|
||||||
to = ms[n]; from = to - win
|
to = ms[n]; from = to - win
|
||||||
k = 0; distinct = 0; topn = 0; top = ""
|
k = 0; distinct = 0; topn = 0; top = ""
|
||||||
starts = 0; refused = 0; blocked = 0; timeout = 0; done = 0
|
starts = 0; refused = 0; blocked = 0; timeout = 0; done = 0; rewards = 0
|
||||||
|
for (q = 1; q <= nr; q++) if (rms[q] >= from) rewards++
|
||||||
for (i = 1; i <= n; i++) {
|
for (i = 1; i <= n; i++) {
|
||||||
if (ms[i] < from) continue
|
if (ms[i] < from) continue
|
||||||
if (lbl[i] ~ / blocked$/) { blocked++; continue }
|
if (lbl[i] ~ / blocked$/) { blocked++; continue }
|
||||||
|
|
@ -873,9 +919,9 @@ loop_analyze() {
|
||||||
tailstr = ""
|
tailstr = ""
|
||||||
start = k - 11; if (start < 1) start = 1
|
start = k - 11; if (start < 1) start = 1
|
||||||
for (j = start; j <= k; j++) tailstr = tailstr (tailstr == "" ? "" : ", ") seq[j]
|
for (j = start; j <= k; j++) tailstr = tailstr (tailstr == "" ? "" : ", ") seq[j]
|
||||||
printf "%d\037%d\037%d\037%d\037%d\037%d\037%d\037%s\037%s\037%s\037%d\037%d\037%d\037%d\037%d\n", \
|
printf "%d\037%d\037%d\037%d\037%d\037%d\037%d\037%s\037%s\037%s\037%d\037%d\037%d\037%d\037%d\037%d\n", \
|
||||||
starts, distinct, topn, period, repeats, from, to, top, block, tailstr, \
|
starts, distinct, topn, period, repeats, from, to, top, block, tailstr, \
|
||||||
k, refused, blocked, timeout, done
|
k, refused, blocked, timeout, done, rewards
|
||||||
}'
|
}'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -933,20 +979,21 @@ check_loop() {
|
||||||
echo "$delta" > "${WD_RUN_DIR}/loop.places_delta"
|
echo "$delta" > "${WD_RUN_DIR}/loop.places_delta"
|
||||||
|
|
||||||
local analysis total distinct topn period repeats win_from win_to top block tailstr
|
local analysis total distinct topn period repeats win_from win_to top block tailstr
|
||||||
local decisions refused blocked timeouts completed
|
local decisions refused blocked timeouts completed rewards
|
||||||
analysis="$(loop_macro_stream | loop_analyze)"
|
analysis="$(loop_macro_stream | loop_analyze)"
|
||||||
IFS=$'\037' read -r total distinct topn period repeats win_from win_to top block tailstr \
|
IFS=$'\037' read -r total distinct topn period repeats win_from win_to top block tailstr \
|
||||||
decisions refused blocked timeouts completed <<< "$analysis"
|
decisions refused blocked timeouts completed rewards <<< "$analysis"
|
||||||
total="${total:-0}"; distinct="${distinct:-0}"; topn="${topn:-0}"
|
total="${total:-0}"; distinct="${distinct:-0}"; topn="${topn:-0}"
|
||||||
period="${period:-0}"; repeats="${repeats:-0}"
|
period="${period:-0}"; repeats="${repeats:-0}"
|
||||||
decisions="${decisions:-0}"; refused="${refused:-0}"; blocked="${blocked:-0}"
|
decisions="${decisions:-0}"; refused="${refused:-0}"; blocked="${blocked:-0}"
|
||||||
timeouts="${timeouts:-0}"; completed="${completed:-0}"
|
timeouts="${timeouts:-0}"; completed="${completed:-0}"; rewards="${rewards:-0}"
|
||||||
echo "$distinct" > "${WD_RUN_DIR}/loop.distinct"
|
echo "$distinct" > "${WD_RUN_DIR}/loop.distinct"
|
||||||
echo "$period" > "${WD_RUN_DIR}/loop.period"
|
echo "$period" > "${WD_RUN_DIR}/loop.period"
|
||||||
echo "$repeats" > "${WD_RUN_DIR}/loop.repeats"
|
echo "$repeats" > "${WD_RUN_DIR}/loop.repeats"
|
||||||
echo "$refused" > "${WD_RUN_DIR}/loop.refused"
|
echo "$refused" > "${WD_RUN_DIR}/loop.refused"
|
||||||
echo "$(( blocked + timeouts ))" > "${WD_RUN_DIR}/loop.blocked"
|
echo "$(( blocked + timeouts ))" > "${WD_RUN_DIR}/loop.blocked"
|
||||||
echo "$completed" > "${WD_RUN_DIR}/loop.done"
|
echo "$completed" > "${WD_RUN_DIR}/loop.done"
|
||||||
|
echo "$rewards" > "${WD_RUN_DIR}/loop.rewards"
|
||||||
|
|
||||||
local prev_suspected=0
|
local prev_suspected=0
|
||||||
[ -f "${WD_RUN_DIR}/loop.suspected" ] && prev_suspected="$(cat "${WD_RUN_DIR}/loop.suspected" 2>/dev/null || echo 0)"
|
[ -f "${WD_RUN_DIR}/loop.suspected" ] && prev_suspected="$(cat "${WD_RUN_DIR}/loop.suspected" 2>/dev/null || echo 0)"
|
||||||
|
|
@ -967,6 +1014,14 @@ check_loop() {
|
||||||
[ "$decisions" -gt 0 ] && [ "$completed" -eq 0 ] && [ "$grown" -eq 0 ] && idle=1
|
[ "$decisions" -gt 0 ] && [ "$completed" -eq 0 ] && [ "$grown" -eq 0 ] && idle=1
|
||||||
echo "$idle" > "$idle_file"
|
echo "$idle" > "$idle_file"
|
||||||
|
|
||||||
|
# Busy going nowhere (row 58), judged over two probes like zero progress:
|
||||||
|
# many decisions, not one reward event, no new ground.
|
||||||
|
local busy_file="${WD_RUN_DIR}/loop.prev_unrewarded" prev_busy=0 busy=0
|
||||||
|
[ -f "$busy_file" ] && prev_busy="$(cat "$busy_file" 2>/dev/null || echo 0)"
|
||||||
|
case "${prev_busy:-}" in ''|*[!0-9]*) prev_busy=0 ;; esac
|
||||||
|
[ "$decisions" -ge "$WD_LOOP_BUSY_MIN" ] && [ "$rewards" -eq 0 ] && [ "$grown" -eq 0 ] && busy=1
|
||||||
|
echo "$busy" > "$busy_file"
|
||||||
|
|
||||||
local failed=$(( refused + blocked + timeouts ))
|
local failed=$(( refused + blocked + timeouts ))
|
||||||
local suspected=0 reason=""
|
local suspected=0 reason=""
|
||||||
if [ "$decisions" -gt 0 ] && [ "$grown" -eq 0 ]; then
|
if [ "$decisions" -gt 0 ] && [ "$grown" -eq 0 ]; then
|
||||||
|
|
@ -991,6 +1046,11 @@ check_loop() {
|
||||||
# which is silence (above), not a loop.
|
# which is silence (above), not a loop.
|
||||||
suspected=1
|
suspected=1
|
||||||
reason="dominant"
|
reason="dominant"
|
||||||
|
elif [ "$busy" -eq 1 ] && [ "$prev_busy" -eq 1 ]; then
|
||||||
|
# Row 58: an undo pair diluted by other macros. Every one of them
|
||||||
|
# completes, none of them earns anything, and the ground stays put.
|
||||||
|
suspected=1
|
||||||
|
reason="unrewarded"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
echo "$suspected" > "${WD_RUN_DIR}/loop.suspected"
|
echo "$suspected" > "${WD_RUN_DIR}/loop.suspected"
|
||||||
|
|
@ -1022,6 +1082,7 @@ check_loop() {
|
||||||
--argjson blocked "$blocked" \
|
--argjson blocked "$blocked" \
|
||||||
--argjson timeouts "$timeouts" \
|
--argjson timeouts "$timeouts" \
|
||||||
--argjson completed "$completed" \
|
--argjson completed "$completed" \
|
||||||
|
--argjson rewards "$rewards" \
|
||||||
--argjson windowFrom "${win_from:-0}" \
|
--argjson windowFrom "${win_from:-0}" \
|
||||||
--argjson windowTo "${win_to:-0}" \
|
--argjson windowTo "${win_to:-0}" \
|
||||||
--argjson windowMs "$WD_LOOP_WINDOW_MS" \
|
--argjson windowMs "$WD_LOOP_WINDOW_MS" \
|
||||||
|
|
@ -1050,6 +1111,7 @@ check_loop() {
|
||||||
macroStarts: $total,
|
macroStarts: $total,
|
||||||
decisions: $decisions,
|
decisions: $decisions,
|
||||||
outcomes: { done: $completed, blocked: $blocked, timeout: $timeouts, refused: $refused },
|
outcomes: { done: $completed, blocked: $blocked, timeout: $timeouts, refused: $refused },
|
||||||
|
rewards: $rewards,
|
||||||
tail: (if $tail == "" then [] else ($tail | split(", ")) end)
|
tail: (if $tail == "" then [] else ($tail | split(", ")) end)
|
||||||
},
|
},
|
||||||
dominant: { name: (if $dominant == "" then null else $dominant end),
|
dominant: { name: (if $dominant == "" then null else $dominant end),
|
||||||
|
|
@ -1073,7 +1135,9 @@ check_loop() {
|
||||||
|
|
||||||
local mins=$(( WD_LOOP_WINDOW_MS / 60000 ))
|
local mins=$(( WD_LOOP_WINDOW_MS / 60000 ))
|
||||||
if [ "$suspected" -eq 1 ]; then
|
if [ "$suspected" -eq 1 ]; then
|
||||||
if [ "$reason" = "stalled" ] || [ "$reason" = "zero-progress" ]; then
|
if [ "$reason" = "unrewarded" ]; then
|
||||||
|
log_err "loop suspected (unrewarded): ${decisions} decisions and no reward event in the last ${mins} brain minutes, two probes running — ${completed} done, top [${top}] x${topn}, ${distinct} distinct name(s), tail [${tailstr}], exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched — macros that complete and earn nothing are an undo pair or a ring. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'."
|
||||||
|
elif [ "$reason" = "stalled" ] || [ "$reason" = "zero-progress" ]; then
|
||||||
log_err "loop suspected (${reason}): [${shown}] — ${decisions} decisions in the last ${mins} brain minutes, ${total} started, ${completed} done, ${refused} refused, $(( blocked + timeouts )) blocked or timed out, exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched — a pad whose buttons cannot run is a macro bug. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'."
|
log_err "loop suspected (${reason}): [${shown}] — ${decisions} decisions in the last ${mins} brain minutes, ${total} started, ${completed} done, ${refused} refused, $(( blocked + timeouts )) blocked or timed out, exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched — a pad whose buttons cannot run is a macro bug. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'."
|
||||||
elif [ "$reason" = "sequence" ]; then
|
elif [ "$reason" = "sequence" ]; then
|
||||||
log_err "loop suspected: [${shown}] x${repeats} (period ${period}) in the last ${mins} brain minutes — ${total} macro starts, ${distinct} distinct name(s), exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched — a loop is a macro target-choice bug and a bounce would only restore the same loop. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'."
|
log_err "loop suspected: [${shown}] x${repeats} (period ${period}) in the last ${mins} brain minutes — ${total} macro starts, ${distinct} distinct name(s), exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched — a loop is a macro target-choice bug and a bounce would only restore the same loop. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'."
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,12 @@ log "building in $crate_dir for target-cpu=haswell (the host is E5-2660 v3, Hasw
|
||||||
(
|
(
|
||||||
cd "$crate_dir"
|
cd "$crate_dir"
|
||||||
RUSTFLAGS="-C target-cpu=haswell" cargo build --release --target "$CARGO_TARGET" --bin flysim "${features_args[@]}"
|
RUSTFLAGS="-C target-cpu=haswell" cargo build --release --target "$CARGO_TARGET" --bin flysim "${features_args[@]}"
|
||||||
|
# fly-edge (FLY_FEED_VIA=bus, docs/design/flybus.md): the feed WebSocket
|
||||||
|
# served from flysim's feed bus. Small, and no cargo features of its own;
|
||||||
|
# built every time so a release can switch a container onto the bus
|
||||||
|
# without a rebuild. It lands next to OUT_PATH, where package-release.sh
|
||||||
|
# looks for it.
|
||||||
|
RUSTFLAGS="-C target-cpu=haswell" cargo build --release --target "$CARGO_TARGET" --bin fly-edge
|
||||||
)
|
)
|
||||||
|
|
||||||
built="${crate_dir}/target/${CARGO_TARGET}/release/flysim"
|
built="${crate_dir}/target/${CARGO_TARGET}/release/flysim"
|
||||||
|
|
@ -106,4 +112,10 @@ fi
|
||||||
cp "$built" "$OUT_PATH"
|
cp "$built" "$OUT_PATH"
|
||||||
chmod 0755 "$OUT_PATH"
|
chmod 0755 "$OUT_PATH"
|
||||||
log "built $OUT_PATH ($(du -h "$OUT_PATH" | cut -f1))"
|
log "built $OUT_PATH ($(du -h "$OUT_PATH" | cut -f1))"
|
||||||
|
edge_built="${crate_dir}/target/${CARGO_TARGET}/release/fly-edge"
|
||||||
|
[ -x "$edge_built" ] || die "expected binary not found after build: $edge_built"
|
||||||
|
edge_out="$(dirname "$OUT_PATH")/fly-edge"
|
||||||
|
cp "$edge_built" "$edge_out"
|
||||||
|
chmod 0755 "$edge_out"
|
||||||
|
log "built $edge_out ($(du -h "$edge_out" | cut -f1))"
|
||||||
log "next: infra/build/package-release.sh VERSION $OUT_PATH <stage-dir> <bridge-dir> <out-dir>"
|
log "next: infra/build/package-release.sh VERSION $OUT_PATH <stage-dir> <bridge-dir> <out-dir>"
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@
|
||||||
#
|
#
|
||||||
# Output: OUT_DIR/flybrain-<version>.tar.gz, laid out as
|
# Output: OUT_DIR/flybrain-<version>.tar.gz, laid out as
|
||||||
# flysim (the binary, mode 0755)
|
# flysim (the binary, mode 0755)
|
||||||
|
# fly-edge (the feed-bus edge, mode 0755, when build-flysim.sh
|
||||||
|
# left one beside FLYSIM_BIN; flyedge.service stays
|
||||||
|
# inactive on a release without it)
|
||||||
# stage/... (apps/stage's build output)
|
# stage/... (apps/stage's build output)
|
||||||
# bridge/... (services/bridge + node_modules)
|
# bridge/... (services/bridge + node_modules)
|
||||||
# data/fafb-v783/... (the connectome, from the repo; FLY_DATASET points here)
|
# data/fafb-v783/... (the connectome, from the repo; FLY_DATASET points here)
|
||||||
|
|
@ -72,6 +75,13 @@ mkdir -p "$release_dir"
|
||||||
|
|
||||||
cp "$FLYSIM_BIN" "${release_dir}/flysim"
|
cp "$FLYSIM_BIN" "${release_dir}/flysim"
|
||||||
chmod 0755 "${release_dir}/flysim"
|
chmod 0755 "${release_dir}/flysim"
|
||||||
|
EDGE_BIN="$(dirname "$FLYSIM_BIN")/fly-edge"
|
||||||
|
if [ -x "$EDGE_BIN" ]; then
|
||||||
|
cp "$EDGE_BIN" "${release_dir}/fly-edge"
|
||||||
|
chmod 0755 "${release_dir}/fly-edge"
|
||||||
|
else
|
||||||
|
log "no fly-edge beside $FLYSIM_BIN; packaging without it (FLY_FEED_VIA=bus unavailable in this release)"
|
||||||
|
fi
|
||||||
cp -a "$STAGE_DIR" "${release_dir}/stage"
|
cp -a "$STAGE_DIR" "${release_dir}/stage"
|
||||||
cp -a "$BRIDGE_DIR" "${release_dir}/bridge"
|
cp -a "$BRIDGE_DIR" "${release_dir}/bridge"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ d /run/fly 0750 fly fly -
|
||||||
d /run/fly/pulse 0750 fly fly -
|
d /run/fly/pulse 0750 fly fly -
|
||||||
d /run/fly/state 0750 fly fly -
|
d /run/fly/state 0750 fly fly -
|
||||||
d /run/fly/wd 0750 fly fly -
|
d /run/fly/wd 0750 fly fly -
|
||||||
|
# ADDED: the feed bus (FLY_FEED_VIA=bus, docs/design/flybus.md): flysim's
|
||||||
|
# router socket and artifact store. flysim also creates it, 0700, on start.
|
||||||
|
d /run/fly/bus 0700 fly fly -
|
||||||
d /var/lib/fly 0750 fly fly -
|
d /var/lib/fly 0750 fly fly -
|
||||||
d /var/lib/fly/chrome 0700 fly fly -
|
d /var/lib/fly/chrome 0700 fly fly -
|
||||||
d /srv/fly/state 0750 fly fly -
|
d /srv/fly/state 0750 fly fly -
|
||||||
|
|
|
||||||
|
|
@ -2655,3 +2655,107 @@ the fly is no longer held in the pocket.
|
||||||
- `infra/tests/lint.sh`: ALL CHECKS PASSED, the two new check-10 cases and the de-PII guard included.
|
- `infra/tests/lint.sh`: ALL CHECKS PASSED, the two new check-10 cases and the de-PII guard included.
|
||||||
- `flysim --print-compatibility`: **648 bytes, sha256 `4929f340...9ebd9`** -- byte-identical to the
|
- `flysim --print-compatibility`: **648 bytes, sha256 `4929f340...9ebd9`** -- byte-identical to the
|
||||||
base `d5d9249`. Decoder, reward catalog, adapter version and roles untouched.
|
base `d5d9249`. Decoder, reward catalog, adapter version and roles untouched.
|
||||||
|
|
||||||
|
## 2026-09-23, row 58: the gym's door, in and out
|
||||||
|
|
||||||
|
### What was live
|
||||||
|
|
||||||
|
Map 2 (Pewter City) and map 54 (Pewter Gym), rank 10, v0.5.3, for twenty-five minutes: `GO OBJECTIVE`
|
||||||
|
`done` into the gym, `GO OUT` `done` straight back out, `GO ITEM` / `GO FRONTIER` / `YES` / `NO`
|
||||||
|
mixed in. Per ten brain minutes about 93 `GO OUT`, 47 `GO OBJECTIVE`, 200 starts, 0-5 refused or
|
||||||
|
blocked, **no reward event of any kind**, `uniqueLocations` frozen at 1,892. `GO OUT` often started
|
||||||
|
and finished inside 0.05-0.17 s. Check 10 saw ten distinct names and never flagged.
|
||||||
|
|
||||||
|
### The survey: the room on the Nth arrival
|
||||||
|
|
||||||
|
`FLY_PROBE_CATCH=route` drives the real palette from the checkpoint; `FLY_PROBE_CATCH_MAP=54`
|
||||||
|
stops it forty frames into the fly's Nth arrival on the gym and dumps the room with every person's
|
||||||
|
ledger entries. From the bare checkpoint, preferring `GO OBJECTIVE`, the base walks the pair
|
||||||
|
itself inside seven brain minutes: **`GO OUT` 1,118, `GO OBJECTIVE` 583 in 33 brain minutes**, the
|
||||||
|
gym pad `["GO OUT"]`, and on the doormat:
|
||||||
|
|
||||||
|
- `objective_targets` empty, `person_targets` = the guide at (7, 10), talked;
|
||||||
|
- BROCK at (4, 1) and the Jr. Trainer at (3, 6) **absent**: `CheckSpriteAvailability` had written
|
||||||
|
`$ff` into their image index because they are outside the window of (4, 13);
|
||||||
|
- pushed tiles `(54, (16, 17))` and `(2, (5, 13))` -- Pewter's gym door and the gym's doormat,
|
||||||
|
recorded under the *other* map's id.
|
||||||
|
|
||||||
|
### Why nothing inside the gym was offered, and why the door was
|
||||||
|
|
||||||
|
| # | trap | trigger | test | fix, or why it is left |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 58 | the rung's list of people is read from the sprites the cartridge draws, so a person off the screen is not in the room; with the one drawn person talked to, `GO OBJECTIVE` has nothing inside, 12.5 lets `GO OUT` onto the pad, and outside `GO OBJECTIVE` aims at the door | any rung earned by a person who is more than four rows or five columns from where the fly arrives; the Pewter Gym from its doormat | `the_rungs_people_are_in_the_room_when_the_screen_does_not_show_them`, `only_the_rung_reads_people_off_the_screen`, `a_sprite_the_cartridge_hides_off_the_screen_is_still_on_the_map`, `the_gym_is_not_a_door_in_and_a_door_out_from_the_rung_ten_checkpoint` (ROM) | **fixed**: `state::offscreen_npcs` reports a `$ff` sprite whose coordinates fall outside `CheckSpriteAvailability`'s own window (movement byte `WALK` or above), and only the rung's list reads it. `docs/design/macros.md` 12.22, `macros-wram.md` section 12 |
|
||||||
|
| 58b | in front of one of the rung's people, `GO OBJECTIVE` still walks to another | a room with more than one of them: the gym's leader and trainer | `facing_one_of_the_rungs_people_is_the_arrival` | **fixed**: facing any of them is the arrival, and `TALK` is the press |
|
||||||
|
| 58c | a warp's tear -- `wCurMap` changed, header, coordinates and warp table not yet -- reads as a controllable overworld for thirty-two frames; a pad is dealt, a walk plans over the wrong map, and its target and tile go into the ledgers under the new map's id | every warp; live, `GO OUT` started and finished in 0.05 s | `a_warps_tear_deals_no_pad`, `a_teleport_pad_is_not_a_tear` | **fixed** in the driver: the map byte changed and the fly still stands on a loaded warp into the map the byte names (a doormat's `LAST_MAP` under a town's id included) is `Unknown` with an empty pad, bounded at ninety frames. Teleport pads never change the map byte |
|
||||||
|
| 58d | the 219 frames of a battle transition read as a controllable overworld: a walk toward the leader presses into the animation and blocks him for ten brain minutes, and the trainer's conversation reads as over, so a trainer the fly then loses to is "talked to" for the session | every trainer battle, and every wild one | `a_battle_decided_and_not_yet_begun_is_the_cartridges` | **fixed**: `controllable` reads `wCurOpponent`, set when a battle is decided and cleared by `EndOfBattle`; derived as `wBattleType - 1`, both neighbours asserted |
|
||||||
|
| 58e | a trainer walking up to the fly is read as the cartridge refusing the step (12.4): the target is blocked and the tile pushed on the spot | every trainer's line of sight a walk crosses | `a_trainer_walking_up_teaches_the_ledgers_nothing`, `a_scripted_push_back_records_the_tile_it_happened_on`, `a_walk_the_cartridge_pushes_back_excludes_what_it_was_walking_to` | **fixed**: the entries wait for the cartridge to give the joypad back; the overworld is a refusal, written as before, and a battle writes nothing |
|
||||||
|
| 58f | check 10 cannot see an undo pair diluted by other names | ten distinct names, every macro `done`, 211 decisions in ten brain minutes | `lint.sh` check 10 cases 7 and 8 | **fixed**: `unrewarded`, 100+ decisions and no reward event on two probes with no new ground. Against the live row-58 log it flags; the rules before it did not |
|
||||||
|
|
||||||
|
### Before and after
|
||||||
|
|
||||||
|
The route survey, same checkpoint, 120,000 frames (33 brain minutes), base `main` at `174dc7e`
|
||||||
|
(row 57 merged) against this branch:
|
||||||
|
|
||||||
|
| measure | base | branch, `GO OBJECTIVE` preferred | branch, `TALK` preferred |
|
||||||
|
| --- | ---: | ---: | ---: |
|
||||||
|
| `GO OUT` done | **1,118** | **0** | **0** |
|
||||||
|
| `GO OBJECTIVE` done on the gym | **583** (all maps) | **4** | **4** |
|
||||||
|
| rung at the end | 10 | **11, BOULDER BADGE** | **11, BOULDER BADGE** |
|
||||||
|
| pushed tiles under the wrong map's id | `(54, (16, 17))`, `(2, (5, 13))` and four more doormats | none | none |
|
||||||
|
|
||||||
|
The ROM-gated run, `the_gym_is_not_a_door_in_and_a_door_out_from_the_rung_ten_checkpoint`, 30.1
|
||||||
|
brain minutes on the stub rotation: the base goes through the door once, is back out in 309
|
||||||
|
frames and never above row 11 (fails); the branch goes through four times, walks straight back out
|
||||||
|
once, spends 30,879 frames in the gym and stands on row 2 beside BROCK (passes). Row 56's
|
||||||
|
`the_fly_leaves_the_pewter_gym_guides_ring_from_the_rung_ten_checkpoint` now reaches rung 11 at
|
||||||
|
8.45 brain minutes, which it never did; row 57's pocket test passes, and its part one no longer
|
||||||
|
walls `(54, (16, 17))`, the tear's tile.
|
||||||
|
|
||||||
|
The trap hunt, 30 brain minutes each from the same checkpoint and seed, the stub rotation
|
||||||
|
(`FLY_TRAP_STUB=1`; the brain is stepped and the readout replaced), base `174dc7e` against this
|
||||||
|
branch:
|
||||||
|
|
||||||
|
| measure | base | branch |
|
||||||
|
| --- | ---: | ---: |
|
||||||
|
| distinct (map, tile) | 329 | **437** |
|
||||||
|
| windows flagged | 17 | 23 |
|
||||||
|
| macros started / done | 139 / 137 | 131 / 129 |
|
||||||
|
| `GO OUT` done | 3 | 0 |
|
||||||
|
| frames between battle turns | 51,328 | 53,336 |
|
||||||
|
| rung reached | 10 | 10 |
|
||||||
|
|
||||||
|
**The stub does not walk the ring on either arm** -- it spends half of both runs in battles and
|
||||||
|
goes through the gym's door once -- so the hunt says little about this row either way, and the
|
||||||
|
flagged-window count rises (17 -> 23) on battle time, the same judgement as rows 50 and 56. The
|
||||||
|
route survey above is the reproduction; the hunt is reported, not smoothed.
|
||||||
|
|
||||||
|
### Residuals, named rather than worked around
|
||||||
|
|
||||||
|
- **After the badge, a new pair at the Pewter/Route 3 edge.** With the rung earned, the objective
|
||||||
|
is Mt. Moon (map 59), and on Route 3 `GO OBJECTIVE` has nothing to aim at: `geography` carries
|
||||||
|
Route 3's neighbour as Route 4 to the **east** and a Mt. Moon door **on Route 3**, while the
|
||||||
|
disassembly (`data/maps/headers/Route3.asm`, `objects/Route4.asm`) and the cartridge
|
||||||
|
(`wCurMapConnections` north and west, row 54b) say Route 4 is **north** and Mt. Moon's doors
|
||||||
|
are **on Route 4**, whose ground is in two pieces like Route 2's. The survey that prefers
|
||||||
|
`GO OBJECTIVE` walks `GO OBJECTIVE` east into Route 3 and `GO ROUTE` back west 538 times from
|
||||||
|
21 brain minutes. It is row 54b's residual and it needs a `SPLIT` row for Route 4; the next
|
||||||
|
brief. Check 10's new `unrewarded` rule sees it.
|
||||||
|
- **A toggleable object switched off reads as present from outside the window.** Only the rung's
|
||||||
|
own list reads the off-screen people; among the ladder's person places, only Oak's lab (behind
|
||||||
|
this run) and Viridian Gym carry toggleable people.
|
||||||
|
- **The tear is read stateful and bounded**: an arrival onto a warp into the map it arrived on,
|
||||||
|
under a map byte that changed, is a tear for at most ninety frames.
|
||||||
|
- **The real-brain hunt was not run to the end on this branch**: two 30-minute arms were started
|
||||||
|
and stopped at a quarter done when row 57 merged and the branch was rebased; the box was loaded
|
||||||
|
at twelve. The stub arms above are the hunt.
|
||||||
|
|
||||||
|
### Gates
|
||||||
|
|
||||||
|
- `cargo test --workspace` with `FLY_ROM` and `FLY_DATASET`: 1,267 passed, 1 failed --
|
||||||
|
`flysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed`,
|
||||||
|
the known boot-time failure, identical on the base.
|
||||||
|
- `cargo clippy --all-targets`: **0 warnings**.
|
||||||
|
- `npm test` 663 passed; `npm run typecheck` clean.
|
||||||
|
- `infra/tests/lint.sh`: ALL CHECKS PASSED, check 10's two new cases and the de-PII guard included.
|
||||||
|
- `flysim --print-compatibility`: **648 bytes, sha256 `4929f340...9ebd9`**, byte-identical to the
|
||||||
|
base. Decoder, reward catalog, adapter version and roles untouched.
|
||||||
|
|
|
||||||
|
|
@ -602,11 +602,14 @@ pct exec <ctid> -- cat /run/fly/wd/loop.json | jq .
|
||||||
| `fly_loop_refused` | macro presses refused in the window: a bound button pressed, nothing run |
|
| `fly_loop_refused` | macro presses refused in the window: a bound button pressed, nothing run |
|
||||||
| `fly_loop_blocked` | macros that ended `blocked` or `timeout` in the window |
|
| `fly_loop_blocked` | macros that ended `blocked` or `timeout` in the window |
|
||||||
| `fly_loop_done` | macros that ended `done` in the window |
|
| `fly_loop_done` | macros that ended `done` in the window |
|
||||||
|
| `fly_loop_rewards` | reward events in the window |
|
||||||
|
|
||||||
The flag needs **both** halves: at most 3 distinct macro names with the block repeating 20+
|
The flag needs **both** halves: at most 3 distinct macro names with the block repeating 20+
|
||||||
times, one macro at 95%+ of the window's decisions, 90%+ of 20+ decisions ending refused,
|
times, one macro at 95%+ of the window's decisions, 90%+ of 20+ decisions ending refused,
|
||||||
blocked or timed out (`stalled`), or decisions with no `done` among them on two probes in a row
|
blocked or timed out (`stalled`), or decisions with no `done` among them on two probes in a row
|
||||||
(`zero-progress`) — **and** no growth in the exploration count. A decision is a `start` or a
|
(`zero-progress`), or 100+ decisions with no reward event among them on two probes in a row
|
||||||
|
(`unrewarded`, row 58: `GO OBJECTIVE` in and `GO OUT` out of one door, diluted by eight other
|
||||||
|
names, every macro `done`) — **and** no growth in the exploration count. A decision is a `start` or a
|
||||||
`refused`: a refused press starts nothing, which is why counting starts alone read row 57's
|
`refused`: a refused press starts nothing, which is why counting starts alone read row 57's
|
||||||
pad (`GO ROUTE refused` ~740 times in ten brain minutes, `macros-traps.md`) as one start and
|
pad (`GO ROUTE refused` ~740 times in ten brain minutes, `macros-traps.md`) as one start and
|
||||||
one name. A
|
one name. A
|
||||||
|
|
|
||||||
7
infra/env/example.env
vendored
7
infra/env/example.env
vendored
|
|
@ -312,6 +312,13 @@ CHAT_DENY_LIST=/srv/fly/chat-deny.txt
|
||||||
# "palette" and "plan" are the two modes section 12 replaced; flysim still reads
|
# "palette" and "plan" are the two modes section 12 replaced; flysim still reads
|
||||||
# either as "macros", with a warning, for one release.
|
# either as "macros", with a warning, for one release.
|
||||||
FLY_MACRO_MODE=raw
|
FLY_MACRO_MODE=raw
|
||||||
|
# --- feed path ----------------------------------------------------------------
|
||||||
|
# Who serves ws://127.0.0.1:7400/feed (docs/design/flybus.md, "Feed over the
|
||||||
|
# bus"). direct: flysim binds it, as always. bus: flysim publishes every
|
||||||
|
# snapshot on its embedded feed bus (/run/fly/bus) and flyedge.service serves
|
||||||
|
# the same bytes; enable that unit by hand (its header has the steps).
|
||||||
|
# Watchdog check 2 follows this setting to the edge's counters by itself.
|
||||||
|
FLY_FEED_VIA=direct
|
||||||
# How long a macro leaves a target alone after a walk to it aborted "blocked" or
|
# How long a macro leaves a target alone after a walk to it aborted "blocked" or
|
||||||
# "timeout" (macros.md section 12.1). Session state, so a restart offers every
|
# "timeout" (macros.md section 12.1). Session state, so a restart offers every
|
||||||
# target once more. Unset means the default, 10.
|
# target once more. Unset means the default, 10.
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,20 @@ require_release_tag() {
|
||||||
# (An earlier, eight-cpu version of this same live hotfix — CPUSET=
|
# (An earlier, eight-cpu version of this same live hotfix — CPUSET=
|
||||||
# 1,3,5,7,9,11,13,15, ENCODER_CORES=2 (the default) — gave flysim=1,3,5,7,
|
# 1,3,5,7,9,11,13,15, ENCODER_CORES=2 (the default) — gave flysim=1,3,5,7,
|
||||||
# page=9,11, flycast=13,15; infra/tests/lint.sh checks both shapes.)
|
# page=9,11, flycast=13,15; infra/tests/lint.sh checks both shapes.)
|
||||||
|
# feed_via_normalize VALUE — print FLY_FEED_VIA lowercased (empty means "direct"), or
|
||||||
|
# return 1 for anything but direct|bus. flysim itself reads the value case-insensitively
|
||||||
|
# and refuses anything else at boot, which on a container is a restart loop; 05-deploy.sh
|
||||||
|
# refuses it at deploy instead and writes the lowercased word, so watchdog check 2 and
|
||||||
|
# flysim can never read the same line two ways (docs/design/flybus.md).
|
||||||
|
feed_via_normalize() {
|
||||||
|
local via
|
||||||
|
via="$(printf '%s' "${1:-direct}" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
case "$via" in
|
||||||
|
direct|bus) printf '%s\n' "$via" ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
cpuset_partition() {
|
cpuset_partition() {
|
||||||
local cpuset="$1" rayon_threads="$2" encoder_cores="${3:-2}"
|
local cpuset="$1" rayon_threads="$2" encoder_cores="${3:-2}"
|
||||||
local sim_cpus remainder remainder_count page_count page_cpus encoder_cpus
|
local sim_cpus remainder remainder_count page_count page_cpus encoder_cpus
|
||||||
|
|
|
||||||
|
|
@ -429,6 +429,140 @@ else
|
||||||
fi
|
fi
|
||||||
rm -rf "$lint_tmp"
|
rm -rf "$lint_tmp"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3b2. The feed bus edge (docs/design/flybus.md, "Feed over the bus").
|
||||||
|
#
|
||||||
|
# flyedge.service is off unless the operator switches a container to
|
||||||
|
# FLY_FEED_VIA=bus by hand, and when it is on it must follow flysim, which
|
||||||
|
# owns the router. What would break that is statically visible: the unit
|
||||||
|
# ending up in fly.target or 07-enable's list, losing its ordering on
|
||||||
|
# flysim, or the deploy no longer writing the default. Watchdog check 2's
|
||||||
|
# choice of /metrics is driven for real against a fixture fly.env.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo "--- flyedge.service: off by default, after and bound to flysim ---"
|
||||||
|
EDGE_UNIT="$INFRA_DIR/units/flyedge.service"
|
||||||
|
if [ ! -f "$EDGE_UNIT" ]; then
|
||||||
|
fail "units/flyedge.service is missing"
|
||||||
|
else
|
||||||
|
grep -qE '^After=.*\bflysim\.service\b' "$EDGE_UNIT" \
|
||||||
|
&& pass "flyedge.service orders itself After=flysim.service" \
|
||||||
|
|| fail "flyedge.service must be After=flysim.service: flysim owns the feed router"
|
||||||
|
grep -qE '^Requires=.*\bflysim\.service\b' "$EDGE_UNIT" \
|
||||||
|
&& pass "flyedge.service Requires=flysim.service" \
|
||||||
|
|| fail "flyedge.service must Require flysim.service, so a stop or restart of flysim takes the edge with it"
|
||||||
|
grep -qE '^ExecStart=/opt/fly/current/fly-edge$' "$EDGE_UNIT" \
|
||||||
|
&& pass "flyedge.service runs the release's fly-edge" \
|
||||||
|
|| fail "flyedge.service ExecStart must be /opt/fly/current/fly-edge"
|
||||||
|
grep -qE '^ConditionPathExists=/opt/fly/current/fly-edge$' "$EDGE_UNIT" \
|
||||||
|
&& pass "flyedge.service stays inactive on a release without fly-edge" \
|
||||||
|
|| fail "flyedge.service needs ConditionPathExists=/opt/fly/current/fly-edge (a release before it has none)"
|
||||||
|
grep -qE '^Environment=FLY_EDGE_METRICS_ADDR=127\.0\.0\.1:' "$EDGE_UNIT" \
|
||||||
|
&& pass "flyedge.service keeps its metrics on loopback" \
|
||||||
|
|| fail "flyedge.service FLY_EDGE_METRICS_ADDR must be a 127.0.0.1 address"
|
||||||
|
fi
|
||||||
|
# Every unit a target's Wants=/Requires= names, with backslash continuations joined and
|
||||||
|
# comments dropped: fly.target spreads both lists over several physical lines, and the
|
||||||
|
# continuation line is exactly where a new unit would be added.
|
||||||
|
target_pulls() {
|
||||||
|
awk '
|
||||||
|
/^[[:space:]]*[#;]/ { next }
|
||||||
|
{
|
||||||
|
line = $0
|
||||||
|
cont = sub(/\\[[:space:]]*$/, "", line)
|
||||||
|
buf = buf line
|
||||||
|
if (cont) next
|
||||||
|
if (buf ~ /^[[:space:]]*(Wants|Requires)=/) { sub(/^[^=]*=/, "", buf); print buf }
|
||||||
|
buf = ""
|
||||||
|
}
|
||||||
|
' "$1" | tr -s ' \t' '\n' | grep -v '^$' || true
|
||||||
|
}
|
||||||
|
if target_pulls "$INFRA_DIR/units/fly.target" | grep -qx 'flyedge.service'; then
|
||||||
|
fail "fly.target pulls flyedge.service in; it must stay off until the operator enables it"
|
||||||
|
else
|
||||||
|
pass "fly.target does not pull flyedge.service in"
|
||||||
|
fi
|
||||||
|
# The parser itself: a unit named only on a continuation line must be found, a commented one
|
||||||
|
# must not, and the real fly.target must still yield flysim.service.
|
||||||
|
tp_fixture="$(mktemp "${TMPDIR:-/tmp}/fly-lint-target.XXXXXX")"
|
||||||
|
cat > "$tp_fixture" <<'TPTARGET'
|
||||||
|
[Unit]
|
||||||
|
Wants=network-online.target xvfb.service \
|
||||||
|
flysim.service flyedge.service
|
||||||
|
# Requires=commented.service
|
||||||
|
Requires=xvfb.service \
|
||||||
|
pulse.service
|
||||||
|
TPTARGET
|
||||||
|
tp_units="$(target_pulls "$tp_fixture")"
|
||||||
|
if printf '%s\n' "$tp_units" | grep -qx 'flyedge.service' \
|
||||||
|
&& printf '%s\n' "$tp_units" | grep -qx 'pulse.service' \
|
||||||
|
&& ! printf '%s\n' "$tp_units" | grep -qx 'commented.service' \
|
||||||
|
&& target_pulls "$INFRA_DIR/units/fly.target" | grep -qx 'flysim.service'; then
|
||||||
|
pass "target_pulls reads continuation lines and skips comments (fixture + fly.target)"
|
||||||
|
else
|
||||||
|
fail "target_pulls missed a continuation line or read a comment: $(echo "$tp_units" | tr '\n' ' ')"
|
||||||
|
fi
|
||||||
|
rm -f "$tp_fixture"
|
||||||
|
if grep -E '^(ALWAYS_ON_UNITS|APP_UNITS)=' "$INFRA_DIR/07-enable.sh" "$INFRA_DIR/verify.sh" | grep -q 'flyedge'; then
|
||||||
|
fail "07-enable.sh or verify.sh lists flyedge.service as always-on"
|
||||||
|
else
|
||||||
|
pass "07-enable.sh and verify.sh leave flyedge.service alone"
|
||||||
|
fi
|
||||||
|
if grep -qF 'FLY_FEED_VIA_EFFECTIVE="$(feed_via_normalize "${FLY_FEED_VIA:-}")"' "$INFRA_DIR/05-deploy.sh" \
|
||||||
|
&& grep -qF 'echo "FLY_FEED_VIA=${FLY_FEED_VIA_EFFECTIVE}"' "$INFRA_DIR/05-deploy.sh"; then
|
||||||
|
pass "05-deploy.sh validates FLY_FEED_VIA and writes the normalized value"
|
||||||
|
else
|
||||||
|
fail "05-deploy.sh must run FLY_FEED_VIA through feed_via_normalize and write FLY_FEED_VIA_EFFECTIVE"
|
||||||
|
fi
|
||||||
|
# shellcheck source=../lib/common.sh
|
||||||
|
fv_out="$(bash -c '. "$1/lib/common.sh"
|
||||||
|
for v in "" direct DIRECT bus Bus BUS; do printf "%s=%s " "${v:-empty}" "$(feed_via_normalize "$v")"; done
|
||||||
|
for v in buss "bus " direct,bus; do feed_via_normalize "$v" >/dev/null && printf "ACCEPTED:%s " "$v"; done; true' _ "$INFRA_DIR" 2>&1)"
|
||||||
|
if [ "$fv_out" = "empty=direct direct=direct DIRECT=direct bus=bus Bus=bus BUS=bus " ]; then
|
||||||
|
pass "feed_via_normalize: direct|bus in any case, empty is direct, anything else refused"
|
||||||
|
else
|
||||||
|
fail "feed_via_normalize: got '$fv_out'"
|
||||||
|
fi
|
||||||
|
if grep -qE '^[[:space:]]*for u in flysim .*\bflyedge\b.*; do$' "$INFRA_DIR/05-deploy.sh"; then
|
||||||
|
pass "05-deploy.sh writes a cpuset drop-in for flyedge.service"
|
||||||
|
else
|
||||||
|
fail "05-deploy.sh cpuset loop must include flyedge (the page's CPUs, never flysim's)"
|
||||||
|
fi
|
||||||
|
if grep -qE '^Environment=FLY_FEED_VIA' "$INFRA_DIR/units/flysim.service"; then
|
||||||
|
fail "flysim.service pins FLY_FEED_VIA; it belongs to fly.env so a box can be switched by deploy"
|
||||||
|
else
|
||||||
|
pass "flysim.service leaves FLY_FEED_VIA to fly.env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- fly-watchdog check 2: the feed counters follow FLY_FEED_VIA ---"
|
||||||
|
if ! tail -n1 "$INFRA_DIR/bin/fly-watchdog" | grep -qE '^main "\$@"$'; then
|
||||||
|
fail "fly-watchdog: expected the last line to be 'main \"\$@\"' — the check-2 fixture strips it"
|
||||||
|
else
|
||||||
|
fe_fixture="$(mktemp -d "${TMPDIR:-/tmp}/fly-lint-edge.XXXXXX")"
|
||||||
|
sed '$d' "$INFRA_DIR/bin/fly-watchdog" > "$fe_fixture/wd.sh"
|
||||||
|
feed_url_case() {
|
||||||
|
local label="$1" env_line="$2" override="$3" want="$4" got
|
||||||
|
printf '%s\n' "$env_line" > "$fe_fixture/fly.env"
|
||||||
|
got="$(FLY_ENV_FILE="$fe_fixture/fly.env" FLY_FEED_METRICS_URL="$override" \
|
||||||
|
FLY_METRICS_URL=http://sim FLY_EDGE_METRICS_URL=http://edge \
|
||||||
|
WD_RUN_DIR="$fe_fixture/run" WD_STATE_DIR="$fe_fixture/state" \
|
||||||
|
TEXTFILE_DIR="$fe_fixture/textfile" \
|
||||||
|
bash -c "source '$fe_fixture/wd.sh'; feed_metrics_url" 2>&1 || true)"
|
||||||
|
if [ "$got" = "$want" ]; then
|
||||||
|
pass "check 2 feed metrics: $label -> $got"
|
||||||
|
else
|
||||||
|
fail "check 2 feed metrics: $label: got '$got', want '$want'"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
feed_url_case "direct" "FLY_FEED_VIA=direct" "" "http://sim"
|
||||||
|
feed_url_case "no FLY_FEED_VIA line (a fly.env before it)" "FLY_GAME=pokemon-red" "" "http://sim"
|
||||||
|
feed_url_case "bus" "FLY_FEED_VIA=bus" "" "http://edge"
|
||||||
|
feed_url_case "Bus (flysim lowercases)" "FLY_FEED_VIA=Bus" "" "http://edge"
|
||||||
|
feed_url_case "BUS" "FLY_FEED_VIA=BUS" "" "http://edge"
|
||||||
|
feed_url_case "quoted bus" 'FLY_FEED_VIA="bus"' "" "http://edge"
|
||||||
|
feed_url_case "explicit override wins" "FLY_FEED_VIA=bus" "http://other" "http://other"
|
||||||
|
rm -rf "$fe_fixture"
|
||||||
|
fi
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 3c. lib/common.sh cpuset_partition — the three-way cpuset split used by
|
# 3c. lib/common.sh cpuset_partition — the three-way cpuset split used by
|
||||||
# 05-deploy.sh section 3b (flysim / page-capture / flycast). Run as its own
|
# 05-deploy.sh section 3b (flysim / page-capture / flycast). Run as its own
|
||||||
|
|
@ -1065,10 +1199,51 @@ LPCAT
|
||||||
fail "check 10: the zero-progress case gave first=${lp_first} then suspected=$(lp_metric fly_loop_suspected), journal: $(cat "$lp_fixture/journal.log")"
|
fail "check 10: the zero-progress case gave first=${lp_first} then suspected=$(lp_metric fly_loop_suspected), journal: $(cat "$lp_fixture/journal.log")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# (7) Row 58: the gym door, in and out. GO OBJECTIVE / GO OUT diluted by
|
||||||
|
# eight other names, every macro `done`, no reward event, no new ground --
|
||||||
|
# neither the four-name sequence rule, dominance nor zero-progress fires.
|
||||||
|
# Two probes of it flag; the same window with one reward in it does not.
|
||||||
|
lp_reset
|
||||||
|
lp_cycle 20 "GO OBJECTIVE" "GO OUT" "GO OUT" "GO ITEM" "GO OBJECTIVE" "GO OUT" \
|
||||||
|
"GO FRONTIER" "YES" "NO" "GO ROUTE" "NEXT" "TALK" "GO SHOP" \
|
||||||
|
| lp_outcomes "$lp_fixture/events.jsonl" "done"
|
||||||
|
lp_status "$lp_fixture/status.json" 1892
|
||||||
|
lp_pass
|
||||||
|
lp_pass
|
||||||
|
lp_first="$(lp_metric fly_loop_suspected)"
|
||||||
|
lp_pass
|
||||||
|
if [ "$lp_first" = "0" ] && [ "$(lp_metric fly_loop_suspected)" = "1" ] \
|
||||||
|
&& [ "$(lp_metric fly_loop_rewards)" = "0" ] \
|
||||||
|
&& [ "$(lp_metric fly_loop_distinct_macros)" = "10" ] \
|
||||||
|
&& grep -q 'loop suspected (unrewarded): 260 decisions and no reward event' "$lp_fixture/journal.log"; then
|
||||||
|
pass "check 10: an undo pair diluted by eight other names, all done, no reward over two probes flags as unrewarded"
|
||||||
|
else
|
||||||
|
fail "check 10: the row-58 log gave first=${lp_first} then suspected=$(lp_metric fly_loop_suspected) rewards=$(lp_metric fly_loop_rewards) distinct=$(lp_metric fly_loop_distinct_macros), journal: $(cat "$lp_fixture/journal.log")"
|
||||||
|
fi
|
||||||
|
if lp_report="$(jq -e -r '[.reason, (.window.decisions|tostring), (.window.rewards|tostring), .action] | join(" ")' "$lp_fixture/run/loop.json" 2>/dev/null)" \
|
||||||
|
&& [ "$lp_report" = "unrewarded 260 0 none" ]; then
|
||||||
|
pass "check 10: loop.json carries the reward events in the window"
|
||||||
|
else
|
||||||
|
fail "check 10: loop.json read back as '${lp_report:-UNREADABLE}' — expected 'unrewarded 260 0 none'"
|
||||||
|
fi
|
||||||
|
lp_reset
|
||||||
|
{ cat "$lp_fixture/events.jsonl"
|
||||||
|
printf '{"id":999998,"wallMs":1758000999998,"brainMs":150000,"kind":"reward","label":"WILD KO 54:1:1","value":0.1,"rewardKind":"wildwin"}\n'; } \
|
||||||
|
> "$lp_fixture/events-rewarded.jsonl"
|
||||||
|
mv -f "$lp_fixture/events-rewarded.jsonl" "$lp_fixture/events.jsonl"
|
||||||
|
lp_pass
|
||||||
|
lp_pass
|
||||||
|
lp_pass
|
||||||
|
if [ "$(lp_metric fly_loop_suspected)" = "0" ] && [ "$(lp_metric fly_loop_rewards)" = "1" ]; then
|
||||||
|
pass "check 10: the same busy window with one reward in it does not flag"
|
||||||
|
else
|
||||||
|
fail "check 10: a rewarded busy window gave suspected=$(lp_metric fly_loop_suspected) rewards=$(lp_metric fly_loop_rewards)"
|
||||||
|
fi
|
||||||
|
|
||||||
# The ethos, asserted rather than reviewed: over every case above, check 10
|
# The ethos, asserted rather than reviewed: over every case above, check 10
|
||||||
# restarted nothing. It reports; a human or a review agent decides.
|
# restarted nothing. It reports; a human or a review agent decides.
|
||||||
if [ ! -s "$lp_fixture/systemctl.log" ]; then
|
if [ ! -s "$lp_fixture/systemctl.log" ]; then
|
||||||
pass "check 10: never acts — no unit was restarted across any of the six cases"
|
pass "check 10: never acts — no unit was restarted across any of the eight cases"
|
||||||
else
|
else
|
||||||
fail "check 10 ACTED, which it must never do: $(cat "$lp_fixture/systemctl.log")"
|
fail "check 10 ACTED, which it must never do: $(cat "$lp_fixture/systemctl.log")"
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
56
infra/units/flyedge.service
Normal file
56
infra/units/flyedge.service
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
# infra/units/flyedge.service — pushed to /etc/systemd/system/flyedge.service.
|
||||||
|
#
|
||||||
|
# The feed WebSocket served from flysim's feed bus (docs/design/flybus.md,
|
||||||
|
# "Feed over the bus"; services/flysim/crates/fly-edge). DISABLED BY DEFAULT:
|
||||||
|
# it is in no target's Wants=/Requires= and 07-enable.sh does not enable it.
|
||||||
|
# With FLY_FEED_VIA=direct (the default, written into /etc/fly/fly.env by
|
||||||
|
# 05-deploy.sh) flysim binds 127.0.0.1:7400 itself and this unit has nothing
|
||||||
|
# to do. To move the feed onto the bus on one container:
|
||||||
|
#
|
||||||
|
# 1. FLY_FEED_VIA=bus in the env file, then 05-deploy.sh (rewrites fly.env);
|
||||||
|
# 2. systemctl enable --now flyedge.service; systemctl restart flysim.service
|
||||||
|
# (flysim stops binding :7400, the edge binds it once the first snapshot
|
||||||
|
# is on the bus);
|
||||||
|
# 3. nothing for the watchdog: check 2 reads FLY_FEED_VIA from fly.env and
|
||||||
|
# follows the feed counters to this unit's loopback /metrics.
|
||||||
|
#
|
||||||
|
# Back: FLY_FEED_VIA=direct, deploy, systemctl disable --now flyedge.service,
|
||||||
|
# restart flysim.
|
||||||
|
#
|
||||||
|
# Ordering (docs/design/flybus.md, amendment "Feed store lifecycle"): flysim
|
||||||
|
# owns the router and its store under /run/fly/bus, so it starts first and
|
||||||
|
# the edge follows it. Requires= makes an explicit stop or restart of flysim
|
||||||
|
# (the unstick rule's `systemctl restart flysim.service` included) stop or
|
||||||
|
# restart the edge with it. A crash-restart of flysim is covered by the edge
|
||||||
|
# itself: it drops its clients, unbinds :7400 and reconnects every 500 ms,
|
||||||
|
# so nothing here has to be restarted by hand. The edge holds no state; the
|
||||||
|
# store is flysim's and a new router removes the previous one's directory.
|
||||||
|
[Unit]
|
||||||
|
Description=flyedge: the feed WebSocket served from flysim's feed bus
|
||||||
|
After=flysim.service
|
||||||
|
Requires=flysim.service
|
||||||
|
# A release that predates fly-edge has no binary; stay cleanly inactive
|
||||||
|
# rather than restart-looping (the flybridge.service header explains why a
|
||||||
|
# Condition, not a start limit).
|
||||||
|
ConditionPathExists=/opt/fly/current/fly-edge
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=fly
|
||||||
|
# FLY_FEED_VIA, FLY_BUS_DIR and the rest of flysim's configuration: the edge
|
||||||
|
# reads the same file so the two cannot disagree about the port or the bus.
|
||||||
|
EnvironmentFile=/etc/fly/fly.env
|
||||||
|
Environment=FLY_FEED_BIND=127.0.0.1:7400
|
||||||
|
Environment=FLY_BUS_DIR=/run/fly/bus
|
||||||
|
# Its own read-only /metrics and /healthz, for watchdog check 2 in bus mode.
|
||||||
|
# Loopback only: nothing off the container needs the edge's counters.
|
||||||
|
Environment=FLY_EDGE_METRICS_ADDR=127.0.0.1:9102
|
||||||
|
ExecStart=/opt/fly/current/fly-edge
|
||||||
|
Restart=always
|
||||||
|
RestartSec=2
|
||||||
|
# A few snapshots in flight and a WebSocket per client; the store itself is
|
||||||
|
# flysim's (tmpfs, bounded by feedbus::limits at 32 MiB).
|
||||||
|
MemoryMax=256M
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=fly.target
|
||||||
|
|
@ -30,6 +30,10 @@ WatchdogSec=30
|
||||||
User=fly
|
User=fly
|
||||||
EnvironmentFile=/etc/fly/fly.env
|
EnvironmentFile=/etc/fly/fly.env
|
||||||
Environment=FLY_FEED_BIND=127.0.0.1:7400
|
Environment=FLY_FEED_BIND=127.0.0.1:7400
|
||||||
|
# Used only with FLY_FEED_VIA=bus (fly.env; default direct): the embedded
|
||||||
|
# feed router's socket and artifact store, on tmpfs. flyedge.service names
|
||||||
|
# the same directory. docs/design/flybus.md, "Feed over the bus".
|
||||||
|
Environment=FLY_BUS_DIR=/run/fly/bus
|
||||||
Environment=FLY_CONTROL_BIND=127.0.0.1:7401
|
Environment=FLY_CONTROL_BIND=127.0.0.1:7401
|
||||||
Environment=FLY_METRICS_ADDR=0.0.0.0:9101
|
Environment=FLY_METRICS_ADDR=0.0.0.0:9101
|
||||||
Environment=FLY_STATE_HOT=/run/fly/state
|
Environment=FLY_STATE_HOT=/run/fly/state
|
||||||
|
|
|
||||||
20
services/flysim/Cargo.lock
generated
20
services/flysim/Cargo.lock
generated
|
|
@ -416,6 +416,25 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fly-edge"
|
||||||
|
version = "0.1.1"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"axum",
|
||||||
|
"clap",
|
||||||
|
"flate2",
|
||||||
|
"flybus",
|
||||||
|
"flysim",
|
||||||
|
"futures-util",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
|
"tokio",
|
||||||
|
"tokio-tungstenite",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fly-session"
|
name = "fly-session"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
|
|
@ -484,6 +503,7 @@ dependencies = [
|
||||||
"fly-session-types",
|
"fly-session-types",
|
||||||
"flybrain-core",
|
"flybrain-core",
|
||||||
"flybrain-gb",
|
"flybrain-gb",
|
||||||
|
"flybus",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"jsonschema",
|
"jsonschema",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = [
|
members = [
|
||||||
|
"crates/fly-edge",
|
||||||
"crates/fly-session",
|
"crates/fly-session",
|
||||||
"crates/fly-session-types",
|
"crates/fly-session-types",
|
||||||
"crates/flybrain-core",
|
"crates/flybrain-core",
|
||||||
|
|
|
||||||
36
services/flysim/crates/fly-edge/Cargo.toml
Normal file
36
services/flysim/crates/fly-edge/Cargo.toml
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
[package]
|
||||||
|
name = "fly-edge"
|
||||||
|
version.workspace = true
|
||||||
|
edition = "2024"
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish = false
|
||||||
|
description = "The feed WebSocket (:7400) served from flysim's feed bus (FLY_FEED_VIA=bus)."
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "fly_edge"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "fly-edge"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# The feed server and the bus encoding are flysim's own modules (`feed`, `feedbus`,
|
||||||
|
# `snapshot`), so the edge writes the WebSocket bytes with the code flysim uses in direct mode.
|
||||||
|
flysim = { path = "../flysim" }
|
||||||
|
flybus = { path = "../flybus" }
|
||||||
|
|
||||||
|
anyhow = "1.0"
|
||||||
|
axum = { version = "0.8", features = ["ws"] }
|
||||||
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "time", "signal", "macros"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
flate2 = { workspace = true }
|
||||||
|
futures-util = "0.3"
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
tempfile = "3"
|
||||||
|
tokio-tungstenite = "0.29"
|
||||||
344
services/flysim/crates/fly-edge/src/lib.rs
Normal file
344
services/flysim/crates/fly-edge/src/lib.rs
Normal file
|
|
@ -0,0 +1,344 @@
|
||||||
|
//! `fly-edge`: the feed WebSocket, served from flysim's feed bus.
|
||||||
|
//!
|
||||||
|
//! With `FLY_FEED_VIA=bus` flysim does not bind the feed port. It publishes every snapshot on an
|
||||||
|
//! embedded flybus router (`flysim::feedbus`), and this process subscribes and serves
|
||||||
|
//! `ws://<feed.bind>/feed` to the stage, the bridge and tests. The contract is still
|
||||||
|
//! `docs/feed-protocol.md`, byte for byte: the snapshots come off the bus as the same
|
||||||
|
//! `flysim::snapshot::Snapshot` values and are written by the same `flysim::feed` server, so the
|
||||||
|
//! per-client `hello`, `wants`, drop-oldest and idle cadence are flysim's own code.
|
||||||
|
//!
|
||||||
|
//! Lifecycle (`docs/design/flybus.md`, amendment "Feed store lifecycle"):
|
||||||
|
//!
|
||||||
|
//! - the feed port is bound only once the first snapshot has arrived, so before that a client
|
||||||
|
//! is refused exactly as it would be by a flysim that has not started;
|
||||||
|
//! - when the bus goes away (flysim stopped or restarted) the edge drops every client and unbinds
|
||||||
|
//! the port, again exactly what a stopped flysim looks like to the stage, then reconnects every
|
||||||
|
//! `retry` until a router answers. It never serves a stale snapshot as if it were live.
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, anyhow};
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use axum::routing::get;
|
||||||
|
use flybus::{Client, ClientConfig, SubscriptionConfig};
|
||||||
|
use flysim::feed::{self, FeedState};
|
||||||
|
use flysim::feedbus;
|
||||||
|
use flysim::metrics::{Metrics, metric};
|
||||||
|
use tokio::sync::{oneshot, watch};
|
||||||
|
|
||||||
|
/// What the edge needs to know. Built from flysim's own configuration, so both processes read
|
||||||
|
/// one environment file and cannot disagree about the port, the bus directory or the cadence.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EdgeConfig {
|
||||||
|
/// `feed.bus_dir`: the router's socket and store root.
|
||||||
|
pub bus_dir: PathBuf,
|
||||||
|
/// `feed.bind`, the port flysim leaves alone in bus mode.
|
||||||
|
pub feed_bind: SocketAddr,
|
||||||
|
/// `1 / loop.idle_snapshot_hz`, the protocol's header-only cadence.
|
||||||
|
pub idle_period: Duration,
|
||||||
|
/// `FLY_EDGE_METRICS_ADDR`: `/metrics` and `/healthz` for the watchdog, when set.
|
||||||
|
pub metrics_bind: Option<SocketAddr>,
|
||||||
|
/// Delay between attempts to reach the bus.
|
||||||
|
pub retry: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EdgeConfig {
|
||||||
|
pub fn from_flysim(config: &flysim::config::Config, metrics_bind: Option<SocketAddr>) -> Self {
|
||||||
|
Self {
|
||||||
|
bus_dir: config.feed.bus_dir.clone(),
|
||||||
|
feed_bind: config.feed.bind,
|
||||||
|
idle_period: config.publish_periods().1,
|
||||||
|
metrics_bind,
|
||||||
|
retry: Duration::from_millis(500),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The edge's counters. `feed` is the same `Metrics` type flysim uses, so
|
||||||
|
/// `fly_frames_sent_total` and `fly_feed_clients` mean exactly what they mean there.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct EdgeMetrics {
|
||||||
|
pub feed: Arc<Metrics>,
|
||||||
|
/// Snapshots taken off the bus and handed to the feed server.
|
||||||
|
pub snapshots: AtomicU64,
|
||||||
|
/// 1 while subscribed and serving.
|
||||||
|
pub connected: AtomicU64,
|
||||||
|
/// Times a serving session ended because the bus went away.
|
||||||
|
pub bus_lost: AtomicU64,
|
||||||
|
/// Publications that could not be turned back into a snapshot.
|
||||||
|
pub decode_failures: AtomicU64,
|
||||||
|
/// Sessions that reached the bus but could not bind the feed port.
|
||||||
|
pub bind_failures: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EdgeMetrics {
|
||||||
|
pub fn render(&self) -> String {
|
||||||
|
let mut out = String::with_capacity(1_024);
|
||||||
|
let feed = &self.feed;
|
||||||
|
metric(
|
||||||
|
&mut out,
|
||||||
|
"fly_frames_sent_total",
|
||||||
|
"counter",
|
||||||
|
"Feed snapshots written to a client socket.",
|
||||||
|
Metrics::get(&feed.frames_sent),
|
||||||
|
);
|
||||||
|
metric(
|
||||||
|
&mut out,
|
||||||
|
"fly_feed_clients",
|
||||||
|
"gauge",
|
||||||
|
"Feed clients currently subscribed.",
|
||||||
|
feed.clients(),
|
||||||
|
);
|
||||||
|
metric(
|
||||||
|
&mut out,
|
||||||
|
"fly_feed_dropped_total",
|
||||||
|
"counter",
|
||||||
|
"Snapshots superseded before a slow client could be sent them.",
|
||||||
|
Metrics::get(&feed.feed_dropped),
|
||||||
|
);
|
||||||
|
metric(
|
||||||
|
&mut out,
|
||||||
|
"fly_edge_snapshots_total",
|
||||||
|
"counter",
|
||||||
|
"Snapshots taken off the feed bus.",
|
||||||
|
self.snapshots.load(Ordering::Relaxed),
|
||||||
|
);
|
||||||
|
metric(
|
||||||
|
&mut out,
|
||||||
|
"fly_edge_bus_connected",
|
||||||
|
"gauge",
|
||||||
|
"1 while the edge is subscribed to the feed bus and serving.",
|
||||||
|
self.connected.load(Ordering::Relaxed),
|
||||||
|
);
|
||||||
|
metric(
|
||||||
|
&mut out,
|
||||||
|
"fly_edge_bus_lost_total",
|
||||||
|
"counter",
|
||||||
|
"Serving sessions ended by the feed bus going away.",
|
||||||
|
self.bus_lost.load(Ordering::Relaxed),
|
||||||
|
);
|
||||||
|
metric(
|
||||||
|
&mut out,
|
||||||
|
"fly_edge_decode_failures_total",
|
||||||
|
"counter",
|
||||||
|
"Feed bus publications that did not decode to a snapshot.",
|
||||||
|
self.decode_failures.load(Ordering::Relaxed),
|
||||||
|
);
|
||||||
|
metric(
|
||||||
|
&mut out,
|
||||||
|
"fly_edge_bind_failures_total",
|
||||||
|
"counter",
|
||||||
|
"Times the bus was reachable but the feed port could not be bound.",
|
||||||
|
self.bind_failures.load(Ordering::Relaxed),
|
||||||
|
);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve until the process is stopped. Only a metrics listener that cannot bind is fatal;
|
||||||
|
/// everything about the bus is retried.
|
||||||
|
pub async fn run(config: EdgeConfig, metrics: Arc<EdgeMetrics>) -> Result<()> {
|
||||||
|
if let Some(addr) = config.metrics_bind {
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("binding the edge metrics listener on {addr}"))?;
|
||||||
|
let app = axum::Router::new()
|
||||||
|
.route("/metrics", get(prometheus))
|
||||||
|
.route("/healthz", get(healthz))
|
||||||
|
.with_state(Arc::clone(&metrics));
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(error) = axum::serve(listener, app).await {
|
||||||
|
tracing::error!(%error, "the edge metrics listener stopped");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// One line per outage of each kind, not one per retry.
|
||||||
|
let mut last: Option<&'static str> = None;
|
||||||
|
loop {
|
||||||
|
let end = session(&config, &metrics).await;
|
||||||
|
match &end {
|
||||||
|
SessionEnd::BusLost => {
|
||||||
|
tracing::warn!("the feed bus went away; clients dropped, reconnecting");
|
||||||
|
}
|
||||||
|
SessionEnd::Unreachable(error) if last != Some(end.kind()) => {
|
||||||
|
tracing::info!(error = format!("{error:#}"), "waiting for the feed bus");
|
||||||
|
}
|
||||||
|
SessionEnd::BindFailed(error) if last != Some(end.kind()) => {
|
||||||
|
// The bus is fine; the port is not ours. Most likely flysim is still in direct
|
||||||
|
// mode and holds it (FLY_FEED_VIA is not bus), or another process does.
|
||||||
|
tracing::warn!(
|
||||||
|
error = format!("{error:#}"),
|
||||||
|
"the bus is up but the feed port cannot be bound; retrying"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
last = match end {
|
||||||
|
SessionEnd::BusLost => None,
|
||||||
|
other => Some(other.kind()),
|
||||||
|
};
|
||||||
|
tokio::time::sleep(config.retry).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a [`session`] ended.
|
||||||
|
enum SessionEnd {
|
||||||
|
/// No router answered, or it closed before the first snapshot. Nothing was served.
|
||||||
|
Unreachable(anyhow::Error),
|
||||||
|
/// Subscribed and holding a snapshot, but the feed port could not be bound.
|
||||||
|
BindFailed(anyhow::Error),
|
||||||
|
/// A session that served has ended because the bus went away.
|
||||||
|
BusLost,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionEnd {
|
||||||
|
fn kind(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Unreachable(_) => "unreachable",
|
||||||
|
Self::BindFailed(_) => "bind",
|
||||||
|
Self::BusLost => "lost",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prometheus(State(metrics): State<Arc<EdgeMetrics>>) -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[(
|
||||||
|
axum::http::header::CONTENT_TYPE,
|
||||||
|
"text/plain; version=0.0.4",
|
||||||
|
)],
|
||||||
|
metrics.render(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn healthz(State(metrics): State<Arc<EdgeMetrics>>) -> impl IntoResponse {
|
||||||
|
if metrics.connected.load(Ordering::Relaxed) == 1 {
|
||||||
|
(StatusCode::OK, "ok")
|
||||||
|
} else {
|
||||||
|
(StatusCode::SERVICE_UNAVAILABLE, "waiting for the feed bus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One subscription's lifetime.
|
||||||
|
async fn session(config: &EdgeConfig, metrics: &EdgeMetrics) -> SessionEnd {
|
||||||
|
let (subscription, client, first) = match subscribe(config, metrics).await {
|
||||||
|
Ok(subscribed) => subscribed,
|
||||||
|
Err(error) => return SessionEnd::Unreachable(error),
|
||||||
|
};
|
||||||
|
let listener = match tokio::net::TcpListener::bind(config.feed_bind).await {
|
||||||
|
Ok(listener) => listener,
|
||||||
|
Err(error) => {
|
||||||
|
metrics.bind_failures.fetch_add(1, Ordering::Relaxed);
|
||||||
|
return SessionEnd::BindFailed(
|
||||||
|
anyhow::Error::new(error)
|
||||||
|
.context(format!("binding the feed listener on {}", config.feed_bind)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
serve(config, metrics, client, subscription, first, listener).await;
|
||||||
|
SessionEnd::BusLost
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect, subscribe and wait for the first snapshot that decodes.
|
||||||
|
async fn subscribe(
|
||||||
|
config: &EdgeConfig,
|
||||||
|
metrics: &EdgeMetrics,
|
||||||
|
) -> Result<(flybus::Subscription, Client, flysim::snapshot::Snapshot)> {
|
||||||
|
let client = Client::connect_unix(
|
||||||
|
feedbus::socket_path(&config.bus_dir),
|
||||||
|
ClientConfig::new(feedbus::EDGE, feedbus::store_root(&config.bus_dir)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow!("connecting to the feed bus: {error}"))?;
|
||||||
|
// One in flight: while a snapshot is being copied out, the next one waits in the single
|
||||||
|
// latest slot and anything newer replaces it. The edge is never more than one behind.
|
||||||
|
let mut subscription = client
|
||||||
|
.subscribe(
|
||||||
|
feedbus::TOPIC,
|
||||||
|
SubscriptionConfig::latest().in_flight(1).replay(true),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow!("subscribing to {}: {error}", feedbus::TOPIC))?;
|
||||||
|
let first = loop {
|
||||||
|
let message = subscription
|
||||||
|
.next()
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow!("the feed bus closed before the first snapshot"))?;
|
||||||
|
match feedbus::receive(&message).await {
|
||||||
|
Ok(snapshot) => break snapshot,
|
||||||
|
Err(error) => {
|
||||||
|
metrics.decode_failures.fetch_add(1, Ordering::Relaxed);
|
||||||
|
tracing::warn!(%error, "a feed bus publication did not decode");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok((subscription, client, first))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve `listener` from `subscription` until the bus goes away.
|
||||||
|
async fn serve(
|
||||||
|
config: &EdgeConfig,
|
||||||
|
metrics: &EdgeMetrics,
|
||||||
|
client: Client,
|
||||||
|
mut subscription: flybus::Subscription,
|
||||||
|
first: flysim::snapshot::Snapshot,
|
||||||
|
listener: tokio::net::TcpListener,
|
||||||
|
) {
|
||||||
|
let (snapshots, receiver) = watch::channel(Arc::new(first));
|
||||||
|
metrics.snapshots.fetch_add(1, Ordering::Relaxed);
|
||||||
|
tracing::info!(feed = %config.feed_bind, bus = %config.bus_dir.display(), "serving the feed from the bus");
|
||||||
|
metrics.connected.store(1, Ordering::Relaxed);
|
||||||
|
|
||||||
|
let state = FeedState {
|
||||||
|
snapshots: receiver,
|
||||||
|
metrics: Arc::clone(&metrics.feed),
|
||||||
|
idle_period: config.idle_period,
|
||||||
|
};
|
||||||
|
let (stop, stopped) = oneshot::channel::<()>();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let result = axum::serve(listener, feed::router(state))
|
||||||
|
.with_graceful_shutdown(async move {
|
||||||
|
let _ = stopped.await;
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
if let Err(error) = result {
|
||||||
|
tracing::error!(%error, "the feed listener stopped");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
while let Some(message) = subscription.next().await {
|
||||||
|
match feedbus::receive(&message).await {
|
||||||
|
Ok(snapshot) => {
|
||||||
|
drop(message);
|
||||||
|
snapshots.send_replace(Arc::new(snapshot));
|
||||||
|
metrics.snapshots.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
metrics.decode_failures.fetch_add(1, Ordering::Relaxed);
|
||||||
|
tracing::warn!(%error, "a feed bus publication did not decode");
|
||||||
|
if client.closed().is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bus is gone. Dropping the sender ends every client's pump (a closed stream, as when
|
||||||
|
// flysim itself stops), and the graceful shutdown unbinds the port.
|
||||||
|
metrics.connected.store(0, Ordering::Relaxed);
|
||||||
|
metrics.bus_lost.fetch_add(1, Ordering::Relaxed);
|
||||||
|
drop(snapshots);
|
||||||
|
let _ = stop.send(());
|
||||||
|
if tokio::time::timeout(Duration::from_secs(5), server)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
tracing::warn!("the feed listener took more than 5 s to stop");
|
||||||
|
}
|
||||||
|
}
|
||||||
81
services/flysim/crates/fly-edge/src/main.rs
Normal file
81
services/flysim/crates/fly-edge/src/main.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
//! `fly-edge`: serve the feed WebSocket from flysim's feed bus.
|
||||||
|
//!
|
||||||
|
//! ```sh
|
||||||
|
//! FLY_FEED_VIA=bus flysim &
|
||||||
|
//! fly-edge
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Configured through the same environment as flysim (`FLY_FEED_BIND`, `FLY_BUS_DIR`,
|
||||||
|
//! `FLYSIM_LOOP_IDLE_SNAPSHOT_HZ`, or `--config flysim.toml`), plus `FLY_EDGE_METRICS_ADDR` for
|
||||||
|
//! its own `/metrics` and `/healthz`. `infra/units/flyedge.service` runs it with no arguments.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use clap::Parser;
|
||||||
|
use fly_edge::{EdgeConfig, EdgeMetrics};
|
||||||
|
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
#[command(
|
||||||
|
name = "fly-edge",
|
||||||
|
about = "The feed WebSocket, served from flysim's feed bus.",
|
||||||
|
version
|
||||||
|
)]
|
||||||
|
struct Args {
|
||||||
|
/// Path to `flysim.toml`, read for `[feed]` and `[loop]`. Environment overrides apply as
|
||||||
|
/// they do for flysim.
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
config: Option<std::path::PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let args = Args::parse();
|
||||||
|
init_tracing();
|
||||||
|
let config = flysim::config::Config::load(args.config.as_deref())?;
|
||||||
|
let metrics_bind = match std::env::var("FLY_EDGE_METRICS_ADDR") {
|
||||||
|
Ok(value) if !value.is_empty() => Some(value.parse().with_context(|| {
|
||||||
|
format!("FLY_EDGE_METRICS_ADDR: {value:?} is not a host:port address")
|
||||||
|
})?),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let edge = EdgeConfig::from_flysim(&config, metrics_bind);
|
||||||
|
if config.feed.via != flysim::config::FeedVia::Bus {
|
||||||
|
tracing::warn!(
|
||||||
|
"FLY_FEED_VIA is not \"bus\": flysim serves the feed itself and binds {}; \
|
||||||
|
this edge will wait for a bus that is not there",
|
||||||
|
edge.feed_bind
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tracing::info!(config = ?edge, "fly-edge starting");
|
||||||
|
|
||||||
|
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.worker_threads(2)
|
||||||
|
.thread_name("fly-edge")
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.context("building the tokio runtime")?;
|
||||||
|
runtime.block_on(async move {
|
||||||
|
let metrics = Arc::new(EdgeMetrics::default());
|
||||||
|
let mut terminate =
|
||||||
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||||
|
.context("installing the SIGTERM handler")?;
|
||||||
|
tokio::select! {
|
||||||
|
result = fly_edge::run(edge, metrics) => result,
|
||||||
|
_ = tokio::signal::ctrl_c() => { tracing::info!("SIGINT: shutting down"); Ok(()) }
|
||||||
|
_ = terminate.recv() => { tracing::info!("SIGTERM: shutting down"); Ok(()) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logs to stderr, like flysim, under `FLY_EDGE_LOG` (or `RUST_LOG`).
|
||||||
|
fn init_tracing() {
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
let filter = EnvFilter::try_from_env("FLY_EDGE_LOG")
|
||||||
|
.or_else(|_| EnvFilter::try_from_default_env())
|
||||||
|
.unwrap_or_else(|_| EnvFilter::new("info"));
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(filter)
|
||||||
|
.with_writer(std::io::stderr)
|
||||||
|
.with_target(false)
|
||||||
|
.init();
|
||||||
|
}
|
||||||
246
services/flysim/crates/fly-edge/tests/common/mod.rs
Normal file
246
services/flysim/crates/fly-edge/tests/common/mod.rs
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
//! Shared pieces of the edge tests: the committed `.flyfeed` fixtures as snapshots, a feed
|
||||||
|
//! client, the two serving paths side by side, and a `.flyfeed` writer.
|
||||||
|
|
||||||
|
use std::io::Read as _;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use fly_edge::{EdgeConfig, EdgeMetrics};
|
||||||
|
use flysim::feed::FeedState;
|
||||||
|
use flysim::feedbus;
|
||||||
|
use flysim::metrics::Metrics;
|
||||||
|
use flysim::snapshot::{AttachmentKind, FeedHeader, Snapshot};
|
||||||
|
use futures_util::{SinkExt as _, StreamExt as _};
|
||||||
|
use tokio::sync::watch;
|
||||||
|
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||||
|
|
||||||
|
pub fn repo_root() -> PathBuf {
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../../../..")
|
||||||
|
.canonicalize()
|
||||||
|
.expect("the repository root is above services/flysim/crates/fly-edge")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `u32 LE headerLength | header JSON | (u32 LE length | bytes)*`, split.
|
||||||
|
pub fn split(message: &[u8]) -> (&[u8], Vec<&[u8]>) {
|
||||||
|
let read = |at: usize| u32::from_le_bytes(message[at..at + 4].try_into().unwrap()) as usize;
|
||||||
|
let header_len = read(0);
|
||||||
|
let header = &message[4..4 + header_len];
|
||||||
|
let mut at = 4 + header_len;
|
||||||
|
let mut attachments = Vec::new();
|
||||||
|
while at < message.len() {
|
||||||
|
let len = read(at);
|
||||||
|
attachments.push(&message[at + 4..at + 4 + len]);
|
||||||
|
at += 4 + len;
|
||||||
|
}
|
||||||
|
(header, attachments)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A wire message back into the snapshot that produced it, or `None` when its header predates
|
||||||
|
/// fields the Rust producer always writes (the three oldest fixtures lack `game.scene`).
|
||||||
|
pub fn snapshot_of(message: &[u8]) -> Option<Snapshot> {
|
||||||
|
let (header, attachments) = split(message);
|
||||||
|
let header: FeedHeader = serde_json::from_slice(header).ok()?;
|
||||||
|
let mut snapshot = Snapshot {
|
||||||
|
header,
|
||||||
|
frame: Arc::new(Vec::new()),
|
||||||
|
audio: Arc::new(Vec::new()),
|
||||||
|
spikes: Arc::new(Vec::new()),
|
||||||
|
};
|
||||||
|
for (kind, bytes) in snapshot
|
||||||
|
.header
|
||||||
|
.attachments
|
||||||
|
.clone()
|
||||||
|
.into_iter()
|
||||||
|
.zip(attachments)
|
||||||
|
{
|
||||||
|
let bytes = Arc::new(bytes.to_vec());
|
||||||
|
match kind {
|
||||||
|
AttachmentKind::Frame => snapshot.frame = bytes,
|
||||||
|
AttachmentKind::Audio => snapshot.audio = bytes,
|
||||||
|
AttachmentKind::Spikes => snapshot.spikes = bytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every record of `apps/stage/public/fixtures/<name>.flyfeed.gz`, as wire messages.
|
||||||
|
pub fn fixture_messages(name: &str) -> Vec<Vec<u8>> {
|
||||||
|
let path = repo_root().join(format!("apps/stage/public/fixtures/{name}.flyfeed.gz"));
|
||||||
|
let gz = std::fs::read(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display()));
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
flate2::read::GzDecoder::new(gz.as_slice())
|
||||||
|
.read_to_end(&mut bytes)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(&bytes[..8], b"FLYFEED\0", "{name}");
|
||||||
|
let read = |at: usize| u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap()) as usize;
|
||||||
|
assert_eq!(read(8), 1, "{name}: container version");
|
||||||
|
let mut at = 16 + read(12);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while at < bytes.len() {
|
||||||
|
let len = read(at);
|
||||||
|
out.push(bytes[at + 4..at + 4 + len].to_vec());
|
||||||
|
at += 4 + len;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `.flyfeed` file of `messages` (`packages/feed/src/fixture.ts`).
|
||||||
|
pub fn encode_flyfeed(name: &str, source: &str, messages: &[Vec<u8>]) -> Vec<u8> {
|
||||||
|
let wall = |message: &Vec<u8>| -> u64 {
|
||||||
|
let header: serde_json::Value = serde_json::from_slice(split(message).0).unwrap();
|
||||||
|
header["wallMs"].as_u64().unwrap_or(0)
|
||||||
|
};
|
||||||
|
let duration = match (messages.first(), messages.last()) {
|
||||||
|
(Some(first), Some(last)) => wall(last).saturating_sub(wall(first)),
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
let manifest = serde_json::json!({
|
||||||
|
"name": name,
|
||||||
|
"protocol": 1,
|
||||||
|
"recordedAt": "1970-01-01T00:00:00.000Z",
|
||||||
|
"source": source,
|
||||||
|
"snapshotCount": messages.len(),
|
||||||
|
"durationMs": duration,
|
||||||
|
"hz": 30,
|
||||||
|
"attachmentPolicy": {
|
||||||
|
"frame": { "stride": 1 },
|
||||||
|
"audio": { "stride": 1 },
|
||||||
|
"spikes": { "stride": 1 }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let manifest = serde_json::to_vec(&manifest).unwrap();
|
||||||
|
let mut out = b"FLYFEED\0".to_vec();
|
||||||
|
out.extend_from_slice(&1u32.to_le_bytes());
|
||||||
|
out.extend_from_slice(&(manifest.len() as u32).to_le_bytes());
|
||||||
|
out.extend_from_slice(&manifest);
|
||||||
|
for message in messages {
|
||||||
|
out.extend_from_slice(&(message.len() as u32).to_le_bytes());
|
||||||
|
out.extend_from_slice(message);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn free_port() -> SocketAddr {
|
||||||
|
std::net::TcpListener::bind("127.0.0.1:0")
|
||||||
|
.unwrap()
|
||||||
|
.local_addr()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Ws =
|
||||||
|
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
|
||||||
|
|
||||||
|
/// Connect and say `hello`, retrying while the port is not bound yet (the edge binds only once
|
||||||
|
/// its first snapshot has arrived).
|
||||||
|
pub async fn connect(addr: SocketAddr, wants: &[&str]) -> Ws {
|
||||||
|
let url = format!("ws://{addr}/feed");
|
||||||
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
|
||||||
|
loop {
|
||||||
|
match tokio_tungstenite::connect_async(&url).await {
|
||||||
|
Ok((mut ws, _)) => {
|
||||||
|
let hello = serde_json::json!({ "protocol": 1, "client": "test", "wants": wants });
|
||||||
|
ws.send(WsMessage::Text(hello.to_string().into()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
return ws;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
assert!(tokio::time::Instant::now() < deadline, "{url}: {error}");
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The next binary message, within `within`.
|
||||||
|
pub async fn next_binary(ws: &mut Ws, within: Duration) -> Vec<u8> {
|
||||||
|
let deadline = tokio::time::Instant::now() + within;
|
||||||
|
loop {
|
||||||
|
let message = tokio::time::timeout_at(deadline, ws.next())
|
||||||
|
.await
|
||||||
|
.expect("a snapshot in time")
|
||||||
|
.expect("the feed stays open")
|
||||||
|
.expect("a well-formed frame");
|
||||||
|
if let WsMessage::Binary(bytes) = message {
|
||||||
|
return bytes.to_vec();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn seq_of(message: &[u8]) -> u64 {
|
||||||
|
let header: serde_json::Value = serde_json::from_slice(split(message).0).unwrap();
|
||||||
|
header["seq"].as_u64().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same watch slot served both ways at once: flysim's direct server on `direct`, and the
|
||||||
|
/// bus (router, publisher, edge) on `edge`. Owns its runtime-side tasks through the handles.
|
||||||
|
pub struct Paths {
|
||||||
|
pub snapshots: watch::Sender<Arc<Snapshot>>,
|
||||||
|
pub direct: SocketAddr,
|
||||||
|
pub edge: SocketAddr,
|
||||||
|
pub publisher_metrics: Arc<Metrics>,
|
||||||
|
pub edge_metrics: Arc<EdgeMetrics>,
|
||||||
|
pub bus_dir: tempfile::TempDir,
|
||||||
|
pub bus: feedbus::BusFeed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Idle cadence long enough that no test sees a header repeated for idleness.
|
||||||
|
pub const NO_IDLE: Duration = Duration::from_secs(3_600);
|
||||||
|
|
||||||
|
/// Start both paths over `first`. `edge` false leaves the edge out (a test then plays its part).
|
||||||
|
pub async fn start(first: Snapshot, with_edge: bool) -> Paths {
|
||||||
|
let bus_dir = tempfile::tempdir().unwrap();
|
||||||
|
let (snapshots, receiver) = watch::channel(Arc::new(first));
|
||||||
|
let bus = feedbus::start_router(bus_dir.path()).await.unwrap();
|
||||||
|
let publisher_metrics = Arc::new(Metrics::default());
|
||||||
|
tokio::spawn(feedbus::run_publisher(
|
||||||
|
bus.router.clone(),
|
||||||
|
receiver.clone(),
|
||||||
|
Arc::clone(&publisher_metrics),
|
||||||
|
));
|
||||||
|
|
||||||
|
let direct = free_port();
|
||||||
|
let listener = tokio::net::TcpListener::bind(direct).await.unwrap();
|
||||||
|
let state = FeedState {
|
||||||
|
snapshots: receiver,
|
||||||
|
metrics: Arc::new(Metrics::default()),
|
||||||
|
idle_period: NO_IDLE,
|
||||||
|
};
|
||||||
|
tokio::spawn(async move { axum::serve(listener, flysim::feed::router(state)).await });
|
||||||
|
|
||||||
|
let edge = free_port();
|
||||||
|
let edge_metrics = Arc::new(EdgeMetrics::default());
|
||||||
|
if with_edge {
|
||||||
|
let config = EdgeConfig {
|
||||||
|
bus_dir: bus_dir.path().to_path_buf(),
|
||||||
|
feed_bind: edge,
|
||||||
|
idle_period: NO_IDLE,
|
||||||
|
metrics_bind: None,
|
||||||
|
retry: Duration::from_millis(50),
|
||||||
|
};
|
||||||
|
tokio::spawn(fly_edge::run(config, Arc::clone(&edge_metrics)));
|
||||||
|
}
|
||||||
|
Paths {
|
||||||
|
snapshots,
|
||||||
|
direct,
|
||||||
|
edge,
|
||||||
|
publisher_metrics,
|
||||||
|
edge_metrics,
|
||||||
|
bus_dir,
|
||||||
|
bus,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn out_dir() -> Option<PathBuf> {
|
||||||
|
std::env::var_os("FLY_EDGE_PARITY_OUT").map(PathBuf::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write(path: &Path, bytes: &[u8]) {
|
||||||
|
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||||
|
std::fs::write(path, bytes).unwrap();
|
||||||
|
}
|
||||||
301
services/flysim/crates/fly-edge/tests/parity.rs
Normal file
301
services/flysim/crates/fly-edge/tests/parity.rs
Normal file
|
|
@ -0,0 +1,301 @@
|
||||||
|
//! Parity: a feed served through the bus and `fly-edge` is the feed flysim serves directly.
|
||||||
|
//!
|
||||||
|
//! The committed stage fixtures (`apps/stage/public/fixtures/*.flyfeed.gz`, the recordings the
|
||||||
|
//! stage's e2e suite plays) are fed snapshot by snapshot into one watch slot, served both ways at
|
||||||
|
//! once, and recorded by one client per path and per `wants` flavour: the stage's (everything),
|
||||||
|
//! the bridge's (nothing) and a frame-only one. The two recordings must match: headers equal
|
||||||
|
//! apart from wall-time fields and attachments byte-equal -- and in fact the whole messages are
|
||||||
|
//! byte-equal, because both are written by `Snapshot::encode` from equal snapshots. The edge's
|
||||||
|
//! attachments must also equal the fixture's own.
|
||||||
|
//!
|
||||||
|
//! `FLY_EDGE_PARITY_OUT=<dir>` also writes each pair of recordings as `.flyfeed` files, which
|
||||||
|
//! `packages/feed`'s `decodeFlyfeed` reads. `FLY_EDGE_PARITY_ALL=1` replays whole fixtures
|
||||||
|
//! instead of their first 400 snapshots.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use common::*;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// The fixtures whose headers carry every field the Rust producer writes. The three older ones
|
||||||
|
/// (`cold-open`, `steady`, `big-moment`) predate `game.scene` and cannot be a Rust `Snapshot`.
|
||||||
|
const FIXTURES: [&str; 4] = ["macros", "shop", "center", "bigpad"];
|
||||||
|
const WANTS: [(&str, &[&str]); 3] = [
|
||||||
|
("all", &["frame", "audio", "spikes"]),
|
||||||
|
("none", &[]),
|
||||||
|
("frame", &["frame"]),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The header with every `wallMs` removed, at any depth.
|
||||||
|
fn without_wall_time(header: &[u8]) -> Value {
|
||||||
|
fn strip(value: &mut Value) {
|
||||||
|
match value {
|
||||||
|
Value::Object(map) => {
|
||||||
|
map.remove("wallMs");
|
||||||
|
map.values_mut().for_each(strip);
|
||||||
|
}
|
||||||
|
Value::Array(items) => items.iter_mut().for_each(strip),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut value: Value = serde_json::from_slice(header).unwrap();
|
||||||
|
strip(&mut value);
|
||||||
|
value
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
async fn the_edge_writes_the_bytes_the_direct_feed_writes_for_every_committed_fixture() {
|
||||||
|
let limit = if std::env::var_os("FLY_EDGE_PARITY_ALL").is_some() {
|
||||||
|
usize::MAX
|
||||||
|
} else {
|
||||||
|
400
|
||||||
|
};
|
||||||
|
for name in FIXTURES {
|
||||||
|
let snapshots: Vec<_> = fixture_messages(name)
|
||||||
|
.iter()
|
||||||
|
.take(limit)
|
||||||
|
.map(|message| {
|
||||||
|
(
|
||||||
|
message.clone(),
|
||||||
|
snapshot_of(message).unwrap_or_else(|| panic!("{name}")),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
snapshots.len() >= 100,
|
||||||
|
"{name}: {} snapshots",
|
||||||
|
snapshots.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let paths = start(snapshots[0].1.clone(), true).await;
|
||||||
|
let mut clients = Vec::new();
|
||||||
|
for (flavour, wants) in WANTS {
|
||||||
|
let direct = connect(paths.direct, wants).await;
|
||||||
|
let edge = connect(paths.edge, wants).await;
|
||||||
|
clients.push((flavour, direct, edge, Vec::new(), Vec::new()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lockstep: publish one snapshot, wait until every client has it. Nothing is superseded,
|
||||||
|
// so both recordings are complete and comparable message by message.
|
||||||
|
for (index, (_, snapshot)) in snapshots.iter().enumerate() {
|
||||||
|
if index > 0 {
|
||||||
|
paths
|
||||||
|
.snapshots
|
||||||
|
.send_replace(std::sync::Arc::new(snapshot.clone()));
|
||||||
|
}
|
||||||
|
for (_, direct, edge, direct_log, edge_log) in &mut clients {
|
||||||
|
for (ws, log) in [
|
||||||
|
(&mut *direct, &mut *direct_log),
|
||||||
|
(&mut *edge, &mut *edge_log),
|
||||||
|
] {
|
||||||
|
let message = next_binary(ws, Duration::from_secs(20)).await;
|
||||||
|
assert_eq!(seq_of(&message), snapshot.header.seq, "{name} #{index}");
|
||||||
|
log.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (flavour, _, _, direct_log, edge_log) in &clients {
|
||||||
|
assert_eq!(direct_log.len(), snapshots.len());
|
||||||
|
assert_eq!(edge_log.len(), direct_log.len());
|
||||||
|
for (index, (direct, edge)) in direct_log.iter().zip(edge_log).enumerate() {
|
||||||
|
let (direct_header, direct_attachments) = split(direct);
|
||||||
|
let (edge_header, edge_attachments) = split(edge);
|
||||||
|
assert_eq!(
|
||||||
|
without_wall_time(direct_header),
|
||||||
|
without_wall_time(edge_header),
|
||||||
|
"{name}/{flavour} #{index}: headers"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
direct_attachments, edge_attachments,
|
||||||
|
"{name}/{flavour} #{index}: attachments"
|
||||||
|
);
|
||||||
|
// The stronger fact: the whole message, wall times included, is the same bytes.
|
||||||
|
assert!(direct == edge, "{name}/{flavour} #{index}: messages differ");
|
||||||
|
if *flavour == "all" {
|
||||||
|
let (_, fixture_attachments) = split(&snapshots[index].0);
|
||||||
|
assert_eq!(
|
||||||
|
edge_attachments, fixture_attachments,
|
||||||
|
"{name} #{index}: vs the fixture"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(dir) = out_dir() {
|
||||||
|
write(
|
||||||
|
&dir.join(format!("{name}-{flavour}-direct.flyfeed")),
|
||||||
|
&encode_flyfeed(name, "flysim direct", direct_log),
|
||||||
|
);
|
||||||
|
write(
|
||||||
|
&dir.join(format!("{name}-{flavour}-edge.flyfeed")),
|
||||||
|
&encode_flyfeed(name, "flysim bus + fly-edge", edge_log),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let published = flysim::metrics::Metrics::get(&paths.publisher_metrics.bus_published);
|
||||||
|
assert!(
|
||||||
|
published >= snapshots.len() as u64,
|
||||||
|
"{name}: {published} published"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
flysim::metrics::Metrics::get(&paths.publisher_metrics.bus_publish_failures),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
"{name}: {} snapshots x {} flavours identical on both paths",
|
||||||
|
snapshots.len(),
|
||||||
|
WANTS.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
async fn a_header_too_large_for_an_envelope_travels_as_an_artifact_and_arrives_intact() {
|
||||||
|
let mut snapshot = snapshot_of(&fixture_messages("macros")[10]).unwrap();
|
||||||
|
// Far past flybus's 65,536-byte envelope: 400 events of 200 characters.
|
||||||
|
for id in 0..400u64 {
|
||||||
|
snapshot.header.events.push(flysim::snapshot::FeedEvent {
|
||||||
|
id: 10_000 + id,
|
||||||
|
wall_ms: 1_757_000_000_000 + id,
|
||||||
|
brain_ms: 5.0,
|
||||||
|
kind: flysim::snapshot::FeedEventKind::System,
|
||||||
|
label: "x".repeat(200),
|
||||||
|
value: None,
|
||||||
|
reward_kind: None,
|
||||||
|
by: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert!(serde_json::to_vec(&snapshot.header).unwrap().len() > 65_536);
|
||||||
|
let paths = start(snapshot.clone(), true).await;
|
||||||
|
let mut direct = connect(paths.direct, &["frame", "audio", "spikes"]).await;
|
||||||
|
let mut edge = connect(paths.edge, &["frame", "audio", "spikes"]).await;
|
||||||
|
let direct = next_binary(&mut direct, Duration::from_secs(20)).await;
|
||||||
|
let edge = next_binary(&mut edge, Duration::from_secs(20)).await;
|
||||||
|
assert!(direct == edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
async fn the_edge_drops_its_clients_and_unbinds_when_the_bus_goes_away_then_comes_back() {
|
||||||
|
let snapshot = snapshot_of(&fixture_messages("shop")[5]).unwrap();
|
||||||
|
let paths = start(snapshot.clone(), true).await;
|
||||||
|
let mut edge = connect(paths.edge, &[]).await;
|
||||||
|
next_binary(&mut edge, Duration::from_secs(20)).await;
|
||||||
|
|
||||||
|
// flysim stopping is its router stopping.
|
||||||
|
let Paths {
|
||||||
|
snapshots,
|
||||||
|
edge: edge_addr,
|
||||||
|
edge_metrics,
|
||||||
|
bus_dir,
|
||||||
|
bus,
|
||||||
|
..
|
||||||
|
} = paths;
|
||||||
|
bus.router.shutdown();
|
||||||
|
drop(bus);
|
||||||
|
drop(snapshots);
|
||||||
|
let closed = tokio::time::timeout(Duration::from_secs(10), async {
|
||||||
|
use futures_util::StreamExt as _;
|
||||||
|
loop {
|
||||||
|
match edge.next().await {
|
||||||
|
None | Some(Err(_)) => break,
|
||||||
|
Some(Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => break,
|
||||||
|
Some(Ok(_)) => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
assert!(closed.is_ok(), "the client was not dropped");
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||||
|
while tokio::net::TcpStream::connect(edge_addr).await.is_ok() {
|
||||||
|
assert!(
|
||||||
|
std::time::Instant::now() < deadline,
|
||||||
|
"the feed port stayed bound"
|
||||||
|
);
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
edge_metrics
|
||||||
|
.connected
|
||||||
|
.load(std::sync::atomic::Ordering::Relaxed),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// A new flysim on the same directory: the edge finds it and serves again.
|
||||||
|
let (snapshots, receiver) = tokio::sync::watch::channel(std::sync::Arc::new(snapshot.clone()));
|
||||||
|
let bus = flysim::feedbus::start_router(bus_dir.path()).await.unwrap();
|
||||||
|
tokio::spawn(flysim::feedbus::run_publisher(
|
||||||
|
bus.router.clone(),
|
||||||
|
receiver,
|
||||||
|
std::sync::Arc::new(flysim::metrics::Metrics::default()),
|
||||||
|
));
|
||||||
|
let mut edge = connect(edge_addr, &[]).await;
|
||||||
|
let message = next_binary(&mut edge, Duration::from_secs(20)).await;
|
||||||
|
assert_eq!(seq_of(&message), snapshot.header.seq);
|
||||||
|
assert_eq!(
|
||||||
|
edge_metrics
|
||||||
|
.bus_lost
|
||||||
|
.load(std::sync::atomic::Ordering::Relaxed),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
drop(snapshots);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
async fn an_edge_whose_port_is_taken_keeps_retrying_and_serves_once_it_is_free() {
|
||||||
|
let snapshot = snapshot_of(&fixture_messages("center")[7]).unwrap();
|
||||||
|
// Someone else (flysim still in direct mode, say) holds the feed port before the edge starts.
|
||||||
|
let squatter = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let port = squatter.local_addr().unwrap();
|
||||||
|
let bus_dir = tempfile::tempdir().unwrap();
|
||||||
|
let (snapshots, receiver) = tokio::sync::watch::channel(std::sync::Arc::new(snapshot.clone()));
|
||||||
|
let bus = flysim::feedbus::start_router(bus_dir.path()).await.unwrap();
|
||||||
|
tokio::spawn(flysim::feedbus::run_publisher(
|
||||||
|
bus.router.clone(),
|
||||||
|
receiver,
|
||||||
|
std::sync::Arc::new(flysim::metrics::Metrics::default()),
|
||||||
|
));
|
||||||
|
let metrics = std::sync::Arc::new(fly_edge::EdgeMetrics::default());
|
||||||
|
tokio::spawn(fly_edge::run(
|
||||||
|
fly_edge::EdgeConfig {
|
||||||
|
bus_dir: bus_dir.path().to_path_buf(),
|
||||||
|
feed_bind: port,
|
||||||
|
idle_period: NO_IDLE,
|
||||||
|
metrics_bind: None,
|
||||||
|
retry: Duration::from_millis(50),
|
||||||
|
},
|
||||||
|
std::sync::Arc::clone(&metrics),
|
||||||
|
));
|
||||||
|
// It reaches the bus, fails to bind, and says so rather than claiming to wait for the bus.
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||||
|
while metrics
|
||||||
|
.bind_failures
|
||||||
|
.load(std::sync::atomic::Ordering::Relaxed)
|
||||||
|
< 3
|
||||||
|
{
|
||||||
|
assert!(
|
||||||
|
std::time::Instant::now() < deadline,
|
||||||
|
"the edge never reached the bus"
|
||||||
|
);
|
||||||
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
metrics.connected.load(std::sync::atomic::Ordering::Relaxed),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
metrics.bus_lost.load(std::sync::atomic::Ordering::Relaxed),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(squatter);
|
||||||
|
let mut edge = connect(port, &[]).await;
|
||||||
|
let message = next_binary(&mut edge, Duration::from_secs(20)).await;
|
||||||
|
assert_eq!(seq_of(&message), snapshot.header.seq);
|
||||||
|
assert_eq!(
|
||||||
|
metrics.connected.load(std::sync::atomic::Ordering::Relaxed),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
drop(snapshots);
|
||||||
|
drop(bus);
|
||||||
|
}
|
||||||
354
services/flysim/crates/fly-edge/tests/stall.rs
Normal file
354
services/flysim/crates/fly-edge/tests/stall.rs
Normal file
|
|
@ -0,0 +1,354 @@
|
||||||
|
//! A slow or absent edge never slows the loop.
|
||||||
|
//!
|
||||||
|
//! A thread stands in for the sim loop: flysim's own `Pacer` at realtime speed and 60 Hz Game
|
||||||
|
//! Boy frames, publishing full-size snapshots (a real 92,160-byte frame, a 17,407-byte spike
|
||||||
|
//! bitset for 139,255 neurons, 12,800 bytes of audio) into the watch slot every second frame,
|
||||||
|
//! with `watch::Sender::send`, exactly as `Sim::publish` does. Around it, three kinds of bad
|
||||||
|
//! consumer:
|
||||||
|
//!
|
||||||
|
//! - the edge is up but three of its WebSocket clients never read, so their sockets fill;
|
||||||
|
//! - the edge's place on the bus is held by a client that opens every subscription it may
|
||||||
|
//! (4) and never releases a delivery on any of them (the "slow edge", at its worst);
|
||||||
|
//! - nobody is subscribed at all (the "absent edge").
|
||||||
|
//!
|
||||||
|
//! The gated tests assert the claim, and only the claim: the pacer reports no lag, no watch send
|
||||||
|
//! waits on a consumer, no publication is refused, the store stays bounded, and a healthy client
|
||||||
|
//! still reaches the newest snapshot. Those hold on a box at any load, because none of them is a
|
||||||
|
//! rate.
|
||||||
|
//!
|
||||||
|
//! How fast the loop's sleeps come back and how many snapshots a debug-build publisher gets
|
||||||
|
//! through measure the OS scheduler and the CPU left over, not the bus: a starved publisher
|
||||||
|
//! coalesces by design. Those bounds are in `the_three_scenarios_keep_their_rates`, which is
|
||||||
|
//! `#[ignore]`d; run it on a quiet box with
|
||||||
|
//! `cargo test --release -p fly-edge --test stall -- --ignored --nocapture`.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use common::*;
|
||||||
|
use flybus::{Client, ClientConfig, SubscriptionConfig};
|
||||||
|
use flysim::feedbus;
|
||||||
|
use flysim::metrics::Metrics;
|
||||||
|
use flysim::pacing::Pacer;
|
||||||
|
use flysim::snapshot::{AttachmentKind, FeedStatus, Snapshot};
|
||||||
|
|
||||||
|
/// A full-size running snapshot from a real fixture frame.
|
||||||
|
fn full_snapshot() -> Snapshot {
|
||||||
|
let mut snapshot = snapshot_of(&fixture_messages("macros")[3]).unwrap();
|
||||||
|
assert_eq!(snapshot.frame.len(), flysim::snapshot::FRAME_BYTES);
|
||||||
|
snapshot.header.status = FeedStatus::Running;
|
||||||
|
snapshot.header.attachments = AttachmentKind::ALL.to_vec();
|
||||||
|
snapshot.spikes = Arc::new(vec![0b1010_0101; 139_255usize.div_ceil(8)]);
|
||||||
|
snapshot.audio = Arc::new(vec![7; 12_800]);
|
||||||
|
snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoopReport {
|
||||||
|
frames: u64,
|
||||||
|
published: u64,
|
||||||
|
lag_seconds: f64,
|
||||||
|
worst_send: Duration,
|
||||||
|
worst_shortfall: f64,
|
||||||
|
p99_shortfall: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the stand-in loop for `seconds` on its own thread.
|
||||||
|
fn run_loop(
|
||||||
|
snapshots: tokio::sync::watch::Sender<Arc<Snapshot>>,
|
||||||
|
template: Snapshot,
|
||||||
|
seconds: f64,
|
||||||
|
) -> LoopReport {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let frame_ms = flysim::config::GAMEBOY_MS_PER_FRAME;
|
||||||
|
let mut pacer = Pacer::new(frame_ms, 1.0, Instant::now());
|
||||||
|
let frames = (seconds * 1000.0 / frame_ms) as u64;
|
||||||
|
let mut worst_send = Duration::ZERO;
|
||||||
|
let mut shortfalls = Vec::with_capacity(frames as usize);
|
||||||
|
let mut seq = template.header.seq;
|
||||||
|
let mut published = 0;
|
||||||
|
for frame in 0..frames {
|
||||||
|
if frame % 2 == 0 {
|
||||||
|
let mut snapshot = template.clone();
|
||||||
|
seq += 1;
|
||||||
|
snapshot.header.seq = seq;
|
||||||
|
snapshot.header.frame = frame;
|
||||||
|
let started = Instant::now();
|
||||||
|
let _ = snapshots.send(Arc::new(snapshot));
|
||||||
|
worst_send = worst_send.max(started.elapsed());
|
||||||
|
published += 1;
|
||||||
|
}
|
||||||
|
let now = Instant::now();
|
||||||
|
shortfalls.push(pacer.shortfall_seconds(now));
|
||||||
|
let sleep = pacer.next_sleep(now);
|
||||||
|
if !sleep.is_zero() {
|
||||||
|
std::thread::sleep(sleep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shortfalls.sort_by(f64::total_cmp);
|
||||||
|
LoopReport {
|
||||||
|
frames,
|
||||||
|
published,
|
||||||
|
lag_seconds: pacer.lag_seconds(),
|
||||||
|
worst_send,
|
||||||
|
worst_shortfall: *shortfalls.last().unwrap(),
|
||||||
|
p99_shortfall: shortfalls[shortfalls.len() * 99 / 100],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.join()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_report(report: &LoopReport, publisher: &Metrics, what: &str) {
|
||||||
|
eprintln!(
|
||||||
|
"{what}: {} frames, {} snapshots, lag {:.3} s, worst send {:?}, shortfall p99 {:.2} ms worst {:.2} ms, bus published {} failed {}",
|
||||||
|
report.frames,
|
||||||
|
report.published,
|
||||||
|
report.lag_seconds,
|
||||||
|
report.worst_send,
|
||||||
|
report.p99_shortfall * 1e3,
|
||||||
|
report.worst_shortfall * 1e3,
|
||||||
|
Metrics::get(&publisher.bus_published),
|
||||||
|
Metrics::get(&publisher.bus_publish_failures),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The claim: the loop is never held by the bus, whatever the load.
|
||||||
|
fn assert_unharmed(report: &LoopReport, publisher: &Metrics, what: &str) {
|
||||||
|
assert_eq!(report.lag_seconds, 0.0, "{what}: the pacer fell behind");
|
||||||
|
// A watch send is a lock and a swap. A send that waited on a consumer would be a whole
|
||||||
|
// stall, seconds; 50 ms leaves room for a preempted thread on a loaded box.
|
||||||
|
assert!(
|
||||||
|
report.worst_send < Duration::from_millis(50),
|
||||||
|
"{what}: a send took {:?}",
|
||||||
|
report.worst_send
|
||||||
|
);
|
||||||
|
// A slow or absent consumer is never a reason to refuse a latest publication.
|
||||||
|
assert_eq!(
|
||||||
|
Metrics::get(&publisher.bus_publish_failures),
|
||||||
|
0,
|
||||||
|
"{what}: a publication was refused"
|
||||||
|
);
|
||||||
|
// Coalescing is allowed, stopping is not.
|
||||||
|
assert!(
|
||||||
|
Metrics::get(&publisher.bus_published) >= 1,
|
||||||
|
"{what}: nothing reached the bus"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rates: meaningful only on a quiet box (see the module comment).
|
||||||
|
fn assert_rates(report: &LoopReport, publisher: &Metrics, what: &str) {
|
||||||
|
// Sleep overshoot is absorbed by the next frame; under one frame at p99 means the loop kept
|
||||||
|
// its absolute deadlines.
|
||||||
|
assert!(
|
||||||
|
report.p99_shortfall < 0.016,
|
||||||
|
"{what}: p99 shortfall {:.2} ms",
|
||||||
|
report.p99_shortfall * 1e3
|
||||||
|
);
|
||||||
|
let published = Metrics::get(&publisher.bus_published);
|
||||||
|
assert!(
|
||||||
|
published * 2 >= report.published,
|
||||||
|
"{what}: only {published} of {} reached the bus",
|
||||||
|
report.published
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECONDS: f64 = 6.0;
|
||||||
|
|
||||||
|
/// What a scenario leaves for the rate checks.
|
||||||
|
struct Outcome {
|
||||||
|
report: LoopReport,
|
||||||
|
publisher: Arc<Metrics>,
|
||||||
|
/// Snapshots the healthy client received, where there is one.
|
||||||
|
healthy_received: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stalled_clients() -> Outcome {
|
||||||
|
let template = full_snapshot();
|
||||||
|
let paths = start(template.clone(), true).await;
|
||||||
|
// Three stages that said hello and then stopped reading: their sockets fill and stay full.
|
||||||
|
let mut stalled = Vec::new();
|
||||||
|
for _ in 0..3 {
|
||||||
|
stalled.push(connect(paths.edge, &["frame", "audio", "spikes"]).await);
|
||||||
|
}
|
||||||
|
// One healthy stage, read continuously.
|
||||||
|
let mut healthy = connect(paths.edge, &["frame", "audio", "spikes"]).await;
|
||||||
|
let newest = Arc::new(AtomicU64::new(0));
|
||||||
|
let received = Arc::new(AtomicU64::new(0));
|
||||||
|
let reader = {
|
||||||
|
let (newest, received) = (Arc::clone(&newest), Arc::clone(&received));
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let message = next_binary(&mut healthy, Duration::from_secs(120)).await;
|
||||||
|
newest.store(seq_of(&message), Ordering::Relaxed);
|
||||||
|
received.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let snapshots = paths.snapshots.clone();
|
||||||
|
let report = tokio::task::spawn_blocking(move || run_loop(snapshots, template, SECONDS))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
print_report(&report, &paths.publisher_metrics, "stalled clients");
|
||||||
|
assert_unharmed(&report, &paths.publisher_metrics, "stalled clients");
|
||||||
|
|
||||||
|
// The healthy client reaches the last snapshot published: the newest one always gets
|
||||||
|
// through, however many in between were coalesced.
|
||||||
|
let last = paths.snapshots.borrow().header.seq;
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(60);
|
||||||
|
while newest.load(Ordering::Relaxed) < last {
|
||||||
|
assert!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"healthy client stuck at {} of {last}",
|
||||||
|
newest.load(Ordering::Relaxed)
|
||||||
|
);
|
||||||
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||||
|
}
|
||||||
|
// The stalled ones are still connected, not dropped for being slow.
|
||||||
|
assert_eq!(paths.edge_metrics.feed.clients(), 4);
|
||||||
|
reader.abort();
|
||||||
|
drop(stalled);
|
||||||
|
Outcome {
|
||||||
|
report,
|
||||||
|
publisher: Arc::clone(&paths.publisher_metrics),
|
||||||
|
healthy_received: Some(received.load(Ordering::Relaxed)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn hoarding_subscriber() -> Outcome {
|
||||||
|
let template = full_snapshot();
|
||||||
|
let paths = start(template.clone(), false).await;
|
||||||
|
// The edge's seat, taken by a subscriber that keeps every delivery it gets.
|
||||||
|
let client = Client::connect_unix(
|
||||||
|
feedbus::socket_path(paths.bus_dir.path()),
|
||||||
|
ClientConfig::new(feedbus::EDGE, feedbus::store_root(paths.bus_dir.path())),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// Every subscription the seat may open, each keeping every delivery at the in-flight cap:
|
||||||
|
// the worst case the store has to hold (`feedbus::limits`, flybus.md "Feed sizing").
|
||||||
|
let seats = feedbus::limits().max_subscriptions_per_client;
|
||||||
|
let mut hoards = Vec::new();
|
||||||
|
for _ in 0..seats {
|
||||||
|
let mut subscription = client
|
||||||
|
.subscribe(
|
||||||
|
feedbus::TOPIC,
|
||||||
|
SubscriptionConfig::latest().in_flight(2).replay(true),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
hoards.push(tokio::spawn(async move {
|
||||||
|
let mut kept = Vec::new();
|
||||||
|
while let Some(message) = subscription.next().await {
|
||||||
|
kept.push(message);
|
||||||
|
}
|
||||||
|
kept.len()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
client
|
||||||
|
.subscribe(feedbus::TOPIC, SubscriptionConfig::latest())
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"a subscription past max_subscriptions_per_client was admitted"
|
||||||
|
);
|
||||||
|
// And the seat is the only one: a second connection as the edge is refused.
|
||||||
|
assert!(
|
||||||
|
Client::connect_unix(
|
||||||
|
feedbus::socket_path(paths.bus_dir.path()),
|
||||||
|
ClientConfig::new(feedbus::EDGE, feedbus::store_root(paths.bus_dir.path())),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"a second client was admitted on edge.sock"
|
||||||
|
);
|
||||||
|
|
||||||
|
let snapshots = paths.snapshots.clone();
|
||||||
|
let report = tokio::task::spawn_blocking(move || run_loop(snapshots, template, SECONDS))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
print_report(&report, &paths.publisher_metrics, "hoarding subscriber");
|
||||||
|
assert_unharmed(&report, &paths.publisher_metrics, "hoarding subscriber");
|
||||||
|
let stats = paths.bus.router.stats();
|
||||||
|
eprintln!(
|
||||||
|
"hoarding subscriber: store {} bytes, retained {} bytes",
|
||||||
|
stats.store_bytes, stats.retained_bytes
|
||||||
|
);
|
||||||
|
// Held: per subscription two in flight and one queued, plus one retained and whatever is
|
||||||
|
// mid-seal: 4 * 3 + 1 + 2 = 15 snapshots at most. Bounded, not growing with the number
|
||||||
|
// published.
|
||||||
|
assert!(
|
||||||
|
stats.store_bytes <= 15 * 122_367,
|
||||||
|
"store holds {} bytes",
|
||||||
|
stats.store_bytes
|
||||||
|
);
|
||||||
|
for hoard in hoards {
|
||||||
|
hoard.abort();
|
||||||
|
}
|
||||||
|
Outcome {
|
||||||
|
report,
|
||||||
|
publisher: Arc::clone(&paths.publisher_metrics),
|
||||||
|
healthy_received: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn absent_edge() -> Outcome {
|
||||||
|
let template = full_snapshot();
|
||||||
|
let paths = start(template.clone(), false).await;
|
||||||
|
let snapshots = paths.snapshots.clone();
|
||||||
|
let report = tokio::task::spawn_blocking(move || run_loop(snapshots, template, SECONDS))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
print_report(&report, &paths.publisher_metrics, "absent edge");
|
||||||
|
assert_unharmed(&report, &paths.publisher_metrics, "absent edge");
|
||||||
|
let stats = paths.bus.router.stats();
|
||||||
|
assert!(
|
||||||
|
stats.store_bytes <= 3 * 122_367,
|
||||||
|
"store holds {} bytes",
|
||||||
|
stats.store_bytes
|
||||||
|
);
|
||||||
|
Outcome {
|
||||||
|
report,
|
||||||
|
publisher: Arc::clone(&paths.publisher_metrics),
|
||||||
|
healthy_received: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
async fn clients_of_the_edge_that_never_read_do_not_lag_the_loop_or_the_healthy_client() {
|
||||||
|
stalled_clients().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
async fn a_bus_subscriber_that_never_releases_does_not_lag_the_loop_or_fill_the_store() {
|
||||||
|
hoarding_subscriber().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
async fn an_absent_edge_costs_the_loop_nothing() {
|
||||||
|
absent_edge().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same three scenarios, plus the rates. A measurement of the box as much as of the bus,
|
||||||
|
/// so not part of the workspace gate.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
#[ignore = "timing: needs a quiet box; run with --release -- --ignored"]
|
||||||
|
async fn the_three_scenarios_keep_their_rates() {
|
||||||
|
for (what, outcome) in [
|
||||||
|
("stalled clients", stalled_clients().await),
|
||||||
|
("hoarding subscriber", hoarding_subscriber().await),
|
||||||
|
("absent edge", absent_edge().await),
|
||||||
|
] {
|
||||||
|
assert_rates(&outcome.report, &outcome.publisher, what);
|
||||||
|
if let Some(got) = outcome.healthy_received {
|
||||||
|
assert!(
|
||||||
|
got * 2 >= outcome.report.published,
|
||||||
|
"{what}: the healthy client got only {got} of {}",
|
||||||
|
outcome.report.published
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -365,6 +365,24 @@ impl Wram {
|
||||||
self.set(ram::wNumSprites, count)
|
self.set(ram::wNumSprites, count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One sprite the cartridge is not drawing: `$ff` in its image index, which is what
|
||||||
|
/// `CheckSpriteAvailability` writes for a sprite off the screen or switched off, and the
|
||||||
|
/// movement byte that decides whether the window test applies to it (row 58).
|
||||||
|
pub fn npc_undrawn(
|
||||||
|
&mut self,
|
||||||
|
slot: u8,
|
||||||
|
picture: u8,
|
||||||
|
x: u8,
|
||||||
|
y: u8,
|
||||||
|
movement: u8,
|
||||||
|
) -> &mut Self {
|
||||||
|
self.npc(slot, picture, x, y, 0x00);
|
||||||
|
let data1 = ram::wSpriteStateData1 + u16::from(slot) * poke::SPRITE_BYTES;
|
||||||
|
let data2 = ram::wSpriteStateData2 + u16::from(slot) * poke::SPRITE_BYTES;
|
||||||
|
self.set(data1 + poke::SPRITE_IMAGE_INDEX, poke::SPRITE_NOT_DRAWN)
|
||||||
|
.set(data2 + poke::SPRITE_MOVEMENT_BYTE, movement)
|
||||||
|
}
|
||||||
|
|
||||||
/// The current map's sign table: `bg_event`s, `Y, X` per entry with no bias, and a text id
|
/// The current map's sign table: `bg_event`s, `Y, X` per entry with no bias, and a text id
|
||||||
/// each.
|
/// each.
|
||||||
pub fn signs(&mut self, signs: &[(u8, u8, u8)]) -> &mut Self {
|
pub fn signs(&mut self, signs: &[(u8, u8, u8)]) -> &mut Self {
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,20 @@ use crate::macros::{
|
||||||
|
|
||||||
use super::super::mapgrid::MapGrids;
|
use super::super::mapgrid::MapGrids;
|
||||||
use super::super::state::PokeState;
|
use super::super::state::PokeState;
|
||||||
use super::cartridge::{Areas, Frontiers, MacroState, Pushed, Stood, Talked, Targets, Tile};
|
use super::cartridge::{
|
||||||
|
Areas, Frontiers, LAST_MAP, MacroState, Pushed, Stood, Talked, Targets, Tile, outdoors,
|
||||||
|
};
|
||||||
use super::geography;
|
use super::geography;
|
||||||
use super::executor::{MacroAbort, MacroMachine, Refusal};
|
use super::executor::{MacroAbort, MacroMachine, Refusal};
|
||||||
use super::palette::{self, MacroId, Palette};
|
use super::palette::{self, MacroId, Palette};
|
||||||
use super::plan;
|
use super::plan;
|
||||||
use super::state::{GameState, Scene};
|
use super::state::{GameState, Scene};
|
||||||
|
|
||||||
|
/// The longest a warp's tear is honoured (row 58): the thirty-two frames measured at the Pewter Gym
|
||||||
|
/// door, with room for a slower fade, and short enough that a false reading costs a second and a
|
||||||
|
/// half of an empty pad rather than a stall.
|
||||||
|
pub const TEAR_FRAMES: u16 = 90;
|
||||||
|
|
||||||
/// The macro palette over Pokémon Red.
|
/// The macro palette over Pokémon Red.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PokemonPalette {
|
pub struct PokemonPalette {
|
||||||
|
|
@ -99,6 +106,19 @@ pub struct PokemonPalette {
|
||||||
nearest: Option<(u8, u32)>,
|
nearest: Option<(u8, u32)>,
|
||||||
/// Whether the last `observe` was the frame that number fell on.
|
/// Whether the last `observe` was the frame that number fell on.
|
||||||
nearer: bool,
|
nearer: bool,
|
||||||
|
/// The map of the last frame that was not a warp's tear, and how many tear frames have run
|
||||||
|
/// since (row 58, [`PokemonPalette::tear`], [`TEAR_FRAMES`]).
|
||||||
|
///
|
||||||
|
/// Measured on the cartridge at the Pewter Gym's door: `wCurMap` changes to the new map
|
||||||
|
/// **thirty-two frames** before the map header, the coordinates and the warp table follow it,
|
||||||
|
/// while the screen fades. On those frames every reading in the seam describes the map the fly
|
||||||
|
/// just left under the new map's id -- the player "on map 54 at (16, 17)", which is Pewter
|
||||||
|
/// City's doormat -- and nothing sets the joypad bits `controllable` reads until the fade is
|
||||||
|
/// over, so the scene read `Overworld` and a pad was dealt. A walk started there plans over
|
||||||
|
/// the wrong map, ends when the cartridge takes the joypad at the end of the fade, and the
|
||||||
|
/// ledgers wrote what it had been aiming at against the new map's id.
|
||||||
|
settled: Option<u8>,
|
||||||
|
tear_frames: u16,
|
||||||
/// The brain clock of the frame being decided, from [`MacroPalette::clock`].
|
/// The brain clock of the frame being decided, from [`MacroPalette::clock`].
|
||||||
///
|
///
|
||||||
/// The blocked ledger is a *window*, so it needs the same clock the loop publishes rather
|
/// The blocked ledger is a *window*, so it needs the same clock the loop publishes rather
|
||||||
|
|
@ -126,10 +146,46 @@ impl PokemonPalette {
|
||||||
grids: MapGrids::default(),
|
grids: MapGrids::default(),
|
||||||
nearest: None,
|
nearest: None,
|
||||||
nearer: false,
|
nearer: false,
|
||||||
|
settled: None,
|
||||||
|
tear_frames: 0,
|
||||||
now_ms: 0.0,
|
now_ms: 0.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether this frame is a warp's tear (row 58): the map byte has changed since the last
|
||||||
|
/// settled frame, and the fly still stands on a warp of the *loaded* table that leads to the
|
||||||
|
/// map the byte now names. Updates the settled map on every frame that is not one.
|
||||||
|
///
|
||||||
|
/// On a tear the warp table is still the map the fly just left, so the tile underfoot is the
|
||||||
|
/// door it walked through: Pewter City's (16, 17), whose destination is the gym, under the
|
||||||
|
/// gym's id; or a building's doormat, whose destination is `LAST_MAP`, under the id of the
|
||||||
|
/// town outside. Once the header loads, the table is the new map's and the tile underfoot is
|
||||||
|
/// the arrival warp, which leads back where the fly came from -- so the reading ends by itself.
|
||||||
|
/// Measured: thirty-two frames at the gym door each way.
|
||||||
|
///
|
||||||
|
/// The map byte having changed is what keeps the teleport pads of Saffron Gym and Silph Co. --
|
||||||
|
/// the three maps in Red with a warp to themselves -- from reading as a tear: a pad moves the
|
||||||
|
/// fly without changing the map. Bounded by [`TEAR_FRAMES`] all the same, because an empty pad
|
||||||
|
/// that did not end would be a fly that waits for ever.
|
||||||
|
fn tear(&mut self, state: &mut dyn MacroState) -> bool {
|
||||||
|
let Some(player) = state.player() else { return false };
|
||||||
|
let changed = self.settled.is_some_and(|was| was != player.map);
|
||||||
|
let torn = changed
|
||||||
|
&& self.tear_frames < TEAR_FRAMES
|
||||||
|
&& state.warps().iter().any(|warp| {
|
||||||
|
(warp.x, warp.y) == (player.x, player.y)
|
||||||
|
&& (warp.destination_map == player.map
|
||||||
|
|| (warp.destination_map == LAST_MAP && outdoors(player.map)))
|
||||||
|
});
|
||||||
|
if torn {
|
||||||
|
self.tear_frames += 1;
|
||||||
|
} else {
|
||||||
|
self.tear_frames = 0;
|
||||||
|
self.settled = Some(player.map);
|
||||||
|
}
|
||||||
|
torn
|
||||||
|
}
|
||||||
|
|
||||||
/// Frames the running macro has spent, for a log line.
|
/// Frames the running macro has spent, for a log line.
|
||||||
pub fn frames(&self) -> u32 {
|
pub fn frames(&self) -> u32 {
|
||||||
self.machine.frames()
|
self.machine.frames()
|
||||||
|
|
@ -217,7 +273,7 @@ impl PokemonPalette {
|
||||||
}
|
}
|
||||||
// A tile the cartridge drove the fly off: no window, because the map is like that until
|
// A tile the cartridge drove the fly off: no window, because the map is like that until
|
||||||
// the event that unlocks it, and nothing here knows which event that is (row 37).
|
// the event that unlocks it, and nothing here knows which event that is (row 37).
|
||||||
if let Some((map, tile)) = self.machine.take_pushed() {
|
while let Some((map, tile)) = self.machine.take_pushed() {
|
||||||
self.pushed.record(map, tile);
|
self.pushed.record(map, tile);
|
||||||
}
|
}
|
||||||
// A refusal from where the fly is standing: that button is not dealt again from this tile
|
// A refusal from where the fly is standing: that button is not dealt again from this tile
|
||||||
|
|
@ -243,6 +299,10 @@ impl MacroPalette for PokemonPalette {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn observe(&mut self, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger) -> Observed {
|
fn observe(&mut self, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger) -> Observed {
|
||||||
|
let torn = {
|
||||||
|
let mut state = PokeState::new(memory);
|
||||||
|
self.tear(&mut state)
|
||||||
|
};
|
||||||
let (scene, bindings, standing, stepping, approach) = {
|
let (scene, bindings, standing, stepping, approach) = {
|
||||||
let Self {
|
let Self {
|
||||||
machine,
|
machine,
|
||||||
|
|
@ -265,7 +325,10 @@ impl MacroPalette for PokemonPalette {
|
||||||
// `GameState::scene` is `pokemon_red::scene::detect` over the same reader, so the
|
// `GameState::scene` is `pokemon_red::scene::detect` over the same reader, so the
|
||||||
// palette and the scene the feed reports cannot disagree about which frame they are
|
// palette and the scene the feed reports cannot disagree about which frame they are
|
||||||
// for.
|
// for.
|
||||||
let scene = state.scene();
|
// A warp's tear is a warp in flight: the cartridge is driving and the seam's readings
|
||||||
|
// are the last map's under the new map's id, which is section 12.13's `Unknown` with
|
||||||
|
// nothing on screen -- an empty pad the fly waits out, for thirty-two frames (row 58).
|
||||||
|
let scene = if torn { Scene::Unknown } else { state.scene() };
|
||||||
// Whether a conversation has ended, and how, is a question about the frames *after*
|
// Whether a conversation has ended, and how, is a question about the frames *after*
|
||||||
// the `TALK` gave the buttons back, so the machine is given every frame rather than
|
// the `TALK` gave the buttons back, so the machine is given every frame rather than
|
||||||
// only the ones it owns (`docs/design/macros.md` section 12.4).
|
// only the ones it owns (`docs/design/macros.md` section 12.4).
|
||||||
|
|
@ -279,7 +342,7 @@ impl MacroPalette for PokemonPalette {
|
||||||
// master: while the cartridge is walking it -- a warp in flight, a ledge hop, a script
|
// master: while the cartridge is walking it -- a warp in flight, a ledge hop, a script
|
||||||
// -- the coordinates and the loaded map header are from different frames, and a tile
|
// -- the coordinates and the loaded map header are from different frames, and a tile
|
||||||
// recorded from that pair is a tile of nowhere.
|
// recorded from that pair is a tile of nowhere.
|
||||||
let standing = (!state.scripted()).then(|| state.player()).flatten();
|
let standing = (!state.scripted() && !torn).then(|| state.player()).flatten();
|
||||||
// And the tile the step in flight is landing on (row 54). Read from the same frame and
|
// 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.
|
// behind the same "the fly is its own master" gate as the ground itself.
|
||||||
let stepping = standing.and_then(|_| state.stepping_onto());
|
let stepping = standing.and_then(|_| state.stepping_onto());
|
||||||
|
|
@ -455,7 +518,7 @@ impl MacroPalette for PokemonPalette {
|
||||||
let _ = self.machine.take_reached();
|
let _ = self.machine.take_reached();
|
||||||
// A rollback is not the map pushing the fly anywhere, nor its frontier going out of
|
// A rollback is not the map pushing the fly anywhere, nor its frontier going out of
|
||||||
// reach: the fly is about to be standing somewhere else.
|
// reach: the fly is about to be standing somewhere else.
|
||||||
let _ = self.machine.take_pushed();
|
while self.machine.take_pushed().is_some() {}
|
||||||
let _ = self.machine.take_exhausted();
|
let _ = self.machine.take_exhausted();
|
||||||
let _ = self.machine.take_refused();
|
let _ = self.machine.take_refused();
|
||||||
// The cached palette was dealt for a frame that is being thrown away. Dropping it makes
|
// The cached palette was dealt for a frame that is being thrown away. Dropping it makes
|
||||||
|
|
@ -525,6 +588,57 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_warps_tear_deals_no_pad() {
|
||||||
|
// Row 58, measured at the Pewter Gym's door: `wCurMap` names the gym for thirty-two frames
|
||||||
|
// while the header, the coordinates and the warp table are still Pewter City's -- "map 54
|
||||||
|
// at (16, 17)", which is the town's doormat. A pad dealt there started a walk over the
|
||||||
|
// wrong map, and what it was aiming at went into the ledgers under the gym's id.
|
||||||
|
let mut wram = Wram::overworld();
|
||||||
|
wram.map(maps::PEWTER_CITY, 20, 18, 16, 18)
|
||||||
|
.warps(&[(16, 17, 0, maps::PEWTER_GYM), (29, 13, 0, maps::PEWTER_MUSEUM_1F)]);
|
||||||
|
let mut palette = PokemonPalette::new(7);
|
||||||
|
let settled = palette.observe(&mut wram, &NoLedger);
|
||||||
|
assert_eq!(settled.scene, SceneId::Overworld);
|
||||||
|
|
||||||
|
// The step onto the door lands and the map byte changes; nothing else has loaded.
|
||||||
|
wram.map(maps::PEWTER_GYM, 20, 18, 16, 17);
|
||||||
|
let torn = palette.observe(&mut wram, &NoLedger);
|
||||||
|
assert_eq!(torn.scene, SceneId::Unknown, "a warp in flight");
|
||||||
|
assert!(torn.bindings.is_empty(), "and nothing to press: {:?}", torn.bindings);
|
||||||
|
assert_eq!(palette.observe(&mut wram, &NoLedger).scene, SceneId::Unknown);
|
||||||
|
|
||||||
|
// The header loads: the gym's own size, its doormat, its own table.
|
||||||
|
wram.map(maps::PEWTER_GYM, 5, 7, 4, 13).warps(&[(4, 13, 2, 0xff), (5, 13, 2, 0xff)]);
|
||||||
|
assert_eq!(palette.observe(&mut wram, &NoLedger).scene, SceneId::Overworld);
|
||||||
|
|
||||||
|
// And out again: the doormat's `LAST_MAP` under the town's id is the same tear.
|
||||||
|
wram.map(maps::PEWTER_CITY, 5, 7, 4, 13);
|
||||||
|
assert_eq!(palette.observe(&mut wram, &NoLedger).scene, SceneId::Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_teleport_pad_is_not_a_tear() {
|
||||||
|
// Saffron Gym and two Silph Co. floors warp to themselves. Standing on a pad whose
|
||||||
|
// destination is the map the fly is on is an ordinary frame there, and an empty pad on it
|
||||||
|
// would be a fly that waits for ever: the map byte did not change, so it is not a tear.
|
||||||
|
let mut wram = Wram::overworld();
|
||||||
|
wram.map(0xb2, 10, 9, 1, 1).warps(&[(1, 1, 3, 0xb2), (5, 5, 0, 0xb2)]);
|
||||||
|
let mut palette = PokemonPalette::new(7);
|
||||||
|
for _ in 0..3 {
|
||||||
|
assert_eq!(palette.observe(&mut wram, &NoLedger).scene, SceneId::Overworld);
|
||||||
|
}
|
||||||
|
// And a tear that does not end is still bounded.
|
||||||
|
wram.map(0x02, 10, 9, 1, 1).warps(&[(1, 1, 0, 0x02)]);
|
||||||
|
let mut torn = 0;
|
||||||
|
for _ in 0..(TEAR_FRAMES + 10) {
|
||||||
|
if palette.observe(&mut wram, &NoLedger).scene == SceneId::Unknown {
|
||||||
|
torn += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(torn, u32::from(TEAR_FRAMES), "at most {TEAR_FRAMES} frames");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_tile_a_step_is_landing_on_is_ground_the_run_has_covered() {
|
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
|
// Row 54 of `infra/docs/macros-traps.md`. `wXCoord` and `wYCoord` are the tile the step
|
||||||
|
|
|
||||||
|
|
@ -541,6 +541,16 @@ struct PendingTalk {
|
||||||
at: Tile,
|
at: Tile,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a macro the cartridge ended by taking the joypad earned, held until the cartridge gives
|
||||||
|
/// the joypad back ([`MacroMachine::pending_push`], row 58).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
struct PendingPush {
|
||||||
|
/// The tile the fly was driven off, for the pushed ledger (row 37).
|
||||||
|
tile: Option<(u8, Tile)>,
|
||||||
|
/// The target the macro was aimed at, for the blocked ledger (section 12.4).
|
||||||
|
target: Option<(u8, TargetKey)>,
|
||||||
|
}
|
||||||
|
|
||||||
/// A walk the frame cap cut short, as the next start needs it.
|
/// A walk the frame cap cut short, as the next start needs it.
|
||||||
///
|
///
|
||||||
/// Keyed by the target rather than by the macro, because that is what a resumed walk *is*: the
|
/// Keyed by the target rather than by the macro, because that is what a resumed walk *is*: the
|
||||||
|
|
@ -589,7 +599,7 @@ pub struct MacroMachine {
|
||||||
/// and the blocked ledger could only say it about the target the walk was aimed at. Recorded
|
/// and the blocked ledger could only say it about the target the walk was aimed at. Recorded
|
||||||
/// from the frame the push is seen, because by the time the next macro starts the fly has been
|
/// from the frame the push is seen, because by the time the next macro starts the fly has been
|
||||||
/// walked somewhere else.
|
/// walked somewhere else.
|
||||||
pushed_tile: Option<(u8, Tile)>,
|
pushed_tile: Vec<(u8, Tile)>,
|
||||||
/// A refusal the route search or the precondition made, and where the fly stood for it --
|
/// A refusal the route search or the precondition made, and where the fly stood for it --
|
||||||
/// `(map, slot, tile)`, waiting to be taken into the session's ledger
|
/// `(map, slot, tile)`, waiting to be taken into the session's ledger
|
||||||
/// ([`super::cartridge::Targets::record_refused`], row 57 of `infra/docs/macros-traps.md`).
|
/// ([`super::cartridge::Targets::record_refused`], row 57 of `infra/docs/macros-traps.md`).
|
||||||
|
|
@ -637,6 +647,21 @@ pub struct MacroMachine {
|
||||||
/// the next press will not undo. One hold of frames is the window, because that is how long
|
/// the next press will not undo. One hold of frames is the window, because that is how long
|
||||||
/// the fly has to choose again; anything later and something else happened in between.
|
/// the fly has to choose again; anything later and something else happened in between.
|
||||||
pending_answer: Option<PendingAnswer>,
|
pending_answer: Option<PendingAnswer>,
|
||||||
|
/// A macro the cartridge ended by taking the joypad, whose ledger entries wait for the
|
||||||
|
/// cartridge to give it back (row 58).
|
||||||
|
///
|
||||||
|
/// Section 12.4 and row 37 read "the cartridge took the joypad" as the cartridge *refusing*
|
||||||
|
/// the step -- the Viridian gate's "This is private property!" and the walk back -- and wrote
|
||||||
|
/// the target into the blocked ledger and the tile into the pushed one on the spot. A trainer
|
||||||
|
/// who sees the fly takes the joypad the same way: the "!", the walk up, the challenge. In the
|
||||||
|
/// Pewter Gym that cost BROCK: the fly walked toward him past the Jr. Trainer's line of sight,
|
||||||
|
/// the trainer's walk ended the macro, BROCK went into the blocked ledger for ten brain
|
||||||
|
/// minutes and the tile the walk set out from into the pushed one for the session -- and a fly
|
||||||
|
/// that lost the battle and walked back found the leader excluded and the way out on the pad.
|
||||||
|
/// What the cartridge does when it gives the joypad back is what says which it was: back in
|
||||||
|
/// the overworld is a refusal and is written as one; a battle is a battle, and nothing about
|
||||||
|
/// the target or the ground is learned from it.
|
||||||
|
pending_push: Vec<PendingPush>,
|
||||||
/// A finished `TALK`'s target, waiting to be taken into the session's talked ledger.
|
/// A finished `TALK`'s target, waiting to be taken into the session's talked ledger.
|
||||||
///
|
///
|
||||||
/// The machine records rather than keeps: the ledger is the driver's
|
/// The machine records rather than keeps: the ledger is the driver's
|
||||||
|
|
@ -664,12 +689,13 @@ impl MacroMachine {
|
||||||
blocked: Vec::new(),
|
blocked: Vec::new(),
|
||||||
reached: None,
|
reached: None,
|
||||||
exhausted: None,
|
exhausted: None,
|
||||||
pushed_tile: None,
|
pushed_tile: Vec::new(),
|
||||||
refused_at: None,
|
refused_at: None,
|
||||||
timed_out: None,
|
timed_out: None,
|
||||||
resume: VecDeque::new(),
|
resume: VecDeque::new(),
|
||||||
pending_talk: None,
|
pending_talk: None,
|
||||||
pending_answer: None,
|
pending_answer: None,
|
||||||
|
pending_push: Vec::new(),
|
||||||
talked: None,
|
talked: None,
|
||||||
rng: if seed == 0 { 1 } else { seed },
|
rng: if seed == 0 { 1 } else { seed },
|
||||||
}
|
}
|
||||||
|
|
@ -903,8 +929,11 @@ impl MacroMachine {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The tile a scripted push-back earned, taken rather than read (row 37).
|
/// The tile a scripted push-back earned, taken rather than read (row 37).
|
||||||
|
///
|
||||||
|
/// Call it until it answers `None`: the entries a script held back are written together when
|
||||||
|
/// it gives the joypad back (row 58).
|
||||||
pub fn take_pushed(&mut self) -> Option<(u8, Tile)> {
|
pub fn take_pushed(&mut self) -> Option<(u8, Tile)> {
|
||||||
self.pushed_tile.take()
|
if self.pushed_tile.is_empty() { None } else { Some(self.pushed_tile.remove(0)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where the last `no route` or `precondition` refusal happened, taken rather than read
|
/// Where the last `no route` or `precondition` refusal happened, taken rather than read
|
||||||
|
|
@ -936,7 +965,7 @@ impl MacroMachine {
|
||||||
self.reached = None;
|
self.reached = None;
|
||||||
// A rollback is not the map pushing the fly anywhere, and it is not the frontier being
|
// A rollback is not the map pushing the fly anywhere, and it is not the frontier being
|
||||||
// out of reach either: the fly is about to be somewhere else entirely.
|
// out of reach either: the fly is about to be somewhere else entirely.
|
||||||
self.pushed_tile = None;
|
self.pushed_tile.clear();
|
||||||
self.exhausted = None;
|
self.exhausted = None;
|
||||||
self.refused_at = None;
|
self.refused_at = None;
|
||||||
self.timed_out = None;
|
self.timed_out = None;
|
||||||
|
|
@ -948,6 +977,8 @@ impl MacroMachine {
|
||||||
self.pending_talk = None;
|
self.pending_talk = None;
|
||||||
// Nor is it a prompt reopening: the frames the answer was made in are being thrown away.
|
// Nor is it a prompt reopening: the frames the answer was made in are being thrown away.
|
||||||
self.pending_answer = None;
|
self.pending_answer = None;
|
||||||
|
// Nor the cartridge refusing a step: the frames it happened in are being thrown away too.
|
||||||
|
self.pending_push.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the fly is standing somewhere other than where the running macro began.
|
/// Whether the fly is standing somewhere other than where the running macro began.
|
||||||
|
|
@ -976,6 +1007,7 @@ impl MacroMachine {
|
||||||
/// - the fly answered `NO` — not talked, and that one is decided in [`MacroMachine::finish`].
|
/// - the fly answered `NO` — not talked, and that one is decided in [`MacroMachine::finish`].
|
||||||
pub fn observe_frame(&mut self, state: &mut dyn MacroState) {
|
pub fn observe_frame(&mut self, state: &mut dyn MacroState) {
|
||||||
self.observe_answer(state);
|
self.observe_answer(state);
|
||||||
|
self.observe_push(state);
|
||||||
let Some(pending) = self.pending_talk else { return };
|
let Some(pending) = self.pending_talk else { return };
|
||||||
if state.scripted() {
|
if state.scripted() {
|
||||||
self.pending_talk = None;
|
self.pending_talk = None;
|
||||||
|
|
@ -1002,6 +1034,34 @@ impl MacroMachine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One frame after the cartridge took the joypad from a macro: decide what it was (row 58).
|
||||||
|
///
|
||||||
|
/// Back in the overworld with the buttons the fly's again: a refusal, written exactly as
|
||||||
|
/// section 12.4 and row 37 always wrote it. A battle: a trainer's challenge, and it teaches the
|
||||||
|
/// ledgers nothing. Anything else -- the text, the walk, the frames between -- is still the
|
||||||
|
/// cartridge's, and the decision waits.
|
||||||
|
fn observe_push(&mut self, state: &mut dyn MacroState) {
|
||||||
|
if self.pending_push.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match class(state.scene()) {
|
||||||
|
Class::Battle | Class::ForcedSwitch => self.pending_push.clear(),
|
||||||
|
Class::Overworld => {
|
||||||
|
// Every macro the script ended while it held the joypad -- the walk it interrupted
|
||||||
|
// and any press made into its text -- in the order they ended.
|
||||||
|
for pending in std::mem::take(&mut self.pending_push) {
|
||||||
|
if let Some(tile) = pending.tile {
|
||||||
|
self.pushed_tile.push(tile);
|
||||||
|
}
|
||||||
|
if let Some(target) = pending.target {
|
||||||
|
self.blocked.push(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the answer still standing on `map` is a `NO` to a readable prompt: a declined offer
|
/// Whether the answer still standing on `map` is a `NO` to a readable prompt: a declined offer
|
||||||
/// rather than a conversation walked through (section 12.20).
|
/// rather than a conversation walked through (section 12.20).
|
||||||
fn declined_out_of(&self, map: u8) -> bool {
|
fn declined_out_of(&self, map: u8) -> bool {
|
||||||
|
|
@ -1127,14 +1187,18 @@ impl MacroMachine {
|
||||||
// entrance -- with no window, in the middle of the town. A dozen of those fenced
|
// entrance -- with no window, in the middle of the town. A dozen of those fenced
|
||||||
// the fly into a pocket no walk could leave. The tile a walk last stood the fly on
|
// the fly into a pocket no walk could leave. The tile a walk last stood the fly on
|
||||||
// is its own record of where the cartridge took over.
|
// is its own record of where the cartridge took over.
|
||||||
if let Some(player) = at {
|
//
|
||||||
|
// Held until the cartridge gives the joypad back, which is what says whether this
|
||||||
|
// was a refusal or a trainer walking up (row 58, [`MacroMachine::observe_push`]).
|
||||||
|
let tile = at.map(|player| {
|
||||||
let current = Tile::new(player.x, player.y);
|
let current = Tile::new(player.x, player.y);
|
||||||
let tile = match active.plan.front() {
|
let tile = match active.plan.front() {
|
||||||
Some(Step::Walk(walk)) => walk.expect.unwrap_or(current),
|
Some(Step::Walk(walk)) => walk.expect.unwrap_or(current),
|
||||||
_ => active.from.unwrap_or(current),
|
_ => active.from.unwrap_or(current),
|
||||||
};
|
};
|
||||||
self.pushed_tile = Some((player.map, tile));
|
(player.map, tile)
|
||||||
}
|
});
|
||||||
|
self.pending_push.push(PendingPush { tile, target: None });
|
||||||
}
|
}
|
||||||
let (closer, stalled) = walk_flags(&active);
|
let (closer, stalled) = walk_flags(&active);
|
||||||
// A walk the cap cut short keeps its route for the next hold; any other ending means
|
// A walk the cap cut short keeps its route for the next hold; any other ending means
|
||||||
|
|
@ -1181,8 +1245,15 @@ impl MacroMachine {
|
||||||
// target's own fact, not the world's, so it is excluded for the window like
|
// target's own fact, not the world's, so it is excluded for the window like
|
||||||
// any other refusal. Without it the gate was walked into once per hold for
|
// any other refusal. Without it the gate was walked into once per hold for
|
||||||
// ever, because every macro that hit it ended `Done`.
|
// ever, because every macro that hit it ended `Done`.
|
||||||
if pushed && let Some(entry) = active.target {
|
//
|
||||||
self.blocked.push(entry);
|
// Held with the tile above, and for the same reason: a trainer's walk up to the
|
||||||
|
// fly takes the joypad exactly as the gate's walk back does, and only what the
|
||||||
|
// cartridge does next tells them apart (row 58).
|
||||||
|
if pushed
|
||||||
|
&& let Some(entry) = active.target
|
||||||
|
&& let Some(pending) = self.pending_push.last_mut()
|
||||||
|
{
|
||||||
|
pending.target = Some(entry);
|
||||||
}
|
}
|
||||||
// A `GO FRONTIER` whose press faced new ground it could not stand on: `Done`,
|
// A `GO FRONTIER` whose press faced new ground it could not stand on: `Done`,
|
||||||
// because facing it is what the arrival promises, and excluded, because the
|
// because facing it is what the arrival promises, and excluded, because the
|
||||||
|
|
|
||||||
|
|
@ -1687,9 +1687,18 @@ pub fn objective_goals(state: &mut dyn MacroState) -> Vec<Aim> {
|
||||||
if objective.target.is_some() {
|
if objective.target.is_some() {
|
||||||
let ahead = Tile::new(player.x, player.y).step(player.facing);
|
let ahead = Tile::new(player.x, player.y).step(player.facing);
|
||||||
let here_tile = Tile::new(player.x, player.y);
|
let here_tile = Tile::new(player.x, player.y);
|
||||||
let mut ranked: Vec<(u32, Tile, TalkTarget)> = objective_targets(state)
|
let targets = objective_targets(state);
|
||||||
|
// **Facing any of them is the arrival** (row 58). With one target this was already
|
||||||
|
// true -- the thing ahead is left out and nothing else is left -- but a gym has three
|
||||||
|
// people the ladder names, and standing in front of the leader left the Jr. Trainer
|
||||||
|
// to walk to: `GO OBJECTIVE` walked to him, then back to the leader, and `TALK` was
|
||||||
|
// the one press it never made room for. A fly facing a person the rung is waiting on
|
||||||
|
// has nothing left for a walk to do.
|
||||||
|
if targets.iter().any(|(tile, _)| Some(*tile) == ahead) {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let mut ranked: Vec<(u32, Tile, TalkTarget)> = targets
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|(tile, _)| Some(*tile) != ahead)
|
|
||||||
.map(|(tile, target)| (tile.distance(here_tile), tile, target))
|
.map(|(tile, target)| (tile.distance(here_tile), tile, target))
|
||||||
.collect();
|
.collect();
|
||||||
ranked.sort_unstable();
|
ranked.sort_unstable();
|
||||||
|
|
@ -1795,7 +1804,18 @@ pub fn objective_targets(state: &mut dyn MacroState) -> Vec<(Tile, TalkTarget)>
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
let targets = match kind {
|
let targets = match kind {
|
||||||
PlaceKind::Person => path::person_targets(state),
|
// Row 58: the room's people, drawn or not. From the Pewter Gym's doormat the only person
|
||||||
|
// on screen is the guide, and with him talked to this list was empty -- so `GO OUT` was a
|
||||||
|
// candidate and `GO OBJECTIVE` had nothing to aim at, while BROCK stood twelve tiles up the
|
||||||
|
// room, outside the window the cartridge draws. The whole map's grid is what the walk
|
||||||
|
// plans over (section 15), so a person off the screen is somewhere a walk can go.
|
||||||
|
PlaceKind::Person => {
|
||||||
|
let mut all = path::person_targets(state);
|
||||||
|
all.extend(path::offscreen_person_targets(state));
|
||||||
|
all
|
||||||
|
}
|
||||||
|
// Not objects: every item ball in the game is a toggleable object, so a ball the run has
|
||||||
|
// picked up and one out of sight read alike from outside the window.
|
||||||
PlaceKind::Object => path::interactable_targets(state),
|
PlaceKind::Object => path::interactable_targets(state),
|
||||||
};
|
};
|
||||||
targets
|
targets
|
||||||
|
|
|
||||||
|
|
@ -348,6 +348,28 @@ pub fn person_targets(state: &mut dyn MacroState) -> Vec<(Tile, TalkTarget)> {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The people of this map the cartridge is not drawing only because they are off the screen,
|
||||||
|
/// keyed as [`person_targets`] keys the drawn ones (row 58).
|
||||||
|
///
|
||||||
|
/// Kept apart from [`person_targets`] on purpose: that list is what `GO NPC`, `TALK` and the
|
||||||
|
/// talked ledger's facing test read, and a sprite outside the window may also be a toggleable
|
||||||
|
/// object the cartridge has switched off ([`GameState::offscreen_npcs`]). The one reader is the
|
||||||
|
/// ladder's own target list, which has to know the leader is in the room before the fly can see
|
||||||
|
/// him.
|
||||||
|
///
|
||||||
|
/// [`GameState::offscreen_npcs`]: super::state::GameState::offscreen_npcs
|
||||||
|
pub fn offscreen_person_targets(state: &mut dyn MacroState) -> Vec<(Tile, TalkTarget)> {
|
||||||
|
let mut out: Vec<(Tile, TalkTarget)> = state
|
||||||
|
.offscreen_npcs()
|
||||||
|
.iter()
|
||||||
|
.filter(|npc| npc.person())
|
||||||
|
.map(|npc| (Tile::new(npc.x, npc.y), TalkTarget::Sprite(npc.slot)))
|
||||||
|
.collect();
|
||||||
|
out.sort_unstable();
|
||||||
|
out.dedup();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// What is on `tile`: the thing a press at it would talk to, or `None` for bare ground.
|
/// What is on `tile`: the thing a press at it would talk to, or `None` for bare ground.
|
||||||
///
|
///
|
||||||
/// People first, because a person standing on a sign's tile is what the press would reach.
|
/// People first, because a person standing on a sign's tile is what the press would reach.
|
||||||
|
|
|
||||||
|
|
@ -655,6 +655,15 @@ pub trait GameState {
|
||||||
/// the same sixteen slots. [`Npc::person`] is the test that separates the two.
|
/// the same sixteen slots. [`Npc::person`] is the test that separates the two.
|
||||||
fn npcs(&mut self) -> Vec<Npc>;
|
fn npcs(&mut self) -> Vec<Npc>;
|
||||||
|
|
||||||
|
/// Sprites of the current map the cartridge is not drawing only because they are off the
|
||||||
|
/// screen (row 58, `pokemon_red::state::offscreen_npcs`).
|
||||||
|
///
|
||||||
|
/// Defaulted to none, which narrows: a seam that cannot answer knows the drawn sprites and
|
||||||
|
/// nothing more, which is what every reader had before row 58.
|
||||||
|
fn offscreen_npcs(&mut self) -> Vec<Npc> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
/// The current map's signs, i.e. its `bg_event` text tiles.
|
/// The current map's signs, i.e. its `bg_event` text tiles.
|
||||||
///
|
///
|
||||||
/// Empty on a map with none. Required rather than defaulted like the rest of this trait: an
|
/// Empty on a map with none. Required rather than defaulted like the rest of this trait: an
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ use crate::adapter::PlaceKind;
|
||||||
use crate::emulator::buttons;
|
use crate::emulator::buttons;
|
||||||
|
|
||||||
use super::cartridge::{
|
use super::cartridge::{
|
||||||
BLOCKED_MINUTES_DEFAULT, CHEAPEST_PURCHASE, Edge, ExitId, FACINGS, MacroState, Objective,
|
BLOCKED_MINUTES_DEFAULT, CHEAPEST_PURCHASE, Edge, ExitId, FACINGS, LAST_MAP, MacroState, Objective,
|
||||||
TalkTarget, TargetKey, TargetLedger, Targets, Tile, battle_entry, button, item, price,
|
TalkTarget, TargetKey, TargetLedger, Targets, Tile, battle_entry, button, item, price,
|
||||||
};
|
};
|
||||||
use super::geography::Amenity;
|
use super::geography::Amenity;
|
||||||
|
|
@ -89,6 +89,8 @@ struct World {
|
||||||
warps: Vec<Warp>,
|
warps: Vec<Warp>,
|
||||||
connections: Connections,
|
connections: Connections,
|
||||||
npcs: Vec<Npc>,
|
npcs: Vec<Npc>,
|
||||||
|
/// Sprites the cartridge is not drawing only because they are off the screen (row 58).
|
||||||
|
offscreen: Vec<Npc>,
|
||||||
signs: Vec<Sign>,
|
signs: Vec<Sign>,
|
||||||
|
|
||||||
list: List,
|
list: List,
|
||||||
|
|
@ -223,6 +225,7 @@ impl World {
|
||||||
warps: Vec::new(),
|
warps: Vec::new(),
|
||||||
connections: Connections::default(),
|
connections: Connections::default(),
|
||||||
npcs: Vec::new(),
|
npcs: Vec::new(),
|
||||||
|
offscreen: Vec::new(),
|
||||||
signs: Vec::new(),
|
signs: Vec::new(),
|
||||||
list: List::None,
|
list: List::None,
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
|
|
@ -636,6 +639,10 @@ impl GameState for World {
|
||||||
self.npcs.clone()
|
self.npcs.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn offscreen_npcs(&mut self) -> Vec<Npc> {
|
||||||
|
self.offscreen.clone()
|
||||||
|
}
|
||||||
|
|
||||||
fn signs(&mut self) -> Vec<Sign> {
|
fn signs(&mut self) -> Vec<Sign> {
|
||||||
self.signs.clone()
|
self.signs.clone()
|
||||||
}
|
}
|
||||||
|
|
@ -811,6 +818,13 @@ fn drive(
|
||||||
// The loop's own bookkeeping, so a test sees what the next decision would see: whatever the
|
// The loop's own bookkeeping, so a test sees what the next decision would see: whatever the
|
||||||
// finish earned goes into the session's ledgers, which is `PokemonPalette::record_talk`'s job
|
// finish earned goes into the session's ledgers, which is `PokemonPalette::record_talk`'s job
|
||||||
// in the sim loop and this line's here.
|
// in the sim loop and this line's here.
|
||||||
|
settle(machine, world);
|
||||||
|
Ok(machine.outcome().expect("a finished macro has an outcome").1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PokemonPalette::record_talk`, over the fixture: whatever the machine has earned goes into the
|
||||||
|
/// session's ledgers.
|
||||||
|
fn settle(machine: &mut MacroMachine, world: &mut World) {
|
||||||
while let Some((map, target)) = machine.take_blocked() {
|
while let Some((map, target)) = machine.take_blocked() {
|
||||||
world.targets.record_blocked(map, target);
|
world.targets.record_blocked(map, target);
|
||||||
}
|
}
|
||||||
|
|
@ -823,11 +837,25 @@ fn drive(
|
||||||
if let Some((map, target)) = machine.take_reached() {
|
if let Some((map, target)) = machine.take_reached() {
|
||||||
world.targets.record_reached(map, target);
|
world.targets.record_reached(map, target);
|
||||||
}
|
}
|
||||||
|
while let Some((map, tile)) = machine.take_pushed() {
|
||||||
|
assert_eq!(map, world.map);
|
||||||
|
world.pushes.insert(tile);
|
||||||
|
}
|
||||||
if let Some((map, target)) = machine.take_talked() {
|
if let Some((map, target)) = machine.take_talked() {
|
||||||
assert_eq!(map, world.map);
|
assert_eq!(map, world.map);
|
||||||
world.talked.insert(target);
|
world.talked.insert(target);
|
||||||
}
|
}
|
||||||
Ok(machine.outcome().expect("a finished macro has an outcome").1)
|
}
|
||||||
|
|
||||||
|
/// The cartridge gives the joypad back in the overworld: one frame of it, observed, and whatever
|
||||||
|
/// it decided taken into the ledgers (row 58).
|
||||||
|
fn hand_back(machine: &mut MacroMachine, world: &mut World) {
|
||||||
|
world.scene = Scene::Overworld;
|
||||||
|
world.scripted = false;
|
||||||
|
world.scripted_at = None;
|
||||||
|
world.switch = None;
|
||||||
|
machine.observe_frame(world);
|
||||||
|
settle(machine, world);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A palette of exactly one button, for a script whose macro no scene binds any more.
|
/// A palette of exactly one button, for a script whose macro no scene binds any more.
|
||||||
|
|
@ -3510,7 +3538,11 @@ fn a_walk_the_cartridge_pushes_back_excludes_what_it_was_walking_to() {
|
||||||
let north = TargetKey::Exit(ExitId::Edge(Edge::North));
|
let north = TargetKey::Exit(ExitId::Edge(Edge::North));
|
||||||
|
|
||||||
assert!(on_the_pad(&mut world, MacroKind::GoRoute));
|
assert!(on_the_pad(&mut world, MacroKind::GoRoute));
|
||||||
assert_eq!(run(&mut world, MacroKind::GoRoute), Ok(MacroAbort::Done));
|
let mut machine = MacroMachine::new(0x1234_5678);
|
||||||
|
assert_eq!(run_with(&mut machine, &mut world, MacroKind::GoRoute), Ok(MacroAbort::Done));
|
||||||
|
// The gate's text is still up: the cartridge has not given the joypad back (row 58).
|
||||||
|
assert!(!world.targets.blocked(world.map, north), "nothing decided inside the script");
|
||||||
|
hand_back(&mut machine, &mut world);
|
||||||
assert!(
|
assert!(
|
||||||
world.targets.blocked(world.map, north),
|
world.targets.blocked(world.map, north),
|
||||||
"the road the cartridge refused is excluded for the window"
|
"the road the cartridge refused is excluded for the window"
|
||||||
|
|
@ -5023,10 +5055,13 @@ fn an_escorted_walk_walls_the_tile_it_reached_not_the_one_it_set_out_from() {
|
||||||
|
|
||||||
let mut machine = MacroMachine::new(1);
|
let mut machine = MacroMachine::new(1);
|
||||||
let _ = run_with(&mut machine, &mut world, MacroKind::GoRoute);
|
let _ = run_with(&mut machine, &mut world, MacroKind::GoRoute);
|
||||||
let (map, tile) = machine.take_pushed().expect("the script moved the fly: a push-back");
|
// Row 58: written when the cartridge gives the joypad back in the overworld.
|
||||||
assert_eq!(map, maps::PEWTER_CITY);
|
let reached = world.player;
|
||||||
assert_ne!(tile, Tile::new(3, 6), "not the tile the walk set out from");
|
hand_back(&mut machine, &mut world);
|
||||||
assert_eq!(tile, world.player, "the tile the walk had reached when the script took over");
|
let pushed: Vec<Tile> = world.pushes.iter().copied().collect();
|
||||||
|
assert_eq!(pushed.len(), 1, "the script moved the fly: a push-back");
|
||||||
|
assert_ne!(pushed[0], Tile::new(3, 6), "not the tile the walk set out from");
|
||||||
|
assert_eq!(pushed[0], reached, "the tile the walk had reached when the script took over");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The push-back writes the ledger, and it writes the *tile* rather than the target.
|
/// The push-back writes the ledger, and it writes the *tile* rather than the target.
|
||||||
|
|
@ -5042,10 +5077,13 @@ fn a_scripted_push_back_records_the_tile_it_happened_on() {
|
||||||
|
|
||||||
let mut machine = MacroMachine::new(1);
|
let mut machine = MacroMachine::new(1);
|
||||||
let _ = run_with(&mut machine, &mut world, MacroKind::Talk);
|
let _ = run_with(&mut machine, &mut world, MacroKind::Talk);
|
||||||
let pushed = machine.take_pushed();
|
assert!(world.pushes.is_empty(), "nothing is decided while the cartridge holds the joypad");
|
||||||
|
// Row 58: the ledger is written when the cartridge gives the joypad back in the overworld,
|
||||||
|
// which is what tells the gate's walk back from a trainer's walk up.
|
||||||
|
hand_back(&mut machine, &mut world);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pushed,
|
world.pushes.iter().copied().collect::<Vec<_>>(),
|
||||||
Some((world.map, Tile::new(3, 3))),
|
vec![Tile::new(3, 3)],
|
||||||
"the tile the macro was standing on, not the person it was facing"
|
"the tile the macro was standing on, not the person it was facing"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -5198,3 +5236,123 @@ fn a_completed_heal_writes_the_nurse_into_the_talked_ledger() {
|
||||||
assert_eq!(center.player, Tile::new(3, 3));
|
assert_eq!(center.player, Tile::new(3, 3));
|
||||||
assert!(!precondition(MacroKind::Talk, &mut center));
|
assert!(!precondition(MacroKind::Talk, &mut center));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// Row 58: the gym door, in and out
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The Pewter Gym as the fly finds it on its doormat: the guide on screen and talked to, the
|
||||||
|
/// leader and the Jr. Trainer up the room and not drawn.
|
||||||
|
fn pewter_gym_doormat() -> World {
|
||||||
|
let mut world = World::room().at(4, 13);
|
||||||
|
world.map = maps::PEWTER_GYM;
|
||||||
|
world.size = MapSize { width: 10, height: 14 };
|
||||||
|
world.facing = Facing::Up;
|
||||||
|
world.warps = vec![
|
||||||
|
Warp { x: 4, y: 13, destination_warp: 3, destination_map: LAST_MAP },
|
||||||
|
Warp { x: 5, y: 13, destination_warp: 3, destination_map: LAST_MAP },
|
||||||
|
];
|
||||||
|
world.npcs = vec![Npc { slot: 3, picture: 1, x: 7, y: 10, facing: Facing::Down }];
|
||||||
|
world.offscreen = vec![
|
||||||
|
Npc { slot: 1, picture: 2, x: 4, y: 1, facing: Facing::Down },
|
||||||
|
Npc { slot: 2, picture: 3, x: 3, y: 6, facing: Facing::Right },
|
||||||
|
];
|
||||||
|
world.talked.insert(TalkTarget::Sprite(3));
|
||||||
|
// Pewter's errands are paid, as they were live: the objective is the rung's own place.
|
||||||
|
world.areas.insert((Amenity::Mart, maps::PEWTER_CITY));
|
||||||
|
world.areas.insert((Amenity::Center, maps::PEWTER_CITY));
|
||||||
|
world.objective = Some(Objective {
|
||||||
|
map: world.map,
|
||||||
|
tile: None,
|
||||||
|
warp: None,
|
||||||
|
edge: None,
|
||||||
|
target: Some(PlaceKind::Person),
|
||||||
|
});
|
||||||
|
world
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_rungs_people_are_in_the_room_when_the_screen_does_not_show_them() {
|
||||||
|
// Row 58, live for twenty-five minutes: `GO OBJECTIVE` into the Pewter Gym, `GO OUT` straight
|
||||||
|
// back out, ~200 macro starts per ten brain minutes and no reward at all. From the doormat the
|
||||||
|
// cartridge draws only the guide, who had been talked to, so the rung's own list was empty:
|
||||||
|
// `GO OBJECTIVE` had nothing to aim at and `GO OUT` -- whose candidates 12.5 withholds only
|
||||||
|
// while the rung's person is in the room -- was the pad. BROCK was twelve rows up.
|
||||||
|
let mut world = pewter_gym_doormat();
|
||||||
|
let targets = super::palette::objective_targets(&mut world);
|
||||||
|
assert!(
|
||||||
|
targets.contains(&(Tile::new(4, 1), TalkTarget::Sprite(1))),
|
||||||
|
"the leader is one of the rung's people: {targets:?}"
|
||||||
|
);
|
||||||
|
assert!(on_the_pad(&mut world, MacroKind::GoObjective), "there is someone to walk to");
|
||||||
|
assert!(!on_the_pad(&mut world, MacroKind::GoOut), "and the room is not left while he is in it");
|
||||||
|
|
||||||
|
// What the base saw, for the record: the drawn sprites alone leave nothing.
|
||||||
|
world.offscreen.clear();
|
||||||
|
assert!(super::palette::objective_targets(&mut world).is_empty());
|
||||||
|
assert!(!on_the_pad(&mut world, MacroKind::GoObjective));
|
||||||
|
assert!(on_the_pad(&mut world, MacroKind::GoOut), "the undo pair's inside half");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_rung_reads_people_off_the_screen() {
|
||||||
|
// A sprite outside the window may be a toggleable object the cartridge has switched off, and
|
||||||
|
// the two read alike from here (`state::offscreen_npcs`). The rung's list is the one reader:
|
||||||
|
// `GO NPC`, `TALK` and the objects are what they were.
|
||||||
|
let mut world = pewter_gym_doormat();
|
||||||
|
world.objective = None;
|
||||||
|
assert!(super::palette::untalked_people(&mut world).is_empty(), "`GO NPC` sees what is drawn");
|
||||||
|
world.objective = Some(Objective {
|
||||||
|
map: world.map,
|
||||||
|
tile: None,
|
||||||
|
warp: None,
|
||||||
|
edge: None,
|
||||||
|
target: Some(PlaceKind::Object),
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
super::palette::objective_targets(&mut world).is_empty(),
|
||||||
|
"an item ball out of sight and one picked up read alike, so objects are not guessed at"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn facing_one_of_the_rungs_people_is_the_arrival() {
|
||||||
|
// A gym names three people and 12.5's "leave out the one ahead" was written for one: in front
|
||||||
|
// of the leader, `GO OBJECTIVE` still had the Jr. Trainer to walk to, and at the trainer it had
|
||||||
|
// the leader. The walk is done when any of them is ahead, and `TALK` is the press.
|
||||||
|
let mut world = pewter_gym_doormat().at(4, 2);
|
||||||
|
world.facing = Facing::Up;
|
||||||
|
world.npcs = vec![Npc { slot: 1, picture: 2, x: 4, y: 1, facing: Facing::Down }];
|
||||||
|
world.offscreen = vec![Npc { slot: 2, picture: 3, x: 3, y: 6, facing: Facing::Right }];
|
||||||
|
assert!(on_the_pad(&mut world, MacroKind::Talk));
|
||||||
|
assert!(!on_the_pad(&mut world, MacroKind::GoObjective), "no walk left while facing him");
|
||||||
|
|
||||||
|
world.facing = Facing::Left;
|
||||||
|
assert!(on_the_pad(&mut world, MacroKind::GoObjective), "turned away, the walk is back");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_trainer_walking_up_teaches_the_ledgers_nothing() {
|
||||||
|
// The other half of the gym. A walk toward the leader crossed the Jr. Trainer's line of sight;
|
||||||
|
// the trainer's "!" and walk up took the joypad, which 12.4 reads as the cartridge refusing
|
||||||
|
// the step, so BROCK went into the blocked ledger for ten brain minutes and the tile into the
|
||||||
|
// pushed one for the session. What the cartridge does when it gives the joypad back is what
|
||||||
|
// tells a refusal from a challenge.
|
||||||
|
let mut world = World::room().at(3, 3);
|
||||||
|
world.map = 0x00;
|
||||||
|
world.connections = Connections { north: true, south: false, east: false, west: false };
|
||||||
|
world.switch = Some((4, Scene::Dialog));
|
||||||
|
world.scripted_at = Some(4);
|
||||||
|
let north = TargetKey::Exit(ExitId::Edge(Edge::North));
|
||||||
|
let mut machine = MacroMachine::new(0x1234_5678);
|
||||||
|
assert_eq!(run_with(&mut machine, &mut world, MacroKind::GoRoute), Ok(MacroAbort::Done));
|
||||||
|
|
||||||
|
// The challenge closes into a battle.
|
||||||
|
world.scene = Scene::Battle { own_turn: false, forced_switch: false };
|
||||||
|
machine.observe_frame(&mut world);
|
||||||
|
settle(&mut machine, &mut world);
|
||||||
|
// And the battle ends back in the overworld: nothing was refused.
|
||||||
|
hand_back(&mut machine, &mut world);
|
||||||
|
assert!(!world.targets.blocked(world.map, north), "a challenge is not the road refusing");
|
||||||
|
assert!(world.pushes.is_empty(), "and the ground is as walkable as it was");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ pub fn why_unknown(memory: &mut dyn MemoryReader) -> String {
|
||||||
format!(
|
format!(
|
||||||
"started={} map={:?} party={} battle={} type={} font={:#04x} textbox={:#04x} \
|
"started={} map={:?} party={} battle={} type={} font={:#04x} textbox={:#04x} \
|
||||||
list={:#04x} cursor=({},{},{},{},{:#04x}) prompt={} joy={} sim={} flags5={:#04x} \
|
list={:#04x} cursor=({},{},{},{},{:#04x}) prompt={} joy={} sim={} flags5={:#04x} \
|
||||||
flags6={:#04x} move={:#04x} \
|
flags6={:#04x} move={:#04x} opp={:#04x} \
|
||||||
corners=({:#04x},{:#04x},{:#04x},{:#04x})",
|
corners=({:#04x},{:#04x},{:#04x},{:#04x})",
|
||||||
state::started(memory),
|
state::started(memory),
|
||||||
state::map_size(memory).map(|size| (size.width, size.height)),
|
state::map_size(memory).map(|size| (size.width, size.height)),
|
||||||
|
|
@ -126,6 +126,7 @@ pub fn why_unknown(memory: &mut dyn MemoryReader) -> String {
|
||||||
memory.read8(ram::wStatusFlags5),
|
memory.read8(ram::wStatusFlags5),
|
||||||
memory.read8(ram::wStatusFlags6),
|
memory.read8(ram::wStatusFlags6),
|
||||||
memory.read8(ram::wMovementFlags),
|
memory.read8(ram::wMovementFlags),
|
||||||
|
memory.read8(state::poke::CUR_OPPONENT),
|
||||||
box_corners[0],
|
box_corners[0],
|
||||||
box_corners[1],
|
box_corners[1],
|
||||||
box_corners[2],
|
box_corners[2],
|
||||||
|
|
|
||||||
|
|
@ -410,3 +410,18 @@ fn the_start_menus_box_is_read_the_same_way() {
|
||||||
.cursor(2, 11, 0, 7, poke::pad::DOWN | poke::pad::UP | poke::pad::START);
|
.cursor(2, 11, 0, 7, poke::pad::DOWN | poke::pad::UP | poke::pad::START);
|
||||||
assert_eq!(detect(&mut corners), Scene::Unknown, "four corners are not the start menu");
|
assert_eq!(detect(&mut corners), Scene::Unknown, "four corners are not the start menu");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_battle_decided_and_not_yet_begun_is_the_cartridges() {
|
||||||
|
// Row 58. Between a trainer's challenge closing and the battle screen the transition runs for
|
||||||
|
// 219 frames with every joypad and script bit clear; `wCurOpponent` is what says a battle has
|
||||||
|
// been decided. The byte is derived, not generated: it sits between two generated ones.
|
||||||
|
assert_eq!(poke::CUR_OPPONENT, ram::wIsInBattle + 2, "after wIsInBattle and one flag byte");
|
||||||
|
assert_eq!(poke::CUR_OPPONENT, ram::wTrainerNo - 4, "and four before wTrainerNo");
|
||||||
|
let mut wram = Wram::overworld();
|
||||||
|
assert_eq!(detect(&mut wram), Scene::Overworld);
|
||||||
|
wram.set(poke::CUR_OPPONENT, 0xcd);
|
||||||
|
assert_eq!(detect(&mut wram), Scene::Unknown, "OPP_JR_TRAINER_M, decided");
|
||||||
|
wram.set(poke::CUR_OPPONENT, 0x00);
|
||||||
|
assert_eq!(detect(&mut wram), Scene::Overworld, "and `EndOfBattle` clears it");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,19 @@ pub mod poke {
|
||||||
/// deliberately *not* here: standing on a doormat is an ordinary overworld state, and it is the
|
/// deliberately *not* here: standing on a doormat is an ordinary overworld state, and it is the
|
||||||
/// one `docs/design/room-escape.md` cares most about.
|
/// one `docs/design/room-escape.md` cares most about.
|
||||||
pub const SCRIPTED_MOVEMENT: u8 = 0xc0;
|
pub const SCRIPTED_MOVEMENT: u8 = 0xc0;
|
||||||
|
/// `wCurOpponent` (row 58): the species of a wild opponent or `OPP_ID_OFFSET` plus a
|
||||||
|
/// trainer's class, written when a battle is *decided* -- `home/trainers.asm` for a trainer,
|
||||||
|
/// the encounter check for a wild one -- and cleared by `EndOfBattle` together with
|
||||||
|
/// `wIsInBattle`. Not in the generated table, so it is derived rather than pinned:
|
||||||
|
/// `ram/wram.asm` at the pinned commit declares `wIsInBattle:: db`,
|
||||||
|
/// `wPartyGainExpFlags:: flag_array PARTY_LENGTH` (one byte), `wCurOpponent:: db`,
|
||||||
|
/// `wBattleType:: db`, `wDamageMultipliers:: db`, `wGymLeaderNo:: db`, `wTrainerNo:: db` in
|
||||||
|
/// that order, and the table's `wIsInBattle` (`$d057`), `wBattleType` (`$d05a`) and
|
||||||
|
/// `wTrainerNo` (`$d05d`) sit exactly where that layout puts them, so the byte between is
|
||||||
|
/// `wBattleType - 1` with both neighbours checked. Measured on the cartridge in the Pewter Gym:
|
||||||
|
/// zero in the overworld, non-zero from the frame a trainer's challenge closes to the end of
|
||||||
|
/// the battle, including the 219 frames of the battle transition in between.
|
||||||
|
pub const CUR_OPPONENT: u16 = super::ram::wBattleType - 1;
|
||||||
|
|
||||||
/// `constants/battle_constants.asm`: the non-volatile status byte.
|
/// `constants/battle_constants.asm`: the non-volatile status byte.
|
||||||
pub const SLP_MASK: u8 = 0b111;
|
pub const SLP_MASK: u8 = 0b111;
|
||||||
|
|
@ -184,6 +197,19 @@ pub mod poke {
|
||||||
pub const SPRITE_BYTES: u16 = 16;
|
pub const SPRITE_BYTES: u16 = 16;
|
||||||
/// `MACRO object_event` stores map coordinates plus four.
|
/// `MACRO object_event` stores map coordinates plus four.
|
||||||
pub const SPRITE_COORD_BIAS: u8 = 4;
|
pub const SPRITE_COORD_BIAS: u8 = 4;
|
||||||
|
/// `constants/map_object_constants.asm`: `SPRITESTATEDATA1_IMAGEINDEX`, and the `$ff` that
|
||||||
|
/// `CheckSpriteAvailability` writes there for a sprite it will not draw.
|
||||||
|
pub const SPRITE_IMAGE_INDEX: u16 = 2;
|
||||||
|
pub const SPRITE_NOT_DRAWN: u8 = 0xff;
|
||||||
|
/// `SPRITESTATEDATA2_MOVEMENTBYTE1`, and `WALK` (`$fe`): a movement byte below it is a
|
||||||
|
/// scripted mover, which `CheckSpriteAvailability` never hides for being off the screen.
|
||||||
|
pub const SPRITE_MOVEMENT_BYTE: u16 = 6;
|
||||||
|
pub const MOVEMENT_WALK: u8 = 0xfe;
|
||||||
|
/// `CheckSpriteAvailability`'s window, in map tiles past the player's own coordinate:
|
||||||
|
/// `SCREEN_HEIGHT / 2 - 1` rows and `SCREEN_WIDTH / 2 - 1` columns, compared against the
|
||||||
|
/// sprite's *biased* coordinate.
|
||||||
|
pub const DRAWN_ROWS: u8 = 8;
|
||||||
|
pub const DRAWN_COLUMNS: u8 = 9;
|
||||||
|
|
||||||
/// `constants/map_data_constants.asm`: `wCurMapConnections` bits.
|
/// `constants/map_data_constants.asm`: `wCurMapConnections` bits.
|
||||||
pub const CONNECTION_EAST: u8 = 1;
|
pub const CONNECTION_EAST: u8 = 1;
|
||||||
|
|
@ -280,12 +306,22 @@ pub fn started(memory: &mut dyn MemoryReader) -> bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the player's buttons reach the player: no ignored joypad, no simulated input, no
|
/// Whether the player's buttons reach the player: no ignored joypad, no simulated input, no
|
||||||
/// scripted movement, no warp in flight, not mid-ledge-hop.
|
/// scripted movement, no warp in flight, not mid-ledge-hop, no battle decided and not yet begun.
|
||||||
///
|
///
|
||||||
/// The masks are the reward adapter's own scripted gate, minus the door bits — see
|
/// The masks are the reward adapter's own scripted gate, minus the door bits — see
|
||||||
/// [`poke::SCRIPTED_MOVEMENT`].
|
/// [`poke::SCRIPTED_MOVEMENT`].
|
||||||
|
///
|
||||||
|
/// **A battle decided is the cartridge's** (row 58). Between a trainer's challenge closing and the
|
||||||
|
/// battle screen, the battle transition runs for 219 frames with every joypad and script bit
|
||||||
|
/// clear, so the seam read an overworld the fly could walk in: the pad was dealt, a walk toward
|
||||||
|
/// the gym leader pressed into an animation, gave up after three refused steps, and put the
|
||||||
|
/// leader into the blocked ledger for ten brain minutes -- and the Jr. Trainer's conversation read
|
||||||
|
/// as over, so the trainer the fly was about to lose to went into the talked ledger for the
|
||||||
|
/// session. [`poke::CUR_OPPONENT`] is set on the frame the battle is decided and cleared with the
|
||||||
|
/// battle's own end.
|
||||||
pub fn controllable(memory: &mut dyn MemoryReader) -> bool {
|
pub fn controllable(memory: &mut dyn MemoryReader) -> bool {
|
||||||
read(memory, ram::wJoyIgnore) == 0
|
read(memory, poke::CUR_OPPONENT) == 0
|
||||||
|
&& read(memory, ram::wJoyIgnore) == 0
|
||||||
&& read(memory, ram::wSimulatedJoypadStatesIndex) == 0
|
&& read(memory, ram::wSimulatedJoypadStatesIndex) == 0
|
||||||
&& read(memory, ram::wStatusFlags5) & poke::SCRIPTED_STATUS5 == 0
|
&& read(memory, ram::wStatusFlags5) & poke::SCRIPTED_STATUS5 == 0
|
||||||
&& read(memory, ram::wStatusFlags6) & poke::SCRIPTED_STATUS6 == 0
|
&& read(memory, ram::wStatusFlags6) & poke::SCRIPTED_STATUS6 == 0
|
||||||
|
|
@ -1045,6 +1081,78 @@ pub fn npcs(memory: &mut dyn MemoryReader) -> Vec<Npc> {
|
||||||
npcs
|
npcs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The people and objects of the current map the cartridge is not drawing **only because they are
|
||||||
|
/// off the screen** (row 58).
|
||||||
|
///
|
||||||
|
/// [`npcs`] reports what is drawn, and the Pewter Gym showed what that costs: from the gym's
|
||||||
|
/// doormat at (4, 13) BROCK at (4, 1) and the Jr. Trainer at (3, 6) are both outside the window, so
|
||||||
|
/// the macros saw one person in the room -- the guide, already talked to -- and concluded the
|
||||||
|
/// room held nothing the ladder wanted.
|
||||||
|
///
|
||||||
|
/// `CheckSpriteAvailability` (`engine/overworld/movement.asm`) writes `$ff` into a sprite's image
|
||||||
|
/// index for three reasons: it is a toggleable object switched off, it is outside the window, or the
|
||||||
|
/// tile under it is a text box's (a tile id past the map tileset). The window is a pure function of
|
||||||
|
/// bytes this crate already reads -- `wYCoord`, `wXCoord` and the sprite's own biased `MAPY` /
|
||||||
|
/// `MAPX` -- so a sprite the cartridge hides and whose coordinates lie **outside** that window is
|
||||||
|
/// one it would hide for that reason whatever else were true, and its coordinates are still the
|
||||||
|
/// map's: a sprite the cartridge is not updating does not move. A sprite hidden **inside** the
|
||||||
|
/// window is hidden for another reason and is not reported. A scripted mover (movement byte below
|
||||||
|
/// `WALK`) skips the window test altogether, so its `$ff` is never the screen's and it is never
|
||||||
|
/// reported either.
|
||||||
|
///
|
||||||
|
/// What this cannot tell is the first reason from the second for a sprite outside the window: a
|
||||||
|
/// toggleable object that is off reads the same as one that is merely far away. That is named, not
|
||||||
|
/// guessed: [`crate::pokemon_red::macros::palette::objective_targets`] is the one reader, and the
|
||||||
|
/// ladder's places that name a person are Oak's lab and the gyms, of which only the lab and Viridian
|
||||||
|
/// Gym carry toggleable people (`data/maps/toggleable_objects.asm`).
|
||||||
|
pub fn offscreen_npcs(memory: &mut dyn MemoryReader) -> Vec<Npc> {
|
||||||
|
let Some(size) = map_size(memory) else { return Vec::new() };
|
||||||
|
let player_y = read(memory, ram::wYCoord);
|
||||||
|
let player_x = read(memory, ram::wXCoord);
|
||||||
|
if player_x >= size.width || player_y >= size.height {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
// `CheckSpriteAvailability`, one axis: `cp b / jr z, skip / jr nc, invisible / add n / cp b /
|
||||||
|
// jr c, invisible` against the biased coordinate `b`.
|
||||||
|
let drawn = |own: u8, sprite: u8, reach: u8| {
|
||||||
|
sprite == own || (own < sprite && u16::from(sprite) <= u16::from(own) + u16::from(reach))
|
||||||
|
};
|
||||||
|
let count = read(memory, ram::wNumSprites).min(poke::SPRITE_SLOTS - 1);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for slot in 1..=count {
|
||||||
|
let data1 = ram::wSpriteStateData1 + u16::from(slot) * poke::SPRITE_BYTES;
|
||||||
|
let data2 = ram::wSpriteStateData2 + u16::from(slot) * poke::SPRITE_BYTES;
|
||||||
|
let picture = read(memory, data1);
|
||||||
|
if picture == 0 || read(memory, data1 + poke::SPRITE_IMAGE_INDEX) != poke::SPRITE_NOT_DRAWN
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if read(memory, data2 + poke::SPRITE_MOVEMENT_BYTE) < poke::MOVEMENT_WALK {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let y = read(memory, data2 + 4);
|
||||||
|
let x = read(memory, data2 + 5);
|
||||||
|
if y < poke::SPRITE_COORD_BIAS || x < poke::SPRITE_COORD_BIAS {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (map_x, map_y) = (x - poke::SPRITE_COORD_BIAS, y - poke::SPRITE_COORD_BIAS);
|
||||||
|
if map_x >= size.width || map_y >= size.height {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if drawn(player_y, y, poke::DRAWN_ROWS) && drawn(player_x, x, poke::DRAWN_COLUMNS) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(Npc {
|
||||||
|
slot,
|
||||||
|
picture,
|
||||||
|
x: map_x,
|
||||||
|
y: map_y,
|
||||||
|
facing: facing_from(read(memory, data1 + 9)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// The current tileset's list of passable tile ids, terminator included.
|
/// The current tileset's list of passable tile ids, terminator included.
|
||||||
///
|
///
|
||||||
/// `CheckTilePassable` walks the list at `wTilesetCollisionPtr` — a little-endian pointer into the
|
/// `CheckTilePassable` walks the list at `wTilesetCollisionPtr` — a little-endian pointer into the
|
||||||
|
|
@ -1625,6 +1733,10 @@ impl GameState for PokeState<'_> {
|
||||||
npcs(self.memory)
|
npcs(self.memory)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn offscreen_npcs(&mut self) -> Vec<Npc> {
|
||||||
|
offscreen_npcs(self.memory)
|
||||||
|
}
|
||||||
|
|
||||||
fn signs(&mut self) -> Vec<Sign> {
|
fn signs(&mut self) -> Vec<Sign> {
|
||||||
signs(self.memory)
|
signs(self.memory)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1040,3 +1040,30 @@ fn a_state_with_no_cache_still_answers_and_a_state_with_no_cartridge_answers_non
|
||||||
// Which is the frame the window predicate is for.
|
// Which is the frame the window predicate is for.
|
||||||
assert_eq!(state.walkable(3, 6), Walkable::No);
|
assert_eq!(state.walkable(3, 6), Walkable::No);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_sprite_the_cartridge_hides_off_the_screen_is_still_on_the_map() {
|
||||||
|
// Row 58, the Pewter Gym from its doormat at (4, 13). The cartridge draws the guide; BROCK at
|
||||||
|
// (4, 1) and the Jr. Trainer at (3, 6) are outside `CheckSpriteAvailability`'s window, so it
|
||||||
|
// writes `$ff` into their image index and `npcs` -- which reports what is drawn -- skips them.
|
||||||
|
const STAY: u8 = 0xff;
|
||||||
|
let mut wram = Wram::overworld();
|
||||||
|
wram.map(0x36, 5, 7, 4, 13)
|
||||||
|
.npc(3, 0x2b, 7, 10, 0x00)
|
||||||
|
.npc_undrawn(1, 0x1f, 4, 1, STAY)
|
||||||
|
.npc_undrawn(2, 0x0e, 3, 6, STAY)
|
||||||
|
// Undrawn *inside* the window: switched off, or under a text box -- not the screen's doing.
|
||||||
|
.npc_undrawn(4, 0x05, 5, 11, STAY)
|
||||||
|
// Undrawn outside it, but a scripted mover, which the window test never hides.
|
||||||
|
.npc_undrawn(5, 0x05, 8, 1, 0x00);
|
||||||
|
let drawn: Vec<u8> = npcs(&mut wram).iter().map(|npc| npc.slot).collect();
|
||||||
|
assert_eq!(drawn, vec![3]);
|
||||||
|
let off: Vec<(u8, u8, u8)> =
|
||||||
|
offscreen_npcs(&mut wram).iter().map(|npc| (npc.slot, npc.x, npc.y)).collect();
|
||||||
|
assert_eq!(off, vec![(1, 4, 1), (2, 3, 6)], "the leader and the trainer, where they stand");
|
||||||
|
|
||||||
|
// Walk up the room and the trainer is inside the window: a `$ff` there is not the screen's.
|
||||||
|
wram.map(0x36, 5, 7, 4, 8);
|
||||||
|
let off: Vec<u8> = offscreen_npcs(&mut wram).iter().map(|npc| npc.slot).collect();
|
||||||
|
assert_eq!(off, vec![1], "only the leader is still off the screen from (4, 8)");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,9 @@ Where this crate narrows or extends the draft, the difference is listed under
|
||||||
sections 2 to 11 is audited against this code, with the test that proves it, in
|
sections 2 to 11 is audited against this code, with the test that proves it, in
|
||||||
`docs/design/session-framework/bus-conformance.md`.
|
`docs/design/session-framework/bus-conformance.md`.
|
||||||
|
|
||||||
Nothing in the crate is specific to a game, a brain or a stream. It is a workspace member and
|
Nothing in the crate is specific to a game, a brain or a stream. It is a workspace member;
|
||||||
no other crate depends on it yet.
|
`flysim` embeds a router for the feed (`FLY_FEED_VIA=bus`, `flysim::feedbus`) and `fly-edge`
|
||||||
|
subscribes to it (`docs/design/flybus.md`, "Feed over the bus").
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ cuda = ["flybrain-core/cuda"]
|
||||||
[dependencies]
|
[dependencies]
|
||||||
flybrain-core = { path = "../flybrain-core" }
|
flybrain-core = { path = "../flybrain-core" }
|
||||||
flybrain-gb = { path = "../flybrain-gb" }
|
flybrain-gb = { path = "../flybrain-gb" }
|
||||||
|
flybus = { path = "../flybus" }
|
||||||
|
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
axum = { version = "0.8", features = ["ws"] }
|
axum = { version = "0.8", features = ["ws"] }
|
||||||
|
|
|
||||||
|
|
@ -1355,9 +1355,9 @@ fn dialog_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64
|
||||||
/// [`PokemonPalette`]: flybrain_gb::pokemon_red::macros::PokemonPalette
|
/// [`PokemonPalette`]: flybrain_gb::pokemon_red::macros::PokemonPalette
|
||||||
fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) {
|
fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) {
|
||||||
use flybrain_gb::MacroPalette;
|
use flybrain_gb::MacroPalette;
|
||||||
use flybrain_gb::pokemon_red::macros::cartridge::{MacroState, TargetKey};
|
use flybrain_gb::pokemon_red::macros::cartridge::{FACINGS, MacroState, TalkTarget, TargetKey};
|
||||||
use flybrain_gb::pokemon_red::macros::path::Way;
|
use flybrain_gb::pokemon_red::macros::path::Way;
|
||||||
use flybrain_gb::pokemon_red::macros::{PokemonPalette, palette, path};
|
use flybrain_gb::pokemon_red::macros::{PokemonPalette, Tile, palette, path};
|
||||||
|
|
||||||
let budget = env_usize("FLY_PROBE_FRAMES", 240_000);
|
let budget = env_usize("FLY_PROBE_FRAMES", 240_000);
|
||||||
let mut rng = env_usize("FLY_PROBE_RNG", 20_260_923) as u32 | 1;
|
let mut rng = env_usize("FLY_PROBE_RNG", 20_260_923) as u32 | 1;
|
||||||
|
|
@ -1379,6 +1379,14 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
|
||||||
let mut outcomes: BTreeMap<String, u64> = BTreeMap::new();
|
let mut outcomes: BTreeMap<String, u64> = BTreeMap::new();
|
||||||
let mut single_refusals = 0u32;
|
let mut single_refusals = 0u32;
|
||||||
let mut caught_at: Option<usize> = None;
|
let mut caught_at: Option<usize> = None;
|
||||||
|
// `FLY_PROBE_CATCH_MAP=54` with `FLY_PROBE_CATCH_ENTRIES=4` reads the frame the fly is given
|
||||||
|
// the buttons back on its fourth arrival on map 54 (row 58: the pad in and out of one door).
|
||||||
|
let catch_map: Option<u8> =
|
||||||
|
std::env::var("FLY_PROBE_CATCH_MAP").ok().and_then(|value| value.parse().ok());
|
||||||
|
let catch_entries = env_usize("FLY_PROBE_CATCH_ENTRIES", 4);
|
||||||
|
let mut entries = 0usize;
|
||||||
|
let mut arrived_at = 0usize;
|
||||||
|
let mut last_map: Option<u8> = None;
|
||||||
|
|
||||||
// `FLY_PROBE_HOLD=right:96,up:32` holds raw directions first and prints where the fly is
|
// `FLY_PROBE_HOLD=right:96,up:32` holds raw directions first and prints where the fly is
|
||||||
// every eight frames: what the cartridge does with a press, before any macro is asked.
|
// every eight frames: what the cartridge does with a press, before any macro is asked.
|
||||||
|
|
@ -1476,7 +1484,12 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some((name, outcome)) = macros.take_finished() {
|
if let Some((name, outcome)) = macros.take_finished() {
|
||||||
*outcomes.entry(format!("{name} {outcome:?}")).or_default() += 1;
|
*outcomes
|
||||||
|
.entry(format!(
|
||||||
|
"{name} {outcome:?} on {:?}",
|
||||||
|
state::player(gb).map(|p| p.map)
|
||||||
|
))
|
||||||
|
.or_default() += 1;
|
||||||
if !matches!(outcome, flybrain_gb::Outcome::Done) {
|
if !matches!(outcome, flybrain_gb::Outcome::Done) {
|
||||||
println!(
|
println!(
|
||||||
"f{frame:<6} {:?} {name} {outcome:?}",
|
"f{frame:<6} {:?} {name} {outcome:?}",
|
||||||
|
|
@ -1487,11 +1500,12 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
|
||||||
since_decision += 1;
|
since_decision += 1;
|
||||||
if frame < trace_frames {
|
if frame < trace_frames {
|
||||||
println!(
|
println!(
|
||||||
" t{frame:<5} {:?} mask {mask:#04x} running {:?} marks {:?} stood {}",
|
" t{frame:<5} {:?} mask {mask:#04x} running {:?} marks {:?} stood {} | {}",
|
||||||
state::player(gb).map(|p| (p.map, p.x, p.y, p.facing)),
|
state::player(gb).map(|p| (p.map, p.x, p.y, p.facing)),
|
||||||
macros.running(),
|
macros.running(),
|
||||||
macros.fences().1,
|
macros.fences().1,
|
||||||
macros.stood()
|
macros.stood(),
|
||||||
|
scene::why_unknown(gb)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
gb.set_buttons(mask);
|
gb.set_buttons(mask);
|
||||||
|
|
@ -1502,8 +1516,32 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
|
||||||
caught_at = Some(frame);
|
caught_at = Some(frame);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
if let (Some(want), Some(player)) = (catch_map, player) {
|
||||||
|
if player.map == want && last_map != Some(want) {
|
||||||
|
entries += 1;
|
||||||
|
arrived_at = frame;
|
||||||
|
}
|
||||||
|
last_map = Some(player.map);
|
||||||
|
// Forty frames in: the first frames on a new map byte still carry the old map's
|
||||||
|
// warps (the tear 12.16 names), and a reading there says nothing about the room.
|
||||||
|
if player.map == want
|
||||||
|
&& entries >= catch_entries
|
||||||
|
&& frame >= arrived_at + 40
|
||||||
|
&& !running
|
||||||
|
&& matches!(observed.scene, flybrain_gb::SceneId::Overworld)
|
||||||
|
&& !observed.bindings.is_empty()
|
||||||
|
{
|
||||||
|
caught_at = Some(frame);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
println!("```\n");
|
println!("```\n");
|
||||||
|
let progress = adapter.progress();
|
||||||
|
println!(
|
||||||
|
"- at the end: rank {} ({}), badges {}, unique tiles {}",
|
||||||
|
progress.rank, progress.rank_label, progress.counter, progress.unique_locations
|
||||||
|
);
|
||||||
println!("- refusals: {refusals:?}");
|
println!("- refusals: {refusals:?}");
|
||||||
println!("- outcomes: {outcomes:?}");
|
println!("- outcomes: {outcomes:?}");
|
||||||
let Some(frame) = caught_at else {
|
let Some(frame) = caught_at else {
|
||||||
|
|
@ -1512,8 +1550,13 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
println!(
|
println!(
|
||||||
"\n## Caught on frame {frame} ({:.1} brain minutes): one button, refused twenty holds running\n",
|
"\n## Caught on frame {frame} ({:.1} brain minutes): {}\n",
|
||||||
frame as f64 * MS_PER_FRAME / 60_000.0
|
frame as f64 * MS_PER_FRAME / 60_000.0,
|
||||||
|
if single_refusals >= catch_after {
|
||||||
|
"one button, refused twenty holds running".to_string()
|
||||||
|
} else {
|
||||||
|
format!("arrival {entries} on map {catch_map:?}")
|
||||||
|
}
|
||||||
);
|
);
|
||||||
let (pushed, frontiers) = macros.fences();
|
let (pushed, frontiers) = macros.fences();
|
||||||
println!("- pushed tiles (no window): {pushed:?}");
|
println!("- pushed tiles (no window): {pushed:?}");
|
||||||
|
|
@ -1525,6 +1568,18 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
|
||||||
println!("- objective: {:?}", state.objective());
|
println!("- objective: {:?}", state.objective());
|
||||||
println!("- `objective_goals`: {:?}", palette::objective_goals(state));
|
println!("- `objective_goals`: {:?}", palette::objective_goals(state));
|
||||||
println!("- `objective_targets`: {:?}", palette::objective_targets(state));
|
println!("- `objective_targets`: {:?}", palette::objective_targets(state));
|
||||||
|
let drawn = path::person_targets(state);
|
||||||
|
for (tile, target) in drawn.iter().copied().chain(path::offscreen_person_targets(state)) {
|
||||||
|
println!(
|
||||||
|
" - person {target:?} at ({:2},{:2}) {}: talked {}, blocked {}, reached {}",
|
||||||
|
tile.x,
|
||||||
|
tile.y,
|
||||||
|
if drawn.contains(&(tile, target)) { "drawn" } else { "off the screen" },
|
||||||
|
state.talked(target),
|
||||||
|
state.blocked(TargetKey::Thing(target)),
|
||||||
|
state.reached(TargetKey::Thing(target))
|
||||||
|
);
|
||||||
|
}
|
||||||
println!("- `untalked_people`: {:?}", palette::untalked_people(state));
|
println!("- `untalked_people`: {:?}", palette::untalked_people(state));
|
||||||
println!("- `untalked_objects`: {:?}", palette::untalked_objects(state));
|
println!("- `untalked_objects`: {:?}", palette::untalked_objects(state));
|
||||||
println!("- `facing_untalked`: {}", palette::facing_untalked(state));
|
println!("- `facing_untalked`: {}", palette::facing_untalked(state));
|
||||||
|
|
@ -1549,16 +1604,44 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
|
||||||
reach
|
reach
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
println!("\n### The fly's own neighbourhood (pushed = `P`, player = `@`)\n\n```");
|
// A room small enough to print whole is printed whole, with its people on it (row 58:
|
||||||
for y in player.y.saturating_sub(3)..=player.y.saturating_add(3) {
|
// the gym's leader is twelve rows from the door).
|
||||||
let row: String = (player.x.saturating_sub(6)..=player.x.saturating_add(6))
|
let size = state.map_size().expect("a loaded map");
|
||||||
|
let whole = size.width <= 24 && size.height <= 24;
|
||||||
|
let people: Vec<(Tile, TalkTarget)> = path::person_targets(state)
|
||||||
|
.into_iter()
|
||||||
|
.chain(path::offscreen_person_targets(state))
|
||||||
|
.collect();
|
||||||
|
let (rows, columns) = if whole {
|
||||||
|
(0..=size.height - 1, 0..=size.width - 1)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
player.y.saturating_sub(3)..=player.y.saturating_add(3),
|
||||||
|
player.x.saturating_sub(6)..=player.x.saturating_add(6),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let grid = state.map_grid();
|
||||||
|
println!(
|
||||||
|
"\n### The fly's {} (pushed = `P`, player = `@`, a person = `N`; grid {})\n\n```",
|
||||||
|
if whole { "whole map" } else { "own neighbourhood" },
|
||||||
|
grid.is_some()
|
||||||
|
);
|
||||||
|
for y in rows {
|
||||||
|
let row: String = columns
|
||||||
|
.clone()
|
||||||
.map(|x| {
|
.map(|x| {
|
||||||
|
let walk = match grid.as_deref() {
|
||||||
|
Some(grid) => grid.walkable(x, y),
|
||||||
|
None => state.walkable(x, y),
|
||||||
|
};
|
||||||
if x == player.x && y == player.y {
|
if x == player.x && y == player.y {
|
||||||
'@'
|
'@'
|
||||||
|
} else if people.iter().any(|(tile, _)| *tile == Tile::new(x, y)) {
|
||||||
|
'N'
|
||||||
} else if state.pushed_tile(x, y) {
|
} else if state.pushed_tile(x, y) {
|
||||||
'P'
|
'P'
|
||||||
} else {
|
} else {
|
||||||
match state.walkable(x, y) {
|
match walk {
|
||||||
flybrain_gb::pokemon_red::macros::state::Walkable::Yes => '.',
|
flybrain_gb::pokemon_red::macros::state::Walkable::Yes => '.',
|
||||||
flybrain_gb::pokemon_red::macros::state::Walkable::No => '#',
|
flybrain_gb::pokemon_red::macros::state::Walkable::No => '#',
|
||||||
flybrain_gb::pokemon_red::macros::state::Walkable::Unknown => '?',
|
flybrain_gb::pokemon_red::macros::state::Walkable::Unknown => '?',
|
||||||
|
|
@ -1566,9 +1649,14 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
println!("y{y:2} x{:2}.. {row}", player.x.saturating_sub(6));
|
println!("y{y:2} x{:2}.. {row}", if whole { 0 } else { player.x.saturating_sub(6) });
|
||||||
}
|
}
|
||||||
println!("```");
|
println!("```");
|
||||||
|
for (tile, target) in &people {
|
||||||
|
let aims: Vec<Tile> = FACINGS.iter().filter_map(|facing| tile.step(*facing)).collect();
|
||||||
|
let reach = path::route(state, &aims).map(|route| route.goal);
|
||||||
|
println!("- a route to {target:?} at ({:2},{:2}): {reach:?}", tile.x, tile.y);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
//! - the `FLY_*` names the systemd units already set (`FLY_GAME`, `FLY_ROM`, `FLY_DATASET`,
|
//! - the `FLY_*` names the systemd units already set (`FLY_GAME`, `FLY_ROM`, `FLY_DATASET`,
|
||||||
//! `FLY_STATE`, `FLY_STATE_HOT`, `FLY_FEED_BIND`, `FLY_CONTROL_BIND`, `FLY_METRICS_ADDR`,
|
//! `FLY_STATE`, `FLY_STATE_HOT`, `FLY_FEED_BIND`, `FLY_CONTROL_BIND`, `FLY_METRICS_ADDR`,
|
||||||
//! `FLY_ROM_SHA256`, `FLY_ROM_PLATFORMER_SHA256`, `FLY_CHAT_ENABLED`, `FLY_CHAT_DENY_LIST`,
|
//! `FLY_ROM_SHA256`, `FLY_ROM_PLATFORMER_SHA256`, `FLY_CHAT_ENABLED`, `FLY_CHAT_DENY_LIST`,
|
||||||
//! `FLY_MACRO_MODE`, `RAYON_NUM_THREADS`);
|
//! `FLY_MACRO_MODE`, `FLY_FEED_VIA`, `FLY_BUS_DIR`, `RAYON_NUM_THREADS`);
|
||||||
//! - `FLYSIM_<SECTION>_<KEY>` for everything, e.g. `FLYSIM_LOOP_SPEED=0`.
|
//! - `FLYSIM_<SECTION>_<KEY>` for everything, e.g. `FLYSIM_LOOP_SPEED=0`.
|
||||||
//!
|
//!
|
||||||
//! Nothing here is secret (`docs/control-api.md`: "No secrets live in this service or its
|
//! Nothing here is secret (`docs/control-api.md`: "No secrets live in this service or its
|
||||||
|
|
@ -104,6 +104,35 @@ pub struct Feed {
|
||||||
pub bind: SocketAddr,
|
pub bind: SocketAddr,
|
||||||
/// Audio attachment sample rate. The page wants Web Audio's native 48 kHz.
|
/// Audio attachment sample rate. The page wants Web Audio's native 48 kHz.
|
||||||
pub audio_hz: u32,
|
pub audio_hz: u32,
|
||||||
|
/// Who serves `:7400/feed` (`docs/design/flybus.md`, "Feed over the bus").
|
||||||
|
pub via: FeedVia,
|
||||||
|
/// The bus runtime directory in `bus` mode: the router's socket and its artifact store.
|
||||||
|
/// Belongs on tmpfs; a store here holds a few snapshots, never history.
|
||||||
|
pub bus_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the feed WebSocket is served from.
|
||||||
|
///
|
||||||
|
/// `direct` is the default and is the behaviour that predates the bus, byte for byte: flysim
|
||||||
|
/// binds `feed.bind` itself. `bus` starts an embedded flybus router, publishes every snapshot
|
||||||
|
/// on it, and leaves `feed.bind` to the `fly-edge` process. The control API stays in flysim
|
||||||
|
/// either way. Nothing about the fly changes with this knob: it is outside the simulation loop
|
||||||
|
/// and outside the compatibility string.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum FeedVia {
|
||||||
|
#[default]
|
||||||
|
Direct,
|
||||||
|
Bus,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FeedVia {
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Direct => "direct",
|
||||||
|
Self::Bus => "bus",
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
|
@ -197,6 +226,8 @@ impl Default for Feed {
|
||||||
Self {
|
Self {
|
||||||
bind: "127.0.0.1:7400".parse().expect("literal address"),
|
bind: "127.0.0.1:7400".parse().expect("literal address"),
|
||||||
audio_hz: 48_000,
|
audio_hz: 48_000,
|
||||||
|
via: FeedVia::Direct,
|
||||||
|
bus_dir: PathBuf::from("/run/fly/bus"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -255,6 +286,12 @@ impl Config {
|
||||||
if let Some(value) = get("FLY_FEED_BIND") {
|
if let Some(value) = get("FLY_FEED_BIND") {
|
||||||
self.feed.bind = parse_addr("FLY_FEED_BIND", value)?;
|
self.feed.bind = parse_addr("FLY_FEED_BIND", value)?;
|
||||||
}
|
}
|
||||||
|
if let Some(value) = get("FLY_FEED_VIA") {
|
||||||
|
self.feed.via = parse_feed_via("FLY_FEED_VIA", value)?;
|
||||||
|
}
|
||||||
|
if let Some(value) = get("FLY_BUS_DIR") {
|
||||||
|
self.feed.bus_dir = PathBuf::from(value);
|
||||||
|
}
|
||||||
if let Some(value) = get("FLY_CONTROL_BIND") {
|
if let Some(value) = get("FLY_CONTROL_BIND") {
|
||||||
self.control.bind = parse_addr("FLY_CONTROL_BIND", value)?;
|
self.control.bind = parse_addr("FLY_CONTROL_BIND", value)?;
|
||||||
}
|
}
|
||||||
|
|
@ -330,6 +367,12 @@ impl Config {
|
||||||
if let Some(value) = get("FLYSIM_FEED_AUDIO_HZ") {
|
if let Some(value) = get("FLYSIM_FEED_AUDIO_HZ") {
|
||||||
self.feed.audio_hz = parse("FLYSIM_FEED_AUDIO_HZ", value)?;
|
self.feed.audio_hz = parse("FLYSIM_FEED_AUDIO_HZ", value)?;
|
||||||
}
|
}
|
||||||
|
if let Some(value) = get("FLYSIM_FEED_VIA") {
|
||||||
|
self.feed.via = parse_feed_via("FLYSIM_FEED_VIA", value)?;
|
||||||
|
}
|
||||||
|
if let Some(value) = get("FLYSIM_FEED_BUS_DIR") {
|
||||||
|
self.feed.bus_dir = PathBuf::from(value);
|
||||||
|
}
|
||||||
if let Some(value) = get("FLYSIM_CONTROL_BIND") {
|
if let Some(value) = get("FLYSIM_CONTROL_BIND") {
|
||||||
self.control.bind = parse_addr("FLYSIM_CONTROL_BIND", value)?;
|
self.control.bind = parse_addr("FLYSIM_CONTROL_BIND", value)?;
|
||||||
}
|
}
|
||||||
|
|
@ -416,6 +459,16 @@ impl Config {
|
||||||
if self.control.sugar_per_minute == 0 {
|
if self.control.sugar_per_minute == 0 {
|
||||||
bail!("control.sugar_per_minute must be at least 1");
|
bail!("control.sugar_per_minute must be at least 1");
|
||||||
}
|
}
|
||||||
|
// The router's socket and store, and the edge's way to them. A relative path would
|
||||||
|
// resolve against whichever working directory each process happens to have, so the two
|
||||||
|
// could silently disagree; an empty one is a typo. Checked in either mode, so a bad
|
||||||
|
// value is found before the day a box is switched to the bus.
|
||||||
|
if self.feed.bus_dir.as_os_str().is_empty() || !self.feed.bus_dir.is_absolute() {
|
||||||
|
bail!(
|
||||||
|
"feed.bus_dir (FLY_BUS_DIR) must be an absolute path, got {:?}",
|
||||||
|
self.feed.bus_dir
|
||||||
|
);
|
||||||
|
}
|
||||||
if self.feed.bind == self.control.bind {
|
if self.feed.bind == self.control.bind {
|
||||||
bail!("feed.bind and control.bind must differ (7400 and 7401)");
|
bail!("feed.bind and control.bind must differ (7400 and 7401)");
|
||||||
}
|
}
|
||||||
|
|
@ -464,6 +517,14 @@ impl Config {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_feed_via(name: &str, value: &str) -> Result<FeedVia> {
|
||||||
|
match value.to_ascii_lowercase().as_str() {
|
||||||
|
"direct" => Ok(FeedVia::Direct),
|
||||||
|
"bus" => Ok(FeedVia::Bus),
|
||||||
|
_ => bail!("{name}: {value:?} is not a feed path; expected \"direct\" or \"bus\""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_addr(name: &str, value: &str) -> Result<SocketAddr> {
|
fn parse_addr(name: &str, value: &str) -> Result<SocketAddr> {
|
||||||
value
|
value
|
||||||
.parse()
|
.parse()
|
||||||
|
|
@ -687,6 +748,44 @@ mod tests {
|
||||||
assert_eq!(config, Config::default());
|
assert_eq!(config, Config::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_feed_path_is_direct_unless_the_environment_says_bus() {
|
||||||
|
let config = Config::default();
|
||||||
|
assert_eq!(config.feed.via, FeedVia::Direct);
|
||||||
|
assert_eq!(config.feed.bus_dir, PathBuf::from("/run/fly/bus"));
|
||||||
|
|
||||||
|
let mut config = Config::default();
|
||||||
|
config
|
||||||
|
.apply_env(&env(&[("FLY_FEED_VIA", "bus"), ("FLY_BUS_DIR", "/tmp/fly-bus")]))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(config.feed.via, FeedVia::Bus);
|
||||||
|
assert_eq!(config.feed.bus_dir, PathBuf::from("/tmp/fly-bus"));
|
||||||
|
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.apply_env(&env(&[("FLYSIM_FEED_VIA", "DIRECT")])).unwrap();
|
||||||
|
assert_eq!(config.feed.via, FeedVia::Direct);
|
||||||
|
|
||||||
|
// A typo is a refusal, not a silent fallback to one of the two.
|
||||||
|
let error = Config::default().apply_env(&env(&[("FLY_FEED_VIA", "buss")])).unwrap_err();
|
||||||
|
assert!(error.to_string().contains("FLY_FEED_VIA"), "{error}");
|
||||||
|
assert_eq!(toml::from_str::<Config>("[feed]\nvia = \"bus\"\n").unwrap().feed.via, FeedVia::Bus);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_bus_dir_must_be_absolute_and_not_empty() {
|
||||||
|
Config::default().validate().unwrap();
|
||||||
|
for bad in ["", "run/fly/bus", "./bus"] {
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.feed.bus_dir = PathBuf::from(bad);
|
||||||
|
let error = config.validate().unwrap_err();
|
||||||
|
assert!(error.to_string().contains("FLY_BUS_DIR"), "{bad:?}: {error}");
|
||||||
|
}
|
||||||
|
// Through the environment too.
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.apply_env(&env(&[("FLY_BUS_DIR", "relative/bus")])).unwrap();
|
||||||
|
assert!(config.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_example_file_parses_and_validates() {
|
fn the_example_file_parses_and_validates() {
|
||||||
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../flysim.toml.example");
|
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../flysim.toml.example");
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,14 @@
|
||||||
//! - 30 snapshots a second while running, 2 while paused or booting (header only);
|
//! - 30 snapshots a second while running, 2 while paused or booting (header only);
|
||||||
//! - drop-oldest, never queue: the sim publishes into a `watch` slot, so a slow client misses
|
//! - drop-oldest, never queue: the sim publishes into a `watch` slot, so a slow client misses
|
||||||
//! snapshots instead of slowing the loop down. Those misses are counted.
|
//! snapshots instead of slowing the loop down. Those misses are counted.
|
||||||
|
//!
|
||||||
|
//! The server only needs a [`FeedState`]: a watch slot of snapshots, the counters and the idle
|
||||||
|
//! cadence. flysim builds one from its own state when it serves the feed itself
|
||||||
|
//! (`FLY_FEED_VIA=direct`), and `fly-edge` builds one from the snapshots it takes off the bus
|
||||||
|
//! (`FLY_FEED_VIA=bus`), so both paths run this same code and write the same bytes.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||||
|
|
@ -19,9 +25,22 @@ use axum::http::StatusCode;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::any;
|
use axum::routing::any;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use tokio::sync::watch;
|
||||||
|
|
||||||
|
use crate::metrics::Metrics;
|
||||||
use crate::snapshot::{AttachmentKind, FeedStatus, PROTOCOL, Snapshot, Wants};
|
use crate::snapshot::{AttachmentKind, FeedStatus, PROTOCOL, Snapshot, Wants};
|
||||||
use crate::{AppState, metrics::Metrics};
|
|
||||||
|
/// Everything the feed server reads.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct FeedState {
|
||||||
|
/// The newest snapshot. Dropping its sender ends every client's stream.
|
||||||
|
pub snapshots: watch::Receiver<Arc<Snapshot>>,
|
||||||
|
/// `frames_sent`, `feed_clients` and `feed_dropped` are the ones this module moves.
|
||||||
|
pub metrics: Arc<Metrics>,
|
||||||
|
/// The protocol's idle cadence: how long a paused or booting stream waits before it
|
||||||
|
/// repeats the current header (`config.publish_periods().1`).
|
||||||
|
pub idle_period: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
/// The one JSON text message a client sends on connect.
|
/// The one JSON text message a client sends on connect.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
|
@ -37,7 +56,7 @@ pub struct ClientHello {
|
||||||
/// Close code for a protocol violation, as the reference server uses.
|
/// Close code for a protocol violation, as the reference server uses.
|
||||||
const CLOSE_PROTOCOL_ERROR: u16 = 1002;
|
const CLOSE_PROTOCOL_ERROR: u16 = 1002;
|
||||||
|
|
||||||
pub fn router(state: AppState) -> Router {
|
pub fn router(state: FeedState) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/feed", any(upgrade))
|
.route("/feed", any(upgrade))
|
||||||
.fallback(not_found)
|
.fallback(not_found)
|
||||||
|
|
@ -48,11 +67,11 @@ async fn not_found() -> Response {
|
||||||
(StatusCode::NOT_FOUND, "not found").into_response()
|
(StatusCode::NOT_FOUND, "not found").into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn upgrade(upgrade: WebSocketUpgrade, State(state): State<AppState>) -> Response {
|
async fn upgrade(upgrade: WebSocketUpgrade, State(state): State<FeedState>) -> Response {
|
||||||
upgrade.on_upgrade(move |socket| serve_client(socket, state))
|
upgrade.on_upgrade(move |socket| serve_client(socket, state))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn serve_client(mut socket: WebSocket, state: AppState) {
|
async fn serve_client(mut socket: WebSocket, state: FeedState) {
|
||||||
let Some(hello) = read_hello(&mut socket).await else {
|
let Some(hello) = read_hello(&mut socket).await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
@ -64,9 +83,9 @@ async fn serve_client(mut socket: WebSocket, state: AppState) {
|
||||||
spikes = wants.spikes,
|
spikes = wants.spikes,
|
||||||
"feed client connected"
|
"feed client connected"
|
||||||
);
|
);
|
||||||
state.shared.metrics.client_joined();
|
state.metrics.client_joined();
|
||||||
let result = pump(&mut socket, &state, wants).await;
|
let result = pump(&mut socket, &state, wants).await;
|
||||||
state.shared.metrics.client_left();
|
state.metrics.client_left();
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => tracing::info!("feed client disconnected"),
|
Ok(()) => tracing::info!("feed client disconnected"),
|
||||||
Err(error) => tracing::info!(%error, "feed client dropped"),
|
Err(error) => tracing::info!(%error, "feed client dropped"),
|
||||||
|
|
@ -117,9 +136,9 @@ async fn read_hello(socket: &mut WebSocket) -> Option<ClientHello> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn pump(socket: &mut WebSocket, state: &AppState, wants: Wants) -> Result<(), axum::Error> {
|
async fn pump(socket: &mut WebSocket, state: &FeedState, wants: Wants) -> Result<(), axum::Error> {
|
||||||
let mut receiver = state.snapshots.clone();
|
let mut receiver = state.snapshots.clone();
|
||||||
let (_, idle_period) = state.shared.config.publish_periods();
|
let idle_period = state.idle_period;
|
||||||
let mut last_seq = 0u64;
|
let mut last_seq = 0u64;
|
||||||
|
|
||||||
// The current snapshot first, so a client that connects while paused or booting sees the
|
// The current snapshot first, so a client that connects while paused or booting sees the
|
||||||
|
|
@ -161,18 +180,18 @@ async fn pump(socket: &mut WebSocket, state: &AppState, wants: Wants) -> Result<
|
||||||
|
|
||||||
async fn send(
|
async fn send(
|
||||||
socket: &mut WebSocket,
|
socket: &mut WebSocket,
|
||||||
state: &AppState,
|
state: &FeedState,
|
||||||
snapshot: &Arc<Snapshot>,
|
snapshot: &Arc<Snapshot>,
|
||||||
wants: Wants,
|
wants: Wants,
|
||||||
last_seq: &mut u64,
|
last_seq: &mut u64,
|
||||||
) -> Result<(), axum::Error> {
|
) -> Result<(), axum::Error> {
|
||||||
let seq = snapshot.header.seq;
|
let seq = snapshot.header.seq;
|
||||||
if seq > *last_seq + 1 && *last_seq != 0 {
|
if seq > *last_seq + 1 && *last_seq != 0 {
|
||||||
Metrics::add(&state.shared.metrics.feed_dropped, seq - *last_seq - 1);
|
Metrics::add(&state.metrics.feed_dropped, seq - *last_seq - 1);
|
||||||
}
|
}
|
||||||
*last_seq = seq;
|
*last_seq = seq;
|
||||||
socket.send(Message::Binary(snapshot.encode(wants).into())).await?;
|
socket.send(Message::Binary(snapshot.encode(wants).into())).await?;
|
||||||
Metrics::incr(&state.shared.metrics.frames_sent);
|
Metrics::incr(&state.metrics.frames_sent);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
354
services/flysim/crates/flysim/src/feedbus.rs
Normal file
354
services/flysim/crates/flysim/src/feedbus.rs
Normal file
|
|
@ -0,0 +1,354 @@
|
||||||
|
//! The feed over flybus (`FLY_FEED_VIA=bus`, `docs/design/flybus.md` "Feed over the bus").
|
||||||
|
//!
|
||||||
|
//! Both halves of the bus encoding live here, so the publisher in flysim and the subscriber in
|
||||||
|
//! `fly-edge` cannot drift apart:
|
||||||
|
//!
|
||||||
|
//! - [`publish`] turns one [`Snapshot`] into one publication on [`TOPIC`]: every attachment the
|
||||||
|
//! header lists as a sealed artifact named after its kind (`frame`, `audio`, `spikes`), and the
|
||||||
|
//! header itself as the envelope payload `{"header": {...}}`. A header too large for an
|
||||||
|
//! envelope travels as a `header` artifact instead, so no snapshot is ever unpublishable.
|
||||||
|
//! - [`receive`] turns that publication back into the same [`Snapshot`], which `fly-edge` hands to
|
||||||
|
//! [`crate::feed`] exactly as flysim does. The WebSocket bytes are therefore produced by the same
|
||||||
|
//! `Snapshot::encode` on both paths.
|
||||||
|
//!
|
||||||
|
//! The simulation thread never sees any of this. It publishes into its `watch` slot as it
|
||||||
|
//! always has; [`run_publisher`] is a task on the bus's own runtime that reads that slot and
|
||||||
|
//! skips whatever it was too slow to see, the same drop-oldest rule every feed client gets.
|
||||||
|
//! A stalled router, a full store or an absent edge can cost snapshots on the bus, never a
|
||||||
|
//! frame of the loop.
|
||||||
|
|
||||||
|
use std::io::Write as _;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use flybus::{
|
||||||
|
Artifact, BusError, Client, ClientConfig, ErrorCode, Grants, Limits, Message, Pattern, Policy,
|
||||||
|
PublishReceipt, Retained, Router, RouterConfig, UnixListenerHandle,
|
||||||
|
};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
use tokio::sync::watch;
|
||||||
|
|
||||||
|
use crate::metrics::Metrics;
|
||||||
|
use crate::snapshot::{AttachmentKind, FeedHeader, Snapshot};
|
||||||
|
|
||||||
|
/// The one topic: `latest` retention, so a subscriber that joins late starts from the newest
|
||||||
|
/// snapshot and one that falls behind is coalesced rather than queued.
|
||||||
|
pub const TOPIC: &str = "fly.feed.snapshots";
|
||||||
|
/// The publisher's participant id (in-process, launcher-bound).
|
||||||
|
pub const PUBLISHER: &str = "flysim";
|
||||||
|
/// The edge's participant id; the Unix socket is bound to it.
|
||||||
|
pub const EDGE: &str = "fly-edge";
|
||||||
|
/// Socket file under `feed.bus_dir`, bound to [`EDGE`] only.
|
||||||
|
pub const SOCKET: &str = "edge.sock";
|
||||||
|
/// Store root under `feed.bus_dir`; the router makes its per-incarnation directory inside it.
|
||||||
|
pub const STORE: &str = "store";
|
||||||
|
/// Headers up to this many JSON bytes ride in the envelope; anything larger becomes an artifact.
|
||||||
|
/// Well under flybus's 65,536-byte envelope limit, leaving room for the attachment references
|
||||||
|
/// and the router's ids. A real header is 2 to 8 KB.
|
||||||
|
pub const HEADER_INLINE_MAX: usize = 48 * 1024;
|
||||||
|
/// The attachment name of an out-of-line header.
|
||||||
|
pub const HEADER_ARTIFACT: &str = "header";
|
||||||
|
|
||||||
|
/// `<bus_dir>/edge.sock`.
|
||||||
|
pub fn socket_path(bus_dir: &Path) -> PathBuf {
|
||||||
|
bus_dir.join(SOCKET)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<bus_dir>/store`.
|
||||||
|
pub fn store_root(bus_dir: &Path) -> PathBuf {
|
||||||
|
bus_dir.join(STORE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The router limits for the feed (`docs/design/flybus.md`, amendment "Feed sizing").
|
||||||
|
///
|
||||||
|
/// One snapshot with attachments is 122,367 bytes on the live fly: a 92,160-byte 160x144 RGBA
|
||||||
|
/// frame, a 17,407-byte spike bitset (139,255 neurons) and about 12,800 bytes of audio (1,600
|
||||||
|
/// stereo f32 frames at 48 kHz per 30 Hz snapshot). A `latest` subscriber pins at most its one
|
||||||
|
/// queued slot plus its in-flight credits, the topic pins one retained value, and the publisher
|
||||||
|
/// holds one snapshot of staging plus the sealed copy while it seals.
|
||||||
|
///
|
||||||
|
/// Only one client can subscribe at all: the publisher is in process, and the one socket is
|
||||||
|
/// launcher-bound to [`EDGE`], which the router admits once at a time. So the worst case is
|
||||||
|
/// that client holding every subscription it may open ([`Limits::max_subscriptions_per_client`],
|
||||||
|
/// 4), each never consuming with in-flight credits at the cap of 2: `4 * 3 + 1 + 2 = 15`
|
||||||
|
/// snapshots, about 1.8 MB. `max_clients` bounds connections, pending handshakes included, not
|
||||||
|
/// subscribers. The store cap is well over ten times that so a burst of catch-up audio after a
|
||||||
|
/// stall still fits, and it is RAM (tmpfs), so it is kept small on purpose.
|
||||||
|
pub fn limits() -> Limits {
|
||||||
|
Limits {
|
||||||
|
max_clients: 8,
|
||||||
|
max_services: 8,
|
||||||
|
max_topics: 8,
|
||||||
|
max_subscriptions_per_client: 4,
|
||||||
|
max_subscriptions: 16,
|
||||||
|
max_latest_in_flight: 2,
|
||||||
|
max_owners_per_client: 64,
|
||||||
|
reserved_owners_per_client: 8,
|
||||||
|
// Audio accumulates while the loop is behind its publish deadline; 4 MiB is ten seconds
|
||||||
|
// of it, far past anything the pacer allows before it logs lag.
|
||||||
|
max_artifact_bytes: 4 << 20,
|
||||||
|
max_store_bytes: 32 << 20,
|
||||||
|
max_retained_bytes: 8 << 20,
|
||||||
|
..Limits::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// flysim may declare and publish the feed topic; the edge may only subscribe to it.
|
||||||
|
pub fn policy() -> Policy {
|
||||||
|
Policy::closed()
|
||||||
|
.client(
|
||||||
|
PUBLISHER,
|
||||||
|
Grants {
|
||||||
|
publish: vec![Pattern::exact(TOPIC)],
|
||||||
|
manage_topics: vec![Pattern::exact(TOPIC)],
|
||||||
|
..Grants::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.client(
|
||||||
|
EDGE,
|
||||||
|
Grants {
|
||||||
|
subscribe: vec![Pattern::exact(TOPIC)],
|
||||||
|
..Grants::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A running router and the edge's socket. Dropping it stops listening; the router stops with
|
||||||
|
/// the runtime it was started on.
|
||||||
|
pub struct BusFeed {
|
||||||
|
pub router: Router,
|
||||||
|
_listener: UnixListenerHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the embedded router under `bus_dir` and listen for the edge on `<bus_dir>/edge.sock`.
|
||||||
|
///
|
||||||
|
/// Must run inside a Tokio runtime. `Router::new` removes store directories a previous flysim
|
||||||
|
/// left behind (their `flock` is free once that process is gone); a stale socket file is removed
|
||||||
|
/// here, because a socket outlives its listener on disk.
|
||||||
|
pub async fn start_router(bus_dir: &Path) -> anyhow::Result<BusFeed> {
|
||||||
|
use anyhow::Context as _;
|
||||||
|
use std::os::unix::fs::DirBuilderExt as _;
|
||||||
|
std::fs::DirBuilder::new()
|
||||||
|
.recursive(true)
|
||||||
|
.mode(0o700)
|
||||||
|
.create(bus_dir)
|
||||||
|
.with_context(|| format!("creating the bus directory {}", bus_dir.display()))?;
|
||||||
|
let root = store_root(bus_dir);
|
||||||
|
std::fs::DirBuilder::new()
|
||||||
|
.recursive(true)
|
||||||
|
.mode(0o700)
|
||||||
|
.create(&root)
|
||||||
|
.with_context(|| format!("creating the bus store root {}", root.display()))?;
|
||||||
|
let socket = socket_path(bus_dir);
|
||||||
|
match std::fs::remove_file(&socket) {
|
||||||
|
Ok(()) => tracing::info!(socket = %socket.display(), "removed a stale bus socket"),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(error) => {
|
||||||
|
return Err(error).with_context(|| format!("removing {}", socket.display()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut config = RouterConfig::new(root);
|
||||||
|
config.limits = limits();
|
||||||
|
config.policy = policy();
|
||||||
|
let router = Router::new(config).context("starting the flybus router")?;
|
||||||
|
let listener = router
|
||||||
|
.listen_unix_as(&socket, EDGE)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("listening on {}", socket.display()))?;
|
||||||
|
tracing::info!(
|
||||||
|
socket = %socket.display(),
|
||||||
|
store = %router.store_dir().display(),
|
||||||
|
router = router.router_id(),
|
||||||
|
"feed bus listening"
|
||||||
|
);
|
||||||
|
Ok(BusFeed {
|
||||||
|
router,
|
||||||
|
_listener: listener,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attachment_name(kind: AttachmentKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
AttachmentKind::Frame => "frame",
|
||||||
|
AttachmentKind::Audio => "audio",
|
||||||
|
AttachmentKind::Spikes => "spikes",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content_type(kind: AttachmentKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
AttachmentKind::Frame => "image/x-rgba",
|
||||||
|
AttachmentKind::Audio => "audio/x-f32le",
|
||||||
|
AttachmentKind::Spikes => "application/x-spike-bitset",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes_of(snapshot: &Snapshot, kind: AttachmentKind) -> &[u8] {
|
||||||
|
match kind {
|
||||||
|
AttachmentKind::Frame => &snapshot.frame,
|
||||||
|
AttachmentKind::Audio => &snapshot.audio,
|
||||||
|
AttachmentKind::Spikes => &snapshot.spikes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seal(client: &Client, bytes: &[u8], content_type: &str) -> Result<Artifact, BusError> {
|
||||||
|
let mut writer = client
|
||||||
|
.artifacts()
|
||||||
|
.allocate(bytes.len() as u64, content_type)
|
||||||
|
.await?;
|
||||||
|
writer
|
||||||
|
.write_all(bytes)
|
||||||
|
.map_err(|error| BusError::new(ErrorCode::StoreFailure, format!("staging: {error}")))?;
|
||||||
|
writer.seal().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish one snapshot: its attachments as artifacts, its header as the payload.
|
||||||
|
pub async fn publish(client: &Client, snapshot: &Snapshot) -> Result<PublishReceipt, BusError> {
|
||||||
|
let header = &snapshot.header;
|
||||||
|
let json = serde_json::to_vec(header).expect("a FeedHeader always serializes");
|
||||||
|
|
||||||
|
let mut kinds: Vec<AttachmentKind> = Vec::with_capacity(3);
|
||||||
|
for kind in header.attachments.iter().copied() {
|
||||||
|
if !kinds.contains(&kind) {
|
||||||
|
kinds.push(kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut artifacts: Vec<(&'static str, Artifact)> = Vec::with_capacity(4);
|
||||||
|
for kind in kinds {
|
||||||
|
let artifact = seal(client, bytes_of(snapshot, kind), content_type(kind)).await?;
|
||||||
|
artifacts.push((attachment_name(kind), artifact));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut payload = Map::new();
|
||||||
|
if json.len() <= HEADER_INLINE_MAX {
|
||||||
|
let value: Value = serde_json::from_slice(&json).expect("a serialized header re-parses");
|
||||||
|
payload.insert("header".into(), value);
|
||||||
|
} else {
|
||||||
|
artifacts.push((
|
||||||
|
HEADER_ARTIFACT,
|
||||||
|
seal(client, &json, "application/json").await?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let attachments: Vec<(&str, &Artifact)> = artifacts
|
||||||
|
.iter()
|
||||||
|
.map(|(name, artifact)| (*name, artifact))
|
||||||
|
.collect();
|
||||||
|
client.publish(TOPIC, payload, &attachments).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild the snapshot one publication carries. The message's delivery is released when the
|
||||||
|
/// caller drops it; every byte has been copied out by then.
|
||||||
|
pub async fn receive(message: &Message) -> Result<Snapshot, BusError> {
|
||||||
|
let invalid = |what: String| BusError::new(ErrorCode::InvalidEnvelope, what);
|
||||||
|
let header: FeedHeader = match message.payload().get("header") {
|
||||||
|
Some(value) => serde_json::from_value(value.clone())
|
||||||
|
.map_err(|error| invalid(format!("feed header: {error}")))?,
|
||||||
|
None => {
|
||||||
|
let bytes = message.artifact(HEADER_ARTIFACT)?.read_all().await?;
|
||||||
|
serde_json::from_slice(&bytes)
|
||||||
|
.map_err(|error| invalid(format!("feed header artifact: {error}")))?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut snapshot = Snapshot {
|
||||||
|
header,
|
||||||
|
frame: Arc::new(Vec::new()),
|
||||||
|
audio: Arc::new(Vec::new()),
|
||||||
|
spikes: Arc::new(Vec::new()),
|
||||||
|
};
|
||||||
|
let kinds = snapshot.header.attachments.clone();
|
||||||
|
for kind in kinds {
|
||||||
|
let bytes = Arc::new(message.artifact(attachment_name(kind))?.read_all().await?);
|
||||||
|
match kind {
|
||||||
|
AttachmentKind::Frame => snapshot.frame = bytes,
|
||||||
|
AttachmentKind::Audio => snapshot.audio = bytes,
|
||||||
|
AttachmentKind::Spikes => snapshot.spikes = bytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How often a failing publisher repeats its warning.
|
||||||
|
const WARN_EVERY: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// Publish every snapshot the sim puts in its watch slot until the sim is gone.
|
||||||
|
///
|
||||||
|
/// Connects in process as [`PUBLISHER`], declares [`TOPIC`] with `latest` retention and
|
||||||
|
/// publishes the current snapshot first, so an edge that connects at once still sees the boot
|
||||||
|
/// state. A refused publication (a full store, say) is counted and skipped; a lost connection
|
||||||
|
/// is re-made after a second. Borrows of the watch slot end before any await, exactly as in
|
||||||
|
/// [`crate::feed`]: a held borrow is a lock the sim thread's next publish would wait on.
|
||||||
|
pub async fn run_publisher(
|
||||||
|
router: Router,
|
||||||
|
mut snapshots: watch::Receiver<Arc<Snapshot>>,
|
||||||
|
metrics: Arc<Metrics>,
|
||||||
|
) {
|
||||||
|
let store_root = router.store_root().to_path_buf();
|
||||||
|
let mut last_warning: Option<Instant> = None;
|
||||||
|
let mut warn = |error: &BusError, what: &str| {
|
||||||
|
if last_warning.is_none_or(|at| at.elapsed() >= WARN_EVERY) {
|
||||||
|
tracing::warn!(%error, "feed bus: {what}");
|
||||||
|
last_warning = Some(Instant::now());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loop {
|
||||||
|
let transport = router.connect_in_memory_as(PUBLISHER);
|
||||||
|
let client =
|
||||||
|
match Client::connect(transport, ClientConfig::new(PUBLISHER, &store_root)).await {
|
||||||
|
Ok(client) => client,
|
||||||
|
Err(error) => {
|
||||||
|
warn(&error, "the publisher could not connect");
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(error) = client.declare_topic(TOPIC, Retained::Latest).await {
|
||||||
|
warn(&error, "the feed topic could not be declared");
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut current = snapshots.borrow_and_update().clone();
|
||||||
|
loop {
|
||||||
|
match publish(&client, ¤t).await {
|
||||||
|
Ok(_) => Metrics::incr(&metrics.bus_published),
|
||||||
|
Err(error) => {
|
||||||
|
Metrics::incr(&metrics.bus_publish_failures);
|
||||||
|
warn(&error, "a snapshot was not published");
|
||||||
|
if client.closed().is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if snapshots.changed().await.is_err() {
|
||||||
|
// The sim thread is gone; so is the service.
|
||||||
|
client.close().await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current = snapshots.borrow_and_update().clone();
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_limits_validate_and_hold_the_worst_case_with_room() {
|
||||||
|
let limits = limits();
|
||||||
|
limits.validate().unwrap();
|
||||||
|
// A full snapshot on the live fly (see `limits`).
|
||||||
|
let snapshot_bytes = crate::snapshot::FRAME_BYTES + 139_255usize.div_ceil(8) + 12_800;
|
||||||
|
assert_eq!(snapshot_bytes, 122_367);
|
||||||
|
// One subscribing client (the socket's), every subscription it may open, none consuming.
|
||||||
|
let subscriptions = limits.max_subscriptions_per_client as u64;
|
||||||
|
let pinned = subscriptions * (1 + limits.max_latest_in_flight) + 1 + 2;
|
||||||
|
assert_eq!(pinned, 15);
|
||||||
|
assert!(
|
||||||
|
pinned * snapshot_bytes as u64 * 10 <= limits.max_store_bytes,
|
||||||
|
"{pinned}"
|
||||||
|
);
|
||||||
|
assert!(limits.max_artifact_bytes >= crate::snapshot::FRAME_BYTES as u64 * 40);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,8 @@
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! +-- watch<Snapshot> --> feed :7400/feed (axum + ws)
|
//! +-- watch<Snapshot> --> feed :7400/feed (axum + ws)
|
||||||
//! sim thread ---------+
|
//! sim thread ---------+ \-> feedbus -> flybus -> fly-edge :7400/feed
|
||||||
|
//! | (FLY_FEED_VIA=bus instead of the line above)
|
||||||
//! agent +-- Shared ------------> api :7401 (axum)
|
//! agent +-- Shared ------------> api :7401 (axum)
|
||||||
//! emulator | /status /stimulate /reward /checkpoint
|
//! emulator | /status /stimulate /reward /checkpoint
|
||||||
//! adapter | /pause /resume /events /healthz /metrics
|
//! adapter | /pause /resume /events /healthz /metrics
|
||||||
|
|
@ -25,6 +26,7 @@ pub mod chat;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod eventlog;
|
pub mod eventlog;
|
||||||
pub mod feed;
|
pub mod feed;
|
||||||
|
pub mod feedbus;
|
||||||
pub mod macros;
|
pub mod macros;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
pub mod pacing;
|
pub mod pacing;
|
||||||
|
|
@ -41,7 +43,7 @@ use std::sync::Arc;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use tokio::sync::{mpsc, watch};
|
use tokio::sync::{mpsc, watch};
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::{Config, FeedVia};
|
||||||
use crate::eventlog::{EventRing, now_wall_ms};
|
use crate::eventlog::{EventRing, now_wall_ms};
|
||||||
use crate::simloop::{COMMAND_QUEUE, Command, Shared, Sim, booting_snapshot};
|
use crate::simloop::{COMMAND_QUEUE, Command, Shared, Sim, booting_snapshot};
|
||||||
use crate::snapshot::Snapshot;
|
use crate::snapshot::Snapshot;
|
||||||
|
|
@ -59,6 +61,15 @@ impl AppState {
|
||||||
pub fn snapshot(&self) -> Arc<Snapshot> {
|
pub fn snapshot(&self) -> Arc<Snapshot> {
|
||||||
Arc::clone(&self.snapshots.borrow())
|
Arc::clone(&self.snapshots.borrow())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the feed server needs, when flysim serves the feed itself.
|
||||||
|
pub fn feed(&self) -> feed::FeedState {
|
||||||
|
feed::FeedState {
|
||||||
|
snapshots: self.snapshots.clone(),
|
||||||
|
metrics: Arc::clone(&self.shared.metrics),
|
||||||
|
idle_period: self.shared.config.publish_periods().1,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the service until a signal or a fatal simulation error.
|
/// Run the service until a signal or a fatal simulation error.
|
||||||
|
|
@ -86,10 +97,17 @@ pub fn run(config: Config) -> Result<()> {
|
||||||
let feed_addr = config.feed.bind;
|
let feed_addr = config.feed.bind;
|
||||||
let control_addr = config.control.bind;
|
let control_addr = config.control.bind;
|
||||||
let metrics_addr = config.control.metrics_bind;
|
let metrics_addr = config.control.metrics_bind;
|
||||||
|
let via = config.feed.via;
|
||||||
let listeners = runtime.block_on(async {
|
let listeners = runtime.block_on(async {
|
||||||
let feed = tokio::net::TcpListener::bind(feed_addr)
|
// In bus mode the feed port belongs to `fly-edge`; binding it here would take it away.
|
||||||
.await
|
let feed = match via {
|
||||||
.with_context(|| format!("binding the feed listener on {feed_addr}"))?;
|
FeedVia::Direct => Some(
|
||||||
|
tokio::net::TcpListener::bind(feed_addr)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("binding the feed listener on {feed_addr}"))?,
|
||||||
|
),
|
||||||
|
FeedVia::Bus => None,
|
||||||
|
};
|
||||||
let control = tokio::net::TcpListener::bind(control_addr)
|
let control = tokio::net::TcpListener::bind(control_addr)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("binding the control listener on {control_addr}"))?;
|
.with_context(|| format!("binding the control listener on {control_addr}"))?;
|
||||||
|
|
@ -104,16 +122,42 @@ pub fn run(config: Config) -> Result<()> {
|
||||||
Ok::<_, anyhow::Error>((feed, control, metrics))
|
Ok::<_, anyhow::Error>((feed, control, metrics))
|
||||||
})?;
|
})?;
|
||||||
let (feed_listener, control_listener, metrics_listener) = listeners;
|
let (feed_listener, control_listener, metrics_listener) = listeners;
|
||||||
tracing::info!(feed = %feed_addr, control = %control_addr, metrics = ?metrics_addr, "listening");
|
tracing::info!(
|
||||||
|
feed = %feed_addr,
|
||||||
|
feed_via = via.as_str(),
|
||||||
|
control = %control_addr,
|
||||||
|
metrics = ?metrics_addr,
|
||||||
|
"listening"
|
||||||
|
);
|
||||||
|
|
||||||
{
|
if let Some(feed_listener) = feed_listener {
|
||||||
let state = state.clone();
|
let state = state.feed();
|
||||||
runtime.spawn(async move {
|
runtime.spawn(async move {
|
||||||
if let Err(error) = axum::serve(feed_listener, feed::router(state)).await {
|
if let Err(error) = axum::serve(feed_listener, feed::router(state)).await {
|
||||||
tracing::error!(%error, "the feed listener stopped");
|
tracing::error!(%error, "the feed listener stopped");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// The bus gets a runtime of its own, so neither its router nor the artifact copies can take
|
||||||
|
// a worker from the control API; and it is fed from the watch slot, never from the sim thread.
|
||||||
|
let bus_runtime = match via {
|
||||||
|
FeedVia::Direct => None,
|
||||||
|
FeedVia::Bus => {
|
||||||
|
let bus_runtime = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.worker_threads(2)
|
||||||
|
.thread_name("flysim-bus")
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.context("building the bus runtime")?;
|
||||||
|
let bus = bus_runtime.block_on(feedbus::start_router(&config.feed.bus_dir))?;
|
||||||
|
bus_runtime.spawn(feedbus::run_publisher(
|
||||||
|
bus.router.clone(),
|
||||||
|
state.snapshots.clone(),
|
||||||
|
Arc::clone(&state.shared.metrics),
|
||||||
|
));
|
||||||
|
Some((bus_runtime, bus))
|
||||||
|
}
|
||||||
|
};
|
||||||
{
|
{
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
runtime.spawn(async move {
|
runtime.spawn(async move {
|
||||||
|
|
@ -139,6 +183,13 @@ pub fn run(config: Config) -> Result<()> {
|
||||||
let result = sim.run(¬ifier);
|
let result = sim.run(¬ifier);
|
||||||
notifier.notify("STOPPING=1\n");
|
notifier.notify("STOPPING=1\n");
|
||||||
drop(sim);
|
drop(sim);
|
||||||
|
if let Some((bus_runtime, bus)) = bus_runtime {
|
||||||
|
// The publisher ends by itself once the watch sender is gone; stopping the runtime under
|
||||||
|
// it, rather than the router first, keeps a last in-flight publish from being logged as
|
||||||
|
// a refusal. The edge sees the socket close either way.
|
||||||
|
drop(bus);
|
||||||
|
bus_runtime.shutdown_timeout(std::time::Duration::from_secs(1));
|
||||||
|
}
|
||||||
runtime.shutdown_timeout(std::time::Duration::from_secs(2));
|
runtime.shutdown_timeout(std::time::Duration::from_secs(2));
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,10 @@ pub struct Metrics {
|
||||||
pub lag_ms: AtomicU64,
|
pub lag_ms: AtomicU64,
|
||||||
/// 1 when the restore fell back past the newest candidate.
|
/// 1 when the restore fell back past the newest candidate.
|
||||||
pub restore_fallback: AtomicU64,
|
pub restore_fallback: AtomicU64,
|
||||||
|
/// Snapshots published on the feed bus (`FLY_FEED_VIA=bus`); 0 in direct mode.
|
||||||
|
pub bus_published: AtomicU64,
|
||||||
|
/// Snapshots the feed bus refused or could not take; each one is skipped, never retried.
|
||||||
|
pub bus_publish_failures: AtomicU64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Metrics {
|
impl Metrics {
|
||||||
|
|
@ -81,7 +85,7 @@ impl Metrics {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One metric line plus its help and type headers.
|
/// One metric line plus its help and type headers.
|
||||||
fn metric(out: &mut String, name: &str, kind: &str, help: &str, value: impl std::fmt::Display) {
|
pub fn metric(out: &mut String, name: &str, kind: &str, help: &str, value: impl std::fmt::Display) {
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
let _ = writeln!(out, "# HELP {name} {help}");
|
let _ = writeln!(out, "# HELP {name} {help}");
|
||||||
let _ = writeln!(out, "# TYPE {name} {kind}");
|
let _ = writeln!(out, "# TYPE {name} {kind}");
|
||||||
|
|
@ -114,6 +118,20 @@ pub fn render(metrics: &Metrics, snapshot: &Snapshot, now_wall_ms: u64) -> Strin
|
||||||
"Snapshots superseded before a slow client could be sent them.",
|
"Snapshots superseded before a slow client could be sent them.",
|
||||||
Metrics::get(&metrics.feed_dropped),
|
Metrics::get(&metrics.feed_dropped),
|
||||||
);
|
);
|
||||||
|
metric(
|
||||||
|
&mut out,
|
||||||
|
"fly_bus_published_total",
|
||||||
|
"counter",
|
||||||
|
"Snapshots published on the feed bus (FLY_FEED_VIA=bus).",
|
||||||
|
Metrics::get(&metrics.bus_published),
|
||||||
|
);
|
||||||
|
metric(
|
||||||
|
&mut out,
|
||||||
|
"fly_bus_publish_failures_total",
|
||||||
|
"counter",
|
||||||
|
"Snapshots the feed bus did not take; skipped, like any superseded snapshot.",
|
||||||
|
Metrics::get(&metrics.bus_publish_failures),
|
||||||
|
);
|
||||||
metric(
|
metric(
|
||||||
&mut out,
|
&mut out,
|
||||||
"fly_snapshots_published_total",
|
"fly_snapshots_published_total",
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,7 @@ pub struct DecoderChannelStatus {
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Shared {
|
pub struct Shared {
|
||||||
pub config: Config,
|
pub config: Config,
|
||||||
pub metrics: Metrics,
|
pub metrics: Arc<Metrics>,
|
||||||
pub events: EventRing,
|
pub events: EventRing,
|
||||||
/// `Date.now()` at the top of the most recent loop iteration. `GET /healthz` is 200 while
|
/// `Date.now()` at the top of the most recent loop iteration. `GET /healthz` is 200 while
|
||||||
/// this is less than two seconds old, which is true while paused as well: a paused loop is
|
/// this is less than two seconds old, which is true while paused as well: a paused loop is
|
||||||
|
|
@ -173,7 +173,7 @@ impl Shared {
|
||||||
pub fn new(config: Config, events: EventRing) -> Self {
|
pub fn new(config: Config, events: EventRing) -> Self {
|
||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
metrics: Metrics::default(),
|
metrics: Arc::default(),
|
||||||
events,
|
events,
|
||||||
heartbeat_ms: AtomicU64::new(0),
|
heartbeat_ms: AtomicU64::new(0),
|
||||||
versions: OnceLock::new(),
|
versions: OnceLock::new(),
|
||||||
|
|
|
||||||
|
|
@ -3182,3 +3182,111 @@ fn the_pewter_east_pad_is_never_one_dead_button_from_the_rung_ten_checkpoint() {
|
||||||
// ten brain minutes in.
|
// ten brain minutes in.
|
||||||
assert!(minutes < 1.0, "the fly waited {minutes:.2} brain minutes for a window to lapse");
|
assert!(minutes < 1.0, "the fly waited {minutes:.2} brain minutes for a window to lapse");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The row-58 checkpoint (Pewter City, outside the gym, taken during the loop), or `None` to skip.
|
||||||
|
fn door_checkpoint() -> Option<flysim::store::Checkpoint> {
|
||||||
|
std::env::var_os("FLY_DOOR_CHECKPOINT").map(|path| {
|
||||||
|
flysim::store::load(std::path::Path::new(&path))
|
||||||
|
.expect("the checkpoint should be a FLYSIM01 envelope")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// From Pewter City, the checkpoint taken while the fly was walking in and out of the gym's door.
|
||||||
|
///
|
||||||
|
/// **What was live** (2026-09-23, rank 10 PEWTER CITY, v0.5.3): for twenty-five minutes
|
||||||
|
/// `GO OBJECTIVE` into the Pewter Gym and `GO OUT` straight back out, with `GO ITEM`,
|
||||||
|
/// `GO FRONTIER`, `YES` and `NO` mixed in -- about 93 `GO OUT` and 47 `GO OBJECTIVE` per ten brain
|
||||||
|
/// minutes, every one `done`, and not one reward event. The watchdog saw ten distinct names.
|
||||||
|
///
|
||||||
|
/// **What the survey found** (`infra/docs/macros-traps.md` row 58): from the gym's doormat the
|
||||||
|
/// cartridge draws only the guide, already talked to, and hides BROCK and the Jr. Trainer for being
|
||||||
|
/// off the screen -- so the rung's list of people was empty, `GO OBJECTIVE` had nothing to aim at
|
||||||
|
/// inside and `GO OUT` was the pad; outside, `GO OBJECTIVE` aimed at the door. And three frames
|
||||||
|
/// the seam read as the fly's own were the cartridge's: a warp's tear, a battle's transition, and
|
||||||
|
/// a trainer walking up -- each of which wrote an entry that kept the room empty.
|
||||||
|
///
|
||||||
|
/// The claims, none of them about which button the fly presses:
|
||||||
|
///
|
||||||
|
/// - **the gym is not a door in and a door out**: at most three arrivals end in the fly walking
|
||||||
|
/// straight back out inside ten seconds, against one every few seconds on the base;
|
||||||
|
/// - **the fly goes up the room**: it stands at row 6 or above on map 54, where the Jr. Trainer
|
||||||
|
/// is, which it never does on the base.
|
||||||
|
///
|
||||||
|
/// Rung 11 is printed and not asserted: which button the fly presses at the leader is the fly's.
|
||||||
|
///
|
||||||
|
/// ```sh
|
||||||
|
/// FLY_ROM=/path/to/pokemon-red.gb \
|
||||||
|
/// FLY_DOOR_CHECKPOINT=.local/checkpoints/release-rank10-row58.checkpoint \
|
||||||
|
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture the_gym
|
||||||
|
/// ```
|
||||||
|
#[test]
|
||||||
|
fn the_gym_is_not_a_door_in_and_a_door_out_from_the_rung_ten_checkpoint() {
|
||||||
|
let rom = skip_without_rom!();
|
||||||
|
let Some(checkpoint) = door_checkpoint() else {
|
||||||
|
eprintln!("skipped: no FLY_DOOR_CHECKPOINT");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
|
||||||
|
assert_eq!(run.map(), PEWTER_CITY, "the checkpoint is the town outside the gym's door");
|
||||||
|
|
||||||
|
let mut arrivals = 0u32;
|
||||||
|
let mut bounces = 0u32;
|
||||||
|
let mut arrived_at: Option<u32> = None;
|
||||||
|
let mut highest_row: Option<u8> = None;
|
||||||
|
let mut frames_in_gym = 0u32;
|
||||||
|
let mut badge = None;
|
||||||
|
let mut previous = run.map();
|
||||||
|
for frame in 0..108_000u32 {
|
||||||
|
run.frame();
|
||||||
|
let map = run.map();
|
||||||
|
if map != previous {
|
||||||
|
if map == PEWTER_GYM {
|
||||||
|
arrivals += 1;
|
||||||
|
arrived_at = Some(frame);
|
||||||
|
} else if previous == PEWTER_GYM {
|
||||||
|
if arrived_at.is_some_and(|at| frame - at < 600) {
|
||||||
|
bounces += 1;
|
||||||
|
}
|
||||||
|
arrived_at = None;
|
||||||
|
}
|
||||||
|
previous = map;
|
||||||
|
}
|
||||||
|
if map == PEWTER_GYM {
|
||||||
|
frames_in_gym += 1;
|
||||||
|
if let Some(player) = flybrain_gb::pokemon_red::state::player(&mut run.gb)
|
||||||
|
&& u32::from(player.map) == PEWTER_GYM
|
||||||
|
{
|
||||||
|
highest_row = Some(highest_row.map_or(player.y, |row| row.min(player.y)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if badge.is_none() && run.adapter.progress().rank >= 11 {
|
||||||
|
badge = Some(frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let progress = run.adapter.progress();
|
||||||
|
eprintln!(
|
||||||
|
"{:.1} brain minutes: gym arrivals {arrivals}, straight back out {bounces}, frames in the \
|
||||||
|
gym {frames_in_gym}, highest row reached {highest_row:?}, macros {:?}, rank {} ({})",
|
||||||
|
run.ms / 60_000.0,
|
||||||
|
run.started,
|
||||||
|
progress.rank,
|
||||||
|
progress.rank_label
|
||||||
|
);
|
||||||
|
match badge {
|
||||||
|
Some(frame) => eprintln!(
|
||||||
|
"rung 11 at frame {frame} ({:.2} brain minutes)",
|
||||||
|
f64::from(frame) * MS_PER_FRAME / 60_000.0
|
||||||
|
),
|
||||||
|
None => eprintln!("rung 11 not reached inside the budget"),
|
||||||
|
}
|
||||||
|
assert!(arrivals > 0, "the fly never went through the gym's door: {:?}", run.route);
|
||||||
|
assert!(
|
||||||
|
bounces <= 3,
|
||||||
|
"{bounces} of {arrivals} arrivals walked straight back out: {:?}",
|
||||||
|
run.started
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
highest_row.is_some_and(|row| row <= 6),
|
||||||
|
"the fly never went up the room past the doormat rows: highest row {highest_row:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,13 @@ bind = "127.0.0.1:7400"
|
||||||
# Audio attachment rate. 48 kHz is Web Audio's native rate on Linux, so the page never resamples.
|
# Audio attachment rate. 48 kHz is Web Audio's native rate on Linux, so the page never resamples.
|
||||||
# env: FLYSIM_FEED_AUDIO_HZ
|
# env: FLYSIM_FEED_AUDIO_HZ
|
||||||
audio_hz = 48000
|
audio_hz = 48000
|
||||||
|
# Who serves `bind`: "direct" (flysim, the default) or "bus" (flysim publishes on an embedded
|
||||||
|
# flybus router and the `fly-edge` process serves the same bytes; docs/design/flybus.md).
|
||||||
|
# env: FLY_FEED_VIA, FLYSIM_FEED_VIA
|
||||||
|
via = "direct"
|
||||||
|
# The bus router's socket and artifact store in "bus" mode. tmpfs.
|
||||||
|
# env: FLY_BUS_DIR, FLYSIM_FEED_BUS_DIR
|
||||||
|
bus_dir = "/run/fly/bus"
|
||||||
|
|
||||||
[control]
|
[control]
|
||||||
# http://127.0.0.1:7401 — docs/control-api.md. Loopback only; there is no auth because nothing
|
# http://127.0.0.1:7401 — docs/control-api.md. Loopback only; there is no auth because nothing
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue