This repository has been archived on 2026-09-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Syplheed-Reborn/docs/re/structures/ui-keyframe-rotation.md
Sylpheed RE agent f11fde51e5 re(ui): rotation is not nested-only, and the pivot-anchored scale is measured
Two corrections and one new confirmation, all from finishing the sweep I
left running last iteration.

Refuted, by my own sweep, within the hour: "rotation appears to live only
in nested .rat leaf records". That held for GP_TITLE, GP_BUNK and
GP_CHALLENGE -- the three archives the sweep had reached when I wrote it
-- and fails on GP_DIALOG and GP_DEBRIEFING_PILOTLOG, which rotate
top-level elements. Scoping the claim made it cheap to withdraw, but the
sentence should have waited for the sweep.

Those top-level cases are the best evidence on the disc, and they show up
in `screen info --geometry` without a hex dump. GP_DIALOG build 0:
pceff03/pceff04 ramp r = 90 -> 30 -> 10 -> 3 -> 0 while alpha ramps
0 -> 255 and they slide into place -- a swing-in settling upright. Build
6: pzeff02 ramps 43 -> 61 -> 75 -> 90 while scaling 112% -> 200% and
fading to 0 -- a spin-out. Rows committed as reference data.

New: the pivot-anchored scale term in blit, kf.x - pivot*(scale-100)/100,
was implemented and reasoned about but never measured, because every
element previously examined sits at 100% scale where the term is exactly
zero. The ptloop pair scale 600% and 800%, where it is worth 450 and 630
px. Formula predicts centre y = 360.0 for both; capture measures 359.1
and 360.0. Top-left anchoring predicts 810 and 990. Horizontally it makes
t-from-position agree with t-from-alpha to 0.33 / 0.65 units against ~8
without it.

That agreement does NOT prove linear interpolation -- both fields were
inverted through the same linear map, so a shared easing curve cancels.
It shows position and alpha ride one shared parameter. Recorded as such.

The disc-wide sweep is still running; the count is incomplete, the
existence is settled.
2026-08-28 22:46:44 +00:00

8.8 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.

The strongest evidence is GP_DIALOG, and it is visible in the CLI

sylpheed-cli screen info --build 0 --geometry dat/GP_DIALOG.pak. These are top-level elements, so no hex dump is needed to see them:

element +12 across the group what else moves
pceff03.t32 90 → 30 → 10 → 3 → 0 alpha 0 → 128 → 192 → 224 → 255; position slides (630,348) → (470,198)
pceff04.t32 90 → 30 → 10 → 3 → 0 the same ramp, mirrored path (413,77) → (553,247)
pzeff02.t32 (build 6) 43 → 61 → 75 → 90 scale 112 % → 200 %, alpha 255 → 0

The first two are a swing-in that decays to zero exactly as the element settles; the third is a burst that spins as it expands and fades. A monotone decay to 0 synchronised with a fade-in, and a spin-out synchronised with an expanding fade-out, are what a rotation field does and are not what any other reading of the word would produce.

GP_DEBRIEFING_PILOTLOG build 10 holds the static case: pjeff24a.t32, one keyframe, r=90 at 210 % scale.

These four rows are committed verbatim at data/rotation-top-level-examples.txt. ⚠️ The sweep that found them — every build of every GP_*.pak, grepped for a non-zero r=had not finished at the time of writing; it was in GP_DIALOG build 37 with two archives hit so far. Treat the count as incomplete and the existence as settled.

Bonus, from the same two quads: the pivot-anchored scale is measured-correct

ui_layout::blit places a scaled element as ox = kf.x pivot·(scale100)/100, keeping the pivot fixed rather than the top-left. That was implemented and reasoned about, never measured — and until now there was nothing to measure it against, because the title's other elements are all at 100 % scale where the term vanishes. The ptloop pair scale 600 % and 800 % vertically, so the term is worth 450 and 630 px:

element measured centre Y pivot-anchored top-left position-as-centre
ptloop01 (600 %) 359.1 360.0 810.0 270.0
ptloop02 (800 %) 360.0 360.0 990.0 270.0

Two different scales, both landing on 360.0. Horizontally the same term is worth 199.5 px, and there is an independent cross-check available: solve the group's t from the quad's position, then from its vertex alpha, and see whether the two agree.

element t from position t from alpha Δ
ptloop01 with the pivot term 571.98 571.65 0.33
without 579.65 571.65 8.00
ptloop02 with the pivot term 668.92 668.27 0.65
without 659.71 668.27 8.55

🟡 What this does not show is that interpolation is linear. Both fields were inverted through the same linear map, so a shared easing curve would cancel out and still agree. What it does show is that position and alpha are driven off one shared parameter — which is worth knowing on its own, and is why the agreement is a real check on the placement formula rather than a tautology.

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." Refuted by my own sweep, within the hour. It held for GP_TITLE, GP_BUNK and GP_CHALLENGE — the three archives I had checked when I wrote it — and fails on the very next one: GP_DIALOG and GP_DEBRIEFING_PILOTLOG carry rotation on top-level elements, and those are the best evidence on the disc (below). Rotation lives in both places. The half that survives is the one that matters to a consumer: a composer that reads only the declaration table will miss the title's rotations, because those particular ones are nested.
  • 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.