cli/formats: let the screen commands reach bundles with no .rat child
The developer-logo splash declares its sprites directly and has no .rat layout child, so is_build rejected it and no screen command could render it — despite it being one of only two screens whose paint order has been read off the running game, and the one where the layer key explains the whole permutation. Adds ui_layout::is_composable (a declaration table plus at least one element resolving to a T8aD the bundle carries) and an opt-in --all on screen list/info/render. Measured on the disc: 2859 RATC bundles, 965 pass is_build, 2751 pass is_composable, and 0 pass is_build without passing it — a strict superset. It is opt-in because the 1786 extra bundles are mostly two-element fragments (a button and its glow), and because widening the default would renumber --build for every pak, invalidating the build indices the corpus's notes cite by number. The splash now renders 6/7 elements, painting its glows first in the order measured off the game; a disc test pins that order.
This commit is contained in:
@@ -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<usize>,
|
||||
/// 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<Vec<(usize, Vec<u8>)>> {
|
||||
///
|
||||
/// `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<Vec<(usize, Vec<u8>)>> {
|
||||
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<u8>)], want: Option<usize>) -> Result<usize>
|
||||
}
|
||||
}
|
||||
|
||||
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<usize>, geometry: bool) -> Result<()> {
|
||||
fn cmd_screen_info(pak: &Path, want: Option<usize>, 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")?;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
86
docs/re/structures/ui-composable-bundles.md
Normal file
86
docs/re/structures/ui-composable-bundles.md
Normal file
@@ -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"]
|
||||
```
|
||||
|
||||

|
||||
|
||||
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.
|
||||
Reference in New Issue
Block a user