The shifted keyframe-time reading looked like it implied something simple: the final pose is reached at a definite time and nothing follows, so rest should just be the last keyframe and the plateau heuristic could go. Tested by applying it to EVERY element: 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 (zero variance, corr undefined) developer splash +0.9643 -> blank Refuted, and the failure supplies the model. A group is entry -> hold -> exit, and the exit is the screen's DISMISSAL. While a screen is displayed it has not reached its last keyframe; it is sitting at the hold. So rest_plateau is the correct primary rule, and the last keyframe is the post-exit state -- correct only once the screen is gone, which is why applying it everywhere blanks the splashes. This does not contradict the shifted 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. The step between them was mine. And it explains why last wins for the two plateau-less elements: an element with no hold is a transient, it flashes and is over, and at any settled moment it is gone -- which is its last keyframe. The draw capture says the same independently: on the developer splash the _eff glows draw on frames 94-115 and the logos on 116-211, so the glows are already finished when the logos are up. Three independent observables -- animation timing, static composites, and the per-frame draw log -- now agree on one rule: plateau where there is one, last keyframe where there is not. METHOD: a blank render is a NaN correlation, not a low score, and that NaN was the strongest form of the result; and when a model predicts something the measurement refuses, suspect the step you supplied between them.
577 lines
26 KiB
Markdown
577 lines
26 KiB
Markdown
# A keyframe is the start of a ramp, not a pose that is held
|
||
|
||
**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 kf5–kf6.
|
||
|
||
## 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 150–1150, y 200–400) 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 440–839, y 108–577** — 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
|
||
|
||
`GP_TITLE` build 7, `ptlogo_eff3.t32` — a transient bloom:
|
||
|
||
```
|
||
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
|
||
```
|
||
|
||
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_SHIFT=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.
|
||
|
||
### 🔴 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 **94–115** and the logos on **116–211**. 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.**
|