re(ui): answer four of the port's five asks -- splash, fade-out, focus, gamma

1 SPLASH ADDRESSING (was blocking P3). No content predicate exists: design size
fails (every extra composable bundle sampled is 1280x720, like every screen) and
element count fails (fragments run 2..15, the splash halves are 3 and 7). But
GP_TITLE needs none -- `--all` adds exactly four bundles there and all four are
real screens, with the --all index equal to the pak entry index 1:1. And there
are TWO splash screens: 11/14 are the developer logos, 10/13 are the SQUARE ENIX
publisher wordmark, which the port did not have and which the boot shows first.

2 FADE-OUT (was blocking P3). It is (a), and it is bigger than the fade quad.
Every element ends on exactly ONE untimed keyframe, which rules out (b); that
block is where the screen plays out -- quad to a=255, buttons/labels/glows to
a=0, frames hold. (c) is refuted by a null test that discriminates: a black quad
alone holds the button/background brightness ratio constant, and through the
fade it falls 6.50 -> 1.94, 3.4x monotonic.

3 FOCUS (saves P5 rework). Over-vs-instead is unobservable -- the focused sprite
covers the base at 100.0% of base-visible pixels on three pairs once aligned
(true offset (7,7); the centre alignment reads a misleading 78-84%), and
compositing both ways differs by RMSE 1.1 inside the button rect. The real defect
is the focus record's SECOND element: ptbtn0Nf.rat declares ptbtneff01.t32 (a
42x46 glowing ring, focus only) plus the bright label, where the base declares
one sprite. That ring is the marker the port draws nowhere.

5 GAMMA. The capture is not neutral: capture ~ 255*(render/255)^g, g ~ 1.34-1.49,
and the chain says it is a ramp the GAME installed, not a capture artefact. So
RMSE against captures has a floor. Reach stated: the flat patches are all dark
(render ~0-60), so midtones and highlights are unconstrained.

4 ROTATION is a human's call and is recorded in MISSION, not acted on -- the port
rotating while the reference renderer does not would make verify-screen report a
large diff meaning "the port is right". The RE half is answered: rotation is
about the declared pivot, measured against a GPU capture.

The focus record's +20 element-count word is marked 🟡 not  -- read on GP_TITLE's
ten button records only; the disc-wide check is written and still running.
This commit is contained in:
Sylpheed RE agent
2026-08-29 08:19:23 +00:00
parent 9501879b28
commit 0fd8e6953e
5 changed files with 326 additions and 0 deletions

View File

@@ -41,6 +41,76 @@ authored version can be deleted.
## Already settled — the port can rely on these today
### ⬅ Answers to the port's five asks (2026-08-29)
* **1 — how to recognise the splash.** ❔ **No content rule exists; you are
authoring this.** Design size fails (every extra composable bundle sampled is
1280×720, same as every screen) and element count fails (the fragments run
2…15 elements, the splash halves have 3 and 7 — the ranges overlap).
**But `GP_TITLE` needs no rule.** There, `--all` adds exactly **four**
bundles and all four are real screens — no fragments at all — and the `--all`
index equals the pak **entry** index 1:1 across all 16, so addressing by entry
index does not mean something different from elsewhere.
🔴 **And there are TWO splash screens; you have one.** Entries **11/14** are the
developer logos (GAME ARTS / SETA / studio anima). Entries **10/13** are the
**SQUARE ENIX publisher** wordmark — the *first* thing the boot shows — and you
do not have them. The pairs are region twins (`™` on 10, `®` on 13). All four
draw every element they declare.
[`ui-splash-addressing.md`](../re/ui-splash-addressing.md) ·
[render grid](../re/captures/title-builds/splash-both-halves-rendered.png)
* **2 — the ~0.4 s fade-out is (a)**, and it is bigger than the fade quad.
Every element of a screen ends on **exactly one** untimed keyframe — so there
is one unknown duration per screen, which rules out (b). That final block is
where the screen plays out: the quad goes `a=255` (black) while the buttons,
`ptmsg` and the glows go `a=0` and the two frames hold. (c) is refuted by a
null test on the capture: a black quad alone keeps the button÷background
brightness **ratio constant**, and measured through the fade it falls
**6.50 → 1.94, a 3.4× monotonic drop**. So: write one authored constant
(~0.4 s / ~24 units) and **play the group to its end on every element** — do
not fade a black rectangle over a frozen screen.
[`screen-transitions.md`](../re/screen-transitions.md)
* **3 — focus: your choice is fine, and it is not your bug.** ✅ The focused
sprite **completely covers** the base — `f` alpha ≥ base alpha at **100.0 %** of
base-visible pixels on three pairs across both languages, once aligned properly
(the true offset is **(7,7)**, and at the centre alignment it reads a
misleading 7884 %). Compositing both ways differs by **RMSE 1.1 inside the
button rectangle**, max 12/255 on ~25 px — unmeasurable at frame level.
🔴 **What you are actually missing is the focus record's SECOND element.**
`ptbtn0Nf.rat` declares **two** sprites — `ptbtneff01.t32` (a 42×46 **glowing
ring**, focus only) then `ptbtn0Nf.t32` (the bright label) — where the base
record declares one. That ring is the marker you say you draw nowhere. ⚠️ Note
the small dot-in-circle at each underline's left end is *not* it: that is on
every button all the time, part of the base art.
[`structures/ui-button-focus-record.md`](../re/structures/ui-button-focus-record.md)
* **4 — rotation: not mine to decide alone.** Raised with the human; see
MISSION. What I can say without a decision: the two sub-questions are not
equally open. Rotation is about the **declared pivot** — that anchor is
*measured*, not assumed: the title's two `ptloop` sweeps scale 600 %/800 %
vertically, where the pivot term is worth 450 and 630 px, and the GPU capture
puts both quad centres at y **359.1**/**360.0** against the pivot formula's
**360.0**; top-left anchoring predicts 810/990 and centre-as-position 270. So
if you draw rotation, rotate about the declared pivot.
⚠️ It changes nothing on your five screens **at rest** — they have zero
top-level rotations, and the title's two nested ones sit entirely off-screen at
rest. [`structures/ui-keyframe-rotation.md`](../re/structures/ui-keyframe-rotation.md)
* **5 — the capture is not gamma-neutral, and RMSE against it has a floor.**
Measured on flat patches (16×16, both images `std < 8`):
`capture ≈ 255·(render/255)^γ` with **γ ≈ 1.49** (main menu), **1.49**
(`EXTRAS`), **1.34** (title). The chain says this is a ramp **the game
installed**, not a capture-path artefact: canary's swap-path gamma stage is a
pure 256-entry LUT that defaults to identity, and the game is measured calling
`VdGetCurrentDisplayGamma` once at video init. ⚠️ **Reach: the flat patches are
almost all dark (render ~060), so nothing here constrains midtones or
highlights** — which is where γ 1.4 does its visible work. So: yes, there is a
floor; a γ ≈ 1.4 darkening gets closer and is **authored**, best applied where
it was measured rather than extrapolated. Do not chase RMSE below it.
[`structures/ui-render-tone-curve.md`](../re/structures/ui-render-tone-curve.md)
* **`GP_TITLE.pak` is eight screens, each shipped twice — English and Japanese.**
Build 4 is the English title art and 7 its Japanese twin; **2/3 are the
`PRESS Ⓐ BUTTON` plate, a build of their own** composited over the title and

View File

@@ -208,6 +208,28 @@ settled and only multi-keyframe absolute timing is open; `rest()` differs from
its alternative on **one** element across all five screens, and the current
answer there is the defensible one.
## 🔵 Needs a human decision — rotation (raised 2026-08-29)
The port agent asks whether it should **render** `rotation_deg` (decoded at
keyframe `+12`) when `sylpheed-cli screen render` deliberately does not. Its own
framing is the reason this is not mine to settle: if the port rotates and the
reference renderer does not, then `verify-screen` reports a large title diff that
means *"the port is right"* — a silently inverted signal.
The RE half is answered and is in HANDOFF: rotation is about the **declared
pivot** (measured against a GPU capture, not assumed), and it changes nothing on
the five screens **at rest**.
What needs a decision is which way the divergence gets closed:
* teach `ui_layout::blit` a rotating path, so the two renderers stay comparable
and the diff keeps meaning "someone is wrong" — costs work in the reference
renderer, which is otherwise not on the port's critical path; or
* let the port render rotation and mark the title as a known-divergent screen in
`verify-screen`, accepting a check that no longer guards the title.
Recorded rather than chosen, per "do not improvise around a blocker".
## Known unknowns — say so, do not fill them in
Some of these may turn out to be undecodable. That is a valid, useful answer, and

View File

@@ -115,3 +115,56 @@ screen's build-in is a separate, longer thing.
quad);
* the ~0.4 s fade-out and the black hold are **authored from this page** — the
disc does not carry them.
## ✅ Which quantity the ~0.4 s is — measured 2026-08-29
Asked by the port: is 0.4 s **(a)** the ramp from the hold to the exit pose, i.e.
exactly the missing duration of that untimed keyframe, **(b)** several keyframes'
worth, or **(c)** something the game does independently of the group?
**It is (a)** — and it is bigger than the fade quad. Two facts.
**1. There is exactly one untimed keyframe, and every element has it.** Reading
`screen info --build 5 --geometry` for the main menu, all 16 elements end on a
single timeless block; none has two. So there is one unknown duration per screen,
not a chain of them — which rules out (b) outright. And that final block is not
idle: it is where the screen *plays out*.
| elements | final untimed block | what it does |
|---|---|---|
| `pteff00.prm` (the fade quad) | `a = 255` | goes **black** |
| `pteff10`, `pteff12`, `ptbtn01…05`, `ptmsg` | `a = 0` | **fade out** |
| `ptframe1`, `ptframe2` | `a = 255` | hold, and get covered |
| `ptbase`, `pteff05`, `ptloop*`, `pteff02.prm` | single keyframe | hold |
**2. The capture shows the content fading, not just a black quad arriving.** This
has a null hypothesis that discriminates: under (c) — the game blackens the frame
independently — every region is scaled by the same `1 α`, so the **ratio**
between a button region and a background region is *constant* through the
fade-out. Under (a) it must fall, because the buttons ramp to `a = 0` while the
background elements hold at 255 and are only dimmed.
Measured on [`transition-filmstrip.png`](captures/transitions/transition-filmstrip.png),
button column ÷ upper-right background art, frame by frame through the fade-out:
```
frame 0 1 2 3 4 5
ratio 6.495 5.574 3.105 2.125 1.935 (black)
```
**A 3.4× monotonic fall.** Constant is refuted. The buttons really are fading
independently of the overall dim, exactly as their declared final block says.
(The incoming screen runs it in reverse, 2.22 → 3.47 over frames 712.)
⚠️ **Reach.** The filmstrip is downsampled and the "button" region unavoidably
contains some background, so the ratio is a direction, not a clean alpha
measurement. It refutes the constant-ratio null decisively; it does not by itself
pin the 0.4 s to ±0.05 s. And it is measured on **one** transition pair.
### For the port
Write **one** authored constant — the duration of the final untimed keyframe,
~0.4 s / ~24 units — and **play the group to its end on every element**. Do not
model the exit as a black rectangle fading over a frozen screen: the buttons and
labels ramp to transparent at the same time, and that difference is visible.

View File

@@ -0,0 +1,111 @@
# ✅ 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 7884 %, 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.
## ❔ Not established
* **Where `ptbtneff01.t32` is placed.** The record declares two elements; the
per-element placement inside a `.rat` leaf was not decoded here. The capture
shows the ring **left of** the label, roughly on the underline's left end.
A port should take the offset from the record, and until it is decoded, from
the capture.
* **`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.

View File

@@ -0,0 +1,70 @@
# ❔ How to address the developer-logo splash — no content rule, but `GP_TITLE` needs none
**Status:****undecodable as a general predicate, with reach** — and ✅
**exact for `GP_TITLE`**, which is the only archive in scope. Answers the port's
*"I need a predicate, not an index."*
## The ask
The splash has no `.rat` layout child, so `is_build` rejects it and it is
reachable only through `--all` / `is_composable`, which disc-wide also admits
~1 786 non-screen bundles. The port wanted a rule that admits the splash and not
those.
## ✅ In `GP_TITLE` the problem does not arise
```
$ sylpheed-cli screen list GP_TITLE.pak 12 builds — entries 0-9, 12, 15
$ sylpheed-cli screen list --all GP_TITLE.pak 16 bundles — entries 0-15
```
`--all` adds exactly **four** bundles: entries **10, 11, 13, 14**. All four are
real screens, and **not one fragment appears**. So within this archive
`is_composable` *is* the predicate — it is exact, with no filtering needed.
⚠️ And the renumbering worry is moot here: in `GP_TITLE` the `--all` index equals
the pak entry index **1:1 across all 16**. That is an accident of this archive
having 16 composable bundles at entries 0…15, not a general property — but it
means the port can address these by entry index without the index meaning
something different from elsewhere.
## ✅ And there are TWO splash screens, not one
Rendered with `screen render --all --build <n> --primitives`
([grid](captures/title-builds/splash-both-halves-rendered.png)):
| entry | elements | what it draws |
|---|---|---|
| **10** | 3 | **SQUARE ENIX** publisher wordmark, `™` |
| **11** | 7 | **GAME ARTS** / **SETA** / **studio anima** developer logos |
| **13** | 3 | SQUARE ENIX, `®` — the twin of 10 |
| **14** | 7 | the twin of 11 |
⚠️ **The port has 11/14 and is missing 10/13** — the publisher half, which is the
*first* thing the boot sequence shows. The two halves differ only by the
trademark glyph (`™` on 10, `®` on 13), which is the region/language split this
archive uses everywhere else.
All four draw every element they declare (3/3 and 7/7), so nothing is silently
dropped.
## ❔ The general rule: looked for, not found
Two candidate predicates, both dead:
* **Design size.** Every extra composable bundle sampled is **1280×720** — the
same as every screen. It does not separate anything.
* **Element count.** The "two-element fragment" story holds in
`GP_MISSION_SELECT` (10 extras, all 2 elements) and **fails elsewhere**:
`GP_OPTIONS`'s extras have 5 and 15 elements, `GP_SAVE_LOAD`'s have 4, 5, 6, 9
and 13. The splash's own halves have 3 and 7. The ranges overlap, so no
threshold separates them.
Reach: three archives sampled beyond `GP_TITLE`, chosen because they have
non-build composables. Not a disc-wide sweep. A rule may exist in a field not
looked at — the `.prm` fade quad is one candidate
([transitions](screen-transitions.md) shows overlays lack it), untested here.
**So: the port is authoring this.** Locating the splash by entry index is the
honest description, and `name_source` should say it was located by index and not
by a rule.