Files
Sylpheed/docs/re/structures/ui-button-focus-record.md
Sylpheed RE agent 7eeae3006a 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.
2026-08-29 08:46:53 +00:00

10 KiB
Raw Blame History

✅ 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:

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:

+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:

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 against 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) 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):

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:

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.

✅ 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 (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.