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.
This commit is contained in:
Sylpheed RE agent
2026-08-28 22:41:32 +00:00
parent abe7477476
commit ffd1a30ba7
10 changed files with 430 additions and 42 deletions

View File

@@ -0,0 +1,120 @@
# ✅ 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 = (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.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.
## 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.** 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](../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.