diff --git a/metabolism/src/demo/main.js b/metabolism/src/demo/main.js
index 6f54aec..c93e663 100644
--- a/metabolism/src/demo/main.js
+++ b/metabolism/src/demo/main.js
@@ -52,6 +52,7 @@ const state = {
playing: false,
loading: false,
fps: null, // achieved film rate, measured by the circuit breaker
+ lagS: null, // seconds the picture runs behind the audio clock
};
// ---- audio pipeline (browser side of the shared pipeline) ------------------
@@ -99,6 +100,7 @@ async function loadSongData(id, seed, res) {
const song = buildSong(analysis, seed);
const player = new Player(els.view, w, h);
player.build(song);
+ await player.warm(); // the pool answers its boot acks before the show begins
const audio = new AudioTransport(pcmSlot.pcm, pcmSlot.sampleRate, { onEnd: handleEnded });
return { entry: state.byId.get(id), song, analysis, player, audio };
}
@@ -212,19 +214,46 @@ function doSeek(seconds) {
const BREAKER_FPS = 25; // the minimum sustained film rate we accept
const BREAKER_WIN = 2.0; // seconds per measurement window
const BREAKER_STRIKES = 2; // consecutive slow windows before we stop
+const BREAKER_LAG = 30; // frames behind the audio clock that count as stranded
+const BREAKER_LAG_HOLD = 750; // ms of stranding before we stop
-const breaker = { frames: 0, dt: 0, last: 0, strikes: 0 };
+const breaker = { frames: 0, dt: 0, last: 0, strikes: 0, lagSince: 0 };
function breakerReset() {
breaker.frames = 0;
breaker.dt = 0;
breaker.last = 0;
breaker.strikes = 0;
+ breaker.lagSince = 0;
state.fps = null;
+ state.lagS = null;
}
-function breakerTick(steps) {
+function tripBreaker(why) {
+ console.warn('[metabolism] circuit breaker:', JSON.stringify(why));
+ breakerReset();
+ pauseCurrent();
+ const detail = why.lagS != null
+ ? `the picture fell ${why.lagS.toFixed(1)}s behind the song — playback stopped`
+ : `sustained ${Math.round(why.fps)} fps < ${BREAKER_FPS} — playback stopped`;
+ setOverlay(
+ `the world outgrew this machine` +
+ `${detail}. Try a lower resolution (res) or another seed.`
+ );
+}
+
+function breakerTick(steps, behind) {
const now = performance.now();
+ // The lag trip: a rate that still clears BREAKER_FPS but never reaches
+ // the audio clock means the picture drifts (the deficit grows every
+ // tick) — that is what chugging looks like when the rate looks fine.
+ if (behind >= BREAKER_LAG) {
+ if (!breaker.lagSince) breaker.lagSince = now;
+ else if (now - breaker.lagSince >= BREAKER_LAG_HOLD) { tripBreaker({ lagS: behind / FPS }); return; }
+ } else {
+ breaker.lagSince = 0;
+ state.lagS = behind > 0 ? behind / FPS : null;
+ }
if (!breaker.last) { breaker.last = now; return; }
breaker.dt += (now - breaker.last) / 1000;
breaker.last = now;
@@ -238,18 +267,7 @@ function breakerTick(steps) {
breaker.strikes = 0;
return;
}
- if (++breaker.strikes >= BREAKER_STRIKES) tripBreaker(fps);
-}
-
-function tripBreaker(fps) {
- console.warn(`[metabolism] circuit breaker: sustained ${fps.toFixed(1)} fps < ${BREAKER_FPS} — stopping`);
- breakerReset();
- pauseCurrent();
- setOverlay(
- `the world outgrew this machine` +
- `sustained ${Math.round(fps)} fps < ${BREAKER_FPS} — playback stopped. ` +
- `Try a lower resolution (res) or another seed.`
- );
+ if (++breaker.strikes >= BREAKER_STRIKES) tripBreaker({ fps });
}
let hudTick = 0;
@@ -261,7 +279,7 @@ function loop() {
const before = c.player.run.world.frame;
c.player.stepTo(target);
c.player.renderCurrent();
- breakerTick(target - before);
+ breakerTick(target - before, Math.max(0, target - c.player.run.world.frame));
updateSeekUI(pos);
if (++hudTick % 4 === 0) updateHUD();
} else {
@@ -339,7 +357,8 @@ function updateHUD() {
const s = w.stats();
els.hud.textContent =
`${s.alive} agents · field ${s.fieldMass.toFixed(2)} · trail ${s.trailMass.toFixed(3)}` +
- (state.fps != null ? ` · ${Math.round(state.fps)} fps` : '');
+ (state.fps != null ? ` · ${Math.round(state.fps)} fps` : '') +
+ (state.lagS != null && state.lagS >= 0.5 ? ` · ${(state.lagS).toFixed(1)}s behind` : '');
const cam = c.player.run.camera;
const size = cam.zoom < 1.8 ? 'wide' : cam.zoom < 2.7 ? 'medium' : 'close';
els.shot.textContent = `shot ${cam.shot} · ${size} · zoom ${cam.zoom.toFixed(2)}`;
diff --git a/metabolism/src/demo/player.js b/metabolism/src/demo/player.js
index c2c1e65..20d8714 100644
--- a/metabolism/src/demo/player.js
+++ b/metabolism/src/demo/player.js
@@ -10,6 +10,18 @@ import { Run } from '../film.js';
export const FPS = 30;
+/**
+ * The demo's pooled parallelism. The main thread keeps the render and the
+ * page; the spare cores grow the world. Only when cross-origin isolation
+ * exposes SharedArrayBuffer and the hardware has cores beyond
+ * (main thread + one) — otherwise stay serial and let the breaker decide.
+ */
+export function demoWorkers() {
+ if (typeof SharedArrayBuffer === 'undefined' || self.crossOriginIsolated === false) return 0;
+ const hc = navigator.hardwareConcurrency || 4;
+ return hc > 2 ? Math.min(4, hc - 2) : 0;
+}
+
export class Player {
/**
* @param {HTMLCanvasElement} canvas
@@ -21,6 +33,7 @@ export class Player {
this.ctx = canvas.getContext('2d');
this.width = width;
this.height = height;
+ this.workers = demoWorkers(); // fixed per page load, reused on rebuilds
this.run = null;
this.song = null;
this.img = null;
@@ -31,7 +44,10 @@ export class Player {
this.song = song;
this.canvas.width = this.width;
this.canvas.height = this.height;
- this.run = new Run(song, { fps: FPS, width: this.width, height: this.height, seed: song.seed });
+ // The old pool (if any) never shows another audio clock: stop it
+ // before it silently accumulates one idle world per re-grow.
+ if (this.run?.world?.pool) this.run.world.dispose();
+ this.run = new Run(song, { fps: FPS, width: this.width, height: this.height, seed: song.seed, workers: this.workers });
// The renderer's framebuffer aliases the canvas ImageData so there is
// exactly one copy per frame: render() draws straight into img.data and
// putImageData() uploads it. Same bitmap the renderer paints into node.
@@ -44,14 +60,21 @@ export class Player {
this.run.stepTo(frame);
}
+ /** Resolves once the world's pool can run phases (serial: immediately). */
+ warm() {
+ return this.run.world.warm();
+ }
+
/** Step the world to a time in seconds, rebuilding on a backwards seek. */
seekTo(seconds) {
const target = clamp(Math.floor(seconds * FPS), 0, this.song.analysis.frames - 1);
if (target < this.run.world.frame) {
// The world and the camera are stateful; a backwards seek re-grows
// from frame 0. Forward stepping is cheap (pure sim), so only a
- // rewind pays the replay cost.
- this.run = new Run(this.song, { fps: FPS, width: this.width, height: this.height, seed: this.song.seed });
+ // rewind pays the replay cost. The fresh pool boots cold (web);
+ // the demo loop skips steps until it answers.
+ this.run.world.dispose();
+ this.run = new Run(this.song, { fps: FPS, width: this.width, height: this.height, seed: this.song.seed, workers: this.workers });
this.run.renderer.bitmap.data = this.img.data;
}
this.run.stepTo(target);
diff --git a/metabolism/src/film.js b/metabolism/src/film.js
index bf366e9..753f98e 100644
--- a/metabolism/src/film.js
+++ b/metabolism/src/film.js
@@ -7,7 +7,7 @@ import { Camera } from './camera.js';
import { Renderer } from './render/view.js';
export class Run {
- constructor(song, { fps = 30, width = 640, height = 360, seed = song.seed, field, genesis } = {}) {
+ constructor(song, { fps = 30, width = 640, height = 360, seed = song.seed, field, genesis, workers } = {}) {
this.song = song;
this.fps = fps;
this.width = width;
@@ -17,6 +17,7 @@ export class Run {
seed,
...(field ? { field } : {}),
...(genesis !== undefined ? { genesis } : {}),
+ ...(workers ? { workers } : {}),
});
this.camera = new Camera(song, this.world, { fps, width, height });
this.renderer = new Renderer(width, height);
@@ -24,6 +25,10 @@ export class Run {
/** Advance to a frame, updating the camera on every step. */
stepTo(frame) {
+ // A cold web pool (workers still booting) cannot advance the world:
+ // calling step() would no-op forever inside this loop. The next
+ // stepTo after warm-up catches up to the audio clock.
+ if (!this.world.poolWarm) return this;
while (this.world.frame < frame) {
this.world.step();
this.camera.update(this.world.frame);
diff --git a/metabolism/src/sim/region-core.js b/metabolism/src/sim/region-core.js
new file mode 100644
index 0000000..8b414e5
--- /dev/null
+++ b/metabolism/src/sim/region-core.js
@@ -0,0 +1,87 @@
+// The shared body of the region pool's workers.
+//
+// Both shells — node's worker_threads (region-worker.js) and the browser's
+// module worker (region-worker.web.js) — receive the same init object, build
+// the same bare World over the shared arena, and run the same phase body
+// below. The protocol is written down in region-worker.js.
+
+import { World } from './world.js';
+
+/**
+ * Attach a region to the shared arena. `init` carries the song and geometry
+ * (factory references live in world.js); the typed-array views are carved
+ * from the same buffers the main thread owns, so the worker is the same
+ * world seen through its band.
+ */
+export function makeRegion(init) {
+ const w = new World(init.song, {
+ field: init.field,
+ seed: init.seed,
+ fps: init.fps,
+ substeps: init.substeps,
+ vmEvery: init.vmEvery,
+ sab: init.arena,
+ layout: init.layout,
+ bare: true,
+ });
+ const region = {
+ w,
+ band: init.band,
+ ctrl: new Int32Array(init.ctrl),
+ mail: new Int32Array(init.mail),
+ warmth: new Float32Array(init.mail, 12, 1),
+ seen: 0,
+ };
+ // boot counter (mail[63]): beginPhase's stall diagnostic reports it; the
+ // web shell also posts a one-shot ack so the main can await warmth.
+ Atomics.add(region.mail, 63, 1);
+ return region;
+}
+
+/**
+ * Wait for a phase and run it. False only when the main thread shut the
+ * pool down (negative sequence); otherwise the worker sleeps again.
+ */
+export function regionPhase(region) {
+ const { w, band, ctrl, mail, warmth } = region;
+ Atomics.wait(ctrl, 0, region.seen);
+ const seq = Atomics.load(ctrl, 0);
+ if (seq < 0) return false;
+ region.seen = seq;
+ // The frame and the decide cadence are mailed per phase; the worker's
+ // own state is otherwise derived from the shared arena. Without the
+ // frame the worker's regime would be frozen at the song's first section.
+ w.frame = mail[0];
+ w.warmth = warmth[0];
+ const phase = mail[2];
+ const decide = mail[1] === 1;
+ const aliveCount = mail[4];
+ const alive = [];
+ for (let k = 0; k < aliveCount; k++) alive.push(mail[5 + k]);
+ w.aliveBodies = alive;
+ // follow the main thread's swap orders (the named arrays drift
+ // frame by frame; both sides must address the same storage)
+ w.pop = [];
+ for (let k = 0; k < w.m; k++) w.pop.push(w.popSlots[mail[20 + k]]);
+ w.alleg = [];
+ for (let k = 0; k < w.B; k++) w.alleg.push(w.allegSlots[mail[40 + k]]);
+ w.carry = [];
+ for (let k = 0; k < w.m; k++) w.carry.push(w.carrySlots[mail[60 + k]]);
+ // and the NEXT-slot orders: the main's afterFields swaps pop<->popNext
+ // every substep, so the workers' `next` views must point at the same
+ // storage the main is about to swap in.
+ w.popNext = [];
+ for (let k = 0; k < w.m; k++) w.popNext.push(w.popSlots[mail[80 + k]]);
+ w.allegNext = [];
+ for (let k = 0; k < w.B; k++) w.allegNext.push(w.allegSlots[mail[100 + k]]);
+ w.carryNext = [];
+ for (let k = 0; k < w.m; k++) w.carryNext.push(w.carrySlots[mail[120 + k]]);
+
+ const regime = w.regimeForFrame(w.frame);
+ if (phase === 1) w.phaseSums(band.c0, band.c1, regime);
+ else if (phase === 2) w.phaseStrains(regime, band.c0, band.c1, decide);
+ else if (phase === 3) w.phaseFields(regime, band.c0, band.c1);
+ Atomics.add(ctrl, 1, 1);
+ Atomics.notify(ctrl, 1);
+ return true;
+}
diff --git a/metabolism/src/sim/region-worker.js b/metabolism/src/sim/region-worker.js
index 8f32d6b..1132ab4 100644
--- a/metabolism/src/sim/region-worker.js
+++ b/metabolism/src/sim/region-worker.js
@@ -1,60 +1,24 @@
-// A region worker: owns a horizontal band of coarse rows and runs the
-// band-local phases of the world's substep against the shared arena.
+// A region worker (node variant): owns a horizontal band of coarse rows and
+// runs the band-local phases of the world's substep against the shared arena.
//
// The protocol is a pair of small shared arrays:
// ctrl [0] = phase sequence number (raised by the main thread)
// [1] = done count (raised by each worker; the main waits on it)
// mail [0] frame, [1] decide flag, [3] warmth (f32), [4] aliveCount,
-// [5..] alive body ids, [20..] pop order, [30..] alleg order,
-// [40..] carry order
+// [5..] alive body ids, [20..] pop order, [40..] alleg order,
+// [60..] carry order
//
// The worker constructs a bare World whose arrays are views into the SAME
// arena the main thread uses — it is the same world, seen through a band.
+// The phase body itself lives in region-core.js, shared with the browser
+// worker so the two runtimes cannot drift apart.
-import { World } from './world.js';
+import { makeRegion, regionPhase } from './region-core.js';
import { parentPort, workerData } from 'node:worker_threads';
+const region = makeRegion(workerData);
-const { song, field, seed, fps, substeps, vmEvery, layout, arena, ctrl: ctrlBuf, mail: mailBuf, band } = workerData;
-
-const w = new World(song, { field, seed, fps, substeps, vmEvery, sab: arena, layout, bare: true });
-
-const ctrl = new Int32Array(ctrlBuf);
-const mail = new Int32Array(mailBuf);
-const warmth = new Float32Array(mailBuf, 12, 1);
-
-
-let seen = 0;
for (;;) {
- Atomics.wait(ctrl, 0, seen);
- const seq = Atomics.load(ctrl, 0);
- if (seq < 0) break;
- seen = seq;
- const phase = mail[2];
- const decide = mail[1] === 1;
- w.warmth = warmth[0];
- const aliveCount = mail[4];
- const alive = [];
- for (let k = 0; k < aliveCount; k++) alive.push(mail[5 + k]);
- w.aliveBodies = alive;
- // follow the main thread's swap orders
- const popOrder = [];
- for (let k = 0; k < w.m; k++) popOrder.push(mail[20 + k]);
- const allegOrder = [];
- for (let k = 0; k < w.B; k++) allegOrder.push(mail[40 + k]);
- const carryOrder = [];
- for (let k = 0; k < w.m; k++) carryOrder.push(mail[60 + k]);
- w.pop = popOrder.map((i) => w.popSlots[i]);
- w.alleg = allegOrder.map((i) => w.allegSlots[i]);
- w.carry = carryOrder.map((i) => w.carrySlots[i]);
-
-
- const regime = w.regimeForFrame(w.frame);
- if (phase === 1) w.phaseSums(band.c0, band.c1, regime);
- else if (phase === 2) w.phaseStrains(regime, band.c0, band.c1, decide);
- else if (phase === 3) w.phaseFields(regime, band.c0, band.c1);
-
- Atomics.add(ctrl, 1, 1);
- Atomics.notify(ctrl, 1);
+ if (!regionPhase(region)) break;
}
parentPort.close();
diff --git a/metabolism/src/sim/region-worker.web.js b/metabolism/src/sim/region-worker.web.js
new file mode 100644
index 0000000..cb95718
--- /dev/null
+++ b/metabolism/src/sim/region-worker.web.js
@@ -0,0 +1,26 @@
+// A region worker (browser variant): the same band body as the node worker,
+// booted from a postMessage'd init rather than workerData. Runs as an ES
+// module worker (Vite bundles the graph the same way as the page's own).
+//
+// Booting is asynchronous: the module evaluates (and processes this init
+// message) a beat after the main thread spawns the pool. The shell's first
+// act inside onmessage is to ack (`pool`) — the main's warm gate (see
+// World.warmPool/step) skips stepping until every worker has acked, which
+// matters because a pending worker's script load is frozen by a hot main
+// thread (Chromium routes worker startup through the window's loop).
+
+import { makeRegion, regionPhase } from './region-core.js';
+
+const host = globalThis.self ?? globalThis;
+
+host.onmessage = ({ data: init }) => {
+ const region = makeRegion(init);
+ // Boot ack: the pool's warm gate on the main counts these — one per
+ // worker. The counter also lives in mail[63] (raised in makeRegion).
+ postMessage('pool');
+ // The phase loop blocks on Atomics.wait between phases; the worker's
+ // event loop never returns to this handler. Shutdown is terminate().
+ for (;;) {
+ if (!regionPhase(region)) return;
+ }
+};
diff --git a/metabolism/src/sim/world.js b/metabolism/src/sim/world.js
index a01255e..949c759 100644
--- a/metabolism/src/sim/world.js
+++ b/metabolism/src/sim/world.js
@@ -31,17 +31,20 @@
import { Rng } from '../rng.js';
/**
- * The region pool runs on node's worker_threads; the browser gets its
- * parallelism through the GPU decision layer (and, later, a sim worker).
- * Resolved lazily so the module keeps loading in the browser.
+ * How the region pool's workers are built: worker_threads on node, ES module
+ * workers in the browser (the demo server already sends the COOP/COEP headers
+ * that expose SharedArrayBuffer under cross-origin isolation). Resolved
+ * lazily so the module keeps loading anywhere.
*/
function workerCtor() {
try {
const mod = globalThis.process?.getBuiltinModule?.('node:worker_threads');
- return mod?.Worker ?? null;
- } catch {
- return null;
+ if (mod?.Worker) return { ctor: mod.Worker, kind: 'node' };
+ } catch { /* fall through */ }
+ if (typeof globalThis.Worker === 'function') {
+ return { ctor: globalThis.Worker, kind: 'web' };
}
+ return null;
}
import { wellPos, wellEnv, buildKernel } from './wells.js';
import {
@@ -167,6 +170,11 @@ export function arenaLayout(field, m, B, FC) {
items.push(A('aimY', m * fc, Int8Array, m * fc));
items.push(A('activity', 32 * 32 * 4, Float32Array, 32 * 32));
items.push(A('hungerMass', 4, Float32Array, 1));
+ // Per-row accumulation partials for the frame counters that scattered
+ // writes used to race across bands (hunger, move, births, deaths).
+ // Bands always cover whole rows, and the merge in afterFields runs in a
+ // fixed row order, so the folded sum is identical for any worker count.
+ items.push(A('bandStats', field * 4 * 4, Float32Array, field * 4));
return { items, bytes: items.reduce((a, it) => a + it.bytes, 0) };
}
@@ -186,6 +194,7 @@ export class World {
this.palette = song.palette;
this.wellTimeline = song.wells.wells;
this.rng = new Rng(seed >>> 0).fork('world');
+ this.seed = seed >>> 0; // the workers must seed the SAME noise field
// Pre-simulation (genesis): how long the world evolves before frame 0,
// so the opening is an old ecosystem, not a blank plate. The amount is
@@ -260,6 +269,18 @@ export class World {
this.activity = carve('activity', Float32Array, 32 * 32);
this.hungerCell = carve('hungerMass', Float32Array, 1); // shared accumulator
+ this.bandStats = carve('bandStats', Float32Array, field * 4); // per-row partials
+
+ // The gather advection's motion cache: computed once per source cell
+ // and shared by every destination it feeds. PRIVATE to this world
+ // instance (serial main or one region worker) — never in the arena,
+ // so concurrent regions cannot touch each other's cache.
+ this._advSeen = new Uint8Array(n);
+ this._advVx = new Float64Array(n);
+ this._advVy = new Float64Array(n);
+ this._advmx = new Float64Array(n);
+ this._advmy = new Float64Array(n);
+ this._advLeave = new Float64Array(n);
// Rock (M2.8): the inert resource. A barrier the organisms cannot
// cross, that the song drops and shatters, and that builders quarry,
@@ -418,6 +439,7 @@ export class World {
};
this.frame = -1;
+ this.poolWarm = true; // web pools go cold at boot; see step()/warmPool()
this.substepCount = 0;
this.pulses = [];
this.totalPop = 0;
@@ -443,26 +465,80 @@ export class World {
const WorkerC = workerCtor();
if (!bare && workers > 1 && !WorkerC) workers = 0; // no worker system: stay serial
if (!bare && workers > 1) {
+ this.workerKind = WorkerC.kind;
this.ctrl = new Int32Array(new SharedArrayBuffer(32));
this.mail = new Int32Array(new SharedArrayBuffer(512));
this.pool = [];
const bands = [];
+ let ready = 0; // 'pool' boot acks (web only; node answers barriers directly)
+ if (WorkerC.kind === 'web') {
+ // Web pools go cold at boot; step()/Run.stepTo skip frames
+ // until every worker answers its boot ack (see below).
+ this.poolWarm = false;
+ this.poolReady = new Promise((res) => { this._warmResolve = res; });
+ } else {
+ // Node workers are native threads; they answer the first
+ // phase barrier without any main-thread involvement.
+ this.poolReady = Promise.resolve();
+ }
for (let k = 0; k < workers; k++) {
const c0 = Math.round((this.FC * k) / workers);
const c1 = Math.round((this.FC * (k + 1)) / workers);
bands.push({ c0, c1 });
- const worker = new WorkerC(new URL('./region-worker.js', import.meta.url), {
- workerData: {
- song, field, seed: this.seed, fps, substeps, vmEvery: this.vmEvery,
- layout: { items: spec.items.map(({ name, bytes, offset, len }) => ({ name, bytes, offset, len })), bytes: spec.bytes },
- arena: this.arena, ctrl: this.ctrl.buffer,
- mail: this.mail.buffer, band: bands[k],
- },
- });
- worker.unref(); // the pool never holds the process open
+ const init = {
+ song, field, seed: this.seed, fps, substeps, vmEvery: this.vmEvery,
+ layout: { items: spec.items.map(({ name, bytes, offset, len }) => ({ name, bytes, offset, len })), bytes: spec.bytes },
+ arena: this.arena, ctrl: this.ctrl.buffer,
+ mail: this.mail.buffer, band: bands[k],
+ };
+ let worker;
+ if (WorkerC.kind === 'web') {
+ // Module workers boot asynchronously (vite serves the
+ // script through a transform; the module evaluates and
+ // processes the init message a beat later). Each worker
+ // posts a single `pool` boot ack after makeRegion; the
+ // pool is warm (see step()) only once every worker has
+ // answered. A beginPhase spin must never be entered on a
+ // cold pool: Chromium routes a pending worker's script
+ // load through the busy main thread, so a hot spin while
+ // the pool boots would freeze the boot itself.
+ // (The literal `new Worker(new URL(...))` matters: vite
+ // only recognizes the canonical spelling when it bundles
+ // the worker's graph for the dist build.)
+ worker = new Worker(new URL('./region-worker.web.js', import.meta.url), { type: 'module' });
+ worker.onerror = (e) => {
+ // A failed worker never answers phase barriers; warm
+ // anyway so the world keeps stepping into the existing
+ // `region pool stalled` diagnostic (done N/4) instead
+ // of freezing silently behind the cold gate.
+ console.error(`region worker ${k} failed: ${e.message} @${e.filename}:${e.lineno}`);
+ this.warmPool();
+ };
+ worker.onmessage = () => {
+ ready += 1; // 'pool' boot ack; mail[63] raised in makeRegion
+ if (ready >= this.pool.length) this.warmPool();
+ };
+ worker.postMessage(init);
+ } else {
+ const NodeWorker = WorkerC.ctor;
+ worker = new NodeWorker(new URL('./region-worker.js', import.meta.url), { workerData: init });
+ worker.unref(); // the pool never holds the process open
+ }
this.pool.push(worker);
}
this.bands = bands;
+ // The phase barrier. Browsers forbid a blocking Atomics.wait on a
+ // window's main thread; the probe decides between blocking waits
+ // (node, worker hosts) and plain spinning (Chrome's main thread).
+ this.blockingWait = (() => {
+ try {
+ const probe = new Int32Array(new SharedArrayBuffer(4));
+ Atomics.wait(probe, 0, 0, 1);
+ return true;
+ } catch {
+ return false;
+ }
+ })();
}
}
@@ -870,7 +946,24 @@ export class World {
return living;
}
+ /** The pool's boot handshake (web workers only): warm once every worker answered. */
+ warmPool() {
+ if (this.poolWarm) return;
+ this.poolWarm = true;
+ this._warmResolve?.();
+ }
+
+ /** Resolves when the pool can run phases (immediately for serial/node). */
+ warm() {
+ return this.poolReady ?? Promise.resolve();
+ }
+
step() {
+ // Cold pool: the workers are still booting their script (vite serves
+ // it through a transform; a spin here would freeze that boot —
+ // Chromium routes a pending worker's script load through this
+ // thread). Skip frames until warm; post-warm catch-up is unbounded.
+ if (!this.poolWarm) return null;
this.frame++;
const f = this.frame;
const regime = this.regimeForFrame(f);
@@ -1109,7 +1202,7 @@ export class World {
// forces already sit in fxX/fxY, actions in the buffer.
const syn = this.syn[s];
const kinShare = dom >= 0 ? clamp01((blkBody[dom][b] - own) / Math.max(1e-6, syn.threshold)) : 0;
- synScratch[s * FC + b] = syn.base + (1 - syn.base) * kinShare;
+ synScratch[s * actFC + b] = syn.base + (1 - syn.base) * kinShare;
continue;
}
const nb = (blkPop[s][ug] + blkPop[s][dg] + blkPop[s][lg] + blkPop[s][rg]) / 4;
@@ -1170,7 +1263,10 @@ export class World {
gg[G_GARDEN * 2 + 1] = Math.sign(blkGarden[dg] - blkGarden[ug]);
const flat = ensureFlat(strains[s]);
- runProgram(this.stMem, s * REGS * FC + b, ss, gg, act, flat._flatI, flat._flatF, flat._flatN);
+ // Memory layout: [strain][block][REGS] — the base of this
+ // (strain, block) is REGS registers into the strain's slab
+ // of REGS×fc cells (the arena sizes stMem m*REGS*fc).
+ runProgram(this.stMem, s * REGS * actFC + b, ss, gg, act, flat._flatI, flat._flatF, flat._flatN);
// Remember the aim: the next substep's work-senses look there.
let nx = act[12] !== 0 ? Math.sign(act[12]) : Math.sign(act[0]);
let ny = act[13] !== 0 ? Math.sign(act[13]) : Math.sign(act[1]);
@@ -1196,9 +1292,32 @@ export class World {
// crowd — the share of the block's population that is kin.
const syn = this.syn[s];
const kinShare = dom >= 0 ? clamp01((blkBody[dom][b] - own) / Math.max(1e-6, syn.threshold)) : 0;
- synScratch[s * FC + b] = syn.base + (1 - syn.base) * kinShare;
+ synScratch[s * actFC + b] = syn.base + (1 - syn.base) * kinShare;
}
}
+ // Block-owner velocity pass: the density-weighted mean strain force
+ // per block, a pure function of this block's phase-1 sums (blkPop)
+ // and its own just-written fxX/fxY. Nothing here reads a neighbour
+ // block, so whichever band owns the block computes the same bits —
+ // and phase 3 (allegiance/carry advection) reads velocities that are
+ // FULLY written once the phase-2 barrier passes. (Computing this at
+ // the top of phase 3 instead left band-edge reads racing against the
+ // neighbouring worker's writes.)
+ const velX = this.velX;
+ const velY = this.velY;
+ for (let b = c0 * FC; b < c1 * FC; b++) {
+ let sx = 0;
+ let sy = 0;
+ let wPop = 0;
+ for (let s = 0; s < m; s++) {
+ const p = blkPop[s][b];
+ sx += fxX[s][b] * p;
+ sy += fxY[s][b] * p;
+ wPop += p;
+ }
+ velX[b] = wPop > 1e-6 ? sx / wPop : 0;
+ velY[b] = wPop > 1e-6 ? sy / wPop : 0;
+ }
}
gradOf(arr, b, ug, dg, lg, rg) { return Math.sign(arr[rg] - arr[lg]); }
@@ -1254,18 +1373,31 @@ export class World {
for (let s = 0; s < this.m; s++) Atomics.store(mail, 20 + s, this.popSlots.indexOf(this.pop[s]));
for (let b = 0; b < this.B; b++) Atomics.store(mail, 40 + b, this.allegSlots.indexOf(this.alleg[b]));
for (let s = 0; s < this.m; s++) Atomics.store(mail, 60 + s, this.carrySlots.indexOf(this.carry[s]));
- // raise the phase sequence and wait for every done-flag
+ // ...and the NEXT-slot orders: afterFields swaps pop<->popNext (and
+ // the alleg/carry pairs) on the main every substep, so the workers'
+ // `next` views must follow the same storage the main will swap —
+ // otherwise every substep after the first lands in stale space.
+ for (let s = 0; s < this.m; s++) Atomics.store(mail, 80 + s, this.popSlots.indexOf(this.popNext[s]));
+ for (let b = 0; b < this.B; b++) Atomics.store(mail, 100 + b, this.allegSlots.indexOf(this.allegNext[b]));
+ for (let s = 0; s < this.m; s++) Atomics.store(mail, 120 + s, this.carrySlots.indexOf(this.carryNext[s]));
+ // raise the phase sequence and wait until every done-flag
Atomics.store(ctrl, 1, 0);
this.phaseSeq = (this.phaseSeq ?? 0) + 1;
const seq = this.phaseSeq;
Atomics.store(ctrl, 0, seq);
Atomics.notify(ctrl, 0, this.pool.length);
+ const deadline = performance.now() + 30000;
let polls = 0;
while (Atomics.load(ctrl, 1) < this.pool.length) {
- if (++polls > 2000) {
+ if (this.blockingWait) {
+ Atomics.wait(ctrl, 1, Atomics.load(ctrl, 1), 25);
+ }
+ // The spin path must poll generously (a module worker's first
+ // boot can take hundreds of ms); the clock only polices waits,
+ // it never touches world state.
+ if (++polls > 64 && performance.now() > deadline) {
throw new Error(`region pool stalled: phase ${id}, done ${Atomics.load(ctrl, 1)}/${this.pool.length}, workerBoot ${Atomics.load(mail, 63)}`);
}
- Atomics.wait(ctrl, 1, Atomics.load(ctrl, 1), 25);
}
}
@@ -1389,6 +1521,30 @@ export class World {
// ---- after the fields: swap + the rock economy (main; aim targets cross bands)
afterFields(regime) {
+ // Fold the per-row accumulator partials into the frame counters.
+ // Only the main thread runs this, and the fold visits rows in the
+ // SAME fixed order whatever the band layout (a band always covers
+ // whole rows of fine cells) — so serial, 2 regions and 4 regions
+ // add up the same bits. The slots zero as they fold: the next
+ // substep starts from a clean partial set.
+ const Fm = this.field;
+ const rowStats = this.bandStats;
+ let hunger = this.hungerCell[0];
+ let move = this.moveAccum;
+ let births = this.birthsThisFrame;
+ let deaths = this.deathsThisFrame;
+ for (let r = 0; r < Fm; r++) {
+ const k = r * 4;
+ hunger += rowStats[k];
+ move += rowStats[k + 1];
+ births += rowStats[k + 2];
+ deaths += rowStats[k + 3];
+ rowStats[k] = 0; rowStats[k + 1] = 0; rowStats[k + 2] = 0; rowStats[k + 3] = 0;
+ }
+ this.hungerCell[0] = hunger;
+ this.moveAccum = move;
+ this.birthsThisFrame = births;
+ this.deathsThisFrame = deaths;
const aimTarget = (b, s) => {
const actFC = this.fc;
const actBuf = this.stAct;
@@ -1561,6 +1717,8 @@ export class World {
const noise = this.noise;
const noise2 = this.noise2;
const n = this.n;
+ const F = this.field;
+ const stats = this.bandStats;
const rock = this.rock;
const blkPop = this.blkPop;
const blkTotal = this.blkTotal;
@@ -1569,29 +1727,16 @@ export class World {
const actBuf = this.stAct;
const actFC = fc;
const hostility = this.hostility;
+ // Block velocities committed by phaseStrains before the phase-2
+ // barrier (see the block-owner velocity pass there); only read here.
+ const velX = this.velX;
+ const velY = this.velY;
// ---- advection + reaction (band rows [c0, c1)) -------------------------
const f0 = c0 * (this.field / FC);
const f1 = c1 * (this.field / FC);
const lo = f0 * this.field;
const hi = f1 * this.field;
- // Allegiance rides the population flux: the density-weighted mean of
- // the strains' velocities.
- const velX = this.velX;
- const velY = this.velY;
- for (let b = c0 * FC; b < c1 * FC; b++) {
- let sx = 0;
- let sy = 0;
- let w = 0;
- for (let s = 0; s < m; s++) {
- const p = blkPop[s][b];
- sx += this.fxX[s][b] * p;
- sy += this.fxY[s][b] * p;
- w += p;
- }
- velX[b] = w > 1e-6 ? sx / w : 0;
- velY[b] = w > 1e-6 ? sy / w : 0;
- }
const advFrac = Math.min(0.4, this.avgSpeed * ops.speed * sub * 240);
for (let s = 0; s < m; s++) {
@@ -1621,6 +1766,7 @@ export class World {
for (let i = lo; i < hi; i++) {
const r0 = res[i];
const b = coarseOf[i];
+ const statsRow = (i / F) | 0;
const dom = domB[b];
if (r0 < 1e-6 && density[i] < 1e-6) continue;
@@ -1629,7 +1775,10 @@ export class World {
// Saturated food factor (rational approximation, no transcendentals).
const foodFactor = (r0 * 5) / (1 + r0 * 5);
- this.hungerCell[0] += density[i] * (1 - foodFactor);
+ // The frame counters accumulate per-row partials (bandStats):
+ // rows are never split across bands and afterFields folds them
+ // in a fixed row order, so serial and any pool agree on the bit.
+ stats[statsRow * 4] += density[i] * (1 - foodFactor);
// The gate is nearly shut on bare ground: food is the substrate, not a
// catalyst. A colony that outgrows its ground starves back and must
// wander — the world tracks its wells instead of congealing.
@@ -1665,7 +1814,7 @@ export class World {
for (let s = 0; s < m; s++) {
const p = vals[s];
const spn = actBuf[s * ACTION_COUNT * actFC + 5 * actFC + b];
- const synB = synScratch[s * FC + b];
+ const synB = synScratch[s * actFC + b];
let g = p * this.growth[s] * ops.growth * globalDamp * (1 + spn) * synB * sub * (1 - p / (this.caps[s] * capMul)) * gate;
g *= (1 - casualty * 8);
// Starvation is the body's metabolic floor: bare ground kills,
@@ -1697,8 +1846,8 @@ export class World {
const after = grown[s];
next[s][i] = after;
dNew += after;
- if (after > before) this.birthsThisFrame += after - before;
- else if (after < before) this.deathsThisFrame += before - after;
+ if (after > before) stats[statsRow * 4 + 2] += after - before;
+ else if (after < before) stats[statsRow * 4 + 3] += before - after;
}
density[i] = dNew;
@@ -1719,17 +1868,17 @@ export class World {
}
}
- /**
- * Conservative L1 advection + diffusion of one field, shared by the
- * strains (which track activity) and the allegiance composition. `frac`
- * is the fraction of a cell that departs this substep.
- */
/**
* Conservative L1 advection + diffusion, in GATHER form: every
- * destination cell computes its own inflow from src (self terms plus
- * the diffusion flux of its open neighbours). No cross-cell writes —
- * which is what makes the field phases band-parallel EXACTLY, whatever
- * the region count. A 2-row halo of src covers every read.
+ * destination cell sums its own inflow from src — its own stay-home
+ * terms, the four edge arrivals it now receives, its diffusion share
+ * and the sheds of its neighbours — and writes ONLY to itself. A band
+ * reads a one-cell halo and never writes outside its rows, which is
+ * what makes the field phase band-parallel EXACTLY, whatever the
+ * region count. The per-destination arrival order replicates the old
+ * scatter's per-cell accumulation order bit for bit: arrivals from
+ * i-F and i-1 (processed earlier in the old loop), then i's own
+ * terms in full, then the arrivals from i+1 and i+F.
*/
advect(src, dst, fxArr, fyArr, frac, jitterAmt, diff, sub, track, lo = 0, hi = this.n) {
const F = this.field;
@@ -1741,7 +1890,14 @@ export class World {
const noise = this.noise;
const noise2 = this.noise2;
const mask = this.rockMask;
- dst.fill(0, lo, hi);
+ const seen = this._advSeen;
+ const vxC = this._advVx;
+ const vyC = this._advVy;
+ const mxC = this._advmx;
+ const myC = this._advmy;
+ const leaveC = this._advLeave;
+ const n = this.n;
+ seen.fill(0, 0, n);
// diffusion flux d_j for every cell j this pass may touch (the cell
// itself and its four neighbours); computed read-only from src.
const flux = (j) => {
@@ -1756,51 +1912,106 @@ export class World {
if (open === 0) return 0;
return (nb - open * vj) * diff * sub;
};
- for (let i = lo; i < hi; i++) {
- const v = src[i];
- const b = coarseOf[i];
- const vx = fxArr[b] + (track ? noise[i] * jitterAmt : 0);
- const vy = fyArr[b] + (track ? noise2[i] * jitterAmt : 0);
- const w = Math.abs(vx) + Math.abs(vy);
- const leave = v * frac;
- if (v > 1e-6 && leave > 0 && w > 1e-6) {
- const mx = (leave * Math.abs(vx)) / w;
- const my = (leave * Math.abs(vy)) / w;
- dst[i] += v - leave;
- // Rock is a wall: mass aimed at a stone cell stays put and
- // piles against it.
- const tx = vx > 0.02 ? nRight[i] : vx < -0.02 ? nLeft[i] : i;
- const ty = vy > 0.02 ? nDown[i] : vy < -0.02 ? nUp[i] : i;
- dst[mask[tx] ? i : tx] += mx;
- dst[mask[ty] ? i : ty] += my;
-
- if (track) {
- const ax = ((i % F) * 32 / F) | 0;
- const ay = ((i / F) * 32 / F) | 0;
- this.activity[Math.min(31, ay) * 32 + Math.min(31, ax)] += leave * 6;
- this.moveAccum += leave;
+ const shed = (j) => {
+ const d = flux(j);
+ if (d === 0) return 0;
+ let open = 0;
+ if (!mask[nUp[j]]) open++;
+ if (!mask[nDown[j]]) open++;
+ if (!mask[nLeft[j]]) open++;
+ if (!mask[nRight[j]]) open++;
+ return open > 0 ? d * 0.5 / open : 0;
+ };
+ // One motion solve per source cell, shared by every destination cell
+ // it feeds (halo rows included). Each world instance — the serial
+ // main or one region worker — walks its own calls with its own
+ // cache, so concurrent bands never collide on it.
+ const motion = (k) => {
+ if (seen[k]) return;
+ seen[k] = 1;
+ let vx = 0, vy = 0, mx = 0, my = 0, leave = 0;
+ const v = src[k];
+ if (v > 1e-6) {
+ const b = coarseOf[k];
+ vx = fxArr[b] + (track ? noise[k] * jitterAmt : 0);
+ vy = fyArr[b] + (track ? noise2[k] * jitterAmt : 0);
+ const w = Math.abs(vx) + Math.abs(vy);
+ leave = v * frac;
+ if (leave > 0 && w > 1e-6) {
+ mx = (leave * Math.abs(vx)) / w;
+ my = (leave * Math.abs(vy)) / w;
}
- } else if (v > 1e-6) {
- dst[i] += v;
}
+ vxC[k] = vx;
+ vyC[k] = vy;
+ mxC[k] = mx;
+ myC[k] = my;
+ leaveC[k] = leave;
+ };
+ dst.fill(0, lo, hi);
+ const stats = this.bandStats;
+ for (let i = lo; i < hi; i++) {
+ // ---- arrivals from cells the old loop processed earlier ----
+ // from the row above (old loop visited i-F first):
+ const up = i - F;
+ if (up >= 0 && !mask[i]) {
+ motion(up);
+ if (vyC[up] > 0.02) dst[i] += myC[up]; // its down-flow landed here
+ }
+ // from the left:
+ if (i % F !== 0 && !mask[i]) {
+ motion(i - 1);
+ if (vxC[i - 1] > 0.02) dst[i] += mxC[i - 1]; // its right-flow landed here
+ }
+ // ---- the cell's own terms, exactly as the old body issued them
+ const v = src[i];
+ if (v > 1e-6) {
+ motion(i);
+ const leave = leaveC[i];
+ const vx = vxC[i];
+ const vy = vyC[i];
+ if (leave > 0 && Math.abs(vx) + Math.abs(vy) > 1e-6) {
+ const mx = mxC[i];
+ const my = myC[i];
+ dst[i] += v - leave;
+ // Rock is a wall: mass aimed at a stone cell stays put
+ // (and a slow drift has nowhere to go: it stays too —
+ // the edge-clamped neighbour arrays make tx/ty === i).
+ const tx = vx > 0.02 ? nRight[i] : vx < -0.02 ? nLeft[i] : i;
+ const ty = vy > 0.02 ? nDown[i] : vy < -0.02 ? nUp[i] : i;
+ if (mask[tx] || tx === i) dst[i] += mx;
+ if (mask[ty] || ty === i) dst[i] += my;
- // Diffusion, gathered: my own flux half stays here; each open
- // neighbour sheds its flux equally on its open neighbours.
- if (v > 1e-6) dst[i] += flux(i) * 0.5;
- const shed = (j) => {
- const d = flux(j);
- if (d === 0) return 0;
- let open = 0;
- if (!mask[nUp[j]]) open++;
- if (!mask[nDown[j]]) open++;
- if (!mask[nLeft[j]]) open++;
- if (!mask[nRight[j]]) open++;
- return open > 0 ? d * 0.5 / open : 0;
- };
+ if (track) {
+ const ax = ((i % F) * 32 / F) | 0;
+ const ay = ((i / F) * 32 / F) | 0;
+ this.activity[Math.min(31, ay) * 32 + Math.min(31, ax)] += leave * 6;
+ stats[((i / F) | 0) * 4 + 1] += leave;
+ }
+ } else {
+ // too slow or too spent to move: everything stays
+ dst[i] += v;
+ }
+ if (v > 1e-6) dst[i] += flux(i) * 0.5;
+ }
+ // The neighbour's diffusion sheds its equal share this way —
+ // independent of whether this cell holds anything.
if (!mask[nUp[i]] && src[nUp[i]] > 1e-6) dst[i] -= shed(nUp[i]);
if (!mask[nDown[i]] && src[nDown[i]] > 1e-6) dst[i] -= shed(nDown[i]);
if (!mask[nLeft[i]] && src[nLeft[i]] > 1e-6) dst[i] -= shed(nLeft[i]);
if (!mask[nRight[i]] && src[nRight[i]] > 1e-6) dst[i] -= shed(nRight[i]);
+ // ---- arrivals from cells the old loop processed later ----
+ // from the right:
+ if (i % F !== F - 1 && !mask[i]) {
+ motion(i + 1);
+ if (vxC[i + 1] < -0.02) dst[i] += mxC[i + 1]; // its left-flow landed here
+ }
+ // from the row below:
+ const down = i + F;
+ if (down < n && !mask[i]) {
+ motion(down);
+ if (vyC[down] < -0.02) dst[i] += myC[down]; // its up-flow landed here
+ }
}
}
diff --git a/metabolism/test/world.test.js b/metabolism/test/world.test.js
index ef79dd8..ebf24fe 100644
--- a/metabolism/test/world.test.js
+++ b/metabolism/test/world.test.js
@@ -97,11 +97,11 @@ test('genesis pre-simulates generations so frame 0 is already an ecosystem', ()
});
test('the region pool is exact: parallel == serial, bit for bit', () => {
- const grow = (workers) => {
+ const grow = (workers, { frames = 150, seed = 12345 } = {}) => {
const song = load('drone');
- const run = new Run(song, { fps: 30, seed: 12345, width: 240, height: 135, field: 64, genesis: 0, workers });
- for (let f = 0; f < 150; f++) { run.world.step(); run.camera.update(f); }
- const history = Array.from(run.world.history.alive.slice(0, 150));
+ const run = new Run(song, { fps: 30, seed, width: 240, height: 135, field: 64, genesis: 0, workers });
+ for (let f = 0; f < frames; f++) { run.world.step(); run.camera.update(f); }
+ const history = Array.from(run.world.history.alive.slice(0, frames));
const trail = Array.from(run.world.trail[0].slice(0, 4000));
run.world.dispose?.();
return { history, trail };
@@ -113,6 +113,17 @@ test('the region pool is exact: parallel == serial, bit for bit', () => {
a.trail.every((v, i) => v === b.trail[i]);
assert.ok(eq(serial, two), '2 regions must match the serial world exactly');
assert.ok(eq(serial, four), '4 regions must match the serial world exactly');
+
+ // LONG: across a section boundary (drone's drop lands at frame 1545) and
+ // with a seed distinct from song.seed (the workers must be told the same
+ // seed — they seed their own jitter-noise fields). A 150-frame run inside
+ // one section with the default seed passes even with both bugs present.
+ const longSerial = grow(0, { frames: 1600, seed: 999 });
+ const longTwo = grow(2, { frames: 1600, seed: 999 });
+ const longEq = (a, b) => a.history.every((v, i) => v === b.history[i]) &&
+ a.trail.every((v, i) => v === b.trail[i]);
+ assert.ok(longEq(longSerial, longTwo),
+ '2 regions must match the serial world exactly across the section boundary');
});
test('genesis is deterministic and song-scaled', () => {