diff --git a/Cargo.toml b/Cargo.toml index 1784e02..820f1dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ default = ["all-fmt", "image-jpg", "image-jxl", "image-webp", "audio-flac", "jie all-fmt = ["all-script", "all-img", "all-arc", "all-audio"] all-script = ["artemis", "artemis-panmimisoft", "bgi", "cat-system", "circus", "entis-gls", "escude", "ex-hibit", "favorite", "hexen-haus", "kirikiri", "silky", "softpal", "will-plus", "yaneurao", "yaneurao-itufuru"] all-img = ["bgi-img", "cat-system-img", "circus-img", "emote-img", "kirikiri-img", "softpal-img"] -all-arc = ["artemis-arc", "bgi-arc", "cat-system-arc", "circus-arc", "escude-arc", "ex-hibit-arc", "softpal-arc"] +all-arc = ["artemis-arc", "bgi-arc", "cat-system-arc", "circus-arc", "escude-arc", "ex-hibit-arc", "hexen-haus-arc", "softpal-arc"] all-audio = ["bgi-audio", "circus-audio"] artemis = ["stylua", "utils-escape"] artemis-panmimisoft = ["artemis", "rust-ini"] @@ -78,6 +78,7 @@ ex-hibit = [] ex-hibit-arc = ["ex-hibit"] favorite = [] hexen-haus = ["memchr", "utils-str"] +hexen-haus-arc = ["hexen-haus"] kirikiri = ["emote-psb", "fancy-regex", "flate2", "json", "lz4", "utils-escape"] kirikiri-img = ["kirikiri", "image", "libtlg-rs"] silky = [] diff --git a/README.md b/README.md index b4a3931..24d74b1 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,10 @@ msg-tool create -t | Script Type | Feature Name | Name | Export | Import | Export Multiple | Import Multiple | Custom Export | Custom Import | Create | Remarks | |---|---|---|---|---|---|---|---|---|---|---| | `hexen-haus` | `hexen-haus` | HexenHaus Script File (.bin) | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | | + +| Archive Type | Feature Name | Name | Unpack | Pack | Remarks | +|---|---|---|---|---|---| +| `hexen-haus-arcc` | `hexen-haus-arc` | HexenHaus Arcc Archive File (.arcc) | ✔️ | ❌ | | ### Kirikiri | Script Type | Feature Name | Name | Export | Import | Export Multiple | Import Multiple | Custom Export | Custom Import | Create | Remarks | |---|---|---|---|---|---|---|---|---|---|---| diff --git a/src/scripts/hexen_haus/archive/arcc.rs b/src/scripts/hexen_haus/archive/arcc.rs new file mode 100644 index 0000000..749a9e8 --- /dev/null +++ b/src/scripts/hexen_haus/archive/arcc.rs @@ -0,0 +1,340 @@ +//! HexenHaus ARCC archive (.arc) +use crate::ext::io::*; +use crate::scripts::base::*; +use crate::types::*; +use crate::utils::encoding::decode_to_string; +use anyhow::Result; +use std::io::{Read, Seek, SeekFrom}; +use std::sync::{Arc, Mutex}; + +#[derive(Debug)] +/// HexenHaus ARCC archive builder +pub struct HexenHausArccArchiveBuilder; + +impl HexenHausArccArchiveBuilder { + /// Creates a new `HexenHausArccArchiveBuilder` + pub const fn new() -> Self { + HexenHausArccArchiveBuilder + } +} + +impl ScriptBuilder for HexenHausArccArchiveBuilder { + 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(HexenHausArccArchive::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(HexenHausArccArchive::new( + MemReader::new(data), + archive_encoding, + config, + )?)); + } + let file = std::fs::File::open(filename)?; + let reader = std::io::BufReader::new(file); + Ok(Box::new(HexenHausArccArchive::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(HexenHausArccArchive::new( + reader, + archive_encoding, + config, + )?)) + } + + fn extensions(&self) -> &'static [&'static str] { + &["arc"] + } + + fn script_type(&self) -> &'static ScriptType { + &ScriptType::HexenHausArcc + } + + fn is_this_format(&self, _filename: &str, buf: &[u8], buf_len: usize) -> Option { + if buf_len >= 4 && buf.starts_with(b"ARCC") { + Some(10) + } else { + None + } + } + + fn is_archive(&self) -> bool { + true + } +} + +#[derive(Debug, Clone)] +struct HexenHausArccEntry { + name: String, + offset: u64, + size: u32, +} + +#[derive(Debug)] +/// HexenHaus ARCC archive +pub struct HexenHausArccArchive { + reader: Arc>, + entries: Vec, +} + +impl HexenHausArccArchive { + /// Creates a new `HexenHausArccArchive` + 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 != *b"ARCC" { + return Err(anyhow::anyhow!("Invalid HexenHaus ARCC signature")); + } + reader.seek(SeekFrom::Start(0))?; + let reader = Arc::new(Mutex::new(reader)); + + let file_count = reader.cpeek_u32_at(0x14)?; + let entry_count = file_count as usize; + + let mut index_offset = 0x2a_u64; + let mut tag = [0u8; 4]; + reader.cpeek_exact_at(index_offset, &mut tag)?; + if &tag != b"NAME" { + return Err(anyhow::anyhow!("Missing NAME section in ARCC archive")); + } + let addr_offset = reader.cpeek_u64_at(index_offset + 4)?; + index_offset += 0x0e; + + reader.cpeek_exact_at(index_offset, &mut tag)?; + if &tag != b"NIDX" { + return Err(anyhow::anyhow!("Missing NIDX section in ARCC archive")); + } + index_offset += 4; + for _ in 0..entry_count { + let _ = reader.cpeek_u32_at(index_offset + 2)?; + index_offset += 8; + } + + reader.cpeek_exact_at(index_offset, &mut tag)?; + if &tag != b"EIDX" { + return Err(anyhow::anyhow!("Missing EIDX section in ARCC archive")); + } + index_offset += 4 + 8 * file_count as u64; + + reader.cpeek_exact_at(index_offset, &mut tag)?; + if &tag != b"CINF" { + return Err(anyhow::anyhow!("Missing CINF section in ARCC archive")); + } + index_offset += 4; + + let mut entries = Vec::with_capacity(entry_count); + for _ in 0..entry_count { + index_offset += 6; + let name_len = reader.cpeek_u16_at(index_offset)? as usize; + let mut name_buf = vec![0u8; name_len]; + if name_len > 0 { + reader.cpeek_exact_at(index_offset + 4, &mut name_buf)?; + decrypt_name(&mut name_buf); + } + index_offset += 6 + name_len as u64; + let name = decode_to_string(archive_encoding, &name_buf, true)?; + entries.push(HexenHausArccEntry { + name, + offset: 0, + size: 0, + }); + } + + let mut addr_offset = addr_offset; + reader.cpeek_exact_at(addr_offset, &mut tag)?; + if &tag != b"ADDR" { + return Err(anyhow::anyhow!("Missing ADDR section in ARCC archive")); + } + addr_offset += 4; + for entry in &mut entries { + entry.offset = reader.cpeek_u64_at(addr_offset + 2)?; + addr_offset += 12; + } + + for entry in &mut entries { + if reader.cpeek_and_equal_at(entry.offset, b"FILE").is_err() { + continue; + } + entry.size = reader.cpeek_u32_at(entry.offset + 0x18)?; + entry.offset += 0x22; + } + + entries.retain(|entry| entry.size > 0); + if entries.is_empty() { + return Err(anyhow::anyhow!("ARCC archive contains no files")); + } + + Ok(HexenHausArccArchive { reader, entries }) + } +} + +impl Script for HexenHausArccArchive { + 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::anyhow!( + "Index out of bounds: {} (total files: {})", + index, + self.entries.len() + )); + } + let entry = &self.entries[index]; + let header = self + .reader + .cpeek_at_vec(entry.offset, (entry.size as usize).min(16))?; + Ok(Box::new(Entry { + reader: self.reader.clone(), + header: entry.clone(), + pos: 0, + typ: super::detect_script_type(&entry.name, &header), + })) + } +} + +struct Entry { + header: HexenHausArccEntry, + reader: Arc>, + pos: u64, + typ: Option, +} + +impl ArchiveContent for Entry { + fn name(&self) -> &str { + &self.header.name + } + + fn script_type(&self) -> Option<&ScriptType> { + self.typ.as_ref() + } +} + +impl Read for Entry { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let mut reader = self.reader.lock().map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to lock mutex: {}", e), + ) + })?; + reader.seek(SeekFrom::Start(self.header.offset + self.pos))?; + let bytes_read = buf.len().min(self.header.size as usize - self.pos as usize); + if bytes_read == 0 { + return Ok(0); + } + let bytes_read = reader.read(&mut buf[..bytes_read])?; + self.pos += bytes_read as u64; + Ok(bytes_read) + } +} + +impl Seek for Entry { + fn seek(&mut self, pos: SeekFrom) -> std::io::Result { + let new_pos = match pos { + SeekFrom::Start(offset) => offset as u64, + SeekFrom::End(offset) => { + if offset < 0 { + if (-offset) as u64 > self.header.size as u64 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Seek from end exceeds file length", + )); + } + self.header.size as u64 - (-offset) as u64 + } else { + self.header.size as u64 + offset as u64 + } + } + SeekFrom::Current(offset) => { + if offset < 0 { + if (-offset) as u64 > self.pos { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Seek from current exceeds current position", + )); + } + self.pos.saturating_sub((-offset) as u64) + } else { + self.pos + offset as u64 + } + } + }; + self.pos = new_pos; + Ok(self.pos) + } + + fn stream_position(&mut self) -> std::io::Result { + Ok(self.pos) + } +} + +fn decrypt_name(buf: &mut [u8]) { + for byte in buf.iter_mut() { + *byte ^= 0x69; + } +} diff --git a/src/scripts/hexen_haus/archive/mod.rs b/src/scripts/hexen_haus/archive/mod.rs new file mode 100644 index 0000000..7a17b30 --- /dev/null +++ b/src/scripts/hexen_haus/archive/mod.rs @@ -0,0 +1,10 @@ +pub mod arcc; + +use crate::types::ScriptType; + +fn detect_script_type(_filename: &str, buf: &[u8]) -> Option { + if buf.len() >= 4 && buf.starts_with(b"NORI") { + return Some(ScriptType::HexenHaus); + } + None +} diff --git a/src/scripts/hexen_haus/mod.rs b/src/scripts/hexen_haus/mod.rs index 7aba5e6..ee2c8bd 100644 --- a/src/scripts/hexen_haus/mod.rs +++ b/src/scripts/hexen_haus/mod.rs @@ -1,2 +1,4 @@ //! HexenHaus Scripts +#[cfg(feature = "hexen-haus-arc")] +pub mod archive; pub mod bin; diff --git a/src/scripts/mod.rs b/src/scripts/mod.rs index 7004979..f23f89a 100644 --- a/src/scripts/mod.rs +++ b/src/scripts/mod.rs @@ -140,6 +140,8 @@ lazy_static::lazy_static! { Box::new(softpal::img::pgd::pgd3::Pgd3Builder::new()), #[cfg(feature = "ex-hibit-arc")] Box::new(ex_hibit::arc::grp::ExHibitGrpArchiveBuilder::new()), + #[cfg(feature = "hexen-haus-arc")] + Box::new(hexen_haus::archive::arcc::HexenHausArccArchiveBuilder::new()), ]; /// A list of all script extensions. pub static ref ALL_EXTS: Vec = diff --git a/src/types.rs b/src/types.rs index d684139..6918d5f 100644 --- a/src/types.rs +++ b/src/types.rs @@ -595,6 +595,9 @@ pub enum ScriptType { #[cfg(feature = "hexen-haus")] /// HexenHaus bin script HexenHaus, + #[cfg(feature = "hexen-haus-arc")] + /// HexenHaus Arcc archive + HexenHausArcc, #[cfg(feature = "kirikiri")] #[value(alias("kr-scn"))] /// Kirikiri SCN script