diff --git a/Cargo.lock b/Cargo.lock index 38d022c..118d27a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,6 +60,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bindgen" version = "0.62.0" @@ -124,6 +130,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -219,6 +234,7 @@ checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "num-traits", + "serde", "windows-link", ] @@ -542,8 +558,18 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.13.4", + "darling_macro 0.13.4", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -556,21 +582,45 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.10.0", "syn 1.0.109", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835" dependencies = [ - "darling_core", + "darling_core 0.13.4", "quote", "syn 1.0.109", ] +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.117", +] + [[package]] name = "dasp_sample" version = "0.11.0" @@ -600,6 +650,15 @@ dependencies = [ "bindgen 0.62.0", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + [[package]] name = "directories" version = "3.0.2" @@ -693,6 +752,7 @@ dependencies = [ "serde_cbor", "serde_derive", "serde_json", + "serde_with", "strum", "strum_macros", "vec_mut_scan", @@ -709,6 +769,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.16.0" @@ -948,6 +1014,12 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.17.1" @@ -966,6 +1038,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "home" version = "0.5.12" @@ -1144,6 +1222,17 @@ dependencies = [ "mint", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1151,7 +1240,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -1494,7 +1585,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0df7ac00c4672f9d5aece54ee3347520b7e20f158656c7db2e6de01902eb7a6c" dependencies = [ - "darling", + "darling 0.13.4", "proc-macro-crate 1.3.1", "proc-macro2", "quote", @@ -1544,6 +1635,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-derive" version = "0.3.3" @@ -1805,6 +1902,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -1911,6 +2014,26 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "regex" version = "1.12.3" @@ -1980,6 +2103,30 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2072,6 +2219,38 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "shared_library" version = "0.1.9" @@ -2124,6 +2303,12 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.24.1" @@ -2216,6 +2401,36 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "time" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -2271,7 +2486,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap", + "indexmap 2.14.0", "toml_datetime 0.6.11", "winnow 0.5.40", ] @@ -2282,7 +2497,7 @@ version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ - "indexmap", + "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "winnow 1.0.3", diff --git a/Cargo.toml b/Cargo.toml index 599f2e4..7f84799 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,6 +87,7 @@ serde = { version = "1", features = ["derive"] } serde_derive = "1" serde_cbor = { version = "0.11", optional = true } serde_json = "1.0" +serde_with = "3.8.1" strum = "0.24" strum_macros = "0.24" # remove and replace with extract_if, when our MSRV is 1.87 diff --git a/src/common.rs b/src/common.rs index a634459..3c66273 100644 --- a/src/common.rs +++ b/src/common.rs @@ -1,4 +1,5 @@ use std::fmt; +use std::str::FromStr; use std::time::{SystemTime, UNIX_EPOCH}; use bitfield::bitfield; @@ -8,6 +9,7 @@ use serde::de::{SeqAccess, Visitor}; use serde::ser::SerializeTupleStruct; use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; +use crate::framework::error::GameError; use crate::graphics::texture_set::G_MAG; pub const ORG_NAME: &str = "io.github"; @@ -579,3 +581,149 @@ impl SliceExt for [T] { } } } + +#[derive(Debug, PartialEq, Eq, Copy, Clone)] +#[allow(non_camel_case_types)] +#[repr(u8)] +pub enum Encoding { + UTF8 = 0, + ShiftJIS, + UTF16BE, + UTF16LE, + ISO_2022_JP, + ISO_8859_2, + ISO_8859_3, + ISO_8859_4, + ISO_8859_5, + ISO_8859_6, + ISO_8859_7, + ISO_8859_8, + ISO_8859_8_I, + ISO_8859_10, + ISO_8859_13, + ISO_8859_14, + ISO_8859_15, + ISO_8859_16, + KOI8_R, + KOI8_U, + MACINTOSH, + EUC_JP, + EUC_KR, + GB18030, + GBK, + BIG5, + Win1250, + Win1251, + Win1252, + Win1253, + Win1254, + Win1255, + Win1256, + Win1257, + Win1258, +} + +impl Default for Encoding { + fn default() -> Self { + Self::ShiftJIS + } +} + +impl From<&str> for Encoding { + fn from(s: &str) -> Self { + s.parse().unwrap_or_default() + } +} + +impl FromStr for Encoding { + type Err = GameError; + + fn from_str(s: &str) -> Result { + match s { + "utf-8" => Ok(Self::UTF8), + "utf-16be" => Ok(Self::UTF16BE), + "utf-16le" => Ok(Self::UTF16LE), + + "iso-2022-jp" => Ok(Self::ISO_2022_JP), + "iso-8859-2" => Ok(Self::ISO_8859_2), + "iso-8859-3" => Ok(Self::ISO_8859_3), + "iso-8859-4" => Ok(Self::ISO_8859_4), + "iso-8859-5" => Ok(Self::ISO_8859_5), + "iso-8859-6" => Ok(Self::ISO_8859_6), + "iso-8859-7" => Ok(Self::ISO_8859_7), + "iso-8859-8" => Ok(Self::ISO_8859_8), + "iso-8859-8-i" => Ok(Self::ISO_8859_8_I), + "iso-8859-10" => Ok(Self::ISO_8859_10), + "iso-8859-13" => Ok(Self::ISO_8859_13), + "iso-8859-14" => Ok(Self::ISO_8859_14), + "iso-8859-15" => Ok(Self::ISO_8859_15), + "iso-8859-16" => Ok(Self::ISO_8859_16), + + "koi8-r" => Ok(Self::KOI8_R), + "koi8-u" => Ok(Self::KOI8_U), + + "macintosh" => Ok(Self::MACINTOSH), + + "euc-jp" => Ok(Self::EUC_JP), + "euc-kr" => Ok(Self::EUC_KR), + + "gb18030" => Ok(Self::GB18030), + "gbk" => Ok(Self::GBK), + "big5" => Ok(Self::BIG5), + + "windows-1250" => Ok(Self::Win1250), + "windows-1251" => Ok(Self::Win1251), + "windows-1252" => Ok(Self::Win1252), + "windows-1253" => Ok(Self::Win1253), + "windows-1254" => Ok(Self::Win1254), + "windows-1255" => Ok(Self::Win1255), + "windows-1256" => Ok(Self::Win1256), + "windows-1257" => Ok(Self::Win1257), + "windows-1258" => Ok(Self::Win1258), + + _ => Err(Self::Err::ParseError(format!("Invalid or unsupported encoding: {}", s))), + } + } +} + +impl From for &'static encoding_rs::Encoding { + fn from(value: Encoding) -> Self { + match value { + Encoding::ShiftJIS => encoding_rs::SHIFT_JIS, + Encoding::UTF8 => encoding_rs::UTF_8, + Encoding::UTF16BE => encoding_rs::UTF_16BE, + Encoding::UTF16LE => encoding_rs::UTF_16LE, + Encoding::ISO_2022_JP => encoding_rs::ISO_2022_JP, + Encoding::ISO_8859_2 => encoding_rs::ISO_8859_2, + Encoding::ISO_8859_3 => encoding_rs::ISO_8859_3, + Encoding::ISO_8859_4 => encoding_rs::ISO_8859_4, + Encoding::ISO_8859_5 => encoding_rs::ISO_8859_5, + Encoding::ISO_8859_6 => encoding_rs::ISO_8859_6, + Encoding::ISO_8859_7 => encoding_rs::ISO_8859_7, + Encoding::ISO_8859_8 => encoding_rs::ISO_8859_8, + Encoding::ISO_8859_8_I => encoding_rs::ISO_8859_8_I, + Encoding::ISO_8859_10 => encoding_rs::ISO_8859_10, + Encoding::ISO_8859_13 => encoding_rs::ISO_8859_13, + Encoding::ISO_8859_14 => encoding_rs::ISO_8859_14, + Encoding::ISO_8859_15 => encoding_rs::ISO_8859_15, + Encoding::ISO_8859_16 => encoding_rs::ISO_8859_16, + Encoding::KOI8_R => encoding_rs::KOI8_R, + Encoding::KOI8_U => encoding_rs::KOI8_U, + Encoding::MACINTOSH => encoding_rs::MACINTOSH, + Encoding::EUC_JP => encoding_rs::EUC_JP, + Encoding::EUC_KR => encoding_rs::EUC_KR, + Encoding::GB18030 => encoding_rs::GB18030, + Encoding::GBK => encoding_rs::GBK, + Encoding::BIG5 => encoding_rs::BIG5, + Encoding::Win1250 => encoding_rs::WINDOWS_1250, + Encoding::Win1251 => encoding_rs::WINDOWS_1251, + Encoding::Win1252 => encoding_rs::WINDOWS_1252, + Encoding::Win1253 => encoding_rs::WINDOWS_1253, + Encoding::Win1254 => encoding_rs::WINDOWS_1254, + Encoding::Win1255 => encoding_rs::WINDOWS_1255, + Encoding::Win1256 => encoding_rs::WINDOWS_1256, + Encoding::Win1257 => encoding_rs::WINDOWS_1257, + Encoding::Win1258 => encoding_rs::WINDOWS_1258, + } + } +} diff --git a/src/engine_constants/mod.rs b/src/engine_constants/mod.rs index 918e3bb..b3725c5 100644 --- a/src/engine_constants/mod.rs +++ b/src/engine_constants/mod.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::ffi::OsStr; use std::io::{BufRead, BufReader, Cursor, Read}; use byteorder::{ReadBytesExt, LE}; @@ -6,19 +7,18 @@ use case_insensitive_hashmap::CaseInsensitiveHashMap; use xmltree::Element; use crate::case_insensitive_hashmap; -use crate::common::{BulletFlag, Color, Rect}; +use crate::common::{BulletFlag, Color, Encoding, Rect}; use crate::engine_constants::npcs::NPCConsts; use crate::framework::context::Context; use crate::framework::error::GameResult; use crate::framework::filesystem; use crate::framework::gamepad::{Axis, Button}; use crate::game::player::ControlMode; -use crate::game::scripting::tsc::text_script::TextScriptEncoding; use crate::game::settings::Settings; use crate::game::shared_game_state::{FontData, Season}; use crate::game::stage::StageData; use crate::graphics::texture_set::TextureSet; -use crate::i18n::Locale; +use crate::i18n::{Locale, Translation}; use crate::sound::pixtone::{Channel, Envelope, PixToneParameters, Waveform}; use crate::sound::SoundManager; @@ -178,7 +178,7 @@ pub struct ExtraSoundtrack { #[derive(Debug, Copy, Clone)] pub struct TextScriptConsts { - pub encoding: TextScriptEncoding, + pub encoding: Encoding, pub encrypted: bool, pub reset_invicibility_on_any_script: bool, pub animated_face_pics: bool, @@ -346,20 +346,45 @@ impl DataType { #[derive(Clone, Copy, Debug, PartialEq)] pub enum RootType { - Base, // "/" path. Supports mods and locales. - Mod, // Mod root (not CS+ challenge). Doesn't support mods, but supports locales. - Translation // Translation of the main game. Doesn't support mods and locales. + /// The root of the main game, located in the "data" folder. Supports mods, locales, and translation variants. + Base, + /// The root of the mod (not CS+ challenge). Doesn't support mods and translation variants, but supports locales. + Mod, + // Translation of the main game. Doesn't support mods, locales, and translation variants. + Translation, +} + +impl RootType { + /// Whether the root supports locales. + /// + /// This determines whether the list of locales will be reloaded when switching to the specified root. + pub fn support_locales(&self) -> bool { + *self != Self::Translation + } + + /// Whether the root supports translation variants. + /// + /// Translation variants are only intended for the base (main game) root, since historically, only + /// the base game has has multiple translation variants for each language (locale). Mods, on the other hand, + /// often have almost no translations into other languages, and even if they do, they typically have only one + /// translation variant for a given language. + pub fn support_translations(&self) -> bool { + *self == Self::Base + } } #[derive(Clone, Debug)] pub struct DataRoot { - // Type of the game data in the root + /// The type of game data in the root. It's `None` if the data type cannot be determined, or if the root is + /// an incomplete translation, since incomplete (overlaid) translations don't contain any files specific to any data type, + /// as all such files are untranslatable. pub data_type: Option, pub root_type: RootType, - // Full path to the root dir + /// Full path to the root dir. pub path: String, pub support_locales: bool, + pub support_translations: bool, } impl Default for DataRoot { @@ -368,7 +393,8 @@ impl Default for DataRoot { data_type: None, root_type: RootType::Base, path: "/".to_string(), - support_locales: false + support_locales: false, + support_translations: false, } } } @@ -386,12 +412,13 @@ impl DataRoot { root_type, path, - support_locales: root_type != RootType::Translation, + support_locales: root_type.support_locales(), + support_translations: root_type.support_translations(), } } - // Path to the directory with a base game data. - // For CS+ it's "/base", for WiiWare "/root" etc. + /// Returns the path to the directory with the base game data. + /// For CS+ it's "/base", for WiiWare "/root", for CS 3D it's "/data". pub fn base_path(&self) -> String { let mut base_path = self.path.clone(); @@ -403,6 +430,10 @@ impl DataRoot { base_path } + /// Checks whether the translation root is complete. + /// + /// Incomplete translations (also called overlaid translations) often lack necessary files — such as the stage table + /// and non-translatable textures — since those files are expected to be loaded from the base root. pub fn is_complete(&self, ctx: &Context) -> bool { // Only overlaid translations may be incomplete. // As a result, incomplete translations don't have any specific files to determine their data type. @@ -426,6 +457,12 @@ impl DataRoot { true } + + pub fn translation_path(&self, _ctx: &Context, translation: &Translation) -> String { + assert!(self.support_locales, "Translation path requested for the root ({}), which doesn't support locales", &self.path); + + [&self.base_path(), &translation.full_code(), "/"].join("") + } } @@ -467,8 +504,9 @@ pub struct EngineConstants { pub missile_flags: Vec, pub base_locale: String, pub locales: Vec, + pub translations: Vec, pub gamepad: GamepadConsts, - pub stage_encoding: Option, + pub stage_encoding: Option, } impl EngineConstants { @@ -1621,7 +1659,7 @@ impl EngineConstants { "title".to_owned(), ], textscript: TextScriptConsts { - encoding: TextScriptEncoding::ShiftJIS, + encoding: Encoding::ShiftJIS, encrypted: true, reset_invicibility_on_any_script: true, animated_face_pics: false, @@ -1777,6 +1815,7 @@ impl EngineConstants { missile_flags: vec![200, 201, 202, 218, 550, 766, 880, 920, 1551], base_locale: "en".to_owned(), locales: Vec::new(), + translations: Vec::new(), gamepad: { let mut holder = GamepadConsts { button_rects: HashMap::from([ @@ -1818,6 +1857,10 @@ impl EngineConstants { } } + pub fn is_base(&self) -> bool { + !self.is_switch && !self.is_cs_plus && !self.is_demo + } + pub fn apply_csplus_patches(&mut self, sound_manager: &mut SoundManager) { log::info!("Applying Cave Story+ constants patches..."); @@ -1869,10 +1912,6 @@ impl EngineConstants { let _ = sound_manager.set_sample_params(2, typewriter_sample); } - pub fn is_base(&self) -> bool { - !self.is_switch && !self.is_cs_plus && !self.is_demo - } - pub fn apply_csplus_nx_patches(&mut self) { log::info!("Applying Switch-specific Cave Story+ constants patches..."); @@ -1885,7 +1924,7 @@ impl EngineConstants { self.title.logo_rect = Rect { left: 0, top: 0, right: 214, bottom: 62 }; self.inventory_dim_color = Color::from_rgba(0, 0, 32, 150); self.intro_background_color = Color::from_rgb(0, 0, 32); - self.textscript.encoding = TextScriptEncoding::UTF8; + self.textscript.encoding = Encoding::UTF8; self.textscript.encrypted = false; self.textscript.animated_face_pics = true; self.textscript.text_shadow = true; @@ -1913,6 +1952,7 @@ impl EngineConstants { let mut consts = Self::defaults(); consts.roots.clone_from(&self.roots); consts.locales.clone_from(&self.locales); + consts.translations.clone_from(&self.translations); consts.active_root.clone_from(&self.active_root); *self = consts; @@ -1937,22 +1977,20 @@ impl EngineConstants { consts.active_root = consts.roots.get(&data_root).cloned().unwrap(); consts.rebuild_path_list(None, Season::current(), settings); - let _ = consts.load_locales(ctx); + let _ = consts.load_translations(ctx, true); log::debug!("Temporary instance of EngineConstants has been initialized"); // The following roots are locales with data files whose type differs from that of the main root. // Switching to a such root requires reloading the engine constants. - for loc in &consts.locales { - let mut full_path = if consts.is_cs_plus { "/base/" } else { "/" }.to_string(); - full_path.push_str(loc.code.as_str()); - full_path.push('/'); + for tran in &consts.translations { + let tran_path = consts.active_root.translation_path(ctx, tran); - if filesystem::exists(ctx, full_path.as_str()) { - let data_type = DataType::from_path(ctx, full_path.as_str()); + if filesystem::exists(ctx, tran_path.as_str()) { + let data_type = DataType::from_path(ctx, tran_path.as_str()); if data_type != consts.data_type { - let root = DataRoot::load(ctx, full_path.clone(), RootType::Translation); + let root = DataRoot::load(ctx, tran_path.clone(), RootType::Translation); if root.is_complete(ctx) { - self.roots.insert(full_path.clone(), root); + self.roots.insert(tran_path, root); } } } @@ -1983,7 +2021,7 @@ impl EngineConstants { self.rebuild_path_list(None, Season::current(), settings); if data_root.support_locales { - let _ = self.load_locales(ctx); + let _ = self.load_translations(ctx, true); } log::debug!("Path list after switching the root: {:?}", &self.base_paths); @@ -2083,13 +2121,14 @@ impl EngineConstants { Ok(()) } - pub fn load_locales(&mut self, ctx: &mut Context) -> GameResult { + pub fn load_locales(&mut self, ctx: &Context) -> GameResult { self.locales.clear(); let locale_files = filesystem::read_dir_find(ctx, &self.base_paths, "locale/"); - for locale_file in locale_files.unwrap() { - if locale_file.extension().unwrap() != "json" { + for locale_file in locale_files? { + let file_ext = locale_file.extension().and_then(OsStr::to_str); + if file_ext != Some("json") { continue; } @@ -2100,38 +2139,62 @@ impl EngineConstants { }; let mut locale = Locale::new(ctx, &self.base_paths, &locale_code); - - // Roots that support locales may have their own default locale, which differs from English - if locale.code == self.base_locale { - locale.is_present = true; - locale.is_complete = true; - locale.data_type = self.active_root.data_type; - } else { - let translation_path = [self.active_root.base_path().as_str(), locale.code.as_str(), "/"].join(""); - locale.is_present = filesystem::exists(ctx, &translation_path); - if locale.is_present { - locale.data_type = DataType::from_path(ctx, translation_path.as_str()); - - if locale.data_type.is_none() { - let translation_root = self.roots.get(&translation_path); - - locale.is_complete = translation_root.is_some(); // incomplete translation roots aren't added to the list - locale.data_type = translation_root.unwrap_or(&self.active_root).data_type; - } - } - } - if locale_code == "jp" && filesystem::exists(ctx, "/base/credit_jp.tsc") { locale.set_font(FontData::new("csfontjp.fnt".to_owned(), 0.5, 0.0)); } - self.locales.push(locale.clone()); log::info!("Loaded locale {} ({})", locale_code, locale.name.clone()); + self.locales.push(locale); } Ok(()) } + pub fn load_translations(&mut self, ctx: &Context, load_locales: bool) -> GameResult { + self.translations.clear(); + + if self.locales.is_empty() || load_locales { + self.load_locales(ctx)?; + } + + // If the root doesn't support locales, only the default translation variants will be generated from loaded locales + if self.active_root.support_translations { + let translation_files = filesystem::read_dir_find(ctx, &self.base_paths, "translation/")?; + + for tran_file in translation_files { + let file_ext = tran_file.extension().and_then(OsStr::to_str); + if file_ext != Some("json") { + continue; + } + + let file = filesystem::open(ctx, tran_file)?; + if let Ok(mut tran) = Translation::from_file(file) { + if let Some(locale) = self.locales.iter().find(|loc| loc.code == tran.locale) { + tran.analyze_data(ctx, &self); + + log::info!("Loaded {} translation for {}", &tran.name, locale.name); + self.translations.push(tran); + } else { + log::debug!("Translation {} ({:?}) omitted because locale {} is missing", &tran.name, &tran.code, tran.locale); + } + } + } + } + + // Add default translations for locales that don't have one + for locale in &self.locales { + if !self.translations.iter().any(|t| t.locale == locale.code && t.is_default()) { + let mut tran: Translation = locale.into(); + tran.analyze_data(ctx, &self); + + log::debug!("Default translation variant created for {}", &locale.name); + self.translations.push(tran); + } + } + + Ok(()) + } + pub fn apply_constant_json_files(&mut self) {} // TODO: load soundtrack metadata from a json file diff --git a/src/game/scripting/tsc/bytecode_utils.rs b/src/game/scripting/tsc/bytecode_utils.rs index 27c44e4..399b348 100644 --- a/src/game/scripting/tsc/bytecode_utils.rs +++ b/src/game/scripting/tsc/bytecode_utils.rs @@ -1,8 +1,8 @@ use std::io::{Cursor, Read}; +use crate::common::Encoding; use crate::framework::error::GameError::ParseError; use crate::framework::error::GameResult; -use crate::game::scripting::tsc::text_script::TextScriptEncoding; pub fn put_varint(val: i32, out: &mut Vec) { let mut x = ((val as u32) >> 31) ^ ((val as u32) << 1); @@ -57,7 +57,7 @@ pub fn read_varint>(iter: &mut I) -> GameResult { Ok(((result << 31) ^ (result >> 1)) as i32) } -pub fn put_string(buffer: &mut Vec, out: &mut Vec, encoding: TextScriptEncoding) { +pub fn put_string(buffer: &mut Vec, out: &mut Vec, encoding: Encoding) { if buffer.is_empty() { return; } diff --git a/src/game/scripting/tsc/compiler.rs b/src/game/scripting/tsc/compiler.rs index 8419645..e253146 100644 --- a/src/game/scripting/tsc/compiler.rs +++ b/src/game/scripting/tsc/compiler.rs @@ -4,17 +4,18 @@ use std::str::FromStr; use itertools::Itertools; +use crate::common::Encoding; use crate::framework::error::GameError::ParseError; use crate::framework::error::GameResult; use crate::game::scripting::tsc::bytecode_utils::{put_string, put_varint}; use crate::game::scripting::tsc::credit_script::CreditScript; use crate::game::scripting::tsc::opcodes::{CreditOpCode, TSCOpCode}; use crate::game::scripting::tsc::parse_utils::{expect_char, read_number, skip_until}; -use crate::game::scripting::tsc::text_script::{TextScript, TextScriptEncoding}; +use crate::game::scripting::tsc::text_script::TextScript; impl TextScript { /// Compiles a decrypted text script data into internal bytecode. - pub fn compile(data: &[u8], strict: bool, encoding: TextScriptEncoding) -> GameResult { + pub fn compile(data: &[u8], strict: bool, encoding: Encoding) -> GameResult { let mut event_map = HashMap::new(); let mut iter = data.iter().copied().peekable(); let mut last_event = 0; @@ -70,7 +71,7 @@ impl TextScript { fn compile_event>( iter: &mut Peekable, strict: bool, - encoding: TextScriptEncoding, + encoding: Encoding, ) -> GameResult> { let mut bytecode = Vec::new(); let mut char_buf = Vec::with_capacity(16); @@ -313,7 +314,7 @@ impl TextScript { } impl CreditScript { - pub fn compile(data: &[u8], strict: bool, encoding: TextScriptEncoding) -> GameResult { + pub fn compile(data: &[u8], strict: bool, encoding: Encoding) -> GameResult { let mut labels = HashMap::new(); let mut bytecode = Vec::new(); let mut iter = data.iter().copied().peekable(); diff --git a/src/game/scripting/tsc/text_script.rs b/src/game/scripting/tsc/text_script.rs index 28a6d8f..76f7537 100644 --- a/src/game/scripting/tsc/text_script.rs +++ b/src/game/scripting/tsc/text_script.rs @@ -8,6 +8,7 @@ use std::io::SeekFrom; use std::ops::ControlFlow; use std::ops::Not; use std::rc::Rc; +use std::str::FromStr; use bitfield::bitfield; use num_traits::{clamp, FromPrimitive}; @@ -17,8 +18,7 @@ use crate::common::{Direction, FadeDirection, FadeState, Rect}; use crate::engine_constants::EngineConstants; use crate::entity::GameEntity; use crate::framework::context::Context; -use crate::framework::error::map_err_to_break; -use crate::framework::error::GameResult; +use crate::framework::error::{GameError, GameResult, map_err_to_break}; use crate::game::frame::UpdateTarget; use crate::game::npc::NPCContext; use crate::game::npc::NPC; @@ -47,139 +47,6 @@ bitfield! { pub cutscene_skip, set_cutscene_skip: 7; } -#[derive(Debug, PartialEq, Eq, Copy, Clone)] -#[allow(non_camel_case_types)] -#[repr(u8)] -pub enum TextScriptEncoding { - UTF8 = 0, - ShiftJIS, - UTF16BE, - UTF16LE, - ISO_2022_JP, - ISO_8859_2, - ISO_8859_3, - ISO_8859_4, - ISO_8859_5, - ISO_8859_6, - ISO_8859_7, - ISO_8859_8, - ISO_8859_8_I, - ISO_8859_10, - ISO_8859_13, - ISO_8859_14, - ISO_8859_15, - ISO_8859_16, - KOI8_R, - KOI8_U, - MACINTOSH, - EUC_JP, - EUC_KR, - GB18030, - GBK, - BIG5, - Win1250, - Win1251, - Win1252, - Win1253, - Win1254, - Win1255, - Win1256, - Win1257, - Win1258, -} - -impl From<&str> for TextScriptEncoding { - fn from(s: &str) -> Self { - match s { - "utf-8" => Self::UTF8, - - "iso-2022-jp" => Self::ISO_2022_JP, - "iso-8859-2" => Self::ISO_8859_2, - "iso-8859-3" => Self::ISO_8859_3, - "iso-8859-4" => Self::ISO_8859_4, - "iso-8859-5" => Self::ISO_8859_5, - "iso-8859-6" => Self::ISO_8859_6, - "iso-8859-7" => Self::ISO_8859_7, - "iso-8859-8" => Self::ISO_8859_8, - "iso-8859-8-i" => Self::ISO_8859_8_I, - "iso-8859-10" => Self::ISO_8859_10, - "iso-8859-13" => Self::ISO_8859_13, - "iso-8859-14" => Self::ISO_8859_14, - "iso-8859-15" => Self::ISO_8859_15, - "iso-8859-16" => Self::ISO_8859_16, - - "koi8-r" => Self::KOI8_R, - "koi8-u" => Self::KOI8_U, - - "macintosh" => Self::MACINTOSH, - - "euc-jp" => Self::EUC_JP, - "euc-kr" => Self::EUC_KR, - - "gb18030" => Self::GB18030, - "gbk" => Self::GBK, - "big5" => Self::BIG5, - - "windows-1250" => Self::Win1250, - "windows-1251" => Self::Win1251, - "windows-1252" => Self::Win1252, - "windows-1253" => Self::Win1253, - "windows-1254" => Self::Win1254, - "windows-1255" => Self::Win1255, - "windows-1256" => Self::Win1256, - "windows-1257" => Self::Win1257, - "windows-1258" => Self::Win1258, - - "utf-16be" => Self::UTF16BE, - "utf-16le" => Self::UTF16LE, - - _ => Self::ShiftJIS, - } - } -} - -impl From for &'static encoding_rs::Encoding { - fn from(value: TextScriptEncoding) -> Self { - match value { - TextScriptEncoding::ShiftJIS => encoding_rs::SHIFT_JIS, - TextScriptEncoding::UTF8 => encoding_rs::UTF_8, - TextScriptEncoding::UTF16BE => encoding_rs::UTF_16BE, - TextScriptEncoding::UTF16LE => encoding_rs::UTF_16LE, - TextScriptEncoding::ISO_2022_JP => encoding_rs::ISO_2022_JP, - TextScriptEncoding::ISO_8859_2 => encoding_rs::ISO_8859_2, - TextScriptEncoding::ISO_8859_3 => encoding_rs::ISO_8859_3, - TextScriptEncoding::ISO_8859_4 => encoding_rs::ISO_8859_4, - TextScriptEncoding::ISO_8859_5 => encoding_rs::ISO_8859_5, - TextScriptEncoding::ISO_8859_6 => encoding_rs::ISO_8859_6, - TextScriptEncoding::ISO_8859_7 => encoding_rs::ISO_8859_7, - TextScriptEncoding::ISO_8859_8 => encoding_rs::ISO_8859_8, - TextScriptEncoding::ISO_8859_8_I => encoding_rs::ISO_8859_8_I, - TextScriptEncoding::ISO_8859_10 => encoding_rs::ISO_8859_10, - TextScriptEncoding::ISO_8859_13 => encoding_rs::ISO_8859_13, - TextScriptEncoding::ISO_8859_14 => encoding_rs::ISO_8859_14, - TextScriptEncoding::ISO_8859_15 => encoding_rs::ISO_8859_15, - TextScriptEncoding::ISO_8859_16 => encoding_rs::ISO_8859_16, - TextScriptEncoding::KOI8_R => encoding_rs::KOI8_R, - TextScriptEncoding::KOI8_U => encoding_rs::KOI8_U, - TextScriptEncoding::MACINTOSH => encoding_rs::MACINTOSH, - TextScriptEncoding::EUC_JP => encoding_rs::EUC_JP, - TextScriptEncoding::EUC_KR => encoding_rs::EUC_KR, - TextScriptEncoding::GB18030 => encoding_rs::GB18030, - TextScriptEncoding::GBK => encoding_rs::GBK, - TextScriptEncoding::BIG5 => encoding_rs::BIG5, - TextScriptEncoding::Win1250 => encoding_rs::WINDOWS_1250, - TextScriptEncoding::Win1251 => encoding_rs::WINDOWS_1251, - TextScriptEncoding::Win1252 => encoding_rs::WINDOWS_1252, - TextScriptEncoding::Win1253 => encoding_rs::WINDOWS_1253, - TextScriptEncoding::Win1254 => encoding_rs::WINDOWS_1254, - TextScriptEncoding::Win1255 => encoding_rs::WINDOWS_1255, - TextScriptEncoding::Win1256 => encoding_rs::WINDOWS_1256, - TextScriptEncoding::Win1257 => encoding_rs::WINDOWS_1257, - TextScriptEncoding::Win1258 => encoding_rs::WINDOWS_1258, - } - } -} - #[derive(Debug, PartialEq, Eq, Copy, Clone)] #[repr(u8)] pub enum TextScriptLine { diff --git a/src/game/settings.rs b/src/game/settings.rs index 5df1301..48a732a 100644 --- a/src/game/settings.rs +++ b/src/game/settings.rs @@ -87,6 +87,7 @@ pub struct Settings { pub discord_rpc: bool, #[serde(default = "default_true")] pub allow_strafe: bool, + pub translation: Option, } fn default_true() -> bool { @@ -95,7 +96,7 @@ fn default_true() -> bool { #[inline(always)] fn current_version() -> u32 { - 25 + 26 } #[inline(always)] @@ -359,6 +360,11 @@ impl Settings { } } + if self.version == 25 { + self.version = 26; + self.translation = None; + } + if self.version != initial_version { log::info!("Upgraded configuration file from version {} to {}.", initial_version, self.version); } @@ -477,6 +483,7 @@ impl Default for Settings { cutscene_skip_mode: CutsceneSkipMode::Hold, discord_rpc: true, allow_strafe: true, + translation: None, } } } diff --git a/src/game/shared_game_state.rs b/src/game/shared_game_state.rs index 3d4c6cd..97e183c 100644 --- a/src/game/shared_game_state.rs +++ b/src/game/shared_game_state.rs @@ -2,7 +2,7 @@ use std::{cmp, ops::Div}; use chrono::{Datelike, Local}; -use crate::common::{Color, ControlFlags, Direction, FadeState}; +use crate::common::{Color, ControlFlags, Direction, Encoding, FadeState}; use crate::components::draw_common::{draw_number, Alignment}; use crate::data::vanilla::VanillaExtractor; #[cfg(feature = "discord-rpc")] @@ -20,13 +20,13 @@ use crate::game::player::TargetPlayer; use crate::game::profile::GameProfile; use crate::game::scripting::tsc::credit_script::{CreditScript, CreditScriptVM}; use crate::game::scripting::tsc::text_script::{ - ScriptMode, TextScript, TextScriptEncoding, TextScriptExecutionState, TextScriptVM, + ScriptMode, TextScript, TextScriptExecutionState, TextScriptVM, }; use crate::game::settings::Settings; use crate::game::stage::StageData; use crate::graphics::bmfont::BMFont; use crate::graphics::texture_set::TextureSet; -use crate::i18n::Locale; +use crate::i18n::{Locale, Translation}; use crate::input::touch_controls::TouchControls; use crate::mod_list::ModList; use crate::mod_requirements::ModRequirements; @@ -346,6 +346,7 @@ pub struct SharedGameState { pub replay_state: ReplayState, pub mod_requirements: ModRequirements, pub loc: Locale, + pub tran: Translation, pub tutorial_counter: u16, pub more_rust: bool, #[cfg(feature = "discord-rpc")] @@ -398,11 +399,11 @@ impl SharedGameState { let season = Season::current(); constants.rebuild_path_list(None, season, &settings); - constants.load_locales(ctx)?; + constants.load_translations(ctx, true)?; constants.rebuild_roots_list(ctx, &settings, &mut sound_manager); - let locale = SharedGameState::get_locale(&constants, &settings.locale).unwrap_or_default(); - let font = Self::try_update_locale(ctx, &mut constants, &settings, &mut sound_manager, &locale).unwrap(); + let (locale, translation) = SharedGameState::get_translation(&constants, &settings.locale, settings.translation.as_deref()).unwrap_or_default(); + let font = Self::try_update_locale(ctx, &mut constants, &settings, &mut sound_manager, &locale, &translation).unwrap(); let mod_list = ModList::load(ctx, &constants)?; @@ -484,6 +485,7 @@ impl SharedGameState { replay_state: ReplayState::None, mod_requirements, loc: locale, + tran: translation, tutorial_counter: 0, more_rust, #[cfg(feature = "discord-rpc")] @@ -563,8 +565,10 @@ impl SharedGameState { settings: &Settings, sound_manager: &mut SoundManager, locale: &Locale, + translation: &Translation ) -> GameResult { log::info!("Updating locale to {} ({})", &locale.code, &locale.name); + assert!(locale.code == translation.locale); // If the active root doesn't support locales (we're probably on a Translation root) // or the target locale isn't the default one (its data dir could be a root), we need to switch the root @@ -573,14 +577,14 @@ impl SharedGameState { let path = if constants.active_root.support_locales { // If the active root support locales, we'll try to set active root to the translation data dir. - format!("{}{}/", constants.active_root.base_path(), &locale.code) - } else if constants.active_root.root_type == RootType::Translation && locale.code != constants.base_locale && locale.is_complete { + constants.active_root.translation_path(ctx, &translation) + } else if constants.active_root.root_type == RootType::Translation && locale.code != constants.base_locale && translation.is_complete { // Only the main game can have translations of different data types, // so we'll try to find the translation data dir in the main root. // We only need to do it if the translation is complete; otherwise it could be missing, // which results in applying the new locale to the menu entries, while the game data is // used from the previous locale. - format!("{}{}/", main_root.base_path(), &locale.code) + main_root.translation_path(ctx, &translation) } else { // The active root doesn't support translations and isn't a translation itself, // or the data files in the target lcoale data dir are incomplete (they're missing or the translation isn't a root). @@ -591,32 +595,30 @@ impl SharedGameState { constants.set_active_root(ctx, path, settings, sound_manager); } - constants.textscript.encoding = if let Some(encoding) = locale.encoding { - encoding - } else { + constants.textscript.encoding = translation.encoding.unwrap_or_else(|| { // In freeware, Japanese and English text scripts use ShiftJIS. // In Cave Story+, Japanese scripts use ShiftJIS and English scripts use UTF-8. // In the Switch edition, UTF-8 is used for both English and Japanese scripts. match locale.code.as_str() { "jp" => { if constants.is_switch { - TextScriptEncoding::UTF8 + Encoding::UTF8 } else { - TextScriptEncoding::ShiftJIS + Encoding::ShiftJIS } } "en" => { if constants.is_base() { - TextScriptEncoding::ShiftJIS + Encoding::ShiftJIS } else { - TextScriptEncoding::UTF8 + Encoding::UTF8 } } - _ => TextScriptEncoding::UTF8, + _ => Encoding::UTF8, } - }; + }); - constants.stage_encoding = locale.stage_encoding; + constants.stage_encoding = translation.stage_encoding; let font = BMFont::load(&constants.base_paths, &locale.font.path, ctx, locale.font.scale).or_else(|e| { log::warn!("Failed to load font, using built-in: {}", e); @@ -627,14 +629,16 @@ impl SharedGameState { } pub fn update_locale(&mut self, ctx: &mut Context) { - let Some(locale) = SharedGameState::get_locale(&self.constants, &self.settings.locale) else { + let loc_res = SharedGameState::get_translation(&self.constants, &self.settings.locale, self.settings.translation.as_deref()); + let Some((locale, translation)) = loc_res else { return; }; let prev_root = self.constants.active_root.path.clone(); - let font = Self::try_update_locale(ctx, &mut self.constants, &self.settings, &mut self.sound_manager, &locale).unwrap(); + let font = Self::try_update_locale(ctx, &mut self.constants, &self.settings, &mut self.sound_manager, &locale, &translation).unwrap(); self.loc = locale; + self.tran = translation; self.font = font; let _ = if prev_root != self.constants.active_root.path { @@ -934,21 +938,23 @@ impl SharedGameState { return self.difficulty as u16; } - fn get_locale(constants: &EngineConstants, user_locale: &str) -> Option { - let mut out_locale = None; + fn get_translation(constants: &EngineConstants, locale: &str, translation: Option<&str>) -> Option<(Locale, Translation)> { + let mut out = None; - for locale in &constants.locales { - if locale.code == constants.base_locale { - out_locale = Some(locale.clone()); + for tran in &constants.translations { + if tran.locale == constants.base_locale { + let loc = constants.locales.iter().find(|l| l.code == locale)?; + out = Some((loc.clone(), tran.clone())); } - if locale.code == user_locale { - out_locale = Some(locale.clone()); + if tran.locale == locale && tran.code.as_deref() == translation { + let loc = constants.locales.iter().find(|l| l.code == locale)?; + out = Some((loc.clone(), tran.clone())); break; } } - out_locale + out } pub fn get_localized_soundtrack_name(&self, id: &str) -> String { diff --git a/src/game/stage.rs b/src/game/stage.rs index bf1ea2d..bde9950 100644 --- a/src/game/stage.rs +++ b/src/game/stage.rs @@ -5,14 +5,14 @@ use byteorder::ReadBytesExt; use byteorder::LE; use strum::IntoEnumIterator; -use crate::common::Color; +use crate::common::{Color, Encoding}; use crate::engine_constants::EngineConstants; use crate::framework::context::Context; use crate::framework::error::GameError::ResourceLoadError; use crate::framework::error::{GameError, GameResult}; use crate::framework::filesystem; use crate::game::map::{Map, NPCData}; -use crate::game::scripting::tsc::text_script::{TextScript, TextScriptEncoding}; +use crate::game::scripting::tsc::text_script::TextScript; use crate::game::SharedGameState; #[derive(Debug, PartialEq, Eq, Hash, Clone)] @@ -225,7 +225,7 @@ fn zero_index(s: &[u8]) -> usize { s.iter().position(|&c| c == b'\0').unwrap_or(s.len()) } -fn from_encoding(s: &[u8], encoding: Option) -> String { +fn from_encoding(s: &[u8], encoding: Option) -> String { if let Some(encoding) = encoding { let encoding: &encoding_rs::Encoding = encoding.into(); return encoding.decode_without_bom_handling(s).0.into_owned(); @@ -238,7 +238,7 @@ fn from_shift_jis(s: &[u8]) -> String { encoding_rs::SHIFT_JIS.decode_without_bom_handling(s).0.into_owned() } -fn from_csplus_stagetbl(s: &[u8], is_switch: bool, encoding: Option) -> String { +fn from_csplus_stagetbl(s: &[u8], is_switch: bool, encoding: Option) -> String { if let Some(encoding) = encoding { let encoding: &encoding_rs::Encoding = encoding.into(); return encoding.decode_without_bom_handling(s).0.into_owned(); diff --git a/src/i18n.rs b/src/i18n.rs index 0b64e7b..e1cddd0 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -1,9 +1,13 @@ use std::collections::HashMap; +use std::io::Read; -use crate::engine_constants::DataType; +use serde_with::{DisplayFromStr, serde_as}; + +use crate::common::Encoding; +use crate::engine_constants::{DataType, EngineConstants}; use crate::framework::context::Context; +use crate::framework::error::GameResult; use crate::framework::filesystem; -use crate::game::scripting::tsc::text_script::TextScriptEncoding; use crate::game::shared_game_state::FontData; #[derive(Debug, Clone)] @@ -11,8 +15,8 @@ pub struct Locale { pub code: String, pub name: String, pub font: FontData, - pub encoding: Option, - pub stage_encoding: Option, + pub encoding: Option, + pub stage_encoding: Option, strings: HashMap, // Properties of the translation data "root" @@ -39,7 +43,7 @@ impl Default for Locale { } impl Locale { - pub fn new(ctx: &mut Context, base_paths: &Vec, code: &str) -> Locale { + pub fn new(ctx: &Context, base_paths: &Vec, code: &str) -> Locale { let file = filesystem::open_find(ctx, base_paths, &format!("locale/{code}.json")).unwrap(); let json: serde_json::Value = serde_json::from_reader(file).unwrap(); @@ -52,12 +56,12 @@ impl Locale { let font = FontData::new(font_name, font_scale, 0.0); let encoding = if let Some(enc) = strings.get("encoding").clone() { - Some(TextScriptEncoding::from(enc.as_str())) + Some(Encoding::from(enc.as_str())) } else { None }; let stage_encoding = if let Some(enc) = strings.get("stage_encoding").clone() { - Some(TextScriptEncoding::from(enc.as_str())) + Some(Encoding::from(enc.as_str())) } else { None }; @@ -115,3 +119,121 @@ impl Locale { self.font = font; } } + +#[serde_as] +#[derive(Clone, Debug, serde::Deserialize)] +/// Info about a translation variant. +/// +/// A translation variant contains information about a specific translation of game files +/// into a particular language (its name, code and game data parameters), while the [`Locale`] contains data +/// common to all translation variants (font parameters and UI strings). +pub struct Translation { + /// The locale code for which the translation variant is intended. + pub locale: String, + /// Code of the translation variant. + /// `None` represents the default translation variant for the specified locale. + pub code: Option, + /// Name of the the translation variant (typically the name of the translator or translation team). + pub name: String, + + #[serde_as(as = "Option")] + /// Encoding of text scripts. + pub encoding: Option, + #[serde_as(as = "Option")] + /// Encoding of strings in the stage table. + pub stage_encoding: Option, + + // Properties of the translation data "root" + #[serde(default, skip)] + /// Whether the translation game data directory exists. + pub available: bool, + #[serde(default, skip)] + /// Whether the translation data directory is complete. + /// + /// Incomplete translations don't contain all necessary files, + /// as they are intended to contain only translatable files (scripts, textures, the stage table); + /// therefore, a type of a type of game data cannot be determined for them. + pub is_complete: bool, + #[serde(default, skip)] + pub data_type: Option, +} + +impl Default for Translation { + fn default() -> Self { + let locale = Locale::default(); + Self { + locale: locale.code, + code: None, + name: "".to_string(), + encoding: None, + stage_encoding: None, + available: false, + is_complete: false, + data_type: None, + } + } +} + +impl Translation { + pub fn new(ctx: &mut Context, base_paths: &Vec, code: &str) -> GameResult { + let file = filesystem::open_find(ctx, base_paths, &format!("translation/{code}.json"))?; + Self::from_file(file) + } + + pub fn from_file(mut file: R) -> GameResult { + let mut content = String::new(); + file.read_to_string(&mut content)?; + + Ok(serde_json::from_str(&content)?) + } + + pub fn full_code(&self) -> String { + let mut full_code = self.locale.clone(); + + if let Some(code) = &self.code { + full_code.push('_'); + full_code.push_str(code); + } + + full_code + } + + /// Whether this translation is the default for its locale. + pub fn is_default(&self) -> bool { + self.code.is_none() + } + + pub fn analyze_data(&mut self, ctx: &Context, constants: &EngineConstants) { + if self.locale == constants.base_locale && self.is_default() { + self.available = true; + self.is_complete = true; + self.data_type = constants.active_root.data_type; + } else { + let tran_path = constants.active_root.translation_path(ctx, self); + self.available = filesystem::exists(ctx, &tran_path); + + if self.available { + self.data_type = DataType::from_path(ctx, tran_path.as_str()); + + if self.data_type.is_none() { + let translation_root = constants.roots.get(&tran_path); + + self.is_complete = translation_root.is_some(); // incomplete translation roots aren't added to the list + self.data_type = translation_root.unwrap_or(&constants.active_root).data_type; + } + } + } + } +} + +impl From<&Locale> for Translation { + fn from(value: &Locale) -> Self { + Self { + locale: value.code.clone(), + name: "".to_string(), + encoding: value.encoding, + stage_encoding: value.stage_encoding, + ..Default::default() + } + } +} diff --git a/src/live_debugger/command_line.rs b/src/live_debugger/command_line.rs index a208957..53df541 100644 --- a/src/live_debugger/command_line.rs +++ b/src/live_debugger/command_line.rs @@ -1,8 +1,9 @@ use num_traits::FromPrimitive; +use crate::common::Encoding; use crate::framework::error::{GameError::CommandLineError, GameResult}; use crate::game::npc::NPC; -use crate::game::scripting::tsc::text_script::{ScriptMode, TextScript, TextScriptEncoding}; +use crate::game::scripting::tsc::text_script::{ScriptMode, TextScript}; use crate::game::shared_game_state::SharedGameState; use crate::game::weapon::WeaponType; use crate::scene::game_scene::GameScene; @@ -249,7 +250,7 @@ impl CommandLineCommand { } CommandLineCommand::TSC(script) => { log::info!("Executing TSC script: {}", format!("#9999\n{}", script)); - match TextScript::compile(format!("#9999\n{}", script).as_bytes(), true, TextScriptEncoding::UTF8) { + match TextScript::compile(format!("#9999\n{}", script).as_bytes(), true, Encoding::UTF8) { Ok(text_script) => { state.textscript_vm.set_debug_script(text_script); state.textscript_vm.set_mode(ScriptMode::Debug); diff --git a/src/menu/settings_menu.rs b/src/menu/settings_menu.rs index 0c97676..68ee8bc 100644 --- a/src/menu/settings_menu.rs +++ b/src/menu/settings_menu.rs @@ -1,6 +1,7 @@ use itertools::Itertools; use crate::common::Rect; +use crate::engine_constants::RootType; use crate::framework::context::Context; use crate::framework::error::GameResult; use crate::framework::graphics::VSyncMode; @@ -101,7 +102,7 @@ impl Default for SoundtrackMenuEntry { #[derive(Debug, Clone, Eq, PartialEq)] enum LanguageMenuEntry { Title, - Language(String), + Language(String, Option), // locale code, translation code (None represents a default translation variant) Spacer, Warning, Back, @@ -379,8 +380,13 @@ impl SettingsMenu { let mut has_diff_data_locales = false; let mut has_no_data_locales = false; - for locale in &state.constants.locales { - if !locale.is_present { + for tran in &state.constants.translations { + if !tran.available { + if !tran.is_default() { + log::debug!("Skipped locale: {:?}", tran); + continue; + } + has_no_data_locales = true; } @@ -388,15 +394,37 @@ impl SettingsMenu { let main_root = state.constants.roots.get("/").unwrap(); let active_root = &state.constants.active_root; - let mut entry = locale.name.clone(); - if !locale.is_present { - entry.push(no_data_char.0) - } else if locale.code != state.constants.base_locale && active_root.support_locales && active_root.data_type != locale.data_type || main_root.data_type != locale.data_type { + let loc = state.constants.locales.iter().find(|l| l.code == tran.locale).unwrap(); + let mut entry = loc.name.clone(); + + // Workaround for locales like "Chinese (Simplified)" + if !tran.is_default() { + if entry.ends_with(')') { + let _ = entry.pop(); + entry.push_str(", "); + } else { + entry.push_str(" ("); + } + + entry.push_str(tran.name.as_str()); + entry.push(')'); + } + + if !tran.available { + entry.push(no_data_char.0); + } else if + tran.locale != state.constants.base_locale && + active_root.support_locales && active_root.data_type != tran.data_type || + (main_root.data_type != tran.data_type && active_root.root_type == RootType::Translation) + { + // We compare the data type of the translation with the data type of the active root, + // if it supports locales and isn't a translation root (in case it's a mod root that, for some reason, + // doesn't support locales), or with the data type of the main root otherwise. has_diff_data_locales = true; entry.push(save_warn_char.0); } - self.language.push_entry(LanguageMenuEntry::Language(locale.code.clone()), MenuEntry::Active(entry)); + self.language.push_entry(LanguageMenuEntry::Language(tran.locale.clone(), tran.code.clone()), MenuEntry::Active(entry)); } self.language.push_entry(LanguageMenuEntry::Spacer, MenuEntry::Spacer(8.0)); @@ -982,12 +1010,13 @@ impl SettingsMenu { )?; } CurrentMenu::LanguageMenu => match self.language.tick(controller, state) { - MenuSelectionResult::Selected(LanguageMenuEntry::Language(new_locale), entry) => { + MenuSelectionResult::Selected(LanguageMenuEntry::Language(new_locale, new_translation), entry) => { if let MenuEntry::Active(_) = entry { - if new_locale == state.settings.locale { + if new_locale == state.settings.locale && new_translation == state.settings.translation { self.current = CurrentMenu::MainMenu; } else { state.settings.locale = new_locale; + state.settings.translation = new_translation; state.update_locale(ctx); let _ = state.settings.save(ctx);