From ff4316178b60b2ce9d24bb624a68b89b72599b2e Mon Sep 17 00:00:00 2001 From: lifegpc Date: Wed, 10 Sep 2025 21:18:35 +0800 Subject: [PATCH] Add Favorite Hcb Script (.hcb) support --- Cargo.toml | 3 +- README.md | 4 + src/args.rs | 4 + src/main.rs | 2 + src/scripts/favorite/disasm.rs | 174 +++++++++++++++++++++++++++++++ src/scripts/favorite/hcb.rs | 182 +++++++++++++++++++++++++++++++++ src/scripts/favorite/mod.rs | 3 + src/scripts/mod.rs | 4 + src/types.rs | 7 ++ 9 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 src/scripts/favorite/disasm.rs create mode 100644 src/scripts/favorite/hcb.rs create mode 100644 src/scripts/favorite/mod.rs diff --git a/Cargo.toml b/Cargo.toml index 7760877..f0d5817 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,7 @@ zstd = { version = "0.13", optional = true } [features] default = ["all-fmt", "image-jpg", "image-webp", "audio-flac"] all-fmt = ["all-script", "all-img", "all-arc", "all-audio"] -all-script = ["artemis", "artemis-panmimisoft", "bgi", "cat-system", "circus", "entis-gls", "escude", "ex-hibit", "hexen-haus", "kirikiri", "silky", "softpal", "will-plus", "yaneurao", "yaneurao-itufuru"] +all-script = ["artemis", "artemis-panmimisoft", "bgi", "cat-system", "circus", "entis-gls", "escude", "ex-hibit", "favorite", "hexen-haus", "kirikiri", "silky", "softpal", "will-plus", "yaneurao", "yaneurao-itufuru"] all-img = ["bgi-img", "cat-system-img", "circus-img", "emote-img", "kirikiri-img"] all-arc = ["artemis-arc", "bgi-arc", "cat-system-arc", "circus-arc", "escude-arc"] all-audio = ["bgi-audio", "circus-audio"] @@ -71,6 +71,7 @@ entis-gls = ["xml5ever", "markup5ever", "markup5ever_rcdom"] escude = ["int-enum"] escude-arc = ["escude", "rand", "utils-bit-stream"] ex-hibit = [] +favorite = [] hexen-haus = ["memchr", "utils-str"] kirikiri = ["emote-psb", "fancy-regex", "flate2", "json", "utils-escape"] kirikiri-img = ["kirikiri", "image", "libtlg-rs"] diff --git a/README.md b/README.md index b28b324..2725aa6 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,10 @@ msg-tool create -t | Script Type | Feature Name | Name | Export | Import | Custom Export | Custom Import | Create | Remarks | |---|---|---|---|---|---|---|---|---| | `ex-hibit` | `ex-hibit` | ExHibit Script File (.rld) | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | | +### Favorite +| Script Type | Feature Name | Name | Export | Import | Custom Export | Custom Import | Create | Remarks | +|---|---|---|---|---|---|---|---|---| +| `favorite` | `favorite` | Favorite Hcb Script (.hcb) | ✔️ | ✔️ | ✔️ | ❌ | ❌ | | ### HexenHaus | Script Type | Feature Name | Name | Export | Import | Custom Export | Custom Import | Create | Remarks | |---|---|---|---|---|---|---|---|---| diff --git a/src/args.rs b/src/args.rs index 8d3da4f..a1dc240 100644 --- a/src/args.rs +++ b/src/args.rs @@ -419,6 +419,10 @@ pub struct Arg { /// Add a mark to the end of each message for LLM translation. /// Only works on m3t format. pub llm_trans_mark: Option, + #[cfg(feature = "favorite")] + #[arg(long, global = true, action = ArgAction::SetTrue)] + /// Do not filter ascii strings in Favorite HCB script. + pub favorite_hcb_no_filter_ascii: bool, #[command(subcommand)] /// Command pub command: Command, diff --git a/src/main.rs b/src/main.rs index 9e1a753..c1edf4f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1923,6 +1923,8 @@ fn main() { artemis_asb_format_lua: !arg.artemis_asb_no_format_lua, #[cfg(feature = "kirikiri")] kirikiri_title: arg.kirikiri_title, + #[cfg(feature = "favorite")] + favorite_hcb_filter_ascii: !arg.favorite_hcb_no_filter_ascii, }; match &arg.command { args::Command::Export { input, output } => { diff --git a/src/scripts/favorite/disasm.rs b/src/scripts/favorite/disasm.rs new file mode 100644 index 0000000..be3d9e6 --- /dev/null +++ b/src/scripts/favorite/disasm.rs @@ -0,0 +1,174 @@ +use crate::ext::io::*; +use crate::types::*; +use crate::utils::encoding::*; +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::io::{Read, Seek, SeekFrom}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Oper { + // Byte + B, + // Word + W, + // Double Word + D, + // String + S, +} + +use Oper::*; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "t", content = "c")] +pub enum Operand { + B(u8), + W(u16), + D(u32), + S(String), +} + +impl Operand { + pub fn len(&self, encoding: Encoding) -> Result { + Ok(match self { + Operand::B(_) => 1, + Operand::W(_) => 2, + Operand::D(_) => 4, + Operand::S(s) => { + let bytes = encode_string(encoding, s, true)?; + // null terminator + length byte + bytes.len() + 2 + } + }) + } +} + +const OPS: [(u8, &[Oper]); 49] = [ + (0x00, &[]), + (0x01, &[B, B]), //unknown + (0x02, &[D]), //call function + (0x03, &[W]), //unknown + (0x04, &[]), //retn? + (0x05, &[]), //retn? + (0x06, &[D]), //jump? + (0x07, &[D]), //cond jump? + (0x08, &[]), //unknown + (0x09, &[]), //unknown + (0x0a, &[D]), //unknown + (0x0b, &[W]), //unknown + (0x0c, &[B]), //unknown + (0x0d, &[]), //empty + (0x0e, &[S]), //string + (0x0f, &[W]), //unknown + (0x10, &[B]), //unknown + (0x11, &[W]), //unknown + (0x12, &[B]), //unknown + (0x13, &[]), + (0x14, &[]), //unknown + (0x15, &[W]), //unknown + (0x16, &[B]), //unknown + (0x17, &[W]), //unknown + (0x18, &[B]), //unknown + (0x19, &[]), //unknown + (0x1a, &[]), //unknown + (0x1b, &[]), //unknown + (0x1c, &[]), //unknown + (0x1d, &[]), //unknown + (0x1e, &[]), //unknown + (0x1f, &[]), //unknown + (0x20, &[]), //unknown + (0x21, &[]), //unknown + (0x22, &[]), //unknown + (0x23, &[]), //unknown + (0x24, &[]), //unknown + (0x25, &[]), //unknown + (0x26, &[]), //unknown + (0x27, &[]), //unknown + (0x33, &[]), + (0x3f, &[]), + (0x40, &[]), + (0xb3, &[]), + (0xb8, &[]), + (0xd8, &[]), + (0xf0, &[]), + (0x52, &[]), + (0x9e, &[]), +]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Func { + pub pos: u64, + pub opcode: u8, + pub operands: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Data { + pub functions: Vec, + pub main_script: Vec, + pub extra_data: Vec, +} + +impl Data { + pub fn disasm(mut reader: R, encoding: Encoding) -> Result { + let mut data = Data { + functions: Vec::new(), + main_script: Vec::new(), + extra_data: Vec::new(), + }; + let script_len = reader.read_u32()? as u64; + let main_script_data = reader.peek_u32_at(script_len)? as u64; + { + let mut target = &mut data.functions; + let mut pos = reader.stream_position()?; + while pos < script_len { + if pos >= main_script_data { + target = &mut data.main_script; + } + target.push(Self::read_func(&mut reader, encoding)?); + pos = reader.stream_position()?; + } + } + reader.seek(SeekFrom::Start(script_len + 4))?; + reader.read_to_end(&mut data.extra_data)?; + Ok(data) + } + + fn read_func(reader: &mut R, encoding: Encoding) -> Result { + let pos = reader.stream_position()?; + let opcode = reader.read_u8()?; + let operands = if let Some((_, ops)) = OPS.iter().find(|(code, _)| *code == opcode) { + let mut operands = Vec::with_capacity(ops.len()); + for &op in *ops { + let operand = match op { + B => Operand::B(reader.read_u8()?), + W => Operand::W(reader.read_u16()?), + D => Operand::D(reader.read_u32()?), + S => { + let len = reader.read_u8()? as usize; + let s = reader.read_cstring()?; + if s.as_bytes_with_nul().len() != len { + return Err(anyhow::anyhow!( + "String length mismatch at {:#x}: expected {}, got {}", + pos, + len, + s.as_bytes_with_nul().len() + )); + } + let s = decode_to_string(encoding, s.as_bytes(), true)?; + Operand::S(s) + } + }; + operands.push(operand); + } + operands + } else { + return Err(anyhow::anyhow!("Unknown opcode: {:#x} at {:#x}", opcode, pos)) + }; + Ok(Func { + pos, + opcode, + operands, + }) + } +} diff --git a/src/scripts/favorite/hcb.rs b/src/scripts/favorite/hcb.rs new file mode 100644 index 0000000..4e3789e --- /dev/null +++ b/src/scripts/favorite/hcb.rs @@ -0,0 +1,182 @@ +//! Favorite HCB script (.hcb) +use super::disasm::*; +use crate::ext::io::*; +use crate::scripts::base::*; +use crate::types::*; +use crate::utils::encoding::*; +use anyhow::Result; + +#[derive(Debug)] +/// Favorite HCB script builder +pub struct HcbScriptBuilder {} + +impl HcbScriptBuilder { + /// Create a new HcbScriptBuilder + pub fn new() -> Self { + Self {} + } +} + +impl ScriptBuilder for HcbScriptBuilder { + fn default_encoding(&self) -> Encoding { + Encoding::Cp932 + } + + fn build_script( + &self, + buf: Vec, + _filename: &str, + encoding: Encoding, + _archive_encoding: Encoding, + config: &ExtraConfig, + _archive: Option<&Box>, + ) -> Result> { + Ok(Box::new(HcbScript::new(buf, encoding, config)?)) + } + + fn extensions(&self) -> &'static [&'static str] { + &["hcb"] + } + + fn script_type(&self) -> &'static ScriptType { + &ScriptType::Favorite + } +} + +#[derive(Debug)] +pub struct HcbScript { + data: Data, + reader: MemReader, + custom_yaml: bool, + filter_ascii: bool, + encoding: Encoding, +} + +impl HcbScript { + pub fn new(buf: Vec, encoding: Encoding, config: &ExtraConfig) -> Result { + let reader = MemReader::new(buf); + let data = Data::disasm(reader.to_ref(), encoding)?; + Ok(Self { + data, + reader, + custom_yaml: config.custom_yaml, + filter_ascii: config.favorite_hcb_filter_ascii, + encoding, + }) + } +} + +impl Script for HcbScript { + 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 custom_output_extension<'a>(&'a self) -> &'a str { + if self.custom_yaml { "yaml" } else { "json" } + } + + fn extract_messages(&self) -> Result> { + let mut messages = Vec::new(); + for funcs in [&self.data.functions, &self.data.main_script] { + for func in funcs { + for operand in &func.operands { + if let Operand::S(s) = operand { + if self.filter_ascii && s.chars().all(|c| c.is_ascii()) { + continue; + } + messages.push(Message::new(s.clone(), None)); + } + } + } + } + Ok(messages) + } + + fn import_messages<'a>( + &'a self, + messages: Vec, + file: Box, + _filename: &str, + encoding: Encoding, + replacement: Option<&'a ReplacementTable>, + ) -> Result<()> { + let mut mess = messages.iter(); + let mut mes = mess.next(); + let mut patcher = + BinaryPatcher::new(self.reader.to_ref(), file, |pos| Ok(pos), |pos| Ok(pos))?; + let mut need_pacth_addresses = Vec::new(); + for funcs in [&self.data.functions, &self.data.main_script] { + for func in funcs { + let mut cur_pos = func.pos + 1; + if matches!(func.opcode, 0x02 | 0x06 | 0x07) { + need_pacth_addresses.push(cur_pos); + } + for operand in &func.operands { + if let Operand::S(s) = operand { + if self.filter_ascii && s.chars().all(|c| c.is_ascii()) { + continue; + } + let m = match mes { + Some(m) => m, + None => { + return Err(anyhow::anyhow!( + "Not enough messages to import. Missing message: {}", + s + )); + } + }; + let mut message = m.message.clone(); + if let Some(table) = replacement { + for (k, v) in &table.map { + message = message.replace(k, v); + } + } + patcher.copy_up_to(cur_pos)?; + let ori_len = operand.len(self.encoding)? as u64; + let mut s = encode_string(encoding, &message, true)?; + s.push(0); // null-terminated + let len = s.len(); + if len > 255 { + return Err(anyhow::anyhow!( + "Message too long to import (max 255 bytes): {}", + message + )); + } + patcher.replace_bytes_with_write(ori_len, |writer| { + writer.write_u8(len as u8)?; + writer.write_all(&s)?; + Ok(()) + })?; + mes = mess.next(); + } + cur_pos += operand.len(self.encoding)? as u64; + } + } + } + patcher.copy_up_to(self.reader.data.len() as u64)?; + for addr in need_pacth_addresses { + patcher.patch_u32_address(addr)?; + } + Ok(()) + } + + fn custom_export(&self, filename: &std::path::Path, encoding: Encoding) -> Result<()> { + let s = if self.custom_yaml { + serde_yaml_ng::to_string(&self.data)? + } else { + serde_json::to_string_pretty(&self.data)? + }; + let e = encode_string(encoding, &s, false)?; + let mut writer = crate::utils::files::write_file(filename)?; + writer.write_all(&e)?; + Ok(()) + } +} diff --git a/src/scripts/favorite/mod.rs b/src/scripts/favorite/mod.rs new file mode 100644 index 0000000..1f1d401 --- /dev/null +++ b/src/scripts/favorite/mod.rs @@ -0,0 +1,3 @@ +//! FAVORITE scripts +mod disasm; +pub mod hcb; diff --git a/src/scripts/mod.rs b/src/scripts/mod.rs index 1e5af4b..69ff03b 100644 --- a/src/scripts/mod.rs +++ b/src/scripts/mod.rs @@ -16,6 +16,8 @@ pub mod entis_gls; pub mod escude; #[cfg(feature = "ex-hibit")] pub mod ex_hibit; +#[cfg(feature = "favorite")] +pub mod favorite; #[cfg(feature = "hexen-haus")] pub mod hexen_haus; #[cfg(feature = "kirikiri")] @@ -122,6 +124,8 @@ lazy_static::lazy_static! { Box::new(kirikiri::tjs2::Tjs2Builder::new()), #[cfg(feature = "silky")] Box::new(silky::mes::MesBuilder::new()), + #[cfg(feature = "favorite")] + Box::new(favorite::hcb::HcbScriptBuilder::new()), ]; /// A list of all script extensions. pub static ref ALL_EXTS: Vec = diff --git a/src/types.rs b/src/types.rs index ab87bb2..f236422 100644 --- a/src/types.rs +++ b/src/types.rs @@ -422,6 +422,10 @@ pub struct ExtraConfig { #[cfg(feature = "kirikiri")] /// Whether to handle title in Kirikiri SCN script. pub kirikiri_title: bool, + #[cfg(feature = "favorite")] + #[default(true)] + /// Whether to filter ascii strings in Favorite HCB script. + pub favorite_hcb_filter_ascii: bool, } #[derive(Clone, Copy, Debug, ValueEnum, PartialEq, Eq, PartialOrd, Ord)] @@ -532,6 +536,9 @@ pub enum ScriptType { #[cfg(feature = "ex-hibit")] /// ExHibit rld script ExHibit, + #[cfg(feature = "favorite")] + /// Favorite hcb script + Favorite, #[cfg(feature = "hexen-haus")] /// HexenHaus bin script HexenHaus,