Files
Sylpheed/docs/re/structures/ui-keyframe-rotation.md
Sylpheed RE agent f5097779e4 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

180 lines
8.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ✅ 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.
## ✅ 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·(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`](../../../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.