126 lines
7.6 KiB
Python
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')
|