313 lines
11 KiB
Rust
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");
|
|
}
|
|
}
|