rrt/crates/rrt-runtime/src/pk4.rs

313 lines
11 KiB
Rust

use std::fs;
use std::path::Path;
use serde::{Deserialize, Serialize};
pub const PK4_MAGIC: u32 = 0x0000_03eb;
pub const PK4_DIRECTORY_ENTRY_STRIDE: usize = 0x4a;
pub const PK4_DIRECTORY_METADATA_LEN: usize = 13;
pub const PK4_DIRECTORY_NAME_LEN: usize = PK4_DIRECTORY_ENTRY_STRIDE - PK4_DIRECTORY_METADATA_LEN;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Pk4Entry {
pub index: usize,
pub directory_offset: usize,
pub directory_offset_hex: String,
pub crc32: u32,
pub crc32_hex: String,
pub payload_len: u32,
pub payload_len_hex: String,
pub payload_offset: u32,
pub payload_offset_hex: String,
pub payload_absolute_offset: usize,
pub payload_absolute_offset_hex: String,
pub payload_end_offset: usize,
pub payload_end_offset_hex: String,
pub flag: u8,
pub flag_hex: String,
pub extension: Option<String>,
pub payload_signature_ascii: String,
pub payload_signature_hex: String,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Pk4InspectionReport {
pub magic: u32,
pub magic_hex: String,
pub entry_count: usize,
pub directory_entry_stride: usize,
pub directory_len: usize,
pub directory_len_hex: String,
pub payload_base_offset: usize,
pub payload_base_offset_hex: String,
pub file_size: usize,
pub payloads_are_contiguous: bool,
pub notes: Vec<String>,
pub entries: Vec<Pk4Entry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Pk4ExtractionReport {
pub matched_entry_name: String,
pub case_insensitive_match: bool,
pub extracted_len: usize,
pub extracted_len_hex: String,
pub entry: Pk4Entry,
}
pub fn inspect_pk4_file(path: &Path) -> Result<Pk4InspectionReport, Box<dyn std::error::Error>> {
let bytes = fs::read(path)?;
inspect_pk4_bytes(&bytes)
}
pub fn inspect_pk4_bytes(bytes: &[u8]) -> Result<Pk4InspectionReport, Box<dyn std::error::Error>> {
let magic = read_u32_le(bytes, 0).ok_or("truncated pk4 magic")?;
let entry_count = read_u32_le(bytes, 4).ok_or("truncated pk4 entry count")? as usize;
let directory_len = entry_count
.checked_mul(PK4_DIRECTORY_ENTRY_STRIDE)
.ok_or("pk4 directory length overflow")?;
let payload_base_offset = 8usize
.checked_add(directory_len)
.ok_or("pk4 payload base overflow")?;
if payload_base_offset > bytes.len() {
return Err(format!(
"pk4 directory extends past end of file: payload base 0x{payload_base_offset:x}, file size 0x{:x}",
bytes.len()
)
.into());
}
let mut entries = Vec::with_capacity(entry_count);
for index in 0..entry_count {
let directory_offset = 8 + index * PK4_DIRECTORY_ENTRY_STRIDE;
let directory_entry = bytes
.get(directory_offset..directory_offset + PK4_DIRECTORY_ENTRY_STRIDE)
.ok_or_else(|| {
format!(
"truncated pk4 directory entry {} at offset 0x{directory_offset:x}",
index
)
})?;
let crc32 = read_u32_le(directory_entry, 0).ok_or("truncated pk4 entry crc32")?;
let payload_len = read_u32_le(directory_entry, 4).ok_or("truncated pk4 entry length")?;
let payload_offset =
read_u32_le(directory_entry, 8).ok_or("truncated pk4 entry payload offset")?;
let flag = directory_entry[12];
let name = parse_name(&directory_entry[13..])?;
let payload_absolute_offset = payload_base_offset
.checked_add(payload_offset as usize)
.ok_or_else(|| format!("pk4 payload offset overflow for entry {name}"))?;
let payload_end_offset = payload_absolute_offset
.checked_add(payload_len as usize)
.ok_or_else(|| format!("pk4 payload end overflow for entry {name}"))?;
let payload = bytes.get(payload_absolute_offset..payload_end_offset).ok_or_else(|| {
format!(
"pk4 payload for entry {name} extends past end of file: 0x{payload_absolute_offset:x}..0x{payload_end_offset:x} > 0x{:x}",
bytes.len()
)
})?;
entries.push(Pk4Entry {
index,
directory_offset,
directory_offset_hex: format!("0x{directory_offset:04x}"),
crc32,
crc32_hex: format!("0x{crc32:08x}"),
payload_len,
payload_len_hex: format!("0x{payload_len:08x}"),
payload_offset,
payload_offset_hex: format!("0x{payload_offset:08x}"),
payload_absolute_offset,
payload_absolute_offset_hex: format!("0x{payload_absolute_offset:08x}"),
payload_end_offset,
payload_end_offset_hex: format!("0x{payload_end_offset:08x}"),
flag,
flag_hex: format!("0x{flag:02x}"),
extension: Path::new(&name)
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| extension.to_ascii_lowercase()),
payload_signature_ascii: ascii_preview(payload, 8),
payload_signature_hex: hex_preview(payload, 8),
name,
});
}
let payloads_are_contiguous = entries
.windows(2)
.all(|window| window[0].payload_end_offset == window[1].payload_absolute_offset);
let mut notes = Vec::new();
if magic == PK4_MAGIC {
notes.push(
"Header magic matches the observed RT3 pack4 container family (0x03eb).".to_string(),
);
} else {
notes.push(format!(
"Header magic 0x{magic:08x} differs from the observed RT3 pack4 container family 0x{PK4_MAGIC:08x}."
));
}
notes.push(format!(
"Payload base is derived as 8 + entry_count * 0x{PK4_DIRECTORY_ENTRY_STRIDE:02x}."
));
if payloads_are_contiguous {
notes.push(
"Entry payload offsets form one contiguous packed data region in directory order."
.to_string(),
);
}
Ok(Pk4InspectionReport {
magic,
magic_hex: format!("0x{magic:08x}"),
entry_count,
directory_entry_stride: PK4_DIRECTORY_ENTRY_STRIDE,
directory_len,
directory_len_hex: format!("0x{directory_len:08x}"),
payload_base_offset,
payload_base_offset_hex: format!("0x{payload_base_offset:08x}"),
file_size: bytes.len(),
payloads_are_contiguous,
notes,
entries,
})
}
pub fn extract_pk4_entry_file(
pk4_path: &Path,
entry_name: &str,
output_path: &Path,
) -> Result<Pk4ExtractionReport, Box<dyn std::error::Error>> {
let bytes = fs::read(pk4_path)?;
let (report, payload) = extract_pk4_entry_bytes(&bytes, entry_name)?;
fs::write(output_path, payload)?;
Ok(report)
}
pub fn extract_pk4_entry_bytes(
bytes: &[u8],
entry_name: &str,
) -> Result<(Pk4ExtractionReport, Vec<u8>), Box<dyn std::error::Error>> {
let inspection = inspect_pk4_bytes(bytes)?;
let (entry, case_insensitive_match) = find_entry(&inspection.entries, entry_name)
.ok_or_else(|| format!("pk4 entry not found: {entry_name}"))?;
let payload = bytes[entry.payload_absolute_offset..entry.payload_end_offset].to_vec();
let report = Pk4ExtractionReport {
matched_entry_name: entry.name.clone(),
case_insensitive_match,
extracted_len: payload.len(),
extracted_len_hex: format!("0x{:08x}", payload.len()),
entry: entry.clone(),
};
Ok((report, payload))
}
fn find_entry<'a>(entries: &'a [Pk4Entry], requested_name: &str) -> Option<(&'a Pk4Entry, bool)> {
if let Some(entry) = entries.iter().find(|entry| entry.name == requested_name) {
return Some((entry, false));
}
let requested_lower = requested_name.to_ascii_lowercase();
let mut matches = entries
.iter()
.filter(|entry| entry.name.to_ascii_lowercase() == requested_lower);
let first = matches.next()?;
if matches.next().is_some() {
return None;
}
Some((first, true))
}
fn parse_name(bytes: &[u8]) -> Result<String, Box<dyn std::error::Error>> {
let raw = bytes
.split(|byte| *byte == 0)
.next()
.ok_or("missing pk4 entry name")?;
if raw.is_empty() {
return Err("empty pk4 entry name".into());
}
Ok(String::from_utf8(raw.to_vec())?)
}
fn ascii_preview(bytes: &[u8], limit: usize) -> String {
bytes
.iter()
.take(limit)
.map(|byte| match byte {
b' '..=b'~' => *byte as char,
_ => '.',
})
.collect()
}
fn hex_preview(bytes: &[u8], limit: usize) -> String {
let mut output = String::new();
for byte in bytes.iter().take(limit) {
output.push_str(&format!("{byte:02x}"));
}
output
}
fn read_u32_le(bytes: &[u8], offset: usize) -> Option<u32> {
let slice = bytes.get(offset..offset + 4)?;
Some(u32::from_le_bytes(slice.try_into().ok()?))
}
#[cfg(test)]
mod tests {
use super::*;
fn build_entry(
crc32: u32,
payload_len: u32,
payload_offset: u32,
name: &str,
) -> [u8; PK4_DIRECTORY_ENTRY_STRIDE] {
let mut entry = [0u8; PK4_DIRECTORY_ENTRY_STRIDE];
entry[0..4].copy_from_slice(&crc32.to_le_bytes());
entry[4..8].copy_from_slice(&payload_len.to_le_bytes());
entry[8..12].copy_from_slice(&payload_offset.to_le_bytes());
let name_bytes = name.as_bytes();
entry[13..13 + name_bytes.len()].copy_from_slice(name_bytes);
entry
}
#[test]
fn inspects_synthetic_pk4_bytes() {
let mut bytes = Vec::new();
bytes.extend_from_slice(&PK4_MAGIC.to_le_bytes());
bytes.extend_from_slice(&(2u32).to_le_bytes());
bytes.extend_from_slice(&build_entry(0x11223344, 5, 0, "alpha.txt"));
bytes.extend_from_slice(&build_entry(0x55667788, 4, 5, "beta.dds"));
bytes.extend_from_slice(b"helloDDS!");
let report = inspect_pk4_bytes(&bytes).expect("pk4 inspection should succeed");
assert_eq!(report.entry_count, 2);
assert_eq!(
report.payload_base_offset,
8 + 2 * PK4_DIRECTORY_ENTRY_STRIDE
);
assert!(report.payloads_are_contiguous);
assert_eq!(report.entries[0].name, "alpha.txt");
assert_eq!(report.entries[0].payload_signature_ascii, "hello");
assert_eq!(report.entries[1].name, "beta.dds");
assert_eq!(report.entries[1].payload_signature_ascii, "DDS!");
}
#[test]
fn extracts_case_insensitive_entry_match() {
let mut bytes = Vec::new();
bytes.extend_from_slice(&PK4_MAGIC.to_le_bytes());
bytes.extend_from_slice(&(1u32).to_le_bytes());
bytes.extend_from_slice(&build_entry(0x11223344, 5, 0, "Campaign.win"));
bytes.extend_from_slice(b"HELLO");
let (report, payload) =
extract_pk4_entry_bytes(&bytes, "campaign.win").expect("pk4 extraction should succeed");
assert!(report.case_insensitive_match);
assert_eq!(report.matched_entry_name, "Campaign.win");
assert_eq!(payload, b"HELLO");
}
}