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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
⚠️ The earlier "pink versus white" reading compared two differently-shaped
|
||||
renderings and should be re-checked after geometry, not carried as a separate
|
||||
defect.
|
||||
|
||||
@@ -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 `<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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
51
docs/re/data/kf-angle-census.txt
Normal file
51
docs/re/data/kf-angle-census.txt
Normal 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)
|
||||
120
docs/re/structures/ui-keyframe-rotation.md
Normal file
120
docs/re/structures/ui-keyframe-rotation.md
Normal 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 = (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.
|
||||
@@ -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
|
||||
|
||||
|
||||
88
tools/re-capture/kf_rotation_census.py
Normal file
88
tools/re-capture/kf_rotation_census.py
Normal 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)
|
||||
Reference in New Issue
Block a user