diff --git a/Cargo.lock b/Cargo.lock index 39d4733..31ff056 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + [[package]] name = "adler2" version = "2.0.1" @@ -193,6 +199,90 @@ dependencies = [ "memchr", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "emote-psb" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5726783feee87f0effb3e756f3f88e01594be4b47ebd742c70393f20692fb03" +dependencies = [ + "adler", + "byteorder", + "encoding", + "flate2", + "itertools", + "serde", +] + +[[package]] +name = "encoding" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0d943856b990d12d3b55b359144ff341533e516d94098b1d3fc1ac666d36ec" +dependencies = [ + "encoding-index-japanese", + "encoding-index-korean", + "encoding-index-simpchinese", + "encoding-index-singlebyte", + "encoding-index-tradchinese", +] + +[[package]] +name = "encoding-index-japanese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e8b2ff42e9a05335dbf8b5c6f7567e5591d0d916ccef4e0b1710d32a0d0c91" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-korean" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dc33fb8e6bcba213fe2f14275f0963fd16f0a02c878e3095ecfdf5bee529d81" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-simpchinese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87a7194909b9118fc707194baa434a4e3b0fb6a5a757c73c3adb07aa25031f7" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-singlebyte" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3351d5acffb224af9ca265f435b859c7c01537c0849754d3db3fdf2bfe2ae84a" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-tradchinese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd0e20d5688ce3cab59eb3ef3a2083a5c77bf496cb798dc6fcdb75f323890c18" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding_index_tests" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a246d82be1c9d791c5dfde9a2bd045fc3cbba3fa2b11ad558f27d01712f00569" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -276,6 +366,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -318,6 +417,7 @@ dependencies = [ "blowfish", "clap", "csv", + "emote-psb", "encoding_rs", "flate2", "int-enum", diff --git a/Cargo.toml b/Cargo.toml index 258d535..8276267 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ anyhow = "1" blowfish = { version = "0.9", optional = true } clap = { version = "4.5", features = ["derive"] } csv = "1.3" +emote-psb = { version = "0.5.0", features = ["serde"], optional = true } encoding_rs = "0.8" flate2 = { version = "1.1", optional = true } int-enum = { version = "1.2", optional = true } @@ -21,7 +22,7 @@ serde_json = "1" unicode-segmentation = "1.12" [features] -default = ["bgi", "bgi-arc", "bgi-img", "cat-system", "cat-system-arc", "cat-system-img", "circus", "escude", "escude-arc", "yaneurao", "yaneurao-itufuru"] +default = ["bgi", "bgi-arc", "bgi-img", "cat-system", "cat-system-arc", "cat-system-img", "circus", "escude", "escude-arc", "kirikiri", "yaneurao", "yaneurao-itufuru"] bgi = [] bgi-arc = ["bgi", "utils-bit-stream"] bgi-img = ["bgi", "image", "utils-bit-stream"] @@ -31,6 +32,7 @@ cat-system-img = ["cat-system", "flate2", "image", "utils-bit-stream"] circus = [] escude = ["int-enum"] escude-arc = ["escude", "rand", "utils-bit-stream"] +kirikiri = ["emote-psb"] yaneurao = [] yaneurao-itufuru = ["yaneurao"] # basic feature diff --git a/src/args.rs b/src/args.rs index 1fb2672..cc6a39b 100644 --- a/src/args.rs +++ b/src/args.rs @@ -110,6 +110,10 @@ pub struct Arg { #[arg(long, global = true, action = ArgAction::SetTrue)] /// Draw CatSystem2 image on canvas (if canvas width and height are specified in file) pub cat_system_image_canvas: bool, + #[cfg(feature = "kirikiri")] + #[arg(long, global = true)] + /// Kirikiri language index in script. If not specified, the first language will be used. + pub kirikiri_language_index: Option, #[command(subcommand)] /// Command pub command: Command, diff --git a/src/main.rs b/src/main.rs index efe1638..c47b2b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1356,6 +1356,8 @@ fn main() { cat_system_int_encrypt_password: arg.cat_system_int_encrypt_password.clone(), #[cfg(feature = "cat-system-img")] cat_system_image_canvas: arg.cat_system_image_canvas, + #[cfg(feature = "kirikiri")] + kirikiri_language_index: arg.kirikiri_language_index.clone(), }; match &arg.command { args::Command::Export { input, output } => { diff --git a/src/scripts/kirikiri/mod.rs b/src/scripts/kirikiri/mod.rs new file mode 100644 index 0000000..6c45797 --- /dev/null +++ b/src/scripts/kirikiri/mod.rs @@ -0,0 +1 @@ +pub mod scn; diff --git a/src/scripts/kirikiri/scn.rs b/src/scripts/kirikiri/scn.rs new file mode 100644 index 0000000..aeaee16 --- /dev/null +++ b/src/scripts/kirikiri/scn.rs @@ -0,0 +1,310 @@ +use crate::ext::io::*; +use crate::scripts::base::*; +use crate::types::*; +use crate::utils::encoding::{decode_to_string, encode_string}; +use anyhow::Result; +use emote_psb::types::PsbValue; +use emote_psb::{PsbReader, PsbWriter, VirtualPsb}; +use serde::{Deserialize, Serialize}; +use std::io::{Read, Seek, Write}; +use std::path::Path; + +#[derive(Debug)] +pub struct ScnScriptBuilder {} + +impl ScnScriptBuilder { + pub fn new() -> Self { + Self {} + } +} + +impl ScriptBuilder for ScnScriptBuilder { + fn default_encoding(&self) -> Encoding { + Encoding::Utf8 + } + + fn build_script( + &self, + buf: Vec, + filename: &str, + _encoding: Encoding, + _archive_encoding: Encoding, + config: &ExtraConfig, + ) -> Result> { + Ok(Box::new(ScnScript::new( + MemReader::new(buf), + filename, + config, + )?)) + } + + fn build_script_from_file( + &self, + filename: &str, + _encoding: Encoding, + _archive_encoding: Encoding, + config: &ExtraConfig, + ) -> Result> { + if filename == "-" { + let data = crate::utils::files::read_file(filename)?; + Ok(Box::new(ScnScript::new( + MemReader::new(data), + filename, + config, + )?)) + } else { + let f = std::fs::File::open(filename)?; + let reader = std::io::BufReader::new(f); + Ok(Box::new(ScnScript::new(reader, filename, config)?)) + } + } + + fn build_script_from_reader( + &self, + reader: Box, + filename: &str, + _encoding: Encoding, + _archive_encoding: Encoding, + config: &ExtraConfig, + ) -> Result> { + Ok(Box::new(ScnScript::new(reader, filename, config)?)) + } + + fn extensions(&self) -> &'static [&'static str] { + &["scn"] + } + + fn script_type(&self) -> &'static ScriptType { + &ScriptType::KirikiriScn + } + + fn is_this_format(&self, filename: &str, buf: &[u8], buf_len: usize) -> Option { + if Path::new(filename) + .file_name() + .map(|name| { + name.to_ascii_lowercase() + .to_string_lossy() + .ends_with(".ks.scn") + }) + .unwrap_or(false) + && buf_len >= 4 + && buf.starts_with(b"PSB\0") + { + return Some(255); + } + None + } +} + +#[derive(Debug)] +pub struct ScnScript { + psb: VirtualPsb, + language_index: usize, +} + +impl ScnScript { + pub fn new(reader: R, filename: &str, config: &ExtraConfig) -> Result { + let mut psb = PsbReader::open_psb(reader) + .map_err(|e| anyhow::anyhow!("Failed to open PSB from {}: {:?}", filename, e))?; + let psb = psb + .load() + .map_err(|e| anyhow::anyhow!("Failed to load PSB from {}: {:?}", filename, e))?; + Ok(Self { + psb, + language_index: config.kirikiri_language_index.unwrap_or(0), + }) + } +} + +#[derive(Debug, Serialize)] +pub struct PsbDataRef<'a> { + pub version: u16, + pub encryption: u16, + pub root: &'a emote_psb::types::collection::PsbObject, +} + +impl<'a> PsbDataRef<'a> { + pub fn new(psb: &'a VirtualPsb) -> Self { + let header = psb.header(); + Self { + version: header.version, + encryption: header.encryption, + root: psb.root(), + } + } +} + +#[derive(Debug, Deserialize)] +pub struct PsbData { + pub version: u16, + pub encryption: u16, + pub root: emote_psb::types::collection::PsbObject, +} + +impl PsbData { + pub fn header(&self) -> emote_psb::header::PsbHeader { + emote_psb::header::PsbHeader { + version: self.version, + encryption: self.encryption, + } + } +} + +impl Script for ScnScript { + fn default_output_script_type(&self) -> OutputScriptType { + OutputScriptType::Json + } + + fn default_format_type(&self) -> FormatOptions { + FormatOptions::None + } + + fn is_output_supported(&self, _: OutputScriptType) -> bool { + true + } + + fn extract_messages(&self) -> Result> { + let mut messages = Vec::new(); + let root = self.psb.root(); + let scenes = root + .get_value("scenes".into()) + .ok_or(anyhow::anyhow!("scenes not found"))?; + let scenes = match scenes { + PsbValue::List(list) => list, + _ => return Err(anyhow::anyhow!("scenes is not a list")), + }; + for (i, scene) in scenes.iter().enumerate() { + let scene = match scene { + PsbValue::Object(obj) => obj, + _ => return Err(anyhow::anyhow!("scene at index {} is not an object", i)), + }; + if let Some(PsbValue::List(texts)) = scene.get_value("texts".into()) { + for text in texts.iter() { + if let PsbValue::List(text) = text { + let values = text.values(); + if values.len() <= 1 { + continue; // Skip if there are not enough values + } + let name = &values[0]; + let name = match name { + PsbValue::String(s) => Some(s), + PsbValue::Null => None, + _ => return Err(anyhow::anyhow!("name is not a string or null")), + }; + let mut display_name; + let mut message; + if matches!(values[1], PsbValue::List(_)) { + display_name = None; + message = &values[1]; + } else { + if values.len() <= 2 { + continue; // Skip if there is no message + } + display_name = match &values[1] { + PsbValue::String(s) => Some(s), + PsbValue::Null => None, + _ => { + return Err(anyhow::anyhow!( + "display name is not a string or null" + )); + } + }; + message = &values[2]; + } + if matches!(message, PsbValue::List(_)) { + let tmp = message; + if let PsbValue::List(list) = tmp { + if list.len() > self.language_index { + if let PsbValue::List(data) = + &list.values()[self.language_index] + { + if data.len() >= 2 { + let data = data.values(); + display_name = match &data[0] { + PsbValue::String(s) => Some(s), + PsbValue::Null => None, + _ => { + return Err(anyhow::anyhow!( + "display name is not a string or null" + )); + } + }; + message = &data[1]; + } + } + } + } + } + if let PsbValue::String(message) = message { + match name { + Some(name) => { + let name = match display_name { + Some(name) => name.string(), + None => name.string(), + }; + let message = message.string(); + messages.push(Message { + name: Some(name.to_string()), + message: message.replace("\\n", "\n"), + }); + } + None => { + let message = message.string(); + messages.push(Message { + name: None, + message: message.replace("\\n", "\n"), + }); + } + } + } + } + } + } + // #TODO: selects / comudata(circus) + } + Ok(messages) + } + + fn custom_output_extension(&self) -> &'static str { + "json" + } + + fn custom_export(&self, filename: &Path, encoding: Encoding) -> Result<()> { + if !self.psb.resources().is_empty() { + eprintln!( + "Warning: The PSB contains resources, which may not be fully represented in the JSON output." + ); + crate::COUNTER.inc_warning(); + } + if !self.psb.extra().is_empty() { + eprintln!( + "Warning: The PSB contains extra data, which may not be fully represented in the JSON output." + ); + crate::COUNTER.inc_warning(); + } + let psb_data = PsbDataRef::new(&self.psb); + let str = serde_json::to_string_pretty(&psb_data)?; + let s = encode_string(encoding, &str, false)?; + let mut f = std::fs::File::create(filename)?; + f.write_all(&s)?; + Ok(()) + } + + fn custom_import<'a>( + &'a self, + custom_filename: &'a str, + file: Box, + encoding: Encoding, + _output_encoding: Encoding, + ) -> Result<()> { + let data = crate::utils::files::read_file(custom_filename)?; + let s = decode_to_string(encoding, &data)?; + let psb_data: PsbData = serde_json::from_str(&s)?; + let psb = VirtualPsb::new(psb_data.header(), Vec::new(), Vec::new(), psb_data.root); + let writer = PsbWriter::new(psb, file); + writer + .finish() + .map_err(|e| anyhow::anyhow!("Failed to write PSB: {:?}", e))?; + Ok(()) + } +} diff --git a/src/scripts/mod.rs b/src/scripts/mod.rs index def004d..98cd2ce 100644 --- a/src/scripts/mod.rs +++ b/src/scripts/mod.rs @@ -7,6 +7,8 @@ pub mod cat_system; pub mod circus; #[cfg(feature = "escude")] pub mod escude; +#[cfg(feature = "kirikiri")] +pub mod kirikiri; #[cfg(feature = "yaneurao")] pub mod yaneurao; @@ -46,6 +48,8 @@ lazy_static::lazy_static! { Box::new(cat_system::archive::int::CSIntArcBuilder::new()), #[cfg(feature = "cat-system-img")] Box::new(cat_system::image::hg3::Hg3ImageBuilder::new()), + #[cfg(feature = "kirikiri")] + Box::new(kirikiri::scn::ScnScriptBuilder::new()), ]; pub static ref ALL_EXTS: Vec = BUILDER.iter().flat_map(|b| b.extensions()).map(|s| s.to_string()).collect(); diff --git a/src/types.rs b/src/types.rs index e2f56c6..e98ef69 100644 --- a/src/types.rs +++ b/src/types.rs @@ -207,6 +207,8 @@ pub struct ExtraConfig { pub cat_system_int_encrypt_password: Option, #[cfg(feature = "cat-system-img")] pub cat_system_image_canvas: bool, + #[cfg(feature = "kirikiri")] + pub kirikiri_language_index: Option, } #[derive(Clone, Copy, Debug, ValueEnum, PartialEq, Eq, PartialOrd, Ord)] @@ -262,6 +264,10 @@ pub enum ScriptType { #[cfg(feature = "escude")] /// Escude list script EscudeList, + #[cfg(feature = "kirikiri")] + #[value(alias("kr-scn"))] + /// Kirikiri SCN script + KirikiriScn, #[cfg(feature = "yaneurao-itufuru")] #[value(alias("itufuru"))] /// Yaneurao Itufuru script