mirror of
https://github.com/lifegpc/msg-tool.git
synced 2026-06-08 13:58:50 +08:00
524 lines
16 KiB
Rust
524 lines
16 KiB
Rust
//! ExHibit GRP archive extractor.
|
|
use crate::ext::io::*;
|
|
use crate::scripts::base::*;
|
|
use crate::types::*;
|
|
use anyhow::{Context, Result};
|
|
use std::fmt::Debug;
|
|
use std::io::{Read, Seek, SeekFrom};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
#[derive(Debug)]
|
|
/// Builder for ExHibit GRP archives.
|
|
pub struct ExHibitGrpArchiveBuilder {}
|
|
|
|
impl ExHibitGrpArchiveBuilder {
|
|
/// Creates a new builder instance.
|
|
pub const fn new() -> Self {
|
|
Self {}
|
|
}
|
|
}
|
|
|
|
impl ScriptBuilder for ExHibitGrpArchiveBuilder {
|
|
fn default_encoding(&self) -> Encoding {
|
|
Encoding::Cp932
|
|
}
|
|
|
|
fn default_archive_encoding(&self) -> Option<Encoding> {
|
|
Some(Encoding::Cp932)
|
|
}
|
|
|
|
fn build_script(
|
|
&self,
|
|
data: Vec<u8>,
|
|
filename: &str,
|
|
_encoding: Encoding,
|
|
archive_encoding: Encoding,
|
|
config: &ExtraConfig,
|
|
_archive: Option<&Box<dyn Script>>,
|
|
) -> Result<Box<dyn Script + Send + Sync>> {
|
|
Ok(Box::new(ExHibitGrpArchive::new(
|
|
MemReader::new(data),
|
|
filename,
|
|
archive_encoding,
|
|
config,
|
|
)?))
|
|
}
|
|
|
|
fn build_script_from_file(
|
|
&self,
|
|
filename: &str,
|
|
_encoding: Encoding,
|
|
archive_encoding: Encoding,
|
|
config: &ExtraConfig,
|
|
_archive: Option<&Box<dyn Script>>,
|
|
) -> Result<Box<dyn Script + Send + Sync>> {
|
|
if filename == "-" {
|
|
return Err(anyhow::anyhow!(
|
|
"Reading ExHibit GRP from stdin is not supported; provide a file path."
|
|
));
|
|
}
|
|
let file = std::fs::File::open(filename)
|
|
.with_context(|| format!("Failed to open '{}'.", filename))?;
|
|
let reader = std::io::BufReader::new(file);
|
|
Ok(Box::new(ExHibitGrpArchive::new(
|
|
reader,
|
|
filename,
|
|
archive_encoding,
|
|
config,
|
|
)?))
|
|
}
|
|
|
|
fn build_script_from_reader<'a>(
|
|
&self,
|
|
reader: Box<dyn ReadSeek + Send + Sync + 'a>,
|
|
filename: &str,
|
|
_encoding: Encoding,
|
|
archive_encoding: Encoding,
|
|
config: &ExtraConfig,
|
|
_archive: Option<&Box<dyn Script>>,
|
|
) -> Result<Box<dyn Script + Send + Sync + 'a>> {
|
|
Ok(Box::new(ExHibitGrpArchive::new(
|
|
reader,
|
|
filename,
|
|
archive_encoding,
|
|
config,
|
|
)?))
|
|
}
|
|
|
|
fn extensions(&self) -> &'static [&'static str] {
|
|
&["grp"]
|
|
}
|
|
|
|
fn script_type(&self) -> &'static ScriptType {
|
|
&ScriptType::ExHibitGrp
|
|
}
|
|
|
|
fn is_this_format(&self, filename: &str, buf: &[u8], buf_len: usize) -> Option<u8> {
|
|
if !matches_grp_name(filename) {
|
|
return None;
|
|
}
|
|
if buf_len >= 4 && buf.starts_with(b"AiFS") {
|
|
return None;
|
|
}
|
|
Some(10)
|
|
}
|
|
|
|
fn is_archive(&self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct GrpFileEntry {
|
|
name: String,
|
|
offset: u64,
|
|
size: u64,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
/// ExHibit GRP archive instance.
|
|
pub struct ExHibitGrpArchive<'b, T: Read + Seek + Debug + 'b> {
|
|
reader: Arc<Mutex<T>>,
|
|
entries: Vec<GrpFileEntry>,
|
|
_mark: std::marker::PhantomData<&'b ()>,
|
|
}
|
|
|
|
impl<'b, T: Read + Seek + Debug + 'b> ExHibitGrpArchive<'b, T> {
|
|
fn new(
|
|
mut reader: T,
|
|
filename: &str,
|
|
_archive_encoding: Encoding,
|
|
_config: &ExtraConfig,
|
|
) -> Result<Self> {
|
|
let mut header = [0u8; 4];
|
|
reader
|
|
.peek_exact_at(0, &mut header)
|
|
.context("Failed to read GRP header.")?;
|
|
if &header == b"AiFS" {
|
|
return Err(anyhow::anyhow!(
|
|
"Input file is a TOC (AiFS) rather than an archive."
|
|
));
|
|
}
|
|
|
|
let path = Path::new(filename);
|
|
let (toc_path, arc_index) = locate_toc_file(path).context("Failed to locate TOC file.")?;
|
|
|
|
let archive_size = (&mut reader)
|
|
.stream_length()
|
|
.context("Failed to determine archive size.")?;
|
|
|
|
let entries = parse_toc_entries(&toc_path, arc_index, archive_size)
|
|
.with_context(|| format!("Failed to parse TOC '{}'.", toc_path.display()))?;
|
|
|
|
Ok(Self {
|
|
reader: Arc::new(Mutex::new(reader)),
|
|
entries,
|
|
_mark: std::marker::PhantomData,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl<'b, T: Read + Seek + Debug + 'b> Script for ExHibitGrpArchive<'b, T> {
|
|
fn default_output_script_type(&self) -> OutputScriptType {
|
|
OutputScriptType::Json
|
|
}
|
|
|
|
fn default_format_type(&self) -> FormatOptions {
|
|
FormatOptions::None
|
|
}
|
|
|
|
fn is_archive(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
fn iter_archive_filename<'a>(
|
|
&'a self,
|
|
) -> Result<Box<dyn Iterator<Item = Result<String>> + 'a>> {
|
|
Ok(Box::new(
|
|
self.entries.iter().map(|entry| Ok(entry.name.clone())),
|
|
))
|
|
}
|
|
|
|
fn iter_archive_offset<'a>(&'a self) -> Result<Box<dyn Iterator<Item = Result<u64>> + 'a>> {
|
|
Ok(Box::new(self.entries.iter().map(|entry| Ok(entry.offset))))
|
|
}
|
|
|
|
fn open_file<'a>(&'a self, index: usize) -> Result<Box<dyn ArchiveContent + 'a>> {
|
|
if index >= self.entries.len() {
|
|
return Err(anyhow::anyhow!(
|
|
"Index out of bounds: {} (max: {}).",
|
|
index,
|
|
self.entries.len()
|
|
));
|
|
}
|
|
let entry = self.entries[index].clone();
|
|
Ok(Box::new(GrpEntry::new(entry, self.reader.clone())))
|
|
}
|
|
}
|
|
|
|
struct GrpEntry<T: Read + Seek> {
|
|
info: GrpFileEntry,
|
|
reader: Arc<Mutex<T>>,
|
|
pos: u64,
|
|
}
|
|
|
|
impl<T: Read + Seek> GrpEntry<T> {
|
|
fn new(info: GrpFileEntry, reader: Arc<Mutex<T>>) -> Self {
|
|
Self {
|
|
info,
|
|
reader,
|
|
pos: 0,
|
|
}
|
|
}
|
|
|
|
fn remaining(&self) -> u64 {
|
|
self.info.size.saturating_sub(self.pos)
|
|
}
|
|
}
|
|
|
|
impl<T: Read + Seek> ArchiveContent for GrpEntry<T> {
|
|
fn name(&self) -> &str {
|
|
&self.info.name
|
|
}
|
|
}
|
|
|
|
impl<T: Read + Seek> Read for GrpEntry<T> {
|
|
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
|
if buf.is_empty() || self.pos >= self.info.size {
|
|
return Ok(0);
|
|
}
|
|
let remaining = self.remaining() as usize;
|
|
if remaining == 0 {
|
|
return Ok(0);
|
|
}
|
|
let to_read = buf.len().min(remaining);
|
|
let mut reader = self.reader.lock().map_err(|e| {
|
|
std::io::Error::new(
|
|
std::io::ErrorKind::Other,
|
|
format!("Failed to lock reader mutex: {}", e),
|
|
)
|
|
})?;
|
|
reader.seek(SeekFrom::Start(self.info.offset + self.pos))?;
|
|
let bytes = reader.read(&mut buf[..to_read])?;
|
|
self.pos = self.pos.checked_add(bytes as u64).ok_or_else(|| {
|
|
std::io::Error::new(std::io::ErrorKind::Other, "Read position overflow.")
|
|
})?;
|
|
Ok(bytes)
|
|
}
|
|
}
|
|
|
|
impl<T: Read + Seek> Seek for GrpEntry<T> {
|
|
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
|
|
let new_pos = match pos {
|
|
SeekFrom::Start(offset) => offset,
|
|
SeekFrom::End(offset) => {
|
|
let signed = self.info.size as i128 + offset as i128;
|
|
if signed < 0 {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidInput,
|
|
"Seek before entry start is not allowed.",
|
|
));
|
|
}
|
|
signed as u64
|
|
}
|
|
SeekFrom::Current(offset) => {
|
|
let signed = self.pos as i128 + offset as i128;
|
|
if signed < 0 {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidInput,
|
|
"Seek before entry start is not allowed.",
|
|
));
|
|
}
|
|
signed as u64
|
|
}
|
|
};
|
|
if new_pos > self.info.size {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidInput,
|
|
"Seek beyond entry size is not allowed.",
|
|
));
|
|
}
|
|
self.pos = new_pos;
|
|
Ok(self.pos)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct NameInfo {
|
|
digits_offset: usize,
|
|
digits_len: usize,
|
|
arc_num: u32,
|
|
}
|
|
|
|
fn matches_grp_name(filename: &str) -> bool {
|
|
Path::new(filename)
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.and_then(|name| parse_name_info(name).ok())
|
|
.is_some()
|
|
}
|
|
|
|
fn parse_name_info(name: &str) -> Result<NameInfo> {
|
|
if name.len() < 7 {
|
|
return Err(anyhow::anyhow!(
|
|
"Filename '{}' is too short for GRP pattern.",
|
|
name
|
|
));
|
|
}
|
|
let name = name.as_bytes();
|
|
let prefix = &name[..3];
|
|
if !prefix.eq_ignore_ascii_case(b"res") {
|
|
return Err(anyhow::anyhow!(
|
|
"Filename '{:#?}' does not start with 'res'.",
|
|
name
|
|
));
|
|
}
|
|
let suffix = &name[name.len() - 4..];
|
|
if !suffix.eq_ignore_ascii_case(b".grp") {
|
|
return Err(anyhow::anyhow!(
|
|
"Filename '{:#?}' does not end with '.grp'.",
|
|
name
|
|
));
|
|
}
|
|
let digits = &name[3..name.len() - 4];
|
|
if digits.is_empty() || !digits.iter().all(|c| c.is_ascii_digit()) {
|
|
return Err(anyhow::anyhow!(
|
|
"Filename '{:#?}' does not contain a numeric sequence.",
|
|
name
|
|
));
|
|
}
|
|
let arc_num = std::str::from_utf8(digits)
|
|
.with_context(|| {
|
|
format!(
|
|
"Failed to parse archive number from '{:#?}' (digits '{:#?}').",
|
|
name, digits
|
|
)
|
|
})?
|
|
.parse::<u32>()
|
|
.with_context(|| {
|
|
format!(
|
|
"Failed to parse archive number from '{:#?}' (digits '{:#?}').",
|
|
name, digits
|
|
)
|
|
})?;
|
|
Ok(NameInfo {
|
|
digits_offset: 3,
|
|
digits_len: digits.len(),
|
|
arc_num,
|
|
})
|
|
}
|
|
|
|
fn locate_toc_file(path: &Path) -> Result<(PathBuf, u32)> {
|
|
let file_name = path
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Filename contains invalid UTF-8."))?;
|
|
let info = parse_name_info(file_name)?;
|
|
if info.arc_num == 0 {
|
|
return Err(anyhow::anyhow!(
|
|
"Archive '{}' has number 0 and therefore no preceding TOC file.",
|
|
file_name
|
|
));
|
|
}
|
|
|
|
let mut toc_num = info.arc_num as i64 - 1;
|
|
let mut arc_index: u32 = 1;
|
|
while toc_num >= 0 {
|
|
let digits = format!("{:0width$}", toc_num, width = info.digits_len);
|
|
let mut candidate = String::with_capacity(file_name.len());
|
|
candidate.push_str(&file_name[..info.digits_offset]);
|
|
candidate.push_str(&digits);
|
|
candidate.push_str(&file_name[info.digits_offset + info.digits_len..]);
|
|
let candidate_path = path.with_file_name(&candidate);
|
|
if !candidate_path.exists() {
|
|
return Err(anyhow::anyhow!(
|
|
"TOC file '{}' does not exist.",
|
|
candidate_path.display()
|
|
));
|
|
}
|
|
let mut file = std::fs::File::open(&candidate_path).with_context(|| {
|
|
format!(
|
|
"Failed to open TOC candidate '{}'.",
|
|
candidate_path.display()
|
|
)
|
|
})?;
|
|
let mut header = [0u8; 4];
|
|
file.read_exact(&mut header).with_context(|| {
|
|
format!("Failed to read header from '{}'.", candidate_path.display())
|
|
})?;
|
|
if &header == b"AiFS" {
|
|
return Ok((candidate_path, arc_index));
|
|
}
|
|
toc_num -= 1;
|
|
arc_index = arc_index
|
|
.checked_add(1)
|
|
.ok_or_else(|| anyhow::anyhow!("Archive index overflow while searching TOC."))?;
|
|
}
|
|
|
|
Err(anyhow::anyhow!(
|
|
"Unable to locate a TOC (AiFS) file for '{}'.",
|
|
file_name
|
|
))
|
|
}
|
|
|
|
fn parse_toc_entries(
|
|
toc_path: &Path,
|
|
arc_index: u32,
|
|
archive_size: u64,
|
|
) -> Result<Vec<GrpFileEntry>> {
|
|
let file = std::fs::File::open(toc_path)?;
|
|
let mut reader = std::io::BufReader::new(file);
|
|
let toc_len = reader.stream_length()?;
|
|
if toc_len < 0x10 {
|
|
return Err(anyhow::anyhow!("TOC file is too small."));
|
|
}
|
|
|
|
reader.seek(SeekFrom::Start(0xC))?;
|
|
let res_count = reader.read_i32()?;
|
|
if res_count <= 0 {
|
|
return Err(anyhow::anyhow!("TOC resource count is invalid."));
|
|
}
|
|
if arc_index as i64 > res_count as i64 {
|
|
return Err(anyhow::anyhow!(
|
|
"Archive index {} is out of range (resource count {}).",
|
|
arc_index,
|
|
res_count
|
|
));
|
|
}
|
|
|
|
let mut index_offset = 0x10u64;
|
|
let mut arc_offset = None;
|
|
for _ in 0..res_count {
|
|
if index_offset + 0x10 > toc_len {
|
|
break;
|
|
}
|
|
reader.seek(SeekFrom::Start(index_offset))?;
|
|
let mut num = reader.read_i32()?;
|
|
if num == 0x0100_0000 {
|
|
index_offset = index_offset
|
|
.checked_add(4)
|
|
.ok_or_else(|| anyhow::anyhow!("Index offset overflow."))?;
|
|
if index_offset + 4 > toc_len {
|
|
break;
|
|
}
|
|
reader.seek(SeekFrom::Start(index_offset))?;
|
|
num = reader.read_i32()?;
|
|
}
|
|
reader.seek(SeekFrom::Start(index_offset + 0xC))?;
|
|
let entry_count = reader.read_u32()?;
|
|
if num == arc_index as i32 {
|
|
arc_offset = Some(index_offset);
|
|
break;
|
|
}
|
|
let step = (entry_count as u64)
|
|
.checked_mul(8)
|
|
.and_then(|v| v.checked_add(0x10))
|
|
.ok_or_else(|| anyhow::anyhow!("Index offset overflow while skipping entries."))?;
|
|
index_offset = index_offset
|
|
.checked_add(step)
|
|
.ok_or_else(|| anyhow::anyhow!("Index offset overflow while iterating."))?;
|
|
}
|
|
|
|
let arc_offset =
|
|
arc_offset.ok_or_else(|| anyhow::anyhow!("Archive reference not found in TOC."))?;
|
|
|
|
reader.seek(SeekFrom::Start(arc_offset + 4))?;
|
|
let start_index = reader.read_i32()?;
|
|
if start_index < 0 {
|
|
return Err(anyhow::anyhow!("Start index is negative."));
|
|
}
|
|
reader.seek(SeekFrom::Start(arc_offset + 0xC))?;
|
|
let entry_count = reader.read_i32()?;
|
|
if entry_count < 0 {
|
|
return Err(anyhow::anyhow!("Entry count is negative."));
|
|
}
|
|
let entry_count = entry_count as u32;
|
|
|
|
let data_offset = arc_offset
|
|
.checked_add(0x10)
|
|
.ok_or_else(|| anyhow::anyhow!("Entry table offset overflow."))?;
|
|
let table_len = (entry_count as u64)
|
|
.checked_mul(8)
|
|
.ok_or_else(|| anyhow::anyhow!("Entry table size overflow."))?;
|
|
if data_offset + table_len > toc_len {
|
|
return Err(anyhow::anyhow!("TOC entry table exceeds file size."));
|
|
}
|
|
|
|
let mut entries = Vec::with_capacity(entry_count as usize);
|
|
let mut entry_offset = data_offset;
|
|
for i in 0..entry_count {
|
|
reader.seek(SeekFrom::Start(entry_offset))?;
|
|
let offset = reader.read_u32()? as u64;
|
|
let size = reader.read_u32()? as u64;
|
|
if size != 0 {
|
|
let end = offset
|
|
.checked_add(size)
|
|
.ok_or_else(|| anyhow::anyhow!("Entry size overflow."))?;
|
|
if end > archive_size {
|
|
return Err(anyhow::anyhow!(
|
|
"Entry {} exceeds archive size (offset {}, size {}).",
|
|
i,
|
|
offset,
|
|
size
|
|
));
|
|
}
|
|
let index = (start_index as u32)
|
|
.checked_add(i)
|
|
.ok_or_else(|| anyhow::anyhow!("Entry index overflow."))?;
|
|
entries.push(GrpFileEntry {
|
|
name: format!("{:05}.ogg", index),
|
|
offset,
|
|
size,
|
|
});
|
|
}
|
|
entry_offset += 8;
|
|
}
|
|
|
|
if entries.is_empty() {
|
|
return Err(anyhow::anyhow!("Archive contains no entries."));
|
|
}
|
|
|
|
Ok(entries)
|
|
}
|