Nihuya sebe

This commit is contained in:
2026-07-17 01:04:47 +03:00
parent bbaa21d14e
commit cd8e743429
29 changed files with 15952 additions and 16 deletions

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
View 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
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}

View File

@@ -0,0 +1,10 @@
{
"identifier": "default",
"description": "Default capabilities for the app",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default",
"log:default"
]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for the app","local":true,"windows":["main"],"permissions":["core:default","dialog:default","log:default"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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
View 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
View 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
View 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(&sector)?;
}
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, &sector_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
View 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
View 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
View 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
View 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"
]
}
}