formats: the resting pose is the hold, not the longest dwell

Element::rest() picked the keyframe with the largest gap to the next keyframe's
time. That reads a keyframe as a value held until the next one; it is the start
of a ramp toward it. A long gap after keyframe k means the screen spends that
time arriving at k+1, so the settled pose is at the far end of the gap.

The title wordmark zooms in over five frames and holds at (184,193) at 100% from
t=251 to t=264. The old rule picked the frame before the long gap: (179,186) at
101%, still mid-zoom.

Measured against the framebuffer capture of the running title screen, which is a
1:1 crop so frame coordinates map directly (confirmed: the copyright line lands
on row 669 in the capture and in both composites). Edge-correlated over the
wordmark box:

    plateau (landed)      best 0.4597 at shift (0,0)
    longest dwell (old)   best 0.1511 at shift (+3,+8), 0.1268 at (0,0)

The old composite scores 3x lower and only peaks after being moved, by about the
(-5,-7) that picking kf4 instead of kf5 predicts.

It also fixes six title elements the old rule rested at alpha 0x00 where the
capture plainly shows them, and pteff00.prm - the full-screen fade quad painted
last - which rested at opaque black. That was the blocker on .prm compositing.

Adds tools/re-capture/align_to_capture.py, which is how this was scored, and
turns the .prm test that deliberately asserted the old defect into a guard on
the fix.

Not settled and now the next item: compose ignores the keyframe fade alpha
entirely (blit modulates by tint only), which is why choosing the wrong keyframe
was invisible until now.
This commit is contained in:
Sylpheed RE agent
2026-08-19 06:41:24 +00:00
parent eb61368d23
commit b5c44b7c2a
6 changed files with 246 additions and 19 deletions

View File

@@ -0,0 +1,103 @@
# 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 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.
## What is not settled
***`compose` ignores the keyframe `fade` alpha entirely.** `blit` uses
`tint`. Applying `fade` is the obvious next step and is what makes the resting
pose visible rather than academic — but it changes every screen and needs its
own A/B against the capture. It is also the prerequisite for drawing `.prm`
quads, whose entire content is that alpha.
* 🟡 **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.