WebGPU renderer complete: dirty uploads, default limits, legacy removed, WebGPU required, warm-up (t88 phase 2d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -26,13 +26,14 @@ with launch(browser_name='firefox') as (_, page, url):
|
||||
text = page.inner_text('#cap-notice')
|
||||
assert 'about:config' in text and 'dom.webgpu.enabled' in text and 'Firefox Nightly' in text
|
||||
print('PASS Firefox isolation and capability notice')
|
||||
page.click('#btn-new')
|
||||
page.fill('#nw-seed', '424242')
|
||||
page.select_option('#nw-size', '2048x1024')
|
||||
page.click('#btn-generate')
|
||||
page.wait_for_function('GAME.running && !GAME.loading', timeout=60000)
|
||||
before = page.evaluate('tick')
|
||||
page.wait_for_timeout(3000)
|
||||
assert page.evaluate('tick') > before
|
||||
assert not errors, errors
|
||||
print('PASS Firefox new world runs for three seconds without page errors')
|
||||
if not caps['webgpu']:
|
||||
assert not page.evaluate('canStartGame()')
|
||||
assert not page.evaluate('startNewWorld(424242,512,384)')
|
||||
assert not page.evaluate('GAME.running')
|
||||
print('PASS Firefox without WebGPU cannot start a world')
|
||||
else:
|
||||
page.evaluate('startNewWorld(424242,512,384)')
|
||||
page.wait_for_function('GAME.running && !GAME.loading',timeout=60000)
|
||||
before=page.evaluate('tick');page.wait_for_timeout(3000)
|
||||
assert page.evaluate('tick')>before
|
||||
assert not errors,errors
|
||||
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 117 KiB |
@@ -1,19 +1,6 @@
|
||||
// Run the renderer's lighting against real simulation data, with only canvas storage stubbed.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { load, suite, flatWorld } = require('./harness');
|
||||
const { run } = load();
|
||||
const { check, done } = suite();
|
||||
for (const f of ['setup.js', 'light.js']) run(fs.readFileSync(path.join(__dirname, '../src/render', f), 'utf8'));
|
||||
run(`
|
||||
const SET = { lighting: 'full', daylight: 1, ambient: 0.12 };
|
||||
R.lightC = {};
|
||||
R.lightX = { createImageData: (w, h) => ({ data: new Uint8ClampedArray(w * h * 4) }), putImageData() {} };
|
||||
R.vx0 = 0; R.vy0 = 100; R.bw = 320; R.bh = 80;
|
||||
function lightAt(a, x, y) { return a[(Math.floor(y / R.lres) - R.ly0) * R.lw + Math.floor(x / R.lres) - R.lx0]; }
|
||||
`);
|
||||
flatWorld(run, 320, 200, 160);
|
||||
run(`
|
||||
// Lighting behaviour checks executed by the GPU browser harness.
|
||||
await flatWorld(run, 320, 200, 160);
|
||||
await run(`
|
||||
bot.lamp = false;
|
||||
for (let bx = 8; bx < 30; bx++) placeBlock(BP_WALL, RSTONE, bx, 20, true);
|
||||
for (let by = 21; by < 39; by++) {
|
||||
@@ -26,43 +13,43 @@ run(`
|
||||
addMold('bit', 24, 30, RSTONE, true);
|
||||
for (let y = 80; y < 88; y++) for (let x = 180; x < 280; x++) setCell(y * W + x, STONE, AMB[y], F_NAT);
|
||||
updateRoof();
|
||||
const simRoof = Array.from(roofY);
|
||||
window.simRoof = Array.from(roofY);
|
||||
`);
|
||||
for (const mode of ['full', 'fast']) {
|
||||
run(`SET.lighting = '${mode}'; computeLight();`);
|
||||
await run(`SET.lighting = '${mode}'; computeLight();`);
|
||||
for (const channel of ['lr', 'lg']) {
|
||||
const [inside, open, rock, ground, openGround] = run(`[lightAt(R.${channel}, 80, 144), lightAt(R.${channel}, 152, 144), lightAt(R.${channel}, 232, 144), lightAt(R.${channel}, 80, 160), lightAt(R.${channel}, 152, 160)]`);
|
||||
const [inside, open, rock, ground, openGround] = await run(`[lightAt(R.${channel}, 80, 144), lightAt(R.${channel}, 152, 144), lightAt(R.${channel}, 232, 144), lightAt(R.${channel}, 80, 160), lightAt(R.${channel}, 152, 160)]`);
|
||||
check(`${mode} ${channel}: under a built roof matches open daylight`, Math.abs(inside / open - 1) < 0.03, `${inside} / ${open}`);
|
||||
check(`${mode} ${channel}: ground under the base matches outside`, Math.abs(ground / openGround - 1) < 0.03, `${ground} / ${openGround}`);
|
||||
check(`${mode} ${channel}: natural overhang still shades`, rock < open * 0.95, `${rock} / ${open}`);
|
||||
}
|
||||
// Directly check transmission as daylight can hide an opaque piece in the open.
|
||||
for (const [name, x, y] of [['wall', 32, 120], ['belt', 48, 120], ['screw', 56, 120], ['machine', 72, 120], ['mold', 96, 120]]) {
|
||||
const a = run(`lightAt(R.latt, ${x}, ${y})`);
|
||||
check(`${mode}: ${name} transmits like air`, Math.abs(a - Math.pow(0.978, mode === 'fast' ? 4 : 2)) < 1e-6, a);
|
||||
const a = await run(`lightAt(R.latt, ${x}, ${y})`);
|
||||
check(`${mode}: ${name} transmits like air`, Math.abs(a - Math.pow(0.978, mode === 'fast' ? 4 : 2)) < 1e-3, a);
|
||||
}
|
||||
}
|
||||
check('lighting leaves the simulation roof unchanged', run('simRoof.every((y, x) => roofY[x] === y) && roofY[80] === 80'));
|
||||
check('lighting leaves the simulation roof unchanged', await run('simRoof.every((y, x) => roofY[x] === y) && roofY[80] === 80'));
|
||||
for (const material of ['STONE', 'SAND', 'WATER']) {
|
||||
run(`setCell(96 * W + 80, ${material}, AMB[96], F_NAT); setCell(97 * W + 80, STONE, AMB[97], F_NAT); updateRoof(); computeLight();`);
|
||||
check(`${material} above the view still blocks daylight`, run('lightAt(R.lr, 80, 144) < 0.95'));
|
||||
run('setCell(96 * W + 80, EMPTY, AMB[96], 0); setCell(97 * W + 80, EMPTY, AMB[97], 0);');
|
||||
await run(`setCell(96 * W + 80, ${material}, AMB[96], F_NAT); setCell(97 * W + 80, STONE, AMB[97], F_NAT); updateRoof(); computeLight();`);
|
||||
check(`${material} above the view still blocks daylight`, await run('lightAt(R.lr, 80, 144) < 0.925'));
|
||||
await run('setCell(96 * W + 80, EMPTY, AMB[96], 0); setCell(97 * W + 80, EMPTY, AMB[97], 0);');
|
||||
}
|
||||
run('updateRoof(); computeLight();');
|
||||
check('removing an obstruction restores daylight', run('lightAt(R.lr, 80, 144) === 1'));
|
||||
await run('updateRoof(); computeLight();');
|
||||
check('removing an obstruction restores daylight', await run('lightAt(R.lr, 80, 144) === 1'));
|
||||
for (const field of ['blockP[24 * BW + 20]', 'mach[96 * W + 80]', 'part[96 * W + 80]']) {
|
||||
run(`setCell(96 * W + 80, STONE, AMB[96], F_NAT); ${field} = 1; updateRoof(); computeLight();`);
|
||||
check(`${field}: built contents do not shade or absorb light`, run('lightAt(R.lr, 80, 144) === 1 && Math.abs(lightAt(R.latt, 80, 96) - Math.pow(0.978, 4)) < 1e-6'));
|
||||
run(`${field} = 0; setCell(96 * W + 80, EMPTY, AMB[96], 0);`);
|
||||
await run(`setCell(96 * W + 80, STONE, AMB[96], F_NAT); ${field} = 1; updateRoof(); computeLight();`);
|
||||
check(`${field}: built contents do not shade or absorb light`, await run('lightAt(R.lr, 80, 144) === 1 && Math.abs(lightAt(R.latt, 80, 96) - Math.pow(0.978, 4)) < 1e-3'));
|
||||
await run(`${field} = 0; setCell(96 * W + 80, EMPTY, AMB[96], 0);`);
|
||||
}
|
||||
run('updateRoof();');
|
||||
run(`SET.daylight = 0; temp[140 * W + 80] = 1500; computeLight();`);
|
||||
check('hot material inside the base lights its surroundings', run('lightAt(R.lr, 88, 140) > 0.2'));
|
||||
run(`temp[140 * W + 80] = AMB[140]; bot.x = 72; bot.y = 130; bot.lamp = true; computeLight();`);
|
||||
check('the bot lamp still lights the base', run('lightAt(R.lr, 80, 140) > 0.2'));
|
||||
await run('updateRoof();');
|
||||
await run(`SET.daylight = 0; temp[140 * W + 80] = 1500; computeLight();`);
|
||||
check('hot material inside the base lights its surroundings', await run('lightAt(R.lr, 88, 140) > 0.2'));
|
||||
await run(`temp[140 * W + 80] = AMB[140]; bot.x = 72; bot.y = 130; bot.lamp = true; computeLight();`);
|
||||
check('the bot lamp still lights the base', await run('lightAt(R.lr, 80, 140) > 0.2'));
|
||||
|
||||
flatWorld(run, 320, 200, 160);
|
||||
run(`
|
||||
await flatWorld(run, 320, 200, 160);
|
||||
await run(`
|
||||
SET.daylight = 1; bot.lamp = false;
|
||||
for (let bx = 8; bx < 30; bx++) placeBlock(BP_BELT, 1, bx, 20, true);
|
||||
for (let x = 32; x < 120; x++) for (let y = 77; y < 80; y++) setCell(y * W + x, SAND, AMB[y], 0);
|
||||
@@ -74,19 +61,20 @@ run(`
|
||||
updateRoof();
|
||||
`);
|
||||
for (const mode of ['full', 'fast']) {
|
||||
run(`SET.lighting = '${mode}'; computeLight();`);
|
||||
await run(`SET.lighting = '${mode}'; computeLight();`);
|
||||
for (const channel of ['lr', 'lg', 'lb']) {
|
||||
const [belt, open, stone] = run(`[lightAt(R.${channel}, 80, 84), lightAt(R.${channel}, 160, 84), lightAt(R.${channel}, 232, 84)]`);
|
||||
check(`${mode} ${channel}: a loaded belt leaves daylight below it`, Math.abs(belt - open) < 1e-6, `${belt} / ${open}`);
|
||||
const [belt, open, stone] = await run(`[lightAt(R.${channel}, 80, 84), lightAt(R.${channel}, 160, 84), lightAt(R.${channel}, 232, 84)]`);
|
||||
check(`${mode} ${channel}: a loaded belt leaves daylight below it`, Math.abs(belt - open) < 1e-3, `${belt} / ${open}`);
|
||||
check(`${mode} ${channel}: sand on natural stone still shades below`, stone < open * 0.95, `${stone} / ${open}`);
|
||||
const [streamGround, openGround] = run(`[lightAt(R.${channel}, 152, 160), lightAt(R.${channel}, 160, 160)]`);
|
||||
check(`${mode} ${channel}: a falling stream leaves ground as bright as open sky`, Math.abs(streamGround - openGround) < 1e-6, `${streamGround} / ${openGround}`);
|
||||
const [streamGround, openGround] = await run(`[lightAt(R.${channel}, 152, 160), lightAt(R.${channel}, 160, 160)]`);
|
||||
check(`${mode} ${channel}: a falling stream leaves ground as bright as open sky`, Math.abs(streamGround - openGround) < 1e-3, `${streamGround} / ${openGround}`);
|
||||
}
|
||||
run('setCell(76 * W + 80, WATER, AMB[76], 0); updateRoof(); computeLight();');
|
||||
const a = run('lightAt(R.latt, 80, 76)');
|
||||
check(`${mode}: a wet belt load transmits like air`, Math.abs(a - Math.pow(0.978, mode === 'fast' ? 4 : 2)) < 1e-6, a);
|
||||
run('setCell(76 * W + 80, EMPTY, AMB[76], 0); updateRoof();');
|
||||
await run('setCell(76 * W + 80, WATER, AMB[76], 0); updateRoof(); computeLight();');
|
||||
const a = await run('lightAt(R.latt, 80, 76)');
|
||||
check(`${mode}: a wet belt load transmits like air`, Math.abs(a - Math.pow(0.978, mode === 'fast' ? 4 : 2)) < 1e-3, a);
|
||||
await run('setCell(76 * W + 80, EMPTY, AMB[76], 0); updateRoof();');
|
||||
}
|
||||
await run('R.camY=0;');
|
||||
for (const [name, support, depth, gap, expected] of [
|
||||
['left belt', 'mat[i] = BELTL', 12, '', true],
|
||||
['right belt', 'mat[i] = BELTR', 12, '', true],
|
||||
@@ -102,28 +90,29 @@ for (const [name, support, depth, gap, expected] of [
|
||||
['screw', 'part[i] = P_SCREW_R', 3, '', true],
|
||||
['loaded screw', 'mat[i] = SAND; part[i] = P_SCREW_R', 3, '', true],
|
||||
]) {
|
||||
run(`
|
||||
await run(`
|
||||
for (let y = 40; y <= 53; y++) { const i = y * W + 300; mat[i] = SAND; part[i] = 0; flags[i] = 0; }
|
||||
{ const i = (40 + ${depth}) * W + 300; mat[i] = EMPTY; mat[i + W] = BELTR; ${support}; }
|
||||
${gap ? `mat[41 * W + 300] = ${gap};` : ''}
|
||||
`);
|
||||
check(`${name}: loose cell detection`, run('looseForLight(40 * W + 300)') === expected);
|
||||
check(`${name}: loose cell detection`, await run('lightAt(R.latt,300,40) > 0.9') === expected);
|
||||
}
|
||||
run('mat[40 * W + 300] = SAND; mat[41 * W + 300] = EMPTY;');
|
||||
check('a single grain in mid-air is see-through', run('looseForLight(40 * W + 300)'));
|
||||
run('mat[(H - 1) * W + 300] = SAND;');
|
||||
check('loose cell detection stops at the world bottom', run('!looseForLight((H - 1) * W + 300)'));
|
||||
await run('mat[40 * W + 300] = SAND; mat[41 * W + 300] = EMPTY;');
|
||||
check('a single grain in mid-air is see-through', await run('lightAt(R.latt,300,40) > 0.9'));
|
||||
await run('R.camY=120;mat[(H - 1) * W + 300] = SAND;');
|
||||
check('loose cell detection stops at the world bottom', await run('lightAt(R.latt,300,H-1) < 0.92'));
|
||||
|
||||
flatWorld(run, 320, 200, 160);
|
||||
run(`
|
||||
await flatWorld(run, 320, 200, 160);
|
||||
await run('R.camY=100;');
|
||||
await run(`
|
||||
bot.lamp = false; stats.refined = 0;
|
||||
placeBlock(BP_GRIND, 0, 20, 20, true);
|
||||
for (let bx = 8; bx < 30; bx++) placeBlock(BP_BELT, 1, bx, 36, true);
|
||||
setCell(79 * W + 80, SAND, AMB[79], 0);
|
||||
`);
|
||||
check('sand resting on grinder housing is see-through', run('looseForLight(79 * W + 80)'));
|
||||
check('sand resting on grinder housing is see-through', await run('lightAt(R.latt,80,79) > 0.9'));
|
||||
for (const mode of ['full', 'fast']) {
|
||||
const result = run(`(() => {
|
||||
const result = await run(`(() => {
|
||||
SET.lighting = '${mode}';
|
||||
let mismatches = 0, first = '';
|
||||
for (let t = 0; t < 180; t++) {
|
||||
@@ -142,5 +131,5 @@ for (const mode of ['full', 'fast']) {
|
||||
})()`);
|
||||
check(`${mode}: streaming grinder leaves ground in daylight for 180 consecutive ticks`, result.mismatches === 0, result.first || 'no shadow');
|
||||
}
|
||||
check('the streaming grinder actually processes its input', run('stats.refined > 0'), run('stats.refined'));
|
||||
check('the streaming grinder actually processes its input', await run('stats.refined > 0'), await run('stats.refined'));
|
||||
done();
|
||||
@@ -0,0 +1,22 @@
|
||||
// Read float source channels, including transmittance, from the WGSL light pass.
|
||||
function lightAt(a,x,y) { return a[(Math.floor(y/R.lres)-R.ly0)*R.lw+Math.floor(x/R.lres)-R.lx0]; }
|
||||
async function testLight() {
|
||||
if (!W) return;
|
||||
renderDirty.fill(1);R.frame++;drawWorld();
|
||||
const d=GPU.device;
|
||||
if(!window.testLightPipe) window.testLightPipe=await d.createComputePipelineAsync({layout:'auto',compute:{module:d.createShaderModule({code:`
|
||||
@group(0) @binding(0) var source:texture_2d<f32>;
|
||||
@group(0) @binding(1) var<storage,read_write> out:array<vec4f>;
|
||||
@compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id:vec3u){
|
||||
let size=textureDimensions(source);let k=id.x;if(k>=size.x*size.y){return;}
|
||||
out[k]=textureLoad(source,vec2i(i32(k%size.x),i32(k/size.x)),0);
|
||||
}`}),entryPoint:'main'}});
|
||||
const bytes=R.lw*R.lh*16, storage=d.createBuffer({size:bytes,usage:GPUBufferUsage.STORAGE|GPUBufferUsage.COPY_SRC});
|
||||
const read=d.createBuffer({size:bytes,usage:GPUBufferUsage.COPY_DST|GPUBufferUsage.MAP_READ});
|
||||
const bind=d.createBindGroup({layout:testLightPipe.getBindGroupLayout(0),entries:[{binding:0,resource:GPU.lightWork[0].createView()},{binding:1,resource:{buffer:storage}}]});
|
||||
const enc=d.createCommandEncoder(),pass=enc.beginComputePass();pass.setPipeline(testLightPipe);pass.setBindGroup(0,bind);pass.dispatchWorkgroups(Math.ceil(R.lw*R.lh/64));pass.end();
|
||||
enc.copyBufferToBuffer(storage,0,read,0,bytes);d.queue.submit([enc.finish()]);await read.mapAsync(GPUMapMode.READ);
|
||||
const data=new Float32Array(read.getMappedRange());
|
||||
[R.lr,R.lg,R.lb,R.latt]=[0,1,2,3].map(c=>Float32Array.from({length:R.lw*R.lh},(_,i)=>data[i*4+c]));
|
||||
read.unmap();read.destroy();storage.destroy();
|
||||
}
|
||||
@@ -173,7 +173,7 @@ with launch(viewport={'width': 1280, 'height': 800}) as (b, pg, url):
|
||||
check('but not on the title screen', not ev(f'(() => {{ GAME.running = false; const r = {leave}; GAME.running = true; return r; }})()'))
|
||||
ev("SET.zoom = 4; resizeRender(); R.snapNext = true;") # the rest of this play-through was laid out at zoom 4
|
||||
ev(f"SET.look = '{look}'"); pg.wait_for_timeout(200)
|
||||
check(f'the {look} look is drawing', ev('R.smoothOn') == (look == 'smooth'), ev('[SET.look, R.smoothOn, SM.ok]'))
|
||||
check(f'the {look} look is drawing', ev('R.smoothOn') == (look == 'smooth'), ev('[SET.look, R.smoothOn, GPU.ok]'))
|
||||
# Maps use the real frame loop and keyboard, including settings persistence.
|
||||
pg.wait_for_function('MAP.image && MAP.init >= Math.ceil(W / 32) * Math.ceil(H / 32)')
|
||||
check('minimap lives in bottom right with its controls', ev("!!document.querySelector('#hud .br #minimap canvas') && ['mini-plus', 'mini-minus', 'mini-map'].every(id => $(id).tagName === 'BUTTON')"))
|
||||
@@ -1220,8 +1220,6 @@ with launch(viewport={'width': 1280, 'height': 800}) as (b, pg, url):
|
||||
# Fog has its own paused scene; existing play checks keep the whole world visible.
|
||||
fog_context = b.new_context(viewport={'width': 1280, 'height': 800})
|
||||
fp = fog_context.new_page()
|
||||
if pg.evaluate('!!window.__allowSoftwareGL'):
|
||||
fp.add_init_script('window.__allowSoftwareGL = true')
|
||||
fp.on('pageerror', lambda e: logs.append(f'PAGEERROR(fog): {e}'))
|
||||
fp.goto(url)
|
||||
fp.evaluate('startNewWorld(4242, 512, 384)')
|
||||
@@ -1328,12 +1326,11 @@ with launch(viewport={'width': 1280, 'height': 800}) as (b, pg, url):
|
||||
check('fog toggle shows the same rock', fp.evaluate(sample) != 0)
|
||||
fp.evaluate("SET.fog = true; SET.look = 'smooth'; SM.t0 = performance.now() + 100000")
|
||||
fp.wait_for_timeout(500)
|
||||
check('fog draws in the smooth look', fp.evaluate('R.smoothOn && (R.gpuOn ? GPU.ok : SM.ok)'))
|
||||
check('fog draws in the smooth look', fp.evaluate('R.smoothOn && GPU.ok'))
|
||||
fp.add_script_tag(path=str(root / 'test/gpu-readback.js'))
|
||||
check('unseen smooth grains have no terrain shape', fp.evaluate("""async () => {
|
||||
const k=(235-R.vy0)*R.bw+300-R.vx0;
|
||||
const inputs=R.gpuOn ? Array.from((await testSmoothInputs()).slice(k*4,k*4+4)) :
|
||||
[R.px[k],R.backPx[k],R.treePx[k],R.fieldPx[k]];
|
||||
const inputs=Array.from((await testSmoothInputs()).slice(k*4,k*4+4));
|
||||
return inputs[0]===0x04000000 && inputs[1]===0 && inputs[2]===0 && inputs[3]===255;
|
||||
}"""))
|
||||
fp.screenshot(path=str(out / 'fog-smooth.png'))
|
||||
|
||||
@@ -45,7 +45,7 @@ def main():
|
||||
if browser:
|
||||
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
|
||||
for name in ['play.py', 't_smooth.py', 't_dev_console.py', 't_caps.py', 't_gpu.py', 'firefox_smoke.py']:
|
||||
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']:
|
||||
results.append(run_py(name))
|
||||
bad = 0
|
||||
for name, code, out, secs in results:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Capability detection, title warnings and the future startup gate."""
|
||||
"""Capability detection, title notices and the required-WebGPU startup gate."""
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
@@ -8,23 +8,18 @@ ensure_playwright()
|
||||
from browser import capabilities, consume_gpu_option, launch
|
||||
|
||||
consume_gpu_option()
|
||||
for case in ['native', 'absent', 'null', 'reject', 'firefox', 'required']:
|
||||
for case in ['native', 'absent', 'null', 'reject', 'firefox', 'limits']:
|
||||
with launch() as (_, page, url):
|
||||
errors = []
|
||||
page.on('pageerror', lambda e: errors.append(str(e)))
|
||||
if case != 'native':
|
||||
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 == 'firefox':
|
||||
page.add_init_script("Object.defineProperty(navigator, 'userAgent', {value: 'Firefox/157.0'});")
|
||||
if case == 'required':
|
||||
def require(route):
|
||||
response = route.fetch()
|
||||
route.fulfill(response=response, body=response.text().replace(
|
||||
'const REQUIRE_GPU_AND_THREADS = false;', 'const REQUIRE_GPU_AND_THREADS = true;'))
|
||||
page.route('**/index.html', require)
|
||||
page.goto(url)
|
||||
caps = capabilities(page)
|
||||
page.wait_for_function("!$('title').hidden")
|
||||
@@ -36,17 +31,13 @@ for case in ['native', 'absent', 'null', 'reject', 'firefox', 'required']:
|
||||
notice = page.inner_text('#cap-notice')
|
||||
assert 'python3 tools/serve.py' in notice and "opened from a file it can't" in notice
|
||||
assert ('Firefox Nightly' in notice and 'dom.webgpu.enabled' in notice) if case == 'firefox' else 'Chrome or Edge' in notice
|
||||
if case == 'required':
|
||||
if case != 'native':
|
||||
assert not page.evaluate('canStartGame()')
|
||||
page.evaluate('startNewWorld(424242, 512, 384)')
|
||||
assert not page.evaluate('GAME.running || GAME.loading')
|
||||
assert not page.evaluate('startFromSave({})')
|
||||
else:
|
||||
assert page.evaluate('canStartGame()')
|
||||
if case == 'absent':
|
||||
Path('shots').mkdir(exist_ok=True)
|
||||
page.screenshot(path='shots/t88-p1-notice.png')
|
||||
page.evaluate("SET.look='pixel'; startNewWorld(424242, 512, 384)")
|
||||
page.wait_for_function('GAME.running && !GAME.loading', timeout=60000)
|
||||
assert not errors, errors
|
||||
print(f'PASS capabilities: {case}')
|
||||
|
||||
@@ -1,159 +1,125 @@
|
||||
"""WebGPU pixel parity, rare-cell selection, world replacement, resize and device loss."""
|
||||
"""Dirty uploads, default limits, warm pipelines, world replacement and device recovery."""
|
||||
import pathlib, sys
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / 'tools'))
|
||||
sys.path.insert(0,str(pathlib.Path(__file__).resolve().parent.parent/'tools'))
|
||||
from use_venv import ensure_playwright; ensure_playwright()
|
||||
from browser import launch, consume_gpu_option
|
||||
gpu_mode = consume_gpu_option()
|
||||
root = pathlib.Path(__file__).resolve().parent.parent
|
||||
with launch(viewport={'width': 960, 'height': 600}) as (browser, pg, url):
|
||||
errors = []
|
||||
pg.on('pageerror', lambda e: errors.append(str(e)))
|
||||
pg.on('console', lambda m: errors.append(m.text) if m.type == 'error' else None)
|
||||
pg.add_init_script('requestAnimationFrame = () => 0;')
|
||||
pg.goto(url); pg.evaluate('CAPS_CHECK')
|
||||
if gpu_mode == 'soft' and not pg.evaluate('CAPS.webgpu'):
|
||||
print('SKIP WebGPU parity: software browser has no WebGPU adapter')
|
||||
sys.exit(0)
|
||||
assert pg.evaluate('GPU.ok'), pg.evaluate('GPU.error')
|
||||
pg.evaluate('generateWorld(424242, 640, 384, null); enterGame(); GAME.paused=true;')
|
||||
pg.evaluate('() => {' + (root / 'tools/scenes/t88-renderer.js').read_text() + '}')
|
||||
result = pg.evaluate("""() => {
|
||||
SET.look='pixel'; SET.zoom=6; SET.glow='off'; SET.lighting='off';
|
||||
resizeRender(); bot.x=CAM[0]-bot.w/2; bot.y=CAM[1]-bot.h/2; snapCamera(); R.frame=1000;
|
||||
consume_gpu_option()
|
||||
root=pathlib.Path(__file__).resolve().parent.parent
|
||||
with launch(viewport={'width':960,'height':600}) as (_,pg,url):
|
||||
errors=[]
|
||||
pg.on('pageerror',lambda e:errors.append(str(e)))
|
||||
pg.on('console',lambda m:errors.append(m.text) if m.type=='error' else None)
|
||||
pg.add_init_script('requestAnimationFrame=()=>0;')
|
||||
pg.goto(url);pg.evaluate('CAPS_CHECK')
|
||||
assert pg.evaluate('GPU.ok'),pg.evaluate('GPU.error')
|
||||
assert pg.evaluate('GPU.device.limits.maxStorageTexturesPerShaderStage===4')
|
||||
print('PASS all pipelines compile with default limits',pg.evaluate('GPU_LIMITS'))
|
||||
startup=pg.evaluate("""async()=>{
|
||||
await startNewWorld(424242,640,384); GAME.paused=true;
|
||||
const samples=[];
|
||||
for(let n=0;n<10;n++) {const t=performance.now();drawWorld();await GPU.device.queue.onSubmittedWorkDone();samples.push(performance.now()-t);}
|
||||
return {compile:GPU.initMs,warm:GPU.warmMs,first:samples[0],normal:samples.slice(1)};
|
||||
}""")
|
||||
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();")
|
||||
result=pg.evaluate('''async () => {
|
||||
const results=[];
|
||||
for(const rain of [0,0.7])for(const lit of [false,true])for(const lens of [false,true])for(const palette of ['inferno','ironbow','gray'])for(const fogOn of [false,true]) {
|
||||
SET.lighting=lit?'full':'off'; SET.glow=lit?'low':'off';
|
||||
R.lens=lens; SET.thermalPalette=palette; SET.fog=fogOn;
|
||||
R.rain=rain; SET.renderer='legacy';drawWorld(); const a=worldPixelContext().getImageData(0,0,960,600).data;
|
||||
R.rain=rain; SET.renderer='webgpu';drawWorld(); const b=worldPixelContext().getImageData(0,0,960,600).data;
|
||||
let max=0, sum=0, bad=0;
|
||||
for(let i=0;i<a.length;i+=4) {let d=0;for(let j=0;j<3;j++){const e=Math.abs(a[i+j]-b[i+j]);sum+=e;d=Math.max(d,e);}max=Math.max(max,d);bad+=d>8;}
|
||||
results.push({rain,lit,lens,palette,fogOn,max,mean:sum/(960*600*3),bad});
|
||||
}
|
||||
R.lens=false;
|
||||
return results;
|
||||
}""")
|
||||
for r in result:
|
||||
assert r['bad'] == 0, r
|
||||
print('PASS plain cells, built geometry, ghosts, rain and all thermal palettes match legacy within 8', len(result), 'configurations; max', max(r['max'] for r in result))
|
||||
assert pg.evaluate('GPU.overrideCount > 0 && GPU.overrideCount < R.bw*R.bh*0.25')
|
||||
alpha = pg.evaluate('[R.gpuOn,GPU.ok,GPU.error,R.ctx.getImageData(10,10,1,1).data[3],worldPixelContext().getImageData(10,10,1,1).data[3],R.canvas.width,R.canvas.height]')
|
||||
assert alpha[3:5] == [0,255], alpha
|
||||
print('PASS most visible grains use GPU colouring; pixel reads include the opaque GPU world')
|
||||
glow = pg.evaluate("""() => {
|
||||
SET.fog=false; SET.lighting='off'; SET.glow='high'; R.lens=false;
|
||||
spawnBar(BARFE,CAM[0]-30,CAM[1]+15,0,0,20);
|
||||
const x=CAM[0]-64,y=CAM[1]-36;
|
||||
setCell(y*W+x,MAGMA,1450,F_NAT);
|
||||
flyers.push({x,y,m:COAL,rain:false});
|
||||
SET.renderer='legacy';drawWorld();const a=worldPixelContext().getImageData(0,0,960,600).data;
|
||||
SET.renderer='webgpu';drawWorld();const b=worldPixelContext().getImageData(0,0,960,600).data;
|
||||
let max=0;for(let i=0;i<a.length;i++)if((i&3)!==3)max=Math.max(max,Math.abs(a[i]-b[i]));return max;
|
||||
}""")
|
||||
assert glow <= 8, f'Objects must retain the hot grain glow beneath them: max error {glow}'
|
||||
print('PASS bars and flying grains preserve the glow behind them', glow)
|
||||
# Fresh arrays, same dimensions: catches stale world/static textures after a load.
|
||||
pg.evaluate('generateWorld(777, 640, 384, null); buildSky(); SET.renderer="webgpu"; SET.zoom=2; resizeRender(); snapCamera(); drawWorld();')
|
||||
assert pg.evaluate('GPU.world===mat && GPU.colourTex.width===R.bw && GPU.ok')
|
||||
parity = pg.evaluate("""() => {
|
||||
const a=worldPixelContext().getImageData(0,0,960,600).data;
|
||||
SET.renderer='legacy';drawWorld();const b=worldPixelContext().getImageData(0,0,960,600).data;
|
||||
SET.renderer='webgpu'; let max=0,nonblack=0;
|
||||
for(let i=0;i<a.length;i+=4){nonblack+=a[i]+a[i+1]+a[i+2]>0;for(let j=0;j<3;j++)max=Math.max(max,Math.abs(a[i+j]-b[i+j]));}
|
||||
return {max,nonblack};
|
||||
}""")
|
||||
assert parity['max'] <= 8 and parity['nonblack'] > 1000, parity
|
||||
print('PASS world replacement and zoom reallocate textures and draw the new world', parity)
|
||||
pg.evaluate('SET.look="smooth"; drawWorld();')
|
||||
assert pg.evaluate('R.gpuOn && R.smoothOn && !GPU.canvas.hidden')
|
||||
print('PASS smooth uses WebGPU')
|
||||
pg.evaluate('() => {' + (root / 'tools/scenes/t88-renderer.js').read_text() + '}')
|
||||
pg.add_script_tag(path=str(root / 'test/gpu-readback.js'))
|
||||
# Compare all four smooth inputs, including valid zero tags and class-zero overrides.
|
||||
smooth_inputs = pg.evaluate("""async () => {
|
||||
// Every material also gets a plain, unowned cell (including part materials).
|
||||
for(let m=0;m<NMAT;m++) {
|
||||
const x=CAM[0]-16+(m%11)*3,y=CAM[1]-40+Math.floor(m/11)*2,i=y*W+x;
|
||||
setCell(i,m,20,0);part[i]=mach[i]=occ[i]=bgm[i]=0;blockP[(y>>2)*BW+(x>>2)]=0;
|
||||
}
|
||||
const results=[];
|
||||
const now=performance.now.bind(performance); performance.now=()=>5000; SM.t0=0;
|
||||
try {
|
||||
for(const zoom of [12,6,2])for(const fogOn of [false,true]) {
|
||||
SET.zoom=zoom;SET.fog=fogOn;SET.look='smooth';R.lens=false;SET.lighting='off';SET.glow='off';
|
||||
resizeRender();bot.x=CAM[0]-bot.w/2;bot.y=CAM[1]-bot.h/2;snapCamera();R.frame=999;
|
||||
SET.renderer='legacy';SM.scale=1;drawWorld();
|
||||
const expected=[R.px.slice(),R.backPx.slice(),R.treePx.slice(),R.fieldPx.slice()];
|
||||
SET.renderer='webgpu';drawWorld();
|
||||
const actual=await testSmoothInputs(); let alpha=0,max=0,bad=0,examples=[];
|
||||
for(let k=0;k<R.px.length;k++)for(let t=0;t<4;t++) {
|
||||
const a=expected[t][k],b=actual[k*4+t];if((a>>>24)!==(b>>>24))alpha++;
|
||||
for(let c=0;c<3;c++){const diff=Math.abs((a>>>(c*8)&255)-(b>>>(c*8)&255));max=Math.max(max,diff);if(diff>1){bad++;if(examples.length<3)examples.push({k,t,a,b});}}
|
||||
for(let n=0;n<240;n++) {
|
||||
simTick();
|
||||
if(n%20===0) {
|
||||
fogLook(bot.x,bot.y); R.camX=(n*7)%400; R.camY=(n*3)%280;
|
||||
SET.look=n%40?'smooth':'pixel';R.frame++;drawWorld();
|
||||
results.push(await gpuCheckUploads());
|
||||
}
|
||||
results.push({zoom,fogOn,alpha,max,bad,examples,share:GPU.overrideCount/R.px.length});
|
||||
}
|
||||
return results;
|
||||
} finally { performance.now=now; }
|
||||
drawWorld();await gpuCheckUploads();
|
||||
drawWorld();return {checked:results,cleanCells:GPU.uploadCells};
|
||||
}''')
|
||||
assert min(result['checked'])>1000 and result['cleanCells']==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=[];
|
||||
const now=performance.now.bind(performance),frozen=now();performance.now=()=>frozen;
|
||||
try {
|
||||
for(const look of ['pixel','smooth'])for(const light of ['full','fast','off'])for(const lens of [false,true])for(const fogOn of [false,true]) {
|
||||
SET.look=look;SET.lighting=light;R.lens=lens;SET.fog=fogOn;R.frame++;
|
||||
R.rain=0;drawWorld();await GPU.device.queue.onSubmittedWorkDone();
|
||||
const a=worldPixelContext().getImageData(0,0,960,600).data;
|
||||
GPU.resident.fill(0);R.rain=0;drawWorld();await GPU.device.queue.onSubmittedWorkDone();
|
||||
const b=worldPixelContext().getImageData(0,0,960,600).data;
|
||||
let max=0;for(let i=0;i<a.length;i++)max=Math.max(max,Math.abs(a[i]-b[i]));
|
||||
results.push({look,light,lens,fogOn,max});
|
||||
}
|
||||
R.lens=false;SET.lighting='full';return results;
|
||||
} finally {performance.now=now;}
|
||||
}""")
|
||||
for r in smooth_inputs:
|
||||
assert r['alpha'] == 0 and r['bad'] == 0, r
|
||||
print('PASS all four smooth inputs match legacy: classes and flags exact, RGB within 1', smooth_inputs)
|
||||
pg.evaluate('SET.look="pixel"; SET.renderer="webgpu"; PERF_MARKS=true; drawWorld();')
|
||||
# This fixture stops requestAnimationFrame; poll asynchronous GPU events by timer.
|
||||
pg.wait_for_function('!GPU.timingBusy', polling=10)
|
||||
assert pg.evaluate('!GPU.queries || GPU.gpuMs>=0')
|
||||
assert pg.evaluate('perfMarks.override>=0 && perfMarks.upload>=0')
|
||||
print('PASS separate override/upload clocks and optional GPU timestamps')
|
||||
pg.evaluate('() => {' + (root / 'tools/scenes/t88-light.js').read_text() + '}')
|
||||
# Draw GPU first with no CPU grid and forbid accidental CPU lighting calls.
|
||||
pg.evaluate("""() => {
|
||||
window.savedComputeLight=computeLight;
|
||||
computeLight=()=>{throw Error('CPU light called in WebGPU');};
|
||||
R.lr=R.lg=R.lb=R.latt=R.lightPx=null; R.lw=R.lh=0;
|
||||
SET.renderer='webgpu';SET.look='pixel';SET.zoom=6;SET.lighting='full';SET.lightEvery=1;
|
||||
resizeRender();bot.x=CAM[0]-bot.w/2;bot.y=CAM[1]-bot.h/2;snapCamera();drawWorld();
|
||||
computeLight=savedComputeLight;
|
||||
}""")
|
||||
for mode in ['full', 'fast']:
|
||||
for case in ['lamp', 'opening', 'magma', 'fire']:
|
||||
parity = pg.evaluate("""([mode,kind]) => {
|
||||
lightScene(kind);SET.lighting=mode;SET.lightEvery=1;R.frame=1000;R.rain=0;
|
||||
SET.renderer='legacy';drawWorld();const a=worldPixelContext().getImageData(0,0,960,600).data;
|
||||
R.rain=0;SET.renderer='webgpu';drawWorld();const b=worldPixelContext().getImageData(0,0,960,600).data;
|
||||
let max=0,bad=0,sum=0;
|
||||
for(let i=0;i<a.length;i+=4){let d=0;for(let j=0;j<3;j++){const e=Math.abs(a[i+j]-b[i+j]);sum+=e;d=Math.max(d,e);}max=Math.max(max,d);bad+=d>8;}
|
||||
return {mode,kind,max,bad,mean:sum/(960*600*3)};
|
||||
}""", [mode,case])
|
||||
assert parity['bad'] == 0, parity
|
||||
print('PASS light scene', parity)
|
||||
cadence = pg.evaluate("""() => {
|
||||
assert all(r['max']==0 for r in parity),parity
|
||||
print('PASS dirty and forced full uploads render identically in',len(parity),'look/light/lens/fog configurations')
|
||||
cadence=pg.evaluate("""()=>{
|
||||
SET.lightEvery=3;R.frame=1002;drawWorld();const old=GPU.lightFrame,origin=R.lx0;
|
||||
bot.lamp=!bot.lamp;R.camX+=4;R.frame=1003;drawWorld();
|
||||
const held=GPU.lightFrame===old && R.lx0===origin;
|
||||
R.camX+=4;R.frame=1003;drawWorld();const held=GPU.lightFrame===old && R.lx0===origin;
|
||||
R.frame=1005;drawWorld();const updated=GPU.lightFrame===1005 && R.lx0!==origin;
|
||||
SET.lightEvery=1;return {held,updated};
|
||||
}""")
|
||||
assert all(cadence.values()), cadence
|
||||
print('PASS GPU-only lighting, both resolutions and lightEvery cadence')
|
||||
switches = pg.evaluate("""() => {
|
||||
const original=computeLight,frames=[];
|
||||
computeLight=()=>{throw Error('CPU light called in WebGPU smooth');};
|
||||
try {
|
||||
SET.lightEvery=100;
|
||||
for(const look of ['smooth','pixel','smooth']) {
|
||||
SET.look=look;R.frame++;drawWorld();
|
||||
frames.push(R.gpuOn && GPU.lightFrame===R.frame && R.smoothOn===(look==='smooth'));
|
||||
}
|
||||
return frames;
|
||||
} finally {computeLight=original;SET.lightEvery=1;}
|
||||
assert all(cadence.values()),cadence
|
||||
print('PASS lighting cadence holds and then updates its camera origin')
|
||||
# Force paths that can mutate sleeping chunks, away from the busy factory.
|
||||
pg.evaluate('''() => {
|
||||
const i=500*W+800; setCell(i,STONE,20,F_NAT);digp[i]=120;renderDirtyIdx(i);
|
||||
setTreeCell(801,450,WOOD);fogRevealAll();
|
||||
R.camX=650;R.camY=400;drawWorld();
|
||||
}''')
|
||||
assert pg.evaluate('gpuCheckUploads()')>1000
|
||||
pg.evaluate('generateWorld(777,640,384,null);buildSky();resizeRender();snapCamera();drawWorld();')
|
||||
assert pg.evaluate('GPU.world===mat && GPU.ok')
|
||||
assert pg.evaluate('gpuCheckUploads()')>1000
|
||||
print('PASS world replacement refreshes resident chunks and static data')
|
||||
assert pg.evaluate('R.ctx.getImageData(10,10,1,1).data[3]===0 && worldPixelContext().getImageData(10,10,1,1).data[3]===255')
|
||||
print('PASS GPU world stays opaque under transparent overlays')
|
||||
cracks=pg.evaluate("""async()=>{
|
||||
SET.zoom=12;SET.look='pixel';SET.fog=false;SET.lighting='off';SET.glow='off';SET.grain=0;
|
||||
resizeRender();R.camX=200;R.camY=160;
|
||||
for(let y=170;y<192;y++)for(let x=215;x<260;x++)setCell(y*W+x,STONE,20,F_NAT);
|
||||
drawWorld();const clean=worldPixelContext().getImageData(0,0,960,600).data;
|
||||
for(let y=170;y<192;y++)for(let x=215;x<260;x++){digp[y*W+x]=240;renderDirtyXY(x,y);}
|
||||
R.frame=1000;drawWorld();await GPU.device.queue.onSubmittedWorkDone();
|
||||
const damaged=worldPixelContext().getImageData(0,0,960,600).data;
|
||||
let changed=0;for(let i=0;i<clean.length;i+=4)changed+=clean[i]!==damaged[i];
|
||||
return changed;
|
||||
}""")
|
||||
assert all(switches), switches
|
||||
print('PASS switching either look refreshes new light textures without CPU lighting')
|
||||
assert pg.evaluate('!GPU.queries || GPU.lightMs>=0')
|
||||
pg.evaluate('GPU.device.destroy()')
|
||||
pg.wait_for_function('!GPU.ok', polling=10)
|
||||
assert pg.evaluate('SET.renderer==="legacy"')
|
||||
assert cracks>1000,cracks
|
||||
pg.screenshot(path=str(root/'shots/t88-drill-pixel.png'))
|
||||
pg.evaluate("SET.look='smooth';drawWorld()")
|
||||
pg.screenshot(path=str(root/'shots/t88-drill-smooth.png'))
|
||||
print('PASS stationary drill damage changes GPU colour in both looks',cracks)
|
||||
timing=pg.evaluate('''async () => {
|
||||
const originalC=GPU.device.createComputePipeline,originalR=GPU.device.createRenderPipeline;
|
||||
GPU.device.createComputePipeline=GPU.device.createRenderPipeline=()=>{throw Error('synchronous pipeline compilation');};
|
||||
const samples=[];
|
||||
try {
|
||||
for(const [look,zoom] of [['pixel',12],['smooth',12],['smooth',2],['pixel',2],['smooth',6]]) {
|
||||
SET.look=look;SET.zoom=zoom;resizeRender();snapCamera();const start=performance.now();drawWorld();
|
||||
await GPU.device.queue.onSubmittedWorkDone();samples.push({look,zoom,first:performance.now()-start});
|
||||
const times=[];for(let n=0;n<8;n++){const t=performance.now();drawWorld();await GPU.device.queue.onSubmittedWorkDone();times.push(performance.now()-t);}
|
||||
samples.at(-1).normal=Math.max(...times);
|
||||
}
|
||||
} finally {GPU.device.createComputePipeline=originalC;GPU.device.createRenderPipeline=originalR;}
|
||||
return {init:GPU.initMs,samples};
|
||||
}''')
|
||||
print('PASS look and zoom switches create no pipelines; timings ms',timing)
|
||||
# Successful recovery must recreate resources and preserve simulation arrays.
|
||||
pg.evaluate('window.savedMat=mat; GPU.device.destroy();')
|
||||
pg.wait_for_function('GPU.ok && !GPU.recovering',polling=20,timeout=60000)
|
||||
assert pg.evaluate('mat===savedMat')
|
||||
pg.evaluate('drawWorld()')
|
||||
print('PASS device loss restores the legacy renderer')
|
||||
assert not errors, errors
|
||||
assert pg.evaluate('gpuCheckUploads()')>1000
|
||||
print('PASS device loss recreates the device once without changing the world')
|
||||
pg.evaluate("Object.defineProperty(navigator,'gpu',{value:{requestAdapter:async()=>null}});GPU.device.destroy();")
|
||||
pg.wait_for_function('GPU.stopped',polling=20,timeout=60000)
|
||||
assert pg.evaluate('GAME.paused') and 'Reload' in pg.inner_text('#m-dialog')
|
||||
assert pg.evaluate("DB.get(GAME.worldId+':auto').then(s=>!!s)")
|
||||
print('PASS failed recovery stops frames, autosaves and offers Reload')
|
||||
assert not errors,errors
|
||||
print('PASS no page or WebGPU validation errors')
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Frozen legacy references, captured before retirement; compare the WebGPU world only."""
|
||||
import base64, 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
|
||||
consume_gpu_option()
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
GOLD = ROOT / 'test/fixtures/golden'
|
||||
with launch(viewport={'width': 640, 'height': 480}) as (_, pg, url):
|
||||
errors=[]
|
||||
pg.on('pageerror', lambda e: errors.append(str(e)))
|
||||
pg.on('console', lambda m: errors.append(m.text) if m.type=='error' else None)
|
||||
pg.add_init_script('requestAnimationFrame=()=>0;')
|
||||
pg.goto(url); pg.evaluate('CAPS_CHECK')
|
||||
assert pg.evaluate('GPU.ok'), pg.evaluate('GPU.error')
|
||||
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)
|
||||
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']:
|
||||
pg.evaluate('s=>lightScene(s)',scene)
|
||||
if scene=='joins':
|
||||
pg.evaluate('SET.fog=false; CAM=[280,98];')
|
||||
if scene=='sky':
|
||||
pg.evaluate('CAM=[320,55]; SET.fog=false;')
|
||||
pg.evaluate("""([look,zoom])=>{
|
||||
SET.look=look;SET.zoom=zoom;SET.lighting='full';SET.lightEvery=1;SET.glow='low';
|
||||
SM.t0=0; SM.surf=-1;
|
||||
performance.now=()=>5000;resizeRender();bot.x=CAM[0]-bot.w/2;bot.y=CAM[1]-bot.h/2;
|
||||
snapCamera();R.frame=1000;drawWorld();
|
||||
}""",[look,zoom])
|
||||
raw=pg.evaluate("""() => {
|
||||
const c=document.createElement('canvas');c.width=384;c.height=288;
|
||||
c.getContext('2d').drawImage(worldPixelContext().canvas,128,96,384,288,0,0,384,288);
|
||||
return c.toDataURL().split(',')[1];
|
||||
}""")
|
||||
path=GOLD/f'{scene}-{look}-{zoom}.png'
|
||||
actual=base64.b64decode(raw)
|
||||
rgb=lambda data: subprocess.run(['magick','png:-','-alpha','off','-depth','8','rgb:-'],input=data,capture_output=True,check=True).stdout
|
||||
a,b=rgb(actual),rgb(path.read_bytes())
|
||||
ds=[abs(x-y) for x,y in zip(a,b)]
|
||||
mean=sum(ds)/len(ds);bad=sum(max(ds[i:i+3])>8 for i in range(0,len(ds),3))
|
||||
assert mean<0.5 and bad<384*288*0.003, (path.name,mean,bad)
|
||||
print('PASS golden',path.name,round(mean,4),bad)
|
||||
(ROOT/'shots'/('golden-'+path.name)).write_bytes(actual)
|
||||
assert not errors,errors
|
||||
@@ -0,0 +1,43 @@
|
||||
"""The former CPU light behavioural checks, now read from the actual GPU light grid."""
|
||||
import pathlib,sys
|
||||
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
|
||||
consume_gpu_option()
|
||||
root=pathlib.Path(__file__).resolve().parent.parent
|
||||
with launch(viewport={'width':636,'height':156}) as (_,pg,url):
|
||||
errors=[]
|
||||
pg.on('pageerror',lambda e:errors.append(str(e)))
|
||||
pg.on('console',lambda m:errors.append(m.text) if m.type=='error' else None)
|
||||
pg.add_init_script('requestAnimationFrame=()=>0;')
|
||||
pg.goto(url);pg.evaluate('CAPS_CHECK')
|
||||
pg.add_script_tag(path=str(root/'test/light-readback.js'))
|
||||
source=(root/'test/light-cases.js').read_text()
|
||||
result=pg.evaluate("""async source=>{
|
||||
const results=[];
|
||||
function check(name,ok,detail){results.push({name,ok:!!ok,detail});}
|
||||
function done(){}
|
||||
async function run(code){
|
||||
code=code.replaceAll('computeLight();','await testLight();');
|
||||
let value;
|
||||
if(code.includes('await ')) {
|
||||
if(code.trim().startsWith('(() =>')) code=code.replace('(() =>','(async () =>');
|
||||
else code='(async()=>{'+code+'})()';
|
||||
}
|
||||
value=await (0,eval)(code);
|
||||
if(!code.includes('testLight()')) await testLight();
|
||||
return value;
|
||||
}
|
||||
async function flatWorld(run,w,h,g){
|
||||
await run(`alloc(${w},${h});worldSeed=7;for(let x=0;x<W;x++){surfY[x]=${g};dirtY[x]=${g}+10;graniteY[x]=basaltY[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>=${g}?STONE:EMPTY;flags[i]=y>=${g}?F_NAT:0;temp[i]=AMB[y];}
|
||||
bot.x=(W>>1)-8;bot.y=${g}-30;bag=new Int32Array(NMAT);bagTotal=0;player.tankTier=3;updateRoof();resetWeather();weather.next=1e15;buildSky();`);
|
||||
}
|
||||
SET.zoom=2;SET.look='pixel';SET.fog=false;SET.glow='off';SET.daylight=1;SET.ambient=0.12;SET.lightEvery=1;
|
||||
initRender(canvas);resizeRender();R.camX=0;R.camY=100;
|
||||
return await eval('(async()=>{'+source+';return results;})()');
|
||||
}""",source)
|
||||
for r in result:
|
||||
print(('PASS ' if r['ok'] else 'FAIL ')+r['name']+' '+str(r.get('detail','')))
|
||||
assert all(r['ok'] for r in result)
|
||||
assert not errors,errors
|
||||
@@ -0,0 +1,18 @@
|
||||
// Render invalidation must not depend on whether a grain moved or the chunk stayed awake.
|
||||
const {load,suite,flatWorld}=require('./harness');
|
||||
const {run}=load(),{check,done}=suite();
|
||||
flatWorld(run,128,128,100);
|
||||
function marked(code,x,y){run('renderDirty.fill(0);'+code);return run(`!!renderDirty[(${y}>>5)*CW+(${x}>>5)]`);}
|
||||
run('setCell(65*W+65,STONE,20,F_NAT);bot.x=63;bot.y=60;');
|
||||
check('stationary drill damage invalidates the rock',marked('toolDig(65,65,0);',65,65) && run('digp[65*W+65]>0 && mat[65*W+65]===STONE'));
|
||||
check('waking a cell invalidates its render chunk',marked('wakeXY(31,31);',31,31));
|
||||
check('swapping cells invalidates both render chunks',marked('swapCells(10*W+10,70*W+70);',70,70)&&run('!!renderDirty[0]'));
|
||||
check('exploration invalidates its render chunk',marked('fogMark(70,70,255);',70,70));
|
||||
check('revealing the whole map invalidates every chunk',marked('fogRevealAll();',70,70)&&run('renderDirty.every(v=>v===1)'));
|
||||
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'));
|
||||
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'));
|
||||
done();
|
||||
@@ -1,4 +1,4 @@
|
||||
"""The smooth look: the WebGPU pass compiles, draws, falls back cleanly, and the thermal lens still works."""
|
||||
"""The smooth look: the WebGPU pass compiles and draws, and the thermal lens still works."""
|
||||
import sys, pathlib
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / 'tools'))
|
||||
from use_venv import ensure_playwright; ensure_playwright()
|
||||
@@ -19,7 +19,7 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
|
||||
pg.click('#btn-new'); pg.fill('#nw-seed', '777'); pg.click('#btn-generate')
|
||||
pg.wait_for_function('GAME.running === true', timeout=20000); pg.wait_for_timeout(1500)
|
||||
ev = pg.evaluate
|
||||
check('the smooth look is the default and is drawing', ev("SET.look === 'smooth' && GPU.ok && R.gpuOn && R.smoothOn"), ev('[SET.look, SM.ok, SM.why, R.smoothOn]'))
|
||||
check('the smooth look is the default and is drawing', ev("SET.look === 'smooth' && GPU.ok && R.gpuOn && R.smoothOn"), ev('[SET.look, GPU.ok, GPU.error, R.smoothOn]'))
|
||||
# the canvas shows the GPU image: sample a sky pixel and a ground pixel, they must differ and not be black
|
||||
px = ev("""(() => { const c = worldPixelContext().getImageData(R.canvas.width >> 1, 4, 1, 1).data, g = worldToScreenPx(bot.x + 2, surfY[Math.floor(bot.x)] + 6), d = worldPixelContext().getImageData(Math.round(g.x), Math.round(g.y), 1, 1).data; return [Array.from(c), Array.from(d)]; })()""")
|
||||
check('it draws sky and ground', sum(px[0][:3]) > 200 and px[0][:3] != px[1][:3], px)
|
||||
@@ -27,15 +27,11 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
|
||||
ev("player.lens = true"); pg.keyboard.press('h'); pg.wait_for_timeout(1500)
|
||||
check('the thermal lens draws in pixels', ev('R.lens && !R.smoothOn'))
|
||||
pg.keyboard.press('h'); pg.wait_for_timeout(1500)
|
||||
check('and the smooth look comes back after it', ev('!R.lens && R.smoothOn'), ev('[R.lens, R.smoothOn, SET.look, SM.ok, SM.why, SM.ms, SM.scale]'))
|
||||
check('and the smooth look comes back after it', ev('!R.lens && R.smoothOn'), ev('[R.lens, R.smoothOn, SET.look, GPU.ok, GPU.error]'))
|
||||
ev("SET.look = 'pixel'"); pg.wait_for_timeout(1500)
|
||||
check('the pixel look can be chosen', ev('!R.smoothOn'))
|
||||
pg.screenshot(path=str(out / 'pixel.png'))
|
||||
ev("SET.look = 'smooth'"); pg.wait_for_timeout(1500)
|
||||
ev("SET.renderer = 'legacy'; SM.scale = 0.5"); pg.wait_for_timeout(1500)
|
||||
st = ev('[R.smoothOn, SM.scale, SM.canvas.width, R.canvas.width, SET.look, SM.ok, SM.why]'); check('half-resolution smooth still draws', (st[0] and st[2] == round(st[3] * st[1])) or st[1] > 0.5, st)
|
||||
ev("SET.renderer = 'webgpu'"); pg.wait_for_timeout(300)
|
||||
check('smooth returns to WebGPU after legacy comparison', ev('R.gpuOn && R.smoothOn'))
|
||||
# Uniform colours isolate the solid/loose seam from material noise and lighting.
|
||||
ground = ev('''() => {
|
||||
GAME.paused = true; requestAnimationFrame = () => 0;
|
||||
@@ -50,16 +46,16 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
|
||||
const at = (x, y) => { const p=worldToScreenPx(x+0.5,y+0.5), c=worldPixelContext().getImageData(p.x,p.y,1,1).data; return c[0]+c[1]+c[2]; };
|
||||
const out = {};
|
||||
for (const lens of [false, true]) {
|
||||
R.lens = lens; SET.look = 'pixel'; fill(RSTONE); drawWorld();
|
||||
R.lens = lens; SET.look = 'pixel'; fill(RSTONE); renderDirty.fill(1); GPU.world=null; drawWorld();
|
||||
const edge = at(140, 140), inside = at(140, 142), loose = at(140, 130);
|
||||
fill(STONE); drawWorld();
|
||||
fill(STONE); renderDirty.fill(1); GPU.world=null; drawWorld();
|
||||
out[lens ? 'thermal' : 'pixel'] = [edge, inside, loose, at(140, 140)];
|
||||
}
|
||||
R.lens = false;
|
||||
const i = 140 * W + 140;
|
||||
flags[i] = 0; fill(RSTONE); drawWorld(); const built = at(140, 140);
|
||||
fill(STONE); drawWorld(); out.built = [built, at(140, 140)]; flags[i] = F_NAT;
|
||||
fill(RSTONE); SET.look = 'smooth'; SM.scale = 1; drawWorld();
|
||||
flags[i] = 0; fill(RSTONE); renderDirty.fill(1); GPU.world=null; drawWorld(); const built = at(140, 140);
|
||||
fill(STONE); renderDirty.fill(1); GPU.world=null; drawWorld(); out.built = [built, at(140, 140)]; flags[i] = F_NAT;
|
||||
fill(RSTONE); SET.look = 'smooth'; renderDirty.fill(1); GPU.world=null; drawWorld();
|
||||
const sample = (x, y) => {
|
||||
const p = worldToScreenPx(x, y);
|
||||
const c = worldPixelContext().getImageData(Math.floor(p.x), Math.floor(p.y), 1, 1).data;
|
||||
@@ -125,8 +121,8 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
|
||||
check('off hides both label and vein outline', not cursor['off'] and cursor['offStrokes'] == 0)
|
||||
for look in ['pixel', 'smooth']:
|
||||
ev("""look => {
|
||||
SET.look=look; SM.scale=1; SET.cursorLabel='ores'; GAME.aim={x:124,y:122}; cursorLabelKey=null;
|
||||
$('cards').replaceChildren(); renderInfo(); drawWorld(); drawHoveredVein(); drawBot();
|
||||
SET.look=look; SET.cursorLabel='ores'; GAME.aim={x:124,y:122}; cursorLabelKey=null;
|
||||
$('cards').replaceChildren(); renderInfo(); renderDirty.fill(1); GPU.world=null; drawWorld(); drawHoveredVein(); drawBot();
|
||||
const p=worldToScreenPx(124,122); drawCursorLabel(p.x,p.y,1);
|
||||
}""", look)
|
||||
pg.screenshot(path=str(root / 'shots' / ('t77-vein.png' if look == 'pixel' else 't77-vein-smooth.png')))
|
||||
@@ -145,8 +141,8 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
|
||||
setCell(cell,SAND,22,0);setCell(control,SAND,22,0);
|
||||
noiseB[cell]=noiseB[control]=128; GPU.world=null;
|
||||
const at=i=>{ if(R.smoothOn) return R.px[(((i/W)|0)-R.vy0)*R.bw+i%W-R.vx0]; const p=worldToScreenPx(i%W+0.5,((i/W)|0)+0.5), c=worldPixelContext().getImageData(p.x,p.y,1,1).data; return c[0]|c[1]<<8|c[2]<<16; };
|
||||
SET.look='pixel';drawWorld();const colors=[at(cell)&0xffffff,at(control)&0xffffff];
|
||||
SET.look='smooth';drawWorld();const grainClass=at(cell)>>>24;
|
||||
SET.look='pixel';renderDirty.fill(1); GPU.world=null; drawWorld();const colors=[at(cell)&0xffffff,at(control)&0xffffff];
|
||||
SET.look='smooth';renderDirty.fill(1); GPU.world=null; drawWorld();const grainClass=at(cell)>>>24;
|
||||
return {colors,grainClass};
|
||||
}''')
|
||||
check('sidewall stays behind the grain colour in pixels and smooth',
|
||||
@@ -163,7 +159,7 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
|
||||
try {
|
||||
const out={};
|
||||
for(const look of ["pixel","smooth"]) {
|
||||
SET.look=look;SM.scale=1;drawWorld();
|
||||
SET.look=look;renderDirty.fill(1); GPU.world=null; drawWorld();
|
||||
const bad=[], copper=c=>c[0]>c[1]*1.15 && c[1]>c[2]*1.1;
|
||||
const sample=(x,y)=>{const p=worldToScreenPx(x,y);return Array.from(worldPixelContext().getImageData(Math.floor(p.x),Math.floor(p.y),1,1).data);};
|
||||
for(const j of JOIN_CASES.filter(j=>j.code===BP_SCREW)) {
|
||||
@@ -215,9 +211,9 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
|
||||
const plan=new Map([[b,[BP_SCREW,j.v]],[nb,[BP_SCREW,j.straight]]]);
|
||||
removeBlock(j.bx,j.by);removeBlock(j.nx,j.ny);
|
||||
const sample=(x,y)=>{const p=worldToScreenPx(j.nx*4+x,j.ny*4+y);return Array.from(worldPixelContext().getImageData(Math.floor(p.x),Math.floor(p.y),1,1).data).join(',');};
|
||||
SM.scale=1;drawWorld();const bg=sample(ux,uy),empty=sample(2,2);
|
||||
renderDirty.fill(1); GPU.world=null; drawWorld();const bg=sample(ux,uy),empty=sample(2,2);
|
||||
for(const [n,piece] of plan)ghostBlocks.set(n,piece);
|
||||
drawWorld();
|
||||
renderDirty.fill(1); GPU.world=null; drawWorld();
|
||||
if(sample(ux,uy)!==bg || sample(2,2)===empty)bad.push(['ghost elbow silhouette',look,j]);
|
||||
ghostBlocks.clear();
|
||||
const ctx=R.ctx,canvas=document.createElement('canvas');canvas.width=canvas.height=192;
|
||||
@@ -234,16 +230,16 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
|
||||
}''')
|
||||
check('both looks: ghosts and planned previews leave the unused elbow corner empty', not previews, previews[:2])
|
||||
tree_tags = ev('''() => {
|
||||
SET.look='smooth';SM.scale=1;
|
||||
SET.look='smooth';
|
||||
const now=performance.now.bind(performance),frozen=now();performance.now=()=>frozen;
|
||||
try {
|
||||
const j=JOIN_CASES.find(j=>j.v===5 && j.end===1 && !j.vertical && j.spot===2 && j.code===BP_SCREW);
|
||||
const bx=j.nx+1,by=j.ny;
|
||||
const sample=()=>{const p=worldToScreenPx(bx*4,by*4);return Array.from(worldPixelContext().getImageData(Math.floor(p.x),Math.floor(p.y),4*R.zp,4*R.zp).data).join(',');};
|
||||
drawWorld();const plain=sample(),bad=[];
|
||||
renderDirty.fill(1); GPU.world=null; drawWorld();const plain=sample(),bad=[];
|
||||
for(const m of [WOOD,LEAVES]) {
|
||||
for(let y=by*4;y<by*4+4;y++)for(let x=bx*4;x<bx*4+4;x++)bgm[y*W+x]=m;
|
||||
drawWorld();if(sample()!==plain)bad.push(m);
|
||||
renderDirty.fill(1); GPU.world=null; drawWorld();if(sample()!==plain)bad.push(m);
|
||||
}
|
||||
for(let y=by*4;y<by*4+4;y++)for(let x=bx*4;x<bx*4+4;x++)bgm[y*W+x]=0;
|
||||
return bad;
|
||||
@@ -257,8 +253,8 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
|
||||
slopeDetailAt=()=>0;performance.now=()=>frozen;
|
||||
const sample=()=>{const p=worldToScreenPx(j.bx*BS+0.4,j.by*BS+2.5);return Array.from(worldPixelContext().getImageData(Math.floor(p.x),Math.floor(p.y),1,1).data);};
|
||||
for(const look of ['pixel','smooth']) {
|
||||
SET.look=look;SM.scale=1;drawWorld();const joined=sample();
|
||||
removeBlock(j.nx,j.ny);drawWorld();const free=sample();
|
||||
SET.look=look;renderDirty.fill(1); GPU.world=null; drawWorld();const joined=sample();
|
||||
removeBlock(j.nx,j.ny);renderDirty.fill(1); GPU.world=null; drawWorld();const free=sample();
|
||||
placeBlock(BP_BELT,j.straight,j.nx,j.ny,true);
|
||||
out[look]=[joined,free];
|
||||
}
|
||||
@@ -289,12 +285,5 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
|
||||
check('no errors', not errs, errs[:3])
|
||||
b.close()
|
||||
|
||||
# a browser without hardware drawing gets the pixel look and a note
|
||||
with launch(gpu='soft', viewport={'width': 800, 'height': 500}) as (_, pg2, soft_url):
|
||||
pg2.add_init_script('window.__allowSoftwareGL = false; localStorage.clear();')
|
||||
pg2.goto(soft_url); pg2.evaluate("SET.fog = false; SET.renderer='legacy'"); pg2.wait_for_timeout(300)
|
||||
pg2.click('#btn-new'); pg2.fill('#nw-seed', '5'); pg2.click('#btn-generate')
|
||||
pg2.wait_for_function('GAME.running === true', timeout=20000); pg2.wait_for_timeout(1200)
|
||||
check('software rendering falls back to pixels with a note', pg2.evaluate("SET.look === 'pixel' && !R.smoothOn && SM.why === 'software'") and pg2.locator('.toast', has_text='graphics card').count() >= 1, pg2.evaluate('[SET.look, SM.why]'))
|
||||
print('FAILS:', fails)
|
||||
sys.exit(1 if fails else 0)
|
||||
|
||||