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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user