From 59fb9e9ed552593af6263b23bfae12705b749e35 Mon Sep 17 00:00:00 2001 From: Aliet Exposito Garcia Date: Tue, 18 Aug 2026 18:38:30 -0400 Subject: [PATCH 1/7] feat(web version): add webassembly patcher and GitHub Pages deployment This change introduces a WebAssembly crate and a static web client to allow users to inspect, apply, reverse, and create PPF patches directly in web browsers without needing to download the desktop applications (CLI or GUI). Native dependencies in `ppf_core` are now optional feature flags, enabling slice based operations suitable for WebAssembly environments while preserving full performance for the command line and graphical interface targets. A dedicated deployment workflow is also included to build and publish the web application to GitHub Pages. --- .github/workflows/ci.yml | 8 +- .github/workflows/deploy-pages.yml | 53 ++ Cargo.lock | 78 ++- Cargo.toml | 7 +- crates/ppf-core/Cargo.toml | 11 +- crates/ppf-core/src/applier.rs | 140 +++- crates/ppf-core/src/core.rs | 6 + crates/ppf-core/src/creator.rs | 62 +- crates/ppf-core/src/lib.rs | 16 +- crates/ppf-core/src/parser.rs | 82 ++- crates/ppf-core/tests/integration_tests.rs | 111 ++- crates/ppf-wasm/Cargo.toml | 16 + crates/ppf-wasm/src/lib.rs | 203 ++++++ web/app.js | 743 +++++++++++++++++++++ web/index.html | 472 +++++++++++++ web/styles.css | 716 ++++++++++++++++++++ web/worker.js | 117 ++++ 17 files changed, 2744 insertions(+), 97 deletions(-) create mode 100644 .github/workflows/deploy-pages.yml create mode 100644 crates/ppf-wasm/Cargo.toml create mode 100644 crates/ppf-wasm/src/lib.rs create mode 100644 web/app.js create mode 100644 web/index.html create mode 100644 web/styles.css create mode 100644 web/worker.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0abe66a..08661c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI "on": push: - branches: [main, master] + branches: [main] pull_request: - branches: [main, master] + branches: [main] workflow_dispatch: concurrency: @@ -42,6 +42,7 @@ jobs: - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable with: + targets: wasm32-unknown-unknown components: rustfmt, clippy - name: Setup Rust cache @@ -53,6 +54,9 @@ jobs: - name: Run Clippy linter run: cargo clippy --workspace --all-targets --all-features -- -D warnings + - name: Check WASM crate target + run: cargo check -p ppf-wasm --target wasm32-unknown-unknown + test: name: Test (${{ matrix.os }}) runs-on: ${{ matrix.os }} diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml new file mode 100644 index 0000000..75c4221 --- /dev/null +++ b/.github/workflows/deploy-pages.yml @@ -0,0 +1,53 @@ +name: Deploy Web Patcher to GitHub Pages + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build-and-deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + + - name: Install wasm-bindgen CLI + run: cargo install wasm-bindgen-cli --version 0.2.100 --locked + + - name: Build WebAssembly and Bindings + run: | + cargo build --package ppf-wasm --target wasm32-unknown-unknown --release + wasm-bindgen --target web --out-dir web/pkg --out-name ppf_wasm target/wasm32-unknown-unknown/release/ppf_wasm.wasm + + - name: Setup Pages + uses: actions/configure-pages@v5 + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: 'web' + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/Cargo.lock b/Cargo.lock index be33eee..21c7687 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1563,12 +1563,11 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" dependencies = [ - "cfg-if", - "futures-util", + "once_cell", "wasm-bindgen", ] @@ -2288,7 +2287,7 @@ dependencies = [ [[package]] name = "ppf-cli" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "clap", @@ -2298,17 +2297,18 @@ dependencies = [ [[package]] name = "ppf-core" -version = "0.2.0" +version = "0.3.0" dependencies = [ "memmap2", "rayon", + "serde", "tempfile", "thiserror 2.0.20", ] [[package]] name = "ppf-gui" -version = "0.2.0" +version = "0.3.0" dependencies = [ "eframe", "egui", @@ -2316,6 +2316,17 @@ dependencies = [ "rfd", ] +[[package]] +name = "ppf-wasm" +version = "0.3.0" +dependencies = [ + "js-sys", + "ppf-core", + "serde", + "serde-wasm-bindgen", + "wasm-bindgen", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2593,6 +2604,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -3091,32 +3113,48 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.77" +version = "0.4.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" dependencies = [ + "cfg-if", "js-sys", + "once_cell", "wasm-bindgen", + "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3124,22 +3162,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ - "bumpalo", "proc-macro2", "quote", "syn 2.0.119", + "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" dependencies = [ "unicode-ident", ] @@ -3281,9 +3319,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.104" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index f790fdf..43515fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,11 +3,12 @@ members = [ "crates/ppf-core", "crates/ppf-cli", "crates/ppf-gui", + "crates/ppf-wasm", ] resolver = "2" [workspace.package] -version = "0.2.0" +version = "0.3.0" edition = "2024" authors = ["Aliet Exposito"] license = "MIT" @@ -17,6 +18,10 @@ repository = "https://github.com/plinkr/ppf_rust" ppf-core = { path = "crates/ppf-core" } anyhow = "1.0.104" thiserror = "2.0.20" +serde = { version = "1.0.218", features = ["derive"] } +wasm-bindgen = "=0.2.100" +serde-wasm-bindgen = "0.6.5" +js-sys = "=0.3.77" memmap2 = "0.9.11" rayon = "1.12.0" clap = { version = "4.6.6", features = ["derive"] } diff --git a/crates/ppf-core/Cargo.toml b/crates/ppf-core/Cargo.toml index 2c6f815..6fa077c 100644 --- a/crates/ppf-core/Cargo.toml +++ b/crates/ppf-core/Cargo.toml @@ -6,9 +6,16 @@ license.workspace = true authors.workspace = true [dependencies] -memmap2.workspace = true -rayon.workspace = true +memmap2 = { workspace = true, optional = true } +rayon = { workspace = true, optional = true } +serde = { workspace = true, optional = true } thiserror.workspace = true +[features] +default = ["mmap", "parallel"] +mmap = ["dep:memmap2"] +parallel = ["dep:rayon"] +serde = ["dep:serde"] + [dev-dependencies] tempfile.workspace = true diff --git a/crates/ppf-core/src/applier.rs b/crates/ppf-core/src/applier.rs index 8597f39..e8b5fac 100644 --- a/crates/ppf-core/src/applier.rs +++ b/crates/ppf-core/src/applier.rs @@ -1,69 +1,82 @@ use crate::core::{ImageType, PpfError, PpfHeader}; +#[cfg(feature = "mmap")] use crate::parser::PpfFile; +use crate::parser::PpfView; +#[cfg(feature = "mmap")] use memmap2::MmapMut; +#[cfg(feature = "mmap")] use std::fs::OpenOptions; +#[cfg(feature = "mmap")] use std::path::Path; /// Alias for header metadata returned when inspecting or applying a patch. pub type PatchInfo = PpfHeader; +/// Inspects a PPF patch from an in-memory slice and returns its header metadata. +#[inline] +pub fn inspect_patch_slice(patch_data: &[u8]) -> Result { + let view = PpfView::parse(patch_data)?; + Ok(view.header) +} + /// Inspects a PPF patch file and returns its header metadata without modifying any binary. +#[cfg(feature = "mmap")] +#[inline] pub fn inspect_patch(patch_path: impl AsRef) -> Result { let ppf = PpfFile::open(patch_path)?; Ok(ppf.header) } -/// Applies a PPF patch (v1.0, v2.0, or v3.0) to a target binary file. +/// Applies a PPF patch (v1.0, v2.0, or v3.0) to an in-memory mutable binary slice. /// /// # Arguments -/// * `patch_path` - Path to the PPF patch file. -/// * `bin_path` - Path to the target binary file to be modified in-place. +/// * `patch_data` - Byte slice containing the PPF patch. +/// * `target_data` - Target binary slice to be modified in-place. /// * `on_start` - Optional callback invoked with the total number of records before modification. /// * `progress_callback` - Optional callback invoked with the number of records applied (e.g. 1 per record). -pub fn apply_patch( - patch_path: impl AsRef, - bin_path: impl AsRef, +pub fn apply_patch_slice( + patch_data: &[u8], + target_data: &mut [u8], on_start: Option<&dyn Fn(usize)>, progress_callback: Option<&dyn Fn(usize)>, ) -> Result { - process_patch(patch_path, bin_path, false, on_start, progress_callback) + process_patch_slice(patch_data, target_data, false, on_start, progress_callback) } -/// Reverses/undoes a PPF3 patch from a previously patched binary file. +/// Reverses/undoes a PPF3 patch on an in-memory mutable binary slice. /// /// Requires the patch to contain undo data (`has_undo == true`). /// /// # Arguments -/// * `patch_path` - Path to the PPF patch file. -/// * `bin_path` - Path to the binary file to be restored in-place. +/// * `patch_data` - Byte slice containing the PPF patch. +/// * `target_data` - Target binary slice to be restored in-place. /// * `on_start` - Optional callback invoked with the total number of records before modification. /// * `progress_callback` - Optional callback invoked with the number of records restored (e.g. 1 per record). -pub fn undo_patch( - patch_path: impl AsRef, - bin_path: impl AsRef, +pub fn undo_patch_slice( + patch_data: &[u8], + target_data: &mut [u8], on_start: Option<&dyn Fn(usize)>, progress_callback: Option<&dyn Fn(usize)>, ) -> Result { - process_patch(patch_path, bin_path, true, on_start, progress_callback) + process_patch_slice(patch_data, target_data, true, on_start, progress_callback) } -fn process_patch( - patch_path: impl AsRef, - bin_path: impl AsRef, +fn process_patch_slice( + patch_data: &[u8], + target_data: &mut [u8], is_undo: bool, on_start: Option<&dyn Fn(usize)>, progress_callback: Option<&dyn Fn(usize)>, ) -> Result { - let ppf = PpfFile::open(patch_path)?; + let view = PpfView::parse(patch_data)?; - if is_undo && !ppf.header.has_undo { + if is_undo && !view.header.has_undo { return Err(PpfError::UndoNotAvailable); } - let bin_file = OpenOptions::new().read(true).write(true).open(bin_path)?; - let bin_len = bin_file.metadata()?.len(); + let bin_len = target_data.len() as u64; - if let Some(expected_len) = ppf.header.original_bin_length + if let Some(expected_len) = view.header.original_bin_length && expected_len as u64 != bin_len { return Err(PpfError::BinSizeMismatch { @@ -72,12 +85,8 @@ fn process_patch( }); } - let mut bin_mmap = unsafe { MmapMut::map_mut(&bin_file)? }; - #[cfg(unix)] - bin_mmap.advise(memmap2::Advice::Sequential)?; - - if !is_undo && let Some(expected_block) = &ppf.header.block_data { - let block_offset = if ppf.header.image_type == ImageType::Gi { + if !is_undo && let Some(expected_block) = &view.header.block_data { + let block_offset = if view.header.image_type == ImageType::Gi { 0x80A0usize } else { 0x9320usize @@ -87,17 +96,17 @@ fn process_patch( return Err(PpfError::BlockCheckFailed); } - if expected_block.as_slice() != &bin_mmap[block_offset..block_offset + 1024] { + if expected_block.as_slice() != &target_data[block_offset..block_offset + 1024] { return Err(PpfError::BlockCheckFailed); } } if let Some(start_cb) = on_start { - let total = ppf.count_records()?; + let total = view.count_records()?; start_cb(total); } - for record_res in ppf.records() { + for record_res in view.records() { let record = record_res?; let len = record.length as usize; @@ -119,14 +128,77 @@ fn process_patch( record.data }; - bin_mmap[offset..end_offset].copy_from_slice(data_to_write); + target_data[offset..end_offset].copy_from_slice(data_to_write); if let Some(cb) = progress_callback { cb(1); } } - bin_mmap.flush()?; + Ok(view.header) +} - Ok(ppf.header) +/// Applies a PPF patch (v1.0, v2.0, or v3.0) to a target binary file. +/// +/// # Arguments +/// * `patch_path` - Path to the PPF patch file. +/// * `bin_path` - Path to the target binary file to be modified in-place. +/// * `on_start` - Optional callback invoked with the total number of records before modification. +/// * `progress_callback` - Optional callback invoked with the number of records applied (e.g. 1 per record). +#[cfg(feature = "mmap")] +#[inline] +pub fn apply_patch( + patch_path: impl AsRef, + bin_path: impl AsRef, + on_start: Option<&dyn Fn(usize)>, + progress_callback: Option<&dyn Fn(usize)>, +) -> Result { + process_patch_file(patch_path, bin_path, false, on_start, progress_callback) +} + +/// Reverses/undoes a PPF3 patch from a previously patched binary file. +/// +/// Requires the patch to contain undo data (`has_undo == true`). +/// +/// # Arguments +/// * `patch_path` - Path to the PPF patch file. +/// * `bin_path` - Path to the binary file to be restored in-place. +/// * `on_start` - Optional callback invoked with the total number of records before modification. +/// * `progress_callback` - Optional callback invoked with the number of records restored (e.g. 1 per record). +#[cfg(feature = "mmap")] +#[inline] +pub fn undo_patch( + patch_path: impl AsRef, + bin_path: impl AsRef, + on_start: Option<&dyn Fn(usize)>, + progress_callback: Option<&dyn Fn(usize)>, +) -> Result { + process_patch_file(patch_path, bin_path, true, on_start, progress_callback) +} + +#[cfg(feature = "mmap")] +fn process_patch_file( + patch_path: impl AsRef, + bin_path: impl AsRef, + is_undo: bool, + on_start: Option<&dyn Fn(usize)>, + progress_callback: Option<&dyn Fn(usize)>, +) -> Result { + let ppf = PpfFile::open(patch_path)?; + + let bin_file = OpenOptions::new().read(true).write(true).open(bin_path)?; + let mut bin_mmap = unsafe { MmapMut::map_mut(&bin_file)? }; + #[cfg(unix)] + bin_mmap.advise(memmap2::Advice::Sequential)?; + + let header = process_patch_slice( + &ppf.mmap, + &mut bin_mmap, + is_undo, + on_start, + progress_callback, + )?; + + bin_mmap.flush()?; + Ok(header) } diff --git a/crates/ppf-core/src/core.rs b/crates/ppf-core/src/core.rs index 3f0d2b9..21bb56d 100644 --- a/crates/ppf-core/src/core.rs +++ b/crates/ppf-core/src/core.rs @@ -2,8 +2,10 @@ use thiserror::Error; /// PPF error types returned across the library. #[derive(Debug, Error)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum PpfError { #[error("I/O error: {0}")] + #[cfg_attr(feature = "serde", serde(skip))] Io(#[from] std::io::Error), #[error("Invalid magic bytes: {0}")] InvalidMagic(String), @@ -25,6 +27,7 @@ pub enum PpfError { /// Supported PPF patch file versions. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum PpfVersion { V1, V2, @@ -33,6 +36,7 @@ pub enum PpfVersion { /// Disc image type for PPF3 validation offsets. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum ImageType { /// Standard RAW/BIN image (offset 0x9320 for validation). Bin, @@ -42,6 +46,7 @@ pub enum ImageType { /// Parsed PPF header information. #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct PpfHeader { pub version: PpfVersion, pub description: String, @@ -64,6 +69,7 @@ pub struct PpfRecord<'a> { /// Owned PPF patch diff record with allocated Vecs. #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct PpfPatchRecord { pub offset: u64, pub length: u8, diff --git a/crates/ppf-core/src/creator.rs b/crates/ppf-core/src/creator.rs index 347f834..d045ca8 100644 --- a/crates/ppf-core/src/creator.rs +++ b/crates/ppf-core/src/creator.rs @@ -1,19 +1,29 @@ use crate::core::{ImageType, PpfError}; +#[cfg(feature = "mmap")] use memmap2::Mmap; +#[cfg(feature = "parallel")] use rayon::prelude::*; +#[cfg(feature = "mmap")] use std::fs::File; -use std::io::{BufWriter, Read, Seek, SeekFrom, Write}; +#[cfg(feature = "mmap")] +use std::io::BufWriter; +use std::io::{Read, Seek, SeekFrom, Write}; +#[cfg(feature = "mmap")] use std::path::Path; +#[cfg(feature = "mmap")] const WRITE_BUF: usize = 8 * 1024 * 1024; const CHUNK_SIZE: usize = 1024 * 1024; +pub type CreatorProgressCallback<'a> = &'a (dyn Fn(usize) + Sync + Send); + /// Default PPF3 patch description containing application name and compiled version. pub const DEFAULT_DESCRIPTION: &str = concat!("Created with PPF Rust Patcher v", env!("CARGO_PKG_VERSION")); /// Configuration options for creating a PPF3 patch. #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct PpfCreatorOptions { pub description: String, pub image_type: ImageType, @@ -51,12 +61,13 @@ struct DiffRange { /// * `output_path` - Destination path for the resulting PPF3 patch file. /// * `options` - Configuration options for the PPF3 patch. /// * `progress_cb` - Optional thread-safe callback invoked with bytes processed in each chunk. +#[cfg(feature = "mmap")] pub fn create_patch( original_path: impl AsRef, patched_path: impl AsRef, output_path: impl AsRef, options: &PpfCreatorOptions, - progress_cb: Option<&(dyn Fn(usize) + Sync + Send)>, + progress_cb: Option>, ) -> Result { let orig_file = File::open(original_path)?; let mod_file = File::open(patched_path)?; @@ -80,16 +91,26 @@ pub fn create_patch( } let output = BufWriter::with_capacity(WRITE_BUF, File::create(output_path)?); - create_patch_mmap(&orig_mmap, &mod_mmap, output, options, progress_cb) + create_patch_slice(&orig_mmap, &mod_mmap, output, options, progress_cb) } -fn create_patch_mmap( +/// Creates a PPF3 patch from in-memory slices and writes to a `Write` stream. +/// +/// Returns the number of diff record entries written to the patch stream. +pub fn create_patch_slice( orig_data: &[u8], mod_data: &[u8], mut output: W, options: &PpfCreatorOptions, - progress_cb: Option<&(dyn Fn(usize) + Sync + Send)>, + progress_cb: Option>, ) -> Result { + if orig_data.len() != mod_data.len() { + return Err(PpfError::BinSizeMismatch { + expected: orig_data.len() as u64, + actual: mod_data.len() as u64, + }); + } + output.write_all(b"PPF30")?; output.write_all(&[0x02u8])?; @@ -122,18 +143,29 @@ fn create_patch_mmap( output.write_all(&block)?; } + let process_chunk = |(chunk_idx, (orig_chunk, mod_chunk)): (usize, (&[u8], &[u8]))| { + let base_offset = chunk_idx as u64 * CHUNK_SIZE as u64; + let diffs = scan_chunk(orig_chunk, mod_chunk, base_offset); + if let Some(cb) = progress_cb { + cb(orig_chunk.len()); + } + diffs + }; + + #[cfg(feature = "parallel")] let all_diffs: Vec> = orig_data .par_chunks(CHUNK_SIZE) .zip(mod_data.par_chunks(CHUNK_SIZE)) .enumerate() - .map(|(chunk_idx, (orig_chunk, mod_chunk))| { - let base_offset = chunk_idx as u64 * CHUNK_SIZE as u64; - let diffs = scan_chunk(orig_chunk, mod_chunk, base_offset); - if let Some(cb) = progress_cb { - cb(orig_chunk.len()); - } - diffs - }) + .map(process_chunk) + .collect(); + + #[cfg(not(feature = "parallel"))] + let all_diffs: Vec> = orig_data + .chunks(CHUNK_SIZE) + .zip(mod_data.chunks(CHUNK_SIZE)) + .enumerate() + .map(process_chunk) .collect(); let mut entries_found = 0; @@ -220,7 +252,7 @@ pub fn create_patch_stream( mut modified: R2, output: W, options: &PpfCreatorOptions, - progress_cb: Option<&(dyn Fn(usize) + Sync + Send)>, + progress_cb: Option>, ) -> Result where R1: Read + Seek, @@ -245,7 +277,7 @@ where original.read_exact(orig_buf.as_mut_slice())?; modified.read_exact(mod_buf.as_mut_slice())?; - create_patch_mmap( + create_patch_slice( orig_buf.as_slice(), mod_buf.as_slice(), output, diff --git a/crates/ppf-core/src/lib.rs b/crates/ppf-core/src/lib.rs index 8639748..df0cc78 100644 --- a/crates/ppf-core/src/lib.rs +++ b/crates/ppf-core/src/lib.rs @@ -3,7 +3,17 @@ pub mod core; pub mod creator; pub mod parser; -pub use applier::{PatchInfo, apply_patch, inspect_patch, undo_patch}; +pub use applier::{PatchInfo, apply_patch_slice, inspect_patch_slice, undo_patch_slice}; +#[cfg(feature = "mmap")] +pub use applier::{apply_patch, inspect_patch, undo_patch}; + pub use core::{ImageType, PpfError, PpfHeader, PpfPatchRecord, PpfRecord, PpfVersion}; -pub use creator::{DEFAULT_DESCRIPTION, PpfCreatorOptions, create_patch, create_patch_stream}; -pub use parser::{PpfFile, PpfRecords}; +#[cfg(feature = "mmap")] +pub use creator::create_patch; +pub use creator::{ + DEFAULT_DESCRIPTION, PpfCreatorOptions, create_patch_slice, create_patch_stream, +}; + +#[cfg(feature = "mmap")] +pub use parser::PpfFile; +pub use parser::{PpfRecords, PpfView}; diff --git a/crates/ppf-core/src/parser.rs b/crates/ppf-core/src/parser.rs index 84607ec..9830b80 100644 --- a/crates/ppf-core/src/parser.rs +++ b/crates/ppf-core/src/parser.rs @@ -1,21 +1,24 @@ use crate::core::{ImageType, PpfError, PpfHeader, PpfRecord, PpfVersion}; +#[cfg(feature = "mmap")] use memmap2::Mmap; +#[cfg(feature = "mmap")] use std::fs::File; +#[cfg(feature = "mmap")] use std::path::Path; -/// Memory-mapped PPF file reader providing zero-copy access to records. -pub struct PpfFile { +/// Slice-based PPF reader providing zero-copy access to records. +#[derive(Debug, Clone)] +pub struct PpfView<'a> { pub header: PpfHeader, - mmap: Mmap, + data: &'a [u8], data_start: usize, data_end: usize, } -impl PpfFile { - /// Opens and parses a PPF patch file using memory-mapped I/O. - pub fn open(path: impl AsRef) -> Result { - let file = File::open(path)?; - let file_len = file.metadata()?.len() as usize; +impl<'a> PpfView<'a> { + /// Parses a PPF patch from an in-memory byte slice. + pub fn parse(bytes: &'a [u8]) -> Result { + let file_len = bytes.len(); if file_len < 56 { return Err(PpfError::InvalidMagic( @@ -23,9 +26,6 @@ impl PpfFile { )); } - let mmap = unsafe { Mmap::map(&file)? }; - let bytes = &mmap[..]; - let version = match &bytes[..4] { b"PPF1" => PpfVersion::V1, b"PPF2" => PpfVersion::V2, @@ -133,6 +133,59 @@ impl PpfFile { let data_end = file_len - trailer_len; + Ok(PpfView { + header, + data: bytes, + data_start, + data_end, + }) + } + + #[inline] + pub fn payload_slice(&self) -> &'a [u8] { + &self.data[self.data_start..self.data_end] + } + + #[inline] + pub fn records(&self) -> PpfRecords<'a> { + PpfRecords { + slice: self.payload_slice(), + cursor: 0, + version: self.header.version, + has_undo: self.header.has_undo, + } + } + + pub fn count_records(&self) -> Result { + let mut count = 0; + for res in self.records() { + res?; + count += 1; + } + Ok(count) + } +} + +/// Memory-mapped PPF file reader providing zero-copy access to records. +#[cfg(feature = "mmap")] +pub struct PpfFile { + pub header: PpfHeader, + pub mmap: Mmap, + data_start: usize, + data_end: usize, +} + +#[cfg(feature = "mmap")] +impl PpfFile { + /// Opens and parses a PPF patch file using memory-mapped I/O. + pub fn open(path: impl AsRef) -> Result { + let file = File::open(path)?; + let mmap = unsafe { Mmap::map(&file)? }; + let (header, data_start, data_end) = { + let view = PpfView::parse(&mmap)?; + (view.header, view.data_start, view.data_end) + }; + Ok(PpfFile { header, mmap, @@ -141,11 +194,17 @@ impl PpfFile { }) } + #[inline] + pub fn as_bytes(&self) -> &[u8] { + &self.mmap[..] + } + #[inline] pub fn payload_slice(&self) -> &[u8] { &self.mmap[self.data_start..self.data_end] } + #[inline] pub fn records(&self) -> PpfRecords<'_> { PpfRecords { slice: self.payload_slice(), @@ -155,6 +214,7 @@ impl PpfFile { } } + #[inline] pub fn count_records(&self) -> Result { let mut count = 0; for res in self.records() { diff --git a/crates/ppf-core/tests/integration_tests.rs b/crates/ppf-core/tests/integration_tests.rs index 492ebb2..c7d17ab 100644 --- a/crates/ppf-core/tests/integration_tests.rs +++ b/crates/ppf-core/tests/integration_tests.rs @@ -1,9 +1,102 @@ use ppf_core::applier; use ppf_core::core::ImageType; use ppf_core::creator::{self, PpfCreatorOptions}; +#[cfg(feature = "mmap")] use std::io::Write; +#[cfg(feature = "mmap")] use tempfile::NamedTempFile; +#[test] +fn test_slice_apply_ppf1() { + let mut target = vec![0u8; 100]; + let mut patch = Vec::new(); + patch.extend_from_slice(b"PPF1"); + patch.extend_from_slice(&[0, 0]); + patch.extend_from_slice(b"Test description "); + patch.extend_from_slice(&10u32.to_le_bytes()); + patch.push(3); + patch.extend_from_slice(&[1, 2, 3]); + + let info = applier::inspect_patch_slice(&patch).unwrap(); + assert_eq!(info.description, "Test description"); + assert_eq!(info.file_id, None); + + let info = applier::apply_patch_slice(&patch, &mut target, None, None).unwrap(); + assert_eq!(info.description, "Test description"); + assert_eq!(target[10], 1); + assert_eq!(target[11], 2); + assert_eq!(target[12], 3); +} + +#[test] +fn test_slice_apply_ppf2() { + let mut target = vec![0u8; 0x9320 + 2048]; + target[0x9320..0x9320 + 1024].fill(0xAA); + + let mut patch = Vec::new(); + patch.extend_from_slice(b"PPF2"); + patch.extend_from_slice(&[0, 0]); + patch.extend_from_slice(b"PPF2 description "); + patch.extend_from_slice(&(target.len() as u32).to_le_bytes()); + patch.extend_from_slice(&target[0x9320..0x9320 + 1024]); + + patch.extend_from_slice(&100u32.to_le_bytes()); + patch.push(2); + patch.extend_from_slice(&[0x11, 0x22]); + + let file_id_text = "Release file_id for PPF2"; + patch.extend_from_slice(b"@BEGIN_FILE_ID.DIZ"); + patch.extend_from_slice(file_id_text.as_bytes()); + patch.extend_from_slice(b"@END_FILE_ID.DIZ"); + patch.extend_from_slice(&(file_id_text.len() as u32).to_le_bytes()); + + let info = applier::apply_patch_slice(&patch, &mut target, None, None).unwrap(); + assert_eq!(info.description, "PPF2 description"); + assert_eq!(info.file_id.as_deref(), Some(file_id_text)); + assert_eq!(target[100], 0x11); + assert_eq!(target[101], 0x22); +} + +#[test] +fn test_slice_create_apply_undo_roundtrip() { + let size = 64 * 1024; + let mut orig_data = vec![0x33u8; size]; + let mut mod_data = vec![0x33u8; size]; + + orig_data[50..55].copy_from_slice(&[1, 2, 3, 4, 5]); + mod_data[50..55].copy_from_slice(&[10, 20, 30, 40, 50]); + + orig_data[40000..40300].fill(0x55); + mod_data[40000..40300].fill(0x99); + + let options = PpfCreatorOptions { + description: "Slice Roundtrip".to_string(), + image_type: ImageType::Bin, + block_check: true, + undo_data: true, + file_id: Some(b"Slice DIZ metadata".to_vec()), + }; + + let mut patch_bytes = Vec::new(); + let entries = + creator::create_patch_slice(&orig_data, &mod_data, &mut patch_bytes, &options, None) + .unwrap(); + + assert!(entries >= 2); + + let info = applier::inspect_patch_slice(&patch_bytes).unwrap(); + assert_eq!(info.description, "Slice Roundtrip"); + assert_eq!(info.file_id.as_deref(), Some("Slice DIZ metadata")); + + let mut target = orig_data.clone(); + applier::apply_patch_slice(&patch_bytes, &mut target, None, None).unwrap(); + assert_eq!(target, mod_data); + + applier::undo_patch_slice(&patch_bytes, &mut target, None, None).unwrap(); + assert_eq!(target, orig_data); +} + +#[cfg(feature = "mmap")] #[test] fn test_apply_ppf1() { let mut bin = NamedTempFile::new().unwrap(); @@ -33,6 +126,7 @@ fn test_apply_ppf1() { assert_eq!(result_data[12], 3); } +#[cfg(feature = "mmap")] #[test] fn test_apply_ppf2() { let mut bin = NamedTempFile::new().unwrap(); @@ -70,6 +164,7 @@ fn test_apply_ppf2() { assert_eq!(result_data[101], 0x22); } +#[cfg(feature = "mmap")] #[test] fn test_apply_and_undo_ppf3_manual() { let mut bin = NamedTempFile::new().unwrap(); @@ -115,6 +210,7 @@ fn test_apply_and_undo_ppf3_manual() { assert_eq!(result_data[21], 0); } +#[cfg(feature = "mmap")] #[test] fn test_create_and_apply_ppf3_roundtrip() { let mut orig_file = NamedTempFile::new().unwrap(); @@ -174,6 +270,7 @@ fn test_create_and_apply_ppf3_roundtrip() { ); } +#[cfg(feature = "mmap")] #[test] fn test_create_patch_small_file_no_panic() { let mut orig_file = NamedTempFile::new().unwrap(); @@ -210,6 +307,7 @@ fn test_create_patch_small_file_no_panic() { assert_eq!(patched, mod_data); } +#[cfg(feature = "mmap")] #[test] fn test_create_patch_small_file_with_blockcheck_does_not_panic() { let mut orig_file = NamedTempFile::new().unwrap(); @@ -265,17 +363,12 @@ fn test_create_patch_stream_roundtrip() { .unwrap(); assert_eq!(entries, 1); - let mut target_bin = NamedTempFile::new().unwrap(); - target_bin.write_all(&orig_data).unwrap(); - - let mut ppf_file = NamedTempFile::new().unwrap(); - ppf_file.write_all(&patch_bytes).unwrap(); - - applier::apply_patch(ppf_file.path(), target_bin.path(), None, None).unwrap(); - let result = std::fs::read(target_bin.path()).unwrap(); - assert_eq!(result, mod_data); + let mut target_bin = orig_data.clone(); + applier::apply_patch_slice(&patch_bytes, &mut target_bin, None, None).unwrap(); + assert_eq!(target_bin, mod_data); } +#[cfg(feature = "mmap")] #[test] fn test_undo_patch_with_blockcheck_modification() { let mut orig_file = NamedTempFile::new().unwrap(); diff --git a/crates/ppf-wasm/Cargo.toml b/crates/ppf-wasm/Cargo.toml new file mode 100644 index 0000000..a6d2aa1 --- /dev/null +++ b/crates/ppf-wasm/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "ppf-wasm" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +ppf-core = { path = "../ppf-core", default-features = false, features = ["serde"] } +wasm-bindgen.workspace = true +serde.workspace = true +serde-wasm-bindgen.workspace = true +js-sys.workspace = true diff --git a/crates/ppf-wasm/src/lib.rs b/crates/ppf-wasm/src/lib.rs new file mode 100644 index 0000000..9531e6f --- /dev/null +++ b/crates/ppf-wasm/src/lib.rs @@ -0,0 +1,203 @@ +use ppf_core::{ + ImageType, PatchInfo, PpfCreatorOptions, apply_patch_slice, create_patch_slice, + inspect_patch_slice, undo_patch_slice, +}; +use serde::Deserialize; +use std::cell::Cell; +use std::rc::Rc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use wasm_bindgen::prelude::*; + +struct SendSyncFn(js_sys::Function); +unsafe impl Send for SendSyncFn {} +unsafe impl Sync for SendSyncFn {} + +impl SendSyncFn { + fn call_progress(&self, done: f64, total: f64) { + let _ = self + .0 + .call2(&JsValue::NULL, &JsValue::from(done), &JsValue::from(total)); + } +} + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct WasmCreatorOptions { + pub description: Option, + pub image_type: Option, + pub block_check: Option, + pub undo_data: Option, + pub file_id: Option, +} + +/// Inspects a PPF patch from an in-memory byte slice and returns patch metadata. +#[wasm_bindgen] +pub fn inspect_patch(patch_bytes: &[u8]) -> Result { + let info = inspect_patch_slice(patch_bytes) + .map_err(|err| JsValue::from(js_sys::Error::new(&err.to_string())))?; + serde_wasm_bindgen::to_value(&info) + .map_err(|err| JsValue::from(js_sys::Error::new(&err.to_string()))) +} + +/// Applies a PPF patch to a target binary byte slice in-place. +#[wasm_bindgen] +pub fn apply_patch( + patch_bytes: &[u8], + target_bytes: &mut [u8], + progress_cb: Option, +) -> Result { + let total_records = Rc::new(Cell::new(0usize)); + let done_records = Rc::new(Cell::new(0usize)); + + let total_start = total_records.clone(); + let on_start = progress_cb.as_ref().map(|cb| { + let cb = cb.clone(); + move |total: usize| { + total_start.set(total); + let _ = cb.call2( + &JsValue::NULL, + &JsValue::from(0usize), + &JsValue::from(total as f64), + ); + } + }); + + let total_progress = total_records.clone(); + let done_progress = done_records.clone(); + let on_progress = progress_cb.as_ref().map(|cb| { + let cb = cb.clone(); + move |count: usize| { + let done = done_progress.get() + count; + done_progress.set(done); + let total = total_progress.get(); + if total > 0 && (done == total || done.is_multiple_of(200)) { + let _ = cb.call2( + &JsValue::NULL, + &JsValue::from(done as f64), + &JsValue::from(total as f64), + ); + } + } + }); + + let info: PatchInfo = apply_patch_slice( + patch_bytes, + target_bytes, + on_start.as_ref().map(|f| f as &dyn Fn(usize)), + on_progress.as_ref().map(|f| f as &dyn Fn(usize)), + ) + .map_err(|err| JsValue::from(js_sys::Error::new(&err.to_string())))?; + + serde_wasm_bindgen::to_value(&info) + .map_err(|err| JsValue::from(js_sys::Error::new(&err.to_string()))) +} + +/// Reverses/undoes a PPF3 patch on a target binary byte slice in-place. +#[wasm_bindgen] +pub fn undo_patch( + patch_bytes: &[u8], + target_bytes: &mut [u8], + progress_cb: Option, +) -> Result { + let total_records = Rc::new(Cell::new(0usize)); + let done_records = Rc::new(Cell::new(0usize)); + + let total_start = total_records.clone(); + let on_start = progress_cb.as_ref().map(|cb| { + let cb = cb.clone(); + move |total: usize| { + total_start.set(total); + let _ = cb.call2( + &JsValue::NULL, + &JsValue::from(0usize), + &JsValue::from(total as f64), + ); + } + }); + + let total_progress = total_records.clone(); + let done_progress = done_records.clone(); + let on_progress = progress_cb.as_ref().map(|cb| { + let cb = cb.clone(); + move |count: usize| { + let done = done_progress.get() + count; + done_progress.set(done); + let total = total_progress.get(); + if total > 0 && (done == total || done.is_multiple_of(200)) { + let _ = cb.call2( + &JsValue::NULL, + &JsValue::from(done as f64), + &JsValue::from(total as f64), + ); + } + } + }); + + let info: PatchInfo = undo_patch_slice( + patch_bytes, + target_bytes, + on_start.as_ref().map(|f| f as &dyn Fn(usize)), + on_progress.as_ref().map(|f| f as &dyn Fn(usize)), + ) + .map_err(|err| JsValue::from(js_sys::Error::new(&err.to_string())))?; + + serde_wasm_bindgen::to_value(&info) + .map_err(|err| JsValue::from(js_sys::Error::new(&err.to_string()))) +} + +/// Creates a PPF3 patch comparing original and modified binary byte slices. +#[wasm_bindgen] +pub fn create_patch( + orig_bytes: &[u8], + mod_bytes: &[u8], + options: JsValue, + progress_cb: Option, +) -> Result { + let wasm_opts: WasmCreatorOptions = if options.is_undefined() || options.is_null() { + WasmCreatorOptions::default() + } else { + serde_wasm_bindgen::from_value(options) + .map_err(|err| JsValue::from(js_sys::Error::new(&err.to_string())))? + }; + + let img_type = match wasm_opts.image_type.as_deref() { + Some("gi") | Some("GI") => ImageType::Gi, + _ => ImageType::Bin, + }; + + let core_opts = PpfCreatorOptions { + description: wasm_opts + .description + .filter(|d| !d.trim().is_empty()) + .unwrap_or_else(|| ppf_core::DEFAULT_DESCRIPTION.to_string()), + image_type: img_type, + block_check: wasm_opts.block_check.unwrap_or(true), + undo_data: wasm_opts.undo_data.unwrap_or(false), + file_id: wasm_opts.file_id.map(|s| s.into_bytes()), + }; + + let total_bytes = orig_bytes.len(); + let done_bytes = AtomicUsize::new(0); + + let on_progress = progress_cb.as_ref().map(|cb| { + let send_cb = SendSyncFn(cb.clone()); + move |bytes: usize| { + let done = done_bytes.fetch_add(bytes, Ordering::Relaxed) + bytes; + send_cb.call_progress(done as f64, total_bytes as f64); + } + }); + + let mut output = Vec::new(); + create_patch_slice( + orig_bytes, + mod_bytes, + &mut output, + &core_opts, + on_progress + .as_ref() + .map(|f| f as &(dyn Fn(usize) + Sync + Send)), + ) + .map_err(|err| JsValue::from(js_sys::Error::new(&err.to_string())))?; + + Ok(js_sys::Uint8Array::from(&output[..])) +} diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..892eef9 --- /dev/null +++ b/web/app.js @@ -0,0 +1,743 @@ +// PPF Rust Patcher - Web Client Application + +// Worker Management +const worker = new Worker('worker.js', { type: 'module' }); +let nextMessageId = 1; +const pendingRequests = new Map(); + +worker.onmessage = (event) => { + const { id, type, success, info, binBytes, patchBytes, error, percent, message } = event.data; + const req = pendingRequests.get(id); + if (!req) return; + + if (type === 'progress') { + if (req.onProgress) { + req.onProgress({ percent, message }); + } + } else if (type === 'complete') { + pendingRequests.delete(id); + if (success) { + req.resolve({ info, binBytes, patchBytes }); + } else { + req.reject(new Error(error || 'Operation failed')); + } + } +}; + +function sendWorkerMessage(type, payload, transferables = [], onProgress = null) { + return new Promise((resolve, reject) => { + const id = nextMessageId++; + pendingRequests.set(id, { resolve, reject, onProgress }); + worker.postMessage({ id, type, payload }, transferables); + }); +} + +// Utility Functions +function formatFileSize(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +function triggerDownload(buffer, filename, mimeType = 'application/octet-stream') { + const blob = new Blob([buffer], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 60000); +} + +function generatePatchedFilename(originalName) { + const lastDot = originalName.lastIndexOf('.'); + if (lastDot === -1) { + return originalName + '_patched'; + } + return originalName.substring(0, lastDot) + '_patched' + originalName.substring(lastDot); +} + +function generateRestoredFilename(originalName) { + const lastDot = originalName.lastIndexOf('.'); + if (lastDot === -1) { + return originalName + '_restored'; + } + return originalName.substring(0, lastDot) + '_restored' + originalName.substring(lastDot); +} + +// Dropzone Handler Helper +function setupDropzone(options) { + const { + dropzoneEl, + inputEl, + emptyEl, + selectedEl, + nameEl, + sizeEl, + clearEl, + browseBtn, + onFileSelected, + onFileCleared + } = options; + + const handleFile = (file) => { + if (!file) return; + emptyEl.classList.add('hidden'); + selectedEl.classList.remove('hidden'); + nameEl.textContent = file.name; + sizeEl.textContent = formatFileSize(file.size); + if (onFileSelected) onFileSelected(file); + }; + + dropzoneEl.addEventListener('dragover', (e) => { + e.preventDefault(); + dropzoneEl.classList.add('dragover'); + }); + + dropzoneEl.addEventListener('dragleave', () => { + dropzoneEl.classList.remove('dragover'); + }); + + dropzoneEl.addEventListener('drop', (e) => { + e.preventDefault(); + dropzoneEl.classList.remove('dragover'); + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + inputEl.files = e.dataTransfer.files; + handleFile(e.dataTransfer.files[0]); + } + }); + + dropzoneEl.addEventListener('click', (e) => { + if (e.target !== clearEl && !selectedEl.contains(e.target)) { + inputEl.click(); + } + }); + + if (browseBtn) { + browseBtn.addEventListener('click', (e) => { + e.stopPropagation(); + inputEl.click(); + }); + } + + inputEl.addEventListener('change', () => { + if (inputEl.files && inputEl.files.length > 0) { + handleFile(inputEl.files[0]); + } + }); + + clearEl.addEventListener('click', (e) => { + e.stopPropagation(); + inputEl.value = ''; + selectedEl.classList.add('hidden'); + emptyEl.classList.remove('hidden'); + if (onFileCleared) onFileCleared(); + }); +} + +// DOM Elements +// Tab Navigation +const tabButtons = document.querySelectorAll('.tab-btn'); +const tabContents = document.querySelectorAll('.tab-content'); + +// Tab 1: Apply / Undo +const applyBinDropzone = document.getElementById('apply-bin-dropzone'); +const applyBinInput = document.getElementById('apply-bin-input'); +const applyBinEmpty = document.getElementById('apply-bin-empty'); +const applyBinSelected = document.getElementById('apply-bin-selected'); +const applyBinName = document.getElementById('apply-bin-name'); +const applyBinSize = document.getElementById('apply-bin-size'); +const applyBinClear = document.getElementById('apply-bin-clear'); +const applyBinBrowse = document.getElementById('apply-bin-browse'); +const applyOutputName = document.getElementById('apply-output-name'); + +const applyPatchDropzone = document.getElementById('apply-patch-dropzone'); +const applyPatchInput = document.getElementById('apply-patch-input'); +const applyPatchEmpty = document.getElementById('apply-patch-empty'); +const applyPatchSelected = document.getElementById('apply-patch-selected'); +const applyPatchName = document.getElementById('apply-patch-name'); +const applyPatchSize = document.getElementById('apply-patch-size'); +const applyPatchClear = document.getElementById('apply-patch-clear'); +const applyPatchBrowse = document.getElementById('apply-patch-browse'); + +const applyInfoPlaceholder = document.getElementById('apply-info-placeholder'); +const applyInfoCard = document.getElementById('apply-info-card'); +const applyInfoVersion = document.getElementById('apply-info-version'); +const applyInfoDescription = document.getElementById('apply-info-description'); +const applyInfoImagetypeRow = document.getElementById('apply-info-imagetype-row'); +const applyInfoImagetype = document.getElementById('apply-info-imagetype'); +const applyInfoBlockcheckRow = document.getElementById('apply-info-blockcheck-row'); +const applyInfoBlockcheck = document.getElementById('apply-info-blockcheck'); +const applyInfoUndoRow = document.getElementById('apply-info-undo-row'); +const applyInfoUndo = document.getElementById('apply-info-undo'); +const applyInfoFileid = document.getElementById('apply-info-fileid'); + +const btnApplyPatch = document.getElementById('btn-apply-patch'); +const btnUndoPatch = document.getElementById('btn-undo-patch'); +const applyProgressBox = document.getElementById('apply-progress-box'); +const applyProgressTitle = document.getElementById('apply-progress-title'); +const applyProgressPercent = document.getElementById('apply-progress-percent'); +const applyProgressFill = document.getElementById('apply-progress-fill'); +const applyProgressDetail = document.getElementById('apply-progress-detail'); +const applyStatusBanner = document.getElementById('apply-status-banner'); + +// Tab 2: Create Patch +const createOrigDropzone = document.getElementById('create-orig-dropzone'); +const createOrigInput = document.getElementById('create-orig-input'); +const createOrigEmpty = document.getElementById('create-orig-empty'); +const createOrigSelected = document.getElementById('create-orig-selected'); +const createOrigName = document.getElementById('create-orig-name'); +const createOrigSize = document.getElementById('create-orig-size'); +const createOrigClear = document.getElementById('create-orig-clear'); +const createOrigBrowse = document.getElementById('create-orig-browse'); + +const createModDropzone = document.getElementById('create-mod-dropzone'); +const createModInput = document.getElementById('create-mod-input'); +const createModEmpty = document.getElementById('create-mod-empty'); +const createModSelected = document.getElementById('create-mod-selected'); +const createModName = document.getElementById('create-mod-name'); +const createModSize = document.getElementById('create-mod-size'); +const createModClear = document.getElementById('create-mod-clear'); +const createModBrowse = document.getElementById('create-mod-browse'); + +const createOutputName = document.getElementById('create-output-name'); +const createDescInput = document.getElementById('create-desc-input'); +const createDescCounter = document.getElementById('create-desc-counter'); +const createBlockCheck = document.getElementById('create-block-check'); +const createUndoData = document.getElementById('create-undo-data'); +const createDizInput = document.getElementById('create-diz-input'); +const btnCreatePatch = document.getElementById('btn-create-patch'); + +const createProgressBox = document.getElementById('create-progress-box'); +const createProgressTitle = document.getElementById('create-progress-title'); +const createProgressPercent = document.getElementById('create-progress-percent'); +const createProgressFill = document.getElementById('create-progress-fill'); +const createProgressDetail = document.getElementById('create-progress-detail'); +const createStatusBanner = document.getElementById('create-status-banner'); + +// Tab 3: Info & DIZ +const infoPatchDropzone = document.getElementById('info-patch-dropzone'); +const infoPatchInput = document.getElementById('info-patch-input'); +const infoPatchEmpty = document.getElementById('info-patch-empty'); +const infoPatchSelected = document.getElementById('info-patch-selected'); +const infoPatchName = document.getElementById('info-patch-name'); +const infoPatchSize = document.getElementById('info-patch-size'); +const infoPatchClear = document.getElementById('info-patch-clear'); +const infoPatchBrowse = document.getElementById('info-patch-browse'); + +const infoPlaceholder = document.getElementById('info-placeholder'); +const infoGrid = document.getElementById('info-grid'); +const infoVersion = document.getElementById('info-version'); +const infoDescription = document.getElementById('info-description'); +const infoImagetypeRow = document.getElementById('info-imagetype-row'); +const infoImagetype = document.getElementById('info-imagetype'); +const infoBlockcheckRow = document.getElementById('info-blockcheck-row'); +const infoBlockcheck = document.getElementById('info-blockcheck'); +const infoUndoRow = document.getElementById('info-undo-row'); +const infoUndo = document.getElementById('info-undo'); +const infoFileidStatus = document.getElementById('info-fileid-status'); + +const infoDizCard = document.getElementById('info-diz-card'); +const infoDizViewer = document.getElementById('info-diz-viewer'); +const btnCopyDiz = document.getElementById('btn-copy-diz'); + +// State Variables +let state = { + applyBinFile: null, + applyPatchFile: null, + applyPatchInfo: null, + createOrigFile: null, + createModFile: null, + infoPatchFile: null, + infoPatchInfo: null, + isProcessing: false +}; + +// Tab Switching +tabButtons.forEach((btn) => { + btn.addEventListener('click', () => { + const targetTabId = btn.dataset.tab; + tabButtons.forEach((b) => { + b.classList.toggle('active', b === btn); + b.setAttribute('aria-selected', b === btn ? 'true' : 'false'); + }); + tabContents.forEach((c) => { + c.classList.toggle('active', c.id === targetTabId); + }); + }); +}); + +// Update Apply / Undo Button States +function updateApplyButtonStates() { + const hasBin = state.applyBinFile !== null; + const hasPatch = state.applyPatchFile !== null && state.applyPatchInfo !== null; + const canApply = hasBin && hasPatch && !state.isProcessing; + const canUndo = canApply && state.applyPatchInfo.has_undo; + + btnApplyPatch.disabled = !canApply; + btnUndoPatch.disabled = !canUndo; +} + +// Update Create Button State +function updateCreateButtonState() { + const hasOrig = state.createOrigFile !== null; + const hasMod = state.createModFile !== null; + const canCreate = hasOrig && hasMod && !state.isProcessing; + + btnCreatePatch.disabled = !canCreate; +} + +// Render Patch Info into Elements +function renderPatchInfo(info, elements) { + const { + versionEl, + descEl, + imageTypeRow, + imageTypeEl, + blockCheckRow, + blockCheckEl, + undoRow, + undoEl, + fileIdEl + } = elements; + + let verText = 'PPF 1.0'; + if (info.version === 'V2') verText = 'PPF 2.0 (Blockcheck format)'; + else if (info.version === 'V3') verText = 'PPF 3.0 (Advanced format)'; + else if (info.version === 'V1') verText = 'PPF 1.0 (Legacy format)'; + versionEl.textContent = verText; + + descEl.textContent = info.description || '(No description)'; + + if (info.version === 'V3') { + imageTypeRow.classList.remove('hidden'); + imageTypeEl.textContent = info.image_type === 'Gi' + ? 'GI (Global Image / PrimoDVD)' + : 'BIN (Standard RAW / ISO)'; + + blockCheckRow.classList.remove('hidden'); + if (info.block_check) { + blockCheckEl.innerHTML = 'Enabled (1024-byte validation)'; + } else { + blockCheckEl.innerHTML = 'Disabled'; + } + + undoRow.classList.remove('hidden'); + if (info.has_undo) { + undoEl.innerHTML = 'Available (Reversible)'; + } else { + undoEl.innerHTML = 'Not available (One-way)'; + } + } else { + imageTypeRow.classList.add('hidden'); + undoRow.classList.add('hidden'); + if (info.version === 'V2') { + blockCheckRow.classList.remove('hidden'); + blockCheckEl.innerHTML = 'Enabled (PPF2 Checksum)'; + } else { + blockCheckRow.classList.add('hidden'); + } + } + + if (info.file_id) { + fileIdEl.innerHTML = 'Embedded in trailer'; + } else { + fileIdEl.innerHTML = 'None'; + } +} + +// Setup Tab 1: Apply & Undo Dropzones +setupDropzone({ + dropzoneEl: applyBinDropzone, + inputEl: applyBinInput, + emptyEl: applyBinEmpty, + selectedEl: applyBinSelected, + nameEl: applyBinName, + sizeEl: applyBinSize, + clearEl: applyBinClear, + browseBtn: applyBinBrowse, + onFileSelected: (file) => { + state.applyBinFile = file; + applyOutputName.value = generatePatchedFilename(file.name); + applyStatusBanner.classList.add('hidden'); + updateApplyButtonStates(); + }, + onFileCleared: () => { + state.applyBinFile = null; + updateApplyButtonStates(); + } +}); + +setupDropzone({ + dropzoneEl: applyPatchDropzone, + inputEl: applyPatchInput, + emptyEl: applyPatchEmpty, + selectedEl: applyPatchSelected, + nameEl: applyPatchName, + sizeEl: applyPatchSize, + clearEl: applyPatchClear, + browseBtn: applyPatchBrowse, + onFileSelected: async (file) => { + state.applyPatchFile = file; + state.applyPatchInfo = null; + applyStatusBanner.classList.add('hidden'); + applyInfoPlaceholder.textContent = 'Inspecting patch file...'; + applyInfoPlaceholder.classList.remove('hidden'); + applyInfoCard.classList.add('hidden'); + + try { + const buffer = await file.arrayBuffer(); + const res = await sendWorkerMessage('inspect', { patchBytes: buffer }); + state.applyPatchInfo = res.info; + + applyInfoPlaceholder.classList.add('hidden'); + applyInfoCard.classList.remove('hidden'); + renderPatchInfo(res.info, { + versionEl: applyInfoVersion, + descEl: applyInfoDescription, + imageTypeRow: applyInfoImagetypeRow, + imageTypeEl: applyInfoImagetype, + blockCheckRow: applyInfoBlockcheckRow, + blockCheckEl: applyInfoBlockcheck, + undoRow: applyInfoUndoRow, + undoEl: applyInfoUndo, + fileIdEl: applyInfoFileid + }); + } catch (err) { + applyInfoPlaceholder.textContent = `Failed to parse patch: ${err.message}`; + applyInfoPlaceholder.classList.remove('hidden'); + applyInfoCard.classList.add('hidden'); + } + updateApplyButtonStates(); + }, + onFileCleared: () => { + state.applyPatchFile = null; + state.applyPatchInfo = null; + applyInfoPlaceholder.textContent = 'Select or drag & drop a .ppf patch file above to inspect its metadata.'; + applyInfoPlaceholder.classList.remove('hidden'); + applyInfoCard.classList.add('hidden'); + updateApplyButtonStates(); + } +}); + +// Setup Tab 2: Create Patch Dropzones +setupDropzone({ + dropzoneEl: createOrigDropzone, + inputEl: createOrigInput, + emptyEl: createOrigEmpty, + selectedEl: createOrigSelected, + nameEl: createOrigName, + sizeEl: createOrigSize, + clearEl: createOrigClear, + browseBtn: createOrigBrowse, + onFileSelected: (file) => { + state.createOrigFile = file; + createStatusBanner.classList.add('hidden'); + if (state.createModFile && state.createOrigFile.size !== state.createModFile.size) { + showBanner( + createStatusBanner, + 'error', + `Size Mismatch: Original file is ${formatFileSize(state.createOrigFile.size)}, but Modified file is ${formatFileSize(state.createModFile.size)}. Both files must have the exact same size.` + ); + } + updateCreateButtonState(); + }, + onFileCleared: () => { + state.createOrigFile = null; + updateCreateButtonState(); + } +}); + +setupDropzone({ + dropzoneEl: createModDropzone, + inputEl: createModInput, + emptyEl: createModEmpty, + selectedEl: createModSelected, + nameEl: createModName, + sizeEl: createModSize, + clearEl: createModClear, + browseBtn: createModBrowse, + onFileSelected: (file) => { + state.createModFile = file; + createStatusBanner.classList.add('hidden'); + if (state.createOrigFile && state.createOrigFile.size !== state.createModFile.size) { + showBanner( + createStatusBanner, + 'error', + `Size Mismatch: Original file is ${formatFileSize(state.createOrigFile.size)}, but Modified file is ${formatFileSize(state.createModFile.size)}. Both files must have the exact same size.` + ); + } + updateCreateButtonState(); + }, + onFileCleared: () => { + state.createModFile = null; + updateCreateButtonState(); + } +}); + +// Description Character Counter +createDescInput.addEventListener('input', () => { + createDescCounter.textContent = `${createDescInput.value.length}/50`; +}); + +// Setup Tab 3: Info Dropzone +setupDropzone({ + dropzoneEl: infoPatchDropzone, + inputEl: infoPatchInput, + emptyEl: infoPatchEmpty, + selectedEl: infoPatchSelected, + nameEl: infoPatchName, + sizeEl: infoPatchSize, + clearEl: infoPatchClear, + browseBtn: infoPatchBrowse, + onFileSelected: async (file) => { + state.infoPatchFile = file; + infoPlaceholder.textContent = 'Inspecting patch file...'; + infoPlaceholder.classList.remove('hidden'); + infoGrid.classList.add('hidden'); + infoDizCard.classList.add('hidden'); + + try { + const buffer = await file.arrayBuffer(); + const res = await sendWorkerMessage('inspect', { patchBytes: buffer }); + state.infoPatchInfo = res.info; + + infoPlaceholder.classList.add('hidden'); + infoGrid.classList.remove('hidden'); + renderPatchInfo(res.info, { + versionEl: infoVersion, + descEl: infoDescription, + imageTypeRow: infoImagetypeRow, + imageTypeEl: infoImagetype, + blockCheckRow: infoBlockcheckRow, + blockCheckEl: infoBlockcheck, + undoRow: infoUndoRow, + undoEl: infoUndo, + fileIdEl: infoFileidStatus + }); + + if (res.info.file_id) { + infoDizViewer.textContent = res.info.file_id; + infoDizCard.classList.remove('hidden'); + } else { + infoDizCard.classList.add('hidden'); + } + } catch (err) { + infoPlaceholder.textContent = `Failed to parse patch: ${err.message}`; + infoPlaceholder.classList.remove('hidden'); + infoGrid.classList.add('hidden'); + infoDizCard.classList.add('hidden'); + } + }, + onFileCleared: () => { + state.infoPatchFile = null; + state.infoPatchInfo = null; + infoPlaceholder.textContent = 'Select or drag & drop a PPF patch file above to inspect its metadata and embedded release information.'; + infoPlaceholder.classList.remove('hidden'); + infoGrid.classList.add('hidden'); + infoDizCard.classList.add('hidden'); + } +}); + +// Copy DIZ Button +btnCopyDiz.addEventListener('click', async () => { + const text = infoDizViewer.textContent; + if (!text) return; + try { + await navigator.clipboard.writeText(text); + const originalLabel = btnCopyDiz.textContent; + btnCopyDiz.textContent = 'Copied!'; + setTimeout(() => { + btnCopyDiz.textContent = originalLabel; + }, 2000); + } catch (err) { + console.error('Failed to copy DIZ text:', err); + } +}); + +// Banner Display Helper +function showBanner(bannerEl, type, message) { + bannerEl.className = `status-banner ${type}`; + bannerEl.textContent = message; + bannerEl.classList.remove('hidden'); +} + +// Action: Apply Patch +btnApplyPatch.addEventListener('click', async () => { + if (!state.applyBinFile || !state.applyPatchFile || state.isProcessing) return; + + state.isProcessing = true; + updateApplyButtonStates(); + applyStatusBanner.classList.add('hidden'); + applyProgressBox.classList.remove('hidden'); + applyProgressTitle.textContent = 'Applying PPF patch...'; + applyProgressPercent.textContent = '0%'; + applyProgressFill.style.width = '0%'; + applyProgressDetail.textContent = 'Reading binary image into memory...'; + + try { + const patchBuffer = await state.applyPatchFile.arrayBuffer(); + const binBuffer = await state.applyBinFile.arrayBuffer(); + + const onProgress = ({ percent, message }) => { + applyProgressPercent.textContent = `${percent.toFixed(1)}%`; + applyProgressFill.style.width = `${percent}%`; + applyProgressDetail.textContent = message; + }; + + const res = await sendWorkerMessage( + 'apply', + { patchBytes: patchBuffer, binBytes: binBuffer }, + [binBuffer], + onProgress + ); + + applyProgressPercent.textContent = '100%'; + applyProgressFill.style.width = '100%'; + applyProgressDetail.textContent = 'Completed!'; + + const outName = applyOutputName.value.trim() || generatePatchedFilename(state.applyBinFile.name); + triggerDownload(res.binBytes, outName); + + showBanner( + applyStatusBanner, + 'success', + `Patch successfully applied! Downloaded "${outName}". Description: "${res.info.description}"` + ); + } catch (err) { + showBanner(applyStatusBanner, 'error', `Failed to apply patch: ${err.message}`); + } finally { + state.isProcessing = false; + applyProgressBox.classList.add('hidden'); + updateApplyButtonStates(); + } +}); + +// Action: Undo Patch +btnUndoPatch.addEventListener('click', async () => { + if (!state.applyBinFile || !state.applyPatchFile || state.isProcessing) return; + + state.isProcessing = true; + updateApplyButtonStates(); + applyStatusBanner.classList.add('hidden'); + applyProgressBox.classList.remove('hidden'); + applyProgressTitle.textContent = 'Reversing PPF patch...'; + applyProgressPercent.textContent = '0%'; + applyProgressFill.style.width = '0%'; + applyProgressDetail.textContent = 'Reading binary image into memory...'; + + try { + const patchBuffer = await state.applyPatchFile.arrayBuffer(); + const binBuffer = await state.applyBinFile.arrayBuffer(); + + const onProgress = ({ percent, message }) => { + applyProgressPercent.textContent = `${percent.toFixed(1)}%`; + applyProgressFill.style.width = `${percent}%`; + applyProgressDetail.textContent = message; + }; + + const res = await sendWorkerMessage( + 'undo', + { patchBytes: patchBuffer, binBytes: binBuffer }, + [binBuffer], + onProgress + ); + + applyProgressPercent.textContent = '100%'; + applyProgressFill.style.width = '100%'; + applyProgressDetail.textContent = 'Completed!'; + + const outName = generateRestoredFilename(state.applyBinFile.name); + triggerDownload(res.binBytes, outName); + + showBanner( + applyStatusBanner, + 'success', + `Patch successfully reversed! Downloaded original binary as "${outName}".` + ); + } catch (err) { + showBanner(applyStatusBanner, 'error', `Failed to undo patch: ${err.message}`); + } finally { + state.isProcessing = false; + applyProgressBox.classList.add('hidden'); + updateApplyButtonStates(); + } +}); + +// Action: Create Patch +btnCreatePatch.addEventListener('click', async () => { + if (!state.createOrigFile || !state.createModFile || state.isProcessing) return; + + if (state.createOrigFile.size !== state.createModFile.size) { + showBanner( + createStatusBanner, + 'error', + `Size Mismatch: Original file is ${formatFileSize(state.createOrigFile.size)}, but Modified file is ${formatFileSize(state.createModFile.size)}. Both files must have the exact same size.` + ); + return; + } + + state.isProcessing = true; + updateCreateButtonState(); + createStatusBanner.classList.add('hidden'); + createProgressBox.classList.remove('hidden'); + createProgressTitle.textContent = 'Scanning differences...'; + createProgressPercent.textContent = '0%'; + createProgressFill.style.width = '0%'; + createProgressDetail.textContent = 'Reading original and modified binaries...'; + + try { + const origBuffer = await state.createOrigFile.arrayBuffer(); + const modBuffer = await state.createModFile.arrayBuffer(); + + const imageType = document.querySelector('input[name="create-image-type"]:checked')?.value || 'bin'; + const description = createDescInput.value.trim() || undefined; + const blockCheck = createBlockCheck.checked; + const undoData = createUndoData.checked; + const fileId = createDizInput.value.trim() || undefined; + + const options = { + description, + imageType, + blockCheck, + undoData, + fileId + }; + + const onProgress = ({ percent, message }) => { + createProgressPercent.textContent = `${percent.toFixed(1)}%`; + createProgressFill.style.width = `${percent}%`; + createProgressDetail.textContent = message; + }; + + const res = await sendWorkerMessage( + 'create', + { origBytes: origBuffer, modBytes: modBuffer, options }, + [origBuffer, modBuffer], + onProgress + ); + + createProgressPercent.textContent = '100%'; + createProgressFill.style.width = '100%'; + createProgressDetail.textContent = 'Patch created!'; + + const outName = createOutputName.value.trim() || 'patch.ppf'; + triggerDownload(res.patchBytes, outName, 'application/octet-stream'); + + showBanner( + createStatusBanner, + 'success', + `PPF3 patch successfully generated and downloaded as "${outName}" (${formatFileSize(res.patchBytes.byteLength)}).` + ); + } catch (err) { + showBanner(createStatusBanner, 'error', `Failed to create patch: ${err.message}`); + } finally { + state.isProcessing = false; + createProgressBox.classList.add('hidden'); + updateCreateButtonState(); + } +}); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..e43d2b2 --- /dev/null +++ b/web/index.html @@ -0,0 +1,472 @@ + + + + + + PPF Rust Patcher - Web Edition + + + + + +
+ +
+
+ +
+

PPF Rust Patcher

+ WebAssembly Edition +
+
+ +
+ + + + + +
+
+

Target Image & Patch Selection

+ + +
+ +
+ +
+ 📁 +

Drag & drop your target binary image here, or

+
+ +
+
+ + +
+ + + The patched binary image will be downloaded with this filename upon completion. +
+ + +
+ +
+ +
+ 📄 +

Drag & drop your .ppf patch file here, or

+
+ +
+
+
+ + +
+

Patch Details & Validation

+
+ Select or drag & drop a .ppf patch file above to inspect its metadata. +
+ +
+ + +
+ + +
+ + + + + +
+ + +
+
+

Source and Destination Files

+ + +
+ +
+ +
+ 📁 +

Drag & drop clean original binary here, or

+
+ +
+
+ + +
+ +
+ +
+ 📁 +

Drag & drop modified binary here, or

+
+ +
+
+ + +
+ + +
+
+ + +
+

PPF3 Configuration & Metadata

+ + +
+
+ + 0/50 +
+ +
+ + +
+ +
+ + +
+
+ + +
+ +
+ +
+ +
+ + +
+ + +
+
+ + +
+ +
+ + + + + +
+ + +
+
+

Inspect Patch File

+
+ +
+ +
+ 📄 +

Drag & drop any .ppf patch file here, or

+
+ +
+
+
+ +
+

Patch Header Information

+
+ Select or drag & drop a PPF patch file above to inspect its metadata and embedded release information. +
+ +
+ + + +
+ + +
+
+

PPF Rust Patcher - User Guide & Help

+

+ This tool allows you to easily apply, reverse, and create PPF (PlayStation Patch Format) patches for disc images (CD/DVD) and ROM files directly in your web browser with native WebAssembly performance. +

+ +
+ +
+ 1. What is a PPF Patch? +
+

PPF (PlayStation Patch Format) is a binary diff patching standard originally designed for PlayStation CD-ROM images and widely used for translations, game modifications, and fan patches.

+
    +
  • PPF 1.0: Legacy format with simple byte offsets and raw diff records.
  • +
  • PPF 2.0: Introduces a 1024-byte checksum block validation to ensure you are patching the correct file.
  • +
  • PPF 3.0: Modern format with 64-bit offsets (supports multi-gigabyte DVD/Blu-ray images), optional Blockcheck validation, embedded FILE_ID.DIZ release descriptions, and reversible Undo support.
  • +
+
+
+ + +
+ 2. How to Apply a Patch +
+
    +
  1. Select your Target Binary Image (.bin, .iso, .img, etc.) or drag & drop it into the window.
  2. +
  3. Select your PPF Patch File (.ppf) or drag & drop it into the window.
  4. +
  5. Specify your desired output filename.
  6. +
  7. Review the Patch Details card (Description, Version, Validation status).
  8. +
  9. Click the Apply Patch button. Your patched file will be generated and downloaded automatically.
  10. +
+
+
+ + +
+ 3. How to Undo (Reverse) a Patch +
+

If a PPF 3.0 patch was created with Undo Data enabled, you can completely reverse the changes and restore the original binary bytes without needing a fresh game dump.

+
    +
  1. Select the modified/patched binary image in the Apply & Undo tab.
  2. +
  3. Select the matching .ppf patch file.
  4. +
  5. If the patch contains undo data, the Undo Patch button will become active.
  6. +
  7. Click Undo Patch to restore the binary and download the original file.
  8. +
+
+
+ + +
+ 4. How to Create a PPF3 Patch +
+
    +
  1. In the Create Patch tab, select your Original Unmodified Binary.
  2. +
  3. Select your Modified/Patched Binary. (Note: Both files MUST have the exact same file size in bytes).
  4. +
  5. Choose the Output PPF3 Patch File name.
  6. +
  7. Configure patch options: +
      +
    • Description: Short title or author credit (max 50 chars).
    • +
    • Image Type: Choose 'BIN/RAW' for normal CD/ISO images or 'GI' for Global Image format.
    • +
    • Include Block Check: Highly recommended. Prevents users from applying your patch to the wrong revision/region.
    • +
    • Include Undo Data: Enables users to reverse your patch later.
    • +
    • FILE_ID.DIZ: Optional release text file containing changelogs, credits, or instructions.
    • +
    +
  8. +
  9. Click Create PPF3 Patch. The diff engine will process the files and download the resulting .ppf patch.
  10. +
+
+
+ + +
+ 5. Common Errors & Troubleshooting +
+
+ Error: Bin file size mismatch +

The target binary image size does not match what the patch expects. Make sure you are using an uncompressed raw image (.bin/.iso).

+
+
+ Error: Block check validation failed +

The disc image checksum did not match. You might be using a different game region (e.g. NTSC vs PAL), a bad dump, or a file that has already been patched.

+
+
+ Error: Undo data not available +

This patch is a one-way patch and did not include original restoration data.

+
+
+
+
+
+
+ + + +
+ + + + + diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..68ed0c8 --- /dev/null +++ b/web/styles.css @@ -0,0 +1,716 @@ +:root { + --bg-main: #111114; + --bg-card: #1b1b22; + --bg-card-header: #21212a; + --bg-input: #141419; + --bg-dropzone: #16161c; + --bg-dropzone-hover: #1e2436; + + --border-color: #2b2b36; + --border-highlight: #3b82f6; + + --text-main: #f1f1f4; + --text-muted: #9ba1b0; + --text-dim: #6b7280; + + --primary: #3b82f6; + --primary-hover: #2563eb; + --primary-dim: rgba(59, 130, 246, 0.15); + + --success: #10b981; + --success-bg: rgba(16, 185, 129, 0.12); + --warning: #f59e0b; + --warning-bg: rgba(245, 158, 11, 0.12); + --danger: #ef4444; + --danger-bg: rgba(239, 68, 68, 0.12); + --info-blue: #38bdf8; + + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); + --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background-color: var(--bg-main); + color: var(--text-main); + font-family: var(--font-family); + line-height: 1.5; + min-height: 100vh; + display: flex; + justify-content: center; + padding: 24px 16px; + -webkit-font-smoothing: antialiased; +} + +.app-container { + width: 100%; + max-width: 820px; + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Header */ +.app-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} + +.header-left { + display: flex; + align-items: center; + gap: 14px; +} + +.app-logo { + background: linear-gradient(135deg, #1d4ed8, #3b82f6); + color: #fff; + padding: 6px 10px; + border-radius: var(--radius-sm); + display: flex; + flex-direction: column; + align-items: center; + line-height: 1; +} + +.logo-text { + font-weight: 900; + font-size: 16px; + letter-spacing: 1px; +} + +.logo-sub { + font-size: 9px; + letter-spacing: 2px; + font-weight: 700; + opacity: 0.85; +} + +.title-group h1 { + font-size: 18px; + font-weight: 700; + letter-spacing: -0.3px; +} + +.edition-badge { + display: inline-block; + font-size: 11px; + font-weight: 600; + color: var(--primary); + background: var(--primary-dim); + padding: 2px 8px; + border-radius: 999px; + margin-top: 2px; +} + +.header-right .gh-link { + display: flex; + align-items: center; + gap: 6px; + color: var(--text-muted); + text-decoration: none; + font-size: 13px; + font-weight: 600; + padding: 6px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-color); + background: var(--bg-input); + transition: all 0.2s ease; +} + +.header-right .gh-link:hover { + color: var(--text-main); + border-color: var(--text-muted); + background: var(--bg-card-header); +} + +/* Nav Tabs */ +.nav-tabs { + display: flex; + gap: 8px; + background: var(--bg-card); + padding: 6px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + overflow-x: auto; +} + +.tab-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 14px; + background: transparent; + border: none; + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; +} + +.tab-btn:hover { + color: var(--text-main); + background: rgba(255, 255, 255, 0.04); +} + +.tab-btn.active { + background: var(--primary); + color: #fff; + box-shadow: var(--shadow-sm); +} + +/* Tab Content */ +.tab-content { + display: none; + flex-direction: column; + gap: 16px; +} + +.tab-content.active { + display: flex; +} + +/* Cards */ +.card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 18px; + box-shadow: var(--shadow-sm); +} + +.card-title { + font-size: 15px; + font-weight: 700; + color: var(--text-main); + margin-bottom: 14px; + letter-spacing: -0.2px; +} + +.card-header-flex { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.card-header-flex .card-title { + margin-bottom: 0; +} + +/* Form Controls */ +.form-group { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 14px; +} + +.form-group:last-child { + margin-bottom: 0; +} + +.form-group label { + font-size: 13px; + color: var(--text-main); +} + +.dim { + color: var(--text-dim); + font-weight: normal; +} + +.help-text { + font-size: 12px; + color: var(--text-muted); +} + +.text-input, .textarea-input { + width: 100%; + padding: 10px 12px; + background: var(--bg-input); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + color: var(--text-main); + font-size: 13px; + font-family: inherit; + outline: none; + transition: border-color 0.15s ease; +} + +.text-input:focus, .textarea-input:focus { + border-color: var(--border-highlight); +} + +.label-with-counter { + display: flex; + justify-content: space-between; + align-items: center; +} + +.char-counter { + font-size: 11px; + color: var(--text-muted); + font-family: var(--font-mono); +} + +.radio-group { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 4px; +} + +.radio-label, .checkbox-label { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-main); + cursor: pointer; +} + +/* Dropzones */ +.dropzone { + border: 2px dashed var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-dropzone); + padding: 16px; + text-align: center; + transition: all 0.2s ease; + position: relative; + cursor: pointer; +} + +.dropzone:hover, .dropzone.dragover { + border-color: var(--border-highlight); + background: var(--bg-dropzone-hover); +} + +.file-input-hidden { + display: none; +} + +.dropzone-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +.dropzone-icon { + font-size: 24px; +} + +.dropzone-text { + font-size: 13px; + color: var(--text-muted); +} + +.btn-link { + background: none; + border: none; + color: var(--primary); + font-size: 13px; + font-weight: 600; + cursor: pointer; + text-decoration: underline; + padding: 0; +} + +.btn-link:hover { + color: var(--primary-hover); +} + +.dropzone-selected { + display: flex; + align-items: center; + gap: 12px; + background: var(--bg-input); + padding: 10px 14px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-color); +} + +.file-icon { + font-size: 20px; +} + +.file-info { + flex: 1; + display: flex; + flex-direction: column; + text-align: left; + overflow: hidden; +} + +.file-name { + font-size: 13px; + font-weight: 600; + color: var(--text-main); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-family: var(--font-mono); +} + +.file-size { + font-size: 11px; + color: var(--text-muted); +} + +.btn-clear { + background: none; + border: none; + color: var(--text-dim); + font-size: 18px; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + line-height: 1; +} + +.btn-clear:hover { + color: var(--danger); + background: var(--danger-bg); +} + +/* Details Grid */ +.placeholder-box { + padding: 20px; + text-align: center; + font-size: 13px; + color: var(--text-dim); + font-style: italic; + background: var(--bg-input); + border-radius: var(--radius-sm); + border: 1px dashed var(--border-color); +} + +.details-grid { + display: flex; + flex-direction: column; + gap: 8px; + background: var(--bg-input); + padding: 14px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-color); +} + +.grid-row { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 13px; +} + +.grid-label { + color: var(--text-muted); + font-weight: 500; +} + +.grid-value { + color: var(--text-main); + font-weight: 600; + text-align: right; +} + +.font-italic { + font-style: italic; +} + +/* Badges */ +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; +} + +.badge-blue { + background: rgba(56, 189, 248, 0.15); + color: var(--info-blue); +} + +.badge-green { + background: var(--success-bg); + color: var(--success); +} + +.badge-yellow { + background: var(--warning-bg); + color: var(--warning); +} + +.badge-red { + background: var(--danger-bg); + color: var(--danger); +} + +/* Action Buttons */ +.actions-container { + display: flex; + gap: 12px; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 20px; + border-radius: var(--radius-sm); + font-size: 14px; + font-weight: 700; + border: none; + cursor: pointer; + transition: all 0.15s ease; +} + +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.btn-primary { + background: var(--primary); + color: #fff; + flex: 1; +} + +.btn-primary:not(:disabled):hover { + background: var(--primary-hover); + box-shadow: 0 0 12px rgba(59, 130, 246, 0.4); +} + +.btn-secondary { + background: var(--bg-card-header); + color: var(--text-main); + border: 1px solid var(--border-color); +} + +.btn-secondary:not(:disabled):hover { + background: #2a2a36; + border-color: var(--text-muted); +} + +.btn-small { + padding: 6px 12px; + font-size: 12px; + font-weight: 600; + background: var(--bg-input); + border: 1px solid var(--border-color); + color: var(--text-muted); +} + +.btn-small:hover { + color: var(--text-main); + border-color: var(--text-muted); +} + +/* Progress Box */ +.progress-box { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 14px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.progress-header { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 13px; + font-weight: 600; +} + +.progress-percent { + color: var(--primary); + font-family: var(--font-mono); +} + +.progress-track { + height: 8px; + background: var(--bg-input); + border-radius: 999px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, #3b82f6, #60a5fa); + border-radius: 999px; + transition: width 0.1s ease; +} + +.progress-detail { + font-size: 12px; + color: var(--text-muted); + font-family: var(--font-mono); +} + +/* Status Banner */ +.status-banner { + padding: 14px 16px; + border-radius: var(--radius-md); + font-size: 13px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.status-banner.success { + background: var(--success-bg); + border: 1px solid var(--success); + color: #34d399; +} + +.status-banner.error { + background: var(--danger-bg); + border: 1px solid var(--danger); + color: #f87171; +} + +/* DIZ Viewer */ +.diz-viewer { + background: var(--bg-input); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 12px; + font-family: var(--font-mono); + font-size: 12px; + color: #bae6fd; + max-height: 240px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-all; +} + +/* Accordion */ +.accordion { + display: flex; + flex-direction: column; + gap: 8px; +} + +.accordion-item { + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-input); + overflow: hidden; +} + +.accordion-header { + padding: 12px 14px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + user-select: none; + color: var(--text-main); + background: var(--bg-card); +} + +.accordion-header:hover { + color: var(--primary); +} + +.accordion-body { + padding: 14px; + font-size: 13px; + color: var(--text-muted); + border-top: 1px solid var(--border-color); +} + +.guide-list { + margin-left: 20px; + margin-top: 8px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.guide-list strong { + color: var(--text-main); +} + +.trouble-item { + margin-bottom: 12px; +} + +.trouble-item:last-child { + margin-bottom: 0; +} + +.trouble-item p { + margin-top: 4px; +} + +.intro-text { + font-size: 14px; + color: var(--text-muted); + font-style: italic; + margin-bottom: 16px; +} + +/* Footer */ +.app-footer { + text-align: center; + padding: 16px; + font-size: 12px; + color: var(--text-dim); +} + +.app-footer a { + color: var(--text-muted); + text-decoration: none; +} + +.app-footer a:hover { + color: var(--text-main); + text-decoration: underline; +} + +/* Utilities */ +.hidden { + display: none !important; +} + +/* Responsive */ +@media (max-width: 600px) { + .nav-tabs { + justify-content: flex-start; + } + .actions-container { + flex-direction: column; + } + .grid-row { + flex-direction: column; + align-items: flex-start; + gap: 2px; + } +} diff --git a/web/worker.js b/web/worker.js new file mode 100644 index 0000000..9bfec0c --- /dev/null +++ b/web/worker.js @@ -0,0 +1,117 @@ +import init, { inspect_patch, apply_patch, undo_patch, create_patch } from './pkg/ppf_wasm.js'; + +let wasmReady = false; +let wasmInitPromise = null; + +async function ensureWasm() { + if (!wasmReady) { + if (!wasmInitPromise) { + wasmInitPromise = init().then(() => { + wasmReady = true; + }); + } + await wasmInitPromise; + } +} + +self.onmessage = async (event) => { + const { id, type, payload } = event.data; + + try { + await ensureWasm(); + + if (type === 'inspect') { + const patchBytes = new Uint8Array(payload.patchBytes); + const info = inspect_patch(patchBytes); + self.postMessage({ id, type: 'complete', success: true, info }); + } else if (type === 'apply') { + const patchBytes = new Uint8Array(payload.patchBytes); + const targetBytes = new Uint8Array(payload.binBytes); + + const progressCb = (done, total) => { + const percent = total > 0 ? (done / total) * 100 : 0; + self.postMessage({ + id, + type: 'progress', + done, + total, + percent, + message: `Applying records: ${Math.round(done)} / ${Math.round(total)} (${percent.toFixed(1)}%)` + }); + }; + + const info = apply_patch(patchBytes, targetBytes, progressCb); + self.postMessage( + { + id, + type: 'complete', + success: true, + info, + binBytes: targetBytes.buffer + }, + [targetBytes.buffer] + ); + } else if (type === 'undo') { + const patchBytes = new Uint8Array(payload.patchBytes); + const targetBytes = new Uint8Array(payload.binBytes); + + const progressCb = (done, total) => { + const percent = total > 0 ? (done / total) * 100 : 0; + self.postMessage({ + id, + type: 'progress', + done, + total, + percent, + message: `Undoing records: ${Math.round(done)} / ${Math.round(total)} (${percent.toFixed(1)}%)` + }); + }; + + const info = undo_patch(patchBytes, targetBytes, progressCb); + self.postMessage( + { + id, + type: 'complete', + success: true, + info, + binBytes: targetBytes.buffer + }, + [targetBytes.buffer] + ); + } else if (type === 'create') { + const origBytes = new Uint8Array(payload.origBytes); + const modBytes = new Uint8Array(payload.modBytes); + const options = payload.options; + + const progressCb = (doneBytes, totalBytes) => { + const percent = totalBytes > 0 ? (doneBytes / totalBytes) * 100 : 0; + const mbDone = (doneBytes / (1024 * 1024)).toFixed(1); + const mbTotal = (totalBytes / (1024 * 1024)).toFixed(1); + self.postMessage({ + id, + type: 'progress', + done: doneBytes, + total: totalBytes, + percent, + message: `${mbDone} MB / ${mbTotal} MB (${percent.toFixed(1)}%)` + }); + }; + + const patchUint8 = create_patch(origBytes, modBytes, options, progressCb); + self.postMessage( + { + id, + type: 'complete', + success: true, + patchBytes: patchUint8.buffer + }, + [patchUint8.buffer] + ); + } else { + throw new Error(`Unknown action type: ${type}`); + } + } catch (err) { + const errorMsg = err?.message || String(err); + self.postMessage({ id, type: 'complete', success: false, error: errorMsg }); + } +}; From ca5a0cf2b27bf85e51862af4f29830bd71adfc46 Mon Sep 17 00:00:00 2001 From: Aliet Exposito Garcia Date: Tue, 18 Aug 2026 19:00:59 -0400 Subject: [PATCH 2/7] feat(web version): display application version in the web frontend The web interface now displays the current application version sourced directly from the crate metadata in `Cargo.toml`. A new WebAssembly export retrieves the package version at compile time and supplies it to the background worker. The main interface queries this value on startup to populate the title badge and footer details dynamically. --- crates/ppf-wasm/src/lib.rs | 5 +++++ web/app.js | 20 ++++++++++++++++++-- web/index.html | 9 ++++++--- web/styles.css | 18 ++++++++++++++++++ web/worker.js | 5 ++++- 5 files changed, 51 insertions(+), 6 deletions(-) diff --git a/crates/ppf-wasm/src/lib.rs b/crates/ppf-wasm/src/lib.rs index 9531e6f..a46c918 100644 --- a/crates/ppf-wasm/src/lib.rs +++ b/crates/ppf-wasm/src/lib.rs @@ -30,6 +30,11 @@ pub struct WasmCreatorOptions { pub file_id: Option, } +#[wasm_bindgen] +pub fn get_app_version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} + /// Inspects a PPF patch from an in-memory byte slice and returns patch metadata. #[wasm_bindgen] pub fn inspect_patch(patch_bytes: &[u8]) -> Result { diff --git a/web/app.js b/web/app.js index 892eef9..3f280ee 100644 --- a/web/app.js +++ b/web/app.js @@ -6,7 +6,7 @@ let nextMessageId = 1; const pendingRequests = new Map(); worker.onmessage = (event) => { - const { id, type, success, info, binBytes, patchBytes, error, percent, message } = event.data; + const { id, type, success, info, binBytes, patchBytes, version, error, percent, message } = event.data; const req = pendingRequests.get(id); if (!req) return; @@ -17,7 +17,7 @@ worker.onmessage = (event) => { } else if (type === 'complete') { pendingRequests.delete(id); if (success) { - req.resolve({ info, binBytes, patchBytes }); + req.resolve({ info, binBytes, patchBytes, version }); } else { req.reject(new Error(error || 'Operation failed')); } @@ -741,3 +741,19 @@ btnCreatePatch.addEventListener('click', async () => { updateCreateButtonState(); } }); + +sendWorkerMessage('get_version') + .then((res) => { + if (res?.version) { + const footerInfo = document.getElementById('app-footer-info'); + if (footerInfo) { + footerInfo.textContent = `PPF Rust Patcher v${res.version}`; + } + const versionBadge = document.getElementById('app-version-badge'); + if (versionBadge) { + versionBadge.textContent = `v${res.version}`; + versionBadge.classList.remove('hidden'); + } + } + }) + .catch(() => {}); diff --git a/web/index.html b/web/index.html index e43d2b2..098d894 100644 --- a/web/index.html +++ b/web/index.html @@ -18,7 +18,10 @@ RUST
-

PPF Rust Patcher

+
+

PPF Rust Patcher

+ +
WebAssembly Edition
@@ -56,7 +59,7 @@

PPF Rust Patcher

Target Image & Patch Selection

- +
diff --git a/web/styles.css b/web/styles.css index 68ed0c8..0bfc4c7 100644 --- a/web/styles.css +++ b/web/styles.css @@ -102,12 +102,30 @@ body { opacity: 0.85; } +.title-with-badge { + display: flex; + align-items: center; + gap: 8px; +} + .title-group h1 { font-size: 18px; font-weight: 700; letter-spacing: -0.3px; } +.version-badge { + display: inline-block; + font-size: 11px; + font-weight: 700; + font-family: var(--font-mono); + color: #93c5fd; + background: rgba(59, 130, 246, 0.2); + padding: 1px 6px; + border-radius: var(--radius-sm); + border: 1px solid rgba(59, 130, 246, 0.35); +} + .edition-badge { display: inline-block; font-size: 11px; diff --git a/web/worker.js b/web/worker.js index 9bfec0c..673b50a 100644 --- a/web/worker.js +++ b/web/worker.js @@ -1,4 +1,4 @@ -import init, { inspect_patch, apply_patch, undo_patch, create_patch } from './pkg/ppf_wasm.js'; +import init, { inspect_patch, apply_patch, undo_patch, create_patch, get_app_version } from './pkg/ppf_wasm.js'; let wasmReady = false; let wasmInitPromise = null; @@ -107,6 +107,9 @@ self.onmessage = async (event) => { }, [patchUint8.buffer] ); + } else if (type === 'get_version') { + const version = get_app_version(); + self.postMessage({ id, type: 'complete', success: true, version }); } else { throw new Error(`Unknown action type: ${type}`); } From 3f187adad389b76a39588e67fe9e4ce477f3bcac Mon Sep 17 00:00:00 2001 From: Aliet Exposito Garcia Date: Tue, 18 Aug 2026 22:37:56 -0400 Subject: [PATCH 3/7] feat(egui app): add application logo and window icon to desktop app Integrate visual branding into the graphical interface by adding an application logo asset and configuring the native window icon. --- Cargo.lock | 1 + Cargo.toml | 1 + crates/ppf-gui/Cargo.toml | 1 + crates/ppf-gui/assets/app_logo.png | Bin 0 -> 4916 bytes crates/ppf-gui/src/app.rs | 21 +++++++++++++++++++-- crates/ppf-gui/src/main.rs | 27 ++++++++++++++++++++++----- crates/ppf-gui/src/views/help.rs | 11 ++++++++--- 7 files changed, 52 insertions(+), 10 deletions(-) create mode 100644 crates/ppf-gui/assets/app_logo.png diff --git a/Cargo.lock b/Cargo.lock index 21c7687..cf5c6af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2312,6 +2312,7 @@ version = "0.3.0" dependencies = [ "eframe", "egui", + "image", "ppf-core", "rfd", ] diff --git a/Cargo.toml b/Cargo.toml index 43515fe..5c55eff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ eframe = { version = "0.29.1", default-features = false, features = ["default_fo egui = "0.29.1" rfd = "0.15.2" tempfile = "3.27.0" +image = { version = "0.25.10", default-features = false, features = ["png"] } [profile.release] opt-level = 3 diff --git a/crates/ppf-gui/Cargo.toml b/crates/ppf-gui/Cargo.toml index b0479e4..95619ed 100644 --- a/crates/ppf-gui/Cargo.toml +++ b/crates/ppf-gui/Cargo.toml @@ -14,3 +14,4 @@ ppf-core.workspace = true eframe.workspace = true egui.workspace = true rfd.workspace = true +image.workspace = true diff --git a/crates/ppf-gui/assets/app_logo.png b/crates/ppf-gui/assets/app_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..e538106786fcc841bac0cc37c8149989f5aa43b2 GIT binary patch literal 4916 zcmV-46U*$0P)z@;j|==^1poj5AY({UO#lFTCIA3{ga82g0001h=l}q9FaQARU;qF* zm;eA5aGbhPJOBUy32;bRa{vGa`v3q2`vC??wIIoj+X>uPyexI_+T=`=SAJ6)skA7iLCE5N-lUb!r2(k}HEo8r zNRq(h)cZ<{u70o1Ps(!NmT8sMT~i0-4OUqob+YOtwdWCmcC<-KJnJb9&NTT_Tc!53 zdu7?;v<;BKjVsMVBC!GkMN~9Db%LdptTZWa5rsZU^&0-LFmRfQ% z(ZBkW7IHGs~@g-WG)ok<2e`%qiPH2m%q4ejtViCbl|E`fE4~`nW&zHIUF4p#+ zU;p*PdwS{GmGgF>p(ezlIbkk5Dao?=5p5AJ`9=db|L#ClfPDDH5q|UHC_=!;PpqP4 z@mUm;k9dNkw$zg>cl>aS_K}rQ=r4EjE z=`5)1NxgaxIPC3nJoDKr95(IB@$6gUJaqYVjym@WR1n5S>exl_RE7?|NIF3L;B(!u`r_fdSeTg@VridHcvvatpLpo z3@->iC2RSpJ4Y_BTTNSYC_qvsby*T7#(U^_=Vm%LZ_{pTZ3;~ZqqH#!sD^FoB3ORv zY94El_>@-Mk8>eQXiUq{dl1kBIO#qHI)6rbaJRlgmTyv(%)AmwQ-0U1z4+Oh3X$+r z&C-1fW`&7{^@sMQ^19oa`~VadF4jcT(hFIB#7gb={Rf<>9Vy|X%D#X}N<9GcK+tDL z)m?`ycNDS&m!8w0-IvZvcI=&)WWdrFGZd~!-5h>&_5IA8p%byrPbR8vP)G=p2}@(3 zep+1mdo&|)rB0pgN;*L4s)<`96T&myx_ANTE;^--;||ps-m~wFGnSPaM-TY;aACjz zjg5^oHz!$naFo?cM!EQm)_{w9qbuVL%+%hZSvGInF?~vKd)oB_^b9yiQ68f`83_L8 zl~ZfHf5_oye;5u1GQMlw>2<6-y{_=#-jwo7vv!{=;olw`MoOWXtoRaK$cuCpA;vC! zP=$uid!#FuN>B*fU>fWJW7c}Mc6_tV_` z$PoR*`7b>{fDgCq};f(A1HDFUcf#3D{nXf$=X}J5phxlMe)<`q`{6LkdR82s=UU)yv1J4v<@~(%z zLqo^8%v^mJ6C;D{pIAh4?s9GCJv=Ep^or&k0AGdn3UrmI^tU}#LBXV``WH==$h_Sb z6$V=l84GSO+0LAk9tTrMy2%lAym|!)(tqsCJnFF7F6n8Q#dX-ipj-dk9ftufA^c^hH@LqbGdNEHAzYmh}j zL`BAGJlo{sO$m#tDw8mR0^@xjvhS745q6A)-})1&u7}C=cT&ILOk&dxW&dAqDRi>p zUj0!w&)%0W$Bs48cI-8Q+(L=t_$Bw!_m(;_leuaS1!q_>a-3>7T54xvWH;Ht?PLZv z0Wh}rRZ?BQB|XrEJ2674>tRw`ej9w&bx3V~n8EE&md)JKlOX=h^>jhc3yA6_ud2o- zU++&;OeJ==gjEiX5JVdnk(lvSgk2Cr>*k$Cq`sZR^yP%2^|UPiAwpOI%n+7M%UAyq zJ63PRBV)cm0dZR+4pbII{BgFrihg&g>TgxsR0a}nIh^_DJm;O|hK_G)N3QjlpBUY$ zuxw@?cWp(`F6LkmmfmOSZ`(illdtH(b;h6=KlcshaVKPDQx`tA<->`q9vUa6Xa+;$ zE^f{(Cr+IlPIjn*&o4lRRG?$S|Bi6l_LO%d9Y^A1odEmuT%8?9grWh9_BQn3i}pwl zBoL(czr&vAzx~A{qyJ}@9c$u{?`=j{w%HAOD^lFKXwwp=edV9PotPZ(RFj`XPz{*A zfW8kNAllSHa_-61{3U+BXwYYLSzLjnaNR5{D_l_|vHd6?iqzA*{IbdkP*FL_P?aDV zu%`dRpA&CBj2TB?Gc^}0bcsv-T|3)`iTXFF^t`2MX#_K|s+Kkt!NDICnS@lI9}DF5 z0}Ch>uOrQwYAxmE6L?Ijc0ds--Q*8?Oxk+SYnPEpZK36eE5Oau_rYDnTMnab)vfG# z{%>*8{mlIO?c_84^uBuwv8E-=JpL}WJ#r*YCdG`SuVLDjAuR7F>nN4kFzgnf^{fky?ZhWK&z180*2!kKtwqN%wUk ztU|IRpYBIGS)6o#bw!sTz11cq4bZjzhgCyHetyu8j=LeN=$baU& z3Ho^p!GbAuk?cSbls{;Y!F*c0<#6rZa80ZCOrq^5uVX7xN4RE2c`}w&{KwXIQNs*V zp_SA##ASh0XbWM~D11tg|NDRi1X!U6%1TCth^S6MB@A>vL}p+M(WXU2>gQE;nO4>% zBn3{yLWat1sFJoZ#t>BkIX`HsAb4u)ldK(CPyJ{6z17O*!t5Ad%&)AQ=xu4kiG?b9 zfWJTJR*w7V8TRa*#T_-jVQfx;)30uPhO>u$PHgu8--#y~U2rDhM5{6h>GhWkrMNiq zuN>F=Jn?*%u}qYW2M*&GiS>-NCvig6rhMr{G5eFk} zpBIo)(iY#t{K#&iWXk3#p`AXC8ab8gxBr68^N+CtZH zHSEM0g!%LT!r-AvWe3W;QWF~Drj~PAxP3cFiLfD@m|%6{P1*;J=c=u*FubI$eAcZ} zZc;MC-o-)joy2M;JnoN>6b2%_kI|uwp5{Xo(&H++W^*C7_08cO>nJ`+&L`UxC0Uc< z%GsAvvuD(JL{vO3q^cEvOF1CfxbbUrtv{I2Jud}z>CC3}9Mty-xUeHNo5$np=$yBL zOml>}P5U_^`zxZu`9cg|%$N!x5SCznzMZbf;cRJKN>{^DM%!W_1tYmQJ0tV>sObmkoRuktp+)Xu-xK2N$fQoyBTA+w}Xc-w!bqy!->56GVV?0nAX zc$9=pb7j)%cW3Q9$T}mj{L?~9Td7cCLR-EW3EU4W|tT#h$S_D@}-WxlG_c#S2 zJ`tu;IPN7<3X^fAq>`(jQmycj4I7B{7J%_%xe_;Sn-d}>^RXzaJzimSVLaWtpJMNZ z%gGFMmAfnY_|CCaWSYawt{>o@x}&-Dql;O*XCu-63Gbdj-LFqs*1mTHt}IxANj49t z!X>bZM{`^extOv323ivREF0d2ouA;qNDc2NPQbN=m*WpmODVu9Cfrn&Oh~|@F4=-; zT815;7NEc{9Jq+1XFo?ratqB2aZ=*XoGAayzQJbh+xHOL7OWswYiWMnU+|y>Tb9Hs zZHy`4LkWjf97sfS9JjEu3_*b_YXdv4*8X}&P|M|sBCbG3$Nw<2EEx=R z5RAD=Zs~cL3)^nsxa12Y_GhuOF0<^($NCBKq6RB6humm zxP_lw>9Tob1wHmbxX}=68y_Uvmo=xb@YVZBiF6(19@lGs@#1Tlf7TPkTUTi6Z8Vgn z!`jI5{#Qu$3}d+^T_D6;<7>&)g{Tc@2xlks5_o^aXov$WVO4$s1c8N$utIv8y~N^` z)EctWqO{NGqjjVQG2sSte#He$I|*D%5Rc{v=gP8lW2g^1?UI|YNi?pdZqC;UCz=(y zEorcNxo*aHxJh2X2|d3fG(6!gY{D8Qly;HQLWUzA@R}y9aT+O$E>0TXY$+WdODiGu zWqvmdoJqPNN+=}xcJx|eshoC}JQTJB-PsQEwISLX4zObEk0ko?9OQ0g_4qS{hbI^u zufa)1jPC%YoS6)U8*sydwT<^Ow|hI$VTb9?F3uXipZNY9sZ1@IqzL32OmB#k`H;YM zgKwdzSYT^xP3me;e{L3=aw|Ee{$-Z8Z6em4B|RewLLj7`ZrU(-HmA>igjipW%NATo zI`#!n_a7$H8IMHDCd(&)Kl zmc#<#tzZ5sD?#A;lA?a~NC+($D&FMkuh48rttB%pOhfG$$MPi65!d({%iiol{$t=; z4$QB|jS3Qx9Kw=FEZ!Vg$&2k5;YO`M`m7`iK0SB|FDA}obZ!hnkO&thFM)SP59f)F z8_374V1Cc1@{~MUk&_j>EBk)`(GiDyZ=VEcPi$wFwVljF9UqQ=4Y!!hiC9^VsCx}N zJI>yT#q7#0g0NLlB5w|V%8|7jXiIM;GalzH=WKRGm*{$9@!5^{&!S=B8s?t zdtniCZr&twtB71$fcDcLob+W#^;NlwFHlY4g-`$eR-nYhP+#lU?M(wwX{q*li$PK= z_hXfho|J(^t0DbkTv(5X_&tIMD#w2P!wwWJO|pa4cBfjYjK5?J0W((qh{TNLfwHB$~BdVCDQjp|FYJU}1px#L-YO44OjI5qM(^7DLTtftO1WSwUW#zX*H5@QB z9!NY?dI?%>M+6I*CQGtb`_3QiREicg`P~l`ew9ZXpi) String { path.trim().trim_matches(&['\'', '"'][..]).to_string() } +fn load_app_logo_texture(ctx: &egui::Context) -> Option { + let icon_bytes = include_bytes!("../assets/app_logo.png"); + let image = image::load_from_memory(icon_bytes).ok()?.into_rgba8(); + let (width, height) = image.dimensions(); + let color_image = egui::ColorImage::from_rgba_unmultiplied( + [width as usize, height as usize], + image.as_flat_samples().as_slice(), + ); + Some(ctx.load_texture("app_logo", color_image, egui::TextureOptions::LINEAR)) +} + pub struct PpfApp { pub active_tab: Tab, pub tx_event: Sender, pub rx_event: Receiver, + pub logo_texture: Option, // Busy & Progress state pub is_busy: bool, @@ -57,13 +69,15 @@ pub struct PpfApp { } impl PpfApp { - pub fn new(_cc: &eframe::CreationContext<'_>) -> Self { + pub fn new(cc: &eframe::CreationContext<'_>) -> Self { let (tx_event, rx_event) = channel(); + let logo_texture = load_app_logo_texture(&cc.egui_ctx); Self { active_tab: Tab::ApplyUndo, tx_event, rx_event, + logo_texture, is_busy: false, busy_operation: String::new(), @@ -341,13 +355,16 @@ impl eframe::App for PpfApp { // Help dialog window if self.show_help { - views::help::show(&mut self.show_help, ctx); + views::help::show(&mut self.show_help, ctx, self.logo_texture.as_ref()); } // Top Header and Tab Bar egui::TopBottomPanel::top("top_navigation_panel").show(ctx, |ui| { ui.add_space(6.0); ui.horizontal(|ui| { + if let Some(texture) = &self.logo_texture { + ui.image((texture.id(), egui::vec2(22.0, 22.0))); + } ui.heading( RichText::new("PPF Rust Patcher") .strong() diff --git a/crates/ppf-gui/src/main.rs b/crates/ppf-gui/src/main.rs index 6c453d5..0b6db6b 100644 --- a/crates/ppf-gui/src/main.rs +++ b/crates/ppf-gui/src/main.rs @@ -5,13 +5,30 @@ mod worker; use app::PpfApp; use eframe::egui; +fn load_app_icon() -> Option { + let icon_bytes = include_bytes!("../assets/app_logo.png"); + let image = image::load_from_memory(icon_bytes).ok()?.into_rgba8(); + let (width, height) = image.dimensions(); + Some(egui::IconData { + rgba: image.into_raw(), + width, + height, + }) +} + fn main() -> eframe::Result<()> { + let mut viewport = egui::ViewportBuilder::default() + .with_inner_size([740.0, 580.0]) + .with_min_inner_size([640.0, 520.0]) + .with_title("PPF Rust Patcher") + .with_drag_and_drop(true); + + if let Some(icon) = load_app_icon() { + viewport = viewport.with_icon(icon); + } + let native_options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_inner_size([740.0, 580.0]) - .with_min_inner_size([640.0, 520.0]) - .with_title("PPF Rust Patcher") - .with_drag_and_drop(true), + viewport, ..Default::default() }; diff --git a/crates/ppf-gui/src/views/help.rs b/crates/ppf-gui/src/views/help.rs index 1b8fe67..dbc33f8 100644 --- a/crates/ppf-gui/src/views/help.rs +++ b/crates/ppf-gui/src/views/help.rs @@ -1,6 +1,6 @@ -use egui::{Color32, RichText, Window}; +use egui::{Color32, RichText, TextureHandle, Window}; -pub fn show(open: &mut bool, ctx: &egui::Context) { +pub fn show(open: &mut bool, ctx: &egui::Context, logo: Option<&TextureHandle>) { let mut is_open = *open; let mut close_requested = false; @@ -12,7 +12,12 @@ pub fn show(open: &mut bool, ctx: &egui::Context) { .show(ctx, |ui| { egui::ScrollArea::vertical().show(ui, |ui| { ui.add_space(4.0); - ui.heading("Welcome to PPF Rust Patcher!"); + ui.horizontal(|ui| { + if let Some(texture) = logo { + ui.image((texture.id(), egui::vec2(64.0, 64.0))); + } + ui.heading("Welcome to PPF Rust Patcher!"); + }); ui.label( RichText::new( "This tool allows you to easily apply, reverse, and create PPF (PlayStation Patch Format) patches for disc images (CD/DVD) and ROM files without needing complex command-line tools.", From d5c0ce6bcb1d67def850314564475f063ffdf4bb Mon Sep 17 00:00:00 2001 From: Aliet Exposito Garcia Date: Wed, 19 Aug 2026 23:06:00 -0400 Subject: [PATCH 4/7] refactor: deduplicate patch and progress logic across crates Duplicated routines across the CLI, GUI, WASM, and core modules introduced unnecessary maintenance overhead and triggered warnings in `cargo dupes` analysis. This refactoring consolidates repetitive progress reporting, UI file pickers, and symmetrical patch operations into shared helpers and declarative macros. Centralizing these routines ensures consistent behavior across all frontends while significantly simplifying the codebase. Standardizing error conversions and unified execution paths also makes future extensions much easier to maintain and less error prone. --- crates/ppf-cli/src/main.rs | 93 +++++++++-------- crates/ppf-core/src/applier.rs | 158 +++++++++++++++-------------- crates/ppf-core/src/lib.rs | 6 +- crates/ppf-core/src/parser.rs | 73 ++++++------- crates/ppf-gui/src/views/create.rs | 127 ++++++++++++++--------- crates/ppf-gui/src/worker.rs | 113 +++++++++------------ crates/ppf-wasm/src/lib.rs | 111 ++++++++------------ 7 files changed, 341 insertions(+), 340 deletions(-) diff --git a/crates/ppf-cli/src/main.rs b/crates/ppf-cli/src/main.rs index 0107a77..23cb00f 100644 --- a/crates/ppf-cli/src/main.rs +++ b/crates/ppf-cli/src/main.rs @@ -77,6 +77,22 @@ fn setup_record_progress(pb: &ProgressBar, total: usize) { ); } +fn setup_byte_progress(pb: &ProgressBar, total: u64) -> anyhow::Result<()> { + pb.set_length(total); + pb.set_style( + ProgressStyle::default_bar() + .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")? + .progress_chars("#>-"), + ); + Ok(()) +} + +fn make_progress_inc_callback(pb: &ProgressBar) -> impl Fn(usize) + '_ { + move |count: usize| { + pb.inc(count as u64); + } +} + fn print_patch_info(info: &PatchInfo, action: &str, patch_path: &str, bin_path: &str) { let version_str = match info.version { PpfVersion::V1 => "1.0", @@ -127,49 +143,42 @@ fn print_patch_info(info: &PatchInfo, action: &str, patch_path: &str, bin_path: } } +fn run_patch_command(patch: &str, bin: &str, is_undo: bool) -> anyhow::Result<()> { + let pb = ProgressBar::new(0); + let on_start_cb = |total: usize| { + setup_record_progress(&pb, total); + }; + let progress_cb = make_progress_inc_callback(&pb); + + let on_start = Some(&on_start_cb as &dyn Fn(usize)); + let on_progress = Some(&progress_cb as &dyn Fn(usize)); + + let info = if is_undo { + undo_patch(patch, bin, on_start, on_progress)? + } else { + apply_patch(patch, bin, on_start, on_progress)? + }; + + pb.finish_and_clear(); + let (action, success_msg) = if is_undo { + ("Undoing", "Patch undone successfully.") + } else { + ("Applying", "Patch applied successfully.") + }; + print_patch_info(&info, action, patch, bin); + println!("{}", success_msg); + Ok(()) +} + fn main() -> anyhow::Result<()> { let cli = Cli::parse(); match cli.command { Commands::Apply { bin, patch } => { - let pb = ProgressBar::new(0); - let on_start_cb = |total: usize| { - setup_record_progress(&pb, total); - }; - let progress_cb = |records: usize| { - pb.inc(records as u64); - }; - - let info = apply_patch( - patch.as_str(), - bin.as_str(), - Some(&on_start_cb), - Some(&progress_cb), - )?; - - pb.finish_and_clear(); - print_patch_info(&info, "Applying", &patch, &bin); - println!("Patch applied successfully."); + run_patch_command(&patch, &bin, false)?; } Commands::Undo { bin, patch } => { - let pb = ProgressBar::new(0); - let on_start_cb = |total: usize| { - setup_record_progress(&pb, total); - }; - let progress_cb = |records: usize| { - pb.inc(records as u64); - }; - - let info = undo_patch( - patch.as_str(), - bin.as_str(), - Some(&on_start_cb), - Some(&progress_cb), - )?; - - pb.finish_and_clear(); - print_patch_info(&info, "Undoing", &patch, &bin); - println!("Patch undone successfully."); + run_patch_command(&patch, &bin, true)?; } Commands::Info { patch } => { let info = inspect_patch(patch.as_str())?; @@ -211,15 +220,9 @@ fn main() -> anyhow::Result<()> { println!("Finding differences... "); let pb = ProgressBar::new(orig_len); - pb.set_style( - ProgressStyle::default_bar() - .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")? - .progress_chars("#>-"), - ); - - let progress_cb = |bytes: usize| { - pb.inc(bytes as u64); - }; + setup_byte_progress(&pb, orig_len)?; + + let progress_cb = make_progress_inc_callback(&pb); let entries_found = create_patch( original.as_str(), diff --git a/crates/ppf-core/src/applier.rs b/crates/ppf-core/src/applier.rs index e8b5fac..0b8cf53 100644 --- a/crates/ppf-core/src/applier.rs +++ b/crates/ppf-core/src/applier.rs @@ -12,6 +12,13 @@ use std::path::Path; /// Alias for header metadata returned when inspecting or applying a patch. pub type PatchInfo = PpfHeader; +/// Operation mode when modifying binary data with a patch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PatchAction { + Apply, + Undo, +} + /// Inspects a PPF patch from an in-memory slice and returns its header metadata. #[inline] pub fn inspect_patch_slice(patch_data: &[u8]) -> Result { @@ -23,52 +30,19 @@ pub fn inspect_patch_slice(patch_data: &[u8]) -> Result { #[cfg(feature = "mmap")] #[inline] pub fn inspect_patch(patch_path: impl AsRef) -> Result { - let ppf = PpfFile::open(patch_path)?; - Ok(ppf.header) -} - -/// Applies a PPF patch (v1.0, v2.0, or v3.0) to an in-memory mutable binary slice. -/// -/// # Arguments -/// * `patch_data` - Byte slice containing the PPF patch. -/// * `target_data` - Target binary slice to be modified in-place. -/// * `on_start` - Optional callback invoked with the total number of records before modification. -/// * `progress_callback` - Optional callback invoked with the number of records applied (e.g. 1 per record). -pub fn apply_patch_slice( - patch_data: &[u8], - target_data: &mut [u8], - on_start: Option<&dyn Fn(usize)>, - progress_callback: Option<&dyn Fn(usize)>, -) -> Result { - process_patch_slice(patch_data, target_data, false, on_start, progress_callback) + PpfFile::open(patch_path).map(|file| file.header) } -/// Reverses/undoes a PPF3 patch on an in-memory mutable binary slice. -/// -/// Requires the patch to contain undo data (`has_undo == true`). -/// -/// # Arguments -/// * `patch_data` - Byte slice containing the PPF patch. -/// * `target_data` - Target binary slice to be restored in-place. -/// * `on_start` - Optional callback invoked with the total number of records before modification. -/// * `progress_callback` - Optional callback invoked with the number of records restored (e.g. 1 per record). -pub fn undo_patch_slice( +/// Applies or reverts a PPF patch on an in-memory mutable binary slice. +pub fn patch_slice( patch_data: &[u8], target_data: &mut [u8], - on_start: Option<&dyn Fn(usize)>, - progress_callback: Option<&dyn Fn(usize)>, -) -> Result { - process_patch_slice(patch_data, target_data, true, on_start, progress_callback) -} - -fn process_patch_slice( - patch_data: &[u8], - target_data: &mut [u8], - is_undo: bool, + action: PatchAction, on_start: Option<&dyn Fn(usize)>, progress_callback: Option<&dyn Fn(usize)>, ) -> Result { let view = PpfView::parse(patch_data)?; + let is_undo = action == PatchAction::Undo; if is_undo && !view.header.has_undo { return Err(PpfError::UndoNotAvailable); @@ -138,49 +112,68 @@ fn process_patch_slice( Ok(view.header) } -/// Applies a PPF patch (v1.0, v2.0, or v3.0) to a target binary file. -/// -/// # Arguments -/// * `patch_path` - Path to the PPF patch file. -/// * `bin_path` - Path to the target binary file to be modified in-place. -/// * `on_start` - Optional callback invoked with the total number of records before modification. -/// * `progress_callback` - Optional callback invoked with the number of records applied (e.g. 1 per record). -#[cfg(feature = "mmap")] -#[inline] -pub fn apply_patch( - patch_path: impl AsRef, - bin_path: impl AsRef, - on_start: Option<&dyn Fn(usize)>, - progress_callback: Option<&dyn Fn(usize)>, -) -> Result { - process_patch_file(patch_path, bin_path, false, on_start, progress_callback) -} +macro_rules! def_patch_pair { + ( + $apply_name:ident, + $undo_name:ident, + $target_fn:ident, + $patch_param:ident: $patch_ty:ty, + $target_param:ident: $target_ty:ty, + $apply_doc:expr, + $undo_doc:expr + ) => { + #[doc = $apply_doc] + #[inline] + pub fn $apply_name( + $patch_param: $patch_ty, + $target_param: $target_ty, + on_start: Option<&dyn Fn(usize)>, + progress: Option<&dyn Fn(usize)>, + ) -> Result { + $target_fn( + $patch_param, + $target_param, + PatchAction::Apply, + on_start, + progress, + ) + } -/// Reverses/undoes a PPF3 patch from a previously patched binary file. -/// -/// Requires the patch to contain undo data (`has_undo == true`). -/// -/// # Arguments -/// * `patch_path` - Path to the PPF patch file. -/// * `bin_path` - Path to the binary file to be restored in-place. -/// * `on_start` - Optional callback invoked with the total number of records before modification. -/// * `progress_callback` - Optional callback invoked with the number of records restored (e.g. 1 per record). -#[cfg(feature = "mmap")] -#[inline] -pub fn undo_patch( - patch_path: impl AsRef, - bin_path: impl AsRef, - on_start: Option<&dyn Fn(usize)>, - progress_callback: Option<&dyn Fn(usize)>, -) -> Result { - process_patch_file(patch_path, bin_path, true, on_start, progress_callback) + #[doc = $undo_doc] + #[inline] + pub fn $undo_name( + $patch_param: $patch_ty, + $target_param: $target_ty, + on_start: Option<&dyn Fn(usize)>, + progress: Option<&dyn Fn(usize)>, + ) -> Result { + $target_fn( + $patch_param, + $target_param, + PatchAction::Undo, + on_start, + progress, + ) + } + }; } +def_patch_pair!( + apply_patch_slice, + undo_patch_slice, + patch_slice, + patch: &[u8], + target: &mut [u8], + "Applies a PPF patch (v1.0, v2.0, or v3.0) to an in-memory mutable binary slice.", + "Reverses/undoes a PPF3 patch on an in-memory mutable binary slice." +); + +/// Applies or reverts a PPF patch on a target binary file on disk. #[cfg(feature = "mmap")] -fn process_patch_file( +pub fn patch_file( patch_path: impl AsRef, bin_path: impl AsRef, - is_undo: bool, + action: PatchAction, on_start: Option<&dyn Fn(usize)>, progress_callback: Option<&dyn Fn(usize)>, ) -> Result { @@ -191,10 +184,10 @@ fn process_patch_file( #[cfg(unix)] bin_mmap.advise(memmap2::Advice::Sequential)?; - let header = process_patch_slice( + let header = patch_slice( &ppf.mmap, &mut bin_mmap, - is_undo, + action, on_start, progress_callback, )?; @@ -202,3 +195,14 @@ fn process_patch_file( bin_mmap.flush()?; Ok(header) } + +#[cfg(feature = "mmap")] +def_patch_pair!( + apply_patch, + undo_patch, + patch_file, + patch: impl AsRef, + bin: impl AsRef, + "Applies a PPF patch (v1.0, v2.0, or v3.0) to a target binary file.", + "Reverses/undoes a PPF3 patch from a previously patched binary file." +); diff --git a/crates/ppf-core/src/lib.rs b/crates/ppf-core/src/lib.rs index df0cc78..51503b2 100644 --- a/crates/ppf-core/src/lib.rs +++ b/crates/ppf-core/src/lib.rs @@ -3,9 +3,11 @@ pub mod core; pub mod creator; pub mod parser; -pub use applier::{PatchInfo, apply_patch_slice, inspect_patch_slice, undo_patch_slice}; +pub use applier::{ + PatchAction, PatchInfo, apply_patch_slice, inspect_patch_slice, patch_slice, undo_patch_slice, +}; #[cfg(feature = "mmap")] -pub use applier::{apply_patch, inspect_patch, undo_patch}; +pub use applier::{apply_patch, inspect_patch, patch_file, undo_patch}; pub use core::{ImageType, PpfError, PpfHeader, PpfPatchRecord, PpfRecord, PpfVersion}; #[cfg(feature = "mmap")] diff --git a/crates/ppf-core/src/parser.rs b/crates/ppf-core/src/parser.rs index 9830b80..0421892 100644 --- a/crates/ppf-core/src/parser.rs +++ b/crates/ppf-core/src/parser.rs @@ -15,6 +15,24 @@ pub struct PpfView<'a> { data_end: usize, } +macro_rules! impl_record_accessors { + () => { + #[inline] + pub fn records(&self) -> PpfRecords<'_> { + PpfRecords::new( + self.payload_slice(), + self.header.version, + self.header.has_undo, + ) + } + + #[inline] + pub fn count_records(&self) -> Result { + self.records().count_records() + } + }; +} + impl<'a> PpfView<'a> { /// Parses a PPF patch from an in-memory byte slice. pub fn parse(bytes: &'a [u8]) -> Result { @@ -146,24 +164,7 @@ impl<'a> PpfView<'a> { &self.data[self.data_start..self.data_end] } - #[inline] - pub fn records(&self) -> PpfRecords<'a> { - PpfRecords { - slice: self.payload_slice(), - cursor: 0, - version: self.header.version, - has_undo: self.header.has_undo, - } - } - - pub fn count_records(&self) -> Result { - let mut count = 0; - for res in self.records() { - res?; - count += 1; - } - Ok(count) - } + impl_record_accessors!(); } /// Memory-mapped PPF file reader providing zero-copy access to records. @@ -201,23 +202,34 @@ impl PpfFile { #[inline] pub fn payload_slice(&self) -> &[u8] { - &self.mmap[self.data_start..self.data_end] + let range = self.data_start..self.data_end; + &self.mmap[range] } + impl_record_accessors!(); +} + +pub struct PpfRecords<'a> { + slice: &'a [u8], + cursor: usize, + version: PpfVersion, + has_undo: bool, +} + +impl<'a> PpfRecords<'a> { #[inline] - pub fn records(&self) -> PpfRecords<'_> { - PpfRecords { - slice: self.payload_slice(), + pub fn new(slice: &'a [u8], version: PpfVersion, has_undo: bool) -> Self { + Self { + slice, cursor: 0, - version: self.header.version, - has_undo: self.header.has_undo, + version, + has_undo, } } - #[inline] - pub fn count_records(&self) -> Result { + pub fn count_records(self) -> Result { let mut count = 0; - for res in self.records() { + for res in self { res?; count += 1; } @@ -225,13 +237,6 @@ impl PpfFile { } } -pub struct PpfRecords<'a> { - slice: &'a [u8], - cursor: usize, - version: PpfVersion, - has_undo: bool, -} - impl<'a> Iterator for PpfRecords<'a> { type Item = Result, PpfError>; diff --git a/crates/ppf-gui/src/views/create.rs b/crates/ppf-gui/src/views/create.rs index 7f2ca9d..f4d256d 100644 --- a/crates/ppf-gui/src/views/create.rs +++ b/crates/ppf-gui/src/views/create.rs @@ -5,6 +5,53 @@ use egui::{RichText, Ui}; use ppf_core::{ImageType, PpfCreatorOptions}; use std::path::PathBuf; +struct FilePickerConfig<'a> { + label: &'a str, + path: &'a mut String, + hint: &'a str, + input_tooltip: &'a str, + button_tooltip: &'a str, + target: FilePickTarget, + dialog_title: &'static str, +} + +fn render_disc_picker_row( + ui: &mut Ui, + config: FilePickerConfig<'_>, + is_busy: bool, + tx_event: &std::sync::mpsc::Sender, + status: &mut Option>, +) { + ui.label(RichText::new(config.label).strong()); + ui.horizontal(|ui| { + let edit = ui + .add( + egui::TextEdit::singleline(config.path) + .hint_text(config.hint) + .desired_width(ui.available_width() - 100.0), + ) + .on_hover_text(config.input_tooltip); + if edit.changed() { + *status = None; + } + + let browse_btn = ui + .add_enabled(!is_busy, egui::Button::new("Browse...")) + .on_hover_text(config.button_tooltip); + if browse_btn.clicked() { + pick_file_async( + config.target, + config.dialog_title, + vec![ + ("Disc Images", &["bin", "iso", "img", "cue", "raw"]), + ("All Files", &["*"]), + ], + tx_event.clone(), + ); + } + }); +} + pub fn show(app: &mut PpfApp, ui: &mut Ui) { ui.add_space(8.0); @@ -15,60 +62,40 @@ pub fn show(app: &mut PpfApp, ui: &mut Ui) { ui.add_space(4.0); // Original Unpatched Binary - ui.label(RichText::new("Original Binary File (Unmodified):").strong()); - ui.horizontal(|ui| { - let edit = ui.add( - egui::TextEdit::singleline(&mut app.create_original_path) - .hint_text("/path/to/original.bin or drag & drop file here") - .desired_width(ui.available_width() - 100.0), - ).on_hover_text("Path to the clean, unmodified original binary or disc image.\nYou can also drag & drop the file directly into the window."); - if edit.changed() { - app.create_status = None; - } - - let browse_orig = ui.add_enabled(!app.is_busy, egui::Button::new("Browse...")) - .on_hover_text("Open file chooser to select original unpatched file"); - if browse_orig.clicked() { - pick_file_async( - FilePickTarget::CreateOriginal, - "Select Original Unpatched Binary", - vec![ - ("Disc Images", &["bin", "iso", "img", "cue", "raw"]), - ("All Files", &["*"]), - ], - app.tx_event.clone(), - ); - } - }); + render_disc_picker_row( + ui, + FilePickerConfig { + label: "Original Binary File (Unmodified):", + path: &mut app.create_original_path, + hint: "/path/to/original.bin or drag & drop file here", + input_tooltip: "Path to the clean, unmodified original binary or disc image.\nYou can also drag & drop the file directly into the window.", + button_tooltip: "Open file chooser to select original unpatched file", + target: FilePickTarget::CreateOriginal, + dialog_title: "Select Original Unpatched Binary", + }, + app.is_busy, + &app.tx_event, + &mut app.create_status, + ); ui.add_space(6.0); // Modified Binary - ui.label(RichText::new("Modified Binary File (Patched/Translated):").strong()); - ui.horizontal(|ui| { - let edit = ui.add( - egui::TextEdit::singleline(&mut app.create_patched_path) - .hint_text("/path/to/modified.bin or drag & drop file here") - .desired_width(ui.available_width() - 100.0), - ).on_hover_text("Path to the modified, translated, or hacked binary file.\nMust be identical in byte size to the original file.\nYou can also drag & drop the file directly into the window."); - if edit.changed() { - app.create_status = None; - } - - let browse_mod = ui.add_enabled(!app.is_busy, egui::Button::new("Browse...")) - .on_hover_text("Open file chooser to select modified binary image"); - if browse_mod.clicked() { - pick_file_async( - FilePickTarget::CreatePatched, - "Select Modified Binary Image", - vec![ - ("Disc Images", &["bin", "iso", "img", "cue", "raw"]), - ("All Files", &["*"]), - ], - app.tx_event.clone(), - ); - } - }); + render_disc_picker_row( + ui, + FilePickerConfig { + label: "Modified Binary File (Patched/Translated):", + path: &mut app.create_patched_path, + hint: "/path/to/modified.bin or drag & drop file here", + input_tooltip: "Path to the modified, translated, or hacked binary file.\nMust be identical in byte size to the original file.\nYou can also drag & drop the file directly into the window.", + button_tooltip: "Open file chooser to select modified binary image", + target: FilePickTarget::CreatePatched, + dialog_title: "Select Modified Binary Image", + }, + app.is_busy, + &app.tx_event, + &mut app.create_status, + ); ui.add_space(6.0); diff --git a/crates/ppf-gui/src/worker.rs b/crates/ppf-gui/src/worker.rs index 669d5e5..5789e66 100644 --- a/crates/ppf-gui/src/worker.rs +++ b/crates/ppf-gui/src/worker.rs @@ -119,6 +119,53 @@ pub fn inspect_patch_async( }); } +fn create_record_progress_callbacks( + action: &'static str, + tx: Sender, +) -> ( + impl Fn(usize) + Send + Sync + 'static, + impl Fn(usize) + Send + Sync + 'static, +) { + let total_records = Arc::new(AtomicUsize::new(0)); + let current_records = Arc::new(AtomicUsize::new(0)); + + let tx_start = tx.clone(); + let total_start = Arc::clone(&total_records); + let on_start = move |total: usize| { + total_start.store(total, Ordering::SeqCst); + let _ = tx_start.send(WorkerEvent::Progress { + ratio: 0.0, + message: format!( + "Starting {}: 0 / {} records (0.0%)", + action.to_lowercase(), + total + ), + }); + }; + + let total_prog = Arc::clone(&total_records); + let current_prog = Arc::clone(¤t_records); + let on_progress = move |records: usize| { + let done = current_prog.fetch_add(records, Ordering::Relaxed) + records; + let total = total_prog.load(Ordering::Relaxed); + if total > 0 && (done == total || done.is_multiple_of(200)) { + let ratio = (done as f32 / total as f32).clamp(0.0, 1.0); + let _ = tx.send(WorkerEvent::Progress { + ratio, + message: format!( + "{}: {} / {} records ({:.1}%)", + action, + done, + total, + ratio * 100.0 + ), + }); + } + }; + + (on_start, on_progress) +} + pub fn apply_patch_async( patch_path: PathBuf, bin_path: PathBuf, @@ -157,38 +204,7 @@ pub fn apply_patch_async( dest }; - let total_records = Arc::new(AtomicUsize::new(0)); - let current_records = Arc::new(AtomicUsize::new(0)); - - let tx_start = tx.clone(); - let total_clone = Arc::clone(&total_records); - let on_start = move |total: usize| { - total_clone.store(total, Ordering::SeqCst); - let _ = tx_start.send(WorkerEvent::Progress { - ratio: 0.0, - message: format!("Starting: 0 / {} records (0.0%)", total), - }); - }; - - let tx_progress = tx.clone(); - let current_clone = Arc::clone(¤t_records); - let total_clone_for_progress = Arc::clone(&total_records); - let on_progress = move |records: usize| { - let done = current_clone.fetch_add(records, Ordering::Relaxed) + records; - let total = total_clone_for_progress.load(Ordering::Relaxed); - if total > 0 && (done == total || done.is_multiple_of(200)) { - let ratio = (done as f32 / total as f32).clamp(0.0, 1.0); - let _ = tx_progress.send(WorkerEvent::Progress { - ratio, - message: format!( - "Applying: {} / {} records ({:.1}%)", - done, - total, - ratio * 100.0 - ), - }); - } - }; + let (on_start, on_progress) = create_record_progress_callbacks("Applying", tx.clone()); let result = apply_patch( &patch_path, @@ -209,38 +225,7 @@ pub fn apply_patch_async( pub fn undo_patch_async(patch_path: PathBuf, bin_path: PathBuf, tx: Sender) { thread::spawn(move || { - let total_records = Arc::new(AtomicUsize::new(0)); - let current_records = Arc::new(AtomicUsize::new(0)); - - let tx_start = tx.clone(); - let total_clone = Arc::clone(&total_records); - let on_start = move |total: usize| { - total_clone.store(total, Ordering::SeqCst); - let _ = tx_start.send(WorkerEvent::Progress { - ratio: 0.0, - message: format!("Starting undo: 0 / {} records (0.0%)", total), - }); - }; - - let tx_progress = tx.clone(); - let current_clone = Arc::clone(¤t_records); - let total_clone_for_progress = Arc::clone(&total_records); - let on_progress = move |records: usize| { - let done = current_clone.fetch_add(records, Ordering::Relaxed) + records; - let total = total_clone_for_progress.load(Ordering::Relaxed); - if total > 0 && (done == total || done.is_multiple_of(200)) { - let ratio = (done as f32 / total as f32).clamp(0.0, 1.0); - let _ = tx_progress.send(WorkerEvent::Progress { - ratio, - message: format!( - "Undoing: {} / {} records ({:.1}%)", - done, - total, - ratio * 100.0 - ), - }); - } - }; + let (on_start, on_progress) = create_record_progress_callbacks("Undoing", tx.clone()); let result = undo_patch(&patch_path, &bin_path, Some(&on_start), Some(&on_progress)) .map_err(|err| err.to_string()); diff --git a/crates/ppf-wasm/src/lib.rs b/crates/ppf-wasm/src/lib.rs index a46c918..d531602 100644 --- a/crates/ppf-wasm/src/lib.rs +++ b/crates/ppf-wasm/src/lib.rs @@ -1,6 +1,6 @@ use ppf_core::{ - ImageType, PatchInfo, PpfCreatorOptions, apply_patch_slice, create_patch_slice, - inspect_patch_slice, undo_patch_slice, + ImageType, PatchAction, PatchInfo, PpfCreatorOptions, create_patch_slice, inspect_patch_slice, + patch_slice, }; use serde::Deserialize; use std::cell::Cell; @@ -20,6 +20,10 @@ impl SendSyncFn { } } +fn js_err(err: impl std::fmt::Display) -> JsValue { + JsValue::from(js_sys::Error::new(&err.to_string())) +} + #[derive(Debug, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct WasmCreatorOptions { @@ -38,18 +42,15 @@ pub fn get_app_version() -> String { /// Inspects a PPF patch from an in-memory byte slice and returns patch metadata. #[wasm_bindgen] pub fn inspect_patch(patch_bytes: &[u8]) -> Result { - let info = inspect_patch_slice(patch_bytes) - .map_err(|err| JsValue::from(js_sys::Error::new(&err.to_string())))?; - serde_wasm_bindgen::to_value(&info) - .map_err(|err| JsValue::from(js_sys::Error::new(&err.to_string()))) + let info = inspect_patch_slice(patch_bytes).map_err(js_err)?; + serde_wasm_bindgen::to_value(&info).map_err(js_err) } -/// Applies a PPF patch to a target binary byte slice in-place. -#[wasm_bindgen] -pub fn apply_patch( +fn process_patch_wasm( patch_bytes: &[u8], target_bytes: &mut [u8], progress_cb: Option, + action: PatchAction, ) -> Result { let total_records = Rc::new(Cell::new(0usize)); let done_records = Rc::new(Cell::new(0usize)); @@ -85,71 +86,46 @@ pub fn apply_patch( } }); - let info: PatchInfo = apply_patch_slice( + let on_start_dyn = on_start.as_ref().map(|f| f as &dyn Fn(usize)); + let on_progress_dyn = on_progress.as_ref().map(|f| f as &dyn Fn(usize)); + + let info: PatchInfo = patch_slice( patch_bytes, target_bytes, - on_start.as_ref().map(|f| f as &dyn Fn(usize)), - on_progress.as_ref().map(|f| f as &dyn Fn(usize)), + action, + on_start_dyn, + on_progress_dyn, ) - .map_err(|err| JsValue::from(js_sys::Error::new(&err.to_string())))?; + .map_err(js_err)?; - serde_wasm_bindgen::to_value(&info) - .map_err(|err| JsValue::from(js_sys::Error::new(&err.to_string()))) + serde_wasm_bindgen::to_value(&info).map_err(js_err) } -/// Reverses/undoes a PPF3 patch on a target binary byte slice in-place. -#[wasm_bindgen] -pub fn undo_patch( - patch_bytes: &[u8], - target_bytes: &mut [u8], - progress_cb: Option, -) -> Result { - let total_records = Rc::new(Cell::new(0usize)); - let done_records = Rc::new(Cell::new(0usize)); - - let total_start = total_records.clone(); - let on_start = progress_cb.as_ref().map(|cb| { - let cb = cb.clone(); - move |total: usize| { - total_start.set(total); - let _ = cb.call2( - &JsValue::NULL, - &JsValue::from(0usize), - &JsValue::from(total as f64), - ); - } - }); - - let total_progress = total_records.clone(); - let done_progress = done_records.clone(); - let on_progress = progress_cb.as_ref().map(|cb| { - let cb = cb.clone(); - move |count: usize| { - let done = done_progress.get() + count; - done_progress.set(done); - let total = total_progress.get(); - if total > 0 && (done == total || done.is_multiple_of(200)) { - let _ = cb.call2( - &JsValue::NULL, - &JsValue::from(done as f64), - &JsValue::from(total as f64), - ); - } +macro_rules! def_wasm_patch_fn { + ($name:ident, $action:ident, $doc:expr) => { + #[doc = $doc] + #[wasm_bindgen] + pub fn $name( + patch_bytes: &[u8], + target_bytes: &mut [u8], + progress_cb: Option, + ) -> Result { + process_patch_wasm(patch_bytes, target_bytes, progress_cb, PatchAction::$action) } - }); - - let info: PatchInfo = undo_patch_slice( - patch_bytes, - target_bytes, - on_start.as_ref().map(|f| f as &dyn Fn(usize)), - on_progress.as_ref().map(|f| f as &dyn Fn(usize)), - ) - .map_err(|err| JsValue::from(js_sys::Error::new(&err.to_string())))?; - - serde_wasm_bindgen::to_value(&info) - .map_err(|err| JsValue::from(js_sys::Error::new(&err.to_string()))) + }; } +def_wasm_patch_fn!( + apply_patch, + Apply, + "Applies a PPF patch to a target binary byte slice in-place." +); +def_wasm_patch_fn!( + undo_patch, + Undo, + "Reverses/undoes a PPF3 patch on a target binary byte slice in-place." +); + /// Creates a PPF3 patch comparing original and modified binary byte slices. #[wasm_bindgen] pub fn create_patch( @@ -161,8 +137,7 @@ pub fn create_patch( let wasm_opts: WasmCreatorOptions = if options.is_undefined() || options.is_null() { WasmCreatorOptions::default() } else { - serde_wasm_bindgen::from_value(options) - .map_err(|err| JsValue::from(js_sys::Error::new(&err.to_string())))? + serde_wasm_bindgen::from_value(options).map_err(js_err)? }; let img_type = match wasm_opts.image_type.as_deref() { @@ -202,7 +177,7 @@ pub fn create_patch( .as_ref() .map(|f| f as &(dyn Fn(usize) + Sync + Send)), ) - .map_err(|err| JsValue::from(js_sys::Error::new(&err.to_string())))?; + .map_err(js_err)?; Ok(js_sys::Uint8Array::from(&output[..])) } From 01792e57e205f5667ad7f0499a28da5ab54c5fcc Mon Sep 17 00:00:00 2001 From: Aliet Exposito Garcia Date: Thu, 20 Aug 2026 00:17:27 -0400 Subject: [PATCH 5/7] docs(README): document WebAssembly integration and web client features The documentation has been expanded to reflect the introduction of the WebAssembly package and the static web client hosted on GitHub Pages. This helps users understand how to run the patching tools directly in their browsers without installing local software while highlighting the performance trade offs compared to native desktop builds. The guides now include build instructions for generating WebAssembly artifacts and serving the web interface locally. In addition, the documentation details the feature flags available in `ppf-core` for modular builds, provides code examples for working with in memory buffers, and incorporates updated benchmark results covering random access patch operations --- README.md | 217 ++++++++++++++++++++++++++++++++++++++++++--------- README_ES.md | 193 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 356 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index ce31d8a..e27da25 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,14 @@ High-performance Rust suite for creating, applying, inspecting, and reverting patches in the **PlayStation Patch File (PPF v1.0, v2.0, and v3.0)** format. -The project is organized as a modular **Cargo Workspace** comprising a core engine library (`ppf-core`), a command-line interface tool (`ppf-cli`), and a desktop graphical user interface (`ppf-gui`) built with `egui`. +The project is organized as a modular **Cargo Workspace** comprising a core engine library (`ppf-core`), a command-line interface tool (`ppf-cli`), a desktop graphical user interface (`ppf-gui`) built with `egui`, a WebAssembly package (`ppf-wasm`), and a static web client (`web/`) in HTML5 and JavaScript. -Implements concurrency with Rayon and memory-mapped I/O (`memmap2`), delivering maximum processing throughput with strict memory safety. +For desktop tools (`ppf-cli` and `ppf-gui`), it implements concurrency with Rayon and memory-mapped I/O (`memmap2`), delivering maximum processing throughput with strict memory safety. ---
-

A few screenshots, click a thumbnail to open the full image.

+

Screenshots (click a thumbnail to view full size):

Create Patch @@ -27,33 +27,53 @@ Implements concurrency with Rayon and memory-mapped I/O (`memmap2`), delivering --- +## Online Web Version (GitHub Pages) + +You can access the web version of the application directly in your browser at: + +**[https://plinkr.github.io/ppf_rust](https://plinkr.github.io/ppf_rust)** + +The web version runs the `ppf-core` engine compiled to **WebAssembly (WASM)** and features a frontend built with **HTML5, CSS, and JavaScript**. Processing is 100% local in your browser using Web Workers, so no files or disc images are uploaded to the internet. + +> **Important performance note:** +> The WebAssembly version operates on in-memory buffers (`Uint8Array`) and, due to inherent browser environment constraints, **cannot use memory mapping (`memmap2`) or multi-core parallelism (`rayon`)**. +> +> As a result, its processing speed is considerably slower than the native versions and browser RAM consumption increases with large images (such as BIN/ISO files of 500 MB or more). **Using the web version is recommended as a last resort or for quick convenience when desktop binaries are unavailable. For optimal performance on large files, always use the CLI version (`ppf_cli`) or the GUI version (`ppf_gui`).** + +--- + ## Workspace Structure ```text ppf_rust/ ├── Cargo.toml # Root workspace configuration -└── crates/ - ├── ppf-core/ # PPF engine library (public API) - ├── ppf-cli/ # Command-line interface (`ppf_cli` binary) - └── ppf-gui/ # Graphical interface in egui (`ppf_gui` binary) +├── crates/ +│ ├── ppf-core/ # PPF engine library (modular public API with feature flags) +│ ├── ppf-cli/ # Command-line interface (`ppf_cli` binary) +│ ├── ppf-gui/ # Desktop graphical interface in egui (`ppf_gui` binary) +│ └── ppf-wasm/ # WebAssembly bindings (wasm-bindgen) +└── web/ # Static web client (HTML5, JavaScript, Web Workers, CSS) ``` -- **`ppf-core`**: Core engine decoupled from the UI. Provides parsing, block validation (Blockcheck), parallel diff generation, patch application, and patch undo with progress callbacks. -- **`ppf-cli`**: Terminal application (`ppf_cli`) powered by `clap` and `indicatif`, built for automation, scripting, and command-line workflows. +- **`ppf-core`**: Core engine decoupled from the UI. Provides parsing, integrity validation (Blockcheck), parallel diff generation, patch application, and patch undo with progress callbacks. Uses *feature flags* (`mmap`, `parallel`, `serde`) to enable native dependencies on desktop or compile lightweight builds for WebAssembly. +- **`ppf-cli`**: Terminal application (`ppf_cli`) powered by `clap` and `indicatif`, built for maximum speed, automation, and command-line scripting. - **`ppf-gui`**: Cross-platform desktop application (`ppf_gui`) built with `eframe` / `egui`, featuring Drag & Drop support, safe backup copy mode, and visual patch inspection. +- **`ppf-wasm`**: WebAssembly integration layer exporting engine functions (`apply_patch`, `undo_patch`, `create_patch`, `inspect_patch`) to JavaScript via `wasm-bindgen`. +- **`web`**: Static web frontend with tabbed UI, Drag & Drop support, and asynchronous background processing via Web Workers. --- ## Features - **PPF Standards Compatibility**: Full support for reading and applying **PPF 1.0**, **PPF 2.0**, and **PPF 3.0** versions. -- **Optimized PPF3 Creation**: Block-level parallel difference scanning using `rayon`, using all available CPU cores. +- **Optimized PPF3 Creation**: Block-level parallel difference scanning using `rayon` on desktop, utilizing all available CPU cores. - **Integrity Validation (Blockcheck)**: 1024-byte validation block verification for standard BIN images (offset `0x9320`) and GI/PrimoDVD format (offset `0x80A0`). - **Undo Data Support**: Generation and application of reversible patches to restore modified binaries bit-by-bit to their original state. - **FILE_ID.DIZ Metadata Support**: Insertion and extraction of extended descriptions under the Amiga/BBS standard (up to 3072 bytes). -- **Graphical User Interface (GUI) in egui**: Modern visual application featuring Drag & Drop, toggle between in-place patching and safe backup copies, metadata inspector, FILE_ID.DIZ viewer with clipboard copy, and an integrated help guide. +- **Graphical User Interface (GUI) in egui**: Modern visual application featuring Drag & Drop, toggle between direct (*in-place*) patching and safe copy (*safe copy*), metadata viewers, and help modals. - **Command-Line Interface (CLI)**: Structured subcommands (`apply`, `undo`, `info`, `create`) with interactive progress bars and ETA calculation. -- **Decoupled Engine**: Reusable library with typed error handling (`thiserror`) and progress callbacks for custom integrations. +- **Web Version (WebAssembly)**: Client-side web application with zero server dependencies, ready to use in the browser via GitHub Pages. +- **Decoupled and Conditional Engine**: `ppf-core` manages `memmap2` and `rayon` via *features*, maintaining maximum desktop performance without compromising WebAssembly portability. --- @@ -63,12 +83,13 @@ ppf_rust/ - **Rust**: Version 1.85 or later (Rust Edition 2024). - **Cargo**. +- *(Optional for WebAssembly)*: `wasm32-unknown-unknown` target and `wasm-bindgen-cli`. --- ### 1. Build the Entire Workspace -To compile all workspace crates (`ppf-core`, `ppf-cli`, and `ppf-gui`) in release mode: +To compile all workspace desktop crates (`ppf-core`, `ppf-cli`, and `ppf-gui`) in release mode: ```bash cargo build --release @@ -125,9 +146,44 @@ cargo build -p ppf-core --release --- -### 5. System-wide Installation +### 5. Build the WebAssembly Version (`ppf-wasm`) and Prepare the Web Client + +If you want to build the WebAssembly bindings and generate the packages needed to serve the web frontend locally: + +```bash +# 1. Install WebAssembly target +rustup target add wasm32-unknown-unknown + +# 2. Install wasm-bindgen-cli tool +cargo install wasm-bindgen-cli --version 0.2.100 --locked + +# 3. Build ppf-wasm crate for WebAssembly +cargo build --package ppf-wasm --target wasm32-unknown-unknown --release + +# 4. Generate JavaScript bindings in web/pkg directory +wasm-bindgen --target web --out-dir web/pkg --out-name ppf_wasm target/wasm32-unknown-unknown/release/ppf_wasm.wasm +``` + +To test the web client locally, serve the `web/` directory with any static HTTP server: + +```bash +# With Python 3: +python3 -m http.server 8080 -d web + +# Or with tools like `basic-http-server`: +basic-http-server web + +# Or `miniserve`: +miniserve --index index.html --interfaces 127.0.0.1 --port 8080 web/ +``` + +And open `http://localhost:8080` in your browser. + +--- + +### 6. System-wide Installation -You can install the executables directly to your `~/.cargo/bin` directory: +You can install the native executables directly to your `~/.cargo/bin` directory: ```bash # Install the CLI globally @@ -154,6 +210,30 @@ cargo run -p ppf-cli -- info --patch patch.ppf --- +## Web Version Usage + +The web version is available online at **[https://plinkr.github.io/ppf_rust](https://plinkr.github.io/ppf_rust)** and offers the same functional capabilities in your browser: + +1. **Apply / Undo Tab (Apply & Undo)**: + - Load the target binary image (`.bin`, `.iso`, `.img`, `.cue`, `.raw`) and the `.ppf` patch (via file picker or Drag & Drop). + - Live inspection of metadata and image validation. + - Applies or reverts the patch in memory and automatically downloads the resulting binary file. + +2. **Create Patch Tab**: + - Allows loading original and modified files. + - Configurable options: description, image type (BIN/GI), block validation (Blockcheck), undo data, and `FILE_ID.DIZ` file. + - Generates and downloads the `.ppf` patch processed asynchronously in a background Web Worker. + +3. **Inspect Tab (Info & DIZ)**: + - Displays full PPF header details and includes a text viewer for `FILE_ID.DIZ` descriptions with a copy-to-clipboard button. + +4. **Help Guide (Help & Guide)**: + - Step-by-step instructions on the PPF format and solutions to common issues. + +> *Performance reminder:* The web version is a practical alternative when desktop binaries cannot be installed. However, lacking `rayon` and `memmap2`, desktop applications (`ppf_cli` or `ppf_gui`) should be prioritized for heavy disc images. + +--- + ## Graphical User Interface (`ppf_gui`) Run the `ppf_gui` binary: @@ -164,29 +244,29 @@ Run the `ppf_gui` binary: ### GUI Features: -1. **Apply & Undo Tab**: +1. **Apply / Undo Tab (Apply & Undo)**: - - **File Selection**: Load the target disc image (`.bin`, `.iso`, `.img`, `.cue`, `.raw`) and the `.ppf` patch using the file choosers or by dragging and dropping them directly onto the window (**Drag & Drop**). + - **File Selection**: Load the target binary image (`.bin`, `.iso`, `.img`, `.cue`, `.raw`) and the `.ppf` patch using file pickers or by dragging and dropping files directly onto the window (**Drag & Drop**). - **Operation Mode**: - *Patch in-place*: Directly modifies the original binary file (fast, requires no extra disk space). - - *Safe Copy*: Automatically creates a modified copy (e.g. `game_patched.bin`), preserving the original file untouched. - - **Automatic Inspection**: Inspects the patch upon selection and displays its version (PPF1, PPF2, PPF3), description, Undo data availability, and Blockcheck status in real time. - - **Live Progress**: Progress bar with percentage display during operations. + - *Safe Copy*: Automatically creates a modified copy (e.g. `game_patched.bin`), keeping the original binary untouched. + - **Automatic Inspection**: Upon selecting a patch, it analyzes and reports in real time its version (PPF1, PPF2, PPF3), description, Undo data presence, and Blockcheck status. + - **Live Progress**: Progress bar with percentage during heavy operations. 2. **Create Patch Tab**: - - Select original unmodified binary and modified binary files. - - Configurable options: patch description (up to 50 characters), image type (standard BIN or GI), block validation (Blockcheck), reversible undo data generation, and optional `FILE_ID.DIZ` file. - - Non-blocking asynchronous background execution keeping the UI responsive. + - Allows selecting the original unmodified file and the modified file. + - Configurable options: patch description (up to 50 characters), image type (standard BIN or GI), block validation (Blockcheck), reversible undo data inclusion, and optional `FILE_ID.DIZ` file. + - Asynchronous background execution without blocking the graphical interface. -3. **Info & DIZ Tab**: +3. **Inspect Tab (Info & DIZ)**: - - Detailed inspection of PPF patch headers (version, image type, validation flags, undo availability). - - Integrated text viewer for `FILE_ID.DIZ` release metadata with one-click copy to clipboard. + - Detailed inspection of PPF patch headers (version, size, flags, integrity). + - Integrated text viewer for `FILE_ID.DIZ` metadata with a quick copy-to-clipboard button. -4. **Help & Guide**: +4. **Help Guide (Help & Guide)**: - - Integrated help modal explaining step-by-step how to apply, undo, and create patches, along with common troubleshooting tips. + - Integrated help modal with detailed explanations for beginners on how to apply, revert, and create patches, along with common troubleshooting steps. --- @@ -200,9 +280,9 @@ Usage: ppf_cli Commands: apply Apply a PPF patch to a binary file undo Undo a PPF3 patch from a binary file - info Show patch information - create Create a new PPF patch from two binary files - help Print this message or the help of the given subcommand(s) + info Show patch information and metadata + create Create a new PPF3 patch comparing two binary files + help Print help for available commands ``` ### 1. Inspect a Patch (`info`) @@ -247,7 +327,7 @@ ppf_cli create \ - `-O, --original `: Unmodified original binary file. - `-p, --patched `: Binary file with modifications applied. -- `-o, --output `: Output path where the `.ppf` file will be saved. +- `-o, --output `: Destination path where the `.ppf` file will be saved. - `-u, --undo`: Include restore data to allow reverting the patch with `undo`. - `-x, --disable-validation`: Disable block integrity verification (Blockcheck). - `-i, --imagetype <0|1>`: Image type (`0` = standard BIN/RAW [default], `1` = GI / PrimoDVD). @@ -262,6 +342,7 @@ To integrate the patching engine into your own Rust project, add `ppf-core` to y ```toml [dependencies] +# By default includes memory-mapped file support (memmap2) and parallel scanning (rayon) ppf-core = { git = "https://github.com/plinkr/ppf_rust.git" } ``` @@ -272,6 +353,23 @@ Or using a local path within your workspace: ppf-core = { path = "../ppf_rust/crates/ppf-core" } ``` +### Feature Flags Configuration (`ppf-core`) + +`ppf-core` allows selecting the required features depending on your target environment: + +- **`mmap`** *(default)*: Enables efficient disk read and write support via `memmap2` (`apply_patch`, `undo_patch`, `create_patch`, `inspect_patch`, `PpfFile`). +- **`parallel`** *(default)*: Enables multi-core processing with `rayon` for patch creation. +- **`serde`** *(optional)*: Enables `Serialize` and `Deserialize` on data structures (`PpfHeader`, `PpfCreatorOptions`). + +For embedded environments or WebAssembly targets (`wasm32-unknown-unknown`), you can disable default dependencies to operate exclusively on in-memory buffers (`&[u8]` and `&mut [u8]`): + +```toml +[dependencies] +ppf-core = { git = "https://github.com/plinkr/ppf_rust.git", default-features = false, features = ["serde"] } +``` + +--- + ### Example 1: Inspect Patch Metadata ```rust @@ -293,9 +391,7 @@ fn main() -> Result<(), Box> { } ``` -### Example 2: Apply or Revert a Patch with Progress Callbacks - -The library allows attaching callbacks to monitor progress in GUI or custom console applications: +### Example 2: Apply or Revert a Patch on Disk with Progress Callbacks ```rust use ppf_core::{apply_patch, undo_patch}; @@ -306,10 +402,10 @@ fn main() -> Result<(), Box> { }; let on_progress = |records_done: usize| { - // Invoked for each applied record/block + // Invoked for each applied record or block }; - // Apply patch directly to binary + // Apply patch directly to binary on disk apply_patch( "translation.ppf", "game.bin", @@ -360,6 +456,28 @@ fn main() -> Result<(), Box> { } ``` +### Example 4: In-Memory or WebAssembly Operations (`_slice`) + +If operating in WebAssembly environments or processing in-memory buffers without touching disk: + +```rust +use ppf_core::{apply_patch_slice, inspect_patch_slice, undo_patch_slice}; + +fn main() -> Result<(), Box> { + let patch_bytes = std::fs::read("patch.ppf")?; + let mut target_bytes = std::fs::read("game.bin")?; + + // Inspect header from bytes + let info = inspect_patch_slice(&patch_bytes)?; + println!("Patch for image type: {:?}", info.image_type); + + // Apply modifications directly to the mutable slice + apply_patch_slice(&patch_bytes, &mut target_bytes, None, None)?; + + Ok(()) +} +``` + --- ## Testing @@ -478,6 +596,31 @@ Summary | `ppf_cli apply` (Rust) | **2.9 ms ± 0.1 ms** | 2.8 ms | 3.4 ms | **46.11x faster** | | `applyppf3` (C) | 135.3 ms ± 4.9 ms | 127.8 ms | 155.4 ms | Reference | +### 4. Patch Application with Random Modifications - 50 runs (Warm Cache / In-Memory) + +Stress test conducted by applying a patch consisting of a massive volume of modifications distributed completely at random throughout the ppf binary. + +In this extreme random scatter scenario, the performance difference between both implementations becomes very noticeable, the reference C version (`applyppf3`) takes over 14 seconds due to the system call overhead of traditional I/O, while the Rust implementation (`ppf_cli`) using memory mapping (`memmap2`) consistently finishes in under 900 ms (~826 ms), running over **17 times faster**. + +```bash +Benchmark 1: C Version (applyppf3) - warm + Time (mean ± σ): 14.108 s ± 0.335 s [User: 7.292 s, System: 6.772 s] + Range (min … max): 13.711 s … 15.820 s 50 runs + +Benchmark 2: Rust Version (ppf_cli) - warm + Time (mean ± σ): 826.0 ms ± 11.7 ms [User: 473.3 ms, System: 349.0 ms] + Range (min … max): 788.9 ms … 853.4 ms 50 runs + +Summary + Rust Version (ppf_cli) - warm ran + 17.08 ± 0.47 times faster than C Version (applyppf3) - warm +``` + +| Command | Mean | Min | Max | Relative Performance | +|:---|---:|---:|---:|---:| +| `ppf_cli apply` (Rust) | **826.0 ms ± 11.7 ms** | 788.9 ms | 853.4 ms | **17.08x faster** | +| `applyppf3` (C) | 14.108 s ± 0.335 s | 13.711 s | 15.820 s | Reference | + --- ## License diff --git a/README_ES.md b/README_ES.md index f194573..5921e96 100644 --- a/README_ES.md +++ b/README_ES.md @@ -4,9 +4,41 @@ Suite de alto rendimiento en Rust para crear, aplicar, inspeccionar y revertir parches en formato **PlayStation Patch File (PPF v1.0, v2.0 y v3.0)**. -El proyecto está organizado como un **Cargo Workspace** modular compuesto por una biblioteca central (`ppf-core`), una herramienta de línea de comandos (`ppf-cli`) y una interfaz gráfica de escritorio (`ppf-gui`) desarrollada con `egui`. +El proyecto está organizado como un **Cargo Workspace** modular compuesto por una biblioteca central (`ppf-core`), una herramienta de línea de comandos (`ppf-cli`), una interfaz gráfica de escritorio (`ppf-gui`) desarrollada con `egui`, un paquete WebAssembly (`ppf-wasm`) y una versión web con frontend estático (`web/`) en HTML5 y JavaScript. -Implementa concurrencia con Rayon y lectura/escritura mediante mapeo de memoria (`memmap2`), ofreciendo máxima velocidad de procesamiento con seguridad de memoria estricta. +Para las herramientas de escritorio (`ppf-cli` y `ppf-gui`), implementa concurrencia con Rayon y operaciones de lectura/escritura mediante mapeo de memoria (`memmap2`), ofreciendo máxima velocidad de procesamiento con seguridad de memoria estricta. + +--- + + + +--- + +## Versión Web en Línea (GitHub Pages) + +Puedes acceder a la versión web de la aplicación directamente desde el navegador en: + +**[https://plinkr.github.io/ppf_rust](https://plinkr.github.io/ppf_rust)** + +La versión web ejecuta el motor `ppf-core` compilado a **WebAssembly (WASM)** y cuenta con un frontend en **HTML5, CSS y JavaScript**. El procesamiento se realiza al 100% de forma local en tu navegador mediante Web Workers, por lo que ningún archivo ni imagen de disco se sube a internet. + +> **Nota importante sobre el rendimiento:** +> La versión WebAssembly opera sobre búferes en memoria (`Uint8Array`) y, por restricciones inherentes al entorno del navegador, **no puede utilizar mapeo de memoria (`memmap2`) ni paralelismo multinúcleo (`rayon`)**. +> +> Como consecuencia, su velocidad de procesamiento es considerablemente inferior a las versiones nativas y el consumo de RAM en el navegador aumenta con imágenes de gran tamaño (como archivos BIN/ISO de 500 MB o más). **Se aconseja usar la versión web en última instancia o por conveniencia rápida cuando no tengas acceso a los binarios de escritorio. Para un rendimiento óptimo en archivos grandes, utiliza siempre la versión CLI (`ppf_cli`) o la versión GUI (`ppf_gui`).** --- @@ -15,28 +47,33 @@ Implementa concurrencia con Rayon y lectura/escritura mediante mapeo de memoria ```text ppf_rust/ ├── Cargo.toml # Configuración raíz del workspace -└── crates/ - ├── ppf-core/ # Biblioteca del motor PPF (API pública) - ├── ppf-cli/ # Interfaz de línea de comandos (binario `ppf_cli`) - └── ppf-gui/ # Interfaz gráfica en egui (binario `ppf_gui`) +├── crates/ +│ ├── ppf-core/ # Biblioteca del motor PPF (API pública modular con feature flags) +│ ├── ppf-cli/ # Interfaz de línea de comandos (binario `ppf_cli`) +│ ├── ppf-gui/ # Interfaz gráfica de escritorio en egui (binario `ppf_gui`) +│ └── ppf-wasm/ # Bindings WebAssembly (wasm-bindgen) +└── web/ # Cliente web estático (HTML5, JavaScript, Web Workers, CSS) ``` -- **`ppf-core`**: Motor central desacoplado de la interfaz. Provee parsing, validación de integridad (Blockcheck), creación paralela, aplicación y reversión de parches con callbacks de progreso. -- **`ppf-cli`**: Aplicación de terminal (`ppf_cli`) basada en `clap` e `indicatif`, diseñada para automatización, scripts y flujos de trabajo en consola. +- **`ppf-core`**: Motor central desacoplado de la interfaz. Provee parsing, validación de integridad (Blockcheck), creación paralela, aplicación y reversión de parches con callbacks de progreso. Utiliza *feature flags* (`mmap`, `parallel`, `serde`) para habilitar dependencias nativas en escritorio o compilar de forma ligera para WebAssembly. +- **`ppf-cli`**: Aplicación de terminal (`ppf_cli`) basada en `clap` e `indicatif`, diseñada para máxima velocidad, automatización y scripts en consola. - **`ppf-gui`**: Aplicación de escritorio (`ppf_gui`) multiplataforma basada en `eframe` / `egui`, con soporte para arrastrar y soltar (Drag & Drop), modo seguro con copia de respaldo e inspección visual de parches. +- **`ppf-wasm`**: Capa de integración WebAssembly que exporta las funciones del motor (`apply_patch`, `undo_patch`, `create_patch`, `inspect_patch`) hacia JavaScript mediante `wasm-bindgen`. +- **`web`**: Frontend web estático con interfaz de usuario en pestañas, soporte de Drag & Drop y procesamiento asíncrono en segundo plano mediante Web Workers. --- ## Características - **Compatibilidad con estándares PPF**: Soporte completo para lectura y aplicación de versiones **PPF 1.0**, **PPF 2.0** y **PPF 3.0**. -- **Creación optimizada de PPF3**: Escaneo de diferencias en paralelo por bloques mediante `rayon`, aprovechando todos los núcleos de CPU disponibles. +- **Creación optimizada de PPF3**: Escaneo de diferencias en paralelo por bloques mediante `rayon` en escritorio, aprovechando todos los núcleos de CPU disponibles. - **Validación de integridad (Blockcheck)**: Verificación del bloque de validación de 1024 bytes para imágenes BIN estándar (offset `0x9320`) y formato GI/PrimoDVD (offset `0x80A0`). - **Soporte para datos de reversión (Undo Data)**: Generación y aplicación de parches reversibles para restaurar binarios modificados a su estado original bit a bit. - **Soporte de metadatos FILE_ID.DIZ**: Inserción y extracción de descripciones extendidas bajo el estándar Amiga/BBS (hasta 3072 bytes). - **Interfaz Gráfica (GUI) en egui**: Aplicación visual moderna con soporte de Drag & Drop, selección entre parcheo directo (*in-place*) o copia segura (*safe copy*), visores de metadatos y modales de ayuda. - **Interfaz de Línea de Comandos (CLI)**: Subcomandos estructurados (`apply`, `undo`, `info`, `create`) con barras de progreso interactivas y cálculo de tiempo estimado (ETA). -- **Motor desacoplado**: Biblioteca reutilizable con tipos de error tipados (`thiserror`) y callbacks desacoplados para integraciones personalizadas. +- **Versión Web (WebAssembly)**: Aplicación web cliente sin dependencias de servidor, lista para usar desde el navegador en GitHub Pages. +- **Motor desacoplado y condicional**: `ppf-core` gestiona mediante *features* el uso de `memmap2` y `rayon`, manteniendo el máximo rendimiento en escritorio sin comprometer la portabilidad hacia WebAssembly. --- @@ -46,12 +83,13 @@ ppf_rust/ - **Rust**: Versión 1.85 o superior (Rust Edition 2024). - **Cargo**. +- *(Opcional para WebAssembly)*: Target `wasm32-unknown-unknown` y `wasm-bindgen-cli`. --- ### 1. Compilar todo el proyecto (Workspace completo) -Para compilar todos los crates del workspace (`ppf-core`, `ppf-cli` y `ppf-gui`) en modo optimizado: +Para compilar todos los crates de escritorio del workspace (`ppf-core`, `ppf-cli` y `ppf-gui`) en modo optimizado: ```bash cargo build --release @@ -108,9 +146,44 @@ cargo build -p ppf-core --release --- -### 5. Instalación global en el sistema +### 5. Compilar la versión WebAssembly (`ppf-wasm`) y preparar el cliente web -Puedes instalar los ejecutables directamente en tu directorio `~/.cargo/bin`: +Si deseas compilar los bindings WebAssembly y generar los paquetes necesarios para servir el frontend web de forma local: + +```bash +# 1. Instalar el target WebAssembly +rustup target add wasm32-unknown-unknown + +# 2. Instalar la herramienta wasm-bindgen-cli +cargo install wasm-bindgen-cli --version 0.2.100 --locked + +# 3. Compilar el crate ppf-wasm para WebAssembly +cargo build --package ppf-wasm --target wasm32-unknown-unknown --release + +# 4. Generar los enlaces JavaScript en el directorio web/pkg +wasm-bindgen --target web --out-dir web/pkg --out-name ppf_wasm target/wasm32-unknown-unknown/release/ppf_wasm.wasm +``` + +Para probar el cliente web localmente, sirve el directorio `web/` con cualquier servidor estático HTTP: + +```bash +# Con Python 3: +python3 -m http.server 8080 -d web + +# O con herramientas como `basic-http-server`: +basic-http-server web + +# O `miniserve`: +miniserve --index index.html --interfaces 127.0.0.1 --port 8080 web/ +``` + +Y abre en tu navegador la dirección `http://localhost:8080`. + +--- + +### 6. Instalación global en el sistema + +Puedes instalar los ejecutables nativos directamente en tu directorio `~/.cargo/bin`: ```bash # Instalar el CLI globalmente @@ -137,6 +210,30 @@ cargo run -p ppf-cli -- info --patch parche.ppf --- +## Uso de la Versión Web + +La versión web está disponible en línea en **[https://plinkr.github.io/ppf_rust](https://plinkr.github.io/ppf_rust)** y ofrece las mismas capacidades funcionales a través del navegador: + +1. **Pestaña Aplicar / Revertir (Apply & Undo)**: + - Carga la imagen binaria (`.bin`, `.iso`, `.img`, `.cue`, `.raw`) y el parche `.ppf` (vía selector o arrastrando con Drag & Drop). + - Inspección en vivo de los metadatos y validación de la imagen. + - Aplica o revierte el parche en memoria y descarga automáticamente el archivo binario resultante. + +2. **Pestaña Crear (Create Patch)**: + - Permite cargar el archivo original y el modificado. + - Opciones configurables: descripción, tipo de imagen (BIN/GI), validación de bloque (Blockcheck), datos de reversión (Undo) y archivo `FILE_ID.DIZ`. + - Generación y descarga del parche `.ppf` procesado en un Web Worker en segundo plano. + +3. **Pestaña Inspeccionar (Info & DIZ)**: + - Muestra detalles completos de la cabecera PPF e incluye un visor de texto para descripciones `FILE_ID.DIZ` con botón de copia al portapapeles. + +4. **Guía de Ayuda (Help & Guide)**: + - Instrucciones paso a paso sobre el formato PPF y soluciones a incidencias habituales. + +> *Recordatorio de rendimiento:* La versión web es una alternativa práctica cuando no es posible instalar binarios de escritorio. No obstante, al carecer de `rayon` y `memmap2`, para imágenes de disco pesadas se aconseja priorizar las aplicaciones de escritorio (`ppf_cli` o `ppf_gui`). + +--- + ## Uso de la Interfaz Gráfica (`ppf_gui`) Ejecuta el binario `ppf_gui`: @@ -245,6 +342,7 @@ Para integrar el motor de parcheo en tu propio proyecto de Rust, añade `ppf-cor ```toml [dependencies] +# Por defecto incluye soporte para memoria mapeada (memmap2) y escaneo paralelo (rayon) ppf-core = { git = "https://github.com/plinkr/ppf_rust.git" } ``` @@ -255,6 +353,23 @@ O si utilizas una ruta local en tu propio workspace: ppf-core = { path = "../ppf_rust/crates/ppf-core" } ``` +### Configuración de Features (`ppf-core`) + +`ppf-core` permite seleccionar las características necesarias según el entorno de destino: + +- **`mmap`** *(por defecto)*: Habilita el soporte de lectura y escritura eficiente en disco mediante `memmap2` (`apply_patch`, `undo_patch`, `create_patch`, `inspect_patch`, `PpfFile`). +- **`parallel`** *(por defecto)*: Habilita el procesamiento multinúcleo con `rayon` para la creación de parches. +- **`serde`** *(opcional)*: Habilita `Serialize` y `Deserialize` para estructuras de datos (`PpfHeader`, `PpfCreatorOptions`). + +Para entornos embebidos o compilación para WebAssembly (`wasm32-unknown-unknown`), puedes desactivar las dependencias por defecto para operar exclusivamente con búferes en memoria (`&[u8]` y `&mut [u8]`): + +```toml +[dependencies] +ppf-core = { git = "https://github.com/plinkr/ppf_rust.git", default-features = false, features = ["serde"] } +``` + +--- + ### Ejemplo 1: Inspeccionar metadatos de un parche ```rust @@ -276,9 +391,7 @@ fn main() -> Result<(), Box> { } ``` -### Ejemplo 2: Aplicar o revertir un parche con callbacks de progreso - -La biblioteca permite conectar callbacks para monitorizar el progreso en interfaces gráficas o consolas personalizadas: +### Ejemplo 2: Aplicar o revertir un parche en disco con callbacks de progreso ```rust use ppf_core::{apply_patch, undo_patch}; @@ -292,7 +405,7 @@ fn main() -> Result<(), Box> { // Invocado por cada bloque o registro aplicado }; - // Aplicar parche directamente al binario + // Aplicar parche directamente al binario en disco apply_patch( "traduccion.ppf", "juego.bin", @@ -343,6 +456,28 @@ fn main() -> Result<(), Box> { } ``` +### Ejemplo 4: Operaciones en memoria o WebAssembly (`_slice`) + +Si operas en entornos WebAssembly o procesas búferes en memoria sin tocar el disco: + +```rust +use ppf_core::{apply_patch_slice, inspect_patch_slice, undo_patch_slice}; + +fn main() -> Result<(), Box> { + let patch_bytes = std::fs::read("parche.ppf")?; + let mut target_bytes = std::fs::read("juego.bin")?; + + // Inspeccionar cabecera desde bytes + let info = inspect_patch_slice(&patch_bytes)?; + println!("Parche para imagen: {:?}", info.image_type); + + // Aplicar modificaciones directamente al slice mutable + apply_patch_slice(&patch_bytes, &mut target_bytes, None, None)?; + + Ok(()) +} +``` + --- ## Pruebas @@ -461,6 +596,30 @@ Summary | `ppf_cli apply` (Rust) | **2.9 ms ± 0.1 ms** | 2.8 ms | 3.4 ms | **46.11x más rápido** | | `applyppf3` (C) | 135.3 ms ± 4.9 ms | 127.8 ms | 155.4 ms | Referencia | +### 4. Aplicación de parche con modificaciones aleatorias - 50 ejecuciones (Warm Cache / En memoria) + +Prueba de estrés realizada aplicando un parche compuesto por un volumen masivo de modificaciones distribuidas de manera completamente aleatoria en el binario ppf. + +En este escenario con dispersión aleatoria extrema, la diferencia de rendimiento entre ambas implementaciones se vuelve muy notable, la versión de referencia en C (`applyppf3`) demora más de 14 segundos debido a la sobrecarga del esquema clásico de I/O, mientras que la implementación en Rust (`ppf_cli`) mediante memoria mapeada (`memmap2`) completa la operación de forma consistente en menos de 900 ms (~826 ms), resultando más de **17 veces más rápida**. + +```bash +Benchmark 1: C Version (applyppf3) - warm + Time (mean ± σ): 14.108 s ± 0.335 s [User: 7.292 s, System: 6.772 s] + Range (min … max): 13.711 s … 15.820 s 50 runs + +Benchmark 2: Rust Version (ppf_cli) - warm + Time (mean ± σ): 826.0 ms ± 11.7 ms [User: 473.3 ms, System: 349.0 ms] + Range (min … max): 788.9 ms … 853.4 ms 50 runs + +Summary + Rust Version (ppf_cli) - warm ran + 17.08 ± 0.47 times faster than C Version (applyppf3) - warm +``` + +| Comando | Media | Mínimo | Máximo | Rendimiento Relativo | +|:---|---:|---:|---:|---:| +| `ppf_cli apply` (Rust) | **826.0 ms ± 11.7 ms** | 788.9 ms | 853.4 ms | **17.08x más rápido** | +| `applyppf3` (C) | 14.108 s ± 0.335 s | 13.711 s | 15.820 s | Referencia | --- From 75a9e9c36f2d334248ea7218ccfcdc49f5ddcd91 Mon Sep 17 00:00:00 2001 From: Aliet Exposito Garcia Date: Thu, 20 Aug 2026 22:34:20 -0400 Subject: [PATCH 6/7] feat(gui): add file integrity checksum generation for target files Users often need to verify the integrity of their target binary images before or after applying patches. To address this need, the application now includes built in support for calculating `CRC32`, `MD5`, and `SHA1` hashes directly within the graphical interface. This allows users to confirm their files match expected checksums without relying on external tools. The hash computation runs asynchronously in the background using dedicated worker threads. This design ensures the main user interface remains responsive even when processing large files. Users can choose to calculate individual hashes or process all three simultaneously in a single read pass for optimal performance. A new collapsible section in the application view provides the controls to generate, clear, and copy the calculated values to the system clipboard. The window dimensions were slightly expanded to comfortably accommodate these new interface elements. Additionally, comprehensive test coverage was included to guarantee the accuracy of the hashing algorithms. --- Cargo.lock | 87 +++++++- Cargo.toml | 5 +- crates/ppf-gui/Cargo.toml | 6 + crates/ppf-gui/src/app.rs | 123 +++++++++++- crates/ppf-gui/src/main.rs | 4 +- crates/ppf-gui/src/views/apply_undo.rs | 216 +++++++++++++++++++- crates/ppf-gui/src/worker.rs | 267 +++++++++++++++++++++++++ 7 files changed, 695 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cf5c6af..87cb2f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -385,6 +385,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -702,6 +711,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -736,12 +754,32 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "cursor-icon" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1163,6 +1201,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -1664,6 +1712,16 @@ dependencies = [ "libc", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.3" @@ -2287,7 +2345,7 @@ dependencies = [ [[package]] name = "ppf-cli" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "clap", @@ -2297,7 +2355,7 @@ dependencies = [ [[package]] name = "ppf-core" -version = "0.3.0" +version = "0.3.1" dependencies = [ "memmap2", "rayon", @@ -2308,18 +2366,22 @@ dependencies = [ [[package]] name = "ppf-gui" -version = "0.3.0" +version = "0.3.1" dependencies = [ + "crc32fast", "eframe", "egui", "image", + "md-5", "ppf-core", "rfd", + "sha1", + "tempfile", ] [[package]] name = "ppf-wasm" -version = "0.3.0" +version = "0.3.1" dependencies = [ "js-sys", "ppf-core", @@ -2647,6 +2709,17 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -2998,6 +3071,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "uds_windows" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 5c55eff..0d88505 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.3.0" +version = "0.3.1" edition = "2024" authors = ["Aliet Exposito"] license = "MIT" @@ -31,6 +31,9 @@ egui = "0.29.1" rfd = "0.15.2" tempfile = "3.27.0" image = { version = "0.25.10", default-features = false, features = ["png"] } +md-5 = "0.10.6" +sha1 = "0.10.6" +crc32fast = "1.4.2" [profile.release] opt-level = 3 diff --git a/crates/ppf-gui/Cargo.toml b/crates/ppf-gui/Cargo.toml index 95619ed..1a623cf 100644 --- a/crates/ppf-gui/Cargo.toml +++ b/crates/ppf-gui/Cargo.toml @@ -15,3 +15,9 @@ eframe.workspace = true egui.workspace = true rfd.workspace = true image.workspace = true +md-5.workspace = true +sha1.workspace = true +crc32fast.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/ppf-gui/src/app.rs b/crates/ppf-gui/src/app.rs index 4546080..6a4e951 100644 --- a/crates/ppf-gui/src/app.rs +++ b/crates/ppf-gui/src/app.rs @@ -1,10 +1,22 @@ use crate::views; -use crate::worker::{FilePickTarget, InspectTargetTab, WorkerEvent, inspect_patch_async}; +use crate::worker::{ + FilePickTarget, HashAlgorithm, InspectTargetTab, WorkerEvent, compute_all_hashes_async, + compute_hash_async, inspect_patch_async, +}; use egui::{Color32, RichText}; use ppf_core::{ImageType, PatchInfo}; use std::path::PathBuf; use std::sync::mpsc::{Receiver, Sender, channel}; +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum HashState { + #[default] + NotCalculated, + Calculating, + Calculated(String), + Error(String), +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Tab { ApplyUndo, @@ -51,6 +63,13 @@ pub struct PpfApp { pub apply_inspect_error: Option, pub action_status: Option>, + // Target image hash state (CRC32, MD5, SHA-1) + pub hash_crc32: HashState, + pub hash_md5: HashState, + pub hash_sha1: HashState, + pub hash_source_path: String, + pub hash_copied_feedback: Option<(HashAlgorithm, std::time::Instant)>, + // Create tab state pub create_original_path: String, pub create_patched_path: String, @@ -94,6 +113,12 @@ impl PpfApp { apply_inspect_error: None, action_status: None, + hash_crc32: HashState::NotCalculated, + hash_md5: HashState::NotCalculated, + hash_sha1: HashState::NotCalculated, + hash_source_path: String::new(), + hash_copied_feedback: None, + create_original_path: String::new(), create_patched_path: String::new(), create_output_path: String::new(), @@ -174,6 +199,7 @@ impl PpfApp { match result { Ok(apply_res) => { if apply_res.was_in_place { + self.clear_hashes(); self.action_status = Some(Ok(format!( "Patch applied in-place successfully: '{}'", apply_res.info.description @@ -195,6 +221,7 @@ impl PpfApp { self.is_busy = false; match result { Ok(info) => { + self.clear_hashes(); self.action_status = Some(Ok(format!( "Patch reversed successfully: '{}'", info.description @@ -219,6 +246,24 @@ impl PpfApp { } } } + WorkerEvent::HashComplete { + path, + algorithm, + result, + } => { + let current_clean = clean_path_str(&self.apply_bin_path); + if std::path::Path::new(¤t_clean) == path { + let state = match result { + Ok(hash) => HashState::Calculated(hash), + Err(err) => HashState::Error(err), + }; + match algorithm { + HashAlgorithm::Crc32 => self.hash_crc32 = state, + HashAlgorithm::Md5 => self.hash_md5 = state, + HashAlgorithm::Sha1 => self.hash_sha1 = state, + } + } + } WorkerEvent::FilePicked { target, path } => { let path_str = path.to_string_lossy().to_string(); match target { @@ -229,6 +274,7 @@ impl PpfApp { .to_string_lossy() .to_string(); self.action_status = None; + self.reset_hashes_if_path_changed(); } FilePickTarget::ApplyOutputCopy => { self.apply_output_copy_path = path_str; @@ -307,6 +353,7 @@ impl PpfApp { .to_string_lossy() .to_string(); self.action_status = None; + self.reset_hashes_if_path_changed(); } } Tab::Create => { @@ -342,6 +389,78 @@ impl PpfApp { } } } + + pub fn reset_hashes_if_path_changed(&mut self) { + let clean = clean_path_str(&self.apply_bin_path); + if clean != self.hash_source_path { + self.hash_source_path = clean; + self.hash_crc32 = HashState::NotCalculated; + self.hash_md5 = HashState::NotCalculated; + self.hash_sha1 = HashState::NotCalculated; + self.hash_copied_feedback = None; + } + } + + pub fn calculate_hash(&mut self, algorithm: HashAlgorithm) { + let clean = clean_path_str(&self.apply_bin_path); + if clean.is_empty() { + return; + } + let path = PathBuf::from(&clean); + if !path.is_file() { + let err = "File does not exist or is not a regular file".to_string(); + match algorithm { + HashAlgorithm::Crc32 => self.hash_crc32 = HashState::Error(err), + HashAlgorithm::Md5 => self.hash_md5 = HashState::Error(err), + HashAlgorithm::Sha1 => self.hash_sha1 = HashState::Error(err), + } + return; + } + + self.hash_source_path = clean; + match algorithm { + HashAlgorithm::Crc32 => self.hash_crc32 = HashState::Calculating, + HashAlgorithm::Md5 => self.hash_md5 = HashState::Calculating, + HashAlgorithm::Sha1 => self.hash_sha1 = HashState::Calculating, + } + + compute_hash_async(path, algorithm, self.tx_event.clone()); + } + + pub fn calculate_all_hashes(&mut self) { + let clean = clean_path_str(&self.apply_bin_path); + if clean.is_empty() { + return; + } + let path = PathBuf::from(&clean); + if !path.is_file() { + let err = "File does not exist or is not a regular file".to_string(); + self.hash_crc32 = HashState::Error(err.clone()); + self.hash_md5 = HashState::Error(err.clone()); + self.hash_sha1 = HashState::Error(err); + return; + } + + self.hash_source_path = clean; + self.hash_crc32 = HashState::Calculating; + self.hash_md5 = HashState::Calculating; + self.hash_sha1 = HashState::Calculating; + + compute_all_hashes_async(path, self.tx_event.clone()); + } + + pub fn clear_hashes(&mut self) { + self.hash_crc32 = HashState::NotCalculated; + self.hash_md5 = HashState::NotCalculated; + self.hash_sha1 = HashState::NotCalculated; + self.hash_copied_feedback = None; + } + + pub fn is_hashing(&self) -> bool { + self.hash_crc32 == HashState::Calculating + || self.hash_md5 == HashState::Calculating + || self.hash_sha1 == HashState::Calculating + } } impl eframe::App for PpfApp { @@ -349,7 +468,7 @@ impl eframe::App for PpfApp { self.handle_worker_events(); self.handle_drag_and_drop(ctx); - if self.is_busy { + if self.is_busy || self.is_hashing() { ctx.request_repaint_after(std::time::Duration::from_millis(33)); } diff --git a/crates/ppf-gui/src/main.rs b/crates/ppf-gui/src/main.rs index 0b6db6b..bd657fc 100644 --- a/crates/ppf-gui/src/main.rs +++ b/crates/ppf-gui/src/main.rs @@ -18,8 +18,8 @@ fn load_app_icon() -> Option { fn main() -> eframe::Result<()> { let mut viewport = egui::ViewportBuilder::default() - .with_inner_size([740.0, 580.0]) - .with_min_inner_size([640.0, 520.0]) + .with_inner_size([740.0, 640.0]) + .with_min_inner_size([680.0, 560.0]) .with_title("PPF Rust Patcher") .with_drag_and_drop(true); diff --git a/crates/ppf-gui/src/views/apply_undo.rs b/crates/ppf-gui/src/views/apply_undo.rs index bdff1bd..16a4fa0 100644 --- a/crates/ppf-gui/src/views/apply_undo.rs +++ b/crates/ppf-gui/src/views/apply_undo.rs @@ -1,8 +1,8 @@ -use crate::app::{PpfApp, clean_path_str}; +use crate::app::{HashState, PpfApp, clean_path_str}; use crate::views::widgets; use crate::worker::{ - FilePickTarget, InspectTargetTab, apply_patch_async, pick_file_async, save_file_async, - undo_patch_async, + FilePickTarget, HashAlgorithm, InspectTargetTab, apply_patch_async, pick_file_async, + save_file_async, undo_patch_async, }; use egui::{Color32, RichText, Ui}; use ppf_core::{ImageType, PpfVersion}; @@ -31,6 +31,7 @@ pub fn show(app: &mut PpfApp, ui: &mut Ui) { ); if edit.changed() { app.action_status = None; + app.reset_hashes_if_path_changed(); if app.apply_output_copy_path.is_empty() && !app.apply_bin_path.trim().is_empty() { let clean = clean_path_str(&app.apply_bin_path); app.apply_output_copy_path = crate::worker::generate_patched_path( @@ -97,6 +98,10 @@ pub fn show(app: &mut PpfApp, ui: &mut Ui) { }); } + // Checksums & Hashes Collapsible Section + ui.add_space(4.0); + render_hash_collapsible(app, ui); + ui.add_space(8.0); // Patch File Input @@ -224,7 +229,8 @@ pub fn show(app: &mut PpfApp, ui: &mut Ui) { // Card 3: Action Buttons let has_bin = !app.apply_bin_path.trim().is_empty(); let has_patch = !app.apply_patch_path.trim().is_empty() && app.apply_patch_info.is_some(); - let can_apply = has_bin && has_patch && !app.is_busy; + let is_busy = app.is_busy || app.is_hashing(); + let can_apply = has_bin && has_patch && !is_busy; let can_undo = can_apply && app .apply_patch_info @@ -299,3 +305,205 @@ pub fn show(app: &mut PpfApp, ui: &mut Ui) { // Status / Result Banner widgets::render_status_banner(app.action_status.as_ref(), ui); } + +fn render_hash_collapsible(app: &mut PpfApp, ui: &mut Ui) { + egui::CollapsingHeader::new( + RichText::new("Image Checksums & Hashes (CRC32, MD5, SHA-1)") + .color(Color32::from_rgb(170, 200, 240)) + .strong(), + ) + .default_open(false) + .show(ui, |ui| { + ui.add_space(2.0); + let clean = clean_path_str(&app.apply_bin_path); + let file_path = std::path::Path::new(&clean); + let file_valid = !clean.is_empty() && file_path.is_file(); + + if !file_valid { + ui.label( + RichText::new( + "Select a valid target binary image above to compute or verify checksums.", + ) + .size(12.0) + .color(Color32::from_rgb(160, 160, 160)) + .italics(), + ); + return; + } + + ui.label( + RichText::new( + "Generate integrity hashes of the target image (runs concurrently in background):", + ) + .size(12.0) + .weak(), + ); + ui.add_space(4.0); + + let mut trigger_calc: Option = None; + let mut trigger_copy: Option<(HashAlgorithm, String)> = None; + + egui::Grid::new("image_hashes_table") + .num_columns(3) + .spacing([14.0, 6.0]) + .show(ui, |ui| { + for algo in [ + HashAlgorithm::Crc32, + HashAlgorithm::Md5, + HashAlgorithm::Sha1, + ] { + render_hash_row(ui, app, algo, &mut trigger_calc, &mut trigger_copy); + ui.end_row(); + } + }); + + ui.add_space(6.0); + + // Action Buttons Row + ui.horizontal(|ui| { + let is_busy = app.is_busy; + let is_hashing = app.is_hashing(); + + let calc_all_btn = ui.add_enabled( + !is_busy && !is_hashing, + egui::Button::new(RichText::new("Calculate All Hashes").strong()), + ).on_hover_text( + "Computes CRC32, MD5, and SHA-1 simultaneously using background worker threads.", + ); + if calc_all_btn.clicked() { + app.calculate_all_hashes(); + } + + let has_any_result = app.hash_crc32 != HashState::NotCalculated + || app.hash_md5 != HashState::NotCalculated + || app.hash_sha1 != HashState::NotCalculated; + + if has_any_result { + let clear_btn = ui + .add_enabled(!is_hashing, egui::Button::new("Clear Hashes")) + .on_hover_text("Clear all calculated hash values."); + if clear_btn.clicked() { + app.clear_hashes(); + } + } + }); + + if let Some((algo, text)) = trigger_copy { + ui.output_mut(|o| o.copied_text = text); + app.hash_copied_feedback = Some((algo, std::time::Instant::now())); + } + + if let Some(algo) = trigger_calc { + app.calculate_hash(algo); + } + }); +} + +fn render_hash_row( + ui: &mut Ui, + app: &PpfApp, + algorithm: HashAlgorithm, + trigger_calc: &mut Option, + trigger_copy: &mut Option<(HashAlgorithm, String)>, +) { + let label = algorithm.name(); + let tooltip = algorithm.description(); + + // Column 1: Label + ui.label(RichText::new(format!("{}:", label)).strong()) + .on_hover_text(tooltip); + + // Column 2: Status / Hash Output + let state = match algorithm { + HashAlgorithm::Crc32 => &app.hash_crc32, + HashAlgorithm::Md5 => &app.hash_md5, + HashAlgorithm::Sha1 => &app.hash_sha1, + }; + + match state { + HashState::NotCalculated => { + ui.label( + RichText::new("Not calculated") + .color(Color32::from_rgb(140, 140, 140)) + .italics(), + ); + } + HashState::Calculating => { + ui.horizontal(|ui| { + ui.spinner(); + ui.label( + RichText::new("Calculating...") + .color(Color32::from_rgb(255, 200, 100)) + .italics(), + ); + }); + } + HashState::Calculated(hash) => { + let mut text = hash.clone(); + let width = match algorithm { + HashAlgorithm::Crc32 => 90.0, + HashAlgorithm::Md5 => 230.0, + HashAlgorithm::Sha1 => 290.0, + }; + ui.add( + egui::TextEdit::singleline(&mut text) + .font(egui::TextStyle::Monospace) + .min_size(egui::vec2(width, 20.0)) + .interactive(true), + ); + } + HashState::Error(err) => { + ui.colored_label(Color32::from_rgb(255, 100, 100), format!("Error: {}", err)); + } + } + + // Column 3: Action Buttons + ui.horizontal(|ui| match state { + HashState::NotCalculated => { + let btn = ui + .add_enabled( + !app.is_busy, + egui::Button::new(format!("Generate {}", label)), + ) + .on_hover_text(format!("Compute {} checksum in background thread.", label)); + if btn.clicked() { + *trigger_calc = Some(algorithm); + } + } + HashState::Calculating => { + ui.add_enabled(false, egui::Button::new("Working...")); + } + HashState::Calculated(hash) => { + let is_copied = app + .hash_copied_feedback + .as_ref() + .is_some_and(|(algo, instant)| { + *algo == algorithm && instant.elapsed().as_secs() < 2 + }); + let copy_label = if is_copied { + "✔ Copied!" + } else { + "📋 Copy" + }; + let copy_btn = ui + .button(copy_label) + .on_hover_text("Copy hash to clipboard"); + if copy_btn.clicked() { + *trigger_copy = Some((algorithm, hash.clone())); + } + + let recalc_btn = ui + .add_enabled(!app.is_busy, egui::Button::new("🔄")) + .on_hover_text(format!("Recalculate {} checksum", label)); + if recalc_btn.clicked() { + *trigger_calc = Some(algorithm); + } + } + HashState::Error(_) => { + let retry_btn = ui.add_enabled(!app.is_busy, egui::Button::new("Retry")); + if retry_btn.clicked() { + *trigger_calc = Some(algorithm); + } + } + }); +} diff --git a/crates/ppf-gui/src/worker.rs b/crates/ppf-gui/src/worker.rs index 5789e66..72e4498 100644 --- a/crates/ppf-gui/src/worker.rs +++ b/crates/ppf-gui/src/worker.rs @@ -1,12 +1,44 @@ +use crc32fast::Hasher as Crc32Hasher; +use md5::{Digest, Md5}; use ppf_core::{ PatchInfo, PpfCreatorOptions, apply_patch, create_patch, inspect_patch, undo_patch, }; +use sha1::Sha1; +use std::fs::File; +use std::io::{BufReader, Read}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::mpsc::Sender; use std::thread; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HashAlgorithm { + Crc32, + Md5, + Sha1, +} + +impl HashAlgorithm { + pub fn name(&self) -> &'static str { + match self { + HashAlgorithm::Crc32 => "CRC32", + HashAlgorithm::Md5 => "MD5", + HashAlgorithm::Sha1 => "SHA-1", + } + } + + pub fn description(&self) -> &'static str { + match self { + HashAlgorithm::Crc32 => { + "32-bit cyclic redundancy checksum (Redump and No-Intro standard)." + } + HashAlgorithm::Md5 => "128-bit MD5 cryptographic hash.", + HashAlgorithm::Sha1 => "160-bit SHA-1 cryptographic hash.", + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FilePickTarget { ApplyBin, @@ -39,6 +71,11 @@ pub enum WorkerEvent { ApplyComplete(Result), UndoComplete(Result), CreateComplete(Result), + HashComplete { + path: PathBuf, + algorithm: HashAlgorithm, + result: Result, + }, FilePicked { target: FilePickTarget, path: PathBuf, @@ -279,3 +316,233 @@ pub fn create_patch_async( let _ = tx.send(WorkerEvent::CreateComplete(result)); }); } + +pub fn compute_file_hash(path: &Path, algorithm: HashAlgorithm) -> Result { + let file = File::open(path).map_err(|err| format!("Failed to open file: {}", err))?; + let mut reader = BufReader::with_capacity(256 * 1024, file); + let mut buffer = [0u8; 256 * 1024]; + + match algorithm { + HashAlgorithm::Crc32 => { + let mut hasher = Crc32Hasher::new(); + loop { + let n = reader.read(&mut buffer).map_err(|e| e.to_string())?; + if n == 0 { + break; + } + hasher.update(&buffer[..n]); + } + Ok(format!("{:08X}", hasher.finalize())) + } + HashAlgorithm::Md5 => { + let mut hasher = Md5::new(); + loop { + let n = reader.read(&mut buffer).map_err(|e| e.to_string())?; + if n == 0 { + break; + } + hasher.update(&buffer[..n]); + } + Ok(format!("{:032x}", hasher.finalize())) + } + HashAlgorithm::Sha1 => { + let mut hasher = Sha1::new(); + loop { + let n = reader.read(&mut buffer).map_err(|e| e.to_string())?; + if n == 0 { + break; + } + hasher.update(&buffer[..n]); + } + Ok(format!("{:040x}", hasher.finalize())) + } + } +} + +pub struct ImageHashes { + pub crc32: String, + pub md5: String, + pub sha1: String, +} + +pub fn compute_all_file_hashes(path: &Path) -> Result { + let file = File::open(path).map_err(|err| format!("Failed to open file: {}", err))?; + let mut reader = BufReader::with_capacity(256 * 1024, file); + let mut buffer = [0u8; 256 * 1024]; + + let mut crc32_hasher = Crc32Hasher::new(); + let mut md5_hasher = Md5::new(); + let mut sha1_hasher = Sha1::new(); + + loop { + let n = reader.read(&mut buffer).map_err(|e| e.to_string())?; + if n == 0 { + break; + } + let chunk = &buffer[..n]; + crc32_hasher.update(chunk); + md5_hasher.update(chunk); + sha1_hasher.update(chunk); + } + + Ok(ImageHashes { + crc32: format!("{:08X}", crc32_hasher.finalize()), + md5: format!("{:032x}", md5_hasher.finalize()), + sha1: format!("{:040x}", sha1_hasher.finalize()), + }) +} + +pub fn compute_hash_async(path: PathBuf, algorithm: HashAlgorithm, tx: Sender) { + thread::spawn(move || { + let result = compute_file_hash(&path, algorithm); + let _ = tx.send(WorkerEvent::HashComplete { + path, + algorithm, + result, + }); + }); +} + +pub fn compute_all_hashes_async(path: PathBuf, tx: Sender) { + thread::spawn(move || match compute_all_file_hashes(&path) { + Ok(hashes) => { + let _ = tx.send(WorkerEvent::HashComplete { + path: path.clone(), + algorithm: HashAlgorithm::Crc32, + result: Ok(hashes.crc32), + }); + let _ = tx.send(WorkerEvent::HashComplete { + path: path.clone(), + algorithm: HashAlgorithm::Md5, + result: Ok(hashes.md5), + }); + let _ = tx.send(WorkerEvent::HashComplete { + path, + algorithm: HashAlgorithm::Sha1, + result: Ok(hashes.sha1), + }); + } + Err(err) => { + let _ = tx.send(WorkerEvent::HashComplete { + path: path.clone(), + algorithm: HashAlgorithm::Crc32, + result: Err(err.clone()), + }); + let _ = tx.send(WorkerEvent::HashComplete { + path: path.clone(), + algorithm: HashAlgorithm::Md5, + result: Err(err.clone()), + }); + let _ = tx.send(WorkerEvent::HashComplete { + path, + algorithm: HashAlgorithm::Sha1, + result: Err(err), + }); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use std::sync::mpsc::channel; + use tempfile::NamedTempFile; + + #[test] + fn test_compute_file_hashes_quick_brown_fox() { + let mut file = NamedTempFile::new().expect("create temp file"); + file.write_all(b"The quick brown fox jumps over the lazy dog") + .expect("write to temp file"); + let path = file.path(); + + let crc = compute_file_hash(path, HashAlgorithm::Crc32).expect("compute crc32"); + assert_eq!(crc, "414FA339"); + + let md5 = compute_file_hash(path, HashAlgorithm::Md5).expect("compute md5"); + assert_eq!(md5, "9e107d9d372bb6826bd81d3542a419d6"); + + let sha1 = compute_file_hash(path, HashAlgorithm::Sha1).expect("compute sha1"); + assert_eq!(sha1, "2fd4e1c67a2d28fced849ee1bb76e7391b93eb12"); + } + + #[test] + fn test_compute_all_file_hashes_single_pass() { + let mut file = NamedTempFile::new().expect("create temp file"); + file.write_all(b"The quick brown fox jumps over the lazy dog") + .expect("write to temp file"); + let path = file.path(); + + let hashes = compute_all_file_hashes(path).expect("compute all hashes"); + assert_eq!(hashes.crc32, "414FA339"); + assert_eq!(hashes.md5, "9e107d9d372bb6826bd81d3542a419d6"); + assert_eq!(hashes.sha1, "2fd4e1c67a2d28fced849ee1bb76e7391b93eb12"); + } + + #[test] + fn test_compute_file_hashes_empty_file() { + let file = NamedTempFile::new().expect("create temp file"); + let path = file.path(); + + let crc = compute_file_hash(path, HashAlgorithm::Crc32).expect("compute crc32"); + assert_eq!(crc, "00000000"); + + let md5 = compute_file_hash(path, HashAlgorithm::Md5).expect("compute md5"); + assert_eq!(md5, "d41d8cd98f00b204e9800998ecf8427e"); + + let sha1 = compute_file_hash(path, HashAlgorithm::Sha1).expect("compute sha1"); + assert_eq!(sha1, "da39a3ee5e6b4b0d3255bfef95601890afd80709"); + } + + #[test] + fn test_compute_file_hash_nonexistent_file() { + let non_existent = Path::new("/path/that/does/not/exist_12345.bin"); + let result = compute_file_hash(non_existent, HashAlgorithm::Crc32); + assert!(result.is_err()); + + let all_result = compute_all_file_hashes(non_existent); + assert!(all_result.is_err()); + } + + #[test] + fn test_compute_all_hashes_async() { + let mut file = NamedTempFile::new().expect("create temp file"); + file.write_all(b"The quick brown fox jumps over the lazy dog") + .expect("write"); + let path = file.path().to_path_buf(); + + let (tx, rx) = channel(); + + compute_all_hashes_async(path.clone(), tx); + + let mut received = 0; + let mut crc_val = None; + let mut md5_val = None; + let mut sha1_val = None; + + while received < 3 { + if let Ok(WorkerEvent::HashComplete { + path: p, + algorithm, + result, + }) = rx.recv() + { + assert_eq!(p, path); + let val = result.expect("hash success"); + match algorithm { + HashAlgorithm::Crc32 => crc_val = Some(val), + HashAlgorithm::Md5 => md5_val = Some(val), + HashAlgorithm::Sha1 => sha1_val = Some(val), + } + received += 1; + } + } + + assert_eq!(crc_val.as_deref(), Some("414FA339")); + assert_eq!(md5_val.as_deref(), Some("9e107d9d372bb6826bd81d3542a419d6")); + assert_eq!( + sha1_val.as_deref(), + Some("2fd4e1c67a2d28fced849ee1bb76e7391b93eb12") + ); + } +} From 8fb6c2e6609e64df2c693ad57a4bed23627b9ba4 Mon Sep 17 00:00:00 2001 From: Aliet Exposito Garcia Date: Thu, 20 Aug 2026 23:12:30 -0400 Subject: [PATCH 7/7] ci: consolidate test and build jobs to optimize resources The continuous integration workflow has been optimized by merging the build verification step directly into the testing phase. This consolidation eliminates the redundant build job, effectively saving three virtual machines for every single push or pull request. By combining these processes, the overall pipeline execution time and resource consumption are significantly reduced. Additionally, the release compilation step is now strictly limited to desktop executables, specifically `ppf_cli` and `ppf_gui`. This targeted approach prevents the system from pointlessly compiling the WebAssembly component as a native dynamic library. Restricting the build scope ensures that computational power is only spent on the necessary artifacts. Finally, the quality assurance for the WebAssembly crate has been improved. The previous basic check was upgraded to utilize the Clippy linter. --- .github/workflows/ci.yml | 46 +++++----------------------------------- 1 file changed, 5 insertions(+), 41 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08661c7..835781e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,11 +54,11 @@ jobs: - name: Run Clippy linter run: cargo clippy --workspace --all-targets --all-features -- -D warnings - - name: Check WASM crate target - run: cargo check -p ppf-wasm --target wasm32-unknown-unknown + - name: Run Clippy on WASM crate + run: cargo clippy -p ppf-wasm --target wasm32-unknown-unknown -- -D warnings test: - name: Test (${{ matrix.os }}) + name: Test & Build (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -96,41 +96,5 @@ jobs: - name: Run tests run: cargo test --workspace --all-targets --all-features --verbose - build-check: - name: Build Check (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install Linux GUI & system dependencies - if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - pkg-config \ - libgtk-3-dev \ - libxcb-render0-dev \ - libxcb-shape0-dev \ - libxcb-xfixes0-dev \ - libxkbcommon-dev \ - libxkbcommon-x11-dev \ - libgl1-mesa-dev \ - libegl1-mesa-dev \ - libwayland-dev \ - libssl-dev - - - name: Setup Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Setup Rust cache - uses: Swatinem/rust-cache@v2 - with: - key: ${{ matrix.os }} - - - name: Build release binaries - run: cargo build --workspace --release + - name: Build release desktop binaries + run: cargo build --release --bin ppf_cli --bin ppf_gui