From bff162f0654dc177e851079a035f3952387889a3 Mon Sep 17 00:00:00 2001 From: Sylpheed RE agent Date: Wed, 19 Aug 2026 06:41:24 +0000 Subject: [PATCH] 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. --- crates/sylpheed-formats/src/ui_layout.rs | 70 +++++++++++- .../tests/ui_prm_primitives_disc.rs | 27 ++--- docs/re/structures/ui-resting-pose.md | 103 ++++++++++++++++++ tools/re-capture/align_to_capture.py | 65 +++++++++++ 4 files changed, 246 insertions(+), 19 deletions(-) create mode 100644 docs/re/structures/ui-resting-pose.md create mode 100755 tools/re-capture/align_to_capture.py diff --git a/crates/sylpheed-formats/src/ui_layout.rs b/crates/sylpheed-formats/src/ui_layout.rs index ba0d1c04..bd04c9e5 100644 --- a/crates/sylpheed-formats/src/ui_layout.rs +++ b/crates/sylpheed-formats/src/ui_layout.rs @@ -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). diff --git a/crates/sylpheed-formats/tests/ui_prm_primitives_disc.rs b/crates/sylpheed-formats/tests/ui_prm_primitives_disc.rs index 01d8e28b..6c0c6b39 100644 --- a/crates/sylpheed-formats/tests/ui_prm_primitives_disc.rs +++ b/crates/sylpheed-formats/tests/ui_prm_primitives_disc.rs @@ -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" ); } } diff --git a/docs/re/structures/ui-resting-pose.md b/docs/re/structures/ui-resting-pose.md new file mode 100644 index 00000000..68ea026d --- /dev/null +++ b/docs/re/structures/ui-resting-pose.md @@ -0,0 +1,103 @@ +# 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. + +## What is not settled + +* ❔ **`compose` ignores the keyframe `fade` alpha entirely.** `blit` uses + `tint`. Applying `fade` is the obvious next step and is what makes the resting + pose visible rather than academic — but it changes every screen and needs its + own A/B against the capture. It is also the prerequisite for drawing `.prm` + quads, whose entire content is that alpha. +* 🟡 **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. diff --git a/tools/re-capture/align_to_capture.py b/tools/re-capture/align_to_capture.py new file mode 100755 index 00000000..b9aac4e1 --- /dev/null +++ b/tools/re-capture/align_to_capture.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Score a composite against a framebuffer capture of the running game. + +Edge-correlates the two over a region and reports the best shift. A composite +that is *right* peaks at (0,0); one that is displaced peaks somewhere else and +scores lower, which is how the resting-pose rule was settled +(docs/re/structures/ui-resting-pose.md). + +Colour is deliberately discarded — the capture and the composite differ in +palette (undecoded materials, a different animation moment for the background), +so a raw pixel diff is dominated by things the geometry question does not care +about. Gradient magnitude keeps the edges, which is where placement lives. + +Both images must share an origin. A capture that is a 1:1 *crop* of the frame +is fine; a scaled one is not, and must be resampled first. + + align_to_capture.py CAPTURE COMPOSITE [COMPOSITE...] [--region X0 Y0 X1 Y1] +""" +import argparse + +import numpy as np +from PIL import Image + + +def edges(path): + a = np.asarray(Image.open(path).convert("L"), dtype=float) + gy, gx = np.gradient(a) + return np.hypot(gx, gy) + + +def norm(a): + return (a - a.mean()) / (a.std() + 1e-9) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("capture") + ap.add_argument("composite", nargs="+") + ap.add_argument("--region", nargs=4, type=int, metavar=("X0", "Y0", "X1", "Y1"), + default=[150, 200, 1150, 400], + help="frame-space box to score over (default: the title wordmark)") + ap.add_argument("--radius", type=int, default=14, help="max shift searched, px") + args = ap.parse_args() + + x0, y0, x1, y1 = args.region + ref = norm(edges(args.capture)[y0:y1, x0:x1]) + r = args.radius + for path in args.composite: + img = edges(path) + best = (-2.0, 0, 0) + for dy in range(-r, r + 1): + for dx in range(-r, r + 1): + pat = img[y0 + dy:y1 + dy, x0 + dx:x1 + dx] + if pat.shape != ref.shape: + continue + s = float((ref * norm(pat)).mean()) + if s > best[0]: + best = (s, dx, dy) + zero = float((ref * norm(img[y0:y1, x0:x1])).mean()) + print(f"{path}: best {best[0]:.4f} at ({best[1]:+d},{best[2]:+d}) " + f"at (0,0): {zero:.4f}") + + +if __name__ == "__main__": + main()