diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 35763cd..5b5b080 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,7 +17,7 @@ concurrency:
jobs:
build:
- name: typecheck · build
+ name: typecheck · unit · build
runs-on: ubuntu-latest
steps:
@@ -36,6 +36,10 @@ jobs:
- name: Typecheck
run: npm run typecheck
+ - name: Unit tests
+ run: npm run test:unit
+ # Needs node >= 22.18 for unflagged TypeScript stripping.
+
- name: Build
run: npm run build
# vite copies whatever is in public/; the large connectome binaries
diff --git a/.zenodo.json b/.zenodo.json
index 93658b7..aa7f334 100644
--- a/.zenodo.json
+++ b/.zenodo.json
@@ -11,7 +11,7 @@
"affiliation": "Independent researcher"
}
],
- "description": "
webgpu-fly runs a whole-animal Drosophila nervous system inside a web browser with no installation and no server. The FlyWire FAFB whole-brain connectome (139,255 neurons, ~15 million synaptic connections) and the Janelia MANC ventral-nerve-cord connectome (23,188 neurons, 5.2 million connections) are each simulated as leaky integrate-and-fire (LIF) networks in fused WebGPU compute kernels — one dispatch per timestep, with presynaptic-neurotransmitter signs pre-baked into the connection weights so the inner loop never branches on excitatory/inhibitory type.
The brain's descending command neurons drive the spinal cord by cell-type name match (the same named cell on both sides of the brain–VNC boundary), and the spine's motor neurons actuate a physically simulated 67-body, 111-actuator TuragaLab flybody model running in MuJoCo compiled to WebAssembly. A 64×16 retina rendered each frame from the fly's own head pose is fed back into the brain's optic neurons, closing a sensorimotor loop. An optional trained reinforcement-learning walking policy (Vaxenburg et al. 2025) runs as a pure-TypeScript forward pass verified element-wise against the published SavedModel checkpoint.
The deployment is a game: the player fires real descending neurons with keypresses to steer the fly to a target, and a winning run produces a deterministic, shareable replay URL that re-executes the identical neuron cascade against the same connectome — a brain trace, not a video. Performance is reported honestly: the brain LIF kernel is memory-bandwidth-bound and runs at ~0.25 kHz of biological time on an Apple M2 Pro, benchmarked on the same machine against NEST 3.10 (0.67 kHz) and a hand-written multicore Rust port (0.45 kHz). The original 1 kHz target was unreachable for any of the three on that hardware; the contribution is reachability — a real connectome simulation behind a single URL — not raw throughput. Known limitations (RL-walker speed gap, closed-loop visual-reflex approximation, kinematic-assist options) are enumerated in LIMITATIONS.md.",
+ "description": "
webgpu-fly runs a whole-animal Drosophila nervous system inside a web browser with no installation and no server. The FlyWire FAFB whole-brain connectome (139,255 neurons, ~15 million synaptic connections) and the Janelia MANC ventral-nerve-cord connectome (23,188 neurons, 5.2 million connections) are each simulated as leaky integrate-and-fire (LIF) networks in fused WebGPU compute kernels — gather, integrate, threshold and reset in a single kernel, with presynaptic-neurotransmitter signs pre-baked into the connection weights so the inner loop never branches on excitatory/inhibitory type.
The brain's descending command neurons drive the spinal cord by cell-type name match (the same named cell on both sides of the brain–VNC boundary), and the spine's motor neurons actuate a physically simulated 67-body, 111-actuator TuragaLab flybody model running in MuJoCo compiled to WebAssembly. A 64×16 retina rendered each frame from the fly's own head pose is fed back into the brain's optic neurons, closing a sensorimotor loop. An optional trained reinforcement-learning walking policy (Vaxenburg et al. 2025) runs as a pure-TypeScript forward pass verified element-wise against the published SavedModel checkpoint.
The deployment is a game: the player fires real descending neurons with keypresses to steer the fly to a target, and a winning run produces a deterministic, shareable replay URL that re-executes the identical neuron cascade against the same connectome — a brain trace, not a video. Performance is reported honestly: the brain LIF kernel is memory-bandwidth-bound and runs at ~0.25 kHz of biological time on an Apple M2 Pro, benchmarked on the same machine against NEST 3.10 (0.67 kHz) and a hand-written multicore Rust port (0.45 kHz). The original 1 kHz target was unreachable for any of the three on that hardware; the contribution is reachability — a real connectome simulation behind a single URL — not raw throughput. Known limitations (RL-walker speed gap, closed-loop visual-reflex approximation, kinematic-assist options) are enumerated in LIMITATIONS.md.",
"keywords": [
"WebGPU",
"WebAssembly",
diff --git a/CLAUDE.md b/CLAUDE.md
index fd2b74b..23e69a1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -3,21 +3,22 @@
## Goal
Realtime LIF (leaky integrate-and-fire) simulation of the FlyWire FAFB
-*Drosophila* whole-brain connectome on WebGPU. ~140k neurons, ~5M aggregated
-edges. One fused dispatch per timestep, target ≥1 kHz biological-time on
+*Drosophila* whole-brain connectome on WebGPU. ~140k neurons, ~15M aggregated
+edges. One fused LIF kernel per timestep, target ≥1 kHz biological-time on
M2 Pro 16 GB.
-Companion to `~/Downloads/webgpu-dna`. Same thesis (Geant4-class simulator
+Companion to `webgpu-dna`. Same thesis (Geant4-class simulator
ported to WebGPU via kernel fusion), different physics. Crucially the fusion
shape is different — see README.
## Architecture
- **Data pipeline** (`tools/build_csr.py`): FlyWire connectivity feather +
- annotations TSV → `public/brain.bin` (binary CSR, ~45 MB). Pre-signs
+ annotations TSV → `public/brain.bin` (binary CSR, ~120 MB). Pre-signs
weights using presynaptic neurotransmitter so kernel never branches on
E/I at runtime.
-- **Kernel** (`src/shaders/lif.wgsl`): one fused dispatch per timestep.
+- **Kernel** (`src/shaders/lif.wgsl`): one fused LIF dispatch per timestep,
+ preceded by a bitset-clear dispatch.
Per neuron: gather presynaptic spikes via CSR row, integrate Vm with
leak, threshold + reset, write spike bit to output buffer.
- **Snapshot exporter** (planned): every N ms, copy `vm` or `spike_count`
@@ -50,7 +51,7 @@ CSR weight E×f32:
pre-signed: sign(pre_nt) × synapse_count
```
-E ≈ 5M (aggregated proofread pairs). Total bin ≈ 45 MB.
+E ≈ 15M (aggregated proofread pairs). Total bin ≈ 120 MB.
## Neurotransmitter → sign mapping
@@ -77,7 +78,7 @@ Use `soma_*` if non-null, fall back to `pos_*` (synapse-cloud centroid).
or a giant interneuron).
- **Dynamics sanity**: with no input, network goes silent within ~50 ms
(no runaway). With Poisson input to ORNs, downstream Kenyon cells fire
- sparsely (~1–5% population), antennal lobe PNs show characteristic
+ sparsely (~5–15% population), antennal lobe PNs show characteristic
rates.
- **Quantitative**: cross-check firing rates against published FlyWire
LIF simulations (Shiu et al. 2024 or Lappalainen et al. visual-system
@@ -86,7 +87,7 @@ Use `soma_*` if non-null, fall back to `pos_*` (synapse-cloud centroid).
## Known design decisions
- **Aggregated pairs, not raw synapses.** `proofread_connections_783.feather`
- pre-aggregates by (pre, post) pair. We use that directly — gives ~5M edges
+ pre-aggregates by (pre, post) pair. We use that directly — gives ~15M edges
vs ~54M raw synapses. v1 LIF doesn't care about per-synapse spatial
position; if we add dendritic compartments later, switch to the 9.5 GB
raw table.
@@ -104,13 +105,13 @@ Use `soma_*` if non-null, fall back to `pos_*` (synapse-cloud centroid).
bash tools/download_data.sh # ~855 MB Zenodo pull
python3 tools/build_csr.py # → public/brain.bin
npm run dev # localhost:8766
-npm run test
+npm run test:e2e # Playwright; needs WebGPU + assets
npm run typecheck
```
## Cross-refs
-- `~/Downloads/webgpu-dna/CLAUDE.md` — sister project; the kernel-fusion
- pattern that motivated this.
-- `~/Documents/github/webgpu-fly/tools/build_csr.py` — authoritative
- binary format spec lives in this file's docstring.
+- https://github.com/abgnydn/webgpu-dna — sister project; the kernel-fusion
+ pattern that motivated this. Its `CLAUDE.md` has the details.
+- `tools/build_csr.py` — authoritative binary format spec lives in this
+ file's docstring.
diff --git a/DEPLOY.md b/DEPLOY.md
index 6fe72b8..7ed3bde 100644
--- a/DEPLOY.md
+++ b/DEPLOY.md
@@ -8,6 +8,7 @@ Asset budget:
| `assets/mujoco-*.wasm` | 8.6 MB | Pages |
| `public/flybody/*.obj` (85 files) | 134 MB total, biggest 31 MB | R2 |
| `public/flybody/*.xml` (2 files) | <1 MB | R2 |
+| `public/flybody.bundle.bin` | ~140 MB | R2 |
| `public/brain.bin` | 120 MB | R2 |
| `public/brain.meta.json` | 1 KB | R2 |
| `public/vnc.bin` | 43 MB | R2 |
@@ -46,11 +47,17 @@ VITE_BRAIN_META_URL=https:///brain.meta.json
VITE_VNC_URL=https:///vnc.bin
VITE_VNC_META_URL=https:///vnc.meta.json
VITE_FLYBODY_URL=https:///flybody
+VITE_FLYBODY_BUNDLE_URL=https:///flybody.bundle.bin
+VITE_WALKING_POLICY_URL=https:///walking-policy.bin
+VITE_WALKING_OBS_NORM_URL=https:///walking-obs-norm.bin
```
`` is the bucket's r2.dev subdomain (printed by the
`dev-url enable` command), or your custom domain.
+`VITE_FLYBODY_URL` is legacy — `src/physics.ts` loads only the baked
+bundle, so `VITE_FLYBODY_BUNDLE_URL` is the one that must be set.
+
`r2-cors.json`:
```json
{
@@ -67,17 +74,10 @@ VITE_FLYBODY_URL=https:///flybody
```
`public/_headers` already sets long immutable cache on the JS bundle
-and WASM. The R2 bucket should also serve `Cache-Control:
-public, max-age=31536000, immutable` — set this once via:
-
-```bash
-wrangler r2 bucket cors put webgpu-fly-assets --cors-rules '[
- { "AllowedOrigins": ["https://your-pages.pages.dev","https://your-domain.com"],
- "AllowedMethods": ["GET","HEAD"],
- "AllowedHeaders": ["*"],
- "MaxAgeSeconds": 86400 }
-]'
-```
+and WASM. The R2 objects get `Cache-Control: public, max-age=31536000,
+immutable` from `tools/upload_to_r2.sh` at upload time
+(`--cache-control`), and CORS comes from `r2-cors.json` via the
+`wrangler r2 bucket cors set` step above — nothing further to configure.
## Path 2 — Vercel
diff --git a/LIMITATIONS.md b/LIMITATIONS.md
index 38ecdc6..a0f9321 100644
--- a/LIMITATIONS.md
+++ b/LIMITATIONS.md
@@ -34,10 +34,11 @@ publishing fly-brain dynamics.
- Neurons are **leaky integrate-and-fire** with a two-state alpha synapse.
No Hodgkin-Huxley channels, no dendritic compartments, no spatial synapse
positions, no neuromodulation dynamics.
-- We use the **aggregated** proofread connection table (~5M unique (pre,
- post) pairs, ~15M synapse count summed into weights), **not** the ~54M raw
- synapses. v1 LIF does not use per-synapse spatial position. Dendritic
- compartment models would require switching to the much larger raw table.
+- We use the **aggregated** proofread connection table (~15M unique (pre,
+ post) pairs, with each pair's synapse count summed into its weight),
+ **not** the ~54M raw synapses. v1 LIF does not use per-synapse spatial
+ position. Dendritic compartment models would require switching to the much
+ larger raw table.
- **Neurotransmitter → sign is a hard mapping**, baked into the weights at
build time: acetylcholine → +1, GABA/glutamate → −1, and the modulatory
transmitters (dopamine, serotonin, octopamine) plus any prediction below
diff --git a/README.md b/README.md
index dcd0216..abcc845 100644
--- a/README.md
+++ b/README.md
@@ -36,9 +36,9 @@ brain map, into a real fly's spinal cord, driving a physically simulated body.
**What this is**
- A whole-animal *Drosophila* nervous system — brain, spinal cord, and body — running end-to-end in a browser tab on WebGPU, no install and no server.
-- Two real connectomes (FlyWire brain + Janelia MANC spine) simulated as leaky integrate-and-fire networks, one fused GPU dispatch per timestep.
+- Two real connectomes (FlyWire brain + Janelia MANC spine) simulated as leaky integrate-and-fire networks, with gather, integrate, threshold and reset fused into a single LIF kernel per timestep.
- A physically simulated fly body (TuragaLab flybody in MuJoCo/WASM) driven by the spine's motor neurons, with a retina feeding vision back into the brain.
-- A game with **replay-as-URL**: a shared link deterministically re-executes the identical neuron cascade against the same connectome.
+- A game with **replay-as-URL**: a shared link re-fires your keystrokes at the same simulation steps, so the identical neuron cascade re-runs against the same connectome and the same seeded target.
|
@@ -80,9 +80,12 @@ R DNg13 turning F MDN backward
M science view
```
-Win → copy the replay URL. The recipient sees the **identical** simulation —
-deterministic seeded target + recorded keystrokes against the same connectome.
-Daily-challenge mode uses the same target seed for everyone on the same UTC day.
+Win → copy the replay URL. The recipient's brain re-runs the **identical**
+cascade — your keystrokes replayed at the same simulation steps, against the
+same connectome and the same seeded target. The body trajectory can drift: the
+physics advances a fixed number of substeps per animation frame, so it depends
+on display refresh rate. Daily-challenge mode uses the same target seed for
+everyone on the same UTC day.
The landing page at [`/`](https://webgpu-fly.pages.dev) explains the project in
plain language; the simulator itself lives at
@@ -142,16 +145,19 @@ Roughly the same idea as:
Differentiators: **(1)** a browser-tab game with a URL — the others need Python,
a GPU, and a setup hour; **(2)** all three layers (brain + spine + body) wired
together, not just brain+body; **(3)** replay-as-URL — every shared run is a
-deterministic re-execution anyone can verify, study, or remix.
+re-executable brain trace, not a video.
---
## ⏱️ Quickstart (local dev)
```bash
+# One-time Python env (numpy/pandas/pyarrow for the connectomes, TF for the policy)
+uv venv .venv-tf && uv pip install --python .venv-tf/bin/python numpy pandas pyarrow tensorflow
+
# Brain (~855 MB FlyWire pull from Zenodo)
bash tools/download_data.sh
-python3 tools/build_csr.py # → public/brain.bin (120 MB)
+.venv-tf/bin/python tools/build_csr.py # → public/brain.bin (120 MB)
# Spine (~88 MB MANC pull from Janelia GCS)
bash tools/download_manc.sh
@@ -161,7 +167,10 @@ bash tools/download_manc.sh
bash tools/download_flybody_policies.sh
.venv-tf/bin/python tools/extract_walking_policy.py
-# TuragaLab flybody MJCF + 85 OBJ meshes (~149 MB) — see public/flybody/meshes.txt
+# TuragaLab flybody MJCF + 85 OBJ meshes (~149 MB) — not redistributed here (Apache-2.0, see NOTICE)
+git clone --depth 1 https://github.com/TuragaLab/flybody /tmp/flybody
+cp /tmp/flybody/flybody/fruitfly/assets/*.obj public/flybody/
+.venv-tf/bin/python tools/bake_flybody_bundle.py # → public/flybody.bundle.bin
npm install
npm run dev # http://localhost:8766
diff --git a/package.json b/package.json
index 31a9ebb..55ee9f7 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"data": "bash tools/download_data.sh",
"convert": ".venv/bin/python tools/build_csr.py",
"test:e2e": "playwright test",
+ "test:unit": "node --test tests-unit/*.test.ts",
"bench:brain": "playwright test tests/bench.spec.ts --reporter=list",
"build:slim": "npm run build && rm -rf dist/flybody dist/flybody.bundle.bin dist/brain.bin dist/brain.meta.json dist/vnc.bin dist/vnc.meta.json dist/walking-policy.bin dist/walking-obs-norm.bin dist/walking-ref.bin dist/walking-policy-fixtures.json",
"deploy": "npm run build:slim && npx --yes wrangler pages deploy dist --project-name=webgpu-fly --branch=main",
diff --git a/src/brain.ts b/src/brain.ts
index 69aa8b9..c593a01 100644
--- a/src/brain.ts
+++ b/src/brain.ts
@@ -38,7 +38,7 @@ export async function loadBrain(url: string = "/brain.bin"): Promise {
// skip the ~125 MB network fetch (~30s on the dev server). Cache
// key is the full URL including ?v= from assets.json, so a
// new build naturally invalidates the cache (different URL = new
- // entry). Old entries are eventually evicted under storage pressure.
+ // entry), and idbPut deletes the previous generation of the same asset.
const buf = await getOrFetch(url, url);
return parseBrain(buf);
}
diff --git a/src/cache.ts b/src/cache.ts
index 0d37b71..122a11c 100644
--- a/src/cache.ts
+++ b/src/cache.ts
@@ -2,9 +2,10 @@
// only download once per machine. Survives hard refreshes (vite's
// no-cache header otherwise re-downloads on Cmd+Shift+R).
//
-// Single object store keyed by filename. Stores an `{etag, size, bytes}`
-// blob; on hit we revalidate cheaply by comparing size against the new
-// HEAD/Content-Length. If size matches, we trust IDB.
+// Single object store keyed by the full asset URL. Stores an
+// `{etag, size, bytes}` blob; on hit we serve the cached bytes directly.
+// Invalidation comes from the ?v= in the key — a new build asks for
+// a different key, and idbPut drops the previous generation.
const DB_NAME = "webgpu-fly-cache";
const DB_VERSION = 1;
@@ -46,7 +47,17 @@ async function idbPut(key: string, value: Entry): Promise {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
- tx.objectStore(STORE).put(value, key);
+ const store = tx.objectStore(STORE);
+ store.put(value, key);
+ // Drop older generations of the same asset — the key is the versioned
+ // URL, so a rebuild would otherwise orphan the previous ~140 MB entry
+ // forever. IDB evicts whole origins, never individual records.
+ const base = key.split("?")[0];
+ store.getAllKeys().onsuccess = (e) => {
+ for (const k of (e.target as IDBRequest).result) {
+ if (typeof k === "string" && k !== key && k.split("?")[0] === base) store.delete(k);
+ }
+ };
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
diff --git a/src/game.ts b/src/game.ts
index a03d1e2..0bb3b4b 100644
--- a/src/game.ts
+++ b/src/game.ts
@@ -13,9 +13,9 @@
// even though the brain ticks at ~10-15 Hz.
// - HUD updates each animation frame: timer, distance, score.
// - Win = body within WIN_RADIUS cm of target. Stop clock, show result.
-// - Replay: record (t_ms, key_idx) tuples; encode with target seed
+// - Replay: record (t_step, key_idx) tuples; encode with target seed
// into URL hash. On load with hash, enter replay mode and replay
-// keys at recorded times against the same seeded target.
+// keys at the recorded brain steps against the same seeded target.
import type { FlySim } from "./sim";
import type { Room } from "./room";
@@ -64,7 +64,7 @@ function dailySeed(): number {
}
interface ReplayEvent {
- t: number; // ms since round start
+ t: number; // brain steps since round start
key: number; // index into dns[]
down: boolean; // press or release
}
@@ -74,6 +74,7 @@ export class Game {
private state: State = "boot";
private pressed = new Set();
private roundStart = 0;
+ private roundStartStep = 0;
private elapsedMs = 0;
private events: ReplayEvent[] = [];
private spikeCount = 0;
@@ -100,12 +101,19 @@ export class Game {
start() {
this.buildHud();
this.bindKeys();
- this.startBrainLoop();
+ this.startBrainLoop().catch((e) => this.ctx.log(`brain loop stopped: ${(e as Error).message}`, "err"));
this.startHudLoop();
this.maybeEnterReplay();
if (this.state === "boot") this.enterIdle();
}
+ /** Round clock in brain steps. Replay events are stamped and drained
+ * on this clock, not wall time, so playback does not depend on the
+ * display refresh rate or on how fast the brain loop is scheduled. */
+ private roundStep(): number {
+ return this.ctx.sim.currentStep - this.roundStartStep;
+ }
+
// ───── HUD construction ──────────────────────────────────────────
private buildHud() {
@@ -161,13 +169,13 @@ export class Game {
if (down) {
if (!this.pressed.has(i)) {
this.pressed.add(i);
- this.events.push({ t: performance.now() - this.roundStart, key: i, down: true });
+ this.events.push({ t: this.roundStep(), key: i, down: true });
this.flashKey(i, true);
}
} else {
if (this.pressed.has(i)) {
this.pressed.delete(i);
- this.events.push({ t: performance.now() - this.roundStart, key: i, down: false });
+ this.events.push({ t: this.roundStep(), key: i, down: false });
this.flashKey(i, false);
}
}
@@ -277,6 +285,7 @@ export class Game {
private enterPlaying() {
this.state = "playing";
this.roundStart = performance.now();
+ this.roundStartStep = this.ctx.sim.currentStep;
this.overlay.style.display = "none";
this.ctx.log(`game: round started, target seed ${this.targetSeed.toString(16)}`, "ok");
}
@@ -284,6 +293,9 @@ export class Game {
private enterWon() {
this.state = "won";
this.elapsedMs = performance.now() - this.roundStart;
+ // Release whatever is still held, otherwise the encoded replay has
+ // an unmatched down and the replayed fly stays stimulated forever.
+ for (const k of this.pressed) this.events.push({ t: this.roundStep(), key: k, down: false });
this.pressed.clear();
this.ctx.room.setDrive(0, 0);
const score = this.computeScore(this.elapsedMs, this.spikeCount);
@@ -293,6 +305,8 @@ export class Game {
// Behavior recipe: which DNs the player used, how long held in
// total. Press events have `down: true`; pair each with the next
// matching `down: false` to compute hold duration.
+ // Event stamps are brain steps; DEFAULT_PARAMS.dtMs is 1.0, so one
+ // step is 1 ms of simulated time and the durations are already ms.
const holdMs = new Array(this.ctx.dns.length).fill(0);
const lastDown = new Array(this.ctx.dns.length).fill(-1);
for (const ev of this.events) {
@@ -304,7 +318,7 @@ export class Game {
}
// Close any keys still held at win.
for (let k = 0; k < holdMs.length; k++) {
- if (lastDown[k] >= 0) holdMs[k] += Math.max(0, this.elapsedMs - lastDown[k]);
+ if (lastDown[k] >= 0) holdMs[k] += Math.max(0, this.roundStep() - lastDown[k]);
}
const ranked = holdMs
.map((ms, i) => ({ ms, i }))
@@ -402,6 +416,7 @@ export class Game {
if (this.state !== "replay") return;
this.overlay.style.display = "none";
this.roundStart = performance.now();
+ this.roundStartStep = this.ctx.sim.currentStep;
this.replayIdx = 0;
this.ctx.log(`game: replaying ${this.replayQueue.length} events`, "ok");
// Show a persistent "watching replay" banner during playback that
@@ -461,7 +476,7 @@ export class Game {
if (this.state !== "playing") return;
if (e.repeat) return;
this.pressed.add(idx);
- this.events.push({ t: performance.now() - this.roundStart, key: idx, down: true });
+ this.events.push({ t: this.roundStep(), key: idx, down: true });
this.flashKey(idx, true);
});
@@ -472,7 +487,7 @@ export class Game {
if (this.state !== "playing") return;
if (this.pressed.has(idx)) {
this.pressed.delete(idx);
- this.events.push({ t: performance.now() - this.roundStart, key: idx, down: false });
+ this.events.push({ t: this.roundStep(), key: idx, down: false });
this.flashKey(idx, false);
}
});
@@ -499,6 +514,7 @@ export class Game {
const activeIdxs = this.activeStimIdxs();
for (const dnIdx of activeIdxs) {
const dn = this.ctx.dns[dnIdx];
+ if (!dn) continue;
for (const i of dn.neurons) ext[i] = STIM_AMP;
}
@@ -507,7 +523,7 @@ export class Game {
this.ctx.viewer.pushSnapshot(rate);
// Sum spikes in this burst for the score.
- if (this.state === "playing") {
+ if (this.state === "playing" || this.state === "replay") {
let s = 0;
for (let i = 0; i < rate.length; i++) s += rate[i];
// captureRollingRate returns spikes-per-step normalised, so
@@ -520,9 +536,9 @@ export class Game {
// pass no visual sample.
await this.ctx.applyDrive(rate, undefined);
- // Replay-mode: advance the key queue by elapsed time.
+ // Replay-mode: advance the key queue by elapsed brain steps.
if (this.state === "replay" && this.roundStart > 0) {
- const t = performance.now() - this.roundStart;
+ const t = this.roundStep();
while (this.replayIdx < this.replayQueue.length
&& this.replayQueue[this.replayIdx].t <= t) {
const ev = this.replayQueue[this.replayIdx++];
@@ -579,6 +595,15 @@ export class Game {
const t = performance.now() - this.roundStart;
this.timerEl.textContent = this.formatTime(t);
this.spikesEl.textContent = this.spikeCount.toLocaleString();
+ // Reaching the target ends the playback, but deliberately does not
+ // go through enterWon() — the score and the share card belong to
+ // whoever recorded the run, not to whoever opened the link.
+ if (Number.isFinite(dist) && dist < WIN_RADIUS_CM) {
+ this.pressed.clear();
+ this.ctx.room.setDrive(0, 0);
+ this.elapsedMs = t;
+ this.state = "won";
+ }
} else if (this.state === "won") {
this.timerEl.textContent = this.formatTime(this.elapsedMs);
}
@@ -646,19 +671,36 @@ export class Game {
// ───── Replay encoding ───────────────────────────────────────────
+ /** FNV-1a over the DN names. Events encode indices into dns[], so a
+ * reorder, insert or removal has to invalidate old URLs; hashing the
+ * roster does that automatically, with no version byte to maintain. */
+ private dnFingerprint(): number {
+ let h = 0x811c9dc5;
+ for (const dn of this.ctx.dns) {
+ for (let i = 0; i < dn.name.length; i++) {
+ h ^= dn.name.charCodeAt(i);
+ h = Math.imul(h, 0x01000193) >>> 0;
+ }
+ h ^= 0x2c; h = Math.imul(h, 0x01000193) >>> 0; // separator
+ }
+ return h & 0xffff;
+ }
+
private encodeReplayUrl(): string {
- // Format: 4B seed (LE) + 4B per event (u24 t_ms LE + u8 (key|down)).
+ // Format: 2B dn fingerprint (LE) + 4B seed (LE) + 4B per event
+ // (u24 t_steps LE + u8 (key|down)).
const N = this.events.length;
- const buf = new Uint8Array(4 + 4 * N);
+ const buf = new Uint8Array(6 + 4 * N);
const v = new DataView(buf.buffer);
- v.setUint32(0, this.targetSeed, true);
+ v.setUint16(0, this.dnFingerprint(), true);
+ v.setUint32(2, this.targetSeed, true);
for (let i = 0; i < N; i++) {
const e = this.events[i];
const t = Math.min(0xffffff, Math.max(0, Math.round(e.t)));
- v.setUint8(4 + i * 4 + 0, t & 0xff);
- v.setUint8(4 + i * 4 + 1, (t >> 8) & 0xff);
- v.setUint8(4 + i * 4 + 2, (t >> 16) & 0xff);
- v.setUint8(4 + i * 4 + 3, ((e.key & 0x7f) << 1) | (e.down ? 1 : 0));
+ v.setUint8(6 + i * 4 + 0, t & 0xff);
+ v.setUint8(6 + i * 4 + 1, (t >> 8) & 0xff);
+ v.setUint8(6 + i * 4 + 2, (t >> 16) & 0xff);
+ v.setUint8(6 + i * 4 + 3, ((e.key & 0x7f) << 1) | (e.down ? 1 : 0));
}
let bin = "";
for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]);
@@ -679,15 +721,24 @@ export class Game {
const buf = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
const v = new DataView(buf.buffer);
- const seed = v.getUint32(0, true);
- const N = (buf.length - 4) >> 2;
+ // Anything that is not this build's roster is unplayable: the key
+ // indices mean something else, and pre-fingerprint payloads stamp
+ // wall-clock ms where this build expects brain steps.
+ if (buf.length < 6 || (buf.length - 6) % 4 !== 0
+ || v.getUint16(0, true) !== this.dnFingerprint()) {
+ this.ctx.log("replay was recorded against a different DN set — ignoring", "warn");
+ return;
+ }
+ const seed = v.getUint32(2, true);
+ const N = (buf.length - 6) >> 2;
const events: ReplayEvent[] = [];
for (let i = 0; i < N; i++) {
- const t = v.getUint8(4 + i * 4 + 0)
- | (v.getUint8(4 + i * 4 + 1) << 8)
- | (v.getUint8(4 + i * 4 + 2) << 16);
- const packed = v.getUint8(4 + i * 4 + 3);
- events.push({ t, key: (packed >> 1) & 0x7f, down: (packed & 1) === 1 });
+ const t = v.getUint8(6 + i * 4 + 0)
+ | (v.getUint8(6 + i * 4 + 1) << 8)
+ | (v.getUint8(6 + i * 4 + 2) << 16);
+ const packed = v.getUint8(6 + i * 4 + 3);
+ const key = (packed >> 1) & 0x7f;
+ if (key < this.ctx.dns.length) events.push({ t, key, down: (packed & 1) === 1 });
}
this.enterReplay(seed, events);
// Set roundStart to 0 so SPACE triggers playback.
diff --git a/src/main.ts b/src/main.ts
index a8d044e..3accdc9 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -135,6 +135,15 @@ function bootSkip(name: "brain" | "vnc" | "body", detail: string) {
if (det) det.textContent = detail;
bootMaybeDismiss();
}
+// Surface a fatal boot error on the overlay itself — in game mode the log
+// pane is hidden, so log() alone leaves the overlay spinning forever.
+function bootFail(msg: string) {
+ const el = document.querySelector("#boot .blink");
+ if (!el) return; // overlay already dismissed/removed
+ el.textContent = msg;
+ el.style.color = "#ff6b6b";
+ el.style.animation = "none";
+}
async function main() {
// Cache-bust manifest. Maps asset filename → "?v=<12-char sha>" so the
@@ -190,10 +199,14 @@ async function main() {
let famousDns: Record = {};
let famousDnLabels: Record = {};
try {
- const meta = await (await fetch(metaUrl + versionFor("brain.meta.json"))).json();
+ const r = await fetch(metaUrl + versionFor("brain.meta.json"));
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ const meta = await r.json();
famousDns = meta.famous_dns ?? {};
famousDnLabels = meta.famous_dn_descriptions ?? {};
- } catch {}
+ } catch (e) {
+ log(`brain.meta.json unavailable (${(e as Error).message}); famous-DN buttons disabled`, "warn");
+ }
log("");
const container = document.getElementById("canvas-container") as HTMLDivElement;
@@ -492,6 +505,7 @@ async function main() {
if (!("gpu" in navigator)) {
log("navigator.gpu missing — open in Chrome / Edge", "err");
+ bootFail("WebGPU unavailable — open in Chrome or Edge");
return;
}
const sim = await FlySim.create(brain, { ...DEFAULT_PARAMS });
@@ -747,74 +761,78 @@ async function main() {
btn.classList.add("active");
controls.hidden = true;
- log("");
- log(`--- ${stim.label} ---`, "ok");
- const { ext, driven } = stim.build(brain);
- log(`driving ${driven.toLocaleString()} neurons`);
-
- sim.reset(); resetVnc();
- sim.setExternalInput(ext);
- viewer.clearSnapshots();
- room.resetFly();
-
- const t0 = performance.now();
- for (let s = 0; s < N_SNAPSHOTS; s++) {
- const rate = await sim.captureRollingRate(STEPS_PER_SNAPSHOT);
- viewer.pushSnapshot(rate);
- await applyDriveFromSnapshot(rate);
- }
- const elapsed = performance.now() - t0;
- const totalSteps = N_SNAPSHOTS * STEPS_PER_SNAPSHOT;
- log(`${totalSteps} steps in ${elapsed.toFixed(0)} ms wall (${(elapsed / totalSteps).toFixed(2)} ms/step)`, "ok");
-
- // Diagnostic: read back Vm and report distribution. If kernel ran
- // and synaptic drive is reaching neurons, max(Vm) should approach
- // v_thresh = -45 mV. If Vm sits at -52 (=v_rest) for everyone, the
- // kernel didn't move — that's a structural bug, not calibration.
- const vm = await sim.readVm();
- let vmMin = Infinity, vmMax = -Infinity, vmSum = 0;
- let aboveRest = 0;
- for (let i = 0; i < vm.length; i++) {
- if (vm[i] < vmMin) vmMin = vm[i];
- if (vm[i] > vmMax) vmMax = vm[i];
- vmSum += vm[i];
- if (vm[i] > sim.params.vRest + 0.001) aboveRest++;
- }
- log(`vm: min=${vmMin.toFixed(2)} max=${vmMax.toFixed(2)} mean=${(vmSum / vm.length).toFixed(2)} above-rest=${aboveRest.toLocaleString()}`);
- // Drive persists at the stim's end-of-window value so the user
- // can watch the body keep walking after the brain sim completes.
- // Click another stim (or Spontaneous) to change it.
-
- // Per-class peak active count
- const peak = new Map();
- for (const snap of [...Array(viewer.numSnapshots)].map((_, i) => viewer["snapshots"][i] as Float32Array)) {
- const live = new Map();
- for (let i = 0; i < snap.length; i++) {
- if (snap[i] > 0) live.set(neurons.superClass[i], (live.get(neurons.superClass[i]) ?? 0) + 1);
+ try {
+ log("");
+ log(`--- ${stim.label} ---`, "ok");
+ const { ext, driven } = stim.build(brain);
+ log(`driving ${driven.toLocaleString()} neurons`);
+
+ sim.reset(); resetVnc();
+ sim.setExternalInput(ext);
+ viewer.clearSnapshots();
+ room.resetFly();
+
+ const t0 = performance.now();
+ for (let s = 0; s < N_SNAPSHOTS; s++) {
+ const rate = await sim.captureRollingRate(STEPS_PER_SNAPSHOT);
+ viewer.pushSnapshot(rate);
+ await applyDriveFromSnapshot(rate);
}
- for (const [k, v] of live) {
- if (v > (peak.get(k) ?? 0)) peak.set(k, v);
+ const elapsed = performance.now() - t0;
+ const totalSteps = N_SNAPSHOTS * STEPS_PER_SNAPSHOT;
+ log(`${totalSteps} steps in ${elapsed.toFixed(0)} ms wall (${(elapsed / totalSteps).toFixed(2)} ms/step)`, "ok");
+
+ // Diagnostic: read back Vm and report distribution. If kernel ran
+ // and synaptic drive is reaching neurons, max(Vm) should approach
+ // v_thresh = -45 mV. If Vm sits at -52 (=v_rest) for everyone, the
+ // kernel didn't move — that's a structural bug, not calibration.
+ const vm = await sim.readVm();
+ let vmMin = Infinity, vmMax = -Infinity, vmSum = 0;
+ let aboveRest = 0;
+ for (let i = 0; i < vm.length; i++) {
+ if (vm[i] < vmMin) vmMin = vm[i];
+ if (vm[i] > vmMax) vmMax = vm[i];
+ vmSum += vm[i];
+ if (vm[i] > sim.params.vRest + 0.001) aboveRest++;
}
- }
- log("peak active / total per super_class:");
- for (const [cls, n] of [...peak.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6)) {
- const total = sizes.get(cls) ?? 1;
- log(` ${SUPER_CLASS[cls] ?? cls}: ${n.toLocaleString()} / ${total.toLocaleString()} (${(100 * n / total).toFixed(1)}%)`);
- }
- const snaps = [...Array(viewer.numSnapshots)].map((_, i) => viewer["snapshots"][i] as Float32Array);
- logHeroValidation(snaps);
-
- // Wire scrub bar to new snapshot count, autoplay
- scrub.max = String(viewer.numSnapshots - 1);
- scrub.value = "0";
- label.textContent = `snap 0 / ${viewer.numSnapshots} (t=0 ms)`;
- controls.hidden = false;
- playing = true;
- viewer.setAutoplay(true);
- playBtn.textContent = "⏸";
+ log(`vm: min=${vmMin.toFixed(2)} max=${vmMax.toFixed(2)} mean=${(vmSum / vm.length).toFixed(2)} above-rest=${aboveRest.toLocaleString()}`);
+ // Drive persists at the stim's end-of-window value so the user
+ // can watch the body keep walking after the brain sim completes.
+ // Click another stim (or Spontaneous) to change it.
+
+ // Per-class peak active count
+ const peak = new Map();
+ for (const snap of [...Array(viewer.numSnapshots)].map((_, i) => viewer["snapshots"][i] as Float32Array)) {
+ const live = new Map();
+ for (let i = 0; i < snap.length; i++) {
+ if (snap[i] > 0) live.set(neurons.superClass[i], (live.get(neurons.superClass[i]) ?? 0) + 1);
+ }
+ for (const [k, v] of live) {
+ if (v > (peak.get(k) ?? 0)) peak.set(k, v);
+ }
+ }
+ log("peak active / total per super_class:");
+ for (const [cls, n] of [...peak.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6)) {
+ const total = sizes.get(cls) ?? 1;
+ log(` ${SUPER_CLASS[cls] ?? cls}: ${n.toLocaleString()} / ${total.toLocaleString()} (${(100 * n / total).toFixed(1)}%)`);
+ }
+ const snaps = [...Array(viewer.numSnapshots)].map((_, i) => viewer["snapshots"][i] as Float32Array);
+ logHeroValidation(snaps);
- buttons.forEach((b) => { b.disabled = false; });
- busy = false;
+ // Wire scrub bar to new snapshot count, autoplay
+ scrub.max = String(viewer.numSnapshots - 1);
+ scrub.value = "0";
+ label.textContent = `snap 0 / ${viewer.numSnapshots} (t=0 ms)`;
+ controls.hidden = false;
+ playing = true;
+ viewer.setAutoplay(true);
+ playBtn.textContent = "⏸";
+ } catch (e) {
+ log(`stim failed: ${(e as Error).message}`, "err");
+ } finally {
+ buttons.forEach((b) => { b.disabled = false; });
+ busy = false;
+ }
}
// --- Famous-DN stim: drive both L+R copies of a named DN ---
@@ -825,60 +843,65 @@ async function main() {
btn.classList.add("active");
controls.hidden = true;
- log("");
- log(`--- DN stim: ${name} (${idxs.length} neurons, ${famousDnLabels[name] ?? ""}) ---`, "ok");
- const ext = new Float32Array(header.numNeurons);
- // Direct stim of just 2 DN neurons needs a hefty amplitude to
- // ignite a real cascade through the alpha-synapse-shaped fan-out.
- // Lower than ~3.0 leaves DN-with-weak-downstream (DNb01, DNg13,
- // DNp01) firing only the 2 stimmed cells with no propagation.
- for (const idx of idxs) ext[idx] = 4.0;
- sim.reset(); resetVnc();
- sim.setExternalInput(ext);
- viewer.clearSnapshots();
- if (idxs.length > 0) viewer.highlightNeuron(idxs[0]);
- room.resetFly();
-
- const t0 = performance.now();
- for (let s = 0; s < N_SNAPSHOTS; s++) {
- const rate = await sim.captureRollingRate(STEPS_PER_SNAPSHOT);
- viewer.pushSnapshot(rate);
- await applyDriveFromSnapshot(rate);
- }
- const elapsed = performance.now() - t0;
- log(`${N_SNAPSHOTS * STEPS_PER_SNAPSHOT} steps in ${elapsed.toFixed(0)} ms`, "ok");
+ try {
+ log("");
+ log(`--- DN stim: ${name} (${idxs.length} neurons, ${famousDnLabels[name] ?? ""}) ---`, "ok");
+ const ext = new Float32Array(header.numNeurons);
+ // Direct stim of just 2 DN neurons needs a hefty amplitude to
+ // ignite a real cascade through the alpha-synapse-shaped fan-out.
+ // Lower than ~3.0 leaves DN-with-weak-downstream (DNb01, DNg13,
+ // DNp01) firing only the 2 stimmed cells with no propagation.
+ for (const idx of idxs) ext[idx] = 4.0;
+ sim.reset(); resetVnc();
+ sim.setExternalInput(ext);
+ viewer.clearSnapshots();
+ if (idxs.length > 0) viewer.highlightNeuron(idxs[0]);
+ room.resetFly();
- {
- const vm = await sim.readVm();
- let vmMax = -Infinity;
- for (let i = 0; i < vm.length; i++) if (vm[i] > vmMax) vmMax = vm[i];
- const vmAtIdx = idxs.length ? vm[idxs[0]] : NaN;
- log(`vm: max=${vmMax.toFixed(2)} mV driven[0]=${vmAtIdx.toFixed(2)} mV`);
- }
+ const t0 = performance.now();
+ for (let s = 0; s < N_SNAPSHOTS; s++) {
+ const rate = await sim.captureRollingRate(STEPS_PER_SNAPSHOT);
+ viewer.pushSnapshot(rate);
+ await applyDriveFromSnapshot(rate);
+ }
+ const elapsed = performance.now() - t0;
+ log(`${N_SNAPSHOTS * STEPS_PER_SNAPSHOT} steps in ${elapsed.toFixed(0)} ms`, "ok");
+
+ {
+ const vm = await sim.readVm();
+ let vmMax = -Infinity;
+ for (let i = 0; i < vm.length; i++) if (vm[i] > vmMax) vmMax = vm[i];
+ const vmAtIdx = idxs.length ? vm[idxs[0]] : NaN;
+ log(`vm: max=${vmMax.toFixed(2)} mV driven[0]=${vmAtIdx.toFixed(2)} mV`);
+ }
- let recruited = 0;
- const last = viewer["snapshots"][viewer.numSnapshots - 1] as Float32Array;
- for (let i = 0; i < last.length; i++) if (last[i] > 0) recruited++;
- log(`final-window recruits: ${recruited.toLocaleString()} / ${header.numNeurons.toLocaleString()}`);
- if (recruited > 100) {
- const snaps = [...Array(viewer.numSnapshots)].map((_, j) => viewer["snapshots"][j] as Float32Array);
- logHeroValidation(snaps);
- }
+ let recruited = 0;
+ const last = viewer["snapshots"][viewer.numSnapshots - 1] as Float32Array;
+ for (let i = 0; i < last.length; i++) if (last[i] > 0) recruited++;
+ log(`final-window recruits: ${recruited.toLocaleString()} / ${header.numNeurons.toLocaleString()}`);
+ if (recruited > 100) {
+ const snaps = [...Array(viewer.numSnapshots)].map((_, j) => viewer["snapshots"][j] as Float32Array);
+ logHeroValidation(snaps);
+ }
- // Motor command was already produced each step by applyDriveFromSnapshot
- // → motorFromBrain, which reads this DN's *actual* spike rate from the
- // window and multiplies by its canonical primitive. No lookup table.
- log(`brain-driven motor: fwd=${driveFwd.toFixed(2)} turn=${driveTurn.toFixed(2)}`, "ok");
-
- scrub.max = String(viewer.numSnapshots - 1);
- scrub.value = "0";
- label.textContent = `snap 0 / ${viewer.numSnapshots} (t=0 ms)`;
- controls.hidden = false;
- playing = true;
- viewer.setAutoplay(true);
- playBtn.textContent = "⏸";
- buttons.forEach((b) => { b.disabled = false; });
- busy = false;
+ // Motor command was already produced each step by applyDriveFromSnapshot
+ // → motorFromBrain, which reads this DN's *actual* spike rate from the
+ // window and multiplies by its canonical primitive. No lookup table.
+ log(`brain-driven motor: fwd=${driveFwd.toFixed(2)} turn=${driveTurn.toFixed(2)}`, "ok");
+
+ scrub.max = String(viewer.numSnapshots - 1);
+ scrub.value = "0";
+ label.textContent = `snap 0 / ${viewer.numSnapshots} (t=0 ms)`;
+ controls.hidden = false;
+ playing = true;
+ viewer.setAutoplay(true);
+ playBtn.textContent = "⏸";
+ } catch (e) {
+ log(`stim failed: ${(e as Error).message}`, "err");
+ } finally {
+ buttons.forEach((b) => { b.disabled = false; });
+ busy = false;
+ }
}
// --- Closed-loop visual mode ---
@@ -900,99 +923,107 @@ async function main() {
buttons.forEach((b) => { if (b !== btn) b.classList.remove("active"); });
btn.classList.add("active");
controls.hidden = true;
- log("");
- log(`--- closed-loop visual: track red target ---`, "ok");
- sim.reset(); resetVnc();
- viewer.clearSnapshots();
- room.resetFly();
- // Reset stale drive from previous stims so the fly starts from
- // standstill and reacts to THIS loop's sensor signal, not the
- // last preset's residual.
- driveFwd = 0;
- driveTurn = 0;
- room.setDrive(0, 0);
- // Yield long enough for the room's render tick to repaint the
- // retina from the just-reset body pose. Otherwise tick 1 reads
- // stale retina pixels (from before the reset, when the body had
- // wandered) and falsely reports the target lost.
- await new Promise((r) => setTimeout(r, 100));
- // Sample 4000 optic neurons per side — enough cascade to reach DN
- // through the connectome's optic→central wiring. Below ~2000 the
- // signal dissipates before producing meaningful DN activity.
- const sampleN = 4000;
- const stride = Math.max(1, Math.floor(opticLeft.length / sampleN));
- const lSubset: number[] = [];
- for (let i = 0; i < opticLeft.length; i += stride) lSubset.push(opticLeft[i]);
- const rStride = Math.max(1, Math.floor(opticRight.length / sampleN));
- const rSubset: number[] = [];
- for (let i = 0; i < opticRight.length; i += rStride) rSubset.push(opticRight[i]);
-
- const ext = new Float32Array(header.numNeurons);
- let tick = 0;
- let lostTicks = 0;
- let lastKnownAngle = 0;
- let lastKnownArea = 0;
- while (continuousMode) {
- // Sense from a real retinal render at the fly's head pose. No
- // geometry shortcut — pixels of the scene get sampled, red blob
- // centroid → angle. If target is behind, angle is NaN.
- const sample = room.retinalSample();
- const angle = sample.angle;
- const dist = room.targetDistance();
- ext.fill(0);
- if (Number.isFinite(angle) && sample.area > 0) {
- lostTicks = 0;
- lastKnownAngle = angle;
- lastKnownArea = sample.area;
- const align = 1 - Math.abs(angle) / RETINA_FOV_RAD; // 0..1
- const lScale = align * (angle > 0 ? 1.0 : 0.3);
- const rScale = align * (angle < 0 ? 1.0 : 0.3);
- const amp = 1.5 + 12 * sample.area;
- for (const i of lSubset) ext[i] = amp * lScale;
- for (const i of rSubset) ext[i] = amp * rScale;
- } else {
- lostTicks++;
- }
- sim.setExternalInput(ext);
-
- // Step brain in a short burst (50 ms simulated).
- const rate = await sim.captureRollingRate(50);
- viewer.pushSnapshot(rate);
-
- if (lostTicks === 0) {
- // Target visible: full brain → spine → body path.
- await applyDriveFromSnapshot(rate, sample);
- } else if (lostTicks <= 3) {
- // Target briefly lost (1-3 ticks). Keep tracking using the
- // last-known angle — this smooths out single-frame retina
- // dropouts that the NaN-instant-sweep was making jumpy.
- // Decay the angle estimate by 1.5× each missed tick so the
- // memory fades if target stays gone.
- const decay = 1 + 0.5 * lostTicks;
- const memSample = { angle: lastKnownAngle * decay, area: lastKnownArea * 0.6 };
- await applyDriveFromSnapshot(rate, memSample);
- } else {
- // Target lost for 4+ ticks: enter sweep mode. Bypass spine
- // entirely; alternating turn every 8 ticks to find target.
- const scanDir = lastKnownAngle >= 0 ? 1 : -1;
- const scanCycle = Math.floor((lostTicks - 4) / 8) % 2 === 0 ? 1 : -1;
- driveFwd = 0;
- driveTurn = scanDir * scanCycle * 0.5;
- room.setDrive(driveFwd, driveTurn);
- }
+ try {
+ log("");
+ log(`--- closed-loop visual: track red target ---`, "ok");
+ sim.reset(); resetVnc();
+ viewer.clearSnapshots();
+ room.resetFly();
+ // Reset stale drive from previous stims so the fly starts from
+ // standstill and reacts to THIS loop's sensor signal, not the
+ // last preset's residual.
+ driveFwd = 0;
+ driveTurn = 0;
+ room.setDrive(0, 0);
+ // Yield long enough for the room's render tick to repaint the
+ // retina from the just-reset body pose. Otherwise tick 1 reads
+ // stale retina pixels (from before the reset, when the body had
+ // wandered) and falsely reports the target lost.
+ await new Promise((r) => setTimeout(r, 100));
+ // Sample 4000 optic neurons per side — enough cascade to reach DN
+ // through the connectome's optic→central wiring. Below ~2000 the
+ // signal dissipates before producing meaningful DN activity.
+ const sampleN = 4000;
+ const stride = Math.max(1, Math.floor(opticLeft.length / sampleN));
+ const lSubset: number[] = [];
+ for (let i = 0; i < opticLeft.length; i += stride) lSubset.push(opticLeft[i]);
+ const rStride = Math.max(1, Math.floor(opticRight.length / sampleN));
+ const rSubset: number[] = [];
+ for (let i = 0; i < opticRight.length; i += rStride) rSubset.push(opticRight[i]);
+
+ const ext = new Float32Array(header.numNeurons);
+ let tick = 0;
+ let lostTicks = 0;
+ let lastKnownAngle = 0;
+ let lastKnownArea = 0;
+ while (continuousMode) {
+ // Sense from a real retinal render at the fly's head pose. No
+ // geometry shortcut — pixels of the scene get sampled, red blob
+ // centroid → angle. If target is behind, angle is NaN.
+ const sample = room.retinalSample();
+ const angle = sample.angle;
+ const dist = room.targetDistance();
+ ext.fill(0);
+ if (Number.isFinite(angle) && sample.area > 0) {
+ lostTicks = 0;
+ lastKnownAngle = angle;
+ lastKnownArea = sample.area;
+ const align = 1 - Math.abs(angle) / RETINA_FOV_RAD; // 0..1
+ const lScale = align * (angle > 0 ? 1.0 : 0.3);
+ const rScale = align * (angle < 0 ? 1.0 : 0.3);
+ const amp = 1.5 + 12 * sample.area;
+ for (const i of lSubset) ext[i] = amp * lScale;
+ for (const i of rSubset) ext[i] = amp * rScale;
+ } else {
+ lostTicks++;
+ }
+ sim.setExternalInput(ext);
+
+ // Step brain in a short burst (50 ms simulated).
+ const rate = await sim.captureRollingRate(50);
+ viewer.pushSnapshot(rate);
+
+ if (lostTicks === 0) {
+ // Target visible: full brain → spine → body path.
+ await applyDriveFromSnapshot(rate, sample);
+ } else if (lostTicks <= 3) {
+ // Target briefly lost (1-3 ticks). Keep tracking using the
+ // last-known angle — this smooths out single-frame retina
+ // dropouts that the NaN-instant-sweep was making jumpy.
+ // Decay the angle estimate by 1.5× each missed tick so the
+ // memory fades if target stays gone.
+ const decay = 1 + 0.5 * lostTicks;
+ const memSample = { angle: lastKnownAngle * decay, area: lastKnownArea * 0.6 };
+ await applyDriveFromSnapshot(rate, memSample);
+ } else {
+ // Target lost for 4+ ticks: enter sweep mode. Bypass spine
+ // entirely; alternating turn every 8 ticks to find target.
+ const scanDir = lastKnownAngle >= 0 ? 1 : -1;
+ const scanCycle = Math.floor((lostTicks - 4) / 8) % 2 === 0 ? 1 : -1;
+ driveFwd = 0;
+ driveTurn = scanDir * scanCycle * 0.5;
+ room.setDrive(driveFwd, driveTurn);
+ }
- tick++;
- if (tick <= 10 || tick % 10 === 0) {
- log(` tick ${tick}: angle=${(angle * 180 / Math.PI).toFixed(0)}° dist=${dist.toFixed(1)}cm fwd=${driveFwd.toFixed(2)} turn=${driveTurn.toFixed(2)}`);
+ tick++;
+ if (tick <= 10 || tick % 10 === 0) {
+ log(` tick ${tick}: angle=${(angle * 180 / Math.PI).toFixed(0)}° dist=${dist.toFixed(1)}cm fwd=${driveFwd.toFixed(2)} turn=${driveTurn.toFixed(2)}`);
+ }
+ // Yield to render.
+ await new Promise((r) => setTimeout(r, 0));
}
- // Yield to render.
- await new Promise((r) => setTimeout(r, 0));
+ controls.hidden = false;
+ } catch (e) {
+ log(`stim failed: ${(e as Error).message}`, "err");
+ } finally {
+ // Cleanup when loop exits. Reached only once the in-flight
+ // iteration has finished, so the toggle-off path above can't
+ // re-enable the buttons underneath a running loop.
+ continuousMode = false;
+ btn.classList.remove("active");
+ buttons.forEach((b) => { b.disabled = false; });
+ busy = false;
}
- // Cleanup when loop exits.
- btn.classList.remove("active");
- buttons.forEach((b) => { b.disabled = false; });
- busy = false;
- controls.hidden = false;
}
loopBtn.addEventListener("click", () => runContinuousLoop(loopBtn));
@@ -1159,50 +1190,57 @@ async function main() {
buttons.forEach((b) => { b.disabled = true; b.classList.remove("active"); });
controls.hidden = true;
- const sc = SUPER_CLASS[neurons.superClass[idx]] ?? "?";
- const hero = neurons.cellType[idx] & 0xff;
- const heroName = ["", "KC", "MBON", "LHN", "PN", "ORN", "GF", "DN"][hero] ?? "";
- const tag = heroName ? `${heroName} (${sc})` : sc;
- log("");
- log(`--- single-neuron stim: idx ${idx} [${tag}] ---`, "ok");
-
- const ext = new Float32Array(header.numNeurons);
- ext[idx] = 2.0; // strong pulse on this one cell
- sim.reset(); resetVnc();
- sim.setExternalInput(ext);
- viewer.clearSnapshots();
- viewer.highlightNeuron(idx);
- room.resetFly();
-
- const t0 = performance.now();
- for (let s = 0; s < N_SNAPSHOTS; s++) {
- const rate = await sim.captureRollingRate(STEPS_PER_SNAPSHOT);
- viewer.pushSnapshot(rate);
- await applyDriveFromSnapshot(rate);
- }
- const elapsed = performance.now() - t0;
- log(`${N_SNAPSHOTS * STEPS_PER_SNAPSHOT} steps in ${elapsed.toFixed(0)} ms`, "ok");
-
- let recruited = 0;
- const last = viewer["snapshots"][viewer.numSnapshots - 1] as Float32Array;
- for (let i = 0; i < last.length; i++) if (last[i] > 0) recruited++;
- log(`final-window recruits: ${recruited.toLocaleString()} / ${header.numNeurons.toLocaleString()}`);
- if (recruited > 100) {
- const snaps = [...Array(viewer.numSnapshots)].map((_, j) => viewer["snapshots"][j] as Float32Array);
- logHeroValidation(snaps);
- }
+ try {
+ const sc = SUPER_CLASS[neurons.superClass[idx]] ?? "?";
+ const hero = neurons.cellType[idx] & 0xff;
+ const heroName = ["", "KC", "MBON", "LHN", "PN", "ORN", "GF", "DN"][hero] ?? "";
+ const tag = heroName ? `${heroName} (${sc})` : sc;
+ log("");
+ log(`--- single-neuron stim: idx ${idx} [${tag}] ---`, "ok");
+
+ const ext = new Float32Array(header.numNeurons);
+ ext[idx] = 2.0; // strong pulse on this one cell
+ sim.reset(); resetVnc();
+ sim.setExternalInput(ext);
+ viewer.clearSnapshots();
+ viewer.highlightNeuron(idx);
+ room.resetFly();
- scrub.max = String(viewer.numSnapshots - 1);
- scrub.value = "0";
- label.textContent = `snap 0 / ${viewer.numSnapshots} (t=0 ms)`;
- controls.hidden = false;
- playing = true;
- viewer.setAutoplay(true);
- playBtn.textContent = "⏸";
- buttons.forEach((b) => { b.disabled = false; });
- busy = false;
+ const t0 = performance.now();
+ for (let s = 0; s < N_SNAPSHOTS; s++) {
+ const rate = await sim.captureRollingRate(STEPS_PER_SNAPSHOT);
+ viewer.pushSnapshot(rate);
+ await applyDriveFromSnapshot(rate);
+ }
+ const elapsed = performance.now() - t0;
+ log(`${N_SNAPSHOTS * STEPS_PER_SNAPSHOT} steps in ${elapsed.toFixed(0)} ms`, "ok");
+
+ let recruited = 0;
+ const last = viewer["snapshots"][viewer.numSnapshots - 1] as Float32Array;
+ for (let i = 0; i < last.length; i++) if (last[i] > 0) recruited++;
+ log(`final-window recruits: ${recruited.toLocaleString()} / ${header.numNeurons.toLocaleString()}`);
+ if (recruited > 100) {
+ const snaps = [...Array(viewer.numSnapshots)].map((_, j) => viewer["snapshots"][j] as Float32Array);
+ logHeroValidation(snaps);
+ }
+
+ scrub.max = String(viewer.numSnapshots - 1);
+ scrub.value = "0";
+ label.textContent = `snap 0 / ${viewer.numSnapshots} (t=0 ms)`;
+ controls.hidden = false;
+ playing = true;
+ viewer.setAutoplay(true);
+ playBtn.textContent = "⏸";
+ } catch (e) {
+ log(`stim failed: ${(e as Error).message}`, "err");
+ } finally {
+ buttons.forEach((b) => { b.disabled = false; });
+ busy = false;
+ }
}
- viewer.onPick((idx) => { void runSingleNeuronStim(idx); });
+ // Science mode only — the brain pane stays clickable under body.game, and
+ // a 400-step stim there would reset the fly in the middle of a round.
+ if (APP_MODE === "science") viewer.onPick((idx) => { void runSingleNeuronStim(idx); });
if (APP_MODE === "game") {
// Wait for physics to be ready, then start the game. Game mode owns
@@ -1243,4 +1281,7 @@ async function main() {
}
}
-main().catch((e) => log(`uncaught: ${(e as Error).stack ?? e}`, "err"));
+main().catch((e) => {
+ log(`uncaught: ${(e as Error).stack ?? e}`, "err");
+ bootFail(`failed: ${(e as Error).message}`);
+});
diff --git a/src/physics.ts b/src/physics.ts
index f252703..82fdb5e 100644
--- a/src/physics.ts
+++ b/src/physics.ts
@@ -10,7 +10,7 @@
import loadMujoco from "@mujoco/mujoco";
import type {
MainModule, MjModel, MjData,
- MjVFS, MjvScene, MjvOption, MjvPerturb, MjvCamera,
+ MjvScene, MjvOption, MjvPerturb, MjvCamera,
} from "@mujoco/mujoco";
import { getOrFetch } from "./cache";
@@ -20,7 +20,6 @@ export class Physics {
data!: MjData;
scene!: MjvScene;
- private vfs!: MjVFS;
private opt!: MjvOption;
private perturb!: MjvPerturb;
private cam!: MjvCamera;
@@ -102,14 +101,14 @@ export class Physics {
),
);
- p.vfs = new p.mujoco.MjVFS();
+ const vfs = new p.mujoco.MjVFS();
let totalBytes = 0;
for (const file of meshFiles) {
const data = fileBytes.get(file);
if (!data) {
throw new Error(`flybody bundle missing mesh: ${file}`);
}
- p.vfs.addBuffer(file, data);
+ vfs.addBuffer(file, data);
totalBytes += data.byteLength;
}
onProgress?.(`loaded ${meshFiles.length} meshes from bundle (${(totalBytes / 1e6).toFixed(0)} MB)`);
@@ -117,12 +116,15 @@ export class Physics {
// The compiler resolves `` from the
// VFS, so we have to register fruitfly.xml there as well.
- p.vfs.addBuffer("fruitfly.xml", flyBytes);
+ vfs.addBuffer("fruitfly.xml", flyBytes);
onProgress?.("compiling MJCF (synchronous; tab may freeze ~5-15s)");
const tCompile = performance.now();
- p.model = p.mujoco.MjModel.from_xml_string(floorText, p.vfs);
+ p.model = p.mujoco.MjModel.from_xml_string(floorText, vfs);
p.data = new p.mujoco.MjData(p.model);
+ // The compiler copies everything it needs into mjModel; holding the
+ // ~140 MB of OBJ bytes past this point just starves the wasm heap.
+ vfs.delete();
onProgress?.(`MJCF compiled in ${((performance.now() - tCompile) / 1000).toFixed(1)} s`);
// Initialise to flybody's canonical rest pose, matching native
@@ -971,7 +973,14 @@ export class Physics {
const qpos = this.data.qpos as Float64Array;
const qposSpring = this.model.qpos_spring as Float64Array;
if (qposSpring && qposSpring.length === qpos.length) {
- qpos.set(qposSpring);
+ for (const side of ["left", "right"]) {
+ for (const dof of ["yaw", "roll", "pitch"]) {
+ const j = this.mujoco.mj_name2id(this.model, this.mujoco.mjtObj.mjOBJ_JOINT.value, `wing_${dof}_${side}`);
+ if (j < 0) continue;
+ const adr = (this.model.jnt_qposadr as Int32Array)[j];
+ if (adr >= 0 && adr < qpos.length) qpos[adr] = qposSpring[adr];
+ }
+ }
}
if (qpos.length >= 7) {
qpos[0] = 0; qpos[1] = 0; qpos[2] = 0.1278;
@@ -987,7 +996,6 @@ export class Physics {
this.opt.delete();
this.data.delete();
this.model.delete();
- this.vfs.delete();
}
get bodyCount() { return this.model.nbody as number; }
diff --git a/src/shaders/lif.wgsl b/src/shaders/lif.wgsl
index 76ce367..d67d7dd 100644
--- a/src/shaders/lif.wgsl
+++ b/src/shaders/lif.wgsl
@@ -12,10 +12,8 @@
// threshold, atomic-OR spike bit into spikes_curr.
//
// Host ping-pongs spikes_prev / spikes_curr each timestep so the gather
-// always reads stable last-step state. The Params struct stayed at its
-// proven 9-field / 36-byte layout; a_syn is a compile-time const here
-// instead of a runtime field, which sidesteps the silent-dispatch
-// failure we hit when expanding the uniform struct.
+// always reads stable last-step state. a_syn is a compile-time const
+// rather than a Params field because the kernel is fixed at dt = 1 ms.
struct Params {
num_neurons : u32,
@@ -44,8 +42,9 @@ struct Params {
const WG_SIZE : u32 = 64u;
// a_syn = exp(-dt / tau_syn) for dt = 1 ms, tau_syn = 5 ms.
-// Hardcoded so we don't have to extend the Params struct (which broke
-// the kernel last time we tried).
+// Const, so it silently assumes SimParams.dtMs = 1 — that host param
+// feeds alpha and the refractory step count but not this value. Make it
+// a Params field if a different dt is ever used.
const A_SYN : f32 = 0.81873;
fn spike_bit(idx : u32) -> f32 {
diff --git a/src/sim.ts b/src/sim.ts
index 499a5b7..ce92f3e 100644
--- a/src/sim.ts
+++ b/src/sim.ts
@@ -87,6 +87,7 @@ export class FlySim {
maxStorageBuffersPerShaderStage: adapter.limits.maxStorageBuffersPerShaderStage,
};
const device = await adapter.requestDevice({ requiredLimits: required });
+ device.lost.then((info) => console.error(`WebGPU device lost: ${info.reason} — ${info.message}`));
return new FlySim(device, brain, params);
}
diff --git a/tests-unit/vnc.test.ts b/tests-unit/vnc.test.ts
new file mode 100644
index 0000000..9680719
--- /dev/null
+++ b/tests-unit/vnc.test.ts
@@ -0,0 +1,62 @@
+// vnc.test.ts — pure-CPU unit checks that run without a GPU, so CI has
+// something that actually validates behaviour (the playwright suite needs
+// WebGPU + ~1 GB of assets and cannot run on a hosted runner).
+//
+// Sits in tests-unit/ rather than tests/ so playwright's testDir ("./tests")
+// does not try to collect it.
+//
+// Assertions are signs and structural invariants only. The LIF constants in
+// vnc.ts are emergent dynamics, not a contract — asserting magnitudes would
+// turn any legitimate retune into a red build.
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+
+import { motorFromBrain, resetVnc, type MotorContext } from "../src/vnc.ts";
+import { WALKING_OBS_TOTAL, obsOffset } from "../src/walking-policy.ts";
+
+// Five DN inputs at fixed indices; everything else silent.
+const ctxFor = (dn: string, visual?: { angle: number; area: number }) => {
+ const rate = new Float32Array(5);
+ const names = ["DNa01", "DNa02", "DNb01", "DNg13", "DNp01"];
+ const i = names.indexOf(dn);
+ if (i >= 0) rate[i] = 1;
+ const ctx: MotorContext = {
+ famousDns: { DNa01: [0], DNa02: [1], DNb01: [2], DNg13: [3], DNp01: [4] },
+ dnLeft: [],
+ dnRight: [],
+ visual,
+ };
+ return { rate, ctx };
+};
+
+// sharedVnc is module-level mutable LIF state — without resetVnc() every
+// case inherits the previous one's membrane potentials.
+const drive = (dn: string, visual?: { angle: number; area: number }) => {
+ resetVnc();
+ const { rate, ctx } = ctxFor(dn, visual);
+ let out = motorFromBrain(rate, ctx);
+ for (let i = 0; i < 50; i++) out = motorFromBrain(rate, ctx);
+ return out;
+};
+
+test("DNa01 drive walks forward", () => {
+ assert.ok(drive("DNa01").fwd > 0);
+});
+
+test("DNb01 drive walks backward", () => {
+ assert.ok(drive("DNb01").fwd < 0);
+});
+
+test("visual target to the left turns left", () => {
+ assert.ok(drive("", { angle: 0.5, area: 0.01 }).turn > 0);
+});
+
+test("visual target to the right turns right", () => {
+ assert.ok(drive("", { angle: -0.5, area: 0.01 }).turn < 0);
+});
+
+test("walking observation layout matches the trained policy's input", () => {
+ assert.equal(WALKING_OBS_TOTAL, 741);
+ assert.equal(obsOffset("world_zaxis"), 738);
+});
diff --git a/tests/game-feel.spec.ts b/tests/game-feel.spec.ts
index ebdd0c4..90aaaeb 100644
--- a/tests/game-feel.spec.ts
+++ b/tests/game-feel.spec.ts
@@ -12,7 +12,7 @@ import { test, expect } from "./fixtures";
test("pressing Q (DNa01) moves the fly toward the target", async ({ page }) => {
test.setTimeout(180_000);
- await page.goto("/?mode=game");
+ await page.goto("/app?mode=game");
await page.waitForFunction(
() => /game mode: ready/.test(
document.querySelector("#out")?.textContent ?? "",
@@ -61,6 +61,10 @@ test("pressing Q (DNa01) moves the fly toward the target", async ({ page }) => {
console.log(` final dist: ${d1.toFixed(2)} cm`);
console.log(` body x : ${bodyX?.toFixed(3)} cm (started at 0)`);
- // The fly should have moved at all (body x changed > 0.1 cm).
+ // The fly should have moved at all (body x changed > 0.1 cm)...
expect(bodyX !== null && Math.abs(bodyX) > 0.1).toBe(true);
+ // ...and that motion must not be away from the target. Slack is
+ // deliberate: open-loop Q can curve (see smoke.spec.ts |turn| < 0.4).
+ expect(d1, `fly moved away from target: ${d0.toFixed(2)} → ${d1.toFixed(2)} cm`)
+ .toBeLessThan(d0 + 0.5);
});
diff --git a/tests/game-replay.spec.ts b/tests/game-replay.spec.ts
index 4e99d90..6f9337c 100644
--- a/tests/game-replay.spec.ts
+++ b/tests/game-replay.spec.ts
@@ -25,10 +25,19 @@ async function startRound(page: import("@playwright/test").Page) {
test("replay URL roundtrips events + target seed", async ({ page }) => {
test.setTimeout(180_000);
- await page.goto("/?mode=game");
+ await page.goto("/app?mode=game");
await waitGameReady(page);
await startRound(page);
+ // Seeded target position — the byte-exact, GPU-free determinism check.
+ // Captured before the forced win below, which moves the target.
+ const t0 = await page.evaluate(() => {
+ const g = (window as unknown as {
+ __game?: { ctx: { room: { targetPos: [number, number, number] } } };
+ }).__game!;
+ return [g.ctx.room.targetPos[0], g.ctx.room.targetPos[1]];
+ });
+
// Press Q for 1.2s, then E for 0.8s.
await page.keyboard.down("q");
await page.waitForTimeout(1200);
@@ -80,8 +89,8 @@ test("replay URL roundtrips events + target seed", async ({ page }) => {
expect(replayUrl).toContain("mode=game");
const m = replayUrl.match(/#r=([A-Za-z0-9_-]+)/);
expect(m).toBeTruthy();
- // 2 down + 2 up events = 4 records × 4 bytes = 16 + 4 (seed) = 20 bytes,
- // base64 ≈ 27 chars. Tolerant assertion.
+ // 2 down + 2 up events = 4 records × 4 bytes = 16 + 6 (dn fingerprint
+ // + seed) = 22 bytes, base64 ≈ 30 chars. Tolerant assertion.
expect(m![1].length).toBeGreaterThan(20);
// Open the URL in a fresh context (same persistent profile) and verify
@@ -102,7 +111,18 @@ test("replay URL roundtrips events + target seed", async ({ page }) => {
);
const bodyText = await page2.locator(".overlay-body").textContent();
- // 4 events: q-down, q-up, e-down, e-up.
+ // 4 events: q-down, q-up, e-down, e-up. Both keys are released before
+ // the forced win, so the win adds no synthetic releases.
expect(bodyText).toContain("4 keystrokes");
+
+ // Same seed → same LCG → bit-identical target, on a different page.
+ const t1 = await page2.evaluate(() => {
+ const g = (window as unknown as {
+ __game?: { ctx: { room: { targetPos: [number, number, number] } } };
+ }).__game!;
+ return [g.ctx.room.targetPos[0], g.ctx.room.targetPos[1]];
+ });
+ expect(t1[0]).toBeCloseTo(t0[0], 9);
+ expect(t1[1]).toBeCloseTo(t0[1], 9);
await page2.close();
});
diff --git a/tests/game-screenshot.spec.ts b/tests/game-screenshot.spec.ts
index 1611307..e84a10d 100644
--- a/tests/game-screenshot.spec.ts
+++ b/tests/game-screenshot.spec.ts
@@ -6,7 +6,7 @@ import { test } from "./fixtures";
test("capture game screenshots", async ({ page }) => {
test.setTimeout(180_000);
- await page.goto("/?mode=game");
+ await page.goto("/app?mode=game");
await page.waitForFunction(
() => /game mode: ready/.test(document.querySelector("#out")?.textContent ?? ""),
null,
diff --git a/tests/game.spec.ts b/tests/game.spec.ts
index edff0e3..eae3dfd 100644
--- a/tests/game.spec.ts
+++ b/tests/game.spec.ts
@@ -18,7 +18,7 @@ test.describe("game mode", () => {
test.setTimeout(180_000);
test("HUD + key strip + intro render after boot", async ({ page }) => {
- await page.goto("/?mode=game");
+ await page.goto("/app?mode=game");
// Wait for game readiness signal in the log.
await page.waitForFunction(
@@ -43,7 +43,7 @@ test.describe("game mode", () => {
});
test("SPACE starts a round, key press flashes the strip", async ({ page }) => {
- await page.goto("/?mode=game");
+ await page.goto("/app?mode=game");
await page.waitForFunction(
() => /game mode: ready/.test(
document.querySelector("#out")?.textContent ?? "",
@@ -74,7 +74,7 @@ test.describe("game mode", () => {
});
test("HUD timer ticks during a round", async ({ page }) => {
- await page.goto("/?mode=game");
+ await page.goto("/app?mode=game");
await page.waitForFunction(
() => /game mode: ready/.test(
document.querySelector("#out")?.textContent ?? "",
@@ -99,7 +99,7 @@ test.describe("game mode", () => {
});
test("science mode keeps classic layout", async ({ page }) => {
- await page.goto("/?mode=science");
+ await page.goto("/app?mode=science");
// Original sidebar should be visible (not game mode).
await expect(page.locator("#side")).toBeVisible();
await expect(page.locator("#game-hud")).toBeHidden();
@@ -107,7 +107,7 @@ test.describe("game mode", () => {
});
test("daily-challenge button is visible in intro", async ({ page }) => {
- await page.goto("/?mode=game");
+ await page.goto("/app?mode=game");
await page.waitForFunction(
() => /game mode: ready/.test(document.querySelector("#out")?.textContent ?? ""),
null,
diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts
index 7151b5e..7890dfc 100644
--- a/tests/smoke.spec.ts
+++ b/tests/smoke.spec.ts
@@ -62,7 +62,7 @@ function extractNumber(log: string, re: RegExp): number | null {
test.describe("webgpu-fly e2e", () => {
test.beforeEach(async ({ page }) => {
- await page.goto("/?mode=science");
+ await page.goto("/app?mode=science");
await waitForLog(page, READY_MSG, 90_000);
});
@@ -115,7 +115,7 @@ test.describe("webgpu-fly e2e", () => {
// meaningful cascade — cementing the new-DN wiring.
test("MDN button stims connectome (Dallmann walking-circuit roster)", async ({ page }) => {
const btn = page.locator(`.stim-btn:has(.label:has-text("MDN"))`).first();
- await expect(btn, "MDN famous-DN button missing").toBeVisible({ timeout: 30_000 });
+ await expect(btn, "MDN famous-DN button missing").toBeVisible({ timeout: 60_000 });
await clickButton(page, "MDN");
await waitButtonIdle(page, "MDN");
const log = await logText(page);
@@ -130,7 +130,7 @@ test.describe("webgpu-fly e2e", () => {
// the story (RRN → 21 walking-circuit DNs → premotor → legs).
test("RRN button cascades and drives forward motor", async ({ page }) => {
const btn = page.locator(`.stim-btn:has(.label:has-text("RRN"))`).first();
- await expect(btn, "RRN famous-DN button missing").toBeVisible({ timeout: 30_000 });
+ await expect(btn, "RRN famous-DN button missing").toBeVisible({ timeout: 60_000 });
await clickButton(page, "RRN");
await waitButtonIdle(page, "RRN");
const log = await logText(page);
@@ -155,7 +155,7 @@ test.describe("webgpu-fly e2e", () => {
// produce a large cascade and net-forward motor.
test("BPN button cascades and drives forward motor", async ({ page }) => {
const btn = page.locator(`.stim-btn:has(.label:has-text("BPN"))`).first();
- await expect(btn, "BPN famous-DN button missing").toBeVisible({ timeout: 30_000 });
+ await expect(btn, "BPN famous-DN button missing").toBeVisible({ timeout: 60_000 });
await clickButton(page, "BPN");
await waitButtonIdle(page, "BPN");
const log = await logText(page);
@@ -364,7 +364,7 @@ test.describe("webgpu-fly e2e", () => {
// drifts (variable mapping, layer order, activation function),
// this test pinpoints it.
test("trained walking policy matches numpy ground truth", async ({ page }) => {
- await page.goto("/?mode=science");
+ await page.goto("/app?mode=science");
const fixtures = await page.evaluate(async () => {
const fres = await fetch("/walking-policy-fixtures.json");
const cases: Array<{ name: string; obs: number[]; action: number[] }> = await fres.json();
@@ -395,7 +395,7 @@ test.describe("webgpu-fly e2e", () => {
// strict contract — drift here means the policy receives garbage at
// runtime. Lock it down.
test("walking policy obs layout sums to 741", async ({ page }) => {
- await page.goto("/?mode=science");
+ await page.goto("/app?mode=science");
const result = await page.evaluate(async () => {
const modPath = "/src/walking-policy.ts";
const mod = await import(/* @vite-ignore */ modPath);
@@ -422,7 +422,7 @@ test.describe("webgpu-fly e2e", () => {
});
test("trained walking policy loads + forward-passes", async ({ page }) => {
- await page.goto("/?mode=science");
+ await page.goto("/app?mode=science");
const result = await page.evaluate(async () => {
const modPath = "/src/walking-policy.ts";
const mod = await import(/* @vite-ignore */ modPath);
diff --git a/tools/build_csr.py b/tools/build_csr.py
index 39e64d2..7825b91 100644
--- a/tools/build_csr.py
+++ b/tools/build_csr.py
@@ -348,7 +348,7 @@ def main() -> int:
famous_dns[label] = idxs
# Resolve community_name buttons (BPN) from Dallmann 2026 Supp Table 1.
- DALLMANN_TABLE_1 = Path("data/raw/dallmann_2026/supplementary_table_1.xlsx")
+ DALLMANN_TABLE_1 = RAW / "dallmann_2026" / "supplementary_table_1.xlsx"
if community_name_lookups and DALLMANN_TABLE_1.exists():
import openpyxl
wb = openpyxl.load_workbook(DALLMANN_TABLE_1, data_only=True)
@@ -364,6 +364,9 @@ def main() -> int:
idxs.append(int(idx))
if idxs:
famous_dns[label] = idxs
+ elif community_name_lookups:
+ print(f" note: {DALLMANN_TABLE_1} absent — BPN preset skipped "
+ f"(download Dallmann 2026 Supp Table 1 to enable)")
# Walking-circuit DN catalog from Dallmann et al. 2026 (supp fig 2c):
# 21 cell types in two clusters downstream of RRN+BPN. Saved as a
diff --git a/tools/build_vnc.py b/tools/build_vnc.py
index b5c3895..8cc443b 100644
--- a/tools/build_vnc.py
+++ b/tools/build_vnc.py
@@ -20,7 +20,8 @@
num_neurons u32 = N
num_edges u32 = E
flags u32 bit 0: weights are pre-signed by presynaptic NT
- reserved u32[12] pad to 64 B
+ (bytes 24..36 hold voxel_to_nm in brain.bin; zero in vnc.bin)
+ reserved u32[10] pad to 64 B (8 + 4*4 + 40 = 64)
[ Neurons — N × 32 B ]
pos_x f32 soma x in nm (from somaLocation)
@@ -246,9 +247,11 @@ def main() -> None:
# Pre-sign weights by presynaptic NT (same convention as brain.bin).
print("signing weights by presynaptic neurotransmitter …")
pre_nt_by_idx = df["predictedNt"].fillna("unknown").astype(str).str.lower().to_numpy()
+ pre_conf = pd.to_numeric(df["predictedNtProb"], errors="coerce").fillna(0.0).to_numpy()
sign_arr = np.zeros(N, dtype=np.float32)
for i, nt in enumerate(pre_nt_by_idx):
- sign_arr[i] = NT_SIGN.get(nt, 0)
+ if pre_conf[i] >= NT_CONF_MIN:
+ sign_arr[i] = NT_SIGN.get(nt, 0)
pre_idx = edges["pre_idx"].to_numpy(dtype=np.uint32)
post_idx = edges["post_idx"].to_numpy(dtype=np.uint32)
@@ -283,10 +286,7 @@ def main() -> None:
cell_class[i] = classify(row)
leg_seg[i] = leg_segment_packed(row)
# predictedNtProb gives float confidence; default 0
- try:
- nt_conf[i] = float(row.get("predictedNtProb") or 0)
- except Exception:
- nt_conf[i] = 0.0
+ nt_conf[i] = float(pre_conf[i])
# Write binary
OUT_BIN.parent.mkdir(parents=True, exist_ok=True)
@@ -296,7 +296,7 @@ def main() -> None:
f.write(b"WGFLYVNC") # magic 8B
f.write(struct.pack("..r2.dev/vnc.bin
# VITE_VNC_META_URL = https://..r2.dev/vnc.meta.json
# VITE_FLYBODY_URL = https://..r2.dev/flybody
+# VITE_FLYBODY_BUNDLE_URL = https://..r2.dev/flybody.bundle.bin
+# VITE_WALKING_REF_URL = https://..r2.dev/walking-ref.bin
set -euo pipefail
# Force C locale so printf "%.1f" gets `.` for decimal regardless of
@@ -72,6 +74,8 @@ put "public/vnc.bin" "vnc.bin" "application/octet-stre
put "public/vnc.meta.json" "vnc.meta.json" "application/json"
put "public/walking-policy.bin" "walking-policy.bin" "application/octet-stream"
put "public/walking-obs-norm.bin" "walking-obs-norm.bin" "application/octet-stream"
+put "public/flybody.bundle.bin" "flybody.bundle.bin" "application/octet-stream"
+put "public/walking-ref.bin" "walking-ref.bin" "application/octet-stream"
echo "uploading flybody MJCF + meshes (immutable) …"
for f in public/flybody/*.xml; do
@@ -90,4 +94,6 @@ echo " VITE_BRAIN_META_URL = https:///brain.meta.json"
echo " VITE_VNC_URL = https:///vnc.bin"
echo " VITE_VNC_META_URL = https:///vnc.meta.json"
echo " VITE_FLYBODY_URL = https:///flybody"
+echo " VITE_FLYBODY_BUNDLE_URL = https:///flybody.bundle.bin"
+echo " VITE_WALKING_REF_URL = https:///walking-ref.bin"
echo " VITE_ASSET_MANIFEST_URL = https:///assets.json"
diff --git a/vercel.json b/vercel.json
index dda003f..4443575 100644
--- a/vercel.json
+++ b/vercel.json
@@ -23,6 +23,12 @@
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
]
},
+ {
+ "source": "/assets/(.*)",
+ "headers": [
+ { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
+ ]
+ },
{
"source": "/assets/(.*)\\.wasm",
"headers": [
|