diff --git a/crates/sylpheed-cli/src/main.rs b/crates/sylpheed-cli/src/main.rs index 697d1cb7..e847cb20 100644 --- a/crates/sylpheed-cli/src/main.rs +++ b/crates/sylpheed-cli/src/main.rs @@ -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, diff --git a/crates/sylpheed-formats/src/ui_layout.rs b/crates/sylpheed-formats/src/ui_layout.rs index f12788b3..71eac23e 100644 --- a/crates/sylpheed-formats/src/ui_layout.rs +++ b/crates/sylpheed-formats/src/ui_layout.rs @@ -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 { } 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) - 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, diff --git a/docs/port/HANDOFF.md b/docs/port/HANDOFF.md index 95ee6b6b..4739232f 100644 --- a/docs/port/HANDOFF.md +++ b/docs/port/HANDOFF.md @@ -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 `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. diff --git a/docs/re/INDEX.md b/docs/re/INDEX.md index 6cca2f6f..9d6d2056 100644 --- a/docs/re/INDEX.md +++ b/docs/re/INDEX.md @@ -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 000–350 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 `.t32` sprite has a `.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 `.t32` sprite has a `.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 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 | diff --git a/docs/re/METHOD.md b/docs/re/METHOD.md index ca1d059b..29b9e7f3 100644 --- a/docs/re/METHOD.md +++ b/docs/re/METHOD.md @@ -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 ` 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. diff --git a/docs/re/REFUTED.md b/docs/re/REFUTED.md index fadea3c0..1bab80b8 100644 --- a/docs/re/REFUTED.md +++ b/docs/re/REFUTED.md @@ -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 `FFFFFF`, white RGB. * "the swoosh discrepancy is undecodable" → **solved**: the game submits it as two diff --git a/docs/re/data/kf-angle-census.txt b/docs/re/data/kf-angle-census.txt new file mode 100644 index 00000000..570f42b5 --- /dev/null +++ b/docs/re/data/kf-angle-census.txt @@ -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) diff --git a/docs/re/structures/ui-keyframe-rotation.md b/docs/re/structures/ui-keyframe-rotation.md new file mode 100644 index 00000000..38384472 --- /dev/null +++ b/docs/re/structures/ui-keyframe-rotation.md @@ -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 = (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. + +## 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. diff --git a/docs/re/ui-title-build-map.md b/docs/re/ui-title-build-map.md index 90aa9618..423f75f8 100644 --- a/docs/re/ui-title-build-map.md +++ b/docs/re/ui-title-build-map.md @@ -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 diff --git a/tools/re-capture/kf_rotation_census.py b/tools/re-capture/kf_rotation_census.py new file mode 100644 index 00000000..5b9b5b53 --- /dev/null +++ b/tools/re-capture/kf_rotation_census.py @@ -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=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)