TXCM XPR2 resources are the game's world skyboxes (BG_Acheron, BG_Hargenteen, …). The viewer showed only face 0 as a flat 2D image; now it decodes all 6 faces and shows a labelled grid. Formats (Bevy-free): X360Texture::cube_faces_from_xpr2() returns a Cubemap with 6 faces in D3D9 order (+X −X +Y −Y +Z −Z), or None for ordinary 2D textures. Face layout derived from xenia's GetGuestTextureLayout — 6 back-to-back independently- tiled surfaces, per-face stride = tiled-surface-size aligned to the 4 KiB subresource boundary (kTextureSubresourceAlignmentBytes). Verified against real BG_Acheron: data_size == 6 × 0x400000, and all 6 faces decode cleanly (own Python decode + a disc test asserting 6×4 MiB faces and face 0 == the validated from_xpr2 green-planet decode). Extracted a shared decode_surface() helper so the 2D and cube paths are byte-identical; from_xpr2 output unchanged (re-verified). Viewer: new SkyboxPreview resource (6 egui face textures, reusing the existing per-format x360_texture_to_bevy_image); populated in apply_loaded_texture's TXCM branch; freed/reset alongside the other previews (factored free_texture/ free_skybox helpers). Central panel gains a skybox branch rendering a 3-column labelled face grid. DEFERRED (per "do not guess, else defer"): the interactive 3D skybox. Face data + D3D9 order are validated, but wgpu cube-sampling handedness can't be confirmed without eyeballing the GUI — a wrong-oriented skybox is worse than the correct labelled grid. The grid is the reliable deliverable; the 3D look-around is a follow-up once orientation is visually confirmed. (Background agent did the investigation/validation but was blocked from writing files; implemented here in the main tree from its findings, independently re-verified.) 23 formats tests + disc cubemap test pass; viewer/CLI build; face-0 export re-verified as the green planet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
148 lines
5.7 KiB
Rust
148 lines
5.7 KiB
Rust
//! Integration tests against the real extracted Project Sylpheed disc.
|
||
//!
|
||
//! These are **skipped** (pass as no-ops) when the extracted disc is not present,
|
||
//! so the suite still runs on machines/CI without the game. Point at the disc via
|
||
//! the `SYLPHEED_DISC` env var, or drop it at the default dev path below.
|
||
|
||
use std::path::{Path, PathBuf};
|
||
|
||
use sylpheed_formats::texture::X360Texture;
|
||
use sylpheed_formats::{IdxdObject, PakArchive};
|
||
|
||
/// Locate the extracted disc root, or `None` to skip.
|
||
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;
|
||
};
|
||
};
|
||
}
|
||
|
||
#[test]
|
||
fn deftables_header_and_first_entry() {
|
||
skip_without_disc!(root);
|
||
let arc = PakArchive::open(root.join("hidden/DefTables.pak")).unwrap();
|
||
assert_eq!(arc.len(), 1465);
|
||
assert_eq!(arc.block_size, 0x800);
|
||
assert_eq!(arc.flags, 0x1000_0000);
|
||
// TOC is sorted ascending by name_hash.
|
||
assert!(arc
|
||
.entries()
|
||
.windows(2)
|
||
.all(|w| w[0].name_hash < w[1].name_hash));
|
||
// First entries decompress to a known inner format (IDXD or XML).
|
||
let first = &arc.entries()[0];
|
||
let payload = arc.read(first).unwrap();
|
||
assert!(
|
||
payload.starts_with(b"IDXD") || payload.starts_with(b"<?xml"),
|
||
"unexpected inner magic: {:?}",
|
||
&payload[..4.min(payload.len())]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn deltasaber_craft_stats() {
|
||
skip_without_disc!(root);
|
||
let arc = PakArchive::open(root.join("dat/GP_MAIN_GAME_E.pak")).unwrap();
|
||
// rou_f001 "DeltaSaber" — the player craft. Entry hash discovered via content scan.
|
||
let bytes = arc.read_by_hash(0x7c96_296c).expect("entry present").unwrap();
|
||
let obj = IdxdObject::parse(&bytes).unwrap();
|
||
// identifier-valued fields via get_raw
|
||
assert_eq!(obj.get_raw("Type"), Some("Craft"));
|
||
assert_eq!(obj.get_raw("Model"), Some("rou_f001"));
|
||
// numeric flight/combat constants via the reliable typed getters
|
||
assert_eq!(obj.get_f32("Size_X"), Some(10.0));
|
||
assert_eq!(obj.get_f32("Size_Y"), Some(7.0));
|
||
assert_eq!(obj.get_f32("Size_Z"), Some(29.0));
|
||
assert_eq!(obj.get_f32("HP"), Some(1000.0)); // now bound (robust pool start)
|
||
assert_eq!(obj.get_f32("MinimumVelocity"), Some(100.0));
|
||
assert_eq!(obj.get_f32("MaximumVelocity"), Some(1200.0));
|
||
assert_eq!(obj.get_f32("CruisingVelocity"), Some(700.0));
|
||
assert_eq!(obj.get_f32("Acceleration"), Some(600.0));
|
||
assert_eq!(obj.get_f32("Deceleration"), Some(400.0));
|
||
assert_eq!(obj.get_i64("Turn_AngularVelocity"), Some(100));
|
||
assert_eq!(obj.get_f32("ChargeSpeed"), Some(800.0));
|
||
assert_eq!(obj.get_f32("RadarRange"), Some(500000.0));
|
||
// a defaulted/omitted field must be None, not a neighbouring key
|
||
assert_eq!(obj.get_f32("ShieldRatio"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn dsaber_missile_weapon_stats() {
|
||
skip_without_disc!(root);
|
||
let arc = PakArchive::open(root.join("dat/GP_MAIN_GAME_E.pak")).unwrap();
|
||
// Weapon_DSaber_P_wep_26_Missile
|
||
let bytes = arc.read_by_hash(0x4666_5408).expect("entry present").unwrap();
|
||
let obj = IdxdObject::parse(&bytes).unwrap();
|
||
assert_eq!(obj.get_str("TargetType"), Some("Vessel,Craft"));
|
||
assert_eq!(obj.get_i64("LoadingCount"), Some(144));
|
||
assert_eq!(obj.get_f32("Interval"), Some(3.00));
|
||
assert_eq!(obj.get_f32("Mass"), Some(0.77));
|
||
assert_eq!(obj.get_i64("TriggerShotCount"), Some(12));
|
||
assert_eq!(obj.get_f32("MaximumRange"), Some(10000.0));
|
||
}
|
||
|
||
/// The recovered TOC name-hash resolves real entries by their original path.
|
||
#[test]
|
||
fn name_lookup_resolves_weapon_tbl() {
|
||
skip_without_disc!(root);
|
||
let arc = PakArchive::open(root.join("dat/GP_HANGAR_ARSENAL.pak")).unwrap();
|
||
|
||
// eng\weapon.tbl is present and hashes to the retail TOC key.
|
||
let entry = arc
|
||
.find_by_name("eng\\weapon.tbl")
|
||
.expect("eng\\weapon.tbl must resolve by name");
|
||
assert_eq!(entry.name_hash, 0x900C_8DCD);
|
||
|
||
// Case-insensitive, matching the game's lowercasing.
|
||
assert_eq!(
|
||
arc.find_by_name("ENG\\Weapon.TBL").map(|e| e.name_hash),
|
||
Some(0x900C_8DCD)
|
||
);
|
||
|
||
// The payload decompresses to a self-describing IDXD table.
|
||
let bytes = arc.read_by_name("eng\\weapon.tbl").unwrap().unwrap();
|
||
assert_eq!(&bytes[..4], b"IDXD");
|
||
let obj = IdxdObject::parse(&bytes).expect("weapon.tbl parses as IDXD");
|
||
assert!(obj.count > 0);
|
||
}
|
||
|
||
/// The Acheron backdrop is a TXCM world cubemap: 6 faces, and the cube path's
|
||
/// face 0 matches the (validated) 2D `from_xpr2` face-0 decode.
|
||
#[test]
|
||
fn acheron_world_cubemap_six_faces() {
|
||
skip_without_disc!(root);
|
||
let bytes = std::fs::read(root.join("hidden/resource3d/BG_Acheron.xpr")).unwrap();
|
||
|
||
let cube = X360Texture::cube_faces_from_xpr2(&bytes)
|
||
.expect("cube decode ok")
|
||
.expect("BG_Acheron is a TXCM cubemap");
|
||
assert_eq!((cube.width, cube.height), (1024, 1024));
|
||
assert_eq!(cube.faces.len(), 6);
|
||
// A8R8G8B8 → 4 bytes/texel, de-tiled to width×height.
|
||
for face in &cube.faces {
|
||
assert_eq!(face.len(), 1024 * 1024 * 4);
|
||
}
|
||
// Face 0 must equal what the existing 2D path decodes (the green planet).
|
||
let face0_2d = X360Texture::from_xpr2(&bytes).unwrap();
|
||
assert!(face0_2d.is_cubemap);
|
||
assert_eq!(cube.faces[0], face0_2d.data);
|
||
}
|