Skip to content

Write the wire to a file, and play it back with no lab - #80

Merged
aenawi merged 1 commit into
developfrom
feat/hud-record-and-replay
Sep 13, 2026
Merged

aenawi merged 1 commit into
developfrom
feat/hud-record-and-replay

Conversation

@aenawi

@aenawi aenawi commented Sep 13, 2026 •

Copy link
Copy Markdown
Owner

Target

  • This is going into develop: the normal case for a fix, a chapter, a
    lab change, a typo
  • This is a release: develop → main, shipping to the published guide
  • This is a hotfix: straight into main, because published advice is
    currently locking readers out or leaving them exposed

What this changes

The lab's wire can now be written to a file and watched back. A recording is a
.jsonl of /api/stream/hud — one JSON object per line — and a new page,
control/ui/replay.html, reads one and draws it on the same board, with the
same set-pieces and the same cards a theme docked, asking no server anything at
all. This is branch 4 of the HUD sequence and the one the other three were for:
a captured attack is a file somebody can post, and anybody can watch it with no
containers running.

Two ways to make one. make record curls the wire and strips the SSE framing.
The record button in the live page's bar writes down the frames that are
already arriving, so a recording is exactly what that page saw — including the
capture somebody had switched on, and not the one they had not. It opens no
connection and starts no process in a container, which is why it sits above the
busy guard: the minute worth recording is the minute every other button is
disabled.

The opening hello grew a page — the /api/meta payload, the theme store
and the widget registry. That is what makes a file complete on its own: the
player has no server to ask, so a recording carrying only events could not say
which machines to cut doorways for, which actions have a set-piece, or where
the cards go.

The player is a viewer and deliberately not a control surface. No switches, no
presets, no attack buttons, because a recording has nothing to press them
against. What is there instead is a transport, and a list of the moments —
every Result the lab returned — to jump to.

Closes #

Kind

  • Prose: clarity, typo, dead link
  • The advice was wrong, unsafe, or out of date
  • Lab or sandbox: code, checks, a new scenario
  • A divergence between the lab and the sandbox, closed or newly documented
  • Tests
  • Something else

If this changes advice or a command

make record is new and replay.html is new, so both are claims about
commands. Here is what they were measured against.

OS: macOS 26.6.2 (Darwin 25.6.0), arm64
Docker: 29.7.2, compose 5.5.1
Browser: Chrome 147 (headless, via Playwright)
Go: 1.26-alpine (in the control image)
Headscale: 0.29.3, tailscale 1.102.4 in the node image
Measured on: 2026-09-12

A forty-five second recording, with a probe and one attack driven through it:

$ make -C lab record FOR=45 FILE=demo.jsonl
Recording demo.jsonl from http://localhost:8099/api/stream/hud.
Stopping by itself after 45 seconds.

40 frames in demo.jsonl.
Watch it: open http://localhost:8099/replay.html and drop the file on the page.

$ # what is in it
{'hello': 1, 'status': 5, 'netcheck': 15, 'state': 2, 'stat': 15, 'flow': 1, 'verdict': 1}
span ms: 42802
 moment (11999, 'probe', 'probe', 5)
 moment (19229, 'action', 'scan-public', 4)

The first line carries what the player needs, with nothing else to ask:

$ curl -sN --max-time 4 http://localhost:8099/api/stream/hud | sed -n 's/^data: //p' | head -1
type: hello seq: 1
page keys: ['meta', 'themes', 'widgets']
meta keys: ['actions', 'attacks', 'catalog', 'delaySteps', 'grants', 'groups', 'lossSteps', 'panels', 'presets']
themes: ['chapter', 'warroom']
widgets: ['tape', 'wire', 'tally']
layouts: {'chapter': [], 'warroom': [('wire', 'top'), ('tape', 'right'), ('tally', 'bottom')]}

Played back with no Docker in the path — a plain static server over
lab/control/ui, the lab untouched:

$ cd lab/control/ui && python3 -m http.server 8123
# http://localhost:8123/replay.html?src=demo.jsonl
127.0.0.1 - - "GET /replay.html?src=demo.jsonl HTTP/1.1" 200 -
127.0.0.1 - - "GET /wire.js HTTP/1.1" 200 -
127.0.0.1 - - "GET /replay.js HTTP/1.1" 200 -
127.0.0.1 - - "GET /replay-app.js HTTP/1.1" 200 -
127.0.0.1 - - "GET /demo.jsonl HTTP/1.1" 200 -
127.0.0.1 - - "GET /hud/scene.js HTTP/1.1" 200 -
... every module 200, and /favicon.ico the only 404

Jumping to the probe in that file replays the measurement, not a summary of it:

chip:    "direct · punched"
verdict: "rung 5 of 5 — reached 100.71.4.3:22"
rungs:   passed | passed | passed | passed | delivered
nums:    1.1 ms  ·  5 packets  ·  2048 bytes

And to the attack, which held:

verdict:    "rung 4 of 5 — Every public port is closed. The only way in now is
             the tailnet — which is the state chapter 08 is trying to get you to."
rungs:      passed | passed | passed | stopped | -
transcript: nmap -Pn -n -p 22,80,8080,41641 203.0.113.11   # on evil-box

The three docked widgets run against the player with nothing in them edited
except the one line below — wire reads status().replay so it stops claiming
a connection it does not have:

wire:  "the connection · a recording · paused · frames numbered 23 · frames arrived 6 · dropped 0"
tape:  "0023 verdict scan-public — allowed at rung 4 · ufw default incoming policy: deny"
tally: "RUNG 4  1 / 0   scan-public — allowed at rung 4"

The record button, driven on the live page:

after 5s:  button reads "stop · 12 frames", aria-pressed true
on stop:   "15 frames saved. Open it in the player — replay.html — and it draws
            with no containers running at all."

And the round trip through the recorder, which is what caught the one real bug
below:

frames: 10, duration: 6s (recorded for 7)
first five: hello state status netcheck stat, all at offset 0
wasAt present on: 5 frames
skipped: 0, page.meta: present
  • I checked whether any other chapter makes the same claim
  • If this step can lock someone out of a machine, the warning sits before the step
  • The mechanism is explained, not just the command

Checks

  • make -C lab check passes
  • gofmt is clean (make check fails on any output at all)
  • New source files carry an SPDX-License-Identifier header

If this touches the guide's pages

It does not. Nothing under chapters/ or assets/ is touched; the guide still
opens from file:// because none of this is in it.

If this touches the lab or sandbox engine

  • Nothing is drawn that was not measured
  • A failed attack does not render as a green tick
  • danger still outranks ok
  • Time is not faked
  • The engine still returns the rung that decided the outcome, not a bare pass/fail
  • If a score moved, the comparison table in lab/README.md moved with it

The engine is untouched: the player draws from the same Result the API
returned, carried on the feed since #78, and decides nothing. Time is not
faked
is the box worth a sentence, because this branch is about time. The
player's clock is the frames' own at values and the only arithmetic on them
is a monotonic clamp — a file whose timestamps go backwards plays at the same
instant rather than hanging or replaying. The one place a timestamp is written
rather than read is the recorder's opening snapshots, and it is written because
the frame is being issued then; the measurement's own clock travels beside it
as wasAt and nothing is lost. There is a test for both.

Licence

  • I have the right to submit this, and I am fine with it going out under
    CC BY-SA 4.0 (guide) or GPL-3.0-or-later (code), per the file I touched

Three bugs found by driving it, all fixed here

Each of these looked fine in the tests and was wrong in the browser, which is
the reason for driving it.

  1. A seven-second recording claimed forty-two. state, status and
    netcheck are sent only when they changed, so the snapshots a mid-session
    recorder seeds from can be minutes old. Written with their own clock they
    put the whole recording behind a gap of dead air. They are now stamped when
    they were written, with wasAt carrying when they were measured.

  2. One rail in a space-between deck lands on the left. .deck has always
    had two rails, so nothing noticed. The player has one, and every inset was
    then measured from the wrong side: the cards, the transport and the verdict
    all squeezed into a column an inch wide over a board with the whole window
    free beside it. Fixed with .deck-right.

  3. The docks were initialised with a feed that did not exist yet. On the
    live page the feed is there before the page is; here the player is built
    when a file is opened. A one-shot slots.init handed every card a null feed
    and left all three saying "no feed on this page" for the rest of the
    session — beside a board playing perfectly.

Not in this change

Found while driving this, left alone per one idea per branch:

  • .hud-overlay has a fixed 12px bottom padding, so the board's own
    verdict panel sits under a card docked in the bottom slot. It reads
    --pad-r and --pad-l but not --pad-b. This is on the live page too —
    select Warroom, run an attack, and the verdict is behind the tally card — so
    it arrived with Dock cards around the board, so a theme can arrange the instrument #79 rather than with this.

  • The board is still not a widget. Making it one means it reading the feed
    instead of being driven by app.js, and this page drives it the same way
    app.js does rather than changing that. Worth its own branch.


Correction, 2026-09-13

The Environment block above was wrong when this was merged. Four of its six
lines were written from memory rather than read off this machine — macOS 15 for
26.6.2, Docker 28.5.1 for 29.7.2, Go 1.25 for 1.26, Headscale 0.26.1 for
0.29.3 — which is exactly the thing
AGENTS.md is there to
stop, and the form is where it is meant to be caught.

They have been replaced with the measured values. Nothing else in this body
changes: every number in the transcripts above was read off the run it claims
to be from, and the behaviour those versions produced is what is described.

Branch 4 of the HUD sequence, and the one the other three were for. A
recording is a `.jsonl` of /api/stream/hud — one JSON object per line — and
`ui/replay.html` reads one and draws it on the same board, with the same
set-pieces and the same cards a theme docked, asking no server anything.

Two ways to make one. `make record` curls the wire and strips the SSE framing;
the **record** button in the live page's bar writes down the frames already
arriving, so a recording is exactly what that page saw — including the capture
somebody had switched on. It opens no connection and starts no process in a
container, which is why it sits above the busy guard: the minute worth
recording is the minute every other button is disabled.

The opening `hello` grew a `page`: the /api/meta payload, the theme store and
the widget registry. That is what makes a file complete — the player has no
server to ask, so a recording carrying only events could not say which machines
to cut doorways for or where the cards go. `metaPayload()` is now one function
rather than two literals, because the copy that drifts is the one in the
recording and nothing would say so.

The player is a viewer and deliberately not a control surface: no switches, no
attacks, because a recording has nothing to press them against. What is there
is a transport, and a list of the moments — every Result the lab returned — to
jump to.

Three things that are wrong invisibly rather than loudly, so they are tested:

- A scrub is not a fast replay. The snapshots are sent only when they changed,
  so seeking re-applies the ones that were standing and skips the packets and
  verdicts between — replaying at 60× would fire every set-piece it passed.
- The hello is a header, not a moment. On a recording of a session already in
  progress it is older than everything else in the file.
- The snapshots a recorder seeds from can be ten minutes old. They are stamped
  when they were written, with the measurement's own clock kept as `wasAt` —
  driven against the live lab, a seven-second recording claimed forty-two
  before this.

`hud/scene.js` now owns `refs()` and `HUD_IDS`, because two pages create boards
and eighteen querySelector calls copied into the second is how one quietly
loses a readout. A check asserts every id is in both pages.

`wire.js` holds the two lists both feed.js and replay.js read — what the wire
carries, and which of those are snapshots rather than moments. A stale second
copy of the latter is a player drawing yesterday's board after every scrub.

The player needs an HTTP origin and not a lab: `python3 -m http.server` over
`lab/control/ui` serves it with Docker not installed. It cannot be a
double-click, because `<script type="module">` is blocked on `file:` and the
board is a module.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant