Add support to unpack Fate/stay night xp3 files

This commit is contained in:
2026-04-06 10:43:13 +08:00
parent a85c67e806
commit 323a312362
9 changed files with 412 additions and 12 deletions

View File

@@ -38,6 +38,12 @@ pub struct Xp3Entry {
pub extras: Vec<ExtraProp>,
}
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],

View File

@@ -0,0 +1,5 @@
{
"Fate/stay night": {
"$type": "FateCrypt"
}
}

View File

@@ -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<dyn Read + 'a>,
) -> Result<Box<dyn ReadDebug + 'a>> {
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<dyn ReadSeek + 'a>,
) -> Result<Box<dyn ReadSeek + 'a>> {
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<String>,
}
impl Schema {
pub fn create_crypt(&self) -> Box<dyn Crypt> {
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<String, Schema> = {
serde_json::from_str(&CRYPT_DATA).expect("Failed to parse crypt.json")
};
static ref ALIAS_TABLE: HashMap<String, String> = {
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<dyn Read + 'a>,
) -> Result<Box<dyn ReadDebug + 'a>> {
Ok(Box::new(FateCryptReader::new(stream, cur_seg)))
}
fn decrypt_with_seek<'a>(
&self,
_entry: &Xp3Entry,
cur_seg: &Segment,
stream: Box<dyn ReadSeek + 'a>,
) -> Result<Box<dyn ReadSeek + 'a>> {
Ok(Box::new(FateCryptReader::new(stream, cur_seg)))
}
}
struct FateCryptReader<R: Read> {
inner: R,
/// Start offset of the current xp3 entry.
seg_start: u64,
seg_size: u64,
pos: u64,
}
impl<T: Read> FateCryptReader<T> {
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<T: Read> std::fmt::Debug for FateCryptReader<T> {
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<R: Read> Read for FateCryptReader<R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
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<T: Read + Seek> Seek for FateCryptReader<T> {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
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);
}
}

View File

@@ -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 <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(())
}

View File

@@ -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(),