flybrain v0.4.0: public tree (history retained privately)
Some checks failed
201
.forgejo/workflows/ci.yml
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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.
|
||||||
22
apps/stage/components.json
Normal 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
|
|
@ -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>
|
||||||
BIN
apps/stage/mockups/big-moment-t1.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
apps/stage/mockups/chat-wrap.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
apps/stage/mockups/describe.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
apps/stage/mockups/fly-paper.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
apps/stage/mockups/fly-webgl.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
apps/stage/mockups/gameboy-fonts.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
apps/stage/mockups/macros-center.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
apps/stage/mockups/macros-indoors.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
apps/stage/mockups/macros-outcome.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
apps/stage/mockups/macros-overworld.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
apps/stage/mockups/macros-pc.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
apps/stage/mockups/macros-running.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
apps/stage/mockups/macros-shop.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
apps/stage/mockups/macros-tab.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
apps/stage/mockups/moment-badge.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
apps/stage/mockups/moment-milestone.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
apps/stage/mockups/moment-rollback.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
apps/stage/mockups/moment-sugar.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
14
apps/stage/mockups/motion/.gitignore
vendored
Normal 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
|
||||||
BIN
apps/stage/mockups/motion/badge-sheet.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
apps/stage/mockups/motion/dayRollover-sheet.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
apps/stage/mockups/motion/milestone-sheet.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
apps/stage/mockups/motion/modeChange-sheet.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
apps/stage/mockups/motion/reward-sheet.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
apps/stage/mockups/motion/rollback-sheet.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
apps/stage/mockups/motion/sugar-sheet.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
apps/stage/mockups/pad-strip.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
apps/stage/mockups/retina-before-after.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
apps/stage/mockups/steady-t1-connectome.png
Normal file
|
After Width: | Height: | Size: 263 KiB |
BIN
apps/stage/mockups/steady-t1-ladder.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
apps/stage/mockups/steady-t1-senses.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
198
apps/stage/motion-harness/index.html
Normal 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
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
70
apps/stage/playwright.config.ts
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
BIN
apps/stage/public/fixtures/big-moment.flyfeed.gz
Normal file
BIN
apps/stage/public/fixtures/bigpad.flyfeed.gz
Normal file
BIN
apps/stage/public/fixtures/center.flyfeed.gz
Normal file
BIN
apps/stage/public/fixtures/cold-open.flyfeed.gz
Normal file
BIN
apps/stage/public/fixtures/macros.flyfeed.gz
Normal file
BIN
apps/stage/public/fixtures/shop.flyfeed.gz
Normal file
BIN
apps/stage/public/fixtures/steady.flyfeed.gz
Normal file
93
apps/stage/public/fonts/OFL-PixelifySans.txt
Normal 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.
|
||||||
93
apps/stage/public/fonts/OFL-PressStart2P.txt
Normal 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.
|
||||||
93
apps/stage/public/fonts/OFL-Silkscreen.txt
Normal 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.
|
||||||
93
apps/stage/public/fonts/OFL-VT323.txt
Normal 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.
|
||||||
BIN
apps/stage/public/fonts/PixelifySans-Latin.woff2
Normal file
BIN
apps/stage/public/fonts/PressStart2P-Latin.woff2
Normal file
BIN
apps/stage/public/fonts/Silkscreen-Latin.woff2
Normal file
BIN
apps/stage/public/fonts/VT323-Latin.woff2
Normal file
777
apps/stage/src/App.tsx
Normal 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][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
91
apps/stage/src/audio/drift.ts
Normal 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';
|
||||||
|
}
|
||||||
232
apps/stage/src/audio/engine.ts
Normal 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));
|
||||||
|
}
|
||||||
150
apps/stage/src/audio/ring-worklet.js
Normal 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
|
|
@ -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();
|
||||||
|
}
|
||||||
77
apps/stage/src/chat/sanitize.ts
Normal 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;
|
||||||
|
}
|
||||||
7
apps/stage/src/chat/types.ts
Normal 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';
|
||||||
48
apps/stage/src/components/ui/badge.tsx
Normal 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 };
|
||||||
35
apps/stage/src/components/ui/card.tsx
Normal 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 };
|
||||||
36
apps/stage/src/components/ui/progress.tsx
Normal 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 };
|
||||||
31
apps/stage/src/components/ui/separator.tsx
Normal 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 };
|
||||||
85
apps/stage/src/feed/decode.ts
Normal 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;
|
||||||
|
}
|
||||||
238
apps/stage/src/feed/fixture.ts
Normal 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());
|
||||||
|
}
|
||||||
135
apps/stage/src/feed/socket.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
apps/stage/src/feed/source.ts
Normal 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;
|
||||||
|
}
|
||||||
514
apps/stage/src/feed/store.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
apps/stage/src/fly/camera.ts
Normal 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;
|
||||||
96
apps/stage/src/fly/drives.ts
Normal 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;
|
||||||
|
}
|
||||||
74
apps/stage/src/fly/index.ts
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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]);
|
||||||
|
}
|
||||||
51
apps/stage/src/games/describe.ts
Normal 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.',
|
||||||
|
};
|
||||||
25
apps/stage/src/games/index.ts
Normal 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);
|
||||||
|
}
|
||||||
104
apps/stage/src/games/platformer.ts
Normal 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;
|
||||||
100
apps/stage/src/games/pokemon-red.ts
Normal 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;
|
||||||
77
apps/stage/src/games/types.ts
Normal 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
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
72
apps/stage/src/lib/chatters.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
161
apps/stage/src/lib/circuit-scale.ts
Normal 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,
|
||||||
|
};
|
||||||
86
apps/stage/src/lib/describe.ts
Normal 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
|
|
@ -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))),
|
||||||
|
};
|
||||||
|
}
|
||||||
130
apps/stage/src/lib/format.ts
Normal 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';
|
||||||
|
}
|
||||||
425
apps/stage/src/lib/geometry.ts
Normal 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 };
|
||||||
|
}
|
||||||
252
apps/stage/src/lib/labels.ts
Normal 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.',
|
||||||
|
},
|
||||||
|
];
|
||||||
62
apps/stage/src/lib/ladder.ts
Normal 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;
|
||||||
|
}
|
||||||
55
apps/stage/src/lib/macro-names.ts
Normal 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);
|
||||||
|
}
|
||||||