"""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 keeps the legacy path') pg.evaluate('SET.look="pixel"; SET.renderer="webgpu"; PERF_MARKS=true; drawWorld();') pg.wait_for_function('!GPU.timingBusy') 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') assert pg.evaluate('!GPU.queries || GPU.lightMs>=0') pg.evaluate('GPU.device.destroy()') pg.wait_for_function('!GPU.ok') 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')