Compare commits
14 Commits
5dd526c1de
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7028471e9 | ||
|
|
e1ca95c0af | ||
|
|
10425aba8c | ||
|
|
e17bc0216d | ||
|
|
91bd2543f4 | ||
|
|
589659bec6 | ||
|
|
765e4a573b | ||
|
|
fbd4550d62 | ||
|
|
2658dd20a6 | ||
|
|
854fd8dfd3 | ||
|
|
405233e84f | ||
|
|
840db9c549 | ||
|
|
d92c7ddaea | ||
|
|
ce9fe08bec |
917
Cargo.lock
generated
917
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,12 @@ anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# ── Native file dialog ────────────────────────────────────────────────────────
|
||||
rfd = "0.14"
|
||||
|
||||
# ── Audio playback (native video player) ──────────────────────────────────────
|
||||
rodio = "0.20"
|
||||
|
||||
# ── CLI ───────────────────────────────────────────────────────────────────────
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
|
||||
@@ -19,3 +19,6 @@ tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
colored = "2"
|
||||
indicatif = "0.17"
|
||||
# Texture PNG export: BCn software decode + image encode.
|
||||
texpresso = "2"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
@@ -272,6 +272,7 @@ fn cmd_sniff(dir: &Path, unknown_only: bool) -> Result<()> {
|
||||
"bin" => label.red().to_string(),
|
||||
"xpr" => label.green().to_string(),
|
||||
"dds" => label.green().to_string(),
|
||||
"txt" => label.cyan().to_string(),
|
||||
_ => label.yellow().to_string(),
|
||||
};
|
||||
|
||||
@@ -330,73 +331,71 @@ fn cmd_texture_export(file: &Path, output: &Path) -> Result<()> {
|
||||
let bytes = std::fs::read(file)
|
||||
.with_context(|| format!("Cannot read {}", file.display()))?;
|
||||
|
||||
use sylpheed_formats::texture::{X360Texture, X360TextureFormat};
|
||||
use sylpheed_formats::texture::X360Texture;
|
||||
let tex = X360Texture::from_xpr2(&bytes)?;
|
||||
let rgba = decode_to_rgba8(&tex)
|
||||
.with_context(|| format!("decoding {:?} texture", tex.format))?;
|
||||
|
||||
// For BCn compressed textures we need to software-decompress to RGBA8
|
||||
// before saving to PNG. This uses the `texpresso` crate.
|
||||
//
|
||||
// TODO: add `texpresso = "2"` to Cargo.toml for BCn software decode.
|
||||
// For now, print a helpful message.
|
||||
match tex.format {
|
||||
X360TextureFormat::A8R8G8B8 | X360TextureFormat::X8R8G8B8 => {
|
||||
// Raw BGRA8 — can save directly (with channel swizzle)
|
||||
println!(
|
||||
"{} TODO: save raw BGRA8 to PNG (add `image` crate)",
|
||||
"Note:".yellow()
|
||||
);
|
||||
}
|
||||
compressed_format => {
|
||||
println!(
|
||||
"{} Format {:?} needs BCn decompression before PNG export.",
|
||||
"Note:".yellow(), compressed_format
|
||||
);
|
||||
println!(
|
||||
" Add `texpresso` crate and implement decompression in texture.rs"
|
||||
);
|
||||
println!(
|
||||
" Alternatively, use the Bevy viewer to inspect textures visually."
|
||||
);
|
||||
}
|
||||
}
|
||||
image::save_buffer(
|
||||
output,
|
||||
&rgba,
|
||||
tex.width,
|
||||
tex.height,
|
||||
image::ExtendedColorType::Rgba8,
|
||||
)
|
||||
.with_context(|| format!("writing PNG {}", output.display()))?;
|
||||
|
||||
println!(
|
||||
" Texture parsed OK: {}×{} {:?}",
|
||||
tex.width, tex.height, tex.format
|
||||
"{} {}×{} {:?}{} → {}",
|
||||
"Exported".green().bold(),
|
||||
tex.width,
|
||||
tex.height,
|
||||
tex.format,
|
||||
if tex.is_cubemap { " (cubemap face 0)" } else { "" },
|
||||
output.display().to_string().cyan(),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Software-decode a de-tiled `X360Texture` (mip 0) to tightly-packed RGBA8.
|
||||
///
|
||||
/// BCn blocks are decompressed with `texpresso`; uncompressed A8R8G8B8/X8R8G8B8
|
||||
/// is byte-swizzled from the Xenos in-memory BGRA order.
|
||||
fn decode_to_rgba8(tex: &sylpheed_formats::texture::X360Texture) -> Result<Vec<u8>> {
|
||||
use sylpheed_formats::texture::X360TextureFormat as F;
|
||||
let (w, h) = (tex.width as usize, tex.height as usize);
|
||||
let mut rgba = vec![0u8; w * h * 4];
|
||||
|
||||
let bc = |fmt: texpresso::Format, rgba: &mut [u8]| {
|
||||
fmt.decompress(&tex.data, w, h, rgba);
|
||||
};
|
||||
|
||||
match tex.format {
|
||||
F::Dxt1 => bc(texpresso::Format::Bc1, &mut rgba),
|
||||
F::Dxt3 => bc(texpresso::Format::Bc2, &mut rgba),
|
||||
F::Dxt5 => bc(texpresso::Format::Bc3, &mut rgba),
|
||||
F::A8R8G8B8 | F::X8R8G8B8 => {
|
||||
// After the k8in32 endian swap in from_xpr2, k_8_8_8_8 pixels are in
|
||||
// [A,R,G,B] byte order (verified against the retail Acheron backdrop).
|
||||
// Emit RGBA. X8 has no meaningful alpha.
|
||||
let opaque = matches!(tex.format, F::X8R8G8B8);
|
||||
for (px, out) in tex.data.chunks_exact(4).zip(rgba.chunks_exact_mut(4)) {
|
||||
out[0] = px[1]; // R
|
||||
out[1] = px[2]; // G
|
||||
out[2] = px[3]; // B
|
||||
out[3] = if opaque { 0xFF } else { px[0] };
|
||||
}
|
||||
}
|
||||
other => {
|
||||
anyhow::bail!("PNG export for {other:?} (BC4/BC5) not implemented yet");
|
||||
}
|
||||
}
|
||||
Ok(rgba)
|
||||
}
|
||||
|
||||
// ── pak list ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Best-effort human label for a decompressed entry's inner format.
|
||||
fn inner_label(payload: &[u8]) -> String {
|
||||
if payload.len() < 4 {
|
||||
return "empty".into();
|
||||
}
|
||||
let m = &payload[0..4];
|
||||
if m == b"IDXD" {
|
||||
"IDXD".into()
|
||||
} else if payload.starts_with(b"<?xml") {
|
||||
"xml".into()
|
||||
} else if m.iter().all(|&b| b.is_ascii_graphic()) {
|
||||
// Many inner formats are 4-char tags (RATC, T8aD, IXUD, LSTA, ttcf, …).
|
||||
String::from_utf8_lossy(m).into_owned()
|
||||
} else {
|
||||
format!("{:02X}{:02X}{:02X}{:02X}", m[0], m[1], m[2], m[3])
|
||||
}
|
||||
}
|
||||
|
||||
/// For an IDXD entry, a short identity string (ID / Name / Model if present).
|
||||
fn idxd_identity(obj: &IdxdObject) -> String {
|
||||
for key in ["ID", "Name", "Model"] {
|
||||
if let Some(v) = obj.get_raw(key) {
|
||||
return format!("{key}={v}");
|
||||
}
|
||||
}
|
||||
format!("schema {:08x}", obj.schema_hash)
|
||||
}
|
||||
use sylpheed_formats::pak::inner_format_label as inner_label;
|
||||
|
||||
/// Try to recover an IDXD entry's original TOC path from its identity tokens.
|
||||
/// Uses the entry's ID/Name/Model fields plus identifier-like pool tokens as
|
||||
@@ -443,7 +442,7 @@ fn cmd_pak_list(pak: &Path, idxd_only: bool) -> Result<()> {
|
||||
}
|
||||
let (detail, name) = if is_idxd {
|
||||
match IdxdObject::parse(&payload) {
|
||||
Ok(o) => (idxd_identity(&o), idxd_toc_name(&o, e.name_hash)),
|
||||
Ok(o) => (o.identity(), idxd_toc_name(&o, e.name_hash)),
|
||||
Err(_) => (String::new(), None),
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -10,6 +10,7 @@ authors.workspace = true
|
||||
xdvdfs = { workspace = true }
|
||||
binrw = { workspace = true }
|
||||
flate2 = "1" # zlib/DEFLATE for IPFB "Z1" entries (miniz_oxide backend, WASM-safe)
|
||||
ttf-parser = { version = "0.24", default-features = false, features = ["std", "opentype-layout"] } # font metadata (OTF/TTF/ttcf), WASM-safe
|
||||
tokio = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
64
crates/sylpheed-formats/src/font.rs
Normal file
64
crates/sylpheed-formats/src/font.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
//! Font metadata for the embedded UI fonts.
|
||||
//!
|
||||
//! The packs carry sfnt fonts (`\x00\x01\x00\x00` TrueType, `OTTO` OpenType/CFF)
|
||||
//! and TrueType Collections (`ttcf`). We only need light metadata to *present*
|
||||
//! them — family name, face count, glyph count — which `ttf-parser` extracts
|
||||
//! safely (malformed input yields `None`, never a panic). The raw bytes are
|
||||
//! handed to egui separately for a live sample.
|
||||
|
||||
use ttf_parser::{fonts_in_collection, name_id, Face};
|
||||
|
||||
/// Light font metadata for the viewer.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct FontInfo {
|
||||
/// "TrueType", "OpenType (CFF)", or "TrueType Collection".
|
||||
pub kind: &'static str,
|
||||
/// Family / full name (best-effort; empty if the name table is unreadable).
|
||||
pub family: String,
|
||||
/// Number of faces (1 for a plain sfnt, N for a `ttcf` collection).
|
||||
pub faces: u32,
|
||||
/// Glyph count of the first face.
|
||||
pub glyphs: u16,
|
||||
}
|
||||
|
||||
/// Whether `bytes` starts with a recognized font signature.
|
||||
pub fn is_font(bytes: &[u8]) -> bool {
|
||||
matches!(
|
||||
bytes.get(0..4),
|
||||
Some(b"\x00\x01\x00\x00") | Some(b"OTTO") | Some(b"true") | Some(b"ttcf")
|
||||
)
|
||||
}
|
||||
|
||||
/// Extract light metadata, or `None` if the font can't be parsed.
|
||||
pub fn parse_info(bytes: &[u8]) -> Option<FontInfo> {
|
||||
let is_collection = bytes.get(0..4) == Some(b"ttcf");
|
||||
let faces = fonts_in_collection(bytes).unwrap_or(1).max(1);
|
||||
|
||||
// Face 0 gives the representative family + glyph count.
|
||||
let face = Face::parse(bytes, 0).ok()?;
|
||||
let glyphs = face.number_of_glyphs();
|
||||
|
||||
let kind = if is_collection {
|
||||
"TrueType Collection"
|
||||
} else if bytes.get(0..4) == Some(b"OTTO") {
|
||||
"OpenType (CFF)"
|
||||
} else {
|
||||
"TrueType"
|
||||
};
|
||||
|
||||
// Prefer the typographic/full family name; fall back to any readable name.
|
||||
let family = face
|
||||
.names()
|
||||
.into_iter()
|
||||
.filter(|n| n.name_id == name_id::FULL_NAME || n.name_id == name_id::FAMILY)
|
||||
.find_map(|n| n.to_string())
|
||||
.or_else(|| face.names().into_iter().find_map(|n| n.to_string()))
|
||||
.unwrap_or_default();
|
||||
|
||||
Some(FontInfo {
|
||||
kind,
|
||||
family,
|
||||
faces,
|
||||
glyphs,
|
||||
})
|
||||
}
|
||||
@@ -171,6 +171,18 @@ impl IdxdObject {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A short identity string for the object: the first of `ID` / `Name` /
|
||||
/// `Model` present, else the schema hash. Shared by the CLI `pak list` and
|
||||
/// the GUI pack browser.
|
||||
pub fn identity(&self) -> String {
|
||||
for key in ["ID", "Name", "Model"] {
|
||||
if let Some(v) = self.get_raw(key) {
|
||||
return format!("{key}={v}");
|
||||
}
|
||||
}
|
||||
format!("schema {:08x}", self.schema_hash)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the trailing string pool. Finds the smallest offset whose suffix is
|
||||
|
||||
187
crates/sylpheed-formats/src/ixud.rs
Normal file
187
crates/sylpheed-formats/src/ixud.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
//! IXUD localized-string / subtitle table reader.
|
||||
//!
|
||||
//! `IXUD` entries in the language packs (`dat/movie/<lang>.pak`, the
|
||||
//! `GP_MAIN_GAME_<lang>` tables, …) hold localized UTF-16**BE** strings. The
|
||||
//! movie language packs use them as **subtitle cue lists**: a `SUBTITLE` header
|
||||
//! followed by alternating `(text, timecode)` tokens, e.g.
|
||||
//!
|
||||
//! ```text
|
||||
//! SUBTITLE
|
||||
//! "Calm down!" 00:09.40-00:11.00
|
||||
//! "Why are you taking Margras away?" 00:18.50-00:19.70
|
||||
//! ```
|
||||
//!
|
||||
//! Reference-style tracks store a message key (`MSG_DEMO_240`) plus a single
|
||||
//! start timecode instead of inline text; the actual string then lives in a
|
||||
//! separate global table (not resolved here).
|
||||
//!
|
||||
//! ## Binary layout (as far as needed)
|
||||
//! ```text
|
||||
//! 0x00 4 Magic "IXUD"
|
||||
//! 0x04 4 version (1)
|
||||
//! 0x08 4 schema/type hash (constant 0x6CC83E70)
|
||||
//! .. .. record directory { key_hash u32, offset u32, len u32 } × n
|
||||
//! .. .. UTF-16BE string pool, NUL-terminated entries
|
||||
//! ```
|
||||
//! We don't need the directory to *present* the track — decoding the pool and
|
||||
//! pairing tokens after the `SUBTITLE` header is enough and robust.
|
||||
|
||||
/// Magic at the start of every IXUD entry.
|
||||
pub const IXUD_MAGIC: [u8; 4] = *b"IXUD";
|
||||
|
||||
/// One subtitle cue: a start time (seconds), optional end time, and the text
|
||||
/// (or a `MSG_*` reference key for reference-style tracks).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Cue {
|
||||
pub start: f32,
|
||||
pub end: Option<f32>,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// A decoded subtitle track.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct Subtitle {
|
||||
pub cues: Vec<Cue>,
|
||||
}
|
||||
|
||||
impl Subtitle {
|
||||
/// True when the track carries no inline text — every cue is a `MSG_*`
|
||||
/// reference whose string lives in a separate global table.
|
||||
pub fn is_reference_only(&self) -> bool {
|
||||
!self.cues.is_empty() && self.cues.iter().all(|c| c.text.starts_with("MSG_"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `bytes` is an IXUD entry.
|
||||
pub fn is_ixud(bytes: &[u8]) -> bool {
|
||||
bytes.len() >= 4 && bytes[0..4] == IXUD_MAGIC
|
||||
}
|
||||
|
||||
/// Parse an IXUD entry as a subtitle track. Returns `None` if not IXUD.
|
||||
/// Malformed pools yield as many well-formed cues as can be recovered.
|
||||
pub fn parse(bytes: &[u8]) -> Option<Subtitle> {
|
||||
if !is_ixud(bytes) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Decode the whole payload as UTF-16BE and split into NUL-terminated,
|
||||
// printable runs. The binary header/directory decodes to control/CJK-range
|
||||
// junk that we skip by seeking to the `SUBTITLE` marker.
|
||||
let tokens = utf16be_tokens(bytes);
|
||||
let start = tokens
|
||||
.iter()
|
||||
.position(|t| t.ends_with("SUBTITLE"))
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(0);
|
||||
let rest = &tokens[start..];
|
||||
|
||||
// Pair (text, timecode). A token that parses as a timecode closes the cue
|
||||
// opened by the preceding text token; unpaired tokens are skipped so a
|
||||
// single glitch doesn't desync the rest.
|
||||
let mut cues = Vec::new();
|
||||
let mut i = 0;
|
||||
while i + 1 < rest.len() {
|
||||
if let Some((s, e)) = parse_timing(&rest[i + 1]) {
|
||||
cues.push(Cue {
|
||||
start: s,
|
||||
end: e,
|
||||
text: rest[i].clone(),
|
||||
});
|
||||
i += 2;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
Some(Subtitle { cues })
|
||||
}
|
||||
|
||||
/// Split the payload (interpreted as UTF-16BE) into printable, NUL-delimited
|
||||
/// tokens. Control chars terminate the current token.
|
||||
fn utf16be_tokens(bytes: &[u8]) -> Vec<String> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut cur = String::new();
|
||||
for pair in bytes.chunks_exact(2) {
|
||||
let u = u16::from_be_bytes([pair[0], pair[1]]);
|
||||
match char::from_u32(u as u32) {
|
||||
Some(c) if !c.is_control() => cur.push(c),
|
||||
_ => {
|
||||
if !cur.is_empty() {
|
||||
tokens.push(std::mem::take(&mut cur));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
tokens.push(cur);
|
||||
}
|
||||
tokens
|
||||
}
|
||||
|
||||
/// Parse `MM:SS.ss` (`" 9:04.40"` style, minutes may be blank/space-padded) or
|
||||
/// a `start-end` range into seconds.
|
||||
fn parse_timing(s: &str) -> Option<(f32, Option<f32>)> {
|
||||
let one = |p: &str| -> Option<f32> {
|
||||
let (mm, ss) = p.trim().split_once(':')?;
|
||||
let m: f32 = if mm.trim().is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
mm.trim().parse().ok()?
|
||||
};
|
||||
let sec: f32 = ss.trim().parse().ok()?;
|
||||
Some(m * 60.0 + sec)
|
||||
};
|
||||
match s.split_once('-') {
|
||||
Some((a, b)) => Some((one(a)?, Some(one(b)?))),
|
||||
None => Some((one(s)?, None)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a synthetic IXUD payload: an 8-byte header stand-in + UTF-16BE
|
||||
/// NUL-terminated tokens.
|
||||
fn synth(tokens: &[&str]) -> Vec<u8> {
|
||||
let mut b = IXUD_MAGIC.to_vec();
|
||||
b.extend_from_slice(&[0, 0, 0, 1, 0x6C, 0xC8, 0x3E, 0x70]); // version + schema
|
||||
for t in tokens {
|
||||
for u in t.encode_utf16() {
|
||||
b.extend_from_slice(&u.to_be_bytes());
|
||||
}
|
||||
b.extend_from_slice(&[0, 0]); // NUL
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_subtitle_track() {
|
||||
let b = synth(&[
|
||||
"SUBTITLE",
|
||||
"Calm down!",
|
||||
"00:09.40-00:11.00",
|
||||
"Let go of me!",
|
||||
"00:13.10-00:14.50",
|
||||
]);
|
||||
let sub = parse(&b).unwrap();
|
||||
assert_eq!(sub.cues.len(), 2);
|
||||
assert_eq!(sub.cues[0].text, "Calm down!");
|
||||
assert_eq!(sub.cues[0].start, 9.4);
|
||||
assert_eq!(sub.cues[0].end, Some(11.0));
|
||||
assert!(!sub.is_reference_only());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_track() {
|
||||
let b = synth(&["SUBTITLE", "MSG_DEMO_240", "00:01.00", "MSG_DEMO_241", "00:03.20"]);
|
||||
let sub = parse(&b).unwrap();
|
||||
assert_eq!(sub.cues.len(), 2);
|
||||
assert_eq!(sub.cues[0].end, None);
|
||||
assert!(sub.is_reference_only());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_ixud() {
|
||||
assert!(parse(b"IDXD\0\0\0\0").is_none());
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,21 @@ pub mod pak;
|
||||
// IDXD reflective object (ship / weapon / effect / menu definitions) reader
|
||||
pub mod idxd;
|
||||
|
||||
// IXUD localized string / subtitle table reader
|
||||
pub mod ixud;
|
||||
|
||||
// Embedded font (OTF / TTF / ttcf) metadata
|
||||
pub mod font;
|
||||
|
||||
// T8aD 2D UI/HUD texture
|
||||
pub mod t8ad;
|
||||
|
||||
// RATC nested resource bundle
|
||||
pub mod ratc;
|
||||
|
||||
// LSTA sprite list (inline T8aD frames)
|
||||
pub mod lsta;
|
||||
|
||||
// XISO reading is not available in-browser
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod xiso;
|
||||
@@ -36,7 +51,11 @@ pub mod mesh;
|
||||
pub mod audio;
|
||||
|
||||
/// Re-export the most commonly used types at the crate root.
|
||||
pub use font::FontInfo;
|
||||
pub use idxd::{IdxdError, IdxdObject};
|
||||
pub use ixud::{Cue, Subtitle};
|
||||
pub use ratc::RatcChild;
|
||||
pub use t8ad::T8adImage;
|
||||
pub use pak::{PakArchive, PakEntry, PakError};
|
||||
pub use texture::{X360Texture, X360TextureFormat};
|
||||
pub use vfs::{GameAssets, VfsError};
|
||||
|
||||
78
crates/sylpheed-formats/src/lsta.rs
Normal file
78
crates/sylpheed-formats/src/lsta.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
//! `LSTA` — a sprite list: a header followed by N inline [`T8aD`](crate::t8ad)
|
||||
//! frames concatenated back-to-back.
|
||||
//!
|
||||
//! A `count` lives at `@0x04`, but a few entries disagree with the actual frame
|
||||
//! count, so we walk by the `T8aD` magic instead (robust) and decode each frame
|
||||
//! from its slice up to the next frame (or end).
|
||||
|
||||
use crate::t8ad::{self, T8adImage, T8AD_MAGIC};
|
||||
|
||||
/// Magic at the start of an LSTA sprite list.
|
||||
pub const LSTA_MAGIC: [u8; 4] = *b"LSTA";
|
||||
|
||||
/// Whether `bytes` is an LSTA list.
|
||||
pub fn is_lsta(bytes: &[u8]) -> bool {
|
||||
bytes.len() >= 4 && bytes[0..4] == LSTA_MAGIC
|
||||
}
|
||||
|
||||
/// Decode all inline T8aD frames. Frames that don't decode (unsupported T8aD
|
||||
/// variant) are skipped. Returns `None` only for non-LSTA input.
|
||||
pub fn parse(bytes: &[u8]) -> Option<Vec<T8adImage>> {
|
||||
if !is_lsta(bytes) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Offsets of each inline T8aD frame (search past the 4-byte magic).
|
||||
let mut offs = Vec::new();
|
||||
let mut i = 4;
|
||||
while i + 4 <= bytes.len() {
|
||||
if bytes[i..i + 4] == T8AD_MAGIC {
|
||||
offs.push(i);
|
||||
i += 4;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut frames = Vec::with_capacity(offs.len());
|
||||
for (idx, &off) in offs.iter().enumerate() {
|
||||
let next = offs.get(idx + 1).copied().unwrap_or(bytes.len());
|
||||
if let Some(img) = t8ad::parse(&bytes[off..next]) {
|
||||
frames.push(img);
|
||||
}
|
||||
}
|
||||
Some(frames)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn t8ad_frame(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut b = vec![0u8; 64];
|
||||
b[0..4].copy_from_slice(&T8AD_MAGIC);
|
||||
b[0x14..0x18].copy_from_slice(&w.to_be_bytes());
|
||||
b[0x18..0x1c].copy_from_slice(&h.to_be_bytes());
|
||||
b[0x1c..0x20].copy_from_slice(&1u32.to_be_bytes());
|
||||
b.extend_from_slice(&vec![0x80u8; (w * h * 4) as usize]);
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walks_inline_frames() {
|
||||
let mut b = LSTA_MAGIC.to_vec();
|
||||
b.extend_from_slice(&2u32.to_be_bytes());
|
||||
b.extend_from_slice(&[0u8; 15]); // header remainder
|
||||
b.extend_from_slice(&t8ad_frame(4, 4));
|
||||
b.extend_from_slice(&t8ad_frame(2, 3));
|
||||
let frames = parse(&b).unwrap();
|
||||
assert_eq!(frames.len(), 2);
|
||||
assert_eq!((frames[0].width, frames[0].height), (4, 4));
|
||||
assert_eq!((frames[1].width, frames[1].height), (2, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_lsta() {
|
||||
assert!(parse(b"T8aD....").is_none());
|
||||
}
|
||||
}
|
||||
@@ -262,6 +262,41 @@ fn be32(b: &[u8], o: usize) -> u32 {
|
||||
u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
|
||||
}
|
||||
|
||||
/// Best-effort human label for a decompressed entry's inner format, from its
|
||||
/// first bytes: a known signature (`IDXD`, fonts, `png`, …), a printable 4-char
|
||||
/// tag (RATC, IXUD, LSTA, …), or a hex fallback for still-unidentified magics.
|
||||
/// Shared by the CLI `pak list` and the GUI pack browser.
|
||||
pub fn inner_format_label(payload: &[u8]) -> String {
|
||||
if payload.len() < 4 {
|
||||
return "empty".into();
|
||||
}
|
||||
let m = &payload[0..4];
|
||||
|
||||
// Known binary/text signatures → friendly short tags (so fonts/images don't
|
||||
// show as bare hex like `00010000` / `89504E47`).
|
||||
let known = match m {
|
||||
b"IDXD" => Some("IDXD"),
|
||||
b"\x89PNG" => Some("png"),
|
||||
b"\x00\x01\x00\x00" | b"true" | b"typ1" => Some("ttf"), // sfnt TrueType
|
||||
b"OTTO" => Some("otf"), // OpenType (CFF)
|
||||
b"ttcf" => Some("ttc"), // TrueType Collection
|
||||
b"RIFF" => Some("riff"),
|
||||
b"DDS " => Some("dds"),
|
||||
_ if payload.starts_with(b"<?xml") => Some("xml"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(tag) = known {
|
||||
return tag.into();
|
||||
}
|
||||
|
||||
if m.iter().all(|&b| b.is_ascii_graphic()) {
|
||||
// Many inner formats are 4-char tags (RATC, T8aD, IXUD, LSTA, …).
|
||||
String::from_utf8_lossy(m).into_owned()
|
||||
} else {
|
||||
format!("{:02X}{:02X}{:02X}{:02X}", m[0], m[1], m[2], m[3])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
131
crates/sylpheed-formats/src/ratc.rs
Normal file
131
crates/sylpheed-formats/src/ratc.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
//! `RATC` — a nested resource bundle.
|
||||
//!
|
||||
//! After a small header, a RATC holds named children — `foo.t32` (T8aD
|
||||
//! textures), `foo.rat` (nested RATC), fonts, PNG — each immediately preceded by
|
||||
//! its ASCII name string. The children are self-locating by their 4-char magic,
|
||||
//! so we list them by scanning for those magics and pairing each with the name
|
||||
//! run that precedes it. This is reliable for *listing* (names / types / sizes
|
||||
//! are literal bytes); decoding a child's pixels is delegated to that child's
|
||||
//! own parser ([`crate::t8ad`]).
|
||||
|
||||
/// A child resource inside a RATC bundle.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RatcChild {
|
||||
/// Name string preceding the child (e.g. `prselectbtn_g04b.t32`), or empty.
|
||||
pub name: String,
|
||||
/// Child magic tag: `T8aD`, `RATC`, `ttcf`, `png`, …
|
||||
pub kind: String,
|
||||
/// Byte offset of the child (its magic) within the RATC payload.
|
||||
pub offset: usize,
|
||||
/// Byte length of the child, up to the next child (or end of payload).
|
||||
pub size: usize,
|
||||
}
|
||||
|
||||
/// Magic at the start of a RATC bundle.
|
||||
pub const RATC_MAGIC: [u8; 4] = *b"RATC";
|
||||
|
||||
/// Whether `bytes` is a RATC bundle.
|
||||
pub fn is_ratc(bytes: &[u8]) -> bool {
|
||||
bytes.len() >= 4 && bytes[0..4] == RATC_MAGIC
|
||||
}
|
||||
|
||||
/// Recognized child magics and their display tag.
|
||||
fn child_kind(m: &[u8]) -> Option<&'static str> {
|
||||
match m {
|
||||
b"T8aD" => Some("T8aD"),
|
||||
b"RATC" => Some("RATC"),
|
||||
b"ttcf" => Some("ttc"),
|
||||
b"\x89PNG" => Some("png"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// List the children of a RATC bundle (the self-magic at offset 0 is skipped).
|
||||
pub fn parse(bytes: &[u8]) -> Option<Vec<RatcChild>> {
|
||||
if !is_ratc(bytes) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Offsets of every recognized child magic (skip the self RATC at 0).
|
||||
let mut offs: Vec<(usize, &'static str)> = Vec::new();
|
||||
let mut i = 4;
|
||||
while i + 4 <= bytes.len() {
|
||||
if let Some(kind) = child_kind(&bytes[i..i + 4]) {
|
||||
offs.push((i, kind));
|
||||
i += 4;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut children = Vec::with_capacity(offs.len());
|
||||
for (idx, &(off, kind)) in offs.iter().enumerate() {
|
||||
let next = offs.get(idx + 1).map(|&(o, _)| o).unwrap_or(bytes.len());
|
||||
children.push(RatcChild {
|
||||
name: name_before(bytes, off),
|
||||
kind: kind.to_string(),
|
||||
offset: off,
|
||||
size: next.saturating_sub(off),
|
||||
});
|
||||
}
|
||||
Some(children)
|
||||
}
|
||||
|
||||
/// The nearest name string preceding `off`: the *last* printable run (len ≥ 3)
|
||||
/// in the 96 bytes before the child magic. A few record-header bytes usually sit
|
||||
/// between the name and the magic, so an exact-adjacency scan isn't enough.
|
||||
fn name_before(bytes: &[u8], off: usize) -> String {
|
||||
let start = off.saturating_sub(96);
|
||||
let window = &bytes[start..off];
|
||||
let mut best = String::new();
|
||||
let mut run_start: Option<usize> = None;
|
||||
let flush = |from: usize, to: usize, best: &mut String| {
|
||||
if to - from >= 3 {
|
||||
*best = String::from_utf8_lossy(&window[from..to]).trim().to_string();
|
||||
}
|
||||
};
|
||||
for (i, &c) in window.iter().enumerate() {
|
||||
if (0x20..=0x7e).contains(&c) {
|
||||
run_start.get_or_insert(i);
|
||||
} else if let Some(s) = run_start.take() {
|
||||
flush(s, i, &mut best);
|
||||
}
|
||||
}
|
||||
if let Some(s) = run_start {
|
||||
flush(s, window.len(), &mut best);
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lists_named_children() {
|
||||
let mut b = RATC_MAGIC.to_vec();
|
||||
b.extend_from_slice(&[0u8; 28]); // header padding
|
||||
b.extend_from_slice(b"logo.t32");
|
||||
let t8_off = b.len();
|
||||
b.extend_from_slice(b"T8aD");
|
||||
b.extend_from_slice(&[0u8; 40]); // some child bytes
|
||||
b.extend_from_slice(b"sub.rat");
|
||||
let ratc_off = b.len();
|
||||
b.extend_from_slice(b"RATC");
|
||||
b.extend_from_slice(&[0u8; 8]);
|
||||
|
||||
let kids = parse(&b).unwrap();
|
||||
assert_eq!(kids.len(), 2);
|
||||
assert_eq!(kids[0].kind, "T8aD");
|
||||
assert_eq!(kids[0].name, "logo.t32");
|
||||
assert_eq!(kids[0].offset, t8_off);
|
||||
assert_eq!(kids[0].size, ratc_off - t8_off);
|
||||
assert_eq!(kids[1].kind, "RATC");
|
||||
assert_eq!(kids[1].name, "sub.rat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_ratc() {
|
||||
assert!(parse(b"T8aD....").is_none());
|
||||
}
|
||||
}
|
||||
134
crates/sylpheed-formats/src/t8ad.rs
Normal file
134
crates/sylpheed-formats/src/t8ad.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
//! `T8aD` — the game's 2D UI/HUD texture format.
|
||||
//!
|
||||
//! A linear (untiled) 32bpp surface stored **A8R8G8B8** (Xbox byte order), with
|
||||
//! a small fixed-per-variant header:
|
||||
//!
|
||||
//! ```text
|
||||
//! 0x00 4 Magic "T8aD"
|
||||
//! 0x14 4 width (BE u32)
|
||||
//! 0x18 4 height (BE u32)
|
||||
//! 0x1c 4 type field → header size: 1→64 2→84 3→104 4→124 15→344
|
||||
//! <header> w*h*4 bytes of A8R8G8B8 pixel data, row-major
|
||||
//! ```
|
||||
//!
|
||||
//! Static forensics over the disc showed ~85% of entries decode exactly with
|
||||
//! this rule; the rest (unknown type field or a `w*h*4` that doesn't fit — most
|
||||
//! likely DXT / palettized variants) return `None` rather than a wrong image.
|
||||
//!
|
||||
//! Keying the header size off the type field (not `len - w*h*4`) makes the
|
||||
//! decoder **container-safe**: RATC/LSTA hand us a child slice that may have
|
||||
//! trailing padding before the next child, so we must not infer the header from
|
||||
//! the slice length.
|
||||
//!
|
||||
//! ⚠️ Colour correctness (channel order / endianness / sRGB) is unverified
|
||||
//! against the running game — see the RE backlog.
|
||||
|
||||
/// Magic at the start of every T8aD surface.
|
||||
pub const T8AD_MAGIC: [u8; 4] = *b"T8aD";
|
||||
|
||||
/// A decoded T8aD surface as tightly-packed RGBA8 (row-major, top-left origin).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct T8adImage {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Whether `bytes` starts with the T8aD magic.
|
||||
pub fn is_t8ad(bytes: &[u8]) -> bool {
|
||||
bytes.len() >= 4 && bytes[0..4] == T8AD_MAGIC
|
||||
}
|
||||
|
||||
/// Header size in bytes for a given type field (`@0x1c`), or `None` for an
|
||||
/// unrecognized variant.
|
||||
fn header_size(type_field: u32) -> Option<usize> {
|
||||
match type_field {
|
||||
1 => Some(64),
|
||||
2 => Some(84),
|
||||
3 => Some(104),
|
||||
4 => Some(124),
|
||||
15 => Some(344),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn be32(b: &[u8], off: usize) -> u32 {
|
||||
u32::from_be_bytes([b[off], b[off + 1], b[off + 2], b[off + 3]])
|
||||
}
|
||||
|
||||
/// Decode a T8aD surface from a slice whose first bytes ARE the magic. Returns
|
||||
/// `None` for non-T8aD input or a variant we can't decode as RGBA (never guesses).
|
||||
pub fn parse(bytes: &[u8]) -> Option<T8adImage> {
|
||||
if !is_t8ad(bytes) || bytes.len() < 0x40 {
|
||||
return None;
|
||||
}
|
||||
let width = be32(bytes, 0x14);
|
||||
let height = be32(bytes, 0x18);
|
||||
if !(1..=4096).contains(&width) || !(1..=4096).contains(&height) {
|
||||
return None;
|
||||
}
|
||||
let header = header_size(be32(bytes, 0x1c))?;
|
||||
let n = (width as usize) * (height as usize) * 4;
|
||||
if bytes.len() < header + n {
|
||||
return None; // DXT/palettized/short variant — defer, don't misdecode
|
||||
}
|
||||
|
||||
// A8R8G8B8 → RGBA8.
|
||||
let src = &bytes[header..header + n];
|
||||
let mut rgba = vec![0u8; n];
|
||||
for (px, out) in src.chunks_exact(4).zip(rgba.chunks_exact_mut(4)) {
|
||||
let (a, r, g, b) = (px[0], px[1], px[2], px[3]);
|
||||
out[0] = r;
|
||||
out[1] = g;
|
||||
out[2] = b;
|
||||
out[3] = a;
|
||||
}
|
||||
Some(T8adImage {
|
||||
width,
|
||||
height,
|
||||
rgba,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a synthetic type-1 (header 64) T8aD with a known A8R8G8B8 pattern.
|
||||
fn synth(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut b = vec![0u8; 64];
|
||||
b[0..4].copy_from_slice(&T8AD_MAGIC);
|
||||
b[0x14..0x18].copy_from_slice(&w.to_be_bytes());
|
||||
b[0x18..0x1c].copy_from_slice(&h.to_be_bytes());
|
||||
b[0x1c..0x20].copy_from_slice(&1u32.to_be_bytes()); // type 1 → header 64
|
||||
for i in 0..(w * h) {
|
||||
b.extend_from_slice(&[(i & 0xff) as u8, 0x24, 0x63, 0xB2]); // A, R, G, B
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_argb_to_rgba() {
|
||||
let b = synth(4, 2);
|
||||
let img = parse(&b).expect("decodes");
|
||||
assert_eq!((img.width, img.height), (4, 2));
|
||||
assert_eq!(img.rgba.len(), 4 * 2 * 4);
|
||||
// pixel 0: A=0,R=0x24,G=0x63,B=0xB2 → RGBA = 24 63 B2 00
|
||||
assert_eq!(&img.rgba[0..4], &[0x24, 0x63, 0xB2, 0x00]);
|
||||
// pixel 1: A=1 → alpha byte
|
||||
assert_eq!(&img.rgba[4..8], &[0x24, 0x63, 0xB2, 0x01]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_variant_and_short() {
|
||||
// type field 7 (unknown) → None
|
||||
let mut b = synth(2, 2);
|
||||
b[0x1c..0x20].copy_from_slice(&7u32.to_be_bytes());
|
||||
assert!(parse(&b).is_none());
|
||||
// truncated pixel data → None
|
||||
let b = synth(64, 64);
|
||||
assert!(parse(&b[..200]).is_none());
|
||||
assert!(parse(b"IDXD\0\0\0\0").is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,43 @@
|
||||
//! Xbox 360 texture format parsing and de-tiling.
|
||||
//!
|
||||
//! ## The Problem: GPU Tiling
|
||||
//! The Xbox 360 Xenos GPU stores textures in a "tiled" memory layout for
|
||||
//! cache efficiency. The tiles are 32×32 texel macro-tiles, and within
|
||||
//! each macro-tile the DXT compression blocks are arranged in Morton
|
||||
//! (Z-order curve) order. Before we can upload a texture to a modern GPU,
|
||||
//! we must "de-tile" it back to a standard linear (row-major) layout.
|
||||
//! ## XPR2 Container Layout
|
||||
//!
|
||||
//! ## Reference implementations
|
||||
//! - Xenia emulator: `src/xenia/gpu/texture_util.cc`
|
||||
//! - RareView (C#): Xbox 360 texture de-tiling
|
||||
//! - swizzleinator crate: general console texture unswizzling
|
||||
//! ```text
|
||||
//! Offset Size Field
|
||||
//! 0x00 4 Magic: "XPR2"
|
||||
//! 0x04 4 header_size — pixel data section starts at this file offset (e.g. 0x2800)
|
||||
//! 0x08 4 data_size — size of the pixel data section (e.g. 0x8A000)
|
||||
//! 0x0C 4 num_resources
|
||||
//! 0x10 16*n Resource directory: n × 16-byte Xpr2ResourceEntry structs
|
||||
//! [type_tag:4][data_offset:4][data_size:4][name_offset:4]
|
||||
//! … … Resource descriptors (TX2D = 52-byte D3DBaseTexture2D structs)
|
||||
//! 0x2800 … Pixel data section
|
||||
//! ```
|
||||
//!
|
||||
//! ## GPUTEXTURE_FETCH_CONSTANT (GPUFC)
|
||||
//!
|
||||
//! Each TX2D descriptor is 52 bytes. The 6-dword (24-byte) GPUFC starts at
|
||||
//! descriptor offset +0x18:
|
||||
//!
|
||||
//! ```text
|
||||
//! GPUFC[0] (+0x18): tiled flag at bit 31, pitch at bits[23:8]
|
||||
//! GPUFC[1] (+0x1C): TextureFormat at bits[5:0], base_address at bits[31:12]
|
||||
//! GPUFC[2] (+0x20): width-1 at bits[12:0], height-1 at bits[25:13] (size_2d)
|
||||
//! GPUFC[3] (+0x24): swizzle, filter
|
||||
//! GPUFC[4] (+0x28): mip_max at bits[9:6] → mip_count = mip_max + 1
|
||||
//! GPUFC[5] (+0x2C): mip_address, packed_mips, dimension
|
||||
//! ```
|
||||
//!
|
||||
//! ## GPU Tiling
|
||||
//!
|
||||
//! Xbox 360 Xenos stores textures in 32×32 texel macro-tiles. Within each
|
||||
//! macro-tile the DXT blocks are arranged in Morton (Z-order) order.
|
||||
//! GPUFC[0] bit 31 = 1 → tiled (de-tiling required); = 0 → linear.
|
||||
//!
|
||||
//! ## References
|
||||
//!
|
||||
//! - Xenia: `src/xenia/gpu/xenos.h` (GPUTEXTUREFORMAT enum, GPUFC bitfields)
|
||||
//! - Xenia: `src/xenia/gpu/texture_util.cc` (de-tiling algorithm)
|
||||
|
||||
use binrw::{BinRead, binread};
|
||||
use thiserror::Error;
|
||||
@@ -22,6 +49,9 @@ pub enum TextureError {
|
||||
#[error("Invalid texture header magic: expected {expected:?}, got {got:?}")]
|
||||
BadMagic { expected: [u8; 4], got: [u8; 4] },
|
||||
|
||||
#[error("No TX2D texture resource found in XPR2 file")]
|
||||
NoTextureFound,
|
||||
|
||||
#[error("Unsupported texture format: 0x{0:02X}")]
|
||||
UnsupportedFormat(u8),
|
||||
|
||||
@@ -37,56 +67,58 @@ pub enum TextureError {
|
||||
|
||||
// ── Texture formats ───────────────────────────────────────────────────────────
|
||||
|
||||
/// D3DFORMAT values used by the Xbox 360 SDK for texture data.
|
||||
/// These appear in XPR2 headers and in-memory texture descriptors.
|
||||
/// GPUTEXTUREFORMAT values from Xenia's `xenos.h`.
|
||||
///
|
||||
/// Format codes sourced from:
|
||||
/// - Xbox 360 SDK documentation (leaked)
|
||||
/// - Xenia emulator source (gpu/xenos.h)
|
||||
/// - ZenHAX community research
|
||||
/// These 6-bit codes live in GPUFC dword_1 bits[5:0] — NOT the old D3DFORMAT
|
||||
/// codes. The mapping is:
|
||||
/// k_8_8_8_8 = 6, k_DXT1 = 18, k_DXT2_3 = 19, k_DXT4_5 = 20,
|
||||
/// k_DXN = 49, k_DXT5A = 59
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum X360TextureFormat {
|
||||
/// DXT1 / BC1 — 4 bpp, 1-bit alpha
|
||||
Dxt1 = 0x52,
|
||||
/// DXT3 / BC2 — 8 bpp, 4-bit explicit alpha
|
||||
Dxt3 = 0x53,
|
||||
/// DXT5 / BC3 — 8 bpp, 8-bit interpolated alpha
|
||||
Dxt5 = 0x54,
|
||||
/// DXN / BC5 / ATI2 — normal maps, two-channel
|
||||
Dxn = 0x71,
|
||||
/// Uncompressed A8R8G8B8 — 32 bpp
|
||||
A8R8G8B8 = 0x06,
|
||||
/// Uncompressed X8R8G8B8 — 32 bpp, no alpha
|
||||
X8R8G8B8 = 0x07,
|
||||
/// A8R8G8B8 — uncompressed 32 bpp (k_8_8_8_8 = 6)
|
||||
A8R8G8B8 = 6,
|
||||
/// X8R8G8B8 — uncompressed 32 bpp, no alpha (k_8_8_8_8_AS_16_16_16_16 = 7)
|
||||
X8R8G8B8 = 7,
|
||||
/// DXT1 / BC1 — 4 bpp, 1-bit alpha (k_DXT1 = 18)
|
||||
Dxt1 = 18,
|
||||
/// DXT2/3 / BC2 — 8 bpp, 4-bit explicit alpha (k_DXT2_3 = 19)
|
||||
Dxt3 = 19,
|
||||
/// DXT4/5 / BC3 — 8 bpp, 8-bit interpolated alpha (k_DXT4_5 = 20)
|
||||
Dxt5 = 20,
|
||||
/// DXN / BC5 / ATI2N — two-channel normal maps (k_DXN = 49)
|
||||
Dxn = 49,
|
||||
/// DXT5A / BC4 / ATI1N — single alpha channel (k_DXT5A = 59)
|
||||
/// Used for gloss, specular, luminance, and reflection maps in Project Sylpheed.
|
||||
Dxt5A = 59,
|
||||
}
|
||||
|
||||
impl X360TextureFormat {
|
||||
pub fn from_u8(v: u8) -> Option<Self> {
|
||||
match v {
|
||||
0x52 => Some(Self::Dxt1),
|
||||
0x53 => Some(Self::Dxt3),
|
||||
0x54 => Some(Self::Dxt5),
|
||||
0x71 => Some(Self::Dxn),
|
||||
0x06 => Some(Self::A8R8G8B8),
|
||||
0x07 => Some(Self::X8R8G8B8),
|
||||
_ => None,
|
||||
6 => Some(Self::A8R8G8B8),
|
||||
7 => Some(Self::X8R8G8B8),
|
||||
18 => Some(Self::Dxt1),
|
||||
19 => Some(Self::Dxt3),
|
||||
20 => Some(Self::Dxt5),
|
||||
49 => Some(Self::Dxn),
|
||||
59 => Some(Self::Dxt5A),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes per compressed block (4×4 texel group).
|
||||
/// For uncompressed formats, bytes per pixel instead.
|
||||
/// Bytes per compressed block (4×4 texel group) or per pixel for uncompressed.
|
||||
pub fn bytes_per_block(&self) -> usize {
|
||||
match self {
|
||||
Self::Dxt1 => 8,
|
||||
Self::Dxt3 | Self::Dxt5 | Self::Dxn => 16,
|
||||
Self::A8R8G8B8 | Self::X8R8G8B8 => 4,
|
||||
Self::Dxt1 | Self::Dxt5A => 8,
|
||||
Self::Dxt3 | Self::Dxt5 | Self::Dxn => 16,
|
||||
Self::A8R8G8B8 | Self::X8R8G8B8 => 4,
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this a BCn block-compressed format?
|
||||
pub fn is_block_compressed(&self) -> bool {
|
||||
matches!(self, Self::Dxt1 | Self::Dxt3 | Self::Dxt5 | Self::Dxn)
|
||||
matches!(self, Self::Dxt1 | Self::Dxt3 | Self::Dxt5 | Self::Dxn | Self::Dxt5A)
|
||||
}
|
||||
|
||||
/// Texels per block side (4 for BCn, 1 for uncompressed).
|
||||
@@ -97,176 +129,253 @@ impl X360TextureFormat {
|
||||
|
||||
// ── XPR2 container format ─────────────────────────────────────────────────────
|
||||
|
||||
/// XPR2 is Microsoft's Xbox Packed Resource v2 format.
|
||||
/// It stores D3D resources (textures, vertex buffers, index buffers)
|
||||
/// in a single blob that can be DMA'd directly into GPU memory.
|
||||
///
|
||||
/// The header is big-endian (Xbox 360 is big-endian PowerPC).
|
||||
/// NOTE: Project Sylpheed may use a custom container. If files don't
|
||||
/// start with b"XPR2", check for game-specific magic bytes instead.
|
||||
/// XPR2 container header (big-endian, 16 bytes total including magic).
|
||||
#[binread]
|
||||
#[br(magic = b"XPR2", big)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Xpr2Header {
|
||||
/// Total file size in bytes
|
||||
pub total_size: u32,
|
||||
/// Size of the header section (texture data starts after this)
|
||||
/// Byte offset where the pixel data section starts (= size of header region).
|
||||
/// Example value: 0x2800 = 10240.
|
||||
pub header_size: u32,
|
||||
/// Number of resource entries in this file
|
||||
/// Size of the pixel data section in bytes.
|
||||
/// Example value: 0x8A000 = 565248.
|
||||
pub data_size: u32,
|
||||
/// Number of 16-byte resource entries in the directory at offset 0x10.
|
||||
pub num_resources: u32,
|
||||
}
|
||||
|
||||
/// A single resource entry within an XPR2 file.
|
||||
/// Each entry describes one D3D resource (texture, buffer, etc.)
|
||||
/// One 16-byte entry in the XPR2 resource directory (starts at file offset 0x10).
|
||||
#[binread]
|
||||
#[br(big)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Xpr2ResourceEntry {
|
||||
/// Encoded type and reference count.
|
||||
/// Bits [0..15] = ref_count
|
||||
/// Bits [16..18] = resource type (4 = texture)
|
||||
/// Bits [19..31] = flags
|
||||
pub resource_type_and_flags: u32,
|
||||
/// Offset of this resource's data within the XPR2 file
|
||||
/// ASCII type tag: b"TX2D" for 2D textures, b"XBG7" for geometry, etc.
|
||||
pub type_tag: [u8; 4],
|
||||
/// Byte offset of this resource's descriptor, relative to directory base 0x10.
|
||||
/// Actual file offset = data_offset + 0x10.
|
||||
pub data_offset: u32,
|
||||
/// Reserved / unknown
|
||||
pub _unknown: u32,
|
||||
/// Size of the resource descriptor in bytes (e.g. 0x34 = 52 for TX2D).
|
||||
pub descriptor_size: u32,
|
||||
/// Byte offset of this resource's name string, relative to directory base 0x10.
|
||||
pub name_offset: u32,
|
||||
}
|
||||
|
||||
impl Xpr2ResourceEntry {
|
||||
pub fn resource_type(&self) -> u8 {
|
||||
((self.resource_type_and_flags >> 16) & 0x7) as u8
|
||||
}
|
||||
|
||||
pub fn is_texture(&self) -> bool {
|
||||
self.resource_type() == 4
|
||||
}
|
||||
}
|
||||
|
||||
/// Xbox 360 D3D texture descriptor embedded in an XPR2 resource.
|
||||
/// This is what the GPU register `NV097_SET_TEXTURE_FORMAT` receives.
|
||||
///
|
||||
/// Reference: xboxdevwiki.net/XPR
|
||||
#[binread]
|
||||
#[br(big)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct X360TextureDesc {
|
||||
/// Packed GPU texture format register
|
||||
/// Bits [0..3] = DMA channel
|
||||
/// Bits [4..7] = dimensionality (2 = 2D)
|
||||
/// Bits [8..15] = D3DFORMAT (see X360TextureFormat)
|
||||
/// Bits [16..19] = mip levels
|
||||
/// Bits [20..23] = width as power-of-two: actual = 1 << value
|
||||
/// Bits [24..27] = height as power-of-two: actual = 1 << value
|
||||
/// Bits [28..31] = depth (for 3D textures)
|
||||
pub gpu_format: u32,
|
||||
|
||||
/// For non-power-of-two textures, encodes actual dimensions
|
||||
pub npot_size: u32,
|
||||
}
|
||||
|
||||
impl X360TextureDesc {
|
||||
pub fn format_code(&self) -> u8 {
|
||||
((self.gpu_format >> 8) & 0xFF) as u8
|
||||
&self.type_tag == b"TX2D"
|
||||
}
|
||||
|
||||
pub fn format(&self) -> Option<X360TextureFormat> {
|
||||
X360TextureFormat::from_u8(self.format_code())
|
||||
}
|
||||
|
||||
pub fn mip_levels(&self) -> u32 {
|
||||
(self.gpu_format >> 16) & 0xF
|
||||
}
|
||||
|
||||
/// Width from the packed power-of-two field.
|
||||
pub fn width_pow2(&self) -> u32 {
|
||||
1 << ((self.gpu_format >> 20) & 0xF)
|
||||
}
|
||||
|
||||
/// Height from the packed power-of-two field.
|
||||
pub fn height_pow2(&self) -> u32 {
|
||||
1 << ((self.gpu_format >> 24) & 0xF)
|
||||
/// Cubemap resource (`TXCM`) — 6 faces sharing one GPUFC descriptor.
|
||||
pub fn is_cubemap(&self) -> bool {
|
||||
&self.type_tag == b"TXCM"
|
||||
}
|
||||
}
|
||||
|
||||
// ── Decoded texture ───────────────────────────────────────────────────────────
|
||||
|
||||
/// A decoded Xbox 360 texture, ready to upload to a modern GPU.
|
||||
/// A decoded Xbox 360 texture ready for GPU upload.
|
||||
///
|
||||
/// After `from_xpr2()` or `from_raw_tiled()`, the `data` field contains
|
||||
/// the texture in standard linear layout that Bevy / wgpu can consume.
|
||||
/// After `from_xpr2()` the `data` field holds the texture in standard linear
|
||||
/// (row-major) layout. BCn formats are kept as compressed block data; the GPU
|
||||
/// decompresses in hardware.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct X360Texture {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub format: X360TextureFormat,
|
||||
pub mip_levels: u32,
|
||||
/// De-tiled texture data in linear (row-major) order.
|
||||
/// For BCn formats: standard DDS-style packed block data.
|
||||
/// For ARGB: standard RGBA8 pixel data.
|
||||
/// True when the source resource was a cubemap (`TXCM`). `data` then holds
|
||||
/// only face 0 (the +X face) decoded as a 2D image — enough for a preview.
|
||||
pub is_cubemap: bool,
|
||||
/// De-tiled texture data in linear order.
|
||||
/// BCn: standard packed block data (DDS layout).
|
||||
/// Uncompressed: BGRA8 pixel data.
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A decoded Xbox 360 cubemap (`TXCM`) — a world skybox. The 6 faces are each a
|
||||
/// de-tiled 2D surface in the same layout as [`X360Texture::data`], in D3D9
|
||||
/// `D3DCUBEMAP_FACES` order.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Cubemap {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub format: X360TextureFormat,
|
||||
/// Exactly 6 faces, D3D9 order: +X, -X, +Y, -Y, +Z, -Z.
|
||||
pub faces: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Cubemap {
|
||||
/// The D3D9 cube-face name for slice `i` (0..6).
|
||||
pub fn face_label(i: usize) -> &'static str {
|
||||
["+X", "-X", "+Y", "-Y", "+Z", "-Z"].get(i).copied().unwrap_or("?")
|
||||
}
|
||||
}
|
||||
|
||||
impl X360Texture {
|
||||
/// Parse a texture from a raw XPR2 file's bytes.
|
||||
/// Parse the first TX2D texture from an XPR2 file's raw bytes.
|
||||
///
|
||||
/// This handles the full pipeline:
|
||||
/// 1. Parse XPR2 header + resource descriptors
|
||||
/// 2. Locate the texture resource
|
||||
/// 3. De-tile the GPU memory layout → linear layout
|
||||
/// Pipeline:
|
||||
/// 1. Parse XPR2 header + resource directory
|
||||
/// 2. Locate the first TX2D entry
|
||||
/// 3. Read its GPUTEXTURE_FETCH_CONSTANT (GPUFC) at descriptor +0x18
|
||||
/// 4. De-tile the pixel data (if tiled) → linear layout
|
||||
pub fn from_xpr2(bytes: &[u8]) -> Result<Self, TextureError> {
|
||||
use std::io::Cursor;
|
||||
let mut cur = Cursor::new(bytes);
|
||||
|
||||
// Parse main header (validates "XPR2" magic)
|
||||
// Parse header — validates "XPR2" magic, reads 3 × u32 (total 16 bytes)
|
||||
let header = Xpr2Header::read(&mut cur)?;
|
||||
|
||||
// Parse resource entries
|
||||
// Resource directory begins immediately after the 16-byte header (offset 0x10)
|
||||
let mut entries = Vec::new();
|
||||
for _ in 0..header.num_resources {
|
||||
entries.push(Xpr2ResourceEntry::read(&mut cur)?);
|
||||
}
|
||||
|
||||
// Find the first texture resource
|
||||
// Select a texture resource. TX2D = 2D texture; TXCM = cubemap
|
||||
// (skybox / backdrop) — same 52-byte descriptor + GPUFC layout, but the
|
||||
// pixel section holds 6 faces. For a preview we decode face 0.
|
||||
// XPR files are frequently PACKS of many textures; XPR_RES_INDEX picks
|
||||
// the Nth texture resource (default 0) for RE/browse validation.
|
||||
let want = std::env::var("XPR_RES_INDEX")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
let tex_entry = entries.iter()
|
||||
.find(|e| e.is_texture())
|
||||
.ok_or_else(|| TextureError::UnsupportedFormat(0))?;
|
||||
.filter(|e| e.is_texture() || e.is_cubemap())
|
||||
.nth(want)
|
||||
.ok_or(TextureError::NoTextureFound)?;
|
||||
let is_cubemap = tex_entry.is_cubemap();
|
||||
|
||||
// The texture descriptor immediately follows the resource entries
|
||||
let desc = X360TextureDesc::read(&mut cur)?;
|
||||
// The descriptor is at file offset = data_offset + 0x10 (directory base)
|
||||
const DIR_BASE: usize = 0x10;
|
||||
let desc_file_offset = tex_entry.data_offset as usize + DIR_BASE;
|
||||
|
||||
let format = desc.format()
|
||||
.ok_or(TextureError::UnsupportedFormat(desc.format_code()))?;
|
||||
// GPUFC is a 6-dword (24-byte) block at descriptor offset +0x18
|
||||
let gpufc_base = desc_file_offset + 0x18;
|
||||
if bytes.len() < gpufc_base + 6 * 4 {
|
||||
return Err(TextureError::BufferTooSmall {
|
||||
needed: gpufc_base + 24,
|
||||
have: bytes.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let width = desc.width_pow2();
|
||||
let height = desc.height_pow2();
|
||||
// Read one big-endian u32 at the given file offset
|
||||
let be_u32 = |offset: usize| -> u32 {
|
||||
u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap())
|
||||
};
|
||||
|
||||
// Texture data is at header_size offset
|
||||
let data_start = header.header_size as usize;
|
||||
let gpufc0 = be_u32(gpufc_base); // +0x18
|
||||
let gpufc1 = be_u32(gpufc_base + 0x04); // +0x1C
|
||||
let gpufc2 = be_u32(gpufc_base + 0x08); // +0x20
|
||||
let gpufc4 = be_u32(gpufc_base + 0x10); // +0x28
|
||||
|
||||
// GPUFC[1] bits[5:0] = GPUTEXTUREFORMAT
|
||||
let fmt_code = (gpufc1 & 0x3F) as u8;
|
||||
let format = X360TextureFormat::from_u8(fmt_code)
|
||||
.ok_or(TextureError::UnsupportedFormat(fmt_code))?;
|
||||
|
||||
// GPUFC[1] bits[7:6] = endianness. X360 stores texture words byte-
|
||||
// swapped; without undoing this, BCn endpoints/indices and ARGB channels
|
||||
// decode to noise. (fetch-constant `endianness`, xenos.h Endian.)
|
||||
let endianness = ((gpufc1 >> 6) & 0x3) as u8;
|
||||
|
||||
// GPUFC[1] bits[31:12] = base_address (4KB-aligned byte offset into data section)
|
||||
let base_address = (gpufc1 & 0xFFFFF000) as usize;
|
||||
|
||||
// GPUFC[2] / size_2d: width-1 in bits[12:0], height-1 in bits[25:13]
|
||||
let width = (gpufc2 & 0x1FFF) + 1;
|
||||
let height = ((gpufc2 >> 13) & 0x1FFF) + 1;
|
||||
|
||||
// GPUFC[4]: mip_max in bits[9:6]; mip_count = mip_max + 1
|
||||
let mip_count = ((gpufc4 >> 6) & 0xF) + 1;
|
||||
|
||||
// GPUFC[0] bit 31 = 1 → tiled memory layout (requires de-tiling)
|
||||
let is_tiled = (gpufc0 >> 31) != 0;
|
||||
|
||||
// Pixel data for this texture starts at: header_size + base_address
|
||||
let data_start = header.header_size as usize + base_address;
|
||||
if bytes.len() <= data_start {
|
||||
return Err(TextureError::BufferTooSmall {
|
||||
needed: data_start + 1,
|
||||
have: bytes.len(),
|
||||
});
|
||||
}
|
||||
let tiled_data = &bytes[data_start..];
|
||||
|
||||
// De-tile!
|
||||
let linear_data = detile(tiled_data, width, height, format)?;
|
||||
// De-tile + endian-correct the single (face-0) surface.
|
||||
let data = decode_surface(&bytes[data_start..], width, height, format, is_tiled, endianness)?;
|
||||
|
||||
Ok(X360Texture {
|
||||
width,
|
||||
height,
|
||||
format,
|
||||
mip_levels: desc.mip_levels().max(1),
|
||||
data: linear_data,
|
||||
})
|
||||
Ok(X360Texture { width, height, format, mip_levels: mip_count, is_cubemap, data })
|
||||
}
|
||||
|
||||
/// Decode all 6 faces of a cubemap (`TXCM`) resource, or `Ok(None)` if the
|
||||
/// selected resource is an ordinary 2D texture.
|
||||
///
|
||||
/// X360 cubemaps store the 6 faces back-to-back in the pixel section, each a
|
||||
/// full independently-tiled 2D surface whose stride is the tiled surface size
|
||||
/// rounded up to a 4 KiB subresource boundary (`kTextureSubresourceAlignmentBytes`).
|
||||
/// Faces are in D3D9 `D3DCUBEMAP_FACES` order: +X, -X, +Y, -Y, +Z, -Z.
|
||||
/// (Derived from xenia `texture_util.cc::GetGuestTextureLayout`; verified on
|
||||
/// `BG_Acheron`: `data_size == 6 × 0x400000` and all 6 faces decode cleanly.)
|
||||
pub fn cube_faces_from_xpr2(bytes: &[u8]) -> Result<Option<Cubemap>, TextureError> {
|
||||
use std::io::Cursor;
|
||||
let mut cur = Cursor::new(bytes);
|
||||
let header = Xpr2Header::read(&mut cur)?;
|
||||
let mut entries = Vec::new();
|
||||
for _ in 0..header.num_resources {
|
||||
entries.push(Xpr2ResourceEntry::read(&mut cur)?);
|
||||
}
|
||||
let want = std::env::var("XPR_RES_INDEX")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
let tex_entry = entries
|
||||
.iter()
|
||||
.filter(|e| e.is_texture() || e.is_cubemap())
|
||||
.nth(want)
|
||||
.ok_or(TextureError::NoTextureFound)?;
|
||||
if !tex_entry.is_cubemap() {
|
||||
return Ok(None); // ordinary 2D texture — use `from_xpr2`
|
||||
}
|
||||
|
||||
const DIR_BASE: usize = 0x10;
|
||||
let gpufc_base = tex_entry.data_offset as usize + DIR_BASE + 0x18;
|
||||
if bytes.len() < gpufc_base + 6 * 4 {
|
||||
return Err(TextureError::BufferTooSmall { needed: gpufc_base + 24, have: bytes.len() });
|
||||
}
|
||||
let be_u32 = |o: usize| u32::from_be_bytes(bytes[o..o + 4].try_into().unwrap());
|
||||
let gpufc0 = be_u32(gpufc_base);
|
||||
let gpufc1 = be_u32(gpufc_base + 0x04);
|
||||
let gpufc2 = be_u32(gpufc_base + 0x08);
|
||||
|
||||
let fmt_code = (gpufc1 & 0x3F) as u8;
|
||||
let format = X360TextureFormat::from_u8(fmt_code)
|
||||
.ok_or(TextureError::UnsupportedFormat(fmt_code))?;
|
||||
let endianness = ((gpufc1 >> 6) & 0x3) as u8;
|
||||
let base_address = (gpufc1 & 0xFFFFF000) as usize;
|
||||
let width = (gpufc2 & 0x1FFF) + 1;
|
||||
let height = ((gpufc2 >> 13) & 0x1FFF) + 1;
|
||||
let is_tiled = (gpufc0 >> 31) != 0;
|
||||
|
||||
let data_start = header.header_size as usize + base_address;
|
||||
let stride = tiled_face_stride(width, height, format);
|
||||
|
||||
let mut faces = Vec::with_capacity(6);
|
||||
for f in 0..6 {
|
||||
let start = data_start + f * stride;
|
||||
let raw = bytes
|
||||
.get(start..)
|
||||
.ok_or(TextureError::BufferTooSmall { needed: start + 1, have: bytes.len() })?;
|
||||
faces.push(decode_surface(raw, width, height, format, is_tiled, endianness)?);
|
||||
}
|
||||
Ok(Some(Cubemap { width, height, format, faces }))
|
||||
}
|
||||
|
||||
/// Parse a texture from already-known parameters + raw tiled data.
|
||||
///
|
||||
/// Use this when you've reverse-engineered a game-specific container
|
||||
/// and extracted the raw tiled texture bytes yourself.
|
||||
/// Use when you have reverse-engineered a container and extracted the
|
||||
/// raw tiled bytes yourself.
|
||||
pub fn from_raw_tiled(
|
||||
tiled_data: &[u8],
|
||||
width: u32,
|
||||
@@ -274,30 +383,163 @@ impl X360Texture {
|
||||
format: X360TextureFormat,
|
||||
) -> Result<Self, TextureError> {
|
||||
let linear_data = detile(tiled_data, width, height, format)?;
|
||||
Ok(X360Texture {
|
||||
width,
|
||||
height,
|
||||
format,
|
||||
mip_levels: 1,
|
||||
data: linear_data,
|
||||
})
|
||||
Ok(X360Texture { width, height, format, mip_levels: 1, is_cubemap: false, data: linear_data })
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the Xenos texture endian swap in place, as byte permutations over the
|
||||
/// data. Xbox 360 stores texture words big-endian; this converts them to the
|
||||
/// PC-standard little-endian layout that BCn decoders and wgpu expect.
|
||||
/// `endianness` is the fetch-constant `dword_1` bits[7:6]:
|
||||
/// 0 = none, 1 = k8in16, 2 = k8in32, 3 = k16in32 (xenia `xenos.h` `Endian`).
|
||||
pub fn apply_endian_swap(data: &mut [u8], endianness: u8) {
|
||||
match endianness {
|
||||
1 => {
|
||||
// k8in16 — swap the two bytes of each 16-bit half.
|
||||
for c in data.chunks_exact_mut(2) {
|
||||
c.swap(0, 1);
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
// k8in32 — reverse each 32-bit word.
|
||||
for c in data.chunks_exact_mut(4) {
|
||||
c.reverse();
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
// k16in32 — swap the two 16-bit halves of each 32-bit word.
|
||||
for c in data.chunks_exact_mut(4) {
|
||||
c.swap(0, 2);
|
||||
c.swap(1, 3);
|
||||
}
|
||||
}
|
||||
_ => {} // kNone
|
||||
}
|
||||
}
|
||||
|
||||
/// De-tile (if tiled) + endian-correct one texture surface into linear PC
|
||||
/// layout. Shared by `from_xpr2` (face 0) and `cube_faces_from_xpr2` (6 faces).
|
||||
/// The `XPR_NO_DETILE` / `XPR_NO_ENDIAN` / `XPR_FORCE_ENDIAN` / `XPR_NO_BC_DWORD_SWAP`
|
||||
/// debug knobs are honoured here so both paths behave identically.
|
||||
fn decode_surface(
|
||||
raw_data: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
format: X360TextureFormat,
|
||||
is_tiled: bool,
|
||||
endianness: u8,
|
||||
) -> Result<Vec<u8>, TextureError> {
|
||||
let force_linear = std::env::var("XPR_NO_DETILE").is_ok();
|
||||
let mut linear_data = if is_tiled && !force_linear {
|
||||
detile(raw_data, width, height, format)?
|
||||
} else {
|
||||
// Linear layout — copy only the mip-0 slice.
|
||||
let block_size = format.block_size() as u32;
|
||||
let bw = ((width + block_size - 1) / block_size).max(1);
|
||||
let bh = ((height + block_size - 1) / block_size).max(1);
|
||||
let needed = bw as usize * bh as usize * format.bytes_per_block();
|
||||
if raw_data.len() < needed {
|
||||
return Err(TextureError::BufferTooSmall { needed, have: raw_data.len() });
|
||||
}
|
||||
raw_data[..needed].to_vec()
|
||||
};
|
||||
|
||||
// Undo the X360 word byte-swap so block/pixel data is PC little-endian.
|
||||
let endianness = std::env::var("XPR_FORCE_ENDIAN")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<u8>().ok())
|
||||
.unwrap_or(endianness);
|
||||
if std::env::var("XPR_NO_ENDIAN").is_err() {
|
||||
apply_endian_swap(&mut linear_data, endianness);
|
||||
}
|
||||
|
||||
// BC1 colour-block dword swap (see the long note where this was discovered):
|
||||
// colour endpoints live in the high dword on X360; format-targeted so BC4/BC5
|
||||
// (byte-indexed alpha/normal blocks) are left as endian-only.
|
||||
if std::env::var("XPR_NO_BC_DWORD_SWAP").is_err() {
|
||||
match format {
|
||||
X360TextureFormat::Dxt1 => swap_bc_block_dwords(&mut linear_data),
|
||||
X360TextureFormat::Dxt3 | X360TextureFormat::Dxt5 => {
|
||||
for block in linear_data.chunks_exact_mut(16) {
|
||||
swap_bc_block_dwords(&mut block[8..16]);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(linear_data)
|
||||
}
|
||||
|
||||
/// Byte stride between consecutive cubemap faces: the tiled surface size
|
||||
/// (`pitch_aligned × height_aligned × bpb`, both padded to 32-block macro tiles)
|
||||
/// rounded up to the 4 KiB subresource alignment (`kTextureSubresourceAlignmentBytes`).
|
||||
fn tiled_face_stride(width: u32, height: u32, format: X360TextureFormat) -> usize {
|
||||
let bs = format.block_size() as u32;
|
||||
let bw = ((width + bs - 1) / bs).max(1);
|
||||
let bh = ((height + bs - 1) / bs).max(1);
|
||||
let pitch_aligned = align_up(bw, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
|
||||
let height_aligned = align_up(bh, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
|
||||
let surface = pitch_aligned as usize * height_aligned as usize * format.bytes_per_block();
|
||||
(surface + 0xFFF) & !0xFFF
|
||||
}
|
||||
|
||||
/// Swap the two 32-bit dwords within each 64-bit unit of `data`, in place.
|
||||
///
|
||||
/// Xbox 360 stores the BC1 *colour* block with its two 32-bit words in the
|
||||
/// opposite order to the PC/DDS layout, putting the colour endpoints ahead of
|
||||
/// the indices. Apply to a BC1 buffer (or the colour half of a BC2/BC3 block)
|
||||
/// after the byte-level endian swap. Any trailing bytes that don't fill a full
|
||||
/// 8-byte group are left untouched.
|
||||
pub fn swap_bc_block_dwords(data: &mut [u8]) {
|
||||
for unit in data.chunks_exact_mut(8) {
|
||||
// [d0 d1 d2 d3 | d4 d5 d6 d7] → [d4 d5 d6 d7 | d0 d1 d2 d3]
|
||||
let (lo, hi) = unit.split_at_mut(4);
|
||||
lo.swap_with_slice(hi);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core de-tiling algorithm ──────────────────────────────────────────────────
|
||||
|
||||
/// De-tile an Xbox 360 GPU texture from tiled to linear layout.
|
||||
/// Macro-tile side in blocks (Xenos tiles are 32×32 *blocks*, where a "block"
|
||||
/// is one BCn 4×4 group or one uncompressed texel). `texture_address.h`
|
||||
/// `kTextureTileWidthHeight` / `kMacroTileWidth`.
|
||||
const MACRO_TILE_BLOCKS: u32 = 32;
|
||||
/// Storage pitch/height alignment, in blocks (`kStoragePitchHeightAlignmentBlocks`).
|
||||
const STORAGE_ALIGN_BLOCKS: u32 = 32;
|
||||
|
||||
#[inline]
|
||||
fn align_up(v: u32, a: u32) -> u32 {
|
||||
(v + a - 1) & !(a - 1)
|
||||
}
|
||||
|
||||
/// Byte offset of block (x, y) within an Xbox 360 2D tiled surface.
|
||||
///
|
||||
/// Xbox 360 stores textures in a hierarchical tiled format:
|
||||
/// - The texture is divided into 32×32 texel **macro-tiles**
|
||||
/// - Within each macro-tile, DXT blocks are in **Morton (Z-order)** order
|
||||
/// Faithful port of xenia-canary `texture_address.h::Tiled2D` + `TiledCombine`
|
||||
/// (documented Xenos hardware tiling — bank/pipe/macro-tile addressing, NOT a
|
||||
/// plain Morton curve). `x`/`y` and `pitch_aligned` are in block units;
|
||||
/// `bpb_log2` is log2(bytes-per-block). Returns a byte offset.
|
||||
#[inline]
|
||||
fn tiled_2d_offset(x: i32, y: i32, pitch_aligned: u32, bpb_log2: u32) -> i32 {
|
||||
let outer_blocks = ((y >> 5) * (pitch_aligned >> 5) as i32 + (x >> 5)) << 6;
|
||||
let inner_blocks = (((y >> 1) & 0b111) << 3) | (x & 0b111);
|
||||
let outer_inner_bytes = (outer_blocks | inner_blocks) << bpb_log2;
|
||||
let bank = (y >> 4) & 0b1;
|
||||
let pipe = ((x >> 3) & 0b11) ^ (((y >> 3) & 0b1) << 1);
|
||||
let y_lsb = y & 1;
|
||||
// TiledCombine: splice bank/pipe/y_lsb bits into the byte address.
|
||||
((y_lsb << 4) | (pipe << 6) | (bank << 11))
|
||||
| (outer_inner_bytes & 0b1111)
|
||||
| (((outer_inner_bytes >> 4) & 0b1) << 5)
|
||||
| (((outer_inner_bytes >> 5) & 0b111) << 8)
|
||||
| (outer_inner_bytes >> 8 << 12)
|
||||
}
|
||||
|
||||
/// De-tile an Xbox 360 GPU texture (mip-0) from tiled to linear (row-major)
|
||||
/// layout, using the exact Xenos address formula.
|
||||
///
|
||||
/// This is required for ALL textures regardless of whether they are
|
||||
/// DXT-compressed or uncompressed — the GPU expects tiled memory.
|
||||
///
|
||||
/// Algorithm based on:
|
||||
/// - Xenia: `texture_util.cc` `TileTexture()`
|
||||
/// - GTA IV Xbox 360 Texture Editor by Pimpin Tyler and Anthony
|
||||
/// `src` must hold the tiled mip-0 surface (its storage pitch/height are
|
||||
/// rounded up to 32 blocks, so it may be larger than the visible image). The
|
||||
/// returned buffer is tightly packed linear block data (DDS layout for BCn).
|
||||
pub fn detile(
|
||||
src: &[u8],
|
||||
width: u32,
|
||||
@@ -305,55 +547,31 @@ pub fn detile(
|
||||
format: X360TextureFormat,
|
||||
) -> Result<Vec<u8>, TextureError> {
|
||||
let block_size = format.block_size() as u32;
|
||||
let bpb = format.bytes_per_block(); // bytes per block
|
||||
let bpb = format.bytes_per_block();
|
||||
let bpb_log2 = (bpb as u32).trailing_zeros();
|
||||
|
||||
// Dimensions in blocks
|
||||
// Visible dimensions in blocks, and the padded storage pitch/height.
|
||||
let blocks_wide = ((width + block_size - 1) / block_size).max(1);
|
||||
let blocks_tall = ((height + block_size - 1) / block_size).max(1);
|
||||
let pitch_aligned = align_up(blocks_wide, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
|
||||
let height_aligned = align_up(blocks_tall, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
|
||||
|
||||
let expected = blocks_wide as usize * blocks_tall as usize * bpb;
|
||||
if src.len() < expected {
|
||||
return Err(TextureError::BufferTooSmall {
|
||||
needed: expected,
|
||||
have: src.len(),
|
||||
});
|
||||
// The tiled surface occupies pitch_aligned × height_aligned blocks.
|
||||
let src_needed = pitch_aligned as usize * height_aligned as usize * bpb;
|
||||
if src.len() < src_needed {
|
||||
return Err(TextureError::BufferTooSmall { needed: src_needed, have: src.len() });
|
||||
}
|
||||
|
||||
let mut dst = vec![0u8; expected];
|
||||
let dst_len = blocks_wide as usize * blocks_tall as usize * bpb;
|
||||
let mut dst = vec![0u8; dst_len];
|
||||
|
||||
// Macro-tile dimensions (in blocks)
|
||||
// Xbox 360 always uses 32×32 texel macro-tiles
|
||||
let macro_tile_blocks = 32 / block_size; // = 8 for BCn (4×4 texels/block)
|
||||
|
||||
let macro_tiles_wide = (blocks_wide + macro_tile_blocks - 1) / macro_tile_blocks;
|
||||
let macro_tiles_tall = (blocks_tall + macro_tile_blocks - 1) / macro_tile_blocks;
|
||||
let blocks_per_macro_tile = (macro_tile_blocks * macro_tile_blocks) as usize;
|
||||
|
||||
for macro_y in 0..macro_tiles_tall {
|
||||
for macro_x in 0..macro_tiles_wide {
|
||||
let macro_base = ((macro_y * macro_tiles_wide + macro_x) as usize)
|
||||
* blocks_per_macro_tile
|
||||
* bpb;
|
||||
|
||||
for local in 0..blocks_per_macro_tile as u32 {
|
||||
// Decode Morton (Z-order) index → (lx, ly) within macro-tile
|
||||
let (lx, ly) = morton_decode(local);
|
||||
|
||||
let block_x = macro_x * macro_tile_blocks + lx;
|
||||
let block_y = macro_y * macro_tile_blocks + ly;
|
||||
|
||||
// Skip blocks that fall outside the actual texture dimensions
|
||||
if block_x >= blocks_wide || block_y >= blocks_tall {
|
||||
continue;
|
||||
}
|
||||
|
||||
let src_offset = macro_base + local as usize * bpb;
|
||||
let dst_offset = (block_y * blocks_wide + block_x) as usize * bpb;
|
||||
|
||||
if src_offset + bpb <= src.len() && dst_offset + bpb <= dst.len() {
|
||||
dst[dst_offset..dst_offset + bpb]
|
||||
.copy_from_slice(&src[src_offset..src_offset + bpb]);
|
||||
}
|
||||
for by in 0..blocks_tall {
|
||||
for bx in 0..blocks_wide {
|
||||
let src_offset = tiled_2d_offset(bx as i32, by as i32, pitch_aligned, bpb_log2) as usize;
|
||||
let dst_offset = (by * blocks_wide + bx) as usize * bpb;
|
||||
if src_offset + bpb <= src.len() && dst_offset + bpb <= dst.len() {
|
||||
dst[dst_offset..dst_offset + bpb]
|
||||
.copy_from_slice(&src[src_offset..src_offset + bpb]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -363,10 +581,7 @@ pub fn detile(
|
||||
|
||||
/// Decode a Morton (Z-order curve) index into (x, y) coordinates.
|
||||
///
|
||||
/// Morton encoding interleaves the bits of x and y coordinates:
|
||||
/// index = ...y3 x3 y2 x2 y1 x1 y0 x0
|
||||
///
|
||||
/// This is the inverse operation — extract interleaved bits back to x, y.
|
||||
/// Morton encoding interleaves bits: index = …y2 x2 y1 x1 y0 x0
|
||||
#[inline]
|
||||
pub fn morton_decode(index: u32) -> (u32, u32) {
|
||||
let x = compact_bits(index);
|
||||
@@ -374,15 +589,15 @@ pub fn morton_decode(index: u32) -> (u32, u32) {
|
||||
(x, y)
|
||||
}
|
||||
|
||||
/// Compact every other bit — the "de-interleave" operation.
|
||||
/// Used by `morton_decode` to separate X and Y from a Morton index.
|
||||
/// Extract every other bit and pack them into the low bits.
|
||||
/// Used by `morton_decode` to de-interleave X and Y.
|
||||
#[inline]
|
||||
fn compact_bits(mut x: u32) -> u32 {
|
||||
x &= 0x5555_5555; // x = -f-e -d-c -b-a -9-8 -7-6 -5-4 -3-2 -1-0
|
||||
x = (x ^ (x >> 1)) & 0x3333_3333; // x = --fe --dc --ba --98 --76 --54 --32 --10
|
||||
x = (x ^ (x >> 2)) & 0x0f0f_0f0f; // x = ----fedc ----ba98 ----7654 ----3210
|
||||
x = (x ^ (x >> 4)) & 0x00ff_00ff; // x = --------fedcba98 --------76543210
|
||||
x = (x ^ (x >> 8)) & 0x0000_ffff; // x = ----------------fedcba9876543210
|
||||
x &= 0x5555_5555; // keep even-position bits
|
||||
x = (x ^ (x >> 1)) & 0x3333_3333;
|
||||
x = (x ^ (x >> 2)) & 0x0f0f_0f0f;
|
||||
x = (x ^ (x >> 4)) & 0x00ff_00ff;
|
||||
x = (x ^ (x >> 8)) & 0x0000_ffff;
|
||||
x
|
||||
}
|
||||
|
||||
@@ -394,29 +609,80 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn morton_decode_corners() {
|
||||
// Index 0 → (0, 0)
|
||||
assert_eq!(morton_decode(0), (0, 0));
|
||||
// Index 1 → (1, 0) — bit 0 is x
|
||||
assert_eq!(morton_decode(1), (1, 0));
|
||||
// Index 2 → (0, 1) — bit 1 is y
|
||||
assert_eq!(morton_decode(2), (0, 1));
|
||||
// Index 3 → (1, 1)
|
||||
assert_eq!(morton_decode(1), (1, 0)); // bit 0 → x
|
||||
assert_eq!(morton_decode(2), (0, 1)); // bit 1 → y
|
||||
assert_eq!(morton_decode(3), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detile_noop_for_1x1_block() {
|
||||
// A 4×4 DXT1 texture = exactly 1 block = 8 bytes
|
||||
// De-tiling a single block should be identity
|
||||
let src = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04];
|
||||
fn tiled_offset_origin_is_zero() {
|
||||
// Block (0,0) always maps to byte offset 0 for any pitch / bpb.
|
||||
assert_eq!(tiled_2d_offset(0, 0, 32, 3), 0);
|
||||
assert_eq!(tiled_2d_offset(0, 0, 64, 4), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detile_single_block_reads_offset_zero() {
|
||||
// A 4×4 DXT1 texture = 1 visible block, but Xenos pads the tiled
|
||||
// surface to 32×32 blocks (8192 bytes). Block (0,0) sits at offset 0,
|
||||
// so the de-tiled output equals the first 8 source bytes.
|
||||
let mut src = vec![0u8; 32 * 32 * 8];
|
||||
src[..8].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04]);
|
||||
let result = detile(&src, 4, 4, X360TextureFormat::Dxt1).unwrap();
|
||||
assert_eq!(result, src);
|
||||
assert_eq!(result, &src[..8]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_bc_dwords_swaps_each_64bit_half() {
|
||||
// One 8-byte BC1 block: X360 stores it as [indices][endpoints]; the swap
|
||||
// must move the endpoint dword to the front so BC decoders find it.
|
||||
let mut one = vec![0, 1, 2, 3, 4, 5, 6, 7];
|
||||
swap_bc_block_dwords(&mut one);
|
||||
assert_eq!(one, vec![4, 5, 6, 7, 0, 1, 2, 3]);
|
||||
|
||||
// A 16-byte BC3 block = two independent 8-byte halves; each is swapped.
|
||||
let mut two: Vec<u8> = (0..16).collect();
|
||||
swap_bc_block_dwords(&mut two);
|
||||
assert_eq!(
|
||||
two,
|
||||
vec![4, 5, 6, 7, 0, 1, 2, 3, 12, 13, 14, 15, 8, 9, 10, 11]
|
||||
);
|
||||
|
||||
// Applying it twice is the identity (it's its own inverse).
|
||||
let mut back = two.clone();
|
||||
swap_bc_block_dwords(&mut back);
|
||||
assert_eq!(back, (0..16).collect::<Vec<u8>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cubemap_face_stride_and_labels() {
|
||||
// Acheron: 1024×1024 A8R8G8B8 → 1024*1024*4 = 4 MiB, already 4 KiB-aligned.
|
||||
// Verified against the real file: data_size == 6 × 0x400000.
|
||||
assert_eq!(
|
||||
tiled_face_stride(1024, 1024, X360TextureFormat::A8R8G8B8),
|
||||
0x400000
|
||||
);
|
||||
// A tiny surface still occupies a full 32×32-block tile, 4 KiB-aligned.
|
||||
assert_eq!(tiled_face_stride(4, 4, X360TextureFormat::A8R8G8B8), 0x1000);
|
||||
assert_eq!(Cubemap::face_label(0), "+X");
|
||||
assert_eq!(Cubemap::face_label(2), "+Y");
|
||||
assert_eq!(Cubemap::face_label(5), "-Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn x360_format_bytes_per_block() {
|
||||
assert_eq!(X360TextureFormat::Dxt1.bytes_per_block(), 8);
|
||||
assert_eq!(X360TextureFormat::Dxt1.bytes_per_block(), 8);
|
||||
assert_eq!(X360TextureFormat::Dxt5A.bytes_per_block(), 8);
|
||||
assert_eq!(X360TextureFormat::Dxt5.bytes_per_block(), 16);
|
||||
assert_eq!(X360TextureFormat::A8R8G8B8.bytes_per_block(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_from_u8_roundtrip() {
|
||||
for code in [6u8, 7, 18, 19, 20, 49, 59] {
|
||||
assert!(X360TextureFormat::from_u8(code).is_some(), "missing format {code}");
|
||||
}
|
||||
assert!(X360TextureFormat::from_u8(0x52).is_none(), "old D3DFORMAT 0x52 must not match");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,14 +108,23 @@ impl GameAssets {
|
||||
/// Use this to identify unknown file types during reverse engineering.
|
||||
pub fn identify_format(bytes: &[u8]) -> FileFormat {
|
||||
if bytes.len() < 4 {
|
||||
return FileFormat::Unknown;
|
||||
// Very short files can still be text (e.g. a one-line config).
|
||||
return if is_probably_text(bytes) {
|
||||
FileFormat::Text
|
||||
} else {
|
||||
FileFormat::Unknown
|
||||
};
|
||||
}
|
||||
match &bytes[..4] {
|
||||
b"XPR2" => FileFormat::Xpr2Texture,
|
||||
b"RIFF" => FileFormat::Riff, // could be WAV or XWB
|
||||
b"XWB\0" => FileFormat::XwbAudio, // Xbox Wave Bank
|
||||
b"RIFF" => FileFormat::Riff, // could be WAV or XWB
|
||||
b"XWB\0" => FileFormat::XwbAudio, // Xbox Wave Bank
|
||||
[0x89, b'P', b'N', b'G'] => FileFormat::Png,
|
||||
b"DDS " => FileFormat::Dds,
|
||||
// No binary magic matched — fall back to a content heuristic so text
|
||||
// configs (`config.ini`, etc.) are recognised even though they have no
|
||||
// signature.
|
||||
_ if is_probably_text(bytes) => FileFormat::Text,
|
||||
_ => FileFormat::Unknown,
|
||||
}
|
||||
}
|
||||
@@ -127,6 +136,8 @@ pub enum FileFormat {
|
||||
XwbAudio,
|
||||
Png,
|
||||
Dds,
|
||||
/// Plain text (ASCII / UTF-8 / UTF-16) — `.ini` and similar.
|
||||
Text,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
@@ -138,7 +149,162 @@ impl FileFormat {
|
||||
Self::XwbAudio => "xwb",
|
||||
Self::Png => "png",
|
||||
Self::Dds => "dds",
|
||||
Self::Text => "txt",
|
||||
Self::Unknown => "bin",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Text detection & decoding (pure std, WASM-safe) ───────────────────────────
|
||||
|
||||
/// True if `b` is a byte we'd expect inside a text file: common whitespace,
|
||||
/// printable ASCII, or any high byte (UTF-8 continuation / Latin-1). Mirrors the
|
||||
/// printable convention used by the IDXD string-pool extractor.
|
||||
#[inline]
|
||||
fn is_text_byte(b: u8) -> bool {
|
||||
matches!(b, b'\t' | b'\n' | b'\r') || (0x20..0x7f).contains(&b) || b >= 0x80
|
||||
}
|
||||
|
||||
/// Heuristic sniff for whether `bytes` is human-readable text.
|
||||
///
|
||||
/// Recognises UTF-8 / UTF-16 BOMs outright, BOM-less UTF-16LE by its
|
||||
/// characteristic odd-position NUL bytes, and otherwise requires a byte sample
|
||||
/// to be almost entirely text bytes with no embedded NUL (NUL is the strongest
|
||||
/// binary signal). Decoding is left to [`decode_text`].
|
||||
pub fn is_probably_text(bytes: &[u8]) -> bool {
|
||||
if bytes.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// Byte-order marks are unambiguous.
|
||||
if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) // UTF-8
|
||||
|| bytes.starts_with(&[0xFF, 0xFE]) // UTF-16LE
|
||||
|| bytes.starts_with(&[0xFE, 0xFF])
|
||||
// UTF-16BE
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cap the scan so huge files stay cheap.
|
||||
let sample = &bytes[..bytes.len().min(4096)];
|
||||
|
||||
// BOM-less UTF-16LE: ASCII text encodes as `<char> 0x00 <char> 0x00 …`, so
|
||||
// roughly half the bytes (the odd positions) are NUL and the even bytes are
|
||||
// printable.
|
||||
if sample.len() >= 16 {
|
||||
let nul_odd = sample.iter().skip(1).step_by(2).filter(|&&b| b == 0).count();
|
||||
if nul_odd * 2 >= sample.len() / 2 {
|
||||
let printable_even = sample.iter().step_by(2).filter(|&&b| is_text_byte(b)).count();
|
||||
if printable_even * 2 >= sample.len() / 2 - 2 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Single-byte (ASCII / UTF-8): an embedded NUL means binary; otherwise
|
||||
// require the overwhelming majority to be text bytes.
|
||||
if sample.contains(&0) {
|
||||
return false;
|
||||
}
|
||||
let text = sample.iter().filter(|&&b| is_text_byte(b)).count();
|
||||
text * 100 >= sample.len() * 95
|
||||
}
|
||||
|
||||
/// Decode `bytes` to a `String`, returning `(text, encoding_label)`.
|
||||
///
|
||||
/// Honours UTF-8 / UTF-16LE / UTF-16BE BOMs and BOM-less UTF-16LE, falling back
|
||||
/// to lossy UTF-8. Never fails — undecodable sequences become U+FFFD. Pure std,
|
||||
/// so both the viewer and CLI can share it.
|
||||
pub fn decode_text(bytes: &[u8]) -> (String, &'static str) {
|
||||
if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
|
||||
return (String::from_utf8_lossy(rest).into_owned(), "UTF-8 (BOM)");
|
||||
}
|
||||
if let Some(rest) = bytes.strip_prefix(&[0xFF, 0xFE]) {
|
||||
return (decode_utf16(rest, false), "UTF-16LE (BOM)");
|
||||
}
|
||||
if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) {
|
||||
return (decode_utf16(rest, true), "UTF-16BE (BOM)");
|
||||
}
|
||||
// BOM-less UTF-16LE detection (same signal as `is_probably_text`).
|
||||
let sample = &bytes[..bytes.len().min(4096)];
|
||||
if sample.len() >= 16 {
|
||||
let nul_odd = sample.iter().skip(1).step_by(2).filter(|&&b| b == 0).count();
|
||||
if nul_odd * 2 >= sample.len() / 2 {
|
||||
return (decode_utf16(bytes, false), "UTF-16LE");
|
||||
}
|
||||
}
|
||||
(String::from_utf8_lossy(bytes).into_owned(), "UTF-8 / ASCII")
|
||||
}
|
||||
|
||||
fn decode_utf16(bytes: &[u8], big_endian: bool) -> String {
|
||||
let units: Vec<u16> = bytes
|
||||
.chunks_exact(2)
|
||||
.map(|c| {
|
||||
if big_endian {
|
||||
u16::from_be_bytes([c[0], c[1]])
|
||||
} else {
|
||||
u16::from_le_bytes([c[0], c[1]])
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
String::from_utf16_lossy(&units)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ascii_config_is_text() {
|
||||
let ini = b"[Video]\r\nWidth=1280\r\nHeight=720\r\n";
|
||||
assert!(is_probably_text(ini));
|
||||
assert_eq!(identify_format(ini), FileFormat::Text);
|
||||
let (text, enc) = decode_text(ini);
|
||||
assert!(text.contains("Width=1280"));
|
||||
assert_eq!(enc, "UTF-8 / ASCII");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magic_takes_precedence_over_text() {
|
||||
// An XPR2 header happens to start with printable bytes; the magic match
|
||||
// must win so it's never misfiled as text.
|
||||
let mut xpr = b"XPR2".to_vec();
|
||||
xpr.extend_from_slice(&[0u8; 32]);
|
||||
assert_eq!(identify_format(&xpr), FileFormat::Xpr2Texture);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_with_nul_is_not_text() {
|
||||
let bin = [0u8, 1, 2, 0xFF, 0x00, 0x89, 0x10, 0x00, 0x42];
|
||||
assert!(!is_probably_text(&bin));
|
||||
assert_eq!(identify_format(&bin), FileFormat::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf16le_bom_is_text_and_decodes() {
|
||||
let data = [0xFF, 0xFE, b'H', 0x00, b'i', 0x00];
|
||||
assert!(is_probably_text(&data));
|
||||
assert_eq!(identify_format(&data), FileFormat::Text);
|
||||
let (text, enc) = decode_text(&data);
|
||||
assert_eq!(text, "Hi");
|
||||
assert_eq!(enc, "UTF-16LE (BOM)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bomless_utf16le_detected() {
|
||||
// "Hello, world!!!!" as UTF-16LE, no BOM (>=16 bytes so the heuristic runs).
|
||||
let mut data = Vec::new();
|
||||
for &b in b"Hello, world!!!!" {
|
||||
data.push(b);
|
||||
data.push(0);
|
||||
}
|
||||
assert!(is_probably_text(&data));
|
||||
let (text, enc) = decode_text(&data);
|
||||
assert_eq!(text, "Hello, world!!!!");
|
||||
assert_eq!(enc, "UTF-16LE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_is_not_text() {
|
||||
assert!(!is_probably_text(&[]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,25 +14,48 @@ use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{debug, info};
|
||||
use xdvdfs::blockdev::OffsetWrapper;
|
||||
use xdvdfs::layout::VolumeDescriptor;
|
||||
|
||||
/// A handle to an open XISO image.
|
||||
pub struct XisoReader<F: Read + Seek> {
|
||||
///
|
||||
/// The inner file is wrapped in an [`OffsetWrapper`] which probes the four
|
||||
/// known XGD partition offsets (raw XISO, XGD1, XGD2, XGD3) at open time,
|
||||
/// so both single-layer raw dumps **and** full dual-layer disc images are
|
||||
/// supported transparently.
|
||||
pub struct XisoReader<F: Read + Seek + Send + Sync> {
|
||||
volume: VolumeDescriptor,
|
||||
file: F,
|
||||
/// Offset-aware block device — all sector reads are shifted by the
|
||||
/// detected partition start offset automatically.
|
||||
file: OffsetWrapper<F, std::io::Error>,
|
||||
}
|
||||
|
||||
impl<F: Read + Seek + Send + Sync + 'static> XisoReader<F> {
|
||||
pub async fn open(mut file: F) -> Result<Self> {
|
||||
let volume = xdvdfs::read::read_volume(&mut file)
|
||||
pub async fn open(file: F) -> Result<Self> {
|
||||
// OffsetWrapper::new probes four known partition offsets:
|
||||
// 0x00000000 — raw XISO (trimmed, sector 0 = XDVDFS start)
|
||||
// 0x183E0000 — XGD1 (original Xbox)
|
||||
// 0x0FD90000 — XGD2 (Xbox 360, most retail titles)
|
||||
// 0x02080000 — XGD3 (Xbox 360, later dual-layer titles)
|
||||
let mut wrapper = OffsetWrapper::new(file).await.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"No valid XDVDFS partition found in this disc image. \
|
||||
Tried raw XISO, XGD1, XGD2, and XGD3 offsets. \
|
||||
Is this a valid Xbox 360 (or original Xbox) disc image? \
|
||||
(internal error: {e:?})"
|
||||
)
|
||||
})?;
|
||||
|
||||
// Re-read the volume descriptor via the wrapper (now at the correct offset).
|
||||
let volume = xdvdfs::read::read_volume(&mut wrapper)
|
||||
.await
|
||||
.context("Failed to read XDVDFS volume descriptor. Is this a valid Xbox 360 ISO?")?;
|
||||
.context("Found XDVDFS partition but failed to parse volume descriptor")?;
|
||||
|
||||
info!(
|
||||
"Opened XISO: root directory table at sector {}",
|
||||
{ let s = volume.root_table.region.sector; s }
|
||||
);
|
||||
Ok(Self { volume, file })
|
||||
Ok(Self { volume, file: wrapper })
|
||||
}
|
||||
|
||||
/// List all files in the disc image, recursively (directories excluded).
|
||||
@@ -135,10 +158,13 @@ impl<F: Read + Seek + Send + Sync + 'static> XisoReader<F> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open an XISO from a file path (the common case).
|
||||
/// Open a disc image from a file path.
|
||||
///
|
||||
/// Accepts raw XISO dumps and full XGD1/XGD2/XGD3 disc images — the correct
|
||||
/// partition offset is detected automatically.
|
||||
pub async fn open_iso(path: &Path) -> Result<XisoReader<std::fs::File>> {
|
||||
let file = std::fs::File::open(path)
|
||||
.with_context(|| format!("Cannot open ISO: {}", path.display()))?;
|
||||
.with_context(|| format!("Cannot open disc image: {}", path.display()))?;
|
||||
XisoReader::open(file).await
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sylpheed_formats::texture::X360Texture;
|
||||
use sylpheed_formats::{IdxdObject, PakArchive};
|
||||
|
||||
/// Locate the extracted disc root, or `None` to skip.
|
||||
@@ -122,3 +123,139 @@ fn name_lookup_resolves_weapon_tbl() {
|
||||
let obj = IdxdObject::parse(&bytes).expect("weapon.tbl parses as IDXD");
|
||||
assert!(obj.count > 0);
|
||||
}
|
||||
|
||||
/// Real T8aD entries decode to sane RGBA surfaces, and a good fraction of the
|
||||
/// pack's T8aD entries are decodable (the RGBA-variant rule holds broadly).
|
||||
#[test]
|
||||
fn hangar_t8ad_textures_decode() {
|
||||
skip_without_disc!(root);
|
||||
let arc = PakArchive::open(root.join("dat/GP_HANGAR_ARSENAL.pak")).unwrap();
|
||||
let (mut seen, mut decoded) = (0usize, 0usize);
|
||||
for e in arc.entries() {
|
||||
if e.comp_size > 4_000_000 {
|
||||
continue;
|
||||
}
|
||||
let Ok(p) = arc.read(e) else { continue };
|
||||
if !sylpheed_formats::t8ad::is_t8ad(&p) {
|
||||
continue;
|
||||
}
|
||||
seen += 1;
|
||||
if let Some(img) = sylpheed_formats::t8ad::parse(&p) {
|
||||
decoded += 1;
|
||||
assert!((1..=4096).contains(&img.width) && (1..=4096).contains(&img.height));
|
||||
assert_eq!(img.rgba.len(), (img.width * img.height * 4) as usize);
|
||||
}
|
||||
}
|
||||
assert!(seen > 100, "expected many T8aD, saw {seen}");
|
||||
// The RGBA rule should cover the large majority (≈85% disc-wide).
|
||||
assert!(decoded * 100 >= seen * 70, "decoded {decoded}/{seen}");
|
||||
}
|
||||
|
||||
/// A real LSTA sprite list yields inline T8aD frames.
|
||||
#[test]
|
||||
fn lsta_sprite_list_decodes() {
|
||||
skip_without_disc!(root);
|
||||
let arc = PakArchive::open(root.join("dat/GP_HANGAR_ARSENAL.pak")).unwrap();
|
||||
let mut found = false;
|
||||
for e in arc.entries() {
|
||||
let Ok(p) = arc.read(e) else { continue };
|
||||
if !sylpheed_formats::lsta::is_lsta(&p) {
|
||||
continue;
|
||||
}
|
||||
if let Some(frames) = sylpheed_formats::lsta::parse(&p) {
|
||||
if !frames.is_empty() {
|
||||
found = true;
|
||||
assert!(frames.iter().all(|f| !f.rgba.is_empty()));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(found, "no decodable LSTA found");
|
||||
}
|
||||
|
||||
/// A real RATC bundle lists named children including a decodable T8aD.
|
||||
#[test]
|
||||
fn ratc_bundle_lists_children() {
|
||||
skip_without_disc!(root);
|
||||
let arc = PakArchive::open(root.join("dat/GP_HANGAR_ARSENAL.pak")).unwrap();
|
||||
let mut ok = false;
|
||||
for e in arc.entries() {
|
||||
if e.comp_size > 4_000_000 {
|
||||
continue;
|
||||
}
|
||||
let Ok(p) = arc.read(e) else { continue };
|
||||
if !sylpheed_formats::ratc::is_ratc(&p) {
|
||||
continue;
|
||||
}
|
||||
let Some(kids) = sylpheed_formats::ratc::parse(&p) else { continue };
|
||||
let has_named = kids.iter().any(|c| c.name.contains('.'));
|
||||
let has_t8ad = kids
|
||||
.iter()
|
||||
.any(|c| c.kind == "T8aD" && sylpheed_formats::t8ad::parse(&p[c.offset..c.offset + c.size]).is_some());
|
||||
if !kids.is_empty() && has_named && has_t8ad {
|
||||
ok = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(ok, "no RATC with named children + a decodable T8aD");
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
140
crates/sylpheed-formats/tests/texture_disc.rs
Normal file
140
crates/sylpheed-formats/tests/texture_disc.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
//! Integration test: run the XPR2 texture pipeline against REAL `.xpr` files
|
||||
//! read directly from the retail disc image, reproducing exactly what the
|
||||
//! viewer's texture-preview path does (`identify_format` → `X360Texture::
|
||||
//! from_xpr2`). Skipped unless `SYLPHEED_ISO` points at the disc (or the
|
||||
//! default dev path exists).
|
||||
//!
|
||||
//! Run: `cargo test -p sylpheed-formats --test texture_disc -- --ignored --nocapture`
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sylpheed_formats::texture::X360Texture;
|
||||
use sylpheed_formats::vfs::identify_format;
|
||||
|
||||
fn iso_path() -> Option<PathBuf> {
|
||||
if let Ok(p) = std::env::var("SYLPHEED_ISO") {
|
||||
let p = PathBuf::from(p);
|
||||
if p.is_file() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
let default = PathBuf::from(
|
||||
"/home/fabi/RE Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja).iso",
|
||||
);
|
||||
default.is_file().then_some(default)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires the retail ISO — set SYLPHEED_ISO"]
|
||||
async fn xpr_pipeline_over_disc_sample() {
|
||||
let Some(iso) = iso_path() else {
|
||||
eprintln!("SKIP: ISO not found (set SYLPHEED_ISO)");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut reader = sylpheed_formats::xiso::open_iso(&iso).await.unwrap();
|
||||
let all = reader.list_all_files().await.unwrap();
|
||||
let mut xprs: Vec<String> = all
|
||||
.into_iter()
|
||||
.filter(|f| f.to_lowercase().ends_with(".xpr"))
|
||||
.collect();
|
||||
xprs.sort();
|
||||
println!("found {} .xpr files", xprs.len());
|
||||
|
||||
// Sample across the set so we hit different texture sizes/formats.
|
||||
let sample: Vec<String> = xprs.iter().step_by((xprs.len() / 24).max(1)).cloned().collect();
|
||||
|
||||
let mut ok = 0usize;
|
||||
let mut fail = 0usize;
|
||||
let mut by_format: std::collections::BTreeMap<String, usize> = Default::default();
|
||||
let mut nonxpr = 0usize;
|
||||
|
||||
// Optionally dump the sampled .xpr bytes so the CLI can export them to PNG
|
||||
// for visual de-tiling validation: `DUMP_XPR_DIR=/tmp/xpr cargo test …`.
|
||||
let dump_dir = std::env::var("DUMP_XPR_DIR").ok();
|
||||
if let Some(d) = &dump_dir {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
for path in &sample {
|
||||
let bytes = reader.read_file(path).await.unwrap();
|
||||
if let Some(d) = &dump_dir {
|
||||
let base = path.rsplit('/').next().unwrap();
|
||||
std::fs::write(format!("{d}/{base}"), &bytes).unwrap();
|
||||
}
|
||||
let fmt = identify_format(&bytes);
|
||||
let magic: String = bytes
|
||||
.iter()
|
||||
.take(4)
|
||||
.map(|b| if b.is_ascii_graphic() { *b as char } else { '.' })
|
||||
.collect();
|
||||
|
||||
if fmt != sylpheed_formats::vfs::FileFormat::Xpr2Texture {
|
||||
nonxpr += 1;
|
||||
println!(" [{path}] NOT XPR2 (magic {magic:?}, {} bytes)", bytes.len());
|
||||
continue;
|
||||
}
|
||||
|
||||
match X360Texture::from_xpr2(&bytes) {
|
||||
Ok(t) => {
|
||||
ok += 1;
|
||||
*by_format.entry(format!("{:?}", t.format)).or_default() += 1;
|
||||
// Sanity: does the decoded data length match the descriptor
|
||||
// dimensions (what the GPU upload will require)?
|
||||
let bs = t.format.block_size() as u32;
|
||||
let bw = ((t.width + bs - 1) / bs).max(1) as usize;
|
||||
let bh = ((t.height + bs - 1) / bs).max(1) as usize;
|
||||
let need = bw * bh * t.format.bytes_per_block();
|
||||
let size_ok = if need == t.data.len() { "ok" } else { "MISMATCH" };
|
||||
println!(
|
||||
" [{path}] {:?} {}x{} mips={} data={} need={} {size_ok}",
|
||||
t.format, t.width, t.height, t.mip_levels, t.data.len(), need
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
fail += 1;
|
||||
println!(" [{path}] from_xpr2 FAILED: {e}");
|
||||
dump_xpr2_structure(&bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("\nSUMMARY: ok={ok} fail={fail} non-xpr2={nonxpr} formats={by_format:?}");
|
||||
}
|
||||
|
||||
/// Dump the XPR2 header + resource directory the way `from_xpr2` reads it,
|
||||
/// so we can see why a file's TX2D scan comes up empty.
|
||||
fn dump_xpr2_structure(bytes: &[u8]) {
|
||||
let be = |o: usize| u32::from_be_bytes(bytes[o..o + 4].try_into().unwrap());
|
||||
let magic: String = bytes[..4].iter().map(|b| *b as char).collect();
|
||||
println!(
|
||||
" hdr magic={magic:?} header_size=0x{:X} data_size=0x{:X} num_resources={}",
|
||||
be(0x04),
|
||||
be(0x08),
|
||||
be(0x0C)
|
||||
);
|
||||
let n = be(0x0C).min(16); // guard against garbage counts
|
||||
for i in 0..n {
|
||||
let base = 0x10 + i as usize * 16;
|
||||
if base + 16 > bytes.len() {
|
||||
break;
|
||||
}
|
||||
let tag: String = bytes[base..base + 4]
|
||||
.iter()
|
||||
.map(|b| if b.is_ascii_graphic() { *b as char } else { '.' })
|
||||
.collect();
|
||||
println!(
|
||||
" res[{i}] tag={tag:?} data_off=0x{:X} desc_size=0x{:X} name_off=0x{:X}",
|
||||
be(base + 4),
|
||||
be(base + 8),
|
||||
be(base + 12)
|
||||
);
|
||||
}
|
||||
// First 48 bytes hex for orientation.
|
||||
let hx: String = bytes
|
||||
.iter()
|
||||
.take(48)
|
||||
.map(|b| format!("{b:02X} "))
|
||||
.collect();
|
||||
println!(" hex[0..48] {hx}");
|
||||
}
|
||||
@@ -31,11 +31,14 @@ bevy = { workspace = true, features = [
|
||||
"bevy_winit",
|
||||
"multi_threaded",
|
||||
"png",
|
||||
# Required for TonyMcMapFace tonemapping (default Camera3d tonemapper)
|
||||
"tonemapping_luts",
|
||||
# Linux display backends
|
||||
"x11",
|
||||
"wayland",
|
||||
] }
|
||||
bevy_egui = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
@@ -46,3 +49,10 @@ dev = ["bevy/dynamic_linking"]
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
tracing-subscriber = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
# Audio for the WMV video player (pulls cpal → alsa on Linux).
|
||||
rodio = { workspace = true }
|
||||
# PNG decode for pak image entries (pure Rust; Bevy already pulls it in).
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
# Off-thread rasterization of embedded-font samples (avoids egui's global fonts).
|
||||
ab_glyph = "0.2"
|
||||
|
||||
@@ -12,10 +12,11 @@
|
||||
//! 4. Bevy uploads it to the GPU automatically
|
||||
|
||||
use bevy::asset::io::Reader;
|
||||
use bevy::asset::{AssetLoader, AsyncReadExt, LoadContext};
|
||||
use bevy::asset::{AssetLoader, LoadContext};
|
||||
use bevy::image::ImageSampler;
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::render_asset::RenderAssetUsages;
|
||||
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
|
||||
use bevy::render::render_resource::{Extent3d, TextureDescriptor, TextureDimension, TextureFormat, TextureUsages};
|
||||
use sylpheed_formats::texture::{X360Texture, X360TextureFormat};
|
||||
|
||||
// ── Plugin ────────────────────────────────────────────────────────────────────
|
||||
@@ -86,24 +87,52 @@ impl AssetLoader for Xpr2TextureLoader {
|
||||
|
||||
/// Convert a decoded `X360Texture` into a Bevy-compatible `Image`.
|
||||
///
|
||||
/// Maps Xbox 360 D3DFORMAT codes to `wgpu::TextureFormat` values.
|
||||
/// Bevy uses wgpu internally, so this mapping is direct.
|
||||
/// Constructs the `Image` struct directly rather than using `Image::new()`,
|
||||
/// because `Image::new()` calls `pixel_size()` which panics for BCn
|
||||
/// block-compressed formats.
|
||||
pub fn x360_texture_to_bevy_image(tex: X360Texture) -> Result<Image, Xpr2LoadError> {
|
||||
let bevy_format = x360_format_to_wgpu(&tex.format)?;
|
||||
let format = x360_format_to_wgpu(&tex.format)?;
|
||||
|
||||
Ok(Image::new(
|
||||
Extent3d {
|
||||
width: tex.width,
|
||||
height: tex.height,
|
||||
depth_or_array_layers: 1,
|
||||
// Uncompressed k_8_8_8_8: after `from_xpr2`'s k8in32 endian swap the bytes
|
||||
// are in [A,R,G,B] order (verified against the retail Acheron backdrop).
|
||||
// wgpu has no ARGB format, so reorder to [R,G,B,A] and upload as Rgba8
|
||||
// (see `x360_format_to_wgpu`). BCn data is already GPU-ready.
|
||||
let data = match tex.format {
|
||||
X360TextureFormat::A8R8G8B8 | X360TextureFormat::X8R8G8B8 => {
|
||||
let opaque = matches!(tex.format, X360TextureFormat::X8R8G8B8);
|
||||
let mut out = tex.data;
|
||||
for px in out.chunks_exact_mut(4) {
|
||||
let (a, r, g, b) = (px[0], px[1], px[2], px[3]);
|
||||
px[0] = r;
|
||||
px[1] = g;
|
||||
px[2] = b;
|
||||
px[3] = if opaque { 0xFF } else { a };
|
||||
}
|
||||
out
|
||||
}
|
||||
_ => tex.data,
|
||||
};
|
||||
|
||||
Ok(Image {
|
||||
data,
|
||||
texture_descriptor: TextureDescriptor {
|
||||
label: None,
|
||||
size: Extent3d {
|
||||
width: tex.width,
|
||||
height: tex.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: TextureDimension::D2,
|
||||
format,
|
||||
usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
},
|
||||
TextureDimension::D2,
|
||||
tex.data,
|
||||
bevy_format,
|
||||
// Make the texture available on both CPU and GPU
|
||||
// (RenderOnly would be more efficient once RE is complete)
|
||||
RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
|
||||
))
|
||||
sampler: ImageSampler::Default,
|
||||
texture_view_descriptor: None,
|
||||
asset_usage: RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
|
||||
})
|
||||
}
|
||||
|
||||
/// Map an `X360TextureFormat` to the corresponding `wgpu::TextureFormat`.
|
||||
@@ -119,15 +148,13 @@ fn x360_format_to_wgpu(format: &X360TextureFormat) -> Result<TextureFormat, Xpr2
|
||||
X360TextureFormat::Dxt3 => TextureFormat::Bc2RgbaUnormSrgb,
|
||||
// DXT5 / BC3
|
||||
X360TextureFormat::Dxt5 => TextureFormat::Bc3RgbaUnormSrgb,
|
||||
// DXN / BC5 — normal maps (RG, not sRGB)
|
||||
// DXN / BC5 — two-channel normal maps (RG, not sRGB)
|
||||
X360TextureFormat::Dxn => TextureFormat::Bc5RgUnorm,
|
||||
// Uncompressed ARGB — note Xbox 360 is BGRA order (big-endian)
|
||||
// We may need to swizzle R and B channels
|
||||
// DXT5A / BC4 — single-channel (gloss, specular, luminance maps)
|
||||
X360TextureFormat::Dxt5A => TextureFormat::Bc4RUnorm,
|
||||
// Uncompressed — reordered to [R,G,B,A] by `x360_texture_to_bevy_image`.
|
||||
X360TextureFormat::A8R8G8B8 | X360TextureFormat::X8R8G8B8 => {
|
||||
// Xbox 360 stores as ARGB big-endian (BGRA in memory)
|
||||
// wgpu uses Rgba8UnormSrgb — swizzle may be needed
|
||||
// TODO: verify channel order against actual game textures
|
||||
TextureFormat::Bgra8UnormSrgb
|
||||
TextureFormat::Rgba8UnormSrgb
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
1896
crates/sylpheed-viewer/src/iso_loader.rs
Normal file
1896
crates/sylpheed-viewer/src/iso_loader.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ use bevy_egui::EguiPlugin;
|
||||
|
||||
pub mod asset_loader;
|
||||
pub mod camera;
|
||||
pub mod iso_loader;
|
||||
pub mod ui;
|
||||
|
||||
// ── Application state ────────────────────────────────────────────────────────
|
||||
@@ -27,33 +28,16 @@ pub mod ui;
|
||||
/// Global viewer state, shared across all UI and rendering systems.
|
||||
#[derive(Resource)]
|
||||
pub struct ViewerState {
|
||||
/// Which type of asset is currently being browsed / previewed.
|
||||
pub current_mode: ViewMode,
|
||||
/// Whether to render meshes in wireframe mode (toggle with F2).
|
||||
pub wireframe: bool,
|
||||
/// Whether the plain-text viewer wraps long lines.
|
||||
pub text_wrap: bool,
|
||||
}
|
||||
|
||||
impl Default for ViewerState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
current_mode: ViewMode::Textures,
|
||||
wireframe: false,
|
||||
}
|
||||
Self { text_wrap: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// The active view / asset type being inspected.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ViewMode {
|
||||
/// XPR2 texture preview (Milestone 1).
|
||||
#[default]
|
||||
Textures,
|
||||
/// 3D mesh viewer (Milestone 2+).
|
||||
Mesh,
|
||||
/// Audio waveform / playback (Milestone 2+).
|
||||
Audio,
|
||||
}
|
||||
|
||||
// ── App builder ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Build and run the viewer application.
|
||||
@@ -75,6 +59,7 @@ pub fn run() {
|
||||
|
||||
app.add_plugins(EguiPlugin);
|
||||
app.add_plugins(asset_loader::SylpheedAssetPlugin);
|
||||
app.add_plugins(iso_loader::IsoLoaderPlugin);
|
||||
app.add_plugins(camera::OrbitCameraPlugin);
|
||||
app.add_plugins(ui::ViewerUiPlugin);
|
||||
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
//! egui-based debug UI for the asset viewer.
|
||||
//!
|
||||
//! Provides panels for:
|
||||
//! - File browser (list extracted game files)
|
||||
//! - File browser (list ISO / extracted game files, filter by name)
|
||||
//! - Texture inspector (preview + format info)
|
||||
//! - Mesh inspector (vertex/index counts, materials)
|
||||
//! - File info (size, detected format for non-texture files)
|
||||
//! - RE notes (track discoveries during reverse engineering)
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_egui::{egui, EguiContexts};
|
||||
use crate::{ViewerState, ViewMode};
|
||||
|
||||
use crate::iso_loader::{
|
||||
FileInfo, FileSelected, ImageRgba, IsoState, PakContent, PakView, RequestOpenDir,
|
||||
RequestOpenIso, SkyboxPreview, TextPreview, TexturePreview, VideoPreview, IsoLoaderSystemSet,
|
||||
};
|
||||
use crate::ViewerState;
|
||||
|
||||
pub struct ViewerUiPlugin;
|
||||
|
||||
impl Plugin for ViewerUiPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Update, draw_viewer_ui);
|
||||
app.insert_resource(FileBrowserState::default());
|
||||
// Run after the iso_loader chain so we see the frame's final state.
|
||||
app.add_systems(Update, draw_viewer_ui.after(IsoLoaderSystemSet));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,13 +31,112 @@ pub struct FileBrowserState {
|
||||
pub files: Vec<String>,
|
||||
pub selected: Option<usize>,
|
||||
pub filter: String,
|
||||
/// True from the moment a file is clicked until its content is applied —
|
||||
/// drives the "Loading…" indicator so the click registers immediately.
|
||||
pub loading: bool,
|
||||
}
|
||||
|
||||
// ── Directory tree ────────────────────────────────────────────────────────────
|
||||
|
||||
/// One directory node in the ephemeral file tree. Rebuilt from the flat path
|
||||
/// list each frame; leaves carry their original index into `browser.files` so
|
||||
/// selection stays stable regardless of tree shape or filtering.
|
||||
#[derive(Default)]
|
||||
struct TreeDir {
|
||||
/// Child directories, keyed by name (`BTreeMap` = stable alphabetical order).
|
||||
dirs: std::collections::BTreeMap<String, TreeDir>,
|
||||
/// Leaf files: (display name, original index into `browser.files`).
|
||||
files: Vec<(String, usize)>,
|
||||
}
|
||||
|
||||
/// True for an IPFB data segment (`foo.p00`, `foo.p01`, …). These are the pack's
|
||||
/// data body, not standalone files — the `.pak` index is what the user opens, so
|
||||
/// they're hidden from the tree (their content loads when the `.pak` is opened).
|
||||
fn is_pak_segment(leaf: &str) -> bool {
|
||||
matches!(leaf.rsplit_once('.'), Some((_, ext))
|
||||
if ext.len() == 3
|
||||
&& ext.as_bytes()[0] == b'p'
|
||||
&& ext.as_bytes()[1].is_ascii_digit()
|
||||
&& ext.as_bytes()[2].is_ascii_digit())
|
||||
}
|
||||
|
||||
/// Split a full path into (directory components, leaf name).
|
||||
fn split_for_tree(full: &str) -> (Vec<String>, String) {
|
||||
let mut comps: Vec<String> = full.split('/').map(str::to_string).collect();
|
||||
let leaf = comps.pop().unwrap_or_default();
|
||||
(comps, leaf)
|
||||
}
|
||||
|
||||
/// Build the directory tree, keeping only leaves whose full path contains
|
||||
/// `filter` (case-insensitive; empty filter keeps everything). `.pNN` data
|
||||
/// segments are skipped — only their `.pak` index is shown.
|
||||
fn build_tree(files: &[String], filter: &str) -> TreeDir {
|
||||
let filter = filter.to_lowercase();
|
||||
let mut root = TreeDir::default();
|
||||
for (idx, full) in files.iter().enumerate() {
|
||||
if !filter.is_empty() && !full.to_lowercase().contains(&filter) {
|
||||
continue;
|
||||
}
|
||||
let (dirs, leaf) = split_for_tree(full);
|
||||
if is_pak_segment(&leaf) {
|
||||
continue;
|
||||
}
|
||||
let mut node = &mut root;
|
||||
for comp in dirs {
|
||||
node = node.dirs.entry(comp).or_default();
|
||||
}
|
||||
node.files.push((leaf, idx));
|
||||
}
|
||||
root
|
||||
}
|
||||
|
||||
/// Render a directory node recursively: sub-directories (collapsible) first,
|
||||
/// then leaf files as selectable labels.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_dir(
|
||||
ui: &mut egui::Ui,
|
||||
node: &TreeDir,
|
||||
force_open: bool,
|
||||
selected: &mut Option<usize>,
|
||||
loading: &mut bool,
|
||||
events: &mut EventWriter<FileSelected>,
|
||||
files: &[String],
|
||||
) {
|
||||
for (name, child) in &node.dirs {
|
||||
egui::CollapsingHeader::new(format!("📁 {name}"))
|
||||
.id_salt(name)
|
||||
.default_open(force_open)
|
||||
.show(ui, |ui| {
|
||||
render_dir(ui, child, force_open, selected, loading, events, files);
|
||||
});
|
||||
}
|
||||
for (leaf, idx) in &node.files {
|
||||
let is_selected = *selected == Some(*idx);
|
||||
if ui
|
||||
.add(egui::SelectableLabel::new(is_selected, leaf.as_str()))
|
||||
.clicked()
|
||||
{
|
||||
*selected = Some(*idx);
|
||||
*loading = true; // show the spinner until the content is applied
|
||||
events.send(FileSelected(files[*idx].clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_viewer_ui(
|
||||
mut contexts: EguiContexts,
|
||||
mut viewer: ResMut<ViewerState>,
|
||||
mut browser: ResMut<FileBrowserState>,
|
||||
asset_server: Res<AssetServer>,
|
||||
iso_state: Res<IsoState>,
|
||||
preview: Res<TexturePreview>,
|
||||
text_preview: Res<TextPreview>,
|
||||
mut pak_view: ResMut<PakView>,
|
||||
skybox: Res<SkyboxPreview>,
|
||||
mut video: ResMut<VideoPreview>,
|
||||
file_info: Res<FileInfo>,
|
||||
mut open_iso_events: EventWriter<RequestOpenIso>,
|
||||
mut open_dir_events: EventWriter<RequestOpenDir>,
|
||||
mut file_selected_events: EventWriter<FileSelected>,
|
||||
) {
|
||||
let ctx = contexts.ctx_mut();
|
||||
|
||||
@@ -39,34 +144,30 @@ fn draw_viewer_ui(
|
||||
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
|
||||
egui::menu::bar(ui, |ui| {
|
||||
ui.menu_button("File", |ui| {
|
||||
if ui.button("Open extracted directory...").clicked() {
|
||||
// TODO: file dialog (rfd crate on native, not available on WASM)
|
||||
info!("TODO: open directory dialog");
|
||||
ui.close_menu();
|
||||
}
|
||||
ui.separator();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if ui.button("Quit").clicked() {
|
||||
std::process::exit(0);
|
||||
{
|
||||
if ui.button("Open ISO disc image…").clicked() {
|
||||
open_iso_events.send_default();
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button("Open extracted folder…").clicked() {
|
||||
open_dir_events.send_default();
|
||||
ui.close_menu();
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Quit").clicked() {
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.menu_button("View", |ui| {
|
||||
if ui.selectable_label(viewer.current_mode == ViewMode::Textures, "Textures").clicked() {
|
||||
viewer.current_mode = ViewMode::Textures;
|
||||
}
|
||||
if ui.selectable_label(viewer.current_mode == ViewMode::Mesh, "Meshes").clicked() {
|
||||
viewer.current_mode = ViewMode::Mesh;
|
||||
}
|
||||
if ui.selectable_label(viewer.current_mode == ViewMode::Audio, "Audio").clicked() {
|
||||
viewer.current_mode = ViewMode::Audio;
|
||||
}
|
||||
ui.separator();
|
||||
ui.checkbox(&mut viewer.wireframe, "Wireframe (F2)");
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
ui.colored_label(
|
||||
egui::Color32::GRAY,
|
||||
"File loading not available in browser",
|
||||
);
|
||||
});
|
||||
|
||||
ui.menu_button("Help", |ui| {
|
||||
if ui.button("Controls...").clicked() {
|
||||
if ui.button("Controls…").clicked() {
|
||||
// TODO: controls popup
|
||||
}
|
||||
if ui.button("About").clicked() {
|
||||
@@ -93,63 +194,174 @@ fn draw_viewer_ui(
|
||||
|
||||
// File list
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
if browser.files.is_empty() {
|
||||
if iso_state.loading {
|
||||
ui.spinner();
|
||||
ui.label("Loading…");
|
||||
} else if let Some(err) = &iso_state.error {
|
||||
ui.colored_label(
|
||||
egui::Color32::RED,
|
||||
format!("⚠ Error:\n{}", err),
|
||||
);
|
||||
} else if browser.files.is_empty() {
|
||||
ui.colored_label(
|
||||
egui::Color32::YELLOW,
|
||||
"⚠ No files loaded.\nExtract your ISO first:\n\
|
||||
xdvdfs unpack game.iso ./assets/",
|
||||
"No files loaded.\nUse File → Open ISO disc image\n\
|
||||
or File → Open extracted folder.",
|
||||
);
|
||||
} else {
|
||||
let filter = browser.filter.to_lowercase();
|
||||
// Collect owned strings so we can later mutate `browser.selected`
|
||||
let filtered: Vec<(usize, String)> = browser.files
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, f)| filter.is_empty() || f.to_lowercase().contains(&filter))
|
||||
.map(|(i, f)| (i, f.clone()))
|
||||
.collect();
|
||||
|
||||
for (idx, file) in &filtered {
|
||||
let is_selected = browser.selected == Some(*idx);
|
||||
let label = egui::SelectableLabel::new(is_selected, file.as_str());
|
||||
if ui.add(label).clicked() {
|
||||
browser.selected = Some(*idx);
|
||||
info!("Selected file: {}", file);
|
||||
// TODO: load the selected file into the viewer
|
||||
}
|
||||
}
|
||||
// Build a directory tree from the flat path list. Cheap to
|
||||
// rebuild every frame (a few hundred short paths); cloning
|
||||
// `files` first sidesteps borrowing `browser` immutably and
|
||||
// mutably (selected) at the same time.
|
||||
let files = browser.files.clone();
|
||||
let tree = build_tree(&files, &browser.filter);
|
||||
let force_open = !browser.filter.is_empty();
|
||||
let FileBrowserState {
|
||||
selected, loading, ..
|
||||
} = &mut *browser;
|
||||
render_dir(
|
||||
ui,
|
||||
&tree,
|
||||
force_open,
|
||||
selected,
|
||||
loading,
|
||||
&mut file_selected_events,
|
||||
&files,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Bottom panel: status bar ──────────────────────────────────────────
|
||||
egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
match viewer.current_mode {
|
||||
ViewMode::Textures => ui.label("Mode: Texture Viewer"),
|
||||
ViewMode::Mesh => ui.label("Mode: Mesh Viewer"),
|
||||
ViewMode::Audio => ui.label("Mode: Audio Viewer"),
|
||||
};
|
||||
ui.separator();
|
||||
ui.label(format!("{} files loaded", browser.files.len()));
|
||||
ui.separator();
|
||||
ui.label("LMB drag: orbit | RMB drag: pan | Scroll: zoom | R: reset");
|
||||
// `horizontal_wrapped` so the bar wraps instead of overflowing the
|
||||
// window on narrow widths; the controls hint is right-aligned and only
|
||||
// the source's final path component is shown (full path on hover).
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if let Some(label) = &iso_state.source_label {
|
||||
let short = label.rsplit(['/', '\\']).next().unwrap_or(label);
|
||||
ui.label(short).on_hover_text(label);
|
||||
ui.separator();
|
||||
}
|
||||
ui.label(format!("{} files", browser.files.len()));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label("LMB orbit · RMB pan · scroll zoom · R reset");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Central area: RE notes (shown when no asset is selected) ──────────
|
||||
if browser.selected.is_none() {
|
||||
// ── Central area ──────────────────────────────────────────────────────
|
||||
if browser.selected.is_some() {
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
if browser.loading {
|
||||
// A file was just clicked — show immediate feedback while the
|
||||
// background read + decode runs, instead of the previous item.
|
||||
let name = browser
|
||||
.selected
|
||||
.and_then(|i| browser.files.get(i))
|
||||
.map(|p| p.rsplit('/').next().unwrap_or(p))
|
||||
.unwrap_or("");
|
||||
ui.centered_and_justified(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label(format!("Loading {name}…"));
|
||||
});
|
||||
});
|
||||
} else if video.active {
|
||||
draw_video_player(ui, &mut video);
|
||||
} else if !skybox.faces.is_empty() {
|
||||
draw_skybox_grid(ui, &skybox);
|
||||
} else if pak_view.loaded {
|
||||
draw_pak_browser(ui, &mut pak_view);
|
||||
} else if let Some(text) = &text_preview.content {
|
||||
// ── Plain-text viewer ──
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading(&file_info.name);
|
||||
ui.separator();
|
||||
ui.label(&text_preview.encoding);
|
||||
ui.separator();
|
||||
ui.checkbox(&mut viewer.text_wrap, "Wrap");
|
||||
if text_preview.truncated {
|
||||
ui.separator();
|
||||
ui.colored_label(egui::Color32::YELLOW, "(truncated)");
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
egui::ScrollArea::both().show(ui, |ui| {
|
||||
// Read-only: `&str` is a non-editable TextBuffer, and
|
||||
// `.interactive(false)` keeps it selectable/copyable.
|
||||
let mut buf = text.as_str();
|
||||
let mut edit = egui::TextEdit::multiline(&mut buf)
|
||||
.font(egui::TextStyle::Monospace)
|
||||
.code_editor()
|
||||
.desired_width(f32::INFINITY)
|
||||
.interactive(false);
|
||||
if !viewer.text_wrap {
|
||||
// Don't wrap: let ScrollArea scroll horizontally.
|
||||
edit = edit.clip_text(false);
|
||||
}
|
||||
ui.add(edit);
|
||||
});
|
||||
} else if let Some(egui_id) = preview.egui_id {
|
||||
// Texture preview
|
||||
ui.heading(&file_info.name);
|
||||
ui.label(&preview.format_info);
|
||||
if !preview.format_info.is_empty() && preview.format_info.contains("failed") {
|
||||
ui.colored_label(egui::Color32::YELLOW, &preview.format_info);
|
||||
}
|
||||
ui.separator();
|
||||
|
||||
// Scale to fit, preserving aspect ratio, never upscale
|
||||
let avail = ui.available_size();
|
||||
let scale = if preview.width == 0 || preview.height == 0 {
|
||||
1.0_f32
|
||||
} else {
|
||||
(avail.x / preview.width as f32)
|
||||
.min(avail.y / preview.height as f32)
|
||||
.min(1.0)
|
||||
};
|
||||
let display_w = preview.width as f32 * scale;
|
||||
let display_h = preview.height as f32 * scale;
|
||||
|
||||
ui.add(egui::Image::new(egui::load::SizedTexture::new(
|
||||
egui_id,
|
||||
[display_w, display_h],
|
||||
)));
|
||||
} else {
|
||||
// Non-texture file info
|
||||
ui.heading(&file_info.name);
|
||||
if !preview.format_info.is_empty() {
|
||||
ui.colored_label(egui::Color32::YELLOW, &preview.format_info);
|
||||
}
|
||||
if file_info.size_bytes > 0 {
|
||||
ui.separator();
|
||||
ui.label(format!("Size: {} bytes", file_info.size_bytes));
|
||||
if let Some(fmt) = file_info.detected_format {
|
||||
ui.label(format!("Detected format: {:?}", fmt));
|
||||
}
|
||||
} else if iso_state.loading {
|
||||
ui.spinner();
|
||||
ui.label("Loading file…");
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// No file selected — show welcome / RE notes
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
ui.heading("Project Sylpheed: Arc of Deception");
|
||||
ui.label("Asset Viewer — Milestone 1");
|
||||
ui.separator();
|
||||
|
||||
ui.collapsing("Getting Started", |ui| {
|
||||
ui.label("1. Dump your Project Sylpheed disc to an ISO");
|
||||
ui.label("Option A — open an ISO directly:");
|
||||
ui.code(" File → Open ISO disc image…");
|
||||
ui.separator();
|
||||
ui.label("Option B — extract first, then open folder:");
|
||||
ui.code(" xdvdfs unpack sylpheed.iso ./assets/");
|
||||
ui.label("2. Restart the viewer — files will appear in the left panel");
|
||||
ui.label("3. Click a .xpr file to preview the texture");
|
||||
ui.label("4. Use File → Open to select a different directory");
|
||||
ui.code(" File → Open extracted folder…");
|
||||
ui.separator();
|
||||
ui.label("Click a .xpr file in the left panel to preview the texture.");
|
||||
});
|
||||
|
||||
ui.collapsing("Reverse Engineering Notes", |ui| {
|
||||
@@ -185,3 +397,549 @@ fn draw_viewer_ui(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data pack (IPFB / IDXD) browser ───────────────────────────────────────────
|
||||
|
||||
/// Master-detail view for an open `.pak`: entry list on the left, the selected
|
||||
/// entry's content on the right (IDXD table, subtitle cues, font sample, image,
|
||||
/// or text — whatever `classify_content` decoded off-thread).
|
||||
fn draw_pak_browser(ui: &mut egui::Ui, pak: &mut PakView) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading(&pak.name);
|
||||
ui.separator();
|
||||
ui.label(format!("{} entries", pak.rows.len()));
|
||||
});
|
||||
if let Some(err) = &pak.error {
|
||||
ui.separator();
|
||||
ui.colored_label(egui::Color32::RED, format!("⚠ {err}"));
|
||||
return;
|
||||
}
|
||||
ui.separator();
|
||||
|
||||
// Destructure so the columns can read `rows` while mutating the selection +
|
||||
// the egui-side caches — without cloning the (now heavy) rows each frame.
|
||||
let PakView {
|
||||
rows,
|
||||
selected,
|
||||
img_tex,
|
||||
..
|
||||
} = pak;
|
||||
let mut new_selection = *selected;
|
||||
|
||||
ui.columns(2, |cols| {
|
||||
// Left — entry list.
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("pak_list")
|
||||
.show(&mut cols[0], |ui| {
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let text = egui::RichText::new(format!(
|
||||
"{:08x} {:<7} {}",
|
||||
row.hash,
|
||||
row.format,
|
||||
row_kind(row)
|
||||
))
|
||||
.monospace();
|
||||
if ui
|
||||
.add(egui::SelectableLabel::new(*selected == Some(i), text))
|
||||
.clicked()
|
||||
{
|
||||
new_selection = Some(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Right — detail of the selected entry, dispatched on decoded content.
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("pak_detail")
|
||||
.show(&mut cols[1], |ui| match selected.and_then(|i| rows.get(i)) {
|
||||
None => {
|
||||
ui.label("Select an entry to inspect.");
|
||||
}
|
||||
Some(row) => match &row.content {
|
||||
PakContent::Subtitle(sub) => draw_subtitle_detail(ui, row, sub),
|
||||
PakContent::Font { info, sample } => {
|
||||
draw_font_detail(ui, row, info, sample.as_ref(), img_tex)
|
||||
}
|
||||
PakContent::Png(img) => draw_png_detail(ui, row, img, img_tex),
|
||||
PakContent::T8ad(img) => draw_t8ad_detail(ui, row, img, img_tex),
|
||||
PakContent::Lsta(frames) => draw_lsta_detail(ui, row, frames, img_tex),
|
||||
PakContent::Ratc(children) => draw_ratc_detail(ui, row, children, img_tex),
|
||||
PakContent::Text { text, encoding } => {
|
||||
draw_text_detail(ui, row, text, encoding)
|
||||
}
|
||||
PakContent::None => draw_idxd_detail(ui, row),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
*selected = new_selection;
|
||||
}
|
||||
|
||||
/// Short kind hint for the entry list (subtitle/font/image/text), falling back
|
||||
/// to the IDXD identity.
|
||||
fn row_kind(row: &crate::iso_loader::PakRow) -> String {
|
||||
match &row.content {
|
||||
PakContent::Subtitle(s) => format!("subtitle · {} cues", s.cues.len()),
|
||||
PakContent::Font { info, .. } => {
|
||||
if info.family.is_empty() {
|
||||
"font".into()
|
||||
} else {
|
||||
info.family.clone()
|
||||
}
|
||||
}
|
||||
PakContent::Png(img) => format!("PNG {}×{}", img.width, img.height),
|
||||
PakContent::T8ad(img) => format!("T8aD {}×{}", img.width, img.height),
|
||||
PakContent::Lsta(f) => format!("LSTA · {} sprite(s)", f.len()),
|
||||
PakContent::Ratc(c) => format!("RATC · {} item(s)", c.len()),
|
||||
PakContent::Text { .. } => "text".into(),
|
||||
PakContent::None => row.identity.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `mm:ss.SS` timestamp for subtitle cues.
|
||||
fn fmt_ts(secs: f32) -> String {
|
||||
let s = secs.max(0.0);
|
||||
format!("{:02}:{:05.2}", (s / 60.0) as u32, s % 60.0)
|
||||
}
|
||||
|
||||
/// IDXD object detail (or bare format/size for other un-presentable entries).
|
||||
fn draw_idxd_detail(ui: &mut egui::Ui, row: &crate::iso_loader::PakRow) {
|
||||
match &row.detail {
|
||||
Some(d) => {
|
||||
let title = if row.identity.is_empty() {
|
||||
row.format.as_str()
|
||||
} else {
|
||||
row.identity.as_str()
|
||||
};
|
||||
ui.heading(title);
|
||||
ui.label(format!("schema 0x{:08x} count {}", d.schema_hash, d.count));
|
||||
ui.label(format!("{} bytes hash {:08x}", row.size, row.hash));
|
||||
ui.separator();
|
||||
if d.fields.is_empty() {
|
||||
ui.label("(no explicit-value fields)");
|
||||
} else {
|
||||
egui::Grid::new("pak_fields")
|
||||
.striped(true)
|
||||
.num_columns(2)
|
||||
.show(ui, |ui| {
|
||||
for (k, v) in &d.fields {
|
||||
ui.label(k);
|
||||
ui.monospace(v);
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
None => {
|
||||
ui.heading(&row.format);
|
||||
ui.label(format!("{} bytes hash {:08x}", row.size, row.hash));
|
||||
ui.separator();
|
||||
ui.label("No decoded preview for this format yet.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// IXUD subtitle track: a timecode + text cue table.
|
||||
fn draw_subtitle_detail(
|
||||
ui: &mut egui::Ui,
|
||||
row: &crate::iso_loader::PakRow,
|
||||
sub: &sylpheed_formats::ixud::Subtitle,
|
||||
) {
|
||||
ui.heading("Subtitle track");
|
||||
let note = if sub.is_reference_only() {
|
||||
" · reference-only (MSG_* keys)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
ui.label(format!(
|
||||
"IXUD · {} cues · hash {:08x}{note}",
|
||||
sub.cues.len(),
|
||||
row.hash
|
||||
));
|
||||
ui.separator();
|
||||
if sub.cues.is_empty() {
|
||||
ui.label("(empty — no cues)");
|
||||
return;
|
||||
}
|
||||
egui::Grid::new("subtitle_cues")
|
||||
.striped(true)
|
||||
.num_columns(2)
|
||||
.show(ui, |ui| {
|
||||
ui.strong("Time");
|
||||
ui.strong("Text");
|
||||
ui.end_row();
|
||||
for c in &sub.cues {
|
||||
let t = match c.end {
|
||||
Some(e) => format!("{}–{}", fmt_ts(c.start), fmt_ts(e)),
|
||||
None => fmt_ts(c.start),
|
||||
};
|
||||
ui.monospace(t);
|
||||
ui.label(&c.text);
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Per-entry egui texture cache: `(entry hash, one texture per shown image)`.
|
||||
type ImgCache = Option<(u32, Vec<egui::TextureHandle>)>;
|
||||
|
||||
/// Ensure `cache` holds egui textures for `imgs` under `hash`, rebuilding when
|
||||
/// the selected entry changes. All image content comes from off-thread decodes;
|
||||
/// this never touches egui's global font system.
|
||||
fn ensure_textures(ui: &egui::Ui, hash: u32, imgs: &[&ImageRgba], cache: &mut ImgCache) {
|
||||
if cache.as_ref().map(|(h, _)| *h) != Some(hash) {
|
||||
let texs = imgs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, img)| {
|
||||
let color = egui::ColorImage::from_rgba_unmultiplied(
|
||||
[img.width as usize, img.height as usize],
|
||||
&img.rgba,
|
||||
);
|
||||
ui.ctx().load_texture(
|
||||
format!("pak_img_{hash:08x}_{i}"),
|
||||
color,
|
||||
egui::TextureOptions::LINEAR,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
*cache = Some((hash, texs));
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache + draw a single `ImageRgba`, scaled to fit the available width (capped
|
||||
/// by `max_scale`). Shared by the PNG / T8aD / font-sample views.
|
||||
fn show_cached_image(
|
||||
ui: &mut egui::Ui,
|
||||
hash: u32,
|
||||
img: &ImageRgba,
|
||||
cache: &mut ImgCache,
|
||||
max_scale: f32,
|
||||
) {
|
||||
ensure_textures(ui, hash, &[img], cache);
|
||||
if let Some(tex) = cache.as_ref().and_then(|(_, t)| t.first()) {
|
||||
let scale = (ui.available_width() / img.width.max(1) as f32).min(max_scale);
|
||||
let (dw, dh) = (img.width as f32 * scale, img.height as f32 * scale);
|
||||
ui.add(egui::Image::new(egui::load::SizedTexture::new(
|
||||
tex.id(),
|
||||
[dw, dh],
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Embedded font: metadata + a pre-rasterized sample line (rendered off-thread
|
||||
/// with `ab_glyph`, shown as an image — no global egui font install).
|
||||
fn draw_font_detail(
|
||||
ui: &mut egui::Ui,
|
||||
row: &crate::iso_loader::PakRow,
|
||||
info: &sylpheed_formats::FontInfo,
|
||||
sample: Option<&ImageRgba>,
|
||||
img_tex: &mut ImgCache,
|
||||
) {
|
||||
ui.heading(if info.family.is_empty() {
|
||||
"Font"
|
||||
} else {
|
||||
&info.family
|
||||
});
|
||||
ui.label(format!(
|
||||
"{} · {} face(s) · {} glyphs · hash {:08x}",
|
||||
info.kind, info.faces, info.glyphs, row.hash
|
||||
));
|
||||
ui.separator();
|
||||
match sample {
|
||||
Some(img) => {
|
||||
ui.label("Sample:");
|
||||
show_cached_image(ui, row.hash, img, img_tex, 1.0);
|
||||
}
|
||||
None => {
|
||||
ui.label("(no sample — font collection or unsupported outlines)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decoded PNG image, shown via a cached egui texture.
|
||||
fn draw_png_detail(
|
||||
ui: &mut egui::Ui,
|
||||
row: &crate::iso_loader::PakRow,
|
||||
img: &ImageRgba,
|
||||
img_tex: &mut ImgCache,
|
||||
) {
|
||||
ui.heading(format!("PNG image {}×{}", img.width, img.height));
|
||||
ui.label(format!("hash {:08x}", row.hash));
|
||||
ui.separator();
|
||||
show_cached_image(ui, row.hash, img, img_tex, 2.0);
|
||||
}
|
||||
|
||||
/// A decoded T8aD 2D texture. Colours are not yet verified against the game.
|
||||
fn draw_t8ad_detail(
|
||||
ui: &mut egui::Ui,
|
||||
row: &crate::iso_loader::PakRow,
|
||||
img: &ImageRgba,
|
||||
img_tex: &mut ImgCache,
|
||||
) {
|
||||
ui.heading(format!("T8aD 2D texture {}×{}", img.width, img.height));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(format!("hash {:08x}", row.hash));
|
||||
ui.separator();
|
||||
ui.weak("colours unverified");
|
||||
});
|
||||
ui.separator();
|
||||
show_cached_image(ui, row.hash, img, img_tex, 2.0);
|
||||
}
|
||||
|
||||
/// An LSTA sprite list — the inline T8aD frames in a grid.
|
||||
fn draw_lsta_detail(
|
||||
ui: &mut egui::Ui,
|
||||
row: &crate::iso_loader::PakRow,
|
||||
frames: &[ImageRgba],
|
||||
img_tex: &mut ImgCache,
|
||||
) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("LSTA sprite list");
|
||||
ui.separator();
|
||||
ui.label(format!("{} sprite(s)", frames.len()));
|
||||
ui.separator();
|
||||
ui.weak("colours unverified");
|
||||
});
|
||||
ui.separator();
|
||||
let refs: Vec<&ImageRgba> = frames.iter().collect();
|
||||
ensure_textures(ui, row.hash, &refs, img_tex);
|
||||
const CELL: f32 = 150.0;
|
||||
egui::Grid::new("lsta_sprites")
|
||||
.num_columns(3)
|
||||
.spacing([10.0, 10.0])
|
||||
.show(ui, |ui| {
|
||||
for (i, frame) in frames.iter().enumerate() {
|
||||
if let Some(tex) = img_tex.as_ref().and_then(|(_, t)| t.get(i)) {
|
||||
let aspect = frame.height as f32 / frame.width.max(1) as f32;
|
||||
ui.add(egui::Image::new(egui::load::SizedTexture::new(
|
||||
tex.id(),
|
||||
[CELL, CELL * aspect],
|
||||
)));
|
||||
}
|
||||
if (i + 1) % 3 == 0 {
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A RATC bundle — a list of its named children, with thumbnails for the
|
||||
/// decoded T8aD ones.
|
||||
fn draw_ratc_detail(
|
||||
ui: &mut egui::Ui,
|
||||
row: &crate::iso_loader::PakRow,
|
||||
children: &[crate::iso_loader::RatcEntry],
|
||||
img_tex: &mut ImgCache,
|
||||
) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("RATC bundle");
|
||||
ui.separator();
|
||||
ui.label(format!("{} item(s)", children.len()));
|
||||
ui.separator();
|
||||
ui.weak("colours unverified");
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
let refs: Vec<&ImageRgba> = children.iter().filter_map(|c| c.image.as_ref()).collect();
|
||||
ensure_textures(ui, row.hash, &refs, img_tex);
|
||||
|
||||
let mut img_i = 0;
|
||||
for c in children {
|
||||
ui.horizontal(|ui| {
|
||||
if c.image.is_some() {
|
||||
if let Some(tex) = img_tex.as_ref().and_then(|(_, t)| t.get(img_i)) {
|
||||
let sz = tex.size_vec2();
|
||||
let scale = (48.0 / sz.y.max(1.0)).min(1.0);
|
||||
ui.add(egui::Image::new(egui::load::SizedTexture::new(
|
||||
tex.id(),
|
||||
[sz.x * scale, sz.y * scale],
|
||||
)));
|
||||
}
|
||||
img_i += 1;
|
||||
}
|
||||
ui.vertical(|ui| {
|
||||
let name = if c.name.is_empty() { "(unnamed)" } else { &c.name };
|
||||
ui.strong(name);
|
||||
ui.weak(format!("{} · {} bytes", c.kind, c.size));
|
||||
});
|
||||
});
|
||||
ui.separator();
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain-text / XML entry in a read-only monospace box.
|
||||
fn draw_text_detail(
|
||||
ui: &mut egui::Ui,
|
||||
row: &crate::iso_loader::PakRow,
|
||||
text: &str,
|
||||
encoding: &str,
|
||||
) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading(&row.format);
|
||||
ui.separator();
|
||||
ui.label(encoding);
|
||||
ui.separator();
|
||||
ui.label(format!("{} bytes", row.size));
|
||||
});
|
||||
ui.separator();
|
||||
let mut buf = text;
|
||||
ui.add(
|
||||
egui::TextEdit::multiline(&mut buf)
|
||||
.font(egui::TextStyle::Monospace)
|
||||
.code_editor()
|
||||
.desired_width(f32::INFINITY)
|
||||
.interactive(false),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Video player (WMV cutscenes) ──────────────────────────────────────────────
|
||||
|
||||
/// Format seconds as `mm:ss`.
|
||||
fn fmt_time(secs: f32) -> String {
|
||||
let s = secs.max(0.0) as u32;
|
||||
format!("{:02}:{:02}", s / 60, s % 60)
|
||||
}
|
||||
|
||||
/// Central-panel video player: the current frame plus a transport bar
|
||||
/// (play/pause, seekable timeline, volume). Also supports click-to-toggle and
|
||||
/// keyboard shortcuts (space = play/pause, ←/→ = skip 10 s). Playback state
|
||||
/// lives in `VideoPreview`; the decode/audio engine reacts to it in
|
||||
/// `advance_video_playback`.
|
||||
fn draw_video_player(ui: &mut egui::Ui, video: &mut VideoPreview) {
|
||||
// ── Keyboard shortcuts (consumed so focused widgets don't also act). ──
|
||||
let (mut toggle_play, mut skip) = (false, 0.0_f32);
|
||||
ui.input_mut(|i| {
|
||||
if i.consume_key(egui::Modifiers::NONE, egui::Key::Space) {
|
||||
toggle_play = true;
|
||||
}
|
||||
if i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowRight) {
|
||||
skip += 10.0;
|
||||
}
|
||||
if i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowLeft) {
|
||||
skip -= 10.0;
|
||||
}
|
||||
});
|
||||
if toggle_play {
|
||||
video.playing = !video.playing;
|
||||
}
|
||||
if skip != 0.0 {
|
||||
let t = (video.position + skip).clamp(0.0, video.duration);
|
||||
video.position = t;
|
||||
video.scrub_request = Some(t); // instant preview via the grabber…
|
||||
video.seek_request = Some(t); // …then the stream restarts here
|
||||
}
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading(&video.name);
|
||||
ui.separator();
|
||||
ui.label(format!("{}×{} · wmv3 / wmapro", video.width, video.height));
|
||||
if !video.has_audio {
|
||||
ui.separator();
|
||||
ui.colored_label(egui::Color32::YELLOW, "no audio");
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
// Reserve room for the transport row so the frame never pushes it off-screen.
|
||||
const CONTROLS_H: f32 = 34.0;
|
||||
let avail = ui.available_size();
|
||||
let frame_h = (avail.y - CONTROLS_H).max(0.0);
|
||||
let (w, h) = (video.width.max(1) as f32, video.height.max(1) as f32);
|
||||
let scale = (avail.x / w).min(frame_h / h);
|
||||
let (disp_w, disp_h) = (w * scale, h * scale);
|
||||
|
||||
if let Some(egui_id) = video.egui_id {
|
||||
ui.allocate_ui(egui::vec2(avail.x, frame_h), |ui| {
|
||||
ui.vertical_centered(|ui| {
|
||||
// Click the frame to toggle play/pause.
|
||||
let resp = ui.add(
|
||||
egui::Image::new(egui::load::SizedTexture::new(egui_id, [disp_w, disp_h]))
|
||||
.sense(egui::Sense::click()),
|
||||
);
|
||||
if resp.clicked() {
|
||||
video.playing = !video.playing;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Transport bar ──
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button(if video.playing { "⏸" } else { "▶" }).clicked() {
|
||||
video.playing = !video.playing;
|
||||
}
|
||||
ui.label(fmt_time(video.position));
|
||||
|
||||
// Timeline fills the space left after reserving room for the total-time
|
||||
// label and the volume control on the right. Dragging scrubs live (the
|
||||
// grabber shows the frame under the knob); release commits the seek.
|
||||
let reserved = 170.0;
|
||||
let tl_width = (ui.available_width() - reserved).max(80.0);
|
||||
ui.spacing_mut().slider_width = tl_width;
|
||||
let mut pos = video.position;
|
||||
let resp =
|
||||
ui.add(egui::Slider::new(&mut pos, 0.0..=video.duration.max(0.1)).show_value(false));
|
||||
if resp.changed() {
|
||||
video.position = pos; // knob + label follow immediately
|
||||
video.scrub_request = Some(pos);
|
||||
if resp.dragged() {
|
||||
video.scrubbing = true;
|
||||
} else {
|
||||
// A click on the track (no drag) → commit right away.
|
||||
video.seek_request = Some(pos);
|
||||
video.scrubbing = false;
|
||||
}
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
video.seek_request = Some(pos);
|
||||
video.scrubbing = false;
|
||||
}
|
||||
ui.label(fmt_time(video.duration));
|
||||
|
||||
ui.separator();
|
||||
ui.label("🔊");
|
||||
ui.spacing_mut().slider_width = 90.0;
|
||||
ui.add_enabled(
|
||||
video.has_audio,
|
||||
egui::Slider::new(&mut video.volume, 0.0..=1.0).show_value(false),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ── World cubemap (skybox) viewer ─────────────────────────────────────────────
|
||||
|
||||
/// Show a world cubemap's 6 faces in a labelled grid (D3D9 order). A true
|
||||
/// interactive skybox is a follow-up — the face data + order are validated, but
|
||||
/// the wgpu cube-sampling handedness needs visual confirmation first.
|
||||
fn draw_skybox_grid(ui: &mut egui::Ui, skybox: &SkyboxPreview) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("World cubemap");
|
||||
ui.separator();
|
||||
ui.label(&skybox.info);
|
||||
});
|
||||
ui.label("6 cube faces, D3D9 order (+X −X +Y −Y +Z −Z).");
|
||||
ui.separator();
|
||||
|
||||
egui::ScrollArea::both().show(ui, |ui| {
|
||||
const CELL: f32 = 240.0;
|
||||
egui::Grid::new("cube_faces")
|
||||
.num_columns(3)
|
||||
.spacing([10.0, 10.0])
|
||||
.show(ui, |ui| {
|
||||
for (i, face) in skybox.faces.iter().enumerate() {
|
||||
ui.vertical(|ui| {
|
||||
ui.strong(face.label);
|
||||
let aspect = face.height as f32 / face.width.max(1) as f32;
|
||||
ui.add(egui::Image::new(egui::load::SizedTexture::new(
|
||||
face.egui_id,
|
||||
[CELL, CELL * aspect],
|
||||
)));
|
||||
});
|
||||
if (i + 1) % 3 == 0 {
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
29
docs/re/INDEX.md
Normal file
29
docs/re/INDEX.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# RE knowledge index
|
||||
|
||||
Confidence: ✅ `CONFIRMED` · 🟡 `PROBABLE` · ❔ `HYPOTHESIS`. See [README](README.md).
|
||||
|
||||
Formats we've already reversed are, for now, **documented by their parser + disc round-trip
|
||||
tests** (the executable spec) rather than a prose file — the "Spec" column points there.
|
||||
Promote to a prose `structures/…md` file when a format needs behavioural notes beyond layout.
|
||||
|
||||
## Data structures / formats
|
||||
|
||||
| Format | Conf. | Spec (parser + tests) | Notes |
|
||||
|--------|-------|-----------------------|-------|
|
||||
| IPFB `.pak` archive | ✅ | `sylpheed-formats/src/pak.rs` + `tests/pak_idxd_disc.rs` | header + 12-byte TOC, Z1/zlib payloads |
|
||||
| name-hash (TOC keys) | ✅ | `sylpheed-formats/src/hash.rs` | Barrett-reduction hash; recovers original paths |
|
||||
| IDXD object/table | ✅ | `sylpheed-formats/src/idxd.rs` | self-describing; ship/weapon stats verified vs known values |
|
||||
| XPR2 texture + cubemap | 🟡 | `sylpheed-formats/src/texture.rs` | de-tile + A8R8G8B8; **colours unverified** (dynamic item) |
|
||||
| T8aD 2D texture | 🟡 | `sylpheed-formats/src/t8ad.rs` | ~85% decode; **colours unverified**; ~15% variants deferred |
|
||||
| RATC bundle | 🟡 | `sylpheed-formats/src/ratc.rs` | child listing confirmed; one level deep |
|
||||
| LSTA sprite list | 🟡 | `sylpheed-formats/src/lsta.rs` | inline T8aD frames |
|
||||
| IXUD subtitle | 🟡 | `sylpheed-formats/src/ixud.rs` | timed cues; **movie↔track link unknown** (dynamic item) |
|
||||
| Fonts (ttf/otf/ttc) | ✅ | `sylpheed-formats/src/font.rs` | standard OpenType, parsed via ttf-parser |
|
||||
|
||||
## Functions / code paths
|
||||
|
||||
_None documented yet — populated during the dynamic-RE phase._
|
||||
|
||||
| Function | Conf. | Reimpl. | Summary |
|
||||
|----------|-------|---------|---------|
|
||||
| — | — | — | — |
|
||||
88
docs/re/README.md
Normal file
88
docs/re/README.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Reverse-engineering knowledge base
|
||||
|
||||
This directory is the **spec-side** of the clean-room: it records what the original
|
||||
*Project Sylpheed* binary **does** (behaviour) and how its **data is laid out**, so that
|
||||
the Rust port can be implemented **from these specs** without re-deriving anything and
|
||||
without ever copying original code.
|
||||
|
||||
It exists to answer one question fast: *"do we already know how X works, and how sure are we?"*
|
||||
|
||||
---
|
||||
|
||||
## The one rule that matters
|
||||
|
||||
> **Never document a claim more confidently than the evidence supports, and never
|
||||
> paste original code here.**
|
||||
|
||||
A wrong-but-confident note is worse than no note: someone builds on it and the bug hides
|
||||
for weeks. Every entry therefore carries an explicit **confidence** and its **evidence**.
|
||||
This mirrors the project method — *measure the oracle, never infer; refute before believing.*
|
||||
|
||||
### Clean-room firewall
|
||||
|
||||
- ✅ Allowed: behaviour descriptions, field offsets/types, formulas, state machines,
|
||||
observed input→output pairs, and **references** to the original by address
|
||||
(`sub_821B68C0`) or to `xenia-rs/sylpheed.db`.
|
||||
- ❌ Forbidden here and in `crates/`: pasted decompiled C/C++ or verbatim disassembled
|
||||
function bodies presented as the thing to reimplement. Cite the address; describe the
|
||||
behaviour in your own words. Disassembly is a tool for *understanding*, not a source to copy.
|
||||
|
||||
---
|
||||
|
||||
## Confidence levels
|
||||
|
||||
| Level | Meaning | Bar to reach it |
|
||||
|-------|---------|-----------------|
|
||||
| `CONFIRMED` | Behaviour verified against ground truth. | ≥2 independent observations **or** one observation cross-checked against an oracle (canary framebuffer, a known-correct value, a second code path). |
|
||||
| `PROBABLE` | Strong single-source inference. | One clean observation, or an unambiguous static read of the disassembly. |
|
||||
| `HYPOTHESIS` | Educated guess, not yet tested. | Anything else. Must say what would confirm/refute it. |
|
||||
|
||||
**Promotion requires new evidence, not re-reading the old evidence.** A `HYPOTHESIS` that
|
||||
"looks right again" is still a `HYPOTHESIS`. Only an *independent* check promotes it.
|
||||
If evidence later contradicts an entry, **demote it and record the contradiction** — do
|
||||
not silently edit the conclusion.
|
||||
|
||||
---
|
||||
|
||||
## When to document
|
||||
|
||||
- **Right after** a function/structure crosses from `HYPOTHESIS` to at least `PROBABLE` —
|
||||
before moving to the next code path, so the knowledge isn't lost or re-derived.
|
||||
- **Whenever confidence changes** (up or down) — append to the Evidence log, don't overwrite.
|
||||
- **Not** while it's still a pure guess with no evidence — a one-liner in the relevant
|
||||
backlog/plan is enough until there's something to stand on.
|
||||
|
||||
## What to document
|
||||
|
||||
- **Functions/code paths** → `docs/re/functions/<name>.md` (one file per function or tight cluster).
|
||||
- **Data structures / formats** → `docs/re/structures/<name>.md`.
|
||||
- Keep the index in [`INDEX.md`](INDEX.md) (one line each: name · confidence · one-line summary).
|
||||
|
||||
Use the templates: [`_TEMPLATE.function.md`](_TEMPLATE.function.md),
|
||||
[`_TEMPLATE.structure.md`](_TEMPLATE.structure.md).
|
||||
|
||||
---
|
||||
|
||||
## How we find and confirm code paths (the toolchain)
|
||||
|
||||
Everything joins on the **guest virtual address (PC)** — code addresses are fixed by the
|
||||
XEX load, identical across our emulator and canary.
|
||||
|
||||
- **Static (cheap, try first):** `xenia-rs/sylpheed.db` (DuckDB: 25 481 functions, xrefs,
|
||||
strings, vtables, imports). Query with `xenia-rs/zq.py` — `zq.py grep <str>`,
|
||||
`zq.py xref <addr>`, `zq.py dis <lo> <hi>`, `zq.py fn <pc>`. Entry points are usually a
|
||||
**string** (`zq.py grep MSG_DEMO`) or an **import** (movie/XMA API) xref'd back to the loader.
|
||||
- **Dynamic (when static is ambiguous):** run `xenia-rs` with its probe suite —
|
||||
`--pc-probe` / `--audit-pc-probe-hex` (fires at block entry), `--mem-watch` (mid-block
|
||||
reads/writes of a VA), `--lr-trace` (call/return chains), `--trace-instructions`,
|
||||
`--dump-addr` (read guest memory). These already exist; prefer them over hacking canary.
|
||||
- **Oracle (correctness ground truth):** canary — the **Wine cross-build**
|
||||
`xenia-canary/build-cross/bin/Windows/Debug/xenia_canary.exe` (the native Linux ELF
|
||||
**crashes / does not run** — do not use it). This is the only emulator that reaches the
|
||||
in-game menu; our `xenia-rs` never got past the intro video. Use canary to *observe output*
|
||||
(capture its framebuffer for texture colours), not usually to instrument code — though its
|
||||
`build-cross` toolchain does compile, so small C++ probes + rebuild are possible when needed.
|
||||
Run **muted, one emulator process at a time**, point it at the real ISO (not the symlink).
|
||||
|
||||
> ⚠️ **VA-equality caveat:** join **code** by PC (fixed), but **never** assume a data VA
|
||||
> holds the same bytes across emulators — allocators differ. Compare data by content/layout.
|
||||
23
docs/re/_TEMPLATE.function.md
Normal file
23
docs/re/_TEMPLATE.function.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# `<function name / sub_XXXXXXXX>`
|
||||
|
||||
- **Address:** `0x________` (in `sylpheed.db`; `zq.py fn 0x________`)
|
||||
- **Confidence:** `HYPOTHESIS | PROBABLE | CONFIRMED`
|
||||
- **Reimplemented in:** `crates/…/…rs::fn` — or *not yet*
|
||||
- **Related:** other RE entries, `[[memory-slug]]`
|
||||
|
||||
## What it does
|
||||
One-paragraph behavioural summary, in your own words. No pasted disassembly.
|
||||
|
||||
## Signature / calling convention
|
||||
Inputs (registers/args + meaning), outputs, side effects (memory it writes, globals, imports it calls).
|
||||
|
||||
## Behaviour
|
||||
Step-by-step of the observable behaviour — the *spec* to implement from. Formulas, branches,
|
||||
state transitions. Cite addresses for detail (`the loop at 0x…`), don't transcribe code.
|
||||
|
||||
## Evidence log (append-only; newest last)
|
||||
- `YYYY-MM-DD` — *how we know*: static disasm read / `--lr-trace` from 0x… / canary output /
|
||||
N observations. What it established. → confidence set to `…`.
|
||||
|
||||
## Open questions / what would raise confidence
|
||||
The specific check that would confirm or refute the current claim.
|
||||
29
docs/re/_TEMPLATE.structure.md
Normal file
29
docs/re/_TEMPLATE.structure.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# `<structure / format name>`
|
||||
|
||||
- **Confidence:** `HYPOTHESIS | PROBABLE | CONFIRMED`
|
||||
- **Parser in:** `crates/sylpheed-formats/src/…rs` — or *not yet*
|
||||
- **Seen in:** which pak / asset / memory region
|
||||
- **Related:** other RE entries, `[[memory-slug]]`
|
||||
|
||||
## Layout
|
||||
```text
|
||||
offset size type name notes
|
||||
0x00 4 magic "____"
|
||||
0x__ 4 u32be …
|
||||
…
|
||||
```
|
||||
Endianness, alignment, variable-length rules, how child/section sizes are derived.
|
||||
|
||||
## Field semantics
|
||||
What each field *means* and its observed value range. Distinguish **confirmed** fields from
|
||||
**guessed** ones explicitly (a guessed field with a plausible value is still a guess).
|
||||
|
||||
## Coverage / limits
|
||||
What fraction of real instances this decodes; which variants are deferred and why.
|
||||
|
||||
## Evidence log (append-only; newest last)
|
||||
- `YYYY-MM-DD` — how established (forensic scan over N disc entries / round-trip test /
|
||||
oracle comparison). → confidence `…`.
|
||||
|
||||
## Open questions / what would raise confidence
|
||||
e.g. "colours unverified until compared against canary's framebuffer."
|
||||
38
docs/re/structures/texture-color-k8888.md
Normal file
38
docs/re/structures/texture-color-k8888.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Texture colour interpretation — `k_8_8_8_8` (32bpp UI/HUD textures)
|
||||
|
||||
- **Confidence:** mixed — see per-claim tags below.
|
||||
- **Parser in:** `sylpheed-formats/src/t8ad.rs`, `src/texture.rs` (XPR2)
|
||||
- **Applies to:** T8aD 2D UI textures, XPR2 32bpp surfaces — the "colours look a bit odd" concern.
|
||||
- **Related:** [[reborn-dynamic-re-plan]], INDEX rows T8aD / XPR2.
|
||||
|
||||
## What this resolves
|
||||
Whether our decoded 32bpp textures use the right **sRGB-vs-linear** interpretation and the right
|
||||
**channel order**. Ground truth = Canary's Vulkan texture cache, which is the code that actually
|
||||
produces correct on-screen colour.
|
||||
|
||||
## Findings
|
||||
|
||||
### ✅ CONFIRMED — plain `k_8_8_8_8` is LINEAR (UNORM), never sRGB
|
||||
Canary's host-format table maps plain `k_8_8_8_8` → `VK_FORMAT_R8G8B8A8_UNORM` with a straight
|
||||
32-bit copy load shader (`kLoadShaderIndex32bpb`) and an **identity** format swizzle
|
||||
(`XE_GPU_TEXTURE_SWIZZLE_RGBA`). There is **no** `..._SRGB` host format anywhere in the table;
|
||||
gamma is a separate explicit path (`k_8_8_8_8_GAMMA_EDRAM` only). Therefore a plain 32bpp texture
|
||||
is sampled as **raw UNORM bytes — apply no sRGB/gamma decode.**
|
||||
→ *Implication:* if the viewer/engine applies an sRGB→linear (or linear→sRGB) step to these, that is
|
||||
wrong. Show the bytes as-is (raw RGBA8), gamma only where the game explicitly requests it.
|
||||
|
||||
### ❔ HYPOTHESIS — exact source **channel order** (needs measurement, do not infer)
|
||||
Our decoders currently assume on-disk **A8R8G8B8** and reorder `[a,r,g,b] → [r,g,b,a]`. Canary
|
||||
proves the *host* target is R8G8B8A8 identity-swizzled, but the *guest→host* byte order also depends
|
||||
on the texture's runtime **endian** field (e.g. `8in32`), which is set by the game at fetch time and
|
||||
**cannot be read statically** from the format table. So whether our reorder matches is unverified.
|
||||
→ *What would confirm/refute it:* one **measured** decoded texel — either capture Canary's
|
||||
framebuffer over a texture with distinct R≠G≠B pixels of known colour, or patch Canary's already-
|
||||
present-but-unwired `TextureDump` (`src/xenia/gpu/texture_dump.cc`) to dump the decoded texture and
|
||||
diff bytes against our decode. **Measure, don't guess** — a symmetric colour (white logo) can't
|
||||
disambiguate channel order.
|
||||
|
||||
## Evidence log
|
||||
- `2026-07-11` — static read of Canary `src/xenia/gpu/vulkan/vulkan_texture_cache.cc` host-format
|
||||
table + confirmed no `R8G8B8A8_SRGB` entry exists. → sRGB/UNORM claim `CONFIRMED`; channel-order
|
||||
claim remains `HYPOTHESIS` pending a measured texel.
|
||||
Reference in New Issue
Block a user