Files
Sylpheed/docs/re/structures/ui-resting-pose.md
sylph-decoder 70ee001327 re: sweep the SILENT instruction surface -- 5 files still named a dead gate
sylpheed-port refined my ranking: rank silent instructions above loud ones. All
of theirs were loud -- wrong paths that error out and announce themselves --
while mine was silent: an inert env var returning a clean, wrong result. Only the
silent kind manufactures evidence.

The silent surface is enumerable, so this is a sweep rather than a sample: every
environment variable the docs name, checked against the code.

SYLPHEED_KF_TIME_SHIFT was still live in FIVE doc files after I fixed one last
iteration. Two of the five were genuine hits rather than historical quotes:

  ui-resting-pose.md -- a RESULTS TABLE ROW labelled "with
  SYLPHEED_KF_TIME_SHIFT=1". Re-running it sets an inert variable, produces the
  DEFAULT row, and lets a reader conclude the two readings agree. A stale
  instruction inside a results table is the purest form of the evidence-
  manufacturing class.

  HANDOFF.md -- "experiment reachable via SYLPHEED_KF_TIME_SHIFT=1", a live
  instruction in the delivery contract.

And a live gate exists under a DIFFERENT NAME that the docs never pointed at:
SYLPHEED_KF_TIME_LEGACY, verified read at ui_layout.rs:595 -- the parser itself,
not only the tests, so it does reach screen info and screen render. Both hits now
redirect there.

Beware the proxy, which is the trap the port named about their own "parsed"
check: absent-from-code also flags SYLPHEED_DISC, XENIA_SRC and SYLPH_ISO, which
are container paths the brief sets and no code reads. Absent-from-code is
necessary, not sufficient, and I checked each rather than reporting the seven raw
hits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 18:07:54 +00:00

1200 lines
59 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# A keyframe is the start of a ramp, not a pose that is held
## ✅ 2026-08-30 — the corrected association's rest pose is what the GAME shows
**Status: ✅ measured against the oracle.** Until now the keyframe record-layout
fix rested on *internal consistency* — 0 of 1 042 multi-segment alpha ramps
constant-rate under the old reading against 857 of 1 540 under the new. That is a
strong argument and it is not a measurement of the game. It now has one, on the
single screen in `GP_TITLE` where the two readings change pixels.
`ptlogo_eff3.t32` on entry 7 (`title_jp`) is the **only** element whose rest pose
moves between the two eras — `(108,72)` stale, `(98,42)` fixed — and it accounts
for all 74 507 differing pixels. Scored against
[`live-title-jp-at-rest.png`](../captures/title-builds/live-title-jp-at-rest.png)
over the 388×423 box where the two renders differ, so the result is not diluted by
the ~92 % of the frame that is identical:
| candidate | RMSE vs the running game |
|---|---|
| stale era, rest `(108,72)` | 58.412 |
| **fixed era, rest `(98,42)`** | **41.690** |
| fixed era, `--settle` t=213 | 40.210 |
**Three controls, run before believing any of it:**
* **alignment found by sweep, not assumed** — offset 0 → 87.29, 40 → 56.37,
**45 → 32.41**, 50 → 53.08, 60 → 72.84. A sharp minimum at the known
game-surface offset;
* **the box discriminates** — the same box against a *different* screen's capture
(the EN title) scores 98103, against 4058 here;
* **`--black` changes nothing** (58.412 / 41.690 either way) — every pixel in this
box is covered by an element, so the canvas never shows through it. Recorded
because the flag's own help says a framebuffer capture must be compared against
a black canvas, and here it happens not to matter.
### 🟡 And the same run says settle-vs-rest is NOT decidable from this capture
Sweeping the screen's own timeline with `--at` gives the instrument's noise scale
([`data/ptlogo-eff3-rest-vs-oracle.txt`](../data/ptlogo-eff3-rest-vs-oracle.txt)):
```
t= 0 77.97 t= 90 58.50 t=150 40.59 t=210 40.23 t=270 78.41
t= 45 63.05 t=120 45.49 t=165 40.20 t=240 40.07 t=285 78.42
```
The capture sits on a broad **plateau from t≈135 to t≈240, flat to 1.2 RMSE across
105 units**, with sharp rises outside it. So:
* the **stale-vs-fixed margin of 16.7 is ~14× that flatness** — decisive;
* the **settle-vs-rest margin of 1.5 is *inside* it** — not decisive.
That is a better statement than "non-decisive": this capture separates the *eras*
and cannot separate the *policies*, and the number that says so is the plateau's
own width. The settle-instant proposal stays unadopted on the same evidence it
had.
### ✅ Why the capture's own instant is not a confound here
`sylpheed-port` found their harness capturing the same screen one keyframe unit
apart in different sessions — 70 % of the picture, mid-build-in, and stable enough
within a session that repeat runs said "deterministic". That hazard would void this
adjudication if the JP capture had been grabbed at an arbitrary moment. It was not,
and there are two independent reasons:
* **prospective** — the grab was gated on the **plate pulse**, the title's own
settled signature, and [`data/jp-title-at-rest.txt`](../data/jp-title-at-rest.txt)
records the gate and a contrast control taken with it;
* **retrospective** — the `--at` sweep above shows the capture on a **plateau flat
to 1.2 RMSE across 105 units with edges at 78**. A capture caught mid-build would
produce a sharp minimum instead. The sweep was run for a noise scale and answers
this too.
### ✅ The CAPTURE axis has a noise floor too, and it is 0 in the region measured
`sylpheed-port` found their `main_menu` row drifting 13.2513.30 across runs — a
focus ring spinning on a free-running clock — and made the general point that **a
margin only means something against the noise it sits on**. The `--at` plateau
above measures the *render* axis. It says nothing about how much the score moves
between two **captures** of the same screen, which is a second noise source and
the one my single JP grab is exposed to.
Measured, from two independent captures of the settled EN title taken at
different phases of its free-running plate pulse, scored against one render:
| | whole frame | inside the measurement box |
|---|---|---|
| plate-pulse **peak** | 31.302 | 21.230 |
| plate-pulse **trough** | 28.463 | 21.230 |
| **spread** | **2.839** | **0.000** |
⚠️ A 0.000 is the result this corpus distrusts most, so it carries its control:
the two captures differ by **83 496 pixels** whole-frame (max |d| 174) — they are
genuinely different grabs — and by **0** inside the box. The screen's free-running
element is the plate, which lies outside the logo region the adjudication uses.
### ✅ And a second JP capture, from a fresh session, closes the transfer
The paragraph above transferred the *EN* title's capture noise to the *JP* box and
flagged that as a limit. A second independent capture was taken —
[`jp_title_session.sh`](../../tools/re-capture/jp_title_session.sh), fresh boot,
separate session, locale set and restored —
[`live-title-jp-at-rest-run2.png`](../captures/title-builds/live-title-jp-at-rest-run2.png).
Within-run stability **reproduces** (0 of 138 600 in the ROI, 47k73k px moving
whole-frame as the contrast control). Between sessions, inside the box this
adjudication uses: **645 of 164 124 px differ, RMSE 0.3215**, against 116 492 px
whole-frame — genuinely different sessions.
**The verdict reproduces to three decimals:**
| | vs session-1 capture | vs session-2 capture |
|---|---|---|
| stale era rest `(108,72)` | 58.412 | 58.413 |
| fixed era rest `(98,42)` | 41.690 | 41.692 |
| **margin** | **16.722** | **16.721** |
📌 **Capture noise moves both candidates together, so it nearly cancels in a
margin.** The absolute scores moved 0.0010.002 between sessions while the margin
moved 0.001, against an in-box capture noise of 0.32. A margin between two renders
scored on one capture is far more robust than either score is — which is why the
render axis, not the capture axis, is the binding constraint here.
### ✅ The capture-phase term, measured — and why the box was the right choice
`sylpheed-port` overturned their own phase-0 result using the identical-leaves
fact: sweeping the *same* leaf against a `title` capture minimises at phase 240
where `main_menu` minimises at 0, so **the best-matching phase is a property of
when the shutter fell, not of the game's rest state**. A continuously sweeping
element has no canonical rest phase. They then warned that any whole-frame score
against a single capture carries an irreducible phase term of ~1.0 RMSE.
That term is measurable on my own two JP sessions, which certainly differed in
sweep phase — 44 025 px differ in the band the leaf crosses:
| region scored | between-session RMSE |
|---|---|
| **whole frame** | **4.566** |
| the sweep band, x 721..1241 | 4.088 |
| **the adjudication box** | **0.3215** |
⚠️ **Their ~1.0 understates it for this screen.** A whole-frame score against one
capture of the JP title carries **~4.6**, not ~1.0 — theirs is the leaf-phase
component isolated in a renderer, mine is everything that varies between sessions
(the plate pulse alone contributes ~2.8, measured separately on the EN pair) and
includes theirs. Anyone quoting a whole-frame number on this screen should use the
larger figure.
### 🔴 The crop is NOT why the box is robust — and that questions "the leaf free-runs in the game"
`sylpheed-port` could not transfer the masking rule to their screens and inferred
a precondition: *my* free-running element is a localised plate I can crop around,
theirs is a wide sweep they cannot. **Tested, and that is wrong about my case.**
The JP title carries the *same* sweep — the leaves are identical on entries 4, 5
and 7 — and it crosses the box:
```
two renders of build 7 on the settled plateau, t=135 vs t=240
whole frame RMSE 12.135 95 791 px differ
in the box RMSE 11.923 57 981 px <- the sweep is INSIDE the box
differences span y 70..674, x 128..1140; the box is y 54..476, x 389..776
```
So cropping did not exclude the mover, and the in-box between-session term of
**0.3215** is not explained by the crop. That leaves a real tension:
* two **renders** one plateau-phase apart differ by **11.9** inside the box;
* two **captures** of that screen, different sessions, differ by **0.32** there;
* and the `--at` sweep of renders against a capture is **flat to 1.2** across
t=135..240, despite those renders differing from each other by 11.9.
A metric cannot be insensitive to a 11.9 change unless the thing that changed is
largely absent from what it is being compared against.
🟡 **Hypothesis, untested: the game may not draw these leaves on the settled title
at all** — our renderer poses them wherever `--at` says, the game shows none of
it. That would explain the flat plateau, the tiny between-session term, and part
of the ~40 residual in one move. ⚠️ It would also mean `sylpheed-port`'s "the leaf
free-runs in the game too" is **not established by their evidence**: their two
minima come from two *different screens*, which can differ for reasons other than
a free-running phase. My two captures are of the **same** screen and barely differ
where the sweep would be.
I am not claiming the leaves are invisible — that needs a capture-side test
(a draw-stream check for `pteff03`/`pteff03a` on a settled title would settle it in
one run). What is established is narrower and enough to stop the inference: **the
box's robustness is not the crop, so no precondition about compactness explains
it, and the phase term for captures of this screen is measured at 0.32 while the
term for renders of it is 11.9.**
📌 **And it is why the box matters more than I realised when I chose it.** Scoring
the 388×423 region rather than the frame drops the between-session term from 4.566
to **0.3215** — a factor of 14 — because the sweep contributes at x 721..1241 and
the box is mostly clear of it. That was not the reason I cropped (the crop was to
avoid diluting a local difference across 92 % of an identical frame), so the
robustness is luck. **The general rule it earns: score inside a region that
excludes the free-running elements, and *measure* the residual term there rather
than estimating it.**
**So the margins re-stated against every noise floor now measured:**
| comparison | margin | render noise (1.2) | in-box capture noise (0.32) |
|---|---|---|---|
| stale vs fixed era | **16.72** | **14×** — decisive | 52× — decisive |
| settle vs rest | **1.48** | **1.2×** — NOT decisive | 4.6× |
🔴 **Correction to a claim made earlier the same day.** This page briefly said the
settle-vs-rest negative was *strengthened* because 1.48 sits below the
**whole-frame** capture spread of 2.8. That was the wrong comparison: the
measurement lives in the box, and the in-box between-session noise is **0.32**, so
1.48 is well above it. The negative rests on the **render** axis alone, exactly as
first stated — the "strengthening" was reaching for a number that was to hand
rather than the one that applies.
### 🔴 WITHDRAWN — "`ptloop01/02` do not free-run on the settled title"
**Retracted 2026-08-30, same day, by my own corpus.** The claim below measured the
parent's declared rect at `(441,270)` 200×90. **That rect is a PIVOT ANCHOR, not
the drawn extent.** [`data/ptloop-leaf-sweep-positions.txt`](../data/ptloop-leaf-sweep-positions.txt)
— written earlier in this same corpus — records `ptloop01.rat`'s nested record at
loop length **600**, whose leaf `pteff03.t32` sweeps a **400 px-wide** quad with
its centre running x≈921→1041 over t=340..370. That is nowhere inside x 441..640.
Checked against the two JP captures: the rect I measured differs by **0 px** — but
so does the entire region y 270..450 × x 480..960 around it, a dead zone — while
the band the sweep actually occupies (x 721..1241) differs by **44 025 px**. The
zero was measured where nothing happens and proves nothing about the loops.
**`sylpheed-port`'s reading is right and is confirmed from the disc**: the
parent is static while the **leaf record animates**, and the two nested records
cycle at *different* lengths, **600 and 720**. My "single static keyframe" was a
statement about the parent only.
**Their full table also survives an independent check against the disc**
([`data/ptloop-leaf-extent.txt`](../data/ptloop-leaf-extent.txt)), to the digit:
| | `ptloop01` | `ptloop02` |
|---|---|---|
| leaf | `pteff03` | `pteff03a` |
| cycle span | 600 | 720 |
| x track (left edge) | **639 … 1521** | **839 … 1721** |
| scale | (100 %, **600 %**) | (100 %, **800 %**) |
The quad is 400 px wide and **not** widened; `scale_y` makes it 1080 / 1440 px
tall — a full-height strip taller than the screen, whose left edge travels right
across the frame and off both sides.
📌 **And a fact neither of us had: these leaves are IDENTICAL on entries 4, 5 and
7** — the title, the **main menu**, and the JP title. Same leaf names, spans, x
tracks, scales and parent rest position. So the menu declares exactly the same
sweep as the title; the open menu question is about the game's *behaviour*, not
about a different declaration.
⚠️ **My own "centre running x≈921→1041" was a 30-unit window of a 600-unit cycle**
whose centre spans 439…1721. A sub-range is not an extent — the same caution as a
pivot not being a bounding box, one level up, and I made both errors in one day.
⚠️ The era adjudication is unaffected: its box is x 389..776, and the overlap with
the sweep band (x 721..776) shows no between-session differences — the 645 in-box
differing pixels all sit at y 99..128.
### ~~✅ And `ptloop01/02` do NOT free-run on the settled title~~ (see above)
The transfer above was flagged as uncertain because build 7 carries
`ptloop01/02.rat`, which might animate inside the adjudication box where the EN
plate does not. `sylpheed-port` then found those same two leaves free-running in
*their renderer* on the menu path, and was explicit that pinning a phase picks one
pose rather than the game's — *"a capture question, not a harness one"*. It is, and
two captures from different sessions answer it:
```
ptloop01/02 rest at (441,270), 200x90 -- INSIDE the adjudication box
differing px between session 1 and session 2: 0 of 18 000, max |d| 0
contrast, whole frame: 116 492 px differ, max |d| 51
```
**Byte-identical across sessions**, while 12.6 % of the frame moves. So on the
settled title the loops are static, and the in-box capture noise of 0.32 is not
theirs — the 645 differing pixels all lie in a 30-row band at `y 99..128`, nowhere
near the loop rect.
🟡 **The menu is a different bundle and is NOT settled by this.** `GP_TITLE` build
5 declares `ptloop01/02` at the same rect with a single static keyframe, and that
is the screen the port's row actually drifted on. A probe to capture five settled
menu frames and diff that rect was written
([`menu_loop_rest.sh`](../../tools/re-capture/menu_loop_rest.sh)) and **did not
run to completion**: the run reached a title at t=146 s and Ⓐ did not take across
six attempts. That is the documented intermittency — the *attract loop's* title
accepts nothing, unlike the boot title
([`canary-scripted-input-traps.md`](../canary-scripted-input-traps.md)) — so the
question is open, with the instrument written and one successful run away.
⚠️ Two committed main-menu captures exist but **cannot** answer it: they differ
across **57 %** of the surface (different geometries and capture paths), so the
88 % differing on the loop rect measures the mismatch, not the loops.
⚠️ **Reach.** One screen, one capture. The absolute residual is ~40 RMSE even at
the best instant, because the JP title carries live animation a static render
cannot reproduce; every comparison here is relative and none of it says our render
is *correct*, only which of two candidates the game is closer to.
**Status:**`CONFIRMED` against the framebuffer capture of the running title
screen — the new rule aligns at **zero shift**, the old one had to be moved.
🟡 the fallback for groups that never hold is unverified. ❔ interpolation
between keyframes is still not implemented, only the resting pose.
## The rule that was wrong
`Element::rest()` answers "where is this element when the screen is just sitting
there", and every composite the port draws depends on it. It used to pick the
keyframe with the **largest gap to the next keyframe's time** — the frame that
"dwells longest".
That reads a keyframe as a value held until the next one. It is not: a keyframe
is the **start of a ramp toward the next one**. So a long gap after keyframe *k*
means the screen spends that whole time *arriving at* `k+1` — the settled pose is
at the **far** end of the gap, not the near one.
The title wordmark makes it concrete. `ptlogo1.t32` zooms in from off-screen:
```
kf0 150% (-116, -7) a=0x00 t=26 off-screen, invisible
kf1 150% (-116, -7) a=0x00 t=34
kf2 112% ( 109, 123) a=0x80 t=38
kf3 103% ( 165, 171) a=0xc0 t=40
kf4 101% ( 179, 186) a=0xe0 t=42 ← old rule picked this
kf5 100% ( 184, 193) a=0xff t=251 ← the settled pose
kf6 100% ( 184, 193) a=0xff t=264 ← held here
kf7 100% ( 184, 193) a=0x00 t=None fades out
```
The gap 42 → 251 is by far the largest, so the old rule picked **kf4** — 1 % too
large and 5 px up-left, a frame from mid-zoom. The pose the screen actually holds
is kf5kf6.
## The rule that is right
**The resting pose is the hold: the longest run of consecutive keyframes with an
identical pose.** Ties go to the later run, matching the in → hold → out shape.
Groups that ramp through every frame and never hold fall back to longest-dwell.
## The measurement
`docs/re/captures/title-screen-oracle.png` is a framebuffer capture of the
running title screen. It is a **1:1 crop** of the 1280×720 frame (1279×675) —
verified by the copyright line landing on row 669 in the capture and in both
composites — so frame coordinates map directly and a shift is meaningful.
Edge-correlated over the wordmark box (x 1501150, y 200400) with
`tools/re-capture/align_to_capture.py`. Gradient magnitude, not colour: the
capture's planet is mid-explosion and orange while ours is blue, and the wordmark
materials are undecoded, so a pixel diff would measure everything except the
question being asked.
| resting rule | best correlation | at shift | at (0,0) |
|---|---|---|---|
| **plateau** (landed) | **0.4597** | **(0, 0)** | 0.4597 |
| longest dwell (old) | 0.1511 | (+3, +8) | 0.1268 |
The old composite peaks 3× lower **and only after being moved** — displaced by
about the (5,7) that kf4-instead-of-kf5 predicts. The new one is already where
the game puts it.
* [composited with the plateau rule](../captures/ui-layout/title-composited-plateau-rest.png)
* [composited with the old longest-dwell rule](../captures/ui-layout/title-composited-longest-dwell-rest.png)
## What else it fixed, on the same screen
The old rule systematically picked the **invisible** end of a fade-in. On the
title screen it rested these at alpha `0x00`, where the capture plainly shows
them:
`ptlogo_tm` (the ™), `ptcopyright`, `ptlogo_back2`, `ptlogo_back2eff`,
`ptloop01`/`ptloop02`.
And `pteff00.prm` — the full-screen fade quad that paints **last** — rested at
**opaque black**. That is the whole screen, and it is why `.prm` compositing was
blocked ([`ui-prm-primitives.md`](ui-prm-primitives.md)).
None of that was visible before, because `compose` never applied the `fade`
alpha at all: `blit` modulates by `tint` only, and `tint` is `0xffffffff` on
essentially every keyframe. The wrong keyframes were being chosen and then their
one distinguishing field was ignored. That is worth stating as its own finding —
see below.
## The `fade` alpha, applied (same day)
`blit` modulated by `tint` only — `0xffffffff` on essentially every keyframe — so
the `fade` word was decoded, stored, and then thrown away. Applying it as an
**ARGB** modulate on top of `tint` is what turns the resting pose from an
academic result into a picture:
| composite | best correlation vs the capture | at shift |
|---|---|---|
| plateau rest, `fade` applied | **0.9538** | (0, 0) |
| plateau rest, `fade` ignored | 0.4597 | (0, 0) |
| longest-dwell rest | 0.1511 | (+3, +8) |
0.95 against a framebuffer capture of the running game.
[The composite](../captures/ui-layout/title-composited-fade-applied.png) now has
the white wordmark with its blue outline, the ™, the copyright, and the orange
exploding planet — the last of which appeared because a full-screen blue effect
that rests at alpha 0 had been painting over it at full opacity.
**ARGB is measured, not assumed.** Across a fade-in the *high* byte walks
`0x00 → 0x80 → 0xc0 → 0xe0 → 0xff` while the low three stay `ffffff`, and the low
24 bits are `0xffffff` on 5 276 of the disc's 5 453 resting keyframes (with
`0x000000` on 165 — the `.prm` blacks — and `0x5dc9ff` on 12).
Risk checked, because a modulate can only ever remove pixels: it is a **no-op on
4 060 of 5 200** sprite elements, partial on 453, and hides 687 — which are
transient HUD indicators (`pb_emergency`, `pb_refilling`, `pbcm1_arrow1`) that
should not be lit on a resting screen. **No build is left with nothing visible**,
and a disc test asserts it.
### And it caught a bug in the resting rule
With `fade` applied, the pause menu lost the word **PAUSE**, which the capture of
the running game plainly shows
([`pause-tutorial-real-vs-rebuilt.png`](../captures/ui-layout/pause-tutorial-real-vs-rebuilt.png)).
`pgptitle.rat` has **three** runs of two identical keyframes — invisible,
visible, invisible:
```
kf0 a=0x00 t=5 kf1 a=0x00 t=13 pre-roll
kf2 a=0xff t=23 kf3 a=0xff t=28 the hold
kf4 a=0x00 t=30 kf5 a=0x00 t=None the exit
```
A group carries the screen's entry animation **and its exit**. The tie-break
"later run wins" grabbed the exit. Fixed: a run ending on the last keyframe is
excluded unless it is the only one. The title's correlation is unchanged at
0.9538, and [PAUSE is back](../captures/ui-layout/pause-composited-fade-applied.png).
This is why the two changes landed together: the trailing-run defect is
invisible — in the literal sense — until `fade` is applied.
## What is not settled
***Blend mode.** Everything above is straight alpha-over. The near-white
flash quads and the coloured ones may well be additive, and nothing has been
measured; the title capture cannot separate the two because its resting
elements are all `0xffffff`.
* 🟡 **The fallback.** Groups with no two adjacent keyframes alike still use
longest-dwell. How many there are, and whether the correct answer for them is
the *last* keyframe instead, is unmeasured.
***Interpolation.** Only the resting pose is decoded; nothing tweens. A
viewer that animates these screens needs the ramp, and whether it is linear is
unknown.
* 🟡 One-shot flashes (`ptlogoall_eff`, `ptlogoall_eff2`) ramp 0 → 0x80 → 0x4b →
0 and never hold at a visible value, so the plateau rule rests them at alpha 0
— invisible. That is *probably* right for a settled screen, but the capture
cannot confirm it while `fade` is unapplied.
## 🔴 `rest_plateau` is wrong for elements with no exit animation (2026-08-28)
Reported by the port agent with a capture that proves it, and it affects
`sylpheed-cli screen render` too — this is not only a port concern.
`rest_plateau` drops a trailing run of identical keyframes because that run is
normally the **exit** animation. On an element that has **no exit**, the trailing
run *is* the hold, and dropping it puts the element back at its **first**
keyframe — off-position and transparent.
**The condition that identifies these exactly** (no false positives across the
port's whole export):
> the final untimed keyframe has the same pose as the last timed one
**Six elements** on `main_menu` match it and `rest()` misses all six. The
visible cost: on `main_menu` the timeline render and the `rest` render differ in
exactly one region — **400 × 470 at (440,108)**, the bounding box of `ptframe1`
and `ptframe2` and nothing else. That is the **bright circuit bracket around the
menu**, plainly present in
[`../captures/main-menu-oracle.png`](../captures/main-menu-oracle.png) and absent
from the `rest` render. Cropping the same region from the capture and from both
renders puts the ring and its elbow trace pixel-aligned with the game's in the
timeline render.
This also explains a long-standing ❔ on
[`ui-paint-order-key.md`](ui-paint-order-key.md): *"`ptframe1`/`ptframe2` rest at
`0x00ffffff` (alpha 0) and are therefore not drawn, but the capture shows the menu
frame plainly."* Same two elements, same cause — now identified.
## ✅ Fixed 2026-08-28 — but the condition is the ALPHA, not the pose
The report's proposed test — *"the final untimed keyframe has the same pose as the
last timed one"* — **misfires**, and on the exact case the exclusion was written
for. `pgptitle.rat`'s last two keyframes are also identical:
```
pgptitle.rat kf4: fade=0x00ffffff pos=(220,69) t=30
kf5: fade=0x00ffffff pos=(220,69) t=None <- same pose
```
Adopting it as stated would erase the word PAUSE again. What separates the two is
**visibility**:
| | trailing run | alpha | is it the hold? |
|---|---|---|---|
| `ptframe1`/`ptframe2` (main menu) | 3 × `0xffffffff` at (440,108) | `0xff` | **yes** — no exit animation |
| `pgptitle` (pause menu) | 2 × `0x00ffffff` | `0x00` | no — it is the fade-out |
An exit fades the element out, so its last keyframe is transparent; an element
with no exit ends on the pose you can see. **So a trailing run is the hold exactly
when it is visible**, and that is what `rest_plateau` now tests.
### Verified against a capture, not against another renderer
| check | result |
|---|---|
| `ptframe1` rest | `(620,108) t=16`**`(440,108) t=62`** |
| bracket region draws | mean 61.74 → **62.79** |
| **oracle correlation** over that region vs [`main-menu-oracle.png`](../captures/main-menu-oracle.png) | **0.9596 → 0.9748** |
| pixels changed, whole frame | 10 082, bounding box **x 440839, y 108577** — exactly the 400 × 470 at (440,108) the report predicted |
| **regression control**: PAUSE wordmark, 3 pause builds | **unchanged** (2833 / 2858 / 2833 bright px) |
**`cargo test -p sylpheed-formats` with `SYLPHEED_DISC` set: 131 passed, 0
failed** across 6 binaries including the disc-gated ones. (One pre-existing
`ignored` — the XBG7 shared-resource test — unrelated.)
### ✅ The disc-wide check
Both rules reimplemented over the parsed keyframes of **every** `RATC` bundle on
the disc — 2 859 bundles, 13 991 elements with ≥ 2 keyframes:
| | |
|---|---|
| elements whose `rest` moves | **30** (0.21 %) |
| invisible → visible | **4** |
| **visible → invisible** | **0** — the safety property |
The change is surgical and it never hides something that was being drawn. The
remaining 26 move `rest` between two *visible* poses (position, not visibility).
The 4 revealed are `ptframe1.t32` and `ptframe2.t32` in `GP_TITLE` **entry 5 and
entry 8** — the same two elements, once per language build.
### 🟡 Reconciling with the report's "six elements"
On the **English main menu exactly two** elements satisfy the report's condition
(last two keyframes identical), and both are `ptframe1`/`ptframe2`. So the six
must span its whole 12-screen export, not that one screen — consistent with its
own observation that the timeline and `rest` differ *"in exactly one region …
the bounding box of `ptframe1`/`ptframe2` and nothing else"*.
**Open, and it matters:** if any of the other four have a **transparent**
trailing run, this rule deliberately leaves them alone — that exclusion is what
protects the word PAUSE. If a capture shows one of them drawn, the alpha rule is
incomplete and needs a third discriminator. **Which screens are they on?**
---
## 🔴 The dwell fallback is unsound whenever it actually runs (2026-08-28)
`rest()` tries `rest_plateau()` first and, failing that, picks the keyframe with
the longest **dwell** — the largest gap `t[k+1] t[k]`. That rule is not sound,
and the reason is structural rather than a tuning problem.
A gap between `t[k]` and `t[k+1]` is time the element spends **interpolating from
pose `k` to pose `k+1`**. Neither pose is *held* during it — unless the two poses
are equal, which is exactly a plateau, and the plateau path has already handled
that case and returned. **So by the time the fallback runs, it is guaranteed that
no pose is held, and the rule is choosing an endpoint of a movement.**
### The element that exposed it
🔴 **This listing is the STALE PARSER's, and the example it supports is dead
(2026-08-30).** The times below are shifted by one with an untimed final pose —
the pre-record-layout-fix reading. See
[CONTAINER-NOTES](../../agents/CONTAINER-NOTES.md) for the trap.
`GP_TITLE` build 7, `ptlogo_eff3.t32` — a transient bloom, **as it was printed**:
```
46: (98,42) 100%,100% a=0
61: (108,72) 0%,0% a=0
103: (108,72) 200%,200% a=255 r=80
-: (108,72) 0%,0% a=0 r=150 ← stale: shifted, final pose untimed
```
**and as it actually reads:**
```
0: (98,42) 100%,100% a=0
46: (108,72) 0%,0% a=0
61: (108,72) 200%,200% a=255 r=80
103: (108,72) 0%,0% a=0 r=150
```
| | gaps | longest | its two ends |
|---|---|---|---|
| stale | 15, 42 | 61→103 | one is **a=255 at 200 %** — the screen-filling bloom |
| **fresh** | **46, 15, 42** | **0→46** | **both a=0** |
**So this element no longer discriminates.** `rest()` returns `(98,42) a=0`
invisible — and build 7 renders **byte-identical** under the corrected and legacy
readings (0 pixels differ, max Δ 0). ⚠️ `MISSION.md` lists this element as the one
case a **Japanese-locale capture** was needed to settle. It is not; that capture was
still worth taking, for the port's `title_jp` question, but not for this.
No two adjacent poses are equal, so there is no plateau. The longest gap is
`61 → 103` (42 units), during which the sprite grows from nothing to **200 %** at
full alpha while rotating 80°, then collapses again. The rule returns whichever
end of that movement the indexing lands on:
| | returned "rest pose" |
|---|---|
| as decoded | `(108,72) 0%,0% a=0` — invisible |
| with `SYLPHEED_KF_TIME_LEGACY=1` ⚠️ | `(108,72) 200%,200% a=255` — the peak |
An 896×389 sprite at 200 % scale is 1792×778 — larger than the screen. Painting
it permanently is what made build 7's render 13.1 % different and 4.9 luminance
units brighter.
**The element has no resting pose.** It is a flash; after it plays there is
nothing. Neither answer is *derived* — one of them is merely harmless.
### 🟡 A proposal — and my own control cannot validate it
Three iterations have measured how badly the fallback behaves without proposing
anything. The proposal is **pose every element at the *screen's* settle instant**
(`UiBuild::settle_time()`, the midpoint of the longest keyframe-free interval across
the build) rather than asking each element for its own resting pose
([`../data/rest-vs-settle.txt`](../data/rest-vs-settle.txt)).
**Effect**, on the 2 249 fallback elements in bundles that settle at all: the
visible-pose rate falls **73.6 % → 34.7 %** — consistent with transient peaks
disappearing.
🔴 **But the control fails, and then fails better, and still is not a pass:**
| control | agreement |
|---|---|
| naive — every plateau element | **46.6 %** |
| fair — only those **holding across** the settle instant | **78.1 %** |
The naive one was misspecified, caught by asking what 46.6 % means physically:
`rest()` finds *a* held pose, and many elements hold one during the build-in then
move on. Different questions; disagreement proves nothing.
⚠️ **The fair control's 21.9 % residual looked ambiguous by construction**
`rest_plateau()` picks one plateau, and an element with two whose settle instant
falls in the *other* disagrees.
✅ **RESOLVED (2026-08-30, later): it is not ambiguous, and the residual is entirely
the incumbent's** ([`../data/plateau-choice.txt`](../data/plateau-choice.txt)).
| | |
|---|---|
| **control** — exactly one plateau, covering the settle instant | **3 072 / 3 072 agree (100.0 %)** |
| **test** — more than one plateau, at least one covering | 1 622 elements, agree on 586 (36.1 %) |
| of the **1 036** disagreements, `rest()` landed on a run **not covering** the settle instant | **1 036 — all of them** |
`rest_plateau()` selects the **longest** run (`len >= any_len`), which need not be
the one the screen is actually sitting in. **Both poses are genuinely held** — these
are plateau cases, not transients — so this is `rest()` returning a pose the screen
has **already left** by the time it settles.
🔴 ~~**Comparing a candidate to the incumbent cannot adjudicate when the incumbent is
the thing under suspicion.**~~ **Too strong — corrected the same day.** The *bare*
comparison cannot. The comparison **plus a structural property that independently
says which side is wrong in each disagreement** can, and *"does the chosen run
contain the settle instant"* is such a property: it attributes **1 036 of 1 036**.
What was missing was not an oracle but a **discriminator**.
### ✅ Closing the gap: `settle_time()` itself, against the game
The port ran the proposal against captures and favoured it 3/3 — but tested **its
own** settled pose, not `UiBuild::settle_time()`, and said so. That gap is this
crate's to close
([`../data/settle-vs-rest-against-captures.txt`](../data/settle-vs-rest-against-captures.txt)).
**Geometry first, because the first attempt got it wrong.** A 1280×720 render meets
a 1279×675 capture by **crop, not scale**:
| convention | RMSE |
|---|---|
| **crop rows 0…675** | **14.07** |
| resize bilinear | 68.89 |
| crop rows 45…720 | 79.61 |
⚠️ The 45-row offset holds for a full **display** frame; these committed captures are
already the game surface.
Gamma is fitted **per pose**, so each candidate gets its own best case and the
comparison cannot be won by the fit:
| screen | pose | γ | RMSE | % > 8 |
|---|---|---|---|---|
| **title** | **settle** | 0.84 | **8.17** | **15.28** |
| title | rest | 1.04 | 20.92 | 70.84 |
| publisher | settle / rest | **0.30 — railed** | 36.38 / 36.69 | 2.36 / 2.74 |
| developer | settle / rest | **0.30 — railed** | 33.07 / 33.42 | 6.16 / 6.54 |
🔴 **The two splashes do not adjudicate and are not counted.** Their gamma fit sits
on the **edge of the search range** — widened to 0.303.00 and it still rails — so
the photometric model is wrong for them, and with γ railed the margins collapse to
1.16× and 1.06×.
**`title` does adjudicate**, at an *interior* γ, and decisively: **15.28 % against
70.84 %** differing (4.6×), RMSE **8.17 against 20.92** (2.6×). So **the
implementation and not merely the direction** is supported.
⚠️ Absolute agreement is poor — the port's settled `title` row is 0.21 % where mine
is 15.28 %. Its renderer draws things mine does not and a single global gamma is a
crude model. **Take the ordering from this table, not the values.**
✅ **The stronger evidence remains the oracle, and it is the port agent's, not
mine**: its publisher splash against a committed capture, **settle-instant pose RMSE
2.17 / 0.01 % differing** against **`--pose=rest` 9.05 / 0.75 %** — 75× the
differing area, against the game. **That** is the evidence for the proposal; the
numbers above describe its effect and do not establish it.
### 🟡 The candidate's own failure mode — censused, and the obvious explanation is wrong
The port agent found `ptmsg`, the main menu's footer, at **alpha 127.5 of 255** at
that screen's settle instant. Verified: build 5's window is **[44, 56] = 12 units**,
and `screen render --settle` **already prints** *"narrow — this bundle may never
settle"*.
Disc-wide, elements caught **mid-ramp** at their screen's settle instant
([`../data/settle-midramp-census.txt`](../data/settle-midramp-census.txt)):
| settle window | elements | mid-ramp | share |
|---|---|---|---|
| < 10 | 3 571 | 1 460 | 40.9 % |
| **1019** | 1 755 | 791 | **45.1 %** |
| 2029 | 935 | 297 | 31.8 % |
| 3059 | 4 084 | 476 | 11.7 % |
| ≥ 60 | 3 646 | 548 | 15.0 % |
| **all** | **13 991** | **3 572** | **25.5 %** |
🔴 **WITHDRAWN 2026-08-30 — the table below is wrong and so is the conclusion I
drew from it.** `screen render --build N` takes a **build ordinal**, not a pak
entry: `screen list` says `[10] entry 12`, `[11] entry 15`. The splashes are
entries 10 and 11 and are **not screen builds at all**, so my "`--build 10/11`"
windows of 8 are the **loading screens**. From the file the splashes are **190** and
**145** — the *widest* of the five, not the narrowest
([`../data/splash-settle-window-retraction.txt`](../data/splash-settle-window-retraction.txt)).
**So width and mid-ramp are perfectly confounded across every screen either agent
has measured, and the width hypothesis is NOT refuted.** The port's predictor may
still be the mechanism; this evidence does not establish it over width.
The withdrawn reading, kept because the mistake is instructive:
| build | screen | window | port's measurement |
|---|---|---|---|
| 4 | title | 76 | settle wins **9×** |
| 5 | main menu | 12 | settle loses 1.2× |
| **10** | **publisher** | **8** | settle wins **75×** |
| **11** | **developer** | **8** | settle wins **33×** |
~~The splashes are narrower than the menu and the settle pose wins by 75×.~~ **They
are wider. The rows read 8 because they are the loading screens.**
⚠️ **One half of the filter criticism survives**: dropping bundles with a window
under 10 units admitted the **1019** bucket, the *worst* at 45.1 % mid-ramp. The
other half — that it excluded the splashes — is withdrawn; at 190 and 145 they were
never near the cutoff.
### 🔴 Losing the example did not close the question — it is larger than one element
Disc-wide ([`../data/rest-fallback-census.txt`](../data/rest-fallback-census.txt)):
| | |
|---|---|
| elements with ≥ 2 keyframes | 13 991 |
| have a plateau — the fallback never runs | 11 686 |
| **have none — the fallback decides** | **2 305** |
| of those, it returns a visible pose | 1 697 (74 %) |
| **of those, it returns the element's MAXIMUM alpha** | **1 457** |
⚠️ **"1 697" is not a defect count and this page briefly implied it was.** An element
that genuinely ends visible *should* rest visible. **The number that survives is
1 457**: the fallback runs only when no two adjacent poses are equal — i.e. only when
**no pose is held** — so every pose it can return is un-held by construction, and
1 457 times it hands back the *brightest* one.
🔴 **A first attempt to correct this failed its own control**, and is recorded
because the failure is instructive
([`../data/rest-fallback-audit.txt`](../data/rest-fallback-audit.txt)). Splitting the
1 697 by whether the element's **last** keyframe is visible gave 347 / 1 350 — plausible,
arithmetic sound. But **12 278 of 13 991 elements (87.8 %) end at alpha 0**, because a
screen's *exit ramp* drives everything to zero. The split carries almost no
information. The port agent had been bitten by exactly this an hour earlier — its
census called `ptmsg`, the main menu's permanent footer, "a 2-unit flash" — and I ran
the control only because it said so.
**`GP_TITLE`: 5 fires, 4 visible** — and all four are on the **splash screens**,
`palogo_sqex_eff.t32` / `palogo_anima_eff.t32` on entries 10/11/13/14. Each reads
`[0:a0 15:a255 30:a212 45:a0]`: a flash peaking at t=15, dead by t=45, and the
fallback returns **t=30, a=212** — near the peak of a transient.
**Independently converged on from the other side.** The port agent, working from
the Japanese title capture and knowing nothing of this census, found
`ptlogo_back2eff1`'s `rest.t` sitting at the peak of its own **4-unit** sparkle,
with six staggered across the logo — so `--pose=rest` fires every sparkle at once,
a frame the game never shows.
⚠️ **The consequence, and it is a rule about how a rest render may be used:** a
render posed at `rest` is a legitimate **common reference for comparing two
decoders**, and is **not** a frame to score against a capture of the game.
**Now an oracle number rather than an argument.** The port measured its publisher
splash against the committed capture in both poses: **timeline RMSE 2.17 / 0.01 %
differing**, against **`--pose=rest` RMSE 9.05 / 0.75 %** — **75× the differing
area**, on a screen it ships. Nothing it ships is wrong; its settled pose evaluates
`pose_at(hold)` and skips the flashes.
### 🔴 …and the rule is NOT a consequence of the fallback being unsound
The port also listed `palogo_gamearts_eff` and `palogo_seta_eff` among the four
visible fallback fires. **They are not** — refutation attempt, and it succeeds
([`../data/palogo-eff-plateau-vs-fallback.txt`](../data/palogo-eff-plateau-vs-fallback.txt)):
| element | keyframes | path | `rest` |
|---|---|---|---|
| `palogo_sqex_eff`, `palogo_anima_eff` | `0:a0 15:a255 30:a212 45:a0` | **dwell fallback** (unsound) | t=30, a=212 |
| `palogo_gamearts_eff`, `palogo_seta_eff` | `0:a0 15:a255 **30:a255** 45:a0` | **plateau** (sound — the pose is held) | t=15, **a=255** |
The second pair holds `a=255` at identical x, y and scale from t=15 to t=30. That
**is** a plateau, `rest_plateau()` handles it, and t=15 is the *correct* answer. The
census's four stand.
🔴 **But that makes the port's point stronger, not weaker.** Its rest pose for those
two is the flash's **peak**, reached by the **sound** path. So "a rest render is not
a frame to score against a capture" does **not** follow from the fallback being
unsound — **a plateau can itself be the held peak of a transient.** The rule covers
both paths, and the fallback census (2 305 / 1 697) *understates* the exposure
rather than bounding it.
### 🔴 What this retracts
Last iteration I reported the build 7 render difference as evidence **against**
the keyframe-time shift, on the reasoning that language twins should match in
brightness. **Withdrawn.** The difference is not about the time association at
all: it is the dwell fallback guessing, and it would guess on this element under
any reading of the times. The brightness comparison was measuring a heuristic,
not a decode.
What that leaves: the case *for* the shift (a factor of 26 on the hold:fade-out
ratio, [`ui-keyframe-time-unit.md`](../ui-keyframe-time-unit.md)) is no longer
opposed by render evidence — 10 of 11 builds are byte-identical and the 11th
differs only through an unsound heuristic.
🟡 **It is still not adopted**, for a different reason than before: adopting it
would flip this element to the visibly wrong answer, so the shift and a decision
about what `rest()` should do for plateau-less elements have to land together,
and the second half has no capture to verify against either.
### ⚠️ What the port should take from this
Any element whose keyframes contain **no two adjacent identical poses** has a
`rest()` result that is guessed, not decoded — in our renderer and in anything
built from it. That is a property a consumer can test for itself in one pass over
the keyframes, and it is worth flagging in an export rather than silently
inheriting our guess.
## The size of the defect, and why it does not block the menu port
[`tools/re-capture/plateau_census.py`](../../../tools/re-capture/plateau_census.py)
walks every `GP_*.pak` placement region directly (the CLI route decodes every
texture and is far too slow for a disc-wide pass). Its control reproduces
`GP_TITLE` build 7's three fallback elements and names `ptlogo_eff3.t32` among
them before it counts anything. Output: [`data/plateau-census.txt`](../data/plateau-census.txt).
| | |
|---|---|
| elements with a keyframe group, disc-wide | **15 493** |
| no plateau | 3 807 (24.57 %) — ⚠️ **overstated, see the correction below** |
| …of those, **single-keyframe** (trivially at rest, not a guess) | **1 502** |
| **genuinely ambiguous** (2+ keyframes, no plateau) | **2 305 (14.88 %)** |
| of those, current rule returns an **invisible** pose | 1 711 (44.9 %) |
| of those, current rule returns a **zero-scale** pose | **195 (5.1 %)** |
| the two candidate rules **agree** | 1 911 (50.2 %) |
A returned pose with `scale = 0 %` is not a pose at all, and 195 elements get
one. Disc-wide the choice of rule is not cosmetic: the candidates agree only half
the time.
### ✅ But on the five screens the port needs, the exposure is one element
| screen | plateau-less | rules differ |
|---|---|---|
| main menu (entry 5) | 5 / 16 | 0 |
| `EXTRAS` (entry 6) | 5 / 18 | 0 |
| title (entry 4) | 2 / 24 | 0 |
| developer splash (entry 11) | 2 / 7 | **1** |
Thirteen of the fourteen affected elements get the same answer either way. The
one disagreement is `palogo_anima_eff.t32`.
### 🔴 And "rest = last keyframe" is refuted on it
That was the alternative I named last iteration. The developer splash carries
**three sibling glows**, identical in structure and in every time:
```
palogo_gamearts_eff 15:a=0 30:a=255 45:a=255 -:a=0 → plateau → visible
palogo_seta_eff 15:a=0 30:a=255 45:a=255 -:a=0 → plateau → visible
palogo_anima_eff 15:a=0 30:a=255 45:a=212 -:a=0 → no plateau
```
They differ in **one byte**`212` where the others have `255`. Under "last
keyframe", `anima_eff` alone goes invisible while its two siblings stay lit. A
rule that makes one of three parallel elements behave differently because of a
single alpha count is producing an artefact, not a decode.
The capture agrees weakly. Comparing box means in
[`live-splash-developer.png`](../captures/title-builds/live-splash-developer.png)
against our render (the screenshot is 1279×675, top-aligned, so only ratios are
comparable): gamearts **0.717**, seta **0.723**, anima **0.772**. If our render
were adding a glow the game does not draw, anima's ratio would sit *below* its
siblings'. It sits above.
### 🟡 Where this leaves it
The **defect** is established and measured: `rest()` guesses for **14.88 %** of
elements disc-wide and returns a degenerate zero-scale pose for 195 of them.
The **fix** is not decided — "last keyframe" is refuted, and the current rule
survives on the only captured element that discriminates. ⚠️ For the menu port
specifically this is **not a blocker**: one element on one screen, and our
current answer for it is the defensible one.
---
## 🔴 The 24.57 % was overstated, and my proposed fix is refuted
**2026-08-29.** Two corrections, both from one experiment.
### The prediction: draw nothing for an element with no held pose
A keyframe group is **entry → hold → exit**, and the exit ends invisible — on the
five port screens the final keyframe is invisible for **21/24** (title),
**8/16** (main menu), **12/18** (`EXTRAS`), **2/3** and **6/7** (splashes). So
the screen "as seen" is the *hold*, which is why `rest_plateau` is the right
primary rule and why "rest = the last keyframe" would empty every screen.
That suggested a fix: an element with **no** hold has no representative pose, so
draw nothing rather than guess an endpoint. Tested via `compose`'s `visible` mask
(`examples/plateauless_suppression.rs`), correlated against the live captures:
| screen | as-is | plateau-less suppressed | Δ |
|---|---|---|---|
| title | +0.9500 | +0.6839 | **0.2661** |
| main menu | +0.9460 | +0.9037 | 0.0423 |
| `EXTRAS` | +0.9440 | +0.9094 | 0.0346 |
**Refuted, decisively, on all three.**
### Why — and it invalidates the headline number
An element with a **single keyframe** has no *adjacent pair*, so the plateau test
marks it plateau-less. But a single-keyframe element is not animated at all: its
one pose *is* its rest, unambiguously. Suppressing those removes backgrounds and
full-screen layers, which is where the title's 0.27 comes from.
The same flaw is in the census this page published:
| | |
|---|---|
| no plateau (as published) | 3 807 (24.57 %) |
| …of which **single-keyframe** | **1 502** — trivially at rest |
| **genuinely ambiguous** | **2 305 (14.88 %)** |
So the guessed-rest population is **2 305, not 3 807** — the published figure
overstated it by **65 %**. The defect is real and smaller than reported.
---
## ✅ Where the fallback actually fires — and "last keyframe" wins there
**2026-08-29.** Two things fell out of scoring rules by rendering instead of
arguing about them.
### The port's exposure is TWO elements, not fourteen
The guessing fallback is reached only by an element that is plateau-less **and**
has more than one keyframe — a single-keyframe element short-circuits at
`match len { 1 => first }`. Per screen:
| screen | elements | plateau-less | **reach the fallback** |
|---|---|---|---|
| title (4) | 24 | 2 | **0** |
| main menu (5) | 16 | 5 | **0** |
| `EXTRAS` (6) | 18 | 5 | **0** |
| publisher splash (10) | 3 | 2 | **1** |
| developer splash (11) | 7 | 2 | **1** |
On the three screens a port cares most about, **`rest()` never guesses at all**.
That is why three different fallback rules render builds 4, 5 and 6 to *identical*
correlations (+0.9500 / +0.9460 / +0.9440 for every rule) — the code is
unreachable there.
### On the two splashes, the last keyframe is markedly better
Rendering under each rule (`SYLPHEED_REST_RULE=last|maxalpha`, default off) and
correlating against the live captures:
| splash | dwell (current) | **last** | maxalpha |
|---|---|---|---|
| publisher | +0.9600 | **+0.9982** | +0.9600 |
| developer | +0.9643 | **+0.9758** | +0.9643 |
`maxalpha` is indistinguishable from the current rule; **`last` gains +0.038 and
+0.012**, and 0.9982 is very close to exact.
### 🔴 This refutes my own refutation
I had killed "rest = the last keyframe" by argument: on the developer splash it
makes `palogo_anima_eff` invisible while its two siblings stay lit, which looked
like an artefact. **The capture says otherwise** — making it invisible is what
improves the match. The sibling symmetry was my expectation, not evidence.
⚠️ **The caveat that keeps this honest:** both captures are *single frames of a
transient animation*. They fix which pose matches **these reference frames**, not
which pose is canonically "at rest" — a grab a second earlier would show the
glows. What the measurement supports is: for reproducing the committed captures,
use the last keyframe.
🟡 **Default unchanged.** The rule is better on both screens where it fires and
provably identical on the other three, but it would alter the rest pose of
**2 305** elements disc-wide and two of them have been measured. Reachable via
`SYLPHEED_REST_RULE=last` for anyone who wants to take that further.
✅ Unaffected by the earlier correction: all **195** zero-scale rest poses are
inside the genuinely-ambiguous 2 305 — none is a single-keyframe element.
### The disc-wide blast radius — measurable even though the rule is not scorable there
Only two ambiguous elements sit on a screen with a live capture, so the rule
cannot be *scored* disc-wide. What can be quantified is what changing it would
do ([`tools/re-capture/rest_rule_blast_radius.py`](../../../tools/re-capture/rest_rule_blast_radius.py),
[data](../data/rest-rule-blast-radius.txt)):
| | dwell (current) | `last` |
|---|---|---|
| the two rules **differ** | **1 896 of 2 305 (82.3 %)** | |
| returns an **invisible** pose | 1 711 (74.2 %) | 1 618 (70.2 %) |
| returns a **zero-scale** pose | **195 (8.5 %)** | **43 (1.9 %)** |
Two things worth reading off this.
**It is not a marginal choice.** The rules disagree on 82 % of the affected
elements, so "either is fine" is not available.
**The current rule produces 4.5× more degenerate poses.** A zero-scale pose is
*collapsed to nothing* ([rat layout](ui-rat-layout.md)) — an element's **pre-roll**,
before it has grown in. A pre-roll is definitionally not a rest, so 195 elements
currently rest at a frame the element is only passing through, against 43 under
`last`. That is an argument from the data's own structure rather than from the
two captures, and it points the same way they do.
🟡 It remains **indirect**: fewer degenerate results is not the same as more
correct results, and `last` still returns an invisible pose 70 % of the time —
which for a transient element may well be right, and for a persistent one would
be wrong. The default stays put.
---
## ✅ Why `rest_plateau` is right, and why `last` is right only for a transient
**2026-08-29.** The shifted keyframe-time reading
([time unit](../ui-keyframe-time-unit.md)) appears to predict something simple:
the final pose is reached at a definite time and nothing follows, so "rest"
should just be the last keyframe, with no heuristic at all. Tested
(`SYLPHEED_REST_RULE=lastall`, bypassing the plateau rule for **every** element):
| screen | current | `lastall` | Δ |
|---|---|---|---|
| title | +0.9500 | +0.6819 | **0.2681** |
| main menu | +0.9460 | +0.6416 | **0.3044** |
| `EXTRAS` | +0.9440 | +0.5745 | **0.3695** |
| publisher splash | +0.9600 | **blank** | — |
| developer splash | +0.9643 | **blank** | — |
The splashes render *entirely empty* — zero variance, so the correlation is not
even defined. **Refuted.**
### What the failure shows
A group's keyframes describe **entry → hold → exit**, and the exit is the
screen's *dismissal*. While a screen is on display it has not reached its last
keyframe; it is sitting at the hold, and the exit plays only when the screen
leaves. That is why:
* `rest_plateau` is the correct primary rule — the hold is where the screen
lives, and it is what a capture of a displayed screen shows;
* the last keyframe is the **post-exit** state, correct only at the moment the
screen is already gone — which is why applying it everywhere blanks the
splashes;
* and it does **not** contradict the shifted time reading. That reading says when
each pose is reached; it says nothing about the group being played to
completion while the screen is still up.
### And it explains why `last` wins for the plateau-less two
An element with **no hold** is a transient: it flashes and is over. At any
settled moment it is *gone*, and "gone" is its last keyframe. That is not a
general rule about rest — it is what a transient's settled state *is*.
The draw capture says exactly this independently: on the developer splash the
`_eff` glows are drawn on frames **94115** and the logos on **116211**. The
glows are already finished when the logos are up, so a capture of the logo
moment must show them invisible — which is what `SYLPHEED_REST_RULE=last`
produces, and why it scored +0.9982 and +0.9758.
✅ So the picture is coherent across three independent observables — animation
timing, static composites, and the per-frame draw log — and the rule it supports
is: **plateau where there is one, last keyframe where there is not.**
### ✅ The structural case for `last`, disc-wide — 2 293 of 2 305
Only two ambiguous elements could be *scored* against a capture. But the
entry → hold → exit model makes a prediction that can be checked on all 2 305:
what does each element's **final** keyframe look like?
([`tools/re-capture/plateauless_endstate.py`](../../../tools/re-capture/plateauless_endstate.py),
[data](../data/plateauless-endstate.txt))
| final keyframe | count | is `last` the right answer? |
|---|---|---|
| **invisible** (`a = 0`) | **1 618** | the element is a transient; at any settled moment it is *gone* — ✅ model-consistent, and confirmed by the draw log for the two observed |
| **visible, at maximum alpha** | **675** | it faded in and stopped — the final pose *is* the settled one ✅ |
| visible, **below** maximum alpha | **12** | ❔ genuinely unclear |
Of the 687 that end visible, **472 have monotonically non-decreasing alpha** — a
plain fade-in that stops, `[0, 255]` over two keyframes in the commonest case
(`pjex_eff.rat`, `pghud_speed_cut.t32`) — and another 203 end at their maximum
after dipping. Only **12 elements in 2 305** end visible at less than full alpha.
So `last` is **structurally defensible for 2 293 of 2 305 (99.5 %)**, against a
dwell rule that returns a mid-movement frame by construction. It is *observed*
correct for 2, *structural* for 675, *model-consistent* for 1 618, and unclear
for 12.
⚠️ The 1 618 carry the weight of an assumption, and it should be visible: that a
plateau-less element's animation has finished by the time the screen is settled.
The draw log establishes this for the two splash glows (drawn on frames 94115,
with the logos on 116211) and nothing establishes it for the rest.
---
## ✅ A static "rest" composite is only meaningful for a screen that SETTLES
**2026-08-29.** The model's sharpest prediction, tested with its control.
The draw log says that on the developer splash the `_eff` glows are drawn on
frames **94115** and the logos on **116211** — so at the moment the reference
capture was taken, *every* glow is already finished, including the two that have
plateaus and which `rest_plateau` therefore renders visible. Suppressing them
should improve the match. Suppressing them on a screen that genuinely settles
should make it worse.
| screen | as-is | `eff` elements suppressed | Δ |
|---|---|---|---|
| **publisher splash** | +0.9604 | **+0.9982** | **+0.0377** |
| **developer splash** | +0.9659 | **+0.9980** | **+0.0321** |
| title *(control)* | +0.9500 | +0.9480 | 0.0020 |
| main menu *(control)* | +0.9460 | +0.8544 | **0.0916** |
| `EXTRAS` *(control)* | +0.9440 | +0.8370 | **0.1070** |
Both splashes jump to **≈0.998**; all three persistent screens get worse. The
control is what makes this a finding rather than a coincidence — the same edit
helps on exactly the screens the model says it should and hurts on exactly the
ones it says it should not.
### What this settles
**`rest_plateau` is not over-drawing in general — it over-draws on transient
screens.** A plateau in the middle of an animation is evidence the element is
held *at that point in the timeline*, not that it is on screen when the screen
has settled. On a screen that settles, the held pose is the settled pose and the
rule is right (the controls). On a screen that plays through and leaves, there is
no settled moment at all, and the reference capture is a picture of one arbitrary
frame.
⚠️ **So the honest answer to "what is the rest pose of a transient screen" is:
there isn't one.** The splashes are animations. A static composite of them can
match a chosen frame — ≈0.998 for the frame these captures hold — but "the
resting pose" is a question the format does not answer because the screen never
rests.
✅ For the port: **play the timeline for the splashes**, which the settled
keyframe timing now supports ([time unit](../ui-keyframe-time-unit.md)). Use a
static composite for the title, main menu and `EXTRAS`, where the screen does
settle and `rest_plateau` is measurably right.
⚠️ **The row above said `SYLPHEED_KF_TIME_SHIFT=1` until 2026-08-30.** That gate
was removed with the record-layout fix and reading it back would have set an inert
variable, produced the DEFAULT row, and let a reader conclude the two readings
agree — a stale instruction inside a results table, which is the form that
manufactures evidence rather than merely misleading. The live equivalent is
`SYLPHEED_KF_TIME_LEGACY=1`, read in `ui_layout.rs:595` (the parser itself, not
only the tests, so it does reach `screen info` and `screen render`).