diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7b14256..d303357 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -75,7 +75,7 @@ jobs: python tools/unreal_inventory.py --phase minimal if ($LASTEXITCODE -ne 0) { throw 'Unreal source inventory failed.' } - - name: Test Unreal integration in debug and release + - name: Test Unreal integration and fix behavior in debug and release env: BUILD_VERSION: ${{ steps.metadata.outputs.version }} run: | @@ -86,6 +86,10 @@ jobs: if ($LASTEXITCODE -ne 0) { throw "Unreal $Mode test build failed." } & xmake run unreal-integration-tests if ($LASTEXITCODE -ne 0) { throw "Unreal $Mode tests failed." } + & xmake build -y -v fix-behavior-tests + if ($LASTEXITCODE -ne 0) { throw "Fix behavior $Mode test build failed." } + & xmake run fix-behavior-tests + if ($LASTEXITCODE -ne 0) { throw "Fix behavior $Mode tests failed." } } - name: Build and package Windows x64 release diff --git a/AGENTS.md b/AGENTS.md index 2b1d70b..ab6f228 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,9 @@ P3RFix is a Windows x64 C++ ASI plugin for Persona 3 Reload. - `src/dllmain.cpp` handles initialization, configuration, and game hooks; `src/helper.hpp` provides shared utilities. - `src/unreal/Integration.hpp` exposes the focused Unreal API. Keep memory layouts and runtime internals in `src/unreal/detail/`. +- `src/render/Scaling.*` defines render-target scaling; `src/input/RawMousePacket.*` validates Windows raw-input reads and applies the existing mouse deltas. - `tests/unreal_integration_tests.cpp` contains synthetic memory tests; `tools/unreal_inventory.py` audits integration sources. +- `tests/fix_behavior_tests.cpp` runs scaling and packet-reader cases from the adjacent test headers. - `external/` contains pinned dependency submodules. `assets/r2-package/` holds Reloaded-II metadata; `.github/` contains workflows, issue templates, and screenshots. ## Build, Test, and Development Commands @@ -15,15 +17,17 @@ Run from the repository root on Windows with Visual Studio 2022 C++ tools, Windo ```powershell git submodule update --init --recursive -xmake f -y -p windows -a x64 -m debug --fix_version=1.3.0 +xmake f -y -p windows -a x64 -m debug --fix_version=1.4.0 xmake build -y P3RFix xmake build -y unreal-integration-tests xmake run unreal-integration-tests +xmake build -y fix-behavior-tests +xmake run fix-behavior-tests python tools/unreal_inventory.py --phase minimal -./create_release.ps1 -Version 1.3.0 +./create_release.ps1 -Version 1.4.0 ``` -These commands initialize dependencies, configure a debug build, build the plugin, build/run tests, audit source boundaries, and package a release. Packaging creates standalone and Reloaded-II ZIPs under `build/`. Load the packaged fix with the game using the installation instructions in `README.md`. +These commands initialize dependencies, configure a debug build, build the plugin, build/run tests, audit source boundaries, and package a release. Packaging creates standalone and Reloaded-II ZIPs under `build/`, validates all thirteen license/notice inputs, and verifies each archive's combined `LICENSES` and binary paths. The maintained source license files remain in the repository. Load the packaged fix with the game using the installation instructions in `README.md`. ## Coding Style & Naming Conventions diff --git a/CHANGELOG.md b/CHANGELOG.md index 40878b1..e1d2b8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.4.0] - Unreleased + +### Fixed + +- Base automatic menu render texture scaling on effective viewport height, retaining native quality below 1080p while honoring explicit user multipliers and rejecting invalid or overflowing dimensions. +- Ignore failed, incomplete, and inconsistent raw-input reads before parsing mouse movement; preserve the existing sensitivity and accepted mouse flags. +- Attempt independent fix installation before the optional console discovery wait on the initialization worker, including intro skipping and mouse hooks. + +### Maintenance + +- Replace the release ZIPs' separate license documents and `licenses/` directory with one complete `LICENSES` file, preserving all maintained notices and existing binary destinations. Overwrite upgrades may retain earlier notice files. +- Run focused scaling and Windows packet-reader tests alongside the Unreal integration checks in debug and release builds. + ## [1.3.0] - 2026-10-01 ### Maintenance diff --git a/README.md b/README.md index 6b65853..0930901 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ Maintained by [dev-camo](https://github.com/dev-camo) and contributors, continui - Xbox/MS Store: `XboxGames\Persona 3 Reload\Content\P3R\Binaries\WinGDK` - `P3RFix.asi`, `P3RFix.ini` and `dsound.dll` should be alongside `P3R.exe`. +Both ZIPs include one `LICENSES` document with the project license and complete third-party notices. Overwriting an older installation can leave its previous notice files in place; `LICENSES` contains the complete notices for the new package. + ### Steam Deck/Linux Additional Instructions - Open up the game properties in Steam and add `WINEDLLOVERRIDES="dsound=n,b" %command%` to the launch options. @@ -44,6 +46,7 @@ If you previously installed the standalone package, back up your `P3RFix.ini` se ## Configuration - See **P3RFix.ini** to adjust settings for the fix. +- `[Render Texture Resolution].Multiplier` applies after automatic scaling based on viewport height and screen percentage. Automatic scaling keeps at least the native 1080p target quality; an explicit value below 1 can still reduce it. The combined multiplier is limited to 0.25–4. ## Screenshots @@ -67,10 +70,10 @@ Use Windows with Visual Studio 2022's C++ build tools and Windows SDK, CMake, Po git clone --recurse-submodules https://github.com/dev-camo/P3RFix.git cd P3RFix git submodule update --init --recursive -./create_release.ps1 -Version 1.3.0 +./create_release.ps1 -Version 1.4.0 ``` -The script builds Windows x64 release binaries and creates `build/P3RFix_1.3.0.zip` and `build/P3RFix_Reloaded-II.zip`. The version is embedded in the binary and Reloaded-II metadata. Dependencies use the revisions recorded in Git; the standalone package bundles a pinned Ultimate ASI Loader download. +The script builds Windows x64 release binaries and creates `build/P3RFix_1.4.0.zip` and `build/P3RFix_Reloaded-II.zip`. The version is embedded in the binary and Reloaded-II metadata. Dependencies use the revisions recorded in Git; the standalone package bundles a pinned Ultimate ASI Loader download. Packaging combines all thirteen maintained license/notice documents into `LICENSES` and checks both archives for complete matching notices and the expected binary/configuration paths. The [Build packages workflow](https://github.com/dev-camo/P3RFix/actions/workflows/build.yml) also builds and packages pushes and pull requests to `main`, and supports manual runs. Review its build result and downloadable artifacts before tagging a release. @@ -78,43 +81,47 @@ The [Build packages workflow](https://github.com/dev-camo/P3RFix/actions/workflo Console construction and menu render texture access use the focused interface in `src/unreal/Integration.hpp`. Game memory layouts and dispatch constants live privately in `src/unreal/detail/Layouts.hpp`, with their original Dumper-7 symbols recorded beside them. The full generated SDK is no longer a build or checkout dependency. Add only the fields needed for a new feature, with provenance and a boundary test. -Run the synthetic memory tests in Windows x64 debug and release configurations: +Run the synthetic memory and fix behavior tests in Windows x64 debug and release configurations: ```powershell -xmake f -y -p windows -a x64 -m debug --fix_version=1.3.0 +xmake f -y -p windows -a x64 -m debug --fix_version=1.4.0 xmake build -y unreal-integration-tests xmake run unreal-integration-tests -xmake f -y -p windows -a x64 -m release --fix_version=1.3.0 +xmake build -y fix-behavior-tests +xmake run fix-behavior-tests +xmake f -y -p windows -a x64 -m release --fix_version=1.4.0 xmake build -y unreal-integration-tests xmake run unreal-integration-tests +xmake build -y fix-behavior-tests +xmake run fix-behavior-tests python tools/unreal_inventory.py --phase minimal ``` -These tests check layouts, reflected console calls, failure handling, name conversion, and render writes using independent byte fixtures. They do not establish compatibility with a particular game update; compare console operation and menu rendering with the baseline DLL in the actual game. Console lookup assumes the inherited signatures, registry layout, and virtual dispatch entry; render access remains available if console dependencies cannot be discovered. +The integration tests check layouts, reflected console calls, failure handling, name conversion, and render writes using independent byte fixtures. The behavior tests check scaling arithmetic, integer bounds, raw-input read failures, signed mouse deltas, and allocation failure using a controlled Windows reader. They do not establish compatibility with a particular game update; compare console operation, input, and menu rendering with the baseline DLL in the actual game. Console lookup assumes the inherited signatures, registry layout, and virtual dispatch entry; independent hook installation runs before the optional console discovery wait. ## Releasing -Commit the release changes, then push the branch and a version tag pointing at that commit: +Validate the packages and game behavior, then have a human review the branch before merging. After approval, push a new version tag pointing at the exact tested release commit: ```sh git push origin HEAD -git tag -a 1.3.0 -m "Release 1.3.0" -git push origin 1.3.0 +git tag -a 1.4.0 -m "Release 1.4.0" +git push origin 1.4.0 ``` -Use a new version number for each release. Tags may use `1.3.0` or `v1.3.0`. The [Publish release workflow](https://github.com/dev-camo/P3RFix/actions/workflows/release.yml) checks out the tagged commit and its submodules, builds that version and publishes both ZIP files on the matching [GitHub Release](https://github.com/dev-camo/P3RFix/releases). +Use a new version number for each release. Tags may use `1.4.0` or `v1.4.0`. The [Publish release workflow](https://github.com/dev-camo/P3RFix/actions/workflows/release.yml) checks out the tagged commit and its submodules, builds that version and publishes both ZIP files on the matching [GitHub Release](https://github.com/dev-camo/P3RFix/releases). To retry a release for an existing tag, open **Publish release → Run workflow** in GitHub Actions and enter the tag in the `tag` field, or use GitHub CLI: ```sh -gh workflow run release.yml -f tag=1.3.0 +gh workflow run release.yml -f tag=1.4.0 ``` A retry rebuilds the commit referenced by that tag and replaces the release's ZIP attachments. The workflow checks the tag's commit before building and publishing. ## License and Credits -P3RFix is distributed under the [MIT License](LICENSE.md). Original copyrights and third-party attributions are preserved. See [third-party notices](THIRD_PARTY_NOTICES.md) and [license texts](licenses/) for dependency licensing and retained Unreal integration provenance. +P3RFix is distributed under the [MIT License](LICENSE.md). Original copyrights and third-party attributions are preserved. In the source checkout, see [third-party notices](THIRD_PARTY_NOTICES.md) and [license texts](licenses/) for dependency licensing and retained Unreal integration provenance. Release ZIPs ship those complete texts together in one generated `LICENSES` document; individual source notice files remain maintained here. - Lyall for the original P3RFix implementation. - [Ultimate ASI Loader](https://github.com/ThirteenAG/Ultimate-ASI-Loader) for ASI loading. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 8d4b3ea..4180847 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,6 +1,6 @@ # Third-party notices -P3RFix retains its original MIT license and copyright notice in [LICENSE.md](LICENSE.md), with an additional copyright notice for current maintenance. Third-party components retain their own licenses and attribution. Both release packages include this notice and the `licenses/` directory. +P3RFix retains its original MIT license and copyright notice in [LICENSE.md](LICENSE.md), with an additional copyright notice for current maintenance. Third-party components retain their own licenses and attribution. Both release packages carry one combined `LICENSES` document containing the project license, this notice, and the complete component license texts. The linked individual license files remain maintained in the source repository. ## Components compiled into P3RFix.asi diff --git a/create_release.ps1 b/create_release.ps1 index 2967b4f..1d483dd 100644 --- a/create_release.ps1 +++ b/create_release.ps1 @@ -7,8 +7,263 @@ param ( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +# Keep this manifest explicit: adding or removing a source notice requires review. +# Loader-related sections are retained in both packages; only standalone bundles +# the loader binary itself. +$LicenseSections = @( + @{ Title = 'P3RFix license'; Path = 'LICENSE.md' } + @{ Title = 'Third-party attribution and provenance'; Path = 'THIRD_PARTY_NOTICES.md' } + @{ Title = 'Compiled dependency: inipp'; Path = 'licenses/inipp/LICENSE.txt' } + @{ Title = 'Compiled dependency: spdlog'; Path = 'licenses/spdlog/LICENSE' } + @{ Title = 'Compiled dependency: fmt'; Path = 'licenses/fmt/LICENSE' } + @{ Title = 'Compiled dependency: SafetyHook'; Path = 'licenses/SafetyHook/LICENSE' } + @{ Title = 'Compiled dependency: Zydis'; Path = 'licenses/Zydis/LICENSE' } + @{ Title = 'Compiled dependency: Zycore'; Path = 'licenses/Zycore/LICENSE' } + @{ Title = 'Retained Unreal support: UnrealContainers'; Path = 'licenses/UnrealContainers/LICENSE' } + @{ Title = 'Standalone loader: Ultimate ASI Loader'; Path = 'licenses/Ultimate-ASI-Loader/LICENSE' } + @{ Title = 'Standalone loader dependency: miniz'; Path = 'licenses/Ultimate-ASI-Loader/miniz-LICENSE' } + @{ Title = 'Standalone loader dependency: MinHook and Hacker Disassembler Engine'; Path = 'licenses/Ultimate-ASI-Loader/MinHook-LICENSE.txt' } + @{ Title = 'Standalone loader dependency: injector utility'; Path = 'licenses/Ultimate-ASI-Loader/injector-utility-LICENSE.txt' } +) + +function Read-LicenseText { + param ( + [string]$RepositoryRoot, + [string]$RelativePath + ) + + $InputPath = Join-Path $RepositoryRoot $RelativePath + if (-not (Test-Path -LiteralPath $InputPath -PathType Leaf)) { + throw "Missing license input: $RelativePath" + } + + try { + # Strict decoding reports damaged input rather than replacing author names. + $Utf8 = [System.Text.UTF8Encoding]::new($false, $true) + $Text = $Utf8.GetString([System.IO.File]::ReadAllBytes($InputPath)) + } catch { + throw "Could not read UTF-8 license input '$RelativePath': $($_.Exception.Message)" + } + if ($Text.StartsWith([string][char]0xFEFF, [System.StringComparison]::Ordinal)) { + $Text = $Text.Substring(1) + } + if ([string]::IsNullOrWhiteSpace($Text)) { + throw "Empty license input: $RelativePath" + } + + # Normalize only line endings. Preserve wording, indentation, and whitespace. + return $Text.Replace("`r`n", "`n").Replace("`r", "`n").Replace("`n", "`r`n") +} + +function Write-CombinedLicenseFile { + param ( + [string]$RepositoryRoot, + [object[]]$Sections, + [string]$DestinationPath + ) + + $InputPaths = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase) + $Titles = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal) + $Inputs = @() + foreach ($Section in $Sections) { + if (-not $InputPaths.Add($Section.Path)) { + throw "Duplicate license input in manifest: $($Section.Path)" + } + if (-not $Titles.Add($Section.Title)) { + throw "Duplicate license section title: $($Section.Title)" + } + $Inputs += @{ + Title = $Section.Title + Path = $Section.Path + Text = Read-LicenseText -RepositoryRoot $RepositoryRoot -RelativePath $Section.Path + } + } + + # Enumeration is only for completeness checking; it never determines order. + $LicenseDirectory = Join-Path $RepositoryRoot 'licenses' + if (-not (Test-Path -LiteralPath $LicenseDirectory -PathType Container)) { + throw 'Missing license input directory: licenses' + } + $ActualPaths = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase) + foreach ($File in Get-ChildItem -LiteralPath $LicenseDirectory -File -Recurse -Force) { + $RelativePath = [System.IO.Path]::GetRelativePath($RepositoryRoot, $File.FullName). + Replace('\', '/') + [void]$ActualPaths.Add($RelativePath) + if (-not $InputPaths.Contains($RelativePath)) { + throw "License input is not listed in the manifest: $RelativePath" + } + } + foreach ($InputPath in $InputPaths) { + if ($InputPath.StartsWith('licenses/', [System.StringComparison]::OrdinalIgnoreCase) -and + -not $ActualPaths.Contains($InputPath)) { + throw "Manifest license input is absent from licenses inventory: $InputPath" + } + } + + $Newline = "`r`n" + $Document = [System.Text.StringBuilder]::new() + [void]$Document.Append("P3RFix release licenses and notices$Newline$Newline") + [void]$Document.Append("This document collects P3RFix's license and the full notices for components$Newline") + [void]$Document.Append("used or credited by this release. Each component retains its own terms.$Newline") + [void]$Document.Append("Ultimate ASI Loader and its dependencies describe the loader bundled only$Newline") + [void]$Document.Append("with the standalone ZIP. The Reloaded-II ZIP does not include or require$Newline") + [void]$Document.Append("the standalone loader binary, dsound.dll.$Newline$Newline") + + $Payloads = @() + foreach ($SectionInput in $Inputs) { + $Heading = "======================================================================$Newline" + + "$($SectionInput.Title)$Newline" + + "Source repository path: $($SectionInput.Path)$Newline$Newline" + [void]$Document.Append($Heading) + if ($SectionInput.Path -eq 'THIRD_PARTY_NOTICES.md') { + [void]$Document.Append("Repository-relative links in the notice below refer to the source checkout.$Newline") + [void]$Document.Append("The full referenced license texts follow in this same document.$Newline$Newline") + } elseif ($SectionInput.Path.StartsWith('licenses/Ultimate-ASI-Loader/', + [System.StringComparison]::OrdinalIgnoreCase)) { + [void]$Document.Append("This loader component is bundled only with the standalone ZIP.$Newline") + [void]$Document.Append("The Reloaded-II ZIP does not include the standalone loader binary.$Newline$Newline") + } + + $Payloads += @{ + Heading = $Heading + Path = $SectionInput.Path + Offset = $Document.Length + Length = $SectionInput.Text.Length + } + [void]$Document.Append($SectionInput.Text) + if (-not $SectionInput.Text.EndsWith($Newline, [System.StringComparison]::Ordinal)) { + [void]$Document.Append($Newline) + } + [void]$Document.Append($Newline) + } + + $Utf8 = [System.Text.UTF8Encoding]::new($false, $true) + [System.IO.File]::WriteAllText($DestinationPath, $Document.ToString(), $Utf8) + + # Verify the written document, including every complete payload in order. + $WrittenBytes = [System.IO.File]::ReadAllBytes($DestinationPath) + $HasEncodingMarker = $WrittenBytes.Length -ge 3 -and + $WrittenBytes[0] -eq 0xEF -and $WrittenBytes[1] -eq 0xBB -and $WrittenBytes[2] -eq 0xBF + if ($WrittenBytes.Length -eq 0 -or $HasEncodingMarker) { + throw "Combined licensing document is empty or has an encoding marker: $DestinationPath" + } + $WrittenText = $Utf8.GetString($WrittenBytes) + $PreviousHeading = -1 + foreach ($Payload in $Payloads) { + $HeadingOffset = $WrittenText.IndexOf($Payload.Heading, [System.StringComparison]::Ordinal) + if ($HeadingOffset -le $PreviousHeading -or + $WrittenText.IndexOf($Payload.Heading, $HeadingOffset + 1, + [System.StringComparison]::Ordinal) -ne -1) { + throw "Combined licensing section is missing, duplicated, or out of order: $($Payload.Path)" + } + $ExpectedText = Read-LicenseText -RepositoryRoot $RepositoryRoot -RelativePath $Payload.Path + if ($WrittenText.Substring($Payload.Offset, $Payload.Length) -cne $ExpectedText) { + throw "Combined licensing section does not preserve its input: $($Payload.Path)" + } + $PreviousHeading = $HeadingOffset + } + if (-not $WrittenText.EndsWith($Newline, [System.StringComparison]::Ordinal)) { + throw "Combined licensing document has no final newline: $DestinationPath" + } + Write-Information "Combined licensing document: all $($Inputs.Count) source documents included." -InformationAction Continue +} + +function Test-ByteArraysEqual { + param ( + [byte[]]$First, + [byte[]]$Second + ) + + if ($First.Length -ne $Second.Length) { + return $false + } + for ($Index = 0; $Index -lt $First.Length; $Index++) { + if ($First[$Index] -ne $Second[$Index]) { + return $false + } + } + return $true +} + +function Assert-PackageContent { + param ( + [string]$ArchivePath, + [byte[]]$ReferenceLicenses, + [bool]$Standalone + ) + + $Archive = [System.IO.Compression.ZipFile]::OpenRead($ArchivePath) + try { + $Entries = @{} + $LicenseEntries = @() + $LoaderEntries = @() + foreach ($Entry in $Archive.Entries) { + $Name = $Entry.FullName.Replace('\', '/') + if ($Name -match '(^|/)licenses/' -or + $Name -ieq 'LICENSE.md' -or $Name -ieq 'THIRD_PARTY_NOTICES.md') { + throw "Archive '$ArchivePath' contains a legacy licensing entry: $Name" + } + if ($Name.EndsWith('/')) { + continue + } + if ($Entries.ContainsKey($Name)) { + throw "Archive '$ArchivePath' contains a duplicate entry: $Name" + } + $Entries[$Name] = $Entry + $FileName = ($Name -split '/')[-1] + if ($FileName -ieq 'LICENSES') { + $LicenseEntries += $Name + } + if ($FileName -ieq 'dsound.dll' -or $FileName -ieq 'dinput8.dll') { + $LoaderEntries += $Name + } + } + + if ($LicenseEntries.Count -ne 1 -or $LicenseEntries[0] -ine 'LICENSES') { + throw "Archive '$ArchivePath' must contain exactly one LICENSES file at its existing root." + } + $RequiredEntries = @('P3RFix.asi', 'P3RFix.ini', 'LICENSES') + if ($Standalone) { + $RequiredEntries += 'dsound.dll' + if ($LoaderEntries.Count -ne 1 -or $LoaderEntries[0] -ine 'dsound.dll') { + throw "Archive '$ArchivePath' must contain its standalone loader only at dsound.dll." + } + } else { + $RequiredEntries += 'ModConfig.json' + if ($LoaderEntries.Count -ne 0) { + throw "Archive '$ArchivePath' must not contain the standalone loader." + } + } + foreach ($Name in $RequiredEntries) { + if (-not $Entries.ContainsKey($Name) -or $Entries[$Name].Length -eq 0) { + throw "Archive '$ArchivePath' is missing a nonempty required root entry: $Name" + } + } + + $EntryStream = $Entries['LICENSES'].Open() + $Memory = [System.IO.MemoryStream]::new() + try { + $EntryStream.CopyTo($Memory) + $ArchivedLicenses = $Memory.ToArray() + } finally { + $Memory.Dispose() + $EntryStream.Dispose() + } + if (-not (Test-ByteArraysEqual -First $ArchivedLicenses -Second $ReferenceLicenses)) { + throw "Archive '$ArchivePath' has LICENSES bytes that differ from the generated reference." + } + Write-Information "$(Split-Path $ArchivePath -Leaf): one complete LICENSES; package destinations verified." -InformationAction Continue + return ,$ArchivedLicenses + } finally { + $Archive.Dispose() + } +} + if (-not $Version) { - throw 'Pass a release version, for example: ./create_release.ps1 -Version 1.3.0' + throw 'Pass a release version, for example: ./create_release.ps1 -Version 1.4.0' } if (-not $IsWindows) { throw 'Release builds require Windows with Visual Studio 2022, CMake, and xmake.' @@ -29,6 +284,7 @@ try { $StandaloneDirectory = Join-Path $StagingDirectory 'standalone' $ReloadedDirectory = Join-Path $StagingDirectory 'reloaded' $LoaderDirectory = Join-Path $StagingDirectory 'loader' + $CombinedLicensePath = Join-Path $StagingDirectory 'LICENSES' $ZipPath = Join-Path $BuildDirectory $ZipName $ReloadedZipPath = Join-Path $BuildDirectory 'P3RFix_Reloaded-II.zip' $ReleaseBodyPath = Join-Path $BuildDirectory 'release_body.md' @@ -46,13 +302,16 @@ try { } New-Item -ItemType Directory -Path $StandaloneDirectory, $ReloadedDirectory, $LoaderDirectory, $DownloadDirectory -Force | Out-Null + Write-CombinedLicenseFile -RepositoryRoot $PSScriptRoot -Sections $LicenseSections ` + -DestinationPath $CombinedLicensePath + foreach ($OutputPath in @($ZipPath, $ReloadedZipPath, $ReleaseBodyPath)) { if (Test-Path -LiteralPath $OutputPath) { Remove-Item -LiteralPath $OutputPath -Force } } - Write-Host "Building P3RFix $Version for Windows x64..." + Write-Information "Building P3RFix $Version for Windows x64..." -InformationAction Continue & xmake f -y -p windows -a x64 -m release "--fix_version=$Version" if ($LASTEXITCODE -ne 0) { throw "xmake configuration failed ($LASTEXITCODE)." } & xmake build -y -v @@ -78,9 +337,8 @@ try { } foreach ($PackageDirectory in @($StandaloneDirectory, $ReloadedDirectory)) { - Copy-Item -LiteralPath $BinaryPath, 'P3RFix.ini', 'LICENSE.md', - 'THIRD_PARTY_NOTICES.md' -Destination $PackageDirectory - Copy-Item -LiteralPath 'licenses' -Destination $PackageDirectory -Recurse + Copy-Item -LiteralPath $BinaryPath, 'P3RFix.ini', $CombinedLicensePath ` + -Destination $PackageDirectory } Copy-Item -LiteralPath $LoaderDllPath -Destination (Join-Path $StandaloneDirectory 'dsound.dll') @@ -91,6 +349,17 @@ try { Compress-Archive -Path (Join-Path $StandaloneDirectory '*') -DestinationPath $ZipPath Compress-Archive -Path (Join-Path $ReloadedDirectory '*') -DestinationPath $ReloadedZipPath + Add-Type -AssemblyName System.IO.Compression.FileSystem + $ReferenceLicenses = [System.IO.File]::ReadAllBytes($CombinedLicensePath) + $StandaloneLicenses = Assert-PackageContent -ArchivePath $ZipPath ` + -ReferenceLicenses $ReferenceLicenses -Standalone $true + $ReloadedLicenses = Assert-PackageContent -ArchivePath $ReloadedZipPath ` + -ReferenceLicenses $ReferenceLicenses -Standalone $false + if (-not (Test-ByteArraysEqual -First $StandaloneLicenses -Second $ReloadedLicenses)) { + throw 'Packaged LICENSES documents do not match each other.' + } + Write-Information 'Both packaged LICENSES documents match the generated reference.' -InformationAction Continue + $ReleaseBody = (Get-Content -LiteralPath 'release_body.md' -Raw). Replace('', $ZipName).Replace('', $Version) if (Test-Path -LiteralPath 'CHANGELOG.md') { @@ -105,7 +374,7 @@ try { $ReleaseBody | Set-Content -LiteralPath $ReleaseBodyPath -Encoding utf8NoBOM Remove-Item -LiteralPath $StagingDirectory -Recurse -Force - Write-Host "Created $ZipPath and $ReloadedZipPath" + Write-Information "Created $ZipPath and $ReloadedZipPath" -InformationAction Continue } finally { Pop-Location } diff --git a/release_body.md b/release_body.md index e315bde..f1ef113 100644 --- a/release_body.md +++ b/release_body.md @@ -2,6 +2,7 @@ - Download `` from the release assets below. - Extract its contents into the folder containing `P3R.exe`: `steamapps\common\P3R\P3R\Binaries\Win64` for Steam or `XboxGames\Persona 3 Reload\Content\P3R\Binaries\WinGDK` for Xbox/MS Store. +- The standalone ZIP contains `P3RFix.asi`, `P3RFix.ini`, `dsound.dll`, and one complete `LICENSES` document. An overwrite upgrade can leave older notice files in place. ### Steam Deck/Linux @@ -15,6 +16,7 @@ This option applies to both Windows and Steam Deck/Linux. - If you previously installed the standalone package, back up your configuration and remove its `P3RFix.ini`, `P3RFix.asi`, `dsound.ini` (if present), and `dsound.dll` from the game folder before switching. - Download `P3RFix_Reloaded-II.zip` from the release assets below and drag it onto the Reloaded-II window. See the [Reloaded-II installation guide](https://reloaded-project.github.io/Reloaded-II/QuickStart/) for manual setup. - Enable P3RFix in the Reloaded-II mod list and launch the game through Reloaded-II. +- This ZIP contains the ASI, INI, Reloaded-II metadata, and the same `LICENSES` document. It does not include the standalone loader binary. diff --git a/src/dllmain.cpp b/src/dllmain.cpp index 717543c..d0fd06c 100644 --- a/src/dllmain.cpp +++ b/src/dllmain.cpp @@ -7,9 +7,12 @@ #include #include "unreal/Integration.hpp" +#include "input/RawMousePacket.hpp" +#include "render/Scaling.hpp" #include #include +#include #include #include @@ -20,7 +23,7 @@ HMODULE thisModule; // Fix details #ifndef P3RFIX_VERSION -#define P3RFIX_VERSION "1.3.0" +#define P3RFIX_VERSION "1.4.0" #endif std::string sFixName = "P3RFix"; std::string sFixVersion = P3RFIX_VERSION; @@ -74,7 +77,6 @@ int iCurrentResX = 0; int iCurrentResY = 0; bool bIntroSkipHasRun = false; int iFadeStatus = 0; -float fRenTexResMulti = 1.0f; int iRTCapX = 1920; int iRTCapY = 1080; float fRawMouseX = 0.0f; @@ -375,34 +377,43 @@ void* UTextureRenderTarget2D_PostLoad_hk(void* pRenderTarget) if (!target) return UTextureRenderTarget2D_PostLoad_fn.stdcall(pRenderTarget); - // Calculate optimal resolution multiplier assuming target is 1080p - // Screen percentage is only retrieved when the hook is run, meaning that on first boot we have to assume it is 100% - float fOptimalRenTexResMulti = (iCurrentResY * (fScreenPercentage / 100)) / 1080; - - if (iCurrentResX <= 1920 || iCurrentResX <= 1080) - fOptimalRenTexResMulti = 1.0f; // Avoid lowering resolution of render targets when resolution is <1080p. - - if (fRenTexResUserMulti == 1.0f) - fRenTexResMulti = fOptimalRenTexResMulti; // If set to 1, use the calculated optimal multiplier. - else - fRenTexResMulti = fOptimalRenTexResMulti * fRenTexResUserMulti; // If not set to 1, then multiply on top using user defined value. - - if (fRenTexResMulti < 0.25f || fRenTexResMulti > 4.0f) { - fRenTexResMulti = std::clamp(fRenTexResMulti, (float)0.25, (float)4); - spdlog::warn("Render Texture 2D Resolution: fRenTexResMulti value invalid, clamped to {}", fRenTexResMulti); + // Keep the automatic baseline at native quality, then apply the user's multiplier. + const auto scale = p3r::render::ComputeRenderTargetScale( + iCurrentResY, fScreenPercentage, fRenTexResUserMulti); + if (scale.usedFallback) { + // An unknown startup height is expected. Report invalid runtime values + // separately so that early startup cannot suppress a later warning. + static bool bLoggedInvalidScreenPercentage = false; + if ((!std::isfinite(fScreenPercentage) || fScreenPercentage <= 0.0f) && + !bLoggedInvalidScreenPercentage) { + spdlog::warn("Render Texture 2D Resolution: Invalid screen percentage {}; using 100 for scaling. " + "Further percentage warnings are suppressed.", fScreenPercentage); + bLoggedInvalidScreenPercentage = true; + } + static bool bLoggedInvalidUserMultiplier = false; + if ((!std::isfinite(fRenTexResUserMulti) || fRenTexResUserMulti <= 0.0f) && + !bLoggedInvalidUserMultiplier) { + spdlog::warn("Render Texture 2D Resolution: Invalid user multiplier {}; using 1 for scaling. " + "Further multiplier warnings are suppressed.", fRenTexResUserMulti); + bLoggedInvalidUserMultiplier = true; + } } - spdlog::info("Render Texture 2D Resolution: fRenTexResMulti = {}", fRenTexResMulti); + spdlog::info("Render Texture 2D Resolution: fRenTexResMulti = {}", scale.finalMultiplier); spdlog::info("Render Texture 2D Resolution: Old render texture resolution = {}x{}", target->width, target->height); - const auto width = static_cast(target->width * fRenTexResMulti); - const auto height = static_cast(target->height * fRenTexResMulti); - if (p3r::unreal::SetRenderTargetSize(pRenderTarget, width, height)) { + const auto dimensions = p3r::render::ScaleDimensions( + target->width, target->height, scale.finalMultiplier); + if (!dimensions) { + spdlog::warn("Render Texture 2D Resolution: Invalid or overflowing dimensions; leaving the target unchanged."); + } + else if (p3r::unreal::SetRenderTargetSize(pRenderTarget, dimensions->width, dimensions->height)) { if (target->isRgba16f) { - iRTCapX = width; - iRTCapY = height; + iRTCapX = dimensions->width; + iRTCapY = dimensions->height; } - spdlog::info("Render Texture 2D Resolution: New render texture resolution = {}x{}", width, height); + spdlog::info("Render Texture 2D Resolution: New render texture resolution = {}x{}", + dimensions->width, dimensions->height); } // Forward to the original function once, including unavailable targets. @@ -844,40 +855,34 @@ BOOL __stdcall RegisterRawInputDevices_Injected(PCRAWINPUTDEVICE pRawInputDevice } static SafetyHookInline PeekMessageWHook{}; -UINT __stdcall PeekMessageW_Injected(LPMSG lpMsg, HWND hWnd, UINT wMsgFilterMin, UINT wMsgFilterMax, UINT wRemoveMsg) { - // Alrighty boys, we need to basically write our own input handling so buckle up +UINT __stdcall PeekMessageW_Injected(LPMSG lpMsg, HWND hWnd, UINT wMsgFilterMin, UINT wMsgFilterMax, UINT wRemoveMsg) +{ + // Observe only messages removed by the game; peeking must not accumulate motion twice. auto output = PeekMessageWHook.call(lpMsg, hWnd, wMsgFilterMin, wMsgFilterMax, wRemoveMsg); - if (output && wRemoveMsg & 0b1) { - // Only read (valid) messages on consumption so we don't accidentally double-process anything - // Game seems to process raw mouse input despite unregistering it (thank God) so we don't have to fight it for messages - if (lpMsg->message == WM_INPUT) { - // We've just received an input message. - UINT dwSize = 0; - GetRawInputData((HRAWINPUT)lpMsg->lParam, RID_INPUT, NULL, &dwSize, sizeof(RAWINPUTHEADER)); //Get size of rawinput structure - - LPBYTE lpb = new BYTE[dwSize]; - if (lpb == NULL) - return output; // No Raw input data, abort current message injection - - if (GetRawInputData((HRAWINPUT)lpMsg->lParam, RID_INPUT, lpb, &dwSize, sizeof(RAWINPUTHEADER)) != dwSize) //Actually retrieve raw input data into lpb - spdlog::debug("Win32 API Error: GetRawInputData does not return correct size!"); //Doubt this'll happen but might as well log it - - RAWINPUT* raw = (RAWINPUT*)lpb; - if (raw->header.dwType == RIM_TYPEMOUSE) - { - // We got the good stuff, mouse input data! - if (!raw->data.mouse.usFlags) { - if (raw->data.mouse.lLastX || raw->data.mouse.lLastY) { - bLastValidInputWasFromMouse = true; - } - // These numbers were completely just eyeballed. TODO: if you're bored you can find more "accurate" numbers - fRawMouseX += raw->data.mouse.lLastX / 1200.0f; - fRawMouseY += raw->data.mouse.lLastY / 1200.0f; - } - } - delete[] lpb; + if (!output || !(wRemoveMsg & PM_REMOVE) || !lpMsg || lpMsg->message != WM_INPUT) + return output; + + const auto packet = p3r::input::ReadRelativeMousePacket( + reinterpret_cast(lpMsg->lParam), GetRawInputData); + if (packet.status == p3r::input::PacketStatus::Invalid) { + static bool bLoggedInvalidPacket = false; + if (!bLoggedInvalidPacket) { + spdlog::warn("Mouse Fix: Ignoring invalid or incomplete raw-input data. " + "Further invalid-packet warnings are suppressed."); + bLoggedInvalidPacket = true; + } + } + else if (packet.status == p3r::input::PacketStatus::AllocationFailed) { + static bool bLoggedAllocationFailure = false; + if (!bLoggedAllocationFailure) { + spdlog::warn("Mouse Fix: Unable to allocate raw-input storage. " + "Further allocation warnings are suppressed."); + bLoggedAllocationFailure = true; } } + + p3r::input::ApplyRelativeMousePacket( + packet, fRawMouseX, fRawMouseY, bLastValidInputWasFromMouse); return output; } @@ -1003,13 +1008,14 @@ DWORD __stdcall Main(void*) InitializeUnrealIntegration(); Resolution(); RenderTextures(); - EnableConsole(); IntroSkip(); AspectRatioFOV(); HUDFix(); Framerate(); WindowFocus(); MouseFix(); + spdlog::info("Independent fix installation attempts completed."); + EnableConsole(); return true; } diff --git a/src/input/RawMousePacket.cpp b/src/input/RawMousePacket.cpp new file mode 100644 index 0000000..0f54716 --- /dev/null +++ b/src/input/RawMousePacket.cpp @@ -0,0 +1,92 @@ +#include "RawMousePacket.hpp" + +#include +#include +#include + +namespace p3r::input +{ +namespace +{ +constexpr UINT readError = static_cast(-1); + +RawInputBuffer AllocateBuffer(std::size_t wordCount) noexcept +{ + return RawInputBuffer(new (std::nothrow) std::uint32_t[wordCount]); +} + +PacketResult NoMovement(PacketStatus status) noexcept +{ + return {status, {0, 0}}; +} +} // namespace + +PacketResult ReadRelativeMousePacket(HRAWINPUT input, RawInputReader reader, + RawInputAllocator allocator) noexcept +{ + if (input == nullptr || reader == nullptr) + return NoMovement(PacketStatus::Invalid); + + UINT capacity = 0; + const UINT queryResult = reader(input, RID_INPUT, nullptr, &capacity, sizeof(RAWINPUTHEADER)); + if (queryResult != 0 || capacity < sizeof(RAWINPUTHEADER)) + return NoMovement(PacketStatus::Invalid); + + // Round up for DWORD alignment without allowing the allocation arithmetic + // to overflow. The API still receives the original capacity in bytes. + constexpr std::size_t wordSize = sizeof(std::uint32_t); + const std::size_t byteCapacity = capacity; + if (byteCapacity > (std::numeric_limits::max)() - (wordSize - 1)) + return NoMovement(PacketStatus::Invalid); + + const std::size_t wordCount = (byteCapacity + wordSize - 1) / wordSize; + RawInputBuffer buffer = (allocator != nullptr ? allocator : &AllocateBuffer)(wordCount); + if (!buffer) + return NoMovement(PacketStatus::AllocationFailed); + + UINT readSize = capacity; + const UINT copiedCount = reader(input, RID_INPUT, buffer.get(), &readSize, sizeof(RAWINPUTHEADER)); + if (copiedCount == readError || copiedCount != capacity || readSize != capacity) + return NoMovement(PacketStatus::Invalid); + + // Copy into SDK objects after checking the available bytes. DWORD alignment + // alone does not imply the stronger alignment of the x64 packet header. + RAWINPUTHEADER header{}; + std::memcpy(&header, buffer.get(), sizeof(header)); + if (header.dwSize < sizeof(header) || header.dwSize != copiedCount) + return NoMovement(PacketStatus::Invalid); + + if (header.dwType != RIM_TYPEMOUSE) + return NoMovement(PacketStatus::Ignored); + + constexpr std::size_t mouseOffset = offsetof(RAWINPUT, data); + constexpr std::size_t mousePacketSize = mouseOffset + sizeof(RAWMOUSE); + if (copiedCount < mousePacketSize) + return NoMovement(PacketStatus::Invalid); + + RAWMOUSE mouse{}; + const auto* bytes = reinterpret_cast(buffer.get()); + std::memcpy(&mouse, bytes + mouseOffset, sizeof(mouse)); + + // Retain the current fix's exact flag selection. Other relative flag + // combinations need a separate camera-behavior change and validation. + if (mouse.usFlags != 0) + return NoMovement(PacketStatus::Ignored); + + return {PacketStatus::RelativeMouse, {mouse.lLastX, mouse.lLastY}}; +} + +void ApplyRelativeMousePacket(const PacketResult& packet, float& accumulatedX, float& accumulatedY, + bool& lastValidInputWasFromMouse) noexcept +{ + if (packet.status != PacketStatus::RelativeMouse) + return; + + if (packet.delta.x != 0 || packet.delta.y != 0) + lastValidInputWasFromMouse = true; + + // Preserve the existing mouse fix's sensitivity and signed movement. + accumulatedX += static_cast(packet.delta.x) / 1200.0f; + accumulatedY += static_cast(packet.delta.y) / 1200.0f; +} +} // namespace p3r::input diff --git a/src/input/RawMousePacket.hpp b/src/input/RawMousePacket.hpp new file mode 100644 index 0000000..2b0db79 --- /dev/null +++ b/src/input/RawMousePacket.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include + +#include + +namespace p3r::input +{ +using RawInputReader = UINT(WINAPI*)(HRAWINPUT, UINT, LPVOID, PUINT, UINT); + +struct MouseDelta +{ + LONG x; + LONG y; +}; + +enum class PacketStatus +{ + RelativeMouse, + Ignored, + Invalid, + AllocationFailed +}; + +struct PacketResult +{ + PacketStatus status; + MouseDelta delta; +}; + +// DWORD-aligned storage satisfies GetRawInputData's buffer alignment contract. +// Tests may supply a failing allocator without exhausting system memory. +using RawInputBuffer = std::unique_ptr; +using RawInputAllocator = RawInputBuffer (*)(std::size_t wordCount) noexcept; + +PacketResult ReadRelativeMousePacket(HRAWINPUT input, RawInputReader reader, + RawInputAllocator allocator = nullptr) noexcept; + +// The message hook calls this only for removed WM_INPUT messages. Keeping this +// small operation shared lets tests verify the existing camera interpretation. +void ApplyRelativeMousePacket(const PacketResult& packet, float& accumulatedX, float& accumulatedY, + bool& lastValidInputWasFromMouse) noexcept; +} // namespace p3r::input diff --git a/src/render/Scaling.cpp b/src/render/Scaling.cpp new file mode 100644 index 0000000..02ce740 --- /dev/null +++ b/src/render/Scaling.cpp @@ -0,0 +1,64 @@ +#include "Scaling.hpp" + +#include +#include +#include + +namespace p3r::render +{ +ScaleResult ComputeRenderTargetScale(int viewportHeight, float screenPercentage, + float userMultiplier) noexcept +{ + bool usedFallback = viewportHeight <= 0; + + if (!std::isfinite(screenPercentage) || screenPercentage <= 0.0f) + { + screenPercentage = 100.0f; + usedFallback = true; + } + + if (!std::isfinite(userMultiplier) || userMultiplier <= 0.0f) + { + userMultiplier = 1.0f; + usedFallback = true; + } + + double automaticMultiplier = 1.0; + if (viewportHeight > 0) + { + const double effectiveHeight = static_cast(viewportHeight) * screenPercentage / 100.0; + automaticMultiplier = std::max(1.0, effectiveHeight / 1080.0); + } + + // Apply limits to the combined value, not the automatic baseline. A high + // baseline and deliberate undersampling must keep their relative meaning. + const double combinedMultiplier = automaticMultiplier * userMultiplier; + const float finalMultiplier = static_cast(std::clamp(combinedMultiplier, 0.25, 4.0)); + + return {automaticMultiplier, finalMultiplier, usedFallback}; +} + +std::optional ScaleDimensions(std::int32_t width, std::int32_t height, + float multiplier) noexcept +{ + if (width <= 0 || height <= 0 || !std::isfinite(multiplier) || multiplier <= 0.0f) + { + return std::nullopt; + } + + const double scaledWidth = static_cast(width) * multiplier; + const double scaledHeight = static_cast(height) * multiplier; + constexpr double maximumDimension = static_cast(std::numeric_limits::max()); + + // Check floating-point bounds before either cast: casting an out-of-range + // value to an integer is unsafe even when a later clamp would discard it. + if (!std::isfinite(scaledWidth) || !std::isfinite(scaledHeight) || scaledWidth > maximumDimension || + scaledHeight > maximumDimension) + { + return std::nullopt; + } + + return ScaledDimensions{static_cast(std::max(1.0, scaledWidth)), + static_cast(std::max(1.0, scaledHeight))}; +} +} // namespace p3r::render diff --git a/src/render/Scaling.hpp b/src/render/Scaling.hpp new file mode 100644 index 0000000..bfc267f --- /dev/null +++ b/src/render/Scaling.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include +#include + +namespace p3r::render +{ +struct ScaleResult +{ + // The automatic baseline can exceed the float range for extreme inputs. + // Keep it wide so diagnostics describe the value used before final clamping. + double automaticMultiplier; + float finalMultiplier; + bool usedFallback; +}; + +// Automatic scaling preserves native quality below 1080 effective vertical +// pixels. An explicit user multiplier applies afterward, before final limits. +ScaleResult ComputeRenderTargetScale(int viewportHeight, float screenPercentage, + float userMultiplier) noexcept; + +struct ScaledDimensions +{ + std::int32_t width; + std::int32_t height; +}; + +// Reject invalid or overflowing dimensions without producing a memory-write +// value. Valid positive products truncate toward zero, with a one-pixel minimum. +std::optional ScaleDimensions(std::int32_t width, std::int32_t height, + float multiplier) noexcept; +} // namespace p3r::render diff --git a/tests/fix_behavior_tests.cpp b/tests/fix_behavior_tests.cpp new file mode 100644 index 0000000..01a6cf5 --- /dev/null +++ b/tests/fix_behavior_tests.cpp @@ -0,0 +1,43 @@ +// Test the shipped helpers with independent arithmetic answers and Windows packets. +#define NOMINMAX + +#include +#include +#include +#include + +namespace +{ +void Require(bool condition, const char* explanation) +{ + if (!condition) + throw std::runtime_error(explanation); +} +} // namespace + +#include "raw_mouse_packet_tests.hpp" +#include "render_scaling_tests.hpp" + +int main() +{ + int passed = 0; + int total = 0; + const auto runTest = [&](const char* name, const std::function& test) { + ++total; + try + { + test(); + ++passed; + std::cout << "[PASS] " << name << '\n'; + } + catch (const std::exception& error) + { + std::cerr << "[FAIL] " << name << ": " << error.what() << '\n'; + } + }; + + RunRenderScalingTests(runTest); + RunRawMousePacketTests(runTest); + std::cout << passed << '/' << total << " checks passed\n"; + return passed == total ? 0 : 1; +} diff --git a/tests/raw_mouse_packet_tests.hpp b/tests/raw_mouse_packet_tests.hpp new file mode 100644 index 0000000..17adae3 --- /dev/null +++ b/tests/raw_mouse_packet_tests.hpp @@ -0,0 +1,262 @@ +#pragma once + +#include "input/RawMousePacket.hpp" + +#include +#include +#include +#include +#include +#include + +namespace raw_mouse_tests +{ +using namespace p3r::input; + +// These literal offsets are the independently reviewed Windows x64 ABI: +// RAWINPUTHEADER is 24 bytes, followed by a 24-byte RAWMOUSE payload. +constexpr UINT headerSize = 0x18; +constexpr UINT mouseSize = 0x30; +constexpr UINT readError = static_cast(-1); + +struct ReaderFixture +{ + std::array bytes{}; + UINT queryResult = 0; + UINT capacity = mouseSize; + UINT copiedCount = mouseSize; + UINT returnedSize = mouseSize; + int calls = 0; + bool argumentsValid = true; + + template + void Write(std::size_t offset, const T& value) + { + std::memcpy(bytes.data() + offset, &value, sizeof(value)); + } + + ReaderFixture(LONG x = 1200, LONG y = -2400) + { + Write(0x00, static_cast(RIM_TYPEMOUSE)); + Write(0x04, static_cast(mouseSize)); + Write(0x18, static_cast(0)); + Write(0x24, x); + Write(0x28, y); + } +}; + +inline ReaderFixture* activeFixture = nullptr; + +HRAWINPUT FixtureHandle() +{ + return reinterpret_cast(static_cast(0x1234)); +} + +UINT WINAPI FixtureReader(HRAWINPUT input, UINT command, LPVOID data, PUINT size, UINT sizeHeader) +{ + auto& fixture = *activeFixture; + ++fixture.calls; + fixture.argumentsValid &= + input == FixtureHandle() && command == RID_INPUT && size != nullptr && sizeHeader == headerSize; + if (data == nullptr) + { + fixture.argumentsValid &= fixture.calls == 1 && *size == 0; + *size = fixture.capacity; + return fixture.queryResult; + } + + fixture.argumentsValid &= + fixture.calls == 2 && *size == fixture.capacity && reinterpret_cast(data) % 4 == 0; + const std::size_t available = std::min(fixture.capacity, fixture.bytes.size()); + const std::size_t written = + fixture.copiedCount == readError ? 0 : std::min(available, fixture.copiedCount); + std::memcpy(data, fixture.bytes.data(), written); + *size = fixture.returnedSize; + return fixture.copiedCount; +} + +PacketResult Read(ReaderFixture& fixture, RawInputAllocator allocator = nullptr) +{ + activeFixture = &fixture; + const auto result = ReadRelativeMousePacket(FixtureHandle(), FixtureReader, allocator); + activeFixture = nullptr; + Require(fixture.argumentsValid, "reader receives the correct two-call Windows contract"); + return result; +} + +void RequireNoMovement(const PacketResult& packet, PacketStatus expected) +{ + Require(packet.status == expected, "packet has expected failure or ignored status"); + Require(packet.delta.x == 0 && packet.delta.y == 0, "unaccepted packet has no delta"); + for (const bool initialMouseActivity : {false, true}) + { + float x = 2.0f; + float y = -3.0f; + bool mouseActivity = initialMouseActivity; + ApplyRelativeMousePacket(packet, x, y, mouseActivity); + Require(x == 2.0f && y == -3.0f, "unaccepted packet leaves accumulated motion unchanged"); + Require(mouseActivity == initialMouseActivity, "unaccepted packet preserves input ownership"); + } +} + +RawInputBuffer FailAllocation(std::size_t) noexcept +{ + return {}; +} +} // namespace raw_mouse_tests + +void RunRawMousePacketTests(const std::function&)>& runTest) +{ + using namespace raw_mouse_tests; + + runTest("successful two-call mouse read preserves signed movement and sensitivity", [] { + ReaderFixture fixture; + const auto packet = Read(fixture); + Require(fixture.calls == 2, "one query and one read"); + Require(packet.status == PacketStatus::RelativeMouse, "valid relative mouse packet"); + Require(packet.delta.x == 1200 && packet.delta.y == -2400, "signed deltas preserved"); + float x = 0.5f; + float y = 0.25f; + bool mouseActivity = false; + ApplyRelativeMousePacket(packet, x, y, mouseActivity); + Require(x == 1.5f && y == -1.75f, "valid delta is accumulated once using the existing divisor"); + Require(mouseActivity, "nonzero relative motion activates the mouse"); + }); + + runTest("zero motion preserves existing input ownership", [] { + ReaderFixture fixture(0, 0); + const auto packet = Read(fixture); + Require(packet.status == PacketStatus::RelativeMouse, "zero-motion packet remains valid"); + for (const bool initialMouseActivity : {false, true}) + { + float x = 1.0f; + float y = -1.0f; + bool mouseActivity = initialMouseActivity; + ApplyRelativeMousePacket(packet, x, y, mouseActivity); + Require(x == 1.0f && y == -1.0f, "zero motion preserves accumulators"); + Require(mouseActivity == initialMouseActivity, "zero motion preserves active device"); + } + }); + + runTest("extreme signed deltas retain the existing camera interpretation", [] { + ReaderFixture fixture((std::numeric_limits::min)(), (std::numeric_limits::max)()); + const auto packet = Read(fixture); + float x = 0.0f; + float y = 0.0f; + bool mouseActivity = false; + ApplyRelativeMousePacket(packet, x, y, mouseActivity); + Require(std::isfinite(x) && std::isfinite(y) && x < 0.0f && y > 0.0f, + "extreme signed motion produces finite deltas with the correct signs"); + Require(mouseActivity, "extreme motion activates the mouse"); + }); + + runTest("invalid reader or input handle is rejected without a read", [] { + RequireNoMovement(ReadRelativeMousePacket(FixtureHandle(), nullptr), PacketStatus::Invalid); + ReaderFixture fixture; + activeFixture = &fixture; + const auto result = ReadRelativeMousePacket(nullptr, FixtureReader); + activeFixture = nullptr; + RequireNoMovement(result, PacketStatus::Invalid); + Require(fixture.calls == 0, "invalid handle never reaches the reader"); + }); + + runTest("size-query errors and unexpected return values stop before allocation", [] { + for (const UINT result : {readError, 1u}) + { + ReaderFixture fixture; + fixture.queryResult = result; + RequireNoMovement(Read(fixture), PacketStatus::Invalid); + Require(fixture.calls == 1, "failed query has no data read"); + } + }); + + runTest("zero and undersized queried capacities are rejected", [] { + for (const UINT capacity : {0u, headerSize - 1}) + { + ReaderFixture fixture; + fixture.capacity = capacity; + RequireNoMovement(Read(fixture), PacketStatus::Invalid); + Require(fixture.calls == 1, "undersized query has no data read"); + } + }); + + runTest("allocation failure leaves movement and ownership unchanged", [] { + ReaderFixture fixture; + RequireNoMovement(Read(fixture, FailAllocation), PacketStatus::AllocationFailed); + Require(fixture.calls == 1, "allocation failure prevents the data read"); + }); + + runTest("failed and short data reads are rejected before header parsing", [] { + for (const UINT copied : {readError, 0u, headerSize - 1, headerSize, mouseSize - 1}) + { + ReaderFixture fixture; + fixture.copiedCount = copied; + RequireNoMovement(Read(fixture), PacketStatus::Invalid); + Require(fixture.calls == 2, "bad data read still uses only the original two calls"); + } + }); + + runTest("an oversized returned byte count is rejected", [] { + ReaderFixture fixture; + fixture.copiedCount = mouseSize + 1; + RequireNoMovement(Read(fixture), PacketStatus::Invalid); + }); + + runTest("the API cannot disguise a short read by changing the size argument", [] { + ReaderFixture fixture; + fixture.copiedCount = mouseSize - 1; + fixture.returnedSize = mouseSize - 1; + RequireNoMovement(Read(fixture), PacketStatus::Invalid); + }); + + runTest("inconsistent returned size arguments are rejected", [] { + for (const UINT size : {0u, mouseSize - 1, mouseSize + 1}) + { + ReaderFixture fixture; + fixture.returnedSize = size; + RequireNoMovement(Read(fixture), PacketStatus::Invalid); + } + }); + + runTest("inconsistent declared packet sizes are rejected", [] { + for (const DWORD size : {0ul, static_cast(headerSize - 1), static_cast(mouseSize - 1), + static_cast(mouseSize + 1)}) + { + ReaderFixture fixture; + fixture.Write(0x04, size); + RequireNoMovement(Read(fixture), PacketStatus::Invalid); + } + }); + + runTest("a complete header cannot authorize a truncated mouse payload", [] { + for (const UINT size : {headerSize, mouseSize - 1}) + { + ReaderFixture fixture; + fixture.capacity = size; + fixture.copiedCount = size; + fixture.returnedSize = size; + fixture.Write(0x04, static_cast(size)); + RequireNoMovement(Read(fixture), PacketStatus::Invalid); + } + }); + + runTest("valid non-mouse packets are ignored", [] { + for (const DWORD type : {static_cast(RIM_TYPEKEYBOARD), static_cast(RIM_TYPEHID)}) + { + ReaderFixture fixture; + fixture.Write(0x00, type); + RequireNoMovement(Read(fixture), PacketStatus::Ignored); + } + }); + + runTest("nonzero mouse flags retain the baseline selection behavior", [] { + for (const USHORT flags : + {static_cast(MOUSE_MOVE_ABSOLUTE), static_cast(MOUSE_VIRTUAL_DESKTOP), + static_cast(MOUSE_ATTRIBUTES_CHANGED), static_cast(MOUSE_MOVE_NOCOALESCE)}) + { + ReaderFixture fixture; + fixture.Write(0x18, flags); + RequireNoMovement(Read(fixture), PacketStatus::Ignored); + } + }); +} diff --git a/tests/render_scaling_tests.hpp b/tests/render_scaling_tests.hpp new file mode 100644 index 0000000..0340091 --- /dev/null +++ b/tests/render_scaling_tests.hpp @@ -0,0 +1,217 @@ +#pragma once + +#include "render/Scaling.hpp" + +#include +#include +#include +#include +#include + +// Include after the runner's Require(bool, const char*) declaration. Each case +// uses the actual shipped helper and independent expected arithmetic answers. +void RunRenderScalingTests(const std::function&)>& runTest) +{ + using namespace p3r::render; + + runTest("automatic scale retains native baseline on a wide 720p viewport", [] { + // Width is deliberately absent from the policy. A 2560x720 viewport + // therefore has the same baseline as 1280x720; the old guard did not. + const auto result = ComputeRenderTargetScale(720, 100.0f, 1.0f); + Require(result.automaticMultiplier == 1.0, "720p automatic baseline"); + Require(result.finalMultiplier == 1.0f, "720p final multiplier"); + Require(!result.usedFallback, "valid 720p inputs need no fallback"); + }); + + runTest("automatic scale retains native baseline at 800p and 1080p", [] { + for (const int height : {800, 1080}) + { + const auto result = ComputeRenderTargetScale(height, 100.0f, 1.0f); + Require(result.automaticMultiplier == 1.0, "native automatic baseline"); + Require(result.finalMultiplier == 1.0f, "native final multiplier"); + } + }); + + runTest("automatic scale follows vertical rendering resolution", [] { + const auto result1440 = ComputeRenderTargetScale(1440, 100.0f, 1.0f); + Require(std::abs(result1440.automaticMultiplier - 1.3333333333333333) < 1e-12, + "1440p automatic baseline"); + Require(std::abs(result1440.finalMultiplier - 1.3333333333333333f) < 1e-6f, "1440p final multiplier"); + + // This also covers a narrow 1920x2160 viewport: width cannot suppress + // the height-based baseline as it did in the old width-only guard. + const auto result2160 = ComputeRenderTargetScale(2160, 100.0f, 1.0f); + Require(result2160.automaticMultiplier == 2.0, "2160p automatic baseline"); + Require(result2160.finalMultiplier == 2.0f, "2160p final multiplier"); + }); + + runTest("screen percentage changes the automatic baseline", [] { + const auto half2160 = ComputeRenderTargetScale(2160, 50.0f, 1.0f); + Require(half2160.automaticMultiplier == 1.0, "2160p at 50 percent baseline"); + Require(half2160.finalMultiplier == 1.0f, "2160p at 50 percent final"); + + const auto boosted1440 = ComputeRenderTargetScale(1440, 150.0f, 1.0f); + Require(boosted1440.automaticMultiplier == 2.0, "1440p at 150 percent baseline"); + Require(boosted1440.finalMultiplier == 2.0f, "1440p at 150 percent final"); + }); + + runTest("explicit undersampling applies after the automatic baseline", [] { + const auto result = ComputeRenderTargetScale(1440, 100.0f, 0.5f); + Require(std::abs(result.automaticMultiplier - 1.3333333333333333) < 1e-12, + "undersampling keeps the automatic baseline"); + Require(std::abs(result.finalMultiplier - 0.6666666666666667f) < 1e-6f, + "explicit undersampling halves the automatic baseline"); + }); + + runTest("only the combined multiplier receives upper and lower limits", [] { + const auto highBaseline = ComputeRenderTargetScale(10800, 100.0f, 0.25f); + Require(highBaseline.automaticMultiplier == 10.0, "automatic baseline is uncapped"); + Require(highBaseline.finalMultiplier == 2.5f, "undersampling precedes final cap"); + + const auto capped = ComputeRenderTargetScale(2160, 100.0f, 4.0f); + Require(capped.finalMultiplier == 4.0f, "combined upper limit"); + + const auto floored = ComputeRenderTargetScale(1080, 100.0f, 0.125f); + Require(floored.finalMultiplier == 0.25f, "combined lower limit"); + Require(!floored.usedFallback, "positive finite undersampling is valid"); + }); + + runTest("invalid viewport heights use a temporary native baseline", [] { + for (const int height : {0, -1, std::numeric_limits::min()}) + { + const auto result = ComputeRenderTargetScale(height, 150.0f, 0.5f); + Require(result.automaticMultiplier == 1.0, "invalid height native baseline"); + Require(result.finalMultiplier == 0.5f, "invalid height still honors user multiplier"); + Require(result.usedFallback, "invalid height is reported"); + } + }); + + runTest("invalid screen percentages use 100 percent and report fallback", [] { + const std::array invalidValues{0.0f, -100.0f, std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN()}; + for (const float percentage : invalidValues) + { + const auto result = ComputeRenderTargetScale(2160, percentage, 0.5f); + Require(result.automaticMultiplier == 2.0, "invalid percentage uses 100 percent"); + Require(result.finalMultiplier == 1.0f, "percentage fallback honors user multiplier"); + Require(result.usedFallback, "invalid percentage is reported"); + } + }); + + runTest("invalid user multipliers use one and report fallback", [] { + const std::array invalidValues{0.0f, -1.0f, std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN()}; + for (const float multiplier : invalidValues) + { + const auto result = ComputeRenderTargetScale(2160, 100.0f, multiplier); + Require(result.automaticMultiplier == 2.0, "user fallback keeps automatic baseline"); + Require(result.finalMultiplier == 2.0f, "invalid user multiplier uses one"); + Require(result.usedFallback, "invalid user multiplier is reported"); + } + }); + + runTest("large finite inputs remain safe before the final clamp", [] { + const auto result = + ComputeRenderTargetScale(std::numeric_limits::max(), std::numeric_limits::max(), + std::numeric_limits::max()); + Require(std::isfinite(result.automaticMultiplier), "wide automatic arithmetic stays finite"); + Require(result.automaticMultiplier > std::numeric_limits::max(), + "automatic diagnostic retains values beyond the float range"); + Require(result.finalMultiplier == 4.0f, "large combined value receives final cap"); + Require(!result.usedFallback, "large positive finite inputs remain valid"); + }); + + runTest("a baseline beyond the float range still permits combined undersampling", [] { + const float maximum = std::numeric_limits::max(); + const auto result = ComputeRenderTargetScale(216000, maximum, 1.0f / maximum); + Require(result.automaticMultiplier > maximum, "automatic baseline exceeds float range"); + Require(std::abs(result.finalMultiplier - 2.0f) < 1e-6f, + "wide automatic baseline combines before float narrowing"); + Require(!result.usedFallback, "finite extreme undersampling remains valid"); + }); + + runTest("tiny positive inputs retain deliberate multiplier semantics", [] { + const float tiny = std::numeric_limits::denorm_min(); + const auto tinyPercentage = ComputeRenderTargetScale(2160, tiny, 1.0f); + Require(tinyPercentage.automaticMultiplier == 1.0, "tiny percentage keeps native floor"); + Require(!tinyPercentage.usedFallback, "tiny positive percentage remains valid"); + + const auto tinyMultiplier = ComputeRenderTargetScale(2160, 100.0f, tiny); + Require(tinyMultiplier.finalMultiplier == 0.25f, "tiny positive user value reaches final floor"); + Require(!tinyMultiplier.usedFallback, "tiny positive user multiplier remains valid"); + }); + + runTest("normal automatic scales produce exact expected target dimensions", [] { + const auto scale1440 = ComputeRenderTargetScale(1440, 100.0f, 1.0f); + const auto dimensions1440 = ScaleDimensions(1920, 1080, scale1440.finalMultiplier); + Require(dimensions1440.has_value(), "1440p dimensions are valid"); + Require(dimensions1440->width == 2560, "1440p target width"); + Require(dimensions1440->height == 1440, "1440p target height"); + + const auto scale2160 = ComputeRenderTargetScale(2160, 100.0f, 1.0f); + const auto dimensions2160 = ScaleDimensions(1920, 1080, scale2160.finalMultiplier); + Require(dimensions2160.has_value(), "2160p dimensions are valid"); + Require(dimensions2160->width == 3840, "2160p target width"); + Require(dimensions2160->height == 2160, "2160p target height"); + }); + + runTest("positive scaled dimensions truncate toward zero", [] { + const auto dimensions = ScaleDimensions(5, 7, 0.5f); + Require(dimensions.has_value(), "fractional dimensions are valid"); + Require(dimensions->width == 2, "fractional width truncates"); + Require(dimensions->height == 3, "fractional height truncates"); + }); + + runTest("valid undersampled dimensions retain at least one pixel", [] { + const auto dimensions = ScaleDimensions(1, 3, 0.25f); + Require(dimensions.has_value(), "small target is valid"); + Require(dimensions->width == 1, "undersampled width minimum"); + Require(dimensions->height == 1, "undersampled height minimum"); + + const auto tiny = ScaleDimensions(1, 1, std::numeric_limits::denorm_min()); + Require(tiny.has_value(), "tiny positive scale is valid"); + Require(tiny->width == 1 && tiny->height == 1, "tiny scale preserves one pixel"); + }); + + runTest("nonpositive source dimensions are rejected", [] { + Require(!ScaleDimensions(0, 1080, 1.0f), "zero width is rejected"); + Require(!ScaleDimensions(1920, 0, 1.0f), "zero height is rejected"); + Require(!ScaleDimensions(-1, 1080, 1.0f), "negative width is rejected"); + Require(!ScaleDimensions(1920, -1, 1.0f), "negative height is rejected"); + }); + + runTest("invalid dimension multipliers are rejected", [] { + const std::array invalidValues{0.0f, -1.0f, std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN()}; + for (const float multiplier : invalidValues) + Require(!ScaleDimensions(1920, 1080, multiplier), "invalid dimension scale"); + }); + + runTest("the largest int32 dimension is accepted without rounding upward", [] { + constexpr auto maximum = std::numeric_limits::max(); + const auto dimensions = ScaleDimensions(maximum, maximum, 1.0f); + Require(dimensions.has_value(), "largest int32 dimensions remain valid"); + Require(dimensions->width == maximum && dimensions->height == maximum, + "double arithmetic preserves exact int32 dimensions"); + }); + + runTest("integer overflow is rejected before dimensions are narrowed", [] { + constexpr auto maximum = std::numeric_limits::max(); + Require(!ScaleDimensions(maximum, 1, 2.0f), "width overflow is rejected"); + Require(!ScaleDimensions(1, maximum, 2.0f), "height overflow is rejected"); + Require(!ScaleDimensions(maximum, 1, std::nextafter(1.0f, 2.0f)), + "scale just above one would overflow width"); + Require(!ScaleDimensions(1, 1, std::numeric_limits::max()), + "large finite product is rejected"); + }); + + runTest("integer upper-bound checks distinguish adjacent source dimensions", [] { + const auto valid = ScaleDimensions(1073741823, 1, 2.0f); + Require(valid.has_value(), "product immediately below int32 limit is valid"); + Require(valid->width == 2147483646, "valid near-limit width is exact"); + Require(!ScaleDimensions(1073741824, 1, 2.0f), "product immediately above int32 limit is rejected"); + }); +} diff --git a/xmake.lua b/xmake.lua index 205f35d..4ec9d3c 100644 --- a/xmake.lua +++ b/xmake.lua @@ -6,7 +6,7 @@ set_languages("cxxlatest", "clatest") set_optimize("smallest") option("fix_version") - set_default("1.3.0") + set_default("1.4.0") set_showmenu(true) set_description("Version embedded in P3RFix (set from the release tag in CI)") option_end() @@ -36,7 +36,9 @@ target(name) set_kind("shared") set_prefixname("") set_extension(".asi") - add_files("src/*.cpp", "external/safetyhook/src/*.cpp", "src/unreal/Integration.cpp", "src/unreal/detail/Runtime.cpp") + add_files("src/*.cpp", "external/safetyhook/src/*.cpp", + "src/unreal/Integration.cpp", "src/unreal/detail/Runtime.cpp", + "src/render/Scaling.cpp", "src/input/RawMousePacket.cpp") add_syslinks("user32") add_deps("zydis") on_load(function (target) @@ -66,3 +68,18 @@ target("unreal-integration-tests") add_cxflags("/utf-8", "/GL") add_ldflags("/LTCG", "/OPT:REF", "/OPT:ICF") end + +-- Exercise the shipped scaling and Windows packet-reader boundaries without the game. +target("fix-behavior-tests") + set_kind("binary") + set_default(false) + add_files("tests/fix_behavior_tests.cpp", "src/render/Scaling.cpp", + "src/input/RawMousePacket.cpp") + add_includedirs("src") + add_syslinks("user32") + if is_plat("windows") then + set_toolchains("msvc") + set_runtimes("MT") + add_cxflags("/utf-8", "/W4", "/WX", "/GL") + add_ldflags("/LTCG", "/OPT:REF", "/OPT:ICF") + end