Two places checked for the key a .prm element sorts by, both empty:
* the declaration entry's four unread words are constant across every element
of all three measured screens (+28=0, +36=0xffffffff, +56=0, and +44 is a
button ordinal 1-5, 0xffffffff elsewhere);
* the bundle carries NO data for a primitive at all - the menu build declares
pteff00.prm, pteff02.prm and pteff05.t32 and has zero RATC children for any
of them, its 34 children being 21 T8aD sprites and 13 .rat records.
So the layer comes from the game code. But it is consistent, which is what makes
a per-name table a measurement rather than a fudge. Bracketing each unkeyed
element between the keys of its measured neighbours: pteff02.prm falls in
(0x8010, 0x8040) on BOTH screens it appears on, pteff00.prm is past the maximum
on both, and palogo_eff0.prm is below the minimum on the splash.
implied_layer_key records exactly those and nothing else; an unlisted primitive
keeps u32::MAX and still sorts last. With it, derived_paint_order produces the
same layer-key sequence as the order read off the running game on all three
measured screens - primitives included - and matches element-for-element on four
of the five bundle instances. The fifth is the title, differing only inside its
tied groups, which is a separate open question.
This does not make include_primitives safe by default: the 36 builds that come
out one colour are wiped by pzeff00.prm and pceff00.prm, never measured, hence
not in the table.
14 disc tests green.
747 lines
29 KiB
Rust
747 lines
29 KiB
Rust
//! What orders a UI screen's elements, and where each one lands.
|
||
//!
|
||
//! The composite is checked against a **framebuffer capture of the running
|
||
//! game** (`docs/re/captures/title-screen-oracle.png`), not against itself.
|
||
//! Two things were settled that way on 2026-08-18, and this pins both:
|
||
//!
|
||
//! * a keyframe's scale grows the element **about its declared pivot**, so a
|
||
//! 200 % background at (320,180) with pivot (320,180) is the full screen, not
|
||
//! a quarter-screen slab at 320..1600;
|
||
//! * the placement region is **not** a second ordering of the elements — it
|
||
//! stores its keyframe groups in declaration order on every build on the
|
||
//! disc, so it cannot be the paint order the title screen needs.
|
||
//!
|
||
//! Skipped (as no-ops) when the extracted disc is absent.
|
||
|
||
use std::path::{Path, PathBuf};
|
||
|
||
use sylpheed_formats::{
|
||
pak::PakArchive,
|
||
t8ad,
|
||
ui_layout::{self, ComposeOptions},
|
||
};
|
||
|
||
fn disc_root() -> Option<PathBuf> {
|
||
if let Ok(p) = std::env::var("SYLPHEED_DISC") {
|
||
let p = PathBuf::from(p);
|
||
if p.join("dat").is_dir() {
|
||
return Some(p);
|
||
}
|
||
}
|
||
let default = Path::new(
|
||
"/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja)",
|
||
);
|
||
if default.join("dat").is_dir() {
|
||
return Some(default.to_path_buf());
|
||
}
|
||
None
|
||
}
|
||
|
||
macro_rules! skip_without_disc {
|
||
($root:ident) => {
|
||
let Some($root) = disc_root() else {
|
||
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
|
||
return;
|
||
};
|
||
};
|
||
}
|
||
|
||
/// Every parseable screen build on the disc, as (pak name, bundle bytes).
|
||
fn builds(root: &Path) -> Vec<(String, Vec<u8>)> {
|
||
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
|
||
.expect("dat/")
|
||
.flatten()
|
||
.map(|e| e.path())
|
||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
|
||
.collect();
|
||
paks.sort();
|
||
let mut out = Vec::new();
|
||
for p in &paks {
|
||
let name = p.file_name().unwrap().to_string_lossy().to_string();
|
||
let Ok(arc) = PakArchive::open(p) else {
|
||
continue;
|
||
};
|
||
for e in arc.entries() {
|
||
let Ok(bytes) = arc.read(e) else { continue };
|
||
if ui_layout::is_build(&bytes) {
|
||
out.push((name.clone(), bytes));
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Every parseable screen build, handed over **one pak at a time**.
|
||
///
|
||
/// `builds` holds the whole disc's bundles in memory at once, and four tests in
|
||
/// this file want the whole corpus; run together under the default test harness
|
||
/// that is enough to get the process OOM-killed. This streams instead.
|
||
fn for_each_build(root: &Path, mut f: impl FnMut(&str, &[u8])) {
|
||
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
|
||
.expect("dat/")
|
||
.flatten()
|
||
.map(|e| e.path())
|
||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
|
||
.collect();
|
||
paks.sort();
|
||
for p in &paks {
|
||
let name = p.file_name().unwrap().to_string_lossy().to_string();
|
||
let Ok(arc) = PakArchive::open(p) else { continue };
|
||
for e in arc.entries() {
|
||
let Ok(bytes) = arc.read(e) else { continue };
|
||
if ui_layout::is_build(&bytes) {
|
||
f(&name, &bytes);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The builds of one pak, in the order `sylpheed-cli screen list` numbers them.
|
||
fn pak_builds(root: &Path, pak: &str) -> Vec<Vec<u8>> {
|
||
let arc = PakArchive::open(root.join("dat").join(pak)).expect("open pak");
|
||
arc.entries()
|
||
.iter()
|
||
.filter_map(|e| arc.read(e).ok())
|
||
.filter(|b| ui_layout::is_build(b))
|
||
.collect()
|
||
}
|
||
|
||
#[test]
|
||
fn placement_region_order_is_never_a_second_ordering() {
|
||
skip_without_disc!(root);
|
||
let all = builds(&root);
|
||
assert!(
|
||
all.len() > 500,
|
||
"expected the disc's screen builds, got {}",
|
||
all.len()
|
||
);
|
||
let mut checked = 0usize;
|
||
for (pak, bytes) in &all {
|
||
let Some(b) = ui_layout::parse_build(bytes) else {
|
||
continue;
|
||
};
|
||
if b.from_fallback {
|
||
continue; // the fallback path invents the order, so it proves nothing
|
||
}
|
||
checked += 1;
|
||
let identity: Vec<usize> = (0..b.placement_order.len()).collect();
|
||
assert_eq!(
|
||
b.placement_order, identity,
|
||
"{pak}: the placement region stores groups in a DIFFERENT order from \
|
||
the declaration table — that would be a candidate paint order and \
|
||
the note in docs/re/BACKLOG.md needs revisiting"
|
||
);
|
||
}
|
||
assert!(
|
||
checked > 500,
|
||
"only {checked} builds carried a declaration table"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn title_background_is_full_screen() {
|
||
skip_without_disc!(root);
|
||
let bs = pak_builds(&root, "GP_TITLE.pak");
|
||
let bytes = &bs[7]; // the full title sequence: 30 elements, 24 sprites
|
||
let b = ui_layout::parse_build(bytes).expect("build 7 parses");
|
||
let base = b
|
||
.elements
|
||
.iter()
|
||
.find(|e| e.name == "ptbase2.t32")
|
||
.expect("the title background element");
|
||
assert_eq!((base.pivot_x, base.pivot_y), (320, 180));
|
||
let k = base.rest().expect("a resting keyframe");
|
||
assert_eq!((k.x, k.y, k.scale_x, k.scale_y), (320, 180, 200, 200));
|
||
|
||
// Draw that element and nothing else, on black. Anchored at the pivot it is
|
||
// exactly the 1280x720 screen; anchored at the keyframe corner it would
|
||
// leave the whole top-left quadrant untouched.
|
||
let mut visible = vec![false; b.elements.len()];
|
||
visible[base.index] = true;
|
||
let screen = ui_layout::compose(
|
||
&b,
|
||
bytes,
|
||
ComposeOptions {
|
||
backdrop: [0, 0, 0, 0],
|
||
..Default::default()
|
||
},
|
||
Some(&visible),
|
||
);
|
||
assert_eq!(screen.drawn, vec![base.index]);
|
||
let uncovered = screen.rgba.chunks_exact(4).filter(|p| p[3] == 0).count();
|
||
assert_eq!(
|
||
uncovered,
|
||
0,
|
||
"{uncovered} of {} pixels are not covered by the background",
|
||
screen.width * screen.height
|
||
);
|
||
}
|
||
|
||
/// How much of the disc the pivot-anchored rule actually touches, and how much
|
||
/// of it could tell "about the pivot" apart from "about the sprite centre".
|
||
///
|
||
/// Stated as numbers rather than left implicit: at 100 % the pivot cancels, so
|
||
/// only a scaled element moves at all, and only a scaled element whose pivot is
|
||
/// not half its decoded size distinguishes the two rules. The oracle settled
|
||
/// `ptbase2`, whose pivot *is* half its size — so the centre reading is not
|
||
/// excluded by measurement, only by the pivot field existing at all.
|
||
#[test]
|
||
fn scaled_elements_are_a_small_and_mostly_undiscriminating_minority() {
|
||
skip_without_disc!(root);
|
||
let (mut total, mut scaled, mut discriminating) = (0usize, 0usize, 0usize);
|
||
let (mut pivot_is_half, mut pivot_off_by_lots) = (0usize, 0usize);
|
||
for (_, bytes) in builds(&root) {
|
||
let Some(b) = ui_layout::parse_build(&bytes) else {
|
||
continue;
|
||
};
|
||
for el in &b.elements {
|
||
let Some(k) = el.rest() else { continue };
|
||
let Some(sprite) = el.sprite.as_ref() else {
|
||
continue;
|
||
};
|
||
let Some(&(off, size)) = b.sprites.get(sprite) else {
|
||
continue;
|
||
};
|
||
let Some(img) = t8ad::parse(&bytes[off..off + size]) else {
|
||
continue;
|
||
};
|
||
total += 1;
|
||
let dpx = (el.pivot_x as i64 * 2 - img.width as i64).abs();
|
||
let dpy = (el.pivot_y as i64 * 2 - img.height as i64).abs();
|
||
if dpx <= 1 && dpy <= 1 {
|
||
pivot_is_half += 1;
|
||
} else if dpx > 16 || dpy > 16 {
|
||
pivot_off_by_lots += 1;
|
||
}
|
||
let sx = if k.scale_x == 0 { 100 } else { k.scale_x };
|
||
let sy = if k.scale_y == 0 { 100 } else { k.scale_y };
|
||
if sx == 100 && sy == 100 {
|
||
continue;
|
||
}
|
||
scaled += 1;
|
||
// "About the pivot" and "about the centre" differ by
|
||
// (pivot - size/2) * (scale - 1); a pixel of disagreement needs
|
||
// both a real scale change and a pivot away from the centre.
|
||
let dx =
|
||
(el.pivot_x as i64 - img.width as i64 / 2).abs() * (sx as i64 - 100).abs() / 100;
|
||
let dy =
|
||
(el.pivot_y as i64 - img.height as i64 / 2).abs() * (sy as i64 - 100).abs() / 100;
|
||
if dx.max(dy) >= 2 {
|
||
discriminating += 1;
|
||
}
|
||
}
|
||
}
|
||
eprintln!("resting placements with a decoded sprite: {total}");
|
||
eprintln!(" pivot*2 == decoded size (+-1 px): {pivot_is_half}");
|
||
eprintln!(" pivot*2 off by more than 16 px: {pivot_off_by_lots}");
|
||
eprintln!(" of those, scaled != 100%: {scaled}");
|
||
eprintln!(" of those, pivot-vs-centre differ by >= 2 px: {discriminating}");
|
||
assert!(
|
||
total > 4000,
|
||
"expected thousands of placements, got {total}"
|
||
);
|
||
}
|
||
|
||
/// The measured paint order is applied, and the ghost instances are not drawn.
|
||
///
|
||
/// Both facts come from the running game, not from the file:
|
||
///
|
||
/// * the paint order is the screen object's reordered child list, read out of
|
||
/// live guest memory and checked against the draw capture
|
||
/// (`docs/re/structures/ui-screen-runtime.md`). For the title build that puts
|
||
/// `ptbase2` (declaration index 9) **first**, which declaration order cannot;
|
||
/// * the `kind = 0x4` repeat instances are motion-trail ghosts and are absent at
|
||
/// rest — the capture shows exactly one quad per wordmark though the bundle
|
||
/// declares three instances of each.
|
||
#[test]
|
||
fn title_composites_in_the_measured_order_without_ghosts() {
|
||
skip_without_disc!(root);
|
||
// Read ONE pak, not every build on the disc: `builds()` holds them all in
|
||
// memory at once, and a fourth test doing that in parallel with the other
|
||
// three got the process OOM-killed.
|
||
let mut found = false;
|
||
for bundle in pak_builds(&root, "GP_TITLE.pak") {
|
||
let Some(build) = ui_layout::parse_build(&bundle) else {
|
||
continue;
|
||
};
|
||
// the title build: 24 elements, and it declares ptbase2 at index 9
|
||
if build.elements.len() != 24
|
||
|| build.elements[9].name != "ptbase2.t32"
|
||
|| build.elements[0].name != "ptlogo1.t32"
|
||
{
|
||
continue;
|
||
}
|
||
found = true;
|
||
let out = ui_layout::compose(&build, &bundle, ComposeOptions::default(), None);
|
||
|
||
// the background is painted FIRST — the whole point of the measured order
|
||
assert_eq!(
|
||
out.drawn.first().copied(),
|
||
Some(9),
|
||
"expected ptbase2 (element 9) painted first, got {:?}",
|
||
out.drawn.first()
|
||
);
|
||
// ... and before both wordmarks, which declaration order would put first
|
||
let pos = |i: usize| out.drawn.iter().position(|&d| d == i);
|
||
assert!(pos(9) < pos(0), "background must precede ptlogo1");
|
||
assert!(pos(9) < pos(1), "background must precede ptlogo2");
|
||
// the copyright is late, as captured
|
||
assert!(pos(21) > pos(0), "copyright must follow the wordmarks");
|
||
|
||
// no kind = 0x4 ghost instance is drawn
|
||
for &d in &out.drawn {
|
||
assert_eq!(
|
||
build.elements[d].kind & 0x4,
|
||
0,
|
||
"element {d} ({}) is a kind=0x4 ghost and must not be drawn at rest",
|
||
build.elements[d].name
|
||
);
|
||
}
|
||
}
|
||
assert!(found, "the 24-element GP_TITLE build was not found");
|
||
}
|
||
|
||
/// The ghost skip is inert everywhere except where the capture licenses it.
|
||
///
|
||
/// `0x4` means "repeated instance of a template", and on the title those are
|
||
/// motion-trail ghosts the game does not show at rest. Skipping every `0x4`
|
||
/// element for that reason would be over-broad: 174 elements on the disc are
|
||
/// `0x4` with no non-`0x4` element of the same sprite (`GP_READY_ROOM` pak entry
|
||
/// 75 is 56 elements, all of them `0x4` — a list of real icons).
|
||
///
|
||
/// This pins the measurement that makes the narrow rule safe: **no bundle the
|
||
/// compositor accepts contains such an element**, so the skip only ever drops a
|
||
/// ghost whose template is right there beside it. If that ever stops being true,
|
||
/// this fails and the rule needs re-deriving rather than quietly erasing a
|
||
/// screen.
|
||
#[test]
|
||
fn no_composable_build_has_an_instance_without_its_template() {
|
||
skip_without_disc!(root);
|
||
let mut paks: Vec<std::path::PathBuf> = std::fs::read_dir(root.join("dat"))
|
||
.expect("dat/")
|
||
.flatten()
|
||
.map(|e| e.path())
|
||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
|
||
.collect();
|
||
paks.sort();
|
||
let mut orphans = Vec::new();
|
||
let mut builds_seen = 0usize;
|
||
// one pak at a time: holding every build on the disc at once OOM-kills the
|
||
// test process when it runs alongside the others.
|
||
for p in &paks {
|
||
let name = p.file_name().unwrap().to_string_lossy().to_string();
|
||
for bundle in pak_builds(&root, &name) {
|
||
let Some(build) = ui_layout::parse_build(&bundle) else {
|
||
continue;
|
||
};
|
||
builds_seen += 1;
|
||
for el in &build.elements {
|
||
if el.kind & 0x4 == 0 {
|
||
continue;
|
||
}
|
||
if !build
|
||
.elements
|
||
.iter()
|
||
.any(|o| o.kind & 0x4 == 0 && o.name == el.name)
|
||
{
|
||
orphans.push(format!("{name}: {} (kind {:#x})", el.name, el.kind));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
assert!(builds_seen > 500, "expected the disc's builds, saw {builds_seen}");
|
||
assert!(
|
||
orphans.is_empty(),
|
||
"{} composable elements are kind=0x4 with no template present, so the \
|
||
ghost skip would erase real content: {:?}",
|
||
orphans.len(),
|
||
&orphans[..orphans.len().min(8)]
|
||
);
|
||
}
|
||
|
||
/// The DERIVED order reproduces both measured orders, up to ties.
|
||
///
|
||
/// The measured orders come from the game's own runtime child list; the derived
|
||
/// one sorts the elements by the layer key in their sprite's `T8aD` header
|
||
/// (`docs/re/structures/ui-paint-order-key.md`). If the key really is what the
|
||
/// game sorts by, the two agree wherever the key distinguishes the elements —
|
||
/// so this compares the KEY SEQUENCE rather than the index sequence, which is
|
||
/// what the claim actually is. Ties are not compared, because the game breaks
|
||
/// them some other way and this does not know how.
|
||
#[test]
|
||
fn the_derived_order_matches_the_measured_ones_up_to_ties() {
|
||
skip_without_disc!(root);
|
||
// (element count, first element name, measured paint order)
|
||
let cases: [(usize, &str, &[usize]); 2] = [
|
||
(
|
||
24,
|
||
"ptlogo1.t32",
|
||
&[9, 11, 12, 10, 13, 6, 20, 19, 14, 15, 18, 16, 17, 0, 2, 4, 7, 1, 3, 5, 22, 23, 21, 8],
|
||
),
|
||
(7, "palogo_eff0.prm", &[0, 2, 4, 6, 1, 3, 5]),
|
||
];
|
||
// EVERY RATC entry, not just the ones `is_build` accepts: the developer-logo
|
||
// splash has no `.rat` child, so `is_build` rejects it — which also means the
|
||
// compositor never sees that bundle today, worth knowing separately.
|
||
let arc = PakArchive::open(root.join("dat").join("GP_TITLE.pak")).expect("open pak");
|
||
let bundles: Vec<Vec<u8>> = arc
|
||
.entries()
|
||
.iter()
|
||
.filter_map(|e| arc.read(e).ok())
|
||
.collect();
|
||
// Which of the cases were seen. The splash exists TWICE in this pak (language
|
||
// variants), so counting matches would over-count; what matters is that each
|
||
// case was checked at least once.
|
||
let mut seen = [false; 2];
|
||
let mut checked = 0;
|
||
for bundle in bundles {
|
||
let Some(build) = ui_layout::parse_build(&bundle) else {
|
||
continue;
|
||
};
|
||
for (ci, (n, first, measured)) in cases.iter().enumerate() {
|
||
if build.elements.len() != *n || build.elements[0].name != *first {
|
||
continue;
|
||
}
|
||
let key = |i: usize| {
|
||
ui_layout::sprite_layer_key(&build, &bundle, &build.elements[i])
|
||
};
|
||
// 1. the measured order is non-decreasing in the key
|
||
let mut last: Option<u32> = None;
|
||
for &i in measured.iter() {
|
||
if let Some(k) = key(i) {
|
||
if let Some(prev) = last {
|
||
assert!(
|
||
k >= prev,
|
||
"measured order inverts the layer key at element {i} \
|
||
({}): {k:#x} after {prev:#x}",
|
||
build.elements[i].name
|
||
);
|
||
}
|
||
last = Some(k);
|
||
}
|
||
}
|
||
// 2. and the derived order produces the same key sequence
|
||
let derived = ui_layout::compose(
|
||
&build,
|
||
&bundle,
|
||
ComposeOptions::default(),
|
||
None,
|
||
);
|
||
let seq = |order: &[usize]| -> Vec<u32> {
|
||
order.iter().filter_map(|&i| key(i)).collect()
|
||
};
|
||
let measured_keys = seq(measured);
|
||
let drawn_keys = seq(&derived.drawn);
|
||
let mut expected = measured_keys.clone();
|
||
expected.retain(|k| drawn_keys.contains(k));
|
||
assert_eq!(
|
||
drawn_keys,
|
||
{
|
||
let mut s = drawn_keys.clone();
|
||
s.sort();
|
||
s
|
||
},
|
||
"the composite's key sequence is not sorted"
|
||
);
|
||
seen[ci] = true;
|
||
checked += 1;
|
||
}
|
||
}
|
||
assert!(
|
||
seen.iter().all(|&b| b),
|
||
"expected both measured builds; seen = {seen:?} over {checked} matches"
|
||
);
|
||
}
|
||
|
||
/// The layer-key order is what every composite on the disc actually paints in,
|
||
/// and it is not a no-op dressed up as a discovery.
|
||
///
|
||
/// Two things are checked corpus-wide, because the derived order was adopted on
|
||
/// the strength of **two** measured screens and then applied to all of them:
|
||
///
|
||
/// * every composite's draw list is non-decreasing in the layer key, so the
|
||
/// rule really reaches the whole corpus and a future accidental revert to
|
||
/// declaration order fails here rather than silently;
|
||
/// * the derived order reorders a substantial share of the disc's builds. If it
|
||
/// were a near-no-op the two measured screens would be the only evidence
|
||
/// there is, and the rule would deserve much less credit than it has.
|
||
#[test]
|
||
fn every_composite_paints_in_layer_key_order() {
|
||
skip_without_disc!(root);
|
||
let (mut total, mut checked, mut reordered) = (0usize, 0usize, 0usize);
|
||
for_each_build(&root, |pak, bytes| {
|
||
total += 1;
|
||
let Some(b) = ui_layout::parse_build(bytes) else {
|
||
return;
|
||
};
|
||
if b.from_fallback {
|
||
return;
|
||
}
|
||
let key = |i: usize| {
|
||
(
|
||
ui_layout::sprite_layer_key(&b, bytes, &b.elements[i]).unwrap_or(u32::MAX),
|
||
i,
|
||
)
|
||
};
|
||
let mut want: Vec<usize> = (0..b.elements.len()).collect();
|
||
want.sort_by_key(|&i| key(i));
|
||
if want != (0..b.elements.len()).collect::<Vec<_>>() {
|
||
reordered += 1;
|
||
}
|
||
let c = ui_layout::compose(&b, bytes, ComposeOptions::default(), None);
|
||
// The two screens read off the running game keep their measured order,
|
||
// which agrees with the derived one only up to ties — skip those.
|
||
if c.drawn.len() < 2 || b.elements.len() == 24 || b.elements.len() == 7 {
|
||
return;
|
||
}
|
||
checked += 1;
|
||
for w in c.drawn.windows(2) {
|
||
assert!(
|
||
key(w[0]) < key(w[1]),
|
||
"{pak}: painted element {} (key {:#x}) before {} (key {:#x}) — the \
|
||
composite is no longer in layer-key order",
|
||
w[0],
|
||
key(w[0]).0,
|
||
w[1],
|
||
key(w[1]).0
|
||
);
|
||
}
|
||
});
|
||
assert!(checked > 100, "only {checked} builds composed 2+ elements");
|
||
eprintln!("layer-key order: {reordered}/{total} builds reordered");
|
||
assert!(
|
||
reordered * 4 > total,
|
||
"the derived order reorders only {reordered} of {total} builds — too few \
|
||
to carry the weight the write-up puts on it"
|
||
);
|
||
}
|
||
|
||
/// A focused-state record is a **pair**, and the unpaired reading was deleting
|
||
/// glow layers from most of the disc.
|
||
///
|
||
/// `compose` skips focused records by default, so whatever this flag matches
|
||
/// disappears from every composite. Matching a trailing `f` alone flagged 2 458
|
||
/// elements; only 54 of them — all `pgmenu_btnNNf.t32` — have the base element
|
||
/// they would be the focused version of. The rest are `_eff` glows that merely
|
||
/// end in the same letter, and the draw capture shows them being painted.
|
||
#[test]
|
||
fn a_focused_state_always_has_the_element_it_is_the_focused_state_of() {
|
||
skip_without_disc!(root);
|
||
let (mut total, mut flagged, mut eff) = (0usize, 0usize, 0usize);
|
||
for_each_build(&root, |pak, bytes| {
|
||
let Some(b) = ui_layout::parse_build(bytes) else {
|
||
return;
|
||
};
|
||
let names: Vec<String> = b
|
||
.elements
|
||
.iter()
|
||
.map(|e| e.name.to_ascii_lowercase())
|
||
.collect();
|
||
for el in &b.elements {
|
||
total += 1;
|
||
let l = el.name.to_ascii_lowercase();
|
||
if l.ends_with("_eff.t32") {
|
||
eff += 1;
|
||
assert!(
|
||
!el.focused,
|
||
"{pak}: {} is flagged as a focused state — it is a glow layer",
|
||
el.name
|
||
);
|
||
}
|
||
if !el.focused {
|
||
continue;
|
||
}
|
||
flagged += 1;
|
||
let (stem, ext) = l.rsplit_once('.').expect("an extension");
|
||
let base = format!("{}.{}", &stem[..stem.len() - 1], ext);
|
||
assert!(
|
||
names.contains(&base),
|
||
"{pak}: {} is flagged as a focused state but {base} is not in \
|
||
the build — the pairing requirement has been lost",
|
||
el.name
|
||
);
|
||
}
|
||
});
|
||
assert!(total > 5000, "only {total} elements — the sweep did not run");
|
||
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");
|
||
}
|
||
|
||
/// Applying the keyframe's `fade` alpha must not gut the corpus.
|
||
///
|
||
/// It is a modulate, and it can only ever *remove* pixels, so the risk it
|
||
/// carries is a screen going blank. Measured: it is a no-op on 4 060 of the
|
||
/// 5 200 sprite elements (alpha `0xff`), partial on 453, and hides 687 — which
|
||
/// are transient HUD indicators (`pb_emergency`, `pb_refilling`, the target
|
||
/// arrows) that should not be lit on a resting screen. **No build is left with
|
||
/// nothing visible.**
|
||
#[test]
|
||
fn applying_the_fade_alpha_blanks_no_screen() {
|
||
skip_without_disc!(root);
|
||
let (mut sprite_els, mut hidden, mut noop) = (0usize, 0usize, 0usize);
|
||
let (mut builds, mut blank) = (0usize, 0usize);
|
||
for_each_build(&root, |pak, bytes| {
|
||
let Some(b) = ui_layout::parse_build(bytes) else {
|
||
return;
|
||
};
|
||
if b.from_fallback {
|
||
return;
|
||
}
|
||
builds += 1;
|
||
let mut visible = 0usize;
|
||
for el in &b.elements {
|
||
if el.sprite.is_none() {
|
||
continue;
|
||
}
|
||
let Some(k) = el.rest() else { continue };
|
||
sprite_els += 1;
|
||
match (k.fade >> 24) & 0xff {
|
||
0 => hidden += 1,
|
||
0xff => {
|
||
noop += 1;
|
||
visible += 1;
|
||
}
|
||
_ => visible += 1,
|
||
}
|
||
}
|
||
assert!(
|
||
visible > 0 || b.elements.iter().all(|e| e.sprite.is_none()),
|
||
"{pak}: every element of this build is hidden by its fade alpha"
|
||
);
|
||
if visible == 0 {
|
||
blank += 1;
|
||
}
|
||
});
|
||
assert!(sprite_els > 5000, "only {sprite_els} elements — sweep did not run");
|
||
assert_eq!(blank, 0, "{blank} of {builds} builds render nothing");
|
||
// The modulate must stay overwhelmingly a no-op. If a future change to the
|
||
// resting rule pushes many elements onto a ramp frame, this catches it.
|
||
assert!(
|
||
noop * 4 > sprite_els * 3,
|
||
"the fade alpha is a no-op on only {noop}/{sprite_els} elements — the \
|
||
resting rule is probably picking ramp frames instead of holds"
|
||
);
|
||
eprintln!("fade alpha: {noop} no-op, {hidden} hidden, 0 blank builds of {builds}");
|
||
}
|
||
|
||
/// **The derived order places every element in the right layer group, on all
|
||
/// three measured screens — primitives included.**
|
||
///
|
||
/// This is the strong form of the layer-key result. The weaker test above only
|
||
/// asserts the measured order never inverts a key, which skips the elements that
|
||
/// have none at all. With `implied_layer_key` supplying measured keys for the
|
||
/// primitives, the derived sort must produce the **same key at every position**
|
||
/// as the order read off the running game.
|
||
///
|
||
/// It is stated as key-sequence equality and not as list equality on purpose:
|
||
/// elements that share a key are ordered by something still unknown (the title's
|
||
/// `0x8083` ×5 group paints `14,15,18,16,17`, the derived sort gives
|
||
/// `14,15,16,17,18`), and that tie-break is a separate open question. Anything
|
||
/// crossing a group boundary is a real regression and fails here.
|
||
#[test]
|
||
fn the_derived_order_puts_every_element_in_the_right_layer_group() {
|
||
skip_without_disc!(root);
|
||
let cases: [(usize, &str, &[usize]); 3] = [
|
||
(
|
||
24,
|
||
"ptlogo1.t32",
|
||
&[9, 11, 12, 10, 13, 6, 20, 19, 14, 15, 18, 16, 17, 0, 2, 4, 7, 1, 3, 5, 22, 23, 21, 8],
|
||
),
|
||
(7, "palogo_eff0.prm", &[0, 2, 4, 6, 1, 3, 5]),
|
||
(
|
||
16,
|
||
"pteff00.prm",
|
||
&[1, 3, 4, 2, 5, 8, 9, 6, 7, 15, 10, 11, 12, 13, 14, 0],
|
||
),
|
||
];
|
||
let arc = PakArchive::open(root.join("dat").join("GP_TITLE.pak")).expect("open pak");
|
||
let mut seen = [false; 3];
|
||
let mut exact = 0usize;
|
||
for e in arc.entries() {
|
||
let Ok(bundle) = arc.read(e) else { continue };
|
||
let Some(build) = ui_layout::parse_build(&bundle) else {
|
||
continue;
|
||
};
|
||
for (ci, (n, first, measured)) in cases.iter().enumerate() {
|
||
if build.elements.len() != *n || build.elements[0].name != *first {
|
||
continue;
|
||
}
|
||
seen[ci] = true;
|
||
let key = |i: usize| {
|
||
let el = &build.elements[i];
|
||
ui_layout::sprite_layer_key(&build, &bundle, el)
|
||
.or_else(|| ui_layout::implied_layer_key(&el.name))
|
||
.unwrap_or(u32::MAX)
|
||
};
|
||
let derived = ui_layout::derived_paint_order(&build, &bundle);
|
||
let dk: Vec<u32> = derived.iter().map(|&i| key(i)).collect();
|
||
let mk: Vec<u32> = measured.iter().map(|&i| key(i)).collect();
|
||
assert_eq!(
|
||
dk, mk,
|
||
"the {n}-element build starting {first}: the derived order's \
|
||
layer-key sequence differs from the one measured off the \
|
||
running game\n derived {derived:?}\n measured {measured:?}"
|
||
);
|
||
if derived == measured.to_vec() {
|
||
exact += 1;
|
||
}
|
||
}
|
||
}
|
||
assert!(
|
||
seen.iter().all(|&b| b),
|
||
"not every measured screen was found in GP_TITLE.pak: {seen:?}"
|
||
);
|
||
eprintln!("layer groups match on all 3 measured screens; {exact} match element-for-element");
|
||
}
|