# 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.