test: lock today's format findings into disc-wide guards
Each finding today was originally missed by reasoning from a sample, so these
assert DISC-WIDE invariants rather than one hand-picked file.
ui_surfaces_disc.rs (3 passing)
every_t8ad_on_the_disc_decodes -- all ~19216 surfaces; the old
256-grid model looked like 96%
lsta_count_equals_sprites_plus_primitives -- header counts T8aD AND PRMD,
64/64, which is what made the
count look unreliable
ratc_nesting_is_exactly_one_level -- nested records are leaves; zero
grandchildren disc-wide
mesh_consistency_disc.rs (1 ignored, deliberately)
shared_resources_decode_identically_in_every_container
The mesh test is written as the TARGET state, not a snapshot of the bug: a
resource shared by several containers must decode to the same bounds, which today
fails for 125 of 681 shared resources. Fixing the anchor scan makes it pass;
un-ignoring it is then the last step rather than a rewrite. It only compares
decodes that agree on vertex/triangle counts, so "found different geometry" stays
a separate question from "placed the same geometry differently".
All suites green: 81 lib + the disc guards, with 2 ignored.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
104
crates/sylpheed-formats/tests/mesh_consistency_disc.rs
Normal file
104
crates/sylpheed-formats/tests/mesh_consistency_disc.rs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
//! Cross-container consistency for XBG7 geometry.
|
||||||
|
//!
|
||||||
|
//! A geometry resource shared by several stage containers must decode to the
|
||||||
|
//! same bounds in each. This needs no ground truth, and on 2026-08-11 it found
|
||||||
|
//! **125 of 681** shared resources decoding to different bounds while reporting
|
||||||
|
//! identical vertex and triangle counts — the anchor scan locating a different
|
||||||
|
//! buffer of the same size (see `docs/re/structures/xbg7-mesh.md`).
|
||||||
|
//!
|
||||||
|
//! The test is `#[ignore]`d because the decoder does not satisfy it yet. It is
|
||||||
|
//! written as the *target* state so that fixing the anchor scan makes it pass,
|
||||||
|
//! rather than as a snapshot of the bug.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use sylpheed_formats::mesh::Xbg7Model;
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rounded (w, h, d) of a model's own geometry.
|
||||||
|
fn span(m: &Xbg7Model) -> Option<[i64; 3]> {
|
||||||
|
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
|
||||||
|
for s in &m.meshes {
|
||||||
|
for q in &s.positions {
|
||||||
|
for k in 0..3 {
|
||||||
|
lo[k] = lo[k].min(q[k]);
|
||||||
|
hi[k] = hi[k].max(q[k]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lo[0] == f32::MAX {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some([
|
||||||
|
(hi[0] - lo[0]).round() as i64,
|
||||||
|
(hi[1] - lo[1]).round() as i64,
|
||||||
|
(hi[2] - lo[2]).round() as i64,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "known-failing: 125 of 681 shared resources decode inconsistently (2026-08-11)"]
|
||||||
|
fn shared_resources_decode_identically_in_every_container() {
|
||||||
|
let Some(root) = disc_root() else {
|
||||||
|
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let dir = root.join("hidden/resource3d");
|
||||||
|
let mut files: Vec<PathBuf> = std::fs::read_dir(&dir)
|
||||||
|
.expect("resource3d/")
|
||||||
|
.flatten()
|
||||||
|
.map(|e| e.path())
|
||||||
|
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
|
||||||
|
.collect();
|
||||||
|
files.sort();
|
||||||
|
|
||||||
|
// name -> (verts, tris) -> set of spans seen
|
||||||
|
let mut seen: BTreeMap<String, Vec<([i64; 3], usize, usize, String)>> = BTreeMap::new();
|
||||||
|
for f in &files {
|
||||||
|
let Ok(bytes) = std::fs::read(f) else { continue };
|
||||||
|
let where_ = f.file_name().unwrap().to_string_lossy().to_string();
|
||||||
|
for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) {
|
||||||
|
let Some(sp) = span(&m) else { continue };
|
||||||
|
let v: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
|
||||||
|
let t: usize = m.meshes.iter().map(|s| s.indices.len() / 3).sum();
|
||||||
|
seen.entry(m.name.clone()).or_default().push((sp, v, t, where_.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut bad: Vec<String> = Vec::new();
|
||||||
|
for (name, list) in &seen {
|
||||||
|
if list.len() < 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Only compare decodes that agree on how much geometry they found;
|
||||||
|
// a differing vertex/triangle count is a different question.
|
||||||
|
if !list.iter().all(|e| e.1 == list[0].1 && e.2 == list[0].2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let spans: std::collections::BTreeSet<[i64; 3]> = list.iter().map(|e| e.0).collect();
|
||||||
|
if spans.len() > 1 && bad.len() < 10 {
|
||||||
|
bad.push(format!("{name}: {spans:?}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
bad.is_empty(),
|
||||||
|
"{} shared resources decode to different bounds; first: {bad:?}",
|
||||||
|
bad.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
152
crates/sylpheed-formats/tests/ui_surfaces_disc.rs
Normal file
152
crates/sylpheed-formats/tests/ui_surfaces_disc.rs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
//! Disc-wide guards for the 2D surface formats, locking in what was measured on
|
||||||
|
//! 2026-08-11. Skipped (as no-ops) when the extracted disc is absent.
|
||||||
|
//!
|
||||||
|
//! These assert *disc-wide invariants* rather than one hand-picked file, because
|
||||||
|
//! each of the findings they guard was originally missed by reasoning from a
|
||||||
|
//! sample: T8aD's "~15 % unsupported variants" were a wrong model, and LSTA's
|
||||||
|
//! "a few entries disagree with the count" was a miscount.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use sylpheed_formats::{lsta, pak::PakArchive, ratc, t8ad};
|
||||||
|
|
||||||
|
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 entry of every pak, plus every RATC child, as raw bytes.
|
||||||
|
fn for_each_blob(root: &Path, mut f: impl FnMut(&str, &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 pak_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 };
|
||||||
|
f(&pak_name, &format!("{:08x}", e.name_hash), &bytes);
|
||||||
|
if ratc::is_ratc(&bytes) {
|
||||||
|
if let Some(kids) = ratc::parse(&bytes) {
|
||||||
|
for k in &kids {
|
||||||
|
if k.offset + k.size <= bytes.len() {
|
||||||
|
f(&pak_name, &k.name, &bytes[k.offset..k.offset + k.size]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A T8aD surface is a list of sub-rectangles, and on this disc **every** one
|
||||||
|
/// decodes. Regressing the rectangle model would show up here as a decode gap,
|
||||||
|
/// which is exactly how the old 256-grid reading looked (96 %, not 100 %).
|
||||||
|
#[test]
|
||||||
|
fn every_t8ad_on_the_disc_decodes() {
|
||||||
|
skip_without_disc!(root);
|
||||||
|
let (mut total, mut ok) = (0usize, 0usize);
|
||||||
|
let mut first_failure = None;
|
||||||
|
for_each_blob(&root, |pak, name, b| {
|
||||||
|
if !t8ad::is_t8ad(b) || b.len() < 0x40 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
total += 1;
|
||||||
|
if t8ad::parse(b).is_some() {
|
||||||
|
ok += 1;
|
||||||
|
} else if first_failure.is_none() {
|
||||||
|
first_failure = Some(format!("{pak}:{name}"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert!(total > 19_000, "expected the disc's ~19 216 surfaces, saw {total}");
|
||||||
|
assert_eq!(ok, total, "first failure: {first_failure:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An LSTA's header count is exact and counts **both** kinds of element: T8aD
|
||||||
|
/// sprites and `PRMD` primitives. (It was long read as unreliable because the
|
||||||
|
/// comparison ignored primitives.)
|
||||||
|
#[test]
|
||||||
|
fn lsta_count_equals_sprites_plus_primitives() {
|
||||||
|
skip_without_disc!(root);
|
||||||
|
let count_magic = |b: &[u8], magic: &[u8; 4]| {
|
||||||
|
let (mut n, mut i) = (0usize, 4usize);
|
||||||
|
while i + 4 <= b.len() {
|
||||||
|
if &b[i..i + 4] == magic {
|
||||||
|
n += 1;
|
||||||
|
i += 4;
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n
|
||||||
|
};
|
||||||
|
let (mut lists, mut exact) = (0usize, 0usize);
|
||||||
|
let mut bad = Vec::new();
|
||||||
|
for_each_blob(&root, |pak, name, b| {
|
||||||
|
if !lsta::is_lsta(b) || b.len() < 8 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lists += 1;
|
||||||
|
let declared = u32::from_be_bytes([b[4], b[5], b[6], b[7]]) as usize;
|
||||||
|
let sprites = count_magic(b, b"T8aD");
|
||||||
|
let prims = count_magic(b, b"PRMD");
|
||||||
|
if declared == sprites + prims {
|
||||||
|
exact += 1;
|
||||||
|
} else if bad.len() < 4 {
|
||||||
|
bad.push(format!("{pak}:{name} declared {declared} != {sprites}+{prims}"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert!(lists >= 60, "expected the disc's 64 LSTA lists, saw {lists}");
|
||||||
|
assert_eq!(exact, lists, "mismatches: {bad:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nested RATC records are **leaves**: they carry no child list of their own.
|
||||||
|
/// "One level deep" describes the data, not a parser limit.
|
||||||
|
#[test]
|
||||||
|
fn ratc_nesting_is_exactly_one_level() {
|
||||||
|
skip_without_disc!(root);
|
||||||
|
let mut grandchildren = 0usize;
|
||||||
|
let mut bundles = 0usize;
|
||||||
|
for_each_blob(&root, |_, _, b| {
|
||||||
|
if !ratc::is_ratc(b) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(kids) = ratc::parse(b) else { return };
|
||||||
|
bundles += 1;
|
||||||
|
for k in &kids {
|
||||||
|
if k.offset + k.size > b.len() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let sub = &b[k.offset..k.offset + k.size];
|
||||||
|
if ratc::is_ratc(sub) {
|
||||||
|
grandchildren += ratc::parse(sub).map(|g| g.len()).unwrap_or(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert!(bundles > 2_000, "expected thousands of RATC bundles, saw {bundles}");
|
||||||
|
assert_eq!(grandchildren, 0, "a nested RATC record listed children");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user