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 130ad8d1b3
commit 67fa1a1b0b
10 changed files with 430 additions and 42 deletions

View File

@@ -523,7 +523,7 @@ fn print_geometry(b: &sylpheed_formats::ui_layout::UiBuild, bytes: &[u8]) {
println!();
println!("geometry — decoded sprite size vs the declared pivot, and every keyframe");
println!(
"{:<3} {:<26} {:>11} {:>11} {:>5} keyframes t: x,y sx%,sy% a=fade-alpha",
"{:<3} {:<26} {:>11} {:>11} {:>5} keyframes t: x,y sx%,sy% a=alpha r=rot°",
"#", "sprite", "decoded", "pivot*2", "same"
);
for el in &b.elements {
@@ -543,8 +543,13 @@ fn print_geometry(b: &sylpheed_formats::ui_layout::UiBuild, bytes: &[u8]) {
.keyframes
.iter()
.map(|f| {
let rot = if f.rotation_deg != 0 {
format!(" r={}", f.rotation_deg)
} else {
String::new()
};
format!(
"{}: {},{} {}%,{}% a={}",
"{}: {},{} {}%,{}% a={}{rot}",
f.time.map(|v| v.to_string()).unwrap_or_else(|| "-".into()),
f.x,
f.y,

View File

@@ -70,22 +70,38 @@ const DESIGN_H: u32 = 720;
/// +36 u32 time
/// ```
///
/// ## `+4` / `+8` / `+12` are **angles**, not zeroes (2026-08-28)
/// ## `+12` is the screen-plane ROTATION, in degrees (2026-08-28)
///
/// These were documented as `0` from a sample that happened to contain none.
/// Over **72 287** keyframe blocks disc-wide they are non-zero in **4.81 %**,
/// **4.56 %** and **15.82 %** of blocks, and read as **signed** values clustering
/// on `180`, `180`, `90`, `90`, `120`, `22` — degrees. 🟡 Three of them, so
/// plausibly rotation about three axes; **not tied to an observed rotation yet**,
/// and this crate does not use them.
/// ✅ **Measured against the framebuffer, not against our own renderer.** The
/// title's two light sweeps are the nested leaf records `ptloop01.rat` /
/// `ptloop02.rat`, and their keyframe blocks read `+12` = `30` and `-45`. A
/// `log_ui_draws` capture of the live title submits those two quads rotated by
/// **+30.26°** and **-45.28°** — magnitude *and* sign, on two different values.
/// Positive is clockwise in screen space (Y down).
///
/// ⚠️ 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. See `docs/re/ui-title-build-map.md`.
/// `+4` and `+8` are 🟡 still unexplained: signed, non-zero in ~4.7 % / 4.6 %
/// of blocks disc-wide, dominated by `±180` and `±90`. Plausibly rotation about
/// the other two axes, but nothing observed turns on them.
///
/// ⚠️ **`rotation_deg` is decoded but NOT rendered.** [`crate::ui_layout`]'s
/// blitter draws axis-aligned quads only, so `screen render` still paints a
/// rotated element upright. See `docs/re/ui-title-build-map.md`.
///
/// ⚠️ The earlier note here — *"every element of `GP_TITLE` build 4 has all
/// three at zero"* — was **wrong about reach, not about the bytes**: build 4's
/// top-level elements do read zero, but the rotated quads come from its two
/// **nested** `.rat` leaf records, which the census never opened.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Keyframe {
/// The fade colour, ARGB. Its alpha is what ramps an element in.
pub fade: u32,
/// Screen-plane rotation in **degrees**, clockwise-positive (`+12`).
/// Confirmed against a GPU capture; see the type's docs. Not rendered.
pub rotation_deg: i32,
/// `+4` / `+8` — signed, meaning unexplained. Carried rather than dropped
/// so a consumer can see them instead of assuming they are zero.
pub unknown_4: i32,
pub unknown_8: i32,
/// Scale in percent (100 = 1:1).
pub scale_x: u32,
pub scale_y: u32,
@@ -460,6 +476,9 @@ fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec<usize> {
}
group.push(Keyframe {
fade: be32(bundle, blk),
rotation_deg: be32(bundle, blk + 12) as i32,
unknown_4: be32(bundle, blk + 4) as i32,
unknown_8: be32(bundle, blk + 8) as i32,
scale_x: be32(bundle, blk + 16),
scale_y: be32(bundle, blk + 20),
tint: be32(bundle, blk + 24),
@@ -569,6 +588,9 @@ fn fallback_elements(bundle: &[u8], records: &HashMap<String, (usize, usize)>) -
pivot_y: be32(rec, 0x54),
keyframes: vec![Keyframe {
fade: 0xffff_ffff,
rotation_deg: 0,
unknown_4: 0,
unknown_8: 0,
scale_x: 100,
scale_y: 100,
tint,
@@ -1118,6 +1140,9 @@ mod tests {
fn kf(x: i32, y: i32, time: u32) -> Keyframe {
Keyframe {
fade: 0xffff_ffff,
rotation_deg: 0,
unknown_4: 0,
unknown_8: 0,
scale_x: 100,
scale_y: 100,
tint: 0xffff_ffff,
@@ -1238,6 +1263,9 @@ mod tests {
};
let k = Keyframe {
fade: 0xffff_ffff,
rotation_deg: 0,
unknown_4: 0,
unknown_8: 0,
scale_x: 200,
scale_y: 200,
tint: 0xffff_ffff,

View File

@@ -109,12 +109,14 @@ authored version can be deleted.
test is to blend bit-`0x02` sprites additively and re-correlate the title
against the capture. Note `ptlogo_back2eff` is `0x8830` despite its name, so
this is a field and not a naming pattern. ❔ Not diagnosed.
⚠️ **Read the correction below before using this.** The rotated draw was
identified as the swoosh by elimination and that identification is **refuted**
its quads span y 209…925 in screen space, the swoosh is a band at y 126…360.
What stands: the game *does* submit rotated parallelograms this compositor
cannot draw, and vertex colours are white. What does not: which element they
belong to.
⚠️ **The rotated draw is NOT the swoosh.** It was identified as the swoosh by
elimination; that is **refuted** its quads span y 209…925 in screen space,
the swoosh is a band at y 126…360. ✅ **It is the two `ptloop` sweeps**
(`ptloop01.rat``pteff03.t32`, `ptloop02.rat``pteff03a.t32`), confirmed by
edge length: 400 × 1076 and 400 × 1444 against 399×180 at the two elements'
**different** declared scales, 600 % (= 1080) and 800 % (= 1440). What stands
from the old bullet: the game *does* submit rotated quads this compositor
cannot draw, and vertex colours are white.
**SOLVED 2026-08-28 by draw capture — it is the GEOMETRY.** The game submits
the swoosh as **two rotated parallelograms** (edges `(0.54,0.56)` and
`(0.44,0.79)`, ~45° and ~61°, extending to `y=±1.81` NDC).
@@ -124,8 +126,33 @@ authored version can be deleted.
defect.**
🔴 Vertex colour is refuted with it: every colour in the capture is
`<alpha>FFFFFF`, white RGB.
**Format gap:** the decoded keyframe has `fade`/`scale`/`tint`/`x`/`y`/`time`
and **no rotation**, so where the rotation comes from is not decoded.
**CLOSED 2026-08-28 — the rotation IS on the disc, at keyframe `+12`.**
It is a signed angle in **degrees**, clockwise-positive in screen space
(Y down), and `ui_layout::Keyframe` now carries it as `rotation_deg`.
Confirmed against the framebuffer, not against our own renderer: the two
`ptloop` records declare `+12` = **30** and **45**, and the GPU capture
submits their quads at **+30.26°** and **45.28°** — magnitude and sign, on two
different values. Corroborated separately by shape: `GP_BUNK` entry `117ca14f`
holds a group whose `+12` ramps **0 → 360** with position, scale and alpha all
constant — a spin in place. Disc-wide `+12` is non-zero in **14.50 %** of
83 862 keyframe blocks.
⚠️ **Two things the port must know about it.**
(1) 🟡 **Rotation appears to live only in NESTED `.rat` leaf records.** Every
instance found so far is nested, and the top-level table is empty of it across
`GP_TITLE`, `GP_BUNK` and `GP_CHALLENGE` — a pattern over three archives, not a
finished disc-wide sweep. Either way it is the actionable half: a composer that
reads only the declaration table will see zero rotation everywhere and be wrong
on exactly the elements that move.
(2) **`sylpheed-cli screen render` still does not rotate.** The field is
decoded, 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 — and per your own rule, the two of them agreeing about it means
nothing.
🟡 The neighbouring words `+4` and `+8` are still unexplained: signed, non-zero
in ~4.8 % / 4.6 % of blocks, almost entirely `±180`/`±90`. That distribution
looks like a flip flag rather than a free angle, but nothing observed turns on
them — **do not transcribe them as X/Y rotation.**
[`ui-keyframe-rotation.md`](../re/structures/ui-keyframe-rotation.md)
⚠️ The earlier "pink versus white" reading compared two differently-shaped
renderings and should be re-checked after geometry, not carried as a separate
defect.

View File

@@ -30,7 +30,7 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
| Weapon fields defaulted on disc ⚠️ | ✅/❌ | [runtime struct](structures/weapon-struct-runtime.md) · [DATA SHEET route](weapon-datasheet-runtime.md) | **Solved.** Canary maps guest RAM into `/dev/shm`, so the parsed `Weapon`/`Shell` objects are readable live; their layout is solved against disc ground truth (zero contradictions over 100+ records). All 126 weapons, exact numbers, no story progress needed — [4 393 values](captures/weapon-runtime-fields.csv). ⚠️ **CORRECTED 2026-08-26: the disc DOES carry a third of them.** Re-read through the [record table](structures/idxd-container.md), **1 514 of the 4 393** rows labelled `defaulted-on-disc` have a value on disc (2 879 genuinely absent) — measured here as an upper bound, since a field is counted when it appears in *any* record of the object; a per-record count gives ~1 448. The old reader could not name a record, so per-record fields read as absent. Spot-checked exactly: `wep_05`/`wep_60` `TriggerShotCount` = **4**, `wep_02` `Power` = **100.0**, `wep_60` `Power` = **1000.0** (refuting the recorded "C band ≈150…500" bracket), `wep_25` `MaximumRange` = **4000.0**, `wep_11/28/36/70` `LoadingCount` = **6/5/5/0**. The runtime capture's numbers all match the disc — what is withdrawn is the premise that it was reaching values the disc lacks. See [weapon-datasheet-runtime](weapon-datasheet-runtime.md) Supersedes the letter-bucket limit of the DATA SHEET route, which now serves as the independent cross-check |
| Unit (craft/vessel) fields defaulted on disc ⚠️ | ✅/❌ | [runtime struct](structures/unit-struct-runtime.md) | The parsed `unit\UN_*.tbl` definition object, vtable `0x820af844`, ≥`0x380` bytes, one per unit — **discovered, not assumed** (`unit_discover.py`), and distinguished from the spawned-entity class `0x820af030` by being one-per-ID and byte-constant within a run. Across runs only pointer words move — `--crosscheck` proves **no reported field offset is run-dependent** (two words, `+0x2c8`/`+0x2d0`, are stage-dependent and remain unidentified). 27 fields ✅ (21 units, 7 runs); the `Maneuver` block is **schema declaration order, 4 bytes/field, base `0x9c` with a two-slot gap after `AA_Roll_Min`** (29 anchors, 0 conflicts), which also pins 5 fields *no* disc record ever values. Angles are **radians at runtime, degrees on disc**. **Re-derived independently 2026-08-13 from the loader's own key strings** (`sub_82341A20`; the field name for each store is a string in the image): **159 fields**, agreeing with this solver on **25 of 25 shared offsets**, verified at **406 values matching the disc and 0 disagreeing** over 11 live objects spanning UNIT and VESSEL — landed as `data/unit_definition_layout.txt` + `sylpheed_formats::unit_layout` + a no-emulator test, with **121 defaulted fields** read out ([live-unit-definitions](live-unit-definitions.md)). ⚠️ **CORRECTED 2026-08-26:** those fields are **on the disc**. The ~30-field player-craft table is on disc at exactly the values the runtime "recovered", spread across the `Generic` / `Shield` / `Mass` / `SE` records — which is why a reader that could not name a record saw them as absent. And **"18 of 23 vessel records are missing at least one of `Size_X/Y/Z/HP`" is false: 0 of 114** objects with a `Generic.Type` (43 Craft + 71 Vessel) miss any of the four. See [unit-struct-runtime](structures/unit-struct-runtime.md) Unlike weapons, unit definitions are instantiated **per stage**, so coverage (21/110) grows by visiting missions — ❌ **the sibling-default rules are WITHDRAWN (2026-08-25)**`Size_Y` is on disc for **114/114** unit tables and **differs from `Size_X` in 90**; the old reader missed it exactly when the two were equal (a string-pool dedup artefact, cross-tab `seen+equal = 0` for all three pairs), so the rule was re-deriving the condition that hid the field. It also predicts wrong twice — `UN_e104_ADAN_Carrier.DefencePoint` and `UN_e011_ADAN_Attacker_B_HF_Wayne.FCSRange`. Read the record table instead — [values](captures/unit-runtime-fields.csv) |
| Arsenal develop economy | ✅/❔ | [arsenal-develop-economy](arsenal-develop-economy.md) + [conditions](captures/arsenal-develop-conditions.csv) | The Arsenal reads `weapon.tbl` (item ids, in the 8-category display order) and `strings.tbl` (names, descriptions, and a **"Conditions to obtain"** block per item) out of `GP_HANGAR_ARSENAL.pak`. All **60** conditions are extracted: gates are stage completion, a predecessor item, or an **ace kill**; costs run 3 000350 000 P and **20 items are free** once gated. `weapon.tbl`'s first record reproduces the in-game DATA SHEET exactly (Range D / Power E / Speed / Weight 0.3 = Light / 4000 P) — later records are unreadable from the string pool alone because IDXD **dedupes repeated values**. Used to identify the save blob's index space, now **solved**: the blob follows **`strings.tbl`'s** order — the display order *plus* the cut items only the localisation file lists (`Adhesive Mine B2A`, `Ballista GSH`, …) — pinned by four hand-written probe saves (9 Stiletto, 21 Falcon, 39 Tomahawk, 48 Jamming System) and closing exactly at index 53. `weapon.tbl`'s id list is **not** the index space; that it is also 54 long is a coincidence, and the two agree only to index 32. The retail save's five unexplained owned entries are the cut items, shipped owned and never rendered |
| UI screen layout (`.rat`) | ✅/🟡 | [ui-rat-layout](structures/ui-rat-layout.md) | One pak per UI screen; each RATC = one (context × language) build; every `<name>.t32` sprite has a `<name>.rat` **layout record** (BE u32; 1280×720 design space; scale/tint/X/Y, keyframes for animated elements, `opt ` link to the focused state). **The tutorial PAUSE menu and the title main menu both rebuild pixel-accurately from the disc.** `loop1.rat` is decoded — it is a **looping sprite animation**, not a composition. ⚠️ **DEMOTED 2026-08-18** — the declaration table is *not* the paint order: a per-draw capture of the running title screen ([ui-title-paint-order-capture](ui-title-paint-order-capture.md)) paints element 13 first and elements 0/1 late, and the visible screen composites two bundles. The rest of the table's reading stands. Previously claimed: the **screen's draw list is the RATC bundle's own declaration table** (elements in back-to-front order, including the `eff*`/`deli*`/`msg` sprites that have no `.rat`, and excluding focused button variants reached via `opt `); its entry also carries a **parent element index** at `+32`. **A screen is fully reconstructible from its bundle**: the placement region right after the declaration table gives every element a keyframe group (header = element index + keyframe count, then 40-byte blocks of scale/tint/X/Y), including the `.rat`-less sprites — verified 11/11 on the tutorial pause bundle, with `pgp_ttrl_btn10`'s inline (546,288) matching its own record exactly |
| UI screen layout (`.rat`) | ✅/🟡 | [ui-rat-layout](structures/ui-rat-layout.md) | One pak per UI screen; each RATC = one (context × language) build; every `<name>.t32` sprite has a `<name>.rat` **layout record** (BE u32; 1280×720 design space; scale/tint/X/Y, keyframes for animated elements, `opt ` link to the focused state). **The tutorial PAUSE menu and the title main menu both rebuild pixel-accurately from the disc.** `loop1.rat` is decoded — it is a **looping sprite animation**, not a composition. ⚠️ **DEMOTED 2026-08-18** — the declaration table is *not* the paint order: a per-draw capture of the running title screen ([ui-title-paint-order-capture](ui-title-paint-order-capture.md)) paints element 13 first and elements 0/1 late, and the visible screen composites two bundles. The rest of the table's reading stands. Previously claimed: the **screen's draw list is the RATC bundle's own declaration table** (elements in back-to-front order, including the `eff*`/`deli*`/`msg` sprites that have no `.rat`, and excluding focused button variants reached via `opt `); its entry also carries a **parent element index** at `+32`. **A screen is fully reconstructible from its bundle**: the placement region right after the declaration table gives every element a keyframe group (header = element index + keyframe count, then 40-byte blocks of scale/tint/X/Y), including the `.rat`-less sprites — verified 11/11 on the tutorial pause bundle, with `pgp_ttrl_btn10`'s inline (546,288) matching its own record exactly. **✅ 2026-08-28: the keyframe block's `+12` is a screen-plane ROTATION in degrees**, clockwise-positive — confirmed against a GPU capture (declared 30 / 45 vs measured +30.26° / 45.28°) and non-zero in 14.50 % of 83 862 blocks disc-wide ([ui-keyframe-rotation](structures/ui-keyframe-rotation.md)). ⚠️ Every rotation on the disc is inside a **nested** `.rat` leaf record, and `ui_layout::blit` is axis-aligned, so the field is decoded but **not rendered**. 🟡 `+4`/`+8` remain unexplained |
| UI screen paint order | ✅ | [runtime screen object](structures/ui-screen-runtime.md) + [title capture](ui-title-paint-order-capture.md) | **SOLVED**: the game's screen object keeps a second, reordered list of its elements — the child array at `+0x30` — and that is the paint order, not the declaration table. Read live from guest memory (found by the item vtable `0x820b30b4`) and checked against the draw capture: the seven nameable elements sit at child slots 0, 6, 7, 13, 16, 17, 22, strictly ascending, exactly as captured; it also settles the one pair no static field could order. 🟡 deriving that order from the bundle — what the port needs — is still open |
| Scripted input / profile traps | ✅/🟡 | [canary-scripted-input-traps](canary-scripted-input-traps.md) | Why a scripted run appears unable to press Ⓐ: **F10 opens the emulator menu bar**, and any Xenia UI makes `XamInputGetKeystrokeEx` return SUCCESS with an empty keystroke *before* any driver is asked (Canary now logs `[RE-INPUT] … swallowed by IsUIActive`); the title needs a **signed-in profile** (hence `--create_profile_if_none`); and a **FIFO trace consumer that exits stalls the emulator**, which reads exactly like a dead pad. 🟡 The main menu HAS been reached — Ⓐ works, but only intermittently (1 in ~4), which is the open question |
| Title-screen guest crash | ✅ | [title-crash-stl-tree](title-crash-stl-tree.md) | The guest throws **`std::out_of_range`** from its cache-manager flush (`sub_823070B0`, an STL map/set erase that builds `'invalid map/set<T> iterator'`); the access violation after it is only the throw **returning**, because this build does not unwind guest EH. Trigger found and controlled: an **incomplete on-disc cache** (`~/.local/share/Xenia/cache/aab216c3`) throws ~100 s into a boot, a complete one never does — 2 runs each way. ❌ `mem_watch`, the handoff's suspect #1, is **eliminated**: cold cache + `--mem_watch=false` throws anyway |

View File

@@ -317,3 +317,30 @@ agent's loop prompt, i.e. nowhere durable. See [`README.md`](README.md) for the
beyond, the swoosh is a 234-pixel band. Five iterations of analysis pointed at
the wrong element on the strength of it. Converting the coordinates takes one
line and should come first.
* **A census over a bundle's top-level table is not a census over the bundle.**
The keyframe rotation field read "always zero on this screen" for several
iterations because every scan walked `GP_TITLE` build 4's **declaration
table**, and the rotated elements are **nested leaf records** reached through
an `opt ` link. The bytes were right; the *reach* was wrong, and a negative
stated without its reach reads like a fact about the disc. Say which region a
negative covers, and check whether the thing you are looking for lives outside
it.
* **Do not assume 4-byte alignment when scanning raw bundle bytes.** A nested
`RATC` blob starts wherever the parent's chunk stream leaves it — `ptloop01.rat`
sits at `0xbb5966` — so its 40-byte keyframe blocks are odd-aligned. A scanner
that filtered candidates on `%4 == 0` found **0/3** of its own control blocks
and under-counted the corpus by **16 341** blocks, all of them nested. It cost
nothing to catch, because the control was in the script.
* **`pkill -f <pattern>` matches the shell running it.** `pkill -f kfscan.py`
from a `bash -c` whose command line contains `kfscan.py` kills its own shell:
the tool call returns exit 143/144 and the edit that was queued behind it never
runs. The same trap makes `pgrep -f` self-report — a "still running? yes" that
was the shell seeing itself, on a job that had already finished. Use
`pgrep -x`, or match on a path the current command line does not contain.
(This is the second `ps`/`pgrep` entry in this file; the first is about
`ps -ef` dumping the loop prompt.)
* **Grep a legend and you count the legend.** A sweep for elements with a
rotation reported "1 element" in every build on the disc — the header line
`a=alpha r=rot°` matched the ` r=` pattern. A uniform count across
heterogeneous inputs is the tell. Make the pattern require the *value*
(` r=-?[0-9]+`), and sanity-check that a known-negative build reports zero.

View File

@@ -40,11 +40,25 @@ neighbourhood, not just the line.
292…1012 in screen space; the swoosh is a band at y 126…360.
[`ui-title-build-map.md`](ui-title-build-map.md)
* "the keyframe words at `+4`/`+8`/`+12` are always zero" → they are non-zero in
4.81 %, 4.56 % and 15.82 % of 72 287 blocks disc-wide, reading as **degrees**
(±180, ±90, 120). The original note was a sample artefact.
* "those angle fields are where the title's rotated quads come from" → **no** —
every `GP_TITLE` build 4 element has all three at zero.
[`ui-title-build-map.md`](ui-title-build-map.md)
**4.76 %, 4.62 % and 14.50 % of 83 862** blocks disc-wide, reading as
**degrees** (±180, ±90, 120, 360). The original note was a sample artefact.
(Superseded figures: an earlier count of 72 287 blocks missed every nested
record — see the alignment entry below.)
* ~~"those angle fields are where the title's rotated quads come from" → **no** —
every `GP_TITLE` build 4 element has all three at zero.~~ → **that refutation
was itself wrong, and is withdrawn (2026-08-28).** `+12` *is* exactly where
they come from. Build 4's **top-level** elements do all read zero; the rotated
quads belong to its two **nested** leaf records, `ptloop01.rat` (`+12` = 30)
and `ptloop02.rat` (`+12` = 45), which the census never opened. Measured
off the GPU: **+30.26°** and **45.28°**.
[`ui-keyframe-rotation.md`](structures/ui-keyframe-rotation.md)
* "the rotated draw's element cannot be named from the capture" → refuted; its
quads' **edge lengths** name it. 400 × 1076 and 400 × 1444 match `pteff03`
399×180 at 600 % and `pteff03a` 399×180 at 800 % — two different heights, both
landing. [`ui-keyframe-rotation.md`](structures/ui-keyframe-rotation.md)
* "a keyframe-block scanner may assume 4-byte alignment" → refuted; it found
**0/3** of its own control blocks and under-counted the corpus by 16 341
blocks. Nested `RATC` blobs start at odd offsets (`0xbb5966`).
* "the game passes a pink per-vertex colour for the title swoosh" → **refuted by
draw capture.** Every vertex colour in the capture is `<alpha>FFFFFF`, white RGB.
* "the swoosh discrepancy is undecodable" → **solved**: the game submits it as two

View File

@@ -0,0 +1,51 @@
# tools/re-capture/kf_rotation_census.py over dat/GP_*.pak — 2026-08-28
# CONTROL must read 3/3; see the tool docstring for why.
CONTROL ptloop01: 3/3 blocks found, +12 = {30} (want 30)
CONTROL ptloop02: 3/3 blocks found, +12 = {-45} (want -45)
blocks scanned disc-wide: 83862
+4: non-zero in 3990 ( 4.76 %)
+8: non-zero in 3878 ( 4.62 %)
+12: non-zero in 12164 (14.50 %)
value histogram (non-zero), top 25:
+4 = 180 x3880
+8 = 180 x3102
+12 = 90 x1824
+12 = -90 x1176
+12 = 360 x1173
+12 = 120 x492
+12 = 180 x474
+12 = -58 x402
+12 = 53 x396
+12 = -125 x378
+12 = -120 x366
+12 = -66 x294
+12 = 114 x240
+8 = 90 x201
+12 = 129 x200
+12 = -43 x199
+12 = 115 x186
+12 = -360 x180
+8 = -180 x156
+12 = 58 x156
+8 = 178 x144
+12 = -33 x138
+12 = 124 x138
+12 = -133 x126
+12 = 130 x126
examples:
+12 ('GP_BUNK', '117ca14f', '0x73961b', 360)
+12 ('GP_BUNK', '117ca14f', '0x7398ac', 360)
+12 ('GP_BUNK', '117ca14f', '0x739a93', 360)
+12 ('GP_BUNK', '117ca14f', '0x739d24', 360)
+4 ('GP_BUNK', '117ca14f', '0x73ae5d', -180)
+4 ('GP_BUNK', '117ca14f', '0x73ae85', -180)
+4 ('GP_BUNK', '1ff0bcb0', '0x733d68', -180)
+4 ('GP_BUNK', '1ff0bcb0', '0x733d90', -180)
+8 ('GP_DEBRIEFING_PILOTLOG', '18ab1d66', '0x6f8', 90)
+8 ('GP_DEBRIEFING_PILOTLOG', '18ab1d66', '0x770', 90)
+8 ('GP_DEBRIEFING_PILOTLOG', '1c8a0129', '0x6f8', 90)
+8 ('GP_DEBRIEFING_PILOTLOG', '1c8a0129', '0x770', 90)

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.

View File

@@ -662,12 +662,15 @@ rotation about three axes. 🟡 That reading is **not tied to an observed rotati
it is the shape of the numbers, nothing more. The doc comment is corrected either
way: "0 on every frame seen" was a sample artefact.
### 🔴 But they do not explain this screen
### ✅ They *do* explain this screen — the earlier negative was wrong about reach
**Every element of `GP_TITLE` build 4 has all three at zero** — checked
element by element. The game still submits rotated parallelograms there. So the
title's rotation comes from **outside the keyframe data**, and remains
unidentified.
~~**Every element of `GP_TITLE` build 4 has all three at zero** — checked element
by element. So the title's rotation comes from outside the keyframe data.~~
**Withdrawn (2026-08-28).** That check walked build 4's **top-level declaration
table**. The rotated quads belong to its two **nested leaf records**, and there
`+12` reads **30** and **45** — against a measured **+30.26°** and **45.28°**.
The bytes were read correctly; the *region* was too small. See
[`structures/ui-keyframe-rotation.md`](structures/ui-keyframe-rotation.md).
⚠️ **One thing I should not have stated flatly:** that the skewed draw *is* the
swoosh. It is the only skewed geometry in the capture and the swoosh is the only
@@ -688,18 +691,43 @@ These span the **full screen height and well beyond it**. The swoosh
(`ptlogo_back2`, rest `(71,126)`, 1118 × 262) is a **band** at y 126…360. Draw 2
is not it.
🟡 **A candidate, offered as one:** the two `ptloop` sweeps. `pteff03a.t32` is
399 × 180 and its element carries scale **`(100,800)`** — 399 × 1440 — while
`pteff03` carries `(100,600)`. Two long thin sprites, two quads, and the drawn
vertex alphas `0xC3`/`0xB6` sit inside the `0x80``0xff` ramp those records
declare. **Not confirmed** — no texture or position match was made.
## ✅ It is the two `ptloop` sweeps — confirmed
⚠️ **If** that is right it has a consequence worth flagging: our renderer parks
those sweeps at their final keyframe (`x = 1521` and `839`, both off-screen) and
draws nothing, while the game draws them across the screen — which would mean the
draw capture caught them **mid-sweep**, inside the `t = 150…600` window, and that
the "groups hold" reading needs re-examining for these two elements specifically.
Conditional on an unconfirmed identification, so recorded and not acted on.
The bounding box was the wrong measurement; the quads are rotated, so what
identifies them is their **edge lengths**:
| 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) |
| quad | element | sprite × declared scale |
|---|---|---|
| A | `ptloop01.rat` | `pteff03.t32` 399×180 @ 100 %,**600 %** = 399 × **1080** |
| B | `ptloop02.rat` | `pteff03a.t32` 399×180 @ 100 %,**800 %** = 399 × **1440** |
Five independent agreements, listed in
[`structures/ui-keyframe-rotation.md`](structures/ui-keyframe-rotation.md): the
count (two quads, two `ptloop` elements), both widths (400 vs 399), **both
heights, which are different numbers that both land**, the direction each quad
moves between the capture's two frames (matching each record's own sweep
direction), and the vertex alphas falling inside the declared ramps.
The known-positives in the same capture pass the same test: draw 5 measures
915 × 115 (`ptlogo1.t32` 919×113), draw 7 measures 691 × 18 (`ptcopyright.t32`
694×20) and 512 × 50 (`ptbtn00.t32` 513×50).
### ✅ And "a keyframe group holds" survives
The consequence I flagged conditionally last iteration resolves the *other* way.
Our renderer parks these two sweeps at their final keyframe (`x = 1521` and
`839`, both off-screen); the game draws them across the screen. That is not a
contradiction — the capture caught them **mid-sweep**: the quad centres, x = 992
and x = 467, both fall inside the decoded `t = 150…600` / `150…720` travel, the
alphas are mid-ramp, and the sprites move in the decoded direction between
frames. The capture is of the **build-in**, not of the settled screen, and the
18 s stillness measurement (sd ≤ 0.01) still says the groups stop. No change to
[the holds-not-loops finding](#-a-keyframe-group-holds-at-its-last-keyframe--it-does-not-loop).
### What this costs

View File

@@ -0,0 +1,88 @@
"""Structural census of the keyframe block's angle words (+4/+8/+12), disc-wide.
`+12` is the screen-plane ROTATION in degrees (see
docs/re/structures/ui-keyframe-rotation.md). This is the disc-wide check behind
that decode, and it exists because the FIRST version of it was wrong in a way
that hid the very blocks the decode rests on.
⚠️ The blocks are **not 4-byte aligned**. A nested leaf record's `RATC` blob can
start at an odd offset (`ptloop01.rat` sits at 0xbb5966), so its keyframe blocks
inherit that alignment. An earlier scan filtered candidates on `%4 == 0`, found
0/3 of its own control blocks, and under-counted the corpus by 16 341 blocks —
every one of them inside a nested record. The CONTROL below is not decoration:
it must print 3/3 with +12 = {30} and {-45} or the numbers mean nothing.
A keyframe block is 40 bytes: fade(ARGB) | w1 w2 w3 | sx sy | tint | x y | t.
The filter keys on SHAPE, not on a count: >=2 consecutive blocks whose fade is
0x??ffffff, whose tint is 0xffffffff, and whose scale words are 1..4000.
"""
import struct, zlib, glob, os, sys, collections, re
def entries(base):
stub=open(base+'.pak','rb').read()
if stub[:4]!=b'IPFB': return
n=struct.unpack_from('>I',stub,4)[0]
segs=sorted(glob.glob(base+'.p[0-9][0-9]'))
if not segs: return
blob=b''.join(open(s,'rb').read() for s in segs)
for i in range(n):
h,off,sz=struct.unpack_from('>III',stub,0x10+12*i)
st=blob[off:off+sz]
if len(st)<10: continue
try: yield h,(zlib.decompress(st[10:]) if st[:2]==b'Z1' else st)
except Exception: continue
def blocks(d):
"""Yield offsets of the first block of each run of >=2 keyframe blocks."""
n=len(d)
U=lambda p: struct.unpack_from('>I',d,p)[0]
seen=set()
cands=sorted({m.start()-24 for m in re.finditer(b'\xff\xff\xff\xff',d)
if m.start()>=24})
for o in cands:
if o in seen or o+80>n: continue
if (U(o)&0x00ffffff)==0x00ffffff and U(o+24)==0xffffffff \
and 0<U(o+16)<=4000 and 0<U(o+20)<=4000:
k=0
while o+40*(k+1)<=n and (U(o+40*k)&0x00ffffff)==0x00ffffff \
and U(o+40*k+24)==0xffffffff \
and 0<U(o+40*k+16)<=4000 and 0<U(o+40*k+20)<=4000:
k+=1
if k>=2:
for j in range(k):
seen.add(o+40*j); yield o+40*j
S=lambda d,p: struct.unpack_from('>i',d,p)[0]
# --- control: the two known ptloop blocks must be found, with 30 / -45 ---
d4=open('/tmp/build4.bin','rb').read()
found={o for o in blocks(d4)}
for name,base,want in [("ptloop01",0xbb5966,30),("ptloop02",0xbb5a82,-45)]:
hits=[b for b in (base+0x68+40*k for k in range(3)) if b in found]
vals={S(d4,b+12) for b in hits}
print(f"CONTROL {name}: {len(hits)}/3 blocks found, +12 = {vals} (want {want})")
if not all(True for _ in [0]): sys.exit(1)
hist=collections.Counter(); nz=collections.Counter(); total=0
examples=collections.defaultdict(list)
for pak in sorted(glob.glob('/work/sylph_extract/dat/GP_*.pak')):
base=pak[:-4]
for h,d in entries(base):
if b'RATC' not in d[:4] and d[:4]!=b'RATC': pass
for o in blocks(d):
total+=1
for lbl,off in (("+4",4),("+8",8),("+12",12)):
v=S(d,o+off)
if v!=0:
nz[lbl]+=1
hist[(lbl,v)]+=1
if len(examples[lbl])<8: examples[lbl].append((os.path.basename(base),f"{h:08x}",hex(o),v))
print(f"\nblocks scanned disc-wide: {total}")
for lbl in ("+4","+8","+12"):
print(f" {lbl}: non-zero in {nz[lbl]:6d} ({100*nz[lbl]/max(total,1):5.2f} %)")
print("\nvalue histogram (non-zero), top 25:")
for (lbl,v),c in hist.most_common(25):
print(f" {lbl} = {v:>8} x{c}")
print("\nexamples:")
for lbl,ex in examples.items():
for e in ex[:4]: print(" ",lbl,e)