Nihuya sebe
This commit is contained in:
5084
src-tauri/Cargo.lock
generated
Normal file
5084
src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
src-tauri/Cargo.toml
Normal file
24
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "persona-script-editor"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-log = "2"
|
||||
log = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[lib]
|
||||
name = "persona_script_editor_lib"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "persona-script-editor"
|
||||
path = "src/main.rs"
|
||||
3
src-tauri/build.rs
Normal file
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build();
|
||||
}
|
||||
10
src-tauri/capabilities/default.json
Normal file
10
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for the app",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"dialog:default",
|
||||
"log:default"
|
||||
]
|
||||
}
|
||||
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
src-tauri/gen/schemas/capabilities.json
Normal file
1
src-tauri/gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for the app","local":true,"windows":["main"],"permissions":["core:default","dialog:default","log:default"]}}
|
||||
2376
src-tauri/gen/schemas/desktop-schema.json
Normal file
2376
src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2376
src-tauri/gen/schemas/windows-schema.json
Normal file
2376
src-tauri/gen/schemas/windows-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
src-tauri/icons/icon.ico
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
121
src-tauri/src/e0.rs
Normal file
121
src-tauri/src/e0.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
//! E0/E1/E2/E3 container format parser
|
||||
//!
|
||||
//! Each container has:
|
||||
//! - Pointer table: uint16_le[] values, each = offset in 0x800-byte units
|
||||
//! Terminated by 0x0000. Last entry marks end-of-data.
|
||||
//! - Sub-files: aligned to 0x800 boundaries
|
||||
|
||||
use log::{debug, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const ALIGNMENT: usize = 0x800;
|
||||
|
||||
/// Parsed E0-style container
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Container {
|
||||
/// Raw sub-file data for each slot
|
||||
pub sub_files: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Parse a container from raw bytes
|
||||
pub fn parse_container(data: &[u8]) -> Container {
|
||||
let ptrs = parse_pointer_table(data);
|
||||
let num_sub_files = if ptrs.is_empty() { 0 } else { ptrs.len() - 1 };
|
||||
|
||||
debug!("parse_container: {} bytes, {} pointer entries, {} sub-files", data.len(), ptrs.len(), num_sub_files);
|
||||
|
||||
let mut sub_files = Vec::with_capacity(num_sub_files);
|
||||
for i in 0..num_sub_files {
|
||||
let start = ptrs[i] as usize * ALIGNMENT;
|
||||
let end = ptrs[i + 1] as usize * ALIGNMENT;
|
||||
let end = end.min(data.len());
|
||||
if start < end && start < data.len() {
|
||||
sub_files.push(data[start..end].to_vec());
|
||||
} else {
|
||||
warn!("parse_container: sub-file {} has invalid range [{:#x}..{:#x}], data.len()={:#x}", i, start, end, data.len());
|
||||
sub_files.push(Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
Container { sub_files }
|
||||
}
|
||||
|
||||
/// Rebuild container from sub-files back to raw bytes
|
||||
pub fn build_container(container: &Container) -> Vec<u8> {
|
||||
let n = container.sub_files.len();
|
||||
info!("build_container: rebuilding {} sub-files", n);
|
||||
// Pointer table: n+1 entries (sub-file starts + end marker) + terminator 0x0000
|
||||
// Padded to ALIGNMENT
|
||||
let _table_size = (n + 2) * 2; // +1 for end ptr, +1 for 0x0000 terminator
|
||||
let data_start = ALIGNMENT; // First sub-file always at 0x800
|
||||
|
||||
// Calculate offsets for each sub-file
|
||||
let mut offsets: Vec<usize> = Vec::with_capacity(n + 1);
|
||||
let mut current = data_start;
|
||||
for sf in &container.sub_files {
|
||||
offsets.push(current);
|
||||
let aligned_size = (sf.len() + ALIGNMENT - 1) & !(ALIGNMENT - 1);
|
||||
current += if sf.is_empty() { ALIGNMENT } else { aligned_size };
|
||||
}
|
||||
offsets.push(current); // end-of-data pointer
|
||||
|
||||
let total_size = current;
|
||||
let mut out = vec![0u8; total_size];
|
||||
|
||||
// Write pointer table
|
||||
for (i, &off) in offsets.iter().enumerate() {
|
||||
let ptr_val = (off / ALIGNMENT) as u16;
|
||||
let table_off = i * 2;
|
||||
if table_off + 2 <= out.len() {
|
||||
out[table_off..table_off + 2].copy_from_slice(&ptr_val.to_le_bytes());
|
||||
}
|
||||
}
|
||||
// Terminator 0x0000 is already there (vec initialized to 0)
|
||||
|
||||
// Write sub-files
|
||||
for (i, sf) in container.sub_files.iter().enumerate() {
|
||||
if !sf.is_empty() {
|
||||
let off = offsets[i];
|
||||
out[off..off + sf.len()].copy_from_slice(sf);
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse pointer table from container start, returns vec of uint16 values
|
||||
fn parse_pointer_table(data: &[u8]) -> Vec<u16> {
|
||||
let mut ptrs = Vec::new();
|
||||
let mut p = 0;
|
||||
while p + 2 <= data.len() && p < 4096 {
|
||||
let v = u16::from_le_bytes([data[p], data[p + 1]]);
|
||||
if v == 0 {
|
||||
break;
|
||||
}
|
||||
if !ptrs.is_empty() && v <= *ptrs.last().unwrap() {
|
||||
break;
|
||||
}
|
||||
ptrs.push(v);
|
||||
p += 2;
|
||||
}
|
||||
ptrs
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip() {
|
||||
// Create a simple container with 2 sub-files
|
||||
let container = Container {
|
||||
sub_files: vec![vec![0x42; 100], vec![0x55; 200]],
|
||||
};
|
||||
let built = build_container(&container);
|
||||
let parsed = parse_container(&built);
|
||||
assert_eq!(parsed.sub_files.len(), 2);
|
||||
// Sub-files are padded to ALIGNMENT in the container
|
||||
assert!(parsed.sub_files[0].starts_with(&[0x42; 100]));
|
||||
assert!(parsed.sub_files[1].starts_with(&[0x55; 200]));
|
||||
}
|
||||
}
|
||||
328
src-tauri/src/encoding.rs
Normal file
328
src-tauri/src/encoding.rs
Normal file
@@ -0,0 +1,328 @@
|
||||
//! Text encoding/decoding for Persona scripts
|
||||
//!
|
||||
//! Original Japanese encoding:
|
||||
//! - Single byte 0x01-0x7F: kana characters (font glyph index)
|
||||
//! - Two bytes 0x80xx: kanji/punctuation (TBL lookup)
|
||||
//! - 0xFF xx: control codes (newline, clear, wait, etc.)
|
||||
//!
|
||||
//! For Russian translation, single bytes 0x01-0x43 are remapped to Cyrillic.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// FF-prefix control code
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ControlCode {
|
||||
End, // FF 01 (string terminator)
|
||||
EndDialogue, // FF 02
|
||||
Newline, // FF 03
|
||||
Clear, // FF 04
|
||||
Wait(u8), // FF 05 XX 00
|
||||
Color(u8), // FF 06 XX
|
||||
FirstName, // FF 07
|
||||
Nickname, // FF 08
|
||||
Choice(u8), // FF 0E XX
|
||||
LastName, // FF 0F
|
||||
Unknown(u8), // FF XX (anything else)
|
||||
}
|
||||
|
||||
/// A decoded text element (either a character or control code)
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum TextElement {
|
||||
/// A displayable character (decoded to Unicode)
|
||||
Char(char),
|
||||
/// A raw two-byte code that couldn't be decoded
|
||||
RawCode(u8, u8),
|
||||
/// A control code
|
||||
Control(ControlCode),
|
||||
}
|
||||
|
||||
/// Character table mapping two-byte codes to Unicode characters
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CharTable {
|
||||
/// Map from 2-byte code (big-endian u16) to character
|
||||
pub code_to_char: HashMap<u16, char>,
|
||||
/// Map from character to 2-byte code
|
||||
pub char_to_code: HashMap<char, u16>,
|
||||
/// Single-byte glyph table (index -> char)
|
||||
pub single_byte: HashMap<u8, char>,
|
||||
/// Reverse single-byte (char -> index)
|
||||
pub single_byte_rev: HashMap<char, u8>,
|
||||
}
|
||||
|
||||
/// Standard Japanese kana table (single-byte 0x01-0x7F)
|
||||
const KANA: &[char] = &[
|
||||
'\0', 'あ','い','う','え','お','か','き','く','け','こ',
|
||||
'さ','し','す','せ','そ','た','ち','つ','て','と',
|
||||
'な','に','ぬ','ね','の','は','ひ','ふ','へ','ほ',
|
||||
'ま','み','む','め','も','や','ゆ','よ','ら','り',
|
||||
'る','れ','ろ','わ','を','ん',
|
||||
'ア','イ','ウ','エ','オ','カ','キ','ク','ケ','コ',
|
||||
'サ','シ','ス','セ','ソ','タ','チ','ツ','テ','ト',
|
||||
'ナ','ニ','ヌ','ネ','ノ','ハ','ヒ','フ','ヘ','ホ',
|
||||
'マ','ミ','ム','メ','モ','ヤ','ユ','ヨ','ラ','リ',
|
||||
'ル','レ','ロ','ワ','ヲ','ン',
|
||||
'ガ','ギ','グ','ゲ','ゴ','ザ','ジ','ズ','ゼ','ゾ',
|
||||
'ダ','ヂ','ヅ','デ','ド','バ','ビ','ブ','ベ','ボ',
|
||||
'パ','ピ','プ','ペ','ポ','ァ','ィ','ゥ','ェ','ォ',
|
||||
'ャ','ュ','ョ','ッ','。',
|
||||
// 0x7F = 。 is the last single-byte kana (0x01-0x7F range)
|
||||
// 0x80+ are always first byte of two-byte codes (via TBL)
|
||||
];
|
||||
|
||||
/// Russian Cyrillic single-byte mapping (0x01-0x43)
|
||||
const CYRILLIC: &str = "АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдеёжзийклмнопрстуфхцчшщъыьэюя";
|
||||
|
||||
impl CharTable {
|
||||
/// Create a table for original Japanese encoding
|
||||
pub fn japanese() -> Self {
|
||||
let mut single_byte = HashMap::new();
|
||||
let mut single_byte_rev = HashMap::new();
|
||||
for (i, &ch) in KANA.iter().enumerate().skip(1) {
|
||||
if ch != '\0' {
|
||||
single_byte.insert(i as u8, ch);
|
||||
single_byte_rev.insert(ch, i as u8);
|
||||
}
|
||||
}
|
||||
CharTable {
|
||||
code_to_char: HashMap::new(),
|
||||
char_to_code: HashMap::new(),
|
||||
single_byte,
|
||||
single_byte_rev,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a table for Russian Cyrillic encoding
|
||||
pub fn russian() -> Self {
|
||||
let mut single_byte = HashMap::new();
|
||||
let mut single_byte_rev = HashMap::new();
|
||||
for (i, ch) in CYRILLIC.chars().enumerate() {
|
||||
let code = (i + 1) as u8;
|
||||
single_byte.insert(code, ch);
|
||||
single_byte_rev.insert(ch, code);
|
||||
}
|
||||
// Space = 0x43 (index 67)
|
||||
let space_code = (CYRILLIC.chars().count() + 1) as u8;
|
||||
single_byte.insert(space_code, ' ');
|
||||
single_byte_rev.insert(' ', space_code);
|
||||
|
||||
CharTable {
|
||||
code_to_char: HashMap::new(),
|
||||
char_to_code: HashMap::new(),
|
||||
single_byte,
|
||||
single_byte_rev,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a TBL file to populate two-byte mappings
|
||||
pub fn load_tbl(&mut self, tbl_content: &str) {
|
||||
for line in tbl_content.lines() {
|
||||
let line = line.trim();
|
||||
if let Some((hex_part, char_part)) = line.split_once('=') {
|
||||
if let Ok(code) = u16::from_str_radix(hex_part.trim(), 16) {
|
||||
if let Some(ch) = char_part.chars().next() {
|
||||
self.code_to_char.insert(code, ch);
|
||||
self.char_to_code.entry(ch).or_insert(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode raw script bytes into text elements
|
||||
pub fn decode_string(raw: &[u8], table: &CharTable) -> Vec<TextElement> {
|
||||
let mut result = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < raw.len() {
|
||||
let b = raw[i];
|
||||
if b == 0xFF {
|
||||
// Control code
|
||||
if i + 1 >= raw.len() {
|
||||
break;
|
||||
}
|
||||
let cmd = raw[i + 1];
|
||||
let ctrl = match cmd {
|
||||
0x01 => { i += 2; ControlCode::End }
|
||||
0x02 => { i += 2; ControlCode::EndDialogue }
|
||||
0x03 => { i += 2; ControlCode::Newline }
|
||||
0x04 => { i += 2; ControlCode::Clear }
|
||||
0x05 => {
|
||||
let val = if i + 2 < raw.len() { raw[i + 2] } else { 0 };
|
||||
i += 4; // FF 05 XX 00
|
||||
ControlCode::Wait(val)
|
||||
}
|
||||
0x06 => {
|
||||
let val = if i + 2 < raw.len() { raw[i + 2] } else { 0 };
|
||||
i += 3;
|
||||
ControlCode::Color(val)
|
||||
}
|
||||
0x07 => { i += 2; ControlCode::FirstName }
|
||||
0x08 => { i += 2; ControlCode::Nickname }
|
||||
0x0E => {
|
||||
let val = if i + 2 < raw.len() { raw[i + 2] } else { 0 };
|
||||
i += 3;
|
||||
ControlCode::Choice(val)
|
||||
}
|
||||
0x0F => { i += 2; ControlCode::LastName }
|
||||
_ => { i += 2; ControlCode::Unknown(cmd) }
|
||||
};
|
||||
result.push(TextElement::Control(ctrl));
|
||||
} else if b >= 0x80 {
|
||||
// Two-byte character (kanji, punctuation via TBL)
|
||||
if i + 1 >= raw.len() {
|
||||
break;
|
||||
}
|
||||
let hi = b;
|
||||
let lo = raw[i + 1];
|
||||
let code = ((hi as u16) << 8) | (lo as u16);
|
||||
if let Some(&ch) = table.code_to_char.get(&code) {
|
||||
result.push(TextElement::Char(ch));
|
||||
} else {
|
||||
result.push(TextElement::RawCode(hi, lo));
|
||||
}
|
||||
i += 2;
|
||||
} else if b >= 0x01 {
|
||||
// Single-byte character (0x01-0x7F kana only)
|
||||
if let Some(&ch) = table.single_byte.get(&b) {
|
||||
result.push(TextElement::Char(ch));
|
||||
} else {
|
||||
result.push(TextElement::RawCode(0x00, b));
|
||||
}
|
||||
i += 1;
|
||||
} else {
|
||||
// Null byte - skip
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Convert text elements back to a display string (for the editor UI)
|
||||
pub fn elements_to_display(elements: &[TextElement]) -> String {
|
||||
let mut s = String::new();
|
||||
for el in elements {
|
||||
match el {
|
||||
TextElement::Char(ch) => s.push(*ch),
|
||||
TextElement::RawCode(hi, lo) => {
|
||||
s.push_str(&format!("|{:02X}{:02X}", hi, lo));
|
||||
}
|
||||
TextElement::Control(ctrl) => {
|
||||
match ctrl {
|
||||
ControlCode::End => s.push_str("[end]"),
|
||||
ControlCode::EndDialogue => {} // don't show terminator
|
||||
ControlCode::Newline => s.push_str("[nl]"),
|
||||
ControlCode::Clear => s.push_str("[clear]"),
|
||||
ControlCode::Wait(n) => s.push_str(&format!("[wait={}]", n)),
|
||||
ControlCode::Color(n) => s.push_str(&format!("[color={}]", n)),
|
||||
ControlCode::FirstName => s.push_str("[firstname]"),
|
||||
ControlCode::Nickname => s.push_str("[nickname]"),
|
||||
ControlCode::Choice(n) => s.push_str(&format!("[choice={}]", n)),
|
||||
ControlCode::LastName => s.push_str("[lastname]"),
|
||||
ControlCode::Unknown(n) => s.push_str(&format!("[ff{:02x}]", n)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Encode a display string (with [nl], [clear], etc.) back to raw script bytes.
|
||||
/// This is the inverse of decode_string + elements_to_display.
|
||||
pub fn encode_display_string(text: &str, table: &CharTable) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut i = 0;
|
||||
|
||||
while i < chars.len() {
|
||||
let ch = chars[i];
|
||||
|
||||
if ch == '[' {
|
||||
// Parse control code tag
|
||||
if let Some(end) = chars[i..].iter().position(|&c| c == ']') {
|
||||
let tag: String = chars[i + 1..i + end].iter().collect();
|
||||
let encoded = encode_control_tag(&tag);
|
||||
out.extend_from_slice(&encoded);
|
||||
i += end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if ch == '|' && i + 4 < chars.len() {
|
||||
// Raw hex code |XXYY
|
||||
let hex: String = chars[i + 1..i + 5].iter().collect();
|
||||
if let Ok(val) = u16::from_str_radix(&hex, 16) {
|
||||
if val <= 0xFF {
|
||||
out.push(val as u8);
|
||||
} else {
|
||||
out.push((val >> 8) as u8);
|
||||
out.push((val & 0xFF) as u8);
|
||||
}
|
||||
i += 5;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Try single-byte encoding first
|
||||
if let Some(&code) = table.single_byte_rev.get(&ch) {
|
||||
out.push(code);
|
||||
} else if let Some(&code) = table.char_to_code.get(&ch) {
|
||||
// Two-byte encoding
|
||||
out.push((code >> 8) as u8);
|
||||
out.push((code & 0xFF) as u8);
|
||||
} else {
|
||||
// Unknown character - skip with warning
|
||||
log::warn!("encode_display_string: unknown char '{}' (U+{:04X}), skipping", ch, ch as u32);
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Ensure string ends with FF 01
|
||||
if !out.ends_with(&[0xFF, 0x01]) {
|
||||
out.push(0xFF);
|
||||
out.push(0x01);
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Encode a single control tag like "nl", "clear", "wait=32", "choice=16"
|
||||
fn encode_control_tag(tag: &str) -> Vec<u8> {
|
||||
let lower = tag.to_lowercase();
|
||||
|
||||
// Check for tags with values: "wait=X", "color=X", "choice=X"
|
||||
if let Some(val_str) = lower.strip_prefix("wait=") {
|
||||
let val: u8 = val_str.parse().unwrap_or(32);
|
||||
return vec![0xFF, 0x05, val, 0x00];
|
||||
}
|
||||
if let Some(val_str) = lower.strip_prefix("color=") {
|
||||
let val: u8 = val_str.parse().unwrap_or(0);
|
||||
return vec![0xFF, 0x06, val];
|
||||
}
|
||||
if let Some(val_str) = lower.strip_prefix("choice=") {
|
||||
let val: u8 = val_str.parse().unwrap_or(0);
|
||||
return vec![0xFF, 0x0E, val];
|
||||
}
|
||||
|
||||
// Check for ff## pattern (raw FF code)
|
||||
if lower.starts_with("ff") && lower.len() == 4 {
|
||||
if let Ok(val) = u8::from_str_radix(&lower[2..], 16) {
|
||||
return vec![0xFF, val];
|
||||
}
|
||||
}
|
||||
|
||||
match lower.as_str() {
|
||||
"end" => vec![0xFF, 0x02],
|
||||
"nl" => vec![0xFF, 0x03],
|
||||
"clear" => vec![0xFF, 0x04],
|
||||
"firstname" => vec![0xFF, 0x07],
|
||||
"nickname" => vec![0xFF, 0x08],
|
||||
"lastname" => vec![0xFF, 0x0F],
|
||||
"close" => vec![0xFF, 0x01], // close = terminator
|
||||
_ => {
|
||||
log::warn!("encode_control_tag: unknown tag [{}]", tag);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
208
src-tauri/src/iso.rs
Normal file
208
src-tauri/src/iso.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
//! ISO 9660 MODE2/2352 raw CD image — sector-based I/O
|
||||
//!
|
||||
//! PSX discs use raw sectors of 2352 bytes each.
|
||||
//! User data is at offset 24, size 2048 per sector.
|
||||
//! This module reads/writes sectors on demand via file seek, not loading the entire image.
|
||||
|
||||
use log::{info, warn};
|
||||
use std::fs::File;
|
||||
use std::io::{self, Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const SECTOR_SIZE: usize = 2352;
|
||||
pub const USER_OFFSET: usize = 24;
|
||||
pub const USER_SIZE: usize = 2048;
|
||||
|
||||
/// Game file table LBA (FSECT = sector start for each game file)
|
||||
pub const FSECT_LBA: u32 = 634;
|
||||
|
||||
/// Known file indices in the game's internal file table
|
||||
pub const FILE_IDX_E0: usize = 273;
|
||||
pub const FILE_IDX_E1: usize = 274;
|
||||
pub const FILE_IDX_E2: usize = 275;
|
||||
pub const FILE_IDX_E3: usize = 276;
|
||||
pub const FILE_IDX_FONT: usize = 5;
|
||||
|
||||
/// Handle to an opened ISO image (sector-based I/O)
|
||||
pub struct IsoImage {
|
||||
pub path: PathBuf,
|
||||
pub sector_count: u32,
|
||||
}
|
||||
|
||||
impl IsoImage {
|
||||
/// Open an ISO image and validate it
|
||||
pub fn open(path: &Path) -> io::Result<Self> {
|
||||
let metadata = std::fs::metadata(path)?;
|
||||
let file_size = metadata.len() as usize;
|
||||
|
||||
if file_size % SECTOR_SIZE != 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("File size {} is not a multiple of {} (not a raw CD image)", file_size, SECTOR_SIZE),
|
||||
));
|
||||
}
|
||||
|
||||
let sector_count = (file_size / SECTOR_SIZE) as u32;
|
||||
info!("ISO opened: {} bytes, {} sectors", file_size, sector_count);
|
||||
|
||||
Ok(IsoImage {
|
||||
path: path.to_path_buf(),
|
||||
sector_count,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read user data (2048 bytes) from a single sector
|
||||
pub fn read_sector(&self, lba: u32) -> io::Result<Vec<u8>> {
|
||||
let mut file = File::open(&self.path)?;
|
||||
let offset = lba as u64 * SECTOR_SIZE as u64 + USER_OFFSET as u64;
|
||||
file.seek(SeekFrom::Start(offset))?;
|
||||
let mut buf = vec![0u8; USER_SIZE];
|
||||
file.read_exact(&mut buf)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Read multiple consecutive sectors' user data
|
||||
pub fn read_sectors(&self, lba: u32, count: u32) -> io::Result<Vec<u8>> {
|
||||
let mut file = File::open(&self.path)?;
|
||||
let mut data = Vec::with_capacity(count as usize * USER_SIZE);
|
||||
for i in 0..count {
|
||||
let offset = (lba + i) as u64 * SECTOR_SIZE as u64 + USER_OFFSET as u64;
|
||||
file.seek(SeekFrom::Start(offset))?;
|
||||
let mut buf = vec![0u8; USER_SIZE];
|
||||
file.read_exact(&mut buf)?;
|
||||
data.extend_from_slice(&buf);
|
||||
}
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Read a game file (consecutive sectors, trimmed to exact size)
|
||||
pub fn read_file(&self, lba: u32, size: u32) -> io::Result<Vec<u8>> {
|
||||
let sector_count = (size as usize + USER_SIZE - 1) / USER_SIZE;
|
||||
let mut data = self.read_sectors(lba, sector_count as u32)?;
|
||||
data.truncate(size as usize);
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Write user data to consecutive sectors (preserves sector headers)
|
||||
pub fn write_file(&self, lba: u32, data: &[u8]) -> io::Result<()> {
|
||||
let mut file = std::fs::OpenOptions::new().write(true).open(&self.path)?;
|
||||
let sector_count = (data.len() + USER_SIZE - 1) / USER_SIZE;
|
||||
for i in 0..sector_count {
|
||||
let src_start = i * USER_SIZE;
|
||||
let src_end = std::cmp::min(src_start + USER_SIZE, data.len());
|
||||
let chunk = &data[src_start..src_end];
|
||||
|
||||
let offset = (lba as usize + i) * SECTOR_SIZE + USER_OFFSET;
|
||||
file.seek(SeekFrom::Start(offset as u64))?;
|
||||
file.write_all(chunk)?;
|
||||
|
||||
// Zero-pad remainder if chunk is short
|
||||
if chunk.len() < USER_SIZE {
|
||||
let padding = vec![0u8; USER_SIZE - chunk.len()];
|
||||
file.write_all(&padding)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Append data at end of image (extends the file). Returns new LBA.
|
||||
pub fn append_file(&self, data: &[u8]) -> io::Result<u32> {
|
||||
let mut file = std::fs::OpenOptions::new().write(true).read(true).open(&self.path)?;
|
||||
let file_size = file.seek(SeekFrom::End(0))?;
|
||||
let current_sectors = file_size as usize / SECTOR_SIZE;
|
||||
let new_lba = current_sectors as u32;
|
||||
|
||||
let sector_count = (data.len() + USER_SIZE - 1) / USER_SIZE;
|
||||
|
||||
// Write new sectors (full 2352-byte sectors with minimal headers)
|
||||
for i in 0..sector_count {
|
||||
let src_start = i * USER_SIZE;
|
||||
let src_end = std::cmp::min(src_start + USER_SIZE, data.len());
|
||||
let chunk = &data[src_start..src_end];
|
||||
|
||||
// Write a blank sector first
|
||||
let mut sector = vec![0u8; SECTOR_SIZE];
|
||||
// Copy user data at offset 24
|
||||
sector[USER_OFFSET..USER_OFFSET + chunk.len()].copy_from_slice(chunk);
|
||||
file.write_all(§or)?;
|
||||
}
|
||||
|
||||
info!("Appended {} sectors at LBA {}", sector_count, new_lba);
|
||||
Ok(new_lba)
|
||||
}
|
||||
|
||||
/// Read uint32_le from FSECT table at given index
|
||||
pub fn read_fsect_entry(&self, index: usize) -> io::Result<u32> {
|
||||
let sector_in_table = (index * 4) / USER_SIZE;
|
||||
let offset_in_sector = (index * 4) % USER_SIZE;
|
||||
let sector_data = self.read_sector(FSECT_LBA + sector_in_table as u32)?;
|
||||
Ok(u32::from_le_bytes(
|
||||
sector_data[offset_in_sector..offset_in_sector + 4].try_into().unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Write uint32_le to FSECT table at given index
|
||||
pub fn write_fsect_entry(&self, index: usize, value: u32) -> io::Result<()> {
|
||||
let sector_in_table = (index * 4) / USER_SIZE;
|
||||
let offset_in_sector = (index * 4) % USER_SIZE;
|
||||
let mut sector_data = self.read_sector(FSECT_LBA + sector_in_table as u32)?;
|
||||
sector_data[offset_in_sector..offset_in_sector + 4].copy_from_slice(&value.to_le_bytes());
|
||||
self.write_sector_data(FSECT_LBA + sector_in_table as u32, §or_data)
|
||||
}
|
||||
|
||||
/// Write 2048 bytes of user data to a specific sector
|
||||
fn write_sector_data(&self, lba: u32, data: &[u8]) -> io::Result<()> {
|
||||
assert!(data.len() == USER_SIZE);
|
||||
let mut file = std::fs::OpenOptions::new().write(true).open(&self.path)?;
|
||||
let offset = lba as u64 * SECTOR_SIZE as u64 + USER_OFFSET as u64;
|
||||
file.seek(SeekFrom::Start(offset))?;
|
||||
file.write_all(data)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Determine container size by reading its pointer table from first sector
|
||||
pub fn get_container_size(&self, lba: u32) -> io::Result<u32> {
|
||||
let first_sector = self.read_sector(lba)?;
|
||||
let mut last_ptr: u16 = 0;
|
||||
let mut p = 0;
|
||||
while p + 2 <= first_sector.len() {
|
||||
let v = u16::from_le_bytes([first_sector[p], first_sector[p + 1]]);
|
||||
if v == 0 {
|
||||
break;
|
||||
}
|
||||
if last_ptr > 0 && v <= last_ptr {
|
||||
break;
|
||||
}
|
||||
last_ptr = v;
|
||||
p += 2;
|
||||
}
|
||||
Ok((last_ptr as u32) * (crate::e0::ALIGNMENT as u32))
|
||||
}
|
||||
|
||||
/// Get info about all text container files
|
||||
pub fn get_text_files(&self) -> io::Result<Vec<GameFile>> {
|
||||
let files = [
|
||||
(FILE_IDX_E0, "E0.BIN"),
|
||||
(FILE_IDX_E1, "E1.BIN"),
|
||||
(FILE_IDX_E2, "E2.BIN"),
|
||||
(FILE_IDX_E3, "E3.BIN"),
|
||||
];
|
||||
|
||||
let mut result = Vec::new();
|
||||
for &(idx, name) in &files {
|
||||
let lba = self.read_fsect_entry(idx)?;
|
||||
let size = self.get_container_size(lba)?;
|
||||
result.push(GameFile { index: idx, name, lba, size });
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
/// Information about a game file
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GameFile {
|
||||
pub index: usize,
|
||||
pub name: &'static str,
|
||||
pub lba: u32,
|
||||
pub size: u32,
|
||||
}
|
||||
4
src-tauri/src/lib.rs
Normal file
4
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod e0;
|
||||
pub mod script;
|
||||
pub mod encoding;
|
||||
pub mod iso;
|
||||
382
src-tauri/src/main.rs
Normal file
382
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,382 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use log::{error, info, warn};
|
||||
use persona_script_editor_lib::{e0, encoding, iso, script};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
use tauri::State;
|
||||
|
||||
/// Translation store: file_index -> scene_index -> string_index -> translated text
|
||||
type TranslationMap = HashMap<usize, HashMap<usize, HashMap<usize, String>>>;
|
||||
|
||||
struct AppState {
|
||||
iso: Mutex<Option<iso::IsoImage>>,
|
||||
/// Cached containers (only E0-E3, ~6MB total)
|
||||
containers: Mutex<HashMap<usize, e0::Container>>,
|
||||
table: Mutex<encoding::CharTable>,
|
||||
translations: Mutex<TranslationMap>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct FileInfo {
|
||||
name: String,
|
||||
index: usize,
|
||||
lba: u32,
|
||||
size: u32,
|
||||
sub_file_count: usize,
|
||||
string_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SceneInfo {
|
||||
index: usize,
|
||||
size: usize,
|
||||
string_count: usize,
|
||||
has_strings: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct StringInfo {
|
||||
index: usize,
|
||||
original: String,
|
||||
translation: String,
|
||||
raw_hex: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_iso(path: String, state: State<AppState>) -> Result<Vec<FileInfo>, String> {
|
||||
let start = Instant::now();
|
||||
info!("Opening ISO: {}", path);
|
||||
|
||||
let image = iso::IsoImage::open(std::path::Path::new(&path)).map_err(|e| {
|
||||
error!("Failed to open ISO: {}", e);
|
||||
format!("Failed to open: {}", e)
|
||||
})?;
|
||||
|
||||
info!("ISO validated: {} sectors in {:?}", image.sector_count, start.elapsed());
|
||||
|
||||
let text_files = image.get_text_files().map_err(|e| format!("Read error: {}", e))?;
|
||||
info!("Found {} text containers", text_files.len());
|
||||
|
||||
let mut result = Vec::new();
|
||||
let mut containers = HashMap::new();
|
||||
|
||||
for gf in &text_files {
|
||||
let t = Instant::now();
|
||||
let file_data = image.read_file(gf.lba, gf.size).map_err(|e| format!("Read error: {}", e))?;
|
||||
let container = e0::parse_container(&file_data);
|
||||
let mut string_count = 0;
|
||||
let mut scenes_with_strings = 0;
|
||||
for sf in &container.sub_files {
|
||||
if let Some(parsed) = script::parse_script(sf) {
|
||||
if !parsed.strings.is_empty() {
|
||||
scenes_with_strings += 1;
|
||||
string_count += parsed.strings.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
info!(" {} : LBA={} size={} scenes={}/{} strings={} ({:?})",
|
||||
gf.name, gf.lba, gf.size, scenes_with_strings, container.sub_files.len(), string_count, t.elapsed());
|
||||
|
||||
result.push(FileInfo {
|
||||
name: gf.name.to_string(),
|
||||
index: gf.index,
|
||||
lba: gf.lba,
|
||||
size: gf.size,
|
||||
sub_file_count: container.sub_files.len(),
|
||||
string_count,
|
||||
});
|
||||
containers.insert(gf.index, container);
|
||||
}
|
||||
|
||||
let total: usize = result.iter().map(|f| f.string_count).sum();
|
||||
info!("Total: {} strings, ~{}MB in RAM. Done in {:?}", total,
|
||||
containers.values().map(|c| c.sub_files.iter().map(|s| s.len()).sum::<usize>()).sum::<usize>() / 1024 / 1024,
|
||||
start.elapsed());
|
||||
|
||||
*state.iso.lock().unwrap() = Some(image);
|
||||
*state.containers.lock().unwrap() = containers;
|
||||
state.translations.lock().unwrap().clear();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_scenes(file_index: usize, state: State<AppState>) -> Result<Vec<SceneInfo>, String> {
|
||||
let containers = state.containers.lock().unwrap();
|
||||
let container = containers.get(&file_index).ok_or("File not loaded")?;
|
||||
|
||||
let mut scenes = Vec::new();
|
||||
for (i, sf) in container.sub_files.iter().enumerate() {
|
||||
let (string_count, has_strings) = if let Some(parsed) = script::parse_script(sf) {
|
||||
(parsed.strings.len(), !parsed.strings.is_empty())
|
||||
} else {
|
||||
(0, false)
|
||||
};
|
||||
scenes.push(SceneInfo { index: i, size: sf.len(), string_count, has_strings });
|
||||
}
|
||||
Ok(scenes)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_strings(file_index: usize, scene_index: usize, state: State<AppState>) -> Result<Vec<StringInfo>, String> {
|
||||
let containers = state.containers.lock().unwrap();
|
||||
let table = state.table.lock().unwrap();
|
||||
let translations = state.translations.lock().unwrap();
|
||||
|
||||
let container = containers.get(&file_index).ok_or("File not loaded")?;
|
||||
let sf = container.sub_files.get(scene_index).ok_or("Scene not found")?;
|
||||
let parsed = script::parse_script(sf).ok_or("Failed to parse script")?;
|
||||
|
||||
let scene_trans = translations.get(&file_index).and_then(|f| f.get(&scene_index));
|
||||
|
||||
let mut result = Vec::new();
|
||||
for (i, s) in parsed.strings.iter().enumerate() {
|
||||
let elements = encoding::decode_string(&s.raw, &table);
|
||||
let original = encoding::elements_to_display(&elements);
|
||||
let raw_hex = s.raw.iter().map(|b| format!("{:02x}", b)).collect::<String>();
|
||||
let translation = scene_trans
|
||||
.and_then(|st| st.get(&i))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| original.clone());
|
||||
result.push(StringInfo { index: i, original, translation, raw_hex });
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn update_string(file_index: usize, scene_index: usize, string_index: usize, translation: String, state: State<AppState>) -> Result<(), String> {
|
||||
state.translations.lock().unwrap()
|
||||
.entry(file_index).or_default()
|
||||
.entry(scene_index).or_default()
|
||||
.insert(string_index, translation);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn export_json(file_index: usize, output_path: String, state: State<AppState>) -> Result<String, String> {
|
||||
let start = Instant::now();
|
||||
info!("export_json: file={} -> {}", file_index, output_path);
|
||||
|
||||
let containers = state.containers.lock().unwrap();
|
||||
let table = state.table.lock().unwrap();
|
||||
let translations = state.translations.lock().unwrap();
|
||||
|
||||
let container = containers.get(&file_index).ok_or("File not loaded")?;
|
||||
let file_name = match file_index { 273=>"E0.BIN", 274=>"E1.BIN", 275=>"E2.BIN", 276=>"E3.BIN", _=>"unknown" };
|
||||
let scene_trans = translations.get(&file_index);
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ExportScene { index: usize, strings: Vec<ExportString> }
|
||||
#[derive(Serialize)]
|
||||
struct ExportString { index: usize, original: String, translation: String }
|
||||
#[derive(Serialize)]
|
||||
struct ExportFile { file_index: usize, file_name: String, scenes: Vec<ExportScene> }
|
||||
|
||||
let mut scenes = Vec::new();
|
||||
for (si, sf) in container.sub_files.iter().enumerate() {
|
||||
if let Some(parsed) = script::parse_script(sf) {
|
||||
if parsed.strings.is_empty() { continue; }
|
||||
let st = scene_trans.and_then(|f| f.get(&si));
|
||||
let strings: Vec<ExportString> = parsed.strings.iter().enumerate().map(|(i, s)| {
|
||||
let elements = encoding::decode_string(&s.raw, &table);
|
||||
let original = encoding::elements_to_display(&elements);
|
||||
let translation = st.and_then(|m| m.get(&i)).cloned().unwrap_or_else(|| original.clone());
|
||||
ExportString { index: i, original, translation }
|
||||
}).collect();
|
||||
scenes.push(ExportScene { index: si, strings });
|
||||
}
|
||||
}
|
||||
|
||||
let export = ExportFile { file_index, file_name: file_name.to_string(), scenes };
|
||||
let json = serde_json::to_string_pretty(&export).map_err(|e| format!("JSON error: {}", e))?;
|
||||
std::fs::write(&output_path, &json).map_err(|e| format!("Write error: {}", e))?;
|
||||
|
||||
let sc = export.scenes.len();
|
||||
let stc: usize = export.scenes.iter().map(|s| s.strings.len()).sum();
|
||||
info!("Exported {} scenes, {} strings in {:?}", sc, stc, start.elapsed());
|
||||
Ok(format!("Exported {} scenes, {} strings", sc, stc))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn import_json(input_path: String, state: State<AppState>) -> Result<String, String> {
|
||||
let start = Instant::now();
|
||||
info!("import_json: {}", input_path);
|
||||
|
||||
let content = std::fs::read_to_string(&input_path).map_err(|e| format!("Read error: {}", e))?;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ImportFile { file_index: usize, scenes: Vec<ImportScene>, #[serde(default)] file_name: String }
|
||||
#[derive(Deserialize)]
|
||||
struct ImportScene { index: usize, strings: Vec<ImportString> }
|
||||
#[derive(Deserialize)]
|
||||
struct ImportString { index: usize, translation: String, #[serde(default)] original: String }
|
||||
|
||||
let import: ImportFile = serde_json::from_str(&content).map_err(|e| format!("JSON error: {}", e))?;
|
||||
|
||||
let mut translations = state.translations.lock().unwrap();
|
||||
let mut count = 0;
|
||||
for scene in &import.scenes {
|
||||
for s in &scene.strings {
|
||||
if s.translation != s.original && !s.translation.is_empty() {
|
||||
translations.entry(import.file_index).or_default()
|
||||
.entry(scene.index).or_default()
|
||||
.insert(s.index, s.translation.clone());
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Imported {} translations in {:?}", count, start.elapsed());
|
||||
Ok(format!("Imported {} translations for {}", count, import.file_name))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_iso(output_path: String, state: State<AppState>) -> Result<String, String> {
|
||||
let start = Instant::now();
|
||||
info!("save_iso: {}", output_path);
|
||||
|
||||
let iso_guard = state.iso.lock().unwrap();
|
||||
let image = iso_guard.as_ref().ok_or("No ISO loaded")?;
|
||||
let containers = state.containers.lock().unwrap();
|
||||
let table = state.table.lock().unwrap();
|
||||
let translations = state.translations.lock().unwrap();
|
||||
|
||||
if translations.is_empty() {
|
||||
return Err("No translations to save".into());
|
||||
}
|
||||
|
||||
// Copy original ISO to output path
|
||||
info!("Copying ISO to output...");
|
||||
std::fs::copy(&image.path, &output_path).map_err(|e| format!("Copy error: {}", e))?;
|
||||
|
||||
let out_image = iso::IsoImage::open(std::path::Path::new(&output_path))
|
||||
.map_err(|e| format!("Open output error: {}", e))?;
|
||||
|
||||
let mut files_modified = 0;
|
||||
let mut strings_applied = 0;
|
||||
|
||||
for (&file_index, file_trans) in translations.iter() {
|
||||
let container = match containers.get(&file_index) {
|
||||
Some(c) => c,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let orig_lba = out_image.read_fsect_entry(file_index).map_err(|e| format!("FSECT read: {}", e))?;
|
||||
let orig_size = out_image.get_container_size(orig_lba).map_err(|e| format!("Size read: {}", e))?;
|
||||
|
||||
let mut new_container = container.clone();
|
||||
let mut modified = false;
|
||||
|
||||
for (&scene_idx, scene_trans) in file_trans.iter() {
|
||||
if scene_idx >= new_container.sub_files.len() { continue; }
|
||||
let sf = &new_container.sub_files[scene_idx];
|
||||
let parsed = match script::parse_script(sf) {
|
||||
Some(p) if !p.strings.is_empty() => p,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let mut new_strings: Vec<Vec<u8>> = Vec::new();
|
||||
let mut scene_modified = false;
|
||||
for (i, orig) in parsed.strings.iter().enumerate() {
|
||||
if let Some(trans_text) = scene_trans.get(&i) {
|
||||
new_strings.push(encoding::encode_display_string(trans_text, &table));
|
||||
strings_applied += 1;
|
||||
scene_modified = true;
|
||||
} else {
|
||||
new_strings.push(orig.raw.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if scene_modified {
|
||||
if let Some(rebuilt) = script::rebuild_script(&parsed, &new_strings) {
|
||||
info!(" file={} S{:03}: {} -> {} bytes", file_index, scene_idx, sf.len(), rebuilt.len());
|
||||
new_container.sub_files[scene_idx] = rebuilt;
|
||||
modified = true;
|
||||
} else {
|
||||
warn!(" file={} S{:03}: rebuild FAILED", file_index, scene_idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if modified {
|
||||
let new_data = e0::build_container(&new_container);
|
||||
info!(" Container rebuilt: {} bytes (was {})", new_data.len(), orig_size);
|
||||
|
||||
if new_data.len() as u32 <= orig_size {
|
||||
// Fits in-place
|
||||
out_image.write_file(orig_lba, &new_data).map_err(|e| format!("Write error: {}", e))?;
|
||||
info!(" Written in-place at LBA {}", orig_lba);
|
||||
} else {
|
||||
// Relocate: append at end, update FSECT
|
||||
let new_lba = out_image.append_file(&new_data).map_err(|e| format!("Append error: {}", e))?;
|
||||
out_image.write_fsect_entry(file_index, new_lba).map_err(|e| format!("FSECT write: {}", e))?;
|
||||
info!(" Relocated: LBA {} -> {} (FSECT updated)", orig_lba, new_lba);
|
||||
}
|
||||
files_modified += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let msg = format!("Saved! {} files, {} strings applied in {:?}", files_modified, strings_applied, start.elapsed());
|
||||
info!("{}", msg);
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut table = encoding::CharTable::japanese();
|
||||
|
||||
// Look for any *.tbl file next to the exe
|
||||
let exe_dir = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.to_path_buf()));
|
||||
|
||||
let mut tbl_loaded = false;
|
||||
if let Some(dir) = &exe_dir {
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("tbl") {
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
table.load_tbl(&content);
|
||||
eprintln!("[init] Loaded TBL: {} codes from {:?}", table.code_to_char.len(), path.file_name().unwrap());
|
||||
tbl_loaded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !tbl_loaded {
|
||||
eprintln!("[init] WARNING: No .tbl file found next to exe!");
|
||||
}
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(
|
||||
tauri_plugin_log::Builder::new()
|
||||
.target(tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout))
|
||||
.target(tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir { file_name: None }))
|
||||
.target(tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview))
|
||||
.level(log::LevelFilter::Info)
|
||||
.build(),
|
||||
)
|
||||
.manage(AppState {
|
||||
iso: Mutex::new(None),
|
||||
containers: Mutex::new(HashMap::new()),
|
||||
table: Mutex::new(table),
|
||||
translations: Mutex::new(HashMap::new()),
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
open_iso,
|
||||
get_scenes,
|
||||
get_strings,
|
||||
update_string,
|
||||
export_json,
|
||||
import_json,
|
||||
save_iso,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
206
src-tauri/src/script.rs
Normal file
206
src-tauri/src/script.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
//! Event script parser - extracts and rebuilds dialogue strings
|
||||
//!
|
||||
//! Sub-file internal structure:
|
||||
//! - Header: 8 bytes (hdr_size=8, marker=0x8010, varies, 0x8010)
|
||||
//! - Data section (everything after header):
|
||||
//! - Script bytecode and command tables
|
||||
//! - f[52]: uint16_le = ett (end of text table / start of text blob)
|
||||
//! - f[96]: uint16_le = table_ptr (string pointer table offset, 0xFFFF = no strings)
|
||||
//! - String pointer table entries: pattern FF 55 00 00 [ptr_lo ptr_hi] 10 80
|
||||
//! - Text blob: strings terminated by FF 01
|
||||
|
||||
use log::{debug, trace, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A single dialogue string extracted from a script
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScriptString {
|
||||
/// Offset of the pointer in the string pointer table (relative to data section)
|
||||
pub ptr_table_offset: usize,
|
||||
/// Offset of the actual text data (relative to data section)
|
||||
pub text_offset: usize,
|
||||
/// Raw encoded bytes of the string (including FF 01 terminator)
|
||||
pub raw: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Parsed event script sub-file
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EventScript {
|
||||
/// Header size in bytes (typically 8)
|
||||
pub header_size: usize,
|
||||
/// Raw header bytes
|
||||
pub header: Vec<u8>,
|
||||
/// End-of-text-table offset (start of text blob area) within data section
|
||||
pub ett: usize,
|
||||
/// String pointer table offset within data section (0xFFFF = no strings)
|
||||
pub table_ptr: usize,
|
||||
/// Extracted dialogue strings
|
||||
pub strings: Vec<ScriptString>,
|
||||
/// Full raw data section (everything after header)
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Parse an event script sub-file
|
||||
pub fn parse_script(sub_file: &[u8]) -> Option<EventScript> {
|
||||
if sub_file.len() < 108 {
|
||||
trace!("parse_script: sub_file too small ({} bytes)", sub_file.len());
|
||||
return None;
|
||||
}
|
||||
|
||||
let header_size = u16::from_le_bytes([sub_file[0], sub_file[1]]) as usize;
|
||||
if header_size < 4 || header_size > 256 || header_size >= sub_file.len() {
|
||||
warn!("parse_script: invalid header_size={} (file_len={})", header_size, sub_file.len());
|
||||
return None;
|
||||
}
|
||||
|
||||
let header = sub_file[..header_size].to_vec();
|
||||
let data = sub_file[header_size..].to_vec();
|
||||
|
||||
if data.len() < 100 {
|
||||
trace!("parse_script: data section too small ({} bytes)", data.len());
|
||||
return None;
|
||||
}
|
||||
|
||||
let ett = u16::from_le_bytes([data[52], data[53]]) as usize;
|
||||
let table_ptr = u16::from_le_bytes([data[96], data[97]]) as usize;
|
||||
|
||||
debug!("parse_script: size={} hdr={} data={} ett=0x{:04x} table_ptr=0x{:04x}",
|
||||
sub_file.len(), header_size, data.len(), ett, table_ptr);
|
||||
|
||||
// Scan for string pointers using FF 55 00 00 [lo] [hi] 10 80 pattern.
|
||||
// If table_ptr is valid, start scanning from there.
|
||||
// If table_ptr is 0xFFFF, do a full scan of the data section (fallback).
|
||||
let scan_start = if table_ptr != 0xFFFF && table_ptr < data.len() {
|
||||
table_ptr
|
||||
} else {
|
||||
debug!("parse_script: table_ptr=0x{:04x}, using full scan fallback", table_ptr);
|
||||
4 // start from beginning (need at least 4 bytes for prefix)
|
||||
};
|
||||
|
||||
let mut strings = Vec::new();
|
||||
let mut i = scan_start;
|
||||
while i + 4 <= data.len() {
|
||||
// Check if bytes at i+2..i+4 are 10 80
|
||||
// AND bytes at i-4..i are FF 55 00 00
|
||||
if i >= 4
|
||||
&& i + 4 <= data.len()
|
||||
&& data[i + 2] == 0x10
|
||||
&& data[i + 3] == 0x80
|
||||
&& data[i - 4] == 0xFF
|
||||
&& data[i - 3] == 0x55
|
||||
&& data[i - 2] == 0x00
|
||||
&& data[i - 1] == 0x00
|
||||
{
|
||||
let ptr_val = u16::from_le_bytes([data[i], data[i + 1]]) as usize;
|
||||
if ptr_val > 0 && ptr_val < data.len() {
|
||||
// Read string until FF 01 terminator
|
||||
let mut raw = Vec::new();
|
||||
let mut j = ptr_val;
|
||||
while j < data.len() - 1 {
|
||||
if data[j] == 0xFF && data[j + 1] == 0x01 {
|
||||
raw.push(0xFF);
|
||||
raw.push(0x01);
|
||||
break;
|
||||
}
|
||||
raw.push(data[j]);
|
||||
j += 1;
|
||||
}
|
||||
// Only accept if we found the terminator
|
||||
if raw.ends_with(&[0xFF, 0x01]) {
|
||||
strings.push(ScriptString {
|
||||
ptr_table_offset: i,
|
||||
text_offset: ptr_val,
|
||||
raw,
|
||||
});
|
||||
}
|
||||
}
|
||||
i += 4;
|
||||
} else {
|
||||
i += 2;
|
||||
}
|
||||
}
|
||||
|
||||
debug!("parse_script: found {} strings", strings.len());
|
||||
|
||||
Some(EventScript {
|
||||
header_size,
|
||||
header,
|
||||
ett,
|
||||
table_ptr,
|
||||
strings,
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/// Rebuild a script sub-file with new string data
|
||||
///
|
||||
/// Takes the original script and a list of new raw string bytes (one per original string).
|
||||
/// Returns the rebuilt sub-file bytes.
|
||||
pub fn rebuild_script(script: &EventScript, new_strings: &[Vec<u8>]) -> Option<Vec<u8>> {
|
||||
if new_strings.len() != script.strings.len() {
|
||||
warn!("rebuild_script: string count mismatch: got {} expected {}", new_strings.len(), script.strings.len());
|
||||
return None;
|
||||
}
|
||||
|
||||
debug!("rebuild_script: rebuilding with {} strings, ett=0x{:04x}", new_strings.len(), script.ett);
|
||||
|
||||
let mut data = script.data.clone();
|
||||
|
||||
// Build new text blob starting at ett
|
||||
let mut blob = Vec::new();
|
||||
let mut new_offsets: Vec<usize> = Vec::with_capacity(new_strings.len());
|
||||
|
||||
for raw in new_strings {
|
||||
let offset = script.ett + blob.len();
|
||||
new_offsets.push(offset);
|
||||
let mut s = raw.clone();
|
||||
if !s.ends_with(&[0xFF, 0x01]) {
|
||||
s.push(0xFF);
|
||||
s.push(0x01);
|
||||
}
|
||||
blob.extend_from_slice(&s);
|
||||
}
|
||||
|
||||
// Update string pointer values in the data section
|
||||
for (i, string_info) in script.strings.iter().enumerate() {
|
||||
let ptr_off = string_info.ptr_table_offset;
|
||||
let new_val = new_offsets[i] as u16;
|
||||
if ptr_off + 2 <= data.len() {
|
||||
data[ptr_off] = new_val as u8;
|
||||
data[ptr_off + 1] = (new_val >> 8) as u8;
|
||||
}
|
||||
}
|
||||
|
||||
// Replace text area: truncate at ett, append new blob, then append trailing data
|
||||
// "Trailing data" = everything after the last string in the original
|
||||
let original_text_end = if let Some(last) = script.strings.last() {
|
||||
last.text_offset + last.raw.len()
|
||||
} else {
|
||||
script.ett
|
||||
};
|
||||
let trailing = if original_text_end < script.data.len() {
|
||||
script.data[original_text_end..].to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
data.truncate(script.ett);
|
||||
data.extend_from_slice(&blob);
|
||||
data.extend_from_slice(&trailing);
|
||||
|
||||
// Rebuild full sub-file: header + data
|
||||
let mut result = script.header.clone();
|
||||
result.extend_from_slice(&data);
|
||||
Some(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_empty() {
|
||||
assert!(parse_script(&[]).is_none());
|
||||
assert!(parse_script(&[0; 50]).is_none());
|
||||
}
|
||||
}
|
||||
36
src-tauri/tauri.conf.json
Normal file
36
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicegui/nicegui/main/tauri.conf.json",
|
||||
"productName": "Persona Script Editor",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.persona.script-editor",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Persona Script Editor",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user