diff --git a/crates/sylpheed-cli/src/main.rs b/crates/sylpheed-cli/src/main.rs index a32193df..e8fe46d3 100644 --- a/crates/sylpheed-cli/src/main.rs +++ b/crates/sylpheed-cli/src/main.rs @@ -129,6 +129,14 @@ enum ScreenCommands { List { /// Path to a `GP_*.pak` pak: PathBuf, + /// 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 + /// 965 are screen builds, and the rest are mostly two-element fragments + /// (a button and its glow), so this is off by default. **It renumbers + /// `--build`**, which is why it is a flag and not the default. + #[arg(long)] + all: bool, }, /// Print one build's element declaration table and resting placements Info { @@ -137,6 +145,15 @@ enum ScreenCommands { /// Which build (index into `screen list`); default = the largest #[arg(long)] build: Option, + /// 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 + /// 965 are screen builds, and the rest are mostly two-element fragments + /// (a button and its glow), so this is off by default. **It renumbers + /// `--build`**, which is why it is a flag and not the default. + #[arg(long)] + all: bool, + /// Also print the bundle's other orderings and, per element, the decoded /// sprite size beside the declared pivot and every keyframe's /// scale/position/time — what a placement or paint-order hypothesis has @@ -164,6 +181,14 @@ enum ScreenCommands { /// what a framebuffer capture must be compared against. #[arg(long)] black: 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 + /// 965 are screen builds, and the rest are mostly two-element fragments + /// (a button and its glow), so this is off by default. **It renumbers + /// `--build`**, which is why it is a flag and not the default. + #[arg(long)] + all: bool, }, } @@ -306,12 +331,13 @@ async fn main() -> Result<()> { AudioCommands::Info { file } => cmd_audio_info(&file), }, Commands::Screen { cmd } => match cmd { - ScreenCommands::List { pak } => cmd_screen_list(&pak), + ScreenCommands::List { pak, all } => cmd_screen_list(&pak, all), ScreenCommands::Info { pak, build, geometry, - } => cmd_screen_info(&pak, build, geometry), + all, + } => cmd_screen_info(&pak, build, geometry, all), ScreenCommands::Render { pak, output, @@ -319,8 +345,9 @@ async fn main() -> Result<()> { focus, animated, black, + all, } => { - cmd_screen_render(&pak, &output, build, focus, animated, black) + cmd_screen_render(&pak, &output, build, focus, animated, black, all) } }, Commands::Save { cmd } => match cmd { @@ -332,13 +359,24 @@ async fn main() -> Result<()> { // ── UI screens ─────────────────────────────────────────────────────────────── /// Every RATC entry of a UI pak that parses as a screen build, with its bytes. -fn screen_builds(pak: &Path) -> Result)>> { +/// +/// `all` widens the filter from `is_build` — which requires a `.rat` layout +/// child — to anything `compose` can draw. The developer-logo splash is the +/// reason: it declares its sprites directly, has no `.rat` child at all, and so +/// is invisible to every screen command without this. See +/// `docs/re/structures/ui-composable-bundles.md`. +fn screen_builds(pak: &Path, all: bool) -> Result)>> { use sylpheed_formats::{pak::PakArchive, ui_layout}; let ar = PakArchive::open(pak).context("open pak")?; let mut out = Vec::new(); for (i, e) in ar.entries().iter().enumerate() { let Ok(bytes) = ar.read(e) else { continue }; - if ui_layout::is_build(&bytes) { + let keep = if all { + ui_layout::is_composable(&bytes) + } else { + ui_layout::is_build(&bytes) + }; + if keep { out.push((i, bytes)); } } @@ -364,9 +402,9 @@ fn pick_build(builds: &[(usize, Vec)], want: Option) -> Result } } -fn cmd_screen_list(pak: &Path) -> Result<()> { +fn cmd_screen_list(pak: &Path, all: bool) -> Result<()> { use sylpheed_formats::ui_layout; - let builds = screen_builds(pak)?; + let builds = screen_builds(pak, all)?; println!("{} screen build(s) in {}", builds.len(), pak.display()); for (i, (entry, bytes)) in builds.iter().enumerate() { match ui_layout::parse_build(bytes) { @@ -389,9 +427,9 @@ fn cmd_screen_list(pak: &Path) -> Result<()> { Ok(()) } -fn cmd_screen_info(pak: &Path, want: Option, geometry: bool) -> Result<()> { +fn cmd_screen_info(pak: &Path, want: Option, geometry: bool, all: bool) -> Result<()> { use sylpheed_formats::ui_layout; - let builds = screen_builds(pak)?; + let builds = screen_builds(pak, all)?; let idx = pick_build(&builds, want)?; let bytes = &builds[idx].1; let b = ui_layout::parse_build(bytes).context("build did not parse")?; @@ -529,9 +567,10 @@ fn cmd_screen_render( focus: bool, animated: bool, black: bool, + all: bool, ) -> Result<()> { use sylpheed_formats::ui_layout::{self, ComposeOptions}; - let builds = screen_builds(pak)?; + let builds = screen_builds(pak, all)?; let idx = pick_build(&builds, want)?; let bytes = &builds[idx].1; let b = ui_layout::parse_build(bytes).context("build did not parse")?; diff --git a/crates/sylpheed-formats/src/ui_layout.rs b/crates/sylpheed-formats/src/ui_layout.rs index c44f1a28..ba0d1c04 100644 --- a/crates/sylpheed-formats/src/ui_layout.rs +++ b/crates/sylpheed-formats/src/ui_layout.rs @@ -190,6 +190,30 @@ pub fn is_build(bundle: &[u8]) -> bool { }) } +/// Whether `compose` can draw this bundle: it parses a real declaration table +/// and at least one element resolves to a `T8aD` sprite the bundle carries. +/// +/// Strictly wider than [`is_build`], which additionally requires a `.rat` layout +/// child. Measured on the disc: 2 859 RATC bundles, 965 pass `is_build`, 2 751 +/// pass this — and no bundle passes `is_build` without passing this, so it is a +/// superset and not a different rule. +/// +/// The extra 1 786 are **not** all screens. Most are two-element fragments — a +/// button beside its glow (`pvbtnnew.t32` + `pvbtnneweff.t32`) — which is why +/// `is_build` stays the default enumeration and this is opt-in. What it does +/// unlock is the developer-logo splash, which declares its sprites directly, +/// has no `.rat` child, and was therefore impossible to render at all despite +/// being one of only two screens whose paint order has been measured off the +/// running game. +pub fn is_composable(bundle: &[u8]) -> bool { + parse_build(bundle).is_some_and(|b| { + !b.from_fallback + && b.elements + .iter() + .any(|e| e.sprite.as_ref().is_some_and(|s| b.sprites.contains_key(s))) + }) +} + /// Trim a NUL-padded fixed-width name field. fn fixed_name(b: &[u8]) -> String { let end = b.iter().position(|&c| c == 0).unwrap_or(b.len()); diff --git a/crates/sylpheed-formats/tests/ui_paint_order_disc.rs b/crates/sylpheed-formats/tests/ui_paint_order_disc.rs index a598eb4c..e89ab0a7 100644 --- a/crates/sylpheed-formats/tests/ui_paint_order_disc.rs +++ b/crates/sylpheed-formats/tests/ui_paint_order_disc.rs @@ -566,3 +566,54 @@ fn a_focused_state_always_has_the_element_it_is_the_focused_state_of() { assert!(eff > 100, "only {eff} `_eff` elements, expected the disc's glows"); eprintln!("focused states: {flagged} of {total} elements; {eff} `_eff` glows kept"); } + +/// The developer-logo splash composes — glows and all — even though it has no +/// `.rat` layout child and so is not an `is_build` "screen build". +/// +/// It is one of only two screens whose paint order has been read off the running +/// game, and until `is_composable` existed it could not be rendered at all, which +/// made that measurement uncheckable. +#[test] +fn the_developer_logo_splash_composes_with_its_glows() { + skip_without_disc!(root); + let arc = PakArchive::open(root.join("dat").join("GP_TITLE.pak")).expect("GP_TITLE.pak"); + let mut seen = 0usize; + for e in arc.entries() { + let Ok(bytes) = arc.read(e) else { continue }; + let Some(b) = ui_layout::parse_build(&bytes) else { + continue; + }; + let names: Vec<&str> = b.elements.iter().map(|e| e.name.as_str()).collect(); + if names + != [ + "palogo_eff0.prm", + "palogo_gamearts.t32", + "palogo_gamearts_eff.t32", + "palogo_seta.t32", + "palogo_seta_eff.t32", + "palogo_anima.t32", + "palogo_anima_eff.t32", + ] + { + continue; + } + seen += 1; + assert!( + !ui_layout::is_build(&bytes), + "the splash has gained a .rat child — the reason is_composable exists \ + has changed and this test is now testing nothing" + ); + assert!(ui_layout::is_composable(&bytes)); + let c = ui_layout::compose(&b, &bytes, ComposeOptions::default(), None); + // All six sprites: three logos and the three `_eff` glows behind them. + // The seventh element is a `.prm` primitive with no sprite to draw. + assert_eq!( + c.drawn, + vec![2, 4, 6, 1, 3, 5], + "the splash must paint its glows first, in the order measured off \ + the running game" + ); + assert!(c.missing.is_empty(), "missing sprites: {:?}", c.missing); + } + assert!(seen >= 2, "found {seen} splash bundles, expected the language pair"); +} diff --git a/docs/re/structures/ui-composable-bundles.md b/docs/re/structures/ui-composable-bundles.md new file mode 100644 index 00000000..93bec64b --- /dev/null +++ b/docs/re/structures/ui-composable-bundles.md @@ -0,0 +1,86 @@ +# A screen build is not the only thing `compose` can draw + +**Status:** ✅ `CONFIRMED` by measurement over the disc, with the artifact to +show for it. 🟡 the wider set is not "all screens" — most of what it adds are +fragments. ❔ what distinguishes a screen from a fragment in the file is unknown. + +## The gap + +`ui_layout::is_build` — the predicate every screen command and every disc test +enumerates with — requires a `.rat` layout child: + +```rust +ratc::is_ratc(bundle) && kids.iter().any(|c| c.name.ends_with(".rat")) +``` + +The **developer-logo splash** has none. Its elements name their `T8aD` sprites +directly (`palogo_gamearts.t32`), so there is nothing for a `.rat` record to +place, and `parse_build` handles it perfectly well — it was simply never reached. + +That was worse than a missing screen. The splash is **one of only two screens +whose paint order has been read off the running game** +([`ui-paint-order-key.md`](ui-paint-order-key.md)), and it is the one where the +layer key explains the *whole* permutation. Its measurement could not be checked +against a render, because there was no way to produce one. + +## The measurement + +| | count | +|---|---| +| RATC bundles on the disc | 2 859 | +| pass `is_build` | 965 | +| pass `is_composable` (declaration table + a resolvable `T8aD`) | 2 751 | +| passing `is_build` but **not** `is_composable` | **0** | + +So the new predicate is a strict superset, not a rival rule. + +**But the 1 786 extra bundles are not 1 786 screens.** They are dominated by +two-element fragments — a button beside its glow: + +``` +GP_READY_ROOM.pak +792 GP_MAIN_GAME_*2D.pak +108 each (six languages) +GP_DEBRIEFING_PILOTLOG.pak +164 GP_HANGAR_ARSENAL.pak +96 + e.g. 2 els: ["pvbtnnew.t32", "pvbtnneweff.t32"] + 2 els: ["py_menu_new.t32", "py_menu_new_eff.t32"] +``` + +That is why `is_build` stays the default. Widening it would also **renumber +`--build`** for every pak, and the corpus's notes cite build indices by number +(`GP_TITLE` build 4, entries 11/14, `GP_OPTIONS` build 11) — silently shifting +them would invalidate written-down evidence. + +## What landed + +`ui_layout::is_composable`, and an opt-in `--all` on `screen list`, `screen info` +and `screen render`. Default behaviour and default numbering are unchanged. + +``` +$ sylpheed-cli screen render dat/GP_TITLE.pak splash.png --all --build 11 --black +build [11]: drew 6/7 elements + not drawn (1): ["palogo_eff0.prm"] +``` + +![the splash](../captures/ui-layout/developer-logo-splash-composed.png) + +All three logos with their glows behind them. The seventh element is a `.prm` +primitive, which has no sprite and is skipped as everywhere else. A disc test +pins the draw order to `[2,4,6,1,3,5]` — the glows first — which is the order +measured off the running game, so the measurement is now checkable rather than +merely recorded. + +Note this render also depends on the `_eff` fix +([`ui-focus-and-effect-elements.md`](ui-focus-and-effect-elements.md)): before +it, the three glows were dropped as "focused-state records" and the splash would +have rendered as three bare logos. + +## What is not settled + +* ❔ **What makes a bundle a screen.** `is_composable` answers "can this be + drawn", not "is this a screen the game shows". Element count is a crude proxy + (fragments are 2–5 elements) and has not been checked against anything. +* ❔ **The `.prm` primitives.** `palogo_eff0.prm` is a full-screen element with + pivot (640,360) and a single keyframe; nothing decodes `.prm` yet, so every + composite is missing whatever they draw. On the splash the measured paint order + puts it **first**, i.e. it is the backdrop. +* 🟡 The splash render has not been diffed against a framebuffer capture. It is + now *possible* to, which it was not before.