From 4f3110cef8631ae92a9f118f436072ff0855fba6 Mon Sep 17 00:00:00 2001 From: lifegpc Date: Sat, 11 Apr 2026 10:37:12 +0800 Subject: [PATCH] Add PuCaCrypt Fix CaseInsensitive cause BTreeMap not works correctly --- msg_tool_xp3data/crypt.json | 68 +++++++++ src/scripts/kirikiri/archive/xp3/crypt/mod.rs | 142 +++++++++++++++++- src/scripts/kirikiri/archive/xp3/mod.rs | 2 +- src/utils/case_insensitive_string.rs | 102 +++++++++++-- 4 files changed, 299 insertions(+), 15 deletions(-) diff --git a/msg_tool_xp3data/crypt.json b/msg_tool_xp3data/crypt.json index aeb8194..13d0286 100644 --- a/msg_tool_xp3data/crypt.json +++ b/msg_tool_xp3data/crypt.json @@ -1673,6 +1673,74 @@ "$type": "FlyingShineCrypt", "Title": "プリンセスサーガ" }, + "PURELY x CATION": { + "$type": "PuCaCrypt", + "HashTable": [ + 2973060117, + 3110907910, + 5928495, + 3996087360, + 59226653, + 3654524669, + 4285421800, + 3106491131, + 3503769144, + 2695546800, + 3466626651, + 476175124, + 4011134407, + 1134990289, + 1896688000, + 3350866490, + 4256029198, + 1234119340, + 4286929704, + 2375390458, + 1511181946, + 1873108191, + 713711560, + 3871855233, + 1166290799, + 1983402936, + 59142054, + 1436954750, + 3775493970, + 2381873347, + 1652264962, + 1773190413, + 1901946443, + 2983697227, + 3386732454, + 1420405981, + 3458099917, + 3459696020, + 2854601074, + 2151150242, + 873580943, + 3951633673, + 858980605, + 3394701319, + 3477634643, + 1155964947, + 2121337663, + 1750793461, + 3317152749, + 1508931760, + 106697855, + 1530528080, + 3681333500, + 4250263468, + 2727502642, + 3032903351, + 443565257, + 2317297865, + 1552064824, + 1746688873, + 3147812161 + ], + "KeyTable": "lPnQv+ICFwTHT6TrOC5/xfFT1wWFIDd+kEdZga08/fK0tFkiMmuNXXD2Avis7MAKEk+ArwNTzUg2NseWvg==", + "Title": "PURELY×CATION" + }, "PURELY x CATION [Trial]": { "$type": "AppliqueCrypt", "Title": "PURELY×CATION 体験版" diff --git a/src/scripts/kirikiri/archive/xp3/crypt/mod.rs b/src/scripts/kirikiri/archive/xp3/crypt/mod.rs index 76a6b62..08edb2e 100644 --- a/src/scripts/kirikiri/archive/xp3/crypt/mod.rs +++ b/src/scripts/kirikiri/archive/xp3/crypt/mod.rs @@ -18,6 +18,8 @@ use std::collections::{BTreeMap, HashMap}; use std::io::{Read, Seek, SeekFrom}; use std::sync::Arc; +type CIS = CaseInsensitiveStr; + pub fn default_init_crypt(archive: &mut Xp3Archive) -> Result<()> { if archive.extras.iter().any(|extra| extra.is_filename_hash()) { let mut filename_map = HashMap::new(); @@ -216,6 +218,11 @@ enum CryptType { YuzuCrypt, HighRunningCrypt, KissCrypt, + #[serde(rename_all = "PascalCase")] + PuCaCrypt { + hash_table: Vec, + key_table: Base64Bytes, + }, } #[derive(Clone, Debug, Deserialize)] @@ -331,6 +338,14 @@ impl Schema { CryptType::YuzuCrypt => Box::new(YuzuCrypt::new(self.base.clone())), CryptType::HighRunningCrypt => Box::new(HighRunningCrypt::new(self.base.clone())), CryptType::KissCrypt => Box::new(cz::KissCrypt::new(self.base.clone())), + CryptType::PuCaCrypt { + hash_table, + key_table, + } => Box::new(PuCaCrypt::new( + self.base.clone(), + hash_table.clone(), + key_table.bytes.clone(), + )?), }) } } @@ -387,10 +402,10 @@ pub fn get_supported_games_with_title() -> Vec<(&'static str, Option<&'static st } pub fn query_crypt_schema(game: &str) -> Option<&'static Schema> { - CRYPT_SCHEMA.get(game).or_else(|| { + CRYPT_SCHEMA.get(CIS::from_str(game)).or_else(|| { ALIAS_TABLE .get(game) - .and_then(|real_game| CRYPT_SCHEMA.get(real_game)) + .and_then(|real_game| CRYPT_SCHEMA.get(CIS::from_str(real_game))) }) } @@ -1375,11 +1390,134 @@ impl Read for HighRunningCryptReader { seek_reader_key_impl!(KissCryptReader, u32); +#[derive(Debug)] +pub struct PuCaCrypt { + base: BaseSchema, + hash_table: Vec, + key_table: Vec, +} + +impl PuCaCrypt { + pub fn new(base: BaseSchema, hash_table: Vec, key_table: Vec) -> Result { + if hash_table.len() != key_table.len() { + anyhow::bail!( + "Hash table and key table must have the same length, but got {} and {}", + hash_table.len(), + key_table.len() + ); + } + Ok(Self { + base, + hash_table, + key_table, + }) + } + fn get_key_table(&self, file_hash: u32) -> [u8; 0x400] { + let mut hash_table = [0u8; 32]; + let mut hash = file_hash; + for k in (0..32).step_by(4) { + if hash & 1 != 0 { + hash |= 0x80000000; + } else { + hash &= 0x7FFFFFFF; + } + hash_table[k..k + 4].copy_from_slice(&hash.to_le_bytes()); + hash >>= 1; + } + let mut key_table = [0u8; 0x400]; + for l in 0..32 { + for m in 0..32 { + key_table[l * 32 + m] = (!hash_table[l]) ^ hash_table[m]; + } + } + key_table + } +} + +impl Crypt for PuCaCrypt { + base_schema_impl!(); + 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> { + if let Some(pos) = self.hash_table.iter().position(|&h| h == entry.file_hash) { + Ok(Box::new(PuCaCryptReader::new( + stream, + cur_seg, + self.key_table[pos], + ))) + } else { + Ok(Box::new(PuCaCryptReader2::new( + stream, + cur_seg, + self.get_key_table(entry.file_hash), + ))) + } + } + fn decrypt_with_seek<'a>( + &self, + entry: &Xp3Entry, + cur_seg: &Segment, + stream: Box, + ) -> Result> { + if let Some(pos) = self.hash_table.iter().position(|&h| h == entry.file_hash) { + Ok(Box::new(PuCaCryptReader::new( + stream, + cur_seg, + self.key_table[pos], + ))) + } else { + Ok(Box::new(PuCaCryptReader2::new( + stream, + cur_seg, + self.get_key_table(entry.file_hash), + ))) + } + } +} + +seek_reader_key_impl!(PuCaCryptReader, u8); + +impl Read for PuCaCryptReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let readed = self.inner.read(buf)?; + for t in (&mut buf[..readed]).iter_mut() { + *t ^= self.key; + } + self.pos += readed as u64; + Ok(readed) + } +} + +seek_reader_key_impl!(PuCaCryptReader2, [u8; 0x400]); + +impl Read for PuCaCryptReader2 { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let readed = self.inner.read(buf)?; + let mut offset = ((self.seg_start + self.pos) & 0x3FF) as usize; + for t in (&mut buf[..readed]).iter_mut() { + *t ^= self.key[offset]; + offset = (offset + 1) & 0x3FF; + } + self.pos += readed as u64; + Ok(readed) + } +} + #[test] fn test_deserialize_crypt() { for (key, schema) in CRYPT_SCHEMA.iter() { println!("Title: {}, Schema: {:?}", key, schema); } + assert!(CRYPT_SCHEMA.contains_key(CIS::from_str("PURELY x CATION"))); } #[test] diff --git a/src/scripts/kirikiri/archive/xp3/mod.rs b/src/scripts/kirikiri/archive/xp3/mod.rs index cdc24cb..c18ac32 100644 --- a/src/scripts/kirikiri/archive/xp3/mod.rs +++ b/src/scripts/kirikiri/archive/xp3/mod.rs @@ -134,7 +134,7 @@ impl ScriptBuilder for Xp3ArchiveBuilder { } fn extensions(&self) -> &'static [&'static str] { - &["xp3", "bin"] + &["xp3", "bin", "dat"] } fn script_type(&self) -> &'static ScriptType { diff --git a/src/utils/case_insensitive_string.rs b/src/utils/case_insensitive_string.rs index 5283c99..e6c1c47 100644 --- a/src/utils/case_insensitive_string.rs +++ b/src/utils/case_insensitive_string.rs @@ -1,6 +1,7 @@ use serde::Deserialize; use std::borrow::Borrow; use std::cmp::Ordering; +use std::hash::{Hash, Hasher}; use std::ops::{Deref, DerefMut}; #[derive(Debug, Deserialize)] @@ -57,20 +58,97 @@ impl DerefMut for CaseInsensitiveString { } } -impl Borrow for CaseInsensitiveString { - fn borrow(&self) -> &str { - &self.0 - } -} - -impl Borrow for CaseInsensitiveString { - fn borrow(&self) -> &String { - &self.0 - } -} - impl std::fmt::Display for CaseInsensitiveString { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.0.fmt(f) } } + +impl Hash for CaseInsensitiveString { + fn hash(&self, state: &mut H) { + self.0.to_ascii_lowercase().hash(state); + } +} + +impl Borrow for CaseInsensitiveString { + fn borrow(&self) -> &CaseInsensitiveStr { + CaseInsensitiveStr::from_str(&self.0) + } +} + +#[repr(transparent)] +pub struct CaseInsensitiveStr(str); + +impl CaseInsensitiveStr { + pub fn from_str(s: &str) -> &Self { + // SAFETY: CaseInsensitiveStr has the same memory layout as str, so this transmute is safe. + unsafe { &*(s as *const str as *const Self) } + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl PartialEq for CaseInsensitiveStr { + fn eq(&self, other: &Self) -> bool { + self.eq_ignore_ascii_case(&other.0) + } +} + +impl Eq for CaseInsensitiveStr {} + +impl PartialOrd for CaseInsensitiveStr { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for CaseInsensitiveStr { + fn cmp(&self, other: &Self) -> Ordering { + self.0 + .to_ascii_lowercase() + .cmp(&other.0.to_ascii_lowercase()) + } +} + +impl Deref for CaseInsensitiveStr { + type Target = str; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::fmt::Display for CaseInsensitiveStr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl Hash for CaseInsensitiveStr { + fn hash(&self, state: &mut H) { + self.0.to_ascii_lowercase().hash(state); + } +} + +#[test] +fn test_btree_map() { + let mut map = std::collections::BTreeMap::new(); + map.insert(CaseInsensitiveString("hella".to_string()), 0); + map.insert(CaseInsensitiveString("Hello".to_string()), 1); + map.insert(CaseInsensitiveString("world".to_string()), 2); + assert_eq!(map.get(CaseInsensitiveStr::from_str("hello")), Some(&1)); + assert_eq!(map.get(CaseInsensitiveStr::from_str("WORLD")), Some(&2)); + assert_eq!(map.get(CaseInsensitiveStr::from_str("hella")), Some(&0)); +} + +#[test] +fn test_hash_map() { + let mut map = std::collections::HashMap::new(); + map.insert(CaseInsensitiveString("hells".to_string()), 0); + map.insert(CaseInsensitiveString("Hello".to_string()), 1); + map.insert(CaseInsensitiveString("world".to_string()), 2); + assert_eq!(map.get(CaseInsensitiveStr::from_str("hello")), Some(&1)); + assert_eq!(map.get(CaseInsensitiveStr::from_str("WORLD")), Some(&2)); + assert_eq!(map.get(CaseInsensitiveStr::from_str("hells")), Some(&0)); +}