flybrain v0.4.0: public tree (history retained privately)
Some checks failed
ci / node 22 (test + typecheck) (push) Has been cancelled
ci / rust stable (cargo test --workspace --release) (push) Has been cancelled
ci / infra/tests/lint.sh (push) Has been cancelled
ci / playwright apps/stage (allowed to fail) (push) Has been cancelled

This commit is contained in:
acamilo 2026-09-21 15:09:46 +00:00
commit 660c3cf00d
603 changed files with 138182 additions and 0 deletions

201
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,201 @@
# flybrain CI (Forgejo Actions). Identical in substance to .github/workflows/ci.yml.
#
# Forgejo runner notes (the only differences from the GitHub copy: this block, and one `uses:`):
# * `runs-on: ubuntu-latest` needs a runner registered with that label (the usual
# `ubuntu-latest:docker://...node:22-bookworm` mapping). A bare node image has no rustup and
# no shellcheck, which is why the Rust job installs its own toolchain and the lint job
# tolerates a failed install.
# * `actions/checkout`, `actions/setup-node` and `actions/cache` resolve through
# code.forgejo.org, so no github.com access is needed for `uses:`.
# * `cache: npm` and `actions/cache` need the runner's cache server enabled; without it these
# jobs still pass, just slower.
#
# Four jobs, in the order `make ci` / `npm run ci` runs them locally:
# node npm ci + connectome checksum verify + npm test + npm run typecheck
# rust cargo test --workspace --release (services/flysim)
# infra-lint infra/tests/lint.sh
# stage-e2e Playwright against apps/stage — allowed to fail, see the job comment
#
# What this workflow deliberately does NOT need:
#
# * No ROM. Game Boy cartridges never enter the repo (`.gitignore` excludes `*.gb`), and every
# test that needs one is gated on `FLY_ROM`. `FLY_ROM` is left unset here, so those tests
# return early instead of failing: `crates/flybrain-gb/tests/rom.rs` goes through
# `skip_without_rom!` (prints "skipped: FLY_ROM is not set"), and
# `crates/flysim/tests/integration.rs` uses `let Some(rom) = rom_path() else { eprintln!(...);
# return; }`. Both are ordinary passes with a skip line on stderr.
# * No GPU. The `cuda` feature of `flybrain-core`/`flysim` is off by default and `cudarc` is
# built with `dynamic-loading`, so a default build never links or opens libcuda.
# * No secrets. Nothing here reads a token, a stream key or `pass`. `infra/06-secrets.sh` is
# linted, never executed.
# * No network fetch of data. The FlyWire artifacts in `data/fafb-v783` are committed (11 MB
# total, largest `targets.binz` at 7.1 MB) and CC BY-NC 4.0 — see
# `data/fafb-v783/ATTRIBUTION.md`. Only the multi-GB *raw Codex exports* that
# `tools/build_flywire.py` reads are gitignored (`.tools/`), and nothing in CI rebuilds them.
# The `node` job verifies the committed copies against `tools/artifact-checksums.txt`.
name: ci
on:
push:
branches: ['**']
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
node:
name: node 22 (test + typecheck)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
# Keys off package-lock.json; this is the npm cache.
cache: npm
- name: Install workspace
run: npm ci
# The connectome is in git, so this is a verification and not a download. If it ever
# moves out of git, replace this step with `uv run python3 tools/build_flywire.py`
# (several GB of Codex exports) and gate `packages/brain/tests/dataset.test.ts` on the
# artifacts being present instead.
- name: Verify committed FlyWire artifacts
run: sha256sum -c ../../tools/artifact-checksums.txt
working-directory: data/fafb-v783
- name: npm test
run: npm test
- name: npm run typecheck
run: npm run typecheck
rust:
name: rust stable (cargo test --workspace --release)
runs-on: ubuntu-latest
defaults:
run:
working-directory: services/flysim
steps:
- uses: actions/checkout@v4
# No third-party toolchain action, so this file works unchanged on a Forgejo runner that
# cannot reach github.com for `uses:`. `--profile minimal` skips docs/clippy we do not run.
- name: Install stable Rust
working-directory: .
run: |
if command -v rustup >/dev/null 2>&1; then
rustup toolchain install stable --profile minimal
rustup default stable
else
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --default-toolchain stable --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
fi
rustc --version
cargo --version
- name: Cache cargo registry and target
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
services/flysim/target
key: cargo-${{ runner.os }}-release-${{ hashFiles('services/flysim/Cargo.lock') }}
restore-keys: |
cargo-${{ runner.os }}-release-
# `--release`, not the default debug profile, and that is load-bearing:
# `flysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed`
# asserts the feed's 30 Hz contract (it accepts 25.0..31.0 Hz), and a debug build of the sim
# loop only reaches ~9.5-10.4 Hz, so it fails on any box in debug. That is a known,
# pre-existing debug-profile artefact recorded throughout `infra/docs/macros-traps.md`, not
# a regression. Two independent reasons it cannot bite here: the release profile, and the
# fact that the test is also `FLY_ROM`-gated and therefore skips on this runner.
# If you ever need a debug run, exclude exactly that one test instead:
# cargo test --workspace -- --skip the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed
# `[profile.release] debug = 1` in services/flysim/Cargo.toml keeps line tables, and the
# release profile is not allowed to reassociate floats, so bit-exactness with the
# TypeScript oracle still holds — the golden tests are meaningful in release.
- name: cargo test --workspace --release
run: cargo test --workspace --release
infra-lint:
name: infra/tests/lint.sh
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# lint.sh falls back to `bash -n` without shellcheck and skips the unit-file pass without
# systemd-analyze, so this is best-effort: install shellcheck if the image lacks it, but
# never fail the job over the installer. `sudo` is absent on some container runners.
- name: Install shellcheck (best effort)
run: |
if command -v shellcheck >/dev/null 2>&1; then
shellcheck --version
exit 0
fi
SUDO=''
if [ "$(id -u)" -ne 0 ]; then SUDO='sudo'; fi
$SUDO apt-get update && $SUDO apt-get install -y shellcheck || \
echo 'shellcheck unavailable; lint.sh will fall back to bash -n'
- name: infra/tests/lint.sh
run: infra/tests/lint.sh
stage-e2e:
# ALLOWED TO FAIL, on purpose. `apps/stage/playwright.config.ts` runs the real `vite build`
# output at 1920x1080 DPR 1 and compares screenshots at maxDiffPixelRatio 0.002. Two things
# make that flaky on a hosted runner and neither is a product bug:
# * fonts — a runner's fontconfig is not the broadcast host's, so glyph rasterisation
# differs by more than 0.2% of the frame even with `--disable-lcd-text`;
# * timing — the suite drives a 30 Hz feed with a 90 s per-test timeout and a single
# worker, and the shared-runner CPU budget is not the capture host's pinned cpuset.
# So this job reports and uploads its HTML report, but `continue-on-error` keeps a red
# screenshot diff from blocking a merge. the operator reviews stage frames as PNGs
# (`apps/stage/mockups/`), which is the real gate for how the page looks.
name: playwright apps/stage (allowed to fail)
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- name: Install workspace
run: npm ci
# Chromium only: the config declares exactly one `chromium` project.
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
working-directory: apps/stage
- name: playwright test
run: npm run test:e2e --workspace @flybrain/stage
env:
CI: '1'
# forgejo/upload-artifact, not actions/upload-artifact: a Forgejo runner does not speak
# the v4 GitHub artifact API. This is the only non-comment line that differs.
- uses: forgejo/upload-artifact@v4
if: always()
with:
name: playwright-report
path: |
apps/stage/playwright-report
apps/stage/test-results
retention-days: 7
if-no-files-found: ignore

189
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,189 @@
# flybrain CI.
#
# Four jobs, in the order `make ci` / `npm run ci` runs them locally:
# node npm ci + connectome checksum verify + npm test + npm run typecheck
# rust cargo test --workspace --release (services/flysim)
# infra-lint infra/tests/lint.sh
# stage-e2e Playwright against apps/stage — allowed to fail, see the job comment
#
# What this workflow deliberately does NOT need:
#
# * No ROM. Game Boy cartridges never enter the repo (`.gitignore` excludes `*.gb`), and every
# test that needs one is gated on `FLY_ROM`. `FLY_ROM` is left unset here, so those tests
# return early instead of failing: `crates/flybrain-gb/tests/rom.rs` goes through
# `skip_without_rom!` (prints "skipped: FLY_ROM is not set"), and
# `crates/flysim/tests/integration.rs` uses `let Some(rom) = rom_path() else { eprintln!(...);
# return; }`. Both are ordinary passes with a skip line on stderr.
# * No GPU. The `cuda` feature of `flybrain-core`/`flysim` is off by default and `cudarc` is
# built with `dynamic-loading`, so a default build never links or opens libcuda.
# * No secrets. Nothing here reads a token, a stream key or `pass`. `infra/06-secrets.sh` is
# linted, never executed.
# * No network fetch of data. The FlyWire artifacts in `data/fafb-v783` are committed (11 MB
# total, largest `targets.binz` at 7.1 MB) and CC BY-NC 4.0 — see
# `data/fafb-v783/ATTRIBUTION.md`. Only the multi-GB *raw Codex exports* that
# `tools/build_flywire.py` reads are gitignored (`.tools/`), and nothing in CI rebuilds them.
# The `node` job verifies the committed copies against `tools/artifact-checksums.txt`.
name: ci
on:
push:
branches: ['**']
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
node:
name: node 22 (test + typecheck)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
# Keys off package-lock.json; this is the npm cache.
cache: npm
- name: Install workspace
run: npm ci
# The connectome is in git, so this is a verification and not a download. If it ever
# moves out of git, replace this step with `uv run python3 tools/build_flywire.py`
# (several GB of Codex exports) and gate `packages/brain/tests/dataset.test.ts` on the
# artifacts being present instead.
- name: Verify committed FlyWire artifacts
run: sha256sum -c ../../tools/artifact-checksums.txt
working-directory: data/fafb-v783
- name: npm test
run: npm test
- name: npm run typecheck
run: npm run typecheck
rust:
name: rust stable (cargo test --workspace --release)
runs-on: ubuntu-latest
defaults:
run:
working-directory: services/flysim
steps:
- uses: actions/checkout@v4
# No third-party toolchain action, so this file works unchanged on a Forgejo runner that
# cannot reach github.com for `uses:`. `--profile minimal` skips docs/clippy we do not run.
- name: Install stable Rust
working-directory: .
run: |
if command -v rustup >/dev/null 2>&1; then
rustup toolchain install stable --profile minimal
rustup default stable
else
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --default-toolchain stable --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
fi
rustc --version
cargo --version
- name: Cache cargo registry and target
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
services/flysim/target
key: cargo-${{ runner.os }}-release-${{ hashFiles('services/flysim/Cargo.lock') }}
restore-keys: |
cargo-${{ runner.os }}-release-
# `--release`, not the default debug profile, and that is load-bearing:
# `flysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed`
# asserts the feed's 30 Hz contract (it accepts 25.0..31.0 Hz), and a debug build of the sim
# loop only reaches ~9.5-10.4 Hz, so it fails on any box in debug. That is a known,
# pre-existing debug-profile artefact recorded throughout `infra/docs/macros-traps.md`, not
# a regression. Two independent reasons it cannot bite here: the release profile, and the
# fact that the test is also `FLY_ROM`-gated and therefore skips on this runner.
# If you ever need a debug run, exclude exactly that one test instead:
# cargo test --workspace -- --skip the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed
# `[profile.release] debug = 1` in services/flysim/Cargo.toml keeps line tables, and the
# release profile is not allowed to reassociate floats, so bit-exactness with the
# TypeScript oracle still holds — the golden tests are meaningful in release.
- name: cargo test --workspace --release
run: cargo test --workspace --release
infra-lint:
name: infra/tests/lint.sh
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# lint.sh falls back to `bash -n` without shellcheck and skips the unit-file pass without
# systemd-analyze, so this is best-effort: install shellcheck if the image lacks it, but
# never fail the job over the installer. `sudo` is absent on some container runners.
- name: Install shellcheck (best effort)
run: |
if command -v shellcheck >/dev/null 2>&1; then
shellcheck --version
exit 0
fi
SUDO=''
if [ "$(id -u)" -ne 0 ]; then SUDO='sudo'; fi
$SUDO apt-get update && $SUDO apt-get install -y shellcheck || \
echo 'shellcheck unavailable; lint.sh will fall back to bash -n'
- name: infra/tests/lint.sh
run: infra/tests/lint.sh
stage-e2e:
# ALLOWED TO FAIL, on purpose. `apps/stage/playwright.config.ts` runs the real `vite build`
# output at 1920x1080 DPR 1 and compares screenshots at maxDiffPixelRatio 0.002. Two things
# make that flaky on a hosted runner and neither is a product bug:
# * fonts — a runner's fontconfig is not the broadcast host's, so glyph rasterisation
# differs by more than 0.2% of the frame even with `--disable-lcd-text`;
# * timing — the suite drives a 30 Hz feed with a 90 s per-test timeout and a single
# worker, and the shared-runner CPU budget is not the capture host's pinned cpuset.
# So this job reports and uploads its HTML report, but `continue-on-error` keeps a red
# screenshot diff from blocking a merge. the operator reviews stage frames as PNGs
# (`apps/stage/mockups/`), which is the real gate for how the page looks.
name: playwright apps/stage (allowed to fail)
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- name: Install workspace
run: npm ci
# Chromium only: the config declares exactly one `chromium` project.
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
working-directory: apps/stage
- name: playwright test
run: npm run test:e2e --workspace @flybrain/stage
env:
CI: '1'
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: |
apps/stage/playwright-report
apps/stage/test-results
retention-days: 7
if-no-files-found: ignore

40
.gitignore vendored Normal file
View file

@ -0,0 +1,40 @@
node_modules/
dist/
coverage/
test-results/
*.tsbuildinfo
*.log
.env
.env.*
!.env.example
# Real infra env files never enter this repo: they name the host, the container
# id, the LAN address, the channel and the `pass` entry. infra/env/example.env is
# the only env file here; see infra/env/README.md.
infra/env/*.env
!infra/env/example.env
.DS_Store
__pycache__/
*.pyc
.venv/
# Raw FlyWire Codex downloads (fetched by tools/build_flywire.py)
.tools/
# Rust build output
target/
# Game cartridges and saves never enter this repo
*.gb
*.gbc
*.rom
*.sav
*.state
local/
# Rust build output
target/
# Playwright
/apps/stage/playwright-report/
/apps/stage/test-results/
# Legibility downscale artifacts are regenerated by the e2e suite on every run
/apps/stage/tests/e2e/artifacts/
# Agent worktrees (never committed)
.claude/worktrees/
.local/

37
CLAUDE.md Normal file
View file

@ -0,0 +1,37 @@
# flybrain
A simulated fruit-fly brain (FlyWire connectome, 139,255 neurons) that plays Game Boy games on a
24/7 stream. Monorepo: `packages/brain` (TypeScript reference core), `packages/feed` (contracts,
codec, fake sim), `services/flysim` (Rust service: brain + emulator + adapters), `services/bridge`
(Twitch), `apps/stage` (broadcast page), `infra/` (LXC provisioning and units), `docs/`.
Read `docs/architecture-tour.md` first. Then `docs/stream-mvp-plan.md` for decisions and status.
## Binding contracts
- `docs/feed-protocol.md` and `docs/control-api.md`. Where a design doc differs, the contracts win.
- `packages/brain` is the oracle. Never change its semantics to match another implementation; fix
the other side. Default-config version strings `lif-1ms-f64-v2` and `fly-kc-mbon-rstdp-v2` stay.
## Hard rules
- Never go live on Twitch without the operator's explicit approval for that run. `flypush.service` stays
disabled; local MediaMTX demos are fine. Stream keys and tokens live in `pass`, never in git.
- No AI attribution lines in commit messages.
- ROMs are never committed, copied into the repo, shown on stream, or linked.
- Fable (the coordinator) plans, writes contracts and reviews; opus and sonnet agents build and
test, each on its own feature branch in a worktree, merged with `--no-ff`. Run `npm test`,
`npm run typecheck`, `cargo test --workspace` and `infra/tests/lint.sh` before merging.
- The operator reviews screens as PNGs (`apps/stage/mockups/`), never as prose. On-screen copy is terse.
- Work on the deployment host is serialised: **one agent at a time**. Claim the container before
touching it and release it when you are done, by appending a dated line to the host's agent
claim log — the file the operator's `AGENT_CLAIM_LOG` names (`infra/env/example.env`,
`infra/README.md`). No claim, no host work. Never touch a guest this repo did not provision;
other services share the host.
- This repo is public. Nothing that identifies the operator's network goes in it: no hostnames,
LAN addresses, container ids, host paths, account ids, channel names, people's names, `pass`
entry names or forge URLs. Say "the host", "the release container", "the dev container", "the
channel", "the operator"; put the real values in the operator's infra repo. Real values belong
in an env file outside the checkout — see `infra/env/README.md`. `infra/tests/lint.sh` refuses
the patterns; the rules live in the operator's infra repo and
`infra/tests/de-pii-allow.txt` the few legitimate mentions.

175
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,175 @@
# Contributing
A simulated fly brain that plays Game Boy games on a 24/7 stream. Start with
`docs/architecture-tour.md`: it walks every layer once, says why each is built the way it is, and
points at the code. Then `docs/stream-mvp-plan.md` for what has been decided and what state it is in.
There is no game in this repository. `ROM-POLICY.md` explains what that means and how to run
everything without one — which is almost all of it.
## Branches and worktrees
Work happens on a branch, in its own git worktree, never on `main` directly.
```sh
git worktree add -b feat/my-change .claude/worktrees/feat-my-change main
cd .claude/worktrees/feat-my-change
```
`.claude/worktrees/` is gitignored, so the checkouts never enter history. One branch per piece of
work, named for what it does (`feat/`, `fix/`, `docs/`, `chore/`, `publish/`). Several can be in
flight at once because each has its own tree; that is the point of using worktrees rather than
switching branches in place.
Merge into `main` with `--no-ff`, so a branch stays visible as a unit in the history:
```sh
git merge --no-ff feat/my-change
git worktree remove .claude/worktrees/feat-my-change
```
Never rewrite published history on `main`.
## The suites, before every merge
All four, green, on the branch. Not a subset.
```sh
npm ci
npm test # TypeScript: brain, feed, stage units, bridge
npm run typecheck # tsc across every workspace
cd services/flysim && cargo test --workspace # Rust: core, gb, service; ROM-gated tests skip
bash infra/tests/lint.sh # shellcheck + systemd-analyze over infra/
```
Plus the stage's browser suite when anything visual changed:
```sh
cd apps/stage && npm run test:e2e # Playwright: screenshot baselines per fixture and tab,
# text-size lint, phone-downscale legibility, structure
npm run mockups # regenerate the review PNGs
```
Notes that save time:
- The ROM-gated Rust tests skip with a printed line when `FLY_ROM` is unset. That is a normal green
run, not a hole. See `ROM-POLICY.md`.
- The dataset tests read `data/fafb-v783`, which is committed; they need no download.
- The Rust golden tests compare against `services/flysim/golden/*.flygold` at 0 ulp. If one fails
after a change to the Rust side, the Rust side is wrong — see the oracle rule below. Goldens are
regenerated from the TypeScript with `packages/brain/tools/golden.ts`, never edited by hand.
- Screens are reviewed as PNGs under `apps/stage/mockups/`, not as prose descriptions. On-screen
copy is terse.
## Commit messages
Plain and factual. A short imperative subject with a scope prefix, a body when the change needs one,
and nothing else.
```
feat(decoder): add blocked-direction cooldown
Raises the habituation of a channel the sim loop has watched produce no
movement for a whole hold. Does not name a position or an alternative.
```
**No attribution trailers of any kind.** No `Co-Authored-By`, no generated-with lines, no tool or
model credits, no session links. This applies to every commit, including ones written by an agent.
No secrets in a commit, ever — no stream keys, tokens, passwords or credentials. Nothing from a
cartridge. Check the diff before committing, not after.
## Licences and file headers
The repository is not under a single licence. `LICENSES.md` at the root says which paths fall under
which terms; the short version is Apache-2.0 for the code, CC BY 4.0 for the documentation,
on-screen copy and stage assets, and CC BY-NC 4.0 — unchanged and not ours to relicense — for the
FlyWire connectome artifacts in `data/fafb-v783/`.
- **No per-file licence headers are required.** Do not add them to new files, and do not add them to
existing ones. `LICENSE`, `LICENSES.md` and `NOTICE` at the root carry the terms for the whole
tree; a header on every file is noise that then has to be kept accurate.
- **`NOTICE` carries the attributions.** If you add third-party material — a vendored source, a
font, a dataset, anything you did not write — put its attribution in `NOTICE` and its row in
`LICENSES.md` in the same branch as the material, with its own licence text alongside it in the
tree. Material whose upstream licence you cannot establish does not go in; if it does go in,
`NOTICE` says so in plain words rather than guessing.
- **Contributions are accepted under Apache-2.0.** By submitting a change you license it under the
Apache License 2.0, per section 5 of that licence. There is no separate CLA and none is planned.
- **Anything derived from `data/fafb-v783/` is CC BY-NC 4.0, like the data.**
`services/flysim/golden/real.flygold` is the one such artifact in the tree today. If a change adds
another — a recorded run, a prerendered image, a checked-in trace — say so in the commit and add
it to section 3 of `LICENSES.md`. The four `.flyfeed` fixtures are generated by the fake simulator
and are deliberately not in that category; keep it that way.
## The two binding contracts
- `docs/feed-protocol.md` — the WebSocket snapshot format the service publishes and the page reads.
- `docs/control-api.md` — the loopback HTTP API the bridge calls.
These are binding. Where any other document, design note or comment disagrees with them, **the
contracts win** and the other document is the thing that is wrong. Changing a contract is its own
change, made deliberately, with both sides of it updated in the same branch and the reason written
down. Both have a Rust implementation and a TypeScript implementation and a shared fixture that
each must pass; a change that only satisfies one side is not done.
One property of the control API is load-bearing: **there is no button endpoint.** Nothing outside
the simulation can press a button, and a test asserts the route table contains no such route. Do not
add one.
## The oracle rule
**`packages/brain` is the reference implementation.** Its semantics define what the neural core does:
the LIF kernel, plasticity, the decoder, the agent loop, the checkpoint envelope.
When the Rust port, the service, the page or anything else disagrees with it, **fix the other side.**
Never adjust `packages/brain` to make another implementation's output match — that converts a bug
into the specification. The verbatim prototype modules under `packages/brain/tests/legacy/` and the
bit-exact oracle tests against them exist to make this rule enforceable, and so do the Rust goldens.
The default-config version strings `lif-1ms-f64-v2` and `fly-kc-mbon-rstdp-v2` stay as they are.
Checkpoints are keyed on them; changing one invalidates saved state on the release box.
If the reference itself is genuinely wrong, that is a deliberate change to the reference with a new
version string, regenerated goldens, and the reasoning recorded — not a quiet edit.
## The honesty rule
Nothing on the stream is scripted. This is not a style preference; it is the point of the project,
and it constrains contributions.
- **The buttons are always the fly's.** Nothing outside the simulation chooses, biases, defaults or
times a button press. There is no fallback press, no scripted objective, no nudge on a timeout.
A scene the fly ignores waits.
- **Rewards are read out of memory after the fact.** A reward rule may observe what the game state
became and stimulate the modulatory pathway. It may not tell the fly where to go or what to press.
Every value is positive by design; there are no penalties.
- **The readout is fixed, not learned**, and it knows nothing about maps, doors or goals.
- **Failure is shown.** Stalls, rollbacks, unsupported cartridges and lag appear on screen with the
reason. They are not hidden and not smoothed over.
- **What is real and what is scaffolding is documented.** `docs/limitations.md` is the honest list —
the reward modulator is synthetic, the retina is not fly optics, learning has not been shown to
improve play. `docs/rewards-learning.md` has its own "Honesty" section stating exactly what a
reward can and cannot move. If your change alters where that line falls, update those pages in the
same branch and say so plainly. If a claim has not been measured, write that it has not been
measured.
A change that makes the demo look better by making it less true will be rejected, however small.
## Where the documents are
- `docs/architecture-tour.md` — read first; the whole system, layer by layer, with the reasons.
- `docs/feed-protocol.md`, `docs/control-api.md` — the binding contracts.
- `docs/stream-mvp-plan.md` — decisions and current status.
- `docs/model.md`, `docs/plasticity.md`, `docs/readout.md`, `docs/rewards-learning.md`,
`docs/dataset-format.md`, `docs/verification.md` — the neural core, what it learns, how it decides,
what it is paid for, the data layout, and how all of it is checked.
- `docs/limitations.md` — what this is not.
- `docs/design/` — per-feature design notes (`flysim.md`, `stage-bridge.md`, `fly-avatar.md`,
`animation.md`, `ladder.md`, `macros.md`, `platformer.md`, `room-escape.md`, `gpu.md`, `infra.md`,
and others). Designs, not contracts: where one differs from a contract, the contract wins.
- `infra/docs/` — the runbook, provisioning records, measurements and spike write-ups for the
machines that actually run it.
- `docs/publish/` — notes prepared for publication, including how the licence decision was reached.
- `LICENSE`, `LICENSES.md`, `NOTICE` — the terms, the path-by-path split, and the attributions.
- `ROM-POLICY.md` — no game here, and how to run everything anyway.

202
LICENSE Normal file
View file

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

105
LICENSES.md Normal file
View file

@ -0,0 +1,105 @@
# Licences
This repository is not under a single licence. Three sets of terms apply to three kinds of thing,
and some third-party material inside it is under neither because it was never ours to license.
Read this file before reusing anything. `LICENSE` at the root is the Apache-2.0 text; `NOTICE`
carries the attributions those terms require.
Decided 2026-09-17 by the copyright holder (see NOTICE), who holds the copyright in the original work.
## 1. Code — Apache License 2.0
Copyright 2026 Alex Camilo. Full text in `LICENSE`, with the required attributions in `NOTICE`.
| Path | What |
| --- | --- |
| `packages/brain/`, `packages/feed/` | the reference neural core, contracts, codec, fake sim |
| `apps/stage/src/`, `apps/stage/tools/`, `apps/stage/tests/` | the broadcast page |
| `services/flysim/crates/` | the Rust service, emulator wrapper, reward adapters |
| `services/bridge/src/`, `services/bridge/tests/` | the Twitch bridge |
| `infra/` | provisioning scripts, systemd units, host tooling, test suites |
| `tools/` | the dataset builder and repository tooling |
| build and config files at any level | `package.json`, `Cargo.toml`, `tsconfig.json`, and friends |
You may use, modify and redistribute this code, commercially included, under the Apache-2.0 terms:
keep the licence and copyright notices, pass on the `NOTICE` content, and state the files you
changed. The licence includes an express patent grant from contributors and grants no trademark
rights.
Excluded from this grant, because they are third-party material — see section 4:
`services/flysim/vendor/binjgb/`, `apps/stage/public/fonts/*.woff2`, and the extracted symbol
names and addresses in `services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs`.
## 2. Documentation, on-screen copy and stage assets — CC BY 4.0
Copyright 2026 Alex Camilo, licensed under
[Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/).
| Path | What |
| --- | --- |
| `docs/` | the architecture tour, the binding contracts, the model and learning pages, the design notes, the limitations list |
| `infra/docs/` | runbooks, provisioning records, measurements, spike write-ups |
| `README.md`, `CONTRIBUTING.md`, `ROM-POLICY.md`, this file | root documents |
| `apps/stage/mockups/` | the review PNGs |
| `apps/stage/public/fixtures/` | the four recorded `.flyfeed` feed fixtures |
| on-screen copy | the caption bands, chip labels, panel headings and ticker strings the broadcast page displays |
You may share and adapt these, commercially included, with attribution and an indication of any
changes.
Two things worth stating so nobody has to guess:
- **On-screen copy is text, but it lives inside source files.** The strings the page displays are
CC BY 4.0 as text; the `.tsx` and `.rs` files that contain them are Apache-2.0 as code. The same
copyright holder grants both, so a reuser may rely on whichever fits what they are taking.
- **The feed fixtures are synthetic.** All four `.flyfeed` recordings in
`apps/stage/public/fixtures/` are generated by the fake simulator in `packages/feed/src/fake/`,
not recorded from a run on the connectome. They carry no FlyWire-derived content and so are not
bound by section 3.
## 3. The connectome artifacts — CC BY-NC 4.0, unchanged
`data/fafb-v783/` — all twelve files.
These are **not ours to relicense.** They are artifacts derived from the FlyWire FAFB public Codex
v783 exports, used under
[CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/), and they keep those terms for
everyone downstream. Nothing in sections 1 or 2 loosens them.
**The authoritative statement of source, modifications and required citations is
[`data/fafb-v783/ATTRIBUTION.md`](data/fafb-v783/ATTRIBUTION.md).** It must travel with any copy of
these files. `NOTICE` reproduces it in summary; that file is the original.
What the licence asks of you if you redistribute them or an adaptation of them:
- keep the creator and attribution-party names, the copyright notice, the licence notice and the
disclaimer notice;
- link to the material and to the licence;
- say that the material is modified, and keep the indication of previous modifications — these
artifacts are already an adaptation, described in `ATTRIBUTION.md`;
- do not use them for commercial advantage or monetary compensation;
- do not add legal terms or technological measures that stop anyone else doing what the licence
permits;
- do not imply endorsement by FlyWire or the cited authors.
The data and anything derived from it stay under CC BY-NC 4.0.
One derived artifact inside the repository inherits these terms: `services/flysim/golden/real.flygold`
is a recorded run on this dataset. The other golden files are synthetic and are Apache-2.0 with the
code.
## 4. Third-party material, under its own terms
| What | Where | Terms |
| --- | --- | --- |
| binjgb emulator core, vendored unmodified at `c60e138` | `services/flysim/vendor/binjgb/` | MIT, © 2016 Ben Smith — `LICENSE`, `PROVENANCE.md` there |
| Press Start 2P, Pixelify Sans, Silkscreen, VT323 | `apps/stage/public/fonts/` | SIL OFL 1.1, each with its `OFL-*.txt` alongside |
| pret/pokered RAM symbol names and addresses at `0cd19d3` | `.../pokemon_red/symbols.rs` | **no upstream licence statement.** Names and numeric addresses only; no assembly source, game code or game asset |
| npm and cargo dependencies | not redistributed here | their own; `lightningcss` is MPL-2.0 and `caniuse-lite` is CC-BY-4.0, both build-time |
## 5. No game ROM
No Game Boy ROM, ROM fragment, save file, emulator save state or ripped game asset is in this
repository, and none is distributed with it. See [`ROM-POLICY.md`](ROM-POLICY.md). "Pokémon" and
"Super Mario Land" are trademarks of Nintendo, used here descriptively to say which adapter reads
which game; no affiliation or endorsement is claimed, and no licence granted here extends to any
third party's trademarks.

60
Makefile Normal file
View file

@ -0,0 +1,60 @@
# The same gates CI runs, in the same order, on this box.
#
# make ci install -> artifacts -> test -> typecheck -> rust -> lint
# make e2e the Playwright suite (allowed to fail in CI; run it by hand here)
# make all ci + e2e
#
# `npm run ci` is the same chain minus `npm ci` itself (an npm script cannot safely wipe the
# node_modules it is running out of), so `make install && npm run ci` == `make ci`.
#
# Nothing here needs a ROM, a GPU or a secret. FLY_ROM stays unset, so the ROM-gated Rust tests
# skip with a note on stderr; set it yourself for a full local run:
# FLY_ROM="$$HOME/fly-plays-pokemon/Pokemon Red (U) [S][BF].gb" make rust
SHELL := /usr/bin/env bash
.SHELLFLAGS := -euo pipefail -c
.DEFAULT_GOAL := ci
.PHONY: ci all install artifacts test typecheck rust lint e2e browsers clean
ci: install artifacts test typecheck rust lint
all: ci e2e
install:
npm ci
# The FlyWire connectome is committed (11 MB, CC BY-NC 4.0 — data/fafb-v783/ATTRIBUTION.md), so
# this verifies rather than downloads. Only the raw multi-GB Codex exports are fetched, and only
# by `uv run python3 tools/build_flywire.py`, which CI never runs.
artifacts:
cd data/fafb-v783 && sha256sum -c ../../tools/artifact-checksums.txt
test:
npm test
typecheck:
npm run typecheck
# --release, not debug: the flysim integration test that asserts the feed's 30 Hz contract only
# reaches ~9.5-10.4 Hz in a debug build (known, pre-existing — infra/docs/macros-traps.md). For a
# debug run, skip exactly that test:
# cd services/flysim && cargo test --workspace -- \
# --skip the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed
rust:
cd services/flysim && cargo test --workspace --release
lint:
infra/tests/lint.sh
browsers:
cd apps/stage && npx playwright install --with-deps chromium
# Screenshot comparisons at maxDiffPixelRatio 0.002 against the real `vite build` output. Font
# rasterisation and timing make this flaky on a shared runner, which is why CI marks the job
# allowed-to-fail; locally it is a real gate.
e2e:
npm run test:e2e --workspace @flybrain/stage
clean:
rm -rf apps/stage/playwright-report apps/stage/test-results services/flysim/target

93
NOTICE Normal file
View file

@ -0,0 +1,93 @@
flybrain
Copyright 2026 Alex Camilo
This product includes software developed as part of the flybrain project,
licensed under the Apache License, Version 2.0 (see LICENSE).
Portions of this product are licensed separately. LICENSES.md records which
paths fall under which terms; the attributions those terms require are below.
--------------------------------------------------------------------------------
FlyWire FAFB Codex v783 connectome artifacts (data/fafb-v783/)
--------------------------------------------------------------------------------
This product includes artifacts derived from the FlyWire FAFB public Codex v783
exports, used under the Creative Commons Attribution-NonCommercial 4.0
International licence (CC BY-NC 4.0).
Source: https://codex.flywire.ai/
Licence: https://creativecommons.org/licenses/by-nc/4.0/
The artifacts are modified: connectivity is aggregated by directed neuron pair,
assigned stable numeric indices, encoded as typed sparse arrays, and joined with
classification, representative-coordinate, cell-type and optic-lobe column
annotations. Functional role predicates and neural-model signs are project
modelling choices. The full statement of source, modifications and required
citations is data/fafb-v783/ATTRIBUTION.md, which accompanies these artifacts
and must travel with any copy of them.
Dorkenwald et al., "Neuronal wiring diagram of an adult brain,"
Nature 634 (2024), https://doi.org/10.1038/s41586-024-07558-y
Schlegel et al., "Whole-brain annotation and multi-connectome cell typing,"
Nature 634 (2024), https://doi.org/10.1038/s41586-024-07686-5
Matsliah et al., "Neuronal parts list and wiring diagram for a visual system,"
Nature 634 (2024), https://doi.org/10.1038/s41586-024-07981-1
No endorsement by FlyWire or the cited authors is implied. These artifacts, and
anything derived from them, are under CC BY-NC 4.0; see LICENSES.md.
--------------------------------------------------------------------------------
binjgb (services/flysim/vendor/binjgb/)
--------------------------------------------------------------------------------
This product includes the binjgb Game Boy emulator core, used under the MIT
licence and vendored unmodified at revision
c60e138da5a795ebb55e56b11b7e90024e41112c.
Copyright (c) 2016 Ben Smith
https://github.com/binji/binjgb
The MIT licence text is services/flysim/vendor/binjgb/LICENSE and the vendoring
is described in services/flysim/vendor/binjgb/PROVENANCE.md.
--------------------------------------------------------------------------------
pret/pokered symbol names and addresses
--------------------------------------------------------------------------------
services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs reproduces RAM
symbol names and numeric addresses extracted from the pret/pokered
disassembly at revision 0cd19d3b877b7dc66d12c7050bed9a7f38154d4b.
https://github.com/pret/pokered
That upstream project carries no licence statement of its own, which is stated
here rather than assumed. Only names and addresses are reproduced: no assembly
source, no game code and no game asset from the disassembly is present in this
product, and no game ROM is distributed with it (see ROM-POLICY.md).
--------------------------------------------------------------------------------
Fonts (apps/stage/public/fonts/)
--------------------------------------------------------------------------------
This product includes four typefaces used under the SIL Open Font License 1.1,
each accompanied by its own licence text in the same directory:
Press Start 2P OFL-PressStart2P.txt
Pixelify Sans OFL-PixelifySans.txt
Silkscreen OFL-Silkscreen.txt
VT323 OFL-VT323.txt
The font files are unmodified subsets. They are not relicensed by this product.
--------------------------------------------------------------------------------
Build and runtime dependencies
--------------------------------------------------------------------------------
Dependencies resolved by npm and cargo are not redistributed in this source
repository and keep their own licences. Two npm dependencies carry terms worth
naming because they are not notice-only permissive:
lightningcss (and its linux-x64 binaries) MPL-2.0, build-time, unmodified
caniuse-lite CC-BY-4.0, build-time data

98
README.md Normal file
View file

@ -0,0 +1,98 @@
# flybrain
A simulated fruit-fly brain that plays video games. A connectome-constrained spiking network reads
the screen, its population rates become controller inputs, and a scalar reward nudges a bounded set
of Kenyon-cell to MBON gains.
The library is `@flybrain/brain` in `packages/brain`. It holds the connectome dataset format, the
LIF kernel, the plasticity rule, the population-rate readout and the activity-map geometry. It
holds no game, no emulator and no reward rules: those belong to whatever embeds it.
## Workspace layout
| Path | Contents |
| --- | --- |
| `packages/brain` | the library (`@flybrain/brain`) |
| `data/fafb-v783` | FlyWire-derived browser artifacts (CC BY-NC 4.0) |
| `tools/` | the Python builder that regenerates `data/` from official Codex exports |
| `docs/` | overview, dataset format, model, plasticity, readout, integration, limitations, verification |
| `services/flysim` | the Rust service: brain, emulator, snapshot feed, control API, checkpoints |
| `apps/` | planned: one directory per game demo |
| `infra/` | planned: deployment for the 24/7 stream (see `docs/streaming-plan.md`) |
## Quick start
```sh
npm ci
npm test
npm run typecheck
```
76 tests, about 7 seconds. There is no build step. `npx tsx packages/brain/examples/node-random-frames.ts 60`
runs the full 139,255-neuron brain with the Game Boy readout on noise frames in plain Node
(about 0.9x Game Boy real time single-threaded on a WSL laptop).
## Usage
```ts
import { NeuralAgent, gameboyDecoderConfig, toButtonMask } from '@flybrain/brain';
import { loadBrainDatasetFromDir } from '@flybrain/brain/node';
const dataset = await loadBrainDatasetFromDir('data/fafb-v783');
const agent = new NeuralAgent(dataset, { decoder: gameboyDecoderConfig() });
agent.warmup(firstFrame); // 2,500 ms with plasticity off, then calibrate
// every emulator frame:
const { active } = agent.tick(framebuffer, { // RGBA 160x144 by default; any size via config
rewards: [{ value: 0.5 }], // scalar rewards your game adapter detected
boot: !inGame, // relaxes Start/Select throttling on title screens
});
emulator.setButtons(toButtonMask(active));
const checkpoint = agent.exportState(); // bit-exact resume, validated on import
```
The lower layers (`LifNetwork`, `RewardModulatedStdp`, `PopulationDecoder`) are exported too for
hosts that want to run the loop themselves.
[integration.md](docs/integration.md) has the full per-frame loop, the fractional frame timing and
the checkpoint contract.
## Documentation
- [Overview](docs/overview.md): the pipeline, the layer map and the design principles.
- [Dataset format](docs/dataset-format.md): artifacts, CSR layout, weight encoding, every role and
its count, fingerprinting, regeneration, license.
- [Model](docs/model.md): the 1-ms LIF kernel step by step, every default constant, the retina
projection, the RNG, state export and version strings.
- [Plasticity](docs/plasticity.md): edge selection, the eligibility and reinforcement equations,
statistics, topology hash and the explicit non-claims.
- [Readout](docs/readout.md): scores, exclusive groups, pulse channels, the blocked-direction
cooldown, the Game Boy preset table and checkpoint versions.
- [Integration](docs/integration.md): what a game must provide, the Pokemon Red integration as a
worked example, and a sketch of a platformer adapter.
- [Limitations](docs/limitations.md): what is not claimed, what is unproven, measured throughput.
- [Verification](docs/verification.md): the oracle-test strategy and what each test file covers.
- [Streaming plan](docs/streaming-plan.md): headless capture, Twitch, VM design and the phased
plan for a 24/7 stream.
- [Artifact builder](tools/README.md): how to regenerate and verify `data/fafb-v783`.
- [Data attribution](data/fafb-v783/ATTRIBUTION.md): source, license and citations.
## Provenance
The network, plasticity rule, readout, FlyWire pipeline and activity viewer were extracted from
the `fly-plays-pokemon` prototype so several game demos can share one core. The default
configuration reproduces that prototype's kernel bit for bit, and verbatim copies of its modules
live in `packages/brain/tests/legacy/` as oracles. Built with Astra.
## Licensing
The `data/fafb-v783` artifacts are derived from the FlyWire FAFB public Codex v783 exports and are
licensed [CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/). That is a
non-commercial license, so a commercial demo needs a different data source or separate permission.
Citations and the list of modifications are in
[`data/fafb-v783/ATTRIBUTION.md`](data/fafb-v783/ATTRIBUTION.md).
No license has been chosen for the code in this repository yet.
ROMs and save states never enter this repository.

115
ROM-POLICY.md Normal file
View file

@ -0,0 +1,115 @@
# ROM policy
**There is no game in this repository, and there never has been.**
This project simulates a fruit-fly brain and lets it press buttons on a Game Boy emulator. To watch
it play a commercial game you need that game's cartridge image, and you have to supply it yourself.
Nothing here will help you find one.
## What that means in practice
- **No ROM is committed.** `.gitignore` excludes `*.gb`, `*.gbc`, `*.rom`, `*.sav` and `*.state`, so
a cartridge image or a save cannot be added by accident.
- **No ROM is copied into the working tree**, not even temporarily, and not into a fixture, a test
resource or a checkpoint. The emulator reads the file from a path outside the repo and nothing
writes it back in.
- **No ROM is linked.** You will not find a download link, a torrent, a "known-good dump" name, a
mirror or a hint about where to look, in the code, the docs, the commit history or the issues.
Requests for one will be closed.
- **No ROM appears on stream.** The game's video is on screen because the fly is playing it; the
file is not offered, served or made downloadable from the broadcast, and the stream page exposes
no path to it.
- **No game assets are vendored.** No sprites, tiles, palettes, music, text or disassembly source.
What the repository does contain, none of which is game content:
- **One SHA-256 digest** of the supported cartridge. A hash is a fingerprint, not the file: it lets
the service refuse to run against something other than the build the reward rules were written
for. Semantic rewards are enabled for exactly that one digest; any other cartridge boots and
plays, pays nothing, and says `UNSUPPORTED ROM . SEMANTIC REWARDS OFF` on screen.
- **Audited RAM addresses and symbol names**, generated from the public pret/pokered disassembly at
a pinned commit, in `services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs`. These are
numbers and names that describe where the running game keeps its progress flags. The disassembly
checkout they came from lives outside this repository.
- **The emulator core**: [binjgb](https://github.com/binji/binjgb), MIT-licensed, vendored as seven
unmodified upstream C files under `services/flysim/vendor/binjgb/` with its licence and a
provenance note.
## How the release box gets a ROM
Not from git. The deployment takes two values from an environment file that lives outside this
repository, on the machine doing the deploy:
- a **path** to the cartridge, which the service opens read-only, and
- a **SHA-256 pin** for that file.
The service hashes the file it opened and compares it against the pin. A mismatch is a startup
failure (`FLY_ROM_SHA256 does not match the cartridge on disk`), not a warning: it will not quietly
play the wrong build. The file is staged into the container as `/srv/fly/rom/<sha256>.gb`, mode
`0400`, owned by the service user, and it stays there — never on the hypervisor, never in a backup
of this repository, never in a log or a journal line.
If the pin is empty, the deploy writes no ROM path at all and the service runs without one.
## Tests that need a cartridge
They are gated on an environment variable and **skip cleanly when it is unset** — they do not fail,
and they do not need to be excluded from a test run. The variables are `FLY_ROM` for the Pokémon Red
adapter and `FLY_ROM_PLATFORMER` for the Super Mario Land one. With neither set, each such test
prints `skipped: FLY_ROM is not set` and returns; the ROM-driven example binaries print instructions
instead of running.
```sh
cd services/flysim && cargo test --workspace # ROM-gated tests skip, everything else runs
FLY_ROM=/path/to/your/cartridge.gb cargo test --workspace # ROM-gated tests run too
```
So `cargo test --workspace` is a complete, honest green run without a cartridge anywhere in sight.
That is the default, and it is what CI-equivalent checks are expected to do.
## Running everything else with no ROM at all
Almost the whole system is exercisable without a game. The emulator is the only part that needs one.
**The fake sim.** A stand-in for the real service that speaks the same feed protocol and control API
over the same ports, so the page, the bridge and any client can be developed and tested against it.
No brain, no emulator, no cartridge.
```sh
npx tsx packages/feed/src/fake/server.ts --scenario running # serves :7400 feed, :7401 control
```
**Recorded fixtures.** Four `.flyfeed` recordings ship in `apps/stage/public/fixtures/`. The stage
page replays them frame by frame, which is how its layout, motion, tabs and screenshot baselines are
developed and reviewed.
```sh
cd apps/stage && npm run dev
# then open ?mode=player&fixture=steady (also cold-open, big-moment, macros)
# ?tab=senses|connectome|ladder ?fly=webgl|paper|off
```
**The TypeScript oracle tests.** `packages/brain` is the reference implementation of the neural
core, and its suite includes bit-exact comparisons against verbatim copies of the original prototype
modules, plus a run on the real connectome dataset — which *is* in the repository, under
`data/fafb-v783`. No cartridge involved.
```sh
npm ci && npm test && npm run typecheck
```
**The Rust golden tests.** The Rust port is checked against golden files generated from the
TypeScript oracle (`services/flysim/golden/*.flygold`, produced by `packages/brain/tools/golden.ts`).
They cover the LIF kernel, the maths, plasticity, the decoder, the agent loop, checkpoint restore,
the version strings and the platformer preset, and they assert 0-ulp agreement. They are part of
`cargo test --workspace` and none of them needs a ROM.
**The infra suite and the stage end-to-end suite.**
```sh
bash infra/tests/lint.sh # shell scripts and systemd units
cd apps/stage && npm run test:e2e && npm run mockups # Playwright baselines and the review PNGs
```
If you want to see the fly actually play a commercial game, supply your own legally obtained
cartridge image and point `FLY_ROM` at it. That is the whole extent of the help available here.

650
apps/stage/README.md Normal file
View file

@ -0,0 +1,650 @@
# flystage
The 1920x1080 broadcast page for the 24/7 stream: a simulated fruit-fly connectome (139,255
neurons, FlyWire FAFB v783) plays a Game Boy game, and this page is what the encoder captures.
It is a **display only**. The simulation runs as a service (`flysim`) and this page renders what
arrives over a local WebSocket. There is no ROM here, no emulator, no input path, and no code
path that accepts text from anywhere but the feed.
Binding contracts: [`docs/feed-protocol.md`](../../docs/feed-protocol.md) and
[`docs/control-api.md`](../../docs/control-api.md). Design: `docs/design/stage-bridge.md`
sections 0 and A. Deviations from that design are listed at the bottom of this file.
## Running it
```sh
npm ci # from the repo root
# Player mode: replay a recorded fixture, no service needed. This is the default.
npm run dev -w @flybrain/stage
# http://127.0.0.1:5273/?mode=player&fixture=steady
# Live mode: the real feed. Needs flysim, or the fake one:
npx tsx packages/feed/src/fake/server.ts --scenario running
npm run dev -w @flybrain/stage
# http://127.0.0.1:5273/?mode=live
```
### Query parameters
Everything the page can be told is a query parameter, because the only two things that launch it
are a `chromium --kiosk` line in a systemd unit and a Playwright test.
| Parameter | Values | Meaning |
|---|---|---|
| `mode` | `player` (default), `live` | Replay a fixture, or open the feed socket. |
| `fixture` | `cold-open`, `steady`, `big-moment`, `macros` | Which recording to replay. `macros` is the only one with `[macros] mode = macros`; the other three predate the macro channels and replay the raw layout. |
| `t` | seconds | Seek there. **Without `play=1` the page holds and freezes its clock**, which is what makes a screenshot reproducible. |
| `play` | `1` | Keep playing after a seek. |
| `loop` | `0` | Stop at the end instead of looping. |
| `res` | `1080` (default), `720` | `720` is `transform: scale(0.6667)` on the one 1920x1080 stage: thumbnails and the downscale tests. |
| `theme` | `t1` (default), `t2`, `t3` | T1 Instrument, T2 Phosphor, T3 Field lab. |
| `fly` | `webgl` (default), `paper`, `off` | Which renderer draws the fly strip. `paper` is the 2D fallback for a host with no usable WebGL, and `webgl` **falls back to it by itself** when the GL context cannot be created (the capture container's `--disable-gpu` Chromium, measured on the P0 spike); `off` draws no fly and creates no GL context. `window.__stage.fly()` reports the renderer actually drawing as `mode` and the query parameter as `requested`. |
| `tab` | `senses`, `connectome`, `ladder` | Pin the tab slot and stop the cycle. Every visual test and every mockup uses it. |
| `chat` | `off` (or `0`) | The chat kill switch: no panel at all, whatever the feed carries. |
| `game` | `pokemon-red` (default), `platformer` | Per-game config. |
| `feed` | ws URL | Feed override for `mode=live`. Default `ws://127.0.0.1:7400/feed`. |
| `gain`, `gamegain`, `sfxgain` | 0..1 | Master / game / SFX gain. Defaults 0.9 / 0.8 / 0.5. |
| `audio` | `0` | Do not create an AudioContext at all. |
`window.__stage` exposes the operator surface: `metrics()` (per-stage paint timings), `audio()`
(context state, ring fill, underruns, drops), `health()` (accepted snapshots, feed gaps, decode
errors), `manifest()`, `seek(seconds)`, `stopFeed()`, `gameScale()`, `fly()` (renderer mode, gait
phase, leg tips, proboscis extension), `motion()` (which tab and why, the moment on stage and its
phase, the queue depth, live particles), `pam()` (the PAM centroid the flare spreads from), and
`fire(type, label, detail)` — the one deliberate way to drive the moment catalogue by hand, which
is what the moment mockups and the moment assertions use instead of waiting for a fixture to
contain one of each.
### Recording fixtures
```sh
# Starts a fake flysim itself, records 120 s, writes public/fixtures/steady.flyfeed.gz
npm run record -w @flybrain/stage -- --name steady --scenario running --seconds 120
# From a service that is already running, with two sugar redemptions in the middle
npm run record -w @flybrain/stage -- --name live --url ws://127.0.0.1:7400/feed \
--control-url http://127.0.0.1:7401 --seconds 60 --stimulate-at 8,34
```
The `.flyfeed` container lives in `packages/feed/src/fixture.ts` (shared, because the recorder is
Node and the player is the browser). `--spikes-stride 1 --audio-seconds 0` records at full
fidelity; the committed fixtures do not, and the reasons and numbers are in the recorder's header
comment and in each file's own manifest.
### Audio in player mode
Two things look like bugs in player mode and are not:
- the committed fixtures carry audio only for their first 12 s, so past that the ring buffer
underruns continuously and `__stage.audio().underruns` climbs by about 375 a second (one per
128-sample block). Record with `--audio-seconds 999` if you need a continuous stream.
- the ring fill sits near 60 ms rather than the 250 ms target, and the servo holds its rate at
0.997 trying to grow it. A fixture delivers audio at exactly 1x real time, so there is no
surplus to build the cushion out of; a real `flysim` running slightly ahead of the sound card
builds it in a few seconds. The servo is pulling in the right direction either way.
### Mockups (the sign-off gate)
```sh
npm run mockups -w @flybrain/stage
```
Builds, serves, and writes sixteen PNGs to `mockups/`, at 1920x1080 and DPR 1:
`steady-t1-{senses,connectome,ladder}` and `describe` for the four tabs, `big-moment-t1` 2.5 s into that
fixture's milestone, `moment-{milestone,badge,sugar,rollback}` shot 300 ms after the trigger (the
middle of every arrival in the catalogue), `macros-{overworld,running,outcome,battle,indoors}` for
the macro strip's five states, and the two `fly-*` review crops at 2x. `--only <substring>` shoots
just the ones whose name contains it, which is how one panel gets re-reviewed without rewriting
every committed PNG.
The theme sweep these used to be is gone: T1 was chosen in September, so what the images are for
now is the layout and the motion. They still serve the kickoff playbook's purpose — a human looks
at a picture before anyone argues about prose.
```sh
npm run fonts -w @flybrain/stage
```
Writes the eleventh PNG, `mockups/gameboy-fonts.png`: the three OFL pixel body candidates
`docs/design/gameboy-theme.md` names, each rendered in the parts of the rail where a body face has
to work — the 30 px rung line, the 38-cell spine, the cluster's readouts and footer, two ticker
lines and two chat lines — at their real token sizes and inside the dialogue-box frame. This was
the document's gate, and **the operator picked Silkscreen from it on 2026-09-15**; the frame marks the
pick, and the other two faces stay committed so the comparison can be re-rendered.
Neither the mockup tool nor the e2e suite waits on a timer for the page any more. `data-ready="1"`
means the fonts and the brain map's base bitmap are in — not that the fixture is — and on a cold
browser context `steady` (23.3 MB) reports ready at 1.7 s and its first accepted snapshot at 4.2 s.
Both now wait for `__stage.health().accepted` to move before they shoot, because a mockup of the
page's own initial zeroes is worse than a slow one.
### Tests
```sh
npm test -w @flybrain/stage # node --test: 213 unit tests
npm run typecheck -w @flybrain/stage
npm run test:e2e -w @flybrain/stage # Playwright, against the real build via vite preview
npx playwright install chromium # once
npm run test:e2e -w @flybrain/stage -- --update-snapshots # after an intended visual change
```
The e2e suite builds the app and serves it with `vite preview`, because the page that goes on air
is the build output, not the dev server. The downscale artifacts the legibility test produces land
in `tests/e2e/artifacts/` and are attached to the HTML report.
### Building a release
`__STAGE_VERSION__` (the title strip's version chip, `data-testid="stage-version"`) is
`vite.config.ts`'s `git describe --tags --always --dirty`, read from the working tree at build
time — a tagged, clean checkout gives `v0.1.0`; an untagged one falls back to the short sha on its
own; a dirty tree appends `-dirty`; the dev server always reads `dev` instead, since HMR does not
represent a build. **The stage build has to run from the tagged checkout for the string to be
right** — `npm run build -w @flybrain/stage` (or the `npm run build` a release pipeline calls)
reads `git describe` from wherever it is invoked, not from a version this repo tracks separately,
so a build off a branch that has moved past the tag, or off a dirty tree, bakes that into the page.
`infra/build/package-release.sh` and `infra/05-deploy.sh` do not pass the stage build anything
extra for this — they already require and record the tag independently (`infra/05-deploy.sh`'s
`require_release_tag`) — so getting the version chip right is entirely about building from the
right commit before packaging.
## Geometry
Layout B at **1920x1080**, 48 px safe insets, all geometry in `src/lib/geometry.ts`. There is one
authoring resolution and it is the broadcast resolution, so `#stage` carries no transform at all
on air; `?res=720` is `transform: scale(0.6667)` on that one element, for thumbnails and the
downscale tests.
The arithmetic, which closes exactly in both columns:
```
left column 800 wide: title 40 + game 720 + gap 4 + fly strip 220 = 984 = 1080 - 2x48
fly strip 220 tall: 4 border + button row 32 + gap 4 + row 176 = 220
its row: fly canvas 416 + gap 12 + macro palette 364 = 792 = 800 - 2x4
right rail 1012 wide: cluster 144 + slot 420 + events 100 + chat 244
+ 3 gutters of 12 = 944 = 720 + 4 + 220
width 800 + 12 + 1012 = 1824 = 1920 - 2x48
tab slot 420 tall: 48 tab strip + 370 pane + 2 border = 420
```
The title strip spans the full usable width and the game abuts it with no gutter, which is what
makes the left column close on 984. The rail starts level with the top of the game and ends level
with the bottom of the fly strip, at y=1032.
Text floors, in authoring pixels: body/ticker >= 24, labels 30-36 — 10% down from 27/33-39
(2026-09-16, once the VT323 split below gave the rail room to spare) — and
`tests/e2e/text-size.spec.ts` enforces both those and the 16 / 20-24 they land on in the 720p
thumbnail mode. Layout v2 has no 72 px hero: see the deviations.
## Rail layout v2
Locked in `docs/stream-mvp-plan.md` ("Rail layout v2, locked 2026-09-15 night") and built here.
Four rail panels instead of layout v1's five, on a 12 px gutter; the left column is unchanged:
| Panel | Box | What it shows | Source |
|---|---|---|---|
| Title strip | 1824x40 | Wordmark, mode chip (hidden when unknown), the DAY N slide | `panels/TitleStrip.tsx` |
| Game | 800x720 | The framebuffer at exactly 5x, and the rollback rewind wipe | `panels/GamePanel.tsx` |
| Fly strip | 800x220 | A plain 32 px button row along the top edge, the 3D fly filling the rest | `panels/FlyStrip.tsx` |
| Progress cluster | 1012x144 | The whole progress readout, in one panel | `panels/ProgressCluster.tsx` |
| Tab slot | 1012x420 | A 48 px tab strip and one of four panes | `panels/TabSlot.tsx` |
| EVENTS | 1012x100 | Three ticker rows, dwell-gated, tiered. No title | `panels/EventsTicker.tsx` |
| CHAT | 1012x244 | The last seven chat lines, or nothing at all | `panels/ChatPanel.tsx` |
| Moment layer | — | Caption band, rail flash, particles, day slide | `panels/MomentLayer.tsx` |
| Stale feed banner | 1824x40 | Over the title strip after 2 s of silence | `panels/StaleBanner.tsx` |
### The progress cluster
One panel where layout v1 had four (ladder, stuck-o-meter, run clock, sugar), because that was 14
panel borders and four titles for eight numbers (the middot and the ring are drawn, not typed):
```
here for brain
0/37 Boot screen -> Left the bedroom 1m34s 11.4 Hz
###############################################################
0/8 badges * 214 places try 1 06:12:33 * day 3 () SUGAR READY
```
The rung line is one line of 30 px mono — between the 27 px body floor and the 33 px label band,
deliberately neither. The spine draws one cell per rung of the ladder, from `milestone.total` when
the service sends it and from the game config's ladder otherwise (`src/lib/ladder.ts`), which is 38
for this demo. The SUGAR chip carries the cooldown ring.
### The tabs
Four tabs — SENSES, CONNECTOME, LADDER, DESCRIBE — in one 420 px slot, with an amber underline that
slides and a 300 ms crossfade. `src/lib/tabs.ts` decides which one is up, and `?tab=` pins it:
- **Focus.** A moment gives its own tab the slot for the moment's duration and then hands it back.
This is what replaced layout v1's promotion of the brain map over the whole rail.
- **Event steering.** A rung change goes to LADDER, a reward to CONNECTOME, gated by a 12 s dwell
so a reward every twenty seconds cannot make the slot flicker.
- **The cycle.** Otherwise the slot advances on its own every 45 to 60 s, and "walking with high
command activity" biases that rotation toward SENSES rather than owning it. DESCRIBE only ever
arrives this way: nothing steers to it and no moment focuses it, because it says what the stream
is rather than what just happened.
| Pane | What it shows |
|---|---|
| SENSES | The retina raster at 548x316, both eyes, beside six labelled circuit groups with their peak-hold and decision-threshold ticks, plus a MACROS row of whichever macro channels the scene has bound |
| CONNECTOME | The 2D brain map at the pane's own 1008x370, with the reward flare spreading from the PAM cluster's measured centroid |
| LADDER | All 38 rungs in three column-major columns, the rollback budget (this rung and lifetime) and the stall meter |
| DESCRIBE | What this is, one card at a time: a Silkscreen label, a paragraph of VT323 at the body floor, and a cell per card showing where the cycle is |
**All four panes stay mounted.** Only one is `data-visible="1"` — which is what the structural
test counts — and the others are `visibility: hidden`, out of paint entirely. They stay mounted
because the connectome's base bitmap is a worker's 139,255-point raster: unmounting the canvas
would throw it away and re-raster it on every tab cycle, twenty times an hour, for ever.
DESCRIBE's copy is `src/games/describe.ts` and nowhere else — one entry per card, in the reading
order, pending the operator's review (`docs/design/describe-tab.md`). The cards' numbers are placeholders
the page fills from the dataset metadata, the game config and the build's version
(`src/lib/describe.ts`), so a count on that tab cannot outlive the connectome it describes, and
`tests/unit/describe.test.ts` holds the file against the doc card for card. One card is up per
appearance of the tab, which is the rail's own 45-to-60 s cadence; `src/motion/director.ts`
advances it.
### Chat
The last seven lines of `header.chat`. Strictly text: no links, no images, no markup, no embeds.
The service is the authority — `packages/feed/src/chat.ts` is the sanitizer, and
`services/flysim/crates/flysim/src/chat.rs` enforces byte-identical rules in Rust — and
`src/chat/sanitize.ts` runs **that same shared implementation** again at the point of render,
dropping any line that fails. Not a local copy of the rules: there is no second set to drift.
A line that fails is not truncated or masked, it is not shown. With no `chat` in the header (an
older service, or `[chat] enabled = false`, which omits the key) and with `?chat=off`, the panel
renders nothing at all — no border, no title, no empty box. "The panel is there but empty" is what
a viewer reads as "the stream is broken", so it is the case the structural test pins.
Bot lines green, names amber, text ink: a viewer has to be able to tell the bridge's own template
replies from a person at a glance, because the bridge is the only thing on this stream that can be
made to say something by accident.
### Moments
`docs/design/animation.md`'s catalogue, wired. The engine (`src/motion/`, landed separately) owns
the queue, the particle pool and the catalogue as data; `src/motion/director.ts` is the half that
touches the rail, and it runs once per animation frame:
| Trigger | What happens |
|---|---|
| Milestone | Caption band in from the left over the tab slot, the new rung pulses twice then fills, amber sparks from that rung, LADDER focus for 9 s, chime |
| Badge | All of that plus the rail border flashing 120/600 ms, the badge count bouncing 1.15x, a fountain over the rail, a shockwave from the count, the connectome flaring, fanfare |
| Sugar | Proboscis and head glow (already rate-driven), the ring filling and draining over the cooldown, warm sparks drifting from the fly's head to the sugar chip, tone |
| Small reward | The ticker row slides up 240 ms, its value flashes amber 120/600 ms, sparks scaled by the reward's value tier, tick |
| Rollback | A 400 ms horizontal rewind wipe over the game canvas with a scanline flicker and backward streaks, the try count, "REWIND . try 3", rewind sweep |
| Mode change | The chip's text rolls vertically over 200 ms |
| Day rollover | "DAY N" slides across the title strip once, 2 s, soft stinger |
| HERE FOR 1 h / 3 h / 6 h | The number pulses once and its colour steps warmer |
Two things about the numbers. The holds are 9 s of *total* stage time (320 ms in, 8360 holding,
320 out), because that is what the design's own verification measures and what "LADDER focus 9 s"
means on screen. And every readout is lerped by the paint loop rather than committed by React
(`src/motion/readouts.ts`): the components render the numeric elements *empty* and the loop owns
their text, with a 120-300 ms time constant. The three discrete ones — the rung index, the try
count, the rollback budgets — are deliberately not lerped, because "RUNG 4.6/37" is not smooth,
it is wrong.
The particle layer is one 2D canvas over the frame, capped at 400, additive, pooled, seeded so a
screenshot is reproducible. It spans the whole stage because the sugar sparks have to cross from
the fly's head to the sugar chip, and it is clipped to the rail plus the fly strip so a badge's
shockwave can never cross the game.
**Paint cost**, measured on this laptop over 10 s of the `steady` fixture with a badge fired 4 s
in — the most expensive frame the page ever draws: a fountain, a shockwave, the rail flash, the
caption band, the connectome flare and the counter bounce, all at once. About 600 frames per tab,
and `tests/e2e/behaviour.spec.ts` prints the same figures on every run:
| Tab | whole frame p50 | p95 | `motion` p95 | brain map p95 |
|---|---|---|---|---|
| SENSES | 1.8 ms | 3.5 ms | 1.7 ms | 1.0 ms |
| CONNECTOME | 1.8 ms | 3.3 ms | 1.8 ms | 0.8 ms |
| LADDER | 1.7 ms | 2.9 ms | 1.4 ms | 0.8 ms |
| idle, no moment | 1.8 ms | 3.2 ms | — | 0.8 ms |
The design's budget is a whole-frame p95 under 4 ms, and the `motion` stage — the moment queue,
the particle simulation and its draw, the lerped readouts and the tab slot — is under 2 ms of it.
The `max` column is left out on purpose: it is 20 to 46 ms on every one of these runs, always on
the first frame, and always the WebGL fly's context creation and shader compile. It never recurs,
and `?fly=paper` or `?fly=off` removes it.
### The fly
A small 3D fly sits under the game, facing the screen, with a plain row of eight button
indicators along the top of its strip. Every motion of the *fly itself* is a real population
rate, and nothing about it presses a button: tripod gait speed from `forward`/`backward`, body
yaw and stride asymmetry from `steer_left`/`steer_right`, wing beat amplitude and frequency (and
haltere jitter) from the sum of `command_0..7` — a stand-in for a `motor` role rate until one is
in the feed, see below — the proboscis from `proboscis` and sugar events, the head and thorax
glow from `reward_pam`, and the abdomen's breathing from the population rate. Nothing is scripted
or random except a small idle floor on the wing beat. The binding brief is
[`docs/design/fly-avatar.md`](../../docs/design/fly-avatar.md).
| File | What it is |
|---|---|
| `src/fly/rig.ts` | The animal: proportions, the tripod gait, leg IK, the wing beat, drives to joints. Emits world-space points and knows nothing about drawing. |
| `src/fly/drives.ts` | Feed rates to 0..1 drives, through the same running reference the circuit bars use. Holds `WING_DRIVE_ROLES`, the one table that maps the wing/flight drive to its source roles. |
| `src/fly/camera.ts` | The one camera both renderers share. |
| `src/fly/webgl.ts` | three.js, one GL context, well under 1,600 triangles, flat-shaded Lambert. |
| `src/fly/paper.ts` | The same rig projected by hand into a 2D canvas, painter's algorithm. |
TODO: `docs/design/fly-avatar.md`'s neuron table calls for a `motor` role (110 neurons) driving
the wings; `docs/feed-protocol.md`'s `rates` does not carry `motor` yet, so `WING_DRIVE_ROLES` in
`src/fly/drives.ts` sums the eight `command_0..7` descending-command rates instead. Swapping in
`motor` once the feed grows it is a one-line change in that file.
Every drive is normalized against its role's own running reference (`src/lib/circuit-scale.ts`)
and then re-centred on the resting level that scale implies (1 / headroom, about 0.67), so the fly
reads as still when the fly is at its own normal and moves when a rate rises above it. A raw
fraction would leave the proboscis half out and the head half lit for ever.
**Paint cost**, measured over 12 s of the `steady` fixture playing on this laptop, one sample per
*drawn* frame at the 30 fps cap (`window.__stage.metrics().stages.fly`):
| Renderer | n | mean | p50 | p95 | max | whole-frame p95 |
|---|---|---|---|---|---|---|
| `webgl` | 371 | 0.35 ms | 0.30 ms | 0.40 ms | 27.20 ms | 1.30 ms |
| `paper` | 371 | 0.09 ms | 0.10 ms | 0.20 ms | 1.50 ms | 1.10 ms |
| `off` | — | — | — | — | — | 1.00 ms |
The WebGL `max` is the first frame — context creation and shader compilation — and never recurs;
every later frame is inside the design's 4 ms budget with two orders of magnitude to spare. The
paper fly is cheaper still and looks plainer, which is the trade the design accepts for a host
with no usable WebGL.
### Copy
Terse and instrument-like, per the direction of 2026-09-15: short nouns for panel titles, no
parenthetical justifications, no sentences under widgets, no captions.
```
A FLY BRAIN PLAYS POKEMON RED
SENSES CONNECTOME LADDER RETINA CIRCUITS RUNG HERE FOR BRAIN CHAT SUGAR READY
```
The neuron count and a learning/frozen chip used to sit beside the wordmark; both are gone
(2026-09-15 review) — the count duplicated the rotating card's own credit line, and "learning" was
one more piece of operator status. The mode chip stays, in plain words (`walking`, `battle`,
`menu`, `boot`, `demo`, …), and disappears entirely rather than show a placeholder when the
adapter reports `UNKNOWN`.
**No explainer card anywhere**, per the locked layout. Layout v1 rotated one card through the
narrative lane for the last minute of every four minutes; v2 gives that lane to the ticker
outright, and the persistent chat panel is what fills the space an explanation used to. The eight
explainer cards and the two-column real-vs-scaffolding panel went in the copy pass before it, and
so did the per-widget captions they duplicated: "one dot per L1 column", "legs: real, wired to
nothing", "stimulates dopamine, never a button", "what the fly sees", "what just happened".
`ROTATING_CARDS` and `src/lib/schedule.ts` are still in the tree, unrendered, and that is a
deliberate loose end rather than dead code left by accident: the four lines include the FlyWire
credit with its CC BY-NC licence, which has to end up *somewhere* (the channel's about page, a
periodic bridge message, or a panel nobody has designed yet). Deleting the strings would lose the
only reviewed copy of them; rendering them would break the locked layout. They stay until that is
decided, in one place, with this paragraph attached.
`tests/unit/labels.test.ts` enforces the register mechanically: every on-screen label is 24
characters or fewer, and none of them contains a full stop, an exclamation mark or a parenthesis.
The middot in the wordmark is in the committed Press Start 2P subset — checked with
`document.fonts.check` and a width comparison against a glyph the subset does not have, because a
missing glyph falls back to a proportional face and shows as tofu on air. The É was in the subset
too, but the face draws it at x-height, so POKÉMON read as POKéMON — a little smaller than the
caps around it. The wordmark in `src/games/pokemon-red.ts` drops the accent (POKEMON) for that
reason; Pokémon Red keeps its accent everywhere else this doc names the game.
### Why the brain map has no WebGL
Decision 3 of the plan: the capture VM has no GPU, and an accidental SwiftShader context costs
one to two cores silently. So the map is three tiers of 2D canvas (design A5): a base bitmap of
all 139,255 neurons rasterised once in a worker, a 252x185 density accumulator over the spike
bitset with a 110 ms decay, and at most 256 pre-rendered sprites on the brightest cells. On top of
those, one moment effect: the reward flare, an expanding ring from the PAM cluster's own centroid,
which the worker computes from `meta.json`'s `reward_pam` role and the normalized positions rather
than from a hand-placed coordinate that would point at the wrong part of the brain the first time
the dataset is rebuilt.
The accumulator's grid takes a different divisor per axis — 1008/4 and 370/2 — because 370 is not
a multiple of 4 and a fractional cell would put the sprite pass a subpixel off the cell it belongs
to. The particle layer is 2D canvas too, for the same reason.
That decision still holds for the map. The fly is the one deliberate exception, and it is
counted rather than trusted: `tests/e2e/structure.spec.ts` asserts the page requests **exactly
one** GL context with `?fly=webgl` and **zero** with `?fly=paper` or `?fly=off`.
## The game config contract
There will be a second demo, so the page is game-agnostic by construction. `src/games/types.ts`
is the contract; `src/games/pokemon-red.ts` and `src/games/platformer.ts` (a stub) implement it,
and `?game=` selects one.
**The split:** live values always come from the feed header. A config only supplies the human copy
for them, plus the wordmark.
| On screen | Value from | Copy from |
|---|---|---|
| Current rung name | `milestone.label` | — (the service is authoritative) |
| Next rung | `milestone.next` | — |
| The rung count | `milestone.total` | `milestoneLadder`'s length, as the fallback (`src/lib/ladder.ts`) |
| The ladder's rung names | — | `milestoneLadder` (the header carries only the current one) |
| Game mode | `game.mode` | `modeLabels` |
| Ticker rows | `events[].rewardKind` | `rewardCopy[kind].label` + `tier` + `dedupeMs` |
| Counters | `game.badges`, `game.uniqueLocations` | `counters[]` |
| The DESCRIBE cards' game name | — | `name`, reached through `{game}` in `src/games/describe.ts` |
Two rules the tests enforce:
- No game is named outside `src/games/`. `tests/unit/labels.test.ts` asserts the dataset-level
copy mentions no game vocabulary, and `tests/e2e/structure.spec.ts` loads the page with
`?game=platformer` and asserts the other game's name appears nowhere in the frame.
- The role-to-label mapping for the circuit bars is **dataset-level**, not game-level — it is the
same fly for both demos — so it lives in `src/lib/labels.ts`, and a unit test asserts it covers
every role key in `data/fafb-v783/meta.json` and `circuit-roles.json`. A dataset rebuild that
adds a role fails the test instead of silently dropping a bar.
Adding the platformer for real means replacing the ladder and the copy in
`src/games/platformer.ts`. Nothing else.
Three places outside `src/games/` still contain the string, and none of them is display copy:
- `src/lib/query.ts` and `src/games/index.ts` carry `'pokemon-red'` as the default `?game=` id.
A default has to name something, and this is a registry key.
- `RewardKind` includes `'pokedex'`, and `src/feed/store.ts` watches `rewardCounts.pokedex` to
promote the brain map when a counter moves without its event. That key is in the feed protocol
contract, not in this page, and the config is what turns it into words ("found a secret" for the
platformer).
- two source comments cite the research the layout came from.
`grep -ril pokemon apps/stage/src` is the check, and `tests/e2e/structure.spec.ts` asserts the
rendered frame contains no trace of the other game when `?game=platformer` is loaded.
## Deviations from design A and from the locked layout
Every one of these is a case where a specification's numbers do not close, or where rendering it
showed the choice failing its own legibility requirement. The first five are about rail layout v2
and supersede the layout v1 deviations they replace.
1. **The rail is the locked four rows** (superseding A2's four *and* layout v1's five). Layout v1
needed five rows because the brain map was an inset with its own band; v2 makes the map a tab,
so the rows are the locked 144 / 420 / 100 / 244 on a 12 px gutter, which closes on 944
exactly. `src/motion/catalogue.ts`'s region boxes read those numbers out of `geometry.ts`
rather than restating them.
2. **The button row lives inside the fly strip, not above the game** (A2). A2 puts eight glyph
cells at the bottom of the left column, inside the zone Twitch overlays with chat; this page
puts them along the fly strip's own top edge instead (32 of its 220 px, full width), with the
fly's canvas filling the rest. The row briefly moved further still, into the fly's own 3D scene
as the Game Boy's caps it tapped, but came back out on review: the fly's legs and wings are
wired to real motor neurons, not to button presses, so tapping a button was never honest
(`docs/design/fly-avatar.md`). Only the fly's own canvas reaches into the bottom-left
no-content zone; the button row sits well above it.
Since 2026-09-16 the strip's lower row is **the fly on the left and the macro strip on the
right**, sharing one baseline and the strip's one frame (the operator: "slide the fly over and put the
macro palette right next to it", `docs/design/macros.md` section 6). The fly's canvas is 416 px
because that is the widest it can be while the strip's left edge stays at x = 480, the
no-content zone's right edge — the macro cells carry text and its bottom rows are inside the
zone's band. It is one column of six 27 px cells, not two columns of three, because a cell has
to hold "BUY POTION" in Silkscreen at the 24 px floor (168 px) and two columns leave 164. Since
section 12 a cell is its channel tag and the macro's name: the tag is eight characters at its
longest (`MB·WARP`), which is 140 px of the row's 356, and that is where the gloss column went
— the gloss is still on the wire, it just has no room on the strip.
3. **The brain map is a tab, and there is no promotion at all** (A2/A5, and superseding layout
v1's promote-over-the-rail). A2 promotes the map "over the left column", which puts it over the
game — the one thing a broadcast overlay must never do. Layout v1 promoted it over the rail
instead; v2 deletes the promotion outright, because the locked layout makes the map one of three
tabs and "big moments pre-empt for 9 s" is a *tab focus*. So the backing store is the pane's own
1008x370, allocated once, never rescaled, and the 520 ms transform is off the broadcast's
critical path. The one thing a moment now draws outside a panel is the caption band, and
`tests/e2e/behaviour.spec.ts` asserts it is inside the tab slot and clear of the game, the fly
and the title.
4. **The spine runs across the cluster, not down a panel** (A3). A vertical spine gives each rung
a couple of pixels, which is under one pixel at the phone downscale the legibility test checks:
gone. Across the progress cluster's full 984 px, 38 rungs are 24.7 px each, which survives —
and the current rung's 20 px floor (`docs/design/ladder.md`, measured in
`tests/e2e/ladder.spec.ts`) no longer even binds.
5. **No 72 px hero anywhere** (A2/A4). The type floors are unchanged, but layout v1's hero role is
unused: the four panels that carried one (the run clock's Hz, the stuck-o-meter's time) are one
144 px cluster now, and 72 px of anything does not fit beside a 38-rung spine. The cluster's
headline is one line of 30 px mono — deliberately between the 27 px body floor and the 33 px
label band — with the two right-hand readouts at `label-lg`. `tests/e2e/text-size.spec.ts`
asserts the absence, so a hero cannot creep back in unmeasured.
6. **The grid LUT is a `Uint32Array`** (A5), not a `Uint16Array`. The grid is 252x185 (46,620
cells), which is inside a u16 — but the LUT is indexed by *neuron*, and there are 139,255 of
them, so the array is 139,255 entries of cell index either way. It stays `Uint32Array` because
a future larger grid would overflow silently, which is the failure a `Uint16Array` would buy
for 278 KB in a worker.
7. **There is no backing-store multiplier at all** (A1). A1 multiplies the game and retina backing
stores by 1.5 for the 1080p mode; 1080p *is* the authoring size now, so every backing store is
already 1:1 with the encoded frame and `?res=720` only ever scales down.
8. **Resolved 2026-09-16 (was: the rung line abbreviates the *next* rung, and the LADDER tab
abbreviates every rung).** The Game Boy pass gave both to Press Start 2P or Silkscreen, and the
arithmetic never closed at any size inside the floors — a single rung name alone wanted 330 to
450 px in Press Start 2P or 250 to 335 in Silkscreen, against a rung line with about 370 to
spare, and the LADDER tab's three columns left each name 147 px, seven characters of Silkscreen,
so "Viridian City" and "Viridian Forest" both read "Viridia…". The 2026-09-16 pairing moves both
to `--font-text` (VT323, `src/theme/tokens.css`) — 0.4 em a character against Silkscreen's
0.73 — and neither needs the cap or the abbreviation any more: the ladder's longest names
(15 characters) measure about 162 px at 27 px, and both rung names together with the arrow and
gaps come to roughly 350 px of the rung line's own column. `src/theme/rail.css`'s
`.rung-line__name`/`.rung-line__next` keep a generous `max-width` and an ellipsis as a
structural backstop for a service that free-texts something longer than any name in this
build's ladder, not because either is expected to hit it.
9. **Tabular digits come from a second face, not from the body/label face**
(`docs/design/gameboy-theme.md`, Type). The design asks for tabular digits "everywhere numbers
change" and notes that monospace makes it automatic. Silkscreen is proportional and carries no
`tnum` — measured, every digit is 20/27 em except "1", which is 17 — so `.num` renders in Press
Start 2P (`--font-num`), the one monospaced face on the page and the design's own face for big
numbers. This held through the 2026-09-16 font-pairing revision too: VT323 *is* monospaced, but
`.num`'s digits stayed on Press Start 2P rather than following `--font-text`, because the two
faces have different cap-heights and swapping mid-line under a `--font-label` word would set a
readout and its label on two different baselines (`src/theme/tokens.css`). Three consequences of
the original pick: the two cluster readouts sit at 30 px rather than 36 (2026-09-16's -10% pass;
33/39 before it), the label band's floor rather than its ceiling, because a full em per
character took more of the cluster's 980 than the rung line could give up; the clock's day and
the Hz unit are split out of `.num` into words; and the try count and the ladder's rung index
are *not* `.num`, because neither is a number that changes often enough for a viewer to catch it
moving.
10. **Resolved 2026-09-16 (was: the progress cluster's counters line truncates)**. Silkscreen was
1.8x VT323's width, and the cluster's footer held five readouts wanting about 1,060 px in 980
even after the splits in (9), so the counters line carried `truncate` and "0/8 badges · 214
places" ellipsised. Under `--font-text` (VT323) the same line measures well under its column
(`ProgressCluster.tsx` no longer sets `truncate` on it). **The SENSES head's lost unit word is
still gone**, though, and is unrelated to the font pairing: the head is 422 px, of which
"circuits" (`--font-label`) is 229 and the gap 12, and " spikes" alone was 121 against 135 to
189 for the number it labels — the number is the half that cannot be paraphrased, so the word
stays off. Visible in `mockups/steady-t1-senses.png`.
11. **The realtime factor is not on screen.** A2 does not ask for it, and "1.00x" is exactly the
operator status vocabulary the audit complained about. It is on `window.__stage` and in the
feed header for whoever is on call.
12. **`@flybrain/brain/view/layout` is a new subpath export.** The design says to import
`normalizePositions`/`classifyByRoles` from `@flybrain/brain/view` and *not* from
`view/connectome.ts`, but `./view` resolves to `connectome.ts`, which imports `three`. The
new subpath reaches `view/layout.ts` directly, which is what the design meant.
13. **Fixtures are over the 5 MB target** (cold-open 0.28 MB, big-moment 13.0 MB, steady
23.3 MB), and gzip does not fix it. Measured: one snapshot from the fake flysim gzips to
9.7 KB, so 120 s at 30 Hz is 35 MB at full fidelity, and at 30 Hz the *headers alone* are
2.3 MB gzipped over 120 s while the spike bitsets are close to incompressible. The recorder's
attachment stride already cuts 442 MB to 9.8 MB; getting under 5 MB would have meant 15 Hz
frames and 5 Hz spikes, which costs more than the bytes do. big-moment and steady roughly
doubled on 2026-09-16 when `@flybrain/feed`'s fake simulator changed its default
`spikesPerTick` from 2,000 to the live service's measured ~30,000 (`packages/feed`'s
`fake/simulator.ts` and README): the bitset's raw size does not change — it is fixed at
`ceil(139_255 / 8) = 17,407` bytes regardless of density — but a ~21.5% full bitset is far
less compressible than a ~1.4% full one, so the kept-every-third-snapshot spike bytes gzip
much worse. cold-open is unaffected because its `boot` scenario never reaches `running` and
so never emits spikes.
14. **Fixtures live in `public/fixtures/`**, not `fixtures/`, so Vite serves and copies them
without a second bespoke middleware (the dataset artifacts already need one).
15. **The SFX bank is synthesised, not sampled** (A8 asks for six short OFL/CC0 samples). Eight
recipes of oscillators and gain envelopes rendered into `AudioBuffer`s at startup — the
original six plus the rollback's rewind sweep and the day-rollover stinger, which the moment
catalogue's sound tiers had been borrowing other samples for. No audio assets in the repo and
no third-party licence to track. `src/audio/sfx.ts`.
16. **`apps/stage` does not set `noUncheckedIndexedAccess`.** It typechecks `packages/brain` and
`packages/feed` sources directly (they are source-only workspace packages), and those are
written against the workspace's own stricter-than-default-but-not-that-strict settings.
17. **The particle layer spans the stage, not just the rail**
(`docs/design/animation.md`, addendum). The addendum puts particles "on a 2D canvas layer over
the rail" and then asks for sugar sparks that "drift from the fly's head toward the sugar
chip" — a path from x=448 in the left column to x=1790 in the rail, which a rail-sized canvas
cannot draw. The canvas is the whole frame and is *clipped* to the rail plus the fly strip
instead, so the wider surface buys the effect the addendum describes without buying a licence
to paint over the game. It also costs nothing on an idle frame: with no live particles the
layer is not cleared and not drawn.
18. **HERE FOR's thresholds are not queued moments** (`docs/design/animation.md`, catalogue). Every
other row of the catalogue is a moment; this one is a property of a number that is on screen
all the time, so the warmth is a function of the *value* (a page that loads into a fly stuck
for four hours is already warm) and only the crossing pulses. Queueing it would have meant a
moment that could be pre-empted by a badge and then never seen.
19. **`viewer` events do not reach the EVENTS ticker.** Every accepted chat line logs one, and v2
gives chat its own panel, so a `viewer` row in EVENTS is the same line twice — once with its
text and once as the word "chat". Sugar is its own event kind and still lands in EVENTS, which
is the viewer action that panel is about.
20. **The body face is split in two, VT323 replacing Silkscreen for running text**
(2026-09-16, the operator, from the sign-off mockup). `--font-body` is `--font-label` (Silkscreen) and
`--font-text` (VT323) now, not one face doing both jobs: `--font-label` for panel titles, tab
titles, chips (mode, sugar, button-row glyphs) and the circuit bar row labels — everything
short and closed-vocabulary, which is what Silkscreen's boxy weight was picked for; `--font-text`
for everything an open vocabulary or the feed writes — the rung names, the ladder tab, the
ticker, chat, the clock's day word. `--font-pixel` (Press Start 2P) keeps exactly `.num` and the
wordmark, unchanged. This is what let deviations 8 and 10 above resolve, and it came with a
10% cut to the type floors (`--fs-body` 27 -> 24, `--fs-label` 33-39 -> 30-36) once the
narrower running-text face gave the rail room to spare — `tests/e2e/legibility.spec.ts`'s 0.31
downscale check is the guard on that cut, and every region cleared it with margin to spare
(chat 0.104, events 0.126, the rung line 0.177-0.190, the retina 0.094-0.146 by tab, all against
a 0.035 floor), so nothing reverted to the old size.
21. **The retina raster's coordinates are an axial hex lattice, not Cartesian** (found 2026-09-16,
The operator: "the retina seems squashed"). `column_assignment.csv`'s `x`/`y`, copied straight through
by `tools/build_flywire.py`, are integer axial hex column coordinates (measured: 18 columns by
60 rows, uniform raw nearest-neighbour distance of 1.0) — plotted as Cartesian, one eye's
bounding box is 17 units wide and 59 tall, which is the squash. `src/paint/retina.ts`'s
`hexToCartesian` (standard pointy-top axial-to-pixel) runs once in `setColumns`, ahead of the
existing per-eye letterboxed fit, and takes the aspect to about 1.5 (taller than wide, a real
compound eye's own shape) rather than the mirrored assignment's 0.25 (also measured, and worse).
22. **The LADDER tab has no best-snapshot thumbnail** (dropped 2026-09-16, the operator: give the width
back to the rung names). It cost 160 of the stats column's 236 px for a picture, not the ratchet
the tab is about; `LADDER_STATS_WIDTH` is 130 now (rollbacks, lifetime, last and the stall meter
only), and the freed width plus the VT323 split in (20) is what lets all three columns show
every rung name whole rather than the seven-character "Viridia…" deviation 8 used to describe.
A name that still cannot fit — none of this build's do — gets a slow stepped marquee pan
(`src/lib/marquee.ts`, `useLadderMarquee` in `LadderTab.tsx`) instead of an ellipsis, because
the pixel cursor makes a rung's name the one thing on this pane a viewer is meant to read in
full.
23. **The title strip carries a release version, last and quietest** (2026-09-16, the operator).
`__STAGE_VERSION__` (`vite.config.ts`, `git describe --tags --always --dirty`; `dev` from the
dev server) renders beside the mode chip in `--font-label` at the label floor, dim ink — for
whoever is on call, not a viewer's read of the game. The string is only right when the build
runs from the tagged checkout the release is cut from; see "Building a release" below.
## Things this page deliberately cannot do
- It cannot press a button. The feed is one-directional and the control API has no button
endpoint; both are structural, not configuration.
- It cannot render text from outside the feed. Viewer display names arrive only inside a
`FeedEvent`, are validated by the bridge before the sim call, and are re-validated here on
render (`safeDisplayName`, one chokepoint, tested).
- It cannot generate prose. Every string on screen is a constant in `src/lib/labels.ts` or a game
config, or a template-generated label from the service.

View file

@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

20
apps/stage/index.html Normal file
View file

@ -0,0 +1,20 @@
<!doctype html>
<html lang="en" data-theme="t1" data-res="1080">
<head>
<meta charset="utf-8" />
<title>Fly brain plays</title>
<meta name="viewport" content="width=1920, initial-scale=1" />
<meta
name="description"
content="A simulated fruit-fly connectome (139,255 neurons, FlyWire FAFB v783) plays a Game Boy game live."
/>
<!-- Only the two faces the page renders in. The other two body candidates are declared in
src/index.css so the pick is a one-line swap, and are deliberately not preloaded. -->
<link rel="preload" href="/fonts/PressStart2P-Latin.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/fonts/VT323-Latin.woff2" as="font" type="font/woff2" crossorigin />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

14
apps/stage/mockups/motion/.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
# The 42 individual frames (`<moment>-<t>.png`, six per moment at 1920x1080) are build output, not
# review material: what a human reads is the committed contact sheet per moment, and the frames
# regenerate in about twenty seconds with
#
# cd apps/stage && npx tsx tools/motion-strip.mts
#
# A run is ~12 MB, which is not something the repo should carry again on every animation tweak. The
# sheets themselves (`<moment>-sheet.png`, 38-66 KB each) are committed and are not ignored.
*-0.png
*-100.png
*-300.png
*-600.png
*-1200.png
*-9500.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View file

@ -0,0 +1,198 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>flystage · motion harness</title>
<!--
The motion harness: a dev-only page for judging one moment at a time.
Why it is a separate page and not a mode of the broadcast page: the effects have to be judged
*in isolation*, against placeholder rectangles, without a game canvas, a fly and a 30 Hz feed
moving underneath them. And because the rail panels are being rebuilt for layout v2 next door,
this page deliberately shares no CSS with them — everything it needs is in the <style> below.
Where it is served. This is `motion-harness/index.html` inside the app root, so `vite dev`
serves it at `/motion-harness/` with no config change, and `vite build` does not build it at
all (the build's only input is the app's own `index.html`). That is the whole "dev only"
mechanism: there is nothing to strip from the broadcast bundle because it was never in it.
npm run dev -w @flybrain/stage # then http://127.0.0.1:5273/motion-harness/
Query parameters:
?controls=0 hide the control bar and the region labels (what the screenshot tool uses)
?moment=badge fire that moment once the page is ready
?manual=1 virtual clock: the page only advances when `window.__motion.seek()` says so
-->
<style>
:root {
color-scheme: dark;
--bg: #05060a;
--panel: #0f131c;
--edge: #232b3a;
--ink: #c8d0dc;
--ink-dim: #6b7688;
--accent: #ffb020;
--dopamine: #f472a8;
--sensory: #4fc3f7;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
background: #000;
color: var(--ink);
font: 14px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
overflow: hidden;
}
/* One authoring resolution, exactly as the broadcast page does it: 1920x1080, scaled down by
a single transform on one element when the window is smaller. Playwright shoots at
1920x1080, where the scale is 1 and nothing is resampled. */
#stage-fit {
position: fixed;
inset: 0;
display: grid;
place-items: start center;
}
#stage {
position: relative;
width: 1920px;
height: 1080px;
background: radial-gradient(1200px 700px at 70% 20%, #0b1018 0%, var(--bg) 70%);
transform-origin: top center;
overflow: hidden;
}
.region {
position: absolute;
border: 1px dashed var(--edge);
border-radius: 2px;
background: rgba(255, 255, 255, 0.012);
}
.region > b {
position: absolute;
left: 8px;
top: 6px;
font-size: 13px;
letter-spacing: 0.12em;
color: var(--ink-dim);
text-transform: uppercase;
font-weight: 500;
}
.region[data-lit='1'] {
border-color: color-mix(in srgb, var(--accent) 55%, var(--edge));
background: rgba(255, 176, 32, 0.045);
}
#particles {
position: absolute;
inset: 0;
pointer-events: none;
}
/* The caption band: enters from the left over the tab slot, per the catalogue's milestone row. */
#caption {
position: absolute;
display: flex;
align-items: center;
gap: 18px;
padding: 0 28px;
background: linear-gradient(90deg, rgba(255, 176, 32, 0.22), rgba(255, 176, 32, 0.04));
border-left: 4px solid var(--accent);
will-change: transform, opacity;
}
#caption .kind {
font-size: 30px;
letter-spacing: 0.18em;
color: var(--accent);
}
#caption .label {
font-size: 30px;
color: #fff;
}
#caption .detail {
font-size: 24px;
color: var(--ink-dim);
}
/* The badge count, which bounces on out-back. */
#badge-count {
position: absolute;
font-size: 40px;
color: var(--accent);
will-change: transform;
}
/* The day strip, which slides across the title strip once. */
#day-slide {
position: absolute;
font-size: 26px;
letter-spacing: 0.3em;
color: var(--ink);
will-change: transform, opacity;
}
#controls {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: rgba(6, 8, 12, 0.92);
border-top: 1px solid var(--edge);
backdrop-filter: blur(2px);
}
button {
font: inherit;
color: var(--ink);
background: var(--panel);
border: 1px solid var(--edge);
border-radius: 3px;
padding: 7px 12px;
cursor: pointer;
}
button:hover {
border-color: var(--accent);
color: #fff;
}
button[data-kind='danger']:hover {
border-color: var(--dopamine);
}
#readout {
margin-left: auto;
display: flex;
gap: 18px;
color: var(--ink-dim);
white-space: pre;
}
#readout b {
color: var(--ink);
font-weight: 500;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/motion/harness.tsx"></script>
</body>
</html>

48
apps/stage/package.json Normal file
View file

@ -0,0 +1,48 @@
{
"name": "@flybrain/stage",
"version": "0.1.1",
"description": "flystage: the fixed 1920x1080 broadcast page for the 24/7 stream of a simulated fly connectome playing a Game Boy game.",
"license": "Apache-2.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.json --noEmit --pretty false && vite build",
"preview": "vite preview",
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false",
"test": "node --import tsx --test tests/unit/*.test.ts",
"test:e2e": "playwright test",
"mockups": "tsx tools/mockup.mts",
"fonts": "tsx tools/font-compare.mts",
"record": "tsx tools/record-fixture.mts"
},
"dependencies": {
"@flybrain/brain": "*",
"@flybrain/feed": "*",
"@radix-ui/react-progress": "1.1.16",
"@radix-ui/react-separator": "1.1.15",
"@radix-ui/react-slot": "1.3.3",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"react": "19.2.8",
"react-dom": "19.2.8",
"tailwind-merge": "3.6.0",
"three": "0.178.0",
"zustand": "5.0.15"
},
"devDependencies": {
"@playwright/test": "1.62.1",
"@tailwindcss/vite": "4.3.3",
"@types/node": "22.17.0",
"@types/three": "0.178.1",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
"@types/ws": "8.18.1",
"@vitejs/plugin-react": "5.2.0",
"tailwindcss": "4.3.3",
"tsx": "4.20.3",
"typescript": "5.9.2",
"vite": "7.3.6",
"ws": "8.21.3"
}
}

View file

@ -0,0 +1,70 @@
/**
* Playwright against the real build, at the real broadcast size: 1920x1080, DPR 1.
*
* Not the dev server: the page that goes on air is the `vite build` output served by `vite
* preview`, and the differences that matter (asset URLs, the worklet module, the worker chunk,
* no HMR client) all live in the build.
*
* The Chromium flags mirror what `flystage`'s systemd unit must use, per the two corrections at
* the top of the design document: keep `--autoplay-policy=no-user-gesture-required`, drop
* `--mute-audio` (the page is the stream's audio source), and drop every SwiftShader and ANGLE
* flag, so the browser picks its own GL path exactly as the capture host will.
*
* There is now exactly one intentional GL context on the page — the fly
* (`docs/design/fly-avatar.md`) — and `tests/e2e/structure.spec.ts` asserts it is exactly one with
* `?fly=webgl` and none at all with `?fly=paper` or `?fly=off`. The brain map is still 2D canvas.
*/
import { defineConfig, devices } from '@playwright/test';
/**
* Preview port, 4300 unless `FLYSTAGE_E2E_PORT` says otherwise.
*
* Overridable because `--strictPort` means two suites on one machine collide, and two of them at
* once is normal here: a second worktree running the same suite, or a dev preview already holding
* 4300. `FLYSTAGE_E2E_PORT=4400 npm run test:e2e` is the whole workaround.
*/
const PORT = Number(process.env.FLYSTAGE_E2E_PORT ?? 4300);
export default defineConfig({
testDir: './tests/e2e',
outputDir: './test-results',
snapshotPathTemplate: '{testDir}/__screenshots__/{arg}{ext}',
fullyParallel: false,
workers: 1,
retries: 0,
timeout: 90_000,
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list']],
expect: {
toHaveScreenshot: {
maxDiffPixelRatio: 0.002,
animations: 'disabled',
},
},
use: {
baseURL: `http://127.0.0.1:${PORT}`,
viewport: { width: 1920, height: 1080 },
deviceScaleFactor: 1,
trace: 'retain-on-failure',
launchOptions: {
args: ['--autoplay-policy=no-user-gesture-required', '--disable-lcd-text'],
},
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], viewport: { width: 1920, height: 1080 }, deviceScaleFactor: 1 },
},
],
webServer: {
command: `npm run build && npx vite preview --host 127.0.0.1 --port ${PORT} --strictPort`,
url: `http://127.0.0.1:${PORT}/`,
reuseExistingServer: !process.env.CI,
timeout: 180_000,
stdout: 'ignore',
stderr: 'pipe',
},
});

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,93 @@
Copyright 2021 The Pixelify Sans Project Authors (https://github.com/eifetx/Pixelify-Sans)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -0,0 +1,93 @@
Copyright 2012 The Press Start 2P Project Authors (cody@zone38.net), with Reserved Font Name "Press Start 2P".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -0,0 +1,93 @@
Copyright 2001 The Silkscreen Project Authors (https://github.com/googlefonts/silkscreen)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -0,0 +1,93 @@
Copyright 2011, The VT323 Project Authors (peter.hull@oikoi.com)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

777
apps/stage/src/App.tsx Normal file
View file

@ -0,0 +1,777 @@
/**
* The stage: one page, one rAF loop, one feed.
*
* Everything is wired here rather than inside the panels, because the panels must not own
* anything that ticks: React commits at 4 Hz, the paint loop runs at 60, and the feed arrives at
* 30. See `src/feed/store.ts` for that split and `src/paint/loop.ts` for the instrumentation.
* Rail layout v2's choreography — tabs, moments, lerped readouts — is one object too
* (`src/motion/director.ts`), so this file stays wiring rather than becoming the animation.
*
* `data-ready="1"` goes on `<html>` only after `document.fonts.ready` *and* the brain map's base
* bitmap, because both the capture launcher and every test wait on it. A broadcast page that
* paints once and runs for weeks will happily broadcast a fallback font forever if nobody gates
* the first frame.
*/
import { useEffect, useMemo, useRef } from 'react';
import { loadCompressed } from '@flybrain/brain/browser';
import { GAMEBOY_BUTTONS } from '@flybrain/brain';
import type { BrainBaseRequest, BrainBaseResponse } from '@/workers/brain-base.worker';
import BrainBaseWorker from '@/workers/brain-base.worker?worker';
import { AudioEngine } from '@/audio/engine';
import { FixturePlayer } from '@/feed/fixture';
import { FeedSocket } from '@/feed/socket';
import type { FeedSource } from '@/feed/source';
import { AFTERGLOW_MS, FeedIngest, hot, useStage } from '@/feed/store';
import { createFlyRenderer, type FlyFrame, type FlyRenderer } from '@/fly';
import { readFlyDrives } from '@/fly/drives';
import { FlyRig, idleDrives } from '@/fly/rig';
import { resolveGame } from '@/games';
import { CIRCUIT_DECISION_THRESHOLD, circuitFraction, thresholdRateHz } from '@/lib/circuit-scale';
import { BAR_ROLES, CIRCUIT_GROUPS, MACRO_CIRCUIT } from '@/lib/labels';
import {
FLY_CANVAS_HEIGHT,
FLY_CANVAS_WIDTH,
GAME_HEIGHT,
GAME_WIDTH,
LAYOUT,
MACRO_CELL_GAP,
MACRO_CELL_HEIGHT,
MACRO_CELL_ROWS,
MACRO_CELL_WIDTH,
MACRO_PALETTE_PAD,
MAP_GRID_HEIGHT,
MAP_GRID_WIDTH,
MAP_HERO_HEIGHT,
MAP_HERO_WIDTH,
RETINA_CANVAS_HEIGHT,
RETINA_CANVAS_WIDTH,
NO_CONTENT_ZONE,
boxStyle,
} from '@/lib/geometry';
import { stageOptions } from '@/lib/query';
import { Director, FLY_HEAD_ANCHOR } from '@/motion/director';
import { MotionEngine } from '@/motion/engine';
import type { MomentType } from '@/motion/moments';
import { MOTION_SEED, resolveMotionColours } from '@/motion/catalogue';
import { RailSignals } from '@/motion/rail-signals';
import { BAR_STEP, quantizePixels } from '@/motion/lerp';
import { DIR } from '@/motion/particles';
import { BrainMapSurface } from '@/paint/brainmap';
import { GameSurface } from '@/paint/game';
import { PaintLoop, type TimeFn } from '@/paint/loop';
import { RetinaSurface } from '@/paint/retina';
import { readCanvasPalette } from '@/theme/colors';
import { ChatPanel } from '@/panels/ChatPanel';
import { EventsTicker } from '@/panels/EventsTicker';
import { FlyStrip } from '@/panels/FlyStrip';
import { GamePanel } from '@/panels/GamePanel';
import { MomentLayer } from '@/panels/MomentLayer';
import { ProgressCluster } from '@/panels/ProgressCluster';
import { StaleBanner } from '@/panels/StaleBanner';
import { TabSlot } from '@/panels/TabSlot';
import { TitleStrip } from '@/panels/TitleStrip';
/** Where the dataset artifacts are served from (dev middleware and build copy both use this). */
const DATASET_BASE = '/data/fafb-v783';
/** The brain map repaints at up to 30 Hz; its 110 ms decay does not need 60. */
const MAP_INTERVAL_MS = 33;
/** The fly is capped at 30 fps, per `docs/design/fly-avatar.md`. */
const FLY_INTERVAL_MS = 33;
/**
* How long a finished macro's outcome stays on its cell.
*
* `docs/design/macros.md` section 6: "then shows its outcome for a beat (done / blocked /
* timeout)". A second, on the simulation clock, which is long enough to read a seven-character
* word at a glance and short enough that the cell is back to its gloss before the fly's next
* decision (the sim consults the channels again at most once per `holdMs`, 800 ms).
*/
const MACRO_OUTCOME_MS = 1000;
/** How new a running macro has to be for its cell to throw sparks: two snapshots' worth. */
const MACRO_BURST_MS = 200;
/** If the base bitmap never arrives, go ready anyway after this long, and say so. */
const READY_TIMEOUT_MS = 15_000;
export function App() {
const options = useMemo(() => stageOptions(), []);
const game = useMemo(() => resolveGame(options.game), [options.game]);
const gameCanvas = useRef<HTMLCanvasElement | null>(null);
const retinaCanvas = useRef<HTMLCanvasElement | null>(null);
const mapCanvas = useRef<HTMLCanvasElement | null>(null);
const flyCanvas = useRef<HTMLCanvasElement | null>(null);
const particleCanvas = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = document.documentElement;
root.dataset.theme = options.theme;
root.dataset.res = options.res;
root.dataset.game = game.id;
}, [options.theme, options.res, game.id]);
useEffect(() => {
const root = document.documentElement;
const stage = document.getElementById('stage') ?? root;
const palette = readCanvasPalette(root);
// One authoring resolution, and it is the native broadcast size: the backing stores are
// already 1:1 with the encoded frame, so there is no multiplier to apply. `?res=720` only ever
// scales *down* (thumbnails and the downscale tests), which needs no extra backing pixels.
const backingScale = 1;
const gameEl = gameCanvas.current;
const retinaEl = retinaCanvas.current;
const mapEl = mapCanvas.current;
if (!gameEl || !retinaEl || !mapEl) return;
const gameSurface = new GameSurface(gameEl, GAME_WIDTH, GAME_HEIGHT, backingScale);
const retinaSurface = new RetinaSurface(retinaEl, RETINA_CANVAS_WIDTH, RETINA_CANVAS_HEIGHT, backingScale);
const mapSurface = new BrainMapSurface(mapEl);
const backgroundCss = `rgb(${palette.background[0]} ${palette.background[1]} ${palette.background[2]})`;
gameSurface.drawPlaceholder(backgroundCss);
mapSurface.drawPlaceholder(backgroundCss);
retinaSurface.setColors(palette.sensory, palette.panel);
retinaSurface.clear();
mapSurface.setColors({ sensory: palette.sensory, internal: palette.internal, output: palette.output });
// -- The animation engine, and the rail's own derived readouts --------------------------------
const motion = new MotionEngine({
seed: MOTION_SEED,
palette: resolveMotionColours(root),
// The fly is inside a canvas, so its head is the one emission point with no element to
// measure; every other anchor is replaced from the DOM each frame by the director.
anchors: { flyHead: FLY_HEAD_ANCHOR },
});
const signals = new RailSignals();
const ingest = new FeedIngest(game, motion, signals);
const engine = options.audio ? new AudioEngine(options.gains) : null;
void engine?.start();
const director = new Director({
root: stage as HTMLElement,
game,
engine: motion,
signals,
palette,
map: mapSurface,
gameSurface,
particleCtx: particleCanvas.current?.getContext('2d') ?? null,
forcedTab: options.tab,
playSfx: engine ? (name, gain) => engine.playSfx(name, gain) : null,
});
// -- Button afterglow, as a plain row of eight indicators -------------------------------------
// The row is DOM, not canvas (`src/panels/FlyStrip.tsx`): one cell per `GAMEBOY_BUTTONS`
// entry, each mutated only through `data-down`/`data-glow` so nothing here re-renders at
// 30 Hz. Lit on the rising edge and for 250 ms past the *falling* edge, which is what keeps
// an 85 ms A press visible.
const buttonCells = new Map<string, HTMLElement>();
for (const button of GAMEBOY_BUTTONS) {
const cell = document.querySelector<HTMLElement>(`[data-button="${button}"]`);
if (cell) buttonCells.set(button, cell);
}
const buttonPainted = new Map<string, string>();
const paintButtons = (nowMs: number): void => {
for (const [button, cell] of buttonCells) {
const state = hot.buttonStates[button];
if (!state) continue;
const glow = state.down || nowMs - state.upAtMs < AFTERGLOW_MS;
const key = `${state.down ? 'd' : '-'}${glow ? 'g' : '-'}`;
if (buttonPainted.get(button) === key) continue;
buttonPainted.set(button, key);
cell.dataset.down = state.down ? '1' : '0';
cell.dataset.glow = glow ? '1' : '0';
}
};
// -- The macro cells -------------------------------------------------------------------------
// Two places draw them since section 14's decided layout: the strip under the game, which is
// the pad now, and the MACROS tab, which is the whole keyboard. React owns both lots of text,
// because it changes once per scene (`src/panels/MacroPalette.tsx`,
// `src/panels/tabs/MacrosTab.tsx`); what is painted here is which cell is lit and what its
// outcome was, on the same 30 Hz path as the button afterglow above, for both at once — a
// macro that is running is running in both places, and matching by name makes that one loop
// rather than two. The DOM is re-queried whenever React re-deals, since the cells are keyed on
// the scene and a cached node list goes stale on every scene change.
const paletteRoot = document.querySelector<HTMLElement>('[data-testid="macro-palette"]');
const boardRoot = document.querySelector<HTMLElement>('[data-testid="macros-tab"]');
let paletteCells: HTMLElement[] = [];
let paletteKey = '';
const refreshPaletteCells = (): void => {
if (!paletteRoot) return;
const key = `${paletteRoot.dataset.mode ?? ''}:${paletteRoot.dataset.scene ?? ''}`;
const stale = paletteCells.length === 0 || !(paletteCells[0] as HTMLElement).isConnected;
if (key === paletteKey && !stale) return;
paletteKey = key;
paletteCells = [paletteRoot, boardRoot].flatMap((element) =>
element === null ? [] : [...element.querySelectorAll<HTMLElement>('[data-macro-row]')],
);
};
// Sparks from the chosen cell, the moment it lights: the rail's own burst, aimed with
// arithmetic rather than a measured box, because the palette's geometry is fixed
// (`src/lib/geometry.ts`) and `PARTICLE_CLIP` already covers the fly strip.
//
// Aimed by `data-macro-slot`, the cell's place *in the strip*, rather than by its type: the
// strip packs the pad from the top into two columns of seven, so type 29 can be its third
// cell. A macro that is only on the board — the pad is allowed to be wider than the strip —
// has no slot and gets no burst, because the burst belongs to the strip's geometry and the
// board's tab may not even be up.
const motionColours = resolveMotionColours(root);
const cellCentre = (slot: number): { x: number; y: number } => ({
x:
LAYOUT.macroPalette.x +
MACRO_PALETTE_PAD +
Math.floor(slot / MACRO_CELL_ROWS) * (MACRO_CELL_WIDTH + MACRO_CELL_GAP) +
MACRO_CELL_WIDTH / 2,
y:
LAYOUT.macroPalette.y +
MACRO_PALETTE_PAD +
(slot % MACRO_CELL_ROWS) * (MACRO_CELL_HEIGHT + MACRO_CELL_GAP) +
MACRO_CELL_HEIGHT / 2,
});
let lastMacroKey = '';
const paintPalette = (): void => {
refreshPaletteCells();
if (paletteCells.length === 0) return;
// Read the two fields straight off the header rather than through `paletteView`: this runs
// 60 times a second and the view allocates all thirty-one cells, which the *commit* path
// (4 Hz, where the cells are what React needs) can afford and this one should not.
const game = hot.header?.game;
const inPalette = game?.macroMode === 'macros';
const macro = (inPalette ? game?.macro : null) ?? null;
// The outcome beat runs on the *simulation* clock, not the page's: a held fixture seek
// freezes the feed, so a page clock would tick the beat away under a screenshot.
const brainMs = hot.header?.brainMs ?? 0;
const reported = (inPalette ? game?.macroOutcome : null) ?? null;
const outcome = reported !== null && brainMs - reported.atMs <= MACRO_OUTCOME_MS ? reported : null;
// A cell is matched by the macro's *name*, not by the wire slot or by its place on screen:
// a type is a channel (`docs/design/macros.md` section 12), so the name is what identifies
// it — and an outcome the feed is still holding from the scene before then lights nothing,
// rather than lighting whichever cell inherited its slot. It is also what makes the strip
// and the board one loop: the same macro matches its cell in each.
let liveSlot = -1;
for (const cell of paletteCells) {
const name = cell.dataset.macroName ?? '';
const live = macro !== null && name !== '' && macro.name === name ? '1' : '0';
if (live === '1' && cell.dataset.macroSlot !== undefined) liveSlot = Number(cell.dataset.macroSlot);
if (cell.dataset.live !== live) cell.dataset.live = live;
// A cell that is running again shows the run, not the last result.
const word = outcome !== null && name !== '' && outcome.name === name && live === '0' ? outcome.outcome : '';
if (cell.dataset.outcome !== word) {
cell.dataset.outcome = word;
const label = cell.querySelector<HTMLElement>('.macro-cell__outcome');
if (label) label.textContent = word.toUpperCase();
}
}
// `sinceMs` counts from the start, so the start's own clock value identifies the run: a
// second `GO EXIT` on the same slot is a different burst. The burst only fires for a macro
// that started *just now*, which is what keeps it off two frames it does not belong to: the
// page connecting in the middle of a long macro, and a fixture seek, whose silent catch-up
// lands on a snapshot mid-run and would otherwise freeze a burst into every screenshot.
const key = macro === null ? '' : `${macro.slot}:${macro.name}:${Math.round(brainMs - macro.sinceMs)}`;
if (key !== lastMacroKey) {
if (key !== '' && macro !== null && liveSlot >= 0 && macro.sinceMs <= MACRO_BURST_MS) {
const at = cellCentre(liveSlot);
motion.field.sparks(at.x, at.y, 10, motionColours.amber, DIR.right, Math.PI / 2.5, { speed: 0.14 });
}
lastMacroKey = key;
}
};
// -- The fly ---------------------------------------------------------------------------------
const rig = new FlyRig();
const flyDrives = idleDrives();
let fly: FlyRenderer | null = null;
let lastFlyFrame: FlyFrame | null = null;
let lastFlyMs = Number.NEGATIVE_INFINITY;
let lastFlyClockMs = Number.NEGATIVE_INFINITY;
let flyStoppedDrawn = false;
let flyAccepted = -1;
if (options.fly !== 'off' && flyCanvas.current) {
void createFlyRenderer(options.fly, {
canvas: flyCanvas.current,
width: FLY_CANVAS_WIDTH,
height: FLY_CANVAS_HEIGHT,
palette,
}).then((renderer) => {
if (disposed) {
renderer?.dispose();
return;
}
fly = renderer;
});
}
// `time` is passed in rather than wrapping the whole call, so the `fly` histogram holds one
// sample per *drawn* frame. Timing the skipped frames too would bury the real cost under a
// pile of zeroes and report a p50 of 0 ms for a renderer doing real work at 30 fps.
const paintFly = (nowMs: number, time: TimeFn): void => {
if (!fly) return;
// 30 fps while the clock moves, plus a frame whenever a snapshot lands. When the clock has
// stopped — a fixture held on a seek target — draw exactly one more frame and then nothing:
// the rig zeroes its gait phase on a stopped clock, so that one frame is the same fly every
// time, which is what makes the screenshot tests reproducible.
const stopped = nowMs === lastFlyClockMs;
lastFlyClockMs = nowMs;
const due = stopped
? !flyStoppedDrawn
: nowMs - lastFlyMs >= FLY_INTERVAL_MS || hot.accepted !== flyAccepted;
flyStoppedDrawn = stopped;
if (!due) return;
lastFlyMs = nowMs;
flyAccepted = hot.accepted;
const sugar = motion.snapshot().active?.type === 'sugar' ? 1 : 0;
const frame = rig.advance(readFlyDrives(sugar, flyDrives), nowMs);
lastFlyFrame = frame;
time('fly', () => fly?.draw(frame));
};
// -- Circuit bars -------------------------------------------------------------------------
// Each bar scales against its own adaptive reference (`hot.circuitReferenceHz`, kept warm by
// `FeedIngest`, see `src/lib/circuit-scale.ts`), not a fixed Hz ceiling: a fixed scale is what
// pegged every bar at 100% on the first live run, whose per-role rates run far above whatever
// the fixture generator produced (`infra/docs/p0-local-encoded-frame.png`).
//
// The fills are also quantised to the theme's 8 px cells (`docs/design/gameboy-theme.md`:
// "Bars are chunky: 12 px tall, hard edges, filled in 8 px steps (quantized), no gradients"),
// which is why each bar's track width is measured once here: the quantum is 8 px of the
// element's own width, and a bar in a flex row has no width this file could derive.
const barFills = new Map<string, HTMLElement>();
const barTracks = new Map<string, number>();
const barPeaks = new Map<string, HTMLElement>();
const barThresholds = new Map<string, HTMLElement>();
const fullScale = new Map<string, number>();
const decisionThreshold = new Map<string, number>();
for (const group of CIRCUIT_GROUPS) {
for (const bar of group.bars) {
fullScale.set(bar.role, group.fullScaleHz);
const threshold = CIRCUIT_DECISION_THRESHOLD[group.id];
if (threshold !== undefined) decisionThreshold.set(bar.role, threshold);
}
}
for (const role of BAR_ROLES) {
const fill = document.querySelector<HTMLElement>(`[data-bar="${role}"]`);
const peak = document.querySelector<HTMLElement>(`[data-peak="${role}"]`);
const threshold = document.querySelector<HTMLElement>(`[data-threshold="${role}"]`);
if (fill) {
barFills.set(role, fill);
barTracks.set(role, fill.parentElement?.clientWidth ?? 0);
}
if (peak) barPeaks.set(role, peak);
if (threshold) barThresholds.set(role, threshold);
}
/**
* The MACROS row's bars, which come and go with the scene.
*
* The six fixed groups are queried once above; these are re-queried whenever React re-renders
* the row (`src/panels/tabs/SensesTab.tsx`), keyed on the channel tags it drew. Everything
* else is the same arithmetic: the fill scales against the role's own adaptive reference and is
* quantised to the theme's 8 px cells.
*/
let macroBars: { role: string; fill: HTMLElement; peak: HTMLElement | null; track: number }[] = [];
let macroBarKey = '\u0000';
const refreshMacroBars = (): void => {
const rows = [...document.querySelectorAll<HTMLElement>('[data-macro-bar]')];
const key = rows.map((row) => row.dataset.macroBar ?? '').join(',');
const stale = macroBars.length > 0 && !(macroBars[0] as { fill: HTMLElement }).fill.isConnected;
if (key === macroBarKey && !stale) return;
macroBarKey = key;
macroBars = [];
for (const row of rows) {
const fill = row.querySelector<HTMLElement>('[data-bar]');
const role = fill?.dataset.bar;
if (!fill || !role) continue;
macroBars.push({
role,
fill,
peak: row.querySelector<HTMLElement>('[data-peak]'),
track: fill.parentElement?.clientWidth ?? 0,
});
}
};
const barPainted = new Map<string, number>();
const paintBars = (): void => {
for (const [role, element] of barFills) {
const reference = hot.circuitReferenceHz[role] ?? fullScale.get(role) ?? 30;
const value = circuitFraction(hot.rates[role] ?? 0, reference);
// Re-measure while the track reads zero: the effect that caches these can run before the
// slot's panes have been laid out, and an unmeasured track would quietly mean no steps.
let track = barTracks.get(role) ?? 0;
if (track === 0) {
track = element.parentElement?.clientWidth ?? 0;
barTracks.set(role, track);
}
const quantised = quantizePixels(value, track, BAR_STEP);
if (barPainted.get(role) === quantised) continue;
barPainted.set(role, quantised);
element.style.transform = `scaleX(${quantised})`;
}
for (const [role, element] of barPeaks) {
const reference = hot.circuitReferenceHz[role] ?? fullScale.get(role) ?? 30;
const peak = circuitFraction(hot.peaks[role]?.value ?? 0, reference);
element.style.transform = `translateX(${(peak * 100).toFixed(1)}%)`;
}
for (const [role, element] of barThresholds) {
const threshold = decisionThreshold.get(role);
if (threshold === undefined) continue;
const reference = hot.circuitReferenceHz[role] ?? fullScale.get(role) ?? 30;
const median = hot.circuitMedianHz[role] ?? 0;
const fraction = circuitFraction(thresholdRateHz(threshold, median), reference);
element.style.transform = `translateX(${(fraction * 100).toFixed(1)}%)`;
}
refreshMacroBars();
for (const bar of macroBars) {
const reference = hot.circuitReferenceHz[bar.role] ?? MACRO_CIRCUIT.fullScaleHz;
const value = circuitFraction(hot.rates[bar.role] ?? 0, reference);
if (bar.track === 0) bar.track = bar.fill.parentElement?.clientWidth ?? 0;
const quantised = quantizePixels(value, bar.track, BAR_STEP);
if (barPainted.get(bar.role) !== quantised) {
barPainted.set(bar.role, quantised);
bar.fill.style.transform = `scaleX(${quantised})`;
}
if (bar.peak) {
const peak = circuitFraction(hot.peaks[bar.role]?.value ?? 0, reference);
bar.peak.style.transform = `translateX(${(peak * 100).toFixed(1)}%)`;
}
}
};
// -- Sound effects the moments do not own -------------------------------------------------
// Every moment's sound comes from the engine's cue queue, drained by the director. What is
// left here is the two alarms, which are states rather than moments: the stuck-o-meter
// crossing its threshold, and the feed going stale.
let sfxArmed = false;
let stuckFired = false;
let staleFired = false;
const fireSfx = (): void => {
if (!engine) return;
const state = useStage.getState();
if (!sfxArmed) {
// Arm after the first frame so a seek's replayed history is silent.
stuckFired = state.milestone.sinceSeconds >= game.stuckAlarmSeconds;
staleFired = state.stale;
sfxArmed = true;
return;
}
const stuck = state.milestone.sinceSeconds >= game.stuckAlarmSeconds;
if (stuck && !stuckFired) engine.playSfx('stuck');
stuckFired = stuck;
if (state.stale && !staleFired) engine.playSfx('stale');
staleFired = state.stale;
};
// -- The loop -----------------------------------------------------------------------------
let lastMapMs = Number.NEGATIVE_INFINITY;
let mapDirty = false;
let source: FeedSource | null = null;
const loop = new PaintLoop((rawNowMs, dtMs, time) => {
if (source) time('pump', () => source?.pump(rawNowMs));
// A fixture held on a seek target freezes the clock, so everything that is a function of
// elapsed time stops with it and a screenshot is reproducible.
const nowMs = source?.clock ? source.clock(rawNowMs) : rawNowMs;
if (hot.frameDirty && hot.frame) {
const frame = hot.frame;
hot.frameDirty = false;
time('game', () => gameSurface.drawFrame(frame, rawNowMs));
if (retinaSurface.ready()) time('retina', () => retinaSurface.drawFrame(frame));
} else if (gameSurface.rewinding(rawNowMs)) {
// The rollback wipe is 400 ms of animation over a 30 Hz picture, so the game canvas is
// repainted on the frames between snapshots for its duration and on no others.
time('game', () => gameSurface.redraw(rawNowMs));
}
if (hot.spikesDirty && hot.spikes) {
mapSurface.ingestSpikes(hot.spikes);
hot.spikesDirty = false;
mapDirty = true;
}
// Redraw at up to 30 Hz while the feed is moving, and not at all when it is not: the
// accumulator's decay would otherwise keep fading a frozen frame under a screenshot. A
// flare is animation rather than data, so it keeps the map repainting for its 500 ms.
const feedIdle = nowMs - hot.lastSnapshotMs > 500;
const flaring = mapSurface.flaring(rawNowMs);
if (mapSurface.ready() && (mapDirty || flaring || !feedIdle) && nowMs - lastMapMs >= MAP_INTERVAL_MS) {
lastMapMs = nowMs;
mapDirty = false;
time('brainmap', () => mapSurface.draw(rawNowMs));
}
time('buttons', () => paintButtons(nowMs));
time('palette', () => paintPalette());
time('bars', () => paintBars());
paintFly(nowMs, time);
time('motion', () => director.frame(nowMs, rawNowMs, dtMs));
if (engine && hot.audioQueue.length > 0) {
const chunks = hot.audioQueue.splice(0, hot.audioQueue.length);
time('audio', () => {
for (const chunk of chunks) engine.push(chunk);
});
}
time('commit', () => ingest.commit(nowMs));
time('sfx', () => fireSfx());
});
// -- Dataset, worker, feed ----------------------------------------------------------------
let disposed = false;
const worker = new BrainBaseWorker();
let readyTimer: ReturnType<typeof setTimeout> | null = null;
const markReady = (degraded: boolean): void => {
if (disposed || root.dataset.ready === '1') return;
if (degraded) root.dataset.degraded = '1';
root.dataset.ready = '1';
};
worker.addEventListener('message', (event: MessageEvent<BrainBaseResponse>) => {
const message = event.data;
if (message.type === 'error') {
console.warn(`brain map base failed: ${message.message}`);
markReady(true);
return;
}
mapSurface.setBase(
message.bitmap,
message.lut,
message.cellClasses,
message.neuronCount,
message.pam,
message.fit,
);
// On a paused fixture there is no later snapshot to trigger a repaint, so ask for one.
mapDirty = true;
void document.fonts.ready.then(() => markReady(false));
worker.terminate();
});
const request: BrainBaseRequest = {
type: 'load',
base: DATASET_BASE,
width: MAP_HERO_WIDTH,
height: MAP_HERO_HEIGHT,
gridWidth: MAP_GRID_WIDTH,
gridHeight: MAP_GRID_HEIGHT,
colors: { sensory: palette.sensory, internal: palette.internal, output: palette.output },
};
worker.postMessage(request);
readyTimer = setTimeout(() => markReady(true), READY_TIMEOUT_MS);
void (async () => {
try {
const response = await fetch(`${DATASET_BASE}/meta.json`);
if (response.ok) {
const meta = (await response.json()) as {
neurons: number;
edges: number;
dataset: string;
visual: { count: number };
};
if (!disposed) {
useStage.getState().setDataset({
neurons: meta.neurons,
edges: meta.edges,
name: /v\d+/.exec(meta.dataset)?.[0] ?? meta.dataset,
});
}
}
} catch (error) {
console.warn(`dataset metadata unavailable: ${(error as Error).message}`);
}
try {
const [xy, hemisphere] = await Promise.all([
loadCompressed(`${DATASET_BASE}/visual-xy.binz`, (buffer) => new Float32Array(buffer)),
loadCompressed(`${DATASET_BASE}/visual-hemisphere.binz`, (buffer) => new Uint8Array(buffer)),
]);
if (!disposed) {
retinaSurface.setColumns({ xy, hemisphere, count: hemisphere.length });
// Same race as the brain map: the columns can land after the last snapshot of a seek.
if (hot.frame) retinaSurface.drawFrame(hot.frame);
}
} catch (error) {
console.warn(`retina columns unavailable: ${(error as Error).message}`);
}
})();
const player =
options.mode === 'player'
? new FixturePlayer(`/fixtures/${options.fixture}.flyfeed.gz`, ingest, {
seekSeconds: options.seekSeconds,
autoplay: options.autoplay,
loop: options.loop,
})
: null;
source = player ?? new FeedSocket(options.feedUrl, ingest);
void source.start().catch((error: unknown) => {
console.error(`feed source failed: ${(error as Error).message}`);
});
loop.start();
// -- Test and operator surface ------------------------------------------------------------
window.__stage = {
options,
game: game.id,
metrics: () => loop.metrics(),
audio: () => engine?.state() ?? null,
manifest: () => player?.manifest() ?? null,
seek: (seconds: number) => {
director.reset();
player?.seek(seconds);
},
stopFeed: () => source?.stop(),
sprites: () => mapSurface.sprites(),
health: () => ({ gaps: hot.gaps, decodeErrors: hot.decodeErrors, accepted: hot.accepted }),
state: () => useStage.getState(),
gameScale: () => gameSurface.scale(),
motion: () => director.state(),
pam: () => mapSurface.pamCentroid(),
// The brain map's own geometry and saturation. The one way to tell, from outside the page,
// whether the CONNECTOME tab's canvas, its LUT and the worker's fit still agree.
brainmap: () => mapSurface.stats(),
// The one deliberate way to drive the catalogue by hand: the moment mockups and the e2e
// moment assertions fire a trigger rather than waiting for a fixture to contain one.
fire: (type, label = '', detail = '') =>
motion.enqueue({ type, label, detail, intensity: 1, source: 'event' }),
/**
* Replace the held feed's chat ring, the same way `fire` replaces a moment: by hand, for the
* chat mockup and the wrapping assertions, because the committed fixtures were recorded off a
* live bridge and none of them happens to contain a 200-character line.
*
* Written into `hot.header`, not into the store, because the paint loop's commit rebuilds
* `chat` from the header every time it runs and a `setState` here would last 250 ms. The
* lines still go through `sanitizeChatRing` on the way to the panel, so this cannot put
* anything on screen that a real line could not.
*
* The wall time is a day old on purpose: a line older than the page's connect time is
* history rather than an arrival, so an injected name cannot pin the DESCRIBE tab
* (`src/lib/chatters.ts`) and move a screenshot that was meant to be about chat.
*/
chat: (lines) => {
const header = hot.header;
if (!header) return 0;
const wallMs = Date.now() - 86_400_000;
header.chat = lines.map((line, index) => ({
id: index + 1,
wallMs,
by: line.by,
text: line.text,
...(line.bot === true ? { bot: true } : {}),
}));
// Forced, because a fixture held on a seek target freezes the clock the commit gate reads:
// without this the panel would not see the new ring until something else opened the gate.
ingest.commit(hot.lastSnapshotMs, true);
return useStage.getState().chat.length;
},
fly: () => ({
// The renderer that is actually drawing, which is not always the one that was asked for:
// `webgl` falls back to `paper` on a host with no usable GL context (`src/fly/index.ts`).
// `requested` keeps the query parameter visible so the two can be told apart on air.
mode: fly ? fly.mode : options.fly,
requested: options.fly,
rendering: fly !== null,
gaitPhase: rig.gaitPhase,
// Extension of the proboscis in rig units, which a sugar event visibly grows.
proboscis: lastFlyFrame
? Math.hypot(
lastFlyFrame.proboscis.b[0] - lastFlyFrame.proboscis.a[0],
lastFlyFrame.proboscis.b[1] - lastFlyFrame.proboscis.a[1],
lastFlyFrame.proboscis.b[2] - lastFlyFrame.proboscis.a[2],
)
: 0,
// The tip of every leg as last drawn, so a test can check the fly is still whenever the
// clock is (`tests/e2e/behaviour.spec.ts`).
legTips: (lastFlyFrame?.legs ?? []).map((joints) => joints[3] as [number, number, number]),
}),
};
return () => {
disposed = true;
if (readyTimer !== null) clearTimeout(readyTimer);
loop.stop();
source?.stop();
fly?.dispose();
worker.terminate();
void engine?.stop();
delete root.dataset.ready;
};
// The stage is built once. Every option is a page-load-time decision by design: the capture
// launcher restarts the page to change anything.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div id="stage">
{/* Declared, and asserted by the structural test: no text or readout may land here. */}
<div data-nocontent="1" style={boxStyle(NO_CONTENT_ZONE)} aria-hidden />
<TitleStrip game={game} />
<GamePanel canvasRef={gameCanvas} />
<FlyStrip mode={options.fly} canvasRef={flyCanvas} />
<ProgressCluster game={game} />
<TabSlot game={game} retinaRef={retinaCanvas} mapRef={mapCanvas} />
<EventsTicker />
<ChatPanel source={options.chat} />
<MomentLayer particleRef={particleCanvas} />
<StaleBanner />
</div>
);
}
declare global {
interface Window {
__stage?: {
options: ReturnType<typeof stageOptions>;
game: string;
metrics: () => ReturnType<PaintLoop['metrics']>;
audio: () => ReturnType<AudioEngine['state']> | null;
manifest: () => ReturnType<FixturePlayer['manifest']> | null;
seek: (seconds: number) => void;
stopFeed: () => void;
sprites: () => number;
health: () => { gaps: number; decodeErrors: number; accepted: number };
state: () => ReturnType<typeof useStage.getState>;
gameScale: () => number;
motion: () => ReturnType<Director['state']>;
pam: () => { x: number; y: number } | null;
brainmap: () => ReturnType<BrainMapSurface['stats']>;
fire: (type: MomentType, label?: string, detail?: string) => number | null;
/** Replace the held feed's chat ring by hand; returns how many lines the panel accepted. */
chat: (lines: readonly { by: string; text: string; bot?: boolean }[]) => number;
fly: () => {
mode: string;
requested: string;
rendering: boolean;
gaitPhase: number;
proboscis: number;
legTips: [number, number, number][];
};
};
}
}

View file

@ -0,0 +1,91 @@
/**
* Ring-buffer policy and the varispeed drift servo (design A8).
*
* The emulator produces audio in 30 Hz chunks off the simulation clock; the AudioContext consumes
* it off the sound card's clock. Those two clocks are never the same, so over an hour the buffer
* either fills until it overflows or drains until it clicks. A fractional read index resampling at
* 1 ± 0.3 percent absorbs the difference inaudibly, and only a gross excursion (double the target
* fill, or an empty buffer) is corrected by dropping or inserting samples.
*
* The policy lives here, in TypeScript, and is handed to the AudioWorklet as `processorOptions`:
* the worklet applies the formula but owns none of the numbers, so there is exactly one place to
* change them and `tests/unit/drift.test.ts` can check them without an AudioContext.
*/
/** Interleaved stereo. */
export const CHANNELS = 2;
export interface DriftPolicy {
/** Target buffered audio, ms. */
targetMs: number;
/** Above this, the servo is pulling the read rate up. */
highMs: number;
/** Below this, the servo is pulling the read rate down. */
lowMs: number;
/** Maximum fractional rate deviation, e.g. 0.003 for ±0.3 percent. */
maxDrift: number;
/** Proportional gain on the normalised fill error. */
gain: number;
/** Ring capacity, ms. Must comfortably exceed `highMs`. */
capacityMs: number;
}
export const DEFAULT_DRIFT_POLICY: DriftPolicy = {
targetMs: 250,
highMs: 400,
lowMs: 120,
maxDrift: 0.003,
gain: 0.5,
capacityMs: 1500,
};
/** Frames (per channel) for a duration at a sample rate. */
export function framesFor(ms: number, sampleRate: number): number {
return Math.round((ms / 1000) * sampleRate);
}
/**
* Playback rate for the current fill level.
*
* Above the target the rate goes slightly *up* (consume faster, drain the excess); below, down.
* Clamped to `maxDrift` in both directions, which is the whole point: the correction must be
* inaudible, so it is never allowed to be fast.
*/
export function varispeedRate(fillFrames: number, policy: DriftPolicy, sampleRate: number): number {
const target = framesFor(policy.targetMs, sampleRate);
if (target <= 0) return 1;
const error = (fillFrames - target) / target;
const correction = Math.max(-policy.maxDrift, Math.min(policy.maxDrift, error * policy.gain));
return 1 + correction;
}
/** What the servo cannot fix on its own. */
export type HardCorrection = 'drop' | 'insert' | null;
/**
* Gross excursions.
*
* `drop` when the buffer holds more than twice the target (the producer ran ahead, e.g. after the
* page was throttled): discard down to the target rather than play a growing delay for the rest of
* the broadcast. `insert` when it is empty: emit silence and count an underrun.
*/
export function hardCorrection(fillFrames: number, policy: DriftPolicy, sampleRate: number): HardCorrection {
const target = framesFor(policy.targetMs, sampleRate);
if (fillFrames <= 0) return 'insert';
if (fillFrames > target * 2) return 'drop';
return null;
}
/** Frames to discard to bring an over-full buffer back to the target. */
export function dropCount(fillFrames: number, policy: DriftPolicy, sampleRate: number): number {
const target = framesFor(policy.targetMs, sampleRate);
return Math.max(0, fillFrames - target);
}
/** Where the servo currently is, for the health readout. */
export function fillZone(fillFrames: number, policy: DriftPolicy, sampleRate: number): 'low' | 'ok' | 'high' {
const ms = (fillFrames / sampleRate) * 1000;
if (ms < policy.lowMs) return 'low';
if (ms > policy.highMs) return 'high';
return 'ok';
}

View file

@ -0,0 +1,232 @@
/**
* The audio engine: game PCM from the feed through a worklet ring buffer, plus the SFX bank
* (design A8).
*
* The page is the stream's audio source: `flycast` captures the same X display and the same Pulse
* sink, so A/V sync is the browser's problem and not ffmpeg's. That makes three failure modes
* worth designing against, all of which look like success:
*
* - the AudioContext never leaves `suspended` (no `--autoplay-policy=no-user-gesture-required`)
* - the worklet module fails to load
* - the sink exists but nothing is written to it
*
* So every step is guarded and reported rather than thrown, `state()` exposes what actually
* happened, and nothing here can take the page down. In tests the context is usually suspended
* and `start()` must still resolve without throwing — `tests/unit` covers the policy maths and
* the e2e "no console errors" check covers the rest.
*/
import { AUDIO_RATE } from '@flybrain/feed';
import { CHANNELS, DEFAULT_DRIFT_POLICY, framesFor, type DriftPolicy } from './drift';
import { SFX_NAMES, renderSfxBank, type SfxName } from './sfx';
import workletUrl from './ring-worklet.js?url';
export interface AudioGains {
master: number;
game: number;
sfx: number;
}
export interface AudioEngineState {
/** `unavailable` when the browser has no Web Audio at all. */
context: AudioContextState | 'unavailable';
worklet: 'idle' | 'ready' | 'failed';
fillMs: number;
rate: number;
underruns: number;
drops: number;
pushedFrames: number;
sfxLoaded: number;
lastError: string | null;
}
interface WorkletStats {
type: 'stats';
fillFrames: number;
rate: number;
underruns: number;
drops: number;
pushed: number;
}
export class AudioEngine {
private context: AudioContext | null = null;
private node: AudioWorkletNode | null = null;
private masterGain: GainNode | null = null;
private gameGain: GainNode | null = null;
private sfxGain: GainNode | null = null;
private bank = new Map<SfxName, AudioBuffer>();
private stats: WorkletStats | null = null;
private workletState: 'idle' | 'ready' | 'failed' = 'idle';
private lastError: string | null = null;
private readonly policy: DriftPolicy;
constructor(
private gains: AudioGains,
policy: DriftPolicy = DEFAULT_DRIFT_POLICY,
) {
this.policy = policy;
}
/**
* Bring up the context, the worklet and the SFX bank.
*
* Never throws. A failure is recorded in `state().lastError` and the page keeps running silent,
* which is exactly what `flycast`'s pre-flight check is for: it refuses to go live when
* `state().context !== 'running'`.
*/
async start(): Promise<void> {
if (typeof AudioContext === 'undefined') {
this.lastError = 'this browser has no AudioContext';
return;
}
try {
// 48 kHz is the feed's rate and Web Audio's native rate on Linux, so nothing resamples.
this.context = new AudioContext({ sampleRate: AUDIO_RATE, latencyHint: 'playback' });
} catch (error) {
this.lastError = `AudioContext: ${(error as Error).message}`;
return;
}
const context = this.context;
this.masterGain = context.createGain();
this.gameGain = context.createGain();
this.sfxGain = context.createGain();
this.applyGains();
this.gameGain.connect(this.masterGain);
this.sfxGain.connect(this.masterGain);
this.masterGain.connect(context.destination);
try {
await context.audioWorklet.addModule(workletUrl);
const node = new AudioWorkletNode(context, 'ring-player', {
numberOfInputs: 0,
numberOfOutputs: 1,
outputChannelCount: [CHANNELS],
processorOptions: {
channels: CHANNELS,
capacityFrames: framesFor(this.policy.capacityMs, context.sampleRate),
targetFrames: framesFor(this.policy.targetMs, context.sampleRate),
maxDrift: this.policy.maxDrift,
gain: this.policy.gain,
reportEveryFrames: framesFor(100, context.sampleRate),
},
});
node.port.onmessage = (event: MessageEvent<WorkletStats>) => {
if (event.data?.type === 'stats') this.stats = event.data;
};
node.connect(this.gameGain);
this.node = node;
this.workletState = 'ready';
} catch (error) {
this.workletState = 'failed';
this.lastError = `audio worklet: ${(error as Error).message}`;
}
try {
this.bank = await renderSfxBank(context.sampleRate);
} catch (error) {
this.lastError = `sfx: ${(error as Error).message}`;
}
// A kiosk Chromium launched with `--autoplay-policy=no-user-gesture-required` starts running;
// anywhere else this is a no-op that leaves the context suspended, which is not an error.
try {
if (context.state === 'suspended') await context.resume();
} catch {
// Suspended is a legitimate state in a test browser. Nothing to do.
}
}
/** Queue one snapshot's PCM. Transfers the buffer, so the caller must not reuse it. */
push(chunk: Float32Array): void {
const node = this.node;
if (!node || chunk.length === 0) return;
try {
node.port.postMessage(chunk, [chunk.buffer]);
} catch {
// A detached buffer or a torn-down node: drop the chunk rather than fail the frame.
}
}
/** Drop everything buffered. Used when a fixture loops or the feed reconnects. */
flush(): void {
try {
this.node?.port.postMessage({ type: 'flush' });
} catch {
// Nothing to flush.
}
}
/**
* Fire one effect. Silent and harmless when the bank or the context is unavailable.
*
* `gain` is the per-cue level the moment catalogue's sound tier asks for
* (`src/motion/sfx-tiers.ts`), applied through its own one-shot `GainNode` under the SFX bus, so
* a tier that deliberately under-plays its sample cannot change the bus level for everything
* after it. 1 is the common case and allocates nothing extra.
*/
playSfx(name: SfxName, gain = 1): void {
const context = this.context;
const buffer = this.bank.get(name);
const target = this.sfxGain;
if (!context || !buffer || !target || context.state !== 'running') return;
try {
const source = context.createBufferSource();
source.buffer = buffer;
if (gain >= 1) {
source.connect(target);
} else {
const trim = context.createGain();
trim.gain.value = Math.max(0, gain);
source.connect(trim).connect(target);
}
source.start();
} catch {
// Same rule: a sound effect never breaks the page.
}
}
setGains(gains: AudioGains): void {
this.gains = gains;
this.applyGains();
}
state(): AudioEngineState {
const sampleRate = this.context?.sampleRate ?? AUDIO_RATE;
return {
context: this.context ? this.context.state : 'unavailable',
worklet: this.workletState,
fillMs: this.stats ? (this.stats.fillFrames / sampleRate) * 1000 : 0,
rate: this.stats?.rate ?? 1,
underruns: this.stats?.underruns ?? 0,
drops: this.stats?.drops ?? 0,
pushedFrames: this.stats?.pushed ?? 0,
sfxLoaded: SFX_NAMES.filter((name) => this.bank.has(name)).length,
lastError: this.lastError,
};
}
async stop(): Promise<void> {
try {
this.node?.port.postMessage({ type: 'close' });
this.node?.disconnect();
await this.context?.close();
} catch {
// Shutting down is best-effort.
}
this.node = null;
this.context = null;
}
private applyGains(): void {
if (this.masterGain) this.masterGain.gain.value = clamp01(this.gains.master);
if (this.gameGain) this.gameGain.gain.value = clamp01(this.gains.game);
if (this.sfxGain) this.sfxGain.gain.value = clamp01(this.gains.sfx);
}
}
function clamp01(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(1, value));
}

View file

@ -0,0 +1,150 @@
/**
* AudioWorklet processor: a ring buffer with a fractional read index (design A8).
*
* Plain JavaScript on purpose. An AudioWorklet module is loaded by URL into its own global scope,
* so it is not part of the bundle graph; keeping it as hand-written ES module JS avoids relying on
* static imports inside worklet scope, which is not a portable thing to depend on.
*
* It owns no policy: `targetFrames`, `maxDrift`, `gain` and the capacity all arrive in
* `processorOptions` from `src/audio/drift.ts`, which is where they are documented and tested.
* This file only applies them.
*
* Audio arrives as transferred `Float32Array`s over `port.postMessage` — deliberately not a
* SharedArrayBuffer, which would need COOP/COEP headers on the page, while 30 messages a second
* costs nothing.
*/
class RingPlayer extends AudioWorkletProcessor {
constructor(options) {
super();
const config = (options && options.processorOptions) || {};
this.channels = config.channels || 2;
this.capacity = Math.max(2048, config.capacityFrames || 72000);
this.targetFrames = config.targetFrames || 12000;
this.maxDrift = config.maxDrift || 0.003;
this.gain = config.gain || 0.5;
this.reportEvery = config.reportEveryFrames || 4800;
// One interleaved ring, so a chunk is a single copy in.
this.ring = new Float32Array(this.capacity * this.channels);
this.writeIndex = 0;
this.readIndex = 0; // fractional, in frames
this.fill = 0; // frames available
this.underruns = 0;
this.drops = 0;
this.pushed = 0;
this.rate = 1;
this.framesSinceReport = 0;
this.closed = false;
this.port.onmessage = (event) => {
const data = event.data;
if (data instanceof Float32Array) {
this.push(data);
return;
}
if (data && data.type === 'flush') {
this.writeIndex = 0;
this.readIndex = 0;
this.fill = 0;
return;
}
if (data && data.type === 'close') {
this.closed = true;
}
};
}
/** Append interleaved frames, dropping the oldest if the producer has run away. */
push(interleaved) {
const frames = Math.floor(interleaved.length / this.channels);
if (frames <= 0) return;
this.pushed += frames;
for (let frame = 0; frame < frames; frame++) {
const slot = (this.writeIndex % this.capacity) * this.channels;
for (let channel = 0; channel < this.channels; channel++) {
this.ring[slot + channel] = interleaved[frame * this.channels + channel];
}
this.writeIndex += 1;
}
this.fill += frames;
if (this.fill > this.capacity) {
// Hard overflow: keep the newest audio, count it, carry on.
const excess = this.fill - this.capacity;
this.readIndex += excess;
this.fill = this.capacity;
this.drops += excess;
}
}
/** One interpolated frame at the fractional read index. */
sample(channel, position) {
const base = Math.floor(position);
const fraction = position - base;
const a = this.ring[(base % this.capacity) * this.channels + channel];
const b = this.ring[((base + 1) % this.capacity) * this.channels + channel];
return a + (b - a) * fraction;
}
process(_inputs, outputs) {
const output = outputs[0];
if (!output || output.length === 0) return !this.closed;
const blockFrames = output[0].length;
// Gross excursion first: an over-full ring is trimmed to the target rather than played out.
if (this.fill > this.targetFrames * 2) {
const excess = this.fill - this.targetFrames;
this.readIndex += excess;
this.fill -= excess;
this.drops += excess;
}
// Varispeed: pull the read rate towards whatever keeps the fill at the target.
const error = this.targetFrames > 0 ? (this.fill - this.targetFrames) / this.targetFrames : 0;
const correction = Math.max(-this.maxDrift, Math.min(this.maxDrift, error * this.gain));
this.rate = 1 + correction;
// Needing more than the ring holds is an underrun: emit silence and say so.
const needed = Math.ceil(blockFrames * this.rate) + 2;
if (this.fill < needed) {
this.underruns += 1;
for (let channel = 0; channel < output.length; channel++) output[channel].fill(0);
this.report(blockFrames);
return !this.closed;
}
for (let frame = 0; frame < blockFrames; frame++) {
const position = this.readIndex + frame * this.rate;
for (let channel = 0; channel < output.length; channel++) {
output[channel][frame] = this.sample(Math.min(channel, this.channels - 1), position);
}
}
const consumed = blockFrames * this.rate;
this.readIndex += consumed;
this.fill -= consumed;
this.report(blockFrames);
return !this.closed;
}
report(blockFrames) {
this.framesSinceReport += blockFrames;
if (this.framesSinceReport < this.reportEvery) return;
this.framesSinceReport = 0;
this.port.postMessage({
type: 'stats',
fillFrames: this.fill,
rate: this.rate,
underruns: this.underruns,
drops: this.drops,
pushed: this.pushed,
});
}
}
registerProcessor('ring-player', RingPlayer);

210
apps/stage/src/audio/sfx.ts Normal file
View file

@ -0,0 +1,210 @@
/**
* The SFX bank, synthesised rather than shipped (design A8 asks for six short samples).
*
* Eight procedurally generated sounds, rendered once into `AudioBuffer`s with an
* `OfflineAudioContext` and oscillators, so the repo carries no audio assets and no third-party
* licence: a reward tick, a badge fanfare, a milestone chime, a sugar sparkle, the stuck-threshold
* tone, a feed-stale alarm, the rollback's rewind sweep and the day-rollover stinger. The last two
* are the gap `src/motion/sfx-tiers.ts` recorded as `NEEDS_SAMPLE`: its `rewind` tier was playing
* the stuck alarm and its `stinger` the milestone chime at half level, so both moments sounded
* like something they were not. They are deliberately plain shapes at modest level — this plays
* *under* game audio on a 24/7 stream, so the design goal is "noticeable once", not "musical".
*
* Every entry point is guarded: a suspended or unavailable AudioContext must never throw, because
* an exception here would take the broadcast page down for a sound effect.
*/
export type SfxName = 'reward' | 'badge' | 'milestone' | 'sugar' | 'stuck' | 'stale' | 'rewind' | 'stinger';
export const SFX_NAMES: readonly SfxName[] = [
'reward',
'badge',
'milestone',
'sugar',
'stuck',
'stale',
'rewind',
'stinger',
];
interface Voice {
/** Oscillator type. */
type: OscillatorType;
/** Frequency envelope as [timeFraction, Hz] pairs. */
freq: readonly [number, number][];
/** Gain envelope as [timeFraction, gain] pairs. */
gain: readonly [number, number][];
/** Start offset as a fraction of the sample duration. */
start?: number;
}
interface Recipe {
durationSeconds: number;
voices: readonly Voice[];
}
/**
* The recipes. Kept declarative so a sound can be retuned without touching the renderer.
*/
const RECIPES: Record<SfxName, Recipe> = {
// A soft blip: one short sine, the sound of a +0.05 exploration tick.
reward: {
durationSeconds: 0.09,
voices: [
{
type: 'sine',
freq: [
[0, 880],
[1, 1180],
],
gain: [
[0, 0],
[0.1, 0.5],
[1, 0],
],
},
],
},
// Badge: a rising triad, the loudest thing in the bank.
badge: {
durationSeconds: 0.75,
voices: [
{ type: 'triangle', freq: [[0, 523]], gain: [[0, 0], [0.05, 0.5], [0.45, 0.3], [1, 0]] },
{ type: 'triangle', freq: [[0, 659]], gain: [[0, 0], [0.05, 0.4], [0.6, 0.25], [1, 0]], start: 0.12 },
{ type: 'triangle', freq: [[0, 784]], gain: [[0, 0], [0.05, 0.4], [0.7, 0.25], [1, 0]], start: 0.26 },
{ type: 'sine', freq: [[0, 1046]], gain: [[0, 0], [0.1, 0.25], [1, 0]], start: 0.4 },
],
},
// Milestone: two notes, a step up. Quieter than a badge; it happens more often.
milestone: {
durationSeconds: 0.45,
voices: [
{ type: 'sine', freq: [[0, 587]], gain: [[0, 0], [0.08, 0.4], [1, 0]] },
{ type: 'sine', freq: [[0, 880]], gain: [[0, 0], [0.08, 0.35], [1, 0]], start: 0.2 },
],
},
// Sugar: a quick sparkle up, to go with the dopamine bar jumping.
sugar: {
durationSeconds: 0.3,
voices: [
{
type: 'sine',
freq: [
[0, 660],
[1, 1760],
],
gain: [
[0, 0],
[0.06, 0.4],
[1, 0],
],
},
{ type: 'square', freq: [[0, 220]], gain: [[0, 0], [0.05, 0.08], [0.4, 0]] },
],
},
// Stuck threshold: a low, flat, slightly ominous pair. This is the "interesting part" cue.
stuck: {
durationSeconds: 0.9,
voices: [
{ type: 'sine', freq: [[0, 196]], gain: [[0, 0], [0.15, 0.35], [0.8, 0.2], [1, 0]] },
{ type: 'sine', freq: [[0, 185]], gain: [[0, 0], [0.2, 0.25], [1, 0]], start: 0.25 },
],
},
// Rollback: the rewind sweep. A fast fall through two octaves with a second voice a beat behind
// it, which is what a tape running backwards sounds like without sampling one.
rewind: {
durationSeconds: 0.5,
voices: [
{
type: 'triangle',
freq: [
[0, 1320],
[1, 180],
],
gain: [
[0, 0],
[0.06, 0.4],
[0.7, 0.22],
[1, 0],
],
},
{
type: 'square',
freq: [
[0, 660],
[1, 120],
],
gain: [
[0, 0],
[0.08, 0.1],
[1, 0],
],
start: 0.12,
},
],
},
// Day rollover: a soft stinger. Two sine notes a fifth apart at low level — structure, not an
// achievement, so it must not sound like a rung.
stinger: {
durationSeconds: 0.55,
voices: [
{ type: 'sine', freq: [[0, 392]], gain: [[0, 0], [0.12, 0.2], [1, 0]] },
{ type: 'sine', freq: [[0, 587]], gain: [[0, 0], [0.12, 0.16], [1, 0]], start: 0.18 },
],
},
// Feed stale: a two-tone alarm, the only sound that means something is wrong.
stale: {
durationSeconds: 0.6,
voices: [
{ type: 'square', freq: [[0, 440]], gain: [[0, 0], [0.05, 0.22], [0.45, 0.22], [0.5, 0]] },
{ type: 'square', freq: [[0, 330]], gain: [[0, 0], [0.05, 0.22], [0.45, 0.22], [0.5, 0]], start: 0.5 },
],
},
};
/** Render the whole bank into buffers. Returns an empty map if Web Audio is unusable. */
export async function renderSfxBank(sampleRate: number): Promise<Map<SfxName, AudioBuffer>> {
const bank = new Map<SfxName, AudioBuffer>();
if (typeof OfflineAudioContext === 'undefined') return bank;
for (const name of SFX_NAMES) {
try {
const buffer = await renderOne(RECIPES[name], sampleRate);
bank.set(name, buffer);
} catch {
// A missing sound effect is not worth failing a broadcast over.
}
}
return bank;
}
async function renderOne(recipe: Recipe, sampleRate: number): Promise<AudioBuffer> {
const length = Math.max(1, Math.ceil(recipe.durationSeconds * sampleRate));
const offline = new OfflineAudioContext({ numberOfChannels: 2, length, sampleRate });
for (const voice of recipe.voices) {
const startAt = (voice.start ?? 0) * recipe.durationSeconds;
const span = recipe.durationSeconds - startAt;
if (span <= 0) continue;
const oscillator = offline.createOscillator();
oscillator.type = voice.type;
const gainNode = offline.createGain();
oscillator.frequency.setValueAtTime(voice.freq[0]?.[1] ?? 440, startAt);
for (const [fraction, hz] of voice.freq.slice(1)) {
oscillator.frequency.linearRampToValueAtTime(hz, startAt + fraction * span);
}
gainNode.gain.setValueAtTime(voice.gain[0]?.[1] ?? 0, startAt);
for (const [fraction, value] of voice.gain.slice(1)) {
gainNode.gain.linearRampToValueAtTime(value, startAt + fraction * span);
}
oscillator.connect(gainNode).connect(offline.destination);
oscillator.start(startAt);
oscillator.stop(recipe.durationSeconds);
}
return offline.startRendering();
}

View file

@ -0,0 +1,77 @@
/**
* Chat re-validation, on the page, at the point of render (defence in depth).
*
* The service is the authority. `docs/feed-protocol.md` says `header.chat[].text` has already been
* through the shared sanitizer and `by` through `validateDisplayName`, and
* `services/flysim/crates/flysim/src/chat.rs` enforces byte-identical rules in Rust. This module
* runs **the same shared implementation again** — `sanitizeChatText` from `@flybrain/feed`, not a
* local copy of its rules — and drops anything that fails.
*
* Why bother, when the service already did it: this is the only text on a 24/7 broadcast that
* originates with a stranger. The Nothing, Forever precedent (a 14-day ban for generated text) is
* about what reaches the frame, not about whose bug let it through, and a page that re-validates
* cannot be made to render a slur by a service regression, a replayed fixture, or a future
* transport nobody has written yet. Calling the shared function rather than reimplementing it is
* what makes the second check free of the usual cost of defence in depth — there is no second set
* of rules to drift.
*
* Drop, never repair: a line that fails is not truncated or masked, it is not shown. A missing
* line is invisible; a half-cleaned one is a liability. The one thing this module accepts from the
* sanitizer is its *cleaning* (NFC, folded whitespace), because that is what the service already
* put in the header.
*
* Pure, so `tests/unit/chat-render.test.ts` can drive the whole table.
*/
import { CHAT_MAX_TEXT_LENGTH, sanitizeChatText, validateDisplayName, type ChatLine } from '@flybrain/feed';
export { CHAT_MAX_TEXT_LENGTH };
/** True when a display name is one the page will render, by the bridge's own rule. */
export function isSafeChatName(name: unknown): name is string {
return typeof name === 'string' && validateDisplayName(name) === name;
}
/** The line's text as it will be rendered, or null when any rule refuses it. */
export function safeChatText(text: unknown): string | null {
return sanitizeChatText(text);
}
/**
* Re-validate one line. Returns a fresh object with only the fields the panel renders, or null.
*
* A fresh object, not the input: whatever else the service may have put on that line, nothing
* beyond `id`, `wallMs`, `by`, `text` and `bot` can reach a component from here.
*/
export function sanitizeChatLine(line: unknown): ChatLine | null {
if (typeof line !== 'object' || line === null) return null;
const candidate = line as Partial<ChatLine>;
if (typeof candidate.id !== 'number' || !Number.isFinite(candidate.id)) return null;
if (!isSafeChatName(candidate.by)) return null;
const text = safeChatText(candidate.text);
if (text === null) return null;
return {
id: candidate.id,
wallMs: typeof candidate.wallMs === 'number' && Number.isFinite(candidate.wallMs) ? candidate.wallMs : 0,
by: candidate.by,
text,
...(candidate.bot === true ? { bot: true } : {}),
};
}
/**
* The last `keep` renderable lines of a chat ring, oldest first.
*
* Oldest first because that is the reading order on screen and the newest line is the one that
* slides in at the bottom. The header already arrives oldest-first; sorting by id rather than
* trusting the order costs nothing and makes the panel independent of that promise.
*/
export function sanitizeChatRing(lines: unknown, keep: number): ChatLine[] {
if (!Array.isArray(lines)) return [];
const out: ChatLine[] = [];
for (const line of lines) {
const safe = sanitizeChatLine(line);
if (safe) out.push(safe);
}
out.sort((a, b) => a.id - b.id);
return out.length > keep ? out.slice(out.length - keep) : out;
}

View file

@ -0,0 +1,7 @@
/**
* `ChatLine`, from the protocol.
*
* This file declared the shape locally while `header.chat` was in flight on another branch; the
* field has landed, so it is a re-export and the page has one definition of a chat line again.
*/
export type { ChatLine } from '@flybrain/feed';

View file

@ -0,0 +1,48 @@
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* A chip.
*
* `docs/design/gameboy-theme.md`: "Chips (buttons, mode) are boxes with the same double frame,
* 4 px corner cut instead of radius." `.chip-cut` (`src/theme/panels.css`) carries the cut and both
* lines of the frame; the frame is 2 px + 2 px rather than the panels' 4 + 2, because the title
* strip is 40 px and 27 px of type inside a 6 px frame does not fit in it.
*
* No `font-semibold`: neither face on the page has a bold, and a synthetic one at the body floor is
* a smear. A chip that needs emphasis takes the accent border and the accent ink, which every
* variant but `secondary` already does.
*/
const badgeVariants = cva(
'chip-cut inline-flex items-center justify-center gap-1 px-2 py-0.5 whitespace-nowrap uppercase tracking-[0.06em]',
{
variants: {
variant: {
default: 'border-accent bg-transparent text-accent',
secondary: 'border-bezel bg-bg-2 text-ink-1',
warn: 'border-warn bg-transparent text-warn',
alarm: 'border-alarm bg-alarm text-bg-0',
ok: 'border-ok bg-transparent text-ok',
outline: 'border-ink-2 bg-transparent text-ink-1',
},
},
defaultVariants: {
variant: 'default',
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'span';
return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

View file

@ -0,0 +1,35 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn('flex flex-col rounded-[var(--radius)] border-[length:var(--border-w)] border-bezel bg-panel', className)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-header" className={cn('flex flex-col gap-1', className)} {...props} />;
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-title" className={cn('leading-none font-semibold', className)} {...props} />;
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-description" className={cn('text-ink-2', className)} {...props} />;
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-content" className={cn('', className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-footer" className={cn('flex items-center', className)} {...props} />;
}
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View file

@ -0,0 +1,36 @@
import * as ProgressPrimitive from '@radix-ui/react-progress';
import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* shadcn/ui Progress. The indicator animates with `transform: translateX`, which stays on the
* compositor — the one requirement the broadcast page puts on it (design A3: bars never touch
* layout, and nothing animated carries a shadow or a blur).
*/
function Progress({
className,
value,
indicatorClassName,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root> & { indicatorClassName?: string }) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn('relative w-full overflow-hidden rounded-[3px] bg-bg-0', className)}
value={value}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn('h-full w-full flex-1 bg-accent transition-transform', indicatorClassName)}
style={{
transform: `translateX(-${100 - (value ?? 0)}%)`,
transitionDuration: 'var(--bar-ms)',
}}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View file

@ -0,0 +1,31 @@
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* shadcn/ui Separator, thickened to 2 px: x264 at 3000 kbps erases 1 px hairlines (the audit
* finding), so the broadcast page has no hairlines anywhere.
*/
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-bezel',
orientation === 'horizontal' ? 'h-[2px] w-full' : 'h-full w-[2px]',
className,
)}
{...props}
/>
);
}
export { Separator };

View file

@ -0,0 +1,85 @@
/**
* Thin adapter over `@flybrain/feed`'s `decodeSnapshot`: one wire message in, typed views out.
*
* Two things this layer exists for, both of them sharp edges:
*
* 1. **Alignment.** The wire format is `u32 headerLength | header JSON | attachments...`, and the
* header JSON length is arbitrary, so an attachment's byte offset is arbitrary too. A
* `Float32Array` view needs a 4-byte-aligned offset, so the audio attachment is copied when it
* is not aligned. Skipping this check gives you a `RangeError` at some random snapshot hours
* into a broadcast.
* 2. **Shape.** A snapshot whose `frame` is not 160x144 RGBA, or whose `spikes` bitset is not the
* dataset's size, is dropped as a decode error rather than painted as garbage. The page shows
* the stale banner instead, which is the honest failure.
*/
import { FRAME_HEIGHT, FRAME_WIDTH, decodeSnapshot, type FeedHeader } from '@flybrain/feed';
/** Bytes in a `frame` attachment: 160 x 144 RGBA. */
export const FRAME_BYTES = FRAME_WIDTH * FRAME_HEIGHT * 4;
/** One decoded snapshot: the header plus whichever attachments came with it. */
export interface DecodedSnapshot {
header: FeedHeader;
/** 160x144 RGBA, or null when the snapshot carried no frame. */
frame: Uint8Array | null;
/** Interleaved stereo f32 at 48 kHz, or null. Always a copy, so it can be transferred. */
audio: Float32Array | null;
/** Spike bitset, bit `i` set when neuron `i` fired, or null. A zero-copy view. */
spikes: Uint8Array | null;
}
/** Thrown when a message decodes but does not describe a snapshot this page can paint. */
export class SnapshotShapeError extends Error {
constructor(message: string) {
super(message);
this.name = 'SnapshotShapeError';
}
}
/**
* Decode one binary feed message.
*
* `expectedSpikeBytes` is `ceil(neurons / 8)` once the dataset is known; pass 0 before then to
* accept any size (the page paints the game and the readouts long before the brain map's worker
* has finished loading positions).
*/
export function decodeFeedMessage(bytes: Uint8Array, expectedSpikeBytes = 0): DecodedSnapshot {
const { header, attachments } = decodeSnapshot(bytes);
const frameBytes = attachments.get('frame') ?? null;
if (frameBytes && frameBytes.byteLength !== FRAME_BYTES) {
throw new SnapshotShapeError(
`frame attachment is ${frameBytes.byteLength} bytes, expected ${FRAME_BYTES} (${FRAME_WIDTH}x${FRAME_HEIGHT} RGBA)`,
);
}
const spikeBytes = attachments.get('spikes') ?? null;
if (spikeBytes && expectedSpikeBytes > 0 && spikeBytes.byteLength !== expectedSpikeBytes) {
throw new SnapshotShapeError(
`spikes bitset is ${spikeBytes.byteLength} bytes, expected ${expectedSpikeBytes}`,
);
}
return {
header,
frame: frameBytes,
audio: toFloat32(attachments.get('audio')),
spikes: spikeBytes,
};
}
/**
* Copy an audio attachment into a `Float32Array`.
*
* Always a copy: the caller transfers it to the AudioWorklet, and a view into a shared message
* buffer cannot be transferred without taking the frame and spikes with it.
*/
function toFloat32(bytes: Uint8Array | undefined): Float32Array | null {
if (!bytes) return null;
if (bytes.byteLength % 4 !== 0) {
throw new SnapshotShapeError(`audio attachment is ${bytes.byteLength} bytes, not a whole number of f32 samples`);
}
const copy = new Float32Array(bytes.byteLength / 4);
new Uint8Array(copy.buffer).set(bytes);
return copy;
}

View file

@ -0,0 +1,238 @@
/**
* Player mode: replay a recorded `.flyfeed` with no service running (design A7).
*
* The timeline comes from the recording itself — every snapshot header carries `wallMs` — so one
* affine map does all the work:
*
* clock(record) = baseClock + (record.wallMs - firstRecord.wallMs)
*
* Seeking to `?t=95` is then not a special case: set `baseClock = now - 95_000` and pump. Every
* snapshot up to that point is ingested against its own virtual clock value, so the ticker's
* dwell timers, the button afterglow and the moment overlay all end up in exactly the state they
* would have been in had the page watched those 95 seconds live. Intermediate snapshots are
* ingested `silent`, so the catch-up neither paints 2,850 frames nor queues 95 seconds of audio.
*
* The same catch-up path covers a slow frame during normal playback, which is why there is no
* separate "we fell behind" branch.
*/
import { isGzip, iterateFlyfeedRecords, readFlyfeedManifest, type FlyfeedManifest } from '@flybrain/feed';
import { decodeFeedMessage, type DecodedSnapshot } from './decode';
import type { FeedIngest } from './store';
import type { FeedSource } from './source';
export interface FixturePlayerOptions {
/** Seconds to seek to before the first paint. */
seekSeconds?: number | null;
/** False to hold on the seek target instead of playing (what a screenshot wants). */
autoplay?: boolean;
/** Restart from the beginning when the recording ends. */
loop?: boolean;
/** Bytes of the spikes bitset to require, or 0 to accept any. */
expectedSpikeBytes?: number;
}
/**
* Slack on the "is this snapshot due yet?" comparison.
*
* `baseClock = nowMs - seek` and then `dueAt = baseClock + seek` is not exactly `nowMs` in
* IEEE 754, so without slack a seek lands on snapshot 2850 or 2851 depending on the fractional
* part of `performance.now()` — which makes a screenshot non-deterministic in a way that is
* almost impossible to see. Half a millisecond is nothing against a 33 ms frame.
*/
const DUE_EPSILON_MS = 0.5;
export class FixturePlayer implements FeedSource {
private readonly url: string;
private readonly ingest: FeedIngest;
private readonly options: Required<FixturePlayerOptions>;
private messages: Uint8Array[] = [];
private manifestValue: FlyfeedManifest | null = null;
private index = 0;
private firstWallMs = 0;
private baseClock = 0;
private paused = false;
/** Virtual clock of the last ingested snapshot, used while paused. */
private frozenClock: number | null = null;
/** Decoded one record ahead, so `pump` can ask when the next snapshot is due. */
private next: DecodedSnapshot | null = null;
private started = false;
constructor(url: string, ingest: FeedIngest, options: FixturePlayerOptions = {}) {
this.url = url;
this.ingest = ingest;
this.options = {
seekSeconds: options.seekSeconds ?? null,
autoplay: options.autoplay ?? true,
loop: options.loop ?? true,
expectedSpikeBytes: options.expectedSpikeBytes ?? 0,
};
}
/** The recording's manifest, once loaded. */
manifest(): FlyfeedManifest | null {
return this.manifestValue;
}
/** Length of the recording in ms, from the manifest. */
durationMs(): number {
return this.manifestValue?.durationMs ?? 0;
}
async start(): Promise<void> {
const bytes = await fetchFixture(this.url);
const { manifest, bodyOffset } = readFlyfeedManifest(bytes);
this.manifestValue = manifest;
this.messages = [...iterateFlyfeedRecords(bytes, bodyOffset)];
if (this.messages.length === 0) throw new Error(`fixture ${this.url} holds no snapshots`);
const first = this.firstDecodable();
if (first === null) {
throw new Error(`fixture ${this.url} holds no snapshot this page can decode`);
}
this.firstWallMs = first;
this.started = true;
this.rewind(performance.now());
}
/**
* The clock to use while held on a seek target: the virtual time of the last snapshot
* ingested, so nothing that depends on elapsed time keeps moving under a screenshot.
*/
clock(nowMs: number): number {
return this.paused && this.frozenClock !== null ? this.frozenClock : nowMs;
}
/** True while held on a seek target (`?t=` without `&play=1`). */
isPaused(): boolean {
return this.paused;
}
pump(nowMs: number): void {
if (!this.started || this.paused) return;
// Catch up: ingest everything due, painting and sounding only the last of a burst. Each
// record is decoded exactly once, one ahead, so "is the next one also due?" is free.
for (let guard = 0; guard < 200_000; guard++) {
if (!this.next) {
this.next = this.advance();
if (!this.next) {
// End of the recording. Looping restarts from the top *without* re-applying the seek
// target: a `?t=` past the end would otherwise restart, run out, and restart again
// forever (it did, until this test).
if (!this.options.loop) return;
this.restart(nowMs);
this.next = this.advance();
if (!this.next) return;
}
}
const dueAt = this.dueAt(this.next);
if (dueAt > nowMs + DUE_EPSILON_MS) return;
const snapshot = this.next;
this.next = this.advance();
const followerDue = this.next !== null && this.dueAt(this.next) <= nowMs + DUE_EPSILON_MS;
this.frozenClock = dueAt;
this.ingest.ingest(snapshot, dueAt, { silent: followerDue });
}
}
private dueAt(snapshot: DecodedSnapshot): number {
return this.baseClock + (snapshot.header.wallMs - this.firstWallMs);
}
/** Decode forward to the next usable record, or null at the end of the recording. */
private advance(): DecodedSnapshot | null {
while (this.index < this.messages.length) {
const decoded = this.decodeAt(this.index);
this.index += 1;
if (decoded) return decoded;
}
return null;
}
stop(): void {
this.started = false;
this.messages = [];
this.next = null;
}
/** Jump to `seconds` into the recording, then hold or play per `autoplay`. */
seek(seconds: number, nowMs = performance.now()): void {
this.restart(nowMs - seconds * 1000);
this.paused = false;
this.pump(nowMs);
this.paused = !this.options.autoplay;
}
/** Rewind to the top of the recording. Does not pump. */
private restart(baseClock: number): void {
this.index = 0;
this.next = null;
this.frozenClock = null;
this.ingest.reset();
this.baseClock = baseClock;
}
private rewind(nowMs: number): void {
const seek = this.options.seekSeconds;
if (seek !== null && seek > 0) {
this.seek(seek, nowMs);
return;
}
this.restart(nowMs);
this.paused = !this.options.autoplay;
}
/**
* `wallMs` of the first record this page can decode, or null if there is none.
*
* Scanned rather than assumed: a recording of a real service can start with a snapshot this
* build rejects, and the timeline has to come from a record that actually decodes.
*/
private firstDecodable(): number | null {
for (let index = 0; index < this.messages.length; index++) {
const decoded = this.decodeAt(index);
if (decoded) return decoded.header.wallMs;
}
return null;
}
/**
* Decode one record, counting and skipping a bad one.
*
* A fixture is a recording of a real service, so it can contain a message the current page
* rejects (a dataset with a different neuron count, say). Skipping keeps the recording playable
* and the count visible in the honesty panel.
*/
private decodeAt(index: number): DecodedSnapshot | null {
const message = this.messages[index];
if (!message) return null;
try {
return decodeFeedMessage(message, this.options.expectedSpikeBytes);
} catch {
this.ingest.noteDecodeError();
return null;
}
}
}
/**
* Fetch a `.flyfeed` or `.flyfeed.gz`.
*
* The `.gz` path inflates with `DecompressionStream`, the same way `loadCompressed` handles the
* dataset's `.binz` artifacts, and for the same reason: the server must serve the bytes raw
* without `content-encoding`, so the page controls when inflation happens.
*/
export async function fetchFixture(url: string): Promise<Uint8Array> {
const response = await fetch(url);
if (!response.ok) throw new Error(`unable to load fixture ${url}: HTTP ${response.status}`);
const raw = new Uint8Array(await response.arrayBuffer());
if (!isGzip(raw)) return raw;
const stream = new Blob([raw as BlobPart]).stream().pipeThrough(new DecompressionStream('gzip'));
return new Uint8Array(await new Response(stream).arrayBuffer());
}

View file

@ -0,0 +1,135 @@
/**
* Live mode: the real WebSocket client (design A6).
*
* Implemented and unit-tested now, but only reachable with `?mode=live`, because S3 (wiring the
* page to a running flysim) waits on the theme being chosen from the mockups. What it does:
*
* - sends exactly one message, ever: the `hello` with its `wants` list. The page is a display.
* - reconnects with exponential backoff and jitter, forever, because a 24/7 stream outlives
* every service restart underneath it.
* - counts, rather than hides, the things that go wrong: a decode failure increments a counter
* the honesty panel shows, and 2 s of silence raises the STALE FEED banner (the banner is
* driven by the store's clock, so it appears whether the socket noticed or not — a half-open
* TCP connection is exactly the case where the socket does *not* notice).
*/
import { FEED_PROTOCOL, type AttachmentKind, type ClientHello } from '@flybrain/feed';
import { decodeFeedMessage } from './decode';
import type { FeedIngest } from './store';
import type { FeedSource } from './source';
import { useStage } from './store';
export interface FeedSocketOptions {
wants?: AttachmentKind[];
/** First retry delay. Doubles per attempt up to `maxBackoffMs`. */
backoffMs?: number;
maxBackoffMs?: number;
expectedSpikeBytes?: number;
/** Injected for tests; defaults to the platform `WebSocket`. */
factory?: (url: string) => WebSocket;
}
export class FeedSocket implements FeedSource {
private readonly url: string;
private readonly ingest: FeedIngest;
private readonly wants: AttachmentKind[];
private readonly backoffMs: number;
private readonly maxBackoffMs: number;
private readonly expectedSpikeBytes: number;
private readonly factory: (url: string) => WebSocket;
private socket: WebSocket | null = null;
private retries = 0;
private timer: ReturnType<typeof setTimeout> | null = null;
private closed = false;
constructor(url: string, ingest: FeedIngest, options: FeedSocketOptions = {}) {
this.url = url;
this.ingest = ingest;
this.wants = options.wants ?? ['frame', 'audio', 'spikes'];
this.backoffMs = options.backoffMs ?? 250;
this.maxBackoffMs = options.maxBackoffMs ?? 5000;
this.expectedSpikeBytes = options.expectedSpikeBytes ?? 0;
this.factory = options.factory ?? ((target) => new WebSocket(target));
}
async start(): Promise<void> {
this.closed = false;
this.open();
}
/** Nothing to pump: messages arrive by event. Kept so both sources share one interface. */
pump(): void {}
stop(): void {
this.closed = true;
if (this.timer !== null) clearTimeout(this.timer);
this.timer = null;
const socket = this.socket;
this.socket = null;
if (socket) {
socket.onopen = null;
socket.onmessage = null;
socket.onclose = null;
socket.onerror = null;
socket.close();
}
}
/** Delay before retry `attempt` (0-based), with jitter so restarts do not synchronise. */
backoffFor(attempt: number): number {
const base = Math.min(this.maxBackoffMs, this.backoffMs * 2 ** attempt);
return Math.round(base * (0.75 + Math.random() * 0.5));
}
private open(): void {
if (this.closed) return;
useStage.getState().setConnection('connecting');
let socket: WebSocket;
try {
socket = this.factory(this.url);
} catch {
this.scheduleReconnect();
return;
}
socket.binaryType = 'arraybuffer';
this.socket = socket;
socket.onopen = () => {
this.retries = 0;
useStage.getState().setConnection('open');
const hello: ClientHello = { protocol: FEED_PROTOCOL as 1, client: 'stage', wants: this.wants };
socket.send(JSON.stringify(hello));
};
socket.onmessage = (event: MessageEvent<unknown>) => {
const data = event.data;
if (!(data instanceof ArrayBuffer)) return;
try {
this.ingest.ingest(decodeFeedMessage(new Uint8Array(data), this.expectedSpikeBytes), performance.now());
} catch {
this.ingest.noteDecodeError();
}
};
socket.onerror = () => {
// `onclose` always follows, and that is where the retry lives.
};
socket.onclose = () => {
if (this.socket === socket) this.socket = null;
useStage.getState().setConnection('closed');
this.scheduleReconnect();
};
}
private scheduleReconnect(): void {
if (this.closed) return;
const delay = this.backoffFor(this.retries);
this.retries += 1;
this.timer = setTimeout(() => {
this.timer = null;
this.open();
}, delay);
}
}

View file

@ -0,0 +1,26 @@
/**
* What the page needs from a feed, whichever end it comes from.
*
* Both implementations are pumped by the one rAF loop rather than owning a timer, so the whole
* page runs on a single clock: seeking a fixture, catching up after a slow frame and painting all
* read the same `nowMs`.
*/
export interface FeedSource {
/** Open the socket or load the fixture. Resolves once the first snapshot could arrive. */
start(): Promise<void>;
/** Called once per animation frame with the paint clock. */
pump(nowMs: number): void;
/** Close everything. Safe to call twice. */
stop(): void;
/**
* The clock the rest of the page should use this frame.
*
* Normally `nowMs`. A fixture held on a seek target (`?t=` without `&play=1`) returns the
* virtual time of the snapshot it stopped on, which freezes the ticker's dwell timers, the
* button afterglow, the moment overlay and the stale check — everything whose state is a
* function of elapsed time. That is what makes a screenshot of a seek reproducible instead of
* depending on how long the test took to get around to taking it.
*/
clock?(nowMs: number): number;
}

View file

@ -0,0 +1,514 @@
/**
* The decoupling that makes a 30 Hz feed paintable at 60 Hz without React in the way (design A6).
*
* Three clocks, on purpose:
* - **ingest, 30 Hz.** `ingest()` writes typed arrays and scalars into `hot`, a plain mutable
* object with no subscribers. Nothing re-renders.
* - **paint, 60 Hz.** The rAF loop reads `hot` directly and repaints only dirty surfaces.
* - **React, 4 Hz.** `commit()` copies the handful of values the DOM shows into a zustand store,
* coalesced to 250 ms, plus immediate pushes for the ticker and moment overlay, which are
* event-driven and must not wait for the next commit tick.
*
* Every method takes `nowMs` rather than calling `performance.now()`, because the fixture player
* seeks by replaying the recording against virtual time: with an injected clock, a seek to t=95
* leaves the ticker, the afterglow and the moment overlay in exactly the state they would have
* been in had the page watched those 95 seconds live.
*/
import { GAMEBOY_BUTTONS, GAMEBOY_BUTTON_BITS } from '@flybrain/brain';
import {
paletteView,
type FeedHeader,
type FeedMilestone,
type FeedStatus,
type GameMode,
type GameScene,
type MacroMode,
type PaletteCell,
type RewardKind,
} from '@flybrain/feed';
import { create } from 'zustand';
import { sanitizeChatRing } from '@/chat/sanitize';
import type { ChatLine } from '@/chat/types';
import type { GameConfig } from '@/games';
import { CircuitScale, RunningMedian } from '@/lib/circuit-scale';
import { CHAT_LINES } from '@/lib/geometry';
import { BAR_ROLES, CIRCUIT_GROUPS, MACRO_BAR_ROLES, MACRO_CIRCUIT } from '@/lib/labels';
import { TickerQueue, type TickerItem } from '@/lib/ticker';
import type { MotionEngine } from '@/motion/engine';
import type { MomentSnapshot } from '@/motion/moments';
import type { RailSignals } from '@/motion/rail-signals';
import type { DecodedSnapshot } from './decode';
/** Silence after which the page stops claiming the numbers are live (design A6). */
export const STALE_AFTER_MS = 2000;
/** Cold-store commit interval. */
export const COMMIT_MS = 250;
/** Afterglow after the falling edge of a button (design A3). */
export const AFTERGLOW_MS = 250;
/** Peak-hold decay on a circuit bar. */
export const PEAK_HOLD_MS = 600;
/** Per-button edge state the paint loop turns into an afterglow class. */
export interface ButtonState {
/** True while the mask bit is set. */
down: boolean;
/** Clock value of the last rising edge, or -Infinity. */
downAtMs: number;
/** Clock value of the last falling edge, or -Infinity. */
upAtMs: number;
}
/**
* The hot store. Mutable, unobserved, read by the paint loop every frame.
*
* Typed arrays here are views into the last message (or, for audio, owned copies), so nothing is
* allocated per snapshot beyond the audio chunk.
*/
export interface HotStore {
header: FeedHeader | null;
/** Clock value of the last accepted snapshot; drives the stale banner. */
lastSnapshotMs: number;
/** Monotonic count of accepted snapshots, for the dropped-frame check. */
accepted: number;
/** Snapshots whose `seq` skipped, i.e. the service dropped them rather than queueing. */
gaps: number;
/** Decode failures since load. Surfaced in the honesty panel rather than hidden. */
decodeErrors: number;
frame: Uint8Array | null;
frameDirty: boolean;
spikes: Uint8Array | null;
spikesDirty: boolean;
buttons: number;
buttonStates: Record<string, ButtonState>;
rates: Record<string, number>;
/** Peak-hold value per role, decayed by the paint loop. */
peaks: Record<string, { value: number; atMs: number }>;
/**
* Per-role adaptive reference (Hz) each circuit bar's fill scales against — see
* `src/lib/circuit-scale.ts`. Updated once per snapshot, not once per animation frame, so a
* fixture's silent seek catch-up (`src/feed/fixture.ts`) builds the same reference a viewer
* watching live would have settled into.
*/
circuitReferenceHz: Record<string, number>;
/**
* Per-role running median (Hz): the display's stand-in "resting level" for the threshold tick,
* since the feed does not carry the decoder's real calibration baseline (`docs/readout.md`).
*/
circuitMedianHz: Record<string, number>;
populationRate: number;
/**
* The same adaptive reference, for the whole-brain rate: the fly's breathing and wing tremor
* scale against it rather than against a hard-coded Hz (`docs/design/fly-avatar.md`).
*/
populationReferenceHz: number;
/** Last reported spike count from a snapshot that actually carried the bitset. */
spikeCount: number;
/** Audio chunks waiting for the engine. Drained, not accumulated. */
audioQueue: Float32Array[];
}
function freshButtonStates(): Record<string, ButtonState> {
const states: Record<string, ButtonState> = {};
for (const button of GAMEBOY_BUTTONS) {
states[button] = { down: false, downAtMs: Number.NEGATIVE_INFINITY, upAtMs: Number.NEGATIVE_INFINITY };
}
return states;
}
/** Seed Hz per bar role, from the group table (`src/lib/labels.ts`), for the adaptive trackers. */
const CIRCUIT_SEED_HZ = new Map<string, number>();
for (const group of CIRCUIT_GROUPS) {
for (const bar of group.bars) CIRCUIT_SEED_HZ.set(bar.role, group.fullScaleHz);
}
// All 31 macro channels are tracked too, though only the scene's bound ones have a rate bar at any
// moment (`docs/design/macros.md` section 12): a channel the scene binds again a minute later has
// to come back with the reference it had, not with a cold one.
for (const role of MACRO_BAR_ROLES) CIRCUIT_SEED_HZ.set(role, MACRO_CIRCUIT.fullScaleHz);
/** Every role with an adaptive reference: the fixed bars plus the macro channels. */
const SCALED_ROLES: readonly string[] = [...BAR_ROLES, ...MACRO_BAR_ROLES];
function freshCircuitScales(): Map<string, CircuitScale> {
const scales = new Map<string, CircuitScale>();
for (const role of SCALED_ROLES) scales.set(role, new CircuitScale(CIRCUIT_SEED_HZ.get(role) ?? 10));
return scales;
}
function freshCircuitMedians(): Map<string, RunningMedian> {
const medians = new Map<string, RunningMedian>();
for (const role of SCALED_ROLES) medians.set(role, new RunningMedian(CIRCUIT_SEED_HZ.get(role) ?? 10));
return medians;
}
function freshCircuitReferenceHz(): Record<string, number> {
const out: Record<string, number> = {};
for (const role of SCALED_ROLES) out[role] = CIRCUIT_SEED_HZ.get(role) ?? 10;
return out;
}
/** Seed for the whole-brain rate's own envelope. A resting fly brain sits in this neighbourhood. */
const POPULATION_SEED_HZ = 10;
/** Module-singleton per-role trackers. Not part of `HotStore` itself: the paint loop only ever
* reads the plain Hz numbers those trackers publish into `hot.circuitReferenceHz` /
* `circuitMedianHz`, never the tracker instances. */
let circuitScales = freshCircuitScales();
let circuitMedians = freshCircuitMedians();
let populationScale = new CircuitScale(POPULATION_SEED_HZ);
/** The one hot store instance. Deliberately a module singleton: there is one stage per page. */
export const hot: HotStore = {
header: null,
lastSnapshotMs: Number.NEGATIVE_INFINITY,
accepted: 0,
gaps: 0,
decodeErrors: 0,
frame: null,
frameDirty: false,
spikes: null,
spikesDirty: false,
buttons: 0,
buttonStates: freshButtonStates(),
rates: {},
peaks: {},
circuitReferenceHz: freshCircuitReferenceHz(),
circuitMedianHz: freshCircuitReferenceHz(),
populationRate: 0,
populationReferenceHz: POPULATION_SEED_HZ,
spikeCount: 0,
audioQueue: [],
};
/** What React renders. Nothing here changes more than 4 times a second except ticker/moment. */
export interface ColdState {
connection: 'idle' | 'connecting' | 'open' | 'closed';
status: FeedStatus;
stale: boolean;
mode: GameMode;
realtimeFactor: number;
runSeconds: number;
uptimeSeconds: number;
populationRate: number;
spikeCount: number;
badges: number;
uniqueLocations: number;
rewardTotal: number;
rewardCounts: Record<RewardKind, number> | null;
semanticRewards: boolean;
/**
* The scene's macros, as `paletteView` normalizes them (`docs/design/macros.md` sections 6,
* 12 and 14): one cell per macro type in the contract's order, bound or not.
*
* Here rather than in `hot` because it is React's to draw: the rows change once per scene, not
* once per snapshot, and the SENSES panel's MACROS row is drawn from the same list. What the
* *paint loop* needs — which cell is lit, what its outcome was, and each channel's rate — it
* reads straight off `hot`, the same split the button row's afterglow uses.
*/
scene: GameScene;
macroMode: MacroMode;
palette: readonly PaletteCell[];
learning: { enabled: boolean; updates: number; changed: number; synapses: number; signal: number };
/** The header's milestone object verbatim, so a protocol field cannot go missing here. */
milestone: FeedMilestone;
sugar: { active: boolean; remainingMs: number; cooldownMs: number; lastBy: string | null; todayCount: number };
ticker: readonly TickerItem[];
/** The moment on stage, as the caption band renders it (`src/motion/moments.ts`). */
moment: MomentSnapshot['active'];
/**
* The last seven chat lines, re-validated on render.
*
* Empty both when the feed carries no `chat` (an older service, or the kill switch) and when
* every line it carried failed re-validation, and the panel renders nothing in either case —
* which is the same on-screen outcome and deliberately indistinguishable.
*/
chat: readonly ChatLine[];
/** Feed gaps and decode errors, shown in the honesty panel. */
health: { gaps: number; decodeErrors: number };
/**
* Set once the dataset metadata has loaded.
*
* `neurons` and `edges` are `meta.json`'s own counts and `name` its version (`v783`). The
* DESCRIBE tab quotes all three (`src/panels/tabs/DescribeTab.tsx`), which is why `edges` is
* here: the doc's rule is that a number on that tab comes from the dataset rather than from a
* sentence someone typed, so the card cannot outlive a connectome rebuild.
*/
dataset: { neurons: number; edges: number; name: string } | null;
setConnection: (connection: ColdState['connection']) => void;
setDataset: (dataset: { neurons: number; edges: number; name: string }) => void;
}
const INITIAL_COLD = {
connection: 'idle',
status: 'booting',
stale: false,
mode: 'BOOT',
realtimeFactor: 0,
runSeconds: 0,
uptimeSeconds: 0,
populationRate: 0,
spikeCount: 0,
badges: 0,
uniqueLocations: 0,
rewardTotal: 0,
rewardCounts: null,
semanticRewards: true,
scene: 'unknown',
macroMode: 'raw',
palette: paletteView(null).cells,
learning: { enabled: false, updates: 0, changed: 0, synapses: 0, signal: 0 },
milestone: { rank: 0, label: '', next: '', sinceSeconds: 0, attempts: 0 } as FeedMilestone,
sugar: { active: false, remainingMs: 0, cooldownMs: 0, lastBy: null, todayCount: 0 },
ticker: [] as readonly TickerItem[],
moment: null as MomentSnapshot['active'],
chat: [] as readonly ChatLine[],
health: { gaps: 0, decodeErrors: 0 },
dataset: null,
} satisfies Omit<ColdState, 'setConnection' | 'setDataset'>;
export const useStage = create<ColdState>()((set) => ({
...INITIAL_COLD,
setConnection: (connection) => set({ connection }),
setDataset: (dataset) => set({ dataset }),
}));
/**
* Ingest and commit. One instance, created by `App` once the game config is known.
*
* `silent` ingestion is what a seek uses: state advances, the ticker and moment overlay evolve,
* but audio is not queued (nobody wants 95 seconds of fast-forwarded sound) and no surface is
* marked dirty until the last snapshot of the seek.
*/
export class FeedIngest {
private readonly ticker: TickerQueue;
private lastSeq = -1;
private lastCommitMs = Number.NEGATIVE_INFINITY;
private lastTickerVersion = -1;
private lastMomentId = 0;
private lastCircuitMedianMs: number | null = null;
/**
* The motion engine and the rail's derived signals, if they are wired.
*
* Both optional so the store stays testable on its own, and a seam so every decision about
* *what a moment means* lives in `src/motion/` rather than in the ingest path: this file hands
* over the snapshot and its clock and asks nothing else.
*/
constructor(
game: GameConfig,
private readonly motion: MotionEngine | null = null,
private readonly signals: RailSignals | null = null,
) {
this.ticker = new TickerQueue(game);
}
/** Reset every derived piece of state. Used when a fixture seeks or loops. */
reset(): void {
this.lastSeq = -1;
// Both commit gates reopen: a seek replays history against a virtual clock, and the next
// commit has to happen whatever that clock says relative to the last one.
this.lastCommitMs = Number.NEGATIVE_INFINITY;
this.lastTickerVersion = -1;
this.lastMomentId = 0;
this.motion?.reset();
this.signals?.reset();
hot.buttonStates = freshButtonStates();
hot.audioQueue.length = 0;
hot.gaps = 0;
// A fixture seek replays from the top against a virtual clock (`src/feed/fixture.ts`), so
// the circuit bar trackers reset with everything else rather than carrying a stale reference
// in from whatever the page had loaded before.
circuitScales = freshCircuitScales();
circuitMedians = freshCircuitMedians();
populationScale = new CircuitScale(POPULATION_SEED_HZ);
hot.circuitReferenceHz = freshCircuitReferenceHz();
hot.circuitMedianHz = freshCircuitReferenceHz();
hot.populationReferenceHz = POPULATION_SEED_HZ;
this.lastCircuitMedianMs = null;
}
/** Count a message that failed to decode. The page keeps running; the honesty panel says so. */
noteDecodeError(): void {
hot.decodeErrors += 1;
}
ingest(snapshot: DecodedSnapshot, nowMs: number, options: { silent?: boolean } = {}): void {
const { header } = snapshot;
const silent = options.silent ?? false;
if (this.lastSeq >= 0 && header.seq > this.lastSeq + 1) hot.gaps += header.seq - this.lastSeq - 1;
this.lastSeq = header.seq;
hot.header = header;
hot.lastSnapshotMs = nowMs;
hot.accepted += 1;
hot.rates = header.rates;
hot.populationRate = header.populationRate;
// A snapshot without the spikes attachment reports spikeCount 0 per the contract, so the
// readout holds the last real measurement rather than blinking to zero (the recorder strides
// spikes down to 10 Hz in the committed fixtures).
if (snapshot.spikes) {
hot.spikes = snapshot.spikes;
hot.spikesDirty = !silent;
hot.spikeCount = header.spikeCount;
}
if (snapshot.frame) {
hot.frame = snapshot.frame;
hot.frameDirty = !silent;
}
if (snapshot.audio && !silent) hot.audioQueue.push(snapshot.audio);
this.updateButtons(header.buttons, nowMs);
this.updatePeaks(header.rates, nowMs);
this.updateCircuitScales(header.rates, nowMs);
populationScale.observe(header.populationRate, nowMs);
hot.populationReferenceHz = populationScale.referenceHz;
for (const event of header.events) this.ticker.push(event, nowMs);
// The engine reads the events *and* the header deltas itself (`TriggerMapper`), so it is given
// the whole snapshot rather than a replay of the loop above.
this.motion?.ingest(header, nowMs);
this.signals?.observe(header, nowMs);
this.ticker.tick(nowMs);
}
/** Called by the paint loop every frame. Commits the cold store at most every `COMMIT_MS`. */
commit(nowMs: number, force = false): void {
this.ticker.tick(nowMs);
this.decayPeaks(nowMs);
const tickerChanged = this.ticker.version() !== this.lastTickerVersion;
// A clock that jumps *backwards* also means "commit now". A fixture held on a seek target
// freezes its clock at the virtual time of the snapshot it landed on, which is earlier than
// the real clock the loop was using before the seek finished loading — so a plain
// `elapsed >= COMMIT_MS` gate stays shut forever and the page renders its initial state. That
// is what the cold-open fixture caught: every readout stuck at zero on a feed that had
// already delivered sixteen snapshots.
const elapsed = nowMs - this.lastCommitMs;
const due = force || !(elapsed >= 0 && elapsed < COMMIT_MS);
// A moment must reach the DOM the frame it starts, not up to 250 ms later: the caption band,
// the rail flash and the tab focus all key off it, and the SFX fires immediately. A rollback
// is the case that proves it — its ticker row is queued behind the dwell gate, so nothing
// else on this path would have opened the commit gate for it.
const moment = this.motion?.snapshot().active ?? null;
const momentChanged = (moment?.id ?? 0) !== this.lastMomentId;
if (!due && !tickerChanged && !momentChanged) return;
const header = hot.header;
const stale = header !== null && nowMs - hot.lastSnapshotMs > STALE_AFTER_MS;
const palette = paletteView(header);
if (tickerChanged) this.lastTickerVersion = this.ticker.version();
if (due) this.lastCommitMs = nowMs;
this.lastMomentId = moment?.id ?? 0;
useStage.setState({
...(header
? {
status: header.status,
mode: header.game.mode,
realtimeFactor: header.realtimeFactor,
runSeconds: header.runSeconds,
uptimeSeconds: header.uptimeSeconds,
populationRate: header.populationRate,
spikeCount: hot.spikeCount,
badges: header.game.badges,
uniqueLocations: header.game.uniqueLocations,
rewardTotal: header.game.rewardTotal,
rewardCounts: header.game.rewardCounts,
semanticRewards: header.game.semanticRewards,
scene: palette.scene,
macroMode: palette.mode,
palette: palette.cells,
learning: header.learning,
milestone: header.milestone,
sugar: header.sugar,
chat: sanitizeChatRing((header as FeedHeader & { chat?: unknown }).chat, CHAT_LINES),
}
: {}),
stale,
ticker: [...this.ticker.items()],
moment,
health: { gaps: hot.gaps, decodeErrors: hot.decodeErrors },
});
}
/** The ticker, for tests and for the panel that needs the queued count. */
queue(): TickerQueue {
return this.ticker;
}
private updateButtons(mask: number, nowMs: number): void {
for (const button of GAMEBOY_BUTTONS) {
const bit = GAMEBOY_BUTTON_BITS[button];
const down = (mask & bit) !== 0;
const state = hot.buttonStates[button];
if (!state) continue;
if (down && !state.down) state.downAtMs = nowMs;
if (!down && state.down) state.upAtMs = nowMs;
state.down = down;
}
hot.buttons = mask;
}
private updatePeaks(rates: Record<string, number>, nowMs: number): void {
for (const [role, value] of Object.entries(rates)) {
const peak = hot.peaks[role];
if (!peak || value >= peak.value) {
hot.peaks[role] = { value, atMs: nowMs };
}
}
}
/** Advance each bar role's adaptive reference and resting-level median by one snapshot. */
private updateCircuitScales(rates: Record<string, number>, nowMs: number): void {
for (const role of SCALED_ROLES) {
const value = rates[role] ?? 0;
const scale = circuitScales.get(role);
if (scale) {
scale.observe(value, nowMs);
hot.circuitReferenceHz[role] = scale.referenceHz;
}
const median = circuitMedians.get(role);
if (median) {
// The median only needs the *elapsed* time between snapshots, not the absolute virtual
// clock, so it advances correctly whether it is fed live snapshots roughly every 33 ms or
// a seek's silent catch-up stream.
const dtMs = this.lastCircuitMedianMs === null ? 0 : nowMs - this.lastCircuitMedianMs;
median.observe(value, dtMs);
hot.circuitMedianHz[role] = median.medianHz;
}
}
this.lastCircuitMedianMs = nowMs;
}
/** Linear decay of the peak-hold tick so a spike stays visible after the value drops. */
private decayPeaks(nowMs: number): void {
for (const [role, peak] of Object.entries(hot.peaks)) {
const age = nowMs - peak.atMs;
if (age <= 0) continue;
const current = hot.rates[role] ?? 0;
if (age >= PEAK_HOLD_MS) {
hot.peaks[role] = { value: current, atMs: nowMs };
continue;
}
const decayed = peak.value + (current - peak.value) * (age / PEAK_HOLD_MS);
if (decayed <= current) hot.peaks[role] = { value: current, atMs: nowMs };
else hot.peaks[role] = { value: decayed, atMs: peak.atMs };
}
}
}

View file

@ -0,0 +1,22 @@
/**
* The one camera both renderers use, so the paper fly and the WebGL fly frame the same shot.
*
* Behind and above the fly at about 30 degrees, looking over its head toward the game screen —
* the design's words. There is no 3D Game Boy in this scene any more (the button row is its own
* plain strip, `src/panels/FlyStrip.tsx`), so the shot is the fly and the floor alone, pulled in
* closer than the old framing: same angle, same side, just nearer, which is what makes the fly
* "a little larger now that the strip is its own."
*/
import type { Vec3 } from './rig';
export const CAMERA = {
position: [0, 3.6, -4.7] as Vec3,
target: [0, 0.45, 0.8] as Vec3,
/** Vertical field of view, degrees. */
fov: 25,
near: 0.1,
far: 40,
} as const;
/** Floor plane the fly stands on, drawn as a quiet gradient rather than a lit surface. */
export const FLOOR = { halfWidth: 12, nearZ: -6, farZ: 9 } as const;

View file

@ -0,0 +1,96 @@
/**
* Feed rates to fly drives.
*
* Every value the fly moves on is a fraction of that role's *own* running reference
* (`src/lib/circuit-scale.ts`), exactly as a circuit bar is: the page never sees the decoder's
* calibration baseline, and a fixed Hz ceiling is what pegged every bar at 100% on the first live
* run. The design says it in one line — "Every value is normalized the same way the circuits panel
* does (running reference per role), never hard-coded Hz" — and this file is the whole of it.
*
* The population rate gets the same treatment through its own reference, which `FeedIngest`
* advances per snapshot alongside the bar roles.
*/
import { GAMEBOY_BUTTONS } from '@flybrain/brain';
import { hot } from '@/feed/store';
import { circuitFraction, DEFAULT_HEADROOM } from '@/lib/circuit-scale';
import type { FlyDrives } from './rig';
/** Command roles in `GAMEBOY_BUTTONS` order: `command_0` is up, `command_7` is Select. */
const COMMAND_ROLES = GAMEBOY_BUTTONS.map((_, index) => `command_${index}`);
/**
* The wing/flight drive's source roles — the one table `docs/design/fly-avatar.md` asks this
* file to keep, so swapping the source later is a one-line change.
*
* The honest source is a `motor` role rate (110 neurons in `data/fafb-v783/meta.json`), but
* `docs/feed-protocol.md`'s `rates` does not carry `motor` today — only the tracked roles it
* lists, `command_0..7` among them. Those eight *are* descending motor commands (their neuron
* counts sum to exactly the dataset's `descending` total), so their sum stands in for the wing
* drive until the feed grows a `motor` rate.
*
* TODO(motor-in-feed): once `rates.motor` exists, change this to `['motor']`. `sumDrive` below
* scores a sum of one role the same way it scores eight, so nothing else in this file changes.
*/
export const WING_DRIVE_ROLES: readonly string[] = COMMAND_ROLES;
/**
* What "resting" reads as on the circuit scale.
*
* A role sitting exactly at its own running reference scores `1 / headroom`, about 0.67 — that
* headroom is deliberate, so a burst still has somewhere to go. A *bar* can sit at two thirds
* forever and look right; a fly cannot. Left raw, the proboscis would be half out and the head
* half lit at all times, which is neither honest nor what the design asks for ("above resting it
* walks in place"; "idle: subtle breathing only").
*
* So every drive is re-centred on that resting level: zero at or below its own recent normal,
* rising to one as the rate reaches the top of its own scale. It is the same running reference,
* read as a deviation rather than as a level.
*/
const RESTING = 1 / DEFAULT_HEADROOM;
function aboveResting(fraction: number): number {
return fraction <= RESTING ? 0 : (fraction - RESTING) / (1 - RESTING);
}
/** One role's rate as a 0..1 drive: how far above its own resting level it is running. */
function drive(role: string): number {
const reference = hot.circuitReferenceHz[role];
if (reference === undefined) return 0;
return aboveResting(circuitFraction(hot.rates[role] ?? 0, reference));
}
/**
* Several roles' rates and references summed before scoring, so the group reads as one drive
* against the sum of its own recent normals — the same formula `drive` uses for a single role,
* which is what makes `WING_DRIVE_ROLES` a one-line swap later.
*/
function sumDrive(roles: readonly string[]): number {
let rateSum = 0;
let referenceSum = 0;
for (const role of roles) {
rateSum += hot.rates[role] ?? 0;
referenceSum += hot.circuitReferenceHz[role] ?? 0;
}
if (referenceSum <= 0) return 0;
return aboveResting(circuitFraction(rateSum, referenceSum));
}
/**
* Read one frame of drives out of the hot store.
*
* No button state to time here any more — the button row's own afterglow is painted straight
* from `hot.buttonStates` in `src/App.tsx`, off the page's own clock.
*/
export function readFlyDrives(sugarPulse: number, out: FlyDrives): FlyDrives {
out.forward = drive('forward');
out.backward = drive('backward');
out.steerLeft = drive('steer_left');
out.steerRight = drive('steer_right');
out.wing = sumDrive(WING_DRIVE_ROLES);
out.proboscis = drive('proboscis');
out.reward = drive('reward_pam');
out.population = aboveResting(circuitFraction(hot.populationRate, hot.populationReferenceHz));
out.sugarPulse = sugarPulse;
return out;
}

View file

@ -0,0 +1,74 @@
/**
* The fly strip's renderer, and the `?fly=` switch that picks one.
*
* Two renderers draw the same rig (`src/fly/rig.ts`):
*
* - `webgl` — three.js, one WebGL context, low-poly meshes with flat shading. The design's
* primary, and the default.
* - `paper` — the same joints projected by hand into a 2D canvas and filled as flat polygons.
* It exists because the capture host may have no usable WebGL at all; the design calls it the
* "paper fly" and accepts that it is plainer.
*
* `off` renders nothing and creates no context, which is the honest escape hatch if the fly ever
* costs more than it is worth on air.
*
* `webgl` is loaded through a dynamic import so that a page running `paper` or `off` never parses
* three.js at all — the fallback exists for the weakest host on the list, and handing it half a
* megabyte of dead module would be a strange way to help it.
*/
import type { CanvasPalette } from '@/theme/colors';
import type { FlyDrives, FlyFrame } from './rig';
export type FlyMode = 'off' | 'webgl' | 'paper';
export interface FlyRenderer {
readonly mode: FlyMode;
/** Draw one posed frame. Called at most 30 times a second by the paint loop. */
draw(frame: FlyFrame): void;
dispose(): void;
}
export interface FlyRendererOptions {
canvas: HTMLCanvasElement;
width: number;
height: number;
palette: CanvasPalette;
}
/**
* Build the renderer for a mode. Resolves to null for `off`, and also if no renderer can start —
* a broadcast page drops the fly rather than failing to paint.
*
* `webgl` falls back to `paper` by itself when the GL context cannot be created. Measured on the
* P0 spike (2026-09-15, run 2): the capture container's Chromium runs with `--disable-gpu
* --disable-software-rasterizer` (`infra/config/chromium-flags`), so `getContext('webgl')` returns
* null, `new WebglFly(...)` throws, and the page went to air at the default `?fly=webgl` with
* **no fly at all** — `__stage.fly()` reporting `{mode: 'webgl', rendering: false, legTips: []}`.
* That is the exact host the paper fly was written for, so it is taken automatically instead of
* requiring an operator to have already known to put `&fly=paper` in the kiosk URL. `?fly=paper`
* and `?fly=off` are unchanged, and a host that does have WebGL still gets the WebGL fly, so the
* "exactly one GL context at `?fly=webgl`, zero at `?fly=paper`/`off`" count in
* `tests/e2e/structure.spec.ts` still holds.
*/
export async function createFlyRenderer(mode: FlyMode, options: FlyRendererOptions): Promise<FlyRenderer | null> {
if (mode === 'webgl') {
try {
const { WebglFly } = await import('./webgl');
return new WebglFly(options);
} catch (error) {
console.warn(`fly renderer webgl unavailable, falling back to paper: ${(error as Error).message}`);
mode = 'paper';
}
}
if (mode === 'paper') {
try {
const { PaperFly } = await import('./paper');
return new PaperFly(options);
} catch (error) {
console.warn(`fly renderer paper unavailable: ${(error as Error).message}`);
}
}
return null;
}
export type { FlyDrives, FlyFrame };

270
apps/stage/src/fly/paper.ts Normal file
View file

@ -0,0 +1,270 @@
/**
* The paper fly: the same rig, projected by hand into a 2D canvas and filled flat.
*
* It exists for one reason, stated in the design: the capture host may have no usable WebGL, and
* three.js has no maintained software renderer. So this is a hand-written perspective projection
* and a painter's-algorithm fill — ellipses for the body blobs, quads for the limb segments,
* polygons for the wings. Plainer than the WebGL fly, and the same animal in the same pose from
* the same camera: every number it draws comes from the same `FlyFrame`.
*
* Shading is one directional light evaluated per shape rather than per pixel, which is what
* "flat-shaded" means here — a low-poly look by construction rather than by a shader.
*/
import type { FlyRenderer, FlyRendererOptions } from './index';
import { CAMERA } from './camera';
import { LEG_RADII, type Blob, type FlyFrame, type Vec3 } from './rig';
/** Matches `webgl.ts`: the animal's colours, not the theme's. */
const CHITIN: Rgb = [111, 92, 56];
const CHITIN_DARK: Rgb = [74, 61, 37];
const EYE_RED: Rgb = [179, 54, 42];
const WING: Rgb = [200, 220, 234];
type Rgb = [number, number, number];
/** One thing to fill, with the view-space depth it sorts by. */
interface Shape {
depth: number;
kind: 'poly' | 'ellipse';
points: number[][];
/** Ellipse: centre x, y and the two radii. */
ellipse?: { x: number; y: number; rx: number; ry: number };
fill: string;
}
const LIGHT: Vec3 = [-0.55, 0.74, -0.38];
export class PaperFly implements FlyRenderer {
readonly mode = 'paper' as const;
private readonly ctx: CanvasRenderingContext2D;
private readonly width: number;
private readonly height: number;
private readonly background: string;
private readonly floor: string;
private readonly accent: Rgb;
/** Camera basis, built once: right, up, forward. */
private readonly right: Vec3;
private readonly up: Vec3;
private readonly forward: Vec3;
private readonly focal: number;
private readonly shapes: Shape[] = [];
constructor({ canvas, width, height, palette }: FlyRendererOptions) {
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d', { alpha: false });
if (!ctx) throw new Error('paper fly: no 2d context');
this.ctx = ctx;
this.width = width;
this.height = height;
this.background = css(palette.panel);
this.floor = css(palette.background);
this.accent = [...palette.output] as Rgb;
this.forward = normalize(sub(CAMERA.target, CAMERA.position));
this.right = normalize(cross(this.forward, [0, 1, 0]));
this.up = cross(this.right, this.forward);
this.focal = 1 / Math.tan(((CAMERA.fov * Math.PI) / 180) / 2);
}
draw(frame: FlyFrame): void {
const ctx = this.ctx;
ctx.fillStyle = this.background;
ctx.fillRect(0, 0, this.width, this.height);
this.shapes.length = 0;
// -- The fly --------------------------------------------------------------------------------
for (const wing of frame.wings) this.pushPoly(wing.points, WING, 1.35, 0.3);
for (let index = 0; index < frame.abdomen.length; index++) {
this.pushEllipse(frame.abdomen[index] as Blob, index % 2 === 0 ? CHITIN : CHITIN_DARK, 1);
}
for (const haltere of frame.halteres) this.pushEllipse(haltere, CHITIN_DARK, 1);
for (const leg of frame.legs) {
for (let joint = 0; joint < 3; joint++) {
this.pushLimb(
leg[joint] as Vec3,
leg[joint + 1] as Vec3,
LEG_RADII[joint] as number,
LEG_RADII[joint + 1] as number,
joint === 2 ? CHITIN_DARK : CHITIN,
);
}
}
const glow = 1 + frame.glow * 0.9;
this.pushEllipse(frame.thorax, mix(CHITIN, this.accent, frame.glow * 0.45), glow);
this.pushEllipse(frame.head, mix(CHITIN, this.accent, frame.glow * 0.45), glow);
for (const eye of frame.eyes) this.pushEllipse(eye, EYE_RED, 1.15);
for (const segment of frame.antennae) this.pushLimb(segment.a, segment.b, segment.ra, segment.rb, CHITIN_DARK);
this.pushLimb(frame.proboscis.a, frame.proboscis.b, frame.proboscis.ra, frame.proboscis.rb, CHITIN_DARK);
// -- Paint, far to near ---------------------------------------------------------------------
this.paintFloor();
this.shapes.sort((a, b) => b.depth - a.depth);
for (const shape of this.shapes) {
ctx.fillStyle = shape.fill;
ctx.beginPath();
if (shape.kind === 'ellipse' && shape.ellipse) {
ctx.ellipse(shape.ellipse.x, shape.ellipse.y, shape.ellipse.rx, shape.ellipse.ry, 0, 0, Math.PI * 2);
} else {
for (let index = 0; index < shape.points.length; index++) {
const point = shape.points[index] as number[];
if (index === 0) ctx.moveTo(point[0] as number, point[1] as number);
else ctx.lineTo(point[0] as number, point[1] as number);
}
ctx.closePath();
}
ctx.fill();
}
}
dispose(): void {
// Nothing to release: a 2D context owns no GPU resources.
}
/** A horizon band, so the empty half of the strip is not a flat rectangle. */
private paintFloor(): void {
const horizon = this.project([0, 0, 40]);
const y = horizon ? Math.max(0, Math.min(this.height, horizon[1] as number)) : this.height * 0.3;
const gradient = this.ctx.createLinearGradient(0, y, 0, this.height);
gradient.addColorStop(0, this.floor);
gradient.addColorStop(1, this.background);
this.ctx.fillStyle = gradient;
this.ctx.fillRect(0, y, this.width, this.height - y);
}
/** World point to canvas pixels, or null when it is behind the camera. */
private project(p: Vec3): [number, number, number] | null {
const d = sub(p, CAMERA.position);
const z = dot(d, this.forward);
if (z <= 0.05) return null;
const x = dot(d, this.right);
const y = dot(d, this.up);
const aspect = this.width / this.height;
return [
(0.5 + ((x * this.focal) / (aspect * z)) * 0.5) * this.width,
(0.5 - ((y * this.focal) / z) * 0.5) * this.height,
z,
];
}
/** Pixels per world unit at a given view depth. */
private pixelsPerUnit(z: number): number {
return (this.focal / z) * 0.5 * this.height;
}
private pushPoly(points: readonly Vec3[], colour: Rgb, shade: number, alpha = 1): void {
const projected: number[][] = [];
let depth = 0;
for (const point of points) {
const p = this.project(point);
if (!p) return;
projected.push([p[0], p[1]]);
depth += p[2];
}
if (projected.length < 3) return;
this.shapes.push({
depth: depth / projected.length,
kind: 'poly',
points: projected,
fill: css(scaleColour(colour, shade * faceShade(points)), alpha),
});
}
private pushEllipse(blob: Blob, colour: Rgb, shade: number): void {
const p = this.project(blob.c);
if (!p) return;
const scale = this.pixelsPerUnit(p[2]);
this.shapes.push({
depth: p[2],
kind: 'ellipse',
points: [],
ellipse: {
x: p[0],
y: p[1],
rx: Math.max(0.6, ((blob.r[0] + blob.r[2]) / 2) * scale),
ry: Math.max(0.6, ((blob.r[1] + blob.r[2]) / 2) * scale),
},
fill: css(scaleColour(colour, shade), 1),
});
}
/** A limb segment as a screen-space quad between two projected joints. */
private pushLimb(a: Vec3, b: Vec3, ra: number, rb: number, colour: Rgb): void {
const pa = this.project(a);
const pb = this.project(b);
if (!pa || !pb) return;
const dx = (pb[0] as number) - (pa[0] as number);
const dy = (pb[1] as number) - (pa[1] as number);
const length = Math.hypot(dx, dy);
if (length < 0.2) return;
const nx = -dy / length;
const ny = dx / length;
const wa = Math.max(0.7, ra * this.pixelsPerUnit(pa[2]));
const wb = Math.max(0.6, rb * this.pixelsPerUnit(pb[2]));
this.shapes.push({
depth: (pa[2] + pb[2]) / 2,
kind: 'poly',
points: [
[pa[0] + nx * wa, pa[1] + ny * wa],
[pb[0] + nx * wb, pb[1] + ny * wb],
[pb[0] - nx * wb, pb[1] - ny * wb],
[pa[0] - nx * wa, pa[1] - ny * wa],
],
fill: css(scaleColour(colour, 1), 1),
});
}
}
/** Lambert term for a polygon, from its own winding normal. */
function faceShade(points: readonly Vec3[]): number {
if (points.length < 3) return 1;
const normal = normalize(cross(sub(points[1] as Vec3, points[0] as Vec3), sub(points[2] as Vec3, points[0] as Vec3)));
return 0.55 + 0.6 * Math.abs(dot(normal, LIGHT));
}
function scaleColour(colour: Rgb, factor: number): Rgb {
return [
Math.max(0, Math.min(255, colour[0] * factor)),
Math.max(0, Math.min(255, colour[1] * factor)),
Math.max(0, Math.min(255, colour[2] * factor)),
];
}
function mix(a: Rgb, b: readonly number[], t: number): Rgb {
return [
a[0] + ((b[0] as number) - a[0]) * t,
a[1] + ((b[1] as number) - a[1]) * t,
a[2] + ((b[2] as number) - a[2]) * t,
];
}
function css(colour: readonly number[], alpha = 1): string {
const r = Math.round(colour[0] as number);
const g = Math.round(colour[1] as number);
const b = Math.round(colour[2] as number);
return alpha >= 1 ? `rgb(${r} ${g} ${b})` : `rgb(${r} ${g} ${b} / ${alpha})`;
}
function sub(a: Vec3, b: Vec3): Vec3 {
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
}
function dot(a: Vec3, b: Vec3): number {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
function cross(a: Vec3, b: Vec3): Vec3 {
return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
}
function normalize(a: Vec3): Vec3 {
const l = Math.hypot(a[0], a[1], a[2]);
return l < 1e-6 ? [0, 0, 0] : [a[0] / l, a[1] / l, a[2] / l];
}

410
apps/stage/src/fly/rig.ts Normal file
View file

@ -0,0 +1,410 @@
/**
* The fly's skeleton, and the pose it takes for one frame.
*
* This file is the whole animal: proportions, the tripod gait, the leg IK, the wing beat and the
* mapping from normalized neural drives to joints. It knows nothing about how it will be drawn —
* it emits world-space points — which is what lets the WebGL renderer and the 2D "paper" fallback
* be the *same* fly rather than two lookalikes (`docs/design/fly-avatar.md`).
*
* There is no Game Boy in this file. The fly used to tap one with its front legs; per review
* ("the fly isn't really pressing buttons; just have his limbs and wings wired up to the motor
* neurons"), the legs and wings are driven only by real population rates now, and the eight
* button indicators are their own plain row again, above this scene (`src/panels/FlyStrip.tsx`).
*
* Rig space: +X right, +Y up, +Z toward the game screen, floor at y = 0, the fly's thorax over
* the origin facing +Z. One unit is about 0.4 mm of fly, and the camera is set so the body reads
* clearly in the fly's own share of the 800x220 strip.
*
* Nothing here is random and nothing is scripted: every number below is either a fixed proportion
* or a function of `drives` and the clock. That is the design's one hard rule for this panel.
*/
export type Vec3 = [number, number, number];
/** An ellipsoid body part. */
export interface Blob {
c: Vec3;
/** Radii per axis. */
r: Vec3;
}
/** A tapered limb segment between two joints. */
export interface Segment {
a: Vec3;
b: Vec3;
ra: number;
rb: number;
}
/** A flat polygon (the wings). */
export interface Poly {
points: Vec3[];
}
/** One frame of the fly, in world space. */
export interface FlyFrame {
head: Blob;
eyes: [Blob, Blob];
antennae: Segment[];
thorax: Blob;
abdomen: Blob[];
/** Six legs, four joints each: coxa, trochanter, knee, tarsus tip. */
legs: Vec3[][];
wings: [Poly, Poly];
halteres: [Blob, Blob];
proboscis: Segment;
/** Warm glow inside head and thorax, 0..1, from the PAM rate. */
glow: number;
}
/**
* Normalized 0..1 drives. Every one comes from the running-reference scaler in `src/fly/drives.ts`,
* never from raw Hz.
*/
export interface FlyDrives {
forward: number;
backward: number;
steerLeft: number;
steerRight: number;
/** Wing/flight drive: amplitude and frequency of the wing beat, and haltere jitter follow it.
* See `src/fly/drives.ts` for what feeds this today and why. */
wing: number;
proboscis: number;
reward: number;
population: number;
/** 1 while a sugar moment is on screen. Extends the proboscis whatever the taste rate does. */
sugarPulse: number;
}
/** An all-zero drive set: what the rig poses from before the first snapshot. */
export function idleDrives(): FlyDrives {
return {
forward: 0,
backward: 0,
steerLeft: 0,
steerRight: 0,
wing: 0,
proboscis: 0,
reward: 0,
population: 0,
sugarPulse: 0,
};
}
// -- Proportions -------------------------------------------------------------------------------
const THORAX: Blob = { c: [0, 0.6, 0], r: [0.4, 0.36, 0.52] };
const HEAD: Blob = { c: [0, 0.68, 0.6], r: [0.3, 0.3, 0.28] };
const EYE_OFFSET = 0.25;
const EYE: Blob = { c: [0, 0.71, 0.64], r: [0.21, 0.24, 0.22] };
/** Four abdominal segments, tapering back and down. The banding is a per-segment shade. */
const ABDOMEN: readonly Blob[] = [
{ c: [0, 0.58, -0.48], r: [0.34, 0.32, 0.26] },
{ c: [0, 0.55, -0.76], r: [0.32, 0.3, 0.24] },
{ c: [0, 0.5, -1.02], r: [0.27, 0.25, 0.22] },
{ c: [0, 0.45, -1.26], r: [0.19, 0.18, 0.2] },
];
/** Where each leg leaves the thorax, and where its foot rests. Left, right, front to back. */
const COXA: readonly Vec3[] = [
[-0.3, 0.46, 0.4],
[0.3, 0.46, 0.4],
[-0.34, 0.44, 0.02],
[0.34, 0.44, 0.02],
[-0.3, 0.44, -0.34],
[0.3, 0.44, -0.34],
];
const STANCE: readonly Vec3[] = [
[-0.62, 0, 0.55],
[0.62, 0, 0.55],
[-0.72, 0, 0.02],
[0.72, 0, 0.02],
[-0.66, 0, -0.62],
[0.66, 0, -0.62],
];
/** Femur and tibia. */
const FEMUR = 0.62;
const TIBIA = 0.7;
/** The short coxa stub before the two-bone chain, which is what makes the leg three-jointed. */
const COXA_STUB = 0.14;
/** Tripod gait: legs 0, 3, 4 step together, then 1, 2, 5. */
const GAIT_OFFSET: readonly number[] = [0, 0.5, 0.5, 0, 0, 0.5];
/** Top step rate, in steps per second, at a fully-driven walk. */
const STEP_HZ = 2.2;
/** How far a foot travels fore-and-aft in one stride, and how high it lifts in swing. */
const STRIDE = 0.26;
const LIFT = 0.16;
/** Body yaw at full one-sided steering, radians (about 7 degrees). */
const YAW_MAX = 0.12;
/**
* How much a leg's own stride lengthens or shortens per unit of differential steering.
*
* Turning is a differential-drive read of `steer_left`/`steer_right`: the leg on the side away
* from the stronger steering signal is the "outer" leg and takes a longer stride, the near side
* a shorter one — the same shape a tank uses to turn, and what makes the walk visibly lean into a
* turn rather than just yawing the body.
*/
const STEER_STRIDE_GAIN = 0.85;
const STRIDE_SCALE_MIN = 0.15;
const STRIDE_SCALE_MAX = 1.85;
/** Wing beat, idle floor and full drive. Frequency and amplitude both ramp with `drives.wing`. */
const WING_BEAT_HZ_IDLE = 3;
const WING_BEAT_HZ_MAX = 22;
const WING_AMPLITUDE_IDLE = 0.015;
const WING_AMPLITUDE_MAX = 0.2;
/** Halteres beat antiphase to the wings (the real animal's own gyroscopic pairing), smaller. */
const HALTERE_AMPLITUDE_SCALE = 0.35;
const WING_OUTLINE: readonly Vec3[] = [
[0.14, 0.92, -0.05],
[0.34, 0.95, -0.55],
[0.46, 0.94, -1.25],
[0.3, 0.92, -1.45],
[0.18, 0.91, -0.9],
];
const HALTERE: Blob = { c: [0.24, 0.52, -0.4], r: [0.07, 0.07, 0.07] };
const ANTENNA_BASE: Vec3 = [0.1, 0.52, 0.78];
const ANTENNA_MID: Vec3 = [0.14, 0.44, 0.9];
const ANTENNA_TIP: Vec3 = [0.13, 0.33, 0.95];
const PROBOSCIS_BASE: Vec3 = [0, 0.46, 0.74];
/** Retracted length, and how much the taste circuit (or a sugar pulse) adds. */
const PROBOSCIS_MIN = 0.1;
const PROBOSCIS_MAX = 0.52;
// -- Small vector helpers ----------------------------------------------------------------------
function sub(a: Vec3, b: Vec3): Vec3 {
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
}
function add(a: Vec3, b: Vec3): Vec3 {
return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
}
function scale(a: Vec3, k: number): Vec3 {
return [a[0] * k, a[1] * k, a[2] * k];
}
function length(a: Vec3): number {
return Math.hypot(a[0], a[1], a[2]);
}
function normalize(a: Vec3): Vec3 {
const l = length(a);
return l < 1e-6 ? [0, 0, 0] : [a[0] / l, a[1] / l, a[2] / l];
}
function dot(a: Vec3, b: Vec3): number {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
function clamp01(value: number): number {
return value < 0 ? 0 : value > 1 ? 1 : value;
}
function clampRange(value: number, min: number, max: number): number {
return value < min ? min : value > max ? max : value;
}
/** Smooth 0..1 ramp, used for the proboscis extension. */
function ease(t: number): number {
const x = clamp01(t);
return x * x * (3 - 2 * x);
}
// -- The rig -----------------------------------------------------------------------------------
/**
* A posed fly.
*
* `advance` is the only stateful call: it integrates the gait phase and the wing phase, and it
* does so from the clock the caller passes, which on a held fixture seek is frozen. A clock that
* has stopped (dt = 0) or jumped (dt over `PHASE_RESET_MS`, which is what a seek looks like)
* resets both phases to zero rather than integrating across it. That is what makes a screenshot
* of a held page reproducible: a frozen clock always poses the same fly, whatever the page did on
* its way there.
*/
export class FlyRig {
private phase = 0;
private wingPhase = 0;
private lastMs: number | null = null;
/** Beyond this, the clock jumped (a seek, a tab wake) and the phases restart. */
private static readonly PHASE_RESET_MS = 250;
/** The gait phase, 0..1. Exposed for the tests. */
get gaitPhase(): number {
return this.phase;
}
reset(): void {
this.phase = 0;
this.wingPhase = 0;
this.lastMs = null;
}
advance(drives: FlyDrives, nowMs: number): FlyFrame {
const forward = clamp01(drives.forward);
const backward = clamp01(drives.backward);
const speed = Math.max(forward, backward);
const direction = backward > forward ? -1 : 1;
const dtMs = this.lastMs === null ? 0 : nowMs - this.lastMs;
this.lastMs = nowMs;
if (dtMs <= 0 || dtMs > FlyRig.PHASE_RESET_MS) {
this.phase = 0;
this.wingPhase = 0;
} else {
this.phase = (this.phase + (dtMs / 1000) * STEP_HZ * speed) % 1;
const wingHz = WING_BEAT_HZ_IDLE + (WING_BEAT_HZ_MAX - WING_BEAT_HZ_IDLE) * clamp01(drives.wing);
this.wingPhase = (this.wingPhase + (dtMs / 1000) * wingHz) % 1;
}
return this.pose(drives, nowMs, speed, direction);
}
private pose(drives: FlyDrives, nowMs: number, speed: number, direction: number): FlyFrame {
const seconds = nowMs / 1000;
// Body. Yaw toward the stronger steering side; bob with the gait; breathe with the
// population rate (and a little even at rest, which is the design's "idle: breathing only").
const steerDelta = clamp01(drives.steerRight) - clamp01(drives.steerLeft);
const yaw = steerDelta * YAW_MAX;
const bob = Math.sin(seconds * STEP_HZ * speed * 4 * Math.PI) * 0.018 * speed;
const breathAmplitude = 0.012 + 0.05 * clamp01(drives.population);
const breath = 1 + breathAmplitude * Math.sin(seconds * 2.4);
const cos = Math.cos(yaw);
const sin = Math.sin(yaw);
/** Body space to world: yaw about Y through the origin, then the gait bob. */
const toWorld = (p: Vec3): Vec3 => [p[0] * cos + p[2] * sin, p[1] + bob, -p[0] * sin + p[2] * cos];
const head = { c: toWorld(HEAD.c), r: [...HEAD.r] as Vec3 };
const thorax = { c: toWorld(THORAX.c), r: [...THORAX.r] as Vec3 };
const eyes: [Blob, Blob] = [
{ c: toWorld([EYE.c[0] - EYE_OFFSET, EYE.c[1], EYE.c[2]]), r: [...EYE.r] as Vec3 },
{ c: toWorld([EYE.c[0] + EYE_OFFSET, EYE.c[1], EYE.c[2]]), r: [...EYE.r] as Vec3 },
];
// The abdomen breathes: each segment swells about its own centre, and the whole train
// stretches slightly, which is what reads as breathing at this size.
const abdomen = ABDOMEN.map((segment) => ({
c: toWorld([segment.c[0], segment.c[1], segment.c[2] * (2 - breath)]),
r: [segment.r[0] * breath, segment.r[1] * breath, segment.r[2]] as Vec3,
}));
const antennae: Segment[] = [];
for (const side of [-1, 1]) {
const base = toWorld([ANTENNA_BASE[0] * side, ANTENNA_BASE[1], ANTENNA_BASE[2]]);
const mid = toWorld([ANTENNA_MID[0] * side, ANTENNA_MID[1], ANTENNA_MID[2]]);
const tip = toWorld([ANTENNA_TIP[0] * side, ANTENNA_TIP[1], ANTENNA_TIP[2]]);
antennae.push({ a: base, b: mid, ra: 0.035, rb: 0.03 }, { a: mid, b: tip, ra: 0.03, rb: 0.055 });
}
// Wings: amplitude and frequency both ramp with the wing drive (`drives.wing`); an idle floor
// keeps a small tremor rather than dead stillness. Halteres jitter antiphase, at a fraction of
// the same amplitude — the real animal's own gyroscopic pairing.
const wingDrive = clamp01(drives.wing);
const wingAmplitude = WING_AMPLITUDE_IDLE + (WING_AMPLITUDE_MAX - WING_AMPLITUDE_IDLE) * wingDrive;
const wingBeat = Math.sin(this.wingPhase * 2 * Math.PI);
const wings = [-1, 1].map((side) => ({
points: WING_OUTLINE.map((point) => {
const lift = (point[2] + 0.05) * wingBeat * wingAmplitude * side;
return toWorld([point[0] * side, point[1] + lift, point[2]]);
}),
})) as [Poly, Poly];
const haltereJitter = Math.sin(this.wingPhase * 2 * Math.PI + Math.PI) * wingAmplitude * HALTERE_AMPLITUDE_SCALE;
const halteres: [Blob, Blob] = [
{ c: toWorld([-HALTERE.c[0], HALTERE.c[1] + haltereJitter, HALTERE.c[2]]), r: [...HALTERE.r] as Vec3 },
{ c: toWorld([HALTERE.c[0], HALTERE.c[1] + haltereJitter, HALTERE.c[2]]), r: [...HALTERE.r] as Vec3 },
];
// Proboscis: the taste circuit's own rate, and a sugar redemption overrides it upward.
const extend = Math.max(clamp01(drives.proboscis), clamp01(drives.sugarPulse));
const probBase = toWorld(PROBOSCIS_BASE);
const probDirection = normalize(toWorld([0, -0.55, 0.84]));
const probLength = PROBOSCIS_MIN + (PROBOSCIS_MAX - PROBOSCIS_MIN) * ease(extend);
const proboscis: Segment = {
a: probBase,
b: add(probBase, scale(probDirection, probLength)),
ra: 0.075,
rb: 0.05,
};
// Legs: a tripod gait when moving, otherwise the resting stance (idle: no gait motion at
// all, per the design's "idle: subtle breathing only" — the body's own bob and breath already
// zero out at speed 0, so a resting leg is simply still).
const legs: Vec3[][] = [];
for (let leg = 0; leg < 6; leg++) {
const coxa = toWorld(COXA[leg] as Vec3);
const stance = toWorld(STANCE[leg] as Vec3);
let tip = stance;
if (speed > 0.002) {
const side = (STANCE[leg] as Vec3)[0] < 0 ? -1 : 1;
const strideScale = clampRange(1 - side * STEER_STRIDE_GAIN * steerDelta, STRIDE_SCALE_MIN, STRIDE_SCALE_MAX);
const reach = STRIDE * strideScale;
const u = (this.phase + (GAIT_OFFSET[leg] ?? 0)) % 1;
const along = u < 0.5 ? reach * (1 - 4 * u) : reach * (4 * (u - 0.5) - 1);
tip = [tip[0], tip[1], tip[2] + along * direction * speed];
if (u >= 0.5) tip[1] += Math.sin(Math.PI * (u - 0.5) * 2) * LIFT * speed;
}
legs.push(solveLeg(coxa, tip, (STANCE[leg] as Vec3)[0] < 0 ? -1 : 1));
}
return {
head,
eyes,
antennae,
thorax,
abdomen,
legs,
wings,
halteres,
proboscis,
glow: clamp01(drives.reward),
};
}
}
/**
* Two-bone IK with a short coxa stub in front of it: coxa, trochanter, knee, tarsus tip.
*
* The knee is placed on the side of the femur-tibia plane that points up and away from the body,
* which is what gives a fly its high-elbow stance instead of a mammal's.
*/
export function solveLeg(coxa: Vec3, tip: Vec3, side: number): Vec3[] {
const outward: Vec3 = normalize([side * 1, 1.25, 0]);
const trochanter = add(coxa, scale(normalize(add(outward, sub(tip, coxa))), COXA_STUB));
const delta = sub(tip, trochanter);
const distance = Math.min(Math.max(length(delta), Math.abs(FEMUR - TIBIA) + 0.02), FEMUR + TIBIA - 0.02);
const direction = normalize(delta);
const along = (FEMUR * FEMUR - TIBIA * TIBIA + distance * distance) / (2 * distance);
const out = Math.sqrt(Math.max(0, FEMUR * FEMUR - along * along));
// Bend axis: `outward` with its component along the limb removed, so the knee rises sideways.
const projected = sub(outward, scale(direction, dot(outward, direction)));
const bend = length(projected) < 1e-4 ? ([0, 1, 0] as Vec3) : normalize(projected);
const knee = add(add(trochanter, scale(direction, along)), scale(bend, out));
return [coxa, trochanter, knee, [...tip] as Vec3];
}
/** Limb thickness at each joint, for whichever renderer is drawing the segments. */
export const LEG_RADII = [0.075, 0.06, 0.042, 0.022];

240
apps/stage/src/fly/webgl.ts Normal file
View file

@ -0,0 +1,240 @@
/**
* The fly in three.js: one WebGL context, low-poly primitives, flat shading.
*
* Everything is allocated once in the constructor and only transformed afterwards, because this
* runs 30 times a second forever on a machine that is also running an emulator, a simulation and
* an encoder. No geometry is rebuilt per frame; the only per-frame writes are positions, scales,
* quaternions, one emissive colour and the wings' six vertices.
*
* There is no Game Boy mesh here: the fly's legs and wings are driven by motor rates, not by
* button taps, and the eight button indicators are their own plain DOM row above this canvas
* (`src/panels/FlyStrip.tsx`). What's left is well under the design's 3,000-triangle budget.
* Materials are Lambert rather than Standard: a physically-based material costs far more in a
* software rasteriser and buys nothing at this size.
*/
import {
AmbientLight,
BufferAttribute,
BufferGeometry,
Color,
CylinderGeometry,
DirectionalLight,
DoubleSide,
DynamicDrawUsage,
Mesh,
MeshBasicMaterial,
MeshLambertMaterial,
PerspectiveCamera,
PlaneGeometry,
Quaternion,
Scene,
SphereGeometry,
Vector3,
WebGLRenderer,
} from 'three';
import type { FlyRenderer, FlyRendererOptions } from './index';
import { CAMERA, FLOOR } from './camera';
import { LEG_RADII, type Blob, type FlyFrame, type Segment, type Vec3 } from './rig';
/** The fly's own colours. These are the animal, not the theme, so they do not move with `?theme=`. */
const CHITIN = 0x6f5c38;
const CHITIN_DARK = 0x4a3d25;
const EYE_RED = 0xb3362a;
const WING = 0xc8dcea;
const UP = new Vector3(0, 1, 0);
export class WebglFly implements FlyRenderer {
readonly mode = 'webgl' as const;
private readonly renderer: WebGLRenderer;
private readonly scene = new Scene();
private readonly camera: PerspectiveCamera;
private readonly head: Mesh;
private readonly thorax: Mesh;
private readonly eyes: Mesh[] = [];
private readonly abdomen: Mesh[] = [];
private readonly halteres: Mesh[] = [];
private readonly legSegments: Mesh[] = [];
private readonly antennaSegments: Mesh[] = [];
private readonly proboscis: Mesh;
private readonly wings: { mesh: Mesh; position: BufferAttribute }[] = [];
private readonly glowMaterial: MeshLambertMaterial;
private readonly accent: Color;
private readonly scratchA = new Vector3();
private readonly scratchB = new Vector3();
private readonly scratchDirection = new Vector3();
private readonly scratchQuaternion = new Quaternion();
constructor({ canvas, width, height, palette }: FlyRendererOptions) {
this.renderer = new WebGLRenderer({ canvas, antialias: false, alpha: false, powerPreference: 'low-power' });
this.renderer.setPixelRatio(1);
this.renderer.setSize(width, height, false);
this.renderer.setClearColor(new Color(rgb(palette.panel)), 1);
this.camera = new PerspectiveCamera(CAMERA.fov, width / height, CAMERA.near, CAMERA.far);
this.camera.position.set(...CAMERA.position);
this.camera.lookAt(new Vector3(...CAMERA.target));
this.accent = new Color(rgb(palette.output));
this.scene.add(new AmbientLight(0x5a6272, 1.5));
const key = new DirectionalLight(0xfff0d6, 1.7);
key.position.set(-2.5, 4, -1.5);
this.scene.add(key);
// The floor: built once, never touched again.
const floor = new Mesh(
new PlaneGeometry(FLOOR.halfWidth * 2, FLOOR.farZ - FLOOR.nearZ),
new MeshBasicMaterial({ color: new Color(rgb(palette.background)) }),
);
floor.rotation.x = -Math.PI / 2;
floor.position.set(0, 0, (FLOOR.farZ + FLOOR.nearZ) / 2);
this.scene.add(floor);
// -- The fly --------------------------------------------------------------------------------
const sphere = new SphereGeometry(1, 8, 6);
this.glowMaterial = new MeshLambertMaterial({ color: CHITIN, flatShading: true });
const shell = new MeshLambertMaterial({ color: CHITIN, flatShading: true });
const band = new MeshLambertMaterial({ color: CHITIN_DARK, flatShading: true });
this.head = new Mesh(sphere, this.glowMaterial);
this.thorax = new Mesh(sphere, this.glowMaterial);
this.scene.add(this.head, this.thorax);
const eyeMaterial = new MeshLambertMaterial({ color: EYE_RED, flatShading: true });
for (let index = 0; index < 2; index++) {
const eye = new Mesh(sphere, eyeMaterial);
this.eyes.push(eye);
this.scene.add(eye);
}
// Four segments, alternating shade: that alternation is the abdomen's banding.
for (let index = 0; index < 4; index++) {
const segment = new Mesh(sphere, index % 2 === 0 ? shell : band);
this.abdomen.push(segment);
this.scene.add(segment);
}
for (let index = 0; index < 2; index++) {
const haltere = new Mesh(sphere, band);
this.halteres.push(haltere);
this.scene.add(haltere);
}
// Legs: three tapered segments each, their radii fixed per joint so only the length changes.
for (let leg = 0; leg < 6; leg++) {
for (let joint = 0; joint < 3; joint++) {
const mesh = new Mesh(
new CylinderGeometry(LEG_RADII[joint + 1] as number, LEG_RADII[joint] as number, 1, 6),
joint === 2 ? band : shell,
);
this.legSegments.push(mesh);
this.scene.add(mesh);
}
}
for (let index = 0; index < 4; index++) {
const mesh = new Mesh(new CylinderGeometry(index % 2 === 0 ? 0.03 : 0.055, 0.035, 1, 6), band);
this.antennaSegments.push(mesh);
this.scene.add(mesh);
}
this.proboscis = new Mesh(new CylinderGeometry(0.05, 0.075, 1, 6), band);
this.scene.add(this.proboscis);
const wingMaterial = new MeshBasicMaterial({ color: WING, transparent: true, opacity: 0.24, side: DoubleSide });
for (let index = 0; index < 2; index++) {
const geometry = new BufferGeometry();
const position = new BufferAttribute(new Float32Array(9 * 3), 3);
position.setUsage(DynamicDrawUsage);
geometry.setAttribute('position', position);
const mesh = new Mesh(geometry, wingMaterial);
mesh.frustumCulled = false;
this.wings.push({ mesh, position });
this.scene.add(mesh);
}
}
draw(frame: FlyFrame): void {
placeBlob(this.head, frame.head);
placeBlob(this.thorax, frame.thorax);
for (let index = 0; index < 2; index++) placeBlob(this.eyes[index] as Mesh, frame.eyes[index] as Blob);
for (let index = 0; index < frame.abdomen.length; index++) {
placeBlob(this.abdomen[index] as Mesh, frame.abdomen[index] as Blob);
}
for (let index = 0; index < 2; index++) placeBlob(this.halteres[index] as Mesh, frame.halteres[index] as Blob);
for (let leg = 0; leg < frame.legs.length; leg++) {
const joints = frame.legs[leg] as Vec3[];
for (let joint = 0; joint < 3; joint++) {
this.placeSegment(this.legSegments[leg * 3 + joint] as Mesh, joints[joint] as Vec3, joints[joint + 1] as Vec3);
}
}
for (let index = 0; index < this.antennaSegments.length; index++) {
const segment = frame.antennae[index] as Segment;
this.placeSegment(this.antennaSegments[index] as Mesh, segment.a, segment.b);
}
this.placeSegment(this.proboscis, frame.proboscis.a, frame.proboscis.b);
for (let index = 0; index < 2; index++) {
const wing = this.wings[index];
if (!wing) continue;
const points = frame.wings[index]?.points ?? [];
// A triangle fan over the outline, written straight into the attribute.
let cursor = 0;
for (let corner = 1; corner + 1 < points.length; corner++) {
cursor = writePoint(wing.position, cursor, points[0] as Vec3);
cursor = writePoint(wing.position, cursor, points[corner] as Vec3);
cursor = writePoint(wing.position, cursor, points[corner + 1] as Vec3);
}
wing.position.needsUpdate = true;
}
// Head and thorax warm with the PAM rate. Emissive, not colour, so the shading survives.
this.glowMaterial.emissive.copy(this.accent).multiplyScalar(frame.glow * 0.45);
this.renderer.render(this.scene, this.camera);
}
/** Stretch a unit-height cylinder between two joints. */
private placeSegment(mesh: Mesh, a: Vec3, b: Vec3): void {
this.scratchA.set(a[0], a[1], a[2]);
this.scratchB.set(b[0], b[1], b[2]);
this.scratchDirection.subVectors(this.scratchB, this.scratchA);
const length = this.scratchDirection.length();
if (length < 1e-5) {
mesh.visible = false;
return;
}
mesh.visible = true;
this.scratchDirection.divideScalar(length);
mesh.position.copy(this.scratchA).addScaledVector(this.scratchDirection, length / 2);
mesh.quaternion.copy(this.scratchQuaternion.setFromUnitVectors(UP, this.scratchDirection));
mesh.scale.set(1, length, 1);
}
dispose(): void {
this.renderer.dispose();
}
}
function placeBlob(mesh: Mesh, blob: Blob): void {
mesh.position.set(blob.c[0], blob.c[1], blob.c[2]);
mesh.scale.set(blob.r[0], blob.r[1], blob.r[2]);
}
function writePoint(attribute: BufferAttribute, cursor: number, point: Vec3): number {
attribute.setXYZ(cursor, point[0], point[1], point[2]);
return cursor + 1;
}
/** `[r, g, b]` 0-255 to the 0xrrggbb three.js wants. */
function rgb(color: readonly [number, number, number]): number {
return (Math.round(color[0]) << 16) | (Math.round(color[1]) << 8) | Math.round(color[2]);
}

View file

@ -0,0 +1,51 @@
/**
* The DESCRIBE tab's copy. **This file is the whole of it.**
*
* `docs/design/describe-tab.md` is the review surface and this is the build's copy of it: one
* card, and nothing else in the repo carries a word of it. A copy change after the operator's review is
* an edit here and a rebuild — no component, no CSS, no test holds a sentence of its own
* (`tests/unit/describe.test.ts` asserts this file still matches the doc, title and paragraph, so
* the two cannot drift).
*
* The copy is **approved by the operator (2026-09-17)**: one densely packed card, no cycling. Do not
* paraphrase it here; change the doc, have it reviewed, then bring it across.
*
* `{…}` placeholders are the one thing that is not static: the doc's rule is that "names and
* numbers come from the feed where they exist", so the card writes a placeholder and the renderer
* fills it from the live page (`src/lib/describe.ts` resolves them, and the numbers are formatted
* there rather than typed out here, so the count on screen cannot drift from the dataset):
*
* {neurons} the loaded dataset's neuron count, e.g. `139,255`
* {synapses} its connection count, rounded, e.g. `2.7 million`
* {game} the game config's human name — the only place a game may be named
* {dataset} the dataset version, e.g. `v783`
* {version} the release version the page was built at
*
* All five resolve; the approved card uses two. A future card that wants the build's version
* writes `{version}` and gets it, which is why the list is longer than the copy needs.
*/
/** The card: the Silkscreen title, and the VT323 paragraph under it. */
export interface DescribeCard {
/** The card's title, in the caps the doc gives it (`### …` in `docs/design/describe-tab.md`). */
title: string;
/** The paragraph. Placeholders as above. */
text: string;
}
/**
* The card, as approved.
*
* One, not eight. The operator, 2026-09-17: "keep it to a densely packed card" — so there is no cycle, no
* card index and no cell row anywhere downstream of this file, and a reader who arrives at any
* moment gets the whole explanation rather than one eighth of it.
*/
export const DESCRIBE_CARD: DescribeCard = {
title: 'A CONNECTOME MEETS A GAME BOY',
text:
"This is a real fly's brain, {neurons} mapped neurons and {synapses} synapses, running live. " +
'The screen is its eye. Its motor neurons press the buttons. Each scene offers a few actions, ' +
'walk to a door, talk, attack; the fly picks one. When the game rewards it, a few thousand ' +
'synapses shift, and what worked gets likelier. !sugar sends it a small reward pulse, no ' +
'buttons. FlyWire connectome.',
};

View file

@ -0,0 +1,25 @@
/**
* Game config registry, selected by `?game=`.
*
* Static imports, not dynamic: two configs are a few kilobytes, the page must be able to paint
* before any network round-trip finishes, and a broken `?game=` must fall back rather than throw
* on a live broadcast.
*/
import { platformer } from './platformer';
import { pokemonRed } from './pokemon-red';
import type { GameConfig } from './types';
export type { GameConfig, GameCounter, RewardCopy, RewardTier } from './types';
export const GAMES: Record<string, GameConfig> = {
'pokemon-red': pokemonRed,
platformer,
};
export const DEFAULT_GAME = 'pokemon-red';
/** Resolve a `?game=` value, falling back to the default rather than failing on air. */
export function resolveGame(id: string | null | undefined): GameConfig {
const config = id ? GAMES[id] : undefined;
return config ?? (GAMES[DEFAULT_GAME] as GameConfig);
}

View file

@ -0,0 +1,104 @@
/**
* Per-game config for the second demo, Super Mario Land (`sml-progress-v1`).
*
* This is the only file in the app that names this game. The ladder is the adapter's 16 ranks from
* `docs/design/platformer.md` §3; the feed header stays authoritative for the *current* rank's
* label, so a ladder change in the service shows up on screen without a page release. The same goes
* for the mode and the reward counters: this file supplies words, never values.
*/
import type { GameConfig } from './types';
/**
* Ticker words for the adapter's nine reward kinds.
*
* Seven of them reach the page as one of the feed's published `RewardKind` counters and are worded
* in `rewardCopy` below; the mapping is in `docs/feed-protocol.md`. `started` and `clear` map to no
* counter, because each pays once in a lifetime, so the ticker shows the adapter's own event label
* for them ("RUN STARTED", "GAME CLEARED"). This record is the one place all nine words live, so a
* reader can see the catalogue in one glance; the two unmapped entries are documentation until the
* feed grows a counter for them.
*/
export const PLATFORMER_REWARD_WORDS: Record<string, string> = {
started: 'run started',
band: 'new ground',
coin: 'coin',
score: 'points',
powerup: 'power-up',
life: '1UP',
level: 'level cleared',
world: 'world cleared',
clear: 'game clear',
};
export const platformer: GameConfig = {
id: 'platformer',
// 34 characters, four over the ~30 the title strip fits at 18 px (see `GameConfig.wordmark`), so
// the strip measures this one at 16 px. The game's name is not negotiable and abbreviating it
// ("SUPER MARIO") would read as a different game.
wordmark: 'A FLY BRAIN PLAYS SUPER MARIO LAND',
name: 'Super Mario Land',
// The adapter's ladder, in the page's lower case. Rungs 4 to 14 are "4 + highest cleared level",
// and the boss names come from the design, which took them from Mario Wiki.
milestoneLadder: [
'booting',
'started a run',
'found a coin',
'halfway through 1-1',
'cleared 1-1',
'cleared 1-2',
'cleared world 1',
'cleared 2-1',
'cleared 2-2',
'cleared world 2',
'cleared 3-1',
'cleared 3-2',
'cleared world 3',
'cleared 4-1',
'cleared 4-2',
'finished the game',
],
// Short state words, not sentences: these read as a badge in the title strip. The adapter's five
// modes fold onto the feed's closed set (`docs/feed-protocol.md`): `IN LEVEL <world>-<stage>` is
// OVERWORLD, and GAME OVER is TRANSITION -- which is why the TRANSITION word has to cover a level
// load, a death, a pause and a game over at once. BATTLE and SAFARI are unreachable for this
// adapter; they are here because the mode set is closed.
modeLabels: {
BOOT: 'booting',
OVERWORLD: 'in the level',
BATTLE: 'boss',
TRANSITION: 'not in play',
DEMO: 'attract demo',
SAFARI: 'bonus game',
UNKNOWN: 'unknown',
},
// Ticker rows are noun phrases, so a row reads as a log line rather than as narration. The keys
// are the feed's counters; the adapter kind each one carries is named in the comment.
rewardCopy: {
explore: { label: 'new ground', tier: 'quiet', dedupeMs: 20_000, collapsedNoun: 'new ground' }, // band
wildwin: { label: 'coin', tier: 'quiet', dedupeMs: 15_000, collapsedNoun: 'coins' }, // coin
area: { label: 'points', tier: 'quiet', dedupeMs: 10_000, collapsedNoun: 'points' }, // score
pokedex: { label: 'power-up', tier: 'notable', dedupeMs: 0 }, // powerup
trainer: { label: '1UP', tier: 'notable', dedupeMs: 0 }, // life
story: { label: 'level cleared', tier: 'notable', dedupeMs: 0 }, // level
badge: { label: 'world cleared', tier: 'moment', dedupeMs: 0 }, // world
},
// `badges` carries the adapter's headline counter, which for this game is lives, so there is no
// denominator: a 1UP can push it past three. `uniqueLocations` is the band ledger, i.e. ten-column
// stretches of ground the fly has been paid for.
counters: [
{ field: 'badges', label: 'lives' },
{ field: 'uniqueLocations', label: 'ground' },
],
// Ranks are hours apart here -- a level clear is a rare event for a fly at 250 ms per decision --
// so the alarm sits where the Pokémon demo's does in spirit rather than in value. The design's
// 5-minute "time since the last new band" dial is a different measurement, and the feed carries no
// field for it yet; `milestone.sinceSeconds` is what this threshold reads.
stuckAlarmSeconds: 2 * 3600,
};
export default platformer;

View file

@ -0,0 +1,100 @@
/**
* Per-game config for the first demo.
*
* This is the only file in the app that names this game. The feed header stays authoritative for
* the *current* rank's label and for how many rungs exist (`milestone.total`), so a ratchet change
* in the service shows up on screen without a page release.
*
* The full 38 labels are here anyway, and that is a decision rather than an oversight: the header
* carries `rank`, `label`, `next` and `total` but **not** the ladder's other 37 names, and the
* LADDER tab's whole job is to spell the ladder out — the visible long-horizon goal the research
* says holds an audience for months. So the list is transcribed from `docs/design/ladder.md`'s
* table, index for index with the service's own ratchet, and `tests/unit/rungs.test.ts` pins its
* length at the header's `total` for this game. The drift risk is real and is paid for with a test
* rather than by leaving the tab blank.
*/
import type { GameConfig } from './types';
export const pokemonRed: GameConfig = {
id: 'pokemon-red',
// Unaccented on purpose: Press Start 2P draws É at x-height, so "POKÉMON" reads as "POKéMON" —
// smaller and off next to the surrounding caps. `apps/stage/README.md` records the glyph check.
wordmark: 'A FLY BRAIN PLAYS POKEMON RED',
name: 'Pokémon Red',
// 38 rungs, 0..37, transcribed from `docs/design/ladder.md`'s table. Short on purpose: they
// render three to a row in a 1008 px pane, and the current one also shares one 30 px mono line
// with the next rung in the progress cluster.
milestoneLadder: [
'Boot screen',
'Bedroom',
'Downstairs',
'Pallet Town',
"Oak's lab",
'Got a starter',
"Oak's parcel",
'Pokédex',
'Viridian City',
'Viridian Forest',
'Pewter City',
'Boulder Badge',
'Mt. Moon',
'Cerulean City',
'Cascade Badge',
'Nugget Bridge',
'Met Bill',
'Vermilion City',
'HM Cut',
'Thunder Badge',
'Rock Tunnel',
'Lavender Town',
'Celadon City',
'Silph Scope',
'Rainbow Badge',
'Poké Flute',
'Fuchsia City',
'Soul Badge',
'Silph Co. freed',
'Marsh Badge',
'Cinnabar Island',
'Volcano Badge',
'Earth Badge',
'Indigo Plateau',
'Beat Lorelei',
'Beat Bruno',
'Beat Agatha',
'Champion',
],
// Short state words, not sentences: these read as a badge in the title strip. `UNKNOWN` has no
// real word — `TitleStrip` hides the chip entirely rather than show a placeholder.
modeLabels: {
BOOT: 'boot',
OVERWORLD: 'walking',
BATTLE: 'battle',
TRANSITION: 'menu',
DEMO: 'demo',
SAFARI: 'safari zone',
UNKNOWN: 'unknown',
},
// Ticker rows are noun phrases, so a row reads as a log line rather than as narration.
rewardCopy: {
explore: { label: 'new place', tier: 'quiet', dedupeMs: 20_000, collapsedNoun: 'new places' },
area: { label: 'new area', tier: 'notable', dedupeMs: 20_000, collapsedNoun: 'new areas' },
wildwin: { label: 'wild win', tier: 'quiet', dedupeMs: 20_000, collapsedNoun: 'wild wins' },
trainer: { label: 'trainer beaten', tier: 'notable', dedupeMs: 0 },
pokedex: { label: 'new Pokédex entry', tier: 'notable', dedupeMs: 0 },
story: { label: 'story', tier: 'notable', dedupeMs: 0 },
badge: { label: 'gym badge', tier: 'moment', dedupeMs: 0 },
},
counters: [
{ field: 'badges', label: 'badges', outOf: 8 },
{ field: 'uniqueLocations', label: 'places' },
],
stuckAlarmSeconds: 6 * 3600,
};
export default pokemonRed;

View file

@ -0,0 +1,77 @@
/**
* The per-game config contract.
*
* There will be a second demo (a Game Boy platformer), so the page is game-agnostic by
* construction: the *live values* always come from the feed header (`milestone.label`,
* `milestone.next`, `game.mode`, `game.rewardCounts` keys, `game.badges`), and a game config
* supplies only the human copy for those values plus the wordmark. The config is selected by
* `?game=` and resolved in `src/games/index.ts`.
*
* The rule this encodes: no game's name, ladder or vocabulary appears anywhere outside its own
* file in `src/games/`. Grep for the game's name and you should find exactly one file.
*/
import type { GameMode, RewardKind } from '@flybrain/feed';
/** How loudly a reward is presented in the ticker (design A3: value tiers drive presentation). */
export type RewardTier = 'quiet' | 'notable' | 'moment';
/** Human copy for one reward kind the adapter can report. */
export interface RewardCopy {
/** Short ticker text. Constant; never interpolated with feed values beyond the amount. */
label: string;
tier: RewardTier;
/**
* Identical events within this window collapse into one ticker row with a count
* ("+3 new places"). 0 disables collapsing for the kind.
*/
dedupeMs: number;
/** Plural noun for a collapsed row, e.g. `new places`. */
collapsedNoun?: string;
}
/** A counter shown in the progress row beside the milestone ladder. */
export interface GameCounter {
/** Which `FeedGame` field to read. */
field: 'badges' | 'uniqueLocations' | 'rewardTotal';
label: string;
/** Denominator for a "3 of 8" readout, when the total is known and fixed. */
outOf?: number;
}
export interface GameConfig {
/** Matches the file name and the `?game=` value. */
id: string;
/**
* Title-strip wordmark, rendered in Press Start 2P.
*
* Press Start 2P is one em per character and the strip is 1216 px wide, so at the 18 px the
* strip uses this has to stay at or under about 30 characters to leave room for the subtitle
* and the status badges. Measured, not guessed.
*/
wordmark: string;
/** Human name used in the sugar honesty line and the explainer cards. */
name: string;
/**
* Fallback rung labels, index = `milestone.rank`.
*
* The feed is authoritative for both the current rung's label and the number of rungs
* (`milestone.total`); this is what the panel falls back to when a header carries neither, which
* is a recorded fixture or a flysim older than `total`. So its length no longer defines how many
* rungs exist, and it does not have to match the service's ladder rung for rung.
*/
milestoneLadder: readonly string[];
/** Human copy for each `game.mode` the adapter can report. */
modeLabels: Record<GameMode, string>;
/** Human copy and presentation tier for each reward kind. */
rewardCopy: Record<RewardKind, RewardCopy>;
/**
* Counters shown on the progress cluster's footer line (`ProgressCluster`), e.g.
* "3/8 badges · 214 places". Per-game *data*, not copy: the values come
* from the matching `FeedGame` field, and this config only supplies the label and denominator.
*/
counters: readonly GameCounter[];
/** Stuck-o-meter threshold in simulated seconds past which the panel changes colour. */
stuckAlarmSeconds: number;
/** True while the config is a placeholder for a demo that does not exist yet. */
stub?: boolean;
}

391
apps/stage/src/index.css Normal file
View file

@ -0,0 +1,391 @@
@import 'tailwindcss';
@import './theme/tokens.css';
@import './theme/panels.css';
@import './theme/rail.css';
@import './theme/motion.css';
/**
* Self-hosted OFL faces. `font-display: block` with the default 3 s block period is deliberate:
* this page paints once and then broadcasts for weeks, so a flash of fallback text is worse than
* a late first paint, and `data-ready` gates the capture on `document.fonts.ready` anyway.
*
* Two faces are on the page (`docs/design/gameboy-theme.md`): Press Start 2P for titles, rung
* names, big numbers, tab labels and chips, and one pixel monospace for everything else. Inter
* and IBM Plex Mono are gone — the document drops them "from the page entirely" — and so are
* their files.
*
* All three of that document's body candidates are declared, because only the one `--font-body`
* names is ever fetched: an unused `@font-face` costs nothing, and having them all here is what
* makes the pick a one-line change in `src/theme/tokens.css` rather than a commit. They are
* subset to the same Latin repertoire as the Press Start 2P subset (`tools/font-compare.mts`
* documents how, and `mockups/gameboy-fonts.png` is the frame they were picked from).
*
* The `*-Fallback` faces carry `size-adjust` so that if a load ever fails the metrics still match
* and the grid does not reflow (design A4, risk 3). Against DejaVu Sans Mono's 0.602 em advance:
* VT323's is 0.4, hence 66%, with ascent/descent 0.8/0.2 em; Silkscreen's mean is 0.734, hence
* 122%, with 1.04/0.26. Silkscreen is proportional, so 122% matches its *average* line rather
* than every glyph — which is the best a single number can do, and is why the fallback is a
* safety net for a failed load and not a layout the page is designed against.
*/
@font-face {
font-family: 'Press Start 2P';
font-style: normal;
font-weight: 400;
font-display: block;
src: url('/fonts/PressStart2P-Latin.woff2') format('woff2');
}
@font-face {
font-family: 'VT323';
font-style: normal;
font-weight: 400;
font-display: block;
src: url('/fonts/VT323-Latin.woff2') format('woff2');
}
@font-face {
font-family: 'Silkscreen';
font-style: normal;
font-weight: 400;
font-display: block;
src: url('/fonts/Silkscreen-Latin.woff2') format('woff2');
}
@font-face {
font-family: 'Pixelify Sans';
font-style: normal;
font-weight: 400;
font-display: block;
src: url('/fonts/PixelifySans-Latin.woff2') format('woff2');
}
@font-face {
font-family: 'VT323 Fallback';
src: local('DejaVu Sans Mono'), local('Courier New');
size-adjust: 66%;
ascent-override: 80%;
descent-override: 20%;
line-gap-override: 0%;
}
@font-face {
font-family: 'Silkscreen Fallback';
src: local('DejaVu Sans Mono'), local('Courier New');
size-adjust: 122%;
ascent-override: 104%;
descent-override: 26%;
line-gap-override: 0%;
}
@font-face {
font-family: 'Press Start 2P Fallback';
src: local('DejaVu Sans Mono'), local('Courier New');
size-adjust: 78%;
ascent-override: 100%;
descent-override: 25%;
line-gap-override: 0%;
}
/**
* Tailwind v4 CSS-first theme. Only the tokens the utilities need are mapped; the panels read the
* CSS variables directly, which keeps one source of truth for a theme swap at runtime.
*
* `--font-sans` and `--font-mono` are the same face now: there is no separate text face on the
* page at all, so a utility asking for either gets the body pick. `--font-mono` is a Tailwind name
* rather than a promise — Silkscreen is a pixel face, not a fixed-advance one, and `.num` is where
* the page gets fixed advances from.
*/
@theme {
--color-bg-0: var(--bg-0);
--color-bg-1: var(--bg-1);
--color-bg-2: var(--bg-2);
--color-panel: var(--panel);
--color-bezel: var(--bezel);
--color-ink-0: var(--ink-0);
--color-ink-1: var(--ink-1);
--color-ink-2: var(--ink-2);
--color-accent: var(--accent);
--color-accent-warm: var(--accent-warm);
--color-sensory: var(--sensory);
--color-motor: var(--motor);
--color-dopamine: var(--dopamine);
--color-ok: var(--ok);
--color-warn: var(--warn);
--color-alarm: var(--alarm);
--font-sans: var(--font-body);
--font-mono: var(--font-body);
--radius-panel: var(--radius);
}
@layer base {
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
/* No scrollbars, ever: the structural test asserts it (audit finding). */
overflow: hidden;
background: var(--bg-0);
color: var(--ink-0);
font-family: var(--font-body);
font-size: var(--fs-body);
/*
* No ligatures anywhere. A readout page has no use for them, and Pixelify Sans's f-l ligature
* renders as a capital A at the body floor ("silly fly" came out "silly Ay" in the candidate
* frame), which is the sort of thing that reaches air unnoticed.
*
* No `font-variation-settings` and no `cv05`: neither face is variable and neither has stylistic
* sets. No `tnum` either, but for a blunter reason — measured, Silkscreen carries no such
* feature, so asking for it here would read as a guarantee the page does not have. The
* guarantee lives on `.num` instead, which changes face rather than asking a face for a
* table it has not got.
*/
font-variant-ligatures: none;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* The page is a broadcast surface: no selection, no caret, no focus rings in the frame. */
body {
user-select: none;
cursor: none;
}
canvas {
display: block;
}
}
/* -- The stage ------------------------------------------------------------------------------- */
#stage {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
transform-origin: top left;
background-color: var(--bg-0);
/* A grid wash, flat colours only, so the encoder has something stable to chew on. */
background-image:
linear-gradient(to right, var(--grid) 0 1px, transparent 1px 100%),
linear-gradient(to bottom, var(--grid) 0 1px, transparent 1px 100%);
background-size: 48px 48px;
}
/*
* 1920x1080 is the authoring size and the broadcast size, so it needs no transform at all.
* `?res=720` is the 2/3 downscale for thumbnails and the downscale tests: 1920 x 0.6667 = 1280.
*/
:root[data-res='720'] #stage {
transform: scale(0.6667);
}
/* -- Panels: the Game Boy dialogue box ------------------------------------------------------- */
/**
* `docs/design/gameboy-theme.md`: "Every panel is a Game Boy dialogue box: square corners, a 4 px
* outer border with a 2 px inner line (the double frame), no drop shadows, no rounded radius."
*
* The outer frame is a real `border`, so the panel's box arithmetic is unchanged (`box-sizing:
* border-box`, and `src/lib/geometry.ts`'s `BORDER_WIDTH` is the same 4). The inner line is
* `::after` at `inset: 0` of the padding box, which puts it immediately inside the frame and, being
* the last generated child, over the panel's own contents rather than under them — the DMG's text
* boxes draw their inner rule the same way round.
*
* `::before` is still the scanline wash, so the two do not collide.
*/
.panel {
position: absolute;
background: var(--panel);
border: var(--border-w) solid var(--frame);
border-radius: var(--radius);
overflow: hidden;
}
.panel::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
border: var(--border-inner-w) solid var(--frame-inner);
}
.panel__body {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
padding: 9px 12px;
gap: 6px;
}
/* Panel body texture / scanlines: never behind text, so it is a pseudo-element under content. */
.panel::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background-image: repeating-linear-gradient(
to bottom,
color-mix(in srgb, var(--ink-0) 100%, transparent) 0 1px,
transparent 1px 3px
);
opacity: var(--scanline-opacity);
}
/**
* A panel title is a *title*, so it is `--font-label` (Silkscreen) — the 2026-09-16 pairing's
* chip and title face, not Press Start 2P: RUNG, HERE FOR, BRAIN, CIRCUITS, RETINA, EVENTS and
* CHAT are all short, uppercase and closed-vocabulary, which is exactly what Silkscreen's boxy
* weight suits and where it never runs into the width tax that moved everything else to VT323
* (`src/theme/tokens.css`).
*
* `font-weight` is gone from everything in the pixel faces: neither has a bold, so a 700 here is
* a synthetic smear, which at the body floor is the difference between a readable label and a
* blurred one. Where layout v1 used weight to mark something, this theme uses the accent colour
* or the pixel cursor.
*
* 1.05, not Silkscreen's own 1.30 em box: its *ink* is only 0.76 em (0.63 ascent, 0.13 descent,
* measured), so the fixed-height rows this sits above keep the leading they were tuned at and
* clip nothing — the extra 0.30 em of the declared box is side bearing, not ink.
*/
.panel-title {
font-family: var(--font-label);
font-size: var(--fs-body);
letter-spacing: 0.06em;
color: var(--ink-2);
text-transform: uppercase;
line-height: 1.05;
flex: 0 0 auto;
}
/* -- Type roles the text-size lint checks ---------------------------------------------------- */
[data-role='body'] {
font-size: var(--fs-body);
color: var(--ink-1);
line-height: 1.25;
}
[data-role='label'] {
font-size: var(--fs-label);
color: var(--ink-0);
line-height: 1.15;
}
/*
* 1, not 1.1: the body face's *ink* is 0.76 em at the outside (Silkscreen, measured: 0.63 ascent,
* 0.13 descent), and the progress cluster is 144 px for four rows — the 4 px of leading this gives
* back is what keeps the panel from clipping its own footer. The face's declared box is 1.30 em,
* but that is side bearing and nothing is drawn in it.
*/
[data-role='label-lg'] {
font-size: var(--fs-label-lg);
color: var(--ink-0);
line-height: 1;
}
[data-role='hero'] {
font-family: var(--font-pixel);
font-size: var(--fs-hero);
color: var(--ink-0);
line-height: 1.3;
}
/*
* Digits that change, in the one monospaced face on the page.
*
* `docs/design/gameboy-theme.md`: "Tabular digits everywhere numbers change (monospace makes this
* automatic)." With Silkscreen as the body pick it is not automatic — the face is proportional and
* has no `tnum`, and every digit is 20/27 em except "1", which is 17 — so a run clock ticking at
* 1 Hz and a Hz readout lerped at 30 Hz would each shuffle 3 px sideways per "1" that came or
* went, and the two cluster readouts would drag the whole rung line with them.
*
* So `.num` is Press Start 2P (`--font-num`), which is exactly one em per character. That is the
* design's own face for "big numbers", and every element carrying this class is a number the
* paint loop rewrites: the rung count, HERE FOR, the Hz, the try count, the run clock.
*
* `font-variant-numeric` stays for the same reason the fallback stack does: it costs nothing and
* it is correct the moment `--font-num` points at a face that honours it.
*/
.num {
font-family: var(--font-num);
font-variant-numeric: tabular-nums;
}
/**
* Press Start 2P, for the two things that still carry it: `.num` above, and the wordmark
* (`src/panels/TitleStrip.tsx`). Needs a taller line box than 1.0 or a `truncate` ancestor clips
* its descenders.
*/
.pixel,
[data-role='body'].pixel,
[data-role='label'].pixel,
[data-role='label-lg'].pixel {
font-family: var(--font-pixel);
letter-spacing: 0;
line-height: 1.3;
}
/**
* The `--font-label` counterpart to `.pixel`, for a chip or glyph that is not a `.panel-title` and
* not a chip built on `.chip-cut` (which sets the same face itself): today, only the fly strip's
* eight button-row glyphs (`src/panels/FlyStrip.tsx`). Silkscreen's ink box is short enough that it
* needs none of `.pixel`'s extra leading.
*/
.label-face {
font-family: var(--font-label);
letter-spacing: 0;
line-height: 1;
}
/* -- Opaque backing plate for text that crosses the map (design A2) -------------------------- */
.plate {
background: color-mix(in srgb, var(--bg-0) 88%, transparent);
border-radius: 0;
padding: 3px 9px;
}
/* -- The pixel cursor ------------------------------------------------------------------------ */
/**
* The selection cursor, as a *drawn* triangle rather than a typed glyph.
*
* `docs/design/gameboy-theme.md` asks for "Selection cursor '▶' (as a small pixel triangle)" on
* the current rung and the active tab, and none of the three body candidates carries U+25B6 (nor
* U+2192, which is why the rung line's arrow is drawn from this same shape). A `clip-path`
* staircase is the honest way to get it: seven 2 px rows, so it is pixel art at the page's own
* resolution instead of a smooth vector triangle that the encoder would soften.
*/
.cursor {
flex: 0 0 auto;
width: 8px;
height: 14px;
background: currentcolor;
clip-path: polygon(
0 0,
2px 0,
2px 2px,
4px 2px,
4px 4px,
6px 4px,
6px 6px,
8px 6px,
8px 8px,
6px 8px,
6px 10px,
4px 10px,
4px 12px,
2px 12px,
2px 14px,
0 14px
);
}

View file

@ -0,0 +1,72 @@
/**
* "Somebody new is here": the first sighting of a display name, which the rail answers by putting
* DESCRIBE on the slot for a few seconds (`docs/design/describe-tab.md`, 2026-09-17).
*
* The operator's ask, verbatim: "when new user joins chat, switch to it for a few sec, cool down timer."
* The hold and the cooldown are the tab controller's, because they are cadence and cadence lives
* in `src/lib/tabs.ts` (`NEW_CHATTER_HOLD_MS`, `NEW_CHATTER_COOLDOWN_MS`). This module answers the
* one question the controller cannot: *is this name new*.
*
* Three ways a line is not a new chatter, and all three matter on a 24/7 broadcast:
*
* - **the bridge's own replies.** `bot: true` lines are flybridgebot answering a viewer
* (`services/bridge/src/onscreen-chat.ts`), and the bot is not a person arriving. It is still
* recorded as seen, so nothing about it can trigger later either.
* - **history.** `header.chat` is a ring the feed re-sends every snapshot, so the lines present
* when the page connected were said before anyone was watching *this* page, and a socket
* reconnect hands the whole ring over again. A line older than the page's connect time is
* therefore never a trigger — which is also what keeps a recorded fixture inert: its lines
* carry the wall time of the recording, hours or days before the page loaded, so replaying
* `steady` cannot make the slot jump (and cannot move a screenshot baseline).
* - **a name already seen.** Every line's name is recorded whether or not it triggered, so the
* second sighting is never the first, including after the cooldown swallowed the first switch.
*
* Names are keyed case-folded: Twitch display names differ from logins only by case and
* punctuation, and `Dendrite` arriving after `dendrite` is the same person.
*
* Pure and DOM-free, clock supplied by the caller, so `tests/unit/new-chatter.test.ts` drives the
* whole thing with a scripted chat ring.
*/
import type { ChatLine } from '@/chat/types';
export class ChatterWatch {
private readonly seen = new Set<string>();
/**
* Wall-clock instant the page connected. Lines older than this are history, never arrivals.
*
* Wall clock rather than the page's data clock on purpose: the data clock is virtual under a
* fixture and freezes on a held seek, while `ChatLine.wallMs` is a real `Date.now()` from
* whoever accepted the line. Comparing the two in the same units is the only version of this
* test that is right for both a live socket and a replay.
*/
constructor(private readonly connectedAtWallMs: number) {}
/** A fixture seek or loop: nothing this page saw, it saw. The connect time is not a seek's to move. */
reset(): void {
this.seen.clear();
}
/**
* Record one snapshot's chat ring. True when at least one line in it is somebody new arriving.
*
* Every line is recorded as seen either way, so a name that shows up while the cooldown is
* running does not get a second chance at it later.
*/
observe(lines: readonly ChatLine[] | undefined): boolean {
if (!lines || lines.length === 0) return false;
let arrived = false;
for (const line of lines) {
const key = line.by.toLowerCase();
const fresh = !this.seen.has(key);
this.seen.add(key);
if (fresh && line.bot !== true && line.wallMs >= this.connectedAtWallMs) arrived = true;
}
return arrived;
}
/** How many distinct names this page has seen, for `window.__stage` and the tests. */
get count(): number {
return this.seen.size;
}
}

View file

@ -0,0 +1,161 @@
/**
* How a NAMED CIRCUITS bar decides what "full" means, without ever being told the feed's
* absolute Hz range.
*
* The bug this replaces: the panel used to divide every role's rate by a fixed `fullScaleHz`
* picked against the fixture generator's own numbers (`src/lib/labels.ts`). The first live run
* (`infra/docs/p0-local-encoded-frame.png`) showed every command bar pegged at 100% because the
* real per-role rates run far above what the fixture ever produced — a fixed ceiling can always
* be exceeded by a feed nobody measured yet.
*
* The fix is the same idea `docs/readout.md` already uses for the decoder itself: score a rate
* against a *reference* recorded from the feed, not against a constant. `PopulationDecoder`
* scores `(rate + 1) / (baseline + 1)` against a baseline captured once at calibration. A bar
* cannot do that — the page never sees the decoder's calibration baseline — so `CircuitScale`
* keeps its own slow-moving reference per role instead: an envelope that climbs toward a
* sustained high rate over `attackHalfLifeMs` and relaxes back down over `releaseHalfLifeMs`.
*
* Two time constants, not one, is what makes a burst read as a burst instead of just being
* "the new normal" a frame later: a fast pulse (an A/B press, well under a second) barely moves
* an envelope with a multi-second attack, so it still reads near full scale while it lasts, and
* the envelope only relaxes back toward the lower steady rate afterward, over tens of seconds
* — which is also why a bar sits mid-scale rather than pinned at 100% once the reference has
* caught up: `headroom` keeps the fill at `1 / headroom` when the rate exactly equals its own
* reference, leaving room above for the next burst to still be visible as one.
*/
/** How far above its own reference a role's rate reads as "full", so steady state has headroom
* left for a real burst to still stand out. 1.5 puts a settled bar at 1/1.5 ≈ 67%. */
export const DEFAULT_HEADROOM = 1.5;
/** How fast the reference climbs toward a sustained higher rate. */
export const DEFAULT_ATTACK_HALF_LIFE_MS = 4_000;
/** How fast the reference relaxes back down once the rate drops — the "decays back" half of the
* unit test, and roughly the 60 s memory window the fix asks for (three half-lives is ~87%). */
export const DEFAULT_RELEASE_HALF_LIFE_MS = 20_000;
/** Never let a reference (or the median below) collapse toward zero during a quiet boot. */
export const DEFAULT_FLOOR_HZ = 1;
export interface CircuitScaleOptions {
attackHalfLifeMs?: number;
releaseHalfLifeMs?: number;
headroom?: number;
floorHz?: number;
}
/** Clamp a raw Hz value to a `[0, 1]` bar fill against a reference, `headroom` included. Pure —
* used for the live rate, the peak-hold dot and the threshold tick alike, so all three read off
* the same scale. */
export function circuitFraction(valueHz: number, referenceHz: number, headroom: number = DEFAULT_HEADROOM): number {
const safeReference = Math.max(referenceHz, 1e-6) * headroom;
const fraction = Math.max(0, valueHz) / safeReference;
return Math.max(0, Math.min(1, fraction));
}
/**
* Per-role adaptive envelope. One instance per bar role, fed every snapshot (not every animation
* frame) so a fixture seek's silent catch-up replay builds the same reference a live viewer would
* have watched settle in real time (`src/feed/fixture.ts`'s seek contract).
*/
export class CircuitScale {
private reference: number;
private lastMs: number | null = null;
private readonly attackHalfLifeMs: number;
private readonly releaseHalfLifeMs: number;
private readonly headroom: number;
private readonly floorHz: number;
constructor(seedHz: number, options: CircuitScaleOptions = {}) {
this.attackHalfLifeMs = options.attackHalfLifeMs ?? DEFAULT_ATTACK_HALF_LIFE_MS;
this.releaseHalfLifeMs = options.releaseHalfLifeMs ?? DEFAULT_RELEASE_HALF_LIFE_MS;
this.headroom = options.headroom ?? DEFAULT_HEADROOM;
this.floorHz = options.floorHz ?? DEFAULT_FLOOR_HZ;
this.reference = Math.max(seedHz, this.floorHz);
}
/** Advance the envelope to `nowMs` given the latest rate. Call once per role per snapshot. */
observe(valueHz: number, nowMs: number): void {
const value = Math.max(0, valueHz);
if (this.lastMs !== null) {
const dtMs = Math.max(0, nowMs - this.lastMs);
const halfLife = value >= this.reference ? this.attackHalfLifeMs : this.releaseHalfLifeMs;
const decay = halfLife > 0 ? Math.pow(0.5, dtMs / halfLife) : 0;
this.reference = value + (this.reference - value) * decay;
}
this.lastMs = nowMs;
this.reference = Math.max(this.reference, this.floorHz);
}
/** `observe` then read the fraction back in one call — the common case for a live bar. */
update(valueHz: number, nowMs: number): number {
this.observe(valueHz, nowMs);
return this.toFraction(valueHz);
}
/** Project any Hz value (the peak-hold dot, an inferred threshold) against the *current*
* reference without advancing it. */
toFraction(valueHz: number): number {
return circuitFraction(valueHz, this.reference, this.headroom);
}
get referenceHz(): number {
return this.reference;
}
}
/**
* A cheap streaming approximation of the running median, used as the display's stand-in for the
* decoder's real calibration baseline (`docs/readout.md`'s `(rate + 1) / (baseline + 1)` score),
* which the feed protocol does not carry to the page (`docs/feed-protocol.md`'s `FeedHeader` has
* no baseline field). It is not an exact order statistic — it nudges toward the value at a fixed
* Hz-per-second rate rather than maintaining a sorted window — but it converges to the true
* median of a role's rate over tens of seconds, which is precise enough for a faint tick mark.
* Documented here and in `docs/readout.md` per the fix's own "document it" instruction.
*/
export class RunningMedian {
private median: number;
private readonly stepHzPerMs: number;
private readonly floorHz: number;
constructor(seedHz: number, stepHzPerSecond = 2, floorHz: number = DEFAULT_FLOOR_HZ) {
this.floorHz = floorHz;
this.median = Math.max(seedHz, floorHz);
this.stepHzPerMs = stepHzPerSecond / 1000;
}
observe(valueHz: number, dtMs: number): void {
const value = Math.max(0, valueHz);
const step = this.stepHzPerMs * Math.max(0, dtMs);
if (value > this.median) this.median = Math.min(value, this.median + step);
else if (value < this.median) this.median = Math.max(value, this.median - step);
this.median = Math.max(this.median, this.floorHz);
}
get medianHz(): number {
return this.median;
}
}
/**
* Invert the decoder's own score formula (`docs/readout.md`: `score = (rate + 1) / (baseline +
* 1)`) to find the rate a role would need to cross a given decision threshold, using the running
* median in place of the real baseline. This is what the faint threshold tick is positioned at.
*/
export function thresholdRateHz(decisionThreshold: number, medianHz: number): number {
return Math.max(0, decisionThreshold * (Math.max(0, medianHz) + 1) - 1);
}
/**
* The decoder's decision threshold (`docs/readout.md`), by `CircuitGroup.id`, for the two groups
* the readout actually gates on a fixed score: A/B at 1, Start/Select at 1.35 after boot. The
* drive D-pad is an exclusive argmax with no fixed threshold, so it is absent here.
*
* TODO: this belongs on `CircuitGroup` in `src/lib/labels.ts` next to `fullScaleHz` — kept here
* instead for now because another pass is editing that file's copy concurrently with this fix.
*/
export const CIRCUIT_DECISION_THRESHOLD: Record<string, number> = {
press: 1,
menu: 1.35,
};

View file

@ -0,0 +1,86 @@
/**
* The DESCRIBE tab's placeholder resolver: the half of the copy that is not copy.
*
* `src/games/describe.ts` holds every word the tab says and nothing else, so the live numbers it
* quotes arrive as `{…}` placeholders and are filled here from the page's own sources — the loaded
* dataset metadata (`store.dataset`), the game config's name, and the build's version string. The
* doc's rule, verbatim: "names and numbers come from the feed where they exist (neuron count,
* version); the rest is static copy".
*
* Pure and DOM-free, so `tests/unit/describe.test.ts` can render every card against the dataset's
* own `meta.json` and compare the result with the doc.
*
* An unknown value renders as the page's own em dash rather than a guess or a hardcoded default: a
* card that quoted a baked-in 139,255 while the dataset said something else would be the one thing
* this tab cannot afford, which is a sentence that is not true.
*/
import { formatCount } from './format';
/** Everything a card may interpolate. Keys are the placeholder names, minus the braces. */
export interface DescribeValues {
/** Dataset neuron count, or null until `meta.json` has landed. */
neurons: number | null;
/** Dataset connection count, or null. */
synapses: number | null;
/** The game config's human name: the only place a game is named. */
game: string;
/** Dataset version, e.g. `v783`, or null. */
dataset: string | null;
/** Release version from the build (`__STAGE_VERSION__`). */
version: string;
}
/** The placeholders `fill` resolves. A copy edit may use any of these and no others. */
export const DESCRIBE_PLACEHOLDERS: readonly (keyof DescribeValues)[] = [
'neurons',
'synapses',
'game',
'dataset',
'version',
];
/** What an unfilled number reads as, matching `src/lib/format.ts`. */
const UNKNOWN = '—';
/**
* A synapse count as the copy says it: `2.7 million`, not `2,700,513`.
*
* The doc's register is "every sentence a fact", and 2,700,513 in the middle of a sentence is a
* number a viewer stops to parse. One decimal, trailing `.0` dropped, and anything under a million
* falls back to plain separators — a smaller connectome would be a different dataset, not a
* rounding problem.
*/
export function formatMillions(value: number): string {
if (!Number.isFinite(value)) return UNKNOWN;
if (Math.abs(value) < 1_000_000) return formatCount(value);
const millions = (value / 1_000_000).toFixed(1).replace(/\.0$/, '');
return `${millions} million`;
}
/** Placeholder name -> rendered string. */
function resolve(values: DescribeValues): Record<string, string> {
return {
neurons: values.neurons === null ? UNKNOWN : formatCount(values.neurons),
synapses: values.synapses === null ? UNKNOWN : formatMillions(values.synapses),
game: values.game,
dataset: values.dataset ?? UNKNOWN,
version: values.version,
};
}
/**
* Fill one card's text.
*
* An unknown placeholder is left exactly as it was written, braces and all, so a typo shows up on
* the mockup a reviewer is looking at instead of silently deleting half a sentence on air. The
* unit test fails on one, which is where it is meant to be caught.
*/
export function fillDescribe(text: string, values: DescribeValues): string {
const table = resolve(values);
return text.replace(/\{(\w+)\}/g, (whole, key: string) => table[key] ?? whole);
}
/** Every placeholder a card's text uses, in order of appearance. */
export function placeholdersIn(text: string): string[] {
return [...text.matchAll(/\{(\w+)\}/g)].map((match) => match[1] as string);
}

88
apps/stage/src/lib/fit.ts Normal file
View file

@ -0,0 +1,88 @@
/**
* Uniform, aspect-preserving fit of a 2D point cloud into a rectangular canvas.
*
* One scale for both axes — never two — sized so the whole extent fits inside the box without
* cropping either axis, and centered (letterboxed on whichever axis has slack). Shared by the
* connectome's base raster, its density-accumulator LUT and its PAM-centroid flare origin
* (`workers/brain-base.worker.ts`), and by their unit tests, so none of them can compute a
* different scale than the others and quietly pile points onto an edge.
*
* The one thing this deliberately does not do is clamp: a point outside the fitted box is
* dropped (`project` returns `null`). Fitting a point cloud to its own true extent, with margin,
* means nothing should ever fall outside it — but "should never happen" is exactly the case a
* clamp turns into a silent pileup on the first or last row instead of a bug report.
*/
/** Margin so the extreme point sits just inside the edge, never touching it. */
export const FIT_MARGIN = 0.97;
/** The position-space box that a uniform letterboxed fit maps onto a `width` x `height` canvas. */
export interface HalfExtent {
/** Half-width, in the point cloud's own units, that maps exactly to the canvas's left/right edge. */
x: number;
/** Half-height, in the point cloud's own units, that maps exactly to the canvas's top/bottom edge. */
y: number;
}
/**
* The half-extents of the box that fits `maxAbsX` x `maxAbsY` inside `width` x `height`.
*
* `Math.min` picks whichever axis is the tighter fit, and the same resulting scale sizes both
* halves, so whatever is plotted with them keeps its own aspect ratio — it is letterboxed on the
* other axis, never stretched to fill it.
*/
export function fitHalfExtent(
maxAbsX: number,
maxAbsY: number,
width: number,
height: number,
margin = FIT_MARGIN,
): HalfExtent {
const scale = Math.min(width / (2 * (maxAbsX || 1)), height / (2 * (maxAbsY || 1))) * margin;
return { x: width / 2 / scale, y: height / 2 / scale };
}
/**
* The half-extents of a flattened xyz point cloud on x and y, which is what `fitHalfExtent` has
* to be fed to fit that cloud to a canvas.
*
* Shared rather than inlined at the one call site (`brain-base.worker.ts`) so that the unit tests
* can fit the real dataset the way the worker fits it — the worker itself cannot be imported into
* a test, since it installs a `self` message listener on load. A loop this small is exactly the
* kind that gets copied into a test, drifts, and leaves the test passing over a fit the worker no
* longer computes.
*/
export function pointCloudExtent(positions: Float32Array): { maxAbsX: number; maxAbsY: number } {
const count = Math.floor(positions.length / 3);
let maxAbsX = 0;
let maxAbsY = 0;
for (let i = 0; i < count; i++) {
maxAbsX = Math.max(maxAbsX, Math.abs(positions[i * 3] as number));
maxAbsY = Math.max(maxAbsY, Math.abs(positions[i * 3 + 1] as number));
}
return { maxAbsX, maxAbsY };
}
/**
* Project one centered point into pixel coordinates against `halfExtent`, or `null` when it
* falls outside the fitted box.
*
* Dropped, never clamped: an out-of-range point disappears rather than piling onto the first or
* last row or column.
*/
export function project(
x: number,
y: number,
halfExtent: HalfExtent,
width: number,
height: number,
): { x: number; y: number } | null {
const nx = x / halfExtent.x;
const ny = y / halfExtent.y;
if (nx < -1 || nx > 1 || ny < -1 || ny > 1) return null;
return {
x: Math.min(width - 1, Math.round(((nx + 1) / 2) * (width - 1))),
// Screen y grows downward; the point cloud's y grows upward.
y: Math.min(height - 1, Math.round(((1 - ny) / 2) * (height - 1))),
};
}

View file

@ -0,0 +1,130 @@
/**
* Number and duration formatting for the readouts.
*
* Everything here is tabular-safe: fixed decimal places and fixed-width unit words, so a value
* changing 30 times a second never reflows the layout (the jitter the audit found on the old
* page). The face does the rest — `.num` in `src/index.css`, which is Press Start 2P at exactly
* one em per character, because the body face (Silkscreen) is proportional and has no `tnum`.
*/
/** `3 h 41 m`, `12 m 04 s`, `48 s`. Coarse on purpose: a stream clock is not a stopwatch. */
export function formatDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return '—';
const total = Math.floor(seconds);
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
if (hours > 0) return `${hours} h ${String(minutes).padStart(2, '0')} m`;
if (minutes > 0) return `${minutes} m ${String(secs).padStart(2, '0')} s`;
return `${secs} s`;
}
/**
* `3h41m`, `12m04s`, `48s`: the same information as {@link formatDuration} with no spaces.
*
* Used for HERE FOR and the two "ago" readouts. Layout v2 has no hero (README deviation 5), but
* the six-character ceiling this form guarantees matters more than it did: HERE FOR is Press
* Start 2P at 33 px, a full em per character, in a column that starts at five characters. The
* spaced form would be nine and take the width out of the rung line beside it.
*/
export function formatDurationCompact(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return '—';
const total = Math.floor(seconds);
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
if (hours > 0) return `${hours}h${String(minutes).padStart(2, '0')}m`;
if (minutes > 0) return `${minutes}m${String(secs).padStart(2, '0')}s`;
return `${secs}s`;
}
/**
* `0:47`, `2:00`: the stall meter, which is the one readout on the page measured in seconds.
*
* Its window is 120 s (`docs/design/ladder.md`), so `formatDurationCompact`'s "48s" then "1m00s"
* changes width halfway through and the meter jumps. Minutes and seconds, always, fixed width.
*/
export function formatMinutesSeconds(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
const total = Math.floor(seconds);
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
}
/** `104:12:33`, for the run clock where every digit is wanted. */
export function formatClock(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return '--:--:--';
const total = Math.floor(seconds);
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
/** Day 1 is the first 24 h of simulated run time. */
export function dayNumber(runSeconds: number): number {
if (!Number.isFinite(runSeconds) || runSeconds < 0) return 1;
return Math.floor(runSeconds / 86_400) + 1;
}
/** One decimal, always. `13.2`. */
export function formatHz(hz: number): string {
if (!Number.isFinite(hz)) return '—';
return hz.toFixed(1);
}
/** Thousands separators with a thin space, which survives the encoder better than a comma. */
export function formatCount(value: number): string {
if (!Number.isFinite(value)) return '—';
return Math.round(value).toLocaleString('en-US');
}
/** `+0.05`, `+3.0`. Reward values are always shown signed so a tick reads as a gain. */
export function formatReward(value: number): string {
if (!Number.isFinite(value)) return '—';
const decimals = Math.abs(value) >= 1 ? 1 : 2;
return `${value >= 0 ? '+' : '−'}${Math.abs(value).toFixed(decimals)}`;
}
/** `0.98x`, the realtime factor. */
export function formatRealtime(factor: number): string {
if (!Number.isFinite(factor)) return '—';
return `${factor.toFixed(2)}x`;
}
/** Seconds remaining on a cooldown, rounded up so it never shows 0 while still blocking. */
export function formatCooldown(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return 'ready';
return `${Math.ceil(ms / 1000)} s`;
}
/** One `game.counters` entry, formatted: `3/8 badges` when `outOf` is set, else `214 places`. */
export function formatCounter(counter: { field: string; label: string; outOf?: number }, value: number): string {
const shown = formatCount(value);
return counter.outOf !== undefined ? `${shown}/${counter.outOf} ${counter.label}` : `${shown} ${counter.label}`;
}
/**
* The ladder panel's terse game-counters line: `3/8 badges · 214 places`. No sentence, no label
* beyond the counters themselves.
*/
export function formatCounters(
counters: readonly { field: string; label: string; outOf?: number }[],
values: Record<string, number>,
): string {
return counters.map((counter) => formatCounter(counter, values[counter.field] ?? 0)).join(' · ');
}
/**
* Only display names the feed carried, and only in the shape the bridge promises
* (`^[\p{L}\p{N}_]{1,25}$`). Defence in depth: the bridge validates before the sim call, and the
* page validates again on render, so no path exists from chat text to the video frame.
*/
const DISPLAY_NAME = /^[\p{L}\p{N}_]{1,25}$/u;
/** A safe display name, or the literal fallback the bridge uses. */
export function safeDisplayName(name: string | null | undefined): string {
if (typeof name !== 'string') return 'a viewer';
return DISPLAY_NAME.test(name) ? name : 'a viewer';
}

View file

@ -0,0 +1,425 @@
/**
* Rail layout v2 geometry in 1920x1080 authoring pixels, as numbers.
*
* One authoring resolution: everything is laid out in these CSS pixels inside `#stage`, which is
* the native broadcast frame (`docs/design/fly-avatar.md`, "Canvas"). `?res=720` is
* `transform: scale(0.6667)` on that one element and exists only for thumbnails and the
* downscale tests. So these numbers are the only layout truth in the app, the panels take their
* absolute positions from them, and the e2e tests assert against them rather than against
* hand-copied constants.
*
* The rail is the locked "Rail layout v2" of `docs/stream-mvp-plan.md`: a compact progress
* cluster, one tabbed slot, the events ticker, and a persistent chat panel. Verified arithmetic:
*
* left 40 + 720 + 4 + 220 = 984 (title, game, gap, fly strip)
* rail 144 + 12 + 420 + 12 + 100 + 12 + 244 = 944 (= 720 + 4 + 220)
* width 800 + 12 + 1012 = 1824
* rail y 88 .. 1032 (usable, after the 48 px insets)
* slot 420 = 48 tab strip + 4 frame + 368 content
* strip 4 + 416 + 12 + 364 + 4 = 800 (frame, fly, gap, macro pad, frame)
*/
import { MACRO_TYPES } from '@flybrain/feed';
/** Authoring viewport. Not configurable: the encoder, the capture and every test assume it. */
export const STAGE_WIDTH = 1920;
export const STAGE_HEIGHT = 1080;
/** Safe inset on all four sides. Background art may bleed past it; nothing load-bearing may. */
export const INSET = 48;
/** Gutter between cells inside one panel. */
export const GUTTER = 4;
/**
* Gutter between the rail's four panels.
*
* 12, not the 4 of layout v1: four panels instead of five, and the locked geometry puts the tab
* slot at 244 and the chat panel at 788, which are only reachable with 12 px between rows.
*/
export const RAIL_GUTTER = 12;
/** Gutter between the two columns. */
export const COLUMN_GAP = 12;
/** Usable box after the insets. */
export const USABLE_WIDTH = STAGE_WIDTH - INSET * 2;
export const USABLE_HEIGHT = STAGE_HEIGHT - INSET * 2;
/** Integer scale of the 160x144 emulator framebuffer. 5x is 800x720 in a native 1080p frame. */
export const GAME_SCALE = 5;
export const GAME_NATIVE_WIDTH = 160;
export const GAME_NATIVE_HEIGHT = 144;
export const GAME_WIDTH = GAME_NATIVE_WIDTH * GAME_SCALE;
export const GAME_HEIGHT = GAME_NATIVE_HEIGHT * GAME_SCALE;
/** Title strip across the full usable width. */
export const TITLE_HEIGHT = 40;
/**
* The fly strip: a plain row of eight button indicators along its top edge, then the 3D fly on one
* side and the macro palette beside it, under the game.
*/
export const FLY_STRIP_WIDTH = GAME_WIDTH;
export const FLY_STRIP_HEIGHT = 220;
/** Height of the plain button row along the fly strip's top edge, at 1080p authoring. */
export const FLY_BUTTON_ROW_HEIGHT = 32;
/** Right rail. */
export const RAIL_WIDTH = USABLE_WIDTH - GAME_WIDTH - COLUMN_GAP;
/**
* Panel frame width, mirroring `--border-w` in `src/theme/tokens.css`.
*
* 4, not layout v1's 2: `docs/design/gameboy-theme.md` makes every panel a dialogue box with "a
* 4 px outer border with a 2 px inner line". The outer one is the real `border`, so it is the one
* the interior arithmetic has to subtract — the inner line is a pseudo-element over the padding
* box and takes no space. Every panel's *outer* box is unchanged (`box-sizing: border-box`), so
* the only numbers that move are the ones derived from the inside of the tab slot.
*/
export const BORDER_WIDTH = 4;
/** The inner line of the double frame, mirroring `--border-inner-w`. Drawn, never laid out. */
export const BORDER_INNER_WIDTH = 2;
/** The row below the button row: the fly and the palette share it, and its baseline. */
export const FLY_ROW_HEIGHT = FLY_STRIP_HEIGHT - BORDER_WIDTH * 2 - FLY_BUTTON_ROW_HEIGHT - GUTTER;
/** Gap between the fly's canvas and the palette beside it. */
export const FLY_PALETTE_GAP = 12;
/**
* The fly's own canvas, now 416 wide instead of the strip's full 792.
*
* The operator, 2026-09-16: "slide the fly over and put the macro palette right next to it". The number
* is not a taste call — it is the *widest* the fly can be and still leave the palette clear of the
* no-content zone. Twitch overlays chat and extensions over the bottom left of the player
* ({@link NO_CONTENT_ZONE}, x < 480), the palette's cells carry text, and the palette's bottom row
* is at y ≈ 970, inside the zone's band. So the palette starts at exactly x = 480 and the fly gets
* everything left of it: 52 (the strip's inner edge) + 416 + 12 = 480. The fly's canvas may reach
* into the zone, as it always has, because it carries no text at all.
*
* The vertical field of view is unchanged (`src/fly/camera.ts`), so the fly is the same size in
* pixels and the narrower canvas crops the shot rather than shrinking the animal — which is what
* "shrunk to make room" has to mean for a perspective camera with a fixed vertical framing.
*/
export const FLY_CANVAS_WIDTH = 416;
export const FLY_CANVAS_HEIGHT = FLY_ROW_HEIGHT;
/**
* The pad beside it: the rest of the strip's inner width, and all of its inner height.
*
* 364 wide is not a choice — it is what is left once the fly's canvas has stopped at x = 480, the
* no-content zone's right edge ({@link NO_CONTENT_ZONE}, and {@link FLY_CANVAS_WIDTH} has the
* reasoning). The fly did not shrink for section 14's second column: 480 is where the pad's text
* has to start, so a narrower fly would only move readable cells under Twitch's chat overlay.
* What the pad took instead was the 36 px band the button row used to span, which is height
* nothing else wanted.
*/
export const MACRO_PALETTE_WIDTH =
FLY_STRIP_WIDTH - BORDER_WIDTH * 2 - FLY_CANVAS_WIDTH - FLY_PALETTE_GAP;
export const MACRO_PALETTE_HEIGHT = FLY_STRIP_HEIGHT - BORDER_WIDTH * 2;
/**
* The strip's cells: the macros on the pad *now*, up to fourteen, two columns of seven.
*
* The layout decision at the end of `docs/design/macros.md` section 14: the strip under the game
* shows every button on the pad now, at the 24 px floor, and the whole keyboard of thirty-one
* types lives on the MACROS tab instead ({@link MACRO_BOARD_COLUMNS}). Fourteen is what two
* columns of seven hold and it is comfortably above the widest pad any scene deals — ten, the
* indoor overworld inside a centre (section 13.1) — so nothing is cut in practice, and a pad that
* did overflow would lose its *last* types rather than its first.
*
* Seven rows of 28 plus six 2 px gaps is the pad's 208 px of inner height exactly, and two
* columns of 179 plus one 2 px gap is its 360 px of inner width. The 2 px gap rather than a frame
* per cell is the spine's treatment (`docs/design/gameboy-theme.md`: "a row of square cells with
* 2 px gaps"), and it is also what lets a 24 px line sit in the row.
*/
export const MACRO_CELL_COLUMNS = 2;
export const MACRO_CELL_ROWS = 7;
export const MACRO_CELL_COUNT = MACRO_CELL_COLUMNS * MACRO_CELL_ROWS;
export const MACRO_CELL_GAP = 2;
/** Inset between the palette's own edge and its cells. */
export const MACRO_PALETTE_PAD = 2;
export const MACRO_CELL_HEIGHT =
(MACRO_PALETTE_HEIGHT - MACRO_PALETTE_PAD * 2 - MACRO_CELL_GAP * (MACRO_CELL_ROWS - 1)) / MACRO_CELL_ROWS;
export const MACRO_CELL_WIDTH =
(MACRO_PALETTE_WIDTH - MACRO_PALETTE_PAD * 2 - MACRO_CELL_GAP * (MACRO_CELL_COLUMNS - 1)) / MACRO_CELL_COLUMNS;
/**
* Width of a cell's channel tag, and the *minimum* width of its name column.
*
* Both are measured in VT323 at the 24 px body floor, which is the change section 14's second
* column paid for: the cells were Silkscreen, whose 0.75 em advance made `MB·FRONT` 135 px and
* `GO OBJECTIVE` 195, and 330 px of one cell does not go into 179 twice. VT323 is the page's own
* body face at 0.4 em (`src/theme/tokens.css`), so the same eight-character tag is 77 px and the
* cell holds the tag *and* a name at the floor rather than one of them above it.
*
* 85 is that 77 plus the glyph chip's 8 px of padding. 87 is nine characters of VT323, which is
* what {@link MACRO_SHORT_NAME_MAX} caps the strip's names at, and the two plus the cell's own
* padding and gap spend 178 of the 179 a cell has. The MACROS tab's cells are 332 px and carry the
* full name instead ({@link MACROS_TAB_CELL_WIDTH}); the gloss stays on the wire in both places.
*/
export const MACRO_CHANNEL_WIDTH = 85;
export const MACRO_NAME_WIDTH = 87;
/**
* Longest short name the pad's cells draw, in characters.
*
* Nine of VT323 at the floor is 87 px ({@link MACRO_NAME_WIDTH}), which is what a 179 px cell has
* left once the tag chip has taken its 85. Six of the contract's names are longer than that, and
* `src/lib/macro-names.ts` is the table that shortens them — with a truncating fallback, so a type
* this page has never heard of still draws a name that fits rather than an ellipsis.
*/
export const MACRO_SHORT_NAME_MAX = 9;
/**
* The SENSES panel's MACROS row: one labelled bar per bound channel (section 12).
*
* Two columns of three rather than a stack of six, and the numbers are measured rather than
* chosen. The groups column is 317 px and the six fixed groups take 260 of it (35 for a group
* whose name sets its height, 60 for the two with four bars), so this row has 57 px. Six labelled
* rows in one column need 94 — a 24 px label's cap box is 13.4 px of VT323, so the pitch cannot go
* below 16 — and every bar on the panel gets squeezed when it overflows. Three rows of two fit in
* 46.
*
* Which is what the width is spent on: this group's name column is 148 ("MACROS" in Silkscreen at
* the label size is 142) instead of the 200 that "dopamine" needs, leaving 264 for two 126 px
* cells — the channel tag in VT323 at the body floor (eight characters at 0.4 em is 77 px) and a
* 38 px track.
*/
export const MACRO_BAR_ROW_HEIGHT = 14;
export const MACRO_BAR_LABEL_WIDTH = 84;
/** The MACROS row's own name column: 148, not `CIRCUIT_NAME_WIDTH`, to pay for the second column. */
export const MACRO_CIRCUIT_NAME_WIDTH = 148;
/** Rail row heights, top to bottom. Locked in `docs/stream-mvp-plan.md`, "Rail layout v2". */
export const RAIL_ROWS = {
/** Progress cluster: rung line, 38-rung spine, counters, clock, sugar chip. */
progress: 144,
/** The tabbed slot: 48 px tab strip plus the pane. */
tabs: 420,
/** EVENTS: three ticker rows, no title (the rows say what they are). */
events: 100,
/** CHAT: seven lines and a title. */
chat: 244,
} as const;
/** Total height of the right rail: four rows plus three inter-row gutters (944). */
export const RAIL_HEIGHT =
RAIL_ROWS.progress + RAIL_ROWS.tabs + RAIL_ROWS.events + RAIL_ROWS.chat + RAIL_GUTTER * 3;
/** Height of the tab strip inside the slot. */
export const TAB_STRIP_HEIGHT = 48;
/**
* Retina raster canvas, inside the SENSES pane's left cell.
*
* Both eyes, big: the pane is 1004x368 and the retina takes 560 of it, which leaves 548x316 for
* the canvas after the cell's 6 px padding, its 30 px label row and the gap between them. 2.1x
* the area the raster had in layout v1's 360 px rail cell, which is what the locked layout's
* "retina raster left (both eyes, big)" asks for. The height is measured rather than derived: the
* label's line box rounds up, and the structural test fails the panel if the sum overruns.
*/
export const SENSES_RETINA_CELL_WIDTH = 560;
export const RETINA_CANVAS_WIDTH = 548;
export const RETINA_CANVAS_HEIGHT = 316;
/**
* Fixed width of a circuit group's name, inside the SENSES pane's narrower right cell.
*
* 200, because the longest of the six group labels is "dopamine" and in Silkscreen at the 33 px
* label size that is 196 px (measured). It was 150 under VT323, whose 0.4 em advance made the same
* word 119; the pick changed and this is one of the places that had to follow it, because the
* alternative was a group name reading "dopamin…" on the panel that names the fly's reward
* circuit. The 50 px comes off the bar track, which is measured at runtime and quantised against
* whatever it turns out to be (`src/App.tsx`), so no cell arithmetic depends on this number.
*/
export const CIRCUIT_NAME_WIDTH = 200;
/**
* Width of the LADDER pane's right-hand column: rollbacks, lifetime, last and the stall meter.
*
* 130, not the 236 it was when this column also held the best-snapshot thumbnail (dropped
* 2026-09-16, the operator: give the freed width to the rung names instead — three names a column no
* longer abbreviate to "Viridia…" at this width, see `docs/design/gameboy-theme.md` deviation 8,
* now resolved). 130 is a compact narrow column rather than the tightest that would fit: the
* widest readout here ("0:47 ago" style rollback age) still occasionally ellipsises at the far
* end of an hour-plus run, which the column accepts as a rare cost.
*/
export const LADDER_STATS_WIDTH = 130;
/** Chat lines kept on screen. */
export const CHAT_LINES = 7;
/** Absolute positions of every region, in authoring pixels from the top left of `#stage`. */
export const LAYOUT = (() => {
const left = INSET;
const top = INSET;
const title = { x: left, y: top, width: USABLE_WIDTH, height: TITLE_HEIGHT };
const game = { x: left, y: title.y + title.height, width: GAME_WIDTH, height: GAME_HEIGHT };
const flyStrip = {
x: left,
y: game.y + game.height + GUTTER,
width: FLY_STRIP_WIDTH,
height: FLY_STRIP_HEIGHT,
};
/** The fly's canvas and the palette: one row inside the strip's frame, one baseline. */
const flyRowY = flyStrip.y + BORDER_WIDTH + FLY_BUTTON_ROW_HEIGHT + GUTTER;
const flyPane = {
x: flyStrip.x + BORDER_WIDTH,
y: flyRowY,
width: FLY_CANVAS_WIDTH,
height: FLY_ROW_HEIGHT,
};
const macroPalette = {
x: flyPane.x + flyPane.width + FLY_PALETTE_GAP,
y: flyStrip.y + BORDER_WIDTH,
width: MACRO_PALETTE_WIDTH,
height: MACRO_PALETTE_HEIGHT,
};
const railX = left + GAME_WIDTH + COLUMN_GAP;
let y = title.y + title.height;
const row = (height: number, width = RAIL_WIDTH) => {
const box = { x: railX, y, width, height };
y += height + RAIL_GUTTER;
return box;
};
const progress = row(RAIL_ROWS.progress);
const tabs = row(RAIL_ROWS.tabs);
const events = row(RAIL_ROWS.events);
const chat = row(RAIL_ROWS.chat);
/** The pane below the tab strip: the box every tab's content is drawn into. */
const tabContent = {
x: tabs.x + BORDER_WIDTH,
y: tabs.y + TAB_STRIP_HEIGHT,
width: tabs.width - BORDER_WIDTH * 2,
height: tabs.height - TAB_STRIP_HEIGHT - BORDER_WIDTH,
};
return { title, game, flyStrip, flyPane, macroPalette, progress, tabs, tabContent, events, chat } as const;
})();
/** The rail as one box: what the border flash outlines and the particle layer is sized against. */
export const RAIL_BOX = {
x: LAYOUT.progress.x,
y: LAYOUT.progress.y,
width: RAIL_WIDTH,
height: RAIL_HEIGHT,
} as const;
/**
* The brain map's backing store: the tab pane, exactly.
*
* The map is no longer an inset that promotes over the rail (layout v1) — it is the CONNECTOME
* tab, drawn at slot size and nothing else, so the backing store is the pane's own 1004x368 and
* there is no promotion transform at all. `docs/stream-mvp-plan.md`'s "big moments pre-empt for
* 9 s" is now a tab focus, which is a crossfade rather than a scale.
*/
export const MAP_HERO_WIDTH = LAYOUT.tabContent.width;
export const MAP_HERO_HEIGHT = LAYOUT.tabContent.height;
/**
* Density accumulator grid.
*
* 1004/4 = 251 and 368/2 = 184, so the cell is 4x2 device pixels: the two axes take different
* divisors because the pane's height is not a multiple of 4 and a fractional grid would put the
* sprite pass a subpixel off the cell it belongs to. `brainmap.ts` derives the per-axis scale
* from these rather than from one shared divisor.
*/
export const MAP_GRID_WIDTH = MAP_HERO_WIDTH / 4;
export const MAP_GRID_HEIGHT = MAP_HERO_HEIGHT / 2;
/**
* The MACROS tab's keyboard: every macro type there is, three columns, rows as needed.
*
* `docs/design/macros.md` section 14: "a new rail tab MACROS shows the whole keyboard, three
* columns of eleven, bound cells lit and unbound dim, the running one bright". Eleven rows is the
* 31-type contract; on this one it is eight, because the row count is derived from the contract's
* own table and not written down twice — the tab grows a row when three types are added and needs
* no edit here.
*
* Three columns of 332 in the pane's 1004, and rows of 43.75 (eight) down to 31.3 (eleven): every
* one of them carries a 24 px line with room, which is the whole reason the keyboard is here and
* not under the game.
*/
export const MACROS_TAB_COLUMNS = 3;
export const MACROS_TAB_ROWS = Math.ceil(MACRO_TYPES.length / MACROS_TAB_COLUMNS);
/**
* One cell of the MACROS tab's keyboard, inside the pane.
*
* Derived here rather than beside {@link MACROS_TAB_COLUMNS} because it needs the pane's box, and
* the pane is `LAYOUT`'s. The pad's own 2 px inset and 2 px gaps are reused, so the keyboard and
* the pad are the same grid at two sizes and one CSS rule set draws both.
*/
export const MACROS_TAB_CELL_WIDTH =
(LAYOUT.tabContent.width - MACRO_PALETTE_PAD * 2 - MACRO_CELL_GAP * (MACROS_TAB_COLUMNS - 1)) /
MACROS_TAB_COLUMNS;
export const MACROS_TAB_CELL_HEIGHT =
(LAYOUT.tabContent.height - MACRO_PALETTE_PAD * 2 - MACRO_CELL_GAP * (MACROS_TAB_ROWS - 1)) /
MACROS_TAB_ROWS;
/**
* The MACROS tab — same numbers as {@link MACROS_TAB_COLUMNS} and {@link MACROS_TAB_ROWS}, the
* alias kept because the rail tab that shows the keyboard is named `macros`
* (`src/lib/tabs.ts`) and the geometry of "the macros tab" is what callers reach for. The pad and
* the keyboard share the 2 px inset and 2 px gap (above) so these match the tab's actual
* dimensions exactly.
*/
export const MACRO_BOARD_COLUMNS = MACROS_TAB_COLUMNS;
export const MACRO_BOARD_ROWS = MACROS_TAB_ROWS;
export const MACRO_BOARD_PAD = MACRO_PALETTE_PAD;
export const MACRO_BOARD_GAP = MACRO_CELL_GAP;
export const MACRO_BOARD_CELL_WIDTH = MACROS_TAB_CELL_WIDTH;
export const MACRO_BOARD_CELL_HEIGHT = MACROS_TAB_CELL_HEIGHT;
/** The caption band a milestone/badge moment slides over the top of the tab slot. */
export const MAP_CAPTION_HEIGHT = 72;
/**
* Twitch overlays chat and extensions over the bottom left of the player, so nothing
* load-bearing goes here. At 1080p the fly strip is what reaches into this box, and it carries no
* text below its button row.
*/
export const NO_CONTENT_ZONE = { x: 0, y: STAGE_HEIGHT - 180, width: 480, height: 180 } as const;
/** Type of one absolute box. */
export interface Box {
x: number;
y: number;
width: number;
height: number;
}
/** Inline style for an absolutely positioned region. */
export function boxStyle(box: Box): {
position: 'absolute';
left: string;
top: string;
width: string;
height: string;
} {
return {
position: 'absolute',
left: `${box.x}px`,
top: `${box.y}px`,
width: `${box.width}px`,
height: `${box.height}px`,
};
}
/** True when two boxes share any area. Used by the no-content-zone assertion. */
export function intersects(a: Box, b: Box): boolean {
return a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
}
/** Centre of a box, in stage coordinates. Particle emitters aim at these. */
export function centreOf(box: Box): { x: number; y: number } {
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
}

View file

@ -0,0 +1,252 @@
/**
* The single source of human copy for the connectome itself.
*
* This table is **dataset-level, not game-level**: it is the same fly in both demos, so the
* role-to-label mapping lives here and the per-game copy lives in `src/games/*`. Nothing in this
* file may name a game.
*
* The authority for the role keys is `data/fafb-v783/meta.json` (`roles`) plus its
* `circuit-roles.json` sidecar. `tests/unit/labels.test.ts` asserts every role key in those files
* has an entry here, so a dataset rebuild cannot silently drop a bar.
*
* **Register** (from the 2026-09-15 copy pass): terse and instrument-like. Panel titles are
* short nouns. No parenthetical justifications, no sentences under widgets, no captions. Every
* explanation this page makes lives in `ROTATING_CARDS` and nowhere else — one short line each.
*
* Neuron counts are deliberately absent: they are read from the loaded `meta.json` at runtime and
* rendered from there, so the count on screen cannot drift from the dataset.
*/
import { MACRO_TYPES, macroChannel, macroRateRole } from '@flybrain/feed';
/** Which colour token a bar or dot uses. */
export type CircuitTint = 'sensory' | 'motor' | 'dopamine' | 'ink-2';
/** Where a role surfaces on screen. */
export type RoleSurface = 'bar' | 'retina' | 'map';
/**
* The 31 macro channels (`docs/design/macros.md` sections 12 to 14), labelled with their tags.
*
* Derived from the contract rather than typed out again: the role is `macro_` plus the type's name
* lowercased, and the label is the tag the strip's cell already draws, so a type added to the
* contract cannot arrive here as an unlabelled bar. Motor, because a macro is a button.
*/
const MACRO_ROLE_LABELS: Record<string, RoleLabel> = Object.fromEntries(
MACRO_TYPES.map((name) => [
macroRateRole(name),
{ label: macroChannel(name), surface: 'bar', tint: 'motor' } satisfies RoleLabel,
]),
);
/** Human copy for one dataset role. */
export interface RoleLabel {
/** Short on-screen name. Lower case; the panel uppercases what it renders. */
label: string;
/** Where this role is rendered. `map` roles only tint the brain map. */
surface: RoleSurface;
tint: CircuitTint;
}
/**
* Every role key in the dataset.
*
* `command_0..7` are the readout's eight output channels, in `GAMEBOY_BUTTON_BITS` order
* (`packages/brain/src/readout/presets/gameboy.ts`): up, down, left, right, A, B, Start, Select.
* `macro_*` are the 31 macro channels above.
*/
export const ROLE_LABELS: Record<string, RoleLabel> = {
...MACRO_ROLE_LABELS,
command_0: { label: 'up', surface: 'bar', tint: 'motor' },
command_1: { label: 'down', surface: 'bar', tint: 'motor' },
command_2: { label: 'left', surface: 'bar', tint: 'motor' },
command_3: { label: 'right', surface: 'bar', tint: 'motor' },
command_4: { label: 'A', surface: 'bar', tint: 'motor' },
command_5: { label: 'B', surface: 'bar', tint: 'motor' },
command_6: { label: 'Start', surface: 'bar', tint: 'motor' },
command_7: { label: 'Select', surface: 'bar', tint: 'motor' },
reward_pam: { label: 'PAM', surface: 'bar', tint: 'dopamine' },
proboscis: { label: 'proboscis', surface: 'bar', tint: 'sensory' },
forward: { label: 'fwd', surface: 'bar', tint: 'ink-2' },
backward: { label: 'back', surface: 'bar', tint: 'ink-2' },
steer_left: { label: 'left', surface: 'bar', tint: 'ink-2' },
steer_right: { label: 'right', surface: 'bar', tint: 'ink-2' },
visual_l1: { label: 'L1', surface: 'retina', tint: 'sensory' },
sensory: { label: 'sensory', surface: 'map', tint: 'sensory' },
motor: { label: 'motor', surface: 'map', tint: 'motor' },
descending: { label: 'descending', surface: 'map', tint: 'motor' },
kenyon: { label: 'kenyon', surface: 'map', tint: 'ink-2' },
mbon: { label: 'mbon', surface: 'map', tint: 'ink-2' },
};
/** One sub-bar inside a circuit group. */
export interface CircuitBar {
/** Feed `rates` key. */
role: string;
/** Short label as rendered. */
label: string;
}
/** One labelled group in the CIRCUITS panel. */
export interface CircuitGroup {
id: string;
/** Group name, uppercased on screen. A noun, with nothing after it. */
label: string;
tint: CircuitTint;
/** Full-scale rate for the bars, Hz. */
fullScaleHz: number;
bars: readonly CircuitBar[];
}
/**
* The six groups of the CIRCUITS panel, top to bottom (design A2: 6 groups).
*
* A3's table lists PRESS A and PRESS B as separate groups, which would make seven; they share one
* group with two sub-bars here so the panel matches A2's count and pitch.
*/
export const CIRCUIT_GROUPS: readonly CircuitGroup[] = [
{
id: 'drive',
label: 'drive',
tint: 'motor',
fullScaleHz: 30,
bars: [
{ role: 'command_0', label: 'up' },
{ role: 'command_1', label: 'down' },
{ role: 'command_2', label: 'left' },
{ role: 'command_3', label: 'right' },
],
},
{
id: 'press',
label: 'A / B',
tint: 'motor',
fullScaleHz: 30,
bars: [
{ role: 'command_4', label: 'A' },
{ role: 'command_5', label: 'B' },
],
},
{
id: 'menu',
label: 'menu',
tint: 'motor',
fullScaleHz: 20,
bars: [
{ role: 'command_6', label: 'Start' },
{ role: 'command_7', label: 'Select' },
],
},
{
id: 'dopamine',
label: 'dopamine',
tint: 'dopamine',
fullScaleHz: 25,
bars: [{ role: 'reward_pam', label: 'PAM' }],
},
{
id: 'taste',
label: 'taste',
tint: 'sensory',
fullScaleHz: 10,
bars: [{ role: 'proboscis', label: 'proboscis' }],
},
{
/**
* The legs are real and drive nothing. That used to be said on a line under the panel ("legs:
* real, wired to nothing"), which is exactly the explanatory micro-copy the copy direction
* rejects; the fact now lives in the scaffolding card, and the row is just LEGS.
*/
id: 'legs',
label: 'legs',
tint: 'ink-2',
fullScaleHz: 12,
bars: [
{ role: 'forward', label: 'fwd' },
{ role: 'backward', label: 'back' },
{ role: 'steer_left', label: 'left' },
{ role: 'steer_right', label: 'right' },
],
},
];
/**
* The MACROS row in the SENSES panel (`docs/design/macros.md` section 12), beside DRIVE and A/B.
*
* Not one of {@link CIRCUIT_GROUPS}, because its bars are not fixed: the row draws the channels the
* scene has bound right now, in the palette's own cell order, and it draws nothing in raw mode.
* The full scale is the direction group's, since these are decided against the same thresholds.
*/
export const MACRO_CIRCUIT = { id: 'macros', label: 'macros', tint: 'motor', fullScaleHz: 30 } as const;
/** Every macro channel's rate role, in the contract's type order. */
export const MACRO_BAR_ROLES: readonly string[] = MACRO_TYPES.map(macroRateRole);
/** Every role a circuit bar reads, in render order. */
export const BAR_ROLES: readonly string[] = CIRCUIT_GROUPS.flatMap((group) => group.bars.map((bar) => bar.role));
/**
* Copy that describes the whole apparatus rather than any one game.
*
* All strings are constants: the page never generates or interpolates prose (the "no generated
* text on a Twitch stream" rule from the Nothing, Forever precedent).
*/
export const DATASET_COPY = {
hzUnit: 'Hz',
retinaTitle: 'retina',
circuitsTitle: 'circuits',
eventsTitle: 'events',
hereForTitle: 'here for',
/** The whole-brain rate's label, over the Hz readout in the progress cluster. */
brainTitle: 'brain',
/** The persistent chat panel's title. Rendered only when there are lines to show. */
chatTitle: 'chat',
/** LADDER tab's stats column: the rollback budget lines and the stall meter. */
rollbacksTitle: 'rollbacks',
lifetimeTitle: 'lifetime',
lastTitle: 'last',
stallTitle: 'stall',
agoSuffix: 'ago',
/** The run clock's day counter, beside the clock rather than inside it. See `readouts.ts`. */
dayPrefix: 'day',
sugarReady: 'SUGAR READY',
/** `{name}` is a feed-supplied display name, re-validated on render. */
sugarBy: 'SUGAR by {name}',
mapTitle: 'connectome',
staleBanner: 'STALE FEED',
feedDown: 'FEED DOWN',
simError: 'SIM ERROR',
emptyTicker: 'no events yet',
} as const;
/**
* The rotating card: the one place on the page that explains anything.
*
* Four topics, one short line each — what is real, what is scaffolding, what sugar does, and the
* dataset credit with its licence. One card takes the narrative lane for the last minute of every
* four-minute cycle (`src/lib/schedule.ts`), and the next cycle shows the next card.
*
* Everything the page used to say in captions under widgets is in here, or nowhere.
*/
export const ROTATING_CARDS: readonly { title: string; line: string }[] = [
{
title: 'real',
line: 'Measured wiring, simulated spikes, reward-modulated plasticity on real synapses.',
},
{
title: 'scaffolding',
line: 'Our mapping: eyes to the screen, eight command groups to buttons. The legs drive nothing.',
},
{
title: 'sugar',
line: 'Sugar fires one dopamine pulse. There is no path from chat to a button.',
},
{
title: 'credit',
line: 'FlyWire FAFB v783 · CC BY-NC 4.0 · 139,255 neurons, 2.7 million connections.',
},
];

View file

@ -0,0 +1,62 @@
/**
* How many rungs the milestone spine draws.
*
* The feed is authoritative. `milestone.total` is the running game adapter's ladder length
* (`docs/feed-protocol.md`), so the service can change its ladder — Pokémon Red's went from 16
* rungs to 38 in `docs/design/ladder.md` — and the spine follows without a page release.
*
* The per-game config's `milestoneLadder` is the fallback, for a feed that predates `total`: a
* recorded fixture, or an older flysim. It is also still the source of the rung *labels* the panel
* falls back to when the header's own label is empty, which is why it does not simply go away.
*/
export const DEFAULT_RUNG_COUNT = 16;
/**
* Resolve the spine's rung count from the feed's `total`, falling back to the config's ladder.
*
* A `total` of 0, a negative, a fraction or a non-number is ignored rather than trusted: this
* value sizes a render loop on a page that has to keep painting on air, and the feed is a network
* input. The fallback chain ends at {@link DEFAULT_RUNG_COUNT} so the count is never 0, which
* would erase the panel.
*/
export function rungCount(total: number | undefined, ladderLength: number): number {
if (typeof total === 'number' && Number.isInteger(total) && total >= 1) {
return total;
}
if (Number.isInteger(ladderLength) && ladderLength >= 1) {
return ladderLength;
}
return DEFAULT_RUNG_COUNT;
}
/**
* The labels the LADDER tab spells out, padded or trimmed to `total`.
*
* The header carries `rank`, `label`, `next` and `total` but not the ladder's other names
* (`docs/feed-protocol.md`), so the list comes from the per-game config and the *count* from the
* feed. Padded rather than truncated to the config's length, because a service reporting more
* rungs than this build knows the names of must not silently shorten the ladder on screen: the
* unnamed rungs exist, and the tab shows them as numbered blanks.
*/
export function rungLabels(total: number, labels: readonly string[]): string[] {
const out: string[] = [];
for (let index = 0; index < total; index++) out.push(labels[index] ?? '');
return out;
}
/**
* Deal `items` into `columns` top-to-bottom columns of equal height.
*
* Column-major, not row-major: the ladder is a sequence, and a reader following 0, 1, 2 down the
* first column and on to the next is following the fly's own path. Row-major would put rung 1 next
* to rung 14.
*/
export function ladderColumns<T>(items: readonly T[], columns: number): T[][] {
const count = Math.max(1, Math.floor(columns));
const perColumn = Math.ceil(items.length / count);
const out: T[][] = [];
for (let column = 0; column < count; column++) {
out.push(items.slice(column * perColumn, (column + 1) * perColumn));
}
return out;
}

View file

@ -0,0 +1,55 @@
/**
* Short names for the macro pad's cells.
*
* The pad under the game is two columns of seven at the 24 px body floor
* (`docs/design/macros.md` section 14), which leaves a cell 179 px: 85 for the channel tag's chip
* and 87 for the name, nine characters of VT323 (`src/lib/geometry.ts`). Six of the contract's
* names are longer than that, so the pad draws a short form and the MACROS tab — whose cells are
* 332 px — draws the contract's own name in full. Nothing here is a second vocabulary: a short
* name is the same words, shortened the way the channel tag already shortens them
* (`GO OBJECTIVE` / `MB·GOAL` -> `GO GOAL`), so a viewer reading the tab and then the pad sees one
* button and not two.
*
* **Why a table and not a rule.** A rule that cut words to fit produced `GO OBJEC` and
* `BUY ANTID`, which read as a rendering bug at the floor rather than as an abbreviation. The
* table is six lines; the rule below it is the fallback, and it exists for exactly one case: a
* producer or a contract ahead of this page. `tests/unit/macro-names.test.ts` holds every type in
* `MACRO_TYPES` to the cap and to being distinct, so a name added to the contract that needs a
* line here fails the suite rather than the broadcast.
*/
import { MACRO_SHORT_NAME_MAX } from './geometry';
/**
* The names that do not fit, shortened.
*
* Keyed on the contract's name, and deliberately including the nine types of
* `docs/design/macros.md` sections 13 and 14 that are still in flight: a table that only knows
* today's contract would put `BUY ANTID` on air on the day they land.
*/
const SHORT_NAMES: Record<string, string> = {
'GO OBJECTIVE': 'GO GOAL',
'GO FRONTIER': 'GO FRONT',
'BUY POTION': 'BUY POTN',
'BUY ANTIDOTE': 'BUY ANTI',
// The fly's own ball, against the mart's `BUY BALL`: the verb is what distinguishes them and the
// noun is what does not fit, so the verb is what stays.
'THROW BALL': 'THROW',
};
/**
* The name the pad's cell draws for a macro type.
*
* Total, and never empty: a name that fits is returned unchanged, a name in the table is its short
* form, and anything else is cut to the cap at a word boundary if there is one inside it. No
* ellipsis — a character of "…" is a character of name at this size, and the cell's `overflow`
* remains the structural backstop.
*/
export function shortMacroName(name: string): string {
if (name.length <= MACRO_SHORT_NAME_MAX) return name;
const short = SHORT_NAMES[name];
if (short !== undefined) return short;
const space = name.lastIndexOf(' ', MACRO_SHORT_NAME_MAX);
return space > 0 ? name.slice(0, space) : name.slice(0, MACRO_SHORT_NAME_MAX);
}

Some files were not shown because too many files have changed in this diff Show more