#!/usr/bin/env python3
"""retroDE_ps2 — Ch354 Brick 1: MULTI-DRAW SH3 fixture.

Composite N authentic SH3 draws that SHARE one texture+CLUT into ONE LPDDR framebuffer, proving the FB path is
scene-capable (a real draw LIST, not one selected draw). ONE new variable vs Ch353: multiple authentic draws
accumulating into one FB. Explicitly NOT in Brick 1 (Codex): multi-texture residency, full 640x480, large Z-buffer.

Codex guardrails enforced (fail-CLOSED, before any integration sim):
  #1 report each draw's clipped tri count + total staging words; FAIL if > FEEDER_STG_WORDS.
  #2 prove ALL feeder-visible state matches: TEX0(all fields), FRAME, PRIM/FST/TME/ABE, TEST/Z, CLAMP, ALPHA, TEXA,
     texture dims/format.
  #3 texture/CLUT check is CONTENT-based (epoch-aware): a same-byte re-upload is fine; a changed payload FAILS.
  #4 draws justified MECHANICALLY (same frame, contiguous run of the same texture key), not visually.
  #5 (emit stage) the oracle is generated INDEPENDENTLY from reconstructed GS local memory, NOT from feeder records.
  #6 (emit stage) preserve dump order; explicitly SCORE the overlap regions (where accumulation is actually proven).

Usage: gs_make_sh3_multidraw_fixture.py [dump.gs.zst] --draw-list 89548,89761,89974 [--emit]
LOCAL/gitignored outputs (dump-derived). This tool NEVER touches the Ch353 single-draw fixture (byte-stable).
"""
import sys, os, glob
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_draw_census as C
import gs_sh3_recon as RC
import gs_texture_residency as R
sys.path.insert(0, DATA); import bake

# address map — identical to the Ch353 single-draw fixture (128 KiB VRAM / CBP=480 / texture in LPDDR, Codex option A)
CBP        = 0x1E000//256        # 480  CLUT base (grid bytes)
NEW_TBP    = 0x40000//256        # 1024 texture VRAM base (cache-intercepted)
TEX_BYTES  = 512*512             # 262144 PSMT8
STG_WORDS  = 2048                # FEEDER_STG_WORDS for the multi-draw PROFILE (Codex-approved; crop/Ch353 keep 768).
                                 # Fail-CLOSED gate: a combined list beyond this must raise the param again (board-BRAM),
                                 # never silently truncate. 16-bit tri count + 12-bit staging addr cover 2047 words.
FBW_MAX    = 10                  # 640 px hard ceiling for Brick 1 (no full-frame work); union wider than this = fail

def f32(bits):
    import struct; return struct.unpack("<f", struct.pack("<I", bits & 0xFFFFFFFF))[0]

def edge(ax,ay,bx,by,px,py): return (px-ax)*(by-ay)-(py-ay)*(bx-ax)

def load_draws(dump, want_idxs, collected=None, allow_untextured=False):
    """Walk the GS event stream once; for each selected draw (by first primitive idx) capture the FULL feeder-visible
    state latched at its first kick + its vertices. Mirrors gs_sh3_draw_census.census but captures ALL render state."""
    d, h, events, uploads, runs, vram = collected if collected is not None else R.collect(dump, 0)
    st = dict(PRIM=None, TEX0_1=None, TEX0_2=None, FRAME_1=None, FRAME_2=None, TEST_1=None, TEST_2=None,
              ZBUF_1=None, ZBUF_2=None, CLAMP_1=None, CLAMP_2=None, SCISSOR_1=None, SCISSOR_2=None,
              ALPHA_1=None, ALPHA_2=None, TEXA=None, FOGCOL=0)
    prim = dict(type=7, tme=0, fge=0, fst=0, abe=0, ctxt=0)
    ofx={1:0.0,2:0.0}; ofy={1:0.0,2:0.0}; cur_st=(0.0,0.0,1.0); cur_uv=(0.0,0.0); cur_rgba=0
    cur=None; cur_frame=0; want=set(want_idxs); got={}

    def ctx(): return 1 if prim["ctxt"]==0 else 2
    def texkey():
        t0=st["TEX0_1" if prim["ctxt"]==0 else "TEX0_2"]
        if t0 is None: return None
        return (t0["tbp"], t0["psm"], t0["tbw"], prim["type"], prim["tme"], prim["abe"])
    def snapshot_state():
        c=ctx()
        return dict(tex0=dict(st["TEX0_1" if c==1 else "TEX0_2"]), prim=dict(prim),
                    frame=st["FRAME_%d"%c], test=st["TEST_%d"%c], zbuf=st["ZBUF_%d"%c],
                    clamp=st["CLAMP_%d"%c], scissor=st["SCISSOR_%d"%c],
                    alpha=st["ALPHA_%d"%c], texa=st["TEXA"], fogcol=st["FOGCOL"], ctxt=c)
    def close():
        nonlocal cur
        if cur is not None and cur["nprim"]>=1 and cur["first_idx"] in want:
            got[cur["first_idx"]]=cur
        cur=None
    def open_draw(idx):
        nonlocal cur
        cur=dict(first_idx=idx, frame=cur_frame, key=texkey(), state=snapshot_state(), nprim=0, verts=[])

    for e in events:
        if e.kind=="FRAME_BOUNDARY": cur_frame=e.frame+1; continue
        if e.kind!="GSREG": continue
        cur_frame=e.frame; r,v=e.reg,e.value
        if   r=="PRIM": close(); prim=dict(type=v&7, tme=(v>>4)&1, fge=(v>>5)&1,
                                             fst=(v>>8)&1, abe=(v>>6)&1, ctxt=(v>>9)&1)
        elif r in ("TEX0_1","TEX0_2"): st[r]=R.dec_tex0(v)
        elif r=="XYOFFSET_1": ofx[1]=(v&0xFFFF)/16.0; ofy[1]=((v>>32)&0xFFFF)/16.0
        elif r=="XYOFFSET_2": ofx[2]=(v&0xFFFF)/16.0; ofy[2]=((v>>32)&0xFFFF)/16.0
        elif r in st: st[r]=v                        # FRAME/TEST/ZBUF/CLAMP/ALPHA/TEXA raw qwords (compared verbatim)
        elif r=="RGBAQ": cur_rgba=v&0xFFFFFFFF
        elif r=="ST":
            s=f32(v&0xFFFFFFFF); t=f32((v>>32)&0xFFFFFFFF); q=f32(e.info.get("q_stq",0x3F800000)); cur_st=(s,t,q)
        elif r=="UV": cur_uv=((v&0x3FFF)/16.0, ((v>>16)&0x3FFF)/16.0)
        elif r in ("XYZF2","XYZ2","XYZF3","XYZ3"):
            xf=v&0xFFFF; yf=(v>>16)&0xFFFF; c=ctx(); x=xf/16.0-ofx[c]; y=yf/16.0-ofy[c]
            is_xyzf = r in ("XYZF2", "XYZF3")
            z=(v>>32)&0xFFFFFF if is_xyzf else (v>>32)&0xFFFFFFFF
            fog=(v>>56)&0xFF if is_xyzf else 0xFF
            if (not prim["tme"] and not allow_untextured) or texkey() is None: continue
            if cur is None or cur["key"]!=texkey(): close(); open_draw(e.idx)
            t0=cur["state"]["tex0"]
            # S/T/Q are architecturally ignored when TME=0.  Canonicalize
            # them so untextured fixture generation never depends on stale
            # ST/UV registers or trips the perspective fixed-point gates.
            stq=((0.0,0.0,1.0) if not prim["tme"] else
                 ((cur_uv[0]/t0["tw"],cur_uv[1]/t0["th"],1.0) if prim["fst"] else cur_st))
            # XYZ2/XYZF2 normally perform a drawing kick, but PACKED XYZ2/F2
            # carries ADC in bit 111.  gs_parse exposes that as note="adc"
            # while retaining the XYZ2/F2 register name.  ADC and XYZ3/F3
            # both feed the primitive assembler without drawing the triangle
            # completed by this vertex.  Retain the vertex for strip
            # continuity, but mark it as non-kicking.
            cur["verts"].append(dict(x=x,y=y,z=z,fog=fog,s=stq[0],t=stq[1],q=stq[2],rgba=cur_rgba,
                                     kick=(r in ("XYZF2","XYZ2") and e.info.get("note") != "adc")))
            cur["nprim"]+=1
    close()
    return got, vram

def clip_rect(tri, W, H):
    def lerp(p1,p2,a): return {k:(p1[k]+a*(p2[k]-p1[k])) for k in ("x","y","s","t","q")}
    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"],s=v["s"],t=v["t"],q=v["q"]) 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"])))
    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"]<=H,  lambda a,b:lerp(a,b,(H-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 main(argv):
    a=argv[1:]; dump=a[0] if a and not a[0].startswith("--") else None
    if "--draw-list" not in a: sys.exit("need --draw-list idx,idx,...")
    idxs=[int(x) for x in a[a.index("--draw-list")+1].split(",")]
    tag = a[a.index("--tag")+1] if "--tag" in a else "multi"        # output-file suffix (diagnostic isolation runs)
    only = int(a[a.index("--only")+1]) if "--only" in a else None   # emit ONLY this draw's tris (keep full-set union)
    if dump is None:
        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"[Ch354] dump={os.path.basename(dump)}  draw-list={idxs}")

    got, vram = load_draws(dump, idxs)
    missing=[i for i in idxs if i not in got]
    if missing: sys.exit(f"[Ch354] FAIL: draw idx {missing} not found as textured draws")
    draws=[got[i] for i in idxs]                      # dump order = the order the user passed (verify below)

    # ---- Guardrail #4: MECHANICAL justification — same frame, contiguous run of ONE texture key ----
    frames={d["frame"] for d in draws}
    keys={d["key"] for d in draws}
    order_ok = idxs==sorted(idxs)
    print(f"[Ch354] frames={sorted(frames)}  texkeys={keys}  dump-order(ascending idx)={order_ok}")
    if len(frames)!=1: sys.exit(f"[Ch354] FAIL(#4): draws span multiple frames {sorted(frames)} — not one scene/run")
    if len(keys)!=1:   sys.exit(f"[Ch354] FAIL(#4): draws have different texture keys {keys} — not one shared texture")
    if not order_ok:   sys.exit(f"[Ch354] FAIL(#4): --draw-list not in ascending dump order {idxs}")

    # ---- Guardrail #2: ALL feeder-visible state must match across the selected draws ----
    ref=draws[0]["state"]; t0r=ref["tex0"]
    for fld in ("tbp","tbw","psm","tw","th","tcc","tfx","cbp","cpsm","cld"):
        vals={d["state"]["tex0"][fld] for d in draws}
        if len(vals)!=1: sys.exit(f"[Ch354] FAIL(#2): TEX0.{fld} differs across draws: {vals}")
    for fld in ("type","fst","tme","abe"):
        vals={d["state"]["prim"][fld] for d in draws}
        if len(vals)!=1: sys.exit(f"[Ch354] FAIL(#2): PRIM.{fld} differs: {vals}")
    for reg in ("frame","test","zbuf","clamp","alpha","texa"):
        vals={d["state"][reg] for d in draws}
        if len(vals)!=1: sys.exit(f"[Ch354] FAIL(#2): {reg.upper()} state differs across draws: {vals}")
    assert t0r["tw"]==512 and t0r["th"]==512 and t0r["psm"]==0x13, f"unexpected TEX0 {t0r}"
    print(f"[Ch354] #2 OK: all feeder-visible state identical — TEX0 tbp={t0r['tbp']} cbp={t0r['cbp']} psm=0x{t0r['psm']:02x} "
          f"{t0r['tw']}x{t0r['th']}; PRIM type={ref['prim']['type']} fst={ref['prim']['fst']} tme={ref['prim']['tme']} "
          f"abe={ref['prim']['abe']}; TEST/ZBUF/CLAMP/ALPHA/TEXA match")

    # ---- Guardrail #3: CONTENT-based (epoch-aware) texture + CLUT check. Reconstruct local memory to EACH draw and
    #      compare the texture bytes @tbp + CLUT bytes @cbp. Same-byte re-upload -> identical -> OK; changed -> FAIL. ----
    tbp=t0r["tbp"]; cbp=t0r["cbp"]; texref=None; clutref=None
    for d in draws:
        mem, *_ = RC.build_localmem_to(dump, d["first_idx"])
        if mem is None: sys.exit(f"[Ch354] FAIL(#3): VRAM snapshot absent at idx{d['first_idx']}")
        tb=bytes(mem.m[tbp*256 : tbp*256+TEX_BYTES]); cb=bytes(mem.m[cbp*256 : cbp*256+1024])
        if texref is None: texref=tb; clutref=cb; anchor=d["first_idx"]
        else:
            if tb!=texref:  sys.exit(f"[Ch354] FAIL(#3): texture @tbp={tbp} CHANGED between idx{anchor} and idx{d['first_idx']} "
                                     f"(payload differs — needs multi-texture residency, deferred past Brick 1)")
            if cb!=clutref: sys.exit(f"[Ch354] FAIL(#3): CLUT @cbp={cbp} CHANGED between idx{anchor} and idx{d['first_idx']}")
    print(f"[Ch354] #3 OK: texture @tbp={tbp} ({TEX_BYTES} B) + CLUT @cbp={cbp} (1024 B) byte-identical across all "
          f"{len(draws)} draws (content-compared; same-byte re-uploads are fine)")

    # ---- Guardrail #3: deterministic UNION bbox -> FB origin/size/stride ----
    OX=int(min(min(v["x"] for v in d["verts"]) for d in draws))
    OY=int(min(min(v["y"] for v in d["verts"]) for d in draws))
    UXMAX=max(max(v["x"] for v in d["verts"]) for d in draws)
    UYMAX=max(max(v["y"] for v in d["verts"]) for d in draws)
    W=int(UXMAX)-OX+1; H=int(UYMAX)-OY+1
    FBW=(W+63)//64; FBPXW=FBW*64
    if FBW>FBW_MAX: sys.exit(f"[Ch354] FAIL: union width {W}px (FBW={FBW}) exceeds Brick-1 ceiling {FBW_MAX*64}px")
    STRIDE=FBPXW*4
    print(f"[Ch354] #3 UNION bbox: origin=({OX},{OY}) size={W}x{H}px  -> FB {FBPXW}x{H} (FBW={FBW}) stride={STRIDE}B "
          f"= {FBPXW*H*4} B ({FBPXW*H*4//1024} KiB)")

    # ---- Guardrail #1: per-draw CLIPPED tri count + total staging words, fail-CLOSED on FEEDER_STG_WORDS ----
    HEADER_WORDS=7    # ntris|flag, FRAME, ALPHA, TEST, ZBUF, TEX0, PRIM
    total_tris=0; per=[]
    for d in draws:
        fv=[dict(x=v["x"]-OX, y=v["y"]-OY, s=v["s"], t=v["t"], q=v["q"]) for v in d["verts"]]
        raw=[(fv[i-2],fv[i-1],fv[i]) for i in range(2,len(fv))]          # TRI_STRIP -> triangles
        clipped=[]
        for tri in raw: clipped += clip_rect(tri, FBPXW, H)
        per.append((d["first_idx"], len(fv), len(raw), len(clipped))); total_tris+=len(clipped)
    words=HEADER_WORDS + total_tris*9
    print(f"[Ch354] #1 staging capacity (FEEDER_STG_WORDS={STG_WORDS}):")
    for (idx,nv,nraw,ncl) in per:
        print(f"           idx{idx}: {nv} verts -> {nraw} strip-tris -> {ncl} clipped-tris")
    print(f"           TOTAL {total_tris} clipped tris -> {words} staging words "
          f"({'OK' if words<=STG_WORDS else 'OVER by %d'%(words-STG_WORDS)})")
    if words>STG_WORDS:
        sys.exit(f"[Ch354] FAIL-CLOSED(#1): combined list {words} words > FEEDER_STG_WORDS={STG_WORDS}. "
                 f"Raise the PROFILE FEEDER_STG_WORDS (board-BRAM cost) to >= {words} (e.g. {1<<(words-1).bit_length()}) "
                 f"OR reduce the draw set. Do NOT discover this in integration sim.")

    print(f"[Ch354] all fail-closed gates PASS.")
    if "--emit" not in a:
        print("[Ch354] (validation only; pass --emit to generate the fixture)"); return 0

    # ================= --emit: combined feeder list + INDEPENDENT oracle (guardrails #5, #6) =================
    STG_WORDS_MULTI=2048                     # Codex-approved profile-only staging RAM (crop/Ch353 keep 768)
    PERSP_FRAC=bake.PERSP_FRAC; PSCALE=4096; TW=512; TH=512; TW_LOG=TH_LOG=9; TBW_TEX=8
    NEW_TBP=0x40000//256; TEX_VRAM_BASE=NEW_TBP*256; LPDDR_TEX_BASE=0x00200000; VRAM_BYTES=0x20000
    S24_MAX=(1<<23)-1
    def tex0_real(tbp2,cbp2):
        v=bake.tex0_pack(tbp2,TBW_TEX,psm=0x13,tw=TW_LOG,th=TH_LOG,tfx=1)
        v|=(cbp2&0x3FFF)<<37; v|=(0&0xF)<<51; v|=(0&0x1)<<55; v|=(0&0x1F)<<56; v|=(1&0x7)<<61
        return v
    def vert_words(v):
        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: sys.exit(f"[Ch354] ST overflow {s_fp},{t_fp} (lower PSCALE)")
        if abs(q_fp)>0x7FFFFFFF: sys.exit(f"[Ch354] Q overflow {q_fp}")
        sx=max(0,min(FBPXW-1,int(round(v["x"])))); sy=max(0,min(H-1,int(round(v["y"]))))
        return [bake.rgbaq_with_q(0,0,0,q_fp&0xFFFFFFFF), bake.st_data(s_fp&0xFFFFFF,t_fp&0xFFFFFF),
                bake.xyz2_dataz(sx,sy,0x0000_5000)]
    def draw_tris(d):
        fv=[dict(x=v["x"]-OX,y=v["y"]-OY,s=v["s"],t=v["t"],q=v["q"]) for v in d["verts"]]
        raw=[(fv[i-2],fv[i-1],fv[i]) for i in range(2,len(fv))]
        out=[]
        for tri in raw: out += clip_rect(tri, FBPXW, H)
        return out

    # shared texture/CLUT (tbp=9216) reconstructed ONCE — feeds BOTH the LPDDR upload AND the independent oracle
    mem0, *_ = RC.build_localmem_to(dump, draws[0]["first_idx"])
    idx = mem0.read_psmt8(tbp, t0r["tbw"], TW, TH)
    pal = RC.read_clut32(mem0, cbp, order="grid")
    clut_bytes = bytes(mem0.m[cbp*256 : cbp*256+1024])
    idx_words = [idx[i*4]|(idx[i*4+1]<<8)|(idx[i*4+2]<<16)|(idx[i*4+3]<<24) for i in range(TW*TH//4)]

    # render set: the FULL list, or a SINGLE draw (--only) for the isolation/composition diagnostic (same union frame)
    render_set = [got[only]] if only is not None else draws
    if only is not None and only not in got: sys.exit(f"[Ch354] --only {only} not in --draw-list")
    print(f"[Ch354] tag='{tag}' render_set={[d['first_idx'] for d in render_set]} (union from full {idxs})")
    # combined feeder list: ONE shared-state header + the render-set's clipped tris in DUMP ORDER (#6)
    all_tris=[]
    for d in render_set: all_tris += draw_tris(d)
    ntris=len(all_tris)
    stg=[ntris|(1<<32), bake.frame_1_psmct32(FBW), bake.alpha_pack(0,1,0,1), 0, bake.zbuf1_pack(0,zmsk=1),
         tex0_real(NEW_TBP,CBP), 3|(1<<4)]
    for (v0,v1,v2) in all_tris:
        for v in (v0,v1,v2): stg += vert_words(v)
    if len(stg)>STG_WORDS_MULTI: sys.exit(f"[Ch354] staging {len(stg)} > {STG_WORDS_MULTI}")
    max_addr=len(stg)-1
    print(f"[Ch354] combined feeder list: {ntris} tris -> {len(stg)} words (max staging addr {max_addr} < {STG_WORDS_MULTI}); records_emitted should == {ntris}")

    # INDEPENDENT oracle (#5): rasterize the RECONSTRUCTED geometry+texture in DUMP ORDER (paint-order, DECAL/no-Z,
    # matching the RTL flush order), per FB pixel — NOT parsed from feeder records. Track DISTINCT draw count per pixel
    # -> OVERLAP regions (#6). refmap word: [31]covered [30]interior [28]overlap [17:9]tu [8:0]tv.
    refmap=[0]*(FBPXW*H); refpix=[(0,0,0)]*(FBPXW*H); ndraw=[0]*(FBPXW*H); lastdraw=[-1]*(FBPXW*H)
    for di,d in enumerate(render_set):
        for (v0,v1,v2) in draw_tris(d):
            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
                    a0=edge(x1,y1,x2,y2,float(px),float(py))*inv; a1=edge(x2,y2,x0,y0,float(px),float(py))*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
                    tu=int((S/Q)*TW)%TW; tv=int((T/Q)*TH)%TH
                    if tu<0: tu+=TW
                    if tv<0: tv+=TH
                    o=py*FBPXW+px; mw=min(w0,w1,w2); interior=1 if mw>0.04 else 0
                    if lastdraw[o]!=di: ndraw[o]+=1; lastdraw[o]=di       # DISTINCT draws covering this pixel
                    refmap[o]=(1<<31)|(interior<<30)|((tu&0x1FF)<<9)|(tv&0x1FF)   # later draw overwrites (paint-order)
                    p=pal[idx[tv*TW+tu]&0xFF]; refpix[o]=(p&0xFF,(p>>8)&0xFF,(p>>16)&0xFF)
    overlap_px=0
    for o in range(FBPXW*H):
        if ndraw[o]>1: refmap[o]|=(1<<28); overlap_px+=1
    covered=sum(1 for w in refmap if w>>31)
    print(f"[Ch354] #5/#6 independent oracle: {covered} covered px, {overlap_px} OVERLAP px (>1 distinct draw) [refmap bit28]")
    if only is None and overlap_px==0:
        sys.exit("[Ch354] FAIL(#6): NO overlap pixels — the draws don't accumulate; multi-draw not proven")

    # OWNER map (Codex diagnostic): the FULL-set winning draw index per pixel, in DUMP ORDER (last covering draw wins).
    # Used to compose the isolated single-draw RTL framebuffers and compare to the combined RTL render.
    owner=[255]*(FBPXW*H)                                  # 255 = uncovered
    for di,d in enumerate(draws):                          # ALWAYS the full set (independent of --only)
        for (v0,v1,v2) in draw_tris(d):
            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
                    owner[py*FBPXW+px]=di                  # later draw overwrites -> final owner (paint order)

    # ---- emit LOCAL fixtures (tag 'multi'; NEVER touch the Ch353 sh3_real_* single-draw fixture) ----
    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")
    wmem(f"sh3_{tag}_idx.mem",       idx_words, "Ch354 LOCAL shared SH3 512x512 de-swizzled indices (4/word). gitignored.")
    wmem(f"sh3_{tag}_tex_lpddr.mem", idx_words, "Ch354 LOCAL shared SH3 512x512 LINEAR indices -> LPDDR (PSMT8_SWIZZLE=0). gitignored.")
    wmem(f"sh3_{tag}_clut.mem",  [int.from_bytes(clut_bytes[i*4:i*4+4],'little') for i in range(256)],
         "Ch354 LOCAL shared SH3 CSM1 CLUT (grid bytes @cbp) -> BRAM. gitignored.")
    wmem(f"sh3_{tag}_pal.mem",   [p & 0xFFFFFFFF for p in pal], "Ch354 LOCAL de-gridded palette pal[i] for the TB. gitignored.")
    wmem(f"sh3_{tag}_refmap.mem", refmap, "Ch354 LOCAL per-FB-pixel covered|interior|overlap|tu|tv oracle (render_set). gitignored.")
    wmem(f"sh3_{tag}_owner.mem", owner, "Ch354 LOCAL per-FB-pixel FULL-set winning draw index (dump order; 255=uncovered). gitignored.")
    bake.write_feeder_stg_mem(f"feeder_sh3_{tag}.mem", stg,
        f"Ch354 LOCAL multi-draw SH3 (render {[d['first_idx'] for d in render_set]} of {idxs}) feeder staging: {ntris}-tri list + TEX0(PSMT8,CSM1,DECAL). gitignored.",
        total=STG_WORDS_MULTI)                    # padded to 2048 (Codex)

    # setup bootlet: CSM1 CLUT 256x1 BITBLT -> CBP (identical pattern to Ch352; shared CLUT), DISPLAY1 = FBPXW x H
    clut_words_b=[int.from_bytes(clut_bytes[i*4:i*4+4],"little") for i in range(256)]
    RAM_QWORDS=512; pay=[]
    pay.append(bake.giftag(1,0,0,4,int('E'*4,16)))
    pay.append(bake.aplusd(bake.R_BITBLTBUF, bake.bitbltbuf_pack(CBP,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|=(clut_words_b[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_sh3_{tag}.mem"),"w") as f:
        f.write(f"// Ch354 LOCAL multi-draw setup payload (CSM1 CLUT -> CBP={CBP}). 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_sh3_{tag}.mem", bake.build_textured_demo_bootlet_disp(qwc, disp_hi, FBW),
        f"Ch354 LOCAL multi-draw setup bootlet (QWC={qwc}, DISPLAY1={FBPXW}x{H}). gitignored.")

    with open(os.path.join(DATA,f"sh3_{tag}_params.vh"),"w") as f:
        f.write("// Ch354 LOCAL generated params for the multi-draw integration TB. gitignored.\n")
        f.write(f"localparam int FBW           = {FBW};\n")
        f.write(f"localparam int FBPXW         = {FBPXW};\n")
        f.write(f"localparam int FBH           = {H};\n")
        f.write(f"localparam int VRAM_BYTES_P  = {VRAM_BYTES};\n")
        f.write(f"localparam int CLUT_CBP      = {CBP};\n")
        f.write(f"localparam int NEW_TBP       = {NEW_TBP};\n")
        f.write(f"localparam int TEX_VRAM_BASE = {TEX_VRAM_BASE};\n")
        f.write(f"localparam int TEX_BYTES     = {TW*TH};\n")
        f.write(f"localparam [29:0] LPDDR_TEX_BASE = 30'h{LPDDR_TEX_BASE:07x};\n")
        f.write(f"localparam int N_BEATS       = {TW*TH//32};\n")
        f.write(f"localparam int STG_WORDS     = {STG_WORDS_MULTI};\n")
        f.write(f"localparam int TW            = {TW};\n")
        f.write(f"localparam int TH            = {TH};\n")
        f.write(f"localparam int NDRAWS        = {len(draws)};\n")
        f.write(f"localparam int NTRIS         = {ntris};\n")
        f.write(f"localparam int UNION_OX      = {OX};\n")
        f.write(f"localparam int UNION_OY      = {OY};\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"sh3_{tag}_ref.png"))
        print(f"[Ch354] wrote sh3_{tag}_ref.png")
    except Exception as ex: print("(PIL skipped:", ex, ")")
    tex_sum=sum(idx_words)&0xFFFFFFFF; tex_xor=0
    for w in idx_words: tex_xor^=w
    print(f"[Ch354] emitted multi-draw fixtures -> {DATA}. FB {FBPXW}x{H} stride {FBPXW*4}B; feeder {len(stg)}w (pad {STG_WORDS_MULTI}); "
          f"tex sum32=0x{tex_sum:08x} xor32=0x{tex_xor:08x} @LPDDR 0x{LPDDR_TEX_BASE:07x}")
    return 0

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