Add support for artemis txt script

This commit is contained in:
2025-09-28 15:28:22 +08:00
parent 93b07d08d2
commit 9200b1e214
6 changed files with 305 additions and 0 deletions

View File

@@ -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 <file>` 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`.

View File

@@ -69,6 +69,7 @@ msg-tool create -t <script-type> <input> <output>
|---|---|---|---|---|---|---|---|---|---|---|
| `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 |

View File

@@ -5,3 +5,4 @@ pub mod asb;
pub mod ast;
#[cfg(feature = "artemis-panmimisoft")]
pub mod panmimisoft;
pub mod txt;

297
src/scripts/artemis/txt.rs Normal file
View File

@@ -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<u8>,
_filename: &str,
encoding: Encoding,
_archive_encoding: Encoding,
_config: &ExtraConfig,
_archive: Option<&Box<dyn Script>>,
) -> Result<Box<dyn Script>> {
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<String>,
speaker_line_index: Option<usize>,
}
#[derive(Debug)]
pub struct ArtemisTxtScript {
lines: Vec<String>,
message_map: Vec<MessageRef>,
use_crlf: bool,
trailing_newline: bool,
}
impl ArtemisTxtScript {
fn new(buf: Vec<u8>, encoding: Encoding) -> Result<Self> {
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<String> = 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<MessageRef> {
let mut refs = Vec::new();
let mut current_speaker: Option<String> = None;
let mut current_speaker_line: Option<usize> = 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<String> {
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<Vec<Message>> {
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<Message>,
mut file: Box<dyn WriteSeek + 'a>,
_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(())
}
}

View File

@@ -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<String> =

View File

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