Files
deepgrain-foundry/test/t_gpu.py
T

126 lines
7.6 KiB
Python

"""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'))
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':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("async()=>{perfScene('factory',1024,640);await startThreads();simPool.force=true;SET.look='pixel';SET.zoom=2;resizeRender();snapCamera();drawWorld();}")
result=pg.evaluate('''async () => {
const results=[];
for(let n=0;n<240;n++) {
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());
}
}
drawWorld();await gpuCheckUploads();
drawWorld();return {checked:results,cleanCells:GPU.uploadCells,workers:simPool.workers.length,active:simPool.activeMax};
}''')
assert min(result['checked'])>1000 and result['cleanCells']==0 and result['workers']>0 and result['active']>0,result
print('PASS busy ticks, heat, fog, scrolling, both looks: GPU bytes equal full CPU data; clean frame uploads no world cells',result)
parity=pg.evaluate("""async()=>{
const results=[];
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;}
}""")
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;
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 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 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()')
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')