diff --git a/AGENTS.md b/AGENTS.md index 7f6995d..c83b1a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,7 @@ The CLI lives in `src/main.rs` with argument parsing in `src/args.rs` and data types in `src/types.rs`. Submodules under `src/format`, `src/scripts`, `src/output_scripts`, and `src/utils` hold codec implementations and shared helpers - mirror that layout when adding new game engines or formats. The procedural macro crate is in `msg_tool_macro/`; keep its API stable with the matching version declared in `Cargo.toml`. Sample game assets used for manual verification live under `testscripts/`, while patched reference outputs go in `patched/` and scratch artifacts in `output/`. All scripts should implement the `Script` and `ScriptBuilder` trait in `src/scripts/base.rs`. New script's type should be registered in `src/types.rs`. The corresponding script builder should be registered in `src/scripts/mod.rs`. If new flag are added, please register them in `src/args.rs` and `src/types.rs` (`ExtraConfig`). Some useful utilities are in `src/utils/`. Some utilities should enabled via feature flags in `Cargo.toml`. +If user give some example files in `testscripts/`. Run `cargo run -- export ` to test if export works. ## Coding Style & Naming Conventions Target the Rust 2024 edition with `rustfmt` defaults (4-space indentation, trailing commas). Modules and files stay in `snake_case`, public types in `PascalCase`, and flags/features use the hyphenated scheme already present (e.g., `bgi-arc`). Prefer explicit `use` blocks near call sites and annotate complex transforms with concise comments. Keep CLI option identifiers aligned with the conventions in `src/args.rs`. diff --git a/README.md b/README.md index a60d1b1..f06011f 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ msg-tool create -t |---|---|---|---|---|---|---|---|---|---|---| | `artemis` | `artemis` | Artemis Engine AST file (.ast) | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | | | `artemis-asb` | `artemis` | Artemis Engine ASB file (.asb/.iet) | ✔️ | ✔️ | ❌ | ❌ | ✔️ | ✔️ | ✔️ | For `.iet` files, only custom export/import and create features are supported. | +| `artemis-txt` | `artemis` | Artemis Engine TXT (General) script | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | | | `artemis-panmimisoft-txt` | `artemis-panmimisoft` | Artemis Engine TXT ([ぱんみみそふと](https://pannomimi.net/panmimisoft)) file (.txt) | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | | | Archive Type | Feature Name | Name | Unpack | Pack | Remarks | diff --git a/src/scripts/artemis/mod.rs b/src/scripts/artemis/mod.rs index ed3cb77..94e1fb5 100644 --- a/src/scripts/artemis/mod.rs +++ b/src/scripts/artemis/mod.rs @@ -5,3 +5,4 @@ pub mod asb; pub mod ast; #[cfg(feature = "artemis-panmimisoft")] pub mod panmimisoft; +pub mod txt; diff --git a/src/scripts/artemis/txt.rs b/src/scripts/artemis/txt.rs new file mode 100644 index 0000000..7928128 --- /dev/null +++ b/src/scripts/artemis/txt.rs @@ -0,0 +1,297 @@ +use crate::scripts::base::*; +use crate::types::*; +use crate::utils::encoding::*; +use anyhow::{Result, anyhow}; +use std::io::Write; + +#[derive(Debug)] +/// Builder for general Artemis TXT scripts. +pub struct ArtemisTxtBuilder {} + +impl ArtemisTxtBuilder { + /// Creates a new builder instance. + pub const fn new() -> Self { + Self {} + } +} + +impl ScriptBuilder for ArtemisTxtBuilder { + 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(ArtemisTxtScript::new(buf, encoding)?)) + } + + fn extensions(&self) -> &'static [&'static str] { + &["txt"] + } + + fn script_type(&self) -> &'static ScriptType { + &ScriptType::ArtemisTxt + } +} + +#[derive(Debug, Clone)] +struct MessageRef { + line_index: usize, + speaker: Option, + speaker_line_index: Option, +} + +#[derive(Debug)] +pub struct ArtemisTxtScript { + lines: Vec, + message_map: Vec, + use_crlf: bool, + trailing_newline: bool, +} + +impl ArtemisTxtScript { + fn new(buf: Vec, encoding: Encoding) -> Result { + let script = decode_to_string(encoding, &buf, true)?; + let use_crlf = script.contains("\r\n"); + let trailing_newline = script.ends_with('\n'); + let mut lines: Vec = script + .split('\n') + .map(|line| { + if use_crlf { + line.strip_suffix('\r').unwrap_or(line).to_string() + } else { + line.to_string() + } + }) + .collect(); + if trailing_newline { + // split('\n') keeps a trailing empty entry we do not want to lose + if lines.last().map(|s| s.is_empty()).unwrap_or(false) { + lines.pop(); + } + } + let message_map = Self::collect_messages(&lines); + Ok(Self { + lines, + message_map, + use_crlf, + trailing_newline, + }) + } + + fn collect_messages(lines: &[String]) -> Vec { + let mut refs = Vec::new(); + let mut current_speaker: Option = None; + let mut current_speaker_line: Option = None; + for (idx, line) in lines.iter().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if trimmed.starts_with("//") { + continue; + } + if trimmed.starts_with('*') { + continue; + } + if trimmed.starts_with('[') { + continue; + } + if trimmed.starts_with('#') { + match Self::parse_hash_speaker(trimmed) { + Some(name) => { + current_speaker = Some(name); + current_speaker_line = Some(idx); + } + None => { + current_speaker = None; + current_speaker_line = None; + } + } + continue; + } + + let speaker = if Self::is_dialogue_line(trimmed) { + current_speaker.clone() + } else { + None + }; + let speaker_line_index = if speaker.is_some() { + current_speaker_line + } else { + None + }; + refs.push(MessageRef { + line_index: idx, + speaker, + speaker_line_index, + }); + } + refs + } + + fn parse_hash_speaker(line: &str) -> Option { + let content = line.trim_start_matches('#').trim(); + if content.is_empty() { + return None; + } + let mut parts = content.split_whitespace(); + let token = parts.next()?; + let upper = token.to_ascii_uppercase(); + if upper.starts_with("BGM") + || upper.starts_with("SE") + || upper.starts_with("FGA") + || upper.starts_with("FG") + { + return None; + } + if token == "服装" { + return None; + } + Some(token.to_string()) + } + + fn is_dialogue_line(line: &str) -> bool { + match line.chars().next() { + Some('"') | Some('“') | Some('〝') | Some('(') | Some('(') | Some('「') + | Some('『') => true, + _ => false, + } + } + + fn join_lines(&self, lines: &[String]) -> String { + let newline = if self.use_crlf { "\r\n" } else { "\n" }; + let mut combined = lines.join(newline); + if self.trailing_newline { + combined.push_str(newline); + } + combined + } + + fn set_speaker_line(line: &str, name: &str) -> String { + if let Some(hash_pos) = line.find('#') { + let after_hash = &line[hash_pos + 1..]; + let start_rel = after_hash + .char_indices() + .find(|(_, ch)| !ch.is_whitespace()) + .map(|(offset, _)| offset); + let start_rel = match start_rel { + Some(offset) => offset, + None => { + let mut result = String::with_capacity(line.len() + name.len()); + result.push_str(line); + result.push_str(name); + return result; + } + }; + let start = hash_pos + 1 + start_rel; + let tail = &after_hash[start_rel..]; + let mut name_len = 0; + let mut end_rel = tail.len(); + for (offset, ch) in tail.char_indices() { + if ch.is_whitespace() { + end_rel = offset; + break; + } + name_len = offset + ch.len_utf8(); + } + let end = if tail.is_empty() { + start + } else if end_rel == tail.len() { + start + name_len + } else { + start + end_rel + }; + let mut result = String::with_capacity(line.len() + name.len()); + result.push_str(&line[..start]); + result.push_str(name); + result.push_str(&line[end..]); + return result; + } + format!("#{}", name) + } +} + +impl Script for ArtemisTxtScript { + fn default_output_script_type(&self) -> OutputScriptType { + OutputScriptType::Json + } + + fn default_format_type(&self) -> FormatOptions { + FormatOptions::None + } + + fn extract_messages(&self) -> Result> { + let mut messages = Vec::with_capacity(self.message_map.len()); + for entry in &self.message_map { + let text = self + .lines + .get(entry.line_index) + .cloned() + .unwrap_or_default(); + messages.push(Message { + name: entry.speaker.clone(), + message: text, + }); + } + Ok(messages) + } + + fn import_messages<'a>( + &'a self, + messages: Vec, + mut file: Box, + _filename: &str, + encoding: Encoding, + replacement: Option<&'a ReplacementTable>, + ) -> Result<()> { + if messages.len() != self.message_map.len() { + return Err(anyhow!( + "Message count mismatch: expected {}, got {}", + self.message_map.len(), + messages.len() + )); + } + let mut output_lines = self.lines.clone(); + for (entry, message) in self.message_map.iter().zip(messages.iter()) { + let mut text = message.message.clone(); + if let Some(repl) = replacement { + for (from, to) in &repl.map { + text = text.replace(from, to); + } + } + if let Some(line) = output_lines.get_mut(entry.line_index) { + *line = text; + } + if let (Some(speaker_line_index), Some(name)) = + (entry.speaker_line_index, message.name.as_ref()) + { + let mut patched_name = name.clone(); + if let Some(repl) = replacement { + for (from, to) in &repl.map { + patched_name = patched_name.replace(from, to); + } + } + if let Some(line) = output_lines.get_mut(speaker_line_index) { + *line = Self::set_speaker_line(line, &patched_name); + } else { + return Err(anyhow!( + "Speaker line index out of bounds: {}", + speaker_line_index + )); + } + } + } + let combined = self.join_lines(&output_lines); + let encoded = encode_string(encoding, &combined, true)?; + file.write_all(&encoded)?; + Ok(()) + } +} diff --git a/src/scripts/mod.rs b/src/scripts/mod.rs index 2cf45f4..47eaa8a 100644 --- a/src/scripts/mod.rs +++ b/src/scripts/mod.rs @@ -152,6 +152,8 @@ lazy_static::lazy_static! { Box::new(hexen_haus::archive::odio::HexenHausOdioArchiveBuilder::new()), #[cfg(feature = "will-plus-img")] Box::new(will_plus::img::wip::WillPlusWipImageBuilder::new()), + #[cfg(feature = "artemis")] + Box::new(artemis::txt::ArtemisTxtBuilder::new()), ]; /// A list of all script extensions. pub static ref ALL_EXTS: Vec = diff --git a/src/types.rs b/src/types.rs index 5a21876..87fe59e 100644 --- a/src/types.rs +++ b/src/types.rs @@ -483,6 +483,9 @@ pub enum ScriptType { #[cfg(feature = "artemis")] /// Artemis Engine ASB script ArtemisAsb, + #[cfg(feature = "artemis")] + /// Artemis Engine TXT (General) script + ArtemisTxt, #[cfg(feature = "artemis-panmimisoft")] /// Artemis Engine TXT (ぱんみみそふと) script ArtemisPanmimisoftTxt,