Add support for Yu-Ris scenario text file (.txt)

This commit is contained in:
2026-05-17 15:54:41 +08:00
parent f7b1a51b9f
commit 17fbc10d2e
4 changed files with 763 additions and 0 deletions

View File

@@ -184,6 +184,8 @@ lazy_static::lazy_static! {
Box::new(yuris::yscfg::YSCFGBuilder::new()),
#[cfg(feature = "yuris")]
Box::new(yuris::ystb::YSTBBuilder::new()),
#[cfg(feature = "yuris")]
Box::new(yuris::txt::YurisTxtBuilder::new()),
];
/// A list of all script extensions.
pub static ref ALL_EXTS: Vec<String> =

View File

@@ -1,4 +1,5 @@
//! Yu-Ris Engine Scripts
pub mod txt;
mod types;
pub mod yscfg;
pub mod yscm;

757
src/scripts/yuris/txt.rs Normal file
View File

@@ -0,0 +1,757 @@
//! Yu-Ris scenario text file (.txt)
use crate::scripts::base::*;
use crate::types::*;
use crate::utils::encoding::*;
use anyhow::Result;
use std::ops::{Deref, DerefMut};
use unicode_segmentation::UnicodeSegmentation;
#[derive(Debug)]
pub struct YurisTxtBuilder {}
impl YurisTxtBuilder {
/// Creates a new instance of `YurisTxtBuilder`
pub const fn new() -> Self {
YurisTxtBuilder {}
}
}
impl ScriptBuilder for YurisTxtBuilder {
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 + Send + Sync>> {
Ok(Box::new(YurisTxt::new(&buf, encoding)?))
}
fn extensions(&self) -> &'static [&'static str] {
&["txt"]
}
fn script_type(&self) -> &'static ScriptType {
&ScriptType::YurisTxt
}
}
trait INode {
fn serialize(&self) -> String;
}
#[derive(Clone, Debug, PartialEq)]
struct CommentNode(String);
impl Deref for CommentNode {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for CommentNode {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl INode for CommentNode {
fn serialize(&self) -> String {
format!("//{}", self.0)
}
}
#[derive(Clone, Debug, PartialEq)]
struct LabelNode(String);
impl Deref for LabelNode {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for LabelNode {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl INode for LabelNode {
fn serialize(&self) -> String {
format!("#{}", self.0)
}
}
#[derive(Clone, Debug, PartialEq)]
struct CommandNode {
name: String,
args: Vec<String>,
has_args: bool,
}
impl INode for CommandNode {
fn serialize(&self) -> String {
if !self.has_args {
return format!("\\{}", self.name);
}
let mut s = format!("\\{}(", self.name);
let mut first = true;
for arg in &self.args {
if first {
first = false;
} else {
s.push_str(", ");
}
if arg.contains(" ") || arg.contains(",") {
s.push_str(&format!("\"{}\"", arg));
} else {
s.push_str(arg);
}
}
s.push(')');
s
}
}
#[derive(Clone, Debug, PartialEq)]
struct NameNode(String);
impl Deref for NameNode {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for NameNode {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl INode for NameNode {
fn serialize(&self) -> String {
format!("{}", self.0)
}
}
#[derive(Clone, Debug, PartialEq)]
struct TextNode(String);
impl Deref for TextNode {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for TextNode {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl INode for TextNode {
fn serialize(&self) -> String {
self.0.clone()
}
}
#[derive(Clone, Debug, PartialEq)]
struct CommentBlock(String);
impl Deref for CommentBlock {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for CommentBlock {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl INode for CommentBlock {
fn serialize(&self) -> String {
format!("/*{}*/", self.0)
}
}
#[derive(Clone, Debug, PartialEq)]
enum LineNode {
Comment(CommentNode),
Comments(CommentBlock),
Command(CommandNode),
Name(NameNode),
Text(TextNode),
}
impl INode for LineNode {
fn serialize(&self) -> String {
match self {
Self::Comment(node) => node.serialize(),
Self::Comments(node) => node.serialize(),
Self::Command(node) => node.serialize(),
Self::Name(node) => node.serialize(),
Self::Text(node) => node.serialize(),
}
}
}
impl INode for Vec<LineNode> {
fn serialize(&self) -> String {
self.iter()
.map(|s| s.serialize())
.collect::<Vec<_>>()
.join("")
}
}
#[derive(Clone, Debug, PartialEq)]
enum Line {
Line(Vec<LineNode>),
Empty,
Label(LabelNode),
}
impl INode for Line {
fn serialize(&self) -> String {
match self {
Self::Line(line) => line.serialize(),
Self::Empty => String::new(),
Self::Label(label) => label.serialize(),
}
}
}
impl INode for Vec<Line> {
fn serialize(&self) -> String {
self.iter()
.map(|s| s.serialize())
.collect::<Vec<_>>()
.join("\n")
}
}
#[derive(Debug)]
struct Parser<'a> {
lines: std::str::Lines<'a>,
cur_line: &'a str,
cur_pos: usize,
line_num: u64,
cur_line_chars: Vec<&'a str>,
}
impl<'a> Parser<'a> {
fn new(data: &'a str) -> Self {
Self {
lines: data.lines(),
cur_line: "",
cur_pos: 0,
line_num: 0,
cur_line_chars: Vec::new(),
}
}
fn error(&self, msg: impl std::fmt::Display) -> anyhow::Error {
anyhow::anyhow!("{} at line {} char {}", msg, self.line_num, self.cur_pos)
}
fn parse(mut self) -> Result<Vec<Line>> {
let mut lines = Vec::new();
while let Some(line) = self.lines.next() {
self.line_num += 1;
self.cur_line = line;
self.cur_line_chars = line.graphemes(true).collect();
lines.push(self.parse_line()?);
}
Ok(lines)
}
fn add_next_line(&mut self) -> Result<()> {
let next_line = self
.lines
.next()
.ok_or_else(|| anyhow::anyhow!("Unexpected eof"))?;
self.line_num += 1;
self.cur_line = next_line;
self.cur_line_chars.push("\n");
self.cur_line_chars.extend(next_line.graphemes(true));
Ok(())
}
fn parse_line(&mut self) -> Result<Line> {
self.cur_pos = 0;
if self.cur_line.trim_matches(' ').is_empty() {
return Ok(Line::Empty);
}
let mut line = Vec::new();
let mut text = String::new();
while let Some(c) = self.peek_char() {
// Skip space if text is empty
if text.is_empty() && (c == " " || c == "\t") {
self.cur_pos += 1;
continue;
}
// Label
if line.is_empty() && c == "#" {
self.cur_pos += 1;
let label = self.cur_line_chars[self.cur_pos..].join("");
return Ok(Line::Label(LabelNode(label)));
}
if c == "/" {
// Comment
if let Some(c) = self.peek_char_offset(1) {
if c == "/" {
let ctext = text.trim_end_matches(' ').trim_end_matches('\t');
if !ctext.is_empty() {
line.push(LineNode::Text(TextNode(ctext.to_owned())));
text.clear();
}
self.cur_pos += 2;
let comment = self.cur_line_chars[self.cur_pos..].join("");
line.push(LineNode::Comment(CommentNode(comment)));
break;
} else if c == "*" {
let ctext = text.trim_end_matches(' ').trim_end_matches('\t');
if !ctext.is_empty() {
line.push(LineNode::Text(TextNode(ctext.to_owned())));
text.clear();
}
self.cur_pos += 2;
let start_pos = self.cur_pos;
let mut ok = false;
loop {
while let Some(c) = self.next_char() {
if c == "*" && self.peek_char().is_some_and(|c| c == "/") {
let end_pos = self.cur_pos - 1;
self.cur_pos += 1;
ok = true;
line.push(LineNode::Comments(CommentBlock(
self.cur_line_chars[start_pos..end_pos].join(""),
)));
break;
}
}
if ok {
break;
}
self.add_next_line()?;
}
continue;
}
}
}
// command
if c == "\\" {
// check \R
if self.peek_char_offset(1).is_some_and(|c| c == "R") {
self.cur_pos += 2;
text.push_str("\\R");
continue;
}
let ctext = text.trim_end_matches(' ').trim_end_matches('\t');
if !ctext.is_empty() {
line.push(LineNode::Text(TextNode(ctext.to_owned())));
text.clear();
}
line.push(LineNode::Command(self.parse_command()?));
continue;
}
// name
if c == "" {
let ctext = text.trim_end_matches(' ').trim_end_matches('\t');
if !ctext.is_empty() {
line.push(LineNode::Text(TextNode(ctext.to_owned())));
text.clear();
}
line.push(LineNode::Name(self.parse_name()?));
continue;
}
text.push_str(c);
self.cur_pos += 1;
}
let ctext = text.trim_end_matches(' ').trim_end_matches('\t');
if !ctext.is_empty() {
line.push(LineNode::Text(TextNode(ctext.to_owned())));
}
Ok(Line::Line(line))
}
fn parse_command(&mut self) -> Result<CommandNode> {
let c = self
.next_char()
.ok_or_else(|| self.error("Unexpected end of line"))?;
if c != "\\" {
return Err(self.error("Unexpected command start token"));
}
let mut name = String::new();
let mut args = Vec::new();
let mut in_quote = false;
let mut arg = String::new();
let mut ok = false;
while let Some(c) = self.peek_char() {
if c == "(" {
ok = true;
self.cur_pos += 1;
break;
}
if c == ")" {
return Err(self.error("Unexpected ) when parsing command"));
}
if !c.is_ascii() {
break;
}
name.push_str(c);
self.cur_pos += 1;
continue;
}
if !ok {
return Ok(CommandNode {
name: name.trim_matches(' ').trim_matches('\t').to_owned(),
args: Vec::new(),
has_args: false,
});
}
loop {
let c = self
.next_char()
.ok_or_else(|| self.error("Unexpected end of line when parsing command"))?;
if in_quote {
if c == "\"" {
in_quote = false;
continue;
}
} else {
if c == "\"" {
in_quote = true;
continue;
}
if c == " " || c == "\t" {
if arg.is_empty() {
continue;
}
let mut tmp = c.to_string();
while let Some(c) = self.peek_char() {
if c == " " || c == "\t" {
self.cur_pos += 1;
tmp.push_str(c);
} else if c == "," || c == ")" {
break;
} else {
arg.push_str(&tmp);
break;
}
}
continue;
}
if c == "," {
args.push(arg);
arg = String::new();
continue;
}
if c == ")" {
args.push(arg);
return Ok(CommandNode {
name: name.trim_matches(' ').trim_matches('\t').to_owned(),
args,
has_args: true,
});
}
}
arg.push_str(c);
}
}
fn parse_name(&mut self) -> Result<NameNode> {
let c = self
.next_char()
.ok_or_else(|| self.error("Unexpected end of line"))?;
if c != "" {
return Err(self.error("Unexpected command start token"));
}
let mut name = String::new();
loop {
let c = self
.next_char()
.ok_or_else(|| self.error("Unexpected end of line when parsing name"))?;
if c == "" {
return Ok(NameNode(name));
}
name.push_str(c);
}
}
fn peek_char(&self) -> Option<&'a str> {
self.cur_line_chars.get(self.cur_pos).map(|s| *s)
}
fn peek_char_offset(&self, offset: isize) -> Option<&'a str> {
let target = (self.cur_pos as isize + offset as isize) as usize;
self.cur_line_chars.get(target).map(|s| *s)
}
fn next_char(&mut self) -> Option<&'a str> {
let t = self.cur_line_chars.get(self.cur_pos).map(|s| *s);
if t.is_some() {
self.cur_pos += 1;
}
t
}
}
#[derive(Debug)]
pub struct YurisTxt {
data: Vec<Line>,
bom: BomType,
}
impl YurisTxt {
pub fn new<D: AsRef<[u8]> + ?Sized>(data: &D, encoding: Encoding) -> Result<Self> {
let (text, bom) = decode_with_bom_detect(encoding, data.as_ref(), true)?;
let data = Parser::new(&text).parse()?;
Ok(Self { data, bom })
}
}
impl Script for YurisTxt {
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::new();
for line in &self.data {
if let Line::Line(line) = line {
let mut name = None;
let mut message = String::new();
for node in line.iter() {
if let LineNode::Name(n) = node {
name = Some(n.as_str());
} else if let LineNode::Text(text) = node {
message.push_str(&text.replace("\\R", "\n"));
} else if let LineNode::Command(cmd) = node {
if !message.is_empty() {
message.push_str(&cmd.serialize());
}
if cmd.name == "SEL" {
for arg in &cmd.args {
messages.push(Message::new(arg.to_owned(), None));
}
}
}
}
if !message.is_empty() {
messages.push(Message::new(message, name.map(|s| s.to_owned())));
}
}
}
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<()> {
let mut data = self.data.clone();
let mut mess = messages.iter();
let mut mes = mess.next();
for line in data.iter_mut() {
if let Line::Line(line) = line {
let mut name_index = None;
let mut message_index = None;
for (i, node) in line.iter_mut().enumerate() {
if let LineNode::Name(_) = node {
name_index = Some(i);
} else if let LineNode::Text(_) = node {
if message_index.is_none() {
message_index = Some(i);
}
} else if let LineNode::Command(cmd) = node {
if cmd.name == "SEL" {
for arg in cmd.args.iter_mut() {
let mut m = mes
.ok_or_else(|| anyhow::anyhow!("No more messages to import"))?
.message
.clone();
mes = mess.next();
if let Some(rep) = replacement {
for (k, v) in &rep.map {
m = m.replace(k, v);
}
}
*arg = m;
}
}
}
}
if let Some(message_idx) = message_index {
let m = mes.ok_or_else(|| anyhow::anyhow!("No more messages to import"))?;
mes = mess.next();
if let Some(name_idx) = name_index {
let mut name = m
.name
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Message don't have name"))?
.clone();
if let Some(rep) = replacement {
for (k, v) in &rep.map {
name = name.replace(k, v);
}
}
if let LineNode::Name(n) = &mut line[name_idx] {
n.0 = name;
}
}
let mut m = m.message.replace("\n", "\\R");
if let Some(rep) = replacement {
for (k, v) in &rep.map {
m = m.replace(k, v);
}
}
let data = Parser::new(&m).parse()?;
if data.len() != 1 {
anyhow::bail!("parsed length is not 1.");
}
let li = data[0].clone();
match li {
Line::Label(_) => anyhow::bail!("Unsupported"),
Line::Empty => {
line.splice(message_idx.., []);
}
Line::Line(li) => {
line.splice(message_idx.., li);
}
}
}
}
}
if mes.is_some() || mess.next().is_some() {
return Err(anyhow::anyhow!("Some messages were not processed."));
}
let data = data.serialize();
let data = encode_string_with_bom(encoding, &data, false, self.bom)?;
file.write_all(&data)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse1() {
let data = "\\T( , 250 ) \t【name】\t「……なんて」\t";
assert_eq!(
Parser::new(data).parse().unwrap(),
vec![Line::Line(vec![
LineNode::Command(CommandNode {
name: "T".into(),
args: vec!["".into(), "250".into()],
has_args: true,
}),
LineNode::Name(NameNode("name".into())),
LineNode::Text(TextNode("「……なんて」".into())),
])]
);
}
#[test]
fn test_parse2() {
let data = "\\T(2 5 \t0\t ) //TEST\n\\T ( \"250 \" , \"Wor,ks\" )";
assert_eq!(
Parser::new(data).parse().unwrap(),
vec![
Line::Line(vec![
LineNode::Command(CommandNode {
name: "T".into(),
args: vec!["2 5 \t0".into()],
has_args: true,
}),
LineNode::Comment(CommentNode("TEST".into()))
]),
Line::Line(vec![LineNode::Command(CommandNode {
name: "T".into(),
args: vec!["250 ".into(), "Wor,ks".into()],
has_args: true,
}),])
]
);
}
#[test]
fn test_parse3() {
let data = "\\VO(UDA_0_ALL_0007_0004)【ウダツ】「んで、昨日あの後どうしたん? 実習の日程はもう決まった\\Rのか?」";
assert_eq!(
Parser::new(data).parse().unwrap(),
vec![Line::Line(vec![
LineNode::Command(CommandNode {
name: "VO".into(),
args: vec!["UDA_0_ALL_0007_0004".into()],
has_args: true,
}),
LineNode::Name(NameNode("ウダツ".into())),
LineNode::Text(TextNode(
"「んで、昨日あの後どうしたん? 実習の日程はもう決まった\\Rのか?」".into()
)),
])]
);
}
#[test]
fn test_parse4() {
let data = "\\GO.TITLE";
assert_eq!(
Parser::new(data).parse().unwrap(),
vec![Line::Line(vec![LineNode::Command(CommandNode {
name: "GO.TITLE".into(),
args: vec![],
has_args: false,
}),])]
);
}
#[test]
fn test_parse5() {
let data = r"TEST/*
\FOUT(600, 42, white)
\BG.CMXYZ( 402, 0, -45)
\BG(bg51 , 260, 0, 0)
\PSET(回想フレーム, 0)
\FIN(600, 41)
*/Test";
assert_eq!(
Parser::new(data).parse().unwrap(),
vec![Line::Line(vec![
LineNode::Text(TextNode("TEST".into())),
LineNode::Comments(CommentBlock(
r"
\FOUT(600, 42, white)
\BG.CMXYZ( 402, 0, -45)
\BG(bg51 , 260, 0, 0)
\PSET(回想フレーム, 0)
\FIN(600, 41)
"
.into()
)),
LineNode::Text(TextNode("Test".into())),
])]
);
}
}

View File

@@ -923,6 +923,9 @@ pub enum ScriptType {
#[cfg(feature = "yuris")]
/// Yu-Ris YSTB(compiled script) file (.ybn)
YurisYSTB,
#[cfg(feature = "yuris")]
/// Yu-Ris scenario text file (.txt)
YurisTxt,
}
#[derive(Clone, Debug, Serialize, Deserialize)]