Files
Sylpheed/tools/re-capture/kf_rotation_census.py
Sylpheed RE agent 67fa1a1b0b 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.
2026-08-28 22:42:00 +00:00

89 lines
3.8 KiB
Python

"""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)