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

@@ -124,11 +124,39 @@ pub struct Element {
impl Element {
/// Where the element actually rests on screen.
///
/// A keyframe group is an in → hold → out animation, so the resting pose is
/// neither the first nor the last frame: it is the one that **dwells
/// longest** — the largest gap to the next keyframe's time. A single
/// keyframe is its own rest position.
/// A keyframe group is an in → hold → out animation, and the resting pose is
/// **the hold**: the longest run of consecutive keyframes whose pose is
/// identical. A single keyframe is its own rest position.
///
/// This replaced a longest-*dwell* rule — pick the keyframe with the largest
/// gap to the next keyframe's time — which was wrong for a reason worth
/// stating, because it is the whole shape of this format. A keyframe is the
/// **start of a ramp toward the next one**, not a value held until it. So a
/// long gap after keyframe *k* means the screen spends that time *arriving
/// at* `k+1`; the settled pose is at the far end of the gap, not the near
/// one. The title wordmark zooms in over 5 frames and then 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, not argued. Edge-correlating the composite against the
/// framebuffer capture of the running title screen
/// (`docs/re/captures/title-screen-oracle.png`, which is a 1:1 crop, so
/// coordinates map directly):
///
/// | rule | best correlation | at shift |
/// |---|---|---|
/// | plateau (this one) | **0.4597** | **(0, 0)** |
/// | longest dwell (old) | 0.1511 | (+3, +8) |
///
/// The old composite had to be *moved* to line up with the game. See
/// `docs/re/structures/ui-resting-pose.md`.
///
/// Falls back to the longest-dwell rule when no two adjacent keyframes
/// agree — a group that ramps through every frame and never holds.
pub fn rest(&self) -> Option<&Keyframe> {
if let Some(k) = self.rest_plateau() {
return Some(k);
}
match self.keyframes.len() {
0 => None,
1 => self.keyframes.first(),
@@ -154,6 +182,40 @@ impl Element {
}
}
}
/// The hold: the longest run of consecutive keyframes whose pose is
/// identical. `None` when no two adjacent frames agree.
fn rest_plateau(&self) -> Option<&Keyframe> {
let n = self.keyframes.len();
if n < 2 {
return None;
}
let same = |a: &Keyframe, b: &Keyframe| {
a.fade == b.fade
&& a.scale_x == b.scale_x
&& a.scale_y == b.scale_y
&& a.tint == b.tint
&& a.x == b.x
&& a.y == b.y
};
let (mut best_start, mut best_len) = (0usize, 0usize);
let mut i = 0usize;
while i < n {
let mut j = i;
while j + 1 < n && same(&self.keyframes[j], &self.keyframes[j + 1]) {
j += 1;
}
let len = j - i + 1;
// `>=` so a later run of equal length wins, matching the in → hold →
// out reasoning behind the longest-dwell rule's tie-break.
if len >= 2 && len >= best_len {
best_start = i;
best_len = len;
}
i = j + 1;
}
(best_len >= 2).then(|| &self.keyframes[best_start])
}
}
/// A parsed UI build: one screen layout (one context × language).

View File

@@ -109,22 +109,20 @@ fn kind_bit_0x10_means_prm_exactly_and_prm_carries_no_payload() {
eprintln!("{prm} `.prm` elements: all kind&0x10, none with a payload, {full_screen} full-screen");
}
/// **A `.prm` fade quad cannot be drawn at `Element::rest()`** — the pose that
/// rule picks would black out the screen.
/// **A `.prm` fade quad rests at its transparent plateau**, which is what makes
/// it drawable at all.
///
/// The title's `pteff00.prm` is a screen transition: opaque black at t=12,
/// transparent by t=64, held transparent to t=74, opaque black again on the way
/// out. What the title screen sits at is the transparent plateau. `rest()` picks
/// by longest dwell to the next keyframe's time, which lands on an *endpoint* of
/// the fade — an opaque black full-screen quad, painted last in the measured
/// order, i.e. the whole screen.
/// out. What the title screen sits at is the transparent plateau — and this
/// element is painted **last** in the measured order, so getting it wrong means
/// a full-screen opaque black quad over everything.
///
/// This test states the defect rather than hiding it: it asserts the shape of
/// the group (so the reasoning stays checkable) and that `rest()` returns the
/// opaque frame (so that when the resting rule is fixed, this test fails and
/// someone reads the note above instead of rediscovering it).
/// It *was* wrong: the old longest-dwell resting rule picked the opaque
/// endpoint. This test asserted that defect deliberately until the plateau rule
/// replaced it (`docs/re/structures/ui-resting-pose.md`); now it guards the fix.
#[test]
fn the_title_fade_quad_is_a_transition_and_rest_picks_the_wrong_end() {
fn the_title_fade_quad_rests_transparent() {
let Some(root) = disc_root() else {
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
return;
@@ -149,10 +147,9 @@ fn the_title_fade_quad_is_a_transition_and_rest_picks_the_wrong_end() {
);
assert_eq!(
el.rest().map(|k| k.fade),
Some(0xff00_0000),
"rest() still picks an endpoint of the fade — if this now returns \
the transparent plateau the resting rule has been fixed and the \
note on this test is stale"
Some(0x0000_0000),
"the fade quad must rest at its transparent plateau — an opaque \
answer here paints a full-screen black quad over the title"
);
}
}