Files
retroDE_ps2/tools/gs_translate_tex.py
thejayman77 ec82764bef Initial commit: retroDE_ps2 — first-of-its-kind PS2 GS FPGA core (DE25-Nano / Agilex 5)
RTL (GS rasterizer, EE core stub, platform bridge, LPDDR4B path), sim regression
(272 TBs), docs, and tooling. Copyrighted PS2 content (BIOS, game code, GS dumps,
and all dump-derived textures/traces) is excluded via .gitignore and stays local.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 20:10:50 -04:00

242 lines
13 KiB
Python

#!/usr/bin/env python3
"""retroDE_ps2 — Ch341 textured-triangle translator (declared affine ST/Q surrogate).
Extracts the earliest contiguous single-TEX0 textured-triangle subsegment that FITS staging (<=27 tris)
from a GS dump, derives faithful per-vertex texel coords u=S/Q, v=T/Q (FST=0 perspective ST), applies
the declared 256->64 downscale, and emits a ps2_feeder textured scene (tex0 + tritex) — BUT ONLY if the
honesty gate passes: the affine interpolation of vertex (S/Q,T/Q) must stay within MAX_ERR texels of the
true perspective interpolation over the triangle. Otherwise it FAILS CLOSED (Ch342 = real ST/Q).
This is NOT faithful perspective-correct GS texturing. It is authentic cube geometry + authentic
extracted texels through the affine-UV feeder, with a DECLARED affine substitute for perspective ST/Q
on a tiny-span segment. Sprites stay unsupported. Reports are aggregate; scene text is dump-derived.
Usage: gs_translate_tex.py <dump> [--tbp 64] [--dst 64] [--maxtri 27] [--scene out.txt] [--report r.txt]
"""
import sys, os, struct
sys.path.insert(0, os.path.dirname(__file__))
import gs_parse
MAX_ERR = 0.5 # texels, on the 64x64 fixture
def f32(bits): return struct.unpack("<f", struct.pack("<I", bits & 0xFFFFFFFF))[0]
def bit(v,b): return (v>>b)&1
def extract(events, dst, maxtri):
"""Walk events; return (segment tris, meta). Each tri: dict(scr=[(x,y)*3], z, stq=[(S,T,Q)*3], rgb)."""
st_S=st_T=0.0; q=1.0; rgb=(255,255,255); tex0=0; fst=0; ptype=7; xyoff=0; clamp=0
prim_val=0; test_val=0
uploads_to={} # TBP -> count of IMAGE uploads seen so far (to detect re-upload crossing)
bitbltbuf=0
vbuf=[] # pending verts
tris=[] # each: dict
seg=[]; seg_tex0=None; seg_clamp=None; seg_prim=None; seg_test=None; started=False; stop_reason=None
first=None; last=None
for e in events:
if e.kind=="IMAGE":
dbp=(bitbltbuf>>32)&0x3FFF
uploads_to[dbp]=uploads_to.get(dbp,0)+1
if started and seg_tex0 is not None and ((seg_tex0&0x3FFF)==dbp):
stop_reason=f"re-upload to bound TBP {dbp} at event #{e.idx}"; break
continue
if e.kind!="GSREG": continue
r=e.reg; v=e.value
if r=="BITBLTBUF": bitbltbuf=v
elif r=="PRIM": ptype=v&7; fst=bit(v,8); prim_val=v; vbuf=[]
elif r=="TEST_1": test_val=v
elif r=="PRMODE": fst=bit(v,8)
elif r=="ST":
st_S=f32(v&0xFFFFFFFF); st_T=f32((v>>32)&0xFFFFFFFF)
if "q_stq" in e.info: q=f32(e.info["q_stq"]) # PACKED ST routes Q -> RGBAQ.Q
elif r=="RGBAQ": rgb=(v&0xFF,(v>>8)&0xFF,(v>>16)&0xFF); q=f32((v>>32)&0xFFFFFFFF)
elif r=="TEX0_1": tex0=v
elif r in ("CLAMP_1",): clamp=v
elif r in ("XYOFFSET_1",): xyoff=v
elif r in ("XYZ2","XYZ3","XYZF2","XYZF3"):
x=(v&0xFFFF)/16.0 - (xyoff&0xFFFF)/16.0
y=((v>>16)&0xFFFF)/16.0 - ((xyoff>>16)&0xFFFF)/16.0
kick = e.reg in ("XYZ2","XYZF2")
vbuf.append(dict(x=x,y=y,z=(v>>32)&0xFFFFFFFF,S=st_S,T=st_T,Q=q,rgb=rgb,tex0=tex0,fst=fst,clamp=clamp))
if kick and ptype==3 and len(vbuf)>=3:
t=vbuf[-3:]; vbuf=[]
if not all(vv["fst"]==0 for vv in t): # only FST=0 textured tris in this rung
if started: stop_reason="prim left FST=0 ST mode"; break
continue
tt = t[0]["tex0"]
if not started:
seg_tex0=tt; seg_clamp=t[0]["clamp"]; seg_prim=prim_val; seg_test=test_val; started=True; first=e.idx
if tt!=seg_tex0:
stop_reason=f"TEX0 changed at event #{e.idx}"; break
if t[0]["clamp"]!=seg_clamp:
stop_reason=f"CLAMP changed at event #{e.idx}"; break
seg.append(t); last=e.idx
if len(seg)>=maxtri:
stop_reason=f"staging cap ({maxtri} tris)"; break
return seg, dict(tex0=seg_tex0, clamp=seg_clamp, prim=seg_prim, test=seg_test, first=first, last=last, stop=stop_reason)
def reconstruct_all_textured(events):
"""Every FST=0 textured triangle (3 verts) with its active tex0/clamp + texture-upload epoch
(uploads-to-its-TBP seen so far). For the closeout all-window scan — NOT the mechanical earliest
selection (extract() above is that). Documentation only, per Codex."""
st_S=st_T=0.0; q=1.0; rgb=(255,255,255); tex0=0; fst=0; ptype=7; xyoff=0; clamp=0; bitbltbuf=0
epoch={}; vbuf=[]; out=[]
for e in events:
if e.kind=="IMAGE":
dbp=(bitbltbuf>>32)&0x3FFF; epoch[dbp]=epoch.get(dbp,0)+1; continue
if e.kind!="GSREG": continue
r=e.reg; v=e.value
if r=="BITBLTBUF": bitbltbuf=v
elif r=="PRIM": ptype=v&7; fst=(v>>8)&1; vbuf=[]
elif r=="PRMODE": fst=(v>>8)&1
elif r=="ST":
st_S=f32(v&0xFFFFFFFF); st_T=f32((v>>32)&0xFFFFFFFF)
if "q_stq" in e.info: q=f32(e.info["q_stq"]) # PACKED ST routes Q -> RGBAQ.Q
elif r=="RGBAQ": rgb=(v&0xFF,(v>>8)&0xFF,(v>>16)&0xFF); q=f32((v>>32)&0xFFFFFFFF)
elif r=="TEX0_1": tex0=v
elif r=="CLAMP_1": clamp=v
elif r=="XYOFFSET_1": xyoff=v
elif r in ("XYZ2","XYZ3","XYZF2","XYZF3"):
kick=e.reg in ("XYZ2","XYZF2")
vbuf.append(dict(S=st_S,T=st_T,Q=q))
if kick and ptype==3 and len(vbuf)>=3:
t=vbuf[-3:]; vbuf=[]
if fst==0:
out.append(dict(v=t, tex0=tex0, clamp=clamp, epoch=epoch.get(tex0&0x3FFF,0)))
return out
def scan_windows(tris, dst, maxtri):
"""Slide a <=maxtri window from every start; a window breaks on tex0/clamp/epoch change.
Returns (n_windows, n_pass<=0.5, min_window_error)."""
n=len(tris); npass=0; best=1e9; total=0
for i in range(n):
win=[tris[i]]
for j in range(i+1, min(n, i+maxtri)):
a,b=tris[i],tris[j]
if a["tex0"]!=b["tex0"] or a["clamp"]!=b["clamp"] or a["epoch"]!=b["epoch"]: break
win.append(tris[j])
me=max(tri_error(t["v"], dst)[0] for t in win)
total+=1; best=min(best,me)
if me<=MAX_ERR: npass+=1
return total, npass, best
def bary_samples():
pts=[(1/3,1/3,1/3)]
for a in (0.25,0.5,0.75):
pts += [(a,(1-a)/2,(1-a)/2),((1-a)/2,a,(1-a)/2),((1-a)/2,(1-a)/2,a)]
return pts
def tri_error(t, dst):
"""max |affine - perspective| texel error over the triangle, for u and v (dst-sized texture)."""
SQ=[(v["S"]/v["Q"] if v["Q"] else 0.0, v["T"]/v["Q"] if v["Q"] else 0.0) for v in t]
uv=[(sq[0]*dst, sq[1]*dst) for sq in SQ]
me=0.0
for (b0,b1,b2) in bary_samples():
Sb=b0*t[0]["S"]+b1*t[1]["S"]+b2*t[2]["S"]; Tb=b0*t[0]["T"]+b1*t[1]["T"]+b2*t[2]["T"]
Qb=b0*t[0]["Q"]+b1*t[1]["Q"]+b2*t[2]["Q"]
if Qb==0: continue
up=(Sb/Qb)*dst; vp=(Tb/Qb)*dst
ua=b0*uv[0][0]+b1*uv[1][0]+b2*uv[2][0]; va=b0*uv[0][1]+b1*uv[1][1]+b2*uv[2][1]
me=max(me, abs(up-ua), abs(vp-va))
return me, uv
def main(argv):
if len(argv)<2: print(__doc__); return 2
path=argv[1]
def opt(n,d=None): return argv[argv.index(n)+1] if n in argv else d
tbp=int(opt("--tbp","64")); dst=int(opt("--dst","64")); maxtri=int(opt("--maxtri","27"))
h, events = gs_parse.parse_dump(path)
fst0 = sum(1 for e in events if e.kind=="GSREG" and e.reg=="PRIM" and ((e.value>>8)&1)==0)
seg, meta = extract(events, dst, maxtri)
R=[f"# Ch341 textured-triangle translation (source {os.path.basename(path)}; aggregate facts + dump-derived scene)"]
R.append(f"FST=0 (perspective ST) PRIM submissions: {fst0}")
if not seg:
R.append(f"NO textured-tri segment selected (stop: {meta['stop']}).")
print("\n".join(R)); return 0
# Ch342 — FAITHFUL perspective ST/Q emit (no affine, no error gate; the Ch301 path is exact).
# Packs the Ch301 fixed-point contract: S_fp=round(S*4096), T_fp=round(T*4096), Q_fp=round(Q*4096)
# (24-bit FRAC=12). The 256->64 downscale is in the TEXTURE (TW=6); ST/Q stay normalized — the GS
# computes texel=(S/Q)*2^TW=(S/Q)*64. Fails closed on Q<=0 or fixed-point overflow.
if "--perspective" in argv:
xs=[v["x"] for t in seg for v in t]; ys=[v["y"] for t in seg for v in t]
x0,x1,y0,y1=min(xs),max(xs),min(ys),max(ys)
FB=64; sc=min((FB-2)/max(1.0,x1-x0),(FB-2)/max(1.0,y1-y0))
fx=lambda x:max(0,min(FB-1,1+int((x-x0)*sc))); fy=lambda y:max(0,min(FB-1,1+int((y-y0)*sc)))
L=[f"# Ch342 authentic cube subsegment ({len(seg)} tris) — FAITHFUL perspective ST/Q (Ch301 fixed-point, FRAC=12)",
f"# events #{meta['first']}..#{meta['last']}; screen ({x0:.0f},{y0:.0f})..({x1:.0f},{y1:.0f}) viewport-fit -> {FB}x{FB}; texture 256->{dst}",
f"tex0 {tbp} 1 6 6 0", "persp"]
for t in seg:
a=[]
for v in t:
if v["Q"]<=0:
R.append(f"GATE FAILED: Q<=0 ({v['Q']}) at event #{meta['first']} — fail closed."); print("\n".join(R)); return 0
sfp=round(v["S"]*4096); tfp=round(v["T"]*4096); qfp=round(v["Q"]*4096)
if not (0<=sfp<=0xFFFFFF and 0<=tfp<=0xFFFFFF and 0<qfp<=0xFFFFFF):
R.append(f"GATE FAILED: ST/Q fixed-point out of 24-bit range -> fail closed."); print("\n".join(R)); return 0
a += [fx(v["x"]), fy(v["y"]), sfp, tfp, qfp]
(r,g,b)=t[-1]["rgb"]; z=t[-1]["z"]
L.append("persptri "+" ".join(map(str,a+[z,r,g,b])))
L.append("go")
Qs=[v["Q"] for t in seg for v in t]
pv=meta["prim"] or 0; tv=meta["test"] or 0
R.append(f"segment PRIM: type={pv&7}(TRI=3) IIP={(pv>>3)&1} TME={(pv>>4)&1} FGE={(pv>>5)&1} ABE={(pv>>6)&1} FST={(pv>>8)&1}"
f" TEST_1: ZTE={(tv>>16)&1} ZTST={(tv>>17)&3}(GEQ=2)")
if ((pv>>6)&1):
R.append("WARN: segment ABE=1 -> routes to the combined-TAZ path (perspective there is a known follow-on bug), NOT the proven S1 path. Do NOT flip ABE.")
R.append("S1 perspective path honors TME+FST=0 + ZTE/ZTST GEQUAL; cube segment is ABE=0 (S1 path).")
R.append(f"FAITHFUL PERSPECTIVE: {len(seg)} tris, TEX0->TBP={tbp} TW=6 TH=6 TFX=0; S_fp/T_fp/Q_fp=round(*4096); Q span {min(Qs):.4f}..{max(Qs):.4f}")
R.append(f"staging words: {7+9*len(seg)} (perspective format word0[32]=1, no rects)")
print("\n".join(R)+"\n")
sp=opt("--scene")
if sp: open(sp,"w").write("\n".join(L)+"\n"); print(f"[wrote {len(seg)}-tri FAITHFUL perspective scene -> {sp}]")
if opt("--report"): open(opt("--report"),"w").write("\n".join(R)+"\n")
return 0
# span + Q + error
Qs=[v["Q"] for t in seg for v in t]; Ss=[v["S"] for t in seg for v in t]; Ts=[v["T"] for t in seg for v in t]
maxerr=0.0
for t in seg:
me,_=tri_error(t,dst); maxerr=max(maxerr,me)
tx=tbp; tex0=meta["tex0"]
R.append(f"selected segment: {len(seg)} triangles, events #{meta['first']}..#{meta['last']}, stop after: {meta['stop']}")
R.append(f"active TEX0 (orig): TBP0={tex0&0x3FFF} TW={(tex0>>26)&0xF} TH={(tex0>>30)&0xF} TFX={(tex0>>35)&3}")
R.append(f"relocated TEX0 (fixture): TBP0={tbp} TBW=1 TW=6 TH=6 TFX=0 (downscale 256->{dst}, UV scale /{256//dst})")
R.append(f"Q span: {min(Qs):.4f}..{max(Qs):.4f} S span {min(Ss):.4f}..{max(Ss):.4f} T span {min(Ts):.4f}..{max(Ts):.4f}")
R.append(f"perspective-vs-affine max error: {maxerr:.4f} texels (threshold {MAX_ERR})")
ok = maxerr <= MAX_ERR
staging_words = 7 + 9*len(seg)
R.append(f"staging words: {staging_words} (<=256: {'ok' if staging_words<=256 else 'OVERFLOW'})")
if not ok:
R.append("GATE FAILED: affine ST/Q surrogate exceeds error threshold -> FAIL CLOSED. Ch342 = real ST/Q through the feeder.")
else:
R.append(f"DECLARED APPROXIMATION: perspective ST/Q rendered as affine UV; max_error={maxerr:.4f} texels (NOT faithful perspective-correct texturing).")
print("\n".join(R)+"\n")
if opt("--report"): # write the report in BOTH branches (the failure report is the useful one)
open(opt("--report"),"w").write("\n".join(R)+"\n"); print(f"[wrote report -> {opt('--report')}]")
if not ok:
return 0
sp=opt("--scene")
if sp:
# viewport-fit the segment's screen bbox into [1,dst-2] (declared, like Ch340)
xs=[v["x"] for t in seg for v in t]; ys=[v["y"] for t in seg for v in t]
x0,x1,y0,y1=min(xs),max(xs),min(ys),max(ys)
FB=64; s=min((FB-2)/max(1.0,x1-x0),(FB-2)/max(1.0,y1-y0))
def fx(x): return max(0,min(FB-1,1+int((x-x0)*s)))
def fy(y): return max(0,min(FB-1,1+int((y-y0)*s)))
L=[f"# Ch341 authentic cube subsegment ({len(seg)} tris) — DECLARED affine ST/Q surrogate, max_error={maxerr:.3f} texels",
f"# screen bbox ({x0:.0f},{y0:.0f})..({x1:.0f},{y1:.0f}) viewport-fit -> {FB}x{FB}; texture 256->{dst} downscale",
f"tex0 {tbp} 1 6 6 0"]
for t in seg:
a=[]
for v in t:
u=max(0,min(dst-1,int(round((v["S"]/v["Q"])*dst)))) if v["Q"] else 0
w=max(0,min(dst-1,int(round((v["T"]/v["Q"])*dst)))) if v["Q"] else 0
a += [fx(v["x"]), fy(v["y"]), u, w]
(r,g,b)=t[-1]["rgb"]; z=t[-1]["z"]
L.append("tritex "+" ".join(map(str,a+[z,r,g,b])))
L.append("go")
open(sp,"w").write("\n".join(L)+"\n"); print(f"[wrote {len(seg)}-tri textured scene -> {sp}]")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))