mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 10:19:47 -04:00
60801820fd
Replace absolute F:/C: paths with repo-relative (../TombaRecomp, bios/, beetle-psx/) or placeholders (<desktop>/<psxref>/<nesrecomp>/<psxrecomp-v4>) across accuracy docs, WIDESCREEN/SHADOW/RECURSION_BUG/config_schema/ISSUES/README, and tools/*.py|.ps1. Removed a personal C:\Users\Matthew\Desktop path (ISSUES.md). Left CLAUDE.md/PLAN.md sibling-project + version-history paths intact (intentional layout documentation).
126 lines
4.6 KiB
Python
126 lines
4.6 KiB
Python
#!/usr/bin/env python3
|
|
"""tomba_loadtest.py — headless title->LOAD GAME self-test for the Tomba recomp.
|
|
|
|
Unattended PASS/FAIL oracle:
|
|
- relaunches the exe (fast_boot) with freeze-detect + retry (Issue #6),
|
|
- drives the title menu via injected ACTIVE-LOW pad input:
|
|
START(0xFFF7) breaks attract -> title; RIGHT(0xFFDF) NEW GAME->LOAD; CROSS(0xBFFF) selects,
|
|
- detects LOAD entry via mc_reads jump, then samples the framebuffer:
|
|
slot list rendered (non-black) = PASS ; black = FAIL.
|
|
|
|
Usage: python tomba_loadtest.py <exe> <game.toml> [port]
|
|
"""
|
|
import sys, time, subprocess, socket, json
|
|
|
|
EXE = sys.argv[1] if len(sys.argv)>1 else r"../TombaRecomp/build-codex-ape-fw/psx-runtime.exe"
|
|
TOML = sys.argv[2] if len(sys.argv)>2 else "game-loadtest.toml"
|
|
PORT = int(sys.argv[3]) if len(sys.argv)>3 else 4470
|
|
BIOS = r"bios/SCPH1001.BIN"
|
|
CWD = r"../TombaRecomp"
|
|
NONE,RIGHT,CROSS,START = 0xFFFF,0xFFDF,0xBFFF,0xFFF7
|
|
|
|
def q(cmd, **kw):
|
|
d={"cmd":cmd}; d.update(kw)
|
|
try:
|
|
s=socket.socket(); s.settimeout(5); s.connect(("127.0.0.1",PORT))
|
|
s.sendall((json.dumps(d)+"\n").encode())
|
|
buf=b""; dc=ds=0; ins=False; esc=False; started=False
|
|
while True:
|
|
ch=s.recv(65536)
|
|
if not ch: break
|
|
for b in ch:
|
|
buf+=bytes([b]); c=chr(b)
|
|
if ins:
|
|
if esc: esc=False
|
|
elif c=="\\": esc=True
|
|
elif c=='"': ins=False
|
|
continue
|
|
if c=='"': ins=True
|
|
elif c=="{": dc+=1; started=True
|
|
elif c=="}": dc-=1
|
|
elif c=="[": ds+=1
|
|
elif c=="]": ds-=1
|
|
if started and dc==0 and ds==0:
|
|
s.close(); return json.loads(buf.decode().strip())
|
|
except Exception as e:
|
|
return {"err":str(e)}
|
|
def frame(): return q("ping").get("frame")
|
|
def mc(): return q("sio_state").get("mc_reads")
|
|
def press(b,frames=6): q("press",buttons=b,frames=frames)
|
|
|
|
def kill_launch():
|
|
subprocess.run(["powershell","-Command","Stop-Process -Name psx-runtime -Force -ErrorAction SilentlyContinue"],capture_output=True)
|
|
time.sleep(1.5)
|
|
subprocess.Popen(["powershell","-Command",
|
|
f"Start-Process -FilePath '{EXE}' -ArgumentList '--game','{TOML}','--bios','{BIOS}','--disc','tomba/tomba.cue' -WorkingDirectory '{CWD}'"])
|
|
|
|
def alive_advancing():
|
|
a=frame()
|
|
if a is None: return False
|
|
time.sleep(1.2); b=frame()
|
|
return b is not None and b!=a
|
|
|
|
def nonblack():
|
|
g=q("gpu_state"); dx,dy=g.get("display_x",0),g.get("display_y",0)
|
|
nz=tot=0
|
|
for ry in range(0,224,16):
|
|
h=q("vram_peek",x=dx,y=dy+ry,w=320,h=1).get("hex","")
|
|
for j in range(0,len(h),4):
|
|
tot+=1; nz+= (h[j:j+4] not in ("0000",""))
|
|
return nz/tot if tot else 0
|
|
|
|
def shot(name):
|
|
b=f"../_scratch/{name}.bmp"; q("screenshot_file",path=b)
|
|
try:
|
|
from PIL import Image; Image.open(b).save(b.replace(".bmp",".png"))
|
|
except Exception: pass
|
|
|
|
def attempt():
|
|
kill_launch()
|
|
# wait for TCP
|
|
for _ in range(25):
|
|
if q("ping").get("ok"): break
|
|
time.sleep(1)
|
|
# wait for boot past intro, with freeze detection
|
|
t0=time.time()
|
|
while time.time()-t0 < 45:
|
|
if not alive_advancing():
|
|
f=frame()
|
|
time.sleep(2)
|
|
if frame()==f: # frozen
|
|
print(f" FROZEN at frame {f}")
|
|
return None
|
|
if time.time()-t0 > 18: # intro likely done -> attract/title cycle
|
|
break
|
|
time.sleep(2)
|
|
base=mc()
|
|
# drive START->RIGHT->CROSS, retry the sequence until mc jumps (LOAD entered)
|
|
for tseq in range(10):
|
|
if not alive_advancing():
|
|
print(" froze mid-nav"); return None
|
|
press(START); time.sleep(1.3)
|
|
press(RIGHT); time.sleep(0.7)
|
|
press(CROSS); time.sleep(1.5)
|
|
cur=mc()
|
|
if cur is not None and base is not None and cur > base+40:
|
|
print(f" LOAD entered (mc {base}->{cur}) on seq {tseq}")
|
|
time.sleep(3.0) # let it render or black
|
|
shot("loadtest_result")
|
|
return nonblack()
|
|
time.sleep(1.0)
|
|
print(" never entered LOAD (mc stayed ~%s)"%base)
|
|
return None
|
|
|
|
def main():
|
|
for a in range(6):
|
|
print(f"=== attempt {a} ===")
|
|
r=attempt()
|
|
if r is not None:
|
|
verdict="PASS (slot list rendered)" if r>0.10 else "FAIL (black)"
|
|
print(f"RESULT: {verdict} nonblack={r:.2%} screenshot=_scratch/loadtest_result.png")
|
|
return 0 if r>0.10 else 2
|
|
print("RESULT: INCONCLUSIVE (freeze/timing across all attempts)")
|
|
return 3
|
|
|
|
sys.exit(main())
|