diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0abe66a..835781e 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,8 +54,11 @@ jobs: - name: Run Clippy linter run: cargo clippy --workspace --all-targets --all-features -- -D warnings + - 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 @@ -92,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 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..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" @@ -1563,12 +1611,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", ] @@ -1665,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" @@ -2288,7 +2345,7 @@ dependencies = [ [[package]] name = "ppf-cli" -version = "0.2.0" +version = "0.3.1" dependencies = [ "anyhow", "clap", @@ -2298,22 +2355,39 @@ dependencies = [ [[package]] name = "ppf-core" -version = "0.2.0" +version = "0.3.1" dependencies = [ "memmap2", "rayon", + "serde", "tempfile", "thiserror 2.0.20", ] [[package]] name = "ppf-gui" -version = "0.2.0" +version = "0.3.1" dependencies = [ + "crc32fast", "eframe", "egui", + "image", + "md-5", "ppf-core", "rfd", + "sha1", + "tempfile", +] + +[[package]] +name = "ppf-wasm" +version = "0.3.1" +dependencies = [ + "js-sys", + "ppf-core", + "serde", + "serde-wasm-bindgen", + "wasm-bindgen", ] [[package]] @@ -2593,6 +2667,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" @@ -2624,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" @@ -2975,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" @@ -3091,32 +3193,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 +3242,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 +3399,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..0d88505 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.1" 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"] } @@ -25,6 +30,10 @@ 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"] } +md-5 = "0.10.6" +sha1 = "0.10.6" +crc32fast = "1.4.2" [profile.release] opt-level = 3 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. + +--- + +
+

Capturas de pantalla (haz clic en una miniatura para verla en tamaño completo):

+
+ + Crear Parche + + + Aplicar y Revertir + + + Información y DIZ + +
+
+ +--- + +## 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 | --- 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/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..0b8cf53 100644 --- a/crates/ppf-core/src/applier.rs +++ b/crates/ppf-core/src/applier.rs @@ -1,69 +1,56 @@ 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 file and returns its header metadata without modifying any binary. -pub fn inspect_patch(patch_path: impl AsRef) -> Result { - let ppf = PpfFile::open(patch_path)?; - Ok(ppf.header) +/// Operation mode when modifying binary data with a patch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PatchAction { + Apply, + Undo, } -/// 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). -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(patch_path, bin_path, false, on_start, progress_callback) +/// 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) } -/// 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). -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(patch_path, bin_path, true, on_start, progress_callback) +/// 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 { + PpfFile::open(patch_path).map(|file| file.header) } -fn process_patch( - patch_path: impl AsRef, - bin_path: impl AsRef, - is_undo: bool, +/// Applies or reverts a PPF patch on an in-memory mutable binary slice. +pub fn patch_slice( + patch_data: &[u8], + target_data: &mut [u8], + action: PatchAction, 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)?; + let is_undo = action == PatchAction::Undo; - 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 +59,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 +70,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 +102,107 @@ 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) +} + +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, + ) + } + + #[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")] +pub fn patch_file( + patch_path: impl AsRef, + bin_path: impl AsRef, + action: PatchAction, + on_start: Option<&dyn Fn(usize)>, + progress_callback: Option<&dyn Fn(usize)>, +) -> Result { + let ppf = PpfFile::open(patch_path)?; - Ok(ppf.header) + 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 = patch_slice( + &ppf.mmap, + &mut bin_mmap, + action, + on_start, + progress_callback, + )?; + + 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/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..51503b2 100644 --- a/crates/ppf-core/src/lib.rs +++ b/crates/ppf-core/src/lib.rs @@ -3,7 +3,19 @@ pub mod core; pub mod creator; pub mod parser; -pub use applier::{PatchInfo, apply_patch, inspect_patch, undo_patch}; +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, patch_file, 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..0421892 100644 --- a/crates/ppf-core/src/parser.rs +++ b/crates/ppf-core/src/parser.rs @@ -1,21 +1,42 @@ 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; +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 { + let file_len = bytes.len(); if file_len < 56 { return Err(PpfError::InvalidMagic( @@ -23,9 +44,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 +151,42 @@ 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] + } + + impl_record_accessors!(); +} + +/// 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,23 +195,41 @@ 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] + let range = self.data_start..self.data_end; + &self.mmap[range] } - pub fn records(&self) -> PpfRecords<'_> { - PpfRecords { - slice: self.payload_slice(), + impl_record_accessors!(); +} + +pub struct PpfRecords<'a> { + slice: &'a [u8], + cursor: usize, + version: PpfVersion, + has_undo: bool, +} + +impl<'a> PpfRecords<'a> { + #[inline] + 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, } } - 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; } @@ -165,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-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-gui/Cargo.toml b/crates/ppf-gui/Cargo.toml index b0479e4..1a623cf 100644 --- a/crates/ppf-gui/Cargo.toml +++ b/crates/ppf-gui/Cargo.toml @@ -14,3 +14,10 @@ ppf-core.workspace = true 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/assets/app_logo.png b/crates/ppf-gui/assets/app_logo.png new file mode 100644 index 0000000..e538106 Binary files /dev/null and b/crates/ppf-gui/assets/app_logo.png differ diff --git a/crates/ppf-gui/src/app.rs b/crates/ppf-gui/src/app.rs index 144506e..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, @@ -16,10 +28,22 @@ pub fn clean_path_str(path: &str) -> 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, @@ -39,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, @@ -57,13 +88,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(), @@ -80,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(), @@ -160,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 @@ -181,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 @@ -205,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 { @@ -215,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; @@ -293,6 +353,7 @@ impl PpfApp { .to_string_lossy() .to_string(); self.action_status = None; + self.reset_hashes_if_path_changed(); } } Tab::Create => { @@ -328,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 { @@ -335,19 +468,22 @@ 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)); } // 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..bd657fc 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, 640.0]) + .with_min_inner_size([680.0, 560.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/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/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/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.", diff --git a/crates/ppf-gui/src/worker.rs b/crates/ppf-gui/src/worker.rs index 669d5e5..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, @@ -119,6 +156,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 +241,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 +262,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()); @@ -294,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") + ); + } +} 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..d531602 --- /dev/null +++ b/crates/ppf-wasm/src/lib.rs @@ -0,0 +1,183 @@ +use ppf_core::{ + ImageType, PatchAction, PatchInfo, PpfCreatorOptions, create_patch_slice, inspect_patch_slice, + 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)); + } +} + +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 { + pub description: Option, + pub image_type: Option, + pub block_check: Option, + pub undo_data: Option, + 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 { + let info = inspect_patch_slice(patch_bytes).map_err(js_err)?; + serde_wasm_bindgen::to_value(&info).map_err(js_err) +} + +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)); + + 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 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, + action, + on_start_dyn, + on_progress_dyn, + ) + .map_err(js_err)?; + + serde_wasm_bindgen::to_value(&info).map_err(js_err) +} + +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) + } + }; +} + +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( + 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(js_err)? + }; + + 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(js_err)?; + + Ok(js_sys::Uint8Array::from(&output[..])) +} diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..3f280ee --- /dev/null +++ b/web/app.js @@ -0,0 +1,759 @@ +// 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, version, 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, version }); + } 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(); + } +}); + +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 new file mode 100644 index 0000000..098d894 --- /dev/null +++ b/web/index.html @@ -0,0 +1,475 @@ + + + + + + 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..0bfc4c7 --- /dev/null +++ b/web/styles.css @@ -0,0 +1,734 @@ +: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-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; + 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..673b50a --- /dev/null +++ b/web/worker.js @@ -0,0 +1,120 @@ +import init, { inspect_patch, apply_patch, undo_patch, create_patch, get_app_version } 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 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}`); + } + } catch (err) { + const errorMsg = err?.message || String(err); + self.postMessage({ id, type: 'complete', success: false, error: errorMsg }); + } +};