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