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