Files
Sylpheed/crates/sylpheed-formats/tests/ui_paint_order_disc.rs
Sylpheed RE agent afb737b8d0 formats: composite in the MEASURED paint order, and drop the ghost instances
The compositor painted in declaration order, which the draw capture proved wrong
— the title's background is declared ninth and paints first. The order the game
uses is its runtime child list, and no decoded field reproduces it, so rather
than leave the screen composited wrongly this lands the orders that HAVE been
read off the running game and falls back to declaration order everywhere else.
Two builds are covered: GP_TITLE's title build and the GAME ARTS / SETA / studio
anima splash. Keyed by element names, which identify a build across paks and
language variants.

Rendering it exposed a second defect, and the same capture settles it: the
kind = 0x4 elements are motion-trail ghosts, not resting content. The bundle
declares three instances of each wordmark; the capture shows exactly ONE quad at
each wordmark's position. Drawing them at their resting keyframe put three
oversized PROJECT SYLPHEED copies across the composite. They are now skipped.

Verified with an artifact, not a green build: the composite is committed
(captures/title-composited-measured-order.png) and now reads as the title screen
— background, planet, ship, wordmark, TM, copyright, correctly layered.

The test is disc-gated and was checked BOTH ways: it passes as landed, and
disabling the order table makes it fail. It reads one pak rather than every
build on the disc — the first version used the all-builds helper and got the
test process OOM-killed running alongside the other three.
2026-08-19 02:32:14 +00:00

278 lines
10 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
}
/// 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");
}