Files
Sylpheed/docs/re/structures/ui-keyframe-rotation.md
Sylpheed RE agent ffd1a30ba7 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

5.7 KiB
Raw Blame History

The keyframe block's +12 is a screen-plane ROTATION, in degrees

Status: DECODED — field, disc-wide census, and confirmed against the framebuffer (a GPU draw capture), not against our own renderer. 🟡 +4 and +8 remain unexplained.

This closes the ⚠️ that stood on the Keyframe type: "they do not explain the title screen — every element of GP_TITLE build 4 has all three at zero, while the game demonstrably submits rotated quads there." That note was wrong about reach, not about the bytes it read.

The block

+0   u32  ARGB fade colour
+4   i32  🟡 unexplained
+8   i32  🟡 unexplained
+12  i32  ✅ rotation, DEGREES, clockwise-positive in screen space (Y down)
+16  u32  scale X, percent
+20  u32  scale Y, percent
+24  u32  tint
+28  i32  X   ← signed
+32  i32  Y   ← signed
+36  u32  time  (the last block of a group does not own this word)

How it was confirmed: two quads, two different angles, measured

The live title submits one draw of two rotated quads that our renderer does not reproduce (title-draw-capture-vertex-colours.log, draw 2). Converting its NDC vertices to screen space — X = (x+1)·640, Y = (1y)·360 — and taking the quads' edge lengths rather than their bounding boxes:

quad size rotation centre
A 400.1 × 1076.3 +30.26° (992.0, 359.1)
B 400.2 × 1444.5 45.28° (467.2, 360.0)

Both are true rectangles (adjacent edges perpendicular to < 0.6 %).

The two elements they belong to are the title's nested leaf records ptloop01.ratpteff03.t32 and ptloop02.ratpteff03a.t32. Their keyframe blocks, at 0xbb5966+0x68 and 0xbb5a82+0x68 in the decompressed build 4:

ptloop01 ptloop02
sprite pteff03.t32 399×180 pteff03a.t32 399×180
scale 100 %, 600 % → 399 × 1080 100 %, 800 % → 399 × 1440
+12 30 45
measured 400.1 × 1076.3 at +30.26° 400.2 × 1444.5 at 45.28°

Magnitude and sign, on two different values, from the GPU.

Why this is an identification and not a coincidence

The same capture was first attributed to a different element entirely (REFUTED.md), so the identification is carried by five independent agreements, not by elimination:

  1. The draw has exactly two quads; the screen declares exactly two ptloop elements.
  2. Both widths measure 400 against a declared sprite width of 399.
  3. The heights are different numbers that both land: 1076 ≈ 1080 (600 %) and 1444 ≈ 1440 (800 %). A coincidence would have to hit both.
  4. Between the capture's two frames quad A moves +19 px right and quad B moves left — matching each record's own decoded sweep direction (ptloop01 x: 639 → 1521, ptloop02 x: 1721 → 839).
  5. The drawn vertex alphas 0xC3 / 0xB6 both fall inside the declared 0x800xff alpha ramps.

A second corroboration, by ramp shape rather than by angle

GP_BUNK entry 117ca14f at 0x7395f3 holds a two-keyframe group in which +12 ramps 0 → 360 while position, scale and alpha all stay constant. A full-turn ramp that changes nothing else is a spin in place; no non-rotation reading of the field explains it. Disc-wide, 360 occurs 1 173 times and 360 180 times.

Disc-wide census

tools/re-capture/kf_rotation_census.py, output committed at data/kf-angle-census.txt. Over 83 862 keyframe blocks in the GP_*.pak UI archives:

word non-zero dominant values
+4 3 990 (4.76 %) 180 (3 880)
+8 3 878 (4.62 %) 180 (3 102), 90 (201), 180 (156)
+12 12 164 (14.50 %) 90, 90, 360, 120, 180, and a long tail of arbitrary angles (53, 58, 125, 114, 33, …)

+12's long tail of arbitrary values is itself part of the decode: +4 and +8 are almost entirely ±180/±90, which is the signature of a flip flag expressed in degrees rather than of a free angle. 🟡 That is a hypothesis about +4/+8, not a decode — nothing observed turns on them.

⚠️ Reach and limits

  • rotation_deg is decoded but NOT rendered. ui_layout's blitter draws axis-aligned quads only, so sylpheed-cli screen render still paints a rotated element upright. Rotating the blit is a real change to the compositor and was not attempted here. The title render's residual band is expected to persist until it is.
  • 🟡 Rotation appears to live only in nested .rat leaf records. Every instance located so far — the two ptloop records, the GP_BUNK spin — is nested, and screen info --geometry's new r= column is empty on every build of GP_TITLE, GP_BUNK and GP_CHALLENGE (the CLI lists the top-level declaration table only). A full sweep of every build on the disc has not finished, so this is a pattern across three archives, not a disc-wide negative. It is stated because it matters to a consumer either way: a composer that reads only the declaration table will see no rotation on the elements that actually rotate.
  • The blocks are not 4-byte aligned. A nested RATC blob can start at an odd offset (ptloop01.rat at 0xbb5966), and its blocks inherit that. Any scanner over raw bundle bytes must not assume alignment — see METHOD.md.
  • +4 and +8 are not claimed as X/Y rotation. Three adjacent signed degree-valued words invite that reading; only +12 has been observed to do anything.