Multi-core simulation: grain movement and heat across a worker pool (t88 phase 3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 03:23:43 -04:00
co-authored by Claude Opus 5.5
parent 20963c776e
commit 7468d00efb
35 changed files with 578 additions and 113 deletions
Binary file not shown.
Binary file not shown.
+5 -3
View File
@@ -1,4 +1,4 @@
// Reference heat loop before t21 phase 3; preserves conduction and Float32 store order.
// Unoptimized heat oracle, re-baselined for t88 chunk-pass heat outputs.
function referenceHeatChunk(c, sc) {
const cx = c % CW, cy = (c / CW) | 0;
const x0 = cx << 5, y0 = cy << 5, x1 = Math.min(W, x0 + 32), y1 = Math.min(H, y0 + 32);
@@ -37,6 +37,8 @@ function referenceHeatChunk(c, sc) {
if (flags[i] & F_BURN) burning = true;
}
}
if (burning || maxDev > 1.5) { hot[c] = 6; hotLvl[c] = (burning || maxDev > 60) ? 2 : 1; }
else hot[c]--;
const h = burning || maxDev > 1.5 ? 6 : hot[c] - 1;
const level = burning || maxDev > 1.5 ? ((burning || maxDev > 60) ? 2 : 1) : hotLvl[c];
if (chunkWork) { chunkWork.ownHot[c] = h; chunkWork.ownLevel[c] = level; }
else { hot[c] = h; hotLvl[c] = level; }
}
+31
View File
@@ -81,3 +81,34 @@ function dump(run, x0, y0, x1, y1, withTemp) {
}
}
module.exports.dump = dump;
// Node uses exactly the browser worker entry point and scheduler.
async function enableThreads(run, count) {
const { Worker } = require('worker_threads');
const entry = `const {parentPort}=require('worker_threads');
require(${JSON.stringify(__filename)}).load();
parentPort.on('message', data=>threadMessage(data));`;
const pool = { world: run('mat'), workers: [], slots: [], force: true };
try {
const ready = [];
for (let k = 0; k < count; k++) {
const worker = new Worker(entry, {eval:true}), slot = run('threadSlot()');
pool.workers.push(worker); pool.slots.push(slot);
ready.push(new Promise((resolve,reject) => {
const deadline = Date.now()+10000;
worker.once('error',reject);
const poll = () => {
if (Atomics.load(slot.control,0)) resolve();
else if (Date.now()>deadline) reject(Error('Worker startup timed out'));
else setTimeout(poll,1);
};
poll();
}));
worker.postMessage({world:run('threadWorld()'),slot});
}
await Promise.all(ready);
globalThis.__testPool = pool; run('simPool = globalThis.__testPool;'); delete globalThis.__testPool;
return async () => { run('simPool=null;'); await Promise.all(pool.workers.map(w=>w.terminate())); };
} catch (e) { await Promise.all(pool.workers.map(w=>w.terminate())); throw e; }
}
module.exports.enableThreads = enableThreads;
+11 -2
View File
@@ -8,16 +8,18 @@ ensure_playwright()
from browser import capabilities, consume_gpu_option, launch
consume_gpu_option()
for case in ['native', 'absent', 'null', 'reject', 'firefox', 'limits']:
for case in ['native', 'absent', 'null', 'reject', 'firefox', 'limits', 'threads']:
with launch() as (_, page, url):
errors = []
page.on('pageerror', lambda e: errors.append(str(e)))
if case != 'native':
if case not in ('native', 'threads'):
gpu = 'undefined' if case == 'absent' else "{requestAdapter: async () => null}"
if case == 'reject': gpu = "{requestAdapter: async () => {throw new Error('adapter denied')}}"
if case == 'limits': gpu="{requestAdapter:async()=>({limits:{maxStorageTexturesPerShaderStage:3}})}"
page.add_init_script(f"Object.defineProperty(navigator, 'gpu', {{value: {gpu}}});")
page.add_init_script("Object.defineProperty(self, 'crossOriginIsolated', {value: false});")
if case == 'threads':
page.add_init_script("Object.defineProperty(self, 'crossOriginIsolated', {value: false});")
if case == 'firefox':
page.add_init_script("Object.defineProperty(navigator, 'userAgent', {value: 'Firefox/157.0'});")
page.goto(url)
@@ -26,6 +28,13 @@ for case in ['native', 'absent', 'null', 'reject', 'firefox', 'limits']:
if case == 'native':
assert caps['isolated']
assert page.locator('#cap-notice').is_hidden() == caps['webgpu']
elif case == 'threads':
assert caps['webgpu'] and not caps['isolated']
assert not page.evaluate('canStartGame()')
page.evaluate('startNewWorld(424242, 512, 384)')
assert not page.evaluate('GAME.running || GAME.loading')
assert not page.evaluate('startFromSave({})')
assert 'python3 tools/serve.py' in page.inner_text('#cap-notice')
else:
assert not caps['webgpu'] and caps['adapterInfo'] is None
notice = page.inner_text('#cap-notice')
+2 -1
View File
@@ -123,7 +123,8 @@ const liftLanes = run('(() => { const s = new Set(); for (let y = 76; y < 80; y+
check('a lift turns its corner without squeezing down to one lane', liftLanes === 4 && lr > 30, `${lr.toFixed(0)}/s, ${liftLanes} lanes at the top`);
fresh();
run('for (let bx = 20; bx < 32; bx++) placeBlock(BP_BELT, 1, bx, 25, true);');
rect(run, 80, 20, 95, 99, run('SAND'));
// A wide heap keeps the belt fed across the new chunk-local random streams.
rect(run, 80, 20, 111, 99, run('SAND'));
const br = rate(128, 319, 104, 119, 90, 300);
ticks(run, 0);
check('a belt carries a pile off at several dozen grains a second', br > 50, `${br.toFixed(0)}/s`);
+6 -6
View File
@@ -5,13 +5,13 @@ const { run } = load();
const { check, done } = suite();
for (const [seed, expected] of [
[777, '040644fb50c891911383ef0206ea988677d7ec447a3e6637cefa99364e765861'],
[77, '2e6af816af54c8de4fe9c7188607f7af503ef844b2087245779bcf39e8330bdb'],
[4242, 'f09270f4ac5da9347bc277c9959e31ee797313b4a994598b4647dcf852532782'],
[777, 'b6f7b493e93a59e38ee57d33f24690daa87e7be0f4b356b32225a3a6f1a9d001'],
[77, '5e08c24603686c8617dd1156b10612b91a05fd9ee8b3648dfc4ee32fc008383a'],
[4242, 'f7ca75d19eba9eae4894b21b95fe1b683bd54e1f65a415dcc296f3c23b5d7e05'],
]) {
run(`generateWorld(${seed}, 512, 384)`);
check(`seed ${seed} restores corner sealing`,run('CORNER_SEAL')===true);
check(`seed ${seed} keeps its pre-seal material hash`, createHash('sha256').update(run('mat')).digest('hex') === expected);
check(`seed ${seed} matches its thread-independent material hash`, createHash('sha256').update(run('mat')).digest('hex') === expected);
}
for (const material of ['SAND', 'WATER', 'STEAM']) for (const mirror of [false, true]) {
@@ -64,8 +64,8 @@ ticks(run, 600);
const pile = run(`(()=>{let x0=W,x1=0,y0=H,y1=0;
for(let y=0;y<H;y++)for(let x=0;x<W;x++)if(mat[y*W+x]===SAND){x0=Math.min(x0,x);x1=Math.max(x1,x);y0=Math.min(y0,y);y1=Math.max(y1,y);}
return [x1-x0+1,y1-y0+1];})()`);
// Before the fix: 57 wide, 17 high. Small bounds allow random-state differences from prior scenes.
check('open-ground sand still forms the same size pile', pile[0]>=55 && pile[0]<=59 && pile[1]>=16 && pile[1]<=18 && count(run,run('SAND'))===400, pile);
// t88 chunk streams re-baseline this seeded pile to 46 wide and 19 high.
check('the seeded sand pile keeps its re-baselined shape and all grains', pile[0]>=44 && pile[0]<=48 && pile[1]>=18 && pile[1]<=20 && count(run,run('SAND'))===400, pile);
const sealedPile = createHash('sha256').update(run('mat')).digest('hex'), sealedRS=run('RS');
flatWorld(run,240,160,120);
run('RS=12345; CORNER_SEAL=false;');
+3 -3
View File
@@ -23,7 +23,7 @@ with launch(viewport={'width':960,'height':600}) as (_,pg,url):
assert startup['warm']>=0
print('PASS both looks submit behind loading screen; first visible frame ms',startup)
pg.add_script_tag(path=str(root/'tools/perf-scenes.js'))
pg.evaluate("perfScene('factory',1024,640); SET.look='pixel'; SET.zoom=2;resizeRender();snapCamera();drawWorld();")
pg.evaluate("async()=>{perfScene('factory',1024,640);await startThreads();simPool.force=true;SET.look='pixel';SET.zoom=2;resizeRender();snapCamera();drawWorld();}")
result=pg.evaluate('''async () => {
const results=[];
for(let n=0;n<240;n++) {
@@ -35,9 +35,9 @@ with launch(viewport={'width':960,'height':600}) as (_,pg,url):
}
}
drawWorld();await gpuCheckUploads();
drawWorld();return {checked:results,cleanCells:GPU.uploadCells};
drawWorld();return {checked:results,cleanCells:GPU.uploadCells,workers:simPool.workers.length,active:simPool.activeMax};
}''')
assert min(result['checked'])>1000 and result['cleanCells']==0,result
assert min(result['checked'])>1000 and result['cleanCells']==0 and result['workers']>0 and result['active']>0,result
print('PASS busy ticks, heat, fog, scrolling, both looks: GPU bytes equal full CPU data; clean frame uploads no world cells',result)
parity=pg.evaluate("""async()=>{
const results=[];
+12 -2
View File
@@ -1,5 +1,5 @@
"""Frozen legacy references, captured before retirement; compare the WebGPU world only."""
import base64, pathlib, sys, subprocess
import base64, gzip, json, pathlib, sys, subprocess
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / 'tools'))
from use_venv import ensure_playwright; ensure_playwright()
from browser import launch, consume_gpu_option
@@ -16,7 +16,17 @@ with launch(viewport={'width': 640, 'height': 480}) as (_, pg, url):
for scene in ['renderer','joins','sky','lamp','opening','magma','fire']:
for look in ['pixel','smooth']:
for zoom in [12,2]:
pg.evaluate('(h)=>{generateWorld(424242,640,h,null);enterGame();GAME.paused=true;}',512 if scene=='joins' else 384)
height = 512 if scene=='joins' else 384
frozen = json.loads(gzip.decompress((ROOT/f'test/fixtures/golden-world-640x{height}.json.gz').read_bytes()))
# Keep the inputs that produced the legacy PNGs when simulation RNG changes.
pg.evaluate("""([h, frozen])=>{
generateWorld(424242,640,h,null);
for(const [name,encoded]of Object.entries(frozen)) {
const target=eval(name),raw=Uint8Array.from(atob(encoded),c=>c.charCodeAt(0));
new Uint8Array(target.buffer).set(raw);
}
enterGame();GAME.paused=true;
}""",[height,frozen])
name = 'joins' if scene=='joins' else 't88-renderer' if scene in ['renderer','sky'] else 't88-light'
pg.evaluate('() => {'+(ROOT / f'tools/scenes/{name}.js').read_text()+'}')
if scene not in ['renderer','joins','sky']:
+5 -1
View File
@@ -42,7 +42,11 @@ check('the settled water area goes back to sleep', run('awake[6 * CW + 1] === 0
run('deserializeWorld(loadSave.meta, loadSave.buffers); bot.x = 14; bot.y = 190;');
check('the destination is still waiting before moving wakes it', run('loadWakePending[6 * CW] === 2 && hot[6 * CW] === 0'));
ticks(run, 8);
check('moving near a waiting area wakes it within eight ticks, including heat', run('loadWakePending[6 * CW] === 0 && hot[6 * CW] > 0 && life[192 * W + 16] < FUEL[WOOD]'));
check('moving near a waiting area wakes it within eight ticks, including heat', run('loadWakePending[6 * CW] === 0 && hot[6 * CW] > 0'));
// Burning is probabilistic: waking does not promise a fuel draw on that exact tick.
ticks(run, 32);
check('newly woken fuel resumes burning', run('life[192 * W + 16] < FUEL[WOOD]'));
// Saving midway keeps the actual cells, and loading starts a fresh queue at the new bot.
run('globalThis.midLoadSave = serializeWorld(); deserializeWorld(midLoadSave.meta, midLoadSave.buffers);');
+3 -1
View File
@@ -12,7 +12,9 @@ check('revealing the whole map invalidates every chunk',marked('fogRevealAll();'
check('heat invalidates the chunk and conduction across its edges',marked('heatChunk(5,1);',64,40)&&run('!!renderDirty[1] && !!renderDirty[4] && !!renderDirty[9]'));
check('tree growth invalidates the background',marked('setTreeCell(70,70,WOOD);',70,70));
run('setCell(60*W+60,FIRE,900,0);life[60*W+60]=10;for(const j of [60*W+59,60*W+61,59*W+60,61*W+60])setCell(j,STONE,20,F_NAT);');
check('blocked fire aging invalidates without movement',marked('gas(60*W+60,60,60,FIRE);',60,60) && run('life[60*W+60]===9'));
check('blocked fire aging invalidates without movement',marked('tick++;tickStamp++;simMove();',60,60) && run('life[60*W+60]===9'));
run('setCell(32*W+64,SAND,20,0);part[32*W+64]=P_GOPEN;setCell(31*W+64,EMPTY,20,0);');
check('closing a gate invalidates the displaced grain across a chunk edge',marked('toggleGateAt(64,32);',64,31) && run('mat[31*W+64]===SAND'));
run('awake.fill(0);setCell(63*W+63,SAND,20,0);renderDirty.fill(0);tick++;tickStamp++;simMove();');
check('movement invalidates its scanned chunk and diagonal destination',run('renderDirty[5] && renderDirty[9]'));
done();
+6
View File
@@ -52,4 +52,10 @@ for (const scale of [1, 4]) {
run(`for (let c = 1; c < NCH; c++) heatChunk(c, ${scale});`);
check('heat matches original bytes and random state at scale ' + scale, snapshot() === expected);
}
setup();
run('const optimizedHeatChunk=heatChunk;heatChunk=referenceHeatChunk;heatPass();');
const passExpected=snapshot();
run('heatChunk=optimizedHeatChunk;');
setup();run('heatPass();');
check('four-pass heat matches the unoptimized oracle byte for byte',snapshot()===passExpected);
done();
+2 -1
View File
@@ -88,7 +88,8 @@ for(const grade of [-1,1]) {
for(const grade of [-1,1]) {
const s=line(grade,true);
run('for(const b of filterCfg.keys())setFilter(b,{m:[SAND],inv:1});');
collect(s,220,true);
// Let the changed seeded pile reach the full screen before timing its throughput.
collect(s,440,true);
const r=collect(s,600,true);
console.log(`Filter throughput grade ${grade}, grains/s: ${r.out/10}`);
check(`downhill screen grade ${grade} discharges its generous feed`,r.out/10>=45 && r.out/10<=90,JSON.stringify(r));
+75
View File
@@ -0,0 +1,75 @@
// Shared allocation, checkerboard independence, and real worker-count determinism.
const assert = require('assert');
const { load, flatWorld } = require('./harness');
const { run } = load();
flatWorld(run, 131, 129, 110);
assert(run('[mat,temp,life,flags,stamp,part,mach,digp,noiseB,bgm,airS,netId,isPow,occ,awake,hot,hotLvl,renderDirty,powderArea,cleatMask,cleatChunks,roofDirty,blockP,blockV,slopePhase,fog,AMB,roofY].every(a=>a.buffer instanceof SharedArrayBuffer)'));
console.log('PASS world arrays use shared memory');
const crypto = require('crypto');
const { enableThreads } = require('./harness');
flatWorld(run, 192, 96, 31);
run(`setCell(30*W+32,WATER,20,0);setCell(31*W+130,EMPTY,20,0);
tick++;tickStamp++;var draws=0,savedRnd=rnd;rnd=()=>{draws++;return .99};
var searchWork=inlineChunkWork();runChunks([1],0,searchWork);`);
assert(run('searchWork.events.some(e=>e[1]==="liquid")'), 'fixture must defer a long search');
run('mergeChunkWork([searchWork]);rnd=savedRnd;');
assert.strictEqual(run('draws'),1,'resuming a liquid search must not reroll its movement chance');
assert.strictEqual(run('mat[30*W+40]'),run('WATER'),'the resumed search finds the distant drop');
console.log('PASS long liquid search resumes without rerolling movement');
function setup() {
flatWorld(run, 387, 257, 214);
run(`{RS=123;WS=456;resetWeather();weather.next=1e15;seen.fill(0);ledger={};EV.length=0;for(const k of Object.keys(stats))stats[k]=0;
resetEconomy();nextPowerBuild=0;bot.x=185;bot.y=120;
const random=mulberry32(99), materials=[SAND,COAL,WATER,MAGMA,MCU,SNOW,LWOOD,STEAM,SMOKE];
for(let y=40;y<210;y++)for(let x=8;x<W-8;x++) {
if(random()<.68)continue;
const m=materials[(random()*materials.length)|0];
setCell(y*W+x,m,m===MAGMA?1450:m===MCU?1200:m===STEAM?110:20,0);
}
addMachine('wheel',40,35,0);
for(let x=0;x<W;x++)setCell(31*W+x,STONE,20,F_NAT);
setCell(31*W+W-10,EMPTY,20,0);
for(let x=32;x<W-10;x+=64)setCell(30*W+x,WATER,20,0);
for(let bx=12;bx<40;bx++)placeBlock(BP_FILTER,128,bx,48,true,{m:[SAND],inv:0});
for(let x=80;x<210;x++)setCell(120*W+x,COAL,1000,F_BURN);
hot.fill(6);hotLvl.fill(2);awake.fill(3);updateRoof();}
`);
}
function hash() {
const h = crypto.createHash('sha256');
for (const a of run('[mat,temp,life,flags,stamp,part,mach,digp,bgm,isPow,netId,awake,hot,hotLvl,powderArea,roofDirty,seen,renderDirty]'))
h.update(Buffer.from(a.buffer,a.byteOffset,a.byteLength));
h.update(run('JSON.stringify({RS,stats,EV,flyers,brokenMachines,pcells:[...pcells],powerDirty,weather,machines})'));
return h.digest('hex');
}
(async () => {
let expected;
for (const n of [0,1,2,8,8,2,1,0]) {
setup(); const stop = n ? await enableThreads(run,n) : null;
try {
run('for(let k=0;k<320;k++)simTick();');
const actual=hash();
if(!expected)expected=actual;
assert.strictEqual(actual,expected,`world and effects differ with ${n} workers`);
console.log(`PASS identical world and side effects with ${n} workers (${actual.slice(0,16)})`);
} finally { if(stop)await stop(); }
}
setup();
run('const forwardChunks=runChunks;runChunks=(chunks,pass,work)=>forwardChunks(chunks.slice().reverse(),pass,work);for(let k=0;k<320;k++)simTick();runChunks=forwardChunks;');
assert.strictEqual(hash(),expected,'chunk order within a colour must not matter');
console.log('PASS reversing chunks within every pass preserves all bytes and effects');
const {execFileSync}=require('child_process');
const fallback=execFileSync(process.execPath,['-e',`
global.SharedArrayBuffer=undefined;
const {load,flatWorld}=require(${JSON.stringify(require.resolve('./harness'))});
const {run}=load();flatWorld(run,64,64,50);
if(!run('mat.buffer instanceof ArrayBuffer'))throw Error('fallback allocation');
run('setCell(20*W+20,SAND,20,0);for(let k=0;k<60;k++)simTick();');
process.stdout.write(JSON.stringify(run('Array.from(mat)')));
`],{encoding:'utf8'});
flatWorld(run,64,64,50);
run('setCell(20*W+20,SAND,20,0);for(let k=0;k<60;k++)simTick();');
assert.strictEqual(JSON.stringify(run('Array.from(mat)')),fallback);
console.log('PASS Node ArrayBuffer fallback matches shared inline movement');
console.log('ALL PASS');
})().catch(e=>{console.error(e);process.exitCode=1;});