#!/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))
