The title screen needs an order that puts element 13 (`ptbase2.t32`, the full-screen background) behind elements 0-5 (the wordmarks) — the capture shows the wordmarks on top, so the declaration table is not it. Two other orderings the bundle carries were the cheap candidates, and both are now dead: * the **placement region** stores a keyframe group per element with an explicit element index, so it could be a second ordering. It is not — it equals the declaration order on every build on the disc. `UiBuild::placement_order` exposes it and `placement_region_order_is_never_a_second_ordering` pins it, so the refutation stays checkable instead of remembered. * the **RATC child order** is the declaration order with the `.prm` elements absent — strictly less information, and no place to put the background other than where the table already puts it. `screen info --geometry` prints both, plus each element's decoded sprite size beside `pivot*2` and every keyframe's scale/position/time — the numbers a placement hypothesis has to be tested against, and how the pivot/scale rule in the previous commit was found. `title_background_is_full_screen` pins that rule against the disc rather than a synthetic sprite. `scaled_elements_are_a_small_and_mostly_undiscriminating_minority` reports the scope honestly: 865 of 5 130 resting placements are scaled at all, and only 213 of those could tell "about the pivot" from "about the sprite centre" — which the capture did *not* settle, because `ptbase2`'s pivot is its centre. It also counts how far `pivot*2` is from the decoded size disc-wide (2 521 agree, 1 884 are off by more than 16 px), which demotes the "pivot is exactly half the texture" result to a property of the tutorial bundle.
219 lines
7.9 KiB
Rust
219 lines
7.9 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}"
|
|
);
|
|
}
|