From 7467cc2aa4e6e17ae97e9600218b15364aa17e31 Mon Sep 17 00:00:00 2001 From: Jon-Luke Biddle Date: Mon, 3 Aug 2026 12:33:37 -0600 Subject: [PATCH 1/2] cleanup / fixes --- .cargo-husky/hooks/pre-commit | 22 - .gitattributes | 3 + .github/workflows/ci.yml | 27 +- .github/workflows/release.yml | 19 +- .gitignore | 4 + Cargo.lock | 218 +----- Cargo.toml | 10 +- README.md | 137 +++- fc2-systemdetection.ini.example | 9 + mise.toml | 3 + prek.toml | 39 + rust-toolchain.toml | 2 + src/gear/cpu.rs | 29 +- src/gear/graphics.rs | 4 +- src/lib.rs | 41 +- src/patches/config.rs | 213 ++++++ src/patches/hooks.rs | 62 -- src/patches/memory.rs | 471 ++++++++++-- src/patches/mod.rs | 1209 +++++++++++++++++++++++++------ src/patches/offline.rs | 212 ++++++ src/patches/sigscan.rs | 786 +++++++++++++++++--- tests/dunia-validation.rs | 35 + 22 files changed, 2820 insertions(+), 735 deletions(-) delete mode 100644 .cargo-husky/hooks/pre-commit create mode 100644 .gitattributes create mode 100644 fc2-systemdetection.ini.example create mode 100644 mise.toml create mode 100644 prek.toml create mode 100644 src/patches/config.rs delete mode 100644 src/patches/hooks.rs create mode 100644 src/patches/offline.rs create mode 100644 tests/dunia-validation.rs diff --git a/.cargo-husky/hooks/pre-commit b/.cargo-husky/hooks/pre-commit deleted file mode 100644 index b0ec64f..0000000 --- a/.cargo-husky/hooks/pre-commit +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/sh -# Pre-commit hook to check code formatting and lints - -# Check formatting -cargo fmt --check -if [ $? -ne 0 ]; then - echo "" - echo "❌ Code is not formatted. Run 'cargo fmt' to fix." - echo "" - exit 1 -fi - -# Run clippy -cargo clippy --all-targets --all-features -- -D warnings -if [ $? -ne 0 ]; then - echo "" - echo "❌ Clippy found issues. Fix the warnings above." - echo "" - exit 1 -fi - -exit 0 \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..195bd22 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Treat the carriage return in the repository's existing CRLF files as EOL, +# while retaining Git's standard trailing-whitespace checks. +* whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f7d77e..6aa66dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,28 +12,13 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Rust - uses: dtolnay/rust-toolchain@nightly - with: - targets: i686-pc-windows-msvc - components: rustfmt, clippy + - name: Install repository toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 - - name: Cache cargo - uses: actions/cache@v4 + - name: Prek checks + uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0 with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - restore-keys: ${{ runner.os }}-cargo- + prek-version: "0.4.11" - name: Build - run: cargo build --release --target i686-pc-windows-msvc - - - name: Check formatting - run: cargo fmt --check - - - name: Clippy - run: cargo clippy --target i686-pc-windows-msvc -- -D warnings - + run: cargo build --locked --release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5bdc631..a55a8bb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,23 +38,11 @@ jobs: git tag ${{ steps.tag.outputs.tag }} git push origin ${{ steps.tag.outputs.tag }} - - name: Install Rust - uses: dtolnay/rust-toolchain@nightly - with: - targets: i686-pc-windows-msvc - - - name: Cache cargo - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - restore-keys: ${{ runner.os }}-cargo- + - name: Install repository toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 - name: Build - run: cargo build --release --target i686-pc-windows-msvc + run: cargo build --locked --release - name: Create Release uses: softprops/action-gh-release@v2 @@ -62,4 +50,3 @@ jobs: tag_name: ${{ steps.tag.outputs.tag }} files: target/i686-pc-windows-msvc/release/systemdetection.dll generate_release_notes: true - diff --git a/.gitignore b/.gitignore index 5afaebc..249fe46 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,10 @@ *.exp *.lib +# Local runtime settings and diagnostics +fc2-systemdetection.ini +fc2-systemdetection.log + # IDA Pro files *.i64 *.id0 diff --git a/Cargo.lock b/Cargo.lock index 57ac69c..509f369 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,35 +2,19 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "cargo-husky" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b02b629252fe8ef6460461409564e2c21d0c8e77e0944f3d189ff06c4e932ad" - -[[package]] -name = "cc" -version = "1.2.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" -dependencies = [ - "find-msvc-tools", - "shlex", -] - [[package]] name = "cppvtable" version = "0.1.0" -source = "git+https://github.com/coconutbird/cppvtable.git#44f80c05fa7a2a7dc39da4bbc3a43c2a08828911" +source = "git+https://github.com/coconutbird/cppvtable.git#63bb299d3a26a5e54449bd4a713f5b6c2f5c64aa" dependencies = [ "cppvtable-macro", - "paste", + "pastey", ] [[package]] name = "cppvtable-macro" version = "0.1.0" -source = "git+https://github.com/coconutbird/cppvtable.git#44f80c05fa7a2a7dc39da4bbc3a43c2a08828911" +source = "git+https://github.com/coconutbird/cppvtable.git#63bb299d3a26a5e54449bd4a713f5b6c2f5c64aa" dependencies = [ "proc-macro2", "quote", @@ -38,74 +22,40 @@ dependencies = [ ] [[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "log" -version = "0.4.29" +name = "pastey" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" [[package]] -name = "minhook" -version = "0.9.0" +name = "portex" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "455e088cb1600ffa5f1524c9702c9eeb0ba3604e0f4932efa165b99973272a8d" -dependencies = [ - "cc", - "tracing", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "72d23fd9901d6350cac8c7ba110a5ce19ade8149daaab75d30c27d432572f0f5" [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.44" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - [[package]] name = "syn" -version = "2.0.116" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -116,42 +66,9 @@ dependencies = [ name = "systemdetection" version = "0.1.0" dependencies = [ - "cargo-husky", "cppvtable", - "minhook", - "windows", -] - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", + "portex", + "windows-sys", ] [[package]] @@ -160,73 +77,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "windows" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-link" version = "0.2.1" @@ -234,38 +84,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "windows-numerics" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" -dependencies = [ - "windows-core", - "windows-link", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-threading" -version = "0.2.1" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] diff --git a/Cargo.toml b/Cargo.toml index b2bca5e..736bbc5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,17 +3,20 @@ name = "systemdetection" version = "0.1.0" edition = "2024" description = "Far Cry 2 systemdetection.dll drop-in replacement" +license = "MIT" [lib] crate-type = ["cdylib", "rlib"] name = "systemdetection" [dependencies] -windows = { version = "0.62", features = [ +windows-sys = { version = "0.61", features = [ "Win32_Foundation", "Win32_System_Console", + "Win32_System_Diagnostics_Debug", "Win32_System_SystemServices", "Win32_System_LibraryLoader", + "Win32_System_ProcessStatus", "Win32_System_Threading", "Win32_System_SystemInformation", "Win32_System_Registry", @@ -21,12 +24,9 @@ windows = { version = "0.62", features = [ "Win32_UI_WindowsAndMessaging", ]} cppvtable = { git = "https://github.com/coconutbird/cppvtable.git" } -minhook = "0.9" +portex = "0.2" [profile.release] opt-level = 3 lto = true strip = true - -[dev-dependencies] -cargo-husky = { version = "1.5.0", features = ["precommit-hook", "user-hooks"] } diff --git a/README.md b/README.md index e63cc7b..b36cc52 100644 --- a/README.md +++ b/README.md @@ -2,51 +2,136 @@ [![CI](https://github.com/coconutbird/fc2-systemdetection/actions/workflows/ci.yml/badge.svg)](https://github.com/coconutbird/fc2-systemdetection/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/coconutbird/fc2-systemdetection)](https://github.com/coconutbird/fc2-systemdetection/releases/latest) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -Drop-in replacement for Far Cry 2's `systemdetection.dll` with bug fixes and quality-of-life improvements. +A 32-bit drop-in replacement for Far Cry 2's `systemdetection.dll`. It fixes +the original DLL's high-core-count crash and applies a small set of validated +runtime patches to `Dunia.dll`. ## Features -### CPU Crash Fix +The replacement CPU detector avoids the original crash on systems with 32 or +more logical processors. + +Runtime patches are resolved inside +[Portex](https://github.com/coconutbird/portex)-parsed PE sections. Every +signature-relative destination must be unique and contain either the exact +known original bytes or the exact replacement bytes before anything is +written. + +| Patch | Default | Behavior | +| --- | --- | --- | +| `jackal_tapes` | On | Fixes incorrect Southern-map Jackal tape recordings | +| `devmode_always_on` | On | Permanently bypasses the developer-command visibility check | +| `predecessor_tapes` | On | Unlocks the seven predecessor bonus missions | +| `machetes` | On | Unlocks the two bonus machete skins | +| `no_blinking_items` | Off | Disables three interactable/objective blinking effects | + +Steam, Retail/GOG, and Ubisoft Connect 1.03 file layouts are recognized. An +unknown build is never patched from an unguarded address: compatible +signature-based patches may resolve, while unsupported targets fail closed +and are reported. + +The DevMode patch is deliberately an always-on bypass. It does not add the +`devmodeon`/`devmodeoff` console commands from Far Cry 2 Multi Fixer. FOV and +launcher-only features such as affinity, FPS arguments, and intro skipping are +also not implemented here. -The original DLL crashes on systems with 32+ logical CPU cores due to a bug in the CPU topology detection code. This replacement fixes that issue. +## Installation -### Dunia Engine Patches +1. Download `systemdetection.dll` from + [Releases](https://github.com/coconutbird/fc2-systemdetection/releases). +2. Back up the original `bin/systemdetection.dll`. +3. Copy the replacement DLL into the game's `bin` directory. +4. Launch the game. -Runtime patches applied to `Dunia.dll` (similar to [Far Cry 2 Multi Fixer](https://github.com/FoxAhead/Far-Cry-2-Multi-Fixer)): +Common installation locations include: -| Patch | Description | -| --------------------- | ------------------------------------------------------------- | -| **Jackal Tapes Fix** | Fixes incorrect tape recordings in the Southern map | -| **DevMode Unlock** | Enables developer console commands | -| **Predecessor Tapes** | Unlocks 7 bonus missions (originally tied to Ubisoft account) | -| **Machetes Unlock** | Unlocks 2 bonus machete skins | +- Steam: `C:\Program Files (x86)\Steam\steamapps\common\Far Cry 2\bin` +- GOG: `C:\GOG Games\Far Cry 2\bin` +- Ubisoft Connect: + `C:\Program Files (x86)\Ubisoft\Ubisoft Game Launcher\games\Far Cry 2\bin` -## Installation +## Optional configuration -1. Download `systemdetection.dll` from [Releases](https://github.com/coconutbird/fc2-systemdetection/releases) -2. Navigate to your Far Cry 2 installation folder -3. Backup the original `bin/systemdetection.dll` -4. Copy the downloaded DLL to the `bin` folder -5. Launch the game +No configuration file is required. To change patch defaults, copy +[`fc2-systemdetection.ini.example`](fc2-systemdetection.ini.example) beside +the DLL and rename it to `fc2-systemdetection.ini`. -### Common Install Locations +```ini +[patches] +jackal_tapes = true +devmode_always_on = true +predecessor_tapes = true +machetes = true +no_blinking_items = false +``` -- **Steam**: `C:\Program Files (x86)\Steam\steamapps\common\Far Cry 2\bin` -- **GOG**: `C:\GOG Games\Far Cry 2\bin` -- **Ubisoft Connect**: `C:\Program Files (x86)\Ubisoft\Ubisoft Game Launcher\games\Far Cry 2\bin` +Values may be `true`/`false`, `yes`/`no`, `on`/`off`, or `1`/`0`. Disabled +patches are not scanned. + +On initialization, the DLL writes `fc2-systemdetection.log` beside itself. +The log records build detection, configuration warnings, and whether every +patch was applied, already applied, disabled, or rejected. ## Building -Requires Rust nightly and the 32-bit MSVC toolchain: +Install [rustup](https://rustup.rs/), then run the normal Cargo commands from +the repository root: -``` -rustup target add i686-pc-windows-msvc +```powershell +cargo test cargo build --release ``` -The DLL will be at `target/i686-pc-windows-msvc/release/systemdetection.dll` +`rust-toolchain.toml` selects nightly and provisions Clippy, rustfmt, and the +32-bit MSVC standard library. `.cargo/config.toml` selects +`i686-pc-windows-msvc` as the default build target. + +The DLL is written to +`target/i686-pc-windows-msvc/release/systemdetection.dll`. + +## Development checks + +[prek](https://prek.j178.dev/) replaces the former Cargo Husky hook. If you +use [mise](https://mise.jdx.dev/), the checked-in `mise.toml` tracks the latest +Rust and prek releases: + +```powershell +mise install +prek install +``` + +Otherwise, install prek using its normal package for your platform, then run +`prek install`. The hook runs rustfmt, Clippy with warnings denied, and the +test suite using the repository-selected Rust toolchain. It also checks line +endings, whitespace, merge markers, and TOML/YAML syntax. Run the same checks +across the worktree on demand with: + +```powershell +prek run --all-files +``` + +To validate a legally obtained `Dunia.dll` without launching the game, point +the ignored integration test at it: + +```powershell +$env:FC2_DUNIA_DLL = "C:\path\to\Dunia.dll" +cargo test --test dunia-validation -- --ignored --nocapture +``` + +The test parses the file with Portex, reconstructs its sections in disposable +heap memory, checks the real patch plans and known RVAs, and applies each patch +twice to that copy to verify idempotency. It never loads or executes the game +DLL. + +## Attribution + +The patch behavior and per-build address research were compared with +[FoxAhead's Far Cry 2 Multi Fixer](https://github.com/FoxAhead/Far-Cry-2-Multi-Fixer). +The independent Rust implementation in this repository does not incorporate +its Delphi source. See [`PATCH_COMPARISON.md`](PATCH_COMPARISON.md) for the +comparison, limitations, and provenance notes. ## License diff --git a/fc2-systemdetection.ini.example b/fc2-systemdetection.ini.example new file mode 100644 index 0000000..0f669af --- /dev/null +++ b/fc2-systemdetection.ini.example @@ -0,0 +1,9 @@ +# Copy this file beside systemdetection.dll as fc2-systemdetection.ini. +# If the file is absent, these same defaults are used. + +[patches] +jackal_tapes = true +devmode_always_on = true +predecessor_tapes = true +machetes = true +no_blinking_items = false diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..dc9ae46 --- /dev/null +++ b/mise.toml @@ -0,0 +1,3 @@ +[tools] +prek = "latest" +rust = "latest" diff --git a/prek.toml b/prek.toml new file mode 100644 index 0000000..43f4f4e --- /dev/null +++ b/prek.toml @@ -0,0 +1,39 @@ +minimum_prek_version = "0.4.11" + +[[repos]] +repo = "builtin" +hooks = [ + { id = "check-merge-conflict" }, + { id = "check-toml" }, + { id = "check-yaml" }, + { id = "end-of-file-fixer" }, + { id = "mixed-line-ending", args = ["--fix=no"] }, + { id = "trailing-whitespace" }, +] + +[[repos]] +repo = "local" + +[[repos.hooks]] +id = "cargo-fmt" +name = "cargo fmt" +language = "system" +entry = "cargo fmt -- --check" +pass_filenames = false +always_run = true + +[[repos.hooks]] +id = "cargo-clippy" +name = "cargo clippy" +language = "system" +entry = "cargo clippy --locked --all-targets --all-features -- -D warnings" +pass_filenames = false +always_run = true + +[[repos.hooks]] +id = "cargo-test" +name = "cargo test" +language = "system" +entry = "cargo test --locked" +pass_filenames = false +always_run = true diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 4ba39f0..44c354d 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,5 @@ [toolchain] channel = "nightly" +profile = "minimal" +components = ["clippy", "rustfmt"] targets = ["i686-pc-windows-msvc"] diff --git a/src/gear/cpu.rs b/src/gear/cpu.rs index 1cb3d80..e63465b 100644 --- a/src/gear/cpu.rs +++ b/src/gear/cpu.rs @@ -17,14 +17,14 @@ use cppvtable::proc::{cppvtable, cppvtable_impl}; use std::ffi::c_void; -use windows::Win32::System::Registry::{ - HKEY_LOCAL_MACHINE, KEY_READ, RegCloseKey, RegOpenKeyExA, RegQueryValueExA, +use windows_sys::Win32::Foundation::ERROR_SUCCESS; +use windows_sys::Win32::System::Registry::{ + HKEY, HKEY_LOCAL_MACHINE, KEY_READ, RegCloseKey, RegOpenKeyExA, RegQueryValueExA, }; -use windows::Win32::System::SystemInformation::{GetSystemInfo, SYSTEM_INFO}; -use windows::Win32::System::Threading::{ +use windows_sys::Win32::System::SystemInformation::{GetSystemInfo, SYSTEM_INFO}; +use windows_sys::Win32::System::Threading::{ GetCurrentProcess, GetCurrentThread, GetProcessAffinityMask, SetThreadAffinityMask, }; -use windows::core::PCSTR; /// GearBasicString - simplified string class at offset 0x28 of GearCPU #[repr(C)] @@ -150,26 +150,25 @@ impl GearCPU { unsafe { let key_path = b"HARDWARE\\DESCRIPTION\\System\\CentralProcessor\\0\0"; let value_name = b"~MHz\0"; - let mut hkey = std::mem::zeroed(); + let mut hkey: HKEY = std::ptr::null_mut(); if RegOpenKeyExA( HKEY_LOCAL_MACHINE, - PCSTR::from_raw(key_path.as_ptr()), - Some(0), + key_path.as_ptr(), + 0, KEY_READ, &mut hkey, - ) - .is_ok() + ) == ERROR_SUCCESS { let mut mhz: u32 = 0; let mut size = 4u32; let _ = RegQueryValueExA( hkey, - PCSTR::from_raw(value_name.as_ptr()), - None, - None, - Some(&mut mhz as *mut u32 as *mut u8), - Some(&mut size), + value_name.as_ptr(), + std::ptr::null(), + std::ptr::null_mut(), + &mut mhz as *mut u32 as *mut u8, + &mut size, ); let _ = RegCloseKey(hkey); if mhz > 0 { diff --git a/src/gear/graphics.rs b/src/gear/graphics.rs index 70a4705..f826d5a 100644 --- a/src/gear/graphics.rs +++ b/src/gear/graphics.rs @@ -7,8 +7,8 @@ use cppvtable::proc::cppvtable; use cppvtable::proc::cppvtable_impl; use std::ffi::c_void; -use windows::Win32::Foundation::RECT; -use windows::Win32::UI::WindowsAndMessaging::{ +use windows_sys::Win32::Foundation::RECT; +use windows_sys::Win32::UI::WindowsAndMessaging::{ GetDesktopWindow, GetSystemMetrics, GetWindowRect, SM_CMONITORS, }; diff --git a/src/lib.rs b/src/lib.rs index f09f79d..7a97c3f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,11 +5,16 @@ mod patches; pub use gear::GearHardware; pub use gear::GearScore; +pub use patches::{DuniaFileValidation, OfflinePatchState, ValidatedPatch, validate_dunia_file}; use std::ffi::c_void; use std::sync::OnceLock; -use windows::Win32::Foundation::HMODULE; -use windows::Win32::System::SystemServices::DLL_PROCESS_ATTACH; +use std::sync::atomic::{AtomicUsize, Ordering}; +use windows_sys::Win32::Foundation::HMODULE; +use windows_sys::Win32::System::SystemServices::DLL_PROCESS_ATTACH; + +static SELF_MODULE: AtomicUsize = AtomicUsize::new(0); +static RUNTIME_INITIALIZED: OnceLock<()> = OnceLock::new(); /// DLL entry point /// @@ -18,37 +23,41 @@ use windows::Win32::System::SystemServices::DLL_PROCESS_ATTACH; #[unsafe(no_mangle)] #[allow(non_snake_case)] pub unsafe extern "system" fn DllMain( - _hinst_dll: HMODULE, + hinst_dll: HMODULE, fdw_reason: u32, _lpv_reserved: *mut c_void, ) -> i32 { if fdw_reason == DLL_PROCESS_ATTACH { + SELF_MODULE.store(hinst_dll as usize, Ordering::Release); + } + 1 // TRUE +} + +/// Perform allocation, file access, PE parsing, and memory patching outside +/// `DllMain` and the Windows loader lock. +fn ensure_runtime_initialized() { + RUNTIME_INITIALIZED.get_or_init(|| { #[cfg(debug_assertions)] unsafe { init_console(); - } - #[cfg(debug_assertions)] - { println!("==========================================="); println!(" Far Cry 2 - systemdetection.dll replacement"); println!("==========================================="); } - // Apply Dunia.dll patches - patches::apply_patches(); - } - 1 // TRUE + let module = SELF_MODULE.load(Ordering::Acquire); + patches::initialize(module as HMODULE); + }); } /// Initialize console for debug output #[cfg(debug_assertions)] unsafe fn init_console() { - use windows::Win32::System::Console::{AllocConsole, SetConsoleTitleA}; - use windows::core::PCSTR; + use windows_sys::Win32::System::Console::{AllocConsole, SetConsoleTitleA}; unsafe { let _ = AllocConsole(); - let _ = SetConsoleTitleA(PCSTR::from_raw(c"FC2 SystemDetection".as_ptr() as *const u8)); + let _ = SetConsoleTitleA(c"FC2 SystemDetection".as_ptr() as *const u8); unsafe extern "C" { fn freopen(filename: *const i8, mode: *const i8, stream: *mut c_void) -> *mut c_void; @@ -63,7 +72,7 @@ unsafe fn init_console() { /// Global singleton for GearHardware static GEAR_HARDWARE: OnceLock> = OnceLock::new(); -/// Global singleton for GearScore +/// Global singleton for GearScore static GEAR_SCORE: OnceLock> = OnceLock::new(); /// Get the GearHardware singleton instance @@ -72,6 +81,8 @@ static GEAR_SCORE: OnceLock> = OnceLock::new(); /// This function is called from C code and returns a raw pointer #[unsafe(no_mangle)] pub unsafe extern "C" fn GetHardwareInstance() -> *mut GearHardware { + ensure_runtime_initialized(); + let hardware = GEAR_HARDWARE.get_or_init(|| { println!("systemdetection: Creating GearHardware instance"); Box::new(GearHardware::new()) @@ -86,6 +97,8 @@ pub unsafe extern "C" fn GetHardwareInstance() -> *mut GearHardware { /// This function is called from C code and returns a raw pointer #[unsafe(no_mangle)] pub unsafe extern "C" fn GetScoreInstance() -> *mut GearScore { + ensure_runtime_initialized(); + let score = GEAR_SCORE.get_or_init(|| { println!("systemdetection: Creating GearScore instance"); Box::new(GearScore::new()) diff --git a/src/patches/config.rs b/src/patches/config.rs new file mode 100644 index 0000000..1397bec --- /dev/null +++ b/src/patches/config.rs @@ -0,0 +1,213 @@ +//! Runtime patch configuration. + +use std::fs; +use std::path::PathBuf; + +/// Runtime switches for independently applying Dunia patches. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PatchConfig { + pub jackal_tapes: bool, + pub devmode_always_on: bool, + pub predecessor_tapes: bool, + pub machetes: bool, + pub no_blinking_items: bool, +} + +impl Default for PatchConfig { + fn default() -> Self { + Self { + jackal_tapes: true, + devmode_always_on: true, + predecessor_tapes: true, + machetes: true, + no_blinking_items: false, + } + } +} + +#[derive(Debug)] +pub struct ConfigLoad { + pub config: PatchConfig, + pub path: PathBuf, + pub source: ConfigSource, + pub warnings: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ConfigSource { + Loaded, + MissingUsingDefaults, + ReadFailedUsingDefaults(String), +} + +impl ConfigLoad { + pub fn load(path: PathBuf) -> Self { + match fs::read_to_string(&path) { + Ok(contents) => { + let (config, warnings) = parse(&contents); + Self { + config, + path, + source: ConfigSource::Loaded, + warnings, + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Self { + config: PatchConfig::default(), + path, + source: ConfigSource::MissingUsingDefaults, + warnings: Vec::new(), + }, + Err(error) => Self { + config: PatchConfig::default(), + path, + source: ConfigSource::ReadFailedUsingDefaults(error.to_string()), + warnings: Vec::new(), + }, + } + } +} + +fn parse(contents: &str) -> (PatchConfig, Vec) { + let mut config = PatchConfig::default(); + let mut warnings = Vec::new(); + let mut in_patches_section = true; + + for (index, raw_line) in contents.trim_start_matches('\u{feff}').lines().enumerate() { + let line_number = index + 1; + let line = raw_line.trim(); + + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + + if line.starts_with('[') && line.ends_with(']') { + let section = line[1..line.len() - 1].trim(); + in_patches_section = section.eq_ignore_ascii_case("patches"); + continue; + } + + if !in_patches_section { + continue; + } + + let Some((raw_key, raw_value)) = line.split_once('=') else { + warnings.push(format!("line {line_number}: expected key = value")); + continue; + }; + + let key = raw_key.trim().to_ascii_lowercase(); + let value = raw_value + .split_once(['#', ';']) + .map_or(raw_value, |(value, _)| value) + .trim(); + + let Some(enabled) = parse_bool(value) else { + warnings.push(format!( + "line {line_number}: invalid Boolean value {value:?} for {key}" + )); + continue; + }; + + match key.as_str() { + "jackal_tapes" => config.jackal_tapes = enabled, + "devmode_always_on" => config.devmode_always_on = enabled, + "predecessor_tapes" => config.predecessor_tapes = enabled, + "machetes" => config.machetes = enabled, + "no_blinking_items" => config.no_blinking_items = enabled, + _ => warnings.push(format!("line {line_number}: unknown patch option {key:?}")), + } + } + + (config, warnings) +} + +fn parse_bool(value: &str) -> Option { + match value.to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Some(true), + "0" | "false" | "no" | "off" => Some(false), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn defaults_preserve_existing_runtime_behavior() { + assert_eq!( + PatchConfig::default(), + PatchConfig { + jackal_tapes: true, + devmode_always_on: true, + predecessor_tapes: true, + machetes: true, + no_blinking_items: false, + } + ); + } + + #[test] + fn parses_patch_section_and_common_boolean_spellings() { + let (config, warnings) = parse( + "\u{feff}; comment + [patches] + jackal_tapes = off + devmode_always_on = 0 + predecessor_tapes = YES + machetes = true ; inline comment + no_blinking_items = on # inline comment + [ignored] + jackal_tapes = true", + ); + + assert!(warnings.is_empty()); + assert_eq!( + config, + PatchConfig { + jackal_tapes: false, + devmode_always_on: false, + predecessor_tapes: true, + machetes: true, + no_blinking_items: true, + } + ); + } + + #[test] + fn invalid_lines_keep_defaults_and_return_warnings() { + let (config, warnings) = parse( + "[patches] + jackal_tapes = perhaps + unknown = true + malformed", + ); + + assert!(config.jackal_tapes); + assert_eq!(warnings.len(), 3); + } + + #[test] + fn loads_an_optional_file_and_reports_its_source() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "fc2-systemdetection-{}-{unique}.ini", + std::process::id() + )); + fs::write(&path, "[patches]\njackal_tapes = false\n").unwrap(); + + let loaded = ConfigLoad::load(path.clone()); + let cleanup = fs::remove_file(&path); + + cleanup.unwrap(); + assert_eq!(loaded.path, path); + assert_eq!(loaded.source, ConfigSource::Loaded); + assert!(!loaded.config.jackal_tapes); + assert!(loaded.warnings.is_empty()); + } +} diff --git a/src/patches/hooks.rs b/src/patches/hooks.rs deleted file mode 100644 index 09b4584..0000000 --- a/src/patches/hooks.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! Function hooks for Dunia.dll using MinHook -//! -//! This module provides detouring/hooking functionality to intercept -//! game functions and inject custom behavior. -//! -//! Currently no hooks are active - this is infrastructure for future use. - -#[allow(unused_imports)] -use crate::patches::sigscan::{Pattern, scan_module}; -#[allow(unused_imports)] -use minhook::{MH_STATUS, MinHook}; -#[allow(unused_imports)] -use std::ffi::c_void; -#[allow(unused_imports)] -use std::sync::OnceLock; - -/// Signature definitions for hookable functions -#[allow(dead_code)] -mod signatures { - // CFCXOptionGamePage::InitOptions - Game options page initialization - // sub esp, 160h | push ebx | push ebp | push esi | push edi | xor ebx, ebx - pub const INIT_OPTIONS: &str = "81 EC 60 01 00 00 53 55 56 57 33 DB"; - - // CreateSliderOption - Creates a slider widget - // mov eax, [esp+1Ch] | push ebx | push esi | mov esi, [esp+0Ch] - pub const CREATE_SLIDER: &str = "8B 44 24 1C 53 56 8B 74 24 0C"; -} - -/// Cached function addresses found via signature scanning -#[allow(dead_code)] -pub struct HookAddresses { - // Reserved for future hooks -} - -impl HookAddresses { - /// Scan for all hook target signatures - pub fn scan(_base: usize) -> Self { - // No hooks currently active - Self {} - } -} - -/// Install all function hooks -pub fn install_hooks(_addrs: &HookAddresses) -> Result<(), MH_STATUS> { - // No hooks currently active - infrastructure ready for future use - - #[cfg(debug_assertions)] - println!("hooks: No hooks to install (infrastructure ready)"); - - Ok(()) -} - -/// Cleanup hooks on unload -#[allow(dead_code)] -pub fn remove_hooks() { - #[cfg(debug_assertions)] - println!("hooks: Removing all hooks"); - - unsafe { - let _ = MinHook::disable_all_hooks(); - } -} diff --git a/src/patches/memory.rs b/src/patches/memory.rs index d1ea380..da24cc2 100644 --- a/src/patches/memory.rs +++ b/src/patches/memory.rs @@ -1,71 +1,444 @@ -//! Memory patching utilities +//! Checked memory reads and writes for runtime patches. use std::ffi::c_void; -use windows::Win32::System::Memory::{ - PAGE_EXECUTE_READWRITE, PAGE_PROTECTION_FLAGS, VirtualProtect, +use std::fmt; +use std::mem::size_of; + +use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache; +use windows_sys::Win32::System::Memory::{ + MEM_COMMIT, MEMORY_BASIC_INFORMATION, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, + PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_PROTECTION_FLAGS, PAGE_READONLY, PAGE_READWRITE, + PAGE_WRITECOPY, VirtualProtect, VirtualQuery, }; +use windows_sys::Win32::System::Threading::GetCurrentProcess; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WriteState { + Applied, + AlreadyApplied, +} + +#[derive(Debug)] +pub enum MemoryError { + EmptyReplacement, + LengthMismatch { + expected: usize, + replacement: usize, + }, + AddressOverflow { + address: usize, + length: usize, + }, + QueryFailed { + address: usize, + }, + NotReadable { + address: usize, + protection: u32, + }, + NotCommitted { + address: usize, + }, + CrossesProtectionRegion { + address: usize, + length: usize, + }, + UnexpectedBytes { + address: usize, + expected: Vec, + replacement: Vec, + actual: Vec, + }, + Protect { + address: usize, + error: String, + }, + RestoreProtection { + address: usize, + error: String, + }, + FlushInstructionCache { + address: usize, + error: String, + }, + WriteVerification { + address: usize, + expected: Vec, + actual: Vec, + }, +} -/// Write bytes to a memory address, handling page protection -pub fn write_bytes(address: usize, bytes: &[u8]) -> bool { - if bytes.is_empty() { - return true; +impl fmt::Display for MemoryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyReplacement => write!(formatter, "replacement byte sequence is empty"), + Self::LengthMismatch { + expected, + replacement, + } => write!( + formatter, + "expected-byte length {expected} differs from replacement length {replacement}" + ), + Self::AddressOverflow { address, length } => write!( + formatter, + "memory range 0x{address:08X} + {length} overflows" + ), + Self::QueryFailed { address } => { + write!(formatter, "VirtualQuery failed at 0x{address:08X}") + } + Self::NotReadable { + address, + protection, + } => write!( + formatter, + "memory at 0x{address:08X} is not readable (protection 0x{protection:X})" + ), + Self::NotCommitted { address } => { + write!(formatter, "memory at 0x{address:08X} is not committed") + } + Self::CrossesProtectionRegion { address, length } => write!( + formatter, + "write range 0x{address:08X} + {length} crosses a virtual-memory protection region" + ), + Self::UnexpectedBytes { + address, + expected, + replacement, + actual, + } => write!( + formatter, + "unexpected bytes at 0x{address:08X}: expected {} or already-patched {}, found {}", + format_bytes(expected), + format_bytes(replacement), + format_bytes(actual) + ), + Self::Protect { address, error } => write!( + formatter, + "VirtualProtect failed at 0x{address:08X}: {error}" + ), + Self::RestoreProtection { address, error } => write!( + formatter, + "restoring memory protection failed at 0x{address:08X}: {error}" + ), + Self::FlushInstructionCache { address, error } => write!( + formatter, + "FlushInstructionCache failed at 0x{address:08X}: {error}" + ), + Self::WriteVerification { + address, + expected, + actual, + } => write!( + formatter, + "write verification failed at 0x{address:08X}: expected {}, found {}", + format_bytes(expected), + format_bytes(actual) + ), + } } +} + +impl std::error::Error for MemoryError {} + +/// Read bytes after validating every virtual-memory region in the range. +pub fn read_bytes(address: usize, length: usize) -> Result, MemoryError> { + validate_readable_range(address, length)?; - let ptr = address as *mut u8; - let len = bytes.len(); - let mut old_protect = PAGE_PROTECTION_FLAGS(0); + let mut bytes = vec![0; length]; + if length != 0 { + unsafe { + std::ptr::copy_nonoverlapping(address as *const u8, bytes.as_mut_ptr(), length); + } + } + Ok(bytes) +} + +/// Validate that a destination contains either its original or replacement +/// bytes without modifying memory. +pub fn validate_bytes( + address: usize, + expected: &[u8], + replacement: &[u8], +) -> Result { + validate_lengths(expected, replacement)?; + classify_bytes( + address, + expected, + replacement, + read_bytes(address, expected.len())?, + ) +} + +/// Apply an idempotent, expected-byte-guarded memory write. +pub fn write_checked( + address: usize, + expected: &[u8], + replacement: &[u8], +) -> Result { + validate_lengths(expected, replacement)?; + + match classify_bytes( + address, + expected, + replacement, + read_bytes(address, expected.len())?, + )? { + WriteState::AlreadyApplied => return Ok(WriteState::AlreadyApplied), + WriteState::Applied => {} + } + + let pointer = address as *mut u8; + let length = replacement.len(); + let writable_protection = writable_protection_for_single_region(address, length)?; + let mut old_protection = 0; + + let protect_result = unsafe { + VirtualProtect( + pointer as *const c_void, + length, + writable_protection, + &mut old_protection, + ) + }; + if protect_result == 0 { + return Err(MemoryError::Protect { + address, + error: std::io::Error::last_os_error().to_string(), + }); + } unsafe { - // Make memory writable - let protect_result = VirtualProtect( - ptr as *const c_void, - len, - PAGE_EXECUTE_READWRITE, - &mut old_protect, - ); + std::ptr::copy_nonoverlapping(replacement.as_ptr(), pointer, length); + } + + let mut ignored_protection = 0; + let restore_result = unsafe { + VirtualProtect( + pointer as *const c_void, + length, + old_protection, + &mut ignored_protection, + ) + }; + let restore_error = (restore_result == 0).then(|| std::io::Error::last_os_error().to_string()); + let flush_result = + unsafe { FlushInstructionCache(GetCurrentProcess(), pointer as *const c_void, length) }; + let flush_error = (flush_result == 0).then(|| std::io::Error::last_os_error().to_string()); + + if let Some(error) = restore_error { + return Err(MemoryError::RestoreProtection { address, error }); + } + if let Some(error) = flush_error { + return Err(MemoryError::FlushInstructionCache { address, error }); + } + + let actual = read_bytes(address, length)?; + if actual != replacement { + return Err(MemoryError::WriteVerification { + address, + expected: replacement.to_vec(), + actual, + }); + } + + Ok(WriteState::Applied) +} + +fn validate_lengths(expected: &[u8], replacement: &[u8]) -> Result<(), MemoryError> { + if replacement.is_empty() { + return Err(MemoryError::EmptyReplacement); + } + if expected.len() != replacement.len() { + return Err(MemoryError::LengthMismatch { + expected: expected.len(), + replacement: replacement.len(), + }); + } + Ok(()) +} - if protect_result.is_err() { - #[cfg(debug_assertions)] - println!("patches: VirtualProtect failed for 0x{:08X}", address); - return false; +fn classify_bytes( + address: usize, + expected: &[u8], + replacement: &[u8], + actual: Vec, +) -> Result { + if actual == replacement { + Ok(WriteState::AlreadyApplied) + } else if actual == expected { + // `Applied` here means a write is required. + Ok(WriteState::Applied) + } else { + Err(MemoryError::UnexpectedBytes { + address, + expected: expected.to_vec(), + replacement: replacement.to_vec(), + actual, + }) + } +} + +fn validate_readable_range(address: usize, length: usize) -> Result<(), MemoryError> { + if length == 0 { + return Ok(()); + } + + let end = address + .checked_add(length) + .ok_or(MemoryError::AddressOverflow { address, length })?; + let mut cursor = address; + + while cursor < end { + let mut information = MEMORY_BASIC_INFORMATION::default(); + let queried = unsafe { + VirtualQuery( + cursor as *const _, + &mut information, + size_of::(), + ) + }; + if queried == 0 { + return Err(MemoryError::QueryFailed { address: cursor }); + } + if information.State != MEM_COMMIT { + return Err(MemoryError::NotCommitted { address: cursor }); } - // Write the bytes - std::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr, len); + let protection = information.Protect; + if protection & PAGE_GUARD != 0 || !is_readable_protection(protection) { + return Err(MemoryError::NotReadable { + address: cursor, + protection, + }); + } - // Restore original protection - let _ = VirtualProtect(ptr as *const c_void, len, old_protect, &mut old_protect); + let region_end = (information.BaseAddress as usize) + .checked_add(information.RegionSize) + .ok_or(MemoryError::AddressOverflow { + address: information.BaseAddress as usize, + length: information.RegionSize, + })?; + if region_end <= cursor { + return Err(MemoryError::QueryFailed { address: cursor }); + } + cursor = region_end.min(end); + } + + Ok(()) +} + +fn writable_protection_for_single_region( + address: usize, + length: usize, +) -> Result { + let end = address + .checked_add(length) + .ok_or(MemoryError::AddressOverflow { address, length })?; + let mut information = MEMORY_BASIC_INFORMATION::default(); + let queried = unsafe { + VirtualQuery( + address as *const _, + &mut information, + size_of::(), + ) + }; + if queried == 0 { + return Err(MemoryError::QueryFailed { address }); + } + if information.State != MEM_COMMIT { + return Err(MemoryError::NotCommitted { address }); } - #[cfg(debug_assertions)] - println!("patches: Wrote {} bytes to 0x{:08X}", len, address); + let protection = information.Protect; + if protection & PAGE_GUARD != 0 || !is_readable_protection(protection) { + return Err(MemoryError::NotReadable { + address, + protection, + }); + } - true + let region_end = (information.BaseAddress as usize) + .checked_add(information.RegionSize) + .ok_or(MemoryError::AddressOverflow { + address: information.BaseAddress as usize, + length: information.RegionSize, + })?; + if end > region_end { + return Err(MemoryError::CrossesProtectionRegion { address, length }); + } + + let base = protection & 0xFF; + if base == PAGE_EXECUTE_READ || base == PAGE_EXECUTE_READWRITE || base == PAGE_EXECUTE_WRITECOPY + { + Ok(PAGE_EXECUTE_READWRITE) + } else { + Ok(PAGE_READWRITE) + } } -/// Write a relative call instruction (E8 xx xx xx xx) -#[allow(dead_code)] -pub fn write_call(from: usize, to: usize) -> bool { - let relative = (to as isize) - (from as isize) - 5; - let mut bytes = [0u8; 5]; - bytes[0] = 0xE8; // CALL opcode - bytes[1..5].copy_from_slice(&(relative as i32).to_le_bytes()); - write_bytes(from, &bytes) +fn is_readable_protection(protection: PAGE_PROTECTION_FLAGS) -> bool { + let base = protection & 0xFF; + base == PAGE_READONLY + || base == PAGE_READWRITE + || base == PAGE_WRITECOPY + || base == PAGE_EXECUTE_READ + || base == PAGE_EXECUTE_READWRITE + || base == PAGE_EXECUTE_WRITECOPY } -/// Write a relative jump instruction (E9 xx xx xx xx) -#[allow(dead_code)] -pub fn write_jump(from: usize, to: usize) -> bool { - let relative = (to as isize) - (from as isize) - 5; - let mut bytes = [0u8; 5]; - bytes[0] = 0xE9; // JMP opcode - bytes[1..5].copy_from_slice(&(relative as i32).to_le_bytes()); - write_bytes(from, &bytes) +fn format_bytes(bytes: &[u8]) -> String { + bytes + .iter() + .map(|byte| format!("{byte:02X}")) + .collect::>() + .join(" ") } -/// Write NOP instructions -#[allow(dead_code)] -pub fn write_nops(address: usize, count: usize) -> bool { - let nops = vec![0x90u8; count]; - write_bytes(address, &nops) +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_original_and_patched_bytes() { + assert_eq!( + classify_bytes(0x1000, &[0x74, 0x16], &[0xEB, 0x0E], vec![0x74, 0x16]).unwrap(), + WriteState::Applied + ); + assert_eq!( + classify_bytes(0x1000, &[0x74, 0x16], &[0xEB, 0x0E], vec![0xEB, 0x0E]).unwrap(), + WriteState::AlreadyApplied + ); + } + + #[test] + fn rejects_unexpected_bytes() { + assert!( + classify_bytes(0x1000, &[0x0A], &[0x14], vec![0x24]) + .unwrap_err() + .to_string() + .contains("found 24") + ); + } + + #[test] + fn rejects_empty_and_mismatched_replacements() { + assert!(validate_lengths(&[], &[]).is_err()); + assert!(validate_lengths(&[1], &[1, 2]).is_err()); + } + + #[test] + fn writes_and_then_recognizes_an_idempotent_patch() { + let mut memory = vec![0x0A]; + let address = memory.as_mut_ptr() as usize; + + assert_eq!( + write_checked(address, &[0x0A], &[0x14]).unwrap(), + WriteState::Applied + ); + assert_eq!(memory, [0x14]); + assert_eq!( + write_checked(address, &[0x0A], &[0x14]).unwrap(), + WriteState::AlreadyApplied + ); + } } diff --git a/src/patches/mod.rs b/src/patches/mod.rs index 714fc83..07b5b7c 100644 --- a/src/patches/mod.rs +++ b/src/patches/mod.rs @@ -1,273 +1,1036 @@ -//! Dunia.dll runtime patches +//! Validated runtime patches for `Dunia.dll`. //! -//! This module applies fixes to Dunia.dll at runtime, similar to Far Cry 2 Multi Fixer. -//! Since systemdetection.dll is loaded by Dunia.dll before initialization completes, -//! we can safely patch Dunia.dll from here. -//! -//! All signature scans are performed upfront before any patches are applied, -//! ensuring that patches don't corrupt signatures we haven't scanned yet. +//! Enabled patches are resolved and byte-validated before any write occurs. +//! Signature searches are restricted to Portex-parsed PE sections, and every +//! target accepts only its known original or already-patched bytes. -mod hooks; +mod config; mod memory; +mod offline; mod sigscan; -use memory::write_bytes; -use sigscan::{Pattern, scan_module}; -use windows::Win32::System::LibraryLoader::GetModuleHandleA; -use windows::core::PCSTR; +pub use offline::{DuniaFileValidation, OfflinePatchState, ValidatedPatch, validate_dunia_file}; + +use std::ffi::OsString; +use std::fmt; +use std::os::windows::ffi::OsStringExt; +use std::path::PathBuf; + +use config::{ConfigLoad, ConfigSource, PatchConfig}; +use memory::{MemoryError, WriteState, validate_bytes, write_checked}; +use sigscan::{ModuleImage, Pattern, ScanError, ScanScope}; +use windows_sys::Win32::Foundation::HMODULE; +use windows_sys::Win32::System::Diagnostics::Debug::OutputDebugStringW; +use windows_sys::Win32::System::LibraryLoader::{GetModuleFileNameW, GetModuleHandleA}; + +const STEAM_DUNIA_FILE_SIZE: u64 = 20_183_176; +const RETAIL_DUNIA_FILE_SIZE: u64 = 19_412_104; +const UBISOFT_DUNIA_FILE_SIZE: u64 = 20_184_168; + +// FoxAhead's Retail/GOG 1.03 target is VA 0x10048987 at preferred image base +// 0x10000000. Use the RVA so relocation/ASLR does not affect the target. +const RETAIL_PREDECESSOR_RVA: usize = 0x0004_8987; + +const STEAM_NO_BLINK_RVAS: [usize; 3] = [0x00E4_9D08, 0x00E1_15B8, 0x00E9_33B3]; +const RETAIL_NO_BLINK_RVAS: [usize; 3] = [0x00DC_1A94, 0x00D8_B3B0, 0x00E0_AFC2]; -/// Signature definitions mod signatures { - // Jackal Tapes: cmp byte ptr [esi+74h], 0 | jnz short | cmp ecx, edx | jnz pub const JACKAL_TAPES: &str = "80 7E 74 00 75 ?? 3B CA 75"; - // DevMode: cmp byte ptr [ecx+offset], 0 | mov edx, [esp+arg] | jnz - pub const DEVMODE: &str = "80 79 ?? 00 8B 54 24 ?? 75"; + // The final opcode is a wildcard so an already-patched JMP remains + // discoverable. Destination-byte validation accepts only JNZ or JMP. + pub const DEVMODE_ALWAYS_ON: &str = "80 79 ?? 00 8B 54 24 ?? ??"; - // Predecessor Tapes: mov ecx, [ecx+0Ch] | test ecx, ecx | jz - // Function checks online service pointer, patch makes it always skip the null check - pub const PREDECESSOR_TAPES: &str = "8B 49 0C 85 C9 74 ?? 8B 44 24"; + // Both branch bytes are wildcards for idempotent discovery. + pub const PREDECESSOR_TAPES_STEAM: &str = "8B 49 0C 85 C9 ?? ?? 8B 44 24"; - // Machetes: sub esp, ?? | push ebx | lea eax, [esp+??] | push eax | push - // This is the prologue of IsMachetesUnlocked function + // This prologue is qualified by checking the known return instruction at + // +0x69. Multiple raw prologue matches are safe because exactly one + // validated destination is required. pub const MACHETES: &str = "83 EC ?? 53 8D 44 24 ?? 50 68"; - // No Blinking Items: String literals to corrupt - pub const MESH_HIGHLIGHT: &str = "4D 65 73 68 5F 48 69 67 68 6C 69 67 68 74"; // "Mesh_Highlight" - pub const ARCH_BLINK: &str = "61 72 63 68 42 6C 69 6E 6B"; // "archBlink" - pub const SAVE_DISK: &str = "67 61 64 67 65 74 73 2E 4F 62 6A 65 63 74 69 76 65 49 63 6F 6E 73 2E 53 61 76 65 44 69 73 6B"; // "gadgets.ObjectiveIcons.SaveDisk" -} - -/// Cached addresses from signature scans (found before patching) -#[allow(dead_code)] -struct PatchAddresses { - jackal_tapes: Option, - devmode: Option, - predecessor_tapes: Option, - machetes: Option, - mesh_highlight: Option, - arch_blink: Option, - save_disk: Option, -} - -impl PatchAddresses { - /// Scan for all signatures upfront, before any patches are applied - fn scan(base: usize) -> Self { - #[cfg(debug_assertions)] - println!("patches: Scanning for all signatures..."); - - let jackal_tapes = - Pattern::parse(signatures::JACKAL_TAPES).and_then(|p| scan_module(base, &p)); - let devmode = Pattern::parse(signatures::DEVMODE).and_then(|p| scan_module(base, &p)); - let predecessor_tapes = - Pattern::parse(signatures::PREDECESSOR_TAPES).and_then(|p| scan_module(base, &p)); - let machetes = Pattern::parse(signatures::MACHETES).and_then(|p| scan_module(base, &p)); - let mesh_highlight = - Pattern::parse(signatures::MESH_HIGHLIGHT).and_then(|p| scan_module(base, &p)); - let arch_blink = Pattern::parse(signatures::ARCH_BLINK).and_then(|p| scan_module(base, &p)); - let save_disk = Pattern::parse(signatures::SAVE_DISK).and_then(|p| scan_module(base, &p)); - - #[cfg(debug_assertions)] - { - println!( - "patches: Jackal Tapes: {:?}", - jackal_tapes.map(|a| format!("0x{:08X}", a)) - ); - println!( - "patches: DevMode: {:?}", - devmode.map(|a| format!("0x{:08X}", a)) - ); - println!( - "patches: Predecessor Tapes: {:?}", - predecessor_tapes.map(|a| format!("0x{:08X}", a)) - ); - println!( - "patches: Machetes: {:?}", - machetes.map(|a| format!("0x{:08X}", a)) - ); - println!( - "patches: Mesh_Highlight: {:?}", - mesh_highlight.map(|a| format!("0x{:08X}", a)) - ); - println!( - "patches: archBlink: {:?}", - arch_blink.map(|a| format!("0x{:08X}", a)) - ); - println!( - "patches: SaveDisk: {:?}", - save_disk.map(|a| format!("0x{:08X}", a)) + // Mutation bytes are wildcards so already-patched strings remain + // discoverable and can be reported as idempotently applied. + pub const MESH_HIGHLIGHT: &str = "4D 65 73 68 ?? 48 69 67 68 6C 69 67 68 74"; + pub const ARCH_BLINK: &str = "61 72 63 68 42 6C 69 6E ??"; + pub const SAVE_DISK: &str = "67 61 64 67 65 74 73 2E 4F 62 6A 65 63 74 69 76 65 49 63 6F 6E 73 2E 53 61 76 65 44 69 73 6B ??"; +} + +/// Apply the configured patch set once runtime initialization is outside the +/// Windows loader lock. +pub fn initialize(self_module: HMODULE) { + let (config, configuration, configuration_warnings) = load_patch_config(self_module); + let mut report = run(config); + report.configuration = Some(configuration); + report.warnings = configuration_warnings; + emit_report(self_module, &report.to_string()); +} + +fn load_patch_config(self_module: HMODULE) -> (PatchConfig, String, Vec) { + let dll_path = match module_path(self_module) { + Ok(path) => path, + Err(error) => { + return ( + PatchConfig::default(), + format!("DLL path unavailable; using defaults ({error})"), + Vec::new(), ); } + }; + let Some(directory) = dll_path.parent() else { + return ( + PatchConfig::default(), + "DLL directory unavailable; using defaults".to_owned(), + Vec::new(), + ); + }; - Self { - jackal_tapes, - devmode, - predecessor_tapes, - machetes, - mesh_highlight, - arch_blink, - save_disk, + let ConfigLoad { + config, + path, + source, + warnings, + } = ConfigLoad::load(directory.join("fc2-systemdetection.ini")); + let description = match source { + ConfigSource::Loaded => format!("loaded {}", path.display()), + ConfigSource::MissingUsingDefaults => { + format!("{} not found; using defaults", path.display()) } - } + ConfigSource::ReadFailedUsingDefaults(error) => { + format!( + "could not read {}; using defaults ({error})", + path.display() + ) + } + }; + + (config, description, warnings) } -/// Apply all enabled patches to Dunia.dll -pub fn apply_patches() { - // Get Dunia.dll base address - let dunia_base = - unsafe { GetModuleHandleA(PCSTR::from_raw(c"Dunia.dll".as_ptr() as *const u8)) }; +fn run(config: PatchConfig) -> PatchReport { + let mut report = PatchReport::new(); - let Ok(dunia) = dunia_base else { - #[cfg(debug_assertions)] - println!("patches: Dunia.dll not loaded, skipping patches"); - return; + let dunia = unsafe { GetModuleHandleA(c"Dunia.dll".as_ptr() as *const u8) }; + if dunia.is_null() { + report.fail_enabled( + &config, + format!( + "Dunia.dll is not loaded: {}", + std::io::Error::last_os_error() + ), + ); + return report; + } + + let image = match unsafe { ModuleImage::from_module(dunia) } { + Ok(image) => image, + Err(error) => { + report.fail_enabled(&config, format!("could not inspect Dunia.dll: {error}")); + return report; + } }; - if dunia.is_invalid() { - #[cfg(debug_assertions)] - println!("patches: Dunia.dll handle invalid, skipping patches"); - return; + report.image = Some(format!( + "base=0x{:08X}, SizeOfImage=0x{:X}", + image.base(), + image.size() + )); + + let build = detect_build(dunia); + report.build = Some(build.to_string()); + + // Build and validate every enabled plan before applying any plan. This + // prevents one patch from changing a later patch's signature or guard. + let prepared = vec![ + prepare_patch("jackal_tapes", config.jackal_tapes, || { + plan_jackal_tapes(&image) + }), + prepare_patch("devmode_always_on", config.devmode_always_on, || { + plan_devmode_always_on(&image) + }), + prepare_patch("predecessor_tapes", config.predecessor_tapes, || { + plan_predecessor_tapes(&image, &build) + }), + prepare_patch("machetes", config.machetes, || plan_machetes(&image)), + prepare_patch("no_blinking_items", config.no_blinking_items, || { + plan_no_blinking_items(&image, &build) + }), + ]; + + report.outcomes = prepared.into_iter().map(PreparedPatch::apply).collect(); + report +} + +fn plan_jackal_tapes(image: &ModuleImage) -> Result { + let target = find_signature_target( + image, + signatures::JACKAL_TAPES, + ScanScope::Code, + 5, + &[0x0A], + &[0x14], + )?; + PatchPlan::single(target, &[0x0A], &[0x14]) +} + +fn plan_devmode_always_on(image: &ModuleImage) -> Result { + let target = find_signature_target( + image, + signatures::DEVMODE_ALWAYS_ON, + ScanScope::Code, + 8, + &[0x75], + &[0xEB], + )?; + PatchPlan::single(target, &[0x75], &[0xEB]) +} + +fn plan_predecessor_tapes( + image: &ModuleImage, + build: &DuniaBuild, +) -> Result { + match build { + DuniaBuild::Retail => { + let target = image.address_from_rva(RETAIL_PREDECESSOR_RVA, 2, ScanScope::Code)?; + PatchPlan::single(target, &[0x8A, 0xC3], &[0xB0, 0x01]) + } + DuniaBuild::Steam | DuniaBuild::Ubisoft | DuniaBuild::Unknown { .. } => { + let target = find_signature_target( + image, + signatures::PREDECESSOR_TAPES_STEAM, + ScanScope::Code, + 5, + &[0x74, 0x16], + &[0xEB, 0x0E], + )?; + PatchPlan::single(target, &[0x74, 0x16], &[0xEB, 0x0E]) + } } +} - let base = dunia.0 as usize; +fn plan_machetes(image: &ModuleImage) -> Result { + let target = find_signature_target( + image, + signatures::MACHETES, + ScanScope::Code, + 0x69, + &[0x8A, 0xC3], + &[0xB0, 0x01], + )?; + PatchPlan::single(target, &[0x8A, 0xC3], &[0xB0, 0x01]) +} - #[cfg(debug_assertions)] - println!("patches: Dunia.dll base = 0x{:08X}", base); - - // IMPORTANT: Scan for ALL signatures BEFORE applying any patches - // This prevents patches from corrupting signatures we haven't found yet - let addrs = PatchAddresses::scan(base); - let hook_addrs = hooks::HookAddresses::scan(base); - - // Now apply patches using the cached addresses - apply_jackal_tapes_fix(&addrs); - // apply_no_blinking_items(&addrs); - apply_devmode_unlock(&addrs); - apply_predecessor_tapes_unlock(&addrs); - apply_machetes_unlock(&addrs); - - // Install function hooks (for FOV slider, etc.) - if let Err(_e) = hooks::install_hooks(&hook_addrs) { - #[cfg(debug_assertions)] - println!("patches: Failed to install hooks: {:?}", _e); - } -} - -/// Fix: Jackal Tapes - All tapes in Southern map play correct recordings -/// -/// The bug: In the Southern map, some Jackal tape pickups play incorrect recordings. -/// This is caused by an incorrect jump offset in the tape lookup logic. -fn apply_jackal_tapes_fix(addrs: &PatchAddresses) { - let Some(addr) = addrs.jackal_tapes else { - #[cfg(debug_assertions)] - println!("patches: Jackal Tapes signature not found, skipping"); - return; +fn plan_no_blinking_items( + image: &ModuleImage, + build: &DuniaBuild, +) -> Result { + let known_rvas = match build { + DuniaBuild::Steam | DuniaBuild::Ubisoft => Some(STEAM_NO_BLINK_RVAS), + DuniaBuild::Retail => Some(RETAIL_NO_BLINK_RVAS), + DuniaBuild::Unknown { .. } => None, }; + if let Some([mesh_rva, arch_rva, save_rva]) = known_rvas { + return PatchPlan::new(vec![ + checked_known_signature_write( + image, + signatures::MESH_HIGHLIGHT, + ScanScope::ReadOnlyData, + mesh_rva, + 4, + b"_", + b".", + )?, + checked_known_signature_write( + image, + signatures::ARCH_BLINK, + ScanScope::ReadOnlyData, + arch_rva, + 8, + b"k", + b".", + )?, + checked_known_signature_write( + image, + signatures::SAVE_DISK, + ScanScope::ReadOnlyData, + save_rva, + 31, + b"\0", + b".", + )?, + ]); + } - // The jump offset byte is at offset 5 in the pattern (after "75") - let patch_addr = addr + 5; + let mesh_highlight = find_signature_target( + image, + signatures::MESH_HIGHLIGHT, + ScanScope::ReadOnlyData, + 4, + b"_", + b".", + )?; + let arch_blink = find_signature_target( + image, + signatures::ARCH_BLINK, + ScanScope::ReadOnlyData, + 8, + b"k", + b".", + )?; + let save_disk = find_signature_target( + image, + signatures::SAVE_DISK, + ScanScope::ReadOnlyData, + 31, + b"\0", + b".", + )?; - #[cfg(debug_assertions)] - println!("patches: Applying Jackal Tapes fix at 0x{:08X}", patch_addr); + PatchPlan::new(vec![ + CheckedWrite::validated(mesh_highlight, b"_", b".")?, + CheckedWrite::validated(arch_blink, b"k", b".")?, + CheckedWrite::validated(save_disk, b"\0", b".")?, + ]) +} + +/// Validate both a known-build RVA and the complete signature context around +/// it. This avoids treating common single-byte guards such as NUL as +/// sufficient identification. +fn checked_known_signature_write( + image: &ModuleImage, + signature: &str, + scope: ScanScope, + target_rva: usize, + target_offset: usize, + expected: &'static [u8], + replacement: &'static [u8], +) -> Result { + let pattern = Pattern::parse(signature)?; + let start_rva = + target_rva + .checked_sub(target_offset) + .ok_or_else(|| PatchError::NoValidatedTarget { + signature: signature.to_owned(), + raw_matches: 0, + reasons: vec![format!( + "known target RVA 0x{target_rva:08X} precedes offset {target_offset}" + )], + })?; + let expected_start = image.address_from_rva(start_rva, pattern.len(), scope)?; + let matches = image.scan(&pattern, scope); + if !matches.contains(&expected_start) { + return Err(PatchError::NoValidatedTarget { + signature: signature.to_owned(), + raw_matches: matches.len(), + reasons: vec![format!( + "no matching context at known start RVA 0x{start_rva:08X}" + )], + }); + } - // Change jump offset (add 0x10 to fix tape index calculation) - let current = unsafe { *(patch_addr as *const u8) }; - write_bytes(patch_addr, &[current.wrapping_add(0x10)]); + let target = image.address_from_rva(target_rva, expected.len(), scope)?; + CheckedWrite::validated(target, expected, replacement) } -/// Visual: No Blinking Items - Remove highlight blinking on interactables -/// -/// Patches string literals to break the shader lookup, disabling the blinking effect. -#[allow(dead_code)] -fn apply_no_blinking_items(addrs: &PatchAddresses) { - // Patch "Mesh_Highlight" - change '_' to '.' - if let Some(addr) = addrs.mesh_highlight { - #[cfg(debug_assertions)] - println!("patches: Patching Mesh_Highlight at 0x{:08X}", addr); - write_bytes(addr + 4, &[0x2E]); // offset 4 = '_' +/// Resolve a single destination by combining a section-scoped signature with +/// destination-byte validation. +fn find_signature_target( + image: &ModuleImage, + signature: &str, + scope: ScanScope, + target_offset: usize, + expected: &'static [u8], + replacement: &'static [u8], +) -> Result { + let pattern = Pattern::parse(signature)?; + let raw_matches = image.scan(&pattern, scope); + if raw_matches.is_empty() { + return Err(PatchError::SignatureNotFound { + signature: signature.to_owned(), + scope, + }); } - // Patch "archBlink" - change 'k' to '.' - if let Some(addr) = addrs.arch_blink { - #[cfg(debug_assertions)] - println!("patches: Patching archBlink at 0x{:08X}", addr); - write_bytes(addr + 8, &[0x2E]); // offset 8 = 'k' + let mut accepted = Vec::new(); + let mut rejected = Vec::new(); + + for start in &raw_matches { + let Some(target) = start.checked_add(target_offset) else { + rejected.push(format!("match 0x{start:08X}: target address overflow")); + continue; + }; + + if !image.contains_relative_target(*start, target, expected.len(), scope) { + rejected.push(format!( + "match 0x{start:08X}: target 0x{target:08X} leaves its matched PE section" + )); + continue; + } + + match validate_bytes(target, expected, replacement) { + Ok(_) => accepted.push(target), + Err(error) => rejected.push(error.to_string()), + } } - // Patch "gadgets.ObjectiveIcons.SaveDisk" - change 'k' to '.' - if let Some(addr) = addrs.save_disk { - #[cfg(debug_assertions)] - println!("patches: Patching SaveDisk at 0x{:08X}", addr); - write_bytes(addr + 30, &[0x2E]); // offset 30 = 'k' + match accepted.as_slice() { + [target] => Ok(*target), + [] => Err(PatchError::NoValidatedTarget { + signature: signature.to_owned(), + raw_matches: raw_matches.len(), + reasons: rejected, + }), + _ => Err(PatchError::AmbiguousTarget { + signature: signature.to_owned(), + addresses: accepted, + }), } } -/// Fix: DevMode Unlock - Enable developer console commands -/// -/// Patches CConsoleService_IsCommandVisible to always skip the devmode check, -/// making all "ConsoleDeveloperOnly" commands visible and usable. -fn apply_devmode_unlock(addrs: &PatchAddresses) { - let Some(addr) = addrs.devmode else { - #[cfg(debug_assertions)] - println!("patches: DevMode signature not found, skipping"); - return; - }; +fn prepare_patch( + name: &'static str, + enabled: bool, + build: impl FnOnce() -> Result, +) -> PreparedPatch { + if enabled { + match build() { + Ok(plan) => PreparedPatch::Ready { name, plan }, + Err(error) => PreparedPatch::Failed { name, error }, + } + } else { + PreparedPatch::Disabled { name } + } +} - // The jnz opcode is at offset 8 in the pattern - let jnz_addr = addr + 8; +#[derive(Debug)] +struct PatchPlan { + writes: Vec, +} - #[cfg(debug_assertions)] - println!("patches: Applying DevMode unlock at 0x{:08X}", jnz_addr); +impl PatchPlan { + fn single( + address: usize, + expected: &'static [u8], + replacement: &'static [u8], + ) -> Result { + Self::new(vec![CheckedWrite::validated( + address, + expected, + replacement, + )?]) + } + + fn new(writes: Vec) -> Result { + if writes.is_empty() { + return Err(PatchError::EmptyPlan); + } + Ok(Self { writes }) + } + + fn apply(self) -> Result { + let mut applied = 0; + let mut already_applied = 0; + let mut applied_writes = Vec::new(); + + for write in self.writes { + match write.apply() { + Ok(WriteState::Applied) => { + applied += 1; + applied_writes.push(write); + } + Ok(WriteState::AlreadyApplied) => already_applied += 1, + Err(error) => { + let rollback_errors = applied_writes + .into_iter() + .rev() + .filter_map(|applied_write| applied_write.rollback().err()) + .collect::>(); - // Change jnz (0x75) to jmp (0xEB) - always skip the devmode check - write_bytes(jnz_addr, &[0xEB]); + return if rollback_errors.is_empty() { + Err(error) + } else { + Err(PatchError::PartialWrite { + applied, + error: Box::new(error), + rollback_errors, + }) + }; + } + } + } + + if applied == 0 && already_applied != 0 { + Ok(WriteState::AlreadyApplied) + } else { + Ok(WriteState::Applied) + } + } } -/// Unlock: Predecessor Tapes - Unlock 7 bonus missions -/// -/// The predecessor tapes were originally tied to an online Ubisoft account. -/// This patches IsPredecessorTapesUnlocked to always return true. -fn apply_predecessor_tapes_unlock(addrs: &PatchAddresses) { - let Some(addr) = addrs.predecessor_tapes else { - #[cfg(debug_assertions)] - println!("patches: Predecessor Tapes signature not found, skipping"); - return; - }; +#[derive(Clone, Copy, Debug)] +struct CheckedWrite { + address: usize, + expected: &'static [u8], + replacement: &'static [u8], +} - // Pattern: 8B 49 0C 85 C9 74 ?? 8B 44 24 - // The jz opcode is at offset 5, jump offset at offset 6 - // We change "74 ??" (jz) to "EB 0E" (jmp +14) to skip the null check - let jz_addr = addr + 5; +impl CheckedWrite { + fn validated( + address: usize, + expected: &'static [u8], + replacement: &'static [u8], + ) -> Result { + validate_bytes(address, expected, replacement)?; + Ok(Self { + address, + expected, + replacement, + }) + } - #[cfg(debug_assertions)] - println!( - "patches: Applying Predecessor Tapes unlock at 0x{:08X}", - jz_addr - ); - - // Change jz (0x74) to jmp (0xEB), and set offset to 0x0E - write_bytes(jz_addr, &[0xEB, 0x0E]); -} - -/// Unlock: Machetes - Unlock 2 bonus machete skins -/// -/// The bonus machetes were originally unlocked via a registry key. -/// This patches IsMachetesUnlocked to always return true. -fn apply_machetes_unlock(addrs: &PatchAddresses) { - let Some(addr) = addrs.machetes else { - #[cfg(debug_assertions)] - println!("patches: Machetes signature not found, skipping"); - return; - }; + fn apply(self) -> Result { + Ok(write_checked( + self.address, + self.expected, + self.replacement, + )?) + } + + fn rollback(self) -> Result { + Ok(write_checked( + self.address, + self.replacement, + self.expected, + )?) + } +} + +#[derive(Debug)] +enum PreparedPatch { + Disabled { + name: &'static str, + }, + Ready { + name: &'static str, + plan: PatchPlan, + }, + Failed { + name: &'static str, + error: PatchError, + }, +} + +impl PreparedPatch { + fn apply(self) -> PatchOutcome { + match self { + Self::Disabled { name } => PatchOutcome { + name, + status: PatchStatus::Disabled, + }, + Self::Failed { name, error } => PatchOutcome { + name, + status: PatchStatus::Failed(error.to_string()), + }, + Self::Ready { name, plan } => PatchOutcome { + name, + status: match plan.apply() { + Ok(WriteState::Applied) => PatchStatus::Applied, + Ok(WriteState::AlreadyApplied) => PatchStatus::AlreadyApplied, + Err(error) => PatchStatus::Failed(error.to_string()), + }, + }, + } + } +} - // Signature is at function prologue (sub esp | push ebx | lea eax | push eax | push) - // Offset from function start to "mov al, bl" (8A C3) is 0x69 (105 bytes) - // We change "mov al, bl" to "mov al, 1" (B0 01) to always return true - let patch_addr = addr + 0x69; +#[derive(Debug)] +enum PatchError { + Scan(ScanError), + Memory(MemoryError), + SignatureNotFound { + signature: String, + scope: ScanScope, + }, + NoValidatedTarget { + signature: String, + raw_matches: usize, + reasons: Vec, + }, + AmbiguousTarget { + signature: String, + addresses: Vec, + }, + EmptyPlan, + PartialWrite { + applied: usize, + error: Box, + rollback_errors: Vec, + }, +} + +impl fmt::Display for PatchError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Scan(error) => error.fmt(formatter), + Self::Memory(error) => error.fmt(formatter), + Self::SignatureNotFound { signature, scope } => { + write!( + formatter, + "signature {signature:?} was not found in {scope:?} sections" + ) + } + Self::NoValidatedTarget { + signature, + raw_matches, + reasons, + } => { + write!( + formatter, + "signature {signature:?} had {raw_matches} raw match(es), but no guarded target" + )?; + if !reasons.is_empty() { + write!(formatter, ": {}", reasons.join("; "))?; + } + Ok(()) + } + Self::AmbiguousTarget { + signature, + addresses, + } => write!( + formatter, + "signature {signature:?} resolved to multiple guarded targets: {}", + addresses + .iter() + .map(|address| format!("0x{address:08X}")) + .collect::>() + .join(", ") + ), + Self::EmptyPlan => write!(formatter, "patch plan contains no writes"), + Self::PartialWrite { + applied, + error, + rollback_errors, + } => write!( + formatter, + "patch failed after {applied} write(s), and rollback was incomplete: {error}; rollback error(s): {}", + rollback_errors + .iter() + .map(ToString::to_string) + .collect::>() + .join("; ") + ), + } + } +} + +impl std::error::Error for PatchError {} + +impl From for PatchError { + fn from(error: ScanError) -> Self { + Self::Scan(error) + } +} + +impl From for PatchError { + fn from(error: MemoryError) -> Self { + Self::Memory(error) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum DuniaBuild { + Steam, + Retail, + Ubisoft, + Unknown { file_size: Option }, +} + +impl DuniaBuild { + fn from_file_size(file_size: Option) -> Self { + match file_size { + Some(STEAM_DUNIA_FILE_SIZE) => Self::Steam, + Some(RETAIL_DUNIA_FILE_SIZE) => Self::Retail, + Some(UBISOFT_DUNIA_FILE_SIZE) => Self::Ubisoft, + file_size => Self::Unknown { file_size }, + } + } +} + +impl fmt::Display for DuniaBuild { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Steam => write!(formatter, "Steam 1.03"), + Self::Retail => write!(formatter, "Retail/GOG 1.03"), + Self::Ubisoft => write!(formatter, "Ubisoft Connect 1.03"), + Self::Unknown { + file_size: Some(file_size), + } => write!(formatter, "unknown build (file size {file_size})"), + Self::Unknown { file_size: None } => { + write!(formatter, "unknown build (file size unavailable)") + } + } + } +} + +fn detect_build(module: HMODULE) -> DuniaBuild { + let file_size = module_path(module) + .ok() + .and_then(|path| std::fs::metadata(path).ok()) + .map(|metadata| metadata.len()); + + DuniaBuild::from_file_size(file_size) +} + +fn module_path(module: HMODULE) -> Result { + let mut capacity = 260; + + loop { + let mut buffer = vec![0u16; capacity]; + let length = unsafe { GetModuleFileNameW(module, buffer.as_mut_ptr(), buffer.len() as u32) } + as usize; + if length == 0 { + return Err(std::io::Error::last_os_error().to_string()); + } + if length < buffer.len() { + buffer.truncate(length); + return Ok(PathBuf::from(OsString::from_wide(&buffer))); + } + + if capacity >= 32_768 { + return Err("module path exceeds the Windows path limit".to_owned()); + } + capacity = (capacity * 2).min(32_768); + } +} + +#[derive(Debug)] +struct PatchReport { + configuration: Option, + build: Option, + image: Option, + outcomes: Vec, + warnings: Vec, +} + +impl PatchReport { + fn new() -> Self { + Self { + configuration: None, + build: None, + image: None, + outcomes: Vec::new(), + warnings: Vec::new(), + } + } + + fn fail_enabled(&mut self, config: &PatchConfig, reason: String) { + let settings = [ + ("jackal_tapes", config.jackal_tapes), + ("devmode_always_on", config.devmode_always_on), + ("predecessor_tapes", config.predecessor_tapes), + ("machetes", config.machetes), + ("no_blinking_items", config.no_blinking_items), + ]; + + self.outcomes = settings + .into_iter() + .map(|(name, enabled)| PatchOutcome { + name, + status: if enabled { + PatchStatus::Failed(reason.clone()) + } else { + PatchStatus::Disabled + }, + }) + .collect(); + } +} + +impl fmt::Display for PatchReport { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(formatter, "fc2-systemdetection patch report")?; + writeln!( + formatter, + "Configuration: {}", + self.configuration.as_deref().unwrap_or("unavailable") + )?; + writeln!( + formatter, + "Dunia build: {}", + self.build.as_deref().unwrap_or("unavailable") + )?; + writeln!( + formatter, + "Dunia image: {}", + self.image.as_deref().unwrap_or("unavailable") + )?; + for outcome in &self.outcomes { + writeln!(formatter, "{}: {}", outcome.name, outcome.status)?; + } + for warning in &self.warnings { + writeln!(formatter, "configuration warning: {warning}")?; + } + Ok(()) + } +} + +#[derive(Debug)] +struct PatchOutcome { + name: &'static str, + status: PatchStatus, +} + +#[derive(Debug)] +enum PatchStatus { + Disabled, + Applied, + AlreadyApplied, + Failed(String), +} + +impl fmt::Display for PatchStatus { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Disabled => write!(formatter, "disabled"), + Self::Applied => write!(formatter, "applied"), + Self::AlreadyApplied => write!(formatter, "already applied"), + Self::Failed(reason) => write!(formatter, "FAILED: {reason}"), + } + } +} + +fn emit_report(self_module: HMODULE, report: &str) { + let mut wide: Vec = report.encode_utf16().collect(); + wide.push(0); + unsafe { + OutputDebugStringW(wide.as_ptr()); + } #[cfg(debug_assertions)] - println!("patches: Applying Machetes unlock at 0x{:08X}", patch_addr); + eprintln!("{report}"); + + let log_path = module_path(self_module) + .ok() + .and_then(|path| path.parent().map(PathBuf::from)) + .or_else(|| { + std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(PathBuf::from)) + }) + .map(|directory| directory.join("fc2-systemdetection.log")); + + if let Some(log_path) = log_path + && let Err(error) = std::fs::write(&log_path, report) + { + let message = format!( + "fc2-systemdetection: could not write {}: {error}\0", + log_path.display() + ); + let wide: Vec = message.encode_utf16().collect(); + unsafe { + OutputDebugStringW(wide.as_ptr()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_known_dunia_file_sizes() { + assert_eq!( + DuniaBuild::from_file_size(Some(STEAM_DUNIA_FILE_SIZE)), + DuniaBuild::Steam + ); + assert_eq!( + DuniaBuild::from_file_size(Some(RETAIL_DUNIA_FILE_SIZE)), + DuniaBuild::Retail + ); + assert_eq!( + DuniaBuild::from_file_size(Some(UBISOFT_DUNIA_FILE_SIZE)), + DuniaBuild::Ubisoft + ); + assert_eq!( + DuniaBuild::from_file_size(Some(123)), + DuniaBuild::Unknown { + file_size: Some(123) + } + ); + assert_eq!( + DuniaBuild::from_file_size(None), + DuniaBuild::Unknown { file_size: None } + ); + } + + #[test] + fn signature_target_accepts_original_and_patched_bytes() { + for displacement in [0x0A, 0x14] { + let memory = vec![0x80, 0x7E, 0x74, 0x00, 0x75, displacement, 0x3B, 0xCA, 0x75]; + let base = memory.as_ptr() as usize; + let image = ModuleImage::for_test_section(base, 0, memory.clone(), ScanScope::Code); + + let target = find_signature_target( + &image, + signatures::JACKAL_TAPES, + ScanScope::Code, + 5, + &[0x0A], + &[0x14], + ) + .unwrap(); + + assert_eq!(target, base + 5); + } + } + + #[test] + fn signature_target_rejects_ambiguous_guarded_matches() { + let occurrence = [0x80, 0x7E, 0x74, 0x00, 0x75, 0x0A, 0x3B, 0xCA, 0x75]; + let memory = occurrence + .into_iter() + .chain([0x90]) + .chain(occurrence) + .collect::>(); + let image = ModuleImage::for_test_section( + memory.as_ptr() as usize, + 0, + memory.clone(), + ScanScope::Code, + ); + + let error = find_signature_target( + &image, + signatures::JACKAL_TAPES, + ScanScope::Code, + 5, + &[0x0A], + &[0x14], + ) + .unwrap_err(); + + assert!(matches!( + error, + PatchError::AmbiguousTarget { addresses, .. } if addresses.len() == 2 + )); + } + + #[test] + fn signature_target_rejects_unexpected_destination_bytes() { + let memory = vec![0x80, 0x7E, 0x74, 0x00, 0x75, 0x24, 0x3B, 0xCA, 0x75]; + let image = ModuleImage::for_test_section( + memory.as_ptr() as usize, + 0, + memory.clone(), + ScanScope::Code, + ); + + let error = find_signature_target( + &image, + signatures::JACKAL_TAPES, + ScanScope::Code, + 5, + &[0x0A], + &[0x14], + ) + .unwrap_err(); + + assert!(matches!( + error, + PatchError::NoValidatedTarget { raw_matches: 1, .. } + )); + } + + #[test] + fn retail_predecessor_uses_relocation_safe_rva_and_guard() { + for bytes in [[0x8A, 0xC3], [0xB0, 0x01]] { + let memory = Vec::from(bytes); + let target = memory.as_ptr() as usize; + let base = target - RETAIL_PREDECESSOR_RVA; + let image = ModuleImage::for_test_section( + base, + RETAIL_PREDECESSOR_RVA, + memory.clone(), + ScanScope::Code, + ); + + let plan = plan_predecessor_tapes(&image, &DuniaBuild::Retail).unwrap(); + + assert_eq!(plan.writes.len(), 1); + assert_eq!(plan.writes[0].address, target); + } + } - // Change "mov al, bl" (8A C3) to "mov al, 1" (B0 01) - write_bytes(patch_addr, &[0xB0, 0x01]); + #[test] + fn unknown_build_no_blinking_fallback_targets_the_save_disk_terminator() { + let mut memory = b"Mesh_Highlight\0archBlink\0gadgets.ObjectiveIcons.SaveDisk\0".to_vec(); + let base = memory.as_mut_ptr() as usize; + let image = ModuleImage::for_test_section(base, 0, memory.clone(), ScanScope::ReadOnlyData); + let find = |needle: &[u8]| { + memory + .windows(needle.len()) + .position(|window| window == needle) + .unwrap() + }; + let targets = [ + find(b"Mesh_Highlight") + 4, + find(b"archBlink") + 8, + find(b"gadgets.ObjectiveIcons.SaveDisk") + 31, + ]; + + let plan = + plan_no_blinking_items(&image, &DuniaBuild::Unknown { file_size: None }).unwrap(); + assert_eq!( + plan.writes + .iter() + .map(|write| write.address - base) + .collect::>(), + targets + ); + assert_eq!(plan.apply().unwrap(), WriteState::Applied); + assert!(targets.iter().all(|offset| memory[*offset] == b'.')); + + let second = + plan_no_blinking_items(&image, &DuniaBuild::Unknown { file_size: None }).unwrap(); + assert_eq!(second.apply().unwrap(), WriteState::AlreadyApplied); + } + + #[test] + fn a_multi_write_plan_rolls_back_when_a_later_guard_changes() { + let mut first = vec![0x0A]; + let mut second = vec![0x74]; + let plan = PatchPlan::new(vec![ + CheckedWrite::validated(first.as_mut_ptr() as usize, &[0x0A], &[0x14]).unwrap(), + CheckedWrite::validated(second.as_mut_ptr() as usize, &[0x74], &[0xEB]).unwrap(), + ]) + .unwrap(); + second[0] = 0x75; + + assert!(plan.apply().is_err()); + assert_eq!(first, [0x0A]); + assert_eq!(second, [0x75]); + } + + #[test] + fn disabled_patches_do_not_resolve_or_scan() { + let mut called = false; + let prepared = prepare_patch("disabled", false, || { + called = true; + Err(PatchError::EmptyPlan) + }); + + assert!(!called); + assert!(matches!(prepared, PreparedPatch::Disabled { .. })); + } + + #[test] + fn patch_status_is_unambiguous() { + assert_eq!(PatchStatus::Disabled.to_string(), "disabled"); + assert_eq!(PatchStatus::Applied.to_string(), "applied"); + assert_eq!(PatchStatus::AlreadyApplied.to_string(), "already applied"); + assert_eq!( + PatchStatus::Failed("bad bytes".to_owned()).to_string(), + "FAILED: bad bytes" + ); + } } diff --git a/src/patches/offline.rs b/src/patches/offline.rs new file mode 100644 index 0000000..98505c2 --- /dev/null +++ b/src/patches/offline.rs @@ -0,0 +1,212 @@ +//! Offline validation of patch recipes against an on-disk `Dunia.dll`. + +use std::path::Path; + +use super::memory::{WriteState, read_bytes}; +use super::sigscan::{ModuleImage, Pattern, ScanScope}; +use super::{ + DuniaBuild, PatchError, PatchPlan, RETAIL_NO_BLINK_RVAS, RETAIL_PREDECESSOR_RVA, + STEAM_NO_BLINK_RVAS, plan_devmode_always_on, plan_jackal_tapes, plan_machetes, + plan_no_blinking_items, plan_predecessor_tapes, signatures, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OfflinePatchState { + AppliedToCopy, + AlreadyPresent, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValidatedPatch { + pub name: &'static str, + pub rvas: Vec, + pub original_bytes: Vec>, + pub replacement_bytes: Vec>, + pub state: OfflinePatchState, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DuniaFileValidation { + pub build: String, + pub file_size: u64, + pub image_size: usize, + pub patches: Vec, +} + +/// Validate every patch against an on-disk PE without loading or executing it. +/// +/// Portex parses the file, its sections are reconstructed in ordinary heap +/// memory, and the real patch planners write only to that disposable copy. +pub fn validate_dunia_file(path: impl AsRef) -> Result { + let path = path.as_ref(); + let file = std::fs::read(path) + .map_err(|error| format!("could not read {}: {error}", path.display()))?; + let build = DuniaBuild::from_file_size(Some(file.len() as u64)); + let mapped = ModuleImage::from_pe_file(&file) + .map_err(|error| format!("could not map {}: {error}", path.display()))?; + let image = &mapped.image; + + let mut failures = Vec::new(); + let mut prepared = Vec::new(); + for (name, result) in make_plans(image, &build) { + match result { + Ok(plan) => { + let mut rvas = Vec::with_capacity(plan.writes.len()); + let mut original_bytes = Vec::with_capacity(plan.writes.len()); + let mut replacement_bytes = Vec::with_capacity(plan.writes.len()); + for write in &plan.writes { + rvas.push(write.address - image.base()); + original_bytes.push( + read_bytes(write.address, write.expected.len()) + .map_err(|error| format!("{name}: {error}"))?, + ); + replacement_bytes.push(write.replacement.to_vec()); + } + prepared.push((name, plan, rvas, original_bytes, replacement_bytes)); + } + Err(error) => { + let diagnostics = if name == "no_blinking_items" { + format!("; candidates: {}", no_blink_diagnostics(image)) + } else { + String::new() + }; + failures.push(format!("{name}: {error}{diagnostics}")); + } + } + } + if !failures.is_empty() { + return Err(format!( + "static validation failed for {} ({build}):\n{}", + path.display(), + failures.join("\n") + )); + } + + validate_known_rvas(&build, &prepared)?; + + let mut patches = Vec::with_capacity(prepared.len()); + for (name, plan, rvas, original_bytes, replacement_bytes) in prepared { + let state = match plan + .apply() + .map_err(|error| format!("{name} apply-to-copy failed: {error}"))? + { + WriteState::Applied => OfflinePatchState::AppliedToCopy, + WriteState::AlreadyApplied => OfflinePatchState::AlreadyPresent, + }; + patches.push(ValidatedPatch { + name, + rvas, + original_bytes, + replacement_bytes, + state, + }); + } + + for (name, result) in make_plans(image, &build) { + let plan = + result.map_err(|error| format!("{name} could not re-plan patched copy: {error}"))?; + let state = plan + .apply() + .map_err(|error| format!("{name} reapply-to-copy failed: {error}"))?; + if state != WriteState::AlreadyApplied { + return Err(format!("{name} was not idempotent on the mapped copy")); + } + } + + Ok(DuniaFileValidation { + build: build.to_string(), + file_size: file.len() as u64, + image_size: image.size(), + patches, + }) +} + +fn make_plans( + image: &ModuleImage, + build: &DuniaBuild, +) -> Vec<(&'static str, Result)> { + vec![ + ("jackal_tapes", plan_jackal_tapes(image)), + ("devmode_always_on", plan_devmode_always_on(image)), + ("predecessor_tapes", plan_predecessor_tapes(image, build)), + ("machetes", plan_machetes(image)), + ("no_blinking_items", plan_no_blinking_items(image, build)), + ] +} + +type PreparedPatch = ( + &'static str, + PatchPlan, + Vec, + Vec>, + Vec>, +); + +fn validate_known_rvas(build: &DuniaBuild, prepared: &[PreparedPatch]) -> Result<(), String> { + let (jackal, predecessor, machetes, no_blinking) = match build { + DuniaBuild::Steam | DuniaBuild::Ubisoft => { + (0x0074_E465, 0x002E_1D15, 0x0004_8939, STEAM_NO_BLINK_RVAS) + } + DuniaBuild::Retail => ( + 0x0074_0F55, + RETAIL_PREDECESSOR_RVA, + 0x0004_8A09, + RETAIL_NO_BLINK_RVAS, + ), + DuniaBuild::Unknown { .. } => return Ok(()), + }; + + for (name, expected) in [ + ("jackal_tapes", vec![jackal]), + ("predecessor_tapes", vec![predecessor]), + ("machetes", vec![machetes]), + ("no_blinking_items", no_blinking.to_vec()), + ] { + let actual = prepared + .iter() + .find_map(|(candidate, _, rvas, _, _)| (*candidate == name).then_some(rvas)) + .ok_or_else(|| format!("{name} did not produce a patch plan"))?; + if actual != &expected { + return Err(format!( + "{name} resolved to {:?}, expected {:?} for {build}", + format_rvas(actual), + format_rvas(&expected) + )); + } + } + + Ok(()) +} + +fn format_rvas(rvas: &[usize]) -> Vec { + rvas.iter().map(|rva| format!("0x{rva:08X}")).collect() +} + +fn no_blink_diagnostics(image: &ModuleImage) -> String { + [ + ("Mesh_Highlight", signatures::MESH_HIGHLIGHT, 4), + ("archBlink", signatures::ARCH_BLINK, 8), + ("SaveDisk", signatures::SAVE_DISK, 31), + ] + .into_iter() + .map(|(name, signature, offset)| { + let candidates = Pattern::parse(signature) + .map(|pattern| image.scan(&pattern, ScanScope::ReadOnlyData)) + .unwrap_or_default() + .into_iter() + .filter_map(|start| { + let target = start.checked_add(offset)?; + let byte = read_bytes(target, 1).ok()?; + Some(format!( + "start RVA 0x{:08X} -> target RVA 0x{:08X} ({:02X})", + start - image.base(), + target - image.base(), + byte[0] + )) + }) + .collect::>(); + format!("{name} [{}]", candidates.join(", ")) + }) + .collect::>() + .join("; ") +} diff --git a/src/patches/sigscan.rs b/src/patches/sigscan.rs index 6dfd5bd..346f265 100644 --- a/src/patches/sigscan.rs +++ b/src/patches/sigscan.rs @@ -1,136 +1,758 @@ -//! Signature scanning for pattern matching in memory +//! PE-aware signature scanning for loaded modules. //! -//! Supports wildcard bytes using `??` in patterns. +//! The scanner snapshots validated PE sections once, then searches only the +//! requested section class. It never walks arbitrary address space beyond the +//! module image. -use windows::Win32::System::Memory::{ - MEMORY_BASIC_INFORMATION, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, PAGE_READONLY, - PAGE_READWRITE, VirtualQuery, +use std::fmt; +use std::mem::size_of; + +use portex::reader::Reader; +use portex::{Error as PortexError, MachineType, PEHeaders}; +use windows_sys::Win32::Foundation::HMODULE; +use windows_sys::Win32::System::Memory::{ + MEM_COMMIT, MEMORY_BASIC_INFORMATION, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, + PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_PROTECTION_FLAGS, PAGE_READONLY, PAGE_READWRITE, + PAGE_WRITECOPY, VirtualQuery, }; -use windows::Win32::System::SystemInformation::{GetSystemInfo, SYSTEM_INFO}; +use windows_sys::Win32::System::ProcessStatus::{K32GetModuleInformation, MODULEINFO}; +use windows_sys::Win32::System::Threading::GetCurrentProcess; -/// A parsed signature pattern with optional wildcard bytes +/// A parsed byte pattern. `None` entries are wildcards. +#[derive(Clone, Debug, Eq, PartialEq)] pub struct Pattern { - bytes: Vec, - mask: Vec, // true = must match, false = wildcard + bytes: Vec>, } impl Pattern { - /// Parse a pattern string like "80 79 ?? ?? 8B 54" - pub fn parse(pattern: &str) -> Option { + pub fn parse(pattern: &str) -> Result { let mut bytes = Vec::new(); - let mut mask = Vec::new(); for part in pattern.split_whitespace() { if part == "??" || part == "?" { - bytes.push(0); - mask.push(false); + bytes.push(None); } else { - let byte = u8::from_str_radix(part, 16).ok()?; - bytes.push(byte); - mask.push(true); + let byte = u8::from_str_radix(part, 16) + .map_err(|_| ScanError::InvalidPattern(pattern.to_owned()))?; + bytes.push(Some(byte)); } } if bytes.is_empty() { - return None; + return Err(ScanError::InvalidPattern(pattern.to_owned())); } - Some(Self { bytes, mask }) + Ok(Self { bytes }) + } + + pub fn len(&self) -> usize { + self.bytes.len() + } + + fn matches(&self, candidate: &[u8]) -> bool { + self.bytes + .iter() + .zip(candidate) + .all(|(expected, actual)| expected.is_none_or(|expected| expected == *actual)) } - /// Check if pattern matches at the given memory location + fn find_offsets(&self, bytes: &[u8]) -> Vec { + if bytes.len() < self.len() { + return Vec::new(); + } + + bytes + .windows(self.len()) + .enumerate() + .filter_map(|(offset, candidate)| self.matches(candidate).then_some(offset)) + .collect() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ScanScope { + Code, + ReadOnlyData, +} + +#[derive(Clone, Debug)] +struct PeSection { + name: String, + rva: usize, + size: usize, + executable: bool, + readable: bool, + writable: bool, +} + +impl PeSection { + fn is_in_scope(&self, scope: ScanScope) -> bool { + match scope { + ScanScope::Code => self.executable, + ScanScope::ReadOnlyData => self.readable && !self.writable && !self.executable, + } + } +} + +#[derive(Debug)] +struct LoadedSection { + metadata: PeSection, + bytes: Vec, +} + +/// A validated snapshot of the readable sections in a loaded PE image. +#[derive(Debug)] +pub struct ModuleImage { + base: usize, + size: usize, + sections: Vec, +} + +pub(super) struct FileMappedModule { + pub(super) image: ModuleImage, + _mapping: Vec, +} + +impl ModuleImage { + /// Snapshot a loaded module's validated PE sections. /// /// # Safety - /// Caller must ensure `ptr` points to readable memory of at least `self.len()` bytes. - #[inline] - pub unsafe fn matches_at(&self, ptr: *const u8) -> bool { - for (i, (&pattern_byte, &must_match)) in self.bytes.iter().zip(self.mask.iter()).enumerate() - { - if must_match && unsafe { *ptr.add(i) } != pattern_byte { + /// + /// `module` must be a live module handle in the current process. + pub unsafe fn from_module(module: HMODULE) -> Result { + let mut module_info = MODULEINFO::default(); + let module_information_result = unsafe { + K32GetModuleInformation( + GetCurrentProcess(), + module, + &mut module_info, + size_of::() as u32, + ) + }; + if module_information_result == 0 { + return Err(ScanError::ModuleInformation( + std::io::Error::last_os_error().to_string(), + )); + } + + let base = module_info.lpBaseOfDll as usize; + let size = module_info.SizeOfImage as usize; + if base == 0 || size == 0 { + return Err(ScanError::InvalidPe( + "module information returned an empty image".to_owned(), + )); + } + + let reader = ValidatedModuleReader { base, size }; + let headers = PEHeaders::read_from(&reader, 0).map_err(|error| { + ScanError::InvalidPe(format!("Portex rejected the loaded headers: {error}")) + })?; + let sections = validate_pe_sections(headers, size)?; + + let mut loaded_sections = Vec::new(); + for section in sections { + if section.size == 0 || (!section.readable && !section.executable) { + continue; + } + + let address = base + .checked_add(section.rva) + .ok_or_else(|| ScanError::InvalidPe("section address overflow".to_owned()))?; + validate_readable_range(address, section.size).map_err(|reason| { + ScanError::UnreadableSection { + name: section.name.clone(), + reason, + } + })?; + + loaded_sections.push(LoadedSection { + bytes: copy_memory(address, section.size), + metadata: section, + }); + } + + if loaded_sections.is_empty() { + return Err(ScanError::InvalidPe( + "PE image has no readable code or data sections".to_owned(), + )); + } + + Ok(Self { + base, + size, + sections: loaded_sections, + }) + } + + pub fn base(&self) -> usize { + self.base + } + + pub fn size(&self) -> usize { + self.size + } + + /// Return every match in the selected PE section class. + pub fn scan(&self, pattern: &Pattern, scope: ScanScope) -> Vec { + let mut matches = Vec::new(); + + for section in &self.sections { + if !section.metadata.is_in_scope(scope) { + continue; + } + + let Some(section_address) = self.base.checked_add(section.metadata.rva) else { + continue; + }; + for offset in pattern.find_offsets(§ion.bytes) { + if let Some(address) = section_address.checked_add(offset) { + matches.push(address); + } + } + } + + matches + } + + pub fn address_from_rva( + &self, + rva: usize, + length: usize, + scope: ScanScope, + ) -> Result { + let end = rva + .checked_add(length) + .ok_or_else(|| ScanError::InvalidPe("RVA range overflow".to_owned()))?; + if end > self.size { + return Err(ScanError::AddressOutsideImage { rva, length }); + } + + let in_section = self.sections.iter().any(|section| { + if !section.metadata.is_in_scope(scope) { return false; } + + let section_start = section.metadata.rva; + let Some(section_end) = section_start.checked_add(section.metadata.size) else { + return false; + }; + rva >= section_start && end <= section_end + }); + + if !in_section { + return Err(ScanError::AddressOutsideScope { rva, length, scope }); } - true + + self.base + .checked_add(rva) + .ok_or_else(|| ScanError::InvalidPe("RVA address overflow".to_owned())) } - /// Get the length of the pattern - pub fn len(&self) -> usize { - self.bytes.len() + pub fn contains_relative_target( + &self, + match_address: usize, + target_address: usize, + length: usize, + scope: ScanScope, + ) -> bool { + let Some(match_rva) = match_address.checked_sub(self.base) else { + return false; + }; + let Some(target_rva) = target_address.checked_sub(self.base) else { + return false; + }; + let Some(target_end) = target_rva.checked_add(length) else { + return false; + }; + + self.sections.iter().any(|section| { + if !section.metadata.is_in_scope(scope) { + return false; + } + + let section_start = section.metadata.rva; + let Some(section_end) = section_start.checked_add(section.metadata.size) else { + return false; + }; + + match_rva >= section_start + && match_rva < section_end + && target_rva >= section_start + && target_end <= section_end + }) + } + + #[cfg(test)] + pub(crate) fn for_test_section( + base: usize, + rva: usize, + bytes: Vec, + scope: ScanScope, + ) -> Self { + let (name, executable, readable, writable) = match scope { + ScanScope::Code => (".text", true, true, false), + ScanScope::ReadOnlyData => (".rdata", false, true, false), + }; + let size = bytes.len(); + + Self { + base, + size: rva + size, + sections: vec![LoadedSection { + metadata: PeSection { + name: name.to_owned(), + rva, + size, + executable, + readable, + writable, + }, + bytes, + }], + } + } + + /// Parse an on-disk PE with Portex and reconstruct its loaded section + /// layout in ordinary heap memory without loading or executing the image. + pub(super) fn from_pe_file(file: &[u8]) -> Result { + let headers = PEHeaders::from_slice(file) + .map_err(|error| ScanError::InvalidPe(format!("Portex rejected the file: {error}")))?; + let image_size = headers.optional_header.size_of_image() as usize; + let sections = validate_pe_sections(headers.clone(), image_size)?; + let mut mapping = vec![0; image_size]; + + for (section, header) in sections.iter().zip(&headers.section_headers) { + let raw_size = header.size_of_raw_data as usize; + if raw_size == 0 { + continue; + } + + let raw_start = header.pointer_to_raw_data as usize; + if raw_start == 0 { + return Err(ScanError::InvalidPe(format!( + "section {:?} has raw data at file offset zero", + section.name + ))); + } + let raw_end = raw_start.checked_add(raw_size).ok_or_else(|| { + ScanError::InvalidPe(format!("section {:?} raw range overflow", section.name)) + })?; + if raw_end > file.len() { + return Err(ScanError::InvalidPe(format!( + "section {:?} raw data ends outside the file", + section.name + ))); + } + + let mapped_end = section.rva.checked_add(raw_size).ok_or_else(|| { + ScanError::InvalidPe(format!("section {:?} mapped range overflow", section.name)) + })?; + if mapped_end > mapping.len() || raw_size > section.size { + return Err(ScanError::InvalidPe(format!( + "section {:?} raw data ends outside its mapped range", + section.name + ))); + } + + mapping[section.rva..mapped_end].copy_from_slice(&file[raw_start..raw_end]); + } + + let base = mapping.as_mut_ptr() as usize; + let loaded_sections = sections + .into_iter() + .filter(|section| section.size != 0 && (section.readable || section.executable)) + .map(|section| LoadedSection { + bytes: mapping[section.rva..section.rva + section.size].to_vec(), + metadata: section, + }) + .collect::>(); + + if loaded_sections.is_empty() { + return Err(ScanError::InvalidPe( + "PE image has no readable code or data sections".to_owned(), + )); + } + + Ok(FileMappedModule { + image: Self { + base, + size: image_size, + sections: loaded_sections, + }, + _mapping: mapping, + }) + } +} + +#[derive(Debug)] +pub enum ScanError { + InvalidPattern(String), + ModuleInformation(String), + InvalidPe(String), + UnreadableSection { + name: String, + reason: String, + }, + AddressOutsideImage { + rva: usize, + length: usize, + }, + AddressOutsideScope { + rva: usize, + length: usize, + scope: ScanScope, + }, +} + +impl fmt::Display for ScanError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidPattern(pattern) => write!(formatter, "invalid signature {pattern:?}"), + Self::ModuleInformation(error) => { + write!(formatter, "could not query module information: {error}") + } + Self::InvalidPe(reason) => write!(formatter, "invalid PE image: {reason}"), + Self::UnreadableSection { name, reason } => { + write!(formatter, "PE section {name:?} is not readable: {reason}") + } + Self::AddressOutsideImage { rva, length } => write!( + formatter, + "RVA 0x{rva:08X} (length {length}) is outside the module image" + ), + Self::AddressOutsideScope { rva, length, scope } => write!( + formatter, + "RVA 0x{rva:08X} (length {length}) is outside {scope:?} sections" + ), + } + } +} + +impl std::error::Error for ScanError {} + +/// A Portex reader that bounds every request to the loaded image and validates +/// the corresponding virtual-memory pages before copying. +struct ValidatedModuleReader { + base: usize, + size: usize, +} + +impl Reader for ValidatedModuleReader { + fn read_at(&self, offset: u64, buffer: &mut [u8]) -> portex::Result { + let Ok(offset) = usize::try_from(offset) else { + return Ok(0); + }; + let Some(available) = self.size.checked_sub(offset) else { + return Ok(0); + }; + let length = buffer.len().min(available); + if length == 0 { + return Ok(0); + } + + let address = self + .base + .checked_add(offset) + .ok_or_else(|| PortexError::generic("loaded-module address overflow"))?; + validate_readable_range(address, length).map_err(|reason| { + PortexError::generic(format!( + "loaded-module read at offset 0x{offset:X} is unsafe: {reason}" + )) + })?; + + unsafe { + std::ptr::copy_nonoverlapping(address as *const u8, buffer.as_mut_ptr(), length); + } + Ok(length) + } + + fn size(&self) -> Option { + Some(self.size as u64) } } -/// Scan a memory region for a pattern -/// -/// # Safety -/// Caller must ensure `start` to `start + size` is valid readable memory. -pub unsafe fn scan_region(start: usize, size: usize, pattern: &Pattern) -> Option { - if size < pattern.len() { - return None; +#[cfg(test)] +fn parse_pe_sections(headers: &[u8], module_size: usize) -> Result, ScanError> { + let parsed = PEHeaders::from_slice(headers) + .map_err(|error| ScanError::InvalidPe(format!("Portex rejected the headers: {error}")))?; + validate_pe_sections(parsed, module_size) +} + +fn validate_pe_sections( + parsed: PEHeaders, + module_size: usize, +) -> Result, ScanError> { + if parsed.is_64bit() { + return Err(ScanError::InvalidPe( + "Dunia.dll is not a 32-bit PE image".to_owned(), + )); + } + if parsed.coff_header.machine_type() != Some(MachineType::I386) { + return Err(ScanError::InvalidPe(format!( + "Dunia.dll has unsupported machine type 0x{:04X}", + parsed.coff_header.machine + ))); + } + if !parsed.coff_header.is_dll() { + return Err(ScanError::InvalidPe( + "loaded image is not marked as a DLL".to_owned(), + )); + } + + let number_of_sections = parsed.section_headers.len(); + if number_of_sections == 0 || number_of_sections > 96 { + return Err(ScanError::InvalidPe(format!( + "invalid section count {number_of_sections}" + ))); } - let end = start + size - pattern.len(); - let mut addr = start; + let image_size = parsed.optional_header.size_of_image() as usize; + if image_size == 0 || image_size != module_size { + return Err(ScanError::InvalidPe(format!( + "header SizeOfImage 0x{image_size:X} differs from mapped size 0x{module_size:X}" + ))); + } + + let mut sections = Vec::with_capacity(number_of_sections); + for header in parsed.section_headers { + let name = header.name_str().into_owned(); + let size = header.virtual_size.max(header.size_of_raw_data) as usize; + let rva = header.virtual_address as usize; - while addr <= end { - if unsafe { pattern.matches_at(addr as *const u8) } { - return Some(addr); + let end = rva + .checked_add(size) + .ok_or_else(|| ScanError::InvalidPe(format!("section {name:?} range overflow")))?; + if end > image_size { + return Err(ScanError::InvalidPe(format!( + "section {name:?} ends outside SizeOfImage" + ))); } - addr += 1; + + sections.push(PeSection { + name, + rva, + size, + executable: header.is_executable(), + readable: header.is_readable(), + writable: header.is_writable(), + }); } - None + for (index, section) in sections.iter().enumerate() { + if section.size == 0 { + continue; + } + let section_end = section.rva + section.size; + + for other in sections.iter().skip(index + 1) { + if other.size == 0 { + continue; + } + let other_end = other.rva + other.size; + if section.rva < other_end && other.rva < section_end { + return Err(ScanError::InvalidPe(format!( + "sections {:?} and {:?} overlap in memory", + section.name, other.name + ))); + } + } + } + + Ok(sections) } -/// Scan a module's executable sections for a pattern -/// -/// Returns the address of the first match, or None if not found. -pub fn scan_module(module_base: usize, pattern: &Pattern) -> Option { - // Initialize system info (not currently used but may be useful later) - let _page_size = unsafe { - let mut si = SYSTEM_INFO::default(); - GetSystemInfo(&mut si); - si.dwPageSize as usize - }; +fn validate_readable_range(address: usize, length: usize) -> Result<(), String> { + if length == 0 { + return Ok(()); + } - let mut addr = module_base; - let max_scan = 0x10000000; // 256MB max scan range + let end = address + .checked_add(length) + .ok_or_else(|| "address range overflow".to_owned())?; + let mut cursor = address; - while addr < module_base + max_scan { - let mut mbi = MEMORY_BASIC_INFORMATION::default(); - let result = unsafe { + while cursor < end { + let mut information = MEMORY_BASIC_INFORMATION::default(); + let queried = unsafe { VirtualQuery( - Some(addr as *const _), - &mut mbi, + cursor as *const _, + &mut information, size_of::(), ) }; + if queried == 0 { + return Err(format!("VirtualQuery failed at 0x{cursor:08X}")); + } - if result == 0 { - break; + validate_region(&information)?; + let region_end = (information.BaseAddress as usize) + .checked_add(information.RegionSize) + .ok_or_else(|| "memory-region range overflow".to_owned())?; + if region_end <= cursor { + return Err("VirtualQuery did not advance".to_owned()); } + cursor = region_end.min(end); + } - // Check if this is a readable code section - let protect = mbi.Protect; - let is_readable = protect == PAGE_EXECUTE_READ - || protect == PAGE_EXECUTE_READWRITE - || protect == PAGE_READONLY - || protect == PAGE_READWRITE; + Ok(()) +} - if is_readable && mbi.RegionSize > 0 { - let region_start = mbi.BaseAddress as usize; - let region_size = mbi.RegionSize; +fn validate_region(information: &MEMORY_BASIC_INFORMATION) -> Result<(), String> { + if information.State != MEM_COMMIT { + return Err("memory is not committed".to_owned()); + } - if let Some(found) = unsafe { scan_region(region_start, region_size, pattern) } { - return Some(found); - } - } + let protection = information.Protect; + if protection & PAGE_GUARD != 0 || !is_readable_protection(protection) { + return Err(format!( + "memory protection 0x{:X} is not readable", + protection + )); + } - // Move to next region - addr = mbi.BaseAddress as usize + mbi.RegionSize; - if addr < mbi.BaseAddress as usize { - break; // Overflow protection - } + Ok(()) +} + +fn is_readable_protection(protection: PAGE_PROTECTION_FLAGS) -> bool { + let base = protection & 0xFF; + base == PAGE_READONLY + || base == PAGE_READWRITE + || base == PAGE_WRITECOPY + || base == PAGE_EXECUTE_READ + || base == PAGE_EXECUTE_READWRITE + || base == PAGE_EXECUTE_WRITECOPY +} + +fn copy_memory(address: usize, length: usize) -> Vec { + let mut bytes = vec![0; length]; + unsafe { + std::ptr::copy_nonoverlapping(address as *const u8, bytes.as_mut_ptr(), length); + } + bytes +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_wildcards_and_finds_overlapping_matches() { + let pattern = Pattern::parse("AA ?? AA").unwrap(); + assert_eq!(pattern.find_offsets(&[0xAA, 1, 0xAA, 2, 0xAA]), [0, 2]); + } + + #[test] + fn rejects_empty_and_invalid_patterns() { + assert!(Pattern::parse("").is_err()); + assert!(Pattern::parse("GG").is_err()); + } + + #[test] + fn parses_and_classifies_pe_sections() { + let headers = synthetic_pe_headers(); + let sections = parse_pe_sections(&headers, 0x3000).unwrap(); + + assert_eq!(sections.len(), 2); + assert_eq!(sections[0].name, ".text"); + assert!(sections[0].is_in_scope(ScanScope::Code)); + assert!(!sections[0].is_in_scope(ScanScope::ReadOnlyData)); + assert_eq!(sections[1].name, ".rdata"); + assert!(sections[1].is_in_scope(ScanScope::ReadOnlyData)); + } + + #[test] + fn portex_reads_headers_through_validated_module_memory() { + let headers = synthetic_pe_headers(); + let reader = ValidatedModuleReader { + base: headers.as_ptr() as usize, + size: headers.len(), + }; + + let parsed = PEHeaders::read_from(&reader, 0).unwrap(); + + assert_eq!(parsed.coff_header.machine_type(), Some(MachineType::I386)); + assert_eq!(parsed.section_headers.len(), 2); + } + + #[test] + fn rejects_sections_outside_the_image() { + let mut headers = synthetic_pe_headers(); + let section_table = 0x80 + 24 + 0xE0; + put_u32(&mut headers, section_table + 8, 0x2001); + + assert!(parse_pe_sections(&headers, 0x3000).is_err()); + } + + #[test] + fn rejects_overlapping_sections() { + let mut headers = synthetic_pe_headers(); + let section_table = 0x80 + 24 + 0xE0; + let rdata = section_table + portex::SectionHeader::SIZE; + put_u32(&mut headers, rdata + 12, 0x1080); + + assert!(parse_pe_sections(&headers, 0x3000).is_err()); + } + + #[test] + fn relative_targets_must_stay_in_the_matched_section() { + let image = ModuleImage::for_test_section(0x1000, 0x200, vec![0; 4], ScanScope::Code); + + assert!(image.contains_relative_target(0x1201, 0x1203, 1, ScanScope::Code)); + assert!(!image.contains_relative_target(0x1201, 0x1203, 2, ScanScope::Code)); + assert!(!image.contains_relative_target(0x1201, 0x1203, 1, ScanScope::ReadOnlyData)); } - None + fn synthetic_pe_headers() -> Vec { + let mut headers = vec![0; 0x400]; + put_u16(&mut headers, 0, 0x5A4D); + put_u32(&mut headers, 0x3C, 0x80); + put_u32(&mut headers, 0x80, 0x0000_4550); + put_u16(&mut headers, 0x80 + 4, MachineType::I386 as u16); + put_u16(&mut headers, 0x80 + 6, 2); + put_u16(&mut headers, 0x80 + 20, 0xE0); + put_u16( + &mut headers, + 0x80 + 22, + portex::coff::characteristics::EXECUTABLE_IMAGE + | portex::coff::characteristics::MACHINE_32BIT + | portex::coff::characteristics::DLL, + ); + + let optional = 0x80 + 24; + put_u16(&mut headers, optional, 0x010B); + put_u32(&mut headers, optional + 56, 0x3000); + + let section_table = optional + 0xE0; + headers[section_table..section_table + 5].copy_from_slice(b".text"); + put_u32(&mut headers, section_table + 8, 0x100); + put_u32(&mut headers, section_table + 12, 0x1000); + put_u32(&mut headers, section_table + 16, 0x100); + put_u32( + &mut headers, + section_table + 36, + portex::section::characteristics::CODE + | portex::section::characteristics::EXECUTE + | portex::section::characteristics::READ, + ); + + let rdata = section_table + portex::SectionHeader::SIZE; + headers[rdata..rdata + 6].copy_from_slice(b".rdata"); + put_u32(&mut headers, rdata + 8, 0x80); + put_u32(&mut headers, rdata + 12, 0x2000); + put_u32(&mut headers, rdata + 16, 0x80); + put_u32( + &mut headers, + rdata + 36, + portex::section::characteristics::READ, + ); + + headers + } + + fn put_u16(bytes: &mut [u8], offset: usize, value: u16) { + bytes[offset..offset + 2].copy_from_slice(&value.to_le_bytes()); + } + + fn put_u32(bytes: &mut [u8], offset: usize, value: u32) { + bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } } diff --git a/tests/dunia-validation.rs b/tests/dunia-validation.rs new file mode 100644 index 0000000..32ac877 --- /dev/null +++ b/tests/dunia-validation.rs @@ -0,0 +1,35 @@ +use std::path::PathBuf; + +use systemdetection::validate_dunia_file; + +#[test] +#[ignore = "requires FC2_DUNIA_DLL to point to a legally obtained Dunia.dll"] +fn validates_user_supplied_dunia_without_loading_it() { + let path = std::env::var_os("FC2_DUNIA_DLL") + .map(PathBuf::from) + .expect("set FC2_DUNIA_DLL to the Dunia.dll path"); + let report = validate_dunia_file(&path) + .unwrap_or_else(|error| panic!("offline Dunia.dll validation failed: {error}")); + + assert_eq!(report.patches.len(), 5); + eprintln!( + "validated {} as {} (file size {}, SizeOfImage 0x{:X})", + path.display(), + report.build, + report.file_size, + report.image_size + ); + for patch in report.patches { + eprintln!( + "{}: {} ({:?})", + patch.name, + patch + .rvas + .iter() + .map(|rva| format!("RVA 0x{rva:08X}")) + .collect::>() + .join(", "), + patch.state + ); + } +} From ef59839f7a52836a4ec7b4c73fb30916f3ca2bf3 Mon Sep 17 00:00:00 2001 From: Jon-Luke Biddle Date: Mon, 3 Aug 2026 12:38:18 -0600 Subject: [PATCH 2/2] Keep README focused on current behavior --- README.md | 31 ++++++++----------------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index b36cc52..d31ecf5 100644 --- a/README.md +++ b/README.md @@ -4,20 +4,18 @@ [![Release](https://img.shields.io/github/v/release/coconutbird/fc2-systemdetection)](https://github.com/coconutbird/fc2-systemdetection/releases/latest) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -A 32-bit drop-in replacement for Far Cry 2's `systemdetection.dll`. It fixes -the original DLL's high-core-count crash and applies a small set of validated -runtime patches to `Dunia.dll`. +A 32-bit drop-in replacement for Far Cry 2's `systemdetection.dll`. It +supports high-core-count systems and applies a small set of validated runtime +patches to `Dunia.dll`. ## Features -The replacement CPU detector avoids the original crash on systems with 32 or -more logical processors. +CPU detection supports systems with 32 or more logical processors. Runtime patches are resolved inside [Portex](https://github.com/coconutbird/portex)-parsed PE sections. Every signature-relative destination must be unique and contain either the exact -known original bytes or the exact replacement bytes before anything is -written. +expected game bytes or the exact patched bytes before anything is written. | Patch | Default | Behavior | | --- | --- | --- | @@ -32,17 +30,12 @@ unknown build is never patched from an unguarded address: compatible signature-based patches may resolve, while unsupported targets fail closed and are reported. -The DevMode patch is deliberately an always-on bypass. It does not add the -`devmodeon`/`devmodeoff` console commands from Far Cry 2 Multi Fixer. FOV and -launcher-only features such as affinity, FPS arguments, and intro skipping are -also not implemented here. - ## Installation 1. Download `systemdetection.dll` from [Releases](https://github.com/coconutbird/fc2-systemdetection/releases). -2. Back up the original `bin/systemdetection.dll`. -3. Copy the replacement DLL into the game's `bin` directory. +2. Back up the existing `bin/systemdetection.dll`. +3. Copy the downloaded DLL into the game's `bin` directory. 4. Launch the game. Common installation locations include: @@ -93,7 +86,7 @@ The DLL is written to ## Development checks -[prek](https://prek.j178.dev/) replaces the former Cargo Husky hook. If you +[prek](https://prek.j178.dev/) manages the repository's commit checks. If you use [mise](https://mise.jdx.dev/), the checked-in `mise.toml` tracks the latest Rust and prek releases: @@ -125,14 +118,6 @@ heap memory, checks the real patch plans and known RVAs, and applies each patch twice to that copy to verify idempotency. It never loads or executes the game DLL. -## Attribution - -The patch behavior and per-build address research were compared with -[FoxAhead's Far Cry 2 Multi Fixer](https://github.com/FoxAhead/Far-Cry-2-Multi-Fixer). -The independent Rust implementation in this repository does not incorporate -its Delphi source. See [`PATCH_COMPARISON.md`](PATCH_COMPARISON.md) for the -comparison, limitations, and provenance notes. - ## License [MIT](LICENSE)