diff --git a/README.md b/README.md index da178cb..5f0ddf3 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,7 @@ msg-tool create -t | Archive Type | Feature Name | Name | Unpack | Pack | Remarks | |---|---|---|---|---|---| | `hexen-haus-arcc` | `hexen-haus-arc` | HexenHaus Arcc Archive File (.arc) | ✔️ | ❌ | | +| `hexen-haus-odio` | `hexen-haus-arc` | HexenHaus Audio Archive File (.bin) | ✔️ | ❌ | | | `hexen-haus-wag` | `hexen-haus-arc` | HexenHaus Wag Archive File (.wag) | ✔️ | ❌ | | | Image Type | Feature Name | Name | Export | Import | Export Multiple | Import Multiple | Create | Remarks | diff --git a/src/scripts/hexen_haus/archive/mod.rs b/src/scripts/hexen_haus/archive/mod.rs index 1b1352a..c23bcda 100644 --- a/src/scripts/hexen_haus/archive/mod.rs +++ b/src/scripts/hexen_haus/archive/mod.rs @@ -1,4 +1,5 @@ pub mod arcc; +pub mod odio; pub mod wag; use crate::types::ScriptType; diff --git a/src/scripts/hexen_haus/archive/odio.rs b/src/scripts/hexen_haus/archive/odio.rs new file mode 100644 index 0000000..19f7b03 --- /dev/null +++ b/src/scripts/hexen_haus/archive/odio.rs @@ -0,0 +1,418 @@ +//! HexenHaus ODIO archive (.bin) +use crate::ext::io::*; +use crate::scripts::base::*; +use crate::types::*; +use anyhow::{Result, anyhow}; +use std::io::{Read, Seek, SeekFrom}; +use std::sync::{Arc, Mutex}; + +const ODIO_SIGNATURE: &[u8; 4] = b"ODIO"; +const HEADER_CHECK_OFFSET: u64 = 0x0A; +const HEADER_CHECK_VALUE: u32 = 0xCCAE_01FF; +const INDEX_START: u64 = 0x12; +const INDEX_ENTRY_SIZE: u64 = 6; +const ENTRY_HEADER_SIZE: u64 = 0x2C; + +#[derive(Debug)] +/// HexenHaus ODIO archive builder +pub struct HexenHausOdioArchiveBuilder; + +impl HexenHausOdioArchiveBuilder { + /// Creates a new `HexenHausOdioArchiveBuilder` + pub const fn new() -> Self { + HexenHausOdioArchiveBuilder + } +} + +impl ScriptBuilder for HexenHausOdioArchiveBuilder { + fn default_encoding(&self) -> Encoding { + Encoding::Cp932 + } + + fn default_archive_encoding(&self) -> Option { + Some(Encoding::Cp932) + } + + fn build_script( + &self, + buf: Vec, + _filename: &str, + _encoding: Encoding, + archive_encoding: Encoding, + config: &ExtraConfig, + _archive: Option<&Box>, + ) -> Result> { + Ok(Box::new(HexenHausOdioArchive::new( + MemReader::new(buf), + archive_encoding, + config, + )?)) + } + + fn build_script_from_file( + &self, + filename: &str, + _encoding: Encoding, + archive_encoding: Encoding, + config: &ExtraConfig, + _archive: Option<&Box>, + ) -> Result> { + if filename == "-" { + let data = crate::utils::files::read_file(filename)?; + return Ok(Box::new(HexenHausOdioArchive::new( + MemReader::new(data), + archive_encoding, + config, + )?)); + } + let file = std::fs::File::open(filename)?; + let reader = std::io::BufReader::new(file); + Ok(Box::new(HexenHausOdioArchive::new( + reader, + archive_encoding, + config, + )?)) + } + + fn build_script_from_reader( + &self, + reader: Box, + _filename: &str, + _encoding: Encoding, + archive_encoding: Encoding, + config: &ExtraConfig, + _archive: Option<&Box>, + ) -> Result> { + Ok(Box::new(HexenHausOdioArchive::new( + reader, + archive_encoding, + config, + )?)) + } + + fn extensions(&self) -> &'static [&'static str] { + &["bin"] + } + + fn script_type(&self) -> &'static ScriptType { + &ScriptType::HexenHausOdio + } + + fn is_this_format(&self, _filename: &str, buf: &[u8], buf_len: usize) -> Option { + if buf_len >= ODIO_SIGNATURE.len() && buf.starts_with(ODIO_SIGNATURE) { + Some(10) + } else { + None + } + } + + fn is_archive(&self) -> bool { + true + } +} + +#[derive(Debug, Clone)] +struct HexenHausOdioEntry { + name: String, + offset: u64, + size: u64, +} + +#[derive(Debug)] +/// HexenHaus ODIO archive reader +pub struct HexenHausOdioArchive { + reader: Arc>, + entries: Vec, +} + +impl HexenHausOdioArchive { + /// Creates a new `HexenHausOdioArchive` + pub fn new(mut reader: T, _archive_encoding: Encoding, _config: &ExtraConfig) -> Result { + reader.seek(SeekFrom::Start(0))?; + let mut signature = [0u8; 4]; + reader.read_exact(&mut signature)?; + if signature != *ODIO_SIGNATURE { + return Err(anyhow!("Invalid HexenHaus ODIO signature")); + } + + let reserved = reader.read_u32()?; + if reserved != 0 { + return Err(anyhow!("Unexpected reserved field in ODIO header")); + } + + reader.seek(SeekFrom::Start(HEADER_CHECK_OFFSET))?; + let header_check = reader.read_u32()?; + if header_check != HEADER_CHECK_VALUE { + return Err(anyhow!("Invalid HexenHaus ODIO header check value")); + } + + let file_length = reader.seek(SeekFrom::End(0))?; + reader.seek(SeekFrom::Start(INDEX_START))?; + let first_offset = u64::from(reader.read_u32()?); + if first_offset < INDEX_START { + return Err(anyhow!("First entry offset precedes index start")); + } + if first_offset > file_length { + return Err(anyhow!("First entry offset exceeds file length")); + } + + let index_len = first_offset + .checked_sub(INDEX_START) + .ok_or_else(|| anyhow!("Invalid index length in ODIO archive"))?; + if index_len % INDEX_ENTRY_SIZE != 0 { + return Err(anyhow!("ODIO index length is not aligned")); + } + let entry_count_u64 = index_len / INDEX_ENTRY_SIZE; + let entry_count = + usize::try_from(entry_count_u64).map_err(|_| anyhow!("ODIO entry count overflow"))?; + if entry_count == 0 { + return Err(anyhow!("ODIO archive contains no entries")); + } + + let mut entries = Vec::with_capacity(entry_count); + let mut index_offset = INDEX_START; + let mut next_offset = first_offset; + + for i in 0..entry_count { + let entry_offset = next_offset; + + index_offset = index_offset + .checked_add(INDEX_ENTRY_SIZE) + .ok_or_else(|| anyhow!("Index offset overflow"))?; + + if i + 1 == entry_count { + next_offset = file_length; + } else { + if index_offset + 4 > file_length { + return Err(anyhow!("Index offset exceeds file length")); + } + reader.seek(SeekFrom::Start(index_offset))?; + next_offset = u64::from(reader.read_u32()?); + } + + if entry_offset > file_length { + return Err(anyhow!("Entry offset exceeds file length")); + } + if next_offset > file_length { + return Err(anyhow!("Entry extends beyond file length")); + } + if next_offset < entry_offset { + return Err(anyhow!("Entry offsets are out of order")); + } + + let size = next_offset - entry_offset; + if size == 0 { + continue; + } + + let name = format!("{:04}.ogg", i); + entries.push(HexenHausOdioEntry { + name, + offset: entry_offset, + size, + }); + } + + if entries.is_empty() { + return Err(anyhow!("ODIO archive contains no readable entries")); + } + + reader.seek(SeekFrom::Start(0))?; + Ok(HexenHausOdioArchive { + reader: Arc::new(Mutex::new(reader)), + entries, + }) + } +} + +impl Script for HexenHausOdioArchive { + 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> + 'a>> { + Ok(Box::new( + self.entries.iter().map(|entry| Ok(entry.name.clone())), + )) + } + + fn iter_archive_offset<'a>(&'a self) -> Result> + 'a>> { + Ok(Box::new(self.entries.iter().map(|entry| Ok(entry.offset)))) + } + + fn open_file<'a>(&'a self, index: usize) -> Result> { + if index >= self.entries.len() { + return Err(anyhow!( + "Index out of bounds: {} (total files: {})", + index, + self.entries.len() + )); + } + let entry = self.entries[index].clone(); + + let decrypt = if entry.size >= ENTRY_HEADER_SIZE { + let mut header = [0u8; 4]; + let mut guard = self + .reader + .lock() + .map_err(|e| anyhow!("Failed to lock reader: {}", e))?; + guard.seek(SeekFrom::Start(entry.offset))?; + guard.read_exact(&mut header)?; + header == *b"ONCE" + } else { + false + }; + + let (data_offset, data_size) = if decrypt { + let data_offset = entry + .offset + .checked_add(ENTRY_HEADER_SIZE) + .ok_or_else(|| anyhow!("Entry data offset overflow"))?; + let data_size = entry + .size + .checked_sub(ENTRY_HEADER_SIZE) + .ok_or_else(|| anyhow!("Entry data size underflow"))?; + (data_offset, data_size) + } else { + (entry.offset, entry.size) + }; + + Ok(Box::new(OdioEntry { + name: entry.name, + reader: self.reader.clone(), + data_offset, + data_size, + pos: 0, + decrypt, + })) + } +} + +struct OdioEntry { + name: String, + reader: Arc>, + data_offset: u64, + data_size: u64, + pos: u64, + decrypt: bool, +} + +impl ArchiveContent for OdioEntry { + fn name(&self) -> &str { + &self.name + } +} + +impl Read for OdioEntry { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let total_size = self.data_size; + if self.pos >= total_size { + return Ok(0); + } + + let remaining = total_size - self.pos; + let remaining_usize = match usize::try_from(remaining) { + Ok(value) => value, + Err(_) => usize::MAX, + }; + let to_read = remaining_usize.min(buf.len()); + if to_read == 0 { + return Ok(0); + } + + let absolute_offset = match self.data_offset.checked_add(self.pos) { + Some(offset) => offset, + None => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Read position overflow", + )); + } + }; + + let mut guard = self.reader.lock().map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to lock mutex: {}", e), + ) + })?; + guard.seek(SeekFrom::Start(absolute_offset))?; + let bytes_read = guard.read(&mut buf[..to_read])?; + drop(guard); + + if self.decrypt { + for byte in &mut buf[..bytes_read] { + *byte = byte.rotate_right(4); + } + } + + self.pos = self.pos.saturating_add(bytes_read as u64); + Ok(bytes_read) + } +} + +impl Seek for OdioEntry { + fn seek(&mut self, pos: SeekFrom) -> std::io::Result { + let new_pos = match pos { + SeekFrom::Start(offset) => offset, + SeekFrom::End(offset) => { + let size = i64::try_from(self.data_size).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Data size exceeds seek range", + ) + })?; + let target = size.checked_add(offset).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Seek from end caused overflow", + ) + })?; + if target < 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Seek from end before start", + )); + } + target as u64 + } + SeekFrom::Current(offset) => { + let current = i64::try_from(self.pos).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Current position overflow", + ) + })?; + let target = current.checked_add(offset).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Seek from current caused overflow", + ) + })?; + if target < 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Seek before start", + )); + } + target as u64 + } + }; + self.pos = new_pos; + Ok(self.pos) + } + + fn stream_position(&mut self) -> std::io::Result { + Ok(self.pos) + } +} diff --git a/src/scripts/mod.rs b/src/scripts/mod.rs index 89a787a..fdb509f 100644 --- a/src/scripts/mod.rs +++ b/src/scripts/mod.rs @@ -148,6 +148,8 @@ lazy_static::lazy_static! { Box::new(hexen_haus::archive::wag::HexenHausWagArchiveBuilder::new()), #[cfg(feature = "hexen-haus-img")] Box::new(hexen_haus::img::png::PngImageBuilder::new()), + #[cfg(feature = "hexen-haus-arc")] + Box::new(hexen_haus::archive::odio::HexenHausOdioArchiveBuilder::new()), ]; /// A list of all script extensions. pub static ref ALL_EXTS: Vec = diff --git a/src/types.rs b/src/types.rs index 844ade7..5edea77 100644 --- a/src/types.rs +++ b/src/types.rs @@ -603,6 +603,9 @@ pub enum ScriptType { /// HexenHaus Arcc archive HexenHausArcc, #[cfg(feature = "hexen-haus-arc")] + /// HexenHaus Audio archive + HexenHausOdio, + #[cfg(feature = "hexen-haus-arc")] /// HexenHaus WAG archive HexenHausWag, #[cfg(feature = "hexen-haus-img")]