From fb07e46caa08db304eb9bd91eec5bffc1d67ea7b Mon Sep 17 00:00:00 2001 From: lifegpc Date: Wed, 24 Sep 2025 09:13:42 +0800 Subject: [PATCH] Add softpal pac archive support --- .github/workflows/CI.yml | 1 + .github/workflows/github-pages.yml | 1 + AGENTS.md | 18 ++ Cargo.toml | 3 +- README.md | 5 + src/scripts/mod.rs | 4 + src/scripts/softpal/arc/mod.rs | 18 ++ src/scripts/softpal/arc/pac.rs | 452 +++++++++++++++++++++++++++++ src/scripts/softpal/mod.rs | 2 + src/types.rs | 6 + 10 files changed, 509 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md create mode 100644 src/scripts/softpal/arc/mod.rs create mode 100644 src/scripts/softpal/arc/pac.rs diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 3cd1f83..5f5c101 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -5,6 +5,7 @@ on: - README.md - '.github/workflows/github-pages.yml' - '.github/workflows/release.yml' + - AGENTS.md branches: - master pull_request: diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml index 94866e8..9374ea5 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -15,6 +15,7 @@ on: - docker-compose.yml - check_features.py - README.md + - AGENTS.md # Allows you to run this workflow manually from the Actions tab workflow_dispatch: diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7d083eb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ +# Repository Guidelines + +## Project Structure & Module Organization +The CLI lives in `src/main.rs` with argument parsing in `src/args.rs` and data types in `src/types.rs`. Submodules under `src/format`, `src/scripts`, `src/output_scripts`, and `src/utils` hold codec implementations and shared helpers - mirror that layout when adding new game engines or formats. The procedural macro crate is in `msg_tool_macro/`; keep its API stable with the matching version declared in `Cargo.toml`. Sample game assets used for manual verification live under `testscripts/`, while patched reference outputs go in `patched/` and scratch artifacts in `output/`. +All scripts should implement the `Script` and `ScriptBuilder` trait in `src/scripts/base.rs`. New script's type should be registered in `src/types.rs`. The corresponding script builder should be registered in `src/scripts/mod.rs`. If new flag are added, please register them in `src/args.rs` and `src/types.rs` (`ExtraConfig`). +Some useful utilities are in `src/utils/`. Some utilities should enabled via feature flags in `Cargo.toml`. + +## Coding Style & Naming Conventions +Target the Rust 2024 edition with `rustfmt` defaults (4-space indentation, trailing commas). Modules and files stay in `snake_case`, public types in `PascalCase`, and flags/features use the hyphenated scheme already present (e.g., `bgi-arc`). Prefer explicit `use` blocks near call sites and annotate complex transforms with concise comments. Keep CLI option identifiers aligned with the conventions in `src/args.rs`. +DO NOT USE ANY CODE CAN CAUSE PANIC IN LIBRARY CODE. +panic only allowed in main.rs , args.rs and tests. + +## Core Utilities (`src/utils/`) +- `counter.rs` - Thread-safe counters summarizing script outcomes; used to report OK/ignored/error/warning totals. Use `crate::COUNTER` to get global instance. +- `encoding.rs` - Shared encode/decode helpers with BOM detection, replacement handling, and optional Kirikiri wrappers for MDF and SimpleCrypt payloads. +- `files.rs` - Path utilities to collect inputs, filter by known script or archive extensions, stream stdin/stdout, and sanitize Windows file names. +- `struct_pack.rs` - Traits plus blanket implementations for binary pack/unpack backed by the `msg_tool_macro` crate; used when codecs read or write structured data. +- Feature-gated helpers such as `bit_stream.rs` or `threadpool.rs` stay under the same module; enable them via the matching `utils-*` features in `Cargo.toml`. diff --git a/Cargo.toml b/Cargo.toml index 947129e..35f2aaa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ default = ["all-fmt", "image-jpg", "image-jxl", "image-webp", "audio-flac", "jie all-fmt = ["all-script", "all-img", "all-arc", "all-audio"] all-script = ["artemis", "artemis-panmimisoft", "bgi", "cat-system", "circus", "entis-gls", "escude", "ex-hibit", "favorite", "hexen-haus", "kirikiri", "silky", "softpal", "will-plus", "yaneurao", "yaneurao-itufuru"] all-img = ["bgi-img", "cat-system-img", "circus-img", "emote-img", "kirikiri-img", "softpal-img"] -all-arc = ["artemis-arc", "bgi-arc", "cat-system-arc", "circus-arc", "escude-arc"] +all-arc = ["artemis-arc", "bgi-arc", "cat-system-arc", "circus-arc", "escude-arc", "softpal-arc"] all-audio = ["bgi-audio", "circus-audio"] artemis = ["stylua", "utils-escape"] artemis-panmimisoft = ["artemis", "rust-ini"] @@ -81,6 +81,7 @@ kirikiri = ["emote-psb", "fancy-regex", "flate2", "json", "lz4", "utils-escape"] kirikiri-img = ["kirikiri", "image", "libtlg-rs"] silky = [] softpal = ["int-enum"] +softpal-arc = ["softpal"] softpal-img = ["softpal", "image"] will-plus = ["utils-str"] yaneurao = [] diff --git a/README.md b/README.md index 045b5dc..fed9ecc 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,11 @@ msg-tool create -t |---|---|---|---|---|---|---|---|---|---|---| | `softpal` | `softpal` | Softpal Script File (.src) | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | ❌ | | +| Archive Type | Feature Name | Name | Unpack | Pack | Remarks | +|---|---|---|---|---|---| +| `softpal-pac` | `softpal-arc` | Softpal Pac Archive File (.pac) | ✔️ | ❌ | | +| `softpal-pac-amuse` | `softpal-arc` | Softpal Amuse Pac Archive File (.pac) | ✔️ | ❌ | | + | Image Type | Feature Name | Name | Export | Import | Export Multiple | Import Multiple | Create | Remarks | |---|---|---|---|---|---|---|---|---| | `softpal-pgd-ge`/`pgd-ge`/`pgd` | `softpal-img` | Softpal PGD Ge Image File (.pgd) | ✔️ | ✔️ | ❌ | ❌ | ✔️ | | diff --git a/src/scripts/mod.rs b/src/scripts/mod.rs index 6fb335d..79d5ba3 100644 --- a/src/scripts/mod.rs +++ b/src/scripts/mod.rs @@ -114,6 +114,10 @@ lazy_static::lazy_static! { Box::new(bgi::audio::audio::BgiAudioBuilder::new()), #[cfg(feature = "entis-gls")] Box::new(entis_gls::srcxml::SrcXmlScriptBuilder::new()), + #[cfg(feature = "softpal-arc")] + Box::new(softpal::arc::pac::SoftpalPacBuilder::new()), + #[cfg(feature = "softpal-arc")] + Box::new(softpal::arc::pac::SoftpalPacBuilder::new_amuse()), #[cfg(feature = "softpal")] Box::new(softpal::scr::SoftpalScriptBuilder::new()), #[cfg(feature = "artemis-panmimisoft")] diff --git a/src/scripts/softpal/arc/mod.rs b/src/scripts/softpal/arc/mod.rs new file mode 100644 index 0000000..2034486 --- /dev/null +++ b/src/scripts/softpal/arc/mod.rs @@ -0,0 +1,18 @@ +pub mod pac; + +use crate::types::*; + +fn detect_script_type(_filename: &str, data: &[u8]) -> Option { + if data.len() >= 4 && data.starts_with(b"Sv20") { + return Some(ScriptType::Softpal); + } + #[cfg(feature = "softpal-img")] + if data.len() >= 4 && data.starts_with(b"GE \0") { + return Some(ScriptType::SoftpalPgdGe); + } + #[cfg(feature = "softpal-img")] + if data.len() >= 4 && (data.starts_with(b"PGD3") || data.starts_with(b"PGD2")) { + return Some(ScriptType::SoftpalPgd3); + } + None +} diff --git a/src/scripts/softpal/arc/pac.rs b/src/scripts/softpal/arc/pac.rs new file mode 100644 index 0000000..dd5f664 --- /dev/null +++ b/src/scripts/softpal/arc/pac.rs @@ -0,0 +1,452 @@ +//! Softpal PAC archive (.pac) +use super::*; +use crate::ext::io::*; +use crate::scripts::base::*; +use crate::types::*; +use anyhow::{Result, anyhow, ensure}; +use std::io::{Read, Seek, SeekFrom}; +use std::sync::{Arc, Mutex}; + +const SOFTPAL_INDEX_OFFSET: u64 = 0x3FE; +const AMUSE_INDEX_OFFSET: u64 = 0x804; +const XOR_KEY: u32 = 0xF7D5859D; + +#[derive(Debug, Clone, Copy)] +enum SoftpalPacVariant { + Softpal, + Amuse, +} + +#[derive(Debug)] +/// Softpal PAC archive builder. +pub struct SoftpalPacBuilder { + variant: SoftpalPacVariant, +} + +impl SoftpalPacBuilder { + /// Creates a builder for the classic Softpal PAC layout. + pub fn new() -> Self { + Self { + variant: SoftpalPacVariant::Softpal, + } + } + + /// Creates a builder for the Amuse Craft PAC layout. + pub fn new_amuse() -> Self { + Self { + variant: SoftpalPacVariant::Amuse, + } + } +} + +impl ScriptBuilder for SoftpalPacBuilder { + fn default_encoding(&self) -> Encoding { + Encoding::Cp932 + } + + fn default_archive_encoding(&self) -> Option { + Some(Encoding::Cp932) + } + + fn build_script( + &self, + buf: Vec, + _filename: &str, + _encoding: Encoding, + archive_encoding: Encoding, + config: &ExtraConfig, + _archive: Option<&Box>, + ) -> Result> { + Ok(Box::new(SoftpalPacArchive::new( + MemReader::new(buf), + archive_encoding, + config, + self.variant, + )?)) + } + + fn build_script_from_file( + &self, + filename: &str, + _encoding: Encoding, + archive_encoding: Encoding, + config: &ExtraConfig, + _archive: Option<&Box>, + ) -> Result> { + let file = std::fs::File::open(filename)?; + let reader = std::io::BufReader::new(file); + Ok(Box::new(SoftpalPacArchive::new( + reader, + archive_encoding, + config, + self.variant, + )?)) + } + + fn build_script_from_reader( + &self, + reader: Box, + _filename: &str, + _encoding: Encoding, + archive_encoding: Encoding, + config: &ExtraConfig, + _archive: Option<&Box>, + ) -> Result> { + Ok(Box::new(SoftpalPacArchive::new( + reader, + archive_encoding, + config, + self.variant, + )?)) + } + + fn extensions(&self) -> &'static [&'static str] { + &["pac"] + } + + fn script_type(&self) -> &'static ScriptType { + match self.variant { + SoftpalPacVariant::Softpal => &ScriptType::SoftpalPac, + SoftpalPacVariant::Amuse => &ScriptType::SoftpalPacAmuse, + } + } + + fn is_archive(&self) -> bool { + true + } + + fn is_this_format(&self, _filename: &str, buf: &[u8], buf_len: usize) -> Option { + match self.variant { + SoftpalPacVariant::Softpal => None, + SoftpalPacVariant::Amuse => { + if buf_len >= 4 && buf.starts_with(b"PAC ") { + Some(10) + } else { + None + } + } + } + } +} + +#[derive(Debug, Clone)] +struct SoftpalPacEntry { + name: String, + offset: u32, + size: u32, +} + +#[derive(Debug)] +/// Softpal PAC archive reader. +pub struct SoftpalPacArchive { + reader: Arc>, + entries: Vec, +} + +impl SoftpalPacArchive { + fn new( + mut reader: T, + archive_encoding: Encoding, + _config: &ExtraConfig, + variant: SoftpalPacVariant, + ) -> Result { + let encoding = match archive_encoding { + Encoding::Auto => Encoding::Cp932, + other => other, + }; + let file_len = reader.stream_length()?; + if let SoftpalPacVariant::Amuse = variant { + let signature = reader.peek_u32_at(0)?; + ensure!( + signature == 0x2043_4150, + "Invalid Softpal PAC/Amuse signature: {signature:08X}" + ); + } + + let count_offset = match variant { + SoftpalPacVariant::Softpal => 0, + SoftpalPacVariant::Amuse => 8, + }; + let count = reader.peek_i32_at(count_offset)?; + ensure!(count >= 0, "Negative entry count: {count}"); + let count = count as usize; + + if count == 0 { + return Ok(Self { + reader: Arc::new(Mutex::new(reader)), + entries: Vec::new(), + }); + } + + let (index_offset, name_length) = match variant { + SoftpalPacVariant::Softpal => { + let mut chosen = None; + for &candidate in &[0x20usize, 0x10usize] { + let first_offset = + reader.peek_u32_at(SOFTPAL_INDEX_OFFSET + candidate as u64 + 4)? as u64; + let expected = SOFTPAL_INDEX_OFFSET + (candidate as u64 + 8) * count as u64; + if first_offset == expected { + ensure!( + first_offset <= file_len, + "First entry offset {first_offset:#X} exceeds archive length {file_len:#X}" + ); + chosen = Some((SOFTPAL_INDEX_OFFSET, candidate)); + break; + } + } + chosen.ok_or_else(|| anyhow!("Unsupported Softpal PAC layout"))? + } + SoftpalPacVariant::Amuse => { + let name_length = 0x20usize; + let first_offset = + reader.peek_u32_at(AMUSE_INDEX_OFFSET + name_length as u64 + 4)? as u64; + let expected = AMUSE_INDEX_OFFSET + (name_length as u64 + 8) * count as u64; + ensure!( + first_offset == expected, + "Invalid Softpal PAC/Amuse index layout: expected {expected:#X}, got {first_offset:#X}" + ); + ensure!( + first_offset <= file_len, + "First entry offset {first_offset:#X} exceeds archive length {file_len:#X}" + ); + (AMUSE_INDEX_OFFSET, name_length) + } + }; + + reader.seek(SeekFrom::Start(index_offset))?; + let mut entries = Vec::with_capacity(count); + for _ in 0..count { + let name = reader.read_fstring(name_length, encoding, true)?; + let size = reader.read_u32()?; + let offset = reader.read_u32()?; + let end = offset as u64 + size as u64; + ensure!( + end <= file_len, + "Entry '{name}' exceeds archive bounds: offset={offset:#X}, size={size:#X}" + ); + entries.push(SoftpalPacEntry { name, offset, size }); + } + + Ok(Self { + reader: Arc::new(Mutex::new(reader)), + entries, + }) + } +} + +impl Script for SoftpalPacArchive { + fn default_output_script_type(&self) -> OutputScriptType { + OutputScriptType::Json + } + + fn default_format_type(&self) -> FormatOptions { + FormatOptions::None + } + + fn is_archive(&self) -> bool { + true + } + + fn iter_archive_filename<'a>( + &'a self, + ) -> Result> + 'a>> { + Ok(Box::new( + self.entries.iter().map(|entry| Ok(entry.name.clone())), + )) + } + + fn iter_archive_offset<'a>(&'a self) -> Result> + 'a>> { + Ok(Box::new( + self.entries.iter().map(|entry| Ok(entry.offset as u64)), + )) + } + + fn open_file<'a>(&'a self, index: usize) -> Result> { + let entry = self + .entries + .get(index) + .ok_or_else(|| anyhow!("Index out of bounds: {index}"))?; + let mut buf = [0u8; 16]; + let buflen = self.reader.cpeek_at(entry.offset as u64, &mut buf)?; + let script_type = detect_script_type(&entry.name, &buf[..buflen]); + if buflen >= 16 && should_decrypt_entry(&buf) { + let mut data = vec![0u8; entry.size as usize]; + self.reader.cpeek_exact_at(entry.offset as u64, &mut data)?; + decrypt_entry(&mut data); + Ok(Box::new(MemEntry::new( + entry.name.clone(), + data, + script_type, + ))) + } else { + Ok(Box::new(PacEntry::new( + entry.clone(), + self.reader.clone(), + script_type, + ))) + } + } +} + +fn should_decrypt_entry(data: &[u8]) -> bool { + data.len() > 16 && data[0] == b'$' +} + +fn decrypt_entry(data: &mut [u8]) { + if data.len() <= 16 { + return; + } + let mut shift: u32 = 4; + for chunk in data[16..].chunks_exact_mut(4) { + let mut block = [0u8; 4]; + block.copy_from_slice(chunk); + let rotate = (shift & 7) as u32; + block[0] = block[0].rotate_left(rotate); + shift = shift.wrapping_add(1); + let decrypted = u32::from_le_bytes(block) ^ XOR_KEY; + chunk.copy_from_slice(&decrypted.to_le_bytes()); + } +} + +#[derive(Debug)] +struct MemEntry { + name: String, + data: Vec, + pos: usize, + script_type: Option, +} + +impl MemEntry { + pub fn new(name: String, data: Vec, script_type: Option) -> Self { + Self { + name, + data, + pos: 0, + script_type, + } + } +} + +impl ArchiveContent for MemEntry { + fn name(&self) -> &str { + &self.name + } + + fn script_type(&self) -> Option<&ScriptType> { + self.script_type.as_ref() + } +} + +impl Read for MemEntry { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if self.pos >= self.data.len() { + return Ok(0); + } + let bytes_to_read = buf.len().min(self.data.len() - self.pos); + if bytes_to_read == 0 { + return Ok(0); + } + buf[..bytes_to_read].copy_from_slice(&self.data[self.pos..self.pos + bytes_to_read]); + self.pos += bytes_to_read; + Ok(bytes_to_read) + } +} + +impl Seek for MemEntry { + fn seek(&mut self, pos: SeekFrom) -> std::io::Result { + let len = self.data.len() as i64; + let current = self.pos as i64; + let new_pos = match pos { + SeekFrom::Start(offset) => offset as i64, + SeekFrom::End(offset) => len + offset, + SeekFrom::Current(offset) => current + offset, + }; + if new_pos < 0 || new_pos > len { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Seek position is out of bounds", + )); + } + self.pos = new_pos as usize; + Ok(self.pos as u64) + } + + fn stream_position(&mut self) -> std::io::Result { + Ok(self.pos as u64) + } +} + +#[derive(Debug)] +struct PacEntry { + header: SoftpalPacEntry, + pos: u64, + reader: Arc>, + script_type: Option, +} + +impl PacEntry { + fn new( + header: SoftpalPacEntry, + reader: Arc>, + script_type: Option, + ) -> Self { + Self { + header, + pos: 0, + reader, + script_type, + } + } +} + +impl ArchiveContent for PacEntry { + fn name(&self) -> &str { + &self.header.name + } + + fn script_type(&self) -> Option<&ScriptType> { + self.script_type.as_ref() + } +} + +impl Read for PacEntry { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if self.pos >= self.header.size as u64 { + return Ok(0); + } + let bytes_to_read = buf.len().min((self.header.size as u64 - self.pos) as usize); + if bytes_to_read == 0 { + return Ok(0); + } + let bytes_read = self.reader.cpeek_at( + self.header.offset as u64 + self.pos, + &mut buf[..bytes_to_read], + )?; + self.pos += bytes_read as u64; + Ok(bytes_read) + } +} + +impl Seek for PacEntry { + fn seek(&mut self, pos: SeekFrom) -> std::io::Result { + let len = self.header.size as i64; + let current = self.pos as i64; + let new_pos = match pos { + SeekFrom::Start(offset) => offset as i64, + SeekFrom::End(offset) => len + offset, + SeekFrom::Current(offset) => current + offset, + }; + if new_pos < 0 || new_pos > len { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Seek position is out of bounds", + )); + } + self.pos = new_pos as u64; + Ok(self.pos as u64) + } + + fn stream_position(&mut self) -> std::io::Result { + Ok(self.pos as u64) + } +} diff --git a/src/scripts/softpal/mod.rs b/src/scripts/softpal/mod.rs index 286e809..072b52d 100644 --- a/src/scripts/softpal/mod.rs +++ b/src/scripts/softpal/mod.rs @@ -1,4 +1,6 @@ //! Softpal scripts +#[cfg(feature = "softpal-arc")] +pub mod arc; #[cfg(feature = "softpal-img")] pub mod img; pub mod scr; diff --git a/src/types.rs b/src/types.rs index 63043a8..5939f8d 100644 --- a/src/types.rs +++ b/src/types.rs @@ -614,6 +614,12 @@ pub enum ScriptType { #[cfg(feature = "softpal")] /// Softpal src script Softpal, + #[cfg(feature = "softpal-arc")] + /// Softpal Pac archive + SoftpalPac, + #[cfg(feature = "softpal-arc")] + /// Softpal Pac/AMUSE archive + SoftpalPacAmuse, #[cfg(feature = "softpal-img")] #[value(alias = "pgd-ge", alias = "pgd")] /// Softpal Pgd Ge image