Files
retroDE_ps2/tools/gs_make_sh3_scheduler_fixture.py
thejayman77 ba74bbd5aa Snapshot: fog implementation + fidelity tooling baseline (pre bilinear-clamp fix)
Per-vertex GS fog end-to-end (gs_stub emit incl. persp_emit5, gs_prim_list_feeder
XYZ2->XYZF2 on PRIM.FGE, gs_make_sh3_scheduler_fixture.py F/FGE packing), new fog
TBs, fidelity attribution tooling. Functional baseline before removing the dead
bilinear lerp8 clamps (Codex: 161-node comb loop -> -0.042ns setup fail).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:56:46 -04:00

1222 lines
74 KiB
Python

#!/usr/bin/env python3
"""retroDE_ps2 — Ch356: N-TEXTURE SCHEDULER (data-driven epoch descriptors, >=3 distinct texture epochs).
Generalizes Ch355's hard-coded two-group flow to a DATA-DRIVEN scheduler over ordered authentic draw groups. Each
epoch descriptor carries: dump-order index, texture source + LPDDR location + size + expected CRC, relocated CBP +
expected palette identity, staging image + word count + expected records, expected drain transition. The scheduler
operates over a PRELOADED CLUT table (the bootlet uploads all N CLUTs to distinct relocated CBPs) — runtime CLUT
UPLOAD is explicitly OUT of scope (Codex); the scheduler SELECTS among preloaded palettes per epoch.
Default epochs (dump order, 3 DISTINCT textures, frame f1): E0=idx11671 (tbp=10240) E1=idx19562 (tbp=11264)
E2=idx89761 (tbp=9216).
Preflight fail-CLOSED gates (Codex): >=3 epochs w/ >=3 distinct textures; same frame + authentic ascending order;
supported format/state; resident texture + CLUT per epoch; distinct palettes (no aliasing); non-overlapping relocated
CBPs within BRAM; per-triangle Q canonicalization (all-neg -> negate; mixed/zero -> FAIL CLOSED); each list fits
staging (2048); deterministic union framebuffer geometry.
Usage: gs_make_sh3_scheduler_fixture.py [dump.gs.zst] [--draw-list i0,i1,i2,...] [--pscale N|auto] [--xy-quant round|floor|ceil|trunc] [--subpixel-xy] [--legacy-strip-kicks] [--cbp-base N] [--ref-sample integer|center] [--ref-xy-quant|--float-ref-xy] [--emit-clamp-header] [--normalize-full-region-clamp] [--clip-fb-guardband] [--auth-scissor-clip] [--capacity-epochs PIXELS] [--emit]
LOCAL/gitignored. Reuses gs_make_sh3_multidraw_fixture.load_draws / clip_rect / edge.
"""
import sys, os, math
HERE=os.path.dirname(os.path.abspath(__file__)); ROOT=os.path.normpath(os.path.join(HERE,".."))
DATA=os.path.join(ROOT,"sim","data","top_psmct32_raster_demo")
import gs_sh3_recon as RC
import gs_make_sh3_multidraw_fixture as MD
import gs_texture_residency as R
sys.path.insert(0, DATA); import bake
DEFAULT_DRAWS=[11671, 19562, 89761] # dump order; textures tbp=10240, 11264, 9216 (all distinct)
TEX_BYTES=512*512
CBP_BASE=0x1E000//256 # 480; each epoch relocated +4 blocks (1 KiB) -> non-overlapping
STG_WORDS=2048
VRAM_BLOCKS=(128*1024)//256 # 512
def bbox(dr):
xs=[v["x"] for v in dr["verts"]]; ys=[v["y"] for v in dr["verts"]]; return (min(xs),min(ys),max(xs),max(ys))
def topology_tris(fv, prim_type, honor_kick=True):
"""Expand one GS primitive run into independent triangle records.
The hardware feeder restarts PRIM for every emitted triangle, so preserving
the captured topology here is mandatory: triangle lists are disjoint
triples, fans retain vertex zero, and sprites are opposing-corner pairs.
"""
if prim_type == 3: # TRIANGLE list
if len(fv)%3:
sys.exit(f"[Ch409] FAIL: TRIANGLE run has {len(fv)} vertices (not divisible by 3)")
return [(fv[i],fv[i+1],fv[i+2]) for i in range(0,len(fv),3) if fv[i+2].get("kick",True)]
if prim_type == 4: # TRI_STRIP
return [(fv[i-2],fv[i-1],fv[i]) for i in range(2,len(fv)) if not honor_kick or fv[i].get("kick",True)]
if prim_type == 5: # TRI_FAN
return [(fv[0],fv[i-1],fv[i]) for i in range(2,len(fv)) if fv[i].get("kick",True)]
if prim_type == 6: # SPRITE: two opposing corners -> two triangles
if len(fv)%2:
sys.exit(f"[Ch409] FAIL: SPRITE run has {len(fv)} vertices (not divisible by 2)")
out=[]
for i in range(0,len(fv),2):
a,b=fv[i],fv[i+1]
if not b.get("kick",True): continue
# GS sprite depth/color come from the second vertex. Texture and
# XY coordinates are the Cartesian product of the corner pair.
def corner(x,y,s,t):
v=dict(b); v.update(x=x,y=y,s=s,t=t); return v
tl=corner(a["x"],a["y"],a["s"],a["t"])
tr=corner(b["x"],a["y"],b["s"],a["t"])
bl=corner(a["x"],b["y"],a["s"],b["t"])
br=corner(b["x"],b["y"],b["s"],b["t"])
out += [(tl,tr,bl),(tr,br,bl)]
return out
sys.exit(f"[Ch409] FAIL: unsupported textured primitive type {prim_type}")
def coalesce_solid_sprite_grid(fv):
"""Replace an exactly tiled solid-sprite grid with one rectangle.
The GS sprite primitive owns a half-open rectangle. Expanding every
captured tile to two triangles independently exposes internal diagonal/
edge exclusions in the triangle walker. This opt-in transform is legal
only when the captured rectangles tile their bounding box exactly and all
non-coordinate interpolants match.
"""
if len(fv)<2 or len(fv)%2:
sys.exit(f"[Ch434] cannot coalesce {len(fv)} sprite vertices")
rects=[]
sig=None
for i in range(0,len(fv),2):
a,b=fv[i],fv[i+1]
x0,x1=sorted((a['x'],b['x'])); y0,y1=sorted((a['y'],b['y']))
if x0==x1 or y0==y1:
sys.exit("[Ch434] degenerate solid-sprite tile")
cur=(a.get('rgba'),b.get('rgba'),a.get('z'),b.get('z'),a.get('fog'),b.get('fog'))
if sig is None: sig=cur
elif cur!=sig: sys.exit("[Ch434] solid-sprite grid changes color/Z/fog")
rects.append((x0,y0,x1,y1))
xs=sorted({x for r in rects for x in (r[0],r[2])})
ys=sorted({y for r in rects for y in (r[1],r[3])})
for ya,yb in zip(ys,ys[1:]):
for xa,xb in zip(xs,xs[1:]):
cx=(xa+xb)/2; cy=(ya+yb)/2
n=sum(x0<=cx<x1 and y0<=cy<y1 for x0,y0,x1,y1 in rects)
if n!=1: sys.exit(f"[Ch434] solid-sprite grid is not an exact tiling at ({cx},{cy}), owners={n}")
a=dict(fv[0]); b=dict(fv[-1])
a.update(x=xs[0],y=ys[0],kick=True)
b.update(x=xs[-1],y=ys[-1],kick=True)
return [a,b]
def half_open_sprite_rects(fv):
"""Convert GS half-open sprite maxima for the integer-sample walker."""
out=[]
for i in range(0,len(fv),2):
a=dict(fv[i]); b=dict(fv[i+1])
dx=b['x']-a['x']; dy=b['y']-a['y']
if dx:
fx=(abs(dx)-(1.0/16.0))/abs(dx)
b['x']=a['x']+dx*fx
b['s']=a['s']+(b['s']-a['s'])*fx
if dy:
fy=(abs(dy)-(1.0/16.0))/abs(dy)
b['y']=a['y']+dy*fy
b['t']=a['t']+(b['t']-a['t'])*fy
out.extend((a,b))
return out
def main(argv):
a=argv[1:]; dump=a[0] if a and not a[0].startswith("--") else None
idxs=[int(x) for x in a[a.index("--draw-list")+1].split(",")] if "--draw-list" in a else list(DEFAULT_DRAWS)
# Ch357: native 640x480 LPDDR FB (authentic screen coords, NO union-origin translation). Distinct fixture prefix so
# both chapters' fixtures coexist. Ch356 default = union-cropped 384x381.
FB640 = "--fb640" in a
# Ch357 persistent-Z: --authz carries AUTHENTIC per-vertex XYZ2 Z (instead of the paint-order constant flatten);
# --tag NAME sets the output prefix so the Z fixtures (e.g. sh3_zsched=[8634,12757,145742], sh3_ztrio=trio) coexist.
AUTHZ = "--authz" in a
TAG = a[a.index("--tag")+1] if "--tag" in a else None
CBP_RELOC_BASE = int(a[a.index("--cbp-base")+1]) if "--cbp-base" in a else CBP_BASE
RUNTIME_CLUT = "--runtime-clut" in a
ALLOW_ABE = "--allow-abe" in a
ALLOW_UNTEXTURED = "--allow-untextured" in a
OVERRIDE_CT32={}
for ai,arg in enumerate(a):
if arg=="--override-ct32":
if ai+1>=len(a) or ":" not in a[ai+1]:
sys.exit("[Ch434] --override-ct32 requires DRAW_IDX:LINEAR_MEM")
oi,op=a[ai+1].split(":",1)
OVERRIDE_CT32[int(oi)]=op
EMIT_CLAMP_HEADER = "--emit-clamp-header" in a
NORMALIZE_FULL_REGION_CLAMP = "--normalize-full-region-clamp" in a
AUTH_COLOR_TFX = "--auth-color-tfx" in a
# Ch415 fixture-side implementation of the captured GS fog subset used by
# this SH3 frame. Every selected scene draw has PRIM.FGE=1 and FOGCOL=0,
# so fog is a multiply toward black. Folding each vertex RGB by F/256
# before the existing Gouraud+MODULATE path is exact at vertices and a
# bounded approximation within triangles, without a blanket post-process
# pass that incorrectly erases the foreground character.
AUTH_FOG_BLACK_FOLD = "--auth-fog-black-fold" in a
# Real per-vertex GS fog: for draws whose captured PRIM has FGE=1, set
# PRIM.FGE (bit 5) in the emitted PRIM descriptor and pack the vertex commit
# word as XYZF2 (0x04) carrying the 8-bit fog coefficient F=[63:56] atop the
# SAME 24-bit Z. The feeder then tags the commit XYZF2 and gs_stub applies
# texel*F + FOGCOL*(255-F) at emit. This is the correct per-pixel path,
# distinct from --auth-fog-black-fold (an RGB fold that DECAL discards).
# Default ON; --no-auth-fog reverts to XYZ2 (fog dropped). FGE=0 draws are
# unaffected either way (they always emit XYZ2), preserving byte-identity.
AUTH_FOG = "--no-auth-fog" not in a
REF_BILINEAR = "--ref-bilinear" in a
# Preserve the capture's native GS 12.4 screen coordinates in XYZ2.
# The legacy/signoff path quantizes to integer pixels first; keeping this
# opt-in lets the subpixel RTL land without perturbing old fixtures.
SUBPIXEL_XY = "--subpixel-xy" in a
# Preserve the exact strip population used by the Ch415 signoff fixture
# for controlled A/B fidelity work. The newer parsed-kick interpretation
# otherwise removes broad layers and confounds an XY-only experiment.
LEGACY_STRIP_KICKS = "--legacy-strip-kicks" in a
TRI_EPOCHS = "--tri-epochs" in a
CLIP_FB_GUARDBAND = "--clip-fb-guardband" in a
AUTH_SCISSOR_CLIP = "--auth-scissor-clip" in a
COALESCE_SOLID_SPRITES = "--coalesce-solid-sprites" in a
HALF_OPEN_SPRITES = "--half-open-sprites" in a
FAST_FIT_SCALE = "--fast-fit-scale" in a
SKIP_MIXED_Q = "--skip-mixed-q-tris" in a
CAPACITY_EPOCH_PIXELS = int(a[a.index("--capacity-epochs")+1]) if "--capacity-epochs" in a else None
if CAPACITY_EPOCH_PIXELS is not None and CAPACITY_EPOCH_PIXELS <= 0:
sys.exit(f"[Ch407] FAIL: --capacity-epochs must be positive, got {CAPACITY_EPOCH_PIXELS}")
if CAPACITY_EPOCH_PIXELS is not None and TRI_EPOCHS:
sys.exit("[Ch407] FAIL: choose --tri-epochs or --capacity-epochs, not both")
if CBP_RELOC_BASE < 0 or CBP_RELOC_BASE >= VRAM_BLOCKS:
sys.exit(f"[Ch356] FAIL: --cbp-base {CBP_RELOC_BASE} outside BRAM block range 0..{VRAM_BLOCKS-1}")
# Ch359 packs a fixed number of consecutive draws into an epoch. Ch364 adds an explicit group partition so
# a real ordered scene can rebind textures and then retain one texture for several larger feeder lists.
if "--group-size" in a and "--group-sizes" in a:
sys.exit("[Ch359] FAIL: choose only one of --group-size or --group-sizes")
if "--group-sizes" in a:
GROUP_SIZES=[int(x) for x in a[a.index("--group-sizes")+1].split(",")]
if not GROUP_SIZES or any(x < 1 for x in GROUP_SIZES):
sys.exit(f"[Ch364] FAIL: --group-sizes must contain positive integers, got {GROUP_SIZES}")
if sum(GROUP_SIZES) != len(idxs):
sys.exit(f"[Ch364] FAIL: --group-sizes sums to {sum(GROUP_SIZES)}, need {len(idxs)} draws")
MIXED_GROUPS=True
else:
GROUP=int(a[a.index("--group-size")+1]) if "--group-size" in a else 1
if GROUP < 1: sys.exit(f"[Ch359] FAIL: --group-size must be >=1, got {GROUP}")
if len(idxs) % GROUP != 0: sys.exit(f"[Ch359] FAIL: {len(idxs)} draws not divisible by --group-size {GROUP}")
GROUP_SIZES=[GROUP]*(len(idxs)//GROUP)
MIXED_GROUPS=False
# Explicit grouping also permits deliberately separate singleton groups;
# this is needed to interleave isolated draw families chronologically.
GROUPED=("--group-size" in a or "--group-sizes" in a)
N_EP=len(GROUP_SIZES)
REF_SAMPLE = a[a.index("--ref-sample")+1].lower() if "--ref-sample" in a else "integer"
if REF_SAMPLE not in ("integer", "center"):
sys.exit(f"[Ch356] FAIL: --ref-sample must be integer or center, got {REF_SAMPLE}")
if "--ref-xy-quant" in a and "--float-ref-xy" in a:
sys.exit("[Ch356] FAIL: choose only one of --ref-xy-quant or --float-ref-xy")
# ZSCHED signoff fixtures feed integer XYZ2 screen coordinates to the RTL, so the reference must use the same
# quantized geometry. --float-ref-xy is kept only as a report-only fidelity-debt diagnostic.
REF_XY_QUANT = ("--ref-xy-quant" in a) or (AUTHZ and TAG in ("zsched", "zs640", "zs640c6") and "--float-ref-xy" not in a)
XY_QUANT_RAW = a[a.index("--xy-quant")+1] if "--xy-quant" in a else "round"
XY_QUANT_LIST = [x.strip().lower() for x in XY_QUANT_RAW.split(",")] if "," in XY_QUANT_RAW else None
if XY_QUANT_LIST is not None and len(XY_QUANT_LIST) != N_EP:
sys.exit(f"[Ch356] FAIL: comma --xy-quant needs {N_EP} entries (one per EPOCH), got {len(XY_QUANT_LIST)}")
for q in (XY_QUANT_LIST if XY_QUANT_LIST is not None else [XY_QUANT_RAW.lower()]):
if q not in ("round", "floor", "ceil", "trunc"):
sys.exit(f"[Ch356] FAIL: --xy-quant must be round/floor/ceil/trunc, got {q}")
PSCALE_RAW = a[a.index("--pscale")+1] if "--pscale" in a else "4096"
PSCALE_LIST = [x.strip().lower() for x in PSCALE_RAW.split(",")] if "," in PSCALE_RAW else None
PSCALE_AUTO = PSCALE_RAW.lower() in ("auto", "pertri", "per-tri") or (PSCALE_LIST is not None and any(x in ("auto","pertri","per-tri") for x in PSCALE_LIST))
PSCALE_ARG = None if (PSCALE_AUTO or PSCALE_LIST is not None) else int(PSCALE_RAW)
if PSCALE_LIST is not None and len(PSCALE_LIST) != N_EP:
sys.exit(f"[Ch356] FAIL: comma --pscale needs {N_EP} entries (one per EPOCH), got {len(PSCALE_LIST)}")
if (not PSCALE_AUTO) and PSCALE_LIST is None and PSCALE_ARG <= 0:
sys.exit(f"[Ch356] FAIL: --pscale must be positive, got {PSCALE_ARG}")
PFX = f"sh3_{TAG}" if TAG else ("sh3_s640" if FB640 else "sh3_sched")
CH = (a[a.index("--chapter")+1] if "--chapter" in a else
("Ch364" if MIXED_GROUPS else ("Ch359" if GROUPED else ("Ch357" if (FB640 or AUTHZ) else "Ch356"))))
if dump is None:
import glob; c=glob.glob(os.path.join(ROOT,"captures","gs","silenthill3","*224139*.gs.zst"))
if not c: sys.exit("no SH3 dump found; pass the .gs.zst path")
dump=c[0]
print(f"[Ch356] dump={os.path.basename(dump)} epochs(dump order)={idxs}")
# Parse the 15 MiB capture once. Full chronological fixtures can contain
# hundreds of draws; reparsing and replaying from frame zero per draw made
# generation scale quadratically.
collected=R.collect(dump,0)
got, vram = MD.load_draws(dump, idxs, collected=collected,
allow_untextured=ALLOW_UNTEXTURED)
if vram is None: sys.exit("[Ch409] FAIL: GS VRAM snapshot absent")
for i in idxs:
if i not in got: sys.exit(f"[Ch356] FAIL: idx{i} not found as a supported draw")
eps=[got[i] for i in idxs]
for e in eps:
if e['first_idx'] in OVERRIDE_CT32:
t0=e['state']['tex0']
if t0['tw']!=256 or t0['th']!=256:
sys.exit(f"[Ch434] override idx{e['first_idx']} requires captured 256x256 TEX0, got {t0['tw']}x{t0['th']}")
# The staged source is already decoded to linear CT32. Retain
# the captured dimensions/function but describe the replacement
# with its real 256-pixel physical row stride.
t0.update(psm=0x00,tbw=4,tw=256,th=256)
# ---- gate: enough draws, authentic ascending dump order, same frame ----
if not GROUPED and len(eps)<3: sys.exit(f"[Ch356] FAIL: need >=3 epochs, got {len(eps)}")
# A single exact-state group is valid for a recovered local draw family;
# capacity scheduling may still split it into several bounded epochs.
if GROUPED and N_EP<1: sys.exit(f"[Ch359] FAIL: grouped mode needs >=1 epoch, got {N_EP}")
if idxs!=sorted(idxs): sys.exit(f"[Ch356] FAIL: --draw-list not ascending dump order {idxs}")
frames={e["frame"] for e in eps}
if len(frames)!=1: sys.exit(f"[Ch356] FAIL: epochs span multiple frames {sorted(frames)}")
# ---- gate: texture layout + supported format/state per draw ----
tbps=[e["state"]["tex0"]["tbp"] for e in eps]
if not GROUPED:
if len(set(tbps))<3: sys.exit(f"[Ch356] FAIL: <3 distinct textures {tbps}")
for e in eps:
t0=e["state"]["tex0"]; pr=e["state"]["prim"]
# The LPDDR cache remains a 512x512 address window, but the authentic
# sampler descriptor may address a smaller PSMT8 tile within it.
indexed_shape_ok = ((t0["psm"]==0x00
and t0["tw"]==64 and t0["th"]==64
and t0["tbw"]==1)
or (e['first_idx'] in OVERRIDE_CT32
and t0["psm"]==0x00 and t0["tw"]==256
and t0["th"]==256 and t0["tbw"]==4)
or (t0["psm"]==0x13
and t0["tw"] in (128,256,512)
and t0["th"] in (128,256,512))
or (t0["psm"]==0x14
and t0["tw"]==512 and t0["th"]==1024
and t0["tbw"]==8))
textured_ok = indexed_shape_ok and pr["tme"]==1 and pr["fst"]==0
untextured_ok = ALLOW_UNTEXTURED and pr["tme"]==0
if not (textured_ok or untextured_ok):
sys.exit(f"[Ch356] FAIL: idx{e['first_idx']} unsupported format/state (psm=0x{t0['psm']:02x} {t0['tw']}x{t0['th']} tme={pr['tme']} fst={pr['fst']})")
if pr["abe"] and not ALLOW_ABE:
sys.exit(f"[Ch356] FAIL: idx{e['first_idx']} is ABE=1; pass --allow-abe only for the LPDDR destination-blend path")
if ALLOW_ABE:
fbmask=(e['state']['frame']>>32)&0xFFFFFFFF
mask_bytes=[(fbmask>>(8*i))&0xFF for i in range(4)]
if any(x not in (0x00,0xFF) for x in mask_bytes):
sys.exit(f"[{CH}] FAIL: idx{e['first_idx']} FRAME.FBMSK 0x{fbmask:08x} is not byte-granular")
# Texture addressing state is irrelevant for TME=0. Do not reject
# an untextured draw because a stale TEX0/CLAMP register happens to
# use a mode unsupported by the sampler it bypasses.
if not pr["tme"]:
continue
clamp=e["state"]["clamp"]
wms=clamp&3; wmt=(clamp>>2)&3
minu=(clamp>>4)&0x3FF; maxu=(clamp>>14)&0x3FF
minv=(clamp>>24)&0x3FF; maxv=(clamp>>34)&0x3FF
if wms>1 or wmt>1:
u_full=(wms==2 and minu==0 and maxu==t0["tw"]-1)
v_full=(wmt==2 and minv==0 and maxv==t0["th"]-1)
if (wms>1 and not u_full) or (wmt>1 and not v_full) or wms==3 or wmt==3:
sys.exit(f"[Ch356] FAIL: idx{e['first_idx']} uses unmodelled CLAMP WMS={wms} [{minu},{maxu}] WMT={wmt} [{minv},{maxv}]")
if not NORMALIZE_FULL_REGION_CLAMP:
sys.exit(f"[Ch356] FAIL: idx{e['first_idx']} uses full-texture region CLAMP; pass --normalize-full-region-clamp to prove and normalize it")
# GS region-clamp [0,size-1] is exactly ordinary clamp for this texture.
# Normalize only the mode bits; retaining the bounds makes the emitted
# state auditable while keeping the software reference on modes 0/1.
clamp=(clamp&~0xF) | ((1 if wms==2 else wms)<<0) | ((1 if wmt==2 else wmt)<<2)
e["state"]["clamp"]=clamp
print(f"[{CH}] idx{e['first_idx']} normalized full-region CLAMP WMS/WMT {wms}/{wmt} -> {clamp&3}/{(clamp>>2)&3}")
if not GROUPED:
print(f"[Ch356] frame f{eps[0]['frame']}; {len(eps)} epochs, textures tbp={tbps} (all distinct); PSMT8 512x512 perspective TME — supported")
# ---- gate: resident texture + CLUT per epoch; collect CRC + palette; assign relocated CBP ----
descs=[]; pals=[]; next_cbp=CBP_RELOC_BASE; group_no=0; group_acc=None
memseq=RC.iter_localmem_at(dump,idxs,collected=collected)
for k,(e,(mem_idx,mem)) in enumerate(zip(eps,memseq)):
if mem_idx != e["first_idx"]: sys.exit(f"[Ch409] internal localmem sequence mismatch {mem_idx} != {e['first_idx']}")
t0=e["state"]["tex0"]
is_textured=bool(e["state"]["prim"]["tme"])
override_path=OVERRIDE_CT32.get(e['first_idx'])
tex=(bytes(mem.m[t0['tbp']*256:t0['tbp']*256+TEX_BYTES])
if is_textured and override_path is None else bytes(TEX_BYTES))
clut=((bytes(1024) if t0['psm']==0x00 else
bytes(mem.m[t0['cbp']*256:t0['cbp']*256+1024]))
if is_textured else bytes(1024))
if is_textured and override_path is None and not any(tex):
sys.exit(f"[Ch356] FAIL: texture idx{e['first_idx']} @tbp={t0['tbp']} not resident")
if is_textured and t0['psm']!=0x00 and not any(clut):
sys.exit(f"[Ch356] FAIL: CLUT idx{e['first_idx']} @cbp={t0['cbp']} not resident")
# The cache is physically 512 texels wide. Place a captured 256x256
# texture into that stride so TEX0 can retain logical TW/TH=8. Keep
# the historical 128/512 reconstruction byte-identical.
if override_path is not None:
vals=[]
with open(override_path) as of:
for ln in of:
s=ln.strip()
if s and not s.startswith(("//","#")):
vals.append(int(s,16)&0xFFFFFFFF)
if len(vals)==640*480:
idxw=[vals[y*640+x] for y in range(256) for x in range(256)]
elif len(vals)==256*256:
idxw=vals
else:
sys.exit(f"[Ch434] override idx{e['first_idx']} has {len(vals)} words; expected 65536 or 307200")
elif not is_textured:
idxw=[0]*(TEX_BYTES//4)
elif t0['psm']==0x00:
# Direct-color 64x64 sources are linearized into the upper-left
# of the fixed 512-wide, 256-KiB cache window. TEX0 is rebound
# with TBW=8 below, so each source row must use this wider stride.
idx=[0]*(512*128)
for yy in range(64):
for xx in range(64):
idx[yy*512+xx]=mem.read_ct32_word(t0['tbp'],t0['tbw'],xx,yy)
idxw=idx
elif t0['psm']==0x14:
# The recovered character texture is 512x1024 PSMT4: exactly
# 256 KiB when packed two texels per byte, matching the existing
# cache window without padding or an RTL geometry change.
src4=mem.read_psmt4(t0['tbp'],t0['tbw'],t0['tw'],t0['th'])
idx=bytearray((src4[n]&15)|((src4[n+1]&15)<<4)
for n in range(0,len(src4),2))
if len(idx)!=TEX_BYTES:
sys.exit(f"[{CH}] FAIL: idx{e['first_idx']} packed PSMT4 cache is {len(idx)} bytes, expected {TEX_BYTES}")
elif t0['tw']==256 and t0['th']==256:
src=mem.read_psmt8(t0['tbp'], t0['tbw'], 256, 256)
idx=bytearray(512*512)
for yy in range(256):
idx[yy*512:yy*512+256]=src[yy*256:(yy+1)*256]
# Fail closed before the emitted cache image and RTL can agree on
# the same bad host-side remap. The logical tile must survive
# byte-exact at the start of every physical 512-texel row, and
# all padding (right half plus unused lower rows) must stay zero.
for yy in range(256):
row=idx[yy*512:(yy+1)*512]
if row[:256] != src[yy*256:(yy+1)*256] or any(row[256:]):
sys.exit(f"[{CH}] FAIL: idx{e['first_idx']} 256x256 logical texture cache row {yy} remap mismatch")
if any(idx[256*512:]):
sys.exit(f"[{CH}] FAIL: idx{e['first_idx']} 256x256 logical texture lower cache padding is nonzero")
else:
idx=mem.read_psmt8(t0['tbp'], t0['tbw'], 512, 512)
if t0['tw']==128 and t0['th']==128:
# The emitted cache descriptor intentionally widens TBW to
# eight, so verify that the logical source tile occupies the
# matching upper-left portion of the 512-wide cache image.
src=mem.read_psmt8(t0['tbp'],t0['tbw'],128,128)
for yy in range(128):
if idx[yy*512:yy*512+128] != src[yy*128:(yy+1)*128]:
sys.exit(f"[{CH}] FAIL: idx{e['first_idx']} 128x128 logical texture cache row {yy} remap mismatch")
if is_textured and t0['psm']!=0x00:
idxw=[idx[i*4]|(idx[i*4+1]<<8)|(idx[i*4+2]<<16)|(idx[i*4+3]<<24) for i in range(512*512//4)]
crc=sum(idxw)&0xFFFFFFFF
pal=(tuple([0]*256) if (not is_textured or t0['psm']==0x00) else
tuple(RC.read_clut32(mem,t0['cbp'],order="grid")))
one=dict(k=k, idx=e["first_idx"], tbp=t0['tbp'], cbp_orig=t0['cbp'], cbp_reloc=None,
crc=crc, bbox=bbox(e), lpddr=0x200000, size=TEX_BYTES, e=e,
state=e["state"], idxw=idxw, pal=list(pal), clut=clut,
logical_tw=(t0['tw'] if is_textured else 512),
logical_th=(t0['th'] if is_textured else 512),
tex_psm=(t0['psm'] if is_textured else 0x13),
tex_tbw=(4 if override_path is not None else 8))
if not GROUPED:
pals.append(pal); descs.append(one)
continue
# Fold a declared group immediately so hundreds of full-frame draws
# do not retain hundreds of 256 KiB Python texture arrays at once.
if group_acc is None:
group_acc=dict(one); group_acc.update(k=group_no,idx_all=[],members=[])
else:
s0=group_acc['state']
for key in ("prim","tex0","test","zbuf","clamp","alpha","texa"):
if one['state'].get(key)!=s0.get(key):
sys.exit(f"[{CH}] FAIL: idx{one['idx']} state '{key}' differs from idx{group_acc['idx']} inside group {group_no}")
if one['idxw']!=group_acc['idxw'] or one['clut']!=group_acc['clut']:
sys.exit(f"[{CH}] FAIL: idx{one['idx']} texture/CLUT bytes differ from idx{group_acc['idx']} inside group {group_no}")
ba=group_acc['bbox']; bb=one['bbox']
group_acc['bbox']=(min(ba[0],bb[0]),min(ba[1],bb[1]),max(ba[2],bb[2]),max(ba[3],bb[3]))
group_acc['idx_all'].append(one['idx']); group_acc['members'].append(e); group_acc['e']=None
if len(group_acc['members'])==GROUP_SIZES[group_no]:
s0=group_acc['state']; tst=s0['test']; zbf=s0['zbuf']
zte=(tst>>16)&1; ztst=(tst>>17)&3; zpsm=(zbf>>24)&0xF; zmsk=(zbf>>32)&1
untextured_group=all(not m['state']['prim']['tme'] for m in group_acc['members'])
override_group=all(m['first_idx'] in OVERRIDE_CT32 for m in group_acc['members'])
# Authentic light-buffer setup uses ZTEST=ALWAYS for its opaque
# gray clear and final bias-removal sprite, while the intervening
# volumes use GEQUAL against the scene Z and never write Z.
z_ok=(zte==1 and zpsm==0xA and
(ztst==2 or ((ALLOW_UNTEXTURED and untextured_group) or override_group)
and ztst==1 and zmsk==1))
if not z_ok:
sys.exit(f"[{CH}] FAIL: group {group_no} idx{group_acc['idx']} lacks authentic ZTE=1/GEQUAL/PSMZ16S state")
reuse=bool(descs and group_acc['idxw']==descs[-1]['idxw'] and group_acc['clut']==descs[-1]['clut'])
if RUNTIME_CLUT:
group_acc['cbp_reloc']=CBP_RELOC_BASE
else:
group_acc['cbp_reloc']=descs[-1]['cbp_reloc'] if reuse else next_cbp
if not reuse: next_cbp+=4
group_acc['reuse']=int(reuse); descs.append(group_acc)
group_no+=1; group_acc=None
if GROUPED and (group_acc is not None or group_no!=len(GROUP_SIZES)):
sys.exit(f"[{CH}] FAIL: group fold ended at {group_no}/{len(GROUP_SIZES)}")
if not GROUPED:
# Runtime-CLUT fixtures deliberately reuse one staging CBP: the host
# commits the epoch palette before GO. Keep each draw as its own
# chronological epoch while retaining an already resident identical
# texture/CLUT payload. The old preloaded-CLUT mode still requires a
# distinct relocated palette for every epoch.
if RUNTIME_CLUT:
for k,d in enumerate(descs):
d['cbp_reloc']=CBP_RELOC_BASE
d['reuse']=int(k>0 and d['idxw']==descs[k-1]['idxw'] and d['clut']==descs[k-1]['clut'])
else:
for k,d in enumerate(descs):
d['cbp_reloc']=CBP_RELOC_BASE+k*4
# ---- gate: distinct palettes (real multi-texture, no aliasing) ----
if not RUNTIME_CLUT:
for i in range(len(pals)):
for j in range(i+1,len(pals)):
if pals[i]==pals[j]: sys.exit(f"[Ch356] FAIL: epochs {idxs[i]},{idxs[j]} share a palette (aliasing)")
# ---- gate: non-overlapping relocated CBPs within BRAM ----
for d in descs:
if d['cbp_reloc']+4 > VRAM_BLOCKS: sys.exit(f"[Ch356] FAIL: relocated CBP {d['cbp_reloc']} (+4) exceeds VRAM {VRAM_BLOCKS} blocks")
cbps=[d['cbp_reloc'] for d in descs]
if not RUNTIME_CLUT:
for i in range(len(cbps)):
for j in range(i+1,len(cbps)):
if abs(cbps[i]-cbps[j])<4: sys.exit(f"[Ch356] FAIL: relocated CBPs {cbps[i]},{cbps[j]} overlap")
print(f"[Ch356] all {len(eps)} textures + CLUTs resident; palettes pairwise-distinct; relocated CBPs {cbps} distinct + in BRAM (512 blocks)")
else:
print(f"[{CH}] all {len(eps)} textures + CLUTs resident; runtime palette staging CBP={CBP_RELOC_BASE}; reuse epochs={sum(d['reuse'] for d in descs)}")
else:
print(f"[{CH}] {len(eps)} draws -> {N_EP} validated epochs; "
f"groups {descs[0]['idx_all']} ... {descs[-1]['idx_all']}; reuse epochs={sum(d['reuse'] for d in descs)}")
for d in descs:
if d['cbp_reloc']+4 > VRAM_BLOCKS:
sys.exit(f"[{CH}] FAIL: relocated CBP {d['cbp_reloc']} (+4) exceeds BRAM {VRAM_BLOCKS} blocks")
# ---- framebuffer geometry ----
UX=max(d['bbox'][2] for d in descs); UY=max(d['bbox'][3] for d in descs)
UMINX=int(min(d['bbox'][0] for d in descs)); UMINY=int(min(d['bbox'][1] for d in descs))
LPDDR_TEX_BASE_P = 0x00200000
if FB640:
# Ch357: NATIVE 640x480 — preserve authentic screen coordinates (NO union-origin translation).
OX=0; OY=0; FBPXW=640; H=480; FBW=10; STRIDE=2560; W=FBPXW
# Most fixtures must be wholly in bounds. Post-process fans may carry
# authentic GS guard-band vertices outside the display; accept those
# only with an explicit opt-in, then use the existing x/y/s/t/q/Z/RGBA
# polygon clip below before quantization and emission.
if UMINX<0 or UMINY<0 or UX>FBPXW-1 or UY>H-1:
if not CLIP_FB_GUARDBAND:
sys.exit(f"[Ch357] FAIL: authentic coords x[{UMINX}..{UX:.0f}] y[{UMINY}..{UY:.0f}] exceed 640x480; pass --clip-fb-guardband only for intentional GS guard-band geometry")
print(f"[{CH}] guard-band clip enabled: authentic coords x[{UMINX}..{UX:.0f}] y[{UMINY}..{UY:.0f}] -> 640x480")
FB_END = FBPXW*H*4
if FBW!=10 or STRIDE!=2560 or FB_END!=0x12C000 or STRIDE//32!=80 or (STRIDE//32*H)!=38400:
sys.exit(f"[Ch357] FAIL: geometry mismatch (FBW={FBW} stride={STRIDE} size=0x{FB_END:x} beats/row={STRIDE//32} beats/frame={STRIDE//32*H})")
# gate: FB region [0,FB_END) disjoint from the texture region [0x200000, +TEX_BYTES).
if FB_END > LPDDR_TEX_BASE_P:
sys.exit(f"[Ch357] FAIL: FB end 0x{FB_END:x} overlaps texture base 0x{LPDDR_TEX_BASE_P:x}")
print(f"[Ch357] NATIVE 640x480 (FBW={FBW}) stride={STRIDE}B size=0x{FB_END:x} beats/row={STRIDE//32} "
f"scanout={STRIDE//32*H} beats/frame; authentic coords x[{UMINX}..{UX:.0f}] y[{UMINY}..{UY:.0f}] "
f"(no union translation); FB[0..0x{FB_END:x}) disjoint from tex[0x{LPDDR_TEX_BASE_P:x}..0x{LPDDR_TEX_BASE_P+TEX_BYTES:x})")
else:
# Ch356: union-cropped geometry.
OX=UMINX; OY=UMINY
W=int(UX)-OX+1; H=int(UY)-OY+1; FBW=(W+63)//64; FBPXW=FBW*64; STRIDE=FBPXW*4
print(f"[Ch356] UNION origin=({OX},{OY}) content={W}x{H} -> stored FB {FBPXW}x{H} (FBW={FBW}) stride={STRIDE}B "
f"size=0x{FBPXW*H*4:x} scanout={STRIDE//32*H} beats/frame")
# ---- gate: per-triangle Q canonicalization (all-neg -> negate; mixed/zero -> FAIL CLOSED) + list fits staging ----
for d in descs:
tris=[]
# Ch359: merged epochs expand EACH member draw's strip separately (no phantom bridging triangles), dump order.
for e_m in (d['members'] if d.get('members') else [d['e']]):
fv=[dict(x=v["x"]-OX,y=v["y"]-OY,z=v.get("z"),fog=v.get("fog"),rgba=v.get("rgba"),
s=v["s"],t=v["t"],q=v["q"],kick=v.get("kick",True)) for v in e_m["verts"]]
if (COALESCE_SOLID_SPRITES and e_m["state"]["prim"]["type"]==6
and not e_m["state"]["prim"]["tme"]):
fv=coalesce_solid_sprite_grid(fv)
if HALF_OPEN_SPRITES and e_m["state"]["prim"]["type"]==6:
fv=half_open_sprite_rects(fv)
raw=topology_tris(fv,e_m["state"]["prim"]["type"], honor_kick=not LEGACY_STRIP_KICKS)
clip_w=FBPXW; clip_h=H
if AUTH_SCISSOR_CLIP:
sc=e_m["state"].get("scissor")
if sc is None: sys.exit(f"[{CH}] FAIL: idx{d['idx']} missing authentic SCISSOR state")
sx0=sc&0x7FF; sx1=(sc>>16)&0x7FF; sy0=(sc>>32)&0x7FF; sy1=(sc>>48)&0x7FF
if sx0!=0 or sy0!=0: sys.exit(f"[{CH}] FAIL: nonzero SCISSOR origin ({sx0},{sy0}) not yet modelled")
clip_w=min(clip_w,sx1+1); clip_h=min(clip_h,sy1+1)
for tri in raw:
for ct in MD.clip_rect(tri, clip_w, clip_h):
qs=[v["q"] for v in ct]
if SKIP_MIXED_Q and not (all(q>0 for q in qs) or all(q<0 for q in qs)):
d['mixed_q_skipped']=d.get('mixed_q_skipped',0)+1
continue
tris.append(ct)
nneg=0
for (v0,v1,v2) in tris:
qs=[v0["q"],v1["q"],v2["q"]]
if all(q>0 for q in qs): pass
elif all(q<0 for q in qs): nneg+=1
else: sys.exit(f"[Ch356] FAIL-CLOSED: idx{d['idx']} mixed-sign/zero-Q triangle (qs={qs}) — signed-Q unsupported")
d['ntris']=len(tris); d['words']=(8 if EMIT_CLAMP_HEADER else 7)+len(tris)*9; d['nneg']=nneg
if d['words']>STG_WORDS and CAPACITY_EPOCH_PIXELS is None:
sys.exit(f"[Ch356] FAIL: idx{d['idx']} list {d['words']} words > staging {STG_WORDS}")
print(f"[Ch356] PREFLIGHT PASS. epoch descriptors (dump order):")
print(f" {'idx':>7} {'tbp':>6} {'cbp_reloc':>9} {'tex_crc':>10} {'lpddr':>9} {'tris':>5} {'words':>6} {'neg-Q':>6} {'expect_records':>14}")
for d in descs:
print(f" {d['idx']:>7} {d['tbp']:>6} {d['cbp_reloc']:>9} 0x{d['crc']:08x} 0x{d['lpddr']:07x} {d['ntris']:>5} {d['words']:>6} {d['nneg']:>6} {d['ntris']:>14}")
if "--emit" not in a:
print("[Ch356] (preflight only; --emit stage pending)"); return 0
# ================= --emit: N feeder lists + N textures/CLUTs + bootlet (ALL CLUTs preloaded) + composed ref =================
NEW_TBP=0x40000//256; TEX_VRAM_BASE=NEW_TBP*256; LPDDR_TEX_BASE=0x00200000; VRAM_BYTES=0x20000
PERSP_FRAC=bake.PERSP_FRAC; PSCALE=PSCALE_ARG; TW=512; TH=512; TW_LOG=TH_LOG=9; TBW_TEX=8; S24_MAX=(1<<23)-1
# The capture contains both tiny homogeneous STQ values (opaque scene
# geometry) and post-process/fan draws with normalized S/T~=1, Q=1. A
# common positive scale cancels in S/Q; include exact binary fractions so
# the latter fit the signed 24-bit S/T fields without sacrificing ratio.
AUTO_PSCALES=(1/4096,1/3072,1/2048,1/1536,1/1024,1/768,1/512,1/384,1/256,1/192,1/128,1/96,1/64,1/48,1/32,1/24,
0.0625,0.09375,0.125,0.1875,0.25,0.375,0.5,0.75,1,
2,4,8,16,32,64,128,256,384,512,768,1024,1536,2048,3072,4096,6144)
if PSCALE_LIST is not None:
print(f"[{CH}] perspective fixed-point scale PSCALE per-epoch spec={PSCALE_LIST}; auto candidates={AUTO_PSCALES}")
elif PSCALE_AUTO:
print(f"[{CH}] perspective fixed-point scale PSCALE=auto per-triangle candidates={AUTO_PSCALES}")
else:
print(f"[{CH}] perspective fixed-point scale PSCALE={PSCALE} (effective frac {PERSP_FRAC}+{PSCALE.bit_length()-1})")
print(f"[{CH}] XY mode={'native-12.4' if SUBPIXEL_XY else 'integer-quantized'}"
f" quantization per-epoch spec={XY_QUANT_LIST if XY_QUANT_LIST is not None else [XY_QUANT_RAW.lower()]*len(idxs)}")
edge=MD.edge; clip=MD.clip_rect
def tex0_word(cbp, tfx=1, logical_tw=TW, logical_th=TH, tex_psm=0x13, tex_tbw=TBW_TEX):
tw_log=logical_tw.bit_length()-1; th_log=logical_th.bit_length()-1
v=bake.tex0_pack(NEW_TBP,tex_tbw,psm=tex_psm,tw=tw_log,th=th_log,tfx=tfx)
v|=(cbp&0x3FFF)<<37; v|=(0&0x1)<<55; v|=(0&0x1F)<<56; v|=((0 if RUNTIME_CLUT else 1)&0x7)<<61
return v
def canon(v0,v1,v2):
qs=[v0["q"],v1["q"],v2["q"]]
if all(q>0 for q in qs): return (v0,v1,v2)
neg=lambda v: dict(v, s=-v["s"],t=-v["t"],q=-v["q"]) # negate S/T/Q only; Z/color unchanged
return (neg(v0),neg(v1),neg(v2))
# z-aware triangle clip (screen-linear Z, like the GS): mirrors MD.clip_rect but interpolates Z too.
def clip_rect_z(tri, W, Hh):
def lerp(p1,p2,al):
out={k:(p1[k]+al*(p2[k]-p1[k])) for k in ("x","y","z","fog","s","t","q")}
c=[]
for sh in (0,8,16,24):
a=(p1["rgba"]>>sh)&0xFF; b=(p2["rgba"]>>sh)&0xFF
c.append(max(0,min(255,int(round(a+al*(b-a))))))
out["rgba"]=c[0]|(c[1]<<8)|(c[2]<<16)|(c[3]<<24)
return out
def clip_edge(poly, inside, isect):
out=[]; n=len(poly)
for i in range(n):
A=poly[i]; B=poly[(i+1)%n]; ina=inside(A); inb=inside(B)
if ina: out.append(A)
if ina!=inb: out.append(isect(A,B))
return out
poly=[dict(x=v["x"],y=v["y"],z=v.get("z",0.0),fog=v.get("fog",255.0),
s=v["s"],t=v["t"],q=v["q"],rgba=v.get("rgba",0xFFFFFFFF)) for v in tri]
poly=clip_edge(poly, lambda p:p["x"]>=0.0, lambda A,B:lerp(A,B,(0.0-A["x"])/(B["x"]-A["x"]))); poly=poly or []
if not poly: return []
poly=clip_edge(poly, lambda p:p["x"]<=W, lambda A,B:lerp(A,B,(W-A["x"])/(B["x"]-A["x"])))
if not poly: return []
poly=clip_edge(poly, lambda p:p["y"]>=0.0, lambda A,B:lerp(A,B,(0.0-A["y"])/(B["y"]-A["y"])))
if not poly: return []
poly=clip_edge(poly, lambda p:p["y"]<=Hh, lambda A,B:lerp(A,B,(Hh-A["y"])/(B["y"]-A["y"])))
if len(poly)<3: return []
return [(poly[0],poly[k],poly[k+1]) for k in range(1,len(poly)-1)]
def edge_i(px, py, ax, ay, bx, by):
return (px-ax)*(by-ay)-(py-ay)*(bx-ax)
def top_or_left(ax, ay, bx, by):
dx=bx-ax; dy=by-ay
return (dy>0) or (dy==0 and dx>0)
def trunc_div(num, den):
if den==0: return 0
return (-1 if (num<0)^(den<0) else 1) * (abs(num)//abs(den))
def grad_num_dadx(a0,a1,a2,x0,y0,x1,y1,x2,y2):
return ((a1-a0)*(y2-y0)-(a2-a0)*(y1-y0)) << 16
def grad_num_dady(a0,a1,a2,x0,y0,x1,y1,x2,y2):
return ((a2-a0)*(x1-x0)-(a1-a0)*(x2-x0)) << 16
def interp_wide(base, dadx, dady, x, y, x0, y0):
return (base + ((dadx*(x-x0) + dady*(y-y0)) >> 16)) & 0xFFFFFF
def recip_lut(q, idx_bits=11, scale=24):
out_max=(1<<(scale+1))-1
if q<=0: return out_max
top=idx_bits-1; e=q.bit_length()-1
m=(q>>(e-top)) if e>=top else (q<<(top-e))
m &= (1<<idx_bits)-1
if m==0: return out_max
return min(((1<<(scale+top))//m)>>e, out_max)
def persp_uv(s, t, q):
r=recip_lut(q); u=(s*r)>>24; v=(t*r)>>24
return (min(u,2047), min(v,2047))
def quant_xy(v, mode):
if mode=="floor": return math.floor(v)
if mode=="ceil": return math.ceil(v)
if mode=="trunc": return math.trunc(v)
return round(v)
def pack_v(v, pscale, xy_mode):
s_fp=round(v["s"]*TW*(1<<PERSP_FRAC)*pscale); t_fp=round(v["t"]*TH*(1<<PERSP_FRAC)*pscale); q_fp=round(v["q"]*(1<<PERSP_FRAC)*pscale)
if abs(s_fp)>S24_MAX or abs(t_fp)>S24_MAX: return None
if q_fp<=0 or q_fp>0xFFFFFF: return None
return dict(x=max(0,min(FBPXW-1,int(quant_xy(v["x"],xy_mode)))),
y=max(0,min(H-1,int(quant_xy(v["y"],xy_mode)))),
s=s_fp&0xFFFFFF, t=t_fp&0xFFFFFF, q=q_fp&0xFFFFFF)
def prep_packed(tri, pscale, xy_mode):
verts=[pack_v(v,pscale,xy_mode) for v in tri]
if any(v is None for v in verts): return None
x0,y0=verts[0]["x"],verts[0]["y"]; x1,y1=verts[1]["x"],verts[1]["y"]; x2,y2=verts[2]["x"],verts[2]["y"]
det=(x1-x0)*(y2-y0)-(x2-x0)*(y1-y0)
if det==0: return None
if det<0:
verts=[verts[0],verts[2],verts[1]]; det=-det
v0,v1,v2=verts; x0,y0=v0["x"],v0["y"]; x1,y1=v1["x"],v1["y"]; x2,y2=v2["x"],v2["y"]
bias=[0 if top_or_left(x0,y0,x1,y1) else 1,
0 if top_or_left(x1,y1,x2,y2) else 1,
0 if top_or_left(x2,y2,x0,y0) else 1]
def grad(attr):
a0,a1,a2=v0[attr],v1[attr],v2[attr]
return (trunc_div(grad_num_dadx(a0,a1,a2,x0,y0,x1,y1,x2,y2),det),
trunc_div(grad_num_dady(a0,a1,a2,x0,y0,x1,y1,x2,y2),det))
return dict(v=verts,bias=bias,ds=grad("s"),dt=grad("t"),dq=grad("q"))
def tex_col(idxlin, palg, u, v, logical_tw=TW, logical_th=TH, tex_psm=0x13, tex_stride=TW):
if u<0 or u>=logical_tw or v<0 or v>=logical_th: return None
# The LPDDR cache has a fixed physical 512-texel row stride even when
# TEX0 advertises a smaller logical texture.
lin=v*tex_stride+u
if tex_psm==0x00:
return idxlin[lin]&0xFFFFFF
if tex_psm==0x14:
byte_off=lin//2
packed=(idxlin[byte_off//4]>>(8*(byte_off%4)))&0xFF
ci=((packed>>4)&15) if (lin&1) else (packed&15)
else:
ci=(idxlin[lin//4]>>(8*(lin%4)))&0xFF
return palg[ci]&0xFFFFFF
def texel_coord(coord, size, mode):
if mode==0:
return coord % size
if mode==1:
return max(0,min(size-1,coord))
raise AssertionError(f"unmodelled wrap mode {mode}")
def texel_uv(clamp, u, v, logical_tw=TW, logical_th=TH):
return (texel_coord(u,logical_tw,clamp&3), texel_coord(v,logical_th,(clamp>>2)&3))
def ref_near_match(idxlin, palg, color, tu, tv):
for rad in range(0,2):
for du in range(-rad,rad+1):
for dv in range(-rad,rad+1):
if max(abs(du),abs(dv))!=rad: continue
if tex_col(idxlin,palg,tu+du,tv+dv)==color:
return True
return False
def scale_score(tri, pscale, idxlin, palg, xy_mode, clamp):
pt=prep_packed(tri,pscale,xy_mode)
if pt is None: return None
v0,v1,v2=pt["v"]
minx=max(0,min(v0["x"],v1["x"],v2["x"])); maxx=min(FBPXW-1,max(v0["x"],v1["x"],v2["x"]))
miny=max(0,min(v0["y"],v1["y"],v2["y"])); maxy=min(H-1,max(v0["y"],v1["y"],v2["y"]))
fx0,fy0=tri[0]["x"],tri[0]["y"]; fx1,fy1=tri[1]["x"],tri[1]["y"]; fx2,fy2=tri[2]["x"],tri[2]["y"]
far=edge(fx0,fy0,fx1,fy1,fx2,fy2)
if abs(far)<1e-12: return None
inv=1.0/far; ok=0; total=0; oob=0; errsum=0
for y in range(miny,maxy+1):
for x in range(minx,maxx+1):
e0=edge_i(x,y,v0["x"],v0["y"],v1["x"],v1["y"])+pt["bias"][0]
e1=edge_i(x,y,v1["x"],v1["y"],v2["x"],v2["y"])+pt["bias"][1]
e2=edge_i(x,y,v2["x"],v2["y"],v0["x"],v0["y"])+pt["bias"][2]
if e0>0 or e1>0 or e2>0: continue
a0=edge(fx1,fy1,fx2,fy2,float(x),float(y))*inv
a1=edge(fx2,fy2,fx0,fy0,float(x),float(y))*inv
a2=1.0-a0-a1
Q=a0*tri[0]["q"]+a1*tri[1]["q"]+a2*tri[2]["q"]
if abs(Q)<1e-12: continue
tu,tv=texel_uv(clamp,
int(((a0*tri[0]["s"]+a1*tri[1]["s"]+a2*tri[2]["s"])/Q)*TW),
int(((a0*tri[0]["t"]+a1*tri[1]["t"]+a2*tri[2]["t"])/Q)*TH))
s=interp_wide(v0["s"],pt["ds"][0],pt["ds"][1],x,y,v0["x"],v0["y"])
t=interp_wide(v0["t"],pt["dt"][0],pt["dt"][1],x,y,v0["x"],v0["y"])
q=interp_wide(v0["q"],pt["dq"][0],pt["dq"][1],x,y,v0["x"],v0["y"])
u,v=persp_uv(s,t,q)
total+=1
col=tex_col(idxlin,palg,u,v)
if col is None:
oob+=1
errsum+=2048
continue
du=abs(u-tu); dv=abs(v-tv)
err=max(du,dv); errsum+=err
if ref_near_match(idxlin,palg,col,tu,tv): ok+=1
return (ok,total,oob,errsum)
def parse_fixed_pscale(raw):
v=int(raw)
if v<=0: sys.exit(f"[Ch356] FAIL: --pscale must be positive, got {v}")
return v
def choose_pscale(tri, idxlin=None, palg=None, mode=None, xy_mode="round", clamp=0,
logical_tw=TW, logical_th=TH):
if mode is None:
mode="auto" if PSCALE_AUTO else str(PSCALE)
if mode not in ("auto","pertri","per-tri"):
return parse_fixed_pscale(mode)
if FAST_FIT_SCALE:
# A common positive scale cancels exactly in S/Q. Choose the
# largest candidate whose packed S,T,Q all fit, maximizing fixed-
# point precision without an expensive per-pixel texture search.
for pscale in reversed(AUTO_PSCALES):
good=True
for v in tri:
sf=round(v["s"]*logical_tw*(1<<PERSP_FRAC)*pscale)
tf=round(v["t"]*logical_th*(1<<PERSP_FRAC)*pscale)
qf=round(v["q"]*(1<<PERSP_FRAC)*pscale)
if abs(sf)>S24_MAX or abs(tf)>S24_MAX or qf<=0 or qf>0xFFFFFF:
good=False; break
if good: return pscale
sys.exit(f"[{CH}] FAIL: no representable common S/T/Q scale for triangle {tri}")
best=None
for pscale in AUTO_PSCALES:
sc=scale_score(tri,pscale,idxlin,palg,xy_mode,clamp)
if sc is None: continue
ok,total,oob,errsum=sc
# The authentic floating-point UV is known, so texel-coordinate
# error is the primary fidelity metric. Color-match count cannot
# lead here: large black/repeated palette regions let a badly
# displaced UV score as a "match" and previously selected scales
# that blacked out coherent scene geometry.
key=(-oob, -errsum, ok, total, pscale)
if best is None or key>best[0]:
best=(key,pscale)
if best is None:
return 1024
return best[1]
def vwords(v, pscale, xy_mode, logical_tw=TW, logical_th=TH, fge=False):
s_fp=round(v["s"]*logical_tw*(1<<PERSP_FRAC)*pscale); t_fp=round(v["t"]*logical_th*(1<<PERSP_FRAC)*pscale); q_fp=round(v["q"]*(1<<PERSP_FRAC)*pscale)
if abs(s_fp)>S24_MAX or abs(t_fp)>S24_MAX:
sys.exit(f"[Ch356] ST overflow pscale={pscale} xy=({v['x']},{v['y']}) stq=({v['s']},{v['t']},{v['q']}) fp=({s_fp},{t_fp},{q_fp})")
if q_fp<=0 or q_fp>0xFFFFFF:
sys.exit(f"[Ch356] Q overflow pscale={pscale} xy=({v['x']},{v['y']}) stq=({v['s']},{v['t']},{v['q']}) fp=({s_fp},{t_fp},{q_fp})")
sx=max(0,min(FBPXW-1,int(quant_xy(v["x"],xy_mode)))); sy=max(0,min(H-1,int(quant_xy(v["y"],xy_mode))))
sx12_4=max(0,min((FBPXW<<4)-1,int(round(v["x"]*16.0))))
sy12_4=max(0,min((H<<4)-1,int(round(v["y"]*16.0))))
zval = (max(0,min(0xFFFFFF,int(round(v.get("z",0.0))))) if AUTHZ else 0x0000_5000) # authentic 24-bit XYZ2 Z, else flat
rgba=v.get("rgba",0xFFFFFFFF) if AUTH_COLOR_TFX else 0
# GS per-vertex FOG — when this draw is FGE (and --auth-fog on), pack the
# commit word as XYZF2: same X/Y/24-bit-Z, with F=[63:56] the clamped
# per-vertex fog byte. The feeder tags it reg 0x04 (see EDIT 1). zval is
# already masked to 24 bits, so [63:56] is free for F and Z is unchanged.
fog_this = AUTH_FOG and fge
F = max(0,min(255,int(round(v.get("fog",255.0)))))
xyz = ((sx12_4 & 0xFFFF) | ((sy12_4 & 0xFFFF) << 16) | ((zval & 0xFFFFFF) << 32)) \
if SUBPIXEL_XY else bake.xyz2_dataz(sx,sy,zval)
if fog_this:
xyz = (xyz & 0x00FFFFFFFFFFFFFF) | ((F & 0xFF) << 56)
# rgbaq_with_q() is a legacy opaque helper and always writes A=0xff.
# Authentic MODULATE+TCC alpha passes depend on the captured vertex
# alpha, so pack the complete RGBA word when authentic color is
# enabled. Keep the legacy opaque-alpha contract for old fixtures.
rgbaq = (((q_fp & 0xFFFFFFFF) << 32) | (rgba & 0xFFFFFFFF)) \
if AUTH_COLOR_TFX else bake.rgbaq_with_q(0,0,0,q_fp&0xFFFFFFFF)
return [rgbaq,
bake.st_data(s_fp&0xFFFFFF,t_fp&0xFFFFFF), xyz]
def tris_of(d):
if d.get("forced_tris") is not None:
return d["forced_tris"]
out=[]
# Expand each captured topology into independent triangle records. The
# feeder reissues PRIM for every record, so FAN records must carry the
# real anchor vertex explicitly; treating a FAN as a sliding STRIP
# silently changes every triangle after the first.
for e_m in (d['members'] if d.get('members') else [d['e']]):
def fog_fold_rgba(v):
rgba=v["rgba"]
if not (AUTH_FOG_BLACK_FOLD and e_m["state"]["prim"].get("fge",0)):
return rgba
if (e_m["state"].get("fogcol", 0) & 0xFFFFFF) != 0:
sys.exit(f"[{CH}] FAIL: --auth-fog-black-fold requires FOGCOL=0, idx{e_m['first_idx']} has 0x{e_m['state']['fogcol']&0xFFFFFF:06x}")
fog=max(0,min(255,int(round(v.get("fog",255.0)))))
rgb=[(((rgba>>sh)&0xFF)*fog)>>8 for sh in (0,8,16)]
return rgb[0] | (rgb[1]<<8) | (rgb[2]<<16) | (rgba&0xFF000000)
fv=[dict(x=v["x"]-OX,y=v["y"]-OY,z=v["z"],fog=v.get("fog",255.0),
s=v["s"],t=v["t"],q=v["q"],rgba=fog_fold_rgba(v),kick=v.get("kick",True)) for v in e_m["verts"]]
prim_type=e_m["state"]["prim"]["type"]
if COALESCE_SOLID_SPRITES and prim_type==6 and not e_m["state"]["prim"]["tme"]:
fv=coalesce_solid_sprite_grid(fv)
if HALF_OPEN_SPRITES and prim_type==6:
fv=half_open_sprite_rects(fv)
raw=topology_tris(fv,prim_type, honor_kick=not LEGACY_STRIP_KICKS)
clip_w=FBPXW; clip_h=H
if AUTH_SCISSOR_CLIP:
sc=e_m["state"].get("scissor")
if sc is None: sys.exit(f"[{CH}] FAIL: idx{d['idx']} missing authentic SCISSOR state")
sx0=sc&0x7FF; sx1=(sc>>16)&0x7FF; sy0=(sc>>32)&0x7FF; sy1=(sc>>48)&0x7FF
if sx0!=0 or sy0!=0: sys.exit(f"[{CH}] FAIL: nonzero SCISSOR origin ({sx0},{sy0}) not yet modelled")
clip_w=min(clip_w,sx1+1); clip_h=min(clip_h,sy1+1)
for t in raw:
for ct in clip_rect_z(t, clip_w, clip_h): # z-aware clip carries authentic interpolants
qs=[v["q"] for v in ct]
if SKIP_MIXED_Q and not (all(q>0 for q in qs) or all(q<0 for q in qs)):
continue
out.append(ct)
return out
# Clip an already framebuffer-clipped triangle into a horizontal strip.
# Strip boundaries are integral pixel coordinates, while coverage samples
# are at pixel centers, so adjacent strips cannot double-shade a sample.
# All interpolants (including authentic Z and vertex color) are carried at
# the new vertices exactly as in the framebuffer guard-band clip above.
def clip_y_band_z(tri, lo, hi):
def lerp(p1,p2,al):
out={k:(p1[k]+al*(p2[k]-p1[k])) for k in ("x","y","z","fog","s","t","q")}
c=[]
for sh in (0,8,16,24):
av=(p1["rgba"]>>sh)&0xFF; bv=(p2["rgba"]>>sh)&0xFF
c.append(max(0,min(255,int(round(av+al*(bv-av))))))
out["rgba"]=c[0]|(c[1]<<8)|(c[2]<<16)|(c[3]<<24)
return out
def clip_edge(poly, inside, isect):
out=[]; n=len(poly)
for i in range(n):
A=poly[i]; B=poly[(i+1)%n]; ina=inside(A); inb=inside(B)
if ina: out.append(A)
if ina!=inb: out.append(isect(A,B))
return out
poly=list(tri)
poly=clip_edge(poly, lambda p:p["y"]>=lo,
lambda A,B:lerp(A,B,(lo-A["y"])/(B["y"]-A["y"])))
if not poly: return []
poly=clip_edge(poly, lambda p:p["y"]<=hi,
lambda A,B:lerp(A,B,(hi-A["y"])/(B["y"]-A["y"])))
if len(poly)<3: return []
return [(poly[0],poly[k],poly[k+1]) for k in range(1,len(poly)-1)]
def center_coverage(tri, xy_mode):
# Match pack_v exactly at the right/bottom framebuffer edges. The
# geometric clip uses the conventional exclusive W/H boundary, while
# XYZ2 stores the last addressable pixel (W-1/H-1).
if SUBPIXEL_XY:
# Capacity scheduling must use the same native 12.4 geometry as
# the enabled raster coverage path. Integer-quantizing here can
# collapse thin but visible triangles and silently discard them
# as zero-coverage pieces before they ever reach the RTL.
pts=[(max(0.0,min(FBPXW-(1.0/16.0),round(v["x"]*16.0)/16.0)),
max(0.0,min(H-(1.0/16.0),round(v["y"]*16.0)/16.0))) for v in tri]
else:
pts=[(max(0,min(FBPXW-1,int(quant_xy(v["x"],xy_mode)))),
max(0,min(H-1,int(quant_xy(v["y"],xy_mode))))) for v in tri]
(x0,y0),(x1,y1),(x2,y2)=pts; ar=edge(x0,y0,x1,y1,x2,y2)
if not ar: return 0
inv=1.0/ar; count=0
for py in range(max(0,int(math.floor(min(y0,y1,y2)))),min(H-1,int(math.ceil(max(y0,y1,y2))))+1):
for px in range(max(0,int(math.floor(min(x0,x1,x2)))),min(FBPXW-1,int(math.ceil(max(x0,x1,x2))))+1):
cx=px+0.5; cy=py+0.5
w0=edge(x1,y1,x2,y2,cx,cy)*inv
w1=edge(x2,y2,x0,y0,cx,cy)*inv
if w0>=-0.001 and w1>=-0.001 and (1.0-w0-w1)>=-0.001: count+=1
return count
# Ch407: large guard-band fans can cover most of the framebuffer, while
# the production request FIFO holds 16K fragments. Split into 16-row bands
# and pack consecutive, non-overlapping pieces into ordered epochs whose
# measured center-sample coverage stays below the requested safety limit.
# This is a fixture scheduling transformation only; texture/state and GS
# primitive order remain unchanged.
if CAPACITY_EPOCH_PIXELS is not None:
split=[]; BAND_H=16; MAX_EPOCH_TRIS=(STG_WORDS-(8 if EMIT_CLAMP_HEADER else 7))//9
for d in descs:
xy_mode = XY_QUANT_LIST[d['k']] if XY_QUANT_LIST is not None else XY_QUANT_RAW.lower()
pieces=[]
for tri in tris_of(d):
for lo in range(0,H,BAND_H):
band_hi=min(H,lo+BAND_H)-(1.0/16.0 if HALF_OPEN_SPRITES and SUBPIXEL_XY else 0.0)
for piece in clip_y_band_z(tri,lo,band_hi):
cov=center_coverage(piece,xy_mode)
if cov:
if cov>CAPACITY_EPOCH_PIXELS:
sys.exit(f"[Ch407] FAIL: band piece coverage {cov} exceeds epoch cap {CAPACITY_EPOCH_PIXELS}")
pieces.append((piece,cov))
groups=[]; cur=[]; cur_cov=0
for piece,cov in pieces:
if cur and (cur_cov+cov>CAPACITY_EPOCH_PIXELS or len(cur)>=MAX_EPOCH_TRIS):
groups.append((cur,cur_cov)); cur=[]; cur_cov=0
cur.append(piece); cur_cov+=cov
if cur: groups.append((cur,cur_cov))
for gi,(group,cov) in enumerate(groups):
nd=dict(d); nd["forced_tris"]=group; nd["k"]=len(split)
nd["reuse"]=d.get("reuse",0) if gi==0 else 1
nd["ntris"]=len(group); nd["words"]=(8 if EMIT_CLAMP_HEADER else 7)+9*len(group)
nd["nneg"]=sum(1 for tri in group if all(v["q"]<0 for v in tri))
nd["capacity_px"]=cov; split.append(nd)
descs=split
print(f"[{CH}] --capacity-epochs {CAPACITY_EPOCH_PIXELS}: 16-row band split/ordered pack -> {len(descs)} epochs; max scheduled coverage={max(d['capacity_px'] for d in descs)}")
# Ch405 capacity contract: destination-alpha blending deliberately waits
# for each committed write so overlapping GS fragments observe exact
# destination order. Bound the unthrottled raster burst to one triangle
# per scene marker; a 16K request FIFO then covers the measured worst fan
# triangle (14,752 px) without hiding drops. Geometry/order are unchanged.
if TRI_EPOCHS:
split=[]
for d in descs:
for ti, tri in enumerate(tris_of(d)):
nd=dict(d)
nd["forced_tris"]=[tri]
nd["k"]=len(split)
nd["reuse"]=d.get("reuse",0) if ti==0 else 1
nd["ntris"]=1
nd["words"]=(8 if EMIT_CLAMP_HEADER else 7)+9
nd["nneg"]=1 if all(v["q"]<0 for v in tri) else 0
split.append(nd)
descs=split
print(f"[{CH}] --tri-epochs: split into {len(descs)} one-triangle bounded-burst epochs")
# Splitting intentionally drops source groups with no covered pixels.
# Rebase both the dense epoch index and the texture-residency chain from
# the descriptors that will actually be emitted. Otherwise the first
# surviving descriptor can inherit reuse=1 from an empty predecessor and
# point at a texture payload that does not exist in the output table.
prev_asset=None
for k,d in enumerate(descs):
d['k']=k
d['reuse']=int(prev_asset is not None and
prev_asset['idxw']==d['idxw'] and
prev_asset['clut']==d['clut'])
prev_asset=d
def wmem(name, words, banner):
with open(os.path.join(DATA,name),"w") as f:
f.write(f"// {banner}\n")
for x in words: f.write(f"{x&0xFFFFFFFF:08x}\n")
N=len(descs); FB_WORDS=FBPXW*H
# Reuse epochs point at the most recent fresh texture payload; palettes
# are content-deduplicated too. This keeps a 3K-epoch fixture to tens of
# megabytes instead of duplicating gigabytes of identical local assets.
last_asset_k=None; pal_asset={}
for d in descs:
if not d.get('reuse',0): last_asset_k=d['k']
if last_asset_k is None or descs[last_asset_k]['crc']!=d['crc']:
sys.exit(f"[{CH}] FAIL: epoch {d['k']} reuse chain has no matching fresh texture")
d['asset_k']=last_asset_k
pk=tuple(d['pal'])
if pk not in pal_asset: pal_asset[pk]=d['k']
d['pal_k']=pal_asset[pk]
# SYNTHETIC GATE (Codex, from Ch355): a positive-Q triangle and its ALL-NEGATED twin must canonicalize to
# BIT-IDENTICAL staging words (proves (-s)/(-q)=s/q exactness of the reciprocal canonicalization).
def _pack(tri):
out=[]
ctri=canon(*tri); pscale=1024 if (PSCALE_AUTO or PSCALE_LIST is not None) else choose_pscale(ctri)
for v in ctri: out+=vwords(v,pscale,"round")
return out
_tp=[dict(x=10,y=20,s=0.00030,t=0.00040,q=0.0020), dict(x=30,y=25,s=0.00050,t=0.00010,q=0.0030), dict(x=15,y=40,s=0.00020,t=0.00060,q=0.0025)]
_tn=[dict(x=v["x"],y=v["y"],s=-v["s"],t=-v["t"],q=-v["q"]) for v in _tp]
if _pack(_tp)!=_pack(_tn): sys.exit("[Ch356] FAIL: canonicalization not bit-exact (positive-Q tri vs its all-negated twin differ)")
print("[Ch356] canonicalization self-test PASS: positive-Q triangle and its all-negated twin pack BIT-IDENTICALLY")
# per-epoch: feeder list (canonicalized) + de-swizzled texture -> LPDDR + relocated CLUT bytes + de-gridded palette
for d in descs:
tris=tris_of(d)
scale_hist={}
scale_mode = PSCALE_LIST[d['k']] if PSCALE_LIST is not None else ("auto" if PSCALE_AUTO else str(PSCALE))
xy_mode = XY_QUANT_LIST[d['k']] if XY_QUANT_LIST is not None else XY_QUANT_RAW.lower()
# Preserve authentic PRIM state bits, but not its topology. tris_of()
# has already expanded every captured TRI_STRIP, TRI_FAN, and SPRITE
# into independent three-vertex records, and the feeder reissues PRIM
# before every such record. Advertising the captured topology here
# makes gs_stub assemble a 9-word triangle record as (for example) a
# two-vertex SPRITE plus a dangling vertex. The staged grammar is
# unconditionally TRIANGLE; TME/ABE/FST/CTXT remain authentic.
# Preserve authentic ALPHA_1, including ABE, for the LPDDR ROP.
# Opaque fixtures retain their historical source-over/tri defaults.
state=d['state']; p=state['prim']
# GS per-vertex FOG — carry PRIM.FGE (bit 5) so the feeder tags the
# vertex commit XYZF2 and gs_stub's ras_fge applies the fog blend. Only
# for captured-FGE draws with --auth-fog on; FGE=0 draws are unchanged.
fog_draw = AUTH_FOG and bool(p.get('fge',0))
fge_bit = (1<<5) if fog_draw else 0
prim_word=(3 | (p['tme']<<4) | fge_bit | (p['abe']<<6) | (p['fst']<<8) | (p['ctxt']<<9)) if ALLOW_ABE else (3 | (1<<4) | fge_bit)
alpha_word=state['alpha'] if ALLOW_ABE else bake.alpha_pack(0,1,0,1)
# Preserve architectural TEST/ZBUF for the external LPDDR ROP. Older
# fixtures could use placeholders because the de25 wrapper hardwired
# ZTE=1/ZMSK=0; Ch405 carries these bits per fragment so authentic
# read-only-Z overlays (ZMSK=1) can coexist with opaque Z writers.
# The external framebuffer is relocated to FBP=0/FBW=10/PSMCT32,
# but authentic byte-granular FRAME.FBMSK remains part of the draw.
# The RTL transports its four byte enables alongside each fragment.
frame_word=bake.frame_1_psmct32(FBW)
if ALLOW_ABE:
frame_word |= state['frame'] & 0xFFFFFFFF00000000
stg=[len(tris)|(1<<32), frame_word, alpha_word,
state['test'], state['zbuf'],
tex0_word(d['cbp_reloc'], state['tex0']['tfx'] if AUTH_COLOR_TFX else 1,
d.get('logical_tw',TW), d.get('logical_th',TH), d.get('tex_psm',0x13),
d.get('tex_tbw',8))]
if EMIT_CLAMP_HEADER:
stg[0] |= 1<<34
stg += [state['clamp'], prim_word]
else:
stg += [prim_word]
for tri in tris:
ctri=canon(*tri); pscale=choose_pscale(ctri,d['idxw'],d['pal'],scale_mode,xy_mode,d['state']['clamp'],
d.get('logical_tw',TW),d.get('logical_th',TH)); scale_hist[pscale]=scale_hist.get(pscale,0)+1
for v in ctri:
vw=vwords(v,pscale,xy_mode,d.get('logical_tw',TW),d.get('logical_th',TH),fge=fog_draw)
if AUTH_COLOR_TFX and ((vw[0]>>24)&0xFF) != ((v['rgba']>>24)&0xFF):
sys.exit(f"[{CH}] FAIL: epoch {d['k']} vertex alpha changed while packing RGBAQ")
stg += vw
if len(stg)>STG_WORDS: sys.exit(f"[Ch356] epoch {d['k']} staging {len(stg)} > {STG_WORDS}")
bake.write_feeder_stg_mem(f"feeder_{PFX}{d['k']}.mem", stg,
f"{CH} LOCAL epoch {d['k']} (idx{d['idx']}, TEX0 CBP={d['cbp_reloc']}) {len(tris)} tris. gitignored.", total=STG_WORDS)
if d['asset_k']==d['k']:
wmem(f"{PFX}{d['k']}_tex_lpddr.mem", d['idxw'], f"{CH} LOCAL epoch {d['k']} tex (idx{d['idx']}/tbp={d['tbp']}) LINEAR -> LPDDR. gitignored.")
wmem(f"{PFX}{d['k']}_idx.mem", d['idxw'], f"{CH} LOCAL epoch {d['k']} idx. gitignored.")
if d['pal_k']==d['k']:
wmem(f"{PFX}{d['k']}_pal.mem", [p&0xFFFFFFFF for p in d['pal']], f"{CH} LOCAL epoch {d['k']} de-gridded palette. gitignored.")
d['nwords']=len(stg)
d['scale_hist']=scale_hist
if (PSCALE_AUTO or PSCALE_LIST is not None) and (d['k']<10 or d['k']%100==0 or d['k']==N-1):
print(f"[{CH}] epoch {d['k']} per-triangle PSCALE hist {dict(sorted(scale_hist.items()))}")
# INDEPENDENT composed reference (dump order, paint order) + per-epoch refmaps + per-pixel OWNER (epoch idx).
# refmap: [31]cov [30]int [28]multi(>=2 epochs) [26:24]owner_epoch [17:9]tu [8:0]tv
print(f"[{CH}] independent reference sample={REF_SAMPLE} ref_xy_quant={int(REF_XY_QUANT)}")
refmap=[0]*FB_WORDS; refpix=[(0,0,0)]*FB_WORDS; nep=[0]*FB_WORDS; lastep=[-1]*FB_WORDS
emit_perep = N <= 128
perep=([[0]*FB_WORDS for _ in range(N)] if emit_perep else None)
for k,d in enumerate(descs):
idxlin=d['idxw']; palg=d['pal']
logical_tw=d.get('logical_tw',TW); logical_th=d.get('logical_th',TH)
tex_psm=d.get('tex_psm',0x13)
tex_stride=d.get('tex_tbw',8)*64
xy_mode = XY_QUANT_LIST[k] if XY_QUANT_LIST is not None else XY_QUANT_RAW.lower()
for (v0,v1,v2) in tris_of(d):
if REF_XY_QUANT:
v0=dict(v0, x=quant_xy(v0["x"],xy_mode), y=quant_xy(v0["y"],xy_mode))
v1=dict(v1, x=quant_xy(v1["x"],xy_mode), y=quant_xy(v1["y"],xy_mode))
v2=dict(v2, x=quant_xy(v2["x"],xy_mode), y=quant_xy(v2["y"],xy_mode))
x0,y0=v0["x"],v0["y"]; x1,y1=v1["x"],v1["y"]; x2,y2=v2["x"],v2["y"]; ar=edge(x0,y0,x1,y1,x2,y2)
if abs(ar)<1e-9: continue
inv=1.0/ar
minx=max(0,int(min(x0,x1,x2))); maxx=min(FBPXW-1,int(max(x0,x1,x2))+1)
miny=max(0,int(min(y0,y1,y2))); maxy=min(H-1,int(max(y0,y1,y2))+1)
for py in range(miny,maxy+1):
for px in range(minx,maxx+1):
cx,cy=px+0.5,py+0.5
w0=edge(x1,y1,x2,y2,cx,cy)*inv; w1=edge(x2,y2,x0,y0,cx,cy)*inv; w2=1.0-w0-w1
if w0<-0.001 or w1<-0.001 or w2<-0.001: continue
sx=float(px)+0.5 if REF_SAMPLE=="center" else float(px)
sy=float(py)+0.5 if REF_SAMPLE=="center" else float(py)
a0=edge(x1,y1,x2,y2,sx,sy)*inv; a1=edge(x2,y2,x0,y0,sx,sy)*inv; a2=1.0-a0-a1
S=a0*v0["s"]+a1*v1["s"]+a2*v2["s"]; T=a0*v0["t"]+a1*v1["t"]+a2*v2["t"]; Q=a0*v0["q"]+a1*v1["q"]+a2*v2["q"]
if abs(Q)<1e-12: continue
uf=(S/Q)*logical_tw; vf=(T/Q)*logical_th
tu,tv=(texel_uv(d['state']['clamp'], math.floor(uf), math.floor(vf), logical_tw, logical_th)
if d['state']['prim']['tme'] else (0,0))
o=py*FBPXW+px; mw=min(w0,w1,w2); interior=1 if mw>0.04 else 0
if d['state']['prim']['tme']:
col=tex_col(idxlin,palg,tu,tv,logical_tw,logical_th,tex_psm,tex_stride)
else:
vc=[]
for sh in (0,8,16):
c0=(v0['rgba']>>sh)&0xFF; c1=(v1['rgba']>>sh)&0xFF; c2=(v2['rgba']>>sh)&0xFF
vc.append(max(0,min(255,int(a0*c0+a1*c1+a2*c2))))
col=vc[0]|(vc[1]<<8)|(vc[2]<<16)
if REF_BILINEAR and d['state']['prim']['tme']:
u_next,v_next=texel_uv(d['state']['clamp'], math.floor(uf)+1, math.floor(vf)+1, logical_tw, logical_th)
c00=col
c10=tex_col(idxlin,palg,u_next,tv,logical_tw,logical_th,tex_psm,tex_stride)
c01=tex_col(idxlin,palg,tu,v_next,logical_tw,logical_th,tex_psm,tex_stride)
c11=tex_col(idxlin,palg,u_next,v_next,logical_tw,logical_th,tex_psm,tex_stride)
fu=uf-math.floor(uf); fv=vf-math.floor(vf)
chans=[]
for sh in (0,8,16):
a=((c00>>sh)&255)*(1.0-fu)+((c10>>sh)&255)*fu
b=((c01>>sh)&255)*(1.0-fu)+((c11>>sh)&255)*fu
chans.append(max(0,min(255,int(a*(1.0-fv)+b*fv))))
col=chans[0]|(chans[1]<<8)|(chans[2]<<16)
if AUTH_COLOR_TFX and d['state']['prim']['tme'] and d['state']['tex0']['tfx']==0:
vc=[]
for sh in (0,8,16):
c0=(v0['rgba']>>sh)&0xFF; c1=(v1['rgba']>>sh)&0xFF; c2=(v2['rgba']>>sh)&0xFF
vc.append(max(0,min(255,int(a0*c0+a1*c1+a2*c2))))
tc=[col&0xFF,(col>>8)&0xFF,(col>>16)&0xFF]
mc=[min(255,(tc[j]*vc[j])>>7) for j in range(3)]
col=mc[0]|(mc[1]<<8)|(mc[2]<<16)
if lastep[o]!=k: nep[o]+=1; lastep[o]=k # distinct epochs covering this pixel
refmap[o]=(1<<31)|(interior<<30)|((k&0x7)<<24)|((tu&0x1FF)<<9)|(tv&0x1FF) # LAST epoch wins (paint order)
if emit_perep:
perep[k][o]=(1<<31)|(interior<<30)|((tu&0x1FF)<<9)|(tv&0x1FF)
refpix[o]=(col&0xFF,(col>>8)&0xFF,(col>>16)&0xFF)
for o in range(FB_WORDS):
if nep[o]>=2: refmap[o]|=(1<<28) # multi-epoch (>=2 distinct) pixel
multi_px=sum(1 for o in range(FB_WORDS) if refmap[o]&(1<<28))
covered=sum(1 for w in refmap if w>>31)
print(f"[Ch356] independent composed reference: {covered} covered px, {multi_px} multi-epoch (>=2) px")
wmem(f"{PFX}_refmap.mem", refmap, f"{CH} LOCAL composed per-pixel cov|int|multi|owner_epoch|tu|tv reference. gitignored.")
if emit_perep:
for k in range(N): wmem(f"{PFX}{k}_refmap.mem", perep[k], f"{CH} LOCAL epoch {k} per-pixel reference. gitignored.")
else:
print(f"[{CH}] skipped {N} per-epoch full-frame refmaps; composed reference remains emitted")
# Bootlet preload is the legacy path. Runtime mode leaves the CLUT
# table empty and emits CLD=0 lists; the HPS scheduler stages each
# palette before its GO instead, removing the bootlet palette cap.
RAM_QWORDS=512; pay=[]; _seen_cbp=set()
for d in (() if RUNTIME_CLUT else (x for x in descs if not (x['cbp_reloc'] in _seen_cbp or _seen_cbp.add(x['cbp_reloc'])))):
clutw=[int.from_bytes(d['clut'][i*4:i*4+4],'little') for i in range(256)]
pay.append(bake.giftag(1,0,0,4,int('E'*4,16)))
pay.append(bake.aplusd(bake.R_BITBLTBUF, bake.bitbltbuf_pack(d['cbp_reloc'],1,0x00)))
pay.append(bake.aplusd(bake.R_TRXPOS, bake.trxpos_pack(0,0)))
pay.append(bake.aplusd(bake.R_TRXREG, bake.trxreg_pack(256,1)))
pay.append(bake.aplusd(bake.R_TRXDIR, bake.trxdir_pack(0)))
pay.append(bake.giftag(256//4,1,2,0,0))
for q in range(256//4):
word=0
for lane in range(4): word|=(clutw[q*4+lane]&0xFFFFFFFF)<<(32*lane)
pay.append(word)
qwc=len(pay); disp_hi=((H-1)<<12)|(FBPXW-1)
with open(os.path.join(DATA,f"payload_{PFX}.mem"),"w") as f:
f.write(f"// {CH} LOCAL {'runtime-CLUT empty' if RUNTIME_CLUT else f'{N}-CLUT preload'} payload (CBPs {[d['cbp_reloc'] for d in descs]}). gitignored. QWC={qwc}.\n")
for _ in range(16): f.write(f"{0:032x}\n")
for x in pay: f.write(f"{x&((1<<128)-1):032x}\n")
for _ in range(RAM_QWORDS-16-qwc): f.write(f"{0:032x}\n")
bake.write_bios_mem(f"bios_{PFX}.mem", bake.build_textured_demo_bootlet_disp(qwc, disp_hi, FBW),
f"{CH} LOCAL {N}-CLUT preload bootlet (QWC={qwc}, DISPLAY1={FBPXW}x{H}). gitignored.")
# params + epoch descriptor table (for the host + TB)
with open(os.path.join(DATA,f"{PFX}_params.vh"),"w") as f:
f.write(f"// {CH} LOCAL generated params for the scheduler integration TB. gitignored.\n")
for kk,vv in (("FBW",FBW),("FBPXW",FBPXW),("FBH",H),("VRAM_BYTES_P",VRAM_BYTES),("NEW_TBP",NEW_TBP),
("TEX_VRAM_BASE",TEX_VRAM_BASE),("TEX_BYTES",TW*TH),("N_BEATS",TW*TH//32),("STG_WORDS",STG_WORDS),
("TW",TW),("TH",TH),("N_EPOCHS",N),("UNION_OX",OX),("UNION_OY",OY)):
f.write(f"localparam int {kk:<14}= {vv};\n")
f.write(f"localparam [29:0] LPDDR_TEX_BASE = 30'h{LPDDR_TEX_BASE:07x};\n")
for d in descs:
f.write(f"localparam int EP{d['k']}_NTRIS = {d['ntris']};\n")
f.write(f"localparam int EP{d['k']}_CBP = {d['cbp_reloc']};\n")
f.write(f"localparam [31:0] EP{d['k']}_CRC = 32'h{d['crc']:08x};\n")
f.write(f"localparam bit EP{d['k']}_REUSE = 1'b{d.get('reuse',0)};\n")
# The shared testbenches include this after declaring their descriptor arrays. Keeping the assignments as
# generated text avoids simulator-dependent function elaboration inside a loop while retaining one source TB.
with open(os.path.join(DATA, f"{PFX}_epoch_table.vh"), "w") as f:
f.write(f"// {CH} LOCAL generated descriptor assignments for the shared-epoch TB. gitignored.\n")
for d in descs:
k = d['k']
f.write(f"EP_CRC[{k}] = EP{k}_CRC; EP_REC[{k}] = EP{k}_NTRIS; EP_REUSE[{k}] = EP{k}_REUSE;\n")
with open(os.path.join(DATA,f"{PFX}_epochs.txt"),"w") as f:
f.write(f"# {CH} epoch descriptor table ({N} epochs, dump order). CLUT={'runtime HPS staging' if RUNTIME_CLUT else 'bootlet preload'};\n")
f.write(f"# per epoch: upload tex -> LPDDR 0x{LPDDR_TEX_BASE:x}, fill+verify CRC, stream list, GO, fresh drain.\n")
f.write(f"# reuse=1 (Ch359): the texture cache is ALREADY resident from a prior epoch — the host must SKIP the\n")
f.write(f"# upload and fill, and instead VERIFY the resident CRC register still equals crc (fail-closed residency).\n")
f.write(f"META n_epochs {N} fbpxw {FBPXW} fbh {H} fbwords {FBPXW*H} lpddr_tex 0x{LPDDR_TEX_BASE:x} tex_words {TW*TH//4} n_beats {TW*TH//32}\n")
f.write(f"# k idx tbp cbp_reloc tex_file lpddr size crc list_file words records reuse pal_file pal_sum32\n")
for d in descs:
pal_file = f"{PFX}{d['pal_k']}_pal.mem" if RUNTIME_CLUT else "-"
pal_sum = sum(d['pal']) & 0xFFFFFFFF if RUNTIME_CLUT else 0
f.write(f"{d['k']} {d['idx']} {d['tbp']} {d['cbp_reloc']} {PFX}{d['asset_k']}_tex_lpddr.mem 0x{LPDDR_TEX_BASE:x} {d['size']} "
f"0x{d['crc']:08x} feeder_{PFX}{d['k']}.mem {d['nwords']} {d['ntris']} {d.get('reuse',0)} {pal_file} 0x{pal_sum:08x}\n")
try:
from PIL import Image
im=Image.new("RGB",(FBPXW,H)); im.putdata(refpix); im.save(os.path.join(ROOT,"captures","gs","silenthill3","extracted","recon",f"{PFX}_ref.png"))
print(f"[{CH}] wrote {PFX}_ref.png")
except Exception as ex: print("(PIL skipped:", ex, ")")
print(f"[{CH}] emitted {N}-epoch scheduler fixtures -> {DATA}. FB {FBPXW}x{H} stride {STRIDE}; epochs CRCs {[hex(d['crc']) for d in descs]}")
return 0
if __name__=="__main__":
raise SystemExit(main(sys.argv))