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