Files
MechaCat02 c9dd2cb705 fix(xexdb): clear the lint gate on the imported crates
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>
2026-09-13 20:25:44 +02:00

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());
}
}