VISIBLE, not when its pose repeats The port agent's report was right about the defect and about which elements it hits, and its proposed condition does not survive the case the exclusion was written for. Their test -- the final untimed keyframe has the same pose as the last timed one -- is also true of pgptitle.rat, whose trailing run is two identical transparent frames. Adopting it would erase the word PAUSE again, which a committed capture disproves. What separates the two is alpha: an exit fades the element out so its last keyframe is transparent, while an element with no exit ends on the pose you can see. So rest_plateau now accepts a trailing run exactly when it is visible. Verified against a capture rather than against another renderer, which is the point their own report made: ptframe1's rest moves from (620,108) t=16 to (440,108) t=62; the changed pixels are 10082 in a bounding box of x 440-839, y 108-577, which is exactly the 400x470 at (440,108) they predicted; and correlation against main-menu-oracle.png over that region improves from 0.9596 to 0.9748. The PAUSE wordmark is unchanged across all three pause builds. This also closes a question ui-paint-order-key.md has carried for a while, that ptframe1 and ptframe2 rest at alpha 0 while the capture shows the menu frame plainly. One trap cost most of this iteration and goes in METHOD: CARGO_TARGET_DIR is redirected in this container, so ./target/debug/sylpheed-cli is hours stale and every render I made against it was old code. Byte-identical before and after reads as "no effect" when it actually means "you ran the old binary". The full disc-gated test suite was still executing when this was committed; the verification above is artifact-based.
223 lines
10 KiB
Markdown
223 lines
10 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) |
|
||
|
||
🟡 The full disc-gated `build-reborn test` run was **still executing when this was
|
||
committed**; the verification above is artifact-based.
|