FORMAT v3: rotation, and the focus record -- the ring the port could not reach

Pin bumped to the TAG formats-pin-2026-08-29 (7eeae30), applying the policy the
previous commit wrote. What I wanted from it: `UiBuild` gained a public
`records` map. Without it a leaf was unreachable through the public API --
parse_build sorted T8aD children into `sprites` and `.rat` children into a
PRIVATE map -- so the focus ring, which lives inside ptbtn0Nf.rat, a record the
parent bundle declares NO element for, could not be located at all. My exporter
was writing 19 of build 5's 21 sprites and I could not see why.

v3 carries two new things.

ROTATION. `rotation_deg`, decoded at keyframe +12, and the game DRAWS it --
confirmed twice by the RE agent on different screens with different elements:
the title's ptloop sweeps declare +30/-45 and a GPU capture submits them at
+30.26/-45.28, and the focus ring ramps 0 -> 360 with everything else constant,
caught mid-spin in a capture. Rotation is about the DECLARED PIVOT, measured.
The comparison renderer does not draw it yet, so a rotation disagreement means
sylpheed-cli is behind, not that the port is wrong. Sign is still an assumption.

THE FOCUS RECORD. A focused button is not a sprite swap: ptbtn0Nf.rat declares
the spinning ring AND the bright label, and since the parent declares no element
for the record, the leaf is the only source of placement for both. v2's single
focus_sprite could not carry the ring at all and drew the highlight 7 px
off-centre by inheriting the base position. That -7,-7 is load-bearing: the f
label is 13 px larger per axis and -7 keeps the two concentric.

Checked against the game, not against the other renderer: rendering main_menu
with OPTIONS focused changes the region x 504..703, y 399..448. The RE agent
measured the same difference in the live capture at x 505..703, y 397..446 --
independently, from the other side. Ring, label and underline all land; the only
visible residual is the ring's spin PHASE, which is exactly the one thing
neither of us has resolved (its second keyframe is untimed, and the screen-level
rule for that is not established to apply inside a leaf). Listed in `unresolved`
rather than invented.

verify-screen is unchanged at 16/16 -- rotation has no effect at rest on these
screens, as predicted.
This commit is contained in:
Sylpheed port agent
2026-08-29 09:04:47 +00:00
parent c0a5725934
commit 700094bef1
8 changed files with 314 additions and 23 deletions

View File

@@ -1,9 +1,9 @@
# The open export format — v2
# The open export format — v3
The format the disc is converted *into*, and the one the Godot project and any
modding tool read. **It is versioned, so a change is a deliberate act with a
version bump**, not a silent edit. [Changes from v1](#changes-from-v1) is at the
bottom, with a reason for each.
version bump**, not a silent edit. [Changes from v2](#changes-from-v2) and
[Changes from v1](#changes-from-v1) are at the bottom, with a reason for each.
Design rules, in priority order:
@@ -53,7 +53,7 @@ rest are applied by the runtime over `export/`.
```json
{
"format": "sylpheed.screen/2",
"format": "sylpheed.screen/3",
"exporter": "sylpheed-export 0.1.0",
"formats_rev": "8b6dbcf",
"source": { "archive": "dat/GP_TITLE.pak", "entry": 5, "build": 5 }
@@ -69,7 +69,7 @@ the index into that pak's list of screen builds (what `sylpheed-cli screen
```json
{
"format": "sylpheed.screen/2",
"format": "sylpheed.screen/3",
"exporter": "sylpheed-export 0.1.0",
"formats_rev": "8b6dbcf",
"source": { "archive": "dat/GP_TITLE.pak", "entry": 5, "build": 5 },
@@ -154,6 +154,46 @@ pairs **by name** on the sprite — `ptbtn01.t32` ↔ `ptbtn01f.t32` — which i
naming convention that holds for all 54 real pairs on the disc, not a decoded
field.
**`focus`** is the focused state, and it **supersedes `focus_sprite`**. A
focused button is not a sprite swap: `ptbtn0Nf.rat` is a nested `.rat` **leaf**
declaring *two* elements — the spinning ring `ptbtneff01.t32` and the bright
label — and **the parent bundle declares no element for the record at all**, so
the leaf is the only source of placement for both and there is nothing for it to
inherit.
```json
"focus": {
"record": "ptbtn04f.rat",
"elements": [
{ "id": "ptbtneff01", "sprite": "…/ptbtneff01.png", "pivot": [21, 23],
"rest": { "pos": [500, 396], "rotation_deg": 0, "t": 120, … },
"keyframes": [ { "t": 120, "rotation_deg": 0, … },
{ "rotation_deg": 360, … } ] },
{ "id": "ptbtn04f", "sprite": "…/ptbtn04f.png", "rest": { "pos": [535, 395], … } }
]
}
```
Elements are back-to-front in the leaf's own declaration order — ring first,
then label. Positions are **absolute design-space top-left**, not offsets from
the button.
> The label's `(−7, −7)` against its base is load-bearing, not noise:
> `ptbtn0Nf.t32` is 13 px larger per axis, and −7 keeps the two **concentric**
> (535 + 96/2 = 583 against 542 + 83/2 = 583.5). Drawing the highlight at the
> base position pushes it 7 px down-right and off-centre.
> ⚠️ **Leaf placement is authoritative for an `f` record and NOT for a base
> record.** A base record's leaf *duplicates* its parent's placement and the two
> can disagree by a unit (`ptbtn04`: parent y=401, leaf y=402) — there the parent
> wins. The `f` record is the case where the parent declares nothing.
> ❔ **The ring spins, and its period is unresolved.** Its two keyframes differ
> in `rotation_deg` alone, 0 → 360. But the second is untimed, and what an
> untimed keyframe means *inside a leaf* — as opposed to at screen level, where
> it is the exit ramp — is untested. A consumer should draw the resting angle
> rather than invent a spin rate. This is listed in `unresolved`.
**`opt_link`** is the raw `opt ` link inside the element's `.rat` record, carried
through unresolved. ⚠️ **It is not a focus link.** That reading was measured and
refuted: on the main menu it chains `ptloop01 → ptloop02 → ptbtn01`, across two
@@ -177,6 +217,23 @@ there — and a file that puts one on it is wrong, not merely odd. The unit of `
is measured, not on the disc, and so lives in `authored/` and is applied in
exactly one place.
**`rotation_deg`** is screen-plane rotation in degrees, clockwise-positive,
decoded from the keyframe's `+12`. **The game renders it**, confirmed twice by
the RE agent on different screens and different elements: the title's `ptloop`
sweeps declare +30 / −45 and a GPU capture submits their quads at +30.26 /
−45.28, and the main menu's focus ring ramps 0 → 360 with position, scale, alpha
and tint all constant — a capture caught it mid-spin.
Rotation is **about the declared pivot**, which is measured rather than assumed:
the `ptloop` sweeps scale 600 %/800 % vertically, where the pivot term is worth
450 and 630 px, and the capture puts both quad centres at y 359.1/360.0 against
the pivot formula's 360.0 (top-left predicts 810/990, centre-as-position
predicts 270).
> ⚠️ `sylpheed-cli screen render` does **not** draw rotation yet — its `blit` is
> axis-aligned. A rotation disagreement between it and a consumer that does draw
> rotation means the CLI is behind, not that the consumer is wrong.
**Two colours multiply.** `tint_rgba` is RGBA and is `0xffffffff` on essentially
every keyframe; `fade_argb` is **ARGB**, and its high byte is the alpha that ramps
during a fade. The byte order is in the key name because getting it backwards is
@@ -260,6 +317,17 @@ reaches which entry is Q4 and is not).
`video_transcode` will record the exact command so a modder can re-run it rather
than reverse-engineer what was done. It is absent until P4 writes a video.
## Changes from v2
v2 was written before the keyframe's `+12` was decoded and before anyone could
reach a `.rat` leaf through the public decoder API.
| Change | Why |
|---|---|
| `rotation_deg` on every keyframe and on `rest` | Decoded at keyframe `+12`, and **the game draws it** — confirmed on two different screens with two different elements against GPU captures. Dropping it would have made the focus ring's whole animation invisible. |
| `focus` (a record with its own elements) added; `focus_sprite` kept but demoted | The focused state is two elements in a nested leaf, not one sprite. v2's single `focus_sprite` could not carry the ring at all, and drew the highlight label 7 px off-centre by inheriting the base's position. `focus_sprite` stays because it is still the 54-pair naming convention and a consumer may want the bare texture. |
| `unresolved` gains `focus_ring_spin_period` | The ring's rotation ramps 0 → 360 across two keyframes whose second is untimed. The screen-level rule for an untimed keyframe (the exit ramp) is not established to apply inside a leaf, so the period is unknown and is not being invented. |
## Changes from v1
v1 was written before HANDOFF answered Q1 and Q3, and before the two-colour