#!/usr/bin/env python3
"""retroDE_ps2 — Ch346 generic texture-residency preflight.

Stops the hand-chasing of stale-VRAM frames (Ch345b: 32 sprites bound TBP=13440, but the captured frame's
VRAM there held cube-checker residue, not the font). Before ANY repack/render/fit, this proves — for each
textured draw run in a dump — that the bound TEX0 maps to a REAL upload whose payload actually covers the
sampled footprint and looks like resident content, NOT stale/placeholder VRAM.

Generic (per Codex): the gate is RESIDENT + PLAUSIBLE content, not "font-like" — future targets may be
sprites, UI, backgrounds, or indexed textures. `--font-like` adds the glyph-specific extra check on top.

Checks (Codex's minimum set):
  1. active draw TEX0 maps to a known upload region (DBP/DPSM/stride), not just an address;
  2. upload EPOCH tracked — if a later upload overwrites that TBP, the candidate uses the latest payload;
  3. sampled footprint (UV bbox, REPEAT-wrapped into the TEX0 TW/TH) is COVERED by the uploaded rect;
  4. payload sanity — reject all-zero / single-color flat / flat-alpha-on-alpha-draw / known stale hashes;
  5. emit RANKED candidates with frame/event offsets, prim run, TEX0, upload source offset, PSM, dims,
     alpha stats, and WHY it passed/failed.
Repack/render tools refuse to run unless `residency_ok()` returns a PASS for the bound texture.

A checker is NOT auto-rejected — it can be legit authentic content (the Ch343 cube). The signal that killed
Ch345b is RESIDENCY (no upload to TBP=13440 at all), not "it's a checker".

Usage:  gs_texture_residency.py <dump.gs[.xz|.zst]> [--max-runs N] [--font-like] [--report r.txt] [--json j.json]
        gs_texture_residency.py <dump> --assert TBP[:PSM]   # exit 0 iff that TBP is resident+plausible
"""
import sys, os, json, hashlib
from collections import Counter
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import gs_parse

PSMCT32 = 0x00
MIN_DISTINCT_COLORS = 2          # <2 => flat fill (stale/cleared); fonts (B/W+transp=3) still pass
KNOWN_STALE_HASHES = set()       # sha256[:16] denylist of textures known to be placeholder/test residue

INDEXED_PSMS = (0x13, 0x14)   # PSMT8, PSMT4 — need a resident CLUT palette to render
def dec_tex0(v):
    return dict(tbp=v&0x3FFF, tbw=(v>>14)&0x3F, psm=(v>>20)&0x3F,
                tw=1<<((v>>26)&0xF), th=1<<((v>>30)&0xF), tcc=(v>>34)&1, tfx=(v>>35)&3,
                cbp=(v>>37)&0x3FFF, cpsm=(v>>51)&0xF, csm=(v>>55)&1,
                csa=(v>>56)&0x1F, cld=(v>>61)&7)
def dec_bitbltbuf(v):
    return dict(sbp=v&0x3FFF, sbw=(v>>16)&0x3F, spsm=(v>>24)&0x3F,
                dbp=(v>>32)&0x3FFF, dbw=(v>>48)&0x3F, dpsm=(v>>56)&0x3F)
def dec_trxreg(v):  return ((v & 0xFFF), ((v>>32) & 0xFFF))          # RRW, RRH
def dec_trxpos(v):  return (((v>>32)&0x7FF), ((v>>48)&0x7FF))        # DSAX, DSAY
def dec_trxdir(v):  return v & 3                                     # 0 = host->local (upload)

def payload_stats(blob, psm):
    """Decode an upload payload and summarize. Only PSMCT32 fully supported; others -> stats=None."""
    if psm != PSMCT32 or len(blob) < 4:
        return None
    n = len(blob)//4
    texels = [int.from_bytes(blob[i*4:i*4+4], "little") for i in range(n)]
    colors = Counter(texels)
    a = [(t>>24)&0xFF for t in texels]
    transp = sum(1 for x in a if x == 0); opaque = sum(1 for x in a if x >= 0x80)
    partial = n - transp - opaque
    h = hashlib.sha256(blob).hexdigest()[:16]
    return dict(texels=n, distinct=len(colors), top=colors.most_common(1)[0][1],
                a_transp=transp, a_opaque=opaque, a_partial=partial,
                flat_alpha=(len(set(a))==1), sha16=h,
                checkerish=_checkerish(texels))

def _checkerish(texels):
    """Heuristic structural flag (REPORTED, never an auto-reject — a checker can be legit content):
    a 2-color image whose value flips on a regular coarse grid. Returns block size or 0."""
    vals = set(texels)
    if len(vals) > 4: return 0
    n = len(texels); side = int(n**0.5)
    if side*side != n: return 0
    rows = [texels[r*side:(r+1)*side] for r in range(side)]
    # find the run length of the first row; a checker has long uniform runs that alternate
    first = rows[0]; run = 1
    while run < side and first[run] == first[0]: run += 1
    if run < 4 or run > side//2: return 0
    return run

def collect(path, max_runs):
    d = gs_parse.read_dump_bytes(path)
    h, events = gs_parse.parse_dump(path)
    uploads = []                 # ordered upload log
    epoch = {}                   # dbp -> count seen so far
    bbuf = trxreg = trxpos = None; trxdir = 3
    for e in events:
        if e.kind == "GSREG":
            if   e.reg == "BITBLTBUF": bbuf = dec_bitbltbuf(e.value)
            elif e.reg == "TRXREG":    trxreg = dec_trxreg(e.value)
            elif e.reg == "TRXPOS":    trxpos = dec_trxpos(e.value)
            elif e.reg == "TRXDIR":    trxdir = dec_trxdir(e.value)
        elif e.kind == "IMAGE" and bbuf is not None and trxdir == 0:    # host->local upload
            w, hh = trxreg if trxreg else (0,0); dx, dy = trxpos if trxpos else (0,0)
            dbp = bbuf["dbp"]; epoch[dbp] = epoch.get(dbp, 0) + 1
            nbytes = e.info.get("bytes", 0)
            uploads.append(dict(idx=e.idx, frame=e.frame, byte_off=e.byte_off, bytes=nbytes,
                                dbp=dbp, dbw=bbuf["dbw"], dpsm=bbuf["dpsm"], w=w, h=hh, dx=dx, dy=dy,
                                epoch=epoch[dbp], blob_range=(e.byte_off, e.byte_off+nbytes)))
    # draw runs: contiguous textured (TME) draws sharing one TEX0 base+psm; track sampled UV bbox
    runs = _draw_runs(events, max_runs)
    # VRAM snapshot: PCSX2 GS dumps freeze the full 4 MiB GS local memory at the END of the state blob
    # (register prefix first, then VRAM). Commercial games upload textures/CLUTs at scene-load — BEFORE the
    # dump — so they live here, not as in-stream upload events. This is the correct "resident" source.
    VRAM = 0x400000
    vstart = h.packet_start - 8192 - VRAM
    vram = d[vstart:vstart+VRAM] if 0 <= vstart and vstart + VRAM <= len(d) else None
    return d, h, events, uploads, runs, vram

def snapshot_present(vram, base, nb=512, min_nz=16):
    """Is there resident, non-flat content at this base pointer (256-byte units) in the VRAM snapshot?
    The snapshot is SWIZZLED, so this is a presence/plausibility check, not accurate content (de-swizzle
    is the translation step). Returns stats dict or None."""
    if vram is None: return None
    o = base * 256
    if o < 0 or o + nb > len(vram): return None
    chunk = vram[o:o+nb]
    nz = sum(1 for b in chunk if b); dist = len(set(chunk))
    return dict(nonzero=nz, distinct=dist) if (nz >= min_nz and dist >= 2) else None

def _draw_runs(events, max_runs):
    cur = {"type":None,"tme":0,"fst":0,"abe":0}; tex0 = {1:None,2:None}; ctxt = 0
    uvbuf = []; runs = []; run = None
    def close():
        nonlocal run
        if run and run["nprim"] > 0: runs.append(run)
        run = None
    for e in events:
        if e.kind != "GSREG": continue
        r, v = e.reg, e.value
        if r == "PRIM":
            cur = {"type":v&7,"tme":(v>>4)&1,"fst":(v>>8)&1,"abe":(v>>6)&1}; ctxt=(v>>9)&1; uvbuf=[]
        elif r == "TEX0_1": tex0[1]=dec_tex0(v)
        elif r == "TEX0_2": tex0[2]=dec_tex0(v)
        elif r == "UV": uvbuf.append((v&0x3FFF,(v>>14)&0x3FFF))
        elif r == "XYZ2":
            if not cur["tme"]: continue                       # only textured draws make residency runs
            t0 = tex0[1 if ctxt==0 else 2]
            if t0 is None: continue
            key = (t0["tbp"], t0["psm"], t0["tbw"])
            if run is None or run["key"] != key or run["last_idx_gap"] != e.idx-1:
                close()
                run = dict(key=key, tex0=t0, type=cur["type"], abe=cur["abe"], fst=cur["fst"],
                           first_idx=e.idx, frame=e.frame, nprim=0,
                           umin=1<<30,umax=-1,vmin=1<<30,vmax=-1, last_idx_gap=e.idx)
                if len(runs) >= max_runs and max_runs>0: break
            run["nprim"] += 1; run["last_idx"]=e.idx; run["last_idx_gap"]=e.idx
            for (u,vv) in uvbuf[-2:]:                          # sprite=2 verts; tri uses last 3 — bbox is fine
                tu=u>>4; tv=vv>>4
                run["umin"]=min(run["umin"],tu); run["umax"]=max(run["umax"],tu)
                run["vmin"]=min(run["vmin"],tv); run["vmax"]=max(run["vmax"],tv)
            uvbuf=[]
    close()
    return runs

def evaluate(run, uploads, d, vram=None):
    """Return verdict dict: resident (in-stream upload OR VRAM snapshot) + coverage + plausibility + CLUT."""
    t0 = run["tex0"]; tbp=t0["tbp"]; psm=t0["psm"]; tbw=t0["tbw"]
    # candidate uploads: same base + psm, occurring BEFORE the run's first draw; pick latest by idx
    cands = [u for u in uploads if u["dbp"]==tbp and u["dpsm"]==psm and u["idx"] < run["first_idx"]]
    reasons = []
    if not cands:
        # no in-stream upload — fall back to the VRAM snapshot (scene-load uploads live there)
        snap = snapshot_present(vram, tbp, nb=512)
        if snap is None:
            any_dbp = any(u["dbp"]==tbp for u in uploads)
            reasons.append("texture NOT resident: no in-stream upload to TBP and VRAM snapshot empty/absent"
                           if not any_dbp else "upload(s) to TBP exist but PSM mismatch / all after draw, and snapshot empty")
            return dict(verdict="REJECT", reasons=reasons, upload=None, coverage=None, stats=None, clut=None, tex_source=None)
        reasons.append(f"texture resident in VRAM SNAPSHOT @tbp={tbp} (nz={snap['nonzero']}/512 distinct={snap['distinct']}; swizzled — content via translation)")
        clut = _clut_residency(t0, uploads, run, vram, reasons)
        clut_ok = (psm not in INDEXED_PSMS) or (clut is not None and clut["resident"])
        return dict(verdict=("PASS" if clut_ok else "REJECT"), reasons=reasons or ["resident (snapshot)"],
                    upload=None, coverage=None, stats=None, clut=clut, tex_source="snapshot")
    up = max(cands, key=lambda u: u["idx"])
    # coverage: sampled footprint, REPEAT-wrapped into TW/TH, must fall inside the uploaded rect [dx..dx+w)x[dy..dy+h)
    tw, th = t0["tw"], t0["th"]
    foot_known = run["umax"] >= 0 and run["vmax"] >= 0   # UV captured (fst=1); fst=0 ST/Q not yet sampled
    if not foot_known:
        # honest: do NOT claim coverage we didn't verify. Verdict rests on residency + plausibility.
        reasons.append("footprint UNVERIFIED (ST/Q draw — UV not captured); coverage not asserted")
        inside = True; coverage = None
    else:
        wrap = (run["umax"]>=tw or run["vmax"]>=th or run["umin"]<0 or run["vmin"]<0)
        fmin_u = run["umin"] % tw if tw else run["umin"]; fmin_v = run["vmin"] % th if th else run["vmin"]
        fmax_u = (run["umax"] % tw if tw else run["umax"]); fmax_v=(run["vmax"] % th if th else run["vmax"])
        inside = (up["dx"] <= fmin_u and fmax_u < up["dx"]+up["w"] and
                  up["dy"] <= fmin_v and fmax_v < up["dy"]+up["h"]) if (up["w"] and up["h"]) else False
        coverage = 1.0 if inside else 0.0
        if wrap: reasons.append(f"footprint WRAPS texture ({run['umin']}..{run['umax']} x {run['vmin']}..{run['vmax']} vs {tw}x{th}); REPEAT declared")
        if not inside: reasons.append(f"sampled footprint NOT covered by upload rect ({up['dx']}..{up['dx']+up['w']} x {up['dy']}..{up['dy']+up['h']})")
    blob = d[up["blob_range"][0]:up["blob_range"][1]]
    stats = payload_stats(blob, psm)
    plausible = True
    if stats is None:
        reasons.append(f"payload plausibility UNSUPPORTED for PSM 0x{psm:02x} (fail-closed)"); plausible=False
    else:
        if in_known_stale(stats["sha16"]): reasons.append(f"payload is a KNOWN stale/test texture ({stats['sha16']})"); plausible=False
        if stats["distinct"] < MIN_DISTINCT_COLORS: reasons.append(f"payload flat ({stats['distinct']} color)"); plausible=False
        if run["abe"] and stats["flat_alpha"]: reasons.append("alpha draw but payload alpha is FLAT (no mask)"); plausible=False
        if stats["checkerish"]: reasons.append(f"payload is structurally checker-like (block~{stats['checkerish']}) — verify it's intended content")
    clut = _clut_residency(t0, uploads, run, vram, reasons)
    clut_ok = (psm not in INDEXED_PSMS) or (clut is not None and clut["resident"])
    verdict = "PASS" if (inside and plausible and clut_ok) else "REJECT"
    return dict(verdict=verdict, reasons=reasons or ["resident + plausible"], upload=up, coverage=coverage, stats=stats, clut=clut, tex_source="stream")

def _clut_residency(t0, uploads, run, vram, reasons):
    """Ch347 — indexed textures (PSMT8/PSMT4) need a resident CLUT at CBP (in-stream upload OR VRAM
    snapshot). The datapath proof is NOT authentic ingestion: the emitted TEX0's CBP/CPSM/CLD must select
    a CLUT that is actually loaded."""
    if t0["psm"] not in INDEXED_PSMS: return None
    cbp = t0["cbp"]
    ccands = [u for u in uploads if u["dbp"] == cbp and u["idx"] < run["first_idx"]]
    if ccands:
        cup = max(ccands, key=lambda u: u["idx"])
        reasons.append(f"CLUT resident (stream) @cbp={cbp} cpsm=0x{t0['cpsm']:02x} cld={t0['cld']} (upload idx{cup['idx']} f{cup['frame']})")
        clut = dict(resident=True, source="stream", upload_idx=cup["idx"], frame=cup["frame"], cbp=cbp, cpsm=t0["cpsm"], cld=t0["cld"], distinct=None)
    else:
        snap = snapshot_present(vram, cbp, nb=1024, min_nz=64)    # a real 256-entry palette is rich + non-flat
        if snap:
            reasons.append(f"CLUT resident (snapshot) @cbp={cbp} cpsm=0x{t0['cpsm']:02x} cld={t0['cld']} (nz={snap['nonzero']}/1024 distinct={snap['distinct']})")
            clut = dict(resident=True, source="snapshot", upload_idx=None, frame=None, cbp=cbp, cpsm=t0["cpsm"], cld=t0["cld"], distinct=snap["distinct"])
        else:
            reasons.append(f"CLUT NOT resident: no upload to CBP={cbp} and snapshot empty — indexed texture cannot render authentically")
            clut = dict(resident=False, source=None, upload_idx=None, frame=None, cbp=cbp, cpsm=t0["cpsm"], cld=t0["cld"], distinct=None)
    if t0["cld"] == 0:
        reasons.append("CLD=0: this TEX0 does not trigger a CLUT (re)load — palette would be whatever a prior load left")
    return clut

def in_known_stale(sha16): return sha16 in KNOWN_STALE_HASHES   # helper (denylist seeded empty by default)

def font_like(stats):
    """Glyph-specific EXTRA check (only when --font-like): mask-like alpha (real transparent + opaque
    regions), modest palette. NOT part of the generic gate."""
    if stats is None: return False, "no stats"
    if stats["a_transp"] == 0: return False, "no transparent texels (not a glyph mask)"
    if stats["a_opaque"] == 0: return False, "no opaque texels"
    frac_t = stats["a_transp"]/stats["texels"]
    if not (0.05 <= frac_t <= 0.95): return False, f"transparent fraction {frac_t:.2f} not mask-like"
    return True, f"mask-like (transp {frac_t:.2f})"

def residency_ok(path, tbp, psm=PSMCT32):
    """Programmatic gate for repack/render tools. True iff SOME textured run binding (tbp,psm) is PASS."""
    d, h, events, uploads, runs, vram = collect(path, 0)
    for run in runs:
        if run["tex0"]["tbp"]==tbp and run["tex0"]["psm"]==psm:
            if evaluate(run, uploads, d, vram)["verdict"]=="PASS": return True
    return False

def main(argv):
    if len(argv) < 2: print(__doc__); return 2
    path = argv[1]
    def has(f): return f in argv
    def opt(n,dv=None): return argv[argv.index(n)+1] if n in argv else dv
    d, h, events, uploads, runs, vram = collect(path, int(opt("--max-runs","0")))

    if has("--assert"):
        spec = opt("--assert"); tbp = int(spec.split(":")[0],0); psm=int(spec.split(":")[1],0) if ":" in spec else PSMCT32
        ok = residency_ok(path, tbp, psm)
        print(f"residency {'PASS' if ok else 'REJECT'} for TBP={tbp} PSM=0x{psm:02x}")
        return 0 if ok else 1

    R = [f"# Ch346 texture-residency preflight: {os.path.basename(path)}",
         f"uploads(host->local): {len(uploads)}   textured draw runs: {len(runs)}"]
    results = []
    for run in runs:
        ev = evaluate(run, uploads, d, vram); t0=run["tex0"]
        fl = font_like(ev["stats"]) if has("--font-like") else None
        verdict = ev["verdict"]
        if verdict=="PASS" and fl is not None and not fl[0]: verdict="REJECT"
        results.append(dict(run=run, ev=ev, font=fl, verdict=verdict))
    passes = [r for r in results if r["verdict"]=="PASS"]
    passes.sort(key=lambda r: (r["ev"]["stats"]["distinct"] if r["ev"]["stats"] else 0, r["run"]["nprim"]), reverse=True)

    R.append(f"\n== {len(passes)} PASS / {len(results)-len(passes)} REJECT ==")
    for i,r in enumerate(results):
        run=r["run"]; ev=r["ev"]; t0=run["tex0"]; up=ev["upload"]
        R.append(f"\n[{r['verdict']}] run f{run['frame']} idx{run['first_idx']}+{run['nprim']}prim  "
                 f"TEX0 tbp={t0['tbp']} tbw={t0['tbw']} psm=0x{t0['psm']:02x} {t0['tw']}x{t0['th']} abe={run['abe']} fst={run['fst']}")
        if run["umax"] >= 0: R.append(f"    footprint u[{run['umin']}..{run['umax']}] v[{run['vmin']}..{run['vmax']}]")
        else:                R.append(f"    footprint UNVERIFIED (fst={run['fst']} ST/Q draw)")
        if up: R.append(f"    upload  @byte0x{up['byte_off']:x} f{up['frame']} epoch{up['epoch']} dbp={up['dbp']} dpsm=0x{up['dpsm']:02x} {up['w']}x{up['h']} @({up['dx']},{up['dy']}) {up['bytes']}B")
        if ev["stats"]:
            s=ev["stats"]; R.append(f"    payload distinct={s['distinct']} alpha[t/o/p]={s['a_transp']}/{s['a_opaque']}/{s['a_partial']} flatA={s['flat_alpha']} checker~{s['checkerish']} sha={s['sha16']}")
        if ev.get("clut") is not None:
            c=ev["clut"]; R.append(f"    CLUT {'RESIDENT' if c['resident'] else 'MISSING'} cbp={c['cbp']} cpsm=0x{c['cpsm']:02x} cld={c['cld']}")
        if r["font"] is not None: R.append(f"    font-like: {r['font'][0]} ({r['font'][1]})")
        for why in ev["reasons"]: R.append(f"    -> {why}")
    if passes:
        R.append(f"\n== RANKED PASS candidates ==")
        for r in passes:
            run=r["run"]; t0=run["tex0"]
            s=r['ev']['stats']; c=r['ev'].get('clut')
            extra = f"distinct={s['distinct']}" if s else f"tex_src={r['ev'].get('tex_source')}"
            if c: extra += f" CLUT@cbp={c['cbp']}/{c['source']}"
            R.append(f"  tbp={t0['tbp']} psm=0x{t0['psm']:02x} {run['nprim']}prim {extra} f{run['frame']} idx{run['first_idx']}")
    report = "\n".join(R)
    print(report)
    if has("--report"): open(opt("--report"),"w").write(report+"\n")
    if has("--json"):
        j = [dict(verdict=r["verdict"], tbp=r["run"]["tex0"]["tbp"], psm=r["run"]["tex0"]["psm"],
                  frame=r["run"]["frame"], first_idx=r["run"]["first_idx"], nprim=r["run"]["nprim"],
                  stats=r["ev"]["stats"], clut=r["ev"].get("clut"), reasons=r["ev"]["reasons"]) for r in results]
        open(opt("--json"),"w").write(json.dumps(j, indent=1)+"\n")
    return 0 if passes else 1

if __name__ == "__main__":
    sys.exit(main(sys.argv))
