From fb7051124270927653d7a220544da7884a7dd47b Mon Sep 17 00:00:00 2001 From: Sylpheed RE agent Date: Fri, 28 Aug 2026 16:14:23 +0200 Subject: [PATCH] viewer: make the two screen presenters agree, and expose the compose options The UI Screens window enumerated with `is_build` while the PAK browser's inline preview composes anything `parse_build` accepts. So the browser drew screens this window flatly refused to list -- most visibly `palogo`, the publisher splash, which declares its sprites directly and has no `.rat` layout child. `compose_screen` now gates on `is_composable`, the documented superset (every `is_build` bundle passes it), so anything the list offers is drawable and the two presenters share one rule. A "Fragments" toggle widens the enumeration to it as well, off by default: the extra ~1786 bundles are mostly two-element fragments (a button beside its glow) that would bury the real screens. It re-scans, which renumbers the display ordinal -- harmless, because the pak ENTRY index is the locator and the stale catalog is cleared. The toggle sets a `rescan` flag rather than self-sending RequestScreenCatalog: a system that both reads and writes one event type is a B0002 panic at startup, which is how the Save browser broke. Also plumbs the last two ComposeOptions the CLI had and the UI did not -- `black backdrop` (what a framebuffer capture must be compared against) and `primitives` (decoded, but paint order unsolved; hover text says so). Verified: `screen render --all --build 13 GP_TITLE.pak` draws the SQUARE ENIX splash, 2/3 elements, the third being the .prm quad. Workspace builds, viewer reaches steady state, disc-gated suite 20/20 green. Co-Authored-By: Claude Opus 5 --- crates/sylpheed-viewer/src/iso_loader.rs | 129 +++++++++++++++++------ crates/sylpheed-viewer/src/ui.rs | 43 ++++++++ 2 files changed, 139 insertions(+), 33 deletions(-) diff --git a/crates/sylpheed-viewer/src/iso_loader.rs b/crates/sylpheed-viewer/src/iso_loader.rs index 84cba5c1..7fc64d9f 100644 --- a/crates/sylpheed-viewer/src/iso_loader.rs +++ b/crates/sylpheed-viewer/src/iso_loader.rs @@ -716,6 +716,21 @@ pub struct ScreenBrowser { pub loading: bool, pub loaded: bool, pub filter: String, + /// Enumerate every bundle `compose` can draw ([`ui_layout::is_composable`]) + /// rather than only full screens ([`ui_layout::is_build`], the default). + /// + /// This is what reconciles the two presenters: the pak browser's inline + /// preview composes any RATC bundle it can, so without this toggle it drew + /// things this window flatly refused to list — most visibly the developer + /// logo splash, which declares its sprites directly and has no `.rat` child. + /// Off by default because the extra ~1 786 bundles are mostly two-element + /// fragments (a button beside its glow), which would bury the real screens. + pub include_fragments: bool, + /// Set by the window when `include_fragments` is toggled. A flag, not a + /// self-sent [`RequestScreenCatalog`]: a system that both reads and writes + /// one event type is a Bevy B0002 panic at startup, which is how the Save + /// browser broke. `handle_screen_catalog_request` consumes it. + pub rescan: bool, pub paks: Vec, /// Which pak is selected, and which of its builds. pub selected: Option, @@ -724,6 +739,13 @@ pub struct ScreenBrowser { pub show_focus: bool, /// Draw `loop*` sprite animations. pub show_animated: bool, + /// Composite over black instead of the default dim slate — what the game + /// composites over on a screen that carries its own full-screen background, + /// and so what a framebuffer capture must be compared against. + pub black_backdrop: bool, + /// Draw the untextured `.prm` fade/dim/flash quads. Off by default because + /// their paint order is unsolved, not because they are undecoded. + pub show_primitives: bool, /// Per-element visibility for the current build (index-aligned). pub hidden: Vec, pub composed: Option, @@ -737,7 +759,12 @@ pub struct ScreenBrowser { /// Ask the loader to find the UI screen paks on the disc. #[derive(Event, Default)] -pub struct RequestScreenCatalog; +pub struct RequestScreenCatalog { + /// Re-scan even though a catalog is already loaded — the enumeration rule + /// changed. The View-menu button sends the default (`false`), so re-opening + /// the window stays free. + pub force: bool, +} /// Ask the loader to reassemble + composite one screen build. #[derive(Event)] @@ -750,6 +777,8 @@ pub struct RequestScreenCompose { pub entry: usize, pub focus: bool, pub animated: bool, + pub black_backdrop: bool, + pub primitives: bool, /// Element indices to leave out (the per-element toggles). pub hidden: Vec, } @@ -4120,25 +4149,41 @@ fn handle_screen_catalog_request( channels: Res, mut screens: ResMut, ) { - if events.read().next().is_none() { + // Either the View-menu event, or the in-window toggle's flag. + let force = match events.read().last() { + Some(req) => req.force || screens.rescan, + None if screens.rescan => true, + None => return, + }; + screens.rescan = false; + if screens.loading || (screens.loaded && !force) { return; } - if screens.loaded || screens.loading { - return; + if force { + // The rule changed, so every locator in the old catalog is stale. + screens.loaded = false; + screens.paks.clear(); + screens.selected = None; + screens.composed = None; } screens.loading = true; + let fragments = screens.include_fragments; let source = iso_state.source_kind.clone(); let files = browser.files.clone(); let sender = channels.sender.clone(); std::thread::spawn(move || { - let paks = build_screen_catalog(&source, &files); + let paks = build_screen_catalog(&source, &files, fragments); let _ = sender.send(IsoLoaderMsg::ScreenCatalogLoaded(paks)); }); } /// Open every `GP_*.pak` and record which entries parse as a screen build. #[cfg(not(target_arch = "wasm32"))] -fn build_screen_catalog(source: &SourceKind, files: &[String]) -> Vec { +fn build_screen_catalog( + source: &SourceKind, + files: &[String], + fragments: bool, +) -> Vec { use sylpheed_formats::ui_layout; let mut out = Vec::new(); let mut candidates: Vec<&String> = files @@ -4170,7 +4215,12 @@ fn build_screen_catalog(source: &SourceKind, files: &[String]) -> Vec } let Ok(bytes) = ar.read(e) else { continue }; budget += bytes.len(); - if ui_layout::is_build(&bytes) { + let keep = if fragments { + ui_layout::is_composable(&bytes) + } else { + ui_layout::is_build(&bytes) + }; + if keep { builds.push((i, bytes.len())); } } @@ -4203,18 +4253,17 @@ fn handle_screen_compose_request( screens.generation = screens.generation.wrapping_add(1); screens.composing = true; let generation = screens.generation; - let (pak, build, entry, focus, animated, hidden) = ( - req.pak.clone(), - req.build, - req.entry, - req.focus, - req.animated, - req.hidden.clone(), - ); + let opts = ScreenComposeOpts { + focus: req.focus, + animated: req.animated, + black_backdrop: req.black_backdrop, + primitives: req.primitives, + }; + let (pak, build, entry, hidden) = (req.pak.clone(), req.build, req.entry, req.hidden.clone()); let source = iso_state.source_kind.clone(); let sender = channels.sender.clone(); std::thread::spawn(move || { - let composed = compose_screen(&source, &pak, entry, build, focus, animated, &hidden); + let composed = compose_screen(&source, &pak, entry, build, opts, &hidden); let _ = sender.send(IsoLoaderMsg::ScreenComposed { generation, composed: Box::new(composed), @@ -4222,14 +4271,23 @@ fn handle_screen_compose_request( }); } +/// The compose toggles, bundled so `compose_screen` keeps a readable signature. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone, Copy)] +struct ScreenComposeOpts { + focus: bool, + animated: bool, + black_backdrop: bool, + primitives: bool, +} + #[cfg(not(target_arch = "wasm32"))] fn compose_screen( source: &SourceKind, pak: &str, entry: usize, build_idx: usize, - focus: bool, - animated: bool, + opts: ScreenComposeOpts, hidden: &[bool], ) -> ComposedScreenResult { use sylpheed_formats::ui_layout::{self, ComposeOptions}; @@ -4251,21 +4309,21 @@ fn compose_screen( // version inflated EVERY entry in the pak and held every build in memory at // once, just to index into the result — repeated on every checkbox click, // with the ISO re-opened from scratch each time. That, not the compositing, - // was the cost. - let one: Option> = ar + // was the cost. `build_idx` stays a display ordinal; `entry` is the locator. + // + // Gate on `is_composable`, not `is_build`: it is the documented superset + // (every `is_build` bundle passes it) and it is the rule the pak browser's + // inline preview already draws by, so the two presenters agree about what + // this viewer can render. + let Some(bundle) = ar .entries() .get(entry) .and_then(|e| ar.read(e).ok()) - .filter(|b| ui_layout::is_build(b)); - let bundles: Vec> = one.into_iter().collect(); - let Some(bundle) = bundles.first().filter(|_| { - // `build_idx` stays the display ordinal; `entry` is the real locator. - let _ = build_idx; - true - }) else { - return fail(format!("build {build_idx} not in {pak}")); + .filter(|b| ui_layout::is_composable(b)) + else { + return fail(format!("build {build_idx} (entry {entry}) not in {pak}")); }; - let Some(build) = ui_layout::parse_build(bundle) else { + let Some(build) = ui_layout::parse_build(&bundle) else { return fail("build did not parse".into()); }; // `visible` is the inverse of the UI's `hidden`; a short list means "all @@ -4277,11 +4335,16 @@ fn compose_screen( .collect(); let screen = ui_layout::compose( &build, - bundle, + &bundle, ComposeOptions { - include_focus: focus, - include_animated: animated, - ..Default::default() + include_focus: opts.focus, + include_animated: opts.animated, + include_primitives: opts.primitives, + backdrop: if opts.black_backdrop { + [0, 0, 0, 255] + } else { + ComposeOptions::default().backdrop + }, }, Some(&visible), ); diff --git a/crates/sylpheed-viewer/src/ui.rs b/crates/sylpheed-viewer/src/ui.rs index 7d762fde..84c721f1 100644 --- a/crates/sylpheed-viewer/src/ui.rs +++ b/crates/sylpheed-viewer/src/ui.rs @@ -1876,6 +1876,21 @@ fn draw_screens_ui( if !screens.filter.is_empty() && ui.small_button("✖").clicked() { screens.filter.clear(); } + ui.separator(); + // Widens the enumeration to everything `compose` can draw — the + // same rule the pak browser's inline preview uses. Costs a + // re-scan, so it is a deliberate click rather than the default. + if ui + .checkbox(&mut screens.include_fragments, "Fragments") + .on_hover_text( + "Also list RATC bundles with no .rat layout child: the developer-logo \ + splash, and ~1 786 two-element fragments (a button beside its glow). \ + These are what the PAK browser preview draws but this list omits.", + ) + .changed() + { + screens.rescan = true; + } }); ui.separator(); @@ -1928,6 +1943,32 @@ fn draw_screens_ui( { recompose = true; } + if ui + .checkbox(&mut screens.black_backdrop, "Black backdrop") + .on_hover_text( + "Composite over black instead of the default dim slate. The slate \ + stands in for the PRMD dim quad plus the live 3D scene behind an \ + in-mission screen; black is what the game composites over on a \ + screen carrying its own background — and what a framebuffer \ + capture must be compared against.", + ) + .changed() + { + recompose = true; + } + if ui + .checkbox(&mut screens.show_primitives, "Primitives") + .on_hover_text( + "Draw the untextured .prm fade/dim/flash quads. Decoded, but their \ + paint order is unsolved: a primitive has no T8aD header and so no \ + layer key, and the derived order forces keyless elements last. \ + That is measurably wrong — expect an opaque quad to wipe some \ + screens (32 of GP_DIALOG's builds).", + ) + .changed() + { + recompose = true; + } if screens.composing { ui.spinner(); ctx.request_repaint(); @@ -2096,6 +2137,8 @@ fn draw_screens_ui( .unwrap_or(0), focus: screens.show_focus, animated: screens.show_animated, + black_backdrop: screens.black_backdrop, + primitives: screens.show_primitives, hidden: screens.hidden.clone(), }); }