From 76653ca41bacb6307d184af6f1f44269224ef666 Mon Sep 17 00:00:00 2001 From: Sylpheed RE agent Date: Sat, 29 Aug 2026 08:46:53 +0000 Subject: [PATCH] re(ui): the focus ring SPINS, the game draws it, and the leaf owns the f record Three things, all from parsing ptbtn0Nf.rat as a build. 1. THE RING SPINS. Its two keyframes differ in exactly one field: rotation_deg ramps 0 -> 360 with position, scale, alpha and tint all constant. A spin in place, the same shape as the GP_BUNK example already recorded. 2. THE ORACLE CONFIRMS THE GAME RENDERS IT. In the OPTIONS-focused capture the ring's bright head sits in a completely different angular position from the sprite's own -- caught mid-spin. This is a SECOND independent confirmation that rotation_deg is drawn, now on a different screen and a different element from the ptloop sweeps, and it raises rotation's priority: it is not a title-only concern that sits off-screen at rest, it is the main menu's focus marker. NO ANGLE IS QUOTED. A brightest-region centroid says ~250 deg, but the control refuses that precision -- rotating the sprite by a known 30/90/180/270 and re-measuring gives errors up to 19.8 deg. What survives the error bar is that a <=20 deg error cannot manufacture a ~250 deg displacement. 3. WHICH PLACEMENT WINS -- correcting this page's own earlier caveat, which said to use the leaf only for elements the parent does not declare. Right for a BASE record, wrong for an f record: the parent declares NO element for ptbtn0Nf.rat at all (zero of build 5's 16), so the f record's placement comes from its leaf for BOTH elements, label included. The label's (-7,-7) is load-bearing -- the f sprite is 13px larger per axis and -7 keeps them concentric (535+96/2 = 583 vs 542+83/2 = 583.5). Corroborated against the oracle: the focused-minus-unfocused region is x 505..703, and the leaf predicts a right edge near 707 where the parent reading predicts 714. Also exposes UiBuild::records (name -> (offset, size) of a nested .rat leaf). Nested records were parsed into a PRIVATE map, so a consumer holding a UiBuild could not locate a leaf's bytes at all -- which is exactly what blocked the port from reaching the ring. --- .../examples/rat_leaf_placement.rs | 7 ++ crates/sylpheed-formats/src/ui_layout.rs | 15 ++++ docs/re/structures/ui-button-focus-record.md | 68 ++++++++++++++++--- 3 files changed, 82 insertions(+), 8 deletions(-) diff --git a/crates/sylpheed-formats/examples/rat_leaf_placement.rs b/crates/sylpheed-formats/examples/rat_leaf_placement.rs index e5500687..9bac6e26 100644 --- a/crates/sylpheed-formats/examples/rat_leaf_placement.rs +++ b/crates/sylpheed-formats/examples/rat_leaf_placement.rs @@ -55,6 +55,13 @@ fn main() { r.map(|k| k.y).unwrap_or(-1), el.keyframes.len() ); + for (i, k) in el.keyframes.iter().enumerate() { + println!( + " kf{i} t={:?} pos=({},{}) scale={}%,{}% a={} rot={} tint={:#010x}", + k.time, k.x, k.y, k.scale_x, k.scale_y, + k.fade >> 24, k.rotation_deg, k.tint + ); + } } } } diff --git a/crates/sylpheed-formats/src/ui_layout.rs b/crates/sylpheed-formats/src/ui_layout.rs index 194fca31..1154a092 100644 --- a/crates/sylpheed-formats/src/ui_layout.rs +++ b/crates/sylpheed-formats/src/ui_layout.rs @@ -322,6 +322,20 @@ pub struct UiBuild { pub elements: Vec, /// Sprite name → (offset, size) of its `T8aD` child within the bundle. pub sprites: HashMap, + /// Record name → (offset, size) of its nested `.rat` **leaf** within the + /// bundle, e.g. `ptbtn01f.rat`. + /// + /// Exposed because a leaf is where a focused button's extra elements live — + /// `ptbtn0Nf.rat` declares the focus ring `ptbtneff01.t32` **and** the bright + /// label, and the parent bundle declares no element for the `f` record at + /// all. A consumer that walks only top-level elements cannot see either. + /// + /// A leaf is itself readable by [`parse_build`]: its first 32 bytes have the + /// same shape as a bundle header, so + /// `parse_build(&bundle[off..off + size])` returns its elements with + /// absolute design-space placements. See + /// `docs/re/structures/ui-button-focus-record.md`. + pub records: HashMap, /// A guessed context from the sprite naming (e.g. `"tutorial"`), if any. pub context_hint: Option, /// True when the declaration table was unusable and the build was recovered @@ -599,6 +613,7 @@ pub fn parse_build(bundle: &[u8]) -> Option { design_h, elements, sprites, + records, context_hint, from_fallback, placement_order, diff --git a/docs/re/structures/ui-button-focus-record.md b/docs/re/structures/ui-button-focus-record.md index 81d3ef09..d838002b 100644 --- a/docs/re/structures/ui-button-focus-record.md +++ b/docs/re/structures/ui-button-focus-record.md @@ -125,15 +125,67 @@ Positions are **absolute design-space top-left**, not offsets The offset is **uniform**: `(−42,−6)` for the ring and `(−7,−7)` for the label on every button, and identical in the Japanese bundle (pak entry 8). -⚠️ **One 1-unit disagreement, and it is real.** `ptbtn04`'s parent element rests -at y **401** while its own leaf says y **402** (and in the JP bundle the leaf says -401 against a parent 401). So a leaf's placement **duplicates** the parent's -rather than being relative to it, and the two copies are not always byte-equal. -The parent's is what `compose` honours; treat the leaf's as the source only for -elements the parent does not declare — which is exactly the ring's case. +⚠️ **Which placement wins — and an earlier version of this page was misleading.** +It said "the parent is what `compose` honours; treat the leaf's as the source only +for elements the parent does not declare". That is right for a **base** record and +wrong for an **`f`** record, because *the parent declares no element for +`ptbtn0Nf.rat` at all* — checked, zero of build 5's 16 elements name it. So the +`f` record's placement can only come from its own leaf, for **both** its elements, +the bright label included. -🟡 The ring carries **2 keyframes** where the label carries 1, so it animates. -What it does between them is not decoded here. +The `(−7,−7)` on the label is real and load-bearing: `ptbtn0Nf.t32` is 13 px +larger in each axis than the base, and −7 keeps the two **concentric** +(`535 + 96/2 = 583` against `542 + 83/2 = 583.5`). Drawing the `f` label at the +base element's position would push it 7 px down-right. + +✅ **Checked against the oracle.** Differencing the `OPTIONS`-focused capture +against the unfocused one, the changed region is x **505…703**, y **397…446**. +The leaf predicts ink starting inside the ring's box at x ≥ 500 (measured 505, a +5 px art inset) and the label's right edge near 707 (measured 703); the +parent-position reading predicts 714. Both the right edge and the bottom edge +favour the leaf by ~7 px. ⚠️ Ink-inset reasoning is soft — the decisive argument +is the structural one above, that there is no parent element to inherit from. + +The one thing that *is* duplicated is the **base** record: `ptbtn04`'s parent +element rests at y **401** while its own leaf says **402** (and in the JP bundle +the leaf says 401 against a parent 401). For a base record the parent is what +`compose` honours. + +## ✅ The ring SPINS — and the oracle confirms the game draws the rotation + +The ring's two keyframes differ in exactly one field: + +```text +ptbtneff01.t32 kf0 t=120 pos=(500,156) scale=100%,100% a=255 rot=0 + kf1 t=None pos=(500,156) scale=100%,100% a=255 rot=360 +``` + +Position, scale, alpha and tint are all constant; only `rotation_deg` ramps, +**0 → 360**. That is a spin in place — the same shape as the `GP_BUNK` example +already recorded in [`ui-keyframe-rotation.md`](ui-keyframe-rotation.md). + +✅ **And it is actually rendered.** In the oracle's `OPTIONS`-focused frame the +ring's bright head sits in a completely different angular position from the +sprite's own — the game caught it mid-spin: +[`focus-ring-oracle-vs-sprite.png`](../captures/ui-layout/focus-ring-oracle-vs-sprite.png) +(left: the oracle at the ring's declared box; right: the sprite, unrotated). + +🔴 **Do not quote an angle from this.** A brightest-region centroid puts the +displacement near 250°, but the **control refuses it**: rotating the sprite by a +known 30/90/180/270° and re-measuring gives errors up to **19.8°**, so the +estimator is not trustworthy at that precision. What survives is the part the +error bar cannot touch — a ≤20° error cannot manufacture a ~250° displacement, so +**the ring is drawn substantially rotated**, and the exact angle is one frame of a +continuous spin and not a stable quantity anyway. + +⚠️ **This raises rotation's priority for a port.** It is not a title-only concern +that sits off-screen at rest: the main menu's focus marker spins, so a renderer +that ignores `rotation_deg` draws a static ring with its highlight in the wrong +place, on the screen the player looks at most. + +🟡 The spin's **period** is not established. `t=120` is the first keyframe's time, +and what the untimed second one means for a *leaf* — as opposed to a screen, where +it is the ~0.4 s exit ramp — was not tested. ## ⚠️ `screen render --focus` is blind to the ring, and so was this page