port: implement the Decoder's forced-backdrop rule; two screens were black for their whole life

build_12 and build_15 rendered mean 0 at every instant of a PLAYING timeline,
and verify-screen scored both OK -- two renderers sharing implied_layer_key,
comparing nothing against nothing.

Implements the constraint as a post-pass over ui_layout::derived_paint_order,
with both of the Decoder's limits copied verbatim: layerless elements only (a
sprite's element alpha says nothing about its texture's coverage) and NOT a
name heuristic (palogo_eff0 is named like an overlay and paints first).

Both controls reproduce: palogo_eff0 forced first, pteff00 still last on all
four title screens at exactly 2 opaque instants. Splashes unmoved against the
oracle at 0.01%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
This commit is contained in:
Sylpheed port agent
2026-08-29 21:11:23 +00:00
parent d0f6dfa861
commit 0653230983
2 changed files with 217 additions and 1 deletions

View File

@@ -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<usize>, elements: &[Element], design: [u32; 2]) -> Vec<usize> {
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<usize> = 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<i64> = 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<i64> = (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
}

View File

@@ -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=832 and is gone by
t=3240 — 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.