smodusermc/offline-games / _tools /verify_games.py
smodusermc's picture
download
raw
23.3 kB
"""Stage original bucket assets, repair observed dependencies, then test a network-blocked local mirror.
HF_TOKEN is read from the environment only. Game assets live under /tmp and are deleted after each game.
"""
import os,json,hashlib,shutil,time,threading,mimetypes,re,sys,traceback
from pathlib import Path
from urllib.parse import urlparse,unquote,quote
from http.server import ThreadingHTTPServer,BaseHTTPRequestHandler
import requests
from huggingface_hub import HfApi
from playwright.sync_api import sync_playwright
HOME=Path('/home/user/game-archive');REPORT=HOME/'reports';EVIDENCE=REPORT/'evidence';EVIDENCE.mkdir(exist_ok=True)
BUCKET='smodusermc/offline-games';BASE='https://garbsoftball.com';api=HfApi(token=os.environ['HF_TOKEN'])
SITE_HOST='garbsoftball.com';VENDORS=('unpkg.com','cdn.jsdelivr.net','ajax.googleapis.com','maxcdn.bootstrapcdn.com','cdn-factory.marketjs.com','cdn.fbrq.io')
IGNORE=('googletagmanager.com','google-analytics.com','cloudflareinsights.com','fonts.googleapis.com','fonts.gstatic.com')
STAGE=Path(os.environ.get('GAME_VERIFY_STAGE','/tmp/game-verify-stage'))
remote={f.path:f for f in api.list_bucket_tree(BUCKET,recursive=True) if hasattr(f,'size')}
# Inventory check for every existing manifest; file size presence is NOT a runtime test.
audit=[]
for mp in sorted(REPORT.glob('game-*.json')):
m=json.loads(mp.read_text());missing=[];mismatch=[]
for f in m['files']:
rf=remote.get(f['path'])
if rf is None:missing.append(f['path'])
elif rf.size!=f['bytes']:mismatch.append({'path':f['path'],'expected':f['bytes'],'stored':rf.size})
audit.append({'id':int(mp.stem.split('-')[1]),'name':m['name'],'recorded_files':len(m['files']),'missing_objects':missing,'size_mismatches':mismatch,'check':'remote metadata presence/size only, not SHA256 or gameplay'})
(REPORT/'storage-audit.json').write_text(json.dumps(audit,indent=2))
class State:
def __init__(self,m):
self.manifest=m;self.files={f['path']:f for f in m['files']};self.added={};self.redirects={};self.hits=[];self.failed={};self.repair=True;self.fetch_bytes=0;self.lock=threading.RLock();self.busy={};self.fetched=set()
def path_for_url(self,url):
u=urlparse(url);path=unquote(u.path)
if path.endswith('/'):path+='index.html'
if '..' in Path(path).parts:raise ValueError('unsafe path')
return ('site' if u.hostname==SITE_HOST else '_vendor/'+u.netloc)+path
def localurl(self,u):
p=urlparse(u)
return ('' if p.hostname==SITE_HOST else '/__vendor/'+p.netloc)+p.path+('?' + p.query if p.query else '')
def fetch(self,source):
key=self.path_for_url(source);dest=STAGE/key
with self.lock:
if dest.exists():return key
if key in self.failed:return None
if not self.repair:self.failed[key]='not staged; network disabled';return None
lock=self.busy.setdefault(key,threading.Lock())
with lock:
if dest.exists():return key
try:
if self.fetch_bytes>700*1024**2:raise ValueError('per-game repair size limit reached')
if key in remote:
api.download_bucket_files(BUCKET,[(key,dest)],raise_on_missing_files=True)
info={'url':source,'path':key,'bytes':dest.stat().st_size,'sha256':hashlib.file_digest(dest.open('rb'),'sha256').hexdigest(),'content_type':mimetypes.guess_type(source)[0] or 'application/octet-stream'}
else:
with requests.get(source,stream=True,timeout=(8,25)) as r:
r.raise_for_status()
if urlparse(r.url).hostname not in (SITE_HOST,*VENDORS):raise ValueError('external redirect not permitted')
if int(r.headers.get('Content-Length',0))>350*1024**2:raise ValueError('file exceeds 350 MiB repair limit')
# Redirects matter for ES modules and dynamic Ruffle chunks.
final=r.url
finalkey=self.path_for_url(final)
if unquote(final.split('?')[0])!=unquote(source.split('?')[0]):self.redirects[self.localurl(source)]=self.localurl(final)
dest=STAGE/finalkey;dest.parent.mkdir(parents=True,exist_ok=True);size=0;h=hashlib.sha256();head=b''
with dest.open('wb') as f:
for chunk in r.iter_content(1024*1024):
size+=len(chunk)
if size>350*1024**2:raise ValueError('file exceeds 350 MiB repair limit')
if len(head)<256:head+=chunk[:256-len(head)]
f.write(chunk);h.update(chunk)
if head.strip().startswith(b'PS 404 Not Found!'):raise ValueError('source returned plain-text 404 body with HTTP 200')
if re.match(br'\s*(?:<!doctype html|<html)',head,re.I) and not urlparse(final).path.endswith(('.html','/','.htm')):raise ValueError('server returned HTML in place of an asset')
key=finalkey;self.fetch_bytes+=size
info={'url':final,'path':key,'bytes':size,'sha256':h.hexdigest(),'content_type':r.headers.get('Content-Type','application/octet-stream')}
with self.lock:
self.files[key]=info;self.added[key]=info;self.fetched.add(source)
return key
except Exception as e:
dest.unlink(missing_ok=True)
with self.lock:self.failed[key]=str(e)[:180]
return None
class Handler(BaseHTTPRequestHandler):
def log_message(self,*a):pass
def do_HEAD(self):
self.do_GET()
def do_GET(self):
S=self.server.state;p=urlparse(self.path);req=self.path
if p.path.startswith('/__vendor/'):
rest=p.path[len('/__vendor/'):];host,_,path=rest.partition('/')
if host not in VENDORS:self.send_error(403);return
# Only runtime-library dependencies are admitted, not external full game embeds.
if not any(term in path.lower() for term in ('ruffle','peerjs','jquery','font-awesome','generic.png')):self.send_error(403);return
source='https://'+host+'/'+path+('?' + p.query if p.query else '')
else:source=BASE+req
if req in S.redirects:
self.send_response(302);self.send_header('Location',S.redirects[req]);self.end_headers();return
try:key=S.path_for_url(source)
except:self.send_error(400);return
S.hits.append({'path':key,'repair_phase':S.repair})
file=STAGE/key
if not file.exists():
found=S.fetch(source)
if req in S.redirects:
self.send_response(302);self.send_header('Location',S.redirects[req]);self.end_headers();return
if found:key=found;file=STAGE/key
if not file.exists():self.send_error(404);return
info=S.files.get(key,{})
ctype=info.get('content_type') or mimetypes.guess_type(str(file))[0] or 'application/octet-stream'
if file.suffix in ('.js','.mjs'):ctype='application/javascript; charset=utf-8'
if file.suffix=='.wasm':ctype='application/wasm'
data=None
if file.stat().st_size<16*1024**2 and ('text/' in ctype or 'javascript' in ctype or file.suffix in ('.html','.js','.mjs','.css')):
data=file.read_bytes()
# Original files remain unchanged; local-mirror routing is applied only in the test HTTP response.
data=data.replace(b'https://garbsoftball.com',b'').replace(b'http://garbsoftball.com',b'')
for host in VENDORS:data=data.replace(('https://'+host).encode(),('/__vendor/'+host).encode())
self.send_response(200);self.send_header('Content-Type',ctype)
self.send_header('Cross-Origin-Opener-Policy','same-origin');self.send_header('Cross-Origin-Embedder-Policy','credentialless');self.send_header('Cross-Origin-Resource-Policy','cross-origin');self.send_header('Access-Control-Allow-Origin','*');self.send_header('Cache-Control','no-store');self.send_header('Content-Length',str(len(data) if data is not None else file.stat().st_size));self.end_headers()
if self.command=='HEAD':return
try:
if data is not None:self.wfile.write(data)
else:
with file.open('rb') as f:shutil.copyfileobj(f,self.wfile,1024*1024)
except (BrokenPipeError,ConnectionResetError):pass
def browser_pass(p,server,ident,phase):
b=p.chromium.launch(headless=True,args=['--no-sandbox','--enable-unsafe-swiftshader','--disable-dev-shm-usage'])
ctx=b.new_context(viewport={'width':1024,'height':720},service_workers='block')
blocked=[];errors=[];logs=[];crashed=[]
def gate(route):
u=urlparse(route.request.url)
if u.hostname in ('127.0.0.1','localhost'):route.continue_()
elif u.scheme in ('data','blob'):route.continue_()
else:
if not any(h in (u.hostname or '') for h in IGNORE):blocked.append(route.request.url)
route.abort()
ctx.route('**/*',gate);page=ctx.new_page();page.set_default_timeout(5000);page.on('pageerror',lambda e:errors.append(str(e)[:500]));page.on('crash',lambda _:crashed.append(True));page.on('console',lambda m:logs.append(m.text[:350]) if m.type in ('error','warning') else None)
result={'phase':phase,'external_network':'blocked in browser','runtime_test':'not_yet_classified','input_assertions':[]}
try:
u='http://127.0.0.1:'+str(server.server_port)+urlparse(server.state.manifest['entry']).path
page.goto(u,wait_until='domcontentloaded',timeout=45000)
page.wait_for_timeout(3500)
if ident==143:
result['tutorial_steps_clicked']=[]
for step in range(8):
try:
control=page.get_by_text(re.compile(r'^\s*(Next|Done|Finish)\s*$',re.I)).first
if not control.is_visible(timeout=500):break
label=control.inner_text();control.click(timeout=1500);result['tutorial_steps_clicked'].append(label);page.wait_for_timeout(500)
except:break
page.keyboard.press('Enter');page.wait_for_timeout(1200)
if ident==182:
page.goto(u.rsplit('/',1)[0]+'/play/index.html',wait_until='domcontentloaded');result['declared_play_link_followed']=True;page.wait_for_timeout(6000)
if ident==152:
try:
page.get_by_text('Flash',exact=True).click(timeout=1500);page.wait_for_timeout(3500)
except:pass
for sel in ['#loadFly','#startButton','#start-button','#playButton','#playBtn','button:has-text("Play")','button:has-text("Start")']:
try:
loc=page.locator(sel).first
if loc.is_visible(timeout=300):loc.click(timeout=1000);result['auto_clicked']=sel;break
except:pass
try:
flash=page.locator('ruffle-player,ruffle-object,ruffle-embed').first
if flash.count() and flash.is_visible():
box=flash.bounding_box()
if box:page.mouse.click(box['x']+box['width']/2,box['y']+box['height']/2,delay=150);result['flash_play_clicked']=True
for selector in ['#play-button','.play-button','button[title="Play"]']:
try:
button=page.locator(selector).first
if button.is_visible(timeout=200):button.click(timeout=1000)
except:pass
result['flash_api']=page.evaluate('''() => {const p=document.querySelector('ruffle-player,ruffle-object,ruffle-embed'); if(!p)return null; const r=p.ruffle?.(); try {r?.play?.();p.play?.();}catch(e){} return {ready:p.readyState, isPlaying:p.isPlaying, shadow: p.shadowRoot?.innerHTML.slice(0,700)};}''')
except:pass
if ident in (0,4):
before=page.locator('.tile-container').inner_html()
for k in ['ArrowLeft','ArrowUp','ArrowRight','ArrowDown']*3:page.keyboard.press(k)
page.wait_for_timeout(500);after=page.locator('.tile-container').inner_html()
result['input_assertions'].append({'assertion':'tile board changes after arrow-key moves','passed':before!=after,'tiles':page.locator('.tile').count()})
if ident==101:
try:
english=page.get_by_text('English',exact=True).first
if english.is_visible(timeout=300):english.click(timeout=1500);page.wait_for_timeout(2000)
before=page.evaluate('Game.cookies');page.locator('#bigCookie').click(timeout=2000);after=page.evaluate('Game.cookies')
result['input_assertions'].append({'assertion':'cookie count increases after cookie click','passed':after>before,'before':before,'after':after})
except Exception as e:result['input_assertions'].append({'assertion':'cookie click','passed':False,'error':str(e)[:150]})
if ident in (373,374,575,582):page.wait_for_timeout(6500)
if ident in (182,183,359,373,374,376,575,582):
result['batch_interactions']=[]
if ident==359:
page.locator('#splash').click();page.wait_for_timeout(700)
before=page.evaluate('({cycle:simulation.cycle,x:m.pos.x,title:simulation.onTitlePage})')
page.keyboard.down('d');page.wait_for_timeout(1000);page.keyboard.up('d')
after=page.evaluate('({cycle:simulation.cycle,x:m.pos.x,title:simulation.onTitlePage})')
result['input_assertions'].append({'assertion':'n-gon starts, simulates, and responds to D movement','passed':not after['title'] and after['cycle']>before['cycle'] and abs(after['x']-before['x'])>1,'before':before,'after':after})
elif ident==376:
page.locator('a.start').click();page.wait_for_timeout(4000)
before=page.locator('.p1-score span').inner_text()
for key in ['ArrowLeft','ArrowDown','ArrowRight']:
page.keyboard.down(key);page.wait_for_timeout(900);page.keyboard.up(key)
after=page.locator('.p1-score span').inner_text()
result['input_assertions'].append({'assertion':'Pacman score increases after starting and arrow-key movement','passed':int(after)>int(before),'before':before,'after':after})
elif ident==373:
before=page.evaluate('window.cr_getC2Runtime?.()?.running_layout?.name')
page.mouse.click(512,350,delay=180);page.wait_for_timeout(2000)
page.keyboard.down('ArrowRight');page.wait_for_timeout(900);page.keyboard.press('ArrowUp');page.keyboard.up('ArrowRight')
after=page.evaluate('window.cr_getC2Runtime?.()?.running_layout?.name')
result['batch_interactions'].append({'action':'Play, right movement, jump','layout_before':before,'layout_after':after})
elif ident==374:
for key in ['ArrowLeft','ArrowRight','Space']:
page.keyboard.down(key);page.wait_for_timeout(700);page.keyboard.up(key)
result['batch_interactions'].append('Exercised left/right/jump in the introductory scene')
elif ident==582:
page.mouse.click(512,360,delay=180);page.wait_for_timeout(4500)
page.screenshot(path=str(REPORT/f'evidence/{ident:04d}-{phase}-mode-menu.jpg'),type='jpeg',quality=55)
page.mouse.click(300,425,delay=180);page.wait_for_timeout(2000)
for n in range(4):page.keyboard.press('ArrowUp');page.keyboard.press('w');page.wait_for_timeout(500)
result['batch_interactions'].append('Play, clicked 1-player control, four Up/W presses')
elif ident==575:
page.mouse.click(512,600,delay=180);page.wait_for_timeout(2000)
page.mouse.click(300,320,delay=180);page.wait_for_timeout(2200)
for key in ['ArrowRight','ArrowUp']:
page.keyboard.down(key);page.wait_for_timeout(500);page.keyboard.up(key)
result['batch_interactions'].append('Clicked splash Play, menu Play Game, and exercised right/jump')
else:
page.mouse.click(512,600,delay=180);page.wait_for_timeout(2000)
page.mouse.click(512,440,delay=180);page.wait_for_timeout(1800)
result['batch_interactions'].append('Attempted splash Play and main Play')
result['later_level_cases']=[]
for fraction in [0.5,1.0]:
case=page.evaluate("""(fraction)=>{const g=globalThis.Phaser?.GAMES?.find(x=>x);if(!g?.progress||g.state.current!=='level')return {attempted:false,reason:'Engine/level API not available',state:g?.state?.current};const temple=g.progress.get('temples')[0];const index=Math.round((temple.levels.length-1)*fraction);const meta=temple.levels[index];g.state.start('level',true,false,meta);return {attempted:true,index,filename:meta.filename,method:'Existing engine state API; not normal progression or a completed playthrough'};}""",fraction)
if case.get('attempted'):
page.wait_for_timeout(3500)
case['observed']=page.evaluate("() => {const g=Phaser.GAMES.find(x=>x);const s=g.state.getCurrentState();return {state:g.state.current,levelPath:s.levelPath,width:s.mapData?.width,height:s.mapData?.height};}")
case['map_loaded']=case['observed'].get('width',0)>0 and case['observed'].get('height',0)>0
result['later_level_cases'].append(case)
if ident in (155,180,181,281):
result['deeper_interaction_attempts']=[]
if ident==155:
page.mouse.click(860,650,delay=150);result['deeper_interaction_attempts'].append('Clicked the Duck Life 4 Play position')
elif ident in (180,181):
page.mouse.click(512,440,delay=150);result['deeper_interaction_attempts'].append('Clicked the Fireboy/Watergirl Play position')
else:
page.evaluate("() => {window.__clickAudioStarts=[];const native=AudioBufferSourceNode.prototype.start;AudioBufferSourceNode.prototype.start=function(...a){__clickAudioStarts.push({frames:this.buffer?.length||0});return native.apply(this,a);};}")
for x,y in [(530,190),(555,190),(580,190),(605,190),(630,190),(655,190),(680,190),(705,190)]:page.mouse.click(x,y,delay=80);page.wait_for_timeout(100)
result['deeper_interaction_attempts'].append('Clicked eight positions in the brick grid')
result['audio_sources_during_clicks']=page.evaluate('__clickAudioStarts')
page.wait_for_timeout(2500)
if ident in (180,181):
page.screenshot(path=str(REPORT/f'evidence/{ident:04d}-{phase}-before-movement.jpg'),type='jpeg',quality=55)
for key in ['ArrowRight','d']:
page.keyboard.down(key);page.wait_for_timeout(600);page.keyboard.up(key)
result['deeper_interaction_attempts'].append('Held ArrowRight and D for 600ms each after Play')
if ident==543:
page.mouse.click(512,360,delay=150);page.wait_for_timeout(12000)
result['construct_state']=page.evaluate('() => {const r=window.cr_getC2Runtime?.();return {layout:r?.running_layout?.name,isloading:r?.isloading,loadingprogress:r?.loadingprogress};}')
result['activation_note']='Clicked canvas to allow audio and waited for opening sequence; not a playthrough.'
if ident==591:
try:
for selector in ['game-icon[icon="close"]','#close','.close']:
for el in page.locator(selector).all():
try:
if el.is_visible(timeout=200):el.click(timeout=700)
except:pass
page.keyboard.press('Escape');page.keyboard.type('crane');page.keyboard.press('Enter');page.wait_for_timeout(2500)
tiles=page.locator('game-tile[evaluation]').evaluate_all("els => els.map(e=>({letter:e.getAttribute('letter'),evaluation:e.getAttribute('evaluation')}))")
good=[t for t in tiles if t['evaluation'] in ['correct','present','absent']]
result['input_assertions'].append({'assertion':'Wordle accepts CRANE and evaluates five tiles','passed':len(good)>=5,'evaluated_tiles':good})
except Exception as e:result['wordle_input_error']=str(e)[:250]
if ident==136:
try:
before=page.evaluate('Boolean(window.Runner?.instance_?.playing)');page.keyboard.press('Space');page.wait_for_timeout(400)
after=page.evaluate('Boolean(window.Runner?.instance_?.playing)')
result['input_assertions'].append({'assertion':'Dinosaur runner starts after Space','passed':not before and after,'before':before,'after':after})
except Exception as e:result['dinosaur_check_error']=str(e)[:150]
page.wait_for_timeout(14500 if ident in (153,154,155,156,174,175) else (10500 if result.get('flash_play_clicked') else 4500))
if phase=='repair' and server.state.added:
stable=0;last=len(server.state.hits)
for _ in range(18):
active=any(lock.locked() for lock in server.state.busy.values())
current=len(server.state.hits)
stable=stable+1 if current==last and not active else 0
if stable>=2:break
last=current;page.wait_for_timeout(1000)
result['title']=page.title();result['body_text']=page.locator('body').inner_text(timeout=3000)[:1800]
result['canvas_count']=page.locator('canvas').count();result['screenshot']=f'evidence/{ident:04d}-{phase}.jpg'
page.screenshot(path=str(REPORT/result['screenshot']),type='jpeg',quality=55,timeout=10000)
result['runtime_test']='input_smoke_passed' if result['input_assertions'] and all(x['passed'] for x in result['input_assertions']) else 'startup_observed_needs_visual_review'
except Exception as e:result['runtime_test']='browser_crashed' if crashed else 'startup_error';result['exception']=str(e)[:500]
result['page_errors']=errors[:30];result['console_warnings_errors']=logs[-25:];result['blocked_external_requests']=sorted(set(blocked));result['browser_crashed']=bool(crashed)
b.close();return result
ids=[int(x) for x in sys.argv[1:]]
with sync_playwright() as p:
for ident in ids:
mp=REPORT/f'game-{ident}.json';m=json.loads(mp.read_text());res={'id':ident,'name':m['name'],'entry':m['entry'],'verification_scope':'staged bucket bytes + observed repairs, network-blocked local HTTP mirror, not file:// or self-contained HTML'}
shutil.rmtree(STAGE,ignore_errors=True);STAGE.mkdir(exist_ok=True)
server=None
try:
total=sum(x['bytes'] for x in m['files'])
if total>350*1024**2:
res['status']='runtime_deferred_memory_limit';res['reason']='Captured assets exceed 350 MiB; 2 GiB test host cannot reliably stage and execute this build concurrently.'
else:
unique={x['path']:x for x in m['files']}
api.download_bucket_files(BUCKET,[(k,STAGE/k) for k in unique if k in remote],raise_on_missing_files=True)
mismatches=[];verified=0
for key,f in unique.items():
local=STAGE/key
if not local.exists():continue
with local.open('rb') as stream:sha=hashlib.file_digest(stream,'sha256').hexdigest()
if f.get('sha256') and f['sha256']!=sha:mismatches.append(key)
else:verified+=1
res['sha256_checked_files']=verified;res['sha256_mismatches']=mismatches
S=State(m);server=ThreadingHTTPServer(('127.0.0.1',0),Handler);server.daemon_threads=False;server.state=S
threading.Thread(target=server.serve_forever,daemon=True).start()
print('Testing',ident,m['name'],round(total/1e6,1),'MB',flush=True)
res['repair_pass']=browser_pass(p,server,ident,'repair')
# Let in-flight requests settle before freezing the file set.
time.sleep(1)
res['repair_fetch_failures']=dict(S.failed)
S.repair=False;S.failed={};hits0=len(S.hits)
res['offline_pass']=browser_pass(p,server,ident,'offline')
res['offline_missing_requests']=dict(S.failed);res['observed_offline_paths']=sorted(set(x['path'] for x in S.hits[hits0:]))
res['repairs']=list(S.added.values());res['runtime_redirects']=S.redirects
res['status']=res['offline_pass']['runtime_test']
additions=[(STAGE/k,k) for k in S.added if (STAGE/k).exists()]
if additions:
api.batch_bucket_files(BUCKET,add=additions)
lookup={f.path:f for f in api.get_bucket_paths_info(BUCKET,[k for _,k in additions])}
for local,k in additions:assert k in lookup and lookup[k].size==local.stat().st_size
remote.update(lookup)
merged={f['path']:f for f in m['files']};merged.update(S.added);m['files']=list(merged.values());m['runtime_repairs_note']='Browser-observed missing dependencies captured; see verification report for offline test results.'
mp.write_text(json.dumps(m,indent=2));api.batch_bucket_files(BUCKET,add=[(mp,f'_reports/games/{ident:04d}.json')])
# Always record failures: no loading-screen-only evidence gets promoted to gameplay verified.
out=REPORT/f'verification-{ident:04d}.json';out.write_text(json.dumps(res,indent=2));api.batch_bucket_files(BUCKET,add=[(out,f'_verification/games/{ident:04d}.json')])
print('RESULT',ident,res['status'],'repairs',len(res.get('repairs',[])),'offline missing',len(res.get('offline_missing_requests',{})),flush=True)
except Exception as e:
res['status']='verification_exception';res['exception']=str(e)[:500];(REPORT/f'verification-{ident:04d}.json').write_text(json.dumps(res,indent=2));print('FAIL',ident,str(e)[:200],flush=True)
finally:
if server:server.shutdown();server.server_close()
shutil.rmtree(STAGE,ignore_errors=True)

Xet Storage Details

Size:
23.3 kB
·
Xet hash:
dae8a07014a85fb4dec9dad2559787dcef50684857bd54585043054f5099e7c8

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.