# βœ… 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 ```text +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`](../captures/title-builds/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](../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 `0x80` β†’ `0xff` 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`](../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`](../../../tools/re-capture/kf_rotation_census.py), output committed at [`data/kf-angle-census.txt`](../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](../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`](../../../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`](../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](ui-resting-pose.md).) ⚠️ **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".