Faces cut into loose piles collapse outward instead of draining column by column (t93)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 22:06:48 -04:00
co-authored by Claude Opus 5.5
parent 6ac5f463aa
commit 84dd8b8d0a
11 changed files with 271 additions and 9 deletions
+15
View File
@@ -555,6 +555,21 @@ above, including:
keeps its chunk awake to retry. Burning fuel beds keep their tuned settling behavior. keeps its chunk awake to retry. Burning fuel beds keep their tuned settling behavior.
Grain stamps, sealed corners, transport barriers and per-chunk random streams still apply. Grain stamps, sealed corners, transport barriers and per-chunk random streams still apply.
- **Vacuum-cut loose faces spread while falling (t93 reopened, 2026-10-28).** The owner
reports that stone-only and mixed faces still stand after vacuuming into settled piles.
Sticky-step retries alone do not address the descending column formed by always choosing
straight down first. A falling grain on an exposed vertical face, backed by supported
powder, prefers an open outward diagonal. With at least 12 grains of backing above it
and unmoved support below that backing, it spills across up to eight clear grains and
falls up to eight clear grains. This is a local pressure approximation: the falling
outer column must drain quickly enough to let the body of the pile release. Shallow
faces keep the one-grain step, preserving small heaps and conveyor feeds. Check the
whole horizontal/downward path for occupancy, parts and cleats, including other chunks.
Keep one stamped move per tick and the existing eight-grain worker write halo.
Resting heaps do not enter this path; world generation keeps its recorded settling rules
and seed layouts.
## Ideas not built yet ## Ideas not built yet
- **Concrete**, as a stronger wall material from rubble. - **Concrete**, as a stronger wall material from rubble.
+5 -2
View File
@@ -254,10 +254,10 @@ 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 (240 lines) ### src/sim/cells.js (272 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, swapCellData, move, enter, enterFree, powder, passable, liquid, liquidSearch, gas, wakeBotPowder, simMove, moveChunk - 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, POWDER_FAN
### src/sim/heat.js (266 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.
@@ -630,6 +630,7 @@ The frame loop and boot.
- `test/harness.js`: Loads the simulation (the src/sim/ files listed in src/manifest.json, joined in order into one script, the same way build.py joins them) so scenarios can drive it headlessly in Node. Errors point at the real file and line (sim/pipes.js:42), not at the joined script. - `test/harness.js`: Loads the simulation (the src/sim/ files listed in src/manifest.json, joined in order into one script, the same way build.py joins them) so scenarios can drive it headlessly in Node. Errors point at the real file and line (sim/pipes.js:42), not at the joined script.
- `test/light-cases.js`: Lighting behaviour checks executed by the GPU browser harness. - `test/light-cases.js`: Lighting behaviour checks executed by the GPU browser harness.
- `test/light-readback.js`: Read float source channels, including transmittance, from the WGSL light pass. - `test/light-readback.js`: Read float source channels, including transmittance, from the WGSL light pass.
- `test/pile-cut.js`: Shared Node/browser reproduction: vacuum the foot of settled 60-grain heaps, one stroke per tick.
- `test/play.py`: Play-test in a real browser: mouse, keyboard and a mocked gamepad drive the game; save and continue. Screenshots go to shots/play/ (or the folder given as the first argument). Pixel look unless the second argument is 'smooth'. - `test/play.py`: Play-test in a real browser: mouse, keyboard and a mocked gamepad drive the game; save and continue. Screenshots go to shots/play/ (or the folder given as the first argument). Pixel look unless the second argument is 'smooth'.
- `test/run_all.py`: Run the test suites and report. - `test/run_all.py`: Run the test suites and report.
- `test/t_bar_stacking.js`: Resting bars snap into pairs of grains and tip off narrow supports. - `test/t_bar_stacking.js`: Resting bars snap into pairs of grains and tip off narrow supports.
@@ -668,6 +669,8 @@ The frame loop and boot.
- `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_threads.js`: Shared allocation, checkerboard independence, and real worker-count determinism.
- `test/t_vacuum_collapse.js`: Real nozzle cuts into settled heaps, inline and through the real Node worker pool.
- `test/t_vacuum_collapse.py`: Vacuum cuts in the real pixel frame loop with active browser workers and hardware graphics.
- `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.
+8
View File
@@ -78,6 +78,14 @@ checks waking within eight ticks separately from the probabilistic consumption o
gamepad and world sizes. Screenshots go to gamepad and world sizes. Screenshots go to
`shots/play/`. Pixel look by default; `python3 test/play.py shots/play smooth` uses the smooth `shots/play/`. Pixel look by default; `python3 test/play.py shots/play smooth` uses the smooth
look. look.
- `test/t_vacuum_collapse.py` cuts settled stone and mixed heaps with one normal-width
vacuum stroke per simulation tick in the real frame loop. It uses the shared
`test/pile-cut.js` fixture, requires actual helper dispatch (forced for this small world),
checks both faces within 180 ticks, and saves time-series pixel screenshots in `shots/`.
`test/t_vacuum_collapse.js` uses the same cuts inline and with Node helpers, including
bot-near/far, grain conservation, equal material bytes and forced-awake resting stability.
It also checks a broad 120-grain wall within 600 ticks and a spill stopped by cleats
across chunk edges, where the source chunk's one-grain barrier flag is clear.
- `test/t_smooth.py` checks WebGPU smooth shapes, seams, joins, fog and the thermal lens. - `test/t_smooth.py` checks WebGPU smooth shapes, seams, joins, fog and the thermal lens.
- `test/t_gpu_golden.py` compares 28 cropped frozen reference PNGs (384x288, under - `test/t_gpu_golden.py` compares 28 cropped frozen reference PNGs (384x288, under
`test/fixtures/golden/`) against WebGPU: renderer, joins, sky and four light scenes, `test/fixtures/golden/`) against WebGPU: renderer, joins, sky and four light scenes,
+53
View File
@@ -5,6 +5,59 @@ note or request it was for, what changed, the files touched, the tests run, and
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: t93 reopened, vacuum-cut faces drain in columns (Codex)
- Read AGENTS, the t93 task/log and 807217b; inspected both new owner screenshots.
The remaining reproduced cause is straight-down movement taking priority over outward
fall: an exposed edge stays a descending column, blocking the next column's diagonal
exits. This is a movement/scan-order bottleneck, not lost wakes in this reproduction.
`setCell` wakes the cut; worker wakes merge each checkerboard pass. A radius-6 vacuum,
one call per tick into a settled 60-grain RSTONE heap, leaves a 39-grain cliff; after
120 ticks it is still 18, with exactly the same series when all chunks are forced awake.
RSTONE already has STICK=1. Inline and actual Node helpers agree, bot near or far.
- Added outward downward movement only for a falling exposed vertical face backed by
supported powder. Retain one downward step per tick, occupancy, parts, barriers and
burning-grain guard. Resting heaps do not enter this path. `POWDER_FAN` goes to workers
and is disabled during the existing legacy worldgen settle to preserve all seed hashes.
Rejected a horizontal-step experiment (forced-awake creep) and a broad falling-edge
rule (spread free-falling blocks too far); no old test expectation was changed.
- New shared `test/pile-cut.js`: two 60-grain triangular heaps, 600 settling ticks,
stone and stone/copper ore/iron ore/dirt, then actual radius-6 vacuum strokes.
New Node suite passes 40 checks: cuts inline/2 helpers, near/far bot, conservation,
exact inline/helper material bytes, initially stable heaps and 600 forced-awake
stable ticks afterward. Browser suite uses isolated HTTP, real NVIDIA Blackwell GPU,
real frame loop and two helpers with confirmed dispatch (small fixture forces dispatch).
Verdict is recorded at exactly tick 180. Control with face spreading disabled:
cliff stone/mixed 33/32 at cut end, 14/13 at 120, 8/5 at 180; browser assertion fails.
Fixed browser reaches <=2 by 120 and passes at 180. Earlier Node red run fails eight
collapse checks (its mixed fixture included clay); existing clay collapse checks pass.
- Full `python3 test/run_all.py --browser`: ALL PASSED, 34 headless + 9 browser suites,
including collapse 17, threads 12, vacuum 40, gameplay 361 and browser vacuum 1.
Log: `/tmp/t93b-all-browser.log`. No existing test was weakened. Browser regression
is included in the suite runner. Original seed hashes and liquid-sinking checks pass.
- Saved and inspected `shots/t93b-before.png` (control at cut end) and
`shots/t93b-after.png` (fixed after 180 ticks): vertical cut faces become slopes.
Fixed series: `shots/t93b-time-{0,60,120,180}.png`; matching control series:
`shots/t93b-control-time-{0,60,120,180}.png`. Browser control log:
`/tmp/t93b-browser-matching-control.log`. Small initial browser passes stayed inline
despite a pool existing; final regression requires actual `simPool.activeMax > 0`.
- Requested idle profile, real GPU, no concurrent tests:
`python3 tools/perf.py --scenes settled,heat --looks pixel --zooms 6 --compare tools/perf-after-t88.json`.
Rule-disabled control -> final total ms/tick: settled 1.9855 -> 2.0226 (+1.9%),
heat 2.7027 -> 2.7734 (+2.6%). Against historical t88: +12.0%/+5.7%.
Movement ms/tick 0.9639 -> 0.9818 / 1.3481 -> 1.3885; unchanged heat work also
varies 0.9412 -> 0.9593 / 1.2724 -> 1.3019. End awake counts identical, 425/494.
These timings show small run-to-run differences, not exact equality; historical settled
time remains over the profiler's 10% comparison threshold. Evidence:
`/tmp/t93b-idle-control.json`, `/tmp/t93b-idle-final.json`, `/tmp/t93b-idle-summary.log`.
An earlier control profile overlapped headless checks and was excluded from this comparison.
- Files: `src/sim/cells.js`, `src/sim/workers.js`, `src/sim/worldgen.js`,
`test/pile-cut.js`, `test/t_vacuum_collapse.js`, `test/t_vacuum_collapse.py`,
`test/run_all.py`, `docs/DESIGN.md`, `docs/TESTING.md`, generated project map,
t93 task and this log. No commit. Owner playtesting of their larger worlds remains open.
## 2026-10-28: t92 keep pass-through, restore original loose-grain behaviour (Codex) ## 2026-10-28: t92 keep pass-through, restore original loose-grain behaviour (Codex)
- Read the 7293787 then dadf62a t92 history and original drill in 90f2d3f. Restored the - Read the 7293787 then dadf62a t92 history and original drill in 90f2d3f. Restored the
+34 -2
View File
@@ -77,11 +77,43 @@ const SORTP_FUEL = 0.003;
// times as far for one (a two-times range strands the far end of a 200-grain-wide ledge). // times as far for one (a two-times range strands the far end of a 200-grain-wide ledge).
// World generation settles with both at 1 and corner sealing off, so seeds keep the same world. // World generation settles with both at 1 and corner sealing off, so seeds keep the same world.
let LSTEP = 8, LFILM = 4; let LSTEP = 8, LFILM = 4;
let CORNER_SEAL = true; let CORNER_SEAL = true, POWDER_FAN = true;
function powder(i, x, y, m, barriers = cleatChunks[(y >> 5) * CW + (x >> 5)]) { function powder(i, x, y, m, barriers = cleatChunks[(y >> 5) * CW + (x >> 5)]) {
if (y >= H - 1) return; if (y >= H - 1) return;
const b = i + W; const b = i + W;
if ((!barriers || (!cleatMask[i] && !cleatMask[b])) && enterFree(m, PH_POWDER, b, 0, 1)) { move(i, b, x, y, x, y + 1); return; } if ((!barriers || (!cleatMask[i] && !cleatMask[b])) && enterFree(m, PH_POWDER, b, 0, 1)) {
// A falling face fans outward; dropping straight down keeps a packed column in its way.
if (POWDER_FAN && mat[b] === EMPTY && !part[i] && !(flags[i] & F_BURN) && x > 0 && x < W - 1) {
const dx = mat[i - 1] === EMPTY && PH[mat[i + 1]] === PH_POWDER ? -1 :
mat[i + 1] === EMPTY && PH[mat[i - 1]] === PH_POWDER ? 1 : 0;
if (dx && y > 0 && PH[mat[i - W]] === PH_POWDER && mat[i - W + dx] === EMPTY &&
PH[mat[b - dx]] === PH_POWDER) {
// Deep faces spill across the falling column, within the eight-grain worker halo.
let depth = 0;
while (depth < 12 && y >= depth && PH[mat[i - dx - W * depth]] === PH_POWDER) depth++;
let reach = 0;
for (let step = 1; step <= (depth === 12 && stamp[b - dx] !== tickStamp ? 8 : 1); step++) {
const nx = x + dx * step, side = i + dx * step, j = side + W;
if (nx < 0 || nx >= W || mat[side] !== EMPTY || part[side] ||
cleatMask[side] || !passable(m, PH_POWDER, side, dx, 0)) break;
if (mat[j] === EMPTY && !part[j] && !cleatMask[j] &&
passable(m, PH_POWDER, j, dx, 1)) reach = step;
}
if (reach) {
let drop = 1;
if (depth === 12 && stamp[b - dx] !== tickStamp) {
for (let step = 2; step <= 8 && y + step < H; step++) {
const j = i + W * step + dx * reach;
if (mat[j] !== EMPTY || part[j] || cleatMask[j] || !passable(m, PH_POWDER, j, 0, 1)) break;
drop = step;
}
}
move(i, i + W * drop + dx * reach, x, y, x + dx * reach, y + drop); return;
}
}
}
move(i, b, x, y, x, y + 1); return;
}
// 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];
+2 -2
View File
@@ -86,11 +86,11 @@ function chunkPasses(heat) {
} }
} }
function threadParams() { function threadParams() {
return { tick,tickStamp,worldSeed,LSTEP,LFILM,SORTP,CORNER_SEAL, return { tick,tickStamp,worldSeed,LSTEP,LFILM,SORTP,CORNER_SEAL,POWDER_FAN,
seen, bot: {...bot}, skyY: weather.skyY, barriers: !!cleatCells.length, filterCfg }; seen, bot: {...bot}, skyY: weather.skyY, barriers: !!cleatCells.length, filterCfg };
} }
function threadSetParams(s) { function threadSetParams(s) {
({tick,tickStamp,worldSeed,LSTEP,LFILM,SORTP,CORNER_SEAL,filterCfg} = s); ({tick,tickStamp,worldSeed,LSTEP,LFILM,SORTP,CORNER_SEAL,POWDER_FAN,filterCfg} = s);
seen.set(s.seen); Object.assign(bot, s.bot); seen.set(s.seen); Object.assign(bot, s.bot);
weather.skyY = s.skyY; cleatCells = s.barriers ? [1] : []; weather.skyY = s.skyY; cleatCells = s.barriers ? [1] : [];
} }
+2 -2
View File
@@ -245,7 +245,7 @@ function* worldSteps(seedIn, w, h) {
yield [0.75, 'Settling loose ground']; yield [0.75, 'Settling loose ground'];
// settle loose material at the water and sorting speeds the seeds were made with // settle loose material at the water and sorting speeds the seeds were made with
awake.fill(3); awake.fill(3);
const lstep = LSTEP, lfilm = LFILM, sortp = SORTP, cornerSeal = CORNER_SEAL; LSTEP = LFILM = 1; SORTP = 0.003; CORNER_SEAL = false; const lstep = LSTEP, lfilm = LFILM, sortp = SORTP, cornerSeal = CORNER_SEAL, powderFan = POWDER_FAN; LSTEP = LFILM = 1; SORTP = 0.003; CORNER_SEAL = POWDER_FAN = false;
for (let k = 0; k < 260; k++) { for (let k = 0; k < 260; k++) {
tick++; tickStamp = (tickStamp % 255) + 1; tick++; tickStamp = (tickStamp % 255) + 1;
simMove(); simMove();
@@ -253,7 +253,7 @@ function* worldSteps(seedIn, w, h) {
if (!any) break; if (!any) break;
if ((k & 31) === 0) yield [0.75 + 0.24 * k / 260, 'Settling loose ground']; if ((k & 31) === 0) yield [0.75 + 0.24 * k / 260, 'Settling loose ground'];
} }
LSTEP = lstep; LFILM = lfilm; SORTP = sortp; CORNER_SEAL = cornerSeal; LSTEP = lstep; LFILM = lfilm; SORTP = sortp; CORNER_SEAL = cornerSeal; POWDER_FAN = powderFan;
yield [0.99, 'Finishing']; yield [0.99, 'Finishing'];
// re-ambient after settling, then start clean // re-ambient after settling, then start clean
for (let i = 0; i < N; i++) { const m = mat[i]; if (m !== MAGMA) temp[i] = AMB[(i / w) | 0]; life[i] = PH[m] === PH_LIQUID && FRZ[m] ? 255 : 0; } for (let i = 0; i < N; i++) { const m = mat[i]; if (m !== MAGMA) temp[i] = AMB[(i / w) | 0]; life[i] = PH[m] === PH_LIQUID && FRZ[m] ? 255 : 0; }
+34
View File
@@ -0,0 +1,34 @@
// Shared Node/browser reproduction: vacuum the foot of settled 60-grain heaps, one stroke per tick.
function pileCutWorld() {
alloc(768, 600); worldSeed = 7; RS = 123;
for (let x = 0; x < W; x++) { surfY[x] = 224; dirtY[x] = 234; graniteY[x] = basaltY[x] = magmaY[x] = H; }
computeAmbient(); fillNoise();
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
const i = y * W + x; mat[i] = y >= 224 ? STONE : EMPTY;
flags[i] = y >= 224 ? F_NAT : 0; temp[i] = AMB[y];
}
bag.fill(0); bagTotal = 0; player.tankTier = 3;
resetWeather(); weather.next = 1e15; updateRoof();
}
function pileCutSetup(peak, mixed) {
const rubble = [RSTONE, CUORE, FEORE, LDIRT];
for (let y = 164; y < 224; y++) for (let x = peak - (y - 164); x <= peak + (y - 164); x++)
setCell(y * W + x, mixed ? rubble[(x + y) % rubble.length] : RSTONE, AMB[y], 0);
}
function pileCutStroke(peak) {
bot.x = peak - 50; bot.y = 170; bot.vx = bot.vy = 0;
for (let y = 223; y >= 164; y--) for (let x = peak - 100; x < peak; x++) {
if (PH[mat[y * W + x]] !== PH_POWDER) continue;
return toolVacuum(Math.min(peak - 7, x), y - 3, 6, () => true).got;
}
return 0;
}
function pileCutProfile(peak) {
let cliff = 0, previous = 0, grains = 0;
for (let x = peak - 110; x <= peak + 110; x++) {
let y = 0; while (y < 224 && PH[mat[y * W + x]] !== PH_POWDER) y++;
const height = 224 - y; cliff = Math.max(cliff, Math.abs(height - previous)); previous = height;
for (; y < 224; y++) if (PH[mat[y * W + x]] === PH_POWDER) grains++;
}
return {cliff, grains};
}
+1 -1
View File
@@ -45,7 +45,7 @@ def main():
if browser: if browser:
subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT, check=True) subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT, check=True)
# one at a time: two headless browsers at once can starve each other and flake # one at a time: two headless browsers at once can starve each other and flake
for name in ['play.py', 't_smooth.py', 't_dev_console.py', 't_caps.py', 't_gpu.py', 't_gpu_golden.py', 't_gpu_light.py', 'firefox_smoke.py']: for name in ['play.py', 't_smooth.py', 't_dev_console.py', 't_caps.py', 't_gpu.py', 't_vacuum_collapse.py', 't_gpu_golden.py', 't_gpu_light.py', 'firefox_smoke.py']:
results.append(run_py(name)) results.append(run_py(name))
bad = 0 bad = 0
for name, code, out, secs in results: for name, code, out, secs in results:
+59
View File
@@ -0,0 +1,59 @@
// Real nozzle cuts into settled heaps, inline and through the real Node worker pool.
const fs = require('fs');
const {createHash} = require('crypto');
const {load, suite, enableThreads, flatWorld} = require('./harness');
const {run} = load(), {check, done} = suite();
run(fs.readFileSync(require.resolve('./pile-cut.js'), 'utf8'));
const hash = () => createHash('sha256').update(run('mat')).digest('hex');
// The longer spill must check barriers even when its starting chunk has no cleats.
flatWorld(run, 96, 160, 80);
run(`for(let y=18;y<=31;y++)setCell(y*W+29,RSTONE,AMB[y],0);
setCell(29*W+30,RSTONE,AMB[29],0);setCell(30*W+30,RSTONE,AMB[30],0);
cleatMask[30*W+34]=1;cleatMask[34*W+33]=1;
powder(30*W+30,30,30,RSTONE);`);
check('face spill stops before cleats across chunk edges',run('mat[30*W+30]===EMPTY && mat[33*W+33]===RSTONE && !cleatChunks[0]'));
(async () => {
const expected = new Map();
for (const workers of [0, 2]) for (const mixed of [false, true]) for (const near of [false, true]) {
run(`pileCutWorld();pileCutSetup(160,${mixed});bot.x=700;bot.y=100;`);
const stop = workers ? await enableThreads(run, workers) : null;
try {
run('for(let k=0;k<600;k++)simTick();');
const settled = hash();
run('awake.fill(3);for(let k=0;k<60;k++)simTick();');
check('uncut heap is settled', hash() === settled);
run(`for(let k=0;k<70;k++){pileCutStroke(160);simTick();}bot.x=${near ? 110 : 700};bot.y=170;`);
const before = run('pileCutProfile(160)');
check('vacuum removes grains without losing them', run('bagTotal') > 500 && before.grains + run('bagTotal') === 3600, JSON.stringify(before));
run('for(let k=0;k<180;k++)simTick();');
const after = run('pileCutProfile(160)'), label = `${mixed ? 'mixed' : 'stone'}, ${workers} workers, bot ${near ? 'near' : 'far'}`;
check(`vacuum face slumps within 180 ticks (${label})`, after.cliff <= 2, `before ${before.cliff}, after ${after.cliff}`);
if (workers) check('helpers actually processed chunks', run('simPool.activeMax') > 0);
const key = `${mixed}/${near}`;
if (!workers) expected.set(key, hash());
else check(`vacuum collapse matches inline bytes (${label})`, hash() === expected.get(key));
run('for(let k=0;k<1200;k++){awake.fill(3);simTick();}');
const resting = hash();
run('for(let k=0;k<600;k++){awake.fill(3);simTick();}for(let k=0;k<3;k++)simTick();');
check(`settled cut heap stays put and sleeps (${label})`, hash() === resting && run('awake.every(v=>!v)'));
} finally { if (stop) await stop(); }
}
let wideExpected;
for (const workers of [0, 2]) {
flatWorld(run, 768, 600, 224);
run('bot.x=740;bot.y=100;for(let y=104;y<224;y++)for(let x=256;x<576;x++)setCell(y*W+x,RSTONE,AMB[y],0);');
const stop = workers ? await enableThreads(run, workers) : null;
try {
run('for(let k=0;k<600;k++)simTick();');
const result = run(`(()=>{let cliff=0,previous=0,grains=0;
for(let x=0;x<W;x++){let y=0;while(y<224&&mat[y*W+x]!==RSTONE)y++;
const h=224-y;cliff=Math.max(cliff,Math.abs(h-previous));previous=h;
for(;y<224;y++)if(mat[y*W+x]===RSTONE)grains++;}
return {cliff,grains};})()`);
check(`a broad 120-grain wall also releases (${workers} workers)`, result.cliff<=2 && result.grains===38400, JSON.stringify(result));
if (!workers) wideExpected=hash();
else check('broad wall release matches inline bytes',hash()===wideExpected);
} finally { if (stop) await stop(); }
}
done();
})().catch(e => { console.error(e); process.exitCode = 1; });
+58
View File
@@ -0,0 +1,58 @@
"""Vacuum cuts in the real pixel frame loop with active browser workers and hardware graphics."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / 'tools'))
from use_venv import ensure_playwright; ensure_playwright()
from browser import launch, consume_gpu_option, capabilities
consume_gpu_option()
ROOT = pathlib.Path(__file__).resolve().parents[1]
with launch(viewport={'width': 1440, 'height': 760}) as (_, page, url):
errors = []
page.on('pageerror', lambda e: errors.append(str(e)))
page.goto(url)
caps = capabilities(page)
assert caps['isolated'] and caps['webgpu'], caps
print('GPU', caps, flush=True)
page.evaluate("""() => {
const enter = enterGame; enterGame = function() { enter(); GAME.paused = true; };
SET.workerCap=2; SET.fog=false; SET.lighting='off'; SET.cameraLead=false; SET.autosave=0;
startNewWorld(7,768,600);
}""")
page.wait_for_function('GAME.running', timeout=60000)
page.evaluate((ROOT / 'test/pile-cut.js').read_text())
page.evaluate("""() => {
pileCutWorld(); pileCutSetup(160,false); pileCutSetup(416,true);
bot.x=700;bot.y=100;for(let k=0;k<600;k++)simTick();
renderDirty.fill(1);GPU.world=null;SM.surf=-1;
}""")
page.evaluate('startThreads()')
page.evaluate("""() => {
// Tiny isolated fixtures otherwise run inline below the production dispatch threshold.
simPool.force=true;
window.cutTicks=0;window.cutEnd=0;
const tool=toolTick;toolTick=()=>{
tool();
if(cutEnd && tick===cutEnd+180) window.cutResult={tick,cutEnd,
stone:pileCutProfile(160),mixed:pileCutProfile(416),helpers:simPool.activeMax};
if(cutTicks<140){pileCutStroke(cutTicks%2?416:160);cutTicks++;if(cutTicks===140)cutEnd=tick+1;}
};
document.querySelectorAll('.card,.toast').forEach(e=>e.remove());
$('hud').hidden=true;GAME.hidePreviews=true;SET.zoom=3;resizeRender();
bot.x=288-bot.w/2;bot.y=190-bot.h/2;snapCamera();
// Keep both heaps in view while the bot follows each vacuum stroke.
updateCamera=()=>{}; GAME.paused=false;
}""")
page.wait_for_function('cutEnd>0', timeout=30000)
for delay in (0, 60, 120, 180):
page.wait_for_function(f'tick>=cutEnd+{delay}', timeout=15000)
result = page.evaluate('({tick,cutEnd,stone:pileCutProfile(160),mixed:pileCutProfile(416),helpers:simPool.activeMax})')
print('profile', delay, result, flush=True)
page.screenshot(path=str(ROOT / f'shots/t93b-time-{delay}.png'))
page.wait_for_function('window.cutResult', timeout=15000)
result = page.evaluate('cutResult')
page.evaluate('GAME.paused=true')
page.screenshot(path=str(ROOT / 'shots/t93b-after.png'))
assert result['helpers'] > 0, result
assert result['stone']['cliff'] <= 2 and result['mixed']['cliff'] <= 2, result
assert not errors, errors
print('PASS vacuum-cut stone and mixed faces slump within 180 real-loop ticks with browser helpers')