diff --git a/crates/sylpheed-export/src/screen.rs b/crates/sylpheed-export/src/screen.rs index 8147dc93..c808270c 100644 --- a/crates/sylpheed-export/src/screen.rs +++ b/crates/sylpheed-export/src/screen.rs @@ -583,6 +583,11 @@ pub fn export_build( buttons.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1))); let window = settle_window(&elements); + let order = forced_backdrop_first( + ui_layout::derived_paint_order(&b, bundle), + &elements, + [b.design_w, b.design_h], + ); let screen = Screen { format: "sylpheed.screen/3", exporter: exporter.to_string(), @@ -597,7 +602,7 @@ pub fn export_build( name_why, design: [b.design_w, b.design_h], elements, - paint_order: ui_layout::derived_paint_order(&b, bundle), + paint_order: order, buttons: buttons.into_iter().map(|(_, n)| n).collect(), settle_window: window, unresolved: vec![ @@ -652,3 +657,115 @@ fn settle_window(elements: &[Element]) -> Option<[i64; 3]> { .max_by_key(|(a, b)| b - a)?; Some([a, b, (a + b) / 2]) } + + +/// Alpha of one element at instant `t`, under the linear ramp the port uses. +fn alpha_at(e: &Element, t: i64) -> u8 { + let ks = &e.keyframes; + let a = |k: &Keyframe| (u32::from_str_radix(k.fade_argb.trim_start_matches("0x"), 16) + .unwrap_or(0) >> 24) as i64; + let timed: Vec<&Keyframe> = ks.iter().filter(|k| k.t.is_some()).collect(); + if timed.is_empty() { + return 0; + } + if t <= timed[0].t.unwrap() as i64 { + return a(timed[0]) as u8; + } + 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 a(w[0]) as u8; + } + let f = (t - t0) as f64 / (t1 - t0) as f64; + return (a(w[0]) as f64 + (a(w[1]) - a(w[0])) as f64 * f).round() as u8; + } + } + a(timed[timed.len() - 1]) as u8 +} + +/// Move a full-screen opaque primitive to the FRONT of the paint order when the +/// file forces it there. +/// +/// 🔴 **The rule is a constraint, not a preference**, and it is the Decoder's: +/// *an element that covers the screen and is fully opaque at some instant cannot +/// paint above anything visible at that instant; where the elements visible +/// during its opaque span are ALL of them, its position is forced to first.* +/// +/// It was found because `build_12`/`build_15` are **black at every instant** of +/// their declared timeline under the old rule — `pgloading_eff00` is opaque for +/// 39 instants while all 9 other elements live and die inside that span. A +/// screen that is black for its whole life is impossible on its face, which is +/// the only kind of check that survives two renderers sharing an assumption: +/// `sylpheed-cli` agreed with the port here because it agreed about +/// `implied_layer_key`. +/// +/// Two measured controls, both prior orders off the running game: +/// +/// | primitive | measured | opaque instants | forced below | | +/// |---|---|---|---|---| +/// | `palogo_eff0.prm` | **first** | 211 | 6 of 6 | ✅ forced | +/// | `pteff00.prm` | **last** | 2 | 3 of 23 | ✅ permitted on top | +/// +/// ⚠️ **Do NOT reduce this to a name heuristic.** `*base*` first / `*eff*` last +/// matches 77 of 80 and fails on exactly the three families that cross it — +/// `palogo_eff0`, `pgloading_eff00`, `pzeff00`. `palogo_eff0.prm` is *named like +/// an overlay* and is measured painting first. The name is not the rule. +/// +/// 🔴 **And it is restricted to elements with NO SPRITE**, which is the limit +/// that the rule's own disc-wide test caught: applied to sprites it claimed 22 +/// `.t32` textures must sort first *against their own layer keys*. **An +/// element's alpha says nothing about whether its texture covers the screen** — +/// most of a sprite may be transparent. +/// +/// ⚠️ Reach: assumes straight alpha-over. Blend mode is undecoded, and an +/// additive quad at alpha 255 would not occlude. It is a lower bound, not an +/// ordering — it says nothing about elements that are constrained but not +/// forced. Delete this when a pinned `sylpheed-formats` does it. +fn forced_backdrop_first(order: Vec, elements: &[Element], design: [u32; 2]) -> Vec { + let screen_end: i64 = elements + .iter() + .flat_map(|e| e.keyframes.iter().filter_map(|k| k.t)) + .map(i64::from) + .max() + .unwrap_or(0); + let forced: Vec = elements + .iter() + .enumerate() + .filter(|(_, e)| { + e.sprite.is_none() + && e.size.is_some_and(|s| s[0] as u32 >= design[0] && s[1] as u32 >= design[1]) + }) + .filter(|(i, e)| { + let span: Vec = e + .keyframes + .iter() + .filter_map(|k| k.t) + .map(i64::from) + .collect(); + let Some(&lo) = span.first() else { return false }; + // 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 + // at t=0, and reading its span as `0..=0` makes the splash's backdrop + // 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(); + if opaque.is_empty() { + return false; + } + // Every OTHER element must be visible somewhere inside that span. + elements.iter().enumerate().all(|(j, o)| { + j == *i || opaque.iter().any(|&t| alpha_at(o, t) > 0) + }) + }) + .map(|(i, _)| i) + .collect(); + if forced.is_empty() { + return order; + } + let mut out = forced.clone(); + out.extend(order.into_iter().filter(|i| !forced.contains(i))); + out +} diff --git a/docs/port/DECISIONS.md b/docs/port/DECISIONS.md index 136ae6bb..7c7dbca5 100644 --- a/docs/port/DECISIONS.md +++ b/docs/port/DECISIONS.md @@ -4894,3 +4894,102 @@ is the least likely to contain that instrument's blind spot. Nothing in the port changes: `eff3` was never dropped, and the developer splash still draws three sprites. + +## The forced backdrop: two of sixteen screens were black for their whole life + +`build_12` and `build_15` — the two dressed loading screens — rendered as **pure +black at every instant of their declared timeline**. Not at rest, where a wrong +`rest.t` could explain it: at t = 20, 30, 36, 40, 42, 44, 46 and 50 units with +the timeline *playing*, mean 0 in every frame. + +That is not a defect you can attribute to a pose. A screen that is black for its +entire life is impossible on its face, and it is the kind of impossibility that +survives a render-vs-render diff: `verify-screen` scored those two rows +`max 0 mean 0 over3 0 OK` — **the strongest verdict that script has, awarded for +comparing nothing against nothing.** Both renderers were black because both +share `implied_layer_key`. The blank guard now in `verify-screen` was written +after that, and it is what turned the pass into a row that says it proves +nothing. + +### The rule, and whose it is + +It is the **Decoder's**, decoded from the file rather than inferred from the +render: + +> An element that covers the screen and is **fully opaque** at some instant +> cannot paint above anything visible at that instant. Where the elements +> visible during its opaque span are **all** of them, its position is forced to +> first. + +`pgloading_eff00` is a full-screen quad at alpha 255 from t=0 to t=38, clearing +at t=48; every other element on those screens peaks around t=8–32 and is gone by +t=32–40 — entirely inside the opaque span. Under a layer-key sort it painted +over all nine of them, at every instant they existed. Hence black. + +### What is implemented, and the two limits that are not negotiable + +`forced_backdrop_first` in `crates/sylpheed-export/src/screen.rs`, as a post-pass +over `ui_layout::derived_paint_order`. Two restrictions are copied from the +Decoder verbatim because each one was found by a test that failed: + +* 🔴 **Elements with no sprite only.** Applied to sprites, the rule claimed 22 + `.t32` textures must sort first *against their own layer keys*. An element's + alpha says nothing about whether its **texture** covers the screen — most of a + sprite may be transparent. The assertion that caught this was one the Decoder + had nearly deleted as over-strict. +* 🔴 **Not a name heuristic.** `*base*` first / `*eff*` last matches 77 of 80 and + fails on exactly the three families that cross it: `palogo_eff0`, + `pgloading_eff00`, `pzeff00`. `palogo_eff0.prm` is named like an overlay and is + *measured* painting first. The name is not the rule; occlusion is. + +⚠️ Reach: it assumes straight alpha-over. Blend mode is undecoded, and an +additive quad at alpha 255 would not occlude. It is a **lower bound on one +element's position**, not an ordering — 80 elements are forced, 50 are +constrained but not forced, and this says nothing about those 50. + +### The controls + +Both are the Decoder's prior measurements off the running game. No new oracle run +was made for this change, by either agent. + +| primitive | measured | our opaque instants | outcome | +|---|---|---|---| +| `palogo_eff0.prm` | **first** | 256 (they measured 211) | ✅ forced first | +| `pteff00.prm` | **last** | **2** (they measured 2) | ✅ still last | + +`pteff00` is the one that would break if this were implemented as "push every +layerless element down". It is the fade cover: opaque at t=0 and again at t=269, +its screen's entry and exit, and transparent for the 253 instants between. The +constraint never binds it, and it remains last on all four title-family screens. + +The `palogo_eff0` count differs — 256 against 211 — because we take the opaque +span to the **screen's** last keyframe (255) and they stop at 210. It changes no +verdict here, since the element is opaque across the whole span either way, but +the two spans are not the same span and only one of them can be the screen's. +Filed in BLOCKED. + +An element **holds its final pose to the end of the screen**; it does not vanish +at its own last keyframe. Reading `palogo_eff0`'s span as `0..=0` — it declares a +single keyframe — would make the splash's backdrop a one-instant event rather +than the thing on screen for the whole splash. Rendering `build_12` confirms the +hold directly: the frame is constant from t=30 to t=60 with the timeline running. + +### What changed, measured + +* `build_12`/`build_15`: mean 0 at every instant → ramps in over t=0…30 and + holds (mean 1.95, max 214.5). The two BLANK rows are gone from `verify-screen`. +* The splashes are unmoved against the **oracle**: `publisher_logo` 0.01 %, + `developer_logos` 0.01 % differing region, unchanged before and after. + ⚠️ That is **non-regression, not confirmation** — `verify-capture` poses at the + settle instant, and the ordering does not necessarily bind there. The evidence + for the rule is the Decoder's two controls and the impossibility of a + permanently black screen, not this row. +* Six `verify-screen` rows now DIFFER: the six screens the rule touches. The + reference `sylpheed-cli` builds from the workspace `sylpheed-formats`, which + does not have the rule. **That disagreement is expected and must not be tuned + away** — it ends when a pinned tag carries the Decoder's change, at which point + this post-pass is deleted rather than kept in two places. + +It also explains 36 builds the Decoder had filed as "coming out one colour": +`pzeff00.prm` is forced first in 32 of 32 of them. Those were wiped by our own +sort.