#!/usr/bin/env python3 """retroDE_ps2 — Ch340 GS-dump parser + GIF/GS decoder (Bricks 1-2). Deterministically decodes a PCSX2 .gs/.gs.xz/.gs.zst dump into a NORMALIZED, versioned event stream (container parse -> GIF tag walk -> GS register-write / IMAGE / transfer events). No hidden approximation: anything not understood is emitted as an explicit event (MALFORMED / unknown reg / IMAGE-not-inlined), never silently dropped or guessed. Container format: see memory/reference_pcsx2_gsdump_format.md (pinned from PCSX2 source + validated byte-exact against captures/gs/cubes/cubes_frame.gs.zst). This module is the parser/decoder ONLY. Census/histograms (Brick 3) and the ps2_feeder-scene translator (Brick 4) consume `parse_dump()`'s event stream. Raw IMAGE/transfer PAYLOADS are never inlined into committable output — only structural facts (sizes, formats, offsets). Usage: gs_parse.py [--summary] [--events N] [--json events.jsonl] """ import sys, struct, lzma, subprocess, shutil, json from dataclasses import dataclass, field, asdict SCHEMA_VERSION = 1 # ---- GS register address -> name (A+D + decoded-PACKED targets) ---- GS_REG = { 0x00:"PRIM",0x01:"RGBAQ",0x02:"ST",0x03:"UV",0x04:"XYZF2",0x05:"XYZ2",0x06:"TEX0_1",0x07:"TEX0_2", 0x08:"CLAMP_1",0x09:"CLAMP_2",0x0A:"FOG",0x0C:"XYZF3",0x0D:"XYZ3",0x14:"TEX1_1",0x15:"TEX1_2", 0x16:"TEX2_1",0x17:"TEX2_2",0x18:"XYOFFSET_1",0x19:"XYOFFSET_2",0x1A:"PRMODECONT",0x1B:"PRMODE", 0x1C:"TEXCLUT",0x22:"SCANMSK",0x34:"MIPTBP1_1",0x35:"MIPTBP1_2",0x36:"MIPTBP2_1",0x37:"MIPTBP2_2", 0x3B:"TEXA",0x3D:"FOGCOL",0x3F:"TEXFLUSH",0x40:"SCISSOR_1",0x41:"SCISSOR_2",0x42:"ALPHA_1", 0x43:"ALPHA_2",0x44:"DIMX",0x45:"DTHE",0x46:"COLCLAMP",0x47:"TEST_1",0x48:"TEST_2",0x49:"PABE", 0x4A:"FBA_1",0x4B:"FBA_2",0x4C:"FRAME_1",0x4D:"FRAME_2",0x4E:"ZBUF_1",0x4F:"ZBUF_2",0x50:"BITBLTBUF", 0x51:"TRXPOS",0x52:"TRXREG",0x53:"TRXDIR",0x54:"HWREG",0x60:"SIGNAL",0x61:"FINISH",0x62:"LABEL", } # PACKED descriptor (REGS nibble) -> ("how to decode", target GS reg addr) PACKED_PRIM,PACKED_RGBAQ,PACKED_ST,PACKED_UV = 0x0,0x1,0x2,0x3 PACKED_XYZF2,PACKED_XYZ2,PACKED_TEX0_1,PACKED_TEX0_2 = 0x4,0x5,0x6,0x7 PACKED_CLAMP1,PACKED_CLAMP2,PACKED_FOG = 0x8,0x9,0xA PACKED_XYZF3,PACKED_XYZ3,PACKED_AD,PACKED_NOP = 0xC,0xD,0xE,0xF GST = {0:"Transfer",1:"VSync",2:"ReadFIFO2",3:"Registers"} GSPATH = {0:"Path1Old",1:"Path2",2:"Path3",3:"Path1New",4:"Dummy"} @dataclass class Event: kind: str # GSREG | IMAGE | GIFTAG | FRAME_BOUNDARY | READFIFO | TRANSFER | MALFORMED frame: int idx: int byte_off: int # offset in the DECOMPRESSED .gs of the source byte reg: str = "" # for GSREG addr: int = -1 value: int = 0 info: dict = field(default_factory=dict) # ---------------------------------------------------------------- decompression def read_dump_bytes(path): if path.endswith(".gs.xz") or path.endswith(".xz"): return lzma.open(path, "rb").read() if path.endswith(".gs.zst") or path.endswith(".zst"): if shutil.which("zstd") is None: sys.exit("error: .zst dump but `zstd` not found on PATH") return subprocess.run(["zstd","-d","-c",path], capture_output=True, check=True).stdout return open(path,"rb").read() # ---------------------------------------------------------------- container parse @dataclass class Header: state_version:int; state_size:int; serial_offset:int; serial_size:int; crc:int ss_w:int; ss_h:int; ss_off:int; ss_size:int; header_size:int; serial:str; packet_start:int def parse_header(d): if len(d) < 12 or struct.unpack_from(" len(d): raise ValueError(f"packet_start 0x{h.packet_start:x} past EOF 0x{len(d):x}") return h # ---------------------------------------------------------------- GIF tag decode def _bits(q, lo, n): # extract n bits at lo from a 128-bit int return (q >> lo) & ((1 << n) - 1) def decode_packed_reg(desc, q): """Return (addr, value_64, note) for one PACKED register qword, or (None,0,note) to skip.""" if desc == PACKED_AD: return _bits(q,64,8), q & 0xFFFFFFFFFFFFFFFF, "" if desc == PACKED_NOP: return None, 0, "nop" if desc == PACKED_PRIM: return 0x00, _bits(q,0,11), "" if desc == PACKED_RGBAQ: r=_bits(q,0,8); g=_bits(q,32,8); b=_bits(q,64,8); a=_bits(q,96,8); Q=_bits(q,96,32) if False else _bits(q,96,32) # NOTE: Q float lives in [127:96]; we keep R/G/B/A (the 8-bit color the renderer uses). Qf=_bits(q,96,32) # not used for color return 0x01, (r | (g<<8) | (b<<16) | (a<<24)), "" if desc == PACKED_ST: return 0x02, (_bits(q,0,32) | (_bits(q,32,32)<<32)), "" # S,T floats (Q -> RGBAQ.Q) if desc == PACKED_UV: return 0x03, (_bits(q,0,14) | (_bits(q,16,14)<<14)), "" if desc == PACKED_XYZ2: return 0x05, (_bits(q,0,16) | (_bits(q,32,16)<<16) | (_bits(q,64,32)<<32)), ("adc" if _bits(q,111,1) else "") if desc == PACKED_XYZ3: return 0x0D, (_bits(q,0,16) | (_bits(q,32,16)<<16) | (_bits(q,64,32)<<32)), "" if desc == PACKED_XYZF2: return 0x04, (_bits(q,0,16) | (_bits(q,32,16)<<16) | (_bits(q,64,24)<<32) | (_bits(q,100,8)<<56)), ("adc" if _bits(q,111,1) else "") if desc == PACKED_XYZF3: return 0x0C, (_bits(q,0,16) | (_bits(q,32,16)<<16) | (_bits(q,64,24)<<32) | (_bits(q,100,8)<<56)), "" if desc in (PACKED_TEX0_1,PACKED_TEX0_2,PACKED_CLAMP1,PACKED_CLAMP2): addr = {PACKED_TEX0_1:0x06,PACKED_TEX0_2:0x07,PACKED_CLAMP1:0x08,PACKED_CLAMP2:0x09}[desc] return addr, q & 0xFFFFFFFFFFFFFFFF, "" if desc == PACKED_FOG: return 0x0A, (_bits(q,100,8) << 56), "" return None, 0, f"packed_desc_0x{desc:x}_unhandled" def walk_gif(data, base_off, frame, emit): """Walk the GIF tag chain in `data` (a Transfer payload). emit(Event).""" off = 0; n = len(data) while off + 16 <= n: q = int.from_bytes(data[off:off+16], "little") nloop=_bits(q,0,15); eop=_bits(q,15,1); pre=_bits(q,46,1); prim=_bits(q,47,11) flg=_bits(q,58,2); nreg=_bits(q,60,4); regs=_bits(q,64,64) nregs = nreg if nreg != 0 else 16 emit(Event("GIFTAG",frame,0,base_off+off,info=dict(nloop=nloop,eop=eop,pre=pre,prim=prim,flg=flg,nreg=nregs))) off += 16 if pre: emit(Event("GSREG",frame,0,base_off+off,reg="PRIM",addr=0x00,value=prim,info=dict(via="PRE"))) if flg == 0: # PACKED: nloop * nregs qwords descs = [(regs >> (4*k)) & 0xF for k in range(nregs)] need = nloop*nregs*16 for _ in range(nloop): for d in descs: if off+16 > n: break qq = int.from_bytes(data[off:off+16],"little") addr,val,note = decode_packed_reg(d, qq) if addr is not None: inf = {"note":note} if note else {} # Ch342 audit: PACKED ST also carries Q in lane2 [95:64] -> routed to RGBAQ.Q by # the GS (the STQ mechanism). PACKED RGBAQ carries NO Q; Q comes from ST. Expose # it so consumers reconstruct RGBAQ.Q consistently across PACKED/REGLIST/A+D. if addr == 0x02: inf["q_stq"] = _bits(qq, 64, 32) emit(Event("GSREG",frame,0,base_off+off,reg=GS_REG.get(addr,f"UNKNOWN_0x{addr:02x}"), addr=addr,value=val,info=inf)) elif note and note!="nop": emit(Event("MALFORMED",frame,0,base_off+off,info=dict(reason=note))) off += 16 off = (base_off+0) and off # keep off as is # if data ran short, account for it if need > n - (off): pass elif flg == 1: # REGLIST: nloop * nregs registers, 2 per qword (64-bit each), A+D-less descs = [(regs >> (4*k)) & 0xF for k in range(nregs)] total = nloop*nregs half = 0 cur = 0 for i in range(total): if half == 0: if off+16 > n: break qq = int.from_bytes(data[off:off+16],"little"); val = qq & 0xFFFFFFFFFFFFFFFF; cur=qq half = 1 else: val = (cur >> 64) & 0xFFFFFFFFFFFFFFFF; half = 0; off += 16 d = descs[i % nregs] if d == PACKED_NOP: continue addr = d if d in GS_REG else d emit(Event("GSREG",frame,0,base_off+off,reg=GS_REG.get(d,f"UNKNOWN_0x{d:02x}"),addr=d,value=val, info=dict(via="REGLIST"))) if half == 1: off += 16 elif flg == 2: # IMAGE: nloop qwords of raw data (texture / FB upload) — NOT inlined qbytes = nloop*16 emit(Event("IMAGE",frame,0,base_off+off,info=dict(qwc=nloop,bytes=qbytes))) off += qbytes else: # flg == 3 disabled emit(Event("GIFTAG",frame,0,base_off+off,info=dict(flg=3,note="disabled"))) if off > n: emit(Event("MALFORMED",frame,0,base_off+off,info=dict(reason="gif_payload_overrun"))) break # ---------------------------------------------------------------- packet stream def parse_dump(path): d = read_dump_bytes(path) h = parse_header(d) events = []; frame = 0 def emit(ev): ev.idx = len(events); events.append(ev) off = h.packet_start while off < len(d): tid = d[off]; pkt_off = off; off += 1 if tid == 0: # Transfer if off+5 > len(d): emit(Event("MALFORMED",frame,0,pkt_off,info=dict(reason="trunc_transfer_hdr"))); break path_id = d[off]; length = struct.unpack_from(" len(d): emit(Event("MALFORMED",frame,0,pkt_off,info=dict(reason="trunc_transfer_data",len=length))); break emit(Event("TRANSFER",frame,0,pkt_off,info=dict(path=GSPATH.get(path_id,path_id),length=length))) walk_gif(d[off:off+length], off, frame, emit) off += length elif tid == 1: # VSync (frame boundary) if off >= len(d): break emit(Event("FRAME_BOUNDARY",frame,0,pkt_off,info=dict(field=d[off]))); off += 1; frame += 1 elif tid == 2: # ReadFIFO2 if off+4 > len(d): break emit(Event("READFIFO",frame,0,pkt_off,info=dict(qwc=struct.unpack_from(" {outp}") return 0 if __name__ == "__main__": sys.exit(main(sys.argv))