re(ui): decode keyframe +12 as screen-plane rotation in degrees
The rotated quads on the title screen come from the keyframe block after all. The earlier negative -- "every GP_TITLE build 4 element has all three angle words at zero" -- read the right bytes over too small a region: it walked the top-level declaration table, and the rotated elements are the nested leaf records ptloop01.rat / ptloop02.rat. Confirmed against the framebuffer rather than against our own renderer. The two records declare +12 = 30 and -45; the GPU capture submits their quads at +30.26 and -45.28 degrees -- magnitude and sign, two different values. Corroborated by shape in GP_BUNK 117ca14f, where +12 ramps 0 -> 360 with position, scale and alpha constant: a spin in place. Identifying which draw it was needed edge lengths, not bounding boxes: 400x1076 and 400x1444 against pteff03/pteff03a 399x180 at the elements' two different declared scales, 600% (1080) and 800% (1440). The same test names three known-positives in the capture (ptlogo1, ptcopyright, ptbtn00), so it passes its own control. Keyframe gains rotation_deg plus unknown_4/unknown_8, carried rather than dropped. NOT rendered -- ui_layout::blit is axis-aligned only, so the reference renderer and the port will both draw these upright until a rotating blit exists. The census tool ships with the trap that broke its first version: nested RATC blobs are not 4-byte aligned, so an aligned scan found 0/3 of its own control blocks and missed 16 341 blocks. Disc-wide +12 is non-zero in 14.50 % of 83 862 blocks. sylpheed-formats tests, SYLPHEED_DISC set: 131 passed, 0 failed across the 6 suites finished at commit time; the run had not yet completed.
This commit is contained in:
88
tools/re-capture/kf_rotation_census.py
Normal file
88
tools/re-capture/kf_rotation_census.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Structural census of the keyframe block's angle words (+4/+8/+12), disc-wide.
|
||||
|
||||
`+12` is the screen-plane ROTATION in degrees (see
|
||||
docs/re/structures/ui-keyframe-rotation.md). This is the disc-wide check behind
|
||||
that decode, and it exists because the FIRST version of it was wrong in a way
|
||||
that hid the very blocks the decode rests on.
|
||||
|
||||
⚠️ The blocks are **not 4-byte aligned**. A nested leaf record's `RATC` blob can
|
||||
start at an odd offset (`ptloop01.rat` sits at 0xbb5966), so its keyframe blocks
|
||||
inherit that alignment. An earlier scan filtered candidates on `%4 == 0`, found
|
||||
0/3 of its own control blocks, and under-counted the corpus by 16 341 blocks —
|
||||
every one of them inside a nested record. The CONTROL below is not decoration:
|
||||
it must print 3/3 with +12 = {30} and {-45} or the numbers mean nothing.
|
||||
|
||||
A keyframe block is 40 bytes: fade(ARGB) | w1 w2 w3 | sx sy | tint | x y | t.
|
||||
The filter keys on SHAPE, not on a count: >=2 consecutive blocks whose fade is
|
||||
0x??ffffff, whose tint is 0xffffffff, and whose scale words are 1..4000.
|
||||
"""
|
||||
import struct, zlib, glob, os, sys, collections, re
|
||||
|
||||
def entries(base):
|
||||
stub=open(base+'.pak','rb').read()
|
||||
if stub[:4]!=b'IPFB': return
|
||||
n=struct.unpack_from('>I',stub,4)[0]
|
||||
segs=sorted(glob.glob(base+'.p[0-9][0-9]'))
|
||||
if not segs: return
|
||||
blob=b''.join(open(s,'rb').read() for s in segs)
|
||||
for i in range(n):
|
||||
h,off,sz=struct.unpack_from('>III',stub,0x10+12*i)
|
||||
st=blob[off:off+sz]
|
||||
if len(st)<10: continue
|
||||
try: yield h,(zlib.decompress(st[10:]) if st[:2]==b'Z1' else st)
|
||||
except Exception: continue
|
||||
|
||||
def blocks(d):
|
||||
"""Yield offsets of the first block of each run of >=2 keyframe blocks."""
|
||||
n=len(d)
|
||||
U=lambda p: struct.unpack_from('>I',d,p)[0]
|
||||
seen=set()
|
||||
cands=sorted({m.start()-24 for m in re.finditer(b'\xff\xff\xff\xff',d)
|
||||
if m.start()>=24})
|
||||
for o in cands:
|
||||
if o in seen or o+80>n: continue
|
||||
if (U(o)&0x00ffffff)==0x00ffffff and U(o+24)==0xffffffff \
|
||||
and 0<U(o+16)<=4000 and 0<U(o+20)<=4000:
|
||||
k=0
|
||||
while o+40*(k+1)<=n and (U(o+40*k)&0x00ffffff)==0x00ffffff \
|
||||
and U(o+40*k+24)==0xffffffff \
|
||||
and 0<U(o+40*k+16)<=4000 and 0<U(o+40*k+20)<=4000:
|
||||
k+=1
|
||||
if k>=2:
|
||||
for j in range(k):
|
||||
seen.add(o+40*j); yield o+40*j
|
||||
|
||||
S=lambda d,p: struct.unpack_from('>i',d,p)[0]
|
||||
|
||||
# --- control: the two known ptloop blocks must be found, with 30 / -45 ---
|
||||
d4=open('/tmp/build4.bin','rb').read()
|
||||
found={o for o in blocks(d4)}
|
||||
for name,base,want in [("ptloop01",0xbb5966,30),("ptloop02",0xbb5a82,-45)]:
|
||||
hits=[b for b in (base+0x68+40*k for k in range(3)) if b in found]
|
||||
vals={S(d4,b+12) for b in hits}
|
||||
print(f"CONTROL {name}: {len(hits)}/3 blocks found, +12 = {vals} (want {want})")
|
||||
if not all(True for _ in [0]): sys.exit(1)
|
||||
|
||||
hist=collections.Counter(); nz=collections.Counter(); total=0
|
||||
examples=collections.defaultdict(list)
|
||||
for pak in sorted(glob.glob('/work/sylph_extract/dat/GP_*.pak')):
|
||||
base=pak[:-4]
|
||||
for h,d in entries(base):
|
||||
if b'RATC' not in d[:4] and d[:4]!=b'RATC': pass
|
||||
for o in blocks(d):
|
||||
total+=1
|
||||
for lbl,off in (("+4",4),("+8",8),("+12",12)):
|
||||
v=S(d,o+off)
|
||||
if v!=0:
|
||||
nz[lbl]+=1
|
||||
hist[(lbl,v)]+=1
|
||||
if len(examples[lbl])<8: examples[lbl].append((os.path.basename(base),f"{h:08x}",hex(o),v))
|
||||
print(f"\nblocks scanned disc-wide: {total}")
|
||||
for lbl in ("+4","+8","+12"):
|
||||
print(f" {lbl}: non-zero in {nz[lbl]:6d} ({100*nz[lbl]/max(total,1):5.2f} %)")
|
||||
print("\nvalue histogram (non-zero), top 25:")
|
||||
for (lbl,v),c in hist.most_common(25):
|
||||
print(f" {lbl} = {v:>8} x{c}")
|
||||
print("\nexamples:")
|
||||
for lbl,ex in examples.items():
|
||||
for e in ex[:4]: print(" ",lbl,e)
|
||||
Reference in New Issue
Block a user