diff --git a/crates/sylpheed-formats/src/ui_layout.rs b/crates/sylpheed-formats/src/ui_layout.rs index bd04c9e5..cc2edf16 100644 --- a/crates/sylpheed-formats/src/ui_layout.rs +++ b/crates/sylpheed-formats/src/ui_layout.rs @@ -185,6 +185,19 @@ impl Element { /// The hold: the longest run of consecutive keyframes whose pose is /// identical. `None` when no two adjacent frames agree. + /// + /// **A run that ends on the last keyframe is the exit, not the hold.** A + /// group carries the screen's entry animation *and* its exit, so the shape + /// is pre-roll → ramp in → **hold** → ramp out → post-roll, and the last two + /// of those are often plateaus themselves. The pause menu's `pgptitle.rat` + /// has three runs of two — invisible, visible, invisible — and taking the + /// last one erased the word PAUSE, which the capture of the running game + /// plainly shows (`captures/ui-layout/pause-tutorial-real-vs-rebuilt.png`). + /// So trailing runs are excluded, and only fall back to if there is nothing + /// else. + /// + /// Among the runs that remain, longest wins and a **later** run breaks a tie: + /// the pre-roll comes first and the hold after it. fn rest_plateau(&self) -> Option<&Keyframe> { let n = self.keyframes.len(); if n < 2 { @@ -198,7 +211,10 @@ impl Element { && a.x == b.x && a.y == b.y }; - let (mut best_start, mut best_len) = (0usize, 0usize); + // (start, len) of the best run that does not end on the last keyframe, + // and separately of the best run overall. + let (mut best, mut best_len) = (None, 0usize); + let (mut any, mut any_len) = (None, 0usize); let mut i = 0usize; while i < n { let mut j = i; @@ -206,15 +222,19 @@ impl Element { j += 1; } let len = j - i + 1; - // `>=` so a later run of equal length wins, matching the in → hold → - // out reasoning behind the longest-dwell rule's tie-break. - if len >= 2 && len >= best_len { - best_start = i; - best_len = len; + if len >= 2 { + if len >= any_len { + any = Some(i); + any_len = len; + } + if j != n - 1 && len >= best_len { + best = Some(i); + best_len = len; + } } i = j + 1; } - (best_len >= 2).then(|| &self.keyframes[best_start]) + best.or(any).map(|k| &self.keyframes[k]) } } @@ -812,11 +832,23 @@ fn blit( // Keep the pivot point fixed as the element scales. let ox = kf.x - (pivot_x as i32 * (sx_pct as i32 - 100)) / 100; let oy = kf.y - (pivot_y as i32 * (sy_pct as i32 - 100)) / 100; + // Two modulate colours multiply into one: `tint` (RGBA, and `0xffffffff` on + // essentially every keyframe seen) and `fade` (**ARGB** — the high byte is + // the alpha that ramps, the low 24 bits a colour multiply that is `0xffffff` + // on 5 276 of the disc's 5 453 resting keyframes). The byte order is not a + // guess: it is the high byte that walks 0x00 → 0x80 → 0xc0 → 0xe0 → 0xff + // across a fade-in while the low three stay `ffffff`. + let (fa, fr, fg, fb) = ( + (kf.fade >> 24) & 0xff, + (kf.fade >> 16) & 0xff, + (kf.fade >> 8) & 0xff, + kf.fade & 0xff, + ); let (tr, tg, tb, ta) = ( - (kf.tint >> 24) & 0xff, - (kf.tint >> 16) & 0xff, - (kf.tint >> 8) & 0xff, - kf.tint & 0xff, + ((kf.tint >> 24) & 0xff) * fr / 255, + ((kf.tint >> 16) & 0xff) * fg / 255, + ((kf.tint >> 8) & 0xff) * fb / 255, + (kf.tint & 0xff) * fa / 255, ); for row in 0..dh { let ty = oy + row as i32; diff --git a/crates/sylpheed-formats/tests/ui_paint_order_disc.rs b/crates/sylpheed-formats/tests/ui_paint_order_disc.rs index e89ab0a7..40ada1be 100644 --- a/crates/sylpheed-formats/tests/ui_paint_order_disc.rs +++ b/crates/sylpheed-formats/tests/ui_paint_order_disc.rs @@ -617,3 +617,60 @@ fn the_developer_logo_splash_composes_with_its_glows() { } assert!(seen >= 2, "found {seen} splash bundles, expected the language pair"); } + +/// Applying the keyframe's `fade` alpha must not gut the corpus. +/// +/// It is a modulate, and it can only ever *remove* pixels, so the risk it +/// carries is a screen going blank. Measured: it is a no-op on 4 060 of the +/// 5 200 sprite elements (alpha `0xff`), partial on 453, and hides 687 — which +/// are transient HUD indicators (`pb_emergency`, `pb_refilling`, the target +/// arrows) that should not be lit on a resting screen. **No build is left with +/// nothing visible.** +#[test] +fn applying_the_fade_alpha_blanks_no_screen() { + skip_without_disc!(root); + let (mut sprite_els, mut hidden, mut noop) = (0usize, 0usize, 0usize); + let (mut builds, mut blank) = (0usize, 0usize); + for_each_build(&root, |pak, bytes| { + let Some(b) = ui_layout::parse_build(bytes) else { + return; + }; + if b.from_fallback { + return; + } + builds += 1; + let mut visible = 0usize; + for el in &b.elements { + if el.sprite.is_none() { + continue; + } + let Some(k) = el.rest() else { continue }; + sprite_els += 1; + match (k.fade >> 24) & 0xff { + 0 => hidden += 1, + 0xff => { + noop += 1; + visible += 1; + } + _ => visible += 1, + } + } + assert!( + visible > 0 || b.elements.iter().all(|e| e.sprite.is_none()), + "{pak}: every element of this build is hidden by its fade alpha" + ); + if visible == 0 { + blank += 1; + } + }); + assert!(sprite_els > 5000, "only {sprite_els} elements — sweep did not run"); + assert_eq!(blank, 0, "{blank} of {builds} builds render nothing"); + // The modulate must stay overwhelmingly a no-op. If a future change to the + // resting rule pushes many elements onto a ramp frame, this catches it. + assert!( + noop * 4 > sprite_els * 3, + "the fade alpha is a no-op on only {noop}/{sprite_els} elements — the \ + resting rule is probably picking ramp frames instead of holds" + ); + eprintln!("fade alpha: {noop} no-op, {hidden} hidden, 0 blank builds of {builds}"); +} diff --git a/docs/re/captures/ui-layout/pause-composited-fade-applied.png b/docs/re/captures/ui-layout/pause-composited-fade-applied.png new file mode 100644 index 00000000..41212b21 Binary files /dev/null and b/docs/re/captures/ui-layout/pause-composited-fade-applied.png differ diff --git a/docs/re/captures/ui-layout/title-composited-fade-applied.png b/docs/re/captures/ui-layout/title-composited-fade-applied.png new file mode 100644 index 00000000..7b85d2a8 Binary files /dev/null and b/docs/re/captures/ui-layout/title-composited-fade-applied.png differ diff --git a/docs/re/structures/ui-resting-pose.md b/docs/re/structures/ui-resting-pose.md index 68ea026d..40faa096 100644 --- a/docs/re/structures/ui-resting-pose.md +++ b/docs/re/structures/ui-resting-pose.md @@ -84,13 +84,65 @@ essentially every keyframe. The wrong keyframes were being chosen and then their one distinguishing field was ignored. That is worth stating as its own finding — see below. +## The `fade` alpha, applied (same day) + +`blit` modulated by `tint` only — `0xffffffff` on essentially every keyframe — so +the `fade` word was decoded, stored, and then thrown away. Applying it as an +**ARGB** modulate on top of `tint` is what turns the resting pose from an +academic result into a picture: + +| composite | best correlation vs the capture | at shift | +|---|---|---| +| plateau rest, `fade` applied | **0.9538** | (0, 0) | +| plateau rest, `fade` ignored | 0.4597 | (0, 0) | +| longest-dwell rest | 0.1511 | (+3, +8) | + +0.95 against a framebuffer capture of the running game. +[The composite](../captures/ui-layout/title-composited-fade-applied.png) now has +the white wordmark with its blue outline, the ™, the copyright, and the orange +exploding planet — the last of which appeared because a full-screen blue effect +that rests at alpha 0 had been painting over it at full opacity. + +**ARGB is measured, not assumed.** Across a fade-in the *high* byte walks +`0x00 → 0x80 → 0xc0 → 0xe0 → 0xff` while the low three stay `ffffff`, and the low +24 bits are `0xffffff` on 5 276 of the disc's 5 453 resting keyframes (with +`0x000000` on 165 — the `.prm` blacks — and `0x5dc9ff` on 12). + +Risk checked, because a modulate can only ever remove pixels: it is a **no-op on +4 060 of 5 200** sprite elements, partial on 453, and hides 687 — which are +transient HUD indicators (`pb_emergency`, `pb_refilling`, `pbcm1_arrow1`) that +should not be lit on a resting screen. **No build is left with nothing visible**, +and a disc test asserts it. + +### And it caught a bug in the resting rule + +With `fade` applied, the pause menu lost the word **PAUSE**, which the capture of +the running game plainly shows +([`pause-tutorial-real-vs-rebuilt.png`](../captures/ui-layout/pause-tutorial-real-vs-rebuilt.png)). + +`pgptitle.rat` has **three** runs of two identical keyframes — invisible, +visible, invisible: + +``` +kf0 a=0x00 t=5 kf1 a=0x00 t=13 pre-roll +kf2 a=0xff t=23 kf3 a=0xff t=28 the hold +kf4 a=0x00 t=30 kf5 a=0x00 t=None the exit +``` + +A group carries the screen's entry animation **and its exit**. The tie-break +"later run wins" grabbed the exit. Fixed: a run ending on the last keyframe is +excluded unless it is the only one. The title's correlation is unchanged at +0.9538, and [PAUSE is back](../captures/ui-layout/pause-composited-fade-applied.png). + +This is why the two changes landed together: the trailing-run defect is +invisible — in the literal sense — until `fade` is applied. + ## What is not settled -* ❔ **`compose` ignores the keyframe `fade` alpha entirely.** `blit` uses - `tint`. Applying `fade` is the obvious next step and is what makes the resting - pose visible rather than academic — but it changes every screen and needs its - own A/B against the capture. It is also the prerequisite for drawing `.prm` - quads, whose entire content is that alpha. +* ❔ **Blend mode.** Everything above is straight alpha-over. The near-white + flash quads and the coloured ones may well be additive, and nothing has been + measured; the title capture cannot separate the two because its resting + elements are all `0xffffff`. * 🟡 **The fallback.** Groups with no two adjacent keyframes alike still use longest-dwell. How many there are, and whether the correct answer for them is the *last* keyframe instead, is unmeasured.