diff --git a/crates/sylpheed-cli/src/main.rs b/crates/sylpheed-cli/src/main.rs index e8fe46d..697d1cb 100644 --- a/crates/sylpheed-cli/src/main.rs +++ b/crates/sylpheed-cli/src/main.rs @@ -181,6 +181,12 @@ enum ScreenCommands { /// what a framebuffer capture must be compared against. #[arg(long)] black: bool, + /// Draw the untextured `.prm` primitives (fade / dim / flash quads). + /// Off by default: they are decoded, but where they paint on a screen + /// without a measured order is unsolved — see + /// `docs/re/structures/ui-prm-primitives.md`. + #[arg(long)] + primitives: bool, /// Widen the list from screen builds to **every composable bundle** — /// including the ones with no `.rat` layout child, such as the /// developer-logo splash. 2 859 RATC bundles on the disc compose; only @@ -346,9 +352,10 @@ async fn main() -> Result<()> { animated, black, all, - } => { - cmd_screen_render(&pak, &output, build, focus, animated, black, all) - } + primitives, + } => cmd_screen_render( + &pak, &output, build, focus, animated, black, all, primitives, + ), }, Commands::Save { cmd } => match cmd { SaveCommands::Info { file, all } => cmd_save_info(&file, all), @@ -568,6 +575,7 @@ fn cmd_screen_render( animated: bool, black: bool, all: bool, + primitives: bool, ) -> Result<()> { use sylpheed_formats::ui_layout::{self, ComposeOptions}; let builds = screen_builds(pak, all)?; @@ -585,6 +593,7 @@ fn cmd_screen_render( } else { ComposeOptions::default().backdrop }, + include_primitives: primitives, }, None, ); diff --git a/crates/sylpheed-formats/src/ui_layout.rs b/crates/sylpheed-formats/src/ui_layout.rs index cc2edf1..2d25c97 100644 --- a/crates/sylpheed-formats/src/ui_layout.rs +++ b/crates/sylpheed-formats/src/ui_layout.rs @@ -604,6 +604,19 @@ pub struct ComposeOptions { /// framebuffer capture needs the backdrop to match, or every partially /// transparent pixel is off by the backdrop. pub backdrop: [u8; 4], + /// Draw the untextured `.prm` primitives — the fade / dim / flash quads. + /// + /// **Off by default, and not because they are undecoded.** They are decoded + /// (`docs/re/structures/ui-prm-primitives.md`) and drawing them is right on the + /// title screen, whose paint order was read off the running game. What is + /// unsolved is *where they paint on every other screen*: a primitive has no + /// `T8aD` header, so it has no layer key, and the derived order forces the + /// keyless to the end. That is wrong — the two measured screens show a + /// primitive painting **first** (the splash's black backdrop) and another + /// painting **last** (the title's fade-out), so no single default is right. + /// Left on with the derived order, an opaque black quad sorts last and wipes + /// 32 of `GP_DIALOG`'s builds. + pub include_primitives: bool, } impl Default for ComposeOptions { @@ -612,6 +625,7 @@ impl Default for ComposeOptions { include_focus: false, include_animated: false, backdrop: [14, 14, 20, 255], + include_primitives: false, } } } @@ -777,6 +791,19 @@ pub fn compose( continue; } let Some(kf) = el.rest() else { continue }; + // An untextured primitive: a solid quad of the keyframe's `fade` colour, + // sized by the declared pivot. `kind & 0x10` marks these exactly — see + // `docs/re/structures/ui-prm-primitives.md`. They are the screen's + // fade-to-black, dim-behind-a-menu and flash layers. + if el.kind & 0x10 != 0 && el.sprite.is_none() { + if !opts.include_primitives { + continue; + } + if fill_quad(&mut canvas, w, h, kf, el.pivot_x, el.pivot_y) { + drawn.push(el.index); + } + continue; + } let Some(sprite) = el.sprite.as_ref() else { continue; }; @@ -800,6 +827,69 @@ pub fn compose( } } +/// Alpha-blend an untextured primitive: a solid rectangle of the keyframe's +/// `fade` colour, `pivot × 2` in size, placed and scaled exactly as a sprite is. +/// +/// Returns whether anything was drawn — a primitive resting at alpha 0 (189 of +/// the 369 on the disc) contributes nothing and should not be counted as drawn. +/// +/// The size comes from the pivot because there is no texture to take it from, +/// and a `.t32` element's pivot is exactly half its decoded sprite. 361 of the +/// 369 primitives are `pivot × 2 == 1280×720`, the design space. +fn fill_quad( + canvas: &mut [u8], + cw: u32, + ch: u32, + kf: &Keyframe, + pivot_x: u32, + pivot_y: u32, +) -> bool { + let (a, r, g, b) = ( + (kf.fade >> 24) & 0xff, + (kf.fade >> 16) & 0xff, + (kf.fade >> 8) & 0xff, + kf.fade & 0xff, + ); + if a == 0 { + return false; + } + let (sw, sh) = (pivot_x * 2, pivot_y * 2); + if sw == 0 || sh == 0 { + return false; + } + let sx_pct = if kf.scale_x == 0 { 100 } else { kf.scale_x }; + let sy_pct = if kf.scale_y == 0 { 100 } else { kf.scale_y }; + let dw = (sw * sx_pct / 100).max(1); + let dh = (sh * sy_pct / 100).max(1); + 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; + for row in 0..dh { + let ty = oy + row as i32; + if ty < 0 { + continue; + } + if ty >= ch as i32 { + break; + } + for col in 0..dw { + let tx = ox + col as i32; + if tx < 0 { + continue; + } + if tx >= cw as i32 { + break; + } + let di = ((ty as u32 * cw + tx as u32) * 4) as usize; + for (k, sc) in [r, g, b].into_iter().enumerate() { + let dc = canvas[di + k] as u32; + canvas[di + k] = ((sc * a + dc * (255 - a)) / 255) as u8; + } + canvas[di + 3] = 255; + } + } + true +} + /// Alpha-blend one sprite onto the canvas at a keyframe's placement, with tint /// and scale. Placements may be negative or run off the edge, so both axes clip. /// diff --git a/crates/sylpheed-formats/tests/ui_prm_primitives_disc.rs b/crates/sylpheed-formats/tests/ui_prm_primitives_disc.rs index 6c0c6b3..e306e2c 100644 --- a/crates/sylpheed-formats/tests/ui_prm_primitives_disc.rs +++ b/crates/sylpheed-formats/tests/ui_prm_primitives_disc.rs @@ -155,3 +155,79 @@ fn the_title_fade_quad_rests_transparent() { } assert!(checked >= 3, "found {checked} title fade quads, expected several"); } + +/// **Drawing the primitives with the derived order swallows screens** — which +/// is why `ComposeOptions::include_primitives` is off by default. +/// +/// A primitive has no `T8aD` header, so it has no layer key, and +/// `derived_paint_order` sorts the keyless to the very end. That is not a +/// harmless default: `GP_DIALOG`'s `pzeff00.prm` is a **single** keyframe of +/// opaque black at full screen, and painted last it wipes the build. +/// +/// The ground truth contradicts *both* simple defaults. On the developer-logo +/// splash the game paints `palogo_eff0.prm` **first** — it is the black +/// backdrop. On the title it paints `pteff02.prm` at slot 4, beneath the +/// wordmark, and `pteff00.prm` **last**, as the fade-out. So the order is real, +/// per-element, and not derivable from anything decoded so far. +/// +/// This test measures the damage rather than asserting the feature works, so the +/// number stays honest and moves when the ordering is solved. +#[test] +fn the_derived_order_puts_primitives_last_and_that_wipes_screens() { + let Some(root) = disc_root() else { + eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)"); + return; + }; + let opts = |primitives| ui_layout::ComposeOptions { + backdrop: [0, 0, 0, 255], + include_primitives: primitives, + ..Default::default() + }; + let flatness = |c: &ui_layout::ComposedScreen| { + let mut hist = std::collections::HashMap::<[u8; 3], usize>::new(); + for p in c.rgba.chunks_exact(4) { + *hist.entry([p[0], p[1], p[2]]).or_default() += 1; + } + *hist.values().max().unwrap_or(&0) as f64 / (c.width * c.height) as f64 + }; + let (mut with_prm, mut wiped_on, mut wiped_off) = (0usize, 0usize, 0usize); + for_each_build(&root, |_pak, bytes| { + if !ui_layout::is_build(bytes) { + return; + } + let Some(b) = ui_layout::parse_build(bytes) else { + return; + }; + if b.from_fallback + || !b.elements.iter().any(|e| { + e.kind & 0x10 != 0 + && e.sprite.is_none() + && e.rest().map(|k| (k.fade >> 24) & 0xff).unwrap_or(0) != 0 + }) + { + return; + } + with_prm += 1; + if flatness(&ui_layout::compose(&b, bytes, opts(true), None)) > 0.99 { + wiped_on += 1; + } + if flatness(&ui_layout::compose(&b, bytes, opts(false), None)) > 0.99 { + wiped_off += 1; + } + }); + assert!(with_prm > 50, "only {with_prm} builds draw a primitive"); + assert_eq!( + wiped_off, 0, + "the DEFAULT composite wipes {wiped_off} builds — primitives are supposed \ + to be off unless asked for" + ); + assert!( + wiped_on > 0, + "no build is wiped with primitives on — the ordering problem this flag \ + exists for may be solved, in which case turn it on by default" + ); + eprintln!( + "{with_prm} builds draw a visible primitive; with the derived order \ + {wiped_on} of them come out >99% one colour, {wiped_off} by default" + ); +} diff --git a/docs/re/captures/ui-layout/dialog-difficulty-with-primitives.png b/docs/re/captures/ui-layout/dialog-difficulty-with-primitives.png new file mode 100644 index 0000000..9fd4741 Binary files /dev/null and b/docs/re/captures/ui-layout/dialog-difficulty-with-primitives.png differ diff --git a/docs/re/captures/ui-layout/title-composited-with-primitives.png b/docs/re/captures/ui-layout/title-composited-with-primitives.png new file mode 100644 index 0000000..9bd4d14 Binary files /dev/null and b/docs/re/captures/ui-layout/title-composited-with-primitives.png differ diff --git a/docs/re/structures/ui-prm-primitives.md b/docs/re/structures/ui-prm-primitives.md index fbcca00..e918588 100644 --- a/docs/re/structures/ui-prm-primitives.md +++ b/docs/re/structures/ui-prm-primitives.md @@ -49,45 +49,81 @@ dim-behind-a-menu and flash layers. That matches the standing note on `ComposeOptions::backdrop`, which describes the compositor's dim slate as a stand-in for "the PRMD dim-quad" — this is that quad, and it is in the file. -## Refuted: you cannot just draw them at rest +## Drawing them (2026-08-19) -The obvious next step — treat a `.prm` as a quad and blit it at `rest()` — is -wrong, and would have been a visible disaster rather than a subtle one. +`fill_quad` composites a primitive as a solid rectangle of the keyframe's `fade` +colour, `pivot × 2` in size, placed and scaled exactly as a sprite is. It is +behind `ComposeOptions::include_primitives` / `screen render --primitives`, and +**off by default** — for a reason that is itself the result of this iteration. -The title screen's `pteff00.prm`: +On the title screen, whose paint order is ground truth, it is measurably right: -``` -kf0 fade=0xff000000 t=12 opaque black -kf1 fade=0x00000000 t=64 transparent -kf2 fade=0x00000000 t=74 transparent -kf3 fade=0xff000000 t=None opaque black -``` +| composite | mean luminance | vs capture | mean abs diff | +|---|---|---|---| +| primitives off | 76.30 | **+18 %** | 16.07 | +| primitives on | **63.72** | **−1.3 %** | **13.08** | -That is a **transition**: the screen fades up out of black, sits clear, and fades -back down on the way out. What the title screen *shows* is the transparent -plateau, kf1–kf2. +(capture mean 64.58). The composite was ~40 % too bright in the background +regions; `pteff02.prm`, a 25 % black dim, is what was missing. The wordmark is +*not* dimmed by it — the measured order paints that quad at slot 4, beneath the +logo — and the fraction of wordmark pixels above 200 moves 29.9 % → 28.2 % +against the capture's 26.8 %. +[The composite](../captures/ui-layout/title-composited-with-primitives.png). -`Element::rest()` picks the keyframe with the largest gap to the next keyframe's -time — 12→64 is the biggest gap, so it picks **kf0**, opaque black. And in the -measured paint order `pteff00.prm` is painted **last** on the title screen. Drawn -at `rest()`, it is a full-screen opaque black quad over everything. +Edge correlation goes 0.9538 → 0.9480, which sounds like a loss and is not +informative: a uniform dim scales gradients uniformly, so a normalised edge +correlation barely sees it. Brightness is the metric that discriminates here, and +it moves 20 percentage points toward the capture. -The rule's assumption is the problem: it treats a keyframe as a pose that is -*held* until the next one. For a fade it is the *start of a ramp*. The pose a -screen actually rests at is the **plateau** — a run of consecutive keyframes with -equal values — which for this group is kf1–kf2, transparent. +## Refuted twice, and the second one is the blocker -This is pinned by `the_title_fade_quad_is_a_transition_and_rest_picks_the_wrong_end`, -which asserts the current (wrong) answer on purpose, so that fixing the resting -rule fails the test and leads whoever does it here. +**Refuted: you cannot draw them at `Element::rest()`** — as the old longest-dwell +rule computed it. The title's `pteff00.prm` runs opaque → transparent → +transparent → opaque, a screen transition whose resting pose is the transparent +plateau, and it paints **last**; the old rule picked the opaque endpoint, i.e. +the whole screen. That is fixed — [`ui-resting-pose.md`](ui-resting-pose.md). + +**Refuted: the derived paint order does not place them.** This is what keeps the +flag off. A primitive has no `T8aD` header, so it has **no layer key**, and +`derived_paint_order` sorts the keyless to the very end. `GP_DIALOG`'s +`pzeff00.prm` is a *single* keyframe of opaque black at full screen; painted last +it wipes the build. Measured: of the 125 builds that draw a visible primitive, +**36 come out more than 99 % one colour** with the derived order. + +And no simple default fixes it, because the two screens read off the running game +disagree with each other: + +* the developer-logo splash paints `palogo_eff0.prm` **first** — it is the black + backdrop the logos sit on; +* the title paints `pteff02.prm` at slot **4**, beneath the wordmark, and + `pteff00.prm` **last**, as the fade-out. + +So a primitive's position is real, per-element, and not derivable from anything +decoded so far. "Primitives first" would break the title's fade-out; "primitives +last" wipes 36 builds; "keep declaration position" was checked against the title +and fails there too — `pteff00.prm` is element 8, declared among the wordmarks, +and the game paints it 23rd. + +A disc test measures the damage (36 / 125 / 0-by-default) rather than asserting +the feature works, so the number stays honest and changes when the ordering is +solved. + +Worth seeing anyway: the DIFFICULTY dialog +([capture](../captures/ui-layout/dialog-difficulty-with-primitives.png)) renders +legibly with its 50 % dim — an early false alarm said 36 `GP_DIALOG` builds went +"100 % black", which was a crude near-black pixel threshold, not a black screen. +The genuinely wiped ones are a different set, wiped by an *opaque* quad. ## What is not settled -* ❔ **The resting rule.** "Longest plateau of equal consecutive keyframes" - is the candidate, and it is *not* `.prm`-specific — it would change `rest()` - for every element on the disc, including the ones checked against the title - framebuffer capture. It has to be A/B'd against those before it can land. That - is the next step and the reason `.prm` compositing is not in this change. +## What is not settled + +* ❔ **Where a primitive paints.** The blocker, described above. It has no layer + key and the two measured screens rule out every constant default. The cheapest + next step is a third measured order from a screen that carries a primitive — + the `GP_DIALOG` DIFFICULTY box is reachable from the main menu and has exactly + one, so a runtime child-list read there would say whether its dim is first or + last. * ❔ **Blend mode.** A dim quad at `0x7f000000` is presumably straight alpha over what is beneath, but the flash (`0xf0ffffff`) and the coloured ones (`0x60ff0000`) may well be additive. Nothing measured.