formats/cli: the bundle's other orderings, and a guard that neither is a paint order
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.
This commit is contained in:
@@ -137,6 +137,12 @@ enum ScreenCommands {
|
||||
/// Which build (index into `screen list`); default = the largest
|
||||
#[arg(long)]
|
||||
build: Option<usize>,
|
||||
/// 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
|
||||
/// to be tested against.
|
||||
#[arg(long)]
|
||||
geometry: bool,
|
||||
},
|
||||
/// Composite one build to a PNG — the headless self-verify for the viewer
|
||||
Render {
|
||||
@@ -301,7 +307,11 @@ async fn main() -> Result<()> {
|
||||
},
|
||||
Commands::Screen { cmd } => match cmd {
|
||||
ScreenCommands::List { pak } => cmd_screen_list(&pak),
|
||||
ScreenCommands::Info { pak, build } => cmd_screen_info(&pak, build),
|
||||
ScreenCommands::Info {
|
||||
pak,
|
||||
build,
|
||||
geometry,
|
||||
} => cmd_screen_info(&pak, build, geometry),
|
||||
ScreenCommands::Render {
|
||||
pak,
|
||||
output,
|
||||
@@ -379,7 +389,7 @@ fn cmd_screen_list(pak: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_screen_info(pak: &Path, want: Option<usize>) -> Result<()> {
|
||||
fn cmd_screen_info(pak: &Path, want: Option<usize>, geometry: bool) -> Result<()> {
|
||||
use sylpheed_formats::ui_layout;
|
||||
let builds = screen_builds(pak)?;
|
||||
let idx = pick_build(&builds, want)?;
|
||||
@@ -436,9 +446,82 @@ fn cmd_screen_info(pak: &Path, want: Option<usize>) -> Result<()> {
|
||||
println!("{:<3} {:<30} → focus {link}", "", "");
|
||||
}
|
||||
}
|
||||
if geometry {
|
||||
print_geometry(&b, bytes);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The two other orderings the bundle carries, and each element's real drawn
|
||||
/// size. Both were needed to settle the title screen's paint order — see
|
||||
/// `docs/re/BACKLOG.md`.
|
||||
fn print_geometry(b: &sylpheed_formats::ui_layout::UiBuild, bytes: &[u8]) {
|
||||
println!();
|
||||
let identity: Vec<usize> = (0..b.placement_order.len()).collect();
|
||||
println!(
|
||||
"placement-region group order: {:?}{}",
|
||||
b.placement_order,
|
||||
if b.placement_order == identity {
|
||||
" (== declaration order)"
|
||||
} else {
|
||||
" (DIFFERS from declaration order)"
|
||||
}
|
||||
);
|
||||
if let Some(kids) = sylpheed_formats::ratc::parse(bytes) {
|
||||
println!(
|
||||
"RATC child order: {:?}",
|
||||
kids.iter()
|
||||
.map(|c| format!("{}:{}", c.kind, c.name))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
println!();
|
||||
println!("geometry — decoded sprite size vs the declared pivot, and every keyframe");
|
||||
println!(
|
||||
"{:<3} {:<26} {:>11} {:>11} {:>5} keyframes t: x,y sx%,sy% a=fade-alpha",
|
||||
"#", "sprite", "decoded", "pivot*2", "same"
|
||||
);
|
||||
for el in &b.elements {
|
||||
let dims = el
|
||||
.sprite
|
||||
.as_ref()
|
||||
.and_then(|s| b.sprites.get(s))
|
||||
.and_then(|&(off, size)| sylpheed_formats::t8ad::parse(&bytes[off..off + size]))
|
||||
.map(|img| (img.width, img.height));
|
||||
let pv = (el.pivot_x * 2, el.pivot_y * 2);
|
||||
let same = match dims {
|
||||
Some(d) if d == pv => "yes",
|
||||
Some(_) => "NO",
|
||||
None => "-",
|
||||
};
|
||||
let kfs = el
|
||||
.keyframes
|
||||
.iter()
|
||||
.map(|f| {
|
||||
format!(
|
||||
"{}: {},{} {}%,{}% a={}",
|
||||
f.time.map(|v| v.to_string()).unwrap_or_else(|| "-".into()),
|
||||
f.x,
|
||||
f.y,
|
||||
f.scale_x,
|
||||
f.scale_y,
|
||||
(f.fade >> 24) & 0xff
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
println!(
|
||||
"{:<3} {:<26} {:>11} {:>11} {:>5} {kfs}",
|
||||
el.index,
|
||||
el.sprite.as_deref().unwrap_or("—"),
|
||||
dims.map(|(w, h)| format!("{w}x{h}"))
|
||||
.unwrap_or_else(|| "—".into()),
|
||||
format!("{}x{}", pv.0, pv.1),
|
||||
same,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_screen_render(
|
||||
pak: &Path,
|
||||
output: &Path,
|
||||
|
||||
@@ -169,6 +169,14 @@ pub struct UiBuild {
|
||||
/// by scanning `.rat` records instead — placements are then per-record and
|
||||
/// `.rat`-less elements are missing.
|
||||
pub from_fallback: bool,
|
||||
/// Element indices in the order the placement region stores their keyframe
|
||||
/// groups. Each group names its element explicitly, so this *could* be a
|
||||
/// second, independent ordering — and therefore a candidate for the paint
|
||||
/// order the title screen needs. It is not: it equals the declaration order
|
||||
/// on every build on the disc. Kept, with
|
||||
/// `placement_region_order_is_never_a_second_ordering` guarding it, so the
|
||||
/// refutation stays checkable rather than remembered.
|
||||
pub placement_order: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Whether `bundle` is a RATC screen build (has at least one `.rat` layout child).
|
||||
@@ -254,8 +262,9 @@ fn parse_decls(bundle: &[u8]) -> Option<Vec<Element>> {
|
||||
|
||||
/// Read the placement region that follows the declaration table, filling in each
|
||||
/// element's keyframe group.
|
||||
fn parse_placements(bundle: &[u8], elements: &mut [Element]) {
|
||||
fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec<usize> {
|
||||
let count = elements.len();
|
||||
let mut order = Vec::with_capacity(count);
|
||||
let mut pos = DECL_TABLE_AT + count * DECL_ENTRY;
|
||||
for _ in 0..count {
|
||||
if pos + 8 > bundle.len() {
|
||||
@@ -290,8 +299,10 @@ fn parse_placements(bundle: &[u8], elements: &mut [Element]) {
|
||||
});
|
||||
}
|
||||
elements[idx].keyframes = group;
|
||||
order.push(idx);
|
||||
pos = group_end;
|
||||
}
|
||||
order
|
||||
}
|
||||
|
||||
/// Parse a build bundle into its elements and sprite table.
|
||||
@@ -308,15 +319,19 @@ pub fn parse_build(bundle: &[u8]) -> Option<UiBuild> {
|
||||
}
|
||||
}
|
||||
|
||||
let (mut elements, from_fallback) = match parse_decls(bundle) {
|
||||
let (mut elements, from_fallback, placement_order) = match parse_decls(bundle) {
|
||||
Some(mut els) => {
|
||||
parse_placements(bundle, &mut els);
|
||||
(els, false)
|
||||
let order = parse_placements(bundle, &mut els);
|
||||
(els, false, order)
|
||||
}
|
||||
// No usable declaration table: recover what the `.rat` records alone can
|
||||
// say. Elements without a record (eff*/deli*/msg) are then missing, so
|
||||
// callers are told via `from_fallback`.
|
||||
None => (fallback_elements(bundle, &records), true),
|
||||
None => {
|
||||
let els = fallback_elements(bundle, &records);
|
||||
let order = (0..els.len()).collect();
|
||||
(els, true, order)
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve each element to the sprite it draws, and pick up its focus link.
|
||||
@@ -355,6 +370,7 @@ pub fn parse_build(bundle: &[u8]) -> Option<UiBuild> {
|
||||
sprites,
|
||||
context_hint,
|
||||
from_fallback,
|
||||
placement_order,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
218
crates/sylpheed-formats/tests/ui_paint_order_disc.rs
Normal file
218
crates/sylpheed-formats/tests/ui_paint_order_disc.rs
Normal file
@@ -0,0 +1,218 @@
|
||||
//! 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}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user