rustfmt, then clippy -D warnings across the three new crates. Mechanical,
except three decisions that are stated rather than silently allowed:
* lzx.rs gets file-scoped needless_range_loop/explicit_counter_loop allows.
Index arithmetic IS the algorithm -- LZX is defined over symbol indices,
Huffman slots and window positions, and a decompressor that is merely
idiomatic is worth nothing if it is not bit-exact.
* sylpheed-xexdb gets crate-scoped allows for needless_range_loop (nine
sites index reg[r] where r is the PowerPC register number -- the index is
the meaning), too_many_arguments and type_complexity. This code arrived
whole from a retired repository; a refactor here would be an unreviewed
edit dressed as a lint fix.
* Everything else clippy asked for is FIXED, including all 14 doc-indent
sites, the let-else, and a Prepared type alias in the binary.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
479 lines
16 KiB
Rust
479 lines
16 KiB
Rust
//! XDBF / SPA — the title metadata package embedded in the XEX.
|
|
//!
|
|
//! A title's `XEX_HEADER_RESOURCE_INFO` names one resource whose body is an
|
|
//! **XDBF** ("Xbox DataBase File") container, in its SPA flavour: achievement
|
|
//! definitions, one string table per shipped language, PNG images, and the
|
|
//! matchmaking / leaderboard / presence schema.
|
|
//!
|
|
//! ```text
|
|
//! XdbfHeader 24 bytes magic 'XDBF', version, entry_count, entry_used,
|
|
//! free_count, free_used
|
|
//! XdbfEntry[] 18 each namespace u16, id u64, offset u32, size u32
|
|
//! XdbfFileLoc[] 8 each the free-space table
|
|
//! data entry offsets are relative to the end of the two tables
|
|
//! ```
|
|
//!
|
|
//! Each entry's body starts with a section header — `magic, version, size`,
|
|
//! plus a `u16 count` for the table-shaped ones.
|
|
//!
|
|
//! Entries are enumerated from the **entry table**, not by scanning for section
|
|
//! magics. Scanning is what the project's earlier `tools/xach_dump.py` does, and
|
|
//! on this title it finds a phantom seventh `XSTR` (the byte pattern occurs
|
|
//! outside any declared entry) where the entry table declares six — which shifts
|
|
//! every language index derived from the scan order.
|
|
//!
|
|
//! Layouts follow the reference implementation in xenia-canary
|
|
//! (`src/xenia/kernel/xam/xdbf/{xdbf_io,spa_info}.h`), which in turn cites
|
|
//! freestyledash `Tools/XEX/SPA.{h,cpp}`.
|
|
|
|
/// `XDBF` big-endian.
|
|
const XDBF_MAGIC: u32 = 0x5844_4246;
|
|
|
|
/// The well-known entry id carrying the title's own name (in the string-table
|
|
/// namespace) and its icon (in the image namespace) — canary's `kXdbfIdTitle`.
|
|
pub const ID_TITLE: u64 = 0x8000;
|
|
|
|
const NS_METADATA: u16 = 1;
|
|
const NS_IMAGE: u16 = 2;
|
|
const NS_STRING_TABLE: u16 = 3;
|
|
|
|
/// One row of the container's entry table.
|
|
#[derive(Debug, Clone)]
|
|
pub struct XdbfEntry {
|
|
/// 1 = metadata, 2 = image, 3 = string table.
|
|
pub namespace: u16,
|
|
/// Entry id. For metadata entries this is the section fourcc as an integer;
|
|
/// for string tables it is the [`XLanguage`] value; for images, the image id.
|
|
pub id: u64,
|
|
/// Absolute offset of the entry body within the image buffer.
|
|
pub offset: usize,
|
|
/// Entry body length in bytes.
|
|
pub size: usize,
|
|
/// The body's leading fourcc, when it has one (`XACH`, `XSTR`, …).
|
|
pub magic: Option<String>,
|
|
}
|
|
|
|
/// One achievement definition (`XACH`, 36-byte records).
|
|
#[derive(Debug, Clone)]
|
|
pub struct Achievement {
|
|
pub id: u16,
|
|
/// String id of the achievement's name.
|
|
pub label_id: u16,
|
|
/// String id of the description shown once unlocked.
|
|
pub description_id: u16,
|
|
/// String id of the description shown while locked.
|
|
pub unachieved_id: u16,
|
|
pub image_id: u32,
|
|
pub gamerscore: u16,
|
|
pub flags: u32,
|
|
}
|
|
|
|
/// One localized string table (`XSTR`).
|
|
#[derive(Debug, Clone)]
|
|
pub struct StringTable {
|
|
/// `XLanguage` value; the entry id.
|
|
pub language: u32,
|
|
/// `(string id, value)` in table order.
|
|
pub strings: Vec<(u16, String)>,
|
|
}
|
|
|
|
/// `XTHD` — the title header.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct TitleHeader {
|
|
pub title_id: u32,
|
|
pub title_type: u32,
|
|
pub major: u16,
|
|
pub minor: u16,
|
|
pub build: u16,
|
|
pub revision: u16,
|
|
pub flags: u32,
|
|
}
|
|
|
|
/// An embedded image (namespace 2). Bodies are raw files, in practice PNG.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Image {
|
|
pub id: u64,
|
|
pub offset: usize,
|
|
pub size: usize,
|
|
/// `"png"` when the body carries the PNG signature, else `"unknown"`.
|
|
pub format: &'static str,
|
|
}
|
|
|
|
/// Everything recovered from one XDBF package.
|
|
#[derive(Debug, Default)]
|
|
pub struct Xdbf {
|
|
/// Offset of the container within the image buffer.
|
|
pub base: usize,
|
|
pub version: u32,
|
|
pub entries: Vec<XdbfEntry>,
|
|
pub achievements: Vec<Achievement>,
|
|
pub string_tables: Vec<StringTable>,
|
|
pub images: Vec<Image>,
|
|
pub title: Option<TitleHeader>,
|
|
/// `XSTC` default language (an `XLanguage` value).
|
|
pub default_language: Option<u32>,
|
|
}
|
|
|
|
fn be16(b: &[u8], o: usize) -> Option<u16> {
|
|
Some(u16::from_be_bytes([*b.get(o)?, *b.get(o + 1)?]))
|
|
}
|
|
fn be32(b: &[u8], o: usize) -> Option<u32> {
|
|
Some(u32::from_be_bytes([
|
|
*b.get(o)?,
|
|
*b.get(o + 1)?,
|
|
*b.get(o + 2)?,
|
|
*b.get(o + 3)?,
|
|
]))
|
|
}
|
|
fn be64(b: &[u8], o: usize) -> Option<u64> {
|
|
let hi = be32(b, o)? as u64;
|
|
let lo = be32(b, o + 4)? as u64;
|
|
Some((hi << 32) | lo)
|
|
}
|
|
|
|
/// Render a fourcc as text when all four bytes are printable ASCII.
|
|
fn fourcc(v: u32) -> Option<String> {
|
|
let b = v.to_be_bytes();
|
|
b.iter()
|
|
.all(|c| (0x20..0x7F).contains(c))
|
|
.then(|| String::from_utf8_lossy(&b).into_owned())
|
|
}
|
|
|
|
/// Human-readable name for an `XLanguage` value.
|
|
pub fn language_name(v: u32) -> &'static str {
|
|
match v {
|
|
1 => "English",
|
|
2 => "Japanese",
|
|
3 => "German",
|
|
4 => "French",
|
|
5 => "Spanish",
|
|
6 => "Italian",
|
|
7 => "Korean",
|
|
8 => "Chinese (Traditional)",
|
|
9 => "Portuguese",
|
|
10 => "Chinese (Simplified)",
|
|
11 => "Polish",
|
|
12 => "Russian",
|
|
_ => "unknown",
|
|
}
|
|
}
|
|
|
|
/// Parse the XDBF package at `base` within `image`.
|
|
///
|
|
/// Returns `None` when there is no XDBF magic there — callers locate the
|
|
/// package via `sylpheed_xex::resources`, and a title without one is normal.
|
|
#[tracing::instrument(skip_all, fields(base = format_args!("{base:#x}")))]
|
|
pub fn analyze(image: &[u8], base: usize) -> Option<Xdbf> {
|
|
let started = std::time::Instant::now();
|
|
if be32(image, base)? != XDBF_MAGIC {
|
|
return None;
|
|
}
|
|
let version = be32(image, base + 4)?;
|
|
let entry_count = be32(image, base + 8)? as usize;
|
|
let entry_used = be32(image, base + 12)? as usize;
|
|
let free_count = be32(image, base + 16)? as usize;
|
|
|
|
// Guard against a corrupt header pointing the data region off the end.
|
|
if entry_used > entry_count || entry_count > 0x10000 || free_count > 0x10000 {
|
|
return None;
|
|
}
|
|
let entry_table = base + 24;
|
|
let data_start = entry_table + entry_count * 18 + free_count * 8;
|
|
if data_start > image.len() {
|
|
return None;
|
|
}
|
|
|
|
let mut out = Xdbf {
|
|
base,
|
|
version,
|
|
..Default::default()
|
|
};
|
|
|
|
for i in 0..entry_used {
|
|
let p = entry_table + i * 18;
|
|
let (Some(namespace), Some(id), Some(off), Some(size)) = (
|
|
be16(image, p),
|
|
be64(image, p + 2),
|
|
be32(image, p + 10),
|
|
be32(image, p + 14),
|
|
) else {
|
|
continue;
|
|
};
|
|
let body = data_start + off as usize;
|
|
let size = size as usize;
|
|
if body + size > image.len() {
|
|
continue;
|
|
}
|
|
let magic = be32(image, body).and_then(fourcc);
|
|
out.entries.push(XdbfEntry {
|
|
namespace,
|
|
id,
|
|
offset: body,
|
|
size,
|
|
magic: magic.clone(),
|
|
});
|
|
|
|
match namespace {
|
|
NS_IMAGE => out.images.push(Image {
|
|
id,
|
|
offset: body,
|
|
size,
|
|
format: if image[body..].starts_with(b"\x89PNG") {
|
|
"png"
|
|
} else {
|
|
"unknown"
|
|
},
|
|
}),
|
|
NS_STRING_TABLE => {
|
|
if let Some(t) = parse_string_table(image, body, size, id as u32) {
|
|
out.string_tables.push(t);
|
|
}
|
|
}
|
|
NS_METADATA => match magic.as_deref() {
|
|
Some("XACH") => out
|
|
.achievements
|
|
.extend(parse_achievements(image, body, size)),
|
|
Some("XTHD") => out.title = parse_title_header(image, body),
|
|
Some("XSTC") => out.default_language = be32(image, body + 12),
|
|
_ => {}
|
|
},
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
metrics::histogram!("analysis.phase_ms", "phase" => "xdbf")
|
|
.record(started.elapsed().as_millis() as f64);
|
|
tracing::info!(
|
|
entries = out.entries.len(),
|
|
achievements = out.achievements.len(),
|
|
string_tables = out.string_tables.len(),
|
|
images = out.images.len(),
|
|
default_language = out.default_language,
|
|
"XDBF package parsed",
|
|
);
|
|
Some(out)
|
|
}
|
|
|
|
/// `XACH`: `magic, version, size, count u16`, then 36-byte records.
|
|
fn parse_achievements(image: &[u8], body: usize, size: usize) -> Vec<Achievement> {
|
|
let Some(count) = be16(image, body + 12) else {
|
|
return Vec::new();
|
|
};
|
|
let mut out = Vec::with_capacity(count as usize);
|
|
for i in 0..count as usize {
|
|
let p = body + 14 + i * 36;
|
|
if p + 36 > body + size {
|
|
break;
|
|
}
|
|
let (Some(id), Some(label_id), Some(description_id), Some(unachieved_id)) = (
|
|
be16(image, p),
|
|
be16(image, p + 2),
|
|
be16(image, p + 4),
|
|
be16(image, p + 6),
|
|
) else {
|
|
break;
|
|
};
|
|
out.push(Achievement {
|
|
id,
|
|
label_id,
|
|
description_id,
|
|
unachieved_id,
|
|
image_id: be32(image, p + 8).unwrap_or(0),
|
|
gamerscore: be16(image, p + 12).unwrap_or(0),
|
|
flags: be32(image, p + 16).unwrap_or(0),
|
|
});
|
|
}
|
|
out
|
|
}
|
|
|
|
/// `XSTR`: `magic, version, size, count u16`, then `id u16, len u16, bytes`.
|
|
///
|
|
/// Bodies are UTF-8 (the ASCII subset for most locales; Japanese uses the full
|
|
/// range), decoded lossily so one bad table cannot drop a whole language.
|
|
fn parse_string_table(
|
|
image: &[u8],
|
|
body: usize,
|
|
size: usize,
|
|
language: u32,
|
|
) -> Option<StringTable> {
|
|
if fourcc(be32(image, body)?)? != "XSTR" {
|
|
return None;
|
|
}
|
|
let count = be16(image, body + 12)?;
|
|
let end = body + size;
|
|
let mut p = body + 14;
|
|
let mut strings = Vec::with_capacity(count as usize);
|
|
for _ in 0..count {
|
|
let (Some(id), Some(len)) = (be16(image, p), be16(image, p + 2)) else {
|
|
break;
|
|
};
|
|
let s = p + 4;
|
|
let e = s + len as usize;
|
|
if e > end || e > image.len() {
|
|
break;
|
|
}
|
|
strings.push((id, String::from_utf8_lossy(&image[s..e]).into_owned()));
|
|
p = e;
|
|
}
|
|
Some(StringTable { language, strings })
|
|
}
|
|
|
|
/// `XTHD`: section header then the 32-byte `TitleHeaderData`.
|
|
fn parse_title_header(image: &[u8], body: usize) -> Option<TitleHeader> {
|
|
let p = body + 12;
|
|
Some(TitleHeader {
|
|
title_id: be32(image, p)?,
|
|
title_type: be32(image, p + 4)?,
|
|
major: be16(image, p + 8)?,
|
|
minor: be16(image, p + 10)?,
|
|
build: be16(image, p + 12)?,
|
|
revision: be16(image, p + 14)?,
|
|
flags: be32(image, p + 16)?,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Build a minimal XDBF: one XACH with a single achievement, one XSTR, one
|
|
/// PNG, an XTHD and an XSTC.
|
|
fn mk_xdbf() -> (Vec<u8>, usize) {
|
|
let base = 0x100usize;
|
|
let entry_count = 5usize;
|
|
let free_count = 1usize;
|
|
let data_start = base + 24 + entry_count * 18 + free_count * 8;
|
|
|
|
let mut bodies: Vec<(u16, u64, Vec<u8>)> = Vec::new();
|
|
|
|
let mut xach = Vec::new();
|
|
xach.extend(b"XACH");
|
|
xach.extend(1u32.to_be_bytes());
|
|
xach.extend(0u32.to_be_bytes());
|
|
xach.extend(1u16.to_be_bytes()); // count
|
|
let mut rec = Vec::new();
|
|
rec.extend(7u16.to_be_bytes()); // id
|
|
rec.extend(100u16.to_be_bytes()); // label
|
|
rec.extend(101u16.to_be_bytes()); // description
|
|
rec.extend(102u16.to_be_bytes()); // unachieved
|
|
rec.extend(9u32.to_be_bytes()); // image id
|
|
rec.extend(20u16.to_be_bytes()); // gamerscore
|
|
rec.extend(0u16.to_be_bytes());
|
|
rec.extend(0x0Cu32.to_be_bytes()); // flags
|
|
rec.extend([0u8; 16]);
|
|
assert_eq!(rec.len(), 36);
|
|
xach.extend(rec);
|
|
bodies.push((NS_METADATA, u32::from_be_bytes(*b"XACH") as u64, xach));
|
|
|
|
let mut xstr = Vec::new();
|
|
xstr.extend(b"XSTR");
|
|
xstr.extend(1u32.to_be_bytes());
|
|
xstr.extend(0u32.to_be_bytes());
|
|
xstr.extend(2u16.to_be_bytes());
|
|
for (id, s) in [(100u16, "Space Combat Award"), (101u16, "Well done")] {
|
|
xstr.extend(id.to_be_bytes());
|
|
xstr.extend((s.len() as u16).to_be_bytes());
|
|
xstr.extend(s.as_bytes());
|
|
}
|
|
bodies.push((NS_STRING_TABLE, 1, xstr)); // language 1 = English
|
|
|
|
let mut xthd = Vec::new();
|
|
xthd.extend(b"XTHD");
|
|
xthd.extend(1u32.to_be_bytes());
|
|
xthd.extend(0u32.to_be_bytes());
|
|
xthd.extend(0x5351_07D4u32.to_be_bytes()); // title id
|
|
xthd.extend(1u32.to_be_bytes()); // type = full
|
|
xthd.extend(1u16.to_be_bytes());
|
|
xthd.extend(2u16.to_be_bytes());
|
|
xthd.extend(3u16.to_be_bytes());
|
|
xthd.extend(4u16.to_be_bytes());
|
|
xthd.extend(0u32.to_be_bytes());
|
|
bodies.push((NS_METADATA, u32::from_be_bytes(*b"XTHD") as u64, xthd));
|
|
|
|
let mut xstc = Vec::new();
|
|
xstc.extend(b"XSTC");
|
|
xstc.extend(1u32.to_be_bytes());
|
|
xstc.extend(16u32.to_be_bytes());
|
|
xstc.extend(1u32.to_be_bytes()); // default language = English
|
|
bodies.push((NS_METADATA, u32::from_be_bytes(*b"XSTC") as u64, xstc));
|
|
|
|
let png = b"\x89PNG\r\n\x1a\n----".to_vec();
|
|
bodies.push((NS_IMAGE, 9, png));
|
|
|
|
let total: usize = bodies.iter().map(|(_, _, b)| b.len()).sum();
|
|
let mut img = vec![0u8; data_start + total + 0x10];
|
|
img[base..base + 4].copy_from_slice(&XDBF_MAGIC.to_be_bytes());
|
|
img[base + 4..base + 8].copy_from_slice(&0x10000u32.to_be_bytes());
|
|
img[base + 8..base + 12].copy_from_slice(&(entry_count as u32).to_be_bytes());
|
|
img[base + 12..base + 16].copy_from_slice(&(bodies.len() as u32).to_be_bytes());
|
|
img[base + 16..base + 20].copy_from_slice(&(free_count as u32).to_be_bytes());
|
|
|
|
let mut off = 0usize;
|
|
for (i, (ns, id, b)) in bodies.iter().enumerate() {
|
|
let p = base + 24 + i * 18;
|
|
img[p..p + 2].copy_from_slice(&ns.to_be_bytes());
|
|
img[p + 2..p + 10].copy_from_slice(&id.to_be_bytes());
|
|
img[p + 10..p + 14].copy_from_slice(&(off as u32).to_be_bytes());
|
|
img[p + 14..p + 18].copy_from_slice(&(b.len() as u32).to_be_bytes());
|
|
img[data_start + off..data_start + off + b.len()].copy_from_slice(b);
|
|
off += b.len();
|
|
}
|
|
(img, base)
|
|
}
|
|
|
|
#[test]
|
|
fn parses_container_via_entry_table() {
|
|
let (img, base) = mk_xdbf();
|
|
let x = analyze(&img, base).expect("parses");
|
|
assert_eq!(x.entries.len(), 5);
|
|
assert_eq!(x.achievements.len(), 1);
|
|
assert_eq!(x.string_tables.len(), 1);
|
|
assert_eq!(x.images.len(), 1);
|
|
assert_eq!(x.default_language, Some(1));
|
|
}
|
|
|
|
#[test]
|
|
fn achievement_fields_and_string_ids_line_up() {
|
|
let (img, base) = mk_xdbf();
|
|
let x = analyze(&img, base).unwrap();
|
|
let a = &x.achievements[0];
|
|
assert_eq!((a.id, a.gamerscore, a.image_id, a.flags), (7, 20, 9, 0x0C));
|
|
let t = &x.string_tables[0];
|
|
assert_eq!(t.language, 1);
|
|
assert_eq!(t.strings[0], (100, "Space Combat Award".to_string()));
|
|
// The achievement's label resolves through the table.
|
|
let name = t
|
|
.strings
|
|
.iter()
|
|
.find(|(i, _)| *i == a.label_id)
|
|
.map(|(_, s)| s.as_str());
|
|
assert_eq!(name, Some("Space Combat Award"));
|
|
}
|
|
|
|
#[test]
|
|
fn title_header_and_image_format() {
|
|
let (img, base) = mk_xdbf();
|
|
let x = analyze(&img, base).unwrap();
|
|
let t = x.title.expect("XTHD");
|
|
assert_eq!(t.title_id, 0x5351_07D4);
|
|
assert_eq!((t.major, t.minor, t.build, t.revision), (1, 2, 3, 4));
|
|
assert_eq!(x.images[0].format, "png");
|
|
assert_eq!(x.images[0].id, 9);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_non_xdbf() {
|
|
let img = vec![0u8; 0x200];
|
|
assert!(analyze(&img, 0x100).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_header_pointing_past_the_buffer() {
|
|
let mut img = vec![0u8; 0x200];
|
|
img[0..4].copy_from_slice(&XDBF_MAGIC.to_be_bytes());
|
|
img[8..12].copy_from_slice(&0xFFFFu32.to_be_bytes()); // entry_count
|
|
img[12..16].copy_from_slice(&0xFFFFu32.to_be_bytes()); // entry_used
|
|
assert!(analyze(&img, 0).is_none());
|
|
}
|
|
}
|