diff --git a/README.md b/README.md
index 4226bc7..a3b652a 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,10 @@ python3 build.py
python3 tools/serve.py # builds, serves at http://localhost:8000 and rebuilds when src/ changes
```
+The game requires WebGPU and cross-origin isolation for shared-memory CPU workers.
+Grains and heat run across up to 16 helpers (two CPU cores are reserved); Settings →
+Simulation → CPU workers caps the pool on the next world load. Small jobs run inline.
+
The game must be served: use `python3 tools/serve.py` locally, or serve `dist/` with any web
server that sends these headers. Opening `dist/index.html` from disk is no longer supported.
Use HTTPS when hosting remotely (localhost works over HTTP).
diff --git a/build.py b/build.py
index 8159aa1..d1c03b0 100644
--- a/build.py
+++ b/build.py
@@ -35,9 +35,13 @@ def main():
shell = (src / man['shell']).read_text()
css, _ = join(man['css'])
js, starts = join(man['sim'] + man['client'])
+ sim, _ = join(man['sim'])
+ # Keep worker code outside the indexed script so source line mappings stay exact.
+ worker = ''
head, tail = shell.split('//__JS__')
body = head.replace('/*__CSS__*/', css)
page = body + js + tail
+ page = worker + page
pre = ('
'
''
'')
@@ -46,7 +50,7 @@ def main():
(dist / 'deepgrain-foundry.html').write_text(page)
(dist / 'index.html').write_text(full)
# the script starts on this line of index.html (the doctype prefix has no newlines)
- js_line = body.count('\n') + 1
+ js_line = worker.count('\n') + body.count('\n') + 1
(dist / 'bundle-lines.json').write_text(json.dumps(
{'page': 'dist/index.html', 'files': [[js_line + s - 1, f] for s, f in starts]}, indent=1) + '\n')
print(f'built dist/index.html ({len(full):,} bytes, {len(starts)} source files)')
diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md
index 7f2e5a6..1fcc78b 100644
--- a/docs/PROJECT_MAP.md
+++ b/docs/PROJECT_MAP.md
@@ -244,9 +244,9 @@ Starter furnace designs and the milestone list shown in the notebook.
- functions: smelterBlueprint
- variables: STARTER_DESIGNS, MILESTONES
-### src/sim/world.js (113 lines)
+### src/sim/world.js (123 lines)
World state: the per-cell typed arrays, alloc(), ambient temperature by depth, waking.
-- functions: emit, bagCap, discover, alloc, depthU, depthY, meanSurface, computeAmbient, fillNoise, renderDirtyXY, renderDirtyIdx, wakeXY, wakeHeatXY, wakeIdx, wakeHeatIdx
+- functions: emit, bagCap, discover, sharedArray, alloc, depthU, depthY, meanSurface, computeAmbient, fillNoise, renderDirtyXY, renderDirtyChunk, renderDirtyIdx, wakeXY, wakeHeatXY, wakeIdx, wakeHeatIdx
- variables: W, H, N, CW, CH, NCH, mat, temp, life, flags, stamp, part, mach, digp, noiseB, airS, netId, isPow, occ, surfY, dirtY, graniteY, basaltY, magmaY, roofY, AMB, worldSeed, surfBase, underD, shallowD, lapse, BW, BH, NB, blockP, blockV, ents, bgm, flyers, ghostBlocks, ghostMolds, renderDirty, awake, hot, hotLvl, powderArea, roofDirty, loadWakeOrder, loadWakePending, loadWakeIndex, tick, tickStamp, machines, molds, nets, pcells, powerDirty, beltL, beltR, screwLists, brokenMachines, ghostMachines, ghostBlocked, EV, bot, bag, bagTotal, player, seen, ledger, stats
### src/sim/fog.js (52 lines)
@@ -254,12 +254,12 @@ Exploration only: never changes grains or the random stream.
- functions: fogAlloc, fogMark, fogSeen, fogInit, fogLook, fogRevealAround, fogRevealAll
- variables: FOG, FOG_SENSE, FOG_SENSE_FADE, fog, FW, FH
-### src/sim/cells.js (227 lines)
+### src/sim/cells.js (223 lines)
Cell helpers (setCell, move, enter) and the falling-sand movement of powders, liquids and gases (simMove).
-- functions: setCell, swapCells, move, enter, enterFree, powder, passable, liquid, gas, wakeBotPowder, simMove
+- functions: setCell, swapCells, swapCellData, move, enter, enterFree, powder, passable, liquid, liquidSearch, gas, wakeBotPowder, simMove, moveChunk
- variables: SORTP, SORTP_FUEL, LSTEP, LFILM, CORNER_SEAL
-### src/sim/heat.js (271 lines)
+### src/sim/heat.js (266 lines)
Heat: conduction, melting and freezing, reactions, fire and air, steel.
- functions: curAir, airflowAt, alloyMix, airy, hasO2, condPair, heatPass, heatChunk, neighborDir, react, char, quench, burningNear, burn, steelCheck
- variables: POROUS, SOIL, HOTLIQ, REACT_MIN, ALLOY
@@ -369,6 +369,11 @@ Weather: clouds from steam, rain and snow, and the world water count.
- functions: wrnd, resetWeather, rainGap, worldWater, absorbSteam, spawnRain, rainAt, weatherTick
- variables: WBAND, WS, weather
+### src/sim/workers.js (153 lines)
+The same chunk schedule runs inline or in workers. Workers only own a pass at a time.
+- functions: threadWorld, threadBind, threadContext, resetChunkWork, inlineChunkWork, chunkEvent, chunkWake, chunkHeat, chunkSeed, runChunks, mergeChunkWork, chunkPasses, threadParams, threadSetParams, threadSlot, threadExecute, threadPass, threadWait, threadFailure, threadMessage
+- variables: simPool, chunkWork, inlineWork
+
### src/sim/tick.js (29 lines)
simTick: one simulation step, in order.
- functions: simTick
@@ -451,7 +456,7 @@ Field mip levels preserve the legacy trilinear AO sample at level 2.2.
- functions: gpuSmoothInit, gpuSmoothTextures, gpuSmoothBindings, gpuSmoothUpload, gpuSmoothMips
- variables: GPU_FIELD_MIP
-### src/render/gpu/device.js (89 lines)
+### src/render/gpu/device.js (90 lines)
All pipelines compile during startup; rendering never falls back.
- functions: gpuLimitsOK, gpuInit, gpuFail, gpuRecover, gpuWarmWorld, worldPixelContext
- variables: GPU_LIMITS, GPU
@@ -492,6 +497,11 @@ Game shell (src/ui/): browser only. This file: settings, the GAME state object,
- functions: loadSettings, saveSettings, el, esc, rgbOf, fmtT, toast, setStatus
- variables: SET_DEFAULT, SET, TOOLS, ICONS, BAG_ORDER, GAME, keys, hudDirty, $
+### src/ui/threads.js (39 lines)
+Browser transport only; the simulation owns the schedule and shared state.
+- functions: startThreads
+- variables: threadStarting
+
### src/ui/title.js (77 lines)
Startup capabilities and title art.
- functions: checkCapabilities, renderCapabilityNotice, canStartGame, buildTitleCells, drawTitle
@@ -511,7 +521,7 @@ Shared player state; the world save remains a complete standalone fallback.
Starting, loading and quitting a game; modal dialogs; the pause menu.
- functions: showLoading, enterGame, setupNewWorld, startNewWorld, startFromSave, loadSavedWorld, quitToTitle, refreshContinue, anyModal, openModal, closeModal, closeAllModals, topModal, dialog, openPause, renderWorlds
-### src/ui/settings.js (123 lines)
+### src/ui/settings.js (124 lines)
The settings screen.
- functions: openSettings, renderSettings, applySettings, toggleFullscreen, confirmWipe
- variables: SET_TABS, setTab
@@ -607,7 +617,7 @@ Shared world colours, minimap and full map.
Browser-only clocks; simulation code stays usable without a browser or a clock.
- variables: PERF_MARKS, PERF_FRAME, perfMarks, perfTimes, perfLast
-### src/ui/loop.js (143 lines)
+### src/ui/loop.js (149 lines)
The frame loop and boot.
- functions: scanVisible, frame, boot
- variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT
@@ -656,6 +666,7 @@ The frame loop and boot.
- `test/t_slopes.js`: Sloped walls: flow, sealed joints, building, refunds and persistence.
- `test/t_smooth.py`: The smooth look: the WebGPU pass compiles and draws, and the thermal lens still works.
- `test/t_stone.js`: Uses for waste rock and sand: glass bricks from a fan furnace, the Saw, the silo and builders' orders.
+- `test/t_threads.js`: Shared allocation, checkerboard independence, and real worker-count determinism.
- `test/t_water.js`: Water conservation, settling, ledge pouring, boundaries, pipes and phase changes. Tall worlds keep geothermal heat out of the ponds.
- `test/t_weather.js`: Weather: no more springs; steam rises into clouds and rains back; showers; snow when it is freezing.
- `test/t_world.js`: Trees from acorns, charcoal, brick, density, water/lava/snow/ice, full-bag refunds, drill reach, ballistic blowing, snowy mountains.
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 430b1ef..a730030 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -49,7 +49,7 @@ done(); // prints ALL PASS or N FAILED, sets the
- A new suite named `test/t_.js` is picked up by `run_all.py` automatically.
**Determinism.** The simulation's randomness comes from seeded generators. The same code and the
-same test give the same output every run, apart from lines with timings in ms. To prove a
+same test give outputs identical across thread counts and between runs, apart from lines with timings in ms. To prove a
refactor changed nothing, save every suite's output before and after, and diff them:
```sh
@@ -58,6 +58,17 @@ for t in test/t_*.js; do node $t > /tmp/after/$(basename $t).txt; done # afte
diff -r /tmp/before /tmp/after
```
+`test/t_threads.js` runs the real shared-memory scheduler inline and with 1, 2 and 8
+Node workers, including repeats, comparing all mutable cell/chunk bytes and JS side effects.
+The browser creates up to `min(16, hardwareConcurrency - 2, CPU workers setting)` helpers.
+Small passes run inline; larger passes use batches to amortize dispatch. Wakes and heat flags
+merge after each pass; long liquid searches are deferred until that merge. Global machine and
+weather randomness remains separate from the chunk streams. World generation uses the same
+inline schedule while settling. The t88 re-baseline changes worldgen material hashes and the
+seeded sand pile bounds in `t_corner_seal.js`; the heat oracle now supports pass outputs.
+The conveyor fixture uses a wider feed pile, the screen fixture warms longer, and `t_load.js`
+checks waking within eight ticks separately from the probabilistic consumption of fuel.
+
## Browser tests
- They need the `.venv` from README.md → Develop; plain `python3` switches to it on its own.
@@ -73,7 +84,8 @@ diff -r /tmp/before /tmp/after
both looks at zoom 12 and 2. ImageMagick decodes RGB; the per-channel tolerance is 8,
mean RGB error must stay below 0.5/255 and fewer than 0.3% of pixels may exceed 8
(smooth AO/edge rounding). These are frozen pre-removal legacy images, not generated
- from the new renderer. Inspect the matching `shots/golden-*.png` after visible changes.
+ from the new renderer. The compressed `golden-world-640x*.json.gz` fixtures retain the
+ Phase 2 world bytes that produced them, isolating renderer checks from worldgen RNG changes. Inspect the matching `shots/golden-*.png` after visible changes.
- `test/t_gpu.py` checks default limits (four storage textures per stage), dirty-upload
readback after 240 busy ticks with scrolling and both looks, exact dirty/full image equality
with a frozen clock, world replacement, transparent overlays, drill contours, shader compilation timing, successful device recreation and failed recovery with
@@ -98,7 +110,7 @@ diff -r /tmp/before /tmp/after
- `python3 tools/caps.py` prints isolation, actual WebGPU adapter info and the GL renderer for
Chromium and installed Firefox. Real mode requires a hardware Chromium adapter.
- `test/t_caps.py` checks absent, null, rejected and insufficient-limit adapters, notices and
- the mandatory WebGPU startup gate. Threads remain optional until Phase 3. Firefox smoke
+ the mandatory WebGPU startup gate. Cross-origin isolation is required for threads. Firefox smoke
checks that a browser without WebGPU shows the notice and cannot start a world.
- Install Firefox with `.venv/bin/python -m playwright install firefox`.
`python3 test/firefox_smoke.py` checks isolation and the WebGPU notice, starts a pixel world
diff --git a/docs/agent-log.md b/docs/agent-log.md
index b018ccf..69ab956 100644
--- a/docs/agent-log.md
+++ b/docs/agent-log.md
@@ -4,6 +4,88 @@ What agents have done, newest first. Add an entry for every piece of work: the d
note or request it was for, what changed, the files touched, the tests run, and anything left
open or not verified. Keep each entry short; the git history has the details.
+## 2026-10-28: t88 phase 3 resume, final review and verification (Codex)
+
+- Preserved the interrupted uncommitted implementation and reviewed the schedule, shared
+ writes, deferred effects, startup gate, test changes and profiler against the Phase 3 brief.
+- Reproduced a deferred-liquid bug before fixing it: replaying liquid() rolled the movement
+ chance twice. Extracted liquidSearch() so the deferred search resumes at its exact direction
+ and distance without another random draw; removed the now-unneeded RNG replay records.
+ Added a failing-then-passing regression and a long water shelf to the repeated 320-tick
+ inline/1/2/8-worker and reversed-order fixture.
+- Chunk passes now skip per-cell renderDirty stores in wakeXY/wakeHeatXY; the scanned chunk
+ and write halo provide their invalidation. Outside passes, explicit invalidation remains.
+- All headless suites passed. Initial sandboxed browser gate failed (no GPU device); an
+ elevated capability check confirms RTX 5070 WebGPU, isolation and 24 cores. Firefox still
+ has no adapter and correctly requires WebGPU. Final host-GPU browser gate in progress.
+- Files additionally changed on resume: sim/{cells,workers,world}, test/t_threads.js,
+ PROJECT_MAP and tracking. Full fresh profile, two-minute live run and screenshot inspection
+ pending. No commit; DESIGN and owner's Context untouched.
+
+## 2026-10-27: t88 phase 3, multi-core simulation (Codex; verification in progress)
+
+- SharedArrayBuffer allocation for world arrays (ArrayBuffer only when unavailable in Node).
+ Four (chunkX&1, chunkY&1) movement passes then four heat passes. Each chunk scopes RS to
+ hash(world seed,tick,chunk,pass), restoring the global machine/worldgen RNG afterwards.
+ Long liquid reads outside a 24-cell horizontal halo are deferred and replayed in chunk order;
+ movement writes reach at most 8 cells, so parallel read/write regions cannot overlap.
+- Pass-local wake/heat flags merge by maximum; per-chunk heat outputs merge first. Discoveries,
+ events, fuel ignition counts, wheel flow, cloud moisture, broken machines and partLost merge
+ in chunk order. Flyers/launchGrain remain reachable only from main-thread transport, not these
+ cell passes; their existing state is included in the determinism hash. Direct shared writes
+ are disjoint cell/powder outputs or idempotent render/roof bits.
+- Browser Blob pool uses min(16,hardwareConcurrency-2,setting), at least one. Main thread owns
+ machines, transport and game logic, processes a batch in each pass, then polls Atomics.load.
+ No Atomics.wait on the document: it is prohibited by
+ https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics/wait
+ and https://tc39.es/ecma262/2024/multipage/structured-data.html. Polling is synchronous and
+ occupies the main core; it preserves atomic ticks so input/save/render cannot race workers.
+ No whole-simulation worker or asynchronous rendering overlap. Worker timeouts/errors stop
+ frame simulation. Small jobs run inline and larger batches use only enough helpers to
+ amortize dispatch (32 movement or 8 heat chunks per helper). Pool setting applies on load.
+- Invalidation: remove swap's duplicate hot marks, gas marks and repeated coordinate-to-chunk
+ function calls. Each scanned movement chunk marks its halo once; heat marks conduction edges.
+ wake functions reuse their already computed chunk index for one direct render bit store;
+ rare swaps/building/fog/dig/background writes retain explicit marks. Paused clean draws upload
+ zero world cells. GPU equality passes without changing any tolerance.
+- Measurements so far: fresh Phase 2 settled/factory/heat 3.537/4.077/5.824 ms/tick vs frozen
+ baseline 3.715/3.657/4.938. Isolated removal of hot dirty calls in a temporary Phase 2 copy
+ gave 3.530/3.921/5.936: it did NOT recover the full 15% on its own. Do not claim it did.
+ Initial unbatched pool factory 4.21; batched trial 3.04 ms/tick. Full final matrix pending.
+- Final full matrix (`/tmp/t88-phase3-final.json`, `/tmp/t88-phase3-final-perf.txt`, 218.0 s),
+ RTX 5070, 24 logical cores, 1920x1080, 2048x1024 world. Pool 16 helpers, peak active 4
+ (settled) / 5 (factory, heat), plus main-thread batch. All values below are ms.
+ settled: baseline 3.715 -> wall/main occupied 1.897 (1.96x); main compute excluding polling 1.624, polling 0.272.
+ factory: baseline 3.657 -> wall/main occupied 2.495 (1.47x); main compute excluding polling 2.037, polling 0.458.
+ heat: baseline 4.938 -> wall/main occupied 2.649 (1.86x); main compute excluding polling 2.236, polling 0.412.
+ Real-loop CPU work p50/p95, zoom 2 / zoom 6; pixel then smooth:
+ settled: pixel 7.08/8.58 / 2.77/3.83; smooth 8.46/9.98 / 2.98/4.13.
+ factory: pixel 8.48/10.05 / 4.96/5.95; smooth 9.59/10.80 / 5.13/6.12.
+ heat: pixel 8.53/10.56 / 4.88/6.03; smooth 10.05/11.51 / 5.17/6.21.
+ Frame intervals p50/p95 are 16.67/16.67 for all these runs (60 fps). Tick wall time and
+ occupied main-thread time are equal: polling blocks this core; rendering does not overlap.
+ The final threaded sim meets the baseline target in every scene; standalone dirty-only
+ recovery remains unproven/unmet (numbers above). Recommend a future whole-simulation worker
+ to remove the remaining 1.90-2.65 ms from the main thread; it would require an explicit
+ ownership/input/save boundary and is outside this phase.
+- Re-baseline: three worldgen material hashes and seeded pile bounds (57x17 -> 46x19 grains).
+ The heat oracle retains its unoptimized per-cell computation, updated for buffered pass
+ outputs, and is compared against all four new passes. Same throughput assertions retained:
+ conveyor feed heap widened, downhill-screen warm-up lengthened; load-wake deadline kept at
+ eight ticks, fuel consumption checked later instead of demanding a random burn on that tick.
+ Frozen renderer PNGs and tolerances unchanged: original Phase 2 world bytes captured from the
+ Phase 2 simulation (only invalidation edits) in the temporary copy, compressed as golden-world fixtures, so changed
+ terrain doesn't get counted as changed renderer pixels.
+- Headless suites pass; repeated inline/1/2/8 real-worker hashes pass (world arrays + JS effects).
+ Extended test passes 320 busy ticks including stamp wrap, reversed chunk order, Node fallback.
+ First full browser gate: gameplay 361, smooth 28, console 18, capabilities 7, GPU equality 12,
+ light 71 and Firefox 2 pass; golden sky failed due changed worldgen terrain. Frozen-input
+ golden rerun, final browser gate, full profile and 2-minute play-through pending.
+- Files: sim/{world,cells,heat,workers,fog,machines,weather}, ui/{threads,title,state,settings,loop},
+ render/gpu/device, build.py/manifest; harness, threads/heat/load/corner/conveyor/screen/capability/
+ golden tests and fixtures; profiler, README, TESTING, PROJECT_MAP and t88 tracking.
+ No commit; DESIGN and owner's task context untouched. Phase 3 remains in progress.
+
## 2026-10-27: t88 phase 2d, required WebGPU and dirty uploads (Codex)
- Per-32x32-chunk `renderDirty` marks cell writes, heat with its conduction halo, stationary
diff --git a/src/manifest.json b/src/manifest.json
index 39393da..c2d95c1 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -37,6 +37,7 @@
"sim/bot.js",
"sim/ledger.js",
"sim/weather.js",
+ "sim/workers.js",
"sim/tick.js",
"sim/worldgen-helpers.js",
"sim/worldgen.js",
@@ -66,6 +67,7 @@
"render/overlays.js",
"ui/audio.js",
"ui/state.js",
+ "ui/threads.js",
"ui/title.js",
"ui/saves.js",
"ui/claims.js",
diff --git a/src/render/gpu/device.js b/src/render/gpu/device.js
index 687c05d..91af20b 100644
--- a/src/render/gpu/device.js
+++ b/src/render/gpu/device.js
@@ -68,6 +68,7 @@ async function gpuRecover(message) {
}
// Submit both looks behind the loading screen, including their first real bindings.
async function gpuWarmWorld() {
+ await startThreads();
buildSky(); resizeRender(); snapCamera();
const look = SET.look, started = performance.now();
try {
diff --git a/src/sim/cells.js b/src/sim/cells.js
index d6f6b23..b2be7a8 100644
--- a/src/sim/cells.js
+++ b/src/sim/cells.js
@@ -12,7 +12,9 @@ function setCell(i, m, t, fl) {
if (t - AMB[y] > 20 || AMB[y] - t > 20 || (MELT[m] !== 0 && AMB[y] >= MELT[m]) || (m === WATER && AMB[y] < -0.5)) wakeHeatXY(x, y);
}
function swapCells(i, j) {
- renderDirtyIdx(i); renderDirtyIdx(j);
+ renderDirtyIdx(i); renderDirtyIdx(j); swapCellData(i, j);
+}
+function swapCellData(i, j) {
let a = mat[i]; mat[i] = mat[j]; mat[j] = a;
const f = temp[i]; temp[i] = temp[j]; temp[j] = f;
a = life[i]; life[i] = life[j]; life[j] = a;
@@ -20,16 +22,16 @@ function swapCells(i, j) {
stamp[i] = tickStamp; stamp[j] = tickStamp;
}
function move(i, j, x, y, nx, ny) {
- swapCells(i, j);
+ swapCellData(i, j);
wakeXY(x, y); wakeXY(nx, ny);
if (ny === y && LFILM > 1 && PH[mat[j]] === PH_LIQUID && (y === 0 || PH[mat[i - W]] !== PH_LIQUID)) {
// a top-layer grain stepping sideways may clear the path to a drop for surface grains far
// along the row, so wake them; moves inside a body of water do not (settled lakes must sleep)
const reach = SPREAD[mat[j]] * LFILM, row = (y >> 5) * CW;
const first = Math.max(0, x - reach) >> 5, last = Math.min(W - 1, x + reach) >> 5;
- for (let cx = first; cx <= last; cx++) awake[row + cx] = 3;
+ for (let cx = first; cx <= last; cx++) chunkWake(row + cx);
}
- if (part[j] === P_WHEEL) { const mm = machines[mach[j]]; if (mm) mm.flow += PH[mat[j]] === PH_LIQUID ? 1 : 0.5; }
+ if (part[j] === P_WHEEL) { if (chunkWork) chunkEvent('flow', mach[j], PH[mat[j]] === PH_LIQUID ? 1 : 0.5); else { const mm = machines[mach[j]]; if (mm) mm.flow += PH[mat[j]] === PH_LIQUID ? 1 : 0.5; } }
const d = temp[j] - AMB[ny];
if (d > 25 || d < -25) wakeHeatXY(nx, ny);
}
@@ -83,7 +85,7 @@ function powder(i, x, y, m, barriers = cleatChunks[(y >> 5) * CW + (x >> 5)]) {
// a grain at least twice as dense works its way down through a loose pile. Unlit fuel
// packs too tight for that, so a mixed furnace charge stays mixed until the fire gets in.
const t = mat[b];
- if (PH[t] === PH_POWDER && (!barriers || (!cleatMask[i] && !cleatMask[b])) && DENS[m] >= DENS[t] * 2 && (!FUEL[t] || (flags[b] & F_BURN)) && stamp[b] !== tickStamp && !occ[b] && part[b] !== P_GRATE && part[b] !== P_FILTER && rnd() < (FUEL[t] ? SORTP_FUEL : SORTP)) { move(i, b, x, y, x, y + 1); awake[(y >> 5) * CW + (x >> 5)] = 40; return; }
+ if (PH[t] === PH_POWDER && (!barriers || (!cleatMask[i] && !cleatMask[b])) && DENS[m] >= DENS[t] * 2 && (!FUEL[t] || (flags[b] & F_BURN)) && stamp[b] !== tickStamp && !occ[b] && part[b] !== P_GRATE && part[b] !== P_FILTER && rnd() < (FUEL[t] ? SORTP_FUEL : SORTP)) { move(i, b, x, y, x, y + 1); chunkWake((y >> 5) * CW + (x >> 5), 40); return; }
// a grain lying right on a belt or filter is held by it: the surface carries it, it does not slide off the edge
if ((t === BELTL || t === BELTR || part[b] === P_FILTER) &&
!isSlopeBelt(blockP[((y + 1) >> 2) * BW + (x >> 2)])) return;
@@ -132,11 +134,18 @@ function liquid(i, x, y, m) {
if (rnd() > FLUID[m]) return;
const sp = SPREAD[m] * (y === 0 || PH[mat[i - W]] !== PH_LIQUID ? LFILM : 1);
const d0 = (RS & 4) ? 1 : -1;
- for (let pass = 0; pass < 2; pass++) {
+ liquidSearch(i, x, y, m, sp, d0);
+}
+function liquidSearch(i, x, y, m, sp, d0, firstPass = 0, firstStep = 1) {
+ for (let pass = firstPass; pass < 2; pass++) {
const d = pass ? -d0 : d0;
- for (let s = 1; s <= sp; s++) {
+ for (let s = pass === firstPass ? firstStep : 1; s <= sp; s++) {
const nx = x + d * s;
if (nx < 0 || nx >= W) break;
+ // Same-colour chunks are separated by 32 cells; their maximum write halo is 8.
+ if (chunkWork && (nx < chunkWork.x0 - 24 || nx >= chunkWork.x1 + 24)) {
+ chunkEvent('liquid', i, m, sp, d0, pass, s); return;
+ }
const j = i + d * s;
if (!passable(m, PH_LIQUID, j, d, 0)) break;
if (y < H - 1 && passable(m, PH_LIQUID, j + W, 0, 1)) {
@@ -149,8 +158,7 @@ function liquid(i, x, y, m) {
}
function gas(i, x, y, m) {
// Even blocked gas must keep aging or condensing until it is gone.
- awake[(y >> 5) * CW + (x >> 5)] = 3;
- renderDirtyXY(x, y); // Gas can age without moving.
+ chunkWake((y >> 5) * CW + (x >> 5));
if (m === FIRE) {
const l = life[i];
if (l <= 1) { mat[i] = EMPTY; life[i] = 0; wakeXY(x, y); return; }
@@ -193,35 +201,23 @@ function wakeBotPowder() {
if (dx * dx + dy * dy <= 10000) awake[c] = 1;
}
}
-function simMove() {
- for (let cy = CH - 1; cy >= 0; cy--) {
- const rowBase = cy * CW;
- let any = false;
- for (let cx = 0; cx < CW; cx++) if (awake[rowBase + cx]) { any = true; powderArea[rowBase + cx] = 0; }
- if (!any) continue;
- const y0 = cy << 5, y1 = Math.min(H, y0 + 32);
- for (let y = y1 - 1; y >= y0; y--) {
- const ltr = ((y + tick) & 1) === 0;
- const yw = y * W;
- for (let k = 0; k < CW; k++) {
- const cx = ltr ? k : CW - 1 - k;
- if (!awake[rowBase + cx]) continue;
- roofDirty[cx] = 1;
- const x0 = cx << 5, x1 = Math.min(W, x0 + 32), barriers = cleatChunks[rowBase + cx];
- // alternate rows scan left to right and right to left, so nothing drifts one way
- const dx = ltr ? 1 : -1, xEnd = ltr ? x1 : x0 - 1;
- for (let x = ltr ? x0 : x1 - 1; x !== xEnd; x += dx) {
- const i = yw + x, m = mat[i];
- if (m === EMPTY) continue;
- const ph = PH[m];
- if (ph === PH_POWDER) powderArea[rowBase + cx] = 1;
- if (stamp[i] === tickStamp) continue;
- if (ph === PH_SOLID) continue;
- const p = part[i];
- if (IS_SCREW[p] && ph === PH_POWDER) continue; // a screw conveyor moves its own grains
- if (ph === PH_POWDER) powder(i, x, y, m, barriers); else if (ph === PH_LIQUID) liquid(i, x, y, m); else gas(i, x, y, m);
- }
- }
+function simMove() { chunkPasses(false); }
+function moveChunk(c) {
+ const cx = c % CW, cy = (c / CW) | 0;
+ const x0 = cx << 5, x1 = Math.min(W, x0 + 32), y0 = cy << 5, y1 = Math.min(H, y0 + 32);
+ const barriers = cleatChunks[c];
+ powderArea[c] = 0; roofDirty[cx] = 1; renderDirtyChunk(c);
+ for (let y = y1 - 1; y >= y0; y--) {
+ const ltr = ((y + tick) & 1) === 0, dx = ltr ? 1 : -1, end = ltr ? x1 : x0 - 1, yw = y * W;
+ for (let x = ltr ? x0 : x1 - 1; x !== end; x += dx) {
+ const i = yw + x, m = mat[i];
+ if (m === EMPTY) continue;
+ const ph = PH[m];
+ if (ph === PH_POWDER) powderArea[c] = 1;
+ if (stamp[i] === tickStamp || ph === PH_SOLID) continue;
+ if (IS_SCREW[part[i]] && ph === PH_POWDER) continue;
+ if (ph === PH_POWDER) powder(i, x, y, m, barriers);
+ else if (ph === PH_LIQUID) liquid(i, x, y, m); else gas(i, x, y, m);
}
}
}
diff --git a/src/sim/fog.js b/src/sim/fog.js
index dd3c093..059b0dc 100644
--- a/src/sim/fog.js
+++ b/src/sim/fog.js
@@ -3,7 +3,7 @@ const FOG = 2;
const FOG_SENSE = 24; // Reveal nearby ground through rock, in grains.
const FOG_SENSE_FADE = 12;
let fog, FW, FH;
-function fogAlloc() { FW = Math.ceil(W / FOG); FH = Math.ceil(H / FOG); fog = new Uint8Array(FW * FH); }
+function fogAlloc() { FW = Math.ceil(W / FOG); FH = Math.ceil(H / FOG); fog = sharedArray(Uint8Array, FW * FH); }
function fogMark(x, y, v) {
if (x < 0 || y < 0 || x >= W || y >= H) return;
const i = Math.floor(y / FOG) * FW + Math.floor(x / FOG);
diff --git a/src/sim/heat.js b/src/sim/heat.js
index e3d4402..d72b07a 100644
--- a/src/sim/heat.js
+++ b/src/sim/heat.js
@@ -52,16 +52,9 @@ function condPair(a, b, sc, nc, da) {
const q = d * k * sc;
temp[a] -= q * ICAP[ma];
temp[b] += q * ICAP[mb];
- if (nc >= 0 && (q > 0.15 || q < -0.15)) { if (!hot[nc]) hotLvl[nc] = 2; hot[nc] = 6; }
-}
-function heatPass() {
- for (let c = 0; c < NCH; c++) {
- if (!hot[c]) continue;
- let sc = 1;
- if (hotLvl[c] === 1) { if (((tick + c) & 3) !== 0) continue; sc = 4; }
- heatChunk(c, sc);
- }
+ if (nc >= 0 && (q > 0.15 || q < -0.15)) { chunkHeat(nc, !hot[nc] ? 2 : 0); }
}
+function heatPass() { chunkPasses(true); }
function heatChunk(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);
@@ -93,7 +86,7 @@ function heatChunk(c, sc) {
if ((pu === PH_EMPTY || pu === PH_GAS) && ex > 2) {
const a = ex * (sc > 1 ? 0.45 : 0.25);
t -= a; temp[u] += a;
- if (y === y0 && upCold && a > 0.5) { hot[c - CW] = 6; hotLvl[c - CW] = 2; }
+ if (y === y0 && upCold && a > 0.5) { chunkHeat(c - CW); }
}
}
rel = y < roofY[x] ? 0.02 : 0.002;
@@ -110,8 +103,10 @@ function heatChunk(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; }
}
function neighborDir(i, x, y, d) {
if (d === 0) return y > 0 ? i - W : -1;
@@ -125,7 +120,7 @@ function react(i, x, y, m) {
if (fl & F_BURN) { burn(i, x, y, m, t); return; }
if (IGN[m] !== 0 && t >= IGN[m] && (hasO2(i, x, y) || burningNear(i, x, y))) {
flags[i] = fl | F_BURN; life[i] = FUEL[m];
- stats.ignitions++; discover(FIRE, x, y, m, 'ignite', t);
+ if (chunkWork) chunkEvent('ignite'); else stats.ignitions++; discover(FIRE, x, y, m, 'ignite', t);
return;
}
const ph = PH[m];
@@ -183,7 +178,7 @@ function react(i, x, y, m) {
if (rnd() < 0.03 + (t - mp) * 0.002) {
let to = MELTTO[m];
if (SMELTP[m] !== 0 && rnd() >= SMELTP[m]) to = t >= FRZ[MSLAG] ? MSLAG : SLAG; // the waste runs off molten when it is hot enough
- if (PARTMAT[m]) { partLost(i); if (m === HULL && mach[i]) brokenMachines.push(mach[i]); }
+ if (PARTMAT[m]) { partLost(i); if (m === HULL && mach[i]) { if (chunkWork) chunkEvent('broken', mach[i]); else brokenMachines.push(mach[i]); } }
mat[i] = to; flags[i] = 0; life[i] = PH[to] === PH_LIQUID ? 255 : 0; digp[i] = 0;
wakeXY(x, y); discover(to, x, y, m, SMELTP[m] !== 0 ? 'smelt' : 'melt', mp);
}
diff --git a/src/sim/machines.js b/src/sim/machines.js
index 1070442..7828714 100644
--- a/src/sim/machines.js
+++ b/src/sim/machines.js
@@ -5,7 +5,7 @@ function markPow(i, on) {
else if (isPow[i]) { isPow[i] = 0; pcells.delete(i); netId[i] = 0; }
powerDirty = true;
}
-function partLost(i) { markPow(i, false); }
+function partLost(i) { if (chunkWork) chunkEvent('lost', i); else markPow(i, false); }
let nextPowerBuild = 0;
function rebuildPower() {
powerDirty = false;
diff --git a/src/sim/weather.js b/src/sim/weather.js
index d10ee84..9f7475d 100644
--- a/src/sim/weather.js
+++ b/src/sim/weather.js
@@ -25,7 +25,7 @@ function worldWater() {
for (const mm of machines) if (mm && mm.type === 'silo' && mm.sm === SNOW) n += mm.sn; // and snow in silos
return Math.round(n + bag[WATER] + bag[SNOW]);
}
-function absorbSteam(x) { const b = Math.min(weather.moist.length - 1, Math.max(0, (x / WBAND) | 0)); weather.moist[b] += 1; }
+function absorbSteam(x) { if (chunkWork) { chunkEvent('moist', x); return; } const b = Math.min(weather.moist.length - 1, Math.max(0, (x / WBAND) | 0)); weather.moist[b] += 1; }
// Send about k drops (snowflakes where it is freezing) down from the clouds over x0..x1; returns how many fell
function spawnRain(x0, x1, k) {
if (flyers.length > 2500) return 0;
diff --git a/src/sim/workers.js b/src/sim/workers.js
new file mode 100644
index 0000000..78439bd
--- /dev/null
+++ b/src/sim/workers.js
@@ -0,0 +1,153 @@
+// The same chunk schedule runs inline or in workers. Workers only own a pass at a time.
+let simPool = null, chunkWork = null, inlineWork = null;
+function threadWorld() {
+ return { W,H,N,CW,CH,NCH,BW,BH,NB,mat,temp,life,flags,stamp,part,mach,digp,noiseB,bgm,
+ airS,netId,isPow,occ,cleatMask,cleatChunks,blockP,blockV,slopePhase,awake,hot,hotLvl,
+ powderArea,roofDirty,renderDirty,AMB,roofY };
+}
+function threadBind(s) {
+ ({ W,H,N,CW,CH,NCH,BW,BH,NB,mat,temp,life,flags,stamp,part,mach,digp,noiseB,bgm,
+ airS,netId,isPow,occ,cleatMask,cleatChunks,blockP,blockV,slopePhase,awake,hot,hotLvl,
+ powderArea,roofDirty,renderDirty,AMB,roofY } = s);
+}
+function threadContext() {
+ return { wake: new Uint8Array(NCH), heat: new Uint8Array(NCH), level: new Uint8Array(NCH),
+ ownHot: new Uint8Array(NCH).fill(255), ownLevel: new Uint8Array(NCH), events: [], c: -1,
+ x0: 0, x1: 0 };
+}
+function resetChunkWork(work) {
+ work.wake.fill(0); work.heat.fill(0); work.level.fill(0); work.ownHot.fill(255); work.events.length = 0;
+ return work;
+}
+function inlineChunkWork() {
+ if (!inlineWork || inlineWork.wake.length !== NCH) inlineWork = threadContext();
+ return resetChunkWork(inlineWork);
+}
+function chunkEvent(kind, ...args) { chunkWork.events.push([chunkWork.c, kind, ...args]); }
+function chunkWake(c, n = 3) {
+ if (chunkWork) { if (chunkWork.wake[c] < n) chunkWork.wake[c] = n; }
+ else awake[c] = n;
+}
+function chunkHeat(c, level = 2) {
+ if (chunkWork) { chunkWork.heat[c] = 6; if (chunkWork.level[c] < level) chunkWork.level[c] = level; }
+ else { hot[c] = 6; if (level) hotLvl[c] = level; }
+}
+function chunkSeed(c, pass) { return hash2(c, tick, worldSeed ^ Math.imul(pass + 1, 0x9e3779b9)) | 1; }
+function runChunks(chunks, pass, work) {
+ const savedRS = RS;
+ chunkWork = work;
+ try {
+ for (const c of chunks) {
+ work.c = c; work.x0 = (c % CW) * 32; work.x1 = Math.min(W, work.x0 + 32);
+ RS = chunkSeed(c, pass);
+ if (pass < 4) moveChunk(c); else {
+ const sc = hotLvl[c] === 1 ? 4 : 1;
+ heatChunk(c, sc);
+ }
+ }
+ } finally { chunkWork = null; RS = savedRS; }
+}
+function mergeChunkWork(works) {
+ for (const work of works) for (let c = 0; c < NCH; c++) if (work.ownHot[c] !== 255) {
+ hot[c] = work.ownHot[c]; hotLvl[c] = work.ownLevel[c];
+ }
+ for (const work of works) for (let c = 0; c < NCH; c++) {
+ if (awake[c] < work.wake[c]) awake[c] = work.wake[c];
+ if (hot[c] < work.heat[c]) hot[c] = work.heat[c];
+ if (hotLvl[c] < work.level[c]) hotLvl[c] = work.level[c];
+ }
+ const events = works.flatMap(w => w.events).sort((a,b) => a[0] - b[0]);
+ for (const [,kind,...a] of events) {
+ if (kind === 'discover') discover(...a);
+ else if (kind === 'emit') emit(a[0]);
+ else if (kind === 'flow') { const mm = machines[a[0]]; if (mm) mm.flow += a[1]; }
+ else if (kind === 'moist') absorbSteam(a[0]);
+ else if (kind === 'lost') partLost(a[0]);
+ else if (kind === 'broken') brokenMachines.push(a[0]);
+ else if (kind === 'ignite') stats.ignitions++;
+ else if (kind === 'liquid') {
+ const i = a[0];
+ if (stamp[i] !== tickStamp && mat[i] === a[1]) liquidSearch(i, i % W, (i / W) | 0, ...a.slice(1));
+ }
+ }
+}
+function chunkPasses(heat) {
+ for (let color = 0; color < 4; color++) {
+ const chunks = [];
+ for (let c = 0; c < NCH; c++) {
+ if (((c % CW & 1) | (((c / CW) & 1) << 1)) !== color) continue;
+ if (heat ? (!hot[c] || (hotLvl[c] === 1 && ((tick + c) & 3) !== 0)) : !awake[c]) continue;
+ chunks.push(c);
+ }
+ if (!chunks.length) continue;
+ const pass = color + (heat ? 4 : 0);
+ if (simPool && simPool.world === mat && chunks.length >= (simPool.force ? 1 : (heat ? 16 : 64))) threadPass(simPool, chunks, pass);
+ else { const work = inlineChunkWork(); runChunks(chunks, pass, work); mergeChunkWork([work]); }
+ }
+}
+function threadParams() {
+ return { tick,tickStamp,worldSeed,LSTEP,LFILM,SORTP,CORNER_SEAL,
+ seen, bot: {...bot}, skyY: weather.skyY, barriers: !!cleatCells.length, filterCfg };
+}
+function threadSetParams(s) {
+ ({tick,tickStamp,worldSeed,LSTEP,LFILM,SORTP,CORNER_SEAL,filterCfg} = s);
+ seen.set(s.seen); Object.assign(bot, s.bot);
+ weather.skyY = s.skyY; cleatCells = s.barriers ? [1] : [];
+}
+// Transport is injected by the browser or Node harness; no browser globals in the simulation.
+function threadSlot() {
+ // shortcut: 1 MiB of events per helper per pass; grow or stream it if dense reactions fill it.
+ return { control: sharedArray(Int32Array, 2), bytes: sharedArray(Uint8Array, 1024 * 1024),
+ wake: sharedArray(Uint8Array, NCH), heat: sharedArray(Uint8Array, NCH),
+ level: sharedArray(Uint8Array, NCH), ownHot: sharedArray(Uint8Array, NCH),
+ ownLevel: sharedArray(Uint8Array, NCH) };
+}
+function threadExecute(slot, chunks, pass) {
+ slot.wake.fill(0); slot.heat.fill(0); slot.level.fill(0); slot.ownHot.fill(255);
+ const work = { ...slot, events: [], c: -1 };
+ runChunks(chunks, pass, work);
+ const encoded = new TextEncoder().encode(JSON.stringify(work.events));
+ if (encoded.length > slot.bytes.length) throw Error('Simulation event buffer full');
+ slot.bytes.set(encoded); Atomics.store(slot.control, 1, encoded.length);
+}
+function threadPass(pool, chunks, pass) {
+ const params = threadParams(), n = Math.min(pool.slots.length, (pool.force ? chunks.length : Math.max(1, Math.floor(chunks.length / (pass < 4 ? 32 : 8)))));
+ pool.activeMax = Math.max(pool.activeMax || 0, n);
+ const batches = Array.from({length:n + 1}, () => []);
+ for (let k = 0; k < chunks.length; k++) batches[k % batches.length].push(chunks[k]);
+ for (let k = 0; k < n; k++) {
+ const slot = pool.slots[k]; Atomics.store(slot.control, 0, 0);
+ pool.workers[k].postMessage({ chunks: batches[k], pass, params });
+ }
+ const local = inlineChunkWork(); runChunks(batches[n], pass, local);
+ const works = [local], deadline = Date.now() + 10000;
+ for (let k = 0; k < n; k++) {
+ const slot = pool.slots[k];
+ threadWait(slot, deadline);
+ const events = JSON.parse(new TextDecoder().decode(slot.bytes.slice(0, Atomics.load(slot.control, 1))));
+ if (Atomics.load(slot.control, 0) < 0) throw Error('Simulation worker: ' + events);
+ works.push({ ...slot, events });
+ }
+ mergeChunkWork(works);
+}
+function threadWait(slot, deadline) {
+ let spins = 0;
+ // Atomics.wait is forbidden on the browser main thread. Bound polling to fail closed.
+ while (Atomics.load(slot.control, 0) === 0) {
+ if (++spins === 16384) { spins = 0; if (Date.now() > deadline) throw Error('Simulation worker timed out'); }
+ }
+}
+function threadFailure(slot, error) {
+ const bytes = new TextEncoder().encode(JSON.stringify(String(error.stack || error)));
+ slot.bytes.set(bytes.subarray(0, slot.bytes.length));
+ Atomics.store(slot.control, 1, Math.min(bytes.length, slot.bytes.length));
+ Atomics.store(slot.control, 0, -1); Atomics.notify(slot.control, 0);
+}
+function threadMessage(data) {
+ if (data.world) { threadBind(data.world); globalThis.threadWorkerSlot = data.slot; Atomics.store(data.slot.control, 0, 1); return; }
+ const slot = globalThis.threadWorkerSlot;
+ try {
+ threadSetParams(data.params); threadExecute(slot, data.chunks, data.pass);
+ Atomics.store(slot.control, 0, 1); Atomics.notify(slot.control, 0);
+ } catch (e) { threadFailure(slot, e); }
+}
diff --git a/src/sim/world.js b/src/sim/world.js
index a43e042..33ee4d6 100644
--- a/src/sim/world.js
+++ b/src/sim/world.js
@@ -29,36 +29,41 @@ let seen = new Uint8Array(NMAT);
let ledger = {};
const stats = { dug: 0, moved: 0, placed: 0, ignitions: 0, cast: 0, bars: 0 };
-function emit(e) { if (EV.length < 400) EV.push(e); }
+function emit(e) { if (chunkWork) { chunkEvent('emit', e); return; } if (EV.length < 400) EV.push(e); }
function bagCap() { return TANK_TIERS[player.tankTier].cap; }
// Things count as discovered only when they happen near the player (or in the bag).
// how: 'dig', 'pickup', 'melt', 'smelt', 'freeze', 'boil', 'ignite', 'burn', 'fire', 'blow', 'condense'
function discover(m, x, y, from, how, T) {
if (seen[m]) return;
if (x !== undefined) { const dx = x - bot.x, dy = y - bot.y; if (dx * dx + dy * dy > 110 * 110) return; }
+ if (chunkWork) { chunkEvent('discover', m, x, y, from, how, T); return; }
seen[m] = 1; emit({ t: 'discover', m, from: from || 0, how: how || '', T: T || 0 });
}
+function sharedArray(Type, length) {
+ const Buffer = typeof SharedArrayBuffer === 'function' ? SharedArrayBuffer : ArrayBuffer;
+ return new Type(new Buffer(length * Type.BYTES_PER_ELEMENT));
+}
function alloc(w, h) {
resetDevConsole();
W = w; H = h; N = w * h;
fogAlloc();
CW = Math.ceil(w / 32); CH = Math.ceil(h / 32); NCH = CW * CH;
- cleatMask = new Uint8Array(N); cleatCells = [];
- mat = new Uint8Array(N); temp = new Float32Array(N); life = new Uint8Array(N); flags = new Uint8Array(N);
- stamp = new Uint8Array(N); part = new Uint8Array(N); mach = new Uint16Array(N); digp = new Uint8Array(N);
- noiseB = new Uint8Array(N); bgm = new Uint8Array(N); airS = new Uint16Array(N); netId = new Uint16Array(N); isPow = new Uint8Array(N); occ = new Uint8Array(N);
- BW = Math.ceil(w / BS); BH = Math.ceil(h / BS); NB = BW * BH; blockP = new Uint8Array(NB); blockV = new Uint8Array(NB); slopePhase = new Uint8Array(NB); filtersDirty = true; filterCfg = new Map(); procDirty = true; procState.clear(); rackState.clear();
+ cleatMask = sharedArray(Uint8Array, N); cleatCells = [];
+ mat = sharedArray(Uint8Array, N); temp = sharedArray(Float32Array, N); life = sharedArray(Uint8Array, N); flags = sharedArray(Uint8Array, N);
+ stamp = sharedArray(Uint8Array, N); part = sharedArray(Uint8Array, N); mach = sharedArray(Uint16Array, N); digp = sharedArray(Uint8Array, N);
+ noiseB = sharedArray(Uint8Array, N); bgm = sharedArray(Uint8Array, N); airS = sharedArray(Uint16Array, N); netId = sharedArray(Uint16Array, N); isPow = sharedArray(Uint8Array, N); occ = sharedArray(Uint8Array, N);
+ BW = Math.ceil(w / BS); BH = Math.ceil(h / BS); NB = BW * BH; blockP = sharedArray(Uint8Array, NB); blockV = sharedArray(Uint8Array, NB); slopePhase = sharedArray(Uint8Array, NB); filtersDirty = true; filterCfg = new Map(); procDirty = true; procState.clear(); rackState.clear();
ents = []; flyers = []; ghostBlocks = new Map(); ghostMolds = []; treeBases = []; saplings = [];
surfBase = Math.round(Math.min(h * 0.27, 175)); underD = h - surfBase; shallowD = 0; lapse = 0.36;
- surfY = new Int16Array(w); roofY = new Int16Array(w); dirtY = new Int16Array(w); graniteY = new Int16Array(w); basaltY = new Int16Array(w); magmaY = new Int16Array(w);
- AMB = new Float32Array(h);
+ surfY = sharedArray(Int16Array, w); roofY = sharedArray(Int16Array, w); dirtY = sharedArray(Int16Array, w); graniteY = sharedArray(Int16Array, w); basaltY = sharedArray(Int16Array, w); magmaY = sharedArray(Int16Array, w);
+ AMB = sharedArray(Float32Array, h);
loadWakeOrder = loadWakePending = null; loadWakeIndex = 0;
- renderDirty = new Uint8Array(NCH).fill(1);
- cleatChunks = new Uint8Array(NCH);
- awake = new Uint8Array(NCH); hot = new Uint8Array(NCH); hotLvl = new Uint8Array(NCH);
- roofDirty = new Uint8Array(CW).fill(1);
- powderArea = new Uint8Array(NCH).fill(1); // unknown areas get one visit before being ruled out
+ renderDirty = sharedArray(Uint8Array, NCH).fill(1);
+ cleatChunks = sharedArray(Uint8Array, NCH);
+ awake = sharedArray(Uint8Array, NCH); hot = sharedArray(Uint8Array, NCH); hotLvl = sharedArray(Uint8Array, NCH);
+ roofDirty = sharedArray(Uint8Array, CW).fill(1);
+ powderArea = sharedArray(Uint8Array, NCH).fill(1); // unknown areas get one visit before being ruled out
machines = [null]; molds = []; nets = [null]; pcells = new Set(); powerDirty = true;
beltL = []; beltR = []; screwLists = Array.from({ length: IS_SCREW.length }, () => []); brokenMachines = [];
ghostMachines = []; ghostBlocked = new Set();
@@ -93,21 +98,26 @@ function fillNoise() {
// ---------- Waking ----------
function renderDirtyXY(x, y) { renderDirty[(y >> 5) * CW + (x >> 5)] = 1; }
+function renderDirtyChunk(c) {
+ const cx = c % CW, cy = (c / CW) | 0;
+ for (let yy = Math.max(0, cy - 1); yy <= Math.min(CH - 1, cy + 1); yy++)
+ for (let xx = Math.max(0, cx - 1); xx <= Math.min(CW - 1, cx + 1); xx++) renderDirty[yy * CW + xx] = 1;
+}
function renderDirtyIdx(i) { renderDirtyXY(i % W, (i / W) | 0); }
function wakeXY(x, y) {
- renderDirtyXY(x, y);
const cx = x >> 5, cy = y >> 5, c = cy * CW + cx;
+ if (!chunkWork) renderDirty[c] = 1;
roofDirty[cx] = 1;
- awake[c] = 3;
+ chunkWake(c);
const lx = x & 31, ly = y & 31;
- if (lx === 0) { if (cx > 0) awake[c - 1] = 3; } else if (lx === 31 && cx < CW - 1) awake[c + 1] = 3;
- if (ly === 0) { if (cy > 0) awake[c - CW] = 3; } else if (ly === 31 && cy < CH - 1) awake[c + CW] = 3;
+ if (lx === 0) { if (cx > 0) chunkWake(c - 1); } else if (lx === 31 && cx < CW - 1) chunkWake(c + 1);
+ if (ly === 0) { if (cy > 0) chunkWake(c - CW); } else if (ly === 31 && cy < CH - 1) chunkWake(c + CW);
}
function wakeHeatXY(x, y) {
- renderDirtyXY(x, y);
const c = (y >> 5) * CW + (x >> 5);
+ if (!chunkWork) renderDirty[c] = 1;
roofDirty[x >> 5] = 1;
- hot[c] = 6; hotLvl[c] = 2;
+ chunkHeat(c);
}
function wakeIdx(i) { wakeXY(i % W, (i / W) | 0); }
function wakeHeatIdx(i) { wakeHeatXY(i % W, (i / W) | 0); }
diff --git a/src/ui/loop.js b/src/ui/loop.js
index 9c0455d..739c182 100644
--- a/src/ui/loop.js
+++ b/src/ui/loop.js
@@ -40,7 +40,13 @@ function frame(now) {
GAME.padRun = pm > 0.92 ? GAME.padRun + 1 : 0;
botStep(ix, iy, keys.has('Shift') || GAME.padRun > 24);
toolTick();
- const s0 = performance.now(); simTick(); const elapsed = performance.now() - s0;
+ const s0 = performance.now();
+ try { simTick(); }
+ catch (e) {
+ GAME.paused = true; acc = 0; console.error(e);
+ toast('The world stopped updating. Reload this world to try again.', {big:true}); break;
+ }
+ const elapsed = performance.now() - s0;
simMs = simMs * 0.9 + elapsed * 0.1; frameSim += elapsed; frameSteps++;
if (devPaused) devSteps--; else acc -= step;
steps++;
diff --git a/src/ui/settings.js b/src/ui/settings.js
index e04f68c..d1389c9 100644
--- a/src/ui/settings.js
+++ b/src/ui/settings.js
@@ -29,6 +29,7 @@ const SET_TABS = {
{ note: 'Upscaling (DLSS, FSR), frame generation and hardware ray tracing need native graphics APIs that browsers do not expose. They are planned for the compiled build.' },
],
Simulation: [
+ { k: 'workerCap', label: 'CPU workers', d: 'Maximum helpers for grains and heat. Applies when you next load a world.', type: 'range', min: 1, max: 16, step: 1, fmt: v => v },
{ k: 'simSpeed', label: 'Simulation speed', type: 'select', opts: [[0.5, '0.5×'], [1, '1×'], [2, '2×']] },
{ k: 'maxSteps', label: 'Catch-up steps per frame', d: 'How hard the game tries to keep real time when frames are slow.', type: 'range', min: 1, max: 4, step: 1, fmt: v => v },
{ note: 'The whole world keeps simulating, wherever you are. Your factories keep running when you fly away. Areas where nothing moves or changes temperature go to sleep and cost almost nothing; busy areas cost the same near you or far away.' },
diff --git a/src/ui/state.js b/src/ui/state.js
index d12a0f2..ca7ebdb 100644
--- a/src/ui/state.js
+++ b/src/ui/state.js
@@ -5,7 +5,7 @@
const SET_DEFAULT = {
zoom: 12, integerScale: true, renderScale: 1, uiScale: 1, fpsCap: 0, showPerf: false, shake: true, cameraSmooth: 0.7, cameraLead: true,
look: 'smooth', lighting: 'full', lightEvery: 1, ambient: 0.12, daylight: 1, glow: 'low', grain: 1, incandescence: true, particles: true,
- minimap: true, minimapZoom: 4, fog: true, thermalPalette: 'inferno', simSpeed: 1, maxSteps: 3,
+ minimap: true, minimapZoom: 4, fog: true, thermalPalette: 'inferno', simSpeed: 1, maxSteps: 3, workerCap: 16,
volMaster: 0.8, volMusic: 0.5, volSfx: 0.8, muted: false, muteUnfocused: true,
deadzone: 0.18, aimSpeed: 1, autosave: 3, cardSeconds: 9, cursorLabel: 'ores', sv: 2,
};
diff --git a/src/ui/threads.js b/src/ui/threads.js
new file mode 100644
index 0000000..2448a38
--- /dev/null
+++ b/src/ui/threads.js
@@ -0,0 +1,39 @@
+// Browser transport only; the simulation owns the schedule and shared state.
+let threadStarting = null;
+async function startThreads() {
+ const count = Math.max(1, Math.min(16, Math.floor(SET.workerCap) || 16, (navigator.hardwareConcurrency || 3) - 2));
+ if (simPool && simPool.world === mat && simPool.workers.length === count) return;
+ if (threadStarting) await threadStarting;
+ if (simPool && simPool.world === mat && simPool.workers.length === count) return;
+ threadStarting = (async () => {
+ if (simPool) for (const worker of simPool.workers) worker.terminate();
+ simPool = null;
+ if (!(mat.buffer instanceof SharedArrayBuffer)) throw Error('Shared memory is unavailable');
+ const url = URL.createObjectURL(new Blob([SIM_WORKER_SOURCE, '\nonmessage=e=>threadMessage(e.data);'], {type:'text/javascript'}));
+ const pool = { world: mat, workers: [], slots: [] };
+ try {
+ const ready = [];
+ for (let k = 0; k < count; k++) {
+ const worker = new Worker(url), slot = threadSlot();
+ pool.workers.push(worker); pool.slots.push(slot);
+ ready.push(new Promise((resolve,reject) => {
+ const deadline = performance.now() + 10000;
+ worker.onerror = e => {
+ const error = Error(e.message || 'Simulation worker failed');
+ threadFailure(slot, error); reject(error);
+ };
+ const poll = () => {
+ if (Atomics.load(slot.control,0)) resolve();
+ else if (performance.now() > deadline) reject(Error('Simulation worker startup timed out'));
+ else setTimeout(poll, 1);
+ };
+ poll();
+ }));
+ worker.postMessage({world:threadWorld(),slot});
+ }
+ await Promise.all(ready); simPool = pool;
+ } catch (e) { for (const worker of pool.workers) worker.terminate(); throw e; }
+ finally { URL.revokeObjectURL(url); }
+ })();
+ try { await threadStarting; } finally { threadStarting = null; }
+}
diff --git a/src/ui/title.js b/src/ui/title.js
index d2a06e0..b99f8a8 100644
--- a/src/ui/title.js
+++ b/src/ui/title.js
@@ -1,6 +1,6 @@
// Startup capabilities and title art.
const REQUIRE_WEBGPU = true;
-const REQUIRE_THREADS = false;
+const REQUIRE_THREADS = true;
const CAPS = { isolated: self.crossOriginIsolated === true, webgpu: false, adapterInfo: null, ready: false };
async function checkCapabilities() {
try {
diff --git a/test/fixtures/golden-world-640x384.json.gz b/test/fixtures/golden-world-640x384.json.gz
new file mode 100644
index 0000000..5023b19
Binary files /dev/null and b/test/fixtures/golden-world-640x384.json.gz differ
diff --git a/test/fixtures/golden-world-640x512.json.gz b/test/fixtures/golden-world-640x512.json.gz
new file mode 100644
index 0000000..f189302
Binary files /dev/null and b/test/fixtures/golden-world-640x512.json.gz differ
diff --git a/test/fixtures/heat-chunk.js b/test/fixtures/heat-chunk.js
index 8857653..e08695c 100644
--- a/test/fixtures/heat-chunk.js
+++ b/test/fixtures/heat-chunk.js
@@ -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; }
}
diff --git a/test/harness.js b/test/harness.js
index 6292197..39e65aa 100644
--- a/test/harness.js
+++ b/test/harness.js
@@ -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;
diff --git a/test/t_caps.py b/test/t_caps.py
index f3c80b8..d08cc79 100644
--- a/test/t_caps.py
+++ b/test/t_caps.py
@@ -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')
diff --git a/test/t_conveyors.js b/test/t_conveyors.js
index 4ccaafb..7c74f0a 100644
--- a/test/t_conveyors.js
+++ b/test/t_conveyors.js
@@ -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`);
diff --git a/test/t_corner_seal.js b/test/t_corner_seal.js
index 9fbb981..10b0479 100644
--- a/test/t_corner_seal.js
+++ b/test/t_corner_seal.js
@@ -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=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;');
diff --git a/test/t_gpu.py b/test/t_gpu.py
index 9a5003c..74bc1b2 100644
--- a/test/t_gpu.py
+++ b/test/t_gpu.py
@@ -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=[];
diff --git a/test/t_gpu_golden.py b/test/t_gpu_golden.py
index 6566279..54375fe 100644
--- a/test/t_gpu_golden.py
+++ b/test/t_gpu_golden.py
@@ -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']:
diff --git a/test/t_load.js b/test/t_load.js
index 6308373..fb69008 100644
--- a/test/t_load.js
+++ b/test/t_load.js
@@ -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);');
diff --git a/test/t_render_dirty.js b/test/t_render_dirty.js
index 56b64fd..d270425 100644
--- a/test/t_render_dirty.js
+++ b/test/t_render_dirty.js
@@ -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();
diff --git a/test/t_sim_optimizations.js b/test/t_sim_optimizations.js
index f27d1f2..b068910 100644
--- a/test/t_sim_optimizations.js
+++ b/test/t_sim_optimizations.js
@@ -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();
diff --git a/test/t_slope_belts.js b/test/t_slope_belts.js
index b8e1974..7d837f8 100644
--- a/test/t_slope_belts.js
+++ b/test/t_slope_belts.js
@@ -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));
diff --git a/test/t_threads.js b/test/t_threads.js
new file mode 100644
index 0000000..5e1e4a4
--- /dev/null
+++ b/test/t_threads.js
@@ -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 {
+ 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;});
diff --git a/tools/perf-measure.js b/tools/perf-measure.js
index b027c83..6928584 100644
--- a/tools/perf-measure.js
+++ b/tools/perf-measure.js
@@ -13,20 +13,25 @@ function perfWrap(names, data) {
}
return () => { for (const name of names) window[name] = originals[name]; };
}
-function perfSim(names, scene, w, h) {
+async function perfSim(names, scene, w, h) {
// Five full periods warm even the 120-tick cold pass without forcing conditional calls.
for (let k = 0; k < 600; k++) simTick();
perfScene(scene, w, h);
+ await startThreads();
const start = perfState(), data = {}, restore = perfWrap(names, data), n = 240;
+ const threadData = {}, restoreThread = perfWrap(['threadWait'], threadData);
+ if (simPool) simPool.activeMax = 0;
const t = performance.now();
try { for (let k = 0; k < n; k++) simTick(); }
- finally { restore(); }
+ finally { restore(); restoreThread(); }
const total = (performance.now() - t) / n;
const steps = {};
for (const [name, d] of Object.entries(data)) steps[name] = {
ms_per_tick: d.ms / n, ms_per_call: d.calls ? d.ms / d.calls : null, calls: d.calls
};
- return { total_ms: total, ticks: n, start, end: perfState(), steps,
+ return { total_ms: total, main_thread_ms: total, polling_ms: threadData.threadWait.ms / n,
+ main_compute_ms: total - threadData.threadWait.ms / n,
+ workers: simPool ? simPool.workers.length : 0, active_workers: simPool ? simPool.activeMax : 0, ticks: n, start, end: perfState(), steps,
rest_ms: total - Object.values(data).reduce((s, d) => s + d.ms / n, 0) };
}
async function perfRender(lighting, overlays) {
diff --git a/tools/perf.py b/tools/perf.py
index a5a927b..5f7e780 100644
--- a/tools/perf.py
+++ b/tools/perf.py
@@ -74,6 +74,9 @@ def report(result, baseline=None):
print(f'{name:24}' + ''.join(f'{v:12.4f}' for v in values))
for scene, d in result['scenes'].items():
s = d['sim']
+ print(f" {scene}: wall {s['total_ms']:.3f}, main occupied {s.get('main_thread_ms',s['total_ms']):.3f}, "
+ f"main compute {s.get('main_compute_ms',s['total_ms']):.3f}, polling {s.get('polling_ms',0):.3f} ms/tick; "
+ f"workers {s.get('workers',0)} (active peak {s.get('active_workers',0)})")
print(f" {scene}: awake {s['start']['awake']} -> {s['end']['awake']}, hot {s['start']['hot']} -> {s['end']['hot']} / {s['end']['chunks']}")
print('\nReal loop: frame interval / CPU frame work / sim per frame, p50/p95/p99 ms')
for scene, d in result['scenes'].items():
@@ -174,7 +177,7 @@ def main():
raise RuntimeError('Refusing a software-rendered baseline')
result['machine']['browser_version'] = browser.version
def setup(scene):
- return pg.evaluate('([s,w,h]) => perfScene(s,w,h)', [scene, *args.world])
+ return pg.evaluate('async ([s,w,h]) => { const state=perfScene(s,w,h); await startThreads(); return state; }', [scene, *args.world])
for scene in scenes:
print(f'Measuring {scene}...', file=sys.stderr, flush=True)
setup(scene)