Files
Sylpheed/docs/re/structures/ui-keyframe-rotation.md
Sylpheed RE agent 00b6259e9a re(ui): finish the top-level rotation census; record the JP-capture blocker
Two threads had converged on needing one capture this container cannot
take, so this iteration records that and finishes something reachable.

BLOCKED, written into MISSION.md rather than worked around: Q1's keyframe
time association and the rest() rule for plateau-less elements both now
hinge on a running capture of GP_TITLE build 7, the Japanese title. The
console language is not settable here -- user_language appears only as
DECLARE_int32 at four call sites with no DEFINE anywhere in the tree, and
it is absent from the registered cvars in xenia-canary.config.toml. There
is no flag to pass, and guessing one is specifically unsafe: run-canary's
own header records that xenia calls ShowSimpleMessageBox from
ParseLaunchArguments before logging starts, so a bad flag blocks forever
with an empty log. Rebuilding canary to add the cvar would be improvising
around the blocker; it needs a human decision. Neither question blocks
the five menu screens.

FINISHED: the disc-wide top-level rotation count, left running four
iterations ago as a shell loop over `screen info --geometry` that never
completed (it decodes every texture per build). Walking the placement
region directly takes seconds.

  top-level elements with a keyframe group   15 493
  carrying a non-zero rotation                2 152  (13.89 %)

Both controls pass: GP_TITLE build 4 reports 0 (its rotations are the
nested ptloop records) and GP_DIALOG build 0 reports the expected two.

The control earned its place -- the first version indexed the pak with a
`screen list` BUILD number and got 0 for a screen that has two, because
GP_DIALOG build 0 is entry 2. GP_TITLE maps 1:1, which is how the
assumption survived. METHOD line added.

Two free corroborations of the rotation decode. The rotated population is
dominated by tactical-map ship icons -- pbb_destroyer 444, pbr_destroyer
402, pbr_fighter 276 -- i.e. markers rotated to heading, the single
largest use of the field on the disc. And GP_TITLE entry 7's Japanese
wordmark pieces settle from ALTERNATING tilts:

  ptlogo3a  r = 0, -14, -4, -1, 0, ...
  ptlogo3b  r = 0, +14, +4, +1, 0, ...
  ptlogo3c  r = 0, -14, -4, -1, 0, ...

Same magnitudes, opposite signs, all decaying to upright. A misread field
does not produce that.
2026-08-28 23:46:54 +00:00

11 KiB
Raw Permalink 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.

The top-level census, finished (2026-08-28)

The earlier attempt drove sylpheed-cli screen info --geometry in a shell loop and never finished — it decodes every texture per build. tools/re-capture/rotation_toplevel_census.py walks the top-level placement region the way ui_layout.rs does; seconds instead of hours. Output: data/rotation-toplevel-census.txt.

Both controls pass — GP_TITLE build 4 reports 0 (its rotations are the nested ptloop records) and GP_DIALOG build 0 reports the 2 expected pceff03/pceff04 elements.

top-level elements with a keyframe group 15 493
carrying a non-zero rotation 2 152 (13.89 %)

By archive: GP_READY_ROOM 1 868, GP_DIALOG 40, each of the six GP_MAIN_GAME_*2D 37, GP_GAMEOVER 10, GP_DEBRIEFING_PILOTLOG 8, GP_TITLE 4.

Two more corroborations, both free

The rotated population is tactical-map ship icons. The most-rotated names are pbb_destroyer (444), pbr_destroyer (402), pbr_fighter (276), pbb_fighter (174), pbb_aircraft (168) — map markers, in the archive that holds the tactical map. Rotating a ship icon to its heading is what the field is for, and it is the single largest use of it on the disc.

GP_TITLE entry 7's four rotated elements carry their own control. Three of them are the Japanese wordmark's pieces, and they settle to upright from alternating tilts:

ptlogo3a.t32   r = 0, -14, -4, -1, 0, 0, 0, 0
ptlogo3b.t32   r = 0, +14, +4, +1, 0, 0, 0, 0
ptlogo3c.t32   r = 0, -14, -4, -1, 0, 0, 0, 0

Same magnitudes, alternating sign, all decaying to 0 — three sibling elements tilting opposite ways and straightening. A misread field does not produce that. (The fourth is ptlogo_eff3.t32, r = 0, 0, 80, 150 — the transient bloom discussed in ui-resting-pose.)

⚠️ This is not subtractable from the byte-scan census. That one counts blocks (12 164 of 83 862 with a non-zero +12); this one counts elements. The two are different units and the difference is not "the nested population".