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:
@@ -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).
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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++;
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
+5
-3
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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`);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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);');
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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;});
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user