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.
11 KiB
✅ 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
+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,
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), so the identification is carried by five independent agreements, not by elimination:
- The draw has exactly two quads; the screen declares exactly two
ptloopelements. - Both widths measure 400 against a declared sprite width of 399.
- The heights are different numbers that both land: 1076 ≈ 1080 (600 %) and 1444 ≈ 1440 (800 %). A coincidence would have to hit both.
- 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
(
ptloop01x: −639 → 1521,ptloop02x: 1721 → −839). - The drawn vertex alphas
0xC3/0xB6both fall inside the declared0x80→0xffalpha 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.
⚠️ 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,
output committed at 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_degis decoded but NOT rendered.ui_layout's blitter draws axis-aligned quads only, sosylpheed-cli screen renderstill 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 nestedRefuted by my own sweep, within the hour. It held for.ratleaf records."GP_TITLE,GP_BUNKandGP_CHALLENGE— the three archives I had checked when I wrote it — and fails on the very next one:GP_DIALOGandGP_DEBRIEFING_PILOTLOGcarry 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
RATCblob can start at an odd offset (ptloop01.ratat0xbb5966), and its blocks inherit that. Any scanner over raw bundle bytes must not assume alignment — see METHOD.md. +4and+8are not claimed as X/Y rotation. Three adjacent signed degree-valued words invite that reading; only+12has 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
walks the top-level placement region the way ui_layout.rs does; seconds instead
of hours. Output: 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.)
⚠️ 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".