From 5393c0be02386d31716c895cbef72efc42b0cefb Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Sat, 29 Aug 2026 22:00:48 +0000 Subject: [PATCH] port: test backdrop coverage per instant -- scale animates, and the disc proves it The Decoder found forced_backdrop judged coverage from declared size alone. pbafc.prm declares 844x600 at alpha ff and draws ~17x18 px at 2%x3% scale -- a rule reading declared size would call it screen-covering. scale_at interpolates on the same ramp as the fade, and coverage is folded into the opaque-instant test: an instant counts only where the element is BOTH alpha 255 and covering. The previous code tested the two halves at different times. The size prefilter deliberately no longer rejects, since an element scaled above 100 could cover from a smaller declared size. No verdict moves: 6 forced before and after, 16 screens validate, oracle figures identical to the digit. Nothing in GP_TITLE has a non-100 scale on any keyframe. It is in because the data that would break it exists, not because it failed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF --- crates/sylpheed-export/src/screen.rs | 59 ++++++++++++++++++++++++++-- docs/port/DECISIONS.md | 56 ++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 4 deletions(-) diff --git a/crates/sylpheed-export/src/screen.rs b/crates/sylpheed-export/src/screen.rs index d8457771..ba1381ae 100644 --- a/crates/sylpheed-export/src/screen.rs +++ b/crates/sylpheed-export/src/screen.rs @@ -684,6 +684,35 @@ fn alpha_at(e: &Element, t: i64) -> u8 { a(timed[timed.len() - 1]) as u8 } +/// Scale of one element at instant `t`, in percent per axis, under the same +/// linear ramp as the fade. Interpolated rather than stepped, because a scale +/// that animates passes through every value between its keyframes. +fn scale_at(e: &Element, t: i64) -> [f64; 2] { + let timed: Vec<&Keyframe> = e.keyframes.iter().filter(|k| k.t.is_some()).collect(); + if timed.is_empty() { + return [100.0, 100.0]; + } + let g = |k: &Keyframe, i: usize| k.scale[i] as f64; + if t <= timed[0].t.unwrap() as i64 { + return [g(timed[0], 0), g(timed[0], 1)]; + } + for w in timed.windows(2) { + let (t0, t1) = (w[0].t.unwrap() as i64, w[1].t.unwrap() as i64); + if t < t1 { + if t1 <= t0 { + return [g(w[0], 0), g(w[0], 1)]; + } + let f = (t - t0) as f64 / (t1 - t0) as f64; + return [ + g(w[0], 0) + (g(w[1], 0) - g(w[0], 0)) * f, + g(w[0], 1) + (g(w[1], 1) - g(w[0], 1)) * f, + ]; + } + } + let l = timed[timed.len() - 1]; + [g(l, 0), g(l, 1)] +} + /// Move a full-screen opaque primitive to the FRONT of the paint order when the /// file forces it there. /// @@ -754,9 +783,11 @@ fn forced_backdrop_first(order: Vec, elements: &[Element], design: [u32; // full-screen element here is `.prm` and pure black, checked -- so // this changes no verdict today and is a guard against a corpus // that grows. - e.role == "primitive" - && e.sprite.is_none() - && e.size.is_some_and(|s| s[0] as u32 >= design[0] && s[1] as u32 >= design[1]) + // Cheap prefilter only -- the binding coverage test is per-instant, + // in `covers` below. An element scaled ABOVE 100 could cover the + // screen from a smaller declared size, so this deliberately does + // not reject on size. + e.role == "primitive" && e.sprite.is_none() && e.size.is_some() }) .filter(|(i, e)| { let span: Vec = e @@ -766,6 +797,24 @@ fn forced_backdrop_first(order: Vec, elements: &[Element], design: [u32; .map(i64::from) .collect(); let Some(&lo) = span.first() else { return false }; + // 🔴 COVERAGE IS TESTED AT EACH INSTANT, NOT ONCE FROM `size`. + // Declared size alone is not what the element draws: scale is a + // percent per axis and it animates. `pbafc.prm` is the disc's own + // counterexample -- declared 844x600, scaled 2 % x 3 %, so it draws + // about 17x18 px, a moving glint rather than a wash. A rule that + // read its declared size would call it screen-covering. + // + // Nothing in GP_TITLE needs this: every layerless full-screen + // element here is at scale 100 on every keyframe, so no verdict + // moves. It is in because the data that would break it exists on + // this disc, which is a better reason than a failure would have been. + let covers = |t: i64| { + let sc = scale_at(e, t); + e.size.is_some_and(|s| { + s[0] as f64 * sc[0] / 100.0 >= design[0] as f64 + && s[1] as f64 * sc[1] / 100.0 >= design[1] as f64 + }) + }; // An element HOLDS ITS FINAL POSE to the end of the screen -- it does // not vanish at its own last keyframe. `palogo_eff0.prm` is the case // that shows why: it declares ONE keyframe, opaque black full-screen @@ -773,7 +822,9 @@ fn forced_backdrop_first(order: Vec, elements: &[Element], design: [u32; // a single-instant event instead of the thing that is on screen for // the whole splash. So the span runs to the SCREEN's last keyframe. let hi = screen_end.max(*span.last().unwrap()); - let opaque: Vec = (lo..=hi).filter(|&t| alpha_at(e, t) == 255).collect(); + let opaque: Vec = (lo..=hi) + .filter(|&t| alpha_at(e, t) == 255 && covers(t)) + .collect(); if opaque.is_empty() { return false; } diff --git a/docs/port/DECISIONS.md b/docs/port/DECISIONS.md index 3ba30d48..a14c8941 100644 --- a/docs/port/DECISIONS.md +++ b/docs/port/DECISIONS.md @@ -5446,3 +5446,59 @@ it is correct by construction if the corpus grows. ⚠️ Not adopted from their message: their reading that the blend question now narrows to `pbafc.prm`. That is theirs to settle and the port draws no additive quad either way. + +## Coverage is now tested per instant, because scale animates + +The Decoder found that `forced_backdrop` judged screen coverage from the declared +size alone, ignoring scale — and the disc carries its own counterexample. +`pbafc.prm` declares **844×600 at alpha `ff`**, which reads as a screen-filling +cyan wash; it is scaled **2 % × 3 %** and draws about **17×18 px**, strobing and +travelling x=178→291. A moving glint. A rule reading its declared size would call +it screen-covering. + +The port had the same gap and it is closed. `scale_at` interpolates scale on the +same linear ramp as the fade, and coverage is folded **into the opaque-instant +test** rather than checked once: an instant counts only where the element is both +alpha 255 *and* covering. That is the rule's own wording — "covers the screen +**and** is fully opaque **at some instant**" — where the previous code tested the +two halves at different times. + +The static size prefilter is now deliberately *not* a rejection: an element scaled +**above** 100 could cover the screen from a smaller declared size, so rejecting on +declared size would have replaced one version of the bug with its mirror. + +✅ **No verdict moves.** Six forced elements before and after; 16 screens validate; +the oracle figures are identical to the digit (`publisher_logo` 0.01 %, +`developer_logos` 0.01 %, `main_menu` 0.07 %, `extras` 0.19 %, `title` 0.26 %). +Their claim that all 80 forced instances sit at scale 100 reproduces on the +GP_TITLE subset, and more strongly: **no layerless full-screen element anywhere in +this archive has a non-100 scale on any keyframe.** + +It is in for the reason they gave, which is the right one: the data that would +break it demonstrably exists on this disc. That is a better argument than a +failure would have been, because it does not require the bug to happen first. + +### Their blend-robustness argument, checked + +They classify the blend mode **undecodable with reach** and argue the rule does +not depend on it, for a black quad: + +| | drawn **first** | drawn **last** | +|---|---|---| +| alpha-over, α=255 | correct | blanks the screen | +| additive, α=255 | correct — adds nothing | correct | + +The table holds. An additive black quad contributes nothing at any position, so +both orders are correct under it; only alpha-over distinguishes them, and it +picks *first*. **"First" is right under both hypotheses, "last" under one** — so +`forced_backdrop`'s verdict is robust to a question neither of us can close. + +It also explains a detail of the original bug that I had not accounted for: +"layerless sorts last" was *wrong* under alpha-over and merely *pointless* under +additive, which is why those screens came out **solid black** rather than +**empty**. The symptom was diagnostic of the blend mode all along. + +⚠️ Not evidence that the blend is alpha-over, and I am not recording it as such. +It is the reason the port can stop waiting on it. `pbafc.prm` remains the sole +additive candidate and is outside the rule at 17×18 px; the port draws no +additive quad either way.