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.
204 lines
10 KiB
Markdown
204 lines
10 KiB
Markdown
# ✅ A focused button is a **two-element record**, and the second element is the ring
|
||
|
||
**Status:** 🟡 `DECODED for GP_TITLE`, disc-wide check **not yet run**; the
|
||
sprite contents and the ring are ✅ `MEASURED` against a live capture. Answers the
|
||
port's *"focus: drawn over the base element, or instead of it?"*
|
||
|
||
⚠️ The element-count field at `+20` is read here on **`GP_TITLE`'s ten button
|
||
records only** (five buttons × two language bundles). A disc-wide check —
|
||
"does `+20` equal the number of resource names the leaf embeds, for every `.rat`
|
||
leaf on the disc?" — is written and was still running when this was committed.
|
||
Until it lands, treat `+20` as a **strong local reading, not a decoded field**:
|
||
the *observable* claim below (the focus record carries a second sprite,
|
||
`ptbtneff01.t32`, and the port must draw it) rests on the embedded names and the
|
||
capture, not on that word.
|
||
|
||
**Short answer: it does not matter, and that is not the bug.** The focused sprite
|
||
covers the base completely, so over-vs-instead is worth at most 12/255 on ~25
|
||
pixels. What *is* missing is the focus record's **second element** — a glowing
|
||
ring that only exists when focused.
|
||
|
||
## The record
|
||
|
||
A button's base and focused variants are both `.rat` leaves, and they are not the
|
||
same shape:
|
||
|
||
```text
|
||
ptbtn02.rat 164 B strings: RATC, ptbtn02.t32, opt ptbtn02f.rat
|
||
ptbtn02f.rat 312 B strings: RATC, ptbtneff01.t32, ptbtn02f.t32, opt ptbtn02b.rat
|
||
```
|
||
|
||
The header says so explicitly. A `.rat` leaf begins:
|
||
|
||
```text
|
||
+0 "RATC"
|
||
+16 u32 flags 0x00008110 (base) 0x00008112 (focused)
|
||
+20 u32 ELEMENT COUNT 1 2
|
||
+32 first element name
|
||
```
|
||
|
||
So the focused record declares **two** elements — `ptbtneff01.t32` first, then
|
||
`ptbtn0Nf.t32` — where the base declares one. Both `f` records of both language
|
||
bundles read the same way, and the flag word differs only in bit `0x02`
|
||
alongside the count.
|
||
|
||
⚠️ The `opt ` link on a `.rat` leaf is **not** simply "my focused variant". The
|
||
chain runs `ptbtn01.rat → ptbtn01f.rat → ptbtn02.rat → ptbtn02f.rat →
|
||
ptbtn02b.rat`, i.e. it threads base and focused records together in order. The
|
||
corpus calls it the focus link and for a *base* record that reading works; do not
|
||
generalise it to the `f` records.
|
||
|
||
## The three sprites
|
||
|
||
Decoded with `sylpheed-cli pak textures`, shown over a checkerboard in
|
||
[`button-base-focus-ring-sprites.png`](../captures/ui-layout/button-base-focus-ring-sprites.png):
|
||
|
||
| sprite | size | what it is |
|
||
|---|---|---|
|
||
| `ptbtn02.t32` | 117×43 | the **dim** label, its underline, and a small dot-in-circle at the underline's left end |
|
||
| `ptbtn02f.t32` | 130×56 | the **same label, bright and glowing** — a complete replacement, 13 px larger in each axis |
|
||
| `ptbtneff01.t32` | 42×46 | a **glowing ring**, focus only |
|
||
|
||
The small dot-in-circle is on **every** button all the time; the large ring is
|
||
the focus marker. They are different things and sit side by side on the focused
|
||
row — visible in
|
||
[`live-main-menu-options-focused.png`](../captures/title-builds/live-main-menu-options-focused.png)
|
||
against
|
||
[`live-main-menu.png`](../captures/title-builds/live-main-menu.png).
|
||
|
||
## Over or instead? Measured, and the answer is "unobservable"
|
||
|
||
Aligning base and focused by normalised cross-correlation of their alpha masks —
|
||
the true offset is **(7,7)**, not the (6,6) that centring predicts —
|
||
|
||
| pair | base-visible px | `f` alpha ≥ base alpha there |
|
||
|---|---|---|
|
||
| `ptbtn02` / `f` (bundle `b58a0fe6`) | 1 444 | **100.0 %** |
|
||
| `ptbtn01` / `f` (bundle `b58a0fe6`) | 1 898 | **100.0 %** |
|
||
| `ptbtn02` / `f` (bundle `a715f485`) | 3 720 | **100.0 %** |
|
||
|
||
⚠️ **At the centre alignment it reads 78–84 %, and that number is an artefact.**
|
||
A 1 px shift on strokes this thin manufactures a fifth of a sprite's worth of
|
||
"the focused art is thinner here". Solve the alignment before trusting a coverage
|
||
figure.
|
||
|
||
Coverage is not the same as hiding, though, because `f` is not fully opaque
|
||
everywhere. Compositing both ways over the menu's own background colour:
|
||
|
||
| pair | max channel difference | px > 8/255 | RMSE over the button rect |
|
||
|---|---|---|---|
|
||
| `ptbtn02` | 12.5 | 23 | 1.09 |
|
||
| `ptbtn01` | 12.2 | 27 | 1.08 |
|
||
| `ptbtn02` (JP) | 12.2 | 27 | 1.05 |
|
||
|
||
So the two hypotheses differ by **~1.1 RMSE inside the button rectangle**, on a
|
||
couple of dozen pixels — below the ≈ γ 1.4 tone gap
|
||
([tone curve](ui-render-tone-curve.md)) and far below any frame-level RMSE this
|
||
corpus can resolve. **Either choice is defensible; neither is measurable.**
|
||
Replacing is the cheaper one and is what the file's structure suggests, since
|
||
`ptbtn0Nf.t32` is a whole label rather than an overlay.
|
||
|
||
## ✅ Where the ring is placed — DECODED 2026-08-29, no authoring needed
|
||
|
||
This page previously said the per-element placement inside a `.rat` leaf was not
|
||
decoded and that a consumer should eyeball it off a capture. **That was wrong by
|
||
omission**: a leaf needs no new reader. Its first 32 bytes have the same shape as
|
||
a bundle header — `"RATC"`, `0x3c` declaration-entry size at `+4`, element count
|
||
at `+20`, design size `1280x720` at `+24`/`+28` — so `ui_layout::parse_build`
|
||
reads it **unchanged**.
|
||
|
||
**The control is the base record**, whose position is known independently: the
|
||
parent screen reports `ptbtn01.rat` resting at `(542,162)`, and parsing the leaf
|
||
on its own returns `ptbtn01.t32` at `(542,162)`. It reproduces all five.
|
||
|
||
Positions are **absolute design-space top-left**, not offsets
|
||
([`examples/rat_leaf_placement.rs`](../../../crates/sylpheed-formats/examples/rat_leaf_placement.rs)):
|
||
|
||
| button | base | ring `ptbtneff01.t32` | Δ | label `ptbtn0Nf.t32` | Δ |
|
||
|---|---|---|---|---|---|
|
||
| `ptbtn01` | (542,162) | **(500,156)** | (−42,−6) | (535,155) | (−7,−7) |
|
||
| `ptbtn02` | (542,242) | **(500,236)** | (−42,−6) | (535,235) | (−7,−7) |
|
||
| `ptbtn03` | (542,322) | **(500,316)** | (−42,−6) | (535,315) | (−7,−7) |
|
||
| `ptbtn04` | (542,402) | **(500,396)** | (−42,−6) | (535,395) | (−7,−7) |
|
||
| `ptbtn05` | (542,482) | **(500,476)** | (−42,−6) | (535,475) | (−7,−7) |
|
||
|
||
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).
|
||
|
||
⚠️ **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 `(−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
|
||
|
||
`el.focused` is name-based on **top-level** elements, and a screen's buttons are
|
||
`.rat` records whose focused twin is not itself a top-level element — so
|
||
rendering build 5 with and without `--focus` produces an identical image. The
|
||
reference renderer has the same blind spot the port reported, for the same
|
||
reason: neither walks into the leaf. Fixing it is a renderer change, not a
|
||
format question; the format is decoded above.
|
||
|
||
## ❔ Not established
|
||
* **`ptbtn02b.t32`** — a third variant, `b`, exists for button 02 only, same size
|
||
as the base. Not seen on any capture. Not chased.
|
||
* Whether a **non-title** archive uses the same two-element convention. Checked
|
||
on `GP_TITLE` only.
|