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:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user