Follows up the defect found last iteration: rest()'s dwell fallback is
guessing whenever it runs. Two things were open -- how big it is, and
whether "rest = the last keyframe" is the fix. Both are now answered, and
the second is answered no.
plateau_census.py walks the placement regions directly instead of going
through `screen info --geometry`, which decodes every texture and cannot
do a disc-wide pass in reasonable time. Its control reproduces GP_TITLE
build 7's three fallback elements and names ptlogo_eff3.t32 among them
before counting anything.
elements with a keyframe group 15 493
no plateau -> rest pose is guessed 3 807 (24.57 %)
... current rule returns invisible 1 711 (44.9 %)
... current rule returns scale=0 195 ( 5.1 %)
the two candidate rules agree 1 911 (50.2 %)
195 elements get a rest pose with scale 0%, which is not a pose. And
disc-wide the choice of rule is not cosmetic: the candidates agree half
the time.
But the port's exposure is one element. Across main menu, EXTRAS, title
and the developer splash, 14 elements are plateau-less and the two rules
agree on 13. The single disagreement is palogo_anima_eff.t32.
And "last keyframe" loses there, on a control that needed no new capture:
the splash carries three sibling glows with identical structure and
identical times --
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
-- differing in one byte. "Last keyframe" makes anima alone invisible
while its two siblings stay lit. The capture agrees weakly: box-mean
ratios capture/render are gamearts 0.717, seta 0.723, anima 0.772, and a
glow we drew that the game does not would put anima below its siblings,
not above.
So the defect is measured and the fix is still undecided. Nothing in the
decoder changed.
18 KiB
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 whilefadeis 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.
✅ 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 |
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) 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
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.
| elements with a keyframe group, disc-wide | 15 493 |
| no plateau → rest pose is guessed | 3 807 (24.57 %) |
| 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
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 24.57 % 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.