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 <noreply@anthropic.com>
This commit is contained in:
Sylpheed RE agent
2026-08-28 16:14:23 +02:00
parent b239510fd6
commit cf1f5763b0
2 changed files with 139 additions and 33 deletions

View File

@@ -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<ScreenPak>,
/// Which pak is selected, and which of its builds.
pub selected: Option<usize>,
@@ -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<bool>,
pub composed: Option<ComposedScreenResult>,
@@ -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<bool>,
}
@@ -4120,25 +4149,41 @@ fn handle_screen_catalog_request(
channels: Res<IsoChannels>,
mut screens: ResMut<ScreenBrowser>,
) {
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<ScreenPak> {
fn build_screen_catalog(
source: &SourceKind,
files: &[String],
fragments: bool,
) -> Vec<ScreenPak> {
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<ScreenPak>
}
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<Vec<u8>> = 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<Vec<u8>> = 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),
);

View File

@@ -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(),
});
}