"""WebGPU pixel parity, rare-cell selection, world replacement, resize and device loss.""" 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 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; 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;i8;} 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 { 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;i0;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>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>>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});}} } results.push({zoom,fogOn,alpha,max,bad,examples,share:GPU.overrideCount/R.px.length}); } 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;i8;} 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("""() => { 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.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(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"') pg.evaluate('drawWorld()') print('PASS device loss restores the legacy renderer') assert not errors, errors print('PASS no page or WebGPU validation errors')