metabolism: region pool exactness + browser boot — deterministic velocity pass, pool warm-gate for the web worker boot, breaker

Two root causes fixed end to end:

1. Exactness race: phaseFields' velocity pass wrote its own coarse blocks
   and then read halo blocks no neighbour had committed yet — pool runs
   diverged at random frames (2/4 runs). The velocity pass now runs as a
   block-owner section at the END of phaseStrains; phaseFields only reads
   committed velocities. 8/8 pool runs bit-exact through f1239; tests 9/9
   (including 1600-frame x 2-worker runs across the section boundary).

2. Browser boot stall: beginPhase's hot spin (Atomics.wait throws on a
   window) froze pending web-worker script loads — Chromium routes worker
   startup through the busy main thread, so the show stalled 30s and
   threw 'region pool stalled'. The web shell now acks ('pool') after
   makeRegion (mail[63] is a real boot counter), the world runs cold until
   every worker acks (step/stepTo skip frames; post-warm catch-up is
   unbounded), and loadSongData awaits player.warm() before the show.
   Node pools are untouched (native threads answer barriers directly).

Also: Player.build/rewind dispose the old pool (no more leaked idle
worlds); region-core.js is the shared worker body; region-worker.web.js
the browser shell (vite bundles it via the literal new Worker(new URL)
spelling — dist emits a self-contained worker asset instead of a broken
base64 data URL).

Circuit breaker in the demo: >=2 consecutive sub-25fps windows trip;
>=30 frames behind the audio clock for >=750ms trips (dormant while
stepTo catches up unconditionally — the fps path is live). 12/12 mirror
scenarios pass.

Bench: node serial 74.2ms/f, 2w 38.9 (1.91x), 4w 24.6 (3.02x). Browser:
steady 30fps including a 64s soak to 20k organisms at 640x360.
This commit is contained in:
Dejvino
2026-10-30 07:26:28 +01:00
parent 53a973c34e
commit 23d3696adc
8 changed files with 505 additions and 159 deletions
+35 -16
View File
@@ -52,6 +52,7 @@ const state = {
playing: false, playing: false,
loading: false, loading: false,
fps: null, // achieved film rate, measured by the circuit breaker 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) ------------------ // ---- audio pipeline (browser side of the shared pipeline) ------------------
@@ -99,6 +100,7 @@ async function loadSongData(id, seed, res) {
const song = buildSong(analysis, seed); const song = buildSong(analysis, seed);
const player = new Player(els.view, w, h); const player = new Player(els.view, w, h);
player.build(song); 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 }); const audio = new AudioTransport(pcmSlot.pcm, pcmSlot.sampleRate, { onEnd: handleEnded });
return { entry: state.byId.get(id), song, analysis, player, audio }; 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_FPS = 25; // the minimum sustained film rate we accept
const BREAKER_WIN = 2.0; // seconds per measurement window const BREAKER_WIN = 2.0; // seconds per measurement window
const BREAKER_STRIKES = 2; // consecutive slow windows before we stop 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() { function breakerReset() {
breaker.frames = 0; breaker.frames = 0;
breaker.dt = 0; breaker.dt = 0;
breaker.last = 0; breaker.last = 0;
breaker.strikes = 0; breaker.strikes = 0;
breaker.lagSince = 0;
state.fps = null; 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(
`<b>the world outgrew this machine</b>` +
`<small>${detail}. Try a lower resolution (res) or another seed.</small>`
);
}
function breakerTick(steps, behind) {
const now = performance.now(); 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; } if (!breaker.last) { breaker.last = now; return; }
breaker.dt += (now - breaker.last) / 1000; breaker.dt += (now - breaker.last) / 1000;
breaker.last = now; breaker.last = now;
@@ -238,18 +267,7 @@ function breakerTick(steps) {
breaker.strikes = 0; breaker.strikes = 0;
return; return;
} }
if (++breaker.strikes >= BREAKER_STRIKES) tripBreaker(fps); 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(
`<b>the world outgrew this machine</b>` +
`<small>sustained ${Math.round(fps)} fps < ${BREAKER_FPS} — playback stopped. ` +
`Try a lower resolution (res) or another seed.</small>`
);
} }
let hudTick = 0; let hudTick = 0;
@@ -261,7 +279,7 @@ function loop() {
const before = c.player.run.world.frame; const before = c.player.run.world.frame;
c.player.stepTo(target); c.player.stepTo(target);
c.player.renderCurrent(); c.player.renderCurrent();
breakerTick(target - before); breakerTick(target - before, Math.max(0, target - c.player.run.world.frame));
updateSeekUI(pos); updateSeekUI(pos);
if (++hudTick % 4 === 0) updateHUD(); if (++hudTick % 4 === 0) updateHUD();
} else { } else {
@@ -339,7 +357,8 @@ function updateHUD() {
const s = w.stats(); const s = w.stats();
els.hud.textContent = els.hud.textContent =
`${s.alive} agents · field ${s.fieldMass.toFixed(2)} · trail ${s.trailMass.toFixed(3)}` + `${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 cam = c.player.run.camera;
const size = cam.zoom < 1.8 ? 'wide' : cam.zoom < 2.7 ? 'medium' : 'close'; 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)}`; els.shot.textContent = `shot ${cam.shot} · ${size} · zoom ${cam.zoom.toFixed(2)}`;
+26 -3
View File
@@ -10,6 +10,18 @@ import { Run } from '../film.js';
export const FPS = 30; 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 { export class Player {
/** /**
* @param {HTMLCanvasElement} canvas * @param {HTMLCanvasElement} canvas
@@ -21,6 +33,7 @@ export class Player {
this.ctx = canvas.getContext('2d'); this.ctx = canvas.getContext('2d');
this.width = width; this.width = width;
this.height = height; this.height = height;
this.workers = demoWorkers(); // fixed per page load, reused on rebuilds
this.run = null; this.run = null;
this.song = null; this.song = null;
this.img = null; this.img = null;
@@ -31,7 +44,10 @@ export class Player {
this.song = song; this.song = song;
this.canvas.width = this.width; this.canvas.width = this.width;
this.canvas.height = this.height; 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 // The renderer's framebuffer aliases the canvas ImageData so there is
// exactly one copy per frame: render() draws straight into img.data and // exactly one copy per frame: render() draws straight into img.data and
// putImageData() uploads it. Same bitmap the renderer paints into node. // putImageData() uploads it. Same bitmap the renderer paints into node.
@@ -44,14 +60,21 @@ export class Player {
this.run.stepTo(frame); 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. */ /** Step the world to a time in seconds, rebuilding on a backwards seek. */
seekTo(seconds) { seekTo(seconds) {
const target = clamp(Math.floor(seconds * FPS), 0, this.song.analysis.frames - 1); const target = clamp(Math.floor(seconds * FPS), 0, this.song.analysis.frames - 1);
if (target < this.run.world.frame) { if (target < this.run.world.frame) {
// The world and the camera are stateful; a backwards seek re-grows // The world and the camera are stateful; a backwards seek re-grows
// from frame 0. Forward stepping is cheap (pure sim), so only a // from frame 0. Forward stepping is cheap (pure sim), so only a
// rewind pays the replay cost. // rewind pays the replay cost. The fresh pool boots cold (web);
this.run = new Run(this.song, { fps: FPS, width: this.width, height: this.height, seed: this.song.seed }); // 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.renderer.bitmap.data = this.img.data;
} }
this.run.stepTo(target); this.run.stepTo(target);
+6 -1
View File
@@ -7,7 +7,7 @@ import { Camera } from './camera.js';
import { Renderer } from './render/view.js'; import { Renderer } from './render/view.js';
export class Run { 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.song = song;
this.fps = fps; this.fps = fps;
this.width = width; this.width = width;
@@ -17,6 +17,7 @@ export class Run {
seed, seed,
...(field ? { field } : {}), ...(field ? { field } : {}),
...(genesis !== undefined ? { genesis } : {}), ...(genesis !== undefined ? { genesis } : {}),
...(workers ? { workers } : {}),
}); });
this.camera = new Camera(song, this.world, { fps, width, height }); this.camera = new Camera(song, this.world, { fps, width, height });
this.renderer = new Renderer(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. */ /** Advance to a frame, updating the camera on every step. */
stepTo(frame) { 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) { while (this.world.frame < frame) {
this.world.step(); this.world.step();
this.camera.update(this.world.frame); this.camera.update(this.world.frame);
+87
View File
@@ -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;
}
+9 -45
View File
@@ -1,60 +1,24 @@
// A region worker: owns a horizontal band of coarse rows and runs the // A region worker (node variant): owns a horizontal band of coarse rows and
// band-local phases of the world's substep against the shared arena. // runs the band-local phases of the world's substep against the shared arena.
// //
// The protocol is a pair of small shared arrays: // The protocol is a pair of small shared arrays:
// ctrl [0] = phase sequence number (raised by the main thread) // ctrl [0] = phase sequence number (raised by the main thread)
// [1] = done count (raised by each worker; the main waits on it) // [1] = done count (raised by each worker; the main waits on it)
// mail [0] frame, [1] decide flag, [3] warmth (f32), [4] aliveCount, // mail [0] frame, [1] decide flag, [3] warmth (f32), [4] aliveCount,
// [5..] alive body ids, [20..] pop order, [30..] alleg order, // [5..] alive body ids, [20..] pop order, [40..] alleg order,
// [40..] carry order // [60..] carry order
// //
// The worker constructs a bare World whose arrays are views into the SAME // 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. // 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'; 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 (;;) { for (;;) {
Atomics.wait(ctrl, 0, seen); if (!regionPhase(region)) break;
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);
} }
parentPort.close(); parentPort.close();
+26
View File
@@ -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;
}
};
+301 -90
View File
@@ -31,17 +31,20 @@
import { Rng } from '../rng.js'; import { Rng } from '../rng.js';
/** /**
* The region pool runs on node's worker_threads; the browser gets its * How the region pool's workers are built: worker_threads on node, ES module
* parallelism through the GPU decision layer (and, later, a sim worker). * workers in the browser (the demo server already sends the COOP/COEP headers
* Resolved lazily so the module keeps loading in the browser. * that expose SharedArrayBuffer under cross-origin isolation). Resolved
* lazily so the module keeps loading anywhere.
*/ */
function workerCtor() { function workerCtor() {
try { try {
const mod = globalThis.process?.getBuiltinModule?.('node:worker_threads'); const mod = globalThis.process?.getBuiltinModule?.('node:worker_threads');
return mod?.Worker ?? null; if (mod?.Worker) return { ctor: mod.Worker, kind: 'node' };
} catch { } catch { /* fall through */ }
return null; if (typeof globalThis.Worker === 'function') {
return { ctor: globalThis.Worker, kind: 'web' };
} }
return null;
} }
import { wellPos, wellEnv, buildKernel } from './wells.js'; import { wellPos, wellEnv, buildKernel } from './wells.js';
import { import {
@@ -167,6 +170,11 @@ export function arenaLayout(field, m, B, FC) {
items.push(A('aimY', m * fc, Int8Array, m * fc)); items.push(A('aimY', m * fc, Int8Array, m * fc));
items.push(A('activity', 32 * 32 * 4, Float32Array, 32 * 32)); items.push(A('activity', 32 * 32 * 4, Float32Array, 32 * 32));
items.push(A('hungerMass', 4, Float32Array, 1)); 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) }; return { items, bytes: items.reduce((a, it) => a + it.bytes, 0) };
} }
@@ -186,6 +194,7 @@ export class World {
this.palette = song.palette; this.palette = song.palette;
this.wellTimeline = song.wells.wells; this.wellTimeline = song.wells.wells;
this.rng = new Rng(seed >>> 0).fork('world'); 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, // 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 // 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.activity = carve('activity', Float32Array, 32 * 32);
this.hungerCell = carve('hungerMass', Float32Array, 1); // shared accumulator 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 // Rock (M2.8): the inert resource. A barrier the organisms cannot
// cross, that the song drops and shatters, and that builders quarry, // cross, that the song drops and shatters, and that builders quarry,
@@ -418,6 +439,7 @@ export class World {
}; };
this.frame = -1; this.frame = -1;
this.poolWarm = true; // web pools go cold at boot; see step()/warmPool()
this.substepCount = 0; this.substepCount = 0;
this.pulses = []; this.pulses = [];
this.totalPop = 0; this.totalPop = 0;
@@ -443,26 +465,80 @@ export class World {
const WorkerC = workerCtor(); const WorkerC = workerCtor();
if (!bare && workers > 1 && !WorkerC) workers = 0; // no worker system: stay serial if (!bare && workers > 1 && !WorkerC) workers = 0; // no worker system: stay serial
if (!bare && workers > 1) { if (!bare && workers > 1) {
this.workerKind = WorkerC.kind;
this.ctrl = new Int32Array(new SharedArrayBuffer(32)); this.ctrl = new Int32Array(new SharedArrayBuffer(32));
this.mail = new Int32Array(new SharedArrayBuffer(512)); this.mail = new Int32Array(new SharedArrayBuffer(512));
this.pool = []; this.pool = [];
const bands = []; 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++) { for (let k = 0; k < workers; k++) {
const c0 = Math.round((this.FC * k) / workers); const c0 = Math.round((this.FC * k) / workers);
const c1 = Math.round((this.FC * (k + 1)) / workers); const c1 = Math.round((this.FC * (k + 1)) / workers);
bands.push({ c0, c1 }); bands.push({ c0, c1 });
const worker = new WorkerC(new URL('./region-worker.js', import.meta.url), { const init = {
workerData: { song, field, seed: this.seed, fps, substeps, vmEvery: this.vmEvery,
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 },
layout: { items: spec.items.map(({ name, bytes, offset, len }) => ({ name, bytes, offset, len })), bytes: spec.bytes }, arena: this.arena, ctrl: this.ctrl.buffer,
arena: this.arena, ctrl: this.ctrl.buffer, mail: this.mail.buffer, band: bands[k],
mail: this.mail.buffer, band: bands[k], };
}, let worker;
}); if (WorkerC.kind === 'web') {
worker.unref(); // the pool never holds the process open // 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.pool.push(worker);
} }
this.bands = bands; 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; 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() { 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++; this.frame++;
const f = this.frame; const f = this.frame;
const regime = this.regimeForFrame(f); const regime = this.regimeForFrame(f);
@@ -1109,7 +1202,7 @@ export class World {
// forces already sit in fxX/fxY, actions in the buffer. // forces already sit in fxX/fxY, actions in the buffer.
const syn = this.syn[s]; const syn = this.syn[s];
const kinShare = dom >= 0 ? clamp01((blkBody[dom][b] - own) / Math.max(1e-6, syn.threshold)) : 0; 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; continue;
} }
const nb = (blkPop[s][ug] + blkPop[s][dg] + blkPop[s][lg] + blkPop[s][rg]) / 4; 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]); gg[G_GARDEN * 2 + 1] = Math.sign(blkGarden[dg] - blkGarden[ug]);
const flat = ensureFlat(strains[s]); 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. // 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 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]); 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. // crowd — the share of the block's population that is kin.
const syn = this.syn[s]; const syn = this.syn[s];
const kinShare = dom >= 0 ? clamp01((blkBody[dom][b] - own) / Math.max(1e-6, syn.threshold)) : 0; 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]); } 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 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 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])); 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); Atomics.store(ctrl, 1, 0);
this.phaseSeq = (this.phaseSeq ?? 0) + 1; this.phaseSeq = (this.phaseSeq ?? 0) + 1;
const seq = this.phaseSeq; const seq = this.phaseSeq;
Atomics.store(ctrl, 0, seq); Atomics.store(ctrl, 0, seq);
Atomics.notify(ctrl, 0, this.pool.length); Atomics.notify(ctrl, 0, this.pool.length);
const deadline = performance.now() + 30000;
let polls = 0; let polls = 0;
while (Atomics.load(ctrl, 1) < this.pool.length) { 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)}`); 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) // ---- after the fields: swap + the rock economy (main; aim targets cross bands)
afterFields(regime) { 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 aimTarget = (b, s) => {
const actFC = this.fc; const actFC = this.fc;
const actBuf = this.stAct; const actBuf = this.stAct;
@@ -1561,6 +1717,8 @@ export class World {
const noise = this.noise; const noise = this.noise;
const noise2 = this.noise2; const noise2 = this.noise2;
const n = this.n; const n = this.n;
const F = this.field;
const stats = this.bandStats;
const rock = this.rock; const rock = this.rock;
const blkPop = this.blkPop; const blkPop = this.blkPop;
const blkTotal = this.blkTotal; const blkTotal = this.blkTotal;
@@ -1569,29 +1727,16 @@ export class World {
const actBuf = this.stAct; const actBuf = this.stAct;
const actFC = fc; const actFC = fc;
const hostility = this.hostility; 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)) ------------------------- // ---- advection + reaction (band rows [c0, c1)) -------------------------
const f0 = c0 * (this.field / FC); const f0 = c0 * (this.field / FC);
const f1 = c1 * (this.field / FC); const f1 = c1 * (this.field / FC);
const lo = f0 * this.field; const lo = f0 * this.field;
const hi = f1 * 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); const advFrac = Math.min(0.4, this.avgSpeed * ops.speed * sub * 240);
for (let s = 0; s < m; s++) { for (let s = 0; s < m; s++) {
@@ -1621,6 +1766,7 @@ export class World {
for (let i = lo; i < hi; i++) { for (let i = lo; i < hi; i++) {
const r0 = res[i]; const r0 = res[i];
const b = coarseOf[i]; const b = coarseOf[i];
const statsRow = (i / F) | 0;
const dom = domB[b]; const dom = domB[b];
if (r0 < 1e-6 && density[i] < 1e-6) continue; if (r0 < 1e-6 && density[i] < 1e-6) continue;
@@ -1629,7 +1775,10 @@ export class World {
// Saturated food factor (rational approximation, no transcendentals). // Saturated food factor (rational approximation, no transcendentals).
const foodFactor = (r0 * 5) / (1 + r0 * 5); 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 // 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 // catalyst. A colony that outgrows its ground starves back and must
// wander — the world tracks its wells instead of congealing. // wander — the world tracks its wells instead of congealing.
@@ -1665,7 +1814,7 @@ export class World {
for (let s = 0; s < m; s++) { for (let s = 0; s < m; s++) {
const p = vals[s]; const p = vals[s];
const spn = actBuf[s * ACTION_COUNT * actFC + 5 * actFC + b]; 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; let g = p * this.growth[s] * ops.growth * globalDamp * (1 + spn) * synB * sub * (1 - p / (this.caps[s] * capMul)) * gate;
g *= (1 - casualty * 8); g *= (1 - casualty * 8);
// Starvation is the body's metabolic floor: bare ground kills, // Starvation is the body's metabolic floor: bare ground kills,
@@ -1697,8 +1846,8 @@ export class World {
const after = grown[s]; const after = grown[s];
next[s][i] = after; next[s][i] = after;
dNew += after; dNew += after;
if (after > before) this.birthsThisFrame += after - before; if (after > before) stats[statsRow * 4 + 2] += after - before;
else if (after < before) this.deathsThisFrame += before - after; else if (after < before) stats[statsRow * 4 + 3] += before - after;
} }
density[i] = dNew; 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 * Conservative L1 advection + diffusion, in GATHER form: every
* destination cell computes its own inflow from src (self terms plus * destination cell sums its own inflow from src — its own stay-home
* the diffusion flux of its open neighbours). No cross-cell writes — * terms, the four edge arrivals it now receives, its diffusion share
* which is what makes the field phases band-parallel EXACTLY, whatever * and the sheds of its neighbours — and writes ONLY to itself. A band
* the region count. A 2-row halo of src covers every read. * 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) { advect(src, dst, fxArr, fyArr, frac, jitterAmt, diff, sub, track, lo = 0, hi = this.n) {
const F = this.field; const F = this.field;
@@ -1741,7 +1890,14 @@ export class World {
const noise = this.noise; const noise = this.noise;
const noise2 = this.noise2; const noise2 = this.noise2;
const mask = this.rockMask; 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 // diffusion flux d_j for every cell j this pass may touch (the cell
// itself and its four neighbours); computed read-only from src. // itself and its four neighbours); computed read-only from src.
const flux = (j) => { const flux = (j) => {
@@ -1756,51 +1912,106 @@ export class World {
if (open === 0) return 0; if (open === 0) return 0;
return (nb - open * vj) * diff * sub; return (nb - open * vj) * diff * sub;
}; };
for (let i = lo; i < hi; i++) { const shed = (j) => {
const v = src[i]; const d = flux(j);
const b = coarseOf[i]; if (d === 0) return 0;
const vx = fxArr[b] + (track ? noise[i] * jitterAmt : 0); let open = 0;
const vy = fyArr[b] + (track ? noise2[i] * jitterAmt : 0); if (!mask[nUp[j]]) open++;
const w = Math.abs(vx) + Math.abs(vy); if (!mask[nDown[j]]) open++;
const leave = v * frac; if (!mask[nLeft[j]]) open++;
if (v > 1e-6 && leave > 0 && w > 1e-6) { if (!mask[nRight[j]]) open++;
const mx = (leave * Math.abs(vx)) / w; return open > 0 ? d * 0.5 / open : 0;
const my = (leave * Math.abs(vy)) / w; };
dst[i] += v - leave; // One motion solve per source cell, shared by every destination cell
// Rock is a wall: mass aimed at a stone cell stays put and // it feeds (halo rows included). Each world instance — the serial
// piles against it. // main or one region worker — walks its own calls with its own
const tx = vx > 0.02 ? nRight[i] : vx < -0.02 ? nLeft[i] : i; // cache, so concurrent bands never collide on it.
const ty = vy > 0.02 ? nDown[i] : vy < -0.02 ? nUp[i] : i; const motion = (k) => {
dst[mask[tx] ? i : tx] += mx; if (seen[k]) return;
dst[mask[ty] ? i : ty] += my; seen[k] = 1;
let vx = 0, vy = 0, mx = 0, my = 0, leave = 0;
if (track) { const v = src[k];
const ax = ((i % F) * 32 / F) | 0; if (v > 1e-6) {
const ay = ((i / F) * 32 / F) | 0; const b = coarseOf[k];
this.activity[Math.min(31, ay) * 32 + Math.min(31, ax)] += leave * 6; vx = fxArr[b] + (track ? noise[k] * jitterAmt : 0);
this.moveAccum += leave; 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 if (track) {
// neighbour sheds its flux equally on its open neighbours. const ax = ((i % F) * 32 / F) | 0;
if (v > 1e-6) dst[i] += flux(i) * 0.5; const ay = ((i / F) * 32 / F) | 0;
const shed = (j) => { this.activity[Math.min(31, ay) * 32 + Math.min(31, ax)] += leave * 6;
const d = flux(j); stats[((i / F) | 0) * 4 + 1] += leave;
if (d === 0) return 0; }
let open = 0; } else {
if (!mask[nUp[j]]) open++; // too slow or too spent to move: everything stays
if (!mask[nDown[j]]) open++; dst[i] += v;
if (!mask[nLeft[j]]) open++; }
if (!mask[nRight[j]]) open++; if (v > 1e-6) dst[i] += flux(i) * 0.5;
return open > 0 ? d * 0.5 / open : 0; }
}; // 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[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[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[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]); 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
}
} }
} }
+15 -4
View File
@@ -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', () => { 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 song = load('drone');
const run = new Run(song, { fps: 30, seed: 12345, width: 240, height: 135, field: 64, genesis: 0, workers }); const run = new Run(song, { fps: 30, seed, width: 240, height: 135, field: 64, genesis: 0, workers });
for (let f = 0; f < 150; f++) { run.world.step(); run.camera.update(f); } for (let f = 0; f < frames; f++) { run.world.step(); run.camera.update(f); }
const history = Array.from(run.world.history.alive.slice(0, 150)); const history = Array.from(run.world.history.alive.slice(0, frames));
const trail = Array.from(run.world.trail[0].slice(0, 4000)); const trail = Array.from(run.world.trail[0].slice(0, 4000));
run.world.dispose?.(); run.world.dispose?.();
return { history, trail }; 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]); 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, two), '2 regions must match the serial world exactly');
assert.ok(eq(serial, four), '4 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', () => { test('genesis is deterministic and song-scaled', () => {