diff --git a/Cargo.lock b/Cargo.lock index 37e3b75..5df9e40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1366,6 +1366,7 @@ dependencies = [ "webp", "windows-sys 0.61.2", "xml5ever", + "xp3", "zstd", ] @@ -2534,6 +2535,18 @@ dependencies = [ "markup5ever", ] +[[package]] +name = "xp3" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c728da4ef7d98958a2d42fd957e82dd96723ec9c6255ccb3e743142d556ab6" +dependencies = [ + "adler32", + "byteorder", + "encoding", + "flate2", +] + [[package]] name = "yoke" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index 288300e..efefdec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ url = { version = "2.5", optional = true } utf16string = "0.2" webp = { version = "0.3", default-features = false, optional = true } xml5ever = { version = "0.35", optional = true } +xp3 = { version = "0.3", optional = true} zstd = { version = "0.13", optional = true } [features] @@ -54,7 +55,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", "hexen-haus-img", "kirikiri-img", "softpal-img", "will-plus-img"] -all-arc = ["artemis-arc", "bgi-arc", "cat-system-arc", "circus-arc", "escude-arc", "ex-hibit-arc", "hexen-haus-arc", "softpal-arc"] +all-arc = ["artemis-arc", "bgi-arc", "cat-system-arc", "circus-arc", "escude-arc", "ex-hibit-arc", "hexen-haus-arc", "kirikiri-arc", "softpal-arc"] all-audio = ["bgi-audio", "circus-audio"] artemis = ["stylua", "utils-escape"] artemis-panmimisoft = ["artemis", "rust-ini"] @@ -81,6 +82,7 @@ hexen-haus = ["memchr", "utils-str"] hexen-haus-arc = ["hexen-haus"] hexen-haus-img = ["hexen-haus", "image"] kirikiri = ["emote-psb", "fancy-regex", "flate2", "json", "lz4", "utils-escape"] +kirikiri-arc = ["kirikiri", "xp3"] kirikiri-img = ["kirikiri", "image", "libtlg-rs"] silky = [] softpal = ["int-enum"] diff --git a/README.md b/README.md index 80e0d16..9714106 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,10 @@ msg-tool create -t | `kirikiri-tjs-ns0`/`kr-tjs-ns0` | `kirikiri` | Kirikiri TJS NS0 binary encoded script | ❌ | ❌ | ❌ | ❌ | ✔️ | ✔️ | ✔️ | | | `kirikiri-tjs2`/`kr-tjs2` | `kirikiri` | Kirikiri compiled TJS2 script | ✔️ | ✔️ | ❌ | ❌ | ✔️ | ✔️ | ❌ | | +| Archive Type | Feature Name | Name | Unpack | Pack | Remarks | +|---|---|---|---|---|---| +| `kirikiri-xp3`/`kr-xp3`/`xp3` | `kirikiri-arc` | Kirikiri XP3 Archive File (.xp3) | ✔️ | ❌ | | + | Image Type | Feature Name | Name | Export | Import | Export Multiple | Import Multiple | Create | Remarks | |---|---|---|---|---|---|---|---|---| | `kirikiri-tlg`/`kr-tlg` | `kirikiri-img` | Kirikiri TLG Image File (.tlg) | ✔️ | ✔️ | ❌ | ❌ | ✔️ | tlg6 is not supported when importing/creating image | diff --git a/src/ext/io.rs b/src/ext/io.rs index a5c33dc..57880b1 100644 --- a/src/ext/io.rs +++ b/src/ext/io.rs @@ -4,7 +4,7 @@ use crate::utils::encoding::decode_to_string; use crate::utils::struct_pack::{StructPack, StructUnpack}; use std::ffi::CString; use std::io::*; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; /// A trait to help to peek data from a reader. pub trait Peek { @@ -1999,3 +1999,82 @@ impl Result, O: Fn(u64) -> R Ok(()) } } + +/// A thread-safe wrapper around a Mutex-protected writer/reader. +#[derive(Clone)] +pub struct MutexWrapper { + inner: Arc>, + pos: u64, +} + +impl MutexWrapper { + /// Creates a new `MutexWrapper` with the given inner value. + pub fn new(inner: Arc>, pos: u64) -> Self { + MutexWrapper { inner, pos } + } +} + +impl Read for MutexWrapper { + fn read(&mut self, buf: &mut [u8]) -> Result { + let mut lock = self.inner.lock().map_err(|_| { + std::io::Error::new(std::io::ErrorKind::Other, "Failed to lock the mutex") + })?; + lock.seek(SeekFrom::Start(self.pos))?; + let readed = lock.read(buf)?; + self.pos += readed as u64; + Ok(readed) + } +} + +impl Seek for MutexWrapper { + fn seek(&mut self, pos: SeekFrom) -> Result { + let mut lock = self.inner.lock().map_err(|_| { + std::io::Error::new(std::io::ErrorKind::Other, "Failed to lock the mutex") + })?; + let new_pos = match pos { + SeekFrom::Start(offset) => offset, + SeekFrom::End(offset) => { + let len = lock.stream_length()?; + (len as i64 + offset as i64) as u64 + } + SeekFrom::Current(offset) => (self.pos as i64 + offset as i64) as u64, + }; + if new_pos > lock.stream_length()? { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Seek position is beyond the end of the stream", + )); + } + self.pos = new_pos; + Ok(self.pos) + } + + fn stream_position(&mut self) -> Result { + Ok(self.pos) + } + + fn rewind(&mut self) -> Result<()> { + self.pos = 0; + Ok(()) + } +} + +/// A writer that does nothing and always succeeds. +pub struct EmptyWriter; + +impl EmptyWriter { + /// Creates a new `EmptyWriter`. + pub fn new() -> Self { + Self {} + } +} + +impl Write for EmptyWriter { + fn write(&mut self, buf: &[u8]) -> Result { + Ok(buf.len()) + } + + fn flush(&mut self) -> Result<()> { + Ok(()) + } +} diff --git a/src/scripts/kirikiri/archive/mod.rs b/src/scripts/kirikiri/archive/mod.rs new file mode 100644 index 0000000..402fbea --- /dev/null +++ b/src/scripts/kirikiri/archive/mod.rs @@ -0,0 +1 @@ +pub mod xp3; diff --git a/src/scripts/kirikiri/archive/xp3.rs b/src/scripts/kirikiri/archive/xp3.rs new file mode 100644 index 0000000..5d1c918 --- /dev/null +++ b/src/scripts/kirikiri/archive/xp3.rs @@ -0,0 +1,233 @@ +use crate::ext::io::*; +use crate::scripts::base::*; +use crate::types::*; +use anyhow::Result; +use flate2::read::ZlibDecoder; +use std::io::{Read, Seek, Take}; +use std::sync::{Arc, Mutex}; +use xp3::XP3Reader; +use xp3::index::file::{IndexSegmentFlag, XP3FileIndex}; + +#[derive(Debug)] +/// Builder for Kirikiri XP3 Archive +pub struct Xp3ArchiveBuilder {} + +impl Xp3ArchiveBuilder { + /// Create a new Kirikiri XP3 Archive Builder + pub fn new() -> Self { + Self {} + } +} + +impl ScriptBuilder for Xp3ArchiveBuilder { + fn default_encoding(&self) -> Encoding { + Encoding::Utf8 + } + + fn default_archive_encoding(&self) -> Option { + Some(Encoding::Utf8) + } + + fn build_script( + &self, + buf: Vec, + _filename: &str, + _encoding: Encoding, + _archive_encoding: Encoding, + config: &ExtraConfig, + _archive: Option<&Box>, + ) -> Result> { + Ok(Box::new(Xp3Archive::new(MemReader::new(buf), config)?)) + } + + fn build_script_from_file( + &self, + filename: &str, + _encoding: Encoding, + _archive_encoding: Encoding, + config: &ExtraConfig, + _archive: Option<&Box>, + ) -> Result> { + let file = std::fs::File::open(filename)?; + Ok(Box::new(Xp3Archive::new(file, 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(Xp3Archive::new(reader, config)?)) + } + + fn extensions(&self) -> &'static [&'static str] { + &["xp3"] + } + + fn script_type(&self) -> &'static ScriptType { + &ScriptType::KirikiriXp3 + } + + fn is_archive(&self) -> bool { + true + } +} + +#[derive(Debug)] +/// Kirikiri XP3 Archive +pub struct Xp3Archive { + reader: Arc>, + entries: Vec<(String, XP3FileIndex)>, +} + +impl Xp3Archive { + /// Create a new Kirikiri XP3 Archive + pub fn new(reader: T, _config: &ExtraConfig) -> Result { + let xp3_reader = XP3Reader::open_archive(reader) + .map_err(|e| anyhow::anyhow!("Failed to open XP3 archive: {:?}", e))?; + let entries = xp3_reader + .entries() + .filter_map(|(i, d)| { + // Skip garbage files + if i.find("$$$ This is a protected archive. $$$").is_some() + || (i.to_lowercase().ends_with(".nene") && d.info().file_size() == 0) + { + None + } else { + Some((i.clone(), d.clone())) + } + }) + .collect(); + Ok(Self { + reader: Arc::new(Mutex::new(xp3_reader.close().1)), + entries, + }) + } +} + +impl Script for Xp3Archive { + 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.0.clone())), + )) + } + + fn open_file<'a>(&'a self, index: usize) -> Result> { + let index = self + .entries + .iter() + .nth(index) + .ok_or(anyhow::anyhow!("Index out of bounds: {}", index))? + .1 + .clone(); + let entry = Entry::new(self.reader.clone(), index); + Ok(Box::new(entry)) + } +} + +struct Entry { + reader: Arc>, + index: XP3FileIndex, + cache: Option>>>, + pos: u64, + entries_pos: Vec, +} + +impl Entry { + fn new(reader: Arc>, index: XP3FileIndex) -> Self { + let mut pos = 0; + let entries_pos = index + .segments() + .iter() + .map(|seg| { + let p = pos; + pos += seg.original_size(); + p + }) + .collect(); + Self { + reader, + index, + cache: None, + pos: 0, + entries_pos, + } + } +} + +impl ArchiveContent for Entry { + fn name(&self) -> &str { + &self.index.info().name() + } +} + +impl Read for Entry { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if self.pos >= self.index.info().file_size() { + self.cache.take(); + return Ok(0); + } + if let Some(cache) = self.cache.as_mut() { + let readed = cache.read(buf)?; + if readed > 0 { + self.pos += readed as u64; + return Ok(readed); + } + self.cache.take(); + } + let seg_index = match self.entries_pos.binary_search(&self.pos) { + Ok(i) => i, + Err(i) => { + if i == 0 { + 0 + } else { + i - 1 + } + } + }; + let seg = &self.index.segments()[seg_index]; + let start_pos = seg.data_offset(); + let seg_pos = self.entries_pos[seg_index]; + let skip_pos = self.pos - seg_pos; + let read_size = seg.saved_size(); + match seg.flag() { + IndexSegmentFlag::UnCompressed => { + let mut lock = MutexWrapper::new(self.reader.clone(), start_pos + skip_pos); + let readed = (&mut lock).take(read_size - skip_pos).read(buf)?; + self.pos += readed as u64; + Ok(readed) + } + IndexSegmentFlag::Compressed => { + let mut cache = ZlibDecoder::new( + MutexWrapper::new(self.reader.clone(), start_pos).take(read_size), + ); + if skip_pos != 0 { + let mut e = EmptyWriter::new(); + std::io::copy(&mut (&mut cache).take(skip_pos), &mut e)?; // skip + } + let readed = cache.read(buf)?; + self.pos += readed as u64; + self.cache = Some(cache); + Ok(readed) + } + } + } +} diff --git a/src/scripts/kirikiri/mod.rs b/src/scripts/kirikiri/mod.rs index 294ef3a..f5e7970 100644 --- a/src/scripts/kirikiri/mod.rs +++ b/src/scripts/kirikiri/mod.rs @@ -1,4 +1,6 @@ //! Kirikiri Scripts +#[cfg(feature = "kirikiri-arc")] +pub mod archive; #[cfg(feature = "kirikiri-img")] pub mod image; pub mod ks; diff --git a/src/scripts/mod.rs b/src/scripts/mod.rs index 47eaa8a..f107500 100644 --- a/src/scripts/mod.rs +++ b/src/scripts/mod.rs @@ -154,6 +154,8 @@ lazy_static::lazy_static! { Box::new(will_plus::img::wip::WillPlusWipImageBuilder::new()), #[cfg(feature = "artemis")] Box::new(artemis::txt::ArtemisTxtBuilder::new()), + #[cfg(feature = "kirikiri-arc")] + Box::new(kirikiri::archive::xp3::Xp3ArchiveBuilder::new()), ]; /// A list of all script extensions. pub static ref ALL_EXTS: Vec = diff --git a/src/types.rs b/src/types.rs index 87fe59e..6084f67 100644 --- a/src/types.rs +++ b/src/types.rs @@ -626,6 +626,10 @@ pub enum ScriptType { #[value(alias = "kr", alias = "kr-ks", alias = "kirikiri-ks")] /// Kirikiri script Kirikiri, + #[cfg(feature = "kirikiri-arc")] + #[value(alias = "kr-xp3", alias = "xp3")] + /// Kirikiri XP3 archive + KirikiriXp3, #[cfg(feature = "kirikiri-img")] #[value(alias("kr-tlg"))] /// Kirikiri TLG image