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.
8.8 KiB
✅ 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 = (1−y)·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.rat → pteff03.t32 and ptloop02.rat → pteff03a.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:
- The draw has exactly two quads; the screen declares exactly two
ptloopelements. - Both widths measure 400 against a declared sprite width of 399.
- The heights are different numbers that both land: 1076 ≈ 1080 (600 %) and 1444 ≈ 1440 (800 %). A coincidence would have to hit both.
- 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
(
ptloop01x: −639 → 1521,ptloop02x: 1721 → −839). - The drawn vertex alphas
0xC3/0xB6both fall inside the declared0x80→0xffalpha 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·(scale−100)/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_degis decoded but NOT rendered.ui_layout's blitter draws axis-aligned quads only, sosylpheed-cli screen renderstill 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 nestedRefuted by my own sweep, within the hour. It held for.ratleaf records."GP_TITLE,GP_BUNKandGP_CHALLENGE— the three archives I had checked when I wrote it — and fails on the very next one:GP_DIALOGandGP_DEBRIEFING_PILOTLOGcarry 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
RATCblob can start at an odd offset (ptloop01.ratat0xbb5966), and its blocks inherit that. Any scanner over raw bundle bytes must not assume alignment — see METHOD.md. +4and+8are not claimed as X/Y rotation. Three adjacent signed degree-valued words invite that reading; only+12has been observed to do anything.