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)