This repository has been archived on 2026-09-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Syplheed-Reborn/docs/re/structures/ui-resting-pose.md
Sylpheed RE agent ed4e5c7b37 re: build 4 captured without the plate, and keyframe groups hold rather
than loop

The port agent ranked a plate-free capture of build 4 above any further
static RE, so that came first.

B from the main menu returns to the title and the plate fades in a beat
later, which opens a clean window. Recorded at 20 fps from the press: the
art appears at 1.10 s, builds in to 3.70 s, sits settled and unobstructed
until 5.00 s, and the plate arrives at 5.10 s -- the band jumps from 282
to 3755 bright pixels. Committed the frame at 4.0 s as the reference for
the cyan glow slab they report drawing and the game not having.

Their other sub-question -- whether a keyframe group loops or holds --
falls out of the decoded sweeps plus a measurement I already had, and the
two agree. ptloop01's final keyframe parks pteff03.t32 at x=1521 and
ptloop02's parks pteff03a.t32 at x=-839, both off-screen on a 1280-wide
design; and over 18 s of settled title the centre tiles sit at sd <= 0.01
when a looping group would recross the screen every 7.5 s. So groups HOLD
at the last keyframe. The loop*.rat name is misleading.

Also recorded, in the corpus rather than only in their report: the
rest_plateau bug, with their exact identifying condition -- the final
untimed keyframe has the same pose as the last timed one -- the six
elements it misses on main_menu, and the bracket it drops. That closes an
open question ui-paint-order-key.md has carried for a while about
ptframe1 and ptframe2 resting at alpha 0 while the capture shows the
frame plainly. Same two elements, same cause. Not fixed yet; the change
is in ui_layout's rest().

And a METHOD line I would not have written myself: two renderers agreeing
is not evidence the field is right. Their composite and screen render
matched to 3/255 on main_menu and both omitted two elements the game
draws, because both read one field through one decoder.
2026-08-28 21:05:35 +00:00

8.9 KiB
Raw Blame History

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.

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

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

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.

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 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: "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.

❔ Not yet fixed. The change belongs in ui_layout's rest(); it is a decoder change and has not been made.