Files
Syplheed-Reborn/crates/sylpheed-formats/tests/pak_idxd_disc.rs
MechaCat02 e17bc0216d feat(viewer): present subtitles, fonts, PNG + text in the PAK browser
Decode and present four more PAK item types in the pack browser's detail
pane. All content is decoded off-thread in build_pak_rows and attached to
each PakRow, then rendered with egui's own texture facilities — the Bevy
texture pipeline and egui's global font system are untouched.

formats crate (Bevy-free, reusable by CLI):
- ixud: parse IXUD localized-string tables as subtitle cue lists (UTF-16BE
  pool, SUBTITLE header + (text, timecode) pairs -> Vec<Cue{start,end,text}>).
- font: OTF/TTF/ttcf metadata (family / faces / glyphs) via ttf-parser.

viewer:
- PakRow gains `content: PakContent` (Subtitle | Font | Png | Text | None);
  classify_content fills it for IXUD / fonts / PNG / xml+text within the
  existing decode budget. Font samples are pre-rasterized off-thread with
  ab_glyph into an image (ImageRgba) — ab_glyph returns Option/Result at every
  step, so a bad font yields no sample instead of panicking (the earlier
  egui set_fonts approach crashed the app on atlas rebuild).
- draw_pak_browser dispatches on content: subtitle cue table, font metadata +
  sample image, PNG image, scrollable text; else the existing IDXD detail.
  Borrow-split PakView instead of cloning the (now heavy) rows each frame;
  one hash-keyed egui texture cache serves both PNG and font-sample images.

Subtitle<->movie auto-matching is deferred: movie filenames don't hash to the
IXUD keys and no cross-reference table was found (the link is an internal
MSG_DEMO id), so the movie player gets no subtitle track yet.

Tests: ixud unit tests; disc tests for the real English subtitle track
(cues + timecodes), eng/deu localization, font metadata, and off-thread
rasterization of the real font.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:19:31 +02:00

187 lines
7.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 English movie subtitle track (an `IXUD` entry) decodes to timed cues.
#[test]
fn eng_movie_subtitle_track() {
skip_without_disc!(root);
let arc = PakArchive::open(root.join("dat/movie/eng.pak")).unwrap();
// S04A's track — the longest inline English one (starts "Calm down!").
let bytes = arc.read_by_hash(0x73d2_2a3b).expect("entry present").unwrap();
let sub = sylpheed_formats::ixud::parse(&bytes).expect("IXUD parses as subtitle");
assert!(sub.cues.len() > 50, "cues={}", sub.cues.len());
assert!(!sub.is_reference_only());
assert_eq!(sub.cues[0].text, "Calm down!");
assert!((sub.cues[0].start - 9.4).abs() < 0.01);
assert_eq!(sub.cues[0].end, Some(11.0));
}
/// The subtitle tracks are genuinely localized: the same entry hash differs
/// between the English and German packs.
#[test]
fn subtitles_are_localized() {
skip_without_disc!(root);
let eng = PakArchive::open(root.join("dat/movie/eng.pak")).unwrap();
let deu = PakArchive::open(root.join("dat/movie/deu.pak")).unwrap();
let e = eng.read_by_hash(0x73d2_2a3b).unwrap().unwrap();
let d = deu.read_by_hash(0x73d2_2a3b).unwrap().unwrap();
assert_ne!(e, d, "eng/deu subtitle payloads should differ");
}
/// The English movie pack's embedded subtitle font parses to sane metadata.
#[test]
fn eng_movie_font_metadata() {
skip_without_disc!(root);
let arc = PakArchive::open(root.join("dat/movie/eng.pak")).unwrap();
let bytes = arc.read_by_hash(0x5cd0_fca6).expect("entry present").unwrap();
assert!(sylpheed_formats::font::is_font(&bytes));
let info = sylpheed_formats::font::parse_info(&bytes).expect("font parses");
assert!(info.glyphs > 0, "glyphs={}", info.glyphs);
assert!(!info.family.is_empty(), "family should be readable");
}
/// 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);
}