From 323a31236240c7573575fe2a8380fb8fe7f6eb7d Mon Sep 17 00:00:00 2001 From: lifegpc Date: Mon, 6 Apr 2026 10:43:13 +0800 Subject: [PATCH] Add support to unpack Fate/stay night xp3 files --- Cargo.toml | 2 +- src/args.rs | 27 +++ src/main.rs | 2 + src/scripts/kirikiri/archive/xp3/archive.rs | 6 + src/scripts/kirikiri/archive/xp3/crypt.json | 5 + src/scripts/kirikiri/archive/xp3/crypt.rs | 217 +++++++++++++++++++- src/scripts/kirikiri/archive/xp3/mod.rs | 150 +++++++++++++- src/scripts/kirikiri/archive/xp3/read.rs | 12 +- src/types.rs | 3 + 9 files changed, 412 insertions(+), 12 deletions(-) create mode 100644 src/scripts/kirikiri/archive/xp3/crypt.json diff --git a/Cargo.toml b/Cargo.toml index 82411ea..c5dda3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,7 +92,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", "adler", "fastcdc", "flate2", "parse-size", "sha2", "zopfli", "zstd"] +kirikiri-arc = ["kirikiri", "adler", "fastcdc", "flate2", "include-flate", "parse-size", "sha2", "zopfli", "zstd"] kirikiri-img = ["kirikiri", "image", "libtlg-rs"] musica = [] musica-arc = ["musica", "crc32fast", "flate2", "include-flate", "utils-blowfish", "utils-rc4", "utils-serde-base64bytes", "utils-xored-stream"] diff --git a/src/args.rs b/src/args.rs index a677cc7..9050e5a 100644 --- a/src/args.rs +++ b/src/args.rs @@ -94,6 +94,29 @@ pub fn get_musica_game_title_value_parser() -> Vec .collect() } +#[cfg(feature = "kirikiri-arc")] +pub fn get_xp3_game_title_value_parser() -> Vec { + crate::scripts::kirikiri::archive::xp3::get_supported_games_with_title() + .iter() + .map(|(name, title)| { + let mut pv = clap::builder::PossibleValue::new(*name); + if let Some(t) = title { + pv = pv.help(t); + let mut alias_count = 0usize; + for i in t.split("|") { + pv = pv.alias(i.trim()); + alias_count += 1; + } + // alias for full title + if alias_count > 1 { + pv = pv.alias(t); + } + } + pv + }) + .collect() +} + /// Tools for export and import scripts #[derive(Parser, Debug, Clone)] #[clap( @@ -696,6 +719,10 @@ pub struct Arg { )] /// A list of Artemis ASB script end tags, used to determine a dialogue block in script. pub artemis_asb_end_tags: Vec, + #[cfg(feature = "kirikiri-arc")] + #[arg(long, global = true, value_parser = get_xp3_game_title_value_parser())] + /// Game title for Kirikiri XP3 archive. This is used to decrypt file in archives. + pub xp3_game_title: Option, #[command(subcommand)] /// Command pub command: Command, diff --git a/src/main.rs b/src/main.rs index 3087ff3..c50a382 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3391,6 +3391,8 @@ fn main() { artemis_asb_end_tags: std::sync::Arc::new(std::collections::HashSet::from_iter( arg.artemis_asb_end_tags.iter().cloned(), )), + #[cfg(feature = "kirikiri-arc")] + xp3_game_title: arg.xp3_game_title.clone(), }); match &arg.command { args::Command::Export { input, output } => { diff --git a/src/scripts/kirikiri/archive/xp3/archive.rs b/src/scripts/kirikiri/archive/xp3/archive.rs index 1545471..873c998 100644 --- a/src/scripts/kirikiri/archive/xp3/archive.rs +++ b/src/scripts/kirikiri/archive/xp3/archive.rs @@ -38,6 +38,12 @@ pub struct Xp3Entry { pub extras: Vec, } +impl Xp3Entry { + pub fn is_encrypted(&self) -> bool { + self.flags != 0 + } +} + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct ExtraProp { pub tag: [u8; 4], diff --git a/src/scripts/kirikiri/archive/xp3/crypt.json b/src/scripts/kirikiri/archive/xp3/crypt.json new file mode 100644 index 0000000..b8b9172 --- /dev/null +++ b/src/scripts/kirikiri/archive/xp3/crypt.json @@ -0,0 +1,5 @@ +{ + "Fate/stay night": { + "$type": "FateCrypt" + } +} diff --git a/src/scripts/kirikiri/archive/xp3/crypt.rs b/src/scripts/kirikiri/archive/xp3/crypt.rs index 91bbf78..bb84029 100644 --- a/src/scripts/kirikiri/archive/xp3/crypt.rs +++ b/src/scripts/kirikiri/archive/xp3/crypt.rs @@ -1,9 +1,12 @@ use super::archive::*; use crate::ext::io::*; +use crate::scripts::base::*; use crate::types::*; use crate::utils::encoding::*; use anyhow::Result; -use std::io::Read; +use serde::Deserialize; +use std::collections::{BTreeMap, HashMap}; +use std::io::{Read, Seek, SeekFrom}; pub trait Crypt: std::fmt::Debug { /// Initializes the cryptographic context for the archive. @@ -20,6 +23,109 @@ pub trait Crypt: std::fmt::Debug { name_length as u64 * 2 + 2, )) } + + /// Decrypts the given stream of data for the specified entry and segment. + fn decrypt<'a>( + &self, + _entry: &Xp3Entry, + _cur_seg: &Segment, + _stream: Box, + ) -> Result> { + Err(anyhow::anyhow!("This crypt does not support decrypt")) + } + + /// Decrypts the given stream of data for the specified entry and segment, with seek support. + fn decrypt_with_seek<'a>( + &self, + _entry: &Xp3Entry, + _cur_seg: &Segment, + _stream: Box, + ) -> Result> { + Err(anyhow::anyhow!( + "This crypt does not support decrypt with seek" + )) + } + + /// Returns true if this crypt support decrypt + fn decrypt_supported(&self) -> bool { + false + } + + /// Returns true if this crypt support seek when decrypting + fn decrypt_seek_supported(&self) -> bool { + false + } +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "PascalCase", tag = "$type")] +enum CryptType { + NoCrypt, + FateCrypt, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct Schema { + #[serde(flatten)] + crypt: CryptType, + title: Option, +} + +impl Schema { + pub fn create_crypt(&self) -> Box { + match self.crypt { + CryptType::NoCrypt => Box::new(NoCrypt::new()), + CryptType::FateCrypt => Box::new(FateCrypt::new()), + } + } +} + +include_flate::flate!(static CRYPT_DATA: str from "src/scripts/kirikiri/archive/xp3/crypt.json" with zstd); + +lazy_static::lazy_static! { + static ref CRYPT_SCHEMA: BTreeMap = { + serde_json::from_str(&CRYPT_DATA).expect("Failed to parse crypt.json") + }; + static ref ALIAS_TABLE: HashMap = { + let mut table = HashMap::new(); + for (game, fulltitle) in get_supported_games_with_title() { + if let Some(title) = fulltitle { + let mut alias_count = 0usize; + for part in title.split("|") { + let alias = part.trim(); + table.insert(alias.to_string(), game.to_string()); + alias_count += 1; + } + // also insert full title if there are multiple aliases + if alias_count > 1 { + table.insert(title.to_string(), game.to_string()); + } + } + } + table + }; +} + +/// Get the supported game titles for encrypted xp3 archives. +pub fn get_supported_games() -> Vec<&'static str> { + CRYPT_SCHEMA.keys().map(|s| s.as_str()).collect() +} + +/// Get the supported game titles for encrypted xp3 archives with their full titles. +pub fn get_supported_games_with_title() -> Vec<(&'static str, Option<&'static str>)> { + CRYPT_SCHEMA + .iter() + .map(|(k, v)| (k.as_str(), v.title.as_deref())) + .collect() +} + +pub fn query_crypt_schema(game: &str) -> Option<&'static Schema> { + CRYPT_SCHEMA.get(game).or_else(|| { + ALIAS_TABLE + .get(game) + .and_then(|real_game| CRYPT_SCHEMA.get(real_game)) + }) } #[derive(Debug)] @@ -32,3 +138,112 @@ impl NoCrypt { } impl Crypt for NoCrypt {} + +#[derive(Debug)] +pub struct FateCrypt {} + +impl FateCrypt { + pub fn new() -> Self { + Self {} + } +} + +impl Crypt for FateCrypt { + fn decrypt_supported(&self) -> bool { + true + } + + fn decrypt_seek_supported(&self) -> bool { + true + } + + fn decrypt<'a>( + &self, + _entry: &Xp3Entry, + cur_seg: &Segment, + stream: Box, + ) -> Result> { + Ok(Box::new(FateCryptReader::new(stream, cur_seg))) + } + + fn decrypt_with_seek<'a>( + &self, + _entry: &Xp3Entry, + cur_seg: &Segment, + stream: Box, + ) -> Result> { + Ok(Box::new(FateCryptReader::new(stream, cur_seg))) + } +} + +struct FateCryptReader { + inner: R, + /// Start offset of the current xp3 entry. + seg_start: u64, + seg_size: u64, + pos: u64, +} + +impl FateCryptReader { + pub fn new(inner: T, seg: &Segment) -> Self { + Self { + inner, + seg_start: seg.offset_in_file, + seg_size: seg.original_size, + pos: 0, + } + } +} + +#[automatically_derived] +impl std::fmt::Debug for FateCryptReader { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FateCryptReader") + .field("seg_start", &self.seg_start) + .field("seg_size", &self.seg_size) + .field("pos", &self.pos) + .finish() + } +} + +impl Read for FateCryptReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + const XOR1_OFFSET: u64 = 0x13; + const XOR3_OFFSET: u64 = 0x2ea29; + let readed = self.inner.read(buf)?; + for (i, t) in (&mut buf[..readed]).iter_mut().enumerate() { + let tpos = self.seg_start + self.pos + i as u64; + *t ^= 0x36; + if tpos == XOR1_OFFSET { + *t ^= 0x1; + } else if tpos == XOR3_OFFSET { + *t ^= 0x3; + } + } + self.pos += readed as u64; + Ok(readed) + } +} + +impl Seek for FateCryptReader { + fn seek(&mut self, pos: SeekFrom) -> std::io::Result { + let new_pos: i64 = match pos { + SeekFrom::Start(offset) => offset as i64, + SeekFrom::End(offset) => self.seg_size as i64 + offset, + SeekFrom::Current(offset) => self.pos as i64 + offset, + }; + let offset = new_pos - self.pos as i64; + if offset != 0 { + self.inner.seek(SeekFrom::Current(offset))?; + self.pos = new_pos as u64; + } + Ok(self.pos) + } +} + +#[test] +fn test_deserialize_crypt() { + for (key, schema) in CRYPT_SCHEMA.iter() { + println!("Title: {}, Schema: {:?}", key, schema); + } +} diff --git a/src/scripts/kirikiri/archive/xp3/mod.rs b/src/scripts/kirikiri/archive/xp3/mod.rs index 3def6e5..5b70f87 100644 --- a/src/scripts/kirikiri/archive/xp3/mod.rs +++ b/src/scripts/kirikiri/archive/xp3/mod.rs @@ -12,6 +12,9 @@ use crate::scripts::base::*; use crate::types::*; use anyhow::Result; use consts::ZSTD_SIGNATURE; +use crypt::Crypt; +pub use crypt::get_supported_games; +pub use crypt::get_supported_games_with_title; use flate2::read::ZlibDecoder; use overf::wrapping; pub use segmenter::SegmenterConfig; @@ -166,6 +169,8 @@ impl Xp3Archive { archive.entries.retain(|entry| { let i = &entry.name; !(i.find("$$$ This is a protected archive. $$$").is_some() + // Fate/stay night has spelling mistake. We also filter it. + || i.find("$$$ This is a protectet archive. $$$").is_some() || (i.to_lowercase().ends_with(".nene") && entry.original_size == 0)) }); Ok(Self { @@ -208,7 +213,18 @@ impl Script for Xp3Archive { .nth(index) .ok_or(anyhow::anyhow!("Index out of bounds: {}", index))? .clone(); - let mut entry = Entry::new(self.archive.inner.clone(), index); + let crypt = self.archive.crypt.clone(); + if index.is_encrypted() && !crypt.decrypt_supported() { + return Err(anyhow::anyhow!( + "The archive is encrypted with a method that is not supported by the current crypt implementation. You may need to specify a game title by using --xp3-game-title ." + )); + } + let mut entry = Entry::new( + self.archive.inner.clone(), + index, + self.archive.base_offset, + crypt, + ); let mut header = [0u8; 16]; let header_len = entry.read(&mut header)?; entry.rewind()?; @@ -272,26 +288,41 @@ fn detect_script_type(filename: &str, buf: &[u8], buf_len: usize) -> Option<Scri struct Entry { reader: Arc<Mutex<Box<dyn ReadSeek>>>, index: archive::Xp3Entry, + crypt: Arc<Box<dyn Crypt>>, + /// used to cache segment reader that can't seek. Such as decompressor reader or some decrypter reader. cache: Option<Box<dyn Read>>, + /// used to store decrypted stream of current segment when the cryptor support seek when decrypting. + crypt_stream: Option<Box<dyn ReadSeek>>, pos: u64, + base_offset: u64, entries_pos: Vec<u64>, script_type: Option<ScriptType>, } +#[automatically_derived] impl std::fmt::Debug for Entry { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Entry") - .field("name", &self.index.name) - .field("flags", &self.index.flags) - .field("file_hash", &self.index.file_hash) - .field("original_size", &self.index.original_size) - .field("archived_size", &self.index.archived_size) + .field("reader", &self.reader) + .field("index", &self.index) + .field("crypt", &self.crypt) + .field("cache", &self.cache.is_some()) + .field("crypt_stream", &self.crypt_stream) + .field("pos", &self.pos) + .field("base_offset", &self.base_offset) + .field("entries_pos", &self.entries_pos) + .field("script_type", &self.script_type) .finish() } } impl Entry { - fn new(reader: Arc<Mutex<Box<dyn ReadSeek>>>, index: archive::Xp3Entry) -> Self { + fn new( + reader: Arc<Mutex<Box<dyn ReadSeek>>>, + index: archive::Xp3Entry, + base_offset: u64, + crypt: Arc<Box<dyn Crypt>>, + ) -> Self { let mut pos = 0; let entries_pos = index .segments @@ -309,6 +340,9 @@ impl Entry { pos: 0, entries_pos, script_type: None, + base_offset, + crypt, + crypt_stream: None, } } } @@ -331,6 +365,7 @@ impl Read for Entry { fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { if self.pos >= self.index.original_size { self.cache.take(); + self.crypt_stream.take(); return Ok(0); } if let Some(cache) = self.cache.as_mut() { @@ -341,6 +376,14 @@ impl Read for Entry { } self.cache.take(); } + if let Some(crypt_stream) = self.crypt_stream.as_mut() { + let readed = crypt_stream.read(buf)?; + if readed > 0 { + self.pos += readed as u64; + return Ok(readed); + } + self.crypt_stream.take(); + } let seg_index = match self.entries_pos.binary_search(&self.pos) { Ok(i) => i, Err(i) => { @@ -352,10 +395,72 @@ impl Read for Entry { } }; let seg = &self.index.segments[seg_index]; - let start_pos = seg.start; + let start_pos = seg.start + self.base_offset; let seg_pos = self.entries_pos[seg_index]; let skip_pos = self.pos - seg_pos; let read_size = seg.archived_size; + if self.index.is_encrypted() { + if seg.is_compressed || !self.crypt.decrypt_seek_supported() { + let mut cache: Box<dyn Read> = if seg.is_compressed { + let mut inner = + MutexWrapper::new(self.reader.clone(), start_pos).take(read_size); + let decompressed = if inner.peek_and_equal(ZSTD_SIGNATURE).is_ok() { + Box::new(ZstdDecoder::new(inner)?) as Box<dyn Read> + } else { + Box::new(ZlibDecoder::new(inner)) as Box<dyn Read> + }; + let decrypted = + self.crypt + .decrypt(&self.index, seg, decompressed) + .map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("Decryption failed: {}", e), + ) + })?; + Box::new(decrypted) as Box<dyn Read> + } else { + let inner = MutexWrapper::new(self.reader.clone(), start_pos).take(read_size); + let decrypted = self + .crypt + .decrypt(&self.index, seg, Box::new(inner)) + .map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("Decryption failed: {}", e), + ) + })?; + Box::new(decrypted) as Box<dyn Read> + }; + 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); + return Ok(readed); + } else { + let inner = MutexWrapper::new(self.reader.clone(), start_pos).take(read_size); + let mut decrypted = self + .crypt + .decrypt_with_seek(&self.index, seg, Box::new(inner)) + .map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("Decryption failed: {}", e), + ) + })?; + if skip_pos != 0 { + let mut e = EmptyWriter::new(); + std::io::copy(&mut (&mut decrypted).take(skip_pos), &mut e)?; // skip + } + let readed = decrypted.read(buf)?; + self.pos += readed as u64; + self.crypt_stream = Some(decrypted); + return Ok(readed); + } + } if seg.is_compressed { let mut inner = MutexWrapper::new(self.reader.clone(), start_pos).take(read_size); let mut cache = if inner.peek_and_equal(ZSTD_SIGNATURE).is_ok() { @@ -444,6 +549,34 @@ impl Seek for Entry { } } } + if let Some(crypt_stream) = self.crypt_stream.as_mut() { + let old_seg_index = match self.entries_pos.binary_search(&self.pos) { + Ok(i) => i, + Err(i) => { + if i == 0 { + 0 + } else { + i - 1 + } + } + }; + let new_seg_index = match self.entries_pos.binary_search(&new_pos) { + Ok(i) => i, + Err(i) => { + if i == 0 { + 0 + } else { + i - 1 + } + } + }; + if old_seg_index != new_seg_index { + self.crypt_stream.take(); + } else { + let offset = new_pos as i64 - self.pos as i64; + crypt_stream.seek(SeekFrom::Current(offset))?; + } + } self.pos = new_pos; Ok(self.pos) } @@ -451,6 +584,7 @@ impl Seek for Entry { fn rewind(&mut self) -> std::io::Result<()> { self.pos = 0; self.cache.take(); + self.crypt_stream.take(); Ok(()) } diff --git a/src/scripts/kirikiri/archive/xp3/read.rs b/src/scripts/kirikiri/archive/xp3/read.rs index 3fcdd4e..2d439bc 100644 --- a/src/scripts/kirikiri/archive/xp3/read.rs +++ b/src/scripts/kirikiri/archive/xp3/read.rs @@ -12,8 +12,15 @@ impl Xp3Archive { stream: T, _config: &ExtraConfig, ) -> Result<Self> { - let crypt: Box<dyn Crypt> = Box::new(NoCrypt::new()); - let crypt = Arc::new(crypt); + let crypt: Box<dyn Crypt> = if let Some(game_title) = &_config.xp3_game_title { + query_crypt_schema(game_title) + .ok_or_else(|| { + anyhow::anyhow!("Unsupported game title for XP3 archive: {}", game_title) + })? + .create_crypt() + } else { + Box::new(NoCrypt::new()) + }; let mut stream = Box::new(stream); let base_offset = 0; if base_offset != 0 { @@ -141,6 +148,7 @@ impl Xp3Archive { } } } + let crypt = Arc::new(crypt); let mut archive = Self { inner: Arc::new(Mutex::new(stream)), crypt: crypt.clone(), diff --git a/src/types.rs b/src/types.rs index afe40db..9fd3723 100644 --- a/src/types.rs +++ b/src/types.rs @@ -635,6 +635,9 @@ pub struct ExtraConfig { #[default(default_artemis_asb_end_tags())] /// A list of Artemis ASB script end tags, used to determine a dialogue block in script. pub artemis_asb_end_tags: std::sync::Arc<std::collections::HashSet<String>>, + #[cfg(feature = "kirikiri-arc")] + /// Game title for Kirikiri XP3 archive. This is used to decrypt file in archives. + pub xp3_game_title: Option<String>, } #[cfg(feature = "artemis")]