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.