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:
Sylpheed RE agent
2026-08-18 16:32:40 +00:00
parent 18947218b4
commit d67c1da467
3 changed files with 324 additions and 7 deletions

View File

@@ -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,

View File

@@ -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,
})
}

View 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}"
);
}