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

228 lines
11 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.
## ✅ 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".