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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 03:23:43 -04:00
co-authored by Claude Opus 5.5
parent 20963c776e
commit 7468d00efb
35 changed files with 578 additions and 113 deletions
+4
View File
@@ -19,6 +19,10 @@ python3 build.py
python3 tools/serve.py # builds, serves at http://localhost:8000 and rebuilds when src/ changes 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 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. server that sends these headers. Opening `dist/index.html` from disk is no longer supported.
Use HTTPS when hosting remotely (localhost works over HTTP). Use HTTPS when hosting remotely (localhost works over HTTP).
+5 -1
View File
@@ -35,9 +35,13 @@ def main():
shell = (src / man['shell']).read_text() shell = (src / man['shell']).read_text()
css, _ = join(man['css']) css, _ = join(man['css'])
js, starts = join(man['sim'] + man['client']) 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 = '<script>const SIM_WORKER_SOURCE=' + json.dumps(sim).replace('</', '<\\/') + ';</script>'
head, tail = shell.split('//__JS__') head, tail = shell.split('//__JS__')
body = head.replace('/*__CSS__*/', css) body = head.replace('/*__CSS__*/', css)
page = body + js + tail page = body + js + tail
page = worker + page
pre = ('<!doctype html><html><head><meta charset="utf-8">' pre = ('<!doctype html><html><head><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">' '<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">'
'</head><body>') '</head><body>')
@@ -46,7 +50,7 @@ def main():
(dist / 'deepgrain-foundry.html').write_text(page) (dist / 'deepgrain-foundry.html').write_text(page)
(dist / 'index.html').write_text(full) (dist / 'index.html').write_text(full)
# the script starts on this line of index.html (the doctype prefix has no newlines) # 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( (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') {'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)') print(f'built dist/index.html ({len(full):,} bytes, {len(starts)} source files)')
+19 -8
View File
@@ -244,9 +244,9 @@ Starter furnace designs and the milestone list shown in the notebook.
- functions: smelterBlueprint - functions: smelterBlueprint
- variables: STARTER_DESIGNS, MILESTONES - 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. 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 - 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) ### 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 - functions: fogAlloc, fogMark, fogSeen, fogInit, fogLook, fogRevealAround, fogRevealAll
- variables: FOG, FOG_SENSE, FOG_SENSE_FADE, fog, FW, FH - 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). 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 - 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. 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 - functions: curAir, airflowAt, alloyMix, airy, hasO2, condPair, heatPass, heatChunk, neighborDir, react, char, quench, burningNear, burn, steelCheck
- variables: POROUS, SOIL, HOTLIQ, REACT_MIN, ALLOY - 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 - functions: wrnd, resetWeather, rainGap, worldWater, absorbSteam, spawnRain, rainAt, weatherTick
- variables: WBAND, WS, weather - 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) ### src/sim/tick.js (29 lines)
simTick: one simulation step, in order. simTick: one simulation step, in order.
- functions: simTick - 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 - functions: gpuSmoothInit, gpuSmoothTextures, gpuSmoothBindings, gpuSmoothUpload, gpuSmoothMips
- variables: GPU_FIELD_MIP - 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. All pipelines compile during startup; rendering never falls back.
- functions: gpuLimitsOK, gpuInit, gpuFail, gpuRecover, gpuWarmWorld, worldPixelContext - functions: gpuLimitsOK, gpuInit, gpuFail, gpuRecover, gpuWarmWorld, worldPixelContext
- variables: GPU_LIMITS, GPU - 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 - functions: loadSettings, saveSettings, el, esc, rgbOf, fmtT, toast, setStatus
- variables: SET_DEFAULT, SET, TOOLS, ICONS, BAG_ORDER, GAME, keys, hudDirty, $ - 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) ### src/ui/title.js (77 lines)
Startup capabilities and title art. Startup capabilities and title art.
- functions: checkCapabilities, renderCapabilityNotice, canStartGame, buildTitleCells, drawTitle - 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. 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 - 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. The settings screen.
- functions: openSettings, renderSettings, applySettings, toggleFullscreen, confirmWipe - functions: openSettings, renderSettings, applySettings, toggleFullscreen, confirmWipe
- variables: SET_TABS, setTab - 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. Browser-only clocks; simulation code stays usable without a browser or a clock.
- variables: PERF_MARKS, PERF_FRAME, perfMarks, perfTimes, perfLast - 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. The frame loop and boot.
- functions: scanVisible, frame, boot - functions: scanVisible, frame, boot
- variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT - 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_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_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_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_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_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. - `test/t_world.js`: Trees from acorns, charcoal, brick, density, water/lava/snow/ice, full-bag refunds, drill reach, ballistic blowing, snowy mountains.
+15 -3
View File
@@ -49,7 +49,7 @@ done(); // prints ALL PASS or N FAILED, sets the
- A new suite named `test/t_<topic>.js` is picked up by `run_all.py` automatically. - A new suite named `test/t_<topic>.js` is picked up by `run_all.py` automatically.
**Determinism.** The simulation's randomness comes from seeded generators. The same code and the **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: refactor changed nothing, save every suite's output before and after, and diff them:
```sh ```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 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 ## Browser tests
- They need the `.venv` from README.md → Develop; plain `python3` switches to it on its own. - 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, 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 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 (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 - `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 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 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 - `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. 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 - `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. checks that a browser without WebGPU shows the notice and cannot start a world.
- Install Firefox with `.venv/bin/python -m playwright install firefox`. - 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 `python3 test/firefox_smoke.py` checks isolation and the WebGPU notice, starts a pixel world
+82
View File
@@ -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 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. 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) ## 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 - Per-32x32-chunk `renderDirty` marks cell writes, heat with its conduction halo, stationary
+2
View File
@@ -37,6 +37,7 @@
"sim/bot.js", "sim/bot.js",
"sim/ledger.js", "sim/ledger.js",
"sim/weather.js", "sim/weather.js",
"sim/workers.js",
"sim/tick.js", "sim/tick.js",
"sim/worldgen-helpers.js", "sim/worldgen-helpers.js",
"sim/worldgen.js", "sim/worldgen.js",
@@ -66,6 +67,7 @@
"render/overlays.js", "render/overlays.js",
"ui/audio.js", "ui/audio.js",
"ui/state.js", "ui/state.js",
"ui/threads.js",
"ui/title.js", "ui/title.js",
"ui/saves.js", "ui/saves.js",
"ui/claims.js", "ui/claims.js",
+1
View File
@@ -68,6 +68,7 @@ async function gpuRecover(message) {
} }
// Submit both looks behind the loading screen, including their first real bindings. // Submit both looks behind the loading screen, including their first real bindings.
async function gpuWarmWorld() { async function gpuWarmWorld() {
await startThreads();
buildSky(); resizeRender(); snapCamera(); buildSky(); resizeRender(); snapCamera();
const look = SET.look, started = performance.now(); const look = SET.look, started = performance.now();
try { try {
+34 -38
View File
@@ -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); 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) { 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; let a = mat[i]; mat[i] = mat[j]; mat[j] = a;
const f = temp[i]; temp[i] = temp[j]; temp[j] = f; const f = temp[i]; temp[i] = temp[j]; temp[j] = f;
a = life[i]; life[i] = life[j]; life[j] = a; a = life[i]; life[i] = life[j]; life[j] = a;
@@ -20,16 +22,16 @@ function swapCells(i, j) {
stamp[i] = tickStamp; stamp[j] = tickStamp; stamp[i] = tickStamp; stamp[j] = tickStamp;
} }
function move(i, j, x, y, nx, ny) { function move(i, j, x, y, nx, ny) {
swapCells(i, j); swapCellData(i, j);
wakeXY(x, y); wakeXY(nx, ny); wakeXY(x, y); wakeXY(nx, ny);
if (ny === y && LFILM > 1 && PH[mat[j]] === PH_LIQUID && (y === 0 || PH[mat[i - W]] !== PH_LIQUID)) { 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 // 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) // 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 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; 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]; const d = temp[j] - AMB[ny];
if (d > 25 || d < -25) wakeHeatXY(nx, 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 // 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. // packs too tight for that, so a mixed furnace charge stays mixed until the fire gets in.
const t = mat[b]; 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 // 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) && if ((t === BELTL || t === BELTR || part[b] === P_FILTER) &&
!isSlopeBelt(blockP[((y + 1) >> 2) * BW + (x >> 2)])) return; !isSlopeBelt(blockP[((y + 1) >> 2) * BW + (x >> 2)])) return;
@@ -132,11 +134,18 @@ function liquid(i, x, y, m) {
if (rnd() > FLUID[m]) return; if (rnd() > FLUID[m]) return;
const sp = SPREAD[m] * (y === 0 || PH[mat[i - W]] !== PH_LIQUID ? LFILM : 1); const sp = SPREAD[m] * (y === 0 || PH[mat[i - W]] !== PH_LIQUID ? LFILM : 1);
const d0 = (RS & 4) ? 1 : -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; 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; const nx = x + d * s;
if (nx < 0 || nx >= W) break; 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; const j = i + d * s;
if (!passable(m, PH_LIQUID, j, d, 0)) break; if (!passable(m, PH_LIQUID, j, d, 0)) break;
if (y < H - 1 && passable(m, PH_LIQUID, j + W, 0, 1)) { 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) { function gas(i, x, y, m) {
// Even blocked gas must keep aging or condensing until it is gone. // Even blocked gas must keep aging or condensing until it is gone.
awake[(y >> 5) * CW + (x >> 5)] = 3; chunkWake((y >> 5) * CW + (x >> 5));
renderDirtyXY(x, y); // Gas can age without moving.
if (m === FIRE) { if (m === FIRE) {
const l = life[i]; const l = life[i];
if (l <= 1) { mat[i] = EMPTY; life[i] = 0; wakeXY(x, y); return; } 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; if (dx * dx + dy * dy <= 10000) awake[c] = 1;
} }
} }
function simMove() { function simMove() { chunkPasses(false); }
for (let cy = CH - 1; cy >= 0; cy--) { function moveChunk(c) {
const rowBase = cy * CW; const cx = c % CW, cy = (c / CW) | 0;
let any = false; const x0 = cx << 5, x1 = Math.min(W, x0 + 32), y0 = cy << 5, y1 = Math.min(H, y0 + 32);
for (let cx = 0; cx < CW; cx++) if (awake[rowBase + cx]) { any = true; powderArea[rowBase + cx] = 0; } const barriers = cleatChunks[c];
if (!any) continue; powderArea[c] = 0; roofDirty[cx] = 1; renderDirtyChunk(c);
const y0 = cy << 5, y1 = Math.min(H, y0 + 32); for (let y = y1 - 1; y >= y0; y--) {
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;
const ltr = ((y + tick) & 1) === 0; for (let x = ltr ? x0 : x1 - 1; x !== end; x += dx) {
const yw = y * W; const i = yw + x, m = mat[i];
for (let k = 0; k < CW; k++) { if (m === EMPTY) continue;
const cx = ltr ? k : CW - 1 - k; const ph = PH[m];
if (!awake[rowBase + cx]) continue; if (ph === PH_POWDER) powderArea[c] = 1;
roofDirty[cx] = 1; if (stamp[i] === tickStamp || ph === PH_SOLID) continue;
const x0 = cx << 5, x1 = Math.min(W, x0 + 32), barriers = cleatChunks[rowBase + cx]; if (IS_SCREW[part[i]] && ph === PH_POWDER) continue;
// alternate rows scan left to right and right to left, so nothing drifts one way if (ph === PH_POWDER) powder(i, x, y, m, barriers);
const dx = ltr ? 1 : -1, xEnd = ltr ? x1 : x0 - 1; else if (ph === PH_LIQUID) liquid(i, x, y, m); else gas(i, x, y, m);
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);
}
}
} }
} }
} }
+1 -1
View File
@@ -3,7 +3,7 @@ const FOG = 2;
const FOG_SENSE = 24; // Reveal nearby ground through rock, in grains. const FOG_SENSE = 24; // Reveal nearby ground through rock, in grains.
const FOG_SENSE_FADE = 12; const FOG_SENSE_FADE = 12;
let fog, FW, FH; 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) { function fogMark(x, y, v) {
if (x < 0 || y < 0 || x >= W || y >= H) return; if (x < 0 || y < 0 || x >= W || y >= H) return;
const i = Math.floor(y / FOG) * FW + Math.floor(x / FOG); const i = Math.floor(y / FOG) * FW + Math.floor(x / FOG);
+9 -14
View File
@@ -52,16 +52,9 @@ function condPair(a, b, sc, nc, da) {
const q = d * k * sc; const q = d * k * sc;
temp[a] -= q * ICAP[ma]; temp[a] -= q * ICAP[ma];
temp[b] += q * ICAP[mb]; temp[b] += q * ICAP[mb];
if (nc >= 0 && (q > 0.15 || q < -0.15)) { if (!hot[nc]) hotLvl[nc] = 2; hot[nc] = 6; } if (nc >= 0 && (q > 0.15 || q < -0.15)) { chunkHeat(nc, !hot[nc] ? 2 : 0); }
}
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);
}
} }
function heatPass() { chunkPasses(true); }
function heatChunk(c, sc) { function heatChunk(c, sc) {
const cx = c % CW, cy = (c / CW) | 0; 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); 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) { if ((pu === PH_EMPTY || pu === PH_GAS) && ex > 2) {
const a = ex * (sc > 1 ? 0.45 : 0.25); const a = ex * (sc > 1 ? 0.45 : 0.25);
t -= a; temp[u] += a; 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; rel = y < roofY[x] ? 0.02 : 0.002;
@@ -110,8 +103,10 @@ function heatChunk(c, sc) {
if (flags[i] & F_BURN) burning = true; if (flags[i] & F_BURN) burning = true;
} }
} }
if (burning || maxDev > 1.5) { hot[c] = 6; hotLvl[c] = (burning || maxDev > 60) ? 2 : 1; } const h = burning || maxDev > 1.5 ? 6 : hot[c] - 1;
else hot[c]--; 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) { function neighborDir(i, x, y, d) {
if (d === 0) return y > 0 ? i - W : -1; 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 (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))) { if (IGN[m] !== 0 && t >= IGN[m] && (hasO2(i, x, y) || burningNear(i, x, y))) {
flags[i] = fl | F_BURN; life[i] = FUEL[m]; 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; return;
} }
const ph = PH[m]; const ph = PH[m];
@@ -183,7 +178,7 @@ function react(i, x, y, m) {
if (rnd() < 0.03 + (t - mp) * 0.002) { if (rnd() < 0.03 + (t - mp) * 0.002) {
let to = MELTTO[m]; 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 (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; 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); wakeXY(x, y); discover(to, x, y, m, SMELTP[m] !== 0 ? 'smelt' : 'melt', mp);
} }
+1 -1
View File
@@ -5,7 +5,7 @@ function markPow(i, on) {
else if (isPow[i]) { isPow[i] = 0; pcells.delete(i); netId[i] = 0; } else if (isPow[i]) { isPow[i] = 0; pcells.delete(i); netId[i] = 0; }
powerDirty = true; powerDirty = true;
} }
function partLost(i) { markPow(i, false); } function partLost(i) { if (chunkWork) chunkEvent('lost', i); else markPow(i, false); }
let nextPowerBuild = 0; let nextPowerBuild = 0;
function rebuildPower() { function rebuildPower() {
powerDirty = false; powerDirty = false;
+1 -1
View File
@@ -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 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]); 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 // 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) { function spawnRain(x0, x1, k) {
if (flyers.length > 2500) return 0; if (flyers.length > 2500) return 0;
+153
View File
@@ -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); }
}
+29 -19
View File
@@ -29,36 +29,41 @@ let seen = new Uint8Array(NMAT);
let ledger = {}; let ledger = {};
const stats = { dug: 0, moved: 0, placed: 0, ignitions: 0, cast: 0, bars: 0 }; 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; } function bagCap() { return TANK_TIERS[player.tankTier].cap; }
// Things count as discovered only when they happen near the player (or in the bag). // 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' // how: 'dig', 'pickup', 'melt', 'smelt', 'freeze', 'boil', 'ignite', 'burn', 'fire', 'blow', 'condense'
function discover(m, x, y, from, how, T) { function discover(m, x, y, from, how, T) {
if (seen[m]) return; 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 (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 }); 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) { function alloc(w, h) {
resetDevConsole(); resetDevConsole();
W = w; H = h; N = w * h; W = w; H = h; N = w * h;
fogAlloc(); fogAlloc();
CW = Math.ceil(w / 32); CH = Math.ceil(h / 32); NCH = CW * CH; CW = Math.ceil(w / 32); CH = Math.ceil(h / 32); NCH = CW * CH;
cleatMask = new Uint8Array(N); cleatCells = []; cleatMask = sharedArray(Uint8Array, N); cleatCells = [];
mat = new Uint8Array(N); temp = new Float32Array(N); life = new Uint8Array(N); flags = new Uint8Array(N); mat = sharedArray(Uint8Array, N); temp = sharedArray(Float32Array, N); life = sharedArray(Uint8Array, N); flags = sharedArray(Uint8Array, N);
stamp = new Uint8Array(N); part = new Uint8Array(N); mach = new Uint16Array(N); digp = new Uint8Array(N); stamp = sharedArray(Uint8Array, N); part = sharedArray(Uint8Array, N); mach = sharedArray(Uint16Array, N); digp = sharedArray(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); 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 = new Uint8Array(NB); blockV = new Uint8Array(NB); slopePhase = new Uint8Array(NB); filtersDirty = true; filterCfg = new Map(); procDirty = true; procState.clear(); rackState.clear(); 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 = []; 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; 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); 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 = new Float32Array(h); AMB = sharedArray(Float32Array, h);
loadWakeOrder = loadWakePending = null; loadWakeIndex = 0; loadWakeOrder = loadWakePending = null; loadWakeIndex = 0;
renderDirty = new Uint8Array(NCH).fill(1); renderDirty = sharedArray(Uint8Array, NCH).fill(1);
cleatChunks = new Uint8Array(NCH); cleatChunks = sharedArray(Uint8Array, NCH);
awake = new Uint8Array(NCH); hot = new Uint8Array(NCH); hotLvl = new Uint8Array(NCH); awake = sharedArray(Uint8Array, NCH); hot = sharedArray(Uint8Array, NCH); hotLvl = sharedArray(Uint8Array, NCH);
roofDirty = new Uint8Array(CW).fill(1); roofDirty = sharedArray(Uint8Array, CW).fill(1);
powderArea = new Uint8Array(NCH).fill(1); // unknown areas get one visit before being ruled out 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; machines = [null]; molds = []; nets = [null]; pcells = new Set(); powerDirty = true;
beltL = []; beltR = []; screwLists = Array.from({ length: IS_SCREW.length }, () => []); brokenMachines = []; beltL = []; beltR = []; screwLists = Array.from({ length: IS_SCREW.length }, () => []); brokenMachines = [];
ghostMachines = []; ghostBlocked = new Set(); ghostMachines = []; ghostBlocked = new Set();
@@ -93,21 +98,26 @@ function fillNoise() {
// ---------- Waking ---------- // ---------- Waking ----------
function renderDirtyXY(x, y) { renderDirty[(y >> 5) * CW + (x >> 5)] = 1; } 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 renderDirtyIdx(i) { renderDirtyXY(i % W, (i / W) | 0); }
function wakeXY(x, y) { function wakeXY(x, y) {
renderDirtyXY(x, y);
const cx = x >> 5, cy = y >> 5, c = cy * CW + cx; const cx = x >> 5, cy = y >> 5, c = cy * CW + cx;
if (!chunkWork) renderDirty[c] = 1;
roofDirty[cx] = 1; roofDirty[cx] = 1;
awake[c] = 3; chunkWake(c);
const lx = x & 31, ly = y & 31; 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 (lx === 0) { if (cx > 0) chunkWake(c - 1); } else if (lx === 31 && cx < CW - 1) chunkWake(c + 1);
if (ly === 0) { if (cy > 0) awake[c - CW] = 3; } else if (ly === 31 && cy < CH - 1) awake[c + CW] = 3; if (ly === 0) { if (cy > 0) chunkWake(c - CW); } else if (ly === 31 && cy < CH - 1) chunkWake(c + CW);
} }
function wakeHeatXY(x, y) { function wakeHeatXY(x, y) {
renderDirtyXY(x, y);
const c = (y >> 5) * CW + (x >> 5); const c = (y >> 5) * CW + (x >> 5);
if (!chunkWork) renderDirty[c] = 1;
roofDirty[x >> 5] = 1; roofDirty[x >> 5] = 1;
hot[c] = 6; hotLvl[c] = 2; chunkHeat(c);
} }
function wakeIdx(i) { wakeXY(i % W, (i / W) | 0); } function wakeIdx(i) { wakeXY(i % W, (i / W) | 0); }
function wakeHeatIdx(i) { wakeHeatXY(i % W, (i / W) | 0); } function wakeHeatIdx(i) { wakeHeatXY(i % W, (i / W) | 0); }
+7 -1
View File
@@ -40,7 +40,13 @@ function frame(now) {
GAME.padRun = pm > 0.92 ? GAME.padRun + 1 : 0; GAME.padRun = pm > 0.92 ? GAME.padRun + 1 : 0;
botStep(ix, iy, keys.has('Shift') || GAME.padRun > 24); botStep(ix, iy, keys.has('Shift') || GAME.padRun > 24);
toolTick(); 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++; simMs = simMs * 0.9 + elapsed * 0.1; frameSim += elapsed; frameSteps++;
if (devPaused) devSteps--; else acc -= step; if (devPaused) devSteps--; else acc -= step;
steps++; steps++;
+1
View File
@@ -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.' }, { 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: [ 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: '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 }, { 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.' }, { 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.' },
+1 -1
View File
@@ -5,7 +5,7 @@
const SET_DEFAULT = { const SET_DEFAULT = {
zoom: 12, integerScale: true, renderScale: 1, uiScale: 1, fpsCap: 0, showPerf: false, shake: true, cameraSmooth: 0.7, cameraLead: true, 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, 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, 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, deadzone: 0.18, aimSpeed: 1, autosave: 3, cardSeconds: 9, cursorLabel: 'ores', sv: 2,
}; };
+39
View File
@@ -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; }
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Startup capabilities and title art. // Startup capabilities and title art.
const REQUIRE_WEBGPU = true; const REQUIRE_WEBGPU = true;
const REQUIRE_THREADS = false; const REQUIRE_THREADS = true;
const CAPS = { isolated: self.crossOriginIsolated === true, webgpu: false, adapterInfo: null, ready: false }; const CAPS = { isolated: self.crossOriginIsolated === true, webgpu: false, adapterInfo: null, ready: false };
async function checkCapabilities() { async function checkCapabilities() {
try { try {
Binary file not shown.
Binary file not shown.
+5 -3
View File
@@ -1,4 +1,4 @@
// Reference heat loop before t21 phase 3; preserves conduction and Float32 store order. // Unoptimized heat oracle, re-baselined for t88 chunk-pass heat outputs.
function referenceHeatChunk(c, sc) { function referenceHeatChunk(c, sc) {
const cx = c % CW, cy = (c / CW) | 0; 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); 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 (flags[i] & F_BURN) burning = true;
} }
} }
if (burning || maxDev > 1.5) { hot[c] = 6; hotLvl[c] = (burning || maxDev > 60) ? 2 : 1; } const h = burning || maxDev > 1.5 ? 6 : hot[c] - 1;
else hot[c]--; const level = burning || maxDev > 1.5 ? ((burning || maxDev > 60) ? 2 : 1) : hotLvl[c];
if (chunkWork) { chunkWork.ownHot[c] = h; chunkWork.ownLevel[c] = level; }
else { hot[c] = h; hotLvl[c] = level; }
} }
+31
View File
@@ -81,3 +81,34 @@ function dump(run, x0, y0, x1, y1, withTemp) {
} }
} }
module.exports.dump = dump; module.exports.dump = dump;
// Node uses exactly the browser worker entry point and scheduler.
async function enableThreads(run, count) {
const { Worker } = require('worker_threads');
const entry = `const {parentPort}=require('worker_threads');
require(${JSON.stringify(__filename)}).load();
parentPort.on('message', data=>threadMessage(data));`;
const pool = { world: run('mat'), workers: [], slots: [], force: true };
try {
const ready = [];
for (let k = 0; k < count; k++) {
const worker = new Worker(entry, {eval:true}), slot = run('threadSlot()');
pool.workers.push(worker); pool.slots.push(slot);
ready.push(new Promise((resolve,reject) => {
const deadline = Date.now()+10000;
worker.once('error',reject);
const poll = () => {
if (Atomics.load(slot.control,0)) resolve();
else if (Date.now()>deadline) reject(Error('Worker startup timed out'));
else setTimeout(poll,1);
};
poll();
}));
worker.postMessage({world:run('threadWorld()'),slot});
}
await Promise.all(ready);
globalThis.__testPool = pool; run('simPool = globalThis.__testPool;'); delete globalThis.__testPool;
return async () => { run('simPool=null;'); await Promise.all(pool.workers.map(w=>w.terminate())); };
} catch (e) { await Promise.all(pool.workers.map(w=>w.terminate())); throw e; }
}
module.exports.enableThreads = enableThreads;
+11 -2
View File
@@ -8,16 +8,18 @@ ensure_playwright()
from browser import capabilities, consume_gpu_option, launch from browser import capabilities, consume_gpu_option, launch
consume_gpu_option() 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): with launch() as (_, page, url):
errors = [] errors = []
page.on('pageerror', lambda e: errors.append(str(e))) 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}" gpu = 'undefined' if case == 'absent' else "{requestAdapter: async () => null}"
if case == 'reject': gpu = "{requestAdapter: async () => {throw new Error('adapter denied')}}" if case == 'reject': gpu = "{requestAdapter: async () => {throw new Error('adapter denied')}}"
if case == 'limits': gpu="{requestAdapter:async()=>({limits:{maxStorageTexturesPerShaderStage:3}})}" if case == 'limits': gpu="{requestAdapter:async()=>({limits:{maxStorageTexturesPerShaderStage:3}})}"
page.add_init_script(f"Object.defineProperty(navigator, 'gpu', {{value: {gpu}}});") page.add_init_script(f"Object.defineProperty(navigator, 'gpu', {{value: {gpu}}});")
page.add_init_script("Object.defineProperty(self, 'crossOriginIsolated', {value: false});") 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': if case == 'firefox':
page.add_init_script("Object.defineProperty(navigator, 'userAgent', {value: 'Firefox/157.0'});") page.add_init_script("Object.defineProperty(navigator, 'userAgent', {value: 'Firefox/157.0'});")
page.goto(url) page.goto(url)
@@ -26,6 +28,13 @@ for case in ['native', 'absent', 'null', 'reject', 'firefox', 'limits']:
if case == 'native': if case == 'native':
assert caps['isolated'] assert caps['isolated']
assert page.locator('#cap-notice').is_hidden() == caps['webgpu'] 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: else:
assert not caps['webgpu'] and caps['adapterInfo'] is None assert not caps['webgpu'] and caps['adapterInfo'] is None
notice = page.inner_text('#cap-notice') notice = page.inner_text('#cap-notice')
+2 -1
View File
@@ -123,7 +123,8 @@ const liftLanes = run('(() => { const s = new Set(); for (let y = 76; y < 80; y+
check('a lift turns its corner without squeezing down to one lane', liftLanes === 4 && lr > 30, `${lr.toFixed(0)}/s, ${liftLanes} lanes at the top`); 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(); fresh();
run('for (let bx = 20; bx < 32; bx++) placeBlock(BP_BELT, 1, bx, 25, true);'); 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); const br = rate(128, 319, 104, 119, 90, 300);
ticks(run, 0); ticks(run, 0);
check('a belt carries a pile off at several dozen grains a second', br > 50, `${br.toFixed(0)}/s`); check('a belt carries a pile off at several dozen grains a second', br > 50, `${br.toFixed(0)}/s`);
+6 -6
View File
@@ -5,13 +5,13 @@ const { run } = load();
const { check, done } = suite(); const { check, done } = suite();
for (const [seed, expected] of [ for (const [seed, expected] of [
[777, '040644fb50c891911383ef0206ea988677d7ec447a3e6637cefa99364e765861'], [777, 'b6f7b493e93a59e38ee57d33f24690daa87e7be0f4b356b32225a3a6f1a9d001'],
[77, '2e6af816af54c8de4fe9c7188607f7af503ef844b2087245779bcf39e8330bdb'], [77, '5e08c24603686c8617dd1156b10612b91a05fd9ee8b3648dfc4ee32fc008383a'],
[4242, 'f09270f4ac5da9347bc277c9959e31ee797313b4a994598b4647dcf852532782'], [4242, 'f7ca75d19eba9eae4894b21b95fe1b683bd54e1f65a415dcc296f3c23b5d7e05'],
]) { ]) {
run(`generateWorld(${seed}, 512, 384)`); run(`generateWorld(${seed}, 512, 384)`);
check(`seed ${seed} restores corner sealing`,run('CORNER_SEAL')===true); 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]) { 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; const pile = run(`(()=>{let x0=W,x1=0,y0=H,y1=0;
for(let y=0;y<H;y++)for(let x=0;x<W;x++)if(mat[y*W+x]===SAND){x0=Math.min(x0,x);x1=Math.max(x1,x);y0=Math.min(y0,y);y1=Math.max(y1,y);} for(let y=0;y<H;y++)for(let x=0;x<W;x++)if(mat[y*W+x]===SAND){x0=Math.min(x0,x);x1=Math.max(x1,x);y0=Math.min(y0,y);y1=Math.max(y1,y);}
return [x1-x0+1,y1-y0+1];})()`); return [x1-x0+1,y1-y0+1];})()`);
// Before the fix: 57 wide, 17 high. Small bounds allow random-state differences from prior scenes. // t88 chunk streams re-baseline this seeded pile to 46 wide and 19 high.
check('open-ground sand still forms the same size pile', pile[0]>=55 && pile[0]<=59 && pile[1]>=16 && pile[1]<=18 && count(run,run('SAND'))===400, pile); 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'); const sealedPile = createHash('sha256').update(run('mat')).digest('hex'), sealedRS=run('RS');
flatWorld(run,240,160,120); flatWorld(run,240,160,120);
run('RS=12345; CORNER_SEAL=false;'); run('RS=12345; CORNER_SEAL=false;');
+3 -3
View File
@@ -23,7 +23,7 @@ with launch(viewport={'width':960,'height':600}) as (_,pg,url):
assert startup['warm']>=0 assert startup['warm']>=0
print('PASS both looks submit behind loading screen; first visible frame ms',startup) 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.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 () => { result=pg.evaluate('''async () => {
const results=[]; const results=[];
for(let n=0;n<240;n++) { 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();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) 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()=>{ parity=pg.evaluate("""async()=>{
const results=[]; const results=[];
+12 -2
View File
@@ -1,5 +1,5 @@
"""Frozen legacy references, captured before retirement; compare the WebGPU world only.""" """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')) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / 'tools'))
from use_venv import ensure_playwright; ensure_playwright() from use_venv import ensure_playwright; ensure_playwright()
from browser import launch, consume_gpu_option 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 scene in ['renderer','joins','sky','lamp','opening','magma','fire']:
for look in ['pixel','smooth']: for look in ['pixel','smooth']:
for zoom in [12,2]: 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' 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()+'}') pg.evaluate('() => {'+(ROOT / f'tools/scenes/{name}.js').read_text()+'}')
if scene not in ['renderer','joins','sky']: if scene not in ['renderer','joins','sky']:
+5 -1
View File
@@ -42,7 +42,11 @@ check('the settled water area goes back to sleep', run('awake[6 * CW + 1] === 0
run('deserializeWorld(loadSave.meta, loadSave.buffers); bot.x = 14; bot.y = 190;'); 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')); check('the destination is still waiting before moving wakes it', run('loadWakePending[6 * CW] === 2 && hot[6 * CW] === 0'));
ticks(run, 8); 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. // 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);'); run('globalThis.midLoadSave = serializeWorld(); deserializeWorld(midLoadSave.meta, midLoadSave.buffers);');
+3 -1
View File
@@ -12,7 +12,9 @@ check('revealing the whole map invalidates every chunk',marked('fogRevealAll();'
check('heat invalidates the chunk and conduction across its edges',marked('heatChunk(5,1);',64,40)&&run('!!renderDirty[1] && !!renderDirty[4] && !!renderDirty[9]')); check('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)); 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);'); 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);'); 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')); 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(); done();
+6
View File
@@ -52,4 +52,10 @@ for (const scale of [1, 4]) {
run(`for (let c = 1; c < NCH; c++) heatChunk(c, ${scale});`); run(`for (let c = 1; c < NCH; c++) heatChunk(c, ${scale});`);
check('heat matches original bytes and random state at scale ' + scale, snapshot() === expected); 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(); done();
+2 -1
View File
@@ -88,7 +88,8 @@ for(const grade of [-1,1]) {
for(const grade of [-1,1]) { for(const grade of [-1,1]) {
const s=line(grade,true); const s=line(grade,true);
run('for(const b of filterCfg.keys())setFilter(b,{m:[SAND],inv:1});'); 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); const r=collect(s,600,true);
console.log(`Filter throughput grade ${grade}, grains/s: ${r.out/10}`); 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)); check(`downhill screen grade ${grade} discharges its generous feed`,r.out/10>=45 && r.out/10<=90,JSON.stringify(r));
+75
View File
@@ -0,0 +1,75 @@
// Shared allocation, checkerboard independence, and real worker-count determinism.
const assert = require('assert');
const { load, flatWorld } = require('./harness');
const { run } = load();
flatWorld(run, 131, 129, 110);
assert(run('[mat,temp,life,flags,stamp,part,mach,digp,noiseB,bgm,airS,netId,isPow,occ,awake,hot,hotLvl,renderDirty,powderArea,cleatMask,cleatChunks,roofDirty,blockP,blockV,slopePhase,fog,AMB,roofY].every(a=>a.buffer instanceof SharedArrayBuffer)'));
console.log('PASS world arrays use shared memory');
const crypto = require('crypto');
const { enableThreads } = require('./harness');
flatWorld(run, 192, 96, 31);
run(`setCell(30*W+32,WATER,20,0);setCell(31*W+130,EMPTY,20,0);
tick++;tickStamp++;var draws=0,savedRnd=rnd;rnd=()=>{draws++;return .99};
var searchWork=inlineChunkWork();runChunks([1],0,searchWork);`);
assert(run('searchWork.events.some(e=>e[1]==="liquid")'), 'fixture must defer a long search');
run('mergeChunkWork([searchWork]);rnd=savedRnd;');
assert.strictEqual(run('draws'),1,'resuming a liquid search must not reroll its movement chance');
assert.strictEqual(run('mat[30*W+40]'),run('WATER'),'the resumed search finds the distant drop');
console.log('PASS long liquid search resumes without rerolling movement');
function setup() {
flatWorld(run, 387, 257, 214);
run(`{RS=123;WS=456;resetWeather();weather.next=1e15;seen.fill(0);ledger={};EV.length=0;for(const k of Object.keys(stats))stats[k]=0;
resetEconomy();nextPowerBuild=0;bot.x=185;bot.y=120;
const random=mulberry32(99), materials=[SAND,COAL,WATER,MAGMA,MCU,SNOW,LWOOD,STEAM,SMOKE];
for(let y=40;y<210;y++)for(let x=8;x<W-8;x++) {
if(random()<.68)continue;
const m=materials[(random()*materials.length)|0];
setCell(y*W+x,m,m===MAGMA?1450:m===MCU?1200:m===STEAM?110:20,0);
}
addMachine('wheel',40,35,0);
for(let x=0;x<W;x++)setCell(31*W+x,STONE,20,F_NAT);
setCell(31*W+W-10,EMPTY,20,0);
for(let x=32;x<W-10;x+=64)setCell(30*W+x,WATER,20,0);
for(let bx=12;bx<40;bx++)placeBlock(BP_FILTER,128,bx,48,true,{m:[SAND],inv:0});
for(let x=80;x<210;x++)setCell(120*W+x,COAL,1000,F_BURN);
hot.fill(6);hotLvl.fill(2);awake.fill(3);updateRoof();}
`);
}
function hash() {
const h = crypto.createHash('sha256');
for (const a of run('[mat,temp,life,flags,stamp,part,mach,digp,bgm,isPow,netId,awake,hot,hotLvl,powderArea,roofDirty,seen,renderDirty]'))
h.update(Buffer.from(a.buffer,a.byteOffset,a.byteLength));
h.update(run('JSON.stringify({RS,stats,EV,flyers,brokenMachines,pcells:[...pcells],powerDirty,weather,machines})'));
return h.digest('hex');
}
(async () => {
let expected;
for (const n of [0,1,2,8,8,2,1,0]) {
setup(); const stop = n ? await enableThreads(run,n) : null;
try {
run('for(let k=0;k<320;k++)simTick();');
const actual=hash();
if(!expected)expected=actual;
assert.strictEqual(actual,expected,`world and effects differ with ${n} workers`);
console.log(`PASS identical world and side effects with ${n} workers (${actual.slice(0,16)})`);
} finally { if(stop)await stop(); }
}
setup();
run('const forwardChunks=runChunks;runChunks=(chunks,pass,work)=>forwardChunks(chunks.slice().reverse(),pass,work);for(let k=0;k<320;k++)simTick();runChunks=forwardChunks;');
assert.strictEqual(hash(),expected,'chunk order within a colour must not matter');
console.log('PASS reversing chunks within every pass preserves all bytes and effects');
const {execFileSync}=require('child_process');
const fallback=execFileSync(process.execPath,['-e',`
global.SharedArrayBuffer=undefined;
const {load,flatWorld}=require(${JSON.stringify(require.resolve('./harness'))});
const {run}=load();flatWorld(run,64,64,50);
if(!run('mat.buffer instanceof ArrayBuffer'))throw Error('fallback allocation');
run('setCell(20*W+20,SAND,20,0);for(let k=0;k<60;k++)simTick();');
process.stdout.write(JSON.stringify(run('Array.from(mat)')));
`],{encoding:'utf8'});
flatWorld(run,64,64,50);
run('setCell(20*W+20,SAND,20,0);for(let k=0;k<60;k++)simTick();');
assert.strictEqual(JSON.stringify(run('Array.from(mat)')),fallback);
console.log('PASS Node ArrayBuffer fallback matches shared inline movement');
console.log('ALL PASS');
})().catch(e=>{console.error(e);process.exitCode=1;});
+8 -3
View File
@@ -13,20 +13,25 @@ function perfWrap(names, data) {
} }
return () => { for (const name of names) window[name] = originals[name]; }; 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. // Five full periods warm even the 120-tick cold pass without forcing conditional calls.
for (let k = 0; k < 600; k++) simTick(); for (let k = 0; k < 600; k++) simTick();
perfScene(scene, w, h); perfScene(scene, w, h);
await startThreads();
const start = perfState(), data = {}, restore = perfWrap(names, data), n = 240; 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(); const t = performance.now();
try { for (let k = 0; k < n; k++) simTick(); } try { for (let k = 0; k < n; k++) simTick(); }
finally { restore(); } finally { restore(); restoreThread(); }
const total = (performance.now() - t) / n; const total = (performance.now() - t) / n;
const steps = {}; const steps = {};
for (const [name, d] of Object.entries(data)) steps[name] = { 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 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) }; rest_ms: total - Object.values(data).reduce((s, d) => s + d.ms / n, 0) };
} }
async function perfRender(lighting, overlays) { async function perfRender(lighting, overlays) {
+4 -1
View File
@@ -74,6 +74,9 @@ def report(result, baseline=None):
print(f'{name:24}' + ''.join(f'{v:12.4f}' for v in values)) print(f'{name:24}' + ''.join(f'{v:12.4f}' for v in values))
for scene, d in result['scenes'].items(): for scene, d in result['scenes'].items():
s = d['sim'] 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(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') print('\nReal loop: frame interval / CPU frame work / sim per frame, p50/p95/p99 ms')
for scene, d in result['scenes'].items(): for scene, d in result['scenes'].items():
@@ -174,7 +177,7 @@ def main():
raise RuntimeError('Refusing a software-rendered baseline') raise RuntimeError('Refusing a software-rendered baseline')
result['machine']['browser_version'] = browser.version result['machine']['browser_version'] = browser.version
def setup(scene): 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: for scene in scenes:
print(f'Measuring {scene}...', file=sys.stderr, flush=True) print(f'Measuring {scene}...', file=sys.stderr, flush=True)
setup(scene) setup(scene)