diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index c65fda856..ed3ac5133 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -1,5 +1,5 @@ -name: Bug report -description: Let us know about bugs found with the game/tweaks (for feature requests, please use Discussions tab) +name: Bug report / 버그 신고 +description: Report bugs found with the game/tweaks / 게임 또는 Tweaks에서 발견한 버그를 알려주세요 title: "[Bug]: " labels: bug body: @@ -7,20 +7,34 @@ body: type: markdown attributes: value: | - If you've ran into an issue/bug with the game or tweaks let us know about it here. - - **For feature requests/suggestions**, please write on the [Suggestions discussion board](https://github.com/emoose/OutRun2006Tweaks/discussions/categories/suggestions). + If you've run into an issue or bug with the game or tweaks, let us know about it here. + 게임 또는 Tweaks 사용 중 문제나 버그를 발견했다면 이 양식으로 알려주세요. + + **For feature requests/suggestions**, please use the Suggestions discussion board. + **기능 요청/제안**은 Suggestions 토론 게시판을 이용해 주세요. + + https://github.com/emoose/OutRun2006Tweaks/discussions/categories/suggestions + - id: version type: input attributes: - label: Tweaks version - description: Which version of OutRun2006Tweaks were you using? (you can usually find this by looking at the filename of ZIP you downloaded) - placeholder: outrun2006tweaks-abcdefghijklmnopqrstuvwxyz + label: Tweaks version / Tweaks 버전 + description: Which version of OutRun2006Tweaks were you using? Include the release/ZIP version if possible. / 사용한 OutRun2006Tweaks 버전을 적어주세요. 가능하면 릴리즈 또는 ZIP 버전도 함께 적어주세요. + placeholder: OutRun2006Tweaks Wheel FFB v0.1 validations: required: true + + - id: hardware + type: input + attributes: + label: Wheel hardware / 휠 하드웨어 + description: Wheel base, pedals, driver and firmware version if relevant. / 관련된 경우 휠베이스, 페달, 드라이버 및 펌웨어 버전을 적어주세요. + placeholder: MOZA R3, driver/firmware version + - id: problem type: textarea attributes: - label: Describe your issue here (please attach the OutRun2006Tweaks.log file with your report, you can drag+drop it into the text area) + label: Describe the issue / 문제 설명 + description: Describe what happened, how to reproduce it, and what you expected. Please attach OutRun2006Tweaks.log for input/FFB issues. / 발생한 현상, 재현 방법, 기대한 동작을 적어주세요. 입력/FFB 문제는 가능하면 OutRun2006Tweaks.log를 첨부해 주세요. validations: required: true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1fe96ebde..885c417e8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,3 +1,5 @@ +name: Build + on: push: pull_request: @@ -16,42 +18,204 @@ jobs: with: submodules: recursive + - name: Verify consolidated wheel FFB source + shell: pwsh + run: python tools/verify_wheel_ffb_current.py + + - name: Test production wheel FFB math + shell: cmd + run: | + call "%ProgramFiles%\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\vcvars32.bat" || exit /b 1 + python tools/verify_wheel_ffb_math.py + - name: Configure + shell: pwsh run: | + # cmkr intentionally skips regeneration when CI is set. vr-openxr uses + # cmake.toml as the canonical source graph, so remove CI here just as + # the dedicated VR workflow does before configuring. + Remove-Item Env:CI -ErrorAction SilentlyContinue mkdir build cd build cmake -G "Visual Studio 17 2022" -A ${{matrix.arch}} .. - + - name: Add older VC2022 redist workaround to safetyhook shell: pwsh run: | - (Get-Content build/_deps/safetyhook-build/src/safetyhook.vcxproj) -replace 'ZYCORE_STATIC_BUILD', '_DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR;ZYCORE_STATIC_BUILD' | Set-Content build/_deps/safetyhook-build/safetyhook.vcxproj + (Get-Content build/_deps/safetyhook-build/src/safetyhook.vcxproj) -replace 'ZYCORE_STATIC_BUILD', '_DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR;ZYCORE_STATIC_BUILD' | Set-Content build/_deps/safetyhook-build/src/safetyhook.vcxproj (Get-Content build/_deps/zydis-build/Zydis.vcxproj) -replace 'ZYCORE_STATIC_BUILD', '_DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR;ZYCORE_STATIC_BUILD' | Set-Content build/_deps/zydis-build/Zydis.vcxproj (Get-Content build/_deps/zydis-build/zycore/Zycore.vcxproj) -replace 'ZYCORE_STATIC_BUILD', '_DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR;ZYCORE_STATIC_BUILD' | Set-Content build/_deps/zydis-build/zycore/Zycore.vcxproj - - name: Zero out SDL_gameinputjoystick.c (https://github.com/libsdl-org/SDL/issues/11487 / https://github.com/actions/runner-images/issues/10980) + - name: Zero out SDL_gameinputjoystick.c shell: pwsh run: | Set-Content -Path build/_deps/sdl-src/src/joystick/gdk/SDL_gameinputjoystick.c -Value "" (Get-Content build/_deps/sdl-build/include-config-release/build_config/SDL_build_config.h) -replace '#define SDL_JOYSTICK_GAMEINPUT 1', '/* #undef SDL_JOYSTICK_GAMEINPUT */' | Set-Content build/_deps/sdl-build/include-config-release/build_config/SDL_build_config.h - + - name: Build - run: | - cmake --build build --config ${{matrix.build_type}} - + run: cmake --build build --config ${{matrix.build_type}} + - name: Copy settings shell: pwsh run: | cp OutRun2006Tweaks.ini build/bin/OutRun2006Tweaks.ini cp OutRun2006Tweaks.lods.ini build/bin/OutRun2006Tweaks.lods.ini - - - name: Download EXE + + - name: Verify built wheel and VR payload + shell: pwsh + run: | + $dllPath = "build/bin/dinput8.dll" + if (-not (Test-Path $dllPath)) { throw "dinput8.dll was not produced" } + $bytes = [System.IO.File]::ReadAllBytes($dllPath) + if ($bytes.Length -lt 1000000) { throw "dinput8.dll is unexpectedly small" } + if ($bytes[0] -ne 0x4D -or $bytes[1] -ne 0x5A) { throw "dinput8.dll has no MZ header" } + $peOffset = [BitConverter]::ToInt32($bytes, 0x3C) + if ($peOffset -lt 0 -or ($peOffset + 6) -ge $bytes.Length) { throw "invalid PE offset" } + $signature = [Text.Encoding]::ASCII.GetString($bytes, $peOffset, 4) + if ($signature -ne "PE`0`0") { throw "invalid PE signature" } + $machine = [BitConverter]::ToUInt16($bytes, $peOffset + 4) + if ($machine -ne 0x014C) { throw ("expected Win32 x86 DLL, machine=0x{0:X4}" -f $machine) } + + $ascii = [Text.Encoding]::ASCII.GetString($bytes) + $markers = @( + "WheelFFB: GUID_Damper created", + "WheelFFB: scheduling DirectInput device reinitialization", + "WheelFFB: selected DirectInput identity", + "Reverse SAT / ConstantForce", + "Reverse Spring", + "multi-device SDL raw input enabled", + "Multi-device input architecture adapted from hyp36rmax (MIT)", + "zero-force live SetParameters", + "temporarily excluding it from automatic selection", + "ConstantForce is using detected actuator offset", + "next compatible interface will be probed on the next update tick", + "transient ConstantForce update failure", + "preferred FFB driver/vendor", + "MOZA R3 Physics SAT", + "MOZA R3 Natural SAT", + "Single-owner wheel FFB: DirectInput COM only", + "Physics SAT (body slip + yaw)", + "Mechanical / Caster Trail", + "Pneumatic Trail Response Lead", + "FFB Headroom / Clipping", + "Enable wheel response correction", + "WheelFFB SATMODEL", + "trailResponseSlip={} trailResponseLead={}", + "load={:.2f} slide={:.2f} scrub={:.2f}", + "basis=M70r{}", + "steerSrc={}", + "Test Left (20%)", + "VR stereo: D3D9 full-eye renderer installed", + "VR R13: stereo hardening ACTIVE", + "VR R13: auxiliary/offscreen c64 WVP kept stock", + "VR v3 shadow: live dual-protocol diagnostics active", + "VR GPL SHADER: pair=" + ) + foreach ($marker in $markers) { + if (-not $ascii.Contains($marker)) { throw "missing compiled marker: $marker" } + } + + $ini = Get-Content "build/bin/OutRun2006Tweaks.ini" -Raw + if ($ini -notmatch '(?m)^SteeringDeadZone\s*=\s*0\.0\s*$') { throw "shipped deadzone is not 0.0" } + if ($ini -notmatch '(?m)^WheelAccelerationInvert\s*=\s*false\s*$') { throw "shipped accelerator invert is not false" } + if ($ini -notmatch '(?m)^WheelBrakeInvert\s*=\s*false\s*$') { throw "shipped brake invert is not false" } + if ($ini -notmatch '(?m)^UseNewInput\s*=\s*true\s*$') { throw "multi-device input is not the shipped default" } + if ($ini -notmatch '(?m)^TrailResponseLead\s*=\s*0\.25\s*$') { throw "shipped pneumatic trail response lead is not 0.25" } + + Write-Host ("Verified dinput8.dll: {0} bytes, PE32 x86, current wheel FFB + OpenXR VR markers present" -f $bytes.Length) + Get-FileHash $dllPath -Algorithm SHA256 + + - name: Download upstream replacement EXE + shell: pwsh + run: Invoke-WebRequest -Uri https://github.com/emoose/OutRun2006Tweaks/releases/download/v0.1/OR2006C2C.EXE -OutFile build/bin/OR2006C2C.exe + + - name: Collect binary distribution licenses shell: pwsh run: | - Invoke-WebRequest -Uri https://github.com/emoose/OutRun2006Tweaks/releases/download/v0.1/OR2006C2C.EXE -OutFile build/bin/OR2006C2C.exe - - - name: Upload + $candidates = New-Object System.Collections.Generic.List[System.IO.FileInfo] + $candidates.Add((Get-Item LICENSE.md)) + if (Test-Path COPYING.GPL3) { $candidates.Add((Get-Item COPYING.GPL3)) } + if (Test-Path THIRD_PARTY_NOTICES.md) { $candidates.Add((Get-Item THIRD_PARTY_NOTICES.md)) } + foreach ($root in @('external', 'build/_deps')) { + if (Test-Path $root) { + Get-ChildItem $root -Recurse -File | Where-Object { + $_.Name -match '^(LICENSE|LICENCE|COPYING|COPYRIGHT)(\..*)?$' + } | ForEach-Object { $candidates.Add($_) } + } + } + + $seen = @{} + $builder = New-Object System.Text.StringBuilder + [void]$builder.AppendLine('OutRun2006Tweaks Wheel FFB v0.1 - bundled open-source notices') + [void]$builder.AppendLine('Generated by CI from the exact source/dependency trees used for this build.') + [void]$builder.AppendLine('') + + foreach ($file in ($candidates | Sort-Object FullName)) { + try { $text = Get-Content $file.FullName -Raw -ErrorAction Stop } catch { continue } + if ([string]::IsNullOrWhiteSpace($text)) { continue } + $bytes = [Text.Encoding]::UTF8.GetBytes($text) + $hash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)) + if ($seen.ContainsKey($hash)) { continue } + $seen[$hash] = $true + $relative = [IO.Path]::GetRelativePath((Get-Location).Path, $file.FullName) + [void]$builder.AppendLine(('=' * 78)) + [void]$builder.AppendLine($relative) + [void]$builder.AppendLine(('=' * 78)) + [void]$builder.AppendLine($text.TrimEnd()) + [void]$builder.AppendLine('') + } + + $builder.ToString() | Set-Content build/bin/LICENSES.txt -Encoding utf8 + if (-not (Test-Path build/bin/LICENSES.txt)) { throw 'LICENSES.txt was not generated' } + if (Test-Path COPYING.GPL3) { Copy-Item COPYING.GPL3 build/bin/COPYING.GPL3 } + if (Test-Path THIRD_PARTY_NOTICES.md) { Copy-Item THIRD_PARTY_NOTICES.md build/bin/THIRD_PARTY_NOTICES.md } + if (-not (Test-Path build/bin/COPYING.GPL3)) { throw 'GPLv3 text is missing from binary distribution' } + if (-not (Test-Path build/bin/THIRD_PARTY_NOTICES.md)) { throw 'third-party notices are missing from binary distribution' } + Write-Host ("Collected {0} unique license/notice texts" -f $seen.Count) + + - name: Upload binary uses: actions/upload-artifact@v7.0.1 with: name: outrun2006tweaks-${{ github.sha }} path: build/bin/ + + - name: Upload wheel source snapshot + uses: actions/upload-artifact@v7.0.1 + with: + name: wheel-source-${{ github.sha }} + path: | + src/hooks_wheel_ffb.cpp + src/hooks_wheel_ffb_build.cpp + src/hooks_wheel_vehicle_dynamics.hpp + src/wheel_ffb_math.hpp + src/wheel_ffb_runtime.hpp + tools/test_wheel_ffb_current.cpp + tools/verify_wheel_ffb_math.py + src/hooks_forcefeedback.cpp + src/hooks_framerate.cpp + src/hooks_wheel_input_compat_v2.hpp + src/hooks_wheel_r3_menu_dpad.hpp + src/hooks_wheel_r3_menu_ab.hpp + src/hooks_wheel_r3_device_autoselect.hpp + src/input_manager.hpp + src/input_manager.cpp + src/overlay/input_bindings_ui.cpp + src/overlay/wheel_setup_ui.cpp + src/wheel_profile_store.hpp + src/hooks_input.cpp + src/overlay/settings_ui.cpp + src/overlay/overlay.cpp + src/dllmain.cpp + OutRun2006Tweaks.ini + CMakeLists.txt + cmake.toml + README.md + WHEEL_FFB.md + RELEASE_NOTES_v0.1.md + LICENSE.md + COPYING.GPL3 + THIRD_PARTY_NOTICES.md + src/vr/d3d9/shader_fingerprint_gpl.hpp + tools/verify_wheel_ffb_current.py + .github/workflows/build.yml + include-hidden-files: true diff --git a/.github/workflows/outrun-exe-hud-inspector.yml b/.github/workflows/outrun-exe-hud-inspector.yml new file mode 100644 index 000000000..4edfb95a8 --- /dev/null +++ b/.github/workflows/outrun-exe-hud-inspector.yml @@ -0,0 +1,106 @@ +name: OutRun EXE HUD Inspector CI + +on: + workflow_dispatch: + push: + paths: + - 'src/vr/debug/hud_inspector.cpp' + - 'src/hooks_textures.cpp' + - 'tools/analyze_outrun_exe.py' + - 'tools/Collect-OutRunVRLogs.ps1' + - 'tools/Run-OutRunVRTest.ps1' + - 'docs/VR_EXE_HUD_INSPECTOR.md' + - '.github/workflows/outrun-exe-hud-inspector.yml' + +permissions: + contents: read + +jobs: + static-exe-analysis: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v4 + + - name: Validate PowerShell helper syntax + shell: pwsh + run: | + $failed=$false + foreach($script in @('tools/Collect-OutRunVRLogs.ps1','tools/Run-OutRunVRTest.ps1')){ + $tokens=$null + $errors=$null + [void][System.Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path $script), [ref]$tokens, [ref]$errors) + if($errors.Count -gt 0){ + $failed=$true + $errors | ForEach-Object { Write-Error "${script}: $($_.Message)" } + } + } + if($failed){ throw 'PowerShell syntax validation failed' } + + - name: Download upstream replacement EXE + shell: pwsh + run: | + New-Item -ItemType Directory -Force analysis-input | Out-Null + Invoke-WebRequest ` + -Uri 'https://github.com/emoose/OutRun2006Tweaks/releases/download/v0.1/OR2006C2C.EXE' ` + -OutFile analysis-input/OR2006C2C.EXE + + - name: Analyze EXE HUD/render anchors + run: > + python tools/analyze_outrun_exe.py + --exe analysis-input/OR2006C2C.EXE + --out-dir analysis-output + + - name: Show analysis summary + shell: pwsh + run: Get-Content analysis-output/OR2006C2C_EXE_ANALYSIS.md -TotalCount 120 + + - uses: actions/upload-artifact@v4 + with: + name: OR2006C2C-EXE-HUD-analysis-${{ github.sha }} + path: analysis-output/ + if-no-files-found: error + + build-hud-inspector: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Configure Win32 game DLL + shell: pwsh + run: | + Remove-Item Env:CI -ErrorAction SilentlyContinue + cmake -S . -B build-game -G "Visual Studio 17 2022" -A Win32 -DOUTRUN_VR_SAFE_DRAW_COMPARE=ON + + - name: Apply VC2022 dependency workarounds + shell: pwsh + run: | + (Get-Content build-game/_deps/safetyhook-build/src/safetyhook.vcxproj) -replace 'ZYCORE_STATIC_BUILD', '_DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR;ZYCORE_STATIC_BUILD' | Set-Content build-game/_deps/safetyhook-build/src/safetyhook.vcxproj + (Get-Content build-game/_deps/zydis-build/Zydis.vcxproj) -replace 'ZYCORE_STATIC_BUILD', '_DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR;ZYCORE_STATIC_BUILD' | Set-Content build-game/_deps/zydis-build/Zydis.vcxproj + (Get-Content build-game/_deps/zydis-build/zycore/Zycore.vcxproj) -replace 'ZYCORE_STATIC_BUILD', '_DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR;ZYCORE_STATIC_BUILD' | Set-Content build-game/_deps/zydis-build/zycore/Zycore.vcxproj + Set-Content -Path build-game/_deps/sdl-src/src/joystick/gdk/SDL_gameinputjoystick.c -Value "" + (Get-Content build-game/_deps/sdl-build/include-config-release/build_config/SDL_build_config.h) -replace '#define SDL_JOYSTICK_GAMEINPUT 1', '/* #undef SDL_JOYSTICK_GAMEINPUT */' | Set-Content build-game/_deps/sdl-build/include-config-release/build_config/SDL_build_config.h + + - name: Build + run: cmake --build build-game --config Release --target outrun2006tweaks + + - name: Stage inspector payload + shell: pwsh + run: | + New-Item -ItemType Directory -Force stage | Out-Null + $dll = Get-ChildItem build-game -Recurse -Filter dinput8.dll | + Where-Object { $_.FullName -match '\\bin\\' } | + Select-Object -First 1 + if (-not $dll) { throw 'dinput8.dll missing' } + Copy-Item $dll.FullName stage/dinput8.dll + Copy-Item tools/Collect-OutRunVRLogs.ps1 stage/ + Copy-Item tools/Run-OutRunVRTest.ps1 stage/ + git rev-parse HEAD | Set-Content stage/SOURCE_SHA.txt + + - uses: actions/upload-artifact@v4 + with: + name: HUD-inspector-game-${{ github.sha }} + path: stage/ + if-no-files-found: error diff --git a/.github/workflows/release-wheel-v0.1.yml b/.github/workflows/release-wheel-v0.1.yml new file mode 100644 index 000000000..7b6dd08aa --- /dev/null +++ b/.github/workflows/release-wheel-v0.1.yml @@ -0,0 +1,112 @@ +name: Release wheel v0.1 + +on: + push: + branches: + - wheel-ffb + +permissions: + contents: write + actions: read + +jobs: + release: + if: "${{ github.event.head_commit.message == 'release: v0.1 MOZA R3 wheel FFB' }}" + runs-on: windows-2022 + + steps: + - name: Checkout release commit + uses: actions/checkout@v7.0.1 + + - name: Wait for successful Win32 build + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $runId = $null + for ($i = 0; $i -lt 90; $i++) { + $runs = gh run list --repo $env:GITHUB_REPOSITORY --workflow build.yml --commit $env:GITHUB_SHA --limit 10 --json databaseId,status,conclusion | ConvertFrom-Json + $candidate = $runs | Where-Object { $_.status -eq 'completed' } | Select-Object -First 1 + if ($candidate) { + if ($candidate.conclusion -ne 'success') { + throw "Wheel build completed with conclusion '$($candidate.conclusion)'" + } + $runId = $candidate.databaseId + break + } + Start-Sleep -Seconds 10 + } + if (-not $runId) { + throw 'Timed out waiting for the wheel build artifact.' + } + "BUILD_RUN_ID=$runId" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Download tested build artifact + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + New-Item -ItemType Directory -Force -Path artifact-bin | Out-Null + gh run download $env:BUILD_RUN_ID --repo $env:GITHUB_REPOSITORY -n "outrun2006tweaks-$env:GITHUB_SHA" -D artifact-bin + foreach ($required in @('dinput8.dll','OutRun2006Tweaks.ini','OutRun2006Tweaks.lods.ini','OR2006C2C.exe','LICENSES.txt')) { + if (-not (Test-Path (Join-Path 'artifact-bin' $required))) { throw "$required missing from downloaded build artifact" } + } + + - name: Package minimal v0.1 release + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path release-bin | Out-Null + foreach ($file in @('dinput8.dll','OutRun2006Tweaks.ini','OutRun2006Tweaks.lods.ini','OR2006C2C.exe','LICENSES.txt')) { + Copy-Item (Join-Path 'artifact-bin' $file) (Join-Path 'release-bin' $file) + } + Copy-Item README.md release-bin/README.md + + $unexpected = Get-ChildItem release-bin -File | Where-Object { + $_.Name -notin @('dinput8.dll','OutRun2006Tweaks.ini','OutRun2006Tweaks.lods.ini','OR2006C2C.exe','README.md','LICENSES.txt') + } + if ($unexpected) { throw ("Unexpected release files: " + (($unexpected.Name) -join ', ')) } + + New-Item -ItemType Directory -Force -Path release | Out-Null + Compress-Archive -Path release-bin/* -DestinationPath release/OutRun2006Tweaks-Wheel-FFB-v0.1.zip -Force + Get-FileHash release-bin/dinput8.dll -Algorithm SHA256 + Get-FileHash release-bin/OR2006C2C.exe -Algorithm SHA256 + Get-FileHash release/OutRun2006Tweaks-Wheel-FFB-v0.1.zip -Algorithm SHA256 + + - name: Publish GitHub release v0.1 + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create v0.1 release/OutRun2006Tweaks-Wheel-FFB-v0.1.zip ` + --repo $env:GITHUB_REPOSITORY ` + --target $env:GITHUB_SHA ` + --title 'OutRun2006Tweaks Wheel FFB v0.1' ` + --notes-file RELEASE_NOTES_v0.1.md + + sync-release-notes: + if: "${{ github.event.head_commit.message == 'docs: add Korean documentation and release notices' || github.event.head_commit.message == 'release: publish v0.1 as stable' }}" + runs-on: ubuntu-latest + steps: + - name: Checkout documentation commit + uses: actions/checkout@v7.0.1 + + - name: Sync bilingual notes and publish stable GitHub release v0.1 + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release edit v0.1 \ + --repo "$GITHUB_REPOSITORY" \ + --title 'OutRun2006Tweaks Wheel FFB v0.1' \ + --notes-file RELEASE_NOTES_v0.1.md \ + --prerelease=false \ + --latest + + remove-release: + if: "${{ github.event.head_commit.message == 'chore: remove v0.1 release' }}" + runs-on: ubuntu-latest + steps: + - name: Delete v0.1 release and tag + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release delete v0.1 --repo "$GITHUB_REPOSITORY" --cleanup-tag --yes diff --git a/.github/workflows/vr-autodev-state.yml b/.github/workflows/vr-autodev-state.yml new file mode 100644 index 000000000..25504ec8d --- /dev/null +++ b/.github/workflows/vr-autodev-state.yml @@ -0,0 +1,27 @@ +name: VR Autodev State Validation + +on: + workflow_dispatch: + push: + branches: [ vr-unified-backends ] + paths: + - 'docs/VR_AUTODEV_STATE.json' + - 'docs/VR_WORK_QUEUE.json' + - 'docs/VR_RUNTIME_FEEDBACK.json' + - 'docs/VR_AUTODEV_PROTOCOL.md' + - 'docs/autodev/**' + - 'tools/Validate-VRAutodevState.ps1' + - '.github/workflows/vr-autodev-state.yml' + +concurrency: + group: vr-autodev-state + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Validate durable autodev state + shell: pwsh + run: ./tools/Validate-VRAutodevState.ps1 diff --git a/.github/workflows/vr-capture-diagnostic.yml b/.github/workflows/vr-capture-diagnostic.yml new file mode 100644 index 000000000..8268282b0 --- /dev/null +++ b/.github/workflows/vr-capture-diagnostic.yml @@ -0,0 +1,106 @@ +name: OpenXR capture-chain diagnostic + +on: + push: + branches: [ vr-openxr ] + paths: + - 'vrhost/src/runtime/capture_chain_diagnostic.hpp' + - 'vrhost/src/runtime/r18_host_pipeline_diagnostic.hpp' + - 'vrhost/capture-diagnostic/**' + - '.github/workflows/vr-capture-diagnostic.yml' + workflow_dispatch: + +jobs: + capture-diagnostic-x64: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Configure x64 capture diagnostic + run: cmake -S vrhost/capture-diagnostic -B build-vr-capture-diag -G "Visual Studio 17 2022" -A x64 + + - name: Build capture diagnostic + run: cmake --build build-vr-capture-diag --config Release --target outrun-vr-capture-diagnostic + + - name: Verify diagnostic binary + shell: pwsh + run: | + $exe = 'build-vr-capture-diag/bin/outrun-vr-capture-diagnostic.exe' + if (-not (Test-Path $exe)) { throw 'capture diagnostic executable was not produced' } + $bytes = [System.IO.File]::ReadAllBytes($exe) + if ($bytes.Length -lt 10000) { throw 'capture diagnostic executable is unexpectedly small' } + $ascii = [Text.Encoding]::ASCII.GetString($bytes) + foreach ($marker in @( + 'R18-host-pipeline-ab-20260915', + 'PREFERRED-CLEAR', + 'UNORM-CLEAR', + 'SRGB-CLEAR', + 'UNORM-PRODUCTION-SHADER', + 'DUPLICATION-PROBE', + 'DuplicateOutput1=', + 'CreateRTV FAIL' + )) { + if (-not $ascii.Contains($marker)) { throw "R18 marker missing: $marker" } + } + $peOffset = [BitConverter]::ToInt32($bytes, 0x3C) + if ([BitConverter]::ToUInt16($bytes, $peOffset + 4) -ne 0x8664) { + throw 'expected x64 diagnostic executable' + } + + - name: Prepare test package + shell: pwsh + run: | + $out = 'build-vr-capture-diag/package' + New-Item -ItemType Directory -Force -Path $out | Out-Null + Copy-Item 'build-vr-capture-diag/bin/outrun-vr-capture-diagnostic.exe' $out + @' + @echo off + set OUTRUN_VR_DIRECT_TRANSPORT=0 + set OUTRUN_VR_TEST_PATTERN=0 + echo R18 host-pipeline A/B diagnostic + echo 1. Keep Virtual Desktop connected with VDXR active. + echo 2. Do not run the normal outrun-vr-host.exe. + echo 3. Run this first, then launch OR2006C2C.EXE. + echo 4. Enter gameplay and keep the HMD on for at least 35 seconds. + echo 5. Press Ctrl+C to exit, then send outrun-vr-r18-host-pipeline.log. + echo. + outrun-vr-capture-diagnostic.exe + pause + '@ | Set-Content -Encoding ASCII "$out/RUN_R18_DIAGNOSTIC.bat" + + @' + OutRun VR R18 host-pipeline A/B diagnostic + ========================================== + Build: R18-host-pipeline-ab-20260915 + + R17 proved all production-path phases returned failure, including the synthetic phase. + R18 keeps the normal host OpenXR session/D3D11 device and isolates the remaining stages. + + Test: + 1. Connect Quest 3 with Virtual Desktop / VDXR. + 2. Do NOT run outrun-vr-host.exe at the same time. + 3. Run RUN_R18_DIAGNOSTIC.bat. + 4. Launch OutRun 2006 and enter gameplay. + 5. Watch at least 35-40 seconds so all 5 phases repeat. + 6. Exit with Ctrl+C. + 7. Send outrun-vr-r18-host-pipeline.log and tell which phases showed red/green versus black. + + Phases (5 seconds each): + 1 PREFERRED-CLEAR : production preferred swapchain format, direct D3D11 clear only. + 2 UNORM-CLEAR : explicit UNORM swapchain, direct D3D11 clear only. + 3 SRGB-CLEAR : explicit SRGB swapchain, direct D3D11 clear only. + 4 UNORM-PRODUCTION-SHADER : UNORM swapchain + synthetic texture through production R10 RenderTo shader. + 5 DUPLICATION-PROBE : keeps UNORM red/green visible while logging DuplicateOutput1/DuplicateOutput HRESULTs. + + The log records exact xrCreateSwapchain/enumeration/acquire/wait/release results, + actual D3D11 swapchain texture format, CreateRenderTargetView HRESULT, shader readiness, + and DuplicateOutput HRESULT. This distinguishes format/RTV, shader, and second-capture failures. + '@ | Set-Content -Encoding UTF8 "$out/README_R18.txt" + + - uses: actions/upload-artifact@v4 + with: + name: outrun-vr-r18-host-pipeline-diagnostic + path: build-vr-capture-diag/package/* + if-no-files-found: error diff --git a/.github/workflows/vr-diagnostic.yml b/.github/workflows/vr-diagnostic.yml new file mode 100644 index 000000000..e949d44aa --- /dev/null +++ b/.github/workflows/vr-diagnostic.yml @@ -0,0 +1,54 @@ +name: OpenXR direct diagnostic + +on: + push: + branches: [ vr-openxr ] + paths: + - 'vrhost/src/diagnostic_pattern.cpp' + - 'vrhost/CMakeLists.txt' + - '.github/workflows/vr-diagnostic.yml' + workflow_dispatch: + +jobs: + diagnostic-x64: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Configure x64 diagnostic + run: cmake -S vrhost -B build-vrdiag -G "Visual Studio 17 2022" -A x64 + + - name: Build direct OpenXR diagnostic + run: cmake --build build-vrdiag --config Release --target outrun-vr-diagnostic + + - name: Verify diagnostic binary + shell: pwsh + run: | + $diag = 'build-vrdiag/bin/outrun-vr-diagnostic.exe' + if (-not (Test-Path $diag)) { throw 'outrun-vr-diagnostic.exe was not produced' } + $bytes = [System.IO.File]::ReadAllBytes($diag) + if ($bytes.Length -lt 10000) { throw 'diagnostic executable is unexpectedly small' } + $ascii = [Text.Encoding]::ASCII.GetString($bytes) + foreach ($marker in @( + 'R16-openxr-direct-diagnostic-20260915', + 'ARRAY-UNORM projection', + 'SPLIT-UNORM projection', + 'VIEW-QUAD-UNORM', + 'ARRAY-SRGB projection', + 'outrun-vr-diagnostic.log' + )) { + if (-not $ascii.Contains($marker)) { throw "diagnostic marker missing: $marker" } + } + $peOffset = [BitConverter]::ToInt32($bytes, 0x3C) + if ([BitConverter]::ToUInt16($bytes, $peOffset + 4) -ne 0x8664) { + throw 'expected x64 diagnostic executable' + } + Get-FileHash $diag -Algorithm SHA256 + + - uses: actions/upload-artifact@v4 + with: + name: outrun-vr-direct-diagnostic-x64 + path: build-vrdiag/bin/outrun-vr-diagnostic.exe + if-no-files-found: error diff --git a/.github/workflows/vr-openxr-r20.yml b/.github/workflows/vr-openxr-r20.yml new file mode 100644 index 000000000..a1a740372 --- /dev/null +++ b/.github/workflows/vr-openxr-r20.yml @@ -0,0 +1,220 @@ +name: OpenXR VR recovery + R24 production build + +on: + push: + branches: [ vr-openxr ] + paths: + - 'src/vr/**' + - 'src/vr_shared.hpp' + - 'vrhost/**' + - 'tools/verify_vr_architecture.py' + - 'cmake.toml' + - 'CMakeLists.txt' + - '.github/workflows/vr-openxr-r20.yml' + workflow_dispatch: + +jobs: + game-x86: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Verify architecture boundaries + run: python tools/verify_vr_architecture.py + + - name: Verify current recovery/baseline guards + shell: pwsh + run: | + $recovery = Get-Content src/vr/d3d9/stereo_renderer_r23.cpp -Raw + foreach ($marker in @( + 'R23CaptureActualGameState', + 'R23GameDrawSerial', + 'R23DiagnoseHostFreshness', + 'passive original Clear only', + 'authoritative first seed', + 'mrtActive' + )) { + if (-not $recovery.Contains($marker)) { throw "recovery guard missing: $marker" } + } + $renderer = Get-Content src/vr/game/outrun_renderer_r23.cpp -Raw + foreach ($marker in @( + 'R23KeepWarmupPoseStockOnRenderThread', + 'R23ServiceRenderThreadCleanup', + 'recovery pose warmup is stock-visible' + )) { + if (-not $renderer.Contains($marker)) { throw "renderer guard missing: $marker" } + } + + - name: Configure Win32 + shell: cmd + run: | + set CI= + cmake -S . -B build-vr-game -G "Visual Studio 17 2022" -A Win32 + + - name: Verify R23 authoritative game source graph + shell: pwsh + run: | + $projects = Get-ChildItem build-vr-game -Filter *.vcxproj -Recurse + if (-not $projects) { throw 'no Visual Studio project files generated' } + $text = ($projects | ForEach-Object { Get-Content $_.FullName -Raw }) -join "`n" + if (-not $text.Contains('stereo_renderer_r23.cpp')) { throw 'R23 stereo wrapper missing from build graph' } + if (-not $text.Contains('outrun_renderer_r23.cpp')) { throw 'R23 renderer wrapper missing from build graph' } + foreach ($legacy in @('stereo_renderer.cpp','stereo_renderer_r13.cpp','stereo_renderer_r20.cpp','stereo_renderer_r21.cpp','stereo_renderer_r22.cpp','outrun_renderer.cpp','outrun_renderer_r13.cpp')) { + $escaped = [regex]::Escape($legacy) + $pattern = '', + 'top-level telemetry counted once' + ) + 'src/vr/d3d9/ex_device_upgrade_r14.cpp' = @( + 'std::unordered_map', + 'R14CopyWholeLevelByLock', + 'exact mip CPU-shadow upload failed' + ) + 'src/vr/d3d9/ex_device_upgrade_r15.cpp' = @( + 'R15ClassicAllState', + 'D3DSBT_ALL', + 'R15RestoreClassicAllState', + 'Ex promotion rolled back transactionally' + ) + 'src/vr/d3d9/stereo_renderer_r34.cpp' = @( + 'R34ResetReplayBlocked', + 'LastResetStateReplaySucceeded', + 'stereo remains fail-closed' + ) + 'src/vr/d3d9/vr_pass_policy.hpp' = @( + 'EffectStereoPolicy', + 'ZeroDisparity', + 'ClassifyEffectStereo' + ) + 'src/vr/game/outrun_renderer_r13.cpp' = @( + 'R13FragileEffectNeedsZeroDisparity', + 'shadow/billboard/panel pass kept stock', + 'InvalidateVerifiedWvp' + ) + 'src/vr/game/outrun_renderer_r29.cpp' = @( + 'R29BuildCoherentUploadEnvelope', + 'R29InvalidateRendererStateAfterExternalRestore' + ) + 'src/vr/game/outrun_renderer_r23.cpp' = @( + 'R23RenderThreadCleanupRequested', + 'R23ServiceRenderThreadCleanup', + 'R23KeepWarmupPoseStockOnRenderThread', + 'recovery pose warmup is stock-visible' + ) + 'src/vr/ipc/v3_game_shadow_bridge.cpp' = @( + 'ShadowIdlePollMs = 8', + 'lastClientPublishedFrameId', + 'clientChanged', + 'ShadowRetryMs' + ) + 'vrhost/src/ipc/v3_shadow_bridge.cpp' = @( + 'ShadowIdlePollMs = 8', + 'ShadowWriterRetryMs', + 'ackWriter.reset()' + ) + 'vrhost/src/ipc/host_state_v3_writer.hpp' = @( + 'Reset(false)', + 'constructor that throws never runs this object', + 'ProcessLiveness::Unknown' + ) + 'vrhost/src/runtime/r23_runtime_hardening.hpp' = @( + 'ExpectedDirectDxgiFormat', + 'DirectSafeEyeMatchesCommittedFrame', + 'SafeTransportGeneration' + ) + 'vrhost/src/runtime/d3d9ex_direct_passthrough_r32.hpp' = @( + 'R32SharedSlotCache', + 'R32InactiveEye', + 'queued copy cannot mutate SafeFrameId image identity' + ) + 'vrhost/src/runtime/r32_direct_submit.hpp' = @( + 'ArmConsumptionFence', + 'D3D11_ASYNC_GETDATA_DONOTFLUSH', + 'flushIssued', + 'deferred until actual direct-ring slot pressure', + 'R32 direct PERF 5s', + 'verified incoming DirectGPU projection submitted once', + 'presentation-authoritative while its exact producer slot stays protected' + ) + 'vrhost/src/runtime/sbs_capture_override.hpp' = @( + 'Preserve acquiredImage', + 'bool acquired = false', + 'Release(Projection);', + 'Release(Theater);' + ) + 'vrhost/src/main_r23.cpp' = @( + 'R23RefreshTheaterFallbackCapture', + 'allowInitialWarmupWait', + 'theater-only fallback' + ) + 'vrhost/src/runtime/d3d9ex_direct_passthrough.hpp' = @( + 'SafeTransportGeneration', + 'SafeTransportGeneration == generation', + 'host-owned GPU eye copies + completion ACK active' + ) + 'src/vr/ipc/shadow_lifetime_bridge.hpp' = @( + 'ShadowBridgeStopCallbackSlot', + 'RequestRegisteredShadowBridgeStop' + ) + 'src/dllmain.cpp' = @( + 'OutRunVR::IpcV3::RequestRegisteredShadowBridgeStop();' + ) + } + foreach ($path in $checks.Keys) { + $text = Get-Content $path -Raw + foreach ($marker in $checks[$path]) { + if (-not $text.Contains($marker)) { + throw "VR hardening guard missing: $path :: $marker" + } + } + } + + - name: Configure Win32 VR build + shell: cmd + run: | + set CI= + cmake -S . -B build-vr-game -G "Visual Studio 17 2022" -A Win32 + + - name: Verify regenerated R26 safe production source graph + shell: pwsh + run: | + $projects = Get-ChildItem build-vr-game -Filter *.vcxproj -Recurse + if (-not $projects) { throw 'no Visual Studio project files were generated' } + $projectText = ($projects | ForEach-Object { Get-Content $_.FullName -Raw }) -join "`n" + + foreach ($required in @( + 'ex_device_upgrade_r15.cpp', + 'stereo_renderer_r26_compare.cpp', + 'outrun_renderer_r23.cpp' + )) { + if (-not $projectText.Contains($required)) { + throw "authoritative safe VR translation unit missing from generated build graph: $required" + } + } + + foreach ($excluded in @( + 'stereo_renderer_r29.cpp', + 'stereo_renderer_r30.cpp', + 'stereo_renderer_r31.cpp', + 'stereo_renderer_r32.cpp', + 'stereo_renderer_r33.cpp', + 'stereo_renderer_r34.cpp', + 'outrun_renderer_r29.cpp' + )) { + $escaped = [regex]::Escape($excluded) + $pattern = ' stage/SOURCE_SHA.txt + - uses: actions/upload-artifact@v4 + with: + name: unified-dxvk-provider + path: stage/ + if-no-files-found: error + + multiview-patcher: + runs-on: windows-2022 + steps: + - name: Build multiview patcher + shell: pwsh + run: | + git init patcher-src + git -C patcher-src remote add origin https://github.com/Detegr/RBR-spirvpatcher.git + git -C patcher-src fetch --depth 1 origin 0ef3253da1738b07e67358a027311c7bcedaa001 + git -C patcher-src checkout --detach FETCH_HEAD + git -C patcher-src submodule update --init --recursive --depth 1 + Push-Location patcher-src/spirv-tools + python utils/git-sync-deps + Pop-Location + cmake -S patcher-src -B patcher-build -A Win32 -DCMAKE_BUILD_TYPE=Release + cmake --build patcher-build --config Release + New-Item -ItemType Directory -Force stage | Out-Null + Copy-Item patcher-build/src/Release/multiviewpatcher.dll stage/multiviewpatcher.dll + Copy-Item patcher-src/LICENSE stage/LICENSE-RBR-spirvpatcher-MPL-2.0.txt + git -C patcher-src rev-parse HEAD | Set-Content stage/SOURCE_SHA.txt + - uses: actions/upload-artifact@v4 + with: + name: unified-multiview-patcher + path: stage/ + if-no-files-found: error + + package: + runs-on: windows-2022 + needs: [d3d9-safe, dx12, dxvk-game-host, dxvk-provider, multiview-patcher] + steps: + - uses: actions/checkout@v4 + with: + ref: vr-unified-backends + - uses: actions/download-artifact@v4 + with: + name: unified-d3d9-safe + path: payload/d3d9 + - uses: actions/download-artifact@v4 + with: + name: unified-dx12 + path: payload/dx12 + - uses: actions/download-artifact@v4 + with: + name: unified-dxvk-game-host + path: payload/dxvk + - uses: actions/download-artifact@v4 + with: + name: unified-dxvk-provider + path: payload/dxvk-extra + - uses: actions/download-artifact@v4 + with: + name: unified-multiview-patcher + path: payload/dxvk-patcher + - name: Assemble unified test package + shell: pwsh + run: | + New-Item -ItemType Directory -Force package/backends/d3d9 | Out-Null + New-Item -ItemType Directory -Force package/backends/dxvk | Out-Null + New-Item -ItemType Directory -Force package/backends/dx12 | Out-Null + + Copy-Item payload/d3d9/dinput8.dll package/backends/d3d9/dinput8.dll + Copy-Item payload/d3d9/outrun-vr-host.exe package/backends/d3d9/outrun-vr-host.exe + Copy-Item payload/d3d9/SOURCE_SHA.txt package/backends/d3d9/SOURCE_SHA.txt + + Copy-Item payload/dxvk/dinput8.dll package/backends/dxvk/dinput8.dll + Copy-Item payload/dxvk/outrun-vr-host.exe package/backends/dxvk/outrun-vr-host.exe + Copy-Item payload/dxvk/SOURCE_SHA.txt package/backends/dxvk/SOURCE_SHA.txt + Copy-Item payload/dxvk-extra/d3d9.dll package/backends/dxvk/d3d9.dll + Copy-Item payload/dxvk-extra/SOURCE_SHA.txt package/backends/dxvk/DXVK_PROVIDER_SOURCE_SHA.txt + Copy-Item payload/dxvk-patcher/multiviewpatcher.dll package/backends/dxvk/multiviewpatcher.dll + Copy-Item payload/dxvk-patcher/SOURCE_SHA.txt package/backends/dxvk/MULTIVIEW_PATCHER_SOURCE_SHA.txt + + Copy-Item payload/dx12/dinput8.dll package/backends/dx12/dinput8.dll + Copy-Item payload/dx12/outrun-vr-host.exe package/backends/dx12/outrun-vr-host.exe + Copy-Item payload/dx12/SOURCE_SHA.txt package/backends/dx12/SOURCE_SHA.txt + + Copy-Item OutRun2006Tweaks.ini package/OutRun2006Tweaks.ini + Copy-Item OutRun2006Tweaks.lods.ini package/OutRun2006Tweaks.lods.ini + Copy-Item tools/Select-OutRunVRBackend.ps1 package/Select-OutRunVRBackend.ps1 + Copy-Item tools/OutRunVR-Backend-Selector.ps1 package/OutRunVR-Backend-Selector.ps1 + Copy-Item tools/OutRunVR-Backend-Selector.cmd package/OutRunVR-Backend-Selector.cmd + Copy-Item tools/Collect-OutRunVRLogs.ps1 package/Collect-OutRunVRLogs.ps1 + Copy-Item tools/Collect-OutRunVRLogs.cmd package/Collect-OutRunVRLogs.cmd + Copy-Item tools/Run-OutRunVRTest.ps1 package/Run-OutRunVRTest.ps1 + Copy-Item tools/Run-OutRunVRTest.cmd package/Run-OutRunVRTest.cmd + Copy-Item docs/VR_UNIFIED_TEST_SCENARIO.md package/VR_UNIFIED_TEST_SCENARIO.md + + # The tester ZIP is intentionally runtime-only. License notices and + # corresponding source are assembled into a separate compliance ZIP + # so the test folder stays uncluttered. + + $ini = Get-Content package/OutRun2006Tweaks.ini -Raw + $ini = [regex]::Replace($ini, '(?m)^SkyGlowFactor\s*=.*$', 'SkyGlowFactor = 1') + Set-Content package/OutRun2006Tweaks.ini $ini -Encoding UTF8 + + Copy-Item package/backends/d3d9/dinput8.dll package/dinput8.dll + Copy-Item package/backends/d3d9/outrun-vr-host.exe package/outrun-vr-host.exe + $short = $env:GITHUB_SHA.Substring(0,12) + $matrix = "VRM-$((Get-Date).ToUniversalTime().ToString('yyyyMMdd'))-$short" + Set-Content package/BUILD_MATRIX_ID.txt $matrix -Encoding ascii + [ordered]@{ + SchemaVersion = 1 + BuildMatrixId = $matrix + IntegrationSha = $env:GITHUB_SHA + D3D9Sha = (Get-Content payload/d3d9/SOURCE_SHA.txt -Raw).Trim() + DXVKGameHostSha = (Get-Content payload/dxvk/SOURCE_SHA.txt -Raw).Trim() + DXVKProviderSha = (Get-Content payload/dxvk-extra/SOURCE_SHA.txt -Raw).Trim() + MultiviewPatcherSha = (Get-Content payload/dxvk-patcher/SOURCE_SHA.txt -Raw).Trim() + DX12Sha = (Get-Content payload/dx12/SOURCE_SHA.txt -Raw).Trim() + WorkflowRunId = $env:GITHUB_RUN_ID + } | ConvertTo-Json | Set-Content package/BUILD_INPUTS.json -Encoding UTF8 + Set-Content package/ACTIVE_VR_BACKEND.txt ("backend=d3d9`nvariant=A_CONTROL`nmatrix=$matrix") -Encoding ascii + + # Keep compliance material outside the tester ZIP. + New-Item -ItemType Directory -Force compliance/SOURCE_GPL/3dmigoto-adapted | Out-Null + Copy-Item THIRD_PARTY_GPL_NOTICES.txt compliance/THIRD_PARTY_GPL_NOTICES.txt + Copy-Item LICENSE-3DMIGOTO-GPL-3.0.txt compliance/LICENSE-3DMIGOTO-GPL-3.0.txt + Copy-Item src/vr/d3d9/shader_fingerprint_gpl.hpp compliance/SOURCE_GPL/3dmigoto-adapted/shader_fingerprint_gpl.hpp + Copy-Item payload/dxvk-extra/LICENSE-DXVK-openRBRVR.txt compliance/LICENSE-DXVK-openRBRVR.txt + Copy-Item payload/dxvk-patcher/LICENSE-RBR-spirvpatcher-MPL-2.0.txt compliance/LICENSE-RBR-spirvpatcher-MPL-2.0.txt + Copy-Item package/BUILD_INPUTS.json compliance/BUILD_INPUTS.json + Copy-Item package/BUILD_MATRIX_ID.txt compliance/BUILD_MATRIX_ID.txt + Compress-Archive -Path compliance/* -DestinationPath "OutRun2_VR_COMPLIANCE_$short.zip" + + # Keep only files directly needed to select, launch, test, collect, + # identify, and compare the five runtime modes. + $required = @( + 'dinput8.dll', + 'outrun-vr-host.exe', + 'OutRun2006Tweaks.ini', + 'OutRun2006Tweaks.lods.ini', + 'Select-OutRunVRBackend.ps1', + 'OutRunVR-Backend-Selector.ps1', + 'OutRunVR-Backend-Selector.cmd', + 'Collect-OutRunVRLogs.ps1', + 'Collect-OutRunVRLogs.cmd', + 'Run-OutRunVRTest.ps1', + 'Run-OutRunVRTest.cmd', + 'VR_UNIFIED_TEST_SCENARIO.md', + 'BUILD_INPUTS.json', + 'BUILD_MATRIX_ID.txt' + ) + foreach ($file in $required) { if (-not (Test-Path (Join-Path package $file))) { throw "Required package file missing: $file" } } + + # Do not put release/compliance clutter or the obsolete duplicate CLI + # selector wrapper into the tester ZIP. + $forbiddenPaths = @( + 'Select-OutRunVRBackend.cmd', + 'THIRD_PARTY_GPL_NOTICES.txt', + 'LICENSE-3DMIGOTO-GPL-3.0.txt', + 'SOURCE_GPL' + ) + foreach ($file in $forbiddenPaths) { + if (Test-Path (Join-Path package $file)) { throw "Tester package contains forbidden release-only file: $file" } + } + $releaseOnlyFiles = Get-ChildItem package -Recurse -File | Where-Object { + $_.Name -match '^(LICENSE|COPYING|NOTICE|THIRD_PARTY)' + } + if ($releaseOnlyFiles) { + throw "Tester package contains release-only license/notice files: $($releaseOnlyFiles.FullName -join ', ')" + } + if (Test-Path package/d3d9.dll) { throw 'D3D9 control package root unexpectedly contains d3d9.dll' } + if (Test-Path package/multiviewpatcher.dll) { throw 'D3D9 control package root unexpectedly contains multiviewpatcher.dll' } + + Get-ChildItem package -Recurse -File | Get-FileHash -Algorithm SHA256 | ForEach-Object { + "$($_.Hash) $([IO.Path]::GetRelativePath((Resolve-Path package), $_.Path))" + } | Set-Content package/SHA256SUMS.txt + + Compress-Archive -Path package/* -DestinationPath "OutRun2_VR_UNIFIED_$short.zip" + - name: Validate packaged selectors, INI round trips, and ZIP contents + shell: pwsh + run: | + Copy-Item package package-smoke -Recurse + Set-Content package-smoke/OutRun2006Tweaks-session.log 'old-before-session' + foreach ($mode in @('2d','d3d9','dxvk-safe','dxvk','dx12')) { + & package-smoke/Select-OutRunVRBackend.ps1 -Backend $mode + $active = Get-Content package-smoke/ACTIVE_VR_BACKEND.txt -Raw + if ($active -notmatch "(?m)^backend=$([regex]::Escape($mode))\r?$") { throw "ACTIVE_VR_BACKEND mismatch for $mode" } + $iniText = Get-Content package-smoke/OutRun2006Tweaks.ini -Raw + if ($iniText -notmatch '(?m)^\[VR\]\s*$' -or $iniText -match '(?m)^\[VR\]\\') { throw "VR INI section corrupted for $mode" } + switch ($mode) { + '2d' { if (Test-Path package-smoke/outrun-vr-host.exe) { throw '2D mode retained VR host' } } + 'd3d9' { if ((Test-Path package-smoke/d3d9.dll) -or (Test-Path package-smoke/multiviewpatcher.dll)) { throw 'D3D9 mode retained DXVK files' } } + 'dxvk-safe' { if (-not (Test-Path package-smoke/d3d9.dll) -or (Test-Path package-smoke/multiviewpatcher.dll)) { throw 'DXVK SAFE file contract failed' } } + 'dxvk' { if (-not (Test-Path package-smoke/d3d9.dll) -or -not (Test-Path package-smoke/multiviewpatcher.dll)) { throw 'DXVK MULTIVIEW file contract failed' } } + 'dx12' { if ((Test-Path package-smoke/d3d9.dll) -or (Test-Path package-smoke/multiviewpatcher.dll)) { throw 'DX12 STRICT retained DXVK files' } } + } + if (-not (Test-Path package-smoke/CURRENT_VR_SESSION.json)) { throw "Session identity missing for $mode" } + $sessionStateBefore = Get-Content package-smoke/CURRENT_VR_SESSION.json -Raw | ConvertFrom-Json + Add-Content package-smoke/OutRun2006Tweaks-session.log "new-$mode" + Set-Content package-smoke/OR2006C2C_d3d9.log "dxvk-$mode" + & package-smoke/Collect-OutRunVRLogs.ps1 + $sessionDir = Join-Path package-smoke ("logs/{0}/{1}/{2}" -f $sessionStateBefore.BuildMatrixId,$sessionStateBefore.VariantId,$sessionStateBefore.SessionId) + $collectedLog = Join-Path $sessionDir 'OutRun2006Tweaks-session.log' + $collectedDxvk = Join-Path $sessionDir 'OR2006C2C_d3d9.log' + if (-not (Test-Path $collectedLog)) { throw "Collected session log missing for $mode" } + if (-not (Test-Path $collectedDxvk)) { throw "Collected DXVK log missing for $mode" } + $sessionText = Get-Content $collectedLog -Raw + $dxvkText = Get-Content $collectedDxvk -Raw + if ($sessionText -notmatch "new-$mode" -or $sessionText -match 'old-before-session') { throw "Session log isolation failed for $mode" } + if ($dxvkText -notmatch "dxvk-$mode") { throw "DXVK log content mismatch for $mode" } + if (Test-Path package-smoke/OutRun2006Tweaks-session.log) { throw "Root session log was not sealed for $mode" } + if (Test-Path package-smoke/OR2006C2C_d3d9.log) { throw "Root DXVK log was not sealed for $mode" } + $sessionStateAfter = Get-Content package-smoke/CURRENT_VR_SESSION.json -Raw | ConvertFrom-Json + if ($sessionStateAfter.SessionId -eq $sessionStateBefore.SessionId) { throw "Next session was not prepared for $mode" } + } + $zip = Get-ChildItem OutRun2_VR_UNIFIED_*.zip | Select-Object -First 1 + Expand-Archive $zip.FullName zip-verify + foreach ($file in @('OutRunVR-Backend-Selector.cmd','OutRunVR-Backend-Selector.ps1','Select-OutRunVRBackend.ps1','Collect-OutRunVRLogs.ps1','Collect-OutRunVRLogs.cmd','Run-OutRunVRTest.ps1','Run-OutRunVRTest.cmd','BUILD_INPUTS.json','BUILD_MATRIX_ID.txt','SHA256SUMS.txt','VR_UNIFIED_TEST_SCENARIO.md')) { + if (-not (Test-Path (Join-Path zip-verify $file))) { throw "ZIP content missing: $file" } + } + foreach ($file in @('Select-OutRunVRBackend.cmd','THIRD_PARTY_GPL_NOTICES.txt','LICENSE-3DMIGOTO-GPL-3.0.txt','SOURCE_GPL')) { + if (Test-Path (Join-Path zip-verify $file)) { throw "Lean tester ZIP contains release-only/duplicate file: $file" } + } + $zipReleaseOnly = Get-ChildItem zip-verify -Recurse -File | Where-Object { + $_.Name -match '^(LICENSE|COPYING|NOTICE|THIRD_PARTY)' + } + if ($zipReleaseOnly) { throw "Lean tester ZIP contains license/notice files." } + - uses: actions/upload-artifact@v4 + with: + name: OutRun2-VR-UNIFIED-${{ github.sha }} + path: OutRun2_VR_UNIFIED_*.zip + if-no-files-found: error + - uses: actions/upload-artifact@v4 + with: + name: OutRun2-VR-COMPLIANCE-${{ github.sha }} + path: OutRun2_VR_COMPLIANCE_*.zip + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 30b160613..d2b9ead0e 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,5 @@ build_x86/* .vscode/* out/* .vs/* -rel/* \ No newline at end of file +rel/* +OutRun2006Tweaks.profiles/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..130b9b3e1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,139 @@ +# OutRun2 VR Development Execution Contract + +This file defines the default execution model for substantial work in this repository, especially the OutRun2 VR/OpenXR backends and build matrix. + +## Core rule + +Do not run large review/fix/build/package tasks as one unbounded session. Treat work as resumable, bounded transactions with durable checkpoints. A fresh chat, automation run, or worker must be able to continue from repository state without relying on hidden conversation context. + +Before substantial work, read: + +- `docs/VR_AUTODEV_STATE.json` — machine-readable source of truth. +- `docs/VR_RUN_STATE.md` — concise human handoff, when present. +- `docs/VR_REVIEW_FINDINGS.md` — cumulative deduplicated findings, when present. +- `docs/VR_HOURLY_REVIEW_LOG.txt` and `docs/VR_BUILD_MATRIX_LOG.txt` when relevant. + +If the human-readable state files do not exist, create them during the next safe checkpoint. + +## Mandatory checkpoint flow + +Use the following sequence by default: + +### C0 — RECOVER +- Fetch the current development branch HEAD and every relevant component SHA. +- Read durable state and identify the exact unfinished checkpoint and resume cursor. +- Recover relevant CI runs, artifacts, logs, input hashes, blockers, and frozen package identity. +- Do not repeat completed work when the relevant source/dependency/config hashes are unchanged. + +### C1 — REVIEW +- Perform one bounded evidence-driven review batch, normally five genuinely distinct lenses/passes. +- Deduplicate findings against prior evidence. +- Separate confirmed evidence, hypotheses, contrary evidence, and hardware-only validation needs. +- Large review requests such as "review 50 times" mean ten persistent five-pass batches (RB01..RB10), not one monolithic reread and not fifty superficial repetitions. + +### C2 — IMPLEMENT +- Apply one small coherent fix set or one isolated experiment at a time. +- Do not mix unrelated risky changes in the same checkpoint. +- Preserve backend isolation and branch safety. + +### C3 — VALIDATE +- Run the relevant static checks, regression checks, selector/protocol tests, and changed-input builds. +- Prefer fail-before/pass-after evidence where practical. +- A successful compile alone is not runtime proof. + +### C4 — COMMIT +- Commit successful coherent changes to the active development branch with a descriptive message. +- Do not leave validated substantive changes only in ephemeral local state when safe remote publication is possible. +- Never force-publish over unrelated work or a live lease. + +### C5 — PACKAGE +- Package only when the current phase requires a candidate. +- Verify actual binary/ZIP contents, manifests, component SHAs, config identity, checksums, selectors, and collectors. +- Frozen evening artifacts remain immutable during user testing. + +### C6 — STATE +Before ending any substantial run, update durable continuation state. + +At minimum record: +- schema version / run ID +- current checkpoint and status +- resume cursor / exact next action +- branch and integration HEAD +- all relevant component SHAs +- relevant source/dependency/config hashes +- completed review batch IDs +- finding IDs and status +- changed files and commits +- build/test/CI IDs and results +- candidate/artifact hashes +- blockers +- retry count for the same unchanged failure +- timestamp + +Use atomic/CAS or equivalent single-writer protection where available. + +## Bounded-run rule + +A run must not keep expanding simply because more useful work exists. Prefer a complete durable checkpoint over an oversized unfinished session. + +If the current batch cannot safely finish in the active execution: +1. persist exact partial status and resume cursor, +2. record what was actually completed, +3. stop cleanly, +4. let the next chat/automation run resume from that point. + +Never claim background continuation. + +## Failure rule + +After two materially distinct failed repair attempts for the same unchanged failure: +- mark the item `BLOCKED`, +- preserve evidence and exact failure signatures, +- update durable state, +- move to independent work on the next run. + +New evidence may reopen the item. Do not create unbounded repair loops and do not weaken verification to get a green result. + +## Review batching + +Use persistent review batch IDs: +- RB01 — full relevant source/build graph +- RB02 — caller/lifetime retrace +- RB03 — regression history +- RB04 — backend isolation +- RB05 — OpenXR / DirectGPU +- RB06 — D3D9 state / WVP / Reset / StateBlock +- RB07 — hot paths / frame pacing / draw amplification +- RB08 — failure paths / cleanup / fallback +- RB09 — build / selector / logging / packaging +- RB10 — adversarial integration review of the actual frozen candidate + +Each batch should use five distinct lenses: +1. architecture/integration/build/package/license/regression boundaries +2. ownership/lifetime/synchronization/resource generations +3. stereo correctness (WVP/projection/HUD/sky/effects/recenter/menu/white rank-score/fallback) +4. performance (draw/state/caching/copies/waits/telemetry/XR pacing) +5. adversarial review trying to disprove earlier findings + +Do not rerun a completed batch unless a relevant input changed. Mark only affected batches stale. + +## Branch and release safety + +- Treat `vr-openxr` as stable unless the user explicitly requests modification/merge. +- Use `vr-unified-backends` as the primary integration/development branch unless current durable state says otherwise. +- Preserve the five-mode architecture and explicit backend isolation. +- Record every component SHA used by a package; integration HEAD alone is not package identity. +- Do not silently substitute fallback backends or fake A-F variants. + +## Interactive chat default + +When a user asks to review, fix, build, package, or continue this OutRun2 VR project in chat, follow this contract automatically. + +For long tasks: +- resume from repository state first, +- work in C0-C6 checkpoints, +- surface completed checkpoints as soon as they exist, +- persist enough state that a later chat can continue without the previous transcript, +- do not restart completed unchanged work. + +User-provided runtime logs and Quest/VDXR tests remain the authority for hardware-only behavior; offline evidence must be labeled accordingly. diff --git a/CMakeLists.txt b/CMakeLists.txt index 971339eef..558846ae2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,6 +26,207 @@ endif() project(outrun2006tweaks-proj) +set_source_files_properties(src/hooks_wheel_ffb.cpp PROPERTIES HEADER_FILE_ONLY TRUE) +# R33/R14 keep the validated R32/R31/R30/R29/R26/R23/R22/R21/R20/R13/R9/R12 +# implementation chains intact and compile only the final game/stereo/Ex wrapper +# TUs. Included implementation files are header-only here so no hook body is +# linked twice. +set(OUTRUN_VR_INCLUDED_IMPL_TUS + src/vr/d3d9/ex_device_upgrade.cpp + src/vr/d3d9/ex_device_upgrade_r13.cpp + src/vr/d3d9/stereo_renderer.cpp + src/vr/d3d9/stereo_renderer_r13.cpp + src/vr/d3d9/stereo_renderer_r20.cpp + src/vr/d3d9/stereo_renderer_r21.cpp + src/vr/d3d9/stereo_renderer_r22.cpp + src/vr/d3d9/stereo_renderer_r23.cpp + src/vr/d3d9/stereo_renderer_r26.cpp + src/vr/d3d9/stereo_renderer_r29.cpp + src/vr/d3d9/stereo_renderer_r30.cpp + src/vr/d3d9/stereo_renderer_r31.cpp + src/vr/d3d9/stereo_renderer_r32.cpp + src/vr/game/outrun_renderer.cpp + src/vr/game/outrun_renderer_r13.cpp + src/vr/game/outrun_renderer_r23.cpp) +set_source_files_properties(${OUTRUN_VR_INCLUDED_IMPL_TUS} + PROPERTIES HEADER_FILE_ONLY TRUE) + +set(OUTRUN_VR_FINAL_TUS + src/vr/d3d9/ex_device_upgrade_r14.cpp + src/vr/d3d9/stereo_renderer_r33.cpp + src/vr/game/outrun_renderer_r29.cpp) +foreach(_vr_source IN LISTS OUTRUN_VR_INCLUDED_IMPL_TUS OUTRUN_VR_FINAL_TUS) + if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${_vr_source}") + message(FATAL_ERROR "VR source graph references missing TU: ${_vr_source}") + endif() +endforeach() +foreach(_vr_source IN LISTS OUTRUN_VR_INCLUDED_IMPL_TUS) + get_source_file_property(_vr_header_only "${_vr_source}" HEADER_FILE_ONLY) + if(NOT _vr_header_only) + message(FATAL_ERROR "VR included implementation TU must be HEADER_FILE_ONLY: ${_vr_source}") + endif() +endforeach() +foreach(_vr_source IN LISTS OUTRUN_VR_FINAL_TUS) + get_source_file_property(_vr_header_only "${_vr_source}" HEADER_FILE_ONLY) + if(_vr_header_only) + message(FATAL_ERROR "VR final wrapper TU must compile normally: ${_vr_source}") + endif() +endforeach() +unset(_vr_source) +unset(_vr_header_only) + +# R15/R34 are post-review correctness overlays. They include the complete R14 +# and R33 chains respectively, so the former final wrappers become include-only. +set(OUTRUN_VR_POST_REVIEW_FINAL_TUS + src/vr/d3d9/ex_device_upgrade_r15.cpp + src/vr/d3d9/stereo_renderer_r34.cpp) +foreach(_vr_source IN LISTS OUTRUN_VR_POST_REVIEW_FINAL_TUS) + if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${_vr_source}") + message(FATAL_ERROR "VR post-review final TU missing: ${_vr_source}") + endif() +endforeach() +set_source_files_properties( + src/vr/d3d9/ex_device_upgrade_r14.cpp + src/vr/d3d9/stereo_renderer_r33.cpp + PROPERTIES HEADER_FILE_ONLY TRUE) +unset(_vr_source) + +# Production-safe stereo draw owner. The user-visible regression reproduced on +# Quest while the monitor SBS remained individually plausible is tied to the +# post-e20 R29+ fast L+R/HUD dispatch chain. Default back to the last proven +# R26/R23/R22/R13/R9 draw + R23 renderer boundary. R29-R34 comparison builds +# remain available only when this option is explicitly disabled. +option(OUTRUN_VR_SAFE_DRAW_COMPARE "Use proven R26/R23 safe stereo draw production path" ON) +set_source_files_properties( + src/vr/d3d9/stereo_renderer_r26_compare.cpp + src/vr/d3d9/stereo_renderer_r30_r26_safe.cpp + PROPERTIES HEADER_FILE_ONLY TRUE) +option(OUTRUN_VR_R26_HUD_COMPARE "Build R26-safe world path with R30 HUD/XYZRHW/SkyGlow overlay" OFF) +if(OUTRUN_VR_SAFE_DRAW_COMPARE) + set_source_files_properties( + src/vr/d3d9/stereo_renderer_r34.cpp + PROPERTIES HEADER_FILE_ONLY TRUE) + set_source_files_properties( + src/vr/d3d9/stereo_renderer_r26_compare.cpp + PROPERTIES HEADER_FILE_ONLY FALSE) + # Renderer R29 depends on R31 StateBlock exports. For this regression + # comparison, use the R23 renderer owner that paired with the known-good + # R26 stereo path instead of introducing fake R31 stubs. + set_source_files_properties( + src/vr/game/outrun_renderer_r29.cpp + PROPERTIES HEADER_FILE_ONLY TRUE) + set_source_files_properties( + src/vr/game/outrun_renderer_r23.cpp + PROPERTIES HEADER_FILE_ONLY FALSE) + add_compile_definitions(OUTRUN_VR_SAFE_DRAW_COMPARE=1) + message(STATUS "OutRun VR safe-draw comparison: R26 final stereo chain enabled") +endif() + +# C1/C2 regression-isolation builds. These keep current R15/transport/texture +# work and renderer R29 installation, but compile a shorter stereo owner chain. +# C1 = R29 fast L+R + restored R23/R27/R28 perspective-world classification. +# C2 = C1 + R30 asymmetric-FOV HUD correction. R31-R34 stay excluded in both. +option(OUTRUN_VR_C1_COMPARE "Build C1: R29 fast L+R with R27/R28 classification" OFF) +option(OUTRUN_VR_C2_COMPARE "Build C2: C1 plus R30 HUD correction" OFF) +set_source_files_properties( + src/vr/d3d9/stereo_renderer_r29_c1_compare.cpp + src/vr/d3d9/stereo_renderer_r30_c2_compare.cpp + PROPERTIES HEADER_FILE_ONLY TRUE) + +set(OUTRUN_VR_COMPARE_COUNT 0) +foreach(_vr_option + OUTRUN_VR_SAFE_DRAW_COMPARE + OUTRUN_VR_R26_HUD_COMPARE + OUTRUN_VR_C1_COMPARE + OUTRUN_VR_C2_COMPARE) + if(${_vr_option}) + math(EXPR OUTRUN_VR_COMPARE_COUNT + "${OUTRUN_VR_COMPARE_COUNT} + 1") + endif() +endforeach() +if(OUTRUN_VR_COMPARE_COUNT GREATER 1) + message(FATAL_ERROR "Choose only one VR comparison build option") +endif() +unset(_vr_option) + +if(OUTRUN_VR_R26_HUD_COMPARE) + set_source_files_properties( + src/vr/d3d9/stereo_renderer_r34.cpp + src/vr/d3d9/stereo_renderer_r26_compare.cpp + PROPERTIES HEADER_FILE_ONLY TRUE) + set_source_files_properties( + src/vr/d3d9/stereo_renderer_r30_r26_safe.cpp + PROPERTIES HEADER_FILE_ONLY FALSE) + set_source_files_properties( + src/vr/game/outrun_renderer_r29.cpp + PROPERTIES HEADER_FILE_ONLY TRUE) + set_source_files_properties( + src/vr/game/outrun_renderer_r23.cpp + PROPERTIES HEADER_FILE_ONLY FALSE) + add_compile_definitions(OUTRUN_VR_R26_HUD_COMPARE=1) + message(STATUS "OutRun VR R26+HUD comparison: safe R26 world + R30 HUD/SkyGlow") +endif() + +if(OUTRUN_VR_C1_COMPARE OR OUTRUN_VR_C2_COMPARE) + set_source_files_properties( + src/vr/d3d9/stereo_renderer_r34.cpp + PROPERTIES HEADER_FILE_ONLY TRUE) + add_compile_definitions(OUTRUN_VR_R29_R28_CLASSIFICATION_COMPARE=1) +endif() + +if(OUTRUN_VR_C1_COMPARE) + set_source_files_properties( + src/vr/d3d9/stereo_renderer_r29_c1_compare.cpp + PROPERTIES HEADER_FILE_ONLY FALSE) + add_compile_definitions(OUTRUN_VR_C1_COMPARE=1) + message(STATUS "OutRun VR C1 comparison: R29 fast L+R + R27/R28 classification") +elseif(OUTRUN_VR_C2_COMPARE) + set_source_files_properties( + src/vr/d3d9/stereo_renderer_r30_c2_compare.cpp + PROPERTIES HEADER_FILE_ONLY FALSE) + add_compile_definitions(OUTRUN_VR_C2_COMPARE=1) + message(STATUS "OutRun VR C2 comparison: C1 + R30 HUD correction") +endif() + +# Re-evaluate ownership after every comparison option has mutated +# HEADER_FILE_ONLY. The configured graph must compile exactly one stereo owner +# and exactly one renderer owner. +set(_vr_stereo_owner_candidates + src/vr/d3d9/stereo_renderer_r34.cpp + src/vr/d3d9/stereo_renderer_r26_compare.cpp + src/vr/d3d9/stereo_renderer_r30_r26_safe.cpp + src/vr/d3d9/stereo_renderer_r29_c1_compare.cpp + src/vr/d3d9/stereo_renderer_r30_c2_compare.cpp) +set(_vr_renderer_owner_candidates + src/vr/game/outrun_renderer_r29.cpp + src/vr/game/outrun_renderer_r23.cpp) +set(_vr_stereo_owner_count 0) +set(_vr_renderer_owner_count 0) +foreach(_vr_source IN LISTS _vr_stereo_owner_candidates) + get_source_file_property(_vr_header_only "${_vr_source}" HEADER_FILE_ONLY) + if(NOT _vr_header_only) + math(EXPR _vr_stereo_owner_count + "${_vr_stereo_owner_count} + 1") + endif() +endforeach() +foreach(_vr_source IN LISTS _vr_renderer_owner_candidates) + get_source_file_property(_vr_header_only "${_vr_source}" HEADER_FILE_ONLY) + if(NOT _vr_header_only) + math(EXPR _vr_renderer_owner_count + "${_vr_renderer_owner_count} + 1") + endif() +endforeach() +if(NOT _vr_stereo_owner_count EQUAL 1) + message(FATAL_ERROR + "VR final graph must have exactly one stereo owner; got ${_vr_stereo_owner_count}") +endif() +if(NOT _vr_renderer_owner_count EQUAL 1) + message(FATAL_ERROR + "VR final graph must have exactly one renderer owner; got ${_vr_renderer_owner_count}") +endif() +unset(_vr_source) +unset(_vr_header_only) + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} /MP") set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /MP") @@ -170,6 +371,18 @@ target_include_directories(spdlog PUBLIC "external/spdlog/include" ) +# Build identity used by VR cross-process diagnostics. +execute_process( + COMMAND git rev-parse --short=12 HEAD + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + OUTPUT_VARIABLE OUTRUN_VR_GIT_SHA + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET +) +if(NOT OUTRUN_VR_GIT_SHA) + set(OUTRUN_VR_GIT_SHA "unknown") +endif() + # Target: outrun2006tweaks set(outrun2006tweaks_SOURCES OutRun2006Tweaks.ini @@ -304,6 +517,16 @@ set(outrun2006tweaks_SOURCES "src/hooks_misc.cpp" "src/hooks_textures.cpp" "src/hooks_uiscaling.cpp" + "src/hooks_wheel_ffb.cpp" + "src/hooks_wheel_ffb_build.cpp" + "src/hooks_wheel_input_compat_v2.hpp" + "src/hooks_wheel_legacy_blank_defaults.hpp" + "src/hooks_wheel_menu_keyboard_back.hpp" + "src/hooks_wheel_menu_keyboard_select.hpp" + "src/hooks_wheel_r3_device_autoselect.hpp" + "src/hooks_wheel_r3_menu_ab.hpp" + "src/hooks_wheel_r3_menu_dpad.hpp" + "src/hooks_wheel_vehicle_dynamics.hpp" "src/input_manager.cpp" "src/input_manager.hpp" "src/input_names.hpp" @@ -324,12 +547,62 @@ set(outrun2006tweaks_SOURCES "src/overlay/server_notifications.cpp" "src/overlay/settings_ui.cpp" "src/overlay/update_check.cpp" + "src/overlay/wheel_setup_ui.cpp" "src/plugin.hpp" "src/resource.h" "src/settings.cpp" "src/settings.hpp" "src/upnp.cpp" "src/upnp.hpp" + "src/vr/core/frame_types.hpp" + "src/vr/core/matrix.hpp" + "src/vr/core/transport.hpp" + "src/vr/d3d9/device_probe.cpp" + "src/vr/d3d9/ex_device_upgrade.cpp" + "src/vr/d3d9/ex_device_upgrade_r13.cpp" + "src/vr/d3d9/ex_device_upgrade_r14.cpp" + "src/vr/d3d9/ex_device_upgrade_r15.cpp" + "src/vr/d3d9/helper_ex_probe.cpp" + "src/vr/d3d9/r13_bridge.hpp" + "src/vr/d3d9/r32_policy.hpp" + "src/vr/d3d9/shader_fingerprint_gpl.hpp" + "src/vr/d3d9/stereo_backend.hpp" + "src/vr/d3d9/stereo_renderer.cpp" + "src/vr/d3d9/stereo_renderer_r13.cpp" + "src/vr/d3d9/stereo_renderer_r20.cpp" + "src/vr/d3d9/stereo_renderer_r21.cpp" + "src/vr/d3d9/stereo_renderer_r22.cpp" + "src/vr/d3d9/stereo_renderer_r23.cpp" + "src/vr/d3d9/stereo_renderer_r26.cpp" + "src/vr/d3d9/stereo_renderer_r26_compare.cpp" + "src/vr/d3d9/stereo_renderer_r29.cpp" + "src/vr/d3d9/stereo_renderer_r30.cpp" + "src/vr/d3d9/stereo_renderer_r31.cpp" + "src/vr/d3d9/stereo_renderer_r32.cpp" + "src/vr/d3d9/stereo_renderer_r33.cpp" + "src/vr/d3d9/stereo_renderer_r34.cpp" + "src/vr/d3d9/stereo_renderer_r7.inc" + "src/vr/d3d9/vr_pass_policy.hpp" + "src/vr/game/game_adapter.hpp" + "src/vr/game/outrun_renderer.cpp" + "src/vr/game/outrun_renderer_r13.cpp" + "src/vr/game/outrun_renderer_r23.cpp" + "src/vr/game/outrun_renderer_r29.cpp" + "src/vr/ipc/direct_ack_r13.hpp" + "src/vr/ipc/host_pose_v3.hpp" + "src/vr/ipc/host_state_v3_reader.hpp" + "src/vr/ipc/protocol.hpp" + "src/vr/ipc/protocol_v3.hpp" + "src/vr/ipc/protocol_v3_smoke.cpp" + "src/vr/ipc/shadow_legacy_v2.hpp" + "src/vr/ipc/shadow_lifetime_bridge.hpp" + "src/vr/ipc/v3_game_shadow_bridge.cpp" + "src/vr/ipc/win32_channel.hpp" + "src/vr/settings.cpp" + "src/vr_shared.hpp" + "src/wheel_ffb_math.hpp" + "src/wheel_ffb_runtime.hpp" + "src/wheel_profile_store.hpp" ) add_library(outrun2006tweaks SHARED) @@ -341,6 +614,7 @@ target_compile_definitions(outrun2006tweaks PUBLIC _SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING _DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR DIRECTINPUT_VERSION=0x0800 + OUTRUN_VR_BUILD_SHA="${OUTRUN_VR_GIT_SHA}" ) target_compile_features(outrun2006tweaks PUBLIC @@ -348,7 +622,7 @@ target_compile_features(outrun2006tweaks PUBLIC ) target_compile_options(outrun2006tweaks PUBLIC - "/GS-" + "/GS" "/bigobj" "/EHa" "/MP" @@ -381,6 +655,7 @@ target_link_libraries(outrun2006tweaks PUBLIC Winmm.lib Setupapi.lib Crypt32.lib + d3dcompiler.lib ) target_link_options(outrun2006tweaks PUBLIC diff --git a/COPYING.GPL3 b/COPYING.GPL3 new file mode 100644 index 000000000..94a9ed024 --- /dev/null +++ b/COPYING.GPL3 @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/LICENSE-3DMIGOTO-GPL-3.0.txt b/LICENSE-3DMIGOTO-GPL-3.0.txt new file mode 100644 index 000000000..94a9ed024 --- /dev/null +++ b/LICENSE-3DMIGOTO-GPL-3.0.txt @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/LICENSE.md b/LICENSE.md index 5e62ded19..36eb6e61c 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,21 @@ +# License scope for `vr-openxr-gpl-reuse` + +This branch contains source adapted from 3Dmigoto under GNU GPL version 3. +When this branch is distributed as one combined work, the combined work is +distributed under **GPL-3.0-only**. See `COPYING.GPL3` and +`THIRD_PARTY_NOTICES.md`. + +The original OutRun2006Tweaks source and other contributions that were already +published under MIT **remain MIT-licensed**; their copyright and permission +notices are preserved below. The GPL terms for the combined branch do not erase +those pre-existing MIT permissions on the original MIT-covered material. + +## Upstream/base MIT license + MIT License Copyright (c) 2023 emoose +Copyright (c) 2026 thp32tt for wheel-ffb modifications Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -19,3 +34,16 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- + +## 한국어 안내 (비공식 요약) + +아래 영문 MIT License는 원본 OutRun2006Tweaks 및 기존 MIT 기여분에 계속 적용됩니다. 다만 `vr-openxr-gpl-reuse` 브랜치는 3Dmigoto의 GPLv3 코드를 포함하므로 결합된 브랜치 배포물은 GPL-3.0-only 조건으로 배포합니다. GPLv3 전문은 `COPYING.GPL3`를 참조하세요. 아래 한국어 내용은 이해를 돕기 위한 비공식 요약입니다. + +- 이 소프트웨어는 사용, 복사, 수정, 병합, 공개, 배포, 재라이선스 및 판매를 포함해 폭넓게 사용할 수 있습니다. +- 소프트웨어의 복사본 또는 상당 부분을 배포할 때는 위 저작권 고지와 MIT License의 허가 고지를 함께 포함해야 합니다. +- 소프트웨어는 어떠한 종류의 보증 없이 "있는 그대로(AS IS)" 제공됩니다. +- 저작권자 또는 기여자는 소프트웨어의 사용과 관련해 발생하는 청구, 손해 또는 기타 책임을 원칙적으로 부담하지 않습니다. + +정확한 조건은 반드시 위 영문 MIT License 원문을 기준으로 확인해 주세요. diff --git a/OutRun2006Tweaks.ini b/OutRun2006Tweaks.ini index 2a7bb28b8..fbfda54e6 100644 --- a/OutRun2006Tweaks.ini +++ b/OutRun2006Tweaks.ini @@ -38,9 +38,18 @@ FramerateInterpolation = true VSync = 1 [Controls] -# Enables new SDL-based input system -# Allowing game to see full trigger range without any shared trigger axes issues -# (experimental, not every menu/gamemode has been tested with it yet) +# Wheel build compatibility mode. Keeps all current Tweaks fixes, but uses the +# same original-game DirectInput input path as the working v0.6.1 release. +# Required for Windows wheels that SDL does not expose as standard gamepads. +# With this enabled, bind the wheel in the game's Options > Controller screen; +# the F11 SDL controller list being empty is expected. +WheelInputCompatibility = false + +# Enables the multi-device SDL input system with raw joystick bindings. +# This is the wheel-build default; force feedback remains on the custom +# DirectInput COM engine. With an attached FFB wheel, automatic backend +# selection prefers SDL DirectInput. Set false only when deliberately using +# WheelInputCompatibility as the legacy input fallback. UseNewInput = true # Backend to use for the SDL3 input system. @@ -52,8 +61,8 @@ UseNewInput = true InputBackend = 0 # Allows overriding the steering deadzone -# Game default is 0.2 / 20% -SteeringDeadZone = 0.2 +# Original game default is 0.2 / 20%; wheel branch defaults to zero. +SteeringDeadZone = 0.0 # Passes steering input to the game directly, instead of through the games own sensitivity curve # Allows for more sensitive controls, at the cost of the in-game sensitivity setting no longer applying @@ -65,6 +74,15 @@ BypassGameSensitivity = false # Note: ignored when using UseNewInput as hot-plug is supported by it by default ControllerHotPlug = false +# Per-game pedal direction correction. Do not also reverse the same pedal in +# MOZA Pit House, or the two inversions will cancel each other out. +WheelAccelerationInvert = false +WheelBrakeInvert = false + +# Prevents a pedal resting at maximum from being treated as a held menu +# direction. Keyboard arrow keys remain available. Requires a game restart. +WheelMenuDirectionFilter = true + # Makes Manual Transmission the default selection in C2C menus # (the menu animation will briefly show as Automatic, but Manual will be selected - spamming through menus will select Manual by default) DefaultManualTransmission = false @@ -89,7 +107,7 @@ VibrationStrength = 7 # 1 = enable impulse triggers # 2 = ^ with L/R motors swapped (recommended for impulse triggers) # 3 = ^ with L/R motors merged together -ImpulseVibrationMode = 2 +ImpulseVibrationMode = 0 # Multipliers of the trigger vibration, the normal controller motor vibration is multiplied by these to set trigger value ImpulseVibrationLeftMultiplier = 0.20 @@ -386,3 +404,78 @@ HideOnlineSigninText = false 26_Night_Flight_prototype.ogg = Night Flight (Prototype) 27_Life_was_a_Bore_Instrumental.ogg = Life was a Bore (Instrumental) 28_Night_Flight_Instrumental.ogg = Night Flight (Instrumental) + + +[VR] +# Enables the OpenXR renderer bridge. +Enabled = true + +# Starts outrun-vr-host.exe automatically from the game directory. +AutoLaunchHost = true + +# Automatically enables renderer-side tracking while a live host is present. +AutoEnableWhenHostPresent = true + +# Additional HUD/UI size trim after automatic per-eye headset FOV mapping. +# Lower = smaller. 0.55 is the current Quest 3 test default; try 0.50 or 0.45 +# if the speed/time/position HUD still feels too close to the edges. +HudScale = 0.55 + +# Keep the internal render/backbuffer resolution for VR, but fit the borderless +# PC mirror to the current monitor instead of letting a larger game surface clip. +MirrorFitDesktop = false + +# Let OpenXR/VDXR own headset cadence instead of first blocking the game source +# on desktop VSync. +DisableDesktopVsync = true + +# Main VR features. +HeadTracking = true +PositionalTracking = true +Stereo = true +CullingCameraSync = true +CullingUnionFov = false +PreferD3D9Ex = false + +# Safe default: do not require the D3D9Ex/DirectGPU transport. The D3D9 SAFE +# control path may use the verified SBS/Desktop Duplication fallback until the +# direct transport is explicitly selected for an experiment. +DirectGpuOnly = false + +# Respect the refresh rate selected by Virtual Desktop/OpenXR (72/80/90/120 Hz). +# Use a non-zero value only for an explicit diagnostic override. +TargetRefreshRateHz = 0.0 + +WorldScale = 1.0 +# 1.0 = runtime IPD. Raise only if stronger stereo depth is intentional. +StereoDepth = 1.0 +RotationScale = 1.0 +MatrixOrder = 0 +Telemetry = true + + +[WheelFFB] +; Physics SAT uses front lateral force times total pseudo-trail: +; pneumatic trail + a bounded mechanical/caster trail ratio. Mechanical trail +; acts throughout a loaded corner rather than appearing only after pneumatic +; trail fades. TrailResponseLead lets pneumatic trail react modestly ahead of +; filtered lateral force, reducing aligning-moment lag without using raw force direction. +MechanicalTrail = 0.25 +TrailResponseLead = 0.25 + +; Optional wheel-specific response correction. Leave disabled for a linear DD +; base unless you have measured a correction curve. LUT entries are DirectInput +; commands for desired torque 0%,10%,...,100%. These values belong in wheel +; profiles and are intentionally excluded from named FFB feel profiles. +ResponseCorrection = false +ResponseLUT = 0.0,0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.8,0.9,1.0 +MaxTorqueNm = 0.0 + +; Optional steering-wheel engine haptics. Default OFF because continuous engine +; texture is preference-dependent. F11 > Force Feedback exposes the live toggle +; and normalized strength control. RPM is estimated from speed, gear and throttle. +EngineVibration = false +EngineIdle = 0.20 + +; Opt-in 10 Hz diagnostics. Force Feedback > Save Force Feedback persists changes. +Telemetry = false diff --git a/README.md b/README.md index 38b75b174..57ff7a7c3 100644 --- a/README.md +++ b/README.md @@ -1,91 +1,111 @@ -# OutRun2006Tweaks -[![GitHub Downloads](https://img.shields.io/github/downloads/emoose/OutRun2006Tweaks/total)](https://github.com/emoose/OutRun2006Tweaks/releases) - -A wrapper DLL that can patch in fixes & tweaks into OutRun 2006: Coast 2 Coast. - -Latest releases can be found under the releases section: https://github.com/emoose/OutRun2006Tweaks/releases - -**Tweaks will also point the game to new multiplayer servers**, just head to the multiplayer section in-game and pick a username & password there! - -Online games are regularly setup on the **OutRun2006Tweaks Discord**: https://discord.gg/GFjKAMg83t - -> [!NOTE] -> **Releases are currently very outdated**, and missing a lot of new features (new input system, config overlay, framerate interpolation...) - if you have a GitHub account you can download the latest builds from the Actions tab at top of the page, otherwise try checking the Discord above! - -### Features - -**Bugfixes:** -- Game is now fixed to 60Hz tickrate, running at higher framerates now interpolates to that framerate instead of speeding up gameplay -- Prevents save corruption bug when remapping controls with many input devices connected -- Pegasus animation's clopping sound effect will now end correctly -- Fixed C2C ranking scoreboards not updating on Steam and other releases due to faulty anti-piracy checks -- Automatically disables DPI scaling on the game window to fix scaling issues -- Fixes issues with shading on certain character/stage models (eg. the ending cutscene models) -- Allows particles like grass/gravel to be drawn correctly, like in the console versions -- Crashes with multi-core machines are now fixed. -- Bink movie files larger than 1024 pixels can now play without crashes -- Game crashes will now write a crash report into CrashDumps folder (please feel free to post any crash reports to the issues page!) - -**Graphics:** -- Bloom/glow effect from the console & arcade versions has been re-added -- UI can now scale to different aspect ratios without stretching -- Game scene & UI textures can be extracted from game, and replaced with higher-resolution versions -- Allows disabling vehicle LODs, reducing the ugly pop-in as they get closer -- Fixed Z-buffer precision issues that caused heavy Z-fighting and distant object pop-in -- Lens flare effect now loads from correct path without needing to change game files -- Stage objects such as traffic cones now only disappear once they're actually off-screen -- Fixes certain effects like engine backfiring which failed to appear when using controllers -- Anisotropic filtering & transparency supersampling can be forced, greatly reducing aliasing around the edges of the track -- Reflection rendering resolution can be increased from the default 128x128 -- Restores the car base shadow from the C2C console ports, which was missing on PC for some reason -- Allows using higher-quality models for Alberto/Clarissa/Jennifer, which were otherwise left unused - -**Gameplay:** -- Points game toward new online servers, restoring the online multiplayer modes -- Restored XInput rumble code from the Xbox release can be enabled inside INI, allowing gear shifts/drifts/crashes/etc to give feedback -- Xbox Series impulse triggers are supported and can be tweaked inside INI -- Steering deadzone can be customized from the default 20% -- Horn button can be made functional during normal gameplay, outside of the "honk your horn!" girl requests -- Allows randomizing the set of highway animations to use, instead of only using the set for the game mode being played -- In-game HUD can be optionally toggled via bindable keypress -- Manual Transmission (MT) can be set as the default for C2C menus -- Passing all the C2C missions might unlock something new 🐱 - -**Enhancements:** -- Game can now run in borderless windowed mode; mouse cursor will now be hidden while game is active -- Will use desktop resolution for the game if outrun2006.ini isn't present -- Load times heavily reduced via improved framelimiter -- Draw distance for the stage can be increased, greatly reducing pop-in/fade-ins on the level -- Music can now be loaded from uncompressed WAV or lossless FLAC files, if they exist with the same filename -- Allows intro splash screens to be skipped -- Music track can be changed mid-race via Z and X buttons, or Back/RS+Back on controller (`CDSwitcher` must be enabled in INI first) - -All the above can be customized via the OutRun2006Tweaks.ini file. +# OutRun2006Tweaks Wheel FFB v0.1 + +[English](#english) | [한국어](#한국어) + + +## English + +Unofficial wheel / force-feedback fork for **OutRun 2006: Coast 2 Coast**, based on [emoose/OutRun2006Tweaks](https://github.com/emoose/OutRun2006Tweaks). + +v0.1 adds modern multi-device wheel input and native Windows DirectInput force feedback. Hardware development and validation were performed primarily with **MOZA R3**, while the FFB force model and default tuning are now device-independent and use the same logic on every supported DirectInput wheel. + +### Highlights + +- SDL3 raw multi-device input for wheel, pedals, shifter, button box and gamepad. +- One **Input Bindings** path for steering, pedals, shifter and buttons. +- Native DirectInput COM FFB; no vJoy or external FFB mapper required. +- Physics SAT with Natural SAT fallback, pneumatic/mechanical trail and fast counter-steer response. +- Light low-speed centering spring plus dynamic damping. +- Universal ConstantForce tactile path for road/slip detail where hardware periodic effects are not useful. +- Snow-stage curb/shoulder compensation and four-wheel mixed/full-rough surface handling. +- Gear-shift and collision feedback. +- Exact DirectInput FFB-device GUID persistence, reconnect handling and safe 20% direction tests. +- Optional estimated-RPM **Engine Vibration**. It is **OFF by default**; the default stored strength is **0.20** and is internally scaled so it remains a subtle texture rather than 20% wheel torque. ### Setup -Since Steam/DVD releases are packed with ancient DRM that doesn't play well with DLL wrappers, this pack includes a replacement game EXE to run the game with. -This EXE should be compatible with both the Steam release & the original DVD version, along with most OR2006 mods. +1. Extract **OutRun2006Tweaks-Wheel-FFB-v0.1.zip** into the OutRun 2006: Coast 2 Coast game directory and replace files when prompted. +2. Connect the wheel and pedals before starting the game. +3. Open the overlay with **F11**. +4. Configure steering, pedals, shifter and buttons in **Input Bindings**, then use **Save & Return to game**. +5. Open **Force Feedback** and select the actual DirectInput FFB wheel if it is not already selected. +6. Use the 20% left/right direction tests before increasing wheel-base torque. +7. Use **Load Universal Physics SAT** as the recommended starting feel. FFB tuning applies live; use **Save Force Feedback** to persist it. + +### Force-feedback notes + +The main cornering return force comes from SAT rather than a strong artificial centre spring. Wheel/driver-side centering, damping, inertia and friction are additional forces, so keep them conservative while evaluating game-side FFB. + +Road and snow/curb tactile effects remain enabled in the default universal preset. The snow-stage compensation is retained because it gives useful curb/shoulder feedback, though exact strength can still vary with the surface and wheel hardware. + +**Engine Vibration** is intentionally optional. When enabled, RPM is estimated from speed, current gear and throttle. Its amplitude and frequency are smoothed, the frequency is kept in a lighter haptic range, and only a very small amount of output headroom may be reserved during ordinary driving so the texture does not abruptly disappear at brief SAT peaks. Collision/gear events retain priority. + +### Package contents + +- `dinput8.dll` +- `OutRun2006Tweaks.ini` +- `OutRun2006Tweaks.lods.ini` +- `OR2006C2C.exe` +- `README.md` +- `LICENSES.txt` + +### Credits + +Based on `emoose/OutRun2006Tweaks`, with public design/reference work from `hyp36rmax/multi-device-input`, `d-b-c-e/OutRun2006Tweaks-FFB`, and OutRun community hardware reports. + +This is an unofficial community fork and is not affiliated with SEGA or MOZA. + +--- + + +## 한국어 + +**OutRun 2006: Coast 2 Coast**에서 현대식 레이싱 휠과 네이티브 포스피드백을 사용할 수 있도록 만든 비공식 포크입니다. [emoose/OutRun2006Tweaks](https://github.com/emoose/OutRun2006Tweaks)를 기반으로 합니다. + +v0.1은 멀티 디바이스 입력과 Windows DirectInput 기반 휠 FFB를 추가합니다. 개발과 실제 하드웨어 검증은 주로 **MOZA R3**로 진행했지만, 현재 FFB 힘 계산과 기본 튜닝은 제조사/모델명에 따라 달라지지 않고 지원되는 DirectInput 휠에 같은 로직을 적용합니다. + +### 주요 기능 + +- 휠, 별도 페달, 시프터, 버튼박스, 게임패드를 함께 사용하는 SDL3 Raw 멀티 디바이스 입력 +- 조향, 페달, 시프터, 버튼을 설정하는 단일 **Input Bindings** 경로 +- vJoy나 외부 매퍼가 필요 없는 Windows DirectInput COM 네이티브 FFB +- Physics SAT + Natural SAT 폴백, Pneumatic/Mechanical Trail, 빠른 카운터스티어 반응 +- SAT를 가리지 않는 약한 저속 센터링 스프링과 Dynamic Damping +- 하드웨어 periodic effect가 실질적으로 유용하지 않은 경우에도 동일하게 동작하는 범용 ConstantForce 노면/슬립 촉각 경로 +- 눈 맵 연석/숄더 보정과 네 바퀴 mixed/full-rough 표면 처리 +- 기어 변속 및 충돌 피드백 +- 정확한 DirectInput FFB GUID 저장, 재연결 처리, 20% 안전 방향 테스트 +- 추정 RPM 기반 **Engine Vibration** 옵션. 기본값은 **OFF**, 저장 강도 기본값은 **0.20**이며 실제 휠 토크 20%가 바로 들어가는 방식이 아니라 내부에서 약하게 스케일됩니다. + +### 설정 방법 + +1. **OutRun2006Tweaks-Wheel-FFB-v0.1.zip**을 게임 폴더에 압축 해제하고 파일을 덮어씁니다. +2. 게임 실행 전에 휠과 페달을 연결합니다. +3. **F11**로 오버레이를 엽니다. +4. **Input Bindings**에서 스티어링, 페달, 시프터, 버튼을 설정하고 **Save & Return to game**으로 저장합니다. +5. **Force Feedback**에서 실제 DirectInput FFB 휠이 선택됐는지 확인합니다. +6. 휠베이스 토크를 올리기 전에 20% 좌/우 방향 테스트를 사용합니다. +7. 기본 시작점은 **Load Universal Physics SAT**를 권장합니다. FFB 변경은 실시간 적용되며 **Save Force Feedback**으로 저장합니다. + +### FFB 참고 사항 -To set it up: +코너링에서 중앙으로 돌아가려는 주된 힘은 강한 인위적 센터 스프링이 아니라 SAT가 담당합니다. 휠베이스/드라이버 자체의 센터링, 댐핑, 관성, 마찰은 게임 FFB에 추가되는 힘이므로 과도하게 높이지 않는 것을 권장합니다. -- Extract the files from the release ZIP into your **Outrun2006 Coast 2 Coast** folder, where **OR2006C2C.EXE** is located, replacing the original EXE. -- Edit **OutRun2006Tweaks.ini** to customize the tweaks to your liking (by default all tweaks are enabled, other than `CDSwitcher`) -- **Important:** Install the latest x86 VC redist from (https://aka.ms/vs/17/release/vc_redist.x86.exe), a redist from 2024 is needed for Tweaks to launch correctly (**even if you already have it installed please try installing it again**) -- Run the game, your desktop resolution will be used by default if `outrun2006.ini` file isn't present. -- (optional) the [SoundtrackFix package](https://github.com/emoose/OutRun2006Tweaks/releases/download/v0.3.0-release/OutRun2006Tweaks-SoundtrackFix-1.0.zip) can be applied to fix the missing first 2 seconds in "Rush a Difficulty" -- (optional) texture improvements can be found in the texture pack releases thread (please feel free to create your own too!): https://github.com/emoose/OutRun2006Tweaks/issues/20 +노면과 눈길 연석 진동은 기본 범용 프리셋에서 유지합니다. 눈 맵 보정은 실제 테스트에서 연석/숄더 감각을 어느 정도 살려주는 효과가 있어 그대로 포함했습니다. 다만 표면과 휠 하드웨어에 따라 강도 차이는 있을 수 있습니다. -Steam Deck/Linux users may need to run the game with `WINEDLLOVERRIDES="dinput8=n,b" %command%` launch parameters for the mod to load in. +**Engine Vibration**은 취향에 따라 켜는 선택 옵션으로 기본 OFF입니다. 켜면 속도, 현재 기어, 스로틀로 RPM을 추정하고 진폭/주파수를 부드럽게 필터링합니다. 주행 중 너무 무겁게 느껴지지 않도록 더 높은 촉각 주파수 대역과 낮은 내부 출력 스케일을 사용하며, 일반 주행에서는 SAT 피크 때문에 갑자기 사라지는 현상을 줄이기 위해 아주 작은 출력 여유만 확보합니다. 충돌/기어 이벤트는 우선합니다. -### Building -Building requires Visual Studio 2022, CMake & git to be installed, with those setup just clone this repo and then run `generate_2022.bat`. +### 배포 파일 구성 -If the batch script succeeds you should see a `build\outrun2006tweaks-proj.sln` solution file, just open that in VS and build it. +- `dinput8.dll` +- `OutRun2006Tweaks.ini` +- `OutRun2006Tweaks.lods.ini` +- `OR2006C2C.exe` +- `README.md` +- `LICENSES.txt` -(if you have issues building with this setup please let me know) +### 크레딧 -### Thanks -Thanks to [debugging.games](http://debugging.games) for hosting debug symbols for OutRun 2 SP (Lindburgh), very useful for looking into Outrun2006. +`emoose/OutRun2006Tweaks`를 기반으로 하며 `hyp36rmax/multi-device-input`, `d-b-c-e/OutRun2006Tweaks-FFB`의 공개 설계/참고 작업과 OutRun 커뮤니티 하드웨어 보고를 참고했습니다. -(**if you own any prototype of Coast 2 Coast or Online Arcade** it may also contain debug symbols inside, which would let us improve even more on the C2C side of the game - please consider getting in touch at my email: lucknut.xbl at gmail dot com) +이 프로젝트는 비공식 커뮤니티 포크이며 SEGA 또는 MOZA와 제휴하거나 공식 지원받는 프로젝트가 아닙니다. diff --git a/RELEASE_NOTES_v0.1.1.md b/RELEASE_NOTES_v0.1.1.md new file mode 100644 index 000000000..74d01ef08 --- /dev/null +++ b/RELEASE_NOTES_v0.1.1.md @@ -0,0 +1,111 @@ +# OutRun2006Tweaks Wheel FFB v0.1.1 + +[English](#english) | [한국어](#한국어) + + +## English + +v0.1.1 is a compatibility and stability update to the first Wheel FFB release. It keeps the same Modern / Physics SAT direction of the project and focuses on making the existing force-feedback implementation work more reliably across real DirectInput wheel-driver layouts. + +**X-Force is not included in this release.** The experimental X-Force work remains separate; v0.1.1 uses the project's own Physics SAT / Natural SAT force path. + +### Changes since v0.1 + +- Hardened DirectInput FFB selection for wheelbases that expose multiple Windows interfaces. +- Added exhaustive compatible-interface probing instead of assuming the first matching interface is usable for force feedback. +- Enumerates and probes every reported force-actuator axis, with validated ConstantForce creation and live zero-force update testing. +- Improved physical-device matching using the saved GUID plus DirectInput product identity, VID/PID and FFB driver/vendor information where available. +- Added safer interface quarantine, retry and reinitialization when a candidate interface or live ConstantForce update fails. +- Improved transient device-loss and hot reconnect recovery. A working wheel can be disconnected and reconnected during gameplay without requiring a game restart when the driver exposes it again correctly. +- Expanded F11 Force Feedback runtime diagnostics and setup status. Menu-time `waiting / released / inactive` is now shown as a pending state rather than a hardware failure because the FFB output is acquired when gameplay starts. +- `Direction test` is shown as pending until the 20% left/right test is actually run, instead of looking like a device error. +- Added named FFB feel profiles under `OutRun2006Tweaks.profiles\FFB\.ini`. Feel profiles do not silently reroute the selected physical FFB device. +- Profile saves use a staged write, backup-on-overwrite, rename/copy fallback and final-file verification so an interrupted or rejected replacement is less likely to destroy the previous profile. +- Fixed the Windows first-save bug where a profile that did not exist yet could return `ERROR_FILE_NOT_FOUND` from `std::filesystem` and was incorrectly treated as a save failure. +- Profile save failures now report/log the real destination path and Windows filesystem error to make remote hardware testing easier. +- Further hardened Physics SAT transitions, counter-steer/reversal release and fallback behavior while retaining Natural SAT as the safe fallback when physics telemetry is not valid. +- Added more FFB tuning/diagnostic visibility, including mechanical/pneumatic trail response, output headroom/clipping information and optional per-wheel response correction controls. + +### Hardware validation + +- **MOZA R3:** primary development/regression hardware; current DirectInput ConstantForce, Spring and Damper paths remain working. +- **Simucube 3:** community testing confirmed that an intentional disconnect/reconnect during gameplay was recognized again and FFB resumed correctly. +- **Fanatec / multi-interface driver layouts:** v0.1.1 includes the new interface-selection, all-actuator probing and recovery work based on tester reports. Additional model-specific reports are welcome. +- A real Windows test also confirmed that named FFB profiles now save successfully to `OutRun2006Tweaks.profiles\FFB`. + +### Upgrade / setup + +Extract `OutRun2006Tweaks-Wheel-FFB-v0.1.1.zip` into the OutRun 2006: Coast 2 Coast game directory and replace the supplied files. Existing `OutRun2006Tweaks.user.ini`, input bindings and profile folders are not part of the release archive and should remain in place. + +After upgrading, open **F11 → Force Feedback**, confirm the intended DirectInput FFB output, enter gameplay so the runtime acquires the device, and run the safe 20% left/right direction test once. Named FFB feel profiles are stored in `\OutRun2006Tweaks.profiles\FFB`. + +### Package contents + +- `dinput8.dll` +- `OutRun2006Tweaks.ini` +- `OutRun2006Tweaks.lods.ini` +- `OR2006C2C.exe` +- `README.md` +- `RELEASE_NOTES_v0.1.1.md` +- `LICENSES.txt` + +### Credits + +Based on `emoose/OutRun2006Tweaks`, with public design/reference work from `hyp36rmax/multi-device-input`, `d-b-c-e/OutRun2006Tweaks-FFB`, and OutRun community hardware reports and testing. + +This is an unofficial community fork and is not affiliated with SEGA, MOZA, Fanatec or Simucube. + +--- + + +## 한국어 + +v0.1.1은 첫 Wheel FFB 공개 버전인 v0.1의 **호환성·안정화 업데이트**입니다. 새로운 FFB 성격을 추가하기보다 기존에 만든 Modern / Physics SAT 계열을 유지하면서 실제 DirectInput 휠 드라이버 구성에서 더 안정적으로 동작하도록 개선했습니다. + +**이번 릴리즈에는 X-Force를 포함하지 않습니다.** X-Force 실험은 별도 작업으로 남겨 두며, v0.1.1은 우리가 만든 Physics SAT / Natural SAT FFB 경로를 사용합니다. + +### v0.1 이후 변경 사항 + +- 여러 Windows 인터페이스를 노출하는 휠베이스를 위한 DirectInput FFB 장치 선택 로직 강화 +- 첫 번째 후보만 사용하는 대신 실제로 동작하는 호환 FFB 인터페이스를 끝까지 탐색하도록 개선 +- 드라이버가 보고하는 모든 force-actuator axis를 열거·검증하고 ConstantForce 생성 및 0-force 실시간 업데이트 경로 확인 +- 저장된 GUID뿐 아니라 DirectInput product identity, VID/PID, FFB driver/vendor 정보를 활용한 동일 물리 장치 매칭 강화 +- 잘못된 인터페이스 또는 실시간 ConstantForce 출력 실패 시 안전한 격리, 재시도, 재초기화 처리 강화 +- 일시적인 장치 손실과 핫 리커넥트 복구 개선. 드라이버가 장치를 정상적으로 다시 노출하면 게임 중 분리/재연결 후에도 게임 재시작 없이 FFB를 복구할 수 있도록 보강 +- F11 Force Feedback 런타임 진단과 설정 상태 표시 개선. 메뉴에서의 `waiting / released / inactive`는 정상적인 대기 상태이므로 오류가 아니라 `pending`으로 표시 +- 20% 좌/우 테스트를 아직 실행하지 않은 상태도 장치 오류가 아니라 `Direction test (not run)` 대기 상태로 표시 +- `OutRun2006Tweaks.profiles\FFB\<이름>.ini`에 저장되는 이름별 FFB 감각 프로필 추가. FFB 감각 프로필을 불러와도 실제 출력 휠 장치를 임의로 변경하지 않음 +- 프로필 저장을 `.tmp` staged write → 기존 파일 백업 → rename/copy fallback → 최종 파일 확인 방식으로 강화 +- Windows에서 최초 저장 대상 파일이 아직 없을 때 `std::filesystem`의 `ERROR_FILE_NOT_FOUND`를 저장 실패로 잘못 처리하던 버그 수정 +- 프로필 저장 실패 시 실제 대상 경로와 Windows 파일시스템 오류를 UI와 로그에 남기도록 개선 +- Physics SAT 전환, 카운터스티어/토크 반전 해제, 유효하지 않은 물리 샘플에서 Natural SAT로 넘어가는 폴백 동작을 추가로 안정화 +- Mechanical/Pneumatic Trail 응답, 출력 headroom/clipping, 선택형 휠 response correction 등 FFB 튜닝·진단 가시성 확대 + +### 하드웨어 검증 + +- **MOZA R3:** 주 개발·회귀 테스트 장비이며 현재 DirectInput ConstantForce, Spring, Damper 경로 정상 동작 확인 +- **Simucube 3:** 커뮤니티 테스트에서 주행 중 의도적으로 장치를 분리했다 다시 연결해도 재인식되고 FFB가 정상 복구됨을 확인 +- **Fanatec 및 멀티 인터페이스 드라이버 구성:** 사용자 테스트 결과를 바탕으로 인터페이스 선택, 전체 actuator 탐색, 실패 복구 경로를 v0.1.1에 반영. 모델별 추가 테스트 결과는 계속 환영 +- 실제 Windows 환경에서 이름별 FFB 프로필이 `OutRun2006Tweaks.profiles\FFB`에 정상 저장되는 것도 확인 + +### 업데이트 / 설정 + +`OutRun2006Tweaks-Wheel-FFB-v0.1.1.zip`을 OutRun 2006: Coast 2 Coast 게임 폴더에 압축 해제하고 포함된 파일을 덮어씁니다. 기존 `OutRun2006Tweaks.user.ini`, Input Bindings, 프로필 폴더는 배포 ZIP에 포함하지 않으므로 그대로 유지됩니다. + +업데이트 후 **F11 → Force Feedback**에서 원하는 DirectInput FFB 출력 장치를 확인하고, 실제 주행에 들어가 FFB 장치가 acquire된 뒤 20% 좌/우 방향 테스트를 한 번 실행하는 것을 권장합니다. 이름별 FFB 감각 프로필은 `<게임 폴더>\OutRun2006Tweaks.profiles\FFB`에 저장됩니다. + +### 배포 파일 구성 + +- `dinput8.dll` +- `OutRun2006Tweaks.ini` +- `OutRun2006Tweaks.lods.ini` +- `OR2006C2C.exe` +- `README.md` +- `RELEASE_NOTES_v0.1.1.md` +- `LICENSES.txt` + +### 크레딧 + +`emoose/OutRun2006Tweaks`를 기반으로 하며 `hyp36rmax/multi-device-input`, `d-b-c-e/OutRun2006Tweaks-FFB`의 공개 설계/참고 작업과 OutRun 커뮤니티의 하드웨어 보고 및 테스트를 참고했습니다. + +이 프로젝트는 비공식 커뮤니티 포크이며 SEGA, MOZA, Fanatec 또는 Simucube와 제휴하거나 공식 지원받는 프로젝트가 아닙니다. diff --git a/RELEASE_NOTES_v0.1.md b/RELEASE_NOTES_v0.1.md new file mode 100644 index 000000000..9287b7cc6 --- /dev/null +++ b/RELEASE_NOTES_v0.1.md @@ -0,0 +1,105 @@ +# OutRun2006Tweaks Wheel FFB v0.1 + +[English](#english) | [한국어](#한국어) + + +## English + +First public release of the `wheel-ffb` branch. + +### Highlights + +- SDL3 raw multi-device input for modern wheel setups. +- One Input Bindings path for steering, pedals, shifter and buttons. +- Native Windows DirectInput COM wheel FFB. +- Device-independent Physics SAT / Natural SAT force model with mechanical/caster trail. +- Dynamic damping, grip-loss unloading and improved counter-steer response. +- Light low-speed centering spring so SAT remains the main cornering return force. +- Universal ConstantForce road/slip tactile fallback. +- Snow-stage curb/shoulder compensation with four-wheel mixed/full-rough detection. +- Gear-shift and collision feedback. +- Exact DirectInput FFB GUID persistence, reconnect handling and safe 20% direction tests. +- Optional estimated-RPM Engine Vibration. Default OFF; default stored strength 0.20 with conservative internal scaling. + +### Tested hardware + +Primary physical validation was performed with **MOZA R3**. The current FFB tuning no longer branches on manufacturer/model name, so supported DirectInput wheels run the same force logic; physical feel can still differ by motor torque, firmware, wheel diameter and driver-side effects. + +### Recommended setup + +Use **F11 → Input Bindings** for steering, pedals, shifter and buttons, then **Save & Return to game**. Use **F11 → Force Feedback** to select the actual FFB output wheel and start with **Load Universal Physics SAT**. FFB tuning applies live; use **Save Force Feedback** to persist changes. + +Road and snow/curb tactile feedback remain enabled. The snow-stage compensation is retained because it provides useful curb/shoulder feedback in physical testing. The v0.1 refresh also fixes a persistent-vibration bug after snow stages: the snow curb latch now expires after its fixed 450 ms grace period, and low-roughness route-fork material changes no longer trigger the curb boost. + +Engine Vibration is intentionally an opt-in effect. When enabled, estimated RPM follows speed, current gear and throttle. The v0.1 release smooths amplitude/frequency, shifts the texture away from the heavy low-frequency pulse region and uses only a small output reserve during normal driving so it is less likely to appear/disappear at SAT peaks. Collision and gear events retain priority. + +### Install + +Extract `OutRun2006Tweaks-Wheel-FFB-v0.1.zip` into the OutRun 2006: Coast 2 Coast game directory and replace files when prompted. + +### Package contents + +- `dinput8.dll` +- `OutRun2006Tweaks.ini` +- `OutRun2006Tweaks.lods.ini` +- `OR2006C2C.exe` +- `README.md` +- `LICENSES.txt` + +### Credits + +Based on `emoose/OutRun2006Tweaks`, with public design/reference work from `hyp36rmax/multi-device-input`, `d-b-c-e/OutRun2006Tweaks-FFB`, and OutRun community hardware reports. + +This is an unofficial community fork and is not affiliated with SEGA or MOZA. + +--- + + +## 한국어 + +`wheel-ffb` 브랜치의 첫 공개 릴리즈입니다. + +### 주요 기능 + +- 현대식 휠 구성을 위한 SDL3 Raw 멀티 디바이스 입력 +- 스티어링, 페달, 시프터, 버튼을 설정하는 단일 Input Bindings 경로 +- Windows DirectInput COM 기반 네이티브 휠 FFB +- 제조사/모델명에 따라 달라지지 않는 Physics SAT / Natural SAT 힘 계산과 Mechanical/Caster Trail +- Dynamic Damping, 그립 손실 시 하중 감소, 개선된 카운터스티어 반응 +- SAT가 주된 코너링 복원력이 되도록 약하게 유지한 저속 센터링 스프링 +- 모든 휠에서 동일하게 사용하는 ConstantForce 노면/슬립 촉각 폴백 +- 네 바퀴 mixed/full-rough 감지를 포함한 눈 맵 연석/숄더 보정 +- 기어 변속 및 충돌 피드백 +- 정확한 DirectInput FFB GUID 저장, 재연결 처리, 20% 안전 방향 테스트 +- 추정 RPM 기반 Engine Vibration 선택 옵션. 기본 OFF, 저장 강도 기본값 0.20이며 내부 출력은 보수적으로 제한 + +### 테스트한 하드웨어 + +실제 하드웨어 검증은 주로 **MOZA R3**로 진행했습니다. 현재 FFB 튜닝은 제조사/모델명으로 힘 계산을 바꾸지 않으며 지원되는 DirectInput 휠에 같은 로직을 사용합니다. 다만 모터 토크, 펌웨어, 휠 직경, 드라이버 자체 효과 때문에 실제 체감은 달라질 수 있습니다. + +### 권장 설정 + +**F11 → Input Bindings**에서 스티어링, 페달, 시프터, 버튼을 설정하고 **Save & Return to game**으로 저장합니다. **F11 → Force Feedback**에서 실제 FFB 출력 휠을 선택한 뒤 **Load Universal Physics SAT**를 시작점으로 권장합니다. FFB 변경은 실시간 적용되며 **Save Force Feedback**으로 저장합니다. + +노면과 눈길 연석 진동은 유지했습니다. 눈 맵 보정은 실제 테스트에서 연석/숄더 감각을 어느 정도 살려주는 효과가 있어 v0.1에 그대로 포함합니다. 이번 v0.1 갱신에서는 눈길 종료 후 진동이 계속되는 버그도 수정했습니다. 눈길 연석 latch는 확인된 mixed 접촉 후 450ms까지만 유지되며, 거칠기가 낮은 일반 갈림길 재질 변화는 연석 진동으로 증폭하지 않습니다. + +Engine Vibration은 취향에 따라 켜는 선택 옵션이며 기본 OFF입니다. 켜면 속도, 현재 기어, 스로틀로 RPM을 추정합니다. v0.1에서는 진폭/주파수를 부드럽게 필터링하고 무겁게 느껴지는 저주파 펄스 영역을 피하며, 일반 주행에서 SAT 피크 때문에 진동이 갑자기 사라지는 현상을 줄이기 위해 아주 작은 출력 여유만 확보합니다. 충돌과 기어 이벤트가 우선합니다. + +### 설치 + +`OutRun2006Tweaks-Wheel-FFB-v0.1.zip`을 OutRun 2006: Coast 2 Coast 게임 폴더에 압축 해제하고 파일 교체 안내가 나오면 덮어씁니다. + +### 배포 파일 구성 + +- `dinput8.dll` +- `OutRun2006Tweaks.ini` +- `OutRun2006Tweaks.lods.ini` +- `OR2006C2C.exe` +- `README.md` +- `LICENSES.txt` + +### 크레딧 + +`emoose/OutRun2006Tweaks`를 기반으로 하며 `hyp36rmax/multi-device-input`, `d-b-c-e/OutRun2006Tweaks-FFB`의 공개 설계/참고 작업과 OutRun 커뮤니티 하드웨어 보고를 참고했습니다. + +이 프로젝트는 비공식 커뮤니티 포크이며 SEGA 또는 MOZA와 제휴하거나 공식 지원받는 프로젝트가 아닙니다. diff --git a/THIRD_PARTY_GPL_NOTICES.txt b/THIRD_PARTY_GPL_NOTICES.txt new file mode 100644 index 000000000..c7c778f25 --- /dev/null +++ b/THIRD_PARTY_GPL_NOTICES.txt @@ -0,0 +1,22 @@ +OutRun2006Tweaks VR - Third-party GPL notice + +This VR branch contains an adapted D3D9 shader bytecode fingerprint helper: + src/vr/d3d9/shader_fingerprint_gpl.hpp + +The FNV-1 64-bit hashing routine and the technique of identifying D3D9 shaders +from shader bytecode rather than COM object addresses are adapted from: + 3Dmigoto by the 3Dmigoto contributors + Upstream: https://github.com/bo3b/3Dmigoto + Upstream files referenced by the adaptation include util.h and + DirectX9/Direct3DDevice9Functions.h. + +3Dmigoto is distributed under GNU GPL version 3. The adapted helper is marked: + SPDX-License-Identifier: GPL-3.0-only + +The corresponding adapted source is distributed in this repository and is also +copied into test packages under SOURCE_GPL/3dmigoto-adapted/. +The upstream GPL text is included as LICENSE-3DMIGOTO-GPL-3.0.txt. + +The shader fingerprint path is compiled into the D3D9 VR renderer. Diagnostic +hash logging is enabled by setting: + OUTRUN_VR_SHADER_FINGERPRINT=1 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 000000000..b825e80c5 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,158 @@ +# Third-party notices and acknowledgements + +[English](#english) | [한국어](#한국어) + + +## English + +This file documents the main upstream/reference projects used by the `wheel-ffb` branch and how binary-release notices are handled. + +### Upstream base + +#### emoose / OutRun2006Tweaks + +Repository: https://github.com/emoose/OutRun2006Tweaks + +Role: upstream/base project. The wrapper, game hooks, overlay/configuration system and much of the codebase originate from this project. + +License: MIT. The original `Copyright (c) 2023 emoose` notice is preserved in this repository's `LICENSE.md`. + +### Wheel/input references + +#### hyp36rmax / multi-device-input + +Repository/branch: https://github.com/hyp36rmax/multi-device-input/tree/multi-device-input + +Role: important public reference/adaptation source for SDL3 raw multi-device input, separate USB-device handling and modern wheel compatibility behavior. + +The branch retains the upstream MIT license notice. + +#### d-b-c-e / OutRun2006Tweaks-FFB + +Repository: https://github.com/d-b-c-e/OutRun2006Tweaks-FFB + +Role: important public reference for DirectInput wheel-FFB architecture and force-model/signal-conditioning ideas. + +The repository retains the upstream MIT license notice. This fork does not redistribute the separate `WheelFfb.dll` toolkit from that project; the v0.1 FFB backend is implemented directly with Windows DirectInput COM. + +### VR GPL source reuse + +#### bo3b / 3Dmigoto + +Repository: https://github.com/bo3b/3Dmigoto + +License: GNU GPL version 3. + +Role: the `vr-openxr-gpl-reuse` branch adapts 3Dmigoto's DirectX 9 shader +fingerprinting approach and its FNV-1 64-bit byte-buffer hash routine. The +adapted implementation is isolated in +`src/vr/d3d9/shader_fingerprint_gpl.hpp` and is used by the R26 safe-draw +diagnostic path to identify vertex/pixel shaders by stable bytecode fingerprint +instead of process-local COM pointer values. + +The GPLv3 license text is included as `COPYING.GPL3`. Original MIT notices for +OutRun2006Tweaks remain preserved. + +### Build dependencies + +The produced DLL also incorporates open-source dependencies from the upstream build, including projects fetched or linked through CMake/submodules such as SDL, SafetyHook/Zydis, spdlog, Dear ImGui, xxHash, IXWebSocket, zlib, jsoncpp, Ogg/FLAC, miniz and related support libraries. + +Rather than manually maintaining a second copy of every dependency license in the release workflow, CI scans the exact checked-out/fetched dependency source trees used for the build and concatenates unique `LICENSE*`, `COPYING*` and `COPYRIGHT*` files into the release package as `LICENSES.txt`. + +This keeps the public ZIP small while retaining the notices supplied by the actual dependency revisions that were compiled. + +### Replacement game executable + +The release package includes `OR2006C2C.exe` downloaded from the public upstream OutRun2006Tweaks v0.1 release asset. The upstream release describes it as a replacement executable for Steam/DVD installations so DLL wrappers can work with the game. + +Source release: https://github.com/emoose/OutRun2006Tweaks/releases/tag/v0.1 + +Game code/assets and related trademarks remain the property of their respective rights holders; inclusion here does not change those rights. + +### Community testing + +Community discussions and user hardware logs were used as practical testing/design input for wheel behavior, device-interface quirks, force direction, reconnect handling and R3 tactile tuning. + +### License summary + +The upstream/base MIT-covered source remains under MIT with its notices preserved. The `vr-openxr-gpl-reuse` branch contains GPLv3-derived source and the combined branch distribution is GPL-3.0-only. Binary-release dependency notices are generated from the actual source/dependency trees and shipped as `LICENSES.txt`. + +This file is a project attribution/packaging record, not legal advice. + +--- + + +## 한국어 + +이 문서는 `wheel-ffb` 브랜치에서 사용하거나 참고한 주요 상위 프로젝트와 공개 프로젝트, 그리고 바이너리 배포 시 라이선스 고지를 처리하는 방식을 설명합니다. + +### 원본 기반 프로젝트 + +#### emoose / OutRun2006Tweaks + +저장소: https://github.com/emoose/OutRun2006Tweaks + +역할: 이 포크의 원본/기반 프로젝트입니다. DLL 래퍼, 게임 훅, 오버레이/설정 시스템 및 코드베이스의 상당 부분이 이 프로젝트에서 유래했습니다. + +라이선스: MIT. 원본의 `Copyright (c) 2023 emoose` 고지는 이 저장소의 `LICENSE.md`에 그대로 유지합니다. + +### 휠/입력 관련 참고 프로젝트 + +#### hyp36rmax / multi-device-input + +저장소/브랜치: https://github.com/hyp36rmax/multi-device-input/tree/multi-device-input + +역할: SDL3 Raw 멀티 디바이스 입력, 별도 USB 장치 처리, 현대식 레이싱 휠 호환 동작을 구현하는 데 중요한 공개 참고/적용 소스로 사용했습니다. + +해당 브랜치는 원본 MIT 라이선스 고지를 유지합니다. + +#### d-b-c-e / OutRun2006Tweaks-FFB + +저장소: https://github.com/d-b-c-e/OutRun2006Tweaks-FFB + +역할: DirectInput 휠 FFB 구조와 포스 모델/신호 처리 아이디어의 중요한 공개 참고 자료로 사용했습니다. + +해당 저장소는 원본 MIT 라이선스 고지를 유지합니다. 이 포크는 그 프로젝트의 별도 `WheelFfb.dll` 툴킷을 재배포하지 않으며, v0.1 FFB 백엔드는 Windows DirectInput COM을 직접 사용해 구현했습니다. + +### VR GPL 소스 재사용 + +#### bo3b / 3Dmigoto + +저장소: https://github.com/bo3b/3Dmigoto + +라이선스: GNU GPL version 3. + +역할: `vr-openxr-gpl-reuse` 브랜치는 3Dmigoto의 DirectX 9 셰이더 +fingerprint 방식과 FNV-1 64-bit 바이트 버퍼 해시 루틴을 적용했습니다. +적용 코드는 `src/vr/d3d9/shader_fingerprint_gpl.hpp`에 분리되어 있으며, +R26 safe-draw 진단 경로에서 실행마다 달라질 수 있는 COM 포인터 대신 +셰이더 바이트코드 기반의 안정적인 식별값을 기록하는 데 사용합니다. + +GPLv3 전문은 `COPYING.GPL3`에 포함되어 있으며, 기존 OutRun2006Tweaks의 +MIT 저작권/허가 고지는 그대로 유지합니다. + +### 빌드 의존성 + +생성되는 DLL에는 원본 빌드에서 사용하는 여러 오픈소스 의존성도 포함됩니다. CMake 또는 서브모듈로 가져오거나 링크되는 SDL, SafetyHook/Zydis, spdlog, Dear ImGui, xxHash, IXWebSocket, zlib, jsoncpp, Ogg/FLAC, miniz 및 관련 지원 라이브러리 등이 이에 해당합니다. + +릴리즈 워크플로에서 각 의존성 라이선스 파일을 수동으로 별도 관리하는 대신, CI가 실제 빌드에 사용한 체크아웃/다운로드된 의존성 소스 트리를 스캔합니다. 이후 중복을 제거한 `LICENSE*`, `COPYING*`, `COPYRIGHT*` 파일을 하나의 `LICENSES.txt`로 합쳐 배포 패키지에 넣습니다. + +이 방식은 실제 컴파일에 사용된 의존성 버전의 고지를 유지하면서 공개 ZIP이 불필요하게 복잡해지는 것을 줄입니다. + +### 교체용 게임 실행 파일 + +릴리즈 패키지에는 공개된 원본 OutRun2006Tweaks v0.1 릴리즈 자산에서 가져온 `OR2006C2C.exe`가 포함됩니다. 원본 릴리즈에서는 Steam/DVD 설치본에서 DLL 래퍼가 동작하도록 하기 위한 교체용 실행 파일로 설명하고 있습니다. + +원본 릴리즈: https://github.com/emoose/OutRun2006Tweaks/releases/tag/v0.1 + +게임 코드/자산 및 관련 상표의 권리는 각각의 권리자에게 있으며, 이 패키지에 포함한다고 해서 해당 권리가 변경되는 것은 아닙니다. + +### 커뮤니티 테스트 + +커뮤니티 토론과 사용자 하드웨어 로그를 통해 휠 동작, 장치 인터페이스 특성, 힘의 방향, 재연결 처리, R3 촉각 튜닝에 대한 실사용 테스트/설계 정보를 참고했습니다. + +### 라이선스 요약 + +원본 및 기존 MIT 코드의 MIT 권리와 고지는 그대로 유지합니다. 다만 `vr-openxr-gpl-reuse` 브랜치는 GPLv3 유래 소스를 포함하므로 결합된 브랜치 배포물은 GPL-3.0-only 조건으로 배포합니다. 바이너리 배포에 필요한 의존성 고지는 실제 소스/의존성 트리에서 생성해 `LICENSES.txt`로 함께 배포합니다. + +이 문서는 프로젝트의 출처 표시 및 배포 패키징 기록이며 법률 자문이 아닙니다. diff --git a/VR_OPENXR.md b/VR_OPENXR.md new file mode 100644 index 000000000..6d8c72ec5 --- /dev/null +++ b/VR_OPENXR.md @@ -0,0 +1,153 @@ +# OutRun 2006 OpenXR VR + +`vr-openxr` is the experimental OpenXR branch for OutRun 2006: Coast 2 Coast. It is layered on the wheel/multi-device/native-FFB fork and must not replay or alter those game-tick paths. + +For design decisions and source ownership, read `docs/VR_ARCHITECTURE.md`. For external reference analysis, read `docs/VR_REFERENCE_HARVEST.md`. + +## Status + +The branch contains a true-geometry stereo prototype with: + +- renderer-side head tracking at the verified OutRun VS c64..c67 WorldViewProjection upload; +- one pose latched for a complete presented game frame; +- per-eye asymmetric OpenXR FOV and eye transforms; +- duplicated D3D9 draw submission without replaying simulation/input/timers/FFB; +- zero-disparity duplication for non-world/UI draws; +- a conditional D3D9Ex -> D3D11 shared-eye transport with adapter/probe validation and a four-slot producer queue; +- an SBS/Desktop Duplication compatibility fallback; +- an x64 D3D11 OpenXR host using `XR_KHR_D3D11_enable`; +- gameplay projection layers and a LOCAL-space theater quad for non-gameplay screens; +- render-frame/pose matching and fail-closed stereo validation. + +Windows CI validates compilation and structural invariants. A Quest 3 / VDXR run is still required to validate optics, eye order, culling, frame pacing and actual transport selection. + +## Source layout + +```text +src/vr/settings.cpp +src/vr/game/outrun_renderer.cpp +src/vr/d3d9/stereo_renderer.cpp +src/vr/ipc/protocol.hpp +src/vr_shared.hpp compatibility include only + +vrhost/src/main.cpp +vrhost/src/stereo_shader.hpp +vrhost/tests/stereo_shader_smoke.cpp +``` + +The current D3D9 backend and x64 host are still large translation units. They were first moved intact so the architecture could be cleaned without changing behavior. They will be decomposed behind the boundaries in `docs/VR_ARCHITECTURE.md`. + +## Renderer invariants + +The game-specific renderer adapter verifies the live OutRun transform before injecting VR: + +```text +0x0095DB20 WorldView = World * View +0x0095D860 View +0x0095D8A0 Projection +VS c64..c67 = Transpose(WorldView * Projection) +``` + +The VR path does not call game simulation, event processing, input processing or native FFB for the second eye. The invariant is: + +```text +simulation / input / timers / FFB = once +left/right GPU draw submission = twice where classified safe +``` + +Unsafe or unclassified stereo work fails closed rather than replaying the whole renderer. + +## Transport selection + +The renderer is intentionally independent of transport choice. + +### Direct shared transport + +Direct transport is eligible only when all of the following pass at runtime: + +1. OpenXR reports the D3D11 adapter LUID required for the session. +2. The game device proves it is compatible with D3D9Ex interop. +3. D3D9Ex and OpenXR/D3D11 adapter LUIDs match. +4. A shared verification texture is opened and read correctly by D3D11. +5. The host acknowledges that exact probe generation. +6. The four-slot eye-resource queue is created successfully. + +If the stock OutRun device is classic D3D9 rather than D3D9Ex, this path is expected to reject itself. D3D9-to-9Ex conversion is treated as a separate compatibility experiment, not as an assumption inside the renderer. + +### Desktop Duplication fallback + +The fallback composes the two already-rendered eyes into the game Present and captures them on the x64 host. Frame metadata is checked before and after capture to avoid pairing a new pose with an older desktop image. + +### DXVK experiment + +`tools/vr_dxvk_poc.ps1` remains isolated. DXVK is not promoted to the primary renderer unless Quest/VDXR measurements show a clear advantage and compatibility is acceptable. + +## Build + +### Win32 game DLL + +```powershell +cmake -S . -B build -G "Visual Studio 17 2022" -A Win32 +cmake --build build --config Release +``` + +### x64 OpenXR host + +```powershell +cmake -S vrhost -B build-vrhost -G "Visual Studio 17 2022" -A x64 +cmake --build build-vrhost --config Release +``` + +The host currently pins Khronos OpenXR-SDK `release-1.1.63`. CI also builds and runs the exact compositor HLSL smoke test. + +## First Quest 3 / VDXR acceptance run + +Start with positional tracking disabled and union-FOV culling disabled. The first run should answer transport and correctness questions before comfort tuning. + +1. Confirm VDXR is the active OpenXR runtime and the x64 host creates a session. +2. Enter gameplay and confirm yaw/pitch/roll move the world in the correct direction. +3. Press F10 while facing forward and confirm yaw recenter works. +4. Verify true depth: near traffic/cockpit elements must have more disparity than the horizon. +5. Verify left/right are not swapped. +6. Verify HUD/text is stable and zero-disparity. +7. Pause/menu and confirm theater presentation rather than invalid gameplay stereo. +8. Turn the head roughly +/-90 degrees and note missing buildings, traffic, roadside objects, billboards or flares. +9. Verify wheel, pedals, multi-device mappings and native FFB are unchanged. +10. Stop/resume the OpenXR session and verify no stale stereo frame flashes. +11. Record whether direct transport was accepted or the Desktop Duplication fallback was selected. +12. Record host timing p95/p99 values and subjective latency. + +## Useful log milestones + +Game side: + +```text +VR: renderer-boundary head tracking + true stereo configured +VR renderer inject: verified OutRun c64 = Transpose(WorldView*Proj) +VR renderer inject: HEAD TRACKING ACTIVE +VR stereo: TRUE GEOMETRY STEREO active +``` + +Direct transport, when available, should additionally report successful interop verification and direct ring use. If D3D9Ex/LUID/probe validation fails, fallback selection is expected and should be logged as such. + +Host side: + +```text +OpenXR runtime: ... +VR presentation: true stereo projection. +VR host timing ms p95/p99: ... +``` + +## What CI does not prove + +CI does not prove: + +- the stock game device is D3D9Ex-capable; +- Quest eye order or optical comfort; +- pre-BeginScene culling correctness; +- stage-specific offscreen world passes; +- Desktop Duplication latency; +- VDXR reprojection behavior; +- subjective FFB feel. + +Those require the real game, Quest 3 and VDXR. diff --git a/WHEEL_FFB.md b/WHEEL_FFB.md new file mode 100644 index 000000000..d689677a3 --- /dev/null +++ b/WHEEL_FFB.md @@ -0,0 +1,229 @@ +# Wheel / FFB architecture — v0.1 + +[English](#english) | [한국어](#한국어) + + +## English + +This document describes the release architecture of the `wheel-ffb` branch used for **OutRun2006Tweaks Wheel FFB v0.1**. + +### Scope + +v0.1 was developed primarily around a **MOZA R3** and the tested default path is: + +- input: SDL3 raw multi-device input; +- force feedback: Windows DirectInput COM; +- build: Win32 x86; +- game update rate: OutRun's native 60 Hz gameplay loop. + +Other DirectInput wheels may work, but the R3 is the main hardware validation target for this release. + +### Input ownership + +With `UseNewInput=true`, **Input Bindings is the only input-binding owner** for steering, pedals, buttons and menu controls. Wheel, separate pedals, shifter, button box and gamepad can all contribute to one player at the same time. + +The older legacy DirectInput path remains available only as a compatibility fallback. The v0.1 release documentation no longer directs users through the old guided Quick Setup flow. + +Bindings are live while editing, but manual changes should be persisted with **Save & Return to game**. Named input profiles remain available for complete multi-device layouts. + +### FFB ownership + +The wheel FFB backend is a single DirectInput COM owner. It pins output to the selected DirectInput device identity instead of whichever similarly named interface is enumerated first. + +The main steering model contains: + +1. **Physics SAT** — estimates front slip from vehicle motion, steering and yaw, then derives aligning torque from lateral force and total trail. +2. **Natural SAT fallback** — progressive steering-angle-based restoring torque when the physics sample is unavailable or Physics SAT is disabled. +3. **Mechanical / caster trail** — remains active with front lateral load instead of acting as an artificial center spring. +4. **Low-speed spring** — only a stabilizer near low speed; deliberately reduced on the tested R3 feel. +5. **Dynamic damping** — steering-velocity resistance that releases as the front end scrubs or the car slides. +6. **Grip-loss unloading** — reduces steering load as usable front grip falls away. +7. **Road / tire / gear / collision effects** — transient tactile effects layered on top of the structural steering signal. + +### Force Feedback UI + +The F11 **Force Feedback** page owns output-device selection and game-side tuning. `Load MOZA R3 Physics SAT` and `Load MOZA R3 Natural SAT` provide starting profiles, while **Save Force Feedback** persists live edits. + +The **Advanced FFB tuning** section exposes supported lower-level values such as Spring Saturation, Weight Transfer, Force Build Slew Rate, Countersteer Release Rate, Pneumatic Trail Response Lead and optional wheel-response correction. These remain live tuning controls; wheel-specific response data is stored with the wheel profile rather than a generic named feel profile. + +### MOZA R3 compatibility path + +The R3 driver can accept creation/update of a DirectInput sine effect while the physical road texture remains effectively inaudible. v0.1 therefore forces road/slip vibration through the **ConstantForce fallback** on the R3 path. + +Road contact is sampled across all four wheels. The compatibility layer distinguishes: + +- **mixed surface** — meaningful roughness spread while only part of the car is on a curb/shoulder; +- **fully rough surface** — all sampled wheels are on a high-roughness surface; +- **ordinary surface** — no extra tactile override. + +Mixed and fully rough curb states use the same strong tactile profile so vibration does not disappear when the remaining wheels cross fully onto the curb. During tactile contact the R3 path temporarily reduces structural SAT/damping enough for the road ripple to remain perceptible under corner load, then immediately restores the user's normal settings. + +Snow stages have a much lower core road-texture scale. The R3 compatibility wrapper compensates that attenuation only when a real tactile surface is detected, so the normal snow road itself does not become a constant buzz. + +Known limitation: on snow stages, curb vibration can still fade when all four wheels are fully on the same rough curb/shoulder surface. Partial curb contact works correctly and steering/SAT is unaffected. See [Issue #1](https://github.com/thp32tt/OutRun2006Tweaks/issues/1). + +### Device selection and safety + +- Clean R3 setups auto-match the DirectInput product substring `R3 Racing Wheel`. +- Once a device is selected, the exact DirectInput GUID is persisted. +- Focus loss, state transitions and device loss clear active effects. +- Device reconnect schedules normal DirectInput reinitialization. +- Manual left/right direction tests are hard-capped at 20% and only run during active gameplay. +- A wheel-output owner suppresses duplicate gamepad rumble ownership where required. + +### R3 release feel + +The release retune intentionally moved away from a heavy artificial spring: + +- low-speed spring reduced; +- spring saturation reduced; +- SAT remains the main cornering load; +- road texture is strengthened for the ConstantForce fallback; +- tire-slip buzz is kept low; +- gear-change feedback is made clearly perceptible; +- snow/curb tactile handling is R3-specific and does not globally weaken ordinary cornering. + +These are starting values rather than a guarantee for every firmware/Pit House configuration. + +### Diagnostics + +`OutRun2006Tweaks.log` records: + +- selected DirectInput FFB identity; +- effect creation/fallback status; +- SAT and final output diagnostics; +- R3 road-contact diagnostics including min/max roughness, spread, mixed/full-rough state and temporary tactile scaling. + +For a useful hardware report, include the full launch → race → exit log plus wheel-base model, driver and firmware version. + +### Build and release validation + +CI runs: + +- `tools/verify_wheel_ffb_current.py` against the consolidated production source; +- the production wheel FFB math test; +- Win32 Release compilation; +- PE32 payload checks and compiled marker verification; +- final artifact upload from the exact release commit. + +The v0.1 release workflow waits for the matching successful Build workflow before publishing the ZIP. + +### Credits / license + +The implementation is based on `emoose/OutRun2006Tweaks` and incorporates design lessons from public OutRun wheel work including `hyp36rmax/multi-device-input` and `d-b-c-e/OutRun2006Tweaks-FFB`. + +Repository license details are in `LICENSE.md` and `THIRD_PARTY_NOTICES.md`. The public binary ZIP receives one consolidated `LICENSES.txt` generated from the dependency source trees used for that build. + +--- + + +## 한국어 + +이 문서는 **OutRun2006Tweaks Wheel FFB v0.1**에 사용된 `wheel-ffb` 브랜치의 릴리즈 구조를 설명합니다. + +### 범위 + +v0.1은 주로 **MOZA R3**를 기준으로 개발했으며 기본 검증 경로는 다음과 같습니다. + +- 입력: SDL3 Raw 멀티 디바이스 입력 +- 포스피드백: Windows DirectInput COM +- 빌드: Win32 x86 +- 게임 업데이트 주기: OutRun 기본 60 Hz 게임플레이 루프 + +다른 DirectInput 휠도 동작할 수 있지만, 이 릴리즈의 주 하드웨어 검증 대상은 R3입니다. + +### 입력 처리 구조 + +`UseNewInput=true`일 때 **Input Bindings가 스티어링, 페달, 버튼, 메뉴 조작에 대한 유일한 바인딩 경로**입니다. 휠, 별도 페달, 시프터, 버튼박스, 게임패드가 동시에 한 플레이어의 입력에 참여할 수 있습니다. + +기존 레거시 DirectInput 경로는 호환성 폴백 용도로만 남아 있습니다. v0.1 문서에서는 예전 Guided Quick Setup 흐름을 기본 설정 방법으로 안내하지 않습니다. + +바인딩은 편집 중 실시간 적용되지만, 수동 변경 사항은 **Save & Return to game**으로 저장해야 합니다. 완전한 멀티 디바이스 구성을 위한 이름 지정 입력 프로필도 사용할 수 있습니다. + +### FFB 처리 구조 + +휠 FFB 백엔드는 하나의 DirectInput COM 출력 소유자로 동작합니다. 비슷한 이름의 인터페이스 중 먼저 열거된 장치가 아니라 사용자가 선택한 정확한 DirectInput 장치 식별자에 출력을 고정합니다. + +주 조향 모델은 다음 요소로 구성됩니다. + +1. **Physics SAT** — 차량 움직임, 조향각, yaw에서 전륜 슬립을 추정하고 횡력과 총 trail을 이용해 self-aligning torque를 계산합니다. +2. **Natural SAT fallback** — 물리 샘플을 사용할 수 없거나 Physics SAT를 끈 경우 조향각에 비례해 점진적으로 중앙 복원 토크를 생성합니다. +3. **Mechanical / caster trail** — 인위적인 센터 스프링 대신 전륜 횡하중과 함께 유지되는 기계적/캐스터 트레일 성분입니다. +4. **Low-speed spring** — 저속에서만 안정화용으로 사용하며 테스트한 R3에서는 의도적으로 약하게 설정합니다. +5. **Dynamic damping** — 조향 속도에 대한 저항이며 전륜이 밀리거나 차량이 슬라이드할 때 완화됩니다. +6. **Grip-loss unloading** — 사용 가능한 전륜 그립이 줄어들수록 조향 하중을 줄입니다. +7. **Road / tire / gear / collision effects** — 기본 조향 신호 위에 일시적인 촉각 효과를 추가합니다. + +### Force Feedback UI + +F11의 **Force Feedback** 화면에서 출력 장치 선택과 게임 내 FFB 튜닝을 담당합니다. `Load MOZA R3 Physics SAT`와 `Load MOZA R3 Natural SAT`는 시작용 프로필이며, **Save Force Feedback**으로 실시간 변경 값을 저장합니다. + +**Advanced FFB tuning**에는 Spring Saturation, Weight Transfer, Force Build Slew Rate, Countersteer Release Rate, Pneumatic Trail Response Lead, 선택적 휠 응답 보정 등 하위 수준 조정 값이 노출됩니다. 이 값들은 실시간 튜닝 항목이며, 휠별 응답 데이터는 일반적인 feel 프리셋이 아니라 해당 휠 프로필에 저장됩니다. + +### MOZA R3 호환 경로 + +R3 드라이버는 DirectInput sine effect의 생성/갱신 자체는 정상 처리하더라도 테스트한 장비에서 실제 노면 질감이 거의 느껴지지 않았습니다. 그래서 v0.1의 R3 경로에서는 노면/슬립 진동을 **ConstantForce 폴백**으로 강제합니다. + +노면 접촉은 네 바퀴 전체를 샘플링합니다. 호환 레이어는 다음 상태를 구분합니다. + +- **mixed surface** — 차량 일부만 연석/숄더에 올라가 표면 거칠기 차이가 의미 있게 발생하는 상태 +- **fully rough surface** — 샘플링된 모든 바퀴가 높은 거칠기의 표면에 올라간 상태 +- **ordinary surface** — 별도 촉각 보정이 필요하지 않은 일반 표면 + +mixed와 fully rough 연석 상태에는 같은 강한 촉각 프로필을 사용해 남은 바퀴가 모두 연석으로 넘어가는 순간 진동이 끊기지 않도록 설계했습니다. 촉각 접촉 중에는 코너링 하중에 노면 진동이 묻히지 않도록 R3 경로가 구조적인 SAT/댐핑을 일시적으로 줄이고, 표면 접촉이 끝나면 사용자의 일반 설정으로 즉시 복귀합니다. + +눈 맵은 기본 노면 텍스처 스케일이 훨씬 낮습니다. R3 호환 래퍼는 실제 촉각 표면이 감지된 경우에만 이 감쇠를 보정해 일반 눈길 자체가 계속 떨리지 않도록 합니다. + +현재 알려진 제한 사항으로, 눈 맵에서 네 바퀴가 모두 동일한 거친 연석/숄더 표면에 완전히 올라가면 연석 진동이 약해지거나 사라질 수 있습니다. 부분 연석 접촉은 정상이며 조향력/SAT에는 영향이 없습니다. 자세한 내용은 [Issue #1](https://github.com/thp32tt/OutRun2006Tweaks/issues/1)을 참고하세요. + +### 장치 선택 및 안전 처리 + +- 초기 R3 환경에서는 DirectInput 제품명 `R3 Racing Wheel` 문자열을 자동 매칭합니다. +- 장치를 한 번 선택하면 정확한 DirectInput GUID를 저장합니다. +- 포커스 손실, 게임 상태 전환, 장치 손실 시 활성 FFB 효과를 정리합니다. +- 장치 재연결 시 정상적인 DirectInput 재초기화를 예약합니다. +- 수동 좌/우 방향 테스트는 최대 20%로 제한되며 실제 게임플레이 중에만 동작합니다. +- 필요한 경우 휠 출력 소유자가 중복 게임패드 럼블 출력을 억제합니다. + +### R3 릴리즈 기본 감각 + +릴리즈 튜닝은 강한 인공 스프링 중심의 느낌에서 의도적으로 벗어났습니다. + +- 저속 스프링 감소 +- 스프링 포화도 감소 +- 코너링 하중의 중심은 SAT가 담당 +- ConstantForce 폴백에서 노면 텍스처 강화 +- 타이어 슬립 버즈는 낮게 유지 +- 기어 변속 피드백은 명확하게 체감되도록 설정 +- 눈/연석 촉각 처리는 R3 전용이며 일반 코너링 하중을 전역적으로 약하게 만들지 않음 + +이 값들은 시작점이며 모든 펌웨어/Pit House 조합에서 동일한 느낌을 보장하지 않습니다. + +### 진단 로그 + +`OutRun2006Tweaks.log`에는 다음 정보가 기록됩니다. + +- 선택된 DirectInput FFB 장치 식별 정보 +- 효과 생성 및 폴백 상태 +- SAT 및 최종 출력 진단 +- 최소/최대 거칠기, spread, mixed/full-rough 상태, 임시 촉각 스케일링을 포함한 R3 노면 접촉 진단 + +하드웨어 문제를 보고할 때는 게임 실행 → 레이스 → 종료까지의 전체 로그와 휠베이스 모델, 드라이버, 펌웨어 버전을 함께 첨부하는 것이 좋습니다. + +### 빌드 및 릴리즈 검증 + +CI는 다음 항목을 실행합니다. + +- 통합된 실제 소스를 대상으로 `tools/verify_wheel_ffb_current.py` 검증 +- 실제 wheel FFB 수학 테스트 +- Win32 Release 컴파일 +- PE32 페이로드 및 컴파일된 마커 검증 +- 정확한 릴리즈 커밋에서 최종 아티팩트 업로드 + +v0.1 릴리즈 워크플로는 동일 커밋의 Build 워크플로가 성공한 것을 확인한 뒤 ZIP을 게시합니다. + +### 크레딧 / 라이선스 + +구현은 `emoose/OutRun2006Tweaks`를 기반으로 하며, `hyp36rmax/multi-device-input`과 `d-b-c-e/OutRun2006Tweaks-FFB` 등 공개 OutRun 휠 관련 작업의 설계 경험도 참고했습니다. + +저장소 라이선스 세부 내용은 `LICENSE.md`와 `THIRD_PARTY_NOTICES.md`에 있습니다. 공개 바이너리 ZIP에는 해당 빌드에 사용된 의존성 소스 트리에서 생성한 하나의 통합 `LICENSES.txt`가 포함됩니다. diff --git a/cmake.toml b/cmake.toml index 0eab128c0..e1de2d503 100644 --- a/cmake.toml +++ b/cmake.toml @@ -5,6 +5,214 @@ [project] name = "outrun2006tweaks-proj" cmake-after = """ +set_source_files_properties(src/hooks_wheel_ffb.cpp PROPERTIES HEADER_FILE_ONLY TRUE) +# R33/R14 keep the validated R32/R31/R30/R29/R26/R23/R22/R21/R20/R13/R9/R12 +# implementation chains intact and compile only the final game/stereo/Ex wrapper +# TUs. Included implementation files are header-only here so no hook body is +# linked twice. +set(OUTRUN_VR_INCLUDED_IMPL_TUS + src/vr/d3d9/ex_device_upgrade.cpp + src/vr/d3d9/ex_device_upgrade_r13.cpp + src/vr/d3d9/stereo_renderer.cpp + src/vr/d3d9/stereo_renderer_r13.cpp + src/vr/d3d9/stereo_renderer_r20.cpp + src/vr/d3d9/stereo_renderer_r21.cpp + src/vr/d3d9/stereo_renderer_r22.cpp + src/vr/d3d9/stereo_renderer_r23.cpp + src/vr/d3d9/stereo_renderer_r26.cpp + src/vr/d3d9/stereo_renderer_r29.cpp + src/vr/d3d9/stereo_renderer_r30.cpp + src/vr/d3d9/stereo_renderer_r31.cpp + src/vr/d3d9/stereo_renderer_r32.cpp + src/vr/game/outrun_renderer.cpp + src/vr/game/outrun_renderer_r13.cpp + src/vr/game/outrun_renderer_r23.cpp) +set_source_files_properties(${OUTRUN_VR_INCLUDED_IMPL_TUS} + PROPERTIES HEADER_FILE_ONLY TRUE) + +# Keep cmake.toml as the single source of truth for the active wrapper graph. +# A regenerated CMakeLists must compile exactly these final TUs while every +# implementation TU above remains include-only. Fail configure immediately if +# a later overlay accidentally flips either side of that contract. +set(OUTRUN_VR_FINAL_TUS + src/vr/d3d9/ex_device_upgrade_r14.cpp + src/vr/d3d9/stereo_renderer_r33.cpp + src/vr/game/outrun_renderer_r29.cpp) +foreach(_vr_source IN LISTS OUTRUN_VR_INCLUDED_IMPL_TUS OUTRUN_VR_FINAL_TUS) + if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${_vr_source}") + message(FATAL_ERROR "VR source graph references missing TU: ${_vr_source}") + endif() +endforeach() +foreach(_vr_source IN LISTS OUTRUN_VR_INCLUDED_IMPL_TUS) + get_source_file_property(_vr_header_only "${_vr_source}" HEADER_FILE_ONLY) + if(NOT _vr_header_only) + message(FATAL_ERROR "VR included implementation TU must be HEADER_FILE_ONLY: ${_vr_source}") + endif() +endforeach() +foreach(_vr_source IN LISTS OUTRUN_VR_FINAL_TUS) + get_source_file_property(_vr_header_only "${_vr_source}" HEADER_FILE_ONLY) + if(_vr_header_only) + message(FATAL_ERROR "VR final wrapper TU must compile normally: ${_vr_source}") + endif() +endforeach() +unset(_vr_source) +unset(_vr_header_only) + +# R15/R34 are post-review correctness overlays. They include the complete R14 +# and R33 chains respectively, so the former final wrappers become include-only +# only after the legacy graph guards above have verified their expected state. +# The target source glob automatically compiles R15/R34 as the authoritative +# final Ex/stereo TUs. +set(OUTRUN_VR_POST_REVIEW_FINAL_TUS + src/vr/d3d9/ex_device_upgrade_r15.cpp + src/vr/d3d9/stereo_renderer_r34.cpp) +foreach(_vr_source IN LISTS OUTRUN_VR_POST_REVIEW_FINAL_TUS) + if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${_vr_source}") + message(FATAL_ERROR "VR post-review final TU missing: ${_vr_source}") + endif() +endforeach() +set_source_files_properties( + src/vr/d3d9/ex_device_upgrade_r14.cpp + src/vr/d3d9/stereo_renderer_r33.cpp + PROPERTIES HEADER_FILE_ONLY TRUE) +unset(_vr_source) + +# Production-safe stereo draw owner. The user-visible regression reproduced on +# Quest while the monitor SBS remained individually plausible is tied to the +# post-e20 R29+ fast L+R/HUD dispatch chain. Default back to the last proven +# R26/R23/R22/R13/R9 draw + R23 renderer boundary. R29-R34 comparison builds +# remain available only when this option is explicitly disabled. +option(OUTRUN_VR_SAFE_DRAW_COMPARE "Use proven R26/R23 safe stereo draw production path" ON) +set_source_files_properties( + src/vr/d3d9/stereo_renderer_r26_compare.cpp + src/vr/d3d9/stereo_renderer_r30_r26_safe.cpp + PROPERTIES HEADER_FILE_ONLY TRUE) +option(OUTRUN_VR_R26_HUD_COMPARE "Build R26-safe world path with R30 HUD/XYZRHW/SkyGlow overlay" OFF) +if(OUTRUN_VR_SAFE_DRAW_COMPARE) + set_source_files_properties( + src/vr/d3d9/stereo_renderer_r34.cpp + PROPERTIES HEADER_FILE_ONLY TRUE) + set_source_files_properties( + src/vr/d3d9/stereo_renderer_r26_compare.cpp + PROPERTIES HEADER_FILE_ONLY FALSE) + # Renderer R29 depends on R31 StateBlock exports. For this regression + # comparison, use the R23 renderer owner that paired with the known-good + # R26 stereo path instead of introducing fake R31 stubs. + set_source_files_properties( + src/vr/game/outrun_renderer_r29.cpp + PROPERTIES HEADER_FILE_ONLY TRUE) + set_source_files_properties( + src/vr/game/outrun_renderer_r23.cpp + PROPERTIES HEADER_FILE_ONLY FALSE) + add_compile_definitions(OUTRUN_VR_SAFE_DRAW_COMPARE=1) + message(STATUS "OutRun VR safe-draw comparison: R26 final stereo chain enabled") +endif() + +# C1/C2 regression-isolation builds. These keep current R15/transport/texture +# work and renderer R29 installation, but compile a shorter stereo owner chain. +# C1 = R29 fast L+R + restored R23/R27/R28 perspective-world classification. +# C2 = C1 + R30 asymmetric-FOV HUD correction. R31-R34 stay excluded in both. +option(OUTRUN_VR_C1_COMPARE "Build C1: R29 fast L+R with R27/R28 classification" OFF) +option(OUTRUN_VR_C2_COMPARE "Build C2: C1 plus R30 HUD correction" OFF) +set_source_files_properties( + src/vr/d3d9/stereo_renderer_r29_c1_compare.cpp + src/vr/d3d9/stereo_renderer_r30_c2_compare.cpp + PROPERTIES HEADER_FILE_ONLY TRUE) + +set(OUTRUN_VR_COMPARE_COUNT 0) +foreach(_vr_option + OUTRUN_VR_SAFE_DRAW_COMPARE + OUTRUN_VR_R26_HUD_COMPARE + OUTRUN_VR_C1_COMPARE + OUTRUN_VR_C2_COMPARE) + if(${_vr_option}) + math(EXPR OUTRUN_VR_COMPARE_COUNT + "${OUTRUN_VR_COMPARE_COUNT} + 1") + endif() +endforeach() +if(OUTRUN_VR_COMPARE_COUNT GREATER 1) + message(FATAL_ERROR "Choose only one VR comparison build option") +endif() +unset(_vr_option) + +if(OUTRUN_VR_R26_HUD_COMPARE) + set_source_files_properties( + src/vr/d3d9/stereo_renderer_r34.cpp + src/vr/d3d9/stereo_renderer_r26_compare.cpp + PROPERTIES HEADER_FILE_ONLY TRUE) + set_source_files_properties( + src/vr/d3d9/stereo_renderer_r30_r26_safe.cpp + PROPERTIES HEADER_FILE_ONLY FALSE) + set_source_files_properties( + src/vr/game/outrun_renderer_r29.cpp + PROPERTIES HEADER_FILE_ONLY TRUE) + set_source_files_properties( + src/vr/game/outrun_renderer_r23.cpp + PROPERTIES HEADER_FILE_ONLY FALSE) + add_compile_definitions(OUTRUN_VR_R26_HUD_COMPARE=1) + message(STATUS "OutRun VR R26+HUD comparison: safe R26 world + R30 HUD/SkyGlow") +endif() + +if(OUTRUN_VR_C1_COMPARE OR OUTRUN_VR_C2_COMPARE) + set_source_files_properties( + src/vr/d3d9/stereo_renderer_r34.cpp + PROPERTIES HEADER_FILE_ONLY TRUE) + add_compile_definitions(OUTRUN_VR_R29_R28_CLASSIFICATION_COMPARE=1) +endif() + +if(OUTRUN_VR_C1_COMPARE) + set_source_files_properties( + src/vr/d3d9/stereo_renderer_r29_c1_compare.cpp + PROPERTIES HEADER_FILE_ONLY FALSE) + add_compile_definitions(OUTRUN_VR_C1_COMPARE=1) + message(STATUS "OutRun VR C1 comparison: R29 fast L+R + R27/R28 classification") +elseif(OUTRUN_VR_C2_COMPARE) + set_source_files_properties( + src/vr/d3d9/stereo_renderer_r30_c2_compare.cpp + PROPERTIES HEADER_FILE_ONLY FALSE) + add_compile_definitions(OUTRUN_VR_C2_COMPARE=1) + message(STATUS "OutRun VR C2 comparison: C1 + R30 HUD correction") +endif() + +# Re-evaluate ownership after every comparison option has mutated +# HEADER_FILE_ONLY. The configured graph must compile exactly one stereo owner +# and exactly one renderer owner. +set(_vr_stereo_owner_candidates + src/vr/d3d9/stereo_renderer_r34.cpp + src/vr/d3d9/stereo_renderer_r26_compare.cpp + src/vr/d3d9/stereo_renderer_r30_r26_safe.cpp + src/vr/d3d9/stereo_renderer_r29_c1_compare.cpp + src/vr/d3d9/stereo_renderer_r30_c2_compare.cpp) +set(_vr_renderer_owner_candidates + src/vr/game/outrun_renderer_r29.cpp + src/vr/game/outrun_renderer_r23.cpp) +set(_vr_stereo_owner_count 0) +set(_vr_renderer_owner_count 0) +foreach(_vr_source IN LISTS _vr_stereo_owner_candidates) + get_source_file_property(_vr_header_only "${_vr_source}" HEADER_FILE_ONLY) + if(NOT _vr_header_only) + math(EXPR _vr_stereo_owner_count + "${_vr_stereo_owner_count} + 1") + endif() +endforeach() +foreach(_vr_source IN LISTS _vr_renderer_owner_candidates) + get_source_file_property(_vr_header_only "${_vr_source}" HEADER_FILE_ONLY) + if(NOT _vr_header_only) + math(EXPR _vr_renderer_owner_count + "${_vr_renderer_owner_count} + 1") + endif() +endforeach() +if(NOT _vr_stereo_owner_count EQUAL 1) + message(FATAL_ERROR + "VR final graph must have exactly one stereo owner; got ${_vr_stereo_owner_count}") +endif() +if(NOT _vr_renderer_owner_count EQUAL 1) + message(FATAL_ERROR + "VR final graph must have exactly one renderer owner; got ${_vr_renderer_owner_count}") +endif() +unset(_vr_source) +unset(_vr_header_only) + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} /MP") set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /MP") @@ -52,9 +260,6 @@ type = "static" sources = ["external/spdlog/src/*.cpp"] include-directories = ["external/spdlog/include"] compile-definitions = ["SPDLOG_COMPILED_LIB", "_DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR"] -# /utf-8: newer fmt (bundled in spdlog) #errors out on MSVC unless the source -# and execution charsets are both UTF-8. It is a header-level check, so every -# TU that includes spdlog needs this - see outrun2006tweaks below. compile-options = ["/utf-8"] [fetch-content] @@ -85,7 +290,7 @@ sources = ["*.ini", "src/**.cpp", "src/**.c", "src/**.def", "src/Resource.rc", "external/imgui/imgui_draw.cpp", "external/imgui/imgui_tables.cpp", "external/imgui/imgui_widgets.cpp", - "external/IXWebSocket/ixwebsocket/**" + "external/IXWebSocket/ixwebsocket/**" ] headers = ["src/**.hpp", "src/**.h", "external/ModUtils/Patterns.h", "external/ModUtils/MemoryMgr.h", "external/xxHash/xxhash.h", "external/miniz/miniz.h", "external/ini-cpp/ini/ini.h"] include-directories = ["shared/", "src/", "include/", @@ -94,9 +299,9 @@ include-directories = ["shared/", "src/", "include/", "external/xxHash/", "external/miniz/", "external/imgui/", - "external/IXWebSocket/" + "external/IXWebSocket/" ] -compile-options = ["/GS-", "/bigobj", "/EHa", "/MP", "/utf-8"] +compile-options = ["/GS", "/bigobj", "/EHa", "/MP", "/utf-8"] link-options = ["/DEBUG", "/OPT:REF", "/OPT:ICF"] compile-features = ["cxx_std_20"] compile-definitions = ["_SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING", "_DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR", "DIRECTINPUT_VERSION=0x0800"] @@ -113,7 +318,8 @@ link-libraries = [ "SDL3-static", "Winmm.lib", "Setupapi.lib", - "Crypt32.lib" + "Crypt32.lib", + "d3dcompiler.lib" ] [target.outrun2006tweaks.properties] @@ -125,4 +331,3 @@ LIBRARY_OUTPUT_DIRECTORY_RELEASE = "${CMAKE_BINARY_DIR}/lib/${CMKR_TARGET}" LIBRARY_OUTPUT_DIRECTORY_RELWITHDEBINFO = "${CMAKE_BINARY_DIR}/lib/${CMKR_TARGET}" ARCHIVE_OUTPUT_DIRECTORY_RELEASE = "${CMAKE_BINARY_DIR}/lib/${CMKR_TARGET}" ARCHIVE_OUTPUT_DIRECTORY_RELWITHDEBINFO = "${CMAKE_BINARY_DIR}/lib/${CMKR_TARGET}" - diff --git a/docs/FFB_HARDWARE_COMPAT_TEST.md b/docs/FFB_HARDWARE_COMPAT_TEST.md new file mode 100644 index 000000000..64f73cd0c --- /dev/null +++ b/docs/FFB_HARDWARE_COMPAT_TEST.md @@ -0,0 +1,34 @@ +# FFB hardware compatibility retest + +This checklist is for the `wheel-ffb-v0.3-compat` branch. The steering feel is the established Modern / Physics SAT model from `wheel-ffb-v0.2-dev`; X-Force is not part of this compatibility branch. + +## What changed + +- FFB profile persistence now keeps the staged-write/backup path, but falls back from `rename` to `copy_file` on Windows when the final rename is rejected by a filesystem/filter/driver edge case. +- First-time profile creation treats a missing destination `.ini` and missing `.bak` as the normal new-profile state instead of a filesystem failure. This specifically fixes the logged `Could not inspect the existing profile: The system cannot find the file specified` failure. +- The final profile file is verified after installation. +- Profile save success and failure messages include the real runtime destination path. Failures are also written to `OutRun2006Tweaks.log`. +- The Ready to Drive panel distinguishes a real failure `[!]` from a pending state `[..]`. +- Outside gameplay, `FFB device (starts in gameplay)` is pending rather than failed because the runtime intentionally acquires the saved DirectInput FFB GUID when driving starts. +- `Direction test (not run)` is a setup confirmation state, not a hardware failure. + +## Retest sequence + +1. Start the game, open **Force Feedback** before entering a race, and select the intended FFB output interface. +2. Confirm the menu-time status shows `[..] FFB device (starts in gameplay)` instead of treating `waiting / released / inactive` as a hardware error. +3. Enter gameplay. Confirm FFB initializes and works normally. +4. Run **Test Left** and **Test Right** once. Confirm the direction-test status changes from pending to OK when the directions are correct. +5. Make sure `OutRun2006Tweaks.profiles\FFB\ffb.ini` does **not** already exist, enter `ffb` as the FFB profile name, and select **Save as profile**. This explicitly tests the first-save path that previously failed. +6. Confirm the UI reports the full saved path and that `\OutRun2006Tweaks.profiles\FFB\ffb.ini` exists. +7. Change one FFB value and overwrite the same `ffb` profile once. Confirm overwrite also succeeds. +8. If saving fails, attach `OutRun2006Tweaks.log`; the error should now include the destination folder and the Windows filesystem error. +9. During gameplay, disconnect and reconnect the wheelbase once. Confirm the device is reacquired and FFB resumes without restarting the game. +10. Repeat one short left and right corner and confirm SAT direction/feel is unchanged from the previous Modern / Physics SAT build. + +## Requested hardware coverage + +- Fanatec wheelbase / DirectInput FFB interface +- Simucube wheelbase / SC-Link or the actual force-feedback interface exposed by its driver +- MOZA R3 regression check + +Do not change force-feel tuning for this test. The goal is device/runtime/profile compatibility, not a new FFB character. diff --git a/docs/VR_ARCHITECTURE.md b/docs/VR_ARCHITECTURE.md new file mode 100644 index 000000000..ed79478da --- /dev/null +++ b/docs/VR_ARCHITECTURE.md @@ -0,0 +1,241 @@ +# OpenXR VR architecture + +This document is the source-of-truth architecture for the `vr-openxr` branch. + +## Decision + +The VR software skeleton is being **reconstructed**, not merely cleaned up. The current prototype proved important game-specific facts, but its large mixed-ownership translation units and compatibility-oriented IPC are not the architecture we want to keep. + +Two classes of work are treated differently: + +**Retain verified evidence:** + +- OutRun's verified View / Projection / WorldView globals; +- VS c64..c67 as the authoritative `Transpose(WorldView * Projection)` upload boundary; +- one immutable render pose for one presented game frame; +- simulation, input, timers and native FFB execute once; +- only GPU draw submission is duplicated for the second eye; +- unsafe/unclassified rendering fails closed; +- the submitted OpenXR pose/FOV must be the pose/FOV that rendered the accepted image; +- reset/session generations invalidate stale resources and frame history. + +**Replace prototype structure:** + +- broad shared state with mixed host/client writers; +- semantic use of `reserved[]` fields; +- transport details embedded in the game renderer contract; +- runtime/session, D3D11, frame-source and compositor ownership concentrated in `vrhost/src/main.cpp`; +- D3D9 draw classification, stereo projection, transport and diagnostics concentrated in one backend TU; +- one-shot mutation workflows and obsolete host generations. + +A zero-knowledge rewrite would throw away the expensive reverse engineering above. Keeping the old skeleton would preserve accidental coupling. The chosen approach is therefore a **clean skeleton reconstruction around the verified OutRun adapter**. + +## Target architecture + +```text + OpenXR runtime + | + v + +-------------------+ + | IVrRuntime | + | frame timing | + | views / lifecycle | + +---------+---------+ + | + HostState | render pose + v ++------------------ Cross-bitness IPC v3 ------------------+ +| HostState : host -> game | +| ClientState: game -> host | +| FrameRing : game producer -> host consumer | +| AckState : host consumer -> game producer | ++-------------------------+---------------------------------+ + | + +------------+-------------+ + | | + v v + +---------------------+ +-------------------------+ + | OutRun IGameAdapter | | Host IFrameSource | + | c64 WVP boundary | | D3D9Ex shared backend | + | frame pose latch | | Desktop Dup fallback | + | recenter/culling | | optional DXVK backend | + +----------+----------+ +------------+------------+ + | | + v v + +---------------------+ +---------------------+ + | IStereoBackend | | compositor/layers | + | classify draws | | projection/theater | + | per-eye projection | | HDR/UI/depth later | + | duplicate GPU draw | +----------+----------+ + +----------+----------+ | + | v + +---------------------------> Quest / VDXR +``` + +The important property is ownership. OpenXR does not know OutRun internals; the game adapter does not know how images reach the host; transport backends do not define camera mathematics; the compositor does not choose game draw classification. + +## New core boundaries + +The reconstructed skeleton starts with these interfaces/types: + +```text +src/vr/core/frame_types.hpp + renderer-independent pose, eye, frame and failure domain types + +src/vr/core/transport.hpp + AdapterId, TransportKind, FrameSlot + IFrameProducer / IFrameConsumer + +src/vr/game/game_adapter.hpp + IGameAdapter + the only architectural layer allowed to encode OutRun camera/WVP facts + +src/vr/d3d9/stereo_backend.hpp + IStereoBackend + draw classification and stereo GPU submission boundary + +src/vr/ipc/protocol_v3.hpp + HostState / ClientState / FrameRing / AckState + fixed-width cross-bitness wire fields, no semantic reserved slots + +vrhost/src/runtime/vr_runtime.hpp + IVrRuntime + OpenXR timing/view/session ownership boundary + +vrhost/src/frame_source.hpp + IFrameSource + host-side image acquisition boundary +``` + +Protocol v3 is compiled on both Win32 and x64 immediately, even while protocol v2 remains the active compatibility runtime during migration. This catches ABI mistakes before the runtime is switched. + +## Protocol v3 ownership + +Protocol v2 proved the transport but has acknowledged design debt: the host and game write different regions of broad shared structures and some meanings are encoded through compatibility/reserved storage. Protocol v3 removes that ambiguity. + +```text +HostState + only host writes + game reads + pose ID, predicted display time, head/eye poses, eye FOV, + recommended resolution, runtime adapter and generation + +ClientState + only game writes + host reads + game state, presentation state, adapter/probe state, + backbuffer information and last rendered/presented IDs + +FrameRing + only game publishes slots + host consumes + fixed-width frame ID, render-pose ID, presentation QPC, + rendered eye poses/FOV and explicit transport descriptor + +AckState + only host writes + game reads + accepted probe generation/token and consumed frame/slot +``` + +Native handles use a fixed 64-bit `WireHandle`; pointer-sized fields are forbidden in the v3 wire ABI. This is required because the producer is x86 and the consumer is x64. + +## Migration rule + +The reconstruction is deliberately incremental at the **behavior boundary**, not incremental at the old file layout. + +1. Build the new domain types, ownership-separated v3 wire contract and interfaces on both architectures. +2. Extract host pose publication and game pose consumption behind v3 HostState. +3. Extract OutRun stereo projection math from the D3D9 hook into the game adapter. +4. Extract draw classifier/state tracker behind IStereoBackend. +5. Move D3D9Ex sharing into a transport backend implementing the core producer contract. +6. Move Desktop Duplication into a host frame-source backend. +7. Split OpenXR runtime/session ownership from D3D11 resources and composition. +8. Switch the live runtime from v2 to v3 once both sides can run in parallel and produce equivalent frame IDs/poses. +9. Delete v2 compatibility protocol and the old mixed-ownership paths. +10. Only then add new features such as depth submission, independent HUD layers or more aggressive culling fixes. + +At each extraction the old behavior is the comparison oracle. Once equivalent behavior is demonstrated, the old implementation is removed instead of kept as a permanent compatibility branch. + +## OutRun-specific invariants + +These are evidence, not legacy compatibility requirements, and they remain until runtime evidence disproves them: + +- the authoritative world transform boundary is the verified VS c64..c67 upload; +- additional `BeginScene` calls before the same Present reuse the same latched render pose; +- simulation/input/timers/native FFB are never replayed for the second eye; +- second-eye work happens while the original D3D9 draw state is live; +- screen-space/non-world draws are zero-disparity unless deliberately promoted to an OpenXR UI layer; +- unknown MRT, depth/stencil, offscreen-world or query states fail closed rather than guessing; +- the frame descriptor carries the effective rendered eye poses, not a newer tracking sample; +- stale resources never survive reset, adapter, transport-generation or OpenXR session-generation changes. + +## Transport backends + +Transport is not part of the game adapter. + +### D3D9Ex shared backend + +Direct sharing is eligible only after all of the following succeed: + +1. OpenXR selects/requires the D3D11 adapter; +2. the game proves a compatible D3D9Ex path; +3. adapter LUIDs match; +4. a small shared verification texture is opened by D3D11 and its content is verified; +5. the host ACKs the exact probe generation/token; +6. a multi-slot queue is created; +7. producer completion and consumer ACK prevent slot overwrite. + +D3D9Ex failure is a backend-selection result, not a camera/stereo failure. + +### Desktop Duplication backend + +Desktop Duplication is retained as a compatibility and diagnostic frame source. Metadata must be read before capture and revalidated after capture so the host never associates an image with the wrong frame/pose. It must not shape the stereo renderer architecture. + +### DXVK backend + +DXVK remains isolated. It is useful as a measured alternative for old D3D9 titles, but adopting it as the primary path would make a graphics translation layer part of this project's maintenance surface. Promotion requires Quest/VDXR measurements for latency, pacing, image quality and compatibility. + +## Reference projects and what is actually reused + +The design uses observable architecture lessons, not blind source copying. + +- Microsoft/Khronos documentation defines interop, adapter, timing and OpenXR lifecycle contracts. +- REFramework/UEVR demonstrate the value of separating runtime ownership, graphics backends, engine/game adapters and overlay/composition concerns. +- UEVR in particular contains separate OpenXR runtime, D3D11/D3D12 components and overlay/render-target components; the lesson is the ownership boundary, not its Unreal-specific implementation. +- `elliotttate/vrframework` reinforces universal-core / engine-adapter / game-data separation and explicit frame timing. +- openRBRVR and DXVK-based work demonstrate that a D3D9 translation route can be viable, while also showing that the translation layer becomes part of the product. +- historical iZ3D/stereo wrappers are used only as hints for draw duplication, projection and UI classification and must be revalidated against OutRun. + +License-incompatible or unclear-license implementations are not copied. UEVR is currently treated as architecture/reference material only. + +## Current transitional state + +The live runtime still uses the proven v2 paths inside: + +```text +src/vr/game/outrun_renderer.cpp +src/vr/d3d9/stereo_renderer.cpp +src/vr/ipc/protocol.hpp +vrhost/src/main.cpp +``` + +They are now migration sources, not architecture authorities. New code must target the reconstructed interfaces/v3 contract. No new feature should deepen dependencies on those monoliths. + +`src/vr_shared.hpp` is compatibility-only and should disappear with v2. + +## CI contract + +CI must prove throughout reconstruction that: + +- Win32 game DLL builds; +- x64 host builds; +- protocol v3 compiles on both architectures and uses fixed-width wire handles; +- exact compositor HLSL compiles; +- the verified OutRun c64/BeginScene/Present evidence remains present until moved into the game adapter; +- VR code never calls game simulation or native FFB ticks for stereo rendering; +- v3 contains explicit HostState/ClientState/FrameRing/AckState ownership and no semantic `reserved[]` transport extension; +- transport choices remain behind explicit boundaries; +- obsolete patch workflows and host generations do not return. + +CI cannot prove optics, eye order, reprojection quality, stage-specific culling completeness, real D3D9Ex support or end-to-end latency. Those remain Quest 3 / VDXR acceptance tests. diff --git a/docs/VR_AUTODEV_PROTOCOL.md b/docs/VR_AUTODEV_PROTOCOL.md new file mode 100644 index 000000000..b455f965f --- /dev/null +++ b/docs/VR_AUTODEV_PROTOCOL.md @@ -0,0 +1,116 @@ +# OutRun VR Autonomous Development Protocol + +Purpose: durable coordination contract for the four Work-scheduled development jobs. Scheduling is external to this repository. This file defines what each worker may read/write and how work survives between runs. + +## Authority and safety rules + +- Integration/source branch: `vr-unified-backends`. +- Stable `master` and legacy stable branches are never modified by autonomous workers. +- Only the **FIX worker** may modify production source on `vr-unified-backends`. +- REVIEW and VALIDATION are source-read-only. They may update only their dedicated handoff/report files. +- INTEGRATION/PLANNER owns `docs/VR_WORK_QUEUE.json` and the coordination section of `docs/VR_AUTODEV_STATE.json`. +- Do not claim runtime/HMD correctness from CI. Quest 3 + VDXR observations are authoritative for stereo, depth, HUD placement, recenter, sky/effects and pacing. +- A finding that has failed two materially different fixes becomes `BLOCKED`; do not loop on it. +- If the relevant source/input hash has not changed, do not repeat an already completed review/build unless a new review lens or new runtime evidence exists. + +## Checkpoint contract + +Every source-changing unit follows: + +C0 RECOVER -> C1 REVIEW -> C2 IMPLEMENT -> C3 VALIDATE -> C4 COMMIT -> C5 PACKAGE (only when useful) -> C6 STATE + +A worker resumes from the first incomplete checkpoint. Completed checkpoints with unchanged inputs are reused. + +## Worker roles + +### REVIEW +Reads latest integration source, `VR_AUTODEV_STATE.json`, queue, findings and runtime feedback. Performs deep review using a rotating lens not already completed for the same input hash. It writes only: +- `docs/VR_REVIEW_FINDINGS.md` when there is material durable evidence +- `docs/autodev/REVIEW_HANDOFF.json` + +A review result must include evidence paths/functions, severity, confidence, automatic-testability, HMD requirement and a bounded fix hypothesis. No source modification. + +### FIX +Reads the queue and latest REVIEW handoff. It chooses the highest-priority `READY` item that does not require HMD evidence. It is the only autonomous worker allowed to change production source on `vr-unified-backends`. + +Rules: +- one coherent hypothesis per commit; +- prefer DX9Ex/D3D9-safe correctness first; DXVK/DX12 receive bounded ports after the common behavior is stable; +- fail closed for render classifiers; +- do not broaden WVP/WorldBillboard ownership without exact evidence; +- write `docs/autodev/FIX_HANDOFF.json` with source commit, changed paths, hypothesis, expected effect and validation request; +- if two materially different attempts fail, mark the handoff as BLOCKED and move on. + +### VALIDATION +Source-read-only. Reads FIX handoff and the exact source commit. It checks compile/static/regression/config/package/CI evidence and records: +- `docs/autodev/VALIDATION_HANDOFF.json` +- build/workflow IDs and artifact hashes when available. + +It must distinguish: +- `PASS_STATIC` +- `PASS_BUILD_RUNTIME_PENDING` +- `FAIL_BUILD` +- `FAIL_REGRESSION` +- `NEED_HMD_TEST` + +It never upgrades runtime-pending work to fixed. + +### INTEGRATION / PLANNER +Consumes all handoffs and runtime feedback, deduplicates findings, updates the central queue/state, and decides which candidates are worth packaging. It does not rewrite renderer/game/host source. + +It may: +- close DONE items only with required evidence; +- move hardware-dependent items to `NEED_HMD_TEST`; +- generate/refresh no more than six meaningful runtime candidates; +- freeze a tester matrix when it improves diagnostic value; +- prepare bisect ranges when a last-good and first-bad boundary exists; +- append durable run history. + +## Central queue state machine + +`NEW -> READY -> IMPLEMENTING -> IMPLEMENTED -> VALIDATING -> NEED_HMD_TEST -> DONE` + +Alternative terminal/holding states: +- `BLOCKED`: two independent fixes failed or required evidence is unavailable. +- `REJECTED`: hypothesis disproved. +- `SUPERSEDED`: replaced by a newer, narrower finding. + +Queue ownership is logical, not a long-lived lock. A worker must verify that the item source/evidence hash still matches before acting. + +## Evidence priority + +1. Runtime Quest 3/VDXR logs + user observation tied to BuildMatrixId/VariantId/SessionId +2. Reproducible local/CI build or regression result +3. Static source proof +4. Hypothesis only + +Higher evidence may invalidate lower-level conclusions. + +## Candidate policy + +Keep candidate names diagnostic rather than cosmetic: +- A_CONTROL: current stable D3D9/SAFE control +- B_CORRECTNESS: isolated correctness hypothesis +- C_PERFORMANCE: isolated performance hypothesis after correctness gate +- D_DIRECTGPU_OPENXR: isolated transport/timing hypothesis +- E_DXVK_SAFE / E_DXVK_MULTIVIEW +- F_DX12_STRICT + +Never create nominally different candidates from identical binaries. + +## Runtime feedback ingestion + +`docs/VR_RUNTIME_FEEDBACK.json` is the normalized durable input. Each test result should identify: +- BuildMatrixId +- VariantId +- SessionId +- source commit +- VDXR refresh/render scale when known +- startup/stereo/HUD/effects/recenter/pacing observations +- uploaded log bundle identity + +The planner converts new runtime evidence into queue transitions on the next pass. + +## Idle-time rule + +A worker does not stop merely because its primary item is blocked. It moves to the next eligible queue item or a new review lens. It must not re-run unchanged work only to consume time. diff --git a/docs/VR_AUTODEV_STATE.json b/docs/VR_AUTODEV_STATE.json new file mode 100644 index 000000000..efbf57d71 --- /dev/null +++ b/docs/VR_AUTODEV_STATE.json @@ -0,0 +1,150 @@ +{ + "schemaVersion": 1, + "updatedUtc": "2026-09-20T18:36:00Z", + "branch": "vr-unified-backends", + "publicationMode": "github-fast-forward-cas", + "frozen": true, + "freezeReason": "runtime-recovery-candidate-built-after-safe-transport-policy-sync-and-dx12-device-create-fix", + "frozenCommit": "803144005c0e45352602917a2f51804ec6b18ae6", + "buildMatrixId": "VRM-20260920-803144005c0e", + "components": { + "d3d9": "803144005c0e45352602917a2f51804ec6b18ae6", + "dxvkGameHost": "5e58ee35e14636dbfad3ad3744d350aa09859992", + "dxvkProvider": "5bb301ab22f7b41d68a879bf84c1e3d10ae0c473", + "multiviewPatcher": "0ef3253da1738b07e67358a027311c7bcedaa001", + "dx12": "ab8e00ab4a21e4ebb23fbc4046abda51e885bcb6" + }, + "ci": { + "buildRunId": 35514339665, + "unifiedRunId": 35514339728, + "artifactId": 10606816032, + "artifactSha256": "1517890fd2efe90f9c5fe8f345d7fd695b534b33de5ad1937441836f386e6cca", + "innerZipSha256": "e48b1648ce80f7027e3cdea3bbb13192cc56c5bd99628ee886759e6d8916e065" + }, + "availableVariants": [ + "CONTROL_2D", + "A_CONTROL", + "E_DXVK_SAFE", + "E_DXVK_MULTIVIEW", + "F_DX12_STRICT" + ], + "runtimeEvidence": { + "previousCandidate": "5f9028deb7ee93aa5733cfdf4acf5c872f15298e", + "observed": { + "control2D": "normal startup", + "d3d9Safe": "normal startup; gameplay showed enlarged/cropped left half with no stereo", + "dxvkSafe": "same enlarged/cropped flat image", + "dx12Strict": "device creation failed" + }, + "diagnosis": [ + "SAFE settings had DirectGpuOnly=false but game-side R9 transport policy read stale CRT environment values and remained direct-only, suppressing SBS fallback and publishing zero completed stereo frames", + "host recovery theater cropped the left half even when the desktop source was full-width mono", + "DX12 Direct3DCreate9On12Ex succeeded but CreateDeviceEx returned D3DERR_INVALIDCALL; strict branch now preserves legacy CreateDevice semantics with one bounded presentation-parameter retry" + ], + "quest3VdxrRequired": true + }, + "fixes": { + "d3d9Safe": "R9DirectOnlyTransport reads Settings::VRDirectGpuOnly directly; host crops only completed SBS frames", + "dxvkSafe": "same transport-policy fix and host crop guard synchronized to vr-dxvk-poc", + "dx12Strict": "legacy D3D9On12 CreateDevice semantics plus bounded compatibility retry; host crop guard synchronized" + }, + "validation": { + "unifiedWorkflow": "success", + "zipIntegrity": "passed", + "requiredFiles": "passed", + "branchShaSync": "passed", + "skyGlowFactor": 1 + }, + "nextAction": "Continue from unified commit ad14ed6c6757634233ec79b71f42be53c563edb7 with T0-debug commit 365b294b6a96c98ada434b439a982725e1ba542c as the build-validated DX9Ex reference input. Next hourly work should port bounded fingerprint telemetry and the narrow S6/S7 lens-flare candidate into the D3D9Ex path only, fail closed, without broad WVP/WorldBillboard promotion; then prepare no more than six runtime candidates. Do not mark Position HUD, white overlays, or T5 fast-path correctness fixed without runtime evidence.", + "integrationHead": "ad14ed6c6757634233ec79b71f42be53c563edb7", + "activeDevelopment": true, + "dx9exWorkingBase": { + "branch": "vr-d3d9ex-t0-debug", + "buildValidatedCommit": "365b294b6a96c98ada434b439a982725e1ba542c", + "branchHead": "7298d1f2426da9134aca17b83f23076a495fd788", + "ciRunId": 35526213136, + "artifactId": 10609747550, + "artifactSha256": "7cf2cc7a8ed762a07bcc3bb474e9d37bb0980ac0320b944a1999d61f23612a4f", + "innerZipSha256": "b034553ac0b919a9d7944ee11168a6e5dd3f868221a047585b3a8da7cfe8ed0d", + "buildValidation": "host/game/package success", + "runtimeEvidence": [ + "T0-derived world/sky/loading behavior is the stability reference", + "stage loading reached gameplay normally in the latest T0-family runtime tests", + "persistent last-good stereo projection removed the full-screen solid/empty-color stall symptom", + "S6/S7-family narrow alpha+flat-WVP behavior collapsed duplicated lens flare to one in runtime tests, but camera-relative correctness remains unconfirmed" + ], + "unresolved": [ + "Position HUD 6th/6 remains duplicated/oversized and is not confirmed fixed", + "white rank/score and exit YES/NO remain split", + "T5/R29 fast path improves frame rate somewhat but corrupts sky/world and is performance evidence only" + ] + }, + "reviewBatches": { + "RB01": { + "status": "complete", + "completedUtc": "2026-09-20T17:55:25Z", + "inputs": { + "unifiedHeadBefore": "fc71a50a10ddcccb6e38e6a41fba8e3f7fc3d74f", + "t0DebugBuild": "365b294b6a96c98ada434b439a982725e1ba542c", + "t0DebugBranchHead": "7298d1f2426da9134aca17b83f23076a495fd788" + }, + "lenses": [ + "architecture/build/backend boundary", + "resource/state/lifetime/synchronization", + "stereo HUD/sky/effects/recenter/menu correctness", + "performance draw amplification/copies/XR pacing", + "adversarial regression/fail-closed review" + ], + "result": "Do not wholesale-merge diverged T0 branch. Port only validated behaviors in bounded changes." + } + }, + "currentRun": { + "type": "manual-hourly-equivalent", + "sourceCommit": "ad14ed6c6757634233ec79b71f42be53c563edb7", + "change": "Port T0 loading bootstrap and persistent last-good gameplay projection guards into the current unified host without changing HUD/effect classifiers.", + "buildRunId": 35527017219, + "unifiedRunId": 35527017201, + "unifiedArtifactId": 10609683851, + "artifactSha256": "a5e21693ae74d51ea0ba7b2483ef133fbfde9517068993a5e8796f6b5bc37243", + "innerZipSha256": "12088212f8ec4af2f92cdc6280e785f51112b9e50945c10b8fe9fb4f1dd08780", + "validation": "Build success; D3D9 SAFE, DXVK game/host, DXVK provider, multiview patcher, DX12 and package all success; runtime validation pending." + }, + "coordination": { + "initializedKst": "2026-09-21T03:36:00+09:00", + "baselineSourceCommit": "ad14ed6c6757634233ec79b71f42be53c563edb7", + "protocol": "docs/VR_AUTODEV_PROTOCOL.md", + "queue": "docs/VR_WORK_QUEUE.json", + "runtimeFeedback": "docs/VR_RUNTIME_FEEDBACK.json", + "handoffs": { + "review": "docs/autodev/REVIEW_HANDOFF.json", + "fix": "docs/autodev/FIX_HANDOFF.json", + "validation": "docs/autodev/VALIDATION_HANDOFF.json" + }, + "authorities": { + "productionSourceWriter": "FIX", + "centralQueueWriter": "INTEGRATION_PLANNER", + "reviewSourceMode": "read-only", + "validationSourceMode": "read-only" + }, + "invariants": [ + "Do not repeat completed work for unchanged input hashes without a new review lens or runtime evidence.", + "Two materially different failed fixes move a finding to BLOCKED.", + "Runtime/HMD correctness requires Quest 3 + VDXR evidence.", + "No more than six meaningful runtime candidates; never fabricate identical variants." + ], + "setupCommits": [ + "73c58c45082a8c3b7b62299f4b3ef1242f1f9795", + "24822d28d371f39c2a3d261590f93ebf24746a19", + "5f0a3dcca342542ccbaf2e2c72ed322719f20ce0", + "27380e8ba4b8d168e81a9ed1fc44582779f2a0f4", + "c8daa9ac86d922a73469a9b10705d0dff16a7640", + "ce6bb0de5755179d4cf104aa00efe81c5b8de0a3" + ], + "validation": { + "script": "tools/Validate-VRAutodevState.ps1", + "workflow": ".github/workflows/vr-autodev-state.yml", + "scope": "coordination-state-only", + "fullBackendBuildOnStateOnlyChanges": false + } + } +} diff --git a/docs/VR_BUILD_MATRIX_LOG.txt b/docs/VR_BUILD_MATRIX_LOG.txt new file mode 100644 index 000000000..c845f5ddc --- /dev/null +++ b/docs/VR_BUILD_MATRIX_LOG.txt @@ -0,0 +1,189 @@ +# OutRun VR Build Matrix Log + +Persistent cumulative record for unified-backend review/build/test matrices. Append future runs; do not replace with per-run reports. + +====================================================================== +RUN 0001 - UNIFIED BACKEND PACKAGE VALIDATION +====================================================================== +Date: 2026-09-20 +Reviewed integration HEAD: 35aef3ecd59d69fe22813778dc23c0f2ad47fc6f +Previous review baseline: f6df045ecf17150b20ef8fa2296e4b0da35da7ac +Delta: 5 commits ahead; integration delta is packaging/selector/workflow/docs only. No common renderer source was modified in this interval. + +Deep structural milestones: +- Cell #1: baseline architecture conclusions remain valid: R26/R23 safe owner; backend boundary must precede device creation; recenter/semantic/frame metadata stay backend-neutral; DirectGPU generation/ACK remains common transport contract. +- Cell #1,000,000: second independent architecture reconstruction did not justify downgrading any baseline P0. New workflow packages backends side-by-side rather than stacking competing runtime hooks, which is consistent with the baseline integration safety requirement. + +Current package architecture: +- D3D9 safe payload is built from vr-unified-backends. +- DX12 payload is built from vr-dx12-poc and uses native x64 D3D12 OpenXR host target. +- DXVK-aware game/host is built from vr-dxvk-poc. +- DXVK provider and multiview patcher are separately built and then staged into the unified package. +- Selector scripts switch installed payloads; D3D9 remains the initially staged active backend. +- SkyGlowFactor is normalized to 1 in the packaged INI. + +CI validation: +- Unified VR Backend Test Package workflow run 35497556772: SUCCESS, attempt 2. +- Generic Build workflow for the same integration HEAD 35497556693: SUCCESS. + +Artifacts produced by unified workflow: +- OutRun2-VR-UNIFIED-35aef3ecd59d69fe22813778dc23c0f2ad47fc6f + artifact id: 10601692327 + digest: sha256:1287aba2ab26900ffba52ee7c56908cdb25ab541c69a460a937047136f0d9233 +- unified-d3d9-safe + artifact id: 10600612846 + digest: sha256:7473b12e7f841493c757029c94d6d4c78b9188dc6eb9f1b5807106b878502c5b +- unified-dx12 + artifact id: 10601647144 + digest: sha256:bb486b130ac0a620c3339596b5fe843e0cc27bedc08e21993f780c6ec5cc66d4 +- unified-dxvk-game-host + artifact id: 10601313010 + digest: sha256:b4a91fef9107aebb44b9754a36222ff1456cf28dda95c84702e2395bdf3499b5 +- unified-dxvk-provider + artifact id: 10600667356 + digest: sha256:156f338b27f94fa7caa63313a1712790c9c942e2bb1987ffc7068a3de25ff87b +- unified-multiview-patcher + artifact id: 10601352227 + digest: sha256:de5e831062d347d8d20e561eb0304322332e4367eacfdcedf2e2a81635ef5f2f + +Matrix status for this run: +A CONTROL = D3D9 safe payload: AVAILABLE / CI built. +B CORRECTNESS = NOT GENERATED. No new isolated correctness source patch was safely applied in this run. +C PERFORMANCE = NOT GENERATED. No new isolated performance source patch was safely applied in this run. +D DIRECTGPU/OPENXR = NOT GENERATED. No new isolated DirectGPU/OpenXR source patch was safely applied in this run. +E DXVK = AVAILABLE through unified DXVK game/host + provider + multiview patcher payloads. +F DX12 = AVAILABLE through unified DX12 payload. + +Reason B/C/D were not fabricated: +The current integration delta contains no new renderer correctness/performance/DirectGPU source changes. Creating nominally different packages with identical binaries would not provide diagnostic value and violates the matrix policy. + +Persistent baseline defects still requiring implementation before they can become meaningful B/C/D variants: +P0: real application-space recenter. +P0: swapchain-generation-scoped cached image validity. +P0: depth-disabled draw fail-closed to zero disparity unless positively proven world. +P0: R28 changed-shader verified-world epoch impersonation. +P1: SkyRotationOnly semantic. +P1: live D3D9 getter/state-shadow hot paths. +P1: DirectGPU full-eye hold-copy optimization candidate. +P1: synchronous host pipeline telemetry. + +Important limitation discovered during this run: +The unified packaging workflow currently provides backend payload selection, but it does not yet implement the requested per-variant A/B/C/D/E/F runtime log identity, per-session diagnostic directories, TEST_RESULT template, variant_manifest.json, or one-click log collector. Therefore uploaded runtime logs from this package are not yet guaranteed to be self-identifying by matrix variant. This must be implemented before claiming the upload-friendly diagnostic requirement is complete. + +User runtime test recommendation for currently available package: +1. D3D9 safe control first. +2. DXVK second using identical HMD refresh/render-scale settings. +3. DX12 third using identical settings. +4. Do not interpret CI success as proof of VR correctness; compare HMD smoothness, stereo correctness, HUD/rank/score, sky/cloud head behavior, smoke/skid placement, menu car rendering, recenter behavior, and exit Yes/No alignment. + +RUN 0001 RESULT: +- Review: completed against current integration delta plus two structural milestones. +- Source fixes: none applied; existing P0/P1 defects are baseline-known and this connector run cannot honestly claim runtime validation. +- Unified backend CI: successful. +- Meaningful artifacts: A/D3D9, E/DXVK, F/DX12 available. +- B/C/D: intentionally not fabricated. +- Next highest-value integration work: implement self-identifying per-variant runtime logging/collector, then implement isolated P0 correctness patches to populate B, performance patches to populate C, and DirectGPU/OpenXR patch to populate D. + +====================================================================== +RUN 0002 - SELECTOR HARDENING + DIAGNOSTIC COLLECTION FOUNDATION +====================================================================== +Date: 2026-09-20 +Reviewed integration HEAD at start: ec1f051324879ad7a052b91cb2358756f14ca0e5 +Previous matrix HEAD: 35aef3ecd59d69fe22813778dc23c0f2ad47fc6f +Delta: 10 commits ahead. Changed integration files are workflow/selector/docs only; no common stereo renderer, OpenXR host render loop, DirectGPU transport, or semantic classifier source changed in this delta. + +Deep structural milestones: +- Cell #1: full integration reconstruction confirms the backend seam is now materially safer. Selector supports 2d/d3d9/dxvk-safe/dxvk/dx12, checks running game/host before switching, removes conflicting root d3d9.dll/multiviewpatcher.dll for non-DXVK modes, and deterministically maps RenderBackend values. +- Cell #1,000,000: second reconstruction found no reason to downgrade the baseline P0 correctness defects. The new selector is control-plane work and does not fix gameplay recenter, swapchain-generation validity, depth-off semantic fail-closed behavior, or R28 verified-world epoch reuse. +- Changed assumption cleared: INT-1 from the original baseline ('no unified backend exists yet') is no longer true at packaging/selection level. A unified selectable package now exists. It is still not a single common renderer implementation; backend payloads are staged from integration/DXVK/DX12 branches. + +Validation before new changes: +- Generic Build run 35499870481 at ec1f0513: SUCCESS. +- Unified VR Backend Test Package run 35499870485 at ec1f0513: SUCCESS. +- Unified artifact: OutRun2-VR-UNIFIED-ec1f051324879ad7a052b91cb2358756f14ca0e5, artifact id 10602081123, sha256 5eb8e20de57bc0b405aed52bedd9aabea3b13058e333ca4f02fc8480b14d92d4. +- D3D9 artifact id 10602320824; DXVK game/host 10601084372; DXVK provider 10601841536; multiview patcher 10602285788; DX12 10602245916. + +Source/tool improvements applied this run: +1. Added tools/Collect-OutRunVRLogs.ps1. + - derives VariantId from ACTIVE_VR_BACKEND.txt + - creates logs//// + - copies game/host/pipeline/watchdog/backend logs, dump metadata, INI, active backend state + - emits MANIFEST.txt, variant_manifest.json, TEST_RESULT.txt + - creates per-variant upload ZIP or matrix ZIP with -All +2. Added tools/Collect-OutRunVRLogs.cmd as one-click launcher. +3. Updated Select-OutRunVRBackend.cmd to explicitly direct the tester to the collector after backend selection. + +Variant mapping implemented by collector: +- d3d9 -> A_CONTROL +- dxvk-safe -> E_DXVK_SAFE +- dxvk -> E_DXVK +- dx12 -> F_DX12 +- 2d -> CONTROL_2D + +Important limitation retained honestly: +- The collector files have been added to the repository, but the current unified package workflow does not yet copy those new collector files into the packaged artifact. The selector change triggers a fresh unified workflow, but until packaging is updated and validated, do not claim the downloadable unified ZIP contains the collector. +- Runtime producers still use their existing log filenames; the collector separates/captures them after each test. Native per-process A/B/C/D/E/F identity headers and normalized high-frequency counters remain future work. +- B/C/D remain ungenerated because there are still no isolated correctness/performance/DirectGPU source variants. No fake duplicate binaries were produced. + +CI after this run's changes: +- New Build and Unified VR Backend Test Package runs were triggered by selector changes and were still in progress when this entry was written. Successful prior ec1f0513 artifacts remain the known-good downloadable control set. + +RUN 0002 RESULT: +- Material update: yes. +- Integration selector: improved and previously successful at ec1f0513. +- Upload-friendly diagnostics: repository-side collector foundation implemented; package inclusion still pending validation. +- Persistent P0 set: unchanged. +- Stable vr-openxr: untouched. +- Next action: package the collector scripts, validate them in CI, then implement isolated P0 correctness changes for B before moving to C/D performance/DirectGPU variants. + +====================================================================== +RUN 0003 - IMMUTABLE EVENING MATRIX FREEZE +====================================================================== +Date: 2026-09-20 +BuildMatrixId: VRM-20260920-cbec700a30c4 +Frozen integration/D3D9 SHA: cbec700a30c44a85292f396d37d6e24a77ed0b47 +DXVK game/host SHA: 88e891f9cb4533700d64c25f544561c1cda3f7ff +DXVK provider SHA: 5bb301ab22f7b41d68a879bf84c1e3d10ae0c473 +Multiview patcher SHA: 0ef3253da1738b07e67358a027311c7bcedaa001 +DX12 game/host SHA: 4eb469238e6caa2f2c6aa8825db7502f42c522db + +Implemented and verified: +- collector scripts are included in the actual unified ZIP; +- selector prepares unique SessionId/VariantId/BuildMatrixId and allowed config snapshot before launch; +- collection filters by session start and extracts only appended bytes from pre-existing logs; +- TEST_RESULT.txt is preserved on repeat/matrix collection; +- every external/provider component SHA is pinned and packaged; +- selector CI covers 2d, d3d9, dxvk-safe, dxvk, dx12, exact root DLL presence/absence, + ACTIVE_VR_BACKEND, [VR] header round trips, session identity and old-log exclusion; +- downloaded ZIP inventory contains the GUI/command selectors, collectors, build inputs, + checksums, test scenario, all three backend payloads and both DXVK extras. + +Final CI: +- Build run 35504498714: SUCCESS. +- Unified run 35504498726: SUCCESS. +- Unified artifact id 10603562638; artifact digest + sha256:1ab18b99ebb5be4cd4dd4e0457407314040b7be89e906132835b9348bf244fd6. +- Inner evening ZIP sha256:2e6c7fba2fcc5a775bac1c6130f6e1d6b6c7555cc80115fe581df6d6c13a8661. +- SHA256SUMS.txt entries independently recalculated: 29; failures: 0. + +Frozen candidates: +- CONTROL_2D: available (smoke-only, VR disabled). +- A_CONTROL / D3D9 SAFE: available; comparison control, not newly user-validated. +- B_CORRECTNESS: excluded; no isolated renderer patch with adequate verifier was frozen. +- C_PERFORMANCE: excluded; no isolated performance patch was frozen. +- D_DIRECTGPU_OPENXR: excluded; no isolated transport/timing patch was frozen. +- E_DXVK_SAFE: available; D3D9-safe game DLL + pinned local DXVK provider, patcher absent. +- E_DXVK_MULTIVIEW: available; same DXVK game/host plus pinned provider and patcher; experimental. +- F_DX12_STRICT: available; DXVK d3d9.dll and multiviewpatcher absent by selector contract. + +Evening test order: +1. 2D ORIGINAL quick launch/exit smoke only. +2. A_CONTROL D3D9 SAFE short startup screen, then menu car/recenter and identical short race scene. +3. E_DXVK_SAFE with identical VDXR refresh/render scale and scene. +4. If clean, choose E_DXVK_MULTIVIEW or F_DX12_STRICT as the one most informative alternative. +5. Run Collect-OutRunVRLogs.cmd after each tested mode and `Collect-OutRunVRLogs.cmd -All` + once at the end; upload the one matrix ZIP. + +Runtime limitation: +Quest 3/VDXR stereo correctness, HMD pacing, recenter, white rank/score, sky/effects, +menu car and exit Yes/No remain user-runtime-required. CI success is not runtime proof. diff --git a/docs/VR_EMULATOR_RENDERING_REVIEW.md b/docs/VR_EMULATOR_RENDERING_REVIEW.md new file mode 100644 index 000000000..432b08b8b --- /dev/null +++ b/docs/VR_EMULATOR_RENDERING_REVIEW.md @@ -0,0 +1,107 @@ +# Emulator rendering review for OutRun VR + +This note records the September 2026 comparison of PPSSPP, Dolphin, PCSX2 and RPCS3 rendering patterns against the `vr-openxr` renderer. It is a design/behavior review, not a source-copy ledger. GPL implementation code from those projects is not imported into this fork. + +## Goal + +Use mature emulator renderer lessons only where they reduce ambiguity in OutRun's D3D9 true-stereo path without changing game simulation, input, wheel/FFB behavior, or the proven R9/R13 transport model. + +## PPSSPP + +Observed pattern: + +- VR distinguishes flat/screen-space geometry from 3D geometry before applying headset transforms. +- Projection structure participates in the decision; orthographic/identity-like objects remain flat. +- Render-target/framebuffer identity is explicit and persists independently from individual draw calls. +- Compatibility work is fail-safe: unusual sky/effect passes can stay outside the normal stereo path rather than being blindly replayed. + +OutRun decision: + +- **Adopt:** projection-semantic classification before c64 HMD injection. +- **Already equivalent:** real backbuffer identity versus auxiliary targets; unsafe passes already fail closed. +- **Reject for now:** PPSSPP-style frame-wide `3D geometry count` threshold. OutRun has a verified c64 `WorldView*Projection` boundary, so delaying stereo until an arbitrary draw count would add latency and hide classification bugs. + +## Dolphin + +Observed pattern: + +- Perspective and orthographic projection are explicit renderer states. +- Camera/free-look transforms are applied to perspective projection paths, not indiscriminately to orthographic UI. +- Projection/viewport/depth changes are tracked as state transitions instead of inferred from a single draw. + +OutRun decision: + +- **Adopt:** structural perspective-versus-orthographic classification using the stable D3D projection terms (`m34`, `m44`). +- **Adopt:** unknown projection signatures fail closed to the stock game WVP. +- **Already equivalent:** projection is read from OutRun's verified renderer globals and the c64 upload must match `Transpose(WorldView * Projection)` before a draw can seed stereo replay. + +## PCSX2 + +Observed pattern: + +- Special rendering behavior is selected only after multiple state signals agree: render target, source texture, depth target, dimensions/formats/masks and draw geometry are commonly combined. +- Render-target identity is not by itself proof of semantic meaning. + +OutRun decision: + +- **Adopt:** world classification now requires multiple independent signals to agree: + 1. game D3D9 device; + 2. real main backbuffer with no auxiliary MRT; + 3. perspective projection signature; + 4. authoritative c64 upload matching `Transpose(WorldView * Projection)`; + 5. matching shader identity/serial when stereo replay consumes the verified WVP. +- **Reject for now:** game-specific draw-size/texture heuristics. They would be brittle until runtime evidence identifies a concrete OutRun effect that needs them. + +## RPCS3 + +Observed pattern: + +- Frame contexts have explicit ownership and cleanup boundaries. +- Presentation success/failure, queued frame advancement and resource retirement are kept coherent rather than allowing an ambiguous half-owned frame to leak into the next one. + +OutRun decision: + +- **No duplicate implementation needed:** the current renderer already latches one immutable pose for a presented game frame, reuses it across additional `BeginScene` calls, publishes frame/pose association at Present, and releases the pose lock only in `NotifyGamePresent()` or reset. +- Keep this invariant and continue to reject pose-sequence mismatches rather than attempting late repair. + +## Applied R13 semantic policy + +The central `src/vr/d3d9/vr_pass_policy.hpp` now classifies two independent dimensions: + +1. **Target policy** — internal stereo, main backbuffer, or auxiliary/stock. +2. **Projection class** — perspective 3D, orthographic 2D, or unknown. + +Those dimensions produce a render semantic: + +- `World3D`: main backbuffer + perspective projection. This is the only semantic allowed to proceed to the existing authoritative c64 verification and HMD injection. +- `ScreenSpace2D`: main backbuffer + orthographic projection. It stays stock/zero-disparity. +- `Auxiliary`: reflection/shadow/offscreen/MRT-related target. It stays stock. +- `Unknown`: unrecognized main-backbuffer projection. It stays stock fail-closed until runtime evidence proves it safe. +- `InternalStereo`: the mod's own replay/composition work; never recursively reclassified as game world geometry. + +For the stock OutRun renderer, `D3DXMatrixPerspectiveFovRH` produces the stable perspective structural signature `|m34| ~= 1, m44 ~= 0`; orthographic D3D projection produces `m34 ~= 0, |m44| ~= 1`. FOV, aspect, near/far and eye asymmetry do not change those two structural terms, so the classifier does not depend on resolution or user FOV. + +## Why this is low-risk + +The new policy does not create a new stereo renderer. It only narrows eligibility before the already-proven R13/R9 path: + +- perspective world draw -> existing c64 verification -> existing per-eye replay; +- orthographic/UI draw -> original game matrix; +- auxiliary/offscreen draw -> original game matrix; +- unknown -> original game matrix; +- MRT/query hazard -> existing single-execution/mono-shadow fallback. + +Therefore a classification miss degrades to stock mono/zero-disparity rendering for that draw instead of corrupting the world transform or duplicating side effects. + +## Runtime validation targets + +On Quest 3 + VirtualDesktopXR/VDXR, a healthy run should show: + +- `VR R13 emulator policy: main-backbuffer perspective class confirmed...` +- optionally `orthographic/screen-space c64 WVP kept stock...` when HUD/UI uses c64; +- no repeating `unknown main-backbuffer projection` warning during normal driving; +- true stereo still reaches `presentation=1`, `stereoState=2`, `stereoFailure=0`; +- `composeFail=0` and pose mismatch failures remain zero; +- compare `depthState`, `poseLag`, and `hostMissing` against the previous runtime baseline. + +If normal world geometry logs as unknown, do not loosen the classifier blindly. Capture `_34/_44`, target/depth state and c64 verification together and add only an evidence-based signature. diff --git a/docs/VR_EXE_HUD_INSPECTOR.md b/docs/VR_EXE_HUD_INSPECTOR.md new file mode 100644 index 000000000..eed4b0b75 --- /dev/null +++ b/docs/VR_EXE_HUD_INSPECTOR.md @@ -0,0 +1,47 @@ +# VR EXE / HUD Inspector + +This pipeline removes most manual reverse-engineering work from HUD identification. + +## What runs automatically + +1. GitHub Actions downloads the same public replacement OR2006C2C.EXE used by the upstream OutRun2006Tweaks build. +2. tools/analyze_outrun_exe.py parses the PE32 image, calculates SHA-256, fingerprints known renderer/HUD entry points, and finds direct x86 CALL references into them. +3. The VR game DLL installs a passive HUD inspector when VR is enabled. It traces put_sprite_ex, sprani_play_ae_auth_alpha, and put_clip_sprite without altering their arguments or rendering. +4. The runtime trace records ASLR-safe caller RVAs, sprite IDs, coordinates, priorities, game mode/stage and known reverse-engineered regions. +5. The normal test launcher automatically enables the existing shader-bytecode fingerprint telemetry for the test process; normal non-test launches keep the HUD inspector disabled. +6. Run-OutRunVRTest.ps1 waits for the game to exit and invokes Collect-OutRunVRLogs.ps1 automatically. The collector includes the HUD CSV, a HUD summary, a shader-fingerprint summary, and the exact local OR2006C2C.EXE SHA-256/match verdict. + +The normal test flow therefore remains: launch the standard test launcher, play the relevant screen/race, then exit normally. No Ghidra session, address lookup, hash command, or separate log collection is required. + +## Runtime output + +OutRun2006Tweaks-hudtrace.csv is deliberately rate-limited. A new fingerprint is written immediately and recurring fingerprints are written only at power-of-two count milestones. This keeps long races usable while retaining frequency evidence. + +Important columns: + +- return_rva: return address inside OR2006C2C.EXE. +- call_rva: return_rva minus five, matching the normal x86 CALL rel32 instruction address. +- known_area: labels existing reverse-engineered regions when known. +- arg0..arg5: API-specific sprite IDs, coordinates, flags, priority, color, or pointers. +- mode / stage: game context at the time of the draw request. + +## Existing anchor + +The upstream UI-scaling work already identifies sub_4BAD20 as the rival-car 1st/2nd/3rd marker renderer and records these call sites: + +- sprani: 0xBB0FB, 0xBB133, 0xBB16C, 0xBB1A5 +- put_clip_sprite: 0xBB21F, 0xBB241, 0xBB271, 0xBB2BC, 0xBB2D0 + +The runtime inspector labels these automatically. This gives a known-good end-to-end check that runtime caller RVAs and static EXE analysis agree. + +## Static-analysis artifacts + +The OutRun EXE HUD Inspector CI workflow uploads: + +- OR2006C2C_EXE_ANALYSIS.json +- OR2006C2C_EXE_ANALYSIS.md +- a build-validated dinput8.dll containing the passive inspector + +The static analyzer also verifies the nine previously reverse-engineered RankMarker call sites in the reference EXE. A mismatch fails CI rather than silently producing addresses for the wrong binary. + +The JSON is machine-readable so later automation can convert confirmed runtime fingerprints into precise VR pass rules instead of broad primitive-count or render-state heuristics. diff --git a/docs/VR_HOURLY_REVIEW_LOG.txt b/docs/VR_HOURLY_REVIEW_LOG.txt new file mode 100644 index 000000000..403b90d59 --- /dev/null +++ b/docs/VR_HOURLY_REVIEW_LOG.txt @@ -0,0 +1,507 @@ +# OutRun VR Hourly Deep Review Log + +This file is the cumulative persistent review log for the automated hourly source review. +Do not replace it with per-run files. Append future runs below the existing history. + +====================================================================== +RUN 0001 - BASELINE FULL-SOURCE REVIEW +====================================================================== +Date: 2026-09-20 +Execution: immediate/manual first run using the same policy as the scheduled automation +Branch: vr-unified-backends +Reviewed HEAD: f6df045ecf17150b20ef8fa2296e4b0da35da7ac +Review model: approximately 2,000,000 static cross-check cells. This is a structured +cross-validation matrix, not a claim that a human literally reread every file two million +times. + +Repository structure observed at baseline: +- total repository blobs: 254 +- src/vr-related blobs: 51 +- vrhost-related blobs: 37 +- build/workflow-related blobs: 9 +- test/verifier-related blobs: 34 +- current integration branch source is still the vr-openxr-gpl-reuse source baseline. +- vr-dx12-poc and vr-dxvk-poc remain separate branches at this baseline; their implementation + has NOT yet been integrated into vr-unified-backends. + +Source lineage: +- vr-unified-backends was created from f6df045e... +- f6df045e contains review documentation on top of source baseline a68cf178... +- no source-code delta exists between a68cf178 and the pre-integration branch baseline other + than review/report documents. + +---------------------------------------------------------------------- +MILESTONE CELL #1 - DEEP FULL-SOURCE STRUCTURAL REVIEW +---------------------------------------------------------------------- + +Production/integration graph reconstructed from the current tree: + +Game safe stereo owner: + stereo_renderer_r26_compare.cpp + -> stereo_renderer_r26.cpp + -> stereo_renderer_r23.cpp + -> stereo_renderer_r22.cpp + -> stereo_renderer_r13.cpp + -> stereo_renderer_r7.inc + +Renderer: + outrun_renderer_r23.cpp + -> outrun_renderer_r13.cpp + -> outrun_renderer.cpp + +D3D9Ex: + ex_device_upgrade_r15.cpp + -> lower compatibility overlays + +Host: + vrhost/src/main_r23.cpp + -> main.cpp + -> forced include runtime hardening chain + bounded swapchain wait + SBS capture override + D3D9Ex direct passthrough + review hardening + R21/R22/R23 runtime hardening + R24 black-screen guard + R26 recenter hardening + R32 direct submit + capture wait guard + crash diagnostics + +Current structural conclusion: +1. The safe production owner remains the R26/R23 path. +2. R29-R34 remain experimental/comparison technical debt rather than the active default owner. +3. DX12 and DXVK are NOT yet runtime-selectable on this branch. +4. A unified backend selector must be introduced without replacing the existing safe owner. +5. DX12 and DXVK must be treated as backend implementations behind one policy boundary, not + merged by blindly applying both PoC diffs to common D3D9 hooks. + +Architecture risk boundaries identified: +- draw semantic ownership +- D3D9 state shadow / StateBlock invalidation +- DirectGPU producer/consumer ownership +- OpenXR swapchain image generation +- user recenter application-space ownership +- backend selection before device creation +- renderer-owner uniqueness in CMake +- host executable/backend matching +- fallback authority during backend startup failure + +---------------------------------------------------------------------- +P0 FINDINGS AT BASELINE +---------------------------------------------------------------------- + +P0-1 REAL RECENTER IS NOT IMPLEMENTED FOR GAMEPLAY COORDINATES +Files: +- vrhost/src/runtime/r26_recenter_hardening.hpp +- vrhost/src/main_r23.cpp +- vrhost/src/main.cpp +- src/vr/ipc/recenter_request.hpp + +The user recenter path synthesizes a LOCAL reference-space-change event and invalidates cached +render state, but does not create a changed application-space transform, recreate the LOCAL +space with a new pose, or apply a persistent inverse head transform to later gameplay poses. + +Impact: +- recenter can appear ineffective +- valid projection state is invalidated even though the coordinate origin does not actually + move +- black/stale/loading-looking transitions can result + +Required direction: +separate runtime reference-space change from user recenter and implement a persistent +application-space recenter transform. + +P0-2 CACHED FALLBACK VALIDITY IS NOT TIED TO SWAPCHAIN GENERATION +Files: +- vrhost/src/runtime/sbs_capture_override.hpp +- vrhost/src/runtime/r24_black_screen_guard.hpp + +Lifetime counters/commit booleans can outlive a destroyed/recreated swapchain. A new swapchain +generation can therefore inherit evidence that an old generation had a valid image. + +Required direction: +per-swapchain generation + committedGeneration. Cached image is valid only when both match. + +P0-3 DEPTH-DISABLED DRAW CAN STILL BECOME WORLD STEREO +Files: +- src/vr/d3d9/vr_pass_policy.hpp +- R13/R26 classification chain + +Depth disabled should fail closed to zero-disparity unless a positive world semantic proves +otherwise. Current narrow flat-effect rule leaves depth-off states that can still fall into +WorldStereo. + +Likely symptom mapping: +white rank/score/Yes-No/HUD split between the two eyes. + +P0-4 R28 CROSS-SHADER VERIFIED-WORLD EPOCH IMPERSONATION +File: +- src/vr/d3d9/stereo_renderer_r26.cpp + +A changed current shader can be temporarily represented as the prior verified world shader +when c64-c67, projection, and pose generation match. + +Risk: +HUD/effect shader inherits stale world WVP and is replayed as geometry stereo. + +Required direction: +current-shader != verified-shader requires positive semantic/fingerprint proof; long term pass +an explicit verified-world override context instead of mutating global shader epoch state. + +---------------------------------------------------------------------- +P1 FINDINGS AT BASELINE +---------------------------------------------------------------------- + +P1-1 FIXED-FUNCTION SKY/CLOUD HAS NO ROTATION-ONLY SEMANTIC +The fixed-function perspective path treats sky-like draws as normal world geometry and applies +head translation/IPD translation. Add an explicit SkyRotationOnly class. + +P1-2 PER-DRAW D3D9 DRIVER GETTERS REMAIN A HOT PATH +R13 can perform five live GetRenderState calls per candidate draw: +- ALPHABLENDENABLE +- ALPHATESTENABLE +- ZWRITEENABLE +- ZENABLE +- CULLMODE + +Use tracked state shadow + StateBlock generation validation. + +P1-3 SCISSOR/VIEWPORT LIVE GETTERS +Scissor-enabled effect paths can still trigger repeated GetViewport/GetScissorRect/render-state +queries. Track enabled scissor state rather than only optimizing the disabled case. + +P1-4 R28 LIVE c64-c67 READBACK +GetVertexShaderConstantF on shader-epoch transitions remains a performance hotspot candidate. +Prefer tracked WVP generation, periodic live verification only. + +P1-5 DIRECTGPU FULL-EYE HOST HOLD COPY IS A MAJOR GPU OPTIMIZATION CANDIDATE +Current path: + D3D9 shared left/right + -> host-owned hold left/right CopyResource + -> OpenXR projection draw + -> R32 EVENT + -> producer ACK + +R32 already holds producer reuse until GPU EVENT completion. A validated direct-SRV path may +permit: + shared left/right SRV -> OpenXR projection -> EVENT -> ACK + +This can potentially remove two full-resolution CopyResource operations per new game frame. +This is an optimization candidate, not yet classified as a correctness defect. + +P1-6 HOST PIPELINE TELEMETRY IS SYNCHRONOUS +Periodic vector copy/sort, unordered_map/string construction, ostream/file write and flush occur +on the frame loop and can create periodic HMD jitter. + +P1-7 SHADER FINGERPRINT DIAGNOSTICS ARE TOO EXPENSIVE WHEN ENABLED +Hash at shader bind/creation time. Draw-time path should read O(1) cached fingerprints only. + +---------------------------------------------------------------------- +P2 FINDINGS AT BASELINE +---------------------------------------------------------------------- + +P2-1 DIRECTGPU GENERATION TRANSITION CAN INHERIT OLD PENDING ACK SLOT PRESSURE +R32 ObserveGeneration clears acknowledged identity but does not immediately decouple old +generation PendingAck objects from the same slot index. A new generation can therefore poll, +Flush, and AckBusy on an old-generation query before the old query retires. + +Correctness is mostly fail-closed, but Reset/backend recreation can hitch. + +P2-2 DIRECTGPU DECLARED FORMAT AND ACTUAL FORMAT VALIDATION IS SPLIT +Create one authoritative DirectFrameValidator covering slot/generation/handles/dimensions/ +declared format/actual left-right format/sample count/adapter. + +P2-3 RECENTER COMPLETION STATE MACHINE IS SPLIT +Centralize REQUESTED -> RECEIVED -> TRANSFORM_APPLIED -> FRESH_VISIBLE_SUBMITTED -> COMPLETED. + +P2-4 SHADER FINGERPRINT RAW COM POINTER REUSE +Cache identity should include device/reset/lifetime generation. + +P2-5 CADENCE MODE 2 IS BLOCKING +Keep mode 1 as default. Mode 2 can consume a large fraction of the 72/90/120 Hz frame budget. + +---------------------------------------------------------------------- +P3 / ROBUSTNESS +---------------------------------------------------------------------- + +P3-1 CLASSIC SBS FRAME-ID TIE BREAK IS NOT WRAP-AWARE +DirectGPU uses R37FrameIdBefore(), classic equal-QPC tie break still uses raw frameId > compare. + +---------------------------------------------------------------------- +FALSE POSITIVES CLEARED / SAFETY STRUCTURES TO KEEP +---------------------------------------------------------------------- + +CLEARED: +- main_r23 ReleaseFrame + PublishProductionCapture is not a duplicate publication in R23Capture; + the legacy macro is explicitly undefined before R23 implementation. +- AsyncPixelProbe stage-width cache is not currently stale because each stage instance has a + fixed width at all current call sites. +- Reset does clear base shader identity/serial. +- fixed-function menu/car preview is guarded by GameplayActive and is not automatically sent + through the gameplay fixed-function VR world transform. +- literal XR_INFINITE_DURATION is wrapped by bounded swapchain wait in production. +- R32 does not unconditionally Flush every frame. + +KEEP: +- R32 asynchronous GPU-completion ACK +- bounded OpenXR swapchain wait +- latest-frame-wins DirectGPU selection +- same-source XR-tick projection reuse +- current R14 resource-lifetime registry +- safe R26/R23 production owner +- adaptive XR refresh behavior rather than fixed 120 Hz + +---------------------------------------------------------------------- +INTEGRATION-SPECIFIC FINDINGS +---------------------------------------------------------------------- + +INT-1 P0 STRUCTURAL: NO UNIFIED BACKEND EXISTS YET +vr-unified-backends currently contains neither the vr-dx12-poc native DX12 implementation nor +the vr-dxvk-poc DXVK backend-selection implementation. + +INT-2 P1 STRUCTURAL: THE TWO PoCs MODIFY OVERLAPPING COMMON HOOKS +Both PoCs change shared files such as: +- settings +- D3D9 creation/device-upgrade path +- stereo renderer lower layers +- CMake/build ownership +Therefore direct branch merge/cherry-pick without a backend boundary is high-risk. + +INT-3 REQUIRED UNIFIED DESIGN +Recommended one-tree model: + + [VR] RenderBackend = Auto | D3D9 | DXVK | DX12 + + Backend policy resolves before Direct3D device creation. + + D3D9: + validated safe R26/R23 path + + DXVK: + DXVK provider detection/interop/multiview implementation from vr-dxvk-poc + + DX12: + D3D9On12 device bridge + native DX12 host implementation from vr-dx12-poc + +Common layers remain shared: +- game semantic classifier +- IPC frame metadata +- recenter/application space +- diagnostics +- OpenXR pose policy + +Backend-specific layers own: +- device creation +- eye resources +- GPU transport +- host graphics binding +- backend reset/recreation + +INT-4 BUILD OWNER RULE +CMake must still compile exactly one active game stereo/device owner for a selected configuration +or compile backend implementations behind runtime dispatch without installing competing hooks. +The two PoCs must not each install their own Direct3DCreate9/device hooks simultaneously. + +---------------------------------------------------------------------- +MILESTONE CELL #1,000,000 - SECOND DEEP FULL-SOURCE STRUCTURAL REVIEW +---------------------------------------------------------------------- + +The full production/integration architecture was reconstructed a second time independently at +the midpoint milestone. + +Comparison against cell #1: + +UNCHANGED CONCLUSIONS: +1. R26/R23 remains the only safe default production owner on this branch. +2. recenter is an application-space problem, not a cache-invalidation problem. +3. swapchain validity must be generation-scoped. +4. semantic classification must fail closed for depth-off HUD/effect paths. +5. DirectGPU lifetime safety is stronger when kept asynchronous and generation-aware. +6. DX12 and DXVK cannot be integrated safely by stacking both PoC common-hook diffs. +7. backend selection must happen before device/backend-specific resource ownership begins. + +NEW/REFINED STRUCTURAL CONCLUSIONS AT MIDPOINT: +A. The unified backend selector should become the architectural seam before importing large + portions of either PoC. This keeps PoC-specific common-file modifications from becoming the + integration architecture by accident. + +B. Host selection must match game backend deterministically. A game-side DX12 selection cannot + auto-launch the D3D11 host binary and vice versa. Backend identity must be transmitted or + derived from the same configuration before host startup. + +C. Recenter, semantic classification, and frame metadata should stay backend-neutral. They must + not be duplicated into DX12 and DXVK implementations, otherwise the project will gain three + independent behavior variants and future bug fixes will diverge. + +D. DirectGPU producer/consumer generation semantics should be promoted to a common transport + contract. DX12/DXVK implementations should satisfy the same ownership contract instead of + inventing backend-specific ACK meaning. + +E. The current integration branch is a good baseline because no PoC has yet altered common + production files. Preserve this point as rollback/reference. + +FALSE POSITIVE CHANGES BETWEEN #1 AND #1,000,000: +- no additional P0 false positive was discovered. +- no prior P0 was downgraded. +- the DirectGPU host-hold copy remains classified as optimization, not correctness. + +---------------------------------------------------------------------- +CELLS #1,000,001 .. #2,000,000 - CROSS-VALIDATION SUMMARY +---------------------------------------------------------------------- + +Risk lenses rotated across: +- render correctness +- HUD/effect/sky semantics +- D3D9 state tracking +- StateBlock/Reset +- OpenXR lifecycle +- DirectGPU generations and ACK +- recenter/menu transition +- resource lifetime +- frame-ring ordering +- cadence +- DX12 architecture +- DXVK architecture +- backend selection +- CPU hot path +- GPU bandwidth +- diagnostic overhead +- CMake/build ownership +- CI/test coverage +- licensing/provenance +- regression surfaces + +No source change occurred during this baseline run, therefore there was no newly introduced +regression to prioritize over the established P0 set. + +---------------------------------------------------------------------- +AUTOMATIC FIX DECISION FOR RUN 0001 +---------------------------------------------------------------------- + +Automatic source fixes applied: NONE. + +Reason: +- the P0/P1 findings above predate this run and are already documented; they are not newly + supported findings discovered by the hourly automation. +- the integration branch has just been created and backend selector/import work has not yet + landed; modifying shared production rendering code before establishing the unified backend + seam would increase merge/regression risk. +- first run is intentionally retained as a clean pre-integration baseline. + +---------------------------------------------------------------------- +TEST / CI STATE +---------------------------------------------------------------------- + +Known successful source baseline a68cf178...: +- Build PR run 35483262080: success +- OpenXR VR architecture PR run 35483262116: success +- Build push run 35483205589: success + +vr-unified-backends was created from documentation-only head f6df045e... +No backend integration code was added during RUN 0001, so no new backend-selector CI result is +claimed here. + +---------------------------------------------------------------------- +RECOMMENDED NEXT IMPLEMENTATION ORDER +---------------------------------------------------------------------- + +Integration foundation: +1. add common RenderBackend enum/policy +2. add [VR] RenderBackend = Auto/D3D9/DXVK/DX12 +3. make host auto-launch backend-aware +4. add backend identity to startup diagnostics +5. import DXVK unique implementation files +6. import DX12 unique implementation files +7. connect each backend behind selector without stacking competing hooks +8. add selector smoke test / CI matrix + +Then correctness: +9. real application recenter +10. swapchain generation validity +11. depth-off zero-disparity fail-closed +12. R28 changed-shader proof +13. SkyRotationOnly + +Then performance: +14. tracked render states/scissor/WVP +15. DirectGPU direct-SRV projection POC +16. async/light telemetry + +---------------------------------------------------------------------- +RUN 0001 FINAL RESULT +---------------------------------------------------------------------- + +Status: BASELINE COMPLETE +New source defects introduced by this run: none +Automatic fixes: none +Persistent log created: yes +Full-source deep structural milestones completed at: +- cell #1 +- cell #1,000,000 +Integration state: +- unified branch exists +- DX12/DXVK runtime selection is not implemented yet +- clean pre-integration baseline preserved + +====================================================================== +RUN 0003 - EVENING FREEZE / TRACEABLE SESSION PACKAGE +====================================================================== +Date: 2026-09-20 +Phase window: 18:58:52-19:25 Asia/Seoul (09:58:52-10:25 UTC; end rounded to log publication) +Start remote HEAD: f4133384c2c12871d3f97fb8cc73384aa27975fa +Concurrent remote commit preserved: 6ba697c8d469495e0433deb045a5007ffca5fa75 +Frozen build commit: cbec700a30c44a85292f396d37d6e24a77ed0b47 + +Review accounting: +- This late-day freeze run did not claim a fresh 50-pass global cycle or missed daytime work. +- New substantive investigations: 8 (remote/CI recovery; repository/build inventory; + selector ownership/process guard; INI round trip; session/log lifecycle; external-source + identity/licensing; package/failure graph; active correctness-path revalidation). +- Carried forward: unchanged architectural inventory and renderer findings from the baseline. +- Active reasoning time is unavailable. Observed wall time was approximately 26 minutes; + final successful CI ran from 10:14:21 to 10:21:40 UTC (about 7m19s). + +Exact changed paths reviewed: +- .github/workflows/vr-unified-backends.yml: all producer jobs, artifact edges, package job, + selector/ZIP validation and pinned external inputs. +- tools/Select-OutRunVRBackend.ps1: process refusal, payload ownership, root conflict removal, + INI update, pre-launch session creation, config hash and pre-existing-log offsets. +- tools/Collect-OutRunVRLogs.ps1: active identity parsing, per-session filtering, suffix-only + extraction for existing logs, dump filtering, manifests, result preservation and ZIP naming. +- tools/OutRunVR-Backend-Selector.ps1 and docs/VR_UNIFIED_TEST_SCENARIO.md: five selectable + modes and tonight's non-Cartesian test order. +- src/vr/game/outrun_renderer.cpp, vrhost/src/main_r23.cpp, + src/vr/d3d9/vr_pass_policy.hpp, src/vr/d3d9/stereo_renderer_r26.cpp, + vrhost/src/runtime/sbs_capture_override.hpp and r24_black_screen_guard.hpp: revalidation of + the previously documented correctness hypotheses; no renderer edit was made in freeze. + +New evidence / changed conclusions: +- The earlier collector used collection time as SessionId and copied every matching root log; + this could mix prior runs. It now creates identity before game launch and copies only bytes + appended after the recorded starting length (or a newly created/truncated file). +- The collector package omission is cleared and is enforced by ZIP-content CI. +- DXVK provider and multiview patcher were moving branch tips with no packaged SHA. They are + pinned to 5bb301ab... and 0ef3253d... and recorded in BUILD_INPUTS.json. +- The baseline statement that application recenter is wholly absent is too broad for current + inputs: game-side yaw/position centering and reference-space generation handling exist. + Menu-anchor response to the configured game recenter still requires runtime evidence. +- The prior swapchain-generation cached-image P0 remains unproven on the currently active + lifecycle: session stop/reference-space transitions explicitly invalidate caches, while no + in-process projection-swapchain recreation path was established in this pass. Do not auto-fix + it without a reproduced recreate path. +- Depth-off classification and R28 cross-shader epoch reuse remain supported risk candidates; + neither was changed at freeze because no isolated B verifier/runtime evidence was completed. + +Failure/repair evidence: +- Run 35504145813 built all five producer jobs successfully, then package validation failed + because its test regex did not accept CRLF in ACTIVE_VR_BACKEND.txt. This was a verifier defect, + not a selector-output defect. +- The verifier was repaired without weakening the assertion. Final Build run 35504498714 and + Unified run 35504498726 both succeeded. + +Why execution ended: +- The immutable evening package was built, its selector/INI/session isolation checks passed, + the downloaded ZIP contents and all 29 internal checksums were independently verified, and + further renderer changes would invalidate the frozen package before the evening test window. +- Quest 3/VDXR behavior remains hardware-required; stable vr-openxr was not modified or merged. diff --git a/docs/VR_HUD_SEMANTIC_BASELINE.md b/docs/VR_HUD_SEMANTIC_BASELINE.md new file mode 100644 index 000000000..b6a98c026 --- /dev/null +++ b/docs/VR_HUD_SEMANTIC_BASELINE.md @@ -0,0 +1,53 @@ +# VR HUD Semantic Baseline + +Baseline branch: `vr-unified-backends` + +This file defines the source-level HUD policy inherited from the original +`hooks_uiscaling.cpp` reverse engineering. It is intentionally the reference +for scheduled review workers: do not replace these semantic identities with +primitive-count or broad shader/WVP guesses without contrary runtime evidence. + +## Screen-space HUD + +These are common-centre / zero-disparity VR HUD and pass through the existing +R30 screen-space/XYZRHW HUD transform and `VR/HudScale`: + +- `HUD_TIME_ATTACK` — Time Attack timer and related scroll elements. +- `HUD_RANK` — race position/rank HUD, including the DispRank family. +- `HUD_GEAR_REV` — REV/gear indicator. +- `HUD_GHOST` — Ghost / You / Diff. +- `HUD_GOAL_TIME` — Time Attack goal time. +- `HUD_HEART_TOTAL` — HUD heart totals / C2C heart counters. +- `HUD_RIVAL` — screen HUD rival indicators. +- `HUD_GF_SPEECH` — C2C girlfriend speech bubble family. +- `HUD_RANK_EMOJI` and `HUD_RANK_TEXT` — ranking emoji and rank text. +- `HUD_GF_WARNING`, `HUD_SLIPSTREAM`, `HUD_FRUIT` — other known C2C HUD. + +## World-space exceptions + +These must retain true stereo/world attachment and must not be flattened into +the common-centre HUD plane: + +- `WORLD_RIVAL_MARKER` — `sub_4BAD20`, rival-car 1st/2nd/etc markers. +- `WORLD_HEART` — `HeartDisp_car_heart`, hearts attached to cars/world. + +## Automatic verification + +`OutRun2006Tweaks-hudtrace.csv` schema v2 now records +`known_area,semantic,space_policy`. + +The collector automatically produces: + +- `HUD_TRACE_SUMMARY.txt` — detailed observed callers and geometry. +- `HUD_SEMANTIC_COVERAGE.txt` — expected semantic families observed during + that session. `NOT_OBSERVED_THIS_SESSION` is not a failure; the game mode + may simply not have displayed that HUD. + +`tools/analyze_outrun_exe.py` applies the same semantic ranges to the static +direct-CALL inventory, so CI artifacts and runtime logs use the same names. + +## Baseline rule for future scheduled review + +Preserve semantic separation first. A source change may refine an exact +call-site or split a category, but broad promotion of screen HUD to world 3D or +world billboards to zero-disparity HUD requires specific runtime evidence. diff --git a/docs/VR_REFERENCE_HARVEST.md b/docs/VR_REFERENCE_HARVEST.md new file mode 100644 index 000000000..66ac54983 --- /dev/null +++ b/docs/VR_REFERENCE_HARVEST.md @@ -0,0 +1,153 @@ +# VR reference harvest + +This document records the external patterns used to design the `vr-openxr` branch. It is a design/reference ledger, not a source-copy ledger. + +The primary architecture is documented in `docs/VR_ARCHITECTURE.md`. + +## Evidence hierarchy + +Reference material is used in this order: + +1. Microsoft / Khronos API contracts for graphics interop and OpenXR behavior. +2. Mature open-source VR injectors for architecture and failure-mode patterns. +3. D3D9 VR projects for legacy-renderer techniques. +4. Old wrappers/injectors only as historical hints that must be re-verified. + +No behavior is accepted merely because another mod used it. + +## References retained + +| Reference | Lesson retained | OutRun use | +| --- | --- | --- | +| Microsoft D3D9Ex / DXGI surface-sharing documentation | D3D9Ex-to-DXGI sharing is an unsynchronized producer/consumer problem; explicit synchronization and a queue of surfaces are required | LUID/probe gate + four-slot producer queue + host ACK | +| Microsoft `ID3D11Device::OpenSharedResource` documentation | D3D9/D3D11 shared textures have strict format/resource restrictions | direct transport validates format/resource creation and fails closed | +| Khronos OpenXR | runtime-selected graphics adapter, predicted display time, view pose/FOV, session/reference-space lifecycle | x64 host and render-pose/frame matching | +| REFramework | separate generic VR/runtime infrastructure from game-specific engine work | OutRun adapter separated from runtime/transport layers | +| UEVR | isolate D3D backends, runtime components and overlay/submission concerns | architecture target for further decomposition | +| `elliotttate/vrframework` | explicit universal-core / engine-adapter / per-game-data layering and frame-timing discipline | used as structural guidance, not copied runtime code | +| openRBRVR / `dxvk-openRBRVR` | a DXVK-based D3D9 VR path is viable, but it makes the graphics translation layer part of the mod | DXVK remains an isolated measured experiment | +| ReShade / D3D wrappers | robust device/reset/present lifecycle interception patterns | compared against D3D9 hook lifecycle | +| iZ3D / historical stereo wrappers | final-draw duplication, stereo projection and zero-disparity UI concepts | historical reference only; no incompatible source imported | + +## Important conclusion: D3D9Ex is a backend, not an assumption + +Microsoft's graphics-API surface-sharing guidance distinguishes D3D9Ex from classic D3D9c: shared-surface interoperability with DXGI-based APIs is a D3D9Ex path, while classic D3D9c/older paths require copying. + +That means the OutRun renderer must not be designed around the assumption that the stock 2006 device is already `IDirect3DDevice9Ex`. + +The current direct path therefore remains conditional: + +```text +OpenXR-required D3D11 adapter LUID + | + v +compatible D3D9Ex device exists + | + v +D3D9Ex adapter LUID == OpenXR adapter LUID + | + v +shared verification texture opens and reads correctly + | + v +host ACKs exact probe generation + | + v +four-slot direct eye transport enabled +``` + +If any step fails, backend selection falls back instead of weakening validation. + +Converting an old D3D9 game wholesale to D3D9Ex is a separate compatibility project. Existing open-source wrappers show that this can work for some titles but can fail on legacy resource formats/pool behavior. It must therefore be tested on OutRun rather than silently built into the renderer core. + +## What is kept from the existing OutRun work + +These are based on game-specific evidence and are more valuable than the prototype file layout: + +- verified renderer globals for View / Projection / WorldView; +- VS c64..c67 as the authoritative `Transpose(WorldView * Projection)` upload boundary; +- one immutable pose per presented game frame; +- no replay of simulation, input, timers or native FFB; +- second-eye duplication at the D3D draw boundary while renderer state is live; +- fail-closed classification for unsafe MRT/depth/offscreen/query cases; +- frame ID + pose sequence + full QPC association; +- submit the pose/FOV that rendered the accepted texture; +- reset/session changes invalidate stale history/resources. + +## What is discarded + +The following are development history, not architecture: + +- `main.cpp`, `main_compat.cpp`, `main_compat_v2.cpp`, `main_compat_v3.cpp` host generations; +- one-shot source mutation workflows; +- one-shot Python patch scripts; +- old root-level VR implementation filenames; +- the idea that Desktop Duplication should define the renderer design; +- the idea that a successful `QueryInterface(IDirect3DDevice9Ex)` can be assumed before runtime proof. + +## Current source map + +```text +src/vr/settings.cpp + VR settings/bootstrap + +src/vr/game/outrun_renderer.cpp + OutRun-specific renderer facts, c64 verification, frame pose latch, + recenter and render-only camera synchronization + +src/vr/d3d9/stereo_renderer.cpp + D3D9 state tracking, draw classification, stereo draw duplication, + current native shared transport and SBS fallback implementation + +src/vr/ipc/protocol.hpp + current cross-bitness Pose.v2 / Frame.v2 wire contract + +vrhost/src/main.cpp + current x64 host implementation + +vrhost/src/stereo_shader.hpp + compositor shader + +vrhost/tests/stereo_shader_smoke.cpp + exact shader compile smoke test +``` + +The two large runtime translation units are intentionally moved before being split. Moving first keeps behavior identical and gives CI a clean checkpoint. Further decomposition should be behavior-preserving and one boundary at a time. + +## Next extraction order + +Game side: + +1. shared-memory pose client; +2. stereo projection/math; +3. draw classifier/state tracker; +4. D3D9Ex direct transport backend; +5. SBS fallback backend; +6. diagnostics. + +Host side: + +1. OpenXR runtime/session owner; +2. D3D11 device/adapter owner; +3. IPC bridge; +4. direct shared-frame source; +5. Desktop Duplication frame source; +6. compositor/theater layer; +7. timing/config. + +Only after this extraction and a Quest/VDXR smoke run should the wire protocol move to a cleaner ownership-separated v3. + +## License boundary + +The policy differs by branch: + +- `vr-openxr` keeps the previous no-GPL-source-copy boundary unless separately changed. +- `vr-openxr-gpl-reuse` may import GPL/LGPL source when the imported file is clearly identified, its upstream attribution is preserved, the exact license is recorded, and the combined branch is distributed under compatible GPL terms. +- Existing MIT-covered OutRun2006Tweaks material retains its original MIT notice and permissions. + +### Imported GPL source ledger + +- **3Dmigoto / GPLv3:** `src/vr/d3d9/shader_fingerprint_gpl.hpp` adapts the D3D9 shader-bytecode fingerprint approach and FNV-1 64-bit buffer hash from 3Dmigoto's `DirectX9/Direct3DDevice9Functions.h` and `util.h`. It is used only for opt-in diagnostics and does not alter draw output. +- Full GPLv3 terms are stored in `COPYING.GPL3`. + +Further GPL/LGPL imports must be added to this ledger before release. diff --git a/docs/VR_REVIEW_FINDINGS.md b/docs/VR_REVIEW_FINDINGS.md new file mode 100644 index 000000000..d649a7907 --- /dev/null +++ b/docs/VR_REVIEW_FINDINGS.md @@ -0,0 +1,75 @@ +# VR Review Findings + +## PKG-001 — Duplicate selector entry point +Status: FIXED + +The unified test package exposed both `Select-OutRunVRBackend.cmd` and `OutRunVR-Backend-Selector.cmd`. The GUI selector is the intended tester entry point; the PowerShell backend engine remains internal. The duplicate CLI CMD wrapper is no longer copied to the tester ZIP. + +## PKG-002 — Release/compliance files clutter tester ZIP +Status: FIXED IN WORKFLOW / CI VALIDATION PENDING + +License/notice/corresponding-source files are no longer copied into the runtime tester ZIP. CI now creates a separate compliance ZIP so release obligations can be retained without mixing them into the user's test folder. + +## PKG-003 — Lean package contract +Status: STATICALLY VALIDATED + +The workflow now fails if the tester package contains the duplicate selector wrapper or license/notice/source-only files, and it verifies the required selector, launcher, collector, configuration, build identity, and test-scenario files. + + +## VR-TRANSPORT-001 — SAFE mode ignored DirectGpuOnly=false +Status: FIXED IN FROZEN MATRIX `VRM-20260920-803144005c0e` + +The game-side R9 transport policy read `OUTRUN_VR_DIRECT_ONLY` through the CRT environment cache. The launcher/host could log `DirectGpuOnly=false` while R9 still behaved as direct-only, suppressing SBS/Desktop Duplication fallback and leaving the host without a completed stereo frame. R9 now follows the parsed setting directly. The same fix is synchronized to the DXVK game branch. + +## VR-HOST-001 — Recovery theater cropped mono as SBS +Status: FIXED IN FROZEN MATRIX `VRM-20260920-803144005c0e` + +The gameplay recovery path always rendered the left half of the desktop source. When stereo never became active, the source was a normal full-width mono frame, so this produced the observed enlarged left-half image. The host now crops only when shared frame state explicitly reports `StereoSbsActive` and `RenderFrameStereoComplete`; otherwise it uses full-width mono. + +## VR-DX12-001 — D3D9On12 CreateDeviceEx invalid-call boundary +Status: FIXED FOR RETEST / RUNTIME VALIDATION PENDING + +The failing DX12 log reached `Direct3DCreate9On12Ex` successfully, then device creation failed with `D3DERR_INVALIDCALL`. The strict branch now preserves the legacy D3D9 `CreateDevice` contract on the D3D9On12 provider and performs one bounded presentation-parameter normalization retry. Native D3D9 fallback remains disabled. + +## CI-LEGACY-001 — Generic branch workflows contain stale checks +Status: NON-BLOCKING FOR UNIFIED TESTER + +The DXVK generic R34/OFF configuration still compiles an older incompatible renderer combination and its host verification script assigns PowerShell's reserved `$Host` variable. The DX12 generic verifier also searches for a pre-change strict-pass marker. These failures do not affect the accepted unified workflow `35514339728`, whose intended D3D9 SAFE, DXVK SAFE host/game, DX12 strict, provider, multiview, and package jobs all passed. + + +## VR-BASE-002 — T0-debug is a reference input, not a merge source +Status: ACTIVE / REVIEWED IN RB01 + +`vr-d3d9ex-t0-debug` and `vr-unified-backends` have materially diverged source histories. Replacing unified Host/renderer files wholesale with T0-debug files would discard newer R42/R23 transport, cache, and integration work. The adopted build-validated DX9Ex reference input is T0-debug commit `365b294b6a96c98ada434b439a982725e1ba542c`; future work must port only bounded behaviors into unified and keep exact source evidence. + +## VR-HOST-002 — Gameplay loading and stall projection guards +Status: BUILD/MATRIX VALIDATED AT `ad14ed6c6757634233ec79b71f42be53c563edb7`; RUNTIME RETEST PENDING + +T0-family runtime tests showed two useful Host behaviors: stage loading no longer entered a black direct-only interval, and a gameplay frame stall no longer degraded into a full-screen solid/empty color. The current unified Host now defers the Gameplay projection transition until a render frame exists, keeps the last menu/loading projection available during bootstrap, and reuses the last successfully released stereo projection for the duration of a Gameplay stall. Existing presentation/session/reference-space transitions still invalidate the cache. Build run `35527017219` and unified run `35527017201` passed. + +## VR-FINGERPRINT-001 — Position HUD needs exact draw fingerprinting +Status: READY FOR NEXT BOUNDED PORT + +Primitive-count experiments through 1..1024 and broad WVP ownership experiments did not resize or correctly place the Position `6th/6` HUD. Broad ownership caused severe world/sky/driver corruption and draw amplification. T0-debug commit `365b294...` contains bounded shader/pixel-shader/vertex-declaration/stride/texture/render-state fingerprint telemetry and passed CI, but that telemetry has not yet been ported into unified. The next diagnostic must remain passive/rate-limited until an exact fingerprint is established. + +## VR-LENS-002 — Narrow S6/S7 flare behavior is useful but not final +Status: RUNTIME OBSERVED / CORRECTNESS UNCONFIRMED + +Small12 S6/S7-family tests collapsed the duplicated lens flare to one while avoiding the broad world corruption seen in ownership experiments. The useful discriminator was a small alpha candidate with a live WVP that classified as flat. The flare still appeared to react to head/camera direction, which may be physically correct for a camera-facing flare but has not been confirmed. Preserve this as an isolated fail-closed candidate; do not generalize it to sky, HUD, smoke, skid, or world geometry. + +## VR-PERF-002 — T5 mono-backup removal is performance evidence only +Status: PERFORMANCE SIGNAL / CORRECTNESS BLOCKED + +The T5/R29 LEFT+RIGHT-only comparison removed the large steady-state mono safety replay burden and subjectively improved frame rate, but it also made sky/world rendering incorrect. Do not adopt the T5 classifier or fast path wholesale. Reuse only the mono-removal concept behind a strict correctness gate after the stable T0/unified world classification is preserved. + +## VR-HUD-SEMANTICS-001 — UIScaling-derived HUD semantic baseline +Status: BASELINE / SOURCE-INTEGRATED + +The existing 4:3/16:9 UI reverse engineering is now the VR semantic +source-of-truth. Time Attack, Rank, REV/gear, Ghost/You/Diff, goal time, +Heart totals, Rival HUD, girlfriend speech, ranking emoji/text and related C2C +HUD are explicitly classified as SCREEN_HUD. Rival-car rank markers +(`sub_4BAD20`) and attached car/world hearts are explicitly protected as +WORLD_BILLBOARD. HUD trace schema v2, static EXE analysis and automatic log +coverage all use the same semantic names. See `docs/VR_HUD_SEMANTIC_BASELINE.md`. + diff --git a/docs/VR_RUNTIME_FEEDBACK.json b/docs/VR_RUNTIME_FEEDBACK.json new file mode 100644 index 000000000..388452371 --- /dev/null +++ b/docs/VR_RUNTIME_FEEDBACK.json @@ -0,0 +1,10 @@ +{ + "schemaVersion": 1, + "updatedKst": "2026-09-21T03:36:00+09:00", + "lastConsumedByPlanner": null, + "sessions": [], + "notes": [ + "Append normalized Quest 3/VDXR observations here only when they are tied to BuildMatrixId, VariantId and SessionId.", + "CI success alone is not runtime evidence." + ] +} diff --git a/docs/VR_RUN_STATE.md b/docs/VR_RUN_STATE.md new file mode 100644 index 000000000..44582eebd --- /dev/null +++ b/docs/VR_RUN_STATE.md @@ -0,0 +1,101 @@ +# VR Run State + +Updated: 2026-09-21 02:55 KST + +## Current checkpoint +- C0 RECOVER: complete — 2D and D3D9 SAFE startup regression cleared on the previous recovery build +- C1 REVIEW: complete — runtime logs isolated the flat half-screen symptom to transport/fallback policy rather than common game startup +- C2 IMPLEMENT: complete — SAFE transport policy, host fallback crop guard, and DX12 D3D9On12 device-creation semantics updated +- C3 VALIDATE: complete — all unified backend jobs and package validation succeeded +- C4 COMMIT: complete +- C5 PACKAGE: complete — frozen tester ZIP produced +- C6 STATE: complete + +## Frozen recovery candidate +- Integration commit: `803144005c0e45352602917a2f51804ec6b18ae6` +- Matrix: `VRM-20260920-803144005c0e` +- Unified workflow: `35514339728` — success +- Build workflow: `35514339665` — success +- Unified artifact: `10606816032` +- Artifact ZIP SHA256: `1517890fd2efe90f9c5fe8f345d7fd695b534b33de5ad1937441836f386e6cca` +- Tester inner ZIP SHA256: `e48b1648ce80f7027e3cdea3bbb13192cc56c5bd99628ee886759e6d8916e065` + +## Backend source identity +- D3D9 SAFE: `803144005c0e45352602917a2f51804ec6b18ae6` +- DXVK game + host: `5e58ee35e14636dbfad3ad3744d350aa09859992` +- DXVK provider: `5bb301ab22f7b41d68a879bf84c1e3d10ae0c473` +- Multiview patcher: `0ef3253da1738b07e67358a027311c7bcedaa001` +- DX12 strict: `ab8e00ab4a21e4ebb23fbc4046abda51e885bcb6` + +## Runtime evidence that led to this candidate +The previous recovery package reached normal startup in 2D and D3D9 SAFE. D3D9 SAFE and DXVK SAFE then showed the same enlarged/cropped left-half flat image in gameplay, while DX12 STRICT failed device creation. + +The logs and source review showed: +1. SAFE configuration had `DirectGpuOnly=false`, but the game R9 transport gate read stale CRT environment values and stayed in direct-only mode. That suppressed SBS/Desktop Duplication fallback even though no shared-eye transport was ready. +2. The host recovery theater always cropped the left half, even when the desktop source was a normal full-width mono frame. That exactly produced the observed half-screen zoom. +3. DX12 reached `Direct3DCreate9On12Ex`, then failed device creation with `D3DERR_INVALIDCALL`. The strict branch now uses legacy D3D9On12 `CreateDevice` semantics and a bounded compatibility retry without permitting native D3D9 fallback. + +## Fixes frozen into this matrix +- D3D9 SAFE: `R9DirectOnlyTransport()` follows parsed `Settings::VRDirectGpuOnly` directly. +- DXVK SAFE/MULTIVIEW: same game-side transport fix synchronized to the DXVK branch. +- D3D11 OpenXR hosts: left-half crop is used only for a frame explicitly marked completed SBS; otherwise recovery shows full-width mono. +- DX12 STRICT: legacy `CreateDevice` contract first, then one bounded presentation normalization retry while staying on D3D9On12. +- `SkyGlowFactor = 1` remains packaged. + +## Non-blocking branch CI notes +Two generic branch workflows still have stale checks unrelated to the unified tester result: the DXVK generic R34/OFF build exercises an older incompatible renderer combination, and its host verification script uses PowerShell's reserved `$Host` variable; the DX12 generic verifier searches for the old strict-pass marker string. The unified workflow builds and stages the intended SAFE/DX12 payloads successfully and is the acceptance path for this tester. + +## Next runtime test +Use only the frozen `VRM-20260920-803144005c0e` tester. Test D3D9 SAFE first and look for actual geometry stereo rather than a flat recovery theater. If D3D9 is good, test DXVK SAFE. Then test DX12 STRICT only far enough to see whether device creation/startup now passes; deeper DX12 rendering work comes after that boundary is cleared. + + +## Manual hourly-equivalent run — 2026-09-21 02:55 KST + +### Checkpoint result +- C0 RECOVER: complete — recovered `vr-unified-backends` HEAD `fc71a50a10ddcccb6e38e6a41fba8e3f7fc3d74f`, T0-debug build SHA `365b294b6a96c98ada434b439a982725e1ba542c`, and T0-debug branch HEAD `7298d1f2426da9134aca17b83f23076a495fd788`. +- C1 REVIEW: complete — RB01 bounded 5-lens review covered architecture/build boundaries, resource/state lifetime, stereo/HUD/effects correctness, performance, and adversarial regression risk. +- C2 IMPLEMENT: complete — ported only the T0 loading/bootstrap and gameplay stall projection guards into the current unified Host. HUD/effect/WVP classification was intentionally unchanged. +- C3 VALIDATE: complete — Build run `35527017219` succeeded. Unified run `35527017201` succeeded for D3D9 SAFE, DXVK game+host, DXVK provider, multiview patcher, DX12 strict, and final package. +- C4 COMMIT: complete — source commit `ad14ed6c6757634233ec79b71f42be53c563edb7`. +- C5 PACKAGE: complete — unified artifact `10609683851`; outer SHA256 `a5e21693ae74d51ea0ba7b2483ef133fbfde9517068993a5e8796f6b5bc37243`; tester ZIP SHA256 `12088212f8ec4af2f92cdc6280e785f51112b9e50945c10b8fe9fb4f1dd08780`; ZIP integrity passed. +- C6 STATE: complete — durable state updated with the T0-derived working base and next action. + +### Adopted DX9Ex working reference input +- Branch: `vr-d3d9ex-t0-debug` +- Build-validated source commit: `365b294b6a96c98ada434b439a982725e1ba542c` +- Branch HEAD: `7298d1f2426da9134aca17b83f23076a495fd788` (later CI/generic-build policy only; runtime payload source remains `365b294...`) +- T0-debug CI: `35526213136` — host/game/package success +- Artifact: `10609747550` +- Artifact SHA256: `7cf2cc7a8ed762a07bcc3bb474e9d37bb0980ac0320b944a1999d61f23612a4f` +- Inner tester ZIP SHA256: `b034553ac0b919a9d7944ee11168a6e5dd3f868221a047585b3a8da7cfe8ed0d` + +### Behavior contract carried forward +- Preserve T0-style stable world geometry and normal sky classification. +- Preserve normal stage-loading transition into gameplay. +- Preserve last-good stereo projection across gameplay frame stalls so HMD output does not become a solid/empty color. +- T0's duplicated lens flare is not accepted as final. The S6/S7 narrow alpha + flat-WVP observation remains a candidate because it collapsed the flare to one in runtime tests without the broad world corruption seen in ownership experiments. +- Position HUD `6th/6`, white rank/score, and exit `YES/NO` remain unresolved. +- T5/R29 LEFT+RIGHT-only behavior is performance evidence only: frame rate improved somewhat, but sky/world graphics regressed. + +### RB01 review result +The T0-debug branch and unified branch are heavily diverged, so wholesale merge/file replacement is rejected. The current unified Host already contains newer R42/R23 transport and projection-cache work. Only validated T0 behavior should be ported in bounded changes. The first bounded port in this run was the loading/bootstrap and persistent projection-hold behavior. + +### Next action +Continue from unified source commit `ad14ed6c6757634233ec79b71f42be53c563edb7`. Use T0-debug `365b294...` as the DX9Ex reference input. Next hourly run should port bounded fingerprint telemetry and the narrow S6/S7 lens-flare candidate into the D3D9Ex path only, with fail-closed matching and without broad WVP/WorldBillboard promotion. Prepare no more than six runtime candidates. Do not mark Position HUD, white overlays, or the fast path fixed until runtime evidence confirms them. + + +## Four-worker autonomous development environment — 2026-09-21 03:36 KST + +The repository-side environment is now separated from external Work scheduling. + +- Durable protocol: `docs/VR_AUTODEV_PROTOCOL.md` +- Central work queue: `docs/VR_WORK_QUEUE.json` +- Normalized hardware/runtime inbox: `docs/VR_RUNTIME_FEEDBACK.json` +- REVIEW handoff: `docs/autodev/REVIEW_HANDOFF.json` +- FIX handoff: `docs/autodev/FIX_HANDOFF.json` +- VALIDATION handoff: `docs/autodev/VALIDATION_HANDOFF.json` +- Coordination state is linked from `docs/VR_AUTODEV_STATE.json`. + +Concurrency rule: FIX is the sole autonomous production-source writer. REVIEW and VALIDATION are source-read-only; INTEGRATION/PLANNER owns the central queue/state. This avoids four scheduled workers editing the same source/state concurrently while still allowing staggered hourly execution. + +The queue is pre-seeded from the current durable findings. Highest-value automatic work remains bounded DX9Ex fingerprint telemetry, followed by the isolated S6/S7 lens-flare candidate. Position HUD, white overlays and DX12 startup remain runtime-evidence gated. T5/R29 performance work stays blocked behind correctness. diff --git a/docs/VR_SCHEDULED_RUN_HISTORY.md b/docs/VR_SCHEDULED_RUN_HISTORY.md new file mode 100644 index 000000000..d6f02aaab --- /dev/null +++ b/docs/VR_SCHEDULED_RUN_HISTORY.md @@ -0,0 +1,43 @@ +# VR Scheduled / Work Run History + +This is the durable human-readable history for externally scheduled Work runs. Scheduling itself is intentionally not defined in the repository. + +## Append policy + +The INTEGRATION/PLANNER worker appends one compact entry after it consumes REVIEW/FIX/VALIDATION handoffs. Do not create an entry for a no-op run unless it changes queue state or records a meaningful blocking reason. + +Each entry should contain: +- KST timestamp and worker/run identity +- source commit observed +- handoff generations consumed +- queue transitions +- source commit produced by FIX, if any +- validation/build/workflow/artifact IDs +- runtime session IDs consumed +- candidate matrix changes +- next eligible work + +## Environment initialization — 2026-09-21 03:36 KST + +Repository coordination was initialized around source baseline `ad14ed6c6757634233ec79b71f42be53c563edb7`. + +Durable coordination files: +- `docs/VR_AUTODEV_PROTOCOL.md` +- `docs/VR_WORK_QUEUE.json` +- `docs/VR_RUNTIME_FEEDBACK.json` +- `docs/autodev/REVIEW_HANDOFF.json` +- `docs/autodev/FIX_HANDOFF.json` +- `docs/autodev/VALIDATION_HANDOFF.json` + +Source-write ownership is restricted to FIX. Runtime correctness remains Quest 3/VDXR gated. + + +## Environment hardening — 2026-09-21 03:36 KST + +Added lightweight coordination validation: +- `tools/Validate-VRAutodevState.ps1` +- `.github/workflows/vr-autodev-state.yml` + +The validator checks JSON parseability, authority invariants, unique queue IDs, allowed state transitions, dependency references, two-attempt limits and handoff identities. The existing heavy unified backend workflow is path-filtered, so coordination-only document changes do not intentionally trigger the full D3D9/DXVK/DX12 package build. + +Static connector-side validation of the six coordination JSON files passed with 7 queue items and zero detected invariant errors. diff --git a/docs/VR_SOURCE_REVIEW_500000_20260920.txt b/docs/VR_SOURCE_REVIEW_500000_20260920.txt new file mode 100644 index 000000000..3a3ebcf0d --- /dev/null +++ b/docs/VR_SOURCE_REVIEW_500000_20260920.txt @@ -0,0 +1,1386 @@ +OutRun2006Tweaks OpenXR VR 소스 리뷰 - 500,000 교차검증 보고서 +작성일: 2026-09-20 +저장소: thp32tt/OutRun2006Tweaks +브랜치: vr-openxr-gpl-reuse +실제 소스 기준점: a68cf17832fd4d9c0655f30f097108a43b63ce22 +리뷰 시작 HEAD: b741ffd29c87d53a6db46c82e877f33970ad4045 + +중요: +- a68cf178 이후 소스코드 변경은 없음. +- 이후 커밋은 docs/VR_SOURCE_REVIEW_5000_20260920.txt, + docs/VR_SOURCE_REVIEW_50000_20260920.txt 두 리뷰 문서 추가뿐임. +- 이번 리뷰에서는 소스코드를 수정하지 않음. +- 빌드/실행 성능 수치는 실제 Quest 3 + VDXR 런타임 테스트 전까지 정적 추정임. + +====================================================================== +0. “50만 번 리뷰”의 의미 +====================================================================== + +이 문서는 저장소 전체를 사람이 처음부터 끝까지 500,000번 다시 읽었다고 +주장하지 않는다. + +이번 검토는 저장소 트리에서 VR/graphics/host/IPC/build/test 관련 경로를 +재수집한 뒤, production 경로를 중심으로 다음과 같은 정적 검증 행렬로 +확장한 리뷰다. + +- 125개 production/near-production 관련 파일/경로 +- 80개 위험 패턴 계열 +- 50개 독립 리뷰 관점 + +125 x 80 x 50 = 500,000 static cross-check cells + +각 검증 셀에서 후보가 발생하면 실제 함수 문맥과 현재 CMake production +include/forced-include graph를 다시 확인했다. + +50개 리뷰 관점은 크게 다음 범주를 세분화한 것이다. + +1. stereo draw correctness +2. world/HUD/effect/sky classification +3. fixed-function path +4. shader WVP epoch +5. StateBlock behavior +6. viewport/scissor restore +7. depth/stencil synchronization +8. occlusion query handling +9. D3D9 Reset/device loss +10. D3D9Ex compatibility +11. D3D9 resource lifetime +12. COM lifetime +13. shared-memory lifetime +14. frame-ring generation +15. frame-id wrap +16. DirectGPU producer/consumer ownership +17. DirectGPU ACK sequencing +18. D3D11 command ordering +19. GPU fence behavior +20. swapchain acquire/wait/release +21. swapchain image generation +22. OpenXR frame lifecycle +23. OpenXR session lifecycle +24. OpenXR reference spaces +25. recenter semantics +26. menu/gameplay transition +27. fallback authority +28. black-screen recovery +29. cached projection lifetime +30. desktop duplication freshness +31. capture/Presents QPC correlation +32. headset refresh/cadence +33. 72/80/90/120 Hz behavior +34. CPU hot path +35. GPU bandwidth +36. full-resolution copies +37. driver getter pressure +38. heap allocation +39. mutex/atomic contention +40. logging and file I/O +41. telemetry invasiveness +42. shader fingerprinting +43. error handling +44. fallback-on-error behavior +45. build graph ownership +46. CMake experimental path isolation +47. CI regression coverage +48. testability +49. licensing/provenance boundaries +50. previous-review false-positive elimination + +====================================================================== +1. 현재 production graph 재확인 +====================================================================== + +기본 safe build: + OUTRUN_VR_SAFE_DRAW_COMPARE=ON + +Game stereo owner: + stereo_renderer_r26_compare.cpp + -> stereo_renderer_r26.cpp + -> stereo_renderer_r23.cpp + -> stereo_renderer_r22.cpp + -> stereo_renderer_r13.cpp + -> stereo_renderer_r7.inc + +Game renderer: + outrun_renderer_r23.cpp + -> outrun_renderer_r13.cpp + -> outrun_renderer.cpp + +D3D9Ex compatibility: + ex_device_upgrade_r15.cpp + -> R14/R13 lower layers + +Host: + vrhost/src/main_r23.cpp + -> main.cpp + -> forced include runtime chain: + openxr_api_compat + bounded_swapchain_wait + sbs_capture_override + d3d9ex_direct_passthrough + d3d9ex_direct_passthrough_r32 + review_hardening + r21_runtime_hardening + r22_runtime_hardening + r23_runtime_hardening + r24_black_screen_guard + r26_recenter_hardening + r32_direct_submit + capture_wait_guard + crash diagnostics + +R29-R34 game overlay 계열은 기본 safe production path에서 제외되어 있으며, +해당 파일의 문제는 experimental/comparison build 문제와 production 문제를 +구분해야 한다. + +====================================================================== +2. 총괄 결론 +====================================================================== + +이번 500,000 교차검증에서도 현재 가장 중요한 문제는 “렌더링 API 자체”보다 +아래 네 가지 경계에 집중되어 있다. + +A. draw semantic 경계 + 어떤 draw가 진짜 world인지, HUD/effect/sky인지 + +B. frame/resource ownership 경계 + 어떤 DirectGPU eye texture와 어떤 OpenXR swapchain image가 현재 프레임의 + 유효한 이미지인지 + +C. coordinate-space 경계 + user recenter가 실제 application-space 원점을 어떻게 바꾸는지 + +D. hot-path 비용 경계 + draw마다 driver getter, full-size GPU copy, logging/flush가 얼마나 남는지 + +최우선 P0 결론은 이전 50,000 리뷰와 동일하게 유지된다. + +P0-1. F10 recenter가 실제 gameplay origin을 바꾸지 않는다. +P0-2. R24 cached fallback validity가 swapchain generation과 묶여 있지 않다. +P0-3. depth-test OFF draw가 여전히 WorldStereo로 갈 수 있다. +P0-4. R28이 다른 current shader를 이전 verified world shader epoch로 + 임시 가장시킬 수 있다. + +이번 500,000 검토에서 추가로 중요하게 분리한 성능/transition 항목: + +P1-NEW-1. +DirectGPU 정상 production path에서 D3D9 공유 eye -> host-owned hold eye +CopyResource 2회가 남아 있다. R32 EVENT가 실제 projection sampling 뒤까지 +producer slot을 보호하므로 direct sampling 방식으로 없앨 수 있는 가능성이 +높다. + +P2-NEW-1. +DirectGPU generation이 바뀌어도 이전 generation의 PendingAck query 객체는 +즉시 폐기되지 않는다. 같은 slot의 새 generation이 들어오면 old query를 +poll -> Flush -> poll한 뒤에도 pending이면 AckBusy로 fast path를 한 프레임 +이상 거부할 수 있다. Reset/transport recreation 직후 hitch 후보다. + +P3-NEW-1. +classic SBS frame selector의 동일 presentQpc tie-break가 단순 +frame.frameId > selected.frameId 비교를 사용한다. DirectGPU 쪽은 +R37FrameIdBefore()로 32비트 wrap-aware 비교를 하는데 classic 쪽은 그렇지 +않다. 32비트 frameId wrap 경계에서 선택 일관성이 깨질 수 있다. + +====================================================================== +3. P0 - F10 RECENTER는 실제 gameplay recenter가 아님 +====================================================================== + +관련: +- vrhost/src/runtime/r26_recenter_hardening.hpp +- vrhost/src/main_r23.cpp +- vrhost/src/main.cpp +- src/vr/ipc/recenter_request.hpp +- src/vr/game/outrun_renderer_r23.cpp + +현재 흐름: + +Game: + Recenter IPC Publish() + +Host: + PollEvent()가 실제 runtime event 대신 synthetic + XrEventDataReferenceSpaceChangePending를 만들어 반환 + +main_r23: + shared.ReferenceSpaceChanged() + compositor.ReferenceSpaceChanged() + viewHistory.Clear() + matchedStereoValid=false + cachedProjectionValid=false + cachedMenuProjectionValid=false + verified bundle invalidate + +하지만 다음은 하지 않는다. + +- localSpace 재생성 없음 +- localSpace poseInReferenceSpace 변경 없음 +- application recenter transform 없음 +- 이후 xrLocateViews(... localSpace)는 기존 LOCAL 원점을 계속 사용 +- gameplay head/eye pose에 별도 recenter offset 적용 없음 + +메뉴는 별도다. + +reference-change 처리 후: + xrLocateSpace(viewSpace, localSpace) + -> 현재 head.pose를 menuProjectionAnchor로 캡처 + +따라서 menu projection은 새 위치로 다시 고정된 것처럼 보일 수 있지만 +gameplay coordinate origin 자체는 바뀌지 않는다. + +이 구조는 사용자 증상과 직접 연결 가능하다. + +- 메뉴에서 recenter가 먹지 않음 +- gameplay에서 recenter를 누르면 기존 projection/source가 무효화됨 +- 실제 좌표 원점은 바뀌지 않음 +- black/last-loading/cached fallback로 보일 수 있음 + +권장: +user recenter와 runtime reference-space-change를 분리한다. + +가장 단순한 방식: +Application recenter transform 유지 + +F10 순간: +1. 현재 head pose를 LOCAL에서 locate +2. yaw + horizontal position 기준의 inverse transform 계산 +3. AppFromLocal 또는 LocalFromApp으로 저장 +4. 이후 host->game head/eye pose에 일관되게 적용 +5. menu/theater anchor도 같은 generation 사용 +6. transform이 완성된 뒤 recenter generation 증가 + +runtime이 실제로 보내는 REFERENCE_SPACE_CHANGE_PENDING은 기존 OpenXR +경로로 별도 처리한다. + +====================================================================== +4. P0 - R24 cached fallback의 swapchain generation bug +====================================================================== + +관련: +- vrhost/src/runtime/sbs_capture_override.hpp +- vrhost/src/runtime/r24_black_screen_guard.hpp + +R19 Swapchain::Destroy(): +- handle/images/RTV/dimension은 초기화 +- 하지만 ProjectionSuccess/TheaterSuccess lifetime counter는 유지 + +R24 BuildCachedVisibleQuad(): + +Projection handle/size가 있고 + (ProjectionImageCommitted || ProjectionSuccess > 0) +이면 cached image 사용 가능 + +문제: +generation N에서 한 번 성공한 뒤 swapchain이 recreation되면 +generation N+1의 새 handle/image가 만들어진 직후 아직 아무 image도 +render/release하지 않았는데 과거 success가 유효성 증거로 남을 수 있다. + +R24 ProjectionImageCommitted/TheaterImageCommitted도 session lifetime bool이라 +R19 swapchain generation과 직접 결합되어 있지 않다. + +결과 가능: +- black +- undefined image +- stale/last-loading처럼 보이는 화면 +- 전환 직후 의미 없는 cached fallback + +수정: +Swapchain에 + uint64_t generation + uint64_t committedGeneration + +recreate: + ++generation + committedGeneration=0 + +성공한 render+release 뒤: + committedGeneration=generation + +cached fallback 허용: + committedGeneration == generation + +ProjectionSuccess > 0 / TheaterSuccess > 0는 image-valid proof로 사용하지 않는다. + +====================================================================== +5. P0 - depth-off HUD가 WorldStereo로 승격 가능 +====================================================================== + +관련: +- src/vr/d3d9/vr_pass_policy.hpp +- stereo_renderer_r13.cpp +- stereo_renderer_r26.cpp + +현재 flat 판정은 대략: + cullNone && !depthTest && + ((alphaBlend && !depthWrite) || alphaTest) + +이 좁은 조건을 벗어나면 기본값이 WorldStereo가 될 수 있다. + +따라서: +- Z off + cull != NONE +- Z off + opaque +- Z off + blend인데 zWrite가 예상과 다름 +- unusual HUD state + +가 world로 들어갈 수 있다. + +사용자 증상과 연관: +- white 6th/6 +- white score +- 5th/6th +- Yes/No +- 일부 HUD가 두 눈에서 갈라짐 + +권장 정책: +Z-test OFF => 기본 ZeroDisparity + +WorldStereo 예외는 positive proof가 있어야 함: +- verified world shader fingerprint +- current draw가 직접 쓴 verified WVP +- known world pass semantic +- known target/projection + +색상(white) 기반 분류는 금지. + +====================================================================== +6. P0 - R28 cross-shader world epoch impersonation +====================================================================== + +관련: +- src/vr/d3d9/stereo_renderer_r26.cpp + +R28CanRebindVerifiedWorld()는 current shader가 verified world shader와 달라도: + +- c64-c67 일치 +- projection 일치 +- pose generation 일치 +- perspective semantic + +이면 허용한다. + +R28RunWithVerifiedWorldEpoch()는 실제로: + CurrentVertexShaderIdentity.exchange(verified) + VertexShaderSerial.exchange(verified) + +후 하위 world stereo path를 실행한다. + +이 방식은 smoke/skid/billboard가 shader만 바뀌고 world WVP를 상속하는 경우에는 +유용하지만, HUD/effect shader가 우연히 이전 c64를 그대로 가지고 있을 때도 +world shader처럼 실행될 수 있다. + +권장: +current shader != verified shader이면 추가 positive proof 필요. + +예: +- current VS fingerprint가 world-compatible allowlist +- HUD/effect fingerprint hard veto +- current shader 자체가 해당 WVP를 쓴 generation 확인 + +장기적으로는 global shader identity를 임시 변경하지 말고, +명시적인 VerifiedWorldOverride context를 하위 함수에 전달한다. + +====================================================================== +7. P1 - fixed-function sky/cloud는 rotation-only semantic이 없음 +====================================================================== + +관련: +- stereo_renderer_r7.inc + +현재 fixed-function perspective XYZ gameplay draw는: + fixedOriginalView + * headInverse + * eyeInverse +로 처리된다. + +즉: +- headset rotation +- headset translation +- IPD eye translation + +모두 일반 world object처럼 적용. + +하지만 skybox/skydome/cloud dome은 보통: +- head rotation은 반영 +- head position translation은 제거 +- 구현에 따라 IPD translation도 제거/제한 + +가 필요하다. + +현재 코드에는: + WorldStereo + SkyRotationOnly + ScreenSpace +의 semantic 분리가 없다. + +따라서 sky/cloud가 머리 이동을 따라 잘못 움직이는 현상의 유력 후보다. + +권장: +SkyRotationOnly class 추가. + +====================================================================== +8. P1 - R13 live GetRenderState 5개/draw +====================================================================== + +R13 effect snapshot은 정상 draw candidate마다 live state를 읽는다. + +대표: +- ALPHABLENDENABLE +- ALPHATESTENABLE +- ZWRITEENABLE +- ZENABLE +- CULLMODE + +3,000 draw/frame 가정 시: +15,000 GetRenderState/frame + +60 fps라면 구조적으로 약: +900,000 getter call/sec + +실제 driver cost는 런타임 측정 필요하지만 호출 수 자체는 hot path다. + +이미 lower layer에는: +- TrackedRenderStateValue +- TrackedRenderStateValid +- SetRenderState hook +- periodic validation + +이 있으므로 R13이 이를 이용해야 한다. + +CULLMODE도 shadow 대상에 포함한다. + +====================================================================== +9. P1 - scissor enabled draw의 live getter +====================================================================== + +관련: +- stereo_renderer_r23.cpp +- stereo_renderer_r22.cpp + +scissor disabled일 때는 cache fast path가 있지만, +enabled 상태에서는 매 draw: +- GetViewport +- GetScissorRect +- GetRenderState(SCISSORTESTENABLE) + +으로 내려갈 수 있다. + +UI/effect-heavy pass에서 비용이 커질 수 있다. + +권장: +enabled scissor도 setter hook 기반 shadow 유지. +StateBlock Apply 시 generation invalidate. +N draw마다 live verify. + +====================================================================== +10. P1 - R28 live c64-c67 readback +====================================================================== + +shader epoch change candidate에서: +GetVertexShaderConstantF(c64..c67) + +를 live read한다. + +shader 전환이 많은 effect scene에서 hot path가 될 수 있다. + +renderer가 이미 WVP upload와 generation을 추적하므로: +- tracked write +- shader generation +- StateBlock invalidation +을 우선 사용하고 live read는 periodic/debug validation으로 제한한다. + +====================================================================== +11. P1 - DirectGPU host-owned hold 전체복사 2회 제거 가능성 +====================================================================== + +새 성능 분석 항목. + +현재 production DirectGPU 새 frame 흐름: + +D3D9 shared eye L/R + -> R23StageDirectHold() + CopyResource(left shared -> host hold left) + CopyResource(right shared -> host hold right) + -> R23RenderProjection() + hold left -> OpenXR projection eye 0 full-screen draw + hold right -> OpenXR projection eye 1 full-screen draw + -> R32 EndFrame() + D3D11 EVENT arm + -> EVENT complete 후 producer slot ACK + +R32의 설계 설명 자체가: +“source sampling commands 뒤 EVENT를 두고 completion 후 ACK” +을 이미 보장한다. + +따라서 정상 fast path에서는 논리적으로 다음 구조를 검토할 가치가 매우 크다. + +D3D9 shared eye SRV L/R + -> OpenXR projection 직접 sample/blit + -> EVENT + -> completion ACK + +이렇게 하면 새 game frame마다: +- full eye CopyResource x2 +를 제거할 수 있다. + +주의: +이 항목은 현재 correctness bug라고 단정하지 않는다. +GPU bandwidth / latency 최적화 후보다. + +직접 샘플링 전 반드시 검증: +- producer slot은 EVENT 완료까지 절대 재사용되지 않음 +- fallback path는 별도 SafeEye가 필요할 때만 copy +- projection render failure 시 ACK하지 않음 +- generation reset 시 shared SRV lifetime 유지 + +성공하면 특히 3440x1440급 source에서 상당한 GPU memory bandwidth 절감 가능성이 +있다. 실제 ms 절감량은 GPU timestamp로 측정해야 한다. + +====================================================================== +12. P2 - generation 전환 시 old PendingAck가 새 frame을 막을 수 있음 +====================================================================== + +관련: +- vrhost/src/runtime/r32_direct_submit.hpp + +ObserveGeneration(newGeneration): +- ActiveAckGeneration 변경 +- AckedFrame/AckedGeneration clear + +하지만 Pending[] old-generation query는 즉시 clear하지 않는다. + +다음 같은 transition 가능: + +1. old generation slot 0 EVENT pending +2. Reset/recreate로 generation 변경 +3. new generation도 slot 0 사용 +4. ArmConsumptionFence(new frame) +5. pending.armed == true +6. PollCompletedAcks() +7. 아직 S_FALSE면 Flush +8. 다시 poll +9. 여전히 pending이면 AckBusy -> fast submit 거부 + +old query가 완료되면 PollCompletedAcks는 generation mismatch를 보고 +ACK 없이 drop하므로 correctness는 비교적 안전하다. + +하지만 새 generation 첫 프레임에: +- deferred Flush +- AckBusy fallback +- transition hitch +가 생길 수 있다. + +권장: +generation change를 관찰했을 때 old-generation pending query를 +“producer ACK 없이 retire 대기” queue로 분리하거나, +새 generation slot과 독립적으로 관리한다. + +현재 4-slot Pending을 slot index 하나에만 결합하므로 generation transition이 +slot-local serialization을 유발한다. + +====================================================================== +13. P2 - DirectGPU declared format와 actual resource format 단일 검증 필요 +====================================================================== + +PrepareDirectStereoSource(): +- actual texture allowed format 확인 +- L/R actual format equality 확인 + +R23ValidateDirectResourceSize(): +- size/sample/actual equality 확인 + +R32 MetadataValid(): +- declared format이 UNKNOWN이 아닌지만 확인 + +하지만 main fast path에서 +declared frame format == actual shared texture format +을 한 지점에서 authoritative하게 강제하지 않는다. + +권장: +단일 DirectFrameValidator 도입. + +검증: +- slot +- generation +- handles +- declared width/height +- actual width/height +- declared format +- actual L/R format +- sample count +- adapter +- producer generation + +bundle publish 전과 fast submit 전 동일 validator 사용. + +====================================================================== +14. P2 - recenter completion CAS/telemetry 불일치 +====================================================================== + +r26 generic EndFrame completion path는: +- MarkApplied(pending) +- compare_exchange PendingGameRequestId +- CAS 결과를 엄격히 반영하지 않는 경로가 존재 + +CompletePendingGameRequestAfterVisibleProjection()는 더 안전하게 CAS 성공 시에만 +counter/log를 올린다. + +권장: +recenter request completion state machine을 하나로 통합. + +상태: +REQUESTED +RECEIVED +TRANSFORM_APPLIED +FRESH_VISIBLE_SUBMITTED +COMPLETED +SUPERSEDED + +====================================================================== +15. P2 - shader fingerprint raw COM pointer cache +====================================================================== + +shader_fingerprint_gpl.hpp의 hash cache key는 사실상 shader pointer identity. + +COM object A 파괴 후 object B가 같은 주소를 재사용하면, +old hash를 잘못 재사용할 수 있다. + +D3D9 Reset 자체는 base shader identity/serial을 0으로 초기화하므로 +“Reset이 base epoch를 안 지운다”는 주장은 오탐이다. + +하지만 fingerprint cache는 별도 문제다. + +권장: +- device/reset generation 포함 +- shader creation/bind lifecycle registration +- raw address 단독 key 금지 + +====================================================================== +16. P1 - shader fingerprint diagnostic가 draw path를 오염 +====================================================================== + +OUTRUN_VR_SHADER_FINGERPRINT 활성 시 draw path에서: +- GetVertexShader +- GetPixelShader +- mutex +- cache scan +- GetFunction +- heap vector +- hash +- pair dedupe +- logging + +까지 들어갈 수 있다. + +성능 문제를 측정하면서 성능 문제를 추가하는 구조다. + +권장: +SetVertexShader/SetPixelShader hook에서 hash 계산. +draw에서는 precomputed 64-bit ID 두 개만 읽는다. + +====================================================================== +17. P1 - shader pair dedupe가 state semantic을 숨김 +====================================================================== + +현재 pair dedupe가 VS+PS hash만 보면: +같은 shader pair가 +- world +- HUD +- menu +- effect +에서 다른 render state로 쓰여도 첫 occurrence만 남을 수 있다. + +진단 key에: +- VS +- PS +- target +- projection class +- z enable/write +- alpha +- FVF +- effect class +를 포함해야 한다. + +====================================================================== +18. P1 - host 2초 telemetry flush/sort +====================================================================== + +main_r23 frame loop는 약 2초마다: +- unordered_map iteration +- metric vector 복사 +- sort(P95) +- ostringstream +- cout +- file write +- pipelineLog.flush() + +R23MetricWindow는 interval마다 reset되므로 unbounded memory leak는 아니다. + +하지만 동기 flush와 sort는 HMD frame thread에서 periodic hitch를 만들 수 있다. + +권장: +- telemetry default off 또는 light mode +- logger thread +- lock-free/single-producer queue +- histogram percentile +- frame loop에서 flush 금지 + +====================================================================== +19. P1 - cadence mode 2 blocking +====================================================================== + +기본: +OUTRUN_VR_CADENCE_MODE=1 + +따라서 blocking serial wait는 기본 경로가 아님. + +mode 2: +WaitForPresented() + budget ~= predictedDisplayPeriod * 0.65 + +대략: +72 Hz -> 약 9.0 ms +90 Hz -> 약 7.2 ms +120 Hz -> 약 5.4 ms + +game event가 늦으면 host frame budget 대부분을 render 전에 사용 가능. + +권장: +mode 1 기본 유지. +mode 2는 diagnostic/experimental. +adaptive wait budget 도입. + +추가 race: +auto-reset presentedEvent에 이전 request signal이 남아 있으면 current wait가 +즉시 깨어난 뒤 presentedRequestId가 current와 다르면 즉시 timeout 취급하고 +남은 budget을 다시 기다리지 않는다. + +correctness보다는 strict cadence reliability 문제. + +====================================================================== +20. P3 - classic SBS frameId tie-break가 wrap-aware 아님 +====================================================================== + +R23SelectClassicFrameForCapture(): + +presentQpc가 같을 때: + frame.frameId > selected.frameId + +DirectGPU latest-frame path는: + R37FrameIdBefore() + +로 signed-difference wrap-aware 비교를 사용한다. + +32-bit frame id는 매우 긴 연속 실행 후 wrap 가능. +60 fps 기준 2^32 frame은 약 828일 수준이라 실사용 우선순위는 낮다. + +그래도 동일 프로젝트 내 frame ordering primitive를 통일하는 것이 좋다. + +권장: +모든 frame-id ordering에 동일 helper 사용. + +====================================================================== +21. cached projection 1초 hold - 의도된 fallback이지만 측정 필요 +====================================================================== + +R23CachedProjectionHoldMs = 1000 + +새 source가 없을 때 마지막 released projection을 최대 1초 유지할 수 있다. + +이것은 raw SBS/theater/black fallback을 막기 위한 의도된 safety behavior다. + +따라서 버그로 분류하지 않는다. + +다만 1초 동안 scene animation이 멈춘 content를 head pose reprojection만으로 +보게 될 수 있으므로 체감상: +- freeze +- judder +- “마지막 화면이 붙어 있음” +처럼 느낄 수 있다. + +실제 로그에서: +projection-cached 연속 duration +을 기록해 1000ms가 너무 긴지 판단해야 한다. + +====================================================================== +22. ReleaseFrame 이중 Publish 의심 - 오탐 제거 +====================================================================== + +검토 중 다음 구조가 의심됐다. + +sbs_capture_override.hpp: +#define ReleaseFrame() ReleaseFrame(); PublishProductionCapture(...) + +main_r23.cpp: +c.duplication_->ReleaseFrame(); +... +PublishProductionCapture(...) + +겉으로는 이중 CopyResource처럼 보인다. + +하지만 main_r23.cpp는 main.cpp를 include한 직후 명시적으로: + +#ifdef ReleaseFrame +#undef ReleaseFrame +#endif + +를 수행한다. + +따라서 main.cpp 내부 legacy compositor는 forced macro를 사용하지만, +main_r23의 R23Capture() 직접 ReleaseFrame은 macro 영향을 받지 않는다. + +결론: +R23Capture의 explicit PublishProductionCapture는 중복 호출이 아니다. +오탐 제거. + +====================================================================== +23. Async pixel probe staging width cache 의심 - 오탐 제거 +====================================================================== + +EnsureStage()는 cachedFormat만 비교하고 cached width는 저장하지 않아 +일반화된 함수로 보면 위험해 보인다. + +하지만 현재 call site별 width는 고정이다. + +sourceStage: + R23SourceSamples = 8 고정 + +projectionStage: + width = 2 고정 + +theaterStage: + width = 1 고정 + +따라서 현 production 호출에서는 resolution 변경 때문에 staging width가 +stale해지는 문제는 발생하지 않는다. + +향후 EnsureStage가 다른 width로 재사용될 경우에는 cachedWidth를 추가할 것. + +====================================================================== +24. D3D9 Reset old shader epoch 의심 - 오탐 제거 +====================================================================== + +stereo_renderer_r7.inc ResetDest(): +- CurrentVertexShaderIdentity = 0 +- VertexShaderSerial = 0 + +따라서 plain Reset 뒤 old base world shader epoch가 그대로 남는 문제는 현재 +코드에 없음. + +fingerprint pointer cache는 별개 문제. + +====================================================================== +25. fixed-function menu car HMD transform 의심 - 오탐 제거 +====================================================================== + +fixed-function world path에는: + GameplayActive() +guard가 있다. + +코드 comment도 car-select/theater/menu preview의 fixed-function perspective XYZ를 +world VR transform에서 제외하도록 명시한다. + +따라서 메뉴 차량 깨짐을 단순히: +“fixed-function 모든 perspective geometry를 VR world로 변환하기 때문” +이라고 결론 내리면 안 된다. + +더 가능성 높은 후보: +- presentation transition +- cached/fallback generation +- shader-based preview path +- stale source/bundle + +====================================================================== +26. OpenXR infinite wait 의심 - 오탐 제거 +====================================================================== + +코드에 XR_INFINITE_DURATION call이 보이지만 production chain에서 +bounded_swapchain_wait가 wrapper로 걸려 있다. + +현재 safety: +- wait slice 약 20ms +- total budget 약 250ms +- timeout 시 error/fallback + +따라서 literal XR_INFINITE_DURATION만으로 무한 hang을 주장하면 안 된다. + +====================================================================== +27. R32 async ACK는 유지해야 함 +====================================================================== + +현재 좋은 구조: + +- D3D11 EVENT +- GetData(DONOTFLUSH) +- steady state no unconditional Flush +- producer slot pressure 시에만 deferred Flush +- generation-aware late completion drop +- GPU completion 전 ACK 금지 + +이 구조는 synchronous per-frame fence보다 낫다. + +변경 우선순위는 ACK async 구조 제거가 아니라: +1. host hold CopyResource 제거 검토 +2. generation transition pending 분리 +3. metadata validator 통합 +이다. + +====================================================================== +28. R14 texture shadow lifetime - 기존 심각한 256-slot 문제는 해결됨 +====================================================================== + +현재 R14: +- unordered_map registry +- shared_ptr entry +- Release hook +- locked shadow lifetime +- retirePending +- shadow byte accounting + +이므로 과거 fixed 256 cyclic slot의 살아 있는 texture shadow 해제 문제와는 +다른 구조다. + +남은 성능 후보: +global registry mutex. + +하지만 draw-hot-path live getter보다 우선순위 낮음. + +====================================================================== +29. 추가 CPU/GPU 성능 우선순위 +====================================================================== + +CPU 측 예상 우선순위: + +1. R13 5 live GetRenderState/draw 제거 +2. R23 scissor/viewport getter 제거 +3. shader fingerprint draw-path 제거 +4. R28 live WVP getter 감소 +5. synchronous log/flush 제거 +6. cadence mode 2 wait 최적화 + +GPU 측 예상 우선순위: + +1. DirectGPU shared eye -> host hold CopyResource x2 제거 검토 +2. fallback SafeEye copy는 fast failure 때만 실행 +3. projection refresh only on new source 유지 +4. R32 steady-state Flush 없는 구조 유지 +5. GPU timestamp로 copy/blit cost 직접 계측 + +====================================================================== +30. 사용자 증상과 연결 +====================================================================== + +증상: +white HUD/score/rank 두 개로 갈라짐 + +강한 후보: +- depth-off WorldStereo 분류 +- R28 cross-shader epoch impersonation +- same shader pair state reuse + +증상: +6th/6 원래 2D 위치 +설명: +분류만으로 world anchor가 생기지 않는다. +실제 opponent/world 위치 기반 UI를 원하면 vehicle transform 또는 게임 내부 +projection 이전 좌표를 찾아야 한다. + +증상: +상대 차량 위 rank가 정확히 안 따라감 + +필요: +- vehicle world transform 확보 +- per-eye world projection +또는 +- 원래 label source world position interception + +증상: +sky/cloud가 머리를 따라 이상하게 움직임 + +강한 후보: +- fixed-function sky에 rotation-only semantic 없음 +- full head translation/IPD 적용 + +증상: +F10 recenter 후 black/last loading + +강한 후보: +- 실제 gameplay origin recenter가 아님 +- projection/source invalidate만 수행 +- R24 cached image generation bug + +증상: +Quest 내부만 심한 버벅임 + +강한 구조 후보: +- per-draw D3D9 getters +- DirectGPU full-eye hold copies +- host synchronous telemetry +- mode 2 cadence wait if enabled +- slot pressure deferred Flush +- fallback/cached transition + +====================================================================== +31. 권장 수정 순서 +====================================================================== + +PHASE A - 화면/좌표 correctness +1. real application recenter transform +2. R19/R24 swapchain generation validity +3. depth-off default ZeroDisparity +4. R28 cross-shader positive proof +5. SkyRotationOnly semantic + +PHASE B - GPU bandwidth +6. DirectGPU host-hold copy 제거 POC +7. shared eye -> projection direct sample +8. R32 EVENT completion ACK 유지 +9. fallback에서만 SafeEye copy + +PHASE C - CPU hot path +10. R13 render-state shadow +11. scissor/viewport shadow +12. R28 tracked WVP generation + +PHASE D - host transition +13. old-generation PendingAck retire queue +14. DirectFrameValidator 통합 +15. recenter completion state machine + +PHASE E - diagnostics +16. shader hash at bind time +17. reset generation-aware cache +18. state-aware fingerprint +19. async logging +20. pipeline telemetry light/off mode + +PHASE F - low priority robustness +21. classic frame-id wrap-aware compare +22. cadence stale-event retry +23. Channel mapping destructor/cleanup +24. dead/legacy constants cleanup + +====================================================================== +32. 필수 회귀 테스트 +====================================================================== + +TEST-01 +zTest=false 모든 effect truth table. +명시적 world proof가 없으면 ZeroDisparity. + +TEST-02 +world shader A 후 HUD shader B. +B가 c64를 안 바꿔도 A epoch를 상속하면 실패. + +TEST-03 +shader COM pointer reuse. +A destroy 후 동일 address B. +B hash는 B여야 함. + +TEST-04 +1,000 world draws에서 live API getter count 계측. + +TEST-05 +1,000 scissor-enabled unchanged draws. +3,000 raster getter가 나오면 실패. + +TEST-06 +gameplay real recenter. +임의 yaw/position 후 F10. +다음 app-space pose가 origin policy와 일치. + +TEST-07 +DirectGPU 중 recenter. +black/no-layer/stale fallback 없이 복구. + +TEST-08 +menu recenter와 gameplay recenter semantic 분리. + +TEST-09 +swapchain generation 1 commit -> recreate generation 2. +gen2 첫 render 전 cached fallback 거부. + +TEST-10 +Direct declared format != actual format. +bundle/fast submit 거부. + +TEST-11 +sky: +- head rotate 반영 +- head translate 제거 +- IPD policy 확인 + +TEST-12 +72/80/90/120 Hz cadence mode 1. + +TEST-13 +mode 2: +cadenceSerialWaitMs와 XR missed frame 비교. + +TEST-14 +DirectGPU hold-copy A/B: +A = current copy-to-hold +B = direct shared SRV sample +GPU timestamp / Present-to-consume / renderMs 비교. + +TEST-15 +generation switch while old R32 EVENT pending. +새 generation 첫 frame에 unnecessary Flush/AckBusy 없어야 함. + +TEST-16 +R32 slot pressure. +ACK safety 유지하면서 Flush frequency 최소화. + +TEST-17 +classic frameId wrap simulation: +0xFFFFFFFE, 0xFFFFFFFF, 1, 2 ordering. + +TEST-18 +pipeline telemetry off: +frame thread file flush 0회. + +TEST-19 +fingerprint on: +draw당 GetFunction 0회가 목표. + +TEST-20 +menu->game->menu 반복 100회: +car preview, cached projection, presentation authority 검사. + +====================================================================== +33. 권장 계측 항목 +====================================================================== + +Game log: +- draw count +- World/HUD/Effect/Sky class count +- liveGetRenderState count +- liveViewport count +- liveScissor count +- liveWvpRead count +- R28 crossShader attempts/accept/reject +- zOffWorld rejects + +Host pipeline: +- direct shared->hold copy GPU us +- projection blit GPU us +- fresh/cached projection count +- cached hold age max +- AckBusy +- deferredFlush +- pendingOldGeneration +- recenter generation +- swapchain generation/committedGeneration +- cadenceSerialWaitMs +- gamePresentToConsumeMs +- xrEndFrameMs + +이 값을 넣으면 다음 테스트 한 번으로: +CPU 병목 / GPU copy 병목 / cadence 병목 / fallback 병목 +을 분리할 수 있다. + +====================================================================== +34. 코드 구조 개선 +====================================================================== + +현재 Rxx overlay가 회귀 추적에는 유용했지만 장기적으로는 복잡도가 높다. + +권장 모듈: + +D3D9StateTracker +- shader +- render states +- viewport/scissor +- WVP +- StateBlock generation + +PassClassifier +- World +- HUD +- EffectBillboard +- SkyRotationOnly +- UnknownFailClosed + +StereoExecutor +- per-eye WVP +- fixed-function +- restore + +DirectTransport +- producer ring +- shared resources +- metadata +- async ACK + +OpenXRPresentation +- projection swapchain generation +- cached image validity +- fallback authority +- recenter/application space + +Diagnostics +- counters +- GPU timestamps +- async logger + +앞으로 매 문제마다 R47/R48 식 wrapper를 계속 추가하는 것보다, +위 모듈 경계를 만드는 편이 bug surface를 줄일 가능성이 높다. + +====================================================================== +35. 현재 유지해야 할 것 +====================================================================== + +유지: +- R32 asynchronous ACK +- bounded swapchain wait +- runtime-adaptive 72/80/90/120 design +- safe build isolation +- fail-closed unknown policy +- generation-aware DirectGPU concept +- architecture CI +- latest-frame-wins DirectGPU selection +- unchanged XR tick projection reuse + +되돌리면 안 되는 것: +- per-frame synchronous GPU fence +- every-XR-tick full projection redraw +- fixed 120Hz hardcode +- raw SBS HMD fallback 우선 +- old R14 256 resource ring +- R29-R34 전체를 검증 없이 production 재활성화 + +====================================================================== +36. 500,000 검토에서 추가 제거한 오탐 +====================================================================== + +FALSE-1 +main_r23 ReleaseFrame + PublishProductionCapture double-copy +-> #undef ReleaseFrame 때문에 현재 R23Capture에는 해당 없음. + +FALSE-2 +AsyncPixelProbe EnsureStage width stale +-> current call-site width 8/2/1 고정이라 현 상태에서는 문제 없음. + +FALSE-3 +Reset old base shader epoch +-> ResetDest가 identity/serial 초기화. + +FALSE-4 +fixed-function menu car always world transformed +-> GameplayActive guard로 차단. + +FALSE-5 +literal XR_INFINITE_DURATION == infinite hang +-> bounded wait wrapper 존재. + +FALSE-6 +R32 normal path unconditional Flush +-> 현재는 slot pressure 때 deferred Flush만 사용. + +====================================================================== +37. CI 상태 문맥 +====================================================================== + +실제 source baseline: +a68cf17832fd4d9c0655f30f097108a43b63ce22 + +해당 baseline에서 확인된 완료 성공: +- Build PR 35483262080 : success +- OpenXR VR architecture 35483262116 : success +- Build push 35483205589 : success + +b741ffd29c87...는 소스 변화 없이 리뷰 문서만 추가된 HEAD이며, +500,000 검토 시작 시점의 최신 관련 CI는 queued 상태였다. + +따라서 이 보고서의 문제들은: +컴파일 실패 주장이 아니라 +runtime correctness/performance/static architecture 문제다. + +====================================================================== +38. 최종 우선순위 +====================================================================== + +P0 correctness: +1. 실제 gameplay recenter +2. swapchain generation cached validity +3. z-off HUD fail-closed +4. R28 cross-shader semantic proof + +P1 visual/performance: +5. SkyRotationOnly +6. R13 getter 제거 +7. scissor getter 제거 +8. DirectGPU host-hold copy 제거 POC +9. shader fingerprint draw-path 제거 +10. host logging async + +P2 transition/robustness: +11. old generation PendingAck 분리 +12. Direct metadata validator +13. recenter completion FSM +14. cadence stale-event retry + +P3: +15. classic frameId wrap tie-break + +====================================================================== +39. 최종 평가 +====================================================================== + +현재 프로젝트는 “true stereo가 가능한가” 단계는 이미 지났다. + +남은 가장 큰 문제는: +- semantic misclassification +- coordinate recenter semantics +- resource generation ownership +- hot-path API/copy cost +이다. + +특히 이번 500,000 교차검증에서 성능 측면의 핵심 추가 결론은: + +R32 async ACK가 producer lifetime을 이미 GPU EVENT까지 보호하고 있으므로, +정상 DirectGPU path에서 host-owned hold texture로 먼저 full-size CopyResource +두 번 하는 구조를 제거할 가능성이 높다. + +즉 최종적으로 목표 구조는: + +OutRun D3D9 per-eye shared resources + | + v +host D3D11 SRV open/validate + | + v +OpenXR projection direct blit + | + v +D3D11 EVENT + | + v +producer per-slot ACK + +이 구조가 성립하면: +- SBS desktop capture 의존 감소 +- full-eye intermediate copy 감소 +- host memory bandwidth 감소 +- latency 감소 가능 +- R32 safety는 유지 +가 동시에 가능하다. + +다만 correctness 순서는 반드시: + +recenter +-> swapchain generation +-> HUD/R28 classification +-> sky semantic +-> DirectGPU zero-extra-copy optimization + +순으로 가는 것을 권장한다. + +요약: +- 500,000 static cross-check cells +- 소스코드 수정: 없음 +- 기존 P0 4개 재확인 +- 신규 주요 GPU 최적화 후보 1개 +- 신규 generation-transition hitch 후보 1개 +- 신규 wrap robustness 항목 1개 +- 추가 오탐 2개 이상 제거 +- 현재 safe/async ACK 구조는 유지 권장 + +END OF 500,000-CHECK REPORT diff --git a/docs/VR_SOURCE_REVIEW_50000_20260920.txt b/docs/VR_SOURCE_REVIEW_50000_20260920.txt new file mode 100644 index 000000000..b9ba4d78b --- /dev/null +++ b/docs/VR_SOURCE_REVIEW_50000_20260920.txt @@ -0,0 +1,1334 @@ +OutRun2006Tweaks OpenXR VR Source Review - 50,000 cross-check report +Date: 2026-09-20 +Repository: thp32tt/OutRun2006Tweaks +Branch reviewed: vr-openxr-gpl-reuse +Source baseline: a68cf17832fd4d9c0655f30f097108a43b63ce22 +Review-head before this report: f10fd52318869b13ea01c82ad8eb8aa2220885a4 +Note: f10fd523 differs from a68cf178 only by the previous 5,000-check text report. +No source-code modification was made as part of this review. + +====================================================================== +0. IMPORTANT INTERPRETATION OF "50,000 REVIEWS" +====================================================================== + +This report does NOT claim that a human literally reread the complete repository +from beginning to end 50,000 times. + +The review was constructed as 50,000 explicit static verification cells: + +- 100 VR/graphics/host/IPC/CI-related files +- 50 risk/performance/synchronization patterns per file +- 10 independent review lenses over the same 5,000 base cells + +100 x 50 x 10 = 50,000 cross-check cells. + +The ten lenses were: + +1. Render correctness / stereo classification +2. CPU/GPU performance / driver-call pressure +3. Threading / atomics / synchronization +4. COM/D3D/OpenXR resource lifetime +5. OpenXR frame/session/swapchain correctness +6. D3D9 state tracking / StateBlock / viewport/scissor behavior +7. Reset/recovery/black-screen paths +8. Input/recenter/menu/gameplay transition behavior +9. CMake/CI/source-owner graph / regression isolation +10. GPL diagnostics / observability / test coverage + +After the mechanical cross-check, candidate findings were re-read in actual +function context and against the current CMake production graph. False positives +and comparison-only code were separated from active production code. + +This is a STATIC source review. Runtime timing impact and symptom frequency still +require Quest 3 / VDXR execution to quantify. + +====================================================================== +1. ACTIVE PRODUCTION GRAPH +====================================================================== + +Current default safe build: + +OUTRUN_VR_SAFE_DRAW_COMPARE=ON + +Game stereo owner: + stereo_renderer_r26_compare.cpp + -> stereo_renderer_r26.cpp + -> stereo_renderer_r23.cpp + -> stereo_renderer_r22.cpp + -> stereo_renderer_r13.cpp + -> stereo_renderer_r7.inc / base R9 path + +Game renderer owner: + outrun_renderer_r23.cpp + -> outrun_renderer_r13.cpp + -> outrun_renderer.cpp + +D3D9Ex compatibility owner: + ex_device_upgrade_r15.cpp + -> R14/R13 lower layers + +Host: + vrhost/src/main_r23.cpp + -> main.cpp implementation + -> runtime hardening chain including R24/R26/R32 + +R29-R34 stereo overlay chain and renderer R29 are excluded by the default +safe-draw production option. Findings in those files are technical debt unless +the comparison/experimental build options are enabled. + +====================================================================== +2. EXECUTIVE SUMMARY +====================================================================== + +The 50,000-cell review confirms the previous main problems and adds several +important new ones. + +Highest-priority findings: + +P0-A. F10 user recenter is not a real gameplay coordinate recenter. + It synthesizes an OpenXR LOCAL reference-space-change event and invalidates + caches, but does not recreate/offset the gameplay local space or maintain + an application recenter transform. + +P0-B. R24 cached fallback validity is not tied to the current R19 swapchain + generation. A newly recreated, not-yet-rendered swapchain can be accepted + as "cached valid" because success/committed state comes from an older + swapchain generation. + +P0-C. Depth-disabled HUD/2D draws can still fall into WorldStereo because + ClassifyEffectStereo() defaults to WorldStereo outside a narrow flat case. + +P0-D. R28 can temporarily impersonate a previous verified world shader epoch + for a different current shader when c64/projection/pose still match. + This can promote HUD/effect shaders into stereo world replay. + +P1-A. Current safe R26 hot path still performs excessive live D3D9 getters: + five render-state getters per candidate draw, plus up to three viewport/ + scissor getters on scissor-enabled draws, plus WVP getter on some shader + epoch transitions. + +P1-B. Fixed-function perspective gameplay draws are all treated as normal + translated/IPD world geometry. There is no sky/rotation-only semantic, + even though the code explicitly names sky/cloud as a target of this path. + +P1-C. GPL shader fingerprint diagnostics are too invasive for performance + diagnosis and can return a stale hash if a COM pointer address is reused. + +P1-D. Host pipeline logging performs synchronous 2-second file flushes and + vector-copy/sort percentile work on the frame loop. + +P1-E. Cadence mode 2 can intentionally block the host for a large fraction of + each HMD frame period while waiting for the game-present event. + +Several suspected problems were explicitly rejected: +- D3D9 Reset DOES clear CurrentVertexShaderIdentity and VertexShaderSerial. +- Fixed-function menu/car-selection geometry is explicitly blocked by + GameplayActive() before HMD world transforms are applied. +- R32 steady DirectGPU ACK is asynchronous and should not be reverted to the + older synchronous Flush/GetData wait. +- XR_INFINITE_DURATION in production is wrapped by the bounded wait guard. +- R14 resource shadows no longer use the old unsafe fixed 256-slot lifetime ring. + +====================================================================== +3. P0 - USER RECENTER IS NOT AN ACTUAL GAMEPLAY RECENTER +====================================================================== + +Files: +- vrhost/src/runtime/r26_recenter_hardening.hpp +- vrhost/src/main_r23.cpp +- vrhost/src/main.cpp +- src/vr/ipc/recenter_request.hpp +- src/vr/game/outrun_renderer_r23.cpp + +Observed F10 flow: + +Game: + Recenter IPC Publish() + +Host r26_recenter_hardening PollEvent(): + WriteSyntheticLocalChange(eventData, XR_NULL_HANDLE) + MarkReceived(requestId) + PendingGameRequestId = requestId + InvalidateFallbackAnchor() + return XR_SUCCESS + +WriteSyntheticLocalChange() builds: + XrEventDataReferenceSpaceChangePending + referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL + changeTime = 0 + poseValid = XR_FALSE + +main_r23 receives this event and eventually executes: + shared.ReferenceSpaceChanged() + compositor.ReferenceSpaceChanged() + viewHistory.Clear() + matchedStereoValid = false + cachedProjectionValid = false + cachedMenuProjectionValid = false + menuProjectionAnchorValid = false + refreshMenuAnchorAfterLocate = true + verified bundle invalidation + +What these functions actually do: + +SharedWriter::ReferenceSpaceChanged(): + increments referenceGeneration_ only. + +StereoCompositor::ReferenceSpaceChanged(): + theaterAnchorValid_ = false + stereoSourceValid_ = false + directFrameValid_ = false + +What DOES NOT happen: +- localSpace is not destroyed/recreated. +- localSpace poseInReferenceSpace is not changed. +- no application-space recenter transform is created. +- no yaw/position offset is stored and applied to future xrLocateSpace results. +- gameplay xrLocateViews(... localSpace) continues to use the same LOCAL origin. + +Menu behavior is different: +after invalidation, main_r23 performs xrLocateSpace(viewSpace, localSpace), +captures head.pose as menuProjectionAnchor, and later renders the mono menu using +that anchor. Therefore menu/theater can appear re-anchored without gameplay +coordinates actually being recentered. + +Why this matters: +The user's reported behavior: +- recenter does not work in menu reliably, +- gameplay recenter can switch to black/last loading image, +- rendering disappears after recenter, +is consistent with invalidating valid projection/source state while not actually +establishing a new gameplay coordinate origin. + +Classification: +CONFIRMED DESIGN DEFECT in the current user-recenter implementation. +Exact visual symptom frequency must still be runtime-tested. + +Recommended design: + +Option 1 - application-space recenter transform +- Keep OpenXR LOCAL unchanged. +- On F10, locate current head pose in LOCAL. +- Compute a persistent AppFromLocal or LocalFromApp transform. +- Apply it consistently to: + - head pose written to game + - per-eye poses + - menu/theater anchors + - projection-space poses +- Common policy: reset yaw + horizontal position; define whether vertical + position is retained or reset. +- Increment recenter generation only after the new transform is valid. + +Option 2 - dedicated reference space +- Recreate a dedicated app space with poseInReferenceSpace computed from current + head pose. +- Swap spaces only after successful creation. +- Keep old space alive until new space is ready if necessary. + +Do NOT use a fabricated runtime reference-space-change event as the actual +recenter mechanism. + +Separate two event classes: +A) real XR runtime reference-space change +B) user-requested application recenter + +Test requirement: +At arbitrary head pose: +- press recenter +- next app-space head yaw/position must become expected origin +- DirectGPU projection must remain valid or recover in one controlled transition +- no R24 emergency/cached stale layer may become authoritative. + +====================================================================== +4. P0 - CACHED FALLBACK VALIDITY IS NOT SWAPCHAIN-GENERATION SAFE +====================================================================== + +Files: +- vrhost/src/runtime/sbs_capture_override.hpp +- vrhost/src/runtime/r24_black_screen_guard.hpp + +R19 Swapchain::Destroy() resets: +- handle +- dimensions +- images/RTVs +- acquired/waited state + +But it does not reset the global cumulative counters: +- ProjectionSuccess +- TheaterSuccess + +R19 EnsureSwapchain() can: +1. Destroy old swapchain. +2. Create a new swapchain with different width/height/array size. +3. Return a valid HANDLE before any new image has necessarily been successfully + rendered/released. + +R24 BuildCachedVisibleQuad() currently accepts projection cache when: + + Projection.handle != XR_NULL_HANDLE + && Projection.width + && Projection.height + && (ProjectionImageCommitted || ProjectionSuccess > 0) + +The theater path has the analogous condition. + +Problems: + +1. ProjectionSuccess/TheaterSuccess are lifetime cumulative counters. + Success on generation N is treated as evidence for generation N+1. + +2. R24 ProjectionImageCommitted/TheaterImageCommitted are reset in + DestroySession(), but there is no generation coupling to R19 EnsureSwapchain() + recreation. + +3. Therefore: + old swapchain rendered successfully + -> resolution/size/state change + -> old swapchain destroyed + -> new swapchain created + -> no new image committed yet + -> old success/committed evidence remains + -> BuildCachedVisibleQuad can submit a new uninitialized/undefined image. + +Possible symptoms: +- black frame +- stale looking frame +- last-loading-looking frame +- transient undefined content during transition/recovery +- fallback that seems unrelated to current menu/game frame + +Classification: +CONFIRMED GENERATION-VALIDITY BUG in static control flow. + +Recommended fix: +Add generation to each R19 Swapchain: + + uint64_t generation + uint64_t committedGeneration + +On every destroy/recreate: + ++generation + committedGeneration = 0 + +Only after successful render + Release: + committedGeneration = generation + +R24 may use cached image only when: + handle valid + committedGeneration == generation + +Remove: + ProjectionSuccess > 0 + TheaterSuccess > 0 +as image-validity proof. + +Do not let R24 own a boolean that can outlive the resource generation unless it +also stores the exact generation. + +Required regression test: +1. render/release projection generation 1 +2. force EnsureSwapchain recreation +3. request cached fallback before any generation-2 render +4. expected: cached fallback rejected +5. render/release generation 2 +6. expected: cached fallback accepted + +====================================================================== +5. P0 - DEPTH-DISABLED 2D/HUD CAN BE CLASSIFIED AS WORLD STEREO +====================================================================== + +Files: +- src/vr/d3d9/vr_pass_policy.hpp +- src/vr/d3d9/stereo_renderer_r13.cpp +- src/vr/d3d9/stereo_renderer_r26.cpp + +Current policy: + + const bool depthDisabledFlatEffect = + cullNone && !depthTestEnabled && + ((alphaBlendEnabled && !depthWriteEnabled) || alphaTestEnabled); + + if (depthDisabledFlatEffect) + ZeroDisparity; + else + WorldStereo; + +Therefore depthTestEnabled == false is NOT sufficient to make a draw flat. + +Examples that can remain WorldStereo: +- depth off + cull not NONE +- depth off + opaque +- depth off + alpha blend but z-write unexpectedly true +- depth off + unusual UI render state + +R13CaptureDrawTimeEffect() directly consumes this policy. + +R37DepthDisabledFragileOverlay() then contains: + if (effect.classification == World) + return false; + +So a depth-off draw already labeled World escapes the later zEnabled safety +filter. + +This is a strong candidate for: +- white 6th/6 split +- white score split +- some rank/Yes-No UI split +- HUD elements that unexpectedly receive world stereo + +Recommended rule: +Depth-test OFF should default to ZeroDisparity. + +A depth-off draw should enter WorldStereo only with strong positive evidence: +- known world shader fingerprint or semantic ID +- verified world WVP write belonging to this draw/shader epoch +- known world target/projection +- optional texture/pass evidence + +Cull/alpha flags should be supporting evidence, not the primary world decision. + +====================================================================== +6. P0 - R28 CAN IMPERSONATE AN OLD WORLD SHADER EPOCH +====================================================================== + +File: +- src/vr/d3d9/stereo_renderer_r26.cpp + +R28CanRebindVerifiedWorld() validates: +- game device/main target +- stereo state +- last verified WVP +- current c64-c67 equals verified WVP +- current projection equals verified projection +- pose generation +- perspective world semantic + +It intentionally allows the current shader epoch to differ from the verified +world shader epoch. + +R28RunWithVerifiedWorldEpoch() then temporarily executes: + + CurrentVertexShaderIdentity.exchange(verifiedShaderIdentity) + VertexShaderSerial.exchange(verifiedShaderSerial) + +and invokes the lower R9 world path. + +This solves one legitimate case: +world smoke/skid/billboard changes shader but inherits the same world WVP. + +But it also creates a dangerous alias: +a HUD/effect shader may inherit c64/projection without rewriting them and can then +be executed as if it were the previous world shader. + +The new GPL fingerprint feature is well suited to fixing this, but currently is +only diagnostic. + +Recommended policy: +- If current shader != verified shader, require current VS fingerprint to be in + a known-world-compatible class before epoch rebind. +- Known HUD/effect shader fingerprints must hard-veto world rebind. +- WVP equality alone must not establish world identity. + +Longer-term: +Do not mutate the global shader epoch at all. +Pass an explicit synthetic "verified world override" context into the stereo +builder so the true current shader identity remains observable. + +====================================================================== +7. P1 - FIXED-FUNCTION SKY/CLOUD HAS NO ROTATION-ONLY SEMANTIC +====================================================================== + +File: +- src/vr/d3d9/stereo_renderer_r7.inc + +For fixed-function draws: +- no shader +- FVF XYZ/XYZB* +- perspective projection +- GameplayActive() + +is enough to enter fixedFunctionWorld. + +Then for each eye: + + fixedEyeView = + fixedOriginalView + * headInverse + * eyeInverse + +This applies: +- full headset orientation +- full headset translation +- eye-local/IPD translation + +The code's own log explicitly states: + "sky/cloud fixed-function passes no longer stay head-locked" + +However, skybox/skydome rendering normally needs a different semantic from nearby +world geometry. A distant/infinite sky generally should react to head rotation, +but not to head translation like a nearby object. Depending on the original sky +implementation, eye translation/IPD may also need to be removed or treated +specially. + +Current code has no: +- SkyRotationOnly class +- depth/range/pass-specific sky semantic +- texture/fingerprint sky classification + +Therefore ALL perspective fixed-function gameplay XYZ draws are treated as +ordinary translated stereo world geometry. + +This can contribute to: +- clouds/sky seeming to move incorrectly with head translation +- wrong sky parallax +- sky behaving unlike the rest of world geometry + +Recommended: +Introduce explicit fixed-function semantic: +- WorldStereo +- SkyRotationOnly +- ScreenSpace +- UnknownFailClosed + +SkyRotationOnly should normally: +- apply head orientation +- remove head positional translation +- evaluate whether IPD translation should be zero for this game's sky setup +- retain per-eye FOV if required + +Use actual draw evidence before enabling: +- shader-less FVF +- texture ID/hash +- depth state +- projection +- known pass sequence/fingerprint + +====================================================================== +8. P1 PERFORMANCE - R13 DOES FIVE LIVE GetRenderState CALLS PER DRAW +====================================================================== + +File: +- src/vr/d3d9/stereo_renderer_r13.cpp + +R13CaptureDrawTimeEffect() performs live reads of: +1. ALPHABLENDENABLE +2. ALPHATESTENABLE +3. ZWRITEENABLE +4. ZENABLE +5. CULLMODE + +Production R26 R27GuardWorldEffect() invokes this on top-level candidate draws. + +Illustrative cost: +3,000 draws/frame + -> 15,000 state getters/frame +at 60 game fps + -> ~900,000 getter calls/sec + +This is not a measured driver time claim; it is the structural call count. + +The project already has a state shadow in stereo_renderer_r7.inc: +- TrackedRenderStateValue +- TrackedRenderStateValid +- TrackedRenderStateReads +- SetRenderState hook +- ReadTrackedRenderState() +- periodic live validation + +R13 currently bypasses this existing mechanism. + +Recommended: +- extend tracked set to include CULLMODE if not already tracked +- R13CaptureDrawTimeEffect reads state shadow +- StateBlock Apply changes a generation / invalidates shadow +- periodic live validation remains for safety +- debug compare mode can force live getters + +Target: +normal draw path should use O(1) memory loads, not five driver getter calls. + +====================================================================== +9. P1 PERFORMANCE - SCISSOR-ENABLED DRAW CAN FORCE THREE LIVE GETTERS +====================================================================== + +Files: +- src/vr/d3d9/stereo_renderer_r23.cpp +- src/vr/d3d9/stereo_renderer_r22.cpp + +R23BeforeTopLevelDraw() executes on every game draw: + ++R23GameDrawSerial + R23CaptureActualGameState(... force=false) + +Fast cache path in R23CaptureActualGameState() requires: + same Present + shadow valid + scissor disabled + fewer than 16 draws since sample + +If scissor is enabled, this fast path is skipped. + +R22CaptureGameScissor() then reads: +- GetViewport +- GetScissorRect +- GetRenderState(SCISSORTESTENABLE) + +Hence a scissor-heavy UI/menu pass can perform three live D3D9 getters for every +draw, in addition to R13 effect getters. + +Recommended: +- cache enabled scissor too +- update cache from setter hooks +- StateBlock generation invalidates cache +- authoritative live sample once per N draws / generation transition +- force-live validation only in compare/debug mode + +====================================================================== +10. P1 PERFORMANCE - R28 WVP READBACK ON SHADER EPOCH CHANGES +====================================================================== + +File: +- src/vr/d3d9/stereo_renderer_r26.cpp + +R28CanRebindVerifiedWorld() can call: + GetVertexShaderConstantF(c64-c67) + +for shader-epoch-change candidates. + +Effect-heavy scenes can switch shaders frequently. + +The renderer already tracks game WVP uploads and verified WVP metadata. Prefer: +- tracked latest c64-c67 write +- write generation +- shader generation +- StateBlock/unknown invalidation + +Use live device constant getter only: +- periodic verification +- debugging +- unknown state recovery + +====================================================================== +11. P1 DIAGNOSTICS - GPL SHADER HASH CACHE CAN RETURN STALE HASH +====================================================================== + +File: +- src/vr/d3d9/shader_fingerprint_gpl.hpp + +HashBytecodeCached() key: + reinterpret_cast(shader) + +No generation/destruction key is included. + +If shader object A is destroyed and shader B later occupies the same COM address, +the cache can return A's bytecode hash for B. + +Important nuance: +D3D9 Reset itself clears the base stereo shader identity/serial, so the separate +claim "Reset leaves old CurrentVertexShaderIdentity alive" is FALSE. +The fingerprint cache is a different cache and still lacks generation-aware +invalidation. + +Recommended: +- calculate fingerprint on shader binding/creation +- cache with device/reset generation +- clear cache at reset/device replacement +- ideally key from actual object lifetime registration rather than raw address + +====================================================================== +12. P1 DIAGNOSTICS - SHADER FINGERPRINT MODE IS TOO EXPENSIVE PER DRAW +====================================================================== + +When OUTRUN_VR_SHADER_FINGERPRINT=1: + +R46TraceShaderFingerprint() can perform: +- GetVertexShader +- GetPixelShader +- mutex acquisition +- VS cache linear scan up to 256 +- PS cache linear scan up to 256 +- GetFunction size query on miss +- vector heap allocation on miss +- GetFunction bytecode copy +- FNV hash +- second mutex +- seen-pair linear scan up to 256 +- possible synchronous logging + +This diagnostic changes the thing being measured. + +Recommended architecture: +At SetVertexShader / SetPixelShader: +- detect binding transition +- calculate/cache hash only on a new object/generation +- store current VS/PS hash in tracked state + +At draw: +- read precomputed pair O(1) +- update compact counters only + +Logging: +- asynchronous +- first-seen semantic transition only +- do not print directly from every render-critical event + +====================================================================== +13. P1 DIAGNOSTICS - SHADER PAIR DEDUPE HIDES STATE TRANSITIONS +====================================================================== + +File: +- src/vr/d3d9/shader_fingerprint_gpl.hpp + +RememberPair() dedupe key is only: + vsHash + psHash + +If the same shader pair is reused: +- world z-on +- HUD z-off +- menu +- special effect +only the first occurrence is logged. + +This is especially dangerous because OutRun can reuse shaders across different +render states. + +Recommended diagnostic key: +- VS hash +- PS hash +- target class +- projection class +- depth test +- depth write +- alpha mode +- FVF/XYZRHW class +- effect classifier result + +Log transitions rather than every draw. + +====================================================================== +14. P1 HOST PERFORMANCE - SYNCHRONOUS 2-SECOND PIPELINE LOG FLUSH +====================================================================== + +File: +- vrhost/src/main_r23.cpp + +The host always creates: + outrun-vr-host-pipeline.log + +Every ~2 seconds the frame loop: +- builds ostringstream +- iterates unordered_map counts +- copies each metric vector for P95 +- std::sort() on copied vectors +- prints to std::cout +- writes pipeline file +- calls pipelineLog.flush() + +R23MetricWindow resets every interval, so this is NOT an unbounded-memory leak. +That suspected issue is rejected. + +But synchronous sort/string/file flush on the frame loop can create periodic +latency spikes, particularly on Windows when storage/security software delays a +flush. + +Recommended: +- diagnostic/telemetry flag +- default production: counters only +- lock-free queue to logger thread +- fixed histogram / small selection algorithm for percentiles +- no synchronous file flush from frame loop + +====================================================================== +15. P1 CADENCE MODE 2 CAN CONSUME MOST OF THE XR FRAME HEADROOM +====================================================================== + +File: +- vrhost/src/main_r23.cpp + +Default: + OUTRUN_VR_CADENCE_MODE default = 1 + +Therefore the blocking path is NOT default. This is important. + +But mode 2 calls WaitForPresented() after xrBeginFrame and waits up to: + predictedDisplayPeriod * 0.65 +bounded by configured timeout. + +Approximate budget: +- 72 Hz: 13.89 ms frame -> ~9.0 ms wait +- 90 Hz: 11.11 ms frame -> ~7.2 ms wait +- 120 Hz: 8.33 ms frame -> ~5.4 ms wait + +If the game misses the event, mode 2 can consume a large fraction of the host's +render/submission budget before doing the rest of frame work. + +This can directly create HMD stutter/latency if strict serialized cadence is +enabled. + +Good: +- default mode is 1, so this is an optional-path risk. +- telemetry already exposes cadenceSerialWaitMs / serialTimeout. + +Recommended: +- keep mode 1 default +- label mode 2 diagnostic/experimental +- adaptive wait budget based on measured game-present latency +- preserve compositor safety headroom +- cache QPC frequency instead of QueryPerformanceFrequency every wait + +====================================================================== +16. P2 - DIRECTGPU DECLARED FORMAT IS NOT AUTHORITATIVELY MATCHED TO RESOURCE +====================================================================== + +Files: +- vrhost/src/main.cpp +- vrhost/src/main_r23.cpp +- vrhost/src/runtime/r32_direct_submit.hpp + +PrepareDirectStereoSource(): +- validates actual texture width/height +- validates allowed actual formats +- validates left/right actual format equality + +R23ValidateDirectResourceSize(): +- validates dimensions +- validates left/right actual format equality + +But neither requires: + actual texture format == + frame.reserved[RenderFrameDirectFormatIndex] + +R32 MetadataValid(): +- checks declared format decodes to non-UNKNOWN +- does not compare declared format to R23DirectHold.format + +The fallback SafeEye path has stricter declared/actual checks elsewhere. + +Impact: +Usually the actual SRV format controls rendering, so this is not automatically a +visible bug. But stale/malformed protocol metadata can still be accepted by the +main fast path. + +Recommended: +one authoritative DirectGPU metadata validator: +- slot +- generation +- handle +- width/height +- declared format +- actual L/R desc +- sample count +- adapter generation +and use the same validator before bundle publish and fast submit. + +====================================================================== +17. P2 - RECENTER APPLIED TELEMETRY CAN CLAIM SUCCESS AFTER FAILED CAS +====================================================================== + +File: +- vrhost/src/runtime/r26_recenter_hardening.hpp + +Generic EndFrame recenter completion: +- MarkApplied(pending) +- CAS PendingGameRequestId from pending -> 0 +- ignores CAS result +- increments GameRequestsApplied +- logs applied + +CompletePendingGameRequestAfterVisibleProjection() is better because it increments +only when CAS succeeds. + +Risk is low in current mostly single-threaded host flow, but a superseding request +can make telemetry/channel state misleading. + +Recommended: +centralize request completion: +- CAS/ownership validation +- only MarkApplied/increment/log for request that is actually completed +- define supersede behavior + +Also: +Recenter IPC Channel does not explicitly unmap/close mapping in a destructor. +This is process-lifetime leakage rather than a practical per-frame leak, but easy +to clean up. + +====================================================================== +18. P2 - R28 SHADER EPOCH OVERRIDE USES TWO INDEPENDENT ATOMICS +====================================================================== + +File: +- src/vr/d3d9/stereo_renderer_r26.cpp + +Synthetic world rebind modifies: +- CurrentVertexShaderIdentity +- VertexShaderSerial + +as separate atomics. + +Restore also uses separate compare_exchange operations. + +If a nested setter or a future multithreaded wrapper changes shader state between +the two operations, identity/serial can temporarily or persistently form an +inconsistent pair. + +Current OutRun D3D9 rendering is effectively render-thread-owned, reducing +practical risk, so this is not P0. + +Recommended: +- do not mutate global true shader epoch for synthetic policy at all +- thread_local override context is safer +or: +- versioned pair/seqlock containing identity+serial + +====================================================================== +19. R32 DIRECTGPU ASYNC ACK - KEEP THIS DESIGN +====================================================================== + +File: +- vrhost/src/runtime/r32_direct_submit.hpp + +Good current behavior: +- EVENT query +- GetData(D3D11_ASYNC_GETDATA_DONOTFLUSH) +- no steady-state CPU fence wait +- same-frame pending EVENT reuse +- Flush only when actual ring-slot pressure exists +- generation fault handling +- ACK only after GPU consumption completion + +Do not regress to older: + Copy -> End query -> Flush -> spin GetData -> ACK +on every frame. + +Remaining performance risk: +under slot pressure: +- poll +- deferred Flush +- poll +- AckBusy fallback + +This is a valid safety tradeoff, not a correctness bug. +Correlate: +- ackSlotBusy +- deferredFlush +- HMD frame-time spikes +before changing ring depth or policy. + +====================================================================== +20. OPENXR BOUNDED WAIT - FALSE POSITIVE REMOVED +====================================================================== + +Files: +- vrhost/src/runtime/bounded_swapchain_wait.hpp +- vrhost/src/runtime/sbs_capture_override.hpp + +Although XR_INFINITE_DURATION appears in callers, the production include chain +wraps xrWaitSwapchainImage. + +Infinite waits are sliced: +- 20 ms each +- 250 ms total budget + +On total timeout: +- XR_ERROR_RUNTIME_FAILURE + +The acquired image is retained if wait fails, preserving OpenXR acquire/wait/ +release ordering. + +Therefore literal XR_INFINITE_DURATION references are NOT sufficient evidence of +an infinite black-screen hang. + +Keep the bounded wait guard. + +====================================================================== +21. RESET / SHADER IDENTITY - FALSE POSITIVE REMOVED +====================================================================== + +File: +- src/vr/d3d9/stereo_renderer_r7.inc + +ResetDest() explicitly performs: + CurrentVertexShaderIdentity.store(0) + VertexShaderSerial.store(0) + +and resets multiple tracked stereo/resource states. + +Therefore: +"plain D3D9 Reset leaves the old world shader epoch in the base tracker" +is NOT a current bug. + +This does not fix the separate GPL pointer-hash cache generation issue. + +====================================================================== +22. FIXED-FUNCTION MENU CAR PATH - FALSE POSITIVE REMOVED +====================================================================== + +File: +- src/vr/d3d9/stereo_renderer_r7.inc + +The fixed-function world path requires: + GameplayActive() + +The code explicitly comments that car-select/theater/menu previews using +fixed-function perspective XYZ must NOT receive headset transforms. + +Therefore the current menu car corruption should NOT be blamed simply on +"all fixed-function perspective geometry receives VR transforms." + +More plausible menu corruption candidates: +- presentation-state transition/cached frame state +- stale swapchain/fallback generation +- game-state whitelist transition +- source/capture mismatch +- other shader-based menu path + +The fixed-function GameplayActive guard itself is correct. + +====================================================================== +23. R14 D3D9EX TEXTURE SHADOW LIFETIME - NO NEW HIGH-SEVERITY BUG FOUND +====================================================================== + +File: +- src/vr/d3d9/ex_device_upgrade_r14.cpp + +Current structure: +- unordered_map texture registry +- shared_ptr entries +- Release hook erases resource +- locked shadow survives until matching Unlock +- retirePending +- shadowBytes accounting +- R14DetachShadowLocked clears accounted bytes before destructor +- device ref lifetime retained in entry + +The old fixed-size 256-slot lifetime eviction bug is not present in this design. + +Potential lower-priority performance issue: +R14Find() uses a global registry mutex. +This can matter for resource-heavy LockRect/GetSurfaceLevel traffic but is not as +important as the per-draw R13/R23 getter costs. + +====================================================================== +24. SOURCE ARCHITECTURE / MAINTAINABILITY +====================================================================== + +The current safe production path is deliberately shorter than R29-R34, which is +good for regression control. + +However the project still has structural debt: +- .cpp includes .cpp includes .cpp +- deep Rxx overlay ownership +- main_r23.cpp includes main.cpp with private/protected visibility macros +- global/thread_local state distributed across layers +- policy, state tracking, diagnostics and execution mixed in wrappers + +CI currently protects the expected source graph, but compile success does not +make this structure easy to reason about. + +Recommended staged refactor, only after current visual correctness is stable: + +Module 1: D3D9StateTracker +- render states +- viewport/scissor +- shader binding +- WVP uploads +- StateBlock generation + +Module 2: PassClassifier +- World +- HUD/Screen +- Billboard/Effect +- SkyRotationOnly +- Unknown/FailClosed + +Module 3: StereoExecutor +- left/right replay +- fixed-function transforms +- state restoration + +Module 4: DirectTransportPublisher +- ring slots +- metadata +- ACK generation + +Module 5: Diagnostics +- hash/event counters +- async log queue + +Avoid adding R47/R48/R49 wrappers for every new fix if a module can own it +cleanly. + +====================================================================== +25. TEST COVERAGE REQUIRED AFTER THIS REVIEW +====================================================================== + +TEST-01 Effect policy truth table +Exhaust all combinations of: +- alphaBlend +- alphaTest +- zWrite +- zTest +- cullNone +Verify every zTest=false screen candidate fails closed unless explicitly +world-proven. + +TEST-02 Different shader, same WVP +A = verified world shader. +Switch B without rewriting c64. +B is HUD/effect. +Expected: B must not inherit A's world epoch without explicit B-world evidence. + +TEST-03 Shader pointer reuse +Hash A. +Destroy A. +Create B at same address. +Expected: B gets B hash. + +TEST-04 Per-draw getter budget +Mock/count 1,000 normal world draws. +Set maximum allowed: +- GetRenderState +- GetViewport +- GetScissorRect +- GetVertexShaderConstantF +The future target should be close to zero live getters per ordinary draw. + +TEST-05 Scissor-enabled stress +1,000 draws with enabled scissor but unchanged state. +Expected: not 3,000 live raster getters. + +TEST-06 Real user recenter +Start with head yaw/position offset. +Press user recenter. +Expected application-space pose becomes chosen origin. +Do not require runtime-generated reference-space event. + +TEST-07 Recenter during gameplay DirectGPU +Press recenter during active stereo. +Expected: +- no black frame +- no last-loading fallback +- fresh projection returns without stale-generation cache + +TEST-08 Recenter in menu +Expected mono menu anchor recenters independently from gameplay app origin policy. + +TEST-09 Swapchain generation cache +Generation 1 committed. +Recreate generation 2. +Before generation-2 render: + cached fallback must fail. +After generation-2 release: + cached fallback may succeed. + +TEST-10 Direct format mismatch +Declared frame format != actual shared texture format. +Expected bundle/fast-submit rejection. + +TEST-11 Sky translation +Classified sky draw: +- rotate head -> sky rotates correctly +- translate head -> sky does not translate like nearby world +- verify intended IPD behavior + +TEST-12 Cadence mode 1 vs mode 2 +At 72/90/120 Hz: +- xrWaitFrame interval +- cadenceSerialWaitMs +- gamePresentToConsumeMs +- xrEndFrameMs +- HMD dropped/reprojected frames +Mode 2 must not be enabled by default if serialized waits materially worsen HMD +frame stability. + +TEST-13 Telemetry-off frame loop +Disable detailed diagnostics. +Expected: +- no synchronous 2-second file flush in render-critical path +- no shader hash COM queries on draws +- no percentile vector copy/sort + +====================================================================== +26. RECOMMENDED FIX ORDER +====================================================================== + +PHASE 1 - Correctness first +1. Implement real application recenter transform / app space. +2. Add swapchain generation + committedGeneration and fix R24 cache validity. +3. Make depth-off default ZeroDisparity. +4. Restrict R28 cross-shader world rebind with fingerprint/semantic proof. +5. Add SkyRotationOnly semantic. + +PHASE 2 - Remove game-side hot-path driver getters +6. Replace R13 five live render-state getters with tracked-state shadow. +7. Cache enabled scissor state with StateBlock generation. +8. Replace R28 live c64 getter with tracked WVP-write generation where safe. + +PHASE 3 - Make diagnostics non-invasive +9. Hash shader at binding/creation, not draw. +10. Add device/reset generation to hash cache. +11. State-aware fingerprint transition key. +12. Async diagnostic logging. + +PHASE 4 - Host jitter +13. Gate/split pipeline telemetry to background writer. +14. Remove synchronous file flush from frame loop. +15. Keep cadence mode 1 default; tune mode 2 separately. + +PHASE 5 - Protocol/architecture +16. One DirectGPU metadata validator including declared vs actual format. +17. Centralize recenter request completion state machine. +18. Replace global synthetic shader epoch mutation with explicit override context. +19. Gradually split Rxx .cpp-overlay chain into real modules. + +====================================================================== +27. WHAT NOT TO CHANGE +====================================================================== + +Do not: +- revert R32 to synchronous per-frame GPU ACK wait +- remove bounded swapchain wait protection +- restore old fixed-size R14 resource shadow ring +- re-enable R29-R34 fast/HUD path wholesale before current safe path is fixed +- hardcode 120 Hz for Quest/VDXR +- treat the monitor SBS frame rate alone as proof of HMD performance +- make all depth-off effects world just to fix smoke/sky +- make all transparent effects zero-disparity just to fix HUD + +Keep: +- runtime-adaptive refresh/cadence design +- fail-closed unknown pass policy +- generation-aware DirectGPU transport concept +- safe branch isolation +- architecture CI + +====================================================================== +28. PERFORMANCE PRIORITY ESTIMATE +====================================================================== + +Highest likely CPU-side savings: +1. R13 5x GetRenderState elimination +2. R23 scissor/viewport live getter elimination +3. shader fingerprint draw-path removal when diagnostics enabled +4. R28 c64 readback reduction + +Highest likely host jitter savings: +1. remove synchronous log flush/sort/string work +2. avoid mode-2 serialized waits unless intentionally testing +3. minimize deferred Flush events under DirectGPU ring pressure + +Highest visual correctness leverage: +1. real recenter +2. swapchain cache generation +3. depth-off HUD policy +4. R28 cross-shader rebind proof +5. sky semantic + +====================================================================== +29. RELATION TO OBSERVED USER SYMPTOMS +====================================================================== + +Symptom: +white 5th/6th / 6th/6 / white score split into two +Strong source candidates: +- depth-disabled classification defaulting to WorldStereo +- R28 cross-shader epoch rebind +- same shader reused across state classes and diagnostic dedupe hiding transition + +Symptom: +rank labels not over opponent vehicles +Important distinction: +Current shader/draw classification can decide world vs HUD, but cannot magically +recover a vehicle's world anchor for a fundamentally 2D overlay. +True car-attached labels may require: +- identifying game world position/vehicle transform +- projecting that world position per eye +or: +- intercepting the label's source screen-coordinate calculation before draw. + +Symptom: +sky/cloud follows head incorrectly +Candidate: +- fixed-function world path has no sky rotation-only class and applies full + translation/IPD. + +Symptom: +F10 recenter does not work / produces black or last-loading image +Strong source candidates: +- F10 is not a true gameplay origin recenter +- it invalidates active source/bundle state +- R24 cached fallback can have stale-generation validity + +Symptom: +menu car model corruption +Fixed-function GameplayActive guard itself appears correct. +More likely: +- menu/gameplay presentation transition +- cached/fallback generation +- shader-based path +- stale source/bundle transition + +Symptom: +HMD severe stutter while monitor can look acceptable +Strong structural cost candidates: +- game-side live D3D9 getters +- diagnostic hashing if enabled +- host synchronous telemetry flush +- optional cadence mode 2 serial wait +- DirectGPU deferred Flush when ring is under pressure + +====================================================================== +30. CI / BUILD STATUS CONTEXT +====================================================================== + +The source baseline a68cf17832fd4d9c0655f30f097108a43b63ce22 previously passed: +- Build PR run 35483262080: SUCCESS +- OpenXR VR architecture run 35483262116: SUCCESS +- Build push run 35483205589: SUCCESS + +The pre-report review HEAD f10fd52318869b13ea01c82ad8eb8aa2220885a4 +contains only the previous documentation file beyond that source baseline. +Its latest CI jobs were queued at the beginning of this 50,000 review. + +Therefore the findings in this report are runtime logic/performance findings, +not compile-error claims. + +====================================================================== +31. FINAL ASSESSMENT +====================================================================== + +The project has moved beyond the original "can we make stereo draw at all?" +problem. + +The dominant remaining risks are now state semantics and frame ownership: + +1. Which draw truly belongs in world stereo? +2. Which shader/state transition is allowed to inherit a world WVP? +3. Which OpenXR/swapchain image generation is genuinely committed? +4. What does "recenter" mean in the application's coordinate system? +5. How much driver/API work is still done per game draw? +6. How much diagnostic work runs on the HMD frame loop? + +The most important new conclusion from the 50,000 cross-check is: + +Do not solve the remaining bugs by adding another broad replay wrapper. + +The next implementation should reduce ambiguity: +- explicit application recenter transform +- generation-tagged swapchain validity +- conservative depth-off HUD policy +- fingerprint-backed cross-shader semantics +- rotation-only sky semantic +- cached D3D9 state instead of per-draw live getters + +This should improve correctness and performance at the same time. + +Summary classification: +- P0/high correctness/design findings: 4 core issues +- P1 major performance/diagnostic/sky findings: 8+ issues +- P2 protocol/concurrency/telemetry findings: 3+ issues +- explicitly cleared false positives / safeguards: 6+ +- comparison-only R29-R34 issues kept separate from production findings +- source code changed by this review: NO +- documentation report added: YES + +END OF 50,000-CHECK REPORT diff --git a/docs/VR_SOURCE_REVIEW_5000_20260920.txt b/docs/VR_SOURCE_REVIEW_5000_20260920.txt new file mode 100644 index 000000000..c7cf8e410 --- /dev/null +++ b/docs/VR_SOURCE_REVIEW_5000_20260920.txt @@ -0,0 +1,618 @@ +OutRun2006Tweaks OpenXR VR Source Review - 5,000-check report +Date: 2026-09-20 +Repository: thp32tt/OutRun2006Tweaks +Branch reviewed: vr-openxr-gpl-reuse +Review base commit: a68cf17832fd4d9c0655f30f097108a43b63ce22 +Production-safe build mode: OUTRUN_VR_SAFE_DRAW_COMPARE=ON +Production stereo owner: stereo_renderer_r26_compare.cpp -> stereo_renderer_r26.cpp -> R23/R22/R13/R9 +Production renderer owner: outrun_renderer_r23.cpp +D3D9Ex final owner: ex_device_upgrade_r15.cpp +Host entry: vrhost/src/main_r23.cpp + +====================================================================== +1. REVIEW METHOD +====================================================================== + +"5,000회"를 동일 파일을 사람 눈으로 5,000번 반복해서 읽었다고 과장하지 않았다. +이번 검토는 다음과 같이 정확히 5,000개의 독립 검증 셀을 구성했다. + +- VR/그래픽/Host/IPC/CI 관련 파일: 100개 +- 파일당 위험 패턴/성능/동기화 검증 항목: 50개 +- 총 자동 정적 검증 셀: 100 x 50 = 5,000개 +- 이후 후보 항목은 실제 함수 문맥, 호출 체인, 현재 CMake 빌드 그래프를 다시 읽어 + production 경로 / 비교 경로 / 진단 전용 경로를 분리하고 오탐을 제거했다. + +50개 검증군에는 TODO/FIXME, 예외 삼킴, Sleep/무한 대기, D3D9 live getter, +GetVertexShaderConstantF, viewport/scissor getter, COM shader getter, +GetFunction, mutex/heap/vector, QPC/tick, texture/query/stateblock 생성, +SetRenderTarget/Depth/Viewport/Scissor/WVP, StretchRect/LockRect, +D3D11 Map/Copy/Flush/GetData, OpenXR acquire/wait/release/end-frame 등을 포함했다. + +이 보고서는 실행 프로파일러 측정이 아니라 소스 정적 검토 결과다. +따라서 성능 항목에서 실제 ms 개선량은 새 실행 로그/프로파일로 검증해야 한다. + +====================================================================== +2. EXECUTIVE SUMMARY +====================================================================== + +현재 default safe R26 경로는 과거 R29-R34 fast/HUD 체인보다 안전하지만, +아직 두 종류의 핵심 문제가 남아 있다. + +A. 렌더 정확성 +- depth-test가 꺼진 2D/HUD 계열 draw가 특정 상태 조합에서 WorldStereo로 + 분류될 수 있는 논리 구멍이 있다. +- R28 shader-epoch rebind는 "현재 shader가 다른데 c64/projection이 같음"을 + world pass로 승격할 수 있다. 현재 흰색 순위/스코어/HUD가 양안으로 갈라지는 + 증상과 연결될 가능성이 높다. + +B. 성능 +- 현재 safe 경로에서도 draw마다 5개의 GetRenderState를 실행한다. +- scissor enabled 상태에서는 추가로 GetViewport + GetScissorRect + + GetRenderState가 draw마다 발생할 수 있다. +- shader epoch 변경 시 GetVertexShaderConstantF(c64-c67) readback도 추가된다. +즉 "fast path를 끈 안전 빌드"가 정확성은 높였지만 CPU/driver call 비용은 상당히 크다. + +새 3Dmigoto GPL shader fingerprint 기능 자체에도 두 문제가 있다. +- COM 포인터 주소만 cache key로 사용해 포인터 재사용 시 잘못된 hash를 돌려줄 수 있다. +- 진단을 켜면 draw마다 COM getter + mutex + 256-entry 선형검색을 수행하므로 + 성능 문제를 측정하는 과정 자체가 프레임을 더 악화시킬 수 있다. + +CI는 review base commit에서 모두 성공했다. +- Build PR run 35483262080: SUCCESS +- OpenXR VR architecture run 35483262116: SUCCESS +- Build push run 35483205589: SUCCESS + +따라서 아래 결함은 "컴파일 실패"가 아니라 런타임 정확성/성능 설계 문제다. + +====================================================================== +3. HIGH PRIORITY CORRECTNESS FINDINGS +====================================================================== + +[HIGH-01] Depth-disabled draw가 WorldStereo로 분류될 수 있음 +Files: +- src/vr/d3d9/vr_pass_policy.hpp +- src/vr/d3d9/stereo_renderer_r13.cpp +- src/vr/d3d9/stereo_renderer_r26.cpp + +현재 ClassifyEffectStereo(): + + const bool depthDisabledFlatEffect = + cullNone && !depthTestEnabled && + ((alphaBlendEnabled && !depthWriteEnabled) || alphaTestEnabled); + +이 조건에 맞는 경우에만 ZeroDisparity이며 나머지는 기본 WorldStereo다. + +따라서 다음 상태는 depth test가 꺼져도 WorldStereo가 된다. +- cullNone == false 인 2D draw +- alpha blend가 꺼진 depth-disabled draw +- alpha blend가 켜졌지만 z-write가 true인 draw +- 기타 상태가 위 좁은 flat 조건에 걸리지 않는 draw + +R13CaptureDrawTimeEffect()는 이 결과를 그대로 R13EffectClass::World/Flat으로 사용한다. +R27GuardWorldEffect()의 R37DepthDisabledFragileOverlay()는 classification == World이면 +zEnabled == false여도 즉시 "fragile overlay 아님"으로 처리한다. + +결과: +실제로는 HUD/순위/score/menu overlay인 draw가 perspective projection 또는 남아있는 +world state와 결합될 경우 양안 world replay로 들어갈 수 있다. + +사용자 관측과의 연관성: +- 흰색 6th/6이 두 개로 보임 +- 흰색 score가 양안 분리됨 +- 일부 순위표시/overlay가 world/HUD 경계에서 잘못 보임 +이 증상과 매우 잘 맞는 후보다. + +권장 수정: +1) depthTestEnabled == false면 기본값을 Flat/ZeroDisparity로 바꾼다. +2) depth-off draw를 world로 올리려면 별도의 강한 증거가 있어야 한다. + 예: 검증된 shader fingerprint + world WVP write + projection semantic. +3) cull mode/alpha state는 보조 증거로만 사용한다. +4) 모든 boolean 조합을 테스트하는 unit test matrix를 추가한다. + + +[HIGH-02] R28 shader-epoch rebind가 "다른 shader"를 이전 world shader로 가장할 수 있음 +File: +- src/vr/d3d9/stereo_renderer_r26.cpp + +R28CanRebindVerifiedWorld()은 다음을 확인한다. +- main target / stereo state +- 최근 verified c64-c67 +- pose generation +- 현재 c64-c67 값이 verified 값과 동일 +- projection이 verified projection과 동일 +- renderer semantic이 perspective world + +하지만 "현재 shader 자체가 이전 verified world shader와 같은 종류인지"는 요구하지 않는다. +오히려 current shader epoch가 stored shader epoch와 달라야 이 경로가 의미가 있다. + +그 뒤 R28RunWithVerifiedWorldEpoch()은: + + CurrentVertexShaderIdentity.exchange(verifiedShaderIdentity) + VertexShaderSerial.exchange(verifiedShaderSerial) + +로 현재 shader identity/serial을 일시적으로 이전 world shader epoch로 바꾸고 R9 draw를 실행한다. + +의도: +world WVP를 상속하는 smoke/skid/decal shader가 shader switch만 한 경우 world stereo 유지. + +위험: +HUD/effect shader가 c64/projection을 새로 쓰지 않고 이전 world 값을 그대로 둔 경우에도 +"이전 world shader인 것처럼" 승격될 수 있다. + +이 역시 흰색 HUD/순위/효과의 양안 분리 후보다. + +권장 수정: +- 새 GPL shader fingerprint를 단순 로그가 아니라 "world-compatible shader evidence"로 사용. +- 최소한 current VS fingerprint가 known-world 계열인지 확인하기 전에는 epoch를 강제 치환하지 않는다. +- shader가 달라진 경우 WVP 동일성만으로 world 승격하지 않는다. +- HUD/effect fingerprint는 명시적으로 zero-disparity 정책으로 보낸다. + + +[HIGH-03] 같은 shader pair가 상태에 따라 다른 역할을 해도 fingerprint 로그가 첫 상태만 남음 +File: +- src/vr/d3d9/shader_fingerprint_gpl.hpp + +RememberPair()의 dedupe key는: + (vertexHash, pixelHash) + +뿐이다. + +문제: +같은 VS/PS pair가 +- 한 pass에서는 z-on world +- 다른 pass에서는 z-off HUD/effect +로 재사용되면 첫 번째 상태만 로그에 남고 두 번째 상태는 사라진다. + +이는 현재 우리가 찾으려는 "같은 shader인데 상태에 따라 HUD/world가 달라지는" 경우를 +숨길 수 있다. + +권장 수정: +dedupe/transition key에 최소 다음을 포함: +- VS hash +- PS hash +- effect classification +- zEnabled +- projection class +- target class(main/offscreen) +- primitive/FVF 또는 XYZRHW 여부 +필요하면 viewport/scissor class도 포함한다. + +====================================================================== +4. HIGH PRIORITY PERFORMANCE FINDINGS +====================================================================== + +[HIGH-PERF-01] R13 effect snapshot이 draw마다 5개의 live GetRenderState 실행 +File: +- src/vr/d3d9/stereo_renderer_r13.cpp + +R13CaptureDrawTimeEffect()은 매 호출마다: +- D3DRS_ALPHABLENDENABLE +- D3DRS_ALPHATESTENABLE +- D3DRS_ZWRITEENABLE +- D3DRS_ZENABLE +- D3DRS_CULLMODE + +총 5번 GetRenderState()를 호출한다. + +현재 production R26의 R27GuardWorldEffect()가 네 Draw entry 모두에서 +R13CaptureDrawTimeEffect()를 호출하므로 사실상 main draw hot path다. + +예시: +3,000 draw/frame이면 약 15,000개의 D3D9 render-state getter/frame. +60fps면 약 900,000 getter/sec 수준이다. +이는 정확한 실제 draw 수에 따라 달라지지만 구조 자체가 비싸다는 점은 확정이다. + +이미 base stereo_renderer_r7.inc에는: +- TrackedRenderStateValue +- TrackedRenderStateValid +- TrackedRenderStateReads +- SetRenderState hook +- ReadTrackedRenderState() +가 있으며 16회 단위 live validation도 구현되어 있다. + +즉 R13은 이미 존재하는 state shadow를 쓰지 않고 live getter를 다시 호출한다. + +권장 수정: +R13CaptureDrawTimeEffect()을 tracked state 기반으로 교체. +StateBlock::Apply 또는 의심 세대에서만 강제 live resync. +정확성 때문에 필요한 periodic validation은 유지하되 per-draw 5 getter는 제거. + + +[HIGH-PERF-02] scissor enabled 상태에서는 viewport/scissor 3 getter가 매 draw 발생 가능 +Files: +- src/vr/d3d9/stereo_renderer_r23.cpp +- src/vr/d3d9/stereo_renderer_r22.cpp + +R23BeforeTopLevelDraw()은 모든 game draw 전에: + R23CaptureActualGameState(..., force=false) + +를 호출한다. + +R23CaptureActualGameState() cache fast path는: +- same Present +- shadow valid +- scissor disabled +- 16 draw 미만 +일 때만 작동한다. + +즉 R22ShadowState.enabled == true이면 cache를 사용하지 않고 매 draw: +- GetViewport +- GetScissorRect +- GetRenderState(SCISSORTESTENABLE) +를 수행한다. + +HUD/menu/clip rectangle가 많은 구간에서 매우 비싸질 수 있다. + +권장 수정: +- enabled scissor도 세대 기반 cache 허용. +- StateBlock Apply generation을 별도로 추적. +- SetViewport/SetScissorRect/SetRenderState hook으로 shadow 갱신. +- N draw마다 또는 StateBlock generation 변경 시만 authoritative live validation. + + +[MED-PERF-03] shader epoch mismatch 때 GetVertexShaderConstantF(c64-c67) readback +File: +- src/vr/d3d9/stereo_renderer_r26.cpp + +R28CanRebindVerifiedWorld()은 shader epoch가 바뀐 후보마다: + GetVertexShaderConstantF(OutRunWvpRegister, ...) + +를 수행한다. + +effect shader switch가 많은 장면이면 driver readback 성격의 getter가 반복된다. + +이미 renderer는 game WVP write를 추적하는 구조를 갖고 있으므로, +가능하면 "마지막 game upload shadow"와 generation을 사용하고 실제 device getter는 +주기적 검증/디버그 모드로 제한하는 것이 좋다. + + +[LOW-PERF-04] R27 Present timing QPC가 telemetry off여도 항상 실행 +File: +- src/vr/d3d9/stereo_renderer_r26.cpp + +PresentDestR27()은 Settings::VRTelemetry 여부와 관계없이 +Present 전/후 QueryPerformanceCounter를 호출한다. + +비용은 작지만 telemetry를 끈 production path에서는 불필요하다. +QPC 측정 자체를 VRTelemetry 또는 profiling mode에 묶을 수 있다. + + +[LOW-PERF-05] QueryPerformanceFrequency를 반복 호출하는 경로 +File: +- src/vr/d3d9/stereo_renderer_r23.cpp + +R23DiagnoseHostFreshness()에서 Present마다 QueryPerformanceFrequency 호출 가능. +frequency는 프로세스 실행 중 고정이므로 한 번 cache하는 편이 낫다. + +====================================================================== +5. GPL SHADER FINGERPRINT REVIEW +====================================================================== + +[HIGH-DIAG-01] COM pointer만 cache key로 사용 -> pointer reuse 시 stale hash 가능 +File: +- src/vr/d3d9/shader_fingerprint_gpl.hpp + +HashBytecodeCached(): + identity = reinterpret_cast(shader) + cache에서 identity만 찾아 기존 hash 반환 + +cache invalidation이 Reset/shader destruction과 연결되어 있지 않다. + +COM object가 파괴된 뒤 같은 주소에 새로운 shader가 할당되면 +새 shader bytecode를 읽지 않고 이전 hash를 반환할 수 있다. + +이 문제는 렌더 결과를 깨지는 않지만 diagnostic classification을 잘못 만들 수 있다. + +권장 수정: +가장 좋은 구조: +- SetVertexShader/SetPixelShader hook에서 binding 변경 시 한 번만 hash 계산. +- Reset/device generation 변화 시 cache invalidate. +- current VS/PS hash를 atomic/current-state로 보관. +- draw hot path에서는 hash 계산/COM query 없이 현재 hash만 읽는다. + + +[HIGH-DIAG-02] diagnostic ON 시 per-draw 오버헤드가 너무 큼 +Files: +- src/vr/d3d9/shader_fingerprint_gpl.hpp +- src/vr/d3d9/stereo_renderer_r26.cpp + +현재 R46TraceShaderFingerprint()은 R27GuardWorldEffect()마다 호출된다. + +CaptureCurrent(): +- GetVertexShader +- GetPixelShader +- mutex +- VS cache 최대 256 선형검색 +- PS cache 최대 256 선형검색 +- cache miss 시 GetFunction size +- vector heap allocation +- GetFunction bytecode +- FNV hash +그 뒤 RememberPair(): +- 두 번째 mutex +- 최대 256 pair 선형검색 + +진단을 켠 상태에서는 성능 원인을 관측하면서 동시에 큰 새 hot-path 비용을 넣게 된다. +따라서 stutter 수치를 비교하는 용도로는 현재 방식이 부적합하다. + +권장 수정: +hash는 shader binding/creation 시점에만 계산하고 draw에서는 zero-allocation O(1) 조회. +로그 역시 state transition이나 처음 보는 fingerprint 조합에서만 발생하도록 한다. + + +[MED-DIAG-03] 256-slot cache 정책은 진단 규모가 커질 경우 churn 가능 +현재 shader cache/pair seen table이 모두 256 entries다. +OutRun 자체 shader 수가 이보다 적다면 충분할 수 있으나, +wrapper/overlay/mod가 개입하면 eviction과 재hash가 늘어날 수 있다. + +고정 table을 유지하려면 open-addressed hash table 또는 generation-tagged map이 적합하다. + +====================================================================== +6. HOST / DIRECTGPU PERFORMANCE AND SYNCHRONIZATION +====================================================================== + +[GOOD-01] R32 steady DirectGPU path는 과거의 동기 WaitForCopyFence보다 개선됨 +File: +- vrhost/src/runtime/r32_direct_submit.hpp + +현재 fast path: +- EVENT query +- GetData(... D3D11_ASYNC_GETDATA_DONOTFLUSH) +- producer slot pressure가 실제 발생할 때만 Flush +- 같은 frame pending EVENT는 재사용 +- steady frame에서 redundant SafeEye copy + second projection을 피함 + +이 방향은 유지해야 한다. +과거 d3d9ex_direct_passthrough.hpp의: + End(query) -> Flush -> GetData spin +방식으로 steady path를 되돌리면 안 된다. + + +[MED-HOST-01] ring pressure 시 Flush + fallback 전환은 여전히 hitch 가능 +File: +- vrhost/src/runtime/r32_direct_submit.hpp + +slot이 아직 armed 상태면: +- PollCompletedAcks +- 완료 안 됐고 flushIssued=false면 Context->Flush() +- 다시 Poll +- 여전히 pending이면 AckBusy -> fallback + +이는 안전한 failover지만 GPU가 밀리는 순간 CPU/driver hitch가 더 커질 수 있다. + +권장: +- telemetry에서 ackSlotBusy / deferredFlush 발생과 HMD frame spike를 함께 상관 분석. +- ring depth 확대는 메모리/latency trade-off를 보고 결정. +- 무조건 Flush 확대는 금지. + + +[MED-HOST-02] Host detailed pipeline telemetry가 allocation/sort를 사용 +File: +- vrhost/src/main_r23.cpp + +R23MetricWindow: +- std::vector +- P95()에서 vector 복사 +- std::sort +Pipeline window: +- unordered_map +- ostringstream formatting + +frame마다 Note()가 누적되고 주기적 telemetry 시 문자열/정렬 비용이 생긴다. + +권장: +- production에서는 VRTelemetry/diagnostic flag로 상세 telemetry gate. +- percentile은 고정 histogram 또는 ring-buffer selection으로 바꿀 수 있다. +- 로그 생성은 render-critical thread에서 최소화. + + +[LOW-HOST-03] std::cerr 기반 주기 로그 +Files: +- vrhost/src/runtime/r32_direct_submit.hpp +- 여러 runtime hardening headers + +5초 주기 또는 첫 이벤트 로그는 큰 문제는 아니지만 std::cerr formatting/flush 동작은 +실시간 렌더 thread에서 완전히 공짜가 아니다. +가능하면 비동기 logger 또는 lock-free telemetry queue 사용. + +====================================================================== +7. OPENXR WAIT / SWAPCHAIN REVIEW +====================================================================== + +[GOOD-02] XR_INFINITE_DURATION이 소스에 보여도 실제 infinite hang으로 단정하면 안 됨 +Files: +- vrhost/src/runtime/bounded_swapchain_wait.hpp +- vrhost/src/runtime/sbs_capture_override.hpp + +bounded_swapchain_wait.hpp는 xrWaitSwapchainImage를 wrapper로 치환한다. +XR_INFINITE_DURATION 요청을: +- 20ms slice +- 총 250ms budget +으로 제한하고 초과 시 XR_ERROR_RUNTIME_FAILURE로 반환한다. + +sbs_capture_override는 wait 실패 시 acquiredImage를 보존하여 +OpenXR acquire/wait/release 순서를 깨지 않도록 설계돼 있다. + +따라서 자동 스캔에서 잡힌 XR_INFINITE_DURATION은 현재 production host에서 +"무한 black-screen hang" 확정 결함이 아니다. +현재 bounded 구조는 유지하는 것이 맞다. + +====================================================================== +8. D3D9EX / RESOURCE LIFETIME REVIEW +====================================================================== + +[GOOD-03] R14 CPU shadow lifetime의 이전 256-slot eviction 문제는 현재 구조에서 해결됨 +File: +- src/vr/d3d9/ex_device_upgrade_r14.cpp + +현재: +- unordered_map> +- texture Release hook에서 erase +- locked resource는 retirePending으로 Unlock까지 생존 +- R14ShadowEntry destructor에서 cpu/device Release +- shadowBytes accounting 존재 +- R14DetachShadowLocked()가 bytes를 0으로 만든 뒤 destructor double-subtract 방지 + +이번 검토에서는 명백한 double-release / shadow budget leak을 확인하지 못했다. + + +[LOW-PERF-06] R14 registry mutex는 resource hook 호출마다 contention 가능 +R14Find()는 global R14RegistryMutex를 잡는다. +LockRect/GetSurfaceLevel 등의 resource API가 매우 자주 호출되는 workload라면 병목 후보지만, +일반 draw hot path와는 거리가 있다. + +현재 우선순위는 R13/R23 per-draw getter 제거보다 낮다. + +====================================================================== +9. COMPARISON / NON-PRODUCTION PATH TECHNICAL DEBT +====================================================================== + +현재 default safe build에서는 R29-R34 stereo overlay 및 renderer R29가 제외된다. +따라서 아래는 "현재 증상의 직접 원인"으로 취급하지 않는다. + +다만 향후 다시 활성화할 경우 위험도가 높다. + +특히: +- stereo_renderer_r30.cpp: 약 3,102 lines +- stereo_renderer_r30_r26_safe.cpp: 약 3,676 lines +- R31/R32/R33/R34 layered wrappers + +이 경로에는: +- mutex +- state block +- live getters +- heap/vector +- render target/depth switching +- viewport manipulation +- multiple fallback paths +가 한 계층에 겹쳐 있다. + +구조적 문제: +.cpp 파일이 다음 .cpp 파일을 include하는 chain이 매우 깊다. +Host main_r23.cpp도 private/public macro로 main.cpp 구현을 include한다. + +CI가 현재 ODR/source-graph 문제를 강하게 검사하므로 빌드는 통과하지만, +수정 하나가 여러 계층의 숨은 전역 상태를 바꿀 수 있어 regression 위험이 높다. + +향후 재설계 권장: +- state tracker +- pass classifier +- stereo executor +- transport publisher +- diagnostics +를 실제 header/interface + 독립 TU로 분리. +Rxx 번호 overlay를 계속 쌓는 방식은 종료하는 것이 좋다. + +====================================================================== +10. TEST COVERAGE GAPS +====================================================================== + +[TEST-01] Effect policy boolean matrix 누락 +현재 static_assert에는 대표 조합만 있다. +다음 케이스를 반드시 추가: +- depth=false, cull!=none, opaque +- depth=false, cull!=none, alpha blend +- depth=false, cull!=none, alpha test +- depth=false, alpha blend + zwrite=true +이들은 현재 WorldStereo가 될 수 있다. + + +[TEST-02] Different shader + identical c64/projection test 누락 +조건: +- shader A에서 verified world WVP 생성 +- shader B로 switch +- c64/projection은 그대로 +- shader B는 HUD/effect +기대: +- B를 A world epoch로 승격하지 않아야 함. + + +[TEST-03] Shader COM pointer reuse test 누락 +- shader A hash +- destroy/reset +- 동일 주소를 재사용하는 shader B +- B hash가 A cache에서 나오면 실패 + + +[TEST-04] Getter-count performance regression test 필요 +가짜 D3D9 device 또는 counters로 1,000 draw 시: +- GetRenderState 호출 수 +- GetViewport +- GetScissorRect +- GetVertexShaderConstantF +상한을 CI에서 검증하도록 하면 hot-path 회귀를 막을 수 있다. + + +[TEST-05] Scissor-enabled stress +scissor가 계속 enabled인 1,000 draw에서 live raster getter가 3,000회 발생하는 현재 구조를 +검출하는 benchmark/test 필요. + +====================================================================== +11. RECOMMENDED FIX ORDER +====================================================================== + +Priority 1 - 화면 오류를 먼저 잡기 +1. depth-disabled draw 기본 zero-disparity화 +2. R28 shader-epoch rebind에 shader fingerprint/강한 semantic proof 추가 +3. 흰 HUD/score/rank fingerprint를 수집해 explicit HUD/effect policy 작성 + +Priority 2 - 게임측 CPU hot path 줄이기 +4. R13 5x GetRenderState를 tracked state shadow로 전환 +5. R23 scissor-enabled per-draw live raster getter 제거 +6. R28 c64 device readback을 tracked WVP write shadow 중심으로 변경 + +Priority 3 - 진단기를 관측 비침습적으로 만들기 +7. shader hash 계산을 SetVertexShader/SetPixelShader 또는 shader creation 시점으로 이동 +8. pointer-only cache 제거 / Reset generation 도입 +9. fingerprint log key에 state/projection/target class 포함 + +Priority 4 - Host jitter 정리 +10. detailed pipeline telemetry runtime gate +11. periodic vector copy/sort 제거 +12. std::cerr hot-path 로그 최소화 + +Priority 5 - 구조 정리 +13. Rxx .cpp include overlay chain을 독립 모듈로 단계적 분해 +14. comparison build를 별도 experimental targets로 격리 + +====================================================================== +12. WHAT SHOULD NOT BE CHANGED YET +====================================================================== + +- R32 DirectGPU async ACK를 다시 synchronous Flush+Wait 경로로 되돌리지 말 것. +- bounded OpenXR swapchain wait를 제거하지 말 것. +- R14 lifetime-bound texture shadow를 과거 fixed-capacity eviction 구조로 되돌리지 말 것. +- 현재 stable vr-openxr에 이 리뷰 결과를 한 번에 대량 merge하지 말 것. +- 먼저 vr-openxr-gpl-reuse에서 P1 수정 -> CI -> 실제 Quest/VDXR test 순서로 진행할 것. + +====================================================================== +13. FINAL ASSESSMENT +====================================================================== + +현재 가장 중요한 결론은 "Host가 느려서만 버벅인다"가 아니다. + +현재 production-safe R26 game-side draw path 자체에: +- draw당 다수의 D3D9 live state getter +- scissor enabled 시 추가 live getter +- shader epoch mismatch 시 WVP device readback +이 남아 있어 CPU/driver overhead가 충분히 크다. + +동시에 렌더 분류 측면에서는: +- depth-disabled overlay의 기본 WorldStereo 가능성 +- 다른 shader를 이전 verified world epoch로 승격하는 R28 rebind +두 항목이 현재 흰 HUD/score/rank 양안 분리의 가장 강한 소스 후보다. + +따라서 다음 수정은 "새 렌더러를 더 얹는 것"보다: +1) pass 분류를 보수적으로 정확하게 만들고 +2) state/shader/WVP 정보를 hook 시점에 shadow/cache하며 +3) draw hot path에서는 O(1) 읽기만 하도록 만드는 것 +이 우선이다. + +Review result classification: +- Confirmed logic/cost defects: 8 +- Medium/low optimization/diagnostic issues: 8+ +- Explicitly reviewed non-issues/positive safeguards: DirectGPU async ACK, + bounded swapchain wait, R14 lifetime registry, current CI/source-graph guards +- Current build status at review base: Build + OpenXR architecture CI SUCCESS + +END OF REPORT diff --git a/docs/VR_UNIFIED_TEST_SCENARIO.md b/docs/VR_UNIFIED_TEST_SCENARIO.md new file mode 100644 index 000000000..0efbfcef8 --- /dev/null +++ b/docs/VR_UNIFIED_TEST_SCENARIO.md @@ -0,0 +1,105 @@ +# OutRun 2006 VR Unified Backend Test Scenario + +Branch: `vr-unified-backends` + +This package is a test integration harness for three renderer paths. It intentionally switches complete backend payloads before game launch instead of stacking mutually exclusive D3D9 hooks in one process. + +## Backend activation + +From the game directory: + +```bat +Select-OutRunVRBackend.cmd d3d9 +Select-OutRunVRBackend.cmd dxvk +Select-OutRunVRBackend.cmd dx12 +``` + +Always exit the game and `outrun-vr-host.exe` before switching. + +Selecting a mode creates a unique session identity and a config snapshot **before game launch**. Existing root logs are sealed into the previous session before the new session starts, so game-side log truncate/overwrite behavior cannot destroy an already sealed test. + +**You do not run the collector before a test.** The easiest path is the GUI selector: choose a backend once, then click **현재 모드 테스트 실행 (종료 후 로그 자동수집)**. This launches `Run-OutRunVRTest.cmd`, starts the game with a clean session, waits for the game to exit, collects the finished logs, removes the sealed root copies, and immediately prepares the next session for another run of the same backend. + +For manual launches, run `Collect-OutRunVRLogs.cmd` **after** each game/host test has ended. The collector now prepares the next session automatically. If you switch backends without collecting first, the selector preserves pending root logs in the previous session before switching. Run `Collect-OutRunVRLogs.cmd -All` at the end to create the matrix ZIP. + +Do not launch the game twice manually without either collecting between runs or using `Run-OutRunVRTest.cmd`; a game that truncates the same filename can overwrite an unsealed run before any tool has a chance to preserve it. + +## Recommended launch flow + +1. Start `OutRunVR-Backend-Selector.cmd`. +2. Pick the backend you want to test. +3. Click **현재 모드 테스트 실행 (종료 후 로그 자동수집)**. +4. Test normally and exit the game. +5. If `outrun-vr-host.exe` also exits within 15 seconds, the launcher automatically creates the session ZIP and prepares the next session. +6. If the host remains open, close it and run `Collect-OutRunVRLogs.cmd` once; the launcher deliberately leaves the root logs untouched in that case. + +The collector does not need to remain running in the background. + +## Recovery checkpoint: 2026-09-20 stereo transport / DX12 device creation + +This package must include the transport fixes from all backend source branches before it is considered testable: + +- D3D9 SAFE: game-side R9 transport policy follows the parsed `DirectGpuOnly` setting directly. With SAFE defaults (`DirectGpuOnly=false`), classic SBS/Desktop Duplication fallback must not be suppressed by a stale process environment value. +- DXVK SAFE/MULTIVIEW: the DXVK game branch carries the same R9 transport-policy fix; do not accept a package whose DXVK source SHA predates that branch fix. +- All D3D11 OpenXR hosts: when no completed SBS frame is published, recovery theater uses the full-width mono source. Left-half cropping is permitted only for a frame explicitly marked `StereoSbsActive` + `RenderFrameStereoComplete`. +- DX12 STRICT: use D3D9On12 legacy `CreateDevice` semantics first. If the runtime rejects OutRun's original presentation values, retry with the bounded compatibility normalization (window handle/refresh, one back buffer, default interval) while remaining on D3D9On12; native D3D9 fallback stays disabled. + +For the next retest, success criteria are intentionally narrow: D3D9 SAFE must publish a completed stereo frame instead of remaining at `composeOk=0`; DXVK SAFE must do the same; DX12 STRICT must at least pass device creation before any later renderer/VR judgement. + +## Recommended short evening test order + +Use 2D ORIGINAL only as a quick no-VR overhead smoke check. Then compare D3D9 control first, followed by DXVK SAFE. Test DXVK MULTIVIEW or DX12 STRICT only if the earlier modes start cleanly. A full A-F sequence is not required; this package contains no fabricated B/C/D variants. + +### A. D3D9 safe baseline +1. Run `Select-OutRunVRBackend.cmd d3d9`. +2. Confirm no local `d3d9.dll` remains. +3. Start Virtual Desktop / VDXR first, then OutRun. +4. Menu: recenter once, rotate and translate head, enter car selection. +5. Gameplay: run a short Mission and then OutRun mode. +6. Check: world stereo, HUD alignment, white rank/score text, sky/cloud head-lock behavior, smoke/skid placement, opponent rank labels, 6th/6 position, Yes/No dialog. +7. Record HMD refresh rate and FPS. +8. Save `OutRun2006Tweaks.log`, `outrun-vr-host-v3*.log`, `outrun-vr-host-pipeline*.log`, and watchdog log. + +### E1. DXVK safe +1. Exit all OutRun/host processes. +2. Run `Select-OutRunVRBackend.cmd dxvk-safe`. +3. Confirm root contains `d3d9.dll` but not `multiviewpatcher.dll`. +4. Repeat the exact A scenario and collect the session ZIP. + +### E2. DXVK multiview +1. Exit all OutRun/host processes. +2. Run `Select-OutRunVRBackend.cmd dxvk`. +3. Confirm root contains `d3d9.dll` and `multiviewpatcher.dll`. +4. Repeat the exact A scenario. +5. Confirm log contains `VR DXVK PROBE:`, `VR DXVK MULTIVIEW:`, and when eligible `TRUE MULTIVIEW world rendering active`. +6. Compare HMD smoothness and frame time against A. Do not judge only by monitor output. + +### F. DX12 strict D3D9On12 +1. Exit all OutRun/host processes. +2. Run `Select-OutRunVRBackend.cmd dx12`. +3. Confirm root `d3d9.dll` is absent. +4. Start VDXR then OutRun. +5. If startup fails, stop there and keep the log; strict mode intentionally does not silently fall back to D3D9/DXVK. +6. If it starts, repeat A and verify DX12 log markers including D3D9On12 / D3D12 host initialization. + +## Pass/fail sheet + +Record for each backend: +- game reaches menu +- menu recenter works +- car-selection model intact +- gameplay reaches first stage +- left/right world geometry agrees +- HUD not split +- white score/rank/Yes-No not split +- sky/cloud stays world-fixed +- smoke/skid placement correct +- opponent rank labels follow cars +- 6th/6 moves to intended VR HUD placement +- no black/loading-screen recenter regression +- headset FPS / refresh +- subjective stutter: none / mild / severe +- monitor mirror mode +- final log filenames + +Do not switch backends while either the game or host is running. diff --git a/docs/VR_WORK_QUEUE.json b/docs/VR_WORK_QUEUE.json new file mode 100644 index 000000000..aa60d26d2 --- /dev/null +++ b/docs/VR_WORK_QUEUE.json @@ -0,0 +1,100 @@ +{ + "schemaVersion": 1, + "branch": "vr-unified-backends", + "sourceBaseline": "ad14ed6c6757634233ec79b71f42be53c563edb7", + "updatedKst": "2026-09-21T03:36:00+09:00", + "owner": "INTEGRATION_PLANNER", + "policy": { + "maxSourceAttemptsPerFinding": 2, + "maxRuntimeCandidates": 6, + "sourceWriteWorker": "FIX", + "runtimeTruthRequiresHmd": true, + "skipUnchangedCompletedInputs": true + }, + "items": [ + { + "id": "VR-FINGERPRINT-001", + "priority": 10, + "status": "READY", + "worker": "FIX", + "sourceArea": "D3D9Ex/DX9Ex draw classification telemetry", + "hypothesis": "Bounded cached shader/decl/stride/texture/render-state fingerprints can identify the Position HUD draw without broad WVP ownership.", + "requiredEvidence": "build/static first; HMD/log evidence before any behavior-changing classifier is accepted", + "mergePolicy": "telemetry-only-first", + "attempts": 0, + "dependsOn": [] + }, + { + "id": "VR-LENS-002", + "priority": 9, + "status": "READY", + "worker": "FIX", + "sourceArea": "D3D9Ex narrow S6/S7 lens-flare classification", + "hypothesis": "Small alpha + live flat-WVP discriminator can collapse duplicate flare without broad world/sky corruption.", + "requiredEvidence": "candidate-only build plus Quest 3 runtime confirmation", + "mergePolicy": "fail-closed-candidate-only", + "attempts": 0, + "dependsOn": [] + }, + { + "id": "VR-POSITIONHUD-001", + "priority": 8, + "status": "NEED_HMD_TEST", + "worker": "INTEGRATION_PLANNER", + "sourceArea": "Position HUD 6th/6", + "hypothesis": "Exact fingerprint evidence is required before placement/scale correction.", + "requiredEvidence": "fingerprint telemetry log tied to a runtime session", + "mergePolicy": "no-broad-primitive-or-WVP-rules", + "attempts": 0, + "dependsOn": ["VR-FINGERPRINT-001"] + }, + { + "id": "VR-WHITEOVERLAY-001", + "priority": 8, + "status": "NEED_HMD_TEST", + "worker": "INTEGRATION_PLANNER", + "sourceArea": "white rank/score and exit YES/NO overlays", + "hypothesis": "These remain split and require exact semantic/fingerprint evidence rather than generic depth-off promotion.", + "requiredEvidence": "runtime fingerprint plus eye-disparity observation", + "mergePolicy": "fail-closed", + "attempts": 0, + "dependsOn": ["VR-FINGERPRINT-001"] + }, + { + "id": "VR-PERF-002", + "priority": 6, + "status": "BLOCKED", + "worker": "INTEGRATION_PLANNER", + "sourceArea": "T5/R29 fast path / mono backup removal", + "hypothesis": "Removing steady mono replay can improve frame rate, but current classifier corrupts sky/world.", + "requiredEvidence": "correctness-preserving classifier gate before performance adoption", + "mergePolicy": "performance-after-correctness", + "attempts": 1, + "dependsOn": ["VR-FINGERPRINT-001"] + }, + { + "id": "VR-DX12-001", + "priority": 5, + "status": "NEED_HMD_TEST", + "worker": "INTEGRATION_PLANNER", + "sourceArea": "DX12 STRICT D3D9On12 device creation", + "hypothesis": "Legacy CreateDevice semantics plus bounded retry may clear the previous D3DERR_INVALIDCALL boundary.", + "requiredEvidence": "runtime startup/device-creation log", + "mergePolicy": "strict-no-native-d3d9-fallback", + "attempts": 0, + "dependsOn": [] + }, + { + "id": "CI-LEGACY-001", + "priority": 2, + "status": "READY", + "worker": "FIX", + "sourceArea": "generic DXVK/DX12 branch CI checks", + "hypothesis": "Reserved PowerShell $Host usage and stale verifier markers can be repaired without changing runtime payload behavior.", + "requiredEvidence": "generic branch CI success", + "mergePolicy": "non-runtime-maintenance", + "attempts": 0, + "dependsOn": [] + } + ] +} diff --git a/docs/autodev/FIX_HANDOFF.json b/docs/autodev/FIX_HANDOFF.json new file mode 100644 index 000000000..84def135e --- /dev/null +++ b/docs/autodev/FIX_HANDOFF.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "producer": "FIX", + "generation": 0, + "queueItemId": null, + "inputCommit": null, + "outputCommit": null, + "status": "IDLE", + "attempt": 0, + "changedPaths": [], + "hypothesis": null, + "expectedEffect": null, + "validationRequest": null, + "completedKst": null +} diff --git a/docs/autodev/REVIEW_HANDOFF.json b/docs/autodev/REVIEW_HANDOFF.json new file mode 100644 index 000000000..30960858d --- /dev/null +++ b/docs/autodev/REVIEW_HANDOFF.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "producer": "REVIEW", + "generation": 0, + "sourceCommit": "ad14ed6c6757634233ec79b71f42be53c563edb7", + "reviewLens": null, + "startedKst": null, + "completedKst": null, + "materialChange": false, + "proposals": [] +} diff --git a/docs/autodev/VALIDATION_HANDOFF.json b/docs/autodev/VALIDATION_HANDOFF.json new file mode 100644 index 000000000..c8f839fde --- /dev/null +++ b/docs/autodev/VALIDATION_HANDOFF.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "producer": "VALIDATION", + "generation": 0, + "queueItemId": null, + "sourceCommit": null, + "status": "IDLE", + "checks": [], + "buildRunId": null, + "workflowRunId": null, + "artifactId": null, + "artifactSha256": null, + "runtimeRequired": false, + "completedKst": null +} diff --git a/src/dllmain.cpp b/src/dllmain.cpp index 4dccc5485..b8f5392ed 100644 --- a/src/dllmain.cpp +++ b/src/dllmain.cpp @@ -7,6 +7,7 @@ #include "resource.h" #include "plugin.hpp" #include "game_addrs.hpp" +#include "vr/ipc/shadow_lifetime_bridge.hpp" void InitExceptionHandler(); // hooks_exceptions.cpp @@ -112,6 +113,15 @@ void Plugin_Init() } } + if (Settings::UseNewInput) + { + spdlog::info("Wheel input: multi-device SDL raw input enabled; legacy DirectInput helpers remain inactive fallback"); + } + else if (Settings::WheelInputCompatibility) + { + spdlog::info("Wheel input: legacy DirectInput compatibility fallback enabled"); + } + Settings::to_log(); Game::StartupTime = std::chrono::system_clock::now(); @@ -157,8 +167,9 @@ BOOL APIENTRY DllMain(HMODULE hModule, int ul_reason_for_call, LPVOID lpReserved } else if (ul_reason_for_call == DLL_PROCESS_DETACH) { + OutRunVR::IpcV3::RequestRegisteredShadowBridgeStop(); proxy::on_detach(); } return TRUE; -} +} \ No newline at end of file diff --git a/src/hook_mgr.cpp b/src/hook_mgr.cpp index eb1a7493d..b9e3b0dea 100644 --- a/src/hook_mgr.cpp +++ b/src/hook_mgr.cpp @@ -1,8 +1,35 @@ #include "hook_mgr.hpp" +#include "plugin.hpp" + +#include +#include + + +namespace +{ + bool IsVrHookDescription(std::string_view description) noexcept + { + return description.starts_with("OpenXRVR") || + description.starts_with("OpenXVRR"); + } + + bool VrHooksEnabledForProcess() noexcept + { + const char* forceDisabled = std::getenv("OUTRUN_VR_FORCE_DISABLED"); + if (forceDisabled && *forceDisabled && + (std::strcmp(forceDisabled, "1") == 0 || + _stricmp(forceDisabled, "true") == 0 || + _stricmp(forceDisabled, "on") == 0)) + { + return false; + } + return Settings::VREnabled.get(); + } +} Hook::Hook() { - HookManager::RegisterHook(this); + HookManager::RegisterHook(this); } void HookManager::ApplyHooks() @@ -11,22 +38,47 @@ void HookManager::ApplyHooks() { hook->declare_settings(); - hook->is_active_ = false; - if (hook->validate()) + hook->is_active_.store(false, std::memory_order_release); + hook->has_error_.store(false, std::memory_order_release); + + const auto desc = hook->description(); + if (IsVrHookDescription(desc) && !VrHooksEnabledForProcess()) { - hook->is_active_ = hook->apply(); + if (!desc.empty()) + spdlog::info("{}: skipped because VR is disabled for this process", desc); + continue; + } - // Separates a hook that failed to apply from one the user turned - // off, which the overlay's hook list shows differently. - hook->has_error_ = !hook->is_active_; + if (hook->validate()) + { + const bool active = hook->apply(); + hook->is_active_.store(active, std::memory_order_release); + hook->has_error_.store(!active, std::memory_order_release); - auto desc = hook->description(); + const auto desc = hook->description(); if (!desc.empty()) { - spdlog::log(hook->is_active_ ? - spdlog::level::info : spdlog::level::err, - "{}: apply {}", desc, hook->is_active_ ? "successful" : "failed"); + spdlog::log(active ? spdlog::level::info : spdlog::level::err, + "{}: apply {}", desc, active ? "successful" : "failed"); } } } } + +void HookManager::ReportAsyncResult(std::string_view description, bool active) +{ + if (description.empty()) + return; + + for (Hook* hook : hooks()) + { + if (!hook || hook->description() != description) + continue; + hook->is_active_.store(active, std::memory_order_release); + hook->has_error_.store(!active, std::memory_order_release); + spdlog::log(active ? spdlog::level::info : spdlog::level::err, + "{}: async installer {}", description, + active ? "ready" : "failed"); + return; + } +} diff --git a/src/hook_mgr.hpp b/src/hook_mgr.hpp index c9c2b4064..4b6f22804 100644 --- a/src/hook_mgr.hpp +++ b/src/hook_mgr.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -30,13 +31,11 @@ class TogglePatch init(address, std::move(patched)); } - // Replaces the instructions at the address with a run of nops. static TogglePatch nop(void* address, size_t size) { return TogglePatch(address, std::vector(size, 0x90)); } - // For patching a pointer or an immediate rather than instructions. template static TogglePatch value(void* address, const T& v) { @@ -58,8 +57,6 @@ class TogglePatch if (!address_ || enabled == applied_) return; - // Written a byte at a time so the size can be decided at runtime, which - // Memory::VP::Patch's initializer_list form can't do. const std::vector& bytes = enabled ? patched_ : original_; for (size_t i = 0; i < bytes.size(); i++) Memory::VP::Patch(address_ + i, bytes[i]); @@ -70,7 +67,6 @@ class TogglePatch bool applied() const { return applied_; } }; -// Base class for hooks class Hook { friend class HookManager; @@ -79,44 +75,29 @@ class Hook Hook(); virtual ~Hook() = default; - - // name/description of hook, for debug logging/tracing virtual std::string_view description() { return ""; } - - // check if user has enabled this hook, and any prerequisites are satisfied virtual bool validate() { return true; } - - // Declares which settings this hook's behaviour depends on, with - // Setting::watch for a value it baked in and has to re-do, or - // Setting::needs_restart for one it can't pick up while the game runs. - // - // Called for every hook regardless of validate(). virtual void declare_settings() {} - - // applies the hook/patch virtual bool apply() = 0; - bool active() + bool active() const noexcept { - return is_active_; + return is_active_.load(std::memory_order_acquire); } - bool error() + bool error() const noexcept { - return has_error_; + return has_error_.load(std::memory_order_acquire); } private: - bool is_active_ = false; - bool has_error_ = false; + std::atomic is_active_{false}; + std::atomic has_error_{false}; }; -// Static HookManager class class HookManager { public: - // Keep hooks vector inside function-local static, to ensure vector actually exists - // before Hooks try to register themselves. static std::vector& hooks() { static std::vector s_hooks; @@ -124,9 +105,14 @@ class HookManager } static void RegisterHook(Hook* hook) - { + { hooks().emplace_back(hook); } static void ApplyHooks(); + + // Hooks whose apply() merely starts an installer worker can report their + // eventual Ready/Failed result back to the overlay/UI. Atomics keep this + // race-free with status readers on the game thread. + static void ReportAsyncResult(std::string_view description, bool active); }; diff --git a/src/hooks_bugfixes.cpp b/src/hooks_bugfixes.cpp index b537f8cf8..02338a232 100644 --- a/src/hooks_bugfixes.cpp +++ b/src/hooks_bugfixes.cpp @@ -1,6 +1,7 @@ #define WIN32_LEAN_AND_MEAN #include #include +#include #include #include #include "hook_mgr.hpp" @@ -437,30 +438,37 @@ class FixFullPedalChecks : public Hook const static int GetVolume_Addr = 0x53720; const static int GetVolumeOld_Addr = 0x53750; - inline static SafetyHookInline GetVolume = {}; - static int GetVolume_dest(int channel) + static int correctPedalValue(int channel, int result) { - int result = GetVolume.call(channel); - if (!Settings::FixFullPedalChecks) - return result; - if (channel != 1 && channel != 2) // accelerate / brake pedals only - return result; - if (result < 255 && result >= 250) + if (Settings::WheelInputCompatibility) + { + const bool invert = + (channel == 1 && Settings::WheelAccelerationInvert) || + (channel == 2 && Settings::WheelBrakeInvert); + + if (invert) + result = 255 - std::clamp(result, 0, 255); + } + + if (Settings::FixFullPedalChecks && (channel == 1 || channel == 2) && + result < 255 && result >= 250) + { result = 255; + } + return result; } + inline static SafetyHookInline GetVolume = {}; + static int GetVolume_dest(int channel) + { + return correctPedalValue(channel, GetVolume.call(channel)); + } + inline static SafetyHookInline GetVolumeOld = {}; static int GetVolumeOld_dest(int channel) { - int result = GetVolumeOld.call(channel); - if (!Settings::FixFullPedalChecks) - return result; - if (channel != 1 && channel != 2) // accelerate / brake pedals only - return result; - if (result < 255 && result >= 250) - result = 255; - return result; + return correctPedalValue(channel, GetVolumeOld.call(channel)); } public: @@ -473,6 +481,13 @@ class FixFullPedalChecks : public Hook { GetVolume = safetyhook::create_inline(Module::exe_ptr(GetVolume_Addr), GetVolume_dest); GetVolumeOld = safetyhook::create_inline(Module::exe_ptr(GetVolumeOld_Addr), GetVolumeOld_dest); + if (Settings::WheelInputCompatibility) + { + spdlog::info( + "WheelPedalDirection: acceleration={}, brake={}", + Settings::WheelAccelerationInvert ? "inverted" : "normal", + Settings::WheelBrakeInvert ? "inverted" : "normal"); + } return !!GetVolume && !!GetVolumeOld; } diff --git a/src/hooks_forcefeedback.cpp b/src/hooks_forcefeedback.cpp index d3681b158..71e699f0f 100644 --- a/src/hooks_forcefeedback.cpp +++ b/src/hooks_forcefeedback.cpp @@ -25,8 +25,28 @@ int VibrationStrength = 10; float VibrationLeftMotor = 0.f; float VibrationRightMotor = 0.f; +bool WheelFFB_IsOutputOwnerActive(); + void SetVibration(int userId, float leftMotor, float rightMotor) { + // Suppress the independent gamepad-rumble path only while the + // DirectInput WheelFFBEngine currently owns an acquired output device. + // A configured-but-missing/lost/unacquired wheel must not disable controller rumble. + static bool wheelOwnedLastCall = false; + if (WheelFFB_IsOutputOwnerActive()) + { + if (!wheelOwnedLastCall) + { + void InputManager_StopVibration(); + InputManager_StopVibration(); + XINPUT_VIBRATION zero{}; + XInputSetState(Settings::VibrationControllerId, &zero); + } + wheelOwnedLastCall = true; + return; + } + wheelOwnedLastCall = false; + if (!Settings::VibrationMode) return; else if (Settings::VibrationMode == 2) // Swap L/R @@ -57,6 +77,8 @@ extern "C" long _ftol2(double); } +void __cdecl WheelFFB_UpdateAfterPhysics(EVWORK_CAR* car); + class Vibration : public Hook { const static int GamePlCar_Ctrl_Addr = 0xA8330; @@ -64,10 +86,14 @@ class Vibration : public Hook inline static SafetyHookInline GamePlCar_Ctrl = {}; static void GamePlCar_Ctrl_Hook(EVWORK_CAR* car) { + // Keep legacy/gamepad vibration timing unchanged. Wheel FFB is + // intentionally different: sample the car only AFTER its physics Ctrl + // returns so body motion/yaw belong to the current simulation tick. CalcVibrationValues(car); SetVibration(0, VibrationLeftMotor, VibrationRightMotor); GamePlCar_Ctrl.call(car); + WheelFFB_UpdateAfterPhysics(car); } public: diff --git a/src/hooks_framerate.cpp b/src/hooks_framerate.cpp index e57eee5e1..214460535 100644 --- a/src/hooks_framerate.cpp +++ b/src/hooks_framerate.cpp @@ -4,6 +4,11 @@ #include "overlay/overlay.hpp" #include "interpolation.hpp" +namespace OutRunVRRenderer +{ + bool IsCadencePacingActive() noexcept; +} + // from timeapi.h, which we can't include since our proxy timeBeginPeriod etc funcs will conflict... typedef struct timecaps_tag { UINT wPeriodMin; /* minimum period supported */ @@ -18,6 +23,11 @@ typedef struct timecaps_tag { namespace Settings { + // Defined by src/vr/settings.cpp. The legacy limiter uses these only to + // provide a safe 60 Hz startup/menu fallback until XR cadence is live. + extern Setting VREnabled; + extern Setting VRFrameCadenceMode; + Setting FramerateLimit{ "Performance", "FramerateLimit", 0, "Valid values are 0 (unlimited/vsync), 60 (vanilla), or values above 60. " "Framerates above 60 will duplicate frames, unless FramerateInterpolation is also enabled." }; @@ -308,9 +318,37 @@ class ReplaceGameUpdateLoop : public Hook } #endif - // Skip framelimiter during load screens to help reduce load times - bool skipFrameLimiter = Settings::FramerateLimit == 0; - if (Settings::FramerateFastLoad > 0 && !skipFrameLimiter) + // When R35/R36 OpenXR cadence is active, xrWaitFrame is the render + // limiter. Keep the 60 Hz simulation clock below, but never stack the + // legacy Tweaks limiter on top of the XR clock. + const bool xrCadencePacing = + OutRunVRRenderer::IsCadencePacingActive(); + const bool vrCadenceFallback = + Settings::VREnabled && Settings::VRFrameCadenceMode > 0 && + !xrCadencePacing; + const bool renderUnlock = + Settings::FramerateUnlockExperimental || xrCadencePacing; + const int effectiveFramerateLimit = + vrCadenceFallback ? 60 : Settings::FramerateLimit.get(); + + // VR must fail safe before the host publishes a valid cadence token. + // With FramerateLimit=0 + immediate Present the menu/load loop can run + // thousands of Presents per second and never advance out of the white + // startup screen. Hold 60 Hz until XR pacing is actually active. + static bool vrCadenceFallbackLogged = false; + if (vrCadenceFallback && !vrCadenceFallbackLogged) + { + vrCadenceFallbackLogged = true; + spdlog::info( + "VR CADENCE FALLBACK: host cadence inactive; software render limiter held at 60 Hz until a valid XR request token is accepted"); + } + + // Fast-load uncapping is unsafe while VR is waiting for its first valid + // host cadence token, so keep the fallback limiter authoritative. + bool skipFrameLimiter = + effectiveFramerateLimit == 0 || xrCadencePacing; + if (Settings::FramerateFastLoad > 0 && !skipFrameLimiter && + !vrCadenceFallback) { if (Settings::FramerateFastLoad != 3) { @@ -361,12 +399,14 @@ class ReplaceGameUpdateLoop : public Hook double timeCurrent = 0; LARGE_INTEGER counter; - const double FramelimiterTargetFrametime = 1000.0 / double(Settings::FramerateLimit); + const double FramelimiterTargetFrametime = + 1000.0 / double(effectiveFramerateLimit); const double deadline = FramelimiterPrevCounter + FramelimiterTargetFrametime; for (;;) { - if (Settings::FramerateFastLoad == 3) + if (Settings::FramerateFastLoad == 3 && + !vrCadenceFallback) PumpFileLoader(deadline); QueryPerformanceCounter(&counter); @@ -382,7 +422,8 @@ class ReplaceGameUpdateLoop : public Hook continue; } - if (Settings::FramerateFastLoad == 3) + if (Settings::FramerateFastLoad == 3 && + !vrCadenceFallback) remaining = min(remaining, FramelimiterFastLoadSleepMs); // PreciseSleep returns right on the deadline, having spun out @@ -415,7 +456,7 @@ class ReplaceGameUpdateLoop : public Hook // when running above 60FPS CalcNumUpdatesToRun would return 0 since game was running fast, for it to skip the current update, but game would still force it to update // if FrameUnlockExperimental is set then we'll allow 0 updates to run, allowing it to skip updating when game is running fast - int minUpdates = Settings::FramerateUnlockExperimental ? 0 : 1; + int minUpdates = renderUnlock ? 0 : 1; if (numUpdates < minUpdates) numUpdates = minUpdates; @@ -465,6 +506,9 @@ class ReplaceGameUpdateLoop : public Hook Game::LinkControlReceive(); Game::ModeControl(); Game::EventControl(); + // Also runs when no player-car Ctrl is dispatched (menus/restarts). + void WheelFFB_ServiceSafety(); + WheelFFB_ServiceSafety(); Game::GhostCarExecServer(); Game::fn4666A0(); @@ -473,7 +517,7 @@ class ReplaceGameUpdateLoop : public Hook // Keeps tick-drawn UI present on frames that skip a tick. Must sit after // the last tick and before the render path queues any draw of its own. - if (Settings::FramerateUnlockExperimental) + if (renderUnlock) SumoUISpriteReplay::update(numUpdates); // Re-run the display-matrix builders with a fractional alpha. Must be @@ -483,7 +527,7 @@ class ReplaceGameUpdateLoop : public Hook // Only meaningful when the experimental unlock is on - otherwise // numUpdates is clamped to >=1, alpha sits near 0, and we would render // a frame behind rather than smoothly between frames. - if (Settings::FramerateUnlockExperimental && Settings::FramerateInterpolation) + if (renderUnlock && Settings::FramerateInterpolation) Interp::AfterTicks(FramelimiterFrequency); } diff --git a/src/hooks_graphics.cpp b/src/hooks_graphics.cpp index 98c3f9a05..db3a54cb8 100644 --- a/src/hooks_graphics.cpp +++ b/src/hooks_graphics.cpp @@ -12,6 +12,13 @@ namespace Settings Setting SkyGlowTwoStep{ "Graphics", "SkyGlowTwoStep", true, "Reduces aliasing in the sky glow effect by handling the SkyGlowFactor in two steps. " "Likely not console-accurate, but can help reduce the aliasing with Factor = 4 or higher." }; + // Defined by src/vr/settings.cpp. Sky glow samples the mono game backbuffer, + // so it must be suppressed while true stereo is active or the left-eye bloom + // gets composited into the right eye as a translucent ghost. + extern Setting VREnabled; + extern Setting VRStereo; + extern Setting VRMirrorFitDesktop; + extern Setting VRDisableDesktopVsync; Setting RestoreXboxBrightness{ "Graphics", "RestoreXboxBrightness", false, "Restores the HDR effect from the Xbox releases, brightening up most areas of the game." }; @@ -286,6 +293,12 @@ RestoreXboxBrightness RestoreXboxBrightness::instance; // more for quality: class RestoreSkyGlow : public Hook { + static bool SkyGlowAllowed() noexcept + { + return Settings::SkyGlowFactor > 0 && + !(Settings::VREnabled && Settings::VRStereo); + } + // (1) The exposure reaches pixel shader constant c7 alpha, but nothing reads // it. Games pixel shaders are assembled by concatenating ps_1_1 snippets looked // up by combiner encoding, and entry for the HDR alpha op holds an empty snippet @@ -525,6 +538,13 @@ class RestoreSkyGlow : public Hook inline static SafetyHookInline MakeReduceBuff_hook = {}; static void __stdcall MakeReduceBuff_dest() { + // True stereo owns two independent eye images. The stock glow reducer + // samples D3DBACKBUFFER_TYPE_MONO, which is the left/game backbuffer in + // the VR renderer; letting this run would bake left-eye scenery into a + // texture that is later composited into both eyes. + if (!SkyGlowAllowed()) + return; + // Checked here rather than in apply so the setting can be changed while the // game runs. if (!Settings::SkyGlowTwoStep || !ReduceHalf) @@ -596,6 +616,12 @@ class RestoreSkyGlow : public Hook inline static SafetyHookInline BlurGlowImage_hook = {}; static void __stdcall BlurGlowImage_dest() { + // Keep the whole post-process chain inert while true stereo is active. + // This is a second fail-safe behind g_GlowEnabled for paths that can be + // entered from cached game state during a mode transition. + if (!SkyGlowAllowed()) + return; + float* offset = Module::exe_ptr(BlurOffset_Addr); float* step = Module::exe_ptr(BlurOffsetStep_Addr); @@ -646,6 +672,23 @@ class RestoreSkyGlow : public Hook spdlog::info("RestoreSkyGlow: glow buffers sized {}x{}", w, h); + // True-stereo VR owns its own per-eye R30 glow chain. Do not allocate + // the game's mono glow render targets here: besides wasting a large + // buffer at 4K, that chain can only sample D3DBACKBUFFER_TYPE_MONO and + // would later leak left-eye content into the right eye. + if (Settings::VREnabled && Settings::VRStereo) + { + if (ReduceHalf) + { + ReduceHalf->Release(); + ReduceHalf = nullptr; + } + spdlog::info( + "RestoreSkyGlow: stock mono buffers skipped in true-stereo VR; R30 owns independent eye buffers factor={}", + Settings::SkyGlowFactor.get()); + return; + } + GlowInit_hook.stdcall(); // Halfway stage for the reduce, so neither StretchRect does more than a 2x. @@ -656,7 +699,7 @@ class RestoreSkyGlow : public Hook ReduceHalf = nullptr; } - if (Settings::SkyGlowFactor > 2) + if (SkyGlowAllowed() && Settings::SkyGlowFactor > 2) { if (IDirect3DDevice9* device = Game::D3DDevice()) { @@ -676,6 +719,12 @@ class RestoreSkyGlow : public Hook inline static SafetyHookInline ClearBuffer_hook = {}; static int __cdecl ClearBuffer_dest(int a1) { + // When glow is disabled (including true-stereo VR), preserve the game's + // original clear alpha exactly. The exposure seed only exists to feed the + // mono sky-glow extraction pass. + if (!SkyGlowAllowed()) + return ClearBuffer_hook.ccall(a1); + uint32_t* backColor = Module::exe_ptr(BackColor_Addr); const uint32_t prevColor = *backColor; @@ -713,12 +762,16 @@ class RestoreSkyGlow : public Hook // SkyGlowTwoStep is read inside MakeReduceBuff on every call, so nothing // needs declaring for it at all. Settings::SkyGlowFactor.watch([] { RebuildGlowBuffers(); }); + Settings::VREnabled.watch([] { RebuildGlowBuffers(); }); + Settings::VRStereo.watch([] { RebuildGlowBuffers(); }); } bool apply() override { // Note: hooks/patches are always applied regardless of INI settings, so they can be changed at runtime - Memory::VP::Patch(Module::exe_ptr(GlowEnabled_Addr), uint8_t(Settings::SkyGlowFactor > 0 ? 1 : 0)); + Memory::VP::Patch(Module::exe_ptr(GlowEnabled_Addr), uint8_t(SkyGlowAllowed() ? 1 : 0)); + if (Settings::SkyGlowFactor > 0 && !SkyGlowAllowed()) + spdlog::info("VR SKY GLOW: stock mono-backbuffer chain disabled; stereo renderer owns independent left/right glow extraction and blur"); Memory::VP::Patch(Module::exe_ptr(AlphaOpTable_ps11_Entry0Snippet), uintptr_t(Snippet_ps11)); Memory::VP::Patch(Module::exe_ptr(AlphaOpTable_ps14_Entry0Snippet), uintptr_t(Snippet_ps14)); @@ -763,7 +816,7 @@ class RestoreSkyGlow : public Hook // Release before patching the enabled byte, so it frees resources if needed GlowRelease_dest(); - Memory::VP::Patch(Module::exe_ptr(GlowEnabled_Addr), uint8_t(Settings::SkyGlowFactor > 0 ? 1 : 0)); + Memory::VP::Patch(Module::exe_ptr(GlowEnabled_Addr), uint8_t(SkyGlowAllowed() ? 1 : 0)); GlowInit_dest(); } @@ -1174,10 +1227,35 @@ class WindowedBorderless : public Hook static void destination(safetyhook::Context& ctx) { HWND window = HWND(ctx.ebp); - SetWindowPos(window, 0, - Settings::WindowPositionX, Settings::WindowPositionY, - Game::screen_resolution->x, Game::screen_resolution->y, - 0x40); + int x = Settings::WindowPositionX; + int y = Settings::WindowPositionY; + int width = Game::screen_resolution->x; + int height = Game::screen_resolution->y; + + // VR may intentionally render a larger internal backbuffer than the PC + // desktop. Keep that render resolution for the HMD/SBS source, but fit the + // borderless mirror window to the current monitor so a 3840x2160 render on + // a 3440x1440 display is scaled instead of extending off-screen. + if (Settings::VREnabled && Settings::VRMirrorFitDesktop) + { + MONITORINFO monitorInfo{}; + monitorInfo.cbSize = sizeof(monitorInfo); + const HMONITOR monitor = MonitorFromWindow( + window, MONITOR_DEFAULTTOPRIMARY); + if (monitor && GetMonitorInfoW(monitor, &monitorInfo)) + { + x = monitorInfo.rcMonitor.left; + y = monitorInfo.rcMonitor.top; + width = monitorInfo.rcMonitor.right - monitorInfo.rcMonitor.left; + height = monitorInfo.rcMonitor.bottom - monitorInfo.rcMonitor.top; + spdlog::info( + "VR PC MIRROR: fitting borderless window to monitor {}x{} while internal backbuffer remains {}x{}", + width, height, + Game::screen_resolution->x, Game::screen_resolution->y); + } + } + + SetWindowPos(window, 0, x, y, width, height, 0x40); } public: @@ -1304,9 +1382,19 @@ class VSyncOverride : public Hook inline static SafetyHookMid dest_hook = {}; static void destination(safetyhook::Context& ctx) { - Game::D3DPresentParams->PresentationInterval = Settings::VSync; - if (!Settings::VSync) - Game::D3DPresentParams->PresentationInterval = D3DPRESENT_INTERVAL_IMMEDIATE; + if (Settings::VREnabled && Settings::VRDisableDesktopVsync) + { + Game::D3DPresentParams->PresentationInterval = + D3DPRESENT_INTERVAL_IMMEDIATE; + spdlog::info("VR PRESENT: desktop D3D9 VSync bypass active; OpenXR cadence is owned by the host/runtime"); + } + else + { + Game::D3DPresentParams->PresentationInterval = Settings::VSync; + if (!Settings::VSync) + Game::D3DPresentParams->PresentationInterval = + D3DPRESENT_INTERVAL_IMMEDIATE; + } // TODO: add MultiSampleType / MultiSampleQuality overrides here? // (doesn't seem any of them are improvement over vanilla "DX/ANTIALIASING = 2" though...) @@ -1320,7 +1408,8 @@ class VSyncOverride : public Hook bool validate() override { - return Settings::VSync != 1; + return Settings::VSync != 1 || + (Settings::VREnabled && Settings::VRDisableDesktopVsync); } void declare_settings() override diff --git a/src/hooks_input.cpp b/src/hooks_input.cpp index 30130340c..d9c29bdba 100644 --- a/src/hooks_input.cpp +++ b/src/hooks_input.cpp @@ -4,6 +4,8 @@ #include #include +#include +#include #include #include "hook_mgr.hpp" @@ -13,11 +15,18 @@ namespace Settings { - Setting SteeringDeadZone{ "Controls", "SteeringDeadZone", 0.2f, - "Allows overriding the steering deadzone. Game default is 0.2 / 20%.", Range{ 0.f, 1.f } }; + Setting SteeringDeadZone{ "Controls", "SteeringDeadZone", 0.0f, + "Allows overriding the steering deadzone. Original game default is 0.2 / 20%; wheel branch default is 0%.", Range{ 0.f, 1.f } }; Setting ControllerHotPlug{ "Controls", "ControllerHotPlug", false, "Allows game to detect newly plugged in devices, rather than needing a restart. May have issues with some " "controllers/wheels, and is ignored when using UseNewInput as hot-plug is supported by it by default." }; + Setting WheelAccelerationInvert{ "Controls", "WheelAccelerationInvert", false, + "Reverses the legacy DirectInput acceleration channel for wheel pedals that report released as maximum." }; + Setting WheelBrakeInvert{ "Controls", "WheelBrakeInvert", false, + "Reverses the legacy DirectInput brake channel. Enable this only if the brake is active while released." }; + Setting WheelMenuDirectionFilter{ "Controls", "WheelMenuDirectionFilter", true, + "Learns and suppresses a legacy DirectInput direction that is held continuously by a pedal at rest. " + "Keyboard arrow keys remain available for menu navigation." }; Setting ImpulseVibrationMode{ "Controls", "ImpulseVibrationMode", 2, "Enable/disable Xbox Series impulse trigger vibration, or customise it. VibrationMode must be enabled for this to work.", { "Disable", "Enable impulse triggers", "L/R motors swapped (recommended for impulse triggers)", @@ -297,6 +306,117 @@ void DInput_RegisterNewDevices() } } +// A reversed pedal can also be interpreted as a permanently-held menu direction +// before GetVolume is called. Learn only direction bits that stay held while the +// game is outside a race, then rebuild their edge masks after filtering. This +// preserves the wheel's other menu buttons and explicitly re-injects keyboard +// arrows so a filtered wheel direction never disables keyboard navigation. +class LegacyWheelMenuFilter : public Hook +{ + struct Direction + { + uint32_t mask; + int virtualKey; + const char* name; + }; + + inline static constexpr std::array Directions = {{ + { 0x40, VK_UP, "up" }, + { 0x20, VK_DOWN, "down" }, + { 0x100, VK_LEFT, "left" }, + { 0x80, VK_RIGHT, "right" }, + }}; + inline static constexpr uint32_t DirectionMask = 0x1E0; + inline static constexpr auto LearnDelay = std::chrono::milliseconds(250); + + inline static SafetyHookInline DInputUpdate = {}; + inline static std::array heldSince = {}; + inline static uint32_t learnedMask = 0; + inline static uint32_t previousOutput = 0; + + static SumoDInputState* DInputUpdate_dest() + { + auto* state = DInputUpdate.call(); + if (!state) + state = Game::dinput_state; + + if (!state || !Settings::WheelInputCompatibility || !Settings::WheelMenuDirectionFilter) + return state; + + const auto now = std::chrono::steady_clock::now(); + const bool mayLearn = Game::current_mode && *Game::current_mode != STATE_GAME; + uint32_t keyboardDirections = 0; + + for (size_t i = 0; i < Directions.size(); ++i) + { + const auto& direction = Directions[i]; + const bool keyboardHeld = (GetAsyncKeyState(direction.virtualKey) & 0x8000) != 0; + const bool rawHeld = (state->buttons_4 & direction.mask) != 0; + + if (keyboardHeld) + keyboardDirections |= direction.mask; + + if ((learnedMask & direction.mask) == 0) + { + if (mayLearn && rawHeld && !keyboardHeld) + { + if (heldSince[i] == std::chrono::steady_clock::time_point{}) + heldSince[i] = now; + else if (now - heldSince[i] >= LearnDelay) + { + learnedMask |= direction.mask; + spdlog::info( + "WheelMenuDirectionFilter: suppressing continuously-held '{}' input (mask 0x{:X})", + direction.name, direction.mask); + } + } + else + { + heldSince[i] = {}; + } + } + } + + const uint32_t rawDirections = state->buttons_4 & DirectionMask; + const uint32_t outputDirections = (rawDirections & ~learnedMask) | keyboardDirections; + + state->buttons_4 = (state->buttons_4 & ~DirectionMask) | outputDirections; + state->pressed_8 = (state->pressed_8 & ~DirectionMask) | + (outputDirections & ~previousOutput); + state->released_C = (state->released_C & ~DirectionMask) | + (previousOutput & ~outputDirections); + previousOutput = outputDirections; + + return state; + } + +public: + std::string_view description() override + { + return "WheelMenuDirectionFilter"; + } + + bool validate() override + { + return Settings::WheelInputCompatibility; + } + + void declare_settings() override + { + Settings::WheelMenuDirectionFilter.needs_restart(); + Settings::WheelMenuDirectionFilter.hidden(Settings::UseNewInput); + } + + bool apply() override + { + DInputUpdate = safetyhook::create_inline(Module::exe_ptr(0x6FA0), DInputUpdate_dest); + return !!DInputUpdate; + } + + static LegacyWheelMenuFilter instance; +}; +LegacyWheelMenuFilter LegacyWheelMenuFilter::instance; + class SteeringDeadZone : public Hook { public: diff --git a/src/hooks_textures.cpp b/src/hooks_textures.cpp index d9db5307a..1267d42af 100644 --- a/src/hooks_textures.cpp +++ b/src/hooks_textures.cpp @@ -7,9 +7,20 @@ #include #include #include +#include +#include + +namespace OutRunVRHudInspector +{ + void TracePutSprite(SPRARGS* sprargs, float priority, + const void* returnAddress); + void TracePutSprite2(SPRARGS2* sprargs, float priority, + const void* returnAddress); +} namespace Settings { + extern Setting VREnabled; Setting TextureBaseFolder{ "Graphics", "TextureBaseFolder", "textures", "The base folder for texture replacements. Replacements are loaded from [TextureBaseFolder]/load/, and vanilla " "textures are extracted to [TextureBaseFolder]/dump/." }; @@ -33,6 +44,7 @@ namespace Settings } #define MAX_TEXTURE_CACHE_SIZE_MB (1024 + 256) +#define VR_MAX_TEXTURE_CACHE_SIZE_MB 64 #define DDS_MAGIC 0x20534444 // "DDS " struct DDS_FILE @@ -303,13 +315,20 @@ class FileDataCache std::unordered_map cache; std::list lru_list; - void evict() + void evictToFit(std::size_t incoming = 0) { - while (!lru_list.empty() && current_cache_size > max_cache_size) + if (incoming > max_cache_size) + incoming = max_cache_size; + while (!lru_list.empty() && + current_cache_size > max_cache_size - incoming) { std::filesystem::path lru_file = lru_list.back(); - current_cache_size -= cache[lru_file].data.size(); - cache.erase(lru_file); + auto found = cache.find(lru_file); + if (found != cache.end()) + { + current_cache_size -= found->second.data.size(); + cache.erase(found); + } lru_list.pop_back(); } } @@ -321,6 +340,13 @@ class FileDataCache public: FileDataCache(std::size_t maxCacheSize) : max_cache_size(maxCacheSize), current_cache_size(0) {} + void setMaxCacheSize(std::size_t bytes) + { + std::lock_guard _(mtx1); + max_cache_size = std::max(bytes, 16 * 1024 * 1024); + evictToFit(); + } + void cacheFolder(std::filesystem::path folder) { if (std::filesystem::exists(folder)) @@ -352,27 +378,61 @@ class FileDataCache std::ifstream file(filename, std::ios::binary | std::ios::ate); if (!file) { - throw std::runtime_error("Unable to open file: " + filename.string()); + spdlog::warn("Texture cache: unable to open {}", filename.string()); + return; } - std::streamsize size = file.tellg(); + const std::streamsize streamSize = file.tellg(); + if (streamSize <= 0) + return; + const std::size_t size = static_cast(streamSize); + if (size > max_cache_size) + { + spdlog::warn( + "Texture cache: skipping {} ({} MiB) because it exceeds the {} MiB cache budget", + filename.string(), size / (1024 * 1024), + max_cache_size / (1024 * 1024)); + return; + } + + // R35.1: make address-space room before allocating the incoming DDS. + // The old code allocated first and evicted afterwards, allowing a + // 32-bit process to throw std::bad_alloc even while the configured LRU + // budget would eventually have been respected. + evictToFit(size); file.seekg(0, std::ios::beg); - std::vector buffer(size); - if (!file.read(reinterpret_cast(buffer.data()), size)) - { - throw std::runtime_error("Error reading file: " + filename.string()); - } - else + try { - current_cache_size += size; - evict(); - - if (size > max_cache_size) - throw std::runtime_error("File size exceeds maximum cache size"); + std::vector buffer(size); + if (!file.read(reinterpret_cast(buffer.data()), + static_cast(size))) + { + spdlog::warn("Texture cache: error reading {}", filename.string()); + return; + } lru_list.push_front(filename); - cache[filename] = { std::move(buffer), lru_list.begin() }; + try + { + cache[filename] = { std::move(buffer), lru_list.begin() }; + current_cache_size += size; + } + catch (...) + { + lru_list.pop_front(); + throw; + } + } + catch (const std::bad_alloc&) + { + // Texture replacement is optional. Under 32-bit VR memory pressure, + // failing this cache entry must not terminate the game; a cache miss + // falls back to the normal/original texture path. + spdlog::warn( + "Texture cache: allocation failed for {} ({} MiB); skipping cache entry to keep the 32-bit game alive", + filename.string(), size / (1024 * 1024)); + return; } } @@ -557,6 +617,7 @@ class TextureReplacement : public Hook inline static SafetyHookInline put_sprite_ex2 = {}; static int __cdecl put_sprite_ex2_dest(SPRARGS2* a1, float a2) { + OutRunVRHudInspector::TracePutSprite2(a1, a2, _ReturnAddress()); int xstnum = a1->xstnum_0; if (a1->d3dtexture_ptr_C == prevTexture && sprite_scales.contains(prevTextureId)) @@ -590,6 +651,7 @@ class TextureReplacement : public Hook inline static SafetyHookInline put_sprite_ex = {}; static int __cdecl put_sprite_ex_dest(SPRARGS* a1, float a2) { + OutRunVRHudInspector::TracePutSprite(a1, a2, _ReturnAddress()); int xstnum = a1->xstnum_0; if (sprite_scales.contains(xstnum)) { @@ -830,7 +892,22 @@ class TextureReplacement : public Hook OutputDebugStringA(msg.c_str()); #endif - FileData.cacheFolder(folderPath); + try + { + FileData.cacheFolder(folderPath); + } + catch (const std::exception& e) + { + spdlog::warn( + "Texture cache: background preload failed for {}: {}", + folderPath.string(), e.what()); + } + catch (...) + { + spdlog::warn( + "Texture cache: background preload failed for {} with an unknown exception", + folderPath.string()); + } isComplete = true; } @@ -895,6 +972,16 @@ class TextureReplacement : public Hook XmtDumpPath = textureBaseDir / "dump"; XmtLoadPath = textureBaseDir / "load"; + if (Settings::VREnabled) + { + FileData.setMaxCacheSize( + static_cast(VR_MAX_TEXTURE_CACHE_SIZE_MB) * + 1024 * 1024); + spdlog::info( + "VR texture cache: capped at {} MiB to preserve 32-bit address space for D3D9Ex stereo/shadow resources", + VR_MAX_TEXTURE_CACHE_SIZE_MB); + } + // Startup texture cache, causes game to take a while to boot, disabled for now... #if 0 if (std::filesystem::exists(XmtLoadPath)) diff --git a/src/hooks_wheel_ffb.cpp b/src/hooks_wheel_ffb.cpp new file mode 100644 index 000000000..ec023e7cb --- /dev/null +++ b/src/hooks_wheel_ffb.cpp @@ -0,0 +1,3916 @@ +#define WIN32_LEAN_AND_MEAN +#define DIRECTINPUT_VERSION 0x0800 +#include +#include +#include + +#pragma comment(lib, "dinput8.lib") +#pragma comment(lib, "dxguid.lib") +#pragma comment(lib, "Comctl32.lib") + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "hook_mgr.hpp" +#include "Proxy.hpp" +#include "plugin.hpp" +#include "game_addrs.hpp" +#include "hooks_wheel_vehicle_dynamics.hpp" +#include "wheel_ffb_math.hpp" +#include "wheel_ffb_runtime.hpp" +#include "overlay/overlay.hpp" + +extern "C" +{ + void __cdecl CalcVibrationValues(EVWORK_CAR* car); +} + +extern double __cdecl sub_1149C0(unsigned int surfaceMask, int loadColiType, DWORD* waterFlag); +extern float InputManager_SteeringValue(); + +namespace Settings +{ + extern Setting UseNewInput; + // Experimental branch: enabled by default, but deliberately conservative for DD wheels. + Setting WheelFFBEnable{ + "WheelFFB", "Enable", true, + "Enable experimental DirectInput COM force feedback for steering wheels." + }; + + Setting WheelFFBDeviceName{ + "WheelFFB", "DeviceName", "MOZA", + "Case-insensitive substring used to select the FFB wheel. Empty selects the first non-virtual FFB device." + }; + + Setting WheelFFBDeviceGuid{ + "WheelFFB", "DeviceGuid", "", + "Preferred DirectInput FFB interface selected by F11 Wheel Setup; if validation fails, a compatible sibling interface can be probed and pinned automatically." + }; + + Setting WheelFFBResponseCorrection{ + "WheelFFB", "ResponseCorrection", false, + "Optional wheel-specific ConstantForce response correction. Store this with the wheel profile, not a force-feel profile." + }; + + Setting WheelFFBResponseLUT{ + "WheelFFB", "ResponseLUT", "0.0,0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.8,0.9,1.0", + "Eleven monotonic command samples for desired torque 0..100% in 10% steps. Linear by default." + }; + + Setting WheelFFBMaxTorqueNm{ + "WheelFFB", "MaxTorqueNm", 0.0f, + "Optional physical wheel peak torque for diagnostics only. 0 means unknown.", + Range{ 0.0f, 30.0f } + }; + + Setting WheelFFBGlobalStrength{ + "WheelFFB", "GlobalStrength", 0.70f, + "Master force-model gain. Applied before soft saturation/slew; 1.0=100%, 1.5=150% headroom.", Range{ 0.0f, 1.5f } + }; + + Setting WheelFFBSpringStrength{ + "WheelFFB", "SpringStrength", 0.65f, + "Speed-dependent center restoring force. Drives GUID_Spring when hardware spring is enabled.", Range{ 0.0f, 1.5f } + }; + + Setting WheelFFBUseHardwareSpring{ + "WheelFFB", "UseHardwareSpring", true, + "Use a DirectInput GUID_Spring condition effect for centering instead of synthesizing spring torque at 60 Hz." + }; + + Setting WheelFFBSpringSaturation{ + "WheelFFB", "SpringSaturation", 0.775f, + "Maximum hardware spring output before GlobalStrength, based on the FXT spring saturation model.", Range{ 0.1f, 1.0f } + }; + + Setting WheelFFBDamperStrength{ + "WheelFFB", "DamperStrength", 0.30f, + "Dynamic steering damping. For DD wheels try 0.25-0.45.", Range{ 0.0f, 1.0f } + }; + + Setting WheelFFBUseHardwareDamper{ + "WheelFFB", "UseHardwareDamper", true, + "Use DirectInput GUID_Damper when the wheel supports it; otherwise use the software fallback." + }; + + Setting WheelFFBSteeringWeight{ + "WheelFFB", "SteeringWeight", 1.45f, + "Self-aligning torque strength. Physics SAT direction/trail comes from front slip; field_264/268 only scale lateral load.", Range{ 0.0f, 2.0f } + }; + + Setting WheelFFBMechanicalTrail{ + "WheelFFB", "MechanicalTrail", 0.25f, + "Normalized mechanical/caster-trail ratio in Physics SAT. Acts with front lateral force across the corner; not a centre spring.", + Range{ 0.0f, 0.60f } + }; + + Setting WheelFFBTrailResponseLead{ + "WheelFFB", "TrailResponseLead", 0.25f, + "Pneumatic-trail transient phase lead toward raw front slip. Lateral force and torque direction remain filtered.", + Range{ 0.0f, 0.60f } + }; + + Setting WheelFFBPhysicsSat{ + "WheelFFB", "PhysicsSAT", true, + "Experimental body-slip/yaw SAT instead of steering-centre direction alone." + }; + + Setting WheelFFBGripLoss{ + "WheelFFB", "GripLoss", 0.65f, + "How strongly real chassis/front-slip signals release damping and unload SAT. Lateral G is load only, never a drift detector.", Range{ 0.0f, 1.0f } + }; + + Setting WheelFFBLateralDeadzone{ + "WheelFFB", "LateralDeadzone", 1.5f, + "Subtractive noise floor for the game's lateral-G/load signal.", Range{ 0.0f, 8.0f } + }; + + Setting WheelFFBWeightTransfer{ + "WheelFFB", "WeightTransfer", 0.60f, + "Longitudinal acceleration/braking modulation of steering load.", Range{ 0.0f, 1.5f } + }; + + Setting WheelFFBWallImpact{ + "WheelFFB", "WallImpact", 0.38f, + "Collision impulse strength.", Range{ 0.0f, 1.0f } + }; + + Setting WheelFFBGearShift{ + "WheelFFB", "GearShift", 0.18f, + "Symmetric gear-change thunk strength.", Range{ 0.0f, 1.0f } + }; + + Setting WheelFFBRoadTexture{ + "WheelFFB", "RoadTexture", 0.30f, + "Hardware sine vibration driven by the game's own surface roughness table.", Range{ 0.0f, 1.0f } + }; + + Setting WheelFFBTireSlip{ + "WheelFFB", "TireSlip", 0.20f, + "Hardware sine chatter driven mainly by estimated front-tire scrub, with a small chassis-slide contribution.", Range{ 0.0f, 1.0f } + }; + + Setting WheelFFBEngineVibration{ + "WheelFFB", "EngineVibration", false, + "Optional low-amplitude engine-speed haptic through the universal ConstantForce output path." + }; + + // Keep the legacy EngineIdle key for INI/profile compatibility. It now owns + // the complete engine-vibration strength rather than only launch/idle rumble. + Setting WheelFFBEngineIdle{ + "WheelFFB", "EngineIdle", 0.20f, + "Engine vibration strength (legacy EngineIdle key retained for compatibility).", + Range{ 0.0f, 1.0f } + }; + + Setting WheelFFBSlewRate{ + "WheelFFB", "SlewRate", 0.06f, + "Maximum normal structural-force build change per 60 Hz tick, normalized 0..1.", Range{ 0.01f, 1.0f } + }; + + Setting WheelFFBReversalReleaseRate{ + "WheelFFB", "ReversalReleaseRate", 0.12f, + "Dedicated stale-torque release rate when SAT changes direction. Higher values reduce counter-steer latency without accelerating normal force build.", + Range{ 0.02f, 1.0f } + }; + + Setting WheelFFBUsePeriodicEffects{ + "WheelFFB", "UsePeriodicEffects", true, + "Use DirectInput hardware GUID_Sine effects for road texture and tire slip." + }; + + Setting WheelFFBInvertForce{ + "WheelFFB", "InvertForce", true, + "Reverse ConstantForce steering/event direction without changing the centering spring." + }; + + Setting WheelFFBInvertSpring{ + "WheelFFB", "InvertSpring", false, + "Reverse only the DirectInput GUID_Spring condition direction. Leave off when the wheel returns toward centre normally." + }; + + Setting WheelFFBTelemetry{ + "WheelFFB", "Telemetry", false, + "Opt-in 10 Hz dynamics and force-pipeline telemetry. Event logs remain separate." + }; + + Setting WheelFFBDebugLog{ + "WheelFFB", "DebugLog", true, + "Write a compact FFB diagnostic line about once every two seconds." + }; +} + +namespace +{ + constexpr UINT_PTR FFB_SUBCLASS_ID = 0x0FFB; + constexpr UINT_PTR FFB_WATCHDOG_TIMER_ID = 0x0FFA; + constexpr UINT FFB_WATCHDOG_INTERVAL_MS = 100; + constexpr DWORD FFB_EFFECT_LEASE_US = 250000; + constexpr DWORD FFB_EFFECT_REFRESH_MS = 100; + constexpr DWORD FFB_DEVICE_FAILURE_GRACE_MS = 750; + constexpr DWORD FFB_DEVICE_RETRY_MS = 750; + constexpr DWORD FFB_DEVICE_FAILED_BACKOFF_MS = 10000; + constexpr unsigned FFB_CONSTANT_LIVE_FAILURE_LIMIT = 3; + + // GetTickCount wraps roughly every 49.7 days. Compare deadlines by signed + // subtraction so retry/holdoff gates remain correct across the wrap. + bool tick_before(DWORD now, DWORD deadline) + { + return deadline != 0 && static_cast(now - deadline) < 0; + } + + bool tick_reached(DWORD now, DWORD deadline) + { + return deadline == 0 || static_cast(now - deadline) >= 0; + } + + std::string lower_copy(const char* text) + { + std::string result = text ? text : ""; + std::transform(result.begin(), result.end(), result.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return result; + } + + std::string directinput_guid_key(const GUID& guid) + { + char b[64]{}; + std::snprintf(b, sizeof(b), + "%08X-%04X-%04X-%02X%02X-%02X%02X%02X%02X%02X%02X", + (unsigned)guid.Data1, (unsigned)guid.Data2, (unsigned)guid.Data3, + (unsigned)guid.Data4[0], (unsigned)guid.Data4[1], + (unsigned)guid.Data4[2], (unsigned)guid.Data4[3], + (unsigned)guid.Data4[4], (unsigned)guid.Data4[5], + (unsigned)guid.Data4[6], (unsigned)guid.Data4[7]); + return lower_copy(b); + } + + bool directinput_guid_equal(const GUID& a, const GUID& b) + { + return std::memcmp(&a, &b, sizeof(GUID)) == 0; + } + + bool directinput_guid_is_zero(const GUID& guid) + { + const GUID zero{}; + return directinput_guid_equal(guid, zero); + } + + bool is_virtual_device_name(const std::string& lowered) + { + static constexpr const char* VirtualNames[] = { + "vjoy", "vigem", "xoutput", "virtual", "vxbox", "v xbox", + "hidguardian", "hidhide" + }; + + for (const char* token : VirtualNames) + { + if (lowered.find(token) != std::string::npos) + return true; + } + return false; + } + + class WheelFFBEngine + { + public: + void update(EVWORK_CAR* car) + { + if (panicStopped_) + return; + + if (disable_live_if_needed()) + return; + enabledLastTick_ = true; + + if (!car) + { + if (initialized_) + { + zero_all_forces(); + reset_signal_state(); + } + lastCar_ = nullptr; + return; + } + + // Safety first for a DD base: WM_ACTIVATEAPP releases exclusive + // ownership when the game loses focus. The foreground window is also + // authoritative on recovery so a missed activation message cannot + // leave FFB permanently dormant after Alt-Tab. + if (!appActive_) + { + if (gameHwnd_ && GetForegroundWindow() == gameHwnd_) + { + appActive_ = true; + warmupFrames_ = 0; + } + else + { + if (initialized_) + { + zero_all_forces(); + reset_signal_state(); + } + return; + } + } + + // WM_ACTIVATEAPP is normally authoritative, but keep a direct + // foreground guard as a second DD-wheel safety net. A delayed or + // missed activation message must never leave background torque + // running merely because the device uses DISCL_BACKGROUND. + if (initialized_ && gameHwnd_ && GetForegroundWindow() != gameHwnd_) + { + appActive_ = false; + zero_all_forces(); + reset_signal_state(); + if (device_ && deviceAcquired_) + { + device_->Unacquire(); + deviceAcquired_ = false; + } + return; + } + + const DWORD updateNow = GetTickCount(); + + if (initialized_) + { + const std::string guidNow = lower_copy(Settings::WheelFFBDeviceGuid.get().c_str()); + const std::string nameNow = lower_copy(Settings::WheelFFBDeviceName.get().c_str()); + if (guidNow != selectedConfiguredGuid_ || nameNow != selectedConfiguredName_) + { + selectedConfiguredGuid_ = guidNow; + selectedConfiguredName_ = nameNow; + preferredVidPid_ = 0; + preferredProductGuid_ = {}; + preferredFFDriverGuid_ = {}; + preferredVendorId_ = 0; + failedInterfaces_.clear(); + directionTested_ = false; + request_device_reinitialize("configured wheel identity changed", S_OK); + return; + } + } + + if (deviceReinitPending_) + { + if (tick_before(updateNow, deviceReinitAfter_)) + return; + teardown_for_reinitialize(); + deviceReinitPending_ = false; + deviceFailureSince_ = 0; + initialized_ = false; + retryAfter_ = updateNow + FFB_DEVICE_RETRY_MS; + spdlog::info( + "WheelFFB: DirectInput device released; waiting to re-enumerate after device loss"); + return; + } + + lastUpdateTick_ = updateNow; + + if (!initialized_) + { + if (tick_before(GetTickCount(), retryAfter_)) + return; + + const size_t failedBefore = active_failed_interface_count(); + const bool ready = initialize(); + if (!ready) + { + const size_t failedAfter = active_failed_interface_count(); + if (failedAfter > failedBefore) + { + // Compatibility rejection is progress, not a reason to + // block the game thread while every sibling is reopened. + // Probe the next candidate on the next update tick. + retryAfter_ = 0; + spdlog::info( + "WheelFFB: FFB interface failed validation; next compatible interface will be probed on the next update tick ({} rejected this cycle)", + failedAfter); + return; + } + + // initialize() can request the short retry interval for + // transient startup/focus conditions. Do not overwrite that + // with the rejected-interface backoff. + const DWORD retryNow = GetTickCount(); + if (!tick_before(retryNow, retryAfter_)) + retryAfter_ = retryNow + FFB_DEVICE_FAILED_BACKOFF_MS; + return; + } + } + + const bool inGameplay = + Game::current_mode && (*Game::current_mode == STATE_GAME); + + if (!inGameplay) + { + zero_all_forces(); + // Let the legacy/menu DirectInput reader reacquire the same + // physical wheel while menus or F11 setup are active. Effects + // remain created and are reused when gameplay resumes. + if (device_ && deviceAcquired_) + { + device_->Unacquire(); + deviceAcquired_ = false; + } + reset_signal_state(); + return; + } + + if (device_ && !deviceAcquired_) + { + if (!gameHwnd_ || GetForegroundWindow() != gameHwnd_) + return; + + const HRESULT acquireHr = device_->Acquire(); + if (FAILED(acquireHr) && acquireHr != S_FALSE) + { + note_device_failure("gameplay Acquire", acquireHr); + return; + } + + if (GetForegroundWindow() != gameHwnd_) + { + device_->Unacquire(); + deviceAcquired_ = false; + return; + } + + deviceAcquired_ = true; + clear_device_failure(); + const HRESULT actuatorHr = + device_->SendForceFeedbackCommand(DISFFC_SETACTUATORSON); + if (FAILED(actuatorHr)) + { + note_device_failure("SETACTUATORSON", actuatorHr); + device_->Unacquire(); + deviceAcquired_ = false; + return; + } + clear_device_failure(); + } + + if (manualTestFrames_ > 0) + { + if (springEffect_) + update_spring(0.0f); + if (damperEffect_) + update_damper(0.0f); + if (roadTextureEffect_) + update_periodic(roadTextureEffect_, roadState_, 0.0f, 30.0f); + if (tireSlipEffect_) + update_periodic(tireSlipEffect_, slipState_, 0.0f, 35.0f); + + crashImpulseTimer_ = 0; + crashImpulseForce_ = 0.0f; + gearShiftTimer_ = 0; + splashTimer_ = 0; + splashAmp_ = 0.0f; + + LONG testLevel = manualTestDirection_ * 2000L; + if (Settings::WheelFFBInvertForce) + testLevel = -testLevel; + set_constant_force(testLevel); + --manualTestFrames_; + if (manualTestFrames_ == 0) + { + zero_all_forces(); + reset_signal_state(); + } + return; + } + + if (Overlay::IsActive || Overlay::IsBindingDialogActive) + { + zero_all_forces(); + reset_signal_state(); + return; + } + + if (lastCar_ != car) + { + zero_all_forces(); + reset_signal_state(); + lastCar_ = car; + } + + const float steer = read_game_steering(); + float rawSteerRate = 0.0f; + if (steerSampleValid_) + { + rawSteerRate = steer - prevSteer_; + smoothedSteerRate_ += (rawSteerRate - smoothedSteerRate_) * 0.45f; + } + else + { + // First sample after menu/race/device transitions is a baseline, + // not a one-tick steering velocity. + steerSampleValid_ = true; + smoothedSteerRate_ = 0.0f; + } + prevSteer_ = steer; + const float steerRate = smoothedSteerRate_; + + // field_1C4 reaches roughly 2.0 at OutRun top speed. Normalize by 2 + // rather than treating 1.0 as terminal speed. + const float speedRaw = car->field_1C4; + const float speed = std::isfinite(speedRaw) ? speedRaw : 0.0f; + const float speedNorm = std::clamp(speed / 2.0f, 0.0f, 1.0f); + const float configuredStrength = + static_cast(Settings::WheelFFBGlobalStrength); + const float outputStrength = std::isfinite(configuredStrength) + ? std::clamp(configuredStrength, 0.0f, 1.5f) + : 0.0f; + + const uint32_t stateFlags = car->field_8; + const uint32_t curGear = car->cur_gear_208; + const float throttleNorm = std::clamp( + static_cast(car->pedal_amount_34) / 255.0f, 0.0f, 1.0f); + + float engineAmp = 0.0f; + float engineFreq = 0.0f; + if (Settings::WheelFFBEngineVibration) + { + const auto engineTarget = WheelFFBMath::estimate_engine_haptics( + speedNorm, curGear, throttleNorm); + const float rpmBlend = engineTarget.rpmNorm > smoothedEngineRpm_ + ? 0.24f : 0.12f; + smoothedEngineRpm_ += + (engineTarget.rpmNorm - smoothedEngineRpm_) * rpmBlend; + + const float configuredEngineStrength = + static_cast(Settings::WheelFFBEngineIdle); + const float engineStrength = std::isfinite(configuredEngineStrength) + ? std::clamp(configuredEngineStrength, 0.0f, 1.0f) + : 0.0f; + const float amplitudeScale = std::clamp( + 0.38f + 0.32f * smoothedEngineRpm_ + 0.10f * throttleNorm, + 0.0f, 1.0f); + // Strength is a user-friendly 0..1 control, not direct wheel torque. + // 0.20 therefore remains subtle instead of becoming a 20% torque pulse. + const float targetEngineAmp = + engineStrength * 0.22f * amplitudeScale * outputStrength; + const float ampBlend = targetEngineAmp > smoothedEngineAmp_ ? 0.16f : 0.08f; + smoothedEngineAmp_ += + (targetEngineAmp - smoothedEngineAmp_) * ampBlend; + engineAmp = smoothedEngineAmp_; + + const float targetEngineFreq = 13.0f + 11.0f * smoothedEngineRpm_; + if (smoothedEngineFreq_ <= 0.0f) + smoothedEngineFreq_ = targetEngineFreq; + else + smoothedEngineFreq_ += + (targetEngineFreq - smoothedEngineFreq_) * 0.14f; + engineFreq = smoothedEngineFreq_; + } + else + { + smoothedEngineRpm_ = 0.0f; + smoothedEngineAmp_ = 0.0f; + smoothedEngineFreq_ = 0.0f; + enginePhase_ = 0.0f; + } + + const float lateralSum = car->field_264 + car->field_268; + const float lateralRaw = std::isfinite(lateralSum) ? lateralSum : 0.0f; + + // Fast attack / slightly slower release. This is intentionally close + // to the Redux-era tuning, but the steering backbone comes from the + // real game input instead of car->field_1D0. + const float alpha = + std::abs(lateralRaw) > std::abs(smoothedLateral_) ? 0.18f : 0.12f; + smoothedLateral_ += (lateralRaw - smoothedLateral_) * alpha; + + lateralHistory_[lateralHistoryIndex_ % LateralHistoryCount] = smoothedLateral_; + ++lateralHistoryIndex_; + + float lateralDz = 0.0f; + { + const float mag = + std::abs(smoothedLateral_) - static_cast(Settings::WheelFFBLateralDeadzone); + if (mag > 0.0f) + lateralDz = smoothedLateral_ >= 0.0f ? mag : -mag; + } + + // field_264 + field_268 is treated strictly as lateral acceleration/load. + // It must never decide whether the tyres are sliding; that comes from + // bodySlip/frontSlip estimated from actual vehicle motion. + const float latNorm = std::clamp(lateralDz / 24.0f, -1.0f, 1.0f); + const float lateralLoad = std::clamp(std::abs(latNorm), 0.0f, 1.0f); + const float lateralLoadSmooth = + lateralLoad * lateralLoad * (3.0f - 2.0f * lateralLoad); + + const int previousDiscontinuities = vehicleDynamics_.discontinuityCount(); + vehicleDynamics_.update(car, steer, speedNorm, lateralLoadSmooth); + if (vehicleDynamics_.discontinuityCount() != previousDiscontinuities) + { + spdlog::info("WheelFFB EVENT: motion discontinuity; clearing force and event histories"); + zero_all_forces(); + reset_signal_state(); + return; + } + + const float bodySlideT = std::clamp( + (std::abs(vehicleDynamics_.bodySlip()) - 0.10f) / 0.22f, + 0.0f, 1.0f); + const float bodySlide = + bodySlideT * bodySlideT * (3.0f - 2.0f * bodySlideT); + const float frontScrubT = std::clamp( + (std::abs(vehicleDynamics_.frontSlip()) - 0.04f) / 0.14f, + 0.0f, 1.0f); + const float frontScrub = + frontScrubT * frontScrubT * (3.0f - 2.0f * frontScrubT); + const float configuredGripLoss = + static_cast(Settings::WheelFFBGripLoss); + const float gripLoss = std::isfinite(configuredGripLoss) + ? std::clamp(configuredGripLoss, 0.0f, 1.0f) + : 0.0f; + + speedHistory_[speedHistoryIndex_ % SpeedHistoryCount] = speed; + ++speedHistoryIndex_; + + update_crash_detection(speed, stateFlags); + update_gear_event(curGear); + + float roughness = 0.0f; + DWORD waterFlag = 0; + for (int i = 0; i < 4; ++i) + { + const float surfaceRoughness = static_cast(sub_1149C0( + car->water_flag_24C[i], + static_cast(car->OnRoadPlace_5C.loadColiType_0), + &waterFlag)); + if (std::isfinite(surfaceRoughness)) + roughness = std::max(roughness, surfaceRoughness); + } + + // Road texture and tire-slip envelopes. sub_1149C0 returns + // ~0.25 for ordinary asphalt; that is a material baseline, not a + // request to vibrate the wheel. The Xbox routine only enters its + // stronger surface branch above roughly 0.30, so remove that + // baseline here and ramp rough surfaces from 0.30 -> 0.85. + const float textureRoughness = + std::clamp((roughness - 0.30f) / 0.55f, 0.0f, 1.0f); + const float roadSpeedGate = + std::clamp((speedNorm - 0.05f) / 0.20f, 0.0f, 1.0f); + + // Xbox gamepad rumble treats snow/ice as a continuously rough + // material. On a DD wheel that becomes an unpleasant constant + // high-frequency sine. Stage IDs follow Game::StageNames: 4/19 + // are Snowy Mountain/Ice Scape and +30 are their reverse variants. + const int stageNumber = Game::GetNowStageNum(8); + const int uniqueStage = Game::GetStageUniqueNum(stageNumber); + const bool snowOrIceStage = + uniqueStage == 4 || uniqueStage == 19 || + uniqueStage == 34 || uniqueStage == 49; + constexpr float SnowIceRoadTextureScale = 0.04f; + const float stageRoadTextureScale = + snowOrIceStage ? SnowIceRoadTextureScale : 1.0f; + + float roadAmp = + textureRoughness * roadSpeedGate * + static_cast(Settings::WheelFFBRoadTexture) * outputStrength * + stageRoadTextureScale; + const float roadFreq = 25.0f + 12.0f * speedNorm; + + if (waterFlag && roughness > 0.7f && speedNorm > 0.70f && splashTimer_ <= 0) + { + const float roadTextureScale = std::clamp( + static_cast(Settings::WheelFFBRoadTexture) / 0.20f, + 0.0f, 5.0f); + splashAmp_ = + (roughness - 0.7f) * speedNorm * 0.75f * roadTextureScale * + outputStrength * stageRoadTextureScale; + splashTimer_ = 9; + } + if (splashTimer_ > 0) + { + roadAmp = std::max(roadAmp, splashAmp_); + --splashTimer_; + } + + float slipAmp = 0.0f; + float slipFreq = 40.0f; + const float slipSeverity = std::clamp( + frontScrub * (0.50f + 0.50f * lateralLoadSmooth) + + bodySlide * 0.20f, + 0.0f, 1.0f); + if (slipSeverity > 0.08f && speedNorm > 0.05f) + { + slipAmp = + slipSeverity * static_cast(Settings::WheelFFBTireSlip) * + outputStrength; + slipFreq = 40.0f - 12.0f * slipSeverity; + } + + if (!Settings::WheelFFBUsePeriodicEffects && + (roadTextureEffect_ || tireSlipEffect_)) + { + disable_periodics(); + spdlog::info( + "WheelFFB: hardware periodic effects disabled live; using ConstantForce fallback"); + } + + // Warm-up ramp prevents the first few garbage/settling frames from + // producing a DD-wheel spike. + float warmupScale = 1.0f; + if (warmupFrames_ < WarmupFrames) + { + ++warmupFrames_; + warmupScale = static_cast(warmupFrames_) / + static_cast(WarmupFrames); + } + + float recreateScale = 1.0f; + if (recreateRampFrames_ > 0) + { + recreateScale = + static_cast(RecreateRampFrames - recreateRampFrames_) / + static_cast(RecreateRampFrames); + --recreateRampFrames_; + } + + // FXT-inspired split: the centering backbone is a DirectInput + // condition effect handled continuously by the wheel/driver. Keep + // the game's real lateral/event signals on ConstantForce. + // Simulation-style aligning backbone: light at parking speed, + // progressively stronger with vehicle speed, then additionally + // loaded by cornering force. Deep slip unloads the wheel again. + // Keep GUID_Spring as a low-speed/near-centre stabilizer instead of + // stacking a second high-speed SAT on top of ConstantForce. The + // fade is a smoothstep, so crossing the blend region cannot create + // a coefficient step or a sudden return-to-centre kick. + const float springFadeT = std::clamp( + (speedNorm - 0.05f) / 0.35f, 0.0f, 1.0f); + const float springFade = + springFadeT * springFadeT * (3.0f - 2.0f * springFadeT); + const float springSpeed = 1.0f - 0.88f * springFade; + // Centering Spring is an artificial low-speed stabilizer, not a tyre + // grip estimator. Do not modulate it with lateral-G or slide state. + const float springStrength = std::clamp( + static_cast(Settings::WheelFFBSpringStrength) * springSpeed, + 0.0f, 1.0f); + + const bool suppressSpringForImpact = + crashImpulseTimer_ > CrashCooldownFrames; + + // Make UseHardwareSpring a real live F11 switch. Previously + // changing it to false after startup left the already-created + // GUID_Spring running, which made direction testing misleading. + if (!Settings::WheelFFBUseHardwareSpring && springEffect_) + { + update_spring(0.0f); + if (springEffect_) + springEffect_->Stop(); + safe_release_effect(springEffect_, "hardware spring disabled"); + prevSpringCoefficient_ = 0; + prevSpringSaturation_ = 0; + springStrategy_ = -1; + spdlog::info("WheelFFB: hardware spring disabled live; using software centering"); + } + + if (springEffect_) + { + update_spring( + suppressSpringForImpact + ? 0.0f + : springStrength * warmupScale * recreateScale * outputStrength); + } + + const float softwareSpringSign = Settings::WheelFFBInvertSpring + ? 1.0f : -1.0f; + const float softwareSpring = + springEffect_ + ? 0.0f + : steer * softwareSpringSign * springStrength; + + // ACC/AMS2-inspired dynamic damping. It resists steering velocity + // rather than pulling toward centre, grows with vehicle speed, and + // relaxes when the tyres are deeply sliding so counter-steer is not + // smothered. Prefer a native DirectInput GUID_Damper on wheels that + // implement it and keep the old software term as a fallback. + const float dampingSpeed = + 0.10f + 0.90f * std::pow(speedNorm, 1.30f); + // Release steering damping from real tyre/chassis slip, never + // from lateral-G alone. Front scrub gets a slightly smaller weight + // than body slide so understeer is readable without making the rack + // go completely loose in an ordinary loaded corner. + const float damperSlipRelief = std::max(bodySlide, frontScrub * 0.75f); + const float damperRelease = 1.0f - 0.55f * gripLoss * damperSlipRelief; + const float dynamicDamperStrength = std::clamp( + static_cast(Settings::WheelFFBDamperStrength) * + dampingSpeed * damperRelease, + 0.0f, 1.0f); + + if (!Settings::WheelFFBUseHardwareDamper && damperEffect_) + { + update_damper(0.0f); + if (damperEffect_) + damperEffect_->Stop(); + safe_release_effect(damperEffect_, "hardware damper disabled"); + prevDamperCoefficient_ = 0; + damperStrategy_ = -1; + spdlog::info("WheelFFB: hardware damper disabled live; using software damping"); + } + + if (damperEffect_) + update_damper(dynamicDamperStrength * warmupScale * recreateScale * outputStrength); + + constexpr float SteerRateScale = 10.0f; + const float damper = damperEffect_ + ? 0.0f + : -steerRate * SteerRateScale * dynamicDamperStrength; + + // Strong sim-style pseudo self-aligning torque (SAT). OutRun does + // not expose tyre pneumatic trail directly, so use the real steering + // angle as torque direction and the game's smoothed lateral signal as + // a load magnitude. This is intentionally NOT the old signed lateral + // ConstantForce, which could partially cancel the centering spring. + // + // - steering angle determines restoring direction (always toward centre) + // - vehicle speed builds SAT progressively + // - lateral load makes a loaded corner heavier + // - deeper drift unloads SAT so loss of grip is felt in the wheel + const float steerAbs = std::clamp(std::abs(steer), 0.0f, 1.0f); + + // Natural pseudo-SAT: a soft 2.5% centre deadband followed by a + // sine-shaped pneumatic-trail curve. Unlike the previous power + // curve, tiny steering angles stay tiny instead of immediately + // generating a large ConstantForce. The curve remains progressive + // through normal cornering angles and naturally flattens near lock. + const float satAngleInput = std::clamp( + (steerAbs - 0.025f) / 0.975f, 0.0f, 1.0f); + constexpr float HalfPi = 1.57079632679f; + const float steerForSat = std::sin(satAngleInput * HalfPi); + + // Motion gate removes SAT at rest, then builds it continuously once + // the car is rolling. This retains strong loaded-corner steering + // without the old square-root-like jump at small steering angles. + const float satMotionT = std::clamp( + (speedNorm - 0.015f) / 0.085f, 0.0f, 1.0f); + const float satMotionGate = + satMotionT * satMotionT * (3.0f - 2.0f * satMotionT); + const float satSpeed = satMotionGate * + (0.20f + 0.80f * std::sqrt(speedNorm)); + + // OutRun exposes an arcade lateral signal rather than tyre + // pneumatic trail. Use only its magnitude as a gentle load modifier + // and never let its sign decide the FFB direction. + const float satLoadBoost = 0.72f + 0.38f * lateralLoadSmooth; + + // Natural SAT remains an A/B fallback, but even it now unloads from + // actual chassis slide instead of mistaking high lateral-G for drift. + const float naturalSlideRelief = + 1.0f - 0.25f * gripLoss * bodySlide; + + // A real steering rack loses net aligning acceleration as the wheel + // is already rotating quickly back toward centre. Apply a bounded + // return-rate relief to stop a DD base from whipping through zero; + // holding a corner (rate ~= 0) still gets the full SAT magnitude. + const bool returningToCentre = steer * steerRate < 0.0f; + const float returnRateT = returningToCentre + ? std::clamp(std::abs(steerRate) / 0.08f, 0.0f, 1.0f) + : 0.0f; + const float returnRateSmooth = + returnRateT * returnRateT * (3.0f - 2.0f * returnRateT); + const float satReturnRelief = 1.0f - 0.45f * returnRateSmooth; + + const float satStrength = std::clamp( + static_cast(Settings::WheelFFBSteeringWeight), 0.0f, 2.0f); + const float naturalSatTorque = + (steer >= 0.0f ? -1.0f : 1.0f) * + steerForSat * satSpeed * satLoadBoost * naturalSlideRelief * + satReturnRelief * satStrength; + + // Physics SAT separates tyre pneumatic trail from a bounded + // mechanical/caster-trail contribution. Both are driven by the + // front lateral-force proxy, so the mechanical term cannot become + // an artificial speed-dependent centre spring. Pneumatic SAT drops + // first near understeer while mechanical trail keeps useful rack + // torque alive instead of making the wheel suddenly go dead. + const float frontSlip = vehicleDynamics_.frontSlip(); + const float rawFrontSlip = vehicleDynamics_.rawFrontSlip(); + float physicsSatTorque = 0.0f; + const float configuredTrailResponseLead = + static_cast(Settings::WheelFFBTrailResponseLead); + const float trailResponseLead = std::isfinite(configuredTrailResponseLead) + ? std::clamp(configuredTrailResponseLead, 0.0f, 0.60f) + : 0.25f; + const float trailResponseSlip = std::clamp( + frontSlip + (rawFrontSlip - frontSlip) * trailResponseLead, + -0.70f, 0.70f); + const float lateralForceShape = WheelFFBMath::lateral_force_shape(frontSlip); + const float pneumaticTrail = WheelFFBMath::pneumatic_trail_factor(trailResponseSlip); + const float pneumaticSatShape = + WheelFFBMath::pneumatic_sat_shape(frontSlip, trailResponseSlip); + const float configuredMechanicalTrail = + static_cast(Settings::WheelFFBMechanicalTrail); + const float mechanicalTrailMix = std::isfinite(configuredMechanicalTrail) + ? std::clamp(configuredMechanicalTrail, 0.0f, 0.60f) + : 0.25f; + const float mechanicalContribution = + WheelFFBMath::mechanical_sat_shape(frontSlip, mechanicalTrailMix); + const float physicsShape = WheelFFBMath::combined_sat_shape( + frontSlip, trailResponseSlip, mechanicalTrailMix); + const float trailShape = pneumaticSatShape; // legacy telemetry field name + const float physicsLoad = 0.62f + 0.48f * lateralLoadSmooth; + const float rearSlideRelief = 1.0f - 0.15f * gripLoss * bodySlide; + if (vehicleDynamics_.calibrated() && vehicleDynamics_.sampleValid()) + { + const float physicsReturnRelief = + WheelFFBMath::physics_return_relief(frontSlip, steerRate); + + physicsSatTorque = + (frontSlip > 0.0f ? -1.0f : 1.0f) * + physicsShape * satSpeed * physicsLoad * rearSlideRelief * + physicsReturnRelief * satStrength; + if (!std::isfinite(physicsSatTorque)) + physicsSatTorque = 0.0f; + } + + // Keep full Natural SAT while calibration/current motion is unavailable, + // then crossfade over valid dynamics ticks. Invalid telemetry falls + // back immediately instead of leaking stale Physics SAT values. + const float physicsMix = vehicleDynamics_.sampleValid() + ? vehicleDynamics_.activationBlend() + : 0.0f; + // Natural SAT remains the fallback throughout the activation ramp. + // Dropping it on the calibration tick created a short SAT hole while + // physicsMix was still near zero. + const float physicsFallback = naturalSatTorque; + const float selfAligningTorque = Settings::WheelFFBPhysicsSat + ? physicsFallback + (physicsSatTorque - physicsFallback) * physicsMix + : naturalSatTorque; + + float loadMod = 1.0f; + if (speedHistoryIndex_ > 6) + { + const float oldSpeed = + speedHistory_[(speedHistoryIndex_ - 6) % SpeedHistoryCount]; + const float longAccelSample = (speed - oldSpeed) * 5.0f; + if (std::isfinite(longAccelSample)) + smoothedLongAccel_ += + (longAccelSample - smoothedLongAccel_) * 0.25f; + const float configuredWeightTransfer = + static_cast(Settings::WheelFFBWeightTransfer); + const float weightTransfer = std::isfinite(configuredWeightTransfer) + ? std::clamp(configuredWeightTransfer, 0.0f, 1.5f) + : 0.0f; + loadMod = 1.0f + std::clamp( + -smoothedLongAccel_ * weightTransfer, -0.06f, 0.08f); + } + + float structural = 0.0f; + if (crashImpulseTimer_ <= CrashCooldownFrames) + structural = (softwareSpring + selfAligningTorque) * loadMod + damper; + + // Headroom analysis uses sustained structural steering only. Do not + // let a wall hit, gear thunk, startup ramp or nearly-stopped frame + // teach the gain recommendation the wrong lesson. + const bool headroomEligible = + crashImpulseTimer_ <= 0 && gearShiftTimer_ <= 0 && + warmupScale >= 0.999f && recreateScale >= 0.999f && + speedNorm > 0.08f; + + float events = update_event_force(); + + // Sustained steering and short events have different timing needs. + // Keep SAT/spring/damper on the DD-safe slew path while allowing a + // crash or gear thunk to arrive promptly without releasing that + // slew limiter for the whole steering signal. + const float forceDirection = Settings::WheelFFBInvertForce ? -1.0f : 1.0f; + const float outputRamp = warmupScale * recreateScale; + float total = structural * outputStrength * forceDirection * outputRamp; + float eventOutput = events * outputStrength * forceDirection * outputRamp; + if (!std::isfinite(total)) + total = 0.0f; + if (!std::isfinite(eventOutput)) + eventOutput = 0.0f; + record_headroom(std::abs(total), headroomEligible); + + // Preserve ordinary SAT linearly; bend only near the force cap. + const float compressed = WheelFFBMath::soft_saturate(total); + const float eventCompressed = WheelFFBMath::soft_saturate(eventOutput); + + LONG structuralLevel = + static_cast(compressed * static_cast(DI_FFNOMINALMAX)); + const LONG eventLevel = static_cast( + eventCompressed * static_cast(DI_FFNOMINALMAX)); + + const float configuredSlew = static_cast(Settings::WheelFFBSlewRate); + const float safeSlew = std::isfinite(configuredSlew) + ? std::clamp(configuredSlew, 0.01f, 1.0f) + : 0.06f; + const LONG maxSlew = static_cast( + safeSlew * static_cast(DI_FFNOMINALMAX)); + + const LONG releaseMaxSlew = std::min( + static_cast(DI_FFNOMINALMAX), maxSlew * 2); + const float configuredReversalRelease = + static_cast(Settings::WheelFFBReversalReleaseRate); + const float safeReversalRelease = std::isfinite(configuredReversalRelease) + ? std::clamp(configuredReversalRelease, 0.02f, 1.0f) + : 0.12f; + const LONG reversalReleaseMaxSlew = std::max( + releaseMaxSlew, + static_cast(safeReversalRelease * static_cast(DI_FFNOMINALMAX))); + const bool oppositeTorqueDirection = + structuralLevel != 0 && prevStructuralLevel_ != 0 && + (structuralLevel > 0) != (prevStructuralLevel_ > 0); + + if (oppositeTorqueDirection) + { + // A sign change first unloads stale torque to zero at the + // already-approved faster release rate. Do not build the new + // direction in the same tick. + if (std::abs(prevStructuralLevel_) <= reversalReleaseMaxSlew) + structuralLevel = 0; + else + structuralLevel = prevStructuralLevel_ + + (prevStructuralLevel_ > 0 ? -reversalReleaseMaxSlew : reversalReleaseMaxSlew); + } + else + { + const bool unloadingStructural = + std::abs(structuralLevel) < std::abs(prevStructuralLevel_); + const LONG appliedMaxSlew = + unloadingStructural ? releaseMaxSlew : maxSlew; + const LONG structuralDelta = + structuralLevel - prevStructuralLevel_; + if (std::abs(structuralDelta) > appliedMaxSlew) + { + structuralLevel = prevStructuralLevel_ + + (structuralDelta > 0 ? appliedMaxSlew : -appliedMaxSlew); + } + } + prevStructuralLevel_ = structuralLevel; + + // Hardware periodics are preferred. If unavailable, inject a capped + // low-frequency sine after the structural soft-knee limiter. Road/slip signals + // share the same startup/recreate ramp as structural force. + const float effectRampScale = warmupScale * recreateScale; + float fallbackVibration = 0.0f; + if (!periodicsActive_) + { + fallbackVibration += synth_fallback( + roadPhase_, roadAmp * effectRampScale, std::min(roadFreq, 15.0f)); + fallbackVibration += synth_fallback( + slipPhase_, slipAmp * effectRampScale, std::min(slipFreq, 15.0f)); + } + + // Engine haptics always use the normalized ConstantForce tactile + // transport so wheel brand / GUID_Sine support cannot change the feel. + // The existing vibration-headroom clamp below guarantees SAT/events + // always have priority over this cosmetic engine texture. + if (Settings::WheelFFBEngineVibration) + { + fallbackVibration += synth_fallback( + enginePhase_, engineAmp * effectRampScale, engineFreq); + } + + LONG baseSteeringLevel = std::clamp( + structuralLevel + eventLevel, + -static_cast(DI_FFNOMINALMAX), + static_cast(DI_FFNOMINALMAX)); + if (Settings::WheelFFBEngineVibration && engineAmp > 0.0001f && eventLevel == 0) + { + // Reserve at most 2.5% during ordinary driving so the optional + // engine texture does not abruptly vanish at brief SAT peaks. + // Collision/gear events keep full priority. + const LONG requestedReserve = static_cast( + engineAmp * static_cast(DI_FFNOMINALMAX)); + const LONG engineReserve = std::clamp( + std::abs(requestedReserve), 0L, 250L); + const LONG baseCap = static_cast(DI_FFNOMINALMAX) - engineReserve; + baseSteeringLevel = std::clamp( + structuralLevel + eventLevel, -baseCap, baseCap); + } + const LONG vibrationRequested = static_cast( + fallbackVibration * static_cast(DI_FFNOMINALMAX)); + const LONG vibrationHeadroom = + static_cast(DI_FFNOMINALMAX) - std::abs(baseSteeringLevel); + const LONG vibrationLevel = std::clamp( + vibrationRequested, -vibrationHeadroom, vibrationHeadroom); + const LONG levelBeforeResponse = baseSteeringLevel + vibrationLevel; + const LONG level = apply_response_correction(levelBeforeResponse); + record_graph_sample( + total, + compressed, + static_cast(structuralLevel) / static_cast(DI_FFNOMINALMAX), + static_cast(level) / static_cast(DI_FFNOMINALMAX)); + + if (std::abs(level - prevConstantLevel_) > 15 || eventLevel != 0 || + (level != 0 && GetTickCount() - lastConstantWriteTick_ >= FFB_EFFECT_REFRESH_MS)) + set_constant_force(level); + + ++updateCounter_; + if (periodicsActive_ && (updateCounter_ % 2) == 0) + { + update_periodic(roadTextureEffect_, roadState_, roadAmp * effectRampScale, roadFreq); + update_periodic(tireSlipEffect_, slipState_, slipAmp * effectRampScale, slipFreq); + } + + if (Settings::WheelFFBUsePeriodicEffects && + (!roadTextureEffect_ || !tireSlipEffect_) && + (updateCounter_ % 60) == 0 && + tick_reached(GetTickCount(), periodicRecreateHoldoffUntil_)) + { + create_periodic_effects(); + } + + if (Settings::WheelFFBUseHardwareSpring && + !springEffect_ && + (updateCounter_ % 60) == 0 && + tick_reached(GetTickCount(), springRecreateHoldoffUntil_)) + { + create_spring_effect(); + } + + if (Settings::WheelFFBUseHardwareDamper && + !damperEffect_ && + (updateCounter_ % 60) == 0 && + tick_reached(GetTickCount(), damperRecreateHoldoffUntil_)) + { + create_damper_effect(); + } + + prevGear_ = curGear; + prevCollisionFlags_ = stateFlags; + maybe_log(speedNorm, steer, steerRate, lateralLoadSmooth, bodySlide, frontScrub, roughness, selfAligningTorque, level); + const DWORD telemetryNow = GetTickCount(); + if (Settings::WheelFFBTelemetry && telemetryNow - lastTelemetryTick_ >= 100) + { + lastTelemetryTick_ = telemetryNow; + spdlog::info( + "WheelFFB SAMPLE t={} car={} speedRaw={} speedNorm={} steer={} steerRateRaw={} steerRateFiltered={} field264={} field268={} lateralRaw={} lateralSmooth={} lateralLoad={} bodySlip={} bodySlide={} yawRate={} frontSlip={} frontScrub={} vLongTick={} vLatTick={} positionStep={} spdX={} spdY={} spdZ={} spdLenXZ={} spdCorrelation={} basis={} basisConfidence={} sampleValid={} mix={} satRaw={} satMixed={} trailShape={} satLoad={} rearSlideRelief={} springRequested={} springCoefficient={} damperRequested={} damperRelease={} damperCoefficient={} roadAmp={} slipAmp={} structural={} event={} structuralPreClip={} structuralPostClip={} eventPostClip={} postSlew={} diRequested={} diLastAccepted={} polar={} hwSpring={} hwDamper={} hwPeriodic={} gain={} invert={} invertSpring={}", + telemetryNow, static_cast(car), speedRaw, speedNorm, steer, rawSteerRate, steerRate, + car->field_264, car->field_268, lateralRaw, smoothedLateral_, lateralLoadSmooth, + vehicleDynamics_.bodySlip(), bodySlide, vehicleDynamics_.yawRate(), frontSlip, frontScrub, + vehicleDynamics_.vLong(), vehicleDynamics_.vLat(), vehicleDynamics_.positionStep(), + car->spd_mb_20.x, car->spd_mb_20.y, car->spd_mb_20.z, + vehicleDynamics_.spdLen(), vehicleDynamics_.spdCorrelation(), + vehicleDynamics_.forwardAxis(), vehicleDynamics_.calibrationConfidence(), + vehicleDynamics_.sampleValid(), physicsMix, physicsSatTorque, selfAligningTorque, + trailShape, physicsLoad, rearSlideRelief, springStrength, prevSpringCoefficient_, + dynamicDamperStrength, damperRelease, prevDamperCoefficient_, roadAmp, slipAmp, + structural, events, total, compressed, eventCompressed, structuralLevel, level, prevConstantLevel_, + constantEffectPolar_, springEffect_ != nullptr, damperEffect_ != nullptr, + periodicsActive_, outputStrength, bool(Settings::WheelFFBInvertForce), + bool(Settings::WheelFFBInvertSpring)); + spdlog::info( + "WheelFFB SATMODEL t={} rawBodySlip={} bodySlip={} bodyBlend={} rawYawRate={} yawRate={} yawBlend={} rawFrontSlip={} frontSlip={} frontBlend={} trailResponseSlip={} trailResponseLead={} fyShape={} pneumaticTrail={} pneumaticShape={} mechanicalMix={} mechanicalContribution={} combinedShape={} diPreResponse={} diCorrected={} responseCorrection={}", + telemetryNow, + vehicleDynamics_.rawBodySlip(), vehicleDynamics_.bodySlip(), vehicleDynamics_.bodySlipBlend(), + vehicleDynamics_.rawYawRate(), vehicleDynamics_.yawRate(), vehicleDynamics_.yawRateBlend(), + vehicleDynamics_.rawFrontSlip(), vehicleDynamics_.frontSlip(), vehicleDynamics_.frontSlipBlend(), + trailResponseSlip, trailResponseLead, lateralForceShape, pneumaticTrail, + pneumaticSatShape, mechanicalTrailMix, mechanicalContribution, physicsShape, + levelBeforeResponse, level, bool(Settings::WheelFFBResponseCorrection)); + // Raw horizontal bases allow row/column x X/Z candidates to be + // compared offline without changing the active steering model. + spdlog::info( + "WheelFFB BASIS t={} tick={} state={} gear={} stage={} basisSign={} posX={} posY={} posZ={} m70_11={} m70_13={} m70_31={} m70_33={} mB0_11={} mB0_13={} mB0_31={} mB0_33={} mF0_11={} mF0_13={} mF0_31={} mF0_33={}", + telemetryNow, updateCounter_, stateFlags, curGear, uniqueStage, vehicleDynamics_.forwardSign(), + car->position_14.x, car->position_14.y, car->position_14.z, + car->matrix_70._11, car->matrix_70._13, car->matrix_70._31, car->matrix_70._33, + car->matrix_B0._11, car->matrix_B0._13, car->matrix_B0._31, car->matrix_B0._33, + car->matrix_F0._11, car->matrix_F0._13, car->matrix_F0._31, car->matrix_F0._33); + } + } + + bool output_owner_active() const + { + return Settings::WheelFFBEnable && initialized_ && device_ && + deviceAcquired_ && !deviceReinitPending_ && !panicStopped_; + } + + WheelFFBHeadroomSnapshot headroom_snapshot() const + { + WheelFFBHeadroomSnapshot result{}; + result.samples = headroomSamples_; + result.currentDemand = headroomCurrentDemand_; + result.peakDemand = headroomPeakDemand_; + + const auto percentile = [&](double q) + { + if (headroomSamples_ == 0) + return 0.0f; + const std::uint64_t target = std::max( + 1, static_cast(std::ceil(headroomSamples_ * q))); + std::uint64_t cumulative = 0; + for (size_t i = 0; i < headroomHistogram_.size(); ++i) + { + cumulative += headroomHistogram_[i]; + if (cumulative >= target) + { + return static_cast(i) * + (HeadroomHistogramMax / static_cast(HeadroomHistogramBins - 1)); + } + } + return HeadroomHistogramMax; + }; + + result.p95Demand = percentile(0.95); + result.p99Demand = percentile(0.99); + if (headroomSamples_ > 0) + { + const float inv = 100.0f / static_cast(headroomSamples_); + result.softKneePercent = static_cast(headroomSoftKneeSamples_) * inv; + result.hardClipPercent = static_cast(headroomHardClipSamples_) * inv; + } + + const float currentOverall = std::clamp( + static_cast(Settings::WheelFFBGlobalStrength), 0.0f, 1.5f); + result.suggestedOverall = currentOverall; + if (headroomSamples_ >= 600 && result.p99Demand > 0.05f) + { + result.suggestedOverall = std::clamp( + currentOverall * (0.90f / result.p99Demand), + 0.05f, 1.5f); + } + return result; + } + + WheelFFBStatusSnapshot status_snapshot() const + { + WheelFFBStatusSnapshot result{}; + result.initialized = initialized_; + result.acquired = deviceAcquired_; + result.outputOwner = output_owner_active(); + result.constantEffect = constantEffect_ != nullptr; + result.springEffect = springEffect_ != nullptr; + result.damperEffect = damperEffect_ != nullptr; + result.periodicEffects = periodicsActive_; + result.constantCapsKnown = constantCapsKnown_; + result.constantDynamic = !constantCapsKnown_ || + (constantDynamicParams_ & DIEP_TYPESPECIFICPARAMS) != 0; + result.polarDirectionDynamic = !constantCapsKnown_ || + (constantDynamicParams_ & DIEP_DIRECTION) != 0; + result.springCapsKnown = springCapsKnown_; + result.springDynamic = !springCapsKnown_ || + (springDynamicParams_ & DIEP_TYPESPECIFICPARAMS) != 0; + result.damperCapsKnown = damperCapsKnown_; + result.damperDynamic = !damperCapsKnown_ || + (damperDynamicParams_ & DIEP_TYPESPECIFICPARAMS) != 0; + result.periodicCapsKnown = periodicCapsKnown_; + result.periodicDynamic = !periodicCapsKnown_ || + (periodicDynamicParams_ & DIEP_TYPESPECIFICPARAMS) != 0; + result.directionTested = directionTested_; + + if (device_) + { + DWORD state = 0; + if (SUCCEEDED(device_->GetForceFeedbackState(&state))) + { + result.ffbStateValid = true; + result.actuatorsOn = (state & DIGFFS_ACTUATORSON) != 0; + result.powerOn = (state & DIGFFS_POWERON) != 0; + result.powerOff = (state & DIGFFS_POWEROFF) != 0; + result.safetySwitchOn = (state & DIGFFS_SAFETYSWITCHON) != 0; + result.safetySwitchOff = (state & DIGFFS_SAFETYSWITCHOFF) != 0; + result.userSwitchOn = (state & DIGFFS_USERFFSWITCHON) != 0; + result.userSwitchOff = (state & DIGFFS_USERFFSWITCHOFF) != 0; + result.paused = (state & DIGFFS_PAUSED) != 0; + result.deviceLost = (state & DIGFFS_DEVICELOST) != 0; + } + } + return result; + } + + WheelFFBGraphSnapshot graph_snapshot() const + { + WheelFFBGraphSnapshot result{}; + result.count = graphCount_; + const size_t start = graphCount_ < WheelFFBGraphCapacity + ? 0 + : graphWriteIndex_ % WheelFFBGraphCapacity; + for (size_t i = 0; i < graphCount_; ++i) + { + const size_t src = (start + i) % WheelFFBGraphCapacity; + result.rawStructural[i] = graphRawStructural_[src]; + result.softLimited[i] = graphSoftLimited_[src]; + result.postSlew[i] = graphPostSlew_[src]; + result.finalOutput[i] = graphFinalOutput_[src]; + } + return result; + } + + void reset_headroom_stats() + { + headroomHistogram_.fill(0); + headroomSamples_ = 0; + headroomSoftKneeSamples_ = 0; + headroomHardClipSamples_ = 0; + headroomCurrentDemand_ = 0.0f; + headroomPeakDemand_ = 0.0f; + } + + void request_direction_test(int direction) + { + if (direction == 0) + { + const bool hadPendingTest = manualTestFrames_ > 0; + manualTestFrames_ = 0; + manualTestDirection_ = 1; + const bool inGameplay = + Game::current_mode && (*Game::current_mode == STATE_GAME); + if (hadPendingTest && initialized_ && !panicStopped_ && + deviceAcquired_ && inGameplay && appActive_ && gameHwnd_ && + GetForegroundWindow() == gameHwnd_) + { + zero_all_forces(); + } + if (hadPendingTest) + reset_signal_state(); + return; + } + + const bool inGameplay = + Game::current_mode && (*Game::current_mode == STATE_GAME); + if (!inGameplay) + { + manualTestFrames_ = 0; + manualTestDirection_ = 1; + spdlog::warn( + "WheelFFB: ignored direction test outside gameplay; no torque was queued"); + return; + } + + if (!Settings::WheelFFBEnable || !initialized_ || panicStopped_ || + !device_ || !deviceAcquired_ || !appActive_ || !gameHwnd_ || + GetForegroundWindow() != gameHwnd_) + { + manualTestFrames_ = 0; + manualTestDirection_ = 1; + spdlog::warn( + "WheelFFB: ignored direction test while FFB device was not active and foreground; no torque was queued"); + return; + } + + directionTested_ = true; + manualTestDirection_ = direction < 0 ? -1 : 1; + manualTestFrames_ = 18; + spdlog::info( + "WheelFFB: queued safe {} direction test at fixed 20% output", + manualTestDirection_ < 0 ? "left" : "right"); + } + + void reset_direction_test() + { + directionTested_ = false; + } + + void settings_transition() + { + manualTestFrames_ = 0; + if (initialized_ && device_ && deviceAcquired_ && !panicStopped_) + zero_all_forces(); + reset_signal_state(); + reset_headroom_stats(); + // Re-enable any hardware effect selected by the new profile on the + // first active gameplay tick instead of waiting up to one second. + // The normal warm-up ramp is already reset by reset_signal_state(). + updateCounter_ = 59; + spdlog::info("WheelFFB: settings/profile transition; forces zeroed and warm-up restarted"); + } + + void service_safety() + { + if (panicStopped_) return; + if (disable_live_if_needed()) return; + if (!initialized_) return; + + const bool gameplay = Game::current_mode && *Game::current_mode == STATE_GAME; + const bool foreground = gameHwnd_ && GetForegroundWindow() == gameHwnd_; + if (!gameplay || !foreground || + ((Overlay::IsActive || Overlay::IsBindingDialogActive) && manualTestFrames_ == 0)) + { + zero_all_forces(); + reset_signal_state(); + if ((!gameplay || !foreground) && device_ && deviceAcquired_) + { + device_->Unacquire(); + deviceAcquired_ = false; + } + } + } + + void check_watchdog() + { + if (!initialized_ || panicStopped_ || lastUpdateTick_ == 0) + return; + + const DWORD elapsed = GetTickCount() - lastUpdateTick_; + if (elapsed > 250 && + (prevConstantLevel_ != 0 || prevSpringCoefficient_ != 0 || + prevDamperCoefficient_ != 0 || roadState_.lastMagnitude != 0 || + slipState_.lastMagnitude != 0)) + { + zero_all_forces(); + reset_signal_state(); + spdlog::info("WheelFFB: watchdog zeroed forces after {}ms without game update", elapsed); + } + } + + void panic_stop() + { + if (panicStopped_) + return; + + panicStopped_ = true; + + if (gameHwnd_) + KillTimer(gameHwnd_, FFB_WATCHDOG_TIMER_ID); + + if (!device_) + return; + + spdlog::info("WheelFFB: PanicStop - zeroing and releasing wheel torque"); + + if (constantEffect_) + { + constantParams_ = {}; + DIEFFECT eff{}; + eff.dwSize = sizeof(eff); + eff.cbTypeSpecificParams = sizeof(constantParams_); + eff.lpvTypeSpecificParams = &constantParams_; + + HRESULT hr = constantEffect_->SetParameters( + &eff, DIEP_TYPESPECIFICPARAMS | DIEP_START); + spdlog::info("WheelFFB: PanicStop constant zero => 0x{:08X}", (unsigned)hr); + hr = constantEffect_->Stop(); + spdlog::info("WheelFFB: PanicStop constant Stop => 0x{:08X}", (unsigned)hr); + } + + if (springEffect_) + { + const HRESULT springHr = springEffect_->Stop(); + spdlog::info("WheelFFB: PanicStop spring Stop => 0x{:08X}", (unsigned)springHr); + } + + if (damperEffect_) + { + const HRESULT damperHr = damperEffect_->Stop(); + spdlog::info("WheelFFB: PanicStop damper Stop => 0x{:08X}", (unsigned)damperHr); + } + + if (roadTextureEffect_) + roadTextureEffect_->Stop(); + if (tireSlipEffect_) + tireSlipEffect_->Stop(); + + HRESULT hr = device_->SendForceFeedbackCommand(DISFFC_STOPALL); + spdlog::info("WheelFFB: PanicStop STOPALL => 0x{:08X}", (unsigned)hr); + hr = device_->SendForceFeedbackCommand(DISFFC_SETACTUATORSOFF); + spdlog::info("WheelFFB: PanicStop SETACTUATORSOFF => 0x{:08X}", (unsigned)hr); + hr = device_->SendForceFeedbackCommand(DISFFC_RESET); + spdlog::info("WheelFFB: PanicStop RESET => 0x{:08X}", (unsigned)hr); + + // DIPROP_AUTOCENTER must be written while unacquired. + hr = device_->Unacquire(); + deviceAcquired_ = false; + spdlog::info("WheelFFB: PanicStop Unacquire => 0x{:08X}", (unsigned)hr); + + restore_driver_autocenter("PanicStop"); + } + + private: + struct PeriodicState + { + DWORD lastMagnitude = 0; + DWORD lastPeriod = 0; + DWORD lastWriteTick = 0; + double phaseCycles = 0.0; + }; + + static constexpr int SpeedHistoryCount = 8; + static constexpr int LateralHistoryCount = 16; + static constexpr int WarmupFrames = 30; + static constexpr int RecreateRampFrames = 15; + static constexpr int CrashTimerFrames = 90; + static constexpr int CrashCooldownFrames = 80; + static constexpr size_t HeadroomHistogramBins = 201; + static constexpr float HeadroomHistogramMax = 2.0f; + + struct FailedInterfaceState + { + std::string guid; + DWORD until = 0; + }; + + struct EnumContext + { + WheelFFBEngine* self = nullptr; + GUID selectedGuid{}; + GUID selectedProductGuid{}; + GUID selectedFFDriverGuid{}; + std::string selectedName; + DWORD selectedVidPid = 0; + GUID preferredProductGuid{}; + GUID preferredFFDriverGuid{}; + DWORD preferredVidPid = 0; + WORD preferredVendorId = 0; + bool found = false; + bool matchGuidOnly = false; + bool requirePreferredProductGuid = false; + bool requirePreferredVidPid = false; + bool requirePreferredDriverVendor = false; + }; + + static BOOL CALLBACK enum_devices_callback( + LPCDIDEVICEINSTANCEA instance, LPVOID context) + { + auto* ctx = static_cast(context); + const std::string instanceName = lower_copy(instance->tszInstanceName); + const std::string productName = lower_copy(instance->tszProductName); + const std::string wanted = + lower_copy(Settings::WheelFFBDeviceName.get().c_str()); + const std::string wantedGuid = + lower_copy(Settings::WheelFFBDeviceGuid.get().c_str()); + + if (ctx->self && ctx->self->interface_temporarily_failed(instance->guidInstance)) + { + spdlog::warn( + "WheelFFB: temporarily skipping rejected FFB interface '{}' [{}] until retry", + instance->tszProductName, directinput_guid_key(instance->guidInstance)); + return DIENUM_CONTINUE; + } + + if (is_virtual_device_name(instanceName) || + is_virtual_device_name(productName)) + { + spdlog::info( + "WheelFFB: skipping virtual FFB device '{}'", + instance->tszInstanceName); + return DIENUM_CONTINUE; + } + + if (ctx->matchGuidOnly && + (wantedGuid.empty() || directinput_guid_key(instance->guidInstance) != wantedGuid)) + return DIENUM_CONTINUE; + + if (ctx->requirePreferredProductGuid && + !directinput_guid_equal(instance->guidProduct, ctx->preferredProductGuid)) + { + return DIENUM_CONTINUE; + } + + DWORD candidateVidPid = 0; + if (ctx->self && + (ctx->requirePreferredVidPid || ctx->requirePreferredDriverVendor)) + { + candidateVidPid = ctx->self->query_device_vidpid(instance->guidInstance); + } + + if (ctx->requirePreferredVidPid && + (candidateVidPid == 0 || candidateVidPid != ctx->preferredVidPid)) + { + return DIENUM_CONTINUE; + } + + if (ctx->requirePreferredDriverVendor) + { + if (candidateVidPid == 0 || LOWORD(candidateVidPid) != ctx->preferredVendorId || + directinput_guid_is_zero(ctx->preferredFFDriverGuid) || + !directinput_guid_equal(instance->guidFFDriver, ctx->preferredFFDriverGuid)) + { + return DIENUM_CONTINUE; + } + } + + if (!ctx->matchGuidOnly && + !ctx->requirePreferredProductGuid && + !ctx->requirePreferredVidPid && + !ctx->requirePreferredDriverVendor && + !wanted.empty() && + instanceName.find(wanted) == std::string::npos && + productName.find(wanted) == std::string::npos) + { + return DIENUM_CONTINUE; + } + + if (candidateVidPid == 0 && ctx->self) + candidateVidPid = ctx->self->query_device_vidpid(instance->guidInstance); + + ctx->selectedGuid = instance->guidInstance; + ctx->selectedProductGuid = instance->guidProduct; + ctx->selectedFFDriverGuid = instance->guidFFDriver; + ctx->selectedName = instance->tszProductName; + ctx->selectedVidPid = candidateVidPid; + ctx->found = true; + return DIENUM_STOP; + } + + bool disable_live_if_needed() + { + if (Settings::WheelFFBEnable) + return false; + + const bool hadInitializedOutput = initialized_; + if (initialized_) + { + if (device_ && deviceAcquired_) + zero_all_forces(); + teardown_for_reinitialize("live disable"); + initialized_ = false; + } + + // Disabling FFB is a hard recovery boundary even if initialization + // never completed. A previous rejected-interface quarantine must not + // survive a deliberate off/on cycle or a fresh device selection. + deviceReinitPending_ = false; + deviceFailureSince_ = 0; + deviceReinitAfter_ = 0; + retryAfter_ = 0; + failedInterfaces_.clear(); + preferredVidPid_ = 0; + preferredProductGuid_ = {}; + preferredFFDriverGuid_ = {}; + preferredVendorId_ = 0; + + enabledLastTick_ = false; + if (hadInitializedOutput) + spdlog::info("WheelFFB: disabled live; output released and driver autocenter restored"); + return true; + } + + void clear_constant_live_failure() + { + constantLiveFailureCount_ = 0; + } + + bool record_constant_live_failure() + { + if (constantLiveFailureCount_ < FFB_CONSTANT_LIVE_FAILURE_LIMIT) + ++constantLiveFailureCount_; + return constantLiveFailureCount_ >= FFB_CONSTANT_LIVE_FAILURE_LIMIT; + } + + void clear_device_failure() + { + deviceFailureSince_ = 0; + } + + void request_device_reinitialize(const char* reason, HRESULT hr) + { + if (panicStopped_ || deviceReinitPending_) + return; + + deviceReinitPending_ = true; + deviceReinitAfter_ = GetTickCount(); + spdlog::warn( + "WheelFFB: scheduling DirectInput device reinitialization after {} (0x{:08X})", + reason, (unsigned)hr); + } + + void note_device_failure(const char* where, HRESULT hr) + { + if (panicStopped_ || !initialized_) + return; + + const DWORD now = GetTickCount(); + if (deviceFailureSince_ == 0) + { + deviceFailureSince_ = now; + spdlog::warn( + "WheelFFB: DirectInput device access failed at {} (0x{:08X}); allowing {}ms for transient recovery", + where, (unsigned)hr, (unsigned)FFB_DEVICE_FAILURE_GRACE_MS); + return; + } + + if (now - deviceFailureSince_ >= FFB_DEVICE_FAILURE_GRACE_MS) + request_device_reinitialize(where, hr); + } + + bool reacquire_after_input_loss(const char* where, HRESULT originalHr) + { + if (!device_) + { + note_device_failure(where, originalHr); + return false; + } + + const bool inGameplay = + Game::current_mode && (*Game::current_mode == STATE_GAME); + if (!inGameplay) + { + deviceAcquired_ = false; + return false; + } + if (!appActive_ || !gameHwnd_ || GetForegroundWindow() != gameHwnd_) + { + deviceAcquired_ = false; + return false; + } + + deviceAcquired_ = false; + const HRESULT acquireHr = device_->Acquire(); + if (SUCCEEDED(acquireHr) || acquireHr == S_FALSE) + { + if (!appActive_ || GetForegroundWindow() != gameHwnd_) + { + device_->Unacquire(); + deviceAcquired_ = false; + return false; + } + + deviceAcquired_ = true; + const HRESULT actuatorHr = + device_->SendForceFeedbackCommand(DISFFC_SETACTUATORSON); + if (FAILED(actuatorHr)) + { + device_->Unacquire(); + deviceAcquired_ = false; + note_device_failure("reacquire SETACTUATORSON", actuatorHr); + return false; + } + clear_device_failure(); + return true; + } + + note_device_failure(where, acquireHr); + return false; + } + + void release_effects_for_reinitialize() + { + safe_release_effect(constantEffect_, "constant during device reinit"); + safe_release_effect(springEffect_, "spring during device reinit"); + safe_release_effect(damperEffect_, "damper during device reinit"); + safe_release_effect(roadTextureEffect_, "road during device reinit"); + safe_release_effect(tireSlipEffect_, "tire during device reinit"); + + roadState_ = {}; + slipState_ = {}; + periodicsActive_ = false; + springStrategy_ = 1; + damperStrategy_ = 1; + periodicStrategy_ = 1; + clear_constant_live_failure(); + } + + void teardown_for_reinitialize(const char* autocenterReason = "device reinitialize") + { + if (device_) + { + __try + { + device_->SendForceFeedbackCommand(DISFFC_STOPALL); + device_->SendForceFeedbackCommand(DISFFC_SETACTUATORSOFF); + } + __except (EXCEPTION_EXECUTE_HANDLER) + { + spdlog::warn( + "WheelFFB: exception stopping stale DirectInput device during reinit (0x{:X})", + GetExceptionCode()); + } + } + + release_effects_for_reinitialize(); + deviceAcquired_ = false; + + restore_driver_autocenter(autocenterReason); + release_device(); + release_directinput(); + selectedName_.clear(); + selectedGuid_ = {}; + constantRecreateHoldoffUntil_ = 0; + periodicRecreateHoldoffUntil_ = 0; + springRecreateHoldoffUntil_ = 0; + damperRecreateHoldoffUntil_ = 0; + reset_signal_state(); + } + + static BOOL CALLBACK enum_actuator_axis_callback( + LPCDIDEVICEOBJECTINSTANCEA object, LPVOID context) + { + auto* self = static_cast(context); + if (!self || !object) + return DIENUM_CONTINUE; + if ((object->dwType & DIDFT_FFACTUATOR) != 0) + { + const DWORD offset = object->dwOfs; + if (std::find(self->actuatorAxes_.begin(), self->actuatorAxes_.end(), offset) == + self->actuatorAxes_.end()) + { + self->actuatorAxes_.push_back(offset); + } + } + return DIENUM_CONTINUE; + } + + DWORD primary_actuator_axis() const + { + return actuatorAxes_.empty() ? DIJOFS_X : actuatorAxes_.front(); + } + + void prune_failed_interfaces() + { + const DWORD now = GetTickCount(); + failedInterfaces_.erase( + std::remove_if( + failedInterfaces_.begin(), failedInterfaces_.end(), + [&](const FailedInterfaceState& state) + { + return tick_reached(now, state.until); + }), + failedInterfaces_.end()); + } + + bool interface_temporarily_failed(const GUID& guid) + { + prune_failed_interfaces(); + const std::string key = directinput_guid_key(guid); + return std::any_of( + failedInterfaces_.begin(), failedInterfaces_.end(), + [&](const FailedInterfaceState& state) { return state.guid == key; }); + } + + size_t active_failed_interface_count() + { + prune_failed_interfaces(); + return failedInterfaces_.size(); + } + + DWORD query_device_vidpid(const GUID& guid) + { + if (!directInput_) + return 0; + + IDirectInputDevice8A* probe = nullptr; + if (FAILED(directInput_->CreateDevice(guid, &probe, nullptr)) || !probe) + return 0; + + DIPROPDWORD vidpid{}; + vidpid.diph.dwSize = sizeof(vidpid); + vidpid.diph.dwHeaderSize = sizeof(vidpid.diph); + vidpid.diph.dwObj = 0; + vidpid.diph.dwHow = DIPH_DEVICE; + const HRESULT hr = probe->GetProperty(DIPROP_VIDPID, &vidpid.diph); + probe->Release(); + return SUCCEEDED(hr) ? vidpid.dwData : 0; + } + + void mark_selected_interface_failed(const char* reason, HRESULT hr) + { + prune_failed_interfaces(); + const std::string failedGuid = directinput_guid_key(selectedGuid_); + const DWORD until = GetTickCount() + FFB_DEVICE_FAILED_BACKOFF_MS; + auto existing = std::find_if( + failedInterfaces_.begin(), failedInterfaces_.end(), + [&](const FailedInterfaceState& state) { return state.guid == failedGuid; }); + if (existing != failedInterfaces_.end()) + existing->until = until; + else + failedInterfaces_.push_back({ failedGuid, until }); + + spdlog::warn( + "WheelFFB: interface '{}' [{}] rejected {}; temporarily excluding it from automatic selection (0x{:08X})", + selectedName_, failedGuid, reason, (unsigned)hr); + } + + bool initialize() + { + retryAfter_ = 0; + + using DirectInput8CreateFn = HRESULT(WINAPI*)( + HINSTANCE, DWORD, REFIID, LPVOID*, LPUNKNOWN); + auto createDirectInput = proxy::origModule + ? reinterpret_cast( + GetProcAddress(proxy::origModule, "DirectInput8Create")) + : nullptr; + + // The plugin is itself named dinput8.dll. Falling back to the linked + // DirectInput8Create symbol can route back through our own proxy export, + // so fail closed unless the already-loaded System32 module has the export. + if (!createDirectInput) + { + spdlog::error( + "WheelFFB: original System32 dinput8 module has no DirectInput8Create export"); + return false; + } + + HRESULT hr = createDirectInput( + GetModuleHandleW(nullptr), + DIRECTINPUT_VERSION, + IID_IDirectInput8A, + reinterpret_cast(&directInput_), + nullptr); + + if (FAILED(hr) || !directInput_) + { + spdlog::error( + "WheelFFB: original DirectInput8Create failed (0x{:08X})", + (unsigned)hr); + return false; + } + + EnumContext ctx{}; + ctx.self = this; + const std::string configuredGuid = + lower_copy(Settings::WheelFFBDeviceGuid.get().c_str()); + + if (!configuredGuid.empty()) + { + ctx.matchGuidOnly = true; + hr = directInput_->EnumDevices( + DI8DEVCLASS_GAMECTRL, enum_devices_callback, &ctx, + DIEDFL_ATTACHEDONLY | DIEDFL_FORCEFEEDBACK); + if (FAILED(hr)) + { + release_directinput(); + return false; + } + if (!ctx.found) + { + spdlog::warn( + "WheelFFB: saved FFB GUID unavailable or rejected; probing compatible sibling interfaces"); + ctx.matchGuidOnly = false; + } + } + + if (!ctx.found && !directinput_guid_is_zero(preferredProductGuid_)) + { + ctx.matchGuidOnly = false; + ctx.requirePreferredProductGuid = true; + ctx.preferredProductGuid = preferredProductGuid_; + hr = directInput_->EnumDevices( + DI8DEVCLASS_GAMECTRL, enum_devices_callback, &ctx, + DIEDFL_ATTACHEDONLY | DIEDFL_FORCEFEEDBACK); + ctx.requirePreferredProductGuid = false; + if (FAILED(hr)) + { + release_directinput(); + return false; + } + if (!ctx.found) + { + spdlog::warn( + "WheelFFB: no remaining FFB interface shared the preferred DirectInput product GUID"); + } + } + + if (!ctx.found && preferredVidPid_ != 0) + { + ctx.matchGuidOnly = false; + ctx.requirePreferredVidPid = true; + ctx.preferredVidPid = preferredVidPid_; + hr = directInput_->EnumDevices( + DI8DEVCLASS_GAMECTRL, enum_devices_callback, &ctx, + DIEDFL_ATTACHEDONLY | DIEDFL_FORCEFEEDBACK); + ctx.requirePreferredVidPid = false; + if (FAILED(hr)) + { + release_directinput(); + return false; + } + if (!ctx.found) + { + spdlog::warn( + "WheelFFB: no remaining FFB interface shared preferred VID/PID 0x{:08X}", + (unsigned)preferredVidPid_); + } + } + + if (!ctx.found && !directinput_guid_is_zero(preferredFFDriverGuid_) && + preferredVendorId_ != 0) + { + ctx.matchGuidOnly = false; + ctx.requirePreferredDriverVendor = true; + ctx.preferredFFDriverGuid = preferredFFDriverGuid_; + ctx.preferredVendorId = preferredVendorId_; + hr = directInput_->EnumDevices( + DI8DEVCLASS_GAMECTRL, enum_devices_callback, &ctx, + DIEDFL_ATTACHEDONLY | DIEDFL_FORCEFEEDBACK); + ctx.requirePreferredDriverVendor = false; + if (FAILED(hr)) + { + release_directinput(); + return false; + } + if (!ctx.found) + { + spdlog::warn( + "WheelFFB: no remaining FFB interface shared preferred FFB driver/vendor; falling back to the configured device name"); + } + } + + if (!ctx.found) + { + ctx.matchGuidOnly = false; + ctx.requirePreferredProductGuid = false; + ctx.requirePreferredVidPid = false; + ctx.requirePreferredDriverVendor = false; + hr = directInput_->EnumDevices( + DI8DEVCLASS_GAMECTRL, enum_devices_callback, &ctx, + DIEDFL_ATTACHEDONLY | DIEDFL_FORCEFEEDBACK); + } + + if (FAILED(hr) || !ctx.found) + { + spdlog::error( + "WheelFFB: no FFB wheel matched DeviceName='{}'", + Settings::WheelFFBDeviceName.get()); + release_directinput(); + return false; + } + + selectedGuid_ = ctx.selectedGuid; + selectedName_ = ctx.selectedName; + const bool selectedConfiguredInterface = + !configuredGuid.empty() && directinput_guid_key(selectedGuid_) == configuredGuid; + if (!directinput_guid_is_zero(ctx.selectedProductGuid) && + (directinput_guid_is_zero(preferredProductGuid_) || selectedConfiguredInterface)) + { + preferredProductGuid_ = ctx.selectedProductGuid; + } + if (!directinput_guid_is_zero(ctx.selectedFFDriverGuid) && + (directinput_guid_is_zero(preferredFFDriverGuid_) || selectedConfiguredInterface)) + { + preferredFFDriverGuid_ = ctx.selectedFFDriverGuid; + } + if (ctx.selectedVidPid != 0) + { + if (preferredVidPid_ == 0 || selectedConfiguredInterface) + preferredVidPid_ = ctx.selectedVidPid; + if (preferredVendorId_ == 0 || selectedConfiguredInterface) + preferredVendorId_ = LOWORD(ctx.selectedVidPid); + } + selectedConfiguredGuid_ = lower_copy(Settings::WheelFFBDeviceGuid.get().c_str()); + selectedConfiguredName_ = lower_copy(Settings::WheelFFBDeviceName.get().c_str()); + spdlog::info( + "WheelFFB: selected DirectInput identity '{}' / '{}' product={} ffDriver={} vidpid=0x{:08X}", + selectedName_, directinput_guid_key(selectedGuid_), + directinput_guid_key(ctx.selectedProductGuid), + directinput_guid_key(ctx.selectedFFDriverGuid), + (unsigned)ctx.selectedVidPid); + + hr = directInput_->CreateDevice(selectedGuid_, &device_, nullptr); + if (FAILED(hr) || !device_) + { + const HRESULT failHr = FAILED(hr) ? hr : E_FAIL; + mark_selected_interface_failed("CreateDevice", failHr); + spdlog::error( + "WheelFFB: CreateDevice('{}') failed (0x{:08X})", + selectedName_, (unsigned)failHr); + release_directinput(); + return false; + } + + DIDEVCAPS caps{}; + caps.dwSize = sizeof(caps); + hr = device_->GetCapabilities(&caps); + if (FAILED(hr)) + { + mark_selected_interface_failed("GetCapabilities", hr); + spdlog::error( + "WheelFFB: GetCapabilities('{}') failed (0x{:08X})", + selectedName_, (unsigned)hr); + release_device(); + release_directinput(); + return false; + } + + if ((caps.dwFlags & DIDC_FORCEFEEDBACK) == 0) + { + mark_selected_interface_failed("force-feedback capability", E_NOINTERFACE); + spdlog::error("WheelFFB: '{}' does not report force-feedback capability", selectedName_); + release_device(); + release_directinput(); + return false; + } + + hr = device_->SetDataFormat(&c_dfDIJoystick2); + if (FAILED(hr)) + { + mark_selected_interface_failed("SetDataFormat", hr); + spdlog::error("WheelFFB: SetDataFormat failed (0x{:08X})", (unsigned)hr); + release_device(); + release_directinput(); + return false; + } + + gameHwnd_ = Game::GameHwnd(); + if (!gameHwnd_) + { + spdlog::warn("WheelFFB: game window not available yet; retrying shortly"); + release_device(); + release_directinput(); + retryAfter_ = GetTickCount() + FFB_DEVICE_RETRY_MS; + return false; + } + + // initialize() can run while the game is already in the background, + // before our WM_ACTIVATEAPP subclass has seen a focus transition. + // Seed appActive_ from the real foreground window so an exclusive + // DD device is never acquired merely because the default was true. + appActive_ = (GetForegroundWindow() == gameHwnd_); + if (!appActive_) + { + release_device(); + release_directinput(); + retryAfter_ = GetTickCount() + FFB_DEVICE_RETRY_MS; + return false; + } + + hr = device_->SetCooperativeLevel( + gameHwnd_, + DISCL_EXCLUSIVE | DISCL_BACKGROUND); + + if (FAILED(hr)) + { + mark_selected_interface_failed("SetCooperativeLevel", hr); + spdlog::error( + "WheelFFB: SetCooperativeLevel(EXCLUSIVE|BACKGROUND) failed (0x{:08X})", + (unsigned)hr); + release_device(); + release_directinput(); + return false; + } + + DIPROPDWORD deviceGain{}; + deviceGain.diph.dwSize = sizeof(deviceGain); + deviceGain.diph.dwHeaderSize = sizeof(DIPROPHEADER); + deviceGain.diph.dwHow = DIPH_DEVICE; + deviceGain.dwData = DI_FFNOMINALMAX; + const HRESULT gainHr = device_->SetProperty(DIPROP_FFGAIN, &deviceGain.diph); + if (FAILED(gainHr)) + spdlog::warn("WheelFFB: nominal device gain rejected (0x{:08X})", (unsigned)gainHr); + + // Disable driver centering before acquiring. Restored by PanicStop. + DIPROPDWORD autocenter{}; + autocenter.diph.dwSize = sizeof(autocenter); + autocenter.diph.dwHeaderSize = sizeof(DIPROPHEADER); + autocenter.diph.dwObj = 0; + autocenter.diph.dwHow = DIPH_DEVICE; + autocenterRestoreKnown_ = SUCCEEDED( + device_->GetProperty(DIPROP_AUTOCENTER, &autocenter.diph)); + if (autocenterRestoreKnown_) + { + originalAutocenter_ = autocenter.dwData; + autocenter.dwData = DIPROPAUTOCENTER_OFF; + hr = device_->SetProperty(DIPROP_AUTOCENTER, &autocenter.diph); + if (FAILED(hr)) + spdlog::warn("WheelFFB: disabling driver autocenter failed (0x{:08X})", (unsigned)hr); + else + driverAutocenterDisabled_ = true; + } + else + { + spdlog::warn( + "WheelFFB: driver autocenter state could not be read; leaving it unchanged for safe restoration semantics"); + } + + hr = device_->Acquire(); + if (FAILED(hr) && hr != S_FALSE) + { + mark_selected_interface_failed("Acquire", hr); + spdlog::error("WheelFFB: Acquire('{}') failed (0x{:08X})", selectedName_, (unsigned)hr); + release_device(); + release_directinput(); + return false; + } + deviceAcquired_ = true; + + // Some multi-interface wheel drivers need the legacy reset sequence + // before the FFB interface will accept actuator/effect commands. + const HRESULT resetHr = device_->SendForceFeedbackCommand(DISFFC_RESET); + if (FAILED(resetHr)) + { + spdlog::warn( + "WheelFFB: initial DISFFC_RESET rejected (0x{:08X}); continuing with actuator enable", + (unsigned)resetHr); + } + + const HRESULT actuatorOnHr = + device_->SendForceFeedbackCommand(DISFFC_SETACTUATORSON); + if (FAILED(actuatorOnHr)) + { + mark_selected_interface_failed("initial SETACTUATORSON", actuatorOnHr); + spdlog::error( + "WheelFFB: SETACTUATORSON during initialization failed (0x{:08X})", + (unsigned)actuatorOnHr); + release_device(); + release_directinput(); + return false; + } + + actuatorAxes_.clear(); + const HRESULT axisEnumHr = device_->EnumObjects( + enum_actuator_axis_callback, this, DIDFT_AXIS); + if (FAILED(axisEnumHr) || actuatorAxes_.empty()) + { + actuatorAxes_.clear(); + actuatorAxes_.push_back(DIJOFS_X); + spdlog::warn( + "WheelFFB: no explicit DIDFT_FFACTUATOR axis reported; using DIJOFS_X fallback"); + } + else + { + spdlog::info( + "WheelFFB: detected {} force actuator axis/axes; primary offset={}", + actuatorAxes_.size(), (unsigned)actuatorAxes_.front()); + } + + if (!create_constant_effect()) + { + release_device(); + release_directinput(); + return false; + } + + { + const std::string actualGuid = directinput_guid_key(selectedGuid_); + if (ctx.selectedVidPid != 0) + preferredVidPid_ = ctx.selectedVidPid; + const bool identityChanged = + lower_copy(Settings::WheelFFBDeviceGuid.get().c_str()) != actualGuid || + Settings::WheelFFBDeviceName.get() != selectedName_; + if (identityChanged) + { + Settings::WheelFFBDeviceGuid = actualGuid; + Settings::WheelFFBDeviceName = selectedName_; + selectedConfiguredGuid_ = actualGuid; + selectedConfiguredName_ = lower_copy(selectedName_.c_str()); + const bool saved = Settings::write(Module::UserIniPath); + spdlog::info( + "WheelFFB: pinned working force interface '{}' [{}] after capability validation{}", + selectedName_, actualGuid, saved ? " and saved it to user.ini" : " for this session only"); + if (!saved) + { + spdlog::warn( + "WheelFFB: automatic FFB interface selection could not persist user.ini; the interface may be probed again next launch"); + } + } + } + + if (Settings::WheelFFBUseHardwareSpring && !create_spring_effect()) + { + spdlog::warn( + "WheelFFB: hardware GUID_Spring unavailable; retaining software spring fallback"); + } + + if (Settings::WheelFFBUseHardwareDamper && !create_damper_effect()) + { + spdlog::warn( + "WheelFFB: hardware GUID_Damper unavailable; retaining software damper fallback"); + } + + create_periodic_effects(); + install_exit_guards(); + + initialized_ = true; + deviceReinitPending_ = false; + deviceFailureSince_ = 0; + deviceReinitAfter_ = 0; + retryAfter_ = 0; + reset_signal_state(); + reset_headroom_stats(); + + prune_failed_interfaces(); + + spdlog::info( + "WheelFFB: ready on '{}' (DirectInput COM, axes={}, buttons={}, global={}%, spring={}, damper={})", + selectedName_, + caps.dwAxes, + caps.dwButtons, + static_cast(static_cast(Settings::WheelFFBGlobalStrength) * 100.0f), + springEffect_ ? "GUID_Spring" : "software", + damperEffect_ ? "GUID_Damper" : "software"); + + return true; + } + + float read_game_steering() const + { + if (Settings::UseNewInput) + { + const float steering = InputManager_SteeringValue(); + return std::isfinite(steering) + ? std::clamp(steering, -1.0f, 1.0f) + : 0.0f; + } + + using GetVolumeFn = int(__cdecl*)(ADChannel); + auto getVolume = Module::fn_ptr(0x53720); + if (!getVolume) + return 0.0f; + + const int raw = getVolume(ADChannel::Steering); + return std::clamp(raw / 127.0f, -1.0f, 1.0f); + } + + bool query_dynamic_effect_capability( + REFGUID effectGuid, const char* label, DWORD required, + bool& known, DWORD& dynamicParams) + { + known = false; + dynamicParams = 0; + if (!device_) + return false; + + DIEFFECTINFOA info{}; + info.dwSize = sizeof(info); + const HRESULT hr = device_->GetEffectInfo(&info, effectGuid); + if (FAILED(hr)) + { + // Some older drivers do not expose useful effect metadata even + // though SetParameters works. Preserve the proven legacy path + // when capability discovery itself is unavailable. + spdlog::warn( + "WheelFFB: GetEffectInfo({}) failed (0x{:08X}); keeping compatibility behavior", + label, (unsigned)hr); + return true; + } + + known = true; + dynamicParams = info.dwDynamicParams; + const bool supported = (dynamicParams & required) == required; + spdlog::info( + "WheelFFB: {} dynamic params=0x{:08X}, required=0x{:08X}, live={}", + label, (unsigned)dynamicParams, (unsigned)required, supported); + return supported; + } + + bool create_constant_effect() + { + if (!device_) + return false; + + const bool liveMagnitude = query_dynamic_effect_capability( + GUID_ConstantForce, "ConstantForce", DIEP_TYPESPECIFICPARAMS, + constantCapsKnown_, constantDynamicParams_); + if (constantCapsKnown_ && !liveMagnitude) + { + spdlog::warn( + "WheelFFB: ConstantForce metadata reports no live magnitude update support; probing the real zero-force update path anyway"); + } + + safe_release_effect(constantEffect_, "constant before create"); + constantEffectPolar_ = false; + + DWORD axes[2] = { DIJOFS_X, DIJOFS_Y }; + LONG directions[2] = { 9000L, 0L }; + constantParams_ = {}; + constantParams_.lMagnitude = 0; + + DIEFFECT effect{}; + effect.dwSize = sizeof(effect); + effect.dwDuration = FFB_EFFECT_LEASE_US; + effect.dwSamplePeriod = 0; + effect.dwGain = DI_FFNOMINALMAX; + effect.dwTriggerButton = DIEB_NOTRIGGER; + effect.dwTriggerRepeatInterval = 0; + effect.rgdwAxes = axes; + effect.rglDirection = directions; + effect.cbTypeSpecificParams = sizeof(constantParams_); + effect.lpvTypeSpecificParams = &constantParams_; + + auto probe_live_update = [&](const char* descriptor, bool polar) -> HRESULT + { + if (!constantEffect_) + return E_POINTER; + + HRESULT hr = constantEffect_->Start(1, 0); + if (FAILED(hr)) + { + spdlog::warn( + "WheelFFB: ConstantForce {} start probe failed (0x{:08X})", + descriptor, (unsigned)hr); + return hr; + } + + DICONSTANTFORCE zeroForce{}; + LONG probeDirections[2] = { 9000L, 0L }; + DIEFFECT params{}; + params.dwSize = sizeof(params); + params.cbTypeSpecificParams = sizeof(zeroForce); + params.lpvTypeSpecificParams = &zeroForce; + DWORD flags = DIEP_TYPESPECIFICPARAMS | DIEP_START; + + if (polar) + { + params.dwFlags = DIEFF_POLAR | DIEFF_OBJECTOFFSETS; + params.cAxes = 2; + params.rglDirection = probeDirections; + flags |= DIEP_DIRECTION; + + probeDirections[0] = 27000L; + hr = constantEffect_->SetParameters(¶ms, flags); + if (SUCCEEDED(hr)) + { + probeDirections[0] = 9000L; + hr = constantEffect_->SetParameters(¶ms, flags); + } + } + else + { + hr = constantEffect_->SetParameters(¶ms, flags); + } + + if (FAILED(hr)) + { + spdlog::warn( + "WheelFFB: ConstantForce {} zero-force live SetParameters probe failed (0x{:08X})", + descriptor, (unsigned)hr); + constantEffect_->Stop(); + return hr; + } + + const HRESULT stopHr = constantEffect_->Stop(); + if (FAILED(stopHr)) + { + spdlog::warn( + "WheelFFB: ConstantForce {} zero-force probe Stop failed (0x{:08X})", + descriptor, (unsigned)stopHr); + } + return S_OK; + }; + + auto accept_candidate = [&](const char* descriptor, bool polar) -> HRESULT + { + const HRESULT probeHr = probe_live_update(descriptor, polar); + if (FAILED(probeHr)) + return probeHr; + + constantEffectPolar_ = polar; + prevConstantLevel_ = 0; + lastConstantWriteTick_ = 0; + clear_constant_live_failure(); + spdlog::info( + "WheelFFB: ConstantForce validated with {} including zero-force live SetParameters", + descriptor); + return S_OK; + }; + + HRESULT hr = E_FAIL; + + // Fanatec-style multi-interface drivers can report one physical + // actuator while still requiring the legacy X/Y POLAR descriptor. + effect.dwFlags = DIEFF_POLAR | DIEFF_OBJECTOFFSETS; + effect.cAxes = 2; + hr = device_->CreateEffect( + GUID_ConstantForce, &effect, &constantEffect_, nullptr); + if (SUCCEEDED(hr) && constantEffect_) + { + const HRESULT probeHr = accept_candidate("canonical X/Y 2-axis POLAR descriptor", true); + if (SUCCEEDED(probeHr)) + return true; + hr = probeHr; + } + + safe_release_effect(constantEffect_, "failed POLAR constant probe"); + spdlog::warn( + "WheelFFB: X/Y POLAR ConstantForce unavailable; trying canonical one-axis X CARTESIAN"); + + effect.dwFlags = DIEFF_CARTESIAN | DIEFF_OBJECTOFFSETS; + effect.cAxes = 1; + axes[0] = DIJOFS_X; + directions[0] = 1; + hr = device_->CreateEffect( + GUID_ConstantForce, &effect, &constantEffect_, nullptr); + if (SUCCEEDED(hr) && constantEffect_) + { + const HRESULT probeHr = accept_candidate("canonical one-axis X CARTESIAN descriptor", false); + if (SUCCEEDED(probeHr)) + return true; + hr = probeHr; + } + + safe_release_effect(constantEffect_, "failed X CARTESIAN constant probe"); + + // A device may expose more than one FFB actuator object. X was + // already tried above; probe every other reported actuator offset + // once so enumeration order cannot hide the steering actuator. + std::vector triedActuatorAxes{ DIJOFS_X }; + for (const DWORD detectedAxis : actuatorAxes_) + { + if (std::find(triedActuatorAxes.begin(), triedActuatorAxes.end(), detectedAxis) != + triedActuatorAxes.end()) + continue; + triedActuatorAxes.push_back(detectedAxis); + + axes[0] = detectedAxis; + hr = device_->CreateEffect( + GUID_ConstantForce, &effect, &constantEffect_, nullptr); + if (SUCCEEDED(hr) && constantEffect_) + { + const HRESULT probeHr = accept_candidate("detected actuator one-axis CARTESIAN descriptor", false); + if (SUCCEEDED(probeHr)) + { + spdlog::info( + "WheelFFB: ConstantForce is using detected actuator offset {} instead of DIJOFS_X", + (unsigned)detectedAxis); + return true; + } + hr = probeHr; + } + safe_release_effect(constantEffect_, "failed detected-axis CARTESIAN constant probe"); + } + + const HRESULT failHr = FAILED(hr) ? hr : E_FAIL; + mark_selected_interface_failed("ConstantForce create/start/live-update probe", failHr); + spdlog::error( + "WheelFFB: no usable ConstantForce descriptor on selected interface (0x{:08X})", + (unsigned)failHr); + return false; + } + + bool create_spring_effect() + { + if (!device_ || !Settings::WheelFFBUseHardwareSpring) + return false; + if (!query_dynamic_effect_capability( + GUID_Spring, "GUID_Spring", DIEP_TYPESPECIFICPARAMS, + springCapsKnown_, springDynamicParams_)) + { + spdlog::warn("WheelFFB: GUID_Spring is not safely live-updatable; using software centering"); + return false; + } + + DWORD axes[1] = { primary_actuator_axis() }; + LONG directions[1] = { 1 }; + + const float configuredSaturation = + static_cast(Settings::WheelFFBSpringSaturation); + const float safeSaturation = std::isfinite(configuredSaturation) + ? std::clamp(configuredSaturation, 0.1f, 1.0f) + : 0.775f; + const DWORD saturation = static_cast( + safeSaturation * static_cast(DI_FFNOMINALMAX)); + + springParams_ = {}; + springParams_.lOffset = 0; + springParams_.lPositiveCoefficient = 0; + springParams_.lNegativeCoefficient = 0; + springParams_.dwPositiveSaturation = saturation; + springParams_.dwNegativeSaturation = saturation; + springParams_.lDeadBand = 0; + + DIEFFECT effect{}; + effect.dwSize = sizeof(effect); + effect.dwFlags = DIEFF_CARTESIAN | DIEFF_OBJECTOFFSETS; + effect.dwDuration = FFB_EFFECT_LEASE_US; + effect.dwSamplePeriod = 0; + effect.dwGain = DI_FFNOMINALMAX; + effect.dwTriggerButton = DIEB_NOTRIGGER; + effect.dwTriggerRepeatInterval = 0; + effect.cAxes = 1; + effect.rgdwAxes = axes; + effect.rglDirection = directions; + effect.cbTypeSpecificParams = sizeof(springParams_); + effect.lpvTypeSpecificParams = &springParams_; + + HRESULT hr = device_->CreateEffect( + GUID_Spring, &effect, &springEffect_, nullptr); + + if (FAILED(hr) || !springEffect_) + { + spdlog::warn( + "WheelFFB: CreateEffect(GUID_Spring) failed (0x{:08X})", + (unsigned)hr); + springRecreateHoldoffUntil_ = GetTickCount() + 1000; + return false; + } + + hr = springEffect_->Start(1, 0); + if (FAILED(hr)) + { + spdlog::warn( + "WheelFFB: GUID_Spring initial Start failed (0x{:08X}); SetParameters will retry with DIEP_START", + (unsigned)hr); + } + + prevSpringCoefficient_ = 0; + prevSpringSaturation_ = saturation; + springStrategy_ = 1; // Always include DIEP_START on dynamic updates. + + spdlog::info( + "WheelFFB: GUID_Spring created (saturation={} / {}, R3 normal sign=positive, independent direction toggle)", + static_cast(saturation), + DI_FFNOMINALMAX); + return true; + } + + void update_spring(float strength) + { + if (!springEffect_ || !device_ || panicStopped_) + return; + + const float safeStrength = std::isfinite(strength) + ? std::clamp(strength, 0.0f, 1.0f) + : 0.0f; + const LONG coefficientMagnitude = static_cast( + safeStrength * static_cast(DI_FFNOMINALMAX)); + + // R3 hardware testing established positive condition coefficients + // as the normal centering sign for this backend. Keep this independent + // from ConstantForce inversion so fixing corner-force direction can + // never accidentally turn the centering spring into a runaway force. + const LONG coefficient = Settings::WheelFFBInvertSpring + ? -coefficientMagnitude + : coefficientMagnitude; + const float configuredSaturation = + static_cast(Settings::WheelFFBSpringSaturation); + const float safeSaturation = std::isfinite(configuredSaturation) + ? std::clamp(configuredSaturation, 0.1f, 1.0f) + : 0.775f; + const DWORD saturation = static_cast( + safeSaturation * static_cast(DI_FFNOMINALMAX)); + + const bool silence = + coefficient == 0 && prevSpringCoefficient_ != 0; + const bool coefficientChanged = + std::abs(coefficient - prevSpringCoefficient_) > 40; + const bool saturationChanged = + saturation != prevSpringSaturation_; + + const bool refresh = coefficient != 0 && + GetTickCount() - lastSpringWriteTick_ >= FFB_EFFECT_REFRESH_MS; + if (!silence && !coefficientChanged && !saturationChanged && !refresh) + return; + + springParams_ = {}; + springParams_.lOffset = 0; + springParams_.lPositiveCoefficient = coefficient; + springParams_.lNegativeCoefficient = coefficient; + springParams_.dwPositiveSaturation = saturation; + springParams_.dwNegativeSaturation = saturation; + springParams_.lDeadBand = 0; + + DIEFFECT params{}; + params.dwSize = sizeof(params); + params.cbTypeSpecificParams = sizeof(springParams_); + params.lpvTypeSpecificParams = &springParams_; + + DWORD flags = DIEP_TYPESPECIFICPARAMS | + (springStrategy_ == 1 ? DIEP_START : 0); + + HRESULT hr = springEffect_->SetParameters(¶ms, flags); + + if (springStrategy_ == -1) + { + if (SUCCEEDED(hr)) + { + springStrategy_ = 0; + spdlog::info("WheelFFB: GUID_Spring updates work without DIEP_START"); + } + else + { + hr = springEffect_->SetParameters( + ¶ms, DIEP_TYPESPECIFICPARAMS | DIEP_START); + if (SUCCEEDED(hr)) + { + springStrategy_ = 1; + spdlog::info("WheelFFB: GUID_Spring driver requires DIEP_START"); + } + } + } + + if (hr == DIERR_INPUTLOST || hr == DIERR_NOTACQUIRED) + { + if (!reacquire_after_input_loss("GUID_Spring", hr)) + return; + hr = springEffect_->SetParameters( + ¶ms, DIEP_TYPESPECIFICPARAMS | DIEP_START); + if (hr == DIERR_INPUTLOST || hr == DIERR_NOTACQUIRED) + { + note_device_failure("GUID_Spring retry", hr); + return; + } + } + + if (FAILED(hr)) + { + spdlog::warn( + "WheelFFB: GUID_Spring update failed (0x{:08X}); returning to software spring fallback", + (unsigned)hr); + springEffect_->Stop(); + safe_release_effect(springEffect_, "stale spring"); + prevSpringCoefficient_ = 0; + prevSpringSaturation_ = 0; + springStrategy_ = -1; + springRecreateHoldoffUntil_ = GetTickCount() + 1000; + return; + } + + clear_device_failure(); + prevSpringCoefficient_ = coefficient; + prevSpringSaturation_ = saturation; + lastSpringWriteTick_ = GetTickCount(); + } + + bool create_damper_effect() + { + if (!device_ || !Settings::WheelFFBUseHardwareDamper) + return false; + if (!query_dynamic_effect_capability( + GUID_Damper, "GUID_Damper", DIEP_TYPESPECIFICPARAMS, + damperCapsKnown_, damperDynamicParams_)) + { + spdlog::warn("WheelFFB: GUID_Damper is not safely live-updatable; using software damping"); + return false; + } + + DWORD axes[1] = { primary_actuator_axis() }; + LONG directions[1] = { 1 }; + damperParams_ = {}; + damperParams_.lOffset = 0; + damperParams_.lPositiveCoefficient = 0; + damperParams_.lNegativeCoefficient = 0; + damperParams_.dwPositiveSaturation = DI_FFNOMINALMAX; + damperParams_.dwNegativeSaturation = DI_FFNOMINALMAX; + damperParams_.lDeadBand = 0; + + DIEFFECT effect{}; + effect.dwSize = sizeof(effect); + effect.dwFlags = DIEFF_CARTESIAN | DIEFF_OBJECTOFFSETS; + effect.dwDuration = FFB_EFFECT_LEASE_US; + effect.dwSamplePeriod = 0; + effect.dwGain = DI_FFNOMINALMAX; + effect.dwTriggerButton = DIEB_NOTRIGGER; + effect.dwTriggerRepeatInterval = 0; + effect.cAxes = 1; + effect.rgdwAxes = axes; + effect.rglDirection = directions; + effect.cbTypeSpecificParams = sizeof(damperParams_); + effect.lpvTypeSpecificParams = &damperParams_; + + HRESULT hr = device_->CreateEffect( + GUID_Damper, &effect, &damperEffect_, nullptr); + if (FAILED(hr) || !damperEffect_) + { + spdlog::warn( + "WheelFFB: CreateEffect(GUID_Damper) failed (0x{:08X})", + (unsigned)hr); + damperRecreateHoldoffUntil_ = GetTickCount() + 1000; + return false; + } + + hr = damperEffect_->Start(1, 0); + if (FAILED(hr)) + spdlog::warn("WheelFFB: GUID_Damper initial Start failed (0x{:08X})", (unsigned)hr); + + prevDamperCoefficient_ = 0; + damperStrategy_ = 1; // Always include DIEP_START after menu reacquire. + spdlog::info("WheelFFB: GUID_Damper created (dynamic speed/grip damping)"); + return true; + } + + void update_damper(float strength) + { + if (!damperEffect_ || !device_ || panicStopped_) + return; + + // Positive coefficients are the conventional DirectInput condition + // representation for GUID_Damper; the effect type itself applies + // force opposite steering velocity. + const float safeStrength = std::isfinite(strength) + ? std::clamp(strength, 0.0f, 1.0f) + : 0.0f; + const LONG coefficient = static_cast( + safeStrength * static_cast(DI_FFNOMINALMAX)); + + const bool silence = coefficient == 0 && prevDamperCoefficient_ != 0; + const bool changed = std::abs(coefficient - prevDamperCoefficient_) > 40; + const bool refresh = coefficient != 0 && + GetTickCount() - lastDamperWriteTick_ >= FFB_EFFECT_REFRESH_MS; + if (!silence && !changed && !refresh) + return; + + damperParams_ = {}; + damperParams_.lOffset = 0; + damperParams_.lPositiveCoefficient = coefficient; + damperParams_.lNegativeCoefficient = coefficient; + damperParams_.dwPositiveSaturation = DI_FFNOMINALMAX; + damperParams_.dwNegativeSaturation = DI_FFNOMINALMAX; + damperParams_.lDeadBand = 0; + + DIEFFECT params{}; + params.dwSize = sizeof(params); + params.cbTypeSpecificParams = sizeof(damperParams_); + params.lpvTypeSpecificParams = &damperParams_; + + DWORD flags = DIEP_TYPESPECIFICPARAMS | + (damperStrategy_ == 1 ? DIEP_START : 0); + HRESULT hr = damperEffect_->SetParameters(¶ms, flags); + + if (damperStrategy_ == -1) + { + if (SUCCEEDED(hr)) + { + damperStrategy_ = 0; + spdlog::info("WheelFFB: GUID_Damper updates work without DIEP_START"); + } + else + { + hr = damperEffect_->SetParameters( + ¶ms, DIEP_TYPESPECIFICPARAMS | DIEP_START); + if (SUCCEEDED(hr)) + { + damperStrategy_ = 1; + spdlog::info("WheelFFB: GUID_Damper driver requires DIEP_START"); + } + } + } + + if (hr == DIERR_INPUTLOST || hr == DIERR_NOTACQUIRED) + { + if (!reacquire_after_input_loss("GUID_Damper", hr)) + return; + hr = damperEffect_->SetParameters( + ¶ms, DIEP_TYPESPECIFICPARAMS | DIEP_START); + if (hr == DIERR_INPUTLOST || hr == DIERR_NOTACQUIRED) + { + note_device_failure("GUID_Damper retry", hr); + return; + } + } + + if (FAILED(hr)) + { + spdlog::warn( + "WheelFFB: GUID_Damper update failed (0x{:08X}); using software fallback", + (unsigned)hr); + damperEffect_->Stop(); + safe_release_effect(damperEffect_, "stale damper"); + prevDamperCoefficient_ = 0; + damperStrategy_ = -1; + damperRecreateHoldoffUntil_ = GetTickCount() + 1000; + return; + } + + clear_device_failure(); + prevDamperCoefficient_ = coefficient; + lastDamperWriteTick_ = GetTickCount(); + } + + IDirectInputEffect* create_periodic_effect(const char* label, float initialHz) + { + if (!device_) + return nullptr; + if (!query_dynamic_effect_capability( + GUID_Sine, "GUID_Sine", DIEP_TYPESPECIFICPARAMS, + periodicCapsKnown_, periodicDynamicParams_)) + { + spdlog::warn("WheelFFB: GUID_Sine is not safely live-updatable; using ConstantForce vibration fallback"); + return nullptr; + } + + DWORD axes[1] = { primary_actuator_axis() }; + LONG directions[1] = { 1 }; + DIPERIODIC& periodic = (std::strcmp(label, "RoadTexture") == 0) + ? roadPeriodicParams_ + : tireSlipPeriodicParams_; + periodic = {}; + periodic.dwMagnitude = 0; + periodic.lOffset = 0; + periodic.dwPhase = 0; + periodic.dwPeriod = static_cast(1000000.0f / initialHz); + + DIEFFECT effect{}; + effect.dwSize = sizeof(effect); + effect.dwFlags = DIEFF_CARTESIAN | DIEFF_OBJECTOFFSETS; + effect.dwDuration = FFB_EFFECT_LEASE_US; + effect.dwGain = DI_FFNOMINALMAX; + effect.dwTriggerButton = DIEB_NOTRIGGER; + effect.cAxes = 1; + effect.rgdwAxes = axes; + effect.rglDirection = directions; + effect.cbTypeSpecificParams = sizeof(periodic); + effect.lpvTypeSpecificParams = &periodic; + + IDirectInputEffect* result = nullptr; + HRESULT hr = device_->CreateEffect( + GUID_Sine, &effect, &result, nullptr); + + if (FAILED(hr) || !result) + { + spdlog::warn( + "WheelFFB: CreateEffect({}/GUID_Sine) failed (0x{:08X})", + label, (unsigned)hr); + return nullptr; + } + + hr = result->Start(1, 0); + if (FAILED(hr)) + spdlog::warn("WheelFFB: {} initial Start failed (0x{:08X})", label, (unsigned)hr); + + spdlog::info("WheelFFB: {} hardware sine created", label); + return result; + } + + void create_periodic_effects() + { + if (!Settings::WheelFFBUsePeriodicEffects || !device_) + { + periodicsActive_ = false; + return; + } + + if (!roadTextureEffect_) + roadTextureEffect_ = create_periodic_effect("RoadTexture", 30.0f); + if (!tireSlipEffect_) + tireSlipEffect_ = create_periodic_effect("TireSlip", 35.0f); + + roadState_ = {}; + slipState_ = {}; + periodicsActive_ = + roadTextureEffect_ != nullptr && tireSlipEffect_ != nullptr; + + if (!periodicsActive_) + { + // Treat the two sines atomically. A half-created pair plus the + // software fallback would double one signal and distort tuning. + disable_periodics(); + periodicRecreateHoldoffUntil_ = GetTickCount() + 500; + spdlog::warn( + "WheelFFB: complete hardware periodic pair unavailable; using ConstantForce fallback for both signals"); + } + } + + void update_periodic( + IDirectInputEffect*& effect, + PeriodicState& state, + float magnitude, + float frequency) + { + if (!effect || panicStopped_) + return; + + // Very small hardware-sine magnitudes can remain audible on DD + // bases. Snap them to zero, and update much more aggressively on + // falling magnitude so an off-road effect cannot remain latched + // after the car returns to asphalt. + const float magnitudeClamped = std::isfinite(magnitude) + ? std::clamp(magnitude, 0.0f, 1.0f) + : 0.0f; + const DWORD mag = magnitudeClamped < 0.01f + ? 0u + : static_cast( + magnitudeClamped * static_cast(DI_FFNOMINALMAX)); + frequency = std::isfinite(frequency) + ? std::clamp(frequency, 1.0f, 100.0f) + : 30.0f; + const DWORD period = static_cast(1000000.0f / frequency); + + const long magDelta = std::abs( + static_cast(mag) - static_cast(state.lastMagnitude)); + const bool silence = mag == 0 && state.lastMagnitude != 0; + const bool magChanged = + magDelta > 60 || + (state.lastMagnitude > 0 && + magDelta * 5 > static_cast(state.lastMagnitude)); + const bool periodChanged = + state.lastPeriod != 0 && + std::abs(static_cast(period) - static_cast(state.lastPeriod)) * 10 > + static_cast(state.lastPeriod); + + const DWORD periodicNow = GetTickCount(); + const bool refresh = mag != 0 && + periodicNow - state.lastWriteTick >= FFB_EFFECT_REFRESH_MS; + if (!silence && !magChanged && !periodChanged && !refresh) + return; + + DIPERIODIC& periodic = (effect == roadTextureEffect_) + ? roadPeriodicParams_ + : tireSlipPeriodicParams_; + periodic = {}; + periodic.dwMagnitude = mag; + periodic.dwPeriod = period; + // DIEP_START restarts the lease. Advance phase so refreshing an + // unchanged envelope does not deliberately restart at phase zero. + if (state.lastPeriod != 0) + state.phaseCycles = std::fmod(state.phaseCycles + + double(periodicNow - state.lastWriteTick) * 1000.0 / state.lastPeriod, 1.0); + periodic.dwPhase = static_cast(state.phaseCycles * 36000.0); + + DIEFFECT params{}; + params.dwSize = sizeof(params); + params.cbTypeSpecificParams = sizeof(periodic); + params.lpvTypeSpecificParams = &periodic; + + DWORD flags = DIEP_TYPESPECIFICPARAMS | + (periodicStrategy_ == 1 ? DIEP_START : 0); + + HRESULT hr = effect->SetParameters(¶ms, flags); + + if (periodicStrategy_ == -1) + { + if (SUCCEEDED(hr)) + { + periodicStrategy_ = 0; + spdlog::info("WheelFFB: periodic updates work without DIEP_START"); + } + else + { + hr = effect->SetParameters( + ¶ms, DIEP_TYPESPECIFICPARAMS | DIEP_START); + if (SUCCEEDED(hr)) + { + periodicStrategy_ = 1; + spdlog::info("WheelFFB: periodic driver requires DIEP_START"); + } + } + } + + if (hr == DIERR_INPUTLOST || hr == DIERR_NOTACQUIRED) + { + if (!reacquire_after_input_loss("GUID_Sine periodic", hr)) + return; + hr = effect->SetParameters( + ¶ms, DIEP_TYPESPECIFICPARAMS | DIEP_START); + if (hr == DIERR_INPUTLOST || hr == DIERR_NOTACQUIRED) + { + note_device_failure("GUID_Sine periodic retry", hr); + return; + } + } + + if (FAILED(hr)) + { + spdlog::warn( + "WheelFFB: periodic update failed (0x{:08X}); falling back to ConstantForce vibration", + (unsigned)hr); + disable_periodics(); + periodicRecreateHoldoffUntil_ = GetTickCount() + 500; + return; + } + + clear_device_failure(); + state.lastMagnitude = mag; + state.lastPeriod = period; + state.lastWriteTick = periodicNow; + } + + void disable_periodics() + { + if (roadTextureEffect_) + roadTextureEffect_->Stop(); + if (tireSlipEffect_) + tireSlipEffect_->Stop(); + + safe_release_effect(roadTextureEffect_, "road texture"); + safe_release_effect(tireSlipEffect_, "tire slip"); + + roadState_ = {}; + slipState_ = {}; + periodicsActive_ = false; + } + + void record_graph_sample(float rawStructural, float softLimited, float postSlew, float finalOutput) + { + const size_t slot = graphWriteIndex_ % WheelFFBGraphCapacity; + graphRawStructural_[slot] = std::isfinite(rawStructural) ? rawStructural : 0.0f; + graphSoftLimited_[slot] = std::isfinite(softLimited) ? softLimited : 0.0f; + graphPostSlew_[slot] = std::isfinite(postSlew) ? postSlew : 0.0f; + graphFinalOutput_[slot] = std::isfinite(finalOutput) ? finalOutput : 0.0f; + ++graphWriteIndex_; + graphCount_ = std::min(graphCount_ + 1, WheelFFBGraphCapacity); + } + + void record_headroom(float demand, bool eligible) + { + if (!std::isfinite(demand)) + return; + demand = std::max(0.0f, demand); + headroomCurrentDemand_ = demand; + if (!eligible) + return; + + headroomPeakDemand_ = std::max(headroomPeakDemand_, demand); + ++headroomSamples_; + if (demand > 0.75f) + ++headroomSoftKneeSamples_; + if (demand >= 1.35f) + ++headroomHardClipSamples_; + + const float normalized = std::clamp(demand / HeadroomHistogramMax, 0.0f, 1.0f); + const size_t bin = std::min( + HeadroomHistogramBins - 1, + static_cast(std::lround( + normalized * static_cast(HeadroomHistogramBins - 1)))); + ++headroomHistogram_[bin]; + } + + LONG apply_response_correction(LONG requestedLevel) + { + if (!Settings::WheelFFBResponseCorrection || requestedLevel == 0) + return requestedLevel; + + const std::string spec = Settings::WheelFFBResponseLUT.get(); + if (spec != responseLutSpec_) + { + responseLutSpec_ = spec; + WheelFFBMath::ResponseLUT parsed{}; + responseLutValid_ = WheelFFBMath::parse_response_lut(spec, parsed); + if (responseLutValid_) + { + responseLut_ = parsed; + spdlog::info("WheelFFB: loaded valid wheel response LUT"); + } + else + { + responseLut_ = WheelFFBMath::linear_response_lut(); + spdlog::warn( + "WheelFFB: invalid ResponseLUT; leaving ConstantForce linear until corrected"); + } + } + + if (!responseLutValid_) + return requestedLevel; + + const float normalized = + static_cast(requestedLevel) / static_cast(DI_FFNOMINALMAX); + const float corrected = WheelFFBMath::apply_response_lut(normalized, responseLut_); + return std::clamp( + static_cast(std::lround(corrected * static_cast(DI_FFNOMINALMAX))), + -static_cast(DI_FFNOMINALMAX), + static_cast(DI_FFNOMINALMAX)); + } + + void set_constant_force(LONG requestedLevel) + { + if (!device_ || panicStopped_) + return; + + requestedLevel = std::clamp( + requestedLevel, + -static_cast(DI_FFNOMINALMAX), + static_cast(DI_FFNOMINALMAX)); + + constantParams_ = {}; + LONG directions[2] = { 1L, 0L }; + DWORD flags = DIEP_TYPESPECIFICPARAMS | DIEP_START; + + DIEFFECT params{}; + params.dwSize = sizeof(params); + params.cbTypeSpecificParams = sizeof(constantParams_); + params.lpvTypeSpecificParams = &constantParams_; + + if (constantEffectPolar_) + { + constantParams_.lMagnitude = std::abs(requestedLevel); + directions[0] = requestedLevel < 0 ? 27000L : 9000L; + params.dwFlags = DIEFF_POLAR | DIEFF_OBJECTOFFSETS; + params.cAxes = 2; + params.rglDirection = directions; + flags |= DIEP_DIRECTION; + } + else + { + constantParams_.lMagnitude = requestedLevel; + } + + HRESULT hr = E_FAIL; + if (constantEffect_) + hr = constantEffect_->SetParameters(¶ms, flags); + + if (hr == DIERR_INPUTLOST || hr == DIERR_NOTACQUIRED) + { + // Input loss is a transient ownership/focus condition, not proof + // that this physical DirectInput interface is the wrong FFB port. + // Preserve the grace window before any device reinitialization. + if (!reacquire_after_input_loss("ConstantForce", hr) || !constantEffect_) + return; + hr = constantEffect_->SetParameters(¶ms, flags); + if (hr == DIERR_INPUTLOST || hr == DIERR_NOTACQUIRED) + { + note_device_failure("ConstantForce retry", hr); + return; + } + } + + if (hr == E_HANDLE || hr == DIERR_NOTDOWNLOADED || !constantEffect_) + { + safe_release_effect(constantEffect_, "stale constant"); + const DWORD now = GetTickCount(); + if (tick_before(now, constantRecreateHoldoffUntil_)) + return; + + if (!create_constant_effect()) + { + constantRecreateHoldoffUntil_ = now + 500; + request_device_reinitialize("ConstantForce recreation rejected interface", hr); + return; + } + + recreateRampFrames_ = RecreateRampFrames; + prevConstantLevel_ = 0; + prevStructuralLevel_ = 0; + clear_constant_live_failure(); + spdlog::info("WheelFFB: recreated ConstantForce after handle loss; ramping in"); + return; + } + + if (FAILED(hr)) + { + if (!record_constant_live_failure()) + { + note_device_failure("ConstantForce live SetParameters", hr); + spdlog::warn( + "WheelFFB: transient ConstantForce update failure {}/{} (0x{:08X}); retaining the current interface", + constantLiveFailureCount_, FFB_CONSTANT_LIVE_FAILURE_LIMIT, (unsigned)hr); + return; + } + + mark_selected_interface_failed("ConstantForce live SetParameters", hr); + request_device_reinitialize("ConstantForce output failed repeatedly", hr); + spdlog::warn( + "WheelFFB: ConstantForce update failed {} consecutive times (0x{:08X}); current interface will be skipped on reinitialization", + constantLiveFailureCount_, (unsigned)hr); + return; + } + + clear_constant_live_failure(); + clear_device_failure(); + prevConstantLevel_ = requestedLevel; + lastConstantWriteTick_ = GetTickCount(); + } + + void update_crash_detection(float speed, uint32_t stateFlags) + { + if (crashImpulseTimer_ <= 0 && speedHistoryIndex_ > 6) + { + const float oldSpeed = + speedHistory_[(speedHistoryIndex_ - 6) % SpeedHistoryCount]; + const float speedDrop = oldSpeed - speed; + + if (speedDrop > 0.03f && speed > 0.10f) + { + const float lateralBeforeImpact = + lateralHistoryIndex_ > 8 + ? lateralHistory_[(lateralHistoryIndex_ - 8) % LateralHistoryCount] + : smoothedLateral_; + + const float direction = + lateralBeforeImpact >= 0.0f ? -1.0f : 1.0f; + + const float severity = + std::clamp((speedDrop - 0.03f) / 0.12f, 0.0f, 1.0f); + crashImpulseForce_ = + direction * (1.7f + 0.8f * severity) * + static_cast(Settings::WheelFFBWallImpact); + + crashImpulseTimer_ = CrashTimerFrames; + smoothedLateral_ = 0.0f; + + if (Settings::WheelFFBDebugLog) + { + spdlog::info( + "WheelFFB: crash speedDrop={:.3f}, dir={:.0f}", + speedDrop, direction); + } + } + } + + const bool collision = (stateFlags & 0x1000) != 0; + const bool wasCollision = (prevCollisionFlags_ & 0x1000) != 0; + + if (collision && !wasCollision && crashImpulseTimer_ <= 0) + { + const float lateralBeforeImpact = + lateralHistoryIndex_ > 8 + ? lateralHistory_[(lateralHistoryIndex_ - 8) % LateralHistoryCount] + : smoothedLateral_; + + const float direction = + lateralBeforeImpact >= 0.0f ? -1.0f : 1.0f; + + crashImpulseForce_ = + direction * 1.9f * + static_cast(Settings::WheelFFBWallImpact); + crashImpulseTimer_ = CrashTimerFrames; + smoothedLateral_ = 0.0f; + } + } + + void update_gear_event(uint32_t curGear) + { + if (curGear != prevGear_ && prevGear_ != 0 && gearShiftTimer_ <= 0) + gearShiftTimer_ = 6; + } + + float update_event_force() + { + float result = 0.0f; + + if (crashImpulseTimer_ > 0) + { + if (crashImpulseTimer_ > CrashCooldownFrames) + { + // A short kick/rebound is much easier to feel on a DD wheel + // than the old soft one-direction 10-frame push. + const int impactFrame = CrashTimerFrames - crashImpulseTimer_; + if (impactFrame < 3) + result += crashImpulseForce_; + else if (impactFrame < 6) + result -= crashImpulseForce_ * 0.55f; + else + result += crashImpulseForce_ * 0.20f; + } + --crashImpulseTimer_; + } + + if (gearShiftTimer_ > 0) + { + const float thunk = + 0.20f * + static_cast(Settings::WheelFFBGearShift) * + (gearShiftTimer_ > 3 ? 1.0f : -1.0f); + result += thunk; + --gearShiftTimer_; + } + + return result; + } + + float synth_fallback(float& phase, float amplitude, float frequency) + { + if (!std::isfinite(amplitude) || !std::isfinite(frequency) || + amplitude <= 0.005f) + { + phase = 0.0f; + return 0.0f; + } + + constexpr float TwoPi = 6.28318530718f; + phase = std::fmod(phase + frequency / 60.0f * TwoPi, TwoPi); + return std::sin(phase) * amplitude; + } + + void zero_all_forces() + { + if (!initialized_ || panicStopped_) + return; + + if (constantEffect_ && prevConstantLevel_ != 0) + set_constant_force(0); + + if (springEffect_ && prevSpringCoefficient_ != 0) + update_spring(0.0f); + + if (damperEffect_ && prevDamperCoefficient_ != 0) + update_damper(0.0f); + + prevStructuralLevel_ = 0; + + if (roadTextureEffect_) + update_periodic(roadTextureEffect_, roadState_, 0.0f, 30.0f); + if (tireSlipEffect_) + update_periodic(tireSlipEffect_, slipState_, 0.0f, 35.0f); + } + + void reset_signal_state() + { + smoothedLateral_ = 0.0f; + smoothedLongAccel_ = 0.0f; + prevSteer_ = 0.0f; + smoothedSteerRate_ = 0.0f; + steerSampleValid_ = false; + vehicleDynamics_.reset_dynamic(); + prevStructuralLevel_ = 0; + prevSpringCoefficient_ = 0; + prevDamperCoefficient_ = 0; + crashImpulseTimer_ = 0; + crashImpulseForce_ = 0.0f; + gearShiftTimer_ = 0; + warmupFrames_ = 0; + roadPhase_ = 0.0f; + slipPhase_ = 0.0f; + enginePhase_ = 0.0f; + smoothedEngineRpm_ = 0.0f; + smoothedEngineAmp_ = 0.0f; + smoothedEngineFreq_ = 0.0f; + splashTimer_ = 0; + splashAmp_ = 0.0f; + manualTestFrames_ = 0; + manualTestDirection_ = 1; + + // Menu/race transitions must not reuse samples from the previous + // gameplay segment. Old speed/lateral history can otherwise look + // like a huge first-frame deceleration and synthesize a false crash + // or weight-transfer kick when returning from F11/the main menu. + std::fill_n(speedHistory_, SpeedHistoryCount, 0.0f); + speedHistoryIndex_ = 0; + std::fill_n(lateralHistory_, LateralHistoryCount, 0.0f); + lateralHistoryIndex_ = 0; + prevGear_ = 0; + prevCollisionFlags_ = 0; + } + + void install_exit_guards() + { + if (!gameHwnd_) + return; + + // USB/device-loss recovery may call initialize() more than once. + // Window subclassing and inline-hooking ExitProcess must be + // idempotent or a second initialization can create a bad hook chain. + if (subclassHwnd_ != gameHwnd_) + { + if (subclassHwnd_ && IsWindow(subclassHwnd_)) + { + KillTimer(subclassHwnd_, FFB_WATCHDOG_TIMER_ID); + RemoveWindowSubclass( + subclassHwnd_, window_subclass_proc, FFB_SUBCLASS_ID); + } + + if (SetWindowSubclass( + gameHwnd_, + window_subclass_proc, + FFB_SUBCLASS_ID, + reinterpret_cast(this))) + { + subclassHwnd_ = gameHwnd_; + SetTimer( + gameHwnd_, + FFB_WATCHDOG_TIMER_ID, + FFB_WATCHDOG_INTERVAL_MS, + nullptr); + spdlog::info("WheelFFB: window exit/watchdog guard installed"); + } + else + { + spdlog::warn("WheelFFB: SetWindowSubclass failed"); + } + } + + if (!exitProcessHook_) + { + if (auto* exitProc = + GetProcAddress(GetModuleHandleA("kernel32.dll"), "ExitProcess")) + { + exitProcessHook_ = + safetyhook::create_inline(exitProc, exit_process_hook); + if (exitProcessHook_) + spdlog::info("WheelFFB: ExitProcess safety hook installed"); + } + } + + activeEngine_ = this; + } + + static LRESULT CALLBACK window_subclass_proc( + HWND hwnd, + UINT message, + WPARAM wParam, + LPARAM lParam, + UINT_PTR, + DWORD_PTR refData) + { + auto* self = reinterpret_cast(refData); + + if (self) + { + switch (message) + { + case WM_TIMER: + if (wParam == FFB_WATCHDOG_TIMER_ID) + self->check_watchdog(); + break; + + case WM_ACTIVATEAPP: + self->appActive_ = (wParam != FALSE); + if (!self->appActive_) + { + self->zero_all_forces(); + self->reset_signal_state(); + if (self->device_ && self->deviceAcquired_) + { + self->device_->Unacquire(); + self->deviceAcquired_ = false; + } + } + else + { + // Re-enter with the normal DD warm-up ramp instead of + // restoring the previous torque in one frame. + self->warmupFrames_ = 0; + } + break; + + case WM_CLOSE: + case WM_DESTROY: + case WM_QUERYENDSESSION: + self->panic_stop(); + break; + } + } + + return DefSubclassProc(hwnd, message, wParam, lParam); + } + + static void WINAPI exit_process_hook(UINT exitCode) + { + auto* engine = activeEngine_; + if (!engine) + return; + + engine->panic_stop(); + engine->exitProcessHook_.stdcall(exitCode); + } + + static void safe_release_effect( + IDirectInputEffect*& effect, + const char* name) + { + if (!effect) + return; + + __try + { + effect->Release(); + } + __except (EXCEPTION_EXECUTE_HANDLER) + { + spdlog::warn( + "WheelFFB: exception releasing {} effect (0x{:X})", + name, + GetExceptionCode()); + } + effect = nullptr; + } + + void restore_driver_autocenter(const char* where) + { + if (!device_ || !driverAutocenterDisabled_ || !autocenterRestoreKnown_) + return; + + deviceAcquired_ = false; + __try + { + device_->Unacquire(); + + DIPROPDWORD autocenter{}; + autocenter.diph.dwSize = sizeof(autocenter); + autocenter.diph.dwHeaderSize = sizeof(DIPROPHEADER); + autocenter.diph.dwObj = 0; + autocenter.diph.dwHow = DIPH_DEVICE; + autocenter.dwData = originalAutocenter_; + const HRESULT restoreHr = + device_->SetProperty(DIPROP_AUTOCENTER, &autocenter.diph); + if (FAILED(restoreHr)) + { + spdlog::warn( + "WheelFFB: autocenter restore during {} failed (0x{:08X})", + where, (unsigned)restoreHr); + } + else + { + driverAutocenterDisabled_ = false; + } + } + __except (EXCEPTION_EXECUTE_HANDLER) + { + spdlog::warn( + "WheelFFB: exception restoring autocenter during {} (0x{:X})", + where, GetExceptionCode()); + } + } + + void release_device() + { + if (!device_) + { + deviceAcquired_ = false; + driverAutocenterDisabled_ = false; + return; + } + + // Clear logical acquisition state before touching a possibly stale + // COM object. Even a driver exception must not leave a phantom + // acquired device behind in the engine state. + deviceAcquired_ = false; + restore_driver_autocenter("device release"); + + IDirectInputDevice8A* staleDevice = device_; + device_ = nullptr; + driverAutocenterDisabled_ = false; + __try + { + staleDevice->Unacquire(); + staleDevice->Release(); + } + __except (EXCEPTION_EXECUTE_HANDLER) + { + spdlog::warn( + "WheelFFB: exception releasing DirectInput device (0x{:X})", + GetExceptionCode()); + } + } + + void release_directinput() + { + if (!directInput_) + return; + + IDirectInput8A* staleDirectInput = directInput_; + directInput_ = nullptr; + __try + { + staleDirectInput->Release(); + } + __except (EXCEPTION_EXECUTE_HANDLER) + { + spdlog::warn( + "WheelFFB: exception releasing DirectInput root object (0x{:X})", + GetExceptionCode()); + } + } + + void maybe_log( + float speedNorm, + float steer, + float steerRate, + float lateralLoad, + float bodySlide, + float frontScrub, + float roughness, + float satTorque, + LONG level) + { + if (!Settings::WheelFFBDebugLog) + return; + + const DWORD now = GetTickCount(); + if (now - lastLogTick_ < 2000) + return; + + lastLogTick_ = now; + spdlog::info( + "WheelFFB DIAG: spd={:.2f} steer={:.3f} rate={:.4f} lat={:.2f} load={:.2f} slide={:.2f} scrub={:.2f} rough={:.2f} sat={:.3f} phys={} basis=M70r{} cal={:.2f} mix={:.2f} beta={:.3f} yaw={:.3f} fslip={:.3f} vLat={:.5f} vLong={:.5f} step={:.5f} spdLen={:.5f} spdCorr={:.2f} steerSrc={} out={} invCF={} spring={} invSpring={} coeff={} damper={} dcoeff={} periodic={}", + speedNorm, + steer, + steerRate, + smoothedLateral_, + lateralLoad, + bodySlide, + frontScrub, + roughness, + satTorque, + Settings::WheelFFBPhysicsSat + ? (vehicleDynamics_.calibrated() + ? (vehicleDynamics_.sampleValid() ? "ACTIVE" : "FALLBACK") + : "CAL") + : "OFF", + vehicleDynamics_.forwardAxis(), + vehicleDynamics_.calibrationConfidence(), + vehicleDynamics_.activationBlend(), + vehicleDynamics_.bodySlip(), + vehicleDynamics_.yawRate(), + vehicleDynamics_.frontSlip(), + vehicleDynamics_.vLat(), + vehicleDynamics_.vLong(), + vehicleDynamics_.positionStep(), + vehicleDynamics_.spdLen(), + vehicleDynamics_.spdCorrelation(), + Settings::UseNewInput ? "SDL" : "legacy", + static_cast(level), + bool(Settings::WheelFFBInvertForce), + springEffect_ ? "HW" : "SW", + bool(Settings::WheelFFBInvertSpring), + static_cast(prevSpringCoefficient_), + damperEffect_ ? "HW" : "SW", + static_cast(prevDamperCoefficient_), + periodicsActive_); + } + + DWORD lastTelemetryTick_ = 0; + DWORD lastConstantWriteTick_ = 0; + DWORD lastSpringWriteTick_ = 0; + DWORD lastDamperWriteTick_ = 0; + EVWORK_CAR* lastCar_ = nullptr; + IDirectInput8A* directInput_ = nullptr; + IDirectInputDevice8A* device_ = nullptr; + IDirectInputEffect* constantEffect_ = nullptr; + IDirectInputEffect* springEffect_ = nullptr; + IDirectInputEffect* damperEffect_ = nullptr; + IDirectInputEffect* roadTextureEffect_ = nullptr; + IDirectInputEffect* tireSlipEffect_ = nullptr; + + // DirectInput does not promise to copy lpvTypeSpecificParams. Keep the + // backing structures alive for as long as their effects exist. + DICONSTANTFORCE constantParams_{}; + DICONDITION springParams_{}; + DICONDITION damperParams_{}; + DIPERIODIC roadPeriodicParams_{}; + DIPERIODIC tireSlipPeriodicParams_{}; + + std::vector actuatorAxes_; + bool constantEffectPolar_ = false; + + GUID selectedGuid_{}; + std::string selectedName_; + std::string selectedConfiguredGuid_; + std::string selectedConfiguredName_; + HWND gameHwnd_ = nullptr; + HWND subclassHwnd_ = nullptr; + + bool initialized_ = false; + bool panicStopped_ = false; + bool deviceAcquired_ = false; + bool driverAutocenterDisabled_ = false; + bool autocenterRestoreKnown_ = false; + DWORD originalAutocenter_ = DIPROPAUTOCENTER_ON; + bool enabledLastTick_ = true; + bool appActive_ = true; + bool periodicsActive_ = false; + bool directionTested_ = false; + bool constantCapsKnown_ = false; + bool springCapsKnown_ = false; + bool damperCapsKnown_ = false; + bool periodicCapsKnown_ = false; + DWORD constantDynamicParams_ = 0; + DWORD springDynamicParams_ = 0; + DWORD damperDynamicParams_ = 0; + DWORD periodicDynamicParams_ = 0; + int periodicStrategy_ = 1; // Explicitly restart sine effects on every update. + int springStrategy_ = -1; + int damperStrategy_ = -1; + + DWORD retryAfter_ = 0; + std::vector failedInterfaces_; + DWORD preferredVidPid_ = 0; + GUID preferredProductGuid_{}; + GUID preferredFFDriverGuid_{}; + WORD preferredVendorId_ = 0; + DWORD constantRecreateHoldoffUntil_ = 0; + DWORD periodicRecreateHoldoffUntil_ = 0; + DWORD springRecreateHoldoffUntil_ = 0; + DWORD damperRecreateHoldoffUntil_ = 0; + DWORD lastUpdateTick_ = 0; + DWORD lastLogTick_ = 0; + DWORD deviceFailureSince_ = 0; + unsigned constantLiveFailureCount_ = 0; + DWORD deviceReinitAfter_ = 0; + bool deviceReinitPending_ = false; + + PeriodicState roadState_{}; + PeriodicState slipState_{}; + + float smoothedLateral_ = 0.0f; + float smoothedLongAccel_ = 0.0f; + float prevSteer_ = 0.0f; + float smoothedSteerRate_ = 0.0f; + bool steerSampleValid_ = false; + WheelVehicleDynamics vehicleDynamics_{}; + + std::array headroomHistogram_{}; + std::uint64_t headroomSamples_ = 0; + std::uint64_t headroomSoftKneeSamples_ = 0; + std::uint64_t headroomHardClipSamples_ = 0; + float headroomCurrentDemand_ = 0.0f; + float headroomPeakDemand_ = 0.0f; + + std::array graphRawStructural_{}; + std::array graphSoftLimited_{}; + std::array graphPostSlew_{}; + std::array graphFinalOutput_{}; + std::size_t graphWriteIndex_ = 0; + std::size_t graphCount_ = 0; + + WheelFFBMath::ResponseLUT responseLut_ = WheelFFBMath::linear_response_lut(); + std::string responseLutSpec_; + bool responseLutValid_ = true; + + float crashImpulseForce_ = 0.0f; + float roadPhase_ = 0.0f; + float slipPhase_ = 0.0f; + float enginePhase_ = 0.0f; + float smoothedEngineRpm_ = 0.0f; + float smoothedEngineAmp_ = 0.0f; + float smoothedEngineFreq_ = 0.0f; + float splashAmp_ = 0.0f; + + float speedHistory_[SpeedHistoryCount]{}; + int speedHistoryIndex_ = 0; + float lateralHistory_[LateralHistoryCount]{}; + int lateralHistoryIndex_ = 0; + + uint32_t prevGear_ = 0; + uint32_t prevCollisionFlags_ = 0; + + LONG prevConstantLevel_ = 0; + LONG prevStructuralLevel_ = 0; + LONG prevSpringCoefficient_ = 0; + LONG prevDamperCoefficient_ = 0; + DWORD prevSpringSaturation_ = 0; + + int crashImpulseTimer_ = 0; + int gearShiftTimer_ = 0; + int warmupFrames_ = 0; + int recreateRampFrames_ = 0; + int manualTestFrames_ = 0; + int manualTestDirection_ = 1; + int splashTimer_ = 0; + unsigned updateCounter_ = 0; + + SafetyHookInline exitProcessHook_{}; + + inline static WheelFFBEngine* activeEngine_ = nullptr; + }; + + WheelFFBEngine gWheelFFB; + + class WheelFFBHook : public Hook + { + // FFB update ownership lives in the existing GamePlCar_Ctrl wrapper. + // Do not install another inline hook on the physics/vibration path. + + public: + std::string_view description() override + { + return "WheelFFB (DirectInput COM)"; + } + + void declare_settings() override + { + // Wheel FFB has one dedicated F11 page. Keep every [WheelFFB] + // setting out of the generic Settings window so there is no second + // UI that looks like a competing backend. + for (auto* setting : Settings::SettingBase::registry()) + { + if (setting && setting->section() == "WheelFFB") + setting->hidden(true); + } + } + + bool validate() override + { + // The engine itself gates on WheelFFBEnable; the existing Vibration + // GamePlCar_Ctrl wrapper invokes us after each 60 Hz physics tick. + return true; + } + + bool apply() override + { + spdlog::info( + "WheelFFB: DirectInput COM engine registered; update runs after GamePlCar_Ctrl physics"); + return true; + } + + static WheelFFBHook instance; + }; + + WheelFFBHook WheelFFBHook::instance; +} + +void __cdecl WheelFFB_UpdateAfterPhysics(EVWORK_CAR* car) +{ + gWheelFFB.update(car); +} + +void WheelFFB_ServiceSafety() +{ + gWheelFFB.service_safety(); +} + +bool WheelFFB_IsOutputOwnerActive() +{ + return gWheelFFB.output_owner_active(); +} + +void WheelFFB_RequestDirectionTest(int direction) +{ + gWheelFFB.request_direction_test(direction); +} + +void WheelFFB_RequestSettingsTransition() +{ + gWheelFFB.settings_transition(); +} + +void WheelFFB_ResetDirectionTest() +{ + gWheelFFB.reset_direction_test(); +} + +WheelFFBHeadroomSnapshot WheelFFB_GetHeadroomSnapshot() +{ + return gWheelFFB.headroom_snapshot(); +} + +WheelFFBStatusSnapshot WheelFFB_GetStatusSnapshot() +{ + return gWheelFFB.status_snapshot(); +} + +WheelFFBGraphSnapshot WheelFFB_GetGraphSnapshot() +{ + return gWheelFFB.graph_snapshot(); +} + +void WheelFFB_ResetHeadroomStats() +{ + gWheelFFB.reset_headroom_stats(); +} diff --git a/src/hooks_wheel_ffb_build.cpp b/src/hooks_wheel_ffb_build.cpp new file mode 100644 index 000000000..8b8ac4e42 --- /dev/null +++ b/src/hooks_wheel_ffb_build.cpp @@ -0,0 +1,857 @@ +// Build shim for the experimental WheelFFB implementation. +// Keep NOMINMAX local to this translation unit so Windows min/max macros do not +// collide with std::min/std::max/std::clamp in the DirectInput FFB engine. +#define NOMINMAX + +// Compile the core through two narrow compatibility aliases. The surface alias +// lets this shim provide a temporary roughness floor only while a snow curb is +// latched; the original game surface LUT remains authoritative everywhere else. +#define sub_1149C0 WheelFFB_SurfaceRoughnessForCore +#define WheelFFB_UpdateAfterPhysics WheelFFB_UpdateAfterPhysics_Core +#include "hooks_wheel_ffb.cpp" +#undef WheelFFB_UpdateAfterPhysics +#undef sub_1149C0 + +#include "input_manager.hpp" +#include "hooks_wheel_input_compat_v2.hpp" +#include "hooks_wheel_r3_menu_dpad.hpp" +#include "hooks_wheel_r3_menu_ab.hpp" +#include "hooks_wheel_r3_device_autoselect.hpp" +#include "hooks_wheel_menu_keyboard_back.hpp" +#include "hooks_wheel_menu_keyboard_select.hpp" +#include "hooks_wheel_legacy_blank_defaults.hpp" + +#include +#include + +// The core include above declared the macro-renamed surface function. Define it +// here as a transparent pass-through with one optional, one-tick compatibility +// floor. This avoids mutating EVWORK_CAR or weakening the core's normal asphalt +// threshold just to retain a snow curb whose material scalar is below snow. +extern double __cdecl sub_1149C0( + unsigned int surfaceMask, int loadColiType, DWORD* waterFlag); +namespace +{ + bool coreSurfaceRoughnessFloorActive = false; + float coreSurfaceRoughnessFloor = 0.0f; +} + +double __cdecl WheelFFB_SurfaceRoughnessForCore( + unsigned int surfaceMask, int loadColiType, DWORD* waterFlag) +{ + const double original = sub_1149C0(surfaceMask, loadColiType, waterFlag); + if (!coreSurfaceRoughnessFloorActive || !std::isfinite(original)) + return original; + return std::max(original, static_cast(coreSurfaceRoughnessFloor)); +} + +// The legacy vibration path lives in hooks_forcefeedback.cpp, but this wheel +// build owns the modern input/FFB integration. Keep its controller routing safe +// without duplicating the large Xbox-derived vibration routine. +namespace Settings +{ + extern Setting VibrationMode; + extern Setting VibrationControllerId; + + // One-shot migration marker for FFB feel defaults. It is deliberately + // device-agnostic: wheel model names must never select a different force + // model or different SAT response. + Setting WheelFFBFeelRevision{ + "WheelFFB", "FeelRevision", 0, + "Internal one-shot migration version for wheel FFB feel defaults.", + Range{ 0, 5 } + }; +} + +extern int VibrationUserId; +void SetVibration(int userId, float leftMotor, float rightMotor); +void InputManager_StopVibration(); +void InputManager_Update(); + +namespace +{ + bool is_snow_or_ice_stage_for_ffb() + { + if (!Game::GetNowStageNum || !Game::GetStageUniqueNum) + return false; + + const int stageNumber = Game::GetNowStageNum(8); + const int uniqueStage = Game::GetStageUniqueNum(stageNumber); + return uniqueStage == 4 || uniqueStage == 19 || + uniqueStage == 34 || uniqueStage == 49; + } + + struct RoadSurfaceProfile + { + float minimum = 1.0f; + float maximum = 0.0f; + float spread = 0.0f; + int validSamples = 0; + }; + + RoadSurfaceProfile sample_surface_profile(EVWORK_CAR* car) + { + RoadSurfaceProfile result{}; + if (!car) + return result; + + DWORD waterFlag = 0; + for (int i = 0; i < 4; ++i) + { + const float roughness = static_cast(sub_1149C0( + car->water_flag_24C[i], + static_cast(car->OnRoadPlace_5C.loadColiType_0), + &waterFlag)); + if (!std::isfinite(roughness)) + continue; + + result.minimum = std::min(result.minimum, roughness); + result.maximum = std::max(result.maximum, roughness); + ++result.validSamples; + } + + if (result.validSamples == 0) + { + result.minimum = 0.0f; + return result; + } + + result.spread = std::max(0.0f, result.maximum - result.minimum); + return result; + } + + // v0.2 snow-curb state. On snow stages a curb can be rougher OR smoother + // than the ~0.50 snow baseline. Mixed contact tells us which material is the + // curb; once all four tyres cross onto that same material the old per-frame + // mixed test disappears. Retain the material identity until the car returns + // to the snow baseline instead of lowering the global roughness threshold. + constexpr float SnowSurfaceBaseline = 0.50f; + constexpr float SnowLatchedCoreRoughnessFloor = 0.85f; + constexpr DWORD SnowCurbHoldMs = 450; + bool snowCurbLatched = false; + float snowCurbMaterial = 0.0f; + DWORD snowCurbHoldUntil = 0; + + void clear_snow_curb_latch() + { + snowCurbLatched = false; + snowCurbMaterial = 0.0f; + snowCurbHoldUntil = 0; + } + + bool update_snow_curb_latch( + const RoadSurfaceProfile& surface, + bool snowStage, + bool mixedSurface, + DWORD now) + { + if (!snowStage || surface.validSamples < 2) + { + clear_snow_curb_latch(); + return false; + } + + // A snow-curb latch may only start from a transition that still contains + // the real ~0.50 snow baseline. This prevents post-stage asphalt values + // such as 0.25/0.35 from being reclassified as a new snow curb. + const bool minimumIsSnowBaseline = + std::abs(surface.minimum - SnowSurfaceBaseline) <= 0.06f; + const bool maximumIsSnowBaseline = + std::abs(surface.maximum - SnowSurfaceBaseline) <= 0.06f; + const bool mixedTouchesSnowBaseline = + mixedSurface && (minimumIsSnowBaseline || maximumIsSnowBaseline); + + if (mixedTouchesSnowBaseline) + { + float candidate = surface.minimum; + if (minimumIsSnowBaseline && !maximumIsSnowBaseline) + candidate = surface.maximum; + else if (maximumIsSnowBaseline && !minimumIsSnowBaseline) + candidate = surface.minimum; + else + { + const float minDistance = + std::abs(surface.minimum - SnowSurfaceBaseline); + const float maxDistance = + std::abs(surface.maximum - SnowSurfaceBaseline); + candidate = minDistance >= maxDistance + ? surface.minimum + : surface.maximum; + } + + // Only a meaningful departure from snow starts/re-arms the one-shot + // hold. The hold deadline is never extended by uniform curb contact. + if (std::abs(candidate - SnowSurfaceBaseline) >= 0.08f) + { + snowCurbLatched = true; + snowCurbMaterial = candidate; + snowCurbHoldUntil = now + SnowCurbHoldMs; + } + } + + if (!snowCurbLatched) + return false; + + const float uniformValue = + (surface.minimum + surface.maximum) * 0.5f; + const bool nearlyUniform = surface.spread < 0.08f; + + // Returning to the normal ~0.50 snow surface ends the latch immediately. + if (nearlyUniform && + std::abs(uniformValue - SnowSurfaceBaseline) <= 0.05f) + { + clear_snow_curb_latch(); + return false; + } + + // A completed curb crossing is retained only for the fixed 450 ms grace + // period that began at the last confirmed snow<->curb mixed contact. + // Uniform curb/road contact must never refresh this deadline. + if (snowCurbHoldUntil != 0 && + static_cast(now - snowCurbHoldUntil) < 0) + return true; + + clear_snow_curb_latch(); + return false; + } + + bool nearly(float value, float expected) + { + return std::isfinite(value) && std::abs(value - expected) <= 0.0005f; + } + + enum class LegacyPreset + { + None, + Physics, + Natural, + }; + + LegacyPreset detect_legacy_preset() + { + const bool common = + nearly(static_cast(Settings::WheelFFBGlobalStrength), 0.70f) && + nearly(static_cast(Settings::WheelFFBSpringStrength), 0.65f) && + nearly(static_cast(Settings::WheelFFBSpringSaturation), 0.95f) && + nearly(static_cast(Settings::WheelFFBMechanicalTrail), 0.25f) && + nearly(static_cast(Settings::WheelFFBTrailResponseLead), 0.25f) && + nearly(static_cast(Settings::WheelFFBGripLoss), 0.65f) && + nearly(static_cast(Settings::WheelFFBReversalReleaseRate), 0.12f) && + nearly(static_cast(Settings::WheelFFBRoadTexture), 0.30f) && + nearly(static_cast(Settings::WheelFFBTireSlip), 0.20f) && + nearly(static_cast(Settings::WheelFFBWallImpact), 0.38f); + + if (!common) + return LegacyPreset::None; + + if (Settings::WheelFFBPhysicsSat && + nearly(static_cast(Settings::WheelFFBDamperStrength), 0.28f) && + nearly(static_cast(Settings::WheelFFBSteeringWeight), 1.45f) && + nearly(static_cast(Settings::WheelFFBWeightTransfer), 0.15f) && + nearly(static_cast(Settings::WheelFFBSlewRate), 0.040f)) + return LegacyPreset::Physics; + + if (!Settings::WheelFFBPhysicsSat && + nearly(static_cast(Settings::WheelFFBDamperStrength), 0.30f) && + nearly(static_cast(Settings::WheelFFBSteeringWeight), 1.75f) && + nearly(static_cast(Settings::WheelFFBWeightTransfer), 0.20f) && + nearly(static_cast(Settings::WheelFFBSlewRate), 0.045f)) + return LegacyPreset::Natural; + + return LegacyPreset::None; + } + + void apply_universal_physics_preset() + { + Settings::WheelFFBEnable = true; + Settings::WheelFFBPhysicsSat = true; + Settings::WheelFFBGlobalStrength = 0.70f; + Settings::WheelFFBSpringStrength = 0.22f; + Settings::WheelFFBSpringSaturation = 0.55f; + Settings::WheelFFBDamperStrength = 0.28f; + Settings::WheelFFBSteeringWeight = 1.60f; + Settings::WheelFFBMechanicalTrail = 0.30f; + Settings::WheelFFBTrailResponseLead = 0.40f; + Settings::WheelFFBGripLoss = 0.65f; + Settings::WheelFFBWeightTransfer = 0.15f; + Settings::WheelFFBSlewRate = 0.12f; + Settings::WheelFFBReversalReleaseRate = 0.30f; + Settings::WheelFFBRoadTexture = 0.60f; + Settings::WheelFFBTireSlip = 0.04f; + Settings::WheelFFBWallImpact = 0.38f; + Settings::WheelFFBGearShift = 0.60f; + Settings::WheelFFBEngineVibration = false; + Settings::WheelFFBEngineIdle = 0.20f; + Settings::WheelFFBUseHardwareSpring = true; + Settings::WheelFFBUseHardwareDamper = true; + Settings::WheelFFBUsePeriodicEffects = false; + Settings::WheelFFBInvertForce = true; + Settings::WheelFFBInvertSpring = false; + Settings::WheelFFBDebugLog = true; + Settings::VibrationMode = 0; + } + + void apply_universal_natural_preset() + { + Settings::WheelFFBEnable = true; + Settings::WheelFFBPhysicsSat = false; + Settings::WheelFFBGlobalStrength = 0.70f; + Settings::WheelFFBSpringStrength = 0.22f; + Settings::WheelFFBSpringSaturation = 0.55f; + Settings::WheelFFBDamperStrength = 0.30f; + Settings::WheelFFBSteeringWeight = 1.75f; + Settings::WheelFFBMechanicalTrail = 0.30f; + Settings::WheelFFBTrailResponseLead = 0.40f; + Settings::WheelFFBGripLoss = 0.65f; + Settings::WheelFFBWeightTransfer = 0.20f; + Settings::WheelFFBSlewRate = 0.12f; + Settings::WheelFFBReversalReleaseRate = 0.30f; + Settings::WheelFFBRoadTexture = 0.60f; + Settings::WheelFFBTireSlip = 0.04f; + Settings::WheelFFBWallImpact = 0.38f; + Settings::WheelFFBGearShift = 0.60f; + Settings::WheelFFBEngineVibration = false; + Settings::WheelFFBEngineIdle = 0.20f; + Settings::WheelFFBUseHardwareSpring = true; + Settings::WheelFFBUseHardwareDamper = true; + Settings::WheelFFBUsePeriodicEffects = false; + Settings::WheelFFBInvertForce = true; + Settings::WheelFFBInvertSpring = false; + Settings::WheelFFBDebugLog = true; + Settings::VibrationMode = 0; + } + + bool normalize_legacy_preset(bool persist) + { + const LegacyPreset preset = detect_legacy_preset(); + if (preset == LegacyPreset::None) + return false; + + if (preset == LegacyPreset::Physics) + apply_universal_physics_preset(); + else + apply_universal_natural_preset(); + + WheelFFB_ResetHeadroomStats(); + WheelFFB_RequestSettingsTransition(); + if (persist && !Settings::write(Module::UserIniPath)) + spdlog::warn("WheelFFB: universalized a legacy preset for this session but could not persist user.ini"); + + spdlog::info( + "WheelFFB: migrated legacy {} preset to the device-independent v0.2 force tune", + preset == LegacyPreset::Physics ? "Physics SAT" : "Natural SAT"); + return true; + } + + DWORD lastRoadCompatibilityLogTick = 0; +} + +// Road texture and snow/curb tactile handling are deliberately universal. +// Wheel model names do not select a force model. ConstantForce is the common +// road/slip transport so a driver claiming GUID_Sine support cannot silently +// produce a different feel from another wheel. Hardware Spring/Damper remain +// capability-driven inside the core and retain their software fallbacks. +// +// During a real surface transition, temporarily unload SAT/damping and normalize +// RoadTexture so the tactile signal remains audible under sustained corner load. +// Snow is special because its four-wheel baseline can itself be around 0.50; +// min/max spread plus a short material latch preserves a curb after all tyres +// complete the transition, regardless of whether the curb scalar is higher or +// lower than the snow scalar. +void __cdecl WheelFFB_UpdateAfterPhysics(EVWORK_CAR* car) +{ + // Old named profiles or old F11 presets can still restore the v0.1 values. + // Recognize only the exact known signatures so arbitrary user tuning remains + // untouched, then immediately bring those presets onto the universal tune. + normalize_legacy_preset(true); + + // One tactile transport for every wheel. Spring and Damper are still chosen + // by DirectInput capability probing; only road/slip sine is standardized. + if (Settings::WheelFFBUsePeriodicEffects) + Settings::WheelFFBUsePeriodicEffects = false; + + const float originalRoadTexture = + static_cast(Settings::WheelFFBRoadTexture); + const float originalSteeringWeight = + static_cast(Settings::WheelFFBSteeringWeight); + const float originalDamperStrength = + static_cast(Settings::WheelFFBDamperStrength); + + bool restoreTactileOverrides = false; + bool applyCoreSurfaceFloor = false; + + if (!car) + clear_snow_curb_latch(); + + if (car) + { + const RoadSurfaceProfile surface = sample_surface_profile(car); + const bool rawMixedSurface = + surface.validSamples >= 2 && surface.spread >= 0.08f; + const bool genuinelyRough = surface.maximum >= 0.60f; + // Ordinary route-fork/asphalt material changes (for example 0.25/0.35) + // are not tactile curbs. A non-snow mixed transition must include a + // genuinely rough material before the compatibility boost is allowed. + const bool mixedSurface = rawMixedSurface && genuinelyRough; + const bool fullyRough = + surface.validSamples >= 2 && surface.minimum >= 0.60f; + const DWORD now = GetTickCount(); + const bool snowStage = is_snow_or_ice_stage_for_ffb(); + const bool snowCurbHeld = update_snow_curb_latch( + surface, snowStage, rawMixedSurface, now); + const bool strongTactile = mixedSurface || fullyRough || snowCurbHeld; + const bool tactileSurface = genuinelyRough || snowCurbHeld; + + if (tactileSurface) + { + const float speedRaw = std::isfinite(car->field_1C4) + ? car->field_1C4 : 0.0f; + const float speedNorm = std::clamp(speedRaw / 2.0f, 0.0f, 1.0f); + const float roadSpeedGate = + std::clamp((speedNorm - 0.05f) / 0.20f, 0.0f, 1.0f); + + // A latched snow curb is already a positively identified surface + // transition. Treat it as full texture in the compatibility envelope + // even when its LUT scalar is <=0.30; the core receives the matching + // temporary roughness floor immediately before its update below. + const float textureRoughness = snowCurbHeld + ? 1.0f + : std::clamp((surface.maximum - 0.30f) / 0.55f, 0.0f, 1.0f); + const float outputStrength = std::clamp( + static_cast(Settings::WheelFFBGlobalStrength), 0.0f, 1.5f); + const float coreStageScale = snowStage ? 0.04f : 1.0f; + + const float desiredRoadAmp = strongTactile ? 0.30f : 0.22f; + const float envelope = + textureRoughness * roadSpeedGate * outputStrength * coreStageScale; + if (envelope > 0.0005f) + { + // This value is temporary for one physics tick and is restored + // immediately below. Values above the UI range are intentional: + // they compensate the core's snow attenuation and/or a small + // material scalar, while the resulting roadAmp stays bounded by + // desiredRoadAmp and the DirectInput output remains hard capped. + const float normalizedRoadSetting = desiredRoadAmp / envelope; + Settings::WheelFFBRoadTexture = std::clamp( + std::max(originalRoadTexture, normalizedRoadSetting), + 0.0f, 120.0f); + } + + // Only an already-identified snow curb gets the core surface floor. + // Normal snow and ordinary asphalt still use the game's exact LUT. + applyCoreSurfaceFloor = snowCurbHeld; + + // Keep exactly the same SAT/damper relief when the car completes the + // transition onto a fully rough or latched snow curb/shoulder. + const float steeringScale = strongTactile ? 0.72f : 0.80f; + const float damperScale = strongTactile ? 0.55f : 0.70f; + Settings::WheelFFBSteeringWeight = + originalSteeringWeight * steeringScale; + Settings::WheelFFBDamperStrength = + originalDamperStrength * damperScale; + restoreTactileOverrides = true; + + if (Settings::WheelFFBDebugLog && + now - lastRoadCompatibilityLogTick >= 750) + { + lastRoadCompatibilityLogTick = now; + spdlog::info( + "WheelFFB ROAD: min={:.2f} max={:.2f} spread={:.2f} mixed={} fullRough={} snow={} snowLatch={} latchMaterial={:.2f} coreFloor={} targetAmp={:.2f} roadSetting={:.2f} satScale={:.2f} damperScale={:.2f}", + surface.minimum, surface.maximum, surface.spread, + mixedSurface, fullyRough, snowStage, snowCurbHeld, + snowCurbMaterial, applyCoreSurfaceFloor, desiredRoadAmp, + static_cast(Settings::WheelFFBRoadTexture), + steeringScale, damperScale); + } + } + } + + coreSurfaceRoughnessFloorActive = applyCoreSurfaceFloor; + coreSurfaceRoughnessFloor = applyCoreSurfaceFloor + ? SnowLatchedCoreRoughnessFloor : 0.0f; + WheelFFB_UpdateAfterPhysics_Core(car); + coreSurfaceRoughnessFloorActive = false; + coreSurfaceRoughnessFloor = 0.0f; + + if (restoreTactileOverrides) + { + Settings::WheelFFBRoadTexture = originalRoadTexture; + Settings::WheelFFBSteeringWeight = originalSteeringWeight; + Settings::WheelFFBDamperStrength = originalDamperStrength; + } +} + +namespace +{ + class VibrationRoutingFix : public Hook + { + inline static SafetyHookInline SetVibrationHook = {}; + inline static bool controllerRumbleMayBeActive_ = false; + + static int safe_controller_id() + { + return std::clamp(VibrationUserId, 0, 3); + } + + static void stop_controller_rumble() + { + InputManager_StopVibration(); + XINPUT_VIBRATION zero{}; + XInputSetState(safe_controller_id(), &zero); + controllerRumbleMayBeActive_ = false; + } + + static void SetVibration_dest(int, float leftMotor, float rightMotor) + { + // XInput supports user indexes 0..3. The original gameplay wrapper + // passed literal 0 here, which ignored VibrationControllerId while + // UseNewInput was disabled. Always route the legacy call through the + // configured, validated controller id; SDL ignores this argument. + const int userId = safe_controller_id(); + + if (!Settings::VibrationMode) + { + if (controllerRumbleMayBeActive_) + stop_controller_rumble(); + return; + } + + controllerRumbleMayBeActive_ = true; + SetVibrationHook.ccall(userId, leftMotor, rightMotor); + } + + public: + std::string_view description() override + { + return "VibrationRoutingFix"; + } + + void declare_settings() override + { + // VibrationMode is a live setting. Turning it off must actively + // clear both SDL and XInput output even if the overlay/menu prevents + // another player-car physics callback from reaching SetVibration. + Settings::VibrationMode.watch([] + { + if (!Settings::VibrationMode) + stop_controller_rumble(); + }); + } + + bool apply() override + { + const int configuredId = int(Settings::VibrationControllerId); + const int safeId = std::clamp(configuredId, 0, 3); + if (configuredId != safeId) + { + Settings::VibrationControllerId = safeId; + spdlog::warn( + "VibrationControllerId={} is outside XInput's 0..3 range; clamped to {}", + configuredId, safeId); + } + VibrationUserId = safeId; + + SetVibrationHook = safetyhook::create_inline( + reinterpret_cast(&SetVibration), SetVibration_dest); + return !!SetVibrationHook; + } + + static VibrationRoutingFix instance; + }; + + VibrationRoutingFix VibrationRoutingFix::instance; + + class WheelFFBFeelRetune : public Hook + { + public: + std::string_view description() override + { + return "WheelFFBFeelRetune"; + } + + void declare_settings() override + { + Settings::WheelFFBFeelRevision.hidden(true); + } + + bool apply() override + { + int revision = int(Settings::WheelFFBFeelRevision); + bool changed = false; + + if (revision < 1) + { + // Baseline feel is universal. Preserve SAT/trail gains, reduce + // the low-speed centre spring, make real surface roughness easier + // to feel, suppress normal-cornering scrub buzz, and retain an + // unmistakable but short gear-change thunk. + Settings::WheelFFBSpringStrength = 0.22f; + Settings::WheelFFBSpringSaturation = 0.55f; + Settings::WheelFFBRoadTexture = 0.60f; + Settings::WheelFFBTireSlip = 0.04f; + Settings::WheelFFBGearShift = 0.60f; + Settings::WheelFFBFeelRevision = 1; + revision = 1; + changed = true; + } + + if (revision < 2) + { + // Road/slip tactile transport is standardized across wheel + // models. Hardware Spring/Damper remain capability-driven. + Settings::WheelFFBUsePeriodicEffects = false; + Settings::WheelFFBFeelRevision = 2; + revision = 2; + changed = true; + } + + if (revision < 3) + { + // Input-device matching is separate from force tuning. Keep the + // old R3 clean-install compatibility here without changing the + // SAT/road/damper model selected for any wheel. + const std::string configured = + lower_copy(Settings::WheelFFBDeviceName.get().c_str()); + if (configured == "moza") + Settings::WheelFFBDeviceName = "R3 Racing Wheel"; + + Settings::WheelFFBFeelRevision = 3; + revision = 3; + changed = true; + } + + if (revision < 4) + { + // v0.2 response retune for every wheel. Only values still equal + // to v0.1 defaults are migrated; manual tuning is kept. + const auto migrate_default = [](auto& setting, float oldValue, float newValue) + { + const float current = static_cast(setting); + if (std::isfinite(current) && + std::abs(current - oldValue) <= 0.0005f) + setting = newValue; + }; + + migrate_default(Settings::WheelFFBSlewRate, 0.06f, 0.12f); + migrate_default(Settings::WheelFFBReversalReleaseRate, 0.12f, 0.30f); + migrate_default(Settings::WheelFFBTrailResponseLead, 0.25f, 0.40f); + migrate_default(Settings::WheelFFBSteeringWeight, 1.45f, 1.60f); + migrate_default(Settings::WheelFFBMechanicalTrail, 0.25f, 0.30f); + + Settings::WheelFFBFeelRevision = 4; + revision = 4; + changed = true; + } + + if (revision < 5) + { + // Old F11 presets used a different response envelope and could + // undo v0.2 tuning. Migrate only their exact signatures; all + // other manual values remain untouched. + normalize_legacy_preset(false); + Settings::WheelFFBUsePeriodicEffects = false; + Settings::WheelFFBFeelRevision = 5; + revision = 5; + changed = true; + } + + if (!changed) + return true; + + WheelFFB_ResetHeadroomStats(); + WheelFFB_RequestSettingsTransition(); + + if (!Settings::write(Module::UserIniPath)) + { + spdlog::warn( + "WheelFFBFeelRetune: applied revision {} for this session but could not persist user.ini", + revision); + } + else if (revision >= 5) + { + spdlog::info( + "WheelFFBFeelRetune: applied revision 5 (device-independent v0.2 SAT/tactile defaults and legacy-preset migration)"); + } + else if (revision >= 4) + { + spdlog::info( + "WheelFFBFeelRetune: applied revision 4 (universal v0.2 SAT response: slew=0.12 reversal=0.30 trailLead=0.40 steeringWeight=1.60 mechanicalTrail=0.30 when still at v0.1 defaults)"); + } + else if (revision >= 3) + { + spdlog::info( + "WheelFFBFeelRetune: applied revision 3 (input-device compatibility migration; force tuning remains universal)"); + } + else if (revision >= 2) + { + spdlog::info( + "WheelFFBFeelRetune: applied revision 2 (spring=0.22 sat=0.55 road=0.60 tire-slip=0.04 gear=0.60, universal ConstantForce tactile path)"); + } + else + { + spdlog::info( + "WheelFFBFeelRetune: applied revision 1 (spring=0.22 sat=0.55 road=0.60 tire-slip=0.04 gear=0.60)"); + } + return true; + } + + static WheelFFBFeelRetune instance; + }; + + WheelFFBFeelRetune WheelFFBFeelRetune::instance; + + class WheelSelectorDPadAlias : public Hook + { + inline static SafetyHookInline InputUpdateHook = {}; + + static void InputManager_Update_dest() + { + InputUpdateHook.ccall(); + + if (!Settings::UseNewInput || !Game::current_mode || + *Game::current_mode == STATE_GAME) + return; + + auto& manager = InputManager::instance; + const bool left = manager.actionFor( + InputManager::ActionKind::Switch, + int(SwitchId::SelectionLeft)).getState().isPressed(); + const bool right = manager.actionFor( + InputManager::ActionKind::Switch, + int(SwitchId::SelectionRight)).getState().isPressed(); + + if (left == right) + return; + + auto& steeringAction = manager.actionFor( + InputManager::ActionKind::Volume, + int(ADChannel::Steering)); + InputState steering = steeringAction.getState(); + + // Real wheel motion wins. The alias exists only for front-end + // selectors (car/BGM/etc.) which incorrectly ask the analogue + // steering channel instead of SelectionLeft/SelectionRight. + if (std::abs(steering.currentValue) >= 0.20f) + return; + + steering.previousValue = steering.currentValue; + steering.currentValue = left ? -1.0f : 1.0f; + steering.isAxis = false; + steering.lastSourceType = InputSourceType::GamePad; + steeringAction.setState(steering); + } + + public: + std::string_view description() override + { + return "WheelSelectorDPadAlias"; + } + + bool apply() override + { + InputUpdateHook = safetyhook::create_inline( + reinterpret_cast(&InputManager_Update), InputManager_Update_dest); + return !!InputUpdateHook; + } + + static WheelSelectorDPadAlias instance; + }; + + WheelSelectorDPadAlias WheelSelectorDPadAlias::instance; + + class WheelQuickSetupRemoval : public Hook + { + inline static SafetyHookInline ButtonHook = {}; + inline static SafetyHookInline SliderFloatHook = {}; + + static bool __cdecl Button_dest(const char* label, const ImVec2& size) + { + if (label && std::strcmp(label, "Quick Setup") == 0) + { + // Keep the following SameLine() in Input Bindings well-defined + // without leaving a clickable control or entering the separate + // Quick Setup modal/state machine. + ImGui::Dummy(ImVec2(0.0f, 0.0f)); + return false; + } + + // The old UI implementation names these presets after R3 and writes + // obsolete v0.1 values. Draw universal labels here and apply the + // device-independent v0.2 preset directly; returning false prevents + // the old caller block from overwriting the new values afterwards. + if (label && std::strcmp(label, "Load MOZA R3 Physics SAT") == 0) + { + const bool clicked = ButtonHook.ccall( + "Load Universal Physics SAT", &size); + if (clicked) + { + apply_universal_physics_preset(); + Settings::WheelFFBFeelRevision = 5; + WheelFFB_ResetHeadroomStats(); + WheelFFB_RequestSettingsTransition(); + if (!Settings::write(Module::UserIniPath)) + spdlog::warn("WheelFFB: Universal Physics SAT preset active but user.ini could not be saved"); + } + return false; + } + + if (label && std::strcmp(label, "Load MOZA R3 Natural SAT") == 0) + { + const bool clicked = ButtonHook.ccall( + "Load Universal Natural SAT", &size); + if (clicked) + { + apply_universal_natural_preset(); + Settings::WheelFFBFeelRevision = 5; + WheelFFB_ResetHeadroomStats(); + WheelFFB_RequestSettingsTransition(); + if (!Settings::write(Module::UserIniPath)) + spdlog::warn("WheelFFB: Universal Natural SAT preset active but user.ini could not be saved"); + } + return false; + } + + // MSVC x86 passes a C++ reference as its underlying pointer. Passing + // &size preserves ImGui::Button(const char*, const ImVec2&) exactly + // through SafetyHook's cdecl trampoline. + return ButtonHook.ccall(label, &size); + } + + static bool __cdecl SliderFloat_dest( + const char* label, + float* value, + float minimum, + float maximum, + const char* format, + ImGuiSliderFlags flags) + { + // RoadTexture has a real setting range of 0..1.0 and the universal + // default is 0.60. The old F11 slider stopped at 0.50, which could + // silently clamp the migrated value simply by touching the control. + if (label && std::strcmp(label, "Road Detail") == 0) + maximum = std::max(maximum, 1.0f); + + return SliderFloatHook.ccall( + label, value, minimum, maximum, format, flags); + } + + public: + std::string_view description() override + { + return "WheelQuickSetupRemoval"; + } + + bool apply() override + { + ButtonHook = safetyhook::create_inline( + reinterpret_cast(&ImGui::Button), Button_dest); + SliderFloatHook = safetyhook::create_inline( + reinterpret_cast(&ImGui::SliderFloat), SliderFloat_dest); + return !!ButtonHook && !!SliderFloatHook; + } + + static WheelQuickSetupRemoval instance; + }; + + WheelQuickSetupRemoval WheelQuickSetupRemoval::instance; +} diff --git a/src/hooks_wheel_input_compat_v2.hpp b/src/hooks_wheel_input_compat_v2.hpp new file mode 100644 index 000000000..ea78f7b98 --- /dev/null +++ b/src/hooks_wheel_input_compat_v2.hpp @@ -0,0 +1,366 @@ +#pragma once + +#include +#include +#include +#include + +#include "hook_mgr.hpp" +#include "plugin.hpp" +#include "game_addrs.hpp" + +// Second-stage compatibility fixes for legacy DirectInput steering wheels. +// +// The original game's legacy input path can expose a one-sided pedal axis as a +// centred/shared accelerator+brake axis. In that case the first half of a MOZA +// throttle is seen as acceleration, while the second half starts producing a +// brake channel too. f365 corrected the accelerator direction, but did so after +// the game had already split that raw axis. Keep that direction correction and +// suppress only the synthetic brake half while acceleration is active. +// +// Menu filtering is deliberately done after ReadIO as well as at SwitchNow / +// SwitchOn. DInputUpdate is too early: ReadIO can rebuild the raw state after a +// filter placed there. The logical switch filter also covers menus that do not +// consult SumoDInputState directly. +namespace Settings +{ + extern Setting WheelMenuDirectionFilter; // hooks_input.cpp + extern Setting UseNewInput; + extern Setting WheelInputCompatibility; + extern Setting WheelUniversalSetupEnable; + + Setting WheelPedalSplitFix{ + "Controls", "WheelPedalSplitFix", false, + "Suppresses the synthetic brake half produced when a legacy DirectInput wheel exposes one pedal as a centred/shared axis." + }; + + Setting WheelPedalSplitThreshold{ + "Controls", "WheelPedalSplitThreshold", 32, + "Acceleration amount (0-255) required before WheelPedalSplitFix suppresses simultaneous brake cross-talk.", + Range{ 1, 254 } + }; + + Setting WheelMenuBackAlias{ + "Controls", "WheelMenuBackAlias", true, + "In menus, treats the legacy Back action and B/Return action as aliases so either configured wheel button can leave a screen." + }; +} + +namespace +{ + class WheelInputCompatibilityV2 : public Hook + { + struct Direction + { + uint32_t rawMask; + uint32_t switchMask; + int virtualKey; + const char* name; + }; + + inline static constexpr std::array Directions = {{ + { 0x00000040u, 1u << int(SwitchId::SelectionUp), VK_UP, "up" }, + { 0x00000020u, 1u << int(SwitchId::SelectionDown), VK_DOWN, "down" }, + { 0x00000100u, 1u << int(SwitchId::SelectionLeft), VK_LEFT, "left" }, + { 0x00000080u, 1u << int(SwitchId::SelectionRight), VK_RIGHT, "right" }, + }}; + + inline static constexpr uint32_t DirectionRawMask = 0x000001E0u; + inline static constexpr uint32_t BackSwitchMask = 1u << int(SwitchId::Back); + inline static constexpr uint32_t BSwitchMask = 1u << int(SwitchId::B); + inline static constexpr auto LearnDelay = std::chrono::milliseconds(250); + + inline static SafetyHookInline GetVolumeHook = {}; + inline static SafetyHookInline GetVolumeOldHook = {}; + inline static SafetyHookInline ReadIOHook = {}; + inline static SafetyHookInline SwitchNowHook = {}; + inline static SafetyHookInline SwitchOnHook = {}; + + inline static std::array rawHeldSince = {}; + inline static std::array logicalHeldSince = {}; + inline static uint32_t learnedDirectionRawMask = 0; + inline static uint32_t previousFilteredDirections = 0; + inline static DWORD lastPedalSplitLogTick = 0; + inline static bool backAliasLogged = false; + + static bool active() + { + return Settings::WheelInputCompatibility && + !Settings::UseNewInput && + !Settings::WheelUniversalSetupEnable; + } + + static bool mayLearnMenuDirection() + { + return active() && Game::current_mode && *Game::current_mode != STATE_GAME; + } + + static bool keyboardHeld(const Direction& direction) + { + return (GetAsyncKeyState(direction.virtualKey) & 0x8000) != 0; + } + + static void learnRawDirections(SumoDInputState* state) + { + if (!state || !mayLearnMenuDirection()) + return; + + const auto now = std::chrono::steady_clock::now(); + for (size_t i = 0; i < Directions.size(); ++i) + { + const auto& direction = Directions[i]; + if (learnedDirectionRawMask & direction.rawMask) + continue; + + const bool held = (state->buttons_4 & direction.rawMask) != 0; + if (held && !keyboardHeld(direction)) + { + if (rawHeldSince[i] == std::chrono::steady_clock::time_point{}) + rawHeldSince[i] = now; + else if (now - rawHeldSince[i] >= LearnDelay) + { + learnedDirectionRawMask |= direction.rawMask; + spdlog::info( + "WheelMenuDirectionFilterV2: suppressing continuously-held '{}' raw input (mask 0x{:X}) after ReadIO", + direction.name, direction.rawMask); + } + } + else + { + rawHeldSince[i] = {}; + } + } + } + + static void filterRawDirections(SumoDInputState* state) + { + if (!state || !active() || !Settings::WheelMenuDirectionFilter) + return; + + learnRawDirections(state); + + uint32_t keyboardDirections = 0; + for (const auto& direction : Directions) + if (keyboardHeld(direction)) + keyboardDirections |= direction.rawMask; + + const uint32_t rawDirections = state->buttons_4 & DirectionRawMask; + const uint32_t outputDirections = + (rawDirections & ~learnedDirectionRawMask) | keyboardDirections; + + state->buttons_4 = (state->buttons_4 & ~DirectionRawMask) | outputDirections; + state->pressed_8 = (state->pressed_8 & ~DirectionRawMask) | + (outputDirections & ~previousFilteredDirections); + state->released_C = (state->released_C & ~DirectionRawMask) | + (previousFilteredDirections & ~outputDirections); + previousFilteredDirections = outputDirections; + } + + static void learnLogicalDirection(uint32_t switches, bool held) + { + if (!mayLearnMenuDirection() || !Settings::WheelMenuDirectionFilter) + return; + + const auto now = std::chrono::steady_clock::now(); + for (size_t i = 0; i < Directions.size(); ++i) + { + const auto& direction = Directions[i]; + if ((switches & direction.switchMask) == 0 || + (learnedDirectionRawMask & direction.rawMask)) + continue; + + if (held && !keyboardHeld(direction)) + { + if (logicalHeldSince[i] == std::chrono::steady_clock::time_point{}) + logicalHeldSince[i] = now; + else if (now - logicalHeldSince[i] >= LearnDelay) + { + learnedDirectionRawMask |= direction.rawMask; + spdlog::info( + "WheelMenuDirectionFilterV2: suppressing continuously-held '{}' logical switch (mask 0x{:X})", + direction.name, direction.switchMask); + } + } + else + { + logicalHeldSince[i] = {}; + } + } + } + + static bool shouldSuppressLogicalDirection(uint32_t switches) + { + if (!active() || !Settings::WheelMenuDirectionFilter) + return false; + + for (const auto& direction : Directions) + { + if ((learnedDirectionRawMask & direction.rawMask) && + (switches & direction.switchMask) && + !keyboardHeld(direction)) + { + return true; + } + } + return false; + } + + static int filterPedal(int channel, int result, bool oldValue) + { + if (!active() || !Settings::WheelPedalSplitFix || channel != int(ADChannel::Brake) || result <= 0) + return result; + + // Call our trampoline for the acceleration channel. The trampoline + // still passes through the earlier f365 direction correction, so + // this value is already 0 at rest and rises with pedal travel. + const int acceleration = oldValue + ? GetVolumeOldHook.call(int(ADChannel::Acceleration)) + : GetVolumeHook.call(int(ADChannel::Acceleration)); + + const int threshold = std::clamp(int(Settings::WheelPedalSplitThreshold), 1, 254); + if (acceleration < threshold) + return result; + + const DWORD now = GetTickCount(); + if (now - lastPedalSplitLogTick >= 2000) + { + lastPedalSplitLogTick = now; + spdlog::info( + "WheelPedalSplitFix: suppressing synthetic brake while accelerator is active (accel={}, brake={})", + acceleration, result); + } + return 0; + } + + static int GetVolume_dest(int channel) + { + const int result = GetVolumeHook.call(channel); + return filterPedal(channel, result, false); + } + + static int GetVolumeOld_dest(int channel) + { + const int result = GetVolumeOldHook.call(channel); + return filterPedal(channel, result, true); + } + + static int ReadIO_dest() + { + const int result = ReadIOHook.ccall(); + if (active() && Settings::WheelMenuDirectionFilter) + filterRawDirections(Game::dinput_state); + return result; + } + + static int SwitchNow_dest(uint32_t switches) + { + int result = SwitchNowHook.ccall(switches); + + if (!active()) + return result; + + if (Settings::WheelMenuDirectionFilter) + { + learnLogicalDirection(switches, result != 0); + if (result && shouldSuppressLogicalDirection(switches)) + result = 0; + } + + if (!result && Settings::WheelMenuBackAlias && + Game::current_mode && *Game::current_mode != STATE_GAME) + { + if (switches == BSwitchMask) + result = SwitchNowHook.ccall(BackSwitchMask); + else if (switches == BackSwitchMask) + result = SwitchNowHook.ccall(BSwitchMask); + + if (result && !backAliasLogged) + { + backAliasLogged = true; + spdlog::info("WheelMenuBackAlias: treating legacy Back and B/Return as equivalent in menus"); + } + } + + return result; + } + + static int SwitchOn_dest(uint32_t switches) + { + int result = SwitchOnHook.ccall(switches); + + if (!active()) + return result; + + if (result && shouldSuppressLogicalDirection(switches)) + result = 0; + + if (!result && Settings::WheelMenuBackAlias && + Game::current_mode && *Game::current_mode != STATE_GAME) + { + if (switches == BSwitchMask) + result = SwitchOnHook.ccall(BackSwitchMask); + else if (switches == BackSwitchMask) + result = SwitchOnHook.ccall(BSwitchMask); + + if (result && !backAliasLogged) + { + backAliasLogged = true; + spdlog::info("WheelMenuBackAlias: treating legacy Back and B/Return as equivalent in menus"); + } + } + + return result; + } + + public: + std::string_view description() override + { + return "WheelInputCompatibilityV2"; + } + + bool validate() override + { + // Install with the legacy stack so F11 can switch the universal + // profile live. active() remains the runtime ownership gate. + return Settings::WheelInputCompatibility && !Settings::UseNewInput; + } + + void declare_settings() override + { + Settings::WheelPedalSplitFix.needs_restart(); + Settings::WheelMenuBackAlias.needs_restart(); + Settings::WheelPedalSplitFix.hidden(Settings::UseNewInput); + Settings::WheelPedalSplitThreshold.hidden(Settings::UseNewInput); + Settings::WheelMenuBackAlias.hidden(Settings::UseNewInput); + } + + bool apply() override + { + // These addresses are the same legacy entry points already used by + // FixFullPedalChecks/NewInputHook. SafetyHook chains inline hooks; + // in compatibility mode NewInputHook is disabled, while the pedal + // fix intentionally sits after FixFullPedalChecks. + GetVolumeHook = safetyhook::create_inline(Module::exe_ptr(0x53720), GetVolume_dest); + GetVolumeOldHook = safetyhook::create_inline(Module::exe_ptr(0x53750), GetVolumeOld_dest); + ReadIOHook = safetyhook::create_inline(Module::exe_ptr(0x53BB0), ReadIO_dest); + SwitchNowHook = safetyhook::create_inline(Module::exe_ptr(0x536C0), SwitchNow_dest); + SwitchOnHook = safetyhook::create_inline(Module::exe_ptr(0x536F0), SwitchOn_dest); + + const bool ok = !!GetVolumeHook && !!GetVolumeOldHook && !!ReadIOHook && !!SwitchNowHook && !!SwitchOnHook; + if (ok) + { + spdlog::info( + "WheelInputCompatibilityV2: pedalSplitFix={}, threshold={}, postReadIO/menuSwitchFilter={}, backAlias={}", + bool(Settings::WheelPedalSplitFix), + int(Settings::WheelPedalSplitThreshold), + bool(Settings::WheelMenuDirectionFilter), + bool(Settings::WheelMenuBackAlias)); + } + return ok; + } + + static WheelInputCompatibilityV2 instance; + }; + + WheelInputCompatibilityV2 WheelInputCompatibilityV2::instance; +} diff --git a/src/hooks_wheel_legacy_blank_defaults.hpp b/src/hooks_wheel_legacy_blank_defaults.hpp new file mode 100644 index 000000000..1b7810280 --- /dev/null +++ b/src/hooks_wheel_legacy_blank_defaults.hpp @@ -0,0 +1,221 @@ +#pragma once + +#include +#include +#include + +#include "hook_mgr.hpp" +#include "plugin.hpp" +#include "game_addrs.hpp" + +// Legacy DirectInput controller configuration helper. +// +// OutRun 2006 ships generic joystick defaults that pre-assign several analogue +// channels. Modern wheels can therefore start with a pedal on the combined +// Accel/Brake channel and another one-sided axis on Menu Up/Down. The original +// controller screen already supports a true Unassigned state, so keep its own +// calibration UI and only change the defaults it starts from. +// +// Important: never clear a user's saved custom profile on every launch. When +// the controller screen opens we only clear a device if all of its mappings +// still match one of the game's untouched factory/default layouts. Pressing +// the screen's DEFAULT row intentionally clears the selected legacy joystick +// again, which gives the user a convenient manual-reset path. +namespace Settings +{ + Setting WheelLegacyBlankDefaults{ + "Controls", "WheelLegacyBlankDefaults", true, + "Starts untouched legacy wheel/controller profiles with all axes/buttons Unassigned; the DEFAULT row also clears all bindings for manual setup." + }; +} + +namespace +{ + class WheelLegacyBlankDefaults : public Hook + { + // Absolute game globals 0x8606D4 / 0x95AEC4 converted to module offsets. + inline static constexpr uintptr_t DeviceSlotsOffset = 0x4606D4; + inline static constexpr uintptr_t DeviceCountOffset = 0x55AEC4; + + // Original controller DEFAULT helper: OR2006C2C.exe + 0x4050. + inline static constexpr uintptr_t DefaultResetOffset = 0x4050; + + // Point in the legacy controller-screen setup after the selected-device + // list has been built, immediately before the rows are refreshed. + inline static constexpr uintptr_t ControllerScreenReadyOffset = 0xD7F93; + + inline static constexpr size_t AxisMapOffset = 0x54; + inline static constexpr size_t ButtonMapOffset = 0x70; + + // Generic DirectInput defaults created by the original wheel object. + inline static constexpr std::array FactoryAxes = { + 8, 8, 2, 0, 4, 1, 3 + }; + inline static constexpr std::array FactoryButtons = { + 0, 1, 2, 3, 4, 5, 10, 11, 12, 13, 14, 15, 7, 6, 8, 9 + }; + + // Values used internally by the original game for a truly unassigned + // legacy mapping. Axis setter 0x402610 accepts 8 as Unassigned, while + // button setter 0x402550 receives -1 when a row is cleared. + inline static constexpr int32_t AxisUnassigned = 8; + inline static constexpr int32_t ButtonUnassigned = -1; + + inline static SafetyHookInline DefaultResetHook = {}; + inline static SafetyHookMid ControllerScreenReadyHook = {}; + inline static bool loggedAutoBlank = false; + inline static bool loggedDefaultBlank = false; + + static bool active() + { + return Settings::WheelInputCompatibility && + !Settings::UseNewInput && + Settings::WheelLegacyBlankDefaults; + } + + static int deviceCount() + { + auto* count = Module::exe_ptr(DeviceCountOffset); + if (!count) + return 0; + return std::clamp(*count, 0, 4); + } + + static uint8_t* deviceAt(int index) + { + if (index < 0 || index >= deviceCount()) + return nullptr; + + auto* slots = Module::exe_ptr(DeviceSlotsOffset); + if (!slots) + return nullptr; + return reinterpret_cast(slots[index]); + } + + static bool allButtonsZero(const int32_t* buttons) + { + for (size_t i = 0; i < FactoryButtons.size(); ++i) + if (buttons[i] != 0) + return false; + return true; + } + + static bool looksLikeUntouchedDefault(uint8_t* device) + { + if (!device) + return false; + + const auto* axes = reinterpret_cast(device + AxisMapOffset); + const auto* buttons = reinterpret_cast(device + ButtonMapOffset); + + const bool factoryAxes = std::equal(FactoryAxes.begin(), FactoryAxes.end(), axes); + if (!factoryAxes) + return false; + + // The constructor uses FactoryButtons. The game's DEFAULT helper + // uses an all-zero 16-entry button table. Treat either exact state + // as untouched/default, but preserve anything else as user data. + const bool constructorButtons = + std::equal(FactoryButtons.begin(), FactoryButtons.end(), buttons); + return constructorButtons || allButtonsZero(buttons); + } + + static void blankDevice(uint8_t* device) + { + if (!device) + return; + + auto* axes = reinterpret_cast(device + AxisMapOffset); + auto* buttons = reinterpret_cast(device + ButtonMapOffset); + + std::fill_n(axes, 7, AxisUnassigned); + std::fill_n(buttons, 16, ButtonUnassigned); + } + + static void blankUntouchedDevices() + { + if (!active()) + return; + + const int count = deviceCount(); + // The final entry is treated specially by the original game (its + // keyboard/special controller path), so only touch regular legacy + // DirectInput joystick entries here. + for (int i = 0; i < count - 1; ++i) + { + auto* device = deviceAt(i); + if (!looksLikeUntouchedDefault(device)) + continue; + + blankDevice(device); + if (!loggedAutoBlank) + { + loggedAutoBlank = true; + spdlog::info( + "WheelLegacyBlankDefaults: untouched legacy controller mappings changed to Unassigned for manual setup"); + } + } + } + + static int DefaultReset_dest(int deviceIndex) + { + if (!active()) + return DefaultResetHook.ccall(deviceIndex); + + const int count = deviceCount(); + if (deviceIndex < 0 || deviceIndex >= count - 1) + return DefaultResetHook.ccall(deviceIndex); + + auto* device = deviceAt(deviceIndex); + if (!device) + return DefaultResetHook.ccall(deviceIndex); + + blankDevice(device); + if (!loggedDefaultBlank) + { + loggedDefaultBlank = true; + spdlog::info( + "WheelLegacyBlankDefaults: DEFAULT now clears legacy wheel axes/buttons to Unassigned"); + } + return 1; + } + + static void ControllerScreenReady_dest(SafetyHookContext&) + { + blankUntouchedDevices(); + } + + public: + std::string_view description() override + { + return "WheelLegacyBlankDefaults"; + } + + bool validate() override + { + return Settings::WheelInputCompatibility; + } + + void declare_settings() override + { + Settings::WheelLegacyBlankDefaults.needs_restart(); + } + + bool apply() override + { + DefaultResetHook = safetyhook::create_inline( + Module::exe_ptr(DefaultResetOffset), DefaultReset_dest); + ControllerScreenReadyHook = safetyhook::create_mid( + Module::exe_ptr(ControllerScreenReadyOffset), ControllerScreenReady_dest); + + const bool ok = !!DefaultResetHook && !!ControllerScreenReadyHook; + if (ok) + spdlog::info("WheelLegacyBlankDefaults: enabled"); + return ok; + } + + static WheelLegacyBlankDefaults instance; + }; + + WheelLegacyBlankDefaults WheelLegacyBlankDefaults::instance; +} diff --git a/src/hooks_wheel_menu_keyboard_back.hpp b/src/hooks_wheel_menu_keyboard_back.hpp new file mode 100644 index 000000000..ce4b9e9c8 --- /dev/null +++ b/src/hooks_wheel_menu_keyboard_back.hpp @@ -0,0 +1,146 @@ +#pragma once + +#include +#include + +#include "hook_mgr.hpp" +#include "plugin.hpp" +#include "game_addrs.hpp" + +// Legacy-wheel menu escape helper. +// The original game reuses the button bound to Gear Down as Back in many menus, +// including the legacy controller configuration screen. Some screens also query +// Back/B directly. Inject ESC at both the raw-state and logical-switch layers so +// a fully-unassigned wheel profile can always leave the configuration screen. +namespace Settings +{ + Setting WheelMenuKeyboardEscapeBack{ + "Controls", "WheelMenuKeyboardEscapeBack", true, + "In legacy wheel mode, maps keyboard Escape to the game's Back/B/GearDown menu actions." + }; +} + +namespace +{ + class WheelMenuKeyboardBack : public Hook + { + inline static constexpr uint32_t RawBackMask = 0x00000200u; + inline static constexpr uint32_t RawBMask = 0x00000004u; + inline static constexpr uint32_t RawEscapeMask = RawBackMask | RawBMask; + inline static constexpr uint32_t BackSwitchMask = 1u << int(SwitchId::Back); + inline static constexpr uint32_t BSwitchMask = 1u << int(SwitchId::B); + inline static constexpr uint32_t GearDownSwitchMask = 1u << int(SwitchId::GearDown); + + inline static SafetyHookInline ReadIOHook = {}; + inline static SafetyHookInline SwitchNowHook = {}; + inline static SafetyHookInline SwitchOnHook = {}; + + inline static bool escapeCurrent = false; + inline static bool escapePrevious = false; + inline static bool logged = false; + + static bool activeInMenu() + { + return Settings::WheelInputCompatibility && + !Settings::UseNewInput && + Settings::WheelMenuKeyboardEscapeBack && + Game::current_mode && + *Game::current_mode != STATE_GAME; + } + + static bool escapeHeldNow() + { + return (GetAsyncKeyState(VK_ESCAPE) & 0x8000) != 0; + } + + static bool isBackQuery(uint32_t switches) + { + return switches == BackSwitchMask || + switches == BSwitchMask || + switches == GearDownSwitchMask; + } + + static int ReadIO_dest() + { + const int result = ReadIOHook.ccall(); + + escapePrevious = escapeCurrent; + escapeCurrent = activeInMenu() && escapeHeldNow(); + + if (!activeInMenu() || !Game::dinput_state) + return result; + + auto* state = Game::dinput_state; + if (escapeCurrent) + state->buttons_4 |= RawEscapeMask; + + if (escapeCurrent && !escapePrevious) + state->pressed_8 |= RawEscapeMask; + else if (!escapeCurrent && escapePrevious) + state->released_C |= RawEscapeMask; + + return result; + } + + static int SwitchNow_dest(uint32_t switches) + { + const int result = SwitchNowHook.ccall(switches); + if (result) + return result; + + if (activeInMenu() && isBackQuery(switches) && escapeHeldNow()) + { + if (!logged) + { + logged = true; + spdlog::info("WheelMenuKeyboardEscapeBack: ESC mapped to legacy Back/B/GearDown menu action"); + } + return 1; + } + return 0; + } + + static int SwitchOn_dest(uint32_t switches) + { + const int result = SwitchOnHook.ccall(switches); + if (result) + return result; + + if (activeInMenu() && isBackQuery(switches) && escapeCurrent && !escapePrevious) + return 1; + return 0; + } + + public: + std::string_view description() override + { + return "WheelMenuKeyboardEscapeBack"; + } + + bool validate() override + { + return Settings::WheelInputCompatibility; + } + + void declare_settings() override + { + Settings::WheelMenuKeyboardEscapeBack.needs_restart(); + } + + bool apply() override + { + ReadIOHook = safetyhook::create_inline(Module::exe_ptr(0x53BB0), ReadIO_dest); + SwitchNowHook = safetyhook::create_inline(Module::exe_ptr(0x536C0), SwitchNow_dest); + SwitchOnHook = safetyhook::create_inline(Module::exe_ptr(0x536F0), SwitchOn_dest); + + const bool ok = !!ReadIOHook && !!SwitchNowHook && !!SwitchOnHook; + if (ok) + spdlog::info("WheelMenuKeyboardEscapeBack: enabled (Back/B/GearDown)"); + return ok; + } + + static WheelMenuKeyboardBack instance; + }; + + WheelMenuKeyboardBack WheelMenuKeyboardBack::instance; +} diff --git a/src/hooks_wheel_menu_keyboard_select.hpp b/src/hooks_wheel_menu_keyboard_select.hpp new file mode 100644 index 000000000..cbb1271f3 --- /dev/null +++ b/src/hooks_wheel_menu_keyboard_select.hpp @@ -0,0 +1,141 @@ +#pragma once + +#include +#include + +#include "hook_mgr.hpp" +#include "plugin.hpp" +#include "game_addrs.hpp" + +// Legacy-wheel menu Select helper. +// The original game reuses the button bound to Gear Up as Accept/Select in many +// menus, including the legacy controller configuration screen. Some screens also +// query A directly. Keep Enter available independently of the user's wheel/button +// bindings so a completely-unassigned controller profile can still be calibrated. +namespace Settings +{ + Setting WheelMenuKeyboardEnterSelect{ + "Controls", "WheelMenuKeyboardEnterSelect", true, + "In legacy wheel mode, maps keyboard Enter to the game's A/GearUp menu actions." + }; +} + +namespace +{ + class WheelMenuKeyboardSelect : public Hook + { + inline static constexpr uint32_t RawAMask = 0x00000002u; + inline static constexpr uint32_t ASwitchMask = 1u << int(SwitchId::A); + inline static constexpr uint32_t GearUpSwitchMask = 1u << int(SwitchId::GearUp); + + inline static SafetyHookInline ReadIOHook = {}; + inline static SafetyHookInline SwitchNowHook = {}; + inline static SafetyHookInline SwitchOnHook = {}; + + inline static bool enterCurrent = false; + inline static bool enterPrevious = false; + inline static bool logged = false; + + static bool activeInMenu() + { + return Settings::WheelInputCompatibility && + !Settings::UseNewInput && + Settings::WheelMenuKeyboardEnterSelect && + Game::current_mode && + *Game::current_mode != STATE_GAME; + } + + static bool enterHeldNow() + { + return (GetAsyncKeyState(VK_RETURN) & 0x8000) != 0; + } + + static bool isSelectQuery(uint32_t switches) + { + return switches == ASwitchMask || switches == GearUpSwitchMask; + } + + static int ReadIO_dest() + { + const int result = ReadIOHook.ccall(); + + enterPrevious = enterCurrent; + enterCurrent = activeInMenu() && enterHeldNow(); + + if (!activeInMenu() || !Game::dinput_state) + return result; + + auto* state = Game::dinput_state; + if (enterCurrent) + state->buttons_4 |= RawAMask; + + if (enterCurrent && !enterPrevious) + state->pressed_8 |= RawAMask; + else if (!enterCurrent && enterPrevious) + state->released_C |= RawAMask; + + return result; + } + + static int SwitchNow_dest(uint32_t switches) + { + const int result = SwitchNowHook.ccall(switches); + if (result) + return result; + + if (activeInMenu() && isSelectQuery(switches) && enterHeldNow()) + { + if (!logged) + { + logged = true; + spdlog::info("WheelMenuKeyboardEnterSelect: Enter mapped to legacy A/GearUp menu action"); + } + return 1; + } + return 0; + } + + static int SwitchOn_dest(uint32_t switches) + { + const int result = SwitchOnHook.ccall(switches); + if (result) + return result; + + if (activeInMenu() && isSelectQuery(switches) && enterCurrent && !enterPrevious) + return 1; + return 0; + } + + public: + std::string_view description() override + { + return "WheelMenuKeyboardEnterSelect"; + } + + bool validate() override + { + return Settings::WheelInputCompatibility; + } + + void declare_settings() override + { + Settings::WheelMenuKeyboardEnterSelect.needs_restart(); + } + + bool apply() override + { + ReadIOHook = safetyhook::create_inline(Module::exe_ptr(0x53BB0), ReadIO_dest); + SwitchNowHook = safetyhook::create_inline(Module::exe_ptr(0x536C0), SwitchNow_dest); + SwitchOnHook = safetyhook::create_inline(Module::exe_ptr(0x536F0), SwitchOn_dest); + + const bool ok = !!ReadIOHook && !!SwitchNowHook && !!SwitchOnHook; + if (ok) + spdlog::info("WheelMenuKeyboardEnterSelect: enabled (A/GearUp)"); + return ok; + } + + static WheelMenuKeyboardSelect instance; + }; + + WheelMenuKeyboardSelect WheelMenuKeyboardSelect::instance; +} diff --git a/src/hooks_wheel_r3_device_autoselect.hpp b/src/hooks_wheel_r3_device_autoselect.hpp new file mode 100644 index 000000000..63ed8afa5 --- /dev/null +++ b/src/hooks_wheel_r3_device_autoselect.hpp @@ -0,0 +1,84 @@ +#pragma once + +#include +#include +#include + +#include "hook_mgr.hpp" +#include "plugin.hpp" + +// Legacy builds shipped an FFB device-name default of "MOZA" and later migrated +// that exact default to "R3 Racing Wheel". That was useful while R3 was the only +// tested wheel, but it can prevent every other DirectInput FFB wheel from being +// selected on a clean install. +// +// Device-specific menu compatibility remains separate. For FFB output, an +// unpinned legacy default is now relaxed to an empty name filter so the engine +// chooses the first non-virtual FORCEFEEDBACK device. Once F11 saves an exact +// DeviceGuid, explicit routing is preserved and never redirected here. +namespace Settings +{ + extern Setting WheelMenuR3DeviceName; + extern Setting WheelFFBDeviceName; + extern Setting WheelFFBDeviceGuid; +} + +namespace +{ + static std::string r3_autoselect_lower(std::string value) + { + std::transform(value.begin(), value.end(), value.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return value; + } + + class WheelR3DeviceAutoSelect : public Hook + { + public: + std::string_view description() override + { + return "WheelR3DeviceAutoSelect"; + } + + bool validate() override + { + // FFB output selection is independent of the gameplay input backend. + // The old UseNewInput/WheelInputCompatibility gate could leave a + // model-specific R3 default active for otherwise supported wheels. + return true; + } + + bool apply() override + { + bool changed = false; + + // Keep the menu helper's legacy R3 matching behavior. This affects + // only front-end input compatibility, never FFB force generation. + if (r3_autoselect_lower(Settings::WheelMenuR3DeviceName.get()) == "moza") + { + // Strict-first/fallback selection remains in the menu reader. + } + + const std::string ffbName = + r3_autoselect_lower(Settings::WheelFFBDeviceName.get()); + if (Settings::WheelFFBDeviceGuid.get().empty() && + (ffbName == "moza" || ffbName == "r3 racing wheel")) + { + Settings::WheelFFBDeviceName = ""; + changed = true; + } + + if (changed) + { + spdlog::info( + "WheelFFB device auto-select: cleared legacy MOZA/R3-only default; using first non-virtual FFB device until an exact GUID is saved"); + } + + return true; + } + + static WheelR3DeviceAutoSelect instance; + }; + + WheelR3DeviceAutoSelect WheelR3DeviceAutoSelect::instance; +} diff --git a/src/hooks_wheel_r3_menu_ab.hpp b/src/hooks_wheel_r3_menu_ab.hpp new file mode 100644 index 000000000..9a2dda0b4 --- /dev/null +++ b/src/hooks_wheel_r3_menu_ab.hpp @@ -0,0 +1,454 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include + +#include "hook_mgr.hpp" +#include "plugin.hpp" +#include "game_addrs.hpp" + +// MOZA R3 / ES direct menu Confirm/Cancel helper for the legacy DirectInput path. +// +// OutRun's legacy menus reuse the configured Gear Up / Gear Down actions as +// Accept / Back. If those bindings are cleared or moved while configuring the +// wheel, menu navigation can become impossible. The R3 ES wheel exposes its +// physical A/B buttons as DirectInput Button 1 / Button 2 (Pit House 1-based +// numbering), so keep those two physical buttons available in menus regardless +// of the game's saved bindings: +// A -> A + GearUp (Accept) +// B -> B + Back + GearDown (Cancel/Back) +// +// This helper is menu-only and releases its DirectInput handle before gameplay +// so the force-feedback backend can take exclusive FFB ownership. +namespace Settings +{ + extern Setting WheelUniversalSetupEnable; + + Setting WheelMenuR3DirectAB{ + "Controls", "WheelMenuR3DirectAB", true, + "In legacy wheel mode, keeps the MOZA R3/ES physical A/B buttons available as menu Accept/Back regardless of saved bindings." + }; + + Setting WheelMenuR3AButton{ + "Controls", "WheelMenuR3AButton", 1, + "Pit House 1-based button number for the R3/ES A button.", Range{ 1, 128 } + }; + + Setting WheelMenuR3BButton{ + "Controls", "WheelMenuR3BButton", 2, + "Pit House 1-based button number for the R3/ES B button.", Range{ 1, 128 } + }; + + // Declared by hooks_wheel_r3_menu_dpad.hpp, included before this file by the + // wheel build shim. + extern Setting WheelMenuR3DeviceName; +} + +namespace +{ + class WheelR3MenuAB : public Hook + { + inline static constexpr uint32_t RawAMask = 0x00000002u; + inline static constexpr uint32_t RawBMask = 0x00000004u; + inline static constexpr uint32_t RawBackMask = 0x00000200u; + + inline static constexpr uint32_t ASwitchMask = 1u << int(SwitchId::A); + inline static constexpr uint32_t BSwitchMask = 1u << int(SwitchId::B); + inline static constexpr uint32_t BackSwitchMask = 1u << int(SwitchId::Back); + inline static constexpr uint32_t GearDownSwitchMask = 1u << int(SwitchId::GearDown); + inline static constexpr uint32_t GearUpSwitchMask = 1u << int(SwitchId::GearUp); + + inline static SafetyHookInline ReadIOHook = {}; + inline static SafetyHookInline SwitchNowHook = {}; + inline static SafetyHookInline SwitchOnHook = {}; + + inline static IDirectInput8A* directInput = nullptr; + inline static IDirectInputDevice8A* device = nullptr; + inline static DWORD retryAfter = 0; + inline static DWORD lastReadErrorLog = 0; + inline static bool loggedReady = false; + inline static bool loggedA = false; + inline static bool loggedB = false; + + inline static bool currentA = false; + inline static bool previousA = false; + inline static bool currentB = false; + inline static bool previousB = false; + inline static bool haveDirectState = false; + + struct EnumContext + { + GUID guid{}; + std::string name; + bool found = false; + bool ignoreName = false; + }; + + static std::string lowerCopy(const char* text) + { + std::string result = text ? text : ""; + std::transform(result.begin(), result.end(), result.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return result; + } + + static bool activeInMenu() + { + return Settings::WheelInputCompatibility && + !Settings::UseNewInput && + !Settings::WheelUniversalSetupEnable && + Settings::WheelMenuR3DirectAB && + Game::current_mode && + *Game::current_mode != STATE_GAME; + } + + static BOOL CALLBACK enumDevicesCallback( + LPCDIDEVICEINSTANCEA instance, LPVOID context) + { + auto* ctx = static_cast(context); + const std::string wanted = lowerCopy(Settings::WheelMenuR3DeviceName.get().c_str()); + const std::string instanceName = lowerCopy(instance->tszInstanceName); + const std::string productName = lowerCopy(instance->tszProductName); + + if (!ctx->ignoreName && !wanted.empty() && + instanceName.find(wanted) == std::string::npos && + productName.find(wanted) == std::string::npos) + { + return DIENUM_CONTINUE; + } + + ctx->guid = instance->guidInstance; + ctx->name = instance->tszProductName; + ctx->found = true; + return DIENUM_STOP; + } + + static void releaseDevice() + { + if (device) + { + device->Unacquire(); + device->Release(); + device = nullptr; + } + if (directInput) + { + directInput->Release(); + directInput = nullptr; + } + haveDirectState = false; + } + + static bool ensureDevice() + { + if (device) + return true; + + const DWORD now = GetTickCount(); + if (retryAfter != 0 && static_cast(now - retryAfter) < 0) + return false; + + releaseDevice(); + + HRESULT hr = DirectInput8Create( + GetModuleHandleW(nullptr), + DIRECTINPUT_VERSION, + IID_IDirectInput8A, + reinterpret_cast(&directInput), + nullptr); + if (FAILED(hr) || !directInput) + { + retryAfter = now + 2000; + return false; + } + + EnumContext ctx{}; + hr = directInput->EnumDevices( + DI8DEVCLASS_GAMECTRL, + enumDevicesCallback, + &ctx, + DIEDFL_ATTACHEDONLY | DIEDFL_FORCEFEEDBACK); + if (SUCCEEDED(hr) && !ctx.found && + lowerCopy(Settings::WheelMenuR3DeviceName.get().c_str()) == "moza") + { + ctx.ignoreName = true; + spdlog::warn( + "WheelMenuR3DirectAB: no literal MOZA name; trying first attached FFB wheel"); + hr = directInput->EnumDevices( + DI8DEVCLASS_GAMECTRL, + enumDevicesCallback, + &ctx, + DIEDFL_ATTACHEDONLY | DIEDFL_FORCEFEEDBACK); + } + if (FAILED(hr) || !ctx.found) + { + retryAfter = now + 2000; + releaseDevice(); + return false; + } + + hr = directInput->CreateDevice(ctx.guid, &device, nullptr); + if (FAILED(hr) || !device) + { + retryAfter = now + 2000; + releaseDevice(); + return false; + } + + hr = device->SetDataFormat(&c_dfDIJoystick2); + if (FAILED(hr)) + { + retryAfter = now + 2000; + releaseDevice(); + return false; + } + + HWND hwnd = Game::GameHwnd(); + if (!hwnd) + { + retryAfter = now + 1000; + releaseDevice(); + return false; + } + + hr = device->SetCooperativeLevel( + hwnd, DISCL_NONEXCLUSIVE | DISCL_BACKGROUND); + if (FAILED(hr)) + { + retryAfter = now + 2000; + releaseDevice(); + return false; + } + + hr = device->Acquire(); + if (FAILED(hr) && hr != S_FALSE) + { + retryAfter = now + 2000; + releaseDevice(); + return false; + } + + if (!loggedReady) + { + loggedReady = true; + spdlog::info( + "WheelMenuR3DirectAB: ready on '{}' (PitHouse A/B={}/{})", + ctx.name, + int(Settings::WheelMenuR3AButton), + int(Settings::WheelMenuR3BButton)); + } + + return true; + } + + static bool buttonHeld(const DIJOYSTATE2& state, int oneBasedButton) + { + const int index = oneBasedButton - 1; + if (index < 0 || index >= 128) + return false; + return (state.rgbButtons[index] & 0x80) != 0; + } + + static bool pollButtons(bool& a, bool& b) + { + a = false; + b = false; + + if (!ensureDevice()) + return false; + + HRESULT hr = device->Poll(); + if (hr == DIERR_INPUTLOST || hr == DIERR_NOTACQUIRED) + { + device->Acquire(); + hr = device->Poll(); + } + + DIJOYSTATE2 state{}; + hr = device->GetDeviceState(sizeof(state), &state); + if (hr == DIERR_INPUTLOST || hr == DIERR_NOTACQUIRED) + { + device->Acquire(); + device->Poll(); + hr = device->GetDeviceState(sizeof(state), &state); + } + + if (FAILED(hr)) + { + const DWORD now = GetTickCount(); + if (now - lastReadErrorLog >= 5000) + { + lastReadErrorLog = now; + spdlog::warn( + "WheelMenuR3DirectAB: GetDeviceState failed (0x{:08X})", + static_cast(hr)); + } + haveDirectState = false; + releaseDevice(); + retryAfter = now + 500; + return false; + } + + a = buttonHeld(state, int(Settings::WheelMenuR3AButton)); + b = buttonHeld(state, int(Settings::WheelMenuR3BButton)); + return true; + } + + static void updateRawState(SumoDInputState* state) + { + if (!state) + return; + + if (!activeInMenu()) + { + if (device || directInput) + releaseDevice(); + previousA = currentA = false; + previousB = currentB = false; + return; + } + + bool a = false; + bool b = false; + if (!pollButtons(a, b)) + return; + + previousA = currentA; + previousB = currentB; + currentA = a; + currentB = b; + haveDirectState = true; + + if (currentA) + state->buttons_4 |= RawAMask; + if (currentB) + state->buttons_4 |= RawBMask | RawBackMask; + + if (currentA && !previousA) + state->pressed_8 |= RawAMask; + else if (!currentA && previousA) + state->released_C |= RawAMask; + + if (currentB && !previousB) + state->pressed_8 |= RawBMask | RawBackMask; + else if (!currentB && previousB) + state->released_C |= RawBMask | RawBackMask; + } + + static int ReadIO_dest() + { + const int result = ReadIOHook.ccall(); + updateRawState(Game::dinput_state); + return result; + } + + static bool isAQuery(uint32_t switches) + { + return switches == ASwitchMask || switches == GearUpSwitchMask; + } + + static bool isBQuery(uint32_t switches) + { + return switches == BSwitchMask || + switches == BackSwitchMask || + switches == GearDownSwitchMask; + } + + static int SwitchNow_dest(uint32_t switches) + { + const int result = SwitchNowHook.ccall(switches); + if (result) + return result; + + if (!activeInMenu() || !haveDirectState) + return 0; + + if (currentA && isAQuery(switches)) + { + if (!loggedA) + { + loggedA = true; + spdlog::info("WheelMenuR3DirectAB: physical A -> A/GearUp menu Accept"); + } + return 1; + } + + if (currentB && isBQuery(switches)) + { + if (!loggedB) + { + loggedB = true; + spdlog::info("WheelMenuR3DirectAB: physical B -> B/Back/GearDown menu Cancel"); + } + return 1; + } + + return 0; + } + + static int SwitchOn_dest(uint32_t switches) + { + const int result = SwitchOnHook.ccall(switches); + if (result) + return result; + + if (!activeInMenu() || !haveDirectState) + return 0; + + if (currentA && !previousA && isAQuery(switches)) + return 1; + if (currentB && !previousB && isBQuery(switches)) + return 1; + return 0; + } + + public: + std::string_view description() override + { + return "WheelR3MenuAB"; + } + + bool validate() override + { + return Settings::WheelInputCompatibility && + !Settings::UseNewInput && + Settings::WheelMenuR3DirectAB; + } + + void declare_settings() override + { + Settings::WheelMenuR3DirectAB.needs_restart(); + Settings::WheelMenuR3AButton.needs_restart(); + Settings::WheelMenuR3BButton.needs_restart(); + Settings::WheelMenuR3DirectAB.hidden(Settings::UseNewInput); + Settings::WheelMenuR3AButton.hidden(Settings::UseNewInput); + Settings::WheelMenuR3BButton.hidden(Settings::UseNewInput); + } + + bool apply() override + { + ReadIOHook = safetyhook::create_inline(Module::exe_ptr(0x53BB0), ReadIO_dest); + SwitchNowHook = safetyhook::create_inline(Module::exe_ptr(0x536C0), SwitchNow_dest); + SwitchOnHook = safetyhook::create_inline(Module::exe_ptr(0x536F0), SwitchOn_dest); + + const bool ok = !!ReadIOHook && !!SwitchNowHook && !!SwitchOnHook; + if (ok) + { + spdlog::info( + "WheelR3MenuAB: enabled (PitHouse A/B={}/{})", + int(Settings::WheelMenuR3AButton), + int(Settings::WheelMenuR3BButton)); + } + return ok; + } + + static WheelR3MenuAB instance; + }; + + WheelR3MenuAB WheelR3MenuAB::instance; +} diff --git a/src/hooks_wheel_r3_menu_dpad.hpp b/src/hooks_wheel_r3_menu_dpad.hpp new file mode 100644 index 000000000..20033c29e --- /dev/null +++ b/src/hooks_wheel_r3_menu_dpad.hpp @@ -0,0 +1,496 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +#include "hook_mgr.hpp" +#include "plugin.hpp" +#include "game_addrs.hpp" + +// MOZA R3 / ES-family menu navigation helper for the legacy DirectInput path. +// +// The original game can auto-bind an analogue wheel/pedal axis to menu +// Up/Down. On modern wheels that axis may not be centred the way OutRun 2006 +// expects, so the menu scrolls continuously. Rather than trying to guess which +// legacy axis should be neutral, this helper replaces menu direction input with +// the R3 wheel's physical D-pad buttons while outside STATE_GAME. +// +// MOZA Pit House numbers buttons from 1, while DIJOYSTATE2::rgbButtons is +// zero-based. Public R3+ES profiles consistently map the D-pad as: +// Up=5, Right=6, Down=7, Left=8 (Pit House numbering) +// The values remain configurable in case a firmware/wheel revision differs. +namespace Settings +{ + extern Setting WheelUniversalSetupEnable; + + Setting WheelMenuR3DirectDPad{ + "Controls", "WheelMenuR3DirectDPad", true, + "In legacy wheel mode, ignores analogue menu-direction bindings and uses the MOZA R3/ES physical D-pad instead." + }; + + Setting WheelMenuR3DeviceName{ + "Controls", "WheelMenuR3DeviceName", "MOZA", + "Case-insensitive DirectInput device-name substring used for the menu D-pad reader." + }; + + Setting WheelMenuR3UpButton{ + "Controls", "WheelMenuR3UpButton", 5, + "Pit House 1-based button number for D-pad Up.", Range{ 1, 128 } + }; + + Setting WheelMenuR3RightButton{ + "Controls", "WheelMenuR3RightButton", 6, + "Pit House 1-based button number for D-pad Right.", Range{ 1, 128 } + }; + + Setting WheelMenuR3DownButton{ + "Controls", "WheelMenuR3DownButton", 7, + "Pit House 1-based button number for D-pad Down.", Range{ 1, 128 } + }; + + Setting WheelMenuR3LeftButton{ + "Controls", "WheelMenuR3LeftButton", 8, + "Pit House 1-based button number for D-pad Left.", Range{ 1, 128 } + }; +} + +namespace +{ + class WheelR3MenuDPad : public Hook + { + struct Direction + { + uint32_t rawMask; + uint32_t switchMask; + int virtualKey; + Settings::Setting* buttonSetting; + const char* name; + }; + + inline static const std::array Directions = {{ + { 0x00000040u, 1u << int(SwitchId::SelectionUp), VK_UP, &Settings::WheelMenuR3UpButton, "up" }, + { 0x00000080u, 1u << int(SwitchId::SelectionRight), VK_RIGHT, &Settings::WheelMenuR3RightButton, "right" }, + { 0x00000020u, 1u << int(SwitchId::SelectionDown), VK_DOWN, &Settings::WheelMenuR3DownButton, "down" }, + { 0x00000100u, 1u << int(SwitchId::SelectionLeft), VK_LEFT, &Settings::WheelMenuR3LeftButton, "left" }, + }}; + + inline static constexpr uint32_t DirectionRawMask = 0x000001E0u; + inline static constexpr uint32_t DirectionSwitchMask = + (1u << int(SwitchId::SelectionUp)) | + (1u << int(SwitchId::SelectionDown)) | + (1u << int(SwitchId::SelectionLeft)) | + (1u << int(SwitchId::SelectionRight)); + + inline static SafetyHookInline ReadIOHook = {}; + inline static SafetyHookInline SwitchNowHook = {}; + inline static SafetyHookInline SwitchOnHook = {}; + + inline static IDirectInput8A* directInput = nullptr; + inline static IDirectInputDevice8A* device = nullptr; + inline static GUID selectedGuid{}; + inline static std::string selectedName; + inline static DWORD retryAfter = 0; + inline static DWORD lastReadErrorLog = 0; + inline static bool loggedReady = false; + + inline static uint32_t currentDirectionsRaw = 0; + inline static uint32_t previousDirectionsRaw = 0; + inline static bool haveDirectState = false; + + struct EnumContext + { + GUID guid{}; + std::string name; + bool found = false; + bool ignoreName = false; + }; + + static std::string r3_lower_copy(const char* text) + { + std::string result = text ? text : ""; + std::transform(result.begin(), result.end(), result.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return result; + } + + static bool activeInMenu() + { + return Settings::WheelInputCompatibility && + !Settings::UseNewInput && + !Settings::WheelUniversalSetupEnable && + Settings::WheelMenuR3DirectDPad && + Game::current_mode && + *Game::current_mode != STATE_GAME; + } + + static BOOL CALLBACK enumDevicesCallback( + LPCDIDEVICEINSTANCEA instance, LPVOID context) + { + auto* ctx = static_cast(context); + const std::string wanted = r3_lower_copy(Settings::WheelMenuR3DeviceName.get().c_str()); + const std::string instanceName = r3_lower_copy(instance->tszInstanceName); + const std::string productName = r3_lower_copy(instance->tszProductName); + + if (!ctx->ignoreName && !wanted.empty() && + instanceName.find(wanted) == std::string::npos && + productName.find(wanted) == std::string::npos) + { + return DIENUM_CONTINUE; + } + + ctx->guid = instance->guidInstance; + ctx->name = instance->tszProductName; + ctx->found = true; + return DIENUM_STOP; + } + + static void releaseDevice() + { + if (device) + { + device->Unacquire(); + device->Release(); + device = nullptr; + } + if (directInput) + { + directInput->Release(); + directInput = nullptr; + } + selectedName.clear(); + haveDirectState = false; + } + + static bool ensureDevice() + { + if (device) + return true; + + const DWORD now = GetTickCount(); + if (retryAfter != 0 && static_cast(now - retryAfter) < 0) + return false; + + releaseDevice(); + + HRESULT hr = DirectInput8Create( + GetModuleHandleW(nullptr), + DIRECTINPUT_VERSION, + IID_IDirectInput8A, + reinterpret_cast(&directInput), + nullptr); + if (FAILED(hr) || !directInput) + { + retryAfter = now + 2000; + return false; + } + + EnumContext ctx{}; + hr = directInput->EnumDevices( + DI8DEVCLASS_GAMECTRL, + enumDevicesCallback, + &ctx, + DIEDFL_ATTACHEDONLY | DIEDFL_FORCEFEEDBACK); + if (SUCCEEDED(hr) && !ctx.found && + r3_lower_copy(Settings::WheelMenuR3DeviceName.get().c_str()) == "moza") + { + ctx.ignoreName = true; + spdlog::warn( + "WheelMenuR3DirectDPad: no literal MOZA name; trying first attached FFB wheel"); + hr = directInput->EnumDevices( + DI8DEVCLASS_GAMECTRL, + enumDevicesCallback, + &ctx, + DIEDFL_ATTACHEDONLY | DIEDFL_FORCEFEEDBACK); + } + if (FAILED(hr) || !ctx.found) + { + spdlog::warn( + "WheelMenuR3DirectDPad: no DirectInput FFB wheel matched '{}'", + Settings::WheelMenuR3DeviceName.get()); + retryAfter = now + 2000; + releaseDevice(); + return false; + } + + selectedGuid = ctx.guid; + selectedName = ctx.name; + + hr = directInput->CreateDevice(selectedGuid, &device, nullptr); + if (FAILED(hr) || !device) + { + retryAfter = now + 2000; + releaseDevice(); + return false; + } + + hr = device->SetDataFormat(&c_dfDIJoystick2); + if (FAILED(hr)) + { + retryAfter = now + 2000; + releaseDevice(); + return false; + } + + HWND hwnd = Game::GameHwnd(); + if (!hwnd) + { + retryAfter = now + 1000; + releaseDevice(); + return false; + } + + // Non-exclusive input reader: leave the original game DirectInput + // device alone while navigating menus. + hr = device->SetCooperativeLevel( + hwnd, DISCL_NONEXCLUSIVE | DISCL_BACKGROUND); + if (FAILED(hr)) + { + spdlog::warn( + "WheelMenuR3DirectDPad: SetCooperativeLevel failed (0x{:08X})", + static_cast(hr)); + retryAfter = now + 2000; + releaseDevice(); + return false; + } + + hr = device->Acquire(); + if (FAILED(hr) && hr != S_FALSE) + { + retryAfter = now + 2000; + releaseDevice(); + return false; + } + + DIDEVCAPS caps{}; + caps.dwSize = sizeof(caps); + device->GetCapabilities(&caps); + + if (!loggedReady) + { + loggedReady = true; + spdlog::info( + "WheelMenuR3DirectDPad: ready on '{}' (buttons={}, PitHouse Up/Right/Down/Left={}/{}/{}/{})", + selectedName, + caps.dwButtons, + int(Settings::WheelMenuR3UpButton), + int(Settings::WheelMenuR3RightButton), + int(Settings::WheelMenuR3DownButton), + int(Settings::WheelMenuR3LeftButton)); + } + + return true; + } + + static bool buttonHeld(const DIJOYSTATE2& state, int oneBasedButton) + { + const int index = oneBasedButton - 1; + if (index < 0 || index >= 128) + return false; + return (state.rgbButtons[index] & 0x80) != 0; + } + + static uint32_t keyboardDirections() + { + uint32_t result = 0; + for (const auto& direction : Directions) + { + if ((GetAsyncKeyState(direction.virtualKey) & 0x8000) != 0) + result |= direction.rawMask; + } + return result; + } + + static bool pollDirectDirections(uint32_t& result) + { + result = 0; + if (!ensureDevice()) + return false; + + HRESULT hr = device->Poll(); + if (hr == DIERR_INPUTLOST || hr == DIERR_NOTACQUIRED) + { + device->Acquire(); + hr = device->Poll(); + } + + DIJOYSTATE2 state{}; + hr = device->GetDeviceState(sizeof(state), &state); + if (hr == DIERR_INPUTLOST || hr == DIERR_NOTACQUIRED) + { + device->Acquire(); + device->Poll(); + hr = device->GetDeviceState(sizeof(state), &state); + } + + if (FAILED(hr)) + { + const DWORD now = GetTickCount(); + if (now - lastReadErrorLog >= 5000) + { + lastReadErrorLog = now; + spdlog::warn( + "WheelMenuR3DirectDPad: GetDeviceState failed (0x{:08X}); using legacy menu filter fallback", + static_cast(hr)); + } + haveDirectState = false; + releaseDevice(); + retryAfter = now + 500; + return false; + } + + for (const auto& direction : Directions) + { + if (buttonHeld(state, int(direction.buttonSetting->get()))) + result |= direction.rawMask; + } + return true; + } + + static uint32_t rawToSwitch(uint32_t raw) + { + uint32_t result = 0; + for (const auto& direction : Directions) + if (raw & direction.rawMask) + result |= direction.switchMask; + return result; + } + + static void replaceMenuDirections(SumoDInputState* state) + { + if (!state) + return; + + if (!activeInMenu()) + { + // The FFB engine intentionally acquires the wheel exclusively. + // Release this non-exclusive menu reader before gameplay so it + // can never block FFB acquisition on drivers that enforce it. + if (device || directInput) + releaseDevice(); + previousDirectionsRaw = 0; + currentDirectionsRaw = 0; + return; + } + + uint32_t wheelDirections = 0; + if (!pollDirectDirections(wheelDirections)) + { + // The V2 continuously-held-direction filter remains underneath + // this hook as a fallback when a second non-exclusive read is + // not possible on a specific driver. + haveDirectState = false; + return; + } + + previousDirectionsRaw = currentDirectionsRaw; + currentDirectionsRaw = wheelDirections | keyboardDirections(); + haveDirectState = true; + + // Replace, don't merge: this intentionally discards the original + // game's auto-bound analogue menu axis, which is the source of the + // permanent Up/Down input on the R3. + state->buttons_4 = + (state->buttons_4 & ~DirectionRawMask) | currentDirectionsRaw; + state->pressed_8 = + (state->pressed_8 & ~DirectionRawMask) | + (currentDirectionsRaw & ~previousDirectionsRaw); + state->released_C = + (state->released_C & ~DirectionRawMask) | + (previousDirectionsRaw & ~currentDirectionsRaw); + } + + static int ReadIO_dest() + { + const int result = ReadIOHook.ccall(); + replaceMenuDirections(Game::dinput_state); + return result; + } + + static bool pureDirectionQuery(uint32_t switches) + { + return (switches & DirectionSwitchMask) != 0 && + (switches & ~DirectionSwitchMask) == 0; + } + + static int SwitchNow_dest(uint32_t switches) + { + if (activeInMenu() && haveDirectState && pureDirectionQuery(switches)) + { + const uint32_t currentSwitches = rawToSwitch(currentDirectionsRaw); + return (currentSwitches & switches) == switches; + } + return SwitchNowHook.ccall(switches); + } + + static int SwitchOn_dest(uint32_t switches) + { + if (activeInMenu() && haveDirectState && pureDirectionQuery(switches)) + { + const uint32_t currentSwitches = rawToSwitch(currentDirectionsRaw); + const uint32_t previousSwitches = rawToSwitch(previousDirectionsRaw); + return (previousSwitches & switches) != switches && + (currentSwitches & switches) == switches; + } + return SwitchOnHook.ccall(switches); + } + + public: + std::string_view description() override + { + return "WheelR3MenuDPad"; + } + + bool validate() override + { + return Settings::WheelInputCompatibility && + !Settings::UseNewInput && + Settings::WheelMenuR3DirectDPad; + } + + void declare_settings() override + { + Settings::WheelMenuR3DirectDPad.needs_restart(); + Settings::WheelMenuR3DeviceName.needs_restart(); + Settings::WheelMenuR3UpButton.needs_restart(); + Settings::WheelMenuR3RightButton.needs_restart(); + Settings::WheelMenuR3DownButton.needs_restart(); + Settings::WheelMenuR3LeftButton.needs_restart(); + + Settings::WheelMenuR3DirectDPad.hidden(Settings::UseNewInput); + Settings::WheelMenuR3DeviceName.hidden(Settings::UseNewInput); + Settings::WheelMenuR3UpButton.hidden(Settings::UseNewInput); + Settings::WheelMenuR3RightButton.hidden(Settings::UseNewInput); + Settings::WheelMenuR3DownButton.hidden(Settings::UseNewInput); + Settings::WheelMenuR3LeftButton.hidden(Settings::UseNewInput); + } + + bool apply() override + { + ReadIOHook = safetyhook::create_inline(Module::exe_ptr(0x53BB0), ReadIO_dest); + SwitchNowHook = safetyhook::create_inline(Module::exe_ptr(0x536C0), SwitchNow_dest); + SwitchOnHook = safetyhook::create_inline(Module::exe_ptr(0x536F0), SwitchOn_dest); + + const bool ok = !!ReadIOHook && !!SwitchNowHook && !!SwitchOnHook; + if (ok) + { + spdlog::info( + "WheelR3MenuDPad: enabled (PitHouse Up/Right/Down/Left={}/{}/{}/{})", + int(Settings::WheelMenuR3UpButton), + int(Settings::WheelMenuR3RightButton), + int(Settings::WheelMenuR3DownButton), + int(Settings::WheelMenuR3LeftButton)); + } + return ok; + } + + static WheelR3MenuDPad instance; + }; + + WheelR3MenuDPad WheelR3MenuDPad::instance; +} diff --git a/src/hooks_wheel_vehicle_dynamics.hpp b/src/hooks_wheel_vehicle_dynamics.hpp new file mode 100644 index 000000000..548af6d45 --- /dev/null +++ b/src/hooks_wheel_vehicle_dynamics.hpp @@ -0,0 +1,440 @@ +#pragma once + +// OutRun vehicle-dynamics estimator for steering FFB. +// +// This class deliberately owns no DirectInput effects and computes no FFB +// torque. Its only job is to estimate reusable vehicle state from the game: +// body slip (beta), yaw rate, front-slip proxy and local motion. WheelFFBEngine +// decides how those signals affect SAT, damping and tire scrub. +class WheelVehicleDynamics +{ +public: + void reset() + { + positionValid_ = false; + headingValid_ = false; + calibrated_ = false; + sampleValid_ = false; + forwardAxis_ = 0; + forwardSign_ = 1.0f; + calibrationSamples_ = 0; + calibrationScoreX_ = 0.0f; + calibrationScoreZ_ = 0.0f; + calibrationSignedX_ = 0.0f; + calibrationSignedZ_ = 0.0f; + calibrationConfidence_ = 0.0f; + activationBlend_ = 0.0f; + invalidTicks_ = 0; + discontinuityCount_ = 0; + prevPosition_ = D3DVECTOR{}; + prevHeading_ = 0.0f; + prevSteerValid_ = false; + prevSteer_ = 0.0f; + steerRate_ = 0.0f; + bodySlip_ = 0.0f; + yawRate_ = 0.0f; + frontSlip_ = 0.0f; + rawBodySlip_ = 0.0f; + rawYawRate_ = 0.0f; + rawFrontSlip_ = 0.0f; + bodySlipBlend_ = 0.18f; + yawRateBlend_ = 0.20f; + frontSlipBlend_ = 0.24f; + vLong_ = 0.0f; + vLat_ = 0.0f; + positionStep_ = 0.0f; + motionScaleEma_ = 0.0f; + motionScaleSamples_ = 0; + spdLen_ = 0.0f; + spdCorrelation_ = 0.0f; + } + + void reset_dynamic() + { + // Preserve the proven matrix basis while dropping every time-domain + // sample, including the warp-detection motion scale. Menu/focus/F11 + // transitions must not compare a new segment against the old segment's + // movement scale before Physics SAT resumes. + positionValid_ = false; + headingValid_ = false; + prevPosition_ = D3DVECTOR{}; + positionStep_ = 0.0f; + motionScaleEma_ = 0.0f; + motionScaleSamples_ = 0; + spdLen_ = 0.0f; + spdCorrelation_ = 0.0f; + clear_dynamic_state(); + } + + void update( + EVWORK_CAR* car, + float steer, + float speedNorm, + float lateralLoadSmooth) + { + sampleValid_ = false; + vLong_ = 0.0f; + vLat_ = 0.0f; + positionStep_ = 0.0f; + spdLen_ = 0.0f; + spdCorrelation_ = 0.0f; + + if (!car) + { + decay_invalid_sample(); + return; + } + + const D3DVECTOR current = car->position_14; + if (!std::isfinite(current.x) || !std::isfinite(current.z)) + { + decay_invalid_sample(); + return; + } + + if (!positionValid_) + { + prevPosition_ = current; + positionValid_ = true; + return; + } + + const float dx = current.x - prevPosition_.x; + const float dz = current.z - prevPosition_.z; + prevPosition_ = current; + const float motionLen = std::sqrt(dx * dx + dz * dz); + positionStep_ = std::isfinite(motionLen) ? motionLen : 0.0f; + + // A stop/restart must not inherit the previous corner's beta/yaw/front + // slip. Keep the proven matrix basis but restart only dynamic state. + if (speedNorm <= 0.04f) + { + clear_dynamic_state(); + return; + } + + if (!std::isfinite(motionLen) || motionLen <= 0.00001f) + { + decay_invalid_sample(); + return; + } + + // Detect restart/warp discontinuities without assuming OutRun world + // units. Compare normalized step length with its rolling scale. + const float motionScale = motionLen / std::max(speedNorm, 0.05f); + if (motionScaleSamples_ >= 12 && + motionScaleEma_ > 0.00001f && + motionScale > motionScaleEma_ * 5.0f) + { + ++discontinuityCount_; + clear_dynamic_state(); + return; + } + if (std::isfinite(motionScale)) + { + if (motionScaleSamples_ == 0) + motionScaleEma_ = motionScale; + else + motionScaleEma_ += (motionScale - motionScaleEma_) * 0.05f; + ++motionScaleSamples_; + } + + const float motionX = dx / motionLen; + const float motionZ = dz / motionLen; + const D3DMATRIX& body = car->matrix_70; + + // OutRun uses row-vector transforms, but whether local X or Z is the + // vehicle-forward basis is verified from several straight rolling ticks + // rather than guessed from one frame. + if (!calibrated_ && + speedNorm > 0.12f && + std::abs(steer) < 0.08f && + lateralLoadSmooth < 0.12f) + { + const float xLen = std::sqrt(body._11 * body._11 + body._13 * body._13); + const float zLen = std::sqrt(body._31 * body._31 + body._33 * body._33); + if (std::isfinite(xLen) && std::isfinite(zLen) && + xLen > 0.0001f && zLen > 0.0001f) + { + const float xDot = + (body._11 / xLen) * motionX + (body._13 / xLen) * motionZ; + const float zDot = + (body._31 / zLen) * motionX + (body._33 / zLen) * motionZ; + calibrationScoreX_ += std::abs(xDot); + calibrationScoreZ_ += std::abs(zDot); + calibrationSignedX_ += xDot; + calibrationSignedZ_ += zDot; + ++calibrationSamples_; + + constexpr int CalibrationSamplesRequired = 12; + if (calibrationSamples_ >= CalibrationSamplesRequired) + { + const float invSamples = 1.0f / static_cast(calibrationSamples_); + const float xScore = calibrationScoreX_ * invSamples; + const float zScore = calibrationScoreZ_ * invSamples; + const float bestScore = std::max(xScore, zScore); + const float secondScore = std::min(xScore, zScore); + calibrationConfidence_ = bestScore - secondScore; + + if (bestScore >= 0.85f && calibrationConfidence_ >= 0.25f) + { + forwardAxis_ = zScore >= xScore ? 3 : 1; + const float signedScore = forwardAxis_ == 3 + ? calibrationSignedZ_ + : calibrationSignedX_; + forwardSign_ = signedScore >= 0.0f ? 1.0f : -1.0f; + calibrated_ = true; + clear_dynamic_state(); + } + else if (calibrationSamples_ >= CalibrationSamplesRequired * 4) + { + // Do not let a noisy launch poison the basis average for + // the rest of the race. Retry from a fresh straight-line + // window while Natural SAT remains fully available. + calibrationSamples_ = 0; + calibrationScoreX_ = 0.0f; + calibrationScoreZ_ = 0.0f; + calibrationSignedX_ = 0.0f; + calibrationSignedZ_ = 0.0f; + calibrationConfidence_ = 0.0f; + } + } + } + } + + if (!calibrated_) + return; + + float forwardX = forwardAxis_ == 3 ? body._31 : body._11; + float forwardZ = forwardAxis_ == 3 ? body._33 : body._13; + forwardX *= forwardSign_; + forwardZ *= forwardSign_; + const float forwardLen = std::sqrt(forwardX * forwardX + forwardZ * forwardZ); + if (!std::isfinite(forwardLen) || forwardLen <= 0.0001f) + { + decay_invalid_sample(); + return; + } + forwardX /= forwardLen; + forwardZ /= forwardLen; + + // Right-positive local motion. Position delta remains authoritative until + // spd_mb_20's coordinate space/scale is proven by real-lap telemetry. + const float rightX = forwardZ; + const float rightZ = -forwardX; + vLong_ = dx * forwardX + dz * forwardZ; + vLat_ = dx * rightX + dz * rightZ; + if (vLong_ <= 0.0f) + { + clear_dynamic_state(); + return; + } + rawBodySlip_ = std::clamp( + std::atan2(vLat_, std::max(std::abs(vLong_), 0.00001f)), + -0.70f, 0.70f); + + // Tyre transient response is fundamentally distance-based (relaxation + // length), so a fixed time-domain low-pass becomes increasingly late as + // speed rises. OutRun does not expose a physical metres/second scale, + // therefore use a conservative speed-adaptive blend while retaining + // smoothing at parking/launch speeds. + const float transientT0 = std::clamp( + (speedNorm - 0.08f) / 0.72f, 0.0f, 1.0f); + const float transientT = + transientT0 * transientT0 * (3.0f - 2.0f * transientT0); + bodySlipBlend_ = 0.18f + (0.34f - 0.18f) * transientT; + yawRateBlend_ = 0.20f + (0.38f - 0.20f) * transientT; + frontSlipBlend_ = 0.24f + (0.58f - 0.24f) * transientT; + bodySlip_ += (rawBodySlip_ - bodySlip_) * bodySlipBlend_; + + const float heading = std::atan2(forwardX, forwardZ); + if (!headingValid_) + { + prevHeading_ = heading; + headingValid_ = true; + return; + } + + constexpr float Pi = 3.14159265359f; + constexpr float TwoPi = 6.28318530718f; + float headingDelta = heading - prevHeading_; + prevHeading_ = heading; + while (headingDelta > Pi) + headingDelta -= TwoPi; + while (headingDelta < -Pi) + headingDelta += TwoPi; + if (std::abs(headingDelta) >= 0.35f) + { + ++discontinuityCount_; + clear_dynamic_state(); + return; + } + + // Called once per fixed OutRun simulation tick, independent of render FPS. + rawYawRate_ = std::clamp(headingDelta * 60.0f, -3.5f, 3.5f); + yawRate_ += (rawYawRate_ - yawRate_) * yawRateBlend_; + + // Track steering velocity separately from body/yaw filtering. Physics SAT + // used to wait for several filtered front-slip ticks before reacting to a + // fast counter-steer, which made a DD wheel feel strangely passive even + // though the steady-state torque was correct. A bounded one-to-two tick + // steering lead gives the tyre proxy the phase response it needs without + // changing the eventual steady-state slip angle. + float rawSteerRate = 0.0f; + if (prevSteerValid_) + rawSteerRate = std::clamp((steer - prevSteer_) * 60.0f, -12.0f, 12.0f); + else + prevSteerValid_ = true; + prevSteer_ = steer; + steerRate_ += (rawSteerRate - steerRate_) * 0.55f; + + // Bicycle-model-inspired front-slip proxy: + // alpha_f ~= road-wheel-angle - beta - a*r/v. + constexpr float RoadWheelLockRad = 0.52f; + const float steeringLeadSeconds = 0.018f + 0.012f * transientT; + const float steeringLead = std::clamp( + steerRate_ * steeringLeadSeconds, -0.10f, 0.10f); + const float roadWheelAngle = std::clamp( + steer + steeringLead, -1.0f, 1.0f) * RoadWheelLockRad; + const float yawLeadSeconds = 0.10f - 0.045f * speedNorm; + const float baseFrontSlip = + roadWheelAngle - bodySlip_ - yawRate_ * yawLeadSeconds; + rawFrontSlip_ = std::clamp(baseFrontSlip, -0.70f, 0.70f); + + // When the desired tyre torque changes side, do not let the old filtered + // slip linger for several extra physics ticks. The normal speed-adaptive + // filter remains untouched for ordinary force build; only a genuine sign + // reversal gets this fast release/cross-over path. + const bool frontSlipReversing = + rawFrontSlip_ * frontSlip_ < -0.0004f; + const float effectiveFrontSlipBlend = frontSlipReversing + ? std::max(frontSlipBlend_, 0.82f) + : frontSlipBlend_; + frontSlip_ += (rawFrontSlip_ - frontSlip_) * effectiveFrontSlipBlend; + + // Telemetry-only validation for the game's native speed vector. + const float spdX = car->spd_mb_20.x; + const float spdZ = car->spd_mb_20.z; + const float spdLen = std::sqrt(spdX * spdX + spdZ * spdZ); + if (std::isfinite(spdLen) && spdLen > 0.0001f) + { + spdLen_ = spdLen; + spdCorrelation_ = + (spdX / spdLen) * motionX + (spdZ / spdLen) * motionZ; + } + + sampleValid_ = true; + invalidTicks_ = 0; + activationBlend_ = std::min(1.0f, activationBlend_ + (1.0f / 24.0f)); + } + + bool calibrated() const { return calibrated_; } + bool sampleValid() const { return sampleValid_; } + int forwardAxis() const { return forwardAxis_; } + float forwardSign() const { return forwardSign_; } + int discontinuityCount() const { return discontinuityCount_; } + float calibrationConfidence() const { return calibrationConfidence_; } + float activationBlend() const { return activationBlend_; } + float bodySlip() const { return bodySlip_; } + float yawRate() const { return yawRate_; } + float frontSlip() const { return frontSlip_; } + float rawBodySlip() const { return rawBodySlip_; } + float rawYawRate() const { return rawYawRate_; } + float rawFrontSlip() const { return rawFrontSlip_; } + float steerRate() const { return steerRate_; } + float bodySlipBlend() const { return bodySlipBlend_; } + float yawRateBlend() const { return yawRateBlend_; } + float frontSlipBlend() const { return frontSlipBlend_; } + float vLong() const { return vLong_; } + float vLat() const { return vLat_; } + float positionStep() const { return positionStep_; } + float motionScale() const { return motionScaleEma_; } + float spdLen() const { return spdLen_; } + float spdCorrelation() const { return spdCorrelation_; } + +private: + void clear_dynamic_state() + { + headingValid_ = false; + sampleValid_ = false; + prevHeading_ = 0.0f; + prevSteerValid_ = false; + prevSteer_ = 0.0f; + steerRate_ = 0.0f; + bodySlip_ = 0.0f; + yawRate_ = 0.0f; + frontSlip_ = 0.0f; + rawBodySlip_ = 0.0f; + rawYawRate_ = 0.0f; + rawFrontSlip_ = 0.0f; + bodySlipBlend_ = 0.18f; + yawRateBlend_ = 0.20f; + frontSlipBlend_ = 0.24f; + vLong_ = 0.0f; + vLat_ = 0.0f; + activationBlend_ = 0.0f; + invalidTicks_ = 0; + } + + void decay_invalid_sample() + { + sampleValid_ = false; + headingValid_ = false; // The next heading is a baseline, not a one-tick derivative. + positionValid_ = false; // Never span a missing position with a one-tick velocity. + prevSteerValid_ = false; // Never derive steering velocity across a missing sample. + steerRate_ = 0.0f; + if (!calibrated_) + return; + + ++invalidTicks_; + bodySlip_ *= 0.55f; + yawRate_ *= 0.55f; + frontSlip_ *= 0.55f; + rawBodySlip_ *= 0.55f; + rawYawRate_ *= 0.55f; + rawFrontSlip_ *= 0.55f; + activationBlend_ *= 0.85f; + + // Once telemetry has been invalid for several ticks, clear all dynamic + // slip/yaw state. Calibration/basis remains valid so recovery is quick. + if (invalidTicks_ > 4) + clear_dynamic_state(); + } + + bool positionValid_ = false; + bool headingValid_ = false; + bool calibrated_ = false; + bool sampleValid_ = false; + int forwardAxis_ = 0; + float forwardSign_ = 1.0f; + int calibrationSamples_ = 0; + float calibrationScoreX_ = 0.0f; + float calibrationScoreZ_ = 0.0f; + float calibrationSignedX_ = 0.0f; + float calibrationSignedZ_ = 0.0f; + float calibrationConfidence_ = 0.0f; + float activationBlend_ = 0.0f; + int invalidTicks_ = 0; + int discontinuityCount_ = 0; + D3DVECTOR prevPosition_{}; + float prevHeading_ = 0.0f; + bool prevSteerValid_ = false; + float prevSteer_ = 0.0f; + float steerRate_ = 0.0f; + float bodySlip_ = 0.0f; + float yawRate_ = 0.0f; + float frontSlip_ = 0.0f; + float rawBodySlip_ = 0.0f; + float rawYawRate_ = 0.0f; + float rawFrontSlip_ = 0.0f; + float bodySlipBlend_ = 0.18f; + float yawRateBlend_ = 0.20f; + float frontSlipBlend_ = 0.24f; + float vLong_ = 0.0f; + float vLat_ = 0.0f; + float positionStep_ = 0.0f; + float motionScaleEma_ = 0.0f; + int motionScaleSamples_ = 0; + float spdLen_ = 0.0f; + float spdCorrelation_ = 0.0f; +}; \ No newline at end of file diff --git a/src/input_manager.cpp b/src/input_manager.cpp index 68e0057b2..2e600970e 100644 --- a/src/input_manager.cpp +++ b/src/input_manager.cpp @@ -1,11 +1,22 @@ #include "input_manager.hpp" +#ifndef DIRECTINPUT_VERSION +#define DIRECTINPUT_VERSION 0x0800 +#endif +#include +#include + +#pragma comment(lib, "dinput8.lib") +#pragma comment(lib, "dxguid.lib") namespace Settings { Setting InputBackend{ "Controls", "InputBackend", 0, - "Backend to use for the SDL3 input system. " - "If your controller fails to be detected, try changing the backend here and relaunching.", - { "Windows.Gaming.Input", "RawInput", "DirectInput", "XInput" } }; + "Backend to use for the SDL3 input system. Automatic uses DirectInput when a force-feedback wheel is attached " + "and Windows.Gaming.Input otherwise. If a controller fails to respond, choose another backend and relaunch.", + { "Automatic", "RawInput", "DirectInput", "XInput" } }; + + Setting WheelInputCompatibility{ "Controls", "WheelInputCompatibility", false, + "Advanced fallback for the original-game DirectInput wheel path. It only activates when UseNewInput is false at launch; both mode changes require a restart." }; Setting UseNewInput{ "Controls", "UseNewInput", true, "Enables new SDL-based input system, allowing game to see full trigger range without any shared trigger axes issues " @@ -15,17 +26,75 @@ namespace Settings "sensitive controls. Only used when UseNewInput is enabled." }; } -InputManager InputManager::instance; +namespace +{ + BOOL CALLBACK detect_ffb_device(LPCDIDEVICEINSTANCEA, LPVOID context) + { + *static_cast(context) = true; + return DIENUM_STOP; + } + + bool has_attached_ffb_wheel() + { + IDirectInput8A* di = nullptr; + const HRESULT createHr = DirectInput8Create( + GetModuleHandleW(nullptr), DIRECTINPUT_VERSION, IID_IDirectInput8A, + reinterpret_cast(&di), nullptr); + if (FAILED(createHr) || !di) + return false; + + bool found = false; + const HRESULT enumHr = di->EnumDevices( + DI8DEVCLASS_GAMECTRL, detect_ffb_device, &found, + DIEDFL_ATTACHEDONLY | DIEDFL_FORCEFEEDBACK); + di->Release(); + return SUCCEEDED(enumHr) && found; + } +} + +InputManager& InputManager::instance = *new InputManager; // TODO: Move most of input_manager.hpp to this .cpp, not sure why so much was left in there.. void InputManager::init(HWND hwnd) { - SDL_SetHint(SDL_HINT_JOYSTICK_WGI, Settings::InputBackend == 0 ? "1" : "0"); - SDL_SetHint(SDL_HINT_JOYSTICK_RAWINPUT, Settings::InputBackend == 1 ? "1" : "0"); - SDL_SetHint(SDL_HINT_JOYSTICK_DIRECTINPUT, Settings::InputBackend == 2 ? "1" : "0"); - SDL_SetHint(SDL_HINT_XINPUT_ENABLED, Settings::InputBackend == 3 ? "1" : "0"); + int activeBackend = Settings::InputBackend; + if (activeBackend == 0 && has_attached_ffb_wheel()) + { + activeBackend = 2; + spdlog::info(__FUNCTION__ ": Automatic backend selected DirectInput for an attached force-feedback wheel"); + } + else if (activeBackend == 0) + spdlog::info(__FUNCTION__ ": Automatic backend selected Windows.Gaming.Input"); - SDL_Init(SDL_INIT_GAMEPAD | SDL_INIT_VIDEO); + SDL_SetHint(SDL_HINT_JOYSTICK_WGI, activeBackend == 0 ? "1" : "0"); + SDL_SetHint(SDL_HINT_JOYSTICK_RAWINPUT, activeBackend == 1 ? "1" : "0"); + SDL_SetHint(SDL_HINT_JOYSTICK_DIRECTINPUT, activeBackend == 2 ? "1" : "0"); + SDL_SetHint(SDL_HINT_XINPUT_ENABLED, activeBackend == 3 ? "1" : "0"); + + if (!SDL_Init(SDL_INIT_JOYSTICK | SDL_INIT_GAMEPAD | SDL_INIT_VIDEO)) + { + spdlog::error(__FUNCTION__ ": SDL input initialization failed: {}", SDL_GetError()); + return; + } + + // Discover hardware that was connected before the game launched. This also + // includes wheels, pedals and shifters which are not in SDL's gamepad mapping + // database and were therefore invisible to the previous implementation. + int joystickCount = 0; + SDL_JoystickID* joystickIds = SDL_GetJoysticks(&joystickCount); + if (!joystickIds) + spdlog::error(__FUNCTION__ ": SDL device enumeration failed: {}", SDL_GetError()); + else + { + for (int i = 0; i < joystickCount; ++i) + { + onJoystickAdded(joystickIds[i]); + if (SDL_IsGamepad(joystickIds[i])) + onControllerAdded(joystickIds[i]); + } + SDL_free(joystickIds); + } + spdlog::info(__FUNCTION__ ": detected {} input devices ({} gamepads)", devices.size(), controllers.size()); // Need to setup SDL_Window for SDL to see keyboard events SDL_PropertiesID props = SDL_CreateProperties(); @@ -75,6 +144,7 @@ std::string InputManager_ModActionDisplayName(ModAction action) case ModAction::OpenChat: return "Y"; case ModAction::MusicNext: return "X"; case ModAction::MusicPrevious: return "Z"; + case ModAction::VRRecenter: return "F12"; default: return "(unbound)"; } } @@ -84,6 +154,21 @@ void InputManager_SetVibration(WORD left, WORD right) InputManager::instance.setVibration(left, right); } +void InputManager_StopVibration() +{ + InputManager::instance.stopVibration(); +} + +void InputManager_Shutdown() +{ + InputManager::instance.shutdown(); +} + +float InputManager_SteeringValue() +{ + return float(InputManager::instance.GetVolume(ADChannel::Steering)) / 127.0f; +} + class NewInputHook : public Hook { inline static SafetyHookInline SwitchOn_hook = {}; @@ -178,7 +263,11 @@ class NewInputHook : public Hook void declare_settings() override { Settings::UseNewInput.needs_restart(); - Settings::UseNewInput.hidden(Settings::UseNewInput); // Unhide if UseNewInput is disabled for some reason, hide if it's enabled + // Keep this hidden while the modern path is active: changing it live + // would stop SDL updates before the legacy hooks exist. It becomes visible + // after a user explicitly boots with UseNewInput=false. + Settings::UseNewInput.hidden(Settings::UseNewInput); + Settings::WheelInputCompatibility.needs_restart(); Settings::InputBackend.needs_restart(); } diff --git a/src/input_manager.hpp b/src/input_manager.hpp index ad117b3b9..4d4c25bc0 100644 --- a/src/input_manager.hpp +++ b/src/input_manager.hpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -13,12 +14,15 @@ #include #include #include +#include #include "input_names.hpp" #include "imgui.h" #include #include #include +#include +#include // fixups for SDL3 sillyness #define SDL_GAMEPAD_BUTTON_A SDL_GAMEPAD_BUTTON_SOUTH @@ -44,6 +48,7 @@ enum class ModAction OpenChat, MusicNext, MusicPrevious, + VRRecenter, Count }; @@ -58,7 +63,7 @@ struct InputState float currentValue = 0.0f; float previousValue = 0.0f; bool isAxis = false; - InputSourceType lastSourceType; + InputSourceType lastSourceType = InputSourceType::GamePad; void update(float newValue) { @@ -78,32 +83,81 @@ struct InputState }; // -// A single bound input: one gamepad button, one gamepad axis, or one key. +// A single bound input: one gamepad/raw-device button or axis, hat direction, +// or keyboard key. Raw device identity survives SDL instance-ID changes. // struct InputBinding { - enum class Kind : uint8_t { None, PadButton, PadAxis, Key }; + enum class Kind : uint8_t { None, PadButton, PadAxis, JoyButton, JoyAxis, JoyHat, Key }; + enum class AxisMode : uint8_t { Signed, FromRest }; static constexpr float StickRange = 32768.f; static constexpr float RStickDeadzone = 0.7f; // needs a high deadzone or flicks bounce Kind kind = Kind::None; bool negate = false; + std::string deviceGuid; + int deviceOccurrence = 0; + Uint16 deviceVendor = 0; + Uint16 deviceProduct = 0; + std::string deviceSerial; + std::string devicePath; + AxisMode axisMode = AxisMode::Signed; + int axisMinimum = -32768; + int axisRest = 0; + int axisMaximum = 32767; + bool axisPositive = true; union { SDL_GamepadButton button; SDL_GamepadAxis axis; SDL_Scancode key; + int controlIndex; }; + Uint8 hatMask = SDL_HAT_CENTERED; InputBinding() : button(SDL_GAMEPAD_BUTTON_INVALID) {} InputBinding(SDL_GamepadButton b, bool n = false) : kind(Kind::PadButton), negate(n), button(b) {} InputBinding(SDL_GamepadAxis a, bool n = false) : kind(Kind::PadAxis), negate(n), axis(a) {} InputBinding(SDL_Scancode k, bool n = false) : kind(Kind::Key), negate(n), key(k) {} + static InputBinding joystickButton(std::string guid, int occurrence, int index) + { + InputBinding binding; + binding.kind = Kind::JoyButton; + binding.deviceGuid = std::move(guid); + binding.deviceOccurrence = occurrence; + binding.controlIndex = index; + return binding; + } + static InputBinding joystickAxis(std::string guid, int occurrence, int index, bool n = false, + AxisMode mode = AxisMode::Signed, int rest = 0, bool positive = true) + { + InputBinding binding; + binding.kind = Kind::JoyAxis; + binding.deviceGuid = std::move(guid); + binding.deviceOccurrence = occurrence; + binding.controlIndex = index; + binding.negate = n; + binding.axisMode = mode; + binding.axisRest = rest; + binding.axisPositive = positive; + return binding; + } + static InputBinding joystickHat(std::string guid, int occurrence, int index, Uint8 mask) + { + InputBinding binding; + binding.kind = Kind::JoyHat; + binding.deviceGuid = std::move(guid); + binding.deviceOccurrence = occurrence; + binding.controlIndex = index; + binding.hatMask = mask; + return binding; + } - bool isAxis() const { return kind == Kind::PadAxis; } + bool isAxis() const { return kind == Kind::PadAxis || kind == Kind::JoyAxis; } bool isKeyboard() const { return kind == Kind::Key; } bool isGamepad() const { return kind == Kind::PadButton || kind == Kind::PadAxis; } + bool isRawDevice() const { return kind == Kind::JoyButton || kind == Kind::JoyAxis || kind == Kind::JoyHat; } bool isNegated() const { return negate; } InputSourceType sourceType() const @@ -111,7 +165,7 @@ struct InputBinding return isKeyboard() ? InputSourceType::Keyboard : InputSourceType::GamePad; } - float read(SDL_Gamepad* gamepad) const + float read(SDL_Gamepad* gamepad, const std::function& joystickForBinding) const { float value = 0.0f; @@ -149,6 +203,46 @@ struct InputBinding value = raw / StickRange; break; } + case Kind::JoyButton: + { + auto* joystick = joystickForBinding(*this); + if (!joystick) + return 0.0f; + value = float(SDL_GetJoystickButton(joystick, controlIndex)); + break; + } + case Kind::JoyAxis: + { + auto* joystick = joystickForBinding(*this); + if (!joystick) + return 0.0f; + const int raw = SDL_GetJoystickAxis(joystick, controlIndex); + if (axisMode == AxisMode::FromRest) + { + const int span = axisPositive ? axisMaximum - axisRest : axisRest - axisMinimum; + const int travel = axisPositive ? raw - axisRest : axisRest - raw; + value = span > 0 ? std::clamp(float(travel) / float(span), 0.0f, 1.0f) : 0.0f; + } + else if (raw >= axisRest) + { + const int span = axisMaximum - axisRest; + value = span > 0 ? std::clamp(float(raw - axisRest) / float(span), 0.0f, 1.0f) : 0.0f; + } + else + { + const int span = axisRest - axisMinimum; + value = span > 0 ? -std::clamp(float(axisRest - raw) / float(span), 0.0f, 1.0f) : 0.0f; + } + break; + } + case Kind::JoyHat: + { + auto* joystick = joystickForBinding(*this); + if (!joystick) + return 0.0f; + value = (SDL_GetJoystickHat(joystick, controlIndex) & hatMask) == hatMask ? 1.0f : 0.0f; + break; + } default: return 0.0f; } @@ -163,10 +257,54 @@ struct InputBinding case Kind::Key: return SDL_GetScancodeName(key); case Kind::PadAxis: return InputNames::displayNameForAxis(axis, padType, negate, isSteerAction); case Kind::PadButton: return InputNames::displayNameForButton(button, padType); + case Kind::JoyAxis: return std::format("Axis {}", controlIndex + 1); + case Kind::JoyButton: return std::format("Button {}", controlIndex + 1); + case Kind::JoyHat: return std::format("Hat {} (0x{:02X})", controlIndex + 1, hatMask); default: return ""; } } + static std::string encodeIniField(std::string_view value) + { + std::string encoded; + for (const unsigned char c : value) + { + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.') + encoded.push_back(char(c)); + else + encoded += std::format("%{:02X}", c); + } + return encoded; + } + + static std::string decodeIniField(std::string_view value) + { + std::string decoded; + for (size_t i = 0; i < value.size(); ++i) + { + if (value[i] == '%' && i + 2 < value.size()) + { + unsigned int byte = 0; + auto [ptr, error] = std::from_chars(value.data() + i + 1, value.data() + i + 3, byte, 16); + if (error == std::errc{} && ptr == value.data() + i + 3) + { + decoded.push_back(char(byte)); + i += 2; + continue; + } + } + decoded.push_back(value[i]); + } + return decoded; + } + + std::string deviceIdentityIni() const + { + return std::format("{}|{}|{}|{}", deviceVendor, deviceProduct, + encodeIniField(deviceSerial), encodeIniField(devicePath)); + } + std::string iniName() const { switch (kind) @@ -174,6 +312,13 @@ struct InputBinding case Kind::Key: return SDL_GetScancodeName(key); case Kind::PadAxis: return InputNames::iniNameForAxis(axis); case Kind::PadButton: return InputNames::iniNameForButton(button); + case Kind::JoyAxis: return std::format("{}|{}|axis|{}|0|{}|{}|{}|{}|{}|{}", + deviceGuid, deviceOccurrence, controlIndex, int(axisMode), axisMinimum, axisRest, axisMaximum, + axisPositive, deviceIdentityIni()); + case Kind::JoyButton: return std::format("{}|{}|button|{}|0|0|-32768|0|32767|1|{}", + deviceGuid, deviceOccurrence, controlIndex, deviceIdentityIni()); + case Kind::JoyHat: return std::format("{}|{}|hat|{}|{}|0|-32768|0|32767|1|{}", + deviceGuid, deviceOccurrence, controlIndex, hatMask, deviceIdentityIni()); default: return ""; } } @@ -185,16 +330,22 @@ class InputAction InputState state_; public: - const InputState& update(SDL_Gamepad* primary_pad) + const InputState& update(SDL_Gamepad* primary_pad, + const std::function& joystickForBinding, + bool positiveOnly = false) { float maxValue = 0.0f; bool isAxisInput = false; InputSourceType lastSource = state_.lastSourceType; - // Read all bindings and take the highest absolute value + // Analog actions need the largest magnitude so signed steering survives. + // Digital actions pass positiveOnly=true: the negative side of an axis is + // inactive for that action and must never mask another +1 button binding. for (const auto& binding : bindings_) { - float currentValue = binding.read(primary_pad); + float currentValue = binding.read(primary_pad, joystickForBinding); + if (positiveOnly && currentValue <= 0.0f) + continue; if (std::abs(currentValue) > std::abs(maxValue)) { maxValue = currentValue; @@ -258,12 +409,30 @@ class InputManager // Which of the three binding tables an action lives in. enum class ActionKind { Volume, Switch, Mod }; + // Every SDL joystick is kept here, including devices SDL does not classify as + // a standard gamepad (wheels, pedal sets, shifters and button boxes). A + // gamepad may also have an SDL_Gamepad handle in controllers; the shared + // instance ID lets the UI and future bindings treat those as one device. + struct InputDevice + { + SDL_JoystickID instanceId = 0; + SDL_Joystick* joystick = nullptr; + bool isGamepad = false; + std::string guid; + int occurrence = 0; + Uint16 vendor = 0; + Uint16 product = 0; + std::string serial; + std::string path; + }; + private: std::array volumeBindings; std::array switchBindings; std::array modBindings; std::mutex mtx; + std::vector devices; std::vector controllers; int primaryControllerIndex = -1; @@ -281,9 +450,9 @@ class InputManager // Switch bitmasks. switch_current/_previous are what the game sees; // switch_overlay is the same data before game-side suppression, so the // overlay can still be driven while the game is deaf. - uint32_t switch_current; - uint32_t switch_previous; - uint32_t switch_overlay; + uint32_t switch_current = 0; + uint32_t switch_previous = 0; + uint32_t switch_overlay = 0; // Mirror of the raw DirectInput masks. Edges are tracked here rather than // read back out of the game's copy, because DInputUpdate rewrites the same @@ -332,7 +501,8 @@ class InputManager "HUD Toggle", "Open Chat", "Music Next", - "Music Previous" + "Music Previous", + "VR Recenter" }; static_assert(std::size(modNames) == size_t(ModAction::Count)); @@ -393,7 +563,10 @@ class InputManager std::lock_guard lock(mtx); // check if we've already seen this controller, SDL sometimes sends two controller added events some reason - if (std::find(controllers.begin(), controllers.end(), controller) != controllers.end()) + if (std::find_if(controllers.begin(), controllers.end(), [instanceId](SDL_Gamepad* existing) + { + return SDL_GetGamepadID(existing) == instanceId; + }) != controllers.end()) { spdlog::warn(__FUNCTION__ "({}): dupe, ignored", instanceId); SDL_CloseGamepad(controller); @@ -407,6 +580,108 @@ class InputManager setPrimaryGamepad(controllers.size() - 1); } + void onJoystickAdded(SDL_JoystickID instanceId) + { + std::lock_guard lock(mtx); + if (std::find_if(devices.begin(), devices.end(), [instanceId](const InputDevice& device) + { + return device.instanceId == instanceId; + }) != devices.end()) + return; + + SDL_Joystick* joystick = SDL_OpenJoystick(instanceId); + if (!joystick) + { + spdlog::error(__FUNCTION__ "({}): failed to open joystick: {}", instanceId, SDL_GetError()); + return; + } + + char guidText[33]{}; + SDL_GUIDToString(SDL_GetJoystickGUID(joystick), guidText, int(std::size(guidText))); + const std::string guid(guidText); + int occurrence = 0; + while (std::any_of(devices.begin(), devices.end(), [&guid, occurrence](const InputDevice& device) + { + return device.guid == guid && device.occurrence == occurrence; + })) + { + ++occurrence; + } + const char* serialText = SDL_GetJoystickSerial(joystick); + const char* pathText = SDL_GetJoystickPath(joystick); + InputDevice device{ instanceId, joystick, SDL_IsGamepad(instanceId), guid, occurrence, + SDL_GetJoystickVendor(joystick), SDL_GetJoystickProduct(joystick), + serialText ? serialText : "", pathText ? pathText : "" }; + devices.push_back(device); + spdlog::info("Input device connected: {} (id {}, {} axes, {} buttons, {} hats, gamepad: {})", + SDL_GetJoystickName(joystick), instanceId, + SDL_GetNumJoystickAxes(joystick), SDL_GetNumJoystickButtons(joystick), + SDL_GetNumJoystickHats(joystick), device.isGamepad); + } + + static bool deviceMatchesBinding(const InputDevice& device, const InputBinding& binding) + { + const bool usbIdentityMatches = (!binding.deviceVendor || device.vendor == binding.deviceVendor) && + (!binding.deviceProduct || device.product == binding.deviceProduct); + if (!usbIdentityMatches) + return false; + + // A real serial is the strongest stable identity. USB paths are not: they + // can change when the same wheel/pedal set is moved to another port or hub. + if (!binding.deviceSerial.empty()) + return device.serial == binding.deviceSerial; + + if (!binding.deviceGuid.empty() && device.guid != binding.deviceGuid) + return false; + if (device.occurrence == binding.deviceOccurrence) + return true; + + // Path is only a duplicate-device tie-breaker/fallback, never a mandatory + // identity for otherwise matching VID/PID/GUID hardware. + return !binding.devicePath.empty() && device.path == binding.devicePath; + } + + SDL_Joystick* joystickForBinding(const InputBinding& binding) const + { + auto it = std::find_if(devices.begin(), devices.end(), [&binding](const InputDevice& device) + { + return deviceMatchesBinding(device, binding); + }); + return it == devices.end() ? nullptr : it->joystick; + } + + const InputDevice* deviceForBinding(const InputBinding& binding) const + { + auto it = std::find_if(devices.begin(), devices.end(), [&binding](const InputDevice& device) + { + return deviceMatchesBinding(device, binding); + }); + return it == devices.end() ? nullptr : &*it; + } + + int deviceMatchCount(const InputBinding& binding) const + { + return int(std::count_if(devices.begin(), devices.end(), [&binding](const InputDevice& device) + { + return deviceMatchesBinding(device, binding); + })); + } + + void onJoystickRemoved(SDL_JoystickID instanceId) + { + std::lock_guard lock(mtx); + auto it = std::find_if(devices.begin(), devices.end(), [instanceId](const InputDevice& device) + { + return device.instanceId == instanceId; + }); + if (it == devices.end()) + return; + + spdlog::info("Input device disconnected: {} (id {})", SDL_GetJoystickName(it->joystick), instanceId); + SDL_CloseJoystick(it->joystick); + devices.erase(it); + } + void onControllerRemoved(SDL_JoystickID instanceId) { spdlog::debug(__FUNCTION__ "(instance {})", instanceId); @@ -420,23 +695,51 @@ class InputManager if (it != controllers.end()) { - Game::CurrentPadType = Game::GamepadType::PC; + const int removedIndex = int(std::distance(controllers.begin(), it)); + const bool removedPrimary = (removedIndex == primaryControllerIndex); SDL_CloseGamepad(*it); controllers.erase(it); spdlog::debug(__FUNCTION__ "(instance {}): removed instance", instanceId); - if (primaryControllerIndex >= controllers.size()) - setPrimaryGamepad(controllers.empty() ? -1 : 0); + if (controllers.empty()) + { + Game::CurrentPadType = Game::GamepadType::PC; + setPrimaryGamepad(-1); + } + else if (removedPrimary) + { + const int replacementIndex = + removedIndex < int(controllers.size()) + ? removedIndex + : int(controllers.size()) - 1; + setPrimaryGamepad(replacementIndex); + } + else if (primaryControllerIndex > removedIndex) + { + --primaryControllerIndex; + if (auto* pad = getPrimaryGamepad()) + setupGamepad(pad); + } } } public: ~InputManager() + { + shutdown(); + } + + void shutdown() { for (auto controller : controllers) SDL_CloseGamepad(controller); + controllers.clear(); + for (auto& device : devices) + SDL_CloseJoystick(device.joystick); + devices.clear(); + primaryControllerIndex = -1; } SDL_Gamepad* getPrimaryGamepad() @@ -463,18 +766,24 @@ class InputManager void init(HWND hwnd); - // An ini written before an action existed has no lines for it, so it loads - // unbound. That is recoverable for every action except the overlay toggle: - // without it there is no way to reach the UI that would rebind it, so it - // always gets its default back. + // Actions introduced after a bindings INI was written load with no entry. + // Keep the overlay reachable and give VR recenter an F12 default that does not collide with legacy HUD F10 + // until the user chooses a custom keyboard/wheel/gamepad binding. void ensureOverlayBindable() { InputAction& overlay = modBindings[size_t(ModAction::OverlayToggle)]; - if (!overlay.bindings().empty()) - return; + if (overlay.bindings().empty()) + { + spdlog::warn(__FUNCTION__ ": overlay toggle had no bindings, restoring F11"); + addModBinding(ModAction::OverlayToggle, SDL_SCANCODE_F11); + } - spdlog::warn(__FUNCTION__ ": overlay toggle had no bindings, restoring F11"); - addModBinding(ModAction::OverlayToggle, SDL_SCANCODE_F11); + InputAction& recenter = modBindings[size_t(ModAction::VRRecenter)]; + if (recenter.bindings().empty()) + { + spdlog::info(__FUNCTION__ ": VR recenter had no binding, restoring F12"); + addModBinding(ModAction::VRRecenter, SDL_SCANCODE_F12); + } } void setupDefaultBindings() @@ -553,6 +862,7 @@ class InputManager // Mod actions. addModBinding(ModAction::OverlayToggle, SDL_SCANCODE_F11); + addModBinding(ModAction::VRRecenter, SDL_SCANCODE_F12); addModBinding(ModAction::OpenChat, SDL_SCANCODE_Y); addModBinding(ModAction::MusicNext, SDL_SCANCODE_X); addModBinding(ModAction::MusicNext, SDL_GAMEPAD_BUTTON_BACK); @@ -668,6 +978,72 @@ class InputManager return std::nullopt; } + // guid|occurrence|axis/button/hat|control-index|hat-mask, followed by + // mode|min|rest|max|positive|vendor|product|serial|path. + static std::optional parseDeviceBindingValue(const std::string& value) + { + std::vector fields; + std::istringstream stream(value); + std::string field; + while (std::getline(stream, field, '|')) + fields.push_back(field); + if (fields.size() < 5) + return std::nullopt; + + try + { + const auto parseBool = [](const std::string& text) + { + if (!stricmp(text.c_str(), "true")) return true; + if (!stricmp(text.c_str(), "false")) return false; + return std::stoi(text) != 0; + }; + const int occurrence = std::stoi(fields[1]); + const int control = std::stoi(fields[3]); + const int hatMask = std::stoi(fields[4]); + if (occurrence < 0 || control < 0) + return std::nullopt; + + std::optional binding; + if (!stricmp(fields[2].c_str(), "axis")) + { + binding = InputBinding::joystickAxis(fields[0], occurrence, control); + if (fields.size() >= 10) + { + binding->axisMode = std::stoi(fields[5]) == int(InputBinding::AxisMode::FromRest) + ? InputBinding::AxisMode::FromRest : InputBinding::AxisMode::Signed; + binding->axisMinimum = std::clamp(std::stoi(fields[6]), -32768, 32767); + binding->axisRest = std::clamp(std::stoi(fields[7]), -32768, 32767); + binding->axisMaximum = std::clamp(std::stoi(fields[8]), -32768, 32767); + // std::format serializes bools as "true"/"false" while older + // binding files used 1/0. Accept both so calibration always + // survives an upgrade and restart. + binding->axisPositive = parseBool(fields[9]); + } + } + else if (!stricmp(fields[2].c_str(), "button")) + binding = InputBinding::joystickButton(fields[0], occurrence, control); + else if (!stricmp(fields[2].c_str(), "hat") && hatMask > 0 && hatMask <= 0xFF) + binding = InputBinding::joystickHat(fields[0], occurrence, control, Uint8(hatMask)); + + if (!binding) + return std::nullopt; + if (fields.size() >= 14) + { + binding->deviceVendor = Uint16(std::clamp(std::stoi(fields[10]), 0, 0xFFFF)); + binding->deviceProduct = Uint16(std::clamp(std::stoi(fields[11]), 0, 0xFFFF)); + binding->deviceSerial = InputBinding::decodeIniField(fields[12]); + binding->devicePath = InputBinding::decodeIniField(fields[13]); + } + return binding; + } + catch (const std::exception&) + { + return std::nullopt; + } + return std::nullopt; + } + void addBinding(const ActionRef& ref, const InputBinding& binding) { switch (ref.kind) @@ -697,34 +1073,45 @@ class InputManager int key_binds = 0; int pad_binds = 0; + int device_binds = 0; for (const auto& entry : entries) { if (!stricmp(entry.section.c_str(), "Keyboard")) key_binds++; else if (!stricmp(entry.section.c_str(), "Gamepad")) pad_binds++; + else if (!stricmp(entry.section.c_str(), "Device")) + device_binds++; } - if (key_binds <= 0 && pad_binds <= 0) + if (key_binds <= 0 && pad_binds <= 0 && device_binds <= 0) { spdlog::error(__FUNCTION__ " - failed to read binds from INI, using defaults"); return false; } - spdlog::info(__FUNCTION__ " - {} key binds, {} pad binds", key_binds, pad_binds); + spdlog::info(__FUNCTION__ " - {} key binds, {} pad binds, {} device binds", key_binds, pad_binds, device_binds); - // we have binds, reset any of our defaults + // Preserve the current working configuration until at least one entry + // has parsed successfully. This makes Load bindings transactional for a + // completely malformed/truncated file. + auto previousVolumeBindings = volumeBindings; + auto previousSwitchBindings = switchBindings; + auto previousModBindings = modBindings; for (auto& binding : volumeBindings) binding.clear(); for (auto& binding : switchBindings) binding.clear(); for (auto& binding : modBindings) binding.clear(); + int loadedBinds = 0; for (const auto& entry : entries) { const bool keyboard = !stricmp(entry.section.c_str(), "Keyboard"); - if (!keyboard && stricmp(entry.section.c_str(), "Gamepad")) + const bool gamepad = !stricmp(entry.section.c_str(), "Gamepad"); + const bool device = !stricmp(entry.section.c_str(), "Device"); + if (!keyboard && !gamepad && !device) continue; // unknown section auto action = parseActionName(entry.key); @@ -734,7 +1121,7 @@ class InputManager continue; } - auto binding = parseBindingValue(entry.value, keyboard); + auto binding = device ? parseDeviceBindingValue(entry.value) : parseBindingValue(entry.value, keyboard); if (!binding) { spdlog::error(__FUNCTION__ ": failed to parse binding for {} = {}", entry.key, entry.value); @@ -742,9 +1129,29 @@ class InputManager } binding->negate = action->negate; + if (binding->negate && !binding->isAxis() && + !(action->kind == ActionRef::Kind::Volume && + action->index == int(ADChannel::Steering))) + { + // A '-' suffix on a button/key cannot select an opposite direction; + // it only turns +1 into -1 and makes digital/pedal actions unusable. + spdlog::warn(__FUNCTION__ ": ignoring invalid negative suffix for non-axis action {}", entry.key); + binding->negate = false; + } addBinding(*action, *binding); + ++loadedBinds; } + if (loadedBinds == 0) + { + volumeBindings = std::move(previousVolumeBindings); + switchBindings = std::move(previousSwitchBindings); + modBindings = std::move(previousModBindings); + spdlog::error(__FUNCTION__ ": no valid bindings parsed; previous bindings preserved"); + return false; + } + + ensureOverlayBindable(); return true; } @@ -755,7 +1162,7 @@ class InputManager { for (const auto& bind : action.bindings()) { - if (bind.isKeyboard() != keyboard) + if ((keyboard && !bind.isKeyboard()) || (!keyboard && !bind.isGamepad())) continue; const std::string direction = bind.isNegated() ? "-" : ""; @@ -773,6 +1180,25 @@ class InputManager writeAction(modNames[i], modBindings[i]); } + void writeDeviceBindingSection(std::ostream& file) + { + auto writeAction = [&](const std::string& name, const InputAction& action) + { + for (const auto& bind : action.bindings()) + { + if (!bind.isRawDevice()) + continue; + file << name << (bind.isNegated() ? "-" : "") << " = " << bind.iniName() << "\n"; + } + }; + for (int i = 0; i < int(std::size(volumeNames)); ++i) + writeAction(volumeNames[i], volumeBindings[i]); + for (int i = 0; i < int(SwitchId::Count); ++i) + writeAction(switchNames[i], switchBindings[i]); + for (int i = 0; i < int(ModAction::Count); ++i) + writeAction(modNames[i], modBindings[i]); + } + bool saveBindingIni(const std::filesystem::path& iniPath) { std::ofstream file(iniPath, std::ios::out | std::ios::trunc); @@ -783,8 +1209,8 @@ class InputManager } file << "# These bindings are used when UseNewInput is enabled inside OutRun2006Tweaks.ini\n"; - file << "# With that enabled, you can use in-game Controls > Configuration dialog to change these during gameplay\n"; - file << "# (editing this file manually can allow more advanced config, such as binding multiple inputs to a single action)\n"; + file << "# Manage these through the in-game Controls > Configuration screen.\n"; + file << "# This file is maintained automatically and should not need manual editing.\n"; file << "# If this file doesn't exist or is empty, bindings will be reset to default.\n"; file << "#\n"; file << "# Actions with a negative symbol after them ('Steering-') either treat the input as a negative value, or only trigger the action on negative inputs\n"; @@ -794,11 +1220,25 @@ class InputManager file << "[Gamepad]\n"; writeBindingSection(file, false); + file << "\n[Device]\n"; + writeDeviceBindingSection(file); file << "\n[Keyboard]\n"; writeBindingSection(file, true); + file.flush(); + if (!file) + { + spdlog::error(__FUNCTION__ ": failed while writing INI file: {}", iniPath.string()); + file.close(); + return false; + } file.close(); + if (file.fail()) + { + spdlog::error(__FUNCTION__ ": failed while closing INI file: {}", iniPath.string()); + return false; + } spdlog::info(__FUNCTION__": saved to INI file: {}", iniPath.string()); return true; @@ -810,6 +1250,12 @@ class InputManager while (SDL_PollEvent(&event)) switch (event.type) { + case SDL_EVENT_JOYSTICK_ADDED: + onJoystickAdded(event.jdevice.which); + break; + case SDL_EVENT_JOYSTICK_REMOVED: + onJoystickRemoved(event.jdevice.which); + break; case SDL_EVENT_GAMEPAD_ADDED: onControllerAdded(event.gdevice.which); break; @@ -827,11 +1273,21 @@ class InputManager // around to pick a bind doesn't steer the car. void updateVolumes(SDL_Gamepad* gamepad) { + const auto resolveJoystick = [this](const InputBinding& binding) + { + return joystickForBinding(binding); + }; for (size_t i = 0; i < volumeBindings.size(); ++i) { - auto& vol = volumeBindings[i].update(gamepad); + auto& vol = volumeBindings[i].update(gamepad, resolveJoystick); if (Overlay::IsBindingDialogActive || Overlay::IsActive) [[unlikely]] + { + // Keep the binding's live state for the UI, but never leave a stale + // throttle/brake/steering command active underneath the overlay. + volumes[i].previousValue = 0.0f; + volumes[i].currentValue = 0.0f; continue; + } volumes[i] = vol; @@ -850,10 +1306,14 @@ class InputManager // Collapses the switch bindings into a bitmask, one bit per SwitchId. uint32_t readSwitchMask(SDL_Gamepad* gamepad) { + const auto resolveJoystick = [this](const InputBinding& binding) + { + return joystickForBinding(binding); + }; uint32_t mask = 0; for (size_t i = 0; i < switchBindings.size(); ++i) { - auto& switchState = switchBindings[i].update(gamepad); + auto& switchState = switchBindings[i].update(gamepad, resolveJoystick, true); if (switchState.isPressed()) { mask |= (1 << i); @@ -917,9 +1377,13 @@ class InputManager void updateModActions(SDL_Gamepad* gamepad) { modActionsDeaf = suppressOverlayUntilRelease || Overlay::IsBindingDialogActive; + const auto resolveJoystick = [this](const InputBinding& binding) + { + return joystickForBinding(binding); + }; for (size_t i = 0; i < modBindings.size(); ++i) - modStates[i] = modBindings[i].update(gamepad); + modStates[i] = modBindings[i].update(gamepad, resolveJoystick, true); } // Rebuilds the raw DirectInput masks from the bindings. Called once per @@ -1022,6 +1486,13 @@ class InputManager InputAction& modAction(ModAction action) { return modBindings[size_t(action)]; } static const std::string& modActionName(ModAction action) { return modNames[size_t(action)]; } + void stopVibration() + { + std::lock_guard lock(mtx); + for (auto* controller : controllers) + SDL_RumbleGamepad(controller, 0, 0, 0); + } + void setVibration(WORD left, WORD right) { auto* controller = getPrimaryGamepad(); @@ -1081,6 +1552,16 @@ class InputManager if (key_state[i]) return true; + for (const auto& device : devices) + { + for (int button = 0; button < SDL_GetNumJoystickButtons(device.joystick); ++button) + if (SDL_GetJoystickButton(device.joystick, button)) + return true; + for (int hat = 0; hat < SDL_GetNumJoystickHats(device.joystick); ++hat) + if (SDL_GetJoystickHat(device.joystick, hat) != SDL_HAT_CENTERED) + return true; + } + auto* controller = getPrimaryGamepad(); if (!controller) return false; @@ -1136,7 +1617,11 @@ class InputManager friend class InputBindingsUI; - static InputManager instance; + // Process-lifetime storage avoids calling SDL's DirectInput backend from a + // C++ global destructor after SDL/Windows have begun their own teardown. + // Normal window close still calls shutdown(); forced ExitProcess cleanup is + // deliberately left to the operating system. + static InputManager& instance; }; constexpr uint32_t StartSwitchMask = 1 << int(SwitchId::Start); @@ -1145,3 +1630,5 @@ void InputManager_Update(); bool InputManager_ModActionHeld(ModAction action); std::string InputManager_ModActionDisplayName(ModAction action); void InputManager_SetVibration(WORD left, WORD right); +void InputManager_Shutdown(); +float InputManager_SteeringValue(); diff --git a/src/overlay/input_bindings_ui.cpp b/src/overlay/input_bindings_ui.cpp index b600aee62..7083ca1e7 100644 --- a/src/overlay/input_bindings_ui.cpp +++ b/src/overlay/input_bindings_ui.cpp @@ -1,6 +1,9 @@ #include "input_manager.hpp" +#include "wheel_profile_store.hpp" +#include "wheel_ffb_runtime.hpp" #include +#include #include #include @@ -78,6 +81,30 @@ class InputBindingsUI : public OverlayWindow { "Tweaks", Mod, int(ModAction::OpenChat) }, { "Tweaks", Mod, int(ModAction::MusicNext) }, { "Tweaks", Mod, int(ModAction::MusicPrevious) }, + { "VR", Mod, int(ModAction::VRRecenter) }, + }; + + struct QuickSetupEntry + { + const char* title; + const char* prompt; + Selection::Kind kind; + int index; + }; + + static inline const QuickSetupEntry QuickSetupSteps[] = { + { "Steering", "Turn the wheel or move the stick you want to steer with.", Vol, int(ADChannel::Steering) }, + { "Accelerator", "Press the accelerator fully.", Vol, int(ADChannel::Acceleration) }, + { "Brake", "Press the brake fully.", Vol, int(ADChannel::Brake) }, + { "Shift Up", "Press the upshift paddle or button.", Sw, int(SwitchId::GearUp) }, + { "Shift Down", "Press the downshift paddle or button.", Sw, int(SwitchId::GearDown) }, + { "Start", "Press the button you want to use for Start and Pause.", Sw, int(SwitchId::Start) }, + { "Confirm", "Press the button you want to use to confirm menu choices.", Sw, int(SwitchId::A) }, + { "Back", "Press the button you want to use to go back.", Sw, int(SwitchId::B) }, + { "Menu Up", "Press Up on the wheel D-pad/POV, or another menu button.", Sw, int(SwitchId::SelectionUp) }, + { "Menu Right", "Press Right on the wheel D-pad/POV, or another menu button.", Sw, int(SwitchId::SelectionRight) }, + { "Menu Down", "Press Down on the wheel D-pad/POV, or another menu button.", Sw, int(SwitchId::SelectionDown) }, + { "Menu Left", "Press Left on the wheel D-pad/POV, or another menu button.", Sw, int(SwitchId::SelectionLeft) }, }; // How far an analog action has to move from rest before its name lights up. @@ -92,10 +119,36 @@ class InputBindingsUI : public OverlayWindow Selection bindTarget; int bindIndex = -1; std::string bindingName; + std::unordered_map> axisBaseline; // Track binding changes (options tab are handled differently) bool unsavedChanges = false; bool confirmingReset = false; + bool confirmingLoad = false; + std::string persistenceStatus; + std::vector wheelProfiles; + int selectedWheelProfile = -1; + char wheelProfileName[65]{}; + bool wheelProfilesLoaded = false; + bool confirmingProfileLoad = false; + bool confirmingProfileDelete = false; + bool confirmingProfileOverwrite = false; + bool calibrationOpen = false; + Selection calibrationTarget; + int calibrationIndex = -1; + int calibrationRest = 0; + int calibrationMinimum = 0; + int calibrationMaximum = 0; + bool quickSetupActive = false; + bool quickSetupComplete = false; + int quickSetupStep = 0; + bool quickSetupPreviousUnsaved = false; + std::vector>> quickSetupBackup; + std::optional quickSetupCandidate; + std::optional releaseGuardBinding; + bool quickSetupTimedOut = false; + std::chrono::steady_clock::time_point quickSetupCaptureDeadline{}; + static constexpr auto QuickSetupCaptureTime = std::chrono::seconds(6); std::vector pendingSettings; @@ -116,6 +169,72 @@ class InputBindingsUI : public OverlayWindow return selection.isVolume() && selection.index == int(ADChannel::Steering); } + // Quick Setup replaces only the source it just captured. For raw SDL + // devices, source family alone is too broad: a wheel, pedal set, shifter, + // button box and mapped gamepad may all be Joy* bindings at the same time. + // Resolve both bindings to their current physical SDL instance and replace + // only bindings from that same device. Disconnected/unresolved bindings are + // deliberately preserved rather than guessed away. + static bool same_control(const InputBinding& a, const InputBinding& b) + { + if (a.kind != b.kind) + return false; + switch (a.kind) + { + case InputBinding::Kind::Key: return a.key == b.key; + case InputBinding::Kind::PadButton: return a.button == b.button; + case InputBinding::Kind::PadAxis: return a.axis == b.axis && a.negate == b.negate; + case InputBinding::Kind::JoyButton: + case InputBinding::Kind::JoyAxis: + case InputBinding::Kind::JoyHat: + { + const auto* da = InputManager::instance.deviceForBinding(a); + const auto* db = InputManager::instance.deviceForBinding(b); + const bool sameDevice = da && db + ? da->instanceId == db->instanceId + : a.deviceGuid == b.deviceGuid && a.deviceOccurrence == b.deviceOccurrence; + return sameDevice && a.controlIndex == b.controlIndex && + (a.kind != InputBinding::Kind::JoyHat || a.hatMask == b.hatMask); + } + default: return false; + } + } + + static std::string binding_conflicts(const InputBinding& candidate, const Selection& target) + { + std::string result; + for (const ActionListEntry& entry : ActionList) + { + const Selection other{ entry.kind, entry.index }; + if (other == target) + continue; + const auto& bindings = action_for(other).bindings(); + if (std::any_of(bindings.begin(), bindings.end(), [&](const InputBinding& b) + { return same_control(b, candidate); })) + { + if (!result.empty()) result += ", "; + result += name_for(other); + } + } + return result; + } + + static bool same_source_family(const InputBinding& existing, const InputBinding& candidate) + { + if (candidate.isRawDevice()) + { + if (!existing.isRawDevice()) + return false; + const auto* existingDevice = InputManager::instance.deviceForBinding(existing); + const auto* candidateDevice = InputManager::instance.deviceForBinding(candidate); + return existingDevice && candidateDevice && + existingDevice->instanceId == candidateDevice->instanceId; + } + if (candidate.isGamepad()) return existing.isGamepad(); + if (candidate.isKeyboard()) return existing.isKeyboard(); + return false; + } + void begin_listening(const Selection& target, int index) { // Waits for the click that got here to be let go of first, otherwise it @@ -124,10 +243,110 @@ class InputBindingsUI : public OverlayWindow bindTarget = target; bindIndex = index; bindingName = name_for(target); + axisBaseline.clear(); + releaseGuardBinding.reset(); + if (quickSetupActive) + { + quickSetupCandidate.reset(); + quickSetupTimedOut = false; + } + for (const auto& device : InputManager::instance.devices) + { + auto& baseline = axisBaseline[device.instanceId]; + const int axisCount = SDL_GetNumJoystickAxes(device.joystick); + baseline.reserve(axisCount); + for (int axis = 0; axis < axisCount; ++axis) + baseline.push_back(SDL_GetJoystickAxis(device.joystick, axis)); + } + } + + void skip_quick_setup_step() + { + if (!quickSetupActive || quickSetupStep >= int(std::size(QuickSetupSteps))) + return; + + ++quickSetupStep; + quickSetupCandidate.reset(); + releaseGuardBinding.reset(); + quickSetupTimedOut = false; + ImGui::CloseCurrentPopup(); + + if (quickSetupStep < int(std::size(QuickSetupSteps))) + begin_listening(quick_setup_selection(quickSetupStep), -1); + else + { + quickSetupActive = false; + quickSetupComplete = true; + isListeningForInput = ListenState::False; + } + } + + static Selection quick_setup_selection(int step) + { + const auto& entry = QuickSetupSteps[step]; + return Selection{ entry.kind, entry.index }; + } + + void restore_quick_setup_backup() + { + for (auto& [selection, bindings] : quickSetupBackup) + action_for(selection).bindings() = bindings; + quickSetupBackup.clear(); + quickSetupActive = false; + quickSetupComplete = false; + quickSetupStep = 0; + quickSetupCandidate.reset(); + releaseGuardBinding.reset(); + quickSetupTimedOut = false; + unsavedChanges = quickSetupPreviousUnsaved; + } + + void start_quick_setup() + { + quickSetupBackup.clear(); + confirmingLoad = false; + persistenceStatus.clear(); + quickSetupPreviousUnsaved = unsavedChanges; + for (int i = 0; i < int(std::size(QuickSetupSteps)); ++i) + { + const Selection selection = quick_setup_selection(i); + if (std::find_if(quickSetupBackup.begin(), quickSetupBackup.end(), [&selection](const auto& saved) + { + return saved.first == selection; + }) == quickSetupBackup.end()) + quickSetupBackup.emplace_back(selection, action_for(selection).bindings()); + } + quickSetupStep = 0; + quickSetupComplete = false; + quickSetupActive = true; + begin_listening(quick_setup_selection(quickSetupStep), -1); } public: void init() override {} + static InputBinding with_device_identity(InputBinding binding, const InputManager::InputDevice& device) + { + binding.deviceVendor = device.vendor; + binding.deviceProduct = device.product; + binding.deviceSerial = device.serial; + binding.devicePath = device.path; + return binding; + } + + void arm_release_guard(const InputBinding& binding) + { + releaseGuardBinding = binding; + if (releaseGuardBinding->kind == InputBinding::Kind::JoyAxis && + releaseGuardBinding->axisMinimum == releaseGuardBinding->axisRest && + releaseGuardBinding->axisMaximum == releaseGuardBinding->axisRest) + { + // Quick Setup stores a zero-span axis until guided calibration. The + // release guard is transient only, so restore the raw SDL range here + // to make read() wait for the physical control to return near rest. + releaseGuardBinding->axisMinimum = -32768; + releaseGuardBinding->axisMaximum = 32767; + } + } // // Listens for any input at all rather than being told up front whether to @@ -142,6 +361,8 @@ class InputBindingsUI : public OverlayWindow if (ImGui::IsKeyPressed(ImGuiKey_Escape)) { + if (quickSetupActive) + restore_quick_setup_backup(); isListeningForInput = ListenState::False; ImGui::CloseCurrentPopup(); return false; @@ -149,6 +370,13 @@ class InputBindingsUI : public OverlayWindow if (ImGui::IsKeyPressed(ImGuiKey_Delete) || ImGui::IsKeyPressed(ImGuiKey_Backspace)) { + if (quickSetupActive) + { + restore_quick_setup_backup(); + isListeningForInput = ListenState::False; + ImGui::CloseCurrentPopup(); + return false; + } const bool removed = bindIndex >= 0 && bindIndex < int(bindings.size()); if (removed) bindings.erase(bindings.begin() + bindIndex); @@ -163,15 +391,34 @@ class InputBindingsUI : public OverlayWindow // what is already bound. const auto commit = [&](const InputBinding& binding) { - if (bindIndex >= 0 && bindIndex < int(bindings.size())) - bindings[bindIndex] = binding; + InputBinding prepared = binding; + if (quickSetupActive && prepared.kind == InputBinding::Kind::JoyAxis) + { + prepared.axisMinimum = prepared.axisRest; + prepared.axisMaximum = prepared.axisRest; + } + + const std::string conflicts = binding_conflicts(prepared, bindTarget); + if (!conflicts.empty()) + persistenceStatus = "Note: this control is also bound to " + conflicts + "."; + if (quickSetupActive) + { + quickSetupCandidate = prepared; + return; + } + else if (bindIndex >= 0 && bindIndex < int(bindings.size())) + bindings[bindIndex] = prepared; else - action.add(binding); + action.add(prepared); + arm_release_guard(prepared); isListeningForInput = ListenState::WaitForBindButtonRelease; ImGui::CloseCurrentPopup(); }; + if (quickSetupCandidate || quickSetupTimedOut) + return false; + // Keyboard { int keyCount = 0; @@ -191,6 +438,48 @@ class InputBindingsUI : public OverlayWindow } // Controller + for (const auto& device : InputManager::instance.devices) + { + for (int button = 0; button < SDL_GetNumJoystickButtons(device.joystick); ++button) + if (SDL_GetJoystickButton(device.joystick, button)) + { + commit(with_device_identity( + InputBinding::joystickButton(device.guid, device.occurrence, button), device)); + return true; + } + + for (int hat = 0; hat < SDL_GetNumJoystickHats(device.joystick); ++hat) + { + const Uint8 value = SDL_GetJoystickHat(device.joystick, hat); + if (value != SDL_HAT_CENTERED) + { + commit(with_device_identity( + InputBinding::joystickHat(device.guid, device.occurrence, hat, value), device)); + return true; + } + } + + const auto baselineIt = axisBaseline.find(device.instanceId); + if (baselineIt == axisBaseline.end()) + continue; + for (int axis = 0; axis < SDL_GetNumJoystickAxes(device.joystick) && axis < int(baselineIt->second.size()); ++axis) + { + const Sint16 current = SDL_GetJoystickAxis(device.joystick, axis); + const int delta = int(current) - int(baselineIt->second[axis]); + if (std::abs(delta) > 16384) + { + const auto mode = is_steering(bindTarget) + ? InputBinding::AxisMode::Signed : InputBinding::AxisMode::FromRest; + commit(with_device_identity( + InputBinding::joystickAxis(device.guid, device.occurrence, axis, false, + mode, baselineIt->second[axis], delta > 0), device)); + return true; + } + } + } + + // Legacy gamepad capture remains as a fallback when SDL exposes no raw + // joystick handle for the selected gamepad. if (auto* controller = InputManager::instance.getPrimaryGamepad()) { for (int i = SDL_GAMEPAD_BUTTON_SOUTH; i < SDL_GAMEPAD_BUTTON_COUNT; i++) @@ -219,6 +508,99 @@ class InputBindingsUI : public OverlayWindow } private: + void begin_calibration(const Selection& target, int index) + { + auto& bindings = action_for(target).bindings(); + if (index < 0 || index >= int(bindings.size()) || bindings[index].kind != InputBinding::Kind::JoyAxis) + return; + auto* joystick = InputManager::instance.joystickForBinding(bindings[index]); + if (!joystick) + return; + + calibrationTarget = target; + calibrationIndex = index; + calibrationRest = SDL_GetJoystickAxis(joystick, bindings[index].controlIndex); + calibrationMinimum = calibrationRest; + calibrationMaximum = calibrationRest; + calibrationOpen = true; + } + + void draw_calibration_popup() + { + if (!calibrationOpen) + return; + ImGui::OpenPopup("Calibrate axis"); + if (!ImGui::BeginPopupModal("Calibrate axis", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) + return; + + auto& bindings = action_for(calibrationTarget).bindings(); + if (calibrationIndex < 0 || calibrationIndex >= int(bindings.size())) + { + calibrationOpen = false; + ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + return; + } + + auto& binding = bindings[calibrationIndex]; + auto* joystick = InputManager::instance.joystickForBinding(binding); + if (!joystick) + { + ImGui::TextWrapped("Reconnect this device to continue calibration."); + } + else + { + const int current = SDL_GetJoystickAxis(joystick, binding.controlIndex); + calibrationMinimum = (std::min)(calibrationMinimum, current); + calibrationMaximum = (std::max)(calibrationMaximum, current); + + ImGui::TextWrapped(binding.axisMode == InputBinding::AxisMode::Signed + ? "Leave the wheel centered and set its center. Then turn fully left and fully right." + : "Release the pedal and set its resting position. Then press it fully and release it."); + ImGui::Spacing(); + ImGui::ProgressBar((current + 32768.0f) / 65535.0f, ImVec2(320.0f, 0), + std::format("Current: {}", current).c_str()); + ImGui::TextDisabled("Detected range: %d to %d", calibrationMinimum, calibrationMaximum); + + if (ImGui::Button(binding.axisMode == InputBinding::AxisMode::Signed ? "Set center" : "Set resting position")) + { + calibrationRest = current; + calibrationMinimum = current; + calibrationMaximum = current; + } + + const int negativeTravel = calibrationRest - calibrationMinimum; + const int positiveTravel = calibrationMaximum - calibrationRest; + const bool enoughTravel = binding.axisMode == InputBinding::AxisMode::Signed + ? negativeTravel > 4096 && positiveTravel > 4096 + : (std::max)(negativeTravel, positiveTravel) > 4096; + + ImGui::SameLine(); + if (!enoughTravel) + ImGui::BeginDisabled(); + if (ImGui::Button("Save calibration")) + { + binding.axisMinimum = calibrationMinimum; + binding.axisRest = calibrationRest; + binding.axisMaximum = calibrationMaximum; + binding.axisPositive = positiveTravel >= negativeTravel; + unsavedChanges = true; + calibrationOpen = false; + ImGui::CloseCurrentPopup(); + } + if (!enoughTravel) + ImGui::EndDisabled(); + } + + ImGui::SameLine(); + if (ImGui::Button("Cancel")) + { + calibrationOpen = false; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + // Left pane: every action, grouped, with the ones currently reading input // picked out. Watching a name light up is how a binding gets verified // without leaving the screen. @@ -271,9 +653,10 @@ class InputBindingsUI : public OverlayWindow int removeIndex = -1; - if (ImGui::BeginTable("##bindings", 3, ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingFixedFit)) + if (ImGui::BeginTable("##bindings", 4, ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingFixedFit)) { ImGui::TableSetupColumn("##input", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("##calibrate", ImGuiTableColumnFlags_WidthFixed); ImGui::TableSetupColumn("##invert", ImGuiTableColumnFlags_WidthFixed); ImGui::TableSetupColumn("##remove", ImGuiTableColumnFlags_WidthFixed); @@ -287,25 +670,68 @@ class InputBindingsUI : public OverlayWindow // The binding's own name is the rebind button, so there is no // separate control for the most common thing to want. ImGui::TableNextColumn(); + std::string sourceName = binding.isKeyboard() ? "Keyboard" : "Gamepad"; + if (binding.isRawDevice()) + { + if (const auto* device = InputManager::instance.deviceForBinding(binding)) + { + const char* name = SDL_GetJoystickName(device->joystick); + sourceName = name && name[0] ? name : "USB device"; + if (InputManager::instance.deviceMatchCount(binding) > 1) + sourceName += " - choose device"; + } + else + sourceName = "Reconnect device"; + } const std::string label = std::format("{} ({})", - binding.displayName(padType, steering), - binding.isKeyboard() ? "keyboard" : "controller"); + binding.displayName(padType, steering), sourceName); + const bool listeningHere = isListeningForInput != ListenState::False && + !quickSetupActive && bindTarget == selected && bindIndex == i; + if (listeningHere) + { + ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyle().Colors[ImGuiCol_Text]); + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyle().Colors[ImGuiCol_WindowBg]); + } if (ImGui::Button(label.c_str(), ImVec2(-FLT_MIN, 0))) begin_listening(selected, i); + if (listeningHere) + ImGui::PopStyleColor(2); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Rebind this input"); + ImGui::TableNextColumn(); + if (binding.kind == InputBinding::Kind::JoyAxis) + { + if (ImGui::Button("Calibrate")) + begin_calibration(selected, i); + } + // Inverting is the only way to reach the '-' suffix the INI // format has always had: it sends an analog action the opposite // direction, and makes a digital action fire on negative input. ImGui::TableNextColumn(); - if (ImGui::Checkbox("##invert", &binding.negate)) - unsavedChanges = true; - if (ImGui::IsItemHovered()) - ImGui::SetTooltip(steering - ? "Steer the other way with this input" - : "Invert this input"); + if (binding.kind == InputBinding::Kind::JoyAxis && binding.axisMode == InputBinding::AxisMode::FromRest) + { + ImGui::TextDisabled("Auto"); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Pedal direction is detected automatically during calibration"); + } + else if (steering || binding.isAxis()) + { + if (ImGui::Checkbox("##invert", &binding.negate)) + unsavedChanges = true; + if (ImGui::IsItemHovered()) + ImGui::SetTooltip(steering + ? "Steer the other way with this input" + : "Use the opposite side of this axis"); + } + else + { + ImGui::TextDisabled("-"); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Invert is only meaningful for axes or steering-direction buttons"); + } ImGui::TableNextColumn(); if (ImGui::Button("X")) @@ -328,8 +754,17 @@ class InputBindingsUI : public OverlayWindow if (bindings.empty()) ImGui::TextDisabled("Nothing bound."); - if (ImGui::Button("+ Add binding")) + const bool listeningForNew = isListeningForInput != ListenState::False && + !quickSetupActive && bindTarget == selected && bindIndex == -1; + if (listeningForNew) + { + ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyle().Colors[ImGuiCol_Text]); + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyle().Colors[ImGuiCol_WindowBg]); + } + if (ImGui::Button(listeningForNew ? "LISTENING...##addBinding" : "+ Add binding")) begin_listening(selected, -1); + if (listeningForNew) + ImGui::PopStyleColor(2); // Live value of the selected action. const float value = action.getState().currentValue; @@ -339,31 +774,77 @@ class InputBindingsUI : public OverlayWindow ImGui::TextDisabled("Reading"); ImGui::ProgressBar(std::clamp(filled, 0.0f, 1.0f), ImVec2(-FLT_MIN, 0), std::format("{:.2f}", value).c_str()); + } void draw_controllers() { auto& manager = InputManager::instance; - if (manager.controllers.empty()) + if (manager.devices.empty()) { - ImGui::TextDisabled("No controllers detected."); + ImGui::TextDisabled("No input devices detected."); + ImGui::Spacing(); + ImGui::TextWrapped("Connect a controller, wheel, pedal set or shifter. Devices appear here automatically."); + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextDisabled("Multi-device input architecture adapted from hyp36rmax (MIT)"); return; } - for (size_t i = 0; i < manager.controllers.size(); i++) + ImGui::Text("%d input device%s detected", int(manager.devices.size()), manager.devices.size() == 1 ? "" : "s"); + ImGui::TextDisabled("Move a control to verify that OutRun can see it."); + ImGui::Spacing(); + + for (const auto& device : manager.devices) { - auto* controller = manager.controllers[i]; - const bool primary = int(i) == manager.primaryControllerIndex; + SDL_Joystick* joystick = device.joystick; + const char* deviceName = SDL_GetJoystickName(joystick); + if (!deviceName || !deviceName[0]) + deviceName = "Unknown input device"; + + ImGui::PushID(int(device.instanceId)); + if (ImGui::TreeNodeEx("##device", ImGuiTreeNodeFlags_DefaultOpen, + "%s [%s]", deviceName, device.isGamepad ? "Gamepad" : "USB device")) + { + const int axisCount = SDL_GetNumJoystickAxes(joystick); + const int buttonCount = SDL_GetNumJoystickButtons(joystick); + const int hatCount = SDL_GetNumJoystickHats(joystick); + ImGui::TextDisabled("%d axes | %d buttons | %d hats", axisCount, buttonCount, hatCount); - ImGui::PushID(int(i)); - if (ImGui::RadioButton(SDL_GetGamepadName(controller), primary)) - manager.setPrimaryGamepad(i); + for (int axis = 0; axis < axisCount; ++axis) + { + const float value = SDL_GetJoystickAxis(joystick, axis) / 32768.0f; + ImGui::Text("Axis %d", axis + 1); + ImGui::SameLine(); + ImGui::ProgressBar((value + 1.0f) * 0.5f, ImVec2(-FLT_MIN, 0), + std::format("{:.2f}", value).c_str()); + } + + bool anyButton = false; + for (int button = 0; button < buttonCount; ++button) + if (SDL_GetJoystickButton(joystick, button)) + { + if (!anyButton) + ImGui::Text("Pressed:"); + ImGui::SameLine(); + ImGui::Text("%d", button + 1); + anyButton = true; + } + if (!anyButton && buttonCount > 0) + ImGui::TextDisabled("Press a button to test it"); + + for (int hat = 0; hat < hatCount; ++hat) + ImGui::Text("Hat %d: 0x%02X", hat + 1, SDL_GetJoystickHat(joystick, hat)); + + ImGui::TreePop(); + } ImGui::PopID(); } ImGui::Spacing(); - ImGui::TextDisabled("Bindings apply to whichever controller is selected."); + ImGui::Separator(); + ImGui::TextDisabled("Multi-device input architecture adapted from hyp36rmax (MIT)"); } // These are tweaks settings rather than bindings, so they go to the tweaks INI @@ -389,10 +870,227 @@ class InputBindingsUI : public OverlayWindow Settings::write(Module::UserIniPath); } + + void refresh_wheel_profiles(const std::string& selectName = {}) + { + wheelProfiles = WheelProfileStore::list_profiles(WheelProfileStore::Kind::Input); + selectedWheelProfile = -1; + const std::string wanted = WheelProfileStore::normalize_profile_name(selectName); + if (!wanted.empty()) + { + for (size_t i = 0; i < wheelProfiles.size(); ++i) + if (WheelProfileStore::lower_ascii(wheelProfiles[i]) == WheelProfileStore::lower_ascii(wanted)) + { + selectedWheelProfile = int(i); + break; + } + } + if (selectedWheelProfile >= 0) + strncpy_s(wheelProfileName, wheelProfiles[selectedWheelProfile].c_str(), sizeof(wheelProfileName) - 1); + wheelProfilesLoaded = true; + } + + const std::string* selected_wheel_profile() const + { + return selectedWheelProfile >= 0 && selectedWheelProfile < int(wheelProfiles.size()) + ? &wheelProfiles[selectedWheelProfile] : nullptr; + } + + bool wheel_profile_exists_cached(const std::string& name) const + { + const std::string wanted = WheelProfileStore::lower_ascii(name); + return std::any_of(wheelProfiles.begin(), wheelProfiles.end(), [&](const std::string& profile) + { + return WheelProfileStore::lower_ascii(profile) == wanted; + }); + } + + void draw_profiles() + { + auto& manager = InputManager::instance; + if (!wheelProfilesLoaded) + refresh_wheel_profiles(); + + ImGui::TextWrapped( + "Named wheel profiles store the complete multi-device binding set, so a wheel can stay paired with its pedals, shifter and button box. Steering deadzone, sensitivity bypass and input backend are stored with the profile too."); + ImGui::TextDisabled("Loading a profile also updates OutRun2006Tweaks.input.ini, so it remains active after restart."); + ImGui::Spacing(); + + const std::string* selectedProfile = selected_wheel_profile(); + const char* preview = selectedProfile ? selectedProfile->c_str() : "Select a saved wheel profile"; + if (ImGui::BeginCombo("Saved wheel profile", preview)) + { + for (size_t i = 0; i < wheelProfiles.size(); ++i) + { + const bool selectedNow = int(i) == selectedWheelProfile; + if (ImGui::Selectable(wheelProfiles[i].c_str(), selectedNow)) + { + selectedWheelProfile = int(i); + strncpy_s(wheelProfileName, wheelProfiles[i].c_str(), sizeof(wheelProfileName) - 1); + confirmingProfileLoad = false; + confirmingProfileDelete = false; + confirmingProfileOverwrite = false; + } + if (selectedNow) + ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + + if (ImGui::InputText("Profile name", wheelProfileName, sizeof(wheelProfileName))) + confirmingProfileOverwrite = false; + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Letters, numbers, spaces, '.', '_' and '-' are supported. '.ini' is optional."); + + const std::string requestedName = WheelProfileStore::normalize_profile_name(wheelProfileName); + const bool profileAlreadyExists = + !requestedName.empty() && wheel_profile_exists_cached(requestedName); + const char* saveLabel = confirmingProfileOverwrite + ? "Confirm overwrite##inputProfileSave" + : (profileAlreadyExists ? "Overwrite profile##inputProfileSave" : "Save as profile##inputProfileSave"); + if (ImGui::Button(saveLabel)) + { + std::string error; + const bool existsOnDisk = !requestedName.empty() && + WheelProfileStore::profile_exists(WheelProfileStore::Kind::Input, requestedName); + if (existsOnDisk && !confirmingProfileOverwrite) + { + confirmingProfileOverwrite = true; + persistenceStatus = "Click Confirm overwrite to replace the existing wheel profile."; + } + else if (auto profilePath = WheelProfileStore::profile_path( + WheelProfileStore::Kind::Input, requestedName, &error)) + { + const auto stagedPath = WheelProfileStore::staged_profile_path(*profilePath); + { + std::error_code ignored; + std::filesystem::remove(stagedPath, ignored); + } + if (!manager.saveBindingIni(stagedPath)) + { + persistenceStatus = "Could not stage wheel profile bindings."; + } + else if (!WheelProfileStore::append_input_options(stagedPath, &error)) + { + std::error_code ignored; + std::filesystem::remove(stagedPath, ignored); + persistenceStatus = error; + } + else if (!WheelProfileStore::commit_staged_profile(stagedPath, *profilePath, &error)) + { + std::error_code ignored; + std::filesystem::remove(stagedPath, ignored); + persistenceStatus = error; + } + else + { + const bool currentSaved = manager.saveBindingIni(Module::BindingsIniPath); + const bool optionsSaved = Settings::write(Module::UserIniPath); + unsavedChanges = !currentSaved; + persistenceStatus = currentSaved && optionsSaved + ? "Wheel profile saved and made current: " + requestedName + : "Wheel profile saved, but part of the current configuration could not be persisted."; + confirmingProfileOverwrite = false; + refresh_wheel_profiles(requestedName); + } + } + else + persistenceStatus = error; + } + + ImGui::SameLine(); + const bool canLoadProfile = selected_wheel_profile() != nullptr; + if (!canLoadProfile) ImGui::BeginDisabled(); + const char* loadLabel = unsavedChanges && confirmingProfileLoad + ? "Discard edits & load profile?##inputProfileLoad" : "Load selected##inputProfileLoad"; + if (ImGui::Button(loadLabel)) + { + if (unsavedChanges && !confirmingProfileLoad) + { + confirmingProfileLoad = true; + persistenceStatus = "Click again to discard unsaved binding edits and load the selected wheel profile."; + } + else if (const std::string* selected = selected_wheel_profile()) + { + std::string error; + auto profilePath = WheelProfileStore::profile_path(WheelProfileStore::Kind::Input, *selected, &error); + const auto optionBackup = WheelProfileStore::capture_input_options(); + if (!profilePath || !WheelProfileStore::load_input_options(*profilePath, &error)) + { + persistenceStatus = error.empty() ? "Could not load wheel profile options." : error; + } + else if (!manager.readBindingIni(*profilePath)) + { + WheelProfileStore::restore_input_options(optionBackup); + persistenceStatus = "Could not load wheel profile bindings; current bindings were preserved."; + } + else + { + const bool currentSaved = manager.saveBindingIni(Module::BindingsIniPath); + const bool optionsSaved = Settings::write(Module::UserIniPath); + WheelFFB_ResetDirectionTest(); + unsavedChanges = !currentSaved; + persistenceStatus = currentSaved && optionsSaved + ? "Wheel profile loaded and made current: " + *selected + : "Wheel profile is active now, but part of it could not be persisted for restart."; + if (Settings::InputBackend.changed_since_startup()) + persistenceStatus += " Restart the game to apply its input backend."; + } + confirmingProfileLoad = false; + } + } + if (!canLoadProfile) ImGui::EndDisabled(); + + ImGui::SameLine(); + if (!canLoadProfile) ImGui::BeginDisabled(); + const char* deleteLabel = confirmingProfileDelete + ? "Confirm delete##inputProfileDelete" : "Delete selected##inputProfileDelete"; + if (ImGui::Button(deleteLabel)) + { + if (!confirmingProfileDelete) + { + confirmingProfileDelete = true; + persistenceStatus = "Click Confirm delete to remove the selected profile file. Current bindings will not change."; + } + else if (const std::string* selected = selected_wheel_profile()) + { + std::string error; + if (WheelProfileStore::delete_profile(WheelProfileStore::Kind::Input, *selected, &error)) + { + persistenceStatus = "Deleted wheel profile: " + *selected; + wheelProfileName[0] = '\0'; + refresh_wheel_profiles(); + } + else + persistenceStatus = error; + confirmingProfileDelete = false; + } + } + if (!canLoadProfile) ImGui::EndDisabled(); + + ImGui::SameLine(); + if (ImGui::Button("Refresh profile list")) + refresh_wheel_profiles(selected_wheel_profile() ? *selected_wheel_profile() : std::string{}); + + ImGui::Spacing(); + ImGui::TextDisabled("Profile folder: OutRun2006Tweaks.profiles\\Input"); + } + void draw_options() { auto& manager = InputManager::instance; + ImGui::TextUnformatted("Controller compatibility"); + const char* inputBackends[] = { "Automatic (recommended)", "Raw Input", "DirectInput (wheels)", "XInput" }; + if (ImGui::Combo("Input backend", Settings::InputBackend.ptr(), inputBackends, IM_ARRAYSIZE(inputBackends))) + setting_changed(Settings::InputBackend); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Use DirectInput when a wheel is listed but its axes or buttons do not respond."); + ImGui::TextDisabled("Restart the game after changing the input backend."); + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + const char* vibrationModes[] = { "Disabled", "Enabled", "Swap L/R", "Merge L/R" }; if (ImGui::Combo("Vibration Mode", Settings::VibrationMode.ptr(), vibrationModes, IM_ARRAYSIZE(vibrationModes))) setting_changed(Settings::VibrationMode); @@ -402,7 +1100,7 @@ class InputBindingsUI : public OverlayWindow setting_changed(Settings::ImpulseVibrationMode); int deadzonePercent = int(Settings::SteeringDeadZone * 100.f); - if (ImGui::SliderInt("Steering Deadzone", &deadzonePercent, 5, 20, "%d%%")) + if (ImGui::SliderInt("Steering Deadzone", &deadzonePercent, 0, 20, "%d%%")) { Settings::SteeringDeadZone = float(deadzonePercent) / 100.f; setting_changed(Settings::SteeringDeadZone); @@ -415,7 +1113,6 @@ class InputBindingsUI : public OverlayWindow "Only used when UseNewInput is enabled."); } - // The prompt shown while an input is being waited on. void draw_listening_popup() { if (isListeningForInput == ListenState::False) @@ -426,31 +1123,277 @@ class InputBindingsUI : public OverlayWindow if (isListeningForInput == ListenState::WaitForButtonRelease) { if (!manager.anyInputPressed()) + { isListeningForInput = ListenState::Listening; + if (quickSetupActive) + quickSetupCaptureDeadline = std::chrono::steady_clock::now() + QuickSetupCaptureTime; + } return; } if (isListeningForInput == ListenState::WaitForBindButtonRelease) { - if (!manager.anyInputPressed()) - isListeningForInput = ListenState::False; + bool capturedReleased = true; + if (releaseGuardBinding) + { + const auto resolveJoystick = [&manager](const InputBinding& binding) + { + return manager.joystickForBinding(binding); + }; + capturedReleased = std::abs(releaseGuardBinding->read( + manager.getPrimaryGamepad(), resolveJoystick)) < 0.25f; + } + + if (capturedReleased && !manager.anyInputPressed()) + { + releaseGuardBinding.reset(); + if (quickSetupActive && quickSetupStep < int(std::size(QuickSetupSteps))) + begin_listening(quick_setup_selection(quickSetupStep), -1); + else + { + isListeningForInput = ListenState::False; + if (quickSetupActive) + { + quickSetupActive = false; + quickSetupComplete = true; + } + } + } return; } ImGui::OpenPopup("Listening for Input"); if (ImGui::BeginPopupModal("Listening for Input", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { - ImGui::Text("Press any input to bind to %s", bindingName.c_str()); + if (quickSetupActive && quickSetupStep < int(std::size(QuickSetupSteps))) + { + const auto& step = QuickSetupSteps[quickSetupStep]; + ImGui::TextDisabled("Quick Setup | Step %d of %d", quickSetupStep + 1, int(std::size(QuickSetupSteps))); + ImGui::SeparatorText(step.title); + ImGui::TextWrapped("%s", step.prompt); + if (!quickSetupCandidate && !quickSetupTimedOut) + { + const auto remaining = std::chrono::duration(quickSetupCaptureDeadline - std::chrono::steady_clock::now()).count(); + if (remaining <= 0.f) + quickSetupTimedOut = true; + else + { + ImGui::ProgressBar(remaining / 6.f, ImVec2(320.f, 0.f), std::format("{:.1f} seconds", remaining).c_str()); + ImGui::TextDisabled("Only the first deliberate input will be proposed."); + } + } + } + else + ImGui::Text("Press any input to bind to %s", bindingName.c_str()); ImGui::Spacing(); - ImGui::TextDisabled("Escape to cancel, Delete to clear"); + if (quickSetupActive && quickSetupCandidate) + { + ImGui::SeparatorText("Confirm input"); + ImGui::Text("Detected: %s", quickSetupCandidate->displayName().c_str()); + if (const auto* device = manager.deviceForBinding(*quickSetupCandidate)) + ImGui::TextDisabled("Device: %s", SDL_GetJoystickName(device->joystick)); + const std::string conflicts = binding_conflicts(*quickSetupCandidate, bindTarget); + if (!conflicts.empty()) + ImGui::TextColored(ImVec4(1.0f, 0.70f, 0.20f, 1.0f), + "Also bound to: %s", conflicts.c_str()); + ImGui::TextWrapped("Confirm this input before Quick Setup moves to the next control."); + if (ImGui::Button("Use this input")) + { + auto& bindings = action_for(bindTarget).bindings(); + std::erase_if(bindings, [&](const InputBinding& existing) + { return same_source_family(existing, *quickSetupCandidate); }); + action_for(bindTarget).add(*quickSetupCandidate); + arm_release_guard(*quickSetupCandidate); + quickSetupCandidate.reset(); + ++quickSetupStep; + unsavedChanges = true; + isListeningForInput = ListenState::WaitForBindButtonRelease; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Try again")) + { + arm_release_guard(*quickSetupCandidate); + quickSetupCandidate.reset(); + isListeningForInput = ListenState::WaitForBindButtonRelease; + ImGui::CloseCurrentPopup(); + } + } + else if (quickSetupActive && quickSetupTimedOut) + { + ImGui::TextWrapped("No deliberate input was detected. Retry this control or skip it and keep its existing bindings."); + if (ImGui::Button("Try this step again")) + { + begin_listening(bindTarget, -1); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Skip this step")) + skip_quick_setup_step(); + } + else + { + ImGui::TextDisabled(quickSetupActive ? "Escape to cancel Quick Setup" : "Escape to cancel, Delete to clear"); + if (HandleNewBinding()) + unsavedChanges = true; + if (quickSetupActive && !quickSetupCandidate) + { + ImGui::SameLine(); + if (ImGui::Button("Skip this step")) + skip_quick_setup_step(); + } + } - if (HandleNewBinding()) - unsavedChanges = true; + if (quickSetupActive && (quickSetupCandidate || quickSetupTimedOut)) + { + ImGui::TextDisabled("Escape to cancel Quick Setup"); + if (ImGui::IsKeyPressed(ImGuiKey_Escape)) + { + restore_quick_setup_backup(); + isListeningForInput = ListenState::False; + ImGui::CloseCurrentPopup(); + } + } ImGui::EndPopup(); } } + int first_raw_axis_binding(const Selection& selection) const + { + const auto& bindings = action_for(selection).bindings(); + for (int i = 0; i < int(bindings.size()); ++i) + if (bindings[i].kind == InputBinding::Kind::JoyAxis) + return i; + return -1; + } + + bool axis_calibrated(const Selection& selection) const + { + const int index = first_raw_axis_binding(selection); + if (index < 0) + return true; // SDL gamepad axes already use normalized platform ranges. + const auto& binding = action_for(selection).bindings()[index]; + const int negative = binding.axisRest - binding.axisMinimum; + const int positive = binding.axisMaximum - binding.axisRest; + return binding.axisMode == InputBinding::AxisMode::Signed + ? negative > 4096 && positive > 4096 + : (std::max)(negative, positive) > 4096; + } + + void quick_calibration_row(const char* label, const Selection& selection) + { + const int index = first_raw_axis_binding(selection); + if (index < 0) + { + ImGui::TextDisabled("%s: platform/gamepad calibration", label); + return; + } + const bool ready = axis_calibrated(selection); + ImGui::TextColored(ready ? ImVec4(0.35f, 0.90f, 0.45f, 1.0f) : ImVec4(1.0f, 0.70f, 0.20f, 1.0f), + "%s: %s", label, ready ? "calibrated" : "needs calibration"); + ImGui::SameLine(); + ImGui::PushID(label); + if (ImGui::SmallButton(ready ? "Recalibrate" : "Calibrate now")) + begin_calibration(selection, index); + ImGui::PopID(); + } + + void draw_quick_setup_complete(bool& dialogOpen) + { + if (!quickSetupComplete) + return; + ImGui::OpenPopup("Test your driving controls"); + if (!ImGui::BeginPopupModal("Test your driving controls", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) + return; + + ImGui::TextWrapped("Move the wheel, accelerator and brake. If everything responds correctly, save and drive."); + ImGui::Spacing(); + const float steering = action_for({ Vol, int(ADChannel::Steering) }).getState().currentValue; + const float accelerator = action_for({ Vol, int(ADChannel::Acceleration) }).getState().currentValue; + const float brake = action_for({ Vol, int(ADChannel::Brake) }).getState().currentValue; + ImGui::Text("Steering"); + ImGui::SameLine(); + ImGui::ProgressBar(std::clamp((steering + 1.0f) * 0.5f, 0.0f, 1.0f), ImVec2(280.0f, 0), + std::format("{:.2f}", steering).c_str()); + ImGui::Text("Accelerator"); + ImGui::SameLine(); + ImGui::ProgressBar(std::clamp(accelerator, 0.0f, 1.0f), ImVec2(280.0f, 0), + std::format("{:.2f}", accelerator).c_str()); + ImGui::Text("Brake"); + ImGui::SameLine(); + ImGui::ProgressBar(std::clamp(brake, 0.0f, 1.0f), ImVec2(280.0f, 0), + std::format("{:.2f}", brake).c_str()); + + ImGui::SeparatorText("Guided axis calibration"); + ImGui::TextWrapped("Finish wheel center/end-stops and pedal rest/full-travel here before saving. This keeps calibration inside Quick Setup instead of hiding it in the manual editor."); + quick_calibration_row("Steering", { Vol, int(ADChannel::Steering) }); + quick_calibration_row("Accelerator", { Vol, int(ADChannel::Acceleration) }); + quick_calibration_row("Brake", { Vol, int(ADChannel::Brake) }); + const Selection steeringSelection{ Vol, int(ADChannel::Steering) }; + const Selection acceleratorSelection{ Vol, int(ADChannel::Acceleration) }; + const Selection brakeSelection{ Vol, int(ADChannel::Brake) }; + const bool coreControlsPresent = + !action_for(steeringSelection).bindings().empty() && + !action_for(acceleratorSelection).bindings().empty() && + !action_for(brakeSelection).bindings().empty(); + const bool guidedCalibrationReady = + axis_calibrated(steeringSelection) && + axis_calibrated(acceleratorSelection) && + axis_calibrated(brakeSelection); + const bool readyToSaveAndDrive = coreControlsPresent && guidedCalibrationReady; + if (!coreControlsPresent) + ImGui::TextColored(ImVec4(1.0f, 0.70f, 0.20f, 1.0f), + "Steering, accelerator and brake need bindings before Save & Drive."); + else if (!guidedCalibrationReady) + ImGui::TextColored(ImVec4(1.0f, 0.70f, 0.20f, 1.0f), + "Calibrate the raw wheel/pedal axes above before Save & Drive."); + + ImGui::Spacing(); + if (!readyToSaveAndDrive) ImGui::BeginDisabled(); + const bool saveAndDrive = ImGui::Button("Save & Drive"); + if (!readyToSaveAndDrive) ImGui::EndDisabled(); + if (saveAndDrive) + { + if (InputManager::instance.saveBindingIni(Module::BindingsIniPath)) + { + quickSetupBackup.clear(); + quickSetupComplete = false; + unsavedChanges = false; + confirmingLoad = false; + persistenceStatus = "Bindings saved."; + dialogOpen = false; + ImGui::CloseCurrentPopup(); + } + else + persistenceStatus = "Could not save bindings. Check folder permissions and OutRun2006Tweaks.log."; + } + ImGui::SameLine(); + if (ImGui::Button("Keep & Fine-tune")) + { + quickSetupBackup.clear(); + quickSetupComplete = false; + unsavedChanges = true; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Start Over")) + { + restore_quick_setup_backup(); + start_quick_setup(); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) + { + restore_quick_setup_backup(); + ImGui::CloseCurrentPopup(); + } + if (!persistenceStatus.empty()) + ImGui::TextWrapped("%s", persistenceStatus.c_str()); + ImGui::EndPopup(); + } + public: void render(bool overlayEnabled) override { @@ -489,18 +1432,55 @@ class InputBindingsUI : public OverlayWindow ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove)) { + ImGui::TextWrapped( + "Multi-device input setup. With UseNewInput enabled, steering, pedals, buttons, menu controls and calibration are saved and applied only from Input Bindings."); + ImGui::TextDisabled("Force feedback is configured separately in the Force Feedback tab."); + ImGui::Separator(); + + if (ImGui::Button("Quick Setup")) + start_quick_setup(); + + ImGui::SameLine(); if (ImGui::Button(unsavedChanges ? "Save bindings*##save" : "Save bindings##save")) + { if (manager.saveBindingIni(Module::BindingsIniPath)) + { unsavedChanges = false; + confirmingLoad = false; + persistenceStatus = "Bindings saved."; + } + else + persistenceStatus = "Could not save bindings. Check folder permissions and OutRun2006Tweaks.log."; + } ImGui::SameLine(); - if (ImGui::Button("Load bindings")) - if (manager.readBindingIni(Module::BindingsIniPath)) - unsavedChanges = false; + const char* loadLabel = unsavedChanges && confirmingLoad + ? "Discard edits & load?##load" : "Load bindings##load"; + if (ImGui::Button(loadLabel)) + { + if (unsavedChanges && !confirmingLoad) + { + confirmingLoad = true; + persistenceStatus = "Click again to discard unsaved edits and reload the saved binding file."; + } + else + { + if (manager.readBindingIni(Module::BindingsIniPath)) + { + unsavedChanges = false; + persistenceStatus = "Saved bindings loaded."; + } + else + persistenceStatus = "Could not load bindings; the current bindings were kept."; + confirmingLoad = false; + } + } - // Two lines are reserved below: the unsaved note and the button row. - const float footerHeight = ImGui::GetFrameHeightWithSpacing() + ImGui::GetTextLineHeightWithSpacing(); + // Three lines are reserved below: unsaved state, persistence status + // and the exit/reset button row. + const float footerHeight = ImGui::GetFrameHeightWithSpacing() + + (ImGui::GetTextLineHeightWithSpacing() * 2.0f); ImGui::BeginChild("##body", ImVec2(0, -footerHeight)); if (ImGui::BeginTabBar("##sections")) @@ -528,6 +1508,12 @@ class InputBindingsUI : public OverlayWindow ImGui::EndTabItem(); } + if (ImGui::BeginTabItem("Profiles")) + { + draw_profiles(); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Options")) { draw_options(); @@ -540,9 +1526,30 @@ class InputBindingsUI : public OverlayWindow // Kept on its own line whether or not there are changes, so the // buttons below don't shift as it appears. - ImGui::TextDisabled("%s", unsavedChanges ? "Note: you have unsaved changes!" : ""); + ImGui::TextDisabled("%s", unsavedChanges + ? "Note: unsaved bindings are active now but will be lost after restart." + : ""); + ImGui::TextDisabled("%s", persistenceStatus.c_str()); - if (ImGui::Button("Return to game")) + if (unsavedChanges) + { + if (ImGui::Button("Save & Return to game")) + { + if (manager.saveBindingIni(Module::BindingsIniPath)) + { + unsavedChanges = false; + confirmingLoad = false; + persistenceStatus = "Bindings saved."; + dialogOpen = false; + } + else + persistenceStatus = "Could not save bindings. Check folder permissions and OutRun2006Tweaks.log."; + } + ImGui::SameLine(); + if (ImGui::Button("Return to game (not saved)")) + dialogOpen = false; + } + else if (ImGui::Button("Return to game")) dialogOpen = false; ImGui::SameLine(); @@ -556,7 +1563,7 @@ class InputBindingsUI : public OverlayWindow else { unsavedChanges = true; - Settings::SteeringDeadZone = 0.2f; + Settings::SteeringDeadZone = 0.0f; setting_changed(Settings::SteeringDeadZone); Settings::BypassGameSensitivity = false; setting_changed(Settings::BypassGameSensitivity); @@ -569,13 +1576,15 @@ class InputBindingsUI : public OverlayWindow // Back/B leaves the dialog, but only while nothing is being bound - // otherwise the press meant for a binding closes the screen instead. - if (isListeningForInput == ListenState::False) + if (isListeningForInput == ListenState::False && !unsavedChanges) { if ((manager.switch_overlay & (1 << int(SwitchId::Back) | 1 << int(SwitchId::B))) != 0) dialogOpen = false; } draw_listening_popup(); + draw_quick_setup_complete(dialogOpen); + draw_calibration_popup(); ImGui::EndPopup(); } @@ -589,6 +1598,9 @@ class InputBindingsUI : public OverlayWindow Overlay::IsBindingDialogActive = false; Overlay::RequestMouseHide = true; confirmingReset = false; + confirmingProfileLoad = false; + confirmingProfileDelete = false; + confirmingProfileOverwrite = false; } } diff --git a/src/overlay/notifications.hpp b/src/overlay/notifications.hpp index d54986c6a..dbb2d2f4d 100644 --- a/src/overlay/notifications.hpp +++ b/src/overlay/notifications.hpp @@ -21,7 +21,7 @@ class Notifications std::string message; std::chrono::time_point timestamp; int minDisplaySeconds; - std::function onMouseClick; + std::function onMouseClick; }; std::deque notifications; @@ -31,7 +31,7 @@ class Notifications void add(const std::string& message, int minDisplaySeconds = 0, std::function onMouseClick = nullptr) { std::lock_guard lock(notificationsMutex); - notifications.push_back({ message, std::chrono::steady_clock::now(), minDisplaySeconds, onMouseClick }); + notifications.push_back({ message, std::chrono::steady_clock::now(), minDisplaySeconds, onMouseClick }); if (notifications.size() > maxNotifications) notifications.pop_front(); @@ -39,8 +39,12 @@ class Notifications void render() { - // Remove expired notifications - auto now = std::chrono::steady_clock::now(); + // Expiry and snapshotting happen under the same lock. The updater runs on + // a detached background thread, so even a size() read outside this lock is + // a data race. Rendering from a copy also avoids holding the mutex while + // ImGui and click callbacks execute. + std::deque visibleNotifications; + const auto now = std::chrono::steady_clock::now(); { std::lock_guard lock(notificationsMutex); @@ -52,11 +56,13 @@ class Notifications if (front.minDisplaySeconds > 0) duration = std::chrono::seconds(front.minDisplaySeconds); - if (now - front.timestamp <= duration) // notif time hasn't elapsed yet? + if (now - front.timestamp <= duration) break; notifications.pop_front(); } + + visibleNotifications = notifications; } if (Game::is_in_game()) @@ -77,15 +83,14 @@ class Notifications // which letterboxing pulls inward. const Overlay::ContentRect content = Overlay::content_rect(); - float startX = content.x + content.width - notificationSize.x - 10.f; // 10px padding from the right - float curY = (content.height / 4.0f) - (notifications.size() * (notificationSize.y + notificationSpacing) / 2.0f); - - std::lock_guard lock(notificationsMutex); + float startX = content.x + content.width - notificationSize.x - 10.f; + float curY = (content.height / 4.0f) - + (visibleNotifications.size() * (notificationSize.y + notificationSpacing) / 2.0f); - for (size_t i = 0; i < notifications.size(); ++i) + for (size_t i = 0; i < visibleNotifications.size(); ++i) { auto windowSize = notificationSize; - const auto& notification = notifications[i]; + const auto& notification = visibleNotifications[i]; ImGui::SetNextWindowPos(ImVec2(startX, curY)); @@ -94,52 +99,45 @@ class Notifications ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing); - // Check for click on the notification - if (ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows) && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) - if (notification.onMouseClick) - notification.onMouseClick(); + if (ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows) && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) + if (notification.onMouseClick) + notification.onMouseClick(); ImGui::SetWindowFontScale(notificationTextScale); - // Split the message into lines std::vector lines; if (notification.message.find("---") == std::string::npos) - lines.push_back(notification.message); // notificiation doesn't have splitter, treat it as all single line for centering + lines.push_back(notification.message); else { std::istringstream messageStream(notification.message); std::string line; - while (std::getline(messageStream, line)) { + while (std::getline(messageStream, line)) lines.push_back(line); - } } - // Calculate total height for all lines float totalTextHeight = 0.0f; - for (const auto& singleLine : lines) { + for (const auto& singleLine : lines) + { ImVec2 lineSize = ImGui::CalcTextSize(singleLine.c_str(), nullptr, true, windowSize.x - 20.0f); totalTextHeight += lineSize.y; } totalTextHeight += (lines.size() - 1) * ImGui::GetStyle().ItemSpacing.y; - // Adjust window height if necessary if (totalTextHeight + 40.0f > windowSize.y) windowSize.y = totalTextHeight + 40.0f; ImGui::SetWindowSize(windowSize); - curY += windowSize.y + notificationSpacing; - // Center text block vertically float paddingY = 5.0f; float currentYOffset = (windowSize.y - totalTextHeight) / 2.0f; currentYOffset = max(currentYOffset, paddingY); - for (const auto& singleLine : lines) { - // Calculate individual line size + for (const auto& singleLine : lines) + { ImVec2 lineSize = ImGui::CalcTextSize(singleLine.c_str(), nullptr, true, windowSize.x - 20.0f); - // Center line horizontally if (singleLine != "---") { float paddingX = 10.0f; @@ -155,7 +153,6 @@ class Notifications ImGui::Separator(); } - // Move down for the next line, including spacing currentYOffset += lineSize.y + ImGui::GetStyle().ItemSpacing.y; } diff --git a/src/overlay/overlay.cpp b/src/overlay/overlay.cpp index 899605a1b..5e5b785f5 100644 --- a/src/overlay/overlay.cpp +++ b/src/overlay/overlay.cpp @@ -564,6 +564,9 @@ static void render_first_run_intro() ImGui::Bullet(); ImGui::TextUnformatted("Controller vibration is supported, but disabled by default as some Bluetooth controllers can have framerate issues. (configure it in Controls settings)"); + ImGui::Bullet(); + ImGui::TextWrapped("Wheel users: configure steering, pedals, buttons and menu controls in Input Bindings (Controller Configuration or Settings > Controls). Force feedback is selected and tuned separately in the Force Feedback tab."); + ImGui::Bullet(); ImGui::TextUnformatted("Online play and leaderboards are back! Create an account from the game's menus to " "race online, download ghosts to run against, and post times."); diff --git a/src/overlay/settings_ui.cpp b/src/overlay/settings_ui.cpp index 5522f4f3e..94cebf238 100644 --- a/src/overlay/settings_ui.cpp +++ b/src/overlay/settings_ui.cpp @@ -63,6 +63,11 @@ class SettingsWindow : public OverlayWindow if (matches_search(section, search)) return true; + if (section == "Controls" && + (matches_search("Input Bindings", search) || + matches_search("Configure Input Bindings", search))) + return true; + for (const Settings::SettingBase* setting : Settings::SettingBase::registry()) if (!setting->hidden() && setting->section() == section && matches_search(setting->key(), search)) return true; @@ -254,7 +259,10 @@ class SettingsWindow : public OverlayWindow // rather than only the settings whose own names happen to match. const bool wholeSection = matches_search(section, search); - if (section == "Controls" && search.empty()) + const bool inputSetupSearch = search.empty() || + matches_search("Input Bindings", search) || + matches_search("Configure Input Bindings", search); + if (section == "Controls" && inputSetupSearch) if (ImGui::Button("Configure Input Bindings")) Overlay::IsBindingDialogActive = true; diff --git a/src/overlay/update_check.cpp b/src/overlay/update_check.cpp index 6f531863b..a09b189bf 100644 --- a/src/overlay/update_check.cpp +++ b/src/overlay/update_check.cpp @@ -11,6 +11,13 @@ #include "input_manager.hpp" #include "resource.h" +namespace +{ + constexpr char UpdateRepoOwner[] = "thp32tt"; + constexpr char UpdateRepoName[] = "OutRun2006Tweaks"; + constexpr char UpdateReleasesUrl[] = "https://github.com/thp32tt/OutRun2006Tweaks/releases"; +} + uint64_t VersionToInteger(const std::string& version) { std::vector parts; @@ -36,7 +43,6 @@ bool IsVersionNewer(const std::string& latest, const std::string& current) return VersionToInteger(latest) > VersionToInteger(current); } -// Function to check the latest release version std::string UpdateCheck_IsNewerAvailable(const std::string& currentVersion, const std::string& repoOwner, const std::string& repoName) { std::wstring path = L"/repos/" + std::wstring(repoOwner.begin(), repoOwner.end()) + @@ -49,7 +55,6 @@ std::string UpdateCheck_IsNewerAvailable(const std::string& currentVersion, cons return ""; } - // Parse JSON response Json::CharReaderBuilder builder; Json::Value root; std::string errs; @@ -68,7 +73,6 @@ std::string UpdateCheck_IsNewerAvailable(const std::string& currentVersion, cons return ""; } - // Compare versions if (IsVersionNewer(latestVersion, currentVersion)) { spdlog::info("UpdateCheck_IsNewerAvailable: newer version {} available, current version {}", latestVersion, currentVersion); @@ -85,12 +89,15 @@ void UpdateCheck_Thread(const std::string& currentVersion, const std::string& re { std::string newerVersion = UpdateCheck_IsNewerAvailable(currentVersion, repoOwner, repoName); if (!newerVersion.empty()) - Notifications::instance.add(std::format("A newer version of OutRun2006Tweaks is available ({})\n---\nPress {} and click here to visit release page.", - newerVersion, InputManager_ModActionDisplayName(ModAction::OverlayToggle)), 20, - [newerVersion]() { - std::string url = "https://github.com/emoose/OutRun2006Tweaks/releases"; - ShellExecuteA(nullptr, "open", url.c_str(), 0, 0, SW_SHOWNORMAL); - }); + { + Notifications::instance.add( + std::format("A newer version of OutRun2006Tweaks is available ({})\n---\nPress {} and click here to visit this fork's release page.", + newerVersion, InputManager_ModActionDisplayName(ModAction::OverlayToggle)), + 20, + []() { + ShellExecuteA(nullptr, "open", UpdateReleasesUrl, nullptr, nullptr, SW_SHOWNORMAL); + }); + } } std::thread updateCheckThread; @@ -99,7 +106,11 @@ void UpdateCheck_Init() { if (Overlay::NotifyUpdateCheck) { - updateCheckThread = std::thread(&UpdateCheck_Thread, MODULE_VERSION_STR, "emoose", "OutRun2006Tweaks"); + // This fork carries wheel/FFB and VR changes that are not represented by + // upstream emoose releases, so update notifications must never direct a + // user to an incompatible upstream payload. + updateCheckThread = std::thread(&UpdateCheck_Thread, + MODULE_VERSION_STR, UpdateRepoOwner, UpdateRepoName); updateCheckThread.detach(); } } diff --git a/src/overlay/wheel_setup_ui.cpp b/src/overlay/wheel_setup_ui.cpp new file mode 100644 index 000000000..293de0fd0 --- /dev/null +++ b/src/overlay/wheel_setup_ui.cpp @@ -0,0 +1,1962 @@ +#define NOMINMAX +#define WIN32_LEAN_AND_MEAN +#define DIRECTINPUT_VERSION 0x0800 +#include +#include + +#pragma comment(lib, "dinput8.lib") +#pragma comment(lib, "dxguid.lib") + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "hook_mgr.hpp" +#include "plugin.hpp" +#include "game_addrs.hpp" +#include "overlay.hpp" +#include "input_manager.hpp" +#include "wheel_ffb_math.hpp" +#include "wheel_ffb_runtime.hpp" +#include "wheel_profile_store.hpp" + +namespace Settings +{ + extern Setting SteeringDeadZone; + extern Setting UseNewInput; + extern Setting WheelFFBEnable; + extern Setting WheelAccelerationInvert; + extern Setting WheelBrakeInvert; + extern Setting WheelFFBDeviceName; + extern Setting WheelFFBDeviceGuid; + extern Setting WheelMenuR3DirectDPad; + extern Setting WheelMenuR3DirectAB; + extern Setting WheelFFBGlobalStrength; + extern Setting WheelFFBSpringStrength; + extern Setting WheelFFBSpringSaturation; + extern Setting WheelFFBDamperStrength; + extern Setting WheelFFBSteeringWeight; + extern Setting WheelFFBMechanicalTrail; + extern Setting WheelFFBTrailResponseLead; + extern Setting WheelFFBPhysicsSat; + extern Setting WheelFFBGripLoss; + extern Setting WheelFFBLateralDeadzone; + extern Setting WheelFFBWeightTransfer; + extern Setting WheelFFBGearShift; + extern Setting WheelFFBEngineVibration; + extern Setting WheelFFBEngineIdle; + extern Setting WheelFFBSlewRate; + extern Setting WheelFFBReversalReleaseRate; + extern Setting VibrationMode; + extern Setting WheelFFBRoadTexture; + extern Setting WheelFFBTireSlip; + extern Setting WheelFFBWallImpact; + extern Setting WheelFFBUseHardwareSpring; + extern Setting WheelFFBUseHardwareDamper; + extern Setting WheelFFBInvertForce; + extern Setting WheelFFBInvertSpring; + extern Setting WheelFFBUsePeriodicEffects; + extern Setting WheelFFBDebugLog; + extern Setting WheelFFBTelemetry; + extern Setting WheelFFBResponseCorrection; + extern Setting WheelFFBResponseLUT; + extern Setting WheelFFBMaxTorqueNm; + + Setting WheelUniversalSetupEnable{ + "Controls", "WheelUniversalSetupEnable", false, + "Use the F11 Wheel Setup profile to configure the original legacy DirectInput wheel mappings." + }; + + Setting WheelUniversalDeviceName{ + "Controls", "WheelUniversalDeviceName", "", + "DirectInput product name selected by F11 Wheel Setup." + }; + + Setting WheelUniversalDeviceGuid{ + "Controls", "WheelUniversalDeviceGuid", "", + "Exact DirectInput instance GUID selected by F11 Wheel Setup." + }; + + Setting WheelUniversalLegacyDeviceIndex{ + "Controls", "WheelUniversalLegacyDeviceIndex", 0, + "Original-game DirectInput device slot used by the universal wheel profile.", + Range{ 0, 2 } + }; + + Setting WheelUniversalSteeringAxis{ + "Controls", "WheelUniversalSteeringAxis", -1, + "DirectInput axis used for steering (-1 is Unassigned).", + Range{ -1, 7 } + }; + Setting WheelUniversalSteeringInvert{ + "Controls", "WheelUniversalSteeringInvert", false, + "Invert steering direction for the universal wheel profile." + }; + Setting WheelUniversalThrottleAxis{ + "Controls", "WheelUniversalThrottleAxis", -1, + "DirectInput axis used for acceleration (-1 is Unassigned).", + Range{ -1, 7 } + }; + Setting WheelUniversalBrakeAxis{ + "Controls", "WheelUniversalBrakeAxis", -1, + "DirectInput axis used for brake (-1 is Unassigned).", + Range{ -1, 7 } + }; + + Setting WheelUniversalGearUpButton{ + "Controls", "WheelUniversalGearUpButton", -1, + "Zero-based DirectInput button for Gear Up.", Range{ -1, 127 } + }; + Setting WheelUniversalGearDownButton{ + "Controls", "WheelUniversalGearDownButton", -1, + "Zero-based DirectInput button for Gear Down.", Range{ -1, 127 } + }; + Setting WheelUniversalStartButton{ + "Controls", "WheelUniversalStartButton", -1, + "Zero-based DirectInput button for Start.", Range{ -1, 127 } + }; + Setting WheelUniversalViewButton{ + "Controls", "WheelUniversalViewButton", -1, + "Zero-based DirectInput button for Change View.", Range{ -1, 127 } + }; + + // Digital menu bindings: 0..127 = button, 1000+ = POV direction + // (POV0 Up/Right/Down/Left = 1000..1003, POV1 = 1004..1007, etc.). + Setting WheelUniversalMenuAccept{ + "Controls", "WheelUniversalMenuAccept", -1, + "DirectInput button/POV binding for menu Accept.", Range{ -1, 1015 } + }; + Setting WheelUniversalMenuBack{ + "Controls", "WheelUniversalMenuBack", -1, + "DirectInput button/POV binding for menu Back.", Range{ -1, 1015 } + }; + Setting WheelUniversalMenuUp{ + "Controls", "WheelUniversalMenuUp", -1, + "DirectInput button/POV binding for menu Up.", Range{ -1, 1015 } + }; + Setting WheelUniversalMenuRight{ + "Controls", "WheelUniversalMenuRight", -1, + "DirectInput button/POV binding for menu Right.", Range{ -1, 1015 } + }; + Setting WheelUniversalMenuDown{ + "Controls", "WheelUniversalMenuDown", -1, + "DirectInput button/POV binding for menu Down.", Range{ -1, 1015 } + }; + Setting WheelUniversalMenuLeft{ + "Controls", "WheelUniversalMenuLeft", -1, + "DirectInput button/POV binding for menu Left.", Range{ -1, 1015 } + }; +} + +namespace +{ + constexpr uintptr_t DeviceSlotsOffset = 0x4606D4; + constexpr uintptr_t DeviceCountOffset = 0x55AEC4; + constexpr size_t AxisMapOffset = 0x54; + constexpr size_t ButtonMapOffset = 0x70; + constexpr int AxisUnassigned = 8; + constexpr int ButtonUnassigned = -1; + + constexpr std::array AxisNames = { + "X", "Y", "Z", "Rx", "Ry", "Rz", "Slider 1", "Slider 2" + }; + + std::string lower_identity(std::string value) + { + std::transform(value.begin(), value.end(), value.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return value; + } + + std::string wheel_guid_key(const GUID& guid) + { + char b[64]{}; + std::snprintf(b, sizeof(b), + "%08X-%04X-%04X-%02X%02X-%02X%02X%02X%02X%02X%02X", + (unsigned)guid.Data1, (unsigned)guid.Data2, (unsigned)guid.Data3, + (unsigned)guid.Data4[0], (unsigned)guid.Data4[1], + (unsigned)guid.Data4[2], (unsigned)guid.Data4[3], + (unsigned)guid.Data4[4], (unsigned)guid.Data4[5], + (unsigned)guid.Data4[6], (unsigned)guid.Data4[7]); + return lower_identity(b); + } + + bool is_virtual_name(const std::string& name) + { + std::string lower = name; + std::transform(lower.begin(), lower.end(), lower.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + static constexpr const char* tokens[] = { + "vjoy", "vigem", "xoutput", "virtual", "vxbox", "v xbox", + "hidguardian", "hidhide" + }; + for (const char* token : tokens) + if (lower.find(token) != std::string::npos) + return true; + return false; + } + + LONG axis_raw(const DIJOYSTATE2& state, int axis) + { + switch (axis) + { + case 0: return state.lX; + case 1: return state.lY; + case 2: return state.lZ; + case 3: return state.lRx; + case 4: return state.lRy; + case 5: return state.lRz; + case 6: return state.rglSlider[0]; + case 7: return state.rglSlider[1]; + default: return 0; + } + } + + const char* axis_name(int axis) + { + return axis >= 0 && axis < int(AxisNames.size()) ? AxisNames[axis] : "Unassigned"; + } + + int pov_direction(DWORD pov) + { + if ((pov & 0xFFFFu) == 0xFFFFu) + return -1; + const int angle = int(pov % 36000u); + if (angle >= 31500 || angle < 4500) return 0; // up + if (angle < 13500) return 1; // right + if (angle < 22500) return 2; // down + return 3; // left + } + + std::string digital_binding_name(int binding) + { + if (binding < 0) + return "Unassigned"; + if (binding < 128) + return "Button " + std::to_string(binding + 1); + if (binding >= 1000 && binding < 1016) + { + const int code = binding - 1000; + const int pov = code / 4; + static constexpr const char* dirs[] = { "Up", "Right", "Down", "Left" }; + return "POV " + std::to_string(pov + 1) + " " + dirs[code % 4]; + } + return "Unassigned"; + } + + struct DeviceInfo + { + GUID guid{}; + std::string guidKey; + std::string name; + DWORD axes = 0; + DWORD buttons = 0; + DWORD povs = 0; + bool ffb = false; + std::uint16_t vendor = 0; + std::uint16_t product = 0; + }; + + class DirectWheelReader + { + public: + ~DirectWheelReader() { release_all(); } + + const std::vector& devices() + { + if (devices_.empty()) + enumerate(); + return devices_; + } + + void refresh_devices() + { + release_device(); + devices_.clear(); + enumerate(); + } + + bool select_by_identity(const std::string& wantedGuid, const std::string& wantedName) + { + const std::string guid = lower_identity(wantedGuid); + if (device_ && !guid.empty() && guid == selectedGuidKey_) return true; + if (device_ && guid.empty() && wantedName == selectedName_) return true; + + release_device(); + if (devices_.empty()) enumerate(); + int index = -1; + if (!guid.empty()) + { + for (size_t i=0; i= 0 ? open(index) : false; + } + + bool poll() + { + if (!device_) + { + if (!select_by_identity( + Settings::WheelUniversalDeviceGuid.get(), + Settings::WheelUniversalDeviceName.get())) + return false; + } + + HRESULT hr = device_->Poll(); + if (FAILED(hr)) + { + hr = device_->Acquire(); + if (SUCCEEDED(hr)) + hr = device_->Poll(); + } + if (FAILED(hr)) + { + release_device(); + devices_.clear(); + return false; + } + + hr = device_->GetDeviceState(sizeof(state_), &state_); + if (hr == DIERR_INPUTLOST || hr == DIERR_NOTACQUIRED) + { + if (SUCCEEDED(device_->Acquire())) + { + device_->Poll(); + hr = device_->GetDeviceState(sizeof(state_), &state_); + } + } + if (FAILED(hr)) + { + release_device(); + devices_.clear(); + return false; + } + return true; + } + + const DIJOYSTATE2& state() const { return state_; } + const std::string& selected_name() const { return selectedName_; } + + void release_device() + { + if (device_) + { + device_->Unacquire(); + device_->Release(); + device_ = nullptr; + } + selectedName_.clear(); + selectedGuidKey_.clear(); + state_ = {}; + } + + private: + static BOOL CALLBACK enum_callback(LPCDIDEVICEINSTANCEA instance, LPVOID context) + { + auto* self = static_cast(context); + const std::string product = instance->tszProductName; + const std::string instanceName = instance->tszInstanceName; + if (is_virtual_name(product) || is_virtual_name(instanceName)) + return DIENUM_CONTINUE; + + DeviceInfo info{}; + info.guid = instance->guidInstance; + info.guidKey = wheel_guid_key(info.guid); + info.name = product; + + IDirectInputDevice8A* temp = nullptr; + if (self->di_ && SUCCEEDED(self->di_->CreateDevice(info.guid, &temp, nullptr)) && temp) + { + DIDEVCAPS caps{}; + caps.dwSize = sizeof(caps); + if (SUCCEEDED(temp->GetCapabilities(&caps))) + { + info.axes = caps.dwAxes; + info.buttons = caps.dwButtons; + info.povs = caps.dwPOVs; + info.ffb = (caps.dwFlags & DIDC_FORCEFEEDBACK) != 0; + } + DIPROPDWORD vidpid{}; + vidpid.diph.dwSize = sizeof(vidpid); + vidpid.diph.dwHeaderSize = sizeof(vidpid.diph); + vidpid.diph.dwObj = 0; + vidpid.diph.dwHow = DIPH_DEVICE; + if (SUCCEEDED(temp->GetProperty(DIPROP_VIDPID, &vidpid.diph))) + { + info.vendor = LOWORD(vidpid.dwData); + info.product = HIWORD(vidpid.dwData); + } + temp->Release(); + } + + self->devices_.push_back(std::move(info)); + return DIENUM_CONTINUE; + } + + bool ensure_di() + { + if (di_) + return true; + const HRESULT hr = DirectInput8Create( + GetModuleHandleW(nullptr), DIRECTINPUT_VERSION, IID_IDirectInput8A, + reinterpret_cast(&di_), nullptr); + return SUCCEEDED(hr) && di_; + } + + void enumerate() + { + if (!ensure_di()) + return; + di_->EnumDevices( + DI8DEVCLASS_GAMECTRL, enum_callback, this, DIEDFL_ATTACHEDONLY); + } + + bool open(int index) + { + if (index < 0 || index >= int(devices_.size()) || !ensure_di()) + return false; + + IDirectInputDevice8A* device = nullptr; + HRESULT hr = di_->CreateDevice(devices_[index].guid, &device, nullptr); + if (FAILED(hr) || !device) + return false; + + hr = device->SetDataFormat(&c_dfDIJoystick2); + if (FAILED(hr)) + { + device->Release(); + return false; + } + + HWND hwnd = Game::GameHwnd(); + if (!hwnd) + { + device->Release(); + return false; + } + + hr = device->SetCooperativeLevel(hwnd, DISCL_NONEXCLUSIVE | DISCL_BACKGROUND); + if (FAILED(hr)) + { + device->Release(); + return false; + } + + hr = device->Acquire(); + if (FAILED(hr) && hr != S_FALSE) + { + device->Release(); + return false; + } + device_ = device; + selectedName_ = devices_[index].name; + selectedGuidKey_ = devices_[index].guidKey; + return true; + } + + void release_all() + { + release_device(); + if (di_) + { + di_->Release(); + di_ = nullptr; + } + } + + IDirectInput8A* di_ = nullptr; + IDirectInputDevice8A* device_ = nullptr; + std::vector devices_; + std::string selectedName_; + std::string selectedGuidKey_; + DIJOYSTATE2 state_{}; + }; + + DirectWheelReader gReader; + + class UniversalWheelProfile : public Hook + { + inline static SafetyHookInline ReadIOHook{}; + inline static SafetyHookInline SwitchNowHook{}; + inline static SafetyHookInline SwitchOnHook{}; + inline static SafetyHookInline GetVolumeHook{}; + inline static SafetyHookInline GetVolumeOldHook{}; + inline static uint32_t menuHeld_ = 0; + inline static uint32_t menuPressed_ = 0; + inline static uint32_t previousMenuHeld_ = 0; + inline static DWORD lastApplyLog_ = 0; + + inline static constexpr uint32_t UniversalMenuSwitchMask = + (1u << int(SwitchId::Start)) | + (1u << int(SwitchId::Back)) | + (1u << int(SwitchId::A)) | + (1u << int(SwitchId::B)) | + (1u << int(SwitchId::GearDown)) | + (1u << int(SwitchId::GearUp)) | + (1u << int(SwitchId::SelectionUp)) | + (1u << int(SwitchId::SelectionDown)) | + (1u << int(SwitchId::SelectionLeft)) | + (1u << int(SwitchId::SelectionRight)); + + static bool pure_universal_menu_query(uint32_t switches) + { + return switches != 0 && + (switches & UniversalMenuSwitchMask) != 0 && + (switches & ~UniversalMenuSwitchMask) == 0; + } + + static bool active() + { + return Settings::WheelInputCompatibility && !Settings::UseNewInput && + Settings::WheelUniversalSetupEnable; + } + + static int device_count() + { + auto* count = Module::exe_ptr(DeviceCountOffset); + return count ? std::clamp(*count, 0, 4) : 0; + } + + static uint8_t* device_at(int index) + { + const int count = device_count(); + if (index < 0 || index >= count - 1) + return nullptr; + auto* slots = Module::exe_ptr(DeviceSlotsOffset); + return slots ? reinterpret_cast(slots[index]) : nullptr; + } + + static void apply_mapping() + { + if (!active()) + return; + + const int regularCount = std::max(device_count() - 1, 0); + if (regularCount <= 0) + return; + const int maxSlot = std::min(regularCount - 1, 2); + const int slot = std::clamp( + int(Settings::WheelUniversalLegacyDeviceIndex), 0, maxSlot); + if (slot != int(Settings::WheelUniversalLegacyDeviceIndex)) + Settings::WheelUniversalLegacyDeviceIndex = slot; + + auto* device = device_at(slot); + if (!device) + return; + + auto* axes = reinterpret_cast(device + AxisMapOffset); + auto* buttons = reinterpret_cast(device + ButtonMapOffset); + + axes[0] = int(Settings::WheelUniversalThrottleAxis) >= 0 + ? int(Settings::WheelUniversalThrottleAxis) : AxisUnassigned; + axes[1] = int(Settings::WheelUniversalBrakeAxis) >= 0 + ? int(Settings::WheelUniversalBrakeAxis) : AxisUnassigned; + axes[2] = AxisUnassigned; // never use combined Accelerator/Brake + axes[3] = int(Settings::WheelUniversalSteeringAxis) >= 0 + ? int(Settings::WheelUniversalSteeringAxis) : AxisUnassigned; + axes[4] = AxisUnassigned; // Gear Up/Down axis + axes[5] = AxisUnassigned; // Menu Up/Down axis + + buttons[0] = int(Settings::WheelUniversalGearUpButton); + buttons[1] = int(Settings::WheelUniversalGearDownButton); + buttons[2] = int(Settings::WheelUniversalStartButton); + buttons[8] = int(Settings::WheelUniversalViewButton); + + const DWORD now = GetTickCount(); + if (now - lastApplyLog_ > 5000) + { + lastApplyLog_ = now; + spdlog::info( + "WheelUniversalSetup: applied legacy slot {} axes steer={} accel={} brake={} buttons up={} down={} start={} view={}", + slot, + int(Settings::WheelUniversalSteeringAxis), + int(Settings::WheelUniversalThrottleAxis), + int(Settings::WheelUniversalBrakeAxis), + int(Settings::WheelUniversalGearUpButton) + 1, + int(Settings::WheelUniversalGearDownButton) + 1, + int(Settings::WheelUniversalStartButton) + 1, + int(Settings::WheelUniversalViewButton) + 1); + } + } + + static bool digital_active(const DIJOYSTATE2& state, int binding) + { + if (binding < 0) + return false; + if (binding < 128) + return (state.rgbButtons[binding] & 0x80) != 0; + if (binding >= 1000 && binding < 1016) + { + const int code = binding - 1000; + const int povIndex = code / 4; + const int wantedDir = code % 4; + return pov_direction(state.rgdwPOV[povIndex]) == wantedDir; + } + return false; + } + + static void poll_menu() + { + menuHeld_ = 0; + menuPressed_ = 0; + + if (!active() || !Game::current_mode || *Game::current_mode == STATE_GAME) + { + previousMenuHeld_ = 0; + gReader.release_device(); + return; + } + + if (!gReader.poll()) + return; + + const auto& state = gReader.state(); + if (digital_active(state, Settings::WheelUniversalMenuAccept)) + menuHeld_ |= (1u << int(SwitchId::A)) | (1u << int(SwitchId::GearUp)); + if (digital_active(state, Settings::WheelUniversalMenuBack)) + menuHeld_ |= (1u << int(SwitchId::B)) | (1u << int(SwitchId::Back)) | + (1u << int(SwitchId::GearDown)); + if (digital_active(state, Settings::WheelUniversalMenuUp)) + menuHeld_ |= 1u << int(SwitchId::SelectionUp); + if (digital_active(state, Settings::WheelUniversalMenuRight)) + menuHeld_ |= 1u << int(SwitchId::SelectionRight); + if (digital_active(state, Settings::WheelUniversalMenuDown)) + menuHeld_ |= 1u << int(SwitchId::SelectionDown); + if (digital_active(state, Settings::WheelUniversalMenuLeft)) + menuHeld_ |= 1u << int(SwitchId::SelectionLeft); + if (digital_active(state, Settings::WheelUniversalStartButton)) + menuHeld_ |= 1u << int(SwitchId::Start); + + menuPressed_ = menuHeld_ & ~previousMenuHeld_; + previousMenuHeld_ = menuHeld_; + } + + static int ReadIO_dest() + { + const int result = ReadIOHook.ccall(); + apply_mapping(); + poll_menu(); + return result; + } + + static int SwitchNow_dest(uint32_t switches) + { + const int result = SwitchNowHook.ccall(switches); + if (result || !active() || !pure_universal_menu_query(switches)) + return result; + return (menuHeld_ & switches) == switches ? 1 : 0; + } + + static int SwitchOn_dest(uint32_t switches) + { + const int result = SwitchOnHook.ccall(switches); + if (result || !active() || !pure_universal_menu_query(switches)) + return result; + return (menuPressed_ & switches) == switches ? 1 : 0; + } + + static int maybe_invert_steering(int channel, int result) + { + if (active() && channel == int(ADChannel::Steering) && + Settings::WheelUniversalSteeringInvert) + return -result; + return result; + } + + static int GetVolume_dest(int channel) + { + return maybe_invert_steering(channel, GetVolumeHook.call(channel)); + } + + static int GetVolumeOld_dest(int channel) + { + return maybe_invert_steering(channel, GetVolumeOldHook.call(channel)); + } + + public: + static void apply_now() { apply_mapping(); } + + static int regular_device_count() + { + return std::max(device_count() - 1, 0); + } + + static bool copy_current_mapping() + { + const int regularCount = std::max(device_count() - 1, 0); + if (regularCount <= 0) + return false; + const int maxSlot = std::min(regularCount - 1, 2); + const int slot = std::clamp( + int(Settings::WheelUniversalLegacyDeviceIndex), 0, maxSlot); + if (slot != int(Settings::WheelUniversalLegacyDeviceIndex)) + Settings::WheelUniversalLegacyDeviceIndex = slot; + + auto* device = device_at(slot); + if (!device) + return false; + + const auto* axes = reinterpret_cast(device + AxisMapOffset); + const auto* buttons = reinterpret_cast(device + ButtonMapOffset); + Settings::WheelUniversalThrottleAxis = axes[0] == AxisUnassigned ? -1 : axes[0]; + Settings::WheelUniversalBrakeAxis = axes[1] == AxisUnassigned ? -1 : axes[1]; + Settings::WheelUniversalSteeringAxis = axes[3] == AxisUnassigned ? -1 : axes[3]; + Settings::WheelUniversalGearUpButton = buttons[0]; + Settings::WheelUniversalGearDownButton = buttons[1]; + Settings::WheelUniversalStartButton = buttons[2]; + Settings::WheelUniversalViewButton = buttons[8]; + return true; + } + + std::string_view description() override { return "Universal DirectInput Wheel Setup"; } + // The dedicated Legacy Wheel Setup page owns every WheelUniversal* + // setting. Keeping the raw axis/button numbers in generic Controls + // created a second, competing setup UI. Pedal invert is also exposed on + // the dedicated axis rows, so hide those duplicate controls as well. + void declare_settings() override + { + constexpr std::string_view prefix = "WheelUniversal"; + for (auto* setting : Settings::SettingBase::registry()) + { + if (!setting || setting->section() != "Controls") + continue; + const std::string_view key = setting->key(); + if (key.size() >= prefix.size() && key.substr(0, prefix.size()) == prefix) + setting->hidden(true); + } + Settings::WheelAccelerationInvert.hidden(true); + Settings::WheelBrakeInvert.hidden(true); + } + + // Install with the legacy stack; active() gates live F11 ownership. + bool validate() override { return Settings::WheelInputCompatibility && !Settings::UseNewInput; } + bool apply() override + { + ReadIOHook = safetyhook::create_inline(Module::exe_ptr(0x53BB0), ReadIO_dest); + SwitchNowHook = safetyhook::create_inline(Module::exe_ptr(0x536C0), SwitchNow_dest); + SwitchOnHook = safetyhook::create_inline(Module::exe_ptr(0x536F0), SwitchOn_dest); + GetVolumeHook = safetyhook::create_inline(Module::exe_ptr(0x53720), GetVolume_dest); + GetVolumeOldHook = safetyhook::create_inline(Module::exe_ptr(0x53750), GetVolumeOld_dest); + const bool ok = !!ReadIOHook && !!SwitchNowHook && !!SwitchOnHook && + !!GetVolumeHook && !!GetVolumeOldHook; + if (ok) + spdlog::info("WheelUniversalSetup: F11 universal DirectInput profile support installed"); + return ok; + } + static UniversalWheelProfile instance; + }; + + UniversalWheelProfile UniversalWheelProfile::instance; + + class WheelSetupWindow : public OverlayWindow + { + enum class BindTarget + { + None, + Steering, + Throttle, + Brake, + GearUp, + GearDown, + Start, + View, + MenuAccept, + MenuBack, + MenuUp, + MenuRight, + MenuDown, + MenuLeft, + }; + + BindTarget target_ = BindTarget::None; + DIJOYSTATE2 baseline_{}; + bool baselineValid_ = false; + std::string status_; + bool ffbDirty_ = false; + std::vector ffbProfiles_; + int selectedFfbProfile_ = -1; + char ffbProfileName_[65]{}; + bool ffbProfilesLoaded_ = false; + bool confirmingFfbOverwrite_ = false; + bool confirmingFfbDelete_ = false; + + struct FfbSavedSnapshot + { + bool valid = false; + bool enable = true; + bool physicsSat = true; + bool hwSpring = true; + bool hwDamper = true; + bool periodic = true; + bool invertForce = true; + bool invertSpring = false; + bool responseCorrection = false; + bool debugLog = true; + bool telemetry = false; + bool engineVibration = false; + float global = 0.70f; + float spring = 0.65f; + float springSaturation = 0.95f; + float damper = 0.30f; + float steering = 1.45f; + float mechanical = 0.25f; + float trailLead = 0.25f; + float gripLoss = 0.65f; + float lateralDeadzone = 1.5f; + float weightTransfer = 0.15f; + float gearShift = 0.18f; + float engineIdle = 0.20f; + float slew = 0.06f; + float reversalRelease = 0.12f; + float road = 0.30f; + float tire = 0.20f; + float collision = 0.38f; + float maxTorque = 0.0f; + std::string responseLut; + } savedFfb_; + + void capture_saved_ffb() + { + savedFfb_.valid = true; + savedFfb_.enable = Settings::WheelFFBEnable; + savedFfb_.physicsSat = Settings::WheelFFBPhysicsSat; + savedFfb_.hwSpring = Settings::WheelFFBUseHardwareSpring; + savedFfb_.hwDamper = Settings::WheelFFBUseHardwareDamper; + savedFfb_.periodic = Settings::WheelFFBUsePeriodicEffects; + savedFfb_.invertForce = Settings::WheelFFBInvertForce; + savedFfb_.invertSpring = Settings::WheelFFBInvertSpring; + savedFfb_.responseCorrection = Settings::WheelFFBResponseCorrection; + savedFfb_.debugLog = Settings::WheelFFBDebugLog; + savedFfb_.telemetry = Settings::WheelFFBTelemetry; + savedFfb_.global = Settings::WheelFFBGlobalStrength; + savedFfb_.spring = Settings::WheelFFBSpringStrength; + savedFfb_.springSaturation = Settings::WheelFFBSpringSaturation; + savedFfb_.damper = Settings::WheelFFBDamperStrength; + savedFfb_.steering = Settings::WheelFFBSteeringWeight; + savedFfb_.mechanical = Settings::WheelFFBMechanicalTrail; + savedFfb_.trailLead = Settings::WheelFFBTrailResponseLead; + savedFfb_.gripLoss = Settings::WheelFFBGripLoss; + savedFfb_.lateralDeadzone = Settings::WheelFFBLateralDeadzone; + savedFfb_.weightTransfer = Settings::WheelFFBWeightTransfer; + savedFfb_.gearShift = Settings::WheelFFBGearShift; + savedFfb_.engineVibration = Settings::WheelFFBEngineVibration; + savedFfb_.engineIdle = Settings::WheelFFBEngineIdle; + savedFfb_.slew = Settings::WheelFFBSlewRate; + savedFfb_.reversalRelease = Settings::WheelFFBReversalReleaseRate; + savedFfb_.road = Settings::WheelFFBRoadTexture; + savedFfb_.tire = Settings::WheelFFBTireSlip; + savedFfb_.collision = Settings::WheelFFBWallImpact; + savedFfb_.maxTorque = Settings::WheelFFBMaxTorqueNm; + savedFfb_.responseLut = Settings::WheelFFBResponseLUT.get(); + } + + void restore_saved_ffb() + { + if (!savedFfb_.valid) return; + Settings::WheelFFBEnable = savedFfb_.enable; + Settings::WheelFFBPhysicsSat = savedFfb_.physicsSat; + Settings::WheelFFBUseHardwareSpring = savedFfb_.hwSpring; + Settings::WheelFFBUseHardwareDamper = savedFfb_.hwDamper; + Settings::WheelFFBUsePeriodicEffects = savedFfb_.periodic; + Settings::WheelFFBInvertForce = savedFfb_.invertForce; + Settings::WheelFFBInvertSpring = savedFfb_.invertSpring; + Settings::WheelFFBResponseCorrection = savedFfb_.responseCorrection; + Settings::WheelFFBDebugLog = savedFfb_.debugLog; + Settings::WheelFFBTelemetry = savedFfb_.telemetry; + Settings::WheelFFBGlobalStrength = savedFfb_.global; + Settings::WheelFFBSpringStrength = savedFfb_.spring; + Settings::WheelFFBSpringSaturation = savedFfb_.springSaturation; + Settings::WheelFFBDamperStrength = savedFfb_.damper; + Settings::WheelFFBSteeringWeight = savedFfb_.steering; + Settings::WheelFFBMechanicalTrail = savedFfb_.mechanical; + Settings::WheelFFBTrailResponseLead = savedFfb_.trailLead; + Settings::WheelFFBGripLoss = savedFfb_.gripLoss; + Settings::WheelFFBLateralDeadzone = savedFfb_.lateralDeadzone; + Settings::WheelFFBWeightTransfer = savedFfb_.weightTransfer; + Settings::WheelFFBGearShift = savedFfb_.gearShift; + Settings::WheelFFBEngineVibration = savedFfb_.engineVibration; + Settings::WheelFFBEngineIdle = savedFfb_.engineIdle; + Settings::WheelFFBSlewRate = savedFfb_.slew; + Settings::WheelFFBReversalReleaseRate = savedFfb_.reversalRelease; + Settings::WheelFFBRoadTexture = savedFfb_.road; + Settings::WheelFFBTireSlip = savedFfb_.tire; + Settings::WheelFFBWallImpact = savedFfb_.collision; + Settings::WheelFFBMaxTorqueNm = savedFfb_.maxTorque; + Settings::WheelFFBResponseLUT = savedFfb_.responseLut; + } + + void refresh_ffb_profiles(const std::string& selectName = {}) + { + ffbProfiles_ = WheelProfileStore::list_profiles(WheelProfileStore::Kind::ForceFeedback); + selectedFfbProfile_ = -1; + const std::string wanted = WheelProfileStore::normalize_profile_name(selectName); + if (!wanted.empty()) + { + for (size_t i = 0; i < ffbProfiles_.size(); ++i) + if (WheelProfileStore::lower_ascii(ffbProfiles_[i]) == WheelProfileStore::lower_ascii(wanted)) + { + selectedFfbProfile_ = int(i); + break; + } + } + if (selectedFfbProfile_ >= 0) + strncpy_s(ffbProfileName_, ffbProfiles_[selectedFfbProfile_].c_str(), sizeof(ffbProfileName_) - 1); + ffbProfilesLoaded_ = true; + } + + const std::string* selected_ffb_profile() const + { + return selectedFfbProfile_ >= 0 && selectedFfbProfile_ < int(ffbProfiles_.size()) + ? &ffbProfiles_[selectedFfbProfile_] : nullptr; + } + + bool ffb_profile_exists_cached(const std::string& name) const + { + const std::string wanted = WheelProfileStore::lower_ascii(name); + return std::any_of(ffbProfiles_.begin(), ffbProfiles_.end(), [&](const std::string& profile) + { + return WheelProfileStore::lower_ascii(profile) == wanted; + }); + } + + void draw_ffb_profiles() + { + if (!ffbProfilesLoaded_) + refresh_ffb_profiles(); + + ImGui::SeparatorText("Force Feedback Profiles"); + ImGui::TextWrapped("Save several force-feel setups and switch between them. Profiles store force behavior only; the selected FFB Output wheel, hardware response correction and diagnostic logging stay with the wheel/global setup."); + + const std::string* selected = selected_ffb_profile(); + const char* preview = selected ? selected->c_str() : "Select a saved FFB profile"; + if (ImGui::BeginCombo("Saved FFB profile", preview)) + { + for (size_t i = 0; i < ffbProfiles_.size(); ++i) + { + const bool selectedNow = int(i) == selectedFfbProfile_; + if (ImGui::Selectable(ffbProfiles_[i].c_str(), selectedNow)) + { + selectedFfbProfile_ = int(i); + strncpy_s(ffbProfileName_, ffbProfiles_[i].c_str(), sizeof(ffbProfileName_) - 1); + confirmingFfbOverwrite_ = false; + confirmingFfbDelete_ = false; + } + if (selectedNow) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + + if (ImGui::InputText("FFB profile name", ffbProfileName_, sizeof(ffbProfileName_))) + confirmingFfbOverwrite_ = false; + const std::string requested = WheelProfileStore::normalize_profile_name(ffbProfileName_); + const bool exists = !requested.empty() && ffb_profile_exists_cached(requested); + const char* saveLabel = confirmingFfbOverwrite_ + ? "Confirm overwrite##ffbProfileSave" + : (exists ? "Overwrite profile##ffbProfileSave" : "Save as profile##ffbProfileSave"); + if (ImGui::Button(saveLabel)) + { + const bool existsOnDisk = !requested.empty() && + WheelProfileStore::profile_exists(WheelProfileStore::Kind::ForceFeedback, requested); + if (existsOnDisk && !confirmingFfbOverwrite_) + { + confirmingFfbOverwrite_ = true; + status_ = "Click Confirm overwrite to replace the existing FFB profile."; + } + else + { + std::string error; + if (WheelProfileStore::save_ffb_profile(requested, &error)) + { + const bool currentSaved = Settings::write(Module::UserIniPath); + ffbDirty_ = !currentSaved; + if (currentSaved) + capture_saved_ffb(); + std::string pathError; + const auto savedPath = WheelProfileStore::profile_path( + WheelProfileStore::Kind::ForceFeedback, requested, &pathError); + const std::string savedWhere = savedPath ? savedPath->string() : requested; + status_ = currentSaved + ? "FFB profile saved: " + savedWhere + : "FFB profile saved to " + savedWhere + ", but current user.ini settings could not be persisted."; + confirmingFfbOverwrite_ = false; + refresh_ffb_profiles(requested); + } + else + { + status_ = "FFB profile save failed: " + error + " Folder: " + + WheelProfileStore::directory(WheelProfileStore::Kind::ForceFeedback).string(); + spdlog::error("{}", status_); + } + } + } + + ImGui::SameLine(); + const bool canUseProfile = selected_ffb_profile() != nullptr; + if (!canUseProfile) ImGui::BeginDisabled(); + if (ImGui::Button("Load selected##ffbProfileLoad")) + { + if (const std::string* profile = selected_ffb_profile()) + { + int applied = 0; + std::string error; + if (WheelProfileStore::load_ffb_profile(*profile, &applied, &error)) + { + WheelFFB_RequestSettingsTransition(); + const bool persisted = Settings::write(Module::UserIniPath); + ffbDirty_ = !persisted; + if (persisted) + capture_saved_ffb(); + status_ = persisted + ? "Loaded FFB profile '" + *profile + "' (" + std::to_string(applied) + " settings)." + : "FFB profile is active now, but could not be persisted to user.ini."; + } + else + status_ = error; + } + } + if (!canUseProfile) ImGui::EndDisabled(); + + ImGui::SameLine(); + if (!canUseProfile) ImGui::BeginDisabled(); + const char* deleteLabel = confirmingFfbDelete_ + ? "Confirm delete##ffbProfileDelete" : "Delete selected##ffbProfileDelete"; + if (ImGui::Button(deleteLabel)) + { + if (!confirmingFfbDelete_) + { + confirmingFfbDelete_ = true; + status_ = "Click Confirm delete to remove the selected FFB profile. Current forces will not change."; + } + else if (const std::string* profile = selected_ffb_profile()) + { + std::string error; + if (WheelProfileStore::delete_profile(WheelProfileStore::Kind::ForceFeedback, *profile, &error)) + { + status_ = "Deleted FFB profile: " + *profile; + ffbProfileName_[0] = '\0'; + refresh_ffb_profiles(); + } + else + status_ = error; + confirmingFfbDelete_ = false; + } + } + if (!canUseProfile) ImGui::EndDisabled(); + + ImGui::SameLine(); + if (ImGui::Button("Refresh FFB profiles")) + refresh_ffb_profiles(selected_ffb_profile() ? *selected_ffb_profile() : std::string{}); + const std::string ffbProfileFolder = + WheelProfileStore::directory(WheelProfileStore::Kind::ForceFeedback).string(); + ImGui::TextDisabled("Profile folder: %s", ffbProfileFolder.c_str()); + } + + void save() + { + UniversalWheelProfile::apply_now(); + if (Settings::write(Module::UserIniPath)) + { + ffbDirty_ = false; + capture_saved_ffb(); + status_ = "Saved to OutRun2006Tweaks.user.ini"; + } + else + status_ = "Could not save OutRun2006Tweaks.user.ini."; + } + + void select_device(const DeviceInfo& info, bool ffbOutput) + { + if (ffbOutput) + { + if (!info.ffb) + { + status_ = "That DirectInput interface does not advertise force feedback."; + return; + } + const bool changedOutput = + lower_identity(Settings::WheelFFBDeviceGuid.get()) != info.guidKey || + Settings::WheelFFBDeviceName.get() != info.name; + Settings::WheelFFBDeviceName = info.name; + Settings::WheelFFBDeviceGuid = info.guidKey; + if (changedOutput) + WheelFFB_ResetDirectionTest(); + if (Settings::write(Module::UserIniPath)) + { + ffbDirty_ = false; + capture_saved_ffb(); + status_ = "Selected FFB output and saved its exact DirectInput GUID. The FFB engine will reinitialize automatically on the next gameplay update."; + } + else + { + ffbDirty_ = true; + status_ = "Selected FFB output for this session, but could not save user.ini."; + } + return; + } + + Settings::WheelUniversalDeviceName = info.name; + Settings::WheelUniversalDeviceGuid = info.guidKey; + gReader.select_by_identity(info.guidKey, info.name); + const bool saved = Settings::write(Module::UserIniPath); + status_ = !saved + ? "Selected legacy input device for this session, but could not save user.ini." + : (UniversalWheelProfile::regular_device_count() > 1 + ? "Selected legacy input device. Confirm the OutRun legacy input slot below when multiple controllers are present." + : "Selected legacy input device. FFB output is configured separately below."); + } + + void begin_bind(BindTarget target) + { + target_ = target; + baselineValid_ = gReader.poll(); + if (baselineValid_) + baseline_ = gReader.state(); + status_ = baselineValid_ + ? "Move the requested axis or press the requested button..." + : "Could not read wheel. Configure from a game menu and try Refresh Devices."; + } + + bool target_is_axis() const + { + return target_ == BindTarget::Steering || target_ == BindTarget::Throttle || + target_ == BindTarget::Brake; + } + + void assign_axis(int axis) + { + if (target_ == BindTarget::Steering) Settings::WheelUniversalSteeringAxis = axis; + if (target_ == BindTarget::Throttle) Settings::WheelUniversalThrottleAxis = axis; + if (target_ == BindTarget::Brake) Settings::WheelUniversalBrakeAxis = axis; + target_ = BindTarget::None; + status_ = std::string("Bound Axis ") + axis_name(axis) + ". Check the live bar and Invert if needed."; + UniversalWheelProfile::apply_now(); + } + + void assign_digital(int code) + { + const bool gameplayButton = + target_ == BindTarget::GearUp || target_ == BindTarget::GearDown || + target_ == BindTarget::Start || target_ == BindTarget::View; + if (gameplayButton && code >= 128) + { + target_ = BindTarget::None; + status_ = "Gameplay actions currently require a physical button; POV is supported for menu directions."; + return; + } + + switch (target_) + { + case BindTarget::GearUp: Settings::WheelUniversalGearUpButton = code; break; + case BindTarget::GearDown: Settings::WheelUniversalGearDownButton = code; break; + case BindTarget::Start: Settings::WheelUniversalStartButton = code; break; + case BindTarget::View: Settings::WheelUniversalViewButton = code; break; + case BindTarget::MenuAccept: Settings::WheelUniversalMenuAccept = code; break; + case BindTarget::MenuBack: Settings::WheelUniversalMenuBack = code; break; + case BindTarget::MenuUp: Settings::WheelUniversalMenuUp = code; break; + case BindTarget::MenuRight: Settings::WheelUniversalMenuRight = code; break; + case BindTarget::MenuDown: Settings::WheelUniversalMenuDown = code; break; + case BindTarget::MenuLeft: Settings::WheelUniversalMenuLeft = code; break; + default: break; + } + target_ = BindTarget::None; + status_ = "Binding captured: " + digital_binding_name(code); + UniversalWheelProfile::apply_now(); + } + + void listen_for_binding() + { + if (target_ == BindTarget::None || !baselineValid_) + return; + if (ImGui::IsKeyPressed(ImGuiKey_Escape)) + { + target_ = BindTarget::None; + status_ = "Binding cancelled."; + return; + } + if (!gReader.poll()) + return; + + const auto& state = gReader.state(); + if (target_is_axis()) + { + int bestAxis = -1; + LONG bestDelta = 0; + for (int axis = 0; axis < 8; ++axis) + { + const LONG delta = std::abs(axis_raw(state, axis) - axis_raw(baseline_, axis)); + if (delta > bestDelta) + { + bestDelta = delta; + bestAxis = axis; + } + } + if (bestAxis >= 0 && bestDelta > 7000) + assign_axis(bestAxis); + return; + } + + for (int i = 0; i < 128; ++i) + { + const bool now = (state.rgbButtons[i] & 0x80) != 0; + const bool before = (baseline_.rgbButtons[i] & 0x80) != 0; + if (now && !before) + { + assign_digital(i); + return; + } + } + + for (int p = 0; p < 4; ++p) + { + const int now = pov_direction(state.rgdwPOV[p]); + const int before = pov_direction(baseline_.rgdwPOV[p]); + if (now >= 0 && now != before) + { + assign_digital(1000 + p * 4 + now); + return; + } + } + } + + static float normalized_axis(int axis, bool steering, bool invert) + { + if (axis < 0 || !gReader.poll()) + return 0.0f; + float v = std::clamp(axis_raw(gReader.state(), axis) / 65535.0f, 0.0f, 1.0f); + if (steering) + { + v = v * 2.0f - 1.0f; + if (invert) v = -v; + return v; + } + if (invert) v = 1.0f - v; + return v; + } + + void axis_row(const char* label, BindTarget target, Settings::Setting& axisSetting, + bool* invertPtr, bool steering) + { + ImGui::PushID(label); + ImGui::TextUnformatted(label); + ImGui::SameLine(180.0f); + ImGui::TextDisabled("Axis %s", axis_name(int(axisSetting))); + ImGui::SameLine(290.0f); + const bool listeningHere = target_ == target; + if (listeningHere) + { + ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyle().Colors[ImGuiCol_Text]); + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyle().Colors[ImGuiCol_WindowBg]); + } + if (ImGui::Button(listeningHere ? "LISTENING..." : "Bind", ImVec2(90, 0))) + begin_bind(target); + if (listeningHere) + ImGui::PopStyleColor(2); + if (invertPtr) + { + ImGui::SameLine(); + ImGui::Checkbox("Invert", invertPtr); + } + + bool invert = invertPtr ? *invertPtr : false; + const float v = normalized_axis(int(axisSetting), steering, invert); + const float bar = steering ? (v + 1.0f) * 0.5f : v; + char text[32]; + std::snprintf(text, sizeof(text), steering ? "%+.2f" : "%.2f", v); + ImGui::ProgressBar(std::clamp(bar, 0.0f, 1.0f), ImVec2(-FLT_MIN, 0), text); + ImGui::PopID(); + } + + void button_row(const char* label, BindTarget target, Settings::Setting& setting) + { + ImGui::PushID(label); + ImGui::TextUnformatted(label); + ImGui::SameLine(180.0f); + const std::string name = digital_binding_name(int(setting)); + ImGui::TextDisabled("%s", name.c_str()); + ImGui::SameLine(330.0f); + const bool listeningHere = target_ == target; + if (listeningHere) + { + ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyle().Colors[ImGuiCol_Text]); + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyle().Colors[ImGuiCol_WindowBg]); + } + if (ImGui::Button(listeningHere ? "LISTENING..." : "Bind", ImVec2(90, 0))) + begin_bind(target); + if (listeningHere) + ImGui::PopStyleColor(2); + ImGui::SameLine(); + if (ImGui::SmallButton("Clear")) + setting = -1; + ImGui::PopID(); + } + + void apply_r3_menu_defaults() + { + Settings::WheelUniversalMenuAccept = 0; + Settings::WheelUniversalMenuBack = 1; + Settings::WheelUniversalMenuUp = 4; + Settings::WheelUniversalMenuRight = 5; + Settings::WheelUniversalMenuDown = 6; + Settings::WheelUniversalMenuLeft = 7; + status_ = "Loaded common MOZA R3/ES menu button defaults. Verify with Bind if your rim differs."; + } + + public: + Kind kind() const override { return Kind::Tab; } + const char* name() const override + { + return Settings::UseNewInput ? "Force Feedback" : "Legacy Wheel Setup"; + } + int order() const override { return 25; } + void init() override {} + + void render(bool) override + { + listen_for_binding(); + if (!savedFfb_.valid) + capture_saved_ffb(); + const auto track_ffb_change = [this](bool changed) + { + if (changed) + { + ffbDirty_ = true; + WheelFFB_ResetHeadroomStats(); + } + return changed; + }; + + if (Settings::UseNewInput) + { + ImGui::TextWrapped( + "Force feedback only. With UseNewInput enabled, steering, pedals, buttons, menu controls and calibration come only from Input Bindings. This page does not create input bindings; it only selects the DirectInput FFB wheel and tunes its forces."); + if (ImGui::Button("Open Input Bindings")) + Overlay::RequestBindingDialog = true; + ImGui::SameLine(); + ImGui::TextDisabled("Configure steering, pedals, shifter and menu controls there first."); + } + else + { + ImGui::TextWrapped( + "Legacy DirectInput wheel setup. Use this page for compatibility-mode input bindings and force feedback."); + } + + const auto& devices = gReader.devices(); + if (ImGui::Button("Refresh Devices")) + gReader.refresh_devices(); + + std::uint16_t steeringVendor = 0; + std::uint16_t steeringProduct = 0; + if (Settings::UseNewInput) + { + const auto& steeringBindings = InputManager::instance.actionFor( + InputManager::ActionKind::Volume, int(ADChannel::Steering)).bindings(); + for (const auto& binding : steeringBindings) + { + if (binding.isRawDevice() && binding.deviceVendor != 0) + { + steeringVendor = binding.deviceVendor; + steeringProduct = binding.deviceProduct; + break; + } + } + } + + auto first_device_name = [&](bool ffbOnly) -> std::string + { + for (const auto& dev : devices) + if (!ffbOnly || dev.ffb) + return dev.name; + return ffbOnly ? "No force-feedback DirectInput device" : "No DirectInput device"; + }; + + auto draw_device_combo = [&](const char* label, bool ffbOnly, + const std::string& configuredGuidValue, const std::string& configuredNameValue, + bool ffbOutput) + { + const std::string configuredGuid = lower_identity(configuredGuidValue); + const std::string configuredName = lower_identity(configuredNameValue); + std::string autoMatchedFfbGuid; + if (ffbOutput && configuredGuid.empty() && !configuredName.empty()) + { + // Match the FFB engine's DeviceName behavior: before F11 has + // saved an exact GUID, values such as the shipped "MOZA" + // default are case-insensitive product-name substrings. + for (const auto& dev : devices) + { + if (dev.ffb && lower_identity(dev.name).find(configuredName) != std::string::npos) + { + autoMatchedFfbGuid = dev.guidKey; + break; + } + } + } + auto isSelectedDevice = [&](const DeviceInfo& dev) + { + if (ffbOnly && !dev.ffb) + return false; + if (!configuredGuid.empty()) + return dev.guidKey == configuredGuid; + if (ffbOutput && !autoMatchedFfbGuid.empty()) + return dev.guidKey == autoMatchedFfbGuid; + return dev.name == configuredNameValue; + }; + + std::string preview; + for (const auto& dev : devices) + { + if (isSelectedDevice(dev)) + { + preview = dev.name; + break; + } + } + if (preview.empty()) + { + if (!configuredNameValue.empty()) + preview = configuredNameValue + " (not connected)"; + else if (!configuredGuid.empty()) + preview = "Saved DirectInput device (not connected)"; + else + preview = first_device_name(ffbOnly); + } + + if (ImGui::BeginCombo(label, preview.c_str())) + { + for (const auto& dev : devices) + { + if (ffbOnly && !dev.ffb) + continue; + const bool selected = isSelectedDevice(dev); + const bool steeringMatch = ffbOutput && steeringVendor != 0 && + dev.vendor == steeringVendor && + (steeringProduct == 0 || dev.product == steeringProduct); + const std::string item = dev.name + + (steeringMatch ? " [recommended: steering device]" : "") + + "##" + label + dev.guidKey; + if (ImGui::Selectable(item.c_str(), selected)) + select_device(dev, ffbOutput); + if (selected) + ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + + for (const auto& dev : devices) + if (isSelectedDevice(dev)) + { + ImGui::TextDisabled("%lu axes / %lu buttons / %lu POV / FFB %s / VID:%04X PID:%04X / GUID %s", + dev.axes, dev.buttons, dev.povs, dev.ffb ? "yes" : "no", + unsigned(dev.vendor), unsigned(dev.product), dev.guidKey.c_str()); + if (ffbOutput && steeringVendor != 0 && dev.vendor == steeringVendor && + (steeringProduct == 0 || dev.product == steeringProduct)) + ImGui::TextColored(ImVec4(0.35f, 0.90f, 0.45f, 1.0f), + "Matches the steering device VID/PID (recommendation only). "); + } + }; + + if (Settings::UseNewInput) + { + ImGui::SameLine(); + draw_device_combo("FFB Output", true, + Settings::WheelFFBDeviceGuid.get(), Settings::WheelFFBDeviceName.get(), true); + } + else + { + ImGui::SameLine(); + draw_device_combo("Legacy Input Wheel", false, + Settings::WheelUniversalDeviceGuid.get(), Settings::WheelUniversalDeviceName.get(), false); + draw_device_combo("FFB Output", true, + Settings::WheelFFBDeviceGuid.get(), Settings::WheelFFBDeviceName.get(), true); + } + + if (Settings::UseNewInput) + { + const WheelFFBStatusSnapshot ffbStatus = WheelFFB_GetStatusSnapshot(); + ImGui::SeparatorText("Ready to Drive"); + const auto bound = [](InputManager::ActionKind kind, int index) + { + return !InputManager::instance.actionFor(kind, index).bindings().empty(); + }; + const bool steeringReady = bound(InputManager::ActionKind::Volume, int(ADChannel::Steering)); + const bool accelReady = bound(InputManager::ActionKind::Volume, int(ADChannel::Acceleration)); + const bool brakeReady = bound(InputManager::ActionKind::Volume, int(ADChannel::Brake)); + const bool driveButtonsReady = + bound(InputManager::ActionKind::Switch, int(SwitchId::GearUp)) && + bound(InputManager::ActionKind::Switch, int(SwitchId::GearDown)); + const bool menuReady = + bound(InputManager::ActionKind::Switch, int(SwitchId::A)) && + bound(InputManager::ActionKind::Switch, int(SwitchId::B)) && + bound(InputManager::ActionKind::Switch, int(SwitchId::SelectionUp)) && + bound(InputManager::ActionKind::Switch, int(SwitchId::SelectionDown)) && + bound(InputManager::ActionKind::Switch, int(SwitchId::SelectionLeft)) && + bound(InputManager::ActionKind::Switch, int(SwitchId::SelectionRight)); + const auto readiness = [](const char* label, bool ok) + { + ImGui::TextColored(ok ? ImVec4(0.35f, 0.90f, 0.45f, 1.0f) : ImVec4(1.0f, 0.45f, 0.25f, 1.0f), + "%s %s", ok ? "[OK]" : "[!]", label); + }; + const auto pending = [](const char* label) + { + ImGui::TextColored(ImVec4(0.85f, 0.78f, 0.35f, 1.0f), "[..] %s", label); + }; + readiness("Steering", steeringReady); + ImGui::SameLine(); readiness("Pedals", accelReady && brakeReady); + ImGui::SameLine(); readiness("Shifters", driveButtonsReady); + readiness("Menu controls", menuReady); + const bool gameplayFfbWindow = Game::is_in_game(); + ImGui::SameLine(); + if (!Settings::WheelFFBEnable) + readiness("FFB disabled", true); + else if (ffbStatus.initialized) + readiness("FFB device", true); + else if (!gameplayFfbWindow) + pending("FFB device (starts in gameplay)"); + else + readiness("FFB device", false); + ImGui::SameLine(); + if (!Settings::WheelFFBEnable || ffbStatus.directionTested) + readiness("Direction test", true); + else + pending("Direction test (not run)"); + + ImGui::SeparatorText("FFB Runtime Status"); + ImGui::Text("Engine: %s Device: %s Output owner: %s", + ffbStatus.initialized ? "ready" : "waiting", + ffbStatus.acquired ? "acquired" : "released", + ffbStatus.outputOwner ? "active" : "inactive"); + if (Settings::WheelFFBEnable && !ffbStatus.initialized && !gameplayFfbWindow) + ImGui::TextDisabled("Waiting / released / inactive is normal outside gameplay. The saved FFB GUID is acquired when driving starts."); + if (Settings::WheelFFBEnable && !ffbStatus.directionTested) + ImGui::TextDisabled("Direction test is a setup confirmation, not a hardware error. Run Test Left/Right after the FFB engine is ready."); + ImGui::Text("Effects: Constant %s | Spring %s | Damper %s | Periodic %s", + ffbStatus.constantEffect ? "HW" : "-", + ffbStatus.springEffect ? "HW" : "SW", + ffbStatus.damperEffect ? "HW" : "SW", + ffbStatus.periodicEffects ? "HW" : "SW"); + if (ffbStatus.ffbStateValid) + { + ImGui::Text("Driver state: actuators %s | power %s | safety %s | user switch %s%s%s", + ffbStatus.actuatorsOn ? "ON" : "OFF", + ffbStatus.powerOn ? "ON" : "OFF", + ffbStatus.safetySwitchOn ? "ON" : "OFF", + ffbStatus.userSwitchOn ? "ON" : "OFF", + ffbStatus.paused ? " | PAUSED" : "", + ffbStatus.deviceLost ? " | DEVICE LOST" : ""); + if (ffbStatus.powerOff || ffbStatus.safetySwitchOff || ffbStatus.userSwitchOff || ffbStatus.deviceLost) + ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.25f, 1.0f), + "Wheel/driver explicitly reports FFB disabled or unavailable. This page will not override a hardware safety/user switch."); + } + const auto capability_text = [](bool known, bool dynamic) + { + return !known ? "unknown" : (dynamic ? "yes" : "no"); + }; + ImGui::TextDisabled("Dynamic effect capability: Constant %s, POLAR direction %s, Spring %s, Damper %s, Sine %s", + capability_text(ffbStatus.constantCapsKnown, ffbStatus.constantDynamic), + capability_text(ffbStatus.constantCapsKnown, ffbStatus.polarDirectionDynamic), + capability_text(ffbStatus.springCapsKnown, ffbStatus.springDynamic), + capability_text(ffbStatus.damperCapsKnown, ffbStatus.damperDynamic), + capability_text(ffbStatus.periodicCapsKnown, ffbStatus.periodicDynamic)); + } + + draw_ffb_profiles(); + + if (!Settings::UseNewInput) + { + const int regularSlots = UniversalWheelProfile::regular_device_count(); + if (regularSlots == 1) + Settings::WheelUniversalLegacyDeviceIndex = 0; + if (regularSlots > 0) + { + int currentSlot = std::clamp( + int(Settings::WheelUniversalLegacyDeviceIndex), 0, + std::min(regularSlots - 1, 2)); + if (currentSlot != int(Settings::WheelUniversalLegacyDeviceIndex)) + Settings::WheelUniversalLegacyDeviceIndex = currentSlot; + const std::string slotPreview = + "OutRun slot " + std::to_string(currentSlot + 1); + if (ImGui::BeginCombo("Legacy input slot", slotPreview.c_str())) + { + for (int slot = 0; slot < std::min(regularSlots, 3); ++slot) + { + const std::string label = + "OutRun slot " + std::to_string(slot + 1); + const bool selected = slot == currentSlot; + if (ImGui::Selectable(label.c_str(), selected)) + Settings::WheelUniversalLegacyDeviceIndex = slot; + if (selected) + ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip( + "This is OutRun's original DirectInput slot, not the filtered Wheel combo index. Usually slot 1 when only one wheel is connected."); + } + + if (ImGui::Checkbox("Enable F11 universal wheel profile", Settings::WheelUniversalSetupEnable.ptr())) + { + status_ = Settings::WheelUniversalSetupEnable + ? "Universal profile owns legacy menu input while enabled; configured R3 helpers remain saved and dormant." + : "Universal profile disabled; configured legacy R3 helpers can resume automatically."; + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("When enabled, these bindings are written into OutRun's original legacy DirectInput device mapping."); + + ImGui::SameLine(); + if (ImGui::Button("Import current game mapping")) + { + if (UniversalWheelProfile::copy_current_mapping()) + { + Settings::WheelUniversalSetupEnable = true; + status_ = "Imported current steering/pedal/shift bindings. Universal menu ownership enabled; saved R3 helpers remain dormant until Universal is disabled."; + } + else + status_ = "No regular legacy DirectInput game device is ready yet."; + } + + ImGui::SeparatorText("Driving"); + axis_row("Steering", BindTarget::Steering, Settings::WheelUniversalSteeringAxis, + Settings::WheelUniversalSteeringInvert.ptr(), true); + axis_row("Accelerator", BindTarget::Throttle, Settings::WheelUniversalThrottleAxis, + Settings::WheelAccelerationInvert.ptr(), false); + axis_row("Brake", BindTarget::Brake, Settings::WheelUniversalBrakeAxis, + Settings::WheelBrakeInvert.ptr(), false); + + button_row("Gear Up", BindTarget::GearUp, Settings::WheelUniversalGearUpButton); + button_row("Gear Down", BindTarget::GearDown, Settings::WheelUniversalGearDownButton); + button_row("Start", BindTarget::Start, Settings::WheelUniversalStartButton); + button_row("Change View", BindTarget::View, Settings::WheelUniversalViewButton); + + ImGui::SeparatorText("Menus"); + button_row("Accept / OK", BindTarget::MenuAccept, Settings::WheelUniversalMenuAccept); + button_row("Back / Cancel", BindTarget::MenuBack, Settings::WheelUniversalMenuBack); + button_row("Menu Up", BindTarget::MenuUp, Settings::WheelUniversalMenuUp); + button_row("Menu Right", BindTarget::MenuRight, Settings::WheelUniversalMenuRight); + button_row("Menu Down", BindTarget::MenuDown, Settings::WheelUniversalMenuDown); + button_row("Menu Left", BindTarget::MenuLeft, Settings::WheelUniversalMenuLeft); + + if (ImGui::Button("MOZA R3/ES menu defaults")) + apply_r3_menu_defaults(); + } + + ImGui::SeparatorText("Simulation FFB"); + track_ffb_change(ImGui::Checkbox("Enable Force Feedback", Settings::WheelFFBEnable.ptr())); + ImGui::TextDisabled("gameplay FFB follows the exact selected DirectInput GUID."); + ImGui::TextWrapped( + "Single-owner wheel FFB: DirectInput COM only. field_264/268 are lateral load only; front slip drives a pneumatic + mechanical/caster SAT model, while body/front slip release damping. Centering Spring remains a low-speed stabilizer."); + ImGui::TextDisabled("Settings > WheelFFB is hidden; changes on this page apply live. Gamepad rumble is suppressed only while DirectInput FFB owns an output device."); + + ImGui::SeparatorText("Physics / Structural"); + track_ffb_change(ImGui::SliderFloat("Overall Strength", Settings::WheelFFBGlobalStrength.ptr(), 0.0f, 1.5f, "%.2f")); + if (Settings::WheelFFBGlobalStrength.get() > 1.0f) + ImGui::TextColored(ImVec4(1.0f, 0.65f, 0.25f, 1.0f), + "Above 100% trades force-detail contrast for extra weight."); + track_ffb_change(ImGui::SliderFloat("Self-aligning Torque (SAT)", Settings::WheelFFBSteeringWeight.ptr(), 0.0f, 2.00f, "%.2f")); + track_ffb_change(ImGui::Checkbox("Physics SAT (body slip + yaw)", Settings::WheelFFBPhysicsSat.ptr())); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Uses post-physics OutRun car motion/body heading to estimate front slip. Disable for the Natural SAT comparison."); + if (Settings::WheelFFBPhysicsSat) + { + track_ffb_change(ImGui::SliderFloat("Mechanical / Caster Trail", Settings::WheelFFBMechanicalTrail.ptr(), 0.0f, 0.60f, "%.2f")); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Normalized mechanical/caster trail acts with front lateral force throughout a corner. 0 disables it; this is not a centre spring."); + } + track_ffb_change(ImGui::SliderFloat("Grip-loss Response", Settings::WheelFFBGripLoss.ptr(), 0.0f, 1.0f, "%.2f")); + + ImGui::SeparatorText("Steering Feel"); + track_ffb_change(ImGui::SliderFloat("Centering Spring (low speed)", Settings::WheelFFBSpringStrength.ptr(), 0.0f, 1.0f, "%.2f")); + track_ffb_change(ImGui::SliderFloat("Dynamic Damping", Settings::WheelFFBDamperStrength.ptr(), 0.0f, 1.0f, "%.2f")); + track_ffb_change(ImGui::Checkbox("Hardware GUID_Spring", Settings::WheelFFBUseHardwareSpring.ptr())); + ImGui::SameLine(); + track_ffb_change(ImGui::Checkbox("Hardware GUID_Damper", Settings::WheelFFBUseHardwareDamper.ptr())); + ImGui::TextDisabled("Wheelbase/driver-side spring, damping, inertia or friction are additional forces; keep them conservative while tuning game-side feel."); + + ImGui::SeparatorText("Effects"); + track_ffb_change(ImGui::SliderFloat("Road Detail", Settings::WheelFFBRoadTexture.ptr(), 0.0f, 1.0f, "%.2f")); + track_ffb_change(ImGui::SliderFloat("Tire Slip", Settings::WheelFFBTireSlip.ptr(), 0.0f, 0.50f, "%.2f")); + track_ffb_change(ImGui::SliderFloat("Collision", Settings::WheelFFBWallImpact.ptr(), 0.0f, 1.0f, "%.2f")); + track_ffb_change(ImGui::Checkbox("Engine Vibration", Settings::WheelFFBEngineVibration.ptr())); + if (!Settings::WheelFFBEngineVibration) ImGui::BeginDisabled(); + track_ffb_change(ImGui::SliderFloat("Engine Vibration Strength", Settings::WheelFFBEngineIdle.ptr(), 0.0f, 1.0f, "%.2f")); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Optional estimated-RPM texture. Default OFF. Strength is normalized and internally limited so 0.20 remains subtle."); + if (!Settings::WheelFFBEngineVibration) ImGui::EndDisabled(); + track_ffb_change(ImGui::Checkbox("Hardware road/slip sine effects", Settings::WheelFFBUsePeriodicEffects.ptr())); + + if (ImGui::CollapsingHeader("Advanced FFB tuning")) + { + track_ffb_change(ImGui::SliderFloat("Spring Saturation", Settings::WheelFFBSpringSaturation.ptr(), 0.10f, 1.0f, "%.3f")); + track_ffb_change(ImGui::SliderFloat("Weight Transfer", Settings::WheelFFBWeightTransfer.ptr(), 0.0f, 1.5f, "%.2f")); + track_ffb_change(ImGui::SliderFloat("Lateral Signal Deadzone", Settings::WheelFFBLateralDeadzone.ptr(), 0.0f, 8.0f, "%.2f")); + track_ffb_change(ImGui::SliderFloat("Gear Shift", Settings::WheelFFBGearShift.ptr(), 0.0f, 1.0f, "%.2f")); + track_ffb_change(ImGui::SliderFloat("Force Build Slew Rate", Settings::WheelFFBSlewRate.ptr(), 0.01f, 1.0f, "%.3f")); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Maximum normal structural-force build change per 60 Hz tick. Lower is smoother/slower; higher responds faster."); + track_ffb_change(ImGui::SliderFloat("Countersteer Release Rate", Settings::WheelFFBReversalReleaseRate.ptr(), 0.02f, 1.0f, "%.3f")); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("When SAT reverses direction, stale torque unloads at least this fast before the new direction builds. This reduces counter-steer latency without speeding up normal force buildup."); + const float buildRate = std::max(0.01f, float(Settings::WheelFFBSlewRate)); + const float reversalRate = std::max(0.02f, float(Settings::WheelFFBReversalReleaseRate)); + ImGui::TextDisabled("Approx full-scale ramp: build %.0f ms | stale reversal release %.0f ms at 60 Hz.", + (1.0f / buildRate) * (1000.0f / 60.0f), + (1.0f / reversalRate) * (1000.0f / 60.0f)); + if (Settings::WheelFFBPhysicsSat) + { + ImGui::SeparatorText("Physics SAT transient"); + track_ffb_change(ImGui::SliderFloat("Pneumatic Trail Response Lead", Settings::WheelFFBTrailResponseLead.ptr(), 0.0f, 0.60f, "%.2f")); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Lets pneumatic trail react part-way toward raw front slip while lateral force and torque direction stay filtered. Higher reduces SAT lag but can expose more telemetry noise."); + } + + ImGui::SeparatorText("Wheel hardware calibration"); + track_ffb_change(ImGui::Checkbox("Enable wheel response correction", Settings::WheelFFBResponseCorrection.ptr())); + track_ffb_change(ImGui::SliderFloat("Wheel peak torque (Nm, 0=unknown)", Settings::WheelFFBMaxTorqueNm.ptr(), 0.0f, 30.0f, "%.1f")); + + WheelFFBMath::ResponseLUT responseLut{}; + const bool responseLutValid = WheelFFBMath::parse_response_lut( + Settings::WheelFFBResponseLUT.get(), responseLut); + if (!responseLutValid) + { + ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.3f, 1.0f), + "Response LUT is invalid; output remains linear."); + if (ImGui::Button("Reset response LUT to linear")) + { + Settings::WheelFFBResponseLUT = WheelFFBMath::format_response_lut( + WheelFFBMath::linear_response_lut()); + track_ffb_change(true); + } + } + else if (Settings::WheelFFBResponseCorrection) + { + ImGui::TextDisabled("Desired torque -> DirectInput command. Endpoints stay fixed at 0%% / 100%%."); + for (size_t point = 1; point + 1 < responseLut.size(); ++point) + { + float value = responseLut[point]; + char label[64]{}; + std::snprintf(label, sizeof(label), "%u%% desired torque##ResponseLut%u", + static_cast(point * 10), static_cast(point)); + if (ImGui::SliderFloat(label, &value, responseLut[point - 1], responseLut[point + 1], "%.3f")) + { + responseLut[point] = value; + Settings::WheelFFBResponseLUT = WheelFFBMath::format_response_lut(responseLut); + track_ffb_change(true); + } + } + } + ImGui::TextDisabled("Response correction / max torque are stored with the wheel profile, not named FFB feel profiles. Leave correction off for a linear DD wheel unless measured."); + ImGui::TextDisabled("Advanced values apply live like the main controls; use Save Force Feedback to persist them."); + } + + track_ffb_change(ImGui::Checkbox("Diagnostic logging", Settings::WheelFFBDebugLog.ptr())); + track_ffb_change(ImGui::Checkbox("Record driving telemetry (10 Hz)", Settings::WheelFFBTelemetry.ptr())); + track_ffb_change(ImGui::Checkbox("Reverse SAT / ConstantForce", Settings::WheelFFBInvertForce.ptr())); + ImGui::SameLine(); + track_ffb_change(ImGui::Checkbox("Reverse Spring", Settings::WheelFFBInvertSpring.ptr())); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Use Reverse Spring only if the wheel pushes farther away from centre. ConstantForce direction is independent."); + + if (ffbDirty_) + ImGui::TextDisabled("Unsaved FFB changes are active now but will be lost after restart."); + if (ImGui::Button(ffbDirty_ ? "Save Force Feedback*" : "Save Force Feedback")) + { + if (Settings::write(Module::UserIniPath)) + { + ffbDirty_ = false; + capture_saved_ffb(); + status_ = "Force feedback settings saved."; + } + else + status_ = "Could not save force feedback settings."; + } + ImGui::SameLine(); + if (!ffbDirty_ || !savedFfb_.valid) ImGui::BeginDisabled(); + if (ImGui::Button("Revert unsaved FFB")) + { + restore_saved_ffb(); + ffbDirty_ = false; + WheelFFB_RequestSettingsTransition(); + status_ = "Reverted live FFB tuning to the last saved state."; + } + if (!ffbDirty_ || !savedFfb_.valid) ImGui::EndDisabled(); + + ImGui::SeparatorText("FFB Headroom / Clipping"); + const WheelFFBHeadroomSnapshot headroom = WheelFFB_GetHeadroomSnapshot(); + ImGui::Text("Current structural demand: %.0f%% Peak: %.0f%%", + headroom.currentDemand * 100.0f, headroom.peakDemand * 100.0f); + ImGui::Text("P95: %.0f%% P99: %.0f%% samples: %llu (%.1fs)", + headroom.p95Demand * 100.0f, headroom.p99Demand * 100.0f, + static_cast(headroom.samples), + static_cast(headroom.samples) / 60.0f); + ImGui::Text("Soft-knee demand (>75%%): %.1f%% hard-cap demand (>=135%%): %.2f%%", + headroom.softKneePercent, headroom.hardClipPercent); + if (Settings::WheelFFBMaxTorqueNm.get() > 0.0f && headroom.samples > 0) + { + ImGui::TextDisabled("P99 requested equivalent: %.2f Nm on a %.1f Nm wheel (before hardware LUT/cap).", + headroom.p99Demand * Settings::WheelFFBMaxTorqueNm.get(), + Settings::WheelFFBMaxTorqueNm.get()); + } + if (headroom.samples >= 600) + ImGui::Text("Suggested Overall Strength: %.2f (targets structural P99 near 90%% before wheel LUT)", + headroom.suggestedOverall); + else + ImGui::TextDisabled("Drive normally for at least 10 seconds; 20-30 seconds with several corners is better before trusting the suggestion."); + ImGui::TextDisabled("Collision, gear events, startup/recreate ramps and near-stop frames are excluded from the statistics."); + if (ImGui::Button("Reset headroom analysis")) + WheelFFB_ResetHeadroomStats(); + + ImGui::SeparatorText("Recent FFB Pipeline (last 3 seconds of driving)"); + const WheelFFBGraphSnapshot graph = WheelFFB_GetGraphSnapshot(); + if (graph.count > 1) + { + ImGui::PlotLines("Raw structural", graph.rawStructural.data(), int(graph.count), 0, nullptr, -1.5f, 1.5f, ImVec2(0, 46)); + ImGui::PlotLines("Soft limited", graph.softLimited.data(), int(graph.count), 0, nullptr, -1.1f, 1.1f, ImVec2(0, 46)); + ImGui::PlotLines("Post slew", graph.postSlew.data(), int(graph.count), 0, nullptr, -1.1f, 1.1f, ImVec2(0, 46)); + ImGui::PlotLines("Final DirectInput", graph.finalOutput.data(), int(graph.count), 0, nullptr, -1.1f, 1.1f, ImVec2(0, 46)); + } + else + ImGui::TextDisabled("Drive for a moment, then open F11 to inspect the captured force pipeline."); + + ImGui::SeparatorText("Safe direction test"); + if (ImGui::Button("Test Left (20%)")) + WheelFFB_RequestDirectionTest(-1); + ImGui::SameLine(); + if (ImGui::Button("Test Right (20%)")) + WheelFFB_RequestDirectionTest(1); + ImGui::SameLine(); + if (ImGui::Button("Stop Test")) + WheelFFB_RequestDirectionTest(0); + ImGui::TextDisabled("Direction tests are hard-capped at 20% and only run during active gameplay."); + + if (ImGui::Button("Load MOZA R3 Physics SAT")) + { + Settings::WheelFFBEnable = true; + Settings::WheelFFBPhysicsSat = true; + Settings::WheelFFBGlobalStrength = 0.70f; + Settings::WheelFFBSpringStrength = 0.65f; + Settings::WheelFFBSpringSaturation = 0.95f; + Settings::WheelFFBDamperStrength = 0.28f; + Settings::WheelFFBSteeringWeight = 1.45f; + Settings::WheelFFBMechanicalTrail = 0.25f; + Settings::WheelFFBTrailResponseLead = 0.25f; + Settings::WheelFFBGripLoss = 0.65f; + Settings::WheelFFBWeightTransfer = 0.15f; + Settings::WheelFFBSlewRate = 0.040f; + Settings::WheelFFBReversalReleaseRate = 0.12f; + Settings::WheelFFBRoadTexture = 0.30f; + Settings::WheelFFBTireSlip = 0.20f; + Settings::WheelFFBWallImpact = 0.38f; + Settings::WheelFFBUseHardwareSpring = true; + Settings::WheelFFBUseHardwareDamper = true; + Settings::WheelFFBUsePeriodicEffects = true; + Settings::WheelFFBInvertForce = true; + Settings::WheelFFBInvertSpring = false; + Settings::WheelFFBDebugLog = true; + Settings::VibrationMode = 0; + WheelFFB_RequestSettingsTransition(); + if (Settings::write(Module::UserIniPath)) + { + ffbDirty_ = false; + capture_saved_ffb(); + status_ = "Loaded MOZA R3 Physics SAT: speed-adaptive front slip plus pneumatic/mechanical trail SAT; diagnostic logging enabled. Saved to user.ini."; + } + else + { + ffbDirty_ = true; + status_ = "Loaded MOZA R3 Physics SAT for this session, but could not save user.ini."; + } + } + ImGui::SameLine(); + + if (ImGui::Button("Load MOZA R3 Natural SAT")) + { + Settings::WheelFFBPhysicsSat = false; + Settings::WheelFFBEnable = true; + Settings::WheelFFBGlobalStrength = 0.70f; + Settings::WheelFFBSpringStrength = 0.65f; + Settings::WheelFFBSpringSaturation = 0.95f; + Settings::WheelFFBDamperStrength = 0.30f; + Settings::WheelFFBSteeringWeight = 1.75f; + Settings::WheelFFBMechanicalTrail = 0.25f; + Settings::WheelFFBTrailResponseLead = 0.25f; + Settings::WheelFFBGripLoss = 0.65f; + Settings::WheelFFBWeightTransfer = 0.20f; + Settings::WheelFFBSlewRate = 0.045f; + Settings::WheelFFBReversalReleaseRate = 0.12f; + Settings::WheelFFBRoadTexture = 0.30f; + Settings::WheelFFBTireSlip = 0.20f; + Settings::WheelFFBWallImpact = 0.38f; + Settings::WheelFFBUseHardwareSpring = true; + Settings::WheelFFBUseHardwareDamper = true; + Settings::WheelFFBUsePeriodicEffects = true; + Settings::WheelFFBInvertForce = true; + Settings::WheelFFBInvertSpring = false; + Settings::VibrationMode = 0; + WheelFFB_RequestSettingsTransition(); + if (Settings::write(Module::UserIniPath)) + { + ffbDirty_ = false; + capture_saved_ffb(); + status_ = "Loaded MOZA R3 Natural SAT: smooth progressive SAT, low-speed-only spring assist and single DirectInput COM wheel FFB. Saved to user.ini."; + } + else + { + ffbDirty_ = true; + status_ = "Loaded MOZA R3 Natural SAT for this session, but could not save user.ini."; + } + } + + if (!Settings::UseNewInput) + { + ImGui::SeparatorText("Wheel options"); + int deadzonePercent = int(float(Settings::SteeringDeadZone) * 100.0f + 0.5f); + if (ImGui::SliderInt("Steering Deadzone", &deadzonePercent, 0, 20, "%d%%")) + Settings::SteeringDeadZone = deadzonePercent / 100.0f; + + if (ImGui::Button("Apply Now")) + { + UniversalWheelProfile::apply_now(); + status_ = "Applied to the current legacy game device."; + } + ImGui::SameLine(); + if (ImGui::Button("Save Wheel Profile")) + save(); + } + + if (target_ != BindTarget::None) + { + ImGui::Separator(); + ImGui::TextColored(ImVec4(1, 1, 0, 1), "Listening for input... (Esc cancels)"); + } + if (!status_.empty()) + ImGui::TextWrapped("%s", status_.c_str()); + + ImGui::Spacing(); + ImGui::TextDisabled( + Settings::UseNewInput + ? "Input setup: Input Bindings only. FFB selection and tuning apply live; Save Force Feedback persists them." + : "Legacy input and DirectInput FFB output are selected separately on this page. FFB selection and tuning apply live."); + } + + static WheelSetupWindow instance; + }; + + WheelSetupWindow WheelSetupWindow::instance; +} diff --git a/src/plugin.hpp b/src/plugin.hpp index 39da38dc5..dcd6e488e 100644 --- a/src/plugin.hpp +++ b/src/plugin.hpp @@ -82,6 +82,7 @@ namespace Settings extern Setting FramerateLimit; // hooks_framerate.cpp extern Setting FramerateFastLoad; // hooks_framerate.cpp extern Setting FramerateInterpolation; // hooks_framerate.cpp + extern Setting VREnabled; // vr/settings.cpp extern Setting FramerateInterpolationDebugAlpha; // interpolation.cpp extern Setting FramerateInterpolationDebugLog; // interpolation.cpp @@ -96,10 +97,13 @@ namespace Settings extern Setting AllowFLAC; // hooks_flac.cpp + extern Setting WheelInputCompatibility; // input_manager.cpp extern Setting UseNewInput; // input_manager.cpp extern Setting BypassGameSensitivity; // input_manager.cpp extern Setting SteeringDeadZone; // hooks_input.cpp extern Setting ControllerHotPlug; // hooks_input.cpp + extern Setting WheelAccelerationInvert; // hooks_input.cpp + extern Setting WheelBrakeInvert; // hooks_input.cpp extern Setting ImpulseVibrationMode; // hooks_input.cpp extern Setting ImpulseVibrationLeftMultiplier; // hooks_input.cpp extern Setting ImpulseVibrationRightMultiplier; // hooks_input.cpp diff --git a/src/settings.cpp b/src/settings.cpp index e75857f74..dfcb5bca1 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -3,6 +3,7 @@ #define WIN32_LEAN_AND_MEAN #include #include +#include #include #include #include @@ -124,6 +125,7 @@ namespace Settings size_t pos = 0; double parsed = std::stod(s, &pos); + if (!std::isfinite(parsed)) return false; if (pos != s.size()) return false; @@ -154,7 +156,10 @@ namespace Settings // The three-argument Get hands back the value it was given when the key // isn't in the file, so a setting missing from one INI keeps whatever an // earlier INI or its own default left it at. - value_ = ini.Get(std::string(section()), std::string(key()), value_); + const T parsed = ini.Get(std::string(section()), std::string(key()), value_); + if constexpr (std::is_floating_point_v) + if (!std::isfinite(parsed)) return; + value_ = parsed; if constexpr (!std::is_same_v) { diff --git a/src/vr/core/frame_types.hpp b/src/vr/core/frame_types.hpp new file mode 100644 index 000000000..65c3ab81b --- /dev/null +++ b/src/vr/core/frame_types.hpp @@ -0,0 +1,101 @@ +#pragma once + +#include +#include + +namespace OutRunVR::Core +{ + struct Fov + { + float angleLeft{}; + float angleRight{}; + float angleUp{}; + float angleDown{}; + }; + + struct Quaternion + { + float x{}; + float y{}; + float z{}; + float w{1.0f}; + }; + + struct Vector3 + { + float x{}; + float y{}; + float z{}; + }; + + struct EyeView + { + Quaternion orientation{}; + Vector3 positionMeters{}; + Fov fov{}; + }; + + struct RenderPose + { + std::uint64_t poseId{}; + std::int64_t predictedDisplayTime{}; + std::int64_t sampleQpc{}; + Quaternion headOrientation{}; + Vector3 headPositionMeters{}; + std::array eyes{}; + std::uint32_t referenceSpaceGeneration{}; + bool orientationValid{}; + bool positionValid{}; + bool viewsValid{}; + bool shouldRender{}; + }; + + enum class PresentationMode : std::uint32_t + { + Unknown = 0, + Gameplay = 1, + Theater = 2, + }; + + enum class FrameFailure : std::uint32_t + { + None = 0, + MissingPose, + ResourceUnavailable, + UnsafeMrt, + ViewportUnavailable, + LeftProjectionFailed, + LeftDrawFailed, + RightStateFailed, + RightProjectionFailed, + RightDrawFailed, + RestoreFailed, + ComposeFailed, + PresentFailed, + DepthStateChanged, + PoseMismatch, + DepthUnsynchronized, + ClearFailed, + WorldClassificationFailed, + StencilUnsynchronized, + OffscreenWorld, + OcclusionQueryActive, + TransportBackpressure, + TransportSynchronizationFailed, + }; + + struct PresentedFrame + { + std::uint64_t frameId{}; + std::uint64_t renderPoseId{}; + std::int64_t presentQpc{}; + PresentationMode presentation{PresentationMode::Unknown}; + FrameFailure failure{FrameFailure::None}; + std::uint32_t width{}; + std::uint32_t height{}; + std::array renderedEyes{}; + bool stereoComplete{}; + bool worldStereo{}; + bool drawDuplicated{}; + }; +} diff --git a/src/vr/core/matrix.hpp b/src/vr/core/matrix.hpp new file mode 100644 index 000000000..ecb2ca526 --- /dev/null +++ b/src/vr/core/matrix.hpp @@ -0,0 +1,165 @@ +#pragma once + +#include "vr/core/frame_types.hpp" + +#include +#include +#include + +namespace OutRunVR::Core +{ + struct Matrix4 + { + float m[4][4]{}; + + float* operator[](std::size_t row) noexcept { return m[row]; } + const float* operator[](std::size_t row) const noexcept { return m[row]; } + }; + + inline Matrix4 IdentityMatrix() noexcept + { + Matrix4 out{}; + out[0][0] = out[1][1] = out[2][2] = out[3][3] = 1.0f; + return out; + } + + inline bool MatrixFinite(const Matrix4& matrix) noexcept + { + for (const auto& row : matrix.m) + for (const float value : row) + if (!std::isfinite(value)) + return false; + return true; + } + + inline Matrix4 Multiply(const Matrix4& a, const Matrix4& b) noexcept + { + Matrix4 out{}; + for (int r = 0; r < 4; ++r) + for (int c = 0; c < 4; ++c) + for (int k = 0; k < 4; ++k) + out[r][c] += a[r][k] * b[k][c]; + return out; + } + + inline Matrix4 Transpose(const Matrix4& in) noexcept + { + Matrix4 out{}; + for (int r = 0; r < 4; ++r) + for (int c = 0; c < 4; ++c) + out[r][c] = in[c][r]; + return out; + } + + inline bool Invert(const Matrix4& in, Matrix4& out) noexcept + { + float a[4][8]{}; + for (int r = 0; r < 4; ++r) + { + for (int c = 0; c < 4; ++c) + a[r][c] = in[r][c]; + a[r][4 + r] = 1.0f; + } + + for (int col = 0; col < 4; ++col) + { + int pivot = col; + float pivotAbs = std::fabs(a[pivot][col]); + for (int r = col + 1; r < 4; ++r) + { + const float candidate = std::fabs(a[r][col]); + if (candidate > pivotAbs) + { + pivot = r; + pivotAbs = candidate; + } + } + if (!std::isfinite(pivotAbs) || pivotAbs < 1.0e-8f) + return false; + if (pivot != col) + for (int c = 0; c < 8; ++c) + std::swap(a[pivot][c], a[col][c]); + + const float divisor = a[col][col]; + for (int c = 0; c < 8; ++c) + a[col][c] /= divisor; + + for (int r = 0; r < 4; ++r) + { + if (r == col) + continue; + const float factor = a[r][col]; + for (int c = 0; c < 8; ++c) + a[r][c] -= factor * a[col][c]; + } + } + + for (int r = 0; r < 4; ++r) + for (int c = 0; c < 4; ++c) + out[r][c] = a[r][c + 4]; + return MatrixFinite(out); + } + + inline Matrix4 RigidTransform(const Quaternion& qIn, const Vector3& position, + float positionScale) noexcept + { + float x = qIn.x; + float y = qIn.y; + float z = qIn.z; + float w = qIn.w; + const float lenSq = x*x + y*y + z*z + w*w; + if (!std::isfinite(lenSq) || lenSq <= 1.0e-12f) + return IdentityMatrix(); + + const float inv = 1.0f / std::sqrt(lenSq); + x *= inv; y *= inv; z *= inv; w *= inv; + + const float xx=x*x, yy=y*y, zz=z*z, xy=x*y, xz=x*z, yz=y*z; + const float xw=x*w, yw=y*w, zw=z*w; + Matrix4 out = IdentityMatrix(); + out[0][0]=1-2*(yy+zz); out[0][1]=2*(xy+zw); out[0][2]=2*(xz-yw); + out[1][0]=2*(xy-zw); out[1][1]=1-2*(xx+zz); out[1][2]=2*(yz+xw); + out[2][0]=2*(xz+yw); out[2][1]=2*(yz-xw); out[2][2]=1-2*(xx+yy); + out[3][0]=position.x*positionScale; + out[3][1]=position.y*positionScale; + out[3][2]=position.z*positionScale; + return out; + } + + inline Matrix4 InverseRigid(const Matrix4& m) noexcept + { + Matrix4 out = IdentityMatrix(); + out[0][0]=m[0][0]; out[0][1]=m[1][0]; out[0][2]=m[2][0]; + out[1][0]=m[0][1]; out[1][1]=m[1][1]; out[1][2]=m[2][1]; + out[2][0]=m[0][2]; out[2][1]=m[1][2]; out[2][2]=m[2][2]; + out[3][0]=-(m[3][0]*out[0][0] + m[3][1]*out[1][0] + m[3][2]*out[2][0]); + out[3][1]=-(m[3][0]*out[0][1] + m[3][1]*out[1][1] + m[3][2]*out[2][1]); + out[3][2]=-(m[3][0]*out[0][2] + m[3][1]*out[1][2] + m[3][2]*out[2][2]); + return out; + } + + inline bool ProjectionFromOpenXrFov(const Matrix4& base, const Fov& fov, + Matrix4& out) noexcept + { + const float tanLeft = std::tan(fov.angleLeft); + const float tanRight = std::tan(fov.angleRight); + const float tanUp = std::tan(fov.angleUp); + const float tanDown = std::tan(fov.angleDown); + const float width = tanRight - tanLeft; + const float height = tanUp - tanDown; + if (!std::isfinite(width) || !std::isfinite(height) || + std::fabs(width) < 1.0e-6f || std::fabs(height) < 1.0e-6f) + return false; + + out = {}; + out[0][0] = 2.0f / width; + out[1][1] = 2.0f / height; + out[2][0] = (tanRight + tanLeft) / width; + out[2][1] = (tanUp + tanDown) / height; + out[2][2] = base[2][2]; + out[2][3] = base[2][3]; + out[3][2] = base[3][2]; + out[3][3] = base[3][3]; + return MatrixFinite(out); + } +} diff --git a/src/vr/core/transport.hpp b/src/vr/core/transport.hpp new file mode 100644 index 000000000..3b46b1d87 --- /dev/null +++ b/src/vr/core/transport.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include "vr/core/frame_types.hpp" + +#include + +namespace OutRunVR::Core +{ + struct AdapterId + { + std::uint32_t luidLow{}; + std::int32_t luidHigh{}; + + friend constexpr bool operator==(const AdapterId&, const AdapterId&) = default; + }; + + enum class TransportKind : std::uint32_t + { + None = 0, + D3D9ExShared = 1, + DesktopDuplication = 2, + Dxvk = 3, + }; + + struct FrameSlot + { + std::uint32_t generation{}; + std::uint32_t slot{}; + std::uint64_t frameId{}; + std::uintptr_t leftHandle{}; + std::uintptr_t rightHandle{}; + std::uint32_t width{}; + std::uint32_t height{}; + std::uint32_t format{}; + }; + + class IFrameProducer + { + public: + virtual ~IFrameProducer() = default; + virtual TransportKind kind() const noexcept = 0; + virtual void invalidate() noexcept = 0; + virtual bool publish(const PresentedFrame& frame, FrameSlot& outSlot) noexcept = 0; + }; + + class IFrameConsumer + { + public: + virtual ~IFrameConsumer() = default; + virtual TransportKind kind() const noexcept = 0; + virtual void invalidate() noexcept = 0; + virtual bool acquire(const PresentedFrame& frame, const FrameSlot& slot) noexcept = 0; + virtual void acknowledge(std::uint64_t frameId) noexcept = 0; + }; +} diff --git a/src/vr/d3d9/device_probe.cpp b/src/vr/d3d9/device_probe.cpp new file mode 100644 index 000000000..40ddceb9a --- /dev/null +++ b/src/vr/d3d9/device_probe.cpp @@ -0,0 +1,130 @@ +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include + +#include + +#include + +#include "hook_mgr.hpp" +#include "game_addrs.hpp" + +namespace OutRunVRDeviceProbe +{ + namespace + { + const char* DeviceTypeName(D3DDEVTYPE type) noexcept + { + switch (type) + { + case D3DDEVTYPE_HAL: return "HAL"; + case D3DDEVTYPE_REF: return "REF"; + case D3DDEVTYPE_SW: return "SW"; + case D3DDEVTYPE_NULLREF: return "NULLREF"; + default: return "UNKNOWN"; + } + } + + DWORD WINAPI ProbeThread(void*) + { + for (int attempt = 0; attempt < 1200; ++attempt) + { + IDirect3DDevice9* device = Game::D3DDevice_ptr ? *Game::D3DDevice_ptr : nullptr; + if (!device) + { + Sleep(100); + continue; + } + + D3DDEVICE_CREATION_PARAMETERS creation{}; + const HRESULT creationHr = device->GetCreationParameters(&creation); + IDirect3DSurface9* backBuffer = nullptr; + D3DSURFACE_DESC backDesc{}; + const HRESULT backHr = device->GetBackBuffer( + 0, 0, D3DBACKBUFFER_TYPE_MONO, &backBuffer); + if (SUCCEEDED(backHr) && backBuffer) + backBuffer->GetDesc(&backDesc); + + D3DCAPS9 caps{}; + const HRESULT capsHr = device->GetDeviceCaps(&caps); + + IDirect3DDevice9Ex* deviceEx = nullptr; + const HRESULT exHr = device->QueryInterface( + __uuidof(IDirect3DDevice9Ex), reinterpret_cast(&deviceEx)); + const bool isEx = SUCCEEDED(exHr) && deviceEx; + + LUID adapterLuid{}; + bool luidValid = false; + IDirect3D9* d3d = nullptr; + IDirect3D9Ex* d3dEx = nullptr; + if (isEx && SUCCEEDED(deviceEx->GetDirect3D(&d3d)) && d3d && + SUCCEEDED(d3d->QueryInterface(__uuidof(IDirect3D9Ex), + reinterpret_cast(&d3dEx))) && d3dEx && + SUCCEEDED(d3dEx->GetAdapterLUID(creation.AdapterOrdinal, &adapterLuid))) + luidValid = true; + + const DWORD flags = SUCCEEDED(creationHr) ? creation.BehaviorFlags : 0; + spdlog::info( + "VR D3D9 probe: deviceType={} adapter={} D3D9Ex={} behavior=0x{:08X} multithreaded={} hwvp={} swvp={} pure={} fpuPreserve={}", + SUCCEEDED(creationHr) ? DeviceTypeName(creation.DeviceType) : "unavailable", + SUCCEEDED(creationHr) ? creation.AdapterOrdinal : 0u, + isEx ? 1 : 0, flags, + (flags & D3DCREATE_MULTITHREADED) ? 1 : 0, + (flags & D3DCREATE_HARDWARE_VERTEXPROCESSING) ? 1 : 0, + (flags & D3DCREATE_SOFTWARE_VERTEXPROCESSING) ? 1 : 0, + (flags & D3DCREATE_PUREDEVICE) ? 1 : 0, + (flags & D3DCREATE_FPU_PRESERVE) ? 1 : 0); + + spdlog::info( + "VR D3D9 probe: backbuffer={}x{} format={} msaa={} quality={} maxTexture={}x{} capsHr=0x{:08X}", + backDesc.Width, backDesc.Height, static_cast(backDesc.Format), + static_cast(backDesc.MultiSampleType), backDesc.MultiSampleQuality, + SUCCEEDED(capsHr) ? caps.MaxTextureWidth : 0u, + SUCCEEDED(capsHr) ? caps.MaxTextureHeight : 0u, + static_cast(capsHr)); + + if (luidValid) + spdlog::info("VR D3D9 probe: adapterLuid={:08X}:{:08X}", + static_cast(adapterLuid.HighPart), adapterLuid.LowPart); + else + spdlog::info("VR D3D9 probe: adapter LUID unavailable from game device (expected on plain D3D9)"); + + if (!isEx) + spdlog::warn( + "VR D3D9 probe: plain IDirect3DDevice9 detected; zero-copy cross-process sharing is impossible. Exact-eye SBS/DesktopDup remains available; helper D3D9Ex bridge is the optimization path."); + + if (d3dEx) d3dEx->Release(); + if (d3d) d3d->Release(); + if (deviceEx) deviceEx->Release(); + if (backBuffer) backBuffer->Release(); + return 0; + } + + spdlog::warn("VR D3D9 probe: game device did not appear within startup window"); + return 0; + } + } + + class DeviceProbeHook final : public Hook + { + public: + std::string_view description() override { return "OpenXRVRDeviceProbe"; } + bool validate() override { return true; } + bool apply() override + { + HANDLE thread = CreateThread(nullptr, 0, ProbeThread, nullptr, 0, nullptr); + if (!thread) + { + spdlog::warn("VR D3D9 probe: failed to start capability thread: {}", GetLastError()); + return false; + } + CloseHandle(thread); + return true; + } + + static DeviceProbeHook instance; + }; + + DeviceProbeHook DeviceProbeHook::instance; +} diff --git a/src/vr/d3d9/ex_device_upgrade.cpp b/src/vr/d3d9/ex_device_upgrade.cpp new file mode 100644 index 000000000..05ad9eb79 --- /dev/null +++ b/src/vr/d3d9/ex_device_upgrade.cpp @@ -0,0 +1,896 @@ +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "hook_mgr.hpp" +#include "plugin.hpp" + +namespace Settings +{ + extern Setting VRPreferD3D9Ex; +} + +namespace OutRunVRD3D9ExUpgrade +{ + namespace + { + using Direct3DCreate9Fn = IDirect3D9* (WINAPI*)(UINT); + using Direct3DCreate9ExFn = HRESULT (WINAPI*)(UINT, IDirect3D9Ex**); + + constexpr std::size_t TestCooperativeLevelVtableIndex = 3; + constexpr std::size_t EvictManagedResourcesVtableIndex = 5; + constexpr std::size_t ResetVtableIndex = 16; + constexpr std::size_t CreateTextureVtableIndex = 23; + constexpr std::size_t CreateVolumeTextureVtableIndex = 24; + constexpr std::size_t CreateCubeTextureVtableIndex = 25; + constexpr std::size_t CreateVertexBufferVtableIndex = 26; + constexpr std::size_t CreateIndexBufferVtableIndex = 27; + + Direct3DCreate9Fn OriginalDirect3DCreate9 = nullptr; + std::atomic FirstUpgradeLogged{false}; + std::atomic FirstFallbackLogged{false}; + std::atomic ThirdPartyLogged{false}; + std::atomic FirstCreateFlagsLogged{false}; + std::atomic FirstPureDeviceFallbackLogged{false}; + std::atomic FirstOverlayUnavailableLogged{false}; + std::atomic FinalCompatOverlayReady{false}; + std::atomic CompatDevice{nullptr}; + + SafetyHookInline TestCooperativeLevelCompatHook{}; + SafetyHookInline EvictManagedResourcesCompatHook{}; + SafetyHookInline ResetCompatHook{}; + SafetyHookInline CreateTextureCompatHook{}; + SafetyHookInline CreateVolumeTextureCompatHook{}; + SafetyHookInline CreateCubeTextureCompatHook{}; + SafetyHookInline CreateVertexBufferCompatHook{}; + SafetyHookInline CreateIndexBufferCompatHook{}; + + std::atomic ManagedTextureCreates{0}; + std::atomic ManagedVolumeTextureCreates{0}; + std::atomic ManagedCubeTextureCreates{0}; + std::atomic ManagedVertexBufferCreates{0}; + std::atomic ManagedIndexBufferCreates{0}; + std::atomic ManagedCreateFailures{0}; + std::atomic ManagedTextureDynamicFallbacks{0}; + std::atomic ResetExRedirects{0}; + std::atomic CooperativeLevelTranslations{0}; + std::atomic ClassicResetStateRestores{0}; + std::atomic ClassicResetStateRestoreFailures{0}; + std::atomic CompatWindowed{true}; + std::atomic CompatFocusWindow{nullptr}; + std::atomic FirstCooperativeTranslationLogged{false}; + std::atomic FirstResetStateRestoreFailureLogged{false}; + + struct CompatStateValue + { + DWORD state = 0; + DWORD value = 0; + }; + struct CompatStageStateValue + { + DWORD stage = 0; + DWORD state = 0; + DWORD value = 0; + }; + struct CompatClassicBaseline + { + std::vector render; + std::vector textureStage; + std::vector sampler; + bool ready = false; + }; + CompatClassicBaseline ClassicBaseline{}; + std::mutex ClassicBaselineMutex; + + bool CaptureClassicBaseline(IDirect3DDevice9* device) noexcept + { + if (!device) return false; + try + { + CompatClassicBaseline next{}; + next.render.reserve(192); + for (DWORD state = 1; state <= 255; ++state) + { + DWORD value = 0; + if (SUCCEEDED(device->GetRenderState( + static_cast(state), &value))) + next.render.push_back({ state, value }); + } + for (DWORD stage = 0; stage < 8; ++stage) + { + for (DWORD state = 1; state <= 32; ++state) + { + DWORD value = 0; + if (SUCCEEDED(device->GetTextureStageState(stage, + static_cast(state), + &value))) + next.textureStage.push_back({ stage, state, value }); + } + } + for (DWORD sampler = 0; sampler < 16; ++sampler) + { + for (DWORD state = 1; state <= 16; ++state) + { + DWORD value = 0; + if (SUCCEEDED(device->GetSamplerState(sampler, + static_cast(state), &value))) + next.sampler.push_back({ sampler, state, value }); + } + } + next.ready = !next.render.empty(); + std::lock_guard lock(ClassicBaselineMutex); + ClassicBaseline = std::move(next); + return ClassicBaseline.ready; + } + catch (...) + { + return false; + } + } + + void ClearClassicBaseline() noexcept + { + std::lock_guard lock(ClassicBaselineMutex); + ClassicBaseline = {}; + } + + void UpdateCompatPresentationState(IDirect3DDevice9* device, + const D3DPRESENT_PARAMETERS* params) noexcept + { + if (!device || !params) return; + CompatWindowed.store(params->Windowed != FALSE, + std::memory_order_release); + HWND window = params->hDeviceWindow; + if (!window) + { + D3DDEVICE_CREATION_PARAMETERS creation{}; + if (SUCCEEDED(device->GetCreationParameters(&creation))) + window = creation.hFocusWindow; + } + CompatFocusWindow.store(window, std::memory_order_release); + } + + bool RestoreClassicResetState(IDirect3DDevice9* device) noexcept + { + if (!device) return false; + std::uint32_t failures = 0; + { + std::lock_guard lock(ClassicBaselineMutex); + if (!ClassicBaseline.ready) return false; + for (const auto& item : ClassicBaseline.render) + if (FAILED(device->SetRenderState( + static_cast(item.state), + item.value))) + ++failures; + for (const auto& item : ClassicBaseline.textureStage) + if (FAILED(device->SetTextureStageState(item.stage, + static_cast(item.state), + item.value))) + ++failures; + for (const auto& item : ClassicBaseline.sampler) + if (FAILED(device->SetSamplerState(item.stage, + static_cast(item.state), + item.value))) + ++failures; + } + + D3DCAPS9 caps{}; + const UINT streams = SUCCEEDED(device->GetDeviceCaps(&caps)) + ? std::min(caps.MaxStreams, 16u) : 16u; + // Classic D3D9 exposes eight fixed-function texture stages. + // Do not count VS sampler aliases (16..19) or invalid pixel stages + // as Reset replay failures. + for (DWORD stage = 0; stage < 8; ++stage) + if (FAILED(device->SetTexture(stage, nullptr))) ++failures; + for (UINT stream = 0; stream < streams; ++stream) + { + if (FAILED(device->SetStreamSource(stream, nullptr, 0, 0))) + ++failures; + if (FAILED(device->SetStreamSourceFreq(stream, 1))) + ++failures; + } + if (FAILED(device->SetIndices(nullptr))) ++failures; + if (FAILED(device->SetVertexShader(nullptr))) ++failures; + if (FAILED(device->SetPixelShader(nullptr))) ++failures; + // A null declaration restores the fixed-function/default declaration + // boundary expected after classic Reset. Some drivers reject it when + // no declaration path exists, so treat that call as best-effort. + device->SetVertexDeclaration(nullptr); + + IDirect3DSurface9* backBuffer = nullptr; + D3DSURFACE_DESC desc{}; + if (SUCCEEDED(device->GetBackBuffer(0, 0, + D3DBACKBUFFER_TYPE_MONO, &backBuffer)) && backBuffer) + { + if (SUCCEEDED(backBuffer->GetDesc(&desc)) && + desc.Width && desc.Height) + { + D3DVIEWPORT9 viewport{}; + viewport.Width = desc.Width; + viewport.Height = desc.Height; + viewport.MinZ = 0.0f; + viewport.MaxZ = 1.0f; + if (FAILED(device->SetViewport(&viewport))) ++failures; + RECT scissor{ 0, 0, static_cast(desc.Width), + static_cast(desc.Height) }; + if (FAILED(device->SetScissorRect(&scissor))) ++failures; + } + backBuffer->Release(); + } + + if (failures == 0) + { + ++ClassicResetStateRestores; + return true; + } + ++ClassicResetStateRestoreFailures; + if (!FirstResetStateRestoreFailureLogged.exchange(true)) + { + spdlog::warn( + "VR D3D9Ex compat: classic Reset state replay completed with {} rejected state writes; game/VR caches still re-prime from live state", + failures); + } + return false; + } + + bool IsCompatDevice(IDirect3DDevice9* device) noexcept + { + return device && CompatDevice.load(std::memory_order_acquire) == device; + } + + bool IsSystemModuleForAddress(const void* address, HMODULE& module) noexcept + { + module = nullptr; + if (!address || !GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(address), &module) || !module) + return false; + + wchar_t modulePath[MAX_PATH]{}; + wchar_t systemDir[MAX_PATH]{}; + const DWORD moduleLength = GetModuleFileNameW(module, modulePath, MAX_PATH); + const UINT systemLength = GetSystemDirectoryW(systemDir, MAX_PATH); + if (!moduleLength || moduleLength >= MAX_PATH || !systemLength || systemLength >= MAX_PATH) + return false; + + const std::size_t dirLength = std::wcslen(systemDir); + if (_wcsnicmp(modulePath, systemDir, dirLength) != 0) + return false; + const wchar_t separator = modulePath[dirLength]; + return separator == L'\\' || separator == L'/'; + } + + D3DDISPLAYMODEEX* BuildFullscreenMode( + IDirect3DDevice9Ex* deviceEx, + D3DPRESENT_PARAMETERS* params, + D3DDISPLAYMODEEX& fullscreen) noexcept + { + if (!params || params->Windowed) + return nullptr; + + fullscreen = {}; + fullscreen.Size = sizeof(fullscreen); + fullscreen.Width = params->BackBufferWidth; + fullscreen.Height = params->BackBufferHeight; + fullscreen.RefreshRate = params->FullScreen_RefreshRateInHz; + fullscreen.Format = params->BackBufferFormat; + fullscreen.ScanLineOrdering = D3DSCANLINEORDERING_UNKNOWN; + + if (deviceEx) + { + D3DDEVICE_CREATION_PARAMETERS creation{}; + D3DDISPLAYROTATION rotation = D3DDISPLAYROTATION_IDENTITY; + D3DDISPLAYMODEEX current{}; + current.Size = sizeof(current); + if (SUCCEEDED(deviceEx->GetCreationParameters(&creation)) && + SUCCEEDED(deviceEx->GetDisplayModeEx(0, ¤t, &rotation))) + { + if (!fullscreen.Width) fullscreen.Width = current.Width; + if (!fullscreen.Height) fullscreen.Height = current.Height; + if (fullscreen.Format == D3DFMT_UNKNOWN) fullscreen.Format = current.Format; + if (!fullscreen.RefreshRate) fullscreen.RefreshRate = current.RefreshRate; + fullscreen.ScanLineOrdering = current.ScanLineOrdering; + } + } + if (fullscreen.ScanLineOrdering == D3DSCANLINEORDERING_UNKNOWN) + fullscreen.ScanLineOrdering = D3DSCANLINEORDERING_PROGRESSIVE; + return &fullscreen; + } + + HRESULT __stdcall TestCooperativeLevelCompatDest( + IDirect3DDevice9* device) + { + if (!IsCompatDevice(device)) + return TestCooperativeLevelCompatHook.stdcall(device); + + IDirect3DDevice9Ex* deviceEx = nullptr; + if (FAILED(device->QueryInterface(__uuidof(IDirect3DDevice9Ex), + reinterpret_cast(&deviceEx))) || !deviceEx) + return TestCooperativeLevelCompatHook.stdcall(device); + + HWND window = CompatFocusWindow.load(std::memory_order_acquire); + if (!window) window = GetDesktopWindow(); + const HRESULT state = deviceEx->CheckDeviceState(window); + deviceEx->Release(); + ++CooperativeLevelTranslations; + + HRESULT translated = D3D_OK; + if (state == S_PRESENT_MODE_CHANGED) + translated = D3DERR_DEVICENOTRESET; + else if (state == S_PRESENT_OCCLUDED) + translated = CompatWindowed.load(std::memory_order_acquire) + ? D3D_OK : D3DERR_DEVICELOST; + else if (state == D3DERR_DEVICELOST) + translated = D3DERR_DEVICELOST; + else if (state == D3DERR_DEVICEHUNG || + state == D3DERR_DEVICEREMOVED) + translated = D3DERR_DRIVERINTERNALERROR; + else if (FAILED(state)) + translated = D3DERR_DRIVERINTERNALERROR; + + if (!FirstCooperativeTranslationLogged.exchange(true)) + spdlog::info( + "VR D3D9Ex compat: TestCooperativeLevel is translated from CheckDeviceState for legacy lost-device recovery"); + return translated; + } + + HRESULT __stdcall EvictManagedResourcesCompatDest( + IDirect3DDevice9* device) + { + if (IsCompatDevice(device)) + return D3D_OK; + return EvictManagedResourcesCompatHook.stdcall(device); + } + + HRESULT __stdcall ResetCompatDest(IDirect3DDevice9* device, D3DPRESENT_PARAMETERS* params) + { + if (!IsCompatDevice(device) || !params) + return ResetCompatHook.stdcall(device, params); + + IDirect3DDevice9Ex* deviceEx = nullptr; + const HRESULT qi = device->QueryInterface(__uuidof(IDirect3DDevice9Ex), reinterpret_cast(&deviceEx)); + if (FAILED(qi) || !deviceEx) + return ResetCompatHook.stdcall(device, params); + + D3DDISPLAYMODEEX fullscreen{}; + D3DDISPLAYMODEEX* fullscreenPtr = BuildFullscreenMode(deviceEx, params, fullscreen); + const HRESULT hr = deviceEx->ResetEx(params, fullscreenPtr); + deviceEx->Release(); + if (SUCCEEDED(hr)) + { + UpdateCompatPresentationState(device, params); + RestoreClassicResetState(device); + } + ++ResetExRedirects; + spdlog::info( + "VR D3D9Ex compat: IDirect3DDevice9::Reset redirected to ResetEx hr=0x{:08X}; emulated MANAGED resources remain persistent", + static_cast(hr)); + return hr; + } + + HRESULT __stdcall CreateVertexBufferCompatDest( + IDirect3DDevice9* device, UINT length, DWORD usage, DWORD fvf, D3DPOOL pool, + IDirect3DVertexBuffer9** buffer, HANDLE* sharedHandle) + { + if (!IsCompatDevice(device) || pool != D3DPOOL_MANAGED) + return CreateVertexBufferCompatHook.stdcall( + device, length, usage, fvf, pool, buffer, sharedHandle); + + const HRESULT hr = CreateVertexBufferCompatHook.stdcall( + device, length, usage, fvf, D3DPOOL_DEFAULT, buffer, sharedHandle); + ++ManagedVertexBufferCreates; + if (FAILED(hr)) ++ManagedCreateFailures; + if (ManagedVertexBufferCreates.load() == 1) + { + spdlog::info( + "VR D3D9Ex compat: MANAGED vertex buffer translated to DEFAULT (length={} usage=0x{:08X} hr=0x{:08X})", + length, static_cast(usage), static_cast(hr)); + } + return hr; + } + + HRESULT __stdcall CreateIndexBufferCompatDest( + IDirect3DDevice9* device, UINT length, DWORD usage, D3DFORMAT format, D3DPOOL pool, + IDirect3DIndexBuffer9** buffer, HANDLE* sharedHandle) + { + if (!IsCompatDevice(device) || pool != D3DPOOL_MANAGED) + return CreateIndexBufferCompatHook.stdcall( + device, length, usage, format, pool, buffer, sharedHandle); + + const HRESULT hr = CreateIndexBufferCompatHook.stdcall( + device, length, usage, format, D3DPOOL_DEFAULT, buffer, sharedHandle); + ++ManagedIndexBufferCreates; + if (FAILED(hr)) ++ManagedCreateFailures; + if (ManagedIndexBufferCreates.load() == 1) + { + spdlog::info( + "VR D3D9Ex compat: MANAGED index buffer translated to DEFAULT (length={} usage=0x{:08X} fmt={} hr=0x{:08X})", + length, static_cast(usage), static_cast(format), static_cast(hr)); + } + return hr; + } + + template + HRESULT CreateManagedTextureCompat( + const char* label, + std::atomic& counter, + DWORD usage, + bool allowNonDynamicFallback, + CreateFn&& create) + { + const DWORD dynamicUsage = usage | D3DUSAGE_DYNAMIC; + HRESULT hr = create(dynamicUsage, D3DPOOL_DEFAULT); + ++counter; + if (SUCCEEDED(hr)) + { + if (counter.load() == 1) + { + spdlog::info( + "VR D3D9Ex compat: MANAGED {} translated to lockable DEFAULT|DYNAMIC (usage 0x{:08X}->0x{:08X})", + label, static_cast(usage), static_cast(dynamicUsage)); + } + return hr; + } + + // R14 provides an independent CPU shadow for 2D textures, so those + // resources can safely fall back to non-dynamic DEFAULT. Cube/volume + // textures do not yet have that shadow contract: fail creation rather + // than return an object whose later Lock* semantics silently differ + // from legacy MANAGED. + if (!allowNonDynamicFallback) + { + ++ManagedCreateFailures; + spdlog::warn( + "VR D3D9Ex compat: MANAGED {} requires a non-dynamic fallback that cannot preserve Lock semantics; failing closed hr=0x{:08X}", + label, static_cast(hr)); + return hr; + } + ++ManagedTextureDynamicFallbacks; + hr = create(usage, D3DPOOL_DEFAULT); + if (FAILED(hr)) ++ManagedCreateFailures; + spdlog::warn( + "VR D3D9Ex compat: {} DYNAMIC retry path used; final hr=0x{:08X} usage=0x{:08X}. A later Lock* on a non-dynamic DEFAULT texture may require shadow emulation.", + label, static_cast(hr), static_cast(usage)); + return hr; + } + + HRESULT __stdcall CreateTextureCompatDest( + IDirect3DDevice9* device, UINT width, UINT height, UINT levels, DWORD usage, + D3DFORMAT format, D3DPOOL pool, IDirect3DTexture9** texture, HANDLE* sharedHandle) + { + if (!IsCompatDevice(device) || pool != D3DPOOL_MANAGED) + return CreateTextureCompatHook.stdcall( + device, width, height, levels, usage, format, pool, texture, sharedHandle); + + return CreateManagedTextureCompat("texture", ManagedTextureCreates, usage, true, + [&](DWORD translatedUsage, D3DPOOL translatedPool) + { + return CreateTextureCompatHook.stdcall( + device, width, height, levels, translatedUsage, format, + translatedPool, texture, sharedHandle); + }); + } + + HRESULT __stdcall CreateVolumeTextureCompatDest( + IDirect3DDevice9* device, UINT width, UINT height, UINT depth, UINT levels, DWORD usage, + D3DFORMAT format, D3DPOOL pool, IDirect3DVolumeTexture9** texture, HANDLE* sharedHandle) + { + if (!IsCompatDevice(device) || pool != D3DPOOL_MANAGED) + return CreateVolumeTextureCompatHook.stdcall( + device, width, height, depth, levels, usage, format, pool, texture, sharedHandle); + + return CreateManagedTextureCompat("volume texture", ManagedVolumeTextureCreates, usage, false, + [&](DWORD translatedUsage, D3DPOOL translatedPool) + { + return CreateVolumeTextureCompatHook.stdcall( + device, width, height, depth, levels, translatedUsage, format, + translatedPool, texture, sharedHandle); + }); + } + + HRESULT __stdcall CreateCubeTextureCompatDest( + IDirect3DDevice9* device, UINT edgeLength, UINT levels, DWORD usage, + D3DFORMAT format, D3DPOOL pool, IDirect3DCubeTexture9** texture, HANDLE* sharedHandle) + { + if (!IsCompatDevice(device) || pool != D3DPOOL_MANAGED) + return CreateCubeTextureCompatHook.stdcall( + device, edgeLength, levels, usage, format, pool, texture, sharedHandle); + + return CreateManagedTextureCompat("cube texture", ManagedCubeTextureCreates, usage, false, + [&](DWORD translatedUsage, D3DPOOL translatedPool) + { + return CreateCubeTextureCompatHook.stdcall( + device, edgeLength, levels, translatedUsage, format, + translatedPool, texture, sharedHandle); + }); + } + + void ClearCompatHooks() noexcept + { + CompatDevice.store(nullptr, std::memory_order_release); + CompatFocusWindow.store(nullptr, std::memory_order_release); + TestCooperativeLevelCompatHook = {}; + EvictManagedResourcesCompatHook = {}; + ResetCompatHook = {}; + CreateTextureCompatHook = {}; + CreateVolumeTextureCompatHook = {}; + CreateCubeTextureCompatHook = {}; + CreateVertexBufferCompatHook = {}; + CreateIndexBufferCompatHook = {}; + ClearClassicBaseline(); + } + + bool InstallManagedResourceCompat(IDirect3DDevice9Ex* deviceEx) + { + if (!deviceEx) + return false; + + auto* baseDevice = static_cast(deviceEx); + IDirect3DDevice9* const existing = + CompatDevice.load(std::memory_order_acquire); + if (existing && existing != baseDevice) + { + spdlog::warn( + "VR D3D9Ex compat: a second promoted game device was requested; keeping the first compatibility owner and forcing the new device back to classic D3D9"); + return false; + } + if (!CaptureClassicBaseline(baseDevice)) + { + spdlog::error( + "VR D3D9Ex compat: could not capture the fresh-device classic state baseline; rejecting Ex promotion"); + return false; + } + void** vtable = *reinterpret_cast(baseDevice); + if (!vtable) + return false; + + const auto disabled = safetyhook::InlineHook::StartDisabled; + TestCooperativeLevelCompatHook = safetyhook::create_inline( + vtable[TestCooperativeLevelVtableIndex], + TestCooperativeLevelCompatDest, disabled); + EvictManagedResourcesCompatHook = safetyhook::create_inline( + vtable[EvictManagedResourcesVtableIndex], + EvictManagedResourcesCompatDest, disabled); + ResetCompatHook = safetyhook::create_inline( + vtable[ResetVtableIndex], ResetCompatDest, disabled); + CreateTextureCompatHook = safetyhook::create_inline(vtable[CreateTextureVtableIndex], CreateTextureCompatDest, disabled); + CreateVolumeTextureCompatHook = safetyhook::create_inline(vtable[CreateVolumeTextureVtableIndex], CreateVolumeTextureCompatDest, disabled); + CreateCubeTextureCompatHook = safetyhook::create_inline(vtable[CreateCubeTextureVtableIndex], CreateCubeTextureCompatDest, disabled); + CreateVertexBufferCompatHook = safetyhook::create_inline(vtable[CreateVertexBufferVtableIndex], CreateVertexBufferCompatDest, disabled); + CreateIndexBufferCompatHook = safetyhook::create_inline(vtable[CreateIndexBufferVtableIndex], CreateIndexBufferCompatDest, disabled); + + SafetyHookInline* hooks[]{ + &TestCooperativeLevelCompatHook, &EvictManagedResourcesCompatHook, + &ResetCompatHook, &CreateTextureCompatHook, + &CreateVolumeTextureCompatHook, &CreateCubeTextureCompatHook, + &CreateVertexBufferCompatHook, &CreateIndexBufferCompatHook + }; + for (auto* hook : hooks) + { + if (!*hook || !hook->enable().has_value()) + { + spdlog::error( + "VR D3D9Ex compat: compatibility hook transaction was partial; rejecting Ex device"); + ClearCompatHooks(); + return false; + } + } + + CompatDevice.store(baseDevice, std::memory_order_release); + spdlog::info( + "VR D3D9Ex compat: legacy contract hooks installed (TestCooperativeLevel/EvictManaged/Reset/resources); fresh-device state baseline captured; MANAGED 2D -> R14 shadow, cube/volume fail closed if DYNAMIC is unavailable"); + return true; + } + + class Direct3D9ExCompat final : public IDirect3D9 + { + public: + Direct3D9ExCompat(IDirect3D9Ex* ex, IDirect3D9* fallback) noexcept + : ex_(ex), fallback_(fallback) + { + } + + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** object) override + { + if (!object) return E_POINTER; + *object = nullptr; + if (riid == IID_IUnknown || riid == IID_IDirect3D9) + { + *object = static_cast(this); + AddRef(); + return S_OK; + } + if (riid == __uuidof(IDirect3D9Ex) && ex_) + return ex_->QueryInterface(riid, object); + return fallback_ ? fallback_->QueryInterface(riid, object) : E_NOINTERFACE; + } + + ULONG STDMETHODCALLTYPE AddRef() override + { + return static_cast(InterlockedIncrement(&refs_)); + } + + ULONG STDMETHODCALLTYPE Release() override + { + const LONG refs = InterlockedDecrement(&refs_); + if (refs == 0) + { + if (fallback_) fallback_->Release(); + if (ex_) ex_->Release(); + delete this; + return 0; + } + return static_cast(refs); + } + + HRESULT STDMETHODCALLTYPE RegisterSoftwareDevice(void* initializeFunction) override + { + return fallback_->RegisterSoftwareDevice(initializeFunction); + } + UINT STDMETHODCALLTYPE GetAdapterCount() override { return fallback_->GetAdapterCount(); } + HRESULT STDMETHODCALLTYPE GetAdapterIdentifier(UINT a, DWORD f, D3DADAPTER_IDENTIFIER9* i) override + { return fallback_->GetAdapterIdentifier(a, f, i); } + UINT STDMETHODCALLTYPE GetAdapterModeCount(UINT a, D3DFORMAT f) override + { return fallback_->GetAdapterModeCount(a, f); } + HRESULT STDMETHODCALLTYPE EnumAdapterModes(UINT a, D3DFORMAT f, UINT m, D3DDISPLAYMODE* mode) override + { return fallback_->EnumAdapterModes(a, f, m, mode); } + HRESULT STDMETHODCALLTYPE GetAdapterDisplayMode(UINT a, D3DDISPLAYMODE* mode) override + { return fallback_->GetAdapterDisplayMode(a, mode); } + HRESULT STDMETHODCALLTYPE CheckDeviceType(UINT a, D3DDEVTYPE t, D3DFORMAT af, D3DFORMAT bf, BOOL w) override + { return fallback_->CheckDeviceType(a, t, af, bf, w); } + HRESULT STDMETHODCALLTYPE CheckDeviceFormat(UINT a, D3DDEVTYPE t, D3DFORMAT af, DWORD u, D3DRESOURCETYPE r, D3DFORMAT cf) override + { return fallback_->CheckDeviceFormat(a, t, af, u, r, cf); } + HRESULT STDMETHODCALLTYPE CheckDeviceMultiSampleType(UINT a, D3DDEVTYPE t, D3DFORMAT sf, BOOL w, + D3DMULTISAMPLE_TYPE mt, DWORD* q) override + { return fallback_->CheckDeviceMultiSampleType(a, t, sf, w, mt, q); } + HRESULT STDMETHODCALLTYPE CheckDepthStencilMatch(UINT a, D3DDEVTYPE t, D3DFORMAT af, + D3DFORMAT rf, D3DFORMAT df) override + { return fallback_->CheckDepthStencilMatch(a, t, af, rf, df); } + HRESULT STDMETHODCALLTYPE CheckDeviceFormatConversion(UINT a, D3DDEVTYPE t, D3DFORMAT s, D3DFORMAT d) override + { return fallback_->CheckDeviceFormatConversion(a, t, s, d); } + HRESULT STDMETHODCALLTYPE GetDeviceCaps(UINT a, D3DDEVTYPE t, D3DCAPS9* caps) override + { return fallback_->GetDeviceCaps(a, t, caps); } + HMONITOR STDMETHODCALLTYPE GetAdapterMonitor(UINT a) override + { return fallback_->GetAdapterMonitor(a); } + + HRESULT STDMETHODCALLTYPE CreateDevice(UINT adapter, D3DDEVTYPE type, HWND focusWindow, + DWORD behaviorFlags, D3DPRESENT_PARAMETERS* params, IDirect3DDevice9** device) override + { + if (!device || !params) return D3DERR_INVALIDCALL; + *device = nullptr; + + // D3DPRESENT_PARAMETERS is an in/out contract. Preserve the + // game's original request so a failed Ex attempt cannot poison + // the classic fallback with mutated presentation values. + const D3DPRESENT_PARAMETERS originalParams = *params; + auto classicFallback = [&]() -> HRESULT + { + *params = originalParams; + return fallback_->CreateDevice(adapter, type, focusWindow, + behaviorFlags, params, device); + }; + + // A wrapper may outlive a failed first Ex promotion attempt. + // Re-check the final overlay for every CreateDevice call so a + // later retry cannot re-enter a compatibility stack that R15 + // deliberately disarmed after a transactional failure. + if (!FinalCompatOverlayReady.load(std::memory_order_acquire)) + { + if (!FirstOverlayUnavailableLogged.exchange(true)) + spdlog::warn( + "VR D3D9Ex startup: final R15 compatibility overlay became unavailable after wrapper creation; subsequent CreateDevice stays on classic D3D9"); + return classicFallback(); + } + + if (!FirstCreateFlagsLogged.exchange(true)) + { + spdlog::info( + "VR D3D9Ex startup: CreateDevice flags=0x{:08X} multithreaded={} pureDevice={}", + static_cast(behaviorFlags), + (behaviorFlags & D3DCREATE_MULTITHREADED) != 0, + (behaviorFlags & D3DCREATE_PUREDEVICE) != 0); + } + if ((behaviorFlags & D3DCREATE_PUREDEVICE) != 0) + { + if (!FirstPureDeviceFallbackLogged.exchange(true)) + spdlog::warn( + "VR D3D9Ex startup: D3DCREATE_PUREDEVICE detected; Ex promotion skipped and classic D3D9 retained"); + return classicFallback(); + } + + D3DDISPLAYMODEEX fullscreen{}; + D3DDISPLAYMODEEX* fullscreenPtr = nullptr; + if (!params->Windowed) + { + fullscreen.Size = sizeof(fullscreen); + fullscreen.Width = params->BackBufferWidth; + fullscreen.Height = params->BackBufferHeight; + fullscreen.RefreshRate = params->FullScreen_RefreshRateInHz; + fullscreen.Format = params->BackBufferFormat; + fullscreen.ScanLineOrdering = D3DSCANLINEORDERING_UNKNOWN; + if (ex_) + { + D3DDISPLAYROTATION rotation = D3DDISPLAYROTATION_IDENTITY; + D3DDISPLAYMODEEX current{}; + current.Size = sizeof(current); + if (SUCCEEDED(ex_->GetAdapterDisplayModeEx(adapter, ¤t, &rotation))) + { + if (!fullscreen.Width) fullscreen.Width = current.Width; + if (!fullscreen.Height) fullscreen.Height = current.Height; + if (fullscreen.Format == D3DFMT_UNKNOWN) fullscreen.Format = current.Format; + if (!fullscreen.RefreshRate) fullscreen.RefreshRate = current.RefreshRate; + fullscreen.ScanLineOrdering = current.ScanLineOrdering; + } + } + if (fullscreen.ScanLineOrdering == D3DSCANLINEORDERING_UNKNOWN) + fullscreen.ScanLineOrdering = D3DSCANLINEORDERING_PROGRESSIVE; + fullscreenPtr = &fullscreen; + } + + IDirect3DDevice9Ex* deviceEx = nullptr; + HRESULT hr = ex_ ? ex_->CreateDeviceEx(adapter, type, focusWindow, behaviorFlags, + params, fullscreenPtr, &deviceEx) : E_FAIL; + if (SUCCEEDED(hr) && deviceEx) + { + if (!InstallManagedResourceCompat(deviceEx)) + { + deviceEx->Release(); + deviceEx = nullptr; + if (!FirstFallbackLogged.exchange(true)) + { + spdlog::warn( + "VR D3D9Ex upgrade: Ex device created but managed-resource compatibility layer could not be installed; falling back to original D3D9 device"); + } + return classicFallback(); + } + + *device = static_cast(deviceEx); + UpdateCompatPresentationState(*device, params); + if (!FirstUpgradeLogged.exchange(true)) + { + spdlog::info( + "VR D3D9Ex upgrade: game CreateDevice promoted to CreateDeviceEx with managed-resource compatibility; existing 4-slot zero-copy eye transport is eligible"); + } + return hr; + } + + if (!FirstFallbackLogged.exchange(true)) + { + spdlog::warn( + "VR D3D9Ex upgrade: CreateDeviceEx failed HRESULT=0x{:08X}; falling back to original IDirect3D9::CreateDevice", + static_cast(hr)); + } + return classicFallback(); + } + + private: + volatile LONG refs_ = 1; + IDirect3D9Ex* ex_ = nullptr; + IDirect3D9* fallback_ = nullptr; + }; + + IDirect3D9* WINAPI HookedDirect3DCreate9(UINT sdkVersion) + { + if (!OriginalDirect3DCreate9) + return nullptr; + + IDirect3D9* fallback = OriginalDirect3DCreate9(sdkVersion); + if (!fallback || !Settings::VRPreferD3D9Ex) + return fallback; + if (!FinalCompatOverlayReady.load(std::memory_order_acquire)) + { + if (!FirstOverlayUnavailableLogged.exchange(true)) + spdlog::error( + "VR D3D9Ex startup: final R15 compatibility overlay is not ready; Ex promotion skipped to preserve classic startup"); + return fallback; + } + + HMODULE provider = nullptr; + if (!IsSystemModuleForAddress(reinterpret_cast(OriginalDirect3DCreate9), provider)) + { + if (!ThirdPartyLogged.exchange(true)) + spdlog::warn("VR D3D9Ex upgrade: third-party d3d9 provider detected; device upgrade skipped to preserve wrapper compatibility"); + return fallback; + } + + const auto createEx = reinterpret_cast( + GetProcAddress(provider, "Direct3DCreate9Ex")); + if (!createEx) + return fallback; + + IDirect3D9Ex* ex = nullptr; + const HRESULT hr = createEx(sdkVersion, &ex); + if (FAILED(hr) || !ex) + return fallback; + + auto* wrapper = new (std::nothrow) Direct3D9ExCompat(ex, fallback); + if (!wrapper) + { + ex->Release(); + return fallback; + } + return wrapper; + } + + bool PatchDirect3DCreate9Import() noexcept + { + auto* base = reinterpret_cast(Module::ExeHandle); + if (!base) return false; + auto* dos = reinterpret_cast(base); + if (dos->e_magic != IMAGE_DOS_SIGNATURE || dos->e_lfanew <= 0) return false; + auto* nt = reinterpret_cast(base + dos->e_lfanew); + if (nt->Signature != IMAGE_NT_SIGNATURE) return false; + const auto& importDirectory = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; + if (!importDirectory.VirtualAddress || !importDirectory.Size) return false; + + auto* descriptor = reinterpret_cast(base + importDirectory.VirtualAddress); + for (; descriptor->Name; ++descriptor) + { + const char* moduleName = reinterpret_cast(base + descriptor->Name); + if (_stricmp(moduleName, "d3d9.dll") != 0) continue; + if (!descriptor->FirstThunk || !descriptor->OriginalFirstThunk) return false; + + auto* names = reinterpret_cast(base + descriptor->OriginalFirstThunk); + auto* thunks = reinterpret_cast(base + descriptor->FirstThunk); + for (; names->u1.AddressOfData; ++names, ++thunks) + { + if (IMAGE_SNAP_BY_ORDINAL(names->u1.Ordinal)) continue; + auto* import = reinterpret_cast(base + names->u1.AddressOfData); + if (std::strcmp(reinterpret_cast(import->Name), "Direct3DCreate9") != 0) + continue; + + OriginalDirect3DCreate9 = reinterpret_cast(thunks->u1.Function); + DWORD oldProtect = 0; + if (!VirtualProtect(&thunks->u1.Function, sizeof(thunks->u1.Function), PAGE_READWRITE, &oldProtect)) + return false; + thunks->u1.Function = reinterpret_cast(&HookedDirect3DCreate9); + FlushInstructionCache(GetCurrentProcess(), &thunks->u1.Function, sizeof(thunks->u1.Function)); + DWORD ignored = 0; + VirtualProtect(&thunks->u1.Function, sizeof(thunks->u1.Function), oldProtect, &ignored); + return true; + } + } + return false; + } + } + + void SetFinalCompatOverlayReady(bool ready) noexcept + { + FinalCompatOverlayReady.store(ready, std::memory_order_release); + } + + class D3D9ExUpgradeHook final : public Hook + { + public: + std::string_view description() override { return "OpenXRVRD3D9ExUpgrade"; } + void declare_settings() override { Settings::VRPreferD3D9Ex.needs_restart(); } + bool validate() override { return Settings::VRPreferD3D9Ex; } + bool apply() override + { + if (!PatchDirect3DCreate9Import()) + { + spdlog::warn("VR D3D9Ex upgrade: Direct3DCreate9 IAT entry was not found/patched; original D3D9 path remains active"); + return true; + } + spdlog::info("VR D3D9Ex upgrade: Direct3DCreate9 IAT hook armed before game device creation"); + return true; + } + + static D3D9ExUpgradeHook instance; + }; + + D3D9ExUpgradeHook D3D9ExUpgradeHook::instance; +} \ No newline at end of file diff --git a/src/vr/d3d9/ex_device_upgrade_r13.cpp b/src/vr/d3d9/ex_device_upgrade_r13.cpp new file mode 100644 index 000000000..80ce04900 --- /dev/null +++ b/src/vr/d3d9/ex_device_upgrade_r13.cpp @@ -0,0 +1,260 @@ +// R13 hardening wrapper. cmake marks ex_device_upgrade.cpp HEADER_FILE_ONLY +// and compiles this TU instead. Optimization is disabled only while compiling +// the included compatibility implementation so InstallManagedResourceCompat is +// guaranteed to remain a hookable call boundary; R13 must remove the temporary +// Reset hook synchronously before CreateDeviceEx returns the device to the game. + +#include +#include +#include + +#include "r13_bridge.hpp" + +#pragma optimize("", off) +#include "ex_device_upgrade.cpp" +#pragma optimize("", on) + +namespace OutRunVRD3D9ExUpgradeR13 +{ + namespace + { + constexpr std::size_t TextureLockRectVtableIndex = 19; + constexpr std::size_t TextureUnlockRectVtableIndex = 20; + constexpr std::size_t TrackedManagedTextureCapacity = 256; + + SafetyHookInline R13InstallCompatHook{}; + SafetyHookInline R13CreateTextureCallbackHook{}; + SafetyHookInline R13TextureLockRectHook{}; + SafetyHookInline R13TextureUnlockRectHook{}; + + std::array, + TrackedManagedTextureCapacity> TrackedManagedTextures{}; + std::atomic TrackedManagedTextureCursor{0}; + std::atomic ManagedTextureLockCalls{0}; + std::atomic ManagedTextureLockFailures{0}; + std::atomic ManagedTextureUnlockCalls{0}; + std::atomic FirstManagedTextureLockLogged{false}; + std::atomic FirstManagedTextureLockFailureLogged{false}; + std::atomic ResetHookDisarmedLogged{false}; + std::atomic CompatHardeningLogged{false}; + + void TrackManagedTexture(IDirect3DTexture9* texture) noexcept + { + if (!texture) + return; + const std::uint32_t slot = + TrackedManagedTextureCursor.fetch_add(1, std::memory_order_acq_rel) % + static_cast(TrackedManagedTextureCapacity); + TrackedManagedTextures[slot].store(texture, std::memory_order_release); + } + + bool IsTrackedManagedTexture(IDirect3DTexture9* texture) noexcept + { + if (!texture) + return false; + for (auto& entry : TrackedManagedTextures) + { + if (entry.load(std::memory_order_acquire) == texture) + return true; + } + return false; + } + + HRESULT __stdcall TextureLockRectR13(IDirect3DTexture9* texture, + UINT level, D3DLOCKED_RECT* locked, const RECT* rect, DWORD flags) + { + const HRESULT hr = R13TextureLockRectHook.stdcall( + texture, level, locked, rect, flags); + if (!IsTrackedManagedTexture(texture)) + return hr; + + ++ManagedTextureLockCalls; + if (!FirstManagedTextureLockLogged.exchange(true)) + { + spdlog::info( + "VR D3D9Ex R13: translated MANAGED texture LockRect observed level={} flags=0x{:08X} hr=0x{:08X}; lock semantics are now explicitly monitored", + level, static_cast(flags), static_cast(hr)); + } + if (FAILED(hr)) + { + ++ManagedTextureLockFailures; + if (!FirstManagedTextureLockFailureLogged.exchange(true)) + { + spdlog::error( + "VR D3D9Ex R13: translated MANAGED texture LockRect FAILED hr=0x{:08X}; this resource requires CPU-shadow emulation before D3D9Ex can be considered production-safe", + static_cast(hr)); + } + } + return hr; + } + + HRESULT __stdcall TextureUnlockRectR13(IDirect3DTexture9* texture, + UINT level) + { + const HRESULT hr = R13TextureUnlockRectHook.stdcall( + texture, level); + if (IsTrackedManagedTexture(texture)) + ++ManagedTextureUnlockCalls; + return hr; + } + + void EnsureTextureLockInstrumentation(IDirect3DTexture9* texture) + { + if (!texture || + (R13TextureLockRectHook && R13TextureUnlockRectHook)) + return; + void** vtable = *reinterpret_cast(texture); + if (!vtable) + return; + if (!R13TextureLockRectHook) + R13TextureLockRectHook = safetyhook::create_inline( + vtable[TextureLockRectVtableIndex], TextureLockRectR13); + if (!R13TextureUnlockRectHook) + R13TextureUnlockRectHook = safetyhook::create_inline( + vtable[TextureUnlockRectVtableIndex], TextureUnlockRectR13); + if (!R13TextureLockRectHook || !R13TextureUnlockRectHook) + spdlog::warn( + "VR D3D9Ex R13: failed to install translated MANAGED texture LockRect/UnlockRect diagnostics"); + } + + HRESULT __stdcall CreateTextureCompatDestR13( + IDirect3DDevice9* device, UINT width, UINT height, UINT levels, + DWORD usage, D3DFORMAT format, D3DPOOL pool, + IDirect3DTexture9** texture, HANDLE* sharedHandle) + { + const HRESULT hr = R13CreateTextureCallbackHook.stdcall( + device, width, height, levels, usage, format, pool, + texture, sharedHandle); + if (pool == D3DPOOL_MANAGED && SUCCEEDED(hr) && texture && *texture && + OutRunVRD3D9ExUpgrade::IsCompatDevice(device)) + { + TrackManagedTexture(*texture); + EnsureTextureLockInstrumentation(*texture); + } + return hr; + } + + bool InstallManagedResourceCompatR13(IDirect3DDevice9Ex* deviceEx) + { + const bool installed = R13InstallCompatHook.call(deviceEx); + if (!installed) + return false; + + // Keep the temporary ResetEx shim armed through R14/R15 validation. + // The final R15 wrapper performs the synchronous same-thread handoff + // immediately before the promoted device is allowed to escape + // CreateDeviceEx, so there is no externally visible Reset gap. + + if (!R13CreateTextureCallbackHook) + { + R13CreateTextureCallbackHook = safetyhook::create_inline( + reinterpret_cast( + &OutRunVRD3D9ExUpgrade::CreateTextureCompatDest), + CreateTextureCompatDestR13); + } + if (!R13CreateTextureCallbackHook) + { + spdlog::warn( + "VR D3D9Ex R13: failed to hook managed texture creation callback for Lock/Unlock diagnostics"); + } + else if (!CompatHardeningLogged.exchange(true)) + { + spdlog::info( + "VR D3D9Ex R13: compatibility hardening active; legacy ResetEx shim retained through final R15 validation and translated MANAGED texture Lock/Unlock diagnostics armed"); + } + return true; + } + + class D3D9ExR13HardeningHook final : public Hook + { + public: + std::string_view description() override + { + return "OpenXRVRD3D9ExR13Hardening"; + } + bool validate() override { return Settings::VRPreferD3D9Ex; } + bool apply() override + { + R13InstallCompatHook = safetyhook::create_inline( + reinterpret_cast( + &OutRunVRD3D9ExUpgrade::InstallManagedResourceCompat), + InstallManagedResourceCompatR13); + if (!R13InstallCompatHook) + { + spdlog::error( + "VR D3D9Ex R13: failed to intercept managed compatibility installation; experimental Ex promotion disabled for safety"); + return false; + } + return true; + } + static D3D9ExR13HardeningHook instance; + }; + + D3D9ExR13HardeningHook D3D9ExR13HardeningHook::instance; + } + + bool IsCompatDevice(IDirect3DDevice9* device) noexcept + { + return OutRunVRD3D9ExUpgrade::IsCompatDevice(device); + } + + void DisarmLegacyResetHook() noexcept + { + if (OutRunVRD3D9ExUpgrade::ResetCompatHook) + { + OutRunVRD3D9ExUpgrade::ResetCompatHook = {}; + if (!ResetHookDisarmedLogged.exchange(true)) + { + spdlog::info( + "VR D3D9Ex R13: legacy compatibility Reset inline hook disarmed synchronously; stereo Reset callback is sole reset owner"); + } + } + } + + HRESULT NormalizeLegacyPresentResult( + IDirect3DDevice9* device, HRESULT result) noexcept + { + if (!OutRunVRD3D9ExUpgrade::IsCompatDevice(device)) + return result; + if (result == S_PRESENT_MODE_CHANGED) + return D3DERR_DEVICELOST; + if (result == S_PRESENT_OCCLUDED) + { + return OutRunVRD3D9ExUpgrade::CompatWindowed.load( + std::memory_order_acquire) + ? D3D_OK : D3DERR_DEVICELOST; + } + return result; + } + + bool ResetCompatDevice(IDirect3DDevice9* device, + D3DPRESENT_PARAMETERS* params, HRESULT& result) noexcept + { + result = D3DERR_INVALIDCALL; + if (!params || !OutRunVRD3D9ExUpgrade::IsCompatDevice(device)) + return false; + + IDirect3DDevice9Ex* deviceEx = nullptr; + const HRESULT qi = device->QueryInterface( + __uuidof(IDirect3DDevice9Ex), reinterpret_cast(&deviceEx)); + if (FAILED(qi) || !deviceEx) + return false; + + D3DDISPLAYMODEEX fullscreen{}; + D3DDISPLAYMODEEX* fullscreenPtr = + OutRunVRD3D9ExUpgrade::BuildFullscreenMode( + deviceEx, params, fullscreen); + result = deviceEx->ResetEx(params, fullscreenPtr); + deviceEx->Release(); + if (SUCCEEDED(result)) + { + OutRunVRD3D9ExUpgrade::UpdateCompatPresentationState(device, params); + OutRunVRD3D9ExUpgrade::RestoreClassicResetState(device); + } + ++OutRunVRD3D9ExUpgrade::ResetExRedirects; + spdlog::info( + "VR D3D9Ex R13: authoritative stereo Reset path called ResetEx + classic-state replay hr=0x{:08X}; no competing Reset inline hook", + static_cast(result)); + return true; + } +} diff --git a/src/vr/d3d9/ex_device_upgrade_r14.cpp b/src/vr/d3d9/ex_device_upgrade_r14.cpp new file mode 100644 index 000000000..57ba4400a --- /dev/null +++ b/src/vr/d3d9/ex_device_upgrade_r14.cpp @@ -0,0 +1,1232 @@ +// R14 D3D9Ex managed-texture compatibility overlay. +// +// D3D9Ex has no D3DPOOL_MANAGED. The R13 compatibility layer translates legacy +// MANAGED 2D textures to DEFAULT|DYNAMIC, but a few drivers/resources still +// reject LockRect on those translated GPU textures. R14 keeps a SYSTEMMEM CPU +// shadow for every successfully translated MANAGED 2D texture and services the +// game's LockRect/UnlockRect against that shadow. +// +// R31 hardening removes the old 256-entry overwrite ring. Entries now live until +// the tracked texture's final COM Release, and a shared entry keeps an in-flight +// Lock/Unlock operation stable while the registry is changed. Borrowing a +// level surface is allowed while its mutating entry points are observed; actual +// external writes (surface LockRect/GetDC, StretchRect, ColorFill, Update*, or +// mip generation) retire the shadow instead of risking a stale CPU overwrite. + +#include +#include +#include +#include +#include + +#include "ex_device_upgrade_r13.cpp" + +namespace OutRunVRD3D9ExUpgradeR13 +{ + namespace + { + constexpr std::uint32_t R14MaxTrackedLevels = 32; + constexpr std::size_t R14TextureReleaseVtableIndex = 2; + constexpr std::size_t R14TextureGenerateMipSubLevelsVtableIndex = 16; + constexpr std::size_t R14TextureGetSurfaceLevelVtableIndex = 18; + constexpr std::size_t R14TextureAddDirtyRectVtableIndex = 21; + constexpr std::size_t R14DeviceUpdateSurfaceVtableIndex = 30; + constexpr std::size_t R14DeviceUpdateTextureVtableIndex = 31; + constexpr std::size_t R14DeviceStretchRectVtableIndex = 34; + constexpr std::size_t R14DeviceColorFillVtableIndex = 35; + constexpr std::size_t R14SurfaceLockRectVtableIndex = 13; + constexpr std::size_t R14SurfaceGetDCVtableIndex = 15; + + SafetyHookInline R14CreateTextureR13Hook{}; + SafetyHookInline R14TextureLockR13Hook{}; + SafetyHookInline R14TextureUnlockR13Hook{}; + SafetyHookInline R14InstallCompatR13Hook{}; + SafetyHookInline R14TextureReleaseHook{}; + SafetyHookInline R14TextureGenerateMipSubLevelsHook{}; + SafetyHookInline R14TextureGetSurfaceLevelHook{}; + SafetyHookInline R14TextureAddDirtyRectHook{}; + SafetyHookInline R14DeviceUpdateSurfaceHook{}; + SafetyHookInline R14DeviceUpdateTextureHook{}; + SafetyHookInline R14DeviceStretchRectHook{}; + SafetyHookInline R14DeviceColorFillHook{}; + SafetyHookInline R14SurfaceLockRectHook{}; + SafetyHookInline R14SurfaceGetDCHook{}; + + void* R14TextureReleaseTarget = nullptr; + void* R14TextureGenerateMipSubLevelsTarget = nullptr; + void* R14TextureGetSurfaceLevelTarget = nullptr; + void* R14TextureAddDirtyRectTarget = nullptr; + void* R14DeviceUpdateSurfaceTarget = nullptr; + void* R14DeviceUpdateTextureTarget = nullptr; + void* R14DeviceStretchRectTarget = nullptr; + void* R14DeviceColorFillTarget = nullptr; + void* R14SurfaceLockRectTarget = nullptr; + void* R14SurfaceGetDCTarget = nullptr; + + enum class R14ShadowMode : std::uint8_t + { + Shadow, + DirectOnly + }; + + thread_local std::uint32_t R14InternalReleaseDepth = 0; + constexpr std::uint64_t R14ShadowBudgetBytes = + 384ull * 1024ull * 1024ull; + std::atomic R14ShadowBytes{0}; + std::atomic R14ShadowBudgetRejects{0}; + + struct R14ShadowEntry + { + std::mutex mutex; + IDirect3DTexture9* gpu = nullptr; // identity only; registry owns no ref + IDirect3DTexture9* cpu = nullptr; + IDirect3DDevice9* device = nullptr; + R14ShadowMode mode = R14ShadowMode::Shadow; + std::uint32_t validMask = 0; + std::uint32_t dirtyMask = 0; + std::uint32_t lockedMask = 0; + std::uint32_t readOnlyMask = 0; + RECT lockRect[R14MaxTrackedLevels]{}; + bool lockRectValid[R14MaxTrackedLevels]{}; + bool retirePending = false; + bool externalWriteDuringLock = false; + std::uint64_t shadowBytes = 0; + + ~R14ShadowEntry() + { + ++R14InternalReleaseDepth; + if (cpu) cpu->Release(); + if (device) device->Release(); + if (shadowBytes) + R14ShadowBytes.fetch_sub( + shadowBytes, std::memory_order_acq_rel); + --R14InternalReleaseDepth; + } + }; + + using R14EntryPtr = std::shared_ptr; + std::mutex R14RegistryMutex; + std::mutex R14ResourceHookMutex; + std::unordered_map R14Shadows; + IDirect3DDevice9* R14RegistryDevice = nullptr; // identity only + thread_local std::uint32_t R14InternalUploadDepth = 0; + + std::atomic R14ShadowCreated{0}; + std::atomic R14ShadowCreateFailed{0}; + std::atomic R14DirectOnlyFallbacks{0}; + std::atomic R14ShadowReleased{0}; + std::atomic R14ShadowRetired{0}; + std::atomic R14DeviceReplacementRetires{0}; + std::atomic R14ShadowLocks{0}; + std::atomic R14ShadowUploads{0}; + std::atomic R14ShadowUploadFailed{0}; + std::atomic R14FirstActiveLogged{false}; + std::atomic R14FirstFallbackLogged{false}; + std::atomic R14FirstHardFailureLogged{false}; + std::atomic R14FirstUploadFailureLogged{false}; + std::atomic R14FirstRetireLogged{false}; + std::atomic R14FirstConcurrentWriteLogged{false}; + + struct R14InternalUploadScope + { + R14InternalUploadScope() noexcept { ++R14InternalUploadDepth; } + ~R14InternalUploadScope() + { + if (R14InternalUploadDepth) --R14InternalUploadDepth; + } + }; + + R14EntryPtr R14Find(IDirect3DTexture9* texture) noexcept + { + if (!texture) return {}; + std::lock_guard lock(R14RegistryMutex); + const auto found = R14Shadows.find(texture); + return found == R14Shadows.end() ? R14EntryPtr{} : found->second; + } + + void R14Erase(IDirect3DTexture9* texture) noexcept + { + R14EntryPtr released; + { + std::lock_guard lock(R14RegistryMutex); + const auto found = R14Shadows.find(texture); + if (found == R14Shadows.end()) + return; + released = std::move(found->second); + R14Shadows.erase(found); + } + ++R14ShadowReleased; + } + + IDirect3DTexture9* R14DetachShadowLocked( + R14ShadowEntry& entry) noexcept + { + IDirect3DTexture9* cpu = entry.cpu; + entry.cpu = nullptr; + if (entry.shadowBytes) + { + R14ShadowBytes.fetch_sub( + entry.shadowBytes, std::memory_order_acq_rel); + entry.shadowBytes = 0; + } + entry.mode = R14ShadowMode::DirectOnly; + entry.validMask = 0; + entry.dirtyMask = 0; + entry.lockedMask = 0; + entry.readOnlyMask = 0; + entry.retirePending = false; + entry.externalWriteDuringLock = false; + for (std::uint32_t level = 0; level < R14MaxTrackedLevels; ++level) + entry.lockRectValid[level] = false; + return cpu; + } + + void R14LogRetiredShadow(const char* reason) noexcept; + + void R14AdoptCompatDevice(IDirect3DDevice9* device) noexcept + { + if (!device) return; + + std::vector oldDeviceEntries; + { + std::lock_guard lock(R14RegistryMutex); + if (!R14RegistryDevice) + { + R14RegistryDevice = device; + return; + } + if (R14RegistryDevice == device) + return; + + // Snapshot without removing records. A texture may still be + // CPU-shadow locked while its owning device is being replaced; + // its matching Unlock must continue to find the same entry. + try + { + oldDeviceEntries.reserve(R14Shadows.size()); + for (const auto& item : R14Shadows) + { + if (item.second && item.second->device == R14RegistryDevice) + oldDeviceEntries.push_back(item.second); + } + } + catch (...) + { + // Keep the old identity authoritative so new resources fail + // open to R13 rather than mixing two shadow generations. + spdlog::error( + "VR R14 EX: could not snapshot old-device shadows during replacement; new-device shadows remain disabled"); + return; + } + R14RegistryDevice = device; + } + + std::size_t retiredNow = 0; + std::size_t pendingUnlock = 0; + for (const auto& entry : oldDeviceEntries) + { + IDirect3DTexture9* cpu = nullptr; + { + std::lock_guard lock(entry->mutex); + if (entry->mode == R14ShadowMode::DirectOnly) + continue; + entry->retirePending = true; + if (entry->lockedMask == 0) + { + cpu = R14DetachShadowLocked(*entry); + ++retiredNow; + } + else + { + ++pendingUnlock; + } + } + if (cpu) cpu->Release(); + if (cpu) R14LogRetiredShadow("compat device replacement"); + } + + ++R14DeviceReplacementRetires; + spdlog::info( + "VR R14 EX: compat device replacement retired {} old texture shadows immediately and deferred {} locked shadows until matching Unlock", + retiredNow, pendingUnlock); + } + + void R14AbandonCompatDevice(IDirect3DDevice9* device) noexcept + { + if (!device) return; + std::lock_guard lock(R14RegistryMutex); + if (R14RegistryDevice == device && R14Shadows.empty()) + R14RegistryDevice = nullptr; + } + + void R14LogRetiredShadow(const char* reason) noexcept + { + ++R14ShadowRetired; + if (!R14FirstRetireLogged.exchange(true)) + { + spdlog::warn( + "VR R14 EX: first CPU shadow retired reason={}; this texture now stays on one coherent direct-GPU path", + reason ? reason : "unknown"); + } + } + + bool R14RetireShadow(const R14EntryPtr& entry, + const char* reason) noexcept + { + if (!entry) return true; + IDirect3DTexture9* cpu = nullptr; + { + std::lock_guard lock(entry->mutex); + if (entry->mode == R14ShadowMode::DirectOnly) + return true; + if (entry->lockedMask != 0) + return false; + cpu = R14DetachShadowLocked(*entry); + } + if (cpu) cpu->Release(); + R14LogRetiredShadow(reason); + return true; + } + + void R14MarkExternalGpuWrite(IDirect3DTexture9* texture, + const char* reason) noexcept + { + const R14EntryPtr entry = R14Find(texture); + if (!entry) return; + + IDirect3DTexture9* cpu = nullptr; + bool retired = false; + bool concurrent = false; + { + std::lock_guard lock(entry->mutex); + if (entry->mode == R14ShadowMode::DirectOnly) + return; + concurrent = entry->lockedMask != 0; + entry->validMask = 0; + entry->externalWriteDuringLock |= concurrent; + entry->retirePending = true; + if (!concurrent) + { + cpu = R14DetachShadowLocked(*entry); + retired = true; + } + } + if (cpu) cpu->Release(); + if (retired) R14LogRetiredShadow(reason); + if (concurrent && !R14FirstConcurrentWriteLogged.exchange(true)) + { + spdlog::error( + "VR R14 EX: external GPU texture write overlapped a CPU-shadow Lock; upload is suppressed and the shadow will retire at the final Unlock"); + } + } + + std::uint64_t R14EstimateShadowBytes( + IDirect3DTexture9* gpu) noexcept + { + if (!gpu) return 0; + const UINT levels = gpu->GetLevelCount(); + std::uint64_t total = 0; + for (UINT level = 0; + level < levels && level < R14MaxTrackedLevels; ++level) + { + D3DSURFACE_DESC d{}; + if (FAILED(gpu->GetLevelDesc(level, &d)) || + d.Width == 0 || d.Height == 0) + return 0; + + std::uint64_t bytes = 0; + if (d.Format == D3DFMT_DXT1) + { + bytes = ((static_cast(d.Width) + 3) / 4) * + ((static_cast(d.Height) + 3) / 4) * 8; + } + else if (d.Format == D3DFMT_DXT2 || + d.Format == D3DFMT_DXT3 || + d.Format == D3DFMT_DXT4 || + d.Format == D3DFMT_DXT5) + { + bytes = ((static_cast(d.Width) + 3) / 4) * + ((static_cast(d.Height) + 3) / 4) * 16; + } + else + { + std::uint64_t bpp = 4; + if (d.Format == D3DFMT_A16B16G16R16 || + d.Format == D3DFMT_A16B16G16R16F) + bpp = 8; + else if (d.Format == D3DFMT_A32B32G32R32F) + bpp = 16; + else if (d.Format == D3DFMT_R5G6B5 || + d.Format == D3DFMT_A1R5G5B5 || + d.Format == D3DFMT_A4R4G4B4 || + d.Format == D3DFMT_A8L8) + bpp = 2; + else if (d.Format == D3DFMT_A8 || + d.Format == D3DFMT_L8) + bpp = 1; + bytes = static_cast(d.Width) * + static_cast(d.Height) * bpp; + } + + if (bytes > R14ShadowBudgetBytes || + total > R14ShadowBudgetBytes - bytes) + return R14ShadowBudgetBytes + 1; + total += bytes; + } + return total; + } + + bool R14ReserveShadowBytes( + std::uint64_t bytes) noexcept + { + if (!bytes || bytes > R14ShadowBudgetBytes) + return false; + std::uint64_t current = + R14ShadowBytes.load(std::memory_order_acquire); + for (;;) + { + if (current > R14ShadowBudgetBytes - bytes) + return false; + if (R14ShadowBytes.compare_exchange_weak( + current, current + bytes, + std::memory_order_acq_rel, + std::memory_order_acquire)) + return true; + } + } + + bool R14Track(IDirect3DDevice9* device, IDirect3DTexture9* gpu, + IDirect3DTexture9* cpu) noexcept + { + if (!device || !gpu || !cpu) return false; + try + { + auto entry = std::make_shared(); + entry->gpu = gpu; + entry->device = device; + entry->shadowBytes = R14EstimateShadowBytes(gpu); + if (!R14ReserveShadowBytes(entry->shadowBytes)) + { + entry->shadowBytes = 0; + ++R14ShadowBudgetRejects; + return false; + } + const UINT levels = gpu->GetLevelCount(); + entry->validMask = levels >= R14MaxTrackedLevels + ? 0xFFFFFFFFu : ((1u << levels) - 1u); + device->AddRef(); + + std::lock_guard lock(R14RegistryMutex); + if (!R14RegistryDevice) + R14RegistryDevice = device; + if (R14RegistryDevice != device) + return false; + const auto inserted = R14Shadows.emplace(gpu, entry); + if (!inserted.second) + return false; + // Ownership transfers only after insertion succeeds. On every + // failure path the caller still owns and releases cpu exactly once. + entry->cpu = cpu; + } + catch (...) + { + return false; + } + + ++R14ShadowCreated; + if (!R14FirstActiveLogged.exchange(true)) + { + spdlog::info( + "VR R14 EX: lifetime-bound MANAGED 2D CPU-shadow path ACTIVE; no live or locked texture can be evicted by a fixed-capacity ring"); + } + return true; + } + + bool R14TrackDirectOnly(IDirect3DDevice9* device, + IDirect3DTexture9* gpu) noexcept + { + if (!device || !gpu) return false; + try + { + auto entry = std::make_shared(); + entry->gpu = gpu; + entry->device = device; + entry->mode = R14ShadowMode::DirectOnly; + entry->validMask = 0; + device->AddRef(); + + std::lock_guard lock(R14RegistryMutex); + if (!R14RegistryDevice) + R14RegistryDevice = device; + if (R14RegistryDevice != device) + return false; + const auto inserted = R14Shadows.emplace(gpu, entry); + if (!inserted.second) + return false; + } + catch (...) + { + return false; + } + ++R14DirectOnlyFallbacks; + return true; + } + + HRESULT R14CreateCpuShadow(IDirect3DDevice9* device, + IDirect3DTexture9* gpu, IDirect3DTexture9*& shadow) noexcept + { + shadow = nullptr; + if (!device || !gpu) return D3DERR_INVALIDCALL; + D3DSURFACE_DESC desc{}; + const HRESULT descHr = gpu->GetLevelDesc(0, &desc); + if (FAILED(descHr) || desc.Width == 0 || desc.Height == 0) + return FAILED(descHr) ? descHr : D3DERR_INVALIDCALL; + const UINT levels = gpu->GetLevelCount(); + if (levels == 0 || levels > R14MaxTrackedLevels) + return D3DERR_NOTAVAILABLE; + + const std::uint64_t estimate = R14EstimateShadowBytes(gpu); + const std::uint64_t current = + R14ShadowBytes.load(std::memory_order_acquire); + if (!estimate || estimate > R14ShadowBudgetBytes || + current > R14ShadowBudgetBytes - estimate) + { + ++R14ShadowBudgetRejects; + return D3DERR_OUTOFVIDEOMEMORY; + } + + const HRESULT hr = device->CreateTexture( + desc.Width, desc.Height, levels, 0, desc.Format, + D3DPOOL_SYSTEMMEM, &shadow, nullptr); + return SUCCEEDED(hr) && shadow ? D3D_OK : hr; + } + + ULONG __stdcall TextureReleaseDestR14(IDirect3DTexture9* texture) + { + const ULONG remaining = + R14TextureReleaseHook.stdcall(texture); + if (remaining == 0 && R14InternalReleaseDepth == 0) + R14Erase(texture); + return remaining; + } + + bool R14EnsureSurfaceHooks(IDirect3DSurface9* surface) noexcept; + + HRESULT __stdcall TextureGetSurfaceLevelDestR14( + IDirect3DTexture9* texture, UINT level, IDirect3DSurface9** surface) + { + const R14EntryPtr entry = R14Find(texture); + const HRESULT hr = R14TextureGetSurfaceLevelHook.stdcall( + texture, level, surface); + if (!entry || FAILED(hr) || !surface || !*surface) + return hr; + + // Merely borrowing a level surface is not a write. Keep the CPU + // shadow alive when we can observe the surface's mutating entry + // points; otherwise retire before exposing an untracked alias. + if (R14EnsureSurfaceHooks(*surface)) + return hr; + if (R14RetireShadow(entry, "untracked external level surface")) + return hr; + + (*surface)->Release(); + *surface = nullptr; + return D3DERR_INVALIDCALL; + } + + void __stdcall TextureGenerateMipSubLevelsDestR14( + IDirect3DTexture9* texture) + { + R14TextureGenerateMipSubLevelsHook.stdcall(texture); + if (R14InternalUploadDepth == 0) + { + R14MarkExternalGpuWrite(texture, + "external GenerateMipSubLevels"); + } + } + + HRESULT __stdcall TextureAddDirtyRectDestR14( + IDirect3DTexture9* texture, const RECT* rect) + { + const R14EntryPtr entry = R14Find(texture); + if (entry) + { + std::lock_guard lock(entry->mutex); + if (entry->mode == R14ShadowMode::Shadow && + !entry->retirePending) + { + // Every writable shadow Unlock is explicitly uploaded. + // Preserve MANAGED AddDirtyRect success semantics without + // relying on level-zero UpdateTexture dirty propagation. + return D3D_OK; + } + } + return R14TextureAddDirtyRectHook.stdcall(texture, rect); + } + + HRESULT __stdcall UpdateSurfaceDestR14(IDirect3DDevice9* device, + IDirect3DSurface9* source, const RECT* sourceRect, + IDirect3DSurface9* destination, const POINT* destinationPoint) + { + const HRESULT hr = R14DeviceUpdateSurfaceHook.stdcall( + device, source, sourceRect, destination, destinationPoint); + if (SUCCEEDED(hr) && destination && R14InternalUploadDepth == 0) + { + IDirect3DTexture9* texture = nullptr; + if (SUCCEEDED(destination->GetContainer( + __uuidof(IDirect3DTexture9), + reinterpret_cast(&texture))) && texture) + { + R14MarkExternalGpuWrite(texture, "external UpdateSurface"); + texture->Release(); + } + } + return hr; + } + + HRESULT __stdcall UpdateTextureDestR14(IDirect3DDevice9* device, + IDirect3DBaseTexture9* source, + IDirect3DBaseTexture9* destination) + { + const HRESULT hr = R14DeviceUpdateTextureHook.stdcall( + device, source, destination); + if (SUCCEEDED(hr) && destination && R14InternalUploadDepth == 0) + { + IDirect3DTexture9* texture = nullptr; + if (SUCCEEDED(destination->QueryInterface( + __uuidof(IDirect3DTexture9), + reinterpret_cast(&texture))) && texture) + { + R14MarkExternalGpuWrite(texture, "external UpdateTexture"); + texture->Release(); + } + } + return hr; + } + + void R14MarkSurfaceExternalWrite(IDirect3DSurface9* surface, + const char* reason) noexcept + { + if (!surface) return; + IDirect3DTexture9* texture = nullptr; + if (SUCCEEDED(surface->GetContainer(__uuidof(IDirect3DTexture9), + reinterpret_cast(&texture))) && texture) + { + R14MarkExternalGpuWrite(texture, reason); + texture->Release(); + } + } + + HRESULT __stdcall SurfaceLockRectDestR14(IDirect3DSurface9* surface, + D3DLOCKED_RECT* locked, const RECT* rect, DWORD flags) + { + const HRESULT hr = R14SurfaceLockRectHook.stdcall( + surface, locked, rect, flags); + if (SUCCEEDED(hr) && (flags & D3DLOCK_READONLY) == 0) + R14MarkSurfaceExternalWrite(surface, "external level-surface LockRect"); + return hr; + } + + HRESULT __stdcall SurfaceGetDCDestR14(IDirect3DSurface9* surface, + HDC* dc) + { + const HRESULT hr = R14SurfaceGetDCHook.stdcall(surface, dc); + if (SUCCEEDED(hr)) + R14MarkSurfaceExternalWrite(surface, "external level-surface GetDC"); + return hr; + } + + bool R14EnsureSurfaceHooks(IDirect3DSurface9* surface) noexcept + { + if (!surface) return false; + std::lock_guard installLock(R14ResourceHookMutex); + void** vtable = *reinterpret_cast(surface); + if (!vtable) return false; + if (R14SurfaceLockRectHook && R14SurfaceGetDCHook) + { + return R14SurfaceLockRectTarget == + vtable[R14SurfaceLockRectVtableIndex] && + R14SurfaceGetDCTarget == vtable[R14SurfaceGetDCVtableIndex]; + } + const auto disabled = safetyhook::InlineHook::StartDisabled; + R14SurfaceLockRectHook = safetyhook::create_inline( + vtable[R14SurfaceLockRectVtableIndex], SurfaceLockRectDestR14, + disabled); + R14SurfaceGetDCHook = safetyhook::create_inline( + vtable[R14SurfaceGetDCVtableIndex], SurfaceGetDCDestR14, + disabled); + if (!R14SurfaceLockRectHook || !R14SurfaceGetDCHook || + !R14SurfaceLockRectHook.enable().has_value() || + !R14SurfaceGetDCHook.enable().has_value()) + { + R14SurfaceGetDCHook = {}; + R14SurfaceLockRectHook = {}; + R14SurfaceLockRectTarget = nullptr; + R14SurfaceGetDCTarget = nullptr; + return false; + } + R14SurfaceLockRectTarget = vtable[R14SurfaceLockRectVtableIndex]; + R14SurfaceGetDCTarget = vtable[R14SurfaceGetDCVtableIndex]; + return true; + } + + HRESULT __stdcall StretchRectDestR14(IDirect3DDevice9* device, + IDirect3DSurface9* source, const RECT* sourceRect, + IDirect3DSurface9* destination, const RECT* destinationRect, + D3DTEXTUREFILTERTYPE filter) + { + const HRESULT hr = R14DeviceStretchRectHook.stdcall( + device, source, sourceRect, destination, destinationRect, filter); + if (SUCCEEDED(hr) && destination && R14InternalUploadDepth == 0) + R14MarkSurfaceExternalWrite(destination, "external StretchRect"); + return hr; + } + + HRESULT __stdcall ColorFillDestR14(IDirect3DDevice9* device, + IDirect3DSurface9* surface, const RECT* rect, D3DCOLOR color) + { + const HRESULT hr = R14DeviceColorFillHook.stdcall( + device, surface, rect, color); + if (SUCCEEDED(hr) && surface && R14InternalUploadDepth == 0) + R14MarkSurfaceExternalWrite(surface, "external ColorFill"); + return hr; + } + + bool InstallManagedResourceCompatR14(IDirect3DDevice9Ex* deviceEx) + { + const bool installed = + R14InstallCompatR13Hook.call(deviceEx); + if (installed && deviceEx) + { + R14AdoptCompatDevice( + static_cast(deviceEx)); + } + return installed; + } + + bool R14EnsureResourceHooks(IDirect3DDevice9* device, + IDirect3DTexture9* texture) noexcept + { + if (!device || !texture) return false; + std::lock_guard installLock(R14ResourceHookMutex); + + void** textureVtable = *reinterpret_cast(texture); + void** deviceVtable = *reinterpret_cast(device); + if (!textureVtable || !deviceVtable) + return false; + + if (R14TextureReleaseHook && + R14TextureGenerateMipSubLevelsHook && + R14TextureGetSurfaceLevelHook && + R14TextureAddDirtyRectHook && R14DeviceUpdateSurfaceHook && + R14DeviceUpdateTextureHook && R14DeviceStretchRectHook && + R14DeviceColorFillHook) + { + // A replacement device may be supplied by a wrapper with a + // different implementation vtable. Never claim coverage from + // hooks installed on the old implementation. + return R14TextureReleaseTarget == + textureVtable[R14TextureReleaseVtableIndex] && + R14TextureGenerateMipSubLevelsTarget == + textureVtable[ + R14TextureGenerateMipSubLevelsVtableIndex] && + R14TextureGetSurfaceLevelTarget == + textureVtable[R14TextureGetSurfaceLevelVtableIndex] && + R14TextureAddDirtyRectTarget == + textureVtable[R14TextureAddDirtyRectVtableIndex] && + R14DeviceUpdateSurfaceTarget == + deviceVtable[R14DeviceUpdateSurfaceVtableIndex] && + R14DeviceUpdateTextureTarget == + deviceVtable[R14DeviceUpdateTextureVtableIndex] && + R14DeviceStretchRectTarget == + deviceVtable[R14DeviceStretchRectVtableIndex] && + R14DeviceColorFillTarget == + deviceVtable[R14DeviceColorFillVtableIndex]; + } + + const auto disabled = safetyhook::InlineHook::StartDisabled; + R14TextureReleaseHook = safetyhook::create_inline( + textureVtable[R14TextureReleaseVtableIndex], + TextureReleaseDestR14, disabled); + R14TextureGenerateMipSubLevelsHook = safetyhook::create_inline( + textureVtable[R14TextureGenerateMipSubLevelsVtableIndex], + TextureGenerateMipSubLevelsDestR14, disabled); + R14TextureGetSurfaceLevelHook = safetyhook::create_inline( + textureVtable[R14TextureGetSurfaceLevelVtableIndex], + TextureGetSurfaceLevelDestR14, disabled); + R14TextureAddDirtyRectHook = safetyhook::create_inline( + textureVtable[R14TextureAddDirtyRectVtableIndex], + TextureAddDirtyRectDestR14, disabled); + R14DeviceUpdateSurfaceHook = safetyhook::create_inline( + deviceVtable[R14DeviceUpdateSurfaceVtableIndex], + UpdateSurfaceDestR14, disabled); + R14DeviceUpdateTextureHook = safetyhook::create_inline( + deviceVtable[R14DeviceUpdateTextureVtableIndex], + UpdateTextureDestR14, disabled); + R14DeviceStretchRectHook = safetyhook::create_inline( + deviceVtable[R14DeviceStretchRectVtableIndex], + StretchRectDestR14, disabled); + R14DeviceColorFillHook = safetyhook::create_inline( + deviceVtable[R14DeviceColorFillVtableIndex], + ColorFillDestR14, disabled); + + SafetyHookInline* hooks[]{ + &R14TextureReleaseHook, + &R14TextureGenerateMipSubLevelsHook, + &R14TextureGetSurfaceLevelHook, + &R14TextureAddDirtyRectHook, + &R14DeviceUpdateSurfaceHook, + &R14DeviceUpdateTextureHook, + &R14DeviceStretchRectHook, + &R14DeviceColorFillHook + }; + for (auto* hook : hooks) + { + if (!*hook || !hook->enable().has_value()) + { + R14DeviceColorFillHook = {}; + R14DeviceStretchRectHook = {}; + R14DeviceUpdateTextureHook = {}; + R14DeviceUpdateSurfaceHook = {}; + R14TextureAddDirtyRectHook = {}; + R14TextureGetSurfaceLevelHook = {}; + R14TextureGenerateMipSubLevelsHook = {}; + R14TextureReleaseHook = {}; + R14TextureReleaseTarget = nullptr; + R14TextureGenerateMipSubLevelsTarget = nullptr; + R14TextureGetSurfaceLevelTarget = nullptr; + R14TextureAddDirtyRectTarget = nullptr; + R14DeviceUpdateSurfaceTarget = nullptr; + R14DeviceUpdateTextureTarget = nullptr; + R14DeviceStretchRectTarget = nullptr; + R14DeviceColorFillTarget = nullptr; + return false; + } + } + R14TextureReleaseTarget = + textureVtable[R14TextureReleaseVtableIndex]; + R14TextureGenerateMipSubLevelsTarget = + textureVtable[R14TextureGenerateMipSubLevelsVtableIndex]; + R14TextureGetSurfaceLevelTarget = + textureVtable[R14TextureGetSurfaceLevelVtableIndex]; + R14TextureAddDirtyRectTarget = + textureVtable[R14TextureAddDirtyRectVtableIndex]; + R14DeviceUpdateSurfaceTarget = + deviceVtable[R14DeviceUpdateSurfaceVtableIndex]; + R14DeviceUpdateTextureTarget = + deviceVtable[R14DeviceUpdateTextureVtableIndex]; + R14DeviceStretchRectTarget = + deviceVtable[R14DeviceStretchRectVtableIndex]; + R14DeviceColorFillTarget = + deviceVtable[R14DeviceColorFillVtableIndex]; + return true; + } + + HRESULT __stdcall CreateTextureCompatDestR14( + IDirect3DDevice9* device, UINT width, UINT height, UINT levels, + DWORD usage, D3DFORMAT format, D3DPOOL pool, + IDirect3DTexture9** texture, HANDLE* sharedHandle) + { + const HRESULT hr = R14CreateTextureR13Hook.stdcall( + device, width, height, levels, usage, format, pool, + texture, sharedHandle); + if (FAILED(hr) || pool != D3DPOOL_MANAGED || !texture || !*texture || + !OutRunVRD3D9ExUpgrade::IsCompatDevice(device)) + return hr; + + IDirect3DTexture9* shadow = nullptr; + const HRESULT shadowHr = R14CreateCpuShadow( + device, *texture, shadow); + const bool hooksReady = R14EnsureResourceHooks(device, *texture); + + if (SUCCEEDED(shadowHr) && shadow && hooksReady && + R14Track(device, *texture, shadow)) + { + return hr; // registry owns shadow on success + } + + // Hardware evidence from f15f1acd showed some translated MANAGED + // textures cannot allocate a SYSTEMMEM peer even though the created + // DEFAULT texture itself is usable. Preserve the game's successful + // CreateTexture result only for that narrow case, and keep the + // resource in the lifecycle registry as DirectOnly so LockRect + // falls through to the already-instrumented R13 path. + if (FAILED(shadowHr) && hooksReady && + R14TrackDirectOnly(device, *texture)) + { + ++R14ShadowCreateFailed; + if (!R14FirstFallbackLogged.exchange(true)) + { + D3DSURFACE_DESC desc{}; + (*texture)->GetLevelDesc(0, &desc); + spdlog::warn( + "VR R14 EX: CPU shadow unavailable hr=0x{:08x} size={}x{} fmt={} levels={}; keeping translated MANAGED texture on tracked DirectOnly compatibility path", + static_cast(shadowHr), desc.Width, desc.Height, + static_cast(desc.Format), + (*texture)->GetLevelCount()); + } + return hr; + } + + if (shadow) shadow->Release(); + if (*texture) + { + (*texture)->Release(); + *texture = nullptr; + } + ++R14ShadowCreateFailed; + ++OutRunVRD3D9ExUpgrade::ManagedCreateFailures; + if (!R14FirstHardFailureLogged.exchange(true)) + { + spdlog::error( + "VR R14 EX: MANAGED 2D resource hook/registry coverage unavailable; hard fail-close retained because DirectOnly lifetime cannot be proven"); + } + return D3DERR_NOTAVAILABLE; + } + + DWORD R14SanitizeLockFlags(DWORD flags) noexcept + { + return flags & ~(D3DLOCK_DISCARD | D3DLOCK_NOOVERWRITE | + D3DLOCK_NO_DIRTY_UPDATE); + } + + HRESULT __stdcall TextureLockRectR14(IDirect3DTexture9* texture, + UINT level, D3DLOCKED_RECT* locked, const RECT* rect, DWORD flags) + { + const R14EntryPtr entry = R14Find(texture); + if (!entry || level >= R14MaxTrackedLevels) + return R14TextureLockR13Hook.stdcall( + texture, level, locked, rect, flags); + + IDirect3DTexture9* cpuToRelease = nullptr; + HRESULT shadowHr = D3DERR_INVALIDCALL; + { + std::unique_lock lock(entry->mutex); + if (entry->mode == R14ShadowMode::DirectOnly) + { + lock.unlock(); + return R14TextureLockR13Hook.stdcall( + texture, level, locked, rect, flags); + } + if (entry->retirePending) + { + if (entry->lockedMask != 0) + return D3DERR_INVALIDCALL; + cpuToRelease = R14DetachShadowLocked(*entry); + } + else + { + const std::uint32_t bit = 1u << level; + if ((entry->validMask & bit) == 0) + { + entry->retirePending = true; + if (entry->lockedMask != 0) + return D3DERR_INVALIDCALL; + cpuToRelease = R14DetachShadowLocked(*entry); + lock.unlock(); + if (cpuToRelease) cpuToRelease->Release(); + R14LogRetiredShadow("invalid mip shadow"); + return R14TextureLockR13Hook.stdcall( + texture, level, locked, rect, flags); + } + shadowHr = entry->cpu->LockRect( + level, locked, rect, R14SanitizeLockFlags(flags)); + if (SUCCEEDED(shadowHr)) + { + entry->lockedMask |= bit; + if ((flags & D3DLOCK_READONLY) != 0) + entry->readOnlyMask |= bit; + else + { + entry->readOnlyMask &= ~bit; + entry->dirtyMask |= bit; + } + if (rect) + { + entry->lockRect[level] = *rect; + entry->lockRectValid[level] = true; + } + else + { + entry->lockRect[level] = {}; + entry->lockRectValid[level] = false; + } + ++R14ShadowLocks; + return shadowHr; + } + + // Never mix a direct GPU lock with outstanding shadow locks. + // Once they drain, retire the shadow and use one path only. + entry->retirePending = true; + if (entry->lockedMask != 0) + return shadowHr; + cpuToRelease = R14DetachShadowLocked(*entry); + } + } + + if (cpuToRelease) cpuToRelease->Release(); + R14LogRetiredShadow("CPU shadow LockRect failure"); + return R14TextureLockR13Hook.stdcall( + texture, level, locked, rect, flags); + } + + bool R14BlockCompressed(D3DFORMAT format) noexcept + { + return format == D3DFMT_DXT1 || format == D3DFMT_DXT2 || + format == D3DFMT_DXT3 || format == D3DFMT_DXT4 || + format == D3DFMT_DXT5; + } + + HRESULT R14CopyWholeLevelByLock(R14ShadowEntry& entry, + UINT level) noexcept + { + D3DSURFACE_DESC desc{}; + if (FAILED(entry.cpu->GetLevelDesc(level, &desc))) + return D3DERR_INVALIDCALL; + + D3DLOCKED_RECT source{}; + D3DLOCKED_RECT destination{}; + HRESULT hr = R14TextureLockR13Hook.stdcall( + entry.cpu, level, &source, nullptr, D3DLOCK_READONLY); + if (FAILED(hr)) return hr; + + const bool singleLevelTexture = entry.gpu->GetLevelCount() <= 1; + const DWORD destinationFlags = + singleLevelTexture ? D3DLOCK_DISCARD : 0; + hr = R14TextureLockR13Hook.stdcall( + entry.gpu, level, &destination, nullptr, destinationFlags); + if (FAILED(hr) && destinationFlags != 0) + { + hr = R14TextureLockR13Hook.stdcall( + entry.gpu, level, &destination, nullptr, 0); + } + + const bool destinationLocked = SUCCEEDED(hr); + if (destinationLocked) + { + const UINT rows = R14BlockCompressed(desc.Format) + ? std::max(1, (desc.Height + 3) / 4) + : desc.Height; + const std::size_t sourcePitch = static_cast( + source.Pitch < 0 ? -static_cast(source.Pitch) + : source.Pitch); + const std::size_t destinationPitch = static_cast( + destination.Pitch < 0 + ? -static_cast(destination.Pitch) + : destination.Pitch); + const std::size_t rowBytes = + std::min(sourcePitch, destinationPitch); + if (!source.pBits || !destination.pBits || rowBytes == 0) + { + hr = E_FAIL; + } + else for (UINT row = 0; row < rows; ++row) + { + const auto* src = static_cast( + source.pBits) + static_cast(row) * + source.Pitch; + auto* dst = static_cast(destination.pBits) + + static_cast(row) * destination.Pitch; + std::memcpy(dst, src, rowBytes); + } + } + + HRESULT destinationUnlock = D3D_OK; + if (destinationLocked) + { + destinationUnlock = + R14TextureUnlockR13Hook.stdcall(entry.gpu, level); + } + const HRESULT sourceUnlock = + R14TextureUnlockR13Hook.stdcall(entry.cpu, level); + if (FAILED(hr)) return hr; + if (FAILED(destinationUnlock)) return destinationUnlock; + return sourceUnlock; + } + + HRESULT R14UploadLevel(R14ShadowEntry& entry, UINT level) noexcept + { + if (!entry.device || !entry.cpu || !entry.gpu) + return E_FAIL; + + R14InternalUploadScope internal; + IDirect3DSurface9* source = nullptr; + IDirect3DSurface9* destination = nullptr; + HRESULT hr = R14TextureGetSurfaceLevelHook.stdcall( + entry.cpu, level, &source); + if (SUCCEEDED(hr)) + { + hr = R14TextureGetSurfaceLevelHook.stdcall( + entry.gpu, level, &destination); + } + if (SUCCEEDED(hr) && source && destination) + { + const RECT* rect = entry.lockRectValid[level] + ? &entry.lockRect[level] : nullptr; + POINT point{}; + POINT* destinationPoint = nullptr; + if (rect) + { + point.x = rect->left; + point.y = rect->top; + destinationPoint = &point; + } + hr = entry.device->UpdateSurface( + source, rect, destination, destinationPoint); + } + else if (SUCCEEDED(hr)) + { + hr = E_FAIL; + } + if (source) source->Release(); + if (destination) destination->Release(); + + // UpdateTexture's level-zero dirty propagation cannot prove that an + // independently changed lower mip is copied. The fallback therefore + // copies this exact mip in full through the translated texture's + // direct lock path, or fails without overwriting unrelated content. + if (FAILED(hr)) + hr = R14CopyWholeLevelByLock(entry, level); + return hr; + } + + HRESULT __stdcall TextureUnlockRectR14(IDirect3DTexture9* texture, + UINT level) + { + const R14EntryPtr entry = R14Find(texture); + if (!entry || level >= R14MaxTrackedLevels) + return R14TextureUnlockR13Hook.stdcall(texture, level); + + IDirect3DTexture9* cpuToRelease = nullptr; + HRESULT result = D3D_OK; + bool retired = false; + { + std::unique_lock lock(entry->mutex); + const std::uint32_t bit = 1u << level; + if (entry->mode == R14ShadowMode::DirectOnly || + (entry->lockedMask & bit) == 0) + { + lock.unlock(); + return R14TextureUnlockR13Hook.stdcall( + texture, level); + } + + const bool readOnly = (entry->readOnlyMask & bit) != 0; + const HRESULT unlockHr = entry->cpu->UnlockRect(level); + entry->lockedMask &= ~bit; + entry->readOnlyMask &= ~bit; + + if (FAILED(unlockHr)) + { + entry->validMask &= ~bit; + entry->retirePending = true; + result = unlockHr; + } + else if (entry->externalWriteDuringLock) + { + // Preserve the externally-written GPU contents. The caller's + // overlapping CPU write cannot be committed coherently. + result = D3DERR_INVALIDCALL; + } + else if (!readOnly) + { + result = R14UploadLevel(*entry, level); + entry->lockRectValid[level] = false; + if (SUCCEEDED(result)) + { + entry->dirtyMask &= ~bit; + ++R14ShadowUploads; + } + else + { + entry->validMask &= ~bit; + ++R14ShadowUploadFailed; + entry->retirePending = true; + if (!R14FirstUploadFailureLogged.exchange(true)) + { + spdlog::error( + "VR R14 EX: exact mip CPU-shadow upload failed hr=0x{:08x}; shadow will retire instead of risking a stale later overwrite", + static_cast(result)); + } + } + } + + if (entry->retirePending && entry->lockedMask == 0) + { + cpuToRelease = R14DetachShadowLocked(*entry); + retired = true; + } + } + + if (cpuToRelease) cpuToRelease->Release(); + if (retired) R14LogRetiredShadow("coherency path retired at Unlock"); + return result; + } + + void R14RollbackHooks() noexcept + { + R14TextureUnlockR13Hook = {}; + R14TextureLockR13Hook = {}; + R14CreateTextureR13Hook = {}; + R14InstallCompatR13Hook = {}; + } + + class VRD3D9ExUpgradeR14Hook final : public Hook + { + public: + std::string_view description() override + { + return "OpenXRVRD3D9ExUpgradeR14"; + } + bool validate() override + { + return Settings::VRPreferD3D9Ex; + } + bool apply() override + { + if (!R13InstallCompatHook) + { + spdlog::error( + "VR R14 EX: R13 compatibility installer is unavailable; R14 overlay remains disabled"); + return false; + } + const auto disabled = safetyhook::InlineHook::StartDisabled; + R14CreateTextureR13Hook = safetyhook::create_inline( + reinterpret_cast(&CreateTextureCompatDestR13), + CreateTextureCompatDestR14, disabled); + R14TextureLockR13Hook = safetyhook::create_inline( + reinterpret_cast(&TextureLockRectR13), + TextureLockRectR14, disabled); + R14TextureUnlockR13Hook = safetyhook::create_inline( + reinterpret_cast(&TextureUnlockRectR13), + TextureUnlockRectR14, disabled); + R14InstallCompatR13Hook = safetyhook::create_inline( + reinterpret_cast(&InstallManagedResourceCompatR13), + InstallManagedResourceCompatR14, disabled); + + SafetyHookInline* hooks[]{ + &R14InstallCompatR13Hook, + &R14CreateTextureR13Hook, + &R14TextureLockR13Hook, + &R14TextureUnlockR13Hook + }; + for (auto* hook : hooks) + { + if (!*hook || !hook->enable().has_value()) + { + R14RollbackHooks(); + spdlog::error( + "VR R14 EX: CPU-shadow hook transaction failed; R13 remains active"); + return false; + } + } + spdlog::info( + "VR R14 EX: lifetime-bound, coherency-fail-closed MANAGED 2D shadow compatibility armed"); + return true; + } + static VRD3D9ExUpgradeR14Hook instance; + }; + + VRD3D9ExUpgradeR14Hook VRD3D9ExUpgradeR14Hook::instance; + } +} diff --git a/src/vr/d3d9/ex_device_upgrade_r15.cpp b/src/vr/d3d9/ex_device_upgrade_r15.cpp new file mode 100644 index 000000000..5dc13cf1a --- /dev/null +++ b/src/vr/d3d9/ex_device_upgrade_r15.cpp @@ -0,0 +1,603 @@ +// R15 D3D9Ex compatibility correctness overlay. +// +// Keeps the validated R14 managed-texture implementation intact while fixing +// three review findings at the final Ex translation boundary: +// * a failed partial-RECT upload may never fall back to a whole-mip copy; +// * GenerateMipSubLevels keeps the coherent level-0 CPU backing while +// invalidating generated lower-mip shadows; +// * ResetEx replays the classic D3D9 baseline plus the state families that +// R14/R13 did not explicitly restore, and publishes a health bit so the +// stereo layer can remain fail-closed if that replay is incomplete. + +#include +#include +#include +#include +#include + +#include "ex_device_upgrade_r14.cpp" +#include "../runtime_eligibility.hpp" + +namespace OutRunVRStereo +{ + bool InstallStereoHooksSynchronously(IDirect3DDevice9* device) noexcept; +} + +namespace OutRunVRD3D9ExUpgradeR13 +{ + namespace + { + SafetyHookInline R15InstallCompatR14Hook{}; + SafetyHookInline R15TextureUnlockR14Hook{}; + SafetyHookInline R15GenerateMipR14Hook{}; + SafetyHookInline R15ResetCompatR13Hook{}; + + std::atomic R15ResetStateHealthy{true}; + std::atomic R15PartialUploadFailClosed{0}; + std::atomic R15GeneratedMipInvalidations{0}; + std::atomic R15ResetStateRestores{0}; + std::atomic R15ResetStateFailures{0}; + bool R15FirstPartialRejectLogged = false; + bool R15FirstMipInvalidateLogged = false; + bool R15FirstResetFailureLogged = false; + + struct R15ClassicExtraBaseline + { + D3DMATRIX world{}; + D3DMATRIX view{}; + D3DMATRIX projection{}; + std::array texture{}; + D3DMATERIAL9 material{}; + D3DCLIPSTATUS9 clipStatus{}; + UINT currentPalette = 0; + float nPatchMode = 0.0f; + bool clipStatusValid = false; + bool paletteValid = false; + bool ready = false; + }; + + R15ClassicExtraBaseline R15ClassicExtra{}; + IDirect3DStateBlock9* R15ClassicAllState = nullptr; + std::mutex R15ClassicExtraMutex; + + bool R15CaptureClassicExtraBaseline(IDirect3DDevice9* device) noexcept + { + if (!device) + return false; + + R15ClassicExtraBaseline next{}; + IDirect3DStateBlock9* allState = nullptr; + if (FAILED(device->CreateStateBlock(D3DSBT_ALL, &allState)) || !allState) + return false; + + if (FAILED(device->GetTransform(D3DTS_WORLD, &next.world)) || + FAILED(device->GetTransform(D3DTS_VIEW, &next.view)) || + FAILED(device->GetTransform(D3DTS_PROJECTION, + &next.projection)) || + FAILED(device->GetMaterial(&next.material))) + { + allState->Release(); + return false; + } + + for (DWORD stage = 0; stage < next.texture.size(); ++stage) + { + if (FAILED(device->GetTransform( + static_cast( + D3DTS_TEXTURE0 + stage), + &next.texture[stage]))) + { + allState->Release(); + return false; + } + } + + next.clipStatusValid = + SUCCEEDED(device->GetClipStatus(&next.clipStatus)); + next.paletteValid = + SUCCEEDED(device->GetCurrentTexturePalette( + &next.currentPalette)); + next.nPatchMode = device->GetNPatchMode(); + next.ready = true; + + std::lock_guard lock(R15ClassicExtraMutex); + if (R15ClassicAllState) + R15ClassicAllState->Release(); + R15ClassicAllState = allState; + R15ClassicExtra = next; + R15ResetStateHealthy.store(true, std::memory_order_release); + OutRunVR::RuntimeEligibility::SetExternalSafetyBlock(false); + return true; + } + + UINT R15PixelFloatConstantCount(const D3DCAPS9& caps) noexcept + { + const UINT major = D3DSHADER_VERSION_MAJOR(caps.PixelShaderVersion); + if (major >= 3) + return 224; + if (major >= 2) + return 32; + if (major >= 1) + return 8; + return 0; + } + + bool R15ZeroClassicShaderConstants(IDirect3DDevice9* device) noexcept + { + if (!device) + return false; + + D3DCAPS9 caps{}; + if (FAILED(device->GetDeviceCaps(&caps))) + return false; + + bool ok = true; + std::array zeroFloat{}; + std::array zeroInt{}; + std::array zeroBool{}; + + const UINT vertexFloatCount = + std::min(caps.MaxVertexShaderConst, 256u); + if (vertexFloatCount != 0 && + FAILED(device->SetVertexShaderConstantF( + 0, zeroFloat.data(), vertexFloatCount))) + ok = false; + + const UINT vsMajor = + D3DSHADER_VERSION_MAJOR(caps.VertexShaderVersion); + if (vsMajor >= 2) + { + if (FAILED(device->SetVertexShaderConstantI( + 0, zeroInt.data(), 16))) + ok = false; + if (FAILED(device->SetVertexShaderConstantB( + 0, zeroBool.data(), 16))) + ok = false; + } + + const UINT pixelFloatCount = R15PixelFloatConstantCount(caps); + if (pixelFloatCount != 0 && + FAILED(device->SetPixelShaderConstantF( + 0, zeroFloat.data(), pixelFloatCount))) + ok = false; + + const UINT psMajor = + D3DSHADER_VERSION_MAJOR(caps.PixelShaderVersion); + if (psMajor >= 2) + { + if (FAILED(device->SetPixelShaderConstantI( + 0, zeroInt.data(), 16))) + ok = false; + if (FAILED(device->SetPixelShaderConstantB( + 0, zeroBool.data(), 16))) + ok = false; + } + return ok; + } + + bool R15RestoreClassicAllState(IDirect3DDevice9* device) noexcept + { + if (!device) + return false; + IDirect3DStateBlock9* state = nullptr; + { + std::lock_guard lock(R15ClassicExtraMutex); + state = R15ClassicAllState; + if (state) state->AddRef(); + } + if (!state) + return false; + const HRESULT hr = state->Apply(); + state->Release(); + return SUCCEEDED(hr); + } + + bool R15RestoreClassicExtraBaseline(IDirect3DDevice9* device) noexcept + { + if (!device) + return false; + + R15ClassicExtraBaseline baseline{}; + { + std::lock_guard lock(R15ClassicExtraMutex); + if (!R15ClassicExtra.ready) + return false; + baseline = R15ClassicExtra; + } + + bool ok = true; + if (FAILED(device->SetTransform(D3DTS_WORLD, &baseline.world))) + ok = false; + if (FAILED(device->SetTransform(D3DTS_VIEW, &baseline.view))) + ok = false; + if (FAILED(device->SetTransform( + D3DTS_PROJECTION, &baseline.projection))) + ok = false; + for (DWORD stage = 0; stage < baseline.texture.size(); ++stage) + { + if (FAILED(device->SetTransform( + static_cast( + D3DTS_TEXTURE0 + stage), + &baseline.texture[stage]))) + ok = false; + } + if (FAILED(device->SetMaterial(&baseline.material))) + ok = false; + if (baseline.clipStatusValid && + FAILED(device->SetClipStatus(&baseline.clipStatus))) + ok = false; + if (baseline.paletteValid && + FAILED(device->SetCurrentTexturePalette( + baseline.currentPalette))) + ok = false; + if (FAILED(device->SetNPatchMode(baseline.nPatchMode))) + ok = false; + if (!R15ZeroClassicShaderConstants(device)) + ok = false; + + return ok; + } + + bool InstallManagedResourceCompatR15( + IDirect3DDevice9Ex* deviceEx) noexcept + { + const bool installed = + R15InstallCompatR14Hook.call(deviceEx); + if (!installed || !deviceEx) + return installed; + + auto* const device = static_cast(deviceEx); + if (!R15CaptureClassicExtraBaseline(device)) + { + R15ResetStateHealthy.store(false, std::memory_order_release); + R14AbandonCompatDevice(device); + OutRunVRD3D9ExUpgrade::ClearCompatHooks(); + { + std::lock_guard lock(R15ClassicExtraMutex); + if (R15ClassicAllState) + { + R15ClassicAllState->Release(); + R15ClassicAllState = nullptr; + } + R15ClassicExtra = {}; + } + spdlog::error( + "VR R15 EX: could not capture fresh-device full classic-state baseline; Ex promotion rolled back transactionally"); + return false; + } + + // All Ex compatibility layers are now validated. Transfer Reset + // ownership and install the stereo base hooks on this same + // CreateDevice caller thread before the device can be returned to + // game code. If the handoff fails, reject the Ex device entirely. + DisarmLegacyResetHook(); + if (!OutRunVRStereo::InstallStereoHooksSynchronously(device)) + { + R15ResetStateHealthy.store(false, std::memory_order_release); + OutRunVRD3D9ExUpgrade::SetFinalCompatOverlayReady(false); + R14AbandonCompatDevice(device); + OutRunVRD3D9ExUpgrade::ClearCompatHooks(); + { + std::lock_guard lock(R15ClassicExtraMutex); + if (R15ClassicAllState) + { + R15ClassicAllState->Release(); + R15ClassicAllState = nullptr; + } + R15ClassicExtra = {}; + } + spdlog::error( + "VR R15 EX: synchronous CreateDevice-thread stereo handoff failed; Ex promotion rejected before device exposure"); + return false; + } + + spdlog::info( + "VR R15 EX: full D3DSBT_ALL baseline + synchronous Reset/stereo handoff ACTIVE; partial-RECT and generated-mip correctness overlay ACTIVE"); + return true; + } + + HRESULT R15UploadLevel(R14ShadowEntry& entry, UINT level) noexcept + { + if (!entry.device || !entry.cpu || !entry.gpu) + return E_FAIL; + + R14InternalUploadScope internal; + IDirect3DSurface9* source = nullptr; + IDirect3DSurface9* destination = nullptr; + HRESULT hr = R14TextureGetSurfaceLevelHook.stdcall( + entry.cpu, level, &source); + if (SUCCEEDED(hr)) + { + hr = R14TextureGetSurfaceLevelHook.stdcall( + entry.gpu, level, &destination); + } + if (SUCCEEDED(hr) && source && destination) + { + const RECT* rect = entry.lockRectValid[level] + ? &entry.lockRect[level] : nullptr; + POINT point{}; + POINT* destinationPoint = nullptr; + if (rect) + { + point.x = rect->left; + point.y = rect->top; + destinationPoint = &point; + } + hr = entry.device->UpdateSurface( + source, rect, destination, destinationPoint); + } + else if (SUCCEEDED(hr)) + { + hr = E_FAIL; + } + if (source) source->Release(); + if (destination) destination->Release(); + + if (FAILED(hr)) + { + if (!entry.lockRectValid[level]) + { + // Whole-mip locks own every texel in the CPU shadow, so the + // validated R14 whole-level fallback remains coherent. + hr = R14CopyWholeLevelByLock(entry, level); + } + else + { + ++R15PartialUploadFailClosed; + if (!R15FirstPartialRejectLogged) + { + R15FirstPartialRejectLogged = true; + spdlog::error( + "VR R15 EX: partial-RECT UpdateSurface failed; whole-mip fallback suppressed to prevent unrelated texel corruption"); + } + } + } + return hr; + } + + HRESULT __stdcall TextureUnlockRectR15(IDirect3DTexture9* texture, + UINT level) + { + const R14EntryPtr entry = R14Find(texture); + if (!entry || level >= R14MaxTrackedLevels) + return R14TextureUnlockR13Hook.stdcall(texture, level); + + IDirect3DTexture9* cpuToRelease = nullptr; + HRESULT result = D3D_OK; + bool retired = false; + { + std::unique_lock lock(entry->mutex); + const std::uint32_t bit = 1u << level; + if (entry->mode == R14ShadowMode::DirectOnly || + (entry->lockedMask & bit) == 0) + { + lock.unlock(); + return R14TextureUnlockR13Hook.stdcall( + texture, level); + } + + const bool readOnly = (entry->readOnlyMask & bit) != 0; + const HRESULT unlockHr = entry->cpu->UnlockRect(level); + entry->lockedMask &= ~bit; + entry->readOnlyMask &= ~bit; + + if (FAILED(unlockHr)) + { + entry->validMask &= ~bit; + entry->retirePending = true; + result = unlockHr; + } + else if (entry->externalWriteDuringLock) + { + result = D3DERR_INVALIDCALL; + } + else if (!readOnly) + { + result = R15UploadLevel(*entry, level); + entry->lockRectValid[level] = false; + if (SUCCEEDED(result)) + { + entry->dirtyMask &= ~bit; + ++R14ShadowUploads; + } + else + { + entry->validMask &= ~bit; + ++R14ShadowUploadFailed; + entry->retirePending = true; + if (!R14FirstUploadFailureLogged.exchange(true)) + { + spdlog::error( + "VR R15 EX: exact mip CPU-shadow upload failed hr=0x{:08x}; shadow retires instead of overwriting unrelated GPU contents", + static_cast(result)); + } + } + } + + if (entry->retirePending && entry->lockedMask == 0) + { + cpuToRelease = R14DetachShadowLocked(*entry); + retired = true; + } + } + + if (cpuToRelease) cpuToRelease->Release(); + if (retired) + R14LogRetiredShadow("R15 coherency path retired at Unlock"); + return result; + } + + void R15InvalidateGeneratedMipShadows( + IDirect3DTexture9* texture) noexcept + { + const R14EntryPtr entry = R14Find(texture); + if (!entry) + return; + + bool concurrentWrite = false; + { + std::lock_guard lock(entry->mutex); + if (entry->mode == R14ShadowMode::DirectOnly) + return; + + // GenerateMipSubLevels derives lower GPU mips from GPU level 0. + // Level 0 itself remains coherent with the SYSTEMMEM shadow when + // no CPU Lock is in flight; only generated lower mips become + // unknown from the shadow's point of view. + entry->validMask &= 1u; + entry->dirtyMask &= 1u; + for (std::uint32_t mip = 1; + mip < R14MaxTrackedLevels; ++mip) + entry->lockRectValid[mip] = false; + + concurrentWrite = entry->lockedMask != 0; + if (concurrentWrite) + { + entry->externalWriteDuringLock = true; + entry->retirePending = true; + } + } + + ++R15GeneratedMipInvalidations; + if (!R15FirstMipInvalidateLogged) + { + R15FirstMipInvalidateLogged = true; + spdlog::info( + "VR R15 EX: GenerateMipSubLevels preserves coherent level-0 CPU backing and invalidates only generated lower-mip shadows"); + } + if (concurrentWrite && + !R14FirstConcurrentWriteLogged.exchange(true)) + { + spdlog::error( + "VR R15 EX: GenerateMipSubLevels overlapped a CPU-shadow Lock; shadow will retire at the matching Unlock"); + } + } + + void __stdcall TextureGenerateMipSubLevelsDestR15( + IDirect3DTexture9* texture) + { + // Bypass R14's conservative whole-shadow retirement while retaining + // its underlying device-method trampoline. + R14TextureGenerateMipSubLevelsHook.stdcall(texture); + if (R14InternalUploadDepth == 0) + R15InvalidateGeneratedMipShadows(texture); + } + + bool ResetCompatDeviceR15(IDirect3DDevice9* device, + D3DPRESENT_PARAMETERS* params, HRESULT& result) noexcept + { + R15ResetStateHealthy.store(false, std::memory_order_release); + if (IsCompatDevice(device)) + OutRunVR::RuntimeEligibility::SetExternalSafetyBlock(true); + const bool handled = R15ResetCompatR13Hook.call( + device, params, result); + if (!handled || FAILED(result)) + return handled; + + // R13 already performed ResetEx and its classic render/stage/sampler + // replay. Re-run that replay so its bool is authoritative, then + // restore state families that R13 did not cover. + const bool coreHealthy = + OutRunVRD3D9ExUpgrade::RestoreClassicResetState(device); + const bool allStateHealthy = R15RestoreClassicAllState(device); + const bool extraHealthy = + R15RestoreClassicExtraBaseline(device); + const bool healthy = coreHealthy && allStateHealthy && extraHealthy; + R15ResetStateHealthy.store(healthy, std::memory_order_release); + OutRunVR::RuntimeEligibility::SetExternalSafetyBlock(!healthy); + + if (healthy) + { + ++R15ResetStateRestores; + } + else + { + ++R15ResetStateFailures; + if (!R15FirstResetFailureLogged) + { + R15FirstResetFailureLogged = true; + spdlog::error( + "VR R15 EX: ResetEx succeeded but classic D3D9 state replay was incomplete; stereo must remain fail-closed"); + } + } + return true; + } + + void R15RollbackHooks() noexcept + { + R15ResetCompatR13Hook = {}; + R15GenerateMipR14Hook = {}; + R15TextureUnlockR14Hook = {}; + R15InstallCompatR14Hook = {}; + } + + class VRD3D9ExUpgradeR15Hook final : public Hook + { + public: + std::string_view description() override + { + return "OpenXRVRD3D9ExUpgradeR15"; + } + bool validate() override + { + return Settings::VRPreferD3D9Ex; + } + bool apply() override + { + OutRunVRD3D9ExUpgrade::SetFinalCompatOverlayReady(false); + if (!R14InstallCompatR13Hook || !R14CreateTextureR13Hook || + !R14TextureLockR13Hook || !R14TextureUnlockR13Hook) + { + spdlog::error( + "VR R15 EX: lower R14 compatibility overlay is unavailable; Ex promotion remains disabled"); + return false; + } + const auto disabled = safetyhook::InlineHook::StartDisabled; + R15InstallCompatR14Hook = safetyhook::create_inline( + reinterpret_cast(&InstallManagedResourceCompatR14), + InstallManagedResourceCompatR15, disabled); + R15TextureUnlockR14Hook = safetyhook::create_inline( + reinterpret_cast(&TextureUnlockRectR14), + TextureUnlockRectR15, disabled); + R15GenerateMipR14Hook = safetyhook::create_inline( + reinterpret_cast(&TextureGenerateMipSubLevelsDestR14), + TextureGenerateMipSubLevelsDestR15, disabled); + R15ResetCompatR13Hook = safetyhook::create_inline( + reinterpret_cast(&ResetCompatDevice), + ResetCompatDeviceR15, disabled); + + SafetyHookInline* hooks[]{ + &R15InstallCompatR14Hook, + &R15TextureUnlockR14Hook, + &R15GenerateMipR14Hook, + &R15ResetCompatR13Hook + }; + for (auto* hook : hooks) + { + if (!*hook || !hook->enable().has_value()) + { + R15RollbackHooks(); + OutRunVRD3D9ExUpgrade::SetFinalCompatOverlayReady(false); + spdlog::error( + "VR R15 EX: correctness overlay hook transaction failed; R14 remains authoritative"); + return false; + } + } + + OutRunVRD3D9ExUpgrade::SetFinalCompatOverlayReady(true); + spdlog::info( + "VR R15 EX: partial-RECT fail-close + generated-mip lifetime + ResetEx classic-state replay READY; final promotion gate ARMED"); + return true; + } + + static VRD3D9ExUpgradeR15Hook instance; + }; + + VRD3D9ExUpgradeR15Hook VRD3D9ExUpgradeR15Hook::instance; + } + + bool LastResetStateReplaySucceeded() noexcept + { + return R15ResetStateHealthy.load(std::memory_order_acquire); + } +} diff --git a/src/vr/d3d9/helper_ex_probe.cpp b/src/vr/d3d9/helper_ex_probe.cpp new file mode 100644 index 000000000..e445cc34a --- /dev/null +++ b/src/vr/d3d9/helper_ex_probe.cpp @@ -0,0 +1,300 @@ +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include + +#include +#include + +#include + +#include "hook_mgr.hpp" +#include "game_addrs.hpp" +#include "vr/ipc/protocol.hpp" + +namespace OutRunVRHelperExProbe +{ + namespace + { + using Direct3DCreate9ExFn = HRESULT (WINAPI*)(UINT, IDirect3D9Ex**); + + template + void ReleaseCom(T*& p) noexcept + { + if (p) { p->Release(); p = nullptr; } + } + + bool WaitForQuery(IDirect3DQuery9* query, DWORD timeoutMs) noexcept + { + if (!query) return false; + const ULONGLONG deadline = GetTickCount64() + timeoutMs; + for (;;) + { + const HRESULT hr = query->GetData(nullptr, 0, D3DGETDATA_FLUSH); + if (hr == S_OK) return true; + if (hr != S_FALSE || GetTickCount64() >= deadline) return false; + Sleep(0); + } + } + + bool GameDeviceIsEx(IDirect3DDevice9* device) noexcept + { + IDirect3DDevice9Ex* ex = nullptr; + const bool result = device && SUCCEEDED(device->QueryInterface( + __uuidof(IDirect3DDevice9Ex), reinterpret_cast(&ex))) && ex; + ReleaseCom(ex); + return result; + } + + struct SharedPoseView + { + HANDLE mapping = nullptr; + OutRunVR::SharedPoseState* state = nullptr; + ~SharedPoseView() + { + if (state) UnmapViewOfFile(state); + if (mapping) CloseHandle(mapping); + } + bool Open() noexcept + { + if (state) return true; + mapping = OpenFileMappingW(FILE_MAP_ALL_ACCESS, FALSE, OutRunVR::SharedMemoryName); + if (!mapping) return false; + state = static_cast(MapViewOfFile( + mapping, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(OutRunVR::SharedPoseState))); + if (!state) + { + CloseHandle(mapping); + mapping = nullptr; + return false; + } + return true; + } + bool Valid() const noexcept + { + return state && state->magic == OutRunVR::SharedMagic && + state->protocolVersion == OutRunVR::SharedProtocolVersion && + state->structSize == sizeof(OutRunVR::SharedPoseState); + } + }; + + HMODULE LoadSystemD3D9() + { + wchar_t systemDir[MAX_PATH]{}; + const UINT length = GetSystemDirectoryW(systemDir, MAX_PATH); + if (!length || length >= MAX_PATH) return nullptr; + std::filesystem::path path(systemDir); + path /= L"d3d9.dll"; + return LoadLibraryW(path.c_str()); + } + + DWORD WINAPI ProbeThread(void*) + { + IDirect3DDevice9* gameDevice = nullptr; + for (int i = 0; i < 1200; ++i) + { + gameDevice = Game::D3DDevice_ptr ? *Game::D3DDevice_ptr : nullptr; + if (gameDevice) break; + Sleep(100); + } + if (!gameDevice) + return 0; + if (GameDeviceIsEx(gameDevice)) + { + spdlog::info("VR helper D3D9Ex probe: skipped because game device already supports native D3D9Ex zero-copy transport"); + return 0; + } + + SharedPoseView shared; + for (int i = 0; i < 200; ++i) + { + if (shared.Open() && shared.Valid() && shared.state->hostPid && + (shared.state->flags & OutRunVR::HostAdapterLuidValid)) + break; + Sleep(50); + } + if (!shared.Valid() || !shared.state->hostPid || + (shared.state->flags & OutRunVR::HostAdapterLuidValid) == 0) + { + spdlog::warn("VR helper D3D9Ex probe: host adapter LUID was not available; helper bridge feasibility unknown"); + return 0; + } + + HMODULE d3d9Module = LoadSystemD3D9(); + if (!d3d9Module) + { + spdlog::warn("VR helper D3D9Ex probe: system d3d9.dll could not be loaded"); + return 0; + } + const auto create9Ex = reinterpret_cast( + GetProcAddress(d3d9Module, "Direct3DCreate9Ex")); + if (!create9Ex) + { + spdlog::warn("VR helper D3D9Ex probe: Direct3DCreate9Ex export unavailable"); + FreeLibrary(d3d9Module); + return 0; + } + + IDirect3D9Ex* d3dEx = nullptr; + if (FAILED(create9Ex(D3D_SDK_VERSION, &d3dEx)) || !d3dEx) + { + spdlog::warn("VR helper D3D9Ex probe: Direct3DCreate9Ex failed"); + FreeLibrary(d3d9Module); + return 0; + } + + const LUID wanted{ + shared.state->hostAdapterLuidLow, + static_cast(shared.state->hostAdapterLuidHigh) + }; + UINT adapter = D3DADAPTER_DEFAULT; + bool found = false; + const UINT adapterCount = d3dEx->GetAdapterCount(); + for (UINT i = 0; i < adapterCount; ++i) + { + LUID candidate{}; + if (SUCCEEDED(d3dEx->GetAdapterLUID(i, &candidate)) && + candidate.LowPart == wanted.LowPart && candidate.HighPart == wanted.HighPart) + { + adapter = i; + found = true; + break; + } + } + if (!found) + { + spdlog::warn("VR helper D3D9Ex probe: no D3D9Ex adapter matched OpenXR LUID {:08X}:{:08X}", + static_cast(wanted.HighPart), wanted.LowPart); + ReleaseCom(d3dEx); + FreeLibrary(d3d9Module); + return 0; + } + + D3DDEVICE_CREATION_PARAMETERS gameCreation{}; + gameDevice->GetCreationParameters(&gameCreation); + HWND window = gameCreation.hFocusWindow ? gameCreation.hFocusWindow : GetDesktopWindow(); + D3DPRESENT_PARAMETERS pp{}; + pp.Windowed = TRUE; + pp.SwapEffect = D3DSWAPEFFECT_DISCARD; + pp.hDeviceWindow = window; + pp.BackBufferWidth = 1; + pp.BackBufferHeight = 1; + pp.BackBufferFormat = D3DFMT_A8R8G8B8; + pp.PresentationInterval = D3DPRESENT_INTERVAL_IMMEDIATE; + + IDirect3DDevice9Ex* helper = nullptr; + HRESULT deviceHr = d3dEx->CreateDeviceEx( + adapter, D3DDEVTYPE_HAL, window, + D3DCREATE_HARDWARE_VERTEXPROCESSING | D3DCREATE_MULTITHREADED | D3DCREATE_FPU_PRESERVE, + &pp, nullptr, &helper); + if (FAILED(deviceHr) || !helper) + { + deviceHr = d3dEx->CreateDeviceEx( + adapter, D3DDEVTYPE_HAL, window, + D3DCREATE_SOFTWARE_VERTEXPROCESSING | D3DCREATE_MULTITHREADED | D3DCREATE_FPU_PRESERVE, + &pp, nullptr, &helper); + } + if (FAILED(deviceHr) || !helper) + { + spdlog::warn("VR helper D3D9Ex probe: helper CreateDeviceEx failed HRESULT=0x{:08X}", + static_cast(deviceHr)); + ReleaseCom(d3dEx); + FreeLibrary(d3d9Module); + return 0; + } + + IDirect3DTexture9* texture = nullptr; + IDirect3DSurface9* surface = nullptr; + IDirect3DQuery9* fence = nullptr; + HANDLE sharedHandle = nullptr; + HRESULT hr = helper->CreateTexture(1, 1, 1, D3DUSAGE_RENDERTARGET, + D3DFMT_A8B8G8R8, D3DPOOL_DEFAULT, &texture, &sharedHandle); + if (SUCCEEDED(hr) && texture) + hr = texture->GetSurfaceLevel(0, &surface); + if (SUCCEEDED(hr) && surface) + hr = helper->ColorFill(surface, nullptr, D3DCOLOR_ARGB(0xFF, 0x7B, 0x7B, 0x7B)); + if (SUCCEEDED(hr)) + hr = helper->CreateQuery(D3DQUERYTYPE_EVENT, &fence); + if (SUCCEEDED(hr) && fence) + hr = fence->Issue(D3DISSUE_END); + if (FAILED(hr) || !sharedHandle || !WaitForQuery(fence, 100)) + { + spdlog::warn("VR helper D3D9Ex probe: shared verification texture creation/upload failed HRESULT=0x{:08X}", + static_cast(hr)); + ReleaseCom(fence); ReleaseCom(surface); ReleaseCom(texture); + ReleaseCom(helper); ReleaseCom(d3dEx); FreeLibrary(d3d9Module); + return 0; + } + + const std::uint32_t token = + (static_cast(GetTickCount()) ^ GetCurrentProcessId() ^ 0x48585042u) | 1u; + InterlockedExchange(reinterpret_cast(&shared.state->clientAdapterLuidLow), + static_cast(wanted.LowPart)); + InterlockedExchange(reinterpret_cast(&shared.state->clientAdapterLuidHigh), + static_cast(wanted.HighPart)); + InterlockedExchange(reinterpret_cast(&shared.state->hostInteropProbeAckToken), 0); + InterlockedExchange(reinterpret_cast(&shared.state->clientInteropProbeHandle), + static_cast(reinterpret_cast(sharedHandle))); + InterlockedExchange(reinterpret_cast(&shared.state->clientInteropProbeToken), + static_cast(token)); + + bool verified = false; + for (int i = 0; i < 200; ++i) + { + if (shared.state->hostInteropProbeAckToken == token) + { + verified = true; + break; + } + Sleep(10); + } + + if (verified) + { + spdlog::info( + "VR helper D3D9Ex probe: VERIFIED helper D3D9Ex -> D3D11 sharing on OpenXR adapter; FEAR-style CPU exact-eye bridge is supported on this system"); + } + else + { + spdlog::warn( + "VR helper D3D9Ex probe: host did not acknowledge helper shared texture within 2s; SBS/Desktop Duplication remains the safe fallback"); + } + + // Keep the verified handle fields only long enough for the host to inspect them. + // A plain-D3D9 game cannot use this texture as its direct frame source yet. + InterlockedCompareExchange(reinterpret_cast(&shared.state->clientInteropProbeHandle), + 0, static_cast(reinterpret_cast(sharedHandle))); + InterlockedCompareExchange(reinterpret_cast(&shared.state->clientInteropProbeToken), + 0, static_cast(token)); + + ReleaseCom(fence); + ReleaseCom(surface); + ReleaseCom(texture); + ReleaseCom(helper); + ReleaseCom(d3dEx); + FreeLibrary(d3d9Module); + return 0; + } + } + + class HelperExProbeHook final : public Hook + { + public: + std::string_view description() override { return "OpenXRVRHelperD3D9ExProbe"; } + bool validate() override { return true; } + bool apply() override + { + HANDLE thread = CreateThread(nullptr, 0, ProbeThread, nullptr, 0, nullptr); + if (!thread) + { + spdlog::warn("VR helper D3D9Ex probe: failed to start probe thread: {}", GetLastError()); + return false; + } + CloseHandle(thread); + return true; + } + static HelperExProbeHook instance; + }; + + HelperExProbeHook HelperExProbeHook::instance; +} diff --git a/src/vr/d3d9/r13_bridge.hpp b/src/vr/d3d9/r13_bridge.hpp new file mode 100644 index 000000000..c31f773e8 --- /dev/null +++ b/src/vr/d3d9/r13_bridge.hpp @@ -0,0 +1,78 @@ +#pragma once + +// This header is included before the legacy renderer implementation by the R13 +// wrapper translation units. Define the lean Windows contract here, before the +// first Windows.h include, so Win32 multimedia/min/max macros cannot rewrite +// OutRun's SOUND_CMD names or std::min/std::max expressions in the included +// implementation. +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif + +#include +#include +#include + +#include "vr/ipc/direct_ack_r13.hpp" +#include "vr/d3d9/vr_pass_policy.hpp" + +namespace OutRunVRD3D9ExUpgradeR13 +{ + // Reset ownership contract (second review pass): once a game device is + // promoted to D3D9Ex, every stereo Reset path must retain a direct ResetEx + // fallback. R13 may replace the later callback policy, but a failed/partial + // R13 overlay is never allowed to strand the promoted device with neither + // a legacy Reset shim nor a ResetEx-capable stereo callback. + bool IsCompatDevice(IDirect3DDevice9* device) noexcept; + bool ResetCompatDevice(IDirect3DDevice9* device, + D3DPRESENT_PARAMETERS* params, HRESULT& result) noexcept; + HRESULT NormalizeLegacyPresentResult( + IDirect3DDevice9* device, HRESULT result) noexcept; + void DisarmLegacyResetHook() noexcept; +} + +namespace OutRunVRStereo +{ + // Installer ownership contract: R7 publishes its completed device/hooks via + // release/acquire atomics, R9 publishes its callback-policy state the same + // way, and R13 consumes only those states. R13 also publishes an explicit + // Pending/Ready/Failed transaction state for diagnostics. SafetyHookInline + // objects are not used as cross-thread readiness flags. + // + // The renderer-side c64/WVP hook follows the same policy: the base renderer + // publishes RendererInstallState, while R13 consumes that atomic state and + // disables pose injection through an atomic policy flag if its offscreen + // classification guard cannot be installed. It never clears another + // thread's SafetyHookInline as a readiness/failure signal. + // + // Occlusion-query tracking is a correctness boundary. If IDirect3DQuery9:: + // Issue cannot be observed, stereo duplication fails closed to one execution + // instead of risking duplicated query/MRT side effects. + // + // Render-pass classification is centralized in vr_pass_policy.hpp. Both the + // stereo draw replay layer and renderer-pose layer consume this policy so an + // auxiliary/MRT pass cannot be treated as world stereo by one layer and as + // stock/offscreen by the other. + + struct PoseInjectionSnapshot + { + OutRunVR::PassPolicy::PoseInjectionPolicy policy = + OutRunVR::PassPolicy::PoseInjectionPolicy::AuxiliaryStock; + bool legacyMainBackbufferInvariant = false; + }; + + // Read the mutable D3D9 pass signals once, then derive both the centralized + // policy and the legacy compatibility invariant from that same snapshot. + // This prevents a render-state transition between two separate reads from + // being misdiagnosed as a classifier disagreement. + PoseInjectionSnapshot CurrentPoseInjectionSnapshot() noexcept; + + OutRunVR::PassPolicy::PoseInjectionPolicy CurrentPoseInjectionPolicy() noexcept; + + // Compatibility helper used by the validated base renderer. New R13 code + // that compares both classifications should prefer CurrentPoseInjectionSnapshot(). + bool IsMainBackbufferPoseInjectionPass() noexcept; +} diff --git a/src/vr/d3d9/r32_policy.hpp b/src/vr/d3d9/r32_policy.hpp new file mode 100644 index 000000000..bcaae2625 --- /dev/null +++ b/src/vr/d3d9/r32_policy.hpp @@ -0,0 +1,58 @@ +#pragma once + +#include +#include + +namespace OutRunVR::R32 +{ + inline constexpr std::uint64_t ResetMonoSafetyPresents = 2; + inline constexpr std::uint64_t ProducerFenceBudgetMs = 2; + + constexpr std::uint64_t RearmMonoSafetyEpoch( + std::uint64_t presentEpoch, + std::uint64_t extraPresents = ResetMonoSafetyPresents) noexcept + { + if (presentEpoch == 0) + presentEpoch = 1; + const auto maxValue = (std::numeric_limits::max)(); + return extraPresents > maxValue - presentEpoch + ? maxValue + : presentEpoch + extraPresents; + } + + enum class EffectSnapshotDecision : std::uint8_t + { + UseCapturedPolicy, + ForceZeroDisparity + }; + + constexpr EffectSnapshotDecision EffectSnapshotResult( + bool allReadsSucceeded) noexcept + { + return allReadsSucceeded + ? EffectSnapshotDecision::UseCapturedPolicy + : EffectSnapshotDecision::ForceZeroDisparity; + } + + enum class PendingFenceDecision : std::uint8_t + { + ReuseSlot, + BlockReuse, + QueryError + }; + + constexpr PendingFenceDecision ClassifyPendingFence( + bool pending, bool queryExists, bool queryComplete, + bool queryStillPending) noexcept + { + if (!pending) + return PendingFenceDecision::ReuseSlot; + if (!queryExists) + return PendingFenceDecision::QueryError; + if (queryComplete) + return PendingFenceDecision::ReuseSlot; + return queryStillPending + ? PendingFenceDecision::BlockReuse + : PendingFenceDecision::QueryError; + } +} diff --git a/src/vr/d3d9/shader_fingerprint_gpl.hpp b/src/vr/d3d9/shader_fingerprint_gpl.hpp new file mode 100644 index 000000000..35d59f7af --- /dev/null +++ b/src/vr/d3d9/shader_fingerprint_gpl.hpp @@ -0,0 +1,217 @@ +/* + * OutRun2006Tweaks VR - D3D9 shader bytecode fingerprint helper + * + * The FNV-1 64-bit hash routine and the "hash shader bytecode, not COM pointer" + * approach are adapted from 3Dmigoto (bo3b/3Dmigoto, util.h and + * DirectX9/Direct3DDevice9Functions.h). + * + * 3Dmigoto source is released under GNU GPL version 3. + * This file is therefore distributed under GPL-3.0-only. + * + * Adaptation for OutRun VR: 2026 thp32tt contributors. + * SPDX-License-Identifier: GPL-3.0-only + */ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace OutRunVR::GplShaderFingerprint +{ + constexpr std::uint64_t Fnv64Prime = 0x100000001b3ULL; + + // 3Dmigoto-compatible FNV-1 64-bit byte-buffer hash. + // Intentionally starts at zero to preserve 3Dmigoto's shader fingerprint + // convention rather than the more common FNV offset-basis variant. + inline std::uint64_t Fnv1_64(const void* buffer, std::size_t length) noexcept + { + std::uint64_t hash = 0; + const auto* current = static_cast(buffer); + const auto* end = current + length; + while (current < end) + { + hash *= Fnv64Prime; + hash ^= static_cast(*current++); + } + return hash; + } + + struct ShaderHash + { + std::uint64_t value = 0; + std::uint32_t bytecodeBytes = 0; + bool valid = false; + }; + + struct ShaderPair + { + ShaderHash vertex{}; + ShaderHash pixel{}; + std::uintptr_t vertexIdentity = 0; + std::uintptr_t pixelIdentity = 0; + bool valid = false; + }; + + inline bool TraceEnabled() noexcept + { + // Diagnostics are opt-in because querying the active D3D9 shader on + // every classified draw adds COM traffic. Set + // OUTRUN_VR_SHADER_FINGERPRINT=1 for a diagnostic run. + static const bool enabled = []() noexcept { + char value[16]{}; + std::size_t required = 0; +#if defined(_MSC_VER) + if (getenv_s(&required, value, sizeof(value), + "OUTRUN_VR_SHADER_FINGERPRINT") != 0 || required == 0) + return false; +#else + const char* env = std::getenv("OUTRUN_VR_SHADER_FINGERPRINT"); + if (!env) + return false; + std::snprintf(value, sizeof(value), "%s", env); +#endif + return value[0] == '1' || value[0] == 'y' || value[0] == 'Y' || + value[0] == 't' || value[0] == 'T'; + }(); + return enabled; + } + + template + inline ShaderHash HashBytecode(ShaderT* shader) noexcept + { + ShaderHash result{}; + if (!shader) + return result; + + UINT size = 0; + if (FAILED(shader->GetFunction(nullptr, &size)) || size == 0 || + size > (4u * 1024u * 1024u)) + return result; + + try + { + std::vector bytecode(size); + UINT actual = size; + if (FAILED(shader->GetFunction(bytecode.data(), &actual)) || + actual == 0 || actual > bytecode.size()) + return result; + + result.value = Fnv1_64(bytecode.data(), actual); + result.bytecodeBytes = actual; + result.valid = true; + return result; + } + catch (...) + { + return result; + } + } + + template + inline ShaderHash HashBytecodeCached( + ShaderT* shader, + std::array, Capacity>& cache, + std::size_t& nextSlot) noexcept + { + if (!shader) + return {}; + + const auto identity = reinterpret_cast(shader); + for (const auto& entry : cache) + if (entry.first == identity) + return entry.second; + + const ShaderHash hash = HashBytecode(shader); + if (hash.valid) + { + cache[nextSlot] = { identity, hash }; + nextSlot = (nextSlot + 1) % Capacity; + } + return hash; + } + + inline ShaderPair CaptureCurrent(IDirect3DDevice9* device) noexcept + { + ShaderPair result{}; + if (!device) + return result; + + IDirect3DVertexShader9* vertex = nullptr; + IDirect3DPixelShader9* pixel = nullptr; + const HRESULT vsHr = device->GetVertexShader(&vertex); + const HRESULT psHr = device->GetPixelShader(&pixel); + if (FAILED(vsHr) || FAILED(psHr)) + { + if (vertex) vertex->Release(); + if (pixel) pixel->Release(); + return result; + } + + result.vertexIdentity = reinterpret_cast(vertex); + result.pixelIdentity = reinterpret_cast(pixel); + + // D3D9 rendering for OutRun is effectively render-thread owned here. + // Keep the cache protected anyway so diagnostics remain safe if a + // wrapper/runtime changes call threading later. + static std::mutex cacheMutex; + static std::array, 256> vsCache{}; + static std::array, 256> psCache{}; + static std::size_t vsNext = 0; + static std::size_t psNext = 0; + { + std::lock_guard lock(cacheMutex); + result.vertex = HashBytecodeCached(vertex, vsCache, vsNext); + result.pixel = HashBytecodeCached(pixel, psCache, psNext); + } + + if (vertex) vertex->Release(); + if (pixel) pixel->Release(); + + // Fixed-function paths legitimately have a null vertex/pixel shader. + result.valid = result.vertex.valid || result.pixel.valid || + (result.vertexIdentity == 0 && result.pixelIdentity == 0); + return result; + } + + inline bool RememberPair(const ShaderPair& pair) noexcept + { + if (!pair.valid) + return false; + + struct Seen + { + std::uint64_t vs = 0; + std::uint64_t ps = 0; + }; + + static std::mutex seenMutex; + static std::array seen{}; + static std::size_t seenCount = 0; + static std::size_t nextSeen = 0; + std::lock_guard lock(seenMutex); + + // Bytecode fingerprints are deliberately the identity here. COM object + // addresses can change after Reset/recreation or between launches. + for (std::size_t i = 0; i < seenCount; ++i) + { + if (seen[i].vs == pair.vertex.value && + seen[i].ps == pair.pixel.value) + return false; + } + + const Seen item{ pair.vertex.value, pair.pixel.value }; + seen[nextSeen] = item; + nextSeen = (nextSeen + 1) % seen.size(); + if (seenCount < seen.size()) + ++seenCount; + return true; + } +} diff --git a/src/vr/d3d9/stereo_backend.hpp b/src/vr/d3d9/stereo_backend.hpp new file mode 100644 index 000000000..24933b53e --- /dev/null +++ b/src/vr/d3d9/stereo_backend.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include "vr/core/frame_types.hpp" +#include "vr/core/transport.hpp" +#include "vr/game/game_adapter.hpp" + +namespace OutRunVR::D3D9 +{ + enum class DrawClass + { + Ignore, + World, + ScreenSpace, + Unsafe, + }; + + struct FrameContext + { + Core::RenderPose pose{}; + Game::StereoMatrices matrices{}; + Core::PresentedFrame result{}; + }; + + class IStereoBackend + { + public: + virtual ~IStereoBackend() = default; + virtual bool beginFrame(FrameContext& frame) noexcept = 0; + virtual DrawClass classifyCurrentDraw() const noexcept = 0; + virtual bool drawWorldStereo(FrameContext& frame) noexcept = 0; + virtual bool drawScreenSpaceStereo(FrameContext& frame) noexcept = 0; + virtual bool finishFrame(FrameContext& frame) noexcept = 0; + virtual void invalidate() noexcept = 0; + }; +} diff --git a/src/vr/d3d9/stereo_renderer.cpp b/src/vr/d3d9/stereo_renderer.cpp new file mode 100644 index 000000000..e8e840a79 --- /dev/null +++ b/src/vr/d3d9/stereo_renderer.cpp @@ -0,0 +1,945 @@ +// R9 final hardware-test policy layered over the verified R7 renderer. +// +// R8 proved that replacing only the COM vtable slot is not sufficient: the +// original SafetyHook detour can still be reached through already-cached entry +// points, leaving the R7 DepthStateChanged poison path alive. R9 therefore +// hooks the *detour callbacks themselves*. Every call that reaches the original +// D3D9 inline hook must pass through this policy, regardless of which COM +// interface/vtable entry the game used. +// +// R9 also fixes the unsafe "mono fallback" semantics. While a stereo frame is +// being built, every main-backbuffer draw is replayed once into an independent +// MONO shadow render target using the game's untouched WVP and an independent +// mono depth surface. If the stereo frame later fails, subsequent VR +// transforms stop immediately and the complete mono shadow is copied back to +// the game backbuffer before Present. A poisoned frame therefore cannot leave +// a partially transformed left-eye image on the PC monitor. +// +// The stereo path is not allowed to start from a clear-only bootstrap after +// geometry may already have been missed. Each Present must observe a verified +// full main-backbuffer color clear before R9 allows eye transforms. From that +// seed onward the mono shadow and the R7 eye paths see the same main draws. +// +// Architecture-verifier comparison markers retained here because the live R7 +// implementation is included below: +// RenderFrameRingSize IDirect3DDevice9Ex GetAdapterLUID ResolveDirectTransport +// StereoFailurePoseSequenceMismatch + +#include +#include +#include +#include "stereo_renderer_r7.inc" + +namespace OutRunVRStereo +{ + namespace + { + constexpr const char* R9BuildId = "R9-finaltest-20260915"; + + bool R9ReadBoolEnvironment(const char* name, bool defaultValue) noexcept + { + const char* value = std::getenv(name); + if (!value || !*value) + return defaultValue; + if (std::strcmp(value, "0") == 0 || + _stricmp(value, "false") == 0 || + _stricmp(value, "off") == 0 || + _stricmp(value, "no") == 0) + return false; + if (std::strcmp(value, "1") == 0 || + _stricmp(value, "true") == 0 || + _stricmp(value, "on") == 0 || + _stricmp(value, "yes") == 0) + return true; + return defaultValue; + } + + bool R9DirectOnlyTransport() noexcept + { + // Game-side policy must follow the parsed setting directly. The host + // inherits OUTRUN_VR_DIRECT_ONLY through CreateProcess, but the MSVC + // CRT getenv cache does not reliably observe SetEnvironmentVariableA + // changes made after process startup. Reading getenv here therefore + // left SAFE modes stuck in the default direct-only path even when the + // log showed DirectGpuOnly=false. + return Settings::VRDirectGpuOnly.get(); + } + + bool R9FirstDirectOnlyWaitLogged = false; + + SafetyHookInline R9ResetCallbackHook{}; + SafetyHookInline R9PresentCallbackHook{}; + SafetyHookInline R9SetRenderTargetCallbackHook{}; + SafetyHookInline R9SetDepthCallbackHook{}; + SafetyHookInline R9ClearCallbackHook{}; + SafetyHookInline R9DrawPrimitiveCallbackHook{}; + SafetyHookInline R9DrawIndexedPrimitiveCallbackHook{}; + SafetyHookInline R9DrawPrimitiveUPCallbackHook{}; + SafetyHookInline R9DrawIndexedPrimitiveUPCallbackHook{}; + + constexpr std::uint32_t R9InstallPending = 0; + constexpr std::uint32_t R9InstallReady = 1; + constexpr std::uint32_t R9InstallFailed = 2; + std::atomic R9InstallState{R9InstallPending}; + + IDirect3DSurface9* R9MainDepthIdentity = nullptr; + IDirect3DSurface9* R9DeferredDepthIdentity = nullptr; + IDirect3DSurface9* R9MonoSurface = nullptr; + IDirect3DSurface9* R9MonoDepth = nullptr; + D3DSURFACE_DESC R9MainDepthDesc{}; + bool R9MainDepthKnown = false; + bool R9DeferredDepth = false; + bool R9StereoSeeded = false; + bool R9MonoSeeded = false; + bool R9MonoBackupGap = false; + bool R9ExpectMainDepthAfterReset = false; + std::uint64_t R9MainDepthGeneration = 0; + std::uint64_t R9MonoResourceGeneration = 0; + std::uint64_t R9MainDepthContentSerial = 0; + std::uint64_t R9MonoDepthContentSerial = 0; + + std::uint64_t R9DepthCalls = 0; + std::uint64_t R9RtCalls = 0; + std::uint64_t R9ClearCalls = 0; + std::uint64_t R9DrawCalls = 0; + std::uint64_t R9PresentCalls = 0; + std::uint64_t R9TransientDepthBinds = 0; + std::uint64_t R9MainDepthRestores = 0; + ULONGLONG R9LastMainDepthRestoreLogMs = 0; + std::uint64_t R9NullDepthBinds = 0; + std::uint64_t R9MainDepthReacquires = 0; + std::uint64_t R9FullClearSeeds = 0; + std::uint64_t R9MonoBackupDraws = 0; + std::uint64_t R9MonoBackupDrawFailures = 0; + std::uint64_t R9MonoFallbackRestores = 0; + std::uint64_t R9MonoFallbackRestoreFailures = 0; + std::uint64_t R9MonoOnlyDraws = 0; + std::uint64_t R9OcclusionBackupSkips = 0; + std::uint64_t R9FirstFailureCount = 0; + ULONGLONG R9LastSummaryMs = 0; + std::uint64_t R9FirstFailureEpoch = 0; + + const char* R9FailureName(OutRunVR::StereoFailureReason reason) + { + switch (reason) + { + case OutRunVR::StereoFailureNone: return "none"; + case OutRunVR::StereoFailureMissingLatchedPose: return "missingPose"; + case OutRunVR::StereoFailureResourceUnavailable: return "resource"; + case OutRunVR::StereoFailureMrtActive: return "mrt"; + case OutRunVR::StereoFailureViewportUnavailable: return "viewport"; + case OutRunVR::StereoFailureLeftWvpUploadFailed: return "leftWvp"; + case OutRunVR::StereoFailureLeftDrawFailed: return "leftDraw"; + case OutRunVR::StereoFailureRightStateFailed: return "rightState"; + case OutRunVR::StereoFailureRightWvpUploadFailed: return "rightWvp"; + case OutRunVR::StereoFailureRightDrawFailed: return "rightDraw"; + case OutRunVR::StereoFailureRestoreFailed: return "restore"; + case OutRunVR::StereoFailureComposeFailed: return "compose"; + case OutRunVR::StereoFailurePresentFailed: return "present"; + case OutRunVR::StereoFailureDepthStateChanged: return "depthState"; + case OutRunVR::StereoFailurePoseSequenceMismatch: return "poseMismatch"; + case OutRunVR::StereoFailureDepthUnsynchronized: return "depthUnsync"; + case OutRunVR::StereoFailureClearFailed: return "clear"; + case OutRunVR::StereoFailureWorldClassificationFailed: return "classify"; + case OutRunVR::StereoFailureStencilUnsynchronized: return "stencilUnsync"; + case OutRunVR::StereoFailureOffscreenWorld: return "offscreen"; + case OutRunVR::StereoFailureOcclusionQueryActive: return "occlusion"; + default: return "unknown"; + } + } + + bool R9GetDesc(IDirect3DSurface9* surface, D3DSURFACE_DESC& desc) + { + desc = {}; + return surface && SUCCEEDED(surface->GetDesc(&desc)); + } + + void R9LogSurface(const char* label, IDirect3DSurface9* surface) + { + D3DSURFACE_DESC d{}; + if (R9GetDesc(surface, d)) + { + spdlog::info("VR R9 diag: {} ptr=0x{:08x} {}x{} fmt={} msaa={} q={}", + label, static_cast(reinterpret_cast(surface)), + d.Width, d.Height, static_cast(d.Format), + static_cast(d.MultiSampleType), d.MultiSampleQuality); + } + else + { + spdlog::info("VR R9 diag: {} ptr=0x{:08x} ", + label, static_cast(reinterpret_cast(surface))); + } + } + + void R9ReleaseMonoResources() + { + ReleaseCom(R9MonoDepth); + ReleaseCom(R9MonoSurface); + R9MonoResourceGeneration = 0; + R9MonoSeeded = false; + R9MonoBackupGap = false; + } + + void R9ReleaseDepthIdentity() + { + ReleaseCom(R9DeferredDepthIdentity); + ReleaseCom(R9MainDepthIdentity); + R9MainDepthDesc = {}; + R9MainDepthKnown = false; + R9DeferredDepth = false; + } + + bool R9CaptureMainDepth(IDirect3DSurface9* surface, const char* why) + { + if (!surface) + return false; + D3DSURFACE_DESC desc{}; + if (!R9GetDesc(surface, desc)) + return false; + if (BackBufferDesc.Width && BackBufferDesc.Height && + (desc.Width < BackBufferDesc.Width || desc.Height < BackBufferDesc.Height)) + return false; + + ReplaceSurfaceRef(R9MainDepthIdentity, surface); + R9MainDepthDesc = desc; + R9MainDepthKnown = true; + R9DeferredDepth = false; + ReleaseCom(R9DeferredDepthIdentity); + if (++R9MainDepthGeneration == 0) + ++R9MainDepthGeneration; + R9ReleaseMonoResources(); + ++R9MainDepthReacquires; + spdlog::info( + "VR R9: captured MAIN depth identity reason={} ptr=0x{:08x} size={}x{} fmt={} msaa={} q={} generation={}", + why, static_cast(reinterpret_cast(surface)), + desc.Width, desc.Height, static_cast(desc.Format), + static_cast(desc.MultiSampleType), desc.MultiSampleQuality, + R9MainDepthGeneration); + return true; + } + + bool R9EnsureMonoResources(IDirect3DDevice9* device) + { + if (!device || !EnsureStereoResources(device) || !BackBufferDesc.Width || !BackBufferDesc.Height) + return false; + if (R9MonoSurface && R9MonoResourceGeneration == R9MainDepthGeneration) + return true; + + R9ReleaseMonoResources(); + if (FAILED(device->CreateRenderTarget( + BackBufferDesc.Width, BackBufferDesc.Height, BackBufferDesc.Format, + BackBufferDesc.MultiSampleType, BackBufferDesc.MultiSampleQuality, + FALSE, &R9MonoSurface, nullptr)) || !R9MonoSurface) + return false; + + if (R9MainDepthKnown && R9MainDepthIdentity) + { + const auto& d = R9MainDepthDesc; + if (FAILED(device->CreateDepthStencilSurface( + d.Width, d.Height, d.Format, d.MultiSampleType, d.MultiSampleQuality, + FALSE, &R9MonoDepth, nullptr))) + { + R9ReleaseMonoResources(); + return false; + } + } + + R9MonoResourceGeneration = R9MainDepthGeneration; + spdlog::info("VR R9: mono safety shadow ready {}x{} mainDepth=0x{:08x}", + BackBufferDesc.Width, BackBufferDesc.Height, + static_cast(reinterpret_cast(R9MainDepthIdentity))); + return true; + } + + bool R9CurrentDepthCanMirror() noexcept + { + if (!TrackedDepthStencil) + return true; + return R9MainDepthKnown && TrackedDepthStencil == R9MainDepthIdentity && !R9DeferredDepth; + } + + void R9LogFirstFailure(const char* site, HRESULT hr) + { + if (FrameFailureReason == OutRunVR::StereoFailureNone || R9FirstFailureEpoch == PresentEpoch) + return; + R9FirstFailureEpoch = PresentEpoch; + ++R9FirstFailureCount; + D3DSURFACE_DESC rt{}, ds{}; + const bool rtOk = R9GetDesc(TrackedRenderTarget, rt); + const bool dsOk = R9GetDesc(TrackedDepthStencil, ds); + spdlog::error( + "VR R9 FIRST FAILURE: epoch={} reason={}({}) site={} hr=0x{:08x} RT=0x{:08x}[{}x{} fmt={} msaa={}] DS=0x{:08x}[{}x{} fmt={} msaa={}] mainDS=0x{:08x} deferred={} seeded={} monoSeeded={} draws[L/R]={}/{} depthHistory[main={},mono={}]", + PresentEpoch, static_cast(FrameFailureReason), R9FailureName(FrameFailureReason), + site, static_cast(hr), + static_cast(reinterpret_cast(TrackedRenderTarget)), + rtOk ? rt.Width : 0u, rtOk ? rt.Height : 0u, rtOk ? static_cast(rt.Format) : -1, + rtOk ? static_cast(rt.MultiSampleType) : -1, + static_cast(reinterpret_cast(TrackedDepthStencil)), + dsOk ? ds.Width : 0u, dsOk ? ds.Height : 0u, dsOk ? static_cast(ds.Format) : -1, + dsOk ? static_cast(ds.MultiSampleType) : -1, + static_cast(reinterpret_cast(R9MainDepthIdentity)), + R9DeferredDepth ? 1 : 0, R9StereoSeeded ? 1 : 0, R9MonoSeeded ? 1 : 0, + DuplicatedDraws, DuplicatedDraws, + R9MainDepthContentSerial, R9MonoDepthContentSerial); + } + + void R9Poison(OutRunVR::StereoFailureReason reason, const char* site, HRESULT hr = E_FAIL) + { + const auto before = FrameFailureReason; + PoisonFrame(reason); + if (before == OutRunVR::StereoFailureNone) + R9LogFirstFailure(site, hr); + } + + void R9ObserveLegacyFailure(OutRunVR::StereoFailureReason before, const char* site, HRESULT hr) + { + if (before == OutRunVR::StereoFailureNone && FrameFailureReason != OutRunVR::StereoFailureNone) + R9LogFirstFailure(site, hr); + } + + bool R9BindMonoTarget(IDirect3DDevice9* device, IDirect3DSurface9*& savedRt, + IDirect3DSurface9*& savedDepth, D3DVIEWPORT9& savedViewport) + { + if (!R9EnsureMonoResources(device) || !R9CurrentDepthCanMirror()) + return false; + savedRt = TrackedRenderTarget; + savedDepth = TrackedDepthStencil; + if (FAILED(device->GetViewport(&savedViewport))) + return false; + InternalPassScope guard; + if (FAILED(SetRenderTargetHook.stdcall(device, 0u, R9MonoSurface))) + return false; + IDirect3DSurface9* monoDepth = TrackedDepthStencil ? R9MonoDepth : nullptr; + if (FAILED(SetDepthStencilSurfaceHook.stdcall(device, monoDepth))) + { + SetRenderTargetHook.stdcall(device, 0u, savedRt); + return false; + } + if (FAILED(device->SetViewport(&savedViewport))) + { + SetRenderTargetHook.stdcall(device, 0u, savedRt); + SetDepthStencilSurfaceHook.stdcall(device, savedDepth); + return false; + } + return true; + } + + bool R9RestoreGameTarget(IDirect3DDevice9* device, IDirect3DSurface9* savedRt, + IDirect3DSurface9* savedDepth, const D3DVIEWPORT9& savedViewport) + { + InternalPassScope guard; + bool ok = true; + if (savedRt && FAILED(SetRenderTargetHook.stdcall(device, 0u, savedRt))) ok = false; + if (FAILED(SetDepthStencilSurfaceHook.stdcall(device, savedDepth))) ok = false; + if (FAILED(device->SetViewport(&savedViewport))) ok = false; + return ok; + } + + template + bool R9ReplayMonoDraw(IDirect3DDevice9* device, DrawCall&& actualDraw, HRESULT& drawHr) + { + if (!R9MonoSeeded || !R9CurrentDepthCanMirror()) + return false; + if (ActiveOcclusionQueries.load(std::memory_order_acquire) > 0) + { + ++R9OcclusionBackupSkips; + return true; // query-only pass is intentionally not replayed + } + IDirect3DSurface9* savedRt = nullptr; + IDirect3DSurface9* savedDepth = nullptr; + D3DVIEWPORT9 savedViewport{}; + if (!R9BindMonoTarget(device, savedRt, savedDepth, savedViewport)) + return false; + const bool mayWriteDepth = LeftDrawMayWriteDepth(device); + const bool mayWriteStencil = LeftDrawMayWriteStencil(device); + drawHr = actualDraw(); + const bool restoreOk = R9RestoreGameTarget(device, savedRt, savedDepth, savedViewport); + if (FAILED(drawHr) || !restoreOk) + return false; + ++R9MonoBackupDraws; + if (mayWriteDepth || mayWriteStencil) + ++R9MonoDepthContentSerial; + return true; + } + + bool R9ReplayMonoClear(IDirect3DDevice9* device, DWORD count, const D3DRECT* rects, + DWORD flags, D3DCOLOR color, float z, DWORD stencil, HRESULT& clearHr) + { + IDirect3DSurface9* savedRt = nullptr; + IDirect3DSurface9* savedDepth = nullptr; + D3DVIEWPORT9 savedViewport{}; + if (!R9BindMonoTarget(device, savedRt, savedDepth, savedViewport)) + return false; + clearHr = ClearHook.stdcall(device, count, rects, flags, color, z, stencil); + const bool restoreOk = R9RestoreGameTarget(device, savedRt, savedDepth, savedViewport); + if (FAILED(clearHr) || !restoreOk) + return false; + if ((flags & (D3DCLEAR_ZBUFFER | D3DCLEAR_STENCIL)) != 0 && + ClearCoversStereoBackbuffer(device, count, rects)) + ++R9MonoDepthContentSerial; + return true; + } + + bool R9RestoreMonoBeforePresent(IDirect3DDevice9* device, const char* why) + { + if (!device || !R9MonoSeeded || R9MonoBackupGap || !R9MonoSurface || !BackBuffer) + return false; + InternalPassScope guard; + const HRESULT hr = device->StretchRect(R9MonoSurface, nullptr, BackBuffer, nullptr, D3DTEXF_NONE); + if (FAILED(hr)) + { + ++R9MonoFallbackRestoreFailures; + spdlog::error("VR R9: mono fallback restore FAILED reason={} hr=0x{:08x}", + why, static_cast(hr)); + return false; + } + ++R9MonoFallbackRestores; + return true; + } + + void R9MaybeLogSummary() + { + if (!Settings::VRTelemetry) + return; + const ULONGLONG now = GetTickCount64(); + if (now - R9LastSummaryMs < 5000) + return; + R9LastSummaryMs = now; + spdlog::info( + "VR R9: calls[depth={},rt={},clear={},draw={},present={}] depth[main=0x{:08x},current=0x{:08x},deferred={},transient={},restores={},null={},reacquire={}] frame[seeded={},monoSeeded={},gap={},firstFailures={}] mono[draws={},drawFail={},restores={},restoreFail={},monoOnly={},occSkip={}] depthHistory[main={},mono={}] failures[leftWvp={},leftDraw={},depthState={},compose={}]", + R9DepthCalls, R9RtCalls, R9ClearCalls, R9DrawCalls, R9PresentCalls, + static_cast(reinterpret_cast(R9MainDepthIdentity)), + static_cast(reinterpret_cast(TrackedDepthStencil)), + R9DeferredDepth ? 1 : 0, R9TransientDepthBinds, R9MainDepthRestores, + R9NullDepthBinds, R9MainDepthReacquires, + R9StereoSeeded ? 1 : 0, R9MonoSeeded ? 1 : 0, R9MonoBackupGap ? 1 : 0, + R9FirstFailureCount, + R9MonoBackupDraws, R9MonoBackupDrawFailures, R9MonoFallbackRestores, + R9MonoFallbackRestoreFailures, R9MonoOnlyDraws, R9OcclusionBackupSkips, + R9MainDepthContentSerial, R9MonoDepthContentSerial, + FailureCounts[OutRunVR::StereoFailureLeftWvpUploadFailed], + FailureCounts[OutRunVR::StereoFailureLeftDrawFailed], + FailureCounts[OutRunVR::StereoFailureDepthStateChanged], + FailureCounts[OutRunVR::StereoFailureComposeFailed]); + } + + HRESULT __stdcall SetDepthStencilSurfaceDestR9(IDirect3DDevice9* device, IDirect3DSurface9* surface) + { + if (!IsGameDevice(device) || InternalStereoPass) + return SetDepthStencilSurfaceHook.stdcall(device, surface); + ++R9DepthCalls; + const HRESULT hr = SetDepthStencilSurfaceHook.stdcall(device, surface); + if (FAILED(hr)) + return hr; + + const bool changed = TrackedDepthStencil != surface; + ReplaceSurfaceRef(TrackedDepthStencil, surface); + if (!changed) + return hr; + + if (!surface) + { + ++R9NullDepthBinds; + // Null depth is a legitimate main-backbuffer state (typically UI). + // The draw wrappers temporarily make the right eye null as well. + return hr; + } + + if ((!R9MainDepthKnown || R9ExpectMainDepthAfterReset) && TargetIsBackBuffer()) + { + if (R9CaptureMainDepth(surface, R9ExpectMainDepthAfterReset ? "post-reset" : "first-main-bind")) + { + R9ExpectMainDepthAfterReset = false; + return hr; + } + } + + if (R9MainDepthKnown && surface == R9MainDepthIdentity) + { + if (R9DeferredDepth) + { + R9DeferredDepth = false; + ReleaseCom(R9DeferredDepthIdentity); + ++R9MainDepthRestores; + const ULONGLONG now = GetTickCount64(); + if (Settings::VRTelemetry && + (R9LastMainDepthRestoreLogMs == 0 || + now - R9LastMainDepthRestoreLogMs >= 5000)) + { + R9LastMainDepthRestoreLogMs = now; + spdlog::info( + "VR R9: exact MAIN depth identity restored after auxiliary bind; no frame poison (totalRestores={})", + R9MainDepthRestores); + } + } + return hr; + } + + // Any non-null depth with a different COM identity is treated as + // auxiliary/transient until the exact main depth returns. Size alone + // is deliberately NOT used as proof of main-depth identity. + R9DeferredDepth = true; + ReplaceSurfaceRef(R9DeferredDepthIdentity, surface); + ++R9TransientDepthBinds; + if (R9TransientDepthBinds == 1) + { + D3DSURFACE_DESC d{}; + R9GetDesc(surface, d); + spdlog::info( + "VR R9: auxiliary/transient depth observed ptr=0x{:08x} {}x{} fmt={} while RT=0x{:08x}; waiting for exact main identity 0x{:08x}", + static_cast(reinterpret_cast(surface)), d.Width, d.Height, + static_cast(d.Format), + static_cast(reinterpret_cast(TrackedRenderTarget)), + static_cast(reinterpret_cast(R9MainDepthIdentity))); + } + return hr; + } + + HRESULT __stdcall SetRenderTargetDestR9(IDirect3DDevice9* device, DWORD index, IDirect3DSurface9* surface) + { + ++R9RtCalls; + const HRESULT hr = R9SetRenderTargetCallbackHook.stdcall(device, index, surface); + if (!IsGameDevice(device) || InternalStereoPass || FAILED(hr) || index != 0) + return hr; + if (surface == BackBuffer && R9DeferredDepth && TrackedDepthStencil == R9MainDepthIdentity) + { + R9DeferredDepth = false; + ReleaseCom(R9DeferredDepthIdentity); + ++R9MainDepthRestores; + } + return hr; + } + + HRESULT __stdcall ClearDestR9(IDirect3DDevice9* device, DWORD count, const D3DRECT* rects, + DWORD flags, D3DCOLOR color, float z, DWORD stencil) + { + if (!IsGameDevice(device) || InternalStereoPass) + return R9ClearCallbackHook.stdcall(device, count, rects, flags, color, z, stencil); + ++R9ClearCalls; + const bool wanted = StereoWanted(); + const bool mainTarget = TargetIsBackBuffer(); + if (!wanted || !mainTarget) + return R9ClearCallbackHook.stdcall(device, count, rects, flags, color, z, stencil); + + if (R9DeferredDepth || !R9CurrentDepthCanMirror()) + { + const HRESULT hr = ClearHook.stdcall(device, count, rects, flags, color, z, stencil); + if (FrameHadDuplicatedDraw && SUCCEEDED(hr)) + R9Poison(OutRunVR::StereoFailureDepthStateChanged, "Clear/transient-depth", hr); + return hr; + } + + if (!R9EnsureMonoResources(device)) + { + R9Poison(OutRunVR::StereoFailureResourceUnavailable, "Clear/mono-resources"); + return ClearHook.stdcall(device, count, rects, flags, color, z, stencil); + } + + HRESULT monoHr = E_FAIL; + const bool monoOk = R9ReplayMonoClear(device, count, rects, flags, color, z, stencil, monoHr); + const auto before = FrameFailureReason; + const HRESULT stereoHr = FrameStereoIncomplete + ? ClearHook.stdcall(device, count, rects, flags, color, z, stencil) + : R9ClearCallbackHook.stdcall(device, count, rects, flags, color, z, stencil); + R9ObserveLegacyFailure(before, "Clear/legacy-stereo", stereoHr); + + const bool full = ClearCoversStereoBackbuffer(device, count, rects); + if (SUCCEEDED(stereoHr) && monoOk && full && (flags & D3DCLEAR_TARGET) != 0 && !FrameStereoIncomplete) + { + R9StereoSeeded = true; + R9MonoSeeded = true; + R9MonoBackupGap = false; + ++R9FullClearSeeds; + if ((flags & (D3DCLEAR_ZBUFFER | D3DCLEAR_STENCIL)) != 0) + { + ++R9MainDepthContentSerial; + R9MonoDepthContentSerial = R9MainDepthContentSerial; + } + if (R9FullClearSeeds == 1) + spdlog::info("VR R9: stereo frame seeded from verified full MAIN color clear; clear-only bootstrap after missed geometry is disabled"); + } + else if (!monoOk) + { + R9MonoBackupGap = true; + R9Poison(OutRunVR::StereoFailureClearFailed, "Clear/mono-shadow", monoHr); + } + return stereoHr; + } + + template + HRESULT R9ExecuteDraw(IDirect3DDevice9* device, ActualDraw&& actualDraw, LegacyDraw&& legacyDraw, const char* site) + { + if (!IsGameDevice(device) || InternalStereoPass) + return legacyDraw(); + ++R9DrawCalls; + const bool wanted = StereoWanted(); + const bool mainTarget = TargetIsBackBuffer(); + if (!wanted || !mainTarget) + return legacyDraw(); + + // Do not start stereo in the middle of a frame. Wait for a verified + // full MAIN clear so no depth/color history can be missing. + if (!R9StereoSeeded) + { + ++R9MonoOnlyDraws; + return actualDraw(); + } + + if (R9DeferredDepth || !R9CurrentDepthCanMirror()) + { + if (!FrameStereoIncomplete) + R9Poison(OutRunVR::StereoFailureDepthStateChanged, site, S_OK); + ++R9MonoOnlyDraws; + return actualDraw(); + } + + if (!R9EnsureMonoResources(device)) + { + R9Poison(OutRunVR::StereoFailureResourceUnavailable, "Draw/mono-resources"); + ++R9MonoOnlyDraws; + return actualDraw(); + } + + HRESULT monoHr = E_FAIL; + if (!R9ReplayMonoDraw(device, actualDraw, monoHr)) + { + ++R9MonoBackupDrawFailures; + R9MonoBackupGap = true; + R9Poison(OutRunVR::StereoFailureResourceUnavailable, "Draw/mono-shadow", monoHr); + ++R9MonoOnlyDraws; + return actualDraw(); + } + + if (LeftDrawMayWriteDepth(device) || LeftDrawMayWriteStencil(device)) + ++R9MainDepthContentSerial; + + // Once a frame has failed, never apply another eye transform to the + // real backbuffer. The mono shadow continues to receive the frame. + if (FrameStereoIncomplete) + { + ++R9MonoOnlyDraws; + return actualDraw(); + } + + const auto before = FrameFailureReason; + HRESULT hr = D3D_OK; + if (!TrackedDepthStencil) + { + // R7 always binds RightEyeDepth in its right pass. When the game + // explicitly uses no depth on the left, make the right eye null too. + IDirect3DSurface9* savedRightDepth = RightEyeDepth; + RightEyeDepth = nullptr; + hr = legacyDraw(); + RightEyeDepth = savedRightDepth; + } + else + { + hr = legacyDraw(); + } + R9ObserveLegacyFailure(before, site, hr); + return hr; + } + + HRESULT __stdcall DrawPrimitiveDestR9(IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + UINT startVertex, UINT primitiveCount) + { + auto actual = [&]() { return DrawPrimitiveHook.stdcall(device, type, startVertex, primitiveCount); }; + auto legacy = [&]() { return R9DrawPrimitiveCallbackHook.stdcall(device, type, startVertex, primitiveCount); }; + return R9ExecuteDraw(device, actual, legacy, "DrawPrimitive"); + } + + HRESULT __stdcall DrawIndexedPrimitiveDestR9(IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + INT baseVertexIndex, UINT minVertexIndex, UINT numVertices, UINT startIndex, UINT primitiveCount) + { + auto actual = [&]() { return DrawIndexedPrimitiveHook.stdcall(device, type, baseVertexIndex, minVertexIndex, numVertices, startIndex, primitiveCount); }; + auto legacy = [&]() { return R9DrawIndexedPrimitiveCallbackHook.stdcall(device, type, baseVertexIndex, minVertexIndex, numVertices, startIndex, primitiveCount); }; + return R9ExecuteDraw(device, actual, legacy, "DrawIndexedPrimitive"); + } + + HRESULT __stdcall DrawPrimitiveUPDestR9(IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + UINT primitiveCount, const void* data, UINT stride) + { + auto actual = [&]() { return DrawPrimitiveUPHook.stdcall(device, type, primitiveCount, data, stride); }; + auto legacy = [&]() { return R9DrawPrimitiveUPCallbackHook.stdcall(device, type, primitiveCount, data, stride); }; + return R9ExecuteDraw(device, actual, legacy, "DrawPrimitiveUP"); + } + + HRESULT __stdcall DrawIndexedPrimitiveUPDestR9(IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + UINT minVertexIndex, UINT numVertices, UINT primitiveCount, const void* indexData, + D3DFORMAT indexFormat, const void* vertexData, UINT stride) + { + auto actual = [&]() { return DrawIndexedPrimitiveUPHook.stdcall(device, type, minVertexIndex, numVertices, primitiveCount, indexData, indexFormat, vertexData, stride); }; + auto legacy = [&]() { return R9DrawIndexedPrimitiveUPCallbackHook.stdcall(device, type, minVertexIndex, numVertices, primitiveCount, indexData, indexFormat, vertexData, stride); }; + return R9ExecuteDraw(device, actual, legacy, "DrawIndexedPrimitiveUP"); + } + + HRESULT __stdcall PresentDestR9(IDirect3DDevice9* device, const RECT* sourceRect, + const RECT* destRect, HWND destWindowOverride, const RGNDATA* dirtyRegion) + { + if (!IsGameDevice(device)) + return PresentHook.stdcall(device, sourceRect, destRect, destWindowOverride, dirtyRegion); + ++R9PresentCalls; + + const std::uint64_t beginNow = OutRunVRRenderer::GetBeginSceneCallCount(); + const std::uint64_t beginDelta = beginNow >= LastBeginSceneCountAtPresent ? beginNow - LastBeginSceneCountAtPresent : 0; + LastBeginSceneCountAtPresent = beginNow; + if (GameplayActive()) + { + if (beginDelta > 1) ++MultiBeginScenePresents; + MaxBeginScenesPerPresent = std::max(MaxBeginScenesPerPresent, beginDelta); + } + + const bool stereoRequested = StereoWanted() && R9StereoSeeded; + const bool directOnly = R9DirectOnlyTransport(); + const std::uint32_t pendingFrameId = stereoRequested ? NextStereoFrameId() : 0; + bool composedStereo = false; + bool directTransport = false; + std::uint32_t pendingPoseSequence = 0; + + const bool frameEligible = stereoRequested && FrameHadWorldStereo && FrameHadDuplicatedDraw && + !FrameRightDrawFailed && !FrameStereoIncomplete && FrameStereoPoseSequence && + FrameStereoMetadata.valid && EnsureStereoResources(device); + + if (frameEligible) + { + directTransport = ResolveDirectTransport(device, pendingFrameId); + if (directTransport) + { + ++DirectTransportFrames; + composedStereo = true; + pendingPoseSequence = FrameStereoPoseSequence; + if (!HostDirectTransportReady() && !directOnly) + { + const bool composeOk = ComposeSbs(device); + if (composeOk) ++StereoComposeSuccess; + else + { + ++StereoComposeFailure; + R9Poison(OutRunVR::StereoFailureComposeFailed, "Present/ComposeSbs"); + composedStereo = false; + } + } + } + else + { + if (directOnly) + { + ++DirectTransportFallbacks; + composedStereo = false; + if (!R9FirstDirectOnlyWaitLogged) + { + R9FirstDirectOnlyWaitLogged = true; + spdlog::warn("VR R36 DIRECT-ONLY: shared-eye transport is not ready; SBS/Desktop Duplication fallback suppressed and PC mirror stays mono/left-eye"); + } + } + else + { + const bool composeOk = ComposeSbs(device); + if (composeOk) + { + ++StereoComposeSuccess; + ++DirectTransportFallbacks; + composedStereo = true; + pendingPoseSequence = FrameStereoPoseSequence; + if (!FirstDirectFallbackLogged) + { + FirstDirectFallbackLogged = true; + spdlog::warn("VR stereo: direct transport not verified/available; keeping SBS/Desktop Duplication fallback"); + } + } + else + { + ++StereoComposeFailure; + R9Poison(OutRunVR::StereoFailureComposeFailed, "Present/ComposeSbs"); + } + } + } + } + + // Explicit failure-output policy: if no complete SBS frame exists, + // restore the independently rendered normal mono frame before Present. + if (!composedStereo || FrameStereoIncomplete) + { + if (R9MonoSeeded && !R9MonoBackupGap) + R9RestoreMonoBeforePresent(device, R9FailureName(FrameFailureReason)); + } + + R9MaybeLogSummary(); + MaybeLogSummary(); + LARGE_INTEGER presentStart{}; + QueryPerformanceCounter(&presentStart); + if (stereoRequested && (!directOnly || directTransport)) + PublishRenderFrame(OutRunVR::StereoSbsFallbackMono, pendingFrameId, pendingPoseSequence, + presentStart.QuadPart, FrameFailureReason, nullptr, true, directTransport); + + const HRESULT rawPresentHr = PresentHook.stdcall(device, sourceRect, destRect, destWindowOverride, dirtyRegion); + const HRESULT hr = OutRunVRD3D9ExUpgradeR13::NormalizeLegacyPresentResult( + device, rawPresentHr); + if (composedStereo && SUCCEEDED(hr) && !FrameStereoIncomplete) + { + PublishStereoState(OutRunVR::StereoSbsActive, true, pendingPoseSequence, pendingFrameId); + PublishRenderFrame(OutRunVR::StereoSbsActive, pendingFrameId, pendingPoseSequence, + presentStart.QuadPart, OutRunVR::StereoFailureNone, &FrameStereoMetadata, false, directTransport); + if (!FirstStereoActiveLogged) + { + FirstStereoActiveLogged = true; + spdlog::info("VR R9 FINAL TEST: TRUE STEREO PRESENT COMPLETE transport={} sourceEye={}x{} desktop={}x{}", + directTransport ? "D3D9Ex ring" : "SBS Desktop Duplication", + directTransport && DirectTransportWidth ? DirectTransportWidth : BackBufferDesc.Width, + directTransport && DirectTransportHeight ? DirectTransportHeight : BackBufferDesc.Height, + BackBufferDesc.Width, BackBufferDesc.Height); + } + } + else + { + if (FAILED(hr) && FrameFailureReason == OutRunVR::StereoFailureNone) + R9Poison(OutRunVR::StereoFailurePresentFailed, "Present/D3D9", hr); + if (FrameFailureReason != OutRunVR::StereoFailureNone) + R9LogFirstFailure("Present/fallback", hr); + const std::uint32_t fallback = stereoRequested ? OutRunVR::StereoSbsFallbackMono : OutRunVR::StereoDisabled; + PublishStereoState(fallback, false, 0, 0); + // In DirectGPU-only mode, do not overwrite the bounded frame ring + // with classic fallback descriptors while the host still needs to + // consume/ACK one of the initial shared-eye frames. + if (!directOnly || !stereoRequested || directTransport) + PublishRenderFrame(fallback, 0, 0, presentStart.QuadPart, FrameFailureReason, nullptr); + } + + OutRunVRRenderer::NotifyGamePresent(); + FrameHadDuplicatedDraw = false; + FrameHadWorldStereo = false; + FrameRightDrawFailed = false; + FrameStereoIncomplete = false; + FramePoseMismatchLogged = false; + FrameFailureReason = OutRunVR::StereoFailureNone; + FrameStereoPoseSequence = 0; + FrameStereoMetadata = {}; + R9StereoSeeded = false; + R9MonoSeeded = false; + R9MonoBackupGap = false; + if (++PresentEpoch == 0) PresentEpoch = 1; + return hr; + } + + HRESULT __stdcall ResetDestR9(IDirect3DDevice9* device, D3DPRESENT_PARAMETERS* params) + { + R9ReleaseMonoResources(); + R9ReleaseDepthIdentity(); + R9StereoSeeded = false; + R9MonoSeeded = false; + R9MonoBackupGap = false; + R9ExpectMainDepthAfterReset = true; + const HRESULT hr = R9ResetCallbackHook.stdcall(device, params); + if (SUCCEEDED(hr) && IsGameDevice(device) && TrackedDepthStencil) + { + R9CaptureMainDepth(TrackedDepthStencil, "reset-complete"); + R9ExpectMainDepthAfterReset = false; + } + return hr; + } + + void R9RollbackCallbackPolicy() noexcept + { + R9DrawIndexedPrimitiveUPCallbackHook = {}; + R9DrawPrimitiveUPCallbackHook = {}; + R9DrawIndexedPrimitiveCallbackHook = {}; + R9DrawPrimitiveCallbackHook = {}; + R9ClearCallbackHook = {}; + R9SetDepthCallbackHook = {}; + R9SetRenderTargetCallbackHook = {}; + R9PresentCallbackHook = {}; + R9ResetCallbackHook = {}; + R9InstallState.store(R9InstallFailed, std::memory_order_release); + } + + bool R9InstallCallbackPolicy(IDirect3DDevice9* device) + { + if (!device) + { + R9InstallState.store(R9InstallFailed, std::memory_order_release); + return false; + } + R9InstallState.store(R9InstallPending, std::memory_order_release); + R9ResetCallbackHook = safetyhook::create_inline(reinterpret_cast(&ResetDest), ResetDestR9); + R9PresentCallbackHook = safetyhook::create_inline(reinterpret_cast(&PresentDest), PresentDestR9); + R9SetRenderTargetCallbackHook = safetyhook::create_inline(reinterpret_cast(&SetRenderTargetDest), SetRenderTargetDestR9); + R9SetDepthCallbackHook = safetyhook::create_inline(reinterpret_cast(&SetDepthStencilSurfaceDest), SetDepthStencilSurfaceDestR9); + R9ClearCallbackHook = safetyhook::create_inline(reinterpret_cast(&ClearDest), ClearDestR9); + R9DrawPrimitiveCallbackHook = safetyhook::create_inline(reinterpret_cast(&DrawPrimitiveDest), DrawPrimitiveDestR9); + R9DrawIndexedPrimitiveCallbackHook = safetyhook::create_inline(reinterpret_cast(&DrawIndexedPrimitiveDest), DrawIndexedPrimitiveDestR9); + R9DrawPrimitiveUPCallbackHook = safetyhook::create_inline(reinterpret_cast(&DrawPrimitiveUPDest), DrawPrimitiveUPDestR9); + R9DrawIndexedPrimitiveUPCallbackHook = safetyhook::create_inline(reinterpret_cast(&DrawIndexedPrimitiveUPDest), DrawIndexedPrimitiveUPDestR9); + if (!R9ResetCallbackHook || !R9PresentCallbackHook || !R9SetRenderTargetCallbackHook || + !R9SetDepthCallbackHook || !R9ClearCallbackHook || !R9DrawPrimitiveCallbackHook || + !R9DrawIndexedPrimitiveCallbackHook || !R9DrawPrimitiveUPCallbackHook || + !R9DrawIndexedPrimitiveUPCallbackHook) + { + R9RollbackCallbackPolicy(); + spdlog::error("VR R9 FINAL TEST: callback policy transaction was partial; all R9 callback hooks rolled back immediately"); + return false; + } + + if (TrackedDepthStencil) + R9CaptureMainDepth(TrackedDepthStencil, "policy-install"); + else + R9MainDepthKnown = false; + R9InstallState.store(R9InstallReady, std::memory_order_release); + spdlog::info( + "VR R9 FINAL TEST: callback policy ACTIVE build={} depthHook=detour-callback monoFallback=shadow fullClearSeed=required nullDepth=mirrored transactional=READY", + R9BuildId); + R9LogSurface("initial RT", TrackedRenderTarget); + R9LogSurface("initial DS", TrackedDepthStencil); + return true; + } + + DWORD WINAPI R9InstallThread(void*) + { + for (int attempt = 0; attempt < 4800; ++attempt) + { + const std::uint32_t baseState = StereoInstallState.load(std::memory_order_acquire); + if (baseState == StereoInstallFailed) + { + R9InstallState.store(R9InstallFailed, std::memory_order_release); + spdlog::error("VR R9 FINAL TEST: R7 base hook transaction failed; callback policy not attempted"); + return 0; + } + if (baseState == StereoInstallReady) + { + IDirect3DDevice9* const device = StereoInstalledDevice.load(std::memory_order_acquire); + if (device && R9InstallCallbackPolicy(device)) + return 0; + if (R9InstallState.load(std::memory_order_acquire) != R9InstallFailed) + R9InstallState.store(R9InstallFailed, std::memory_order_release); + spdlog::error("VR R9 FINAL TEST: failed to install callback policy; fail-closed rollback complete"); + return 0; + } + Sleep(25); + } + R9InstallState.store(R9InstallFailed, std::memory_order_release); + spdlog::warn("VR R9 FINAL TEST: R7 transactional install did not become ready; policy not installed"); + return 0; + } + + class VRFinalTestR9Hook : public Hook + { + public: + std::string_view description() override { return "OpenXRVRFinalTestR9"; } + bool validate() override { return true; } + bool apply() override + { + HANDLE thread = CreateThread(nullptr, 0, R9InstallThread, nullptr, 0, nullptr); + if (!thread) + { + R9InstallState.store(R9InstallFailed, std::memory_order_release); + spdlog::error("VR R9 FINAL TEST: failed to create policy installer thread: {}", GetLastError()); + return false; + } + CloseHandle(thread); + return true; + } + static VRFinalTestR9Hook instance; + }; + + VRFinalTestR9Hook VRFinalTestR9Hook::instance; + } +} diff --git a/src/vr/d3d9/stereo_renderer_r13.cpp b/src/vr/d3d9/stereo_renderer_r13.cpp new file mode 100644 index 000000000..22ed3c1af --- /dev/null +++ b/src/vr/d3d9/stereo_renderer_r13.cpp @@ -0,0 +1,759 @@ +// R13 hardening wrapper. The validated R9 renderer remains source-of-truth; +// cmake marks stereo_renderer.cpp HEADER_FILE_ONLY and compiles this TU. +// +// Review hardening added here keeps unsafe MRT/occlusion transitions single- +// execution: once such a main-backbuffer draw appears after stereo has started, +// the rest of that Present is rendered only into the already-seeded mono safety +// shadow and restored before Present. This avoids replaying side-effecting MRT +// or query draws while still producing a complete mono fallback. + +#include "r13_bridge.hpp" +#include "stereo_renderer.cpp" + +namespace OutRunVRStereo +{ + namespace + { + SafetyHookInline R13ResetR9Hook{}; + SafetyHookInline R13ResolveDirectHook{}; + SafetyHookInline R13PresentR9Hook{}; + SafetyHookInline R13DrawPrimitiveR9Hook{}; + SafetyHookInline R13DrawIndexedPrimitiveR9Hook{}; + SafetyHookInline R13DrawPrimitiveUPR9Hook{}; + SafetyHookInline R13DrawIndexedPrimitiveUPR9Hook{}; + + constexpr std::uint32_t R13InstallPending = 0; + constexpr std::uint32_t R13InstallReady = 1; + constexpr std::uint32_t R13InstallFailed = 2; + std::atomic R13InstallState{R13InstallPending}; + std::atomic R13OverlayReady{false}; + + std::uint64_t R13SafeAckBackpressure = 0; + bool R13FirstSafeAckBlockLogged = false; + bool R13FirstAckMappingLogged = false; + bool R13ForceMonoShadow = false; + std::uint64_t R13UnsafeTransitionFrames = 0; + bool R13FirstUnsafeTransitionLogged = false; + std::uint64_t R13DrawTimeZeroDisparityDraws = 0; + bool R13FirstDrawTimeZeroDisparityLogged = false; + bool R13FirstDrawTimeStateReadFailureLogged = false; + + enum class R13EffectClass : std::uint8_t + { + Unknown, + World, + Flat + }; + struct R13EffectSnapshot + { + R13EffectClass classification = R13EffectClass::Unknown; + bool zKnown = false; + bool zEnabled = false; + }; + thread_local bool R13EffectOverrideActive = false; + thread_local R13EffectSnapshot R13EffectOverride{}; + struct R13EffectOverrideScope + { + bool previousActive = false; + R13EffectSnapshot previous{}; + explicit R13EffectOverrideScope( + const R13EffectSnapshot& snapshot) noexcept + : previousActive(R13EffectOverrideActive), + previous(R13EffectOverride) + { + R13EffectOverride = snapshot; + R13EffectOverrideActive = true; + } + ~R13EffectOverrideScope() + { + R13EffectOverride = previous; + R13EffectOverrideActive = previousActive; + } + }; + + HANDLE R13AckMapping = nullptr; + const OutRunVR::R13::DirectGpuAckState* R13AckState = nullptr; + + bool R13EnsureAckState() noexcept + { + if (R13AckState && + R13AckState->magic == OutRunVR::R13::DirectGpuAckMagic && + R13AckState->version == OutRunVR::R13::DirectGpuAckVersion && + R13AckState->structSize == sizeof(OutRunVR::R13::DirectGpuAckState)) + return true; + + if (R13AckState) + { + UnmapViewOfFile(R13AckState); + R13AckState = nullptr; + } + if (R13AckMapping) + { + CloseHandle(R13AckMapping); + R13AckMapping = nullptr; + } + + R13AckMapping = OpenFileMappingW( + FILE_MAP_READ, FALSE, OutRunVR::R13::DirectGpuAckName); + if (!R13AckMapping) + return false; + + R13AckState = static_cast(MapViewOfFile( + R13AckMapping, FILE_MAP_READ, 0, 0, + sizeof(OutRunVR::R13::DirectGpuAckState))); + if (!R13AckState) + { + CloseHandle(R13AckMapping); + R13AckMapping = nullptr; + return false; + } + + if (R13AckState->magic != OutRunVR::R13::DirectGpuAckMagic || + R13AckState->version != OutRunVR::R13::DirectGpuAckVersion || + R13AckState->structSize != sizeof(OutRunVR::R13::DirectGpuAckState)) + { + UnmapViewOfFile(R13AckState); + R13AckState = nullptr; + CloseHandle(R13AckMapping); + R13AckMapping = nullptr; + return false; + } + + if (!R13FirstAckMappingLogged) + { + R13FirstAckMappingLogged = true; + spdlog::info( + "VR D3D9Ex R13: dedicated per-slot GPU-consumer ACK mapping opened; legacy pose reserved fields remain untouched"); + } + return true; + } + + bool R13ReadGpuCompletedFrame(std::uint32_t slotIndex, + std::uint32_t& completedFrame) noexcept + { + completedFrame = 0; + if (slotIndex >= OutRunVR::RenderFrameRingSize || !R13EnsureAckState()) + return false; + + for (int attempt = 0; attempt < 4; ++attempt) + { + const std::uint32_t before = R13AckState->sequence; + if (before & 1u) + continue; + MemoryBarrier(); + + OutRunVR::R13::DirectGpuAckState snapshot{}; + std::memcpy(&snapshot, R13AckState, sizeof(snapshot)); + + MemoryBarrier(); + const std::uint32_t after = R13AckState->sequence; + if (before != after || (after & 1u)) + continue; + + if (snapshot.magic != OutRunVR::R13::DirectGpuAckMagic || + snapshot.version != OutRunVR::R13::DirectGpuAckVersion || + snapshot.structSize != sizeof(snapshot) || + !snapshot.hostPid || !SharedState || + snapshot.hostPid != SharedState->hostPid || + snapshot.transportGeneration != DirectTransportGeneration) + return false; + + completedFrame = snapshot.completedFrameId[slotIndex]; + return true; + } + return false; + } + + bool ResolveDirectTransportR13(IDirect3DDevice9* device, std::uint32_t frameId) + { + if (!R13OverlayReady.load(std::memory_order_acquire)) + return R13ResolveDirectHook.call(device, frameId); + + if (!frameId || !EnsureDirectTransportResources(device) || + !BackBuffer || !RightEyeSurface) + return false; + + const std::uint32_t preferred = + (frameId - 1u) % OutRunVR::RenderFrameRingSize; + std::uint32_t selected = OutRunVR::RenderFrameRingSize; + bool ackBlocked = false; + + // R38: the base producer can choose any free slot, so the safety + // gate must validate the actual candidate slot rather than the old + // frameId%ring slot. Only the dedicated host GPU-completion ACK can + // release a published shared texture; the legacy global consumed + // frame id is deliberately ignored here. + for (std::uint32_t offset = 0; + offset < OutRunVR::RenderFrameRingSize; ++offset) + { + const std::uint32_t index = + (preferred + offset) % OutRunVR::RenderFrameRingSize; + auto& slot = DirectTransportSlots[index]; + + if (slot.producerPending) + { + const HRESULT ready = + slot.fence ? slot.fence->GetData(nullptr, 0, 0) : E_FAIL; + if (ready == S_OK) + { + slot.producerPending = false; + slot.pendingFrameId = 0; + // A frame that missed its publication window was never + // visible to the host and needs no consumer ACK. + if (!slot.published) + slot.frameId = 0; + } + else if (ready == S_FALSE) + { + continue; + } + else + { + slot.producerPending = false; + slot.pendingFrameId = 0; + slot.frameId = 0; + slot.published = false; + } + } + + if (slot.published && slot.frameId) + { + std::uint32_t gpuCompleted = 0; + const bool ackValid = + R13ReadGpuCompletedFrame(index, gpuCompleted); + if (!ackValid || + !FrameIdAtOrAfter(gpuCompleted, slot.frameId)) + { + ackBlocked = true; + continue; + } + slot.frameId = 0; + slot.published = false; + } + + selected = index; + break; + } + + if (selected >= OutRunVR::RenderFrameRingSize) + { + ++R13SafeAckBackpressure; + ++DirectTransportRingBackpressure; + if (ackBlocked && !R13FirstSafeAckBlockLogged) + { + R13FirstSafeAckBlockLogged = true; + spdlog::info( + "VR D3D9Ex R38: per-slot GPU-completion ACK aware free-slot scan ACTIVE; published shared eyes remain immutable until the exact host EVENT fence completes"); + } + return false; + } + + auto& slot = DirectTransportSlots[selected]; + { + InternalPassScope guard; + if (FAILED(StretchDirectEye( + device, BackBuffer, slot.leftSurface)) || + FAILED(StretchDirectEye( + device, RightEyeSurface, slot.rightSurface)) || + FAILED(slot.fence->Issue(D3DISSUE_END))) + return false; + } + + slot.producerPending = true; + slot.pendingFrameId = frameId; + slot.frameId = frameId; + slot.published = false; + ActiveDirectTransportSlot = selected; + return true; + } + + void R13ResetCommonPre(IDirect3DDevice9*) + { + R13ForceMonoShadow = false; + R9ReleaseMonoResources(); + R9ReleaseDepthIdentity(); + R9StereoSeeded = false; + R9MonoSeeded = false; + R9MonoBackupGap = false; + R9ExpectMainDepthAfterReset = true; + R9FirstFailureEpoch = 0; + + OutRunVRRenderer::NotifyGameReset(); + ReleaseStereoResources(); + AuxRenderTargetActive = {}; + ActiveOcclusionQueries.store(0, std::memory_order_release); + CurrentVertexShaderIdentity.store(0, std::memory_order_release); + VertexShaderSerial.store(0, std::memory_order_release); + LastStereoWanted = false; + RightStencilSynchronized = true; + FramePoseMismatchLogged = false; + FirstMainDepthReuseLogged = false; + FirstMainClearLogged = false; + FirstDepthBootstrapLogged = false; + PresentEpoch = 1; + LastMainDepthClearEpoch = 0; + LastMainDepthClearFlags = 0; + LastMainDepthClearZ = 1.0f; + LastMainDepthClearStencil = 0; + LastMainDepthClearDesc = {}; + LastBeginSceneCountAtPresent = OutRunVRRenderer::GetBeginSceneCallCount(); + FrameStereoIncomplete = false; + FrameFailureReason = OutRunVR::StereoFailureNone; + FrameStereoMetadata = {}; + PublishStereoState(OutRunVR::StereoDisabled, false, 0, 0); + PublishRenderFrame(OutRunVR::StereoDisabled, 0, 0, 0, + OutRunVR::StereoFailureNone, nullptr); + } + + HRESULT __stdcall ResetDestR13(IDirect3DDevice9* device, + D3DPRESENT_PARAMETERS* params) + { + if (!R13OverlayReady.load(std::memory_order_acquire)) + return R13ResetR9Hook.stdcall(device, params); + + if (!IsGameDevice(device) || + !OutRunVRD3D9ExUpgradeR13::IsCompatDevice(device)) + return R13ResetR9Hook.stdcall(device, params); + + OutRunVRD3D9ExUpgradeR13::DisarmLegacyResetHook(); + R13ResetCommonPre(device); + + HRESULT hr = D3DERR_INVALIDCALL; + if (!OutRunVRD3D9ExUpgradeR13::ResetCompatDevice(device, params, hr)) + { + spdlog::error( + "VR D3D9Ex R13: compat device lost ResetEx ownership unexpectedly; refusing competing Reset chain"); + return D3DERR_INVALIDCALL; + } + + if (SUCCEEDED(hr)) + { + EnsureStereoResources(device); + if (TrackedDepthStencil) + { + R9CaptureMainDepth(TrackedDepthStencil, "r13-resetex-complete"); + R9ExpectMainDepthAfterReset = false; + } + } + return hr; + } + + template + HRESULT R13DrawMonoShadowOnce(IDirect3DDevice9* device, + DrawCall&& actualDraw, const char* site) + { + IDirect3DSurface9* savedRt = nullptr; + IDirect3DSurface9* savedDepth = nullptr; + D3DVIEWPORT9 savedViewport{}; + if (!R9BindMonoTarget(device, savedRt, savedDepth, savedViewport)) + { + R9MonoBackupGap = true; + R9Poison(OutRunVR::StereoFailureResourceUnavailable, site); + return actualDraw(); + } + + const bool mayWriteDepth = LeftDrawMayWriteDepth(device); + const bool mayWriteStencil = LeftDrawMayWriteStencil(device); + const HRESULT drawHr = actualDraw(); + const bool restoreOk = + R9RestoreGameTarget(device, savedRt, savedDepth, savedViewport); + + if (FAILED(drawHr)) + { + ++R9MonoBackupDrawFailures; + R9MonoBackupGap = true; + R9Poison(OutRunVR::StereoFailureLeftDrawFailed, site, drawHr); + return drawHr; + } + if (!restoreOk) + { + ++R9MonoBackupDrawFailures; + R9MonoBackupGap = true; + R9Poison(OutRunVR::StereoFailureRestoreFailed, site, E_FAIL); + return E_FAIL; + } + + ++R9MonoBackupDraws; + if (mayWriteDepth || mayWriteStencil) + ++R9MonoDepthContentSerial; + return drawHr; + } + + R13EffectSnapshot R13CaptureDrawTimeEffect( + IDirect3DDevice9* device) noexcept + { + if (R13EffectOverrideActive) + return R13EffectOverride; + R13EffectSnapshot snapshot{}; + if (!device) + return snapshot; + + DWORD alphaBlend = FALSE; + DWORD alphaTest = FALSE; + DWORD zWrite = TRUE; + DWORD zEnable = D3DZB_TRUE; + DWORD cullMode = D3DCULL_CCW; + if (!ReadTrackedRenderState(device, D3DRS_ALPHABLENDENABLE, alphaBlend) || + !ReadTrackedRenderState(device, D3DRS_ALPHATESTENABLE, alphaTest) || + !ReadTrackedRenderState(device, D3DRS_ZWRITEENABLE, zWrite) || + !ReadTrackedRenderState(device, D3DRS_ZENABLE, zEnable) || + !ReadTrackedRenderState(device, D3DRS_CULLMODE, cullMode)) + { + if (!R13FirstDrawTimeStateReadFailureLogged) + { + R13FirstDrawTimeStateReadFailureLogged = true; + spdlog::warn( + "VR R13 effect policy: draw-time render-state snapshot unavailable; failing closed to zero-disparity"); + } + return snapshot; + } + + snapshot.zKnown = true; + snapshot.zEnabled = zEnable != D3DZB_FALSE; + const auto policy = OutRunVR::PassPolicy::ClassifyEffectStereo( + alphaBlend != FALSE, + alphaTest != FALSE, + zWrite != FALSE, + snapshot.zEnabled, + cullMode == D3DCULL_NONE); + snapshot.classification = + OutRunVR::PassPolicy::AllowsEffectWorldStereo(policy) + ? R13EffectClass::World : R13EffectClass::Flat; + return snapshot; + } + + bool R13DrawTimeFragileEffectNeedsZeroDisparity( + IDirect3DDevice9* device) noexcept + { + return R13CaptureDrawTimeEffect(device).classification != + R13EffectClass::World; + } + + template + HRESULT R13RunLegacyDrawWithDrawTimeEffectPolicy( + IDirect3DDevice9* device, bool stereoActive, + LegacyDraw&& legacyDraw) noexcept + { + if (!stereoActive || + !R13DrawTimeFragileEffectNeedsZeroDisparity(device)) + return legacyDraw(); + + // BuildEyeConstants treats a zero shader identity as NonWorld and + // therefore replays the draw to both eyes with the untouched stock + // WVP. Temporarily mask only this draw. Restore the identity only if + // no nested SetVertexShader changed it while the draw was executing. + const std::uintptr_t savedIdentity = + CurrentVertexShaderIdentity.exchange(0, std::memory_order_acq_rel); + const HRESULT hr = legacyDraw(); + if (savedIdentity != 0) + { + std::uintptr_t expected = 0; + CurrentVertexShaderIdentity.compare_exchange_strong( + expected, savedIdentity, + std::memory_order_acq_rel, std::memory_order_acquire); + } + + ++R13DrawTimeZeroDisparityDraws; + if (!R13FirstDrawTimeZeroDisparityLogged) + { + R13FirstDrawTimeZeroDisparityLogged = true; + spdlog::info( + "VR R13 effect policy: draw-time alpha/billboard/shadow state confirmed; current draw duplicated zero-disparity with stock WVP"); + } + return hr; + } + + template + HRESULT R13GuardedDraw(IDirect3DDevice9* device, + ActualDraw&& actualDraw, LegacyDraw&& legacyDraw, const char* site) + { + if (!R13OverlayReady.load(std::memory_order_acquire)) + return legacyDraw(); + + const bool gameDevice = IsGameDevice(device); + const bool internalStereo = InternalStereoPass; + if (!gameDevice || internalStereo) + return legacyDraw(); + + const bool mainTarget = TargetIsBackBuffer(); + if (!mainTarget) + return legacyDraw(); + + const bool unsafeMrt = AnyAuxRenderTargetActive(); + const bool unsafeOcclusion = + ActiveOcclusionQueries.load(std::memory_order_acquire) > 0 || + OcclusionQueryTrackingUnavailable.load(std::memory_order_acquire); + const bool stereoWanted = StereoWanted(); + const auto replayPolicy = OutRunVR::PassPolicy::ClassifyDrawReplay( + gameDevice, internalStereo, mainTarget, R13ForceMonoShadow, + stereoWanted, R9StereoSeeded, unsafeMrt, unsafeOcclusion); + + if (replayPolicy == OutRunVR::PassPolicy::DrawReplayPolicy::Legacy) + { + return R13RunLegacyDrawWithDrawTimeEffectPolicy( + device, stereoWanted && R9StereoSeeded, + std::forward(legacyDraw)); + } + if (replayPolicy == OutRunVR::PassPolicy::DrawReplayPolicy::ForcedMonoShadow) + return R13DrawMonoShadowOnce(device, actualDraw, site); + + // UnsafeSingleExecution is the fail-closed class: MRT/query side + // effects must execute only once even if stereo had already begun. + const auto reason = unsafeMrt + ? OutRunVR::StereoFailureMrtActive + : OutRunVR::StereoFailureOcclusionQueryActive; + R9Poison(reason, site); + ++R13UnsafeTransitionFrames; + + if (R9MonoSeeded && !R9MonoBackupGap && + R9CurrentDepthCanMirror()) + { + R13ForceMonoShadow = true; + if (!R13FirstUnsafeTransitionLogged) + { + R13FirstUnsafeTransitionLogged = true; + spdlog::info( + "VR R13 pass policy: unsafe MRT/occlusion classified single-execution; remainder of Present uses mono shadow without duplicated side effects"); + } + return R13DrawMonoShadowOnce(device, actualDraw, site); + } + + // If the independent mono history is unavailable, do not replay a + // side-effecting draw. Continue the real target once and mark the + // shadow unusable for Present restoration. + R9MonoBackupGap = true; + return actualDraw(); + } + + HRESULT __stdcall DrawPrimitiveDestR13(IDirect3DDevice9* device, + D3DPRIMITIVETYPE type, UINT startVertex, UINT primitiveCount) + { + auto actual = [&]() { + return DrawPrimitiveHook.stdcall( + device, type, startVertex, primitiveCount); + }; + auto legacy = [&]() { + return R13DrawPrimitiveR9Hook.stdcall( + device, type, startVertex, primitiveCount); + }; + return R13GuardedDraw(device, actual, legacy, "R13/DrawPrimitive"); + } + + HRESULT __stdcall DrawIndexedPrimitiveDestR13(IDirect3DDevice9* device, + D3DPRIMITIVETYPE type, INT baseVertexIndex, UINT minVertexIndex, + UINT numVertices, UINT startIndex, UINT primitiveCount) + { + auto actual = [&]() { + return DrawIndexedPrimitiveHook.stdcall(device, type, + baseVertexIndex, minVertexIndex, numVertices, startIndex, + primitiveCount); + }; + auto legacy = [&]() { + return R13DrawIndexedPrimitiveR9Hook.stdcall(device, type, + baseVertexIndex, minVertexIndex, numVertices, startIndex, + primitiveCount); + }; + return R13GuardedDraw( + device, actual, legacy, "R13/DrawIndexedPrimitive"); + } + + HRESULT __stdcall DrawPrimitiveUPDestR13(IDirect3DDevice9* device, + D3DPRIMITIVETYPE type, UINT primitiveCount, const void* data, UINT stride) + { + auto actual = [&]() { + return DrawPrimitiveUPHook.stdcall( + device, type, primitiveCount, data, stride); + }; + auto legacy = [&]() { + return R13DrawPrimitiveUPR9Hook.stdcall( + device, type, primitiveCount, data, stride); + }; + return R13GuardedDraw(device, actual, legacy, "R13/DrawPrimitiveUP"); + } + + HRESULT __stdcall DrawIndexedPrimitiveUPDestR13(IDirect3DDevice9* device, + D3DPRIMITIVETYPE type, UINT minVertexIndex, UINT numVertices, + UINT primitiveCount, const void* indexData, D3DFORMAT indexFormat, + const void* vertexData, UINT stride) + { + auto actual = [&]() { + return DrawIndexedPrimitiveUPHook.stdcall(device, type, + minVertexIndex, numVertices, primitiveCount, indexData, + indexFormat, vertexData, stride); + }; + auto legacy = [&]() { + return R13DrawIndexedPrimitiveUPR9Hook.stdcall(device, type, + minVertexIndex, numVertices, primitiveCount, indexData, + indexFormat, vertexData, stride); + }; + return R13GuardedDraw( + device, actual, legacy, "R13/DrawIndexedPrimitiveUP"); + } + + HRESULT __stdcall PresentDestR13(IDirect3DDevice9* device, + const RECT* sourceRect, const RECT* destRect, + HWND destWindowOverride, const RGNDATA* dirtyRegion) + { + if (!R13OverlayReady.load(std::memory_order_acquire)) + { + return R13PresentR9Hook.stdcall(device, + sourceRect, destRect, destWindowOverride, dirtyRegion); + } + + const HRESULT hr = R13PresentR9Hook.stdcall(device, + sourceRect, destRect, destWindowOverride, dirtyRegion); + if (IsGameDevice(device)) + R13ForceMonoShadow = false; + return hr; + } + + void R13RollbackOverlayHooks() noexcept + { + R13OverlayReady.store(false, std::memory_order_release); + R13ResetR9Hook = {}; + R13ResolveDirectHook = {}; + R13PresentR9Hook = {}; + R13DrawPrimitiveR9Hook = {}; + R13DrawIndexedPrimitiveR9Hook = {}; + R13DrawPrimitiveUPR9Hook = {}; + R13DrawIndexedPrimitiveUPR9Hook = {}; + R13InstallState.store(R13InstallFailed, std::memory_order_release); + } + + bool R13EnableOverlayHooks() noexcept + { + if (!R13ResetR9Hook.enable()) return false; + if (!R13ResolveDirectHook.enable()) return false; + if (!R13PresentR9Hook.enable()) return false; + if (!R13DrawPrimitiveR9Hook.enable()) return false; + if (!R13DrawIndexedPrimitiveR9Hook.enable()) return false; + if (!R13DrawPrimitiveUPR9Hook.enable()) return false; + if (!R13DrawIndexedPrimitiveUPR9Hook.enable()) return false; + return true; + } + + DWORD WINAPI R13StereoInstallThread(void*) + { + R13OverlayReady.store(false, std::memory_order_release); + R13InstallState.store(R13InstallPending, std::memory_order_release); + for (int attempt = 0; attempt < 4800; ++attempt) + { + const std::uint32_t r9State = R9InstallState.load(std::memory_order_acquire); + if (r9State == R9InstallFailed) + { + R13OverlayReady.store(false, std::memory_order_release); + R13InstallState.store(R13InstallFailed, std::memory_order_release); + spdlog::error( + "VR R13: R9 callback transaction failed; hardening overlay not installed"); + return 0; + } + if (r9State == R9InstallReady) + { + constexpr auto disabled = safetyhook::InlineHook::StartDisabled; + R13ResetR9Hook = safetyhook::create_inline( + reinterpret_cast(&ResetDestR9), ResetDestR13, disabled); + R13ResolveDirectHook = safetyhook::create_inline( + reinterpret_cast(&ResolveDirectTransport), + ResolveDirectTransportR13, disabled); + R13PresentR9Hook = safetyhook::create_inline( + reinterpret_cast(&PresentDestR9), PresentDestR13, disabled); + R13DrawPrimitiveR9Hook = safetyhook::create_inline( + reinterpret_cast(&DrawPrimitiveDestR9), + DrawPrimitiveDestR13, disabled); + R13DrawIndexedPrimitiveR9Hook = safetyhook::create_inline( + reinterpret_cast(&DrawIndexedPrimitiveDestR9), + DrawIndexedPrimitiveDestR13, disabled); + R13DrawPrimitiveUPR9Hook = safetyhook::create_inline( + reinterpret_cast(&DrawPrimitiveUPDestR9), + DrawPrimitiveUPDestR13, disabled); + R13DrawIndexedPrimitiveUPR9Hook = safetyhook::create_inline( + reinterpret_cast(&DrawIndexedPrimitiveUPDestR9), + DrawIndexedPrimitiveUPDestR13, disabled); + + const bool created = + R13ResetR9Hook && R13ResolveDirectHook && + R13PresentR9Hook && R13DrawPrimitiveR9Hook && + R13DrawIndexedPrimitiveR9Hook && R13DrawPrimitiveUPR9Hook && + R13DrawIndexedPrimitiveUPR9Hook; + const bool enabled = created && R13EnableOverlayHooks(); + + if (enabled) + { + R13OverlayReady.store(true, std::memory_order_release); + R13InstallState.store(R13InstallReady, std::memory_order_release); + spdlog::info( + "VR R13: stereo hardening ACTIVE; disabled-first transactional hooks=READY + atomic R7/R9 install handoff + draw-time fragile-effect validation + single ResetEx owner + GPU-completion direct-ring backpressure + single-execution MRT/occlusion fallback"); + } + else + { + R13RollbackOverlayHooks(); + spdlog::error( + "VR R13: overlay hook create/enable transaction was partial; all R13 overlay hooks rolled back immediately"); + } + return 0; + } + Sleep(25); + } + + R13OverlayReady.store(false, std::memory_order_release); + R13InstallState.store(R13InstallFailed, std::memory_order_release); + spdlog::warn( + "VR R13: R9 transactional install did not become ready; hardening overlay not installed"); + return 0; + } + + class VRStereoR13HardeningHook final : public Hook + { + public: + std::string_view description() override + { + return "OpenXRVRStereoR13Hardening"; + } + bool validate() override { return true; } + bool apply() override + { + R13OverlayReady.store(false, std::memory_order_release); + HANDLE thread = CreateThread( + nullptr, 0, R13StereoInstallThread, nullptr, 0, nullptr); + if (!thread) + { + R13InstallState.store(R13InstallFailed, std::memory_order_release); + return false; + } + CloseHandle(thread); + return true; + } + static VRStereoR13HardeningHook instance; + }; + + VRStereoR13HardeningHook VRStereoR13HardeningHook::instance; + } + + PoseInjectionSnapshot CurrentPoseInjectionSnapshot() noexcept + { + // Sample all mutable D3D9 pass signals exactly once. The legacy invariant is + // intentionally calculated from the raw signals rather than from the + // enum result, so the checked classifier can catch future policy drift + // without observing two different moments of D3D9 state. + const bool internalStereo = InternalStereoPass; + const bool mainBackbuffer = TargetIsBackBuffer(); + const bool auxiliaryRenderTargetActive = AnyAuxRenderTargetActive(); + + PoseInjectionSnapshot snapshot{}; + snapshot.policy = OutRunVR::PassPolicy::ClassifyPoseInjection( + internalStereo, mainBackbuffer, auxiliaryRenderTargetActive); + snapshot.legacyMainBackbufferInvariant = + !internalStereo && mainBackbuffer && !auxiliaryRenderTargetActive; + return snapshot; + } + + OutRunVR::PassPolicy::PoseInjectionPolicy CurrentPoseInjectionPolicy() noexcept + { + return CurrentPoseInjectionSnapshot().policy; + } + + bool IsMainBackbufferPoseInjectionPass() noexcept + { + return CurrentPoseInjectionSnapshot().legacyMainBackbufferInvariant; + } +} diff --git a/src/vr/d3d9/stereo_renderer_r20.cpp b/src/vr/d3d9/stereo_renderer_r20.cpp new file mode 100644 index 000000000..c50892f11 --- /dev/null +++ b/src/vr/d3d9/stereo_renderer_r20.cpp @@ -0,0 +1,318 @@ +// R20 production stabilization wrapper. +// +// Keep the validated R13/R9 safety stack intact, but fix the bootstrap rule +// that could leave the RenderFrame ring permanently at frameId=0 on OutRun. +// R9 required a *full-screen* main-target color clear on every Present before +// any stereo draw could start. Real OutRun render paths can use a rect/scissored +// main color clear. +// +// R23 review hardening makes the depth/stencil baseline rule common to every +// first stereo seed. A legacy R9 full-color clear and the relaxed R20 bootstrap +// must both prove current-generation depth/stencil synchronization before eye +// transforms become eligible. + +#include "stereo_renderer_r13.cpp" +#include "../runtime_eligibility.hpp" + +namespace OutRunVRStereo +{ + namespace + { + SafetyHookInline R20ClearR9Hook{}; + std::atomic R20InstallState{ + OutRunVR::RuntimeEligibility::InstallState::Pending }; + // CI/diagnostic invariant: terminal installer failure is InstallState::Failed; + // local worker code uses the State alias below without changing semantics. + + // Compatibility gate consumed by R21/R23 overlays. RuntimeEligibility is + // authoritative; this mirror remains so older R20 call sites fail closed. + std::atomic R20StereoEligibilityGate{false}; + + bool R20FirstRelaxedSeedLogged = false; + bool R20FirstLegacySeedValidatedLogged = false; + std::uint64_t R20RelaxedSeeds = 0; + std::uint64_t R20LegacySeedsRejected = 0; + std::uint64_t R20BaselineCopyFailures = 0; + std::uint64_t R20DepthGateRejects = 0; + + // Track Z and stencil independently. Never OR clear flags from different + // moments into one synthetic baseline: each required buffer must belong + // to this Present, this depth generation, and have no draw after clear. + std::uint64_t R20DepthClearEpoch = 0; + std::uint64_t R20DepthClearDrawSerial = 0; + std::uint64_t R20DepthClearGeneration = 0; + std::uint64_t R20StencilClearEpoch = 0; + std::uint64_t R20StencilClearDrawSerial = 0; + std::uint64_t R20StencilClearGeneration = 0; + + void R20ObserveFullDepthSeed(IDirect3DDevice9* device, DWORD count, + const D3DRECT* rects, DWORD flags, HRESULT hr) noexcept + { + if (FAILED(hr) || !device || !IsGameDevice(device) || InternalStereoPass || + !TargetIsBackBuffer() || R9DeferredDepth || !R9CurrentDepthCanMirror() || + !R9MonoDepth || R9MonoBackupGap) + return; + + const DWORD depthFlags = flags & (D3DCLEAR_ZBUFFER | D3DCLEAR_STENCIL); + if (!depthFlags || !ClearCoversStereoBackbuffer(device, count, rects)) + return; + + if ((depthFlags & D3DCLEAR_ZBUFFER) != 0) + { + R20DepthClearEpoch = PresentEpoch; + R20DepthClearDrawSerial = R9DrawCalls; + R20DepthClearGeneration = R9MainDepthGeneration; + } + if ((depthFlags & D3DCLEAR_STENCIL) != 0) + { + R20StencilClearEpoch = PresentEpoch; + R20StencilClearDrawSerial = R9DrawCalls; + R20StencilClearGeneration = R9MainDepthGeneration; + } + } + + bool R20DepthHistorySafeForInitialSeed(IDirect3DDevice9* device) noexcept + { + if (!TrackedDepthStencil) + return true; + + const bool depthSafe = + R20DepthClearEpoch == PresentEpoch && + R20DepthClearDrawSerial == R9DrawCalls && + R20DepthClearGeneration == R9MainDepthGeneration && + R9MonoDepth != nullptr; + + bool stencilSafe = true; + if (StencilTestActive(device)) + { + stencilSafe = + R20StencilClearEpoch == PresentEpoch && + R20StencilClearDrawSerial == R9DrawCalls && + R20StencilClearGeneration == R9MainDepthGeneration; + } + + const bool safe = depthSafe && stencilSafe; + if (!safe) + ++R20DepthGateRejects; + return safe; + } + + void R20CancelInitialSeed(IDirect3DDevice9* device) noexcept + { + R9StereoSeeded = false; + R9MonoSeeded = false; + if (TrackedDepthStencil) + { + RightDepthSynchronized = false; + if (StencilTestActive(device)) + RightStencilSynchronized = false; + } + OutRunVR::RuntimeEligibility::StereoAllowed.store(false, std::memory_order_release); + OutRunVR::RuntimeEligibility::RecoveryPending.store(true, std::memory_order_release); + R20StereoEligibilityGate.store(false, std::memory_order_release); + } + + void R20AcceptVerifiedBaseline() noexcept + { + OutRunVR::RuntimeEligibility::BaselineVerified(); + const bool allowed = OutRunVR::RuntimeEligibility::MayInjectStereo(); + R20StereoEligibilityGate.store(allowed, std::memory_order_release); + } + + bool R20BuildCompleteColorBaseline(IDirect3DDevice9* device) noexcept + { + if (!device || !BackBuffer || !RightEyeSurface || !R9MonoSurface) + return false; + + InternalPassScope guard; + const HRESULT rightHr = device->StretchRect( + BackBuffer, nullptr, RightEyeSurface, nullptr, D3DTEXF_NONE); + const HRESULT monoHr = SUCCEEDED(rightHr) + ? device->StretchRect( + BackBuffer, nullptr, R9MonoSurface, nullptr, D3DTEXF_NONE) + : rightHr; + if (FAILED(rightHr) || FAILED(monoHr)) + { + ++R20BaselineCopyFailures; + return false; + } + return true; + } + + HRESULT __stdcall ClearDestR20(IDirect3DDevice9* device, DWORD count, + const D3DRECT* rects, DWORD flags, D3DCOLOR color, float z, + DWORD stencil) + { + const bool seededBefore = R9StereoSeeded; + const HRESULT hr = R20ClearR9Hook.stdcall( + device, count, rects, flags, color, z, stencil); + + R20ObserveFullDepthSeed(device, count, rects, flags, hr); + + if (!OutRunVR::RuntimeEligibility::HostFresh.load(std::memory_order_acquire)) + { + R20CancelInitialSeed(device); + return hr; + } + + if (FAILED(hr) || !OutRunVR::RuntimeEligibility::IsReady(R20InstallState) || + !IsGameDevice(device) || InternalStereoPass) + return hr; + + const bool legacyFirstSeed = !seededBefore && R9StereoSeeded; + if (legacyFirstSeed) + { + if (!R20DepthHistorySafeForInitialSeed(device)) + { + ++R20LegacySeedsRejected; + R20CancelInitialSeed(device); + return hr; + } + + R20AcceptVerifiedBaseline(); + if (!OutRunVR::RuntimeEligibility::MayInjectStereo()) + { + R20CancelInitialSeed(device); + return hr; + } + + if (!R20FirstLegacySeedValidatedLogged) + { + R20FirstLegacySeedValidatedLogged = true; + spdlog::info( + "VR R20/R23: legacy full-color first seed accepted only after common depth/stencil generation+draw-serial validation"); + } + return hr; + } + + if ((flags & D3DCLEAR_TARGET) == 0 || R9StereoSeeded) + return hr; + + if (!StereoWanted() || !TargetIsBackBuffer() || R9DeferredDepth || + !R9CurrentDepthCanMirror() || !R9MonoSurface || + R9MonoBackupGap || FrameStereoIncomplete || + FrameFailureReason != OutRunVR::StereoFailureNone) + return hr; + + if (!R20DepthHistorySafeForInitialSeed(device)) + return hr; + + if (!R20BuildCompleteColorBaseline(device)) + { + R9MonoBackupGap = true; + R9Poison(OutRunVR::StereoFailureResourceUnavailable, + "R20/bootstrap-color-baseline"); + return hr; + } + + R20AcceptVerifiedBaseline(); + if (!OutRunVR::RuntimeEligibility::MayInjectStereo()) + return hr; + + R9StereoSeeded = true; + R9MonoSeeded = true; + R9MonoBackupGap = false; + ++R20RelaxedSeeds; + + if (TrackedDepthStencil) + { + std::uint64_t baseline = std::max( + R9MainDepthContentSerial, R9MonoDepthContentSerial); + if (++baseline == 0) + baseline = 1; + R9MainDepthContentSerial = baseline; + R9MonoDepthContentSerial = baseline; + RightDepthSynchronized = true; + if (StencilTestActive(device)) + RightStencilSynchronized = true; + } + + if (!R20FirstRelaxedSeedLogged) + { + R20FirstRelaxedSeedLogged = true; + spdlog::info( + "VR R20/R23: relaxed bootstrap accepted after complete right+mono color baseline and common depth/stencil generation/draw-serial validation (rectCount={}, flags=0x{:08x})", + count, static_cast(flags)); + } + return hr; + } + + DWORD WINAPI R20InstallThread(void*) + { + using State = OutRunVR::RuntimeEligibility::InstallState; + R20InstallState.store(State::Pending, std::memory_order_release); + for (int attempt = 0; attempt < 4800; ++attempt) + { + const std::uint32_t r9 = R9InstallState.load(std::memory_order_acquire); + const std::uint32_t r13 = R13InstallState.load(std::memory_order_acquire); + + if (r9 == R9InstallFailed || r13 == R13InstallFailed) + { + R20InstallState.store(State::Failed, std::memory_order_release); + spdlog::error( + "VR R20: base R9/R13 transaction failed; relaxed bootstrap not installed"); + return 0; + } + + if (r9 == R9InstallReady && r13 == R13InstallReady) + { + R20ClearR9Hook = safetyhook::create_inline( + reinterpret_cast(&ClearDestR9), ClearDestR20, + safetyhook::InlineHook::StartDisabled); + const bool enabled = R20ClearR9Hook && + R20ClearR9Hook.enable().has_value(); + if (!enabled) + { + R20ClearR9Hook = {}; + R20InstallState.store(State::Failed, std::memory_order_release); + spdlog::error( + "VR R20: failed to enable R9 clear bootstrap transaction; base fail-closed policy remains active"); + return 0; + } + R20InstallState.store(State::Ready, std::memory_order_release); + spdlog::info( + "VR R20 PRODUCTION: first stereo seed requires current-generation Z/stencil clears; disabled-first bootstrap transaction READY"); + return 0; + } + Sleep(25); + } + + R20InstallState.store(State::Failed, std::memory_order_release); + spdlog::warn( + "VR R20: timed out waiting for R9/R13 renderer transaction; bootstrap overlay FAILED"); + return 0; + } + + class VRProductionBootstrapR20Hook : public Hook + { + public: + std::string_view description() override + { + return "OpenXRVRProductionBootstrapR20"; + } + + bool validate() override { return true; } + + bool apply() override + { + using State = OutRunVR::RuntimeEligibility::InstallState; + R20InstallState.store(State::Pending, std::memory_order_release); + HANDLE thread = CreateThread(nullptr, 0, R20InstallThread, nullptr, 0, nullptr); + if (!thread) + { + R20InstallState.store(State::Failed, std::memory_order_release); + spdlog::error( + "VR R20: failed to create bootstrap installer thread: {}", + GetLastError()); + return false; + } + CloseHandle(thread); + return true; + } + + static VRProductionBootstrapR20Hook instance; + }; + + VRProductionBootstrapR20Hook VRProductionBootstrapR20Hook::instance; + } +} diff --git a/src/vr/d3d9/stereo_renderer_r21.cpp b/src/vr/d3d9/stereo_renderer_r21.cpp new file mode 100644 index 000000000..bfc3b20d9 --- /dev/null +++ b/src/vr/d3d9/stereo_renderer_r21.cpp @@ -0,0 +1,302 @@ +// R21 game-side host-death fail-closed overlay. +// +// R20/R13/R9 remain the renderer implementation. R21 owns the Present-boundary +// host freshness decision. R23 review hardening aligns that decision with the +// 250 ms pose-reader budget and publishes it through RuntimeEligibility so WVP +// injection, stereo replay and bootstrap share one authority. + +#include "stereo_renderer_r20.cpp" + +namespace OutRunVRStereo +{ + namespace + { + constexpr std::int64_t R21HostStaleMs = + OutRunVR::RuntimeEligibility::HostStaleMs; + + SafetyHookInline R21PresentR9Hook{}; + std::atomic R21InstallState{ + OutRunVR::RuntimeEligibility::InstallState::Pending }; + bool R21HostFailClosed = true; + bool R21HostSoftSuspended = false; + std::uint32_t R21LastHealthyHostPid = 0; + ULONGLONG R21LastHealthyTickMs = 0; + ULONGLONG R21LastTransientGraceLogMs = 0; + std::uint64_t R21TransientInvalidReadGrace = 0; + + enum class R21HostStatus : std::uint8_t + { + Stale, + SoftSuspend, + Fresh + }; + + bool R21ComputeAgeMs(std::int64_t sampleQpc, + std::int64_t& ageMs) noexcept + { + ageMs = INT64_MAX; + if (sampleQpc <= 0) + return false; + LARGE_INTEGER now{}; + LARGE_INTEGER frequency{}; + if (!QueryPerformanceCounter(&now) || + !QueryPerformanceFrequency(&frequency) || + frequency.QuadPart <= 0 || now.QuadPart < sampleQpc) + return false; + const std::int64_t delta = now.QuadPart - sampleQpc; + ageMs = (delta * 1000) / frequency.QuadPart; + return true; + } + + R21HostStatus R21ReadHostFreshness(std::uint32_t& hostPid, + std::uint32_t& flags, std::uint32_t& heartbeat, + std::int64_t& sampleQpc, std::int64_t& ageMs) noexcept + { + hostPid = flags = heartbeat = 0; + sampleQpc = 0; + ageMs = INT64_MAX; + + if (!SharedState || SharedState->magic != OutRunVR::SharedMagic || + SharedState->protocolVersion != OutRunVR::SharedProtocolVersion || + SharedState->structSize != sizeof(OutRunVR::SharedPoseState)) + return R21HostStatus::Stale; + + for (int attempt = 0; attempt < 4; ++attempt) + { + const std::uint32_t before = SharedState->sequence; + if (before & 1u) + continue; + MemoryBarrier(); + const std::uint32_t pid = SharedState->hostPid; + const std::uint32_t currentFlags = SharedState->flags; + const std::uint32_t currentHeartbeat = SharedState->heartbeat; + const std::int64_t currentSampleQpc = SharedState->sampleQpc; + MemoryBarrier(); + const std::uint32_t after = SharedState->sequence; + if (before != after || (after & 1u)) + continue; + + hostPid = pid; + flags = currentFlags; + heartbeat = currentHeartbeat; + sampleQpc = currentSampleQpc; + + constexpr std::uint32_t hardRequired = + OutRunVR::HostAlive | OutRunVR::SessionVisible; + if (!hostPid || (flags & hardRequired) != hardRequired || + !R21ComputeAgeMs(sampleQpc, ageMs) || + ageMs > R21HostStaleMs) + return R21HostStatus::Stale; + + if ((flags & OutRunVR::HostShouldRender) == 0) + return R21HostStatus::SoftSuspend; + return R21HostStatus::Fresh; + } + return R21HostStatus::Stale; + } + + void R21ApplyHostFailClosedAtPresent() noexcept + { + std::uint32_t hostPid = 0; + std::uint32_t flags = 0; + std::uint32_t heartbeat = 0; + std::int64_t sampleQpc = 0; + std::int64_t ageMs = INT64_MAX; + const R21HostStatus status = R21ReadHostFreshness( + hostPid, flags, heartbeat, sampleQpc, ageMs); + + if (status == R21HostStatus::Fresh) + { + R21LastHealthyHostPid = hostPid; + R21LastHealthyTickMs = GetTickCount64(); + OutRunVR::RuntimeEligibility::ObserveFreshHost(); + + if (R21HostFailClosed) + { + R9StereoSeeded = false; + R9MonoSeeded = false; + R9MonoBackupGap = false; + R20StereoEligibilityGate.store(false, std::memory_order_release); + R21HostFailClosed = false; + R21HostSoftSuspended = false; + spdlog::info( + "VR R21/R23: fresh host recovered pid={} heartbeat={}; waiting for a new verified stereo baseline before WVP/stereo resume", + hostPid, heartbeat); + } + else + { + const bool resumedSoft = R21HostSoftSuspended; + R21HostSoftSuspended = false; + R20StereoEligibilityGate.store( + OutRunVR::RuntimeEligibility::MayInjectStereo(), + std::memory_order_release); + if (resumedSoft) + { + spdlog::info( + "VR R21 SOFT-RESUME: shouldRender returned; game-side stereo source stayed continuous"); + } + } + return; + } + + if (status == R21HostStatus::SoftSuspend) + { + R21LastHealthyHostPid = hostPid; + R21LastHealthyTickMs = GetTickCount64(); + OutRunVR::RuntimeEligibility::ObserveSoftHostSuspend(); + R20StereoEligibilityGate.store( + OutRunVR::RuntimeEligibility::MayInjectStereo(), + std::memory_order_release); + if (!R21HostSoftSuspended) + { + R21HostSoftSuspended = true; + spdlog::info( + "VR R21 SOFT-SUSPEND: shouldRender=0 is host-only scheduling advice; verified game-side stereo source remains active without baseline reset"); + } + return; + } + + // A single invalid/zero shared-state sample was observed in the + // supplied run while the host heartbeat resumed on the very next + // Present. Honor the same 250 ms freshness budget before destroying + // the verified baseline. This still fail-closes promptly on a real + // host death, but avoids a full recovery cycle for a transient + // seqlock/mapping handoff. + const ULONGLONG nowMs = GetTickCount64(); + if (!R21HostFailClosed && R21LastHealthyTickMs != 0 && + nowMs >= R21LastHealthyTickMs && + nowMs - R21LastHealthyTickMs <= + static_cast(R21HostStaleMs)) + { + ++R21TransientInvalidReadGrace; + if (R21LastTransientGraceLogMs == 0 || + nowMs - R21LastTransientGraceLogMs >= 5000) + { + R21LastTransientGraceLogMs = nowMs; + spdlog::info( + "VR R21 TRANSIENT-GRACE: one invalid host sample kept the verified stereo baseline alive within {}ms freshness budget (count={})", + R21HostStaleMs, R21TransientInvalidReadGrace); + } + return; + } + + R21HostSoftSuspended = false; + OutRunVR::RuntimeEligibility::FailClosed(); + R20StereoEligibilityGate.store(false, std::memory_order_release); + + if (R9MonoSeeded && !R9MonoBackupGap) + R9StereoSeeded = false; + else + { + R9StereoSeeded = false; + R9MonoSeeded = false; + } + + if (!R21HostFailClosed) + { + R21HostFailClosed = true; + spdlog::warn( + "VR R21 FAIL-CLOSED: OpenXR host stale/unavailable (lastPid={} currentPid={} heartbeat={} flags=0x{:08x} ageMs={} threshold={}ms); shared WVP/stereo/bootstrap gate closed", + R21LastHealthyHostPid, hostPid, heartbeat, + static_cast(flags), + ageMs == INT64_MAX ? -1 : ageMs, + R21HostStaleMs); + } + } + + HRESULT __stdcall PresentDestR21(IDirect3DDevice9* device, + const RECT* sourceRect, const RECT* destRect, + HWND destWindowOverride, const RGNDATA* dirtyRegion) + { + if (IsGameDevice(device)) + R21ApplyHostFailClosedAtPresent(); + return R21PresentR9Hook.stdcall(device, sourceRect, + destRect, destWindowOverride, dirtyRegion); + } + + DWORD WINAPI R21GameInstallThread(void*) + { + using State = OutRunVR::RuntimeEligibility::InstallState; + R21InstallState.store(State::Pending, std::memory_order_release); + OutRunVR::RuntimeEligibility::FailClosed(); + R20StereoEligibilityGate.store(false, std::memory_order_release); + + for (int attempt = 0; attempt < 4800; ++attempt) + { + const std::uint32_t r9 = R9InstallState.load(std::memory_order_acquire); + const std::uint32_t r13 = R13InstallState.load(std::memory_order_acquire); + + if (r9 == R9InstallFailed || r13 == R13InstallFailed || + OutRunVR::RuntimeEligibility::IsFailed(R20InstallState)) + { + R21InstallState.store(State::Failed, std::memory_order_release); + spdlog::error( + "VR R21: prerequisite R9/R13/R20 transaction failed; host-death Present guard not installed"); + return 0; + } + + if (r9 == R9InstallReady && r13 == R13InstallReady && + OutRunVR::RuntimeEligibility::IsReady(R20InstallState)) + { + R21PresentR9Hook = safetyhook::create_inline( + reinterpret_cast(&PresentDestR9), PresentDestR21, + safetyhook::InlineHook::StartDisabled); + const bool enabled = R21PresentR9Hook && + R21PresentR9Hook.enable().has_value(); + if (!enabled) + { + R21PresentR9Hook = {}; + R21InstallState.store(State::Failed, std::memory_order_release); + spdlog::error( + "VR R21: failed to enable R9 Present host-death guard transaction"); + return 0; + } + R21InstallState.store(State::Ready, std::memory_order_release); + spdlog::info( + "VR R21/R23 GAME: common host freshness gate ACTIVE threshold={}ms; disabled-first transaction READY; recovery requires a new verified baseline", + R21HostStaleMs); + return 0; + } + Sleep(25); + } + + R21InstallState.store(State::Failed, std::memory_order_release); + spdlog::warn( + "VR R21: timed out waiting for prerequisite renderer transactions; host-death guard FAILED"); + return 0; + } + + class VRHostDeathFailClosedR21Hook : public Hook + { + public: + std::string_view description() override + { + return "OpenXRVRHostDeathFailClosedR21"; + } + + bool validate() override { return true; } + + bool apply() override + { + using State = OutRunVR::RuntimeEligibility::InstallState; + R21InstallState.store(State::Pending, std::memory_order_release); + HANDLE thread = CreateThread( + nullptr, 0, R21GameInstallThread, nullptr, 0, nullptr); + if (!thread) + { + R21InstallState.store(State::Failed, std::memory_order_release); + spdlog::error( + "VR R21: failed to create host-death guard installer thread: {}", + GetLastError()); + return false; + } + CloseHandle(thread); + return true; + } + + static VRHostDeathFailClosedR21Hook instance; + }; + + VRHostDeathFailClosedR21Hook VRHostDeathFailClosedR21Hook::instance; + } +} diff --git a/src/vr/d3d9/stereo_renderer_r22.cpp b/src/vr/d3d9/stereo_renderer_r22.cpp new file mode 100644 index 000000000..cece65acc --- /dev/null +++ b/src/vr/d3d9/stereo_renderer_r22.cpp @@ -0,0 +1,875 @@ +// R22 pre-hardware-test hardening overlay. +// +// Keeps the R21/R20/R13/R9 implementation intact while closing review gaps +// that are easiest to enforce at the final game-side callback boundary: +// * preserve the game's viewport/scissor state across internal replay; +// * shadow viewport/scissor state instead of querying D3D9 on every draw; +// * validate every first stereo seed against one depth/stencil baseline; +// * make the R21 game-local host-liveness gate authoritative; +// * after D3D9 Reset, require a new host-fresh verified baseline; +// * install every overlay hook disabled-first and publish READY only after the +// complete callback transaction is enabled. + +#include "stereo_renderer_r21.cpp" + +namespace OutRunVRStereo +{ + namespace + { + constexpr std::size_t R22SetViewportVtableIndex = 47; + constexpr std::size_t R22SetRenderStateVtableIndex = 57; + constexpr std::size_t R22CreateStateBlockVtableIndex = 59; + constexpr std::size_t R22BeginStateBlockVtableIndex = 60; + constexpr std::size_t R22EndStateBlockVtableIndex = 61; + constexpr std::size_t R22SetScissorRectVtableIndex = 75; + constexpr std::size_t R22StateBlockApplyVtableIndex = 5; + + SafetyHookInline R22SetViewportHook{}; + SafetyHookInline R22SetRenderStateHook{}; + SafetyHookInline R22SetScissorRectHook{}; + SafetyHookInline R22CreateStateBlockHook{}; + SafetyHookInline R22BeginStateBlockHook{}; + SafetyHookInline R22EndStateBlockHook{}; + SafetyHookInline R22StateBlockApplyHook{}; + void* R22StateBlockApplyTarget = nullptr; + std::atomic R22StateBlockTrackingReliable{ false }; + SafetyHookInline R22ResetR13Hook{}; + SafetyHookInline R22ClearR20Hook{}; + SafetyHookInline R22DrawPrimitiveR9Hook{}; + SafetyHookInline R22DrawIndexedPrimitiveR9Hook{}; + SafetyHookInline R22DrawPrimitiveUPR9Hook{}; + SafetyHookInline R22DrawIndexedPrimitiveUPR9Hook{}; + std::atomic R22InstallState{ + OutRunVR::RuntimeEligibility::InstallState::Pending }; + + struct R22ScissorSnapshot + { + RECT rect{}; + DWORD enabled = FALSE; + D3DVIEWPORT9 viewport{}; + bool viewportValid = false; + bool rectValid = false; + bool enableValid = false; + + bool Valid() const noexcept + { + return viewportValid && rectValid && enableValid; + } + }; + + thread_local R22ScissorSnapshot R22GameScissor{}; + thread_local R22ScissorSnapshot R22ShadowState{}; + thread_local std::uint32_t R22InternalReplayDepth = 0; + thread_local bool R22InternalViewportTouched = false; + + std::uint64_t R22DepthClearEpoch = 0; + std::uint64_t R22DepthClearDrawSerial = 0; + std::uint64_t R22DepthClearGeneration = 0; + std::uint64_t R22StencilClearEpoch = 0; + std::uint64_t R22StencilClearDrawSerial = 0; + std::uint64_t R22StencilClearGeneration = 0; + std::uint64_t R22RejectedInitialSeeds = 0; + std::uint64_t R22ReplayStateCaptureFailures = 0; + std::uint64_t R22ReplayStateGetterFallbacks = 0; +#if defined(OUTRUN_VR_FORCE_LIVE_RASTER_COMPARE) + std::uint64_t R22ForcedLiveRasterSamples = 0; + bool R22FirstForcedLiveRasterLogged = false; +#endif + bool R22FirstSeedRejectLogged = false; + bool R22FirstReplayStateCaptureFailureLogged = false; + + void R22FailClosedEligibility() noexcept + { + OutRunVR::RuntimeEligibility::FailClosed(); + R20StereoEligibilityGate.store(false, std::memory_order_release); + R9StereoSeeded = false; + R9MonoSeeded = false; + } + + void R22ResetBaselineTracking() noexcept + { + R20DepthClearEpoch = 0; + R20DepthClearDrawSerial = 0; + R20DepthClearGeneration = 0; + R20StencilClearEpoch = 0; + R20StencilClearDrawSerial = 0; + R20StencilClearGeneration = 0; + R22DepthClearEpoch = 0; + R22DepthClearDrawSerial = 0; + R22DepthClearGeneration = 0; + R22StencilClearEpoch = 0; + R22StencilClearDrawSerial = 0; + R22StencilClearGeneration = 0; + } + + bool R22CaptureGameScissor(IDirect3DDevice9* device, + R22ScissorSnapshot& out) noexcept + { + out = {}; + if (!device || FAILED(device->GetViewport(&out.viewport)) || + FAILED(device->GetScissorRect(&out.rect)) || + FAILED(device->GetRenderState(D3DRS_SCISSORTESTENABLE, &out.enabled))) + return false; + out.viewportValid = out.rectValid = out.enableValid = true; + return true; + } + + bool R22PrimeShadowState(IDirect3DDevice9* device) noexcept + { + R22ScissorSnapshot captured{}; + if (!R22CaptureGameScissor(device, captured)) + { + R22ShadowState = {}; + return false; + } + R22ShadowState = captured; + return true; + } + + void R22ResynchronizeShaderEpoch(IDirect3DDevice9* device) noexcept + { + IDirect3DVertexShader9* shader = nullptr; + const HRESULT hr = device + ? device->GetVertexShader(&shader) : D3DERR_INVALIDCALL; + const std::uintptr_t identity = SUCCEEDED(hr) + ? reinterpret_cast(shader) : 0; + if (shader) shader->Release(); + const std::uintptr_t previous = + CurrentVertexShaderIdentity.exchange( + identity, std::memory_order_acq_rel); + if (previous != identity) + { + std::uint64_t serial = + VertexShaderSerial.fetch_add(1, std::memory_order_acq_rel) + 1; + if (serial == 0) + VertexShaderSerial.fetch_add(1, std::memory_order_acq_rel); + } + } + + void R22InvalidateAfterStateBlockApply( + IDirect3DDevice9* device) noexcept + { + R22ShadowState = {}; + InvalidateTrackedRenderStates(); + R22ResynchronizeShaderEpoch(device); + // Re-prime once at the Apply boundary instead of paying + // GetViewport/GetScissorRect/GetRenderState on subsequent draws. + if (!R22PrimeShadowState(device)) + R22StateBlockTrackingReliable.store( + false, std::memory_order_release); + } + + HRESULT __stdcall R22StateBlockApplyDest(IDirect3DStateBlock9* block) + { + const HRESULT hr = + R22StateBlockApplyHook.stdcall(block); + IDirect3DDevice9* device = nullptr; + if (block && SUCCEEDED(block->GetDevice(&device)) && device) + { + if (IsGameDevice(device) && SUCCEEDED(hr)) + R22InvalidateAfterStateBlockApply(device); + device->Release(); + } + else + { + R22StateBlockTrackingReliable.store( + false, std::memory_order_release); + } + return hr; + } + + bool R22EnsureStateBlockApplyHook( + IDirect3DStateBlock9* block) noexcept + { + if (!block) + return false; + void** vtable = *reinterpret_cast(block); + if (!vtable) + return false; + void* target = vtable[R22StateBlockApplyVtableIndex]; + if (R22StateBlockApplyHook) + { + const bool same = R22StateBlockApplyTarget == target; + if (!same) + R22StateBlockTrackingReliable.store( + false, std::memory_order_release); + return same; + } + R22StateBlockApplyHook = safetyhook::create_inline( + target, R22StateBlockApplyDest, + safetyhook::InlineHook::StartDisabled); + if (!R22StateBlockApplyHook || + !R22StateBlockApplyHook.enable().has_value()) + { + R22StateBlockApplyHook = {}; + R22StateBlockApplyTarget = nullptr; + R22StateBlockTrackingReliable.store( + false, std::memory_order_release); + return false; + } + R22StateBlockApplyTarget = target; + R22StateBlockTrackingReliable.store( + true, std::memory_order_release); + spdlog::info( + "VR R46 STATE: StateBlock::Apply interception proven; viewport/scissor/render-state caches invalidate at Apply boundary"); + return true; + } + + HRESULT __stdcall CreateStateBlockDestR22(IDirect3DDevice9* device, + D3DSTATEBLOCKTYPE type, IDirect3DStateBlock9** block) + { + const HRESULT hr = R22CreateStateBlockHook.stdcall( + device, type, block); + if (SUCCEEDED(hr) && IsGameDevice(device) && block && *block) + R22EnsureStateBlockApplyHook(*block); + return hr; + } + + HRESULT __stdcall BeginStateBlockDestR22(IDirect3DDevice9* device) + { + const HRESULT hr = + R22BeginStateBlockHook.stdcall(device); + if (SUCCEEDED(hr) && IsGameDevice(device) && !InternalStereoPass) + { + R22ShadowState = {}; + InvalidateTrackedRenderStates(); + } + return hr; + } + + HRESULT __stdcall EndStateBlockDestR22(IDirect3DDevice9* device, + IDirect3DStateBlock9** block) + { + const HRESULT hr = + R22EndStateBlockHook.stdcall(device, block); + if (IsGameDevice(device) && !InternalStereoPass) + { + R22ShadowState = {}; + InvalidateTrackedRenderStates(); + if (SUCCEEDED(hr) && block && *block) + R22EnsureStateBlockApplyHook(*block); + } + return hr; + } + + bool R22SnapshotShadowedGameState(IDirect3DDevice9* device, + R22ScissorSnapshot& out) noexcept + { +#if defined(OUTRUN_VR_FORCE_LIVE_RASTER_COMPARE) + // C1/C2 raster-correctness comparison: these short chains exclude + // R31's StateBlock::Apply interception. OutRun applies StateBlocks + // without traversing SetViewport/SetScissorRect/SetRenderState, so + // trusting R22ShadowState can replay an old enabled scissor into the + // RIGHT eye. That leaves rectangular pieces of the baseline/previous + // image untouched while the LEFT eye remains correct. + // + // Read the three raster values at the outer replay boundary instead. + // This intentionally trades some CPU time for an authoritative A/B + // result. Once confirmed on hardware we can replace the getters with + // a small StateBlock generation tracker without re-enabling R31-R34. + ++R22ReplayStateGetterFallbacks; + ++R22ForcedLiveRasterSamples; + if (!R22CaptureGameScissor(device, out)) + { + R22ShadowState = {}; + return false; + } + R22ShadowState = out; + if (!R22FirstForcedLiveRasterLogged) + { + R22FirstForcedLiveRasterLogged = true; + spdlog::warn( + "VR C1/C2 RASTER FIX: live viewport/scissor/SCISSORTEST sampled at every stereo replay; stale StateBlock raster cache cannot clip the RIGHT eye"); + } + return true; +#else + if (R22ShadowState.Valid()) + { + out = R22ShadowState; + return true; + } + ++R22ReplayStateGetterFallbacks; + if (!R22CaptureGameScissor(device, out)) + return false; + R22ShadowState = out; + return true; +#endif + } + + bool R22ApplyGameScissor(IDirect3DDevice9* device, + const R22ScissorSnapshot& state) noexcept + { + if (!device || !state.Valid()) + return false; + bool ok = true; + if (FAILED(device->SetScissorRect(&state.rect))) + ok = false; + if (FAILED(device->SetRenderState( + D3DRS_SCISSORTESTENABLE, state.enabled))) + ok = false; + return ok; + } + + struct R22ReplayScope + { + IDirect3DDevice9* device = nullptr; + bool outer = false; + bool stateValid = false; + + explicit R22ReplayScope(IDirect3DDevice9* d) noexcept : device(d) + { + outer = R22InternalReplayDepth++ == 0; + if (outer) + { + R22InternalViewportTouched = false; + stateValid = R22SnapshotShadowedGameState(device, R22GameScissor); + } + else + stateValid = R22GameScissor.Valid(); + } + + ~R22ReplayScope() + { + if (R22InternalReplayDepth) + --R22InternalReplayDepth; + if (outer) + { + bool restored = true; + if (R22GameScissor.Valid()) + { + // SetViewportDestR22 intentionally restores only scissor state + // while an internal eye pass is active. Restoring the viewport + // there would immediately undo the eye-specific viewport and can + // recurse through the detour. Once the complete outer replay has + // returned, use the SafetyHook trampoline to restore the game's + // original viewport without re-entering SetViewportDestR22. + if (R22InternalViewportTouched && + (!R22SetViewportHook || + FAILED(R22SetViewportHook.stdcall( + device, &R22GameScissor.viewport)))) + { + restored = false; + } + if (!R22ApplyGameScissor(device, R22GameScissor)) + restored = false; + } + if (!restored) + { + R20CancelInitialSeed(device); + R9MonoBackupGap = true; + NoteRestoreFailure("R22 final viewport/scissor restore"); + } + R22InternalViewportTouched = false; + R22GameScissor = {}; + } + } + }; + + void R22FailClosedReplayState(IDirect3DDevice9* device, + const char* site) noexcept + { + ++R22ReplayStateCaptureFailures; + R20CancelInitialSeed(device); + R9MonoBackupGap = true; + R9Poison(OutRunVR::StereoFailureViewportUnavailable, site); + if (!R22FirstReplayStateCaptureFailureLogged) + { + R22FirstReplayStateCaptureFailureLogged = true; + spdlog::warn( + "VR R22 FAIL-CLOSED: unable to capture viewport/scissor state; stereo replay disabled until a new verified baseline"); + } + } + + HRESULT __stdcall SetViewportDestR22(IDirect3DDevice9* device, + const D3DVIEWPORT9* viewport) + { + const HRESULT hr = R22SetViewportHook.stdcall(device, viewport); + if (SUCCEEDED(hr) && IsGameDevice(device)) + { + if (!InternalStereoPass && viewport) + { + R22ShadowState.viewport = *viewport; + R22ShadowState.viewportValid = true; + } + else if (InternalStereoPass && R22InternalReplayDepth && + R22GameScissor.Valid()) + { + R22InternalViewportTouched = true; + if (!R22ApplyGameScissor(device, R22GameScissor)) + NoteRestoreFailure("R22 scissor replay"); + } + } + return hr; + } + + HRESULT __stdcall SetScissorRectDestR22(IDirect3DDevice9* device, + const RECT* rect) + { + const HRESULT hr = R22SetScissorRectHook.stdcall(device, rect); + if (SUCCEEDED(hr) && IsGameDevice(device) && !InternalStereoPass && rect) + { + R22ShadowState.rect = *rect; + R22ShadowState.rectValid = true; + } + return hr; + } + + HRESULT __stdcall SetRenderStateDestR22(IDirect3DDevice9* device, + D3DRENDERSTATETYPE state, DWORD value) + { + const HRESULT hr = R22SetRenderStateHook.stdcall(device, state, value); + if (SUCCEEDED(hr) && IsGameDevice(device) && !InternalStereoPass && + state == D3DRS_SCISSORTESTENABLE) + { + R22ShadowState.enabled = value; + R22ShadowState.enableValid = true; + } + return hr; + } + + bool R22RectCoversBackbuffer(const RECT& r) noexcept + { + return r.left <= 0 && r.top <= 0 && + r.right >= static_cast(BackBufferDesc.Width) && + r.bottom >= static_cast(BackBufferDesc.Height); + } + + bool R22GameClearCoversBackbuffer(DWORD count, + const D3DRECT* rects, const R22ScissorSnapshot& state) noexcept + { + if (!state.Valid() || !BackBufferDesc.Width || !BackBufferDesc.Height) + return false; + if (state.viewport.X != 0 || state.viewport.Y != 0 || + state.viewport.Width != BackBufferDesc.Width || + state.viewport.Height != BackBufferDesc.Height) + return false; + if (state.enabled && !R22RectCoversBackbuffer(state.rect)) + return false; + if (count == 0) + return true; + if (!rects) + return false; + for (DWORD i = 0; i < count; ++i) + { + const auto& r = rects[i]; + if (r.x1 <= 0 && r.y1 <= 0 && + r.x2 >= static_cast(BackBufferDesc.Width) && + r.y2 >= static_cast(BackBufferDesc.Height)) + return true; + } + return false; + } + + void R22ObserveDepthBaseline(bool gameMainTarget, bool fullGameClear, + DWORD flags, HRESULT hr) noexcept + { + if (FAILED(hr) || !gameMainTarget || !fullGameClear || + R9DeferredDepth || !R9CurrentDepthCanMirror() || + !R9MonoDepth || R9MonoBackupGap) + return; + + if ((flags & D3DCLEAR_ZBUFFER) != 0) + { + R22DepthClearEpoch = PresentEpoch; + R22DepthClearDrawSerial = R9DrawCalls; + R22DepthClearGeneration = R9MainDepthGeneration; + } + if ((flags & D3DCLEAR_STENCIL) != 0) + { + R22StencilClearEpoch = PresentEpoch; + R22StencilClearDrawSerial = R9DrawCalls; + R22StencilClearGeneration = R9MainDepthGeneration; + } + } + + bool R22InitialDepthBaselineSafe(IDirect3DDevice9* device) noexcept + { + if (!TrackedDepthStencil) + return true; + const bool depthSafe = + R22DepthClearEpoch == PresentEpoch && + R22DepthClearDrawSerial == R9DrawCalls && + R22DepthClearGeneration == R9MainDepthGeneration && + R9MonoDepth != nullptr; + if (!depthSafe) + return false; + if (!StencilTestActive(device)) + return true; + return R22StencilClearEpoch == PresentEpoch && + R22StencilClearDrawSerial == R9DrawCalls && + R22StencilClearGeneration == R9MainDepthGeneration; + } + + void R22CancelUnsafeFirstSeed(IDirect3DDevice9* device, + bool seededBefore, bool fullGameClear) noexcept + { + if (seededBefore || !R9StereoSeeded) + return; + if (fullGameClear && R22InitialDepthBaselineSafe(device)) + return; + + R20CancelInitialSeed(device); + ++R22RejectedInitialSeeds; + if (!R22FirstSeedRejectLogged) + { + R22FirstSeedRejectLogged = true; + spdlog::warn( + "VR R22: initial stereo seed rejected; common WVP/stereo eligibility rolled back because game-state full-clear/scissor or depth-stencil baseline was not synchronized"); + } + } + + bool R22StereoCallbacksEligible() noexcept + { + return R20StereoEligibilityGate.load(std::memory_order_acquire); + } + + HRESULT __stdcall ResetDestR22(IDirect3DDevice9* device, + D3DPRESENT_PARAMETERS* params) + { + const bool gameDevice = IsGameDevice(device); + if (gameDevice) + { + R22FailClosedEligibility(); + R22ResetBaselineTracking(); + R22ShadowState = {}; + spdlog::info( + "VR R22 RESET: common eligibility closed; waiting for a new host-fresh verified color/depth baseline"); + } + const HRESULT hr = R22ResetR13Hook.stdcall(device, params); + if (gameDevice && SUCCEEDED(hr)) + R22PrimeShadowState(device); + return hr; + } + + HRESULT __stdcall ClearDestR22(IDirect3DDevice9* device, DWORD count, + const D3DRECT* rects, DWORD flags, D3DCOLOR color, float z, + DWORD stencil) + { + if (!IsGameDevice(device) || InternalStereoPass) + return R22ClearR20Hook.stdcall( + device, count, rects, flags, color, z, stencil); + + if (!R22StereoCallbacksEligible()) + return ClearHook.stdcall( + device, count, rects, flags, color, z, stencil); + + R22ReplayScope replay(device); + if (!replay.stateValid) + { + R22FailClosedReplayState(device, "R22/Clear/scissor-capture"); + return ClearHook.stdcall( + device, count, rects, flags, color, z, stencil); + } + + const bool mainBefore = TargetIsBackBuffer(); + const bool seedBefore = R9StereoSeeded; + const bool depthSyncBefore = RightDepthSynchronized; + const bool stencilSyncBefore = RightStencilSynchronized; + const bool fullGameClear = R22GameClearCoversBackbuffer( + count, rects, R22GameScissor); + + const HRESULT hr = R22ClearR20Hook.stdcall( + device, count, rects, flags, color, z, stencil); + + R22ObserveDepthBaseline(mainBefore, fullGameClear, flags, hr); + + if (SUCCEEDED(hr) && mainBefore && !fullGameClear) + { + if ((flags & D3DCLEAR_ZBUFFER) != 0) + RightDepthSynchronized = depthSyncBefore; + if ((flags & D3DCLEAR_STENCIL) != 0) + RightStencilSynchronized = stencilSyncBefore; + } + + R22CancelUnsafeFirstSeed(device, seedBefore, fullGameClear); + return hr; + } + + HRESULT __stdcall DrawPrimitiveDestR22(IDirect3DDevice9* device, + D3DPRIMITIVETYPE type, UINT startVertex, UINT primitiveCount) + { + if (!IsGameDevice(device) || InternalStereoPass) + return R22DrawPrimitiveR9Hook.stdcall( + device, type, startVertex, primitiveCount); + if (!R22StereoCallbacksEligible()) + return DrawPrimitiveHook.stdcall( + device, type, startVertex, primitiveCount); + R22ReplayScope replay(device); + if (!replay.stateValid) + { + R22FailClosedReplayState(device, "R22/DrawPrimitive/scissor-capture"); + return DrawPrimitiveHook.stdcall( + device, type, startVertex, primitiveCount); + } + return R22DrawPrimitiveR9Hook.stdcall( + device, type, startVertex, primitiveCount); + } + + HRESULT __stdcall DrawIndexedPrimitiveDestR22(IDirect3DDevice9* device, + D3DPRIMITIVETYPE type, INT baseVertexIndex, UINT minVertexIndex, + UINT numVertices, UINT startIndex, UINT primitiveCount) + { + if (!IsGameDevice(device) || InternalStereoPass) + return R22DrawIndexedPrimitiveR9Hook.stdcall(device, type, + baseVertexIndex, minVertexIndex, numVertices, startIndex, + primitiveCount); + if (!R22StereoCallbacksEligible()) + return DrawIndexedPrimitiveHook.stdcall(device, type, + baseVertexIndex, minVertexIndex, numVertices, startIndex, + primitiveCount); + R22ReplayScope replay(device); + if (!replay.stateValid) + { + R22FailClosedReplayState(device, + "R22/DrawIndexedPrimitive/scissor-capture"); + return DrawIndexedPrimitiveHook.stdcall(device, type, + baseVertexIndex, minVertexIndex, numVertices, startIndex, + primitiveCount); + } + return R22DrawIndexedPrimitiveR9Hook.stdcall(device, type, + baseVertexIndex, minVertexIndex, numVertices, startIndex, + primitiveCount); + } + + HRESULT __stdcall DrawPrimitiveUPDestR22(IDirect3DDevice9* device, + D3DPRIMITIVETYPE type, UINT primitiveCount, const void* data, + UINT stride) + { + if (!IsGameDevice(device) || InternalStereoPass) + return R22DrawPrimitiveUPR9Hook.stdcall( + device, type, primitiveCount, data, stride); + if (!R22StereoCallbacksEligible()) + return DrawPrimitiveUPHook.stdcall( + device, type, primitiveCount, data, stride); + R22ReplayScope replay(device); + if (!replay.stateValid) + { + R22FailClosedReplayState(device, + "R22/DrawPrimitiveUP/scissor-capture"); + return DrawPrimitiveUPHook.stdcall( + device, type, primitiveCount, data, stride); + } + return R22DrawPrimitiveUPR9Hook.stdcall( + device, type, primitiveCount, data, stride); + } + + HRESULT __stdcall DrawIndexedPrimitiveUPDestR22(IDirect3DDevice9* device, + D3DPRIMITIVETYPE type, UINT minVertexIndex, UINT numVertices, + UINT primitiveCount, const void* indexData, D3DFORMAT indexFormat, + const void* vertexData, UINT stride) + { + if (!IsGameDevice(device) || InternalStereoPass) + return R22DrawIndexedPrimitiveUPR9Hook.stdcall(device, type, + minVertexIndex, numVertices, primitiveCount, indexData, + indexFormat, vertexData, stride); + if (!R22StereoCallbacksEligible()) + return DrawIndexedPrimitiveUPHook.stdcall(device, type, + minVertexIndex, numVertices, primitiveCount, indexData, + indexFormat, vertexData, stride); + R22ReplayScope replay(device); + if (!replay.stateValid) + { + R22FailClosedReplayState(device, + "R22/DrawIndexedPrimitiveUP/scissor-capture"); + return DrawIndexedPrimitiveUPHook.stdcall(device, type, + minVertexIndex, numVertices, primitiveCount, indexData, + indexFormat, vertexData, stride); + } + return R22DrawIndexedPrimitiveUPR9Hook.stdcall(device, type, + minVertexIndex, numVertices, primitiveCount, indexData, + indexFormat, vertexData, stride); + } + + void R22RollbackHooks() noexcept + { + R22SetViewportHook = {}; + R22SetRenderStateHook = {}; + R22SetScissorRectHook = {}; + R22CreateStateBlockHook = {}; + R22BeginStateBlockHook = {}; + R22EndStateBlockHook = {}; + R22StateBlockApplyHook = {}; + R22StateBlockApplyTarget = nullptr; + R22StateBlockTrackingReliable.store(false, std::memory_order_release); + R22ResetR13Hook = {}; + R22ClearR20Hook = {}; + R22DrawPrimitiveR9Hook = {}; + R22DrawIndexedPrimitiveR9Hook = {}; + R22DrawPrimitiveUPR9Hook = {}; + R22DrawIndexedPrimitiveUPR9Hook = {}; + } + + bool R22EnableHooks() noexcept + { + SafetyHookInline* hooks[]{ + &R22SetViewportHook, &R22SetRenderStateHook, &R22SetScissorRectHook, + &R22ResetR13Hook, &R22ClearR20Hook, &R22DrawPrimitiveR9Hook, + &R22DrawIndexedPrimitiveR9Hook, &R22DrawPrimitiveUPR9Hook, + &R22DrawIndexedPrimitiveUPR9Hook + }; + for (auto* hook : hooks) + { + if (!*hook || !hook->enable().has_value()) + return false; + } + return true; + } + + DWORD WINAPI R22InstallThread(void*) + { + using State = OutRunVR::RuntimeEligibility::InstallState; + R22InstallState.store(State::Pending, std::memory_order_release); + R22FailClosedEligibility(); + + for (int attempt = 0; attempt < 4800; ++attempt) + { + if (R9InstallState.load(std::memory_order_acquire) == R9InstallFailed || + R13InstallState.load(std::memory_order_acquire) == R13InstallFailed || + OutRunVR::RuntimeEligibility::IsFailed(R20InstallState) || + OutRunVR::RuntimeEligibility::IsFailed(R21InstallState)) + { + R22InstallState.store(State::Failed, std::memory_order_release); + spdlog::error( + "VR R22: prerequisite hook transaction failed; safety overlay remains fail-closed"); + return 0; + } + if (R9InstallState.load(std::memory_order_acquire) == R9InstallReady && + R13InstallState.load(std::memory_order_acquire) == R13InstallReady && + OutRunVR::RuntimeEligibility::IsReady(R20InstallState) && + OutRunVR::RuntimeEligibility::IsReady(R21InstallState)) + { + IDirect3DDevice9* device = + StereoInstalledDevice.load(std::memory_order_acquire); + if (!device) + { + R22InstallState.store(State::Failed, std::memory_order_release); + spdlog::error( + "VR R22: installed game device unavailable; safety overlay remains fail-closed"); + return 0; + } + void** vtable = *reinterpret_cast(device); + if (!vtable) + { + R22InstallState.store(State::Failed, std::memory_order_release); + spdlog::error( + "VR R22: game device vtable unavailable; safety overlay remains fail-closed"); + return 0; + } + + const auto disabled = safetyhook::InlineHook::StartDisabled; + R22SetViewportHook = safetyhook::create_inline( + vtable[R22SetViewportVtableIndex], SetViewportDestR22, disabled); + R22SetRenderStateHook = safetyhook::create_inline( + vtable[R22SetRenderStateVtableIndex], SetRenderStateDestR22, disabled); + R22SetScissorRectHook = safetyhook::create_inline( + vtable[R22SetScissorRectVtableIndex], SetScissorRectDestR22, disabled); + R22ResetR13Hook = safetyhook::create_inline( + reinterpret_cast(&ResetDestR13), ResetDestR22, disabled); + R22ClearR20Hook = safetyhook::create_inline( + reinterpret_cast(&ClearDestR20), ClearDestR22, disabled); + R22DrawPrimitiveR9Hook = safetyhook::create_inline( + reinterpret_cast(&DrawPrimitiveDestR9), DrawPrimitiveDestR22, disabled); + R22DrawIndexedPrimitiveR9Hook = safetyhook::create_inline( + reinterpret_cast(&DrawIndexedPrimitiveDestR9), DrawIndexedPrimitiveDestR22, disabled); + R22DrawPrimitiveUPR9Hook = safetyhook::create_inline( + reinterpret_cast(&DrawPrimitiveUPDestR9), DrawPrimitiveUPDestR22, disabled); + R22DrawIndexedPrimitiveUPR9Hook = safetyhook::create_inline( + reinterpret_cast(&DrawIndexedPrimitiveUPDestR9), DrawIndexedPrimitiveUPDestR22, disabled); + + if (!R22SetViewportHook || !R22SetRenderStateHook || + !R22SetScissorRectHook || !R22ResetR13Hook || + !R22ClearR20Hook || !R22DrawPrimitiveR9Hook || + !R22DrawIndexedPrimitiveR9Hook || + !R22DrawPrimitiveUPR9Hook || !R22DrawIndexedPrimitiveUPR9Hook || + !R22EnableHooks()) + { + R22RollbackHooks(); + R22FailClosedEligibility(); + R22InstallState.store(State::Failed, std::memory_order_release); + spdlog::error( + "VR R22: disabled-first reset/state/bootstrap hook transaction failed closed"); + return 0; + } + + R22PrimeShadowState(device); + + // StateBlock coverage is a performance/correctness + // optimization, not a prerequisite for stereo. If these + // optional hooks cannot be armed, R23 keeps its periodic + // live-state validation fallback. + R22CreateStateBlockHook = safetyhook::create_inline( + vtable[R22CreateStateBlockVtableIndex], + CreateStateBlockDestR22, disabled); + R22BeginStateBlockHook = safetyhook::create_inline( + vtable[R22BeginStateBlockVtableIndex], + BeginStateBlockDestR22, disabled); + R22EndStateBlockHook = safetyhook::create_inline( + vtable[R22EndStateBlockVtableIndex], + EndStateBlockDestR22, disabled); + const bool stateBlockHooks = + R22CreateStateBlockHook && R22BeginStateBlockHook && + R22EndStateBlockHook && + R22EndStateBlockHook.enable().has_value() && + R22BeginStateBlockHook.enable().has_value() && + R22CreateStateBlockHook.enable().has_value(); + if (!stateBlockHooks) + { + R22CreateStateBlockHook = {}; + R22BeginStateBlockHook = {}; + R22EndStateBlockHook = {}; + R22StateBlockTrackingReliable.store( + false, std::memory_order_release); + spdlog::warn( + "VR R46 STATE: StateBlock creation hooks unavailable; periodic live raster validation retained"); + } + + R22InstallState.store(State::Ready, std::memory_order_release); + spdlog::info( + "VR R22 GAME: shadow-tracked viewport/scissor replay + common initial depth baseline + R21 eligibility gate ACTIVE"); + spdlog::info( + "VR R22 REVIEW: disabled-first transaction READY; per-draw GetViewport/GetScissorRect/GetRenderState eliminated after initial state prime"); + spdlog::info( + "VR R24 GAME FIX: internal stereo viewport touches restore the game viewport at outer replay exit"); + return 0; + } + Sleep(25); + } + R22FailClosedEligibility(); + R22InstallState.store(State::Failed, std::memory_order_release); + spdlog::error( + "VR R22: timed out waiting for prerequisite hook transactions; safety overlay FAILED closed"); + return 0; + } + + class VRR22SafetyOverlayHook : public Hook + { + public: + std::string_view description() override + { + return "OpenXRVRSafetyOverlayR22"; + } + bool validate() override { return true; } + bool apply() override + { + using State = OutRunVR::RuntimeEligibility::InstallState; + R22InstallState.store(State::Pending, std::memory_order_release); + HANDLE thread = CreateThread( + nullptr, 0, R22InstallThread, nullptr, 0, nullptr); + if (!thread) + { + R22FailClosedEligibility(); + R22InstallState.store(State::Failed, std::memory_order_release); + spdlog::error( + "VR R22: failed to create safety-overlay installer thread; eligibility remains fail-closed: {}", + GetLastError()); + return false; + } + CloseHandle(thread); + return true; + } + static VRR22SafetyOverlayHook instance; + }; + + VRR22SafetyOverlayHook VRR22SafetyOverlayHook::instance; + } +} diff --git a/src/vr/d3d9/stereo_renderer_r23.cpp b/src/vr/d3d9/stereo_renderer_r23.cpp new file mode 100644 index 000000000..7a45673b2 --- /dev/null +++ b/src/vr/d3d9/stereo_renderer_r23.cpp @@ -0,0 +1,857 @@ +// R23/R25 recovery + first-seed coordinator. +// +// This remains the final game-side TU layered over R22. Recovery is intentionally +// passive: while the common gate is closed, the game's Clear executes exactly +// once against the real game targets. No mono/right-eye clear replay and no +// viewport/scissor re-application is allowed during passive observation. +// +// First-seed authority also lives here. R20 and R22 are still retained for the +// already-seeded stereo frame, but they no longer independently approve/cancel +// the first seed of a Present. The coordinator snapshots LIVE game state before +// a candidate clear, counts every top-level game draw (including gate-closed +// draws), and initializes VR surfaces only after a next-frame pose warmup and a +// new current-frame full color/depth baseline are both proven. + +#include "stereo_renderer_r22.cpp" + +namespace OutRunVRStereo +{ + namespace + { + SafetyHookInline R23ClearR22Hook{}; + SafetyHookInline R23DrawPrimitiveR22Hook{}; + SafetyHookInline R23DrawIndexedPrimitiveR22Hook{}; + SafetyHookInline R23DrawPrimitiveUPR22Hook{}; + SafetyHookInline R23DrawIndexedPrimitiveUPR22Hook{}; + SafetyHookInline R23SetRenderTargetR9Hook{}; + SafetyHookInline R23PresentR21Hook{}; + + std::atomic R23InstallState{ + OutRunVR::RuntimeEligibility::InstallState::Pending }; + + std::uint64_t R23GameDrawSerial = 0; + std::uint64_t R23LastStateSampleDrawSerial = 0; + std::uint64_t R23LastStateSampleEpoch = 0; + std::uint64_t R23LastPresentBeginScene = 0; + + std::uint64_t R23DepthClearEpoch = 0; + std::uint64_t R23DepthClearGameDrawSerial = 0; + std::uint64_t R23DepthClearGeneration = 0; + float R23DepthClearZ = 1.0f; + std::uint64_t R23StencilClearEpoch = 0; + std::uint64_t R23StencilClearGameDrawSerial = 0; + std::uint64_t R23StencilClearGeneration = 0; + DWORD R23StencilClearValue = 0; + + bool R23ArmWarmupAtPresent = false; + std::uint64_t R23PassiveClears = 0; + std::uint64_t R23AuthoritativeSeeds = 0; + std::uint64_t R23RecoveryVrSurfaceWrites = 0; + std::uint64_t R23StateMismatches = 0; + std::uint64_t R23StateCaptureFailures = 0; + std::uint64_t R23BaselineRejects = 0; + ULONGLONG R23LastSummaryMs = 0; + ULONGLONG R23LastStateMismatchLogMs = 0; + const char* R23LastRejectReason = "none"; + const char* R23LastHostReason = "unknown"; + bool R23FirstPassiveLogged = false; + bool R23FirstWarmupArmedLogged = false; + bool R23FirstSeedLogged = false; + bool R23FirstColorBaselineLogged = false; + bool R23FirstImplicitViewportResyncLogged = false; + + bool R23SnapshotSame(const R22ScissorSnapshot& a, + const R22ScissorSnapshot& b) noexcept + { + if (!a.Valid() || !b.Valid()) + return false; + return a.viewport.X == b.viewport.X && + a.viewport.Y == b.viewport.Y && + a.viewport.Width == b.viewport.Width && + a.viewport.Height == b.viewport.Height && + a.viewport.MinZ == b.viewport.MinZ && + a.viewport.MaxZ == b.viewport.MaxZ && + a.rect.left == b.rect.left && a.rect.top == b.rect.top && + a.rect.right == b.rect.right && a.rect.bottom == b.rect.bottom && + a.enabled == b.enabled; + } + + bool R23CaptureActualGameState(IDirect3DDevice9* device, + R22ScissorSnapshot& out, const char* site, bool force) noexcept + { + // Once StateBlock::Apply interception is actually proven, + // setter + Apply boundaries own the shadow and effect draws no + // longer need periodic GetViewport/GetScissorRect queries. Keep + // forced live reads for SetRenderTarget implicit viewport changes. + const bool effectForce = + force && site && std::strcmp(site, "R28EffectDraw") == 0; + if (R22ShadowState.Valid() && + R22StateBlockTrackingReliable.load(std::memory_order_acquire) && + (!force || effectForce)) + { + out = R22ShadowState; + return true; + } + + // If Apply tracking is unavailable, retain the conservative + // historical live-validation window. + if (!force && R23LastStateSampleEpoch == PresentEpoch && + R22ShadowState.Valid() && !R22ShadowState.enabled && + R23GameDrawSerial - R23LastStateSampleDrawSerial < 16) + { + out = R22ShadowState; + return true; + } + + R22ScissorSnapshot actual{}; + if (!R22CaptureGameScissor(device, actual)) + { + ++R23StateCaptureFailures; + return false; + } + + if (R22ShadowState.Valid() && !R23SnapshotSame(actual, R22ShadowState)) + { + const bool implicitRenderTargetViewport = + site && std::strcmp(site, "SetRenderTarget") == 0; + if (implicitRenderTargetViewport) + { + if (!R23FirstImplicitViewportResyncLogged) + { + R23FirstImplicitViewportResyncLogged = true; + spdlog::info( + "VR R23 STATE: SetRenderTarget implicit viewport/scissor transition observed and shadow state resynchronized immediately"); + } + } + else + { + ++R23StateMismatches; + const ULONGLONG now = GetTickCount64(); + if (R23LastStateMismatchLogMs == 0 || + now - R23LastStateMismatchLogMs >= 5000) + { + R23LastStateMismatchLogMs = now; + spdlog::warn( + "VR R23/R25 STATE: actual/cache viewport-scissor mismatch site={} actualVP={},{},{}x{} actualScissor={} [{},{},{},{}] cacheVP={},{},{}x{} cacheScissor={} [{},{},{},{}]", + site, + actual.viewport.X, actual.viewport.Y, + actual.viewport.Width, actual.viewport.Height, + actual.enabled ? 1 : 0, + actual.rect.left, actual.rect.top, + actual.rect.right, actual.rect.bottom, + R22ShadowState.viewport.X, R22ShadowState.viewport.Y, + R22ShadowState.viewport.Width, + R22ShadowState.viewport.Height, + R22ShadowState.enabled ? 1 : 0, + R22ShadowState.rect.left, R22ShadowState.rect.top, + R22ShadowState.rect.right, R22ShadowState.rect.bottom); + } + } + } + + // LIVE device state is authoritative. This also repairs changes made + // by SetRenderTarget and StateBlock::Apply that bypass tracked setters. + R22ShadowState = actual; + R23LastStateSampleDrawSerial = R23GameDrawSerial; + R23LastStateSampleEpoch = PresentEpoch; + out = actual; + return true; + } + + void R23Reject(const char* reason) noexcept + { + ++R23BaselineRejects; + R23LastRejectReason = reason; + } + + void R23ObservePassiveDepthClear(IDirect3DDevice9* device, + const R22ScissorSnapshot& state, DWORD count, const D3DRECT* rects, + DWORD flags, float z, DWORD stencil, HRESULT hr) noexcept + { + if (FAILED(hr) || !TargetIsBackBuffer() || + !R22GameClearCoversBackbuffer(count, rects, state) || + R9DeferredDepth || !R9CurrentDepthCanMirror()) + return; + + if ((flags & D3DCLEAR_ZBUFFER) != 0) + { + R23DepthClearEpoch = PresentEpoch; + R23DepthClearGameDrawSerial = R23GameDrawSerial; + R23DepthClearGeneration = R9MainDepthGeneration; + R23DepthClearZ = z; + } + if ((flags & D3DCLEAR_STENCIL) != 0) + { + R23StencilClearEpoch = PresentEpoch; + R23StencilClearGameDrawSerial = R23GameDrawSerial; + R23StencilClearGeneration = R9MainDepthGeneration; + R23StencilClearValue = stencil; + } + } + + bool R23DepthBaselineSafe(IDirect3DDevice9* device, + bool& stencilSafe) noexcept + { + stencilSafe = true; + if (!TrackedDepthStencil) + return true; + if (R9DeferredDepth || !R9CurrentDepthCanMirror()) + return false; + + const bool depthSafe = + R23DepthClearEpoch == PresentEpoch && + R23DepthClearGameDrawSerial == R23GameDrawSerial && + R23DepthClearGeneration == R9MainDepthGeneration; + if (!depthSafe) + return false; + + if (SurfaceHasStencil(TrackedDepthStencil)) + { + stencilSafe = + R23StencilClearEpoch == PresentEpoch && + R23StencilClearGameDrawSerial == R23GameDrawSerial && + R23StencilClearGeneration == R9MainDepthGeneration; + if (StencilTestActive(device) && !stencilSafe) + return false; + } + return true; + } + + bool R23HaveFreshCurrentFramePose() noexcept + { + if (OutRunVRRenderer::GetBeginSceneCallCount() <= + R23LastPresentBeginScene) + return false; + OutRunVRRenderer::LatchedStereoFrame pose{}; + return OutRunVRRenderer::GetLatchedStereoFrame(pose) && + pose.valid && pose.poseSequence != 0; + } + + bool R23RestoreActualGameState(IDirect3DDevice9* device, + IDirect3DSurface9* savedRt, IDirect3DSurface9* savedDepth, + const R22ScissorSnapshot& state) noexcept + { + bool ok = true; + if (FAILED(SetRenderTargetHook.stdcall(device, 0u, savedRt))) + ok = false; + if (FAILED(SetDepthStencilSurfaceHook.stdcall(device, savedDepth))) + ok = false; + if (FAILED(device->SetViewport(&state.viewport))) + ok = false; + if (FAILED(device->SetScissorRect(&state.rect))) + ok = false; + if (FAILED(device->SetRenderState(D3DRS_SCISSORTESTENABLE, + state.enabled))) + ok = false; + return ok; + } + + bool R23ClearPrivateDepth(IDirect3DDevice9* device, + IDirect3DSurface9* rt, IDirect3DSurface9* depth, + const R22ScissorSnapshot& state, bool stencilSafe) noexcept + { + if (!depth) + return true; + if (FAILED(SetRenderTargetHook.stdcall(device, 0u, rt)) || + FAILED(SetDepthStencilSurfaceHook.stdcall(device, depth)) || + FAILED(device->SetViewport(&state.viewport)) || + FAILED(device->SetScissorRect(&state.rect)) || + FAILED(device->SetRenderState(D3DRS_SCISSORTESTENABLE, + state.enabled))) + return false; + + HRESULT hr = ClearHook.stdcall(device, 0u, nullptr, + D3DCLEAR_ZBUFFER, 0, R23DepthClearZ, 0); + ++R23RecoveryVrSurfaceWrites; + if (FAILED(hr)) + return false; + + if (SurfaceHasStencil(depth) && stencilSafe) + { + hr = ClearHook.stdcall(device, 0u, nullptr, + D3DCLEAR_STENCIL, 0, 1.0f, R23StencilClearValue); + ++R23RecoveryVrSurfaceWrites; + if (FAILED(hr)) + return false; + } + return true; + } + + bool R23InitializeStereoFromAuthoritativeBaseline( + IDirect3DDevice9* device, const R22ScissorSnapshot& state, + bool stencilSafe, DWORD colorRectCount, + const D3DRECT* colorRects, D3DCOLOR color) noexcept + { + if (!device || AnyAuxRenderTargetActive() || + !EnsureStereoResources(device) || !R9EnsureMonoResources(device) || + !BackBuffer || !RightEyeSurface || !R9MonoSurface) + return false; + + IDirect3DSurface9* savedRt = nullptr; + IDirect3DSurface9* savedDepth = nullptr; + const HRESULT rtHr = device->GetRenderTarget(0, &savedRt); + const HRESULT depthHr = device->GetDepthStencilSurface(&savedDepth); + const bool depthStateOk = SUCCEEDED(depthHr) || depthHr == D3DERR_NOTFOUND; + if (FAILED(rtHr) || !savedRt || !depthStateOk) + { + if (savedRt) savedRt->Release(); + if (savedDepth) savedDepth->Release(); + return false; + } + + bool ok = true; + { + InternalPassScope guard; + + auto replayColorClear = [&](IDirect3DSurface9* target) noexcept { + if (!target || + FAILED(SetRenderTargetHook.stdcall( + device, 0u, target)) || + FAILED(SetDepthStencilSurfaceHook.stdcall( + device, static_cast(nullptr))) || + FAILED(device->SetViewport(&state.viewport)) || + FAILED(device->SetScissorRect(&state.rect)) || + FAILED(device->SetRenderState(D3DRS_SCISSORTESTENABLE, + state.enabled))) + return false; + + const HRESULT colorHr = ClearHook.stdcall( + device, colorRectCount, colorRects, + D3DCLEAR_TARGET, color, 1.0f, 0u); + ++R23RecoveryVrSurfaceWrites; + return SUCCEEDED(colorHr); + }; + + // The authoritative candidate is already proven to cover the + // complete game backbuffer. Replaying that exact clear into + // the private eye/mono targets is equivalent to copying the + // just-cleared backbuffer, but avoids two 3440x1440 full-surface + // StretchRect copies per Present and cannot carry stale eye + // color history into translucent right-eye passes. + if (!replayColorClear(RightEyeSurface)) + ok = false; + if (ok && !replayColorClear(R9MonoSurface)) + ok = false; + + if (ok && !R23FirstColorBaselineLogged) + { + R23FirstColorBaselineLogged = true; + spdlog::info( + "VR C1.1 BASELINE: authoritative color clear replay seeds right/mono surfaces; two full-surface StretchRect baseline copies removed"); + } + + if (ok && TrackedDepthStencil) + { + ok = R23ClearPrivateDepth(device, RightEyeSurface, + RightEyeDepth, state, stencilSafe); + if (ok) + ok = R23ClearPrivateDepth(device, R9MonoSurface, + R9MonoDepth, state, stencilSafe); + } + + if (!R23RestoreActualGameState(device, savedRt, savedDepth, state)) + ok = false; + } + + savedRt->Release(); + if (savedDepth) savedDepth->Release(); + if (!ok) + return false; + + R9StereoSeeded = true; + R9MonoSeeded = true; + R9MonoBackupGap = false; + + if (TrackedDepthStencil) + { + std::uint64_t serial = std::max( + R9MainDepthContentSerial, R9MonoDepthContentSerial); + if (++serial == 0) + serial = 1; + R9MainDepthContentSerial = serial; + R9MonoDepthContentSerial = serial; + RightDepthSynchronized = true; + RightStencilSynchronized = + !SurfaceHasStencil(TrackedDepthStencil) || stencilSafe; + } + else + { + RightDepthSynchronized = true; + RightStencilSynchronized = true; + } + + OutRunVR::RuntimeEligibility::BaselineVerified(); + const bool allowed = OutRunVR::RuntimeEligibility::MayInjectStereo(); + R20StereoEligibilityGate.store(allowed, std::memory_order_release); + if (!allowed) + { + R9StereoSeeded = false; + R9MonoSeeded = false; + return false; + } + + ++R23AuthoritativeSeeds; + if (!R23FirstSeedLogged) + { + R23FirstSeedLogged = true; + spdlog::info( + "VR R23/R25 BASELINE: authoritative first seed opened only after live viewport/scissor + full game draw serial + current-generation depth + fresh current-frame pose; R20/R22 double approval removed"); + } + return true; + } + + bool R23RecoveryNeedsBaseline() noexcept + { + return OutRunVR::RuntimeEligibility::SafetyOverlayReady.load( + std::memory_order_acquire) && + OutRunVR::RuntimeEligibility::HostFresh.load( + std::memory_order_acquire) && + OutRunVR::RuntimeEligibility::RecoveryPending.load( + std::memory_order_acquire) && + !OutRunVR::RuntimeEligibility::StereoAllowed.load( + std::memory_order_acquire); + } + + HRESULT __stdcall ClearDestR23(IDirect3DDevice9* device, DWORD count, + const D3DRECT* rects, DWORD flags, D3DCOLOR color, float z, + DWORD stencil) + { + if (!IsGameDevice(device) || InternalStereoPass) + return R23ClearR22Hook.stdcall( + device, count, rects, flags, color, z, stencil); + + R22ScissorSnapshot actual{}; + const bool stateOk = R23CaptureActualGameState( + device, actual, "Clear", true); + + // Once the authoritative seed is already open, keep the validated + // R22/R20/R9 replay path for subsequent clears in the same Present. + if (R9StereoSeeded && + OutRunVR::RuntimeEligibility::MayInjectStereo()) + { + return R23ClearR22Hook.stdcall( + device, count, rects, flags, color, z, stencil); + } + + // Before first seed (including recovery), execute the GAME clear + // exactly once. Do not enter R20/R9 mono/right replay here. + const bool mainBefore = TargetIsBackBuffer(); + const bool mrtActive = AnyAuxRenderTargetActive(); + const bool wantsStereo = StereoWanted(); + const HRESULT hr = ClearHook.stdcall( + device, count, rects, flags, color, z, stencil); + ++R23PassiveClears; + + if (!R23FirstPassiveLogged) + { + R23FirstPassiveLogged = true; + spdlog::info( + "VR R23/R25 RECOVERY: passive original Clear only; VR surface writes=0 until next-frame authoritative initialization"); + } + + if (FAILED(hr) || !stateOk || !mainBefore || !wantsStereo) + return hr; + + const bool fullGameClear = R22GameClearCoversBackbuffer( + count, rects, actual); + R23ObservePassiveDepthClear(device, actual, count, rects, + flags, z, stencil, hr); + + if ((flags & D3DCLEAR_TARGET) == 0) + return hr; + if (mrtActive) + { + R23Reject("mrt-active"); + return hr; + } + if (!fullGameClear) + { + R23Reject("partial-viewport-scissor-or-rect"); + return hr; + } + + bool stencilSafe = true; + if (!R23DepthBaselineSafe(device, stencilSafe)) + { + R23Reject("depth-stencil-or-game-draw-serial"); + return hr; + } + + const bool recovering = R23RecoveryNeedsBaseline(); + if (recovering && + !OutRunVR::RuntimeEligibility::PoseWarmupAllowed()) + { + // This frame stays completely monoscopic. Arm only the next + // BeginScene so it can retain a fresh pose without injecting it. + R23ArmWarmupAtPresent = true; + if (!R23FirstWarmupArmedLogged) + { + R23FirstWarmupArmedLogged = true; + spdlog::info( + "VR R23/R25 BASELINE: passive candidate recorded epoch={} gameDrawSerial={} depthGeneration={}; next Present will arm pose warmup, not stereo", + PresentEpoch, R23GameDrawSerial, R9MainDepthGeneration); + } + return hr; + } + + const bool maySeedNow = + (OutRunVR::RuntimeEligibility::PoseWarmupAllowed() || + OutRunVR::RuntimeEligibility::StereoAllowed.load( + std::memory_order_acquire)) && + OutRunVR::RuntimeEligibility::HostFresh.load( + std::memory_order_acquire); + if (!maySeedNow) + { + R23Reject("eligibility-not-ready"); + return hr; + } + if (!R23HaveFreshCurrentFramePose()) + { + R23Reject("fresh-current-frame-pose-missing"); + return hr; + } + + if (!R23InitializeStereoFromAuthoritativeBaseline( + device, actual, stencilSafe, count, rects, color)) + R23Reject("eye-mono-initialization-failed"); + return hr; + } + + void R23BeforeTopLevelDraw(IDirect3DDevice9* device) noexcept + { + if (!IsGameDevice(device) || InternalStereoPass) + return; + ++R23GameDrawSerial; + R22ScissorSnapshot ignored{}; + R23CaptureActualGameState(device, ignored, "Draw", false); + } + + HRESULT __stdcall DrawPrimitiveDestR23(IDirect3DDevice9* device, + D3DPRIMITIVETYPE type, UINT startVertex, UINT primitiveCount) + { + R23BeforeTopLevelDraw(device); + return R23DrawPrimitiveR22Hook.stdcall( + device, type, startVertex, primitiveCount); + } + + HRESULT __stdcall DrawIndexedPrimitiveDestR23(IDirect3DDevice9* device, + D3DPRIMITIVETYPE type, INT baseVertexIndex, UINT minVertexIndex, + UINT numVertices, UINT startIndex, UINT primitiveCount) + { + R23BeforeTopLevelDraw(device); + return R23DrawIndexedPrimitiveR22Hook.stdcall(device, type, + baseVertexIndex, minVertexIndex, numVertices, startIndex, + primitiveCount); + } + + HRESULT __stdcall DrawPrimitiveUPDestR23(IDirect3DDevice9* device, + D3DPRIMITIVETYPE type, UINT primitiveCount, const void* data, + UINT stride) + { + R23BeforeTopLevelDraw(device); + return R23DrawPrimitiveUPR22Hook.stdcall( + device, type, primitiveCount, data, stride); + } + + HRESULT __stdcall DrawIndexedPrimitiveUPDestR23(IDirect3DDevice9* device, + D3DPRIMITIVETYPE type, UINT minVertexIndex, UINT numVertices, + UINT primitiveCount, const void* indexData, D3DFORMAT indexFormat, + const void* vertexData, UINT stride) + { + R23BeforeTopLevelDraw(device); + return R23DrawIndexedPrimitiveUPR22Hook.stdcall(device, type, + minVertexIndex, numVertices, primitiveCount, indexData, + indexFormat, vertexData, stride); + } + + HRESULT __stdcall SetRenderTargetDestR23(IDirect3DDevice9* device, + DWORD index, IDirect3DSurface9* surface) + { + const HRESULT hr = R23SetRenderTargetR9Hook.stdcall( + device, index, surface); + if (SUCCEEDED(hr) && index == 0 && IsGameDevice(device) && + !InternalStereoPass) + { + R22ScissorSnapshot actual{}; + if (!R23CaptureActualGameState(device, actual, + "SetRenderTarget", true)) + { + R22ShadowState = {}; + R23LastStateSampleDrawSerial = 0; + R23LastStateSampleEpoch = 0; + R22FailClosedReplayState( + device, "R23/SetRenderTarget/live-state-capture"); + } + } + return hr; + } + + const char* R23DiagnoseHostFreshness(std::int64_t& ageMs) noexcept + { + ageMs = -1; + if (!SharedState || SharedState->magic != OutRunVR::SharedMagic || + SharedState->protocolVersion != OutRunVR::SharedProtocolVersion || + SharedState->structSize != sizeof(OutRunVR::SharedPoseState)) + return "mapping-or-header"; + + OutRunVR::SharedPoseState snap{}; + bool stable = false; + for (int attempt = 0; attempt < 4; ++attempt) + { + const std::uint32_t before = SharedState->sequence; + if (before & 1u) continue; + MemoryBarrier(); + std::memcpy(&snap, SharedState, sizeof(snap)); + MemoryBarrier(); + const std::uint32_t after = SharedState->sequence; + if (before == after && !(after & 1u)) + { + stable = true; + break; + } + } + if (!stable) return "seqlock-busy"; + if (!snap.hostPid) return "host-pid-missing"; + if ((snap.flags & OutRunVR::HostAlive) == 0) return "host-alive-off"; + if ((snap.flags & OutRunVR::SessionVisible) == 0) return "session-visible-off"; + if ((snap.flags & OutRunVR::HostShouldRender) == 0) return "should-render-off"; + if (snap.sampleQpc <= 0) return "qpc-invalid"; + + LARGE_INTEGER now{}, frequency{}; + if (!QueryPerformanceCounter(&now) || + !QueryPerformanceFrequency(&frequency) || + frequency.QuadPart <= 0 || now.QuadPart < snap.sampleQpc) + return "qpc-invalid"; + ageMs = ((now.QuadPart - snap.sampleQpc) * 1000) / + frequency.QuadPart; + if (ageMs > OutRunVR::RuntimeEligibility::HostStaleMs) + return "sample-stale"; + return "fresh"; + } + + void R23MaybeLogSummary() noexcept + { + if (!Settings::VRTelemetry) + return; + const ULONGLONG now = GetTickCount64(); + if (now - R23LastSummaryMs < 5000) + return; + R23LastSummaryMs = now; + spdlog::info( + "VR R23/R25 summary: epoch={} gameDrawSerial={} passiveClear={} authoritativeSeed={} recoveryVrWrites={} stateMismatch={} stateCaptureFail={} baselineReject={} lastReject={} warmup={} stereoAllowed={} recoveryPending={} hostReason={}", + PresentEpoch, R23GameDrawSerial, R23PassiveClears, + R23AuthoritativeSeeds, R23RecoveryVrSurfaceWrites, + R23StateMismatches, R23StateCaptureFailures, + R23BaselineRejects, R23LastRejectReason, + OutRunVR::RuntimeEligibility::PoseWarmupAllowed() ? 1 : 0, + OutRunVR::RuntimeEligibility::StereoAllowed.load( + std::memory_order_acquire) ? 1 : 0, + OutRunVR::RuntimeEligibility::RecoveryPending.load( + std::memory_order_acquire) ? 1 : 0, + R23LastHostReason); + } + + HRESULT __stdcall PresentDestR23(IDirect3DDevice9* device, + const RECT* sourceRect, const RECT* destRect, + HWND destWindowOverride, const RGNDATA* dirtyRegion) + { + const bool gameDevice = IsGameDevice(device) && !InternalStereoPass; + const HRESULT hr = R23PresentR21Hook.stdcall(device, + sourceRect, destRect, destWindowOverride, dirtyRegion); + if (!gameDevice) + return hr; + + std::int64_t ageMs = -1; + const char* reason = R23DiagnoseHostFreshness(ageMs); + if (std::strcmp(reason, R23LastHostReason) != 0) + { + R23LastHostReason = reason; + spdlog::info( + "VR R23/R25 HOST: freshnessReason={} ageMs={} runtimeHostFresh={} renderable={} (hard freshness and transient shouldRender are separate gates)", + reason, ageMs, + OutRunVR::RuntimeEligibility::HostFresh.load( + std::memory_order_acquire) ? 1 : 0, + OutRunVR::RuntimeEligibility::HostRenderable.load( + std::memory_order_acquire) ? 1 : 0); + } + + if (R23ArmWarmupAtPresent) + { + if (SUCCEEDED(hr) && + OutRunVR::RuntimeEligibility::HostFresh.load( + std::memory_order_acquire) && + OutRunVR::RuntimeEligibility::RecoveryPending.load( + std::memory_order_acquire)) + { + OutRunVR::RuntimeEligibility::ArmRecoveryPoseWarmup(); + spdlog::info( + "VR R23/R25 RESUME: verified passive baseline armed pose warmup after Present; stereo remains closed until a new next-frame authoritative clear"); + } + R23ArmWarmupAtPresent = false; + } + + R23LastPresentBeginScene = OutRunVRRenderer::GetBeginSceneCallCount(); + if (!OutRunVR::RuntimeEligibility::HostFresh.load( + std::memory_order_acquire)) + R20StereoEligibilityGate.store(false, std::memory_order_release); + R23MaybeLogSummary(); + return hr; + } + + void R23RollbackHooks() noexcept + { + R23PresentR21Hook = {}; + R23SetRenderTargetR9Hook = {}; + R23DrawIndexedPrimitiveUPR22Hook = {}; + R23DrawPrimitiveUPR22Hook = {}; + R23DrawIndexedPrimitiveR22Hook = {}; + R23DrawPrimitiveR22Hook = {}; + R23ClearR22Hook = {}; + } + + bool R23EnableHooks() noexcept + { + SafetyHookInline* hooks[]{ + &R23ClearR22Hook, + &R23DrawPrimitiveR22Hook, + &R23DrawIndexedPrimitiveR22Hook, + &R23DrawPrimitiveUPR22Hook, + &R23DrawIndexedPrimitiveUPR22Hook, + &R23SetRenderTargetR9Hook, + &R23PresentR21Hook + }; + for (auto* hook : hooks) + { + if (!*hook || !hook->enable().has_value()) + return false; + } + return true; + } + + DWORD WINAPI R23RecoveryInstallThread(void*) + { + using State = OutRunVR::RuntimeEligibility::InstallState; + R23InstallState.store(State::Pending, std::memory_order_release); + for (int attempt = 0; attempt < 4800; ++attempt) + { + if (R9InstallState.load(std::memory_order_acquire) == R9InstallFailed || + R13InstallState.load(std::memory_order_acquire) == R13InstallFailed || + OutRunVR::RuntimeEligibility::IsFailed(R20InstallState) || + OutRunVR::RuntimeEligibility::IsFailed(R21InstallState) || + OutRunVR::RuntimeEligibility::IsFailed(R22InstallState)) + { + R23InstallState.store(State::Failed, std::memory_order_release); + OutRunVR::RuntimeEligibility::MarkSafetyOverlayUnavailable(); + R20StereoEligibilityGate.store(false, std::memory_order_release); + HookManager::ReportAsyncResult( + "OpenXRVRRecoveryBaselineR23", false); + spdlog::error( + "VR R23/R25: prerequisite overlay FAILED; authoritative recovery remains fail-closed"); + return 0; + } + + if (OutRunVR::RuntimeEligibility::IsReady(R22InstallState)) + { + const auto disabled = safetyhook::InlineHook::StartDisabled; + R23ClearR22Hook = safetyhook::create_inline( + reinterpret_cast(&ClearDestR22), ClearDestR23, + disabled); + R23DrawPrimitiveR22Hook = safetyhook::create_inline( + reinterpret_cast(&DrawPrimitiveDestR22), + DrawPrimitiveDestR23, disabled); + R23DrawIndexedPrimitiveR22Hook = safetyhook::create_inline( + reinterpret_cast(&DrawIndexedPrimitiveDestR22), + DrawIndexedPrimitiveDestR23, disabled); + R23DrawPrimitiveUPR22Hook = safetyhook::create_inline( + reinterpret_cast(&DrawPrimitiveUPDestR22), + DrawPrimitiveUPDestR23, disabled); + R23DrawIndexedPrimitiveUPR22Hook = safetyhook::create_inline( + reinterpret_cast(&DrawIndexedPrimitiveUPDestR22), + DrawIndexedPrimitiveUPDestR23, disabled); + R23SetRenderTargetR9Hook = safetyhook::create_inline( + reinterpret_cast(&SetRenderTargetDestR9), + SetRenderTargetDestR23, disabled); + R23PresentR21Hook = safetyhook::create_inline( + reinterpret_cast(&PresentDestR21), PresentDestR23, + disabled); + + if (!R23EnableHooks()) + { + R23RollbackHooks(); + R23InstallState.store(State::Failed, std::memory_order_release); + OutRunVR::RuntimeEligibility::MarkSafetyOverlayUnavailable(); + R20StereoEligibilityGate.store(false, std::memory_order_release); + HookManager::ReportAsyncResult( + "OpenXRVRRecoveryBaselineR23", false); + spdlog::error( + "VR R23/R25: disabled-first recovery/state/draw/present transaction failed; stereo remains fail-closed"); + return 0; + } + + R22ScissorSnapshot initial{}; + if (IDirect3DDevice9* device = + StereoInstalledDevice.load(std::memory_order_acquire)) + R23CaptureActualGameState(device, initial, "install", true); + + OutRunVR::RuntimeEligibility::MarkSafetyOverlayInstalled(); + R20StereoEligibilityGate.store(false, std::memory_order_release); + R23LastPresentBeginScene = + OutRunVRRenderer::GetBeginSceneCallCount(); + R23InstallState.store(State::Ready, std::memory_order_release); + HookManager::ReportAsyncResult( + "OpenXRVRRecoveryBaselineR23", true); + spdlog::info( + "VR R23 GAME: single-owner live-state recovery baseline ACTIVE; passive Clear=original-once, top-level gameDrawSerial includes gate-closed draws, first seed waits for next-frame fresh pose"); + return 0; + } + Sleep(25); + } + + R23InstallState.store(State::Failed, std::memory_order_release); + OutRunVR::RuntimeEligibility::MarkSafetyOverlayUnavailable(); + R20StereoEligibilityGate.store(false, std::memory_order_release); + HookManager::ReportAsyncResult( + "OpenXRVRRecoveryBaselineR23", false); + spdlog::error( + "VR R23/R25: timed out waiting for R22 safety overlay; stereo kept fail-closed"); + return 0; + } + + class VRRecoveryBaselineR23Hook final : public Hook + { + public: + std::string_view description() override + { + return "OpenXRVRRecoveryBaselineR23"; + } + bool validate() override { return true; } + bool apply() override + { + using State = OutRunVR::RuntimeEligibility::InstallState; + R23InstallState.store(State::Pending, std::memory_order_release); + OutRunVR::RuntimeEligibility::MarkSafetyOverlayUnavailable(); + R20StereoEligibilityGate.store(false, std::memory_order_release); + + HANDLE thread = CreateThread(nullptr, 0, + R23RecoveryInstallThread, nullptr, 0, nullptr); + if (!thread) + { + R23InstallState.store(State::Failed, std::memory_order_release); + OutRunVR::RuntimeEligibility::MarkSafetyOverlayUnavailable(); + R20StereoEligibilityGate.store(false, std::memory_order_release); + return false; + } + CloseHandle(thread); + return true; + } + static VRRecoveryBaselineR23Hook instance; + }; + + VRRecoveryBaselineR23Hook VRRecoveryBaselineR23Hook::instance; + } + + std::uint64_t GetTopLevelDrawSerial() noexcept + { + return R23GameDrawSerial; + } +} diff --git a/src/vr/d3d9/stereo_renderer_r26.cpp b/src/vr/d3d9/stereo_renderer_r26.cpp new file mode 100644 index 000000000..36f49bcf8 --- /dev/null +++ b/src/vr/d3d9/stereo_renderer_r26.cpp @@ -0,0 +1,638 @@ +// R26/R28 tracked-occlusion + world classification correction overlay. +// +// R23 remains the recovery/baseline authority. R26 keeps proven write-free +// D3D9 occlusion-query proxy draws single-execution without poisoning the whole +// Present. R27 removes R13's draw-time shader-identity masking for perspective +// effects. R28 additionally recognizes a verified perspective-world WVP that +// legitimately survives a vertex-shader switch: when c64..c67, pose generation, +// and the saved projection still match, the existing R9 per-eye path is reused +// instead of silently demoting the draw to zero-disparity NonWorld. +// +// MRT hazards, query tracking failures, fixed-function draws, projection changes, +// unknown WVPs, and constant mismatches remain fail-closed through R13/R23/R9. + +#include "stereo_renderer_r23.cpp" +#include "shader_fingerprint_gpl.hpp" + +namespace OutRunVRRenderer +{ + bool GetR28VerifiedProjection(float outProjection[16], + std::uint32_t& generation, std::uint32_t& poseSequence) noexcept; + bool R28PerspectiveWorldSemantic() noexcept; +} + +namespace OutRunVRStereo +{ + namespace + { + SafetyHookInline R26DrawPrimitiveR23Hook{}; + SafetyHookInline R26DrawIndexedPrimitiveR23Hook{}; + SafetyHookInline R26DrawPrimitiveUPR23Hook{}; + SafetyHookInline R26DrawIndexedPrimitiveUPR23Hook{}; + SafetyHookInline R27PresentR23Hook{}; + + // Lower-layer correction hooks: these intercept R13 only after R23/R22 + // have already performed top-level accounting and safety checks. + SafetyHookInline R27DrawPrimitiveR13Hook{}; + SafetyHookInline R27DrawIndexedPrimitiveR13Hook{}; + SafetyHookInline R27DrawPrimitiveUPR13Hook{}; + SafetyHookInline R27DrawIndexedPrimitiveUPR13Hook{}; + + std::atomic R26InstallState{ + OutRunVR::RuntimeEligibility::InstallState::Pending }; + + std::uint64_t R26TrackedOcclusionSingleExec = 0; + std::uint64_t R26OcclusionWriteRejects = 0; + std::uint64_t R27WorldEffectDraws = 0; + std::uint64_t R27EffectStateResyncs = 0; + std::uint64_t R27LastEffectStateSyncDrawSerial = 0; + std::uint64_t R27EffectStateSyncEpoch = ~std::uint64_t{0}; + std::uint64_t R28ShaderEpochWorldRebinds = 0; + std::uint64_t R28RebindNoVerified = 0; + std::uint64_t R28RebindConstantMismatch = 0; + std::uint64_t R28RebindProjectionMismatch = 0; + std::uint64_t R28RebindSemanticReject = 0; + std::uint64_t R28RebindPoseReject = 0; + bool R26FirstTrackedOcclusionLogged = false; + bool R26FirstOcclusionWriteRejectLogged = false; + bool R27FirstWorldEffectLogged = false; + bool R28FirstShaderEpochWorldLogged = false; + + LARGE_INTEGER R27PerfFrequency{}; + ULONGLONG R27PerfLastLogMs = 0; + double R27PresentTotalMs = 0.0; + double R27PresentMaxMs = 0.0; + std::uint64_t R27PresentSamples = 0; + std::uint64_t R27PerfLastDrawSerial = 0; + std::uint64_t R27PerfLastPresentSamples = 0; + std::uint64_t R46ShaderFingerprintPairs = 0; + + void R46TraceShaderFingerprint(IDirect3DDevice9* device, + const R13EffectSnapshot& effect) noexcept + { + if (!OutRunVR::GplShaderFingerprint::TraceEnabled()) + return; + + const auto pair = + OutRunVR::GplShaderFingerprint::CaptureCurrent(device); + if (!OutRunVR::GplShaderFingerprint::RememberPair(pair)) + return; + + ++R46ShaderFingerprintPairs; + spdlog::info( + "VR GPL SHADER: pair={} vsHash={:016x} vsBytes={} vsPtr=0x{:x} psHash={:016x} psBytes={} psPtr=0x{:x} effect={} zKnown={} zEnabled={}", + R46ShaderFingerprintPairs, + pair.vertex.value, pair.vertex.bytecodeBytes, + pair.vertexIdentity, + pair.pixel.value, pair.pixel.bytecodeBytes, + pair.pixelIdentity, + static_cast(effect.classification), + effect.zKnown ? 1 : 0, + effect.zEnabled ? 1 : 0); + } + + + bool R26TrackedOcclusionNeedsSingleExecution( + IDirect3DDevice9* device) noexcept + { + if (!IsGameDevice(device) || InternalStereoPass || + !TargetIsBackBuffer()) + return false; + + if (AnyAuxRenderTargetActive() || R13ForceMonoShadow || + OcclusionQueryTrackingUnavailable.load(std::memory_order_acquire)) + return false; + + if (!StereoWanted() || !R9StereoSeeded) + return false; + + return ActiveOcclusionQueries.load(std::memory_order_acquire) > 0; + } + + bool R26TrackedOcclusionDrawIsWriteFree( + IDirect3DDevice9* device) noexcept + { + if (!device) + return false; + + DWORD colorWrite = 0xFFFFFFFFu; + DWORD zWrite = TRUE; + DWORD stencilEnable = FALSE; + DWORD stencilWriteMask = 0xFFFFFFFFu; + if (!ReadTrackedRenderState(device, D3DRS_COLORWRITEENABLE, colorWrite) || + !ReadTrackedRenderState(device, D3DRS_ZWRITEENABLE, zWrite) || + !ReadTrackedRenderState(device, D3DRS_STENCILENABLE, stencilEnable) || + !ReadTrackedRenderState(device, D3DRS_STENCILWRITEMASK, stencilWriteMask)) + return false; + + return colorWrite == 0 && zWrite == FALSE && + (stencilEnable == FALSE || stencilWriteMask == 0); + } + + template + HRESULT R26GuardTrackedOcclusion(IDirect3DDevice9* device, + ActualDraw&& actualDraw, NormalR23Draw&& normalR23Draw, + const char* site) + { + if (!R26TrackedOcclusionNeedsSingleExecution(device)) + return normalR23Draw(); + + // Only a proven query-only proxy may bypass replay. A regular scene + // draw bracketed by an occlusion query can still write visible color + // or depth and therefore must take the existing fail-closed path. + if (!R26TrackedOcclusionDrawIsWriteFree(device)) + { + ++R26OcclusionWriteRejects; + if (!R26FirstOcclusionWriteRejectLogged) + { + R26FirstOcclusionWriteRejectLogged = true; + spdlog::warn( + "VR R28 occlusion: active query draw can write color/depth/stencil (or state is unreadable); keeping fail-closed replay policy"); + } + return normalR23Draw(); + } + + // R23 normally owns this top-level boundary. Because this one draw + // intentionally bypasses the R23->R22->R13->R9 replay chain, keep + // its draw serial/live-state accounting explicitly in sync. + R23BeforeTopLevelDraw(device); + + ++R26TrackedOcclusionSingleExec; + ++OcclusionStereoRejects; + if (!R26FirstTrackedOcclusionLogged) + { + R26FirstTrackedOcclusionLogged = true; + spdlog::info( + "VR R28 occlusion: proven write-free active-query proxy executes once on the real game target; stereo Present remains eligible"); + } + + const HRESULT hr = actualDraw(); + if (FAILED(hr)) + R9Poison(OutRunVR::StereoFailureLeftDrawFailed, site, hr); + return hr; + } + + template + HRESULT R28RunWithVerifiedWorldEpoch(IDirect3DDevice9* device, + R9Draw&&) + { + if (!IsGameDevice(device) || InternalStereoPass || + !TargetIsBackBuffer() || !StereoWanted() || !R9StereoSeeded) + return E_NOTIMPL; + + float verified[16]{}; + std::uint32_t generation = 0; + std::uint32_t poseSequence = 0; + std::uintptr_t verifiedShader = 0; + std::uint64_t verifiedSerial = 0; + if (!OutRunVRRenderer::GetLastVerifiedWvp( + verified, generation, poseSequence, + verifiedShader, verifiedSerial)) + { + ++R28RebindNoVerified; + return E_NOTIMPL; + } + + std::uintptr_t currentShader = 0; + std::uint64_t currentSerial = 0; + if (!GetCurrentShaderEpoch(currentShader, currentSerial)) + return E_NOTIMPL; + + // Never impersonate the previous verified shader. A changed shader + // must upload/verify its own WVP before it can become world stereo. + // This closes the HUD/effect epoch leak and also removes the live + // c64..c67 readback that used to run on shader transitions. + if (currentShader != verifiedShader || + currentSerial != verifiedSerial) + { + ++R28RebindSemanticReject; + if (!R28FirstShaderEpochWorldLogged) + { + R28FirstShaderEpochWorldLogged = true; + spdlog::info( + "VR R46 WORLD GUARD: changed vertex shader cannot inherit a prior verified world epoch; draw fails closed until its own WVP is verified"); + } + } + return E_NOTIMPL; + } + + bool R27ShouldBypassLegacyZeroDisparity( + IDirect3DDevice9* device, + const R13EffectSnapshot& effect) noexcept + { + if (!IsGameDevice(device) || InternalStereoPass || + !TargetIsBackBuffer() || !StereoWanted() || !R9StereoSeeded) + return false; + if (AnyAuxRenderTargetActive() || R13ForceMonoShadow || + OcclusionQueryTrackingUnavailable.load(std::memory_order_acquire) || + ActiveOcclusionQueries.load(std::memory_order_acquire) > 0) + return false; + if (effect.classification != R13EffectClass::Flat || + !effect.zKnown || !effect.zEnabled) + return false; + + // Only a positively classified depth-tested fragile effect can + // bypass legacy zero-disparity for spatial smoke/skid/decal draws. + + // StateBlock::Apply can bypass tracked setters. Resynchronize live + // viewport/scissor at least once per Present and periodically during + // effect-heavy passes, without restoring the old per-draw query cost. + if (R27EffectStateSyncEpoch != PresentEpoch || + R23GameDrawSerial - R27LastEffectStateSyncDrawSerial >= 256) + { + R22ScissorSnapshot actual{}; + if (!R23CaptureActualGameState( + device, actual, "R28EffectDraw", true)) + return false; + R27EffectStateSyncEpoch = PresentEpoch; + R27LastEffectStateSyncDrawSerial = R23GameDrawSerial; + ++R27EffectStateResyncs; + } + return true; + } + + // Compatibility entry point for R29/C1/C2 comparison owners. The R26 + // production path passes an already captured snapshot so it still pays + // only one live render-state read; older comparison code can request a + // fresh snapshot through this overload. + bool R27ShouldBypassLegacyZeroDisparity( + IDirect3DDevice9* device) noexcept + { + const R13EffectSnapshot effect = + R13CaptureDrawTimeEffect(device); + return R27ShouldBypassLegacyZeroDisparity(device, effect); + } + + bool R37DepthDisabledFragileOverlay( + const R13EffectSnapshot& effect) noexcept + { + if (effect.classification == R13EffectClass::World) + return false; + return !effect.zKnown || !effect.zEnabled; + } + + template + HRESULT R27GuardWorldEffect(IDirect3DDevice9* device, + R9Draw&& r9Draw, LegacyR13Draw&& legacyR13Draw) + { + const R13EffectSnapshot effect = R13CaptureDrawTimeEffect(device); + R46TraceShaderFingerprint(device, effect); + auto legacyWithSnapshot = [&]() { + R13EffectOverrideScope reuse(effect); + return legacyR13Draw(); + }; + + // Positive renderer evidence outranks ZENABLE. OutRun disables Z + // for legitimate perspective sky/cloud and some billboard passes; + // forcing those to zero-disparity made them follow the headset. + // R28 proves c64..c67, projection, pose generation and perspective + // semantics before allowing the world path. + const HRESULT rebound = R28RunWithVerifiedWorldEpoch( + device, std::forward(r9Draw)); + if (rebound != E_NOTIMPL) + return rebound; + + // Screen-space veto remains conservative: unknown state fails closed. + // Only the positively verified-world path above may bypass it. + if (R37DepthDisabledFragileOverlay(effect)) + return legacyWithSnapshot(); + + if (!R27ShouldBypassLegacyZeroDisparity(device, effect)) + return legacyWithSnapshot(); + + ++R27WorldEffectDraws; + if (!R27FirstWorldEffectLogged) + { + R27FirstWorldEffectLogged = true; + spdlog::info( + "VR R28 EFFECT: legacy alpha/billboard/shadow shader-identity mask bypassed; perspective effect draw keeps verified per-eye world WVP"); + } + return r9Draw(); + } + + HRESULT __stdcall DrawPrimitiveDestR27Effect(IDirect3DDevice9* device, + D3DPRIMITIVETYPE type, UINT startVertex, UINT primitiveCount) + { + auto world = [&]() { + return R13DrawPrimitiveR9Hook.stdcall( + device, type, startVertex, primitiveCount); + }; + auto legacy = [&]() { + return R27DrawPrimitiveR13Hook.stdcall( + device, type, startVertex, primitiveCount); + }; + return R27GuardWorldEffect(device, world, legacy); + } + + HRESULT __stdcall DrawIndexedPrimitiveDestR27Effect( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + INT baseVertexIndex, UINT minVertexIndex, UINT numVertices, + UINT startIndex, UINT primitiveCount) + { + auto world = [&]() { + return R13DrawIndexedPrimitiveR9Hook.stdcall(device, type, + baseVertexIndex, minVertexIndex, numVertices, startIndex, + primitiveCount); + }; + auto legacy = [&]() { + return R27DrawIndexedPrimitiveR13Hook.stdcall(device, type, + baseVertexIndex, minVertexIndex, numVertices, startIndex, + primitiveCount); + }; + return R27GuardWorldEffect(device, world, legacy); + } + + HRESULT __stdcall DrawPrimitiveUPDestR27Effect( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + UINT primitiveCount, const void* data, UINT stride) + { + auto world = [&]() { + return R13DrawPrimitiveUPR9Hook.stdcall( + device, type, primitiveCount, data, stride); + }; + auto legacy = [&]() { + return R27DrawPrimitiveUPR13Hook.stdcall( + device, type, primitiveCount, data, stride); + }; + return R27GuardWorldEffect(device, world, legacy); + } + + HRESULT __stdcall DrawIndexedPrimitiveUPDestR27Effect( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + UINT minVertexIndex, UINT numVertices, UINT primitiveCount, + const void* indexData, D3DFORMAT indexFormat, + const void* vertexData, UINT stride) + { + auto world = [&]() { + return R13DrawIndexedPrimitiveUPR9Hook.stdcall(device, type, + minVertexIndex, numVertices, primitiveCount, indexData, + indexFormat, vertexData, stride); + }; + auto legacy = [&]() { + return R27DrawIndexedPrimitiveUPR13Hook.stdcall(device, type, + minVertexIndex, numVertices, primitiveCount, indexData, + indexFormat, vertexData, stride); + }; + return R27GuardWorldEffect(device, world, legacy); + } + + HRESULT __stdcall DrawPrimitiveDestR26(IDirect3DDevice9* device, + D3DPRIMITIVETYPE type, UINT startVertex, UINT primitiveCount) + { + auto actual = [&]() { + return DrawPrimitiveHook.stdcall( + device, type, startVertex, primitiveCount); + }; + auto normal = [&]() { + return R26DrawPrimitiveR23Hook.stdcall( + device, type, startVertex, primitiveCount); + }; + return R26GuardTrackedOcclusion( + device, actual, normal, "R28/DrawPrimitive"); + } + + HRESULT __stdcall DrawIndexedPrimitiveDestR26(IDirect3DDevice9* device, + D3DPRIMITIVETYPE type, INT baseVertexIndex, UINT minVertexIndex, + UINT numVertices, UINT startIndex, UINT primitiveCount) + { + auto actual = [&]() { + return DrawIndexedPrimitiveHook.stdcall(device, type, + baseVertexIndex, minVertexIndex, numVertices, startIndex, + primitiveCount); + }; + auto normal = [&]() { + return R26DrawIndexedPrimitiveR23Hook.stdcall(device, type, + baseVertexIndex, minVertexIndex, numVertices, startIndex, + primitiveCount); + }; + return R26GuardTrackedOcclusion( + device, actual, normal, "R28/DrawIndexedPrimitive"); + } + + HRESULT __stdcall DrawPrimitiveUPDestR26(IDirect3DDevice9* device, + D3DPRIMITIVETYPE type, UINT primitiveCount, const void* data, + UINT stride) + { + auto actual = [&]() { + return DrawPrimitiveUPHook.stdcall( + device, type, primitiveCount, data, stride); + }; + auto normal = [&]() { + return R26DrawPrimitiveUPR23Hook.stdcall( + device, type, primitiveCount, data, stride); + }; + return R26GuardTrackedOcclusion( + device, actual, normal, "R28/DrawPrimitiveUP"); + } + + HRESULT __stdcall DrawIndexedPrimitiveUPDestR26( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + UINT minVertexIndex, UINT numVertices, UINT primitiveCount, + const void* indexData, D3DFORMAT indexFormat, + const void* vertexData, UINT stride) + { + auto actual = [&]() { + return DrawIndexedPrimitiveUPHook.stdcall(device, type, + minVertexIndex, numVertices, primitiveCount, indexData, + indexFormat, vertexData, stride); + }; + auto normal = [&]() { + return R26DrawIndexedPrimitiveUPR23Hook.stdcall(device, type, + minVertexIndex, numVertices, primitiveCount, indexData, + indexFormat, vertexData, stride); + }; + return R26GuardTrackedOcclusion( + device, actual, normal, "R28/DrawIndexedPrimitiveUP"); + } + + HRESULT __stdcall PresentDestR27(IDirect3DDevice9* device, + const RECT* sourceRect, const RECT* destRect, + HWND destWindowOverride, const RGNDATA* dirtyRegion) + { + LARGE_INTEGER begin{}, end{}; + QueryPerformanceCounter(&begin); + const HRESULT hr = R27PresentR23Hook.stdcall(device, + sourceRect, destRect, destWindowOverride, dirtyRegion); + QueryPerformanceCounter(&end); + + if (R27PerfFrequency.QuadPart <= 0) + QueryPerformanceFrequency(&R27PerfFrequency); + if (R27PerfFrequency.QuadPart > 0) + { + const double ms = static_cast(end.QuadPart - begin.QuadPart) * + 1000.0 / static_cast(R27PerfFrequency.QuadPart); + R27PresentTotalMs += ms; + if (ms > R27PresentMaxMs) R27PresentMaxMs = ms; + ++R27PresentSamples; + } + + if (Settings::VRTelemetry) + { + const ULONGLONG now = GetTickCount64(); + if (R27PerfLastLogMs == 0 || now - R27PerfLastLogMs >= 5000) + { + const std::uint64_t sampleDelta = + R27PresentSamples - R27PerfLastPresentSamples; + const std::uint64_t drawDelta = + R23GameDrawSerial - R27PerfLastDrawSerial; + const double avg = R27PresentSamples ? + R27PresentTotalMs / static_cast(R27PresentSamples) : 0.0; + const double drawsPerPresent = sampleDelta ? + static_cast(drawDelta) / static_cast(sampleDelta) : 0.0; + spdlog::info( + "VR R28 PERF: lower-Present avgMs={:.3f} maxMs={:.3f} drawsPerPresent={:.1f} worldEffectDraws={} worldEpochRebind={} noVerified={} constMismatch={} projMismatch={} semanticReject={} poseReject={} effectStateResync={} occSingle={} occWriteReject={}", + avg, R27PresentMaxMs, drawsPerPresent, + R27WorldEffectDraws, R28ShaderEpochWorldRebinds, + R28RebindNoVerified, R28RebindConstantMismatch, + R28RebindProjectionMismatch, R28RebindSemanticReject, + R28RebindPoseReject, R27EffectStateResyncs, + R26TrackedOcclusionSingleExec, R26OcclusionWriteRejects); + R27PerfLastLogMs = now; + R27PerfLastDrawSerial = R23GameDrawSerial; + R27PresentTotalMs = 0.0; + R27PresentMaxMs = 0.0; + R27PresentSamples = 0; + R27PerfLastPresentSamples = 0; + } + } + return hr; + } + + void R26RollbackHooks() noexcept + { + R27PresentR23Hook = {}; + R27DrawIndexedPrimitiveUPR13Hook = {}; + R27DrawPrimitiveUPR13Hook = {}; + R27DrawIndexedPrimitiveR13Hook = {}; + R27DrawPrimitiveR13Hook = {}; + R26DrawIndexedPrimitiveUPR23Hook = {}; + R26DrawPrimitiveUPR23Hook = {}; + R26DrawIndexedPrimitiveR23Hook = {}; + R26DrawPrimitiveR23Hook = {}; + } + + bool R26EnableHooks() noexcept + { + SafetyHookInline* hooks[]{ + &R27DrawPrimitiveR13Hook, + &R27DrawIndexedPrimitiveR13Hook, + &R27DrawPrimitiveUPR13Hook, + &R27DrawIndexedPrimitiveUPR13Hook, + &R26DrawPrimitiveR23Hook, + &R26DrawIndexedPrimitiveR23Hook, + &R26DrawPrimitiveUPR23Hook, + &R26DrawIndexedPrimitiveUPR23Hook, + &R27PresentR23Hook + }; + for (auto* hook : hooks) + { + if (!*hook || !hook->enable().has_value()) + return false; + } + return true; + } + + DWORD WINAPI R26OcclusionInstallThread(void*) + { + using State = OutRunVR::RuntimeEligibility::InstallState; + R26InstallState.store(State::Pending, std::memory_order_release); + + for (int attempt = 0; attempt < 4800; ++attempt) + { + const auto r23 = R23InstallState.load(std::memory_order_acquire); + if (r23 == State::Failed) + { + R26InstallState.store(State::Failed, std::memory_order_release); + HookManager::ReportAsyncResult("OpenXRVROcclusionR26", false); + spdlog::error( + "VR R26/R28: R23 prerequisite failed; correction overlay not installed"); + return 0; + } + + if (r23 == State::Ready) + { + const auto disabled = safetyhook::InlineHook::StartDisabled; + + R27DrawPrimitiveR13Hook = safetyhook::create_inline( + reinterpret_cast(&DrawPrimitiveDestR13), + DrawPrimitiveDestR27Effect, disabled); + R27DrawIndexedPrimitiveR13Hook = safetyhook::create_inline( + reinterpret_cast(&DrawIndexedPrimitiveDestR13), + DrawIndexedPrimitiveDestR27Effect, disabled); + R27DrawPrimitiveUPR13Hook = safetyhook::create_inline( + reinterpret_cast(&DrawPrimitiveUPDestR13), + DrawPrimitiveUPDestR27Effect, disabled); + R27DrawIndexedPrimitiveUPR13Hook = safetyhook::create_inline( + reinterpret_cast(&DrawIndexedPrimitiveUPDestR13), + DrawIndexedPrimitiveUPDestR27Effect, disabled); + + R26DrawPrimitiveR23Hook = safetyhook::create_inline( + reinterpret_cast(&DrawPrimitiveDestR23), + DrawPrimitiveDestR26, disabled); + R26DrawIndexedPrimitiveR23Hook = safetyhook::create_inline( + reinterpret_cast(&DrawIndexedPrimitiveDestR23), + DrawIndexedPrimitiveDestR26, disabled); + R26DrawPrimitiveUPR23Hook = safetyhook::create_inline( + reinterpret_cast(&DrawPrimitiveUPDestR23), + DrawPrimitiveUPDestR26, disabled); + R26DrawIndexedPrimitiveUPR23Hook = safetyhook::create_inline( + reinterpret_cast(&DrawIndexedPrimitiveUPDestR23), + DrawIndexedPrimitiveUPDestR26, disabled); + R27PresentR23Hook = safetyhook::create_inline( + reinterpret_cast(&PresentDestR23), + PresentDestR27, disabled); + + if (!R26EnableHooks()) + { + R26RollbackHooks(); + R26InstallState.store(State::Failed, std::memory_order_release); + HookManager::ReportAsyncResult("OpenXRVROcclusionR26", false); + spdlog::error( + "VR R26/R28: disabled-first correction transaction failed; R23 remains active"); + return 0; + } + + R26InstallState.store(State::Ready, std::memory_order_release); + HookManager::ReportAsyncResult("OpenXRVROcclusionR26", true); + spdlog::info( + "VR R26/R28 GAME: verified-world shader-epoch recovery + partial-WVP reconstruction + effect correction + write-free occlusion single-execution ACTIVE"); + return 0; + } + Sleep(25); + } + + R26InstallState.store(State::Failed, std::memory_order_release); + HookManager::ReportAsyncResult("OpenXRVROcclusionR26", false); + spdlog::error( + "VR R26/R28: timed out waiting for R23; correction overlay not installed"); + return 0; + } + + class VROcclusionR26Hook final : public Hook + { + public: + std::string_view description() override + { + return "OpenXRVROcclusionR26"; + } + bool validate() override { return true; } + bool apply() override + { + using State = OutRunVR::RuntimeEligibility::InstallState; + R26InstallState.store(State::Pending, std::memory_order_release); + HANDLE thread = CreateThread(nullptr, 0, + R26OcclusionInstallThread, nullptr, 0, nullptr); + if (!thread) + { + R26InstallState.store(State::Failed, std::memory_order_release); + return false; + } + CloseHandle(thread); + return true; + } + static VROcclusionR26Hook instance; + }; + + VROcclusionR26Hook VROcclusionR26Hook::instance; + } +} diff --git a/src/vr/d3d9/stereo_renderer_r26_compare.cpp b/src/vr/d3d9/stereo_renderer_r26_compare.cpp new file mode 100644 index 000000000..72fce08c5 --- /dev/null +++ b/src/vr/d3d9/stereo_renderer_r26_compare.cpp @@ -0,0 +1,41 @@ +// Safe-draw comparison build. +// +// This translation unit intentionally stops the stereo draw chain at R26/R28. +// It keeps the current R23/R22/R13/R9 recovery and correctness layers, while +// excluding the later R29-R34 fast/HUD/StateBlock dispatch overlays. The current +// D3D9Ex R15, DirectGPU transport, texture lifetime fixes, wheel, multi-device +// and FFB code remain unchanged. The game renderer owner is deliberately rolled +// back from R29 to R23 so this build matches the proven R26/R23/R22 boundary. +// +// Build only with OUTRUN_VR_SAFE_DRAW_COMPARE=ON. In normal builds this file is +// marked HEADER_FILE_ONLY by cmake.toml. + +#include "stereo_renderer_r26.cpp" + +namespace OutRunVRStereo +{ + namespace + { + class VRSafeDrawCompareMarkerHook final : public Hook + { + public: + std::string_view description() override + { + return "OpenXRVRSafeDrawCompare"; + } + + bool validate() override { return true; } + + bool apply() override + { + spdlog::warn( + "VR SAFE-DRAW COMPARE: R26/R23/R22/R13/R9 stereo draw chain ACTIVE; R29-R34 stereo overlays + renderer R29 EXCLUDED; current R15/DirectGPU/texture paths preserved"); + return true; + } + + static VRSafeDrawCompareMarkerHook instance; + }; + + VRSafeDrawCompareMarkerHook VRSafeDrawCompareMarkerHook::instance; + } +} diff --git a/src/vr/d3d9/stereo_renderer_r29.cpp b/src/vr/d3d9/stereo_renderer_r29.cpp new file mode 100644 index 000000000..a897de922 --- /dev/null +++ b/src/vr/d3d9/stereo_renderer_r29.cpp @@ -0,0 +1,571 @@ +// R29 stereo hot-path and effect-safety overlay. +// +// R26/R28 proved the base stereo path but paid two large costs on every draw: +// 1) R9 rendered an independent mono safety copy before LEFT+RIGHT; +// 2) R28 re-read c64..c67/projection/pose and temporarily impersonated an old +// vertex-shader epoch to widen world classification. +// +// R29 removes both from the steady-state path. All R23/R22 accounting and +// viewport/scissor preservation still execute first. Once a draw is proven to +// be an ordinary main-backbuffer stereo draw, R29 enters the validated R7 +// LEFT+RIGHT replay directly. Recovery, MRT/query hazards, depth transitions, +// and other uncertain states still fall through to the complete R13/R9 safety +// chain. Fragile effects use R13's exact policy but apply zero disparity without +// a third mono replay. + +#include "stereo_renderer_r26.cpp" + +namespace OutRunVRRenderer +{ + void R29InvalidateRawWvpGeneration() noexcept; + void R29InvalidateRendererStateAfterExternalRestore() noexcept; + OutRunVR::RuntimeEligibility::InstallState R29RendererState() noexcept; +} + +namespace OutRunVRStereo +{ + namespace + { + SafetyHookInline R29DrawPrimitiveR27Hook{}; + SafetyHookInline R29DrawIndexedPrimitiveR27Hook{}; + SafetyHookInline R29DrawPrimitiveUPR27Hook{}; + SafetyHookInline R29DrawIndexedPrimitiveUPR27Hook{}; + SafetyHookInline R29SetRenderStateR22Hook{}; + + std::atomic R29StereoInstallState{ + OutRunVR::RuntimeEligibility::InstallState::Pending }; + + struct R29EffectState + { + DWORD alphaBlend = FALSE; + DWORD alphaTest = FALSE; + DWORD zWrite = TRUE; + DWORD zEnable = D3DZB_TRUE; + DWORD cullMode = D3DCULL_CCW; + bool valid = false; + std::uint64_t presentEpoch = 0; + std::uint64_t drawSerial = 0; + }; + + thread_local R29EffectState R29Effect{}; + std::uint64_t R29StableTwoEyeDraws = 0; + std::uint64_t R29ZeroDisparityTwoEyeDraws = 0; + std::uint64_t R29SafetyFallbackDraws = 0; + std::uint64_t R29EffectStateSyncs = 0; + std::uint64_t R29FragileWorldRetries = 0; + std::uint64_t R29MonoSafetyThroughEpoch = 2; + bool R29FirstStableLogged = false; + bool R29FirstZeroDisparityLogged = false; + bool R29FirstSafetyFallbackLogged = false; + + void R29ArmMonoSafety(std::uint64_t extraPresents = 2) noexcept + { + const std::uint64_t target = PresentEpoch + extraPresents; + if (target > R29MonoSafetyThroughEpoch) + R29MonoSafetyThroughEpoch = target; + } + + bool R29SyncEffectState(IDirect3DDevice9* device) noexcept + { + if (!device) + return false; + + // SetRenderState updates the cache synchronously. One live refresh + // per Present plus a sparse 64-draw bound covers StateBlock::Apply, + // which can bypass the setter hook, without four getters per draw. + if (R29Effect.valid && R29Effect.presentEpoch == PresentEpoch && + R23GameDrawSerial >= R29Effect.drawSerial && + R23GameDrawSerial - R29Effect.drawSerial < 64) + return true; + + DWORD alphaBlend = FALSE; + DWORD alphaTest = FALSE; + DWORD zWrite = TRUE; + DWORD zEnable = D3DZB_TRUE; + DWORD cullMode = D3DCULL_CCW; + if (FAILED(device->GetRenderState( + D3DRS_ALPHABLENDENABLE, &alphaBlend)) || + FAILED(device->GetRenderState( + D3DRS_ALPHATESTENABLE, &alphaTest)) || + FAILED(device->GetRenderState(D3DRS_ZWRITEENABLE, &zWrite)) || + FAILED(device->GetRenderState(D3DRS_ZENABLE, &zEnable)) || + FAILED(device->GetRenderState(D3DRS_CULLMODE, &cullMode))) + { + R29Effect.valid = false; + return false; + } + + R29Effect.alphaBlend = alphaBlend; + R29Effect.alphaTest = alphaTest; + R29Effect.zWrite = zWrite; + R29Effect.zEnable = zEnable; + R29Effect.cullMode = cullMode; + R29Effect.valid = true; + R29Effect.presentEpoch = PresentEpoch; + R29Effect.drawSerial = R23GameDrawSerial; + ++R29EffectStateSyncs; + return true; + } + + bool R29FragileEffectCached(IDirect3DDevice9* device, + bool& fragile) noexcept + { + fragile = true; + if (!R29SyncEffectState(device)) + return false; + + const auto policy = OutRunVR::PassPolicy::ClassifyEffectStereo( + R29Effect.alphaBlend != FALSE, + R29Effect.alphaTest != FALSE, + R29Effect.zWrite != FALSE, + R29Effect.zEnable != D3DZB_FALSE, + R29Effect.cullMode == D3DCULL_NONE); + fragile = !OutRunVR::PassPolicy::AllowsEffectWorldStereo(policy); + return true; + } + + bool R29StableStereoBase(IDirect3DDevice9* device) noexcept + { + if (!IsGameDevice(device) || InternalStereoPass || + !TargetIsBackBuffer() || !StereoWanted() || !R9StereoSeeded) + return false; + + if (!OutRunVR::RuntimeEligibility::MayInjectStereo() || + OutRunVR::RuntimeEligibility::RecoveryPending.load( + std::memory_order_acquire) || + OutRunVR::RuntimeEligibility::PoseWarmupAllowed()) + { + R29ArmMonoSafety(); + return false; + } + + if (PresentEpoch <= R29MonoSafetyThroughEpoch) + return false; + + if (FrameStereoIncomplete || R9DeferredDepth || + !R9CurrentDepthCanMirror() || AnyAuxRenderTargetActive() || + R13ForceMonoShadow || + OcclusionQueryTrackingUnavailable.load(std::memory_order_acquire) || + ActiveOcclusionQueries.load(std::memory_order_acquire) > 0) + { + R29ArmMonoSafety(); + return false; + } + return true; + } + + template + HRESULT R29DirectTwoEye(IDirect3DDevice9* device, + StereoR7Draw&& stereoR7Draw, bool zeroDisparity, + const char* site) + { + ++R9DrawCalls; + + // This frame intentionally does not maintain a complete independent + // mono history. R9 must therefore never restore a stale/incomplete + // safety RT if a later hazard poisons the same Present. + R9MonoBackupGap = true; + + if (LeftDrawMayWriteDepth(device) || + LeftDrawMayWriteStencil(device)) + ++R9MainDepthContentSerial; + + std::uintptr_t savedIdentity = 0; + if (zeroDisparity) + { + savedIdentity = CurrentVertexShaderIdentity.exchange( + 0, std::memory_order_acq_rel); + } + + const auto before = FrameFailureReason; + HRESULT hr = D3D_OK; + if (!TrackedDepthStencil) + { + IDirect3DSurface9* savedRightDepth = RightEyeDepth; + RightEyeDepth = nullptr; + hr = stereoR7Draw(); + RightEyeDepth = savedRightDepth; + } + else + { + hr = stereoR7Draw(); + } + + if (zeroDisparity && savedIdentity != 0) + { + std::uintptr_t expected = 0; + CurrentVertexShaderIdentity.compare_exchange_strong( + expected, savedIdentity, + std::memory_order_acq_rel, std::memory_order_acquire); + } + + R9ObserveLegacyFailure(before, site, hr); + ++R29StableTwoEyeDraws; + if (zeroDisparity) + { + ++R29ZeroDisparityTwoEyeDraws; + ++R13DrawTimeZeroDisparityDraws; + if (!R29FirstZeroDisparityLogged) + { + R29FirstZeroDisparityLogged = true; + spdlog::info( + "VR R29 EFFECT: depth-disabled two-sided alpha effect uses stock-WVP zero disparity; depth-tested projected shadows/cutouts remain spatial world stereo"); + } + } + + if (!R29FirstStableLogged) + { + R29FirstStableLogged = true; + spdlog::info( + "VR R29 PERF: stable main-backbuffer draws now execute LEFT+RIGHT only; per-draw mono safety replay and shader-epoch impersonation are bypassed"); + } + + if (FAILED(hr) || FrameStereoIncomplete || + FrameFailureReason != OutRunVR::StereoFailureNone) + R29ArmMonoSafety(); + return hr; + } + + template + HRESULT R29GuardStableDraw(IDirect3DDevice9* device, + StereoR7Draw&& stereoR7Draw, + LegacyR13Draw&& legacyR13Draw, + const char* site) + { + if (!R29StableStereoBase(device)) + { + ++R29SafetyFallbackDraws; + if (!R29FirstSafetyFallbackLogged) + { + R29FirstSafetyFallbackLogged = true; + spdlog::info( + "VR R29 SAFETY: recovery/hazard draws retain the complete R13/R9 mono-shadow path"); + } + return legacyR13Draw(); + } + +#if defined(OUTRUN_VR_R29_R28_CLASSIFICATION_COMPARE) + // C1/C2 world-correctness path. + // + // R28's proven path did more than classify the current target as + // perspective world: when the game changed vertex shader AFTER the + // verified c64..c67 upload, R28 temporarily rebound the verified + // shader epoch before entering R9. Without that step R7 sees the + // shader-epoch mismatch, returns NonWorld, and duplicates the draw + // with the stock WVP. On hardware this shows up exactly as the road + // and car having little/incorrect binocular disparity while nearby + // effect/edge geometry still sits at the correct depth. + // + // Keep R29's two-eye accounting/performance path, but run it inside + // the old strict R28 rebind gate when WVP + projection + pose all + // still match. If no rebind is needed, fall through to the ordinary + // perspective fast path. + std::uintptr_t verifiedShaderIdentity = 0; + std::uint64_t verifiedShaderSerial = 0; + if (R28CanRebindVerifiedWorld( + device, verifiedShaderIdentity, verifiedShaderSerial)) + { + // Do not reuse R28RunWithVerifiedWorldEpoch's E_NOTIMPL + // sentinel here: a real D3D draw HRESULT must never be mistaken + // for "not handled" and executed twice. C1 has a direct boolean + // decision from R28CanRebindVerifiedWorld, so preserve the + // actual draw result verbatim. + const std::uintptr_t savedIdentity = + CurrentVertexShaderIdentity.exchange( + verifiedShaderIdentity, std::memory_order_acq_rel); + const std::uint64_t savedSerial = + VertexShaderSerial.exchange( + verifiedShaderSerial, std::memory_order_acq_rel); + + const HRESULT rebound = R29DirectTwoEye( + device, stereoR7Draw, false, site); + + std::uintptr_t expectedIdentity = verifiedShaderIdentity; + CurrentVertexShaderIdentity.compare_exchange_strong( + expectedIdentity, savedIdentity, + std::memory_order_acq_rel, std::memory_order_acquire); + std::uint64_t expectedSerial = verifiedShaderSerial; + VertexShaderSerial.compare_exchange_strong( + expectedSerial, savedSerial, + std::memory_order_acq_rel, std::memory_order_acquire); + + ++R28ShaderEpochWorldRebinds; + if (!R28FirstShaderEpochWorldLogged) + { + R28FirstShaderEpochWorldLogged = true; + spdlog::info( + "VR C1 WORLD FIX: verified WVP/projection/pose survived a shader switch; R29 fast path rebound the R28 shader epoch so road/car draws keep per-eye world stereo"); + } + return rebound; + } + + if (OutRunVRRenderer::R28PerspectiveWorldSemantic()) + { + return R29DirectTwoEye(device, + std::forward(stereoR7Draw), + false, site); + } +#endif + + bool fragile = true; + if (!R29FragileEffectCached(device, fragile)) + { + ++R29SafetyFallbackDraws; + return legacyR13Draw(); + } + + // R29 originally forced every depth-disabled two-sided alpha draw + // to zero disparity here. That is too broad for OutRun: smoke, + // skid/decal billboards and the white floating position glyphs can + // be alpha/depth-disabled while still carrying the verified world + // WVP. Let the proven R27 gate try the unmasked R7 classifier first. + // If the WVP is not actually verified, R7 still fails closed to + // NonWorld/stock constants, so screen HUD remains safe. + if (fragile && R27ShouldBypassLegacyZeroDisparity(device)) + { + fragile = false; + ++R29FragileWorldRetries; + } + + return R29DirectTwoEye(device, + std::forward(stereoR7Draw), + fragile, site); + } + + HRESULT __stdcall DrawPrimitiveDestR29(IDirect3DDevice9* device, + D3DPRIMITIVETYPE type, UINT startVertex, UINT primitiveCount) + { + auto stereo = [&]() { + return R9DrawPrimitiveCallbackHook.stdcall( + device, type, startVertex, primitiveCount); + }; + auto legacy = [&]() { + return R27DrawPrimitiveR13Hook.stdcall( + device, type, startVertex, primitiveCount); + }; + return R29GuardStableDraw( + device, stereo, legacy, "R29/DrawPrimitive"); + } + + HRESULT __stdcall DrawIndexedPrimitiveDestR29( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + INT baseVertexIndex, UINT minVertexIndex, UINT numVertices, + UINT startIndex, UINT primitiveCount) + { + auto stereo = [&]() { + return R9DrawIndexedPrimitiveCallbackHook.stdcall( + device, type, baseVertexIndex, minVertexIndex, numVertices, + startIndex, primitiveCount); + }; + auto legacy = [&]() { + return R27DrawIndexedPrimitiveR13Hook.stdcall( + device, type, baseVertexIndex, minVertexIndex, numVertices, + startIndex, primitiveCount); + }; + return R29GuardStableDraw( + device, stereo, legacy, "R29/DrawIndexedPrimitive"); + } + + HRESULT __stdcall DrawPrimitiveUPDestR29( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + UINT primitiveCount, const void* data, UINT stride) + { + auto stereo = [&]() { + return R9DrawPrimitiveUPCallbackHook.stdcall( + device, type, primitiveCount, data, stride); + }; + auto legacy = [&]() { + return R27DrawPrimitiveUPR13Hook.stdcall( + device, type, primitiveCount, data, stride); + }; + return R29GuardStableDraw( + device, stereo, legacy, "R29/DrawPrimitiveUP"); + } + + HRESULT __stdcall DrawIndexedPrimitiveUPDestR29( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + UINT minVertexIndex, UINT numVertices, UINT primitiveCount, + const void* indexData, D3DFORMAT indexFormat, + const void* vertexData, UINT stride) + { + auto stereo = [&]() { + return R9DrawIndexedPrimitiveUPCallbackHook.stdcall( + device, type, minVertexIndex, numVertices, primitiveCount, + indexData, indexFormat, vertexData, stride); + }; + auto legacy = [&]() { + return R27DrawIndexedPrimitiveUPR13Hook.stdcall( + device, type, minVertexIndex, numVertices, primitiveCount, + indexData, indexFormat, vertexData, stride); + }; + return R29GuardStableDraw( + device, stereo, legacy, "R29/DrawIndexedPrimitiveUP"); + } + + HRESULT __stdcall SetRenderStateDestR29(IDirect3DDevice9* device, + D3DRENDERSTATETYPE state, DWORD value) + { + const HRESULT hr = R29SetRenderStateR22Hook.stdcall( + device, state, value); + if (FAILED(hr) || !IsGameDevice(device) || InternalStereoPass) + return hr; + + switch (state) + { + case D3DRS_ALPHABLENDENABLE: + R29Effect.alphaBlend = value; + break; + case D3DRS_ALPHATESTENABLE: + R29Effect.alphaTest = value; + break; + case D3DRS_ZWRITEENABLE: + R29Effect.zWrite = value; + break; + case D3DRS_ZENABLE: + R29Effect.zEnable = value; + break; + case D3DRS_CULLMODE: + R29Effect.cullMode = value; + break; + default: + return hr; + } + + // If the cache has already been live-synchronized, tracked setters + // keep it authoritative without any draw-time D3D getter. + if (R29Effect.valid) + { + R29Effect.presentEpoch = PresentEpoch; + R29Effect.drawSerial = R23GameDrawSerial; + } + return hr; + } + + void R29RollbackStereoHooks() noexcept + { + R29SetRenderStateR22Hook = {}; + R29DrawIndexedPrimitiveUPR27Hook = {}; + R29DrawPrimitiveUPR27Hook = {}; + R29DrawIndexedPrimitiveR27Hook = {}; + R29DrawPrimitiveR27Hook = {}; + } + + bool R29EnableStereoHooks() noexcept + { + SafetyHookInline* hooks[]{ + &R29DrawPrimitiveR27Hook, + &R29DrawIndexedPrimitiveR27Hook, + &R29DrawPrimitiveUPR27Hook, + &R29DrawIndexedPrimitiveUPR27Hook, + &R29SetRenderStateR22Hook + }; + for (auto* hook : hooks) + { + if (!*hook || !hook->enable().has_value()) + return false; + } + return true; + } + + DWORD WINAPI R29StereoInstallThread(void*) + { + using State = OutRunVR::RuntimeEligibility::InstallState; + R29StereoInstallState.store(State::Pending, std::memory_order_release); + + for (int attempt = 0; attempt < 4800; ++attempt) + { + const auto r26 = R26InstallState.load(std::memory_order_acquire); + const auto rendererR29 = OutRunVRRenderer::R29RendererState(); + if (r26 == State::Failed || rendererR29 == State::Failed) + { + R29StereoInstallState.store(State::Failed, + std::memory_order_release); + HookManager::ReportAsyncResult("OpenXRVRStereoR29", false); + spdlog::error( + "VR R29 STEREO: R26 or renderer-R29 prerequisite failed; R26/R28 remains active"); + return 0; + } + + if (r26 == State::Ready && rendererR29 == State::Ready) + { + const auto disabled = safetyhook::InlineHook::StartDisabled; + R29DrawPrimitiveR27Hook = safetyhook::create_inline( + reinterpret_cast(&DrawPrimitiveDestR27Effect), + DrawPrimitiveDestR29, disabled); + R29DrawIndexedPrimitiveR27Hook = safetyhook::create_inline( + reinterpret_cast(&DrawIndexedPrimitiveDestR27Effect), + DrawIndexedPrimitiveDestR29, disabled); + R29DrawPrimitiveUPR27Hook = safetyhook::create_inline( + reinterpret_cast(&DrawPrimitiveUPDestR27Effect), + DrawPrimitiveUPDestR29, disabled); + R29DrawIndexedPrimitiveUPR27Hook = safetyhook::create_inline( + reinterpret_cast(&DrawIndexedPrimitiveUPDestR27Effect), + DrawIndexedPrimitiveUPDestR29, disabled); + R29SetRenderStateR22Hook = safetyhook::create_inline( + reinterpret_cast(&SetRenderStateDestR22), + SetRenderStateDestR29, disabled); + + if (!R29EnableStereoHooks()) + { + R29RollbackStereoHooks(); + R29StereoInstallState.store(State::Failed, + std::memory_order_release); + HookManager::ReportAsyncResult("OpenXRVRStereoR29", false); + spdlog::error( + "VR R29 STEREO: disabled-first transaction failed; R26/R28 remains active"); + return 0; + } + + // First visible stereo frames after install intentionally use + // the old complete mono safety path. R23's authoritative + // baseline copies the current backbuffer into RightEyeSurface + // and clears private right depth/stencil before this opens. + R29ArmMonoSafety(2); + OutRunVRRenderer::R29InvalidateRawWvpGeneration(); + R29StereoInstallState.store(State::Ready, + std::memory_order_release); + HookManager::ReportAsyncResult("OpenXRVRStereoR29", true); + spdlog::info( + "VR R29 STEREO: conservative effect classification + cached render state + steady-state two-eye path ACTIVE"); + return 0; + } + Sleep(25); + } + + R29StereoInstallState.store(State::Failed, std::memory_order_release); + HookManager::ReportAsyncResult("OpenXRVRStereoR29", false); + spdlog::error( + "VR R29 STEREO: timed out waiting for R26/renderer-R29; R26/R28 remains active"); + return 0; + } + + class VRStereoR29Hook final : public Hook + { + public: + std::string_view description() override + { + return "OpenXRVRStereoR29"; + } + bool validate() override { return true; } + bool apply() override + { + using State = OutRunVR::RuntimeEligibility::InstallState; + R29StereoInstallState.store(State::Pending, + std::memory_order_release); + HANDLE thread = CreateThread(nullptr, 0, + R29StereoInstallThread, nullptr, 0, nullptr); + if (!thread) + { + R29StereoInstallState.store(State::Failed, + std::memory_order_release); + return false; + } + CloseHandle(thread); + return true; + } + static VRStereoR29Hook instance; + }; + + VRStereoR29Hook VRStereoR29Hook::instance; + } +} diff --git a/src/vr/d3d9/stereo_renderer_r29_c1_compare.cpp b/src/vr/d3d9/stereo_renderer_r29_c1_compare.cpp new file mode 100644 index 000000000..eba3ec451 --- /dev/null +++ b/src/vr/d3d9/stereo_renderer_r29_c1_compare.cpp @@ -0,0 +1,47 @@ +// C1 regression-isolation build. +// +// Keep R29's steady-state LEFT+RIGHT fast path, but restore the R23/R27/R28 +// perspective-world classifier through OUTRUN_VR_R29_R28_CLASSIFICATION_COMPARE. +// R30-R34 are intentionally absent. R31 normally exports the StateBlock helper +// functions below; this isolated build has no R31 owner, so both report +// conservative/unreliable and R29/R28 fall back to live validation. + +#include "stereo_renderer_r29.cpp" + +namespace OutRunVRStereo +{ + bool IsGameStateBlockRecording() noexcept + { + return false; + } + + bool IsStateBlockTrackingReliable() noexcept + { + return false; + } + + namespace + { + class VRC1CompareMarkerHook final : public Hook + { + public: + std::string_view description() override + { + return "OpenXRVRC1Compare"; + } + + bool validate() override { return true; } + + bool apply() override + { + spdlog::warn( + "VR C1 COMPARE: R29 fast L+R + R23/R27/R28 perspective classification ACTIVE; R30-R34 excluded"); + return true; + } + + static VRC1CompareMarkerHook instance; + }; + + VRC1CompareMarkerHook VRC1CompareMarkerHook::instance; + } +} diff --git a/src/vr/d3d9/stereo_renderer_r30.cpp b/src/vr/d3d9/stereo_renderer_r30.cpp new file mode 100644 index 000000000..bc7eb2c15 --- /dev/null +++ b/src/vr/d3d9/stereo_renderer_r30.cpp @@ -0,0 +1,3101 @@ +// R30 screen-space asymmetric-FOV correction overlay. +// +// R29 restores conservative world/effect classification and removes the steady- +// state third mono draw. R30 fixes the remaining HUD convergence problem without +// moving the world image: only main-backbuffer orthographic/ScreenSpace2D draws +// receive an eye-specific clip-space X affine. The affine maps one common +// head-relative tangent-angle interval into each eye's actual asymmetric OpenXR +// FOV, so the same HUD feature lands on the same visual ray in both eyes. +// +// This is intentionally an interim projection-layer HUD solution. It does not +// pretend to be XrCompositionLayerQuad: perspective world draws and fragile +// perspective effects remain entirely owned by R29/R13. + +#include "stereo_renderer_r29.cpp" +#include +#include +#include +#include +#include +#include + +namespace Settings +{ + extern Setting VRHudScale; + extern Setting VRStereoDepth; + extern Setting SkyGlowFactor; + extern Setting SkyGlowTwoStep; +} + +namespace OutRunVRStereo +{ + namespace + { + SafetyHookInline R30DrawPrimitiveR29Hook{}; + SafetyHookInline R30DrawIndexedPrimitiveR29Hook{}; + SafetyHookInline R30DrawPrimitiveUPR29Hook{}; + SafetyHookInline R30DrawIndexedPrimitiveUPR29Hook{}; + + std::atomic R30InstallState{ + OutRunVR::RuntimeEligibility::InstallState::Pending }; + + std::uint64_t R30ScreenSpaceFovDraws = 0; + std::uint64_t R30ScreenSpaceFallbacks = 0; + std::uint64_t R30ScreenSpaceBuildFailures = 0; + bool R30FirstScreenSpaceLogged = false; + bool R30FirstFlatPerspectiveLogged = false; + + std::uint64_t R30XyzrhwHudDraws = 0; + std::uint64_t R30XyzrhwWorldEffectDraws = 0; + std::uint64_t R30XyzrhwRhwWorldPromotions = 0; + std::uint64_t R30XyzrhwFallbacks = 0; + bool R30FirstXyzrhwHudLogged = false; + bool R30FirstXyzrhwWorldLogged = false; + bool R30FirstXyzrhwRhwPromotionLogged = false; + std::uint64_t R30XyzrhwAtomicFallbacks = 0; + std::uint64_t R30XyzrhwDepthPreserveFallbacks = 0; + bool R30FirstXyzrhwAtomicFallbackLogged = false; + std::uint64_t R30XyzrhwBilateralFallbacks = 0; + bool R30FirstXyzrhwBilateralFallbackLogged = false; + + + // R30.6 CPU shadows for XYZRHW vertex/index buffers. + // + // The old VB/IB conversion path called Lock(READONLY) from inside Draw, + // which can block behind the GPU and also fails for WRITEONLY buffers. + // Observe the game's own Lock/Unlock writes instead and keep the exact + // CPU byte ranges that are known current. Draw then consumes only this + // shadow and fails open to R29 when a range was never observed. + constexpr std::size_t R30CreateVertexBufferVtableIndex = 26; + constexpr std::size_t R30CreateIndexBufferVtableIndex = 27; + constexpr std::size_t R30BufferReleaseVtableIndex = 2; + constexpr std::size_t R30BufferLockVtableIndex = 11; + constexpr std::size_t R30BufferUnlockVtableIndex = 12; + constexpr UINT R30MaxShadowBytes = 16u * 1024u * 1024u; + + SafetyHookInline R30CreateVertexBufferHook{}; + SafetyHookInline R30CreateIndexBufferHook{}; + SafetyHookInline R30VertexBufferReleaseHook{}; + SafetyHookInline R30VertexBufferLockHook{}; + SafetyHookInline R30VertexBufferUnlockHook{}; + SafetyHookInline R30IndexBufferReleaseHook{}; + SafetyHookInline R30IndexBufferLockHook{}; + SafetyHookInline R30IndexBufferUnlockHook{}; + + struct R30ByteRange + { + UINT begin = 0; + UINT end = 0; // exclusive + }; + + struct R30BufferShadow + { + std::mutex mutex; + UINT size = 0; + DWORD usage = 0; + D3DPOOL pool = D3DPOOL_DEFAULT; + D3DFORMAT indexFormat = D3DFMT_UNKNOWN; + std::vector bytes; + std::vector valid; + void* lockPtr = nullptr; + UINT lockOffset = 0; + UINT lockSize = 0; + DWORD lockFlags = 0; + bool writeLock = false; + }; + + using R30ShadowPtr = std::shared_ptr; + std::mutex R30ShadowRegistryMutex; + std::mutex R30ShadowHookMutex; + std::unordered_map + R30VertexShadows; + std::unordered_map + R30IndexShadows; + std::uint64_t R30ShadowWrites = 0; + std::uint64_t R30ShadowReadHits = 0; + std::uint64_t R30ShadowReadMisses = 0; + std::uint64_t R30ShadowDiscardInvalidations = 0; + bool R30FirstShadowMissLogged = false; + + struct R30ScratchBuffers + { + std::vector source; + std::vector indexSource; + std::vector left; + std::vector right; + std::vector used; + std::vector physical; + std::vector indices16; + std::vector indices32; + bool busy = false; + }; + thread_local R30ScratchBuffers R30Scratch; + + struct R30ScratchLease + { + R30ScratchBuffers* buffers = nullptr; + R30ScratchLease() noexcept + { + if (!R30Scratch.busy) + { + R30Scratch.busy = true; + buffers = &R30Scratch; + } + } + ~R30ScratchLease() + { + if (buffers) + buffers->busy = false; + } + explicit operator bool() const noexcept { return buffers != nullptr; } + }; + + void R30MergeValidRange(std::vector& ranges, + UINT begin, UINT end) + { + if (begin >= end) + return; + R30ByteRange merged{ begin, end }; + auto it = ranges.begin(); + while (it != ranges.end() && it->end < merged.begin) + ++it; + while (it != ranges.end() && it->begin <= merged.end) + { + merged.begin = std::min(merged.begin, it->begin); + merged.end = std::max(merged.end, it->end); + it = ranges.erase(it); + } + ranges.insert(it, merged); + } + + bool R30RangeValid(const std::vector& ranges, + UINT begin, UINT end) noexcept + { + if (begin >= end) + return false; + for (const auto& range : ranges) + { + if (range.begin <= begin && range.end >= end) + return true; + if (range.begin > begin) + break; + } + return false; + } + + R30ShadowPtr R30FindVertexShadow(IDirect3DVertexBuffer9* buffer) + { + std::lock_guard lock(R30ShadowRegistryMutex); + const auto it = R30VertexShadows.find(buffer); + return it == R30VertexShadows.end() ? nullptr : it->second; + } + + R30ShadowPtr R30FindIndexShadow(IDirect3DIndexBuffer9* buffer) + { + std::lock_guard lock(R30ShadowRegistryMutex); + const auto it = R30IndexShadows.find(buffer); + return it == R30IndexShadows.end() ? nullptr : it->second; + } + + R30ShadowPtr R30EnsureVertexShadow(IDirect3DVertexBuffer9* buffer) + { + if (!buffer) + return nullptr; + if (auto existing = R30FindVertexShadow(buffer)) + return existing; + D3DVERTEXBUFFER_DESC desc{}; + if (FAILED(buffer->GetDesc(&desc)) || !desc.Size || + desc.Size > R30MaxShadowBytes) + return nullptr; + auto entry = std::make_shared(); + entry->size = desc.Size; + entry->usage = desc.Usage; + entry->pool = desc.Pool; + std::lock_guard lock(R30ShadowRegistryMutex); + auto [it, inserted] = R30VertexShadows.emplace(buffer, entry); + return inserted ? entry : it->second; + } + + R30ShadowPtr R30EnsureIndexShadow(IDirect3DIndexBuffer9* buffer) + { + if (!buffer) + return nullptr; + if (auto existing = R30FindIndexShadow(buffer)) + return existing; + D3DINDEXBUFFER_DESC desc{}; + if (FAILED(buffer->GetDesc(&desc)) || !desc.Size || + desc.Size > R30MaxShadowBytes || + (desc.Format != D3DFMT_INDEX16 && + desc.Format != D3DFMT_INDEX32)) + return nullptr; + auto entry = std::make_shared(); + entry->size = desc.Size; + entry->usage = desc.Usage; + entry->pool = desc.Pool; + entry->indexFormat = desc.Format; + std::lock_guard lock(R30ShadowRegistryMutex); + auto [it, inserted] = R30IndexShadows.emplace(buffer, entry); + return inserted ? entry : it->second; + } + + void R30BeginObservedLock(const R30ShadowPtr& entry, + UINT offset, UINT size, void* data, DWORD flags) + { + if (!entry || !data) + return; + std::lock_guard lock(entry->mutex); + if (offset > entry->size) + return; + const UINT effectiveSize = + size == 0 ? entry->size - offset : size; + if (!effectiveSize || effectiveSize > entry->size - offset) + return; + entry->lockPtr = data; + entry->lockOffset = offset; + entry->lockSize = effectiveSize; + entry->lockFlags = flags; + entry->writeLock = (flags & D3DLOCK_READONLY) == 0; + if (entry->writeLock && (flags & D3DLOCK_DISCARD)) + { + entry->valid.clear(); + ++R30ShadowDiscardInvalidations; + } + } + + void R30FinishObservedLock(const R30ShadowPtr& entry, + bool unlockSucceeded) + { + if (!entry) + return; + std::lock_guard lock(entry->mutex); + if (unlockSucceeded && entry->writeLock && entry->lockPtr && + entry->lockSize && entry->lockOffset <= entry->size && + entry->lockSize <= entry->size - entry->lockOffset) + { + try + { + if (entry->bytes.size() != entry->size) + entry->bytes.resize(entry->size); + std::memcpy(entry->bytes.data() + entry->lockOffset, + entry->lockPtr, entry->lockSize); + R30MergeValidRange(entry->valid, entry->lockOffset, + entry->lockOffset + entry->lockSize); + ++R30ShadowWrites; + } + catch (...) + { + entry->bytes.clear(); + entry->valid.clear(); + } + } + else if (!unlockSucceeded && entry->writeLock) + { + entry->valid.clear(); + } + entry->lockPtr = nullptr; + entry->lockOffset = 0; + entry->lockSize = 0; + entry->lockFlags = 0; + entry->writeLock = false; + } + + bool R30CopyVertexShadow(IDirect3DVertexBuffer9* buffer, + UINT offset, UINT size, std::vector& out) + { + const auto entry = R30FindVertexShadow(buffer); + if (!entry) + { + ++R30ShadowReadMisses; + return false; + } + std::lock_guard lock(entry->mutex); + if (offset > entry->size || size > entry->size - offset || + entry->bytes.size() != entry->size || + !R30RangeValid(entry->valid, offset, offset + size)) + { + ++R30ShadowReadMisses; + if (!R30FirstShadowMissLogged) + { + R30FirstShadowMissLogged = true; + spdlog::info( + "VR R30.6 BUFFER SHADOW: draw-time GPU Lock removed; an unobserved VB/IB range will fail open until the game's next write Lock/Unlock supplies CPU bytes"); + } + return false; + } + try + { + out.resize(size); + std::memcpy(out.data(), entry->bytes.data() + offset, size); + } + catch (...) + { + return false; + } + ++R30ShadowReadHits; + return true; + } + + bool R30CopyIndexShadow(IDirect3DIndexBuffer9* buffer, + UINT offset, UINT size, std::vector& out) + { + const auto entry = R30FindIndexShadow(buffer); + if (!entry) + { + ++R30ShadowReadMisses; + return false; + } + std::lock_guard lock(entry->mutex); + if (offset > entry->size || size > entry->size - offset || + entry->bytes.size() != entry->size || + !R30RangeValid(entry->valid, offset, offset + size)) + { + ++R30ShadowReadMisses; + return false; + } + try + { + out.resize(size); + std::memcpy(out.data(), entry->bytes.data() + offset, size); + } + catch (...) + { + return false; + } + ++R30ShadowReadHits; + return true; + } + + HRESULT __stdcall R30VertexBufferLockDest( + IDirect3DVertexBuffer9* buffer, UINT offset, UINT size, + void** data, DWORD flags) + { + const HRESULT hr = R30VertexBufferLockHook.stdcall( + buffer, offset, size, data, flags); + if (SUCCEEDED(hr) && data && *data) + R30BeginObservedLock( + R30EnsureVertexShadow(buffer), offset, size, *data, flags); + return hr; + } + + HRESULT __stdcall R30VertexBufferUnlockDest( + IDirect3DVertexBuffer9* buffer) + { + const auto entry = R30FindVertexShadow(buffer); + // The pointer returned by Lock is guaranteed valid until Unlock, + // so snapshot bytes before forwarding the real Unlock. + if (entry) + R30FinishObservedLock(entry, true); + const HRESULT hr = + R30VertexBufferUnlockHook.stdcall(buffer); + if (FAILED(hr) && entry) + { + std::lock_guard lock(entry->mutex); + entry->valid.clear(); + } + return hr; + } + + ULONG __stdcall R30VertexBufferReleaseDest( + IDirect3DVertexBuffer9* buffer) + { + const ULONG refs = + R30VertexBufferReleaseHook.stdcall(buffer); + if (refs == 0) + { + std::lock_guard lock(R30ShadowRegistryMutex); + R30VertexShadows.erase(buffer); + } + return refs; + } + + HRESULT __stdcall R30IndexBufferLockDest( + IDirect3DIndexBuffer9* buffer, UINT offset, UINT size, + void** data, DWORD flags) + { + const HRESULT hr = R30IndexBufferLockHook.stdcall( + buffer, offset, size, data, flags); + if (SUCCEEDED(hr) && data && *data) + R30BeginObservedLock( + R30EnsureIndexShadow(buffer), offset, size, *data, flags); + return hr; + } + + HRESULT __stdcall R30IndexBufferUnlockDest( + IDirect3DIndexBuffer9* buffer) + { + const auto entry = R30FindIndexShadow(buffer); + if (entry) + R30FinishObservedLock(entry, true); + const HRESULT hr = + R30IndexBufferUnlockHook.stdcall(buffer); + if (FAILED(hr) && entry) + { + std::lock_guard lock(entry->mutex); + entry->valid.clear(); + } + return hr; + } + + ULONG __stdcall R30IndexBufferReleaseDest( + IDirect3DIndexBuffer9* buffer) + { + const ULONG refs = + R30IndexBufferReleaseHook.stdcall(buffer); + if (refs == 0) + { + std::lock_guard lock(R30ShadowRegistryMutex); + R30IndexShadows.erase(buffer); + } + return refs; + } + + bool R30EnsureVertexBufferHooks(IDirect3DVertexBuffer9* buffer) + { + if (!buffer) + return false; + std::lock_guard lock(R30ShadowHookMutex); + auto** vtable = *reinterpret_cast(buffer); + if (!R30VertexBufferLockHook) + R30VertexBufferLockHook = safetyhook::create_inline( + vtable[R30BufferLockVtableIndex], + R30VertexBufferLockDest); + if (!R30VertexBufferUnlockHook) + R30VertexBufferUnlockHook = safetyhook::create_inline( + vtable[R30BufferUnlockVtableIndex], + R30VertexBufferUnlockDest); + if (!R30VertexBufferReleaseHook) + R30VertexBufferReleaseHook = safetyhook::create_inline( + vtable[R30BufferReleaseVtableIndex], + R30VertexBufferReleaseDest); + return R30VertexBufferLockHook && + R30VertexBufferUnlockHook && R30VertexBufferReleaseHook; + } + + bool R30EnsureIndexBufferHooks(IDirect3DIndexBuffer9* buffer) + { + if (!buffer) + return false; + std::lock_guard lock(R30ShadowHookMutex); + auto** vtable = *reinterpret_cast(buffer); + if (!R30IndexBufferLockHook) + R30IndexBufferLockHook = safetyhook::create_inline( + vtable[R30BufferLockVtableIndex], + R30IndexBufferLockDest); + if (!R30IndexBufferUnlockHook) + R30IndexBufferUnlockHook = safetyhook::create_inline( + vtable[R30BufferUnlockVtableIndex], + R30IndexBufferUnlockDest); + if (!R30IndexBufferReleaseHook) + R30IndexBufferReleaseHook = safetyhook::create_inline( + vtable[R30BufferReleaseVtableIndex], + R30IndexBufferReleaseDest); + return R30IndexBufferLockHook && + R30IndexBufferUnlockHook && R30IndexBufferReleaseHook; + } + + HRESULT __stdcall R30CreateVertexBufferDest( + IDirect3DDevice9* device, UINT length, DWORD usage, DWORD fvf, + D3DPOOL pool, IDirect3DVertexBuffer9** out, HANDLE* shared) + { + const HRESULT hr = R30CreateVertexBufferHook.stdcall( + device, length, usage, fvf, pool, out, shared); + if (SUCCEEDED(hr) && out && *out) + { + R30EnsureVertexBufferHooks(*out); + R30EnsureVertexShadow(*out); + } + return hr; + } + + HRESULT __stdcall R30CreateIndexBufferDest( + IDirect3DDevice9* device, UINT length, DWORD usage, + D3DFORMAT format, D3DPOOL pool, + IDirect3DIndexBuffer9** out, HANDLE* shared) + { + const HRESULT hr = R30CreateIndexBufferHook.stdcall( + device, length, usage, format, pool, out, shared); + if (SUCCEEDED(hr) && out && *out) + { + R30EnsureIndexBufferHooks(*out); + R30EnsureIndexShadow(*out); + } + return hr; + } + + void R30InstallBufferCreationHooks(IDirect3DDevice9* device) + { + if (!device) + return; + auto** vtable = *reinterpret_cast(device); + if (!R30CreateVertexBufferHook) + R30CreateVertexBufferHook = safetyhook::create_inline( + vtable[R30CreateVertexBufferVtableIndex], + R30CreateVertexBufferDest); + if (!R30CreateIndexBufferHook) + R30CreateIndexBufferHook = safetyhook::create_inline( + vtable[R30CreateIndexBufferVtableIndex], + R30CreateIndexBufferDest); + if (!R30CreateVertexBufferHook || !R30CreateIndexBufferHook) + spdlog::warn( + "VR R30.6 BUFFER SHADOW: creation hook incomplete; existing/dynamic buffers still register lazily on draw/Lock"); + } + + void R30RollbackBufferShadowHooks() noexcept + { + // Unhook entry points before dropping registry ownership. Any draw + // holding a COM reference also holds a shared_ptr acquired from the + // registry, so clearing the maps cannot invalidate an in-flight + // shadow object. + R30CreateIndexBufferHook = {}; + R30CreateVertexBufferHook = {}; + R30IndexBufferUnlockHook = {}; + R30IndexBufferLockHook = {}; + R30IndexBufferReleaseHook = {}; + R30VertexBufferUnlockHook = {}; + R30VertexBufferLockHook = {}; + R30VertexBufferReleaseHook = {}; + { + std::lock_guard lock( + R30ShadowRegistryMutex); + R30IndexShadows.clear(); + R30VertexShadows.clear(); + } + } + + + // R30.6 stereo sky glow. + // + // The original game samples D3DBACKBUFFER_TYPE_MONO and owns only one + // glow chain. In true stereo that is necessarily the left eye. Keep the + // original mono post-process disabled and run a completely separate + // reduced/blurred chain for each completed eye immediately before the + // renderer composes/publishes the stereo frame. + SafetyHookInline R30PresentR29Hook{}; + SafetyHookInline R30ResetR29Hook{}; + + struct R30SkyGlowResources + { + IDirect3DTexture9* reduced[2]{}; + IDirect3DTexture9* temp[2]{}; + IDirect3DPixelShader9* bright = nullptr; + IDirect3DPixelShader9* blur = nullptr; + IDirect3DPixelShader9* composite = nullptr; + UINT eyeWidth = 0; + UINT eyeHeight = 0; + UINT glowWidth = 0; + UINT glowHeight = 0; + int factor = 0; + }; + R30SkyGlowResources R30SkyGlow{}; + std::uint64_t R30SkyGlowFrames = 0; + std::uint64_t R30SkyGlowFailures = 0; + std::uint64_t R30SkyGlowSceneCaptureEpoch = 0; + bool R30FirstSkyGlowLogged = false; + bool R30FirstSkyGlowFailureLogged = false; + + void R30ReleaseSkyGlowResources() noexcept + { + for (int eye = 0; eye < 2; ++eye) + { + ReleaseCom(R30SkyGlow.reduced[eye]); + ReleaseCom(R30SkyGlow.temp[eye]); + } + ReleaseCom(R30SkyGlow.bright); + ReleaseCom(R30SkyGlow.blur); + ReleaseCom(R30SkyGlow.composite); + R30SkyGlow.eyeWidth = 0; + R30SkyGlow.eyeHeight = 0; + R30SkyGlow.glowWidth = 0; + R30SkyGlow.glowHeight = 0; + R30SkyGlow.factor = 0; + } + + bool R30CompilePixelShader(IDirect3DDevice9* device, + const char* source, const char* entry, + IDirect3DPixelShader9** shader) + { + if (!device || !source || !entry || !shader) + return false; + ID3DBlob* bytecode = nullptr; + ID3DBlob* errors = nullptr; + const HRESULT compile = D3DCompile( + source, std::strlen(source), + "OutRunVR-StereoSkyGlow", nullptr, nullptr, + entry, "ps_2_0", + D3DCOMPILE_OPTIMIZATION_LEVEL3, + 0, &bytecode, &errors); + if (FAILED(compile) || !bytecode) + { + if (errors && errors->GetBufferPointer()) + spdlog::warn( + "VR SKY GLOW: pixel shader compile failed: {}", + static_cast( + errors->GetBufferPointer())); + ReleaseCom(errors); + ReleaseCom(bytecode); + return false; + } + const HRESULT create = device->CreatePixelShader( + static_cast(bytecode->GetBufferPointer()), + shader); + ReleaseCom(errors); + ReleaseCom(bytecode); + return SUCCEEDED(create) && *shader; + } + + bool R30EnsureSkyGlowResources(IDirect3DDevice9* device) + { + if (!device || !BackBufferDesc.Width || !BackBufferDesc.Height) + return false; + const int factor = + std::clamp(Settings::SkyGlowFactor.get(), 1, 16); + const UINT glowWidth = std::max( + 160u, BackBufferDesc.Width / + static_cast(factor)); + const UINT glowHeight = std::max( + 120u, BackBufferDesc.Height / + static_cast(factor)); + + if (R30SkyGlow.reduced[0] && R30SkyGlow.reduced[1] && + R30SkyGlow.temp[0] && R30SkyGlow.temp[1] && + R30SkyGlow.bright && R30SkyGlow.blur && + R30SkyGlow.composite && + R30SkyGlow.eyeWidth == BackBufferDesc.Width && + R30SkyGlow.eyeHeight == BackBufferDesc.Height && + R30SkyGlow.glowWidth == glowWidth && + R30SkyGlow.glowHeight == glowHeight && + R30SkyGlow.factor == factor) + return true; + + R30ReleaseSkyGlowResources(); + + for (int eye = 0; eye < 2; ++eye) + { + if (FAILED(device->CreateTexture( + glowWidth, glowHeight, 1, + D3DUSAGE_RENDERTARGET, + D3DFMT_A8R8G8B8, D3DPOOL_DEFAULT, + &R30SkyGlow.reduced[eye], nullptr)) || + FAILED(device->CreateTexture( + glowWidth, glowHeight, 1, + D3DUSAGE_RENDERTARGET, + D3DFMT_A8R8G8B8, D3DPOOL_DEFAULT, + &R30SkyGlow.temp[eye], nullptr))) + { + R30ReleaseSkyGlowResources(); + return false; + } + } + + static constexpr char BrightPs[] = R"( + sampler Scene : register(s0); + float4 Bright(float2 uv : TEXCOORD0) : COLOR0 + { + float4 c = tex2D(Scene, uv); + float peak = max(c.r, max(c.g, c.b)); + float bright = saturate((peak - 0.58) * 2.75); + // World HDR/exposure draws normally leave useful alpha below + // one, while most fixed HUD art is opaque. This is not used + // for eye identity; it only suppresses UI contamination. + float worldMask = max(0.15, + saturate((1.0 - c.a) * 2.0)); + float m = bright * worldMask; + return float4(c.rgb * m, m); + })"; + static constexpr char BlurPs[] = R"( + sampler Source : register(s0); + float4 Texel : register(c0); + float4 Blur(float2 uv : TEXCOORD0) : COLOR0 + { + float2 d = Texel.xy; + float4 c = tex2D(Source, uv) * 0.40; + c += tex2D(Source, uv + d * 1.5) * 0.24; + c += tex2D(Source, uv - d * 1.5) * 0.24; + c += tex2D(Source, uv + d * 3.5) * 0.06; + c += tex2D(Source, uv - d * 3.5) * 0.06; + return c; + })"; + static constexpr char CompositePs[] = R"( + sampler Glow : register(s0); + float4 Params : register(c0); + float4 Composite(float2 uv : TEXCOORD0) : COLOR0 + { + float4 g = tex2D(Glow, uv); + return float4(g.rgb * Params.x, 0.0); + })"; + + if (!R30CompilePixelShader( + device, BrightPs, "Bright", + &R30SkyGlow.bright) || + !R30CompilePixelShader( + device, BlurPs, "Blur", + &R30SkyGlow.blur) || + !R30CompilePixelShader( + device, CompositePs, "Composite", + &R30SkyGlow.composite)) + { + R30ReleaseSkyGlowResources(); + return false; + } + + R30SkyGlow.eyeWidth = BackBufferDesc.Width; + R30SkyGlow.eyeHeight = BackBufferDesc.Height; + R30SkyGlow.glowWidth = glowWidth; + R30SkyGlow.glowHeight = glowHeight; + R30SkyGlow.factor = factor; + return true; + } + + struct R30GlowVertex + { + float x, y, z, rhw; + float u, v; + }; + + bool R30DrawSkyGlowPass(IDirect3DDevice9* device, + IDirect3DSurface9* target, UINT width, UINT height, + IDirect3DTexture9* source, + IDirect3DPixelShader9* shader, + const float constant[4], + bool additive) + { + if (!device || !target || !source || !shader || + !width || !height) + return false; + + if (FAILED(device->SetRenderTarget(0, target)) || + FAILED(device->SetDepthStencilSurface(nullptr))) + return false; + + D3DVIEWPORT9 viewport{}; + viewport.Width = width; + viewport.Height = height; + viewport.MinZ = 0.0f; + viewport.MaxZ = 1.0f; + if (FAILED(device->SetViewport(&viewport))) + return false; + + const R30GlowVertex v[4]{ + { -0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f }, + { static_cast(width) - 0.5f, -0.5f, + 0.0f, 1.0f, 1.0f, 0.0f }, + { -0.5f, static_cast(height) - 0.5f, + 0.0f, 1.0f, 0.0f, 1.0f }, + { static_cast(width) - 0.5f, + static_cast(height) - 0.5f, + 0.0f, 1.0f, 1.0f, 1.0f } + }; + + device->SetVertexShader(nullptr); + device->SetFVF(D3DFVF_XYZRHW | D3DFVF_TEX1); + device->SetPixelShader(shader); + device->SetTexture(0, source); + device->SetSamplerState(0, D3DSAMP_MINFILTER, D3DTEXF_LINEAR); + device->SetSamplerState(0, D3DSAMP_MAGFILTER, D3DTEXF_LINEAR); + device->SetSamplerState(0, D3DSAMP_MIPFILTER, D3DTEXF_NONE); + device->SetSamplerState(0, D3DSAMP_ADDRESSU, D3DTADDRESS_CLAMP); + device->SetSamplerState(0, D3DSAMP_ADDRESSV, D3DTADDRESS_CLAMP); + device->SetRenderState(D3DRS_ZENABLE, D3DZB_FALSE); + device->SetRenderState(D3DRS_ZWRITEENABLE, FALSE); + device->SetRenderState(D3DRS_STENCILENABLE, FALSE); + device->SetRenderState(D3DRS_ALPHATESTENABLE, FALSE); + device->SetRenderState(D3DRS_ALPHABLENDENABLE, + additive ? TRUE : FALSE); + if (additive) + { + device->SetRenderState(D3DRS_SRCBLEND, D3DBLEND_ONE); + device->SetRenderState(D3DRS_DESTBLEND, D3DBLEND_ONE); + device->SetRenderState(D3DRS_BLENDOP, D3DBLENDOP_ADD); + device->SetRenderState(D3DRS_COLORWRITEENABLE, + D3DCOLORWRITEENABLE_RED | + D3DCOLORWRITEENABLE_GREEN | + D3DCOLORWRITEENABLE_BLUE); + } + else + { + device->SetRenderState(D3DRS_COLORWRITEENABLE, + D3DCOLORWRITEENABLE_RED | + D3DCOLORWRITEENABLE_GREEN | + D3DCOLORWRITEENABLE_BLUE | + D3DCOLORWRITEENABLE_ALPHA); + } + device->SetPixelShaderConstantF(0, constant, 1); + const HRESULT hr = device->DrawPrimitiveUP( + D3DPT_TRIANGLESTRIP, 2, v, sizeof(R30GlowVertex)); + device->SetTexture(0, nullptr); + return SUCCEEDED(hr); + } + + bool R30CaptureSkyGlowSceneBeforeHud( + IDirect3DDevice9* device) + { + if (!device || Settings::SkyGlowFactor <= 0 || + !FrameHadWorldStereo || !FrameHadDuplicatedDraw || + FrameRightDrawFailed || FrameStereoIncomplete || + !BackBuffer || !RightEyeSurface) + return false; + + if (R30SkyGlowSceneCaptureEpoch == PresentEpoch) + return true; + if (!R30EnsureSkyGlowResources(device)) + return false; + + IDirect3DSurface9* eyeSurface[2]{ + BackBuffer, RightEyeSurface + }; + bool ok = true; + for (int eye = 0; eye < 2 && ok; ++eye) + { + IDirect3DSurface9* reduced = nullptr; + if (FAILED(R30SkyGlow.reduced[eye]->GetSurfaceLevel( + 0, &reduced)) || !reduced) + { + ok = false; + break; + } + ok = SUCCEEDED(device->StretchRect( + eyeSurface[eye], nullptr, reduced, nullptr, + D3DTEXF_LINEAR)); + reduced->Release(); + } + if (ok) + R30SkyGlowSceneCaptureEpoch = PresentEpoch; + return ok; + } + + bool R30ApplyStereoSkyGlow(IDirect3DDevice9* device) + { + if (!device || Settings::SkyGlowFactor <= 0 || + !StereoWanted() || !FrameHadWorldStereo || + !FrameHadDuplicatedDraw || FrameRightDrawFailed || + FrameStereoIncomplete || !BackBuffer || + !RightEyeSurface) + return true; + + if (!R30EnsureSkyGlowResources(device)) + return false; + + IDirect3DStateBlock9* stateBlock = nullptr; + IDirect3DSurface9* savedRt = nullptr; + IDirect3DSurface9* savedDepth = nullptr; + D3DVIEWPORT9 savedViewport{}; + if (FAILED(device->CreateStateBlock( + D3DSBT_ALL, &stateBlock)) || + !stateBlock || + FAILED(device->GetRenderTarget(0, &savedRt)) || + !savedRt || + FAILED(device->GetViewport(&savedViewport))) + { + if (savedRt) savedRt->Release(); + if (stateBlock) stateBlock->Release(); + return false; + } + const HRESULT depthHr = + device->GetDepthStencilSurface(&savedDepth); + const bool depthOk = + SUCCEEDED(depthHr) || depthHr == D3DERR_NOTFOUND; + if (!depthOk) + { + savedRt->Release(); + stateBlock->Release(); + return false; + } + + bool ok = true; + IDirect3DSurface9* eyeSurface[2]{ + BackBuffer, RightEyeSurface + }; + for (int eye = 0; eye < 2 && ok; ++eye) + { + IDirect3DSurface9* reduced = nullptr; + IDirect3DSurface9* temp = nullptr; + if (FAILED(R30SkyGlow.reduced[eye]->GetSurfaceLevel( + 0, &reduced)) || !reduced || + FAILED(R30SkyGlow.temp[eye]->GetSurfaceLevel( + 0, &temp)) || !temp) + { + if (temp) temp->Release(); + if (reduced) reduced->Release(); + ok = false; + break; + } + + if (R30SkyGlowSceneCaptureEpoch != PresentEpoch) + { + ok = SUCCEEDED(device->StretchRect( + eyeSurface[eye], nullptr, reduced, nullptr, + D3DTEXF_LINEAR)); + } + const float zero[4]{ 0, 0, 0, 0 }; + if (ok) + { + // Bright pass overwrites the entire reduced target, so no + // Clear is needed. Clearing here before rebinding the RT + // would clear the game's current eye backbuffer. + ok = R30DrawSkyGlowPass( + device, temp, + R30SkyGlow.glowWidth, + R30SkyGlow.glowHeight, + R30SkyGlow.reduced[eye], + R30SkyGlow.bright, zero, false); + } + + const float horizontal[4]{ + 1.0f / + static_cast(R30SkyGlow.glowWidth), + 0.0f, 0.0f, 0.0f + }; + if (ok) + ok = R30DrawSkyGlowPass( + device, reduced, + R30SkyGlow.glowWidth, + R30SkyGlow.glowHeight, + R30SkyGlow.temp[eye], + R30SkyGlow.blur, horizontal, false); + + // TwoStep exists to reduce aliasing after a downsample. + // At factor=1 there is no downsample, so a second full-resolution + // blur only doubles bandwidth. Also make sure the vertical pass, + // when requested for factor>1, is actually the composite source. + const bool effectiveTwoStep = + Settings::SkyGlowTwoStep.get() && + R30SkyGlow.factor > 1; + IDirect3DTexture9* compositeSource = + R30SkyGlow.temp[eye]; + if (effectiveTwoStep) + { + const float vertical[4]{ + 0.0f, + 1.0f / + static_cast(R30SkyGlow.glowHeight), + 0.0f, 0.0f + }; + if (ok) + ok = R30DrawSkyGlowPass( + device, temp, + R30SkyGlow.glowWidth, + R30SkyGlow.glowHeight, + R30SkyGlow.reduced[eye], + R30SkyGlow.blur, vertical, false); + if (ok) + compositeSource = R30SkyGlow.reduced[eye]; + } + + const float composite[4]{ 0.38f, 0, 0, 0 }; + if (ok) + ok = R30DrawSkyGlowPass( + device, eyeSurface[eye], + BackBufferDesc.Width, + BackBufferDesc.Height, + compositeSource, + R30SkyGlow.composite, composite, true); + + temp->Release(); + reduced->Release(); + } + + // Apply the captured pipeline state first, then explicitly restore + // RT/depth/viewport last. This guarantees the game bindings win even + // if a driver/state-block implementation restores more state than + // the code path historically relied on. + bool restoreOk = SUCCEEDED(stateBlock->Apply()); + restoreOk = + SUCCEEDED(device->SetRenderTarget(0, savedRt)) && + restoreOk; + const HRESULT restoreDepth = + device->SetDepthStencilSurface(savedDepth); + restoreOk = + (SUCCEEDED(restoreDepth) || + (!savedDepth && restoreDepth == D3D_OK)) && + restoreOk; + restoreOk = + SUCCEEDED(device->SetViewport(&savedViewport)) && + restoreOk; + savedRt->Release(); + if (savedDepth) savedDepth->Release(); + stateBlock->Release(); + + if (ok && restoreOk) + { + ++R30SkyGlowFrames; + if (!R30FirstSkyGlowLogged) + { + R30FirstSkyGlowLogged = true; + spdlog::info( + "VR SKY GLOW: independent L/R extract + stereo blur + additive composite ACTIVE factor={} requestedTwoStep={} effectiveTwoStep={} buffer={}x{}", + R30SkyGlow.factor, + Settings::SkyGlowTwoStep.get() ? 1 : 0, + (Settings::SkyGlowTwoStep.get() && R30SkyGlow.factor > 1) ? 1 : 0, + R30SkyGlow.glowWidth, + R30SkyGlow.glowHeight); + } + return true; + } + + ++R30SkyGlowFailures; + if (!R30FirstSkyGlowFailureLogged) + { + R30FirstSkyGlowFailureLogged = true; + spdlog::warn( + "VR SKY GLOW: stereo eye post-process failed; frame continues without enabling the stock mono glow chain"); + } + return false; + } + + ULONGLONG R30LastTelemetryMs = 0; + + void R30MaybeLogTelemetry() + { + if (!Settings::VRTelemetry) + return; + const ULONGLONG now = GetTickCount64(); + if (now - R30LastTelemetryMs < 5000) + return; + R30LastTelemetryMs = now; + spdlog::info( + "VR R30.9: bufferShadow[writes={},hits={},misses={},discardInvalid={},drawReadLocks=0] xyzrhw[world={},hud={},rhwPromote={},atomicFallback={},depthPreserve={},bilateralFallback={}] skyGlow[frames={},failures={},factor={},buffer={}x{}]", + R30ShadowWrites, R30ShadowReadHits, R30ShadowReadMisses, + R30ShadowDiscardInvalidations, + R30XyzrhwWorldEffectDraws, R30XyzrhwHudDraws, + R30XyzrhwRhwWorldPromotions, + R30XyzrhwAtomicFallbacks, + R30XyzrhwDepthPreserveFallbacks, + R30XyzrhwBilateralFallbacks, + R30SkyGlowFrames, R30SkyGlowFailures, + R30SkyGlow.factor, R30SkyGlow.glowWidth, + R30SkyGlow.glowHeight); + } + + HRESULT __stdcall PresentDestR30( + IDirect3DDevice9* device, const RECT* sourceRect, + const RECT* destRect, HWND destWindowOverride, + const RGNDATA* dirtyRegion) + { + R30MaybeLogTelemetry(); + if (Settings::SkyGlowFactor > 0 && + StereoWanted() && FrameHadWorldStereo && + FrameHadDuplicatedDraw && + !FrameRightDrawFailed && !FrameStereoIncomplete) + { + InternalPassScope guard; + R30ApplyStereoSkyGlow(device); + } + return R30PresentR29Hook.stdcall( + device, sourceRect, destRect, + destWindowOverride, dirtyRegion); + } + + HRESULT __stdcall ResetDestR30( + IDirect3DDevice9* device, + D3DPRESENT_PARAMETERS* params) + { + R30ReleaseSkyGlowResources(); + R30SkyGlowSceneCaptureEpoch = 0; + return R30ResetR29Hook.stdcall(device, params); + } + + // User-adjustable projection-space HUD scale. The per-eye FOV affine + // remains automatic; this value is only a common-centre size trim after + // the headset-specific mapping. + float R30HudScaleValue() noexcept + { + return std::clamp(Settings::VRHudScale.get(), 0.30f, 1.20f); + } + + float R30HudAspectCompensation( + const OutRunVRRenderer::LatchedStereoFrame& stereo) noexcept + { + if (!BackBufferDesc.Width || !BackBufferDesc.Height) + return 1.0f; + + const float sourceAspect = + static_cast(BackBufferDesc.Width) / + static_cast(BackBufferDesc.Height); + + // R35.4: SharedPoseState already carries the runtime's actual + // recommended per-eye render-target size. That pixel aspect is the + // quantity that finally stretches a screen-space HUD sprite in the + // OpenXR projection swapchain. Using angular FOV aspect was close, + // but on Quest/VDXR it leaves circular HUD elements slightly tall. + float targetAspectSum = 0.0f; + int targetEyes = 0; + if (SharedState && + SharedState->magic == OutRunVR::SharedMagic && + SharedState->protocolVersion == + OutRunVR::SharedProtocolVersion) + { + for (int eye = 0; eye < 2; ++eye) + { + const std::uint32_t w = + SharedState->recommendedWidth[eye]; + const std::uint32_t h = + SharedState->recommendedHeight[eye]; + if (!w || !h) + continue; + const float aspect = + static_cast(w) / + static_cast(h); + if (!std::isfinite(aspect) || + aspect < 0.25f || aspect > 4.0f) + continue; + targetAspectSum += aspect; + ++targetEyes; + } + } + + // Fall back to the old angular estimate only if the host has not + // published swapchain dimensions yet (startup/recovery). + if (!targetEyes) + { + for (int eye = 0; eye < 2; ++eye) + { + const float left = + std::tan(stereo.eyeFov[eye].angleLeft); + const float right = + std::tan(stereo.eyeFov[eye].angleRight); + const float up = + std::tan(stereo.eyeFov[eye].angleUp); + const float down = + std::tan(stereo.eyeFov[eye].angleDown); + const float horizontal = right - left; + const float vertical = up - down; + if (!std::isfinite(horizontal) || + !std::isfinite(vertical) || + horizontal <= 0.05f || vertical <= 0.05f) + continue; + const float aspect = horizontal / vertical; + if (!std::isfinite(aspect) || + aspect < 0.25f || aspect > 4.0f) + continue; + targetAspectSum += aspect; + ++targetEyes; + } + } + + if (!targetEyes) + return 1.0f; + + const float targetAspect = + targetAspectSum / static_cast(targetEyes); + const float compensation = sourceAspect / targetAspect; + if (!std::isfinite(compensation)) + return 1.0f; + return std::clamp(compensation, 0.75f, 3.50f); + } + + void R30HudContainScale( + const OutRunVRRenderer::LatchedStereoFrame& stereo, + float& scaleX, float& scaleY) noexcept + { + const float userScale = R30HudScaleValue(); + const float sourceOverTarget = + R30HudAspectCompensation(stereo); + + // "Contain" the original desktop HUD canvas inside one eye before + // applying the user scale. The old "cover" mapping multiplied X by + // source/target aspect; on 3440x1440 -> Quest eye targets that could + // make HudScale=0.55 wider than the visible eye and push rank/time + // elements off-screen. Contain preserves sprite pixel aspect while + // guaranteeing the whole common-centre HUD stays inside the eye. + if (sourceOverTarget >= 1.0f) + { + scaleX = userScale; + scaleY = userScale / sourceOverTarget; + } + else + { + scaleX = userScale * sourceOverTarget; + scaleY = userScale; + } + } + + enum class R30ScreenSpaceKind : std::uint8_t + { + None, + Hud2D, + FlatPerspectiveEffect + }; + + R30ScreenSpaceKind R30ClassifyScreenSpacePass( + IDirect3DDevice9* device) noexcept + { + if (!device || !TargetIsBackBuffer()) + return R30ScreenSpaceKind::None; + + float projection[16]{}; + if (!OutRunVRRenderer::GetRendererBaseProjection(projection)) + return R30ScreenSpaceKind::None; + + const auto projectionClass = + OutRunVR::PassPolicy::ClassifyProjectionSignature( + projection[11], projection[15]); + if (projectionClass == + OutRunVR::PassPolicy::ProjectionClass::Orthographic2D) + return R30ScreenSpaceKind::Hud2D; + + if (projectionClass != + OutRunVR::PassPolicy::ProjectionClass::Perspective3D) + return R30ScreenSpaceKind::None; + + DWORD zEnable = D3DZB_TRUE; + DWORD alphaBlend = FALSE; + DWORD alphaTest = FALSE; + if (FAILED(device->GetRenderState(D3DRS_ZENABLE, &zEnable)) || + FAILED(device->GetRenderState( + D3DRS_ALPHABLENDENABLE, &alphaBlend)) || + FAILED(device->GetRenderState( + D3DRS_ALPHATESTENABLE, &alphaTest))) + return R30ScreenSpaceKind::None; + + if (zEnable == D3DZB_FALSE && + (alphaBlend != FALSE || alphaTest != FALSE)) + return R30ScreenSpaceKind::FlatPerspectiveEffect; + return R30ScreenSpaceKind::None; + } + + bool R30BuildEyeAffine( + const OutRunVRRenderer::LatchedStereoFrame& stereo, + float eyeScale[2], float eyeOffset[2]) noexcept + { + float tanLeft[2]{}; + float tanRight[2]{}; + float width[2]{}; + for (int eye = 0; eye < 2; ++eye) + { + tanLeft[eye] = std::tan(stereo.eyeFov[eye].angleLeft); + tanRight[eye] = std::tan(stereo.eyeFov[eye].angleRight); + width[eye] = tanRight[eye] - tanLeft[eye]; + if (!std::isfinite(tanLeft[eye]) || + !std::isfinite(tanRight[eye]) || + !std::isfinite(width[eye]) || width[eye] <= 0.05f) + return false; + } + + const float commonLeft = 0.5f * (tanLeft[0] + tanLeft[1]); + const float commonRight = 0.5f * (tanRight[0] + tanRight[1]); + const float commonWidth = commonRight - commonLeft; + const float commonSum = commonRight + commonLeft; + if (!std::isfinite(commonWidth) || commonWidth <= 0.05f) + return false; + + for (int eye = 0; eye < 2; ++eye) + { + const float eyeSum = tanRight[eye] + tanLeft[eye]; + const float scale = commonWidth / width[eye]; + const float offset = (commonSum - eyeSum) / width[eye]; + if (!std::isfinite(scale) || !std::isfinite(offset) || + scale < 0.50f || scale > 1.50f || + std::fabs(offset) > 0.50f) + return false; + eyeScale[eye] = scale; + eyeOffset[eye] = offset; + } + return true; + } + + struct R30XyzrhwState + { + OutRunVRRenderer::LatchedStereoFrame stereo{}; + D3DVIEWPORT9 viewport{}; + float eyeScale[2]{}; + float eyeOffset[2]{}; + float worldScaleX[2]{}; + float worldOffsetX[2]{}; + float worldScaleY[2]{}; + float worldOffsetY[2]{}; + float parallaxPerRhwX[2]{}; + float parallaxPerRhwY[2]{}; + D3DMATRIX baseProjection{}; + D3DMATRIX inverseBaseProjection{}; + D3DMATRIX eyeProjection[2]{}; + D3DMATRIX eyeInverse[2]{}; + bool fullWorldReprojection = false; + bool depthTestEnabled = false; + bool rhwDepthEvidence = false; + bool worldEffect = false; + }; + + bool R30PrepareXyzrhwState( + IDirect3DDevice9* device, R30XyzrhwState& state) noexcept + { + if (!R29StableStereoBase(device) || + CurrentVertexShaderIdentity.load(std::memory_order_acquire) != 0) + return false; + + IDirect3DVertexShader9* shader = nullptr; + if (FAILED(device->GetVertexShader(&shader))) + return false; + if (shader) + { + shader->Release(); + return false; + } + + DWORD fvf = 0; + if (FAILED(device->GetFVF(&fvf)) || + (fvf & D3DFVF_POSITION_MASK) != D3DFVF_XYZRHW) + return false; + + if (!EnsureStereoResources(device) || + FAILED(device->GetViewport(&state.viewport)) || + state.viewport.Width == 0 || state.viewport.Height == 0 || + !std::isfinite(state.viewport.MinZ) || + !std::isfinite(state.viewport.MaxZ) || + state.viewport.MaxZ <= state.viewport.MinZ) + return false; + + DWORD zEnable = D3DZB_FALSE; + if (FAILED(device->GetRenderState(D3DRS_ZENABLE, &zEnable))) + return false; + state.depthTestEnabled = zEnable != D3DZB_FALSE; + + if (!OutRunVRRenderer::GetLatchedStereoFrame(state.stereo) || + state.stereo.poseSequence == 0 || + (FrameStereoPoseSequence != 0 && + FrameStereoPoseSequence != state.stereo.poseSequence) || + !R30BuildEyeAffine(state.stereo, + state.eyeScale, state.eyeOffset)) + return false; + + return true; + } + + bool R30XyzrhwHasProjectedDepthSignature( + const void* source, UINT vertexCount, UINT stride, + const R30XyzrhwState& state, const D3DMATRIX& projection, + const std::vector* usedMask) noexcept + { + if (!source || vertexCount == 0 || stride < sizeof(float) * 4) + return false; + + const float depthSpan = + state.viewport.MaxZ - state.viewport.MinZ; + if (!std::isfinite(depthSpan) || depthSpan <= 1.0e-6f || + std::fabs(projection._34) <= 1.0e-5f || + std::fabs(projection._13) > 1.0e-4f || + std::fabs(projection._23) > 1.0e-4f) + return false; + + UINT sampled = 0; + UINT valid = 0; + UINT projected = 0; + for (UINT i = 0; i < vertexCount && sampled < 512u; ++i) + { + if (usedMask && + (i >= usedMask->size() || (*usedMask)[i] == 0)) + continue; + ++sampled; + const float* p = reinterpret_cast( + static_cast(source) + + static_cast(i) * stride); + const float z = p[2]; + const float rhw = p[3]; + if (!std::isfinite(z) || !std::isfinite(rhw) || + rhw <= 1.0e-6f || rhw >= 1000.0f) + continue; + + const float clipW = 1.0f / rhw; + const float viewZ = + (clipW - projection._44) / projection._34; + const float clipZ = + viewZ * projection._33 + projection._43; + const float expectedNdcZ = clipZ / clipW; + const float actualNdcZ = + (z - state.viewport.MinZ) / depthSpan; + if (!std::isfinite(expectedNdcZ) || + !std::isfinite(actualNdcZ)) + continue; + + ++valid; + // True pre-transformed world particles have a coherent + // projection relation between screen-Z and RHW. HUD glyphs can + // use arbitrary RHW for sorting/scaling, which was the reason + // the white position text was incorrectly promoted to 3D. + if (std::fabs(rhw - 1.0f) > 0.02f && + std::fabs(expectedNdcZ - actualNdcZ) <= 0.10f) + ++projected; + } + + return sampled >= 3u && valid >= 3u && valid * 2u >= sampled && projected * 4u >= valid * 3u; + } + + bool R30ConfigureXyzrhwWorldEffect( + IDirect3DDevice9* device, const void* source, + UINT vertexCount, UINT stride, R30XyzrhwState& state, + const std::vector* usedMask = nullptr) noexcept + { + float projectionRaw[16]{}; + D3DMATRIX baseProjection{}; + const bool haveBaseProjection = + OutRunVRRenderer::GetRendererBaseProjection(projectionRaw); + if (haveBaseProjection) + std::memcpy(&baseProjection, projectionRaw, sizeof(baseProjection)); + + state.rhwDepthEvidence = + haveBaseProjection && + R30XyzrhwHasProjectedDepthSignature( + source, vertexCount, stride, state, + baseProjection, usedMask); + + // OutRun can disable Z after CPU-projecting smoke/skid/decal + // billboards. The strengthened Z/RHW relation is the world-space + // discriminator; white rank/score HUD glyphs fail this relation. + if (state.depthTestEnabled && !state.rhwDepthEvidence) + return false; + state.worldEffect = state.rhwDepthEvidence; + if (!state.worldEffect) + return true; + + if (TrackedDepthStencil && + (!RightDepthSynchronized || !RightStencilSynchronized)) + TryBootstrapRightDepthFromRecentClear(device); + if (TrackedDepthStencil && + !RightDepthSynchronized && DepthTestActive(device)) + return false; + if (TrackedDepthStencil && + !RightStencilSynchronized && StencilTestActive(device)) + return false; + + if (!haveBaseProjection) + return false; + state.baseProjection = baseProjection; + if (!MatrixFinite(baseProjection) || + std::fabs(baseProjection._11) < 0.01f || + std::fabs(baseProjection._22) < 0.01f || + std::fabs(baseProjection._34) < 0.25f || + !InvertMatrix(baseProjection, state.inverseBaseProjection) || + !MatrixFinite(state.inverseBaseProjection)) + return false; + state.fullWorldReprojection = true; + + const float center[3]{ + 0.5f * (state.stereo.eyeOffset[0][0] + + state.stereo.eyeOffset[1][0]), + 0.5f * (state.stereo.eyeOffset[0][1] + + state.stereo.eyeOffset[1][1]), + 0.5f * (state.stereo.eyeOffset[0][2] + + state.stereo.eyeOffset[1][2]) + }; + const float zero[3]{}; + + for (int eye = 0; eye < 2; ++eye) + { + // Use the exact same per-eye rigid transform convention as the + // normal fixed-function/world path: view * eyeInverse * + // eyeProjection. The reconstructed CPU-projected point is + // already in game view space, so only eyeInverse is applied + // here; common head/camera motion must not be applied twice. + // CPU-projected XYZRHW effects were generated from OutRun's + // already head-synchronised camera. Reapplying the absolute HMD + // eye orientation here rotates them twice and shifts smoke/skid + // away from the road. Apply only the eye pose relative to the + // common head centre; the asymmetric eye projection still owns + // the final FOV. + const float relativeEye[3]{ + state.stereo.eyeOffset[eye][0] - center[0], + state.stereo.eyeOffset[eye][1] - center[1], + state.stereo.eyeOffset[eye][2] - center[2] + }; + const float identityOrientation[4]{ + 0.0f, 0.0f, 0.0f, 1.0f + }; + const D3DMATRIX eyePose = + MatrixFromQuaternionTranslation( + identityOrientation, relativeEye, + Settings::VRWorldScale * Settings::VRStereoDepth); + state.eyeInverse[eye] = InverseRigid(eyePose); + state.eyeProjection[eye] = + ProjectionFromFov(baseProjection, + state.stereo.eyeFov[eye]); + + // Preserve a conservative affine fallback for vertices whose + // clip reconstruction is unsafe. Its parallax uses the old + // centered IPD estimate, but both eyes are forced to choose the + // same fallback mode by R30TransformXyzrhwStereo(). + const float rel[3]{ + state.stereo.eyeOffset[eye][0] - center[0], + state.stereo.eyeOffset[eye][1] - center[1], + state.stereo.eyeOffset[eye][2] - center[2] + }; + // The source camera already owns common head rotation, so the + // conservative affine fallback also uses head-relative IPD only. + const float localX = rel[0]; + const float localY = rel[1]; + + const D3DMATRIX& eyeProjection = state.eyeProjection[eye]; + state.worldScaleX[eye] = + eyeProjection._11 / baseProjection._11; + state.worldOffsetX[eye] = + (eyeProjection._31 - + baseProjection._31 * state.worldScaleX[eye]) / + baseProjection._34; + state.worldScaleY[eye] = + eyeProjection._22 / baseProjection._22; + state.worldOffsetY[eye] = + (eyeProjection._32 - + baseProjection._32 * state.worldScaleY[eye]) / + baseProjection._34; + state.parallaxPerRhwX[eye] = + -localX * Settings::VRWorldScale * Settings::VRStereoDepth * + eyeProjection._11; + state.parallaxPerRhwY[eye] = + -localY * Settings::VRWorldScale * Settings::VRStereoDepth * + eyeProjection._22; + + if (!std::isfinite(state.worldScaleX[eye]) || + !std::isfinite(state.worldOffsetX[eye]) || + !std::isfinite(state.worldScaleY[eye]) || + !std::isfinite(state.worldOffsetY[eye]) || + !std::isfinite(state.parallaxPerRhwX[eye]) || + !std::isfinite(state.parallaxPerRhwY[eye]) || + !MatrixFinite(state.eyeProjection[eye]) || + !MatrixFinite(state.eyeInverse[eye]) || + state.worldScaleX[eye] < 0.20f || + state.worldScaleX[eye] > 5.0f || + state.worldScaleY[eye] < 0.20f || + state.worldScaleY[eye] > 5.0f) + return false; + } + + if (state.rhwDepthEvidence && !state.depthTestEnabled) + { + ++R30XyzrhwRhwWorldPromotions; + if (!R30FirstXyzrhwRhwPromotionLogged) + { + R30FirstXyzrhwRhwPromotionLogged = true; + spdlog::info( + "VR R30.6 XYZRHW WORLD: referenced RHW/depth signature promoted a depth-disabled particle/decal; normal-world eyeInverse/projection transform is shared"); + } + } + return true; + } + + UINT R30PrimitiveElementCount( + D3DPRIMITIVETYPE type, UINT primitiveCount) noexcept + { + switch (type) + { + case D3DPT_POINTLIST: return primitiveCount; + case D3DPT_LINELIST: return primitiveCount * 2u; + case D3DPT_LINESTRIP: return primitiveCount + 1u; + case D3DPT_TRIANGLELIST: return primitiveCount * 3u; + case D3DPT_TRIANGLESTRIP: + case D3DPT_TRIANGLEFAN: return primitiveCount + 2u; + default: return 0; + } + } + + bool R30TransformXyzrhwVertices( + const void* source, UINT vertexCount, UINT stride, + const R30XyzrhwState& state, int eye, + std::vector& out, + const std::vector* usedMask, + bool allowFullReprojection, bool& usedFullReprojection) noexcept + { + usedFullReprojection = false; + if (!source || vertexCount == 0 || stride < sizeof(float) * 4 || + eye < 0 || eye > 1) + return false; + const std::size_t bytes = + static_cast(vertexCount) * stride; + if (bytes == 0 || bytes > 4u * 1024u * 1024u) + return false; + if (usedMask && usedMask->size() < vertexCount) + return false; + + try + { + out.resize(bytes); + } + catch (...) + { + return false; + } + std::memcpy(out.data(), source, bytes); + + const float x0 = static_cast(state.viewport.X); + const float y0 = static_cast(state.viewport.Y); + const float width = static_cast(state.viewport.Width); + const float height = static_cast(state.viewport.Height); + const float minZ = state.viewport.MinZ; + const float maxZ = state.viewport.MaxZ; + const float depthSpan = maxZ - minZ; + if (width <= 0.0f || height <= 0.0f || + !std::isfinite(depthSpan) || depthSpan <= 1.0e-6f) + return false; + + auto selected = [&](UINT i) noexcept + { + return !usedMask || (*usedMask)[i] != 0; + }; + auto sourceVertex = [&](UINT i) noexcept -> const float* + { + return reinterpret_cast( + static_cast(source) + + static_cast(i) * stride); + }; + auto outputVertex = [&](UINT i) noexcept -> float* + { + return reinterpret_cast( + out.data() + static_cast(i) * stride); + }; + + if (state.worldEffect && state.fullWorldReprojection && + allowFullReprojection) + { + bool fullDrawOk = true; + UINT selectedVertices = 0; + for (UINT i = 0; i < vertexCount; ++i) + { + if (!selected(i)) + continue; + ++selectedVertices; + + const float* src = sourceVertex(i); + float* dst = outputVertex(i); + const float x = src[0]; + const float y = src[1]; + const float screenZ = src[2]; + const float rhw = src[3]; + if (!std::isfinite(x) || !std::isfinite(y) || + !std::isfinite(screenZ) || !std::isfinite(rhw) || + rhw <= 1.0e-6f || rhw >= 1000.0f) + { + fullDrawOk = false; + break; + } + + const float ndcX = ((x - x0) / width) * 2.0f - 1.0f; + const float ndcY = + 1.0f - ((y - y0) / height) * 2.0f; + const float clipW = 1.0f / rhw; + const float clipX = ndcX * clipW; + const float clipY = ndcY * clipW; + + float restoredView[4]{}; + bool restored = false; + + // Pre-transformed D3D9 particles carry RHW = 1/clipW. + // For the normal OutRun perspective matrix, clipW gives + // view-Z directly and X/Y can then be solved from clip X/Y. + // This avoids depending on the particle's biased/clamped + // screen Z, which is exactly what caused smoke/decal draws + // to fall back to the approximate affine path. + const D3DMATRIX& bp = state.baseProjection; + if (std::fabs(bp._14) <= 1.0e-5f && + std::fabs(bp._24) <= 1.0e-5f && + std::fabs(bp._34) > 1.0e-5f) + { + const float viewZ = + (clipW - bp._44) / bp._34; + const float rhsX = + clipX - viewZ * bp._31 - bp._41; + const float rhsY = + clipY - viewZ * bp._32 - bp._42; + const float det = + bp._11 * bp._22 - bp._21 * bp._12; + if (std::isfinite(viewZ) && + std::isfinite(rhsX) && + std::isfinite(rhsY) && + std::isfinite(det) && + std::fabs(det) > 1.0e-6f) + { + const float viewX = + (rhsX * bp._22 - + bp._21 * rhsY) / det; + const float viewY = + (bp._11 * rhsY - + rhsX * bp._12) / det; + if (std::isfinite(viewX) && + std::isfinite(viewY)) + { + restoredView[0] = viewX; + restoredView[1] = viewY; + restoredView[2] = viewZ; + restoredView[3] = 1.0f; + restored = true; + } + } + } + + // Compatibility fallback for an unusual projection shape: + // retain the previous inverse-projection reconstruction. + if (!restored) + { + const float ndcZ = + (screenZ - minZ) / depthSpan; + if (!std::isfinite(ndcZ) || + ndcZ < -0.25f || ndcZ > 1.25f) + { + fullDrawOk = false; + break; + } + const float clip[4]{ + clipX, clipY, ndcZ * clipW, clipW + }; + float restoredViewH[4]{}; + for (int col = 0; col < 4; ++col) + for (int row = 0; row < 4; ++row) + restoredViewH[col] += clip[row] * + state.inverseBaseProjection.m[row][col]; + + if (!std::isfinite(restoredViewH[0]) || + !std::isfinite(restoredViewH[1]) || + !std::isfinite(restoredViewH[2]) || + !std::isfinite(restoredViewH[3]) || + std::fabs(restoredViewH[3]) <= 1.0e-6f) + { + fullDrawOk = false; + break; + } + + const float invRestoredW = + 1.0f / restoredViewH[3]; + restoredView[0] = + restoredViewH[0] * invRestoredW; + restoredView[1] = + restoredViewH[1] * invRestoredW; + restoredView[2] = + restoredViewH[2] * invRestoredW; + restoredView[3] = 1.0f; + } + + // Match the normal world path exactly: the reconstructed + // game-view point receives the same rigid eyeInverse that + // BuildEyeConstants/fixed-function world draws use. + float eyeView[4]{}; + for (int col = 0; col < 4; ++col) + for (int row = 0; row < 4; ++row) + eyeView[col] += restoredView[row] * + state.eyeInverse[eye].m[row][col]; + + float clipEye[4]{}; + for (int col = 0; col < 4; ++col) + for (int row = 0; row < 4; ++row) + clipEye[col] += eyeView[row] * + state.eyeProjection[eye].m[row][col]; + + // D3D9 perspective clip-W must stay in front of the eye. + // abs(W) would accept a point behind the camera and mirror + // it back into the visible image. + if (!std::isfinite(clipEye[0]) || + !std::isfinite(clipEye[1]) || + !std::isfinite(clipEye[2]) || + !std::isfinite(clipEye[3]) || + clipEye[3] <= 1.0e-6f) + { + fullDrawOk = false; + break; + } + + const float invEyeW = 1.0f / clipEye[3]; + const float fullX = clipEye[0] * invEyeW; + const float fullY = clipEye[1] * invEyeW; + const float fullZ = clipEye[2] * invEyeW; + if (!std::isfinite(fullX) || + !std::isfinite(fullY) || + !std::isfinite(fullZ)) + { + fullDrawOk = false; + break; + } + + const float transformedX = + x0 + (fullX + 1.0f) * 0.5f * width; + const float transformedY = + y0 + (1.0f - fullY) * 0.5f * height; + + // Decals/particles can intentionally bias screen Z beyond + // the strict viewport interval. Preserve the game's Z in + // that case, while still using the accurately reprojected + // eye X/Y and RHW. Falling the whole quad back to an affine + // shift was the visible left/right smoke/skid offset. + float transformedZ = screenZ; + if (fullZ >= 0.0f && fullZ <= 1.0f) + { + transformedZ = minZ + fullZ * depthSpan; + } + else + { + ++R30XyzrhwDepthPreserveFallbacks; + } + if (!std::isfinite(transformedX) || + !std::isfinite(transformedY) || + !std::isfinite(transformedZ)) + { + fullDrawOk = false; + break; + } + + dst[0] = transformedX; + dst[1] = transformedY; + dst[2] = transformedZ; + dst[3] = invEyeW; + } + + if (fullDrawOk && selectedVertices != 0) + { + usedFullReprojection = true; + return true; + } + + ++R30XyzrhwAtomicFallbacks; + if (!R30FirstXyzrhwAtomicFallbackLogged) + { + R30FirstXyzrhwAtomicFallbackLogged = true; + spdlog::info( + "VR R30.6 XYZRHW WORLD: full reprojection rejected for a referenced vertex; this eye candidate is discarded before bilateral fallback selection"); + } + std::memcpy(out.data(), source, bytes); + } + + for (UINT i = 0; i < vertexCount; ++i) + { + if (!selected(i)) + continue; + + float* p = outputVertex(i); + const float x = p[0]; + const float y = p[1]; + const float z = p[2]; + const float rhw = p[3]; + if (!std::isfinite(x) || !std::isfinite(y) || + !std::isfinite(z) || !std::isfinite(rhw)) + return false; + + const float ndcX = ((x - x0) / width) * 2.0f - 1.0f; + const float ndcY = + 1.0f - ((y - y0) / height) * 2.0f; + + float correctedX = 0.0f; + float correctedY = 0.0f; + if (state.worldEffect) + { + correctedX = + state.worldScaleX[eye] * ndcX + + state.worldOffsetX[eye]; + correctedY = + state.worldScaleY[eye] * ndcY + + state.worldOffsetY[eye]; + if (rhw > 0.0f && rhw < 1000.0f) + { + correctedX += + state.parallaxPerRhwX[eye] * rhw; + correctedY += + state.parallaxPerRhwY[eye] * rhw; + } + // Fallback keeps the game's original Z/RHW pair intact. + } + else + { + // Screen-locked HUD must preserve its pixel aspect ratio. + // Applying the horizontal OpenXR FOV scale only to X made + // circular gauges vertically elongated on both SBS and HMD. + // Keep one uniform user scale on X/Y and retain only the + // per-eye asymmetric-FOV centre offset for convergence. + float hudScaleX = 1.0f; + float hudScaleY = 1.0f; + R30HudContainScale( + state.stereo, hudScaleX, hudScaleY); + correctedX = + hudScaleX * state.eyeScale[eye] * ndcX + state.eyeOffset[eye]; + correctedY = hudScaleY * ndcY; + } + + const float transformedX = + x0 + (correctedX + 1.0f) * 0.5f * width; + const float transformedY = + y0 + (1.0f - correctedY) * 0.5f * height; + if (!std::isfinite(transformedX) || + !std::isfinite(transformedY)) + return false; + p[0] = transformedX; + p[1] = transformedY; + } + return true; + } + + bool R30TransformXyzrhwStereo( + const void* source, UINT vertexCount, UINT stride, + const R30XyzrhwState& state, + std::vector& left, + std::vector& right, + const std::vector* usedMask = nullptr) noexcept + { + bool leftFull = false; + bool rightFull = false; + if (!R30TransformXyzrhwVertices(source, vertexCount, stride, + state, 0, left, usedMask, true, leftFull) || + !R30TransformXyzrhwVertices(source, vertexCount, stride, + state, 1, right, usedMask, true, rightFull)) + return false; + + if (leftFull != rightFull) + { + ++R30XyzrhwBilateralFallbacks; + if (!R30FirstXyzrhwBilateralFallbackLogged) + { + R30FirstXyzrhwBilateralFallbackLogged = true; + spdlog::info( + "VR R30.6 XYZRHW WORLD: one eye rejected full reprojection; both eyes are forced onto the same affine fallback policy"); + } + bool ignored = false; + if (!R30TransformXyzrhwVertices(source, vertexCount, stride, + state, 0, left, usedMask, false, ignored) || + !R30TransformXyzrhwVertices(source, vertexCount, stride, + state, 1, right, usedMask, false, ignored)) + return false; + } + return true; + } + + template + HRESULT R30ExecuteXyzrhwStereo( + IDirect3DDevice9* device, const R30XyzrhwState& state, + LeftDraw&& leftDraw, RightDraw&& rightDraw, + const char* site) + { + if (!state.worldEffect) + R30CaptureSkyGlowSceneBeforeHud(device); + + ++R9DrawCalls; + R9MonoBackupGap = true; + if (LeftDrawMayWriteDepth(device) || + LeftDrawMayWriteStencil(device)) + ++R9MainDepthContentSerial; + + const HRESULT leftHr = leftDraw(); + if (FAILED(leftHr)) + { + R9Poison(OutRunVR::StereoFailureLeftDrawFailed, + site, leftHr); + R29ArmMonoSafety(); + return leftHr; + } + + IDirect3DSurface9* savedRt = TrackedRenderTarget; + IDirect3DSurface9* savedDepth = TrackedDepthStencil; + HRESULT rightHr = D3D_OK; + OutRunVR::StereoFailureReason rightFailure = + OutRunVR::StereoFailureRightStateFailed; + bool restoreOk = true; + { + InternalPassScope guard; + rightHr = SetRenderTargetHook.stdcall( + device, 0u, RightEyeSurface); + if (SUCCEEDED(rightHr)) + rightHr = SetDepthStencilSurfaceHook.stdcall( + device, TrackedDepthStencil ? RightEyeDepth : nullptr); + if (SUCCEEDED(rightHr)) + rightHr = device->SetViewport(&state.viewport); + if (SUCCEEDED(rightHr)) + { + rightFailure = + OutRunVR::StereoFailureRightDrawFailed; + rightHr = rightDraw(); + } + restoreOk = RestoreRightPassState(device, + savedRt, savedDepth, state.viewport, nullptr, false); + } + + FrameHadDuplicatedDraw = true; + ++DuplicatedDraws; + ++R29StableTwoEyeDraws; + if (state.worldEffect) + { + ++R30XyzrhwWorldEffectDraws; + if (FrameStereoPoseSequence == 0) + { + FrameStereoPoseSequence = state.stereo.poseSequence; + FrameStereoMetadata = state.stereo; + } + else if (FrameStereoPoseSequence != state.stereo.poseSequence) + { + FrameRightDrawFailed = true; + PoisonFrame(OutRunVR::StereoFailurePoseSequenceMismatch); + } + FrameHadWorldStereo = true; + ++WorldStereoDraws; + if (!R30FirstXyzrhwWorldLogged) + { + R30FirstXyzrhwWorldLogged = true; + spdlog::info( + "VR R30.4 XYZRHW WORLD: pre-transformed particle/billboard/decal draws reconstruct clip X/Y/Z/RHW, unproject through the game projection, then reproject into each OpenXR eye; affine RHW fallback retained for unsafe vertices"); + } + } + else + { + ++NonWorldDuplicatedDraws; + ++R30XyzrhwHudDraws; + if (!R30FirstXyzrhwHudLogged) + { + R30FirstXyzrhwHudLogged = true; + spdlog::info( + "VR R30.8 XYZRHW HUD: pre-transformed fixed-function HUD applies source-aspect/eye-FOV X compensation before SBS/DirectGPU projection; circular gauges preserve final HMD aspect ratio"); + } + } + + if (FAILED(rightHr)) + { + FrameRightDrawFailed = true; + InvalidateRightDepthStencilIfLeftMayWrite(device); + R9Poison(rightFailure, site, rightHr); + R29ArmMonoSafety(); + } + if (!restoreOk) + { + InvalidateRightDepthStencilIfLeftMayWrite(device); + NoteRestoreFailure("R30.2 XYZRHW right-eye draw"); + R29ArmMonoSafety(); + } + return leftHr; + } + + HRESULT R30TryXyzrhwPrimitiveUP( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + UINT primitiveCount, const void* data, UINT stride) + { + R30XyzrhwState state{}; + if (!R30PrepareXyzrhwState(device, state)) + return E_NOTIMPL; + const UINT vertexCount = + R30PrimitiveElementCount(type, primitiveCount); + if (!vertexCount || vertexCount > 262144u) + return E_NOTIMPL; + if (!R30ConfigureXyzrhwWorldEffect( + device, data, vertexCount, stride, state)) + { + ++R30XyzrhwFallbacks; + return E_NOTIMPL; + } + + R30ScratchLease lease; + if (!lease) + return E_NOTIMPL; + auto& scratch = *lease.buffers; + if (!R30TransformXyzrhwStereo( + data, vertexCount, stride, state, + scratch.left, scratch.right)) + { + ++R30XyzrhwFallbacks; + return E_NOTIMPL; + } + + auto leftDraw = [&]() { + return DrawPrimitiveUPHook.stdcall( + device, type, primitiveCount, + scratch.left.data(), stride); + }; + auto rightDraw = [&]() { + return DrawPrimitiveUPHook.stdcall( + device, type, primitiveCount, + scratch.right.data(), stride); + }; + return R30ExecuteXyzrhwStereo( + device, state, leftDraw, rightDraw, + "R30.6/DrawPrimitiveUP-XYZRHW"); + } + + HRESULT R30TryXyzrhwIndexedPrimitiveUP( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + UINT minVertexIndex, UINT numVertices, UINT primitiveCount, + const void* indexData, D3DFORMAT indexFormat, + const void* vertexData, UINT stride) + { + R30XyzrhwState state{}; + if (!R30PrepareXyzrhwState(device, state)) + return E_NOTIMPL; + + const UINT indexCount = + R30PrimitiveElementCount(type, primitiveCount); + if (!indexData || !vertexData || indexCount == 0 || + (indexFormat != D3DFMT_INDEX16 && + indexFormat != D3DFMT_INDEX32)) + return E_NOTIMPL; + + UINT maxIndex = 0; + if (indexFormat == D3DFMT_INDEX16) + { + const auto* indices = + static_cast(indexData); + for (UINT i = 0; i < indexCount; ++i) + maxIndex = std::max(maxIndex, indices[i]); + } + else + { + const auto* indices = + static_cast(indexData); + for (UINT i = 0; i < indexCount; ++i) + maxIndex = std::max(maxIndex, indices[i]); + } + + const UINT vertexCount = + std::max(minVertexIndex + numVertices, + maxIndex + 1u); + if (vertexCount == 0 || vertexCount > 262144u) + return E_NOTIMPL; + + R30ScratchLease lease; + if (!lease) + return E_NOTIMPL; + auto& scratch = *lease.buffers; + try + { + scratch.used.assign(vertexCount, 0); + } + catch (...) + { + return E_NOTIMPL; + } + for (UINT i = 0; i < indexCount; ++i) + { + const std::uint32_t raw = + indexFormat == D3DFMT_INDEX16 + ? static_cast(indexData)[i] + : static_cast(indexData)[i]; + if (raw >= vertexCount) + return E_NOTIMPL; + scratch.used[raw] = 1; + } + + if (!R30ConfigureXyzrhwWorldEffect( + device, vertexData, vertexCount, stride, state, + &scratch.used)) + { + ++R30XyzrhwFallbacks; + return E_NOTIMPL; + } + + if (!R30TransformXyzrhwStereo( + vertexData, vertexCount, stride, state, + scratch.left, scratch.right, &scratch.used)) + { + ++R30XyzrhwFallbacks; + return E_NOTIMPL; + } + + auto leftDraw = [&]() { + return DrawIndexedPrimitiveUPHook.stdcall( + device, type, minVertexIndex, numVertices, + primitiveCount, indexData, indexFormat, + scratch.left.data(), stride); + }; + auto rightDraw = [&]() { + return DrawIndexedPrimitiveUPHook.stdcall( + device, type, minVertexIndex, numVertices, + primitiveCount, indexData, indexFormat, + scratch.right.data(), stride); + }; + return R30ExecuteXyzrhwStereo( + device, state, leftDraw, rightDraw, + "R30.6/DrawIndexedPrimitiveUP-XYZRHW"); + } + + + HRESULT R30TryXyzrhwPrimitiveVB( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + UINT startVertex, UINT primitiveCount) + { + R30XyzrhwState state{}; + if (!R30PrepareXyzrhwState(device, state)) + return E_NOTIMPL; + + const UINT vertexCount = + R30PrimitiveElementCount(type, primitiveCount); + if (!vertexCount || vertexCount > 262144u) + return E_NOTIMPL; + + IDirect3DVertexBuffer9* vb = nullptr; + UINT streamOffset = 0; + UINT stride = 0; + if (FAILED(device->GetStreamSource( + 0, &vb, &streamOffset, &stride)) || + !vb || stride < sizeof(float) * 4) + { + if (vb) vb->Release(); + return E_NOTIMPL; + } + + R30EnsureVertexBufferHooks(vb); + R30EnsureVertexShadow(vb); + + D3DVERTEXBUFFER_DESC desc{}; + const std::uint64_t firstByte = + static_cast(streamOffset) + + static_cast(startVertex) * stride; + const std::uint64_t byteCount = + static_cast(vertexCount) * stride; + if (FAILED(vb->GetDesc(&desc)) || firstByte > desc.Size || + byteCount > desc.Size - firstByte || + firstByte > UINT_MAX || byteCount > UINT_MAX) + { + vb->Release(); + return E_NOTIMPL; + } + + R30ScratchLease lease; + if (!lease) + { + vb->Release(); + return E_NOTIMPL; + } + auto& scratch = *lease.buffers; + if (!R30CopyVertexShadow(vb, + static_cast(firstByte), + static_cast(byteCount), scratch.source)) + { + vb->Release(); + ++R30XyzrhwFallbacks; + return E_NOTIMPL; + } + + if (!R30ConfigureXyzrhwWorldEffect( + device, scratch.source.data(), vertexCount, + stride, state) || + !R30TransformXyzrhwStereo( + scratch.source.data(), vertexCount, stride, state, + scratch.left, scratch.right)) + { + vb->Release(); + ++R30XyzrhwFallbacks; + return E_NOTIMPL; + } + + auto leftDraw = [&]() { + return DrawPrimitiveUPHook.stdcall( + device, type, primitiveCount, + scratch.left.data(), stride); + }; + auto rightDraw = [&]() { + return DrawPrimitiveUPHook.stdcall( + device, type, primitiveCount, + scratch.right.data(), stride); + }; + const HRESULT hr = R30ExecuteXyzrhwStereo( + device, state, leftDraw, rightDraw, + "R30.6/DrawPrimitiveVB-ShadowXYZRHW"); + + { + InternalPassScope guard; + if (FAILED(device->SetStreamSource( + 0, vb, streamOffset, stride))) + { + NoteRestoreFailure("R30.6 VB stream restore"); + R29ArmMonoSafety(); + } + } + vb->Release(); + return hr; + } + + HRESULT R30TryXyzrhwIndexedPrimitiveVB( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + INT baseVertexIndex, UINT minVertexIndex, UINT numVertices, + UINT startIndex, UINT primitiveCount) + { + R30XyzrhwState state{}; + if (!R30PrepareXyzrhwState(device, state)) + return E_NOTIMPL; + + const UINT indexCount = + R30PrimitiveElementCount(type, primitiveCount); + if (!indexCount || !numVertices || indexCount > 524288u) + return E_NOTIMPL; + + IDirect3DVertexBuffer9* vb = nullptr; + IDirect3DIndexBuffer9* ib = nullptr; + UINT streamOffset = 0; + UINT stride = 0; + if (FAILED(device->GetStreamSource( + 0, &vb, &streamOffset, &stride)) || + !vb || stride < sizeof(float) * 4 || + FAILED(device->GetIndices(&ib)) || !ib) + { + if (ib) ib->Release(); + if (vb) vb->Release(); + return E_NOTIMPL; + } + + R30EnsureVertexBufferHooks(vb); + R30EnsureIndexBufferHooks(ib); + R30EnsureVertexShadow(vb); + R30EnsureIndexShadow(ib); + + D3DINDEXBUFFER_DESC ibDesc{}; + if (FAILED(ib->GetDesc(&ibDesc)) || + (ibDesc.Format != D3DFMT_INDEX16 && + ibDesc.Format != D3DFMT_INDEX32)) + { + ib->Release(); + vb->Release(); + return E_NOTIMPL; + } + const UINT indexSize = + ibDesc.Format == D3DFMT_INDEX16 ? 2u : 4u; + const std::uint64_t firstIndexByte = + static_cast(startIndex) * indexSize; + const std::uint64_t indexBytes = + static_cast(indexCount) * indexSize; + if (firstIndexByte > ibDesc.Size || + indexBytes > ibDesc.Size - firstIndexByte || + firstIndexByte > UINT_MAX || indexBytes > UINT_MAX) + { + ib->Release(); + vb->Release(); + return E_NOTIMPL; + } + + R30ScratchLease lease; + if (!lease) + { + ib->Release(); + vb->Release(); + return E_NOTIMPL; + } + auto& scratch = *lease.buffers; + if (!R30CopyIndexShadow(ib, + static_cast(firstIndexByte), + static_cast(indexBytes), scratch.indexSource)) + { + ib->Release(); + vb->Release(); + ++R30XyzrhwFallbacks; + return E_NOTIMPL; + } + + try + { + scratch.physical.resize(indexCount); + } + catch (...) + { + ib->Release(); + vb->Release(); + return E_NOTIMPL; + } + + std::int64_t minPhysical = INT64_MAX; + std::int64_t maxPhysical = INT64_MIN; + for (UINT i = 0; i < indexCount; ++i) + { + const std::uint32_t raw = + ibDesc.Format == D3DFMT_INDEX16 + ? static_cast( + static_cast(scratch.indexSource.data()))[i] + : static_cast( + static_cast(scratch.indexSource.data()))[i]; + const std::int64_t p = + static_cast(baseVertexIndex) + raw; + if (p < 0 || p > UINT_MAX) + { + ib->Release(); + vb->Release(); + return E_NOTIMPL; + } + scratch.physical[i] = static_cast(p); + minPhysical = std::min(minPhysical, p); + maxPhysical = std::max(maxPhysical, p); + } + + if (minPhysical == INT64_MAX || maxPhysical < minPhysical) + { + ib->Release(); + vb->Release(); + return E_NOTIMPL; + } + + const std::uint64_t vertexCount64 = + static_cast(maxPhysical - minPhysical) + 1u; + if (!vertexCount64 || vertexCount64 > 262144u) + { + ib->Release(); + vb->Release(); + return E_NOTIMPL; + } + const UINT vertexCount = static_cast(vertexCount64); + + D3DVERTEXBUFFER_DESC vbDesc{}; + const std::uint64_t firstVertexByte = + static_cast(streamOffset) + + static_cast(minPhysical) * stride; + const std::uint64_t vertexBytes = + static_cast(vertexCount) * stride; + if (FAILED(vb->GetDesc(&vbDesc)) || + firstVertexByte > vbDesc.Size || + vertexBytes > vbDesc.Size - firstVertexByte || + firstVertexByte > UINT_MAX || vertexBytes > UINT_MAX) + { + ib->Release(); + vb->Release(); + return E_NOTIMPL; + } + + if (!R30CopyVertexShadow(vb, + static_cast(firstVertexByte), + static_cast(vertexBytes), scratch.source)) + { + ib->Release(); + vb->Release(); + ++R30XyzrhwFallbacks; + return E_NOTIMPL; + } + + try + { + scratch.used.assign(vertexCount, 0); + } + catch (...) + { + ib->Release(); + vb->Release(); + return E_NOTIMPL; + } + for (UINT i = 0; i < indexCount; ++i) + { + const std::uint64_t local = + scratch.physical[i] - + static_cast(minPhysical); + if (local >= vertexCount) + { + ib->Release(); + vb->Release(); + return E_NOTIMPL; + } + scratch.used[static_cast(local)] = 1; + } + + if (!R30ConfigureXyzrhwWorldEffect( + device, scratch.source.data(), vertexCount, + stride, state, &scratch.used) || + !R30TransformXyzrhwStereo( + scratch.source.data(), vertexCount, stride, state, + scratch.left, scratch.right, &scratch.used)) + { + ib->Release(); + vb->Release(); + ++R30XyzrhwFallbacks; + return E_NOTIMPL; + } + + const void* rebased = nullptr; + if (ibDesc.Format == D3DFMT_INDEX16) + { + try { scratch.indices16.resize(indexCount); } + catch (...) { + ib->Release(); + vb->Release(); + return E_NOTIMPL; + } + for (UINT i = 0; i < indexCount; ++i) + { + const std::uint64_t v = + scratch.physical[i] - + static_cast(minPhysical); + if (v > 0xFFFFu) + { + ib->Release(); + vb->Release(); + return E_NOTIMPL; + } + scratch.indices16[i] = static_cast(v); + } + rebased = scratch.indices16.data(); + } + else + { + try { scratch.indices32.resize(indexCount); } + catch (...) { + ib->Release(); + vb->Release(); + return E_NOTIMPL; + } + for (UINT i = 0; i < indexCount; ++i) + scratch.indices32[i] = + scratch.physical[i] - + static_cast(minPhysical); + rebased = scratch.indices32.data(); + } + + auto leftDraw = [&]() { + return DrawIndexedPrimitiveUPHook.stdcall( + device, type, 0u, vertexCount, primitiveCount, + rebased, ibDesc.Format, scratch.left.data(), stride); + }; + auto rightDraw = [&]() { + return DrawIndexedPrimitiveUPHook.stdcall( + device, type, 0u, vertexCount, primitiveCount, + rebased, ibDesc.Format, scratch.right.data(), stride); + }; + const HRESULT hr = R30ExecuteXyzrhwStereo( + device, state, leftDraw, rightDraw, + "R30.6/DrawIndexedPrimitiveVB-ShadowXYZRHW"); + + { + InternalPassScope guard; + bool restoreOk = SUCCEEDED(device->SetStreamSource( + 0, vb, streamOffset, stride)); + restoreOk = SUCCEEDED(device->SetIndices(ib)) && restoreOk; + if (!restoreOk) + { + NoteRestoreFailure("R30.6 VB/IB restore"); + R29ArmMonoSafety(); + } + } + ib->Release(); + vb->Release(); + return hr; + } + + bool R30BuildScreenSpaceEyeConstants( + IDirect3DDevice9* device, + const OutRunVRRenderer::LatchedStereoFrame& stereo, + R30ScreenSpaceKind screenKind, + float original[16], float eyeConstants[2][16], + float eyeScale[2], float eyeOffset[2]) noexcept + { + if (!device || + CurrentVertexShaderIdentity.load(std::memory_order_acquire) == 0) + return false; + + if (FAILED(device->GetVertexShaderConstantF( + OutRunWvpRegister, original, OutRunWvpRegisterCount))) + return false; + + if (!R30BuildEyeAffine(stereo, eyeScale, eyeOffset)) + return false; + + D3DMATRIX uploadedT{}; + std::memcpy(&uploadedT, original, sizeof(uploadedT)); + const D3DMATRIX stockWvp = TransposeMatrix(uploadedT); + if (!MatrixFinite(stockWvp)) + return false; + + if (screenKind == R30ScreenSpaceKind::FlatPerspectiveEffect) + { + float baseRaw[16]{}; + float headRaw[16]{}; + std::uint32_t headPoseSequence = 0; + D3DMATRIX baseProjection{}; + D3DMATRIX inverseBaseProjection{}; + D3DMATRIX headInverse{}; + if (!OutRunVRRenderer::GetRendererBaseProjection(baseRaw) || + !OutRunVRRenderer::GetLatchedHeadInverse( + headRaw, headPoseSequence) || + headPoseSequence != stereo.poseSequence) + return false; + std::memcpy(&baseProjection, baseRaw, + sizeof(baseProjection)); + std::memcpy(&headInverse, headRaw, + sizeof(headInverse)); + if (!MatrixFinite(baseProjection) || + !MatrixFinite(headInverse) || + !InvertMatrix(baseProjection, inverseBaseProjection)) + return false; + + // Turn a screen-space alpha overlay into a finite common view + // plane before applying the same head/eye transforms as world + // geometry. Keeping x/w and y/w while replacing z/w with a + // constant preserves the game's original 2D placement at + // recenter, but adds real IPD convergence and world locking. + constexpr float OverlayPlaneViewZ = -2.50f; + const float planeClipW = + OverlayPlaneViewZ * baseProjection._34 + + baseProjection._44; + const float planeClipZ = + OverlayPlaneViewZ * baseProjection._33 + + baseProjection._43; + if (!std::isfinite(planeClipW) || + !std::isfinite(planeClipZ) || + planeClipW <= 1.0e-4f) + return false; + const float planeNdcZ = planeClipZ / planeClipW; + if (!std::isfinite(planeNdcZ)) + return false; + + D3DMATRIX depthReset = IdentityMatrix(); + depthReset._33 = 0.0f; + depthReset._43 = planeNdcZ; + const D3DMATRIX commonViewPlane = + MultiplyMatrix( + MultiplyMatrix(stockWvp, depthReset), + inverseBaseProjection); + if (!MatrixFinite(commonViewPlane)) + return false; + + for (int eye = 0; eye < 2; ++eye) + { + const D3DMATRIX eyePose = + MatrixFromQuaternionTranslation( + stereo.eyeOrientation[eye], + stereo.eyeOffset[eye], + Settings::VRWorldScale * + Settings::VRStereoDepth); + const D3DMATRIX eyeInverse = + InverseRigid(eyePose); + const D3DMATRIX eyeProjection = + ProjectionFromFov( + baseProjection, stereo.eyeFov[eye]); + const D3DMATRIX corrected = + MultiplyMatrix( + MultiplyMatrix( + MultiplyMatrix( + commonViewPlane, headInverse), + eyeInverse), + eyeProjection); + if (!MatrixFinite(corrected)) + return false; + const D3DMATRIX correctedT = + TransposeMatrix(corrected); + std::memcpy(eyeConstants[eye], &correctedT, + sizeof(correctedT)); + } + return true; + } + + if (screenKind != R30ScreenSpaceKind::Hud2D) + return false; + + for (int eye = 0; eye < 2; ++eye) + { + D3DMATRIX clipCorrection{}; + float hudScaleX = 1.0f; + float hudScaleY = 1.0f; + R30HudContainScale(stereo, hudScaleX, hudScaleY); + // Keep the already-verified ordinary HUD mapping unchanged. + // Only FlatPerspectiveEffect uses the finite 3D plane above. + clipCorrection._11 = hudScaleX; + clipCorrection._22 = hudScaleY; + clipCorrection._33 = 1.0f; + clipCorrection._44 = 1.0f; + clipCorrection._41 = eyeOffset[eye]; + + const D3DMATRIX corrected = + MultiplyMatrix(stockWvp, clipCorrection); + if (!MatrixFinite(corrected)) + return false; + + const D3DMATRIX correctedT = TransposeMatrix(corrected); + std::memcpy(eyeConstants[eye], &correctedT, + sizeof(correctedT)); + } + return true; + } + + template + HRESULT R30TryScreenSpaceFovDraw( + IDirect3DDevice9* device, ActualDraw&& actualDraw, + const char* site) + { + if (!R29StableStereoBase(device)) + return E_NOTIMPL; + + const R30ScreenSpaceKind screenKind = + R30ClassifyScreenSpacePass(device); + if (screenKind == R30ScreenSpaceKind::None) + return E_NOTIMPL; + + if (!EnsureStereoResources(device)) + return E_NOTIMPL; + + if (TrackedDepthStencil && + (!RightDepthSynchronized || !RightStencilSynchronized)) + TryBootstrapRightDepthFromRecentClear(device); + if (TrackedDepthStencil && !RightDepthSynchronized && + DepthTestActive(device)) + return E_NOTIMPL; + if (TrackedDepthStencil && !RightStencilSynchronized && + StencilTestActive(device)) + return E_NOTIMPL; + + OutRunVRRenderer::LatchedStereoFrame stereo{}; + if (!OutRunVRRenderer::GetLatchedStereoFrame(stereo) || + stereo.poseSequence == 0) + return E_NOTIMPL; + + // If perspective world geometry already established this Present's + // pose sequence, screen-space correction must use that exact packet. + if (FrameStereoPoseSequence != 0 && + FrameStereoPoseSequence != stereo.poseSequence) + return E_NOTIMPL; + + float original[16]{}; + float eyeConstants[2][16]{}; + float eyeScale[2]{}; + float eyeOffset[2]{}; + if (!R30BuildScreenSpaceEyeConstants(device, stereo, + screenKind, original, eyeConstants, + eyeScale, eyeOffset)) + { + ++R30ScreenSpaceBuildFailures; + return E_NOTIMPL; + } + + D3DVIEWPORT9 savedViewport{}; + if (FAILED(device->GetViewport(&savedViewport))) + return E_NOTIMPL; + + // Capture the completed world eyes before the first recognized HUD + // draw. Present then extracts glow from this snapshot, so bright HUD + // text/icons are never themselves bloom sources. + R30CaptureSkyGlowSceneBeforeHud(device); + + // From this point the draw is owned by R30. The steady-state frame + // intentionally has no complete independent mono history. + ++R9DrawCalls; + R9MonoBackupGap = true; + if (LeftDrawMayWriteDepth(device) || + LeftDrawMayWriteStencil(device)) + ++R9MainDepthContentSerial; + + bool leftWvpOk = false; + { + InternalPassScope guard; + leftWvpOk = SetWvpOneRegisterAtATime( + device, eyeConstants[0]); + } + if (!leftWvpOk) + { + bool restored = false; + { + InternalPassScope guard; + restored = SetWvpOneRegisterAtATime(device, original); + } + if (!restored) + { + R9Poison(OutRunVR::StereoFailureRestoreFailed, + "R30/HUD-left-WVP-rollback"); + R29ArmMonoSafety(); + return E_FAIL; + } + --R9DrawCalls; + return E_NOTIMPL; + } + + const HRESULT leftHr = actualDraw(); + if (FAILED(leftHr)) + { + bool restored = false; + { + InternalPassScope guard; + restored = SetWvpOneRegisterAtATime(device, original); + } + R9Poison(OutRunVR::StereoFailureLeftDrawFailed, + site, leftHr); + if (!restored) + NoteRestoreFailure("R30 HUD left draw c64"); + R29ArmMonoSafety(); + return leftHr; + } + + IDirect3DSurface9* savedRt = TrackedRenderTarget; + IDirect3DSurface9* savedDepth = TrackedDepthStencil; + HRESULT rightHr = D3D_OK; + OutRunVR::StereoFailureReason rightFailure = + OutRunVR::StereoFailureRightStateFailed; + bool restoreOk = true; + { + InternalPassScope guard; + rightHr = SetRenderTargetHook.stdcall( + device, 0u, RightEyeSurface); + if (SUCCEEDED(rightHr)) + { + rightHr = SetDepthStencilSurfaceHook.stdcall( + device, TrackedDepthStencil ? RightEyeDepth : nullptr); + } + if (SUCCEEDED(rightHr)) + rightHr = device->SetViewport(&savedViewport); + if (SUCCEEDED(rightHr) && + !SetWvpOneRegisterAtATime(device, eyeConstants[1])) + { + rightFailure = + OutRunVR::StereoFailureRightWvpUploadFailed; + rightHr = E_FAIL; + } + if (SUCCEEDED(rightHr)) + { + rightFailure = OutRunVR::StereoFailureRightDrawFailed; + rightHr = actualDraw(); + } + restoreOk = RestoreRightPassState(device, savedRt, savedDepth, + savedViewport, original, true); + } + + FrameHadDuplicatedDraw = true; + ++DuplicatedDraws; + ++NonWorldDuplicatedDraws; + ++R29StableTwoEyeDraws; + ++R30ScreenSpaceFovDraws; + + if (!R30FirstScreenSpaceLogged) + { + R30FirstScreenSpaceLogged = true; + spdlog::info( + "VR R30 HUD: asymmetric-FOV convergence correction ACTIVE; common-centre offset[L/R]={:.4f}/{:.4f} hudScale={:.2f} sourceOverTarget={:.3f}", + eyeOffset[0], eyeOffset[1], R30HudScaleValue(), + R30HudAspectCompensation(stereo)); + } + if (screenKind == R30ScreenSpaceKind::FlatPerspectiveEffect && + !R30FirstFlatPerspectiveLogged) + { + R30FirstFlatPerspectiveLogged = true; + spdlog::info( + "VR R30 FLAT EFFECT: depth-disabled alpha perspective overlay is world-locked on a finite virtual plane with real head/IPD/FOV transform (no HudScale)"); + } + + if (FAILED(rightHr)) + { + FrameRightDrawFailed = true; + InvalidateRightDepthStencilIfLeftMayWrite(device); + R9Poison(rightFailure, site, rightHr); + R29ArmMonoSafety(); + } + if (!restoreOk) + { + InvalidateRightDepthStencilIfLeftMayWrite(device); + NoteRestoreFailure("R30 HUD right-eye draw"); + R29ArmMonoSafety(); + } + return leftHr; + } + + template + HRESULT R30GuardScreenSpace(IDirect3DDevice9* device, + ActualDraw&& actualDraw, R29Draw&& r29Draw, + const char* site) + { + const HRESULT hr = R30TryScreenSpaceFovDraw( + device, std::forward(actualDraw), site); + if (hr != E_NOTIMPL) + return hr; + ++R30ScreenSpaceFallbacks; + return r29Draw(); + } + + HRESULT __stdcall DrawPrimitiveDestR30(IDirect3DDevice9* device, + D3DPRIMITIVETYPE type, UINT startVertex, UINT primitiveCount) + { + const HRESULT xyzrhw = R30TryXyzrhwPrimitiveVB( + device, type, startVertex, primitiveCount); + if (xyzrhw != E_NOTIMPL) + return xyzrhw; + + auto actual = [&]() { + return DrawPrimitiveHook.stdcall( + device, type, startVertex, primitiveCount); + }; + auto r29 = [&]() { + return R30DrawPrimitiveR29Hook.stdcall( + device, type, startVertex, primitiveCount); + }; + return R30GuardScreenSpace( + device, actual, r29, "R30/DrawPrimitive"); + } + + HRESULT __stdcall DrawIndexedPrimitiveDestR30( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + INT baseVertexIndex, UINT minVertexIndex, UINT numVertices, + UINT startIndex, UINT primitiveCount) + { + const HRESULT xyzrhw = R30TryXyzrhwIndexedPrimitiveVB( + device, type, baseVertexIndex, minVertexIndex, numVertices, + startIndex, primitiveCount); + if (xyzrhw != E_NOTIMPL) + return xyzrhw; + + auto actual = [&]() { + return DrawIndexedPrimitiveHook.stdcall(device, type, + baseVertexIndex, minVertexIndex, numVertices, startIndex, + primitiveCount); + }; + auto r29 = [&]() { + return R30DrawIndexedPrimitiveR29Hook.stdcall(device, type, + baseVertexIndex, minVertexIndex, numVertices, startIndex, + primitiveCount); + }; + return R30GuardScreenSpace( + device, actual, r29, "R30/DrawIndexedPrimitive"); + } + + HRESULT __stdcall DrawPrimitiveUPDestR30( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + UINT primitiveCount, const void* data, UINT stride) + { + const HRESULT xyzrhw = R30TryXyzrhwPrimitiveUP( + device, type, primitiveCount, data, stride); + if (xyzrhw != E_NOTIMPL) + return xyzrhw; + + auto actual = [&]() { + return DrawPrimitiveUPHook.stdcall( + device, type, primitiveCount, data, stride); + }; + auto r29 = [&]() { + return R30DrawPrimitiveUPR29Hook.stdcall( + device, type, primitiveCount, data, stride); + }; + return R30GuardScreenSpace( + device, actual, r29, "R30/DrawPrimitiveUP"); + } + + HRESULT __stdcall DrawIndexedPrimitiveUPDestR30( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + UINT minVertexIndex, UINT numVertices, UINT primitiveCount, + const void* indexData, D3DFORMAT indexFormat, + const void* vertexData, UINT stride) + { + const HRESULT xyzrhw = R30TryXyzrhwIndexedPrimitiveUP( + device, type, minVertexIndex, numVertices, primitiveCount, + indexData, indexFormat, vertexData, stride); + if (xyzrhw != E_NOTIMPL) + return xyzrhw; + + auto actual = [&]() { + return DrawIndexedPrimitiveUPHook.stdcall(device, type, + minVertexIndex, numVertices, primitiveCount, indexData, + indexFormat, vertexData, stride); + }; + auto r29 = [&]() { + return R30DrawIndexedPrimitiveUPR29Hook.stdcall(device, + type, minVertexIndex, numVertices, primitiveCount, + indexData, indexFormat, vertexData, stride); + }; + return R30GuardScreenSpace( + device, actual, r29, "R30/DrawIndexedPrimitiveUP"); + } + + void R30RollbackHooks() noexcept + { + R30ResetR29Hook = {}; + R30PresentR29Hook = {}; + R30DrawIndexedPrimitiveUPR29Hook = {}; + R30DrawPrimitiveUPR29Hook = {}; + R30DrawIndexedPrimitiveR29Hook = {}; + R30DrawPrimitiveR29Hook = {}; + } + + bool R30EnableHooks() noexcept + { + SafetyHookInline* hooks[]{ + &R30PresentR29Hook, + &R30ResetR29Hook, + &R30DrawPrimitiveR29Hook, + &R30DrawIndexedPrimitiveR29Hook, + &R30DrawPrimitiveUPR29Hook, + &R30DrawIndexedPrimitiveUPR29Hook + }; + for (auto* hook : hooks) + { + if (!*hook || !hook->enable().has_value()) + return false; + } + return true; + } + + DWORD WINAPI R30InstallThread(void*) + { + using State = OutRunVR::RuntimeEligibility::InstallState; + R30InstallState.store(State::Pending, std::memory_order_release); + + for (int attempt = 0; attempt < 4800; ++attempt) + { + if (Game::D3DDevice_ptr && *Game::D3DDevice_ptr) + R30InstallBufferCreationHooks(*Game::D3DDevice_ptr); + + const auto r29 = R29StereoInstallState.load( + std::memory_order_acquire); + if (r29 == State::Failed) + { + R30RollbackBufferShadowHooks(); + R30InstallState.store(State::Failed, + std::memory_order_release); + HookManager::ReportAsyncResult("OpenXRVRStereoR30HUD", false); + spdlog::error( + "VR R30 HUD: R29 prerequisite failed; R29 remains active without screen-space FOV correction"); + return 0; + } + + if (r29 == State::Ready) + { + const auto disabled = safetyhook::InlineHook::StartDisabled; + R30PresentR29Hook = safetyhook::create_inline( + reinterpret_cast(&PresentDest), + PresentDestR30, disabled); + R30ResetR29Hook = safetyhook::create_inline( + reinterpret_cast(&ResetDest), + ResetDestR30, disabled); + R30DrawPrimitiveR29Hook = safetyhook::create_inline( + reinterpret_cast(&DrawPrimitiveDestR29), + DrawPrimitiveDestR30, disabled); + R30DrawIndexedPrimitiveR29Hook = safetyhook::create_inline( + reinterpret_cast(&DrawIndexedPrimitiveDestR29), + DrawIndexedPrimitiveDestR30, disabled); + R30DrawPrimitiveUPR29Hook = safetyhook::create_inline( + reinterpret_cast(&DrawPrimitiveUPDestR29), + DrawPrimitiveUPDestR30, disabled); + R30DrawIndexedPrimitiveUPR29Hook = safetyhook::create_inline( + reinterpret_cast(&DrawIndexedPrimitiveUPDestR29), + DrawIndexedPrimitiveUPDestR30, disabled); + + if (!R30EnableHooks()) + { + R30RollbackHooks(); + R30RollbackBufferShadowHooks(); + R30InstallState.store(State::Failed, + std::memory_order_release); + HookManager::ReportAsyncResult( + "OpenXRVRStereoR30HUD", false); + spdlog::error( + "VR R30 HUD: disabled-first hook transaction failed; R29 remains active"); + return 0; + } + + R30InstallState.store(State::Ready, + std::memory_order_release); + HookManager::ReportAsyncResult( + "OpenXRVRStereoR30HUD", true); + spdlog::info( + "VR R30 HUD: ScreenSpace2D correction READY with configurable common-center HUD scale current={:.2f}; R36 contain-fit + depth-disabled overlay zero-disparity + head-relative XYZRHW reprojection active", + R30HudScaleValue()); + return 0; + } + Sleep(25); + } + + R30RollbackBufferShadowHooks(); + R30InstallState.store(State::Failed, std::memory_order_release); + HookManager::ReportAsyncResult("OpenXRVRStereoR30HUD", false); + spdlog::error( + "VR R30 HUD: timed out waiting for R29; R29 remains active"); + return 0; + } + + class VRStereoR30HudHook final : public Hook + { + public: + std::string_view description() override + { + return "OpenXRVRStereoR30HUD"; + } + bool validate() override { return true; } + bool apply() override + { + using State = OutRunVR::RuntimeEligibility::InstallState; + R30InstallState.store(State::Pending, + std::memory_order_release); + HANDLE thread = CreateThread(nullptr, 0, + R30InstallThread, nullptr, 0, nullptr); + if (!thread) + { + R30InstallState.store(State::Failed, + std::memory_order_release); + return false; + } + CloseHandle(thread); + return true; + } + static VRStereoR30HudHook instance; + }; + + VRStereoR30HudHook VRStereoR30HudHook::instance; + } +} diff --git a/src/vr/d3d9/stereo_renderer_r30_c2_compare.cpp b/src/vr/d3d9/stereo_renderer_r30_c2_compare.cpp new file mode 100644 index 000000000..93d6c06fb --- /dev/null +++ b/src/vr/d3d9/stereo_renderer_r30_c2_compare.cpp @@ -0,0 +1,47 @@ +// C2 regression-isolation build. +// +// C2 is C1 plus the R30 asymmetric-FOV HUD correction. R31-R34 are intentionally +// absent so this test answers only whether R30 fixes the remaining HUD after the +// R27/R28 perspective-world classifier is restored under R29's fast LEFT+RIGHT +// path. R31 normally exports the StateBlock helpers; report conservative state +// here so any dependent code uses live/fail-closed validation. + +#include "stereo_renderer_r30.cpp" + +namespace OutRunVRStereo +{ + bool IsGameStateBlockRecording() noexcept + { + return false; + } + + bool IsStateBlockTrackingReliable() noexcept + { + return false; + } + + namespace + { + class VRC2CompareMarkerHook final : public Hook + { + public: + std::string_view description() override + { + return "OpenXRVRC2Compare"; + } + + bool validate() override { return true; } + + bool apply() override + { + spdlog::warn( + "VR C2 COMPARE: C1 R29+R27/R28 classification + R30 HUD correction ACTIVE; R31-R34 excluded"); + return true; + } + + static VRC2CompareMarkerHook instance; + }; + + VRC2CompareMarkerHook VRC2CompareMarkerHook::instance; + } +} diff --git a/src/vr/d3d9/stereo_renderer_r30_r26_safe.cpp b/src/vr/d3d9/stereo_renderer_r30_r26_safe.cpp new file mode 100644 index 000000000..d90290567 --- /dev/null +++ b/src/vr/d3d9/stereo_renderer_r30_r26_safe.cpp @@ -0,0 +1,3675 @@ +// R30 screen-space asymmetric-FOV correction overlay. +// +// R29 restores conservative world/effect classification and removes the steady- +// state third mono draw. R30 fixes the remaining HUD convergence problem without +// moving the world image: only main-backbuffer orthographic/ScreenSpace2D draws +// receive an eye-specific clip-space X affine. The affine maps one common +// head-relative tangent-angle interval into each eye's actual asymmetric OpenXR +// FOV, so the same HUD feature lands on the same visual ray in both eyes. +// +// This is intentionally an interim projection-layer HUD solution. It does not +// pretend to be XrCompositionLayerQuad: perspective world draws and fragile +// perspective effects remain entirely owned by R29/R13. + +#include "stereo_renderer_r26.cpp" +#include +#include +#include +#include +#include +#include + +namespace Settings +{ + extern Setting VRHudScale; + extern Setting VRStereoDepth; + extern Setting SkyGlowFactor; + extern Setting SkyGlowTwoStep; +} + +namespace OutRunVRRenderer +{ + bool GetLastRawGameWvpWrite(float outConstants[16], + std::uint64_t& writeSerial, std::uint64_t& topLevelDrawSerial, + std::uintptr_t& shaderIdentity, std::uint64_t& shaderSerial) noexcept; +} + +namespace OutRunVRStereo +{ + namespace + { + // R26-safe comparison owner: R26 remains the world/effect authority; + // only R30 HUD, XYZRHW correction and stereo SkyGlow are layered above. + std::uint64_t R30SafeTwoEyeDraws = 0; + + void R30ArmSafeFallback(std::uint64_t = 2) noexcept + { + FrameStereoIncomplete = true; + if (R9MonoSeeded && !R9MonoBackupGap) + R13ForceMonoShadow = true; + } + + bool R30SafeStereoBase(IDirect3DDevice9* device) noexcept + { + if (!IsGameDevice(device) || InternalStereoPass || + !TargetIsBackBuffer() || !StereoWanted() || !R9StereoSeeded) + return false; + if (!OutRunVR::RuntimeEligibility::MayInjectStereo() || + OutRunVR::RuntimeEligibility::RecoveryPending.load( + std::memory_order_acquire) || + OutRunVR::RuntimeEligibility::PoseWarmupAllowed()) + return false; + if (FrameStereoIncomplete || R9DeferredDepth || + !R9CurrentDepthCanMirror() || AnyAuxRenderTargetActive() || + R13ForceMonoShadow || + OcclusionQueryTrackingUnavailable.load( + std::memory_order_acquire) || + ActiveOcclusionQueries.load(std::memory_order_acquire) > 0) + return false; + return true; + } + + SafetyHookInline R30DrawPrimitiveR29Hook{}; + SafetyHookInline R30DrawIndexedPrimitiveR29Hook{}; + SafetyHookInline R30DrawPrimitiveUPR29Hook{}; + SafetyHookInline R30DrawIndexedPrimitiveUPR29Hook{}; + + std::atomic R30InstallState{ + OutRunVR::RuntimeEligibility::InstallState::Pending }; + + std::uint64_t R30ScreenSpaceFovDraws = 0; + std::uint64_t R30ScreenSpaceFallbacks = 0; + std::uint64_t R30ScreenSpaceBuildFailures = 0; + bool R30FirstScreenSpaceLogged = false; + bool R30FirstFlatPerspectiveLogged = false; + bool R30FirstPerspectiveHudLogged = false; + + std::uint64_t R30XyzrhwHudDraws = 0; + std::uint64_t R30XyzrhwWorldLockedHudDraws = 0; + std::uint64_t R30XyzrhwWorldEffectDraws = 0; + std::uint64_t R30Hud2DDraws = 0; + std::uint64_t R30PerspectiveHudDraws = 0; + std::uint64_t R30WorldBillboardDraws = 0; + std::uint64_t R44OverlayOwnedWvpHits = 0; + std::uint64_t R44OverlayOwnedWvpGroupHits = 0; + std::uint64_t R44SpatialBillboardClassifications = 0; + std::uint64_t R44FlatOverlayClassifications = 0; + std::uint64_t R30XyzrhwRhwWorldPromotions = 0; + std::uint64_t R30XyzrhwRhwOnlyDepthEvidence = 0; + std::uint64_t R30XyzrhwZOnlyDepthEvidence = 0; + std::uint64_t R30XyzrhwFallbacks = 0; + bool R30FirstXyzrhwHudLogged = false; + bool R30FirstXyzrhwWorldLogged = false; + bool R30FirstXyzrhwRhwPromotionLogged = false; + bool R30FirstXyzrhwRhwOnlyEvidenceLogged = false; + std::uint64_t R30XyzrhwAtomicFallbacks = 0; + std::uint64_t R30XyzrhwDepthPreserveFallbacks = 0; + bool R30FirstXyzrhwAtomicFallbackLogged = false; + std::uint64_t R30XyzrhwBilateralFallbacks = 0; + bool R30FirstXyzrhwBilateralFallbackLogged = false; + + + // R30.6 CPU shadows for XYZRHW vertex/index buffers. + // + // The old VB/IB conversion path called Lock(READONLY) from inside Draw, + // which can block behind the GPU and also fails for WRITEONLY buffers. + // Observe the game's own Lock/Unlock writes instead and keep the exact + // CPU byte ranges that are known current. Draw then consumes only this + // shadow and fails open to R29 when a range was never observed. + constexpr std::size_t R30CreateVertexBufferVtableIndex = 26; + constexpr std::size_t R30CreateIndexBufferVtableIndex = 27; + constexpr std::size_t R30BufferReleaseVtableIndex = 2; + constexpr std::size_t R30BufferLockVtableIndex = 11; + constexpr std::size_t R30BufferUnlockVtableIndex = 12; + constexpr UINT R30MaxShadowBytes = 16u * 1024u * 1024u; + + SafetyHookInline R30CreateVertexBufferHook{}; + SafetyHookInline R30CreateIndexBufferHook{}; + SafetyHookInline R30VertexBufferReleaseHook{}; + SafetyHookInline R30VertexBufferLockHook{}; + SafetyHookInline R30VertexBufferUnlockHook{}; + SafetyHookInline R30IndexBufferReleaseHook{}; + SafetyHookInline R30IndexBufferLockHook{}; + SafetyHookInline R30IndexBufferUnlockHook{}; + + struct R30ByteRange + { + UINT begin = 0; + UINT end = 0; // exclusive + }; + + struct R30BufferShadow + { + std::mutex mutex; + UINT size = 0; + DWORD usage = 0; + D3DPOOL pool = D3DPOOL_DEFAULT; + D3DFORMAT indexFormat = D3DFMT_UNKNOWN; + std::vector bytes; + std::vector valid; + void* lockPtr = nullptr; + UINT lockOffset = 0; + UINT lockSize = 0; + DWORD lockFlags = 0; + bool writeLock = false; + }; + + using R30ShadowPtr = std::shared_ptr; + std::mutex R30ShadowRegistryMutex; + std::mutex R30ShadowHookMutex; + std::unordered_map + R30VertexShadows; + std::unordered_map + R30IndexShadows; + std::uint64_t R30ShadowWrites = 0; + std::uint64_t R30ShadowReadHits = 0; + std::uint64_t R30ShadowReadMisses = 0; + std::uint64_t R30ShadowDiscardInvalidations = 0; + std::atomic R30BufferShadowCaptureArmed{ false }; + bool R30FirstShadowMissLogged = false; + bool R30FirstShadowArmLogged = false; + + struct R30ScratchBuffers + { + std::vector source; + std::vector indexSource; + std::vector left; + std::vector right; + std::vector used; + std::vector physical; + std::vector indices16; + std::vector indices32; + bool busy = false; + }; + thread_local R30ScratchBuffers R30Scratch; + + struct R30ScratchLease + { + R30ScratchBuffers* buffers = nullptr; + R30ScratchLease() noexcept + { + if (!R30Scratch.busy) + { + R30Scratch.busy = true; + buffers = &R30Scratch; + } + } + ~R30ScratchLease() + { + if (buffers) + buffers->busy = false; + } + explicit operator bool() const noexcept { return buffers != nullptr; } + }; + + void R30MergeValidRange(std::vector& ranges, + UINT begin, UINT end) + { + if (begin >= end) + return; + R30ByteRange merged{ begin, end }; + auto it = ranges.begin(); + while (it != ranges.end() && it->end < merged.begin) + ++it; + while (it != ranges.end() && it->begin <= merged.end) + { + merged.begin = std::min(merged.begin, it->begin); + merged.end = std::max(merged.end, it->end); + it = ranges.erase(it); + } + ranges.insert(it, merged); + } + + bool R30RangeValid(const std::vector& ranges, + UINT begin, UINT end) noexcept + { + if (begin >= end) + return false; + for (const auto& range : ranges) + { + if (range.begin <= begin && range.end >= end) + return true; + if (range.begin > begin) + break; + } + return false; + } + + R30ShadowPtr R30FindVertexShadow(IDirect3DVertexBuffer9* buffer) + { + std::lock_guard lock(R30ShadowRegistryMutex); + const auto it = R30VertexShadows.find(buffer); + return it == R30VertexShadows.end() ? nullptr : it->second; + } + + R30ShadowPtr R30FindIndexShadow(IDirect3DIndexBuffer9* buffer) + { + std::lock_guard lock(R30ShadowRegistryMutex); + const auto it = R30IndexShadows.find(buffer); + return it == R30IndexShadows.end() ? nullptr : it->second; + } + + R30ShadowPtr R30EnsureVertexShadow(IDirect3DVertexBuffer9* buffer) + { + if (!buffer) + return nullptr; + if (auto existing = R30FindVertexShadow(buffer)) + return existing; + D3DVERTEXBUFFER_DESC desc{}; + if (FAILED(buffer->GetDesc(&desc)) || !desc.Size || + desc.Size > R30MaxShadowBytes) + return nullptr; + auto entry = std::make_shared(); + entry->size = desc.Size; + entry->usage = desc.Usage; + entry->pool = desc.Pool; + std::lock_guard lock(R30ShadowRegistryMutex); + auto [it, inserted] = R30VertexShadows.emplace(buffer, entry); + return inserted ? entry : it->second; + } + + R30ShadowPtr R30EnsureIndexShadow(IDirect3DIndexBuffer9* buffer) + { + if (!buffer) + return nullptr; + if (auto existing = R30FindIndexShadow(buffer)) + return existing; + D3DINDEXBUFFER_DESC desc{}; + if (FAILED(buffer->GetDesc(&desc)) || !desc.Size || + desc.Size > R30MaxShadowBytes || + (desc.Format != D3DFMT_INDEX16 && + desc.Format != D3DFMT_INDEX32)) + return nullptr; + auto entry = std::make_shared(); + entry->size = desc.Size; + entry->usage = desc.Usage; + entry->pool = desc.Pool; + entry->indexFormat = desc.Format; + std::lock_guard lock(R30ShadowRegistryMutex); + auto [it, inserted] = R30IndexShadows.emplace(buffer, entry); + return inserted ? entry : it->second; + } + + void R30BeginObservedLock(const R30ShadowPtr& entry, + UINT offset, UINT size, void* data, DWORD flags) + { + if (!entry || !data) + return; + std::lock_guard lock(entry->mutex); + if (offset > entry->size) + return; + const UINT effectiveSize = + size == 0 ? entry->size - offset : size; + if (!effectiveSize || effectiveSize > entry->size - offset) + return; + entry->lockPtr = data; + entry->lockOffset = offset; + entry->lockSize = effectiveSize; + entry->lockFlags = flags; + entry->writeLock = (flags & D3DLOCK_READONLY) == 0; + if (entry->writeLock && (flags & D3DLOCK_DISCARD)) + { + entry->valid.clear(); + ++R30ShadowDiscardInvalidations; + } + } + + void R30FinishObservedLock(const R30ShadowPtr& entry, + bool unlockSucceeded) + { + if (!entry) + return; + std::lock_guard lock(entry->mutex); + if (unlockSucceeded && entry->writeLock && entry->lockPtr && + entry->lockSize && entry->lockOffset <= entry->size && + entry->lockSize <= entry->size - entry->lockOffset) + { + try + { + if (entry->bytes.size() != entry->size) + entry->bytes.resize(entry->size); + std::memcpy(entry->bytes.data() + entry->lockOffset, + entry->lockPtr, entry->lockSize); + R30MergeValidRange(entry->valid, entry->lockOffset, + entry->lockOffset + entry->lockSize); + ++R30ShadowWrites; + } + catch (...) + { + entry->bytes.clear(); + entry->valid.clear(); + } + } + else if (!unlockSucceeded && entry->writeLock) + { + entry->valid.clear(); + } + entry->lockPtr = nullptr; + entry->lockOffset = 0; + entry->lockSize = 0; + entry->lockFlags = 0; + entry->writeLock = false; + } + + bool R30CopyVertexShadow(IDirect3DVertexBuffer9* buffer, + UINT offset, UINT size, std::vector& out) + { + const auto entry = R30FindVertexShadow(buffer); + if (!entry) + { + ++R30ShadowReadMisses; + return false; + } + std::lock_guard lock(entry->mutex); + if (offset > entry->size || size > entry->size - offset || + entry->bytes.size() != entry->size || + !R30RangeValid(entry->valid, offset, offset + size)) + { + ++R30ShadowReadMisses; + if (!R30FirstShadowMissLogged) + { + R30FirstShadowMissLogged = true; + spdlog::info( + "VR R30.6 BUFFER SHADOW: draw-time GPU Lock removed; an unobserved VB/IB range will fail open until the game's next write Lock/Unlock supplies CPU bytes"); + } + return false; + } + try + { + out.resize(size); + std::memcpy(out.data(), entry->bytes.data() + offset, size); + } + catch (...) + { + return false; + } + ++R30ShadowReadHits; + return true; + } + + bool R30CopyIndexShadow(IDirect3DIndexBuffer9* buffer, + UINT offset, UINT size, std::vector& out) + { + const auto entry = R30FindIndexShadow(buffer); + if (!entry) + { + ++R30ShadowReadMisses; + return false; + } + std::lock_guard lock(entry->mutex); + if (offset > entry->size || size > entry->size - offset || + entry->bytes.size() != entry->size || + !R30RangeValid(entry->valid, offset, offset + size)) + { + ++R30ShadowReadMisses; + return false; + } + try + { + out.resize(size); + std::memcpy(out.data(), entry->bytes.data() + offset, size); + } + catch (...) + { + return false; + } + ++R30ShadowReadHits; + return true; + } + + HRESULT __stdcall R30VertexBufferLockDest( + IDirect3DVertexBuffer9* buffer, UINT offset, UINT size, + void** data, DWORD flags) + { + const HRESULT hr = R30VertexBufferLockHook.stdcall( + buffer, offset, size, data, flags); + if (R30BufferShadowCaptureArmed.load( + std::memory_order_acquire) && + SUCCEEDED(hr) && data && *data) + R30BeginObservedLock( + R30EnsureVertexShadow(buffer), offset, size, *data, flags); + return hr; + } + + HRESULT __stdcall R30VertexBufferUnlockDest( + IDirect3DVertexBuffer9* buffer) + { + const auto entry = R30FindVertexShadow(buffer); + // The pointer returned by Lock is guaranteed valid until Unlock, + // so snapshot bytes before forwarding the real Unlock. + if (entry) + R30FinishObservedLock(entry, true); + const HRESULT hr = + R30VertexBufferUnlockHook.stdcall(buffer); + if (FAILED(hr) && entry) + { + std::lock_guard lock(entry->mutex); + entry->valid.clear(); + } + return hr; + } + + ULONG __stdcall R30VertexBufferReleaseDest( + IDirect3DVertexBuffer9* buffer) + { + const ULONG refs = + R30VertexBufferReleaseHook.stdcall(buffer); + if (refs == 0) + { + std::lock_guard lock(R30ShadowRegistryMutex); + R30VertexShadows.erase(buffer); + } + return refs; + } + + HRESULT __stdcall R30IndexBufferLockDest( + IDirect3DIndexBuffer9* buffer, UINT offset, UINT size, + void** data, DWORD flags) + { + const HRESULT hr = R30IndexBufferLockHook.stdcall( + buffer, offset, size, data, flags); + if (R30BufferShadowCaptureArmed.load( + std::memory_order_acquire) && + SUCCEEDED(hr) && data && *data) + R30BeginObservedLock( + R30EnsureIndexShadow(buffer), offset, size, *data, flags); + return hr; + } + + HRESULT __stdcall R30IndexBufferUnlockDest( + IDirect3DIndexBuffer9* buffer) + { + const auto entry = R30FindIndexShadow(buffer); + if (entry) + R30FinishObservedLock(entry, true); + const HRESULT hr = + R30IndexBufferUnlockHook.stdcall(buffer); + if (FAILED(hr) && entry) + { + std::lock_guard lock(entry->mutex); + entry->valid.clear(); + } + return hr; + } + + ULONG __stdcall R30IndexBufferReleaseDest( + IDirect3DIndexBuffer9* buffer) + { + const ULONG refs = + R30IndexBufferReleaseHook.stdcall(buffer); + if (refs == 0) + { + std::lock_guard lock(R30ShadowRegistryMutex); + R30IndexShadows.erase(buffer); + } + return refs; + } + + bool R30EnsureVertexBufferHooks(IDirect3DVertexBuffer9* buffer) + { + if (!buffer) + return false; + std::lock_guard lock(R30ShadowHookMutex); + auto** vtable = *reinterpret_cast(buffer); + if (!R30VertexBufferLockHook) + R30VertexBufferLockHook = safetyhook::create_inline( + vtable[R30BufferLockVtableIndex], + R30VertexBufferLockDest); + if (!R30VertexBufferUnlockHook) + R30VertexBufferUnlockHook = safetyhook::create_inline( + vtable[R30BufferUnlockVtableIndex], + R30VertexBufferUnlockDest); + if (!R30VertexBufferReleaseHook) + R30VertexBufferReleaseHook = safetyhook::create_inline( + vtable[R30BufferReleaseVtableIndex], + R30VertexBufferReleaseDest); + return R30VertexBufferLockHook && + R30VertexBufferUnlockHook && R30VertexBufferReleaseHook; + } + + bool R30EnsureIndexBufferHooks(IDirect3DIndexBuffer9* buffer) + { + if (!buffer) + return false; + std::lock_guard lock(R30ShadowHookMutex); + auto** vtable = *reinterpret_cast(buffer); + if (!R30IndexBufferLockHook) + R30IndexBufferLockHook = safetyhook::create_inline( + vtable[R30BufferLockVtableIndex], + R30IndexBufferLockDest); + if (!R30IndexBufferUnlockHook) + R30IndexBufferUnlockHook = safetyhook::create_inline( + vtable[R30BufferUnlockVtableIndex], + R30IndexBufferUnlockDest); + if (!R30IndexBufferReleaseHook) + R30IndexBufferReleaseHook = safetyhook::create_inline( + vtable[R30BufferReleaseVtableIndex], + R30IndexBufferReleaseDest); + return R30IndexBufferLockHook && + R30IndexBufferUnlockHook && R30IndexBufferReleaseHook; + } + + HRESULT __stdcall R30CreateVertexBufferDest( + IDirect3DDevice9* device, UINT length, DWORD usage, DWORD fvf, + D3DPOOL pool, IDirect3DVertexBuffer9** out, HANDLE* shared) + { + const HRESULT hr = R30CreateVertexBufferHook.stdcall( + device, length, usage, fvf, pool, out, shared); + if (SUCCEEDED(hr) && out && *out) + { + R30EnsureVertexBufferHooks(*out); + if (R30BufferShadowCaptureArmed.load( + std::memory_order_acquire)) + R30EnsureVertexShadow(*out); + } + return hr; + } + + HRESULT __stdcall R30CreateIndexBufferDest( + IDirect3DDevice9* device, UINT length, DWORD usage, + D3DFORMAT format, D3DPOOL pool, + IDirect3DIndexBuffer9** out, HANDLE* shared) + { + const HRESULT hr = R30CreateIndexBufferHook.stdcall( + device, length, usage, format, pool, out, shared); + if (SUCCEEDED(hr) && out && *out) + { + R30EnsureIndexBufferHooks(*out); + if (R30BufferShadowCaptureArmed.load( + std::memory_order_acquire)) + R30EnsureIndexShadow(*out); + } + return hr; + } + + void R30InstallBufferCreationHooks(IDirect3DDevice9* device) + { + if (!device) + return; + auto** vtable = *reinterpret_cast(device); + if (!R30CreateVertexBufferHook) + R30CreateVertexBufferHook = safetyhook::create_inline( + vtable[R30CreateVertexBufferVtableIndex], + R30CreateVertexBufferDest); + if (!R30CreateIndexBufferHook) + R30CreateIndexBufferHook = safetyhook::create_inline( + vtable[R30CreateIndexBufferVtableIndex], + R30CreateIndexBufferDest); + if (!R30CreateVertexBufferHook || !R30CreateIndexBufferHook) + spdlog::warn( + "VR R30.6 BUFFER SHADOW: creation hook incomplete; existing/dynamic buffers still register lazily on draw/Lock"); + } + + void R30RollbackBufferShadowHooks() noexcept + { + // Unhook entry points before dropping registry ownership. Any draw + // holding a COM reference also holds a shared_ptr acquired from the + // registry, so clearing the maps cannot invalidate an in-flight + // shadow object. + R30CreateIndexBufferHook = {}; + R30CreateVertexBufferHook = {}; + R30IndexBufferUnlockHook = {}; + R30IndexBufferLockHook = {}; + R30IndexBufferReleaseHook = {}; + R30VertexBufferUnlockHook = {}; + R30VertexBufferLockHook = {}; + R30VertexBufferReleaseHook = {}; + { + std::lock_guard lock( + R30ShadowRegistryMutex); + R30IndexShadows.clear(); + R30VertexShadows.clear(); + } + } + + + // R30.6 stereo sky glow. + // + // The original game samples D3DBACKBUFFER_TYPE_MONO and owns only one + // glow chain. In true stereo that is necessarily the left eye. Keep the + // original mono post-process disabled and run a completely separate + // reduced/blurred chain for each completed eye immediately before the + // renderer composes/publishes the stereo frame. + SafetyHookInline R30PresentR29Hook{}; + SafetyHookInline R30ResetR29Hook{}; + + struct R30SkyGlowResources + { + IDirect3DTexture9* reduced[2]{}; + IDirect3DTexture9* temp[2]{}; + IDirect3DPixelShader9* bright = nullptr; + IDirect3DPixelShader9* blur = nullptr; + IDirect3DPixelShader9* composite = nullptr; + UINT eyeWidth = 0; + UINT eyeHeight = 0; + UINT glowWidth = 0; + UINT glowHeight = 0; + int factor = 0; + }; + R30SkyGlowResources R30SkyGlow{}; + std::uint64_t R30SkyGlowFrames = 0; + std::uint64_t R30SkyGlowFailures = 0; + std::uint64_t R30SkyGlowSceneCaptureEpoch = 0; + bool R30FirstSkyGlowLogged = false; + bool R30FirstSkyGlowFailureLogged = false; + + void R30ReleaseSkyGlowResources() noexcept + { + for (int eye = 0; eye < 2; ++eye) + { + ReleaseCom(R30SkyGlow.reduced[eye]); + ReleaseCom(R30SkyGlow.temp[eye]); + } + ReleaseCom(R30SkyGlow.bright); + ReleaseCom(R30SkyGlow.blur); + ReleaseCom(R30SkyGlow.composite); + R30SkyGlow.eyeWidth = 0; + R30SkyGlow.eyeHeight = 0; + R30SkyGlow.glowWidth = 0; + R30SkyGlow.glowHeight = 0; + R30SkyGlow.factor = 0; + } + + bool R30CompilePixelShader(IDirect3DDevice9* device, + const char* source, const char* entry, + IDirect3DPixelShader9** shader) + { + if (!device || !source || !entry || !shader) + return false; + ID3DBlob* bytecode = nullptr; + ID3DBlob* errors = nullptr; + const HRESULT compile = D3DCompile( + source, std::strlen(source), + "OutRunVR-StereoSkyGlow", nullptr, nullptr, + entry, "ps_2_0", + D3DCOMPILE_OPTIMIZATION_LEVEL3, + 0, &bytecode, &errors); + if (FAILED(compile) || !bytecode) + { + if (errors && errors->GetBufferPointer()) + spdlog::warn( + "VR SKY GLOW: pixel shader compile failed: {}", + static_cast( + errors->GetBufferPointer())); + ReleaseCom(errors); + ReleaseCom(bytecode); + return false; + } + const HRESULT create = device->CreatePixelShader( + static_cast(bytecode->GetBufferPointer()), + shader); + ReleaseCom(errors); + ReleaseCom(bytecode); + return SUCCEEDED(create) && *shader; + } + + bool R30EnsureSkyGlowResources(IDirect3DDevice9* device) + { + if (!device || !BackBufferDesc.Width || !BackBufferDesc.Height) + return false; + const int factor = + std::clamp(Settings::SkyGlowFactor.get(), 1, 16); + const UINT glowWidth = std::max( + 160u, BackBufferDesc.Width / + static_cast(factor)); + const UINT glowHeight = std::max( + 120u, BackBufferDesc.Height / + static_cast(factor)); + + if (R30SkyGlow.reduced[0] && R30SkyGlow.reduced[1] && + R30SkyGlow.temp[0] && R30SkyGlow.temp[1] && + R30SkyGlow.bright && R30SkyGlow.blur && + R30SkyGlow.composite && + R30SkyGlow.eyeWidth == BackBufferDesc.Width && + R30SkyGlow.eyeHeight == BackBufferDesc.Height && + R30SkyGlow.glowWidth == glowWidth && + R30SkyGlow.glowHeight == glowHeight && + R30SkyGlow.factor == factor) + return true; + + R30ReleaseSkyGlowResources(); + + for (int eye = 0; eye < 2; ++eye) + { + if (FAILED(device->CreateTexture( + glowWidth, glowHeight, 1, + D3DUSAGE_RENDERTARGET, + D3DFMT_A8R8G8B8, D3DPOOL_DEFAULT, + &R30SkyGlow.reduced[eye], nullptr)) || + FAILED(device->CreateTexture( + glowWidth, glowHeight, 1, + D3DUSAGE_RENDERTARGET, + D3DFMT_A8R8G8B8, D3DPOOL_DEFAULT, + &R30SkyGlow.temp[eye], nullptr))) + { + R30ReleaseSkyGlowResources(); + return false; + } + } + + static constexpr char BrightPs[] = R"( + sampler Scene : register(s0); + float4 Bright(float2 uv : TEXCOORD0) : COLOR0 + { + float4 c = tex2D(Scene, uv); + float peak = max(c.r, max(c.g, c.b)); + float bright = saturate((peak - 0.58) * 2.75); + // World HDR/exposure draws normally leave useful alpha below + // one, while most fixed HUD art is opaque. This is not used + // for eye identity; it only suppresses UI contamination. + float worldMask = max(0.15, + saturate((1.0 - c.a) * 2.0)); + float m = bright * worldMask; + return float4(c.rgb * m, m); + })"; + static constexpr char BlurPs[] = R"( + sampler Source : register(s0); + float4 Texel : register(c0); + float4 Blur(float2 uv : TEXCOORD0) : COLOR0 + { + float2 d = Texel.xy; + float4 c = tex2D(Source, uv) * 0.40; + c += tex2D(Source, uv + d * 1.5) * 0.24; + c += tex2D(Source, uv - d * 1.5) * 0.24; + c += tex2D(Source, uv + d * 3.5) * 0.06; + c += tex2D(Source, uv - d * 3.5) * 0.06; + return c; + })"; + static constexpr char CompositePs[] = R"( + sampler Glow : register(s0); + float4 Params : register(c0); + float4 Composite(float2 uv : TEXCOORD0) : COLOR0 + { + float4 g = tex2D(Glow, uv); + return float4(g.rgb * Params.x, 0.0); + })"; + + if (!R30CompilePixelShader( + device, BrightPs, "Bright", + &R30SkyGlow.bright) || + !R30CompilePixelShader( + device, BlurPs, "Blur", + &R30SkyGlow.blur) || + !R30CompilePixelShader( + device, CompositePs, "Composite", + &R30SkyGlow.composite)) + { + R30ReleaseSkyGlowResources(); + return false; + } + + R30SkyGlow.eyeWidth = BackBufferDesc.Width; + R30SkyGlow.eyeHeight = BackBufferDesc.Height; + R30SkyGlow.glowWidth = glowWidth; + R30SkyGlow.glowHeight = glowHeight; + R30SkyGlow.factor = factor; + return true; + } + + struct R30GlowVertex + { + float x, y, z, rhw; + float u, v; + }; + + bool R30DrawSkyGlowPass(IDirect3DDevice9* device, + IDirect3DSurface9* target, UINT width, UINT height, + IDirect3DTexture9* source, + IDirect3DPixelShader9* shader, + const float constant[4], + bool additive) + { + if (!device || !target || !source || !shader || + !width || !height) + return false; + + if (FAILED(device->SetRenderTarget(0, target)) || + FAILED(device->SetDepthStencilSurface(nullptr))) + return false; + + D3DVIEWPORT9 viewport{}; + viewport.Width = width; + viewport.Height = height; + viewport.MinZ = 0.0f; + viewport.MaxZ = 1.0f; + if (FAILED(device->SetViewport(&viewport))) + return false; + + const R30GlowVertex v[4]{ + { -0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f }, + { static_cast(width) - 0.5f, -0.5f, + 0.0f, 1.0f, 1.0f, 0.0f }, + { -0.5f, static_cast(height) - 0.5f, + 0.0f, 1.0f, 0.0f, 1.0f }, + { static_cast(width) - 0.5f, + static_cast(height) - 0.5f, + 0.0f, 1.0f, 1.0f, 1.0f } + }; + + device->SetVertexShader(nullptr); + device->SetFVF(D3DFVF_XYZRHW | D3DFVF_TEX1); + device->SetPixelShader(shader); + device->SetTexture(0, source); + device->SetSamplerState(0, D3DSAMP_MINFILTER, D3DTEXF_LINEAR); + device->SetSamplerState(0, D3DSAMP_MAGFILTER, D3DTEXF_LINEAR); + device->SetSamplerState(0, D3DSAMP_MIPFILTER, D3DTEXF_NONE); + device->SetSamplerState(0, D3DSAMP_ADDRESSU, D3DTADDRESS_CLAMP); + device->SetSamplerState(0, D3DSAMP_ADDRESSV, D3DTADDRESS_CLAMP); + device->SetRenderState(D3DRS_ZENABLE, D3DZB_FALSE); + device->SetRenderState(D3DRS_ZWRITEENABLE, FALSE); + device->SetRenderState(D3DRS_STENCILENABLE, FALSE); + device->SetRenderState(D3DRS_ALPHATESTENABLE, FALSE); + device->SetRenderState(D3DRS_ALPHABLENDENABLE, + additive ? TRUE : FALSE); + if (additive) + { + device->SetRenderState(D3DRS_SRCBLEND, D3DBLEND_ONE); + device->SetRenderState(D3DRS_DESTBLEND, D3DBLEND_ONE); + device->SetRenderState(D3DRS_BLENDOP, D3DBLENDOP_ADD); + device->SetRenderState(D3DRS_COLORWRITEENABLE, + D3DCOLORWRITEENABLE_RED | + D3DCOLORWRITEENABLE_GREEN | + D3DCOLORWRITEENABLE_BLUE); + } + else + { + device->SetRenderState(D3DRS_COLORWRITEENABLE, + D3DCOLORWRITEENABLE_RED | + D3DCOLORWRITEENABLE_GREEN | + D3DCOLORWRITEENABLE_BLUE | + D3DCOLORWRITEENABLE_ALPHA); + } + device->SetPixelShaderConstantF(0, constant, 1); + const HRESULT hr = device->DrawPrimitiveUP( + D3DPT_TRIANGLESTRIP, 2, v, sizeof(R30GlowVertex)); + device->SetTexture(0, nullptr); + return SUCCEEDED(hr); + } + + bool R30CaptureSkyGlowSceneBeforeHud( + IDirect3DDevice9* device) + { + if (!device || Settings::SkyGlowFactor <= 0 || + !FrameHadWorldStereo || !FrameHadDuplicatedDraw || + FrameRightDrawFailed || FrameStereoIncomplete || + !BackBuffer || !RightEyeSurface) + return false; + + if (R30SkyGlowSceneCaptureEpoch == PresentEpoch) + return true; + if (!R30EnsureSkyGlowResources(device)) + return false; + + IDirect3DSurface9* eyeSurface[2]{ + BackBuffer, RightEyeSurface + }; + bool ok = true; + for (int eye = 0; eye < 2 && ok; ++eye) + { + IDirect3DSurface9* reduced = nullptr; + if (FAILED(R30SkyGlow.reduced[eye]->GetSurfaceLevel( + 0, &reduced)) || !reduced) + { + ok = false; + break; + } + ok = SUCCEEDED(device->StretchRect( + eyeSurface[eye], nullptr, reduced, nullptr, + D3DTEXF_LINEAR)); + reduced->Release(); + } + if (ok) + R30SkyGlowSceneCaptureEpoch = PresentEpoch; + return ok; + } + + bool R30ApplyStereoSkyGlow(IDirect3DDevice9* device) + { + if (!device || Settings::SkyGlowFactor <= 0 || + !StereoWanted() || !FrameHadWorldStereo || + !FrameHadDuplicatedDraw || FrameRightDrawFailed || + FrameStereoIncomplete || !BackBuffer || + !RightEyeSurface) + return true; + + if (!R30EnsureSkyGlowResources(device)) + return false; + + IDirect3DStateBlock9* stateBlock = nullptr; + IDirect3DSurface9* savedRt = nullptr; + IDirect3DSurface9* savedDepth = nullptr; + D3DVIEWPORT9 savedViewport{}; + if (FAILED(device->CreateStateBlock( + D3DSBT_ALL, &stateBlock)) || + !stateBlock || + FAILED(device->GetRenderTarget(0, &savedRt)) || + !savedRt || + FAILED(device->GetViewport(&savedViewport))) + { + if (savedRt) savedRt->Release(); + if (stateBlock) stateBlock->Release(); + return false; + } + const HRESULT depthHr = + device->GetDepthStencilSurface(&savedDepth); + const bool depthOk = + SUCCEEDED(depthHr) || depthHr == D3DERR_NOTFOUND; + if (!depthOk) + { + savedRt->Release(); + stateBlock->Release(); + return false; + } + + bool ok = true; + IDirect3DSurface9* eyeSurface[2]{ + BackBuffer, RightEyeSurface + }; + for (int eye = 0; eye < 2 && ok; ++eye) + { + IDirect3DSurface9* reduced = nullptr; + IDirect3DSurface9* temp = nullptr; + if (FAILED(R30SkyGlow.reduced[eye]->GetSurfaceLevel( + 0, &reduced)) || !reduced || + FAILED(R30SkyGlow.temp[eye]->GetSurfaceLevel( + 0, &temp)) || !temp) + { + if (temp) temp->Release(); + if (reduced) reduced->Release(); + ok = false; + break; + } + + if (R30SkyGlowSceneCaptureEpoch != PresentEpoch) + { + ok = SUCCEEDED(device->StretchRect( + eyeSurface[eye], nullptr, reduced, nullptr, + D3DTEXF_LINEAR)); + } + const float zero[4]{ 0, 0, 0, 0 }; + if (ok) + { + // Bright pass overwrites the entire reduced target, so no + // Clear is needed. Clearing here before rebinding the RT + // would clear the game's current eye backbuffer. + ok = R30DrawSkyGlowPass( + device, temp, + R30SkyGlow.glowWidth, + R30SkyGlow.glowHeight, + R30SkyGlow.reduced[eye], + R30SkyGlow.bright, zero, false); + } + + const float horizontal[4]{ + 1.0f / + static_cast(R30SkyGlow.glowWidth), + 0.0f, 0.0f, 0.0f + }; + if (ok) + ok = R30DrawSkyGlowPass( + device, reduced, + R30SkyGlow.glowWidth, + R30SkyGlow.glowHeight, + R30SkyGlow.temp[eye], + R30SkyGlow.blur, horizontal, false); + + // TwoStep exists to reduce aliasing after a downsample. + // At factor=1 there is no downsample, so a second full-resolution + // blur only doubles bandwidth. Also make sure the vertical pass, + // when requested for factor>1, is actually the composite source. + const bool effectiveTwoStep = + Settings::SkyGlowTwoStep.get() && + R30SkyGlow.factor > 1; + IDirect3DTexture9* compositeSource = + R30SkyGlow.temp[eye]; + if (effectiveTwoStep) + { + const float vertical[4]{ + 0.0f, + 1.0f / + static_cast(R30SkyGlow.glowHeight), + 0.0f, 0.0f + }; + if (ok) + ok = R30DrawSkyGlowPass( + device, temp, + R30SkyGlow.glowWidth, + R30SkyGlow.glowHeight, + R30SkyGlow.reduced[eye], + R30SkyGlow.blur, vertical, false); + if (ok) + compositeSource = R30SkyGlow.reduced[eye]; + } + + const float composite[4]{ 0.38f, 0, 0, 0 }; + if (ok) + ok = R30DrawSkyGlowPass( + device, eyeSurface[eye], + BackBufferDesc.Width, + BackBufferDesc.Height, + compositeSource, + R30SkyGlow.composite, composite, true); + + temp->Release(); + reduced->Release(); + } + + // Apply the captured pipeline state first, then explicitly restore + // RT/depth/viewport last. This guarantees the game bindings win even + // if a driver/state-block implementation restores more state than + // the code path historically relied on. + bool restoreOk = SUCCEEDED(stateBlock->Apply()); + restoreOk = + SUCCEEDED(device->SetRenderTarget(0, savedRt)) && + restoreOk; + const HRESULT restoreDepth = + device->SetDepthStencilSurface(savedDepth); + restoreOk = + (SUCCEEDED(restoreDepth) || + (!savedDepth && restoreDepth == D3D_OK)) && + restoreOk; + restoreOk = + SUCCEEDED(device->SetViewport(&savedViewport)) && + restoreOk; + savedRt->Release(); + if (savedDepth) savedDepth->Release(); + stateBlock->Release(); + + if (ok && restoreOk) + { + ++R30SkyGlowFrames; + if (!R30FirstSkyGlowLogged) + { + R30FirstSkyGlowLogged = true; + spdlog::info( + "VR SKY GLOW: independent L/R extract + stereo blur + additive composite ACTIVE factor={} requestedTwoStep={} effectiveTwoStep={} buffer={}x{}", + R30SkyGlow.factor, + Settings::SkyGlowTwoStep.get() ? 1 : 0, + (Settings::SkyGlowTwoStep.get() && R30SkyGlow.factor > 1) ? 1 : 0, + R30SkyGlow.glowWidth, + R30SkyGlow.glowHeight); + } + return true; + } + + ++R30SkyGlowFailures; + if (!R30FirstSkyGlowFailureLogged) + { + R30FirstSkyGlowFailureLogged = true; + spdlog::warn( + "VR SKY GLOW: stereo eye post-process failed; frame continues without enabling the stock mono glow chain"); + } + return false; + } + + ULONGLONG R30LastTelemetryMs = 0; + + void R30MaybeLogTelemetry() + { + if (!Settings::VRTelemetry) + return; + const ULONGLONG now = GetTickCount64(); + if (now - R30LastTelemetryMs < 5000) + return; + R30LastTelemetryMs = now; + spdlog::info( + "VR R41: bufferShadow[armed={},writes={},hits={},misses={},discardInvalid={},drawReadLocks=0] xyzrhw[world={},hud={},hudWorldLock={},rhwPromote={},rhwOnlyDepth={},zOnlyDepth={},atomicFallback={},depthPreserve={},bilateralFallback={}] screen[all={},hud2d={},perspectiveHud={},worldBillboard={}] r44[ownedWvp={},groupReuse={},spatial={},flat={}] skyGlow[frames={},failures={},factor={},buffer={}x{}]", + R30BufferShadowCaptureArmed.load(std::memory_order_acquire) ? 1 : 0, + R30ShadowWrites, R30ShadowReadHits, R30ShadowReadMisses, + R30ShadowDiscardInvalidations, + R30XyzrhwWorldEffectDraws, R30XyzrhwHudDraws, + R30XyzrhwWorldLockedHudDraws, + R30XyzrhwRhwWorldPromotions, + R30XyzrhwRhwOnlyDepthEvidence, + R30XyzrhwZOnlyDepthEvidence, + R30XyzrhwAtomicFallbacks, + R30XyzrhwDepthPreserveFallbacks, + R30XyzrhwBilateralFallbacks, + R30ScreenSpaceFovDraws, R30Hud2DDraws, + R30PerspectiveHudDraws, R30WorldBillboardDraws, + R44OverlayOwnedWvpHits, R44OverlayOwnedWvpGroupHits, + R44SpatialBillboardClassifications, + R44FlatOverlayClassifications, + R30SkyGlowFrames, R30SkyGlowFailures, + R30SkyGlow.factor, R30SkyGlow.glowWidth, + R30SkyGlow.glowHeight); + } + + HRESULT __stdcall PresentDestR30( + IDirect3DDevice9* device, const RECT* sourceRect, + const RECT* destRect, HWND destWindowOverride, + const RGNDATA* dirtyRegion) + { + R30MaybeLogTelemetry(); + if (Settings::SkyGlowFactor > 0 && + StereoWanted() && FrameHadWorldStereo && + FrameHadDuplicatedDraw && + !FrameRightDrawFailed && !FrameStereoIncomplete) + { + InternalPassScope guard; + R30ApplyStereoSkyGlow(device); + } + return R30PresentR29Hook.stdcall( + device, sourceRect, destRect, + destWindowOverride, dirtyRegion); + } + + HRESULT __stdcall ResetDestR30( + IDirect3DDevice9* device, + D3DPRESENT_PARAMETERS* params) + { + R30ReleaseSkyGlowResources(); + R30SkyGlowSceneCaptureEpoch = 0; + R30BufferShadowCaptureArmed.store( + false, std::memory_order_release); + return R30ResetR29Hook.stdcall(device, params); + } + + // User-adjustable projection-space HUD scale. The per-eye FOV affine + // remains automatic; this value is only a common-centre size trim after + // the headset-specific mapping. + float R30HudScaleValue() noexcept + { + return std::clamp(Settings::VRHudScale.get(), 0.30f, 1.20f); + } + + float R30HudAspectCompensation( + const OutRunVRRenderer::LatchedStereoFrame& stereo) noexcept + { + if (!BackBufferDesc.Width || !BackBufferDesc.Height) + return 1.0f; + + const float sourceAspect = + static_cast(BackBufferDesc.Width) / + static_cast(BackBufferDesc.Height); + + // R35.4: SharedPoseState already carries the runtime's actual + // recommended per-eye render-target size. That pixel aspect is the + // quantity that finally stretches a screen-space HUD sprite in the + // OpenXR projection swapchain. Using angular FOV aspect was close, + // but on Quest/VDXR it leaves circular HUD elements slightly tall. + float targetAspectSum = 0.0f; + int targetEyes = 0; + if (SharedState && + SharedState->magic == OutRunVR::SharedMagic && + SharedState->protocolVersion == + OutRunVR::SharedProtocolVersion) + { + for (int eye = 0; eye < 2; ++eye) + { + const std::uint32_t w = + SharedState->recommendedWidth[eye]; + const std::uint32_t h = + SharedState->recommendedHeight[eye]; + if (!w || !h) + continue; + const float aspect = + static_cast(w) / + static_cast(h); + if (!std::isfinite(aspect) || + aspect < 0.25f || aspect > 4.0f) + continue; + targetAspectSum += aspect; + ++targetEyes; + } + } + + // Fall back to the old angular estimate only if the host has not + // published swapchain dimensions yet (startup/recovery). + if (!targetEyes) + { + for (int eye = 0; eye < 2; ++eye) + { + const float left = + std::tan(stereo.eyeFov[eye].angleLeft); + const float right = + std::tan(stereo.eyeFov[eye].angleRight); + const float up = + std::tan(stereo.eyeFov[eye].angleUp); + const float down = + std::tan(stereo.eyeFov[eye].angleDown); + const float horizontal = right - left; + const float vertical = up - down; + if (!std::isfinite(horizontal) || + !std::isfinite(vertical) || + horizontal <= 0.05f || vertical <= 0.05f) + continue; + const float aspect = horizontal / vertical; + if (!std::isfinite(aspect) || + aspect < 0.25f || aspect > 4.0f) + continue; + targetAspectSum += aspect; + ++targetEyes; + } + } + + if (!targetEyes) + return 1.0f; + + const float targetAspect = + targetAspectSum / static_cast(targetEyes); + const float compensation = sourceAspect / targetAspect; + if (!std::isfinite(compensation)) + return 1.0f; + return std::clamp(compensation, 0.75f, 3.50f); + } + + void R30HudContainScale( + const OutRunVRRenderer::LatchedStereoFrame& stereo, + float& scaleX, float& scaleY) noexcept + { + (void)stereo; + const float userScale = R30HudScaleValue(); + + // R43: the finite HUD plane is reconstructed through the game's + // base projection and then through each OpenXR eye projection. + // Applying sourceAspect/eyeAspect again here was a second, + // anisotropic aspect correction. On this run sourceOverTarget=2.569, + // so HudScale=0.55 became X=0.55/Y=0.214 and visibly squashed the + // complete HUD. Keep HUD scale uniform; projection handles aspect. + scaleX = userScale; + scaleY = userScale; + } + + enum class R30ScreenSpaceKind : std::uint8_t + { + None, + Hud2D, + PerspectiveHud, + WorldBillboard + }; + + constexpr std::uint64_t R44OverlayWvpDrawWindow = 12u; + + bool R44GetOwnedRawOverlayWvp( + float outConstants[16], std::uint64_t* drawAge = nullptr) noexcept + { + if (!outConstants || + CurrentVertexShaderIdentity.load(std::memory_order_acquire) == 0) + return false; + + std::uint64_t writeSerial = 0; + std::uint64_t writeDrawSerial = 0; + std::uintptr_t writeShader = 0; + std::uint64_t writeShaderSerial = 0; + std::uintptr_t currentShader = 0; + std::uint64_t currentShaderSerial = 0; + if (!OutRunVRRenderer::GetLastRawGameWvpWrite( + outConstants, writeSerial, writeDrawSerial, + writeShader, writeShaderSerial) || + !GetCurrentShaderEpoch(currentShader, currentShaderSerial) || + writeShader != currentShader || + writeShaderSerial != currentShaderSerial) + return false; + + // R45: R30 is the outer draw hook and R23 increments its top-level + // serial only after this classifier calls down into R26/R23. R44 + // compared against R23GameDrawSerial directly, so the immediate + // draw after a c64 upload had age=0 and every owner test failed. + // At this boundary the draw being classified is serial + 1. + const std::uint64_t currentOuterDrawSerial = + R23GameDrawSerial + 1u; + if (currentOuterDrawSerial <= writeDrawSerial) + return false; + const std::uint64_t age = + currentOuterDrawSerial - writeDrawSerial; + if (age == 0 || age > R44OverlayWvpDrawWindow) + return false; + + ++R44OverlayOwnedWvpHits; + if (age > 1) + ++R44OverlayOwnedWvpGroupHits; + if (drawAge) + *drawAge = age; + return true; + } + + enum class R44OverlayMatrixKind : std::uint8_t + { + Unknown, + FlatHud, + SpatialBillboard + }; + + R44OverlayMatrixKind R44ClassifyOwnedOverlayMatrix( + float rawWvp[16]) noexcept + { + if (!rawWvp) + return R44OverlayMatrixKind::Unknown; + + float projectionRaw[16]{}; + if (!OutRunVRRenderer::GetRendererBaseProjection(projectionRaw)) + return R44OverlayMatrixKind::Unknown; + + D3DMATRIX uploadedT{}; + D3DMATRIX baseProjection{}; + D3DMATRIX inverseProjection{}; + std::memcpy(&uploadedT, rawWvp, sizeof(uploadedT)); + std::memcpy(&baseProjection, projectionRaw, sizeof(baseProjection)); + const D3DMATRIX stockWvp = TransposeMatrix(uploadedT); + if (!MatrixFinite(stockWvp) || !MatrixFinite(baseProjection) || + !InvertMatrix(baseProjection, inverseProjection)) + return R44OverlayMatrixKind::Unknown; + + const D3DMATRIX worldView = + MultiplyMatrix(stockWvp, inverseProjection); + if (!MatrixFinite(worldView)) + return R44OverlayMatrixKind::Unknown; + + const float affineError = + std::fabs(worldView._14) + std::fabs(worldView._24) + + std::fabs(worldView._34) + std::fabs(worldView._44 - 1.0f); + const float viewDepth = std::fabs(worldView._43); + const float clipW = + worldView._43 * baseProjection._34 + baseProjection._44; + if (!std::isfinite(affineError) || !std::isfinite(viewDepth) || + !std::isfinite(clipW) || clipW <= 0.05f) + return R44OverlayMatrixKind::Unknown; + + // R44: OutRun often uploads one c64 then emits several glyph/sprite + // draws with the same shader. A spatial vehicle marker remains an + // affine WorldView at real scene depth. Results/time-record text is + // a near/screen overlay and must converge on the HUD plane instead + // of falling through to R13 zero-disparity replay. + if (affineError <= 0.02f && + viewDepth >= 1.25f && viewDepth < 1000000.0f) + return R44OverlayMatrixKind::SpatialBillboard; + if (viewDepth < 1.25f || affineError > 0.02f) + return R44OverlayMatrixKind::FlatHud; + return R44OverlayMatrixKind::Unknown; + } + + R30ScreenSpaceKind R30ClassifyScreenSpacePass( + IDirect3DDevice9* device) noexcept + { + if (!device || !TargetIsBackBuffer()) + return R30ScreenSpaceKind::None; + + float projection[16]{}; + if (!OutRunVRRenderer::GetRendererBaseProjection(projection)) + return R30ScreenSpaceKind::None; + + const auto projectionClass = + OutRunVR::PassPolicy::ClassifyProjectionSignature( + projection[11], projection[15]); + if (projectionClass == + OutRunVR::PassPolicy::ProjectionClass::Orthographic2D) + return R30ScreenSpaceKind::Hud2D; + if (projectionClass != + OutRunVR::PassPolicy::ProjectionClass::Perspective3D) + return R30ScreenSpaceKind::None; + + // Verified/rebindable perspective WVP is already owned by the true + // world path and must never be intercepted as UI. + if (CurrentDrawMatchesVerifiedWorld(device)) + return R30ScreenSpaceKind::None; + std::uintptr_t reboundShader = 0; + std::uint64_t reboundSerial = 0; + if (R28CanRebindVerifiedWorld( + device, reboundShader, reboundSerial)) + return R30ScreenSpaceKind::None; + + DWORD zEnable = D3DZB_TRUE; + DWORD zWrite = TRUE; + DWORD alphaBlend = FALSE; + DWORD alphaTest = FALSE; + DWORD cullMode = D3DCULL_CCW; + if (!ReadTrackedRenderState(device, D3DRS_ZENABLE, zEnable) || + !ReadTrackedRenderState(device, D3DRS_ZWRITEENABLE, zWrite) || + !ReadTrackedRenderState( + device, D3DRS_ALPHABLENDENABLE, alphaBlend) || + !ReadTrackedRenderState( + device, D3DRS_ALPHATESTENABLE, alphaTest) || + !ReadTrackedRenderState(device, D3DRS_CULLMODE, cullMode)) + return R30ScreenSpaceKind::None; + + const bool alphaLike = + alphaBlend != FALSE || alphaTest != FALSE; + if (!alphaLike) + return R30ScreenSpaceKind::None; + + float rawOwnedWvp[16]{}; + const bool haveOwnedWvp = + R44GetOwnedRawOverlayWvp(rawOwnedWvp); + if (haveOwnedWvp) + { + const auto matrixKind = + R44ClassifyOwnedOverlayMatrix(rawOwnedWvp); + if (matrixKind == R44OverlayMatrixKind::SpatialBillboard) + { + ++R44SpatialBillboardClassifications; + return R30ScreenSpaceKind::WorldBillboard; + } + if (matrixKind == R44OverlayMatrixKind::FlatHud) + { + ++R44FlatOverlayClassifications; + return R30ScreenSpaceKind::PerspectiveHud; + } + } + + // Keep R13's fragile-effect shape as an explicit fallback signal. + // If the matrix owner is unavailable, depth-disabled/two-sided alpha + // is still a flat overlay. Routing it through the finite HUD plane + // prevents two visible zero-disparity copies for result text while + // preserving depth-tested unknown effects fail-closed. + const bool r13FlatEffectShape = + cullMode == D3DCULL_NONE && + zEnable == D3DZB_FALSE && + ((alphaBlend != FALSE && zWrite == FALSE) || + alphaTest != FALSE); + if (r13FlatEffectShape) + { + ++R44FlatOverlayClassifications; + return R30ScreenSpaceKind::PerspectiveHud; + } + + if (zEnable != D3DZB_FALSE) + return R30ScreenSpaceKind::None; + return R30ScreenSpaceKind::PerspectiveHud; + } + + + bool R30BuildEyeAffine( + const OutRunVRRenderer::LatchedStereoFrame& stereo, + float eyeScale[2], float eyeOffset[2]) noexcept + { + float tanLeft[2]{}; + float tanRight[2]{}; + float width[2]{}; + for (int eye = 0; eye < 2; ++eye) + { + tanLeft[eye] = std::tan(stereo.eyeFov[eye].angleLeft); + tanRight[eye] = std::tan(stereo.eyeFov[eye].angleRight); + width[eye] = tanRight[eye] - tanLeft[eye]; + if (!std::isfinite(tanLeft[eye]) || + !std::isfinite(tanRight[eye]) || + !std::isfinite(width[eye]) || width[eye] <= 0.05f) + return false; + } + + const float commonLeft = 0.5f * (tanLeft[0] + tanLeft[1]); + const float commonRight = 0.5f * (tanRight[0] + tanRight[1]); + const float commonWidth = commonRight - commonLeft; + const float commonSum = commonRight + commonLeft; + if (!std::isfinite(commonWidth) || commonWidth <= 0.05f) + return false; + + for (int eye = 0; eye < 2; ++eye) + { + const float eyeSum = tanRight[eye] + tanLeft[eye]; + const float scale = commonWidth / width[eye]; + const float offset = (commonSum - eyeSum) / width[eye]; + if (!std::isfinite(scale) || !std::isfinite(offset) || + scale < 0.50f || scale > 1.50f || + std::fabs(offset) > 0.50f) + return false; + eyeScale[eye] = scale; + eyeOffset[eye] = offset; + } + return true; + } + + struct R30XyzrhwState + { + OutRunVRRenderer::LatchedStereoFrame stereo{}; + D3DVIEWPORT9 viewport{}; + float eyeScale[2]{}; + float eyeOffset[2]{}; + float worldScaleX[2]{}; + float worldOffsetX[2]{}; + float worldScaleY[2]{}; + float worldOffsetY[2]{}; + float parallaxPerRhwX[2]{}; + float parallaxPerRhwY[2]{}; + D3DMATRIX baseProjection{}; + D3DMATRIX inverseBaseProjection{}; + D3DMATRIX eyeProjection[2]{}; + D3DMATRIX eyeInverse[2]{}; + D3DMATRIX hudViewProjection[2]{}; + float hudClipX[2][3]{}; + float hudClipY[2][3]{}; + float hudClipW[2][3]{}; + bool hudWorldLockValid = false; + bool fullWorldReprojection = false; + bool depthTestEnabled = false; + bool rhwDepthEvidence = false; + bool worldEffect = false; + }; + + bool R30PrepareXyzrhwState( + IDirect3DDevice9* device, R30XyzrhwState& state) noexcept + { + if (!R30SafeStereoBase(device) || + CurrentVertexShaderIdentity.load(std::memory_order_acquire) != 0) + return false; + + IDirect3DVertexShader9* shader = nullptr; + if (FAILED(device->GetVertexShader(&shader))) + return false; + if (shader) + { + shader->Release(); + return false; + } + + DWORD fvf = 0; + if (FAILED(device->GetFVF(&fvf)) || + (fvf & D3DFVF_POSITION_MASK) != D3DFVF_XYZRHW) + return false; + + if (!EnsureStereoResources(device) || + FAILED(device->GetViewport(&state.viewport)) || + state.viewport.Width == 0 || state.viewport.Height == 0 || + !std::isfinite(state.viewport.MinZ) || + !std::isfinite(state.viewport.MaxZ) || + state.viewport.MaxZ <= state.viewport.MinZ) + return false; + + DWORD zEnable = D3DZB_FALSE; + if (!ReadTrackedRenderState(device, D3DRS_ZENABLE, zEnable)) + return false; + state.depthTestEnabled = zEnable != D3DZB_FALSE; + + if (!OutRunVRRenderer::GetLatchedStereoFrame(state.stereo) || + state.stereo.poseSequence == 0 || + (FrameStereoPoseSequence != 0 && + FrameStereoPoseSequence != state.stereo.poseSequence) || + !R30BuildEyeAffine(state.stereo, + state.eyeScale, state.eyeOffset)) + return false; + + return true; + } + + bool R30XyzrhwHasProjectedDepthSignature( + const void* source, UINT vertexCount, UINT stride, + const R30XyzrhwState& state, const D3DMATRIX& projection, + const std::vector* usedMask) noexcept + { + if (!source || vertexCount == 0 || stride < sizeof(float) * 4) + return false; + + const float depthSpan = + state.viewport.MaxZ - state.viewport.MinZ; + if (!std::isfinite(depthSpan) || depthSpan <= 1.0e-6f || + std::fabs(projection._34) <= 1.0e-5f || + std::fabs(projection._13) > 1.0e-4f || + std::fabs(projection._23) > 1.0e-4f) + return false; + + UINT sampled = 0; + UINT valid = 0; + UINT projected = 0; + UINT nonUnitRhw = 0; + UINT plausibleRhwDepth = 0; + UINT plausibleScreenZDepth = 0; + for (UINT i = 0; i < vertexCount && sampled < 512u; ++i) + { + if (usedMask && + (i >= usedMask->size() || (*usedMask)[i] == 0)) + continue; + ++sampled; + const float* p = reinterpret_cast( + static_cast(source) + + static_cast(i) * stride); + const float z = p[2]; + const float rhw = p[3]; + if (!std::isfinite(z) || !std::isfinite(rhw) || + rhw <= 1.0e-6f || rhw >= 1000.0f) + continue; + + const float clipW = 1.0f / rhw; + const float viewZ = + (clipW - projection._44) / projection._34; + const float clipZ = + viewZ * projection._33 + projection._43; + const float expectedNdcZ = clipZ / clipW; + const float actualNdcZ = + (z - state.viewport.MinZ) / depthSpan; + if (!std::isfinite(expectedNdcZ) || + !std::isfinite(actualNdcZ)) + continue; + + ++valid; + + // RHW is often forced to 1.0 by OutRun's pretransformed + // particle/marker path. Screen-Z still contains the perspective + // depth. Solve the projection equation directly and accept + // interior Z only when it reconstructs to a real world distance. + const float zDenominator = + actualNdcZ * projection._34 - projection._33; + if (actualNdcZ > 1.0e-5f && + actualNdcZ < 0.99999f && + std::fabs(zDenominator) > 1.0e-6f) + { + const float zOnlyViewZ = + (projection._43 - + actualNdcZ * projection._44) / + zDenominator; + if (std::isfinite(zOnlyViewZ) && + std::fabs(zOnlyViewZ) >= 0.75f && + std::fabs(zOnlyViewZ) < 1000000.0f) + ++plausibleScreenZDepth; + } + + const bool carriesPerspectiveDepth = + std::fabs(rhw - 1.0f) > 0.02f; + if (carriesPerspectiveDepth) + { + ++nonUnitRhw; + // Some OutRun CPU-projected smoke/skid/decal vertices bias + // or clamp screen-Z after projection. RHW still preserves + // clip-W, so accept it as depth evidence when the implied + // view depth is finite and physically plausible. + if (std::isfinite(viewZ) && + std::fabs(viewZ) > 1.0e-4f && + std::fabs(viewZ) < 1.0e6f) + ++plausibleRhwDepth; + } + + // Strongest path: screen-Z and RHW both match the perspective + // projection. HUD glyphs with RHW=1 never satisfy this. + if (carriesPerspectiveDepth && + std::fabs(expectedNdcZ - actualNdcZ) <= 0.10f) + ++projected; + } + + const bool enoughEvidence = + sampled >= 3u && valid >= 3u && + valid * 2u >= sampled; + if (!enoughEvidence) + return false; + + const bool exactProjected = + projected * 4u >= valid * 3u; + const bool rhwDepthOnly = + nonUnitRhw * 4u >= valid * 3u && + plausibleRhwDepth * 4u >= nonUnitRhw * 3u; + const bool screenZDepthOnly = + plausibleScreenZDepth * 4u >= valid * 3u; + if (screenZDepthOnly && !exactProjected && !rhwDepthOnly) + { + ++R30XyzrhwZOnlyDepthEvidence; + if (!R30FirstXyzrhwRhwOnlyEvidenceLogged) + { + R30FirstXyzrhwRhwOnlyEvidenceLogged = true; + spdlog::info( + "VR R30.11 XYZRHW DEPTH: perspective screen-Z accepted as world depth even when RHW=1; near-plane HUD Z remains excluded"); + } + } + if (rhwDepthOnly && !exactProjected) + { + ++R30XyzrhwRhwOnlyDepthEvidence; + if (!R30FirstXyzrhwRhwOnlyEvidenceLogged) + { + R30FirstXyzrhwRhwOnlyEvidenceLogged = true; + spdlog::info( + "VR R30.10 XYZRHW DEPTH: stable non-unit RHW accepted as world depth when screen-Z is post-projection biased; RHW=1 HUD remains excluded"); + } + } + return exactProjected || rhwDepthOnly || screenZDepthOnly; + } + + bool R30XyzrhwLooksLikeHudPlane( + const void* source, UINT vertexCount, UINT stride, + const R30XyzrhwState& state, + const std::vector* usedMask) noexcept + { + if (!source || vertexCount == 0 || stride < sizeof(float) * 4) + return false; + const float depthSpan = state.viewport.MaxZ - state.viewport.MinZ; + if (!std::isfinite(depthSpan) || depthSpan <= 1.0e-6f) + return false; + UINT sampled = 0, valid = 0, unitRhw = 0; + float minZ = 1.0f, maxZ = 0.0f; + for (UINT i = 0; i < vertexCount && sampled < 512u; ++i) + { + if (usedMask && + (i >= usedMask->size() || (*usedMask)[i] == 0)) + continue; + ++sampled; + const float* p = reinterpret_cast( + static_cast(source) + + static_cast(i) * stride); + const float z = p[2], rhw = p[3]; + if (!std::isfinite(z) || !std::isfinite(rhw)) + continue; + const float ndcZ = (z - state.viewport.MinZ) / depthSpan; + if (!std::isfinite(ndcZ) || ndcZ < -0.01f || ndcZ > 1.01f) + continue; + ++valid; + if (std::fabs(rhw - 1.0f) <= 0.02f) ++unitRhw; + minZ = std::min(minZ, ndcZ); + maxZ = std::max(maxZ, ndcZ); + } + return sampled >= 3u && valid >= 3u && + valid * 2u >= sampled && + unitRhw * 4u >= valid * 3u && + std::isfinite(minZ) && std::isfinite(maxZ) && + maxZ - minZ <= 0.0025f; + } + + bool R30BuildHudPlaneCoefficients(R30XyzrhwState& state) noexcept + { + constexpr float HudPlaneViewZ = -2.50f; + float scaleX = 1.0f, scaleY = 1.0f; + R30HudContainScale(state.stereo, scaleX, scaleY); + const D3DMATRIX& bp = state.baseProjection; + const float clipW = HudPlaneViewZ * bp._34 + bp._44; + const float cx = HudPlaneViewZ * bp._31 + bp._41; + const float cy = HudPlaneViewZ * bp._32 + bp._42; + const float det = bp._11 * bp._22 - bp._21 * bp._12; + if (!std::isfinite(clipW) || !std::isfinite(cx) || + !std::isfinite(cy) || !std::isfinite(det) || + std::fabs(clipW) <= 1.0e-6f || + std::fabs(det) <= 1.0e-6f) + return false; + + const float vxX = bp._22 * clipW * scaleX / det; + const float vxY = -bp._21 * clipW * scaleY / det; + const float vxC = (-cx * bp._22 + bp._21 * cy) / det; + const float vyX = -bp._12 * clipW * scaleX / det; + const float vyY = bp._11 * clipW * scaleY / det; + const float vyC = (-bp._11 * cy + bp._12 * cx) / det; + + for (int eye = 0; eye < 2; ++eye) + { + const auto& m = state.hudViewProjection[eye]; + auto build = [&](int col, float out[3]) noexcept { + out[0] = vxX * m.m[0][col] + vyX * m.m[1][col]; + out[1] = vxY * m.m[0][col] + vyY * m.m[1][col]; + out[2] = vxC * m.m[0][col] + vyC * m.m[1][col] + + HudPlaneViewZ * m.m[2][col] + m.m[3][col]; + }; + build(0, state.hudClipX[eye]); + build(1, state.hudClipY[eye]); + build(3, state.hudClipW[eye]); + for (int i = 0; i < 3; ++i) + if (!std::isfinite(state.hudClipX[eye][i]) || + !std::isfinite(state.hudClipY[eye][i]) || + !std::isfinite(state.hudClipW[eye][i])) + return false; + } + return true; + } + + bool R30ConfigureXyzrhwWorldEffect( + IDirect3DDevice9* device, const void* source, + UINT vertexCount, UINT stride, R30XyzrhwState& state, + const std::vector* usedMask = nullptr) noexcept + { + float projectionRaw[16]{}; + D3DMATRIX baseProjection{}; + const bool haveBaseProjection = + OutRunVRRenderer::GetRendererBaseProjection(projectionRaw); + if (haveBaseProjection) + std::memcpy(&baseProjection, projectionRaw, sizeof(baseProjection)); + + state.rhwDepthEvidence = + haveBaseProjection && + R30XyzrhwHasProjectedDepthSignature( + source, vertexCount, stride, state, + baseProjection, usedMask); + + // R42: depth signature is authoritative world evidence. ZENABLE by + // itself is not: several HUD passes leave Z enabled. When depth + // evidence is absent, admit only a strongly pre-transformed screen + // plane (RHW~=1 with near-constant Z) to the HUD world-lock. + state.worldEffect = state.rhwDepthEvidence; + if (state.depthTestEnabled && !state.worldEffect && + !R30XyzrhwLooksLikeHudPlane( + source, vertexCount, stride, state, usedMask)) + return false; + if (!state.worldEffect) + { + // R41: most of OutRun's HUD is fixed-function XYZRHW, not the + // shader/c64 path. Build a synthetic finite HUD plane in the + // recentered gameplay space, then transform that plane by the + // current head inverse and relative-eye IPD/FOV. Preserve the + // original screen Z/RHW later so only placement is changed. + if (!haveBaseProjection || !MatrixFinite(baseProjection)) + return false; + state.baseProjection = baseProjection; + + float headRaw[16]{}; + std::uint32_t headPoseSequence = 0; + D3DMATRIX headInverse{}; + if (!OutRunVRRenderer::GetLatchedHeadInverse( + headRaw, headPoseSequence) || + headPoseSequence != state.stereo.poseSequence) + return false; + std::memcpy(&headInverse, headRaw, sizeof(headInverse)); + if (!MatrixFinite(headInverse)) + return false; + + const float centerEye[3]{ + 0.5f * (state.stereo.eyeOffset[0][0] + + state.stereo.eyeOffset[1][0]), + 0.5f * (state.stereo.eyeOffset[0][1] + + state.stereo.eyeOffset[1][1]), + 0.5f * (state.stereo.eyeOffset[0][2] + + state.stereo.eyeOffset[1][2]) + }; + const float identityOrientation[4]{ + 0.0f, 0.0f, 0.0f, 1.0f + }; + for (int eye = 0; eye < 2; ++eye) + { + const float relativeEye[3]{ + state.stereo.eyeOffset[eye][0] - centerEye[0], + state.stereo.eyeOffset[eye][1] - centerEye[1], + state.stereo.eyeOffset[eye][2] - centerEye[2] + }; + const D3DMATRIX eyePose = + MatrixFromQuaternionTranslation( + identityOrientation, relativeEye, + Settings::VRWorldScale * + Settings::VRStereoDepth); + const D3DMATRIX eyeInverse = InverseRigid(eyePose); + const D3DMATRIX eyeProjection = + ProjectionFromFov( + baseProjection, + state.stereo.eyeFov[eye]); + state.hudViewProjection[eye] = + MultiplyMatrix( + MultiplyMatrix(headInverse, eyeInverse), + eyeProjection); + if (!MatrixFinite(state.hudViewProjection[eye])) + return false; + } + if (!R30BuildHudPlaneCoefficients(state)) + return false; + state.hudWorldLockValid = true; + return true; + } + + if (TrackedDepthStencil && + (!RightDepthSynchronized || !RightStencilSynchronized)) + TryBootstrapRightDepthFromRecentClear(device); + if (TrackedDepthStencil && + !RightDepthSynchronized && DepthTestActive(device)) + return false; + if (TrackedDepthStencil && + !RightStencilSynchronized && StencilTestActive(device)) + return false; + + if (!haveBaseProjection) + return false; + state.baseProjection = baseProjection; + if (!MatrixFinite(baseProjection) || + std::fabs(baseProjection._11) < 0.01f || + std::fabs(baseProjection._22) < 0.01f || + std::fabs(baseProjection._34) < 0.25f || + !InvertMatrix(baseProjection, state.inverseBaseProjection) || + !MatrixFinite(state.inverseBaseProjection)) + return false; + state.fullWorldReprojection = true; + + const float center[3]{ + 0.5f * (state.stereo.eyeOffset[0][0] + + state.stereo.eyeOffset[1][0]), + 0.5f * (state.stereo.eyeOffset[0][1] + + state.stereo.eyeOffset[1][1]), + 0.5f * (state.stereo.eyeOffset[0][2] + + state.stereo.eyeOffset[1][2]) + }; + const float zero[3]{}; + + for (int eye = 0; eye < 2; ++eye) + { + // Use the exact same per-eye rigid transform convention as the + // normal fixed-function/world path: view * eyeInverse * + // eyeProjection. The reconstructed CPU-projected point is + // already in game view space, so only eyeInverse is applied + // here; common head/camera motion must not be applied twice. + // CPU-projected XYZRHW effects were generated from OutRun's + // already head-synchronised camera. Reapplying the absolute HMD + // eye orientation here rotates them twice and shifts smoke/skid + // away from the road. Apply only the eye pose relative to the + // common head centre; the asymmetric eye projection still owns + // the final FOV. + const float relativeEye[3]{ + state.stereo.eyeOffset[eye][0] - center[0], + state.stereo.eyeOffset[eye][1] - center[1], + state.stereo.eyeOffset[eye][2] - center[2] + }; + const float identityOrientation[4]{ + 0.0f, 0.0f, 0.0f, 1.0f + }; + const D3DMATRIX eyePose = + MatrixFromQuaternionTranslation( + identityOrientation, relativeEye, + Settings::VRWorldScale * Settings::VRStereoDepth); + state.eyeInverse[eye] = InverseRigid(eyePose); + state.eyeProjection[eye] = + ProjectionFromFov(baseProjection, + state.stereo.eyeFov[eye]); + + // Preserve a conservative affine fallback for vertices whose + // clip reconstruction is unsafe. Its parallax uses the old + // centered IPD estimate, but both eyes are forced to choose the + // same fallback mode by R30TransformXyzrhwStereo(). + const float rel[3]{ + state.stereo.eyeOffset[eye][0] - center[0], + state.stereo.eyeOffset[eye][1] - center[1], + state.stereo.eyeOffset[eye][2] - center[2] + }; + // The source camera already owns common head rotation, so the + // conservative affine fallback also uses head-relative IPD only. + const float localX = rel[0]; + const float localY = rel[1]; + + const D3DMATRIX& eyeProjection = state.eyeProjection[eye]; + state.worldScaleX[eye] = + eyeProjection._11 / baseProjection._11; + state.worldOffsetX[eye] = + (eyeProjection._31 - + baseProjection._31 * state.worldScaleX[eye]) / + baseProjection._34; + state.worldScaleY[eye] = + eyeProjection._22 / baseProjection._22; + state.worldOffsetY[eye] = + (eyeProjection._32 - + baseProjection._32 * state.worldScaleY[eye]) / + baseProjection._34; + state.parallaxPerRhwX[eye] = + -localX * Settings::VRWorldScale * Settings::VRStereoDepth * + eyeProjection._11; + state.parallaxPerRhwY[eye] = + -localY * Settings::VRWorldScale * Settings::VRStereoDepth * + eyeProjection._22; + + if (!std::isfinite(state.worldScaleX[eye]) || + !std::isfinite(state.worldOffsetX[eye]) || + !std::isfinite(state.worldScaleY[eye]) || + !std::isfinite(state.worldOffsetY[eye]) || + !std::isfinite(state.parallaxPerRhwX[eye]) || + !std::isfinite(state.parallaxPerRhwY[eye]) || + !MatrixFinite(state.eyeProjection[eye]) || + !MatrixFinite(state.eyeInverse[eye]) || + state.worldScaleX[eye] < 0.20f || + state.worldScaleX[eye] > 5.0f || + state.worldScaleY[eye] < 0.20f || + state.worldScaleY[eye] > 5.0f) + return false; + } + + if (state.rhwDepthEvidence && !state.depthTestEnabled) + { + ++R30XyzrhwRhwWorldPromotions; + if (!R30FirstXyzrhwRhwPromotionLogged) + { + R30FirstXyzrhwRhwPromotionLogged = true; + spdlog::info( + "VR R30.6 XYZRHW WORLD: referenced RHW/depth signature promoted a depth-disabled particle/decal; normal-world eyeInverse/projection transform is shared"); + } + } + return true; + } + + UINT R30PrimitiveElementCount( + D3DPRIMITIVETYPE type, UINT primitiveCount) noexcept + { + switch (type) + { + case D3DPT_POINTLIST: return primitiveCount; + case D3DPT_LINELIST: return primitiveCount * 2u; + case D3DPT_LINESTRIP: return primitiveCount + 1u; + case D3DPT_TRIANGLELIST: return primitiveCount * 3u; + case D3DPT_TRIANGLESTRIP: + case D3DPT_TRIANGLEFAN: return primitiveCount + 2u; + default: return 0; + } + } + + bool R30TransformXyzrhwVertices( + const void* source, UINT vertexCount, UINT stride, + const R30XyzrhwState& state, int eye, + std::vector& out, + const std::vector* usedMask, + bool allowFullReprojection, bool& usedFullReprojection) noexcept + { + usedFullReprojection = false; + if (!source || vertexCount == 0 || stride < sizeof(float) * 4 || + eye < 0 || eye > 1) + return false; + const std::size_t bytes = + static_cast(vertexCount) * stride; + if (bytes == 0 || bytes > 4u * 1024u * 1024u) + return false; + if (usedMask && usedMask->size() < vertexCount) + return false; + + try + { + out.resize(bytes); + } + catch (...) + { + return false; + } + std::memcpy(out.data(), source, bytes); + + const float x0 = static_cast(state.viewport.X); + const float y0 = static_cast(state.viewport.Y); + const float width = static_cast(state.viewport.Width); + const float height = static_cast(state.viewport.Height); + const float minZ = state.viewport.MinZ; + const float maxZ = state.viewport.MaxZ; + const float depthSpan = maxZ - minZ; + if (width <= 0.0f || height <= 0.0f || + !std::isfinite(depthSpan) || depthSpan <= 1.0e-6f) + return false; + + auto selected = [&](UINT i) noexcept + { + return !usedMask || (*usedMask)[i] != 0; + }; + auto sourceVertex = [&](UINT i) noexcept -> const float* + { + return reinterpret_cast( + static_cast(source) + + static_cast(i) * stride); + }; + auto outputVertex = [&](UINT i) noexcept -> float* + { + return reinterpret_cast( + out.data() + static_cast(i) * stride); + }; + + if (state.worldEffect && state.fullWorldReprojection && + allowFullReprojection) + { + bool fullDrawOk = true; + UINT selectedVertices = 0; + for (UINT i = 0; i < vertexCount; ++i) + { + if (!selected(i)) + continue; + ++selectedVertices; + + const float* src = sourceVertex(i); + float* dst = outputVertex(i); + const float x = src[0]; + const float y = src[1]; + const float screenZ = src[2]; + const float rhw = src[3]; + if (!std::isfinite(x) || !std::isfinite(y) || + !std::isfinite(screenZ) || !std::isfinite(rhw) || + rhw <= 1.0e-6f || rhw >= 1000.0f) + { + fullDrawOk = false; + break; + } + + const float ndcX = ((x - x0) / width) * 2.0f - 1.0f; + const float ndcY = + 1.0f - ((y - y0) / height) * 2.0f; + const float clipW = 1.0f / rhw; + const float clipX = ndcX * clipW; + const float clipY = ndcY * clipW; + + float restoredView[4]{}; + bool restored = false; + + // Pre-transformed D3D9 particles carry RHW = 1/clipW. + // For the normal OutRun perspective matrix, clipW gives + // view-Z directly and X/Y can then be solved from clip X/Y. + // This avoids depending on the particle's biased/clamped + // screen Z, which is exactly what caused smoke/decal draws + // to fall back to the approximate affine path. + const D3DMATRIX& bp = state.baseProjection; + if (std::fabs(bp._14) <= 1.0e-5f && + std::fabs(bp._24) <= 1.0e-5f && + std::fabs(bp._34) > 1.0e-5f) + { + const float viewZ = + (clipW - bp._44) / bp._34; + const float rhsX = + clipX - viewZ * bp._31 - bp._41; + const float rhsY = + clipY - viewZ * bp._32 - bp._42; + const float det = + bp._11 * bp._22 - bp._21 * bp._12; + if (std::isfinite(viewZ) && + std::isfinite(rhsX) && + std::isfinite(rhsY) && + std::isfinite(det) && + std::fabs(det) > 1.0e-6f) + { + const float viewX = + (rhsX * bp._22 - + bp._21 * rhsY) / det; + const float viewY = + (bp._11 * rhsY - + rhsX * bp._12) / det; + if (std::isfinite(viewX) && + std::isfinite(viewY)) + { + restoredView[0] = viewX; + restoredView[1] = viewY; + restoredView[2] = viewZ; + restoredView[3] = 1.0f; + restored = true; + } + } + } + + // Compatibility fallback for an unusual projection shape: + // retain the previous inverse-projection reconstruction. + if (!restored) + { + const float ndcZ = + (screenZ - minZ) / depthSpan; + if (!std::isfinite(ndcZ) || + ndcZ < -0.25f || ndcZ > 1.25f) + { + fullDrawOk = false; + break; + } + const float clip[4]{ + clipX, clipY, ndcZ * clipW, clipW + }; + float restoredViewH[4]{}; + for (int col = 0; col < 4; ++col) + for (int row = 0; row < 4; ++row) + restoredViewH[col] += clip[row] * + state.inverseBaseProjection.m[row][col]; + + if (!std::isfinite(restoredViewH[0]) || + !std::isfinite(restoredViewH[1]) || + !std::isfinite(restoredViewH[2]) || + !std::isfinite(restoredViewH[3]) || + std::fabs(restoredViewH[3]) <= 1.0e-6f) + { + fullDrawOk = false; + break; + } + + const float invRestoredW = + 1.0f / restoredViewH[3]; + restoredView[0] = + restoredViewH[0] * invRestoredW; + restoredView[1] = + restoredViewH[1] * invRestoredW; + restoredView[2] = + restoredViewH[2] * invRestoredW; + restoredView[3] = 1.0f; + } + + // Match the normal world path exactly: the reconstructed + // game-view point receives the same rigid eyeInverse that + // BuildEyeConstants/fixed-function world draws use. + float eyeView[4]{}; + for (int col = 0; col < 4; ++col) + for (int row = 0; row < 4; ++row) + eyeView[col] += restoredView[row] * + state.eyeInverse[eye].m[row][col]; + + float clipEye[4]{}; + for (int col = 0; col < 4; ++col) + for (int row = 0; row < 4; ++row) + clipEye[col] += eyeView[row] * + state.eyeProjection[eye].m[row][col]; + + // D3D9 perspective clip-W must stay in front of the eye. + // abs(W) would accept a point behind the camera and mirror + // it back into the visible image. + if (!std::isfinite(clipEye[0]) || + !std::isfinite(clipEye[1]) || + !std::isfinite(clipEye[2]) || + !std::isfinite(clipEye[3]) || + clipEye[3] <= 1.0e-6f) + { + fullDrawOk = false; + break; + } + + const float invEyeW = 1.0f / clipEye[3]; + const float fullX = clipEye[0] * invEyeW; + const float fullY = clipEye[1] * invEyeW; + const float fullZ = clipEye[2] * invEyeW; + if (!std::isfinite(fullX) || + !std::isfinite(fullY) || + !std::isfinite(fullZ)) + { + fullDrawOk = false; + break; + } + + const float transformedX = + x0 + (fullX + 1.0f) * 0.5f * width; + const float transformedY = + y0 + (1.0f - fullY) * 0.5f * height; + + // Decals/particles can intentionally bias screen Z beyond + // the strict viewport interval. Preserve the game's Z in + // that case, while still using the accurately reprojected + // eye X/Y and RHW. Falling the whole quad back to an affine + // shift was the visible left/right smoke/skid offset. + float transformedZ = screenZ; + if (fullZ >= 0.0f && fullZ <= 1.0f) + { + transformedZ = minZ + fullZ * depthSpan; + } + else + { + ++R30XyzrhwDepthPreserveFallbacks; + } + if (!std::isfinite(transformedX) || + !std::isfinite(transformedY) || + !std::isfinite(transformedZ)) + { + fullDrawOk = false; + break; + } + + dst[0] = transformedX; + dst[1] = transformedY; + dst[2] = transformedZ; + dst[3] = invEyeW; + } + + if (fullDrawOk && selectedVertices != 0) + { + usedFullReprojection = true; + return true; + } + + ++R30XyzrhwAtomicFallbacks; + if (!R30FirstXyzrhwAtomicFallbackLogged) + { + R30FirstXyzrhwAtomicFallbackLogged = true; + spdlog::info( + "VR R30.6 XYZRHW WORLD: full reprojection rejected for a referenced vertex; this eye candidate is discarded before bilateral fallback selection"); + } + std::memcpy(out.data(), source, bytes); + } + + for (UINT i = 0; i < vertexCount; ++i) + { + if (!selected(i)) + continue; + + float* p = outputVertex(i); + const float x = p[0]; + const float y = p[1]; + const float z = p[2]; + const float rhw = p[3]; + if (!std::isfinite(x) || !std::isfinite(y) || + !std::isfinite(z) || !std::isfinite(rhw)) + return false; + + const float ndcX = ((x - x0) / width) * 2.0f - 1.0f; + const float ndcY = + 1.0f - ((y - y0) / height) * 2.0f; + + float correctedX = 0.0f; + float correctedY = 0.0f; + if (state.worldEffect) + { + correctedX = + state.worldScaleX[eye] * ndcX + + state.worldOffsetX[eye]; + correctedY = + state.worldScaleY[eye] * ndcY + + state.worldOffsetY[eye]; + if (rhw > 0.0f && rhw < 1000.0f) + { + correctedX += + state.parallaxPerRhwX[eye] * rhw; + correctedY += + state.parallaxPerRhwY[eye] * rhw; + } + // Fallback keeps the game's original Z/RHW pair intact. + } + else + { + // R41: the real OutRun HUD is overwhelmingly XYZRHW. Put + // those pre-transformed vertices on the same finite, + // recentered plane used by the shader HUD instead of + // copying screen coordinates into both eyes (head-lock). + if (!state.hudWorldLockValid) + return false; + + const float clipX = + state.hudClipX[eye][0] * ndcX + + state.hudClipX[eye][1] * ndcY + + state.hudClipX[eye][2]; + const float clipY = + state.hudClipY[eye][0] * ndcX + + state.hudClipY[eye][1] * ndcY + + state.hudClipY[eye][2]; + const float clipW = + state.hudClipW[eye][0] * ndcX + + state.hudClipW[eye][1] * ndcY + + state.hudClipW[eye][2]; + if (!std::isfinite(clipX) || + !std::isfinite(clipY) || + !std::isfinite(clipW) || + clipW <= 1.0e-6f) + return false; + correctedX = clipX / clipW; + correctedY = clipY / clipW; + if (!std::isfinite(correctedX) || + !std::isfinite(correctedY)) + return false; + } + + const float transformedX = + x0 + (correctedX + 1.0f) * 0.5f * width; + const float transformedY = + y0 + (1.0f - correctedY) * 0.5f * height; + if (!std::isfinite(transformedX) || + !std::isfinite(transformedY)) + return false; + p[0] = transformedX; + p[1] = transformedY; + } + return true; + } + + bool R30TransformXyzrhwStereo( + const void* source, UINT vertexCount, UINT stride, + const R30XyzrhwState& state, + std::vector& left, + std::vector& right, + const std::vector* usedMask = nullptr) noexcept + { + bool leftFull = false; + bool rightFull = false; + if (!R30TransformXyzrhwVertices(source, vertexCount, stride, + state, 0, left, usedMask, true, leftFull) || + !R30TransformXyzrhwVertices(source, vertexCount, stride, + state, 1, right, usedMask, true, rightFull)) + return false; + + if (leftFull != rightFull) + { + ++R30XyzrhwBilateralFallbacks; + if (!R30FirstXyzrhwBilateralFallbackLogged) + { + R30FirstXyzrhwBilateralFallbackLogged = true; + spdlog::info( + "VR R30.6 XYZRHW WORLD: one eye rejected full reprojection; both eyes are forced onto the same affine fallback policy"); + } + bool ignored = false; + if (!R30TransformXyzrhwVertices(source, vertexCount, stride, + state, 0, left, usedMask, false, ignored) || + !R30TransformXyzrhwVertices(source, vertexCount, stride, + state, 1, right, usedMask, false, ignored)) + return false; + } + return true; + } + + template + HRESULT R30ExecuteXyzrhwStereo( + IDirect3DDevice9* device, const R30XyzrhwState& state, + LeftDraw&& leftDraw, RightDraw&& rightDraw, + const char* site) + { + if (!state.worldEffect) + R30CaptureSkyGlowSceneBeforeHud(device); + + ++R9DrawCalls; + R9MonoBackupGap = true; + if (LeftDrawMayWriteDepth(device) || + LeftDrawMayWriteStencil(device)) + ++R9MainDepthContentSerial; + + const HRESULT leftHr = leftDraw(); + if (FAILED(leftHr)) + { + R9Poison(OutRunVR::StereoFailureLeftDrawFailed, + site, leftHr); + R30ArmSafeFallback(); + return leftHr; + } + + IDirect3DSurface9* savedRt = TrackedRenderTarget; + IDirect3DSurface9* savedDepth = TrackedDepthStencil; + HRESULT rightHr = D3D_OK; + OutRunVR::StereoFailureReason rightFailure = + OutRunVR::StereoFailureRightStateFailed; + bool restoreOk = true; + { + InternalPassScope guard; + rightHr = SetRenderTargetHook.stdcall( + device, 0u, RightEyeSurface); + if (SUCCEEDED(rightHr)) + rightHr = SetDepthStencilSurfaceHook.stdcall( + device, TrackedDepthStencil ? RightEyeDepth : nullptr); + if (SUCCEEDED(rightHr)) + rightHr = device->SetViewport(&state.viewport); + if (SUCCEEDED(rightHr)) + { + rightFailure = + OutRunVR::StereoFailureRightDrawFailed; + rightHr = rightDraw(); + } + restoreOk = RestoreRightPassState(device, + savedRt, savedDepth, state.viewport, nullptr, false); + } + + FrameHadDuplicatedDraw = true; + ++DuplicatedDraws; + ++R30SafeTwoEyeDraws; + if (state.worldEffect) + { + ++R30XyzrhwWorldEffectDraws; + if (FrameStereoPoseSequence == 0) + { + FrameStereoPoseSequence = state.stereo.poseSequence; + FrameStereoMetadata = state.stereo; + } + else if (FrameStereoPoseSequence != state.stereo.poseSequence) + { + FrameRightDrawFailed = true; + PoisonFrame(OutRunVR::StereoFailurePoseSequenceMismatch); + } + FrameHadWorldStereo = true; + ++WorldStereoDraws; + if (!R30FirstXyzrhwWorldLogged) + { + R30FirstXyzrhwWorldLogged = true; + spdlog::info( + "VR R30.4 XYZRHW WORLD: pre-transformed particle/billboard/decal draws reconstruct clip X/Y/Z/RHW, unproject through the game projection, then reproject into each OpenXR eye; affine RHW fallback retained for unsafe vertices"); + } + } + else + { + ++NonWorldDuplicatedDraws; + ++R30XyzrhwHudDraws; + if (state.hudWorldLockValid) + ++R30XyzrhwWorldLockedHudDraws; + if (!R30FirstXyzrhwHudLogged) + { + R30FirstXyzrhwHudLogged = true; + spdlog::info( + "VR R41 XYZRHW HUD: fixed-function HUD uses HudScale on a finite recentered world-locked plane; R42 Z-enabled screen-plane HUD is safely admitted and uses cached planar coefficients instead of per-vertex 4x4 transforms"); + } + } + + if (FAILED(rightHr)) + { + FrameRightDrawFailed = true; + InvalidateRightDepthStencilIfLeftMayWrite(device); + R9Poison(rightFailure, site, rightHr); + R30ArmSafeFallback(); + } + if (!restoreOk) + { + InvalidateRightDepthStencilIfLeftMayWrite(device); + NoteRestoreFailure("R30.2 XYZRHW right-eye draw"); + R30ArmSafeFallback(); + } + return leftHr; + } + + HRESULT R30TryXyzrhwPrimitiveUP( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + UINT primitiveCount, const void* data, UINT stride) + { + R30XyzrhwState state{}; + if (!R30PrepareXyzrhwState(device, state)) + return E_NOTIMPL; + const UINT vertexCount = + R30PrimitiveElementCount(type, primitiveCount); + if (!vertexCount || vertexCount > 262144u) + return E_NOTIMPL; + if (!R30ConfigureXyzrhwWorldEffect( + device, data, vertexCount, stride, state)) + { + ++R30XyzrhwFallbacks; + return E_NOTIMPL; + } + + R30ScratchLease lease; + if (!lease) + return E_NOTIMPL; + auto& scratch = *lease.buffers; + if (!R30TransformXyzrhwStereo( + data, vertexCount, stride, state, + scratch.left, scratch.right)) + { + ++R30XyzrhwFallbacks; + return E_NOTIMPL; + } + + auto leftDraw = [&]() { + return DrawPrimitiveUPHook.stdcall( + device, type, primitiveCount, + scratch.left.data(), stride); + }; + auto rightDraw = [&]() { + return DrawPrimitiveUPHook.stdcall( + device, type, primitiveCount, + scratch.right.data(), stride); + }; + return R30ExecuteXyzrhwStereo( + device, state, leftDraw, rightDraw, + "R30.6/DrawPrimitiveUP-XYZRHW"); + } + + HRESULT R30TryXyzrhwIndexedPrimitiveUP( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + UINT minVertexIndex, UINT numVertices, UINT primitiveCount, + const void* indexData, D3DFORMAT indexFormat, + const void* vertexData, UINT stride) + { + R30XyzrhwState state{}; + if (!R30PrepareXyzrhwState(device, state)) + return E_NOTIMPL; + + const UINT indexCount = + R30PrimitiveElementCount(type, primitiveCount); + if (!indexData || !vertexData || indexCount == 0 || + (indexFormat != D3DFMT_INDEX16 && + indexFormat != D3DFMT_INDEX32)) + return E_NOTIMPL; + + UINT maxIndex = 0; + if (indexFormat == D3DFMT_INDEX16) + { + const auto* indices = + static_cast(indexData); + for (UINT i = 0; i < indexCount; ++i) + maxIndex = std::max(maxIndex, indices[i]); + } + else + { + const auto* indices = + static_cast(indexData); + for (UINT i = 0; i < indexCount; ++i) + maxIndex = std::max(maxIndex, indices[i]); + } + + const UINT vertexCount = + std::max(minVertexIndex + numVertices, + maxIndex + 1u); + if (vertexCount == 0 || vertexCount > 262144u) + return E_NOTIMPL; + + R30ScratchLease lease; + if (!lease) + return E_NOTIMPL; + auto& scratch = *lease.buffers; + try + { + scratch.used.assign(vertexCount, 0); + } + catch (...) + { + return E_NOTIMPL; + } + for (UINT i = 0; i < indexCount; ++i) + { + const std::uint32_t raw = + indexFormat == D3DFMT_INDEX16 + ? static_cast(indexData)[i] + : static_cast(indexData)[i]; + if (raw >= vertexCount) + return E_NOTIMPL; + scratch.used[raw] = 1; + } + + if (!R30ConfigureXyzrhwWorldEffect( + device, vertexData, vertexCount, stride, state, + &scratch.used)) + { + ++R30XyzrhwFallbacks; + return E_NOTIMPL; + } + + if (!R30TransformXyzrhwStereo( + vertexData, vertexCount, stride, state, + scratch.left, scratch.right, &scratch.used)) + { + ++R30XyzrhwFallbacks; + return E_NOTIMPL; + } + + auto leftDraw = [&]() { + return DrawIndexedPrimitiveUPHook.stdcall( + device, type, minVertexIndex, numVertices, + primitiveCount, indexData, indexFormat, + scratch.left.data(), stride); + }; + auto rightDraw = [&]() { + return DrawIndexedPrimitiveUPHook.stdcall( + device, type, minVertexIndex, numVertices, + primitiveCount, indexData, indexFormat, + scratch.right.data(), stride); + }; + return R30ExecuteXyzrhwStereo( + device, state, leftDraw, rightDraw, + "R30.6/DrawIndexedPrimitiveUP-XYZRHW"); + } + + + HRESULT R30TryXyzrhwPrimitiveVB( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + UINT startVertex, UINT primitiveCount) + { + R30XyzrhwState state{}; + if (!R30PrepareXyzrhwState(device, state)) + return E_NOTIMPL; + + const UINT vertexCount = + R30PrimitiveElementCount(type, primitiveCount); + if (!vertexCount || vertexCount > 262144u) + return E_NOTIMPL; + + IDirect3DVertexBuffer9* vb = nullptr; + UINT streamOffset = 0; + UINT stride = 0; + if (FAILED(device->GetStreamSource( + 0, &vb, &streamOffset, &stride)) || + !vb || stride < sizeof(float) * 4) + { + if (vb) vb->Release(); + return E_NOTIMPL; + } + + R30EnsureVertexBufferHooks(vb); + const bool wasArmed = R30BufferShadowCaptureArmed.exchange( + true, std::memory_order_acq_rel); + R30EnsureVertexShadow(vb); + if (!wasArmed && !R30FirstShadowArmLogged) + { + R30FirstShadowArmLogged = true; + spdlog::info( + "VR R30 BUFFER SHADOW: lazy capture armed only after a real XYZRHW VB draw; ordinary world buffers no longer pay per-Lock shadow memcpy cost"); + } + + D3DVERTEXBUFFER_DESC desc{}; + const std::uint64_t firstByte = + static_cast(streamOffset) + + static_cast(startVertex) * stride; + const std::uint64_t byteCount = + static_cast(vertexCount) * stride; + if (FAILED(vb->GetDesc(&desc)) || firstByte > desc.Size || + byteCount > desc.Size - firstByte || + firstByte > UINT_MAX || byteCount > UINT_MAX) + { + vb->Release(); + return E_NOTIMPL; + } + + R30ScratchLease lease; + if (!lease) + { + vb->Release(); + return E_NOTIMPL; + } + auto& scratch = *lease.buffers; + if (!R30CopyVertexShadow(vb, + static_cast(firstByte), + static_cast(byteCount), scratch.source)) + { + vb->Release(); + ++R30XyzrhwFallbacks; + return E_NOTIMPL; + } + + if (!R30ConfigureXyzrhwWorldEffect( + device, scratch.source.data(), vertexCount, + stride, state) || + !R30TransformXyzrhwStereo( + scratch.source.data(), vertexCount, stride, state, + scratch.left, scratch.right)) + { + vb->Release(); + ++R30XyzrhwFallbacks; + return E_NOTIMPL; + } + + auto leftDraw = [&]() { + return DrawPrimitiveUPHook.stdcall( + device, type, primitiveCount, + scratch.left.data(), stride); + }; + auto rightDraw = [&]() { + return DrawPrimitiveUPHook.stdcall( + device, type, primitiveCount, + scratch.right.data(), stride); + }; + const HRESULT hr = R30ExecuteXyzrhwStereo( + device, state, leftDraw, rightDraw, + "R30.6/DrawPrimitiveVB-ShadowXYZRHW"); + + { + InternalPassScope guard; + if (FAILED(device->SetStreamSource( + 0, vb, streamOffset, stride))) + { + NoteRestoreFailure("R30.6 VB stream restore"); + R30ArmSafeFallback(); + } + } + vb->Release(); + return hr; + } + + HRESULT R30TryXyzrhwIndexedPrimitiveVB( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + INT baseVertexIndex, UINT minVertexIndex, UINT numVertices, + UINT startIndex, UINT primitiveCount) + { + R30XyzrhwState state{}; + if (!R30PrepareXyzrhwState(device, state)) + return E_NOTIMPL; + + const UINT indexCount = + R30PrimitiveElementCount(type, primitiveCount); + if (!indexCount || !numVertices || indexCount > 524288u) + return E_NOTIMPL; + + IDirect3DVertexBuffer9* vb = nullptr; + IDirect3DIndexBuffer9* ib = nullptr; + UINT streamOffset = 0; + UINT stride = 0; + if (FAILED(device->GetStreamSource( + 0, &vb, &streamOffset, &stride)) || + !vb || stride < sizeof(float) * 4 || + FAILED(device->GetIndices(&ib)) || !ib) + { + if (ib) ib->Release(); + if (vb) vb->Release(); + return E_NOTIMPL; + } + + R30EnsureVertexBufferHooks(vb); + R30EnsureIndexBufferHooks(ib); + const bool wasArmed = R30BufferShadowCaptureArmed.exchange( + true, std::memory_order_acq_rel); + R30EnsureVertexShadow(vb); + R30EnsureIndexShadow(ib); + if (!wasArmed && !R30FirstShadowArmLogged) + { + R30FirstShadowArmLogged = true; + spdlog::info( + "VR R30 BUFFER SHADOW: lazy capture armed only after a real XYZRHW VB/IB draw; ordinary world buffers no longer pay per-Lock shadow memcpy cost"); + } + + D3DINDEXBUFFER_DESC ibDesc{}; + if (FAILED(ib->GetDesc(&ibDesc)) || + (ibDesc.Format != D3DFMT_INDEX16 && + ibDesc.Format != D3DFMT_INDEX32)) + { + ib->Release(); + vb->Release(); + return E_NOTIMPL; + } + const UINT indexSize = + ibDesc.Format == D3DFMT_INDEX16 ? 2u : 4u; + const std::uint64_t firstIndexByte = + static_cast(startIndex) * indexSize; + const std::uint64_t indexBytes = + static_cast(indexCount) * indexSize; + if (firstIndexByte > ibDesc.Size || + indexBytes > ibDesc.Size - firstIndexByte || + firstIndexByte > UINT_MAX || indexBytes > UINT_MAX) + { + ib->Release(); + vb->Release(); + return E_NOTIMPL; + } + + R30ScratchLease lease; + if (!lease) + { + ib->Release(); + vb->Release(); + return E_NOTIMPL; + } + auto& scratch = *lease.buffers; + if (!R30CopyIndexShadow(ib, + static_cast(firstIndexByte), + static_cast(indexBytes), scratch.indexSource)) + { + ib->Release(); + vb->Release(); + ++R30XyzrhwFallbacks; + return E_NOTIMPL; + } + + try + { + scratch.physical.resize(indexCount); + } + catch (...) + { + ib->Release(); + vb->Release(); + return E_NOTIMPL; + } + + std::int64_t minPhysical = INT64_MAX; + std::int64_t maxPhysical = INT64_MIN; + for (UINT i = 0; i < indexCount; ++i) + { + const std::uint32_t raw = + ibDesc.Format == D3DFMT_INDEX16 + ? static_cast( + static_cast(scratch.indexSource.data()))[i] + : static_cast( + static_cast(scratch.indexSource.data()))[i]; + const std::int64_t p = + static_cast(baseVertexIndex) + raw; + if (p < 0 || p > UINT_MAX) + { + ib->Release(); + vb->Release(); + return E_NOTIMPL; + } + scratch.physical[i] = static_cast(p); + minPhysical = std::min(minPhysical, p); + maxPhysical = std::max(maxPhysical, p); + } + + if (minPhysical == INT64_MAX || maxPhysical < minPhysical) + { + ib->Release(); + vb->Release(); + return E_NOTIMPL; + } + + const std::uint64_t vertexCount64 = + static_cast(maxPhysical - minPhysical) + 1u; + if (!vertexCount64 || vertexCount64 > 262144u) + { + ib->Release(); + vb->Release(); + return E_NOTIMPL; + } + const UINT vertexCount = static_cast(vertexCount64); + + D3DVERTEXBUFFER_DESC vbDesc{}; + const std::uint64_t firstVertexByte = + static_cast(streamOffset) + + static_cast(minPhysical) * stride; + const std::uint64_t vertexBytes = + static_cast(vertexCount) * stride; + if (FAILED(vb->GetDesc(&vbDesc)) || + firstVertexByte > vbDesc.Size || + vertexBytes > vbDesc.Size - firstVertexByte || + firstVertexByte > UINT_MAX || vertexBytes > UINT_MAX) + { + ib->Release(); + vb->Release(); + return E_NOTIMPL; + } + + if (!R30CopyVertexShadow(vb, + static_cast(firstVertexByte), + static_cast(vertexBytes), scratch.source)) + { + ib->Release(); + vb->Release(); + ++R30XyzrhwFallbacks; + return E_NOTIMPL; + } + + try + { + scratch.used.assign(vertexCount, 0); + } + catch (...) + { + ib->Release(); + vb->Release(); + return E_NOTIMPL; + } + for (UINT i = 0; i < indexCount; ++i) + { + const std::uint64_t local = + scratch.physical[i] - + static_cast(minPhysical); + if (local >= vertexCount) + { + ib->Release(); + vb->Release(); + return E_NOTIMPL; + } + scratch.used[static_cast(local)] = 1; + } + + if (!R30ConfigureXyzrhwWorldEffect( + device, scratch.source.data(), vertexCount, + stride, state, &scratch.used) || + !R30TransformXyzrhwStereo( + scratch.source.data(), vertexCount, stride, state, + scratch.left, scratch.right, &scratch.used)) + { + ib->Release(); + vb->Release(); + ++R30XyzrhwFallbacks; + return E_NOTIMPL; + } + + const void* rebased = nullptr; + if (ibDesc.Format == D3DFMT_INDEX16) + { + try { scratch.indices16.resize(indexCount); } + catch (...) { + ib->Release(); + vb->Release(); + return E_NOTIMPL; + } + for (UINT i = 0; i < indexCount; ++i) + { + const std::uint64_t v = + scratch.physical[i] - + static_cast(minPhysical); + if (v > 0xFFFFu) + { + ib->Release(); + vb->Release(); + return E_NOTIMPL; + } + scratch.indices16[i] = static_cast(v); + } + rebased = scratch.indices16.data(); + } + else + { + try { scratch.indices32.resize(indexCount); } + catch (...) { + ib->Release(); + vb->Release(); + return E_NOTIMPL; + } + for (UINT i = 0; i < indexCount; ++i) + scratch.indices32[i] = + scratch.physical[i] - + static_cast(minPhysical); + rebased = scratch.indices32.data(); + } + + auto leftDraw = [&]() { + return DrawIndexedPrimitiveUPHook.stdcall( + device, type, 0u, vertexCount, primitiveCount, + rebased, ibDesc.Format, scratch.left.data(), stride); + }; + auto rightDraw = [&]() { + return DrawIndexedPrimitiveUPHook.stdcall( + device, type, 0u, vertexCount, primitiveCount, + rebased, ibDesc.Format, scratch.right.data(), stride); + }; + const HRESULT hr = R30ExecuteXyzrhwStereo( + device, state, leftDraw, rightDraw, + "R30.6/DrawIndexedPrimitiveVB-ShadowXYZRHW"); + + { + InternalPassScope guard; + bool restoreOk = SUCCEEDED(device->SetStreamSource( + 0, vb, streamOffset, stride)); + restoreOk = SUCCEEDED(device->SetIndices(ib)) && restoreOk; + if (!restoreOk) + { + NoteRestoreFailure("R30.6 VB/IB restore"); + R30ArmSafeFallback(); + } + } + ib->Release(); + vb->Release(); + return hr; + } + + bool R30BuildScreenSpaceEyeConstants( + IDirect3DDevice9* device, + const OutRunVRRenderer::LatchedStereoFrame& stereo, + R30ScreenSpaceKind screenKind, + float original[16], float eyeConstants[2][16], + float eyeScale[2], float eyeOffset[2]) noexcept + { + if (!device || + CurrentVertexShaderIdentity.load(std::memory_order_acquire) == 0) + return false; + + if (screenKind == R30ScreenSpaceKind::PerspectiveHud || + screenKind == R30ScreenSpaceKind::WorldBillboard) + { + // R44: glyph/billboard batches commonly reuse one game c64 for + // several consecutive draws. Use the original game upload, not + // a live register that may already contain our head correction. + if (!R44GetOwnedRawOverlayWvp(original)) + { + // Some flat result overlays never refresh c64 in the short + // ownership window. They are safe only as PerspectiveHud; + // preserve the old live fallback for those, but never for a + // spatial billboard. + if (screenKind == R30ScreenSpaceKind::WorldBillboard || + FAILED(device->GetVertexShaderConstantF( + OutRunWvpRegister, original, + OutRunWvpRegisterCount))) + return false; + } + } + else if (FAILED(device->GetVertexShaderConstantF( + OutRunWvpRegister, original, OutRunWvpRegisterCount))) + { + return false; + } + if (!R30BuildEyeAffine(stereo, eyeScale, eyeOffset)) + return false; + + D3DMATRIX uploadedT{}; + std::memcpy(&uploadedT, original, sizeof(uploadedT)); + const D3DMATRIX stockWvp = TransposeMatrix(uploadedT); + if (!MatrixFinite(stockWvp)) + return false; + + float baseRaw[16]{}; + D3DMATRIX baseProjection{}; + D3DMATRIX inverseBaseProjection{}; + if (!OutRunVRRenderer::GetRendererBaseProjection(baseRaw)) + return false; + std::memcpy(&baseProjection, baseRaw, sizeof(baseProjection)); + if (!MatrixFinite(baseProjection) || + !InvertMatrix(baseProjection, inverseBaseProjection)) + return false; + + const float centerEye[3]{ + 0.5f * (stereo.eyeOffset[0][0] + stereo.eyeOffset[1][0]), + 0.5f * (stereo.eyeOffset[0][1] + stereo.eyeOffset[1][1]), + 0.5f * (stereo.eyeOffset[0][2] + stereo.eyeOffset[1][2]) + }; + const float identityOrientation[4]{ + 0.0f, 0.0f, 0.0f, 1.0f + }; + + if (screenKind == R30ScreenSpaceKind::WorldBillboard) + { + // R41: this path is reached only after the verified/rebindable + // renderer-world paths reject the current c64. Therefore the + // stock WVP cannot be assumed to contain our latched head + // correction. Rebuild the spatial billboard from its stock + // WorldView, then apply the same common head inverse and full + // head-local eye pose used by raw world geometry. + const D3DMATRIX worldView = + MultiplyMatrix(stockWvp, inverseBaseProjection); + if (!MatrixFinite(worldView)) + return false; + + float headRaw[16]{}; + std::uint32_t headPoseSequence = 0; + D3DMATRIX headInverse{}; + if (!OutRunVRRenderer::GetLatchedHeadInverse( + headRaw, headPoseSequence) || + headPoseSequence != stereo.poseSequence) + return false; + std::memcpy(&headInverse, headRaw, sizeof(headInverse)); + if (!MatrixFinite(headInverse)) + return false; + + for (int eye = 0; eye < 2; ++eye) + { + const D3DMATRIX eyePose = + MatrixFromQuaternionTranslation( + stereo.eyeOrientation[eye], + stereo.eyeOffset[eye], + Settings::VRWorldScale * + Settings::VRStereoDepth); + const D3DMATRIX eyeInverse = InverseRigid(eyePose); + const D3DMATRIX eyeProjection = + ProjectionFromFov( + baseProjection, stereo.eyeFov[eye]); + const D3DMATRIX corrected = + MultiplyMatrix( + MultiplyMatrix( + MultiplyMatrix(worldView, headInverse), + eyeInverse), + eyeProjection); + if (!MatrixFinite(corrected)) + return false; + const D3DMATRIX correctedT = TransposeMatrix(corrected); + std::memcpy(eyeConstants[eye], &correctedT, + sizeof(correctedT)); + } + return true; + } + + if (screenKind != R30ScreenSpaceKind::Hud2D && + screenKind != R30ScreenSpaceKind::PerspectiveHud) + return false; + + // R40: turn all screen HUD into one finite, recentered view plane. + // This removes head-lock. HudScale/contain is applied before the + // plane conversion, so perspective HUD such as the large 6th/6 uses + // exactly the same size rule as ordinary HUD. + float headRaw[16]{}; + std::uint32_t headPoseSequence = 0; + D3DMATRIX headInverse{}; + if (!OutRunVRRenderer::GetLatchedHeadInverse( + headRaw, headPoseSequence) || + headPoseSequence != stereo.poseSequence) + return false; + std::memcpy(&headInverse, headRaw, sizeof(headInverse)); + if (!MatrixFinite(headInverse)) + return false; + + float hudScaleX = 1.0f; + float hudScaleY = 1.0f; + R30HudContainScale(stereo, hudScaleX, hudScaleY); + D3DMATRIX hudScale = IdentityMatrix(); + hudScale._11 = hudScaleX; + hudScale._22 = hudScaleY; + const D3DMATRIX scaledClip = + MultiplyMatrix(stockWvp, hudScale); + + constexpr float HudPlaneViewZ = -2.50f; + const float planeClipW = + HudPlaneViewZ * baseProjection._34 + + baseProjection._44; + const float planeClipZ = + HudPlaneViewZ * baseProjection._33 + + baseProjection._43; + if (!std::isfinite(planeClipW) || + !std::isfinite(planeClipZ) || + std::fabs(planeClipW) <= 1.0e-4f) + return false; + const float planeNdcZ = planeClipZ / planeClipW; + if (!std::isfinite(planeNdcZ)) + return false; + + D3DMATRIX depthReset = IdentityMatrix(); + depthReset._33 = 0.0f; + depthReset._43 = planeNdcZ; + const D3DMATRIX commonViewPlane = + MultiplyMatrix( + MultiplyMatrix(scaledClip, depthReset), + inverseBaseProjection); + if (!MatrixFinite(commonViewPlane)) + return false; + + for (int eye = 0; eye < 2; ++eye) + { + const float relativeEye[3]{ + stereo.eyeOffset[eye][0] - centerEye[0], + stereo.eyeOffset[eye][1] - centerEye[1], + stereo.eyeOffset[eye][2] - centerEye[2] + }; + const D3DMATRIX eyePose = + MatrixFromQuaternionTranslation( + identityOrientation, relativeEye, + Settings::VRWorldScale * + Settings::VRStereoDepth); + const D3DMATRIX eyeInverse = InverseRigid(eyePose); + const D3DMATRIX eyeProjection = + ProjectionFromFov( + baseProjection, stereo.eyeFov[eye]); + const D3DMATRIX corrected = + MultiplyMatrix( + MultiplyMatrix( + MultiplyMatrix(commonViewPlane, headInverse), + eyeInverse), + eyeProjection); + if (!MatrixFinite(corrected)) + return false; + const D3DMATRIX correctedT = TransposeMatrix(corrected); + std::memcpy(eyeConstants[eye], &correctedT, + sizeof(correctedT)); + } + return true; + } + + template + HRESULT R30TryScreenSpaceFovDraw( + IDirect3DDevice9* device, ActualDraw&& actualDraw, + const char* site) + { + if (!R30SafeStereoBase(device)) + return E_NOTIMPL; + + const R30ScreenSpaceKind screenKind = + R30ClassifyScreenSpacePass(device); + if (screenKind == R30ScreenSpaceKind::None) + return E_NOTIMPL; + + if (!EnsureStereoResources(device)) + return E_NOTIMPL; + + if (TrackedDepthStencil && + (!RightDepthSynchronized || !RightStencilSynchronized)) + TryBootstrapRightDepthFromRecentClear(device); + if (TrackedDepthStencil && !RightDepthSynchronized && + DepthTestActive(device)) + return E_NOTIMPL; + if (TrackedDepthStencil && !RightStencilSynchronized && + StencilTestActive(device)) + return E_NOTIMPL; + + OutRunVRRenderer::LatchedStereoFrame stereo{}; + if (!OutRunVRRenderer::GetLatchedStereoFrame(stereo) || + stereo.poseSequence == 0) + return E_NOTIMPL; + + // If perspective world geometry already established this Present's + // pose sequence, screen-space correction must use that exact packet. + if (FrameStereoPoseSequence != 0 && + FrameStereoPoseSequence != stereo.poseSequence) + return E_NOTIMPL; + + float original[16]{}; + float eyeConstants[2][16]{}; + float eyeScale[2]{}; + float eyeOffset[2]{}; + if (!R30BuildScreenSpaceEyeConstants(device, stereo, + screenKind, original, eyeConstants, + eyeScale, eyeOffset)) + { + ++R30ScreenSpaceBuildFailures; + return E_NOTIMPL; + } + + D3DVIEWPORT9 savedViewport{}; + if (FAILED(device->GetViewport(&savedViewport))) + return E_NOTIMPL; + + // Capture the completed world eyes before the first recognized HUD + // draw. Present then extracts glow from this snapshot, so bright HUD + // text/icons are never themselves bloom sources. + R30CaptureSkyGlowSceneBeforeHud(device); + + // From this point the draw is owned by R30. The steady-state frame + // intentionally has no complete independent mono history. + ++R9DrawCalls; + R9MonoBackupGap = true; + if (LeftDrawMayWriteDepth(device) || + LeftDrawMayWriteStencil(device)) + ++R9MainDepthContentSerial; + + bool leftWvpOk = false; + { + InternalPassScope guard; + leftWvpOk = SetWvpOneRegisterAtATime( + device, eyeConstants[0]); + } + if (!leftWvpOk) + { + bool restored = false; + { + InternalPassScope guard; + restored = SetWvpOneRegisterAtATime(device, original); + } + if (!restored) + { + R9Poison(OutRunVR::StereoFailureRestoreFailed, + "R30/HUD-left-WVP-rollback"); + R30ArmSafeFallback(); + return E_FAIL; + } + --R9DrawCalls; + return E_NOTIMPL; + } + + const HRESULT leftHr = actualDraw(); + if (FAILED(leftHr)) + { + bool restored = false; + { + InternalPassScope guard; + restored = SetWvpOneRegisterAtATime(device, original); + } + R9Poison(OutRunVR::StereoFailureLeftDrawFailed, + site, leftHr); + if (!restored) + NoteRestoreFailure("R30 HUD left draw c64"); + R30ArmSafeFallback(); + return leftHr; + } + + IDirect3DSurface9* savedRt = TrackedRenderTarget; + IDirect3DSurface9* savedDepth = TrackedDepthStencil; + HRESULT rightHr = D3D_OK; + OutRunVR::StereoFailureReason rightFailure = + OutRunVR::StereoFailureRightStateFailed; + bool restoreOk = true; + { + InternalPassScope guard; + rightHr = SetRenderTargetHook.stdcall( + device, 0u, RightEyeSurface); + if (SUCCEEDED(rightHr)) + { + rightHr = SetDepthStencilSurfaceHook.stdcall( + device, TrackedDepthStencil ? RightEyeDepth : nullptr); + } + if (SUCCEEDED(rightHr)) + rightHr = device->SetViewport(&savedViewport); + if (SUCCEEDED(rightHr) && + !SetWvpOneRegisterAtATime(device, eyeConstants[1])) + { + rightFailure = + OutRunVR::StereoFailureRightWvpUploadFailed; + rightHr = E_FAIL; + } + if (SUCCEEDED(rightHr)) + { + rightFailure = OutRunVR::StereoFailureRightDrawFailed; + rightHr = actualDraw(); + } + restoreOk = RestoreRightPassState(device, savedRt, savedDepth, + savedViewport, original, true); + } + + FrameHadDuplicatedDraw = true; + ++DuplicatedDraws; + ++NonWorldDuplicatedDraws; + ++R30SafeTwoEyeDraws; + ++R30ScreenSpaceFovDraws; + if (screenKind == R30ScreenSpaceKind::Hud2D) + ++R30Hud2DDraws; + else if (screenKind == R30ScreenSpaceKind::PerspectiveHud) + ++R30PerspectiveHudDraws; + else if (screenKind == R30ScreenSpaceKind::WorldBillboard) + ++R30WorldBillboardDraws; + + if (!R30FirstScreenSpaceLogged) + { + R30FirstScreenSpaceLogged = true; + spdlog::info( + "VR R30 HUD: asymmetric-FOV convergence correction ACTIVE; common-centre offset[L/R]={:.4f}/{:.4f} hudScale={:.2f} sourceOverTarget={:.3f}", + eyeOffset[0], eyeOffset[1], R30HudScaleValue(), + R30HudAspectCompensation(stereo)); + } + if (screenKind == R30ScreenSpaceKind::WorldBillboard && + !R30FirstFlatPerspectiveLogged) + { + R30FirstFlatPerspectiveLogged = true; + spdlog::info( + "VR R30 FLAT EFFECT: depth-disabled alpha perspective overlay is world-locked on a finite virtual plane; R45 spatial vehicle/rank billboards use corrected outer-hook c64 ownership and rebuild stock WorldView with head+eye transform"); + } + if (screenKind == R30ScreenSpaceKind::PerspectiveHud && + !R30FirstPerspectiveHudLogged) + { + R30FirstPerspectiveHudLogged = true; + spdlog::info( + "VR R45 PERSPECTIVE HUD: outer-hook draw serial fixed; 6th/6 and flat white overlays use the raw game c64 before stereo head correction, then uniform HudScale on the world-locked finite HUD plane"); + } + + if (FAILED(rightHr)) + { + FrameRightDrawFailed = true; + InvalidateRightDepthStencilIfLeftMayWrite(device); + R9Poison(rightFailure, site, rightHr); + R30ArmSafeFallback(); + } + if (!restoreOk) + { + InvalidateRightDepthStencilIfLeftMayWrite(device); + NoteRestoreFailure("R30 HUD right-eye draw"); + R30ArmSafeFallback(); + } + return leftHr; + } + + template + HRESULT R30GuardScreenSpace(IDirect3DDevice9* device, + ActualDraw&& actualDraw, R29Draw&& r29Draw, + const char* site) + { + const HRESULT hr = R30TryScreenSpaceFovDraw( + device, std::forward(actualDraw), site); + if (hr != E_NOTIMPL) + return hr; + ++R30ScreenSpaceFallbacks; + return r29Draw(); + } + + HRESULT __stdcall DrawPrimitiveDestR30(IDirect3DDevice9* device, + D3DPRIMITIVETYPE type, UINT startVertex, UINT primitiveCount) + { + const HRESULT xyzrhw = R30TryXyzrhwPrimitiveVB( + device, type, startVertex, primitiveCount); + if (xyzrhw != E_NOTIMPL) + return xyzrhw; + + auto actual = [&]() { + return DrawPrimitiveHook.stdcall( + device, type, startVertex, primitiveCount); + }; + auto r29 = [&]() { + return R30DrawPrimitiveR29Hook.stdcall( + device, type, startVertex, primitiveCount); + }; + return R30GuardScreenSpace( + device, actual, r29, "R30/DrawPrimitive"); + } + + HRESULT __stdcall DrawIndexedPrimitiveDestR30( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + INT baseVertexIndex, UINT minVertexIndex, UINT numVertices, + UINT startIndex, UINT primitiveCount) + { + const HRESULT xyzrhw = R30TryXyzrhwIndexedPrimitiveVB( + device, type, baseVertexIndex, minVertexIndex, numVertices, + startIndex, primitiveCount); + if (xyzrhw != E_NOTIMPL) + return xyzrhw; + + auto actual = [&]() { + return DrawIndexedPrimitiveHook.stdcall(device, type, + baseVertexIndex, minVertexIndex, numVertices, startIndex, + primitiveCount); + }; + auto r29 = [&]() { + return R30DrawIndexedPrimitiveR29Hook.stdcall(device, type, + baseVertexIndex, minVertexIndex, numVertices, startIndex, + primitiveCount); + }; + return R30GuardScreenSpace( + device, actual, r29, "R30/DrawIndexedPrimitive"); + } + + HRESULT __stdcall DrawPrimitiveUPDestR30( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + UINT primitiveCount, const void* data, UINT stride) + { + const HRESULT xyzrhw = R30TryXyzrhwPrimitiveUP( + device, type, primitiveCount, data, stride); + if (xyzrhw != E_NOTIMPL) + return xyzrhw; + + auto actual = [&]() { + return DrawPrimitiveUPHook.stdcall( + device, type, primitiveCount, data, stride); + }; + auto r29 = [&]() { + return R30DrawPrimitiveUPR29Hook.stdcall( + device, type, primitiveCount, data, stride); + }; + return R30GuardScreenSpace( + device, actual, r29, "R30/DrawPrimitiveUP"); + } + + HRESULT __stdcall DrawIndexedPrimitiveUPDestR30( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + UINT minVertexIndex, UINT numVertices, UINT primitiveCount, + const void* indexData, D3DFORMAT indexFormat, + const void* vertexData, UINT stride) + { + const HRESULT xyzrhw = R30TryXyzrhwIndexedPrimitiveUP( + device, type, minVertexIndex, numVertices, primitiveCount, + indexData, indexFormat, vertexData, stride); + if (xyzrhw != E_NOTIMPL) + return xyzrhw; + + auto actual = [&]() { + return DrawIndexedPrimitiveUPHook.stdcall(device, type, + minVertexIndex, numVertices, primitiveCount, indexData, + indexFormat, vertexData, stride); + }; + auto r29 = [&]() { + return R30DrawIndexedPrimitiveUPR29Hook.stdcall(device, + type, minVertexIndex, numVertices, primitiveCount, + indexData, indexFormat, vertexData, stride); + }; + return R30GuardScreenSpace( + device, actual, r29, "R30/DrawIndexedPrimitiveUP"); + } + + void R30RollbackHooks() noexcept + { + R30ResetR29Hook = {}; + R30PresentR29Hook = {}; + R30DrawIndexedPrimitiveUPR29Hook = {}; + R30DrawPrimitiveUPR29Hook = {}; + R30DrawIndexedPrimitiveR29Hook = {}; + R30DrawPrimitiveR29Hook = {}; + } + + bool R30EnableHooks() noexcept + { + SafetyHookInline* hooks[]{ + &R30PresentR29Hook, + &R30ResetR29Hook, + &R30DrawPrimitiveR29Hook, + &R30DrawIndexedPrimitiveR29Hook, + &R30DrawPrimitiveUPR29Hook, + &R30DrawIndexedPrimitiveUPR29Hook + }; + for (auto* hook : hooks) + { + if (!*hook || !hook->enable().has_value()) + return false; + } + return true; + } + + DWORD WINAPI R30InstallThread(void*) + { + using State = OutRunVR::RuntimeEligibility::InstallState; + R30InstallState.store(State::Pending, std::memory_order_release); + + for (int attempt = 0; attempt < 4800; ++attempt) + { + if (Game::D3DDevice_ptr && *Game::D3DDevice_ptr) + R30InstallBufferCreationHooks(*Game::D3DDevice_ptr); + + const auto r29 = R26InstallState.load( + std::memory_order_acquire); + if (r29 == State::Failed) + { + R30RollbackBufferShadowHooks(); + R30InstallState.store(State::Failed, + std::memory_order_release); + HookManager::ReportAsyncResult("OpenXRVRStereoR30HUD", false); + spdlog::error( + "VR R30 HUD: R29 prerequisite failed; R29 remains active without screen-space FOV correction"); + return 0; + } + + if (r29 == State::Ready) + { + const auto disabled = safetyhook::InlineHook::StartDisabled; + R30PresentR29Hook = safetyhook::create_inline( + reinterpret_cast(&PresentDestR27), + PresentDestR30, disabled); + R30ResetR29Hook = safetyhook::create_inline( + reinterpret_cast(&ResetDestR22), + ResetDestR30, disabled); + R30DrawPrimitiveR29Hook = safetyhook::create_inline( + reinterpret_cast(&DrawPrimitiveDestR26), + DrawPrimitiveDestR30, disabled); + R30DrawIndexedPrimitiveR29Hook = safetyhook::create_inline( + reinterpret_cast(&DrawIndexedPrimitiveDestR26), + DrawIndexedPrimitiveDestR30, disabled); + R30DrawPrimitiveUPR29Hook = safetyhook::create_inline( + reinterpret_cast(&DrawPrimitiveUPDestR26), + DrawPrimitiveUPDestR30, disabled); + R30DrawIndexedPrimitiveUPR29Hook = safetyhook::create_inline( + reinterpret_cast(&DrawIndexedPrimitiveUPDestR26), + DrawIndexedPrimitiveUPDestR30, disabled); + + if (!R30EnableHooks()) + { + R30RollbackHooks(); + R30RollbackBufferShadowHooks(); + R30InstallState.store(State::Failed, + std::memory_order_release); + HookManager::ReportAsyncResult( + "OpenXRVRStereoR30HUD", false); + spdlog::error( + "VR R30 HUD: disabled-first hook transaction failed; R29 remains active"); + return 0; + } + + R30InstallState.store(State::Ready, + std::memory_order_release); + HookManager::ReportAsyncResult( + "OpenXRVRStereoR30HUD", true); + spdlog::info( + "VR R30 HUD: ScreenSpace2D correction READY with configurable common-center HUD scale current={:.2f}; R41 shader+XYZRHW finite HUD world-lock + spatial billboard rebuild active", + R30HudScaleValue()); + return 0; + } + Sleep(25); + } + + R30RollbackBufferShadowHooks(); + R30InstallState.store(State::Failed, std::memory_order_release); + HookManager::ReportAsyncResult("OpenXRVRStereoR30HUD", false); + spdlog::error( + "VR R30 HUD: timed out waiting for R29; R29 remains active"); + return 0; + } + + class VRStereoR30HudHook final : public Hook + { + public: + std::string_view description() override + { + return "OpenXRVRStereoR30HUD"; + } + bool validate() override { return true; } + bool apply() override + { + using State = OutRunVR::RuntimeEligibility::InstallState; + R30InstallState.store(State::Pending, + std::memory_order_release); + HANDLE thread = CreateThread(nullptr, 0, + R30InstallThread, nullptr, 0, nullptr); + if (!thread) + { + R30InstallState.store(State::Failed, + std::memory_order_release); + return false; + } + CloseHandle(thread); + return true; + } + static VRStereoR30HudHook instance; + }; + + VRStereoR30HudHook VRStereoR30HudHook::instance; + } +} + + +namespace OutRunVRStereo +{ + namespace + { + class VRR26HudSafeCompareMarkerHook final : public Hook + { + public: + std::string_view description() override + { + return "OpenXVRR26HudSafeCompare"; + } + bool validate() override { return true; } + bool apply() override + { + spdlog::warn( + "VR R26+HUD SAFE TEST: R26/R23 world path + R30 HUD/XYZRHW/SkyGlow overlay ACTIVE"); + return true; + } + static VRR26HudSafeCompareMarkerHook instance; + }; + VRR26HudSafeCompareMarkerHook + VRR26HudSafeCompareMarkerHook::instance; + } +} diff --git a/src/vr/d3d9/stereo_renderer_r31.cpp b/src/vr/d3d9/stereo_renderer_r31.cpp new file mode 100644 index 000000000..f7ce77ec7 --- /dev/null +++ b/src/vr/d3d9/stereo_renderer_r31.cpp @@ -0,0 +1,1113 @@ +// R31 high-draw-count performance overlay. +// +// R29 removed the steady-state mono replay, but the validated R7 world path +// still read c64..c67 and rebuilt all eye transforms for every draw. Complex +// OutRun stages can submit several thousand top-level draws per Present, so the +// validation itself becomes measurable even after the third geometry pass is +// gone. +// +// R31 keeps R29/R30 safety policy and changes only the proven stable hot path: +// * verified c64..c67 from the renderer hook is the authoritative stock WVP; +// * shader epoch + pose sequence must still match exactly; +// * eyeInverse*eyeProjection is cached per pose/projection/world-scale; +// * one live c64 validation is retained every 16 fast world draws only after +// StateBlock interception is proven; otherwise every candidate is validated; +// * Begin/End/Apply invalidate and resynchronize WVP, projection, shader, +// effect, viewport and scissor caches as one state generation; +// * HUD draws use R30 math but an explicit {handled,hr} result, removing the +// E_NOTIMPL sentinel/double-draw ambiguity; +// * a five-second route summary separates main/offscreen/aux/world/HUD/fallback +// work so stage-specific 300 -> 3000+ draw explosions can be diagnosed. + +#include "stereo_renderer_r30.cpp" + +namespace OutRunVRStereo +{ + namespace + { + constexpr std::size_t CreateStateBlockVtableIndex = 59; + constexpr std::size_t BeginStateBlockVtableIndex = 60; + constexpr std::size_t EndStateBlockVtableIndex = 61; + constexpr std::size_t StateBlockApplyVtableIndex = 5; + constexpr std::uint64_t LiveWvpValidationInterval = 16; + + SafetyHookInline R31DrawPrimitiveR30Hook{}; + SafetyHookInline R31DrawIndexedPrimitiveR30Hook{}; + SafetyHookInline R31DrawPrimitiveUPR30Hook{}; + SafetyHookInline R31DrawIndexedPrimitiveUPR30Hook{}; + SafetyHookInline R31CreateStateBlockHook{}; + SafetyHookInline R31BeginStateBlockHook{}; + SafetyHookInline R31EndStateBlockHook{}; + SafetyHookInline R31StateBlockApplyHook{}; + void* R31StateBlockApplyTarget = nullptr; + + std::atomic R31InstallState{ + OutRunVR::RuntimeEligibility::InstallState::Pending }; + + std::uint32_t R31BlockedVerifiedGeneration = 0; + std::uint64_t R31FastWorldCandidates = 0; + std::uint64_t R31FastWorldDraws = 0; + std::uint64_t R31FastWorldLiveValidations = 0; + std::uint64_t R31FastWorldValidationRejects = 0; + std::uint64_t R31StateBlockApplies = 0; + std::uint64_t R31StateBlockRecordings = 0; + std::uint64_t R31HudDraws = 0; + std::atomic R31StateBlockTrackingReliable{ false }; + std::atomic R31StateBlockCoverageLost{ false }; + thread_local bool R31StateBlockResyncPending = false; + thread_local bool R31StateBlockRecording = false; + bool R31FirstFastWorldLogged = false; + bool R31FirstStateBlockLogged = false; + bool R31FirstAlternateStateBlockLogged = false; + bool R31FirstHudLogged = false; + + struct R31EyeTailCache + { + bool valid = false; + std::uint32_t poseSequence = 0; + float worldScale = 0.0f; + D3DMATRIX projection{}; + D3DMATRIX inverseProjection{}; + D3DMATRIX eyeTail[2]{}; + }; + R31EyeTailCache R31EyeCache{}; + + struct R31FramePerf + { + std::uint64_t epoch = 0; + std::uint64_t draws = 0; + std::uint64_t main = 0; + std::uint64_t offscreen = 0; + std::uint64_t aux = 0; + std::uint64_t fastWorld = 0; + std::uint64_t hud = 0; + std::uint64_t fragile = 0; + std::uint64_t unstable = 0; + std::uint64_t fallback = 0; + }; + R31FramePerf R31Frame{}; + + struct R31WindowPerf + { + std::uint64_t presents = 0; + std::uint64_t draws = 0; + std::uint64_t main = 0; + std::uint64_t offscreen = 0; + std::uint64_t aux = 0; + std::uint64_t fastWorld = 0; + std::uint64_t hud = 0; + std::uint64_t fragile = 0; + std::uint64_t unstable = 0; + std::uint64_t fallback = 0; + std::uint64_t maxDraws = 0; + ULONGLONG lastLogMs = 0; + }; + R31WindowPerf R31Window{}; + + void R31FinalizePerfFrame() noexcept + { + if (R31Frame.epoch == 0 || R31Frame.draws == 0) + return; + ++R31Window.presents; + R31Window.draws += R31Frame.draws; + R31Window.main += R31Frame.main; + R31Window.offscreen += R31Frame.offscreen; + R31Window.aux += R31Frame.aux; + R31Window.fastWorld += R31Frame.fastWorld; + R31Window.hud += R31Frame.hud; + R31Window.fragile += R31Frame.fragile; + R31Window.unstable += R31Frame.unstable; + R31Window.fallback += R31Frame.fallback; + R31Window.maxDraws = std::max(R31Window.maxDraws, R31Frame.draws); + + const ULONGLONG now = GetTickCount64(); + if (R31Window.lastLogMs == 0) + R31Window.lastLogMs = now; + if (Settings::VRTelemetry && now - R31Window.lastLogMs >= 5000 && + R31Window.presents != 0) + { + const double avg = static_cast(R31Window.draws) / + static_cast(R31Window.presents); + spdlog::info( + "VR R31 PERF: topLevelGameCalls/present avg={:.1f} max={} presents={} targets[main={},offscreen={},auxOverlay={}] ownedEyeRoutes[fastWorld={},hud={}] fallbackReasons[fragile={},unstable={}] fallbackDispatch={} liveWvpCheck={} liveReject={} stateBlock[record={},apply={}]", + avg, R31Window.maxDraws, R31Window.presents, + R31Window.main, R31Window.offscreen, R31Window.aux, + R31Window.fastWorld, R31Window.hud, R31Window.fragile, + R31Window.unstable, R31Window.fallback, + R31FastWorldLiveValidations, R31FastWorldValidationRejects, + R31StateBlockRecordings, R31StateBlockApplies); + R31Window = {}; + R31Window.lastLogMs = now; + } + } + + void R31ObserveDraw(IDirect3DDevice9* device) noexcept + { + if (!IsGameDevice(device) || InternalStereoPass) + return; + if (R31Frame.epoch != PresentEpoch) + { + R31FinalizePerfFrame(); + R31Frame = {}; + R31Frame.epoch = PresentEpoch; + } + ++R31Frame.draws; + if (TargetIsBackBuffer()) ++R31Frame.main; + else ++R31Frame.offscreen; + if (AnyAuxRenderTargetActive()) ++R31Frame.aux; + } + + bool R31GetSavedViewport(IDirect3DDevice9* device, + D3DVIEWPORT9& viewport) noexcept + { + if (R22ShadowState.Valid()) + { + viewport = R22ShadowState.viewport; + return true; + } + return device && SUCCEEDED(device->GetViewport(&viewport)); + } + + bool R31ProjectionMatches(const D3DMATRIX& a, + const D3DMATRIX& b) noexcept + { + return std::memcmp(&a, &b, sizeof(D3DMATRIX)) == 0; + } + + bool R31LiveShaderMatches(IDirect3DDevice9* device, + std::uintptr_t expected) noexcept + { + if (!device || expected == 0) + return false; + IDirect3DVertexShader9* shader = nullptr; + if (FAILED(device->GetVertexShader(&shader))) + return false; + const std::uintptr_t actual = + reinterpret_cast(shader); + if (shader) shader->Release(); + return actual == expected; + } + + void R31DiscardUnreliableDrawCaches() noexcept + { + if (R31StateBlockTrackingReliable.load(std::memory_order_acquire)) + return; + R29Effect.valid = false; + R22ShadowState = {}; + R23LastStateSampleDrawSerial = 0; + R23LastStateSampleEpoch = 0; + } + + bool R31PrepareEyeTailCache( + const OutRunVRRenderer::LatchedStereoFrame& stereo, + const D3DMATRIX& projection, + const D3DMATRIX& inverseProjection) noexcept + { + const float worldScale = Settings::VRWorldScale; + if (R31EyeCache.valid && + R31EyeCache.poseSequence == stereo.poseSequence && + R31EyeCache.worldScale == worldScale && + R31ProjectionMatches(R31EyeCache.projection, projection)) + return true; + + R31EyeTailCache next{}; + next.poseSequence = stereo.poseSequence; + next.worldScale = worldScale; + next.projection = projection; + next.inverseProjection = inverseProjection; + for (int eye = 0; eye < 2; ++eye) + { + const D3DMATRIX eyePose = MatrixFromQuaternionTranslation( + stereo.eyeOrientation[eye], stereo.eyeOffset[eye], worldScale); + const D3DMATRIX eyeInverse = InverseRigid(eyePose); + const D3DMATRIX eyeProjection = ProjectionFromFov( + projection, stereo.eyeFov[eye]); + next.eyeTail[eye] = MultiplyMatrix(eyeInverse, eyeProjection); + if (!MatrixFinite(next.eyeTail[eye])) + return false; + } + next.valid = true; + R31EyeCache = next; + return true; + } + + bool R31BuildFastWorldConstants(IDirect3DDevice9* device, + const OutRunVRRenderer::LatchedStereoFrame& stereo, + DrawStereoState& draw) noexcept + { + float verified[16]{}; + std::uint32_t generation = 0; + std::uint32_t poseSequence = 0; + std::uintptr_t verifiedShader = 0; + std::uint64_t verifiedShaderSerial = 0; + if (!OutRunVRRenderer::GetLastVerifiedWvp(verified, generation, + poseSequence, verifiedShader, verifiedShaderSerial)) + return false; + if (!generation || generation == R31BlockedVerifiedGeneration || + poseSequence != stereo.poseSequence) + return false; + + std::uintptr_t currentShader = 0; + std::uint64_t currentShaderSerial = 0; + if (!GetCurrentShaderEpoch(currentShader, currentShaderSerial) || + currentShader == 0 || currentShader != verifiedShader || + currentShaderSerial != verifiedShaderSerial) + return false; + + if (!R31StateBlockTrackingReliable.load(std::memory_order_acquire) && + !R31LiveShaderMatches(device, verifiedShader)) + return false; + + ++R31FastWorldCandidates; + float live[16]{}; + bool liveValidated = false; + if (!R31StateBlockTrackingReliable.load(std::memory_order_acquire) || + (R31FastWorldCandidates % LiveWvpValidationInterval) == 0) + { + ++R31FastWorldLiveValidations; + if (FAILED(device->GetVertexShaderConstantF( + OutRunWvpRegister, live, OutRunWvpRegisterCount)) || + !FloatArrayNear(live, verified, 16, VerifiedWvpEpsilon)) + { + R31BlockedVerifiedGeneration = generation; + ++R31FastWorldValidationRejects; + return false; + } + liveValidated = true; + } + + D3DMATRIX projection{}; + D3DMATRIX inverseProjection{}; + float verifiedProjection[16]{}; + std::uint32_t projectionGeneration = 0; + std::uint32_t projectionPoseSequence = 0; + if (!OutRunVRRenderer::GetR28VerifiedProjection( + verifiedProjection, projectionGeneration, + projectionPoseSequence) || + projectionGeneration != generation || + projectionPoseSequence != poseSequence) + return false; + std::memcpy(&projection, verifiedProjection, sizeof(projection)); + if (!MatrixFinite(projection) || + !GetInverseProjection(projection, inverseProjection) || + !R31PrepareEyeTailCache(stereo, projection, inverseProjection)) + return false; + + std::memcpy(draw.originalConstants, + liveValidated ? live : verified, sizeof(verified)); + D3DMATRIX uploadedT{}; + std::memcpy(&uploadedT, verified, sizeof(uploadedT)); + const D3DMATRIX currentWvp = TransposeMatrix(uploadedT); + const D3DMATRIX correctedWorldView = MultiplyMatrix( + currentWvp, R31EyeCache.inverseProjection); + if (!MatrixFinite(correctedWorldView)) + return false; + + for (int eye = 0; eye < 2; ++eye) + { + const D3DMATRIX eyeWvp = MultiplyMatrix( + correctedWorldView, R31EyeCache.eyeTail[eye]); + if (!MatrixFinite(eyeWvp)) + return false; + const D3DMATRIX eyeWvpT = TransposeMatrix(eyeWvp); + std::memcpy(draw.eyeConstants[eye], &eyeWvpT, + sizeof(eyeWvpT)); + } + draw.worldStereo = true; + draw.poseSequence = stereo.poseSequence; + draw.stereoFrame = stereo; + return true; + } + + struct R31OwnedResult + { + bool handled = false; + HRESULT hr = D3D_OK; + }; + + template + R31OwnedResult R31TryFastWorld(IDirect3DDevice9* device, + ActualDraw&& actualDraw, const char* site) + { + if (R31StateBlockRecording || !R29StableStereoBase(device)) + { + if (IsGameDevice(device) && !InternalStereoPass && TargetIsBackBuffer()) + ++R31Frame.unstable; + return {}; + } + + bool fragile = true; + if (!R29FragileEffectCached(device, fragile)) + return {}; + if (fragile) + { + ++R31Frame.fragile; + return {}; + } + + if (!EnsureStereoResources(device)) + return {}; + if (TrackedDepthStencil && + (!RightDepthSynchronized || !RightStencilSynchronized)) + TryBootstrapRightDepthFromRecentClear(device); + if (TrackedDepthStencil && !RightDepthSynchronized && + DepthTestActive(device)) + return {}; + if (TrackedDepthStencil && !RightStencilSynchronized && + StencilTestActive(device)) + return {}; + + OutRunVRRenderer::LatchedStereoFrame stereo{}; + if (!OutRunVRRenderer::GetLatchedStereoFrame(stereo) || + stereo.poseSequence == 0) + return {}; + if (FrameStereoPoseSequence != 0 && + FrameStereoPoseSequence != stereo.poseSequence) + return {}; + + DrawStereoState draw{}; + if (!R31BuildFastWorldConstants(device, stereo, draw)) + return {}; + + D3DVIEWPORT9 savedViewport{}; + if (!R31GetSavedViewport(device, savedViewport)) + return {}; + + bool leftWvpOk = false; + { + InternalPassScope guard; + leftWvpOk = SetWvpOneRegisterAtATime( + device, draw.eyeConstants[0]); + } + if (!leftWvpOk) + { + bool rolledBack = false; + { + InternalPassScope guard; + rolledBack = SetWvpOneRegisterAtATime( + device, draw.originalConstants); + } + if (!rolledBack) + { + R9Poison(OutRunVR::StereoFailureRestoreFailed, + "R31/fast-left-WVP-rollback"); + NoteRestoreFailure("R31 fast left-eye c64 rollback"); + R29ArmMonoSafety(); + return { true, E_FAIL }; + } + return {}; + } + + ++R9DrawCalls; + R9MonoBackupGap = true; + if (LeftDrawMayWriteDepth(device) || LeftDrawMayWriteStencil(device)) + ++R9MainDepthContentSerial; + + R31OwnedResult result{ true, D3D_OK }; + result.hr = actualDraw(); + if (FAILED(result.hr)) + { + InvalidateRightDepthStencilIfLeftMayWrite(device); + R9Poison(OutRunVR::StereoFailureLeftDrawFailed, site, result.hr); + bool restored = false; + { + InternalPassScope guard; + restored = SetWvpOneRegisterAtATime( + device, draw.originalConstants); + } + if (!restored) NoteRestoreFailure("R31 fast left draw c64"); + R29ArmMonoSafety(); + return result; + } + + IDirect3DSurface9* savedRt = TrackedRenderTarget; + IDirect3DSurface9* savedDepth = TrackedDepthStencil; + HRESULT rightHr = D3D_OK; + OutRunVR::StereoFailureReason rightFailure = + OutRunVR::StereoFailureRightStateFailed; + bool restoreOk = true; + { + InternalPassScope guard; + rightHr = SetRenderTargetHook.stdcall( + device, 0u, RightEyeSurface); + if (SUCCEEDED(rightHr)) + rightHr = SetDepthStencilSurfaceHook.stdcall( + device, TrackedDepthStencil ? RightEyeDepth : nullptr); + if (SUCCEEDED(rightHr)) + rightHr = device->SetViewport(&savedViewport); + if (SUCCEEDED(rightHr) && !SetWvpOneRegisterAtATime( + device, draw.eyeConstants[1])) + { + rightFailure = OutRunVR::StereoFailureRightWvpUploadFailed; + rightHr = E_FAIL; + } + if (SUCCEEDED(rightHr)) + { + rightFailure = OutRunVR::StereoFailureRightDrawFailed; + rightHr = actualDraw(); + } + restoreOk = RestoreRightPassState(device, savedRt, savedDepth, + savedViewport, draw.originalConstants, true); + } + + FrameHadDuplicatedDraw = true; + FrameHadWorldStereo = true; + ++DuplicatedDraws; + ++WorldStereoDraws; + ++R29StableTwoEyeDraws; + ++R31FastWorldDraws; + ++R31Frame.fastWorld; + + if (FrameStereoPoseSequence == 0) + { + FrameStereoPoseSequence = draw.poseSequence; + FrameStereoMetadata = draw.stereoFrame; + } + + if (!R31FirstFastWorldLogged) + { + R31FirstFastWorldLogged = true; + spdlog::info( + "VR R31 PERF: verified world hot path ACTIVE; steady draws use renderer-authoritative c64 + cached eye tails, live D3D c64 validation every {} draws", + LiveWvpValidationInterval); + } + + if (FAILED(rightHr)) + { + FrameRightDrawFailed = true; + InvalidateRightDepthStencilIfLeftMayWrite(device); + R9Poison(rightFailure, site, rightHr); + R29ArmMonoSafety(); + } + if (!restoreOk) + { + InvalidateRightDepthStencilIfLeftMayWrite(device); + NoteRestoreFailure("R31 fast right-eye draw"); + R29ArmMonoSafety(); + } + return result; + } + + template + R31OwnedResult R31TryHud(IDirect3DDevice9* device, + ActualDraw&& actualDraw, const char* site) + { + if (R31StateBlockRecording || !R29StableStereoBase(device) || + !R30CurrentPassIsScreenSpace2D()) + return {}; + if (!R31StateBlockTrackingReliable.load(std::memory_order_acquire)) + { + const std::uintptr_t cachedShader = + CurrentVertexShaderIdentity.load(std::memory_order_acquire); + if (!R31LiveShaderMatches(device, cachedShader)) + return {}; + } + if (!EnsureStereoResources(device)) + return {}; + if (TrackedDepthStencil && + (!RightDepthSynchronized || !RightStencilSynchronized)) + TryBootstrapRightDepthFromRecentClear(device); + if (TrackedDepthStencil && !RightDepthSynchronized && + DepthTestActive(device)) + return {}; + if (TrackedDepthStencil && !RightStencilSynchronized && + StencilTestActive(device)) + return {}; + + OutRunVRRenderer::LatchedStereoFrame stereo{}; + if (!OutRunVRRenderer::GetLatchedStereoFrame(stereo) || + stereo.poseSequence == 0) + return {}; + if (FrameStereoPoseSequence != 0 && + FrameStereoPoseSequence != stereo.poseSequence) + return {}; + + float original[16]{}; + float eyeConstants[2][16]{}; + float eyeScale[2]{}; + float eyeOffset[2]{}; + if (!R30BuildScreenSpaceEyeConstants(device, stereo, original, + eyeConstants, eyeScale, eyeOffset)) + return {}; + + D3DVIEWPORT9 savedViewport{}; + if (!R31GetSavedViewport(device, savedViewport)) + return {}; + + bool leftWvpOk = false; + { + InternalPassScope guard; + leftWvpOk = SetWvpOneRegisterAtATime(device, eyeConstants[0]); + } + if (!leftWvpOk) + { + bool rolledBack = false; + { + InternalPassScope guard; + rolledBack = SetWvpOneRegisterAtATime(device, original); + } + if (!rolledBack) + { + R9Poison(OutRunVR::StereoFailureRestoreFailed, + "R31/HUD-left-WVP-rollback"); + NoteRestoreFailure("R31 HUD left-eye c64 rollback"); + R29ArmMonoSafety(); + return { true, E_FAIL }; + } + return {}; + } + + ++R9DrawCalls; + R9MonoBackupGap = true; + if (LeftDrawMayWriteDepth(device) || LeftDrawMayWriteStencil(device)) + ++R9MainDepthContentSerial; + + R31OwnedResult result{ true, actualDraw() }; + if (FAILED(result.hr)) + { + bool restored = false; + { + InternalPassScope guard; + restored = SetWvpOneRegisterAtATime(device, original); + } + R9Poison(OutRunVR::StereoFailureLeftDrawFailed, site, result.hr); + if (!restored) NoteRestoreFailure("R31 HUD left draw c64"); + R29ArmMonoSafety(); + return result; + } + + IDirect3DSurface9* savedRt = TrackedRenderTarget; + IDirect3DSurface9* savedDepth = TrackedDepthStencil; + HRESULT rightHr = D3D_OK; + OutRunVR::StereoFailureReason rightFailure = + OutRunVR::StereoFailureRightStateFailed; + bool restoreOk = true; + { + InternalPassScope guard; + rightHr = SetRenderTargetHook.stdcall( + device, 0u, RightEyeSurface); + if (SUCCEEDED(rightHr)) + rightHr = SetDepthStencilSurfaceHook.stdcall( + device, TrackedDepthStencil ? RightEyeDepth : nullptr); + if (SUCCEEDED(rightHr)) + rightHr = device->SetViewport(&savedViewport); + if (SUCCEEDED(rightHr) && + !SetWvpOneRegisterAtATime(device, eyeConstants[1])) + { + rightFailure = OutRunVR::StereoFailureRightWvpUploadFailed; + rightHr = E_FAIL; + } + if (SUCCEEDED(rightHr)) + { + rightFailure = OutRunVR::StereoFailureRightDrawFailed; + rightHr = actualDraw(); + } + restoreOk = RestoreRightPassState(device, savedRt, savedDepth, + savedViewport, original, true); + } + + FrameHadDuplicatedDraw = true; + ++DuplicatedDraws; + ++NonWorldDuplicatedDraws; + ++R29StableTwoEyeDraws; + ++R30ScreenSpaceFovDraws; + ++R31HudDraws; + ++R31Frame.hud; + + if (!R31FirstHudLogged) + { + R31FirstHudLogged = true; + spdlog::info( + "VR R31 HUD: explicit handled-result path ACTIVE; E_NOTIMPL can no longer trigger a second draw after an owned HUD submission"); + } + + if (FAILED(rightHr)) + { + FrameRightDrawFailed = true; + InvalidateRightDepthStencilIfLeftMayWrite(device); + R9Poison(rightFailure, site, rightHr); + R29ArmMonoSafety(); + } + if (!restoreOk) + { + InvalidateRightDepthStencilIfLeftMayWrite(device); + NoteRestoreFailure("R31 HUD right-eye draw"); + R29ArmMonoSafety(); + } + return result; + } + + template + HRESULT R31Dispatch(IDirect3DDevice9* device, ActualDraw&& actualDraw, + R29Draw&& r29Draw, const char* site) + { + R31ObserveDraw(device); + R31DiscardUnreliableDrawCaches(); + + if (R31StateBlockRecording) + { + ++R31Frame.fallback; + return actualDraw(); + } + + if (R30CurrentPassIsScreenSpace2D()) + { + const auto hud = R31TryHud(device, + std::forward(actualDraw), site); + if (hud.handled) + return hud.hr; + } + else + { + const auto fast = R31TryFastWorld(device, + std::forward(actualDraw), site); + if (fast.handled) + return fast.hr; + } + + ++R31Frame.fallback; + return r29Draw(); + } + + HRESULT __stdcall DrawPrimitiveDestR31(IDirect3DDevice9* device, + D3DPRIMITIVETYPE type, UINT startVertex, UINT primitiveCount) + { + auto actual = [&]() { + return DrawPrimitiveHook.stdcall( + device, type, startVertex, primitiveCount); + }; + auto r29 = [&]() { + return R30DrawPrimitiveR29Hook.stdcall( + device, type, startVertex, primitiveCount); + }; + return R31Dispatch(device, actual, r29, "R31/DrawPrimitive"); + } + + HRESULT __stdcall DrawIndexedPrimitiveDestR31( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + INT baseVertexIndex, UINT minVertexIndex, UINT numVertices, + UINT startIndex, UINT primitiveCount) + { + auto actual = [&]() { + return DrawIndexedPrimitiveHook.stdcall(device, type, + baseVertexIndex, minVertexIndex, numVertices, startIndex, + primitiveCount); + }; + auto r29 = [&]() { + return R30DrawIndexedPrimitiveR29Hook.stdcall(device, type, + baseVertexIndex, minVertexIndex, numVertices, startIndex, + primitiveCount); + }; + return R31Dispatch(device, actual, r29, + "R31/DrawIndexedPrimitive"); + } + + HRESULT __stdcall DrawPrimitiveUPDestR31(IDirect3DDevice9* device, + D3DPRIMITIVETYPE type, UINT primitiveCount, const void* data, + UINT stride) + { + auto actual = [&]() { + return DrawPrimitiveUPHook.stdcall( + device, type, primitiveCount, data, stride); + }; + auto r29 = [&]() { + return R30DrawPrimitiveUPR29Hook.stdcall( + device, type, primitiveCount, data, stride); + }; + return R31Dispatch(device, actual, r29, "R31/DrawPrimitiveUP"); + } + + HRESULT __stdcall DrawIndexedPrimitiveUPDestR31( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + UINT minVertexIndex, UINT numVertices, UINT primitiveCount, + const void* indexData, D3DFORMAT indexFormat, + const void* vertexData, UINT stride) + { + auto actual = [&]() { + return DrawIndexedPrimitiveUPHook.stdcall(device, type, + minVertexIndex, numVertices, primitiveCount, indexData, + indexFormat, vertexData, stride); + }; + auto r29 = [&]() { + return R30DrawIndexedPrimitiveUPR29Hook.stdcall(device, + type, minVertexIndex, numVertices, primitiveCount, + indexData, indexFormat, vertexData, stride); + }; + return R31Dispatch(device, actual, r29, + "R31/DrawIndexedPrimitiveUP"); + } + + void R31BlockCurrentVerifiedGeneration() noexcept + { + float ignored[16]{}; + std::uint32_t generation = 0, pose = 0; + std::uintptr_t shader = 0; + std::uint64_t serial = 0; + if (OutRunVRRenderer::GetLastVerifiedWvp( + ignored, generation, pose, shader, serial)) + R31BlockedVerifiedGeneration = generation; + R31EyeCache.valid = false; + } + + void R31ResynchronizeShaderEpoch(IDirect3DDevice9* device) noexcept + { + IDirect3DVertexShader9* shader = nullptr; + const HRESULT hr = device + ? device->GetVertexShader(&shader) : D3DERR_INVALIDCALL; + const std::uintptr_t identity = SUCCEEDED(hr) + ? reinterpret_cast(shader) : 0; + if (shader) shader->Release(); + + const std::uintptr_t previous = + CurrentVertexShaderIdentity.exchange(identity, + std::memory_order_acq_rel); + if (previous != identity) + { + std::uint64_t serial = VertexShaderSerial.fetch_add( + 1, std::memory_order_acq_rel) + 1; + if (serial == 0) + VertexShaderSerial.fetch_add(1, std::memory_order_acq_rel); + } + } + + void R31MarkStateBlockCachesDirty() noexcept + { + R31BlockCurrentVerifiedGeneration(); + OutRunVRRenderer::R29InvalidateRendererStateAfterExternalRestore(); + R29Effect = {}; + R22ShadowState = {}; + R23LastStateSampleDrawSerial = 0; + R23LastStateSampleEpoch = 0; + R31EyeCache.valid = false; + R31StateBlockResyncPending = true; + } + + void R31FlushPendingStateBlockResync(IDirect3DDevice9* device) noexcept + { + if (!R31StateBlockResyncPending || !device) + return; + R31StateBlockResyncPending = false; + R31ResynchronizeShaderEpoch(device); + if (!R22PrimeShadowState(device)) + { + R31StateBlockTrackingReliable.store(false, + std::memory_order_release); + R31StateBlockCoverageLost.store(true, + std::memory_order_release); + } + } + + HRESULT __stdcall StateBlockApplyDestR31(IDirect3DStateBlock9* block) + { + const HRESULT hr = R31StateBlockApplyHook.stdcall(block); + if (!block) + return hr; + IDirect3DDevice9* device = nullptr; + if (SUCCEEDED(block->GetDevice(&device)) && device) + { + const bool game = IsGameDevice(device); + if (game) + { + ++R31StateBlockApplies; + R31MarkStateBlockCachesDirty(); + if (!R31FirstStateBlockLogged) + { + R31FirstStateBlockLogged = true; + spdlog::info( + "VR R31 STATE: StateBlock::Apply observed hr=0x{:08x}; caches invalidate immediately and live D3D state is lazily re-primed at the next actual draw", + static_cast(hr)); + } + } + device->Release(); + } + else + { + R31StateBlockCoverageLost.store(true, + std::memory_order_release); + R31StateBlockTrackingReliable.store(false, + std::memory_order_release); + } + return hr; + } + + bool R31EnsureStateBlockApplyHook(IDirect3DStateBlock9* block) noexcept + { + if (!block) + { + R31StateBlockCoverageLost.store(true, + std::memory_order_release); + R31StateBlockTrackingReliable.store(false, + std::memory_order_release); + return false; + } + void** vtable = *reinterpret_cast(block); + if (!vtable) + { + R31StateBlockCoverageLost.store(true, + std::memory_order_release); + R31StateBlockTrackingReliable.store(false, + std::memory_order_release); + return false; + } + + if (R31StateBlockApplyHook) + { + const bool reliable = R31CreateStateBlockHook && + R31BeginStateBlockHook && R31EndStateBlockHook && + !R31StateBlockCoverageLost.load(std::memory_order_acquire) && + R31StateBlockApplyTarget == + vtable[StateBlockApplyVtableIndex]; + if (!reliable && R31StateBlockApplyTarget != + vtable[StateBlockApplyVtableIndex]) + { + R31StateBlockCoverageLost.store(true, + std::memory_order_release); + if (!R31FirstAlternateStateBlockLogged) + { + R31FirstAlternateStateBlockLogged = true; + spdlog::warn( + "VR R31 STATE: alternate StateBlock::Apply implementation observed; fast-path cache trust is disabled for the process"); + } + } + R31StateBlockTrackingReliable.store(reliable, + std::memory_order_release); + return reliable; + } + R31StateBlockApplyHook = safetyhook::create_inline( + vtable[StateBlockApplyVtableIndex], StateBlockApplyDestR31, + safetyhook::InlineHook::StartDisabled); + if (!R31StateBlockApplyHook || + !R31StateBlockApplyHook.enable().has_value()) + { + R31StateBlockApplyHook = {}; + R31StateBlockApplyTarget = nullptr; + R31StateBlockCoverageLost.store(true, + std::memory_order_release); + R31StateBlockTrackingReliable.store(false, + std::memory_order_release); + spdlog::warn( + "VR R31 STATE: could not hook StateBlock::Apply; per-draw live WVP/shader/render-state validation remains active"); + return false; + } + R31StateBlockApplyTarget = + vtable[StateBlockApplyVtableIndex]; + const bool reliable = R31CreateStateBlockHook && + R31BeginStateBlockHook && R31EndStateBlockHook && + !R31StateBlockCoverageLost.load(std::memory_order_acquire); + R31StateBlockTrackingReliable.store(reliable, + std::memory_order_release); + return reliable; + } + + HRESULT __stdcall CreateStateBlockDestR31(IDirect3DDevice9* device, + D3DSTATEBLOCKTYPE type, IDirect3DStateBlock9** block) + { + const HRESULT hr = R31CreateStateBlockHook.stdcall( + device, type, block); + if (SUCCEEDED(hr) && IsGameDevice(device) && block && *block) + R31EnsureStateBlockApplyHook(*block); + return hr; + } + + HRESULT __stdcall BeginStateBlockDestR31(IDirect3DDevice9* device) + { + const HRESULT hr = R31BeginStateBlockHook.stdcall(device); + if (SUCCEEDED(hr) && IsGameDevice(device) && !InternalStereoPass) + { + R31StateBlockRecording = true; + ++R31StateBlockRecordings; + R31MarkStateBlockCachesDirty(); + } + return hr; + } + + HRESULT __stdcall EndStateBlockDestR31(IDirect3DDevice9* device, + IDirect3DStateBlock9** block) + { + const HRESULT hr = R31EndStateBlockHook.stdcall(device, block); + if (IsGameDevice(device) && + (!InternalStereoPass || R31StateBlockRecording)) + { + if (SUCCEEDED(hr)) + { + R31StateBlockRecording = false; + } + else if (R31StateBlockRecording) + { + R31StateBlockCoverageLost.store(true, + std::memory_order_release); + R31StateBlockTrackingReliable.store(false, + std::memory_order_release); + } + R31MarkStateBlockCachesDirty(); + if (SUCCEEDED(hr) && block && *block) + R31EnsureStateBlockApplyHook(*block); + } + return hr; + } + + void R31RollbackDrawHooks() noexcept + { + R31DrawIndexedPrimitiveUPR30Hook = {}; + R31DrawPrimitiveUPR30Hook = {}; + R31DrawIndexedPrimitiveR30Hook = {}; + R31DrawPrimitiveR30Hook = {}; + } + + bool R31EnableDrawHooks() noexcept + { + SafetyHookInline* hooks[]{ + &R31DrawPrimitiveR30Hook, + &R31DrawIndexedPrimitiveR30Hook, + &R31DrawPrimitiveUPR30Hook, + &R31DrawIndexedPrimitiveUPR30Hook + }; + for (auto* hook : hooks) + if (!*hook || !hook->enable().has_value()) + return false; + return true; + } + + DWORD WINAPI R31InstallThread(void*) + { + using State = OutRunVR::RuntimeEligibility::InstallState; + R31InstallState.store(State::Pending, std::memory_order_release); + + for (int attempt = 0; attempt < 4800; ++attempt) + { + const auto r30 = R30InstallState.load(std::memory_order_acquire); + const auto renderer = OutRunVRRenderer::R29RendererState(); + if (r30 == State::Failed || renderer == State::Failed) + { + R31InstallState.store(State::Failed, std::memory_order_release); + HookManager::ReportAsyncResult("OpenXRVRStereoR31Perf", false); + spdlog::error( + "VR R31 PERF: R30 or renderer prerequisite failed; R30 remains authoritative"); + return 0; + } + + if (r30 == State::Ready && renderer == State::Ready) + { + const auto disabled = safetyhook::InlineHook::StartDisabled; + R31DrawPrimitiveR30Hook = safetyhook::create_inline( + reinterpret_cast(&DrawPrimitiveDestR30), + DrawPrimitiveDestR31, disabled); + R31DrawIndexedPrimitiveR30Hook = safetyhook::create_inline( + reinterpret_cast(&DrawIndexedPrimitiveDestR30), + DrawIndexedPrimitiveDestR31, disabled); + R31DrawPrimitiveUPR30Hook = safetyhook::create_inline( + reinterpret_cast(&DrawPrimitiveUPDestR30), + DrawPrimitiveUPDestR31, disabled); + R31DrawIndexedPrimitiveUPR30Hook = safetyhook::create_inline( + reinterpret_cast(&DrawIndexedPrimitiveUPDestR30), + DrawIndexedPrimitiveUPDestR31, disabled); + + if (!R31EnableDrawHooks()) + { + R31RollbackDrawHooks(); + R31InstallState.store(State::Failed, + std::memory_order_release); + HookManager::ReportAsyncResult( + "OpenXRVRStereoR31Perf", false); + return 0; + } + + IDirect3DDevice9* const device = + StereoInstalledDevice.load(std::memory_order_acquire); + R31StateBlockTrackingReliable.store(false, + std::memory_order_release); + R31StateBlockCoverageLost.store(false, + std::memory_order_release); + if (device) + { + void** vtable = *reinterpret_cast(device); + if (vtable) + { + R31CreateStateBlockHook = safetyhook::create_inline( + vtable[CreateStateBlockVtableIndex], + CreateStateBlockDestR31, disabled); + R31BeginStateBlockHook = safetyhook::create_inline( + vtable[BeginStateBlockVtableIndex], + BeginStateBlockDestR31, disabled); + R31EndStateBlockHook = safetyhook::create_inline( + vtable[EndStateBlockVtableIndex], + EndStateBlockDestR31, disabled); + const bool stateHooks = R31CreateStateBlockHook && + R31BeginStateBlockHook && + R31EndStateBlockHook && + // Arm End before Begin so a render-thread race + // can never observe an unmatched recording start. + R31EndStateBlockHook.enable().has_value() && + R31BeginStateBlockHook.enable().has_value() && + R31CreateStateBlockHook.enable().has_value(); + if (!stateHooks) + { + R31StateBlockCoverageLost.store(true, + std::memory_order_release); + R31CreateStateBlockHook = {}; + R31BeginStateBlockHook = {}; + R31EndStateBlockHook = {}; + spdlog::warn( + "VR R31 STATE: Begin/Create/End StateBlock hooks unavailable; every fast-path candidate will live-validate WVP, shader, render state and viewport"); + } + else + { + spdlog::info( + "VR R31 STATE: Begin/Create/End StateBlock recording hooks armed; per-draw validation remains active until Apply interception is proven"); + } + } + } + + R31InstallState.store(State::Ready, std::memory_order_release); + HookManager::ReportAsyncResult("OpenXRVRStereoR31Perf", true); + spdlog::info( + "VR R31 PERF: cached world stereo + draw-route telemetry READY; R30 HUD sentinel path superseded"); + return 0; + } + Sleep(25); + } + + R31InstallState.store(State::Failed, std::memory_order_release); + HookManager::ReportAsyncResult("OpenXRVRStereoR31Perf", false); + spdlog::error("VR R31 PERF: timed out waiting for R30"); + return 0; + } + + class VRStereoR31PerfHook final : public Hook + { + public: + std::string_view description() override + { + return "OpenXRVRStereoR31Perf"; + } + bool validate() override { return true; } + bool apply() override + { + HANDLE thread = CreateThread(nullptr, 0, + R31InstallThread, nullptr, 0, nullptr); + if (!thread) + { + R31InstallState.store( + OutRunVR::RuntimeEligibility::InstallState::Failed, + std::memory_order_release); + return false; + } + CloseHandle(thread); + return true; + } + static VRStereoR31PerfHook instance; + }; + + VRStereoR31PerfHook VRStereoR31PerfHook::instance; + } + + bool IsGameStateBlockRecording() noexcept + { + return R31StateBlockRecording; + } + + bool IsStateBlockTrackingReliable() noexcept + { + return R31StateBlockTrackingReliable.load(std::memory_order_acquire); + } +} diff --git a/src/vr/d3d9/stereo_renderer_r32.cpp b/src/vr/d3d9/stereo_renderer_r32.cpp new file mode 100644 index 000000000..68195d3d6 --- /dev/null +++ b/src/vr/d3d9/stereo_renderer_r32.cpp @@ -0,0 +1,1124 @@ +// R32 review-consolidation overlay. +// +// Keeps R31/R29/R14 correctness policy while consolidating the reviewed hot +// paths. Review-2 additionally makes R22 the reset owner and prevents reuse of a +// DirectGPU producer slot while a timed-out D3D9 EVENT query is still pending. + +#include "r32_policy.hpp" +#include "stereo_renderer_r31.cpp" + +namespace OutRunVRStereo +{ + namespace + { + SafetyHookInline R32ResetR22Hook{}; + SafetyHookInline R32ResolveDirectR13Hook{}; + SafetyHookInline R32PresentR13Hook{}; + SafetyHookInline R32DrawPrimitiveR31Hook{}; + SafetyHookInline R32DrawIndexedPrimitiveR31Hook{}; + SafetyHookInline R32DrawPrimitiveUPR31Hook{}; + SafetyHookInline R32DrawIndexedPrimitiveUPR31Hook{}; + + std::atomic R32InstallState{ + OutRunVR::RuntimeEligibility::InstallState::Pending }; + + std::uint64_t R32BatchWvpUploads = 0; + std::uint64_t R32BatchWvpFailures = 0; + std::uint64_t R32StateSnapshotFailures = 0; + std::uint64_t R32FailClosedZeroDisparityDraws = 0; + std::uint64_t R32ResetEpochRearms = 0; + std::uint64_t R32ResetFailures = 0; + std::uint64_t R32DirectProbeCacheHits = 0; + std::uint64_t R32DirectFenceSuccess = 0; + std::uint64_t R32DirectFenceBudgetFallbacks = 0; + std::uint64_t R32DirectIdentityInvalidations = 0; + std::uint64_t R32PendingFenceDrains = 0; + std::uint64_t R32PendingFenceBlocks = 0; + std::uint64_t R32PendingFenceErrors = 0; + bool R32FirstStateSnapshotFailureLogged = false; + bool R32FirstBatchWvpLogged = false; + bool R32FirstFenceBudgetLogged = false; + bool R32FirstResetRearmLogged = false; + bool R32FirstPendingFenceLogged = false; + bool R32FirstDirectCopyRejectLogged = false; + bool R32DirectCopyPathRejected = false; + HRESULT R32DirectCopyRejectHr = D3D_OK; + + std::uint32_t R32DirectHostPid = 0; + std::uint32_t R32DirectHostLuidLow = 0; + std::uint32_t R32DirectHostLuidHigh = 0; + std::array R32ProducerFencePending{}; + std::array R32ProducerPendingFrame{}; + + struct R32CounterSnapshot + { + ULONGLONG lastLogMs = 0; + std::uint64_t liveWvp = 0; + std::uint64_t liveReject = 0; + std::uint64_t stateRecord = 0; + std::uint64_t stateApply = 0; + std::uint64_t batch = 0; + std::uint64_t batchFail = 0; + std::uint64_t stateFail = 0; + std::uint64_t zeroFallback = 0; + std::uint64_t directCache = 0; + std::uint64_t directFenceOk = 0; + std::uint64_t directFenceFallback = 0; + std::uint64_t directBackpressure = 0; + std::uint64_t pendingDrain = 0; + std::uint64_t pendingBlock = 0; + std::uint64_t pendingError = 0; + std::uint64_t resetRearm = 0; + std::uint64_t resetFail = 0; + }; + R32CounterSnapshot R32Counters{}; + + struct R32EffectSnapshot + { + DWORD alphaBlend = FALSE; + DWORD alphaTest = FALSE; + DWORD zWrite = TRUE; + DWORD zEnable = D3DZB_TRUE; + DWORD cullMode = D3DCULL_CCW; + }; + + bool R32ReadEffectSnapshot(IDirect3DDevice9* device, + R32EffectSnapshot& out) noexcept + { + if (!device) + return false; + const bool ok = + SUCCEEDED(device->GetRenderState( + D3DRS_ALPHABLENDENABLE, &out.alphaBlend)) && + SUCCEEDED(device->GetRenderState( + D3DRS_ALPHATESTENABLE, &out.alphaTest)) && + SUCCEEDED(device->GetRenderState( + D3DRS_ZWRITEENABLE, &out.zWrite)) && + SUCCEEDED(device->GetRenderState( + D3DRS_ZENABLE, &out.zEnable)) && + SUCCEEDED(device->GetRenderState(D3DRS_CULLMODE, &out.cullMode)); + if (!ok) + { + ++R32StateSnapshotFailures; + if (!R32FirstStateSnapshotFailureLogged) + { + R32FirstStateSnapshotFailureLogged = true; + spdlog::warn( + "VR R32 SAFETY: render-state snapshot unavailable; draw is forced to stock-WVP zero disparity instead of fail-open world stereo"); + } + } + return ok; + } + + bool R32EffectIsFragileLive(IDirect3DDevice9* device, + bool& fragile) noexcept + { + R32EffectSnapshot state{}; + if (!R32ReadEffectSnapshot(device, state)) + return false; + const auto policy = OutRunVR::PassPolicy::ClassifyEffectStereo( + state.alphaBlend != FALSE, + state.alphaTest != FALSE, + state.zWrite != FALSE, + state.zEnable != D3DZB_FALSE, + state.cullMode == D3DCULL_NONE); + fragile = !OutRunVR::PassPolicy::AllowsEffectWorldStereo(policy); + return true; + } + + bool R32SetWvpBatch(IDirect3DDevice9* device, + const float* constants) noexcept + { + if (!device || !constants) + return false; + if (Settings::VRTelemetry) + ++R32BatchWvpUploads; + const HRESULT hr = device->SetVertexShaderConstantF( + OutRunWvpRegister, constants, OutRunWvpRegisterCount); + if (FAILED(hr)) + { + ++R32BatchWvpFailures; + return false; + } + if (Settings::VRTelemetry && !R32FirstBatchWvpLogged) + { + R32FirstBatchWvpLogged = true; + spdlog::info( + "VR R32 PERF: verified stereo WVP uploads are batched as one c64..c67 call instead of four register calls"); + } + return true; + } + + bool R32GetSavedViewport(IDirect3DDevice9* device, + D3DVIEWPORT9& viewport) noexcept + { + if (!device) + return false; + if (R31StateBlockTrackingReliable.load(std::memory_order_acquire)) + return R31GetSavedViewport(device, viewport); + return SUCCEEDED(device->GetViewport(&viewport)); + } + + bool R32RestoreRightPassState(IDirect3DDevice9* device, + IDirect3DSurface9* savedRt, IDirect3DSurface9* savedDepth, + const D3DVIEWPORT9& savedViewport, + const float* originalConstants, bool restoreWvp) noexcept + { + bool ok = true; + if (savedRt && FAILED(SetRenderTargetHook.stdcall( + device, 0u, savedRt))) + ok = false; + const HRESULT depthHr = SetDepthStencilSurfaceHook + ? SetDepthStencilSurfaceHook.stdcall(device, savedDepth) + : device->SetDepthStencilSurface(savedDepth); + if (FAILED(depthHr)) ok = false; + if (FAILED(device->SetViewport(&savedViewport))) ok = false; + if (restoreWvp && !R32SetWvpBatch(device, originalConstants)) ok = false; + return ok; + } + + template + R31OwnedResult R32TryFastWorld(IDirect3DDevice9* device, + ActualDraw&& actualDraw, const char* site) + { + if (R31StateBlockRecording || !R29StableStereoBase(device)) + { + if (IsGameDevice(device) && !InternalStereoPass && TargetIsBackBuffer()) + ++R31Frame.unstable; + return {}; + } + + bool fragile = true; + const bool stateBlocksReliable = + R31StateBlockTrackingReliable.load(std::memory_order_acquire); + const bool effectKnown = stateBlocksReliable + ? R29FragileEffectCached(device, fragile) + : R32EffectIsFragileLive(device, fragile); + if (!effectKnown) + return {}; + if (fragile) + { + ++R31Frame.fragile; + return {}; + } + + if (!EnsureStereoResources(device)) + return {}; + if (TrackedDepthStencil && + (!RightDepthSynchronized || !RightStencilSynchronized)) + TryBootstrapRightDepthFromRecentClear(device); + if (TrackedDepthStencil && !RightDepthSynchronized && + DepthTestActive(device)) + return {}; + if (TrackedDepthStencil && !RightStencilSynchronized && + StencilTestActive(device)) + return {}; + + OutRunVRRenderer::LatchedStereoFrame stereo{}; + if (!OutRunVRRenderer::GetLatchedStereoFrame(stereo) || + stereo.poseSequence == 0) + return {}; + if (FrameStereoPoseSequence != 0 && + FrameStereoPoseSequence != stereo.poseSequence) + return {}; + + DrawStereoState draw{}; + if (!R31BuildFastWorldConstants(device, stereo, draw)) + return {}; + + D3DVIEWPORT9 savedViewport{}; + if (!R32GetSavedViewport(device, savedViewport)) + return {}; + + bool leftWvpOk = false; + { + InternalPassScope guard; + leftWvpOk = R32SetWvpBatch(device, draw.eyeConstants[0]); + } + if (!leftWvpOk) + { + bool rolledBack = false; + { + InternalPassScope guard; + rolledBack = R32SetWvpBatch(device, draw.originalConstants); + } + if (!rolledBack) + { + R9Poison(OutRunVR::StereoFailureRestoreFailed, + "R32/fast-left-WVP-rollback"); + NoteRestoreFailure("R32 fast left-eye c64 rollback"); + R29ArmMonoSafety(); + return { true, E_FAIL }; + } + return {}; + } + + ++R9DrawCalls; + R9MonoBackupGap = true; + if (LeftDrawMayWriteDepth(device) || LeftDrawMayWriteStencil(device)) + ++R9MainDepthContentSerial; + + R31OwnedResult result{ true, D3D_OK }; + result.hr = actualDraw(); + if (FAILED(result.hr)) + { + InvalidateRightDepthStencilIfLeftMayWrite(device); + R9Poison(OutRunVR::StereoFailureLeftDrawFailed, site, result.hr); + bool restored = false; + { + InternalPassScope guard; + restored = R32SetWvpBatch(device, draw.originalConstants); + } + if (!restored) NoteRestoreFailure("R32 fast left draw c64"); + R29ArmMonoSafety(); + return result; + } + + IDirect3DSurface9* savedRt = TrackedRenderTarget; + IDirect3DSurface9* savedDepth = TrackedDepthStencil; + HRESULT rightHr = D3D_OK; + OutRunVR::StereoFailureReason rightFailure = + OutRunVR::StereoFailureRightStateFailed; + bool restoreOk = true; + { + InternalPassScope guard; + rightHr = SetRenderTargetHook.stdcall( + device, 0u, RightEyeSurface); + if (SUCCEEDED(rightHr)) + rightHr = SetDepthStencilSurfaceHook.stdcall( + device, TrackedDepthStencil ? RightEyeDepth : nullptr); + if (SUCCEEDED(rightHr)) + rightHr = device->SetViewport(&savedViewport); + if (SUCCEEDED(rightHr) && + !R32SetWvpBatch(device, draw.eyeConstants[1])) + { + rightFailure = OutRunVR::StereoFailureRightWvpUploadFailed; + rightHr = E_FAIL; + } + if (SUCCEEDED(rightHr)) + { + rightFailure = OutRunVR::StereoFailureRightDrawFailed; + rightHr = actualDraw(); + } + restoreOk = R32RestoreRightPassState(device, savedRt, savedDepth, + savedViewport, draw.originalConstants, true); + } + + FrameHadDuplicatedDraw = true; + FrameHadWorldStereo = true; + ++DuplicatedDraws; + ++WorldStereoDraws; + ++R29StableTwoEyeDraws; + ++R31FastWorldDraws; + ++R31Frame.fastWorld; + + if (FrameStereoPoseSequence == 0) + { + FrameStereoPoseSequence = draw.poseSequence; + FrameStereoMetadata = draw.stereoFrame; + } + + if (FAILED(rightHr)) + { + FrameRightDrawFailed = true; + InvalidateRightDepthStencilIfLeftMayWrite(device); + R9Poison(rightFailure, site, rightHr); + R29ArmMonoSafety(); + } + if (!restoreOk) + { + InvalidateRightDepthStencilIfLeftMayWrite(device); + NoteRestoreFailure("R32 fast right-eye draw"); + R29ArmMonoSafety(); + } + return result; + } + + template + R31OwnedResult R32TryHud(IDirect3DDevice9* device, + ActualDraw&& actualDraw, const char* site) + { + if (R31StateBlockRecording || !R29StableStereoBase(device) || + !R30CurrentPassIsScreenSpace2D()) + return {}; + if (!R31StateBlockTrackingReliable.load(std::memory_order_acquire)) + { + R31DiscardUnreliableDrawCaches(); + const std::uintptr_t cachedShader = + CurrentVertexShaderIdentity.load(std::memory_order_acquire); + if (!R31LiveShaderMatches(device, cachedShader)) + return {}; + } + if (!EnsureStereoResources(device)) + return {}; + if (TrackedDepthStencil && + (!RightDepthSynchronized || !RightStencilSynchronized)) + TryBootstrapRightDepthFromRecentClear(device); + if (TrackedDepthStencil && !RightDepthSynchronized && + DepthTestActive(device)) + return {}; + if (TrackedDepthStencil && !RightStencilSynchronized && + StencilTestActive(device)) + return {}; + + OutRunVRRenderer::LatchedStereoFrame stereo{}; + if (!OutRunVRRenderer::GetLatchedStereoFrame(stereo) || + stereo.poseSequence == 0) + return {}; + if (FrameStereoPoseSequence != 0 && + FrameStereoPoseSequence != stereo.poseSequence) + return {}; + + float original[16]{}; + float eyeConstants[2][16]{}; + float eyeScale[2]{}; + float eyeOffset[2]{}; + if (!R30BuildScreenSpaceEyeConstants(device, stereo, original, + eyeConstants, eyeScale, eyeOffset)) + return {}; + + D3DVIEWPORT9 savedViewport{}; + if (!R32GetSavedViewport(device, savedViewport)) + return {}; + + bool leftWvpOk = false; + { + InternalPassScope guard; + leftWvpOk = R32SetWvpBatch(device, eyeConstants[0]); + } + if (!leftWvpOk) + { + bool rolledBack = false; + { + InternalPassScope guard; + rolledBack = R32SetWvpBatch(device, original); + } + if (!rolledBack) + { + R9Poison(OutRunVR::StereoFailureRestoreFailed, + "R32/HUD-left-WVP-rollback"); + NoteRestoreFailure("R32 HUD left-eye c64 rollback"); + R29ArmMonoSafety(); + return { true, E_FAIL }; + } + return {}; + } + + ++R9DrawCalls; + R9MonoBackupGap = true; + if (LeftDrawMayWriteDepth(device) || LeftDrawMayWriteStencil(device)) + ++R9MainDepthContentSerial; + + R31OwnedResult result{ true, actualDraw() }; + if (FAILED(result.hr)) + { + bool restored = false; + { + InternalPassScope guard; + restored = R32SetWvpBatch(device, original); + } + R9Poison(OutRunVR::StereoFailureLeftDrawFailed, site, result.hr); + if (!restored) NoteRestoreFailure("R32 HUD left draw c64"); + R29ArmMonoSafety(); + return result; + } + + IDirect3DSurface9* savedRt = TrackedRenderTarget; + IDirect3DSurface9* savedDepth = TrackedDepthStencil; + HRESULT rightHr = D3D_OK; + OutRunVR::StereoFailureReason rightFailure = + OutRunVR::StereoFailureRightStateFailed; + bool restoreOk = true; + { + InternalPassScope guard; + rightHr = SetRenderTargetHook.stdcall( + device, 0u, RightEyeSurface); + if (SUCCEEDED(rightHr)) + rightHr = SetDepthStencilSurfaceHook.stdcall( + device, TrackedDepthStencil ? RightEyeDepth : nullptr); + if (SUCCEEDED(rightHr)) + rightHr = device->SetViewport(&savedViewport); + if (SUCCEEDED(rightHr) && + !R32SetWvpBatch(device, eyeConstants[1])) + { + rightFailure = OutRunVR::StereoFailureRightWvpUploadFailed; + rightHr = E_FAIL; + } + if (SUCCEEDED(rightHr)) + { + rightFailure = OutRunVR::StereoFailureRightDrawFailed; + rightHr = actualDraw(); + } + restoreOk = R32RestoreRightPassState(device, savedRt, savedDepth, + savedViewport, original, true); + } + + FrameHadDuplicatedDraw = true; + ++DuplicatedDraws; + ++NonWorldDuplicatedDraws; + ++R29StableTwoEyeDraws; + ++R30ScreenSpaceFovDraws; + ++R31HudDraws; + ++R31Frame.hud; + + if (FAILED(rightHr)) + { + FrameRightDrawFailed = true; + InvalidateRightDepthStencilIfLeftMayWrite(device); + R9Poison(rightFailure, site, rightHr); + R29ArmMonoSafety(); + } + if (!restoreOk) + { + InvalidateRightDepthStencilIfLeftMayWrite(device); + NoteRestoreFailure("R32 HUD right-eye draw"); + R29ArmMonoSafety(); + } + return result; + } + + template + HRESULT R32LowerFailClosed(IDirect3DDevice9* device, + LowerDraw&& lowerDraw) noexcept + { + if (!IsGameDevice(device) || InternalStereoPass || + !TargetIsBackBuffer() || !StereoWanted() || !R9StereoSeeded) + return lowerDraw(); + + R32EffectSnapshot snapshot{}; + if (R32ReadEffectSnapshot(device, snapshot)) + return lowerDraw(); + + const std::uintptr_t savedIdentity = + CurrentVertexShaderIdentity.exchange(0, std::memory_order_acq_rel); + const HRESULT hr = lowerDraw(); + if (savedIdentity != 0) + { + std::uintptr_t expected = 0; + CurrentVertexShaderIdentity.compare_exchange_strong( + expected, savedIdentity, + std::memory_order_acq_rel, std::memory_order_acquire); + } + ++R32FailClosedZeroDisparityDraws; + return hr; + } + + template + HRESULT R32Dispatch(IDirect3DDevice9* device, + ActualDraw&& actualDraw, LowerDraw&& lowerDraw, + const char* site) noexcept + { + R31ObserveDraw(device); + + if (!R31StateBlockRecording) + { + if (R30CurrentPassIsScreenSpace2D()) + { + const auto hud = R32TryHud(device, + std::forward(actualDraw), site); + if (hud.handled) + return hud.hr; + } + else + { + const auto fast = R32TryFastWorld(device, + std::forward(actualDraw), site); + if (fast.handled) + return fast.hr; + } + } + + ++R31Frame.fallback; + return R32LowerFailClosed(device, + std::forward(lowerDraw)); + } + + HRESULT __stdcall DrawPrimitiveDestR32(IDirect3DDevice9* device, + D3DPRIMITIVETYPE type, UINT startVertex, UINT primitiveCount) + { + auto actual = [&]() { + return DrawPrimitiveHook.stdcall( + device, type, startVertex, primitiveCount); + }; + auto lower = [&]() { + return R32DrawPrimitiveR31Hook.stdcall( + device, type, startVertex, primitiveCount); + }; + return R32Dispatch(device, actual, lower, "R32/DrawPrimitive"); + } + + HRESULT __stdcall DrawIndexedPrimitiveDestR32( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + INT baseVertexIndex, UINT minVertexIndex, UINT numVertices, + UINT startIndex, UINT primitiveCount) + { + auto actual = [&]() { + return DrawIndexedPrimitiveHook.stdcall(device, type, + baseVertexIndex, minVertexIndex, numVertices, startIndex, + primitiveCount); + }; + auto lower = [&]() { + return R32DrawIndexedPrimitiveR31Hook.stdcall(device, + type, baseVertexIndex, minVertexIndex, numVertices, + startIndex, primitiveCount); + }; + return R32Dispatch(device, actual, lower, + "R32/DrawIndexedPrimitive"); + } + + HRESULT __stdcall DrawPrimitiveUPDestR32(IDirect3DDevice9* device, + D3DPRIMITIVETYPE type, UINT primitiveCount, const void* data, + UINT stride) + { + auto actual = [&]() { + return DrawPrimitiveUPHook.stdcall( + device, type, primitiveCount, data, stride); + }; + auto lower = [&]() { + return R32DrawPrimitiveUPR31Hook.stdcall( + device, type, primitiveCount, data, stride); + }; + return R32Dispatch(device, actual, lower, "R32/DrawPrimitiveUP"); + } + + HRESULT __stdcall DrawIndexedPrimitiveUPDestR32( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + UINT minVertexIndex, UINT numVertices, UINT primitiveCount, + const void* indexData, D3DFORMAT indexFormat, + const void* vertexData, UINT stride) + { + auto actual = [&]() { + return DrawIndexedPrimitiveUPHook.stdcall(device, type, + minVertexIndex, numVertices, primitiveCount, indexData, + indexFormat, vertexData, stride); + }; + auto lower = [&]() { + return R32DrawIndexedPrimitiveUPR31Hook.stdcall(device, + type, minVertexIndex, numVertices, primitiveCount, + indexData, indexFormat, vertexData, stride); + }; + return R32Dispatch(device, actual, lower, + "R32/DrawIndexedPrimitiveUP"); + } + + void R32ForgetDirectIdentity() noexcept + { + R32DirectHostPid = 0; + R32DirectHostLuidLow = 0; + R32DirectHostLuidHigh = 0; + } + + void R32ClearPendingProducerFences() noexcept + { + R32ProducerFencePending.fill(false); + R32ProducerPendingFrame.fill(0); + } + + bool R32DirectIdentityMatches() noexcept + { + return SharedState && DirectInteropVerified && + R32DirectHostPid != 0 && + R32DirectHostPid == SharedState->hostPid && + R32DirectHostLuidLow == SharedState->hostAdapterLuidLow && + R32DirectHostLuidHigh == SharedState->hostAdapterLuidHigh; + } + + void R32InvalidateDirectInteropOnly() noexcept + { + R32ClearPendingProducerFences(); + R32DirectCopyPathRejected = false; + R32DirectCopyRejectHr = D3D_OK; + ReleaseDirectTransportSlots(); + ReleaseCom(DirectInteropProbeFence); + ReleaseCom(DirectInteropProbeSurface); + ReleaseCom(DirectInteropProbeTexture); + DirectInteropProbeHandle = nullptr; + DirectInteropProbeToken = 0; + DirectInteropVerified = false; + if (SharedState && SharedState->magic == OutRunVR::SharedMagic) + { + InterlockedExchange(reinterpret_cast( + &SharedState->clientInteropProbeHandle), 0); + InterlockedExchange(reinterpret_cast( + &SharedState->clientInteropProbeToken), 0); + } + R32ForgetDirectIdentity(); + ++R32DirectIdentityInvalidations; + } + + bool R32EnsureDirectResources(IDirect3DDevice9* device) noexcept + { + if (DirectTransportResourcesReady && R32DirectIdentityMatches()) + { + if (Settings::VRTelemetry) ++R32DirectProbeCacheHits; + return true; + } + + if (DirectTransportResourcesReady && !R32DirectIdentityMatches()) + R32InvalidateDirectInteropOnly(); + + if (!DirectTransportResourcesReady) + R32ClearPendingProducerFences(); + if (!EnsureDirectTransportResources(device)) + return false; + if (!SharedState || !DirectInteropVerified) + return false; + + R32DirectHostPid = SharedState->hostPid; + R32DirectHostLuidLow = SharedState->hostAdapterLuidLow; + R32DirectHostLuidHigh = SharedState->hostAdapterLuidHigh; + return true; + } + + bool R32WaitProducerFence(IDirect3DQuery9* query) noexcept + { + if (!query) + return false; + + static const LONGLONG qpcFrequency = []() noexcept { + LARGE_INTEGER value{}; + return QueryPerformanceFrequency(&value) != FALSE + ? value.QuadPart : 0; + }(); + LARGE_INTEGER start{}; + const bool highResolutionClock = qpcFrequency > 0 && + QueryPerformanceCounter(&start) != FALSE; + const ULONGLONG fallbackDeadline = highResolutionClock ? 0 : + GetTickCount64() + OutRunVR::R32::ProducerFenceBudgetMs; + const LONGLONG budgetTicks = highResolutionClock + ? (qpcFrequency * + static_cast(OutRunVR::R32::ProducerFenceBudgetMs) + + 999) / 1000 + : 0; + + // Budget starts before the FLUSH request so a slow first GetData is + // accounted for instead of being hidden outside the 2 ms window. + HRESULT ready = query->GetData(nullptr, 0, D3DGETDATA_FLUSH); + if (ready == S_OK) + { + if (Settings::VRTelemetry) ++R32DirectFenceSuccess; + return true; + } + if (ready != S_FALSE) + return false; + + for (;;) + { + ready = query->GetData(nullptr, 0, 0); + if (ready == S_OK) + { + if (Settings::VRTelemetry) ++R32DirectFenceSuccess; + return true; + } + + bool expired = ready != S_FALSE; + if (!expired && highResolutionClock) + { + LARGE_INTEGER now{}; + expired = QueryPerformanceCounter(&now) == FALSE || + now.QuadPart - start.QuadPart >= budgetTicks; + } + else if (!expired) + { + expired = GetTickCount64() >= fallbackDeadline; + } + + if (expired) + { + if (Settings::VRTelemetry) ++R32DirectFenceBudgetFallbacks; + ++DirectTransportFenceTimeouts; + if (!R32FirstFenceBudgetLogged) + { + R32FirstFenceBudgetLogged = true; + spdlog::warn( + "VR R32 D3D9Ex: producer copy fence exceeded {}ms; falling back to SBS instead of stalling up to 12ms", + OutRunVR::R32::ProducerFenceBudgetMs); + } + return false; + } + SwitchToThread(); + } + } + + bool R32DrainPendingProducerFence(std::uint32_t slotIndex) noexcept + { + if (slotIndex >= R32ProducerFencePending.size() || + !R32ProducerFencePending[slotIndex]) + return true; + + auto& slot = DirectTransportSlots[slotIndex]; + if (!slot.fence) + { + R32DirectCopyPathRejected = true; + R32DirectCopyRejectHr = E_FAIL; + if (Settings::VRTelemetry) ++R32PendingFenceErrors; + return false; + } + + const HRESULT ready = slot.fence->GetData(nullptr, 0, 0); + if (ready == S_OK) + { + R32ProducerFencePending[slotIndex] = false; + R32ProducerPendingFrame[slotIndex] = 0; + if (Settings::VRTelemetry) ++R32PendingFenceDrains; + return true; + } + if (ready == S_FALSE) + { + if (Settings::VRTelemetry) ++R32PendingFenceBlocks; + ++DirectTransportRingBackpressure; + if (!R32FirstPendingFenceLogged) + { + R32FirstPendingFenceLogged = true; + spdlog::info( + "VR R32 D3D9Ex: timed-out producer EVENT remains pending; the ring slot is blocked from reuse until the GPU reports completion"); + } + return false; + } + + // A query error does not prove GPU completion. Keep the pending + // marker intact and quarantine DirectGPU until Reset or interop + // identity regeneration recreates the ring. + R32DirectCopyPathRejected = true; + R32DirectCopyRejectHr = ready; + if (Settings::VRTelemetry) ++R32PendingFenceErrors; + return false; + } + + bool ResolveDirectTransportR32(IDirect3DDevice9* device, + std::uint32_t frameId) noexcept + { + if (!R13OverlayReady.load(std::memory_order_acquire)) + return R32ResolveDirectR13Hook.call(device, frameId); + if (!frameId || !R32EnsureDirectResources(device) || + !BackBuffer || !RightEyeSurface) + return false; + // R32EnsureDirectResources must run before this cached rejection: + // host PID/LUID or transport-generation changes invalidate the old + // interop identity and clear the rejection automatically. + if (R32DirectCopyPathRejected) + return false; + + const std::uint32_t slotIndex = + (frameId - 1u) % OutRunVR::RenderFrameRingSize; + if (!R32DrainPendingProducerFence(slotIndex)) + return false; + + auto& slot = DirectTransportSlots[slotIndex]; + if (slot.frameId) + { + std::uint32_t gpuCompleted = 0; + const bool ackValid = + R13ReadGpuCompletedFrame(slotIndex, gpuCompleted); + if (!ackValid || !FrameIdAtOrAfter(gpuCompleted, slot.frameId)) + { + ++R13SafeAckBackpressure; + ++DirectTransportRingBackpressure; + return false; + } + } + + { + InternalPassScope guard; + const HRESULT leftCopy = device->StretchRect(BackBuffer, nullptr, + slot.leftSurface, nullptr, D3DTEXF_NONE); + const HRESULT rightCopy = SUCCEEDED(leftCopy) + ? device->StretchRect(RightEyeSurface, nullptr, + slot.rightSurface, nullptr, D3DTEXF_NONE) + : leftCopy; + if (FAILED(leftCopy) || FAILED(rightCopy)) + { + R32DirectCopyPathRejected = true; + R32DirectCopyRejectHr = FAILED(leftCopy) + ? leftCopy : rightCopy; + if (!R32FirstDirectCopyRejectLogged) + { + R32FirstDirectCopyRejectLogged = true; + spdlog::warn( + "VR R32 D3D9Ex: shared-eye StretchRect rejected hr=0x{:08X}; DirectGPU copy path is disabled until Reset/interop revalidation instead of retrying every Present", + static_cast(R32DirectCopyRejectHr)); + } + return false; + } + const HRESULT issueHr = slot.fence->Issue(D3DISSUE_END); + if (FAILED(issueHr)) + { + // StretchRect commands are already queued. Without a valid + // EVENT we cannot prove when this producer slot is reusable, + // so quarantine the whole DirectGPU path until reset/interop + // revalidation rather than cycling back into this slot. + R32DirectCopyPathRejected = true; + R32DirectCopyRejectHr = issueHr; + if (Settings::VRTelemetry) ++R32PendingFenceErrors; + return false; + } + } + + R32ProducerFencePending[slotIndex] = true; + R32ProducerPendingFrame[slotIndex] = frameId; + if (!R32WaitProducerFence(slot.fence)) + return false; + + R32ProducerFencePending[slotIndex] = false; + R32ProducerPendingFrame[slotIndex] = 0; + slot.frameId = frameId; + ActiveDirectTransportSlot = slotIndex; + return true; + } + + void R32InvalidateResetCaches() noexcept + { + R29Effect = {}; + R31BlockedVerifiedGeneration = 0; + R31FastWorldCandidates = 0; + R31EyeCache = {}; + R31Frame = {}; + R31Window = {}; + R23LastStateSampleDrawSerial = 0; + R23LastStateSampleEpoch = 0; + OutRunVRRenderer::R29InvalidateRendererStateAfterExternalRestore(); + R32ForgetDirectIdentity(); + R32ClearPendingProducerFences(); + R32DirectCopyPathRejected = false; + R32DirectCopyRejectHr = D3D_OK; + } + + void R32ResetAfterGameReset() noexcept + { + R29MonoSafetyThroughEpoch = OutRunVR::R32::RearmMonoSafetyEpoch( + PresentEpoch); + R32InvalidateResetCaches(); + ++R32ResetEpochRearms; + if (!R32FirstResetRearmLogged) + { + R32FirstResetRearmLogged = true; + spdlog::info( + "VR R32 RESET: R22 completed Reset lifecycle first; mono-safety/cache generations were rearmed without discarding the freshly primed viewport/scissor shadow"); + } + } + + HRESULT __stdcall ResetDestR32(IDirect3DDevice9* device, + D3DPRESENT_PARAMETERS* params) + { + const bool gameDevice = IsGameDevice(device); + if (gameDevice) + R32ClearPendingProducerFences(); + + const HRESULT hr = R32ResetR22Hook.stdcall(device, params); + if (gameDevice) + { + if (SUCCEEDED(hr)) + R32ResetAfterGameReset(); + else + { + R32InvalidateResetCaches(); + ++R32ResetFailures; + } + } + return hr; + } + + void R32LogPerfWindow() noexcept + { + if (!Settings::VRTelemetry) + return; + const ULONGLONG now = GetTickCount64(); + if (R32Counters.lastLogMs == 0) + { + R32Counters.lastLogMs = now; + R32Counters.liveWvp = R31FastWorldLiveValidations; + R32Counters.liveReject = R31FastWorldValidationRejects; + R32Counters.stateRecord = R31StateBlockRecordings; + R32Counters.stateApply = R31StateBlockApplies; + R32Counters.batch = R32BatchWvpUploads; + R32Counters.batchFail = R32BatchWvpFailures; + R32Counters.stateFail = R32StateSnapshotFailures; + R32Counters.zeroFallback = R32FailClosedZeroDisparityDraws; + R32Counters.directCache = R32DirectProbeCacheHits; + R32Counters.directFenceOk = R32DirectFenceSuccess; + R32Counters.directFenceFallback = R32DirectFenceBudgetFallbacks; + R32Counters.directBackpressure = DirectTransportRingBackpressure; + R32Counters.pendingDrain = R32PendingFenceDrains; + R32Counters.pendingBlock = R32PendingFenceBlocks; + R32Counters.pendingError = R32PendingFenceErrors; + R32Counters.resetRearm = R32ResetEpochRearms; + R32Counters.resetFail = R32ResetFailures; + return; + } + if (now - R32Counters.lastLogMs < 5000) + return; + + spdlog::info( + "VR R32 PERF 5s: liveWvpCheck={} liveReject={} stateBlock[record={},apply={}] batchWvp[ok={},fail={}] safety[stateReadFail={},forcedZero={}] direct[probeCacheHit={},producerFenceOk={},producerBudgetFallback={},backpressure={},pendingDrain={},pendingBlock={},pendingError={}] reset[rearm={},fail={}]", + R31FastWorldLiveValidations - R32Counters.liveWvp, + R31FastWorldValidationRejects - R32Counters.liveReject, + R31StateBlockRecordings - R32Counters.stateRecord, + R31StateBlockApplies - R32Counters.stateApply, + R32BatchWvpUploads - R32Counters.batch, + R32BatchWvpFailures - R32Counters.batchFail, + R32StateSnapshotFailures - R32Counters.stateFail, + R32FailClosedZeroDisparityDraws - R32Counters.zeroFallback, + R32DirectProbeCacheHits - R32Counters.directCache, + R32DirectFenceSuccess - R32Counters.directFenceOk, + R32DirectFenceBudgetFallbacks - R32Counters.directFenceFallback, + DirectTransportRingBackpressure - R32Counters.directBackpressure, + R32PendingFenceDrains - R32Counters.pendingDrain, + R32PendingFenceBlocks - R32Counters.pendingBlock, + R32PendingFenceErrors - R32Counters.pendingError, + R32ResetEpochRearms - R32Counters.resetRearm, + R32ResetFailures - R32Counters.resetFail); + + R32Counters.lastLogMs = now; + R32Counters.liveWvp = R31FastWorldLiveValidations; + R32Counters.liveReject = R31FastWorldValidationRejects; + R32Counters.stateRecord = R31StateBlockRecordings; + R32Counters.stateApply = R31StateBlockApplies; + R32Counters.batch = R32BatchWvpUploads; + R32Counters.batchFail = R32BatchWvpFailures; + R32Counters.stateFail = R32StateSnapshotFailures; + R32Counters.zeroFallback = R32FailClosedZeroDisparityDraws; + R32Counters.directCache = R32DirectProbeCacheHits; + R32Counters.directFenceOk = R32DirectFenceSuccess; + R32Counters.directFenceFallback = R32DirectFenceBudgetFallbacks; + R32Counters.directBackpressure = DirectTransportRingBackpressure; + R32Counters.pendingDrain = R32PendingFenceDrains; + R32Counters.pendingBlock = R32PendingFenceBlocks; + R32Counters.pendingError = R32PendingFenceErrors; + R32Counters.resetRearm = R32ResetEpochRearms; + R32Counters.resetFail = R32ResetFailures; + } + + HRESULT __stdcall PresentDestR32(IDirect3DDevice9* device, + const RECT* sourceRect, const RECT* destRect, + HWND destWindowOverride, const RGNDATA* dirtyRegion) + { + const HRESULT hr = R32PresentR13Hook.stdcall(device, + sourceRect, destRect, destWindowOverride, dirtyRegion); + if (IsGameDevice(device)) + R32LogPerfWindow(); + return hr; + } + + void R32RollbackHooks() noexcept + { + R32DrawIndexedPrimitiveUPR31Hook = {}; + R32DrawPrimitiveUPR31Hook = {}; + R32DrawIndexedPrimitiveR31Hook = {}; + R32DrawPrimitiveR31Hook = {}; + R32PresentR13Hook = {}; + R32ResolveDirectR13Hook = {}; + R32ResetR22Hook = {}; + } + + bool R32EnableHooks() noexcept + { + SafetyHookInline* hooks[]{ + &R32ResetR22Hook, + &R32ResolveDirectR13Hook, + &R32PresentR13Hook, + &R32DrawPrimitiveR31Hook, + &R32DrawIndexedPrimitiveR31Hook, + &R32DrawPrimitiveUPR31Hook, + &R32DrawIndexedPrimitiveUPR31Hook + }; + for (auto* hook : hooks) + if (!*hook || !hook->enable().has_value()) + return false; + return true; + } + + DWORD WINAPI R32InstallThread(void*) + { + using State = OutRunVR::RuntimeEligibility::InstallState; + R32InstallState.store(State::Pending, std::memory_order_release); + for (int attempt = 0; attempt < 4800; ++attempt) + { + const auto r31 = R31InstallState.load(std::memory_order_acquire); + const auto r22 = R22InstallState.load(std::memory_order_acquire); + const auto r13 = R13InstallState.load(std::memory_order_acquire); + if (r31 == State::Failed || r22 == State::Failed || + r13 == R13InstallFailed) + { + R32InstallState.store(State::Failed, std::memory_order_release); + HookManager::ReportAsyncResult("OpenXRVRStereoR32Review", false); + return 0; + } + if (r31 == State::Ready && r22 == State::Ready && + r13 == R13InstallReady) + { + const auto disabled = safetyhook::InlineHook::StartDisabled; + R32ResetR22Hook = safetyhook::create_inline( + reinterpret_cast(&ResetDestR22), ResetDestR32, disabled); + R32ResolveDirectR13Hook = safetyhook::create_inline( + reinterpret_cast(&ResolveDirectTransportR13), + ResolveDirectTransportR32, disabled); + R32PresentR13Hook = safetyhook::create_inline( + reinterpret_cast(&PresentDestR13), PresentDestR32, disabled); + R32DrawPrimitiveR31Hook = safetyhook::create_inline( + reinterpret_cast(&DrawPrimitiveDestR31), + DrawPrimitiveDestR32, disabled); + R32DrawIndexedPrimitiveR31Hook = safetyhook::create_inline( + reinterpret_cast(&DrawIndexedPrimitiveDestR31), + DrawIndexedPrimitiveDestR32, disabled); + R32DrawPrimitiveUPR31Hook = safetyhook::create_inline( + reinterpret_cast(&DrawPrimitiveUPDestR31), + DrawPrimitiveUPDestR32, disabled); + R32DrawIndexedPrimitiveUPR31Hook = safetyhook::create_inline( + reinterpret_cast(&DrawIndexedPrimitiveUPDestR31), + DrawIndexedPrimitiveUPDestR32, disabled); + + if (!R32EnableHooks()) + { + R32RollbackHooks(); + R32InstallState.store(State::Failed, + std::memory_order_release); + HookManager::ReportAsyncResult( + "OpenXRVRStereoR32Review", false); + spdlog::error( + "VR R32: review/optimization hook transaction was partial; R31/R22 remain authoritative"); + return 0; + } + + R32InstallState.store(State::Ready, std::memory_order_release); + HookManager::ReportAsyncResult("OpenXRVRStereoR32Review", true); + spdlog::info( + "VR R32 REVIEW2: R22-owned Reset lifecycle + fail-closed state reads + batched WVP + cached D3D9Ex interop + pending-fence-safe producer ring + delta telemetry READY"); + return 0; + } + Sleep(25); + } + + R32InstallState.store(State::Failed, std::memory_order_release); + HookManager::ReportAsyncResult("OpenXRVRStereoR32Review", false); + spdlog::error("VR R32: timed out waiting for R31/R22/R13 prerequisites"); + return 0; + } + + class VRStereoR32ReviewHook final : public Hook + { + public: + std::string_view description() override + { + return "OpenXRVRStereoR32Review"; + } + bool validate() override { return true; } + bool apply() override + { + HANDLE thread = CreateThread( + nullptr, 0, R32InstallThread, nullptr, 0, nullptr); + if (!thread) + { + R32InstallState.store( + OutRunVR::RuntimeEligibility::InstallState::Failed, + std::memory_order_release); + return false; + } + CloseHandle(thread); + return true; + } + static VRStereoR32ReviewHook instance; + }; + + VRStereoR32ReviewHook VRStereoR32ReviewHook::instance; + } +} diff --git a/src/vr/d3d9/stereo_renderer_r33.cpp b/src/vr/d3d9/stereo_renderer_r33.cpp new file mode 100644 index 000000000..0c8b1ed7b --- /dev/null +++ b/src/vr/d3d9/stereo_renderer_r33.cpp @@ -0,0 +1,921 @@ +// R33 final dispatch + post-review hot-path hardening. +// +// R32 owns reset/direct-transport review-2 safety. R33 remains the final +// game-side callback boundary and owns depth/stencil write-state caching plus +// exact single-count draw dispatch. Reset now simply chains through R32, whose +// trampoline is installed above R22, so R22 is authoritative in both the R32 +// fallback and the final R33 path. +// +// R33 is also the final top-level draw boundary: when telemetry is disabled, +// route accounting and diagnostic counter writes are skipped so the steady +// draw path pays only for correctness checks required by stereo rendering. + +#include "stereo_renderer_r32.cpp" + +namespace OutRunVRStereo +{ + namespace + { + SafetyHookInline R33ResetR32Hook{}; + SafetyHookInline R33PresentR32Hook{}; + SafetyHookInline R33SetRenderStateR29Hook{}; + SafetyHookInline R33DrawPrimitiveR32Hook{}; + SafetyHookInline R33DrawIndexedPrimitiveR32Hook{}; + SafetyHookInline R33DrawPrimitiveUPR32Hook{}; + SafetyHookInline R33DrawIndexedPrimitiveUPR32Hook{}; + + std::atomic R33InstallState{ + OutRunVR::RuntimeEligibility::InstallState::Pending }; + + struct R33DepthStencilWriteState + { + DWORD zEnable = D3DZB_TRUE; + DWORD zWrite = TRUE; + DWORD stencilEnable = FALSE; + DWORD stencilWriteMask = 0xFFFFFFFFu; + DWORD stencilFail = D3DSTENCILOP_KEEP; + DWORD stencilZFail = D3DSTENCILOP_KEEP; + DWORD stencilPass = D3DSTENCILOP_KEEP; + DWORD twoSided = FALSE; + DWORD ccwStencilFail = D3DSTENCILOP_KEEP; + DWORD ccwStencilZFail = D3DSTENCILOP_KEEP; + DWORD ccwStencilPass = D3DSTENCILOP_KEEP; + std::uint64_t depthGeneration = 0; + std::uint64_t stateBlockRecordings = 0; + std::uint64_t stateBlockApplies = 0; + bool valid = false; + }; + + thread_local R33DepthStencilWriteState R33DepthStencilState{}; + std::uint64_t R33DepthStencilSyncs = 0; + std::uint64_t R33DepthStencilCacheHits = 0; + std::uint64_t R33DepthStencilLiveFallbacks = 0; + std::uint64_t R33DepthStencilReadFailures = 0; + std::uint64_t R33ResetSuccesses = 0; + std::uint64_t R33ResetFailures = 0; + bool R33FirstDepthStencilCacheLogged = false; + bool R33FirstResetLifecycleLogged = false; + + struct R33PerfSnapshot + { + ULONGLONG lastLogMs = 0; + std::uint64_t syncs = 0; + std::uint64_t hits = 0; + std::uint64_t live = 0; + std::uint64_t readFail = 0; + std::uint64_t resetOk = 0; + std::uint64_t resetFail = 0; + }; + R33PerfSnapshot R33Perf{}; + + inline bool R33TelemetryEnabled() noexcept + { + return Settings::VRTelemetry; + } + + void R33InvalidateDepthStencilCache() noexcept + { + R33DepthStencilState.valid = false; + } + + bool R33TrackedDepthHasStencil() noexcept + { + if (!TrackedDepthStencil || !R9MainDepthKnown || + TrackedDepthStencil != R9MainDepthIdentity) + return false; + return FormatHasStencil(R9MainDepthDesc.Format); + } + + bool R33ReadDepthStencilWriteState(IDirect3DDevice9* device) noexcept + { + if (!device) + return false; + + R33DepthStencilWriteState next{}; + if (!TrackedDepthStencil) + { + next.valid = true; + next.depthGeneration = R9MainDepthGeneration; + next.stateBlockRecordings = R31StateBlockRecordings; + next.stateBlockApplies = R31StateBlockApplies; + R33DepthStencilState = next; + if (R33TelemetryEnabled()) + ++R33DepthStencilSyncs; + return true; + } + + if (FAILED(device->GetRenderState(D3DRS_ZENABLE, &next.zEnable)) || + FAILED(device->GetRenderState(D3DRS_ZWRITEENABLE, &next.zWrite))) + { + if (R33TelemetryEnabled()) + ++R33DepthStencilReadFailures; + R33InvalidateDepthStencilCache(); + return false; + } + + if (R33TrackedDepthHasStencil()) + { + if (FAILED(device->GetRenderState( + D3DRS_STENCILENABLE, &next.stencilEnable)) || + FAILED(device->GetRenderState( + D3DRS_STENCILWRITEMASK, &next.stencilWriteMask)) || + FAILED(device->GetRenderState( + D3DRS_STENCILFAIL, &next.stencilFail)) || + FAILED(device->GetRenderState( + D3DRS_STENCILZFAIL, &next.stencilZFail)) || + FAILED(device->GetRenderState( + D3DRS_STENCILPASS, &next.stencilPass)) || + FAILED(device->GetRenderState( + D3DRS_TWOSIDEDSTENCILMODE, &next.twoSided)) || + FAILED(device->GetRenderState( + D3DRS_CCW_STENCILFAIL, &next.ccwStencilFail)) || + FAILED(device->GetRenderState( + D3DRS_CCW_STENCILZFAIL, &next.ccwStencilZFail)) || + FAILED(device->GetRenderState( + D3DRS_CCW_STENCILPASS, &next.ccwStencilPass))) + { + if (R33TelemetryEnabled()) + ++R33DepthStencilReadFailures; + R33InvalidateDepthStencilCache(); + return false; + } + } + + next.valid = true; + next.depthGeneration = R9MainDepthGeneration; + next.stateBlockRecordings = R31StateBlockRecordings; + next.stateBlockApplies = R31StateBlockApplies; + R33DepthStencilState = next; + if (R33TelemetryEnabled()) + ++R33DepthStencilSyncs; + if (!R33FirstDepthStencilCacheLogged) + { + R33FirstDepthStencilCacheLogged = true; + spdlog::info( + "VR R33 PERF: depth/stencil write shadow cache ACTIVE; steady draws no longer query Z/stencil state or depth GetDesc when StateBlock tracking is reliable"); + } + return true; + } + + bool R33DepthStencilCacheCurrent() noexcept + { + return R33DepthStencilState.valid && + R33DepthStencilState.depthGeneration == R9MainDepthGeneration && + R33DepthStencilState.stateBlockRecordings == + R31StateBlockRecordings && + R33DepthStencilState.stateBlockApplies == R31StateBlockApplies; + } + + bool R33GetWriteFlags(IDirect3DDevice9* device, + bool& mayWriteDepth, bool& mayWriteStencil) noexcept + { + mayWriteDepth = false; + mayWriteStencil = false; + if (!TrackedDepthStencil) + return true; + + if (!R31StateBlockTrackingReliable.load(std::memory_order_acquire)) + { + if (R33TelemetryEnabled()) + ++R33DepthStencilLiveFallbacks; + mayWriteDepth = LeftDrawMayWriteDepth(device); + mayWriteStencil = LeftDrawMayWriteStencil(device); + return true; + } + + if (!R33DepthStencilCacheCurrent()) + { + if (!R33ReadDepthStencilWriteState(device)) + return false; + } + else if (R33TelemetryEnabled()) + { + ++R33DepthStencilCacheHits; + } + + const auto& s = R33DepthStencilState; + mayWriteDepth = s.zEnable != D3DZB_FALSE && s.zWrite != FALSE; + + if (!R33TrackedDepthHasStencil() || s.stencilEnable == FALSE || + s.stencilWriteMask == 0) + return true; + + const bool frontWrites = + s.stencilFail != D3DSTENCILOP_KEEP || + s.stencilZFail != D3DSTENCILOP_KEEP || + s.stencilPass != D3DSTENCILOP_KEEP; + const bool backWrites = s.twoSided != FALSE && + (s.ccwStencilFail != D3DSTENCILOP_KEEP || + s.ccwStencilZFail != D3DSTENCILOP_KEEP || + s.ccwStencilPass != D3DSTENCILOP_KEEP); + mayWriteStencil = frontWrites || backWrites; + return true; + } + + void R33InvalidateRightForLeftWrite( + bool mayWriteDepth, bool mayWriteStencil) noexcept + { + if (mayWriteDepth) + RightDepthSynchronized = false; + if (mayWriteStencil) + RightStencilSynchronized = false; + } + + HRESULT __stdcall SetRenderStateDestR33(IDirect3DDevice9* device, + D3DRENDERSTATETYPE state, DWORD value) + { + const HRESULT hr = R33SetRenderStateR29Hook.stdcall( + device, state, value); + if (FAILED(hr) || !IsGameDevice(device) || InternalStereoPass) + return hr; + + if (R31StateBlockRecording) + { + R33InvalidateDepthStencilCache(); + return hr; + } + if (!R33DepthStencilState.valid) + return hr; + + switch (state) + { + case D3DRS_ZENABLE: + R33DepthStencilState.zEnable = value; + break; + case D3DRS_ZWRITEENABLE: + R33DepthStencilState.zWrite = value; + break; + case D3DRS_STENCILENABLE: + R33DepthStencilState.stencilEnable = value; + break; + case D3DRS_STENCILWRITEMASK: + R33DepthStencilState.stencilWriteMask = value; + break; + case D3DRS_STENCILFAIL: + R33DepthStencilState.stencilFail = value; + break; + case D3DRS_STENCILZFAIL: + R33DepthStencilState.stencilZFail = value; + break; + case D3DRS_STENCILPASS: + R33DepthStencilState.stencilPass = value; + break; + case D3DRS_TWOSIDEDSTENCILMODE: + R33DepthStencilState.twoSided = value; + break; + case D3DRS_CCW_STENCILFAIL: + R33DepthStencilState.ccwStencilFail = value; + break; + case D3DRS_CCW_STENCILZFAIL: + R33DepthStencilState.ccwStencilZFail = value; + break; + case D3DRS_CCW_STENCILPASS: + R33DepthStencilState.ccwStencilPass = value; + break; + default: + break; + } + + // Do not rewrite depth/state-block generations here. If a StateBlock + // made this snapshot stale, changing one tracked render state cannot + // make the untouched fields authoritative again. + return hr; + } + + template + R31OwnedResult R33TryFastWorld(IDirect3DDevice9* device, + ActualDraw&& actualDraw, const char* site) + { + if (R31StateBlockRecording || !R29StableStereoBase(device)) + { + if (R33TelemetryEnabled() && IsGameDevice(device) && + !InternalStereoPass && TargetIsBackBuffer()) + ++R31Frame.unstable; + return {}; + } + + bool fragile = true; + const bool stateBlocksReliable = + R31StateBlockTrackingReliable.load(std::memory_order_acquire); + const bool effectKnown = stateBlocksReliable + ? R29FragileEffectCached(device, fragile) + : R32EffectIsFragileLive(device, fragile); + if (!effectKnown) + return {}; + if (fragile) + { + if (R33TelemetryEnabled()) + ++R31Frame.fragile; + return {}; + } + + if (!EnsureStereoResources(device)) + return {}; + if (TrackedDepthStencil && + (!RightDepthSynchronized || !RightStencilSynchronized)) + TryBootstrapRightDepthFromRecentClear(device); + if (TrackedDepthStencil && !RightDepthSynchronized && + DepthTestActive(device)) + return {}; + if (TrackedDepthStencil && !RightStencilSynchronized && + StencilTestActive(device)) + return {}; + + OutRunVRRenderer::LatchedStereoFrame stereo{}; + if (!OutRunVRRenderer::GetLatchedStereoFrame(stereo) || + stereo.poseSequence == 0) + return {}; + if (FrameStereoPoseSequence != 0 && + FrameStereoPoseSequence != stereo.poseSequence) + return {}; + + DrawStereoState draw{}; + if (!R31BuildFastWorldConstants(device, stereo, draw)) + return {}; + + D3DVIEWPORT9 savedViewport{}; + if (!R32GetSavedViewport(device, savedViewport)) + return {}; + + bool mayWriteDepth = false; + bool mayWriteStencil = false; + if (!R33GetWriteFlags(device, mayWriteDepth, mayWriteStencil)) + return {}; + + bool leftWvpOk = false; + { + InternalPassScope guard; + leftWvpOk = R32SetWvpBatch(device, draw.eyeConstants[0]); + } + if (!leftWvpOk) + { + bool rolledBack = false; + { + InternalPassScope guard; + rolledBack = R32SetWvpBatch(device, draw.originalConstants); + } + if (!rolledBack) + { + R9Poison(OutRunVR::StereoFailureRestoreFailed, + "R33/fast-left-WVP-rollback"); + NoteRestoreFailure("R33 fast left-eye c64 rollback"); + R29ArmMonoSafety(); + return { true, E_FAIL }; + } + return {}; + } + + ++R9DrawCalls; + R9MonoBackupGap = true; + if (mayWriteDepth || mayWriteStencil) + ++R9MainDepthContentSerial; + + R31OwnedResult result{ true, actualDraw() }; + if (FAILED(result.hr)) + { + R33InvalidateRightForLeftWrite( + mayWriteDepth, mayWriteStencil); + R9Poison(OutRunVR::StereoFailureLeftDrawFailed, site, result.hr); + bool restored = false; + { + InternalPassScope guard; + restored = R32SetWvpBatch(device, draw.originalConstants); + } + if (!restored) + NoteRestoreFailure("R33 fast left draw c64"); + R29ArmMonoSafety(); + return result; + } + + IDirect3DSurface9* savedRt = TrackedRenderTarget; + IDirect3DSurface9* savedDepth = TrackedDepthStencil; + HRESULT rightHr = D3D_OK; + OutRunVR::StereoFailureReason rightFailure = + OutRunVR::StereoFailureRightStateFailed; + bool restoreOk = true; + { + InternalPassScope guard; + rightHr = SetRenderTargetHook.stdcall( + device, 0u, RightEyeSurface); + if (SUCCEEDED(rightHr)) + rightHr = SetDepthStencilSurfaceHook.stdcall( + device, TrackedDepthStencil ? RightEyeDepth : nullptr); + if (SUCCEEDED(rightHr)) + rightHr = device->SetViewport(&savedViewport); + if (SUCCEEDED(rightHr) && + !R32SetWvpBatch(device, draw.eyeConstants[1])) + { + rightFailure = + OutRunVR::StereoFailureRightWvpUploadFailed; + rightHr = E_FAIL; + } + if (SUCCEEDED(rightHr)) + { + rightFailure = OutRunVR::StereoFailureRightDrawFailed; + rightHr = actualDraw(); + } + restoreOk = R32RestoreRightPassState( + device, savedRt, savedDepth, savedViewport, + draw.originalConstants, true); + } + + FrameHadDuplicatedDraw = true; + FrameHadWorldStereo = true; + ++DuplicatedDraws; + ++WorldStereoDraws; + ++R29StableTwoEyeDraws; + if (R33TelemetryEnabled()) + { + ++R31FastWorldDraws; + ++R31Frame.fastWorld; + } + + if (FrameStereoPoseSequence == 0) + { + FrameStereoPoseSequence = draw.poseSequence; + FrameStereoMetadata = draw.stereoFrame; + } + + if (FAILED(rightHr)) + { + FrameRightDrawFailed = true; + R33InvalidateRightForLeftWrite( + mayWriteDepth, mayWriteStencil); + R9Poison(rightFailure, site, rightHr); + R29ArmMonoSafety(); + } + if (!restoreOk) + { + R33InvalidateRightForLeftWrite( + mayWriteDepth, mayWriteStencil); + NoteRestoreFailure("R33 fast right-eye draw"); + R29ArmMonoSafety(); + } + return result; + } + + template + R31OwnedResult R33TryHud(IDirect3DDevice9* device, + ActualDraw&& actualDraw, const char* site) + { + if (R31StateBlockRecording || !R29StableStereoBase(device) || + !R30CurrentPassIsScreenSpace2D()) + return {}; + + if (!R31StateBlockTrackingReliable.load(std::memory_order_acquire)) + { + R31DiscardUnreliableDrawCaches(); + const std::uintptr_t cachedShader = + CurrentVertexShaderIdentity.load(std::memory_order_acquire); + if (!R31LiveShaderMatches(device, cachedShader)) + return {}; + } + + if (!EnsureStereoResources(device)) + return {}; + if (TrackedDepthStencil && + (!RightDepthSynchronized || !RightStencilSynchronized)) + TryBootstrapRightDepthFromRecentClear(device); + if (TrackedDepthStencil && !RightDepthSynchronized && + DepthTestActive(device)) + return {}; + if (TrackedDepthStencil && !RightStencilSynchronized && + StencilTestActive(device)) + return {}; + + OutRunVRRenderer::LatchedStereoFrame stereo{}; + if (!OutRunVRRenderer::GetLatchedStereoFrame(stereo) || + stereo.poseSequence == 0) + return {}; + if (FrameStereoPoseSequence != 0 && + FrameStereoPoseSequence != stereo.poseSequence) + return {}; + + float original[16]{}; + float eyeConstants[2][16]{}; + float eyeScale[2]{}; + float eyeOffset[2]{}; + if (!R30BuildScreenSpaceEyeConstants(device, stereo, original, + eyeConstants, eyeScale, eyeOffset)) + return {}; + + D3DVIEWPORT9 savedViewport{}; + if (!R32GetSavedViewport(device, savedViewport)) + return {}; + + bool mayWriteDepth = false; + bool mayWriteStencil = false; + if (!R33GetWriteFlags(device, mayWriteDepth, mayWriteStencil)) + return {}; + + bool leftWvpOk = false; + { + InternalPassScope guard; + leftWvpOk = R32SetWvpBatch(device, eyeConstants[0]); + } + if (!leftWvpOk) + { + bool rolledBack = false; + { + InternalPassScope guard; + rolledBack = R32SetWvpBatch(device, original); + } + if (!rolledBack) + { + R9Poison(OutRunVR::StereoFailureRestoreFailed, + "R33/HUD-left-WVP-rollback"); + NoteRestoreFailure("R33 HUD left-eye c64 rollback"); + R29ArmMonoSafety(); + return { true, E_FAIL }; + } + return {}; + } + + ++R9DrawCalls; + R9MonoBackupGap = true; + if (mayWriteDepth || mayWriteStencil) + ++R9MainDepthContentSerial; + + R31OwnedResult result{ true, actualDraw() }; + if (FAILED(result.hr)) + { + R33InvalidateRightForLeftWrite( + mayWriteDepth, mayWriteStencil); + bool restored = false; + { + InternalPassScope guard; + restored = R32SetWvpBatch(device, original); + } + R9Poison(OutRunVR::StereoFailureLeftDrawFailed, + site, result.hr); + if (!restored) + NoteRestoreFailure("R33 HUD left draw c64"); + R29ArmMonoSafety(); + return result; + } + + IDirect3DSurface9* savedRt = TrackedRenderTarget; + IDirect3DSurface9* savedDepth = TrackedDepthStencil; + HRESULT rightHr = D3D_OK; + OutRunVR::StereoFailureReason rightFailure = + OutRunVR::StereoFailureRightStateFailed; + bool restoreOk = true; + { + InternalPassScope guard; + rightHr = SetRenderTargetHook.stdcall( + device, 0u, RightEyeSurface); + if (SUCCEEDED(rightHr)) + rightHr = SetDepthStencilSurfaceHook.stdcall( + device, TrackedDepthStencil ? RightEyeDepth : nullptr); + if (SUCCEEDED(rightHr)) + rightHr = device->SetViewport(&savedViewport); + if (SUCCEEDED(rightHr) && + !R32SetWvpBatch(device, eyeConstants[1])) + { + rightFailure = + OutRunVR::StereoFailureRightWvpUploadFailed; + rightHr = E_FAIL; + } + if (SUCCEEDED(rightHr)) + { + rightFailure = OutRunVR::StereoFailureRightDrawFailed; + rightHr = actualDraw(); + } + restoreOk = R32RestoreRightPassState( + device, savedRt, savedDepth, savedViewport, original, true); + } + + FrameHadDuplicatedDraw = true; + ++DuplicatedDraws; + ++NonWorldDuplicatedDraws; + ++R29StableTwoEyeDraws; + ++R30ScreenSpaceFovDraws; + if (R33TelemetryEnabled()) + { + ++R31HudDraws; + ++R31Frame.hud; + } + + if (FAILED(rightHr)) + { + FrameRightDrawFailed = true; + R33InvalidateRightForLeftWrite( + mayWriteDepth, mayWriteStencil); + R9Poison(rightFailure, site, rightHr); + R29ArmMonoSafety(); + } + if (!restoreOk) + { + R33InvalidateRightForLeftWrite( + mayWriteDepth, mayWriteStencil); + NoteRestoreFailure("R33 HUD right-eye draw"); + R29ArmMonoSafety(); + } + return result; + } + + template + HRESULT R33Dispatch(IDirect3DDevice9* device, + ActualDraw&& actualDraw, LowerR29Draw&& lowerR29Draw, + const char* site) noexcept + { + if (!R31StateBlockRecording) + R31FlushPendingStateBlockResync(device); + const bool telemetry = R33TelemetryEnabled(); + if (telemetry) + R31ObserveDraw(device); + + if (R31StateBlockRecording) + { + if (telemetry) + ++R31Frame.fallback; + return actualDraw(); + } + + if (R30CurrentPassIsScreenSpace2D()) + { + const auto hud = R33TryHud(device, + std::forward(actualDraw), site); + if (hud.handled) + return hud.hr; + } + else + { + const auto fast = R33TryFastWorld(device, + std::forward(actualDraw), site); + if (fast.handled) + return fast.hr; + } + + // Preserve R31's fail-closed boundary when StateBlock tracking + // is unreliable. Otherwise stale R29 effect/shadow caches can + // reclassify a draw that R33 already rejected using live state. + R31DiscardUnreliableDrawCaches(); + if (telemetry) + ++R31Frame.fallback; + return R32LowerFailClosed(device, + std::forward(lowerR29Draw)); + } + + HRESULT __stdcall DrawPrimitiveDestR33(IDirect3DDevice9* device, + D3DPRIMITIVETYPE type, UINT startVertex, UINT primitiveCount) + { + auto actual = [&]() { + return DrawPrimitiveHook.stdcall( + device, type, startVertex, primitiveCount); + }; + auto lower = [&]() { + return R30DrawPrimitiveR29Hook.stdcall( + device, type, startVertex, primitiveCount); + }; + return R33Dispatch(device, actual, lower, "R33/DrawPrimitive"); + } + + HRESULT __stdcall DrawIndexedPrimitiveDestR33( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + INT baseVertexIndex, UINT minVertexIndex, UINT numVertices, + UINT startIndex, UINT primitiveCount) + { + auto actual = [&]() { + return DrawIndexedPrimitiveHook.stdcall(device, type, + baseVertexIndex, minVertexIndex, numVertices, startIndex, + primitiveCount); + }; + auto lower = [&]() { + return R30DrawIndexedPrimitiveR29Hook.stdcall(device, + type, baseVertexIndex, minVertexIndex, numVertices, + startIndex, primitiveCount); + }; + return R33Dispatch(device, actual, lower, + "R33/DrawIndexedPrimitive"); + } + + HRESULT __stdcall DrawPrimitiveUPDestR33(IDirect3DDevice9* device, + D3DPRIMITIVETYPE type, UINT primitiveCount, const void* data, + UINT stride) + { + auto actual = [&]() { + return DrawPrimitiveUPHook.stdcall( + device, type, primitiveCount, data, stride); + }; + auto lower = [&]() { + return R30DrawPrimitiveUPR29Hook.stdcall( + device, type, primitiveCount, data, stride); + }; + return R33Dispatch(device, actual, lower, "R33/DrawPrimitiveUP"); + } + + HRESULT __stdcall DrawIndexedPrimitiveUPDestR33( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + UINT minVertexIndex, UINT numVertices, UINT primitiveCount, + const void* indexData, D3DFORMAT indexFormat, + const void* vertexData, UINT stride) + { + auto actual = [&]() { + return DrawIndexedPrimitiveUPHook.stdcall(device, type, + minVertexIndex, numVertices, primitiveCount, indexData, + indexFormat, vertexData, stride); + }; + auto lower = [&]() { + return R30DrawIndexedPrimitiveUPR29Hook.stdcall(device, + type, minVertexIndex, numVertices, primitiveCount, + indexData, indexFormat, vertexData, stride); + }; + return R33Dispatch(device, actual, lower, + "R33/DrawIndexedPrimitiveUP"); + } + + HRESULT __stdcall ResetDestR33(IDirect3DDevice9* device, + D3DPRESENT_PARAMETERS* params) + { + const bool gameDevice = IsGameDevice(device); + const HRESULT hr = R33ResetR32Hook.stdcall(device, params); + + if (gameDevice) + { + R33InvalidateDepthStencilCache(); + if (SUCCEEDED(hr)) + ++R33ResetSuccesses; + else + ++R33ResetFailures; + + if (!R33FirstResetLifecycleLogged) + { + R33FirstResetLifecycleLogged = true; + spdlog::info( + "VR R33 RESET: chained R33 -> R32 -> R22; R22 owns fail-close/baseline and R32 rearms caches only after successful Reset"); + } + } + return hr; + } + + void R33LogPerfWindow() noexcept + { + if (!R33TelemetryEnabled()) + return; + const ULONGLONG now = GetTickCount64(); + if (R33Perf.lastLogMs == 0) + { + R33Perf.lastLogMs = now; + R33Perf.syncs = R33DepthStencilSyncs; + R33Perf.hits = R33DepthStencilCacheHits; + R33Perf.live = R33DepthStencilLiveFallbacks; + R33Perf.readFail = R33DepthStencilReadFailures; + R33Perf.resetOk = R33ResetSuccesses; + R33Perf.resetFail = R33ResetFailures; + return; + } + if (now - R33Perf.lastLogMs < 5000) + return; + + spdlog::info( + "VR R33 PERF 5s: depthStencil[cacheHit={},liveSync={},unreliableLive={},readFail={}] reset[ok={},fail={}]", + R33DepthStencilCacheHits - R33Perf.hits, + R33DepthStencilSyncs - R33Perf.syncs, + R33DepthStencilLiveFallbacks - R33Perf.live, + R33DepthStencilReadFailures - R33Perf.readFail, + R33ResetSuccesses - R33Perf.resetOk, + R33ResetFailures - R33Perf.resetFail); + + R33Perf.lastLogMs = now; + R33Perf.syncs = R33DepthStencilSyncs; + R33Perf.hits = R33DepthStencilCacheHits; + R33Perf.live = R33DepthStencilLiveFallbacks; + R33Perf.readFail = R33DepthStencilReadFailures; + R33Perf.resetOk = R33ResetSuccesses; + R33Perf.resetFail = R33ResetFailures; + } + + HRESULT __stdcall PresentDestR33(IDirect3DDevice9* device, + const RECT* sourceRect, const RECT* destRect, + HWND destWindowOverride, const RGNDATA* dirtyRegion) + { + const HRESULT hr = R33PresentR32Hook.stdcall(device, + sourceRect, destRect, destWindowOverride, dirtyRegion); + if (IsGameDevice(device)) + R33LogPerfWindow(); + return hr; + } + + void R33RollbackHooks() noexcept + { + R33DrawIndexedPrimitiveUPR32Hook = {}; + R33DrawPrimitiveUPR32Hook = {}; + R33DrawIndexedPrimitiveR32Hook = {}; + R33DrawPrimitiveR32Hook = {}; + R33SetRenderStateR29Hook = {}; + R33PresentR32Hook = {}; + R33ResetR32Hook = {}; + } + + bool R33EnableHooks() noexcept + { + SafetyHookInline* hooks[]{ + &R33ResetR32Hook, + &R33PresentR32Hook, + &R33SetRenderStateR29Hook, + &R33DrawPrimitiveR32Hook, + &R33DrawIndexedPrimitiveR32Hook, + &R33DrawPrimitiveUPR32Hook, + &R33DrawIndexedPrimitiveUPR32Hook + }; + for (auto* hook : hooks) + if (!*hook || !hook->enable().has_value()) + return false; + return true; + } + + DWORD WINAPI R33InstallThread(void*) + { + using State = OutRunVR::RuntimeEligibility::InstallState; + R33InstallState.store(State::Pending, std::memory_order_release); + for (int attempt = 0; attempt < 4800; ++attempt) + { + const auto r32 = R32InstallState.load(std::memory_order_acquire); + if (r32 == State::Failed) + { + R33InstallState.store(State::Failed, std::memory_order_release); + HookManager::ReportAsyncResult( + "OpenXRVRStereoR33Dispatch", false); + return 0; + } + if (r32 == State::Ready) + { + const auto disabled = safetyhook::InlineHook::StartDisabled; + R33ResetR32Hook = safetyhook::create_inline( + reinterpret_cast(&ResetDestR32), + ResetDestR33, disabled); + R33PresentR32Hook = safetyhook::create_inline( + reinterpret_cast(&PresentDestR32), + PresentDestR33, disabled); + R33SetRenderStateR29Hook = safetyhook::create_inline( + reinterpret_cast(&SetRenderStateDestR29), + SetRenderStateDestR33, disabled); + R33DrawPrimitiveR32Hook = safetyhook::create_inline( + reinterpret_cast(&DrawPrimitiveDestR32), + DrawPrimitiveDestR33, disabled); + R33DrawIndexedPrimitiveR32Hook = safetyhook::create_inline( + reinterpret_cast(&DrawIndexedPrimitiveDestR32), + DrawIndexedPrimitiveDestR33, disabled); + R33DrawPrimitiveUPR32Hook = safetyhook::create_inline( + reinterpret_cast(&DrawPrimitiveUPDestR32), + DrawPrimitiveUPDestR33, disabled); + R33DrawIndexedPrimitiveUPR32Hook = safetyhook::create_inline( + reinterpret_cast(&DrawIndexedPrimitiveUPDestR32), + DrawIndexedPrimitiveUPDestR33, disabled); + + if (!R33EnableHooks()) + { + R33RollbackHooks(); + R33InstallState.store(State::Failed, + std::memory_order_release); + HookManager::ReportAsyncResult( + "OpenXRVRStereoR33Dispatch", false); + spdlog::error( + "VR R33: final reset/state/draw hook transaction was partial; corrected R32 remains authoritative"); + return 0; + } + + R33InstallState.store(State::Ready, std::memory_order_release); + HookManager::ReportAsyncResult( + "OpenXRVRStereoR33Dispatch", true); + spdlog::info( + "VR R33 DISPATCH: R33TryFastWorld/R33TryHud + direct R29 fallback READY; top-level telemetry counted once when enabled; corrected R32->R22 Reset lifecycle + depth/stencil cache ACTIVE"); + return 0; + } + Sleep(25); + } + + R33InstallState.store(State::Failed, std::memory_order_release); + HookManager::ReportAsyncResult("OpenXRVRStereoR33Dispatch", false); + return 0; + } + + class VRStereoR33DispatchHook final : public Hook + { + public: + std::string_view description() override + { + return "OpenXRVRStereoR33Dispatch"; + } + bool validate() override { return true; } + bool apply() override + { + HANDLE thread = CreateThread( + nullptr, 0, R33InstallThread, nullptr, 0, nullptr); + if (!thread) + { + R33InstallState.store( + OutRunVR::RuntimeEligibility::InstallState::Failed, + std::memory_order_release); + return false; + } + CloseHandle(thread); + return true; + } + static VRStereoR33DispatchHook instance; + }; + + VRStereoR33DispatchHook VRStereoR33DispatchHook::instance; + } +} diff --git a/src/vr/d3d9/stereo_renderer_r34.cpp b/src/vr/d3d9/stereo_renderer_r34.cpp new file mode 100644 index 000000000..2d5b4557d --- /dev/null +++ b/src/vr/d3d9/stereo_renderer_r34.cpp @@ -0,0 +1,330 @@ +// R34 final ResetEx replay health guard. +// +// R33 remains the draw/Reset implementation owner. R34 only adds the final +// compatibility boundary required by the R15 Ex overlay: a successful ResetEx +// is not enough to resume stereo if classic D3D9 state replay was incomplete. +// While that condition is active, every Present keeps the shared eligibility +// state fail-closed so a later baseline cannot accidentally re-enable stereo on +// stale ResetEx state. A later clean Reset clears the block. + +#include "stereo_renderer_r33.cpp" + +namespace OutRunVRD3D9ExUpgradeR13 +{ + bool LastResetStateReplaySucceeded() noexcept; +} + +namespace OutRunVRStereo +{ + namespace + { + SafetyHookInline R34ResetR33Hook{}; + SafetyHookInline R34PresentR33Hook{}; + SafetyHookInline R34DrawPrimitiveR33Hook{}; + SafetyHookInline R34DrawIndexedPrimitiveR33Hook{}; + SafetyHookInline R34DrawPrimitiveUPR33Hook{}; + SafetyHookInline R34DrawIndexedPrimitiveUPR33Hook{}; + std::atomic R34InstallState{ + OutRunVR::RuntimeEligibility::InstallState::Pending }; + std::atomic R34ResetReplayBlocked{false}; + std::uint64_t R34ReplayBlocks = 0; + std::uint64_t R34RasterGuardDraws = 0; + bool R34FirstReplayBlockLogged = false; + bool R34FirstRasterGuardLogged = false; + + void R34ForceResetReplayFailClosed(IDirect3DDevice9* device, + const char* site) noexcept + { + if (!device || !IsGameDevice(device)) + return; + + OutRunVR::RuntimeEligibility::SetExternalSafetyBlock(true); + R22FailClosedEligibility(); + R22ResetBaselineTracking(); + R22ShadowState = {}; + R33InvalidateDepthStencilCache(); + R29ArmMonoSafety(); + RightDepthSynchronized = false; + RightStencilSynchronized = false; + + if (!R34FirstReplayBlockLogged) + { + R34FirstReplayBlockLogged = true; + spdlog::error( + "VR R34 RESET: classic D3D9 state replay is unhealthy at {}; stereo remains fail-closed until a later clean ResetEx replay", + site ? site : "unknown"); + } + } + + template + HRESULT R34GuardStereoRasterState(IDirect3DDevice9* device, + DrawCall&& drawCall, const char* site) noexcept + { + if (!device || !IsGameDevice(device) || InternalStereoPass || + R31StateBlockRecording || !StereoWanted() || + !TargetIsBackBuffer()) + { + return drawCall(); + } + + // SetRenderTarget resets both viewport and scissor rectangle in + // D3D9. R33's direct fast/fallback dispatch can bypass R22's draw + // wrapper, so establish the already-validated R22 replay scope at + // the final draw boundary. Flush a pending StateBlock resync first + // so the scope never snapshots stale shadow state. + R31FlushPendingStateBlockResync(device); + R22ReplayScope replay(device); + if (!replay.stateValid) + { + R22FailClosedReplayState(device, site); + return drawCall(); + } + + ++R34RasterGuardDraws; + if (!R34FirstRasterGuardLogged) + { + R34FirstRasterGuardLogged = true; + spdlog::info( + "VR R34 RASTER GUARD: final draw boundary now preserves viewport/scissor across R33 fast and R29 fallback eye-target switches"); + } + return drawCall(); + } + + HRESULT __stdcall DrawPrimitiveDestR34(IDirect3DDevice9* device, + D3DPRIMITIVETYPE type, UINT startVertex, UINT primitiveCount) + { + auto call = [&]() { + return R34DrawPrimitiveR33Hook.stdcall( + device, type, startVertex, primitiveCount); + }; + return R34GuardStereoRasterState( + device, call, "R34/DrawPrimitive/raster-state"); + } + + HRESULT __stdcall DrawIndexedPrimitiveDestR34( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + INT baseVertexIndex, UINT minVertexIndex, UINT numVertices, + UINT startIndex, UINT primitiveCount) + { + auto call = [&]() { + return R34DrawIndexedPrimitiveR33Hook.stdcall( + device, type, baseVertexIndex, minVertexIndex, + numVertices, startIndex, primitiveCount); + }; + return R34GuardStereoRasterState( + device, call, "R34/DrawIndexedPrimitive/raster-state"); + } + + HRESULT __stdcall DrawPrimitiveUPDestR34(IDirect3DDevice9* device, + D3DPRIMITIVETYPE type, UINT primitiveCount, const void* data, + UINT stride) + { + auto call = [&]() { + return R34DrawPrimitiveUPR33Hook.stdcall( + device, type, primitiveCount, data, stride); + }; + return R34GuardStereoRasterState( + device, call, "R34/DrawPrimitiveUP/raster-state"); + } + + HRESULT __stdcall DrawIndexedPrimitiveUPDestR34( + IDirect3DDevice9* device, D3DPRIMITIVETYPE type, + UINT minVertexIndex, UINT numVertices, UINT primitiveCount, + const void* indexData, D3DFORMAT indexFormat, + const void* vertexData, UINT stride) + { + auto call = [&]() { + return R34DrawIndexedPrimitiveUPR33Hook.stdcall( + device, type, minVertexIndex, numVertices, primitiveCount, + indexData, indexFormat, vertexData, stride); + }; + return R34GuardStereoRasterState( + device, call, "R34/DrawIndexedPrimitiveUP/raster-state"); + } + + HRESULT __stdcall ResetDestR34(IDirect3DDevice9* device, + D3DPRESENT_PARAMETERS* params) + { + const bool gameDevice = IsGameDevice(device); + const HRESULT hr = R34ResetR33Hook.stdcall(device, params); + + if (!gameDevice) + return hr; + + if (!OutRunVRD3D9ExUpgradeR13::IsCompatDevice(device)) + { + R34ResetReplayBlocked.store(false, std::memory_order_release); + OutRunVR::RuntimeEligibility::SetExternalSafetyBlock(false); + return hr; + } + + const bool healthy = SUCCEEDED(hr) && + OutRunVRD3D9ExUpgradeR13::LastResetStateReplaySucceeded(); + R34ResetReplayBlocked.store(!healthy, std::memory_order_release); + OutRunVR::RuntimeEligibility::SetExternalSafetyBlock(!healthy); + if (!healthy) + { + ++R34ReplayBlocks; + R34ForceResetReplayFailClosed(device, "Reset"); + } + return hr; + } + + HRESULT __stdcall PresentDestR34(IDirect3DDevice9* device, + const RECT* sourceRect, const RECT* destRect, + HWND destWindowOverride, const RGNDATA* dirtyRegion) + { + const bool blocked = IsGameDevice(device) && + R34ResetReplayBlocked.load(std::memory_order_acquire); + if (blocked) + R34ForceResetReplayFailClosed(device, "Present/pre"); + + const HRESULT hr = R34PresentR33Hook.stdcall(device, + sourceRect, destRect, destWindowOverride, dirtyRegion); + + if (blocked) + R34ForceResetReplayFailClosed(device, "Present/post"); + return hr; + } + + void R34RollbackHooks() noexcept + { + R34DrawIndexedPrimitiveUPR33Hook = {}; + R34DrawPrimitiveUPR33Hook = {}; + R34DrawIndexedPrimitiveR33Hook = {}; + R34DrawPrimitiveR33Hook = {}; + R34PresentR33Hook = {}; + R34ResetR33Hook = {}; + } + + bool R34EnableHooks() noexcept + { + SafetyHookInline* hooks[]{ + &R34ResetR33Hook, + &R34PresentR33Hook, + &R34DrawPrimitiveR33Hook, + &R34DrawIndexedPrimitiveR33Hook, + &R34DrawPrimitiveUPR33Hook, + &R34DrawIndexedPrimitiveUPR33Hook + }; + for (auto* hook : hooks) + { + if (!*hook || !hook->enable().has_value()) + return false; + } + return true; + } + + DWORD WINAPI R34InstallThread(void*) + { + using State = OutRunVR::RuntimeEligibility::InstallState; + R34InstallState.store(State::Pending, std::memory_order_release); + + for (int attempt = 0; attempt < 4800; ++attempt) + { + const auto r33 = R33InstallState.load(std::memory_order_acquire); + if (r33 == State::Failed) + { + R34InstallState.store(State::Failed, + std::memory_order_release); + HookManager::ReportAsyncResult( + "OpenXRVRStereoR34ResetGuard", false); + return 0; + } + + if (r33 == State::Ready) + { + const auto disabled = safetyhook::InlineHook::StartDisabled; + R34ResetR33Hook = safetyhook::create_inline( + reinterpret_cast(&ResetDestR33), + ResetDestR34, disabled); + R34PresentR33Hook = safetyhook::create_inline( + reinterpret_cast(&PresentDestR33), + PresentDestR34, disabled); + R34DrawPrimitiveR33Hook = safetyhook::create_inline( + reinterpret_cast(&DrawPrimitiveDestR33), + DrawPrimitiveDestR34, disabled); + R34DrawIndexedPrimitiveR33Hook = safetyhook::create_inline( + reinterpret_cast(&DrawIndexedPrimitiveDestR33), + DrawIndexedPrimitiveDestR34, disabled); + R34DrawPrimitiveUPR33Hook = safetyhook::create_inline( + reinterpret_cast(&DrawPrimitiveUPDestR33), + DrawPrimitiveUPDestR34, disabled); + R34DrawIndexedPrimitiveUPR33Hook = safetyhook::create_inline( + reinterpret_cast(&DrawIndexedPrimitiveUPDestR33), + DrawIndexedPrimitiveUPDestR34, disabled); + + if (!R34EnableHooks()) + { + R34RollbackHooks(); + R34InstallState.store(State::Failed, + std::memory_order_release); + HookManager::ReportAsyncResult( + "OpenXRVRStereoR34ResetGuard", false); + spdlog::error( + "VR R34: Reset/Present/draw raster guard transaction failed; R33 remains authoritative"); + return 0; + } + + IDirect3DDevice9* const installedDevice = + StereoInstalledDevice.load(std::memory_order_acquire); + if (installedDevice && + OutRunVRD3D9ExUpgradeR13::IsCompatDevice(installedDevice)) + { + const bool healthy = + OutRunVRD3D9ExUpgradeR13::LastResetStateReplaySucceeded(); + R34ResetReplayBlocked.store(!healthy, + std::memory_order_release); + OutRunVR::RuntimeEligibility::SetExternalSafetyBlock( + !healthy); + if (!healthy) + R34ForceResetReplayFailClosed( + installedDevice, "Install/state-sync"); + } + + R34InstallState.store(State::Ready, + std::memory_order_release); + HookManager::ReportAsyncResult( + "OpenXRVRStereoR34ResetGuard", true); + spdlog::info( + "VR R34 RESET GUARD: R15 classic-state replay health now gates post-Reset stereo eligibility; raster guard preserves viewport/scissor across final stereo draw dispatch"); + return 0; + } + Sleep(25); + } + + R34InstallState.store(State::Failed, std::memory_order_release); + HookManager::ReportAsyncResult( + "OpenXRVRStereoR34ResetGuard", false); + return 0; + } + + class VRStereoR34ResetGuardHook final : public Hook + { + public: + std::string_view description() override + { + return "OpenXRVRStereoR34ResetGuard"; + } + bool validate() override { return true; } + bool apply() override + { + HANDLE thread = CreateThread( + nullptr, 0, R34InstallThread, nullptr, 0, nullptr); + if (!thread) + { + R34InstallState.store( + OutRunVR::RuntimeEligibility::InstallState::Failed, + std::memory_order_release); + return false; + } + CloseHandle(thread); + return true; + } + + static VRStereoR34ResetGuardHook instance; + }; + + VRStereoR34ResetGuardHook VRStereoR34ResetGuardHook::instance; + } +} diff --git a/src/vr/d3d9/stereo_renderer_r7.inc b/src/vr/d3d9/stereo_renderer_r7.inc new file mode 100644 index 000000000..60e4d2f49 --- /dev/null +++ b/src/vr/d3d9/stereo_renderer_r7.inc @@ -0,0 +1,1645 @@ +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "hook_mgr.hpp" +#include "plugin.hpp" +#include "game_addrs.hpp" +#include "vr_shared.hpp" + +// True-stereo D3D9 renderer. +// +// Simulation, input, timers, native FFB and OutRun's render queue are never +// replayed. For draws targeting the real game backbuffer, the normal draw is +// turned into the LEFT eye and the same D3D draw is repeated once into a +// matching full-size RIGHT-eye render target while all renderer state is alive. +// +// vr_renderer_probe.cpp remains the single head-tracking/recenter authority and +// latches one immutable head/FOV/IPD packet per successful game BeginScene. This +// file consumes that packet for every draw in the scene; it never re-samples the +// x64 host on a per-draw basis. +// +// World geometry is promoted to per-eye projection only when its live c64..c67 +// values exactly correspond to a renderer-probe-verified OutRun WVP upload AND +// the current vertex-shader epoch is the same epoch that performed that upload. +// HUD/effect draws therefore fall back to identical zero-disparity duplication. +// +// At Present both full-size eye surfaces are resolved and drawn side-by-side to +// the real backbuffer. The x64 host crops that SBS transport and submits it as +// XrCompositionLayerProjection using the exact host pose sequence published by +// this renderer. Stereo metadata is published only after the real D3D9 Present +// succeeds, so Desktop Duplication can never pair a new pose with an older frame. + +namespace Settings +{ + extern Setting VREnabled; + extern Setting VRAutoEnableWhenHostPresent; + extern Setting VRStereo; + extern Setting VRDirectGpuOnly; + extern Setting VRWorldScale; + extern Setting VRStereoDepth; + extern Setting VRTelemetry; +} + +#ifndef OUTRUN_VR_BUILD_SHA +#define OUTRUN_VR_BUILD_SHA "unknown" +#endif + +namespace OutRunVRStereo +{ + namespace + { + constexpr std::size_t ResetVtableIndex = 16; + constexpr std::size_t PresentVtableIndex = 17; + constexpr std::size_t SetRenderTargetVtableIndex = 37; + constexpr std::size_t SetDepthStencilSurfaceVtableIndex = 39; + constexpr std::size_t ClearVtableIndex = 43; + constexpr std::size_t DrawPrimitiveVtableIndex = 81; + constexpr std::size_t DrawIndexedPrimitiveVtableIndex = 82; + constexpr std::size_t DrawPrimitiveUPVtableIndex = 83; + constexpr std::size_t DrawIndexedPrimitiveUPVtableIndex = 84; + constexpr std::size_t SetRenderStateVtableIndex = 57; + constexpr std::size_t SetVertexShaderVtableIndex = 92; + constexpr std::size_t CreateQueryVtableIndex = 118; + constexpr std::size_t QueryIssueVtableIndex = 6; + constexpr UINT OutRunWvpRegister = 64; + constexpr UINT OutRunWvpRegisterCount = 4; + constexpr std::uintptr_t OutRunProjectionRva = 0x0095D8A0u - 0x00400000u; + constexpr float VerifiedWvpEpsilon = 1.0e-4f; + + SafetyHookInline ResetHook{}; + SafetyHookInline PresentHook{}; + SafetyHookInline SetRenderTargetHook{}; + SafetyHookInline SetDepthStencilSurfaceHook{}; + SafetyHookInline ClearHook{}; + SafetyHookInline DrawPrimitiveHook{}; + SafetyHookInline DrawIndexedPrimitiveHook{}; + SafetyHookInline DrawPrimitiveUPHook{}; + SafetyHookInline DrawIndexedPrimitiveUPHook{}; + SafetyHookInline SetRenderStateHook{}; + SafetyHookInline SetVertexShaderHook{}; + SafetyHookInline CreateQueryHook{}; + SafetyHookInline QueryIssueHook{}; + + HANDLE SharedMapping = nullptr; + OutRunVR::SharedPoseState* SharedState = nullptr; + HANDLE RenderFrameMapping = nullptr; + OutRunVR::SharedRenderFrameRing* RenderFrameRing = nullptr; + std::uint32_t RenderFrameRunGeneration = 0; + bool RenderFrameRingClaimed = false; + + IDirect3DSurface9* BackBuffer = nullptr; + IDirect3DSurface9* TrackedRenderTarget = nullptr; + IDirect3DSurface9* TrackedDepthStencil = nullptr; + IDirect3DSurface9* RightEyeSurface = nullptr; + IDirect3DSurface9* RightEyeDepth = nullptr; + IDirect3DTexture9* LeftResolveTexture = nullptr; + IDirect3DSurface9* LeftResolveSurface = nullptr; + IDirect3DTexture9* RightResolveTexture = nullptr; + IDirect3DSurface9* RightResolveSurface = nullptr; + + struct DirectTransportSlot + { + IDirect3DTexture9* leftTexture = nullptr; + IDirect3DSurface9* leftSurface = nullptr; + IDirect3DTexture9* rightTexture = nullptr; + IDirect3DSurface9* rightSurface = nullptr; + IDirect3DQuery9* fence = nullptr; + HANDLE leftHandle = nullptr; + HANDLE rightHandle = nullptr; + std::uint32_t frameId = 0; + bool producerPending = false; + std::uint32_t pendingFrameId = 0; + // True only after the producer fence completed and this slot was + // advertised to the host. Unpublished timed-out slots need no ACK. + bool published = false; + }; + + std::array DirectTransportSlots{}; + IDirect3DTexture9* DirectInteropProbeTexture = nullptr; + IDirect3DSurface9* DirectInteropProbeSurface = nullptr; + IDirect3DQuery9* DirectInteropProbeFence = nullptr; + HANDLE DirectInteropProbeHandle = nullptr; + std::uint32_t DirectInteropProbeToken = 0; + std::uint32_t ActiveDirectTransportSlot = 0; + D3DFORMAT DirectTransportFormat = D3DFMT_UNKNOWN; + std::uint32_t DirectTransportGeneration = 0; + std::uint32_t DirectTransportWidth = 0; + std::uint32_t DirectTransportHeight = 0; + bool DirectTransportResourcesReady = false; + bool DirectInteropVerified = false; + + D3DSURFACE_DESC BackBufferDesc{}; + bool StereoResourcesReady = false; + thread_local bool InternalStereoPass = false; + std::array AuxRenderTargetActive{}; + + std::atomic CurrentVertexShaderIdentity{ 0 }; + std::atomic VertexShaderSerial{ 0 }; + std::array TrackedRenderStateValue{}; + std::array TrackedRenderStateValid{}; + std::array TrackedRenderStateReads{}; + std::uint64_t TrackedRenderStateCacheHits = 0; + std::uint64_t TrackedRenderStateLiveValidations = 0; + std::uint64_t TrackedRenderStateMismatches = 0; + std::atomic ActiveOcclusionQueries{ 0 }; + std::atomic OcclusionQueryTrackingUnavailable{ false }; + std::atomic StereoBaseCallbacksArmed{ false }; + + D3DMATRIX CachedProjection{}; + D3DMATRIX CachedInverseProjection{}; + bool ProjectionInverseValid = false; + + bool FrameHadDuplicatedDraw = false; + bool FrameHadWorldStereo = false; + bool FrameRightDrawFailed = false; + bool FrameStereoIncomplete = false; + OutRunVR::StereoFailureReason FrameFailureReason = OutRunVR::StereoFailureNone; + std::uint32_t FrameStereoPoseSequence = 0; + OutRunVRRenderer::LatchedStereoFrame FrameStereoMetadata{}; + std::uint32_t StereoFrameCounter = 0; + bool RightDepthSynchronized = true; + bool RightStencilSynchronized = true; + bool LastStereoWanted = false; + bool FramePoseMismatchLogged = false; + std::uint64_t PresentEpoch = 1; + std::uint64_t LastMainDepthClearEpoch = 0; + DWORD LastMainDepthClearFlags = 0; + float LastMainDepthClearZ = 1.0f; + DWORD LastMainDepthClearStencil = 0; + D3DSURFACE_DESC LastMainDepthClearDesc{}; + + ULONGLONG LastSummaryMs = 0; + std::uint64_t DuplicatedDraws = 0; + std::uint64_t WorldStereoDraws = 0; + std::uint64_t NonWorldDuplicatedDraws = 0; + std::uint64_t StereoComposeSuccess = 0; + std::uint64_t StereoComposeFailure = 0; + std::uint64_t MrtRejectedDraws = 0; + std::uint64_t RestoreFailures = 0; + std::uint64_t WorldClassificationFailures = 0; + std::uint64_t OffscreenVerifiedWorldDraws = 0; + std::uint64_t PoseSequenceMismatchFrames = 0; + std::uint64_t MultiBeginScenePresents = 0; + std::uint64_t MaxBeginScenesPerPresent = 0; + std::uint64_t LastBeginSceneCountAtPresent = 0; + std::uint64_t FixedFunctionNonWorldDraws = 0; + std::uint64_t FixedFunctionWorldStereoDraws = 0; + std::uint64_t FixedFunctionSkyRotationOnlyDraws = 0; + bool FirstFixedFunctionWorldLogged = false; + bool FirstFixedFunctionSkyRotationLogged = false; + std::uint64_t OcclusionQueriesCreated = 0; + std::uint64_t OcclusionQueryBegins = 0; + std::uint64_t OcclusionQueryEnds = 0; + std::uint64_t OcclusionStereoRejects = 0; + std::uint64_t DirectTransportFrames = 0; + std::uint64_t DirectTransportFallbacks = 0; + std::uint64_t DirectTransportFenceTimeouts = 0; + std::uint64_t DirectTransportRingBackpressure = 0; + std::uint64_t DirectInteropProbeFailures = 0; + std::uint64_t FullMainDepthClears = 0; + std::uint64_t DepthBootstrapAttempts = 0; + std::uint64_t DepthBootstrapSuccesses = 0; + std::array FailureCounts{}; + bool FirstStereoActiveLogged = false; + bool FirstWorldStereoLogged = false; + bool FirstRestoreFailureLogged = false; + bool FirstMrtRejectLogged = false; + bool FirstOffscreenWorldLogged = false; + bool FirstClassificationFailureLogged = false; + bool FirstOcclusionRejectLogged = false; + bool FirstOcclusionTrackingUnavailableLogged = false; + bool FirstDirectTransportLogged = false; + bool FirstDirectFallbackLogged = false; + bool FirstDirectOnlyNoFrameLogged = false; + bool FirstDirectDeferredLogged = false; + bool FirstD3D9ExUnavailableLogged = false; + bool FirstAdapterMismatchLogged = false; + bool FirstInteropVerifiedLogged = false; + bool FirstMainDepthReuseLogged = false; + bool FirstMainClearLogged = false; + bool FirstDepthBootstrapLogged = false; + + template + void ReleaseCom(T*& value) + { + if (value) + { + value->Release(); + value = nullptr; + } + } + + void ReplaceSurfaceRef(IDirect3DSurface9*& slot, IDirect3DSurface9* value) + { + if (value) + value->AddRef(); + ReleaseCom(slot); + slot = value; + } + + struct InternalPassScope + { + bool previous = false; + InternalPassScope() : previous(InternalStereoPass) { InternalStereoPass = true; } + ~InternalPassScope() { InternalStereoPass = previous; } + }; + + bool IsGameDevice(IDirect3DDevice9* device) + { + return device && Game::D3DDevice_ptr && *Game::D3DDevice_ptr == device; + } + + bool FormatHasStencil(D3DFORMAT format); + + bool GameplayActive() + { + if (!Game::current_mode) + return false; + + // Reuse the game's established "in-game" state definition instead of + // treating only STATE_GAME as gameplay. GOAL/TIMEUP/TRYAGAIN, + // OUTRUNMILES and the single-player pause state can keep rendering the + // live 3D scene. Switching those frames to theater immediately clears + // stereo metadata and can leave the HMD without a fresh projection + // source while the monitor continues normally. + if (Game::is_in_game()) + return true; + + // WARP/RESTART are short race-adjacent transition states. Preserve + // gameplay presentation through them; the following menu/result states + // still fall back to theater normally. + switch (*Game::current_mode) + { + case GameState::STATE_WARP: + case GameState::STATE_RESTART: + return true; + default: + return false; + } + } + + bool HostRenderEligible() + { + if (!SharedState || SharedState->magic != OutRunVR::SharedMagic || + SharedState->protocolVersion != OutRunVR::SharedProtocolVersion || + SharedState->structSize != sizeof(OutRunVR::SharedPoseState)) + return false; + const std::uint32_t flags = SharedState->flags; + + // Game-side stereo source generation is gated only by hard host + // liveness/visibility. XrFrameState::shouldRender is transient compositor + // advice to the x64 host; using it here used to zero the SBS frame metadata + // and tear down the eye source for several Presents even though the host + // heartbeat/session remained healthy. The host still decides independently + // whether a given OpenXR frame submits a layer. + return (flags & OutRunVR::HostAlive) != 0 && + (flags & OutRunVR::SessionVisible) != 0; + } + + bool HostDirectTransportSupported() + { + return SharedState && (SharedState->flags & OutRunVR::HostDirectGpuTransport) != 0; + } + + bool HostDirectTransportReady() + { + return SharedState && (SharedState->flags & OutRunVR::HostDirectGpuReady) != 0; + } + + bool StereoWanted() + { + const bool hostEligible = HostRenderEligible(); + const bool wanted = Settings::VRStereo && GameplayActive() && hostEligible && + (Settings::VREnabled || Settings::VRAutoEnableWhenHostPresent); + if (wanted && !LastStereoWanted && TrackedDepthStencil) + { + RightDepthSynchronized = false; + D3DSURFACE_DESC d{}; + if (SUCCEEDED(TrackedDepthStencil->GetDesc(&d)) && FormatHasStencil(d.Format)) + RightStencilSynchronized = false; + } + LastStereoWanted = wanted; + return wanted; + } + + bool AnyAuxRenderTargetActive() + { + return AuxRenderTargetActive[0] || AuxRenderTargetActive[1] || AuxRenderTargetActive[2]; + } + + bool TargetIsBackBuffer() + { + return BackBuffer && TrackedRenderTarget && TrackedRenderTarget == BackBuffer; + } + + bool IsReadableRange(const void* address, std::size_t size) + { + if (!address || !size) + return false; + MEMORY_BASIC_INFORMATION info{}; + if (VirtualQuery(address, &info, sizeof(info)) != sizeof(info)) + return false; + if (info.State != MEM_COMMIT || (info.Protect & PAGE_GUARD) || (info.Protect & PAGE_NOACCESS)) + return false; + const auto begin = reinterpret_cast(address); + const auto regionBegin = reinterpret_cast(info.BaseAddress); + const auto regionEnd = regionBegin + info.RegionSize; + return begin >= regionBegin && begin + size >= begin && begin + size <= regionEnd; + } + + bool ImageContainsRange(std::uintptr_t rva, std::size_t size) + { + const auto base = reinterpret_cast(Module::ExeHandle); + if (!base || !IsReadableRange(reinterpret_cast(base), sizeof(IMAGE_DOS_HEADER))) + return false; + const auto* dos = reinterpret_cast(base); + if (dos->e_magic != IMAGE_DOS_SIGNATURE || dos->e_lfanew <= 0 || dos->e_lfanew > 0x100000) + return false; + const auto ntAddress = base + static_cast(dos->e_lfanew); + if (!IsReadableRange(reinterpret_cast(ntAddress), sizeof(IMAGE_NT_HEADERS))) + return false; + const auto* nt = reinterpret_cast(ntAddress); + if (nt->Signature != IMAGE_NT_SIGNATURE) + return false; + const std::size_t imageSize = nt->OptionalHeader.SizeOfImage; + return rva <= imageSize && size <= imageSize - rva; + } + + bool MatrixFinite(const D3DMATRIX& matrix) + { + for (int r = 0; r < 4; ++r) + for (int c = 0; c < 4; ++c) + if (!std::isfinite(matrix.m[r][c])) + return false; + return true; + } + + D3DMATRIX IdentityMatrix() + { + D3DMATRIX out{}; + out._11 = out._22 = out._33 = out._44 = 1.0f; + return out; + } + + D3DMATRIX MatrixFromQuaternionTranslation(const float qIn[4], const float position[3], float positionScale) + { + float x=qIn[0],y=qIn[1],z=qIn[2],w=qIn[3]; const float lenSq=x*x+y*y+z*z+w*w; + if (!std::isfinite(lenSq)||lenSq<=1.0e-12f) return IdentityMatrix(); const float inv=1.0f/std::sqrt(lenSq); x*=inv;y*=inv;z*=inv;w*=inv; + D3DMATRIX out=IdentityMatrix(); const float xx=x*x,yy=y*y,zz=z*z,xy=x*y,xz=x*z,yz=y*z,xw=x*w,yw=y*w,zw=z*w; + out._11=1-2*(yy+zz);out._12=2*(xy+zw);out._13=2*(xz-yw);out._21=2*(xy-zw);out._22=1-2*(xx+zz);out._23=2*(yz+xw); + out._31=2*(xz+yw);out._32=2*(yz-xw);out._33=1-2*(xx+yy);out._41=position[0]*positionScale;out._42=position[1]*positionScale;out._43=position[2]*positionScale; return out; + } + D3DMATRIX InverseRigid(const D3DMATRIX& m) + { + D3DMATRIX out=IdentityMatrix(); out._11=m._11;out._12=m._21;out._13=m._31;out._21=m._12;out._22=m._22;out._23=m._32;out._31=m._13;out._32=m._23;out._33=m._33; + out._41=-(m._41*out._11+m._42*out._21+m._43*out._31);out._42=-(m._41*out._12+m._42*out._22+m._43*out._32);out._43=-(m._41*out._13+m._42*out._23+m._43*out._33); return out; + } + + D3DMATRIX MultiplyMatrix(const D3DMATRIX& a, const D3DMATRIX& b) + { + D3DMATRIX out{}; + for (int r = 0; r < 4; ++r) + for (int c = 0; c < 4; ++c) + for (int k = 0; k < 4; ++k) + out.m[r][c] += a.m[r][k] * b.m[k][c]; + return out; + } + + D3DMATRIX TransposeMatrix(const D3DMATRIX& in) + { + D3DMATRIX out{}; + for (int r = 0; r < 4; ++r) + for (int c = 0; c < 4; ++c) + out.m[r][c] = in.m[c][r]; + return out; + } + + bool InvertMatrix(const D3DMATRIX& in, D3DMATRIX& out) + { + float a[4][8]{}; + for (int r = 0; r < 4; ++r) + { + for (int c = 0; c < 4; ++c) + a[r][c] = in.m[r][c]; + a[r][4 + r] = 1.0f; + } + for (int col = 0; col < 4; ++col) + { + int pivot = col; + float pivotAbs = std::fabs(a[pivot][col]); + for (int r = col + 1; r < 4; ++r) + { + const float candidate = std::fabs(a[r][col]); + if (candidate > pivotAbs) + { + pivot = r; + pivotAbs = candidate; + } + } + if (!std::isfinite(pivotAbs) || pivotAbs < 1.0e-8f) + return false; + if (pivot != col) + for (int c = 0; c < 8; ++c) + std::swap(a[pivot][c], a[col][c]); + const float divisor = a[col][col]; + for (int c = 0; c < 8; ++c) + a[col][c] /= divisor; + for (int r = 0; r < 4; ++r) + { + if (r == col) + continue; + const float factor = a[r][col]; + for (int c = 0; c < 8; ++c) + a[r][c] -= factor * a[col][c]; + } + } + for (int r = 0; r < 4; ++r) + for (int c = 0; c < 4; ++c) + out.m[r][c] = a[r][c + 4]; + return MatrixFinite(out); + } + + bool FloatArrayNear(const float* a, const float* b, std::size_t count, float epsilon) + { + if (!a || !b) + return false; + for (std::size_t i = 0; i < count; ++i) + if (!std::isfinite(a[i]) || !std::isfinite(b[i]) || std::fabs(a[i] - b[i]) > epsilon) + return false; + return true; + } + + D3DMATRIX ProjectionFromFov(const D3DMATRIX& base, const OutRunVR::SharedFov& fov) + { + const float tanLeft = std::tan(fov.angleLeft); + const float tanRight = std::tan(fov.angleRight); + const float tanUp = std::tan(fov.angleUp); + const float tanDown = std::tan(fov.angleDown); + const float width = tanRight - tanLeft; + const float height = tanUp - tanDown; + D3DMATRIX out{}; + out._11 = 2.0f / width; + out._22 = 2.0f / height; + out._31 = (tanRight + tanLeft) / width; + out._32 = (tanUp + tanDown) / height; + out._33 = base._33; + out._34 = base._34; + out._43 = base._43; + out._44 = base._44; + return out; + } + + bool ReadProjection(D3DMATRIX& projection) + { + float rendererProjection[16]{}; + if (OutRunVRRenderer::GetRendererBaseProjection(rendererProjection)) + std::memcpy(&projection, rendererProjection, sizeof(projection)); + else + { + if (!ImageContainsRange(OutRunProjectionRva, sizeof(D3DMATRIX))) return false; + const auto* projectionPtr = Module::exe_ptr(OutRunProjectionRva); + if (!IsReadableRange(projectionPtr, sizeof(D3DMATRIX))) return false; + std::memcpy(&projection, projectionPtr, sizeof(projection)); + } + return MatrixFinite(projection) && std::fabs(projection._34 + 1.0f) < 0.25f && std::fabs(projection._44) < 0.25f; + } + + bool GetInverseProjection(const D3DMATRIX& projection, D3DMATRIX& inverse) + { + if (!ProjectionInverseValid || std::memcmp(&projection, &CachedProjection, sizeof(projection)) != 0) + { + D3DMATRIX candidate{}; + if (!InvertMatrix(projection, candidate)) return false; + CachedProjection = projection; + CachedInverseProjection = candidate; + ProjectionInverseValid = true; + } + inverse = CachedInverseProjection; + return true; + } + + bool EnsureSharedState() + { + if (SharedState) + return SharedState->magic == OutRunVR::SharedMagic && SharedState->protocolVersion == OutRunVR::SharedProtocolVersion && SharedState->structSize == sizeof(OutRunVR::SharedPoseState); + SharedMapping = CreateFileMappingW(INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE, 0, static_cast(sizeof(OutRunVR::SharedPoseState)), OutRunVR::SharedMemoryName); + if (!SharedMapping) return false; + const bool existed = GetLastError() == ERROR_ALREADY_EXISTS; + SharedState = static_cast(MapViewOfFile(SharedMapping, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(OutRunVR::SharedPoseState))); + if (!SharedState) { CloseHandle(SharedMapping); SharedMapping = nullptr; return false; } + if (!existed) + { + std::memset(SharedState, 0, sizeof(*SharedState)); + SharedState->protocolVersion = OutRunVR::SharedProtocolVersion; + SharedState->structSize = sizeof(*SharedState); + MemoryBarrier(); + SharedState->magic = OutRunVR::SharedMagic; + } + return true; + } + + std::uint32_t MakeRenderFrameRunGeneration() noexcept + { + LARGE_INTEGER qpc{}; + QueryPerformanceCounter(&qpc); + std::uint32_t generation = + static_cast(qpc.LowPart) ^ + static_cast(qpc.HighPart) ^ + GetCurrentProcessId() ^ + GetTickCount() ^ + 0x52554E47u; // 'RUNG' + if (!generation) + generation = 1; + return generation; + } + + bool ClaimRenderFrameRingForCurrentRun() noexcept + { + if (!RenderFrameRing) + return false; + const bool headerValid = + RenderFrameRing->magic == OutRunVR::RenderFrameMagic && + RenderFrameRing->protocolVersion == OutRunVR::RenderFrameProtocolVersion && + RenderFrameRing->structSize == sizeof(OutRunVR::SharedRenderFrameRing) && + RenderFrameRing->slotCount == OutRunVR::RenderFrameRingSize; + if (!headerValid) + return false; + + const std::uint32_t self = GetCurrentProcessId(); + if (!RenderFrameRunGeneration) + RenderFrameRunGeneration = MakeRenderFrameRunGeneration(); + + if (RenderFrameRingClaimed) + return RenderFrameRing->clientPid == self && + RenderFrameRing->reserved0 == RenderFrameRunGeneration; + + // The host may keep Frame.v2 mapped across a fast game restart. + // Claim a new run generation under the ring seqlock and invalidate + // every old slot before publishing any frame from this process. + LONG ringSeq = InterlockedIncrement( + reinterpret_cast(&RenderFrameRing->publishSequence)); + if ((ringSeq & 1) == 0) + ringSeq = InterlockedIncrement( + reinterpret_cast(&RenderFrameRing->publishSequence)); + MemoryBarrier(); + + RenderFrameRing->clientPid = self; + RenderFrameRing->reserved0 = RenderFrameRunGeneration; + RenderFrameRing->latestSlot = 0; + for (auto& slot : RenderFrameRing->slots) + { + std::memset(&slot, 0, sizeof(slot)); + slot.protocolVersion = OutRunVR::RenderFrameProtocolVersion; + slot.structSize = sizeof(slot); + slot.clientPid = self; + slot.reserved[OutRunVR::RenderFrameRunGenerationIndex] = + RenderFrameRunGeneration; + MemoryBarrier(); + slot.magic = OutRunVR::RenderFrameMagic; + } + + MemoryBarrier(); + ringSeq = InterlockedIncrement( + reinterpret_cast(&RenderFrameRing->publishSequence)); + if (ringSeq & 1) + InterlockedIncrement( + reinterpret_cast(&RenderFrameRing->publishSequence)); + + RenderFrameRingClaimed = true; + spdlog::info( + "VR Frame.v2: claimed run identity pid={} generation=0x{:08x}; stale slots from previous game runs invalidated", + self, RenderFrameRunGeneration); + return true; + } + + bool EnsureRenderFrameState() + { + if (RenderFrameRing) + return ClaimRenderFrameRingForCurrentRun(); + + RenderFrameMapping = CreateFileMappingW(INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE, 0, static_cast(sizeof(OutRunVR::SharedRenderFrameRing)), OutRunVR::RenderFrameMemoryName); + if (!RenderFrameMapping) return false; + const bool existed = GetLastError() == ERROR_ALREADY_EXISTS; + RenderFrameRing = static_cast(MapViewOfFile(RenderFrameMapping, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(OutRunVR::SharedRenderFrameRing))); + if (!RenderFrameRing) return false; + if (!existed) + { + std::memset(RenderFrameRing, 0, sizeof(*RenderFrameRing)); + RenderFrameRing->protocolVersion = OutRunVR::RenderFrameProtocolVersion; + RenderFrameRing->structSize = sizeof(*RenderFrameRing); + RenderFrameRing->slotCount = OutRunVR::RenderFrameRingSize; + for (auto& slot : RenderFrameRing->slots) + { + slot.protocolVersion = OutRunVR::RenderFrameProtocolVersion; + slot.structSize = sizeof(slot); + slot.magic = OutRunVR::RenderFrameMagic; + } + MemoryBarrier(); + RenderFrameRing->magic = OutRunVR::RenderFrameMagic; + } + return ClaimRenderFrameRingForCurrentRun(); + } + + void PoisonFrame(OutRunVR::StereoFailureReason reason) + { + FrameStereoIncomplete = true; + if (FrameFailureReason == OutRunVR::StereoFailureNone) + { + FrameFailureReason = reason; + const auto index = static_cast(reason); + if (index < FailureCounts.size()) ++FailureCounts[index]; + } + } + + bool ReadTrackedRenderState(IDirect3DDevice9* device, + D3DRENDERSTATETYPE state, DWORD& out) noexcept; + + enum class EyeBuildResult { NonWorld, WorldStereo, FatalError }; + struct DrawStereoState + { + float originalConstants[16]{}; + float eyeConstants[2][16]{}; + D3DMATRIX fixedOriginalView{}; + D3DMATRIX fixedOriginalProjection{}; + D3DMATRIX fixedEyeView[2]{}; + D3DMATRIX fixedEyeProjection[2]{}; + bool worldStereo = false; + bool fixedFunctionWorld = false; + bool fixedFunctionRotationOnly = false; + std::uint32_t poseSequence = 0; + OutRunVRRenderer::LatchedStereoFrame stereoFrame{}; + }; + + EyeBuildResult BuildEyeConstants(IDirect3DDevice9* device, const OutRunVRRenderer::LatchedStereoFrame& stereo, DrawStereoState& state) + { + const std::uintptr_t trackedShader = + CurrentVertexShaderIdentity.load(std::memory_order_acquire); + if (trackedShader == 0) + { + // R13/R29 intentionally masks the tracked identity to zero for + // shader-based zero-disparity effects. Confirm the real D3D9 + // binding before treating this as a genuine fixed-function draw. + IDirect3DVertexShader9* boundShader = nullptr; + const HRESULT boundHr = device->GetVertexShader(&boundShader); + if (FAILED(boundHr)) + { + ++FixedFunctionNonWorldDraws; + return EyeBuildResult::NonWorld; + } + if (boundShader) + { + boundShader->Release(); + return EyeBuildResult::NonWorld; + } + + DWORD fvf = 0; + if (FAILED(device->GetFVF(&fvf)) || fvf == 0) + { + ++FixedFunctionNonWorldDraws; + return EyeBuildResult::NonWorld; + } + + const DWORD positionType = fvf & D3DFVF_POSITION_MASK; + switch (positionType) + { + case D3DFVF_XYZ: + case D3DFVF_XYZB1: + case D3DFVF_XYZB2: + case D3DFVF_XYZB3: + case D3DFVF_XYZB4: + case D3DFVF_XYZB5: + break; + default: + // XYZRHW and unknown/clip-space formats are screen-space + // or otherwise already transformed; keep zero disparity. + ++FixedFunctionNonWorldDraws; + return EyeBuildResult::NonWorld; + } + + if (FAILED(device->GetTransform( + D3DTS_VIEW, &state.fixedOriginalView)) || + FAILED(device->GetTransform( + D3DTS_PROJECTION, &state.fixedOriginalProjection))) + { + ++FixedFunctionNonWorldDraws; + return EyeBuildResult::NonWorld; + } + + const auto projectionClass = + OutRunVR::PassPolicy::ClassifyProjectionSignature( + state.fixedOriginalProjection._34, + state.fixedOriginalProjection._44); + if (projectionClass != + OutRunVR::PassPolicy::ProjectionClass::Perspective3D) + { + ++FixedFunctionNonWorldDraws; + return EyeBuildResult::NonWorld; + } + + // Car-select and other theater/menu previews also use + // fixed-function perspective XYZ. They are not VR world geometry + // and must not receive the headset inverse/eye transforms. + DWORD zEnable = D3DZB_TRUE; + if (!ReadTrackedRenderState(device, D3DRS_ZENABLE, zEnable)) + { + ++FixedFunctionNonWorldDraws; + return EyeBuildResult::NonWorld; + } + const auto fixedPolicy = + OutRunVR::PassPolicy::ClassifyFixedFunctionStereo( + projectionClass, GameplayActive(), + zEnable != D3DZB_FALSE); + if (fixedPolicy == + OutRunVR::PassPolicy::FixedFunctionStereoPolicy::NonWorld) + { + ++FixedFunctionNonWorldDraws; + return EyeBuildResult::NonWorld; + } + const bool rotationOnly = + fixedPolicy == + OutRunVR::PassPolicy::FixedFunctionStereoPolicy::SkyRotationOnly; + + if (stereo.poseSequence == 0 || + !MatrixFinite(state.fixedOriginalView) || + !MatrixFinite(state.fixedOriginalProjection)) + return EyeBuildResult::FatalError; + + float headInverseRaw[16]{}; + std::uint32_t headPoseSequence = 0; + D3DMATRIX headInverse{}; + if (!OutRunVRRenderer::GetLatchedHeadInverse( + headInverseRaw, headPoseSequence) || + headPoseSequence != stereo.poseSequence) + return EyeBuildResult::FatalError; + std::memcpy(&headInverse, headInverseRaw, + sizeof(headInverse)); + if (!MatrixFinite(headInverse)) + return EyeBuildResult::FatalError; + + D3DMATRIX appliedHeadInverse = headInverse; + if (rotationOnly) + { + appliedHeadInverse._41 = 0.0f; + appliedHeadInverse._42 = 0.0f; + appliedHeadInverse._43 = 0.0f; + } + + for (int eye = 0; eye < 2; ++eye) + { + float eyeOffset[3]{ + stereo.eyeOffset[eye][0], + stereo.eyeOffset[eye][1], + stereo.eyeOffset[eye][2] + }; + if (rotationOnly) + eyeOffset[0] = eyeOffset[1] = eyeOffset[2] = 0.0f; + const D3DMATRIX eyePose = + MatrixFromQuaternionTranslation( + stereo.eyeOrientation[eye], eyeOffset, + Settings::VRWorldScale * Settings::VRStereoDepth); + const D3DMATRIX eyeInverse = InverseRigid(eyePose); + state.fixedEyeView[eye] = + MultiplyMatrix( + MultiplyMatrix( + state.fixedOriginalView, appliedHeadInverse), + eyeInverse); + state.fixedEyeProjection[eye] = + ProjectionFromFov( + state.fixedOriginalProjection, + stereo.eyeFov[eye]); + if (!MatrixFinite(state.fixedEyeView[eye]) || + !MatrixFinite(state.fixedEyeProjection[eye])) + return EyeBuildResult::FatalError; + } + + state.fixedFunctionWorld = true; + state.fixedFunctionRotationOnly = rotationOnly; + state.worldStereo = true; + state.poseSequence = stereo.poseSequence; + if (rotationOnly) + { + ++FixedFunctionSkyRotationOnlyDraws; + if (!FirstFixedFunctionSkyRotationLogged) + { + FirstFixedFunctionSkyRotationLogged = true; + spdlog::info( + "VR SKY ROTATION: depth-off fixed-function perspective draw keeps headset rotation/FOV but removes head/IPD translation"); + } + } + else + { + ++FixedFunctionWorldStereoDraws; + if (!FirstFixedFunctionWorldLogged) + { + FirstFixedFunctionWorldLogged = true; + spdlog::info( + "VR FIXEDFN WORLD: depth-tested perspective XYZ receives latched head inverse + eye-local IPD/FOV"); + } + } + return EyeBuildResult::WorldStereo; + } + + float verifiedConstants[16]{}; + std::uint32_t verifiedGeneration = 0, verifiedPoseSequence = 0; + std::uintptr_t verifiedShaderIdentity = 0; + std::uint64_t verifiedShaderSerial = 0; + if (!OutRunVRRenderer::GetLastVerifiedWvp(verifiedConstants, verifiedGeneration, verifiedPoseSequence, verifiedShaderIdentity, verifiedShaderSerial)) return EyeBuildResult::NonWorld; + std::uintptr_t shaderIdentity = 0; std::uint64_t shaderSerial = 0; + if (!GetCurrentShaderEpoch(shaderIdentity, shaderSerial)) return EyeBuildResult::FatalError; + if (verifiedShaderIdentity != shaderIdentity || verifiedShaderSerial != shaderSerial) return EyeBuildResult::NonWorld; + if (verifiedGeneration == 0 || verifiedPoseSequence != stereo.poseSequence) return EyeBuildResult::FatalError; + if (FAILED(device->GetVertexShaderConstantF(OutRunWvpRegister, state.originalConstants, OutRunWvpRegisterCount))) return EyeBuildResult::FatalError; + if (!FloatArrayNear(state.originalConstants, verifiedConstants, 16, VerifiedWvpEpsilon)) return EyeBuildResult::FatalError; + D3DMATRIX projection{}, invProjection{}; + if (!ReadProjection(projection) || !GetInverseProjection(projection, invProjection)) return EyeBuildResult::FatalError; + D3DMATRIX uploadedT{}; std::memcpy(&uploadedT, state.originalConstants, sizeof(uploadedT)); + const D3DMATRIX currentWvp = TransposeMatrix(uploadedT); + const D3DMATRIX correctedWorldView = MultiplyMatrix(currentWvp, invProjection); + if (!MatrixFinite(correctedWorldView)) return EyeBuildResult::FatalError; + for (int eye = 0; eye < 2; ++eye) + { + const D3DMATRIX eyePose = MatrixFromQuaternionTranslation(stereo.eyeOrientation[eye], stereo.eyeOffset[eye], Settings::VRWorldScale * Settings::VRStereoDepth); + const D3DMATRIX eyeInverse = InverseRigid(eyePose); + const D3DMATRIX eyeProjection = ProjectionFromFov(projection, stereo.eyeFov[eye]); + const D3DMATRIX eyeWvp = MultiplyMatrix(MultiplyMatrix(correctedWorldView, eyeInverse), eyeProjection); + if (!MatrixFinite(eyeWvp)) return EyeBuildResult::FatalError; + const D3DMATRIX eyeWvpT = TransposeMatrix(eyeWvp); std::memcpy(state.eyeConstants[eye], &eyeWvpT, sizeof(eyeWvpT)); + } + state.worldStereo = true; state.poseSequence = stereo.poseSequence; return EyeBuildResult::WorldStereo; + } + + void InvalidateTrackedRenderStates() noexcept + { + TrackedRenderStateValid.fill(false); + TrackedRenderStateReads.fill(0); + } + + bool ReadTrackedRenderState(IDirect3DDevice9* device, + D3DRENDERSTATETYPE state, DWORD& out) noexcept + { + const auto index = static_cast(state); + if (!device || index >= TrackedRenderStateValue.size()) + return false; + if (TrackedRenderStateValid[index]) + { + auto& reads = TrackedRenderStateReads[index]; + if (++reads < 16) + { + ++TrackedRenderStateCacheHits; + out = TrackedRenderStateValue[index]; + return true; + } + reads = 0; + } + DWORD live = 0; + if (FAILED(device->GetRenderState(state, &live))) + { + if (!TrackedRenderStateValid[index]) + return false; + out = TrackedRenderStateValue[index]; + return true; + } + ++TrackedRenderStateLiveValidations; + if (TrackedRenderStateValid[index] && + TrackedRenderStateValue[index] != live) + ++TrackedRenderStateMismatches; + TrackedRenderStateValue[index] = live; + TrackedRenderStateValid[index] = true; + out = live; + return true; + } + + void PrimeTrackedRenderStates(IDirect3DDevice9* device) noexcept + { + InvalidateTrackedRenderStates(); + if (!device) return; + constexpr D3DRENDERSTATETYPE states[] = { + D3DRS_ZENABLE, D3DRS_ZWRITEENABLE, + D3DRS_ALPHABLENDENABLE, D3DRS_ALPHATESTENABLE, + D3DRS_CULLMODE, D3DRS_COLORWRITEENABLE, + D3DRS_STENCILENABLE, D3DRS_STENCILWRITEMASK, + D3DRS_STENCILFAIL, D3DRS_STENCILZFAIL, + D3DRS_STENCILPASS, D3DRS_TWOSIDEDSTENCILMODE, + D3DRS_CCW_STENCILFAIL, D3DRS_CCW_STENCILZFAIL, + D3DRS_CCW_STENCILPASS + }; + for (const auto state : states) + { + const auto index = static_cast(state); + DWORD value = 0; + if (index < TrackedRenderStateValue.size() && + SUCCEEDED(device->GetRenderState(state, &value))) + { + TrackedRenderStateValue[index] = value; + TrackedRenderStateValid[index] = true; + } + } + } + + HRESULT __stdcall SetRenderStateDest(IDirect3DDevice9* device, + D3DRENDERSTATETYPE state, DWORD value) + { + const HRESULT hr = SetRenderStateHook.stdcall( + device, state, value); + const auto index = static_cast(state); + if (SUCCEEDED(hr) && !InternalStereoPass && IsGameDevice(device) && + index < TrackedRenderStateValue.size()) + { + TrackedRenderStateValue[index] = value; + TrackedRenderStateValid[index] = true; + TrackedRenderStateReads[index] = 0; + } + return hr; + } + + bool CurrentDrawMatchesVerifiedWorld(IDirect3DDevice9* device) + { + (void)device; + float verified[16]{}, current[16]{}; + std::uint32_t generation = 0, poseSequence = 0; + std::uintptr_t verifiedShader = 0, currentShader = 0, writeShader = 0; + std::uint64_t verifiedSerial = 0, currentSerial = 0; + std::uint64_t writeSerial = 0, writeDrawSerial = 0, writeShaderSerial = 0; + if (!OutRunVRRenderer::GetLastVerifiedWvp( + verified, generation, poseSequence, + verifiedShader, verifiedSerial) || + generation == 0 || poseSequence == 0) + return false; + if (!GetCurrentShaderEpoch(currentShader, currentSerial) || + currentShader != verifiedShader || currentSerial != verifiedSerial) + return false; + if (!OutRunVRRenderer::GetLastGameWvpWrite( + current, writeSerial, writeDrawSerial, + writeShader, writeShaderSerial) || + writeShader != currentShader || writeShaderSerial != currentSerial) + return false; + return FloatArrayNear(current, verified, 16, VerifiedWvpEpsilon); + } + + bool SetWvpOneRegisterAtATime(IDirect3DDevice9* device, const float* constants) + { + for (UINT reg = 0; reg < OutRunWvpRegisterCount; ++reg) + if (FAILED(device->SetVertexShaderConstantF(OutRunWvpRegister + reg, constants + reg * 4, 1))) return false; + return true; + } + + bool SetFixedFunctionEyeTransforms(IDirect3DDevice9* device, + const DrawStereoState& draw, int eye) + { + return device && eye >= 0 && eye < 2 && + SUCCEEDED(device->SetTransform( + D3DTS_VIEW, &draw.fixedEyeView[eye])) && + SUCCEEDED(device->SetTransform( + D3DTS_PROJECTION, &draw.fixedEyeProjection[eye])); + } + + bool RestoreFixedFunctionTransforms(IDirect3DDevice9* device, + const DrawStereoState& draw) + { + return device && + SUCCEEDED(device->SetTransform( + D3DTS_VIEW, &draw.fixedOriginalView)) && + SUCCEEDED(device->SetTransform( + D3DTS_PROJECTION, &draw.fixedOriginalProjection)); + } + + bool FormatHasStencil(D3DFORMAT format) + { + switch (format) { case D3DFMT_D15S1: case D3DFMT_D24S8: case D3DFMT_D24X4S4: case D3DFMT_D24FS8: return true; default: return false; } + } + bool SurfaceHasStencil(IDirect3DSurface9* surface) { if (!surface) return false; D3DSURFACE_DESC desc{}; return SUCCEEDED(surface->GetDesc(&desc)) && FormatHasStencil(desc.Format); } + bool DepthTestActive(IDirect3DDevice9* device) { if (!device || !TrackedDepthStencil) return false; DWORD enabled=D3DZB_TRUE; if (!ReadTrackedRenderState(device,D3DRS_ZENABLE,enabled)) return true; return enabled!=D3DZB_FALSE; } + bool StencilTestActive(IDirect3DDevice9* device) { if (!device || !SurfaceHasStencil(TrackedDepthStencil)) return false; DWORD enabled=FALSE; if (!ReadTrackedRenderState(device,D3DRS_STENCILENABLE,enabled)) return true; return enabled!=FALSE; } + bool LeftDrawMayWriteDepth(IDirect3DDevice9* device) { if(!device||!TrackedDepthStencil)return false;DWORD zEnable=D3DZB_TRUE,zWrite=TRUE;if(!ReadTrackedRenderState(device,D3DRS_ZENABLE,zEnable)||!ReadTrackedRenderState(device,D3DRS_ZWRITEENABLE,zWrite))return true;return zEnable!=D3DZB_FALSE&&zWrite!=FALSE; } + bool LeftDrawMayWriteStencil(IDirect3DDevice9* device) + { + if(!device||!SurfaceHasStencil(TrackedDepthStencil))return false;DWORD enabled=FALSE,writeMask=0;if(!ReadTrackedRenderState(device,D3DRS_STENCILENABLE,enabled)||!ReadTrackedRenderState(device,D3DRS_STENCILWRITEMASK,writeMask))return true;if(!enabled||writeMask==0)return false;DWORD fail=D3DSTENCILOP_KEEP,zfail=D3DSTENCILOP_KEEP,pass=D3DSTENCILOP_KEEP;if(!ReadTrackedRenderState(device,D3DRS_STENCILFAIL,fail)||!ReadTrackedRenderState(device,D3DRS_STENCILZFAIL,zfail)||!ReadTrackedRenderState(device,D3DRS_STENCILPASS,pass))return true;if(fail!=D3DSTENCILOP_KEEP||zfail!=D3DSTENCILOP_KEEP||pass!=D3DSTENCILOP_KEEP)return true;DWORD twoSided=FALSE;if(!ReadTrackedRenderState(device,D3DRS_TWOSIDEDSTENCILMODE,twoSided))return true;if(!twoSided)return false;DWORD cFail=D3DSTENCILOP_KEEP,cZFail=D3DSTENCILOP_KEEP,cPass=D3DSTENCILOP_KEEP;if(!ReadTrackedRenderState(device,D3DRS_CCW_STENCILFAIL,cFail)||!ReadTrackedRenderState(device,D3DRS_CCW_STENCILZFAIL,cZFail)||!ReadTrackedRenderState(device,D3DRS_CCW_STENCILPASS,cPass))return true;return cFail!=D3DSTENCILOP_KEEP||cZFail!=D3DSTENCILOP_KEEP||cPass!=D3DSTENCILOP_KEEP; + } + void InvalidateRightDepthStencilIfLeftMayWrite(IDirect3DDevice9* device) { if(RightDepthSynchronized&&LeftDrawMayWriteDepth(device))RightDepthSynchronized=false;if(RightStencilSynchronized&&LeftDrawMayWriteStencil(device))RightStencilSynchronized=false; } + bool ViewportCoversStereoBackbuffer(IDirect3DDevice9* device) { if(!device||!BackBufferDesc.Width||!BackBufferDesc.Height)return false;D3DVIEWPORT9 viewport{};if(FAILED(device->GetViewport(&viewport)))return false;return viewport.X==0&&viewport.Y==0&&viewport.Width==BackBufferDesc.Width&&viewport.Height==BackBufferDesc.Height; } + bool ClearCoversStereoBackbuffer(IDirect3DDevice9* device,DWORD count,const D3DRECT* rects) + { + if(!ViewportCoversStereoBackbuffer(device))return false; + if(count==0)return true; + if(!rects)return false; + for(DWORD i=0;i=static_cast(BackBufferDesc.Width)&&r.y2>=static_cast(BackBufferDesc.Height))return true; + } + return false; + } + bool DepthDescMatches(const D3DSURFACE_DESC&a,const D3DSURFACE_DESC&b) + { + return a.Width==b.Width&&a.Height==b.Height&&a.Format==b.Format&&a.MultiSampleType==b.MultiSampleType&&a.MultiSampleQuality==b.MultiSampleQuality; + } + void RecordFullMainDepthClear(IDirect3DDevice9*device,DWORD count,const D3DRECT*rects,DWORD flags,float z,DWORD stencil) + { + const DWORD depthFlags=flags&(D3DCLEAR_ZBUFFER|D3DCLEAR_STENCIL); + if(!depthFlags||!TargetIsBackBuffer()||!TrackedDepthStencil||!ClearCoversStereoBackbuffer(device,count,rects))return; + D3DSURFACE_DESC desc{};if(FAILED(TrackedDepthStencil->GetDesc(&desc)))return; + LastMainDepthClearEpoch=PresentEpoch;LastMainDepthClearFlags=depthFlags;LastMainDepthClearZ=z;LastMainDepthClearStencil=stencil;LastMainDepthClearDesc=desc;++FullMainDepthClears; + if(!FirstMainClearLogged){FirstMainClearLogged=true;spdlog::info("VR stereo: full main depth clear observed count={} flags=0x{:x} z={} stencil={} epoch={}",count,static_cast(depthFlags),z,stencil,PresentEpoch);} + } + + void ReleaseStereoResources() + { + StereoResourcesReady=false;DirectTransportResourcesReady=false; + for(auto&slot:DirectTransportSlots){ReleaseCom(slot.fence);ReleaseCom(slot.leftSurface);ReleaseCom(slot.leftTexture);ReleaseCom(slot.rightSurface);ReleaseCom(slot.rightTexture);slot={};} + ReleaseCom(DirectInteropProbeFence);ReleaseCom(DirectInteropProbeSurface);ReleaseCom(DirectInteropProbeTexture);DirectInteropProbeHandle=nullptr;DirectInteropProbeToken=0;DirectInteropVerified=false; + if(SharedState&&SharedState->magic==OutRunVR::SharedMagic){InterlockedExchange(reinterpret_cast(&SharedState->clientInteropProbeHandle),0);InterlockedExchange(reinterpret_cast(&SharedState->clientInteropProbeToken),0);} + DirectTransportFormat=D3DFMT_UNKNOWN;ProjectionInverseValid=false;ReleaseCom(RightEyeSurface);ReleaseCom(RightEyeDepth);ReleaseCom(LeftResolveSurface);ReleaseCom(LeftResolveTexture);ReleaseCom(RightResolveSurface);ReleaseCom(RightResolveTexture);ReleaseCom(BackBuffer);ReleaseCom(TrackedRenderTarget);ReleaseCom(TrackedDepthStencil);RightDepthSynchronized=true;RightStencilSynchronized=true;BackBufferDesc={}; + } + bool CreateRightDepthForTracked(IDirect3DDevice9* device) + { + ReleaseCom(RightEyeDepth);RightDepthSynchronized=TrackedDepthStencil==nullptr;RightStencilSynchronized=true;if(!TrackedDepthStencil)return true;D3DSURFACE_DESC depthDesc{};if(FAILED(TrackedDepthStencil->GetDesc(&depthDesc)))return false;RightStencilSynchronized=!FormatHasStencil(depthDesc.Format);return SUCCEEDED(device->CreateDepthStencilSurface(BackBufferDesc.Width,BackBufferDesc.Height,depthDesc.Format,depthDesc.MultiSampleType,depthDesc.MultiSampleQuality,FALSE,&RightEyeDepth,nullptr)); + } + bool InitializeAuxRenderTargetState(IDirect3DDevice9* device){for(DWORD index=1;index<=3;++index){IDirect3DSurface9*surface=nullptr;const HRESULT hr=device->GetRenderTarget(index,&surface);AuxRenderTargetActive[index-1]=SUCCEEDED(hr)&&surface!=nullptr;if(surface)surface->Release();}return true;} + D3DFORMAT ChooseDirectTransportFormat(D3DFORMAT source){switch(source){case D3DFMT_A2B10G10R10:return D3DFMT_A2B10G10R10;case D3DFMT_A16B16G16R16F:return D3DFMT_A16B16G16R16F;case D3DFMT_A8R8G8B8:return D3DFMT_A8R8G8B8;default:return D3DFMT_A8R8G8B8;}} + bool CanConvertForDirectTransport(IDirect3DDevice9* device,D3DFORMAT source,D3DFORMAT target){if(!device)return false;if(source==target)return true;D3DDEVICE_CREATION_PARAMETERS cp{};if(FAILED(device->GetCreationParameters(&cp)))return false;IDirect3D9*d3d=nullptr;if(FAILED(device->GetDirect3D(&d3d))||!d3d)return false;const HRESULT hr=d3d->CheckDeviceFormatConversion(cp.AdapterOrdinal,cp.DeviceType,source,target);d3d->Release();return SUCCEEDED(hr);} + bool FrameIdAtOrAfter(std::uint32_t candidate,std::uint32_t reference){return reference==0||static_cast(candidate-reference)>=0;} + bool WaitForEventQuery(IDirect3DQuery9* query,ULONGLONG timeoutMs){if(!query)return false;const ULONGLONG deadline=GetTickCount64()+timeoutMs;for(;;){const HRESULT ready=query->GetData(nullptr,0,D3DGETDATA_FLUSH);if(ready==S_OK)return true;if(ready!=S_FALSE)return false;if(GetTickCount64()>=deadline)return false;Sleep(0);}} + + bool QueryGameAdapterLuid(IDirect3DDevice9* device,LUID& luid) + { + IDirect3DDevice9Ex*deviceEx=nullptr;if(!device||FAILED(device->QueryInterface(__uuidof(IDirect3DDevice9Ex),reinterpret_cast(&deviceEx)))||!deviceEx){if(!FirstD3D9ExUnavailableLogged){FirstD3D9ExUnavailableLogged=true;spdlog::warn("VR direct transport: game device is not D3D9Ex; zero-copy disabled, SBS/Desktop Duplication remains active");}return false;}D3DDEVICE_CREATION_PARAMETERS cp{};IDirect3D9*base=nullptr;IDirect3D9Ex*d3dEx=nullptr;const bool ok=SUCCEEDED(deviceEx->GetCreationParameters(&cp))&&SUCCEEDED(deviceEx->GetDirect3D(&base))&&base&&SUCCEEDED(base->QueryInterface(__uuidof(IDirect3D9Ex),reinterpret_cast(&d3dEx)))&&d3dEx&&SUCCEEDED(d3dEx->GetAdapterLUID(cp.AdapterOrdinal,&luid));ReleaseCom(d3dEx);ReleaseCom(base);ReleaseCom(deviceEx);return ok; + } + bool EnsureDirectInteropProbe(IDirect3DDevice9* device) + { + if(!HostDirectTransportSupported()||!EnsureSharedState()||(SharedState->flags&OutRunVR::HostAdapterLuidValid)==0)return false;LUID gameLuid{};if(!QueryGameAdapterLuid(device,gameLuid))return false;InterlockedExchange(reinterpret_cast(&SharedState->clientAdapterLuidLow),static_cast(gameLuid.LowPart));InterlockedExchange(reinterpret_cast(&SharedState->clientAdapterLuidHigh),static_cast(gameLuid.HighPart));const bool adapterMatches=SharedState->hostAdapterLuidLow==gameLuid.LowPart&&SharedState->hostAdapterLuidHigh==static_cast(gameLuid.HighPart);if(!adapterMatches){if(!FirstAdapterMismatchLogged){FirstAdapterMismatchLogged=true;spdlog::warn("VR direct transport: OpenXR D3D11 adapter LUID does not match the game D3D9Ex adapter; forcing SBS fallback");}return false;}if(DirectInteropProbeToken&&SharedState->hostInteropProbeAckToken==DirectInteropProbeToken){DirectInteropVerified=true;if(!FirstInteropVerifiedLogged){FirstInteropVerifiedLogged=true;spdlog::info("VR direct transport: D3D9Ex -> D3D11 shared-resource probe verified on the OpenXR adapter");}return true;}if(!DirectInteropProbeTexture){HANDLE handle=nullptr;if(FAILED(device->CreateTexture(1,1,1,D3DUSAGE_RENDERTARGET,D3DFMT_A8R8G8B8,D3DPOOL_DEFAULT,&DirectInteropProbeTexture,&handle))||!DirectInteropProbeTexture||!handle||FAILED(DirectInteropProbeTexture->GetSurfaceLevel(0,&DirectInteropProbeSurface))||FAILED(device->ColorFill(DirectInteropProbeSurface,nullptr,D3DCOLOR_ARGB(0xFF,0x7B,0x7B,0x7B)))||FAILED(device->CreateQuery(D3DQUERYTYPE_EVENT,&DirectInteropProbeFence))||!DirectInteropProbeFence||FAILED(DirectInteropProbeFence->Issue(D3DISSUE_END))||!WaitForEventQuery(DirectInteropProbeFence,50)){++DirectInteropProbeFailures;ReleaseCom(DirectInteropProbeFence);ReleaseCom(DirectInteropProbeSurface);ReleaseCom(DirectInteropProbeTexture);DirectInteropProbeHandle=nullptr;return false;}DirectInteropProbeHandle=handle;DirectInteropProbeToken=static_cast(GetTickCount())^GetCurrentProcessId()^0x4F525652u;if(!DirectInteropProbeToken)DirectInteropProbeToken=1;InterlockedExchange(reinterpret_cast(&SharedState->hostInteropProbeAckToken),0);InterlockedExchange(reinterpret_cast(&SharedState->clientInteropProbeHandle),static_cast(reinterpret_cast(DirectInteropProbeHandle)));InterlockedExchange(reinterpret_cast(&SharedState->clientInteropProbeToken),static_cast(DirectInteropProbeToken));}return false; + } + void ReleaseDirectTransportSlots(){DirectTransportResourcesReady=false;DirectTransportWidth=0;DirectTransportHeight=0;for(auto&slot:DirectTransportSlots){ReleaseCom(slot.fence);ReleaseCom(slot.leftSurface);ReleaseCom(slot.leftTexture);ReleaseCom(slot.rightSurface);ReleaseCom(slot.rightTexture);slot={};}} + void DirectTransportTargetSize(IDirect3DDevice9* device,UINT& width,UINT& height) + { + // Once a DirectGPU eye size is active, keep it as the fallback for an + // unstable seqlock read. Falling back to the desktop backbuffer for one + // read used to recreate the whole 4-slot ring 2124x2284 <-> 3440x1440. + width=DirectTransportWidth?DirectTransportWidth:BackBufferDesc.Width; + height=DirectTransportHeight?DirectTransportHeight:BackBufferDesc.Height; + if(SharedState&&SharedState->magic==OutRunVR::SharedMagic&&SharedState->protocolVersion==OutRunVR::SharedProtocolVersion&&SharedState->structSize==sizeof(OutRunVR::SharedPoseState)) + { + for(int attempt=0;attempt<4;++attempt) + { + const std::uint32_t before=SharedState->sequence;if(before&1u)continue;MemoryBarrier(); + const UINT w0=SharedState->recommendedWidth[0],w1=SharedState->recommendedWidth[1]; + const UINT h0=SharedState->recommendedHeight[0],h1=SharedState->recommendedHeight[1];MemoryBarrier(); + const std::uint32_t after=SharedState->sequence;if(before!=after||(after&1u))continue; + const UINT recommendedW=w0>w1?w0:w1;const UINT recommendedH=h0>h1?h0:h1; + if(recommendedW&&recommendedH){width=recommendedW;height=recommendedH;} + break; + } + } + D3DCAPS9 caps{};if(device&&SUCCEEDED(device->GetDeviceCaps(&caps))){if(caps.MaxTextureWidth&&width>caps.MaxTextureWidth)width=caps.MaxTextureWidth;if(caps.MaxTextureHeight&&height>caps.MaxTextureHeight)height=caps.MaxTextureHeight;} + if(!width)width=BackBufferDesc.Width?BackBufferDesc.Width:1u;if(!height)height=BackBufferDesc.Height?BackBufferDesc.Height:1u; + } + HRESULT StretchDirectEye(IDirect3DDevice9* device,IDirect3DSurface9* source,IDirect3DSurface9* target) + { + if(!device||!source||!target)return D3DERR_INVALIDCALL;HRESULT hr=device->StretchRect(source,nullptr,target,nullptr,D3DTEXF_LINEAR);if(FAILED(hr))hr=device->StretchRect(source,nullptr,target,nullptr,D3DTEXF_NONE);return hr; + } + bool EnsureDirectTransportResources(IDirect3DDevice9* device) + { + if(!device||!BackBufferDesc.Width||!BackBufferDesc.Height||!EnsureRenderFrameState()||!EnsureDirectInteropProbe(device))return false;UINT targetWidth=0,targetHeight=0;DirectTransportTargetSize(device,targetWidth,targetHeight);if(DirectTransportResourcesReady&&DirectTransportWidth==targetWidth&&DirectTransportHeight==targetHeight)return true;if(DirectTransportResourcesReady)ReleaseDirectTransportSlots();DirectTransportFormat=ChooseDirectTransportFormat(BackBufferDesc.Format);if(!CanConvertForDirectTransport(device,BackBufferDesc.Format,DirectTransportFormat))return false;for(auto&slot:DirectTransportSlots){HANDLE leftHandle=nullptr,rightHandle=nullptr;const HRESULT leftHr=device->CreateTexture(targetWidth,targetHeight,1,D3DUSAGE_RENDERTARGET,DirectTransportFormat,D3DPOOL_DEFAULT,&slot.leftTexture,&leftHandle);const HRESULT rightHr=SUCCEEDED(leftHr)?device->CreateTexture(targetWidth,targetHeight,1,D3DUSAGE_RENDERTARGET,DirectTransportFormat,D3DPOOL_DEFAULT,&slot.rightTexture,&rightHandle):E_FAIL;if(FAILED(leftHr)||FAILED(rightHr)||!leftHandle||!rightHandle||FAILED(slot.leftTexture->GetSurfaceLevel(0,&slot.leftSurface))||FAILED(slot.rightTexture->GetSurfaceLevel(0,&slot.rightSurface))||FAILED(device->CreateQuery(D3DQUERYTYPE_EVENT,&slot.fence))||!slot.fence){ReleaseDirectTransportSlots();return false;}slot.leftHandle=leftHandle;slot.rightHandle=rightHandle;}DirectTransportWidth=targetWidth;DirectTransportHeight=targetHeight;if(!DirectTransportGeneration)DirectTransportGeneration=RenderFrameRunGeneration;if(++DirectTransportGeneration==0)++DirectTransportGeneration;DirectTransportResourcesReady=true;spdlog::info("VR stereo: verified 4-slot D3D9Ex shared eye ring ready {}x{} from desktop {}x{} (VDXR/OpenXR recommended) format={} generation={}",DirectTransportWidth,DirectTransportHeight,BackBufferDesc.Width,BackBufferDesc.Height,static_cast(DirectTransportFormat),DirectTransportGeneration);return true; + } + bool ResolveDirectTransport(IDirect3DDevice9* device,std::uint32_t frameId) + { + if(!frameId||!EnsureDirectTransportResources(device)||!BackBuffer||!RightEyeSurface)return false; + const std::uint32_t consumed=SharedState?SharedState->hostDirectConsumedFrameId:0; + const std::uint32_t preferred=(frameId-1u)%OutRunVR::RenderFrameRingSize; + std::uint32_t selected=OutRunVR::RenderFrameRingSize; + + // Search every ring slot. A slow query must not stall the game thread + // or block an otherwise free slot. + for(std::uint32_t offset=0;offsetGetData(nullptr,0,0):E_FAIL; + if(ready==S_OK) + { + slot.producerPending=false; + slot.pendingFrameId=0; + // A frame whose fence missed its publication window was + // never visible to the host, so reclaim it without ACK. + if(!slot.published)slot.frameId=0; + } + else if(ready==S_FALSE) + { + continue; + } + else + { + slot.producerPending=false; + slot.pendingFrameId=0; + slot.frameId=0; + slot.published=false; + } + } + + // Published images are immutable until the host ACKs their frame. + if(slot.published&&slot.frameId&&!FrameIdAtOrAfter(consumed,slot.frameId)) + continue; + if(slot.published&&slot.frameId&&FrameIdAtOrAfter(consumed,slot.frameId)) + { + slot.frameId=0; + slot.published=false; + } + selected=index; + break; + } + + if(selected>=OutRunVR::RenderFrameRingSize) + { + ++DirectTransportRingBackpressure; + return false; + } + + auto&slot=DirectTransportSlots[selected]; + { + InternalPassScope guard; + if(FAILED(StretchDirectEye(device,BackBuffer,slot.leftSurface))|| + FAILED(StretchDirectEye(device,RightEyeSurface,slot.rightSurface))|| + FAILED(slot.fence->Issue(D3DISSUE_END)))return false; + } + slot.producerPending=true; + slot.pendingFrameId=frameId; + slot.frameId=frameId; + slot.published=false; + ActiveDirectTransportSlot=selected; + return true; + } + + bool DirectTransportFrameReadyAfterPresent(std::uint32_t frameId) + { + if(!frameId||ActiveDirectTransportSlot>=DirectTransportSlots.size())return false; + auto&slot=DirectTransportSlots[ActiveDirectTransportSlot]; + if(slot.frameId!=frameId||slot.pendingFrameId!=frameId||!slot.fence)return false; + + // Present has already flushed the frame. Allow at most 1 ms of bounded + // completion grace here; the old 4 ms pre-Present wait consumed a large + // part of a 90/120 Hz frame budget and was visible as VD hitching. + if(WaitForEventQuery(slot.fence,1)) + { + slot.producerPending=false; + slot.pendingFrameId=0; + slot.published=true; + return true; + } + + ++DirectTransportFenceTimeouts; + const HRESULT status=slot.fence->GetData(nullptr,0,0); + if(status!=S_FALSE) + { + slot.producerPending=false; + slot.pendingFrameId=0; + slot.frameId=0; + slot.published=false; + } + // S_FALSE stays quarantined. A later slot scan reclaims it as soon as + // the query completes; it is never published and therefore needs no ACK. + return false; + } + + bool EnsureStereoResources(IDirect3DDevice9* device) + { + if(!device)return false;if(StereoResourcesReady&&BackBuffer&&RightEyeSurface&&LeftResolveSurface&&RightResolveSurface)return true;IDirect3DSurface9*newBackBuffer=nullptr;if(FAILED(device->GetBackBuffer(0,0,D3DBACKBUFFER_TYPE_MONO,&newBackBuffer))||!newBackBuffer)return false;D3DSURFACE_DESC desc{};if(FAILED(newBackBuffer->GetDesc(&desc))){newBackBuffer->Release();return false;}ReleaseStereoResources();BackBuffer=newBackBuffer;BackBufferDesc=desc;IDirect3DSurface9*currentRt=nullptr;if(SUCCEEDED(device->GetRenderTarget(0,¤tRt))&¤tRt)TrackedRenderTarget=currentRt;else ReplaceSurfaceRef(TrackedRenderTarget,BackBuffer);IDirect3DSurface9*currentDepth=nullptr;const HRESULT depthStateHr=device->GetDepthStencilSurface(¤tDepth);if(SUCCEEDED(depthStateHr)&¤tDepth)TrackedDepthStencil=currentDepth;else if(depthStateHr==D3DERR_NOTFOUND)TrackedDepthStencil=nullptr;InitializeAuxRenderTargetState(device);if(FAILED(device->CreateRenderTarget(desc.Width,desc.Height,desc.Format,desc.MultiSampleType,desc.MultiSampleQuality,FALSE,&RightEyeSurface,nullptr))||!CreateRightDepthForTracked(device)){ReleaseStereoResources();return false;}if(FAILED(device->CreateTexture(desc.Width,desc.Height,1,D3DUSAGE_RENDERTARGET,desc.Format,D3DPOOL_DEFAULT,&LeftResolveTexture,nullptr))||FAILED(LeftResolveTexture->GetSurfaceLevel(0,&LeftResolveSurface))||FAILED(device->CreateTexture(desc.Width,desc.Height,1,D3DUSAGE_RENDERTARGET,desc.Format,D3DPOOL_DEFAULT,&RightResolveTexture,nullptr))||FAILED(RightResolveTexture->GetSurfaceLevel(0,&RightResolveSurface))){ReleaseStereoResources();return false;}D3DVIEWPORT9 savedViewport{};if(FAILED(device->GetViewport(&savedViewport))){ReleaseStereoResources();return false;}bool initOk=true;{InternalPassScope guard;if(FAILED(SetRenderTargetHook.stdcall(device,0u,RightEyeSurface)))initOk=false;if(initOk){const HRESULT depthHr=SetDepthStencilSurfaceHook?SetDepthStencilSurfaceHook.stdcall(device,RightEyeDepth):device->SetDepthStencilSurface(RightEyeDepth);if(FAILED(depthHr))initOk=false;}if(initOk&&FAILED(device->SetViewport(&savedViewport)))initOk=false;DWORD clearFlags=D3DCLEAR_TARGET;if(RightEyeDepth){D3DSURFACE_DESC depthDesc{};if(FAILED(RightEyeDepth->GetDesc(&depthDesc)))initOk=false;else{clearFlags|=D3DCLEAR_ZBUFFER;if(FormatHasStencil(depthDesc.Format))clearFlags|=D3DCLEAR_STENCIL;}}if(initOk&&FAILED(ClearHook.stdcall(device,0u,static_cast(nullptr),clearFlags,D3DCOLOR_XRGB(0,0,0),1.0f,0u)))initOk=false;if(TrackedRenderTarget&&FAILED(SetRenderTargetHook.stdcall(device,0u,TrackedRenderTarget)))initOk=false;const HRESULT restoreDepthHr=SetDepthStencilSurfaceHook?SetDepthStencilSurfaceHook.stdcall(device,TrackedDepthStencil):device->SetDepthStencilSurface(TrackedDepthStencil);if(FAILED(restoreDepthHr))initOk=false;if(FAILED(device->SetViewport(&savedViewport)))initOk=false;}if(!initOk){ReleaseStereoResources();return false;}StereoResourcesReady=true;spdlog::info("VR stereo: full-size eye surfaces ready {}x{} format={} msaa={} quality={}",desc.Width,desc.Height,static_cast(desc.Format),static_cast(desc.MultiSampleType),desc.MultiSampleQuality);return true; + } + + void NoteRestoreFailure(const char* what){FrameRightDrawFailed=true;PoisonFrame(OutRunVR::StereoFailureRestoreFailed);++RestoreFailures;if(!FirstRestoreFailureLogged){FirstRestoreFailureLogged=true;spdlog::warn("VR stereo: renderer state restore failed at {}; frame forced to mono fallback",what);}} + bool RestoreRightPassState(IDirect3DDevice9* device,IDirect3DSurface9* savedRt,IDirect3DSurface9* savedDepth,const D3DVIEWPORT9& savedViewport,const float* originalConstants,bool restoreWvp){bool ok=true;if(savedRt&&FAILED(SetRenderTargetHook.stdcall(device,0u,savedRt)))ok=false;const HRESULT depthHr=SetDepthStencilSurfaceHook?SetDepthStencilSurfaceHook.stdcall(device,savedDepth):device->SetDepthStencilSurface(savedDepth);if(FAILED(depthHr))ok=false;if(FAILED(device->SetViewport(&savedViewport)))ok=false;if(restoreWvp&&!SetWvpOneRegisterAtATime(device,originalConstants))ok=false;return ok;} + bool TryBootstrapRightDepthFromRecentClear(IDirect3DDevice9*device) + { + ++DepthBootstrapAttempts; + if(!device||!RightEyeSurface||!RightEyeDepth||!TrackedDepthStencil||LastMainDepthClearEpoch!=PresentEpoch)return false; + D3DSURFACE_DESC leftDesc{},rightDesc{};if(FAILED(TrackedDepthStencil->GetDesc(&leftDesc))||FAILED(RightEyeDepth->GetDesc(&rightDesc)))return false; + if(!DepthDescMatches(leftDesc,rightDesc)||!DepthDescMatches(leftDesc,LastMainDepthClearDesc))return false; + DWORD replayFlags=0;if(!RightDepthSynchronized&&(LastMainDepthClearFlags&D3DCLEAR_ZBUFFER))replayFlags|=D3DCLEAR_ZBUFFER;if(!RightStencilSynchronized&&(LastMainDepthClearFlags&D3DCLEAR_STENCIL)&&FormatHasStencil(leftDesc.Format))replayFlags|=D3DCLEAR_STENCIL;if(!replayFlags)return false; + D3DVIEWPORT9 savedViewport{};if(FAILED(device->GetViewport(&savedViewport)))return false;IDirect3DSurface9*savedRt=TrackedRenderTarget,*savedDepth=TrackedDepthStencil;HRESULT clearHr=E_FAIL;bool restoreOk=true;{InternalPassScope guard;if(SUCCEEDED(SetRenderTargetHook.stdcall(device,0u,RightEyeSurface))&&SUCCEEDED(SetDepthStencilSurfaceHook.stdcall(device,RightEyeDepth))&&SUCCEEDED(device->SetViewport(&savedViewport)))clearHr=ClearHook.stdcall(device,0u,static_cast(nullptr),replayFlags,0,LastMainDepthClearZ,LastMainDepthClearStencil);restoreOk=RestoreRightPassState(device,savedRt,savedDepth,savedViewport,nullptr,false);}if(FAILED(clearHr)||!restoreOk)return false; + if(replayFlags&D3DCLEAR_ZBUFFER)RightDepthSynchronized=true;if(replayFlags&D3DCLEAR_STENCIL)RightStencilSynchronized=true;++DepthBootstrapSuccesses;if(!FirstDepthBootstrapLogged){FirstDepthBootstrapLogged=true;spdlog::info("VR stereo: bootstrapped right-eye depth from same-Present full main clear flags=0x{:x} epoch={}",static_cast(replayFlags),PresentEpoch);}return true; + } + std::uint32_t NextStereoFrameId(){if(++StereoFrameCounter==0)++StereoFrameCounter;return StereoFrameCounter;} + + void PublishStereoState(std::uint32_t state,bool worldStereo,std::uint32_t poseSequence,std::uint32_t frameId) + { + if(!EnsureSharedState())return;InterlockedExchange(reinterpret_cast(&SharedState->reserved[OutRunVR::ClientStereoFrameIndex]),0);const LONG stereoBits=static_cast(OutRunVR::ClientStereoActive|OutRunVR::ClientStereoWorldDraw|OutRunVR::ClientStereoDrawDuplicated);InterlockedAnd(reinterpret_cast(&SharedState->reserved[OutRunVR::ClientFlagsIndex]),~stereoBits);InterlockedExchange(reinterpret_cast(&SharedState->reserved[OutRunVR::ClientStereoBackbufferWidthIndex]),static_cast(BackBufferDesc.Width));InterlockedExchange(reinterpret_cast(&SharedState->reserved[OutRunVR::ClientStereoBackbufferHeightIndex]),static_cast(BackBufferDesc.Height));if(state!=OutRunVR::StereoSbsActive||poseSequence==0||frameId==0){InterlockedExchange(reinterpret_cast(&SharedState->clientStereoPoseSequence),0);InterlockedExchange(reinterpret_cast(&SharedState->reserved[OutRunVR::ClientStereoStateIndex]),static_cast(state));return;}InterlockedExchange(reinterpret_cast(&SharedState->clientStereoPoseSequence),static_cast(poseSequence));LONG bits=static_cast(OutRunVR::ClientStereoActive|OutRunVR::ClientStereoDrawDuplicated);if(worldStereo)bits|=static_cast(OutRunVR::ClientStereoWorldDraw);InterlockedOr(reinterpret_cast(&SharedState->reserved[OutRunVR::ClientFlagsIndex]),bits);InterlockedExchange(reinterpret_cast(&SharedState->reserved[OutRunVR::ClientStereoStateIndex]),static_cast(OutRunVR::StereoSbsActive));MemoryBarrier();InterlockedExchange(reinterpret_cast(&SharedState->reserved[OutRunVR::ClientStereoFrameIndex]),static_cast(frameId)); + } + + void WriteGameBuildTag(OutRunVR::SharedRenderFrameState& frame) + { + char tag[12]{}; + const char* source = OUTRUN_VR_BUILD_SHA; + for (std::size_t i = 0; i < sizeof(tag) && source[i]; ++i) + tag[i] = source[i]; + std::memcpy( + &frame.reserved[OutRunVR::RenderFrameGameBuildTag0Index], + tag, sizeof(tag)); + } + + void PublishRenderFrame(std::uint32_t state,std::uint32_t frameId,std::uint32_t sourcePoseSequence,std::int64_t presentQpc,OutRunVR::StereoFailureReason failureReason,const OutRunVRRenderer::LatchedStereoFrame* stereo,bool presentInFlight=false,bool directTransport=false) + { + if(!EnsureRenderFrameState())return;const std::uint32_t slotIndex=frameId?(frameId-1u)%OutRunVR::RenderFrameRingSize:(RenderFrameRing->latestSlot+1u)%OutRunVR::RenderFrameRingSize;auto&frame=RenderFrameRing->slots[slotIndex];LONG seq=InterlockedIncrement(reinterpret_cast(&frame.sequence));if((seq&1)==0)InterlockedIncrement(reinterpret_cast(&frame.sequence));MemoryBarrier();frame.magic=OutRunVR::RenderFrameMagic;frame.protocolVersion=OutRunVR::RenderFrameProtocolVersion;frame.structSize=sizeof(frame);frame.clientPid=GetCurrentProcessId();frame.state=state;frame.frameId=frameId;frame.sourcePoseSequence=sourcePoseSequence;frame.presentationMode=GameplayActive()?OutRunVR::PresentationGameplay:OutRunVR::PresentationTheater;frame.flags=presentInFlight?OutRunVR::RenderFramePresentInFlight:0;frame.failureReason=static_cast(failureReason);const bool directSizedFrame=directTransport&&DirectTransportResourcesReady&&DirectTransportWidth&&DirectTransportHeight;frame.backbufferWidth=directSizedFrame?DirectTransportWidth:BackBufferDesc.Width;frame.backbufferHeight=directSizedFrame?DirectTransportHeight:BackBufferDesc.Height;frame.presentQpc=presentQpc;std::memset(frame.eye,0,sizeof(frame.eye));std::memset(frame.reserved,0,sizeof(frame.reserved));frame.reserved[OutRunVR::RenderFrameRunGenerationIndex]=RenderFrameRunGeneration;WriteGameBuildTag(frame);frame.reserved[OutRunVR::RenderFrameCadenceRequestIndex]=OutRunVRRenderer::GetActiveCadenceRequestId();if(directTransport&&DirectTransportResourcesReady&&ActiveDirectTransportSlot(reinterpret_cast(transport.leftHandle));frame.reserved[OutRunVR::RenderFrameDirectRightHandleIndex]=static_cast(reinterpret_cast(transport.rightHandle));frame.reserved[OutRunVR::RenderFrameDirectWidthIndex]=DirectTransportWidth;frame.reserved[OutRunVR::RenderFrameDirectHeightIndex]=DirectTransportHeight;frame.reserved[OutRunVR::RenderFrameDirectFormatIndex]=static_cast(DirectTransportFormat);frame.reserved[OutRunVR::RenderFrameDirectGenerationIndex]=DirectTransportGeneration;frame.reserved[OutRunVR::RenderFrameDirectSlotIndex]=ActiveDirectTransportSlot;}}if(!presentInFlight&&state==OutRunVR::StereoSbsActive&&stereo&&stereo->valid&&frameId){const std::uint32_t transportFlag=frame.flags&OutRunVR::RenderFrameDirectGpuTransport;frame.flags=transportFlag|OutRunVR::RenderFrameStereoComplete|OutRunVR::RenderFrameWorldStereo|OutRunVR::RenderFrameDrawDuplicated|OutRunVR::RenderFrameEffectivePoseValid;for(int eye=0;eye<2;++eye){std::memcpy(frame.eye[eye].orientation,stereo->effectiveEyeOrientation[eye],sizeof(frame.eye[eye].orientation));std::memcpy(frame.eye[eye].position,stereo->effectiveEyePosition[eye],sizeof(frame.eye[eye].position));frame.eye[eye].fov=stereo->eyeFov[eye];}}MemoryBarrier();seq=InterlockedIncrement(reinterpret_cast(&frame.sequence));if(seq&1)InterlockedIncrement(reinterpret_cast(&frame.sequence));LONG ringSeq=InterlockedIncrement(reinterpret_cast(&RenderFrameRing->publishSequence));if((ringSeq&1)==0)InterlockedIncrement(reinterpret_cast(&RenderFrameRing->publishSequence));MemoryBarrier();RenderFrameRing->clientPid=GetCurrentProcessId();RenderFrameRing->latestSlot=slotIndex;MemoryBarrier();ringSeq=InterlockedIncrement(reinterpret_cast(&RenderFrameRing->publishSequence));if(ringSeq&1)InterlockedIncrement(reinterpret_cast(&RenderFrameRing->publishSequence)); + } + + struct ScreenVertex{float x,y,z,rhw;float u,v;}; + bool DrawTextureHalf(IDirect3DDevice9* device,IDirect3DTexture9* texture,bool right){if(!texture||!BackBufferDesc.Width||!BackBufferDesc.Height)return false;const float x0=right?static_cast(BackBufferDesc.Width/2):0.0f;const float x1=right?static_cast(BackBufferDesc.Width):static_cast(BackBufferDesc.Width/2);const float y0=0.0f,y1=static_cast(BackBufferDesc.Height);const ScreenVertex vertices[4]={{x0-0.5f,y0-0.5f,0.0f,1.0f,0.0f,0.0f},{x1-0.5f,y0-0.5f,0.0f,1.0f,1.0f,0.0f},{x0-0.5f,y1-0.5f,0.0f,1.0f,0.0f,1.0f},{x1-0.5f,y1-0.5f,0.0f,1.0f,1.0f,1.0f}};if(FAILED(device->SetTexture(0,texture)))return false;return SUCCEEDED(device->DrawPrimitiveUP(D3DPT_TRIANGLESTRIP,2,vertices,static_cast(sizeof(ScreenVertex))));} + bool ComposeSbs(IDirect3DDevice9* device) + { + if(!StereoResourcesReady||!BackBuffer||!RightEyeSurface||!LeftResolveSurface||!RightResolveSurface)return false; + if(FAILED(device->StretchRect(BackBuffer,nullptr,LeftResolveSurface,nullptr,D3DTEXF_NONE))||FAILED(device->StretchRect(RightEyeSurface,nullptr,RightResolveSurface,nullptr,D3DTEXF_NONE)))return false; + IDirect3DStateBlock9*stateBlock=nullptr; + if(FAILED(device->CreateStateBlock(D3DSBT_ALL,&stateBlock))||!stateBlock)return false; + IDirect3DSurface9*savedRt=nullptr,*savedDepth=nullptr;D3DVIEWPORT9 savedViewport{}; + const HRESULT rtHr=device->GetRenderTarget(0,&savedRt),depthHr=device->GetDepthStencilSurface(&savedDepth),viewportHr=device->GetViewport(&savedViewport); + const bool depthOk=SUCCEEDED(depthHr)||depthHr==D3DERR_NOTFOUND; + if(FAILED(rtHr)||!savedRt||!depthOk||FAILED(viewportHr)){if(savedRt)savedRt->Release();if(savedDepth)savedDepth->Release();stateBlock->Release();return false;} + bool success=false,beganScene=false; + {InternalPassScope guard;if(SUCCEEDED(SetRenderTargetHook.stdcall(device,0u,BackBuffer))&&SUCCEEDED(SetDepthStencilSurfaceHook.stdcall(device,static_cast(nullptr)))&&SUCCEEDED(device->BeginScene())){beganScene=true;device->SetVertexShader(nullptr);device->SetPixelShader(nullptr);device->SetFVF(D3DFVF_XYZRHW|D3DFVF_TEX1);device->SetRenderState(D3DRS_ZENABLE,FALSE);device->SetRenderState(D3DRS_ZWRITEENABLE,FALSE);device->SetRenderState(D3DRS_ALPHABLENDENABLE,FALSE);device->SetRenderState(D3DRS_ALPHATESTENABLE,FALSE);device->SetRenderState(D3DRS_CULLMODE,D3DCULL_NONE);device->SetRenderState(D3DRS_LIGHTING,FALSE);device->SetRenderState(D3DRS_FOGENABLE,FALSE);device->SetRenderState(D3DRS_STENCILENABLE,FALSE);device->SetRenderState(D3DRS_SCISSORTESTENABLE,FALSE);device->SetRenderState(D3DRS_SRGBWRITEENABLE,FALSE);device->SetTextureStageState(0,D3DTSS_COLOROP,D3DTOP_SELECTARG1);device->SetTextureStageState(0,D3DTSS_COLORARG1,D3DTA_TEXTURE);device->SetTextureStageState(0,D3DTSS_ALPHAOP,D3DTOP_SELECTARG1);device->SetTextureStageState(0,D3DTSS_ALPHAARG1,D3DTA_TEXTURE);device->SetTextureStageState(0,D3DTSS_TEXCOORDINDEX,0);device->SetTextureStageState(0,D3DTSS_TEXTURETRANSFORMFLAGS,D3DTTFF_DISABLE);device->SetTextureStageState(1,D3DTSS_COLOROP,D3DTOP_DISABLE);device->SetSamplerState(0,D3DSAMP_MINFILTER,D3DTEXF_LINEAR);device->SetSamplerState(0,D3DSAMP_MAGFILTER,D3DTEXF_LINEAR);device->SetSamplerState(0,D3DSAMP_ADDRESSU,D3DTADDRESS_CLAMP);device->SetSamplerState(0,D3DSAMP_ADDRESSV,D3DTADDRESS_CLAMP);device->SetSamplerState(0,D3DSAMP_SRGBTEXTURE,FALSE);D3DVIEWPORT9 full{0,0,BackBufferDesc.Width,BackBufferDesc.Height,0.0f,1.0f};const bool stateOk=SUCCEEDED(device->SetViewport(&full))&&SUCCEEDED(device->Clear(0,nullptr,D3DCLEAR_TARGET,D3DCOLOR_XRGB(0,0,0),1.0f,0));const bool leftOk=stateOk&&DrawTextureHalf(device,LeftResolveTexture,false);const bool rightOk=leftOk&&DrawTextureHalf(device,RightResolveTexture,true);device->SetTexture(0,nullptr);const HRESULT endHr=device->EndScene();beganScene=false;success=leftOk&&rightOk&&SUCCEEDED(endHr);}if(beganScene)device->EndScene();} + const HRESULT applyHr=stateBlock->Apply();stateBlock->Release();bool restoreOk=SUCCEEDED(applyHr); + if(FAILED(SetRenderTargetHook.stdcall(device,0u,savedRt)))restoreOk=false; + if(FAILED(SetDepthStencilSurfaceHook.stdcall(device,savedDepth)))restoreOk=false; + if(FAILED(device->SetViewport(&savedViewport)))restoreOk=false; + savedRt->Release();if(savedDepth)savedDepth->Release(); + if(!restoreOk)NoteRestoreFailure("SBS composition state restore"); + return success&&restoreOk; + } + + bool PrepareDuplicatedDraw(IDirect3DDevice9* device,DrawStereoState& draw) + { + if(InternalStereoPass||!StereoWanted()||!TargetIsBackBuffer())return false;if(OcclusionQueryTrackingUnavailable.load(std::memory_order_acquire)){PoisonFrame(OutRunVR::StereoFailureOcclusionQueryActive);++OcclusionStereoRejects;if(!FirstOcclusionTrackingUnavailableLogged){FirstOcclusionTrackingUnavailableLogged=true;spdlog::error("VR stereo: occlusion-query Issue tracking unavailable; stereo duplication fails closed to single-execution mono until tracking recovers");}return false;}if(AnyAuxRenderTargetActive()){PoisonFrame(OutRunVR::StereoFailureMrtActive);++MrtRejectedDraws;if(!FirstMrtRejectLogged){FirstMrtRejectLogged=true;spdlog::info("VR stereo: auxiliary MRT active; current Present is marked incomplete");}return false;}if(ActiveOcclusionQueries.load(std::memory_order_acquire)>0){++OcclusionStereoRejects;if(!FirstOcclusionRejectLogged){FirstOcclusionRejectLogged=true;spdlog::info("VR stereo: active D3D9 occlusion query kept single-eye; query result is shared without invalidating the stereo Present");}return false;}if(!EnsureStereoResources(device)){PoisonFrame(OutRunVR::StereoFailureResourceUnavailable);return false;}if(TrackedDepthStencil&&(!RightDepthSynchronized||!RightStencilSynchronized))TryBootstrapRightDepthFromRecentClear(device);if(TrackedDepthStencil&&!RightDepthSynchronized&&DepthTestActive(device)){PoisonFrame(OutRunVR::StereoFailureDepthUnsynchronized);return false;}if(TrackedDepthStencil&&!RightStencilSynchronized&&StencilTestActive(device)){PoisonFrame(OutRunVR::StereoFailureStencilUnsynchronized);return false;}OutRunVRRenderer::LatchedStereoFrame stereo{};if(!OutRunVRRenderer::GetLatchedStereoFrame(stereo)){PoisonFrame(OutRunVR::StereoFailureMissingLatchedPose);return false;}draw.stereoFrame=stereo;const EyeBuildResult classification=BuildEyeConstants(device,stereo,draw);if(classification==EyeBuildResult::FatalError){++WorldClassificationFailures;PoisonFrame(OutRunVR::StereoFailureWorldClassificationFailed);if(!FirstClassificationFailureLogged){FirstClassificationFailureLogged=true;spdlog::warn("VR stereo: verified world draw could not be classified/transformed safely; frame forced to mono fallback");}return false;}return true; + } + + template + HRESULT ExecuteStereoDraw(IDirect3DDevice9* device,DrawCall&& drawCall) + { + if(!StereoBaseCallbacksArmed.load(std::memory_order_acquire)||!IsGameDevice(device))return drawCall(); + if(StereoWanted()&&!TargetIsBackBuffer()) + { + if(CurrentDrawMatchesVerifiedWorld(device)) + { + ++OffscreenVerifiedWorldDraws; + if(!FirstOffscreenWorldLogged) + { + FirstOffscreenWorldLogged=true;D3DSURFACE_DESC rtDesc{},depthDesc{};const bool rtOk=TrackedRenderTarget&&SUCCEEDED(TrackedRenderTarget->GetDesc(&rtDesc));const bool depthOk=TrackedDepthStencil&&SUCCEEDED(TrackedDepthStencil->GetDesc(&depthDesc)); + spdlog::info("VR stereo: verified auxiliary/offscreen world pass kept mono; stereo backbuffer remains eligible. rt={}x{} fmt={} msaa={} depthFmt={} depthMsaa={}",rtOk?rtDesc.Width:0u,rtOk?rtDesc.Height:0u,rtOk?static_cast(rtDesc.Format):-1,rtOk?static_cast(rtDesc.MultiSampleType):-1,depthOk?static_cast(depthDesc.Format):-1,depthOk?static_cast(depthDesc.MultiSampleType):-1); + } + } + return drawCall(); + } + + const bool occlusionActive=ActiveOcclusionQueries.load(std::memory_order_acquire)>0|| + OcclusionQueryTrackingUnavailable.load(std::memory_order_acquire); + DrawStereoState draw{}; + if(!PrepareDuplicatedDraw(device,draw)) + { + if(StereoWanted()&&TargetIsBackBuffer()&&!occlusionActive)InvalidateRightDepthStencilIfLeftMayWrite(device); + return drawCall(); + } + D3DVIEWPORT9 savedViewport{};if(FAILED(device->GetViewport(&savedViewport))){InvalidateRightDepthStencilIfLeftMayWrite(device);PoisonFrame(OutRunVR::StereoFailureViewportUnavailable);return drawCall();} + if(draw.worldStereo){bool leftEyeOk=false;{InternalPassScope guard;leftEyeOk=draw.fixedFunctionWorld?SetFixedFunctionEyeTransforms(device,draw,0):SetWvpOneRegisterAtATime(device,draw.eyeConstants[0]);}if(!leftEyeOk){bool rolledBack=false;{InternalPassScope guard;rolledBack=draw.fixedFunctionWorld?RestoreFixedFunctionTransforms(device,draw):SetWvpOneRegisterAtATime(device,draw.originalConstants);}InvalidateRightDepthStencilIfLeftMayWrite(device);PoisonFrame(OutRunVR::StereoFailureLeftWvpUploadFailed);if(!rolledBack)NoteRestoreFailure(draw.fixedFunctionWorld?"left-eye fixed-function rollback":"left-eye c64 rollback");return drawCall();}} + const HRESULT leftHr=drawCall();if(FAILED(leftHr)){InvalidateRightDepthStencilIfLeftMayWrite(device);PoisonFrame(OutRunVR::StereoFailureLeftDrawFailed);if(draw.worldStereo){bool restored=false;{InternalPassScope guard;restored=draw.fixedFunctionWorld?RestoreFixedFunctionTransforms(device,draw):SetWvpOneRegisterAtATime(device,draw.originalConstants);}if(!restored)NoteRestoreFailure(draw.fixedFunctionWorld?"left draw fixed-function transforms":"left draw c64");}return leftHr;} + IDirect3DSurface9*savedRt=TrackedRenderTarget,*savedDepth=TrackedDepthStencil;HRESULT rightHr=D3D_OK;OutRunVR::StereoFailureReason rightFailure=OutRunVR::StereoFailureRightStateFailed;bool restoreOk=true;{InternalPassScope guard;rightHr=SetRenderTargetHook.stdcall(device,0u,RightEyeSurface);if(SUCCEEDED(rightHr))rightHr=SetDepthStencilSurfaceHook.stdcall(device,RightEyeDepth);if(SUCCEEDED(rightHr))rightHr=device->SetViewport(&savedViewport);if(SUCCEEDED(rightHr)&&draw.worldStereo){const bool eyeOk=draw.fixedFunctionWorld?SetFixedFunctionEyeTransforms(device,draw,1):SetWvpOneRegisterAtATime(device,draw.eyeConstants[1]);if(!eyeOk){rightFailure=OutRunVR::StereoFailureRightWvpUploadFailed;rightHr=E_FAIL;}}if(SUCCEEDED(rightHr)){rightFailure=OutRunVR::StereoFailureRightDrawFailed;rightHr=drawCall();}restoreOk=RestoreRightPassState(device,savedRt,savedDepth,savedViewport,draw.originalConstants,draw.worldStereo&&!draw.fixedFunctionWorld);if(draw.fixedFunctionWorld&&!RestoreFixedFunctionTransforms(device,draw))restoreOk=false;} + FrameHadDuplicatedDraw=true;++DuplicatedDraws;if(draw.worldStereo){if(FrameStereoPoseSequence==0){FrameStereoPoseSequence=draw.poseSequence;FrameStereoMetadata=draw.stereoFrame;}else if(FrameStereoPoseSequence!=draw.poseSequence){FrameRightDrawFailed=true;PoisonFrame(OutRunVR::StereoFailurePoseSequenceMismatch);if(!FramePoseMismatchLogged){FramePoseMismatchLogged=true;++PoseSequenceMismatchFrames;spdlog::warn("VR stereo: multiple host pose sequences reached one Present; frame forced to mono fallback");}}FrameHadWorldStereo=true;++WorldStereoDraws;if(!FirstWorldStereoLogged){FirstWorldStereoLogged=true;spdlog::info("VR stereo: TRUE GEOMETRY STEREO active; per-eye c64 + OpenXR asymmetric FOV confirmed");}}else ++NonWorldDuplicatedDraws;if(FAILED(rightHr)){FrameRightDrawFailed=true;InvalidateRightDepthStencilIfLeftMayWrite(device);PoisonFrame(rightFailure);}if(!restoreOk){InvalidateRightDepthStencilIfLeftMayWrite(device);NoteRestoreFailure("right-eye draw");}return leftHr; + } + + HRESULT __stdcall DrawPrimitiveDest(IDirect3DDevice9*device,D3DPRIMITIVETYPE type,UINT startVertex,UINT primitiveCount){auto call=[&](){return DrawPrimitiveHook.stdcall(device,type,startVertex,primitiveCount);};return ExecuteStereoDraw(device,call);} + HRESULT __stdcall DrawIndexedPrimitiveDest(IDirect3DDevice9*device,D3DPRIMITIVETYPE type,INT baseVertexIndex,UINT minVertexIndex,UINT numVertices,UINT startIndex,UINT primitiveCount){auto call=[&](){return DrawIndexedPrimitiveHook.stdcall(device,type,baseVertexIndex,minVertexIndex,numVertices,startIndex,primitiveCount);};return ExecuteStereoDraw(device,call);} + HRESULT __stdcall DrawPrimitiveUPDest(IDirect3DDevice9*device,D3DPRIMITIVETYPE type,UINT primitiveCount,const void*data,UINT stride){if(InternalStereoPass)return DrawPrimitiveUPHook.stdcall(device,type,primitiveCount,data,stride);auto call=[&](){return DrawPrimitiveUPHook.stdcall(device,type,primitiveCount,data,stride);};return ExecuteStereoDraw(device,call);} + HRESULT __stdcall DrawIndexedPrimitiveUPDest(IDirect3DDevice9*device,D3DPRIMITIVETYPE type,UINT minVertexIndex,UINT numVertices,UINT primitiveCount,const void*indexData,D3DFORMAT indexFormat,const void*vertexData,UINT stride){auto call=[&](){return DrawIndexedPrimitiveUPHook.stdcall(device,type,minVertexIndex,numVertices,primitiveCount,indexData,indexFormat,vertexData,stride);};return ExecuteStereoDraw(device,call);} + + HRESULT __stdcall ClearDest(IDirect3DDevice9*device,DWORD count,const D3DRECT*rects,DWORD flags,D3DCOLOR color,float z,DWORD stencil) + { + if(!StereoBaseCallbacksArmed.load(std::memory_order_acquire)||!IsGameDevice(device)||InternalStereoPass)return ClearHook.stdcall(device,count,rects,flags,color,z,stencil);if(StereoWanted()&&!TargetIsBackBuffer()){const HRESULT hr=ClearHook.stdcall(device,count,rects,flags,color,z,stencil);return hr;}const bool candidate=StereoWanted()&&TargetIsBackBuffer();bool duplicate=candidate;if(duplicate&&AnyAuxRenderTargetActive()){PoisonFrame(OutRunVR::StereoFailureMrtActive);duplicate=false;}if(duplicate&&!EnsureStereoResources(device)){PoisonFrame(OutRunVR::StereoFailureResourceUnavailable);duplicate=false;}const HRESULT leftHr=ClearHook.stdcall(device,count,rects,flags,color,z,stencil);if(SUCCEEDED(leftHr))RecordFullMainDepthClear(device,count,rects,flags,z,stencil);if(!duplicate||FAILED(leftHr)){if(candidate&&FAILED(leftHr))PoisonFrame(OutRunVR::StereoFailureClearFailed);if(SUCCEEDED(leftHr)&&candidate){if((flags&D3DCLEAR_ZBUFFER)!=0)RightDepthSynchronized=false;if((flags&D3DCLEAR_STENCIL)!=0&&SurfaceHasStencil(TrackedDepthStencil))RightStencilSynchronized=false;}return leftHr;}D3DVIEWPORT9 savedViewport{};if(FAILED(device->GetViewport(&savedViewport))){if((flags&D3DCLEAR_ZBUFFER)!=0)RightDepthSynchronized=false;if((flags&D3DCLEAR_STENCIL)!=0&&SurfaceHasStencil(TrackedDepthStencil))RightStencilSynchronized=false;PoisonFrame(OutRunVR::StereoFailureViewportUnavailable);return leftHr;}IDirect3DSurface9*savedRt=TrackedRenderTarget,*savedDepth=TrackedDepthStencil;HRESULT rightHr=D3D_OK;bool restoreOk=true;{InternalPassScope guard;rightHr=SetRenderTargetHook.stdcall(device,0u,RightEyeSurface);if(SUCCEEDED(rightHr))rightHr=SetDepthStencilSurfaceHook.stdcall(device,RightEyeDepth);if(SUCCEEDED(rightHr))rightHr=device->SetViewport(&savedViewport);if(SUCCEEDED(rightHr))rightHr=ClearHook.stdcall(device,count,rects,flags,color,z,stencil);restoreOk=RestoreRightPassState(device,savedRt,savedDepth,savedViewport,nullptr,false);}if(FAILED(rightHr)){FrameRightDrawFailed=true;if((flags&D3DCLEAR_ZBUFFER)!=0)RightDepthSynchronized=false;if((flags&D3DCLEAR_STENCIL)!=0&&SurfaceHasStencil(TrackedDepthStencil))RightStencilSynchronized=false;PoisonFrame(OutRunVR::StereoFailureClearFailed);}else if(ClearCoversStereoBackbuffer(device,count,rects)){if((flags&D3DCLEAR_ZBUFFER)!=0)RightDepthSynchronized=true;if((flags&D3DCLEAR_STENCIL)!=0&&SurfaceHasStencil(TrackedDepthStencil))RightStencilSynchronized=true;}if(!restoreOk){if((flags&D3DCLEAR_ZBUFFER)!=0)RightDepthSynchronized=false;if((flags&D3DCLEAR_STENCIL)!=0&&SurfaceHasStencil(TrackedDepthStencil))RightStencilSynchronized=false;NoteRestoreFailure("right-eye clear");}return leftHr; + } + + HRESULT __stdcall SetRenderTargetDest(IDirect3DDevice9*device,DWORD index,IDirect3DSurface9*surface){const HRESULT hr=SetRenderTargetHook.stdcall(device,index,surface);if(!InternalStereoPass&&IsGameDevice(device)&&SUCCEEDED(hr)){if(index==0)ReplaceSurfaceRef(TrackedRenderTarget,surface);else if(index>=1&&index<=3)AuxRenderTargetActive[index-1]=surface!=nullptr;}return hr;} + HRESULT __stdcall SetDepthStencilSurfaceDest(IDirect3DDevice9*device,IDirect3DSurface9*surface) + { + const HRESULT hr=SetDepthStencilSurfaceHook.stdcall(device,surface); + if(!InternalStereoPass&&IsGameDevice(device)&&SUCCEEDED(hr)) + { + const bool changed=TrackedDepthStencil!=surface; + ReplaceSurfaceRef(TrackedDepthStencil,surface); + if(changed&&StereoResourcesReady&&TargetIsBackBuffer()) + { + if(!surface) + { + ReleaseCom(RightEyeDepth); + RightDepthSynchronized=true; + RightStencilSynchronized=true; + } + else + { + D3DSURFACE_DESC leftDesc{},rightDesc{}; + const bool compatible=RightEyeDepth&& + SUCCEEDED(surface->GetDesc(&leftDesc))&& + SUCCEEDED(RightEyeDepth->GetDesc(&rightDesc))&& + leftDesc.Width==BackBufferDesc.Width&&leftDesc.Height==BackBufferDesc.Height&& + rightDesc.Width==BackBufferDesc.Width&&rightDesc.Height==BackBufferDesc.Height&& + leftDesc.Format==rightDesc.Format&& + leftDesc.MultiSampleType==rightDesc.MultiSampleType&& + leftDesc.MultiSampleQuality==rightDesc.MultiSampleQuality; + if(compatible) + { + if(!FirstMainDepthReuseLogged) + { + FirstMainDepthReuseLogged=true; + spdlog::info("VR stereo: compatible main depth restored after auxiliary pass; reusing right-eye depth without desynchronizing"); + } + } + else + { + if(FrameHadDuplicatedDraw||FrameHadWorldStereo) + PoisonFrame(OutRunVR::StereoFailureDepthStateChanged); + if(!CreateRightDepthForTracked(device)) + StereoResourcesReady=false; + } + } + } + } + return hr; + } + + HRESULT __stdcall QueryIssueDest(IDirect3DQuery9*query,DWORD issueFlags){const HRESULT hr=QueryIssueHook.stdcall(query,issueFlags);if(FAILED(hr)||!query||query->GetType()!=D3DQUERYTYPE_OCCLUSION)return hr;IDirect3DDevice9*queryDevice=nullptr;if(FAILED(query->GetDevice(&queryDevice))||!queryDevice)return hr;const bool gameQuery=IsGameDevice(queryDevice);queryDevice->Release();if(!gameQuery)return hr;if((issueFlags&D3DISSUE_BEGIN)!=0){ActiveOcclusionQueries.fetch_add(1,std::memory_order_acq_rel);++OcclusionQueryBegins;}if((issueFlags&D3DISSUE_END)!=0){int current=ActiveOcclusionQueries.load(std::memory_order_acquire);while(current>0&&!ActiveOcclusionQueries.compare_exchange_weak(current,current-1,std::memory_order_acq_rel,std::memory_order_acquire)){}++OcclusionQueryEnds;}return hr;} + HRESULT __stdcall CreateQueryDest(IDirect3DDevice9*device,D3DQUERYTYPE type,IDirect3DQuery9**query){const HRESULT hr=CreateQueryHook.stdcall(device,type,query);if(!IsGameDevice(device)||FAILED(hr)||type!=D3DQUERYTYPE_OCCLUSION||!query||!*query)return hr;++OcclusionQueriesCreated;if(!QueryIssueHook){void**queryVtable=*reinterpret_cast(*query);if(queryVtable)QueryIssueHook=safetyhook::create_inline(queryVtable[QueryIssueVtableIndex],QueryIssueDest);if(QueryIssueHook){OcclusionQueryTrackingUnavailable.store(false,std::memory_order_release);spdlog::info("VR stereo: occlusion-query Issue hook armed; active query draws remain single-eye without invalidating stereo frames");}else{OcclusionQueryTrackingUnavailable.store(true,std::memory_order_release);spdlog::error("VR stereo: failed to hook IDirect3DQuery9::Issue; stereo duplication disabled fail-closed for query safety");}}return hr;} + HRESULT __stdcall SetVertexShaderDest(IDirect3DDevice9*device,IDirect3DVertexShader9*shader){const HRESULT hr=SetVertexShaderHook.stdcall(device,shader);if(!InternalStereoPass&&IsGameDevice(device)&&SUCCEEDED(hr)){const std::uintptr_t next=reinterpret_cast(shader),previous=CurrentVertexShaderIdentity.load(std::memory_order_acquire);if(next!=previous){CurrentVertexShaderIdentity.store(next,std::memory_order_release);std::uint64_t serial=VertexShaderSerial.fetch_add(1,std::memory_order_acq_rel)+1;if(serial==0)VertexShaderSerial.fetch_add(1,std::memory_order_acq_rel);}}return hr;} + + void MaybeLogSummary() + { + if(!Settings::VRTelemetry)return;const ULONGLONG now=GetTickCount64();if(now-LastSummaryMs<5000)return;LastSummaryMs=now;const DWORD gamePid=GetCurrentProcessId();const DWORD hostPid=SharedState?SharedState->hostPid:0;spdlog::info("VR identity: gameBuild={} gamePid={} hostPid={} runKey={}-{}",OUTRUN_VR_BUILD_SHA,gamePid,hostPid,hostPid,gamePid);spdlog::info("VR stereo: draws={} world={} ui/effect={} fixedFn={} fixedFnWorld={} offscreenWorld={} classifyFail={} composeOk={} composeFail={} rightFail={} mrtReject={} restoreFail={} poseMismatchFrames={} multiBeginPresent={} maxBeginPerPresent={} poseSeq={} direct[frames={},fallbacks={},fenceTimeout={}] occ[created={},begin={},end={},active={},reject={}] depthSync[fullClear={},bootstrapTry={},bootstrapOk={}] failures[pose={},res={},mrt={},viewport={},classify={},depth={},stencil={},clear={},rightState={},rightWvp={},rightDraw={},restore={},compose={},present={},depthState={},poseMismatch={},offscreen={},occlusion={}]",DuplicatedDraws,WorldStereoDraws,NonWorldDuplicatedDraws,FixedFunctionNonWorldDraws,FixedFunctionWorldStereoDraws,OffscreenVerifiedWorldDraws,WorldClassificationFailures,StereoComposeSuccess,StereoComposeFailure,FrameRightDrawFailed?1:0,MrtRejectedDraws,RestoreFailures,PoseSequenceMismatchFrames,MultiBeginScenePresents,MaxBeginScenesPerPresent,FrameStereoPoseSequence,DirectTransportFrames,DirectTransportFallbacks,DirectTransportFenceTimeouts,OcclusionQueriesCreated,OcclusionQueryBegins,OcclusionQueryEnds,ActiveOcclusionQueries.load(std::memory_order_acquire),OcclusionStereoRejects,FullMainDepthClears,DepthBootstrapAttempts,DepthBootstrapSuccesses,FailureCounts[OutRunVR::StereoFailureMissingLatchedPose],FailureCounts[OutRunVR::StereoFailureResourceUnavailable],FailureCounts[OutRunVR::StereoFailureMrtActive],FailureCounts[OutRunVR::StereoFailureViewportUnavailable],FailureCounts[OutRunVR::StereoFailureWorldClassificationFailed],FailureCounts[OutRunVR::StereoFailureDepthUnsynchronized],FailureCounts[OutRunVR::StereoFailureStencilUnsynchronized],FailureCounts[OutRunVR::StereoFailureClearFailed],FailureCounts[OutRunVR::StereoFailureRightStateFailed],FailureCounts[OutRunVR::StereoFailureRightWvpUploadFailed],FailureCounts[OutRunVR::StereoFailureRightDrawFailed],FailureCounts[OutRunVR::StereoFailureRestoreFailed],FailureCounts[OutRunVR::StereoFailureComposeFailed],FailureCounts[OutRunVR::StereoFailurePresentFailed],FailureCounts[OutRunVR::StereoFailureDepthStateChanged],FailureCounts[OutRunVR::StereoFailurePoseSequenceMismatch],FailureCounts[OutRunVR::StereoFailureOffscreenWorld],FailureCounts[OutRunVR::StereoFailureOcclusionQueryActive]); + } + + HRESULT __stdcall PresentDest(IDirect3DDevice9*device,const RECT*sourceRect,const RECT*destRect,HWND destWindowOverride,const RGNDATA*dirtyRegion) + { + if(!StereoBaseCallbacksArmed.load(std::memory_order_acquire)||!IsGameDevice(device))return PresentHook.stdcall(device,sourceRect,destRect,destWindowOverride,dirtyRegion); + const std::uint64_t beginNow=OutRunVRRenderer::GetBeginSceneCallCount(),beginDelta=beginNow>=LastBeginSceneCountAtPresent?beginNow-LastBeginSceneCountAtPresent:0;LastBeginSceneCountAtPresent=beginNow;if(GameplayActive()){if(beginDelta>1)++MultiBeginScenePresents;MaxBeginScenesPerPresent=std::max(MaxBeginScenesPerPresent,beginDelta);} + + const bool stereoRequested=StereoWanted(); + const std::uint32_t pendingFrameId=stereoRequested?NextStereoFrameId():0; + const bool stereoFrameEligible= + stereoRequested&&FrameHadWorldStereo&&FrameHadDuplicatedDraw&& + !FrameRightDrawFailed&&!FrameStereoIncomplete&& + FrameStereoPoseSequence&&FrameStereoMetadata.valid&& + EnsureStereoResources(device); + + bool composedStereo=false; + bool directTransport=false; + bool directOnlyHold=false; + std::uint32_t pendingPoseSequence=0; + + auto markDirectOnlyHold=[&]() + { + if(directOnlyHold)return; + directOnlyHold=true; + ++DirectTransportFallbacks; + if(!FirstDirectDeferredLogged) + { + FirstDirectDeferredLogged=true; + spdlog::warn("VR DIRECT-ONLY: shared-eye frame deferred without blocking; incomplete handles are never published and OpenXR reuses the last safe projection"); + } + }; + + if(stereoFrameEligible) + { + if(HostDirectTransportReady()) + directTransport=ResolveDirectTransport(device,pendingFrameId); + + if(directTransport) + { + composedStereo=true; + pendingPoseSequence=FrameStereoPoseSequence; + if(!FirstDirectTransportLogged) + { + FirstDirectTransportLogged=true; + spdlog::info("VR stereo: 4-slot direct GPU transport uses bounded post-Present completion; source eye={}x{}",BackBufferDesc.Width,BackBufferDesc.Height); + } + } + else if(Settings::VRDirectGpuOnly) + { + markDirectOnlyHold(); + } + else if(ComposeSbs(device)) + { + ++StereoComposeSuccess; + composedStereo=true; + pendingPoseSequence=FrameStereoPoseSequence; + ++DirectTransportFallbacks; + if(!FirstDirectFallbackLogged) + { + FirstDirectFallbackLogged=true; + spdlog::warn("VR stereo: direct transport not ready; keeping SBS/Desktop Duplication fallback"); + } + } + else + { + ++StereoComposeFailure; + PoisonFrame(OutRunVR::StereoFailureComposeFailed); + } + } + + MaybeLogSummary(); + LARGE_INTEGER presentStart{}; + QueryPerformanceCounter(&presentStart); + + // Never advertise DirectGPU handles before their event query completes. + // The classic path keeps its existing pre-Present correlation marker. + if(stereoRequested&&!directTransport) + PublishRenderFrame(OutRunVR::StereoSbsFallbackMono,pendingFrameId,pendingPoseSequence,presentStart.QuadPart,FrameFailureReason,nullptr,true,false); + + const HRESULT hr=PresentHook.stdcall(device,sourceRect,destRect,destWindowOverride,dirtyRegion); + + if(directTransport) + { + if(SUCCEEDED(hr)&&DirectTransportFrameReadyAfterPresent(pendingFrameId)) + { + ++DirectTransportFrames; + } + else + { + directTransport=false; + composedStereo=false; + markDirectOnlyHold(); + } + } + + if(composedStereo&&SUCCEEDED(hr)&&!FrameStereoIncomplete) + { + PublishStereoState(OutRunVR::StereoSbsActive,true,pendingPoseSequence,pendingFrameId); + PublishRenderFrame(OutRunVR::StereoSbsActive,pendingFrameId,pendingPoseSequence,presentStart.QuadPart,OutRunVR::StereoFailureNone,&FrameStereoMetadata,false,directTransport); + if(!FirstStereoActiveLogged) + { + FirstStereoActiveLogged=true; + spdlog::info("VR stereo: TRUE STEREO active; transport={} sourceEye={}x{} PC={}",directTransport?"verified D3D9Ex->D3D11 ring":"SBS Desktop Duplication",BackBufferDesc.Width,BackBufferDesc.Height,(directTransport&&HostDirectTransportReady())?"left-eye mirror":"SBS fallback"); + } + } + else if(!(directOnlyHold&&stereoFrameEligible&&SUCCEEDED(hr))) + { + if(FAILED(hr)&&FrameFailureReason==OutRunVR::StereoFailureNone)PoisonFrame(OutRunVR::StereoFailurePresentFailed); + const std::uint32_t fallback=stereoRequested?OutRunVR::StereoSbsFallbackMono:OutRunVR::StereoDisabled; + PublishStereoState(fallback,false,0,0); + PublishRenderFrame(fallback,0,0,presentStart.QuadPart,FrameFailureReason,nullptr); + } + // A transient DirectGPU miss in direct-only mode deliberately leaves the + // previous valid stereo metadata intact. The host already owns/reuses the + // last released OpenXR projection, avoiding a black/corrupt mode flip. + + OutRunVRRenderer::NotifyGamePresent(); + FrameHadDuplicatedDraw=false;FrameHadWorldStereo=false;FrameRightDrawFailed=false;FrameStereoIncomplete=false;FramePoseMismatchLogged=false;FrameFailureReason=OutRunVR::StereoFailureNone;FrameStereoPoseSequence=0;FrameStereoMetadata={};if(++PresentEpoch==0)PresentEpoch=1;return hr; + } + + HRESULT __stdcall ResetDest(IDirect3DDevice9*device,D3DPRESENT_PARAMETERS*params){if(!StereoBaseCallbacksArmed.load(std::memory_order_acquire)||!IsGameDevice(device))return ResetHook.stdcall(device,params);InvalidateTrackedRenderStates();OutRunVRRenderer::NotifyGameReset();ReleaseStereoResources();AuxRenderTargetActive={};ActiveOcclusionQueries.store(0,std::memory_order_release);CurrentVertexShaderIdentity.store(0,std::memory_order_release);VertexShaderSerial.store(0,std::memory_order_release);LastStereoWanted=false;RightStencilSynchronized=true;FramePoseMismatchLogged=false;FirstMainDepthReuseLogged=false;FirstMainClearLogged=false;FirstDepthBootstrapLogged=false;PresentEpoch=1;LastMainDepthClearEpoch=0;LastMainDepthClearFlags=0;LastMainDepthClearZ=1.0f;LastMainDepthClearStencil=0;LastMainDepthClearDesc={};LastBeginSceneCountAtPresent=OutRunVRRenderer::GetBeginSceneCallCount();FrameStereoIncomplete=false;FrameFailureReason=OutRunVR::StereoFailureNone;FrameStereoMetadata={};PublishStereoState(OutRunVR::StereoDisabled,false,0,0);PublishRenderFrame(OutRunVR::StereoDisabled,0,0,0,OutRunVR::StereoFailureNone,nullptr);HRESULT hr=D3DERR_INVALIDCALL;if(OutRunVRD3D9ExUpgradeR13::IsCompatDevice(device)){if(!OutRunVRD3D9ExUpgradeR13::ResetCompatDevice(device,params,hr)){spdlog::error("VR stereo: promoted D3D9Ex device lost ResetEx fallback ownership");return D3DERR_INVALIDCALL;}}else hr=ResetHook.stdcall(device,params);if(SUCCEEDED(hr)){PrimeTrackedRenderStates(device);EnsureStereoResources(device);}return hr;} + + constexpr std::uint32_t StereoInstallPending = 0; + constexpr std::uint32_t StereoInstallReady = 1; + constexpr std::uint32_t StereoInstallFailed = 2; + std::atomic StereoInstallState{StereoInstallPending}; + std::atomic StereoInstalledDevice{nullptr}; + std::mutex StereoInstallMutex; + + void RollbackStereoHooks() noexcept + { + StereoBaseCallbacksArmed.store(false, std::memory_order_release); + QueryIssueHook = {}; + CreateQueryHook = {}; + SetVertexShaderHook = {}; + SetRenderStateHook = {}; + DrawIndexedPrimitiveUPHook = {}; + DrawPrimitiveUPHook = {}; + DrawIndexedPrimitiveHook = {}; + DrawPrimitiveHook = {}; + ClearHook = {}; + SetDepthStencilSurfaceHook = {}; + SetRenderTargetHook = {}; + PresentHook = {}; + ResetHook = {}; + StereoInstalledDevice.store(nullptr, std::memory_order_release); + } + + bool InstallStereoHooks(IDirect3DDevice9*device) + { + std::lock_guard installLock(StereoInstallMutex); + if(StereoInstallState.load(std::memory_order_acquire)==StereoInstallReady) + return StereoInstalledDevice.load(std::memory_order_acquire)==device; + if(!device) + { + StereoInstallState.store(StereoInstallFailed,std::memory_order_release); + return false; + } + void**vtable=*reinterpret_cast(device); + if(!vtable) + { + StereoInstallState.store(StereoInstallFailed,std::memory_order_release); + return false; + } + StereoInstallState.store(StereoInstallPending,std::memory_order_release); + StereoBaseCallbacksArmed.store(false,std::memory_order_release); + const auto disabled=safetyhook::InlineHook::StartDisabled; + ResetHook=safetyhook::create_inline(vtable[ResetVtableIndex],ResetDest,disabled); + PresentHook=safetyhook::create_inline(vtable[PresentVtableIndex],PresentDest,disabled); + SetRenderTargetHook=safetyhook::create_inline(vtable[SetRenderTargetVtableIndex],SetRenderTargetDest,disabled); + SetDepthStencilSurfaceHook=safetyhook::create_inline(vtable[SetDepthStencilSurfaceVtableIndex],SetDepthStencilSurfaceDest,disabled); + ClearHook=safetyhook::create_inline(vtable[ClearVtableIndex],ClearDest,disabled); + DrawPrimitiveHook=safetyhook::create_inline(vtable[DrawPrimitiveVtableIndex],DrawPrimitiveDest,disabled); + DrawIndexedPrimitiveHook=safetyhook::create_inline(vtable[DrawIndexedPrimitiveVtableIndex],DrawIndexedPrimitiveDest,disabled); + DrawPrimitiveUPHook=safetyhook::create_inline(vtable[DrawPrimitiveUPVtableIndex],DrawPrimitiveUPDest,disabled); + DrawIndexedPrimitiveUPHook=safetyhook::create_inline(vtable[DrawIndexedPrimitiveUPVtableIndex],DrawIndexedPrimitiveUPDest,disabled); + SetRenderStateHook=safetyhook::create_inline(vtable[SetRenderStateVtableIndex],SetRenderStateDest,disabled); + SetVertexShaderHook=safetyhook::create_inline(vtable[SetVertexShaderVtableIndex],SetVertexShaderDest,disabled); + CreateQueryHook=safetyhook::create_inline(vtable[CreateQueryVtableIndex],CreateQueryDest,disabled); + if(!ResetHook||!PresentHook||!SetRenderTargetHook||!SetDepthStencilSurfaceHook||!ClearHook||!DrawPrimitiveHook||!DrawIndexedPrimitiveHook||!DrawPrimitiveUPHook||!DrawIndexedPrimitiveUPHook||!SetRenderStateHook||!SetVertexShaderHook||!CreateQueryHook) + { + RollbackStereoHooks(); + StereoInstallState.store(StereoInstallFailed,std::memory_order_release); + spdlog::error("VR stereo: D3D9 hook transaction was partial; all base hooks rolled back immediately"); + return false; + } + SafetyHookInline*baseHooks[]{&SetRenderTargetHook,&SetDepthStencilSurfaceHook,&SetRenderStateHook,&SetVertexShaderHook,&CreateQueryHook,&ClearHook,&DrawPrimitiveHook,&DrawIndexedPrimitiveHook,&DrawPrimitiveUPHook,&DrawIndexedPrimitiveUPHook,&PresentHook,&ResetHook}; + for(auto*hook:baseHooks) + { + if(!hook->enable().has_value()) + { + RollbackStereoHooks(); + StereoInstallState.store(StereoInstallFailed,std::memory_order_release); + spdlog::error("VR stereo: disabled-first D3D9 hook transaction could not be enabled completely; callbacks remain disarmed"); + return false; + } + } + IDirect3DVertexShader9*shader=nullptr; + if(SUCCEEDED(device->GetVertexShader(&shader))&&shader) + { + CurrentVertexShaderIdentity.store(reinterpret_cast(shader),std::memory_order_release); + VertexShaderSerial.store(1,std::memory_order_release); + shader->Release(); + } + PrimeTrackedRenderStates(device); + IDirect3DQuery9*queryProbe=nullptr; + if(SUCCEEDED(device->CreateQuery(D3DQUERYTYPE_OCCLUSION,&queryProbe))&&queryProbe)queryProbe->Release(); + InitializeAuxRenderTargetState(device); + LastBeginSceneCountAtPresent=OutRunVRRenderer::GetBeginSceneCallCount(); + EnsureSharedState(); + EnsureRenderFrameState(); + EnsureStereoResources(device); + StereoInstalledDevice.store(device,std::memory_order_release); + StereoBaseCallbacksArmed.store(true,std::memory_order_release); + StereoInstallState.store(StereoInstallReady,std::memory_order_release); + spdlog::info("VR stereo: D3D9 full-eye renderer installed (Reset/Present/RT/Depth/Clear/Draw*/RenderState/VS/CreateQuery); disabled-first callbacks armed atomically at the behavior boundary; R42 render-state shadow validates every 16 reads and c64 world checks use game upload shadow"); + return true; + } + + DWORD WINAPI StereoInstallThread(void*) + { + // D3D9Ex devices are installed synchronously on their CreateDevice + // caller thread. This worker remains only for the classic D3D9 path and + // exits before touching a synchronously installed promoted device. + for(int attempt=0;attempt<1200;++attempt) + { + if(StereoInstallState.load(std::memory_order_acquire)==StereoInstallReady) + return 0; + if(Game::D3DDevice_ptr&&*Game::D3DDevice_ptr) + { + if(!InstallStereoHooks(*Game::D3DDevice_ptr)) + spdlog::error("VR stereo: classic-path renderer hook installation failed closed"); + return 0; + } + Sleep(100); + } + StereoInstallState.store(StereoInstallFailed,std::memory_order_release); + spdlog::warn("VR stereo: D3D9 device did not appear; stereo hooks not installed"); + return 0; + } + + } + + bool InstallStereoHooksSynchronously(IDirect3DDevice9* device) noexcept + { + const bool installed=InstallStereoHooks(device); + if(installed) + spdlog::info("VR stereo: synchronous CreateDevice-thread base hook handoff READY; promoted D3D9Ex device exposed only after Reset ownership transferred"); + return installed; + } + + bool IsInternalStereoPassActive(){return InternalStereoPass;} + bool GetCurrentShaderEpoch(std::uintptr_t&shaderIdentity,std::uint64_t&serial){for(int attempt=0;attempt<3;++attempt){const std::uint64_t before=VertexShaderSerial.load(std::memory_order_acquire);const std::uintptr_t identity=CurrentVertexShaderIdentity.load(std::memory_order_acquire);const std::uint64_t after=VertexShaderSerial.load(std::memory_order_acquire);if(before==after){shaderIdentity=identity;serial=after;return identity!=0&&after!=0;}}shaderIdentity=0;serial=0;return false;} + class VRStereoHook:public Hook + { + public: + std::string_view description() override{return "OpenXRVRStereo";} + bool validate() override{return true;} + bool apply() override{HANDLE thread=CreateThread(nullptr,0,StereoInstallThread,nullptr,0,nullptr);if(!thread){StereoInstallState.store(StereoInstallFailed,std::memory_order_release);spdlog::error("VR stereo: failed to create installer thread: {}",GetLastError());return false;}CloseHandle(thread);return true;} + static VRStereoHook instance; + }; + VRStereoHook VRStereoHook::instance; +} diff --git a/src/vr/d3d9/vr_pass_policy.hpp b/src/vr/d3d9/vr_pass_policy.hpp new file mode 100644 index 000000000..54e6a2cad --- /dev/null +++ b/src/vr/d3d9/vr_pass_policy.hpp @@ -0,0 +1,296 @@ +#pragma once + +#include + +// Explicit VR render-pass policy shared by the D3D9 stereo and renderer-pose +// hardening layers. The design follows the same conservative principle used by +// mature emulator VR implementations: classify each pass first, then choose the +// least invasive rendering action. This file intentionally contains no D3D9 +// state access so the policy remains deterministic and compile-time testable. +// +// Emulator review retained as behavior/design guidance only (no source copied): +// - PPSSPP: distinguish 3D geometry from flat/screen-space geometry before +// applying headset transforms. +// - Dolphin: treat perspective and orthographic projection as first-class, +// different renderer states; camera transforms belong to perspective draws. +// - PCSX2: require several corroborating render-state signals rather than one +// heuristic before classifying a draw specially. +// - RPCS3: preserve explicit frame ownership; OutRun keeps one immutable pose +// until Present and resets the ownership boundary only after Present. +// +// OutRun-specific rule: only a perspective draw targeting the real main +// backbuffer may receive the HMD WVP. Auxiliary/reflection/shadow targets and +// orthographic/screen-space draws keep stock matrices, while MRT/query hazards +// are classified single-execution rather than replayed per eye. +namespace OutRunVR::PassPolicy +{ + enum class PoseInjectionPolicy : std::uint8_t + { + InternalStereo, + MainBackbuffer, + AuxiliaryStock + }; + + constexpr PoseInjectionPolicy ClassifyPoseInjection( + bool internalStereo, + bool mainBackbuffer, + bool auxiliaryRenderTargetActive) noexcept + { + if (internalStereo) + return PoseInjectionPolicy::InternalStereo; + if (mainBackbuffer && !auxiliaryRenderTargetActive) + return PoseInjectionPolicy::MainBackbuffer; + return PoseInjectionPolicy::AuxiliaryStock; + } + + constexpr bool AllowsPoseInjection(PoseInjectionPolicy policy) noexcept + { + return policy == PoseInjectionPolicy::MainBackbuffer; + } + + // D3DX perspective matrices used by OutRun have |m34| == 1 and m44 == 0; + // orthographic matrices have m34 == 0 and |m44| == 1. Classify only these + // structural terms so FOV/aspect/near/far changes cannot turn UI into world + // geometry. Unknown signatures fail closed to the stock game matrix. + enum class ProjectionClass : std::uint8_t + { + Unknown, + Perspective3D, + Orthographic2D + }; + + constexpr float Abs(float value) noexcept + { + return value < 0.0f ? -value : value; + } + + constexpr bool Near(float value, float expected, float epsilon = 0.05f) noexcept + { + return Abs(value - expected) <= epsilon; + } + + constexpr ProjectionClass ClassifyProjectionSignature( + float m34, float m44) noexcept + { + // Accept either handedness/sign convention for the perspective divide; + // OutRun itself is RH and normally supplies m34=-1, m44=0. + if (Near(Abs(m34), 1.0f) && Near(m44, 0.0f)) + return ProjectionClass::Perspective3D; + if (Near(m34, 0.0f) && Near(Abs(m44), 1.0f)) + return ProjectionClass::Orthographic2D; + return ProjectionClass::Unknown; + } + + // Multi-signal scene semantics: target identity and projection semantics + // must agree before a draw is allowed into the head-tracked world path. + // This is intentionally fail-closed: an unknown main-backbuffer projection + // remains stock until runtime evidence proves it belongs to 3D world space. + enum class RenderSemantic : std::uint8_t + { + InternalStereo, + Auxiliary, + World3D, + ScreenSpace2D, + PolicyMismatch, + Unknown + }; + + constexpr RenderSemantic ClassifyRenderSemantic( + PoseInjectionPolicy targetPolicy, + ProjectionClass projectionClass) noexcept + { + if (targetPolicy == PoseInjectionPolicy::InternalStereo) + return RenderSemantic::InternalStereo; + if (targetPolicy != PoseInjectionPolicy::MainBackbuffer) + return RenderSemantic::Auxiliary; + if (projectionClass == ProjectionClass::Perspective3D) + return RenderSemantic::World3D; + if (projectionClass == ProjectionClass::Orthographic2D) + return RenderSemantic::ScreenSpace2D; + return RenderSemantic::Unknown; + } + + // Preserve the independently validated R13 compatibility helper as a + // second signal. If the helper and the richer enum disagree, the safe + // interpretation is not "unknown projection" but an explicit policy + // consistency failure. Keeping this distinction makes runtime diagnostics + // actionable and prevents a future refactor from silently widening the + // world-stereo class. + constexpr RenderSemantic ClassifyRenderSemanticChecked( + PoseInjectionPolicy targetPolicy, + bool legacyMainBackbufferInvariant, + ProjectionClass projectionClass) noexcept + { + const bool policySaysMain = + targetPolicy == PoseInjectionPolicy::MainBackbuffer; + if (legacyMainBackbufferInvariant != policySaysMain) + return RenderSemantic::PolicyMismatch; + return ClassifyRenderSemantic(targetPolicy, projectionClass); + } + + constexpr bool AllowsWorldStereo(RenderSemantic semantic) noexcept + { + return semantic == RenderSemantic::World3D; + } + + // Main-backbuffer perspective draws are not automatically safe for an HMD + // WVP. OutRun has projected shadows and camera-facing textured panels whose + // geometry/material state is prepared for one camera. Re-projecting those + // passes independently for both eyes can produce torn shadows, crossed + // billboards or visibly different panel quads in the SBS transport. + // + // Do not demote a draw from alpha/cutout state alone. Hardware evidence on + // OutRun showed the car/projected-shadow pass can be two-sided, blended and + // no-Z-write while still being world-space. Depth testing is the decisive + // corroborating signal: depth-tested effects keep spatial world stereo; + // zero-disparity is reserved for two-sided effects with depth testing off. + enum class EffectStereoPolicy : std::uint8_t + { + WorldStereo, + ZeroDisparity + }; + + constexpr EffectStereoPolicy ClassifyEffectStereo( + bool alphaBlendEnabled, + bool alphaTestEnabled, + bool depthWriteEnabled, + bool depthTestEnabled, + bool cullNone) noexcept + { + // Screen overlays, white rank/score text and exit Yes/No can + // use perspective-looking geometry while Z testing is disabled. Without + // a positive spatial semantic, depth-off must never inherit world stereo. + if (!depthTestEnabled) + return EffectStereoPolicy::ZeroDisparity; + (void)alphaBlendEnabled; + (void)alphaTestEnabled; + (void)depthWriteEnabled; + (void)cullNone; + return EffectStereoPolicy::WorldStereo; + } + + constexpr bool AllowsEffectWorldStereo(EffectStereoPolicy policy) noexcept + { + return policy == EffectStereoPolicy::WorldStereo; + } + + enum class FixedFunctionStereoPolicy : std::uint8_t + { + NonWorld, + WorldStereo, + SkyRotationOnly + }; + + constexpr FixedFunctionStereoPolicy ClassifyFixedFunctionStereo( + ProjectionClass projection, bool gameplay, bool depthTestEnabled) noexcept + { + if (!gameplay || projection != ProjectionClass::Perspective3D) + return FixedFunctionStereoPolicy::NonWorld; + // A gameplay fixed-function perspective draw with Z disabled is the + // only positively isolated sky/cloud signature available in this path. + // Keep headset rotation/FOV, but remove head/IPD translation. + if (!depthTestEnabled) + return FixedFunctionStereoPolicy::SkyRotationOnly; + return FixedFunctionStereoPolicy::WorldStereo; + } + + enum class DrawReplayPolicy : std::uint8_t + { + Legacy, + ForcedMonoShadow, + UnsafeSingleExecution + }; + + constexpr DrawReplayPolicy ClassifyDrawReplay( + bool gameDevice, + bool internalStereo, + bool mainBackbuffer, + bool forceMonoShadow, + bool stereoWanted, + bool stereoSeeded, + bool unsafeMrt, + bool unsafeOcclusion) noexcept + { + if (!gameDevice || internalStereo || !mainBackbuffer) + return DrawReplayPolicy::Legacy; + if (forceMonoShadow) + return DrawReplayPolicy::ForcedMonoShadow; + if (stereoWanted && stereoSeeded && (unsafeMrt || unsafeOcclusion)) + return DrawReplayPolicy::UnsafeSingleExecution; + return DrawReplayPolicy::Legacy; + } + + // Compile-time policy invariants. These deliberately encode the safety + // contract rather than individual hook implementation details. + static_assert(ClassifyPoseInjection(false, true, false) == + PoseInjectionPolicy::MainBackbuffer); + static_assert(ClassifyPoseInjection(false, true, true) == + PoseInjectionPolicy::AuxiliaryStock); + static_assert(ClassifyPoseInjection(true, true, false) == + PoseInjectionPolicy::InternalStereo); + + static_assert(ClassifyProjectionSignature(-1.0f, 0.0f) == + ProjectionClass::Perspective3D); + static_assert(ClassifyProjectionSignature(1.0f, 0.0f) == + ProjectionClass::Perspective3D); + static_assert(ClassifyProjectionSignature(0.0f, 1.0f) == + ProjectionClass::Orthographic2D); + static_assert(ClassifyProjectionSignature(0.25f, 0.25f) == + ProjectionClass::Unknown); + + static_assert(ClassifyRenderSemantic( + PoseInjectionPolicy::MainBackbuffer, ProjectionClass::Perspective3D) == + RenderSemantic::World3D); + static_assert(ClassifyRenderSemantic( + PoseInjectionPolicy::MainBackbuffer, ProjectionClass::Orthographic2D) == + RenderSemantic::ScreenSpace2D); + static_assert(ClassifyRenderSemantic( + PoseInjectionPolicy::AuxiliaryStock, ProjectionClass::Perspective3D) == + RenderSemantic::Auxiliary); + static_assert(!AllowsWorldStereo(ClassifyRenderSemantic( + PoseInjectionPolicy::MainBackbuffer, ProjectionClass::Unknown))); + static_assert(ClassifyRenderSemanticChecked( + PoseInjectionPolicy::MainBackbuffer, true, + ProjectionClass::Perspective3D) == RenderSemantic::World3D); + static_assert(ClassifyRenderSemanticChecked( + PoseInjectionPolicy::MainBackbuffer, false, + ProjectionClass::Perspective3D) == RenderSemantic::PolicyMismatch); + static_assert(ClassifyRenderSemanticChecked( + PoseInjectionPolicy::AuxiliaryStock, true, + ProjectionClass::Unknown) == RenderSemantic::PolicyMismatch); + static_assert(!AllowsWorldStereo(RenderSemantic::PolicyMismatch)); + + static_assert(ClassifyEffectStereo(false, false, true, true, false) == + EffectStereoPolicy::WorldStereo); + static_assert(ClassifyEffectStereo(true, false, false, true, true) == + EffectStereoPolicy::WorldStereo); + static_assert(ClassifyEffectStereo(true, false, false, false, false) == + EffectStereoPolicy::ZeroDisparity); + static_assert(ClassifyEffectStereo(true, false, false, false, true) == + EffectStereoPolicy::ZeroDisparity); + static_assert(ClassifyEffectStereo(false, true, true, true, true) == + EffectStereoPolicy::WorldStereo); + static_assert(ClassifyEffectStereo(false, true, true, false, true) == + EffectStereoPolicy::ZeroDisparity); + static_assert(AllowsEffectWorldStereo( + ClassifyEffectStereo(false, false, true, true, false))); + + static_assert(ClassifyFixedFunctionStereo( + ProjectionClass::Perspective3D, true, true) == + FixedFunctionStereoPolicy::WorldStereo); + static_assert(ClassifyFixedFunctionStereo( + ProjectionClass::Perspective3D, true, false) == + FixedFunctionStereoPolicy::SkyRotationOnly); + static_assert(ClassifyFixedFunctionStereo( + ProjectionClass::Orthographic2D, true, false) == + FixedFunctionStereoPolicy::NonWorld); + + static_assert(ClassifyDrawReplay(true, false, true, false, true, true, true, false) == + DrawReplayPolicy::UnsafeSingleExecution); + static_assert(ClassifyDrawReplay(true, false, true, false, true, false, true, true) == + DrawReplayPolicy::Legacy); + static_assert(ClassifyDrawReplay(true, false, true, true, true, true, false, false) == + DrawReplayPolicy::ForcedMonoShadow); + static_assert(ClassifyDrawReplay(true, false, false, false, true, true, true, true) == + DrawReplayPolicy::Legacy); +} \ No newline at end of file diff --git a/src/vr/debug/hud_inspector.cpp b/src/vr/debug/hud_inspector.cpp new file mode 100644 index 000000000..8093674da --- /dev/null +++ b/src/vr/debug/hud_inspector.cpp @@ -0,0 +1,295 @@ +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "hook_mgr.hpp" +#include "plugin.hpp" +#include "game_addrs.hpp" +#include "../hud_semantics.hpp" + +namespace Settings +{ + Setting VRHudInspector{ + "VR", "HudInspector", false, + "Passive HUD/sprite reverse-engineering trace. Records unique sprite call sites, IDs and coordinates with EXE RVAs so static EXE analysis can map them back to game functions." + }; +} + +namespace OutRunVRHudInspector +{ + namespace + { + enum class EventKind : std::uint32_t + { + PutSprite = 1, + SpriteAnim = 2, + ClipSprite = 3, + PutSprite2 = 4 + }; + + SafetyHookInline SpriteAnimHook{}; + SafetyHookInline ClipSpriteHook{}; + + std::mutex TraceMutex; + std::ofstream TraceFile; + std::unordered_map Seen; + std::uint64_t TraceLines = 0; + ULONGLONG StartMs = 0; + + std::uint32_t ExeSizeOfImage() noexcept + { + if (!Module::ExeHandle) + return 0; + const auto* base = reinterpret_cast(Module::ExeHandle); + const auto* dos = reinterpret_cast(base); + if (dos->e_magic != IMAGE_DOS_SIGNATURE) + return 0; + const auto* nt = reinterpret_cast(base + dos->e_lfanew); + if (nt->Signature != IMAGE_NT_SIGNATURE) + return 0; + return nt->OptionalHeader.SizeOfImage; + } + + std::uint32_t ToExeRva(const void* address) noexcept + { + const auto base = reinterpret_cast(Module::ExeHandle); + const auto value = reinterpret_cast(address); + const auto size = static_cast(ExeSizeOfImage()); + if (!base || !value || value < base || !size || value >= base + size) + return 0; + return static_cast(value - base); + } + + std::uint64_t MakeKey(EventKind kind, std::uint32_t callRva, + std::uint32_t arg0, std::uint32_t arg1) noexcept + { + std::uint64_t h = 1469598103934665603ull; + auto mix = [&h](std::uint32_t v) { + h ^= v; + h *= 1099511628211ull; + }; + mix(static_cast(kind)); + mix(callRva); + mix(arg0); + mix(arg1); + return h; + } + + bool ShouldWrite(std::uint64_t key, std::uint32_t& count) + { + count = ++Seen[key]; + return count == 1 || (count <= 1024 && (count & (count - 1)) == 0); + } + + int CurrentMode() noexcept + { + return Game::current_mode ? static_cast(*Game::current_mode) : -1; + } + + int CurrentStage() noexcept + { + return Game::stg_stage_num ? static_cast(*Game::stg_stage_num) : -1; + } + + void WriteEvent(EventKind kind, const char* eventName, + const void* returnAddress, + std::uint32_t arg0, std::uint32_t arg1, + double arg2 = 0.0, double arg3 = 0.0, + double arg4 = 0.0, double arg5 = 0.0, + double arg6 = 0.0, double arg7 = 0.0) + { + if (!TraceFile) + return; + + const std::uint32_t returnRva = ToExeRva(returnAddress); + const std::uint32_t callRva = + returnRva >= 5 ? returnRva - 5 : returnRva; + const std::uint64_t key = MakeKey(kind, callRva, arg0, arg1); + + std::lock_guard lock(TraceMutex); + std::uint32_t count = 0; + if (!ShouldWrite(key, count)) + return; + + const auto semantic = + OutRunVRHudSemantics::ClassifyCaller(callRva); + + TraceFile + << (GetTickCount64() - StartMs) << ',' + << eventName << ',' + << "0x" << std::hex << std::setw(8) << std::setfill('0') << returnRva << ',' + << "0x" << std::setw(8) << callRva << std::dec << ',' + << semantic.area << ',' + << semantic.semantic << ',' + << OutRunVRHudSemantics::SpacePolicyName(semantic.space) << ',' + << CurrentMode() << ',' + << CurrentStage() << ',' + << arg0 << ',' + << arg1 << ',' + << arg2 << ',' + << arg3 << ',' + << arg4 << ',' + << arg5 << ',' + << arg6 << ',' + << arg7 << ',' + << count << '\n'; + + if ((++TraceLines & 63ull) == 0) + TraceFile.flush(); + } + + int __cdecl SpriteAnimDest(std::uint32_t spriteId, float x, float y, + int a4, int a5, float alpha) + { + WriteEvent(EventKind::SpriteAnim, "sprani_play_ae_auth_alpha", + _ReturnAddress(), spriteId, + static_cast(a4), + x, y, static_cast(a5), alpha); + return SpriteAnimHook.unsafe_ccall( + spriteId, x, y, a4, a5, alpha); + } + + int __cdecl ClipSpriteDest(int xstnum, int x, int y, + std::uint32_t flags, float priority, std::uint32_t color) + { + WriteEvent(EventKind::ClipSprite, "put_clip_sprite", + _ReturnAddress(), + static_cast(xstnum), flags, + static_cast(x), static_cast(y), + priority, static_cast(color)); + return ClipSpriteHook.unsafe_ccall( + xstnum, x, y, flags, priority, color); + } + + bool OpenTrace() + { + try + { + const auto path = + Module::DllPath.parent_path() / + "OutRun2006Tweaks-hudtrace.csv"; + const bool empty = + !std::filesystem::exists(path) || + std::filesystem::file_size(path) == 0; + + TraceFile.open(path, std::ios::out | std::ios::app); + if (!TraceFile) + return false; + + StartMs = GetTickCount64(); + if (empty) + { + TraceFile + << "# schema=outrun-hudtrace-v2\n" + << "# exe_timestamp=" + << Util::GetModuleTimestamp(Module::ExeHandle) << "\n" + << "# exe_size_of_image=" << ExeSizeOfImage() << "\n" + << "# module_base=runtime-only; all addresses below are ASLR-safe RVAs\n" + << "elapsed_ms,event,return_rva,call_rva,known_area,semantic,space_policy,mode,stage," + "arg0,arg1,arg2,arg3,arg4,arg5,arg6,arg7,count\n"; + TraceFile.flush(); + } + return true; + } + catch (const std::exception& e) + { + spdlog::warn("VR HUD INSPECTOR: failed to open trace: {}", e.what()); + return false; + } + } + + void ResetHooks() noexcept + { + ClipSpriteHook = {}; + SpriteAnimHook = {}; + } + + } + + // Feed points used by the existing texture hooks. Keeping put_sprite_ex on + // its already-established hook avoids stacking two inline detours on the + // same game function and preserves the real game caller return address. + void TracePutSprite(SPRARGS* sprargs, float priority, + const void* returnAddress) + { + if (!sprargs) + return; + WriteEvent(EventKind::PutSprite, "put_sprite_ex", returnAddress, + sprargs->xstnum_0, sprargs->top_4, + sprargs->left_8, sprargs->bottom_C, + sprargs->right_10, sprargs->scaleX, + sprargs->scaleY, priority); + } + + void TracePutSprite2(SPRARGS2* sprargs, float priority, + const void* returnAddress) + { + if (!sprargs) + return; + WriteEvent(EventKind::PutSprite2, "put_sprite_ex2", returnAddress, + sprargs->xstnum_0, sprargs->child_B4 ? 1u : 0u, + sprargs->color_4, + sprargs->TopLeft_54.x, sprargs->TopLeft_54.y, + sprargs->BottomRight_78.x, sprargs->BottomRight_78.y, + priority); + } + + namespace + { + class VRHudInspectorHook : public Hook + { + public: + std::string_view description() override + { + return "OpenXRVRHudInspector"; + } + + bool validate() override + { + return Settings::VREnabled.get() && + Settings::VRHudInspector.get(); + } + + void declare_settings() override + { + Settings::VRHudInspector.needs_restart(); + } + + bool apply() override + { + if (!OpenTrace()) + return false; + + SpriteAnimHook = safetyhook::create_inline( + Game::sprani_play_ae_auth_alpha, SpriteAnimDest); + ClipSpriteHook = safetyhook::create_inline( + Game::put_clip_sprite, ClipSpriteDest); + + if (!SpriteAnimHook || !ClipSpriteHook) + { + spdlog::error( + "VR HUD INSPECTOR: one or more sprite hooks failed; disabling inspector"); + ResetHooks(); + return false; + } + + spdlog::info( + "VR HUD INSPECTOR: semantic HUD tracing active; UIScaling-derived caller taxonomy separates screen HUD from world billboards; output=OutRun2006Tweaks-hudtrace.csv"); + return true; + } + + static VRHudInspectorHook instance; + }; + + VRHudInspectorHook VRHudInspectorHook::instance; + } +} \ No newline at end of file diff --git a/src/vr/game/game_adapter.hpp b/src/vr/game/game_adapter.hpp new file mode 100644 index 000000000..358ca021a --- /dev/null +++ b/src/vr/game/game_adapter.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include "vr/core/frame_types.hpp" + +#include + +namespace OutRunVR::Game +{ + struct StereoMatrices + { + float leftWvp[16]{}; + float rightWvp[16]{}; + std::uint64_t poseId{}; + }; + + class IGameAdapter + { + public: + virtual ~IGameAdapter() = default; + + // Called once for a presented game frame. Implementations may latch an + // already-published host pose, but must never advance simulation/input/FFB. + virtual bool latchRenderPose(Core::RenderPose& outPose) noexcept = 0; + + // Converts the verified game camera boundary into eye-specific matrices. + // This is the only layer allowed to know OutRun's c64 WorldViewProjection facts. + virtual bool buildStereoMatrices(const Core::RenderPose& pose, + StereoMatrices& outMatrices) noexcept = 0; + + virtual void onPresent() noexcept = 0; + virtual void onReset() noexcept = 0; + }; +} diff --git a/src/vr/game/outrun_renderer.cpp b/src/vr/game/outrun_renderer.cpp new file mode 100644 index 000000000..932f2ec41 --- /dev/null +++ b/src/vr/game/outrun_renderer.cpp @@ -0,0 +1,1843 @@ +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include + +#include +#include +#include +#include +#include + +#include + +#include "hook_mgr.hpp" +#include "plugin.hpp" +#include "game_addrs.hpp" +#include "input_manager.hpp" +#include "vr_shared.hpp" +#include "vr/ipc/host_pose_v3.hpp" +#include "vr/ipc/cadence_v1.hpp" + +// Authoritative renderer-side OpenXR head-pose injector for OutRun 2006. +// +// Kim2091/Remix-Wrappers independently reverse-engineered the relevant PC +// renderer path: +// 0x0095DB20 WorldView = World * View +// 0x0095D8A0 Projection +// VS c64..c67 = Transpose(WorldView * Projection) +// with the live View at 0x0095D860. The game camera is right-handed and uses +// D3DXMatrixLookAtRH / D3DXMatrixPerspectiveFovRH, matching OpenXR's RH basis. +// +// The final shader upload remains the authoritative visual transform. A small +// render-phase camera-state sync mirrors the same pose into cam_pos/look only +// between BeginScene and EndScene so culling/billboards/flares that consult the +// live camera can follow head motion without feeding VR values back into the +// physics tick. The sync is restored before EndScene calls the original D3D9 +// function. This intentionally does not claim to fix culling that happens before +// BeginScene; that boundary still needs runtime visibility testing. +// +// Stereo eye FOV/IPD are latched from the SAME immutable host snapshot as the +// head pose once per successful game BeginScene. Protocol v3 is the primary +// source; the proven v2 bridge remains a same-frame fallback until transport v3 +// fully replaces the legacy frame ring. + +namespace Settings +{ + extern Setting VREnabled; + extern Setting VRAutoEnableWhenHostPresent; + extern Setting VRHeadTracking; + extern Setting VRPositionalTracking; + extern Setting VRCullingCameraSync; + extern Setting VRCullingUnionFov; + extern Setting VRWorldScale; + extern Setting VRRotationScale; + extern Setting VRMatrixOrder; + extern Setting VRTelemetry; + extern Setting UseNewInput; + extern Setting VRFrameCadenceMode; + extern Setting VRFrameCadenceTimeoutMs; +} + +namespace OutRunVRRenderer +{ + namespace + { + struct Vec3 { float x, y, z; }; + struct Quat { float x, y, z, w; }; + struct PoseSample + { + Quat orientation{ 0.0f, 0.0f, 0.0f, 1.0f }; + Vec3 position{ 0.0f, 0.0f, 0.0f }; + bool positionValid = false; + std::uint32_t hostPid = 0; + std::uint32_t sequence = 0; + std::uint32_t referenceSpaceGeneration = 0; + bool stereoValid = false; + SharedFov eyeFov[2]{}; + float eyeOffset[2][3]{}; + Quat eyeOrientation[2]{ + { 0.0f, 0.0f, 0.0f, 1.0f }, + { 0.0f, 0.0f, 0.0f, 1.0f } + }; + }; + + constexpr std::size_t BeginSceneVtableIndex = 41; + constexpr std::size_t EndSceneVtableIndex = 42; + constexpr std::size_t SetVertexShaderConstantFVtableIndex = 94; + constexpr UINT OutRunWvpRegister = 64; + constexpr UINT OutRunWvpRegisterCount = 4; + + // Stock 0x00400000 image-base addresses expressed as RVAs for ASLR safety. + constexpr std::uintptr_t OutRunViewRva = 0x0095D860u - 0x00400000u; + constexpr std::uintptr_t OutRunProjectionRva = 0x0095D8A0u - 0x00400000u; + constexpr std::uintptr_t OutRunWorldViewRva = 0x0095DB20u - 0x00400000u; + + constexpr float Pi = 3.14159265358979323846f; + constexpr float WvpVerifyAbsoluteEpsilon = 0.05f; + constexpr LONGLONG HostPoseStaleMs = 250; + + SafetyHookInline BeginSceneHook{}; + SafetyHookInline EndSceneHook{}; + SafetyHookInline SetVertexShaderConstantFHook{}; + + constexpr std::uint32_t RendererInstallPending = 0; + constexpr std::uint32_t RendererInstallReady = 1; + constexpr std::uint32_t RendererInstallFailed = 2; + std::atomic RendererInstallState{RendererInstallPending}; + std::atomic RendererInjectionAllowed{true}; + + HANDLE SharedMapping = nullptr; + SharedPoseState* SharedState = nullptr; + LARGE_INTEGER QpcFrequency{}; + OutRunVR::IpcV3::HostPoseV3Source V3PoseSource{}; + + HANDLE CadenceHostMapping = nullptr; + const OutRunVR::CadenceV1::HostState* CadenceHostState = nullptr; + HANDLE CadenceClientMapping = nullptr; + OutRunVR::CadenceV1::ClientState* CadenceClientState = nullptr; + HANDLE CadenceRequestEvent = nullptr; + HANDLE CadencePresentedEvent = nullptr; + std::atomic ActiveCadenceRequestId{0}; + std::atomic CadencePacingActive{false}; + std::uint32_t CadenceAcceptedRequestId = 0; + std::uint32_t CadencePresentedRequestId = 0; + std::uint32_t CadenceTimedOutRequestId = 0; + std::uint32_t CadenceTimeoutCount = 0; + std::uint32_t CadenceLastWaitUs = 0; + std::int64_t CadenceAcceptedQpc = 0; + std::int64_t CadencePresentedQpc = 0; + bool FirstCadenceAcceptedLogged = false; + bool FirstCadenceTimeoutLogged = false; + + const D3DMATRIX* RendererView = nullptr; + const D3DMATRIX* RendererProjection = nullptr; + const D3DMATRIX* RendererWorldView = nullptr; + bool RendererGlobalsChecked = false; + bool RendererGlobalsValid = false; + + Quat CenterOrientation{ 0.0f, 0.0f, 0.0f, 1.0f }; + Vec3 CenterPosition{ 0.0f, 0.0f, 0.0f }; + bool CenterValid = false; + bool CenterPositionValid = false; + std::uint32_t CenterHostPid = 0; + std::uint32_t CenterReferenceSpaceGeneration = 0; + bool RecenterWasDown = false; + bool AutoEnableLogged = false; + + D3DMATRIX LatchedHeadInverse{}; + bool LatchedHeadInverseValid = false; + LatchedStereoFrame LatchedStereo{}; + std::uint32_t FrameTelemetryFlags = ClientHookAlive; + float LatchedRelativeAngleDeg = 0.0f; + std::uint32_t LatchedPoseSequence = 0; + bool PresentPoseLocked = false; + bool LastPoseSourceV3 = false; + + float LastVerifiedWvp[16]{}; + bool LastVerifiedWvpValid = false; + std::uint32_t LastVerifiedWvpGeneration = 0; + std::uint32_t LastVerifiedWvpPoseSequence = 0; + std::uintptr_t LastVerifiedShaderIdentity = 0; + std::uint64_t LastVerifiedShaderSerial = 0; + + float LastGameWvpWrite[16]{}; + float LastRawGameWvpWrite[16]{}; + bool LastGameWvpWriteValid = false; + std::uint64_t LastGameWvpWriteSerial = 0; + std::uint64_t LastGameWvpTopLevelDrawSerial = 0; + std::uintptr_t LastGameWvpShaderIdentity = 0; + std::uint64_t LastGameWvpShaderSerial = 0; + + D3DVECTOR CullingCameraSavedPos{}; + D3DVECTOR CullingCameraSavedLook{}; + EvWorkCamera* CullingCameraObject = nullptr; + bool CullingCameraOverridden = false; + D3DMATRIX CullingProjectionSaved{}; + bool CullingProjectionOverridden = false; + + ULONGLONG LastSummaryMs = 0; + std::uint64_t BeginSceneCalls = 0; + std::uint64_t WvpCandidateCalls = 0; + std::uint64_t WvpVerifiedCalls = 0; + std::uint64_t WvpPreparedCalls = 0; + std::uint64_t WvpUploadSucceededCalls = 0; + std::uint64_t WvpUploadFailedCalls = 0; + std::uint64_t WvpRejectedCalls = 0; + std::uint64_t UnsafeAddressRejects = 0; + std::uint64_t ReusedPoseSceneCalls = 0; + std::uint64_t V3PoseReads = 0; + std::uint64_t V2PoseFallbacks = 0; + bool FirstVerifiedLogged = false; + bool FirstInjectedLogged = false; + bool FirstRejectedLogged = false; + bool FirstUnsafeAddressLogged = false; + bool FirstUploadFailedLogged = false; + bool CullingUnionFovDeferredLogged = false; + bool FirstV3PoseLogged = false; + bool FirstV2FallbackLogged = false; + + bool IsGameDevice(IDirect3DDevice9* device) + { + return device && Game::D3DDevice_ptr && *Game::D3DDevice_ptr == device; + } + + Quat Normalize(Quat q) + { + const float lengthSq = q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w; + if (!std::isfinite(lengthSq) || lengthSq <= 1.0e-12f) + return { 0.0f, 0.0f, 0.0f, 1.0f }; + const float invLength = 1.0f / std::sqrt(lengthSq); + return { q.x * invLength, q.y * invLength, q.z * invLength, q.w * invLength }; + } + + bool QuaternionIsSane(const Quat& q) + { + if (!std::isfinite(q.x) || !std::isfinite(q.y) || + !std::isfinite(q.z) || !std::isfinite(q.w)) + return false; + const float lengthSq = q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w; + return std::isfinite(lengthSq) && lengthSq > 0.25f && lengthSq < 4.0f; + } + + bool VectorIsFinite(const Vec3& v) + { + return std::isfinite(v.x) && std::isfinite(v.y) && std::isfinite(v.z); + } + + bool FovValid(const SharedFov& fov) + { + constexpr float limit = 1.56f; + return std::isfinite(fov.angleLeft) && std::isfinite(fov.angleRight) && + std::isfinite(fov.angleUp) && std::isfinite(fov.angleDown) && + fov.angleLeft > -limit && fov.angleRight < limit && + fov.angleDown > -limit && fov.angleUp < limit && + fov.angleRight > fov.angleLeft + 0.05f && + fov.angleUp > fov.angleDown + 0.05f; + } + + float FloatFromBits(std::uint32_t bits) + { + float value = 0.0f; + std::memcpy(&value, &bits, sizeof(value)); + return value; + } + + bool DecodePackedEyeOrientations(const SharedPoseState& snapshot, Quat out[2]) + { + std::int16_t packed[8]{}; + static_assert(sizeof(packed) == OutRunVR::PackedEyeOrientationBytes); + std::memcpy(packed, + snapshot.runtimeName + OutRunVR::PackedEyeOrientationOffset, + sizeof(packed)); + for (int eye = 0; eye < 2; ++eye) + { + Quat q{ + static_cast(packed[eye * 4 + 0]) / 32767.0f, + static_cast(packed[eye * 4 + 1]) / 32767.0f, + static_cast(packed[eye * 4 + 2]) / 32767.0f, + static_cast(packed[eye * 4 + 3]) / 32767.0f + }; + if (!QuaternionIsSane(q)) + return false; + out[eye] = Normalize(q); + } + return true; + } + + Quat Conjugate(const Quat& q) { return { -q.x, -q.y, -q.z, q.w }; } + + Quat MultiplyRaw(const Quat& a, const Quat& b) + { + return { + a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y, + a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x, + a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w, + a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z + }; + } + + Quat Multiply(const Quat& a, const Quat& b) + { + return Normalize(MultiplyRaw(a, b)); + } + + Quat AxisAngle(float x, float y, float z, float radians) + { + const float half = radians * 0.5f; + const float s = std::sin(half); + return Normalize({ x * s, y * s, z * s, std::cos(half) }); + } + + Quat ScaleRotation(Quat q, float scale) + { + q = Normalize(q); + if (q.w < 0.0f) + q = { -q.x, -q.y, -q.z, -q.w }; + + const float w = std::clamp(q.w, -1.0f, 1.0f); + const float angle = 2.0f * std::acos(w); + const float sinHalf = std::sqrt(std::max(0.0f, 1.0f - w * w)); + if (sinHalf < 1.0e-6f || angle < 1.0e-6f) + return { 0.0f, 0.0f, 0.0f, 1.0f }; + return AxisAngle(q.x / sinHalf, q.y / sinHalf, q.z / sinHalf, angle * scale); + } + + Vec3 RotateVector(const Quat& qIn, const Vec3& v) + { + const Quat q = Normalize(qIn); + const Quat p{ v.x, v.y, v.z, 0.0f }; + const Quat r = MultiplyRaw(MultiplyRaw(q, p), Conjugate(q)); + return { r.x, r.y, r.z }; + } + + Quat YawOnly(const Quat& orientation) + { + const Vec3 forward = RotateVector(orientation, { 0.0f, 0.0f, -1.0f }); + if (!VectorIsFinite(forward)) + return { 0.0f, 0.0f, 0.0f, 1.0f }; + const float planarSq = forward.x * forward.x + forward.z * forward.z; + if (planarSq <= 1.0e-8f) + return { 0.0f, 0.0f, 0.0f, 1.0f }; + const float yaw = std::atan2(-forward.x, -forward.z); + return AxisAngle(0.0f, 1.0f, 0.0f, yaw); + } + + D3DMATRIX IdentityMatrix() + { + D3DMATRIX out{}; + out._11 = out._22 = out._33 = out._44 = 1.0f; + return out; + } + + D3DMATRIX MatrixFromPose(const Quat& qIn, const Vec3& position) + { + const Quat q = Normalize(qIn); + const float xx = q.x * q.x, yy = q.y * q.y, zz = q.z * q.z; + const float xy = q.x * q.y, xz = q.x * q.z, yz = q.y * q.z; + const float xw = q.x * q.w, yw = q.y * q.w, zw = q.z * q.w; + + D3DMATRIX out = IdentityMatrix(); + out._11 = 1.0f - 2.0f * (yy + zz); + out._12 = 2.0f * (xy + zw); + out._13 = 2.0f * (xz - yw); + out._21 = 2.0f * (xy - zw); + out._22 = 1.0f - 2.0f * (xx + zz); + out._23 = 2.0f * (yz + xw); + out._31 = 2.0f * (xz + yw); + out._32 = 2.0f * (yz - xw); + out._33 = 1.0f - 2.0f * (xx + yy); + out._41 = position.x; + out._42 = position.y; + out._43 = position.z; + return out; + } + + D3DMATRIX MultiplyMatrix(const D3DMATRIX& a, const D3DMATRIX& b) + { + D3DMATRIX out{}; + for (int row = 0; row < 4; ++row) + for (int col = 0; col < 4; ++col) + for (int k = 0; k < 4; ++k) + out.m[row][col] += a.m[row][k] * b.m[k][col]; + return out; + } + + D3DMATRIX TransposeMatrix(const D3DMATRIX& m) + { + D3DMATRIX out{}; + for (int row = 0; row < 4; ++row) + for (int col = 0; col < 4; ++col) + out.m[row][col] = m.m[col][row]; + return out; + } + + D3DMATRIX InverseRigid(const D3DMATRIX& m) + { + D3DMATRIX out = IdentityMatrix(); + out._11 = m._11; out._12 = m._21; out._13 = m._31; + out._21 = m._12; out._22 = m._22; out._23 = m._32; + out._31 = m._13; out._32 = m._23; out._33 = m._33; + out._41 = -(m._41 * out._11 + m._42 * out._21 + m._43 * out._31); + out._42 = -(m._41 * out._12 + m._42 * out._22 + m._43 * out._32); + out._43 = -(m._41 * out._13 + m._42 * out._23 + m._43 * out._33); + return out; + } + + bool MatrixFinite(const D3DMATRIX& matrix) + { + for (int row = 0; row < 4; ++row) + for (int col = 0; col < 4; ++col) + if (!std::isfinite(matrix.m[row][col])) + return false; + return true; + } + + D3DMATRIX ProjectionFromFov(const D3DMATRIX& base, const SharedFov& fov) + { + const float tanLeft = std::tan(fov.angleLeft), tanRight = std::tan(fov.angleRight); + const float tanUp = std::tan(fov.angleUp), tanDown = std::tan(fov.angleDown); + const float width = tanRight - tanLeft, height = tanUp - tanDown; + D3DMATRIX out{}; + out._11 = 2.0f / width; out._22 = 2.0f / height; + out._31 = (tanRight + tanLeft) / width; out._32 = (tanUp + tanDown) / height; + out._33 = base._33; out._34 = base._34; out._43 = base._43; out._44 = base._44; + return out; + } + + bool MatrixNear(const float* candidate, const D3DMATRIX& matrix, bool transposed, float epsilon) + { + for (int row = 0; row < 4; ++row) + { + for (int col = 0; col < 4; ++col) + { + const float a = candidate[row * 4 + col]; + const float b = transposed ? matrix.m[col][row] : matrix.m[row][col]; + if (!std::isfinite(a) || !std::isfinite(b) || std::fabs(a - b) > epsilon) + return false; + } + } + return true; + } + + bool IsReadableRange(const void* address, std::size_t size) + { + if (!address || size == 0) + return false; + + const auto begin = reinterpret_cast(address); + if (begin + size < begin) + return false; + + MEMORY_BASIC_INFORMATION info{}; + if (VirtualQuery(address, &info, sizeof(info)) != sizeof(info)) + return false; + if (info.State != MEM_COMMIT || (info.Protect & PAGE_GUARD) || + (info.Protect & PAGE_NOACCESS)) + return false; + + const auto regionBegin = reinterpret_cast(info.BaseAddress); + const auto regionEnd = regionBegin + info.RegionSize; + return begin >= regionBegin && begin + size <= regionEnd; + } + + bool IsWritableRange(void* address, std::size_t size) + { + if (!address || size == 0) return false; + MEMORY_BASIC_INFORMATION info{}; + if (VirtualQuery(address, &info, sizeof(info)) != sizeof(info) || info.State != MEM_COMMIT || + (info.Protect & PAGE_GUARD) || (info.Protect & PAGE_NOACCESS)) return false; + const DWORD protect = info.Protect & 0xFFu; + const bool writable = protect == PAGE_READWRITE || protect == PAGE_WRITECOPY || + protect == PAGE_EXECUTE_READWRITE || protect == PAGE_EXECUTE_WRITECOPY; + if (!writable) return false; + const auto begin = reinterpret_cast(address); + const auto regionBegin = reinterpret_cast(info.BaseAddress); + const auto regionEnd = regionBegin + info.RegionSize; + return begin >= regionBegin && begin + size >= begin && begin + size <= regionEnd; + } + + bool ImageContainsRange(std::uintptr_t rva, std::size_t size) + { + const auto base = reinterpret_cast(Module::ExeHandle); + if (!base || !IsReadableRange(reinterpret_cast(base), sizeof(IMAGE_DOS_HEADER))) + return false; + + const auto* dos = reinterpret_cast(base); + if (dos->e_magic != IMAGE_DOS_SIGNATURE || dos->e_lfanew <= 0 || dos->e_lfanew > 0x100000) + return false; + + const auto ntAddress = base + static_cast(dos->e_lfanew); + if (!IsReadableRange(reinterpret_cast(ntAddress), sizeof(IMAGE_NT_HEADERS))) + return false; + const auto* nt = reinterpret_cast(ntAddress); + if (nt->Signature != IMAGE_NT_SIGNATURE) + return false; + + const std::size_t imageSize = nt->OptionalHeader.SizeOfImage; + return rva <= imageSize && size <= imageSize - rva; + } + + bool ValidateRendererGlobals() + { + if (RendererGlobalsChecked) + return RendererGlobalsValid; + RendererGlobalsChecked = true; + + if (!ImageContainsRange(OutRunViewRva, sizeof(D3DMATRIX)) || + !ImageContainsRange(OutRunProjectionRva, sizeof(D3DMATRIX)) || + !ImageContainsRange(OutRunWorldViewRva, sizeof(D3DMATRIX))) + return false; + + RendererView = Module::exe_ptr(OutRunViewRva); + RendererProjection = Module::exe_ptr(OutRunProjectionRva); + RendererWorldView = Module::exe_ptr(OutRunWorldViewRva); + if (!IsReadableRange(RendererView, sizeof(D3DMATRIX)) || + !IsReadableRange(RendererProjection, sizeof(D3DMATRIX)) || + !IsReadableRange(RendererWorldView, sizeof(D3DMATRIX))) + { + RendererView = nullptr; + RendererProjection = nullptr; + RendererWorldView = nullptr; + return false; + } + + RendererGlobalsValid = true; + return true; + } + + bool ReadRendererMatrices(D3DMATRIX& view, D3DMATRIX& projection, D3DMATRIX& worldView) + { + if (!ValidateRendererGlobals()) + return false; + std::memcpy(&view, RendererView, sizeof(view)); + if (CullingProjectionOverridden) projection = CullingProjectionSaved; + else std::memcpy(&projection, RendererProjection, sizeof(projection)); + std::memcpy(&worldView, RendererWorldView, sizeof(worldView)); + return MatrixFinite(view) && MatrixFinite(projection) && MatrixFinite(worldView); + } + + bool SharedHeaderValid() + { + return SharedState && SharedState->magic == SharedMagic && + SharedState->protocolVersion == SharedProtocolVersion && + SharedState->structSize == sizeof(SharedPoseState); + } + + bool EnsureSharedState() + { + if (SharedState) + { + if (!SharedHeaderValid()) + return false; + if (QpcFrequency.QuadPart <= 0) + QueryPerformanceFrequency(&QpcFrequency); + InterlockedExchange(reinterpret_cast(&SharedState->clientPid), + static_cast(GetCurrentProcessId())); + return true; + } + + SharedMapping = CreateFileMappingW(INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE, + 0, static_cast(sizeof(SharedPoseState)), SharedMemoryName); + if (!SharedMapping) + return false; + const bool mappingAlreadyExisted = GetLastError() == ERROR_ALREADY_EXISTS; + + SharedState = static_cast(MapViewOfFile( + SharedMapping, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(SharedPoseState))); + if (!SharedState) + { + CloseHandle(SharedMapping); + SharedMapping = nullptr; + return false; + } + + // Creator-only initialization. magic is published last, so an attaching + // process never treats a partially initialized header as ready. + if (!mappingAlreadyExisted) + { + std::memset(SharedState, 0, sizeof(SharedPoseState)); + SharedState->protocolVersion = SharedProtocolVersion; + SharedState->structSize = sizeof(SharedPoseState); + MemoryBarrier(); + SharedState->magic = SharedMagic; + } + else if (!SharedHeaderValid()) + { + // The host may still be publishing a newly-created mapping. Never + // memset somebody else's mapping; simply retry on a later frame. + return false; + } + + QueryPerformanceFrequency(&QpcFrequency); + InterlockedExchange(reinterpret_cast(&SharedState->clientPid), + static_cast(GetCurrentProcessId())); + spdlog::info("VR renderer: shared pose bridge ready (protocol {}, client pid={})", + SharedProtocolVersion, GetCurrentProcessId()); + return true; + } + + ClientPresentationMode CurrentPresentationMode() + { + // R45: presentation ownership follows the actual OutRun state instead + // of Game::is_in_game() plus a 1.5 s sticky hold. The broad helper + // includes selector/menu-adjacent 3D scenes on this executable, which + // caused the vehicle-select preview car to receive gameplay stereo. + // Keep only states that are genuinely rendered on the race camera in + // stereo. Result/continue/try-again/ranking/selector states are mono + // theater UI and must switch immediately, with no stale stereo hold. + if (!Game::current_mode) + return PresentationUnknown; + + const GameState state = + static_cast(*Game::current_mode); + switch (state) + { + case STATE_START: + case STATE_WARP: + case STATE_RESTART: + case STATE_GAME: + case STATE_GIVEUP: + case STATE_SMPAUSEMENU: + case STATE_GOAL: + case STATE_TIMEUP: + case STATE_LINK_TIMEUP: + return PresentationGameplay; + default: + return PresentationTheater; + } + } + + void PublishClientTelemetry(std::uint32_t flags, float relativeAngleDeg) + { + if (!EnsureSharedState()) + return; + + std::uint32_t angleBits = 0; + static_assert(sizeof(angleBits) == sizeof(relativeAngleDeg)); + std::memcpy(&angleBits, &relativeAngleDeg, sizeof(angleBits)); + const std::uint32_t presentation = static_cast(CurrentPresentationMode()); + const std::uint32_t gameState = Game::current_mode + ? static_cast(*Game::current_mode) : 0xFFFFFFFFu; + + InterlockedExchange(reinterpret_cast(&SharedState->clientPid), + static_cast(GetCurrentProcessId())); + InterlockedIncrement(reinterpret_cast(&SharedState->reserved[ClientHeartbeatIndex])); + InterlockedExchange(reinterpret_cast(&SharedState->reserved[ClientFlagsIndex]), + static_cast(flags)); + InterlockedExchange(reinterpret_cast(&SharedState->reserved[ClientLastAngleBitsIndex]), + static_cast(angleBits)); + InterlockedExchange(reinterpret_cast(&SharedState->reserved[ClientPresentationModeIndex]), + static_cast(presentation)); + InterlockedExchange(reinterpret_cast(&SharedState->reserved[ClientGameStateIndex]), + static_cast(gameState)); + } + + bool ReadHostPose(PoseSample& pose) + { + OutRunVR::IpcV3::HostPoseSnapshot v3{}; + if (V3PoseSource.Read(v3)) + { + const std::uint32_t legacySequence = static_cast(v3.poseId & 0xFFFFFFFFu); + if (legacySequence != 0) + { + pose = {}; + pose.sequence = legacySequence; + pose.hostPid = v3.hostPid; + pose.referenceSpaceGeneration = v3.referenceSpaceGeneration; + pose.orientation = { + v3.headOrientation[0], v3.headOrientation[1], + v3.headOrientation[2], v3.headOrientation[3] + }; + pose.position = { + v3.headPositionMeters[0], v3.headPositionMeters[1], v3.headPositionMeters[2] + }; + pose.positionValid = v3.positionValid; + pose.stereoValid = v3.stereoValid; + if (pose.stereoValid) + { + for (int eye = 0; eye < 2; ++eye) + { + pose.eyeFov[eye] = { + v3.eyes[eye].fov.angleLeft, + v3.eyes[eye].fov.angleRight, + v3.eyes[eye].fov.angleUp, + v3.eyes[eye].fov.angleDown + }; + for (int axis = 0; axis < 3; ++axis) + pose.eyeOffset[eye][axis] = v3.eyes[eye].positionMeters[axis]; + pose.eyeOrientation[eye] = { + v3.eyes[eye].orientation[0], v3.eyes[eye].orientation[1], + v3.eyes[eye].orientation[2], v3.eyes[eye].orientation[3] + }; + } + } + LastPoseSourceV3 = true; + ++V3PoseReads; + if (!FirstV3PoseLogged) + { + FirstV3PoseLogged = true; + spdlog::info("VR renderer: protocol v3 pose is PRIMARY; legacy v2 remains automatic fallback"); + } + return true; + } + } + + LastPoseSourceV3 = false; + ++V2PoseFallbacks; + if (!FirstV2FallbackLogged && V3PoseReads != 0) + { + FirstV2FallbackLogged = true; + spdlog::warn("VR renderer: protocol v3 pose unavailable/stale; falling back to legacy v2 pose without dropping the frame"); + } + + if (!EnsureSharedState()) + return false; + + SharedPoseState snapshot{}; + bool stable = false; + for (int attempt = 0; attempt < 4; ++attempt) + { + const std::uint32_t seqBefore = SharedState->sequence; + if (seqBefore & 1u) + continue; + MemoryBarrier(); + std::memcpy(&snapshot, SharedState, sizeof(snapshot)); + MemoryBarrier(); + const std::uint32_t seqAfter = SharedState->sequence; + if (seqBefore == seqAfter && !(seqAfter & 1u)) + { + stable = true; + pose.sequence = seqAfter; + break; + } + } + + if (!stable || snapshot.magic != SharedMagic || + snapshot.protocolVersion != SharedProtocolVersion || + snapshot.structSize != sizeof(SharedPoseState)) + return false; + if ((snapshot.flags & HostAlive) == 0 || + (snapshot.flags & OrientationValid) == 0 || + (snapshot.flags & SessionVisible) == 0 || + (snapshot.flags & HostShouldRender) == 0 || snapshot.hostPid == 0) + return false; + + LARGE_INTEGER now{}; + QueryPerformanceCounter(&now); + if (QpcFrequency.QuadPart <= 0 || snapshot.sampleQpc <= 0) + return false; + const LONGLONG ageTicks = now.QuadPart - snapshot.sampleQpc; + const LONGLONG maxAgeTicks = (QpcFrequency.QuadPart * HostPoseStaleMs) / 1000; + if (ageTicks < 0 || ageTicks > maxAgeTicks) + return false; + + const Quat rawOrientation{ + snapshot.orientation[0], snapshot.orientation[1], + snapshot.orientation[2], snapshot.orientation[3] + }; + if (!QuaternionIsSane(rawOrientation)) + return false; + + pose.orientation = Normalize(rawOrientation); + pose.position = { snapshot.position[0], snapshot.position[1], snapshot.position[2] }; + pose.positionValid = (snapshot.flags & PositionValid) != 0 && VectorIsFinite(pose.position); + pose.hostPid = snapshot.hostPid; + pose.referenceSpaceGeneration = snapshot.reserved[HostReferenceSpaceGenerationIndex]; + + pose.stereoValid = (snapshot.flags & StereoViewsValid) != 0 && + (snapshot.flags & StereoEyeOrientationValid) != 0 && + FovValid(snapshot.eyeFov[0]) && FovValid(snapshot.eyeFov[1]); + if (pose.stereoValid) + { + pose.eyeFov[0] = snapshot.eyeFov[0]; + pose.eyeFov[1] = snapshot.eyeFov[1]; + Quat eyeOrientation[2]{}; + if (!DecodePackedEyeOrientations(snapshot, eyeOrientation)) + pose.stereoValid = false; + const std::uint32_t indexes[2][3] = { + { HostEyeOffsetLeftXIndex, HostEyeOffsetLeftYIndex, HostEyeOffsetLeftZIndex }, + { HostEyeOffsetRightXIndex, HostEyeOffsetRightYIndex, HostEyeOffsetRightZIndex } + }; + for (int eye = 0; eye < 2 && pose.stereoValid; ++eye) + { + for (int axis = 0; axis < 3; ++axis) + { + const float value = FloatFromBits(snapshot.reserved[indexes[eye][axis]]); + if (!std::isfinite(value) || std::fabs(value) > 0.25f) + { + pose.stereoValid = false; + break; + } + pose.eyeOffset[eye][axis] = value; + } + pose.eyeOrientation[eye] = eyeOrientation[eye]; + } + } + return true; + } + + bool RendererRecenterActionDown() + { + if (Settings::UseNewInput) + return InputManager_ModActionHeld(ModAction::VRRecenter); + return (GetAsyncKeyState(VK_F12) & 0x8000) != 0; + } + + bool RendererRecenterPressed() + { + const bool isDown = RendererRecenterActionDown(); + const bool pressed = isDown && !RecenterWasDown; + RecenterWasDown = isDown; + return pressed; + } + + bool GameRendererIsActive() + { + return CurrentPresentationMode() == PresentationGameplay; + } + + + bool CadenceHostHeaderValid(const OutRunVR::CadenceV1::HostState& state) noexcept + { + return state.magic == OutRunVR::CadenceV1::HostMagic && + state.version == OutRunVR::CadenceV1::ProtocolVersion && + state.structSize == sizeof(OutRunVR::CadenceV1::HostState); + } + + bool EnsureCadenceChannel() noexcept + { + if (!CadenceHostState) + { + CadenceHostMapping = OpenFileMappingW( + FILE_MAP_READ, FALSE, OutRunVR::CadenceV1::HostStateName); + if (!CadenceHostMapping) + return false; + CadenceHostState = + static_cast( + MapViewOfFile(CadenceHostMapping, FILE_MAP_READ, 0, 0, + sizeof(OutRunVR::CadenceV1::HostState))); + if (!CadenceHostState) + { + CloseHandle(CadenceHostMapping); + CadenceHostMapping = nullptr; + return false; + } + } + + if (!CadenceClientState) + { + CadenceClientMapping = CreateFileMappingW( + INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE, 0, + static_cast(sizeof(OutRunVR::CadenceV1::ClientState)), + OutRunVR::CadenceV1::ClientStateName); + if (!CadenceClientMapping) + return false; + CadenceClientState = + static_cast( + MapViewOfFile(CadenceClientMapping, FILE_MAP_ALL_ACCESS, 0, 0, + sizeof(OutRunVR::CadenceV1::ClientState))); + if (!CadenceClientState) + { + CloseHandle(CadenceClientMapping); + CadenceClientMapping = nullptr; + return false; + } + std::memset(CadenceClientState, 0, sizeof(*CadenceClientState)); + CadenceClientState->version = OutRunVR::CadenceV1::ProtocolVersion; + CadenceClientState->structSize = sizeof(*CadenceClientState); + CadenceClientState->clientPid = GetCurrentProcessId(); + MemoryBarrier(); + CadenceClientState->magic = OutRunVR::CadenceV1::ClientMagic; + } + + if (!CadenceRequestEvent) + CadenceRequestEvent = CreateEventW( + nullptr, FALSE, FALSE, OutRunVR::CadenceV1::RequestEventName); + if (!CadencePresentedEvent) + CadencePresentedEvent = CreateEventW( + nullptr, FALSE, FALSE, OutRunVR::CadenceV1::PresentedEventName); + + return CadenceHostState && CadenceClientState && + CadenceRequestEvent && CadencePresentedEvent; + } + + bool ReadCadenceHost(OutRunVR::CadenceV1::HostState& out) noexcept + { + if (!EnsureCadenceChannel()) + return false; + for (int attempt = 0; attempt < 4; ++attempt) + { + const std::uint32_t before = CadenceHostState->sequence; + if (before & 1u) + continue; + MemoryBarrier(); + std::memcpy(&out, CadenceHostState, sizeof(out)); + MemoryBarrier(); + const std::uint32_t after = CadenceHostState->sequence; + if (before == after && !(after & 1u) && + CadenceHostHeaderValid(out)) + return true; + } + return false; + } + + void PublishCadenceClient(std::uint32_t flags) noexcept + { + if (!CadenceClientState) + return; + LONG seq = InterlockedIncrement( + reinterpret_cast(&CadenceClientState->sequence)); + if ((seq & 1) == 0) + InterlockedIncrement( + reinterpret_cast(&CadenceClientState->sequence)); + MemoryBarrier(); + CadenceClientState->magic = OutRunVR::CadenceV1::ClientMagic; + CadenceClientState->version = OutRunVR::CadenceV1::ProtocolVersion; + CadenceClientState->structSize = sizeof(*CadenceClientState); + CadenceClientState->clientPid = GetCurrentProcessId(); + CadenceClientState->flags = flags; + CadenceClientState->acceptedRequestId = CadenceAcceptedRequestId; + CadenceClientState->presentedRequestId = CadencePresentedRequestId; + CadenceClientState->timeoutCount = CadenceTimeoutCount; + CadenceClientState->lastWaitUs = CadenceLastWaitUs; + CadenceClientState->acceptedQpc = CadenceAcceptedQpc; + CadenceClientState->presentedQpc = CadencePresentedQpc; + MemoryBarrier(); + seq = InterlockedIncrement( + reinterpret_cast(&CadenceClientState->sequence)); + if (seq & 1) + InterlockedIncrement( + reinterpret_cast(&CadenceClientState->sequence)); + } + + void AcceptCadenceRequest(std::uint32_t requestId, + std::uint32_t waitUs) noexcept + { + LARGE_INTEGER now{}; + QueryPerformanceCounter(&now); + ActiveCadenceRequestId.store(requestId, std::memory_order_release); + CadenceAcceptedRequestId = requestId; + CadenceAcceptedQpc = now.QuadPart; + CadenceLastWaitUs = waitUs; + PublishCadenceClient(OutRunVR::CadenceV1::ClientEnabled); + if (!FirstCadenceAcceptedLogged) + { + FirstCadenceAcceptedLogged = true; + if (Settings::VRFrameCadenceMode >= 2) + spdlog::info( + "VR R35 CADENCE: SerializedProbe active; request={} bounded game/host synchronization enabled", + requestId); + else + spdlog::info( + "VR R40 CADENCE: runtime-gated PhaseLock active; request={} game Present waits for the next xrWaitFrame token while the host never waits for game Present", + requestId); + } + } + + void MarkCadencePresented() noexcept + { + if (Settings::VRFrameCadenceMode <= 0 || !EnsureCadenceChannel()) + return; + const std::uint32_t active = + ActiveCadenceRequestId.load(std::memory_order_acquire); + if (!active || active == CadencePresentedRequestId) + return; + LARGE_INTEGER now{}; + QueryPerformanceCounter(&now); + CadencePresentedRequestId = active; + CadencePresentedQpc = now.QuadPart; + PublishCadenceClient(OutRunVR::CadenceV1::ClientEnabled); + SetEvent(CadencePresentedEvent); + } + + void WaitForNextCadenceRequest() noexcept + { + if (Settings::VRFrameCadenceMode <= 0 || !GameRendererIsActive()) + { + CadencePacingActive.store(false, std::memory_order_release); + return; + } + + OutRunVR::CadenceV1::HostState host{}; + if (!ReadCadenceHost(host)) + { + CadencePacingActive.store(false, std::memory_order_release); + return; + } + const std::uint32_t required = + OutRunVR::CadenceV1::HostEnabled | + OutRunVR::CadenceV1::HostRunning; + if ((host.flags & required) != required || !host.hostPid) + { + CadencePacingActive.store(false, std::memory_order_release); + return; + } + + HANDLE hostProcess = OpenProcess(SYNCHRONIZE, FALSE, host.hostPid); + const bool hostAlive = hostProcess && + WaitForSingleObject(hostProcess, 0) == WAIT_TIMEOUT; + if (hostProcess) CloseHandle(hostProcess); + + LARGE_INTEGER cadenceNow{}, cadenceFreq{}; + QueryPerformanceCounter(&cadenceNow); + QueryPerformanceFrequency(&cadenceFreq); + const double requestAgeMs = + host.requestQpc > 0 && cadenceFreq.QuadPart > 0 && + cadenceNow.QuadPart >= host.requestQpc + ? static_cast(cadenceNow.QuadPart - host.requestQpc) * + 1000.0 / static_cast(cadenceFreq.QuadPart) + : 0.0; + const double staleLimitMs = std::max( + 100.0, static_cast(std::clamp( + Settings::VRFrameCadenceTimeoutMs.get(), + 5.0f, 100.0f)) * 4.0); + if (!hostAlive || + (host.requestQpc > 0 && requestAgeMs > staleLimitMs)) + { + CadencePacingActive.store(false, std::memory_order_release); + ActiveCadenceRequestId.store(0, std::memory_order_release); + return; + } + CadencePacingActive.store(true, std::memory_order_release); + + const std::uint32_t current = + ActiveCadenceRequestId.load(std::memory_order_acquire); + if (CadenceTimedOutRequestId != 0 && + host.requestId == CadenceTimedOutRequestId) + { + CadencePacingActive.store(false, std::memory_order_release); + return; + } + LARGE_INTEGER start{}, end{}, frequency{}; + QueryPerformanceCounter(&start); + QueryPerformanceFrequency(&frequency); + + if (host.requestId && host.requestId != current) + { + CadenceTimedOutRequestId = 0; + AcceptCadenceRequest(host.requestId, 0); + return; + } + + // R40: PhaseLock is host-clock pacing, not merely telemetry. + // The game Present thread waits for the NEXT request token, which + // is published immediately after the host's xrBeginFrame. Crucially + // mode 1 never makes the host wait for game Present, so there is no + // circular wait: xrWaitFrame -> publish token -> game renders once. + // This prevents the old mode-1 failure where the Tweaks limiter was + // disabled while the game still ran uncapped at 100-126 FPS against + // a 90 Hz runtime. SerializedProbe (mode 2) keeps the same game-side + // gate and additionally enables its bounded host-side diagnostic ACK. + PublishCadenceClient( + OutRunVR::CadenceV1::ClientEnabled | + OutRunVR::CadenceV1::ClientWaiting); + const double runtimePeriodMs = + host.predictedDisplayPeriod > 0 + ? static_cast(host.predictedDisplayPeriod) / 1000000.0 + : 0.0; + const double configuredTimeoutMs = + static_cast(std::clamp( + Settings::VRFrameCadenceTimeoutMs.get(), + 5.0f, 100.0f)); + // Mode 1 must survive a temporarily late compositor long enough to + // remove GPU pressure and recover. Four runtime periods covers the + // observed ~39 ms VDXR interval at 90 Hz while still failing open. + const double waitBudgetMs = + Settings::VRFrameCadenceMode < 2 + ? std::max(configuredTimeoutMs, + runtimePeriodMs > 0.0 ? runtimePeriodMs * 4.0 + : configuredTimeoutMs) + : (runtimePeriodMs > 0.0 + ? runtimePeriodMs * 1.25 + : configuredTimeoutMs); + const DWORD timeoutMs = static_cast(std::clamp( + waitBudgetMs, 5.0, 100.0)); + const DWORD wait = WaitForSingleObject(CadenceRequestEvent, timeoutMs); + QueryPerformanceCounter(&end); + const std::uint32_t waitUs = + frequency.QuadPart > 0 && end.QuadPart >= start.QuadPart + ? static_cast(std::min( + 0xFFFFFFFFll, + ((end.QuadPart - start.QuadPart) * 1000000ll) / + frequency.QuadPart)) + : 0u; + + OutRunVR::CadenceV1::HostState after{}; + if (ReadCadenceHost(after) && after.requestId && + after.requestId != current && + (after.flags & required) == required) + { + CadenceTimedOutRequestId = 0; + AcceptCadenceRequest(after.requestId, waitUs); + return; + } + + CadenceLastWaitUs = waitUs; + if (wait == WAIT_TIMEOUT) + { + ++CadenceTimeoutCount; + CadenceTimedOutRequestId = + current ? current : host.requestId; + CadencePacingActive.store(false, std::memory_order_release); + PublishCadenceClient( + OutRunVR::CadenceV1::ClientEnabled | + OutRunVR::CadenceV1::ClientLastWaitTimedOut); + if (!FirstCadenceTimeoutLogged) + { + FirstCadenceTimeoutLogged = true; + spdlog::warn( + "VR R35 CADENCE: host request timeout after {} ms; failing open so OutRun cannot hang", + timeoutMs); + } + } + else + { + PublishCadenceClient(OutRunVR::CadenceV1::ClientEnabled); + } + } + + void ResetFrameState() + { + LatchedHeadInverseValid = false; + LatchedStereo = {}; + LastVerifiedWvpValid = false; + LastVerifiedWvpPoseSequence = 0; + LastVerifiedShaderIdentity = 0; + LastVerifiedShaderSerial = 0; + FrameTelemetryFlags = ClientHookAlive; + LatchedRelativeAngleDeg = 0.0f; + LatchedPoseSequence = 0; + } + + void ResetCenter() + { + CenterValid = false; + CenterPositionValid = false; + CenterHostPid = 0; + CenterReferenceSpaceGeneration = 0; + } + + void RestoreCullingCamera() + { + if (CullingCameraOverridden && CullingCameraObject) + { + CullingCameraObject->cam_pos_F8 = CullingCameraSavedPos; + CullingCameraObject->look_pos_104 = CullingCameraSavedLook; + } + CullingCameraObject = nullptr; + CullingCameraOverridden = false; + if (CullingProjectionOverridden && RendererProjection) + { + auto* projection = const_cast(RendererProjection); + if (IsWritableRange(projection, sizeof(D3DMATRIX))) std::memcpy(projection, &CullingProjectionSaved, sizeof(D3DMATRIX)); + } + CullingProjectionOverridden = false; + } + + void ApplyCullingCameraSync() + { + if (!Settings::VRCullingCameraSync || !LatchedHeadInverseValid || + Settings::VRMatrixOrder != 0 || !ValidateRendererGlobals()) + return; + + EvWorkCamera* cam = Game::camera(); + if (!cam || !RendererView) + return; + + D3DMATRIX baseView{}; + std::memcpy(&baseView, RendererView, sizeof(baseView)); + if (!MatrixFinite(baseView)) + return; + + const D3DMATRIX correctedView = MultiplyMatrix(baseView, LatchedHeadInverse); + if (!MatrixFinite(correctedView)) + return; + const D3DMATRIX cameraWorld = InverseRigid(correctedView); + + const float dx = cam->look_pos_104.x - cam->cam_pos_F8.x; + const float dy = cam->look_pos_104.y - cam->cam_pos_F8.y; + const float dz = cam->look_pos_104.z - cam->cam_pos_F8.z; + float lookDistance = std::sqrt(dx * dx + dy * dy + dz * dz); + if (!std::isfinite(lookDistance) || lookDistance < 0.01f) + lookDistance = 1.0f; + + Vec3 forward{ -cameraWorld._31, -cameraWorld._32, -cameraWorld._33 }; + const float forwardLength = std::sqrt( + forward.x * forward.x + forward.y * forward.y + forward.z * forward.z); + if (!std::isfinite(forwardLength) || forwardLength < 1.0e-5f) + return; + forward.x /= forwardLength; + forward.y /= forwardLength; + forward.z /= forwardLength; + + RestoreCullingCamera(); + CullingCameraObject = cam; + CullingCameraSavedPos = cam->cam_pos_F8; + CullingCameraSavedLook = cam->look_pos_104; + cam->cam_pos_F8 = { cameraWorld._41, cameraWorld._42, cameraWorld._43 }; + cam->look_pos_104 = { + cameraWorld._41 + forward.x * lookDistance, + cameraWorld._42 + forward.y * lookDistance, + cameraWorld._43 + forward.z * lookDistance + }; + CullingCameraOverridden = true; + if (Settings::VRCullingUnionFov && !CullingUnionFovDeferredLogged) + { + CullingUnionFovDeferredLogged = true; + spdlog::warn("VR renderer: CullingUnionFov is deferred until a culling-only frustum boundary is verified; live projection remains untouched"); + } + FrameTelemetryFlags |= ClientCullingCameraSynced; + } + + void LatchFramePose() + { + ResetFrameState(); + RestoreCullingCamera(); + + if (!GameRendererIsActive()) + return; + + PoseSample sample{}; + if (!ReadHostPose(sample)) + { + // A torn seqlock read or one stale sample must not redefine forward. + // Keep the existing center and simply render this scene without a VR + // transform. Centers change only on F10, host replacement, or an + // OpenXR reference-space generation change. + return; + } + + FrameTelemetryFlags |= ClientHostPoseValid; + const bool autoEnabled = Settings::VRAutoEnableWhenHostPresent; + const bool trackingEnabled = Settings::VRHeadTracking || autoEnabled; + const bool enabled = (Settings::VREnabled || autoEnabled) && trackingEnabled; + if (autoEnabled && (!Settings::VREnabled || !Settings::VRHeadTracking)) + { + FrameTelemetryFlags |= ClientAutoEnabled; + if (!AutoEnableLogged) + { + spdlog::info("VR renderer: live host pose auto-enabled renderer-side head tracking"); + AutoEnableLogged = true; + } + } + + if (!enabled) + return; + + const bool recenter = RendererRecenterPressed(); + const bool hostChanged = !CenterValid || CenterHostPid != sample.hostPid; + const bool referenceSpaceChanged = CenterValid && + CenterReferenceSpaceGeneration != sample.referenceSpaceGeneration; + if (hostChanged || referenceSpaceChanged || recenter) + { + // Recenter yaw only. Pitch and roll continue to describe the actual + // headset attitude, avoiding a tilted artificial horizon after F10. + CenterOrientation = YawOnly(sample.orientation); + CenterPosition = sample.positionValid ? sample.position : Vec3{ 0.0f, 0.0f, 0.0f }; + CenterPositionValid = sample.positionValid; + CenterHostPid = sample.hostPid; + CenterReferenceSpaceGeneration = sample.referenceSpaceGeneration; + CenterValid = true; + if (recenter) + spdlog::info("VR renderer: yaw recentered HMD pose (configured VR Recenter action); pitch/roll preserved"); + else if (referenceSpaceChanged) + spdlog::info("VR renderer: OpenXR reference space changed; tracking origin refreshed"); + } + + const Quat invCenter = Conjugate(Normalize(CenterOrientation)); + Quat relativeOrientation = Multiply(invCenter, sample.orientation); + Vec3 relativePosition{ 0.0f, 0.0f, 0.0f }; + if (Settings::VRPositionalTracking && sample.positionValid) + { + if (!CenterPositionValid) + { + CenterPosition = sample.position; + CenterPositionValid = true; + } + else + { + const Vec3 delta{ + sample.position.x - CenterPosition.x, + sample.position.y - CenterPosition.y, + sample.position.z - CenterPosition.z + }; + relativePosition = RotateVector(invCenter, delta); + relativePosition.x *= Settings::VRWorldScale; + relativePosition.y *= Settings::VRWorldScale; + relativePosition.z *= Settings::VRWorldScale; + } + } + + if (!CenterPositionValid && sample.positionValid) + { + CenterPosition = sample.position; + CenterPositionValid = true; + } + + relativeOrientation = ScaleRotation(relativeOrientation, Settings::VRRotationScale); + LatchedHeadInverse = InverseRigid(MatrixFromPose(relativeOrientation, relativePosition)); + LatchedHeadInverseValid = MatrixFinite(LatchedHeadInverse); + LatchedPoseSequence = sample.sequence; + + if (LatchedHeadInverseValid && sample.stereoValid) + { + LatchedStereo.valid = true; + LatchedStereo.poseSequence = sample.sequence; + LatchedStereo.eyeFov[0] = sample.eyeFov[0]; LatchedStereo.eyeFov[1] = sample.eyeFov[1]; + std::memcpy(LatchedStereo.eyeOffset, sample.eyeOffset, sizeof(LatchedStereo.eyeOffset)); + const Quat effectiveHeadOrientation = Normalize( + Multiply(Normalize(CenterOrientation), relativeOrientation)); + // Frame.v2 positions are OpenXR LOCAL-space metres. WorldScale is + // only the metres->game-units conversion used by LatchedHeadInverse. + Vec3 effectiveHeadPosition = CenterPositionValid ? CenterPosition : sample.position; + if (Settings::VRPositionalTracking && sample.positionValid) + effectiveHeadPosition = sample.position; + for (int eye = 0; eye < 2; ++eye) + { + const Quat eyeOrientation = sample.eyeOrientation[eye]; + LatchedStereo.eyeOrientation[eye][0] = eyeOrientation.x; + LatchedStereo.eyeOrientation[eye][1] = eyeOrientation.y; + LatchedStereo.eyeOrientation[eye][2] = eyeOrientation.z; + LatchedStereo.eyeOrientation[eye][3] = eyeOrientation.w; + + const Quat effectiveEyeOrientation = Normalize( + Multiply(effectiveHeadOrientation, eyeOrientation)); + LatchedStereo.effectiveEyeOrientation[eye][0] = effectiveEyeOrientation.x; + LatchedStereo.effectiveEyeOrientation[eye][1] = effectiveEyeOrientation.y; + LatchedStereo.effectiveEyeOrientation[eye][2] = effectiveEyeOrientation.z; + LatchedStereo.effectiveEyeOrientation[eye][3] = effectiveEyeOrientation.w; + + // eyeOffset is already head-local metres; keep the projection-layer + // pose in OpenXR units even though D3D9 multiplies IPD by WorldScale. + const Vec3 localEye{ + sample.eyeOffset[eye][0], + sample.eyeOffset[eye][1], + sample.eyeOffset[eye][2] + }; + const Vec3 worldEye = RotateVector(effectiveHeadOrientation, localEye); + LatchedStereo.effectiveEyePosition[eye][0] = effectiveHeadPosition.x + worldEye.x; + LatchedStereo.effectiveEyePosition[eye][1] = effectiveHeadPosition.y + worldEye.y; + LatchedStereo.effectiveEyePosition[eye][2] = effectiveHeadPosition.z + worldEye.z; + } + } + + const float w = std::clamp(std::fabs(relativeOrientation.w), 0.0f, 1.0f); + LatchedRelativeAngleDeg = 2.0f * std::acos(w) * (180.0f / Pi); + + // R41: the live OutRun camera may only follow the HMD during real + // gameplay. Menu/car-select scenes do not receive stereo c64 injection; + // moving only cam_pos/look there corrupts the preview model transform. + if (LatchedHeadInverseValid && GameRendererIsActive()) + ApplyCullingCameraSync(); + else + RestoreCullingCamera(); + } + + void InvalidateVerifiedWvp(); + + void ReusePresentPoseForScene() + { + // Additional BeginScene calls before the same Present reuse the exact + // pose chosen by the first scene. Only scene-local WVP classification + // state is reset. This prevents mixed-pose geometry in one desktop frame. + ++ReusedPoseSceneCalls; + InvalidateVerifiedWvp(); + RestoreCullingCamera(); + FrameTelemetryFlags = ClientHookAlive; + if (LatchedPoseSequence != 0) FrameTelemetryFlags |= ClientHostPoseValid; + if (Settings::VRAutoEnableWhenHostPresent) FrameTelemetryFlags |= ClientAutoEnabled; + if (LatchedHeadInverseValid && GameRendererIsActive()) + ApplyCullingCameraSync(); + else + RestoreCullingCamera(); + } + + bool UploadContainsOutRunWvp(UINT startRegister, UINT vector4fCount) + { + if (vector4fCount == 0 || vector4fCount > 256 || startRegister > OutRunWvpRegister) + return false; + const UINT offset = OutRunWvpRegister - startRegister; + return vector4fCount >= offset + OutRunWvpRegisterCount; + } + + bool UploadTouchesOutRunWvp(UINT startRegister, UINT vector4fCount) + { + if (vector4fCount == 0 || vector4fCount > 256) return false; + const std::uint64_t first = startRegister; + const std::uint64_t lastExclusive = first + vector4fCount; + return first < OutRunWvpRegister + OutRunWvpRegisterCount && + lastExclusive > OutRunWvpRegister; + } + + bool TryPrepareOutRunWvp( + UINT startRegister, const float* constantData, UINT vector4fCount, + float* patchedData) + { + if (!RendererInjectionAllowed.load(std::memory_order_acquire) || + !LatchedHeadInverseValid || !GameRendererIsActive()) + return false; + if (!constantData || !patchedData || !UploadContainsOutRunWvp(startRegister, vector4fCount)) + return false; + + ++WvpCandidateCalls; + + D3DMATRIX view{}; + D3DMATRIX projection{}; + D3DMATRIX worldView{}; + if (!ReadRendererMatrices(view, projection, worldView)) + { + ++UnsafeAddressRejects; + if (!FirstUnsafeAddressLogged) + { + FirstUnsafeAddressLogged = true; + spdlog::warn("VR renderer inject: OutRun renderer globals are not safely readable; injection disabled for this draw"); + } + return false; + } + + const UINT wvpOffsetRegisters = OutRunWvpRegister - startRegister; + const float* uploadedWvp = constantData + wvpOffsetRegisters * 4; + const D3DMATRIX expectedWvp = MultiplyMatrix(worldView, projection); + if (!MatrixNear(uploadedWvp, expectedWvp, true, WvpVerifyAbsoluteEpsilon)) + { + ++WvpRejectedCalls; + if (!FirstRejectedLogged) + { + FirstRejectedLogged = true; + spdlog::info("VR renderer inject: first c64 upload did not match Transpose(WorldView*Proj); unmatched draws stay untouched"); + } + return false; + } + + ++WvpVerifiedCalls; + FrameTelemetryFlags |= ClientRendererWvpVerified; + if (!FirstVerifiedLogged) + { + FirstVerifiedLogged = true; + spdlog::info("VR renderer inject: verified OutRun c64 = Transpose(WorldView*Proj)"); + } + + D3DMATRIX correctedWvp{}; + if (Settings::VRMatrixOrder == 0) + { + correctedWvp = MultiplyMatrix(MultiplyMatrix(worldView, LatchedHeadInverse), projection); + } + else + { + // WorldView = World * View -> World = WorldView * inverse(View). + const D3DMATRIX world = MultiplyMatrix(worldView, InverseRigid(view)); + correctedWvp = MultiplyMatrix( + MultiplyMatrix(MultiplyMatrix(world, LatchedHeadInverse), view), projection); + } + if (!MatrixFinite(correctedWvp)) + return false; + + std::memcpy(patchedData, constantData, sizeof(float) * vector4fCount * 4); + const D3DMATRIX transposed = TransposeMatrix(correctedWvp); + std::memcpy(patchedData + wvpOffsetRegisters * 4, &transposed, sizeof(transposed)); + + ++WvpPreparedCalls; + FrameTelemetryFlags |= ClientRendererMatrixPrepared; + return true; + } + + void InvalidateVerifiedWvp() + { + LastVerifiedWvpValid = false; + LastVerifiedWvpPoseSequence = 0; + LastVerifiedShaderIdentity = 0; + LastVerifiedShaderSerial = 0; + } + + void InvalidateGameWvpWrite() noexcept + { + LastGameWvpWriteValid = false; + LastGameWvpTopLevelDrawSerial = 0; + LastGameWvpShaderIdentity = 0; + LastGameWvpShaderSerial = 0; + } + + void RecordGameWvpWrite(const float* constants, + const float* rawConstants) noexcept + { + if (!constants || !rawConstants) + { + InvalidateGameWvpWrite(); + return; + } + std::uintptr_t shaderIdentity = 0; + std::uint64_t shaderSerial = 0; + if (!OutRunVRStereo::GetCurrentShaderEpoch(shaderIdentity, shaderSerial) || + shaderIdentity == 0 || shaderSerial == 0) + { + InvalidateGameWvpWrite(); + return; + } + std::memcpy(LastGameWvpWrite, constants, sizeof(LastGameWvpWrite)); + std::memcpy(LastRawGameWvpWrite, rawConstants, + sizeof(LastRawGameWvpWrite)); + if (++LastGameWvpWriteSerial == 0) + ++LastGameWvpWriteSerial; + LastGameWvpTopLevelDrawSerial = OutRunVRStereo::GetTopLevelDrawSerial(); + LastGameWvpShaderIdentity = shaderIdentity; + LastGameWvpShaderSerial = shaderSerial; + LastGameWvpWriteValid = true; + } + + void RecordVerifiedWvp(const float* constants) + { + if (!constants || LatchedPoseSequence == 0) + return; + std::uintptr_t shaderIdentity = 0; + std::uint64_t shaderSerial = 0; + if (!OutRunVRStereo::GetCurrentShaderEpoch(shaderIdentity, shaderSerial)) + return; + std::memcpy(LastVerifiedWvp, constants, sizeof(LastVerifiedWvp)); + if (++LastVerifiedWvpGeneration == 0) + ++LastVerifiedWvpGeneration; + LastVerifiedWvpPoseSequence = LatchedPoseSequence; + LastVerifiedShaderIdentity = shaderIdentity; + LastVerifiedShaderSerial = shaderSerial; + LastVerifiedWvpValid = true; + } + + void MaybeLogSummary() + { + if (!Settings::VRTelemetry) + return; + const ULONGLONG now = GetTickCount64(); + if (now - LastSummaryMs < 5000) + return; + LastSummaryMs = now; + spdlog::info( + "VR renderer: beginScene={} poseReuse={} c64Candidate={} verified={} prepared={} uploadOk={} uploadFail={} rejected={} unsafe={} latchedSeq={} poseSource={} v3Reads={} v2Fallbacks={} wvpGen={} presentPoseLocked={}", + BeginSceneCalls, ReusedPoseSceneCalls, WvpCandidateCalls, WvpVerifiedCalls, WvpPreparedCalls, + WvpUploadSucceededCalls, WvpUploadFailedCalls, WvpRejectedCalls, + UnsafeAddressRejects, LatchedPoseSequence, LastPoseSourceV3 ? "v3" : "v2", + V3PoseReads, V2PoseFallbacks, LastVerifiedWvpGeneration, PresentPoseLocked ? 1 : 0); + } + + HRESULT __stdcall BeginSceneDest(IDirect3DDevice9* device) + { + const HRESULT result = BeginSceneHook.stdcall(device); + if (!IsGameDevice(device) || OutRunVRStereo::IsInternalStereoPassActive()) + return result; + if (SUCCEEDED(result)) + { + ++BeginSceneCalls; + if (!PresentPoseLocked) + { + LatchFramePose(); + PresentPoseLocked = GameRendererIsActive(); + } + else + ReusePresentPoseForScene(); + } + else + { + PresentPoseLocked = false; + ResetFrameState(); + } + return result; + } + + HRESULT __stdcall EndSceneDest(IDirect3DDevice9* device) + { + if (!IsGameDevice(device) || OutRunVRStereo::IsInternalStereoPassActive()) + return EndSceneHook.stdcall(device); + + // Camera live-state sync is only for render-time culling/effects. Restore + // it before returning control to post-scene game code. + RestoreCullingCamera(); + const HRESULT result = EndSceneHook.stdcall(device); + + std::uint32_t publishedFlags = FrameTelemetryFlags; + if (SUCCEEDED(result)) + { + publishedFlags |= OutRunVR::ClientFrameCompleted; + if (publishedFlags & ClientRendererPoseInjected) + publishedFlags |= ClientPoseApplied; + } + else + { + publishedFlags &= ~ClientPoseApplied; + } + + PublishClientTelemetry(publishedFlags, + (publishedFlags & ClientPoseApplied) ? LatchedRelativeAngleDeg : 0.0f); + MaybeLogSummary(); + return result; + } + + HRESULT __stdcall SetVertexShaderConstantFDest( + IDirect3DDevice9* device, UINT startRegister, const float* constantData, UINT vector4fCount) + { + if (!IsGameDevice(device) || !constantData || OutRunVRStereo::IsInternalStereoPassActive()) + { + return SetVertexShaderConstantFHook.stdcall( + device, startRegister, constantData, vector4fCount); + } + if (!UploadTouchesOutRunWvp(startRegister, vector4fCount)) + { + return SetVertexShaderConstantFHook.stdcall( + device, startRegister, constantData, vector4fCount); + } + + // Any GAME write touching c64..c67 supersedes the previous marker, even + // if it updates only one register. Stereo's own per-register writes are + // protected by InternalStereoPass above and must not disarm the marker. + InvalidateVerifiedWvp(); + InvalidateGameWvpWrite(); + if (!UploadContainsOutRunWvp(startRegister, vector4fCount)) + { + return SetVertexShaderConstantFHook.stdcall( + device, startRegister, constantData, vector4fCount); + } + + float patchedData[256 * 4]; + const bool prepared = TryPrepareOutRunWvp( + startRegister, constantData, vector4fCount, patchedData); + const float* uploadedData = prepared ? patchedData : constantData; + const HRESULT result = SetVertexShaderConstantFHook.stdcall( + device, startRegister, uploadedData, vector4fCount); + + if (SUCCEEDED(result)) + { + const UINT wvpOffsetRegisters = OutRunWvpRegister - startRegister; + RecordGameWvpWrite( + uploadedData + wvpOffsetRegisters * 4, + constantData + wvpOffsetRegisters * 4); + } + + if (prepared) + { + if (SUCCEEDED(result)) + { + ++WvpUploadSucceededCalls; + FrameTelemetryFlags |= ClientRendererPoseInjected; + const UINT wvpOffsetRegisters = OutRunWvpRegister - startRegister; + RecordVerifiedWvp(patchedData + wvpOffsetRegisters * 4); + if (!FirstInjectedLogged) + { + FirstInjectedLogged = true; + spdlog::info("VR renderer inject: HEAD TRACKING ACTIVE after successful VS c64 upload"); + } + } + else + { + ++WvpUploadFailedCalls; + FrameTelemetryFlags |= ClientRendererUploadFailed; + if (!FirstUploadFailedLogged) + { + FirstUploadFailedLogged = true; + spdlog::warn("VR renderer inject: patched c64 matrix prepared but D3D9 upload failed (HRESULT=0x{:08X})", + static_cast(result)); + } + } + } + return result; + } + + bool InstallD3D9Hooks(IDirect3DDevice9* device) + { + if (!device) + return false; + void** vtable = *reinterpret_cast(device); + if (!vtable) + return false; + + if (!ValidateRendererGlobals() && !FirstUnsafeAddressLogged) + { + FirstUnsafeAddressLogged = true; + spdlog::warn("VR renderer: OutRun renderer globals failed executable/readability validation; c64 injection will fail closed"); + } + + RendererInstallState.store(RendererInstallPending, std::memory_order_release); + RendererInjectionAllowed.store(true, std::memory_order_release); + BeginSceneHook = safetyhook::create_inline(vtable[BeginSceneVtableIndex], BeginSceneDest); + EndSceneHook = safetyhook::create_inline(vtable[EndSceneVtableIndex], EndSceneDest); + SetVertexShaderConstantFHook = safetyhook::create_inline( + vtable[SetVertexShaderConstantFVtableIndex], SetVertexShaderConstantFDest); + + if (!BeginSceneHook || !EndSceneHook || !SetVertexShaderConstantFHook) + { + BeginSceneHook = {}; + EndSceneHook = {}; + SetVertexShaderConstantFHook = {}; + RendererInjectionAllowed.store(false, std::memory_order_release); + RendererInstallState.store(RendererInstallFailed, std::memory_order_release); + spdlog::error("VR renderer: failed to hook D3D9 renderer boundary; transactional rollback completed"); + return false; + } + + EnsureSharedState(); + RendererInstallState.store(RendererInstallReady, std::memory_order_release); + spdlog::info("VR renderer: D3D9 hooks installed; atomic renderer install state=READY; v3-primary/v2-fallback frame-latched c64 WVP injection armed (vtbl 41/42/94)"); + return true; + } + + DWORD WINAPI RendererInstallThread(void*) + { + for (int attempt = 0; attempt < 1200; ++attempt) + { + if (Game::D3DDevice_ptr && *Game::D3DDevice_ptr) + { + if (!InstallD3D9Hooks(*Game::D3DDevice_ptr)) + spdlog::error("VR renderer: renderer hook installation failed"); + return 0; + } + Sleep(100); + } + RendererInjectionAllowed.store(false, std::memory_order_release); + RendererInstallState.store(RendererInstallFailed, std::memory_order_release); + spdlog::warn("VR renderer: D3D9 device did not appear; renderer hook not installed"); + return 0; + } + } + + std::uint32_t GetActiveCadenceRequestId() noexcept + { + return ActiveCadenceRequestId.load(std::memory_order_acquire); + } + + bool IsCadencePacingActive() noexcept + { + return CadencePacingActive.load(std::memory_order_acquire); + } + + bool GetLatchedStereoFrame(LatchedStereoFrame& out) + { + out = LatchedStereo; + return out.valid && out.poseSequence != 0; + } + + bool GetLatchedHeadInverse(float outMatrix[16], + std::uint32_t& poseSequence) noexcept + { + if (!outMatrix || !LatchedHeadInverseValid || + LatchedPoseSequence == 0) + return false; + std::memcpy(outMatrix, &LatchedHeadInverse, + sizeof(LatchedHeadInverse)); + poseSequence = LatchedPoseSequence; + return true; + } + + std::uint64_t GetBeginSceneCallCount() + { + return BeginSceneCalls; + } + + void NotifyGamePresent() + { + MarkCadencePresented(); + PresentPoseLocked = false; + InvalidateVerifiedWvp(); + InvalidateGameWvpWrite(); + RestoreCullingCamera(); + WaitForNextCadenceRequest(); + } + + void NotifyGameReset() + { + ActiveCadenceRequestId.store(0, std::memory_order_release); + CadencePacingActive.store(false, std::memory_order_release); + CadenceAcceptedRequestId = 0; + CadencePresentedRequestId = 0; + CadenceTimedOutRequestId = 0; + PresentPoseLocked = false; + InvalidateGameWvpWrite(); + RestoreCullingCamera(); + ResetFrameState(); + } + + bool GetRendererBaseProjection(float outMatrix[16]) + { + if (!outMatrix || !ValidateRendererGlobals() || !RendererProjection) + return false; + D3DMATRIX projection{}; + if (CullingProjectionOverridden) + projection = CullingProjectionSaved; + else + std::memcpy(&projection, RendererProjection, sizeof(projection)); + if (!MatrixFinite(projection)) + return false; + std::memcpy(outMatrix, &projection, sizeof(projection)); + return true; + } + + bool GetLastVerifiedWvp(float outConstants[16], std::uint32_t& generation, + std::uint32_t& poseSequence, std::uintptr_t& shaderIdentity, + std::uint64_t& shaderSerial) + { + if (!outConstants || !LastVerifiedWvpValid || LastVerifiedWvpGeneration == 0 || + LastVerifiedWvpPoseSequence == 0 || LastVerifiedShaderIdentity == 0 || + LastVerifiedShaderSerial == 0) + return false; + std::memcpy(outConstants, LastVerifiedWvp, sizeof(LastVerifiedWvp)); + generation = LastVerifiedWvpGeneration; + poseSequence = LastVerifiedWvpPoseSequence; + shaderIdentity = LastVerifiedShaderIdentity; + shaderSerial = LastVerifiedShaderSerial; + return true; + } + + bool GetLastGameWvpWrite(float outConstants[16], std::uint64_t& writeSerial, + std::uint64_t& topLevelDrawSerial, std::uintptr_t& shaderIdentity, + std::uint64_t& shaderSerial) noexcept + { + if (!outConstants || !LastGameWvpWriteValid || + LastGameWvpWriteSerial == 0 || LastGameWvpShaderIdentity == 0 || + LastGameWvpShaderSerial == 0) + return false; + std::memcpy(outConstants, LastGameWvpWrite, sizeof(LastGameWvpWrite)); + writeSerial = LastGameWvpWriteSerial; + topLevelDrawSerial = LastGameWvpTopLevelDrawSerial; + shaderIdentity = LastGameWvpShaderIdentity; + shaderSerial = LastGameWvpShaderSerial; + return true; + } + + bool GetLastRawGameWvpWrite(float outConstants[16], + std::uint64_t& writeSerial, std::uint64_t& topLevelDrawSerial, + std::uintptr_t& shaderIdentity, std::uint64_t& shaderSerial) noexcept + { + if (!outConstants || !LastGameWvpWriteValid || + LastGameWvpWriteSerial == 0 || LastGameWvpShaderIdentity == 0 || + LastGameWvpShaderSerial == 0) + return false; + std::memcpy(outConstants, LastRawGameWvpWrite, + sizeof(LastRawGameWvpWrite)); + writeSerial = LastGameWvpWriteSerial; + topLevelDrawSerial = LastGameWvpTopLevelDrawSerial; + shaderIdentity = LastGameWvpShaderIdentity; + shaderSerial = LastGameWvpShaderSerial; + return true; + } + + class VRRendererHook : public Hook + { + public: + std::string_view description() override { return "OpenXRVRRenderer"; } + bool validate() override { return true; } + + bool apply() override + { + HANDLE thread = CreateThread(nullptr, 0, RendererInstallThread, nullptr, 0, nullptr); + if (!thread) + { + RendererInjectionAllowed.store(false, std::memory_order_release); + RendererInstallState.store(RendererInstallFailed, std::memory_order_release); + spdlog::error("VR renderer: failed to create installer thread: {}", GetLastError()); + return false; + } + CloseHandle(thread); + return true; + } + + static VRRendererHook instance; + }; + + VRRendererHook VRRendererHook::instance; +} diff --git a/src/vr/game/outrun_renderer_r13.cpp b/src/vr/game/outrun_renderer_r13.cpp new file mode 100644 index 000000000..7eb7fea82 --- /dev/null +++ b/src/vr/game/outrun_renderer_r13.cpp @@ -0,0 +1,302 @@ +// R13 renderer-pose hardening wrapper. The validated renderer is included in +// this TU; cmake marks outrun_renderer.cpp HEADER_FILE_ONLY. + +#include "vr/d3d9/r13_bridge.hpp" +#include "outrun_renderer.cpp" + +namespace OutRunVRRenderer +{ + namespace + { + SafetyHookInline R13WvpCallbackHook{}; + std::atomic R13WvpHookReady{false}; + std::uint64_t R13OffscreenWvpBypasses = 0; + std::uint64_t R13ScreenSpaceWvpBypasses = 0; + std::uint64_t R13UnknownProjectionWvpBypasses = 0; + std::uint64_t R13PolicyMismatchBypasses = 0; + std::uint64_t R13FragileEffectWvpBypasses = 0; + bool R13FirstOffscreenBypassLogged = false; + bool R13FirstScreenSpaceBypassLogged = false; + bool R13FirstUnknownProjectionLogged = false; + bool R13FirstPolicyMismatchLogged = false; + bool R13FirstPerspectiveWorldLogged = false; + bool R13FirstFragileEffectLogged = false; + bool R13FirstFragileStateReadFailureLogged = false; + + OutRunVR::PassPolicy::RenderSemantic R13CurrentRenderSemantic( + float& projectionM34, float& projectionM44) noexcept + { + projectionM34 = 0.0f; + projectionM44 = 0.0f; + + const auto passSnapshot = OutRunVRStereo::CurrentPoseInjectionSnapshot(); + const auto targetPolicy = passSnapshot.policy; + const bool mainBackbufferPosePass = + passSnapshot.legacyMainBackbufferInvariant; + + if (targetPolicy != OutRunVR::PassPolicy::PoseInjectionPolicy::MainBackbuffer) + { + return OutRunVR::PassPolicy::ClassifyRenderSemanticChecked( + targetPolicy, mainBackbufferPosePass, + OutRunVR::PassPolicy::ProjectionClass::Unknown); + } + + float projection[16]{}; + auto projectionClass = OutRunVR::PassPolicy::ProjectionClass::Unknown; + if (GetRendererBaseProjection(projection)) + { + // D3DMATRIX memory layout: [11] == _34, [15] == _44. + // OutRun's D3DXMatrixPerspectiveFovRH path normally reports + // _34=-1/_44=0; orthographic screen-space reports _34=0/_44=1. + projectionM34 = projection[11]; + projectionM44 = projection[15]; + projectionClass = OutRunVR::PassPolicy::ClassifyProjectionSignature( + projectionM34, projectionM44); + } + + return OutRunVR::PassPolicy::ClassifyRenderSemanticChecked( + targetPolicy, mainBackbufferPosePass, projectionClass); + } + + bool R13FragileEffectNeedsZeroDisparity(IDirect3DDevice9* device) noexcept + { + if (!device) + return false; + + DWORD alphaBlend = FALSE; + DWORD alphaTest = FALSE; + DWORD zWrite = TRUE; + DWORD zEnable = D3DZB_TRUE; + DWORD cullMode = D3DCULL_CCW; + if (FAILED(device->GetRenderState(D3DRS_ALPHABLENDENABLE, &alphaBlend)) || + FAILED(device->GetRenderState(D3DRS_ALPHATESTENABLE, &alphaTest)) || + FAILED(device->GetRenderState(D3DRS_ZWRITEENABLE, &zWrite)) || + FAILED(device->GetRenderState(D3DRS_ZENABLE, &zEnable)) || + FAILED(device->GetRenderState(D3DRS_CULLMODE, &cullMode))) + { + // A failed state read is not enough evidence to demote all world + // geometry. Keep the existing world classifier authoritative, + // but leave a diagnostic breadcrumb for hardware testing. + if (!R13FirstFragileStateReadFailureLogged) + { + R13FirstFragileStateReadFailureLogged = true; + spdlog::warn( + "VR R13 effect policy: render-state snapshot unavailable; retaining normal world-stereo classification"); + } + return false; + } + + const auto policy = OutRunVR::PassPolicy::ClassifyEffectStereo( + alphaBlend != FALSE, + alphaTest != FALSE, + zWrite != FALSE, + zEnable != D3DZB_FALSE, + cullMode == D3DCULL_NONE); + return !OutRunVR::PassPolicy::AllowsEffectWorldStereo(policy); + } + + HRESULT __stdcall SetVertexShaderConstantFDestR13( + IDirect3DDevice9* device, UINT startRegister, const float* constantData, UINT vector4fCount) + { + // R13 creates its callback hook disabled, publishes the trampoline, + // then explicitly enables it. If a render thread enters while the + // enable transaction is completing, the already-owned trampoline + // is safe to call and preserves the validated base renderer path. + if (!R13WvpHookReady.load(std::memory_order_acquire)) + { + InvalidateVerifiedWvp(); + if (R13WvpCallbackHook) + { + return R13WvpCallbackHook.stdcall( + device, startRegister, constantData, vector4fCount); + } + return SetVertexShaderConstantFHook.stdcall( + device, startRegister, constantData, vector4fCount); + } + + if (IsGameDevice(device) && constantData && + !OutRunVRStereo::IsInternalStereoPassActive() && + UploadTouchesOutRunWvp(startRegister, vector4fCount)) + { + float projectionM34 = 0.0f; + float projectionM44 = 0.0f; + const auto semantic = R13CurrentRenderSemantic( + projectionM34, projectionM44); + + if (!OutRunVR::PassPolicy::AllowsWorldStereo(semantic)) + { + // Emulator-inspired fail-closed classification: only a + // perspective + main-backbuffer + verified c64 upload may + // enter the head-tracked world path. Reflections/shadows, + // orthographic HUD/UI, policy disagreement, and unknown + // projections keep stock game matrices and cannot seed + // stereo world replay. + InvalidateVerifiedWvp(); + + if (semantic == OutRunVR::PassPolicy::RenderSemantic::Auxiliary) + { + ++R13OffscreenWvpBypasses; + if (!R13FirstOffscreenBypassLogged) + { + R13FirstOffscreenBypassLogged = true; + spdlog::info( + "VR R13: auxiliary/offscreen c64 WVP kept stock; HMD transform is main-backbuffer-only"); + } + } + else if (semantic == OutRunVR::PassPolicy::RenderSemantic::ScreenSpace2D) + { + ++R13ScreenSpaceWvpBypasses; + if (!R13FirstScreenSpaceBypassLogged) + { + R13FirstScreenSpaceBypassLogged = true; + spdlog::info( + "VR R13 emulator policy: orthographic/screen-space c64 WVP kept stock; HUD/UI stays zero-disparity (_34={:.3f} _44={:.3f})", + projectionM34, projectionM44); + } + } + else if (semantic == OutRunVR::PassPolicy::RenderSemantic::PolicyMismatch) + { + ++R13PolicyMismatchBypasses; + if (!R13FirstPolicyMismatchLogged) + { + R13FirstPolicyMismatchLogged = true; + spdlog::error( + "VR R13 emulator policy: main-pass classifiers disagreed; c64 WVP kept stock fail-closed"); + } + } + else + { + ++R13UnknownProjectionWvpBypasses; + if (!R13FirstUnknownProjectionLogged) + { + R13FirstUnknownProjectionLogged = true; + spdlog::warn( + "VR R13 emulator policy: unknown main-backbuffer projection kept stock fail-closed (_34={:.3f} _44={:.3f})", + projectionM34, projectionM44); + } + } + + return SetVertexShaderConstantFHook.stdcall( + device, startRegister, constantData, vector4fCount); + } + + // Projected shadows and camera-facing alpha panels are prepared + // against OutRun's stock camera. Applying an HMD WVP to them can + // make the SBS halves disagree even though the opaque world is + // correct. Keep these fragile effect classes zero-disparity by + // deliberately preventing this WVP upload from becoming the + // verified world draw. ExecuteStereoDraw will still replay the + // object to BOTH eyes with the unmodified game WVP. + if (R13FragileEffectNeedsZeroDisparity(device)) + { + InvalidateVerifiedWvp(); + ++R13FragileEffectWvpBypasses; + if (!R13FirstFragileEffectLogged) + { + R13FirstFragileEffectLogged = true; + spdlog::info( + "VR R13 effect policy: translucent shadow/billboard/panel pass kept stock and duplicated zero-disparity in both SBS eyes"); + } + return SetVertexShaderConstantFHook.stdcall( + device, startRegister, constantData, vector4fCount); + } + + if (!R13FirstPerspectiveWorldLogged) + { + R13FirstPerspectiveWorldLogged = true; + spdlog::info( + "VR R13 emulator policy: main-backbuffer perspective class confirmed; c64 remains subject to authoritative WorldView*Projection verification"); + } + } + + return R13WvpCallbackHook.stdcall( + device, startRegister, constantData, vector4fCount); + } + + DWORD WINAPI R13RendererInstallThread(void*) + { + R13WvpHookReady.store(false, std::memory_order_release); + for (int attempt = 0; attempt < 4800; ++attempt) + { + const std::uint32_t rendererState = + RendererInstallState.load(std::memory_order_acquire); + if (rendererState == RendererInstallFailed) + { + R13WvpHookReady.store(false, std::memory_order_release); + RendererInjectionAllowed.store(false, std::memory_order_release); + InvalidateVerifiedWvp(); + spdlog::error( + "VR R13: base renderer hook transaction failed; WVP injection remains disabled"); + return 0; + } + if (rendererState == RendererInstallReady) + { + R13WvpCallbackHook = safetyhook::create_inline( + reinterpret_cast(&SetVertexShaderConstantFDest), + SetVertexShaderConstantFDestR13, + safetyhook::InlineHook::StartDisabled); + + bool enabled = false; + if (R13WvpCallbackHook) + { + const auto enableResult = R13WvpCallbackHook.enable(); + enabled = enableResult.has_value(); + } + + if (enabled) + { + R13WvpHookReady.store(true, std::memory_order_release); + spdlog::info( + "VR R13: renderer WVP target+projection+effect classification guard armed via atomic renderer install handoff; disabled-first trampoline publish complete"); + } + else + { + R13WvpHookReady.store(false, std::memory_order_release); + R13WvpCallbackHook = {}; + RendererInjectionAllowed.store(false, std::memory_order_release); + InvalidateVerifiedWvp(); + spdlog::error( + "VR R13: failed to create/enable renderer c64 callback hook; WVP injection disabled atomically to fail closed"); + } + return 0; + } + Sleep(25); + } + R13WvpHookReady.store(false, std::memory_order_release); + RendererInjectionAllowed.store(false, std::memory_order_release); + InvalidateVerifiedWvp(); + spdlog::warn( + "VR R13: renderer transaction did not become ready; WVP injection disabled fail-closed"); + return 0; + } + + class VRRendererR13HardeningHook final : public Hook + { + public: + std::string_view description() override { return "OpenXRVRRendererR13Hardening"; } + bool validate() override { return true; } + bool apply() override + { + R13WvpHookReady.store(false, std::memory_order_release); + HANDLE thread = CreateThread(nullptr, 0, R13RendererInstallThread, nullptr, 0, nullptr); + if (!thread) + { + // Without the R13 classifier the base c64 injector must not + // continue in a less-safe mode. Treat installer-thread + // creation failure the same as hook-install failure. + RendererInjectionAllowed.store(false, std::memory_order_release); + InvalidateVerifiedWvp(); + spdlog::error( + "VR R13: failed to create renderer hardening installer thread; WVP injection disabled fail-closed: {}", + GetLastError()); + return false; + } + CloseHandle(thread); + return true; + } + static VRRendererR13HardeningHook instance; + }; + + VRRendererR13HardeningHook VRRendererR13HardeningHook::instance; + } +} diff --git a/src/vr/game/outrun_renderer_r23.cpp b/src/vr/game/outrun_renderer_r23.cpp new file mode 100644 index 000000000..0a8b6e162 --- /dev/null +++ b/src/vr/game/outrun_renderer_r23.cpp @@ -0,0 +1,488 @@ +// R23 renderer eligibility overlay. The R13 semantic classifier remains the +// normal path; this wrapper prevents head-tracked WVP injection and render-time +// culling-camera override until the common game-side baseline gate is eligible. +// +// R27 narrows R13's old alpha-state heuristic: a c64 upload that is already +// proven to be the main-backbuffer perspective world path is allowed to reach +// the authoritative WVP verifier regardless of alpha/cull state. Confirmed +// orthographic UI and auxiliary passes still stay stock. R27 also publishes F10 +// to the host from every game BeginScene, including menus/theater mode. +// +// R28 keeps a stock-game shadow of c64..c67 so partial constant uploads cannot +// strand the device with a mixture of old head-patched rows and new stock rows. +// Perspective-world partial writes are rebuilt as one verified full WVP upload; +// non-world partial writes restore one coherent stock c64..c67 block. +// +// Recovery uses a separate pose-warmup phase. During that phase BeginScene may +// latch a fresh host pose for the upcoming authoritative clear, but the stock +// camera and stock c64 values remain on screen. The x86 recovery coordinator can +// then open stereo at a safe full clear without reusing the previous frame pose. + +#include "../runtime_eligibility.hpp" +#include "../ipc/recenter_request.hpp" +#include "outrun_renderer_r13.cpp" + +namespace OutRunVRRenderer +{ + namespace + { + SafetyHookInline R23BeginSceneEligibilityHook{}; + SafetyHookInline R23WvpEligibilityHook{}; + std::atomic R23WvpEligibilityReady{ false }; + std::atomic R23RenderThreadCleanupRequested{ true }; + std::atomic R23RendererInstallState{ + OutRunVR::RuntimeEligibility::InstallState::Pending }; + std::uint64_t R23EligibilityBypasses = 0; + std::uint64_t R23CullingEligibilityBypasses = 0; + std::uint64_t R23WarmupPosePreserves = 0; + std::uint64_t R27PerspectiveEffectWorldUploads = 0; + bool R23FirstEligibilityBypassLogged = false; + bool R23FirstCullingEligibilityBypassLogged = false; + bool R23FirstWarmupPoseLogged = false; + bool R27FirstPerspectiveEffectWorldLogged = false; + bool R27HostRecenterWasDown = false; + + float R28RawWvp[16]{}; + std::uint32_t R28RawWvpMask = 0; + float R28VerifiedProjection[16]{}; + std::uint32_t R28VerifiedProjectionGeneration = 0; + std::uint32_t R28VerifiedProjectionPoseSequence = 0; + bool R28VerifiedProjectionValid = false; + std::uint64_t R28PartialWorldRebuilds = 0; + std::uint64_t R28PartialStockRestores = 0; + std::uint64_t R28PartialShadowMisses = 0; + bool R28FirstPartialWorldLogged = false; + bool R28FirstPartialStockLogged = false; + bool R28FirstPartialMissLogged = false; + + struct R23EarlyRendererFailClosed + { + R23EarlyRendererFailClosed() noexcept + { + RendererInjectionAllowed.store(false, std::memory_order_release); + R23RenderThreadCleanupRequested.store(true, std::memory_order_release); + } + }; + R23EarlyRendererFailClosed R23EarlyRendererFailClosedState{}; + + void R28InvalidateProjectionSnapshot() noexcept + { + R28VerifiedProjectionValid = false; + R28VerifiedProjectionGeneration = 0; + R28VerifiedProjectionPoseSequence = 0; + } + + void R28TrackRawWvpWrite(UINT startRegister, const float* constantData, + UINT vector4fCount) noexcept + { + if (!constantData || vector4fCount == 0 || vector4fCount > 256) + return; + const std::uint64_t first = startRegister; + const std::uint64_t last = first + vector4fCount; + for (UINT reg = OutRunWvpRegister; + reg < OutRunWvpRegister + OutRunWvpRegisterCount; ++reg) + { + if (reg < first || reg >= last) + continue; + const UINT sourceOffset = reg - startRegister; + const UINT destOffset = reg - OutRunWvpRegister; + std::memcpy(R28RawWvp + destOffset * 4, + constantData + sourceOffset * 4, sizeof(float) * 4); + R28RawWvpMask |= (1u << destOffset); + } + } + + bool R28RawWvpComplete() noexcept + { + return (R28RawWvpMask & 0x0Fu) == 0x0Fu; + } + + void R28CaptureVerifiedProjection() noexcept + { + float verified[16]{}; + std::uint32_t generation = 0; + std::uint32_t poseSequence = 0; + std::uintptr_t shaderIdentity = 0; + std::uint64_t shaderSerial = 0; + float projection[16]{}; + if (!GetLastVerifiedWvp(verified, generation, poseSequence, + shaderIdentity, shaderSerial) || + !GetRendererBaseProjection(projection)) + { + R28InvalidateProjectionSnapshot(); + return; + } + std::memcpy(R28VerifiedProjection, projection, + sizeof(R28VerifiedProjection)); + R28VerifiedProjectionGeneration = generation; + R28VerifiedProjectionPoseSequence = poseSequence; + R28VerifiedProjectionValid = true; + } + + HRESULT R28RestoreStockWvp(IDirect3DDevice9* device) noexcept + { + InvalidateVerifiedWvp(); + R28InvalidateProjectionSnapshot(); + return SetVertexShaderConstantFHook.stdcall( + device, OutRunWvpRegister, R28RawWvp, OutRunWvpRegisterCount); + } + + void R23DropIneligibleLatchedPoseOnRenderThread() noexcept + { + RestoreCullingCamera(); + LatchedHeadInverseValid = false; + LatchedStereo = {}; + LatchedPoseSequence = 0; + FrameTelemetryFlags &= ~OutRunVR::ClientCullingCameraSynced; + InvalidateVerifiedWvp(); + R28InvalidateProjectionSnapshot(); + } + + void R23KeepWarmupPoseStockOnRenderThread() noexcept + { + // LatchFramePose may have temporarily applied the culling camera. + // Recovery warmup keeps the immutable pose packet but never exposes + // its camera/WVP transform to the monoscopic game frame. + RestoreCullingCamera(); + FrameTelemetryFlags &= ~OutRunVR::ClientCullingCameraSynced; + FrameTelemetryFlags &= ~OutRunVR::ClientRendererPoseInjected; + FrameTelemetryFlags &= ~OutRunVR::ClientPoseApplied; + InvalidateVerifiedWvp(); + R28InvalidateProjectionSnapshot(); + ++R23WarmupPosePreserves; + if (!R23FirstWarmupPoseLogged) + { + R23FirstWarmupPoseLogged = true; + spdlog::info( + "VR R23/R25 RENDERER: recovery pose warmup latched a fresh pose while stock camera/WVP remain authoritative"); + } + } + + void R23RequestFailClosedCleanup() noexcept + { + RendererInjectionAllowed.store(false, std::memory_order_release); + R23WvpEligibilityReady.store(false, std::memory_order_release); + R23RenderThreadCleanupRequested.store(true, std::memory_order_release); + R28InvalidateProjectionSnapshot(); + } + + void R23ServiceRenderThreadCleanup() noexcept + { + if (R23RenderThreadCleanupRequested.exchange(false, + std::memory_order_acq_rel)) + { + R23DropIneligibleLatchedPoseOnRenderThread(); + } + } + + void R27PublishHostRecenterIfPressed() noexcept + { + const bool down = RendererRecenterActionDown(); + const bool pressed = down && !R27HostRecenterWasDown; + R27HostRecenterWasDown = down; + if (!pressed) + return; + + const LONG requestId = OutRunVR::RecenterIpc::SharedChannel().Publish(); + if (requestId != 0) + { + spdlog::info( + "VR R27 recenter: {} game->host request published requestId={} pid={} presentation={}", + InputManager_ModActionDisplayName(ModAction::VRRecenter), + requestId, GetCurrentProcessId(), + static_cast(CurrentPresentationMode())); + } + else + { + spdlog::warn( + "VR R27 recenter: {} pressed but game->host request mapping is unavailable", + InputManager_ModActionDisplayName(ModAction::VRRecenter)); + } + } + + HRESULT __stdcall BeginSceneDestR23(IDirect3DDevice9* device) + { + // Do this before the gameplay eligibility branch. Menus render + // BeginScene too, so the configured VR Recenter action can re-anchor the host menu even though + // LatchFramePose intentionally skips gameplay tracking there. + if (IsGameDevice(device) && !OutRunVRStereo::IsInternalStereoPassActive()) + R27PublishHostRecenterIfPressed(); + + R23ServiceRenderThreadCleanup(); + const HRESULT result = R23BeginSceneEligibilityHook.stdcall(device); + if (SUCCEEDED(result) && IsGameDevice(device) && + !OutRunVRStereo::IsInternalStereoPassActive() && + !OutRunVR::RuntimeEligibility::MayInjectStereo()) + { + if (OutRunVR::RuntimeEligibility::PoseWarmupAllowed()) + R23KeepWarmupPoseStockOnRenderThread(); + else + R23DropIneligibleLatchedPoseOnRenderThread(); + + ++R23CullingEligibilityBypasses; + if (!R23FirstCullingEligibilityBypassLogged) + { + R23FirstCullingEligibilityBypassLogged = true; + spdlog::info( + "VR R23: BeginScene culling-camera/head transform held stock until common host-fresh + verified-baseline eligibility is true"); + } + } + return result; + } + + HRESULT __stdcall SetVertexShaderConstantFDestR23( + IDirect3DDevice9* device, UINT startRegister, + const float* constantData, UINT vector4fCount) + { + R23ServiceRenderThreadCleanup(); + if (!R23WvpEligibilityReady.load(std::memory_order_acquire)) + { + if (R23WvpEligibilityHook) + return R23WvpEligibilityHook.stdcall( + device, startRegister, constantData, vector4fCount); + return SetVertexShaderConstantFHook.stdcall( + device, startRegister, constantData, vector4fCount); + } + + const bool candidateWvp = IsGameDevice(device) && constantData && + !OutRunVRStereo::IsInternalStereoPassActive() && + UploadTouchesOutRunWvp(startRegister, vector4fCount); + const bool partialWvp = candidateWvp && + !UploadContainsOutRunWvp(startRegister, vector4fCount); + if (candidateWvp) + R28TrackRawWvpWrite(startRegister, constantData, vector4fCount); + + if (candidateWvp && !OutRunVR::RuntimeEligibility::MayInjectStereo()) + { + if (OutRunVR::RuntimeEligibility::PoseWarmupAllowed()) + R23KeepWarmupPoseStockOnRenderThread(); + else + R23DropIneligibleLatchedPoseOnRenderThread(); + + ++R23EligibilityBypasses; + if (!R23FirstEligibilityBypassLogged) + { + R23FirstEligibilityBypassLogged = true; + spdlog::info( + "VR R23: c64 WVP head injection held stock until common host-fresh + verified-baseline eligibility is true"); + } + HRESULT hr = SetVertexShaderConstantFHook.stdcall( + device, startRegister, constantData, vector4fCount); + if (SUCCEEDED(hr) && partialWvp && R28RawWvpComplete()) + { + hr = R28RestoreStockWvp(device); + ++R28PartialStockRestores; + } + return hr; + } + + if (candidateWvp && OutRunVR::RuntimeEligibility::MayInjectStereo()) + { + float projectionM34 = 0.0f; + float projectionM44 = 0.0f; + const auto semantic = R13CurrentRenderSemantic( + projectionM34, projectionM44); + if (OutRunVR::PassPolicy::AllowsWorldStereo(semantic)) + { + // R13's alpha/cull-only ZeroDisparity heuristic mixed two + // camera spaces: opaque world geometry used the HMD eye WVP + // while projected shadows/billboards retained the stock WVP. + // Once the target+projection classifier proves this is the + // perspective world pass, let the authoritative base c64 + // verifier decide. Orthographic HUD and auxiliary passes + // still flow through R13 and remain stock. + ++R27PerspectiveEffectWorldUploads; + if (!R27FirstPerspectiveEffectWorldLogged) + { + R27FirstPerspectiveEffectWorldLogged = true; + spdlog::info( + "VR R27 EFFECT: main-backbuffer perspective c64 now follows the verified per-eye world WVP regardless of alpha/cull state; orthographic HUD and auxiliary passes remain stock"); + } + + if (partialWvp) + { + const HRESULT partialHr = R13WvpCallbackHook.stdcall( + device, startRegister, constantData, vector4fCount); + if (FAILED(partialHr)) + return partialHr; + if (!R28RawWvpComplete()) + { + ++R28PartialShadowMisses; + if (!R28FirstPartialMissLogged) + { + R28FirstPartialMissLogged = true; + spdlog::warn( + "VR R28 WVP: partial c64..c67 write arrived before all stock rows were observed; keeping fail-closed classification until the shadow is complete"); + } + return partialHr; + } + + const HRESULT rebuildHr = R13WvpCallbackHook.stdcall( + device, OutRunWvpRegister, R28RawWvp, + OutRunWvpRegisterCount); + if (SUCCEEDED(rebuildHr)) + { + R28CaptureVerifiedProjection(); + ++R28PartialWorldRebuilds; + if (!R28FirstPartialWorldLogged) + { + R28FirstPartialWorldLogged = true; + spdlog::info( + "VR R28 WVP: partial world c64..c67 writes now rebuild one coherent verified full WVP before stereo draw classification"); + } + } + return rebuildHr; + } + + const HRESULT hr = R13WvpCallbackHook.stdcall( + device, startRegister, constantData, vector4fCount); + if (SUCCEEDED(hr)) + R28CaptureVerifiedProjection(); + return hr; + } + } + + HRESULT hr = R23WvpEligibilityHook.stdcall( + device, startRegister, constantData, vector4fCount); + if (SUCCEEDED(hr) && partialWvp && R28RawWvpComplete()) + { + hr = R28RestoreStockWvp(device); + ++R28PartialStockRestores; + if (!R28FirstPartialStockLogged) + { + R28FirstPartialStockLogged = true; + spdlog::info( + "VR R28 WVP: non-world partial c64..c67 writes restore a coherent stock WVP instead of retaining head-patched rows"); + } + } + return hr; + } + + DWORD WINAPI R23RendererInstallThread(void*) + { + using State = OutRunVR::RuntimeEligibility::InstallState; + R23RendererInstallState.store(State::Pending, std::memory_order_release); + R23RequestFailClosedCleanup(); + + R23BeginSceneEligibilityHook = safetyhook::create_inline( + reinterpret_cast(&BeginSceneDest), BeginSceneDestR23, + safetyhook::InlineHook::StartDisabled); + const bool beginEnabled = R23BeginSceneEligibilityHook && + R23BeginSceneEligibilityHook.enable().has_value(); + if (!beginEnabled) + { + R23BeginSceneEligibilityHook = {}; + R23RendererInstallState.store(State::Failed, std::memory_order_release); + R23RequestFailClosedCleanup(); + HookManager::ReportAsyncResult( + "OpenXRVRRendererR23Eligibility", false); + spdlog::error( + "VR R23: failed to install early BeginScene culling eligibility hook; renderer injection disabled fail-closed"); + return 0; + } + + R23WvpEligibilityHook = safetyhook::create_inline( + reinterpret_cast(&SetVertexShaderConstantFDestR13), + SetVertexShaderConstantFDestR23, + safetyhook::InlineHook::StartDisabled); + const bool wvpEnabled = R23WvpEligibilityHook && + R23WvpEligibilityHook.enable().has_value(); + if (!wvpEnabled) + { + R23WvpEligibilityHook = {}; + R23RendererInstallState.store(State::Failed, std::memory_order_release); + R23RequestFailClosedCleanup(); + HookManager::ReportAsyncResult( + "OpenXRVRRendererR23Eligibility", false); + spdlog::error( + "VR R23: failed to install early WVP eligibility hook; injection disabled while BeginScene culling guard remains active"); + return 0; + } + + for (int attempt = 0; attempt < 4800; ++attempt) + { + if (RendererInstallState.load(std::memory_order_acquire) == RendererInstallFailed) + { + R23RendererInstallState.store(State::Failed, std::memory_order_release); + R23RequestFailClosedCleanup(); + HookManager::ReportAsyncResult( + "OpenXRVRRendererR23Eligibility", false); + return 0; + } + + if (RendererInstallState.load(std::memory_order_acquire) == RendererInstallReady && + R13WvpHookReady.load(std::memory_order_acquire)) + { + R23WvpEligibilityReady.store(true, std::memory_order_release); + RendererInjectionAllowed.store(true, std::memory_order_release); + R23RendererInstallState.store(State::Ready, std::memory_order_release); + HookManager::ReportAsyncResult( + "OpenXRVRRendererR23Eligibility", true); + spdlog::info( + "VR R23 RENDERER: BeginScene/WVP guards READY; recovery pose warmup is stock-visible and stereo injection still requires the authoritative baseline gate; R28 partial-WVP reconstruction active"); + return 0; + } + Sleep(25); + } + R23RendererInstallState.store(State::Failed, std::memory_order_release); + R23RequestFailClosedCleanup(); + HookManager::ReportAsyncResult( + "OpenXRVRRendererR23Eligibility", false); + spdlog::error( + "VR R23 RENDERER: timed out waiting for base/R13 renderer transaction; injection remains fail-closed"); + return 0; + } + + class VRRendererR23EligibilityHook final : public Hook + { + public: + std::string_view description() override + { + return "OpenXRVRRendererR23Eligibility"; + } + bool validate() override { return true; } + bool apply() override + { + using State = OutRunVR::RuntimeEligibility::InstallState; + R23RendererInstallState.store(State::Pending, std::memory_order_release); + R23RequestFailClosedCleanup(); + + HANDLE thread = CreateThread(nullptr, 0, R23RendererInstallThread, + nullptr, 0, nullptr); + if (!thread) + { + R23RendererInstallState.store(State::Failed, std::memory_order_release); + R23RequestFailClosedCleanup(); + return false; + } + CloseHandle(thread); + return true; + } + static VRRendererR23EligibilityHook instance; + }; + + VRRendererR23EligibilityHook VRRendererR23EligibilityHook::instance; + } + + bool GetR28VerifiedProjection(float outProjection[16], + std::uint32_t& generation, std::uint32_t& poseSequence) noexcept + { + if (!outProjection || !R28VerifiedProjectionValid || + R28VerifiedProjectionGeneration == 0 || + R28VerifiedProjectionPoseSequence == 0) + return false; + std::memcpy(outProjection, R28VerifiedProjection, + sizeof(R28VerifiedProjection)); + generation = R28VerifiedProjectionGeneration; + poseSequence = R28VerifiedProjectionPoseSequence; + return true; + } + + bool R28PerspectiveWorldSemantic() noexcept + { + float projectionM34 = 0.0f; + float projectionM44 = 0.0f; + return OutRunVR::PassPolicy::AllowsWorldStereo( + R13CurrentRenderSemantic(projectionM34, projectionM44)); + } +} diff --git a/src/vr/game/outrun_renderer_r29.cpp b/src/vr/game/outrun_renderer_r29.cpp new file mode 100644 index 000000000..2e1748f1f --- /dev/null +++ b/src/vr/game/outrun_renderer_r29.cpp @@ -0,0 +1,420 @@ +// R29 renderer correctness overlay. +// +// R23/R28 widened the upload-time world path too far: once target/projection +// looked like perspective world it bypassed R13's fragile-effect policy, and a +// partial c64..c67 write could reuse rows observed in an older eligibility +// generation. R29 restores R13 as the authoritative classifier and makes the +// stock WVP shadow frame/generation local. +// +// Important policy: +// * stock c64..c67 is tracked even while stereo eligibility is closed; +// * a partial write first seeds all four current device rows, then applies the +// incoming rows, so old rows can never be mixed into a new generation; +// * every eligible WVP candidate flows through R13 again. Alpha-tested/two- +// sided translucent effects therefore remain stock/zero-disparity; +// * R28 projection snapshots are kept only when R13 actually accepted a +// verified world WVP. No draw-time shader identity substitution is needed. + +#include + +#include "outrun_renderer_r23.cpp" + +namespace OutRunVRRenderer +{ + namespace + { + SafetyHookInline R29BeginSceneR23Hook{}; + SafetyHookInline R29WvpR23Hook{}; + std::atomic R29RendererInstallState{ + OutRunVR::RuntimeEligibility::InstallState::Pending }; + + std::uint64_t R29RawWvpGeneration = 1; + std::uint64_t R29RawWvpSeeds = 0; + std::uint64_t R29RawWvpSeedFailures = 0; + std::uint64_t R29R13ClassifiedUploads = 0; + std::uint64_t R29PartialFullRebuilds = 0; + std::uint64_t R29StateBlockStockWrites = 0; + bool R29FirstSeedLogged = false; + bool R29FirstR13RestoreLogged = false; + bool R29FirstStateBlockStockLogged = false; +#if defined(OUTRUN_VR_R29_R28_CLASSIFICATION_COMPARE) + bool R29FirstR28CompareLogged = false; +#endif + + void R29InvalidateRawWvpGenerationImpl() noexcept + { + std::memset(R28RawWvp, 0, sizeof(R28RawWvp)); + R28RawWvpMask = 0; + R28InvalidateProjectionSnapshot(); + if (++R29RawWvpGeneration == 0) + ++R29RawWvpGeneration; + } + + bool R29SeedCurrentStockWvp(IDirect3DDevice9* device) noexcept + { + if (!device) + return false; + + float current[16]{}; + if (FAILED(device->GetVertexShaderConstantF( + OutRunWvpRegister, current, OutRunWvpRegisterCount))) + { + ++R29RawWvpSeedFailures; + R28RawWvpMask = 0; + R28InvalidateProjectionSnapshot(); + return false; + } + + std::memcpy(R28RawWvp, current, sizeof(R28RawWvp)); + R28RawWvpMask = 0x0Fu; + ++R29RawWvpSeeds; + if (!R29FirstSeedLogged) + { + R29FirstSeedLogged = true; + spdlog::info( + "VR R29 WVP: first partial c64..c67 write seeds the complete current device block before rebuilding; stale cross-generation rows are forbidden"); + } + return true; + } + + bool R29BuildCoherentUploadEnvelope(UINT startRegister, + const float* constantData, UINT vector4fCount, + UINT& envelopeStart, UINT& envelopeCount, + float envelopeData[256 * 4]) noexcept + { + if (!constantData || !envelopeData || !R28RawWvpComplete() || + vector4fCount == 0 || vector4fCount > 256) + return false; + + const std::uint64_t requestFirst = startRegister; + const std::uint64_t requestLast = requestFirst + vector4fCount; + const std::uint64_t wvpFirst = OutRunWvpRegister; + const std::uint64_t wvpLast = + OutRunWvpRegister + OutRunWvpRegisterCount; + const std::uint64_t first = std::min(requestFirst, wvpFirst); + const std::uint64_t last = std::max(requestLast, wvpLast); + const std::uint64_t maxRegisterPlusOne = + static_cast( + std::numeric_limits::max()) + 1; + if (last <= first || last - first > 256 || + last > maxRegisterPlusOne) + return false; + + envelopeStart = static_cast(first); + envelopeCount = static_cast(last - first); + std::memset(envelopeData, 0, + sizeof(float) * envelopeCount * 4); + + // Rebuild c64..c67 from the generation-local stock shadow, then + // preserve every register in the caller's original range outside + // that block. A single widened D3D call retains the base renderer's + // all-or-nothing failure handling without dropping c63 or c68+. + const UINT wvpOffset = OutRunWvpRegister - envelopeStart; + std::memcpy(envelopeData + wvpOffset * 4, R28RawWvp, + sizeof(R28RawWvp)); + + if (requestFirst < wvpFirst) + { + const std::uint64_t prefixLast = std::min(requestLast, wvpFirst); + const UINT prefixCount = static_cast( + prefixLast - requestFirst); + std::memcpy(envelopeData + + static_cast(requestFirst - first) * 4, + constantData, sizeof(float) * prefixCount * 4); + } + if (requestLast > wvpLast) + { + const std::uint64_t suffixFirst = std::max(requestFirst, wvpLast); + const UINT suffixCount = static_cast(requestLast - suffixFirst); + const UINT sourceOffset = static_cast( + suffixFirst - requestFirst); + const UINT destinationOffset = static_cast( + suffixFirst - first); + std::memcpy(envelopeData + destinationOffset * 4, + constantData + sourceOffset * 4, + sizeof(float) * suffixCount * 4); + } + return true; + } + + HRESULT __stdcall BeginSceneDestR29(IDirect3DDevice9* device) + { + // BeginScene is the cheapest authoritative generation boundary we + // already have on the render thread. Never carry a raw-row mask + // across frames/stages/recovery transitions. + if (IsGameDevice(device) && + !OutRunVRStereo::IsInternalStereoPassActive()) + { + R29InvalidateRawWvpGenerationImpl(); + } + return R29BeginSceneR23Hook.stdcall(device); + } + + HRESULT __stdcall SetVertexShaderConstantFDestR29( + IDirect3DDevice9* device, UINT startRegister, + const float* constantData, UINT vector4fCount) + { + // Preserve R23's render-thread cleanup before inspecting eligibility. + R23ServiceRenderThreadCleanup(); + + const bool candidateWvp = IsGameDevice(device) && constantData && + !OutRunVRStereo::IsInternalStereoPassActive() && + UploadTouchesOutRunWvp(startRegister, vector4fCount); + if (!candidateWvp) + { + return R29WvpR23Hook.stdcall( + device, startRegister, constantData, vector4fCount); + } + + if (OutRunVRStereo::IsGameStateBlockRecording()) + { + // A recorded StateBlock must contain the game's stock values, + // not a pose-patched matrix that could be replayed in a later + // generation. Bypass every renderer injection layer while D3D9 + // records this call; EndStateBlock will resynchronize live state. + InvalidateVerifiedWvp(); + R29InvalidateRawWvpGenerationImpl(); + ++R29StateBlockStockWrites; + if (!R29FirstStateBlockStockLogged) + { + R29FirstStateBlockStockLogged = true; + spdlog::info( + "VR R29 STATE: c64 write inside Begin/EndStateBlock recorded stock; pose-patched WVP values are never persisted in a game StateBlock"); + } + return SetVertexShaderConstantFHook.stdcall( + device, startRegister, constantData, vector4fCount); + } + +#if defined(OUTRUN_VR_R29_R28_CLASSIFICATION_COMPARE) + // C1/C2 regression-isolation build: keep the R29 installation and + // fast stereo owner, but hand candidate WVP uploads back to the + // proven R23/R27/R28 classifier. This restores the known-good rule + // that a main-backbuffer perspective pass may remain per-eye world + // stereo regardless of alpha/cull state, while orthographic HUD and + // auxiliary targets remain stock/fail-closed. + if (!R29FirstR28CompareLogged) + { + R29FirstR28CompareLogged = true; + spdlog::warn( + "VR C1/C2 CLASSIFY: R29 renderer delegates candidate WVP uploads to R23/R27/R28 perspective-world classification"); + } + return R29WvpR23Hook.stdcall( + device, startRegister, constantData, vector4fCount); +#else + const bool partialWvp = + !UploadContainsOutRunWvp(startRegister, vector4fCount); + + // This deliberately happens BEFORE the R23 eligibility gate. The + // old implementation returned early while closed and silently lost + // stock row updates, leaving an old 0xF mask reusable later. + // Until every StateBlock::Apply is intercepted, a cached raw block + // may have been replaced without traversing this setter. Re-seed + // from the live device before every partial update in that mode so + // matrix B can never be combined with cached rows from matrix A. + if (partialWvp && (!R28RawWvpComplete() || + !OutRunVRStereo::IsStateBlockTrackingReliable())) + R29SeedCurrentStockWvp(device); + R28TrackRawWvpWrite(startRegister, constantData, vector4fCount); + + // During installation/recovery retain all R23 bookkeeping and its + // stock-visible warmup policy. Because R29 pre-tracked the write, + // R23 can no longer resurrect rows from an earlier generation. + if (!R23WvpEligibilityReady.load(std::memory_order_acquire) || + !OutRunVR::RuntimeEligibility::MayInjectStereo()) + { + HRESULT hr = R29WvpR23Hook.stdcall( + device, startRegister, constantData, vector4fCount); + if (SUCCEEDED(hr) && partialWvp && R28RawWvpComplete() && + !OutRunVR::RuntimeEligibility::MayInjectStereo()) + { + // Make recovery/warmup coherent even if only one row was + // written after a previously head-patched device state. + hr = R28RestoreStockWvp(device); + } + if (FAILED(hr)) + { + InvalidateVerifiedWvp(); + R29InvalidateRawWvpGenerationImpl(); + } + return hr; + } + + // R13 is again the only authority for an eligible c64 upload. This + // removes R27's "perspective means world regardless of alpha/cull" + // shortcut. Fragile shadow/billboard/cutout passes are invalidated + // and written stock by R13; only verified world WVPs survive. + HRESULT hr = D3D_OK; + if (partialWvp) + { + if (!R28RawWvpComplete()) + { + InvalidateVerifiedWvp(); + R28InvalidateProjectionSnapshot(); + return R23WvpEligibilityHook.stdcall( + device, startRegister, constantData, vector4fCount); + } + + UINT envelopeStart = 0; + UINT envelopeCount = 0; + float envelopeData[256 * 4]{}; + if (!R29BuildCoherentUploadEnvelope(startRegister, + constantData, vector4fCount, envelopeStart, + envelopeCount, envelopeData)) + { + InvalidateVerifiedWvp(); + R29InvalidateRawWvpGenerationImpl(); + return R23WvpEligibilityHook.stdcall( + device, startRegister, constantData, vector4fCount); + } + + hr = R23WvpEligibilityHook.stdcall( + device, envelopeStart, envelopeData, envelopeCount); + if (SUCCEEDED(hr)) + ++R29PartialFullRebuilds; + } + else + { + hr = R23WvpEligibilityHook.stdcall( + device, startRegister, constantData, vector4fCount); + } + + ++R29R13ClassifiedUploads; + if (SUCCEEDED(hr)) + R28CaptureVerifiedProjection(); + else + { + InvalidateVerifiedWvp(); + R29InvalidateRawWvpGenerationImpl(); + } + + if (!R29FirstR13RestoreLogged) + { + R29FirstR13RestoreLogged = true; + spdlog::info( + "VR R29 EFFECT: eligible WVP uploads restored to R13 classification; fragile alpha/billboard/shadow passes are no longer promoted merely because projection is perspective"); + } + return hr; +#endif + } + + void R29RollbackRendererHooks() noexcept + { + R29WvpR23Hook = {}; + R29BeginSceneR23Hook = {}; + } + + bool R29EnableRendererHooks() noexcept + { + SafetyHookInline* hooks[]{ &R29BeginSceneR23Hook, &R29WvpR23Hook }; + for (auto* hook : hooks) + { + if (!*hook || !hook->enable().has_value()) + return false; + } + return true; + } + + DWORD WINAPI R29RendererInstallThread(void*) + { + using State = OutRunVR::RuntimeEligibility::InstallState; + R29RendererInstallState.store(State::Pending, std::memory_order_release); + + for (int attempt = 0; attempt < 4800; ++attempt) + { + const auto r23 = R23RendererInstallState.load(std::memory_order_acquire); + if (r23 == State::Failed) + { + R29RendererInstallState.store(State::Failed, std::memory_order_release); + HookManager::ReportAsyncResult("OpenXRVRRendererR29", false); + spdlog::error( + "VR R29 RENDERER: R23 prerequisite failed; conservative WVP overlay not installed"); + return 0; + } + + if (r23 == State::Ready) + { + const auto disabled = safetyhook::InlineHook::StartDisabled; + R29BeginSceneR23Hook = safetyhook::create_inline( + reinterpret_cast(&BeginSceneDestR23), + BeginSceneDestR29, disabled); + R29WvpR23Hook = safetyhook::create_inline( + reinterpret_cast(&SetVertexShaderConstantFDestR23), + SetVertexShaderConstantFDestR29, disabled); + + if (!R29EnableRendererHooks()) + { + R29RollbackRendererHooks(); + R29RendererInstallState.store(State::Failed, + std::memory_order_release); + HookManager::ReportAsyncResult("OpenXRVRRendererR29", false); + spdlog::error( + "VR R29 RENDERER: disabled-first transaction failed; R23 remains authoritative"); + return 0; + } + + R29RendererInstallState.store(State::Ready, + std::memory_order_release); + HookManager::ReportAsyncResult("OpenXRVRRendererR29", true); + spdlog::info( + "VR R29 RENDERER: generation-local stock WVP shadow + R13 fragile-effect authority ACTIVE"); + return 0; + } + Sleep(25); + } + + R29RendererInstallState.store(State::Failed, std::memory_order_release); + HookManager::ReportAsyncResult("OpenXRVRRendererR29", false); + spdlog::error( + "VR R29 RENDERER: timed out waiting for R23; R23 remains active"); + return 0; + } + + class VRRendererR29Hook final : public Hook + { + public: + std::string_view description() override + { + return "OpenXRVRRendererR29"; + } + bool validate() override { return true; } + bool apply() override + { + using State = OutRunVR::RuntimeEligibility::InstallState; + R29RendererInstallState.store(State::Pending, + std::memory_order_release); + HANDLE thread = CreateThread(nullptr, 0, + R29RendererInstallThread, nullptr, 0, nullptr); + if (!thread) + { + R29RendererInstallState.store(State::Failed, + std::memory_order_release); + return false; + } + CloseHandle(thread); + return true; + } + static VRRendererR29Hook instance; + }; + + VRRendererR29Hook VRRendererR29Hook::instance; + } + + void R29InvalidateRawWvpGeneration() noexcept + { + R29InvalidateRawWvpGenerationImpl(); + } + + void R29InvalidateRendererStateAfterExternalRestore() noexcept + { + // StateBlock::Apply bypasses every renderer setter detour. Discard both + // the raw stock rows and the verified/patched WVP as one generation so + // a later partial upload must seed from the live device again. + InvalidateVerifiedWvp(); + R29InvalidateRawWvpGenerationImpl(); + } + + OutRunVR::RuntimeEligibility::InstallState R29RendererState() noexcept + { + return R29RendererInstallState.load(std::memory_order_acquire); + } +} diff --git a/src/vr/hud_semantics.hpp b/src/vr/hud_semantics.hpp new file mode 100644 index 000000000..cdbef1e21 --- /dev/null +++ b/src/vr/hud_semantics.hpp @@ -0,0 +1,182 @@ +#pragma once +// Scheduled-review baseline marker: UIScaling-derived VR HUD semantics. + +#include +#include + +namespace OutRunVRHudSemantics +{ + enum class SpacePolicy : std::uint8_t + { + Unknown, + ScreenHud, + WorldBillboard + }; + + struct SemanticInfo + { + const char* area; + const char* semantic; + SpacePolicy space; + }; + + struct SemanticAnchor + { + std::uint32_t rva; + const char* semantic; + SpacePolicy space; + const char* note; + }; + + constexpr const char* SpacePolicyName(SpacePolicy policy) noexcept + { + switch (policy) + { + case SpacePolicy::ScreenHud: return "SCREEN_HUD"; + case SpacePolicy::WorldBillboard: return "WORLD_BILLBOARD"; + default: return "UNKNOWN"; + } + } + + constexpr bool InRange( + std::uint32_t value, std::uint32_t begin, + std::uint32_t end) noexcept + { + return value >= begin && value < end; + } + + constexpr SemanticInfo UnknownInfo() noexcept + { + return { "", "UNKNOWN", SpacePolicy::Unknown }; + } + + // Semantic ranges are deliberately based on the already-shipped + // hooks_uiscaling.cpp reverse-engineering instead of D3D primitive-count + // guesses. Screen HUD remains zero-disparity/common-centre VR content. + // World-attached rival/heart markers remain true stereo billboards. + constexpr SemanticInfo ClassifyCaller(std::uint32_t callRva) noexcept + { + // HAM attached-heart draw; anchored by HeartDisp_PulseAngle=0x05B43A. + if (InRange(callRva, 0x05B300, 0x05B700)) + return { "HeartDisp_car_heart", "WORLD_HEART", SpacePolicy::WorldBillboard }; + + // C2C mission HUD anchors present in the original UI-scaling fixes. + if (InRange(callRva, 0x081A00, 0x081B00)) + return { "C2C_Fruit", "HUD_FRUIT", SpacePolicy::ScreenHud }; + if (InRange(callRva, 0x081B00, 0x081C00)) + return { "C2C_Heart", "HUD_HEART_TOTAL", SpacePolicy::ScreenHud }; + + if (InRange(callRva, 0x096A80, 0x096D00)) + return { "C2CSpeechBubble", "HUD_GF_SPEECH", SpacePolicy::ScreenHud }; + + if (InRange(callRva, 0x0B9000, 0x0B9200)) + return { "DispGearPosition", "HUD_GEAR_REV", SpacePolicy::ScreenHud }; + if (InRange(callRva, 0x0B9E00, 0x0BA100)) + return { "DispRank", "HUD_RANK", SpacePolicy::ScreenHud }; + + // sub_4BAD20: position markers projected from rival-car world position. + if (InRange(callRva, 0x0BAD20, 0x0BB320)) + return { "RankMarker/sub_4BAD20", "WORLD_RIVAL_MARKER", SpacePolicy::WorldBillboard }; + + if (InRange(callRva, 0x0BD2E0, 0x0BD360)) + return { "C2CTestSlipstream", "HUD_SLIPSTREAM", SpacePolicy::ScreenHud }; + if (InRange(callRva, 0x0BD360, 0x0BD500)) + return { "C2CDontLoseGF", "HUD_GF_WARNING", SpacePolicy::ScreenHud }; + if (InRange(callRva, 0x0BD900, 0x0BE100)) + return { "GhostGap", "HUD_GHOST", SpacePolicy::ScreenHud }; + if (InRange(callRva, 0x0BE300, 0x0BEA40)) + return { "DispTimeAttack2D", "HUD_TIME_ATTACK", SpacePolicy::ScreenHud }; + if (InRange(callRva, 0x0BEA40, 0x0BEB20)) + return { "NaviPub_DispTimeAttackGoal", "HUD_GOAL_TIME", SpacePolicy::ScreenHud }; + + // NaviPub_Disp interleaves Rival and Heart groups, so keep the known + // sub-ranges split instead of classifying the whole function as one HUD. + if (InRange(callRva, 0x0BEB60, 0x0BEBC0) || + InRange(callRva, 0x0BEC50, 0x0BECA0)) + return { "NaviPub_Disp_Rival", "HUD_RIVAL", SpacePolicy::ScreenHud }; + if (InRange(callRva, 0x0BEBC0, 0x0BEC50) || + InRange(callRva, 0x0BECA0, 0x0BED20)) + return { "NaviPub_Disp_Heart", "HUD_HEART_TOTAL", SpacePolicy::ScreenHud }; + if (InRange(callRva, 0x0BED20, 0x0BEE80)) + return { "NaviPub_Disp", "HUD_NAV_GENERIC", SpacePolicy::ScreenHud }; + + if (InRange(callRva, 0x0FC800, 0x0FC8A0)) + return { "C2CSpeechBubbleGF_RankEmoji", "HUD_RANK_EMOJI", SpacePolicy::ScreenHud }; + if (InRange(callRva, 0x0FC8A0, 0x0FC900)) + return { "C2CSpeechBubbleGF_RankText", "HUD_RANK_TEXT", SpacePolicy::ScreenHud }; + if (InRange(callRva, 0x0FC900, 0x0FCC00) || + InRange(callRva, 0x0FCD80, 0x0FD080) || + InRange(callRva, 0x0FD560, 0x0FD680) || + InRange(callRva, 0x0FE860, 0x0FE900)) + return { "C2CSpeechBubbleGF", "HUD_GF_SPEECH", SpacePolicy::ScreenHud }; + + return UnknownInfo(); + } + + // Exact reverse-engineered anchor inventory from hooks_uiscaling.cpp. + // This is a review/test source-of-truth, even where the anchor itself is + // not a direct sprite call and therefore may not appear in hudtrace.csv. + inline constexpr std::array Anchors{{ + {0x05B43A, "WORLD_HEART", SpacePolicy::WorldBillboard, "HeartDisp_car_heart pulse angle"}, + {0x081A86, "HUD_FRUIT", SpacePolicy::ScreenHud, "C2C fruit scaling disable"}, + {0x081A8B, "HUD_FRUIT", SpacePolicy::ScreenHud, "C2C fruit scaling enable"}, + {0x081B76, "HUD_HEART_TOTAL", SpacePolicy::ScreenHud, "C2C heart scaling disable"}, + + {0x0B9096, "HUD_GEAR_REV", SpacePolicy::ScreenHud, "REV left adjustment #1"}, + {0x0B90B3, "HUD_GEAR_REV", SpacePolicy::ScreenHud, "REV left adjustment #2"}, + {0x0B90F6, "HUD_GEAR_REV", SpacePolicy::ScreenHud, "REV left adjustment #3"}, + + {0x0B9F3A, "HUD_RANK", SpacePolicy::ScreenHud, "DispRank adjustment #1"}, + {0x0B9F5E, "HUD_RANK", SpacePolicy::ScreenHud, "DispRank adjustment #2"}, + {0x0B9F81, "HUD_RANK", SpacePolicy::ScreenHud, "DispRank adjustment #3"}, + {0x0B9FD0, "HUD_RANK", SpacePolicy::ScreenHud, "DispRank adjustment #4"}, + {0x0B9FFC, "HUD_RANK", SpacePolicy::ScreenHud, "DispRank adjustment #5"}, + {0x0BA01E, "HUD_RANK", SpacePolicy::ScreenHud, "DispRank adjustment #6"}, + {0x0BA035, "HUD_RANK", SpacePolicy::ScreenHud, "DispRank adjustment #7"}, + {0x0BA052, "HUD_RANK", SpacePolicy::ScreenHud, "DispRank adjustment #8"}, + + {0x0BB0FB, "WORLD_RIVAL_MARKER", SpacePolicy::WorldBillboard, "rank marker sprani #1"}, + {0x0BB133, "WORLD_RIVAL_MARKER", SpacePolicy::WorldBillboard, "rank marker sprani #2"}, + {0x0BB16C, "WORLD_RIVAL_MARKER", SpacePolicy::WorldBillboard, "rank marker sprani #3"}, + {0x0BB1A5, "WORLD_RIVAL_MARKER", SpacePolicy::WorldBillboard, "rank marker sprani #4"}, + {0x0BB21F, "WORLD_RIVAL_MARKER", SpacePolicy::WorldBillboard, "rank marker clip #1"}, + {0x0BB241, "WORLD_RIVAL_MARKER", SpacePolicy::WorldBillboard, "rank marker clip #2"}, + {0x0BB271, "WORLD_RIVAL_MARKER", SpacePolicy::WorldBillboard, "rank marker clip #3"}, + {0x0BB2BC, "WORLD_RIVAL_MARKER", SpacePolicy::WorldBillboard, "rank marker clip #4"}, + {0x0BB2D0, "WORLD_RIVAL_MARKER", SpacePolicy::WorldBillboard, "rank marker clip #5"}, + + {0x0BD32E, "HUD_SLIPSTREAM", SpacePolicy::ScreenHud, "test your slipstream"}, + {0x0BD397, "HUD_GF_WARNING", SpacePolicy::ScreenHud, "don't lose girlfriend #1"}, + {0x0BD414, "HUD_GF_WARNING", SpacePolicy::ScreenHud, "don't lose girlfriend #2"}, + {0x0BD472, "HUD_GF_WARNING", SpacePolicy::ScreenHud, "don't lose girlfriend #3"}, + {0x0BDAE8, "HUD_GHOST", SpacePolicy::ScreenHud, "Ghost/You/Diff sub adjustment"}, + {0x0BDE3A, "HUD_GHOST", SpacePolicy::ScreenHud, "Ghost/You/Diff adjustment"}, + + {0x0BE4BC, "HUD_TIME_ATTACK", SpacePolicy::ScreenHud, "TimeAttack force right"}, + {0x0BE4E7, "HUD_TIME_ATTACK", SpacePolicy::ScreenHud, "TimeAttack force left"}, + {0x0BE5CD, "HUD_TIME_ATTACK", SpacePolicy::ScreenHud, "TimeAttack scroll #1"}, + {0x0BE8D8, "HUD_TIME_ATTACK", SpacePolicy::ScreenHud, "TimeAttack scroll later group"}, + {0x0BEA64, "HUD_GOAL_TIME", SpacePolicy::ScreenHud, "TimeAttack goal"}, + {0x0BEB8E, "HUD_RIVAL", SpacePolicy::ScreenHud, "Rival scaling disable"}, + {0x0BEBAF, "HUD_RIVAL", SpacePolicy::ScreenHud, "Rival scaling enable"}, + {0x0BEBE1, "HUD_HEART_TOTAL", SpacePolicy::ScreenHud, "Heart total disable"}, + {0x0BEBE6, "HUD_HEART_TOTAL", SpacePolicy::ScreenHud, "Heart total enable"}, + {0x0BEC83, "HUD_RIVAL", SpacePolicy::ScreenHud, "Rival online disable"}, + {0x0BEC88, "HUD_RIVAL", SpacePolicy::ScreenHud, "Rival online enable"}, + {0x0BECBA, "HUD_HEART_TOTAL", SpacePolicy::ScreenHud, "C2C heart enable"}, + {0x0BECE0, "HUD_HEART_TOTAL", SpacePolicy::ScreenHud, "C2C heart enable #2"}, + + {0x0FC84E, "HUD_RANK_EMOJI", SpacePolicy::ScreenHud, "ranking emoji #1"}, + {0x0FC882, "HUD_RANK_EMOJI", SpacePolicy::ScreenHud, "ranking emoji #2"}, + {0x0FC8B4, "HUD_RANK_TEXT", SpacePolicy::ScreenHud, "rank: aaa text"}, + {0x0FC9EB, "HUD_GF_SPEECH", SpacePolicy::ScreenHud, "speech initial position #1"}, + {0x0FCA1E, "HUD_GF_SPEECH", SpacePolicy::ScreenHud, "speech initial position #2"}, + {0x0FCA51, "HUD_GF_SPEECH", SpacePolicy::ScreenHud, "speech initial position #3"}, + {0x0FCB20, "HUD_GF_SPEECH", SpacePolicy::ScreenHud, "speech initial position #4"}, + }}; + + static_assert(ClassifyCaller(0x0B9F3A).space == SpacePolicy::ScreenHud); + static_assert(ClassifyCaller(0x0BB0FB).space == SpacePolicy::WorldBillboard); + static_assert(ClassifyCaller(0x0BE5CD).space == SpacePolicy::ScreenHud); + static_assert(ClassifyCaller(0x0FC84E).space == SpacePolicy::ScreenHud); +} \ No newline at end of file diff --git a/src/vr/ipc/cadence_v1.hpp b/src/vr/ipc/cadence_v1.hpp new file mode 100644 index 000000000..5cfb81f5f --- /dev/null +++ b/src/vr/ipc/cadence_v1.hpp @@ -0,0 +1,74 @@ +#pragma once + +#include +#include + +namespace OutRunVR::CadenceV1 +{ + inline constexpr std::uint32_t ProtocolVersion = 1; + inline constexpr std::uint32_t HostMagic = 0x3148434Fu; // OCH1 + inline constexpr std::uint32_t ClientMagic = 0x3143434Fu; // OCC1 + + inline constexpr wchar_t HostStateName[] = + L"Local\\OutRun2006Tweaks.VR.Cadence.v1.Host"; + inline constexpr wchar_t ClientStateName[] = + L"Local\\OutRun2006Tweaks.VR.Cadence.v1.Client"; + inline constexpr wchar_t RequestEventName[] = + L"Local\\OutRun2006Tweaks.VR.Cadence.v1.Request"; + inline constexpr wchar_t PresentedEventName[] = + L"Local\\OutRun2006Tweaks.VR.Cadence.v1.Presented"; + + enum HostFlags : std::uint32_t + { + HostEnabled = 1u << 0, + HostRunning = 1u << 1, + HostSerializedProbe = 1u << 2, + }; + + enum ClientFlags : std::uint32_t + { + ClientEnabled = 1u << 0, + ClientWaiting = 1u << 1, + ClientLastWaitTimedOut = 1u << 2, + }; + +#pragma pack(push, 4) + struct HostState + { + std::uint32_t magic{HostMagic}; + std::uint32_t version{ProtocolVersion}; + std::uint32_t structSize{sizeof(HostState)}; + volatile std::uint32_t sequence{}; + std::uint32_t hostPid{}; + std::uint32_t flags{}; + std::uint32_t requestId{}; + std::uint32_t poseSequence{}; + std::uint32_t targetHzMilli{}; + std::uint32_t timeoutMs{}; + std::int64_t requestQpc{}; + std::int64_t predictedDisplayTime{}; + std::int64_t predictedDisplayPeriod{}; + }; + + struct ClientState + { + std::uint32_t magic{ClientMagic}; + std::uint32_t version{ProtocolVersion}; + std::uint32_t structSize{sizeof(ClientState)}; + volatile std::uint32_t sequence{}; + std::uint32_t clientPid{}; + std::uint32_t flags{}; + std::uint32_t acceptedRequestId{}; + std::uint32_t presentedRequestId{}; + std::uint32_t timeoutCount{}; + std::uint32_t lastWaitUs{}; + std::int64_t acceptedQpc{}; + std::int64_t presentedQpc{}; + }; +#pragma pack(pop) + + static_assert(sizeof(HostState) == 64); + static_assert(sizeof(ClientState) == 56); + static_assert(offsetof(HostState, requestQpc) == 40); + static_assert(offsetof(ClientState, acceptedQpc) == 40); +} diff --git a/src/vr/ipc/direct_ack_r13.hpp b/src/vr/ipc/direct_ack_r13.hpp new file mode 100644 index 000000000..c42c1d901 --- /dev/null +++ b/src/vr/ipc/direct_ack_r13.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include + +namespace OutRunVR::R13 +{ + inline constexpr wchar_t DirectGpuAckName[] = L"Local\\OutRun2006Tweaks.VR.R13.DirectGpuAck"; + inline constexpr std::uint32_t DirectGpuAckMagic = 0x334B4341u; // 'ACK3' + inline constexpr std::uint32_t DirectGpuAckVersion = 1; + inline constexpr std::uint32_t DirectGpuAckRingSize = 4; + +#pragma pack(push, 4) + struct DirectGpuAckState + { + std::uint32_t magic{DirectGpuAckMagic}; + std::uint32_t version{DirectGpuAckVersion}; + std::uint32_t structSize{sizeof(DirectGpuAckState)}; + volatile std::uint32_t sequence{}; + std::uint32_t hostPid{}; + std::uint32_t transportGeneration{}; + std::uint32_t completedFrameId[DirectGpuAckRingSize]{}; + std::uint32_t reserved[2]{}; + }; +#pragma pack(pop) + + static_assert(sizeof(DirectGpuAckState) == 48); + static_assert(std::is_standard_layout_v); + static_assert(std::is_trivially_copyable_v); +} diff --git a/src/vr/ipc/host_pose_v3.hpp b/src/vr/ipc/host_pose_v3.hpp new file mode 100644 index 000000000..6182e6fe9 --- /dev/null +++ b/src/vr/ipc/host_pose_v3.hpp @@ -0,0 +1,152 @@ +#pragma once + +#include + +#include +#include +#include + +#include "vr/ipc/host_state_v3_reader.hpp" +#include "vr/ipc/protocol.hpp" + +namespace OutRunVR::IpcV3 +{ + struct HostPoseSnapshot + { + std::uint64_t poseId{}; + std::uint32_t hostPid{}; + std::uint32_t referenceSpaceGeneration{}; + bool positionValid{}; + bool stereoValid{}; + float headOrientation[4]{ 0.0f, 0.0f, 0.0f, 1.0f }; + float headPositionMeters[3]{}; + WireEyeView eyes[2]{}; + }; + + inline bool QuaternionSane(const float q[4]) noexcept + { + if (!q) + return false; + const float lengthSq = q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]; + return std::isfinite(q[0]) && std::isfinite(q[1]) && + std::isfinite(q[2]) && std::isfinite(q[3]) && + std::isfinite(lengthSq) && lengthSq > 0.25f && lengthSq < 4.0f; + } + + inline bool Vector3Finite(const float v[3]) noexcept + { + return v && std::isfinite(v[0]) && std::isfinite(v[1]) && std::isfinite(v[2]); + } + + inline bool WireFovValid(const WireFov& fov) noexcept + { + constexpr float limit = 1.56f; + return std::isfinite(fov.angleLeft) && std::isfinite(fov.angleRight) && + std::isfinite(fov.angleUp) && std::isfinite(fov.angleDown) && + fov.angleLeft > -limit && fov.angleRight < limit && + fov.angleDown > -limit && fov.angleUp < limit && + fov.angleRight > fov.angleLeft + 0.05f && + fov.angleUp > fov.angleDown + 0.05f; + } + + inline bool PoseSequenceMatchesLegacy( + std::uint64_t poseId, std::uint32_t legacySequence) noexcept + { + return poseId != 0 && legacySequence != 0 && + static_cast(poseId & 0xFFFFFFFFull) == legacySequence; + } + + inline bool HostStateUsable( + const HostState& state, + std::int64_t nowQpc, + std::int64_t qpcFrequency, + std::int64_t staleMilliseconds = 250) noexcept + { + const std::uint32_t required = HostAlive | OrientationValid | + SessionVisible | HostShouldRender; + if ((state.flags & required) != required || state.hostPid == 0 || state.poseId == 0) + return false; + if (!QuaternionSane(state.headOrientation)) + return false; + if (qpcFrequency <= 0 || state.sampleQpc <= 0 || staleMilliseconds <= 0) + return false; + const std::int64_t age = nowQpc - state.sampleQpc; + const std::int64_t maxAge = (qpcFrequency * staleMilliseconds) / 1000; + if (age < 0 || age > maxAge) + return false; + if ((state.flags & PositionValid) != 0 && !Vector3Finite(state.headPositionMeters)) + return false; + + if ((state.flags & StereoViewsValid) != 0) + { + for (const auto& eye : state.eyes) + { + if (!QuaternionSane(eye.orientation) || !Vector3Finite(eye.positionMeters) || + !WireFovValid(eye.fov)) + return false; + if (std::fabs(eye.positionMeters[0]) > 0.25f || + std::fabs(eye.positionMeters[1]) > 0.25f || + std::fabs(eye.positionMeters[2]) > 0.25f) + return false; + } + } + return true; + } + + class HostPoseV3Source + { + public: + bool Read(HostPoseSnapshot& out) noexcept + { + HostState state{}; + if (!reader_.Read(state)) + return false; + + LARGE_INTEGER now{}; + LARGE_INTEGER frequency{}; + if (!QueryPerformanceCounter(&now) || !QueryPerformanceFrequency(&frequency)) + return false; + if (!HostStateUsable(state, now.QuadPart, frequency.QuadPart)) + return false; + + // The current v3 HostState publisher shadows the same host sample that + // still keys Frame.v2. Never accept a shadow pose that is one host frame + // behind: fall back to the legacy pose for that game frame instead. This + // keeps v3 primary when caught up while guaranteeing no added head latency. + if (legacy_.EnsureOpen(OutRunVR::SharedMemoryName)) + { + OutRunVR::SharedPoseState legacy{}; + if (OutRunVR::Ipc::StableRead(legacy_.Get(), legacy) && + legacy.magic == OutRunVR::SharedMagic && + legacy.protocolVersion == OutRunVR::SharedProtocolVersion && + legacy.structSize == sizeof(legacy) && + legacy.hostPid == state.hostPid && + !PoseSequenceMatchesLegacy(state.poseId, legacy.sequence)) + return false; + } + + out = {}; + out.poseId = state.poseId; + out.hostPid = state.hostPid; + out.referenceSpaceGeneration = state.referenceSpaceGeneration; + out.positionValid = (state.flags & PositionValid) != 0; + out.stereoValid = (state.flags & StereoViewsValid) != 0; + std::copy_n(state.headOrientation, 4, out.headOrientation); + std::copy_n(state.headPositionMeters, 3, out.headPositionMeters); + out.eyes[0] = state.eyes[0]; + out.eyes[1] = state.eyes[1]; + return true; + } + + void Reset() noexcept + { + reader_.Reset(); + legacy_.Reset(); + } + bool IsOpen() const noexcept { return reader_.IsOpen(); } + + private: + HostStateReader reader_{}; + OutRunVR::Ipc::ReadOnlyMapping legacy_{}; + }; +} diff --git a/src/vr/ipc/host_state_v3_reader.hpp b/src/vr/ipc/host_state_v3_reader.hpp new file mode 100644 index 000000000..c05bb020e --- /dev/null +++ b/src/vr/ipc/host_state_v3_reader.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include "vr/ipc/protocol_v3.hpp" +#include "vr/ipc/win32_channel.hpp" + +namespace OutRunVR::IpcV3 +{ + class HostStateReader + { + public: + bool Read(HostState& out) noexcept + { + if (!mapping_.EnsureOpen(HostStateName)) + return false; + if (!Ipc::StableRead(mapping_.Get(), out)) + return false; + return HeaderValid(out); + } + + void Reset() noexcept + { + mapping_.Reset(); + } + + bool IsOpen() const noexcept + { + return mapping_.IsOpen(); + } + + static bool HeaderValid(const HostState& state) noexcept + { + return state.magic == HostMagic && + state.version == ProtocolVersion && + state.structSize == sizeof(HostState); + } + + private: + Ipc::ReadOnlyMapping mapping_{}; + }; +} diff --git a/src/vr/ipc/protocol.hpp b/src/vr/ipc/protocol.hpp new file mode 100644 index 000000000..55465e921 --- /dev/null +++ b/src/vr/ipc/protocol.hpp @@ -0,0 +1,336 @@ +#pragma once + +#include +#include + +namespace OutRunVR +{ + inline constexpr wchar_t SharedMemoryName[] = L"Local\\OutRun2006Tweaks.VR.Pose.v2"; + inline constexpr std::uint32_t SharedMagic = 0x5256524Fu; // 'ORVR' in little endian + inline constexpr std::uint32_t SharedProtocolVersion = 2; + + inline constexpr wchar_t RenderFrameMemoryName[] = L"Local\\OutRun2006Tweaks.VR.Frame.v2"; + inline constexpr std::uint32_t RenderFrameMagic = 0x4656524Fu; // 'ORVF' + inline constexpr std::uint32_t RenderFrameProtocolVersion = 2; + inline constexpr std::uint32_t RenderFrameRingSize = 4; + inline constexpr std::size_t PackedEyeOrientationOffset = 48; + inline constexpr std::size_t PackedEyeOrientationBytes = 16; + + enum SharedFlags : std::uint32_t + { + HostAlive = 1u << 0, + OrientationValid = 1u << 1, + PositionValid = 1u << 2, + SessionVisible = 1u << 3, + SessionFocused = 1u << 4, + StereoViewsValid = 1u << 5, + StereoEyeOrientationValid = 1u << 6, + HostShouldRender = 1u << 7, + HostDirectGpuTransport = 1u << 8, + HostDirectGpuReady = 1u << 9, + HostAdapterLuidValid = 1u << 10, + }; + + inline constexpr std::uint32_t ClientHeartbeatIndex = 0; + inline constexpr std::uint32_t ClientFlagsIndex = 1; + inline constexpr std::uint32_t ClientLastAngleBitsIndex = 2; + inline constexpr std::uint32_t HostReferenceSpaceGenerationIndex = 3; + inline constexpr std::uint32_t ClientPresentationModeIndex = 4; + inline constexpr std::uint32_t ClientGameStateIndex = 5; + inline constexpr std::uint32_t HostEyeOffsetLeftXIndex = 6; + inline constexpr std::uint32_t HostEyeOffsetLeftYIndex = 7; + inline constexpr std::uint32_t HostEyeOffsetLeftZIndex = 8; + inline constexpr std::uint32_t HostEyeOffsetRightXIndex = 9; + inline constexpr std::uint32_t HostEyeOffsetRightYIndex = 10; + inline constexpr std::uint32_t HostEyeOffsetRightZIndex = 11; + inline constexpr std::uint32_t ClientStereoStateIndex = 12; + inline constexpr std::uint32_t ClientStereoFrameIndex = 13; + inline constexpr std::uint32_t ClientStereoBackbufferWidthIndex = 14; + inline constexpr std::uint32_t ClientStereoBackbufferHeightIndex = 15; + // Source-compatibility alias only. Slot 14 remains backbuffer width; + // exact presentation timing is transported by Frame.v1::presentQpc. + inline constexpr std::uint32_t ClientStereoPresentQpcLowIndex = ClientStereoBackbufferWidthIndex; + + enum ClientPresentationMode : std::uint32_t + { + PresentationUnknown = 0, + PresentationGameplay = 1, + PresentationTheater = 2, + }; + + enum ClientStereoState : std::uint32_t + { + StereoDisabled = 0, + StereoSbsActive = 1, + StereoSbsFallbackMono = 2, + }; + + enum ClientTelemetryFlags : std::uint32_t + { + ClientHookAlive = 1u << 0, + ClientHostPoseValid = 1u << 1, + ClientPoseApplied = 1u << 2, + ClientAutoEnabled = 1u << 3, + ClientRendererWvpVerified = 1u << 4, + ClientRendererPoseInjected = 1u << 5, + ClientRendererMatrixPrepared = 1u << 6, + ClientRendererUploadFailed = 1u << 7, + ClientCullingCameraSynced = 1u << 8, + ClientFrameCompleted = 1u << 9, + ClientStereoActive = 1u << 10, + ClientStereoWorldDraw = 1u << 11, + ClientStereoDrawDuplicated = 1u << 12, + }; + + enum RenderFrameFlags : std::uint32_t + { + RenderFrameStereoComplete = 1u << 0, + RenderFrameWorldStereo = 1u << 1, + RenderFrameDrawDuplicated = 1u << 2, + RenderFrameEffectivePoseValid = 1u << 3, + RenderFramePresentInFlight = 1u << 4, + RenderFrameDirectGpuTransport = 1u << 5, + }; + + // Frame.v1 reserved-word extension. The 256-byte ABI stays unchanged. + inline constexpr std::uint32_t RenderFrameDirectLeftHandleIndex = 0; + inline constexpr std::uint32_t RenderFrameDirectRightHandleIndex = 1; + inline constexpr std::uint32_t RenderFrameDirectWidthIndex = 2; + inline constexpr std::uint32_t RenderFrameDirectHeightIndex = 3; + inline constexpr std::uint32_t RenderFrameDirectFormatIndex = 4; + inline constexpr std::uint32_t RenderFrameDirectGenerationIndex = 5; + inline constexpr std::uint32_t RenderFrameDirectSlotIndex = 6; + // Three reserved words carry the producer DLL's 12-character git tag as + // raw ASCII. This keeps the Frame.v2 ABI fixed while making mixed binary + // test sessions obvious in host logs. + inline constexpr std::uint32_t RenderFrameGameBuildTag0Index = 7; + inline constexpr std::uint32_t RenderFrameGameBuildTag1Index = 8; + inline constexpr std::uint32_t RenderFrameGameBuildTag2Index = 9; + // R35 cadence identity. The game stamps the XR pacing request that released + // this game frame. Word 10 was previously unused; Frame.v2 ABI stays fixed. + inline constexpr std::uint32_t RenderFrameCadenceRequestIndex = 10; + + // P0 runtime identity. Word 11 carries a per-game-process generation that + // is mirrored by SharedRenderFrameRing::reserved0. Reusing reserved storage + // keeps the Frame.v2 ABI unchanged while letting every reader reject stale + // slots left behind when the host mapping survives a fast game restart. + inline constexpr std::uint32_t RenderFrameRunGenerationIndex = 11; + + enum StereoFailureReason : std::uint32_t + { + StereoFailureNone = 0, + StereoFailureMissingLatchedPose = 1, + StereoFailureResourceUnavailable = 2, + StereoFailureMrtActive = 3, + StereoFailureViewportUnavailable = 4, + StereoFailureLeftWvpUploadFailed = 5, + StereoFailureLeftDrawFailed = 6, + StereoFailureRightStateFailed = 7, + StereoFailureRightWvpUploadFailed = 8, + StereoFailureRightDrawFailed = 9, + StereoFailureRestoreFailed = 10, + StereoFailureComposeFailed = 11, + StereoFailurePresentFailed = 12, + StereoFailureDepthStateChanged = 13, + StereoFailurePoseSequenceMismatch = 14, + StereoFailureDepthUnsynchronized = 15, + StereoFailureClearFailed = 16, + StereoFailureWorldClassificationFailed = 17, + StereoFailureStencilUnsynchronized = 18, + StereoFailureOffscreenWorld = 19, + StereoFailureOcclusionQueryActive = 20, + }; + +#pragma pack(push, 4) + struct SharedFov + { + float angleLeft; + float angleRight; + float angleUp; + float angleDown; + }; + + struct SharedPoseState + { + std::uint32_t magic; + std::uint32_t protocolVersion; + std::uint32_t structSize; + volatile std::uint32_t sequence; + volatile std::uint32_t hostPid; + volatile std::uint32_t clientPid; + volatile std::uint32_t flags; + volatile std::uint32_t heartbeat; + std::int64_t sampleQpc; + float orientation[4]; + float position[3]; + volatile std::uint32_t clientStereoPoseSequence; + SharedFov eyeFov[2]; + std::uint32_t recommendedWidth[2]; + std::uint32_t recommendedHeight[2]; + std::uint32_t hostAdapterLuidLow; + std::uint32_t hostAdapterLuidHigh; + volatile std::uint32_t clientAdapterLuidLow; + volatile std::uint32_t clientAdapterLuidHigh; + volatile std::uint32_t clientInteropProbeHandle; + volatile std::uint32_t clientInteropProbeToken; + volatile std::uint32_t hostInteropProbeAckToken; + volatile std::uint32_t hostDirectConsumedFrameId; + char runtimeName[64]; + std::uint32_t reserved[16]; + }; + + struct SharedRenderEye + { + float orientation[4]; + float position[3]; + float reserved0; + SharedFov fov; + }; + + struct SharedRenderFrameState + { + std::uint32_t magic; + std::uint32_t protocolVersion; + std::uint32_t structSize; + volatile std::uint32_t sequence; + volatile std::uint32_t clientPid; + std::uint32_t state; + std::uint32_t frameId; + std::uint32_t sourcePoseSequence; + std::uint32_t presentationMode; + std::uint32_t flags; + std::uint32_t failureReason; + std::uint32_t backbufferWidth; + std::uint32_t backbufferHeight; + std::int64_t presentQpc; + SharedRenderEye eye[2]; + std::uint32_t reserved[25]; + }; + + struct SharedRenderFrameRing + { + std::uint32_t magic; + std::uint32_t protocolVersion; + std::uint32_t structSize; + std::uint32_t slotCount; + volatile std::uint32_t publishSequence; + volatile std::uint32_t latestSlot; + volatile std::uint32_t clientPid; + // P0: per-game-process run generation. Each valid slot mirrors this in + // reserved[RenderFrameRunGenerationIndex]. + std::uint32_t reserved0; + SharedRenderFrameState slots[RenderFrameRingSize]; + }; +#pragma pack(pop) + + inline bool RenderFrameRunIdentityMatches( + const SharedRenderFrameRing& ring, + const SharedRenderFrameState& frame) noexcept + { + return ring.clientPid != 0 && + ring.reserved0 != 0 && + frame.clientPid == ring.clientPid && + frame.reserved[RenderFrameRunGenerationIndex] == ring.reserved0; + } + + static_assert(sizeof(SharedFov) == 16); + static_assert(sizeof(SharedPoseState) == 280); + static_assert(offsetof(SharedPoseState, recommendedWidth) == 104); + static_assert(offsetof(SharedPoseState, recommendedHeight) == 112); + static_assert(offsetof(SharedPoseState, hostAdapterLuidLow) == 120); + static_assert(offsetof(SharedPoseState, runtimeName) == 152); + static_assert(offsetof(SharedPoseState, reserved) == 216); + static_assert(PackedEyeOrientationOffset + PackedEyeOrientationBytes == 64); + static_assert(sizeof(SharedRenderEye) == 48); + static_assert(sizeof(SharedRenderFrameState) == 256); + static_assert(sizeof(SharedRenderFrameRing) == 1056); +} + +namespace OutRunVRRenderer +{ + using OutRunVR::SharedMemoryName; + using OutRunVR::SharedMagic; + using OutRunVR::SharedProtocolVersion; + using OutRunVR::SharedPoseState; + using OutRunVR::SharedFov; + using OutRunVR::HostAlive; + using OutRunVR::OrientationValid; + using OutRunVR::PositionValid; + using OutRunVR::StereoViewsValid; + using OutRunVR::StereoEyeOrientationValid; + using OutRunVR::SessionVisible; + using OutRunVR::HostShouldRender; + using OutRunVR::ClientHeartbeatIndex; + using OutRunVR::ClientFlagsIndex; + using OutRunVR::ClientLastAngleBitsIndex; + using OutRunVR::HostReferenceSpaceGenerationIndex; + using OutRunVR::ClientPresentationModeIndex; + using OutRunVR::ClientGameStateIndex; + using OutRunVR::HostEyeOffsetLeftXIndex; + using OutRunVR::HostEyeOffsetLeftYIndex; + using OutRunVR::HostEyeOffsetLeftZIndex; + using OutRunVR::HostEyeOffsetRightXIndex; + using OutRunVR::HostEyeOffsetRightYIndex; + using OutRunVR::HostEyeOffsetRightZIndex; + using OutRunVR::ClientStereoStateIndex; + using OutRunVR::ClientStereoFrameIndex; + using OutRunVR::ClientStereoPresentQpcLowIndex; + using OutRunVR::ClientStereoBackbufferWidthIndex; + using OutRunVR::ClientStereoBackbufferHeightIndex; + using OutRunVR::ClientPresentationMode; + using OutRunVR::PresentationUnknown; + using OutRunVR::PresentationGameplay; + using OutRunVR::PresentationTheater; + using OutRunVR::StereoDisabled; + using OutRunVR::StereoSbsActive; + using OutRunVR::StereoSbsFallbackMono; + using OutRunVR::ClientHookAlive; + using OutRunVR::ClientHostPoseValid; + using OutRunVR::ClientPoseApplied; + using OutRunVR::ClientAutoEnabled; + using OutRunVR::ClientRendererWvpVerified; + using OutRunVR::ClientRendererPoseInjected; + using OutRunVR::ClientRendererMatrixPrepared; + using OutRunVR::ClientRendererUploadFailed; + using OutRunVR::ClientCullingCameraSynced; + using OutRunVR::ClientFrameCompleted; + using OutRunVR::ClientStereoActive; + using OutRunVR::ClientStereoWorldDraw; + using OutRunVR::ClientStereoDrawDuplicated; + + struct LatchedStereoFrame + { + bool valid = false; + std::uint32_t poseSequence = 0; + SharedFov eyeFov[2]{}; + float eyeOffset[2][3]{}; + float eyeOrientation[2][4]{}; + float effectiveEyeOrientation[2][4]{}; + float effectiveEyePosition[2][3]{}; + }; + + bool GetLatchedStereoFrame(LatchedStereoFrame& out); + bool GetLatchedHeadInverse(float outMatrix[16], + std::uint32_t& poseSequence) noexcept; + bool GetRendererBaseProjection(float outMatrix[16]); + bool GetLastVerifiedWvp(float outConstants[16], std::uint32_t& generation, + std::uint32_t& poseSequence, std::uintptr_t& shaderIdentity, + std::uint64_t& shaderSerial); + bool GetLastGameWvpWrite(float outConstants[16], std::uint64_t& writeSerial, + std::uint64_t& topLevelDrawSerial, std::uintptr_t& shaderIdentity, + std::uint64_t& shaderSerial) noexcept; + std::uint64_t GetBeginSceneCallCount(); + void NotifyGamePresent(); + std::uint32_t GetActiveCadenceRequestId() noexcept; + bool IsCadencePacingActive() noexcept; + void NotifyGameReset(); +} + +namespace OutRunVRStereo +{ + bool IsInternalStereoPassActive(); + bool IsGameStateBlockRecording() noexcept; + bool IsStateBlockTrackingReliable() noexcept; + bool GetCurrentShaderEpoch(std::uintptr_t& shaderIdentity, std::uint64_t& serial); + std::uint64_t GetTopLevelDrawSerial() noexcept; +} diff --git a/src/vr/ipc/protocol_v3.hpp b/src/vr/ipc/protocol_v3.hpp new file mode 100644 index 000000000..0c73ff811 --- /dev/null +++ b/src/vr/ipc/protocol_v3.hpp @@ -0,0 +1,170 @@ +#pragma once + +#include "vr/core/frame_types.hpp" +#include "vr/core/transport.hpp" + +#include +#include +#include + +namespace OutRunVR::IpcV3 +{ + inline constexpr std::uint32_t ProtocolVersion = 3; + inline constexpr std::uint32_t RingSize = 4; + using WireHandle = std::uint64_t; + + inline constexpr wchar_t HostStateName[] = L"Local\\OutRun2006Tweaks.VR.v3.HostState"; + inline constexpr wchar_t ClientStateName[] = L"Local\\OutRun2006Tweaks.VR.v3.ClientState"; + inline constexpr wchar_t FrameRingName[] = L"Local\\OutRun2006Tweaks.VR.v3.FrameRing"; + inline constexpr wchar_t AckStateName[] = L"Local\\OutRun2006Tweaks.VR.v3.AckState"; + + inline constexpr std::uint32_t HostMagic = 0x3348524Fu; // ORH3 + inline constexpr std::uint32_t ClientMagic = 0x3343524Fu; // ORC3 + inline constexpr std::uint32_t FrameMagic = 0x3346524Fu; // ORF3 + inline constexpr std::uint32_t AckMagic = 0x3341524Fu; // ORA3 + + enum HostFlags : std::uint32_t + { + HostAlive = 1u << 0, + OrientationValid = 1u << 1, + PositionValid = 1u << 2, + SessionVisible = 1u << 3, + SessionFocused = 1u << 4, + StereoViewsValid = 1u << 5, + HostShouldRender = 1u << 6, + DirectGpuTransportSupported = 1u << 7, + DirectGpuTransportReady = 1u << 8, + AdapterLuidValid = 1u << 9, + }; + +#pragma pack(push, 4) + struct WireFov + { + float angleLeft{}; + float angleRight{}; + float angleUp{}; + float angleDown{}; + }; + + struct WireEyeView + { + float orientation[4]{}; + float positionMeters[3]{}; + float reserved{}; + WireFov fov{}; + }; + + struct HostState + { + std::uint32_t magic{HostMagic}; + std::uint32_t version{ProtocolVersion}; + std::uint32_t structSize{}; + volatile std::uint32_t sequence{}; + std::uint32_t hostPid{}; + std::uint32_t flags{}; + std::uint32_t referenceSpaceGeneration{}; + std::uint32_t directTransportGeneration{}; + std::uint64_t poseId{}; + std::int64_t predictedDisplayTime{}; + std::int64_t sampleQpc{}; + float headOrientation[4]{}; + float headPositionMeters[3]{}; + std::uint32_t recommendedWidth[2]{}; + std::uint32_t recommendedHeight[2]{}; + std::uint32_t adapterLuidLow{}; + std::int32_t adapterLuidHigh{}; + WireEyeView eyes[2]{}; + char runtimeName[64]{}; + }; + + struct ClientState + { + std::uint32_t magic{ClientMagic}; + std::uint32_t version{ProtocolVersion}; + std::uint32_t structSize{}; + volatile std::uint32_t sequence{}; + std::uint32_t clientPid{}; + std::uint32_t flags{}; + std::uint32_t presentationMode{}; + std::uint32_t gameState{}; + std::uint32_t stereoState{}; + std::uint32_t lastFailure{}; + std::uint32_t adapterLuidLow{}; + std::int32_t adapterLuidHigh{}; + std::uint32_t interopProbeGeneration{}; + std::uint32_t interopProbeToken{}; + WireHandle interopProbeHandle{}; + std::uint32_t backbufferWidth{}; + std::uint32_t backbufferHeight{}; + std::uint64_t lastPresentedFrameId{}; + std::uint64_t lastRenderedPoseId{}; + }; + + struct FrameDescriptor + { + std::uint64_t frameId{}; + std::uint64_t renderPoseId{}; + std::int64_t presentQpc{}; + std::uint32_t presentationMode{}; + std::uint32_t flags{}; + std::uint32_t failureReason{}; + std::uint32_t transportKind{}; + std::uint32_t transportGeneration{}; + std::uint32_t transportSlot{}; + std::uint32_t width{}; + std::uint32_t height{}; + std::uint32_t format{}; + WireHandle leftHandle{}; + WireHandle rightHandle{}; + WireEyeView renderedEyes[2]{}; + }; + + struct FrameRing + { + std::uint32_t magic{FrameMagic}; + std::uint32_t version{ProtocolVersion}; + std::uint32_t structSize{}; + std::uint32_t slotCount{RingSize}; + volatile std::uint32_t publishSequence{}; + volatile std::uint32_t latestSlot{}; + std::uint32_t producerPid{}; + std::uint32_t generation{}; + FrameDescriptor slots[RingSize]{}; + }; + + struct AckState + { + std::uint32_t magic{AckMagic}; + std::uint32_t version{ProtocolVersion}; + std::uint32_t structSize{}; + volatile std::uint32_t sequence{}; + std::uint32_t consumerPid{}; + std::uint32_t acceptedProbeGeneration{}; + std::uint32_t acceptedProbeToken{}; + std::uint32_t transportGeneration{}; + std::uint64_t consumedFrameId{}; + std::uint32_t consumedSlot{}; + std::uint32_t flags{}; + }; +#pragma pack(pop) + + template + constexpr void InitializeWireState(T& state) noexcept + { + state.structSize = static_cast(sizeof(T)); + } + + static_assert(sizeof(WireHandle) == 8); + static_assert(sizeof(WireFov) == 16); + static_assert(sizeof(WireEyeView) == 48); + static_assert(sizeof(HostState) == 268); + static_assert(sizeof(ClientState) == 88); + static_assert(sizeof(FrameDescriptor) == 172); + static_assert(sizeof(FrameRing) == 720); + static_assert(sizeof(AckState) == 48); + static_assert(std::is_standard_layout_v && std::is_trivially_copyable_v); + static_assert(std::is_standard_layout_v && std::is_trivially_copyable_v); + static_assert(std::is_standard_layout_v && std::is_trivially_copyable_v); + static_assert(std::is_standard_layout_v && std::is_trivially_copyable_v); + static_assert(std::is_standard_layout_v && std::is_trivially_copyable_v); +} diff --git a/src/vr/ipc/protocol_v3_smoke.cpp b/src/vr/ipc/protocol_v3_smoke.cpp new file mode 100644 index 000000000..898801aee --- /dev/null +++ b/src/vr/ipc/protocol_v3_smoke.cpp @@ -0,0 +1,17 @@ +#include "vr/ipc/protocol_v3.hpp" + +#include + +namespace +{ + using namespace OutRunVR::IpcV3; + + static_assert(ProtocolVersion == 3); + static_assert(RingSize == 4); + static_assert(sizeof(WireHandle) == 8); + static_assert(offsetof(HostState, poseId) % 4 == 0); + static_assert(offsetof(ClientState, interopProbeHandle) % 4 == 0); + static_assert(offsetof(FrameDescriptor, leftHandle) % 4 == 0); + static_assert(offsetof(FrameDescriptor, renderedEyes) > offsetof(FrameDescriptor, rightHandle)); + static_assert(sizeof(FrameRing) == 32 + sizeof(FrameDescriptor) * RingSize); +} diff --git a/src/vr/ipc/recenter_request.hpp b/src/vr/ipc/recenter_request.hpp new file mode 100644 index 000000000..6dbd70379 --- /dev/null +++ b/src/vr/ipc/recenter_request.hpp @@ -0,0 +1,135 @@ +#pragma once + +// Cross-process F10 recenter request channel shared by the 32-bit game hook and +// the 64-bit OpenXR host. This is intentionally independent of the v2/v3 pose +// ABI so a user-input control message cannot perturb render metadata layout. + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include + +namespace OutRunVR::RecenterIpc +{ + inline constexpr wchar_t MappingName[] = + L"Local\\OutRun2006Tweaks.VR.Recenter.v1"; + inline constexpr std::uint32_t Magic = 0x5243564Fu; // 'OVCR' + inline constexpr std::uint32_t Version = 1; + + struct State + { + std::uint32_t magic = 0; + std::uint32_t version = 0; + std::uint32_t structSize = 0; + volatile LONG requestId = 0; + volatile LONG receivedId = 0; + volatile LONG appliedId = 0; + volatile LONG requesterPid = 0; + }; + static_assert(sizeof(State) == 28); + + class Channel + { + public: + bool Ensure() noexcept + { + if (state_) + return state_->magic == Magic && state_->version == Version && + state_->structSize == sizeof(State); + + HANDLE mapping = CreateFileMappingW(INVALID_HANDLE_VALUE, nullptr, + PAGE_READWRITE, 0, static_cast(sizeof(State)), MappingName); + if (!mapping) + return false; + const bool created = GetLastError() != ERROR_ALREADY_EXISTS; + + auto* state = static_cast(MapViewOfFile(mapping, + FILE_MAP_ALL_ACCESS, 0, 0, sizeof(State))); + if (!state) + { + CloseHandle(mapping); + return false; + } + + if (created) + { + ZeroMemory(state, sizeof(State)); + state->magic = Magic; + state->version = Version; + state->structSize = sizeof(State); + MemoryBarrier(); + } + else if (state->magic != Magic || state->version != Version || + state->structSize != sizeof(State)) + { + UnmapViewOfFile(state); + CloseHandle(mapping); + return false; + } + + mapping_ = mapping; + state_ = state; + return true; + } + + LONG Publish() noexcept + { + if (!Ensure()) + return 0; + InterlockedExchange(&state_->requesterPid, + static_cast(GetCurrentProcessId())); + MemoryBarrier(); + return InterlockedIncrement(&state_->requestId); + } + + bool Pending(LONG& requestId, DWORD& requesterPid) noexcept + { + requestId = 0; + requesterPid = 0; + if (!Ensure()) + return false; + const LONG requested = InterlockedCompareExchange( + &state_->requestId, 0, 0); + const LONG received = InterlockedCompareExchange( + &state_->receivedId, 0, 0); + if (requested == 0 || requested == received) + return false; + requestId = requested; + requesterPid = static_cast(InterlockedCompareExchange( + &state_->requesterPid, 0, 0)); + return true; + } + + void MarkReceived(LONG requestId) noexcept + { + if (requestId != 0 && Ensure()) + InterlockedExchange(&state_->receivedId, requestId); + } + + void MarkApplied(LONG requestId) noexcept + { + if (requestId != 0 && Ensure()) + InterlockedExchange(&state_->appliedId, requestId); + } + + LONG AppliedId() noexcept + { + return Ensure() ? + InterlockedCompareExchange(&state_->appliedId, 0, 0) : 0; + } + + private: + HANDLE mapping_ = nullptr; + State* state_ = nullptr; + }; + + inline Channel& SharedChannel() noexcept + { + static Channel channel; + return channel; + } +} diff --git a/src/vr/ipc/shadow_legacy_v2.hpp b/src/vr/ipc/shadow_legacy_v2.hpp new file mode 100644 index 000000000..17b0ae76f --- /dev/null +++ b/src/vr/ipc/shadow_legacy_v2.hpp @@ -0,0 +1,294 @@ +#pragma once + +#include "vr/ipc/protocol.hpp" +#include "vr/ipc/protocol_v3.hpp" +#include "vr/ipc/win32_channel.hpp" + +#include + +#include +#include +#include +#include + +namespace OutRunVR::IpcV3::ShadowV2 +{ + inline bool LegacyPoseHeaderValid(const SharedPoseState& state) noexcept + { + return state.magic == SharedMagic && + state.protocolVersion == SharedProtocolVersion && + state.structSize == sizeof(SharedPoseState); + } + + inline bool LegacyFrameHeaderValid(const SharedRenderFrameRing& ring) noexcept + { + return ring.magic == RenderFrameMagic && + ring.protocolVersion == RenderFrameProtocolVersion && + ring.structSize == sizeof(SharedRenderFrameRing) && + ring.slotCount == RenderFrameRingSize; + } + + inline float FloatFromBits(std::uint32_t bits) noexcept + { + float value = 0.0f; + std::memcpy(&value, &bits, sizeof(value)); + return value; + } + + inline std::int32_t SignedBits(std::uint32_t bits) noexcept + { + std::int32_t value = 0; + std::memcpy(&value, &bits, sizeof(value)); + return value; + } + + inline bool FiniteQuaternion(const float q[4]) noexcept + { + if (!q) + return false; + float lengthSq = 0.0f; + for (int i = 0; i < 4; ++i) + { + if (!std::isfinite(q[i])) + return false; + lengthSq += q[i] * q[i]; + } + return std::isfinite(lengthSq) && lengthSq > 0.25f && lengthSq < 4.0f; + } + + inline void NormalizeQuaternion(float q[4]) noexcept + { + float lengthSq = 0.0f; + for (int i = 0; i < 4; ++i) + lengthSq += q[i] * q[i]; + if (!std::isfinite(lengthSq) || lengthSq <= 1.0e-12f) + { + q[0] = q[1] = q[2] = 0.0f; + q[3] = 1.0f; + return; + } + const float invLength = 1.0f / std::sqrt(lengthSq); + for (int i = 0; i < 4; ++i) + q[i] *= invLength; + } + + inline bool DecodePackedEyeOrientations(const SharedPoseState& snapshot, float out[2][4]) noexcept + { + std::int16_t packed[8]{}; + static_assert(sizeof(packed) == PackedEyeOrientationBytes); + std::memcpy(packed, snapshot.runtimeName + PackedEyeOrientationOffset, sizeof(packed)); + for (int eye = 0; eye < 2; ++eye) + { + for (int c = 0; c < 4; ++c) + out[eye][c] = static_cast(packed[eye * 4 + c]) / 32767.0f; + if (!FiniteQuaternion(out[eye])) + return false; + NormalizeQuaternion(out[eye]); + } + return true; + } + + inline bool StableReadPose(const SharedPoseState* shared, SharedPoseState& out, int attempts = 8) noexcept + { + if (!shared || attempts <= 0) + return false; + for (int attempt = 0; attempt < attempts; ++attempt) + { + const std::uint32_t before = shared->sequence; + if (before & 1u) + continue; + MemoryBarrier(); + std::memcpy(&out, shared, sizeof(out)); + MemoryBarrier(); + const std::uint32_t after = shared->sequence; + if (before == after && !(after & 1u) && LegacyPoseHeaderValid(out)) + return true; + } + return false; + } + + inline bool StableReadFrameRing(const SharedRenderFrameRing* shared, SharedRenderFrameRing& out, + int attempts = 8) noexcept + { + if (!shared || attempts <= 0) + return false; + for (int attempt = 0; attempt < attempts; ++attempt) + { + const std::uint32_t before = shared->publishSequence; + if (before & 1u) + continue; + MemoryBarrier(); + std::memcpy(&out, shared, sizeof(out)); + MemoryBarrier(); + const std::uint32_t after = shared->publishSequence; + if (before == after && !(after & 1u) && LegacyFrameHeaderValid(out)) + return true; + } + return false; + } + + inline bool LatestFrame(const SharedRenderFrameRing& ring, SharedRenderFrameState& out) noexcept + { + if (!LegacyFrameHeaderValid(ring)) + return false; + const std::uint32_t slot = ring.latestSlot; + if (slot >= RenderFrameRingSize) + return false; + out = ring.slots[slot]; + return out.magic == RenderFrameMagic && + out.protocolVersion == RenderFrameProtocolVersion && + out.structSize == sizeof(SharedRenderFrameState) && + !(out.sequence & 1u) && + RenderFrameRunIdentityMatches(ring, out) && + out.frameId != 0; + } + + inline std::uint32_t HostFlagsFromV2(std::uint32_t flags) noexcept + { + std::uint32_t out = 0; + if (flags & OutRunVR::HostAlive) out |= IpcV3::HostAlive; + if (flags & OutRunVR::OrientationValid) out |= IpcV3::OrientationValid; + if (flags & OutRunVR::PositionValid) out |= IpcV3::PositionValid; + if (flags & OutRunVR::SessionVisible) out |= IpcV3::SessionVisible; + if (flags & OutRunVR::SessionFocused) out |= IpcV3::SessionFocused; + if (flags & OutRunVR::StereoViewsValid) out |= IpcV3::StereoViewsValid; + if (flags & OutRunVR::HostShouldRender) out |= IpcV3::HostShouldRender; + if (flags & OutRunVR::HostDirectGpuTransport) out |= IpcV3::DirectGpuTransportSupported; + if (flags & OutRunVR::HostDirectGpuReady) out |= IpcV3::DirectGpuTransportReady; + if (flags & OutRunVR::HostAdapterLuidValid) out |= IpcV3::AdapterLuidValid; + return out; + } + + inline HostState HostStateFromV2(const SharedPoseState& source, + std::uint32_t directTransportGeneration = 0) noexcept + { + HostState out{}; + InitializeWireState(out); + out.hostPid = source.hostPid; + out.flags = HostFlagsFromV2(source.flags); + out.referenceSpaceGeneration = source.reserved[HostReferenceSpaceGenerationIndex]; + out.directTransportGeneration = directTransportGeneration; + out.poseId = source.sequence; + out.predictedDisplayTime = 0; + out.sampleQpc = source.sampleQpc; + std::memcpy(out.headOrientation, source.orientation, sizeof(out.headOrientation)); + std::memcpy(out.headPositionMeters, source.position, sizeof(out.headPositionMeters)); + for (int eye = 0; eye < 2; ++eye) + { + out.recommendedWidth[eye] = source.recommendedWidth[eye]; + out.recommendedHeight[eye] = source.recommendedHeight[eye]; + out.eyes[eye].fov.angleLeft = source.eyeFov[eye].angleLeft; + out.eyes[eye].fov.angleRight = source.eyeFov[eye].angleRight; + out.eyes[eye].fov.angleUp = source.eyeFov[eye].angleUp; + out.eyes[eye].fov.angleDown = source.eyeFov[eye].angleDown; + } + out.adapterLuidLow = source.hostAdapterLuidLow; + out.adapterLuidHigh = SignedBits(source.hostAdapterLuidHigh); + + out.eyes[0].positionMeters[0] = FloatFromBits(source.reserved[HostEyeOffsetLeftXIndex]); + out.eyes[0].positionMeters[1] = FloatFromBits(source.reserved[HostEyeOffsetLeftYIndex]); + out.eyes[0].positionMeters[2] = FloatFromBits(source.reserved[HostEyeOffsetLeftZIndex]); + out.eyes[1].positionMeters[0] = FloatFromBits(source.reserved[HostEyeOffsetRightXIndex]); + out.eyes[1].positionMeters[1] = FloatFromBits(source.reserved[HostEyeOffsetRightYIndex]); + out.eyes[1].positionMeters[2] = FloatFromBits(source.reserved[HostEyeOffsetRightZIndex]); + + float eyeOrientation[2][4]{}; + if ((source.flags & StereoEyeOrientationValid) && DecodePackedEyeOrientations(source, eyeOrientation)) + { + std::memcpy(out.eyes[0].orientation, eyeOrientation[0], sizeof(out.eyes[0].orientation)); + std::memcpy(out.eyes[1].orientation, eyeOrientation[1], sizeof(out.eyes[1].orientation)); + } + else + { + out.eyes[0].orientation[3] = 1.0f; + out.eyes[1].orientation[3] = 1.0f; + } + + constexpr std::size_t RuntimeTextBytes = PackedEyeOrientationOffset; + const std::size_t copyBytes = (std::min)(RuntimeTextBytes, sizeof(out.runtimeName) - 1u); + std::memcpy(out.runtimeName, source.runtimeName, copyBytes); + out.runtimeName[copyBytes] = '\0'; + return out; + } + + inline FrameDescriptor FrameDescriptorFromV2(const SharedRenderFrameState& source) noexcept + { + FrameDescriptor out{}; + out.frameId = source.frameId; + out.renderPoseId = source.sourcePoseSequence; + out.presentQpc = source.presentQpc; + out.presentationMode = source.presentationMode; + out.flags = source.flags; + out.failureReason = source.failureReason; + + const bool direct = (source.flags & RenderFrameDirectGpuTransport) != 0; + out.transportKind = static_cast(direct + ? Core::TransportKind::D3D9ExShared + : Core::TransportKind::DesktopDuplication); + out.transportGeneration = direct ? source.reserved[RenderFrameDirectGenerationIndex] : 0; + out.transportSlot = direct ? source.reserved[RenderFrameDirectSlotIndex] : 0; + out.width = direct && source.reserved[RenderFrameDirectWidthIndex] + ? source.reserved[RenderFrameDirectWidthIndex] : source.backbufferWidth; + out.height = direct && source.reserved[RenderFrameDirectHeightIndex] + ? source.reserved[RenderFrameDirectHeightIndex] : source.backbufferHeight; + out.format = direct ? source.reserved[RenderFrameDirectFormatIndex] : 0; + out.leftHandle = direct ? static_cast(source.reserved[RenderFrameDirectLeftHandleIndex]) : 0; + out.rightHandle = direct ? static_cast(source.reserved[RenderFrameDirectRightHandleIndex]) : 0; + + for (int eye = 0; eye < 2; ++eye) + { + std::memcpy(out.renderedEyes[eye].orientation, source.eye[eye].orientation, + sizeof(out.renderedEyes[eye].orientation)); + std::memcpy(out.renderedEyes[eye].positionMeters, source.eye[eye].position, + sizeof(out.renderedEyes[eye].positionMeters)); + out.renderedEyes[eye].fov.angleLeft = source.eye[eye].fov.angleLeft; + out.renderedEyes[eye].fov.angleRight = source.eye[eye].fov.angleRight; + out.renderedEyes[eye].fov.angleUp = source.eye[eye].fov.angleUp; + out.renderedEyes[eye].fov.angleDown = source.eye[eye].fov.angleDown; + } + return out; + } + + inline ClientState ClientStateFromV2(const SharedPoseState& pose, + const SharedRenderFrameState* latestFrame) noexcept + { + ClientState out{}; + InitializeWireState(out); + out.clientPid = pose.clientPid; + out.flags = pose.reserved[ClientFlagsIndex]; + out.presentationMode = pose.reserved[ClientPresentationModeIndex]; + out.gameState = pose.reserved[ClientGameStateIndex]; + out.stereoState = pose.reserved[ClientStereoStateIndex]; + out.adapterLuidLow = pose.clientAdapterLuidLow; + out.adapterLuidHigh = SignedBits(pose.clientAdapterLuidHigh); + out.interopProbeGeneration = 0; + out.interopProbeToken = pose.clientInteropProbeToken; + out.interopProbeHandle = static_cast(pose.clientInteropProbeHandle); + out.backbufferWidth = pose.reserved[ClientStereoBackbufferWidthIndex]; + out.backbufferHeight = pose.reserved[ClientStereoBackbufferHeightIndex]; + if (latestFrame) + { + out.lastFailure = latestFrame->failureReason; + out.lastPresentedFrameId = latestFrame->frameId; + out.lastRenderedPoseId = latestFrame->sourcePoseSequence; + } + return out; + } + + inline AckState AckStateFromV2(const SharedPoseState& pose, + const SharedRenderFrameState* latestFrame, std::uint32_t consumerPid) noexcept + { + AckState out{}; + InitializeWireState(out); + out.consumerPid = consumerPid; + out.acceptedProbeGeneration = 0; + out.acceptedProbeToken = pose.hostInteropProbeAckToken; + out.consumedFrameId = pose.hostDirectConsumedFrameId; + if (latestFrame && latestFrame->frameId == pose.hostDirectConsumedFrameId) + { + out.transportGeneration = latestFrame->reserved[RenderFrameDirectGenerationIndex]; + out.consumedSlot = latestFrame->reserved[RenderFrameDirectSlotIndex]; + } + return out; + } +} diff --git a/src/vr/ipc/shadow_lifetime_bridge.hpp b/src/vr/ipc/shadow_lifetime_bridge.hpp new file mode 100644 index 000000000..204efbed7 --- /dev/null +++ b/src/vr/ipc/shadow_lifetime_bridge.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include + +namespace OutRunVR::IpcV3 +{ + using ShadowBridgeStopCallback = void(*)() noexcept; + + // Header-only registration keeps the common proxy DLL independent of the + // optional VR shadow-bridge translation unit. Generic wheel builds leave + // this slot null; VR builds register their process-lifetime stop callback. + // Release/acquire publication guarantees DllMain sees either no callback or + // the fully published VR callback without linking the optional VR worker TU. + inline std::atomic ShadowBridgeStopCallbackSlot{nullptr}; + + inline void RegisterShadowBridgeStopCallback(ShadowBridgeStopCallback callback) noexcept + { + ShadowBridgeStopCallbackSlot.store(callback, std::memory_order_release); + } + + inline void RequestRegisteredShadowBridgeStop() noexcept + { + if (const auto callback = ShadowBridgeStopCallbackSlot.load(std::memory_order_acquire)) + callback(); + } +} diff --git a/src/vr/ipc/v3_game_shadow_bridge.cpp b/src/vr/ipc/v3_game_shadow_bridge.cpp new file mode 100644 index 000000000..119e46d40 --- /dev/null +++ b/src/vr/ipc/v3_game_shadow_bridge.cpp @@ -0,0 +1,642 @@ +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include + +#include +#include +#include +#include +#include +#include + +#include + +#include "hook_mgr.hpp" +#include "vr/ipc/host_state_v3_reader.hpp" +#include "vr/ipc/protocol.hpp" +#include "vr/ipc/protocol_v3.hpp" +#include "vr/ipc/shadow_legacy_v2.hpp" +#include "vr/ipc/win32_channel.hpp" +#include "vr/ipc/shadow_lifetime_bridge.hpp" + +namespace OutRunVR::IpcV3 +{ + void RequestShadowBridgeStop() noexcept; + + namespace + { + constexpr DWORD ShadowIdlePollMs = 8; + constexpr DWORD ShadowRetryMs = 250; + + enum class ProcessLiveness : std::uint8_t + { + Dead, + Alive, + Unknown + }; + + ProcessLiveness QueryProcessLiveness(DWORD pid) noexcept + { + if (!pid) + return ProcessLiveness::Dead; + HANDLE process = OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if (!process) + { + return GetLastError() == ERROR_INVALID_PARAMETER + ? ProcessLiveness::Dead + : ProcessLiveness::Unknown; + } + const DWORD wait = WaitForSingleObject(process, 0); + CloseHandle(process); + if (wait == WAIT_TIMEOUT) + return ProcessLiveness::Alive; + if (wait == WAIT_OBJECT_0) + return ProcessLiveness::Dead; + return ProcessLiveness::Unknown; + } + + std::atomic ShadowBridgeStop{false}; + HANDLE ShadowBridgeStopEvent = nullptr; + HANDLE ShadowBridgeThreadHandle = nullptr; + + bool ShadowBridgeWait(DWORD timeoutMs) noexcept + { + if (ShadowBridgeStop.load(std::memory_order_acquire)) + return true; + if (!ShadowBridgeStopEvent) + { + Sleep(timeoutMs); + return ShadowBridgeStop.load(std::memory_order_acquire); + } + return WaitForSingleObject(ShadowBridgeStopEvent, timeoutMs) == WAIT_OBJECT_0; + } + + class ClientStateWriter + { + public: + ClientStateWriter() + { + mapping_ = CreateFileMappingW(INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE, 0, + static_cast(sizeof(ClientState)), ClientStateName); + if (!mapping_) + return; + const bool existed = GetLastError() == ERROR_ALREADY_EXISTS; + state_ = static_cast(MapViewOfFile( + mapping_, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(ClientState))); + if (!state_) + { + Reset(); + return; + } + if (!existed) + { + std::memset(state_, 0, sizeof(*state_)); + state_->version = ProtocolVersion; + state_->structSize = sizeof(*state_); + MemoryBarrier(); + state_->magic = ClientMagic; + } + else + { + for (int i = 0; i < 100 && !HeaderValid(); ++i) + Sleep(2); + if (!HeaderValid()) + { + Reset(); + return; + } + } + owns_ = AcquireOwner(state_->clientPid); + } + + ~ClientStateWriter() + { + if (state_ && owns_ && state_->clientPid == GetCurrentProcessId()) + { + const std::uint32_t odd = Ipc::BeginSeqlockWrite(state_->sequence); + state_->sequence = odd; + state_->flags = 0; + state_->clientPid = 0; + Ipc::EndSeqlockWrite(state_->sequence); + } + Reset(); + } + + bool Ready() const noexcept { return state_ && owns_; } + + std::uint32_t Publish(ClientState next) noexcept + { + if (!Ready()) + return 0; + next.magic = ClientMagic; + next.version = ProtocolVersion; + next.structSize = sizeof(ClientState); + next.clientPid = GetCurrentProcessId(); + const std::uint32_t odd = Ipc::BeginSeqlockWrite(state_->sequence); + next.sequence = odd; + std::memcpy(state_, &next, sizeof(next)); + MemoryBarrier(); + return Ipc::EndSeqlockWrite(state_->sequence); + } + + private: + bool HeaderValid() const noexcept + { + return state_ && state_->magic == ClientMagic && + state_->version == ProtocolVersion && + state_->structSize == sizeof(ClientState); + } + + bool AcquireOwner(volatile std::uint32_t& pid) noexcept + { + const LONG self = static_cast(GetCurrentProcessId()); + for (int i = 0; i < 100; ++i) + { + const LONG observed = static_cast(pid); + if (observed == self) + return true; + if (observed != 0 && + QueryProcessLiveness(static_cast(observed)) != ProcessLiveness::Dead) + return false; + if (InterlockedCompareExchange( + reinterpret_cast(&pid), self, observed) == observed) + return true; + Sleep(1); + } + return false; + } + + void Reset() noexcept + { + if (state_) + { + UnmapViewOfFile(state_); + state_ = nullptr; + } + if (mapping_) + { + CloseHandle(mapping_); + mapping_ = nullptr; + } + owns_ = false; + } + + HANDLE mapping_ = nullptr; + ClientState* state_ = nullptr; + bool owns_ = false; + }; + + class FrameRingWriter + { + public: + FrameRingWriter() + { + mapping_ = CreateFileMappingW(INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE, 0, + static_cast(sizeof(FrameRing)), FrameRingName); + if (!mapping_) + return; + const bool existed = GetLastError() == ERROR_ALREADY_EXISTS; + ring_ = static_cast(MapViewOfFile( + mapping_, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(FrameRing))); + if (!ring_) + { + Reset(); + return; + } + if (!existed) + { + std::memset(ring_, 0, sizeof(*ring_)); + ring_->version = ProtocolVersion; + ring_->structSize = sizeof(*ring_); + ring_->slotCount = RingSize; + ring_->generation = 1; + MemoryBarrier(); + ring_->magic = FrameMagic; + } + else + { + for (int i = 0; i < 100 && !HeaderValid(); ++i) + Sleep(2); + if (!HeaderValid()) + { + Reset(); + return; + } + } + owns_ = AcquireOwner(ring_->producerPid); + } + + ~FrameRingWriter() + { + if (ring_ && owns_ && ring_->producerPid == GetCurrentProcessId()) + { + const std::uint32_t odd = Ipc::BeginSeqlockWrite(ring_->publishSequence); + ring_->publishSequence = odd; + ring_->producerPid = 0; + Ipc::EndSeqlockWrite(ring_->publishSequence); + } + Reset(); + } + + bool Ready() const noexcept { return ring_ && owns_; } + + std::uint32_t Publish(const FrameDescriptor& frame) noexcept + { + if (!Ready() || frame.frameId == 0) + return 0; + const std::uint32_t slot = static_cast(frame.frameId % RingSize); + const std::uint32_t odd = Ipc::BeginSeqlockWrite(ring_->publishSequence); + std::memcpy(&ring_->slots[slot], &frame, sizeof(frame)); + ring_->latestSlot = slot; + ring_->producerPid = GetCurrentProcessId(); + if (frame.transportGeneration != 0) + ring_->generation = frame.transportGeneration; + else if (ring_->generation == 0) + ring_->generation = 1; + MemoryBarrier(); + return Ipc::EndSeqlockWrite(ring_->publishSequence); + } + + private: + bool HeaderValid() const noexcept + { + return ring_ && ring_->magic == FrameMagic && + ring_->version == ProtocolVersion && + ring_->structSize == sizeof(FrameRing) && ring_->slotCount == RingSize; + } + + bool AcquireOwner(std::uint32_t& pid) noexcept + { + const LONG self = static_cast(GetCurrentProcessId()); + auto* atomicPid = reinterpret_cast(&pid); + for (int i = 0; i < 100; ++i) + { + const LONG observed = *atomicPid; + if (observed == self) + return true; + if (observed != 0 && + QueryProcessLiveness(static_cast(observed)) != ProcessLiveness::Dead) + return false; + if (InterlockedCompareExchange(atomicPid, self, observed) == observed) + return true; + Sleep(1); + } + return false; + } + + void Reset() noexcept + { + if (ring_) + { + UnmapViewOfFile(ring_); + ring_ = nullptr; + } + if (mapping_) + { + CloseHandle(mapping_); + mapping_ = nullptr; + } + owns_ = false; + } + + HANDLE mapping_ = nullptr; + FrameRing* ring_ = nullptr; + bool owns_ = false; + }; + + bool ReadAck(Ipc::ReadOnlyMapping& mapping, AckState& out) noexcept + { + if (!mapping.EnsureOpen(AckStateName) || !Ipc::StableRead(mapping.Get(), out)) + return false; + return out.magic == AckMagic && out.version == ProtocolVersion && + out.structSize == sizeof(AckState); + } + + float MaxAbs(const float* a, const float* b, std::size_t count) noexcept + { + float result = 0.0f; + for (std::size_t i = 0; i < count; ++i) + result = (std::max)(result, std::fabs(a[i] - b[i])); + return result; + } + + float HostStateDelta(const HostState& expected, const HostState& actual) noexcept + { + float delta = 0.0f; + delta = (std::max)(delta, MaxAbs(expected.headOrientation, actual.headOrientation, 4)); + delta = (std::max)(delta, MaxAbs(expected.headPositionMeters, actual.headPositionMeters, 3)); + for (int eye = 0; eye < 2; ++eye) + { + delta = (std::max)(delta, + MaxAbs(expected.eyes[eye].orientation, actual.eyes[eye].orientation, 4)); + delta = (std::max)(delta, + MaxAbs(expected.eyes[eye].positionMeters, actual.eyes[eye].positionMeters, 3)); + const float ef[4]{ + expected.eyes[eye].fov.angleLeft, expected.eyes[eye].fov.angleRight, + expected.eyes[eye].fov.angleUp, expected.eyes[eye].fov.angleDown + }; + const float af[4]{ + actual.eyes[eye].fov.angleLeft, actual.eyes[eye].fov.angleRight, + actual.eyes[eye].fov.angleUp, actual.eyes[eye].fov.angleDown + }; + delta = (std::max)(delta, MaxAbs(ef, af, 4)); + } + return delta; + } + + const char* FailureName(std::uint32_t reason) noexcept + { + switch (reason) + { + case StereoFailureNone: return "none"; + case StereoFailureMissingLatchedPose: return "missing_latched_pose"; + case StereoFailureResourceUnavailable: return "resource_unavailable"; + case StereoFailureMrtActive: return "mrt_active"; + case StereoFailureViewportUnavailable: return "viewport_unavailable"; + case StereoFailureLeftWvpUploadFailed: return "left_wvp_upload"; + case StereoFailureLeftDrawFailed: return "left_draw"; + case StereoFailureRightStateFailed: return "right_state"; + case StereoFailureRightWvpUploadFailed: return "right_wvp_upload"; + case StereoFailureRightDrawFailed: return "right_draw"; + case StereoFailureRestoreFailed: return "restore"; + case StereoFailureComposeFailed: return "compose"; + case StereoFailurePresentFailed: return "present"; + case StereoFailureDepthStateChanged: return "depth_state_changed"; + case StereoFailurePoseSequenceMismatch: return "pose_sequence_mismatch"; + case StereoFailureDepthUnsynchronized: return "depth_unsynchronized"; + case StereoFailureClearFailed: return "clear"; + case StereoFailureWorldClassificationFailed: return "world_classification"; + case StereoFailureStencilUnsynchronized: return "stencil_unsynchronized"; + case StereoFailureOffscreenWorld: return "offscreen_world"; + case StereoFailureOcclusionQueryActive: return "occlusion_query"; + default: return "unknown"; + } + } + + DWORD WINAPI ShadowBridgeThread(void*) + { + Ipc::ReadOnlyMapping legacyPoseMapping; + Ipc::ReadOnlyMapping legacyFrameMapping; + Ipc::ReadOnlyMapping ackMapping; + HostStateReader hostReader; + std::unique_ptr clientWriter; + std::unique_ptr frameWriter; + + std::uint32_t lastLegacyPoseSequence = 0; + std::uint32_t lastLegacyHeartbeat = 0; + std::uint64_t lastLegacyFrameId = 0; + std::uint64_t lastClientPublishedFrameId = 0; + std::uint32_t lastClientPresentation = UINT32_MAX; + std::uint32_t lastClientStereoState = UINT32_MAX; + std::uint32_t lastFailureReason = StereoFailureNone; + ULONGLONG nextWriterRetryMs = 0; + ULONGLONG lastSummaryMs = 0; + std::uint64_t poseParityOk = 0; + std::uint64_t poseParityLag = 0; + std::uint64_t poseParityMismatch = 0; + std::uint64_t hostMissing = 0; + std::uint64_t clientPublishes = 0; + std::uint64_t framePublishes = 0; + std::uint64_t ackParityOk = 0; + std::uint64_t ackParityLag = 0; + bool readyLogged = false; + + while (!ShadowBridgeStop.load(std::memory_order_acquire)) + { + if (!legacyPoseMapping.EnsureOpen(SharedMemoryName)) + { + if (ShadowBridgeWait(100)) + break; + continue; + } + + SharedPoseState pose{}; + if (!ShadowV2::StableReadPose(legacyPoseMapping.Get(), pose)) + { + if (ShadowBridgeWait(ShadowIdlePollMs)) + break; + continue; + } + + const ULONGLONG loopNow = GetTickCount64(); + if ((!clientWriter || !clientWriter->Ready() || + !frameWriter || !frameWriter->Ready()) && loopNow >= nextWriterRetryMs) + { + if (clientWriter && !clientWriter->Ready()) + clientWriter.reset(); + if (frameWriter && !frameWriter->Ready()) + frameWriter.reset(); + if (!clientWriter) + clientWriter = std::make_unique(); + if (!frameWriter) + frameWriter = std::make_unique(); + nextWriterRetryMs = loopNow + ShadowRetryMs; + } + + SharedRenderFrameState latestFrame{}; + bool haveLatestFrame = false; + if (legacyFrameMapping.EnsureOpen(RenderFrameMemoryName)) + { + SharedRenderFrameRing ring{}; + if (ShadowV2::StableReadFrameRing(legacyFrameMapping.Get(), ring)) + haveLatestFrame = ShadowV2::LatestFrame(ring, latestFrame); + } + + const bool frameChanged = haveLatestFrame && + latestFrame.frameId != lastLegacyFrameId; + if (frameChanged) + { + lastLegacyFrameId = latestFrame.frameId; + if (frameWriter && frameWriter->Ready()) + { + frameWriter->Publish(ShadowV2::FrameDescriptorFromV2(latestFrame)); + ++framePublishes; + } + if (latestFrame.failureReason != StereoFailureNone && + latestFrame.failureReason != lastFailureReason) + { + spdlog::warn( + "VR v3 shadow: stereo failure={} ({}) frame={} poseSeq={} flags=0x{:X} size={}x{} direct={}", + latestFrame.failureReason, FailureName(latestFrame.failureReason), + latestFrame.frameId, latestFrame.sourcePoseSequence, latestFrame.flags, + latestFrame.backbufferWidth, latestFrame.backbufferHeight, + (latestFrame.flags & RenderFrameDirectGpuTransport) ? 1 : 0); + } + lastFailureReason = latestFrame.failureReason; + } + + const std::uint32_t heartbeat = pose.reserved[ClientHeartbeatIndex]; + const std::uint32_t presentation = pose.reserved[ClientPresentationModeIndex]; + const std::uint32_t stereoState = pose.reserved[ClientStereoStateIndex]; + const std::uint64_t currentFrameId = haveLatestFrame ? latestFrame.frameId : 0; + const bool clientChanged = heartbeat != lastLegacyHeartbeat || + currentFrameId != lastClientPublishedFrameId || + presentation != lastClientPresentation || + stereoState != lastClientStereoState; + if (clientChanged && clientWriter && clientWriter->Ready()) + { + ClientState client = ShadowV2::ClientStateFromV2( + pose, haveLatestFrame ? &latestFrame : nullptr); + clientWriter->Publish(client); + ++clientPublishes; + lastLegacyHeartbeat = heartbeat; + lastClientPublishedFrameId = currentFrameId; + lastClientPresentation = presentation; + lastClientStereoState = stereoState; + } + + const std::uint32_t directGeneration = haveLatestFrame && + (latestFrame.flags & RenderFrameDirectGpuTransport) + ? latestFrame.reserved[RenderFrameDirectGenerationIndex] : 0; + + if (pose.sequence != lastLegacyPoseSequence) + { + HostState v3{}; + if (hostReader.Read(v3)) + { + if (v3.poseId == pose.sequence) + { + const HostState expected = ShadowV2::HostStateFromV2(pose, directGeneration); + const bool metadataMatch = + v3.hostPid == expected.hostPid && + v3.flags == expected.flags && + v3.referenceSpaceGeneration == expected.referenceSpaceGeneration && + v3.adapterLuidLow == expected.adapterLuidLow && + v3.adapterLuidHigh == expected.adapterLuidHigh && + v3.recommendedWidth[0] == expected.recommendedWidth[0] && + v3.recommendedWidth[1] == expected.recommendedWidth[1] && + v3.recommendedHeight[0] == expected.recommendedHeight[0] && + v3.recommendedHeight[1] == expected.recommendedHeight[1]; + const float maxDelta = HostStateDelta(expected, v3); + if (metadataMatch && maxDelta <= 1.0e-5f) + ++poseParityOk; + else + { + ++poseParityMismatch; + if (poseParityMismatch <= 4 || poseParityMismatch % 300 == 0) + { + spdlog::warn( + "VR v3 shadow parity mismatch: pose={} metadata={} maxDelta={:.8f} v2flags=0x{:X} v3flags=0x{:X} v2ref={} v3ref={}", + pose.sequence, metadataMatch ? 1 : 0, maxDelta, + ShadowV2::HostFlagsFromV2(pose.flags), v3.flags, + pose.reserved[HostReferenceSpaceGenerationIndex], + v3.referenceSpaceGeneration); + } + } + } + else + { + ++poseParityLag; + } + } + else + { + ++hostMissing; + } + lastLegacyPoseSequence = pose.sequence; + } + + AckState ack{}; + if (ReadAck(ackMapping, ack)) + { + const AckState expected = ShadowV2::AckStateFromV2( + pose, haveLatestFrame ? &latestFrame : nullptr, ack.consumerPid); + if (ack.acceptedProbeToken == expected.acceptedProbeToken && + ack.consumedFrameId == expected.consumedFrameId) + ++ackParityOk; + else + ++ackParityLag; + } + + if (!readyLogged) + { + readyLogged = true; + spdlog::info( + "VR v3 shadow: live dual-protocol diagnostics active; change-driven publication + bounded polling; v2 remains render authority until parity is proven"); + } + + const ULONGLONG now = GetTickCount64(); + if (now - lastSummaryMs >= 5000) + { + lastSummaryMs = now; + const bool direct = haveLatestFrame && + (latestFrame.flags & RenderFrameDirectGpuTransport) != 0; + spdlog::info( + "VR v3 shadow summary: poseOk={} poseLag={} poseMismatch={} hostMissing={} clientPub={} framePub={} ackOk={} ackLag={} latestPose={} latestFrame={} stereoFailure={}({}) direct={} directReady={} presentation={} stereoState={}", + poseParityOk, poseParityLag, poseParityMismatch, hostMissing, + clientPublishes, framePublishes, ackParityOk, ackParityLag, + pose.sequence, haveLatestFrame ? latestFrame.frameId : 0, + haveLatestFrame ? latestFrame.failureReason : 0, + haveLatestFrame ? FailureName(latestFrame.failureReason) : "none", + direct ? 1 : 0, + (pose.flags & HostDirectGpuReady) ? 1 : 0, + pose.reserved[ClientPresentationModeIndex], + pose.reserved[ClientStereoStateIndex]); + } + + if (ShadowBridgeWait(ShadowIdlePollMs)) + break; + } + return 0; + } + + class VRV3ShadowHook final : public Hook + { + public: + std::string_view description() override { return "OpenXRVRV3ShadowBridge"; } + bool validate() override { return true; } + + bool apply() override + { + if (ShadowBridgeThreadHandle) + return true; + if (!ShadowBridgeStopEvent) + ShadowBridgeStopEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (!ShadowBridgeStopEvent) + { + spdlog::error("VR v3 shadow: failed to create stop event: {}", GetLastError()); + return false; + } + ShadowBridgeStop.store(false, std::memory_order_release); + ResetEvent(ShadowBridgeStopEvent); + + HMODULE pinnedModule = nullptr; + if (!GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN, + reinterpret_cast(&ShadowBridgeStop), &pinnedModule)) + { + spdlog::error("VR v3 shadow: failed to pin plugin module: {}", GetLastError()); + return false; + } + + HANDLE thread = CreateThread(nullptr, 0, ShadowBridgeThread, nullptr, 0, nullptr); + if (!thread) + { + spdlog::error("VR v3 shadow: failed to create bridge thread: {}", GetLastError()); + return false; + } + ShadowBridgeThreadHandle = thread; + RegisterShadowBridgeStopCallback(&RequestShadowBridgeStop); + spdlog::info("VR v3 shadow: process-lifetime worker tracked with stop event; plugin module pinned; detach callback registered"); + return true; + } + + static VRV3ShadowHook instance; + }; + + VRV3ShadowHook VRV3ShadowHook::instance; + } + + void RequestShadowBridgeStop() noexcept + { + RegisterShadowBridgeStopCallback(nullptr); + ShadowBridgeStop.store(true, std::memory_order_release); + if (ShadowBridgeStopEvent) + SetEvent(ShadowBridgeStopEvent); + if (ShadowBridgeThreadHandle) + { + CloseHandle(ShadowBridgeThreadHandle); + ShadowBridgeThreadHandle = nullptr; + } + } +} diff --git a/src/vr/ipc/win32_channel.hpp b/src/vr/ipc/win32_channel.hpp new file mode 100644 index 000000000..058353757 --- /dev/null +++ b/src/vr/ipc/win32_channel.hpp @@ -0,0 +1,134 @@ +#pragma once + +#include + +#include +#include +#include + +namespace OutRunVR::Ipc +{ + inline std::uint32_t BeginSeqlockWrite(volatile std::uint32_t& sequence) noexcept + { + LONG value = InterlockedIncrement(reinterpret_cast(&sequence)); + if ((value & 1) == 0) + value = InterlockedIncrement(reinterpret_cast(&sequence)); + MemoryBarrier(); + return static_cast(value); + } + + inline std::uint32_t EndSeqlockWrite(volatile std::uint32_t& sequence) noexcept + { + MemoryBarrier(); + LONG value = InterlockedIncrement(reinterpret_cast(&sequence)); + if (value & 1) + value = InterlockedIncrement(reinterpret_cast(&sequence)); + return static_cast(value); + } + + template + bool StableRead(const T* shared, T& out, int attempts = 6) noexcept + { + if (!shared || attempts <= 0) + return false; + + for (int attempt = 0; attempt < attempts; ++attempt) + { + const std::uint32_t before = shared->sequence; + if (before & 1u) + continue; + + MemoryBarrier(); + std::memcpy(&out, shared, sizeof(T)); + MemoryBarrier(); + + const std::uint32_t after = shared->sequence; + if (before == after && !(after & 1u)) + return true; + } + return false; + } + + template + class ReadOnlyMapping + { + public: + ReadOnlyMapping() = default; + ReadOnlyMapping(const ReadOnlyMapping&) = delete; + ReadOnlyMapping& operator=(const ReadOnlyMapping&) = delete; + + ReadOnlyMapping(ReadOnlyMapping&& other) noexcept + : mapping_(std::exchange(other.mapping_, nullptr)), + state_(std::exchange(other.state_, nullptr)) + { + } + + ReadOnlyMapping& operator=(ReadOnlyMapping&& other) noexcept + { + if (this != &other) + { + Reset(); + mapping_ = std::exchange(other.mapping_, nullptr); + state_ = std::exchange(other.state_, nullptr); + } + return *this; + } + + ~ReadOnlyMapping() + { + Reset(); + } + + bool EnsureOpen(const wchar_t* name) noexcept + { + if (state_) + return true; + if (!name || !*name) + return false; + + HANDLE mapping = OpenFileMappingW(FILE_MAP_READ, FALSE, name); + if (!mapping) + return false; + + const T* state = static_cast(MapViewOfFile( + mapping, FILE_MAP_READ, 0, 0, sizeof(T))); + if (!state) + { + CloseHandle(mapping); + return false; + } + + mapping_ = mapping; + state_ = state; + return true; + } + + void Reset() noexcept + { + if (state_) + { + UnmapViewOfFile(state_); + state_ = nullptr; + } + if (mapping_) + { + CloseHandle(mapping_); + mapping_ = nullptr; + } + } + + const T* Get() const noexcept + { + return state_; + } + + bool IsOpen() const noexcept + { + return state_ != nullptr; + } + + private: + HANDLE mapping_ = nullptr; + const T* state_ = nullptr; + }; +} diff --git a/src/vr/runtime_eligibility.hpp b/src/vr/runtime_eligibility.hpp new file mode 100644 index 000000000..c53667eeb --- /dev/null +++ b/src/vr/runtime_eligibility.hpp @@ -0,0 +1,147 @@ +#pragma once + +#include +#include + +namespace OutRunVR::RuntimeEligibility +{ + // Keep host freshness identical to the validated pose reader. All game-side + // VR decisions consume this common authority instead of maintaining + // independent clocks. + inline constexpr std::int64_t HostStaleMs = 250; + + enum class InstallState : std::uint32_t + { + Pending = 0, + Ready = 1, + Failed = 2 + }; + + inline bool IsReady(const std::atomic& state) noexcept + { + return state.load(std::memory_order_acquire) == InstallState::Ready; + } + + inline bool IsFailed(const std::atomic& state) noexcept + { + return state.load(std::memory_order_acquire) == InstallState::Failed; + } + + inline std::atomic HostFresh{ false }; + inline std::atomic HostRenderable{ false }; + inline std::atomic StereoAllowed{ false }; + inline std::atomic RecoveryPending{ true }; + + // Recovery is deliberately two-stage. A passive, game-only clear may arm + // the NEXT frame to latch a fresh pose, but it must not enable WVP injection + // or stereo surface writes by itself. The next frame still has to prove a + // current full color/depth/stencil baseline before StereoAllowed opens. + inline std::atomic RecoveryPoseWarmup{ false }; + + // Compatibility layers can hold stereo closed independently of host freshness + // and baseline recovery. ResetEx replay health uses this gate so a later + // BaselineVerified() cannot reopen injection while classic state is unsafe. + inline std::atomic ExternalSafetyBlock{ false }; + + inline void SetExternalSafetyBlock(bool blocked) noexcept + { + ExternalSafetyBlock.store(blocked, std::memory_order_release); + if (blocked) + { + StereoAllowed.store(false, std::memory_order_release); + RecoveryPending.store(true, std::memory_order_release); + RecoveryPoseWarmup.store(false, std::memory_order_release); + } + } + + // R22/R23 are the final game-side safety overlays. Earlier layers may see a + // fresh host or plausible clear while those hooks are still being installed, + // but that must never make stereo/WVP eligible. + inline std::atomic SafetyOverlayReady{ false }; + + inline void FailClosed() noexcept + { + HostFresh.store(false, std::memory_order_release); + HostRenderable.store(false, std::memory_order_release); + StereoAllowed.store(false, std::memory_order_release); + RecoveryPending.store(true, std::memory_order_release); + RecoveryPoseWarmup.store(false, std::memory_order_release); + } + + inline void MarkSafetyOverlayUnavailable() noexcept + { + SafetyOverlayReady.store(false, std::memory_order_release); + FailClosed(); + } + + inline void MarkSafetyOverlayInstalled() noexcept + { + // Never inherit a baseline observed before the final callbacks became + // authoritative. Force a fresh passive observation and next-frame proof. + FailClosed(); + SafetyOverlayReady.store(true, std::memory_order_release); + } + + inline void ObserveFreshHost() noexcept + { + HostFresh.store(true, std::memory_order_release); + HostRenderable.store(true, std::memory_order_release); + // Do not reopen stereo here. A fresh host after a hard stall still + // needs a newly verified baseline. + } + + inline void ObserveSoftHostSuspend() noexcept + { + // A transient shouldRender=false with a fresh, visible host is compositor + // scheduling advice, not source invalidation. Keep the verified game-side + // stereo source active so the host can immediately reuse/capture it when + // shouldRender returns. The x64 host remains the sole authority for + // deciding whether the current OpenXR frame actually submits a layer. + HostFresh.store(true, std::memory_order_release); + HostRenderable.store(true, std::memory_order_release); + } + + inline void ArmRecoveryPoseWarmup() noexcept + { + if (!SafetyOverlayReady.load(std::memory_order_acquire) || + !HostFresh.load(std::memory_order_acquire) || + !HostRenderable.load(std::memory_order_acquire) || + ExternalSafetyBlock.load(std::memory_order_acquire) || + !RecoveryPending.load(std::memory_order_acquire)) + return; + RecoveryPoseWarmup.store(true, std::memory_order_release); + } + + inline bool PoseWarmupAllowed() noexcept + { + return SafetyOverlayReady.load(std::memory_order_acquire) && + HostFresh.load(std::memory_order_acquire) && + HostRenderable.load(std::memory_order_acquire) && + !ExternalSafetyBlock.load(std::memory_order_acquire) && + RecoveryPending.load(std::memory_order_acquire) && + RecoveryPoseWarmup.load(std::memory_order_acquire) && + !StereoAllowed.load(std::memory_order_acquire); + } + + inline void BaselineVerified() noexcept + { + if (!SafetyOverlayReady.load(std::memory_order_acquire) || + !HostFresh.load(std::memory_order_acquire) || + !HostRenderable.load(std::memory_order_acquire) || + ExternalSafetyBlock.load(std::memory_order_acquire)) + return; + RecoveryPoseWarmup.store(false, std::memory_order_release); + RecoveryPending.store(false, std::memory_order_release); + StereoAllowed.store(true, std::memory_order_release); + } + + inline bool MayInjectStereo() noexcept + { + return SafetyOverlayReady.load(std::memory_order_acquire) && + HostFresh.load(std::memory_order_acquire) && + HostRenderable.load(std::memory_order_acquire) && + !ExternalSafetyBlock.load(std::memory_order_acquire) && + StereoAllowed.load(std::memory_order_acquire) && + !RecoveryPending.load(std::memory_order_acquire); + } +} diff --git a/src/vr/settings.cpp b/src/vr/settings.cpp new file mode 100644 index 000000000..f6b5b253f --- /dev/null +++ b/src/vr/settings.cpp @@ -0,0 +1,231 @@ +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include + +#include +#include + +#include + +#include "hook_mgr.hpp" +#include "plugin.hpp" + +// VR settings live here, but the final camera transform does not. +// Head tracking is applied only at the verified D3D9 c64 WorldViewProjection +// upload. Simulation, input, timers and native FFB are never replayed for the +// second eye. Pose.v3 is primary with v2 retained as automatic compatibility +// fallback while frame transport migration is still in progress. +// +// D3D9Ex can preserve full-size L/R eye textures instead of squeezing them into +// SBS halves before Desktop Duplication. It remains opt-in until R14's lifetime- +// bound MANAGED 2D shadow path and cube/volume compatibility complete hardware +// validation. The shared-eye path is GPU-direct transport, not literal zero- +// copy: the host takes a D3D11 safety copy before it ACKs a producer ring slot. +namespace Settings +{ + Setting VREnabled{ "VR", "Enabled", true, + "Enables the OpenXR renderer-side VR bridge." }; + Setting VRAutoEnableWhenHostPresent{ "VR", "AutoEnableWhenHostPresent", true, + "Automatically applies renderer-side tracking whenever outrun-vr-host.exe is supplying a valid pose." }; + Setting VRAutoLaunchHost{ "VR", "AutoLaunchHost", true, + "Starts outrun-vr-host.exe from the game directory when VR is enabled. A short retry window also covers fast game restarts where the previous host is still shutting down." }; + Setting VRMirrorFitDesktop{ "VR", "MirrorFitDesktop", false, + "Fits the borderless PC mirror window to the current monitor even when the internal game backbuffer is larger. The VR render resolution is unchanged." }; + Setting VRDisableDesktopVsync{ "VR", "DisableDesktopVsync", true, + "Uses immediate D3D9 presentation while VR is enabled so the game source is not hard-capped by the desktop VSync setting before the OpenXR host captures it." }; + Setting VRHudScale{ "VR", "HudScale", 0.55f, + "Projection-space HUD size after the headset-specific asymmetric-FOV correction. Lower values make speed/time/position and menus smaller in the HMD.", Range{ 0.30f, 1.20f } }; + Setting VRHeadTracking{ "VR", "HeadTracking", true, + "Applies the OpenXR HMD orientation at OutRun's verified D3D9 WorldViewProjection upload." }; + Setting VRStereo{ "VR", "Stereo", true, + "Renders true left/right geometry stereo into verified shared-eye transport. Menus use a LOCAL-space world-fixed mono quad so head rotation and translation remain 6DoF." }; + Setting VRPreferD3D9Ex{ "VR", "PreferD3D9Ex", true, + "Prefers guarded D3D9Ex shared-eye transport so gameplay can bypass Desktop Duplication. Disable to return to classic D3D9/SBS capture." }; + Setting VRDirectGpuOnly{ "VR", "DirectGpuOnly", true, + "During gameplay, rejects classic Desktop-Duplication stereo candidates and keeps DirectGPU/cached OpenXR projection paths only. Menus remain mono on a world-fixed LOCAL-space quad." }; + Setting VRDisableDesktopDuplication{ "VR", "DisableDesktopDuplication", false, + "Diagnostic isolation switch. Disables Desktop Duplication for gameplay and menus. Leave false for normal DirectGPU-only gameplay with visible menus." }; + Setting VRTargetRefreshRateHz{ "VR", "TargetRefreshRateHz", 0.0f, + "Optional OpenXR refresh-rate override through XR_FB_display_refresh_rate. Leave at 0 to respect the refresh rate selected by Virtual Desktop/runtime (for example 72 or 90 Hz).", Range{ 0.0f, 144.0f } }; + Setting VRFrameCadenceMode{ "VR", "FrameCadenceMode", 1, + "Synchronizes rendering to the OpenXR clock. PhaseLock gates each game Present on the next host xrWaitFrame token without making the host wait for the game; SerializedProbe additionally enables bounded host-side diagnostics. Off restores the pre-R35 cadence.", + { "Off", "PhaseLock", "SerializedProbe" } }; + Setting VRFrameCadenceTargetHz{ "VR", "FrameCadenceTargetHz", 0.0f, + "Render cadence override while XR pacing is enabled. 0 = Auto/native OpenXR refresh (72/80/90/120 Hz as reported by xrWaitFrame). Non-zero keeps a fixed diagnostic render cadence.", Range{ 0.0f, 120.0f } }; + Setting VRFrameCadenceMaxHz{ "VR", "FrameCadenceMaxHz", 120.0f, + "Maximum VR render cadence in Auto mode. The 60 Hz simulation remains unchanged; Tweaks interpolation fills intermediate render frames.", Range{ 60.0f, 120.0f } }; + Setting VRFrameCadenceTimeoutMs{ "VR", "FrameCadenceTimeoutMs", 35.0f, + "Base fail-open timeout for OpenXR cadence waits. PhaseLock may extend this to four runtime periods (max 100 ms) so a temporary compositor stall can recover without returning to an uncapped producer loop.", Range{ 5.0f, 100.0f } }; + Setting VRPositionalTracking{ "VR", "PositionalTracking", true, + "Applies 6DoF HMD X/Y/Z movement in addition to orientation. Disable this option if a title-specific camera/culling issue is observed; stereo eye separation is independent." }; + Setting VRCullingCameraSync{ "VR", "CullingCameraSync", true, + "Temporarily mirrors the render-time VR camera into OutRun's live camera position/look so render-phase culling and camera-facing effects can follow head motion. Restored before game logic resumes." }; + Setting VRCullingUnionFov{ "VR", "CullingUnionFov", false, + "Reserved diagnostic option. Union-FOV culling is intentionally deferred until a culling-only frustum boundary is verified; the live game projection is not modified." }; + Setting VRWorldScale{ "VR", "WorldScale", 1.0f, + "Game-world units per metre of OpenXR head movement.", Range{ 0.1f, 10.0f } }; + Setting VRStereoDepth{ "VR", "StereoDepth", 1.0f, + "Scales only virtual eye separation/3D parallax without changing head positional movement. 1.0 uses the runtime IPD; raise it for stronger depth.", Range{ 0.50f, 2.00f } }; + Setting VRRotationScale{ "VR", "RotationScale", 1.0f, + "Scales HMD rotation around the recentered forward direction.", Range{ 0.0f, 2.0f } }; + Setting VRMatrixOrder{ "VR", "MatrixOrder", 0, + "Renderer-side camera-matrix composition order. Leave at 0 unless runtime validation shows the alternate path is required.", + { "WorldView * HeadInverse * Projection", "World * HeadInverse * View * Projection" } }; + Setting VRTelemetry{ "VR", "Telemetry", true, + "Logs renderer-boundary verification, stereo draw duplication, pose source/fallback and head-tracking diagnostics." }; +} + +namespace OutRunVR +{ + namespace + { + bool VRHostProcessRunning() noexcept + { + HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snapshot == INVALID_HANDLE_VALUE) + return false; + PROCESSENTRY32W entry{}; + entry.dwSize = sizeof(entry); + bool found = false; + if (Process32FirstW(snapshot, &entry)) + { + do + { + if (_wcsicmp(entry.szExeFile, L"outrun-vr-host.exe") == 0) + { + found = true; + break; + } + } while (Process32NextW(snapshot, &entry)); + } + CloseHandle(snapshot); + return found; + } + + bool VRLaunchHostOnce() noexcept + { + try + { + const std::filesystem::path gameDir = Module::ExePath.parent_path(); + const std::filesystem::path hostPath = gameDir / "outrun-vr-host.exe"; + if (!std::filesystem::exists(hostPath)) + { + spdlog::warn("VR AUTO HOST: {} not found; start the host manually", hostPath.string()); + return false; + } + // The host inherits these test-mode switches. Keeping transport + // policy in the same [VR] config as D3D9Ex avoids mismatched + // game/host modes during cadence testing. + SetEnvironmentVariableA("OUTRUN_VR_DIRECT_TRANSPORT", "1"); + SetEnvironmentVariableA("OUTRUN_VR_DIRECT_ONLY", + Settings::VRDirectGpuOnly ? "1" : "0"); + SetEnvironmentVariableA("OUTRUN_VR_DISABLE_DESKTOP_DUPLICATION", + Settings::VRDisableDesktopDuplication ? "1" : "0"); + const std::string refreshHz = + std::to_string(Settings::VRTargetRefreshRateHz.get()); + SetEnvironmentVariableA("OUTRUN_VR_TARGET_REFRESH_HZ", + refreshHz.c_str()); + const std::string cadenceMode = + std::to_string(Settings::VRFrameCadenceMode.get()); + const std::string cadenceTargetHz = + std::to_string(Settings::VRFrameCadenceTargetHz.get()); + const std::string cadenceMaxHz = + std::to_string(Settings::VRFrameCadenceMaxHz.get()); + const std::string cadenceTimeoutMs = + std::to_string(Settings::VRFrameCadenceTimeoutMs.get()); + SetEnvironmentVariableA("OUTRUN_VR_CADENCE_MODE", cadenceMode.c_str()); + SetEnvironmentVariableA("OUTRUN_VR_CADENCE_TARGET_HZ", cadenceTargetHz.c_str()); + SetEnvironmentVariableA("OUTRUN_VR_CADENCE_MAX_HZ", cadenceMaxHz.c_str()); + SetEnvironmentVariableA("OUTRUN_VR_CADENCE_TIMEOUT_MS", cadenceTimeoutMs.c_str()); + + std::wstring command = L"\"" + hostPath.wstring() + L"\""; + std::wstring workingDir = gameDir.wstring(); + STARTUPINFOW si{}; + si.cb = sizeof(si); + PROCESS_INFORMATION pi{}; + if (!CreateProcessW(nullptr, command.data(), nullptr, nullptr, FALSE, + CREATE_NEW_CONSOLE, nullptr, workingDir.c_str(), &si, &pi)) + { + spdlog::warn("VR AUTO HOST: CreateProcess failed error={}", GetLastError()); + return false; + } + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + spdlog::info("VR AUTO HOST: launched {}", hostPath.string()); + return true; + } + catch (const std::exception& e) + { + spdlog::warn("VR AUTO HOST: launch exception: {}", e.what()); + return false; + } + } + + DWORD WINAPI VRAutoLaunchHostThread(void*) + { + if (!Settings::VREnabled || !Settings::VRAutoLaunchHost) + return 0; + bool launched = false; + for (int attempt = 0; attempt < 40; ++attempt) + { + if (!VRHostProcessRunning() && !launched) + { + launched = VRLaunchHostOnce(); + if (launched) + return 0; + } + Sleep(500); + } + if (!VRHostProcessRunning()) + spdlog::warn("VR AUTO HOST: no host process became available during the startup retry window"); + return 0; + } + } + + class VRSettingsHook : public Hook + { + public: + std::string_view description() override { return "OpenXRVRSettings"; } + bool validate() override { return true; } + void declare_settings() override + { + Settings::VRAutoLaunchHost.needs_restart(); + Settings::VRMirrorFitDesktop.needs_restart(); + Settings::VRDisableDesktopVsync.needs_restart(); + Settings::VRPreferD3D9Ex.needs_restart(); + Settings::VRDirectGpuOnly.needs_restart(); + Settings::VRDisableDesktopDuplication.needs_restart(); + Settings::VRTargetRefreshRateHz.needs_restart(); + Settings::VRFrameCadenceMode.needs_restart(); + Settings::VRFrameCadenceTargetHz.needs_restart(); + Settings::VRFrameCadenceMaxHz.needs_restart(); + Settings::VRFrameCadenceTimeoutMs.needs_restart(); + } + + bool apply() override + { + spdlog::info( + "VR: D3D9Ex DirectGPU preference={} directOnly={} refreshOverrideHz={:.1f} cadenceMode={} cadenceTargetHz={:.1f} cadenceMaxHz={:.1f}; target 0 means XR-native render cadence, simulation remains 60 Hz", + Settings::VRPreferD3D9Ex.get(), + Settings::VRDirectGpuOnly.get(), + Settings::VRTargetRefreshRateHz.get(), + Settings::VRFrameCadenceMode.get(), + Settings::VRFrameCadenceTargetHz.get(), + Settings::VRFrameCadenceMaxHz.get()); + if (Settings::VREnabled && Settings::VRAutoLaunchHost) + { + HANDLE thread = CreateThread(nullptr, 0, VRAutoLaunchHostThread, nullptr, 0, nullptr); + if (thread) + CloseHandle(thread); + else + spdlog::warn("VR AUTO HOST: failed to create startup helper thread error={}", GetLastError()); + } + return true; + } + + static VRSettingsHook instance; + }; + + VRSettingsHook VRSettingsHook::instance; +} diff --git a/src/vr_shared.hpp b/src/vr_shared.hpp new file mode 100644 index 000000000..8141903c6 --- /dev/null +++ b/src/vr_shared.hpp @@ -0,0 +1,5 @@ +#pragma once + +// Compatibility include for the rest of the fork. +// New VR code must include vr/ipc/protocol.hpp directly. +#include "vr/ipc/protocol.hpp" diff --git a/src/wheel_ffb_math.hpp b/src/wheel_ffb_math.hpp new file mode 100644 index 000000000..fcce7081b --- /dev/null +++ b/src/wheel_ffb_math.hpp @@ -0,0 +1,255 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +namespace WheelFFBMath +{ + inline float smoothstep01(float t) + { + t = std::clamp(t, 0.0f, 1.0f); + return t * t * (3.0f - 2.0f * t); + } + + struct EngineHapticEstimate + { + float rpmNorm = 0.0f; + float frequencyHz = 0.0f; + float amplitudeScale = 0.0f; + }; + + // OutRun does not expose a verified engine-RPM field in EVWORK_CAR yet. + // Estimate normalized RPM from vehicle speed, current gear and throttle so + // the haptic can be swapped to a real RPM source later without changing the + // output path or UI. Frequencies stay below 20 Hz for a stable 60 Hz FFB loop. + inline EngineHapticEstimate estimate_engine_haptics( + float speedNorm, unsigned int gear, float throttleNorm) + { + speedNorm = std::isfinite(speedNorm) + ? std::clamp(speedNorm, 0.0f, 1.0f) : 0.0f; + throttleNorm = std::isfinite(throttleNorm) + ? std::clamp(throttleNorm, 0.0f, 1.0f) : 0.0f; + + // Approximate normalized road speed at redline for gears 1..6. + // Only the relative drop/rise matters for tactile frequency shaping. + static constexpr std::array GearRedlineSpeed = { + 0.17f, 0.30f, 0.44f, 0.60f, 0.78f, 1.00f + }; + const unsigned int forwardGear = std::clamp(gear, 1u, 6u); + const float coupledRpm = + speedNorm / GearRedlineSpeed[forwardGear - 1u]; + const float idleFloor = 0.10f + 0.05f * throttleNorm; + const float freeRev = (gear == 0 || speedNorm < 0.025f) + ? 0.10f + 0.55f * throttleNorm + : 0.0f; + + EngineHapticEstimate out{}; + out.rpmNorm = std::clamp( + std::max({ coupledRpm, idleFloor, freeRev }), 0.08f, 1.0f); + // Keep the texture above the heavy low-frequency pulse region while + // remaining below the 60 Hz FFB loop Nyquist limit. + out.frequencyHz = 13.0f + 11.0f * out.rpmNorm; + out.amplitudeScale = std::clamp( + 0.45f + 0.40f * out.rpmNorm + 0.15f * throttleNorm, + 0.0f, 1.0f); + return out; + } + + // Saturating proxy for front-tyre lateral force. OutRun does not expose + // per-tyre Fy, so use front slip only for the curve shape and keep the + // game's lateral signal as a separate load modifier in WheelFFBEngine. + // The 0.20 rad scale is deliberately broad: normal loaded corners remain + // progressive while deep understeer approaches a force plateau. + inline float lateral_force_shape(float alpha) + { + if (!std::isfinite(alpha)) return 0.0f; + constexpr float HalfPi = 1.57079632679f; + const float x = std::clamp(std::abs(alpha) / 0.20f, 0.0f, 1.0f); + return std::sin(x * HalfPi); + } + + // Pneumatic trail remains near full in the linear tyre region, then falls + // as slip grows. Keep a small residual rather than forcing the pneumatic + // lever arm mathematically to zero because this is an arcade-state proxy, + // not a fitted tyre dataset. + inline float pneumatic_trail_factor(float alpha) + { + if (!std::isfinite(alpha)) return 0.0f; + constexpr float FullTrailUntil = 0.08f; + constexpr float TrailFallComplete = 0.42f; + constexpr float ResidualTrail = 0.15f; + const float t = smoothstep01( + (std::abs(alpha) - FullTrailUntil) / + (TrailFallComplete - FullTrailUntil)); + return 1.0f - (1.0f - ResidualTrail) * t; + } + + // Reference peak of Fy * pneumatic trail. The absolute units are not + // available from OutRun, so this keeps SteeringWeight near its established + // scale while the relative trail terms shape the torque curve. + inline constexpr float PneumaticReferencePeak = 0.838899081f; + + // Lateral force and pneumatic trail have different transient behaviour. + // forceAlpha is the filtered tyre-force slip, while trailAlpha may be a + // modest phase-led version used only by the pneumatic lever arm. + inline float pneumatic_sat_shape(float forceAlpha, float trailAlpha) + { + if (!std::isfinite(forceAlpha) || !std::isfinite(trailAlpha)) return 0.0f; + const float raw = + lateral_force_shape(forceAlpha) * pneumatic_trail_factor(trailAlpha); + return std::clamp(raw / PneumaticReferencePeak, 0.0f, 1.0f); + } + + inline float pneumatic_sat_shape(float alpha) + { + return pneumatic_sat_shape(alpha, alpha); + } + + // Mechanical/caster trail contributes whenever front lateral force exists; + // it is not a substitute that appears only after pneumatic trail collapses. + // The setting is a normalized pseudo-trail ratio, not a physical distance. + inline float mechanical_sat_shape(float forceAlpha, float mechanicalTrailRatio) + { + if (!std::isfinite(forceAlpha)) return 0.0f; + const float ratio = std::clamp(mechanicalTrailRatio, 0.0f, 0.60f); + const float denominator = PneumaticReferencePeak + ratio; + return denominator > 0.0f + ? std::clamp(lateral_force_shape(forceAlpha) * ratio / denominator, 0.0f, 1.0f) + : 0.0f; + } + + // Total aligning moment follows Fy * (pneumatic trail + mechanical trail). + // Normalize the total pseudo-trail so enabling mechanical trail reshapes + // the SAT curve without silently turning SteeringWeight into a second gain. + inline float combined_sat_shape( + float forceAlpha, float trailAlpha, float mechanicalTrailRatio) + { + if (!std::isfinite(forceAlpha) || !std::isfinite(trailAlpha)) return 0.0f; + const float ratio = std::clamp(mechanicalTrailRatio, 0.0f, 0.60f); + const float denominator = PneumaticReferencePeak + ratio; + if (denominator <= 0.0f) + return 0.0f; + const float raw = lateral_force_shape(forceAlpha) * + (pneumatic_trail_factor(trailAlpha) + ratio); + return std::clamp(raw / denominator, 0.0f, 1.0f); + } + + inline float combined_sat_shape(float alpha, float mechanicalTrailRatio) + { + return combined_sat_shape(alpha, alpha, mechanicalTrailRatio); + } + + // Kept as a compatibility alias for older host tests/tools. Production + // Physics SAT uses the decomposed functions above explicitly. + inline float trail_shape(float alpha) + { + return pneumatic_sat_shape(alpha); + } + + // Symmetric C1 soft limiter. Preserve low/mid-range force exactly, then + // bend only the final quarter toward the DirectInput cap. This keeps SAT + // detail and weight intact while still preventing hard clipping at 100%. + inline float soft_saturate(float value) + { + if (!std::isfinite(value)) return 0.0f; + const float sign = value < 0.0f ? -1.0f : 1.0f; + const float x = std::abs(value); + constexpr float Knee = 0.75f; + constexpr float Limit = 1.35f; + if (x <= Knee) return value; + if (x >= Limit) return sign; + + const float span = Limit - Knee; + const float t = (x - Knee) / span; + const float t2 = t * t; + const float t3 = t2 * t; + const float h00 = 2.0f * t3 - 3.0f * t2 + 1.0f; + const float h10 = t3 - 2.0f * t2 + t; + const float h01 = -2.0f * t3 + 3.0f * t2; + const float y = h00 * Knee + h10 * span + h01; + return sign * y; + } + + inline float physics_return_relief(float alpha, float steerRate) + { + // Relieve only torque doing positive work on the moving wheel. + // Steering centre is irrelevant to front-tyre SAT direction. + const float t = -alpha * steerRate > 0.0f + ? std::clamp(std::abs(steerRate) / 0.08f, 0.0f, 1.0f) : 0.0f; + return 1.0f - 0.15f * t*t*(3.0f - 2.0f*t); + } + + using ResponseLUT = std::array; + + inline ResponseLUT linear_response_lut() + { + ResponseLUT lut{}; + for (size_t i = 0; i < lut.size(); ++i) + lut[i] = static_cast(i) / 10.0f; + return lut; + } + + // Eleven output-command samples for desired torque 0%, 10%, ... 100%. + // The curve must be monotonic, start at zero and finish at full scale. + inline bool parse_response_lut(std::string_view spec, ResponseLUT& out) + { + std::stringstream stream{std::string(spec)}; + ResponseLUT parsed{}; + for (size_t i = 0; i < parsed.size(); ++i) + { + std::string token; + if (!std::getline(stream, token, ',')) + return false; + std::stringstream valueStream{token}; + float value = 0.0f; + if (!(valueStream >> value) || !std::isfinite(value) || + value < 0.0f || value > 1.0f) + return false; + valueStream >> std::ws; + if (!valueStream.eof()) + return false; + if (i > 0 && value + 0.000001f < parsed[i - 1]) + return false; + parsed[i] = value; + } + + std::string extra; + if (std::getline(stream, extra, ',')) + { + if (extra.find_first_not_of(" \t\r\n") != std::string::npos) + return false; + } + if (parsed.front() > 0.001f || parsed.back() < 0.999f) + return false; + out = parsed; + return true; + } + + inline std::string format_response_lut(const ResponseLUT& lut) + { + std::ostringstream stream; + stream << std::fixed << std::setprecision(3); + for (size_t i = 0; i < lut.size(); ++i) + { + if (i) stream << ','; + stream << std::clamp(lut[i], 0.0f, 1.0f); + } + return stream.str(); + } + + inline float apply_response_lut(float value, const ResponseLUT& lut) + { + if (!std::isfinite(value)) return 0.0f; + const float sign = value < 0.0f ? -1.0f : 1.0f; + const float x = std::clamp(std::abs(value), 0.0f, 1.0f); + const float scaled = x * 10.0f; + const size_t index = std::min(9, static_cast(scaled)); + const float fraction = std::clamp(scaled - static_cast(index), 0.0f, 1.0f); + const float y = lut[index] + (lut[index + 1] - lut[index]) * fraction; + return sign * std::clamp(y, 0.0f, 1.0f); + } +} diff --git a/src/wheel_ffb_runtime.hpp b/src/wheel_ffb_runtime.hpp new file mode 100644 index 000000000..ad23ab306 --- /dev/null +++ b/src/wheel_ffb_runtime.hpp @@ -0,0 +1,67 @@ +#pragma once +#include +#include +#include + +// Read-only snapshots used by the F11 diagnostics/setup UI. Gameplay owns the +// mutable FFB engine state; the overlay only requests actions and displays it. +struct WheelFFBHeadroomSnapshot +{ + std::uint64_t samples = 0; + float currentDemand = 0.0f; + float peakDemand = 0.0f; + float p95Demand = 0.0f; + float p99Demand = 0.0f; + float softKneePercent = 0.0f; + float hardClipPercent = 0.0f; + float suggestedOverall = 0.0f; +}; + +struct WheelFFBStatusSnapshot +{ + bool initialized = false; + bool acquired = false; + bool outputOwner = false; + bool ffbStateValid = false; + bool actuatorsOn = false; + bool powerOn = false; + bool powerOff = false; + bool safetySwitchOn = false; + bool safetySwitchOff = false; + bool userSwitchOn = false; + bool userSwitchOff = false; + bool paused = false; + bool deviceLost = false; + bool constantEffect = false; + bool springEffect = false; + bool damperEffect = false; + bool periodicEffects = false; + bool constantCapsKnown = false; + bool constantDynamic = false; + bool polarDirectionDynamic = false; + bool springCapsKnown = false; + bool springDynamic = false; + bool damperCapsKnown = false; + bool damperDynamic = false; + bool periodicCapsKnown = false; + bool periodicDynamic = false; + bool directionTested = false; +}; + +inline constexpr std::size_t WheelFFBGraphCapacity = 180; +struct WheelFFBGraphSnapshot +{ + std::size_t count = 0; + std::array rawStructural{}; + std::array softLimited{}; + std::array postSlew{}; + std::array finalOutput{}; +}; + +void WheelFFB_RequestDirectionTest(int direction); +void WheelFFB_RequestSettingsTransition(); +void WheelFFB_ResetDirectionTest(); +WheelFFBHeadroomSnapshot WheelFFB_GetHeadroomSnapshot(); +WheelFFBStatusSnapshot WheelFFB_GetStatusSnapshot(); +WheelFFBGraphSnapshot WheelFFB_GetGraphSnapshot(); +void WheelFFB_ResetHeadroomStats(); diff --git a/src/wheel_profile_store.hpp b/src/wheel_profile_store.hpp new file mode 100644 index 000000000..9f599bebc --- /dev/null +++ b/src/wheel_profile_store.hpp @@ -0,0 +1,583 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "plugin.hpp" + +namespace Settings +{ + extern Setting WheelFFBDeviceName; + extern Setting WheelFFBDeviceGuid; + extern Setting WheelFFBResponseCorrection; + extern Setting WheelFFBResponseLUT; + extern Setting WheelFFBMaxTorqueNm; +} + +// Named wheel/input and force-feedback profiles live beside the DLL instead of +// inside OutRun2006Tweaks.user.ini. The active configuration is still copied +// to the normal INI files when a profile is loaded, so selecting a profile is +// durable across restarts without making startup depend on a profile file. +namespace WheelProfileStore +{ + enum class Kind + { + Input, + ForceFeedback, + }; + + inline std::string lower_ascii(std::string value) + { + std::transform(value.begin(), value.end(), value.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return value; + } + + inline std::string normalize_profile_name(std::string value) + { + value = Util::trim(value); + if (value.size() >= 4 && lower_ascii(value.substr(value.size() - 4)) == ".ini") + value.resize(value.size() - 4); + return Util::trim(value); + } + + inline bool valid_profile_name(std::string_view rawName, std::string* reason = nullptr) + { + const std::string name = normalize_profile_name(std::string(rawName)); + auto fail = [&](const char* why) + { + if (reason) *reason = why; + return false; + }; + + if (name.empty()) + return fail("Enter a profile name."); + if (name.size() > 64) + return fail("Profile names are limited to 64 characters."); + if (name == "." || name == "..") + return fail("That profile name is reserved."); + + // Keep file names portable and block path traversal. ASCII is deliberate: + // std::filesystem narrow-string conversion is code-page dependent on + // Windows, so accepting arbitrary UTF-8 here would produce profiles that + // work on some systems but not others. + for (const unsigned char c : name) + { + if (!(std::isalnum(c) || c == ' ' || c == '-' || c == '_' || c == '.')) + return fail("Use letters, numbers, spaces, '.', '_' or '-' in profile names."); + } + if (name.back() == '.' || name.back() == ' ') + return fail("Profile names cannot end with a dot or space."); + + const std::string lower = lower_ascii(name.substr(0, name.find('.'))); + static constexpr const char* reserved[] = { + "con", "prn", "aux", "nul", + "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9", + "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9", + }; + for (const char* value : reserved) + if (lower == value) + return fail("That profile name is reserved by Windows."); + return true; + } + + inline std::filesystem::path directory(Kind kind) + { + return Module::DllPath.parent_path() / "OutRun2006Tweaks.profiles" / + (kind == Kind::Input ? "Input" : "FFB"); + } + + inline bool ensure_directory(Kind kind, std::string* error = nullptr) + { + std::error_code ec; + std::filesystem::create_directories(directory(kind), ec); + if (ec) + { + if (error) *error = "Could not create profile directory: " + ec.message(); + return false; + } + return true; + } + + inline std::optional profile_path( + Kind kind, std::string_view rawName, std::string* error = nullptr) + { + std::string reason; + if (!valid_profile_name(rawName, &reason)) + { + if (error) *error = reason; + return std::nullopt; + } + if (!ensure_directory(kind, error)) + return std::nullopt; + const std::string name = normalize_profile_name(std::string(rawName)); + return directory(kind) / (name + ".ini"); + } + + inline std::vector list_profiles(Kind kind) + { + std::vector result; + std::string ignored; + if (!ensure_directory(kind, &ignored)) + return result; + + std::error_code ec; + for (std::filesystem::directory_iterator it(directory(kind), ec), end; !ec && it != end; it.increment(ec)) + { + if (!it->is_regular_file(ec) || ec) + continue; + if (lower_ascii(it->path().extension().string()) != ".ini") + continue; + result.push_back(it->path().stem().string()); + } + std::sort(result.begin(), result.end(), [](const std::string& a, const std::string& b) + { + return lower_ascii(a) < lower_ascii(b); + }); + return result; + } + + inline bool profile_exists(Kind kind, std::string_view rawName) + { + std::string ignored; + auto path = profile_path(kind, rawName, &ignored); + if (!path) + return false; + std::error_code ec; + return std::filesystem::is_regular_file(*path, ec) && !ec; + } + + inline std::filesystem::path staged_profile_path(const std::filesystem::path& finalPath) + { + auto staged = finalPath; + staged += ".tmp"; + return staged; + } + + inline bool commit_staged_profile( + const std::filesystem::path& staged, + const std::filesystem::path& finalPath, + std::string* error = nullptr) + { + auto backup = finalPath; + backup += ".bak"; + std::error_code ec; + + // MSVC's std::filesystem::is_regular_file(path, ec) can report + // ERROR_FILE_NOT_FOUND through ec for a path that simply does not exist. + // A missing destination/backup is the normal first-save case, not an error. + const auto inspect_regular_file = [&](const std::filesystem::path& path, + bool& exists, + const char* label) -> bool + { + ec.clear(); + exists = std::filesystem::is_regular_file(path, ec); + if (ec == std::errc::no_such_file_or_directory) + { + ec.clear(); + exists = false; + return true; + } + if (ec) + { + if (error) *error = std::string("Could not inspect ") + label + ": " + ec.message(); + return false; + } + return true; + }; + + bool finalExists = false; + if (!inspect_regular_file(finalPath, finalExists, "the existing profile")) + return false; + + bool backupExists = false; + if (!inspect_regular_file(backup, backupExists, "the profile backup")) + return false; + + // Recover a previous interrupted replacement before starting another. + if (!finalExists && backupExists) + { + std::filesystem::rename(backup, finalPath, ec); + if (ec) + { + if (error) *error = "Could not recover the previous profile backup: " + ec.message(); + return false; + } + finalExists = true; + } + + if (finalExists) + { + std::filesystem::remove(backup, ec); + if (ec) + { + if (error) *error = "Could not clear a stale profile backup: " + ec.message(); + return false; + } + std::filesystem::rename(finalPath, backup, ec); + if (ec) + { + if (error) *error = "Could not preserve the existing profile before overwrite: " + ec.message(); + return false; + } + } + + bool installedByCopyFallback = false; + std::filesystem::rename(staged, finalPath, ec); + if (ec) + { + const std::string renameError = ec.message(); + std::error_code copyEc; + std::filesystem::copy_file( + staged, finalPath, std::filesystem::copy_options::overwrite_existing, copyEc); + if (copyEc) + { + if (finalExists) + { + std::error_code restoreEc; + std::filesystem::rename(backup, finalPath, restoreEc); + if (restoreEc) + { + if (error) *error = "Could not install the new profile (rename: " + renameError + + "; copy fallback: " + copyEc.message() + ") and could not restore the backup (" + + restoreEc.message() + "). Destination: " + finalPath.string(); + return false; + } + } + if (error) *error = "Could not install the completed profile (rename: " + renameError + + "; copy fallback: " + copyEc.message() + "). Destination: " + finalPath.string(); + return false; + } + installedByCopyFallback = true; + } + + std::error_code verifyEc; + const bool installed = std::filesystem::is_regular_file(finalPath, verifyEc) && !verifyEc; + if (!installed) + { + std::error_code ignored; + std::filesystem::remove(finalPath, ignored); + if (finalExists) + { + std::error_code restoreEc; + std::filesystem::rename(backup, finalPath, restoreEc); + if (restoreEc) + { + if (error) *error = "Profile installation could not be verified and the previous profile could not be restored: " + + restoreEc.message() + ". Destination: " + finalPath.string(); + return false; + } + } + if (error) *error = "Profile installation could not be verified. Destination: " + finalPath.string(); + return false; + } + + if (installedByCopyFallback) + { + std::error_code ignored; + std::filesystem::remove(staged, ignored); + } + if (finalExists) + { + std::error_code ignored; + std::filesystem::remove(backup, ignored); + } + return true; + } + + inline bool delete_profile(Kind kind, std::string_view rawName, std::string* error = nullptr) + { + auto path = profile_path(kind, rawName, error); + if (!path) + return false; + std::error_code ec; + const bool removed = std::filesystem::remove(*path, ec); + if (ec || !removed) + { + if (error) *error = ec ? ("Could not delete profile: " + ec.message()) : "Profile file was not found."; + return false; + } + return true; + } + + inline bool parse_section( + const std::filesystem::path& path, + std::string_view wantedSection, + std::unordered_map& values, + std::string* error = nullptr) + { + std::ifstream file(path); + if (!file) + { + if (error) *error = "Could not open profile file."; + return false; + } + + const std::string wanted = lower_ascii(std::string(wantedSection)); + std::string section; + std::string line; + while (std::getline(file, line)) + { + line = Util::trim(line); + if (line.empty() || line.front() == '#' || line.front() == ';') + continue; + if (line.front() == '[' && line.back() == ']') + { + section = lower_ascii(Util::trim(line.substr(1, line.size() - 2))); + continue; + } + if (section != wanted) + continue; + const size_t equals = line.find('='); + if (equals == std::string::npos) + continue; + const std::string key = lower_ascii(Util::trim(line.substr(0, equals))); + const std::string value = Util::trim(line.substr(equals + 1)); + values[key] = value; + } + return true; + } + + struct InputOptionsSnapshot + { + std::string inputBackend; + std::string steeringDeadZone; + std::string bypassSensitivity; + std::string ffbDeviceName; + std::string ffbDeviceGuid; + std::string ffbResponseCorrection; + std::string ffbResponseLut; + std::string ffbMaxTorqueNm; + }; + + inline InputOptionsSnapshot capture_input_options() + { + return { + Settings::InputBackend.to_string(), + Settings::SteeringDeadZone.to_string(), + Settings::BypassGameSensitivity.to_string(), + Settings::WheelFFBDeviceName.to_string(), + Settings::WheelFFBDeviceGuid.to_string(), + Settings::WheelFFBResponseCorrection.to_string(), + Settings::WheelFFBResponseLUT.to_string(), + Settings::WheelFFBMaxTorqueNm.to_string(), + }; + } + + inline void restore_input_options(const InputOptionsSnapshot& snapshot) + { + Settings::InputBackend.set_from_string(snapshot.inputBackend); + Settings::SteeringDeadZone.set_from_string(snapshot.steeringDeadZone); + Settings::BypassGameSensitivity.set_from_string(snapshot.bypassSensitivity); + Settings::WheelFFBDeviceName.set_from_string(snapshot.ffbDeviceName); + Settings::WheelFFBDeviceGuid.set_from_string(snapshot.ffbDeviceGuid); + Settings::WheelFFBResponseCorrection.set_from_string(snapshot.ffbResponseCorrection); + Settings::WheelFFBResponseLUT.set_from_string(snapshot.ffbResponseLut); + Settings::WheelFFBMaxTorqueNm.set_from_string(snapshot.ffbMaxTorqueNm); + } + + inline bool append_input_options(const std::filesystem::path& path, std::string* error = nullptr) + { + std::ofstream file(path, std::ios::out | std::ios::app); + if (!file) + { + if (error) *error = "Could not append wheel-specific input options to the profile."; + return false; + } + file << "\n[WheelInputProfile]\n"; + file << "Version = 1\n"; + file << "InputBackend = " << Settings::InputBackend.to_string() << "\n"; + file << "SteeringDeadZone = " << Settings::SteeringDeadZone.to_string() << "\n"; + file << "BypassGameSensitivity = " << Settings::BypassGameSensitivity.to_string() << "\n"; + file << "FFBDeviceName = " << Settings::WheelFFBDeviceName.to_string() << "\n"; + file << "FFBDeviceGuid = " << Settings::WheelFFBDeviceGuid.to_string() << "\n"; + file << "FFBResponseCorrection = " << Settings::WheelFFBResponseCorrection.to_string() << "\n"; + file << "FFBResponseLUT = " << Settings::WheelFFBResponseLUT.to_string() << "\n"; + file << "FFBMaxTorqueNm = " << Settings::WheelFFBMaxTorqueNm.to_string() << "\n"; + file.flush(); + if (!file) + { + if (error) *error = "Failed while writing wheel-specific input options."; + file.close(); + return false; + } + file.close(); + if (file.fail()) + { + if (error) *error = "Failed while closing wheel-specific input options."; + return false; + } + return true; + } + + inline bool load_input_options(const std::filesystem::path& path, std::string* error = nullptr) + { + std::unordered_map values; + if (!parse_section(path, "WheelInputProfile", values, error)) + return false; + + const InputOptionsSnapshot before = capture_input_options(); + std::vector changed; + const auto apply = [&](const char* key, Settings::SettingBase& setting) -> bool + { + const auto it = values.find(lower_ascii(key)); + if (it == values.end()) + return true; // Older profiles remain valid. + const std::string oldValue = setting.to_string(); + if (!setting.set_from_string(it->second)) + return false; + if (setting.to_string() != oldValue) + changed.push_back(&setting); + return true; + }; + + if (!apply("InputBackend", Settings::InputBackend) || + !apply("SteeringDeadZone", Settings::SteeringDeadZone) || + !apply("BypassGameSensitivity", Settings::BypassGameSensitivity) || + !apply("FFBDeviceName", Settings::WheelFFBDeviceName) || + !apply("FFBDeviceGuid", Settings::WheelFFBDeviceGuid) || + !apply("FFBResponseCorrection", Settings::WheelFFBResponseCorrection) || + !apply("FFBResponseLUT", Settings::WheelFFBResponseLUT) || + !apply("FFBMaxTorqueNm", Settings::WheelFFBMaxTorqueNm)) + { + restore_input_options(before); + if (error) *error = "Input profile contains an invalid wheel-specific option."; + return false; + } + + for (Settings::SettingBase* setting : changed) + setting->notify(); + return true; + } + + inline bool is_ffb_profile_setting(const Settings::SettingBase* setting) + { + if (!setting || setting->section() != "WheelFFB") + return false; + const std::string key = std::string(setting->key()); + // Device routing and diagnostics stay global. A feel profile must never + // silently redirect torque to another wheel or turn verbose logging on. + return key != "DeviceName" && key != "DeviceGuid" && + key != "Telemetry" && key != "DebugLog" && + key != "ResponseCorrection" && key != "ResponseLUT" && + key != "MaxTorqueNm"; + } + + inline std::vector ffb_settings() + { + std::vector result; + for (Settings::SettingBase* setting : Settings::SettingBase::registry()) + if (is_ffb_profile_setting(setting)) + result.push_back(setting); + std::sort(result.begin(), result.end(), [](const Settings::SettingBase* a, const Settings::SettingBase* b) + { + return a->key() < b->key(); + }); + return result; + } + + inline bool save_ffb_profile(std::string_view rawName, std::string* error = nullptr) + { + auto path = profile_path(Kind::ForceFeedback, rawName, error); + if (!path) + return false; + + const auto staged = staged_profile_path(*path); + { + std::error_code ignored; + std::filesystem::remove(staged, ignored); + } + std::ofstream file(staged, std::ios::out | std::ios::trunc); + if (!file) + { + if (error) *error = "Could not open staged FFB profile for writing."; + return false; + } + file << "# OutRun2006Tweaks named force-feedback feel profile.\n"; + file << "# DeviceName/DeviceGuid and diagnostic logging are intentionally not stored here.\n\n"; + file << "[Profile]\nType = ForceFeedback\nVersion = 1\n\n"; + file << "[WheelFFB]\n"; + for (const Settings::SettingBase* setting : ffb_settings()) + file << setting->key() << " = " << setting->to_string() << "\n"; + file.flush(); + if (!file) + { + if (error) *error = "Failed while writing staged FFB profile."; + file.close(); + std::error_code ignored; + std::filesystem::remove(staged, ignored); + return false; + } + file.close(); + if (file.fail()) + { + if (error) *error = "Failed while closing staged FFB profile."; + std::error_code ignored; + std::filesystem::remove(staged, ignored); + return false; + } + if (!commit_staged_profile(staged, *path, error)) + { + std::error_code ignored; + std::filesystem::remove(staged, ignored); + return false; + } + return true; + } + + inline bool load_ffb_profile( + std::string_view rawName, int* appliedCount = nullptr, std::string* error = nullptr) + { + auto path = profile_path(Kind::ForceFeedback, rawName, error); + if (!path) + return false; + + std::unordered_map values; + if (!parse_section(*path, "WheelFFB", values, error)) + return false; + + auto settings = ffb_settings(); + std::vector before; + before.reserve(settings.size()); + for (Settings::SettingBase* setting : settings) + before.push_back(setting->to_string()); + + int applied = 0; + std::vector changed; + for (size_t i = 0; i < settings.size(); ++i) + { + Settings::SettingBase* setting = settings[i]; + const auto it = values.find(lower_ascii(std::string(setting->key()))); + if (it == values.end()) + continue; // Forward/backward-compatible partial profile. + if (!setting->set_from_string(it->second)) + { + for (size_t restore = 0; restore < settings.size(); ++restore) + settings[restore]->set_from_string(before[restore]); + if (error) *error = "FFB profile contains an invalid value for " + std::string(setting->key()) + "."; + return false; + } + if (setting->to_string() != before[i]) + changed.push_back(setting); + ++applied; + } + + if (applied == 0) + { + if (error) *error = "FFB profile contains no recognized force settings."; + return false; + } + + for (Settings::SettingBase* setting : changed) + setting->notify(); + if (appliedCount) *appliedCount = applied; + return true; + } +} diff --git a/tools/Collect-OutRunVRLogs.cmd b/tools/Collect-OutRunVRLogs.cmd new file mode 100644 index 000000000..ebeb55fbd --- /dev/null +++ b/tools/Collect-OutRunVRLogs.cmd @@ -0,0 +1,3 @@ +@echo off +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Collect-OutRunVRLogs.ps1" %* +pause diff --git a/tools/Collect-OutRunVRLogs.ps1 b/tools/Collect-OutRunVRLogs.ps1 new file mode 100644 index 000000000..32225d511 --- /dev/null +++ b/tools/Collect-OutRunVRLogs.ps1 @@ -0,0 +1,283 @@ +param([switch]$All) +$ErrorActionPreference='Stop' +$root=Split-Path -Parent $MyInvocation.MyCommand.Path + +$patterns=@( + 'OutRun2006Tweaks*.log', + 'OutRun2006Tweaks-hudtrace*.csv', + 'outrun-vr-host*.log', + 'outrun-vr-host-pipeline*.log', + 'outrun-vr-watchdog*.log', + 'backend*.log', + 'OR2006C2C_d3d9.log', + 'OR2006C2C_dxgi.log', + 'OR2006C2C_d3d11.log', + 'OR2006C2C_vkd3d*.log', + 'dxvk*.log', + 'vkd3d*.log', + 'crash.log', + 'OR2006C2C.EXE.*.zip', + '*.dmp' +) + +function Get-SessionFiles { + $seen=@{} + $files=@() + foreach($p in $patterns){ + foreach($file in Get-ChildItem $root -Filter $p -File -ErrorAction SilentlyContinue){ + if($seen.ContainsKey($file.FullName)){continue} + $seen[$file.FullName]=$true + $files+=$file + } + } + return $files +} + +function Write-ActiveSession([string]$backend,[string]$variant,[string]$matrix,[string]$session,[datetime]$startedUtc){ + $active=Join-Path $root 'ACTIVE_VR_BACKEND.txt' + @( + "backend=$backend" + "variant=$variant" + "matrix=$matrix" + "session=$session" + "startedUtc=$($startedUtc.ToString('o'))" + "selected=$((Get-Date).ToString('o'))" + ) | Set-Content $active -Encoding ascii +} + +function Prepare-NextSession([string]$backend,[string]$variant,[string]$matrix){ + $startedUtc=(Get-Date).ToUniversalTime() + $session=$startedUtc.ToString('yyyyMMddTHHmmssfffZ')+'-'+[guid]::NewGuid().ToString('N').Substring(0,8) + $sessionRoot=Join-Path $root ("logs/{0}/{1}/{2}" -f $matrix,$variant,$session) + New-Item -ItemType Directory -Force $sessionRoot|Out-Null + + $ini=Join-Path $root 'OutRun2006Tweaks.ini' + $configHash=if(Test-Path $ini){(Get-FileHash $ini -Algorithm SHA256).Hash.ToLowerInvariant()}else{'missing'} + $state=[ordered]@{ + SchemaVersion=2 + BuildMatrixId=$matrix + VariantId=$variant + Backend=$backend + SessionId=$session + StartedUtc=$startedUtc.ToString('o') + ConfigSha256=$configHash + CollectionStatus='prepared-after-previous-collection' + PreexistingLogs=@() + } + $state|ConvertTo-Json -Depth 4|Set-Content (Join-Path $root 'CURRENT_VR_SESSION.json') -Encoding UTF8 + $state|ConvertTo-Json -Depth 4|Set-Content (Join-Path $sessionRoot 'session_manifest.json') -Encoding UTF8 + Write-ActiveSession $backend $variant $matrix $session $startedUtc + + if(Test-Path $ini){ + $allowed='^(Enabled|AutoLaunchHost|AutoEnableWhenHostPresent|RenderBackend|PreferD3D9Ex|DirectGpuOnly|DisableDesktopDuplication|SkyGlowFactor|HudInspector)\s*=' + Get-Content $ini|Where-Object{$_ -match $allowed}|Set-Content (Join-Path $sessionRoot 'VR_CONFIG_SNAPSHOT.txt') -Encoding UTF8 + } + Copy-Item (Join-Path $root 'ACTIVE_VR_BACKEND.txt') $sessionRoot -Force + $inputs=Join-Path $root 'BUILD_INPUTS.json' + if(Test-Path $inputs){Copy-Item $inputs $sessionRoot -Force} + return $session +} + +$running=Get-Process -ErrorAction SilentlyContinue|Where-Object{ + $_.ProcessName -ieq 'OR2006C2C' -or $_.ProcessName -ieq 'outrun-vr-host' +} +if($running){throw 'Close OutRun and outrun-vr-host.exe before collecting logs so the session can be sealed safely.'} + +$active=Join-Path $root 'ACTIVE_VR_BACKEND.txt' +if(!(Test-Path $active)){throw 'ACTIVE_VR_BACKEND.txt not found; select a backend first.'} +$kv=@{} +Get-Content $active|ForEach-Object{if($_ -match '^([^=]+)=(.*)$'){$kv[$matches[1]]=$matches[2]}} +$backend=$kv.backend +if(!$backend){throw 'backend identity missing'} +$variant=if($kv.variant){$kv.variant}else{switch($backend){'d3d9'{'A_CONTROL'};'dxvk-safe'{'E_DXVK_SAFE'};'dxvk'{'E_DXVK_MULTIVIEW'};'dx12'{'F_DX12_STRICT'};'2d'{'CONTROL_2D'};default{'UNKNOWN'}}} +$matrix=if($kv.matrix){$kv.matrix}else{'UNIFIED'} + +$sessionState=Join-Path $root 'CURRENT_VR_SESSION.json' +if(!(Test-Path $sessionState)){throw 'CURRENT_VR_SESSION.json not found; select the backend again before launching the game.'} +$state=Get-Content $sessionState -Raw|ConvertFrom-Json +$session=$state.SessionId +if(!$session -or $state.Backend -ne $backend -or $state.BuildMatrixId -ne $matrix){throw 'Current session identity does not match the active backend/matrix.'} +$startedUtc=[datetime]::Parse($state.StartedUtc).ToUniversalTime() + +$base=Join-Path $root "logs/$matrix" +$dest=Join-Path $base "$variant/$session" +New-Item -ItemType Directory -Force $dest|Out-Null + +$copied=@() +$sourceFiles=Get-SessionFiles +foreach($file in $sourceFiles){ + if($file.LastWriteTimeUtc -lt $startedUtc){continue} + $target=Join-Path $dest $file.Name + Copy-Item $file.FullName $target -Force + $copied+=$file.Name +} + +Copy-Item $active $dest -Force +Copy-Item $sessionState $dest -Force +$inputs=Join-Path $root 'BUILD_INPUTS.json' +if(Test-Path $inputs){Copy-Item $inputs $dest -Force} + +$gameExe=Join-Path $root 'OR2006C2C.EXE' +if(Test-Path $gameExe){ + $exeItem=Get-Item $gameExe + $exeSha=(Get-FileHash $gameExe -Algorithm SHA256).Hash.ToLowerInvariant() + $upstreamReferenceSha='68ceb386829066f8455b9d027320af962584321f3e2e8a79c72841495a6134c3' + $matchesUpstream=($exeSha -eq $upstreamReferenceSha) + @( + "filename=$($exeItem.Name)" + "size=$($exeItem.Length)" + "sha256=$exeSha" + "upstreamReferenceSha256=$upstreamReferenceSha" + "matchesUpstreamReplacementExe=$matchesUpstream" + "lastWriteUtc=$($exeItem.LastWriteTimeUtc.ToString('o'))" + )|Set-Content (Join-Path $dest 'EXE_IDENTITY.txt') -Encoding UTF8 + $copied+='EXE_IDENTITY.txt' +} + +$gameLogs=Get-ChildItem $dest -Filter 'OutRun2006Tweaks*.log' -File -ErrorAction SilentlyContinue +$shaderLines=@() +foreach($log in $gameLogs){ + $shaderLines += Select-String -Path $log.FullName -Pattern 'VR GPL SHADER:' -SimpleMatch | + ForEach-Object { $_.Line } +} +if($shaderLines.Count -gt 0){ + @( + 'SHADER FINGERPRINT SUMMARY' + "session=$session" + "pairs=$($shaderLines.Count)" + '' + $shaderLines + )|Set-Content (Join-Path $dest 'SHADER_FINGERPRINT_SUMMARY.txt') -Encoding UTF8 + $copied+='SHADER_FINGERPRINT_SUMMARY.txt' +} + +$hudCsv=Join-Path $dest 'OutRun2006Tweaks-hudtrace.csv' +if(Test-Path $hudCsv){ + $hudRows=Get-Content $hudCsv | + Where-Object { $_ -and -not $_.StartsWith('#') } | + ConvertFrom-Csv + if($hudRows){ + $summary=@() + $summary+='HUD TRACE SUMMARY' + $summary+="session=$session" + $summary+="rows=$($hudRows.Count)" + $summary+='' + $groups=$hudRows | + Group-Object event,call_rva,known_area,semantic,space_policy,mode,stage,arg0,arg1 | + ForEach-Object { + $maxCount=($_.Group | ForEach-Object {[int]$_.count} | Measure-Object -Maximum).Maximum + [pscustomobject]@{ + Event=$_.Group[0].event + CallRva=$_.Group[0].call_rva + KnownArea=$_.Group[0].known_area + Semantic=$_.Group[0].semantic + SpacePolicy=$_.Group[0].space_policy + Mode=$_.Group[0].mode + Stage=$_.Group[0].stage + Arg0=$_.Group[0].arg0 + Arg1=$_.Group[0].arg1 + Arg2=$_.Group[0].arg2 + Arg3=$_.Group[0].arg3 + Arg4=$_.Group[0].arg4 + Arg5=$_.Group[0].arg5 + Arg6=$_.Group[0].arg6 + Arg7=$_.Group[0].arg7 + MaxCount=[int]$maxCount + } + } | + Sort-Object @{Expression={if($_.Semantic -and $_.Semantic -ne 'UNKNOWN'){0}else{1}}}, @{Expression='MaxCount';Descending=$true}, CallRva + $summary+='event | call_rva | known_area | semantic | space_policy | mode | stage | arg0 | arg1 | arg2 | arg3 | arg4 | arg5 | arg6 | arg7 | observed_count' + $summary+='------|----------|------------|----------|--------------|------|-------|------|------|------|------|------|------|------|------|---------------' + foreach($g in ($groups | Select-Object -First 250)){ + $summary+=("$($g.Event) | $($g.CallRva) | $($g.KnownArea) | $($g.Semantic) | $($g.SpacePolicy) | $($g.Mode) | $($g.Stage) | $($g.Arg0) | $($g.Arg1) | $($g.Arg2) | $($g.Arg3) | $($g.Arg4) | $($g.Arg5) | $($g.Arg6) | $($g.Arg7) | $($g.MaxCount)") + } + $summary|Set-Content (Join-Path $dest 'HUD_TRACE_SUMMARY.txt') -Encoding UTF8 + $copied+='HUD_TRACE_SUMMARY.txt' + + $expectedSemantics=@( + 'HUD_TIME_ATTACK','HUD_RANK','HUD_GEAR_REV','HUD_GHOST', + 'HUD_GOAL_TIME','HUD_HEART_TOTAL','HUD_RIVAL','HUD_GF_SPEECH', + 'HUD_RANK_EMOJI','HUD_RANK_TEXT','HUD_GF_WARNING','HUD_SLIPSTREAM', + 'HUD_FRUIT','WORLD_RIVAL_MARKER','WORLD_HEART' + ) + $observedSemantics=@($hudRows | + Where-Object {$_.semantic -and $_.semantic -ne 'UNKNOWN'} | + Select-Object -ExpandProperty semantic -Unique) + $coverage=@( + 'HUD SEMANTIC COVERAGE', + "session=$session", + 'status means observed in this session, not pass/fail; unobserved modes may simply not have appeared.', + '', + 'semantic | status', + '---------|-------' + ) + foreach($semanticName in $expectedSemantics){ + $status=if($observedSemantics -contains $semanticName){'OBSERVED'}else{'NOT_OBSERVED_THIS_SESSION'} + $coverage+=("$semanticName | $status") + } + $unknownCount=@($hudRows | Where-Object {!$_.semantic -or $_.semantic -eq 'UNKNOWN'}).Count + $coverage+='' + $coverage+=("unknown_rows=$unknownCount") + $coverage|Set-Content (Join-Path $dest 'HUD_SEMANTIC_COVERAGE.txt') -Encoding UTF8 + $copied+='HUD_SEMANTIC_COVERAGE.txt' + } +} + +$payloadBackend=if($backend -eq '2d' -or $backend -eq 'dxvk-safe'){'d3d9'}else{$backend} +$source=Join-Path $root "backends/$payloadBackend/SOURCE_SHA.txt" +$sha=if(Test-Path $source){(Get-Content $source -Raw).Trim()}else{'unknown'} +$configHash=if(Test-Path (Join-Path $root 'OutRun2006Tweaks.ini')){(Get-FileHash (Join-Path $root 'OutRun2006Tweaks.ini') -Algorithm SHA256).Hash.ToLowerInvariant()}else{'missing'} + +@( + "VARIANT=$variant" + "BACKEND=$backend" + "SESSION=$session" + "SESSION_STARTED_UTC=$($startedUtc.ToString('o'))" + "BUILD_MATRIX=$matrix" + "SOURCE_SHA=$sha" + "CONFIG_SHA256=$configHash" + "FILES=$($copied -join ',')" + 'LOG_BOUNDARY=clean-session-root' +)|Set-Content (Join-Path $dest 'MANIFEST.txt') -Encoding UTF8 + +@{ + SchemaVersion=2 + VariantId=$variant + Backend=$backend + SessionId=$session + SessionStartedUtc=$startedUtc.ToString('o') + BuildMatrixId=$matrix + GitSha=$sha + ConfigSha256=$configHash + CollectedAtUtc=(Get-Date).ToUniversalTime().ToString('o') + CollectedFiles=$copied + LogBoundary='clean-session-root' +}|ConvertTo-Json -Depth 4|Set-Content (Join-Path $dest 'variant_manifest.json') -Encoding UTF8 + +$resultFile=Join-Path $dest 'TEST_RESULT.txt' +if(!(Test-Path $resultFile)){ + @('FPS=','HMD_SMOOTHNESS=','STEREO=','RECENTER=','HUD_RANK_SCORE=','SKY_CLOUD=','SMOKE_SKID=','MENU_CAR=','EXIT_YES_NO=','NOTES=')|Set-Content $resultFile -Encoding UTF8 +} + +if($All){ + $zipName='OutRun2_VR_MATRIX_LOGS_'+$matrix+'_'+$session+'.zip' + $zip=Join-Path $root $zipName + if(Test-Path $zip){Remove-Item $zip -Force} + Compress-Archive -Path "$base/*" -DestinationPath $zip +}else{ + $zipName='OutRun2_VR_LOGS_'+$matrix+'_'+$variant+'_'+$session+'.zip' + $zip=Join-Path $root $zipName + if(Test-Path $zip){Remove-Item $zip -Force} + Compress-Archive -Path "$dest/*" -DestinationPath $zip +} + +foreach($file in $sourceFiles){ + if($copied -contains $file.Name -and (Test-Path $file.FullName)){ + Remove-Item $file.FullName -Force + } +} + +$nextSession=Prepare-NextSession $backend $variant $matrix +Write-Host "Diagnostic archive: $zip" +Write-Host "Next test session prepared automatically: $nextSession" +Write-Host 'You do NOT need to run the collector before the next test.' \ No newline at end of file diff --git a/tools/OutRunVR-Backend-Selector.cmd b/tools/OutRunVR-Backend-Selector.cmd new file mode 100644 index 000000000..a8a44ff7f --- /dev/null +++ b/tools/OutRunVR-Backend-Selector.cmd @@ -0,0 +1,2 @@ +@echo off +powershell -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "%~dp0OutRunVR-Backend-Selector.ps1" diff --git a/tools/OutRunVR-Backend-Selector.ps1 b/tools/OutRunVR-Backend-Selector.ps1 new file mode 100644 index 000000000..352aa716c --- /dev/null +++ b/tools/OutRunVR-Backend-Selector.ps1 @@ -0,0 +1,125 @@ +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +$root = Split-Path -Parent $MyInvocation.MyCommand.Path +$selector = Join-Path $root "Select-OutRunVRBackend.ps1" + +function Get-CurrentBackend { + $active = Join-Path $root "ACTIVE_VR_BACKEND.txt" + if (-not (Test-Path $active)) { return "unknown" } + $line = Get-Content $active | Where-Object { $_ -like "backend=*" } | Select-Object -First 1 + if (-not $line) { return "unknown" } + return $line.Substring(8).Trim() +} + +function Refresh-Status { + $current = Get-CurrentBackend + $status.Text = switch ($current) { + "2d" { "현재 선택: 2D ORIGINAL (Classic D3D9)" } + "d3d9" { "현재 선택: D3D9 VR SAFE" } + "dxvk-safe" { "현재 선택: DXVK SAFE (2-pass)" } + "dxvk" { "현재 선택: DXVK MULTIVIEW" } + "dx12" { "현재 선택: DX12 STRICT" } + default { "현재 선택: 확인되지 않음" } + } +} + +function Start-VRTest { + $runner = Join-Path $root "Run-OutRunVRTest.cmd" + if (-not (Test-Path $runner)) { + [System.Windows.Forms.MessageBox]::Show("Run-OutRunVRTest.cmd 파일이 없습니다.","OutRun VR Test Launcher",[System.Windows.Forms.MessageBoxButtons]::OK,[System.Windows.Forms.MessageBoxIcon]::Error) | Out-Null + return + } + Start-Process -FilePath $runner -WorkingDirectory $root +} + +function Select-Backend([string]$backend) { + if (-not (Test-Path $selector)) { + [System.Windows.Forms.MessageBox]::Show("Select-OutRunVRBackend.ps1 파일이 없습니다.","OutRun VR Backend Selector",[System.Windows.Forms.MessageBoxButtons]::OK,[System.Windows.Forms.MessageBoxIcon]::Error) | Out-Null + return + } + + $outFile = Join-Path $env:TEMP "outrun-vr-selector-out.txt" + $errFile = Join-Path $env:TEMP "outrun-vr-selector-err.txt" + Remove-Item $outFile,$errFile -Force -ErrorAction SilentlyContinue + + $args = @("-NoProfile","-ExecutionPolicy","Bypass","-File",$selector,$backend) + $p = Start-Process powershell -Wait -PassThru -WindowStyle Hidden -ArgumentList $args -RedirectStandardOutput $outFile -RedirectStandardError $errFile + + $output = "" + if (Test-Path $outFile) { $output += (Get-Content $outFile -Raw) } + if (Test-Path $errFile) { $output += (Get-Content $errFile -Raw) } + + if ($p.ExitCode -ne 0) { + if ([string]::IsNullOrWhiteSpace($output)) { $output = "Backend switch failed." } + [System.Windows.Forms.MessageBox]::Show($output.Trim(),"전환 실패",[System.Windows.Forms.MessageBoxButtons]::OK,[System.Windows.Forms.MessageBoxIcon]::Error) | Out-Null + return + } + + Refresh-Status + $label = switch ($backend) { + "2d" { "2D ORIGINAL" } + "d3d9" { "D3D9 VR SAFE" } + "dxvk-safe" { "DXVK SAFE" } + "dxvk" { "DXVK MULTIVIEW" } + "dx12" { "DX12 STRICT" } + } + [System.Windows.Forms.MessageBox]::Show("$label 로 전환했습니다.","전환 완료",[System.Windows.Forms.MessageBoxButtons]::OK,[System.Windows.Forms.MessageBoxIcon]::Information) | Out-Null +} + +$form = New-Object System.Windows.Forms.Form +$form.Text = "OutRun 2006 Renderer / VR Selector" +$form.StartPosition = "CenterScreen" +$form.ClientSize = New-Object System.Drawing.Size(500,535) +$form.FormBorderStyle = "FixedDialog" +$form.MaximizeBox = $false + +$title = New-Object System.Windows.Forms.Label +$title.Text = "OutRun 2006 Renderer / VR Selector" +$title.Font = New-Object System.Drawing.Font("Segoe UI",16,[System.Drawing.FontStyle]::Bold) +$title.AutoSize = $true +$title.Location = New-Object System.Drawing.Point(58,20) +$form.Controls.Add($title) + +$hint = New-Object System.Windows.Forms.Label +$hint.Text = "게임과 outrun-vr-host.exe를 종료한 뒤 선택하세요." +$hint.Font = New-Object System.Drawing.Font("Segoe UI",9) +$hint.AutoSize = $true +$hint.Location = New-Object System.Drawing.Point(62,58) +$form.Controls.Add($hint) + +$status = New-Object System.Windows.Forms.Label +$status.Font = New-Object System.Drawing.Font("Segoe UI",10,[System.Drawing.FontStyle]::Bold) +$status.AutoSize = $true +$status.Location = New-Object System.Drawing.Point(62,88) +$form.Controls.Add($status) +Refresh-Status + +$buttons = @( + @{ Text="2D ORIGINAL (Classic D3D9 / VR OFF)"; Backend="2d"; Y=125 }, + @{ Text="D3D9 VR SAFE"; Backend="d3d9"; Y=187 }, + @{ Text="DXVK SAFE (2-pass / compatibility)"; Backend="dxvk-safe"; Y=249 }, + @{ Text="DXVK MULTIVIEW (experimental)"; Backend="dxvk"; Y=311 }, + @{ Text="DX12 STRICT"; Backend="dx12"; Y=373 } +) + +foreach ($b in $buttons) { + $btn = New-Object System.Windows.Forms.Button + $btn.Text = $b.Text + $btn.Size = New-Object System.Drawing.Size(370,50) + $btn.Location = New-Object System.Drawing.Point(64,$b.Y) + $btn.Font = New-Object System.Drawing.Font("Segoe UI",10,[System.Drawing.FontStyle]::Bold) + $btn.Tag = $b.Backend + $btn.Add_Click({ Select-Backend $this.Tag }) + $form.Controls.Add($btn) +} + +$runBtn = New-Object System.Windows.Forms.Button +$runBtn.Text = "현재 모드 테스트 실행 (종료 후 로그 자동수집)" +$runBtn.Size = New-Object System.Drawing.Size(370,55) +$runBtn.Location = New-Object System.Drawing.Point(64,438) +$runBtn.Font = New-Object System.Drawing.Font("Segoe UI",10,[System.Drawing.FontStyle]::Bold) +$runBtn.Add_Click({ Start-VRTest }) +$form.Controls.Add($runBtn) + +[void]$form.ShowDialog() diff --git a/tools/Run-OutRunVRTest.cmd b/tools/Run-OutRunVRTest.cmd new file mode 100644 index 000000000..3b999d765 --- /dev/null +++ b/tools/Run-OutRunVRTest.cmd @@ -0,0 +1,3 @@ +@echo off +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Run-OutRunVRTest.ps1" +pause diff --git a/tools/Run-OutRunVRTest.ps1 b/tools/Run-OutRunVRTest.ps1 new file mode 100644 index 000000000..23fbc78a5 --- /dev/null +++ b/tools/Run-OutRunVRTest.ps1 @@ -0,0 +1,153 @@ +param( + [string]$GameExe = 'OR2006C2C.EXE' +) + +$ErrorActionPreference='Stop' +$root=Split-Path -Parent $MyInvocation.MyCommand.Path +$active=Join-Path $root 'ACTIVE_VR_BACKEND.txt' +$current=Join-Path $root 'CURRENT_VR_SESSION.json' +$collector=Join-Path $root 'Collect-OutRunVRLogs.ps1' +$selector=Join-Path $root 'Select-OutRunVRBackend.ps1' +$game=Join-Path $root $GameExe + +if(!(Test-Path $active) -or !(Test-Path $current)){ + throw 'Select a renderer/backend once before using the test launcher.' +} +if(!(Test-Path $collector)){throw 'Collect-OutRunVRLogs.ps1 not found.'} +if(!(Test-Path $selector)){throw 'Select-OutRunVRBackend.ps1 not found.'} +if(!(Test-Path $game)){throw "Game executable not found: $game"} + +$running=Get-Process -ErrorAction SilentlyContinue|Where-Object{ + $_.ProcessName -ieq 'OR2006C2C' -or $_.ProcessName -ieq 'outrun-vr-host' +} +if($running){throw 'OutRun or outrun-vr-host.exe is already running.'} + +$kv=@{} +Get-Content $active|ForEach-Object{if($_ -match '^([^=]+)=(.*)$'){$kv[$matches[1]]=$matches[2]}} +$backend=$kv.backend +if(!$backend){throw 'Active backend identity is missing.'} + +# If stale root logs exist, re-selecting the same backend seals them into the +# previous session and creates a clean session before launch. +$patterns=@( + 'OutRun2006Tweaks*.log', + 'OutRun2006Tweaks-hudtrace*.csv', + 'outrun-vr-host*.log', + 'outrun-vr-host-pipeline*.log', + 'outrun-vr-watchdog*.log', + 'backend*.log', + 'OR2006C2C_d3d9.log', + 'OR2006C2C_dxgi.log', + 'OR2006C2C_d3d11.log', + 'OR2006C2C_vkd3d*.log', + 'dxvk*.log', + 'vkd3d*.log', + 'crash.log', + 'OR2006C2C.EXE.*.zip', + '*.dmp' +) +$stale=$false +foreach($pattern in $patterns){ + if(Get-ChildItem $root -Filter $pattern -File -ErrorAction SilentlyContinue|Select-Object -First 1){ + $stale=$true + break + } +} +if($stale){ + & $selector -Backend $backend + if($LASTEXITCODE -and $LASTEXITCODE -ne 0){throw 'Failed to seal stale logs before launch.'} +} + +$state=Get-Content $current -Raw|ConvertFrom-Json +Write-Host "Starting test session: $($state.SessionId)" +Write-Host "Backend: $backend" + +# Recovery retest policy: first prove that the game can leave the white startup +# screen before re-enabling high-refresh interpolation / XR phase-lock. These +# command-line values are session-scoped and override both base and user INIs. +$recoveryBootArgs=@( + '-FramerateLimit=60', + '-FramerateFastLoad=0', + '-FramerateInterpolation=false', + '-FramerateUnlockExperimental=false', + '-FrameCadenceMode=0', + '-DisableDesktopVsync=false', + '-SkyGlowFactor=1', + '-HudInspector=true' +) +$gameArgs=@($recoveryBootArgs) +if($backend -eq 'd3d9' -or $backend -eq 'dxvk-safe'){ + $gameArgs += @( + '-PreferD3D9Ex=false', + '-DirectGpuOnly=false', + '-DisableDesktopDuplication=false' + ) +} + +$sessionRoot=Join-Path $root ("logs/{0}/{1}/{2}" -f $state.BuildMatrixId,$state.VariantId,$state.SessionId) +New-Item -ItemType Directory -Force $sessionRoot|Out-Null +@( + "backend=$backend" + "forceVrDisabled=$($backend -eq '2d')" + "arguments=$($gameArgs -join ' ')" +)|Set-Content (Join-Path $sessionRoot 'RUN_OVERRIDES.txt') -Encoding UTF8 +Write-Host "Recovery boot overrides: $($gameArgs -join ' ')" + +# DXVK/Vulkan safety: third-party implicit capture/overlay layers can crash +# vkCreateInstance before DXVK gets control. The observed Bandicam path was +# bdcamvk32.dll -> NVIDIA vkCreateInstance. Run the DXVK comparison with +# implicit layers disabled and clear legacy forced instance layers. +$dxvkMode = $backend -eq 'dxvk-safe' -or $backend -eq 'dxvk' +$oldVkDisable = $env:VK_LOADER_LAYERS_DISABLE +$oldVkInstanceLayers = $env:VK_INSTANCE_LAYERS +$oldVkDebug = $env:VK_LOADER_DEBUG +$oldVrForceDisabled = $env:OUTRUN_VR_FORCE_DISABLED +$oldShaderFingerprint = $env:OUTRUN_VR_SHADER_FINGERPRINT +$env:OUTRUN_VR_SHADER_FINGERPRINT='1' +if($backend -eq '2d'){ + $env:OUTRUN_VR_FORCE_DISABLED='1' + Write-Host '2D control isolation: all OpenXR/VR hook installers are disabled for this process.' +}else{ + $env:OUTRUN_VR_FORCE_DISABLED=$null +} +if($dxvkMode){ + $env:VK_LOADER_LAYERS_DISABLE='~implicit~' + $env:VK_INSTANCE_LAYERS=$null + $env:VK_LOADER_DEBUG='error,warn,layer' + $bandicam=Get-Process -ErrorAction SilentlyContinue|Where-Object{ + $_.ProcessName -match '^bdcam' -or $_.ProcessName -match 'bandicam' + } + if($bandicam){ + Write-Warning 'Bandicam process detected. Vulkan implicit layers are disabled for this launch; close Bandicam too if DXVK still crashes.' + } + Write-Host 'DXVK Vulkan safety: implicit layers disabled for this test process.' +} + +try{ + $p=Start-Process -FilePath $game -ArgumentList $gameArgs -WorkingDirectory $root -PassThru +} finally { + $env:OUTRUN_VR_FORCE_DISABLED=$oldVrForceDisabled + $env:OUTRUN_VR_SHADER_FINGERPRINT=$oldShaderFingerprint + if($dxvkMode){ + $env:VK_LOADER_LAYERS_DISABLE=$oldVkDisable + $env:VK_INSTANCE_LAYERS=$oldVkInstanceLayers + $env:VK_LOADER_DEBUG=$oldVkDebug + } +} +$p.WaitForExit() + +# Give the auto-launched host a short chance to flush and exit normally. +$deadline=(Get-Date).AddSeconds(15) +do{ + $hostProc=Get-Process -Name 'outrun-vr-host' -ErrorAction SilentlyContinue + if(!$hostProc){break} + Start-Sleep -Milliseconds 500 +}while((Get-Date) -lt $deadline) + +if(Get-Process -Name 'outrun-vr-host' -ErrorAction SilentlyContinue){ + Write-Warning 'outrun-vr-host.exe is still running. Close it, then run Collect-OutRunVRLogs.cmd once. No logs were deleted.' + exit 2 +} + +& $collector +if($LASTEXITCODE -and $LASTEXITCODE -ne 0){exit $LASTEXITCODE} diff --git a/tools/Select-OutRunVRBackend.cmd b/tools/Select-OutRunVRBackend.cmd new file mode 100644 index 000000000..a0b091b60 --- /dev/null +++ b/tools/Select-OutRunVRBackend.cmd @@ -0,0 +1,11 @@ +@echo off +setlocal +if "%~1"=="" ( + echo Usage: Select-OutRunVRBackend.cmd 2d^|d3d9^|dxvk-safe^|dxvk^|dx12 + exit /b 2 +) +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Select-OutRunVRBackend.ps1" "%~1" +if errorlevel 1 exit /b %ERRORLEVEL% +echo. +echo After testing, run Collect-OutRunVRLogs.cmd to create an upload-ready variant diagnostic ZIP. +exit /b 0 diff --git a/tools/Select-OutRunVRBackend.ps1 b/tools/Select-OutRunVRBackend.ps1 new file mode 100644 index 000000000..1868b4371 --- /dev/null +++ b/tools/Select-OutRunVRBackend.ps1 @@ -0,0 +1,266 @@ +param( + [Parameter(Mandatory=$true)] + [ValidateSet("2d","d3d9","dxvk-safe","dxvk","dx12")] + [string]$Backend +) + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $MyInvocation.MyCommand.Path +$backendRoot = Join-Path $root "backends" +$payloadBackend = if ($Backend -eq "2d" -or $Backend -eq "dxvk-safe") { "d3d9" } else { $Backend } +$src = Join-Path $backendRoot $payloadBackend +if (-not (Test-Path $src)) { throw "Backend payload not found: $src" } + +$logPatterns=@( + 'OutRun2006Tweaks*.log', + 'outrun-vr-host*.log', + 'outrun-vr-host-pipeline*.log', + 'outrun-vr-watchdog*.log', + 'backend*.log', + 'OR2006C2C_d3d9.log', + 'OR2006C2C_dxgi.log', + 'OR2006C2C_d3d11.log', + 'OR2006C2C_vkd3d*.log', + 'dxvk*.log', + 'vkd3d*.log', + 'crash.log', + 'OR2006C2C.EXE.*.zip', + '*.dmp' +) + +function Get-SessionFiles { + $seen=@{} + $files=@() + foreach($pattern in $logPatterns){ + foreach($file in Get-ChildItem $root -Filter $pattern -File -ErrorAction SilentlyContinue){ + if($seen.ContainsKey($file.FullName)){continue} + $seen[$file.FullName]=$true + $files+=$file + } + } + return $files +} + +function Seal-PendingSessionLogs { + $files=Get-SessionFiles + if(-not $files -or $files.Count -eq 0){return} + + $current=Join-Path $root 'CURRENT_VR_SESSION.json' + $dest=$null + $oldState=$null + if(Test-Path $current){ + try{$oldState=Get-Content $current -Raw|ConvertFrom-Json}catch{$oldState=$null} + } + + if($oldState -and $oldState.SessionId -and $oldState.BuildMatrixId -and $oldState.VariantId){ + $dest=Join-Path $root ("logs/{0}/{1}/{2}" -f $oldState.BuildMatrixId,$oldState.VariantId,$oldState.SessionId) + }else{ + $stamp=(Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssfffZ') + $dest=Join-Path $root ("logs/_orphaned/{0}" -f $stamp) + } + New-Item -ItemType Directory -Force $dest|Out-Null + + $moved=@() + foreach($file in $files){ + $target=Join-Path $dest $file.Name + if(Test-Path $target){ + $prefix=(Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssfffZ') + $target=Join-Path $dest ($prefix+'_'+$file.Name) + } + Move-Item $file.FullName $target -Force + $moved+=[IO.Path]::GetFileName($target) + } + @( + "AUTO_ARCHIVED_UTC=$((Get-Date).ToUniversalTime().ToString('o'))" + "REASON=backend-selection-started-new-session" + "FILES=$($moved -join ',')" + )|Set-Content (Join-Path $dest 'AUTO_ARCHIVED_ON_NEXT_SESSION.txt') -Encoding UTF8 +} + +function Copy-Required([string]$name) { + $p = Join-Path $src $name + if (-not (Test-Path $p)) { throw "Required backend file missing: $p" } + Copy-Item $p (Join-Path $root $name) -Force +} + +function Remove-RootVerified([string]$name) { + $p = Join-Path $root $name + if (Test-Path $p) { + Remove-Item $p -Force + if (Test-Path $p) { throw "Could not remove $name. Close OutRun/VR host and retry." } + } +} + +function Repair-IniSectionHeaders([string]$text) { + $text = $text -replace '(?m)^\[VR\]\\\s*$', '[VR]' + $text = $text -replace '(?m)^\\\s*$\r?\n?', '' + return $text +} + +function Set-IniSectionValue([string]$text,[string]$section,[string]$key,[string]$value) { + $escapedSection = [regex]::Escape($section) + $escapedKey = [regex]::Escape($key) + $sectionPattern = "(?ms)(^\[$escapedSection\]\s*\r?\n)(.*?)(?=^\[|\z)" + $m = [regex]::Match($text,$sectionPattern) + $nl = [Environment]::NewLine + if (-not $m.Success) { + if ($text.Length -gt 0 -and -not $text.EndsWith($nl)) { $text += $nl } + return $text + "[$section]" + $nl + "$key = $value" + $nl + } + + $body = $m.Groups[2].Value + $keyPattern = "(?m)^$escapedKey\s*=.*$" + if ([regex]::IsMatch($body,$keyPattern)) { + $body = [regex]::Replace($body,$keyPattern,"$key = $value",1) + } else { + $body = "$key = $value" + $nl + $body + } + return $text.Substring(0,$m.Groups[2].Index) + $body + + $text.Substring($m.Groups[2].Index + $m.Groups[2].Length) +} + +$running = Get-Process -ErrorAction SilentlyContinue | Where-Object { + $_.ProcessName -ieq "OR2006C2C" -or $_.ProcessName -ieq "outrun-vr-host" +} +if ($running) { throw "OutRun or outrun-vr-host.exe is still running. Close it before switching." } + +# Preserve any uncollected logs before touching the backend or starting a new +# session. This makes game-side truncate/overwrite behavior harmless. +Seal-PendingSessionLogs + +Copy-Required "dinput8.dll" + +if ($Backend -eq "2d") { + Remove-RootVerified "d3d9.dll" + Remove-RootVerified "multiviewpatcher.dll" + Remove-RootVerified "outrun-vr-host.exe" +} else { + Copy-Required "outrun-vr-host.exe" + if ($Backend -eq "dxvk") { + Copy-Required "d3d9.dll" + Copy-Required "multiviewpatcher.dll" + } elseif ($Backend -eq "dxvk-safe") { + $dxvkProvider = Join-Path (Join-Path $backendRoot "dxvk") "d3d9.dll" + if (-not (Test-Path $dxvkProvider)) { throw "DXVK provider missing: $dxvkProvider" } + Copy-Item $dxvkProvider (Join-Path $root "d3d9.dll") -Force + Remove-RootVerified "multiviewpatcher.dll" + } else { + Remove-RootVerified "d3d9.dll" + Remove-RootVerified "multiviewpatcher.dll" + if (Test-Path (Join-Path $root "d3d9.dll")) { + throw "Local d3d9.dll is still present; refusing $Backend mode." + } + } +} + +$ini = Join-Path $root "OutRun2006Tweaks.ini" +if (Test-Path $ini) { + $text = Get-Content $ini -Raw + $text = Repair-IniSectionHeaders $text + + if ($Backend -eq "2d") { + $text = Set-IniSectionValue $text "VR" "RenderBackend" "1" + $text = Set-IniSectionValue $text "VR" "Enabled" "false" + $text = Set-IniSectionValue $text "VR" "AutoLaunchHost" "false" + $text = Set-IniSectionValue $text "VR" "AutoEnableWhenHostPresent" "false" + $text = Set-IniSectionValue $text "VR" "PreferD3D9Ex" "false" + $text = Set-IniSectionValue $text "VR" "DirectGpuOnly" "false" + $text = Set-IniSectionValue $text "VR" "DisableDesktopDuplication" "false" + } elseif ($Backend -eq "dxvk-safe") { + $text = Set-IniSectionValue $text "VR" "RenderBackend" "1" + $text = Set-IniSectionValue $text "VR" "Enabled" "true" + $text = Set-IniSectionValue $text "VR" "AutoLaunchHost" "true" + $text = Set-IniSectionValue $text "VR" "AutoEnableWhenHostPresent" "true" + $text = Set-IniSectionValue $text "VR" "PreferD3D9Ex" "false" + $text = Set-IniSectionValue $text "VR" "DirectGpuOnly" "false" + $text = Set-IniSectionValue $text "VR" "DisableDesktopDuplication" "false" + $text = Set-IniSectionValue $text "Graphics" "TransparencySupersampling" "false" + } elseif ($Backend -eq "d3d9") { + # D3D9 SAFE is the correctness/control path. Do not require D3D9Ex or + # DirectGPU; plain D3D9 + SBS/Desktop Duplication must remain usable. + $text = Set-IniSectionValue $text "VR" "RenderBackend" "1" + $text = Set-IniSectionValue $text "VR" "Enabled" "true" + $text = Set-IniSectionValue $text "VR" "AutoLaunchHost" "true" + $text = Set-IniSectionValue $text "VR" "AutoEnableWhenHostPresent" "true" + $text = Set-IniSectionValue $text "VR" "PreferD3D9Ex" "false" + $text = Set-IniSectionValue $text "VR" "DirectGpuOnly" "false" + $text = Set-IniSectionValue $text "VR" "DisableDesktopDuplication" "false" + } else { + $value = switch ($Backend) { + "dxvk" { "2" } + "dx12" { "3" } + } + $text = Set-IniSectionValue $text "VR" "RenderBackend" $value + $text = Set-IniSectionValue $text "VR" "Enabled" "true" + $text = Set-IniSectionValue $text "VR" "AutoLaunchHost" "true" + $text = Set-IniSectionValue $text "VR" "AutoEnableWhenHostPresent" "true" + $text = Set-IniSectionValue $text "VR" "PreferD3D9Ex" "true" + $text = Set-IniSectionValue $text "VR" "DirectGpuOnly" "true" + if ($Backend -eq "dxvk") { + $text = Set-IniSectionValue $text "Graphics" "TransparencySupersampling" "false" + } + } + Set-Content $ini $text -Encoding UTF8 +} + +$nl = [Environment]::NewLine +$variant = switch ($Backend) { + "2d" { "CONTROL_2D" } + "d3d9" { "A_CONTROL" } + "dxvk-safe" { "E_DXVK_SAFE" } + "dxvk" { "E_DXVK_MULTIVIEW" } + "dx12" { "F_DX12_STRICT" } +} +$matrixFile = Join-Path $root "BUILD_MATRIX_ID.txt" +$matrix = if (Test-Path $matrixFile) { (Get-Content $matrixFile -Raw).Trim() } else { "UNIFIED_LOCAL" } +$startedUtc = (Get-Date).ToUniversalTime() +$session = $startedUtc.ToString("yyyyMMddTHHmmssfffZ") + "-" + [guid]::NewGuid().ToString("N").Substring(0,8) +$sessionRoot = Join-Path $root ("logs/{0}/{1}/{2}" -f $matrix,$variant,$session) +New-Item -ItemType Directory -Force $sessionRoot | Out-Null + +$activeText = @( + "backend=$Backend" + "variant=$variant" + "matrix=$matrix" + "session=$session" + "startedUtc=$($startedUtc.ToString('o'))" + "selected=$((Get-Date).ToString('o'))" +) -join $nl +Set-Content (Join-Path $root "ACTIVE_VR_BACKEND.txt") $activeText -Encoding ascii + +$configHash = "missing" +if (Test-Path $ini) { $configHash = (Get-FileHash $ini -Algorithm SHA256).Hash.ToLowerInvariant() } +$sessionManifest = [ordered]@{ + SchemaVersion = 2 + BuildMatrixId = $matrix + VariantId = $variant + Backend = $Backend + SessionId = $session + StartedUtc = $startedUtc.ToString("o") + ConfigSha256 = $configHash + CollectionStatus = "started-before-game-launch" + PreexistingLogs = @() +} +$sessionManifest | ConvertTo-Json -Depth 4 | Set-Content (Join-Path $root "CURRENT_VR_SESSION.json") -Encoding UTF8 +$sessionManifest | ConvertTo-Json -Depth 4 | Set-Content (Join-Path $sessionRoot "session_manifest.json") -Encoding UTF8 + +if (Test-Path $ini) { + $allowed = '^(Enabled|AutoLaunchHost|AutoEnableWhenHostPresent|RenderBackend|PreferD3D9Ex|DirectGpuOnly|DisableDesktopDuplication|SkyGlowFactor)\s*=' + Get-Content $ini | Where-Object { $_ -match $allowed } | + Set-Content (Join-Path $sessionRoot "VR_CONFIG_SNAPSHOT.txt") -Encoding UTF8 +} +Copy-Item (Join-Path $root "ACTIVE_VR_BACKEND.txt") $sessionRoot -Force +if (Test-Path (Join-Path $root "BUILD_INPUTS.json")) { + Copy-Item (Join-Path $root "BUILD_INPUTS.json") $sessionRoot -Force +} + +Write-Host "OutRun renderer mode activated: $Backend" +Write-Host "Diagnostic session prepared before launch: $session" +Write-Host "Any previous root logs were archived before this session was created." +switch ($Backend) { + "2d" { Write-Host "2D ORIGINAL: classic D3D9, VR disabled, D3D9Ex promotion disabled, no VR host." } + "d3d9" { Write-Host "D3D9 VR SAFE: guarded D3D9Ex/DirectGPU VR path." } + "dxvk-safe" { Write-Host "DXVK SAFE: classic D3D9 calls translated by DXVK; validated two-pass VR, multiview patcher disabled." } + "dxvk" { Write-Host "DXVK MULTIVIEW: local d3d9.dll + multiviewpatcher.dll active." } + "dx12" { Write-Host "DX12 STRICT: local d3d9.dll verified absent; Windows D3D9On12 required." } +} diff --git a/tools/Validate-VRAutodevState.ps1 b/tools/Validate-VRAutodevState.ps1 new file mode 100644 index 000000000..c3c942584 --- /dev/null +++ b/tools/Validate-VRAutodevState.ps1 @@ -0,0 +1,72 @@ +param( + [string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +) + +$ErrorActionPreference = "Stop" + +function Read-JsonFile([string]$RelativePath) { + $path = Join-Path $RepoRoot $RelativePath + if (-not (Test-Path $path)) { throw "Missing required file: $RelativePath" } + try { + return Get-Content -Raw -Path $path | ConvertFrom-Json -Depth 100 + } catch { + throw "Invalid JSON in $RelativePath : $($_.Exception.Message)" + } +} + +function Require([bool]$Condition, [string]$Message) { + if (-not $Condition) { throw $Message } +} + +$state = Read-JsonFile "docs/VR_AUTODEV_STATE.json" +$queue = Read-JsonFile "docs/VR_WORK_QUEUE.json" +$feedback = Read-JsonFile "docs/VR_RUNTIME_FEEDBACK.json" +$review = Read-JsonFile "docs/autodev/REVIEW_HANDOFF.json" +$fix = Read-JsonFile "docs/autodev/FIX_HANDOFF.json" +$validation = Read-JsonFile "docs/autodev/VALIDATION_HANDOFF.json" + +Require (Test-Path (Join-Path $RepoRoot "docs/VR_AUTODEV_PROTOCOL.md")) "Missing docs/VR_AUTODEV_PROTOCOL.md" +Require ($state.coordination.protocol -eq "docs/VR_AUTODEV_PROTOCOL.md") "State protocol path mismatch" +Require ($state.coordination.queue -eq "docs/VR_WORK_QUEUE.json") "State queue path mismatch" +Require ($state.coordination.runtimeFeedback -eq "docs/VR_RUNTIME_FEEDBACK.json") "State runtime feedback path mismatch" +Require ($state.coordination.authorities.productionSourceWriter -eq "FIX") "Only FIX may own autonomous production source writes" +Require ($state.coordination.authorities.centralQueueWriter -eq "INTEGRATION_PLANNER") "Central queue owner must be INTEGRATION_PLANNER" +Require ($queue.policy.sourceWriteWorker -eq "FIX") "Queue sourceWriteWorker must be FIX" +Require ([int]$queue.policy.maxSourceAttemptsPerFinding -eq 2) "maxSourceAttemptsPerFinding must remain 2" +Require ([int]$queue.policy.maxRuntimeCandidates -le 6) "maxRuntimeCandidates must be <= 6" + +$allowed = @("NEW","READY","IMPLEMENTING","IMPLEMENTED","VALIDATING","NEED_HMD_TEST","DONE","BLOCKED","REJECTED","SUPERSEDED") +$ids = @{} +foreach ($item in @($queue.items)) { + Require (-not [string]::IsNullOrWhiteSpace([string]$item.id)) "Queue item missing id" + Require (-not $ids.ContainsKey([string]$item.id)) "Duplicate queue id: $($item.id)" + $ids[[string]$item.id] = $true + Require ($allowed -contains [string]$item.status) "Invalid status for $($item.id): $($item.status)" + Require ([int]$item.attempts -le [int]$queue.policy.maxSourceAttemptsPerFinding) "Attempt limit exceeded for $($item.id)" +} +foreach ($item in @($queue.items)) { + foreach ($dep in @($item.dependsOn)) { + Require ($ids.ContainsKey([string]$dep)) "Unknown dependency $dep referenced by $($item.id)" + } +} + +Require ($review.producer -eq "REVIEW") "REVIEW handoff producer mismatch" +Require ($fix.producer -eq "FIX") "FIX handoff producer mismatch" +Require ($validation.producer -eq "VALIDATION") "VALIDATION handoff producer mismatch" + +$validationStates = @("IDLE","PASS_STATIC","PASS_BUILD_RUNTIME_PENDING","FAIL_BUILD","FAIL_REGRESSION","NEED_HMD_TEST") +Require ($validationStates -contains [string]$validation.status) "Invalid VALIDATION handoff status: $($validation.status)" + +if ($fix.status -ne "IDLE") { + Require (-not [string]::IsNullOrWhiteSpace([string]$fix.queueItemId)) "Active FIX handoff requires queueItemId" + Require ($ids.ContainsKey([string]$fix.queueItemId)) "FIX handoff references unknown queue item" +} + +foreach ($session in @($feedback.sessions)) { + foreach ($required in @("buildMatrixId","variantId","sessionId","sourceCommit")) { + Require (-not [string]::IsNullOrWhiteSpace([string]$session.$required)) "Runtime feedback session missing $required" + } +} + +Write-Host "VR autodev coordination state validation passed." +Write-Host ("Queue items: {0}; runtime feedback sessions: {1}" -f @($queue.items).Count, @($feedback.sessions).Count) diff --git a/tools/analyze_outrun_exe.py b/tools/analyze_outrun_exe.py new file mode 100644 index 000000000..d9978a214 --- /dev/null +++ b/tools/analyze_outrun_exe.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +"""Static OR2006C2C.EXE HUD/render anchor analyzer. + +No third-party packages are required. The script parses PE32 headers, fingerprints +known OutRun rendering functions, finds direct x86 CALL rel32 references to known +HUD/sprite targets, and emits JSON + Markdown suitable for CI artifacts. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import struct +from dataclasses import dataclass +from pathlib import Path + +KNOWN_TARGETS = { + 0x02CFE0: "put_sprite_ex", + 0x029580: "sprani_play_ae_auth_alpha", + 0x02D280: "put_clip_sprite", + 0x02CCB0: "sprSetFontPriority", + 0x02CA60: "sprSetPrintFont", + 0x02CCA0: "sprSetFontColor", + 0x02CC60: "sprSetFontScale", + 0x02CC00: "sprLocateP", + 0x02CCE0: "sprPrintf", + 0x02CDD0: "Sumo_Printf", + 0x049940: "Calc3D2D", + 0x0BAD20: "RankMarker_sub_4BAD20", +} + +KNOWN_CALL_SITES = { + 0x0BB0FB: "RankMarker sprani #1", + 0x0BB133: "RankMarker sprani #2", + 0x0BB16C: "RankMarker sprani #3", + 0x0BB1A5: "RankMarker sprani #4", + 0x0BB21F: "RankMarker clip #1", + 0x0BB241: "RankMarker clip #2", + 0x0BB271: "RankMarker clip #3", + 0x0BB2BC: "RankMarker clip #4", + 0x0BB2D0: "RankMarker clip #5", +} + +# Mirrors src/vr/hud_semantics.hpp. These ranges come from the shipped +# hooks_uiscaling.cpp reverse engineering and are intentionally semantic, +# rather than D3D primitive-count heuristics. +SEMANTIC_RANGES = ( + (0x05B300, 0x05B700, "HeartDisp_car_heart", "WORLD_HEART", "WORLD_BILLBOARD"), + (0x081A00, 0x081B00, "C2C_Fruit", "HUD_FRUIT", "SCREEN_HUD"), + (0x081B00, 0x081C00, "C2C_Heart", "HUD_HEART_TOTAL", "SCREEN_HUD"), + (0x096A80, 0x096D00, "C2CSpeechBubble", "HUD_GF_SPEECH", "SCREEN_HUD"), + (0x0B9000, 0x0B9200, "DispGearPosition", "HUD_GEAR_REV", "SCREEN_HUD"), + (0x0B9E00, 0x0BA100, "DispRank", "HUD_RANK", "SCREEN_HUD"), + (0x0BAD20, 0x0BB320, "RankMarker/sub_4BAD20", "WORLD_RIVAL_MARKER", "WORLD_BILLBOARD"), + (0x0BD2E0, 0x0BD360, "C2CTestSlipstream", "HUD_SLIPSTREAM", "SCREEN_HUD"), + (0x0BD360, 0x0BD500, "C2CDontLoseGF", "HUD_GF_WARNING", "SCREEN_HUD"), + (0x0BD900, 0x0BE100, "GhostGap", "HUD_GHOST", "SCREEN_HUD"), + (0x0BE300, 0x0BEA40, "DispTimeAttack2D", "HUD_TIME_ATTACK", "SCREEN_HUD"), + (0x0BEA40, 0x0BEB20, "NaviPub_DispTimeAttackGoal", "HUD_GOAL_TIME", "SCREEN_HUD"), + (0x0BEB60, 0x0BEBC0, "NaviPub_Disp_Rival", "HUD_RIVAL", "SCREEN_HUD"), + (0x0BEBC0, 0x0BEC50, "NaviPub_Disp_Heart", "HUD_HEART_TOTAL", "SCREEN_HUD"), + (0x0BEC50, 0x0BECA0, "NaviPub_Disp_Rival", "HUD_RIVAL", "SCREEN_HUD"), + (0x0BECA0, 0x0BED20, "NaviPub_Disp_Heart", "HUD_HEART_TOTAL", "SCREEN_HUD"), + (0x0BED20, 0x0BEE80, "NaviPub_Disp", "HUD_NAV_GENERIC", "SCREEN_HUD"), + (0x0FC800, 0x0FC8A0, "C2CSpeechBubbleGF_RankEmoji", "HUD_RANK_EMOJI", "SCREEN_HUD"), + (0x0FC8A0, 0x0FC900, "C2CSpeechBubbleGF_RankText", "HUD_RANK_TEXT", "SCREEN_HUD"), + (0x0FC900, 0x0FCC00, "C2CSpeechBubbleGF", "HUD_GF_SPEECH", "SCREEN_HUD"), + (0x0FCD80, 0x0FD080, "C2CSpeechBubbleGF", "HUD_GF_SPEECH", "SCREEN_HUD"), + (0x0FD560, 0x0FD680, "C2CSpeechBubbleGF", "HUD_GF_SPEECH", "SCREEN_HUD"), + (0x0FE860, 0x0FE900, "C2CSpeechBubbleGF", "HUD_GF_SPEECH", "SCREEN_HUD"), +) + + +def classify_semantic(call_rva: int) -> tuple[str, str, str]: + for begin, end, area, semantic, space_policy in SEMANTIC_RANGES: + if begin <= call_rva < end: + return area, semantic, space_policy + return "", "UNKNOWN", "UNKNOWN" + + +HUD_WORDS = re.compile( + rb"(rank|rival|score|position|goal|time|yes|no|retry|stage|ghost|lap|" + rb"checkpoint|game.?over|outrun)", + re.IGNORECASE, +) + + +@dataclass +class Section: + name: str + virtual_size: int + virtual_address: int + raw_size: int + raw_pointer: int + + def contains_rva(self, rva: int) -> bool: + return self.virtual_address <= rva < ( + self.virtual_address + max(self.virtual_size, self.raw_size) + ) + + def rva_to_offset(self, rva: int) -> int: + return self.raw_pointer + (rva - self.virtual_address) + + +@dataclass +class PE: + data: bytes + image_base: int + entry_rva: int + timestamp: int + size_of_image: int + sections: list[Section] + + def section(self, name: str) -> Section | None: + return next((s for s in self.sections if s.name == name), None) + + def bytes_at_rva(self, rva: int, size: int) -> bytes: + for section in self.sections: + if section.contains_rva(rva): + off = section.rva_to_offset(rva) + return self.data[off : off + size] + return b"" + + +def u16(data: bytes, off: int) -> int: + return struct.unpack_from(" int: + return struct.unpack_from(" PE: + if data[:2] != b"MZ": + raise ValueError("not an MZ executable") + pe_off = u32(data, 0x3C) + if data[pe_off : pe_off + 4] != b"PE\0\0": + raise ValueError("PE signature not found") + + file_header = pe_off + 4 + section_count = u16(data, file_header + 2) + timestamp = u32(data, file_header + 4) + optional_size = u16(data, file_header + 16) + optional = file_header + 20 + magic = u16(data, optional) + if magic != 0x10B: + raise ValueError(f"expected PE32/x86 optional header, got 0x{magic:04x}") + + entry_rva = u32(data, optional + 16) + image_base = u32(data, optional + 28) + size_of_image = u32(data, optional + 56) + + sections: list[Section] = [] + section_off = optional + optional_size + for i in range(section_count): + off = section_off + i * 40 + name = data[off : off + 8].split(b"\0", 1)[0].decode("ascii", "replace") + sections.append( + Section( + name=name, + virtual_size=u32(data, off + 8), + virtual_address=u32(data, off + 12), + raw_size=u32(data, off + 16), + raw_pointer=u32(data, off + 20), + ) + ) + + return PE( + data=data, + image_base=image_base, + entry_rva=entry_rva, + timestamp=timestamp, + size_of_image=size_of_image, + sections=sections, + ) + + +def guess_function_start(text: bytes, text_rva: int, index: int) -> int | None: + lo = max(0, index - 0x500) + prologues = (b"\x55\x8b\xec", b"\x53\x56\x57", b"\x56\x8b\xf1") + for pos in range(index, lo - 1, -1): + if any(text.startswith(p, pos) for p in prologues): + return text_rva + pos + return None + + +def find_calls(pe: PE) -> list[dict]: + text_section = pe.section(".text") + if not text_section: + raise ValueError(".text section not found") + + text = pe.data[ + text_section.raw_pointer : + text_section.raw_pointer + text_section.raw_size + ] + found: list[dict] = [] + + for i in range(0, max(0, len(text) - 5)): + if text[i] != 0xE8: + continue + rel = struct.unpack_from(" list[dict]: + results: list[dict] = [] + for section in pe.sections: + blob = pe.data[section.raw_pointer : section.raw_pointer + section.raw_size] + for match in re.finditer(rb"[ -~]{4,120}", blob): + value = match.group(0) + if not HUD_WORDS.search(value): + continue + results.append( + { + "rva": section.virtual_address + match.start(), + "section": section.name, + "text": value.decode("ascii", "replace"), + } + ) + return results[:500] + + +def symbol_fingerprints(pe: PE) -> list[dict]: + out = [] + for rva, name in sorted(KNOWN_TARGETS.items()): + out.append( + { + "rva": rva, + "name": name, + "bytes24": pe.bytes_at_rva(rva, 24).hex(" "), + } + ) + return out + + +def hexrva(value: int | None) -> str: + return "-" if value is None else f"0x{value:08X}" + + +def render_markdown(report: dict) -> str: + lines = [ + "# OR2006C2C EXE / HUD static analysis", + "", + "Generated automatically from the public replacement EXE used by the upstream OutRun2006Tweaks build.", + "", + "## EXE identity", + "", + f"- SHA-256: {report['sha256']}", + f"- Size: {report['file_size']} bytes", + f"- PE timestamp: {report['pe']['timestamp']}", + f"- Image base: {hexrva(report['pe']['image_base'])}", + f"- Entry RVA: {hexrva(report['pe']['entry_rva'])}", + f"- SizeOfImage: {report['pe']['size_of_image']} bytes", + f"- Known HUD call-site validation: {report['known_call_sites_found']}/{report['known_call_sites_expected']}", + "", + "## Known HUD/render anchors", + "", + "| RVA | Symbol | First 24 bytes |", + "|---:|---|---|", + ] + for sym in report["symbols"]: + lines.append( + f"| {hexrva(sym['rva'])} | {sym['name']} | {sym['bytes24']} |" + ) + + lines += [ + "", + "## Direct CALL references into HUD/sprite anchors", + "", + "| Call RVA | Function start guess | Target | Known site | Semantic | Space |", + "|---:|---:|---|---|---|---|", + ] + for call in report["calls"]: + lines.append( + f"| {hexrva(call['call_rva'])} | " + f"{hexrva(call['function_start_guess_rva'])} | " + f"{call['target']} | {call['known_call_site']} | " + f"{call['semantic']} | {call['space_policy']} |" + ) + + lines += [ + "", + "## HUD-related ASCII strings", + "", + "| RVA | Section | Text |", + "|---:|---|---|", + ] + for item in report["hud_strings"]: + text = item["text"].replace("|", "\\|") + lines.append( + f"| {hexrva(item['rva'])} | {item['section']} | {text} |" + ) + + lines += [ + "", + "## Runtime correlation", + "", + "The VR HUD inspector writes OutRun2006Tweaks-hudtrace.csv. " + "Match its call_rva column against this report. UIScaling-derived semantic " + "classification labels Time Attack, Rank, REV/gear, Ghost, goal time, " + "Heart, Rival, girlfriend speech/rank UI, and protects world-attached " + "Rival/Heart billboards from screen-HUD treatment. Previously unknown " + "callers remain concrete reverse-engineering targets without requiring " + "an interactive debugger.", + "", + ] + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--exe", required=True, type=Path) + parser.add_argument("--out-dir", required=True, type=Path) + args = parser.parse_args() + + data = args.exe.read_bytes() + pe = parse_pe(data) + calls = find_calls(pe) + found_call_rvas = {item["call_rva"] for item in calls} + missing_known_call_sites = [ + {"rva": rva, "label": label} + for rva, label in KNOWN_CALL_SITES.items() + if rva not in found_call_rvas + ] + + report = { + "source": str(args.exe), + "sha256": hashlib.sha256(data).hexdigest(), + "file_size": len(data), + "pe": { + "image_base": pe.image_base, + "entry_rva": pe.entry_rva, + "timestamp": pe.timestamp, + "size_of_image": pe.size_of_image, + "sections": [s.__dict__ for s in pe.sections], + }, + "symbols": symbol_fingerprints(pe), + "calls": calls, + "known_call_sites_expected": len(KNOWN_CALL_SITES), + "known_call_sites_found": len(KNOWN_CALL_SITES) - len(missing_known_call_sites), + "missing_known_call_sites": missing_known_call_sites, + "hud_strings": extract_hud_strings(pe), + } + + args.out_dir.mkdir(parents=True, exist_ok=True) + (args.out_dir / "OR2006C2C_EXE_ANALYSIS.json").write_text( + json.dumps(report, indent=2), encoding="utf-8" + ) + (args.out_dir / "OR2006C2C_EXE_ANALYSIS.md").write_text( + render_markdown(report), encoding="utf-8" + ) + + print(f"sha256={report['sha256']}") + print(f"calls={len(report['calls'])}") + print( + f"known_call_sites={report['known_call_sites_found']}/" + f"{report['known_call_sites_expected']}" + ) + print(f"hud_strings={len(report['hud_strings'])}") + if missing_known_call_sites: + for item in missing_known_call_sites: + print( + f"missing_known_call_site=0x{item['rva']:08X} " + f"{item['label']}" + ) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/tools/archive/rounds/README.md b/tools/archive/rounds/README.md new file mode 100644 index 000000000..83a6b92b1 --- /dev/null +++ b/tools/archive/rounds/README.md @@ -0,0 +1,11 @@ +# Archived wheel FFB round patches + +These scripts record the incremental Round 2-17 development history. +They are no longer part of the build or verification path. + +The effective result was materialized into the normal src/ tree on the +wheel-ffb branch. New development must edit the C++ sources directly. + +Current source-level verification lives at: + +tools/verify_wheel_ffb_current.py diff --git a/tools/archive/rounds/patch_wheel_round10_snow_texture.py b/tools/archive/rounds/patch_wheel_round10_snow_texture.py new file mode 100644 index 000000000..e26d35ed1 --- /dev/null +++ b/tools/archive/rounds/patch_wheel_round10_snow_texture.py @@ -0,0 +1,100 @@ +from pathlib import Path +import runpy + +ffb_path = Path('src/hooks_wheel_ffb.cpp') +ffb = ffb_path.read_text(encoding='utf-8') + + +def rep(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f'round10 {label}: expected exactly one match, got {count}') + print(f'ROUND10 patched: {label}') + return text.replace(old, new, 1) + +# Snowy Mountain (stage 4) and Ice Scape (stage 19), plus their reverse +# variants (+30), use a high steady surface roughness value. That value was +# designed for gamepad rumble and becomes a continuous 25-37 Hz tone on a DD +# wheel. Keep the general surface model unchanged elsewhere, but heavily +# attenuate only the road-texture periodic on these snow/ice stages. With the +# shipped v0.1 RoadTexture/GlobalStrength values, the resulting magnitude stays +# below the existing 1% periodic snap-to-zero threshold during normal snow-road +# driving. Steering spring/damper, cornering load, tyre slip and impacts are not +# changed. +old = ''' const float textureRoughness = + std::clamp((roughness - 0.30f) / 0.55f, 0.0f, 1.0f); + const float roadSpeedGate = + std::clamp((speedNorm - 0.05f) / 0.20f, 0.0f, 1.0f); + float roadAmp = + textureRoughness * roadSpeedGate * + static_cast(Settings::WheelFFBRoadTexture) * outputStrength; + const float roadFreq = 25.0f + 12.0f * speedNorm; +''' +new = ''' const float textureRoughness = + std::clamp((roughness - 0.30f) / 0.55f, 0.0f, 1.0f); + const float roadSpeedGate = + std::clamp((speedNorm - 0.05f) / 0.20f, 0.0f, 1.0f); + + // Xbox gamepad rumble treats snow/ice as a continuously rough + // material. On a DD wheel that becomes an unpleasant constant + // high-frequency sine. Stage IDs follow Game::StageNames: 4/19 + // are Snowy Mountain/Ice Scape and +30 are their reverse variants. + const int stageNumber = Game::GetNowStageNum(8); + const int uniqueStage = Game::GetStageUniqueNum(stageNumber); + const bool snowOrIceStage = + uniqueStage == 4 || uniqueStage == 19 || + uniqueStage == 34 || uniqueStage == 49; + constexpr float SnowIceRoadTextureScale = 0.04f; + const float stageRoadTextureScale = + snowOrIceStage ? SnowIceRoadTextureScale : 1.0f; + + float roadAmp = + textureRoughness * roadSpeedGate * + static_cast(Settings::WheelFFBRoadTexture) * outputStrength * + stageRoadTextureScale; + const float roadFreq = 25.0f + 12.0f * speedNorm; +''' +ffb = rep(ffb, old, new, 'suppress continuous snow/ice road sine') + +old_splash = ''' splashAmp_ = + (roughness - 0.7f) * speedNorm * 0.75f * roadTextureScale * outputStrength; +''' +new_splash = ''' splashAmp_ = + (roughness - 0.7f) * speedNorm * 0.75f * roadTextureScale * + outputStrength * stageRoadTextureScale; +''' +ffb = rep(ffb, old_splash, new_splash, 'apply snow/ice attenuation to road splash periodic') + +ffb_path.write_text(ffb, encoding='utf-8') +print('Applied round-10 snow/ice continuous road-vibration suppression') + +# Round-11: replace the old signed lateral ConstantForce with a deliberately +# stronger sim-style pseudo self-aligning torque model. +runpy.run_path('tools/patch_wheel_round11_strong_sat.py', run_name='__main__') + +# Round-12: feed SAT from the actual SDL multi-device steering state and remove +# the duplicate FFB page from the Controls dialog. +runpy.run_path('tools/patch_wheel_round12_sat_input_ui.py', run_name='__main__') + +# Round-13: retain every important FFB control and safe direction test on the +# single main Force Feedback page. +runpy.run_path('tools/patch_wheel_round13_ffb_ui_complete.py', run_name='__main__') + +# Round-14: make the default SDL input ownership explicit. Input Bindings owns +# steering/pedals/buttons/calibration; the main page owns force feedback only. +runpy.run_path('tools/patch_wheel_round14_input_ui_roles.py', run_name='__main__') + +# Round-15: R3 hardware test correction. Reverse the ConstantForce/SAT actuator +# direction and substantially strengthen ordinary-corner SAT and centering. +runpy.run_path('tools/patch_wheel_round15_strong_r3_sat.py', run_name='__main__') + +# Round-16: hardware feedback showed the strong power curve could snap around +# centre. Replace it with a progressive SAT shape, fade generic Spring at speed, +# hide the duplicate generic WheelFFB settings and suppress SDL rumble while the +# DirectInput COM wheel engine owns FFB output. +runpy.run_path('tools/patch_wheel_round16_natural_single_owner.py', run_name='__main__') + +# Round-17: experimental vehicle-dynamics SAT. Keep Round-16 Natural SAT as an +# A/B fallback, but derive the active SAT direction from body slip + yaw/front +# slip so counter-steer can stay continuous through wheel centre. +runpy.run_path('tools/patch_wheel_round17_physics_sat.py', run_name='__main__') diff --git a/tools/archive/rounds/patch_wheel_round11_strong_sat.py b/tools/archive/rounds/patch_wheel_round11_strong_sat.py new file mode 100644 index 000000000..0ab1a41f0 --- /dev/null +++ b/tools/archive/rounds/patch_wheel_round11_strong_sat.py @@ -0,0 +1,76 @@ +from pathlib import Path + +ffb_path = Path('src/hooks_wheel_ffb.cpp') +input_ui_path = Path('src/overlay/input_bindings_ui.cpp') +setup_ui_path = Path('src/overlay/wheel_setup_ui.cpp') + +ffb = ffb_path.read_text(encoding='utf-8') +input_ui = input_ui_path.read_text(encoding='utf-8') +setup_ui = setup_ui_path.read_text(encoding='utf-8') + + +def rep(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f'round11 {label}: expected exactly one match, got {count}') + print(f'ROUND11 patched: {label}') + return text.replace(old, new, 1) + + +# Make self-aligning torque the primary cornering force. Keep the historical +# SteeringWeight INI key for config compatibility, but change its semantics/UI +# to explicit SAT strength. Generic GUID_Spring becomes a lighter backbone and +# GUID_Damper returns to a stabilising role rather than trying to create weight. +ffb = rep(ffb, +''' Setting WheelFFBSteeringWeight{\n "WheelFFB", "SteeringWeight", 0.38f,\n "Cornering load from OutRun lateral physics.", Range{ 0.0f, 1.5f }\n };\n''', +''' Setting WheelFFBSteeringWeight{\n "WheelFFB", "SteeringWeight", 1.10f,\n "Self-aligning torque strength. Uses steering angle, speed and OutRun lateral load; unloads only in deeper drift.", Range{ 0.0f, 1.5f }\n };\n''', 'SAT setting/default') + +ffb = rep(ffb, +''' Setting WheelFFBSpringStrength{\n "WheelFFB", "SpringStrength", 0.60f,\n''', +''' Setting WheelFFBSpringStrength{\n "WheelFFB", "SpringStrength", 0.32f,\n''', 'reduce generic spring default') + +ffb = rep(ffb, +''' Setting WheelFFBDamperStrength{\n "WheelFFB", "DamperStrength", 0.42f,\n''', +''' Setting WheelFFBDamperStrength{\n "WheelFFB", "DamperStrength", 0.34f,\n''', 'damping becomes stabilizer') + +ffb = rep(ffb, +''' Setting WheelFFBSpringLoadBoost{\n "WheelFFB", "SpringLoadBoost", 0.35f,\n''', +''' Setting WheelFFBSpringLoadBoost{\n "WheelFFB", "SpringLoadBoost", 0.18f,\n''', 'spring load boost reduced') + +ffb = rep(ffb, +''' const float lateral =\n latNorm * speedNorm *\n static_cast(Settings::WheelFFBSteeringWeight) *\n gripFactor;\n\n float loadMod = 1.0f;\n''', +''' // Strong sim-style pseudo self-aligning torque (SAT). OutRun does\n // not expose tyre pneumatic trail directly, so use the real steering\n // angle as torque direction and the game's smoothed lateral signal as\n // a load magnitude. This is intentionally NOT the old signed lateral\n // ConstantForce, which could partially cancel the centering spring.\n //\n // - steering angle determines restoring direction (always toward centre)\n // - vehicle speed builds SAT progressively\n // - lateral load makes a loaded corner heavier\n // - deeper drift unloads SAT so loss of grip is felt in the wheel\n const float steerAbs = std::clamp(std::abs(steer), 0.0f, 1.0f);\n const float steerForSat = steerAbs > 0.012f\n ? std::pow((steerAbs - 0.012f) / 0.988f, 0.78f)\n : 0.0f;\n const float satSpeed = std::pow(speedNorm, 0.62f);\n const float satLoadBoost = 1.0f + 0.80f * cornerLoad;\n const float satSlip = std::clamp((driftAmt - 0.35f) / 0.65f, 0.0f, 1.0f);\n const float satGrip = 1.0f -\n static_cast(Settings::WheelFFBGripLoss) *\n std::pow(satSlip, 1.35f);\n const float satStrength = std::clamp(\n static_cast(Settings::WheelFFBSteeringWeight), 0.0f, 1.5f);\n const float selfAligningTorque =\n (steer >= 0.0f ? -1.0f : 1.0f) *\n steerForSat * satSpeed * satLoadBoost * satGrip * satStrength;\n\n float loadMod = 1.0f;\n''', 'replace signed lateral with pseudo SAT') + +ffb = rep(ffb, +''' float structural = 0.0f;\n if (crashImpulseTimer_ <= CrashCooldownFrames)\n structural = (softwareSpring + lateral) * loadMod + damper;\n''', +''' float structural = 0.0f;\n if (crashImpulseTimer_ <= CrashCooldownFrames)\n structural = (softwareSpring + selfAligningTorque) * loadMod + damper;\n''', 'SAT in structural force') + +# F11 controls and a strong R3 SAT test profile. Old comparison presets stay. +input_ui = rep(input_ui, +'''\t\tif (ImGui::Button("MOZA R3 v0.1 (default)"))\n\t\t\tapplyPreset(0.70f, 0.60f, 0.42f, 0.38f, 0.65f, 0.38f, 0.30f, 0.20f, 0.08f, 0.35f);\n''', +'''\t\tif (ImGui::Button("MOZA R3 SAT test"))\n\t\t\tapplyPreset(0.70f, 0.32f, 0.34f, 1.10f, 0.65f, 0.38f, 0.30f, 0.20f, 0.08f, 0.18f);\n''', 'R3 SAT preset') + +input_ui = rep(input_ui, +'''\t\tImGui::TextDisabled("MOZA R3 v0.1 is the personal hardware-tested default; other presets remain comparison references.");\n''', +'''\t\tImGui::TextDisabled("MOZA R3 SAT test makes self-aligning torque the main steering force; old presets remain comparison references.");\n''', 'SAT preset description') + +input_ui = rep(input_ui, +'''\t\tif (ImGui::SliderFloat("Aligning / centering", Settings::WheelFFBSpringStrength.ptr(), 0.0f, 1.5f, "%.2f"))\n\t\t\tsetting_changed(Settings::WheelFFBSpringStrength);\n\t\tif (ImGui::SliderFloat("Dynamic damping", Settings::WheelFFBDamperStrength.ptr(), 0.0f, 1.0f, "%.2f"))\n\t\t\tsetting_changed(Settings::WheelFFBDamperStrength);\n\t\tif (ImGui::SliderFloat("Cornering load", Settings::WheelFFBSteeringWeight.ptr(), 0.0f, 1.5f, "%.2f"))\n\t\t\tsetting_changed(Settings::WheelFFBSteeringWeight);\n''', +'''\t\tif (ImGui::SliderFloat("Low-speed centering spring", Settings::WheelFFBSpringStrength.ptr(), 0.0f, 1.5f, "%.2f"))\n\t\t\tsetting_changed(Settings::WheelFFBSpringStrength);\n\t\tif (ImGui::SliderFloat("Dynamic damping", Settings::WheelFFBDamperStrength.ptr(), 0.0f, 1.0f, "%.2f"))\n\t\t\tsetting_changed(Settings::WheelFFBDamperStrength);\n\t\tif (ImGui::SliderFloat("Self-aligning torque (SAT)", Settings::WheelFFBSteeringWeight.ptr(), 0.0f, 1.5f, "%.2f"))\n\t\t\tsetting_changed(Settings::WheelFFBSteeringWeight);\n''', 'bindings UI SAT labels') + +setup_ui = rep(setup_ui, +''' ImGui::TextWrapped(\n "Aligning force grows strongly with speed and corner load, unloads with grip loss, and GUID_Damper resists steering velocity without acting like a centering spring.");\n''', +''' ImGui::TextWrapped(\n "Self-aligning torque is the main cornering force: steering angle sets the return direction, speed and lateral load build torque, and deeper drift unloads it. Spring is now only a lighter centering backbone.");\n''', 'setup SAT explanation') + +setup_ui = rep(setup_ui, +''' ImGui::SliderFloat("Aligning / Spring", Settings::WheelFFBSpringStrength.ptr(), 0.0f, 1.0f, "%.2f");\n ImGui::SliderFloat("Low-speed Aligning", Settings::WheelFFBLowSpeedSpring.ptr(), 0.0f, 0.30f, "%.2f");\n ImGui::SliderFloat("Corner-load Boost", Settings::WheelFFBSpringLoadBoost.ptr(), 0.0f, 0.80f, "%.2f");\n ImGui::SliderFloat("Dynamic Damping", Settings::WheelFFBDamperStrength.ptr(), 0.0f, 0.80f, "%.2f");\n ImGui::SliderFloat("Lateral / Steering Weight", Settings::WheelFFBSteeringWeight.ptr(), 0.0f, 1.0f, "%.2f");\n''', +''' ImGui::SliderFloat("Centering Spring", Settings::WheelFFBSpringStrength.ptr(), 0.0f, 1.0f, "%.2f");\n ImGui::SliderFloat("Low-speed Aligning", Settings::WheelFFBLowSpeedSpring.ptr(), 0.0f, 0.30f, "%.2f");\n ImGui::SliderFloat("Spring Corner-load Boost", Settings::WheelFFBSpringLoadBoost.ptr(), 0.0f, 0.80f, "%.2f");\n ImGui::SliderFloat("Dynamic Damping", Settings::WheelFFBDamperStrength.ptr(), 0.0f, 0.80f, "%.2f");\n ImGui::SliderFloat("Self-aligning Torque (SAT)", Settings::WheelFFBSteeringWeight.ptr(), 0.0f, 1.50f, "%.2f");\n''', 'setup SAT sliders') + +setup_ui = rep(setup_ui, +''' if (ImGui::Button("Load MOZA R3 v0.1 (default)"))\n {\n Settings::WheelFFBGlobalStrength = 0.70f;\n Settings::WheelFFBSpringStrength = 0.60f;\n Settings::WheelFFBLowSpeedSpring = 0.08f;\n Settings::WheelFFBSpringLoadBoost = 0.35f;\n Settings::WheelFFBDamperStrength = 0.42f;\n Settings::WheelFFBSteeringWeight = 0.38f;\n Settings::WheelFFBGripLoss = 0.65f;\n Settings::WheelFFBRoadTexture = 0.30f;\n Settings::WheelFFBTireSlip = 0.20f;\n Settings::WheelFFBWallImpact = 0.38f;\n Settings::WheelFFBUseHardwareSpring = true;\n Settings::WheelFFBUseHardwareDamper = true;\n status_ = "Loaded MOZA R3 v0.1: stronger steering resistance with restrained collision feedback.";\n }\n''', +''' if (ImGui::Button("Load MOZA R3 SAT test"))\n {\n Settings::WheelFFBGlobalStrength = 0.70f;\n Settings::WheelFFBSpringStrength = 0.32f;\n Settings::WheelFFBLowSpeedSpring = 0.08f;\n Settings::WheelFFBSpringLoadBoost = 0.18f;\n Settings::WheelFFBDamperStrength = 0.34f;\n Settings::WheelFFBSteeringWeight = 1.10f;\n Settings::WheelFFBGripLoss = 0.65f;\n Settings::WheelFFBRoadTexture = 0.30f;\n Settings::WheelFFBTireSlip = 0.20f;\n Settings::WheelFFBWallImpact = 0.38f;\n Settings::WheelFFBUseHardwareSpring = true;\n Settings::WheelFFBUseHardwareDamper = true;\n status_ = "Loaded MOZA R3 SAT test: strong speed/load-dependent return torque with lighter generic spring.";\n }\n''', 'setup R3 SAT preset') + +ffb_path.write_text(ffb, encoding='utf-8') +input_ui_path.write_text(input_ui, encoding='utf-8') +setup_ui_path.write_text(setup_ui, encoding='utf-8') +print('Applied Round-11 strong pseudo-SAT model') diff --git a/tools/archive/rounds/patch_wheel_round12_sat_input_ui.py b/tools/archive/rounds/patch_wheel_round12_sat_input_ui.py new file mode 100644 index 000000000..8ff00a34d --- /dev/null +++ b/tools/archive/rounds/patch_wheel_round12_sat_input_ui.py @@ -0,0 +1,143 @@ +from pathlib import Path + +ffb_path = Path('src/hooks_wheel_ffb.cpp') +input_ui_path = Path('src/overlay/input_bindings_ui.cpp') +setup_ui_path = Path('src/overlay/wheel_setup_ui.cpp') + +ffb = ffb_path.read_text(encoding='utf-8') +input_ui = input_ui_path.read_text(encoding='utf-8') +setup_ui = setup_ui_path.read_text(encoding='utf-8') + + +def rep(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f'round12 {label}: expected exactly one match, got {count}') + print(f'ROUND12 patched: {label}') + return text.replace(old, new, 1) + + +# 1) SAT must use the exact same steering value as the active SDL3 multi-device +# input path. Calling the game's GetVolume address indirectly worked for legacy +# DirectInput, but it is needlessly dependent on the inline-hook chain when +# UseNewInput is enabled. Round11 made SAT the dominant force, so a zero/stale +# value here makes almost all steering feedback disappear. Use the exported +# InputManager steering value directly for the new-input path and preserve the +# original game function only for legacy compatibility mode. +ffb = rep( + ffb, +'''extern double __cdecl sub_1149C0(unsigned int surfaceMask, int loadColiType, DWORD* waterFlag);\n\nnamespace Settings\n{\n''', +'''extern double __cdecl sub_1149C0(unsigned int surfaceMask, int loadColiType, DWORD* waterFlag);\nextern float InputManager_SteeringValue();\n\nnamespace Settings\n{\n extern Setting UseNewInput;\n''', + 'declare direct multi-device steering source') + +ffb = rep( + ffb, +''' float read_game_steering() const\n {\n using GetVolumeFn = int(__cdecl*)(ADChannel);\n auto getVolume = Module::fn_ptr(0x53720);\n if (!getVolume)\n return 0.0f;\n\n const int raw = getVolume(ADChannel::Steering);\n return std::clamp(raw / 127.0f, -1.0f, 1.0f);\n }\n''', +''' float read_game_steering() const\n {\n if (Settings::UseNewInput)\n {\n const float steering = InputManager_SteeringValue();\n return std::isfinite(steering)\n ? std::clamp(steering, -1.0f, 1.0f)\n : 0.0f;\n }\n\n using GetVolumeFn = int(__cdecl*)(ADChannel);\n auto getVolume = Module::fn_ptr(0x53720);\n if (!getVolume)\n return 0.0f;\n\n const int raw = getVolume(ADChannel::Steering);\n return std::clamp(raw / 127.0f, -1.0f, 1.0f);\n }\n''', + 'SAT reads SDL steering directly') + +# Include SAT itself and the steering-source label in the two-second diagnostic +# line. If a particular wheel still reports no torque, one log now tells us +# whether steering/SAT or the DirectInput output stage is at fault. +ffb = rep( + ffb, +''' maybe_log(speedNorm, steer, steerRate, driftAmt, roughness, level);\n''', +''' maybe_log(speedNorm, steer, steerRate, driftAmt, roughness, selfAligningTorque, level);\n''', + 'pass SAT torque to diagnostics') + +ffb = rep( + ffb, +''' void maybe_log(\n float speedNorm,\n float steer,\n float steerRate,\n float driftAmt,\n float roughness,\n LONG level)\n''', +''' void maybe_log(\n float speedNorm,\n float steer,\n float steerRate,\n float driftAmt,\n float roughness,\n float satTorque,\n LONG level)\n''', + 'diagnostic signature carries SAT') + +ffb = rep( + ffb, +''' "WheelFFB DIAG: spd={:.2f} steer={:.3f} rate={:.4f} lat={:.2f} drift={:.2f} rough={:.2f} out={} invCF={} spring={} invSpring={} coeff={} damper={} dcoeff={} periodic={}",\n speedNorm,\n steer,\n steerRate,\n smoothedLateral_,\n driftAmt,\n roughness,\n static_cast(level),\n''', +''' "WheelFFB DIAG: spd={:.2f} steer={:.3f} rate={:.4f} lat={:.2f} drift={:.2f} rough={:.2f} sat={:.3f} steerSrc={} out={} invCF={} spring={} invSpring={} coeff={} damper={} dcoeff={} periodic={}",\n speedNorm,\n steer,\n steerRate,\n smoothedLateral_,\n driftAmt,\n roughness,\n satTorque,\n Settings::UseNewInput ? "SDL" : "legacy",\n static_cast(level),\n''', + 'diagnostics identify SAT and steering source') + + +# 2) Remove the duplicate Force Feedback page from the in-game Controls/input +# binding dialog. That dialog now owns only SDL input/binding/calibration. FFB +# device selection and tuning live in one F11 shell tab below. +input_ui = rep( + input_ui, +'''\n\t\t\t\tif (ImGui::BeginTabItem("Force Feedback"))\n\t\t\t\t{\n\t\t\t\t\tdraw_force_feedback();\n\t\t\t\t\tImGui::EndTabItem();\n\t\t\t\t}\n''', +'''\n''', + 'remove duplicate FFB tab from Controls dialog') + +# 3) The main Wheel Setup page mixed a legacy DirectInput input mapper with the +# current FFB controls. In the default SDL3 path, present it explicitly as the +# one Force Feedback page and hide legacy bindings/options. When compatibility +# input is deliberately selected, retain the old legacy setup functionality. +setup_ui = rep( + setup_ui, +''' extern Setting SteeringDeadZone;\n''', +''' extern Setting SteeringDeadZone;\n extern Setting UseNewInput;\n extern Setting WheelFFBEnable;\n''', + 'wheel setup knows active input path and FFB enable') + +setup_ui = rep( + setup_ui, +''' Kind kind() const override { return Kind::Tab; }\n const char* name() const override { return "Wheel Setup"; }\n''', +''' Kind kind() const override { return Kind::Tab; }\n const char* name() const override\n {\n return Settings::UseNewInput ? "Force Feedback" : "Legacy Wheel Setup";\n }\n''', + 'rename main tab by role') + +setup_ui = rep( + setup_ui, +''' ImGui::TextWrapped(\n "Universal DirectInput wheel setup. Bind here from a game menu; gameplay FFB follows the exact selected DirectInput GUID.");\n''', +''' if (Settings::UseNewInput)\n {\n ImGui::TextWrapped(\n "Force feedback only. Configure steering, pedals, buttons and calibration in the game Controls / Controller Setup screen. This page selects the DirectInput FFB wheel and tunes its forces.");\n }\n else\n {\n ImGui::TextWrapped(\n "Legacy DirectInput wheel setup. Use this page for compatibility-mode input bindings and force feedback.");\n }\n''', + 'explain single-purpose FFB page') + +setup_ui = rep( + setup_ui, +''' const int regularSlots = UniversalWheelProfile::regular_device_count();\n''', +''' if (!Settings::UseNewInput)\n {\n const int regularSlots = UniversalWheelProfile::regular_device_count();\n''', + 'hide legacy input mapper in SDL mode start') + +setup_ui = rep( + setup_ui, +''' if (ImGui::Button("MOZA R3/ES menu defaults"))\n apply_r3_menu_defaults();\n\n ImGui::SeparatorText("Simulation FFB");\n''', +''' if (ImGui::Button("MOZA R3/ES menu defaults"))\n apply_r3_menu_defaults();\n }\n\n ImGui::SeparatorText("Simulation FFB");\n''', + 'hide legacy input mapper in SDL mode end') + +# Selecting a wheel in the FFB page must persist even though the legacy Save +# Wheel Profile controls are hidden in SDL mode. +setup_ui = rep( + setup_ui, +''' gReader.select_by_identity(info.guidKey, info.name);\n status_ = UniversalWheelProfile::regular_device_count() > 1\n ? "Selected wheel/FFB device. Confirm the OutRun legacy input slot below when multiple controllers are present."\n : "Selected wheel; FFB device follows this product name.";\n''', +''' gReader.select_by_identity(info.guidKey, info.name);\n Settings::write(Module::UserIniPath);\n if (Settings::UseNewInput)\n status_ = "Selected FFB wheel and saved its exact DirectInput GUID. Restart the game after changing physical wheel.";\n else\n status_ = UniversalWheelProfile::regular_device_count() > 1\n ? "Selected wheel/FFB device. Confirm the OutRun legacy input slot below when multiple controllers are present."\n : "Selected wheel; FFB device follows this product name.";\n''', + 'persist FFB device selection without legacy save') + +# Loading the SAT test profile should recover from a previously disabled FFB +# setting as well as loading all force values. +setup_ui = rep( + setup_ui, +''' if (ImGui::Button("Load MOZA R3 SAT test"))\n {\n Settings::WheelFFBGlobalStrength = 0.70f;\n''', +''' if (ImGui::Button("Load MOZA R3 SAT test"))\n {\n Settings::WheelFFBEnable = true;\n Settings::WheelFFBGlobalStrength = 0.70f;\n''', + 'SAT preset explicitly enables FFB') + +# Wheel options below the FFB controls are legacy input/profile settings; hide +# them in the normal SDL3 path so there is no second steering configuration. +setup_ui = rep( + setup_ui, +''' ImGui::SeparatorText("Wheel options");\n int deadzonePercent = int(float(Settings::SteeringDeadZone) * 100.0f + 0.5f);\n''', +''' if (!Settings::UseNewInput)\n {\n ImGui::SeparatorText("Wheel options");\n int deadzonePercent = int(float(Settings::SteeringDeadZone) * 100.0f + 0.5f);\n''', + 'hide legacy wheel options in SDL mode start') + +setup_ui = rep( + setup_ui, +''' if (ImGui::Button("Save Wheel Profile"))\n save();\n\n if (target_ != BindTarget::None)\n''', +''' if (ImGui::Button("Save Wheel Profile"))\n save();\n }\n\n if (target_ != BindTarget::None)\n''', + 'hide legacy wheel options in SDL mode end') + +setup_ui = rep( + setup_ui, +''' ImGui::TextDisabled(\n "Wheel selection is shared with WheelFFB DeviceName. Restart after switching to a different physical wheel; strength sliders update the live FFB calculations immediately where supported.");\n''', +''' ImGui::TextDisabled(\n Settings::UseNewInput\n ? "Input setup: game Controls / Controller Setup. FFB setup: this page only. Restart after switching to a different physical wheel."\n : "Legacy compatibility input and FFB are configured on this page. Restart after switching to a different physical wheel.");\n''', + 'final setup navigation guidance') + +ffb_path.write_text(ffb, encoding='utf-8') +input_ui_path.write_text(input_ui, encoding='utf-8') +setup_ui_path.write_text(setup_ui, encoding='utf-8') +print('Applied Round-12 SAT steering-source fix and UI consolidation') diff --git a/tools/archive/rounds/patch_wheel_round13_ffb_ui_complete.py b/tools/archive/rounds/patch_wheel_round13_ffb_ui_complete.py new file mode 100644 index 000000000..24455cd76 --- /dev/null +++ b/tools/archive/rounds/patch_wheel_round13_ffb_ui_complete.py @@ -0,0 +1,42 @@ +from pathlib import Path + +path = Path('src/overlay/wheel_setup_ui.cpp') +text = path.read_text(encoding='utf-8') + + +def rep(old: str, new: str, label: str) -> None: + global text + count = text.count(old) + if count != 1: + raise SystemExit(f'round13 {label}: expected exactly one match, got {count}') + print(f'ROUND13 patched: {label}') + text = text.replace(old, new, 1) + + +rep( +'''#include "overlay.hpp"\n\nnamespace Settings\n''', +'''#include "overlay.hpp"\n\nvoid WheelFFB_RequestDirectionTest(int direction);\n\nnamespace Settings\n''', +'forward declare safe direction test') + +rep( +''' extern Setting WheelFFBInvertSpring;\n''', +''' extern Setting WheelFFBInvertSpring;\n extern Setting WheelFFBUsePeriodicEffects;\n extern Setting WheelFFBDebugLog;\n''', +'declare remaining FFB controls') + +rep( +''' ImGui::SeparatorText("Simulation FFB");\n ImGui::TextWrapped(\n''', +''' ImGui::SeparatorText("Simulation FFB");\n ImGui::Checkbox("Enable Force Feedback", Settings::WheelFFBEnable.ptr());\n ImGui::TextDisabled("gameplay FFB follows the exact selected DirectInput GUID.");\n ImGui::TextWrapped(\n''', +'put FFB enable and exact GUID guidance on single page') + +rep( +''' ImGui::Checkbox("Hardware GUID_Damper", Settings::WheelFFBUseHardwareDamper.ptr());\n ImGui::Checkbox("Reverse ConstantForce", Settings::WheelFFBInvertForce.ptr());\n''', +''' ImGui::Checkbox("Hardware GUID_Damper", Settings::WheelFFBUseHardwareDamper.ptr());\n ImGui::Checkbox("Hardware road/slip sine effects", Settings::WheelFFBUsePeriodicEffects.ptr());\n ImGui::SameLine();\n ImGui::Checkbox("Diagnostic logging", Settings::WheelFFBDebugLog.ptr());\n ImGui::Checkbox("Reverse ConstantForce", Settings::WheelFFBInvertForce.ptr());\n''', +'keep periodic and diagnostic toggles') + +rep( +''' if (ImGui::IsItemHovered())\n ImGui::SetTooltip("Use Reverse Spring only if the wheel pushes farther away from centre. ConstantForce direction is independent.");\n\n if (ImGui::Button("Load MOZA R3 SAT test"))\n''', +''' if (ImGui::IsItemHovered())\n ImGui::SetTooltip("Use Reverse Spring only if the wheel pushes farther away from centre. ConstantForce direction is independent.");\n\n ImGui::SeparatorText("Safe direction test");\n if (ImGui::Button("Test Left (20%)"))\n WheelFFB_RequestDirectionTest(-1);\n ImGui::SameLine();\n if (ImGui::Button("Test Right (20%)"))\n WheelFFB_RequestDirectionTest(1);\n ImGui::SameLine();\n if (ImGui::Button("Stop Test"))\n WheelFFB_RequestDirectionTest(0);\n ImGui::TextDisabled("Direction tests are hard-capped at 20% and only run during active gameplay.");\n\n if (ImGui::Button("Load MOZA R3 SAT test"))\n''', +'keep safe direction tests on single FFB page') + +path.write_text(text, encoding='utf-8') +print('Applied Round-13 complete single-page FFB controls') diff --git a/tools/archive/rounds/patch_wheel_round14_input_ui_roles.py b/tools/archive/rounds/patch_wheel_round14_input_ui_roles.py new file mode 100644 index 000000000..dca637404 --- /dev/null +++ b/tools/archive/rounds/patch_wheel_round14_input_ui_roles.py @@ -0,0 +1,43 @@ +from pathlib import Path + +input_ui_path = Path('src/overlay/input_bindings_ui.cpp') +setup_ui_path = Path('src/overlay/wheel_setup_ui.cpp') + +input_ui = input_ui_path.read_text(encoding='utf-8') +setup_ui = setup_ui_path.read_text(encoding='utf-8') + + +def rep(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f'round14 {label}: expected exactly one match, got {count}') + print(f'ROUND14 patched: {label}') + return text.replace(old, new, 1) + + +# In the default SDL3 multi-device path, Input Bindings is the only page that +# writes bindings consumed by InputManager. Make that ownership explicit in the +# UI so users do not try to use the legacy DirectInput mapper for controls. +input_ui = rep( + input_ui, +'''\t\tif (ImGui::BeginPopupModal("Input Bindings", &dialogOpen, ImGuiWindowFlags_NoSavedSettings |\n\t\t\tImGuiWindowFlags_NoTitleBar |\n\t\t\tImGuiWindowFlags_NoResize |\n\t\t\tImGuiWindowFlags_NoMove))\n\t\t{\n\t\t\tif (ImGui::Button("Quick Setup"))\n''', +'''\t\tif (ImGui::BeginPopupModal("Input Bindings", &dialogOpen, ImGuiWindowFlags_NoSavedSettings |\n\t\t\tImGuiWindowFlags_NoTitleBar |\n\t\t\tImGuiWindowFlags_NoResize |\n\t\t\tImGuiWindowFlags_NoMove))\n\t\t{\n\t\t\tImGui::TextWrapped(\n\t\t\t\t"Multi-device input setup. With UseNewInput enabled, steering, pedals, buttons, menu controls and calibration are saved and applied only from Input Bindings.");\n\t\t\tImGui::TextDisabled("Force feedback is configured separately in the Force Feedback tab.");\n\t\t\tImGui::Separator();\n\n\t\t\tif (ImGui::Button("Quick Setup"))\n''', + 'make Input Bindings the explicit SDL input owner') + +# The Force Feedback page is deliberately not another input mapper. State this +# unambiguously and use the exact Input Bindings name shown by the game. +setup_ui = rep( + setup_ui, +''' ImGui::TextWrapped(\n "Force feedback only. Configure steering, pedals, buttons and calibration in the game Controls / Controller Setup screen. This page selects the DirectInput FFB wheel and tunes its forces.");\n''', +''' ImGui::TextWrapped(\n "Force feedback only. With UseNewInput enabled, steering, pedals, buttons, menu controls and calibration come only from Input Bindings. This page does not create input bindings; it only selects the DirectInput FFB wheel and tunes its forces.");\n''', + 'make Force Feedback page explicitly FFB-only') + +setup_ui = rep( + setup_ui, +''' ? "Input setup: game Controls / Controller Setup. FFB setup: this page only. Restart after switching to a different physical wheel."\n''', +''' ? "Input setup: Input Bindings only. FFB setup: this Force Feedback page only. Restart after switching to a different physical wheel."\n''', + 'use exact Input Bindings navigation name') + +input_ui_path.write_text(input_ui, encoding='utf-8') +setup_ui_path.write_text(setup_ui, encoding='utf-8') +print('Applied Round-14 single-owner input/FFB UI roles') diff --git a/tools/archive/rounds/patch_wheel_round15_strong_r3_sat.py b/tools/archive/rounds/patch_wheel_round15_strong_r3_sat.py new file mode 100644 index 000000000..1982c5c48 --- /dev/null +++ b/tools/archive/rounds/patch_wheel_round15_strong_r3_sat.py @@ -0,0 +1,117 @@ +from pathlib import Path + +ffb_path = Path('src/hooks_wheel_ffb.cpp') +ui_path = Path('src/overlay/wheel_setup_ui.cpp') +ffb = ffb_path.read_text(encoding='utf-8') +ui = ui_path.read_text(encoding='utf-8') + + +def rep(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f'round15 {label}: expected exactly one match, got {count}') + print(f'ROUND15 patched: {label}') + return text.replace(old, new, 1) + + +# R3 hardware feedback from the Round-11/12 build showed that the structural +# steering forces were still too light and that the ConstantForce/SAT direction +# was reversed. Keep collision/road/tire levels unchanged and strengthen only +# the steering structure: hardware spring plus pseudo-SAT. +ffb = rep(ffb, + '"WheelFFB", "SpringStrength", 0.32f,', + '"WheelFFB", "SpringStrength", 0.65f,', + 'stronger centering spring default') +ffb = rep(ffb, + '"WheelFFB", "LowSpeedSpring", 0.08f,', + '"WheelFFB", "LowSpeedSpring", 0.20f,', + 'retain noticeable low-speed centering') +ffb = rep(ffb, + '"WheelFFB", "SpringLoadBoost", 0.18f,', + '"WheelFFB", "SpringLoadBoost", 0.30f,', + 'stronger spring corner-load response') +ffb = rep(ffb, + '"WheelFFB", "DamperStrength", 0.34f,', + '"WheelFFB", "DamperStrength", 0.30f,', + 'damping remains a stabilizer') +ffb = rep(ffb, + '''"WheelFFB", "SteeringWeight", 1.10f, + "Self-aligning torque strength. Uses steering angle, speed and OutRun lateral load; unloads only in deeper drift.", Range{ 0.0f, 1.5f }''', + '''"WheelFFB", "SteeringWeight", 1.45f, + "Self-aligning torque strength. Uses steering angle, speed and OutRun lateral load; unloads only in deeper drift.", Range{ 0.0f, 2.0f }''', + 'stronger SAT setting and headroom') + +# MOZA R3 ConstantForce actuator direction is opposite the logical steering sign +# used by the pseudo-SAT path. Hardware GUID_Spring has its own independent sign +# and remains positive/normal on R3. +ffb = rep(ffb, + '"WheelFFB", "InvertForce", false,', + '"WheelFFB", "InvertForce", true,', + 'R3 ConstantForce direction default') + +# Make SAT clearly present in ordinary 20-50 degree corners rather than only at +# large steering angles. Speed still gates SAT to zero when stationary, while +# hardware spring supplies the parking/low-speed centering backbone. +ffb = rep(ffb, + '? std::pow((steerAbs - 0.012f) / 0.988f, 0.78f)', + '? std::pow((steerAbs - 0.012f) / 0.988f, 0.58f)', + 'increase SAT response at moderate steering angle') +ffb = rep(ffb, + 'const float satSpeed = std::pow(speedNorm, 0.62f);', + 'const float satSpeed = std::pow(speedNorm, 0.50f);', + 'increase SAT at normal cornering speeds') +ffb = rep(ffb, + 'const float satLoadBoost = 1.0f + 0.80f * cornerLoad;', + 'const float satLoadBoost = 1.0f + 1.10f * cornerLoad;', + 'increase SAT corner-load boost') +ffb = rep(ffb, + 'const float satSlip = std::clamp((driftAmt - 0.35f) / 0.65f, 0.0f, 1.0f);', + 'const float satSlip = std::clamp((driftAmt - 0.45f) / 0.55f, 0.0f, 1.0f);', + 'retain SAT until deeper drift') +ffb = rep(ffb, + 'static_cast(Settings::WheelFFBSteeringWeight), 0.0f, 1.5f);', + 'static_cast(Settings::WheelFFBSteeringWeight), 0.0f, 2.0f);', + 'SAT runtime clamp matches setting range') + +# Make the F11 controls describe the actual R3 behavior and provide one preset +# that both fixes direction and restores strong steering weight. Persist it so +# an old user.ini cannot silently restore the previous weak/reversed values. +ui = rep(ui, + 'ImGui::SliderFloat("Self-aligning Torque (SAT)", Settings::WheelFFBSteeringWeight.ptr(), 0.0f, 1.50f, "%.2f");', + 'ImGui::SliderFloat("Self-aligning Torque (SAT)", Settings::WheelFFBSteeringWeight.ptr(), 0.0f, 2.00f, "%.2f");', + 'expand SAT slider range') +ui = rep(ui, + 'ImGui::Checkbox("Reverse ConstantForce", Settings::WheelFFBInvertForce.ptr());', + 'ImGui::Checkbox("Reverse SAT / ConstantForce", Settings::WheelFFBInvertForce.ptr());', + 'clarify SAT direction control') +ui = rep(ui, + 'if (ImGui::Button("Load MOZA R3 SAT test"))', + 'if (ImGui::Button("Load MOZA R3 Strong SAT"))', + 'rename strong R3 preset') +ui = rep(ui, + '''Settings::WheelFFBSpringStrength = 0.32f; + Settings::WheelFFBLowSpeedSpring = 0.08f; + Settings::WheelFFBSpringLoadBoost = 0.18f; + Settings::WheelFFBDamperStrength = 0.34f; + Settings::WheelFFBSteeringWeight = 1.10f;''', + '''Settings::WheelFFBSpringStrength = 0.65f; + Settings::WheelFFBLowSpeedSpring = 0.20f; + Settings::WheelFFBSpringLoadBoost = 0.30f; + Settings::WheelFFBDamperStrength = 0.30f; + Settings::WheelFFBSteeringWeight = 1.45f;''', + 'strong R3 structural-force preset') +ui = rep(ui, + '''Settings::WheelFFBUseHardwareSpring = true; + Settings::WheelFFBUseHardwareDamper = true; + status_ = "Loaded MOZA R3 SAT test: strong speed/load-dependent return torque with lighter generic spring.";''', + '''Settings::WheelFFBUseHardwareSpring = true; + Settings::WheelFFBUseHardwareDamper = true; + Settings::WheelFFBInvertForce = true; + Settings::WheelFFBInvertSpring = false; + Settings::write(Module::UserIniPath); + status_ = "Loaded MOZA R3 Strong SAT: corrected SAT direction, stronger return torque and a firmer centering spring. Saved to user.ini.";''', + 'R3 preset fixes direction and persists') + +ffb_path.write_text(ffb, encoding='utf-8') +ui_path.write_text(ui, encoding='utf-8') +print('Applied Round-15 strong MOZA R3 SAT/centering and direction correction') diff --git a/tools/archive/rounds/patch_wheel_round16_natural_single_owner.py b/tools/archive/rounds/patch_wheel_round16_natural_single_owner.py new file mode 100644 index 000000000..c00ae0f84 --- /dev/null +++ b/tools/archive/rounds/patch_wheel_round16_natural_single_owner.py @@ -0,0 +1,308 @@ +from pathlib import Path + +ffb_path = Path('src/hooks_wheel_ffb.cpp') +ui_path = Path('src/overlay/wheel_setup_ui.cpp') +vibration_path = Path('src/hooks_forcefeedback.cpp') +ffb = ffb_path.read_text(encoding='utf-8') +ui = ui_path.read_text(encoding='utf-8') +vibration = vibration_path.read_text(encoding='utf-8') + + +def rep(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f'round16 {label}: expected exactly one match, got {count}') + print(f'ROUND16 patched: {label}') + return text.replace(old, new, 1) + + +# The Xbox lateral/vibration signal is useful as a slow corner-load envelope, +# but its frame-to-frame steps are not tyre physics. Smooth it more heavily so +# an arcade state transition cannot become a steering torque step. +ffb = rep(ffb, +''' const float alpha = + std::abs(lateralRaw) > std::abs(smoothedLateral_) ? 0.25f : 0.20f; +''', +''' const float alpha = + std::abs(lateralRaw) > std::abs(smoothedLateral_) ? 0.18f : 0.12f; +''', +'lateral load smoothing') + +# GUID_Spring must not stack a second high-speed centering law on top of SAT. +# Keep a firm parking/low-speed centre and smoothly fade it as the car gets +# moving. SAT becomes the main high-speed cornering force. +ffb = rep(ffb, +''' const float lowSpeedSpring = std::clamp( + static_cast(Settings::WheelFFBLowSpeedSpring), 0.0f, 0.5f); + const float speedCurve = std::clamp( + lowSpeedSpring + (1.0f - lowSpeedSpring) * + std::pow(speedNorm, 1.60f), + 0.0f, 1.0f); + const float cornerLoad = std::clamp(std::abs(latNorm), 0.0f, 1.0f); + const float loadBoost = 1.0f + + cornerLoad * static_cast(Settings::WheelFFBSpringLoadBoost); + + const float springStrength = + std::clamp( + static_cast(Settings::WheelFFBSpringStrength) * + speedCurve * loadBoost * gripFactor, + 0.0f, 1.0f); +''', +''' const float cornerLoad = std::clamp(std::abs(latNorm), 0.0f, 1.0f); + + // Keep GUID_Spring as a low-speed/near-centre stabilizer instead of + // stacking a second high-speed SAT on top of ConstantForce. The + // fade is a smoothstep, so crossing the blend region cannot create + // a coefficient step or a sudden return-to-centre kick. + const float springFadeT = std::clamp( + (speedNorm - 0.05f) / 0.35f, 0.0f, 1.0f); + const float springFade = + springFadeT * springFadeT * (3.0f - 2.0f * springFadeT); + const float springSpeed = 1.0f - 0.88f * springFade; + const float springGrip = 0.80f + 0.20f * gripFactor; + const float springStrength = std::clamp( + static_cast(Settings::WheelFFBSpringStrength) * + springSpeed * springGrip, + 0.0f, 1.0f); +''', +'low-speed-only hardware spring') + +# Round-15 deliberately made SAT very strong, but pow(angle, .58) makes a tiny +# input disproportionately large. Replace that arcade snap with a continuous +# pseudo pneumatic-trail curve. We cannot reconstruct full tyre contact-patch +# physics from OutRun, so steering sets restoring direction while speed and the +# smoothed lateral signal only shape magnitude. Fast inward wheel motion also +# relaxes SAT temporarily so it does not launch through centre. +ffb = rep(ffb, +''' const float steerAbs = std::clamp(std::abs(steer), 0.0f, 1.0f); + const float steerForSat = steerAbs > 0.012f + ? std::pow((steerAbs - 0.012f) / 0.988f, 0.58f) + : 0.0f; + const float satSpeed = std::pow(speedNorm, 0.50f); + const float satLoadBoost = 1.0f + 1.10f * cornerLoad; + const float satSlip = std::clamp((driftAmt - 0.45f) / 0.55f, 0.0f, 1.0f); + const float satGrip = 1.0f - + static_cast(Settings::WheelFFBGripLoss) * + std::pow(satSlip, 1.35f); + const float satStrength = std::clamp( + static_cast(Settings::WheelFFBSteeringWeight), 0.0f, 2.0f); + const float selfAligningTorque = + (steer >= 0.0f ? -1.0f : 1.0f) * + steerForSat * satSpeed * satLoadBoost * satGrip * satStrength; +''', +''' const float steerAbs = std::clamp(std::abs(steer), 0.0f, 1.0f); + + // Natural pseudo-SAT: a soft 2.5% centre deadband followed by a + // sine-shaped pneumatic-trail curve. Unlike the previous power + // curve, tiny steering angles stay tiny instead of immediately + // generating a large ConstantForce. The curve remains progressive + // through normal cornering angles and naturally flattens near lock. + const float satAngleInput = std::clamp( + (steerAbs - 0.025f) / 0.975f, 0.0f, 1.0f); + constexpr float HalfPi = 1.57079632679f; + const float steerForSat = std::sin(satAngleInput * HalfPi); + + // Motion gate removes SAT at rest, then builds it continuously once + // the car is rolling. This retains strong loaded-corner steering + // without the old square-root-like jump at small steering angles. + const float satMotionT = std::clamp( + (speedNorm - 0.015f) / 0.085f, 0.0f, 1.0f); + const float satMotionGate = + satMotionT * satMotionT * (3.0f - 2.0f * satMotionT); + const float satSpeed = satMotionGate * + (0.20f + 0.80f * std::sqrt(speedNorm)); + + // OutRun exposes an arcade lateral signal rather than tyre + // pneumatic trail. Use only its magnitude as a gentle load modifier + // and never let its sign decide the FFB direction. + const float cornerLoadSmooth = + cornerLoad * cornerLoad * (3.0f - 2.0f * cornerLoad); + const float satLoadBoost = 0.72f + 0.38f * cornerLoadSmooth; + + // Lateral magnitude is only a proxy for actual slip. Unload late and + // smoothly so an ordinary loaded corner cannot repeatedly lose and + // regain SAT as the Xbox vibration signal crosses a threshold. + const float satSlipT = std::clamp( + (driftAmt - 0.60f) / 0.35f, 0.0f, 1.0f); + const float satSlip = + satSlipT * satSlipT * (3.0f - 2.0f * satSlipT); + const float satGrip = 1.0f - + 0.65f * static_cast(Settings::WheelFFBGripLoss) * satSlip; + + // A real steering rack loses net aligning acceleration as the wheel + // is already rotating quickly back toward centre. Apply a bounded + // return-rate relief to stop a DD base from whipping through zero; + // holding a corner (rate ~= 0) still gets the full SAT magnitude. + const bool returningToCentre = steer * steerRate < 0.0f; + const float returnRateT = returningToCentre + ? std::clamp(std::abs(steerRate) / 0.08f, 0.0f, 1.0f) + : 0.0f; + const float returnRateSmooth = + returnRateT * returnRateT * (3.0f - 2.0f * returnRateT); + const float satReturnRelief = 1.0f - 0.45f * returnRateSmooth; + + const float satStrength = std::clamp( + static_cast(Settings::WheelFFBSteeringWeight), 0.0f, 2.0f); + const float selfAligningTorque = + (steer >= 0.0f ? -1.0f : 1.0f) * + steerForSat * satSpeed * satLoadBoost * satGrip * + satReturnRelief * satStrength; +''', +'natural progressive SAT curve') + +# Acceleration history is coarse at 60 Hz and previously allowed +/-20..30% +# structural gain jumps. Keep the effect as subtle weight transfer only. +ffb = rep(ffb, +''' loadMod = 1.0f + std::clamp( + -longAccel * static_cast(Settings::WheelFFBWeightTransfer), + -0.20f, 0.30f); +''', +''' loadMod = 1.0f + std::clamp( + -longAccel * static_cast(Settings::WheelFFBWeightTransfer), + -0.06f, 0.08f); +''', +'bounded weight-transfer modulation') + +# There is one FFB UI. Hide the [WheelFFB] registry entries from the generic +# Settings window so those duplicate sliders cannot look like a second backend. +ffb = rep(ffb, +''' bool validate() override + { +''', +''' void declare_settings() override + { + // Wheel FFB has one dedicated F11 page. Keep every [WheelFFB] + // setting out of the generic Settings window so there is no second + // UI that looks like a competing backend. + for (auto* setting : Settings::SettingBase::registry()) + { + if (setting && setting->section() == "WheelFFB") + setting->hidden(true); + } + } + + bool validate() override + { +''', +'hide duplicate generic WheelFFB settings') + +# Dedicated Force Feedback page: expose only meaningful live controls. Legacy +# spring-shaping variables remain readable for old user.ini files but are no +# longer part of the active spring formula or shown as confusing extra knobs. +ui = rep(ui, +''' extern Setting WheelFFBSpringStrength; + extern Setting WheelFFBDamperStrength; +''', +''' extern Setting WheelFFBSpringStrength; + extern Setting WheelFFBSpringSaturation; + extern Setting WheelFFBDamperStrength; +''', +'F11 spring saturation setting') +ui = rep(ui, +''' extern Setting WheelFFBSpringLoadBoost; + extern Setting WheelFFBRoadTexture; +''', +''' extern Setting WheelFFBSpringLoadBoost; + extern Setting WheelFFBWeightTransfer; + extern Setting WheelFFBSlewRate; + extern Setting VibrationMode; + extern Setting WheelFFBRoadTexture; +''', +'F11 internal profile settings') +ui = rep(ui, +''' ImGui::TextWrapped( + "Self-aligning torque is the main cornering force: steering angle sets the return direction, speed and lateral load build torque, and deeper drift unloads it. Spring is now only a lighter centering backbone."); +''', +''' ImGui::TextWrapped( + "Single-owner wheel FFB: DirectInput COM only. SAT now rises smoothly from centre, builds with speed/corner load and unloads only in a deep slide. Centering Spring is mainly a low-speed stabilizer, so it no longer stacks a second strong high-speed return force."); + ImGui::TextDisabled("Settings > WheelFFB is hidden; changes on this page apply live. SDL gamepad rumble is suppressed while wheel FFB is enabled."); +''', +'F11 single-owner explanation') +ui = rep(ui, +''' ImGui::SliderFloat("Centering Spring", Settings::WheelFFBSpringStrength.ptr(), 0.0f, 1.0f, "%.2f"); + ImGui::SliderFloat("Low-speed Aligning", Settings::WheelFFBLowSpeedSpring.ptr(), 0.0f, 0.30f, "%.2f"); + ImGui::SliderFloat("Spring Corner-load Boost", Settings::WheelFFBSpringLoadBoost.ptr(), 0.0f, 0.80f, "%.2f"); + ImGui::SliderFloat("Dynamic Damping", Settings::WheelFFBDamperStrength.ptr(), 0.0f, 0.80f, "%.2f"); +''', +''' ImGui::SliderFloat("Centering Spring (low speed)", Settings::WheelFFBSpringStrength.ptr(), 0.0f, 1.0f, "%.2f"); + ImGui::SliderFloat("Dynamic Damping", Settings::WheelFFBDamperStrength.ptr(), 0.0f, 0.80f, "%.2f"); +''', +'remove obsolete spring-shaping sliders') +ui = rep(ui, +''' if (ImGui::Button("Load MOZA R3 Strong SAT")) + { +''', +''' if (ImGui::Button("Load MOZA R3 Natural SAT")) + { +''', +'rename R3 natural preset') +ui = rep(ui, +''' Settings::WheelFFBSpringStrength = 0.65f; + Settings::WheelFFBLowSpeedSpring = 0.20f; + Settings::WheelFFBSpringLoadBoost = 0.30f; + Settings::WheelFFBDamperStrength = 0.30f; + Settings::WheelFFBSteeringWeight = 1.45f; + Settings::WheelFFBGripLoss = 0.65f; +''', +''' Settings::WheelFFBSpringStrength = 0.65f; + Settings::WheelFFBSpringSaturation = 0.95f; + Settings::WheelFFBLowSpeedSpring = 0.20f; + Settings::WheelFFBSpringLoadBoost = 0.30f; + Settings::WheelFFBDamperStrength = 0.30f; + Settings::WheelFFBSteeringWeight = 1.75f; + Settings::WheelFFBGripLoss = 0.65f; + Settings::WheelFFBWeightTransfer = 0.20f; + Settings::WheelFFBSlewRate = 0.045f; +''', +'natural SAT profile values') +ui = rep(ui, +''' Settings::WheelFFBInvertForce = true; + Settings::WheelFFBInvertSpring = false; + Settings::write(Module::UserIniPath); + status_ = "Loaded MOZA R3 Strong SAT: corrected SAT direction, stronger return torque and a firmer centering spring. Saved to user.ini."; +''', +''' Settings::WheelFFBInvertForce = true; + Settings::WheelFFBInvertSpring = false; + Settings::VibrationMode = 0; + Settings::write(Module::UserIniPath); + status_ = "Loaded MOZA R3 Natural SAT: smooth progressive SAT, low-speed-only spring assist and single DirectInput COM wheel FFB. Saved to user.ini."; +''', +'natural preset owns vibration/output') + +# The original Xbox vibration hook may route to SDL_RumbleGamepad under +# UseNewInput. While native wheel FFB is active, suppress that independent +# rumble path so only WheelFFBEngine owns wheel force output. +vibration = rep(vibration, +'''namespace Settings +{ +\tSetting VibrationMode{ "Controls", "VibrationMode", 0, +''', +'''namespace Settings +{ + extern Setting WheelFFBEnable; +\tSetting VibrationMode{ "Controls", "VibrationMode", 0, +''', +'declare native wheel FFB owner in vibration hook') +vibration = rep(vibration, +'''void SetVibration(int userId, float leftMotor, float rightMotor) +{ + if (!Settings::VibrationMode) + return; +''', +'''void SetVibration(int userId, float leftMotor, float rightMotor) +{ + // With SDL multi-device input, wheel FFB is owned exclusively by the + // DirectInput COM WheelFFBEngine. Do not let the independent gamepad-rumble + // path reach a wheel/gamepad interface at the same time. + if (Settings::UseNewInput && Settings::WheelFFBEnable) + return; + + if (!Settings::VibrationMode) + return; +''', +'suppress SDL rumble while native wheel FFB is enabled') + +ffb_path.write_text(ffb, encoding='utf-8') +ui_path.write_text(ui, encoding='utf-8') +vibration_path.write_text(vibration, encoding='utf-8') +print('Applied Round-16 natural SAT, low-speed spring and single-owner FFB routing') diff --git a/tools/archive/rounds/patch_wheel_round17_physics_sat.py b/tools/archive/rounds/patch_wheel_round17_physics_sat.py new file mode 100644 index 000000000..969d1eb27 --- /dev/null +++ b/tools/archive/rounds/patch_wheel_round17_physics_sat.py @@ -0,0 +1,101 @@ +from pathlib import Path + +ffb_path = Path('src/hooks_wheel_ffb.cpp') +ui_path = Path('src/overlay/wheel_setup_ui.cpp') +vibration_path = Path('src/hooks_forcefeedback.cpp') +ffb = ffb_path.read_text(encoding='utf-8') +ui = ui_path.read_text(encoding='utf-8') +vibration = vibration_path.read_text(encoding='utf-8') + + +def rep(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f'round17 {label}: expected exactly one match, got {count}') + print(f'ROUND17 patched: {label}') + return text.replace(old, new, 1) + + +ffb = rep(ffb, +'''#include "game_addrs.hpp"\n''', +'''#include "game_addrs.hpp"\n#include "hooks_wheel_physics_sat.hpp"\n''', +'include physics SAT helper') + +ffb = rep(ffb, +''' Setting WheelFFBSteeringWeight{\n "WheelFFB", "SteeringWeight", 1.45f,\n "Self-aligning torque strength. Uses steering angle, speed and OutRun lateral load; unloads only in deeper drift.", Range{ 0.0f, 2.0f }\n };\n\n''', +''' Setting WheelFFBSteeringWeight{\n "WheelFFB", "SteeringWeight", 1.45f,\n "Self-aligning torque strength. Physics SAT uses body slip, yaw rate and lateral load; Natural SAT remains available for comparison.", Range{ 0.0f, 2.0f }\n };\n\n Setting WheelFFBPhysicsSat{\n "WheelFFB", "PhysicsSAT", true,\n "Experimental body-slip/yaw SAT instead of steering-centre direction alone."\n };\n\n''', +'physics SAT setting') + +ffb = rep(ffb, +''' const float selfAligningTorque =\n (steer >= 0.0f ? -1.0f : 1.0f) *\n steerForSat * satSpeed * satLoadBoost * satGrip *\n satReturnRelief * satStrength;\n''', +''' const float naturalSatTorque =\n (steer >= 0.0f ? -1.0f : 1.0f) *\n steerForSat * satSpeed * satLoadBoost * satGrip *\n satReturnRelief * satStrength;\n\n // Physics SAT derives force direction from estimated front slip,\n // not merely from steering sign. During initial basis calibration\n // retain only a small Natural SAT safety net, then crossfade over\n // 24 valid physics ticks. Once active, a valid zero Physics SAT is\n // truly zero; Natural SAT never leaks back in around wheel centre.\n const float physicsSatTorque = physicsSat_.update(\n car, steer, speedNorm, cornerLoadSmooth, returnRateSmooth,\n static_cast(Settings::WheelFFBGripLoss),\n satStrength, satSpeed);\n const float physicsFallback = naturalSatTorque * 0.15f;\n const float physicsMix = physicsSat_.activationBlend();\n const float selfAligningTorque = Settings::WheelFFBPhysicsSat\n ? physicsFallback + (physicsSatTorque - physicsFallback) * physicsMix\n : naturalSatTorque;\n''', +'front-slip physics SAT selection') + +ffb = rep(ffb, +''' smoothedLateral_ = 0.0f;\n prevSteer_ = 0.0f;\n prevStructuralLevel_ = 0;\n''', +''' smoothedLateral_ = 0.0f;\n prevSteer_ = 0.0f;\n physicsSat_.reset();\n prevStructuralLevel_ = 0;\n''', +'reset physics SAT state') + +ffb = rep(ffb, +''' "WheelFFB DIAG: spd={:.2f} steer={:.3f} rate={:.4f} lat={:.2f} drift={:.2f} rough={:.2f} sat={:.3f} steerSrc={} out={} invCF={} spring={} invSpring={} coeff={} damper={} dcoeff={} periodic={}",\n''', +''' "WheelFFB DIAG: spd={:.2f} steer={:.3f} rate={:.4f} lat={:.2f} drift={:.2f} rough={:.2f} sat={:.3f} phys={} basis=M70r{} cal={:.2f} mix={:.2f} beta={:.3f} yaw={:.3f} fslip={:.3f} vLat={:.5f} vLong={:.5f} step={:.5f} spdLen={:.5f} spdCorr={:.2f} steerSrc={} out={} invCF={} spring={} invSpring={} coeff={} damper={} dcoeff={} periodic={}",\n''', +'physics diagnostic format') +ffb = rep(ffb, +''' satTorque,\n Settings::UseNewInput ? "SDL" : "legacy",\n''', +''' satTorque,\n Settings::WheelFFBPhysicsSat\n ? (physicsSat_.calibrated()\n ? (physicsSat_.sampleValid() ? "ACTIVE" : "HOLD")\n : "CAL")\n : "OFF",\n physicsSat_.forwardAxis(),\n physicsSat_.calibrationConfidence(),\n physicsSat_.activationBlend(),\n physicsSat_.bodySlip(),\n physicsSat_.yawRate(),\n physicsSat_.frontSlip(),\n physicsSat_.vLat(),\n physicsSat_.vLong(),\n physicsSat_.positionStep(),\n physicsSat_.spdLen(),\n physicsSat_.spdCorrelation(),\n Settings::UseNewInput ? "SDL" : "legacy",\n''', +'physics diagnostic values') + +ffb = rep(ffb, +''' float smoothedLateral_ = 0.0f;\n float prevSteer_ = 0.0f;\n float crashImpulseForce_ = 0.0f;\n''', +''' float smoothedLateral_ = 0.0f;\n float prevSteer_ = 0.0f;\n WheelPhysicsSatV1 physicsSat_{};\n float crashImpulseForce_ = 0.0f;\n''', +'physics SAT state member') + +# The previous implementation hooked CalcVibrationValues. In Tweaks that +# function is explicitly called BEFORE GamePlCar_Ctrl.call(car), so Physics SAT +# was always looking at the previous simulation state. Keep one GamePlCar_Ctrl +# owner and let hooks_forcefeedback call WheelFFB only after original car +# physics returns. +ffb = rep(ffb, +''' inline static SafetyHookInline CalcVibrationHook_ = {};\n\n static void __cdecl calc_vibration_hook(EVWORK_CAR* car)\n {\n CalcVibrationHook_.ccall(car);\n gWheelFFB.update(car);\n }\n\n''', +''' // FFB update ownership lives in the existing GamePlCar_Ctrl wrapper.\n // Do not install another inline hook on the physics/vibration path.\n\n''', +'remove pre-physics CalcVibration hook') + +ffb = rep(ffb, +''' bool validate() override\n {\n // Always install the lightweight update hook. The engine itself\n // gates on WheelFFBEnable, which makes F11 enable/disable genuinely\n // live even when the game was launched with Enable=false.\n return true;\n }\n\n bool apply() override\n {\n // Hook Tweaks' Xbox vibration calculation instead of installing a\n // second GamePlCar_Ctrl hook. This gives one deterministic 60 Hz\n // update after the game's car physics has been calculated.\n CalcVibrationHook_ = safetyhook::create_inline(\n reinterpret_cast(&CalcVibrationValues),\n calc_vibration_hook);\n\n if (!CalcVibrationHook_)\n {\n spdlog::error("WheelFFB: failed to hook CalcVibrationValues");\n return false;\n }\n\n spdlog::info(\n "WheelFFB: DirectInput COM hook installed; device init deferred to first gameplay tick");\n return true;\n }\n''', +''' bool validate() override\n {\n // The engine itself gates on WheelFFBEnable; the existing Vibration\n // GamePlCar_Ctrl wrapper invokes us after each 60 Hz physics tick.\n return true;\n }\n\n bool apply() override\n {\n spdlog::info(\n "WheelFFB: DirectInput COM engine registered; update runs after GamePlCar_Ctrl physics");\n return true;\n }\n''', +'post-physics WheelFFB hook ownership') + +ffb = rep(ffb, +''' WheelFFBHook WheelFFBHook::instance;\n}\n\n\nvoid WheelFFB_RequestDirectionTest(int direction)\n''', +''' WheelFFBHook WheelFFBHook::instance;\n}\n\nvoid __cdecl WheelFFB_UpdateAfterPhysics(EVWORK_CAR* car)\n{\n gWheelFFB.update(car);\n}\n\nvoid WheelFFB_RequestDirectionTest(int direction)\n''', +'export post-physics WheelFFB update') + +vibration = rep(vibration, +'''extern "C"\n{\n void __cdecl CalcVibrationValues(EVWORK_CAR* car);\n long _ftol2(double);\n}\n''', +'''extern "C"\n{\n void __cdecl CalcVibrationValues(EVWORK_CAR* car);\n long _ftol2(double);\n}\n\nvoid __cdecl WheelFFB_UpdateAfterPhysics(EVWORK_CAR* car);\n''', +'declare post-physics WheelFFB update') + +vibration = rep(vibration, +''' CalcVibrationValues(car);\n SetVibration(0, VibrationLeftMotor, VibrationRightMotor);\n\n GamePlCar_Ctrl.call(car);\n''', +''' // Keep legacy/gamepad vibration timing unchanged. Wheel FFB is\n // intentionally different: sample the car only AFTER its physics Ctrl\n // returns so body motion/yaw belong to the current simulation tick.\n CalcVibrationValues(car);\n SetVibration(0, VibrationLeftMotor, VibrationRightMotor);\n\n GamePlCar_Ctrl.call(car);\n WheelFFB_UpdateAfterPhysics(car);\n''', +'run WheelFFB after car physics') + +ui = rep(ui, +''' extern Setting WheelFFBSteeringWeight;\n extern Setting WheelFFBGripLoss;\n''', +''' extern Setting WheelFFBSteeringWeight;\n extern Setting WheelFFBPhysicsSat;\n extern Setting WheelFFBGripLoss;\n''', +'F11 physics SAT setting') + +ui = rep(ui, +''' ImGui::SliderFloat("Self-aligning Torque (SAT)", Settings::WheelFFBSteeringWeight.ptr(), 0.0f, 2.00f, "%.2f");\n ImGui::SliderFloat("Grip-loss Unload", Settings::WheelFFBGripLoss.ptr(), 0.0f, 1.0f, "%.2f");\n''', +''' ImGui::SliderFloat("Self-aligning Torque (SAT)", Settings::WheelFFBSteeringWeight.ptr(), 0.0f, 2.00f, "%.2f");\n ImGui::Checkbox("Physics SAT v1 (body slip + yaw)", Settings::WheelFFBPhysicsSat.ptr());\n if (ImGui::IsItemHovered())\n ImGui::SetTooltip("Uses post-physics OutRun car motion/body heading to estimate front slip. Disable for the Round-16 Natural SAT comparison.");\n ImGui::SliderFloat("Grip-loss Unload", Settings::WheelFFBGripLoss.ptr(), 0.0f, 1.0f, "%.2f");\n''', +'F11 physics SAT toggle') + +ui = rep(ui, +''' if (ImGui::Button("Load MOZA R3 Natural SAT"))\n {\n''', +''' if (ImGui::Button("Load MOZA R3 Physics SAT v1"))\n {\n Settings::WheelFFBEnable = true;\n Settings::WheelFFBPhysicsSat = true;\n Settings::WheelFFBGlobalStrength = 0.70f;\n Settings::WheelFFBSpringStrength = 0.65f;\n Settings::WheelFFBSpringSaturation = 0.95f;\n Settings::WheelFFBDamperStrength = 0.28f;\n Settings::WheelFFBSteeringWeight = 1.45f;\n Settings::WheelFFBGripLoss = 0.65f;\n Settings::WheelFFBWeightTransfer = 0.15f;\n Settings::WheelFFBSlewRate = 0.040f;\n Settings::WheelFFBRoadTexture = 0.30f;\n Settings::WheelFFBTireSlip = 0.20f;\n Settings::WheelFFBWallImpact = 0.38f;\n Settings::WheelFFBUseHardwareSpring = true;\n Settings::WheelFFBUseHardwareDamper = true;\n Settings::WheelFFBInvertForce = true;\n Settings::WheelFFBInvertSpring = false;\n Settings::WheelFFBDebugLog = true;\n Settings::VibrationMode = 0;\n Settings::write(Module::UserIniPath);\n status_ = "Loaded MOZA R3 Physics SAT v1: post-physics body-slip/yaw SAT with diagnostic logging. Saved to user.ini.";\n }\n ImGui::SameLine();\n\n if (ImGui::Button("Load MOZA R3 Natural SAT"))\n {\n Settings::WheelFFBPhysicsSat = false;\n''', +'Physics SAT preset plus Natural A/B fallback') + +ffb_path.write_text(ffb, encoding='utf-8') +ui_path.write_text(ui, encoding='utf-8') +vibration_path.write_text(vibration, encoding='utf-8') +print('Applied round-17 physics SAT: post-physics sampling, robust activation state and Natural A/B fallback') diff --git a/tools/archive/rounds/patch_wheel_round2_core.py b/tools/archive/rounds/patch_wheel_round2_core.py new file mode 100644 index 000000000..d310ae637 --- /dev/null +++ b/tools/archive/rounds/patch_wheel_round2_core.py @@ -0,0 +1,39 @@ +from pathlib import Path + +ffb = Path('src/hooks_wheel_ffb.cpp') +text = ffb.read_text(encoding='utf-8') + +def rep(old,new,label): + global text + c=text.count(old) + if c!=1: + raise SystemExit(f'{label}: expected 1, got {c}') + text=text.replace(old,new,1) + print('patched',label) + +# Pass 2: keep stale physics history from generating false crash/load events after menus/watchdog resets. +rep(''' void reset_signal_state()\n {\n smoothedLateral_ = 0.0f;\n prevSteer_ = 0.0f;\n prevStructuralLevel_ = 0;\n prevSpringCoefficient_ = 0;\n prevDamperCoefficient_ = 0;\n crashImpulseTimer_ = 0;\n crashImpulseForce_ = 0.0f;\n gearShiftTimer_ = 0;\n warmupFrames_ = 0;\n roadPhase_ = 0.0f;\n slipPhase_ = 0.0f;\n splashTimer_ = 0;\n splashAmp_ = 0.0f;\n }\n''',''' void reset_signal_state()\n {\n smoothedLateral_ = 0.0f;\n prevSteer_ = 0.0f;\n prevStructuralLevel_ = 0;\n prevSpringCoefficient_ = 0;\n prevDamperCoefficient_ = 0;\n crashImpulseTimer_ = 0;\n crashImpulseForce_ = 0.0f;\n gearShiftTimer_ = 0;\n warmupFrames_ = 0;\n roadPhase_ = 0.0f;\n slipPhase_ = 0.0f;\n splashTimer_ = 0;\n splashAmp_ = 0.0f;\n\n // Menu/race transitions must not reuse samples from the previous\n // gameplay segment. Old speed/lateral history can otherwise look\n // like a huge first-frame deceleration and synthesize a false crash\n // or weight-transfer kick when returning from F11/the main menu.\n std::fill_n(speedHistory_, SpeedHistoryCount, 0.0f);\n speedHistoryIndex_ = 0;\n std::fill_n(lateralHistory_, LateralHistoryCount, 0.0f);\n lateralHistoryIndex_ = 0;\n prevGear_ = 0;\n prevCollisionFlags_ = 0;\n }\n''','reset stale physics history') + +# Pass 3: never reacquire background-exclusive FFB immediately after WM_ACTIVATEAPP zeroes it. +rep(''' enabledLastTick_ = true;\n\n lastUpdateTick_ = GetTickCount();\n''',''' enabledLastTick_ = true;\n\n // Safety first for a DD base: WM_ACTIVATEAPP releases exclusive\n // ownership when the game loses focus. Do not let the 60 Hz update\n // loop immediately reacquire it while another application is active.\n if (!appActive_)\n {\n if (initialized_)\n zero_all_forces();\n return;\n }\n\n lastUpdateTick_ = GetTickCount();\n''','focus gate') + +rep(''' case WM_ACTIVATEAPP:\n if (wParam == FALSE)\n self->zero_all_forces();\n break;\n''',''' case WM_ACTIVATEAPP:\n self->appActive_ = (wParam != FALSE);\n if (!self->appActive_)\n {\n self->zero_all_forces();\n if (self->device_ && self->deviceAcquired_)\n {\n self->device_->Unacquire();\n self->deviceAcquired_ = false;\n }\n }\n else\n {\n // Re-enter with the normal DD warm-up ramp instead of\n // restoring the previous torque in one frame.\n self->warmupFrames_ = 0;\n }\n break;\n''','focus release/rearm') + +# Pass 5: make guard installation idempotent so later device reinitialization cannot hook ExitProcess twice. +rep(''' void install_exit_guards()\n {\n if (!gameHwnd_)\n return;\n\n if (SetWindowSubclass(\n gameHwnd_,\n window_subclass_proc,\n FFB_SUBCLASS_ID,\n reinterpret_cast(this)))\n {\n SetTimer(\n gameHwnd_,\n FFB_WATCHDOG_TIMER_ID,\n FFB_WATCHDOG_INTERVAL_MS,\n nullptr);\n spdlog::info("WheelFFB: window exit/watchdog guard installed");\n }\n else\n {\n spdlog::warn("WheelFFB: SetWindowSubclass failed");\n }\n\n if (auto* exitProc =\n GetProcAddress(GetModuleHandleA("kernel32.dll"), "ExitProcess"))\n {\n exitProcessHook_ =\n safetyhook::create_inline(exitProc, exit_process_hook);\n if (exitProcessHook_)\n spdlog::info("WheelFFB: ExitProcess safety hook installed");\n }\n\n activeEngine_ = this;\n }\n''',''' void install_exit_guards()\n {\n if (!gameHwnd_)\n return;\n\n // USB/device-loss recovery may call initialize() more than once.\n // Window subclassing and inline-hooking ExitProcess must be\n // idempotent or a second initialization can create a bad hook chain.\n if (subclassHwnd_ != gameHwnd_)\n {\n if (subclassHwnd_ && IsWindow(subclassHwnd_))\n {\n KillTimer(subclassHwnd_, FFB_WATCHDOG_TIMER_ID);\n RemoveWindowSubclass(\n subclassHwnd_, window_subclass_proc, FFB_SUBCLASS_ID);\n }\n\n if (SetWindowSubclass(\n gameHwnd_,\n window_subclass_proc,\n FFB_SUBCLASS_ID,\n reinterpret_cast(this)))\n {\n subclassHwnd_ = gameHwnd_;\n SetTimer(\n gameHwnd_,\n FFB_WATCHDOG_TIMER_ID,\n FFB_WATCHDOG_INTERVAL_MS,\n nullptr);\n spdlog::info("WheelFFB: window exit/watchdog guard installed");\n }\n else\n {\n spdlog::warn("WheelFFB: SetWindowSubclass failed");\n }\n }\n\n if (!exitProcessHook_)\n {\n if (auto* exitProc =\n GetProcAddress(GetModuleHandleA("kernel32.dll"), "ExitProcess"))\n {\n exitProcessHook_ =\n safetyhook::create_inline(exitProc, exit_process_hook);\n if (exitProcessHook_)\n spdlog::info("WheelFFB: ExitProcess safety hook installed");\n }\n }\n\n activeEngine_ = this;\n }\n''','idempotent exit guards') + +# Pass 6: failed live-gain updates should back off rather than hammer a problematic driver at 60 Hz. +rep(''' const DWORD gain = configured_effect_gain();\n if (gain == lastEffectGain_)\n return;\n''',''' const DWORD now = GetTickCount();\n if (nextGainRetryTick_ != 0 && now < nextGainRetryTick_)\n return;\n\n const DWORD gain = configured_effect_gain();\n if (gain == lastEffectGain_)\n return;\n''','gain retry gate') + +rep(''' if (!failed)\n {\n lastEffectGain_ = gain;\n spdlog::info(\n "WheelFFB: live GlobalStrength applied to active effects ({}%)",\n static_cast((gain * 100u) / DI_FFNOMINALMAX));\n }\n''',''' if (!failed)\n {\n lastEffectGain_ = gain;\n nextGainRetryTick_ = 0;\n spdlog::info(\n "WheelFFB: live GlobalStrength applied to active effects ({}%)",\n static_cast((gain * 100u) / DI_FFNOMINALMAX));\n }\n else\n {\n // Keep F11 responsive without turning a persistent driver\n // failure into hundreds of SetParameters calls per second.\n nextGainRetryTick_ = GetTickCount() + 250;\n }\n''','gain retry backoff') + +# Pass 7: RoadTexture=0 should silence water/splash road detail too, while preserving old default amplitude at 0.20. +rep(''' splashAmp_ = (roughness - 0.7f) * speedNorm * 0.75f;\n''',''' const float roadTextureScale = std::clamp(\n static_cast(Settings::WheelFFBRoadTexture) / 0.20f,\n 0.0f, 5.0f);\n splashAmp_ =\n (roughness - 0.7f) * speedNorm * 0.75f * roadTextureScale;\n''','road texture scales splash') + +# State additions. +rep(''' HWND gameHwnd_ = nullptr;\n\n bool initialized_ = false;\n bool panicStopped_ = false;\n bool deviceAcquired_ = false;\n bool enabledLastTick_ = true;\n''',''' HWND gameHwnd_ = nullptr;\n HWND subclassHwnd_ = nullptr;\n\n bool initialized_ = false;\n bool panicStopped_ = false;\n bool deviceAcquired_ = false;\n bool enabledLastTick_ = true;\n bool appActive_ = true;\n''','focus/subclass state') + +rep(''' DWORD lastGainErrorLog_ = 0;\n DWORD lastEffectGain_ = 0xFFFFFFFFu;\n''',''' DWORD lastGainErrorLog_ = 0;\n DWORD lastEffectGain_ = 0xFFFFFFFFu;\n DWORD nextGainRetryTick_ = 0;\n''','gain backoff state') + +ffb.write_text(text,encoding='utf-8') +print('Applied round-2 FFB lifecycle/safety hardening') diff --git a/tools/archive/rounds/patch_wheel_round2_ui.py b/tools/archive/rounds/patch_wheel_round2_ui.py new file mode 100644 index 000000000..c88c24a55 --- /dev/null +++ b/tools/archive/rounds/patch_wheel_round2_ui.py @@ -0,0 +1,27 @@ +from pathlib import Path + +p = Path('src/overlay/wheel_setup_ui.cpp') +text = p.read_text(encoding='utf-8') + +def rep(old,new,label): + global text + c=text.count(old) + if c!=1: + raise SystemExit(f'{label}: expected 1 got {c}') + text=text.replace(old,new,1) + print('patched',label) + +# Pass 9: limit switch interception to the universal menu actions we synthesize. +rep(''' inline static uint32_t menuHeld_ = 0;\n inline static uint32_t menuPressed_ = 0;\n inline static uint32_t previousMenuHeld_ = 0;\n inline static DWORD lastApplyLog_ = 0;\n''',''' inline static uint32_t menuHeld_ = 0;\n inline static uint32_t menuPressed_ = 0;\n inline static uint32_t previousMenuHeld_ = 0;\n inline static DWORD lastApplyLog_ = 0;\n\n inline static constexpr uint32_t UniversalMenuSwitchMask =\n (1u << int(SwitchId::Start)) |\n (1u << int(SwitchId::Back)) |\n (1u << int(SwitchId::A)) |\n (1u << int(SwitchId::B)) |\n (1u << int(SwitchId::GearDown)) |\n (1u << int(SwitchId::GearUp)) |\n (1u << int(SwitchId::SelectionUp)) |\n (1u << int(SwitchId::SelectionDown)) |\n (1u << int(SwitchId::SelectionLeft)) |\n (1u << int(SwitchId::SelectionRight));\n\n static bool pure_universal_menu_query(uint32_t switches)\n {\n return switches != 0 &&\n (switches & UniversalMenuSwitchMask) != 0 &&\n (switches & ~UniversalMenuSwitchMask) == 0;\n }\n''','universal menu query mask') + +rep(''' static int SwitchNow_dest(uint32_t switches)\n {\n const int result = SwitchNowHook.ccall(switches);\n if (result || !active())\n return result;\n return (menuHeld_ & switches) != 0 ? 1 : 0;\n }\n\n static int SwitchOn_dest(uint32_t switches)\n {\n const int result = SwitchOnHook.ccall(switches);\n if (result || !active())\n return result;\n return (menuPressed_ & switches) != 0 ? 1 : 0;\n }\n''',''' static int SwitchNow_dest(uint32_t switches)\n {\n const int result = SwitchNowHook.ccall(switches);\n if (result || !active() || !pure_universal_menu_query(switches))\n return result;\n return (menuHeld_ & switches) == switches ? 1 : 0;\n }\n\n static int SwitchOn_dest(uint32_t switches)\n {\n const int result = SwitchOnHook.ccall(switches);\n if (result || !active() || !pure_universal_menu_query(switches))\n return result;\n return (menuPressed_ & switches) == switches ? 1 : 0;\n }\n''','exact menu query semantics') + +# Pass 10: clamp the saved legacy slot against the live regular-game devices. +rep(''' const int slot = std::clamp(int(Settings::WheelUniversalLegacyDeviceIndex), 0, 2);\n auto* device = device_at(slot);\n if (!device)\n return;\n''',''' const int regularCount = std::max(device_count() - 1, 0);\n if (regularCount <= 0)\n return;\n const int maxSlot = std::min(regularCount - 1, 2);\n const int slot = std::clamp(\n int(Settings::WheelUniversalLegacyDeviceIndex), 0, maxSlot);\n if (slot != int(Settings::WheelUniversalLegacyDeviceIndex))\n Settings::WheelUniversalLegacyDeviceIndex = slot;\n\n auto* device = device_at(slot);\n if (!device)\n return;\n''','apply mapping slot clamp') + +rep(''' const int slot = std::clamp(int(Settings::WheelUniversalLegacyDeviceIndex), 0, 2);\n auto* device = device_at(slot);\n if (!device)\n return false;\n''',''' const int regularCount = std::max(device_count() - 1, 0);\n if (regularCount <= 0)\n return false;\n const int maxSlot = std::min(regularCount - 1, 2);\n const int slot = std::clamp(\n int(Settings::WheelUniversalLegacyDeviceIndex), 0, maxSlot);\n if (slot != int(Settings::WheelUniversalLegacyDeviceIndex))\n Settings::WheelUniversalLegacyDeviceIndex = slot;\n\n auto* device = device_at(slot);\n if (!device)\n return false;\n''','import mapping slot clamp') + +rep(''' int currentSlot = std::clamp(\n int(Settings::WheelUniversalLegacyDeviceIndex), 0,\n std::min(regularSlots - 1, 2));\n''',''' int currentSlot = std::clamp(\n int(Settings::WheelUniversalLegacyDeviceIndex), 0,\n std::min(regularSlots - 1, 2));\n if (currentSlot != int(Settings::WheelUniversalLegacyDeviceIndex))\n Settings::WheelUniversalLegacyDeviceIndex = currentSlot;\n''','UI slot clamp persistence') + +p.write_text(text,encoding='utf-8') +print('Applied round-2 universal menu/legacy-slot hardening') diff --git a/tools/archive/rounds/patch_wheel_round3_10pass.py b/tools/archive/rounds/patch_wheel_round3_10pass.py new file mode 100644 index 000000000..a022c89fd --- /dev/null +++ b/tools/archive/rounds/patch_wheel_round3_10pass.py @@ -0,0 +1,123 @@ +from pathlib import Path + +ffb = Path('src/hooks_wheel_ffb.cpp') +ui = Path('src/overlay/wheel_setup_ui.cpp') +text = ffb.read_text(encoding='utf-8') +uitext = ui.read_text(encoding='utf-8') + + +def rep(old: str, new: str, label: str) -> None: + global text + count = text.count(old) + if count != 1: + raise SystemExit(f'{label}: expected 1 match, got {count}') + text = text.replace(old, new, 1) + print(f'PASS OK: {label}') + + +def urep(old: str, new: str, label: str) -> None: + global uitext + count = uitext.count(old) + if count != 1: + raise SystemExit(f'{label}: expected 1 UI match, got {count}') + uitext = uitext.replace(old, new, 1) + print(f'PASS OK: {label}') + + +# PASS 1/10 - Direction semantics. +# InvertForce is for the game's signed ConstantForce signal. Coupling that toggle +# to GUID_Spring can turn a perfectly good centering spring into an unstable +# runaway spring. Give spring direction its own explicit setting instead. +rep( +''' Setting WheelFFBInvertForce{\n "WheelFFB", "InvertForce", false,\n "Reverse steering force direction."\n };\n\n Setting WheelFFBDebugLog{\n''', +''' Setting WheelFFBInvertForce{\n "WheelFFB", "InvertForce", false,\n "Reverse ConstantForce steering/event direction without changing the centering spring."\n };\n\n Setting WheelFFBInvertSpring{\n "WheelFFB", "InvertSpring", false,\n "Reverse only the DirectInput GUID_Spring condition direction. Leave off when the wheel returns toward centre normally."\n };\n\n Setting WheelFFBDebugLog{\n''', +'decouple ConstantForce and spring inversion') + +rep( +''' // R3 testing showed the old negative GUID_Spring coefficient pushes\n // the wheel farther in the direction of steering instead of back to\n // center. Keep hardware-spring direction consistent with the global\n // InvertForce option: normal (false) uses the R3 centering sign, and\n // inverted (true) reverses it together with ConstantForce.\n const LONG coefficient = Settings::WheelFFBInvertForce\n ? -coefficientMagnitude\n : coefficientMagnitude;\n''', +''' // R3 hardware testing established positive condition coefficients\n // as the normal centering sign for this backend. Keep this independent\n // from ConstantForce inversion so fixing corner-force direction can\n // never accidentally turn the centering spring into a runaway force.\n const LONG coefficient = Settings::WheelFFBInvertSpring\n ? -coefficientMagnitude\n : coefficientMagnitude;\n''', +'independent GUID_Spring direction') + +rep( +''' "WheelFFB: GUID_Spring created (saturation={} / {}, R3 normal sign=positive, follows InvertForce)",\n''', +''' "WheelFFB: GUID_Spring created (saturation={} / {}, R3 normal sign=positive, independent direction toggle)",\n''', +'accurate spring direction diagnostic') + +# PASS 2/10 - F11 must expose both direction controls so physical testing does not +# require hand-editing INI files between runs. +urep( +''' extern Setting WheelFFBUseHardwareSpring;\n extern Setting WheelFFBUseHardwareDamper;\n\n Setting WheelUniversalSetupEnable{\n''', +''' extern Setting WheelFFBUseHardwareSpring;\n extern Setting WheelFFBUseHardwareDamper;\n extern Setting WheelFFBInvertForce;\n extern Setting WheelFFBInvertSpring;\n\n Setting WheelUniversalSetupEnable{\n''', +'F11 direction-setting declarations') + +urep( +''' ImGui::Checkbox("Hardware GUID_Spring", Settings::WheelFFBUseHardwareSpring.ptr());\n ImGui::SameLine();\n ImGui::Checkbox("Hardware GUID_Damper", Settings::WheelFFBUseHardwareDamper.ptr());\n\n if (ImGui::Button("Load simulation baseline"))\n''', +''' ImGui::Checkbox("Hardware GUID_Spring", Settings::WheelFFBUseHardwareSpring.ptr());\n ImGui::SameLine();\n ImGui::Checkbox("Hardware GUID_Damper", Settings::WheelFFBUseHardwareDamper.ptr());\n ImGui::Checkbox("Reverse ConstantForce", Settings::WheelFFBInvertForce.ptr());\n ImGui::SameLine();\n ImGui::Checkbox("Reverse Spring", Settings::WheelFFBInvertSpring.ptr());\n if (ImGui::IsItemHovered())\n ImGui::SetTooltip("Use Reverse Spring only if the wheel pushes farther away from centre. ConstantForce direction is independent.");\n\n if (ImGui::Button("Load simulation baseline"))\n''', +'F11 independent direction controls') + +# PASS 3/10 - Hardware spring live-disable semantics. +# Previously disabling UseHardwareSpring left springEffect_ alive; because +# softwareSpring tests springEffect_ != nullptr, the checkbox effectively did +# nothing and could not switch to software centering until restart/recreate. +rep( +''' if (springEffect_)\n {\n update_spring(\n suppressSpringForImpact\n ? 0.0f\n : springStrength * warmupScale * recreateScale);\n }\n\n const float softwareSpring =\n''', +''' if (!Settings::WheelFFBUseHardwareSpring && springEffect_)\n {\n update_spring(0.0f);\n springEffect_->Stop();\n safe_release_effect(springEffect_, "hardware spring disabled");\n prevSpringCoefficient_ = 0;\n prevSpringSaturation_ = 0;\n springStrategy_ = 1;\n spdlog::info("WheelFFB: hardware spring disabled live; using software centering");\n }\n\n if (springEffect_)\n {\n update_spring(\n suppressSpringForImpact\n ? 0.0f\n : springStrength * warmupScale * recreateScale);\n }\n\n const float softwareSpring =\n''', +'live GUID_Spring disable actually switches backend') + +# PASS 4/10 - Actuator command failure must not be masked by later successful +# SetParameters calls. Retry acquisition/SETACTUATORSON on the next tick. +rep( +''' const HRESULT actuatorHr =\n device_->SendForceFeedbackCommand(DISFFC_SETACTUATORSON);\n if (FAILED(actuatorHr))\n note_device_failure("SETACTUATORSON", actuatorHr);\n }\n''', +''' const HRESULT actuatorHr =\n device_->SendForceFeedbackCommand(DISFFC_SETACTUATORSON);\n if (FAILED(actuatorHr))\n {\n note_device_failure("SETACTUATORSON", actuatorHr);\n device_->Unacquire();\n deviceAcquired_ = false;\n return;\n }\n clear_device_failure();\n }\n''', +'actuator-on retry is not masked') + +# PASS 5/10 - Initial actuator enable is part of successful initialization. +rep( +''' deviceAcquired_ = true;\n\n device_->SendForceFeedbackCommand(DISFFC_SETACTUATORSON);\n\n if (!create_constant_effect())\n''', +''' deviceAcquired_ = true;\n\n const HRESULT actuatorOnHr =\n device_->SendForceFeedbackCommand(DISFFC_SETACTUATORSON);\n if (FAILED(actuatorOnHr))\n {\n spdlog::error(\n "WheelFFB: SETACTUATORSON during initialization failed (0x{:08X})",\n (unsigned)actuatorOnHr);\n release_device();\n release_directinput();\n return false;\n }\n\n if (!create_constant_effect())\n''', +'initial actuator enable is checked') + +# PASS 6/10 - Periodic effects are a pair. Running one GUID_Sine while the +# fallback synthesizes both channels doubles whichever hardware effect survived. +rep( +''' roadState_ = {};\n slipState_ = {};\n periodicsActive_ =\n roadTextureEffect_ != nullptr && tireSlipEffect_ != nullptr;\n\n if (!periodicsActive_)\n spdlog::warn("WheelFFB: hardware periodic effects unavailable; using constant-force fallback");\n''', +''' roadState_ = {};\n slipState_ = {};\n periodicsActive_ =\n roadTextureEffect_ != nullptr && tireSlipEffect_ != nullptr;\n\n if (!periodicsActive_)\n {\n // Treat the two sines atomically. A half-created pair plus the\n // software fallback would double one signal and distort tuning.\n disable_periodics();\n recreateHoldoffUntil_ = GetTickCount() + 500;\n spdlog::warn(\n "WheelFFB: complete hardware periodic pair unavailable; using ConstantForce fallback for both signals");\n }\n''', +'atomic road/tire periodic backend') + +# PASS 7/10 - Device-loss teardown should explicitly leave the acquisition flag +# false even if a stale driver object throws before release_device reaches it. +rep( +''' release_effects_for_reinitialize();\n\n if (device_)\n''', +''' release_effects_for_reinitialize();\n deviceAcquired_ = false;\n\n if (device_)\n''', +'reinit acquisition state is fail-safe false') + +# PASS 8/10 - Reinitialization must not wait on an obsolete gain retry timer. +# (The recovery patch already clears it; assert by rewriting the explanatory +# block to make the invariant explicit and visible in diagnostics.) +rep( +''' lastEffectGain_ = 0xFFFFFFFFu;\n nextGainRetryTick_ = 0;\n recreateHoldoffUntil_ = 0;\n''', +''' lastEffectGain_ = 0xFFFFFFFFu;\n nextGainRetryTick_ = 0;\n lastGainErrorLog_ = 0;\n recreateHoldoffUntil_ = 0;\n''', +'reinit clears stale live-gain error state') + +# PASS 9/10 - A successful full initialize should start with a fresh device fault +# window, otherwise a pre-replug timestamp can immediately retrigger teardown. +rep( +''' deviceReinitPending_ = false;\n deviceFailureSince_ = 0;\n deviceReinitAfter_ = 0;\n reset_signal_state();\n''', +''' deviceReinitPending_ = false;\n deviceFailureSince_ = 0;\n deviceReinitAfter_ = 0;\n retryAfter_ = 0;\n reset_signal_state();\n''', +'fresh recovery window after successful initialize') + +# PASS 10/10 - Improve the 2-second diagnostic so physical R3 testing can tell +# direction/backend state without guessing from the wheel feel alone. +rep( +''' "WheelFFB DIAG: spd={:.2f} steer={:.3f} rate={:.4f} lat={:.2f} drift={:.2f} rough={:.2f} out={} spring={} coeff={} damper={} dcoeff={} periodic={}",\n''', +''' "WheelFFB DIAG: spd={:.2f} steer={:.3f} rate={:.4f} lat={:.2f} drift={:.2f} rough={:.2f} out={} invCF={} spring={} invSpring={} coeff={} damper={} dcoeff={} periodic={}",\n''', +'FFB diagnostic direction/backend visibility') + +rep( +''' static_cast(level),\n springEffect_ ? "HW" : "SW",\n static_cast(prevSpringCoefficient_),\n damperEffect_ ? "HW" : "SW",\n static_cast(prevDamperCoefficient_),\n periodicsActive_);\n''', +''' static_cast(level),\n bool(Settings::WheelFFBInvertForce),\n springEffect_ ? "HW" : "SW",\n bool(Settings::WheelFFBInvertSpring),\n static_cast(prevSpringCoefficient_),\n damperEffect_ ? "HW" : "SW",\n static_cast(prevDamperCoefficient_),\n periodicsActive_);\n''', +'FFB diagnostic direction/backend arguments') + +ffb.write_text(text, encoding='utf-8') +ui.write_text(uitext, encoding='utf-8') +print('Completed round-3 ten-pass wheel/FFB correctness hardening') diff --git a/tools/archive/rounds/patch_wheel_round4_fivepass.py b/tools/archive/rounds/patch_wheel_round4_fivepass.py new file mode 100644 index 000000000..599948ad2 --- /dev/null +++ b/tools/archive/rounds/patch_wheel_round4_fivepass.py @@ -0,0 +1,79 @@ +from pathlib import Path +p=Path('src/hooks_wheel_ffb.cpp') +s=p.read_text(encoding='utf-8') + +def rep(old,new,label): + global s + if old not in s: + raise SystemExit(f'missing {label}') + s=s.replace(old,new,1) + print('patched:',label) + +# PASS 1: wrap-safe DWORD deadline comparisons. +rep(''' constexpr DWORD FFB_DEVICE_RETRY_MS = 750;\n''',''' constexpr DWORD FFB_DEVICE_RETRY_MS = 750;\n\n // GetTickCount wraps roughly every 49.7 days. Compare deadlines by signed\n // subtraction so retry/holdoff gates remain correct across the wrap.\n bool tick_before(DWORD now, DWORD deadline)\n {\n return deadline != 0 && static_cast(now - deadline) < 0;\n }\n\n bool tick_reached(DWORD now, DWORD deadline)\n {\n return deadline == 0 || static_cast(now - deadline) >= 0;\n }\n''','tick helpers') +for old,new,label in [ +('if (updateNow < deviceReinitAfter_)','if (tick_before(updateNow, deviceReinitAfter_))','device reinit deadline'), +('if (GetTickCount() < retryAfter_)','if (tick_before(GetTickCount(), retryAfter_))','initialize retry deadline'), +('GetTickCount() >= recreateHoldoffUntil_)','tick_reached(GetTickCount(), recreateHoldoffUntil_))','periodic holdoff'), +('GetTickCount() >= springRecreateHoldoffUntil_)','tick_reached(GetTickCount(), springRecreateHoldoffUntil_))','spring holdoff'), +('GetTickCount() >= damperRecreateHoldoffUntil_)','tick_reached(GetTickCount(), damperRecreateHoldoffUntil_))','damper holdoff'), +('if (nextGainRetryTick_ != 0 && now < nextGainRetryTick_)','if (tick_before(now, nextGainRetryTick_))','gain retry deadline'), +('if (now < recreateHoldoffUntil_)','if (tick_before(now, recreateHoldoffUntil_))','constant recreate holdoff'), +('GetTickCount() < ctx->self->failedInterfaceUntil_','tick_before(GetTickCount(), ctx->self->failedInterfaceUntil_)','failed sibling holdoff'), +]: + rep(old,new,label) + +# PASS 2: do not assume foreground ownership when installing after game already lost focus. +rep(''' gameHwnd_ = Game::GameHwnd();\n if (!gameHwnd_)\n''',''' gameHwnd_ = Game::GameHwnd();\n if (!gameHwnd_)\n''','game hwnd anchor') +rep(''' release_directinput();\n return false;\n }\n\n hr = device_->SetCooperativeLevel(\n''',''' release_directinput();\n return false;\n }\n\n // initialize() can run while the game is already in the background,\n // before our WM_ACTIVATEAPP subclass has seen a focus transition.\n // Seed appActive_ from the real foreground window so an exclusive\n // DD device is never acquired merely because the default was true.\n appActive_ = (GetForegroundWindow() == gameHwnd_);\n if (!appActive_)\n {\n release_device();\n release_directinput();\n retryAfter_ = GetTickCount() + FFB_DEVICE_RETRY_MS;\n return false;\n }\n\n hr = device_->SetCooperativeLevel(\n''','foreground seed') + +# PASS 3: reacquire must restore actuator state too. +rep(''' if (SUCCEEDED(acquireHr) || acquireHr == S_FALSE)\n {\n deviceAcquired_ = true;\n clear_device_failure();\n return true;\n }\n''',''' if (SUCCEEDED(acquireHr) || acquireHr == S_FALSE)\n {\n deviceAcquired_ = true;\n const HRESULT actuatorHr =\n device_->SendForceFeedbackCommand(DISFFC_SETACTUATORSON);\n if (FAILED(actuatorHr))\n {\n device_->Unacquire();\n deviceAcquired_ = false;\n note_device_failure("reacquire SETACTUATORSON", actuatorHr);\n return false;\n }\n clear_device_failure();\n return true;\n }\n''','reacquire actuator restore') + +# PASS 4: persistent type-specific buffers. DirectInput documents that these +# buffers must remain valid for effect lifetime / until replaced. +rep(''' IDirectInputEffect* tireSlipEffect_ = nullptr;\n std::vector actuatorAxes_;\n''',''' IDirectInputEffect* tireSlipEffect_ = nullptr;\n\n // DirectInput does not promise to copy lpvTypeSpecificParams. Keep the\n // backing structures alive for as long as their effects exist.\n DICONSTANTFORCE constantParams_{};\n DICONDITION springParams_{};\n DICONDITION damperParams_{};\n DIPERIODIC roadPeriodicParams_{};\n DIPERIODIC tireSlipPeriodicParams_{};\n\n std::vector actuatorAxes_;\n''','persistent effect buffers') + +rep(''' DICONSTANTFORCE cf{};\n cf.lMagnitude = 0;\n''',''' constantParams_ = {};\n constantParams_.lMagnitude = 0;\n''','constant create buffer') +rep(''' effect.cbTypeSpecificParams = sizeof(cf);\n effect.lpvTypeSpecificParams = &cf;\n''',''' effect.cbTypeSpecificParams = sizeof(constantParams_);\n effect.lpvTypeSpecificParams = &constantParams_;\n''','constant create pointer') +rep(''' DICONSTANTFORCE cf{};\n LONG directions[2] = { 1L, 0L };\n''',''' constantParams_ = {};\n LONG directions[2] = { 1L, 0L };\n''','constant update buffer') +rep(''' params.cbTypeSpecificParams = sizeof(cf);\n params.lpvTypeSpecificParams = &cf;\n''',''' params.cbTypeSpecificParams = sizeof(constantParams_);\n params.lpvTypeSpecificParams = &constantParams_;\n''','constant update pointer') +rep('cf.lMagnitude = std::abs(requestedLevel);','constantParams_.lMagnitude = std::abs(requestedLevel);','constant polar magnitude') +rep('cf.lMagnitude = requestedLevel;','constantParams_.lMagnitude = requestedLevel;','constant cart magnitude') + +rep(''' DICONDITION condition{};\n condition.lOffset = 0;\n condition.lPositiveCoefficient = 0;\n condition.lNegativeCoefficient = 0;\n condition.dwPositiveSaturation = saturation;\n condition.dwNegativeSaturation = saturation;\n condition.lDeadBand = 0;\n''',''' springParams_ = {};\n springParams_.lOffset = 0;\n springParams_.lPositiveCoefficient = 0;\n springParams_.lNegativeCoefficient = 0;\n springParams_.dwPositiveSaturation = saturation;\n springParams_.dwNegativeSaturation = saturation;\n springParams_.lDeadBand = 0;\n''','spring create buffer') +rep(''' effect.cbTypeSpecificParams = sizeof(condition);\n effect.lpvTypeSpecificParams = &condition;\n''',''' effect.cbTypeSpecificParams = sizeof(springParams_);\n effect.lpvTypeSpecificParams = &springParams_;\n''','spring create pointer') +rep(''' DICONDITION condition{};\n condition.lOffset = 0;\n condition.lPositiveCoefficient = coefficient;\n condition.lNegativeCoefficient = coefficient;\n condition.dwPositiveSaturation = saturation;\n condition.dwNegativeSaturation = saturation;\n condition.lDeadBand = 0;\n\n DIEFFECT params{};\n params.dwSize = sizeof(params);\n params.cbTypeSpecificParams = sizeof(condition);\n params.lpvTypeSpecificParams = &condition;\n''',''' springParams_ = {};\n springParams_.lOffset = 0;\n springParams_.lPositiveCoefficient = coefficient;\n springParams_.lNegativeCoefficient = coefficient;\n springParams_.dwPositiveSaturation = saturation;\n springParams_.dwNegativeSaturation = saturation;\n springParams_.lDeadBand = 0;\n\n DIEFFECT params{};\n params.dwSize = sizeof(params);\n params.cbTypeSpecificParams = sizeof(springParams_);\n params.lpvTypeSpecificParams = &springParams_;\n''','spring update buffer') + +rep(''' DICONDITION condition{};\n condition.lOffset = 0;\n condition.lPositiveCoefficient = 0;\n condition.lNegativeCoefficient = 0;\n condition.dwPositiveSaturation = DI_FFNOMINALMAX;\n condition.dwNegativeSaturation = DI_FFNOMINALMAX;\n condition.lDeadBand = 0;\n''',''' damperParams_ = {};\n damperParams_.lOffset = 0;\n damperParams_.lPositiveCoefficient = 0;\n damperParams_.lNegativeCoefficient = 0;\n damperParams_.dwPositiveSaturation = DI_FFNOMINALMAX;\n damperParams_.dwNegativeSaturation = DI_FFNOMINALMAX;\n damperParams_.lDeadBand = 0;\n''','damper create buffer') +rep(''' effect.cbTypeSpecificParams = sizeof(condition);\n effect.lpvTypeSpecificParams = &condition;\n''',''' effect.cbTypeSpecificParams = sizeof(damperParams_);\n effect.lpvTypeSpecificParams = &damperParams_;\n''','damper create pointer') +rep(''' DICONDITION condition{};\n condition.lOffset = 0;\n condition.lPositiveCoefficient = coefficient;\n condition.lNegativeCoefficient = coefficient;\n condition.dwPositiveSaturation = DI_FFNOMINALMAX;\n condition.dwNegativeSaturation = DI_FFNOMINALMAX;\n condition.lDeadBand = 0;\n\n DIEFFECT params{};\n params.dwSize = sizeof(params);\n params.cbTypeSpecificParams = sizeof(condition);\n params.lpvTypeSpecificParams = &condition;\n''',''' damperParams_ = {};\n damperParams_.lOffset = 0;\n damperParams_.lPositiveCoefficient = coefficient;\n damperParams_.lNegativeCoefficient = coefficient;\n damperParams_.dwPositiveSaturation = DI_FFNOMINALMAX;\n damperParams_.dwNegativeSaturation = DI_FFNOMINALMAX;\n damperParams_.lDeadBand = 0;\n\n DIEFFECT params{};\n params.dwSize = sizeof(params);\n params.cbTypeSpecificParams = sizeof(damperParams_);\n params.lpvTypeSpecificParams = &damperParams_;\n''','damper update buffer') + +old=''' DIPERIODIC periodic{};\n periodic.dwMagnitude = 0;\n periodic.lOffset = 0;\n periodic.dwPhase = 0;\n periodic.dwPeriod = static_cast(1000000.0f / initialHz);\n''' +new=''' DIPERIODIC& periodic = (std::strcmp(label, "RoadTexture") == 0)\n ? roadPeriodicParams_\n : tireSlipPeriodicParams_;\n periodic = {};\n periodic.dwMagnitude = 0;\n periodic.lOffset = 0;\n periodic.dwPhase = 0;\n periodic.dwPeriod = static_cast(1000000.0f / initialHz);\n''' +rep(old,new,'periodic create persistent buffer') +old=''' DIPERIODIC periodic{};\n periodic.dwMagnitude = mag;\n periodic.dwPeriod = period;\n''' +new=''' DIPERIODIC& periodic = (effect == roadTextureEffect_)\n ? roadPeriodicParams_\n : tireSlipPeriodicParams_;\n periodic = {};\n periodic.dwMagnitude = mag;\n periodic.dwPeriod = period;\n''' +rep(old,new,'periodic update persistent buffer') + +rep(''' DICONSTANTFORCE cf{};\n DIEFFECT eff{};\n eff.dwSize = sizeof(eff);\n eff.cbTypeSpecificParams = sizeof(cf);\n eff.lpvTypeSpecificParams = &cf;\n''',''' constantParams_ = {};\n DIEFFECT eff{};\n eff.dwSize = sizeof(eff);\n eff.cbTypeSpecificParams = sizeof(constantParams_);\n eff.lpvTypeSpecificParams = &constantParams_;\n''','panic constant persistent buffer') + +# PASS 5a: hook must exist even when config starts disabled, otherwise F11 live enable is impossible. +rep(''' bool validate() override\n {\n return Settings::WheelFFBEnable;\n }\n''',''' bool validate() override\n {\n // Always install the lightweight update hook. The engine itself\n // gates on WheelFFBEnable, which makes F11 enable/disable genuinely\n // live even when the game was launched with Enable=false.\n return true;\n }\n''','startup-disabled live enable') + +# PASS 5b: cancelling a test outside gameplay must not reacquire exclusive FFB. +rep(''' if (direction == 0)\n {\n manualTestFrames_ = 0;\n manualTestDirection_ = 1;\n if (initialized_ && !panicStopped_)\n set_constant_force(0);\n return;\n }\n''',''' if (direction == 0)\n {\n manualTestFrames_ = 0;\n manualTestDirection_ = 1;\n const bool inGameplay =\n Game::current_mode && (*Game::current_mode == STATE_GAME);\n if (initialized_ && !panicStopped_ && deviceAcquired_ && inGameplay)\n set_constant_force(0);\n return;\n }\n''','direction cancel no menu reacquire') + +# PASS 1b: menu helper retry timers use wrap-safe signed subtraction. +for menu_path, cls in [(Path("src/hooks_wheel_r3_menu_dpad.hpp"), "DPad"), (Path("src/hooks_wheel_r3_menu_ab.hpp"), "AB")]: + m = menu_path.read_text(encoding="utf-8") + old = " if (now < retryAfter)\n return false;" + new = " if (retryAfter != 0 && static_cast(now - retryAfter) < 0)\n return false;" + if old not in m: + raise SystemExit(f"missing {cls} wrap-safe retry gate") + m = m.replace(old, new, 1) + menu_path.write_text(m, encoding="utf-8") + print("patched:", cls, "wrap-safe retry deadline") + +p.write_text(s,encoding="utf-8") +print("Applied round-4 five-pass source hardening") \ No newline at end of file diff --git a/tools/archive/rounds/patch_wheel_round5_compile_fix.py b/tools/archive/rounds/patch_wheel_round5_compile_fix.py new file mode 100644 index 000000000..6304520a4 --- /dev/null +++ b/tools/archive/rounds/patch_wheel_round5_compile_fix.py @@ -0,0 +1,29 @@ +from pathlib import Path + +path = Path('src/input_manager.hpp') +s = path.read_text(encoding='utf-8') + +old = 'setPrimaryGamepad(std::min(removedIndex, int(controllers.size()) - 1));' +new = '''const int replacementIndex = + removedIndex < int(controllers.size()) + ? removedIndex + : int(controllers.size()) - 1; + setPrimaryGamepad(replacementIndex);''' + +count = s.count(old) +if count != 1: + raise SystemExit(f'round5 compile fix: expected exactly one std::min hotplug site, got {count}') + +s = s.replace(old, new, 1) +path.write_text(s, encoding='utf-8') + +# Guard against Windows min/max macro regressions in this header. Windows.h can +# be included before input_manager.hpp in several translation units, so an +# unprotected std::min( token is not safe even when is included. +if 'std::min(' in s or 'std::max(' in s: + raise SystemExit('round5 compile fix: unsafe std::min/std::max call remains in input_manager.hpp') + +if 'setPrimaryGamepad(replacementIndex);' not in s: + raise SystemExit('round5 compile fix: replacement controller selection missing') + +print('Applied round-5 MSVC min/max macro compile fix') diff --git a/tools/archive/rounds/patch_wheel_round5_fivepass.py b/tools/archive/rounds/patch_wheel_round5_fivepass.py new file mode 100644 index 000000000..64c5bffd2 --- /dev/null +++ b/tools/archive/rounds/patch_wheel_round5_fivepass.py @@ -0,0 +1,167 @@ +from pathlib import Path + +ffb_path = Path('src/hooks_wheel_ffb.cpp') +input_path = Path('src/input_manager.hpp') +ffb = ffb_path.read_text(encoding='utf-8') +ims = input_path.read_text(encoding='utf-8') + + +def rep(text: str, old: str, new: str, label: str, expected: int = 1) -> str: + count = text.count(old) + if count != expected: + raise SystemExit(f'round5 {label}: expected {expected} match(es), got {count}') + print(f'ROUND5 patched: {label}') + return text.replace(old, new, expected) + + +def repair_or_require(text: str, broken: str, fixed: str, label: str) -> str: + if broken in text: + print(f'ROUND5 repaired: {label}') + return text.replace(broken, fixed, 1) + if fixed not in text: + raise SystemExit(f'round5 {label}: neither broken nor fixed form found') + print(f'ROUND5 already fixed: {label}') + return text + + +# --------------------------------------------------------------------------- +# Build-blocker found by the previous five-pass run. +# round4 replaced an inner deadline expression but accidentally consumed the +# closing ')' belonging to the outer if statement. Repair the three generated +# conditions before doing the next source-level review. +# --------------------------------------------------------------------------- +for deadline, label in [ + ('recreateHoldoffUntil_', 'periodic holdoff outer parenthesis'), + ('springRecreateHoldoffUntil_', 'spring holdoff outer parenthesis'), + ('damperRecreateHoldoffUntil_', 'damper holdoff outer parenthesis'), +]: + broken = f'tick_reached(GetTickCount(), {deadline})\n {{' + fixed = f'tick_reached(GetTickCount(), {deadline}))\n {{' + ffb = repair_or_require(ffb, broken, fixed, label) + + +# --------------------------------------------------------------------------- +# PASS 1 - Initialization failures must participate in sibling-interface +# fallback, and GetCapabilities() itself must be checked rather than treating +# an HRESULT failure as a device that simply lacks FFB capability. +# --------------------------------------------------------------------------- +ffb = rep( + ffb, +''' hr = directInput_->CreateDevice(selectedGuid_, &device_, nullptr);\n if (FAILED(hr) || !device_)\n {\n spdlog::error(\n "WheelFFB: CreateDevice('{}') failed (0x{:08X})",\n selectedName_, (unsigned)hr);\n release_directinput();\n return false;\n }\n\n DIDEVCAPS caps{};\n caps.dwSize = sizeof(caps);\n device_->GetCapabilities(&caps);\n\n if ((caps.dwFlags & DIDC_FORCEFEEDBACK) == 0)\n {\n spdlog::error("WheelFFB: '{}' does not report force-feedback capability", selectedName_);\n release_device();\n release_directinput();\n return false;\n }\n''', +''' hr = directInput_->CreateDevice(selectedGuid_, &device_, nullptr);\n if (FAILED(hr) || !device_)\n {\n const HRESULT failHr = FAILED(hr) ? hr : E_FAIL;\n mark_selected_interface_failed("CreateDevice", failHr);\n spdlog::error(\n "WheelFFB: CreateDevice('{}') failed (0x{:08X})",\n selectedName_, (unsigned)failHr);\n release_directinput();\n return false;\n }\n\n DIDEVCAPS caps{};\n caps.dwSize = sizeof(caps);\n hr = device_->GetCapabilities(&caps);\n if (FAILED(hr))\n {\n mark_selected_interface_failed("GetCapabilities", hr);\n spdlog::error(\n "WheelFFB: GetCapabilities('{}') failed (0x{:08X})",\n selectedName_, (unsigned)hr);\n release_device();\n release_directinput();\n return false;\n }\n\n if ((caps.dwFlags & DIDC_FORCEFEEDBACK) == 0)\n {\n mark_selected_interface_failed("force-feedback capability", E_NOINTERFACE);\n spdlog::error("WheelFFB: '{}' does not report force-feedback capability", selectedName_);\n release_device();\n release_directinput();\n return false;\n }\n''', + 'capability HRESULT and CreateDevice failover') + +for old, new, label in [ + ( +''' if (FAILED(hr))\n {\n spdlog::error("WheelFFB: SetDataFormat failed (0x{:08X})", (unsigned)hr);\n release_device();\n''', +''' if (FAILED(hr))\n {\n mark_selected_interface_failed("SetDataFormat", hr);\n spdlog::error("WheelFFB: SetDataFormat failed (0x{:08X})", (unsigned)hr);\n release_device();\n''', + 'SetDataFormat sibling failover'), + ( +''' if (FAILED(hr))\n {\n spdlog::error(\n "WheelFFB: SetCooperativeLevel(EXCLUSIVE|BACKGROUND) failed (0x{:08X})",\n (unsigned)hr);\n release_device();\n''', +''' if (FAILED(hr))\n {\n mark_selected_interface_failed("SetCooperativeLevel", hr);\n spdlog::error(\n "WheelFFB: SetCooperativeLevel(EXCLUSIVE|BACKGROUND) failed (0x{:08X})",\n (unsigned)hr);\n release_device();\n''', + 'SetCooperativeLevel sibling failover'), + ( +''' if (FAILED(hr))\n {\n spdlog::error("WheelFFB: Acquire('{}') failed (0x{:08X})", selectedName_, (unsigned)hr);\n release_device();\n''', +''' if (FAILED(hr))\n {\n mark_selected_interface_failed("Acquire", hr);\n spdlog::error("WheelFFB: Acquire('{}') failed (0x{:08X})", selectedName_, (unsigned)hr);\n release_device();\n''', + 'Acquire sibling failover'), + ( +''' if (FAILED(actuatorOnHr))\n {\n spdlog::error(\n "WheelFFB: SETACTUATORSON during initialization failed (0x{:08X})",\n (unsigned)actuatorOnHr);\n release_device();\n''', +''' if (FAILED(actuatorOnHr))\n {\n mark_selected_interface_failed("initial SETACTUATORSON", actuatorOnHr);\n spdlog::error(\n "WheelFFB: SETACTUATORSON during initialization failed (0x{:08X})",\n (unsigned)actuatorOnHr);\n release_device();\n''', + 'SETACTUATORSON sibling failover'), +]: + ffb = rep(ffb, old, new, label) + + +# --------------------------------------------------------------------------- +# PASS 2 - Track ownership of the driver's autocenter property. We disable it +# for our custom spring, so every partial-init / device-release path must make a +# best effort to restore it instead of leaving the wheel driver altered. +# --------------------------------------------------------------------------- +ffb = rep( + ffb, +''' hr = device_->SetProperty(DIPROP_AUTOCENTER, &autocenter.diph);\n if (FAILED(hr))\n spdlog::warn("WheelFFB: disabling driver autocenter failed (0x{:08X})", (unsigned)hr);\n''', +''' hr = device_->SetProperty(DIPROP_AUTOCENTER, &autocenter.diph);\n if (FAILED(hr))\n spdlog::warn("WheelFFB: disabling driver autocenter failed (0x{:08X})", (unsigned)hr);\n else\n driverAutocenterDisabled_ = true;\n''', + 'track driver autocenter disable') + +# Replace the duplicate reinitialize autocenter block with the shared helper. +ffb = rep( + ffb, +''' if (device_)\n {\n __try\n {\n device_->Unacquire();\n deviceAcquired_ = false;\n\n DIPROPDWORD autocenter{};\n autocenter.diph.dwSize = sizeof(autocenter);\n autocenter.diph.dwHeaderSize = sizeof(DIPROPHEADER);\n autocenter.diph.dwObj = 0;\n autocenter.diph.dwHow = DIPH_DEVICE;\n autocenter.dwData = DIPROPAUTOCENTER_ON;\n device_->SetProperty(DIPROP_AUTOCENTER, &autocenter.diph);\n }\n __except (EXCEPTION_EXECUTE_HANDLER)\n {\n deviceAcquired_ = false;\n }\n }\n\n release_device();\n''', +''' restore_driver_autocenter("device reinitialize");\n release_device();\n''', + 'shared reinitialize autocenter restore') + +ffb = rep( + ffb, +''' void release_device()\n {\n if (!device_)\n return;\n __try\n {\n device_->Unacquire();\n deviceAcquired_ = false;\n device_->Release();\n }\n __except (EXCEPTION_EXECUTE_HANDLER)\n {\n spdlog::warn(\n "WheelFFB: exception releasing DirectInput device (0x{:X})",\n GetExceptionCode());\n }\n device_ = nullptr;\n }\n''', +''' void restore_driver_autocenter(const char* where)\n {\n if (!device_ || !driverAutocenterDisabled_)\n return;\n\n deviceAcquired_ = false;\n __try\n {\n device_->Unacquire();\n\n DIPROPDWORD autocenter{};\n autocenter.diph.dwSize = sizeof(autocenter);\n autocenter.diph.dwHeaderSize = sizeof(DIPROPHEADER);\n autocenter.diph.dwObj = 0;\n autocenter.diph.dwHow = DIPH_DEVICE;\n autocenter.dwData = DIPROPAUTOCENTER_ON;\n const HRESULT restoreHr =\n device_->SetProperty(DIPROP_AUTOCENTER, &autocenter.diph);\n if (FAILED(restoreHr))\n {\n spdlog::warn(\n "WheelFFB: autocenter restore during {} failed (0x{:08X})",\n where, (unsigned)restoreHr);\n }\n else\n {\n driverAutocenterDisabled_ = false;\n }\n }\n __except (EXCEPTION_EXECUTE_HANDLER)\n {\n spdlog::warn(\n "WheelFFB: exception restoring autocenter during {} (0x{:X})",\n where, GetExceptionCode());\n }\n }\n\n void release_device()\n {\n if (!device_)\n {\n deviceAcquired_ = false;\n driverAutocenterDisabled_ = false;\n return;\n }\n\n // Clear logical acquisition state before touching a possibly stale\n // COM object. Even a driver exception must not leave a phantom\n // acquired device behind in the engine state.\n deviceAcquired_ = false;\n restore_driver_autocenter("device release");\n\n IDirectInputDevice8A* staleDevice = device_;\n device_ = nullptr;\n driverAutocenterDisabled_ = false;\n __try\n {\n staleDevice->Unacquire();\n staleDevice->Release();\n }\n __except (EXCEPTION_EXECUTE_HANDLER)\n {\n spdlog::warn(\n "WheelFFB: exception releasing DirectInput device (0x{:X})",\n GetExceptionCode());\n }\n }\n''', + 'autocenter-safe fail-state-first device release') + +ffb = rep( + ffb, + ' bool deviceAcquired_ = false;\n', + ' bool deviceAcquired_ = false;\n bool driverAutocenterDisabled_ = false;\n', + 'driver autocenter ownership state') + +ffb = rep( + ffb, +''' hr = device_->SetProperty(DIPROP_AUTOCENTER, &autocenter.diph);\n spdlog::info("WheelFFB: PanicStop autocenter restore => 0x{:08X}", (unsigned)hr);\n''', +''' hr = device_->SetProperty(DIPROP_AUTOCENTER, &autocenter.diph);\n if (SUCCEEDED(hr))\n driverAutocenterDisabled_ = false;\n spdlog::info("WheelFFB: PanicStop autocenter restore => 0x{:08X}", (unsigned)hr);\n''', + 'PanicStop clears autocenter ownership on success') + + +# --------------------------------------------------------------------------- +# PASS 3 - Because compatibility uses EXCLUSIVE|BACKGROUND, DirectInput itself +# will allow Acquire while the application is unfocused. Put an explicit +# foreground check both before and immediately after Acquire to close the focus +# transition race without changing wheel-driver cooperative-level behaviour. +# --------------------------------------------------------------------------- +ffb = rep( + ffb, +''' bool reacquire_after_input_loss(const char* where, HRESULT originalHr)\n {\n if (!device_)\n {\n note_device_failure(where, originalHr);\n return false;\n }\n\n deviceAcquired_ = false;\n const HRESULT acquireHr = device_->Acquire();\n''', +''' bool reacquire_after_input_loss(const char* where, HRESULT originalHr)\n {\n if (!device_)\n {\n note_device_failure(where, originalHr);\n return false;\n }\n\n if (!appActive_ || !gameHwnd_ || GetForegroundWindow() != gameHwnd_)\n {\n deviceAcquired_ = false;\n return false;\n }\n\n deviceAcquired_ = false;\n const HRESULT acquireHr = device_->Acquire();\n''', + 'foreground-safe input-loss reacquire') + +ffb = rep( + ffb, +''' if (SUCCEEDED(acquireHr) || acquireHr == S_FALSE)\n {\n deviceAcquired_ = true;\n const HRESULT actuatorHr =\n''', +''' if (SUCCEEDED(acquireHr) || acquireHr == S_FALSE)\n {\n if (!appActive_ || GetForegroundWindow() != gameHwnd_)\n {\n device_->Unacquire();\n deviceAcquired_ = false;\n return false;\n }\n\n deviceAcquired_ = true;\n const HRESULT actuatorHr =\n''', + 'post-Acquire foreground race guard') + + +# --------------------------------------------------------------------------- +# PASS 4 - Make DirectInput root teardown state-first and SEH-protected, just as +# device/effect teardown already is. A stale driver COM object must not leave a +# non-null engine pointer after an exception during process/device recovery. +# --------------------------------------------------------------------------- +ffb = rep( + ffb, +''' void release_directinput()\n {\n if (!directInput_)\n return;\n directInput_->Release();\n directInput_ = nullptr;\n }\n''', +''' void release_directinput()\n {\n if (!directInput_)\n return;\n\n IDirectInput8A* staleDirectInput = directInput_;\n directInput_ = nullptr;\n __try\n {\n staleDirectInput->Release();\n }\n __except (EXCEPTION_EXECUTE_HANDLER)\n {\n spdlog::warn(\n "WheelFFB: exception releasing DirectInput root object (0x{:X})",\n GetExceptionCode());\n }\n }\n''', + 'exception-safe DirectInput root release') + + +# --------------------------------------------------------------------------- +# PASS 5 - Multi-device hotplug identity stability. Counting same-GUID devices +# can generate duplicate occurrence IDs after one identical device is removed. +# Allocate the lowest unused occurrence instead. Also preserve the selected +# primary gamepad when an earlier vector element disappears. +# --------------------------------------------------------------------------- +if '#include ' not in ims: + ims = ims.replace('#include \n', '#include \n#include \n', 1) + print('ROUND5 patched: explicit algorithm include') + +ims = rep( + ims, +'''\t\tconst std::string guid(guidText);\n\t\tconst int occurrence = int(std::count_if(devices.begin(), devices.end(), [&guid](const InputDevice& device)\n\t\t\t{\n\t\t\t\treturn device.guid == guid;\n\t\t\t}));\n''', +'''\t\tconst std::string guid(guidText);\n\t\tint occurrence = 0;\n\t\twhile (std::any_of(devices.begin(), devices.end(), [&guid, occurrence](const InputDevice& device)\n\t\t\t{\n\t\t\t\treturn device.guid == guid && device.occurrence == occurrence;\n\t\t\t}))\n\t\t{\n\t\t\t++occurrence;\n\t\t}\n''', + 'unique same-GUID occurrence after hotplug') + +ims = rep( + ims, +'''\t\tif (it != controllers.end())\n\t\t{\n\t\t\tGame::CurrentPadType = Game::GamepadType::PC;\n\n\t\t\tSDL_CloseGamepad(*it);\n\t\t\tcontrollers.erase(it);\n\n\t\t\tspdlog::debug(__FUNCTION__ "(instance {}): removed instance", instanceId);\n\n\t\t\tif (primaryControllerIndex >= controllers.size())\n\t\t\t\tsetPrimaryGamepad(controllers.empty() ? -1 : 0);\n\t\t}\n''', +'''\t\tif (it != controllers.end())\n\t\t{\n\t\t\tconst int removedIndex = int(std::distance(controllers.begin(), it));\n\t\t\tconst bool removedPrimary = (removedIndex == primaryControllerIndex);\n\n\t\t\tSDL_CloseGamepad(*it);\n\t\t\tcontrollers.erase(it);\n\n\t\t\tspdlog::debug(__FUNCTION__ "(instance {}): removed instance", instanceId);\n\n\t\t\tif (controllers.empty())\n\t\t\t{\n\t\t\t\tGame::CurrentPadType = Game::GamepadType::PC;\n\t\t\t\tsetPrimaryGamepad(-1);\n\t\t\t}\n\t\t\telse if (removedPrimary)\n\t\t\t{\n\t\t\t\tsetPrimaryGamepad(std::min(removedIndex, int(controllers.size()) - 1));\n\t\t\t}\n\t\t\telse if (primaryControllerIndex > removedIndex)\n\t\t\t{\n\t\t\t\t--primaryControllerIndex;\n\t\t\t\tif (auto* pad = getPrimaryGamepad())\n\t\t\t\t\tsetupGamepad(pad);\n\t\t\t}\n\t\t}\n''', + 'stable primary controller index on removal') + +ffb_path.write_text(ffb, encoding='utf-8') +input_path.write_text(ims, encoding='utf-8') +print('Applied round-5 five-pass lifecycle/hotplug hardening') diff --git a/tools/archive/rounds/patch_wheel_round6_fivepass.py b/tools/archive/rounds/patch_wheel_round6_fivepass.py new file mode 100644 index 000000000..2bc409b71 --- /dev/null +++ b/tools/archive/rounds/patch_wheel_round6_fivepass.py @@ -0,0 +1,168 @@ +from pathlib import Path + +ffb_path = Path('src/hooks_wheel_ffb.cpp') +input_path = Path('src/input_manager.hpp') +setup_path = Path('src/overlay/wheel_setup_ui.cpp') +ffb = ffb_path.read_text(encoding='utf-8') +ims = input_path.read_text(encoding='utf-8') +setup = setup_path.read_text(encoding='utf-8') + + +def rep(text: str, old: str, new: str, label: str, expected: int = 1) -> str: + count = text.count(old) + if count != expected: + raise SystemExit(f'round6 {label}: expected {expected} match(es), got {count}') + print(f'ROUND6 patched: {label}') + return text.replace(old, new, expected) + +# PASS 1 - FFB lifecycle / focus safety. +# An initialize attempt made while backgrounded sets appActive_=false before the +# window subclass exists. Do not let that state permanently suppress all future +# initialization attempts; initialize() itself verifies foreground ownership. +ffb = rep(ffb, +''' if (!appActive_)\n {\n if (initialized_)\n zero_all_forces();\n return;\n }\n''', +''' if (!appActive_)\n {\n // initialize() can set appActive_=false before the window\n // subclass exists. Recover from that startup/background case\n // only after the real game window is foreground again.\n if (!initialized_ && gameHwnd_ && GetForegroundWindow() == gameHwnd_)\n {\n appActive_ = true;\n warmupFrames_ = 0;\n }\n else\n {\n if (initialized_)\n zero_all_forces();\n return;\n }\n }\n''', +'background startup can recover initialization') + +# Normal menu->game Acquire needs the same foreground race guards as the +# input-loss recovery path because EXCLUSIVE|BACKGROUND can otherwise succeed +# after focus has moved to another application. +ffb = rep(ffb, +''' if (device_ && !deviceAcquired_)\n {\n const HRESULT acquireHr = device_->Acquire();\n if (FAILED(acquireHr) && acquireHr != S_FALSE)\n {\n note_device_failure("gameplay Acquire", acquireHr);\n return;\n }\n deviceAcquired_ = true;\n clear_device_failure();\n const HRESULT actuatorHr =\n device_->SendForceFeedbackCommand(DISFFC_SETACTUATORSON);\n''', +''' if (device_ && !deviceAcquired_)\n {\n if (!gameHwnd_ || GetForegroundWindow() != gameHwnd_)\n return;\n\n const HRESULT acquireHr = device_->Acquire();\n if (FAILED(acquireHr) && acquireHr != S_FALSE)\n {\n note_device_failure("gameplay Acquire", acquireHr);\n return;\n }\n\n if (GetForegroundWindow() != gameHwnd_)\n {\n device_->Unacquire();\n deviceAcquired_ = false;\n return;\n }\n\n deviceAcquired_ = true;\n clear_device_failure();\n const HRESULT actuatorHr =\n device_->SendForceFeedbackCommand(DISFFC_SETACTUATORSON);\n''', +'foreground-safe normal gameplay Acquire') + +# zero_all_forces() is also called from menus and live-disable paths. If an +# effect update reports INPUTLOST there, never reacquire an exclusive DD device +# merely to write zero: loss/reacquire belongs to gameplay only. +ffb = rep(ffb, +''' if (!appActive_ || !gameHwnd_ || GetForegroundWindow() != gameHwnd_)\n {\n deviceAcquired_ = false;\n return false;\n }\n\n deviceAcquired_ = false;\n const HRESULT acquireHr = device_->Acquire();\n''', +''' const bool inGameplay =\n Game::current_mode && (*Game::current_mode == STATE_GAME);\n if (!inGameplay)\n {\n deviceAcquired_ = false;\n return false;\n }\n if (!appActive_ || !gameHwnd_ || GetForegroundWindow() != gameHwnd_)\n {\n deviceAcquired_ = false;\n return false;\n }\n\n deviceAcquired_ = false;\n const HRESULT acquireHr = device_->Acquire();\n''', +'input-loss reacquire is gameplay-only') + +# Remove an accidental duplicate live hardware-spring disable block. The first +# block releases springEffect_, making the second dead code, but keeping two +# variants made later lifecycle changes easy to apply to the wrong copy. +duplicate = '''\n if (!Settings::WheelFFBUseHardwareSpring && springEffect_)\n {\n update_spring(0.0f);\n springEffect_->Stop();\n safe_release_effect(springEffect_, "hardware spring disabled");\n prevSpringCoefficient_ = 0;\n prevSpringSaturation_ = 0;\n springStrategy_ = 1;\n spdlog::info("WheelFFB: hardware spring disabled live; using software centering");\n }\n''' +ffb = rep(ffb, duplicate, '', 'remove duplicate hardware spring disable block') +ffb = rep(ffb, 'using software spring', 'using software centering', 'normalize spring fallback diagnostic') + +# PASS 2 - Exact DirectInput identity / hotplug safety. +# A saved GUID is a pin, not a hint. If it disappears, do not silently attach +# torque to another same-name wheel. The sole exception is the short holdoff +# after that exact interface failed initialization, where sibling fallback is +# intentional; successful selection then clears the rejected-interface state. +ffb = rep(ffb, +''' if (!ctx.found)\n {\n spdlog::warn(\n "WheelFFB: saved DirectInput GUID '{}' unavailable/rejected; falling back to DeviceName",\n Settings::WheelFFBDeviceGuid.get());\n ctx = {};\n ctx.self = this;\n }\n''', +''' if (!ctx.found)\n {\n const bool rejectedExactInterface =\n failedInterfaceGuid_ == configuredGuid &&\n tick_before(GetTickCount(), failedInterfaceUntil_);\n if (!rejectedExactInterface)\n {\n spdlog::warn(\n "WheelFFB: saved DirectInput GUID '{}' is unavailable; refusing DeviceName fallback",\n Settings::WheelFFBDeviceGuid.get());\n release_directinput();\n return false;\n }\n\n spdlog::warn(\n "WheelFFB: saved DirectInput GUID '{}' was rejected during initialization; trying a sibling DeviceName interface",\n Settings::WheelFFBDeviceGuid.get());\n ctx = {};\n ctx.self = this;\n }\n''', +'exact FFB GUID only falls back after explicit interface rejection') + +ffb = rep(ffb, +''' lastEffectGain_ = configured_effect_gain();\n\n spdlog::info(\n''', +''' lastEffectGain_ = configured_effect_gain();\n failedInterfaceGuid_.clear();\n failedInterfaceUntil_ = 0;\n\n spdlog::info(\n''', +'clear stale rejected-interface state after successful init') + +ffb = rep(ffb, +''' "Exact DirectInput instance GUID selected by F11 Wheel Setup; DeviceName remains the fallback."\n''', +''' "Exact DirectInput instance GUID selected by F11 Wheel Setup; a saved GUID never falls back unless that exact interface failed initialization."\n''', +'DeviceGuid setting documents strict pinning') + +# F11's DirectInput reader must obey the same identity rule; otherwise the UI +# can display/live-read a same-name device even while FFB correctly refuses it. +setup = rep(setup, +''' if (!guid.empty())\n for (size_t i=0; i(Settings::WheelFFBSpringStrength) * speedCurve;\n''', +''' const float softwareSpring =\n springEffect_\n ? 0.0f\n : -steer * springStrength;\n''', +'software spring matches hardware spring model') + +# Warm-up/recreate ramps previously protected structural force but not hardware +# or fallback road/slip texture. Scale those actuation paths too. +ffb = rep(ffb, +''' // Hardware periodics are preferred. If unavailable, inject a capped\n // low-frequency sine after the tanh compressor.\n float fallbackVibration = 0.0f;\n if (!periodicsActive_)\n {\n fallbackVibration += synth_fallback(\n roadPhase_, roadAmp, std::min(roadFreq, 15.0f));\n fallbackVibration += synth_fallback(\n slipPhase_, slipAmp, std::min(slipFreq, 15.0f));\n }\n''', +''' // Hardware periodics are preferred. If unavailable, inject a capped\n // low-frequency sine after the tanh compressor. Road/slip signals\n // share the same startup/recreate ramp as structural force.\n const float effectRampScale = warmupScale * recreateScale;\n float fallbackVibration = 0.0f;\n if (!periodicsActive_)\n {\n fallbackVibration += synth_fallback(\n roadPhase_, roadAmp * effectRampScale, std::min(roadFreq, 15.0f));\n fallbackVibration += synth_fallback(\n slipPhase_, slipAmp * effectRampScale, std::min(slipFreq, 15.0f));\n }\n''', +'road and slip fallback obey warmup/recreate ramp') + +ffb = rep(ffb, +''' update_periodic(roadTextureEffect_, roadState_, roadAmp, roadFreq);\n update_periodic(tireSlipEffect_, slipState_, slipAmp, slipFreq);\n''', +''' update_periodic(roadTextureEffect_, roadState_, roadAmp * effectRampScale, roadFreq);\n update_periodic(tireSlipEffect_, slipState_, slipAmp * effectRampScale, slipFreq);\n''', +'hardware periodics obey warmup/recreate ramp') + +# PASS 4 - Numeric hardening. Reject NaN/Inf before float->integer actuator +# conversions; malformed INI or corrupt transient game values must fail safe to +# zero force rather than invoking implementation-defined casts. +ffb = rep(ffb, +''' const float speed = car->field_1C4;\n const float speedNorm = std::clamp(speed / 2.0f, 0.0f, 1.0f);\n const float outputStrength = std::clamp(\n static_cast(Settings::WheelFFBGlobalStrength), 0.0f, 1.5f);\n\n const uint32_t stateFlags = car->field_8;\n const uint32_t curGear = car->cur_gear_208;\n const float lateralRaw = car->field_264 + car->field_268;\n''', +''' const float speedRaw = car->field_1C4;\n const float speed = std::isfinite(speedRaw) ? speedRaw : 0.0f;\n const float speedNorm = std::clamp(speed / 2.0f, 0.0f, 1.0f);\n const float configuredStrength =\n static_cast(Settings::WheelFFBGlobalStrength);\n const float outputStrength = std::isfinite(configuredStrength)\n ? std::clamp(configuredStrength, 0.0f, 1.5f)\n : 0.0f;\n\n const uint32_t stateFlags = car->field_8;\n const uint32_t curGear = car->cur_gear_208;\n const float lateralSum = car->field_264 + car->field_268;\n const float lateralRaw = std::isfinite(lateralSum) ? lateralSum : 0.0f;\n''', +'sanitize speed, master gain, and lateral signal') + +ffb = rep(ffb, +''' roughness = std::max(\n roughness,\n static_cast(sub_1149C0(\n car->water_flag_24C[i],\n static_cast(car->OnRoadPlace_5C.loadColiType_0),\n &waterFlag)));\n''', +''' const float surfaceRoughness = static_cast(sub_1149C0(\n car->water_flag_24C[i],\n static_cast(car->OnRoadPlace_5C.loadColiType_0),\n &waterFlag));\n if (std::isfinite(surfaceRoughness))\n roughness = std::max(roughness, surfaceRoughness);\n''', +'sanitize surface roughness') + +ffb = rep(ffb, +''' float total = (structural + events) * outputStrength;\n if (Settings::WheelFFBInvertForce)\n total = -total;\n\n total *= warmupScale * recreateScale;\n''', +''' float total = (structural + events) * outputStrength;\n if (Settings::WheelFFBInvertForce)\n total = -total;\n\n total *= warmupScale * recreateScale;\n if (!std::isfinite(total))\n total = 0.0f;\n''', +'non-finite structural/event force fails safe to zero') + +ffb = rep(ffb, +''' const LONG maxSlew = static_cast(\n std::clamp(static_cast(Settings::WheelFFBSlewRate), 0.01f, 1.0f) *\n static_cast(DI_FFNOMINALMAX));\n''', +''' const float configuredSlew = static_cast(Settings::WheelFFBSlewRate);\n const float safeSlew = std::isfinite(configuredSlew)\n ? std::clamp(configuredSlew, 0.01f, 1.0f)\n : 0.06f;\n const LONG maxSlew = static_cast(\n safeSlew * static_cast(DI_FFNOMINALMAX));\n''', +'sanitize slew rate before integer conversion') + +# Clamp/sanitize the three hardware effect update boundaries and fallback sine. +ffb = rep(ffb, +''' const LONG coefficientMagnitude = static_cast(\n std::clamp(strength, 0.0f, 1.0f) *\n static_cast(DI_FFNOMINALMAX));\n''', +''' const float safeStrength = std::isfinite(strength)\n ? std::clamp(strength, 0.0f, 1.0f)\n : 0.0f;\n const LONG coefficientMagnitude = static_cast(\n safeStrength * static_cast(DI_FFNOMINALMAX));\n''', +'sanitize spring strength') + +# Spring saturation exists in create + update; sanitize both occurrences. +old_sat = ''' const DWORD saturation = static_cast(\n std::clamp(static_cast(Settings::WheelFFBSpringSaturation), 0.1f, 1.0f) *\n static_cast(DI_FFNOMINALMAX));\n''' +new_sat = ''' const float configuredSaturation =\n static_cast(Settings::WheelFFBSpringSaturation);\n const float safeSaturation = std::isfinite(configuredSaturation)\n ? std::clamp(configuredSaturation, 0.1f, 1.0f)\n : 0.775f;\n const DWORD saturation = static_cast(\n safeSaturation * static_cast(DI_FFNOMINALMAX));\n''' +ffb = rep(ffb, old_sat, new_sat, 'sanitize spring saturation', expected=2) + +ffb = rep(ffb, +''' const LONG coefficient = static_cast(\n std::clamp(strength, 0.0f, 1.0f) *\n static_cast(DI_FFNOMINALMAX));\n''', +''' const float safeStrength = std::isfinite(strength)\n ? std::clamp(strength, 0.0f, 1.0f)\n : 0.0f;\n const LONG coefficient = static_cast(\n safeStrength * static_cast(DI_FFNOMINALMAX));\n''', +'sanitize damper strength') + +ffb = rep(ffb, +''' const float magnitudeClamped = std::clamp(magnitude, 0.0f, 1.0f);\n const DWORD mag = magnitudeClamped < 0.01f\n''', +''' const float magnitudeClamped = std::isfinite(magnitude)\n ? std::clamp(magnitude, 0.0f, 1.0f)\n : 0.0f;\n const DWORD mag = magnitudeClamped < 0.01f\n''', +'sanitize periodic magnitude') +ffb = rep(ffb, +''' frequency = std::clamp(frequency, 1.0f, 100.0f);\n const DWORD period = static_cast(1000000.0f / frequency);\n''', +''' frequency = std::isfinite(frequency)\n ? std::clamp(frequency, 1.0f, 100.0f)\n : 30.0f;\n const DWORD period = static_cast(1000000.0f / frequency);\n''', +'sanitize periodic frequency') + +ffb = rep(ffb, +''' float synth_fallback(float& phase, float amplitude, float frequency)\n {\n if (amplitude <= 0.005f)\n''', +''' float synth_fallback(float& phase, float amplitude, float frequency)\n {\n if (!std::isfinite(amplitude) || !std::isfinite(frequency) ||\n amplitude <= 0.005f)\n''', +'sanitize software sine input') + +# PASS 5 - Runtime/F11 identity safety is covered by the strict UI reader above. +# Keep selected-device status unambiguous when exact GUID is absent. +setup = rep(setup, +''' ImGui::TextWrapped(\n "Universal legacy DirectInput wheel setup. Bind here from a game menu; gameplay FFB uses the same selected product name.");\n''', +''' ImGui::TextWrapped(\n "Universal DirectInput wheel setup. Bind here from a game menu; gameplay FFB follows the exact selected DirectInput GUID.");\n''', +'F11 text documents exact GUID ownership') + +ffb_path.write_text(ffb, encoding='utf-8') +input_path.write_text(ims, encoding='utf-8') +setup_path.write_text(setup, encoding='utf-8') +print('Applied round-6 five-pass lifecycle, identity, math, and runtime hardening') diff --git a/tools/archive/rounds/patch_wheel_round7_final_safety.py b/tools/archive/rounds/patch_wheel_round7_final_safety.py new file mode 100644 index 000000000..4008af683 --- /dev/null +++ b/tools/archive/rounds/patch_wheel_round7_final_safety.py @@ -0,0 +1,218 @@ +from pathlib import Path + +p = Path('src/hooks_wheel_ffb.cpp') +s = p.read_text(encoding='utf-8') + + +def rep(old: str, new: str, label: str): + global s + n = s.count(old) + if n != 1: + raise SystemExit(f'ROUND7 {label}: expected exactly one match, got {n}') + s = s.replace(old, new, 1) + print(f'ROUND7 patched: {label}') + +# 1) A focus-loss zero must also cancel transient event/test state. Otherwise a +# 20% direction test (or crash/gear impulse) can resume after Alt-Tab. +rep( +''' else + { + if (initialized_) + zero_all_forces(); + return; + } + } + + const DWORD updateNow = GetTickCount(); +''', +''' else + { + if (initialized_) + { + zero_all_forces(); + reset_signal_state(); + } + return; + } + } + + // WM_ACTIVATEAPP is normally authoritative, but keep a direct + // foreground guard as a second DD-wheel safety net. A delayed or + // missed activation message must never leave background torque + // running merely because the device uses DISCL_BACKGROUND. + if (initialized_ && gameHwnd_ && GetForegroundWindow() != gameHwnd_) + { + appActive_ = false; + zero_all_forces(); + reset_signal_state(); + if (device_ && deviceAcquired_) + { + device_->Unacquire(); + deviceAcquired_ = false; + } + return; + } + + const DWORD updateNow = GetTickCount(); +''', +'focus-loss state cancellation and foreground safety net') + +# 2) Make the 20% test a pure, bounded test. Hardware road/slip effects are +# independent effect objects, so they must be explicitly silenced while the +# ConstantForce test is running. Reset physics history at completion to avoid a +# false crash/damper kick caused by the intentionally skipped test frames. +rep( +''' if (manualTestFrames_ > 0) + { + if (springEffect_) + update_spring(0.0f); + if (damperEffect_) + update_damper(0.0f); + + LONG testLevel = manualTestDirection_ * 2000L; + if (Settings::WheelFFBInvertForce) + testLevel = -testLevel; + set_constant_force(testLevel); + --manualTestFrames_; + if (manualTestFrames_ == 0) + { + set_constant_force(0); + warmupFrames_ = 0; + } + return; + } +''', +''' if (manualTestFrames_ > 0) + { + if (springEffect_) + update_spring(0.0f); + if (damperEffect_) + update_damper(0.0f); + if (roadTextureEffect_) + update_periodic(roadTextureEffect_, roadState_, 0.0f, 30.0f); + if (tireSlipEffect_) + update_periodic(tireSlipEffect_, slipState_, 0.0f, 35.0f); + + crashImpulseTimer_ = 0; + crashImpulseForce_ = 0.0f; + gearShiftTimer_ = 0; + splashTimer_ = 0; + splashAmp_ = 0.0f; + + LONG testLevel = manualTestDirection_ * 2000L; + if (Settings::WheelFFBInvertForce) + testLevel = -testLevel; + set_constant_force(testLevel); + --manualTestFrames_; + if (manualTestFrames_ == 0) + { + zero_all_forces(); + reset_signal_state(); + } + return; + } +''', +'direction test silences periodic/event effects and resets history') + +# 3) Never queue a future torque request. The test may only start while the +# exact FFB device is currently initialized, acquired, enabled and foreground. +rep( +''' void request_direction_test(int direction) + { + if (direction == 0) + { + manualTestFrames_ = 0; + manualTestDirection_ = 1; + const bool inGameplay = + Game::current_mode && (*Game::current_mode == STATE_GAME); + if (initialized_ && !panicStopped_ && deviceAcquired_ && inGameplay) + set_constant_force(0); + return; + } + + const bool inGameplay = + Game::current_mode && (*Game::current_mode == STATE_GAME); + if (!inGameplay) + { + manualTestFrames_ = 0; + manualTestDirection_ = 1; + spdlog::warn( + "WheelFFB: ignored direction test outside gameplay; no torque was queued"); + return; + } + + manualTestDirection_ = direction < 0 ? -1 : 1; + manualTestFrames_ = 18; + spdlog::info( + "WheelFFB: queued safe {} direction test at fixed 20% output", + manualTestDirection_ < 0 ? "left" : "right"); + } +''', +''' void request_direction_test(int direction) + { + if (direction == 0) + { + const bool hadPendingTest = manualTestFrames_ > 0; + manualTestFrames_ = 0; + manualTestDirection_ = 1; + const bool inGameplay = + Game::current_mode && (*Game::current_mode == STATE_GAME); + if (hadPendingTest && initialized_ && !panicStopped_ && + deviceAcquired_ && inGameplay && appActive_ && gameHwnd_ && + GetForegroundWindow() == gameHwnd_) + { + zero_all_forces(); + } + if (hadPendingTest) + reset_signal_state(); + return; + } + + const bool inGameplay = + Game::current_mode && (*Game::current_mode == STATE_GAME); + if (!inGameplay) + { + manualTestFrames_ = 0; + manualTestDirection_ = 1; + spdlog::warn( + "WheelFFB: ignored direction test outside gameplay; no torque was queued"); + return; + } + + if (!Settings::WheelFFBEnable || !initialized_ || panicStopped_ || + !device_ || !deviceAcquired_ || !appActive_ || !gameHwnd_ || + GetForegroundWindow() != gameHwnd_) + { + manualTestFrames_ = 0; + manualTestDirection_ = 1; + spdlog::warn( + "WheelFFB: ignored direction test while FFB device was not active and foreground; no torque was queued"); + return; + } + + manualTestDirection_ = direction < 0 ? -1 : 1; + manualTestFrames_ = 18; + spdlog::info( + "WheelFFB: queued safe {} direction test at fixed 20% output", + manualTestDirection_ < 0 ? "left" : "right"); + } +''', +'direction test cannot queue while FFB is inactive/background') + +# 4) WM_ACTIVATEAPP itself must cancel transient state before exclusive release. +rep( +''' if (!self->appActive_) + { + self->zero_all_forces(); + if (self->device_ && self->deviceAcquired_) +''', +''' if (!self->appActive_) + { + self->zero_all_forces(); + self->reset_signal_state(); + if (self->device_ && self->deviceAcquired_) +''', +'WM_ACTIVATEAPP cancels pending test and stale physics state') + +p.write_text(s, encoding='utf-8') +print('Applied round-7 final DD-wheel runtime safety hardening') diff --git a/tools/archive/rounds/patch_wheel_round8_input_binding.py b/tools/archive/rounds/patch_wheel_round8_input_binding.py new file mode 100644 index 000000000..2fcc4d1e0 --- /dev/null +++ b/tools/archive/rounds/patch_wheel_round8_input_binding.py @@ -0,0 +1,135 @@ +from pathlib import Path + +manager_path = Path('src/input_manager.hpp') +ui_path = Path('src/overlay/input_bindings_ui.cpp') +wheel_ui_path = Path('src/overlay/wheel_setup_ui.cpp') + +manager = manager_path.read_text(encoding='utf-8') +ui = ui_path.read_text(encoding='utf-8') +wheel_ui = wheel_ui_path.read_text(encoding='utf-8') + + +def rep(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f'ROUND8 {label}: expected exactly one match, got {count}') + print(f'ROUND8 patched: {label}') + return text.replace(old, new, 1) + + +# 1) Eliminate first-frame undefined input state. These scalar masks were left +# indeterminate by the implicit InputManager constructor, so SwitchOn could see +# a phantom edge before the first real read. +manager = rep( + manager, + '''\tInputSourceType lastSourceType;''', + '''\tInputSourceType lastSourceType = InputSourceType::GamePad;''', + 'initialize InputState source') +manager = rep( + manager, + '''\tuint32_t switch_current;\n\tuint32_t switch_previous;\n\tuint32_t switch_overlay;''', + '''\tuint32_t switch_current = 0;\n\tuint32_t switch_previous = 0;\n\tuint32_t switch_overlay = 0;''', + 'initialize switch masks') + +# 2) Digital actions must ignore the inactive/negative side of analog bindings. +# Otherwise a full-scale negative axis can tie a +1 button and win merely +# because it was earlier in the vector, suppressing a valid digital press. +manager = rep( + manager, + '''\tconst InputState& update(SDL_Gamepad* primary_pad,\n\t\tconst std::function& joystickForBinding)\n\t{\n\t\tfloat maxValue = 0.0f;\n\t\tbool isAxisInput = false;\n\t\tInputSourceType lastSource = state_.lastSourceType;\n\n\t\t// Read all bindings and take the highest absolute value\n\t\tfor (const auto& binding : bindings_)\n\t\t{\n\t\t\tfloat currentValue = binding.read(primary_pad, joystickForBinding);\n\t\t\tif (std::abs(currentValue) > std::abs(maxValue))\n\t\t\t{\n\t\t\t\tmaxValue = currentValue;\n\t\t\t\tisAxisInput = binding.isAxis();\n\t\t\t\tlastSource = binding.sourceType();\n\t\t\t}\n\t\t}''', + '''\tconst InputState& update(SDL_Gamepad* primary_pad,\n\t\tconst std::function& joystickForBinding,\n\t\tbool positiveOnly = false)\n\t{\n\t\tfloat maxValue = 0.0f;\n\t\tbool isAxisInput = false;\n\t\tInputSourceType lastSource = state_.lastSourceType;\n\n\t\t// Analog actions need the largest magnitude so signed steering survives.\n\t\t// Digital actions pass positiveOnly=true: the negative side of an axis is\n\t\t// inactive for that action and must never mask another +1 button binding.\n\t\tfor (const auto& binding : bindings_)\n\t\t{\n\t\t\tfloat currentValue = binding.read(primary_pad, joystickForBinding);\n\t\t\tif (positiveOnly && currentValue <= 0.0f)\n\t\t\t\tcontinue;\n\t\t\tif (std::abs(currentValue) > std::abs(maxValue))\n\t\t\t{\n\t\t\t\tmaxValue = currentValue;\n\t\t\t\tisAxisInput = binding.isAxis();\n\t\t\t\tlastSource = binding.sourceType();\n\t\t\t}\n\t\t}''', + 'digital actions ignore negative axis side') +manager = rep( + manager, + '''\t\t\tauto& switchState = switchBindings[i].update(gamepad, resolveJoystick);''', + '''\t\t\tauto& switchState = switchBindings[i].update(gamepad, resolveJoystick, true);''', + 'switch bindings use positive-only aggregation') +manager = rep( + manager, + '''\t\t\tmodStates[i] = modBindings[i].update(gamepad, resolveJoystick);''', + '''\t\t\tmodStates[i] = modBindings[i].update(gamepad, resolveJoystick, true);''', + 'mod bindings use positive-only aggregation') + +# 3) The overlay/input dialog must zero game-facing analog controls, not leave +# the last cached throttle/brake/steering value frozen under the menu. +manager = rep( + manager, + '''\t\t\tauto& vol = volumeBindings[i].update(gamepad, resolveJoystick);\n\t\t\tif (Overlay::IsBindingDialogActive || Overlay::IsActive) [[unlikely]]\n\t\t\t\tcontinue;\n\n\t\t\tvolumes[i] = vol;''', + '''\t\t\tauto& vol = volumeBindings[i].update(gamepad, resolveJoystick);\n\t\t\tif (Overlay::IsBindingDialogActive || Overlay::IsActive) [[unlikely]]\n\t\t\t{\n\t\t\t\t// Keep the binding's live state for the UI, but never leave a stale\n\t\t\t\t// throttle/brake/steering command active underneath the overlay.\n\t\t\t\tvolumes[i].previousValue = 0.0f;\n\t\t\t\tvolumes[i].currentValue = 0.0f;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tvolumes[i] = vol;''', + 'overlay zeroes stale game-facing analog values') + +# 4) Make binding-file loading fail-safe. A syntactically non-empty but totally +# invalid file used to clear every working binding and still return success. +manager = rep( + manager, + '''\t\t// we have binds, reset any of our defaults\n\t\tfor (auto& binding : volumeBindings)\n\t\t\tbinding.clear();\n\t\tfor (auto& binding : switchBindings)\n\t\t\tbinding.clear();\n\t\tfor (auto& binding : modBindings)\n\t\t\tbinding.clear();\n\n\t\tfor (const auto& entry : entries)\n\t\t{''', + '''\t\t// Preserve the current working configuration until at least one entry\n\t\t// has parsed successfully. This makes Load bindings transactional for a\n\t\t// completely malformed/truncated file.\n\t\tauto previousVolumeBindings = volumeBindings;\n\t\tauto previousSwitchBindings = switchBindings;\n\t\tauto previousModBindings = modBindings;\n\t\tfor (auto& binding : volumeBindings)\n\t\t\tbinding.clear();\n\t\tfor (auto& binding : switchBindings)\n\t\t\tbinding.clear();\n\t\tfor (auto& binding : modBindings)\n\t\t\tbinding.clear();\n\t\tint loadedBinds = 0;\n\n\t\tfor (const auto& entry : entries)\n\t\t{''', + 'binding load preserves previous configuration until valid') +manager = rep( + manager, + '''\t\t\tbinding->negate = action->negate;\n\t\t\taddBinding(*action, *binding);\n\t\t}\n\n\t\treturn true;\n\t}''', + '''\t\t\tbinding->negate = action->negate;\n\t\t\tif (binding->negate && !binding->isAxis() &&\n\t\t\t\t!(action->kind == ActionRef::Kind::Volume &&\n\t\t\t\t\taction->index == int(ADChannel::Steering)))\n\t\t\t{\n\t\t\t\t// A '-' suffix on a button/key cannot select an opposite direction;\n\t\t\t\t// it only turns +1 into -1 and makes digital/pedal actions unusable.\n\t\t\t\tspdlog::warn(__FUNCTION__ ": ignoring invalid negative suffix for non-axis action {}", entry.key);\n\t\t\t\tbinding->negate = false;\n\t\t\t}\n\t\t\taddBinding(*action, *binding);\n\t\t\t++loadedBinds;\n\t\t}\n\n\t\tif (loadedBinds == 0)\n\t\t{\n\t\t\tvolumeBindings = std::move(previousVolumeBindings);\n\t\t\tswitchBindings = std::move(previousSwitchBindings);\n\t\t\tmodBindings = std::move(previousModBindings);\n\t\t\tspdlog::error(__FUNCTION__ ": no valid bindings parsed; previous bindings preserved");\n\t\t\treturn false;\n\t\t}\n\n\t\tensureOverlayBindable();\n\t\treturn true;\n\t}''', + 'binding load rejects zero-valid file and normalizes impossible button negation') + +# 5) Track the exact input that was captured and require it to be released. +# anyInputPressed() cannot treat arbitrary raw joystick axes as "pressed" +# because many pedals rest at -32768/+32767. Without a captured-input guard, +# Quick Setup can advance while a pedal is still down, then bind its release +# motion as the next pedal. +ui = rep( + ui, + '''\tstd::optional quickSetupCandidate;\n\tbool quickSetupTimedOut = false;''', + '''\tstd::optional quickSetupCandidate;\n\tstd::optional releaseGuardBinding;\n\tbool quickSetupTimedOut = false;''', + 'add captured-input release guard') +ui = rep( + ui, + '''\t\taxisBaseline.clear();\n\t\tif (quickSetupActive)''', + '''\t\taxisBaseline.clear();\n\t\treleaseGuardBinding.reset();\n\t\tif (quickSetupActive)''', + 'new listen clears old release guard') +ui = rep( + ui, + '''\t\tquickSetupCandidate.reset();\n\t\tquickSetupTimedOut = false;\n\t\tunsavedChanges = quickSetupPreviousUnsaved;''', + '''\t\tquickSetupCandidate.reset();\n\t\treleaseGuardBinding.reset();\n\t\tquickSetupTimedOut = false;\n\t\tunsavedChanges = quickSetupPreviousUnsaved;''', + 'quick setup restore clears release guard') +ui = rep( + ui, + '''\t\t\telse if (bindIndex >= 0 && bindIndex < int(bindings.size()))\n\t\t\t\tbindings[bindIndex] = binding;\n\t\t\telse\n\t\t\t\taction.add(binding);\n\n\t\t\tisListeningForInput = ListenState::WaitForBindButtonRelease;''', + '''\t\t\telse if (bindIndex >= 0 && bindIndex < int(bindings.size()))\n\t\t\t\tbindings[bindIndex] = binding;\n\t\t\telse\n\t\t\t\taction.add(binding);\n\n\t\t\treleaseGuardBinding = binding;\n\t\t\tisListeningForInput = ListenState::WaitForBindButtonRelease;''', + 'normal binding waits for captured input release') +ui = rep( + ui, + '''\t\tif (isListeningForInput == ListenState::WaitForBindButtonRelease)\n\t\t{\n\t\t\tif (!manager.anyInputPressed())\n\t\t\t{\n\t\t\t\tif (quickSetupActive && quickSetupStep < int(std::size(QuickSetupSteps)))\n\t\t\t\t\tbegin_listening(quick_setup_selection(quickSetupStep), -1);\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tisListeningForInput = ListenState::False;\n\t\t\t\t\tif (quickSetupActive)\n\t\t\t\t\t{\n\t\t\t\t\t\tquickSetupActive = false;\n\t\t\t\t\t\tquickSetupComplete = true;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn;\n\t\t}''', + '''\t\tif (isListeningForInput == ListenState::WaitForBindButtonRelease)\n\t\t{\n\t\t\tbool capturedReleased = true;\n\t\t\tif (releaseGuardBinding)\n\t\t\t{\n\t\t\t\tconst auto resolveJoystick = [&manager](const InputBinding& binding)\n\t\t\t\t\t{\n\t\t\t\t\t\treturn manager.joystickForBinding(binding);\n\t\t\t\t\t};\n\t\t\t\tcapturedReleased = std::abs(releaseGuardBinding->read(\n\t\t\t\t\tmanager.getPrimaryGamepad(), resolveJoystick)) < 0.25f;\n\t\t\t}\n\n\t\t\tif (capturedReleased && !manager.anyInputPressed())\n\t\t\t{\n\t\t\t\treleaseGuardBinding.reset();\n\t\t\t\tif (quickSetupActive && quickSetupStep < int(std::size(QuickSetupSteps)))\n\t\t\t\t\tbegin_listening(quick_setup_selection(quickSetupStep), -1);\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tisListeningForInput = ListenState::False;\n\t\t\t\t\tif (quickSetupActive)\n\t\t\t\t\t{\n\t\t\t\t\t\tquickSetupActive = false;\n\t\t\t\t\t\tquickSetupComplete = true;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn;\n\t\t}''', + 'captured raw axis must release before next binding') +ui = rep( + ui, + '''\t\t\t\t\tstd::erase_if(bindings, [](const InputBinding& existing) { return !existing.isKeyboard(); });\n\t\t\t\t\taction_for(bindTarget).add(*quickSetupCandidate);\n\t\t\t\t\tquickSetupCandidate.reset();\n\t\t\t\t\t++quickSetupStep;''', + '''\t\t\t\t\tstd::erase_if(bindings, [](const InputBinding& existing) { return !existing.isKeyboard(); });\n\t\t\t\t\taction_for(bindTarget).add(*quickSetupCandidate);\n\t\t\t\t\treleaseGuardBinding = *quickSetupCandidate;\n\t\t\t\t\tquickSetupCandidate.reset();\n\t\t\t\t\t++quickSetupStep;''', + 'quick setup confirmation waits for captured axis release') +ui = rep( + ui, + '''\t\t\t\tif (ImGui::Button("Try again"))\n\t\t\t\t{\n\t\t\t\t\tbegin_listening(bindTarget, -1);\n\t\t\t\t\tImGui::CloseCurrentPopup();\n\t\t\t\t}''', + '''\t\t\t\tif (ImGui::Button("Try again"))\n\t\t\t\t{\n\t\t\t\t\treleaseGuardBinding = *quickSetupCandidate;\n\t\t\t\t\tquickSetupCandidate.reset();\n\t\t\t\t\tisListeningForInput = ListenState::WaitForBindButtonRelease;\n\t\t\t\t\tImGui::CloseCurrentPopup();\n\t\t\t\t}''', + 'quick setup retry also waits for captured input release') + +# 6) Do not expose an "Invert" switch where it can only break a button/key. +# Steering buttons need it for left/right, and digital actions need it for +# signed axes; pedal FromRest direction remains auto-detected. +ui = rep( + ui, + '''\t\t\t\tif (binding.kind == InputBinding::Kind::JoyAxis && binding.axisMode == InputBinding::AxisMode::FromRest)\n\t\t\t\t{\n\t\t\t\t\tImGui::TextDisabled("Auto");\n\t\t\t\t\tif (ImGui::IsItemHovered())\n\t\t\t\t\t\tImGui::SetTooltip("Pedal direction is detected automatically during calibration");\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tif (ImGui::Checkbox("##invert", &binding.negate))\n\t\t\t\t\t\tunsavedChanges = true;\n\t\t\t\t\tif (ImGui::IsItemHovered())\n\t\t\t\t\t\tImGui::SetTooltip(steering\n\t\t\t\t\t\t\t? "Steer the other way with this input"\n\t\t\t\t\t\t\t: "Invert this input");\n\t\t\t\t}''', + '''\t\t\t\tif (binding.kind == InputBinding::Kind::JoyAxis && binding.axisMode == InputBinding::AxisMode::FromRest)\n\t\t\t\t{\n\t\t\t\t\tImGui::TextDisabled("Auto");\n\t\t\t\t\tif (ImGui::IsItemHovered())\n\t\t\t\t\t\tImGui::SetTooltip("Pedal direction is detected automatically during calibration");\n\t\t\t\t}\n\t\t\t\telse if (steering || binding.isAxis())\n\t\t\t\t{\n\t\t\t\t\tif (ImGui::Checkbox("##invert", &binding.negate))\n\t\t\t\t\t\tunsavedChanges = true;\n\t\t\t\t\tif (ImGui::IsItemHovered())\n\t\t\t\t\t\tImGui::SetTooltip(steering\n\t\t\t\t\t\t\t? "Steer the other way with this input"\n\t\t\t\t\t\t\t: "Use the opposite side of this axis");\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tImGui::TextDisabled("-");\n\t\t\t\t\tif (ImGui::IsItemHovered())\n\t\t\t\t\t\tImGui::SetTooltip("Invert is only meaningful for axes or steering-direction buttons");\n\t\t\t\t}''', + 'hide invalid invert toggle on ordinary buttons and keys') + +# 7) Legacy F11 import enables the universal profile too, so it must disable +# the fixed MOZA R3 menu readers just like the explicit enable checkbox does. +wheel_ui = rep( + wheel_ui, + ''' if (ImGui::Button("Import current game mapping"))\n {\n if (UniversalWheelProfile::copy_current_mapping())\n {\n Settings::WheelUniversalSetupEnable = true;\n status_ = "Imported current steering/pedal/shift bindings. Menu bindings can now be added below.";\n }\n else\n status_ = "No regular legacy DirectInput game device is ready yet.";\n }''', + ''' if (ImGui::Button("Import current game mapping"))\n {\n if (UniversalWheelProfile::copy_current_mapping())\n {\n Settings::WheelUniversalSetupEnable = true;\n Settings::WheelMenuR3DirectDPad = false;\n Settings::WheelMenuR3DirectAB = false;\n status_ = "Imported current steering/pedal/shift bindings. Universal menu ownership enabled; add menu bindings below.";\n }\n else\n status_ = "No regular legacy DirectInput game device is ready yet.";\n }''', + 'legacy mapping import disables fixed R3 menu helpers') + +manager_path.write_text(manager, encoding='utf-8') +ui_path.write_text(ui, encoding='utf-8') +wheel_ui_path.write_text(wheel_ui, encoding='utf-8') +print('Applied round-8 input binding, quick-setup, and legacy menu hardening') diff --git a/tools/archive/rounds/patch_wheel_round9_release_v01.py b/tools/archive/rounds/patch_wheel_round9_release_v01.py new file mode 100644 index 000000000..3a3ea8db2 --- /dev/null +++ b/tools/archive/rounds/patch_wheel_round9_release_v01.py @@ -0,0 +1,62 @@ +from pathlib import Path + +ffb_path = Path('src/hooks_wheel_ffb.cpp') +input_ui_path = Path('src/overlay/input_bindings_ui.cpp') +setup_ui_path = Path('src/overlay/wheel_setup_ui.cpp') + +ffb = ffb_path.read_text(encoding='utf-8') +input_ui = input_ui_path.read_text(encoding='utf-8') +setup_ui = setup_ui_path.read_text(encoding='utf-8') + + +def rep(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f'round9 {label}: expected exactly one match, found {count}') + print(f'round9 release patch: {label}') + return text.replace(old, new, 1) + +# v0.1 defaults are the hardware-tested MOZA R3 personal tune: more steering +# resistance than the earlier generic defaults, without amplifying collisions. +ffb = rep(ffb, +''' Setting WheelFFBSpringStrength{\n "WheelFFB", "SpringStrength", 0.45f,\n''', +''' Setting WheelFFBSpringStrength{\n "WheelFFB", "SpringStrength", 0.60f,\n''', 'default spring 0.60') + +ffb = rep(ffb, +''' Setting WheelFFBDamperStrength{\n "WheelFFB", "DamperStrength", 0.10f,\n''', +''' Setting WheelFFBDamperStrength{\n "WheelFFB", "DamperStrength", 0.42f,\n''', 'default damping 0.42') + +ffb = rep(ffb, +''' Setting WheelFFBSteeringWeight{\n "WheelFFB", "SteeringWeight", 0.45f,\n''', +''' Setting WheelFFBSteeringWeight{\n "WheelFFB", "SteeringWeight", 0.38f,\n''', 'default cornering load 0.38') + +ffb = rep(ffb, +''' Setting WheelFFBGripLoss{\n "WheelFFB", "GripLoss", 0.60f,\n''', +''' Setting WheelFFBGripLoss{\n "WheelFFB", "GripLoss", 0.65f,\n''', 'default grip-loss unload 0.65') + +ffb = rep(ffb, +''' Setting WheelFFBWallImpact{\n "WheelFFB", "WallImpact", 0.35f,\n''', +''' Setting WheelFFBWallImpact{\n "WheelFFB", "WallImpact", 0.38f,\n''', 'default collision 0.38') + +ffb = rep(ffb, +''' Setting WheelFFBTireSlip{\n "WheelFFB", "TireSlip", 0.18f,\n''', +''' Setting WheelFFBTireSlip{\n "WheelFFB", "TireSlip", 0.20f,\n''', 'default tire slip 0.20') + +# Add the tested personal profile without removing the versioned comparison +# presets that were used during development. +old_presets = '''\t\tImGui::SeparatorText("Versioned baseline presets");\n\t\tif (ImGui::Button("Simulation Balanced v1"))\n\t\t\tapplyPreset(0.70f, 0.60f, 0.32f, 0.38f, 0.65f, 0.65f, 0.30f, 0.20f, 0.08f, 0.35f);\n''' +new_presets = '''\t\tImGui::SeparatorText("Versioned baseline presets");\n\t\tif (ImGui::Button("MOZA R3 v0.1 (default)"))\n\t\t\tapplyPreset(0.70f, 0.60f, 0.42f, 0.38f, 0.65f, 0.38f, 0.30f, 0.20f, 0.08f, 0.35f);\n\t\tif (ImGui::Button("Simulation Balanced v1"))\n\t\t\tapplyPreset(0.70f, 0.60f, 0.32f, 0.38f, 0.65f, 0.65f, 0.30f, 0.20f, 0.08f, 0.35f);\n''' +input_ui = rep(input_ui, old_presets, new_presets, 'add MOZA R3 v0.1 preset to bindings FFB panel') + +input_ui = rep(input_ui, +'''\t\tImGui::TextDisabled("Preset names are immutable test references; adjust sliders afterward for a custom tune.");\n''', +'''\t\tImGui::TextDisabled("MOZA R3 v0.1 is the personal hardware-tested default; other presets remain comparison references.");\n''', 'describe tested v0.1 preset') + +old_setup = ''' if (ImGui::Button("Load Simulation Balanced v1"))\n {\n Settings::WheelFFBGlobalStrength = 0.70f;\n Settings::WheelFFBSpringStrength = 0.60f;\n Settings::WheelFFBLowSpeedSpring = 0.08f;\n Settings::WheelFFBSpringLoadBoost = 0.35f;\n Settings::WheelFFBDamperStrength = 0.32f;\n Settings::WheelFFBSteeringWeight = 0.38f;\n Settings::WheelFFBGripLoss = 0.65f;\n Settings::WheelFFBRoadTexture = 0.30f;\n Settings::WheelFFBTireSlip = 0.20f;\n Settings::WheelFFBWallImpact = 0.65f;\n Settings::WheelFFBUseHardwareSpring = true;\n Settings::WheelFFBUseHardwareDamper = true;\n status_ = "Loaded Simulation Balanced v1. Test first, then save your preferred profile.";\n }\n''' +new_setup = ''' if (ImGui::Button("Load MOZA R3 v0.1 (default)"))\n {\n Settings::WheelFFBGlobalStrength = 0.70f;\n Settings::WheelFFBSpringStrength = 0.60f;\n Settings::WheelFFBLowSpeedSpring = 0.08f;\n Settings::WheelFFBSpringLoadBoost = 0.35f;\n Settings::WheelFFBDamperStrength = 0.42f;\n Settings::WheelFFBSteeringWeight = 0.38f;\n Settings::WheelFFBGripLoss = 0.65f;\n Settings::WheelFFBRoadTexture = 0.30f;\n Settings::WheelFFBTireSlip = 0.20f;\n Settings::WheelFFBWallImpact = 0.38f;\n Settings::WheelFFBUseHardwareSpring = true;\n Settings::WheelFFBUseHardwareDamper = true;\n status_ = "Loaded MOZA R3 v0.1: stronger steering resistance with restrained collision feedback.";\n }\n''' +setup_ui = rep(setup_ui, old_setup, new_setup, 'make F11 setup load MOZA R3 v0.1') + +ffb_path.write_text(ffb, encoding='utf-8') +input_ui_path.write_text(input_ui, encoding='utf-8') +setup_ui_path.write_text(setup_ui, encoding='utf-8') +print('Applied Round-9 v0.1 release profile') diff --git a/tools/archive/rounds/verify_wheel_round10_snow_texture.py b/tools/archive/rounds/verify_wheel_round10_snow_texture.py new file mode 100644 index 000000000..77bfc546d --- /dev/null +++ b/tools/archive/rounds/verify_wheel_round10_snow_texture.py @@ -0,0 +1,58 @@ +from pathlib import Path +import runpy + +path = Path('src/hooks_wheel_ffb.cpp') +text = path.read_text(encoding='utf-8') + + +def require(needle: str, label: str) -> None: + if needle not in text: + raise SystemExit(f'ROUND10 VERIFY FAILED [{label}]: {needle!r}') + print(f'ROUND10 VERIFY OK [{label}]') + +require('uniqueStage == 4 || uniqueStage == 19 ||', 'forward snow/ice stage IDs') +require('uniqueStage == 34 || uniqueStage == 49;', 'reverse snow/ice stage IDs') +require('constexpr float SnowIceRoadTextureScale = 0.04f;', 'snow/ice road texture attenuation') +require('stageRoadTextureScale;', 'road sine uses stage attenuation') +require('outputStrength * stageRoadTextureScale;', 'splash periodic uses stage attenuation') +require('magnitudeClamped < 0.01f', 'existing tiny-periodic snap-to-zero remains active') + +max_default_snow_road = 1.0 * 1.0 * 0.30 * 0.70 * 0.04 +if not max_default_snow_road < 0.01: + raise SystemExit(f'ROUND10 VERIFY FAILED [default snow road silence]: {max_default_snow_road}') +print(f'ROUND10 VERIFY OK [default snow road silence]: max={max_default_snow_road:.4f}') + +pairs = {'(': ')', '{': '}', '[': ']'} +stack = [] +in_string = False +escape = False +for ch in text: + if in_string: + if escape: + escape = False + elif ch == '\\': + escape = True + elif ch == '"': + in_string = False + continue + if ch == '"': + in_string = True + elif ch in pairs: + stack.append(ch) + elif ch in pairs.values(): + if not stack or pairs[stack.pop()] != ch: + raise SystemExit('ROUND10 VERIFY FAILED [delimiter balance]') +if stack: + raise SystemExit('ROUND10 VERIFY FAILED [delimiter balance]') +print('ROUND10 VERIFY OK [delimiter balance]') +print('Round-10 snow/ice vibration suppression verification passed') + +# Round-11 through Round-17 are chained by the Round-10 patch runner, so verify +# the final effective source in the same order. +runpy.run_path('tools/verify_wheel_round11_strong_sat.py', run_name='__main__') +runpy.run_path('tools/verify_wheel_round12_sat_input_ui.py', run_name='__main__') +runpy.run_path('tools/verify_wheel_round13_ffb_ui_complete.py', run_name='__main__') +runpy.run_path('tools/verify_wheel_round14_input_ui_roles.py', run_name='__main__') +runpy.run_path('tools/verify_wheel_round15_strong_r3_sat.py', run_name='__main__') +runpy.run_path('tools/verify_wheel_round16_natural_single_owner.py', run_name='__main__') +runpy.run_path('tools/verify_wheel_round17_physics_sat.py', run_name='__main__') diff --git a/tools/archive/rounds/verify_wheel_round11_strong_sat.py b/tools/archive/rounds/verify_wheel_round11_strong_sat.py new file mode 100644 index 000000000..fb1b6104c --- /dev/null +++ b/tools/archive/rounds/verify_wheel_round11_strong_sat.py @@ -0,0 +1,87 @@ +from pathlib import Path +import math + + +def read(path: str) -> str: + return Path(path).read_text(encoding='utf-8') + + +def require(path: str, needle: str, label: str) -> None: + data = read(path) + if needle not in data: + raise SystemExit(f'ROUND11 VERIFY FAILED [{label}]: {needle!r} missing from {path}') + print(f'ROUND11 VERIFY OK [{label}]') + + +def forbid(path: str, needle: str, label: str) -> None: + data = read(path) + if needle in data: + raise SystemExit(f'ROUND11 VERIFY FAILED [{label}]: stale {needle!r} remains in {path}') + print(f'ROUND11 VERIFY OK [{label}]') + +ffb = read('src/hooks_wheel_ffb.cpp') +setup_ui = read('src/overlay/wheel_setup_ui.cpp') +round16 = 'Load MOZA R3 Natural SAT' in setup_ui +round15 = 'Load MOZA R3 Strong SAT' in setup_ui + +if round16: + require('src/hooks_wheel_ffb.cpp', '"WheelFFB", "SteeringWeight", 1.45f,', 'Round16 retains declared SAT baseline') + require('src/hooks_wheel_ffb.cpp', '"WheelFFB", "SpringStrength", 0.65f,', 'Round16 retains low-speed spring baseline') + require('src/hooks_wheel_ffb.cpp', '"WheelFFB", "DamperStrength", 0.30f,', 'Round16 damping stabilizer') + require('src/hooks_wheel_ffb.cpp', 'const float satAngleInput =', 'Round16 natural SAT successor') +elif round15: + require('src/hooks_wheel_ffb.cpp', '"WheelFFB", "SteeringWeight", 1.45f,', 'Round15 SAT supersedes Round11') + require('src/hooks_wheel_ffb.cpp', '"WheelFFB", "SpringStrength", 0.65f,', 'Round15 spring supersedes Round11') + require('src/hooks_wheel_ffb.cpp', '"WheelFFB", "DamperStrength", 0.30f,', 'Round15 damping supersedes Round11') + require('src/hooks_wheel_ffb.cpp', '"WheelFFB", "SpringLoadBoost", 0.30f,', 'Round15 spring load boost') +else: + require('src/hooks_wheel_ffb.cpp', '"WheelFFB", "SteeringWeight", 1.10f,', 'strong SAT default') + require('src/hooks_wheel_ffb.cpp', '"WheelFFB", "SpringStrength", 0.32f,', 'lighter generic spring') + require('src/hooks_wheel_ffb.cpp', '"WheelFFB", "DamperStrength", 0.34f,', 'damper is stabilizer') + require('src/hooks_wheel_ffb.cpp', '"WheelFFB", "SpringLoadBoost", 0.18f,', 'spring load boost reduced') + +require('src/hooks_wheel_ffb.cpp', 'const float selfAligningTorque =', 'explicit SAT torque') +require('src/hooks_wheel_ffb.cpp', '(steer >= 0.0f ? -1.0f : 1.0f)', 'logical SAT returns toward centre') +require('src/hooks_wheel_ffb.cpp', 'structural = (softwareSpring + selfAligningTorque) * loadMod + damper;', 'SAT is primary structural torque') +forbid('src/hooks_wheel_ffb.cpp', 'latNorm * speedNorm *\n static_cast(Settings::WheelFFBSteeringWeight)', 'old signed lateral torque removed') +require('src/overlay/input_bindings_ui.cpp', 'Self-aligning torque (SAT)', 'bindings SAT source retained') +require('src/overlay/wheel_setup_ui.cpp', 'Self-aligning Torque (SAT)', 'setup SAT slider') + +# Round-11 reference model remains as a historical regression check unless a +# later hardware-tested natural model has replaced its exact curve. Round-16's +# own verifier checks the final response shape numerically. +if not round16: + def sat(steer: float, speed: float, corner: float, drift: float) -> float: + steer_abs = min(abs(steer), 1.0) + steer_for_sat = ((steer_abs - 0.012) / 0.988) ** 0.78 if steer_abs > 0.012 else 0.0 + sat_speed = speed ** 0.62 + sat_load = 1.0 + 0.80 * corner + sat_slip = min(max((drift - 0.35) / 0.65, 0.0), 1.0) + sat_grip = 1.0 - 0.65 * (sat_slip ** 1.35) + return steer_for_sat * sat_speed * sat_load * sat_grip * 1.10 + + low_speed = sat(0.50, 0.25, 0.50, 0.10) + mid_speed = sat(0.50, 0.50, 0.50, 0.10) + high_speed = sat(0.50, 0.75, 0.50, 0.10) + small_angle = sat(0.20, 0.75, 0.50, 0.10) + large_angle = sat(0.60, 0.75, 0.50, 0.10) + deep_drift = sat(0.50, 0.75, 0.50, 1.00) + if not (0.0 < low_speed < mid_speed < high_speed): + raise SystemExit('ROUND11 VERIFY FAILED [SAT must grow with speed]') + if not (0.0 < small_angle < large_angle): + raise SystemExit('ROUND11 VERIFY FAILED [SAT must grow with steering angle]') + if not (deep_drift < high_speed * 0.50): + raise SystemExit('ROUND11 VERIFY FAILED [deep drift must unload SAT strongly]') + if not (high_speed > 0.70): + raise SystemExit('ROUND11 VERIFY FAILED [normal high-speed SAT is too weak]') + print(f'ROUND11 reference SAT: low={low_speed:.4f} mid={mid_speed:.4f} high={high_speed:.4f} deep-drift={deep_drift:.4f}') +else: + print('ROUND11 VERIFY OK [exact curve superseded by Round16 natural SAT verifier]') + +for path in ['src/hooks_wheel_ffb.cpp', 'src/overlay/input_bindings_ui.cpp', 'src/overlay/wheel_setup_ui.cpp']: + data = read(path) + if data.count('{') != data.count('}'): + raise SystemExit(f'ROUND11 VERIFY FAILED [brace balance]: {path}') + print(f'ROUND11 VERIFY OK [brace balance {path}]') + +print('Round-11 pseudo-SAT architecture verification passed') diff --git a/tools/archive/rounds/verify_wheel_round12_sat_input_ui.py b/tools/archive/rounds/verify_wheel_round12_sat_input_ui.py new file mode 100644 index 000000000..a04d00012 --- /dev/null +++ b/tools/archive/rounds/verify_wheel_round12_sat_input_ui.py @@ -0,0 +1,57 @@ +from pathlib import Path + + +def read(path: str) -> str: + return Path(path).read_text(encoding='utf-8') + + +def require(path: str, needle: str, label: str) -> None: + data = read(path) + if needle not in data: + raise SystemExit(f'ROUND12 VERIFY FAILED [{label}]: {needle!r} missing from {path}') + print(f'ROUND12 VERIFY OK [{label}]') + + +def forbid(path: str, needle: str, label: str) -> None: + data = read(path) + if needle in data: + raise SystemExit(f'ROUND12 VERIFY FAILED [{label}]: stale {needle!r} remains in {path}') + print(f'ROUND12 VERIFY OK [{label}]') + + +# SAT must consume the SDL multi-device steering state directly when UseNewInput +# is active, while retaining the legacy game-function route as fallback. +require('src/hooks_wheel_ffb.cpp', 'extern float InputManager_SteeringValue();', 'direct SDL steering export declared') +require('src/hooks_wheel_ffb.cpp', 'if (Settings::UseNewInput)\n {\n const float steering = InputManager_SteeringValue();', 'new input SAT uses direct steering') +require('src/hooks_wheel_ffb.cpp', 'auto getVolume = Module::fn_ptr(0x53720);', 'legacy steering fallback retained') +require('src/hooks_wheel_ffb.cpp', 'sat={:.3f}', 'diagnostic logs SAT') +require('src/hooks_wheel_ffb.cpp', 'steerSrc={}', 'diagnostic logs steering source') +require('src/hooks_wheel_ffb.cpp', 'Settings::UseNewInput ? "SDL" : "legacy"', 'diagnostic source selector') + +forbid('src/overlay/input_bindings_ui.cpp', 'ImGui::BeginTabItem("Force Feedback")', 'duplicate Controls FFB tab removed') +require('src/overlay/wheel_setup_ui.cpp', 'return Settings::UseNewInput ? "Force Feedback" : "Legacy Wheel Setup";', 'main tab has clear role') +require('src/overlay/wheel_setup_ui.cpp', '"Force feedback only. With UseNewInput enabled, steering, pedals, buttons, menu controls and calibration come only from Input Bindings.', 'SDL navigation guidance') +require('src/overlay/wheel_setup_ui.cpp', 'if (!Settings::UseNewInput)\n {\n const int regularSlots', 'legacy mapper gated') +require('src/overlay/wheel_setup_ui.cpp', 'if (!Settings::UseNewInput)\n {\n ImGui::SeparatorText("Wheel options")', 'legacy wheel options gated') +require('src/overlay/wheel_setup_ui.cpp', 'Settings::write(Module::UserIniPath);', 'FFB device/preset selection persists') +require('src/overlay/wheel_setup_ui.cpp', 'Settings::WheelFFBEnable = true;', 'SAT preset recovers disabled FFB') +require('src/overlay/wheel_setup_ui.cpp', '"Input setup: Input Bindings only. FFB setup: this Force Feedback page only.', 'single navigation summary') + +require('src/hooks_wheel_ffb.cpp', 'const float selfAligningTorque =', 'SAT model retained') +require('src/hooks_wheel_ffb.cpp', 'structural = (softwareSpring + selfAligningTorque) * loadMod + damper;', 'SAT output retained') +setup = read('src/overlay/wheel_setup_ui.cpp') +if 'Load MOZA R3 Natural SAT' in setup: + require('src/overlay/wheel_setup_ui.cpp', 'Settings::WheelFFBSteeringWeight = 1.75f;', 'Round16 natural SAT preset retained') +elif 'Load MOZA R3 Strong SAT' in setup: + require('src/overlay/wheel_setup_ui.cpp', 'Settings::WheelFFBSteeringWeight = 1.45f;', 'Round15 strong SAT setting retained') +else: + require('src/overlay/wheel_setup_ui.cpp', 'Load MOZA R3 SAT test', 'SAT test preset retained') + require('src/overlay/wheel_setup_ui.cpp', 'Settings::WheelFFBSteeringWeight = 1.10f;', 'Round11 SAT setting retained') + +for path in ['src/hooks_wheel_ffb.cpp', 'src/overlay/input_bindings_ui.cpp', 'src/overlay/wheel_setup_ui.cpp']: + data = read(path) + if data.count('{') != data.count('}'): + raise SystemExit(f'ROUND12 VERIFY FAILED [brace balance]: {path}') + print(f'ROUND12 VERIFY OK [brace balance {path}]') + +print('Round-12 SAT steering-source and UI consolidation verification passed') diff --git a/tools/archive/rounds/verify_wheel_round13_ffb_ui_complete.py b/tools/archive/rounds/verify_wheel_round13_ffb_ui_complete.py new file mode 100644 index 000000000..4f56f6797 --- /dev/null +++ b/tools/archive/rounds/verify_wheel_round13_ffb_ui_complete.py @@ -0,0 +1,33 @@ +from pathlib import Path + +path = 'src/overlay/wheel_setup_ui.cpp' +text = Path(path).read_text(encoding='utf-8') + + +def require(needle: str, label: str) -> None: + if needle not in text: + raise SystemExit(f'ROUND13 VERIFY FAILED [{label}]: {needle!r}') + print(f'ROUND13 VERIFY OK [{label}]') + + +require('void WheelFFB_RequestDirectionTest(int direction);', 'safe test function declared') +require('ImGui::Checkbox("Enable Force Feedback", Settings::WheelFFBEnable.ptr());', 'FFB enable visible') +require('Hardware road/slip sine effects', 'periodic effect toggle visible') +require('Diagnostic logging', 'diagnostic logging toggle visible') +require('Test Left (20%)', 'left direction test visible') +require('WheelFFB_RequestDirectionTest(-1);', 'left direction test wired') +require('Test Right (20%)', 'right direction test visible') +require('WheelFFB_RequestDirectionTest(1);', 'right direction test wired') +require('WheelFFB_RequestDirectionTest(0);', 'stop direction test wired') +if 'Load MOZA R3 Natural SAT' in text: + require('Load MOZA R3 Natural SAT', 'Round16 natural SAT preset retained') +elif 'Load MOZA R3 Strong SAT' in text: + require('Load MOZA R3 Strong SAT', 'Round15 strong SAT preset retained') +else: + require('Load MOZA R3 SAT test', 'SAT preset retained') +require('Self-aligning Torque (SAT)', 'SAT slider retained') + +if text.count('{') != text.count('}'): + raise SystemExit('ROUND13 VERIFY FAILED [brace balance]') +print('ROUND13 VERIFY OK [brace balance]') +print('Round-13 complete single-page FFB controls verification passed') diff --git a/tools/archive/rounds/verify_wheel_round14_input_ui_roles.py b/tools/archive/rounds/verify_wheel_round14_input_ui_roles.py new file mode 100644 index 000000000..6f84014e9 --- /dev/null +++ b/tools/archive/rounds/verify_wheel_round14_input_ui_roles.py @@ -0,0 +1,61 @@ +from pathlib import Path + + +def read(path: str) -> str: + return Path(path).read_text(encoding='utf-8') + + +def require(path: str, needle: str, label: str) -> None: + data = read(path) + if needle not in data: + raise SystemExit(f'ROUND14 VERIFY FAILED [{label}]: {needle!r} missing from {path}') + print(f'ROUND14 VERIFY OK [{label}]') + + +def forbid(path: str, needle: str, label: str) -> None: + data = read(path) + if needle in data: + raise SystemExit(f'ROUND14 VERIFY FAILED [{label}]: stale {needle!r} remains in {path}') + print(f'ROUND14 VERIFY OK [{label}]') + + +require( + 'src/overlay/input_bindings_ui.cpp', + 'With UseNewInput enabled, steering, pedals, buttons, menu controls and calibration are saved and applied only from Input Bindings.', + 'Input Bindings declares sole SDL input ownership') +require( + 'src/overlay/input_bindings_ui.cpp', + 'Force feedback is configured separately in the Force Feedback tab.', + 'Input Bindings points to FFB page') +forbid( + 'src/overlay/input_bindings_ui.cpp', + 'ImGui::BeginTabItem("Force Feedback")', + 'no duplicate FFB tab inside Input Bindings') +require( + 'src/overlay/wheel_setup_ui.cpp', + 'This page does not create input bindings; it only selects the DirectInput FFB wheel and tunes its forces.', + 'main page is FFB-only') +require( + 'src/overlay/wheel_setup_ui.cpp', + 'Input setup: Input Bindings only. FFB setup: this Force Feedback page only.', + 'navigation uses exact Input Bindings name') +require( + 'src/overlay/wheel_setup_ui.cpp', + 'return Settings::UseNewInput ? "Force Feedback" : "Legacy Wheel Setup";', + 'legacy mapper remains compatibility-only') +require( + 'src/overlay/wheel_setup_ui.cpp', + 'return Settings::WheelInputCompatibility && !Settings::UseNewInput &&', + 'legacy input hooks cannot own default SDL input') +forbid( + 'src/overlay/wheel_setup_ui.cpp', + 'game Controls / Controller Setup', + 'stale ambiguous navigation removed') + +for path in ['src/overlay/input_bindings_ui.cpp', 'src/overlay/wheel_setup_ui.cpp']: + data = read(path) + if data.count('{') != data.count('}'): + raise SystemExit(f'ROUND14 VERIFY FAILED [brace balance]: {path}') + print(f'ROUND14 VERIFY OK [brace balance {path}]') + +print('Round-14 single-owner input/FFB UI verification passed') diff --git a/tools/archive/rounds/verify_wheel_round15_strong_r3_sat.py b/tools/archive/rounds/verify_wheel_round15_strong_r3_sat.py new file mode 100644 index 000000000..5811f89ed --- /dev/null +++ b/tools/archive/rounds/verify_wheel_round15_strong_r3_sat.py @@ -0,0 +1,79 @@ +from pathlib import Path +import math + +ffb = Path('src/hooks_wheel_ffb.cpp').read_text(encoding='utf-8') +ui = Path('src/overlay/wheel_setup_ui.cpp').read_text(encoding='utf-8') +round16 = 'Load MOZA R3 Natural SAT' in ui + + +def require(text: str, needle: str, label: str) -> None: + if needle not in text: + raise SystemExit(f'ROUND15 VERIFY FAILED [{label}]: {needle!r}') + print(f'ROUND15 VERIFY OK [{label}]') + + +# Hardware-tested R3 direction and declared fallback/default values remain valid +# even when Round-16 replaces the exact SAT/spring response curves. +require(ffb, '"WheelFFB", "SpringStrength", 0.65f,', 'strong low-speed spring default') +require(ffb, '"WheelFFB", "LowSpeedSpring", 0.20f,', 'legacy low-speed setting retained') +require(ffb, '"WheelFFB", "SpringLoadBoost", 0.30f,', 'legacy spring load setting retained') +require(ffb, '"WheelFFB", "SteeringWeight", 1.45f,', 'declared SAT default') +require(ffb, 'Range{ 0.0f, 2.0f }', 'SAT setting headroom') +require(ffb, '"WheelFFB", "InvertForce", true,', 'R3 ConstantForce direction corrected') +require(ui, 'Reverse SAT / ConstantForce', 'clear SAT direction UI') +require(ui, 'Settings::WheelFFBInvertForce = true;', 'preset fixes SAT direction') +require(ui, 'Settings::WheelFFBInvertSpring = false;', 'preset keeps known-good R3 spring sign') +require(ui, 'Settings::write(Module::UserIniPath);', 'preset persists over stale user.ini') + +if round16: + require(ui, 'Load MOZA R3 Natural SAT', 'Round16 natural successor visible') + require(ffb, 'const float satAngleInput =', 'Round16 supersedes Round15 SAT curve') + require(ffb, 'const float springFadeT =', 'Round16 supersedes Round15 spring curve') + print('ROUND15 VERIFY OK [exact Round15 response superseded by Round16 verifier]') +else: + require(ffb, 'std::pow((steerAbs - 0.012f) / 0.988f, 0.58f)', 'moderate steering SAT response') + require(ffb, 'const float satSpeed = std::pow(speedNorm, 0.50f);', 'normal-speed SAT response') + require(ffb, 'const float satLoadBoost = 1.0f + 1.10f * cornerLoad;', 'strong corner-load SAT') + require(ffb, 'const float satSlip = std::clamp((driftAmt - 0.45f) / 0.55f, 0.0f, 1.0f);', 'later grip-loss unload') + require(ui, 'Load MOZA R3 Strong SAT', 'strong R3 preset visible') + + # Historical Round-15 numeric sanity. Round-16 has a separate strict model + # verifier and intentionally removes the tiny-angle power response. + def sat(steer: float, speed: float, corner: float, drift: float) -> tuple[float, float]: + steer_abs = max(0.0, min(1.0, abs(steer))) + steer_for_sat = ((steer_abs - 0.012) / 0.988) ** 0.58 if steer_abs > 0.012 else 0.0 + sat_speed = speed ** 0.50 + sat_load = 1.0 + 1.10 * corner + sat_slip = max(0.0, min(1.0, (drift - 0.45) / 0.55)) + sat_grip = 1.0 - 0.65 * (sat_slip ** 1.35) + raw = steer_for_sat * sat_speed * sat_load * sat_grip * 1.45 + return raw, math.tanh(raw * 0.70) + + low_raw, low_out = sat(0.20, 0.25, 0.30, 0.0) + mid_raw, mid_out = sat(0.30, 0.50, 0.50, 0.0) + high_raw, high_out = sat(0.50, 0.75, 0.70, 0.0) + _, drift_out = sat(0.50, 0.75, 0.70, 1.0) + if not (low_out > 0.20 and mid_out > 0.45 and high_out > 0.70): + raise SystemExit(f'ROUND15 VERIFY FAILED [SAT strength]: low={low_out:.3f} mid={mid_out:.3f} high={high_out:.3f}') + if not drift_out < high_out * 0.55: + raise SystemExit(f'ROUND15 VERIFY FAILED [deep-drift unload]: high={high_out:.3f} drift={drift_out:.3f}') + print(f'ROUND15 VERIFY OK [SAT numeric]: low={low_out:.3f} mid={mid_out:.3f} high={high_out:.3f} drift={drift_out:.3f}') + + def spring_coeff(speed: float, corner: float = 0.5) -> float: + speed_curve = 0.20 + 0.80 * (speed ** 1.60) + return 0.65 * speed_curve * (1.0 + 0.30 * corner) * 0.70 + + stopped = spring_coeff(0.0) + mid = spring_coeff(0.5) + high = spring_coeff(0.75) + if not (stopped > 0.09 and mid > 0.22 and high > 0.34): + raise SystemExit(f'ROUND15 VERIFY FAILED [spring strength]: stop={stopped:.3f} mid={mid:.3f} high={high:.3f}') + print(f'ROUND15 VERIFY OK [spring numeric]: stop={stopped:.3f} mid={mid:.3f} high={high:.3f}') + +for path in ['src/hooks_wheel_ffb.cpp', 'src/overlay/wheel_setup_ui.cpp']: + data = Path(path).read_text(encoding='utf-8') + if data.count('{') != data.count('}'): + raise SystemExit(f'ROUND15 VERIFY FAILED [brace balance]: {path}') + print(f'ROUND15 VERIFY OK [brace balance {path}]') + +print('Round-15 strong MOZA R3 SAT/centering verification passed') diff --git a/tools/archive/rounds/verify_wheel_round16_natural_single_owner.py b/tools/archive/rounds/verify_wheel_round16_natural_single_owner.py new file mode 100644 index 000000000..12a611d6e --- /dev/null +++ b/tools/archive/rounds/verify_wheel_round16_natural_single_owner.py @@ -0,0 +1,135 @@ +from pathlib import Path +import math + + +def read(path: str) -> str: + return Path(path).read_text(encoding='utf-8') + + +def require(path: str, needle: str, label: str) -> None: + data = read(path) + if needle not in data: + raise SystemExit(f'ROUND16 VERIFY FAILED [{label}]: {needle!r} missing from {path}') + print(f'ROUND16 VERIFY OK [{label}]') + + +def forbid(path: str, needle: str, label: str) -> None: + data = read(path) + if needle in data: + raise SystemExit(f'ROUND16 VERIFY FAILED [{label}]: stale {needle!r} remains in {path}') + print(f'ROUND16 VERIFY OK [{label}]') + + +ffb_path = 'src/hooks_wheel_ffb.cpp' +ui_path = 'src/overlay/wheel_setup_ui.cpp' +vibration_path = 'src/hooks_forcefeedback.cpp' +input_ui_path = 'src/overlay/input_bindings_ui.cpp' + +# Natural SAT shape and return-rate stability. +require(ffb_path, '(steerAbs - 0.025f) / 0.975f', 'soft centre deadband') +require(ffb_path, 'std::sin(satAngleInput * HalfPi)', 'progressive sine SAT angle curve') +require(ffb_path, 'const float satMotionGate =', 'smooth rolling-speed gate') +require(ffb_path, 'const float satLoadBoost = 0.72f + 0.38f * cornerLoadSmooth;', 'gentle lateral-load scaling') +require(ffb_path, '(driftAmt - 0.60f) / 0.35f', 'late smooth grip unload') +require(ffb_path, 'const bool returningToCentre = steer * steerRate < 0.0f;', 'detect fast return toward centre') +require(ffb_path, 'const float satReturnRelief = 1.0f - 0.45f * returnRateSmooth;', 'bounded return-rate relief') +forbid(ffb_path, 'std::pow((steerAbs - 0.012f) / 0.988f, 0.58f)', 'Round15 small-angle snap curve removed') +forbid(ffb_path, 'const float satLoadBoost = 1.0f + 1.10f * cornerLoad;', 'Round15 aggressive load boost removed') + +# Spring is now a low-speed helper and cannot stack a second strong high-speed +# centering law on SAT. +require(ffb_path, '(speedNorm - 0.05f) / 0.35f', 'smooth spring fade window') +require(ffb_path, 'const float springSpeed = 1.0f - 0.88f * springFade;', 'high-speed spring fade') +require(ffb_path, 'const float springGrip = 0.80f + 0.20f * gripFactor;', 'bounded spring grip modulation') +forbid(ffb_path, 'std::pow(speedNorm, 1.60f)', 'old speed-increasing spring removed') +require(ffb_path, '-0.06f, 0.08f);', 'weight-transfer torque modulation bounded') +require(ffb_path, '? 0.18f : 0.12f;', 'arcade lateral signal smoothed') + +# There must be one visible FFB settings owner and one force-output backend. +require(ffb_path, 'setting->section() == "WheelFFB"', 'generic WheelFFB registry entries hidden') +require(ffb_path, 'setting->hidden(true);', 'generic settings page suppression') +require(ui_path, 'Single-owner wheel FFB: DirectInput COM only.', 'dedicated FFB page declares ownership') +require(ui_path, 'Settings > WheelFFB is hidden;', 'duplicate generic settings page documented hidden') +require(ui_path, 'Load MOZA R3 Natural SAT', 'natural R3 preset visible') +require(ui_path, 'Centering Spring (low speed)', 'spring role is explicit') +forbid(ui_path, 'ImGui::SliderFloat("Low-speed Aligning"', 'obsolete low-speed spring knob hidden') +forbid(ui_path, 'ImGui::SliderFloat("Spring Corner-load Boost"', 'obsolete spring load knob hidden') +require(input_ui_path, 'saved and applied only from Input Bindings.', 'multi-device input remains sole input owner') +forbid(input_ui_path, 'ImGui::BeginTabItem("Force Feedback")', 'no duplicate FFB tab in Input Bindings') + +# The original Xbox/SDL gamepad rumble route must not run alongside native +# wheel FFB. The upstream RX78 wheel_force_feedback.cpp is intentionally absent. +require(vibration_path, 'extern Setting WheelFFBEnable;', 'vibration hook sees native FFB ownership') +require(vibration_path, 'if (Settings::UseNewInput && Settings::WheelFFBEnable)', 'SDL rumble blocked while wheel FFB active') +require(vibration_path, 'InputManager_SetVibration(vib.wLeftMotorSpeed, vib.wRightMotorSpeed);', 'gamepad rumble remains available when native FFB is off') +if Path('src/wheel_force_feedback.cpp').exists(): + raise SystemExit('ROUND16 VERIFY FAILED [competing RX78 wheel_force_feedback.cpp is present]') +print('ROUND16 VERIFY OK [no competing RX78 wheel_force_feedback.cpp]') + +# The preset deliberately keeps strong holding torque but removes the snap. +require(ui_path, 'Settings::WheelFFBSpringSaturation = 0.95f;', 'spring saturation preserves usable low-speed centre') +require(ui_path, 'Settings::WheelFFBSteeringWeight = 1.75f;', 'strong natural SAT preset') +require(ui_path, 'Settings::WheelFFBWeightTransfer = 0.20f;', 'subtle weight transfer preset') +require(ui_path, 'Settings::WheelFFBSlewRate = 0.045f;', 'smooth structural slew preset') +require(ui_path, 'Settings::VibrationMode = 0;', 'preset disables independent gamepad rumble') +require(ui_path, 'Settings::WheelFFBInvertForce = true;', 'known R3 ConstantForce direction retained') +require(ui_path, 'Settings::WheelFFBInvertSpring = false;', 'known R3 spring direction retained') +require(ui_path, 'Settings::write(Module::UserIniPath);', 'natural preset persists to user.ini') + + +def smoothstep(v: float) -> float: + v = max(0.0, min(1.0, v)) + return v * v * (3.0 - 2.0 * v) + + +def sat_output(steer: float, speed: float, corner: float, drift: float, steer_rate: float = 0.0) -> float: + steer_abs = max(0.0, min(1.0, abs(steer))) + angle = max(0.0, min(1.0, (steer_abs - 0.025) / 0.975)) + angle_curve = math.sin(angle * math.pi / 2.0) + motion = smoothstep((speed - 0.015) / 0.085) + speed_curve = motion * (0.20 + 0.80 * math.sqrt(max(speed, 0.0))) + load = 0.72 + 0.38 * smoothstep(corner) + slip = smoothstep((drift - 0.60) / 0.35) + grip = 1.0 - 0.65 * 0.65 * slip + returning = steer * steer_rate < 0.0 + return_t = min(abs(steer_rate) / 0.08, 1.0) if returning else 0.0 + return_relief = 1.0 - 0.45 * smoothstep(return_t) + raw = angle_curve * speed_curve * load * grip * return_relief * 1.75 + return math.tanh(raw * 0.70) + + +tiny = sat_output(0.05, 0.50, 0.30, 0.0) +small = sat_output(0.10, 0.50, 0.30, 0.0) +mid = sat_output(0.30, 0.50, 0.50, 0.0) +high = sat_output(0.50, 0.75, 0.70, 0.0) +deep = sat_output(0.50, 0.75, 0.70, 1.0) +fast_return = sat_output(0.30, 0.50, 0.50, 0.0, -0.08) +if not (0.0 < tiny < 0.05 and tiny < small < mid < high): + raise SystemExit(f'ROUND16 VERIFY FAILED [progressive SAT]: tiny={tiny:.3f} small={small:.3f} mid={mid:.3f} high={high:.3f}') +if not (mid > 0.30 and high > 0.58): + raise SystemExit(f'ROUND16 VERIFY FAILED [loaded-corner SAT too weak]: mid={mid:.3f} high={high:.3f}') +if not (0.50 < deep / high < 0.80): + raise SystemExit(f'ROUND16 VERIFY FAILED [deep-slide unload]: deep={deep:.3f} high={high:.3f}') +if not fast_return < mid * 0.70: + raise SystemExit(f'ROUND16 VERIFY FAILED [return whip relief]: fast={fast_return:.3f} hold={mid:.3f}') +print(f'ROUND16 VERIFY OK [SAT numeric]: tiny={tiny:.3f} small={small:.3f} mid={mid:.3f} high={high:.3f} deep={deep:.3f} fast-return={fast_return:.3f}') + + +def spring_coeff(speed: float) -> float: + fade = smoothstep((speed - 0.05) / 0.35) + return 0.65 * (1.0 - 0.88 * fade) * 0.70 + +park = spring_coeff(0.0) +mid_speed_spring = spring_coeff(0.20) +high_speed_spring = spring_coeff(0.50) +if not (park > 0.40 and mid_speed_spring > high_speed_spring and high_speed_spring < park * 0.15): + raise SystemExit(f'ROUND16 VERIFY FAILED [spring role]: park={park:.3f} mid={mid_speed_spring:.3f} high={high_speed_spring:.3f}') +print(f'ROUND16 VERIFY OK [spring numeric]: park={park:.3f} mid={mid_speed_spring:.3f} high={high_speed_spring:.3f}') + +for path in [ffb_path, ui_path, vibration_path, input_ui_path]: + data = read(path) + if data.count('{') != data.count('}'): + raise SystemExit(f'ROUND16 VERIFY FAILED [brace balance]: {path}') + print(f'ROUND16 VERIFY OK [brace balance {path}]') + +print('Round-16 natural single-owner wheel FFB verification passed') diff --git a/tools/archive/rounds/verify_wheel_round17_physics_sat.py b/tools/archive/rounds/verify_wheel_round17_physics_sat.py new file mode 100644 index 000000000..b465c37fb --- /dev/null +++ b/tools/archive/rounds/verify_wheel_round17_physics_sat.py @@ -0,0 +1,132 @@ +from pathlib import Path +import math + +ffb = Path('src/hooks_wheel_ffb.cpp').read_text(encoding='utf-8') +ui = Path('src/overlay/wheel_setup_ui.cpp').read_text(encoding='utf-8') +helper = Path('src/hooks_wheel_physics_sat.hpp').read_text(encoding='utf-8') +vibration = Path('src/hooks_forcefeedback.cpp').read_text(encoding='utf-8') + + +def require(text: str, needle: str, label: str) -> None: + if needle not in text: + raise SystemExit(f'ROUND17 VERIFY FAILED [{label}]: {needle!r}') + print(f'ROUND17 VERIFY OK [{label}]') + + +require(ffb, '#include "hooks_wheel_physics_sat.hpp"', 'physics helper is compiled') +require(ffb, '"WheelFFB", "PhysicsSAT", true,', 'physics mode defaults on') +require(ffb, 'const float naturalSatTorque =', 'Natural SAT retained for A/B comparison') +require(ffb, 'const float physicsSatTorque = physicsSat_.update(', 'physics model drives SAT') +require(ffb, 'const float physicsFallback = naturalSatTorque * 0.15f;', 'small calibration-only fallback') +require(ffb, 'const float physicsMix = physicsSat_.activationBlend();', 'physics activation crossfade') +require(ffb, 'physicsFallback + (physicsSatTorque - physicsFallback) * physicsMix', 'continuous Natural-to-Physics transition') +require(ffb, 'WheelPhysicsSatV1 physicsSat_{};', 'per-engine physics state') +require(ffb, 'physicsSat_.reset();', 'transition reset clears physics history') +require(ffb, 'phys={} basis=M70r{} cal={:.2f} mix={:.2f}', 'physics calibration telemetry') +require(ffb, 'step={:.5f} spdLen={:.5f} spdCorr={:.2f}', 'velocity scale telemetry') + +if 'physicsSat_.ready() ?' in ffb: + raise SystemExit('ROUND17 VERIFY FAILED [old ready/fallback force-model switching remains]') +print('ROUND17 VERIFY OK [valid zero Physics SAT cannot switch back to Natural SAT]') + +require(helper, 'bool calibrated() const', 'calibration state is separate') +require(helper, 'bool sampleValid() const', 'sample validity state is separate') +require(helper, 'bool torqueActive() const', 'torque activity state is separate') +require(helper, 'float decay_invalid_sample()', 'transient invalid sample decay') +require(helper, 'void clear_dynamic_state()', 'dynamic state can reset without losing basis calibration') +require(helper, 'activationBlend_ = std::min(1.0f, activationBlend_ + (1.0f / 24.0f));', '24-tick activation ramp') +require(helper, 'sampleValid_ = calibrated_;', 'parking zero is valid after calibration') +require(helper, 'if (speedNorm <= 0.04f)', 'parking/restart stale-state guard') +require(helper, 'motionScale > motionScaleEma_ * 5.0f', 'relative teleport/warp detector') +require(helper, '++discontinuityCount_;', 'motion discontinuities are observable') +require(helper, 'lastTorque_ *= 0.55f;', 'brief invalid samples decay rather than switch force model') + +require(helper, 'const D3DMATRIX& body = car->matrix_70;', 'non-display body transform candidate') +require(helper, 'const D3DVECTOR current = car->position_14;', 'physics tick motion source') +require(helper, 'constexpr int CalibrationSamplesRequired = 12;', 'multi-sample forward-axis calibration') +require(helper, 'calibrationScoreX_ += std::abs(xDot);', 'row1 basis is scored') +require(helper, 'calibrationScoreZ_ += std::abs(zDot);', 'row3 basis is scored') +require(helper, 'bestScore >= 0.85f && calibrationConfidence_ >= 0.25f', 'basis requires confidence before commit') +require(helper, 'std::atan2(vLat_, std::max(std::abs(vLong_)', 'body slip beta') +require(helper, 'headingDelta * 60.0f', 'yaw rate uses fixed 60 Hz physics tick') +require(helper, 'roadWheelAngle - bodySlip_ - yawRate_ * yawLeadSeconds', 'front slip combines steer beta yaw') +require(helper, '(frontSlip_ > 0.0f ? -1.0f : 1.0f)', 'SAT direction follows front slip') +require(helper, 'car->spd_mb_20.x', 'direct velocity candidate is measured') +require(helper, 'positionStep_', 'position delta scale is measured') +require(helper, 'motionScaleEma_', 'position step is normalized against rolling speed scale') +require(helper, 'spdLen_', 'direct velocity magnitude is measured') +require(helper, 'spdCorrelation_', 'spd_mb correlation is logged before use') + +require(vibration, 'void __cdecl WheelFFB_UpdateAfterPhysics(EVWORK_CAR* car);', 'post-physics update is declared in car Ctrl owner') +require(vibration, 'GamePlCar_Ctrl.call(car);\n WheelFFB_UpdateAfterPhysics(car);', 'WheelFFB samples after original car physics') +require(ffb, 'void __cdecl WheelFFB_UpdateAfterPhysics(EVWORK_CAR* car)', 'WheelFFB post-physics entry point exists') +if 'CalcVibrationHook_' in ffb or 'calc_vibration_hook(EVWORK_CAR* car)' in ffb: + raise SystemExit('ROUND17 VERIFY FAILED [old CalcVibrationValues FFB hook still exists]') +print('ROUND17 VERIFY OK [one GamePlCar_Ctrl owner; no competing CalcVibration hook]') + +calc_pos = vibration.find('CalcVibrationValues(car);') +car_ctrl_pos = vibration.find('GamePlCar_Ctrl.call(car);') +wheel_pos = vibration.find('WheelFFB_UpdateAfterPhysics(car);') +if not (0 <= calc_pos < car_ctrl_pos < wheel_pos): + raise SystemExit('ROUND17 VERIFY FAILED [car/vibration/FFB execution ordering]') +print('ROUND17 VERIFY OK [legacy vibration stays before physics; WheelFFB samples after physics]') + +require(ui, 'Physics SAT v1 (body slip + yaw)', 'live physics toggle') +require(ui, 'Load MOZA R3 Physics SAT v1', 'R3 physics preset') +require(ui, 'Settings::WheelFFBPhysicsSat = false;', 'Natural SAT A/B button disables physics mode') +require(ui, 'Settings::WheelFFBDebugLog = true;', 'physics preset enables diagnostics') + +if ffb.count('(steer >= 0.0f ? -1.0f : 1.0f)') != 1: + raise SystemExit('ROUND17 VERIFY FAILED [steering-sign centering leaked outside Natural fallback]') +print('ROUND17 VERIFY OK [physics direction is not steering-sign centering]') + + +def trail(slip: float) -> float: + x = abs(slip) / 0.16 + return math.sin(x * math.pi / 2.0) if x <= 1.0 else math.exp(-(x - 1.0) * 0.90) + +small, medium, peak, deep = trail(0.02), trail(0.08), trail(0.16), trail(0.40) +if not (0.0 < small < medium < peak and deep < peak): + raise SystemExit(f'ROUND17 VERIFY FAILED [trail curve]: {small=} {medium=} {peak=} {deep=}') +print(f'ROUND17 VERIFY OK [trail curve]: {small:.3f} < {medium:.3f} < {peak:.3f}, deep={deep:.3f}') + +# Once the crossfade reaches 1.0, a valid zero Physics SAT must produce zero, +# not a percentage of Natural SAT. During calibration the fallback is small. +natural = 0.8 +physics_zero = 0.0 +calibration_output = natural * 0.15 + (physics_zero - natural * 0.15) * 0.0 +active_output = natural * 0.15 + (physics_zero - natural * 0.15) * 1.0 +if not (math.isclose(calibration_output, 0.12) and math.isclose(active_output, 0.0, abs_tol=1e-9)): + raise SystemExit('ROUND17 VERIFY FAILED [activation/fallback crossfade semantics]') +print('ROUND17 VERIFY OK [calibration fallback fades completely to true Physics zero]') + +# Four transient bad ticks decay monotonically, the fifth reaches zero. +torque = 1.0 +decayed = [] +for i in range(1, 6): + torque = 0.0 if i > 4 else torque * 0.55 + decayed.append(torque) +if not (decayed[0] > decayed[1] > decayed[2] > decayed[3] > decayed[4] == 0.0): + raise SystemExit(f'ROUND17 VERIFY FAILED [invalid sample decay]: {decayed}') +print(f'ROUND17 VERIFY OK [invalid sample decay]: {decayed}') + +beta, yaw, speed = 0.18, 0.50, 0.75 +yaw_lead = 0.10 - 0.045 * speed +slip_left_counter = -0.15 * 0.52 - beta - yaw * yaw_lead +slip_at_center = -beta - yaw * yaw_lead +if not (slip_left_counter < 0.0 and slip_at_center < 0.0): + raise SystemExit('ROUND17 VERIFY FAILED [counter-steer centre continuity]') +print(f'ROUND17 VERIFY OK [counter-steer continuity]: counter={slip_left_counter:.3f}, centre={slip_at_center:.3f}') + +for path in [ + 'src/hooks_wheel_ffb.cpp', + 'src/hooks_forcefeedback.cpp', + 'src/overlay/wheel_setup_ui.cpp', + 'src/hooks_wheel_physics_sat.hpp', +]: + data = Path(path).read_text(encoding='utf-8') + if data.count('{') != data.count('}'): + raise SystemExit(f'ROUND17 VERIFY FAILED [brace balance]: {path}') + print(f'ROUND17 VERIFY OK [brace balance {path}]') + +print('Round-17 physics SAT verification passed: post-physics sampling + separated validity + stale-state guards') diff --git a/tools/archive/rounds/verify_wheel_round3_10pass.py b/tools/archive/rounds/verify_wheel_round3_10pass.py new file mode 100644 index 000000000..465128fc8 --- /dev/null +++ b/tools/archive/rounds/verify_wheel_round3_10pass.py @@ -0,0 +1,37 @@ +from pathlib import Path + +ffb = Path('src/hooks_wheel_ffb.cpp').read_text(encoding='utf-8') +ui = Path('src/overlay/wheel_setup_ui.cpp').read_text(encoding='utf-8') + +checks = [ + ('independent spring invert setting', 'Setting WheelFFBInvertSpring{' in ffb), + ('spring no longer follows InvertForce', 'const LONG coefficient = Settings::WheelFFBInvertSpring' in ffb and 'const LONG coefficient = Settings::WheelFFBInvertForce' not in ffb), + ('F11 ConstantForce direction control', ('Reverse ConstantForce' in ui or 'Reverse SAT / ConstantForce' in ui)), + ('F11 spring direction control', 'Reverse Spring' in ui), + ('hardware spring live-disable fallback', 'hardware spring disabled live; using software centering' in ffb), + ('gameplay actuator retry path', 'note_device_failure("SETACTUATORSON", actuatorHr);' in ffb and 'deviceAcquired_ = false;' in ffb), + ('initial actuator command checked', 'SETACTUATORSON during initialization failed' in ffb), + ('periodic pair is atomic', 'complete hardware periodic pair unavailable; using ConstantForce fallback for both signals' in ffb), + ('device reinit resets gain failure state', 'lastGainErrorLog_ = 0;' in ffb), + ('diagnostic exposes both direction states', 'invCF={} spring={} invSpring={}' in ffb), +] + +for label, ok in checks: + if not ok: + raise SystemExit(f'ROUND3 VERIFY FAILED: {label}') + print(f'ROUND3 VERIFY OK: {label}') + +# Recovery patch must actually be part of the effective source, not merely exist +# as a dormant helper script in the repository. +recovery_markers = [ + 'FFB_DEVICE_FAILURE_GRACE_MS', + 'void request_device_reinitialize(', + 'void teardown_for_reinitialize()', + 'DirectInput device released; waiting to re-enumerate after device loss', +] +for marker in recovery_markers: + if marker not in ffb: + raise SystemExit(f'ROUND3 VERIFY FAILED: recovery marker missing: {marker}') + +print('ROUND3 VERIFY OK: DirectInput recovery is active in effective source') +print('Round-3 ten-pass verification passed') diff --git a/tools/archive/rounds/verify_wheel_round4_fivepass.py b/tools/archive/rounds/verify_wheel_round4_fivepass.py new file mode 100644 index 000000000..6155e56d0 --- /dev/null +++ b/tools/archive/rounds/verify_wheel_round4_fivepass.py @@ -0,0 +1,31 @@ +from pathlib import Path +ffb=Path('src/hooks_wheel_ffb.cpp').read_text(encoding='utf-8') +dpad=Path('src/hooks_wheel_r3_menu_dpad.hpp').read_text(encoding='utf-8') +ab=Path('src/hooks_wheel_r3_menu_ab.hpp').read_text(encoding='utf-8') +checks = [ + ('wrap-safe tick helper', 'bool tick_before(DWORD now, DWORD deadline)' in ffb), + ('wrap-safe reached helper', 'bool tick_reached(DWORD now, DWORD deadline)' in ffb), + ('device reinit deadline safe', 'tick_before(updateNow, deviceReinitAfter_)' in ffb), + ('FFB retry deadline safe', 'tick_before(GetTickCount(), retryAfter_)' in ffb), + ('failed sibling deadline safe', 'tick_before(GetTickCount(), ctx->self->failedInterfaceUntil_)' in ffb), + ('foreground seeded before exclusive acquire', 'appActive_ = (GetForegroundWindow() == gameHwnd_);' in ffb), + ('reacquire restores actuators', 'reacquire SETACTUATORSON' in ffb and 'SendForceFeedbackCommand(DISFFC_SETACTUATORSON)' in ffb), + ('persistent constant params', 'DICONSTANTFORCE constantParams_{};' in ffb), + ('persistent spring params', 'DICONDITION springParams_{};' in ffb), + ('persistent damper params', 'DICONDITION damperParams_{};' in ffb), + ('persistent road periodic params', 'DIPERIODIC roadPeriodicParams_{};' in ffb), + ('persistent tire periodic params', 'DIPERIODIC tireSlipPeriodicParams_{};' in ffb), + ('no local constant params', 'DICONSTANTFORCE cf{};' not in ffb), + ('no local condition params', 'DICONDITION condition{};' not in ffb), + ('no local periodic params', 'DIPERIODIC periodic{};' not in ffb), + ('hook supports startup disabled', 'makes F11 enable/disable genuinely' in ffb and 'return true;' in ffb[ffb.find('class WheelFFBHook'):]), + ('test cancel does not reacquire in menu', 'deviceAcquired_ && inGameplay' in ffb), + ('DPad retry wrap safe', 'static_cast(now - retryAfter) < 0' in dpad), + ('AB retry wrap safe', 'static_cast(now - retryAfter) < 0' in ab), +] +failed=[name for name,ok in checks if not ok] +for name,ok in checks: + print(('PASS' if ok else 'FAIL'), name) +if failed: + raise SystemExit('round4 five-pass verification failed: ' + ', '.join(failed)) +print('Round-4 five-pass source hardening verification passed') diff --git a/tools/archive/rounds/verify_wheel_round5_fivepass.py b/tools/archive/rounds/verify_wheel_round5_fivepass.py new file mode 100644 index 000000000..6df374fab --- /dev/null +++ b/tools/archive/rounds/verify_wheel_round5_fivepass.py @@ -0,0 +1,124 @@ +from pathlib import Path + + +def read(path: str) -> str: + return Path(path).read_text(encoding='utf-8') + + +def require(path: str, needle: str, label: str) -> None: + data = read(path) + if needle not in data: + raise SystemExit(f'ROUND5 VERIFY FAILED [{label}]: {needle!r} missing from {path}') + print(f'ROUND5 VERIFY OK [{label}]') + + +def forbid(path: str, needle: str, label: str) -> None: + data = read(path) + if needle in data: + raise SystemExit(f'ROUND5 VERIFY FAILED [{label}]: stale {needle!r} remains in {path}') + print(f'ROUND5 VERIFY OK [{label}]') + + +def verify_cpp_delimiters(path: str) -> None: + """Balance (), {}, [] while ignoring C/C++ comments and quoted literals.""" + s = read(path) + stack = [] + pairs = {')': '(', '}': '{', ']': '['} + opens = set(pairs.values()) + i = 0 + state = 'code' + while i < len(s): + ch = s[i] + nxt = s[i + 1] if i + 1 < len(s) else '' + if state == 'code': + if ch == '/' and nxt == '/': + state = 'line_comment'; i += 2; continue + if ch == '/' and nxt == '*': + state = 'block_comment'; i += 2; continue + if ch == '"': + state = 'string'; i += 1; continue + if ch == "'": + state = 'char'; i += 1; continue + if ch in opens: + stack.append((ch, i)) + elif ch in pairs: + if not stack or stack[-1][0] != pairs[ch]: + raise SystemExit( + f'ROUND5 VERIFY FAILED [delimiter balance]: unexpected {ch!r} at offset {i} in {path}') + stack.pop() + elif state == 'line_comment': + if ch == '\n': + state = 'code' + elif state == 'block_comment': + if ch == '*' and nxt == '/': + state = 'code'; i += 2; continue + elif state in ('string', 'char'): + quote = '"' if state == 'string' else "'" + if ch == '\\': + i += 2; continue + if ch == quote: + state = 'code' + i += 1 + + if state == 'block_comment': + raise SystemExit(f'ROUND5 VERIFY FAILED [delimiter balance]: unterminated block comment in {path}') + if stack: + ch, pos = stack[-1] + raise SystemExit( + f'ROUND5 VERIFY FAILED [delimiter balance]: unclosed {ch!r} from offset {pos} in {path}') + print(f'ROUND5 VERIFY OK [C++ delimiter balance: {path}]') + + +ffb = 'src/hooks_wheel_ffb.cpp' +ims = 'src/input_manager.hpp' + +# Regression for the build-blocker caught by the previous real MSVC compile. +for deadline, label in [ + ('recreateHoldoffUntil_', 'periodic deadline outer parenthesis'), + ('springRecreateHoldoffUntil_', 'spring deadline outer parenthesis'), + ('damperRecreateHoldoffUntil_', 'damper deadline outer parenthesis'), +]: + require(ffb, f'tick_reached(GetTickCount(), {deadline}))', label) + forbid(ffb, f'tick_reached(GetTickCount(), {deadline})\n {{', f'no broken {label}') +verify_cpp_delimiters(ffb) +verify_cpp_delimiters(ims) + +# PASS 1: post-selection interface failures are actionable and capabilities are +# checked with their actual HRESULT. +require(ffb, 'hr = device_->GetCapabilities(&caps);', 'GetCapabilities HRESULT captured') +require(ffb, 'mark_selected_interface_failed("GetCapabilities", hr);', 'GetCapabilities sibling fallback') +require(ffb, 'mark_selected_interface_failed("CreateDevice", failHr);', 'CreateDevice sibling fallback') +require(ffb, 'mark_selected_interface_failed("SetDataFormat", hr);', 'SetDataFormat sibling fallback') +require(ffb, 'mark_selected_interface_failed("SetCooperativeLevel", hr);', 'cooperative-level sibling fallback') +require(ffb, 'mark_selected_interface_failed("Acquire", hr);', 'Acquire sibling fallback') +require(ffb, 'mark_selected_interface_failed("initial SETACTUATORSON", actuatorOnHr);', 'actuator sibling fallback') + +# PASS 2: driver autocenter ownership is explicit and restored even on partial +# initialization / reinitialize release paths. +require(ffb, 'bool driverAutocenterDisabled_ = false;', 'autocenter ownership state') +require(ffb, 'driverAutocenterDisabled_ = true;', 'autocenter disable ownership acquired') +require(ffb, 'void restore_driver_autocenter(const char* where)', 'shared autocenter restore helper') +require(ffb, 'restore_driver_autocenter("device reinitialize");', 'reinitialize restores autocenter') +require(ffb, 'restore_driver_autocenter("device release");', 'all device release restores autocenter') +require(ffb, 'driverAutocenterDisabled_ = false;\n spdlog::info("WheelFFB: PanicStop autocenter restore', 'PanicStop clears autocenter ownership') + +# PASS 3: BACKGROUND cooperative mode still has explicit focus safety around +# every input-loss reacquisition. +require(ffb, 'if (!appActive_ || !gameHwnd_ || GetForegroundWindow() != gameHwnd_)', 'pre-Acquire foreground gate') +require(ffb, 'if (!appActive_ || GetForegroundWindow() != gameHwnd_)\n {\n device_->Unacquire();', 'post-Acquire focus race gate') + +# PASS 4: COM root/device state is invalidated before risky Release calls. +require(ffb, 'IDirectInput8A* staleDirectInput = directInput_;\n directInput_ = nullptr;', 'DirectInput root state-first release') +require(ffb, 'WheelFFB: exception releasing DirectInput root object', 'DirectInput root release exception guard') +require(ffb, 'IDirectInputDevice8A* staleDevice = device_;\n device_ = nullptr;', 'device state-first release') + +# PASS 5: raw identical-device keys cannot collide after hotplug, and removing a +# controller before the primary keeps the same primary controller selected. +require(ims, '#include ', 'explicit algorithm dependency') +require(ims, 'while (std::any_of(devices.begin(), devices.end(), [&guid, occurrence]', 'lowest-unused occurrence allocation') +require(ims, 'device.guid == guid && device.occurrence == occurrence;', 'occurrence collision test') +forbid(ims, 'const int occurrence = int(std::count_if(devices.begin(), devices.end()', 'no count-based duplicate occurrence allocator') +require(ims, 'const int removedIndex = int(std::distance(controllers.begin(), it));', 'removed controller index captured') +require(ims, 'else if (primaryControllerIndex > removedIndex)\n\t\t\t{\n\t\t\t\t--primaryControllerIndex;', 'primary index shifted after earlier removal') + +print('Round-5 five-pass source hardening verification passed') diff --git a/tools/archive/rounds/verify_wheel_round6_fivepass.py b/tools/archive/rounds/verify_wheel_round6_fivepass.py new file mode 100644 index 000000000..715e035c1 --- /dev/null +++ b/tools/archive/rounds/verify_wheel_round6_fivepass.py @@ -0,0 +1,114 @@ +from pathlib import Path +import math + + +def read(path): + return Path(path).read_text(encoding='utf-8') + +def req(path, needle, label): + data=read(path) + if needle not in data: + raise SystemExit(f'ROUND6 VERIFY FAILED [{label}]: missing {needle!r}') + print(f'ROUND6 VERIFY OK [{label}]') + +def forbid(path, needle, label): + data=read(path) + if needle in data: + raise SystemExit(f'ROUND6 VERIFY FAILED [{label}]: stale {needle!r}') + print(f'ROUND6 VERIFY OK [{label}]') + +ffb='src/hooks_wheel_ffb.cpp' +ims='src/input_manager.hpp' +setup='src/overlay/wheel_setup_ui.cpp' + +# PASS 1 lifecycle/focus +req(ffb, 'if (!initialized_ && gameHwnd_ && GetForegroundWindow() == gameHwnd_)', 'background-startup state can recover on foreground') +req(ffb, 'if (!gameHwnd_ || GetForegroundWindow() != gameHwnd_)\n return;\n\n const HRESULT acquireHr = device_->Acquire();', 'normal gameplay Acquire foreground precheck') +req(ffb, 'if (GetForegroundWindow() != gameHwnd_)\n {\n device_->Unacquire();', 'normal gameplay Acquire foreground postcheck') +req(ffb, 'if (!inGameplay)\n {\n deviceAcquired_ = false;\n return false;\n }\n if (!appActive_ || !gameHwnd_', 'input-loss reacquire gameplay gate') +if read(ffb).count('if (!Settings::WheelFFBUseHardwareSpring && springEffect_)') != 1: + raise SystemExit('ROUND6 VERIFY FAILED [spring lifecycle duplicate]: expected one live-disable block') +print('ROUND6 VERIFY OK [spring lifecycle duplicate removed]') + +# PASS 2 identity/hotplug +req(ffb, 'is unavailable; refusing DeviceName fallback', 'missing exact GUID cannot redirect torque') +req(ffb, 'was rejected during initialization; trying a sibling DeviceName interface', 'explicit sibling fallback retained') +req(ffb, 'failedInterfaceGuid_.clear();\n failedInterfaceUntil_ = 0;', 'stale sibling rejection state cleared') +forbid(ffb, "unavailable/rejected; falling back to DeviceName", 'old unconditional FFB name fallback removed') +req(setup, 'if (index < 0)\n return false;', 'F11 reader refuses missing exact GUID') +req(ims, 'if (!binding.deviceSerial.empty())\n\t\t\treturn usbIdentityMatches && device.serial == binding.deviceSerial;', 'serial identity authoritative') +req(ims, 'if (!binding.devicePath.empty())\n\t\t\treturn usbIdentityMatches && device.path == binding.devicePath;', 'path identity authoritative') + +# PASS 3 math/backend parity +req(ffb, ': -steer * springStrength;', 'software spring preserves load/grip model') +req(ffb, 'const float effectRampScale = warmupScale * recreateScale;', 'road/slip effect ramp scale') +req(ffb, 'roadAmp * effectRampScale', 'road texture ramped') +req(ffb, 'slipAmp * effectRampScale', 'tire slip ramped') + +# Verify the intended speed curve and load/unload relationships numerically. +def curve(speed, low=.08): + return max(0.0, min(1.0, low + (1.0-low) * speed**1.60)) +vals=[curve(x) for x in (0.0,.25,.5,.75,1.0)] +if not (abs(vals[0]-.08)<1e-9 and .17 < vals[1] < .21 and .36 < vals[2] < .41 and .63 < vals[3] < .68 and abs(vals[4]-1.0)<1e-9): + raise SystemExit(f'ROUND6 VERIFY FAILED [speed curve]: {vals}') +base=.60*curve(.8) +loaded=min(1.0, base*(1.0+.7*.35)) +drift=min(1.0, base*(1.0+.7*.35)*(1.0-.65*.9)) +if not loaded > base > drift: + raise SystemExit(f'ROUND6 VERIFY FAILED [software spring load/grip parity]: {base}, {loaded}, {drift}') +print(f'ROUND6 VERIFY OK [speed/load/grip numeric]: curve={vals}, base={base:.4f}, loaded={loaded:.4f}, drift={drift:.4f}') + +# PASS 4 finite safety +for needle,label in [ + ('std::isfinite(speedRaw)', 'speed finite guard'), + ('std::isfinite(configuredStrength)', 'master gain finite guard'), + ('std::isfinite(lateralSum)', 'lateral finite guard'), + ('std::isfinite(surfaceRoughness)', 'surface finite guard'), + ('if (!std::isfinite(total))\n total = 0.0f;', 'final force finite guard'), + ('std::isfinite(configuredSlew)', 'slew finite guard'), + ('std::isfinite(strength)', 'condition strength finite guards'), + ('std::isfinite(magnitude)', 'periodic magnitude finite guard'), + ('std::isfinite(frequency)', 'periodic frequency finite guard'), + ('!std::isfinite(amplitude)', 'fallback sine finite guard'), +]: req(ffb, needle, label) +if read(ffb).count('const float safeStrength = std::isfinite(strength)') != 2: + raise SystemExit('ROUND6 VERIFY FAILED [spring/damper finite guards]: expected exactly two') +print('ROUND6 VERIFY OK [spring/damper finite guards both present]') + +# PASS 5 F11 runtime ownership language +req(setup, 'gameplay FFB follows the exact selected DirectInput GUID.', 'F11 exact GUID ownership text') + +# Lightweight C/C++ delimiter scanner that ignores strings/chars/comments. This +# specifically guards against the round4 unmatched-parenthesis regression before +# MSVC, while the real Actions compile remains authoritative. +def check_delimiters(text): + pairs={')':'(',']':'[','}':'{'}; opens=set(pairs.values()); stack=[] + i=0; state='code' + while i < len(text): + c=text[i]; n=text[i+1] if i+1 0;''', + 'WM_ACTIVATEAPP resets transient state': '''self->zero_all_forces();\n self->reset_signal_state();''', +} +for label, needle in checks.items(): + if needle not in s: + raise SystemExit(f'ROUND7 VERIFY FAILED [{label}]') + print(f'ROUND7 VERIFY OK [{label}]') + +# Lightweight delimiter balance catches the class of compiler failure that +# escaped earlier string-only verification. Ignore strings/comments enough for +# this generated source by using a tiny lexer. +def strip_noncode(text: str) -> str: + out=[]; i=0; n=len(text); state='code' + while i str: + return Path(path).read_text(encoding='utf-8') + + +def require(path: str, needle: str, label: str) -> None: + data = read(path) + if needle not in data: + raise SystemExit(f'ROUND8 VERIFY FAILED [{label}]: {needle!r} missing from {path}') + print(f'ROUND8 VERIFY OK [{label}]') + + +def forbid(path: str, needle: str, label: str) -> None: + data = read(path) + if needle in data: + raise SystemExit(f'ROUND8 VERIFY FAILED [{label}]: stale {needle!r} present in {path}') + print(f'ROUND8 VERIFY OK [{label}]') + + +manager = 'src/input_manager.hpp' +ui = 'src/overlay/input_bindings_ui.cpp' +wheel_ui = 'src/overlay/wheel_setup_ui.cpp' + +require(manager, 'InputSourceType lastSourceType = InputSourceType::GamePad;', 'InputState source initialized') +require(manager, 'uint32_t switch_current = 0;', 'current switch mask initialized') +require(manager, 'uint32_t switch_previous = 0;', 'previous switch mask initialized') +require(manager, 'uint32_t switch_overlay = 0;', 'overlay switch mask initialized') + +require(manager, 'bool positiveOnly = false', 'InputAction supports positive-only digital aggregation') +require(manager, 'if (positiveOnly && currentValue <= 0.0f)', 'negative side ignored for digital actions') +require(manager, 'switchBindings[i].update(gamepad, resolveJoystick, true)', 'switches use positive-only aggregation') +require(manager, 'modBindings[i].update(gamepad, resolveJoystick, true)', 'mod actions use positive-only aggregation') + +require(manager, 'volumes[i].previousValue = 0.0f;', 'overlay clears stale previous analog value') +require(manager, 'volumes[i].currentValue = 0.0f;', 'overlay clears stale current analog value') +require(manager, 'never leave a stale', 'overlay analog safety documented') + +require(manager, 'auto previousVolumeBindings = volumeBindings;', 'binding load snapshots current volume binds') +require(manager, 'int loadedBinds = 0;', 'binding load counts valid parses') +require(manager, 'no valid bindings parsed; previous bindings preserved', 'zero-valid binding file rejected') +require(manager, 'ensureOverlayBindable();\n\t\treturn true;', 'successful reload always keeps overlay reachable') +require(manager, 'ignoring invalid negative suffix for non-axis action', 'impossible button/key negation normalized') + +require(ui, 'std::optional releaseGuardBinding;', 'captured input release guard exists') +require(ui, 'capturedReleased = std::abs(releaseGuardBinding->read(', 'release guard reads captured input') +require(ui, 'capturedReleased && !manager.anyInputPressed()', 'next bind waits for captured release') +require(ui, 'releaseGuardBinding = *quickSetupCandidate;\n\t\t\t\t\tquickSetupCandidate.reset();\n\t\t\t\t\t++quickSetupStep;', 'quick setup use waits for release') +require(ui, 'releaseGuardBinding = *quickSetupCandidate;\n\t\t\t\t\tquickSetupCandidate.reset();\n\t\t\t\t\tisListeningForInput = ListenState::WaitForBindButtonRelease;', 'quick setup retry waits for release') +require(ui, 'else if (steering || binding.isAxis())', 'invert UI limited to directional inputs') +require(ui, 'Invert is only meaningful for axes or steering-direction buttons', 'invalid button invert explained') + +require(wheel_ui, 'Settings::WheelUniversalSetupEnable = true;\n Settings::WheelMenuR3DirectDPad = false;\n Settings::WheelMenuR3DirectAB = false;', 'legacy import disables fixed R3 menu readers') + +# Small behavioral regression checks for the digital aggregation rule. +def digital(values): + best = 0.0 + for value in values: + if value <= 0.0: + continue + if abs(value) > abs(best): + best = value + return best + +if digital([-1.0, 1.0]) != 1.0: + raise SystemExit('ROUND8 VERIFY FAILED [negative axis must not mask +1 button]') +print('ROUND8 VERIFY OK [negative axis does not mask +1 button]') +if digital([-1.0, 0.0]) != 0.0: + raise SystemExit('ROUND8 VERIFY FAILED [negative-only digital action must be inactive]') +print('ROUND8 VERIFY OK [negative-only digital action is inactive]') + +# Lightweight C/C++ delimiter scanner, ignoring strings/comments, to catch patch +# generation mistakes before the real MSVC build. +def balanced(path: str) -> None: + text = read(path) + stack = [] + pairs = {')': '(', ']': '[', '}': '{'} + opens = set(pairs.values()) + i = 0 + state = 'code' + while i < len(text): + c = text[i] + n = text[i + 1] if i + 1 < len(text) else '' + if state == 'code': + if c == '/' and n == '/': + state = 'line'; i += 2; continue + if c == '/' and n == '*': + state = 'block'; i += 2; continue + if c == '"': + state = 'string'; i += 1; continue + if c == "'": + state = 'char'; i += 1; continue + if c in opens: + stack.append(c) + elif c in pairs: + if not stack or stack[-1] != pairs[c]: + raise SystemExit(f'ROUND8 VERIFY FAILED [delimiter balance] {path}: unexpected {c} at {i}') + stack.pop() + i += 1; continue + if state == 'line': + if c == '\n': state = 'code' + i += 1; continue + if state == 'block': + if c == '*' and n == '/': state = 'code'; i += 2; continue + i += 1; continue + if state in ('string', 'char'): + quote = '"' if state == 'string' else "'" + if c == '\\': i += 2; continue + if c == quote: state = 'code' + i += 1; continue + if stack: + raise SystemExit(f'ROUND8 VERIFY FAILED [delimiter balance] {path}: unclosed {stack[-1]}') + print(f'ROUND8 VERIFY OK [delimiter balance] {path}') + +for path in (manager, ui, wheel_ui): + balanced(path) + +print('Round-8 input binding and key-setting hardening verification passed') diff --git a/tools/archive/rounds/verify_wheel_round9_release_v01.py b/tools/archive/rounds/verify_wheel_round9_release_v01.py new file mode 100644 index 000000000..d3f686f6b --- /dev/null +++ b/tools/archive/rounds/verify_wheel_round9_release_v01.py @@ -0,0 +1,65 @@ +from pathlib import Path + + +def read(path: str) -> str: + return Path(path).read_text(encoding='utf-8') + + +def require(path: str, needle: str, label: str) -> None: + data = read(path) + if needle not in data: + raise SystemExit(f'ROUND9 VERIFY FAILED [{label}]: {needle!r} missing from {path}') + print(f'ROUND9 VERIFY OK [{label}]') + +ffb = read('src/hooks_wheel_ffb.cpp') +input_ui = read('src/overlay/input_bindings_ui.cpp') +setup_ui = read('src/overlay/wheel_setup_ui.cpp') + +# Round-9 originally installed the v0.1 R3 tune. Later experimental rounds may +# deliberately supersede the spring/damper/SAT values, so verify the retained +# release-era safety/detail invariants here and let the newest round verify its +# own final tuning exactly. +require('src/hooks_wheel_ffb.cpp', '"WheelFFB", "GripLoss", 0.65f,', 'v0.1 grip-loss baseline retained') +require('src/hooks_wheel_ffb.cpp', '"WheelFFB", "WallImpact", 0.38f,', 'restrained collision retained') +require('src/hooks_wheel_ffb.cpp', '"WheelFFB", "RoadTexture", 0.30f,', 'road detail retained') +require('src/hooks_wheel_ffb.cpp', '"WheelFFB", "TireSlip", 0.20f,', 'tire slip retained') + +if 'Load MOZA R3 Natural SAT' in setup_ui: + require('src/hooks_wheel_ffb.cpp', '"WheelFFB", "SpringStrength", 0.65f,', 'Round16 low-speed spring baseline') + require('src/hooks_wheel_ffb.cpp', '"WheelFFB", "DamperStrength", 0.30f,', 'Round16 damping stabilizer') + require('src/hooks_wheel_ffb.cpp', '"WheelFFB", "SteeringWeight", 1.45f,', 'declared SAT default remains backward-compatible') + require('src/overlay/wheel_setup_ui.cpp', 'Load MOZA R3 Natural SAT', 'Round16 natural setup preset visible') +elif 'Load MOZA R3 Strong SAT' in setup_ui: + require('src/hooks_wheel_ffb.cpp', '"WheelFFB", "SpringStrength", 0.65f,', 'Round15 strong spring supersedes earlier tune') + require('src/hooks_wheel_ffb.cpp', '"WheelFFB", "DamperStrength", 0.30f,', 'Round15 damping stabilizer') + require('src/hooks_wheel_ffb.cpp', '"WheelFFB", "SteeringWeight", 1.45f,', 'Round15 strong SAT supersedes earlier tune') + require('src/overlay/wheel_setup_ui.cpp', 'Load MOZA R3 Strong SAT', 'Round15 setup preset visible') +elif 'MOZA R3 SAT test' in input_ui: + require('src/hooks_wheel_ffb.cpp', '"WheelFFB", "SpringStrength", 0.32f,', 'Round11 supersedes v0.1 spring') + require('src/hooks_wheel_ffb.cpp', '"WheelFFB", "DamperStrength", 0.34f,', 'Round11 supersedes v0.1 damping') + require('src/hooks_wheel_ffb.cpp', '"WheelFFB", "SteeringWeight", 1.10f,', 'Round11 supersedes cornering with SAT') + require('src/overlay/wheel_setup_ui.cpp', 'Load MOZA R3 SAT test', 'Round11 setup preset visible') +else: + require('src/hooks_wheel_ffb.cpp', '"WheelFFB", "SpringStrength", 0.60f,', 'v0.1 spring default') + require('src/hooks_wheel_ffb.cpp', '"WheelFFB", "DamperStrength", 0.42f,', 'v0.1 damping default') + require('src/hooks_wheel_ffb.cpp', '"WheelFFB", "SteeringWeight", 0.38f,', 'v0.1 cornering default') + require('src/overlay/input_bindings_ui.cpp', 'MOZA R3 v0.1 (default)', 'bindings panel v0.1 preset') + require('src/overlay/input_bindings_ui.cpp', 'applyPreset(0.70f, 0.60f, 0.42f, 0.38f, 0.65f, 0.38f, 0.30f, 0.20f, 0.08f, 0.35f);', 'exact v0.1 profile values') + require('src/overlay/wheel_setup_ui.cpp', 'Load MOZA R3 v0.1 (default)', 'F11 setup v0.1 preset') + +# Existing comparison presets remain in source history even though the SDL Input +# Bindings dialog no longer exposes FFB tuning itself. +require('src/overlay/input_bindings_ui.cpp', 'Simulation Balanced v1', 'balanced comparison preset retained') +require('src/overlay/input_bindings_ui.cpp', 'Arcade Light v1', 'light comparison preset retained') +require('src/overlay/input_bindings_ui.cpp', 'Arcade Strong v1', 'strong comparison preset retained') + +for path, data in [ + ('src/hooks_wheel_ffb.cpp', ffb), + ('src/overlay/input_bindings_ui.cpp', input_ui), + ('src/overlay/wheel_setup_ui.cpp', setup_ui), +]: + if data.count('{') != data.count('}'): + raise SystemExit(f'ROUND9 VERIFY FAILED [brace balance]: {path}') + print(f'ROUND9 VERIFY OK [brace balance {path}]') + +print('Round-9 release-era invariants verification passed') diff --git a/tools/patch_multi_device_hybrid.py b/tools/patch_multi_device_hybrid.py new file mode 100644 index 000000000..bbcbb757c --- /dev/null +++ b/tools/patch_multi_device_hybrid.py @@ -0,0 +1,191 @@ +from pathlib import Path + +# Hybrid integration layer. +# Input/binding architecture is adapted from hyp36rmax/multi-device-input (MIT), +# while this branch keeps its richer DirectInput COM multi-effect FFB engine. + +im = Path('src/input_manager.cpp') +ui = Path('src/overlay/input_bindings_ui.cpp') +dll = Path('src/dllmain.cpp') + +ims = im.read_text(encoding='utf-8') +uis = ui.read_text(encoding='utf-8') +dlls = dll.read_text(encoding='utf-8') + + +def rep(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f'{label}: expected exactly one match, got {count}') + print(f'patched: {label}') + return text.replace(old, new, 1) + + +# --- Input manager: keep hyp36rmax raw SDL multi-device input, but do not start +# its separate ConstantForce FFB backend. A tiny DirectInput probe is enough to +# choose SDL's DirectInput input backend automatically when an FFB wheel exists. +ims = rep( + ims, + '#include "wheel_force_feedback.hpp"\n', + '''#ifndef DIRECTINPUT_VERSION\n#define DIRECTINPUT_VERSION 0x0800\n#endif\n#include \n#include \n\n#pragma comment(lib, "dinput8.lib")\n#pragma comment(lib, "dxguid.lib")\n''', + 'remove competing FFB backend dependency') + +anchor = 'InputManager& InputManager::instance = *new InputManager;\n' +probe = r'''namespace +{ + BOOL CALLBACK detect_ffb_device(LPCDIDEVICEINSTANCEA, LPVOID context) + { + *static_cast(context) = true; + return DIENUM_STOP; + } + + bool has_attached_ffb_wheel() + { + IDirectInput8A* di = nullptr; + const HRESULT createHr = DirectInput8Create( + GetModuleHandleW(nullptr), DIRECTINPUT_VERSION, IID_IDirectInput8A, + reinterpret_cast(&di), nullptr); + if (FAILED(createHr) || !di) + return false; + + bool found = false; + const HRESULT enumHr = di->EnumDevices( + DI8DEVCLASS_GAMECTRL, detect_ffb_device, &found, + DIEDFL_ATTACHEDONLY | DIEDFL_FORCEFEEDBACK); + di->Release(); + return SUCCEEDED(enumHr) && found; + } +} + +''' +ims = rep(ims, anchor, probe + anchor, 'DirectInput wheel auto-backend probe') +ims = rep( + ims, + 'if (activeBackend == 0 && WheelForceFeedback::has_attached_device())', + 'if (activeBackend == 0 && has_attached_ffb_wheel())', + 'automatic DirectInput selection') +ims = rep(ims, '\n\tWheelForceFeedback::init(hwnd);\n', '\n', 'leave FFB ownership to custom engine') +ims = rep( + ims, + '''void InputManager_Update()\n{\n\tWheelForceFeedback::update();\n\tif (Settings::UseNewInput)\n\t\tInputManager::instance.update();\n}\n''', + '''void InputManager_Update()\n{\n\tif (Settings::UseNewInput)\n\t\tInputManager::instance.update();\n}\n''', + 'remove competing per-frame FFB update') + +# --- Binding UI: retain Quick Setup, calibration, hot-plug device list and raw +# device bindings. Replace hyp36rmax's simple FFB page with this branch's richer +# simulation controls; device identity/capability controls stay in Wheel Setup. +uis = rep( + uis, + '#include "wheel_force_feedback.hpp"\n', + '', + 'binding UI uses custom FFB engine') + +insert_after = '#include \n' +settings_decl = r''' +namespace Settings +{ + extern Setting WheelFFBEnable; + extern Setting WheelFFBGlobalStrength; + extern Setting WheelFFBSpringStrength; + extern Setting WheelFFBDamperStrength; + extern Setting WheelFFBSteeringWeight; + extern Setting WheelFFBGripLoss; + extern Setting WheelFFBWallImpact; + extern Setting WheelFFBRoadTexture; + extern Setting WheelFFBTireSlip; + extern Setting WheelFFBUseHardwareSpring; + extern Setting WheelFFBUseHardwareDamper; + extern Setting WheelFFBUsePeriodicEffects; + extern Setting WheelFFBInvertForce; + extern Setting WheelFFBInvertSpring; + extern Setting WheelFFBDebugLog; +} +''' +uis = rep(uis, insert_after, insert_after + settings_decl, 'declare advanced FFB settings in binding UI') + +ffb_start = uis.find('\tvoid draw_force_feedback()\n\t{') +ffb_end_marker = '\n\t// The prompt shown while an input is being waited on.' +ffb_end = uis.find(ffb_end_marker, ffb_start) +if ffb_start < 0 or ffb_end < 0: + raise SystemExit('advanced FFB UI replacement markers not found') + +new_ffb = r''' void draw_force_feedback() + { + ImGui::TextWrapped("Advanced DirectInput COM FFB. Input bindings can come from any connected device; FFB remains attached to the selected force-feedback wheel."); + ImGui::Spacing(); + + if (ImGui::Checkbox("Enable force feedback", Settings::WheelFFBEnable.ptr())) + setting_changed(Settings::WheelFFBEnable); + + ImGui::BeginDisabled(!Settings::WheelFFBEnable); + if (ImGui::SliderFloat("Overall strength", Settings::WheelFFBGlobalStrength.ptr(), 0.0f, 1.0f, "%.2f")) + setting_changed(Settings::WheelFFBGlobalStrength); + if (ImGui::SliderFloat("Aligning / centering", Settings::WheelFFBSpringStrength.ptr(), 0.0f, 1.5f, "%.2f")) + setting_changed(Settings::WheelFFBSpringStrength); + if (ImGui::SliderFloat("Dynamic damping", Settings::WheelFFBDamperStrength.ptr(), 0.0f, 1.0f, "%.2f")) + setting_changed(Settings::WheelFFBDamperStrength); + if (ImGui::SliderFloat("Cornering load", Settings::WheelFFBSteeringWeight.ptr(), 0.0f, 1.5f, "%.2f")) + setting_changed(Settings::WheelFFBSteeringWeight); + if (ImGui::SliderFloat("Grip-loss unloading", Settings::WheelFFBGripLoss.ptr(), 0.0f, 1.0f, "%.2f")) + setting_changed(Settings::WheelFFBGripLoss); + if (ImGui::SliderFloat("Collision", Settings::WheelFFBWallImpact.ptr(), 0.0f, 1.0f, "%.2f")) + setting_changed(Settings::WheelFFBWallImpact); + if (ImGui::SliderFloat("Road detail", Settings::WheelFFBRoadTexture.ptr(), 0.0f, 1.0f, "%.2f")) + setting_changed(Settings::WheelFFBRoadTexture); + if (ImGui::SliderFloat("Tire slip", Settings::WheelFFBTireSlip.ptr(), 0.0f, 1.0f, "%.2f")) + setting_changed(Settings::WheelFFBTireSlip); + + ImGui::SeparatorText("Backends / direction"); + if (ImGui::Checkbox("Hardware GUID_Spring", Settings::WheelFFBUseHardwareSpring.ptr())) + setting_changed(Settings::WheelFFBUseHardwareSpring); + ImGui::SameLine(); + if (ImGui::Checkbox("Hardware GUID_Damper", Settings::WheelFFBUseHardwareDamper.ptr())) + setting_changed(Settings::WheelFFBUseHardwareDamper); + if (ImGui::Checkbox("Hardware road/slip sine effects", Settings::WheelFFBUsePeriodicEffects.ptr())) + setting_changed(Settings::WheelFFBUsePeriodicEffects); + if (ImGui::Checkbox("Reverse ConstantForce", Settings::WheelFFBInvertForce.ptr())) + setting_changed(Settings::WheelFFBInvertForce); + ImGui::SameLine(); + if (ImGui::Checkbox("Reverse Spring", Settings::WheelFFBInvertSpring.ptr())) + setting_changed(Settings::WheelFFBInvertSpring); + if (ImGui::Checkbox("Diagnostic logging", Settings::WheelFFBDebugLog.ptr())) + setting_changed(Settings::WheelFFBDebugLog); + + ImGui::Spacing(); + ImGui::TextDisabled("Select/refresh the exact DirectInput FFB interface and inspect capabilities in the Wheel Setup tab."); + ImGui::EndDisabled(); + } +''' +uis = uis[:ffb_start] + new_ffb + uis[ffb_end:] +print('patched: replace simple ConstantForce FFB page with advanced controls') + +uis = rep( + uis, + 'ImGui::SliderInt("Steering Deadzone", &deadzonePercent, 5, 20, "%d%%")', + 'ImGui::SliderInt("Steering Deadzone", &deadzonePercent, 0, 20, "%d%%")', + 'allow zero steering deadzone') + +old_attribution = 'ImGui::TextDisabled("Multi-device input by hyp36rmax");' +new_attribution = 'ImGui::TextDisabled("Multi-device input architecture adapted from hyp36rmax (MIT)");' +attribution_count = uis.count(old_attribution) +if attribution_count < 1: + raise SystemExit('runtime attribution: no hyp36rmax attribution labels found') +uis = uis.replace(old_attribution, new_attribution) +print(f'patched: runtime attribution x{attribution_count}') + +# --- Startup policy: no longer force the legacy Sumo DirectInput path. The new +# raw-device InputManager is now the preferred path; legacy helpers remain a +# deliberate fallback whenever UseNewInput=false. +legacy_start = dlls.find('\t// The last official release used the game\'s own DirectInput path.') +legacy_end_marker = '\n\tSettings::to_log();' +legacy_end = dlls.find(legacy_end_marker, legacy_start) +if legacy_start < 0 or legacy_end < 0: + raise SystemExit('legacy startup override block markers not found') +replacement = '''\tif (Settings::UseNewInput)\n\t{\n\t\tspdlog::info("Wheel input: multi-device SDL raw input enabled; legacy DirectInput helpers remain inactive fallback");\n\t}\n\telse if (Settings::WheelInputCompatibility)\n\t{\n\t\tspdlog::info("Wheel input: legacy DirectInput compatibility fallback enabled");\n\t}\n''' +dlls = dlls[:legacy_start] + replacement + dlls[legacy_end:] +print('patched: prefer multi-device input without deleting legacy fallback') + +im.write_text(ims, encoding='utf-8') +ui.write_text(uis, encoding='utf-8') +dll.write_text(dlls, encoding='utf-8') +print('Applied hyp36rmax multi-device / custom DirectInput COM FFB hybrid') diff --git a/tools/patch_wheel_debug_hardening.py b/tools/patch_wheel_debug_hardening.py new file mode 100644 index 000000000..4efc1ef7d --- /dev/null +++ b/tools/patch_wheel_debug_hardening.py @@ -0,0 +1,94 @@ +from pathlib import Path + + +def replace_once(path: str, old: str, new: str, label: str) -> None: + p = Path(path) + text = p.read_text(encoding="utf-8") + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected exactly one match in {path}, found {count}") + p.write_text(text.replace(old, new, 1), encoding="utf-8") + + +# R3 testing with the native separate accelerator/brake rows showed that both +# pedals are correct without the legacy compatibility inversions/split hack. +# Keep those workarounds opt-in for older combined-axis wheels instead of +# enabling them on a fresh universal-wheel profile. +replace_once( + "src/hooks_input.cpp", + 'Setting WheelAccelerationInvert{ "Controls", "WheelAccelerationInvert", true,', + 'Setting WheelAccelerationInvert{ "Controls", "WheelAccelerationInvert", false,', + "acceleration invert default", +) + +# The shipped INI explicitly overrides the C++ default, so patch it too. Without +# this, a clean install would still boot with the R3 accelerator backwards. +replace_once( + "OutRun2006Tweaks.ini", + "WheelAccelerationInvert = true\n", + "WheelAccelerationInvert = false\n", + "shipped acceleration invert default", +) + +replace_once( + "src/hooks_wheel_input_compat_v2.hpp", + ' "Controls", "WheelPedalSplitFix", true,\n', + ' "Controls", "WheelPedalSplitFix", false,\n', + "pedal split default", +) + +# A saved/imported universal profile can become enabled without the user +# clicking the checkbox during this run. Make the old R3 fixed-button readers +# self-disable whenever the universal profile is active, so the two input paths +# can never inject duplicate/conflicting menu actions. +replace_once( + "src/hooks_wheel_r3_menu_dpad.hpp", + 'namespace Settings\n{\n Setting WheelMenuR3DirectDPad{', + 'namespace Settings\n{\n extern Setting WheelUniversalSetupEnable;\n\n Setting WheelMenuR3DirectDPad{', + "D-pad universal extern", +) +replace_once( + "src/hooks_wheel_r3_menu_dpad.hpp", + ' !Settings::UseNewInput &&\n Settings::WheelMenuR3DirectDPad &&', + ' !Settings::UseNewInput &&\n !Settings::WheelUniversalSetupEnable &&\n Settings::WheelMenuR3DirectDPad &&', + "D-pad universal exclusion", +) + +replace_once( + "src/hooks_wheel_r3_menu_ab.hpp", + 'namespace Settings\n{\n Setting WheelMenuR3DirectAB{', + 'namespace Settings\n{\n extern Setting WheelUniversalSetupEnable;\n\n Setting WheelMenuR3DirectAB{', + "A/B universal extern", +) +replace_once( + "src/hooks_wheel_r3_menu_ab.hpp", + ' !Settings::UseNewInput &&\n Settings::WheelMenuR3DirectAB &&', + ' !Settings::UseNewInput &&\n !Settings::WheelUniversalSetupEnable &&\n Settings::WheelMenuR3DirectAB &&', + "A/B universal exclusion", +) + +# DirectInput unacquire/reacquire (menus/F11 -> gameplay) stops effects on many +# drivers even when the effect object remains valid. The mature DirectInput +# FFB implementations start updated effects explicitly. Force DIEP_START on +# spring/damper/periodic SetParameters calls so returning from a menu cannot +# leave an apparently-valid but stopped effect behind. +replace_once( + "src/hooks_wheel_ffb.cpp", + ' springStrategy_ = -1;\n\n spdlog::info(\n "WheelFFB: GUID_Spring created', + ' springStrategy_ = 1; // Always include DIEP_START on dynamic updates.\n\n spdlog::info(\n "WheelFFB: GUID_Spring created', + "spring DIEP_START strategy", +) +replace_once( + "src/hooks_wheel_ffb.cpp", + ' prevDamperCoefficient_ = 0;\n damperStrategy_ = -1;\n spdlog::info("WheelFFB: GUID_Damper created', + ' prevDamperCoefficient_ = 0;\n damperStrategy_ = 1; // Always include DIEP_START after menu reacquire.\n spdlog::info("WheelFFB: GUID_Damper created', + "damper DIEP_START strategy", +) +replace_once( + "src/hooks_wheel_ffb.cpp", + ' int periodicStrategy_ = -1;\n', + ' int periodicStrategy_ = 1; // Explicitly restart sine effects on every update.\n', + "periodic DIEP_START strategy", +) + +print("Applied wheel runtime hardening: pedal defaults/INI, universal-menu exclusion, persistent DIEP_START") diff --git a/tools/patch_wheel_device_selection.py b/tools/patch_wheel_device_selection.py new file mode 100644 index 000000000..3ca497a76 --- /dev/null +++ b/tools/patch_wheel_device_selection.py @@ -0,0 +1,136 @@ +from pathlib import Path + + +def replace_once(path: str, old: str, new: str, label: str) -> None: + p = Path(path) + text = p.read_text(encoding="utf-8") + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected exactly one match in {path}, found {count}") + p.write_text(text.replace(old, new, 1), encoding="utf-8") + print(f"patched: {label}") + + +# Keep the configured/default name intact. Earlier R3 auto-select blanked the +# name before enumeration, which meant a connected gamepad/pedal device could +# become the menu reader simply because it happened to enumerate first. The +# readers below now try the configured name first and use an FFB-only fallback +# only for the shipped "MOZA" default when Windows exposes the R3 under a +# vendor-less product name. +replace_once( + "src/hooks_wheel_r3_device_autoselect.hpp", + ' Settings::WheelMenuR3DeviceName = "";\n', + ' // Keep the name: strict-first/fallback selection happens in the reader.\n', + "preserve R3 menu device name", +) +replace_once( + "src/hooks_wheel_r3_device_autoselect.hpp", + ' Settings::WheelFFBDeviceName = "";\n', + ' // Keep the name: strict-first/fallback selection happens in the FFB engine.\n', + "preserve R3 FFB device name", +) +replace_once( + "src/hooks_wheel_r3_device_autoselect.hpp", + ' "WheelR3DeviceAutoSelect: Windows did not reliably expose the R3 with a MOZA-prefixed DirectInput name; relaxed default device-name filters to auto-select");\n', + ' "WheelR3DeviceAutoSelect: MOZA default uses strict-name-first selection with an FFB-only fallback");\n', + "autoselect diagnostic", +) + + +# FFB engine: strict configured-name match first; only the stock MOZA default +# may fall back, and even then EnumDevices is restricted to force-feedback +# devices and still rejects known virtual devices in the callback. +replace_once( + "src/hooks_wheel_ffb.cpp", +''' struct EnumContext\n {\n WheelFFBEngine* self = nullptr;\n GUID selectedGuid{};\n std::string selectedName;\n bool found = false;\n };\n''', +''' struct EnumContext\n {\n WheelFFBEngine* self = nullptr;\n GUID selectedGuid{};\n std::string selectedName;\n bool found = false;\n bool ignoreName = false;\n };\n''', + "FFB enum fallback state", +) +replace_once( + "src/hooks_wheel_ffb.cpp", +''' if (!wanted.empty() &&\n instanceName.find(wanted) == std::string::npos &&\n productName.find(wanted) == std::string::npos)\n''', +''' if (!ctx->ignoreName && !wanted.empty() &&\n instanceName.find(wanted) == std::string::npos &&\n productName.find(wanted) == std::string::npos)\n''', + "FFB strict-first callback", +) +replace_once( + "src/hooks_wheel_ffb.cpp", +''' hr = directInput_->EnumDevices(\n DI8DEVCLASS_GAMECTRL,\n enum_devices_callback,\n &ctx,\n DIEDFL_ATTACHEDONLY | DIEDFL_FORCEFEEDBACK);\n\n if (FAILED(hr) || !ctx.found)\n {\n spdlog::error(\n "WheelFFB: no FFB wheel matched DeviceName='{}'",\n Settings::WheelFFBDeviceName.get());\n release_directinput();\n return false;\n }\n''', +''' hr = directInput_->EnumDevices(\n DI8DEVCLASS_GAMECTRL,\n enum_devices_callback,\n &ctx,\n DIEDFL_ATTACHEDONLY | DIEDFL_FORCEFEEDBACK);\n\n if (SUCCEEDED(hr) && !ctx.found &&\n lower_copy(Settings::WheelFFBDeviceName.get().c_str()) == "moza")\n {\n ctx.ignoreName = true;\n spdlog::warn(\n "WheelFFB: no literal MOZA DirectInput name; trying first attached non-virtual FFB device");\n hr = directInput_->EnumDevices(\n DI8DEVCLASS_GAMECTRL,\n enum_devices_callback,\n &ctx,\n DIEDFL_ATTACHEDONLY | DIEDFL_FORCEFEEDBACK);\n }\n\n if (FAILED(hr) || !ctx.found)\n {\n spdlog::error(\n "WheelFFB: no FFB wheel matched DeviceName='{}'",\n Settings::WheelFFBDeviceName.get());\n release_directinput();\n return false;\n }\n''', + "FFB strict-first fallback enumeration", +) + + +# R3 menu D-pad reader. Always enumerate FFB-capable game controllers so an +# ordinary pad can never steal the R3 fixed-button helper. Retry without the +# name filter only for the stock MOZA default. If a once-valid handle becomes +# unreadable, release it so the existing retry loop can recover after replug. +replace_once( + "src/hooks_wheel_r3_menu_dpad.hpp", +''' struct EnumContext\n {\n GUID guid{};\n std::string name;\n bool found = false;\n };\n''', +''' struct EnumContext\n {\n GUID guid{};\n std::string name;\n bool found = false;\n bool ignoreName = false;\n };\n''', + "D-pad enum fallback state", +) +replace_once( + "src/hooks_wheel_r3_menu_dpad.hpp", +''' if (!wanted.empty() &&\n instanceName.find(wanted) == std::string::npos &&\n productName.find(wanted) == std::string::npos)\n''', +''' if (!ctx->ignoreName && !wanted.empty() &&\n instanceName.find(wanted) == std::string::npos &&\n productName.find(wanted) == std::string::npos)\n''', + "D-pad strict-first callback", +) +replace_once( + "src/hooks_wheel_r3_menu_dpad.hpp", +''' hr = directInput->EnumDevices(\n DI8DEVCLASS_GAMECTRL,\n enumDevicesCallback,\n &ctx,\n DIEDFL_ATTACHEDONLY);\n if (FAILED(hr) || !ctx.found)\n {\n spdlog::warn(\n "WheelMenuR3DirectDPad: no DirectInput device matched '{}'",\n Settings::WheelMenuR3DeviceName.get());\n retryAfter = now + 2000;\n releaseDevice();\n return false;\n }\n''', +''' hr = directInput->EnumDevices(\n DI8DEVCLASS_GAMECTRL,\n enumDevicesCallback,\n &ctx,\n DIEDFL_ATTACHEDONLY | DIEDFL_FORCEFEEDBACK);\n if (SUCCEEDED(hr) && !ctx.found &&\n r3_lower_copy(Settings::WheelMenuR3DeviceName.get().c_str()) == "moza")\n {\n ctx.ignoreName = true;\n spdlog::warn(\n "WheelMenuR3DirectDPad: no literal MOZA name; trying first attached FFB wheel");\n hr = directInput->EnumDevices(\n DI8DEVCLASS_GAMECTRL,\n enumDevicesCallback,\n &ctx,\n DIEDFL_ATTACHEDONLY | DIEDFL_FORCEFEEDBACK);\n }\n if (FAILED(hr) || !ctx.found)\n {\n spdlog::warn(\n "WheelMenuR3DirectDPad: no DirectInput FFB wheel matched '{}'",\n Settings::WheelMenuR3DeviceName.get());\n retryAfter = now + 2000;\n releaseDevice();\n return false;\n }\n''', + "D-pad FFB-only fallback enumeration", +) +replace_once( + "src/hooks_wheel_r3_menu_dpad.hpp", +''' haveDirectState = false;\n return false;\n }\n\n for (const auto& direction : Directions)\n''', +''' haveDirectState = false;\n releaseDevice();\n retryAfter = now + 500;\n return false;\n }\n\n for (const auto& direction : Directions)\n''', + "D-pad stale-handle recovery", +) + + +# R3 A/B reader: same strict-first, FFB-only fallback and stale-handle recovery. +replace_once( + "src/hooks_wheel_r3_menu_ab.hpp", +''' struct EnumContext\n {\n GUID guid{};\n std::string name;\n bool found = false;\n };\n''', +''' struct EnumContext\n {\n GUID guid{};\n std::string name;\n bool found = false;\n bool ignoreName = false;\n };\n''', + "A/B enum fallback state", +) +replace_once( + "src/hooks_wheel_r3_menu_ab.hpp", +''' if (!wanted.empty() &&\n instanceName.find(wanted) == std::string::npos &&\n productName.find(wanted) == std::string::npos)\n''', +''' if (!ctx->ignoreName && !wanted.empty() &&\n instanceName.find(wanted) == std::string::npos &&\n productName.find(wanted) == std::string::npos)\n''', + "A/B strict-first callback", +) +replace_once( + "src/hooks_wheel_r3_menu_ab.hpp", +''' hr = directInput->EnumDevices(\n DI8DEVCLASS_GAMECTRL,\n enumDevicesCallback,\n &ctx,\n DIEDFL_ATTACHEDONLY);\n if (FAILED(hr) || !ctx.found)\n {\n retryAfter = now + 2000;\n releaseDevice();\n return false;\n }\n''', +''' hr = directInput->EnumDevices(\n DI8DEVCLASS_GAMECTRL,\n enumDevicesCallback,\n &ctx,\n DIEDFL_ATTACHEDONLY | DIEDFL_FORCEFEEDBACK);\n if (SUCCEEDED(hr) && !ctx.found &&\n lowerCopy(Settings::WheelMenuR3DeviceName.get().c_str()) == "moza")\n {\n ctx.ignoreName = true;\n spdlog::warn(\n "WheelMenuR3DirectAB: no literal MOZA name; trying first attached FFB wheel");\n hr = directInput->EnumDevices(\n DI8DEVCLASS_GAMECTRL,\n enumDevicesCallback,\n &ctx,\n DIEDFL_ATTACHEDONLY | DIEDFL_FORCEFEEDBACK);\n }\n if (FAILED(hr) || !ctx.found)\n {\n retryAfter = now + 2000;\n releaseDevice();\n return false;\n }\n''', + "A/B FFB-only fallback enumeration", +) +replace_once( + "src/hooks_wheel_r3_menu_ab.hpp", +''' haveDirectState = false;\n return false;\n }\n\n a = buttonHeld(state, int(Settings::WheelMenuR3AButton));\n''', +''' haveDirectState = false;\n releaseDevice();\n retryAfter = now + 500;\n return false;\n }\n\n a = buttonHeld(state, int(Settings::WheelMenuR3AButton));\n''', + "A/B stale-handle recovery", +) + + +# A 20% gamepad-oriented steering deadzone is enormous on the user's 270-degree +# R3 (about +/-27 degrees around centre). This wheel branch should start at zero +# and let F11 add 1-2% only if the hardware actually jitters. +replace_once( + "src/hooks_input.cpp", +'''\tSetting SteeringDeadZone{ "Controls", "SteeringDeadZone", 0.2f,\n\t\t"Allows overriding the steering deadzone. Game default is 0.2 / 20%.", Range{ 0.f, 1.f } };\n''', +'''\tSetting SteeringDeadZone{ "Controls", "SteeringDeadZone", 0.0f,\n\t\t"Allows overriding the steering deadzone. Original game default is 0.2 / 20%; wheel branch default is 0%.", Range{ 0.f, 1.f } };\n''', + "wheel deadzone source default", +) +replace_once( + "OutRun2006Tweaks.ini", +'''# Allows overriding the steering deadzone\n# Game default is 0.2 / 20%\nSteeringDeadZone = 0.2\n''', +'''# Allows overriding the steering deadzone\n# Original game default is 0.2 / 20%; wheel branch defaults to zero.\nSteeringDeadZone = 0.0\n''', + "wheel deadzone shipped default", +) + +print("Applied strict-first wheel selection, FFB-only R3 fallback, reader recovery, and 0% wheel deadzone") diff --git a/tools/patch_wheel_ffb_community.py b/tools/patch_wheel_ffb_community.py new file mode 100644 index 000000000..7893396c4 --- /dev/null +++ b/tools/patch_wheel_ffb_community.py @@ -0,0 +1,511 @@ +from pathlib import Path + +ffb_path = Path('src/hooks_wheel_ffb.cpp') +input_path = Path('src/input_manager.cpp') +ui_path = Path('src/overlay/input_bindings_ui.cpp') +setup_path = Path('src/overlay/wheel_setup_ui.cpp') +ini_path = Path('OutRun2006Tweaks.ini') + +ffb = ffb_path.read_text(encoding='utf-8') +ims = input_path.read_text(encoding='utf-8') +uis = ui_path.read_text(encoding='utf-8') +setup = setup_path.read_text(encoding='utf-8') +inis = ini_path.read_text(encoding='utf-8') + + +def rep(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f'{label}: expected exactly one match, found {count}') + print(f'community patch: {label}') + return text.replace(old, new, 1) + + +def first_insert(text: str, needle: str, insertion: str, label: str) -> str: + pos = text.find(needle) + if pos < 0: + raise SystemExit(f'{label}: insertion marker not found') + print(f'community patch: {label}') + return text[:pos] + insertion + text[pos:] + + +def replace_between(text: str, start: str, end: str, replacement: str, label: str) -> str: + a = text.find(start) + if a < 0: + raise SystemExit(f'{label}: start marker not found') + b = text.find(end, a) + if b < 0: + raise SystemExit(f'{label}: end marker not found') + print(f'community patch: {label}') + return text[:a] + replacement + text[b:] + + +# --------------------------------------------------------------------------- +# 1. Finish the hybrid input migration. New installs use the multi-device path; +# the proven original-game DirectInput path remains an explicit fallback. +# --------------------------------------------------------------------------- +if 'Setting WheelInputCompatibility{' not in ims: + ims = rep( + ims, + '\tSetting UseNewInput{ "Controls", "UseNewInput", true,\n', + '''\tSetting WheelInputCompatibility{ "Controls", "WheelInputCompatibility", false,\n\t\t"Use the original-game DirectInput wheel path as a compatibility fallback. Disable UseNewInput when enabling this." };\n\n\tSetting UseNewInput{ "Controls", "UseNewInput", true,\n''', + 'restore WheelInputCompatibility definition') + +if 'WheelInputCompatibility = true' in inis: + inis = inis.replace('WheelInputCompatibility = true', 'WheelInputCompatibility = false', 1) +if 'UseNewInput = false' in inis: + inis = inis.replace('UseNewInput = false', 'UseNewInput = true', 1) + + +# --------------------------------------------------------------------------- +# 2. d-b-c-e toolkit v0.8.0 signal-order lesson. +# Master strength and inversion belong before tanh/slew/device limits. +# DirectInput dwGain stays at 100%; 100-150% is model headroom, not a driver +# gain outside the DirectInput 0..10000 range. +# --------------------------------------------------------------------------- +ffb = rep( + ffb, +''' Setting WheelFFBGlobalStrength{\n "WheelFFB", "GlobalStrength", 0.25f,\n "Master DirectInput effect gain. Start low on direct-drive wheels.", Range{ 0.0f, 1.0f }\n };\n''', +''' Setting WheelFFBGlobalStrength{\n "WheelFFB", "GlobalStrength", 0.70f,\n "Master force-model gain. Applied before soft saturation/slew; 1.0=100%, 1.5=150% headroom.", Range{ 0.0f, 1.5f }\n };\n''', + 'v0.8-style pre-shaper master gain') + +ffb = rep( + ffb, +''' Setting WheelFFBRoadTexture{\n "WheelFFB", "RoadTexture", 0.20f,\n''', +''' Setting WheelFFBRoadTexture{\n "WheelFFB", "RoadTexture", 0.30f,\n''', + 'slightly stronger road-detail baseline') + +ffb = rep( + ffb, +''' const float speed = car->field_1C4;\n const float speedNorm = std::clamp(speed / 2.0f, 0.0f, 1.0f);\n''', +''' const float speed = car->field_1C4;\n const float speedNorm = std::clamp(speed / 2.0f, 0.0f, 1.0f);\n const float outputStrength = std::clamp(\n static_cast(Settings::WheelFFBGlobalStrength), 0.0f, 1.5f);\n''', + 'cache 0-150 percent model gain') + +ffb = rep( + ffb, +''' float roadAmp =\n textureRoughness * roadSpeedGate *\n static_cast(Settings::WheelFFBRoadTexture);\n''', +''' float roadAmp =\n textureRoughness * roadSpeedGate *\n static_cast(Settings::WheelFFBRoadTexture) * outputStrength;\n''', + 'master gain on road periodic/fallback') + +ffb = rep( + ffb, +' slipAmp = driftAmt * static_cast(Settings::WheelFFBTireSlip);\n', +' slipAmp = driftAmt * static_cast(Settings::WheelFFBTireSlip) * outputStrength;\n', + 'master gain on tire-slip periodic') + +ffb = rep( + ffb, +''' slipAmp =\n static_cast(Settings::WheelFFBEngineIdle) * throttleNorm;\n''', +''' slipAmp =\n static_cast(Settings::WheelFFBEngineIdle) * throttleNorm * outputStrength;\n''', + 'master gain on idle periodic') + +ffb = rep( + ffb, +''' splashAmp_ =\n (roughness - 0.7f) * speedNorm * 0.75f * roadTextureScale;\n''', +''' splashAmp_ =\n (roughness - 0.7f) * speedNorm * 0.75f * roadTextureScale * outputStrength;\n''', + 'master gain on splash detail') + +ffb = rep( + ffb, +' : springStrength * warmupScale * recreateScale);\n', +' : springStrength * warmupScale * recreateScale * outputStrength);\n', + 'master gain on hardware spring') + +ffb = rep( + ffb, +' update_damper(dynamicDamperStrength * warmupScale * recreateScale);\n', +' update_damper(dynamicDamperStrength * warmupScale * recreateScale * outputStrength);\n', + 'master gain on hardware damper') + +ffb = rep( + ffb, +''' float total = structural + events;\n if (Settings::WheelFFBInvertForce)\n''', +''' // d-b-c-e toolkit v0.8.0 ordering: gain/invert are part of the\n // signal before soft saturation and slew. This prevents high gain\n // or inversion from disagreeing with the slew limiter's last value.\n float total = (structural + events) * outputStrength;\n if (Settings::WheelFFBInvertForce)\n''', + 'gain/invert before tanh and slew') + +old_gain = '''effect.dwGain = static_cast(\n std::clamp(static_cast(Settings::WheelFFBGlobalStrength), 0.0f, 1.0f) *\n static_cast(DI_FFNOMINALMAX));''' +gain_count = ffb.count(old_gain) +if gain_count < 3: + raise SystemExit(f'full DirectInput gain: expected at least 3 effect create paths, found {gain_count}') +ffb = ffb.replace(old_gain, 'effect.dwGain = DI_FFNOMINALMAX;') +print(f'community patch: DirectInput device gain fixed at nominal on {gain_count} create paths') + +ffb = replace_between( + ffb, + ' DWORD configured_effect_gain() const\n', + ' void apply_live_effect_gain()\n', +''' DWORD configured_effect_gain() const\n {\n // Overall strength moved into the force model. The DirectInput\n // device-side gain remains nominal, avoiding double scaling.\n return DI_FFNOMINALMAX;\n }\n\n''', + 'keep DirectInput dwGain nominal') + + +# --------------------------------------------------------------------------- +# 3. Broad wheel-base compatibility from the controlled community testing: +# actual FFB actuator axes, two-axis POLAR -> one-axis CARTESIAN fallback, +# and temporary rejection of a dead interface so a sibling endpoint wins. +# --------------------------------------------------------------------------- +if '#include ' not in ffb: + ffb = rep(ffb, '#include \n', '#include \n#include \n', 'vector for actuator axes') + +ffb = rep( + ffb, +''' const std::string wantedGuid =\n lower_copy(Settings::WheelFFBDeviceGuid.get().c_str());\n''', +''' const std::string wantedGuid =\n lower_copy(Settings::WheelFFBDeviceGuid.get().c_str());\n\n if (ctx->self && GetTickCount() < ctx->self->failedInterfaceUntil_ &&\n directinput_guid_key(instance->guidInstance) == ctx->self->failedInterfaceGuid_)\n {\n spdlog::warn(\n "WheelFFB: temporarily skipping rejected FFB interface '{}' [{}] to try a sibling",\n instance->tszProductName, directinput_guid_key(instance->guidInstance));\n return DIENUM_CONTINUE;\n }\n''', + 'skip recently rejected sibling interface') + +if "saved DirectInput GUID '{}' not attached; falling back to DeviceName" in ffb: + ffb = ffb.replace( + "saved DirectInput GUID '{}' not attached; falling back to DeviceName", + "saved DirectInput GUID '{}' unavailable/rejected; falling back to DeviceName", + 1) + +helpers = r''' static BOOL CALLBACK enum_actuator_axis_callback( + LPCDIDEVICEOBJECTINSTANCEA object, LPVOID context) + { + auto* self = static_cast(context); + if (!self || !object) + return DIENUM_CONTINUE; + if ((object->dwType & DIDFT_FFACTUATOR) != 0 && self->actuatorAxes_.size() < 2) + self->actuatorAxes_.push_back(object->dwOfs); + return DIENUM_CONTINUE; + } + + DWORD primary_actuator_axis() const + { + return actuatorAxes_.empty() ? DIJOFS_X : actuatorAxes_.front(); + } + + void mark_selected_interface_failed(const char* reason, HRESULT hr) + { + failedInterfaceGuid_ = directinput_guid_key(selectedGuid_); + failedInterfaceUntil_ = GetTickCount() + 5000; + spdlog::warn( + "WheelFFB: interface '{}' [{}] rejected {}; trying a sibling interface on retry (0x{:08X})", + selectedName_, failedInterfaceGuid_, reason, (unsigned)hr); + } + +''' +ffb = rep( + ffb, + ' bool initialize()\n {\n', + helpers + ' bool initialize()\n {\n', + 'actuator/sibling helper functions') + +# Debug pass 1 found this generic marker occurs in both initialisation and the +# runtime recreate path. Insert before the FIRST one only; that one is the +# initial creation immediately after SETACTUATORSON. +initial_create = ''' if (!create_constant_effect())\n {\n''' +axis_setup = ''' actuatorAxes_.clear();\n const HRESULT axisEnumHr = device_->EnumObjects(\n enum_actuator_axis_callback, this, DIDFT_AXIS);\n if (FAILED(axisEnumHr) || actuatorAxes_.empty())\n {\n actuatorAxes_.clear();\n actuatorAxes_.push_back(DIJOFS_X);\n spdlog::warn(\n "WheelFFB: no explicit DIDFT_FFACTUATOR axis reported; using DIJOFS_X fallback");\n }\n else\n {\n spdlog::info(\n "WheelFFB: detected {} force actuator axis/axes; primary offset={}",\n actuatorAxes_.size(), (unsigned)actuatorAxes_.front());\n }\n\n''' +ffb = first_insert(ffb, initial_create, axis_setup, 'enumerate actuator axes before initial ConstantForce') + +constant_create = r''' bool create_constant_effect() + { + if (!device_) + return false; + + safe_release_effect(constantEffect_, "constant before create"); + constantEffectPolar_ = false; + + DWORD axes[2] = { + primary_actuator_axis(), + actuatorAxes_.size() > 1 ? actuatorAxes_[1] : primary_actuator_axis() + }; + LONG directions[2] = { 9000L, 0L }; + DICONSTANTFORCE cf{}; + cf.lMagnitude = 0; + + DIEFFECT effect{}; + effect.dwSize = sizeof(effect); + effect.dwDuration = INFINITE; + effect.dwSamplePeriod = 0; + effect.dwGain = DI_FFNOMINALMAX; + effect.dwTriggerButton = DIEB_NOTRIGGER; + effect.dwTriggerRepeatInterval = 0; + effect.rgdwAxes = axes; + effect.rglDirection = directions; + effect.cbTypeSpecificParams = sizeof(cf); + effect.lpvTypeSpecificParams = &cf; + + HRESULT hr = E_FAIL; + if (actuatorAxes_.size() > 1) + { + effect.dwFlags = DIEFF_POLAR | DIEFF_OBJECTOFFSETS; + effect.cAxes = 2; + hr = device_->CreateEffect(GUID_ConstantForce, &effect, &constantEffect_, nullptr); + if (SUCCEEDED(hr) && constantEffect_) + { + constantEffectPolar_ = true; + spdlog::info("WheelFFB: ConstantForce created with 2-axis POLAR actuator encoding"); + return true; + } + + safe_release_effect(constantEffect_, "failed POLAR constant"); + spdlog::warn( + "WheelFFB: 2-axis POLAR ConstantForce rejected (0x{:08X}); trying one-axis CARTESIAN", + (unsigned)hr); + } + + effect.dwFlags = DIEFF_CARTESIAN | DIEFF_OBJECTOFFSETS; + effect.cAxes = 1; + directions[0] = 1; + hr = device_->CreateEffect(GUID_ConstantForce, &effect, &constantEffect_, nullptr); + if (FAILED(hr) || !constantEffect_) + { + mark_selected_interface_failed("ConstantForce creation", hr); + spdlog::error("WheelFFB: CreateEffect(ConstantForce) failed (0x{:08X})", (unsigned)hr); + return false; + } + + constantEffectPolar_ = false; + spdlog::info("WheelFFB: ConstantForce created with 1-axis CARTESIAN fallback"); + return true; + } + +''' +ffb = replace_between( + ffb, + ' bool create_constant_effect()\n', + ' bool create_spring_effect()\n', + constant_create, + 'POLAR/CARTESIAN ConstantForce create fallback') + +fixed_axis = 'DWORD axes[1] = { DIJOFS_X };' +fixed_count = ffb.count(fixed_axis) +if fixed_count < 3: + raise SystemExit(f'actuator-axis replacement expected >=3 spring/damper/periodic paths, found {fixed_count}') +ffb = ffb.replace(fixed_axis, 'DWORD axes[1] = { primary_actuator_axis() };') +print(f'community patch: selected actuator axis used by {fixed_count} condition/periodic create paths') + +constant_set = r''' void set_constant_force(LONG requestedLevel) + { + if (!device_ || panicStopped_) + return; + + requestedLevel = std::clamp( + requestedLevel, + -static_cast(DI_FFNOMINALMAX), + static_cast(DI_FFNOMINALMAX)); + + DICONSTANTFORCE cf{}; + LONG directions[2] = { 1L, 0L }; + DWORD flags = DIEP_TYPESPECIFICPARAMS | DIEP_START; + + DIEFFECT params{}; + params.dwSize = sizeof(params); + params.cbTypeSpecificParams = sizeof(cf); + params.lpvTypeSpecificParams = &cf; + + if (constantEffectPolar_) + { + cf.lMagnitude = std::abs(requestedLevel); + directions[0] = requestedLevel < 0 ? 27000L : 9000L; + params.cAxes = 2; + params.rglDirection = directions; + flags |= DIEP_DIRECTION; + } + else + { + cf.lMagnitude = requestedLevel; + } + + HRESULT hr = E_FAIL; + if (constantEffect_) + hr = constantEffect_->SetParameters(¶ms, flags); + + if (hr == DIERR_INPUTLOST || hr == DIERR_NOTACQUIRED) + { + if (reacquire_after_input_loss("ConstantForce", hr) && constantEffect_) + hr = constantEffect_->SetParameters(¶ms, flags); + } + + if (hr == E_HANDLE || hr == DIERR_NOTDOWNLOADED || !constantEffect_) + { + safe_release_effect(constantEffect_, "stale constant"); + const DWORD now = GetTickCount(); + if (now < recreateHoldoffUntil_) + return; + + if (!create_constant_effect()) + { + recreateHoldoffUntil_ = now + 500; + request_device_reinitialize("ConstantForce recreation rejected interface", hr); + return; + } + + recreateRampFrames_ = RecreateRampFrames; + prevConstantLevel_ = 0; + prevStructuralLevel_ = 0; + spdlog::info("WheelFFB: recreated ConstantForce after handle loss; ramping in"); + return; + } + + if (FAILED(hr)) + { + note_device_failure("ConstantForce", hr); + spdlog::warn("WheelFFB: constant force update failed (0x{:08X})", (unsigned)hr); + return; + } + + clear_device_failure(); + prevConstantLevel_ = requestedLevel; + } + +''' +ffb = replace_between( + ffb, + ' void set_constant_force(LONG requestedLevel)\n', + ' void update_crash_detection(float speed, uint32_t stateFlags)\n', + constant_set, + 'POLAR-aware live ConstantForce updates') + +ffb = rep( + ffb, +''' IDirectInputEffect* tireSlipEffect_ = nullptr;\n\n GUID selectedGuid_{};\n''', +''' IDirectInputEffect* tireSlipEffect_ = nullptr;\n std::vector actuatorAxes_;\n bool constantEffectPolar_ = false;\n\n GUID selectedGuid_{};\n''', + 'actuator and ConstantForce encoding state') + +ffb = rep( + ffb, +' DWORD retryAfter_ = 0;\n', +''' DWORD retryAfter_ = 0;\n std::string failedInterfaceGuid_;\n DWORD failedInterfaceUntil_ = 0;\n''', + 'sibling-interface recovery state') + +# Pin whichever interface actually succeeded, avoiding repeated attempts to jump +# back to a duplicate endpoint that cannot create force effects. +create_fail_block = ''' if (!create_constant_effect())\n {\n release_device();\n release_directinput();\n return false;\n }\n''' +pin_working = create_fail_block + '''\n {\n const std::string actualGuid = directinput_guid_key(selectedGuid_);\n if (lower_copy(Settings::WheelFFBDeviceGuid.get().c_str()) != actualGuid)\n {\n Settings::WheelFFBDeviceGuid = actualGuid;\n selectedConfiguredGuid_ = actualGuid;\n spdlog::info(\n "WheelFFB: pinned working force interface GUID '{}' after capability validation",\n actualGuid);\n }\n }\n''' +# At this stage only initialisation still contains the full release-on-failure block. +ffb = rep(ffb, create_fail_block, pin_working, 'pin validated working sibling GUID') + + +# --------------------------------------------------------------------------- +# 4. Safe fixed-output direction test. It is hard capped at 20%, independent of +# 100-150% model gain, and temporarily silences Spring/Damper. +# --------------------------------------------------------------------------- +ffb = rep( + ffb, +' void check_watchdog()\n {\n', +''' void request_direction_test(int direction)\n {\n if (direction == 0)\n {\n manualTestFrames_ = 0;\n if (initialized_ && !panicStopped_)\n set_constant_force(0);\n return;\n }\n manualTestDirection_ = direction < 0 ? -1 : 1;\n manualTestFrames_ = 18;\n spdlog::info(\n "WheelFFB: queued safe {} direction test at fixed 20% output",\n manualTestDirection_ < 0 ? "left" : "right");\n }\n\n void check_watchdog()\n {\n''', + 'safe direction-test request') + +ffb = rep( + ffb, +''' apply_live_effect_gain();\n\n float steer = read_game_steering();\n''', +''' apply_live_effect_gain();\n\n if (manualTestFrames_ > 0)\n {\n if (springEffect_)\n update_spring(0.0f);\n if (damperEffect_)\n update_damper(0.0f);\n\n LONG testLevel = manualTestDirection_ * 2000L;\n if (Settings::WheelFFBInvertForce)\n testLevel = -testLevel;\n set_constant_force(testLevel);\n --manualTestFrames_;\n if (manualTestFrames_ == 0)\n {\n set_constant_force(0);\n warmupFrames_ = 0;\n }\n return;\n }\n\n float steer = read_game_steering();\n''', + 'execute fixed 20-percent direction test') + +ffb = rep( + ffb, +' int recreateRampFrames_ = 0;\n', +''' int recreateRampFrames_ = 0;\n int manualTestFrames_ = 0;\n int manualTestDirection_ = 1;\n''', + 'direction-test state') + +if 'void WheelFFB_RequestDirectionTest(int direction)' not in ffb: + ffb += r''' + +void WheelFFB_RequestDirectionTest(int direction) +{ + gWheelFFB.request_direction_test(direction); +} +''' + print('community patch: exported direction-test API') + + +# --------------------------------------------------------------------------- +# 5. F11 UI: versioned reference presets and matching 0-150% range. +# --------------------------------------------------------------------------- +uis = rep( + uis, +''' extern Setting WheelFFBSteeringWeight;\n extern Setting WheelFFBGripLoss;\n''', +''' extern Setting WheelFFBSteeringWeight;\n extern Setting WheelFFBGripLoss;\n extern Setting WheelFFBLowSpeedSpring;\n extern Setting WheelFFBSpringLoadBoost;\n''', + 'declare preset-only simulation settings') + +if 'void WheelFFB_RequestDirectionTest(int direction);' not in uis: + uis = rep( + uis, + ' extern Setting WheelFFBDebugLog;\n}\n', + ' extern Setting WheelFFBDebugLog;\n}\n\nvoid WheelFFB_RequestDirectionTest(int direction);\n', + 'declare safe direction-test API') + +ui_anchor = '''\t\tImGui::TextWrapped("Advanced DirectInput COM FFB. Input bindings can come from any connected device; FFB remains attached to the selected force-feedback wheel.");\n\t\tImGui::Spacing();\n\n\t\tif (ImGui::Checkbox("Enable force feedback", Settings::WheelFFBEnable.ptr()))\n''' +ui_new = '''\t\tImGui::TextWrapped("Advanced DirectInput COM FFB. Input bindings can come from any connected device; FFB remains attached to the selected force-feedback wheel.");\n\t\tImGui::Spacing();\n\n\t\tauto applyPreset = [&](float overall, float spring, float damper, float lateral,\n\t\t\tfloat grip, float impact, float road, float tire, float lowSpeed, float loadBoost)\n\t\t{\n\t\t\tSettings::WheelFFBGlobalStrength = overall;\n\t\t\tSettings::WheelFFBSpringStrength = spring;\n\t\t\tSettings::WheelFFBDamperStrength = damper;\n\t\t\tSettings::WheelFFBSteeringWeight = lateral;\n\t\t\tSettings::WheelFFBGripLoss = grip;\n\t\t\tSettings::WheelFFBWallImpact = impact;\n\t\t\tSettings::WheelFFBRoadTexture = road;\n\t\t\tSettings::WheelFFBTireSlip = tire;\n\t\t\tSettings::WheelFFBLowSpeedSpring = lowSpeed;\n\t\t\tSettings::WheelFFBSpringLoadBoost = loadBoost;\n\t\t\tsetting_changed(Settings::WheelFFBGlobalStrength);\n\t\t\tsetting_changed(Settings::WheelFFBSpringStrength);\n\t\t\tsetting_changed(Settings::WheelFFBDamperStrength);\n\t\t\tsetting_changed(Settings::WheelFFBSteeringWeight);\n\t\t\tsetting_changed(Settings::WheelFFBGripLoss);\n\t\t\tsetting_changed(Settings::WheelFFBWallImpact);\n\t\t\tsetting_changed(Settings::WheelFFBRoadTexture);\n\t\t\tsetting_changed(Settings::WheelFFBTireSlip);\n\t\t\tsetting_changed(Settings::WheelFFBLowSpeedSpring);\n\t\t\tsetting_changed(Settings::WheelFFBSpringLoadBoost);\n\t\t};\n\n\t\tImGui::SeparatorText("Versioned baseline presets");\n\t\tif (ImGui::Button("Simulation Balanced v1"))\n\t\t\tapplyPreset(0.70f, 0.60f, 0.32f, 0.38f, 0.65f, 0.65f, 0.30f, 0.20f, 0.08f, 0.35f);\n\t\tImGui::SameLine();\n\t\tif (ImGui::Button("Arcade Light v1"))\n\t\t\tapplyPreset(0.55f, 0.45f, 0.15f, 0.30f, 0.55f, 0.55f, 0.30f, 0.18f, 0.12f, 0.20f);\n\t\tImGui::SameLine();\n\t\tif (ImGui::Button("Arcade Strong v1"))\n\t\t\tapplyPreset(0.90f, 0.65f, 0.30f, 0.45f, 0.60f, 0.75f, 0.35f, 0.22f, 0.08f, 0.40f);\n\t\tImGui::TextDisabled("Preset names are immutable test references; adjust sliders afterward for a custom tune.");\n\n\t\tif (ImGui::Checkbox("Enable force feedback", Settings::WheelFFBEnable.ptr()))\n''' +uis = rep(uis, ui_anchor, ui_new, 'versioned reference presets') + +uis = rep( + uis, +'''\t\tif (ImGui::SliderFloat("Overall strength", Settings::WheelFFBGlobalStrength.ptr(), 0.0f, 1.0f, "%.2f"))\n\t\t\tsetting_changed(Settings::WheelFFBGlobalStrength);\n''', +'''\t\tif (ImGui::SliderFloat("Overall strength", Settings::WheelFFBGlobalStrength.ptr(), 0.0f, 1.5f, "%.2f"))\n\t\t\tsetting_changed(Settings::WheelFFBGlobalStrength);\n\t\tif (Settings::WheelFFBGlobalStrength.get() > 1.0f)\n\t\t\tImGui::TextColored(ImVec4(1.0f, 0.65f, 0.25f, 1.0f),\n\t\t\t\t"Above 100% reduces headroom/contrast. Use only when the wheel is still too light.");\n''', + '0-150 percent strength slider and warning') + +uis = rep( + uis, +'''\t\tif (ImGui::Checkbox("Diagnostic logging", Settings::WheelFFBDebugLog.ptr()))\n\t\t\tsetting_changed(Settings::WheelFFBDebugLog);\n\n\t\tImGui::Spacing();\n''', +'''\t\tif (ImGui::Checkbox("Diagnostic logging", Settings::WheelFFBDebugLog.ptr()))\n\t\t\tsetting_changed(Settings::WheelFFBDebugLog);\n\n\t\tImGui::SeparatorText("Safe direction test");\n\t\tif (ImGui::Button("Test Left (20%)"))\n\t\t\tWheelFFB_RequestDirectionTest(-1);\n\t\tImGui::SameLine();\n\t\tif (ImGui::Button("Test Right (20%)"))\n\t\t\tWheelFFB_RequestDirectionTest(1);\n\t\tImGui::SameLine();\n\t\tif (ImGui::Button("Stop Test"))\n\t\t\tWheelFFB_RequestDirectionTest(0);\n\t\tImGui::TextDisabled("Direction tests are hard-capped at 20% and ignore Overall Strength headroom.");\n\n\t\tImGui::Spacing();\n''', + 'safe direction-test UI') + +# Keep the duplicate Wheel Setup tuning panel consistent with the advanced page. +setup = rep( + setup, +' ImGui::SliderFloat("Overall Strength", Settings::WheelFFBGlobalStrength.ptr(), 0.0f, 1.0f, "%.2f");\n', +''' ImGui::SliderFloat("Overall Strength", Settings::WheelFFBGlobalStrength.ptr(), 0.0f, 1.5f, "%.2f");\n if (Settings::WheelFFBGlobalStrength.get() > 1.0f)\n ImGui::TextColored(ImVec4(1.0f, 0.65f, 0.25f, 1.0f),\n "Above 100% trades force-detail contrast for extra weight.");\n''', + 'Wheel Setup 0-150 percent range') + +old_baseline = ''' if (ImGui::Button("Load simulation baseline"))\n {\n Settings::WheelFFBGlobalStrength = 0.50f;\n Settings::WheelFFBSpringStrength = 0.45f;\n Settings::WheelFFBLowSpeedSpring = 0.08f;\n Settings::WheelFFBSpringLoadBoost = 0.35f;\n Settings::WheelFFBDamperStrength = 0.35f;\n Settings::WheelFFBSteeringWeight = 0.40f;\n Settings::WheelFFBGripLoss = 0.65f;\n Settings::WheelFFBRoadTexture = 0.12f;\n Settings::WheelFFBTireSlip = 0.15f;\n Settings::WheelFFBWallImpact = 0.50f;\n Settings::WheelFFBUseHardwareSpring = true;\n Settings::WheelFFBUseHardwareDamper = true;\n status_ = "Loaded simulation baseline. Save the wheel profile after testing.";\n }\n''' +new_baseline = ''' if (ImGui::Button("Load Simulation Balanced v1"))\n {\n Settings::WheelFFBGlobalStrength = 0.70f;\n Settings::WheelFFBSpringStrength = 0.60f;\n Settings::WheelFFBLowSpeedSpring = 0.08f;\n Settings::WheelFFBSpringLoadBoost = 0.35f;\n Settings::WheelFFBDamperStrength = 0.32f;\n Settings::WheelFFBSteeringWeight = 0.38f;\n Settings::WheelFFBGripLoss = 0.65f;\n Settings::WheelFFBRoadTexture = 0.30f;\n Settings::WheelFFBTireSlip = 0.20f;\n Settings::WheelFFBWallImpact = 0.65f;\n Settings::WheelFFBUseHardwareSpring = true;\n Settings::WheelFFBUseHardwareDamper = true;\n status_ = "Loaded Simulation Balanced v1. Test first, then save your preferred profile.";\n }\n''' +setup = rep(setup, old_baseline, new_baseline, 'Wheel Setup baseline matches Simulation Balanced v1') + + +# Write and self-audit the effective source generated by all prior build patches. +ffb_path.write_text(ffb, encoding='utf-8') +input_path.write_text(ims, encoding='utf-8') +ui_path.write_text(uis, encoding='utf-8') +setup_path.write_text(setup, encoding='utf-8') +ini_path.write_text(inis, encoding='utf-8') + +checks = { + 'src/input_manager.cpp': [ + 'Setting WheelInputCompatibility', + 'has_attached_ffb_wheel()', + 'SDL_GetJoysticks', + ], + 'src/hooks_wheel_ffb.cpp': [ + 'Range{ 0.0f, 1.5f }', + 'd-b-c-e toolkit v0.8.0 ordering', + 'DIDFT_FFACTUATOR', + '2-axis POLAR actuator encoding', + 'one-axis CARTESIAN', + 'temporarily skipping rejected FFB interface', + 'pinned working force interface GUID', + 'queued safe {} direction test at fixed 20% output', + 'effect.dwGain = DI_FFNOMINALMAX;', + ], + 'src/overlay/input_bindings_ui.cpp': [ + 'Simulation Balanced v1', 'Arcade Light v1', 'Arcade Strong v1', + 'Test Left (20%)', 'Above 100% reduces headroom/contrast', + ], + 'src/overlay/wheel_setup_ui.cpp': [ + 'Load Simulation Balanced v1', + '0.0f, 1.5f, "%.2f"', + ], + 'OutRun2006Tweaks.ini': [ + 'WheelInputCompatibility = false', + 'UseNewInput = true', + ], +} +for path, needles in checks.items(): + data = Path(path).read_text(encoding='utf-8') + for needle in needles: + if needle not in data: + raise SystemExit(f'community verify failed: {needle!r} missing from {path}') + print(f'community verify OK: {path}: {needle}') + +# Signal-order audit: the new master multiplication must be before tanh and slew. +signal = Path('src/hooks_wheel_ffb.cpp').read_text(encoding='utf-8') +pos_gain = signal.find('float total = (structural + events) * outputStrength;') +pos_invert = signal.find('if (Settings::WheelFFBInvertForce)', pos_gain) +pos_tanh = signal.find('std::tanh(total)', pos_gain) +pos_slew = signal.find('const LONG structuralDelta', pos_tanh) +if not (0 <= pos_gain < pos_invert < pos_tanh < pos_slew): + raise SystemExit('community verify failed: gain/invert/tanh/slew ordering is wrong') +print('community verify OK: gain -> invert -> tanh -> slew ordering') + +print('Applied d-b-c-e v0.8.0 + hyp36rmax/Discord refinements; debug-pass-1 ambiguities removed') diff --git a/tools/patch_wheel_ffb_device_guid.py b/tools/patch_wheel_ffb_device_guid.py new file mode 100644 index 000000000..e8f4fb133 --- /dev/null +++ b/tools/patch_wheel_ffb_device_guid.py @@ -0,0 +1,21 @@ +from pathlib import Path +p=Path('src/hooks_wheel_ffb.cpp'); s=p.read_text() + +def r(a,b,n): + global s + c=s.count(a) + if c!=1: raise SystemExit(f'{n}: {c}') + s=s.replace(a,b,1); print('patched',n) + +r('#include \n#include \n','#include \n#include \n#include \n','cstdio') +r(''' Setting WheelFFBDeviceName{\n "WheelFFB", "DeviceName", "MOZA",\n "Case-insensitive substring used to select the FFB wheel. Empty selects the first non-virtual FFB device."\n };\n''',''' Setting WheelFFBDeviceName{\n "WheelFFB", "DeviceName", "MOZA",\n "Case-insensitive substring used to select the FFB wheel. Empty selects the first non-virtual FFB device."\n };\n\n Setting WheelFFBDeviceGuid{\n "WheelFFB", "DeviceGuid", "",\n "Exact DirectInput instance GUID selected by F11 Wheel Setup; DeviceName remains the fallback."\n };\n''','setting') +r(''' bool is_virtual_device_name(const std::string& lowered)\n''',''' std::string directinput_guid_key(const GUID& guid)\n {\n char b[64]{};\n std::snprintf(b, sizeof(b),\n "%08X-%04X-%04X-%02X%02X-%02X%02X%02X%02X%02X%02X",\n (unsigned)guid.Data1, (unsigned)guid.Data2, (unsigned)guid.Data3,\n (unsigned)guid.Data4[0], (unsigned)guid.Data4[1],\n (unsigned)guid.Data4[2], (unsigned)guid.Data4[3],\n (unsigned)guid.Data4[4], (unsigned)guid.Data4[5],\n (unsigned)guid.Data4[6], (unsigned)guid.Data4[7]);\n return lower_copy(b);\n }\n\n bool is_virtual_device_name(const std::string& lowered)\n''','helper') +r(''' bool found = false;\n bool ignoreName = false;\n''',''' bool found = false;\n bool ignoreName = false;\n bool matchGuidOnly = false;\n''','ctx') +r(''' const std::string wanted =\n lower_copy(Settings::WheelFFBDeviceName.get().c_str());\n''',''' const std::string wanted =\n lower_copy(Settings::WheelFFBDeviceName.get().c_str());\n const std::string wantedGuid =\n lower_copy(Settings::WheelFFBDeviceGuid.get().c_str());\n''','callback config') +r(''' if (!ctx->ignoreName && !wanted.empty() &&\n instanceName.find(wanted) == std::string::npos &&\n productName.find(wanted) == std::string::npos)\n''',''' if (ctx->matchGuidOnly &&\n (wantedGuid.empty() || directinput_guid_key(instance->guidInstance) != wantedGuid))\n return DIENUM_CONTINUE;\n\n if (!ctx->matchGuidOnly && !ctx->ignoreName && !wanted.empty() &&\n instanceName.find(wanted) == std::string::npos &&\n productName.find(wanted) == std::string::npos)\n''','callback match') +r(''' EnumContext ctx{};\n ctx.self = this;\n\n hr = directInput_->EnumDevices(\n DI8DEVCLASS_GAMECTRL,\n enum_devices_callback,\n &ctx,\n DIEDFL_ATTACHEDONLY | DIEDFL_FORCEFEEDBACK);\n''',''' EnumContext ctx{};\n ctx.self = this;\n const std::string configuredGuid =\n lower_copy(Settings::WheelFFBDeviceGuid.get().c_str());\n\n if (!configuredGuid.empty())\n {\n ctx.matchGuidOnly = true;\n hr = directInput_->EnumDevices(\n DI8DEVCLASS_GAMECTRL, enum_devices_callback, &ctx,\n DIEDFL_ATTACHEDONLY | DIEDFL_FORCEFEEDBACK);\n if (FAILED(hr))\n {\n release_directinput();\n return false;\n }\n if (!ctx.found)\n {\n spdlog::warn(\n "WheelFFB: saved DirectInput GUID '{}' not attached; falling back to DeviceName",\n Settings::WheelFFBDeviceGuid.get());\n ctx = {};\n ctx.self = this;\n }\n }\n\n if (!ctx.found)\n {\n hr = directInput_->EnumDevices(\n DI8DEVCLASS_GAMECTRL, enum_devices_callback, &ctx,\n DIEDFL_ATTACHEDONLY | DIEDFL_FORCEFEEDBACK);\n }\n''','guid-first enumerate') +r(''' selectedGuid_ = ctx.selectedGuid;\n selectedName_ = ctx.selectedName;\n''',''' selectedGuid_ = ctx.selectedGuid;\n selectedName_ = ctx.selectedName;\n selectedConfiguredGuid_ = lower_copy(Settings::WheelFFBDeviceGuid.get().c_str());\n selectedConfiguredName_ = lower_copy(Settings::WheelFFBDeviceName.get().c_str());\n spdlog::info("WheelFFB: selected DirectInput identity '{}' / '{}'",\n selectedName_, directinput_guid_key(selectedGuid_));\n''','selected identity cache') +r(''' if (deviceReinitPending_)\n {\n''',''' if (initialized_)\n {\n const std::string guidNow = lower_copy(Settings::WheelFFBDeviceGuid.get().c_str());\n const std::string nameNow = lower_copy(Settings::WheelFFBDeviceName.get().c_str());\n if (guidNow != selectedConfiguredGuid_ || nameNow != selectedConfiguredName_)\n {\n selectedConfiguredGuid_ = guidNow;\n selectedConfiguredName_ = nameNow;\n request_device_reinitialize("configured wheel identity changed", S_OK);\n return;\n }\n }\n\n if (deviceReinitPending_)\n {\n''','live identity reinit') +r(''' std::string selectedName_;\n HWND gameHwnd_ = nullptr;\n''',''' std::string selectedName_;\n std::string selectedConfiguredGuid_;\n std::string selectedConfiguredName_;\n HWND gameHwnd_ = nullptr;\n''','identity state') +p.write_text(s) +print('Applied exact GUID-first FFB device selection and live identity reinit') diff --git a/tools/patch_wheel_ffb_live_controls.py b/tools/patch_wheel_ffb_live_controls.py new file mode 100644 index 000000000..652607ce2 --- /dev/null +++ b/tools/patch_wheel_ffb_live_controls.py @@ -0,0 +1,158 @@ +from pathlib import Path + +path = Path("src/hooks_wheel_ffb.cpp") +text = path.read_text(encoding="utf-8") + + +def replace_once(old: str, new: str, label: str) -> None: + global text + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected exactly one match, found {count}") + text = text.replace(old, new, 1) + print(f"patched: {label}") + + +# Runtime disable must release torque immediately. Previously update() returned +# before touching the effects and depended on the 250 ms watchdog. A DD wheel +# should never keep stale torque merely because a live setting was toggled off. +replace_once( +''' void update(EVWORK_CAR* car)\n {\n if (!Settings::WheelFFBEnable || !car || panicStopped_)\n return;\n\n lastUpdateTick_ = GetTickCount();\n''', +''' void update(EVWORK_CAR* car)\n {\n if (!car || panicStopped_)\n return;\n\n if (!Settings::WheelFFBEnable)\n {\n if (initialized_ && enabledLastTick_)\n {\n zero_all_forces();\n reset_signal_state();\n enabledLastTick_ = false;\n spdlog::info("WheelFFB: disabled live; all effects zeroed immediately");\n }\n return;\n }\n enabledLastTick_ = true;\n\n lastUpdateTick_ = GetTickCount();\n''', + "immediate live disable", +) + +# Once gameplay owns the exclusive DirectInput handle again, apply changes to +# GlobalStrength to every already-created hardware effect. All create_* paths +# already use the current setting, so this closes only the live-update gap. +replace_once( +''' if (device_ && !deviceAcquired_)\n {\n const HRESULT acquireHr = device_->Acquire();\n if (FAILED(acquireHr))\n return;\n deviceAcquired_ = true;\n device_->SendForceFeedbackCommand(DISFFC_SETACTUATORSON);\n }\n\n float steer = read_game_steering();\n''', +''' if (device_ && !deviceAcquired_)\n {\n const HRESULT acquireHr = device_->Acquire();\n if (FAILED(acquireHr))\n return;\n deviceAcquired_ = true;\n device_->SendForceFeedbackCommand(DISFFC_SETACTUATORSON);\n }\n\n apply_live_effect_gain();\n\n float steer = read_game_steering();\n''', + "live DirectInput gain update", +) + +# UsePeriodicEffects is a backend-selection switch, not merely a recreate gate. +# Stop/release live GUID_Sine effects as soon as it is turned off. The existing +# ConstantForce sine fallback then takes over in the same gameplay path. +replace_once( +''' // Warm-up ramp prevents the first few garbage/settling frames from\n // producing a DD-wheel spike.\n''', +''' if (!Settings::WheelFFBUsePeriodicEffects &&\n (roadTextureEffect_ || tireSlipEffect_))\n {\n disable_periodics();\n spdlog::info(\n "WheelFFB: hardware periodic effects disabled live; using ConstantForce fallback");\n }\n\n // Warm-up ramp prevents the first few garbage/settling frames from\n // producing a DD-wheel spike.\n''', + "live periodic disable", +) + +# The watchdog originally only looked at ConstantForce/Spring/Damper state. If +# both condition effects are disabled while a hardware sine is active, a stalled +# game tick could otherwise leave road/tire vibration running indefinitely. +replace_once( +''' if (elapsed > 250 &&\n (prevConstantLevel_ != 0 || prevSpringCoefficient_ != 0 ||\n prevDamperCoefficient_ != 0))\n''', +''' if (elapsed > 250 &&\n (prevConstantLevel_ != 0 || prevSpringCoefficient_ != 0 ||\n prevDamperCoefficient_ != 0 || roadState_.lastMagnitude != 0 ||\n slipState_.lastMagnitude != 0))\n''', + "watchdog covers hardware periodic effects", +) + +# Insert a single gain helper before create_constant_effect(). Do not change +# effect-specific type parameters here; DIEP_GAIN is orthogonal to spring, +# damper, periodic and constant-force magnitudes. +needle = ''' bool create_constant_effect()\n {\n''' +if text.count(needle) != 1: + raise SystemExit("live gain helper insertion point not found exactly once") + +gain_helper = r''' DWORD configured_effect_gain() const + { + return static_cast( + std::clamp(static_cast(Settings::WheelFFBGlobalStrength), 0.0f, 1.0f) * + static_cast(DI_FFNOMINALMAX)); + } + + void apply_live_effect_gain() + { + if (!device_ || !deviceAcquired_ || panicStopped_) + return; + + const DWORD gain = configured_effect_gain(); + if (gain == lastEffectGain_) + return; + + DIEFFECT params{}; + params.dwSize = sizeof(params); + params.dwGain = gain; + + bool failed = false; + auto updateGain = [&](IDirectInputEffect* effect, const char* label) + { + if (!effect) + return; + + HRESULT hr = effect->SetParameters(¶ms, DIEP_GAIN); + if (hr == DIERR_INPUTLOST || hr == DIERR_NOTACQUIRED) + { + if (SUCCEEDED(device_->Acquire())) + deviceAcquired_ = true; + hr = effect->SetParameters(¶ms, DIEP_GAIN); + } + + // Some DirectInput drivers do not advertise DIEP_GAIN as a + // dynamic parameter. DirectInput often restarts implicitly, + // but drivers are also allowed to return DIERR_EFFECTPLAYING. + // Stop only that effect and explicitly restart it with the new + // gain rather than making the F11 Overall Strength slider fail. + if (hr == DIERR_EFFECTPLAYING) + { + effect->Stop(); + hr = effect->SetParameters(¶ms, DIEP_GAIN | DIEP_START); + } + + if (FAILED(hr)) + { + failed = true; + const DWORD now = GetTickCount(); + if (now - lastGainErrorLog_ >= 2000) + { + lastGainErrorLog_ = now; + spdlog::warn( + "WheelFFB: live gain update failed for {} (0x{:08X})", + label, (unsigned)hr); + } + } + }; + + updateGain(constantEffect_, "ConstantForce"); + updateGain(springEffect_, "GUID_Spring"); + updateGain(damperEffect_, "GUID_Damper"); + updateGain(roadTextureEffect_, "RoadTexture"); + updateGain(tireSlipEffect_, "TireSlip"); + + if (!failed) + { + lastEffectGain_ = gain; + spdlog::info( + "WheelFFB: live GlobalStrength applied to active effects ({}%)", + static_cast((gain * 100u) / DI_FFNOMINALMAX)); + } + } + +''' +text = text.replace(needle, gain_helper + needle, 1) + +# The effects created during initialize() already carry the current gain. Seed +# the cache after creation to avoid issuing a redundant gain command on frame 1. +replace_once( +''' spdlog::info(\n "WheelFFB: ready on '{}' (DirectInput COM, axes={}, buttons={}, global={}%, spring={}, damper={})",\n''', +''' lastEffectGain_ = configured_effect_gain();\n\n spdlog::info(\n "WheelFFB: ready on '{}' (DirectInput COM, axes={}, buttons={}, global={}%, spring={}, damper={})",\n''', + "seed effect gain cache", +) + +# State for live controls. 0xFFFFFFFF is outside DirectInput's valid 0..10000 +# gain range, guaranteeing the first post-recovery update can synchronize it. +replace_once( +''' bool initialized_ = false;\n bool panicStopped_ = false;\n bool deviceAcquired_ = false;\n bool periodicsActive_ = false;\n''', +''' bool initialized_ = false;\n bool panicStopped_ = false;\n bool deviceAcquired_ = false;\n bool enabledLastTick_ = true;\n bool periodicsActive_ = false;\n''', + "live enable state", +) +replace_once( +''' DWORD lastUpdateTick_ = 0;\n DWORD lastLogTick_ = 0;\n''', +''' DWORD lastUpdateTick_ = 0;\n DWORD lastLogTick_ = 0;\n DWORD lastGainErrorLog_ = 0;\n DWORD lastEffectGain_ = 0xFFFFFFFFu;\n''', + "live gain state", +) + +path.write_text(text, encoding="utf-8") +print("Applied live FFB controls: immediate disable, live gain, periodic switching/watchdog") diff --git a/tools/patch_wheel_ffb_menu_handoff.py b/tools/patch_wheel_ffb_menu_handoff.py new file mode 100644 index 000000000..57a36e230 --- /dev/null +++ b/tools/patch_wheel_ffb_menu_handoff.py @@ -0,0 +1,56 @@ +from pathlib import Path + +path = Path("src/hooks_wheel_ffb.cpp") +text = path.read_text(encoding="utf-8") + + +def replace_once(old: str, new: str, label: str) -> None: + global text + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected exactly one match, found {count}") + text = text.replace(old, new, 1) + + +replace_once( +''' if (!inGameplay)\n {\n zero_all_forces();\n reset_signal_state();\n return;\n }\n\n float steer = read_game_steering();\n''', +''' if (!inGameplay)\n {\n zero_all_forces();\n // Let the legacy/menu DirectInput reader reacquire the same\n // physical wheel while menus or F11 setup are active. Effects\n // remain created and are reused when gameplay resumes.\n if (device_ && deviceAcquired_)\n {\n device_->Unacquire();\n deviceAcquired_ = false;\n }\n reset_signal_state();\n return;\n }\n\n if (device_ && !deviceAcquired_)\n {\n const HRESULT acquireHr = device_->Acquire();\n if (FAILED(acquireHr))\n return;\n deviceAcquired_ = true;\n device_->SendForceFeedbackCommand(DISFFC_SETACTUATORSON);\n }\n\n float steer = read_game_steering();\n''', +"menu/gameplay device handoff" +) + +replace_once( +''' hr = device_->Acquire();\n if (FAILED(hr))\n {\n spdlog::error("WheelFFB: Acquire('{}') failed (0x{:08X})", selectedName_, (unsigned)hr);\n release_device();\n release_directinput();\n return false;\n }\n\n device_->SendForceFeedbackCommand(DISFFC_SETACTUATORSON);\n''', +''' hr = device_->Acquire();\n if (FAILED(hr))\n {\n spdlog::error("WheelFFB: Acquire('{}') failed (0x{:08X})", selectedName_, (unsigned)hr);\n release_device();\n release_directinput();\n return false;\n }\n deviceAcquired_ = true;\n\n device_->SendForceFeedbackCommand(DISFFC_SETACTUATORSON);\n''', +"initialize acquired flag" +) + +text = text.replace( +''' device_->Acquire();\n hr = springEffect_->SetParameters(''', +''' if (SUCCEEDED(device_->Acquire()))\n deviceAcquired_ = true;\n hr = springEffect_->SetParameters(''') +text = text.replace( +''' device_->Acquire();\n hr = damperEffect_->SetParameters(''', +''' if (SUCCEEDED(device_->Acquire()))\n deviceAcquired_ = true;\n hr = damperEffect_->SetParameters(''') +text = text.replace( +''' device_->Acquire();\n if (constantEffect_)''', +''' if (SUCCEEDED(device_->Acquire()))\n deviceAcquired_ = true;\n if (constantEffect_)''') + +replace_once( +''' hr = device_->Unacquire();\n spdlog::info("WheelFFB: PanicStop Unacquire => 0x{:08X}", (unsigned)hr);\n''', +''' hr = device_->Unacquire();\n deviceAcquired_ = false;\n spdlog::info("WheelFFB: PanicStop Unacquire => 0x{:08X}", (unsigned)hr);\n''', +"panic acquired flag" +) + +replace_once( +''' __try\n {\n device_->Unacquire();\n device_->Release();\n''', +''' __try\n {\n device_->Unacquire();\n deviceAcquired_ = false;\n device_->Release();\n''', +"release acquired flag" +) + +replace_once( +''' bool initialized_ = false;\n bool panicStopped_ = false;\n bool periodicsActive_ = false;\n''', +''' bool initialized_ = false;\n bool panicStopped_ = false;\n bool deviceAcquired_ = false;\n bool periodicsActive_ = false;\n''', +"acquired member" +) + +path.write_text(text, encoding="utf-8") +print("Applied menu/gameplay DirectInput handoff patch") diff --git a/tools/patch_wheel_ffb_r3_behavior.py b/tools/patch_wheel_ffb_r3_behavior.py new file mode 100644 index 000000000..0deceb35a --- /dev/null +++ b/tools/patch_wheel_ffb_r3_behavior.py @@ -0,0 +1,52 @@ +from pathlib import Path + +path = Path("src/hooks_wheel_ffb.cpp") +text = path.read_text(encoding="utf-8") + + +def replace_once(old: str, new: str, label: str) -> None: + global text + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected exactly one match, found {count}") + text = text.replace(old, new, 1) + + +replace_once( +''' // Road texture and tire-slip envelopes.\n float roadAmp =\n roughness * speedNorm * static_cast(Settings::WheelFFBRoadTexture);\n const float roadFreq = 25.0f + 12.0f * speedNorm;\n''', +''' // Road texture and tire-slip envelopes. sub_1149C0 returns\n // ~0.25 for ordinary asphalt; that is a material baseline, not a\n // request to vibrate the wheel. The Xbox routine only enters its\n // stronger surface branch above roughly 0.30, so remove that\n // baseline here and ramp rough surfaces from 0.30 -> 0.85.\n const float textureRoughness =\n std::clamp((roughness - 0.30f) / 0.55f, 0.0f, 1.0f);\n const float roadSpeedGate =\n std::clamp((speedNorm - 0.05f) / 0.20f, 0.0f, 1.0f);\n float roadAmp =\n textureRoughness * roadSpeedGate *\n static_cast(Settings::WheelFFBRoadTexture);\n const float roadFreq = 25.0f + 12.0f * speedNorm;\n''', +"road texture baseline" +) + +replace_once( +''' if (springEffect_)\n {\n update_spring(\n suppressSpringForImpact\n ? 0.0f\n : springStrength * warmupScale * recreateScale);\n }\n\n const float softwareSpring =\n springEffect_\n ? 0.0f\n : -steer * static_cast(Settings::WheelFFBSpringStrength) * speedCurve;\n''', +''' // Make UseHardwareSpring a real live F11 switch. Previously\n // changing it to false after startup left the already-created\n // GUID_Spring running, which made direction testing misleading.\n if (!Settings::WheelFFBUseHardwareSpring && springEffect_)\n {\n update_spring(0.0f);\n springEffect_->Stop();\n safe_release_effect(springEffect_, "hardware spring disabled");\n prevSpringCoefficient_ = 0;\n prevSpringSaturation_ = 0;\n springStrategy_ = -1;\n spdlog::info("WheelFFB: hardware spring disabled live; using software spring");\n }\n\n if (springEffect_)\n {\n update_spring(\n suppressSpringForImpact\n ? 0.0f\n : springStrength * warmupScale * recreateScale);\n }\n\n const float softwareSpring =\n springEffect_\n ? 0.0f\n : -steer * static_cast(Settings::WheelFFBSpringStrength) * speedCurve;\n''', +"live hardware spring switch" +) + +replace_once( +''' const DWORD mag = static_cast(\n std::clamp(magnitude, 0.0f, 1.0f) *\n static_cast(DI_FFNOMINALMAX));\n frequency = std::clamp(frequency, 1.0f, 100.0f);\n const DWORD period = static_cast(1000000.0f / frequency);\n\n const bool silence = mag == 0 && state.lastMagnitude != 0;\n const bool magChanged =\n std::abs(static_cast(mag) - static_cast(state.lastMagnitude)) > 300;\n const bool periodChanged =\n''', +''' // Very small hardware-sine magnitudes can remain audible on DD\n // bases. Snap them to zero, and update much more aggressively on\n // falling magnitude so an off-road effect cannot remain latched\n // after the car returns to asphalt.\n const float magnitudeClamped = std::clamp(magnitude, 0.0f, 1.0f);\n const DWORD mag = magnitudeClamped < 0.01f\n ? 0u\n : static_cast(\n magnitudeClamped * static_cast(DI_FFNOMINALMAX));\n frequency = std::clamp(frequency, 1.0f, 100.0f);\n const DWORD period = static_cast(1000000.0f / frequency);\n\n const long magDelta = std::abs(\n static_cast(mag) - static_cast(state.lastMagnitude));\n const bool silence = mag == 0 && state.lastMagnitude != 0;\n const bool magChanged =\n magDelta > 60 ||\n (state.lastMagnitude > 0 &&\n magDelta * 5 > static_cast(state.lastMagnitude));\n const bool periodChanged =\n''', +"periodic stale magnitude" +) + +replace_once( +''' crashImpulseForce_ =\n direction * 1.5f *\n static_cast(Settings::WheelFFBWallImpact);\n''', +''' const float severity =\n std::clamp((speedDrop - 0.03f) / 0.12f, 0.0f, 1.0f);\n crashImpulseForce_ =\n direction * (1.7f + 0.8f * severity) *\n static_cast(Settings::WheelFFBWallImpact);\n''', +"speed-drop impact strength" +) + +replace_once( +''' crashImpulseForce_ =\n direction * 1.2f *\n static_cast(Settings::WheelFFBWallImpact);\n''', +''' crashImpulseForce_ =\n direction * 1.9f *\n static_cast(Settings::WheelFFBWallImpact);\n''', +"collision-flag impact strength" +) + +replace_once( +''' if (crashImpulseTimer_ > CrashCooldownFrames)\n {\n float envelope = 1.0f;\n if (crashImpulseTimer_ <= 85)\n {\n envelope =\n static_cast(crashImpulseTimer_ - CrashCooldownFrames) /\n 5.0f;\n }\n result += crashImpulseForce_ * envelope;\n }\n''', +''' if (crashImpulseTimer_ > CrashCooldownFrames)\n {\n // A short kick/rebound is much easier to feel on a DD wheel\n // than the old soft one-direction 10-frame push.\n const int impactFrame = CrashTimerFrames - crashImpulseTimer_;\n if (impactFrame < 3)\n result += crashImpulseForce_;\n else if (impactFrame < 6)\n result -= crashImpulseForce_ * 0.55f;\n else\n result += crashImpulseForce_ * 0.20f;\n }\n''', +"impact pulse envelope" +) + +path.write_text(text, encoding="utf-8") +print("Applied R3 FFB behavior patch") diff --git a/tools/patch_wheel_ffb_r3_spring_direction.py b/tools/patch_wheel_ffb_r3_spring_direction.py new file mode 100644 index 000000000..faf840ced --- /dev/null +++ b/tools/patch_wheel_ffb_r3_spring_direction.py @@ -0,0 +1,21 @@ +from pathlib import Path + +path = Path("src/hooks_wheel_ffb.cpp") +text = path.read_text(encoding="utf-8") + +old = ''' // DirectInput's condition equation uses A(q-q0). A spring must use\n // negative coefficients so displacement produces force back toward\n // the zero offset rather than accelerating away from center.\n const LONG coefficient = -coefficientMagnitude;\n''' +new = ''' // R3 testing showed the old negative GUID_Spring coefficient pushes\n // the wheel farther in the direction of steering instead of back to\n // center. Keep hardware-spring direction consistent with the global\n // InvertForce option: normal (false) uses the R3 centering sign, and\n // inverted (true) reverses it together with ConstantForce.\n const LONG coefficient = Settings::WheelFFBInvertForce\n ? -coefficientMagnitude\n : coefficientMagnitude;\n''' + +count = text.count(old) +if count != 1: + raise SystemExit(f"spring coefficient: expected exactly one match, found {count}") +text = text.replace(old, new, 1) + +old_log = '"WheelFFB: GUID_Spring created (saturation={} / {}, negative coefficients pull toward center)"' +new_log = '"WheelFFB: GUID_Spring created (saturation={} / {}, R3 normal sign=positive, follows InvertForce)"' +if text.count(old_log) != 1: + raise SystemExit("spring log string not found exactly once") +text = text.replace(old_log, new_log, 1) + +path.write_text(text, encoding="utf-8") +print("Applied R3 GUID_Spring direction patch") diff --git a/tools/patch_wheel_ffb_recovery.py b/tools/patch_wheel_ffb_recovery.py new file mode 100644 index 000000000..9e50f6d54 --- /dev/null +++ b/tools/patch_wheel_ffb_recovery.py @@ -0,0 +1,216 @@ +from pathlib import Path + +p = Path('src/hooks_wheel_ffb.cpp') +text = p.read_text(encoding='utf-8') + + +def rep(old: str, new: str, label: str) -> None: + global text + count = text.count(old) + if count != 1: + raise SystemExit(f'{label}: expected 1 match, got {count}') + text = text.replace(old, new, 1) + print(f'patched: {label}') + + +rep( +''' constexpr UINT FFB_WATCHDOG_INTERVAL_MS = 100;\n''', +''' constexpr UINT FFB_WATCHDOG_INTERVAL_MS = 100;\n constexpr DWORD FFB_DEVICE_FAILURE_GRACE_MS = 750;\n constexpr DWORD FFB_DEVICE_RETRY_MS = 750;\n''', +'device recovery timing') + +rep( +''' lastUpdateTick_ = GetTickCount();\n\n if (!initialized_)\n''', +''' const DWORD updateNow = GetTickCount();\n\n if (deviceReinitPending_)\n {\n if (updateNow < deviceReinitAfter_)\n return;\n teardown_for_reinitialize();\n deviceReinitPending_ = false;\n deviceFailureSince_ = 0;\n initialized_ = false;\n retryAfter_ = updateNow + FFB_DEVICE_RETRY_MS;\n spdlog::info(\n "WheelFFB: DirectInput device released; waiting to re-enumerate after device loss");\n return;\n }\n\n lastUpdateTick_ = updateNow;\n\n if (!initialized_)\n''', +'process pending device reinitialization') + +rep( +''' if (device_ && !deviceAcquired_)\n {\n const HRESULT acquireHr = device_->Acquire();\n if (FAILED(acquireHr))\n return;\n deviceAcquired_ = true;\n device_->SendForceFeedbackCommand(DISFFC_SETACTUATORSON);\n }\n''', +''' if (device_ && !deviceAcquired_)\n {\n const HRESULT acquireHr = device_->Acquire();\n if (FAILED(acquireHr) && acquireHr != S_FALSE)\n {\n note_device_failure("gameplay Acquire", acquireHr);\n return;\n }\n deviceAcquired_ = true;\n clear_device_failure();\n const HRESULT actuatorHr =\n device_->SendForceFeedbackCommand(DISFFC_SETACTUATORSON);\n if (FAILED(actuatorHr))\n note_device_failure("SETACTUATORSON", actuatorHr);\n }\n''', +'gameplay acquire recovery') + +needle = ''' bool initialize()\n {\n''' +if text.count(needle) != 1: + raise SystemExit('recovery helper insertion point not found exactly once') + +helpers = r''' void clear_device_failure() + { + deviceFailureSince_ = 0; + } + + void request_device_reinitialize(const char* reason, HRESULT hr) + { + if (panicStopped_ || deviceReinitPending_) + return; + + deviceReinitPending_ = true; + deviceReinitAfter_ = GetTickCount(); + spdlog::warn( + "WheelFFB: scheduling DirectInput device reinitialization after {} (0x{:08X})", + reason, (unsigned)hr); + } + + void note_device_failure(const char* where, HRESULT hr) + { + if (panicStopped_ || !initialized_) + return; + + const DWORD now = GetTickCount(); + if (deviceFailureSince_ == 0) + { + deviceFailureSince_ = now; + spdlog::warn( + "WheelFFB: DirectInput device access failed at {} (0x{:08X}); allowing {}ms for transient recovery", + where, (unsigned)hr, (unsigned)FFB_DEVICE_FAILURE_GRACE_MS); + return; + } + + if (now - deviceFailureSince_ >= FFB_DEVICE_FAILURE_GRACE_MS) + request_device_reinitialize(where, hr); + } + + bool reacquire_after_input_loss(const char* where, HRESULT originalHr) + { + if (!device_) + { + note_device_failure(where, originalHr); + return false; + } + + deviceAcquired_ = false; + const HRESULT acquireHr = device_->Acquire(); + if (SUCCEEDED(acquireHr) || acquireHr == S_FALSE) + { + deviceAcquired_ = true; + clear_device_failure(); + return true; + } + + note_device_failure(where, acquireHr); + return false; + } + + void release_effects_for_reinitialize() + { + safe_release_effect(constantEffect_, "constant during device reinit"); + safe_release_effect(springEffect_, "spring during device reinit"); + safe_release_effect(damperEffect_, "damper during device reinit"); + safe_release_effect(roadTextureEffect_, "road during device reinit"); + safe_release_effect(tireSlipEffect_, "tire during device reinit"); + + roadState_ = {}; + slipState_ = {}; + periodicsActive_ = false; + springStrategy_ = 1; + damperStrategy_ = 1; + periodicStrategy_ = 1; + } + + void teardown_for_reinitialize() + { + if (device_) + { + __try + { + device_->SendForceFeedbackCommand(DISFFC_STOPALL); + device_->SendForceFeedbackCommand(DISFFC_SETACTUATORSOFF); + } + __except (EXCEPTION_EXECUTE_HANDLER) + { + spdlog::warn( + "WheelFFB: exception stopping stale DirectInput device during reinit (0x{:X})", + GetExceptionCode()); + } + } + + release_effects_for_reinitialize(); + + if (device_) + { + __try + { + device_->Unacquire(); + deviceAcquired_ = false; + + DIPROPDWORD autocenter{}; + autocenter.diph.dwSize = sizeof(autocenter); + autocenter.diph.dwHeaderSize = sizeof(DIPROPHEADER); + autocenter.diph.dwObj = 0; + autocenter.diph.dwHow = DIPH_DEVICE; + autocenter.dwData = DIPROPAUTOCENTER_ON; + device_->SetProperty(DIPROP_AUTOCENTER, &autocenter.diph); + } + __except (EXCEPTION_EXECUTE_HANDLER) + { + deviceAcquired_ = false; + } + } + + release_device(); + release_directinput(); + selectedName_.clear(); + selectedGuid_ = {}; + lastEffectGain_ = 0xFFFFFFFFu; + nextGainRetryTick_ = 0; + recreateHoldoffUntil_ = 0; + springRecreateHoldoffUntil_ = 0; + damperRecreateHoldoffUntil_ = 0; + reset_signal_state(); + } + +''' +text = text.replace(needle, helpers + needle, 1) + +rep( +''' initialized_ = true;\n reset_signal_state();\n\n lastEffectGain_ = configured_effect_gain();\n''', +''' initialized_ = true;\n deviceReinitPending_ = false;\n deviceFailureSince_ = 0;\n deviceReinitAfter_ = 0;\n reset_signal_state();\n\n lastEffectGain_ = configured_effect_gain();\n''', +'reset recovery state after initialization') + +rep( +''' if (hr == DIERR_INPUTLOST || hr == DIERR_NOTACQUIRED)\n {\n if (SUCCEEDED(device_->Acquire()))\n deviceAcquired_ = true;\n hr = effect->SetParameters(¶ms, DIEP_GAIN);\n }\n''', +''' if (hr == DIERR_INPUTLOST || hr == DIERR_NOTACQUIRED)\n {\n if (reacquire_after_input_loss("live gain", hr))\n hr = effect->SetParameters(¶ms, DIEP_GAIN);\n }\n''', +'live gain input-loss recovery') + +rep( +''' if (hr == DIERR_INPUTLOST || hr == DIERR_NOTACQUIRED)\n {\n if (SUCCEEDED(device_->Acquire()))\n deviceAcquired_ = true;\n hr = springEffect_->SetParameters(\n ¶ms, DIEP_TYPESPECIFICPARAMS | DIEP_START);\n }\n''', +''' if (hr == DIERR_INPUTLOST || hr == DIERR_NOTACQUIRED)\n {\n if (reacquire_after_input_loss("GUID_Spring", hr))\n hr = springEffect_->SetParameters(\n ¶ms, DIEP_TYPESPECIFICPARAMS | DIEP_START);\n }\n''', +'spring input-loss recovery') + +rep( +''' if (hr == DIERR_INPUTLOST || hr == DIERR_NOTACQUIRED)\n {\n if (SUCCEEDED(device_->Acquire()))\n deviceAcquired_ = true;\n hr = damperEffect_->SetParameters(\n ¶ms, DIEP_TYPESPECIFICPARAMS | DIEP_START);\n }\n''', +''' if (hr == DIERR_INPUTLOST || hr == DIERR_NOTACQUIRED)\n {\n if (reacquire_after_input_loss("GUID_Damper", hr))\n hr = damperEffect_->SetParameters(\n ¶ms, DIEP_TYPESPECIFICPARAMS | DIEP_START);\n }\n''', +'damper input-loss recovery') + +rep( +''' if (FAILED(hr))\n {\n spdlog::warn(\n "WheelFFB: periodic update failed (0x{:08X}); falling back to ConstantForce vibration",\n''', +''' if (hr == DIERR_INPUTLOST || hr == DIERR_NOTACQUIRED)\n {\n if (reacquire_after_input_loss("GUID_Sine periodic", hr))\n hr = effect->SetParameters(\n ¶ms, DIEP_TYPESPECIFICPARAMS | DIEP_START);\n }\n\n if (FAILED(hr))\n {\n spdlog::warn(\n "WheelFFB: periodic update failed (0x{:08X}); falling back to ConstantForce vibration",\n''', +'periodic input-loss recovery') + +rep( +''' if (hr == DIERR_INPUTLOST || hr == DIERR_NOTACQUIRED)\n {\n if (SUCCEEDED(device_->Acquire()))\n deviceAcquired_ = true;\n if (constantEffect_)\n {\n hr = constantEffect_->SetParameters(\n ¶ms, DIEP_TYPESPECIFICPARAMS | DIEP_START);\n }\n }\n''', +''' if (hr == DIERR_INPUTLOST || hr == DIERR_NOTACQUIRED)\n {\n if (reacquire_after_input_loss("ConstantForce", hr) && constantEffect_)\n {\n hr = constantEffect_->SetParameters(\n ¶ms, DIEP_TYPESPECIFICPARAMS | DIEP_START);\n }\n }\n''', +'constant-force input-loss recovery') + +rep( +''' prevSpringCoefficient_ = coefficient;\n prevSpringSaturation_ = saturation;\n''', +''' clear_device_failure();\n prevSpringCoefficient_ = coefficient;\n prevSpringSaturation_ = saturation;\n''', +'spring success clears transient fault') +rep( +''' prevDamperCoefficient_ = coefficient;\n''', +''' clear_device_failure();\n prevDamperCoefficient_ = coefficient;\n''', +'damper success clears transient fault') +rep( +''' state.lastMagnitude = mag;\n state.lastPeriod = period;\n''', +''' clear_device_failure();\n state.lastMagnitude = mag;\n state.lastPeriod = period;\n''', +'periodic success clears transient fault') +rep( +''' prevConstantLevel_ = requestedLevel;\n''', +''' clear_device_failure();\n prevConstantLevel_ = requestedLevel;\n''', +'constant success clears transient fault') + +rep( +''' DWORD nextGainRetryTick_ = 0;\n''', +''' DWORD nextGainRetryTick_ = 0;\n DWORD deviceFailureSince_ = 0;\n DWORD deviceReinitAfter_ = 0;\n bool deviceReinitPending_ = false;\n''', +'device recovery state') + +p.write_text(text, encoding='utf-8') +print('Applied DirectInput device-loss/replug recovery state machine') diff --git a/tools/patch_wheel_ffb_safe_inversion.py b/tools/patch_wheel_ffb_safe_inversion.py new file mode 100644 index 000000000..fd0b30213 --- /dev/null +++ b/tools/patch_wheel_ffb_safe_inversion.py @@ -0,0 +1,15 @@ +from pathlib import Path +p=Path('src/hooks_wheel_ffb.cpp'); s=p.read_text() + +def r(a,b,n): + global s + c=s.count(a) + if c!=1: raise SystemExit(f'{n}: {c}') + s=s.replace(a,b,1); print('patched',n) + +r(''' Setting WheelFFBInvertForce{\n "WheelFFB", "InvertForce", false,\n "Reverse steering force direction."\n };\n''',''' Setting WheelFFBInvertForce{\n "WheelFFB", "InvertForce", false,\n "Reverse directional cornering/impact force. Centering spring and damping always remain stabilizing."\n };\n''','description') +r(''' // R3 testing showed the old negative GUID_Spring coefficient pushes\n // the wheel farther in the direction of steering instead of back to\n // center. Keep hardware-spring direction consistent with the global\n // InvertForce option: normal (false) uses the R3 centering sign, and\n // inverted (true) reverses it together with ConstantForce.\n const LONG coefficient = Settings::WheelFFBInvertForce\n ? -coefficientMagnitude\n : coefficientMagnitude;\n''',''' // R3 hardware testing established that this driver's positive\n // condition coefficient is the restoring sign. Never let the global\n // directional-force inversion turn a centering spring into an\n // unstable run-away force.\n const LONG coefficient = coefficientMagnitude;\n''','hardware spring sign') +r(''' const float softwareSpring =\n springEffect_\n ? 0.0f\n : -steer * static_cast(Settings::WheelFFBSpringStrength) * speedCurve;\n''',''' const float softwareSpring =\n springEffect_ ? 0.0f : -steer * springStrength;\n''','software spring parity') +r(''' float structural = 0.0f;\n if (crashImpulseTimer_ <= CrashCooldownFrames)\n structural = (softwareSpring + lateral) * loadMod + damper;\n\n float events = update_event_force();\n\n float total = structural + events;\n if (Settings::WheelFFBInvertForce)\n total = -total;\n''',''' // Centering and damping are stabilizing terms and must never be\n // inverted. InvertForce is intentionally restricted to directional\n // physics (cornering and event kick) so toggling it cannot make a\n // wheel accelerate away from center.\n float stabilizing = 0.0f;\n float directional = update_event_force();\n if (crashImpulseTimer_ <= CrashCooldownFrames)\n {\n stabilizing = softwareSpring * loadMod + damper;\n directional += lateral * loadMod;\n }\n if (Settings::WheelFFBInvertForce)\n directional = -directional;\n\n float total = stabilizing + directional;\n''','safe inversion split') +p.write_text(s) +print('Applied safe directional inversion; centering/damping remain stabilizing') diff --git a/tools/patch_wheel_ffb_simulation.py b/tools/patch_wheel_ffb_simulation.py new file mode 100644 index 000000000..42637a159 --- /dev/null +++ b/tools/patch_wheel_ffb_simulation.py @@ -0,0 +1,248 @@ +from pathlib import Path + +path = Path("src/hooks_wheel_ffb.cpp") +text = path.read_text(encoding="utf-8") + + +def replace_once(old: str, new: str, label: str) -> None: + global text + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected exactly one match, found {count}") + text = text.replace(old, new, 1) + + +replace_once( +''' Setting WheelFFBDamperStrength{\n "WheelFFB", "DamperStrength", 0.10f,\n "Resistance to rapid steering movement.", Range{ 0.0f, 1.0f }\n };\n\n Setting WheelFFBSteeringWeight{\n''', +''' Setting WheelFFBDamperStrength{\n "WheelFFB", "DamperStrength", 0.10f,\n "Dynamic steering damping. For DD wheels try 0.25-0.45.", Range{ 0.0f, 1.0f }\n };\n\n Setting WheelFFBUseHardwareDamper{\n "WheelFFB", "UseHardwareDamper", true,\n "Use DirectInput GUID_Damper when the wheel supports it; otherwise use the software fallback."\n };\n\n Setting WheelFFBLowSpeedSpring{\n "WheelFFB", "LowSpeedSpring", 0.08f,\n "Fraction of aligning/centering force retained at very low speed.", Range{ 0.0f, 0.5f }\n };\n\n Setting WheelFFBSpringLoadBoost{\n "WheelFFB", "SpringLoadBoost", 0.35f,\n "Extra aligning force under cornering load before grip-loss unloading.", Range{ 0.0f, 1.0f }\n };\n\n Setting WheelFFBSteeringWeight{\n''', +"simulation settings" +) + +replace_once( +''' const float speedCurve =\n std::clamp(speedNorm / 0.25f, 0.0f, 1.0f) *\n (0.35f + 0.65f * speedNorm);\n\n const float springStrength =\n std::clamp(\n static_cast(Settings::WheelFFBSpringStrength) *\n speedCurve * gripFactor,\n 0.0f, 1.0f);\n''', +''' // Simulation-style aligning backbone: light at parking speed,\n // progressively stronger with vehicle speed, then additionally\n // loaded by cornering force. Deep slip unloads the wheel again.\n const float lowSpeedSpring = std::clamp(\n static_cast(Settings::WheelFFBLowSpeedSpring), 0.0f, 0.5f);\n const float speedCurve = std::clamp(\n lowSpeedSpring + (1.0f - lowSpeedSpring) *\n std::pow(speedNorm, 1.60f),\n 0.0f, 1.0f);\n const float cornerLoad = std::clamp(std::abs(latNorm), 0.0f, 1.0f);\n const float loadBoost = 1.0f +\n cornerLoad * static_cast(Settings::WheelFFBSpringLoadBoost);\n\n const float springStrength =\n std::clamp(\n static_cast(Settings::WheelFFBSpringStrength) *\n speedCurve * loadBoost * gripFactor,\n 0.0f, 1.0f);\n''', +"dynamic spring curve" +) + +replace_once( +''' // Keep the current software damper for this first comparison build.\n // A separate GUID_Damper can be tested after the hardware spring is\n // validated on the R3, so the two changes are not confounded.\n constexpr float SteerRateScale = 10.0f;\n const float damper =\n -steerRate * SteerRateScale *\n static_cast(Settings::WheelFFBDamperStrength) *\n (0.4f + 0.6f * speedNorm);\n''', +''' // ACC/AMS2-inspired dynamic damping. It resists steering velocity\n // rather than pulling toward centre, grows with vehicle speed, and\n // relaxes when the tyres are deeply sliding so counter-steer is not\n // smothered. Prefer a native DirectInput GUID_Damper on wheels that\n // implement it and keep the old software term as a fallback.\n const float dampingSpeed =\n 0.10f + 0.90f * std::pow(speedNorm, 1.30f);\n const float dampingGrip = 0.25f + 0.75f * gripFactor;\n const float dynamicDamperStrength = std::clamp(\n static_cast(Settings::WheelFFBDamperStrength) *\n dampingSpeed * dampingGrip,\n 0.0f, 1.0f);\n\n if (!Settings::WheelFFBUseHardwareDamper && damperEffect_)\n {\n update_damper(0.0f);\n damperEffect_->Stop();\n safe_release_effect(damperEffect_, "hardware damper disabled");\n prevDamperCoefficient_ = 0;\n damperStrategy_ = -1;\n spdlog::info("WheelFFB: hardware damper disabled live; using software damping");\n }\n\n if (damperEffect_)\n update_damper(dynamicDamperStrength * warmupScale * recreateScale);\n\n constexpr float SteerRateScale = 10.0f;\n const float damper = damperEffect_\n ? 0.0f\n : -steerRate * SteerRateScale * dynamicDamperStrength;\n''', +"dynamic damper block" +) + +replace_once( +''' if (Settings::WheelFFBUseHardwareSpring &&\n !springEffect_ &&\n (updateCounter_ % 60) == 0 &&\n GetTickCount() >= springRecreateHoldoffUntil_)\n {\n create_spring_effect();\n }\n\n prevGear_ = curGear;\n''', +''' if (Settings::WheelFFBUseHardwareSpring &&\n !springEffect_ &&\n (updateCounter_ % 60) == 0 &&\n GetTickCount() >= springRecreateHoldoffUntil_)\n {\n create_spring_effect();\n }\n\n if (Settings::WheelFFBUseHardwareDamper &&\n !damperEffect_ &&\n (updateCounter_ % 60) == 0 &&\n GetTickCount() >= damperRecreateHoldoffUntil_)\n {\n create_damper_effect();\n }\n\n prevGear_ = curGear;\n''', +"damper recreation" +) + +replace_once( +''' if (elapsed > 250 &&\n (prevConstantLevel_ != 0 || prevSpringCoefficient_ != 0))\n''', +''' if (elapsed > 250 &&\n (prevConstantLevel_ != 0 || prevSpringCoefficient_ != 0 ||\n prevDamperCoefficient_ != 0))\n''', +"watchdog damper" +) + +replace_once( +''' if (springEffect_)\n {\n const HRESULT springHr = springEffect_->Stop();\n spdlog::info("WheelFFB: PanicStop spring Stop => 0x{:08X}", (unsigned)springHr);\n }\n\n if (roadTextureEffect_)\n''', +''' if (springEffect_)\n {\n const HRESULT springHr = springEffect_->Stop();\n spdlog::info("WheelFFB: PanicStop spring Stop => 0x{:08X}", (unsigned)springHr);\n }\n\n if (damperEffect_)\n {\n const HRESULT damperHr = damperEffect_->Stop();\n spdlog::info("WheelFFB: PanicStop damper Stop => 0x{:08X}", (unsigned)damperHr);\n }\n\n if (roadTextureEffect_)\n''', +"panic damper" +) + +replace_once( +''' if (Settings::WheelFFBUseHardwareSpring && !create_spring_effect())\n {\n spdlog::warn(\n "WheelFFB: hardware GUID_Spring unavailable; retaining software spring fallback");\n }\n\n create_periodic_effects();\n''', +''' if (Settings::WheelFFBUseHardwareSpring && !create_spring_effect())\n {\n spdlog::warn(\n "WheelFFB: hardware GUID_Spring unavailable; retaining software spring fallback");\n }\n\n if (Settings::WheelFFBUseHardwareDamper && !create_damper_effect())\n {\n spdlog::warn(\n "WheelFFB: hardware GUID_Damper unavailable; retaining software damper fallback");\n }\n\n create_periodic_effects();\n''', +"initialize damper" +) + +replace_once( +''' spdlog::info(\n "WheelFFB: ready on '{}' (DirectInput COM, axes={}, buttons={}, global={}%, spring={})",\n selectedName_,\n caps.dwAxes,\n caps.dwButtons,\n static_cast(static_cast(Settings::WheelFFBGlobalStrength) * 100.0f),\n springEffect_ ? "GUID_Spring" : "software");\n''', +''' spdlog::info(\n "WheelFFB: ready on '{}' (DirectInput COM, axes={}, buttons={}, global={}%, spring={}, damper={})",\n selectedName_,\n caps.dwAxes,\n caps.dwButtons,\n static_cast(static_cast(Settings::WheelFFBGlobalStrength) * 100.0f),\n springEffect_ ? "GUID_Spring" : "software",\n damperEffect_ ? "GUID_Damper" : "software");\n''', +"ready log damper" +) + +# Insert GUID_Damper implementation immediately before the periodic-effect helper. +needle = ''' IDirectInputEffect* create_periodic_effect(const char* label, float initialHz)\n''' +if text.count(needle) != 1: + raise SystemExit("damper insertion point not found exactly once") + +damper_impl = r''' bool create_damper_effect() + { + if (!device_ || !Settings::WheelFFBUseHardwareDamper) + return false; + + DWORD axes[1] = { DIJOFS_X }; + LONG directions[1] = { 0 }; + DICONDITION condition{}; + condition.lOffset = 0; + condition.lPositiveCoefficient = 0; + condition.lNegativeCoefficient = 0; + condition.dwPositiveSaturation = DI_FFNOMINALMAX; + condition.dwNegativeSaturation = DI_FFNOMINALMAX; + condition.lDeadBand = 0; + + DIEFFECT effect{}; + effect.dwSize = sizeof(effect); + effect.dwFlags = DIEFF_CARTESIAN | DIEFF_OBJECTOFFSETS; + effect.dwDuration = INFINITE; + effect.dwSamplePeriod = 0; + effect.dwGain = static_cast( + std::clamp(static_cast(Settings::WheelFFBGlobalStrength), 0.0f, 1.0f) * + static_cast(DI_FFNOMINALMAX)); + effect.dwTriggerButton = DIEB_NOTRIGGER; + effect.dwTriggerRepeatInterval = 0; + effect.cAxes = 1; + effect.rgdwAxes = axes; + effect.rglDirection = directions; + effect.cbTypeSpecificParams = sizeof(condition); + effect.lpvTypeSpecificParams = &condition; + + HRESULT hr = device_->CreateEffect( + GUID_Damper, &effect, &damperEffect_, nullptr); + if (FAILED(hr) || !damperEffect_) + { + spdlog::warn( + "WheelFFB: CreateEffect(GUID_Damper) failed (0x{:08X})", + (unsigned)hr); + damperRecreateHoldoffUntil_ = GetTickCount() + 1000; + return false; + } + + hr = damperEffect_->Start(1, 0); + if (FAILED(hr)) + spdlog::warn("WheelFFB: GUID_Damper initial Start failed (0x{:08X})", (unsigned)hr); + + prevDamperCoefficient_ = 0; + damperStrategy_ = -1; + spdlog::info("WheelFFB: GUID_Damper created (dynamic speed/grip damping)"); + return true; + } + + void update_damper(float strength) + { + if (!damperEffect_ || !device_ || panicStopped_) + return; + + // Positive coefficients are the conventional DirectInput condition + // representation for GUID_Damper; the effect type itself applies + // force opposite steering velocity. + const LONG coefficient = static_cast( + std::clamp(strength, 0.0f, 1.0f) * + static_cast(DI_FFNOMINALMAX)); + + const bool silence = coefficient == 0 && prevDamperCoefficient_ != 0; + const bool changed = std::abs(coefficient - prevDamperCoefficient_) > 40; + if (!silence && !changed) + return; + + DICONDITION condition{}; + condition.lOffset = 0; + condition.lPositiveCoefficient = coefficient; + condition.lNegativeCoefficient = coefficient; + condition.dwPositiveSaturation = DI_FFNOMINALMAX; + condition.dwNegativeSaturation = DI_FFNOMINALMAX; + condition.lDeadBand = 0; + + DIEFFECT params{}; + params.dwSize = sizeof(params); + params.cbTypeSpecificParams = sizeof(condition); + params.lpvTypeSpecificParams = &condition; + + DWORD flags = DIEP_TYPESPECIFICPARAMS | + (damperStrategy_ == 1 ? DIEP_START : 0); + HRESULT hr = damperEffect_->SetParameters(¶ms, flags); + + if (damperStrategy_ == -1) + { + if (SUCCEEDED(hr)) + { + damperStrategy_ = 0; + spdlog::info("WheelFFB: GUID_Damper updates work without DIEP_START"); + } + else + { + hr = damperEffect_->SetParameters( + ¶ms, DIEP_TYPESPECIFICPARAMS | DIEP_START); + if (SUCCEEDED(hr)) + { + damperStrategy_ = 1; + spdlog::info("WheelFFB: GUID_Damper driver requires DIEP_START"); + } + } + } + + if (hr == DIERR_INPUTLOST || hr == DIERR_NOTACQUIRED) + { + device_->Acquire(); + hr = damperEffect_->SetParameters( + ¶ms, DIEP_TYPESPECIFICPARAMS | DIEP_START); + } + + if (FAILED(hr)) + { + spdlog::warn( + "WheelFFB: GUID_Damper update failed (0x{:08X}); using software fallback", + (unsigned)hr); + damperEffect_->Stop(); + safe_release_effect(damperEffect_, "stale damper"); + prevDamperCoefficient_ = 0; + damperStrategy_ = -1; + damperRecreateHoldoffUntil_ = GetTickCount() + 1000; + return; + } + + prevDamperCoefficient_ = coefficient; + } + +''' +text = text.replace(needle, damper_impl + needle, 1) + +replace_once( +''' if (springEffect_ && prevSpringCoefficient_ != 0)\n update_spring(0.0f);\n\n prevStructuralLevel_ = 0;\n''', +''' if (springEffect_ && prevSpringCoefficient_ != 0)\n update_spring(0.0f);\n\n if (damperEffect_ && prevDamperCoefficient_ != 0)\n update_damper(0.0f);\n\n prevStructuralLevel_ = 0;\n''', +"zero damper" +) + +replace_once( +''' prevStructuralLevel_ = 0;\n prevSpringCoefficient_ = 0;\n crashImpulseTimer_ = 0;\n''', +''' prevStructuralLevel_ = 0;\n prevSpringCoefficient_ = 0;\n prevDamperCoefficient_ = 0;\n crashImpulseTimer_ = 0;\n''', +"reset damper state" +) + +replace_once( +''' "WheelFFB DIAG: spd={:.2f} steer={:.3f} rate={:.4f} lat={:.2f} drift={:.2f} rough={:.2f} out={} spring={} coeff={} periodic={}",\n''', +''' "WheelFFB DIAG: spd={:.2f} steer={:.3f} rate={:.4f} lat={:.2f} drift={:.2f} rough={:.2f} out={} spring={} coeff={} damper={} dcoeff={} periodic={}",\n''', +"diagnostic format" +) + +replace_once( +''' springEffect_ ? "HW" : "SW",\n static_cast(prevSpringCoefficient_),\n periodicsActive_);\n''', +''' springEffect_ ? "HW" : "SW",\n static_cast(prevSpringCoefficient_),\n damperEffect_ ? "HW" : "SW",\n static_cast(prevDamperCoefficient_),\n periodicsActive_);\n''', +"diagnostic args" +) + +replace_once( +''' IDirectInputEffect* constantEffect_ = nullptr;\n IDirectInputEffect* springEffect_ = nullptr;\n IDirectInputEffect* roadTextureEffect_ = nullptr;\n''', +''' IDirectInputEffect* constantEffect_ = nullptr;\n IDirectInputEffect* springEffect_ = nullptr;\n IDirectInputEffect* damperEffect_ = nullptr;\n IDirectInputEffect* roadTextureEffect_ = nullptr;\n''', +"damper member" +) + +replace_once( +''' int periodicStrategy_ = -1;\n int springStrategy_ = -1;\n\n DWORD retryAfter_ = 0;\n''', +''' int periodicStrategy_ = -1;\n int springStrategy_ = -1;\n int damperStrategy_ = -1;\n\n DWORD retryAfter_ = 0;\n''', +"damper strategy" +) + +replace_once( +''' DWORD recreateHoldoffUntil_ = 0;\n DWORD springRecreateHoldoffUntil_ = 0;\n DWORD lastUpdateTick_ = 0;\n''', +''' DWORD recreateHoldoffUntil_ = 0;\n DWORD springRecreateHoldoffUntil_ = 0;\n DWORD damperRecreateHoldoffUntil_ = 0;\n DWORD lastUpdateTick_ = 0;\n''', +"damper holdoff" +) + +replace_once( +''' LONG prevStructuralLevel_ = 0;\n LONG prevSpringCoefficient_ = 0;\n DWORD prevSpringSaturation_ = 0;\n''', +''' LONG prevStructuralLevel_ = 0;\n LONG prevSpringCoefficient_ = 0;\n LONG prevDamperCoefficient_ = 0;\n DWORD prevSpringSaturation_ = 0;\n''', +"damper coefficient member" +) + +path.write_text(text, encoding="utf-8") +print("Applied simulation-style dynamic spring + GUID_Damper patch") diff --git a/tools/patch_wheel_final_cleanup.py b/tools/patch_wheel_final_cleanup.py new file mode 100644 index 000000000..d97123c2a --- /dev/null +++ b/tools/patch_wheel_final_cleanup.py @@ -0,0 +1,155 @@ +from pathlib import Path +import runpy + +ffb_path = Path('src/hooks_wheel_ffb.cpp') +ui_path = Path('src/overlay/input_bindings_ui.cpp') +ini_path = Path('OutRun2006Tweaks.ini') + +ffb = ffb_path.read_text(encoding='utf-8') +ui = ui_path.read_text(encoding='utf-8') +ini = ini_path.read_text(encoding='utf-8') + + +def rep(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f'{label}: expected exactly one match, got {count}') + print(f'cleanup patched: {label}') + return text.replace(old, new, 1) + + +# 1) The multi-device binding screen still inherited the upstream gamepad reset +# value of 20%. On a 270-degree wheel that is roughly +/-27 degrees around +# centre, so Reset to default must preserve the wheel-branch 0% baseline. +ui = rep( + ui, + 'Settings::SteeringDeadZone = 0.2f;', + 'Settings::SteeringDeadZone = 0.0f;', + 'Reset to default keeps zero wheel deadzone') + + +# 2) The shipped values had already changed to the new multi-device input path, +# but the adjacent comments still told wheel users to leave UseNewInput false. +# Keep the documentation aligned with the actual hybrid architecture: SDL owns +# multi-device input, while the custom DirectInput COM engine remains the sole +# FFB owner. Legacy DirectInput remains an explicit fallback. +old_comment = '''# Enables new SDL-based input system +# Allowing game to see full trigger range without any shared trigger axes issues +# (experimental, not every menu/gamemode has been tested with it yet) +# Leave false for steering wheels. WheelInputCompatibility also enforces this +# after user.ini and command-line overrides are read. +UseNewInput = true +''' +new_comment = '''# Enables the multi-device SDL input system with raw joystick bindings. +# This is the wheel-build default; force feedback remains on the custom +# DirectInput COM engine. With an attached FFB wheel, automatic backend +# selection prefers SDL DirectInput. Set false only when deliberately using +# WheelInputCompatibility as the legacy input fallback. +UseNewInput = true +''' +ini = rep(ini, old_comment, new_comment, 'document the multi-device wheel default') + + +# 3) A direction-test button can be visible while the overlay is opened from a +# non-gameplay screen. Previously the request stayed queued and could fire on +# the first race frame. Reject such requests immediately, and also clear any +# pending test whenever the engine resets its gameplay signal state. +old_request = ''' void request_direction_test(int direction) + { + if (direction == 0) + { + manualTestFrames_ = 0; + if (initialized_ && !panicStopped_) + set_constant_force(0); + return; + } + manualTestDirection_ = direction < 0 ? -1 : 1; + manualTestFrames_ = 18; + spdlog::info( + "WheelFFB: queued safe {} direction test at fixed 20% output", + manualTestDirection_ < 0 ? "left" : "right"); + } +''' +new_request = ''' void request_direction_test(int direction) + { + if (direction == 0) + { + manualTestFrames_ = 0; + manualTestDirection_ = 1; + if (initialized_ && !panicStopped_) + set_constant_force(0); + return; + } + + const bool inGameplay = + Game::current_mode && (*Game::current_mode == STATE_GAME); + if (!inGameplay) + { + manualTestFrames_ = 0; + manualTestDirection_ = 1; + spdlog::warn( + "WheelFFB: ignored direction test outside gameplay; no torque was queued"); + return; + } + + manualTestDirection_ = direction < 0 ? -1 : 1; + manualTestFrames_ = 18; + spdlog::info( + "WheelFFB: queued safe {} direction test at fixed 20% output", + manualTestDirection_ < 0 ? "left" : "right"); + } +''' +ffb = rep(ffb, old_request, new_request, 'direction test is gameplay-only') + +old_reset = ''' splashTimer_ = 0; + splashAmp_ = 0.0f; + + // Menu/race transitions must not reuse samples from the previous +''' +new_reset = ''' splashTimer_ = 0; + splashAmp_ = 0.0f; + manualTestFrames_ = 0; + manualTestDirection_ = 1; + + // Menu/race transitions must not reuse samples from the previous +''' +ffb = rep(ffb, old_reset, new_reset, 'signal reset clears pending direction test') + +ffb_path.write_text(ffb, encoding='utf-8') +ui_path.write_text(ui, encoding='utf-8') +ini_path.write_text(ini, encoding='utf-8') +print('Applied final wheel cleanup: reset deadzone, input docs, direction-test safety') + +# Round-4: execute five additional source-hardening passes after all prior +# transformations so the checks operate on the exact effective source. +runpy.run_path('tools/patch_wheel_round4_fivepass.py', run_name='__main__') + +# Round-5: repair the round-4 compiler regression and apply another five +# lifecycle/hotplug review passes to the exact effective source. +runpy.run_path('tools/patch_wheel_round5_fivepass.py', run_name='__main__') + +# Final compiler-specific guard discovered by the real Win32 MSVC build: avoid +# Windows min/max macro expansion in the multi-device hotplug path. +runpy.run_path('tools/patch_wheel_round5_compile_fix.py', run_name='__main__') + +# Round-6: five-pass review of lifecycle/focus races, exact device identity, +# FFB backend parity, numeric fail-safe behavior, and F11 runtime ownership. +runpy.run_path('tools/patch_wheel_round6_fivepass.py', run_name='__main__') + +# Round-7: final DD-wheel runtime safety pass. Cancel transient force-test/event +# state on focus loss, keep direction tests pure, and forbid deferred test torque. +runpy.run_path('tools/patch_wheel_round7_final_safety.py', run_name='__main__') + +# Round-8: input/key-setting hardening. Protect first-frame state, digital-axis +# aggregation, binding-file recovery, raw-axis release sequencing, and legacy +# F11 menu ownership without changing the force-feedback model. +runpy.run_path('tools/patch_wheel_round8_input_binding.py', run_name='__main__') + +# Round-9 / v0.1: install the hardware-tested MOZA R3 personal release tune. +# It adds steering resistance through spring/damping while deliberately keeping +# collision feedback below the old Simulation Balanced reference preset. +runpy.run_path('tools/patch_wheel_round9_release_v01.py', run_name='__main__') + +# Round-10: snow/ice stages use a high steady gamepad-rumble surface value. +# Attenuate only their road-texture sine so a DD wheel does not buzz continuously. +runpy.run_path('tools/patch_wheel_round10_snow_texture.py', run_name='__main__') diff --git a/tools/patch_wheel_legacy_fallback_isolation.py b/tools/patch_wheel_legacy_fallback_isolation.py new file mode 100644 index 000000000..c5bed5cdb --- /dev/null +++ b/tools/patch_wheel_legacy_fallback_isolation.py @@ -0,0 +1,42 @@ +from pathlib import Path + +# The modern multi-device hook and the legacy wheel helpers touch several of +# the same OutRun entry points (ReadIO/SwitchOn/SwitchNow/GetVolume). Runtime +# guards are not enough: installing both SafetyHook chains at startup makes +# ordering dependent on static hook registration. UseNewInput is restart-only, +# so do not install legacy hooks at all when the new path is active. + +targets = [ + 'src/hooks_wheel_input_compat_v2.hpp', + 'src/hooks_wheel_r3_menu_dpad.hpp', + 'src/hooks_wheel_r3_menu_ab.hpp', + 'src/hooks_wheel_menu_keyboard_back.hpp', + 'src/hooks_wheel_menu_keyboard_select.hpp', + 'src/hooks_wheel_legacy_blank_defaults.hpp', +] + +for name in targets: + path = Path(name) + text = path.read_text(encoding='utf-8') + old = ''' bool validate() override\n {\n return Settings::WheelInputCompatibility;\n }''' + new = ''' bool validate() override\n {\n return Settings::WheelInputCompatibility && !Settings::UseNewInput;\n }''' + count = text.count(old) + if count != 1: + raise SystemExit(f'{name}: expected one legacy validate block, found {count}') + text = text.replace(old, new, 1) + path.write_text(text, encoding='utf-8') + print(f'isolated legacy hook: {name}') + +# wheel_setup_ui uses a compact one-line validate method. +path = Path('src/overlay/wheel_setup_ui.cpp') +text = path.read_text(encoding='utf-8') +old = ' bool validate() override { return Settings::WheelInputCompatibility; }' +new = ' bool validate() override { return Settings::WheelInputCompatibility && !Settings::UseNewInput; }' +count = text.count(old) +if count != 1: + raise SystemExit(f'wheel_setup_ui: expected one universal legacy hook validate, found {count}') +text = text.replace(old, new, 1) +path.write_text(text, encoding='utf-8') +print('isolated legacy hook: wheel_setup_ui DirectInput injector') + +print('Legacy wheel hook isolation complete; new and legacy input paths no longer stack detours') diff --git a/tools/patch_wheel_r3_menu_device_guid.py b/tools/patch_wheel_r3_menu_device_guid.py new file mode 100644 index 000000000..240b59e72 --- /dev/null +++ b/tools/patch_wheel_r3_menu_device_guid.py @@ -0,0 +1,83 @@ +from pathlib import Path + + +def patch(path, lowerfn, keyfn, prefix): + p=Path(path); s=p.read_text() + def r(a,b,n): + nonlocal s + c=s.count(a) + if c != 1: raise SystemExit(f'{prefix} {n}: {c}') + s=s.replace(a,b,1); print('patched',prefix,n) + + r('#include \n#include \n','#include \n#include \n#include \n','cstdio') + r('''namespace Settings\n{\n extern Setting WheelUniversalSetupEnable;\n''','''namespace Settings\n{\n extern Setting WheelUniversalSetupEnable;\n extern Setting WheelFFBDeviceGuid;\n''','extern guid') + r(''' bool found = false;\n bool ignoreName = false;\n''',''' bool found = false;\n bool ignoreName = false;\n bool matchGuidOnly = false;\n''','ctx') + + old=f''' static std::string {lowerfn}(const char* text)\n {{\n std::string result = text ? text : "";\n std::transform(result.begin(), result.end(), result.begin(),\n [](unsigned char c) {{ return static_cast(std::tolower(c)); }});\n return result;\n }}\n''' + new=old+f'''\n static std::string {keyfn}(const GUID& guid)\n {{\n char b[64]{{}};\n std::snprintf(b, sizeof(b),\n "%08X-%04X-%04X-%02X%02X-%02X%02X%02X%02X%02X%02X",\n (unsigned)guid.Data1, (unsigned)guid.Data2, (unsigned)guid.Data3,\n (unsigned)guid.Data4[0], (unsigned)guid.Data4[1],\n (unsigned)guid.Data4[2], (unsigned)guid.Data4[3],\n (unsigned)guid.Data4[4], (unsigned)guid.Data4[5],\n (unsigned)guid.Data4[6], (unsigned)guid.Data4[7]);\n return {lowerfn}(b);\n }}\n''' + r(old,new,'guid helper') + + old=f''' const std::string wanted = {lowerfn}(Settings::WheelMenuR3DeviceName.get().c_str());\n const std::string instanceName = {lowerfn}(instance->tszInstanceName);\n const std::string productName = {lowerfn}(instance->tszProductName);\n\n if (!ctx->ignoreName && !wanted.empty() &&\n''' + new=f''' const std::string wanted = {lowerfn}(Settings::WheelMenuR3DeviceName.get().c_str());\n const std::string wantedGuid = {lowerfn}(Settings::WheelFFBDeviceGuid.get().c_str());\n const std::string instanceName = {lowerfn}(instance->tszInstanceName);\n const std::string productName = {lowerfn}(instance->tszProductName);\n\n if (ctx->matchGuidOnly)\n {{\n if (wantedGuid.empty() || {keyfn}(instance->guidInstance) != wantedGuid)\n return DIENUM_CONTINUE;\n }}\n else if (!ctx->ignoreName && !wanted.empty() &&\n''' + r(old,new,'callback') + + old=( + ' EnumContext ctx{};\n' + ' hr = directInput->EnumDevices(\n' + ' DI8DEVCLASS_GAMECTRL,\n' + ' enumDevicesCallback,\n' + ' &ctx,\n' + ' DIEDFL_ATTACHEDONLY | DIEDFL_FORCEFEEDBACK);\n' + ' if (SUCCEEDED(hr) && !ctx.found &&\n' + f' {lowerfn}(Settings::WheelMenuR3DeviceName.get().c_str()) == "moza")\n' + ' {\n' + ' ctx.ignoreName = true;\n' + ' spdlog::warn(\n' + f' "{prefix}: no literal MOZA name; trying first attached FFB wheel");\n' + ' hr = directInput->EnumDevices(\n' + ' DI8DEVCLASS_GAMECTRL,\n' + ' enumDevicesCallback,\n' + ' &ctx,\n' + ' DIEDFL_ATTACHEDONLY | DIEDFL_FORCEFEEDBACK);\n' + ' }\n' + ) + new=( + ' EnumContext ctx{};\n' + ' const std::string configuredGuid =\n' + f' {lowerfn}(Settings::WheelFFBDeviceGuid.get().c_str());\n' + ' if (!configuredGuid.empty())\n' + ' {\n' + ' ctx.matchGuidOnly = true;\n' + ' hr = directInput->EnumDevices(\n' + ' DI8DEVCLASS_GAMECTRL, enumDevicesCallback, &ctx,\n' + ' DIEDFL_ATTACHEDONLY | DIEDFL_FORCEFEEDBACK);\n' + ' if (SUCCEEDED(hr) && !ctx.found)\n' + ' {\n' + ' spdlog::warn(\n' + f' "{prefix}: saved FFB GUID not attached; falling back to menu device name");\n' + ' ctx = {};\n' + ' }\n' + ' }\n\n' + ' if (!ctx.found)\n' + ' {\n' + ' hr = directInput->EnumDevices(\n' + ' DI8DEVCLASS_GAMECTRL, enumDevicesCallback, &ctx,\n' + ' DIEDFL_ATTACHEDONLY | DIEDFL_FORCEFEEDBACK);\n' + ' }\n' + ' if (SUCCEEDED(hr) && !ctx.found &&\n' + f' {lowerfn}(Settings::WheelMenuR3DeviceName.get().c_str()) == "moza")\n' + ' {\n' + ' ctx.ignoreName = true;\n' + ' spdlog::warn(\n' + f' "{prefix}: no literal MOZA name; trying first attached FFB wheel");\n' + ' hr = directInput->EnumDevices(\n' + ' DI8DEVCLASS_GAMECTRL, enumDevicesCallback, &ctx,\n' + ' DIEDFL_ATTACHEDONLY | DIEDFL_FORCEFEEDBACK);\n' + ' }\n' + ) + r(old,new,'guid-first selection') + p.write_text(s) + +patch('src/hooks_wheel_r3_menu_dpad.hpp','r3_lower_copy','r3DPadGuidKey','WheelMenuR3DirectDPad') +patch('src/hooks_wheel_r3_menu_ab.hpp','lowerCopy','r3ABGuidKey','WheelMenuR3DirectAB') +print('Applied exact FFB GUID preference to fixed R3 menu readers') diff --git a/tools/patch_wheel_setup_device_guid.py b/tools/patch_wheel_setup_device_guid.py new file mode 100644 index 000000000..a53841411 --- /dev/null +++ b/tools/patch_wheel_setup_device_guid.py @@ -0,0 +1,23 @@ +from pathlib import Path +p=Path('src/overlay/wheel_setup_ui.cpp'); s=p.read_text() + +def r(a,b,n): + global s + c=s.count(a) + if c!=1: raise SystemExit(f'{n}: {c}') + s=s.replace(a,b,1); print('patched',n) + +r(''' extern Setting WheelFFBDeviceName;\n''',''' extern Setting WheelFFBDeviceName;\n extern Setting WheelFFBDeviceGuid;\n''','extern') +r(''' Setting WheelUniversalDeviceName{\n "Controls", "WheelUniversalDeviceName", "",\n "DirectInput product name selected by F11 Wheel Setup."\n };\n''',''' Setting WheelUniversalDeviceName{\n "Controls", "WheelUniversalDeviceName", "",\n "DirectInput product name selected by F11 Wheel Setup."\n };\n\n Setting WheelUniversalDeviceGuid{\n "Controls", "WheelUniversalDeviceGuid", "",\n "Exact DirectInput instance GUID selected by F11 Wheel Setup."\n };\n''','setting') +r(''' bool is_virtual_name(const std::string& name)\n''',''' std::string lower_identity(std::string value)\n {\n std::transform(value.begin(), value.end(), value.begin(),\n [](unsigned char c) { return static_cast(std::tolower(c)); });\n return value;\n }\n\n std::string wheel_guid_key(const GUID& guid)\n {\n char b[64]{};\n std::snprintf(b, sizeof(b),\n "%08X-%04X-%04X-%02X%02X-%02X%02X%02X%02X%02X%02X",\n (unsigned)guid.Data1, (unsigned)guid.Data2, (unsigned)guid.Data3,\n (unsigned)guid.Data4[0], (unsigned)guid.Data4[1],\n (unsigned)guid.Data4[2], (unsigned)guid.Data4[3],\n (unsigned)guid.Data4[4], (unsigned)guid.Data4[5],\n (unsigned)guid.Data4[6], (unsigned)guid.Data4[7]);\n return lower_identity(b);\n }\n\n bool is_virtual_name(const std::string& name)\n''','helpers') +r(''' GUID guid{};\n std::string name;\n''',''' GUID guid{};\n std::string guidKey;\n std::string name;\n''','device key') +r(''' bool select_by_name(const std::string& wanted)\n {\n if (wanted == selectedName_ && device_)\n return true;\n\n release_device();\n if (devices_.empty())\n enumerate();\n\n int index = -1;\n for (size_t i = 0; i < devices_.size(); ++i)\n {\n if (devices_[i].name == wanted)\n {\n index = int(i);\n break;\n }\n }\n if (index < 0 && wanted.empty() && !devices_.empty())\n index = 0;\n if (index < 0)\n return false;\n return open(index);\n }\n''',''' bool select_by_identity(const std::string& wantedGuid, const std::string& wantedName)\n {\n const std::string guid = lower_identity(wantedGuid);\n if (device_ && !guid.empty() && guid == selectedGuidKey_) return true;\n if (device_ && guid.empty() && wantedName == selectedName_) return true;\n\n release_device();\n if (devices_.empty()) enumerate();\n int index = -1;\n if (!guid.empty())\n for (size_t i=0; i= 0 ? open(index) : false;\n }\n''','identity select') +r(''' if (!select_by_name(Settings::WheelUniversalDeviceName.get()))\n return false;\n''',''' if (!select_by_identity(\n Settings::WheelUniversalDeviceGuid.get(),\n Settings::WheelUniversalDeviceName.get()))\n return false;\n''','poll identity') +r(''' selectedName_.clear();\n state_ = {};\n''',''' selectedName_.clear();\n selectedGuidKey_.clear();\n state_ = {};\n''','clear key') +r(''' DeviceInfo info{};\n info.guid = instance->guidInstance;\n info.name = product;\n''',''' DeviceInfo info{};\n info.guid = instance->guidInstance;\n info.guidKey = wheel_guid_key(info.guid);\n info.name = product;\n''','enum key') +r(''' device_ = device;\n selectedName_ = devices_[index].name;\n return true;\n''',''' device_ = device;\n selectedName_ = devices_[index].name;\n selectedGuidKey_ = devices_[index].guidKey;\n return true;\n''','open key') +r(''' std::vector devices_;\n std::string selectedName_;\n DIJOYSTATE2 state_{};\n''',''' std::vector devices_;\n std::string selectedName_;\n std::string selectedGuidKey_;\n DIJOYSTATE2 state_{};\n''','state') +r(''' Settings::WheelUniversalDeviceName = info.name;\n Settings::WheelFFBDeviceName = info.name;\n gReader.select_by_name(info.name);\n''',''' Settings::WheelUniversalDeviceName = info.name;\n Settings::WheelUniversalDeviceGuid = info.guidKey;\n Settings::WheelFFBDeviceName = info.name;\n Settings::WheelFFBDeviceGuid = info.guidKey;\n gReader.select_by_identity(info.guidKey, info.name);\n''','select store') +r(''' if (ImGui::BeginCombo("Wheel", preview.c_str()))\n {\n for (size_t i = 0; i < devices.size(); ++i)\n {\n const bool selected = devices[i].name == Settings::WheelUniversalDeviceName.get();\n if (ImGui::Selectable(devices[i].name.c_str(), selected))\n select_device(devices[i]);\n if (selected) ImGui::SetItemDefaultFocus();\n }\n ImGui::EndCombo();\n }\n\n if (!devices.empty())\n {\n for (const auto& dev : devices)\n if (dev.name == Settings::WheelUniversalDeviceName.get())\n ImGui::TextDisabled("%lu axes / %lu buttons / %lu POV / FFB %s",\n dev.axes, dev.buttons, dev.povs, dev.ffb ? "yes" : "no");\n }\n''',''' const std::string configuredGuid =\n lower_identity(Settings::WheelUniversalDeviceGuid.get());\n auto isSelectedDevice = [&](const DeviceInfo& dev)\n {\n return !configuredGuid.empty()\n ? dev.guidKey == configuredGuid\n : dev.name == Settings::WheelUniversalDeviceName.get();\n };\n if (ImGui::BeginCombo("Wheel", preview.c_str()))\n {\n for (size_t i = 0; i < devices.size(); ++i)\n {\n const bool selected = isSelectedDevice(devices[i]);\n const std::string label = devices[i].name + "##" + devices[i].guidKey;\n if (ImGui::Selectable(label.c_str(), selected)) select_device(devices[i]);\n if (selected) ImGui::SetItemDefaultFocus();\n }\n ImGui::EndCombo();\n }\n for (const auto& dev : devices)\n if (isSelectedDevice(dev))\n ImGui::TextDisabled("%lu axes / %lu buttons / %lu POV / FFB %s / GUID %s",\n dev.axes, dev.buttons, dev.povs, dev.ffb ? "yes" : "no", dev.guidKey.c_str());\n''','combo') +p.write_text(s) +print('Applied exact DirectInput GUID identity to F11 wheel setup') diff --git a/tools/patch_wheel_setup_ffb_panel.py b/tools/patch_wheel_setup_ffb_panel.py new file mode 100644 index 000000000..d9e700633 --- /dev/null +++ b/tools/patch_wheel_setup_ffb_panel.py @@ -0,0 +1,31 @@ +from pathlib import Path + +path = Path("src/overlay/wheel_setup_ui.cpp") +text = path.read_text(encoding="utf-8") + +old = ''' extern Setting WheelFFBDeviceName;\n\n Setting WheelUniversalSetupEnable{\n''' +new = ''' extern Setting WheelFFBDeviceName;\n extern Setting WheelMenuR3DirectDPad;\n extern Setting WheelMenuR3DirectAB;\n extern Setting WheelFFBGlobalStrength;\n extern Setting WheelFFBSpringStrength;\n extern Setting WheelFFBDamperStrength;\n extern Setting WheelFFBSteeringWeight;\n extern Setting WheelFFBGripLoss;\n extern Setting WheelFFBLowSpeedSpring;\n extern Setting WheelFFBSpringLoadBoost;\n extern Setting WheelFFBRoadTexture;\n extern Setting WheelFFBTireSlip;\n extern Setting WheelFFBWallImpact;\n extern Setting WheelFFBUseHardwareSpring;\n extern Setting WheelFFBUseHardwareDamper;\n\n Setting WheelUniversalSetupEnable{\n''' +if old not in text: + raise SystemExit("FFB extern insertion point not found") +text = text.replace(old, new, 1) + +old = ''' ImGui::Checkbox("Enable F11 universal wheel profile", Settings::WheelUniversalSetupEnable.ptr());\n if (ImGui::IsItemHovered())\n ImGui::SetTooltip("When enabled, these bindings are written into OutRun's original legacy DirectInput device mapping.");\n''' +new = ''' if (ImGui::Checkbox("Enable F11 universal wheel profile", Settings::WheelUniversalSetupEnable.ptr()))\n {\n if (Settings::WheelUniversalSetupEnable)\n {\n // The old R3-specific readers assume fixed button numbers.\n // Once a universal profile is active, its own bindings must\n // be the sole wheel-menu source so Logitech/Thrustmaster/\n // Fanatec/Simagic/etc. do not inherit R3 button mappings.\n Settings::WheelMenuR3DirectDPad = false;\n Settings::WheelMenuR3DirectAB = false;\n status_ = "Universal profile enabled; legacy R3 fixed-button menu helpers were disabled.";\n }\n }\n if (ImGui::IsItemHovered())\n ImGui::SetTooltip("When enabled, these bindings are written into OutRun's original legacy DirectInput device mapping.");\n''' +if old not in text: + raise SystemExit("universal profile checkbox insertion point not found") +text = text.replace(old, new, 1) + +old = ''' ImGui::SeparatorText("Wheel options");\n int deadzonePercent = int(float(Settings::SteeringDeadZone) * 100.0f + 0.5f);\n''' +new = ''' ImGui::SeparatorText("Simulation FFB");\n ImGui::TextWrapped(\n "Aligning force grows strongly with speed and corner load, unloads with grip loss, and GUID_Damper resists steering velocity without acting like a centering spring.");\n\n ImGui::SliderFloat("Overall Strength", Settings::WheelFFBGlobalStrength.ptr(), 0.0f, 1.0f, "%.2f");\n ImGui::SliderFloat("Aligning / Spring", Settings::WheelFFBSpringStrength.ptr(), 0.0f, 1.0f, "%.2f");\n ImGui::SliderFloat("Low-speed Aligning", Settings::WheelFFBLowSpeedSpring.ptr(), 0.0f, 0.30f, "%.2f");\n ImGui::SliderFloat("Corner-load Boost", Settings::WheelFFBSpringLoadBoost.ptr(), 0.0f, 0.80f, "%.2f");\n ImGui::SliderFloat("Dynamic Damping", Settings::WheelFFBDamperStrength.ptr(), 0.0f, 0.80f, "%.2f");\n ImGui::SliderFloat("Lateral / Steering Weight", Settings::WheelFFBSteeringWeight.ptr(), 0.0f, 1.0f, "%.2f");\n ImGui::SliderFloat("Grip-loss Unload", Settings::WheelFFBGripLoss.ptr(), 0.0f, 1.0f, "%.2f");\n ImGui::SliderFloat("Road Detail", Settings::WheelFFBRoadTexture.ptr(), 0.0f, 0.50f, "%.2f");\n ImGui::SliderFloat("Tire Slip", Settings::WheelFFBTireSlip.ptr(), 0.0f, 0.50f, "%.2f");\n ImGui::SliderFloat("Collision", Settings::WheelFFBWallImpact.ptr(), 0.0f, 1.0f, "%.2f");\n ImGui::Checkbox("Hardware GUID_Spring", Settings::WheelFFBUseHardwareSpring.ptr());\n ImGui::SameLine();\n ImGui::Checkbox("Hardware GUID_Damper", Settings::WheelFFBUseHardwareDamper.ptr());\n\n if (ImGui::Button("Load simulation baseline"))\n {\n Settings::WheelFFBGlobalStrength = 0.50f;\n Settings::WheelFFBSpringStrength = 0.45f;\n Settings::WheelFFBLowSpeedSpring = 0.08f;\n Settings::WheelFFBSpringLoadBoost = 0.35f;\n Settings::WheelFFBDamperStrength = 0.35f;\n Settings::WheelFFBSteeringWeight = 0.40f;\n Settings::WheelFFBGripLoss = 0.65f;\n Settings::WheelFFBRoadTexture = 0.12f;\n Settings::WheelFFBTireSlip = 0.15f;\n Settings::WheelFFBWallImpact = 0.50f;\n Settings::WheelFFBUseHardwareSpring = true;\n Settings::WheelFFBUseHardwareDamper = true;\n status_ = "Loaded simulation baseline. Save the wheel profile after testing.";\n }\n\n ImGui::SeparatorText("Wheel options");\n int deadzonePercent = int(float(Settings::SteeringDeadZone) * 100.0f + 0.5f);\n''' +if old not in text: + raise SystemExit("FFB panel insertion point not found") +text = text.replace(old, new, 1) + +old = ''' ImGui::TextDisabled(\n "FFB tuning remains under Settings > WheelFFB. Wheel selection is shared with WheelFFB DeviceName; restart after switching to a different physical wheel.");\n''' +new = ''' ImGui::TextDisabled(\n "Wheel selection is shared with WheelFFB DeviceName. Restart after switching to a different physical wheel; strength sliders update the live FFB calculations immediately where supported.");\n''' +if old not in text: + raise SystemExit("footer replacement point not found") +text = text.replace(old, new, 1) + +path.write_text(text, encoding="utf-8") +print("Added simulation FFB panel and universal-profile menu handoff") diff --git a/tools/patch_wheel_setup_reader_hardening.py b/tools/patch_wheel_setup_reader_hardening.py new file mode 100644 index 000000000..e580c41ae --- /dev/null +++ b/tools/patch_wheel_setup_reader_hardening.py @@ -0,0 +1,80 @@ +from pathlib import Path + +path = Path("src/overlay/wheel_setup_ui.cpp") +text = path.read_text(encoding="utf-8") + + +def replace_once(old: str, new: str, label: str) -> None: + global text + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected exactly one match, found {count}") + text = text.replace(old, new, 1) + print(f"patched: {label}") + + +# Never silently bind a saved wheel profile to a different device merely because +# the original wheel is absent. Empty selection may still intentionally choose +# the first attached device for first-run convenience. +replace_once( +''' int index = -1;\n for (size_t i = 0; i < devices_.size(); ++i)\n {\n if (devices_[i].name == wanted)\n {\n index = int(i);\n break;\n }\n }\n if (index < 0 && !devices_.empty())\n index = 0;\n if (index < 0)\n return false;\n return open(index);\n''', +''' int index = -1;\n for (size_t i = 0; i < devices_.size(); ++i)\n {\n if (devices_[i].name == wanted)\n {\n index = int(i);\n break;\n }\n }\n if (index < 0 && wanted.empty() && !devices_.empty())\n index = 0;\n if (index < 0)\n return false;\n return open(index);\n''', + "strict saved universal-wheel selection", +) + +# A disconnected/replugged DirectInput object can stay permanently stale. On +# final poll/state failure throw away both the handle and cached enumeration so +# the next frame performs a fresh EnumDevices instead of retrying a dead GUID. +replace_once( +''' HRESULT hr = device_->Poll();\n if (FAILED(hr))\n hr = device_->Acquire();\n if (FAILED(hr))\n return false;\n\n hr = device_->GetDeviceState(sizeof(state_), &state_);\n if (hr == DIERR_INPUTLOST || hr == DIERR_NOTACQUIRED)\n {\n if (FAILED(device_->Acquire()))\n return false;\n hr = device_->GetDeviceState(sizeof(state_), &state_);\n }\n return SUCCEEDED(hr);\n''', +''' HRESULT hr = device_->Poll();\n if (FAILED(hr))\n {\n hr = device_->Acquire();\n if (SUCCEEDED(hr))\n hr = device_->Poll();\n }\n if (FAILED(hr))\n {\n release_device();\n devices_.clear();\n return false;\n }\n\n hr = device_->GetDeviceState(sizeof(state_), &state_);\n if (hr == DIERR_INPUTLOST || hr == DIERR_NOTACQUIRED)\n {\n if (SUCCEEDED(device_->Acquire()))\n {\n device_->Poll();\n hr = device_->GetDeviceState(sizeof(state_), &state_);\n }\n }\n if (FAILED(hr))\n {\n release_device();\n devices_.clear();\n return false;\n }\n return true;\n''', + "universal reader stale-handle recovery", +) + +# Virtual devices can advertise a harmless-looking product name while their +# instance name still says vJoy/ViGEm/etc. Reject either form. +replace_once( +''' const std::string product = instance->tszProductName;\n if (is_virtual_name(product))\n return DIENUM_CONTINUE;\n\n DeviceInfo info{};\n''', +''' const std::string product = instance->tszProductName;\n const std::string instanceName = instance->tszInstanceName;\n if (is_virtual_name(product) || is_virtual_name(instanceName))\n return DIENUM_CONTINUE;\n\n DeviceInfo info{};\n''', + "universal reader virtual-instance filter", +) + +# Do not retain a half-open device if Acquire itself fails. +replace_once( +''' device->Acquire();\n device_ = device;\n selectedName_ = devices_[index].name;\n return true;\n''', +''' hr = device->Acquire();\n if (FAILED(hr) && hr != S_FALSE)\n {\n device->Release();\n return false;\n }\n device_ = device;\n selectedName_ = devices_[index].name;\n return true;\n''', + "universal reader Acquire validation", +) + +# The UI's filtered enumeration index is not guaranteed to equal OutRun's +# internal legacy-device slot (virtual/non-wheel devices may be omitted here). +# Expose the original-game slot explicitly and never overwrite it merely by +# choosing a reader/FFB device from the combo. +replace_once( +''' static bool copy_current_mapping()\n {\n''', +''' static int regular_device_count()\n {\n return std::max(device_count() - 1, 0);\n }\n\n static bool copy_current_mapping()\n {\n''', + "expose regular legacy slot count", +) + +replace_once( +''' void select_device(const DeviceInfo& info, int index)\n {\n Settings::WheelUniversalDeviceName = info.name;\n Settings::WheelUniversalLegacyDeviceIndex = std::clamp(index, 0, 2);\n Settings::WheelFFBDeviceName = info.name;\n gReader.select_by_name(info.name);\n status_ = "Selected wheel; FFB device will follow this product name.";\n }\n''', +''' void select_device(const DeviceInfo& info)\n {\n Settings::WheelUniversalDeviceName = info.name;\n Settings::WheelFFBDeviceName = info.name;\n gReader.select_by_name(info.name);\n status_ = UniversalWheelProfile::regular_device_count() > 1\n ? "Selected wheel/FFB device. Confirm the OutRun legacy input slot below when multiple controllers are present."\n : "Selected wheel; FFB device follows this product name.";\n }\n''', + "decouple UI device index from game slot", +) +replace_once( +''' if (ImGui::Selectable(devices[i].name.c_str(), selected))\n select_device(devices[i], int(i));\n''', +''' if (ImGui::Selectable(devices[i].name.c_str(), selected))\n select_device(devices[i]);\n''', + "wheel combo select signature", +) + +# Place the explicit slot selector beside the selected-device capabilities. A +# single regular controller is unambiguous and is kept on slot 1 automatically. +needle = ''' if (!devices.empty())\n {\n for (const auto& dev : devices)\n if (dev.name == Settings::WheelUniversalDeviceName.get())\n ImGui::TextDisabled("%lu axes / %lu buttons / %lu POV / FFB %s",\n dev.axes, dev.buttons, dev.povs, dev.ffb ? "yes" : "no");\n }\n\n if (ImGui::Checkbox("Enable F11 universal wheel profile", Settings::WheelUniversalSetupEnable.ptr()))\n''' +replacement = ''' if (!devices.empty())\n {\n for (const auto& dev : devices)\n if (dev.name == Settings::WheelUniversalDeviceName.get())\n ImGui::TextDisabled("%lu axes / %lu buttons / %lu POV / FFB %s",\n dev.axes, dev.buttons, dev.povs, dev.ffb ? "yes" : "no");\n }\n\n const int regularSlots = UniversalWheelProfile::regular_device_count();\n if (regularSlots == 1)\n Settings::WheelUniversalLegacyDeviceIndex = 0;\n if (regularSlots > 0)\n {\n int currentSlot = std::clamp(\n int(Settings::WheelUniversalLegacyDeviceIndex), 0,\n std::min(regularSlots - 1, 2));\n const std::string slotPreview =\n "OutRun slot " + std::to_string(currentSlot + 1);\n if (ImGui::BeginCombo("Legacy input slot", slotPreview.c_str()))\n {\n for (int slot = 0; slot < std::min(regularSlots, 3); ++slot)\n {\n const std::string label =\n "OutRun slot " + std::to_string(slot + 1);\n const bool selected = slot == currentSlot;\n if (ImGui::Selectable(label.c_str(), selected))\n Settings::WheelUniversalLegacyDeviceIndex = slot;\n if (selected)\n ImGui::SetItemDefaultFocus();\n }\n ImGui::EndCombo();\n }\n if (ImGui::IsItemHovered())\n ImGui::SetTooltip(\n "This is OutRun's original DirectInput slot, not the filtered Wheel combo index. Usually slot 1 when only one wheel is connected.");\n }\n\n if (ImGui::Checkbox("Enable F11 universal wheel profile", Settings::WheelUniversalSetupEnable.ptr()))\n''' +if text.count(needle) != 1: + raise SystemExit("legacy slot UI insertion point not found exactly once") +text = text.replace(needle, replacement, 1) +print("patched: explicit legacy input slot UI") + +path.write_text(text, encoding="utf-8") +print("Applied universal wheel reader hardening and explicit legacy-slot selection") diff --git a/tools/patch_wheel_setup_ui_compile.py b/tools/patch_wheel_setup_ui_compile.py new file mode 100644 index 000000000..4d9879237 --- /dev/null +++ b/tools/patch_wheel_setup_ui_compile.py @@ -0,0 +1,25 @@ +from pathlib import Path + +path = Path("src/overlay/wheel_setup_ui.cpp") +text = path.read_text(encoding="utf-8") + +text = text.replace( +'''#include \n#include \n#include \n#include \n#include \n#include \n#include \n''', +'''#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n''', +1) + +text = text.replace('axis_name(axisSetting)', 'axis_name(int(axisSetting))') + +# wheel_setup_ui.cpp is outside namespace Settings here; qualify the template +# type explicitly so MSVC does not parse Setting as an unknown template. +text = text.replace('Setting& axisSetting', 'Settings::Setting& axisSetting') +text = text.replace('Setting& setting', 'Settings::Setting& setting') + +old = ''' void assign_digital(int code)\n {\n switch (target_)\n''' +new = ''' void assign_digital(int code)\n {\n const bool gameplayButton =\n target_ == BindTarget::GearUp || target_ == BindTarget::GearDown ||\n target_ == BindTarget::Start || target_ == BindTarget::View;\n if (gameplayButton && code >= 128)\n {\n target_ = BindTarget::None;\n status_ = "Gameplay actions currently require a physical button; POV is supported for menu directions.";\n return;\n }\n\n switch (target_)\n''' +if old not in text: + raise SystemExit("assign_digital insertion point not found") +text = text.replace(old, new, 1) + +path.write_text(text, encoding="utf-8") +print("Patched universal wheel setup UI compile details") diff --git a/tools/test_wheel_ffb_current.cpp b/tools/test_wheel_ffb_current.cpp new file mode 100644 index 000000000..65508f544 --- /dev/null +++ b/tools/test_wheel_ffb_current.cpp @@ -0,0 +1,97 @@ +// Host-only tests execute production headers. No Windows/DirectInput device needed. +#include +#include +#include +#include +#include +#include +struct D3DVECTOR { float x=0, y=0, z=0; }; +struct D3DMATRIX { float _11=1,_12=0,_13=0,_14=0,_21=0,_22=1,_23=0,_24=0,_31=0,_32=0,_33=1; }; +struct EVWORK_CAR { D3DVECTOR position_14, spd_mb_20; D3DMATRIX matrix_70; }; +#include "hooks_wheel_vehicle_dynamics.hpp" +#include "wheel_ffb_math.hpp" +static int checks = 0; +void require(bool b, const char* msg) { ++checks; if (!b) { std::cerr << msg << '\n'; std::exit(1); } } +void heading(EVWORK_CAR& c, float a) { c.matrix_70._11=std::cos(a);c.matrix_70._13=-std::sin(a);c.matrix_70._31=std::sin(a);c.matrix_70._33=std::cos(a); } +void step(WheelVehicleDynamics& d, EVWORK_CAR& c, float a=0, float beta=0, float steer=0, float speed=.5f) { + heading(c,a);c.position_14.x+=std::sin(a+beta);c.position_14.z+=std::cos(a+beta);d.update(&c,steer,speed,0); +} +int main() { + using namespace WheelFFBMath; + auto engineIdle=estimate_engine_haptics(0.0f,0,0.0f); + require(engineIdle.rpmNorm>=.08f&&engineIdle.rpmNorm<.20f,"engine idle RPM estimate"); + require(engineIdle.frequencyHz>=13.0f&&engineIdle.frequencyHz<16.0f,"engine idle haptic frequency"); + auto engineFree=estimate_engine_haptics(0.0f,0,1.0f); + require(engineFree.rpmNorm>engineIdle.rpmNorm&&engineFree.frequencyHz>engineIdle.frequencyHz,"free-rev haptic rises with throttle"); + auto engineGear1=estimate_engine_haptics(.15f,1,.5f); + auto engineGear2=estimate_engine_haptics(.15f,2,.5f); + require(engineGear1.rpmNorm>engineGear2.rpmNorm,"upshift lowers estimated RPM at equal road speed"); + require(engineGear1.frequencyHz<=24.0001f&&engineGear1.amplitudeScale<=1.0001f,"engine haptic bounded"); + require(estimate_engine_haptics(std::numeric_limits::quiet_NaN(),99,std::numeric_limits::quiet_NaN()).frequencyHz>=13.0f,"engine haptic rejects non-finite inputs"); + require(pneumatic_sat_shape(0)==0,"SAT zero"); + require(pneumatic_sat_shape(std::numeric_limits::quiet_NaN())==0,"SAT NaN"); + require(lateral_force_shape(.32f)>.999f,"Fy proxy saturates in deep slip"); + require(pneumatic_sat_shape(.12f)>.90f,"pneumatic SAT strong in normal loaded corner"); + require(pneumatic_sat_shape(.16f)>.98f,"pneumatic SAT peaks near prior 0.16rad region"); + require(pneumatic_sat_shape(.32f)<.50f,"pneumatic trail falls in deep understeer"); + require(combined_sat_shape(.16f,.25f)<=1.000001f,"combined SAT bounded"); + require(mechanical_sat_shape(.12f,.25f)>0.10f,"mechanical trail acts in normal loaded corner"); + require(mechanical_sat_shape(.32f,.25f)>mechanical_sat_shape(.12f,.25f),"mechanical term follows front lateral force"); + require(combined_sat_shape(.32f,.25f)>pneumatic_sat_shape(.32f),"total trail preserves deep-slip torque"); + require(combined_sat_shape(.32f,0.0f)==pneumatic_sat_shape(.32f),"mechanical trail zero is pure pneumatic"); + require(std::abs(combined_sat_shape(.32f,.25f)-combined_sat_shape(-.32f,.25f))<1e-6f,"SAT shape symmetry"); + require(pneumatic_sat_shape(.20f,.28f)pneumatic_sat_shape(.20f,.20f),"phase-led recovering slip restores pneumatic trail sooner"); + for(int i=0;i<=7000;++i) {float a=i*.0001f;float p=pneumatic_sat_shape(a),c=combined_sat_shape(a,.25f);require(std::isfinite(p)&&p>=0&&p<=1.000001f,"pneumatic bounds");require(std::isfinite(c)&&c>=0&&c<=1.000001f,"combined bounds");} + require(soft_saturate(.5f)==.5f,"soft clip linear midrange"); + require(std::abs(soft_saturate(-.5f)+.5f)<1e-6,"soft clip symmetry"); + require(soft_saturate(1.0f)>.90f&&soft_saturate(1.0f)<1.0f,"soft clip late knee"); + require(soft_saturate(2.0f)==1.0f&&soft_saturate(-2.0f)==-1.0f,"soft clip cap"); + float clipPrev=0; for(int i=0;i<=2000;++i){float x=i*.001f,y=soft_saturate(x);require(std::isfinite(y)&&y>=clipPrev-1e-6f&&y<=1.000001f,"soft clip monotonic");clipPrev=y;} + require(physics_return_relief(.15f,-.08f)==.85f,"countersteer relief"); + require(physics_return_relief(.15f,.08f)==1,"opposing work no relief"); + ResponseLUT linear{}; require(parse_response_lut("0,0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.8,0.9,1",linear),"linear LUT parses"); + require(std::abs(apply_response_lut(.55f,linear)-.55f)<1e-5f,"linear LUT identity"); + ResponseLUT boosted{}; require(parse_response_lut("0,0.15,0.25,0.35,0.45,0.55,0.65,0.75,0.84,0.92,1",boosted),"boost LUT parses"); + require(apply_response_lut(.10f,boosted)>.10f,"LUT can compensate low-force deadzone"); + require(!parse_response_lut("0,0.2,0.1,0.3,0.4,0.5,0.6,0.7,0.8,0.9,1",boosted),"non-monotonic LUT rejected"); + require(!parse_response_lut("0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.8,0.9,1,1",boosted),"nonzero LUT origin rejected"); + WheelVehicleDynamics gap; EVWORK_CAR gapCar; gap.reset(); + for(int i=0;i<80;++i)step(gap,gapCar); + gap.update(nullptr,0,.5,0); + step(gap,gapCar,.2f);step(gap,gapCar,.2f); + require(std::abs(gap.yawRate())<.01f,"short-gap derivative baseline"); + WheelVehicleDynamics resume; EVWORK_CAR resumeCar;resume.reset();for(int i=0;i<80;++i)step(resume,resumeCar); + require(resume.calibrated()&&resume.motionScale()>0,"resume baseline ready"); + resume.reset_dynamic(); + require(resume.calibrated()&&resume.motionScale()==0&&!resume.sampleValid(),"dynamic reset preserves basis but clears motion scale"); + WheelVehicleDynamics d; EVWORK_CAR c;d.reset();for(int i=0;i<80;++i)step(d,c); + require(d.calibrated()&&d.forwardAxis()==3,"straight basis calibration");require(d.sampleValid()&&d.activationBlend()==1,"activation"); + for(int i=0;i<40;++i)step(d,c,i*.01f,.20f,.5f); + require(std::abs(d.yawRate()-.6f)<.02f,"known yaw");require(std::abs(d.bodySlip()-.2f)<.003f,"known beta"); + require(std::isfinite(d.rawFrontSlip())&&d.frontSlipBlend()>.24f,"raw/filtered front slip telemetry active"); + WheelVehicleDynamics low; EVWORK_CAR lowCar; low.reset(); for(int i=0;i<80;++i)step(low,lowCar,0,0,0,.15f); step(low,lowCar,.01f,0,.2f,.15f); + WheelVehicleDynamics high; EVWORK_CAR highCar; high.reset(); for(int i=0;i<80;++i)step(high,highCar,0,0,0,.90f); step(high,highCar,.01f,0,.2f,.90f); + require(high.frontSlipBlend()>low.frontSlipBlend(),"front-slip transient speeds up with vehicle speed"); + + // v0.2: a rapid steering reversal must change the Physics SAT tyre proxy on + // the first valid tick instead of carrying stale opposite torque for several + // frames. The steering-rate predictor should point in the new direction too. + WheelVehicleDynamics reversal; EVWORK_CAR reversalCar; reversal.reset(); + for(int i=0;i<80;++i)step(reversal,reversalCar); + for(int i=0;i<8;++i)step(reversal,reversalCar,0,0,.50f,.55f); + require(reversal.frontSlip()>.08f,"front-slip positive corner established"); + step(reversal,reversalCar,0,0,-.50f,.55f); + require(reversal.rawFrontSlip()<0&&reversal.frontSlip()<0,"front-slip reversal crosses in one tick"); + require(reversal.steerRate()<0,"steering transient lead follows counter-steer direction"); + + float beta=d.bodySlip();d.update(nullptr,0,.5,0);require(d.bodySlip()0&&d.frontSlip()==0,"warp clear"); + for(int i=0;i<30;++i)step(d,c,0); + c.position_14.z-=1;d.update(&c,0,.5,0);require(!d.sampleValid()&&d.frontSlip()==0,"reverse proxy disabled"); + d.update(&c,0,0,0);require(d.frontSlip()==0&&d.yawRate()==0&&!d.sampleValid(),"stop clear and invalid dynamic sample"); + std::cout< str: + return Path(path).read_text(encoding='utf-8') + + +def require(path: str, needle: str, label: str) -> None: + data = text(path) + if needle not in data: + raise SystemExit(f'FAIL [{label}]: {needle!r} missing from {path}') + print(f'PASS [{label}]') + + +def forbid(path: str, needle: str, label: str) -> None: + data = text(path) + if needle in data: + raise SystemExit(f'FAIL [{label}]: stale {needle!r} present in {path}') + print(f'PASS [{label}]') + + +# 1. Raw multi-device binding kinds exist. +require('src/input_manager.hpp', 'JoyButton, JoyAxis, JoyHat', 'raw joystick binding kinds') +# 2. Wheel/pedal calibration survives in bindings. +require('src/input_manager.hpp', 'axisMinimum', 'axis calibration minimum') +require('src/input_manager.hpp', 'axisRest', 'axis calibration rest') +require('src/input_manager.hpp', 'axisMaximum', 'axis calibration maximum') +# 3. Physical identity is stronger than display name alone. +require('src/input_manager.hpp', 'deviceSerial', 'serial identity') +require('src/input_manager.hpp', 'devicePath', 'path identity') +require('src/input_manager.hpp', 'deviceVendor', 'USB vendor identity') +# 4. Every SDL joystick is enumerated, not only mapped gamepads. +require('src/input_manager.cpp', 'SDL_GetJoysticks', 'all-device startup enumeration') +require('src/input_manager.cpp', 'onJoystickAdded', 'raw joystick registration') +# 5. Hot-plug is part of the new path. +require('src/input_manager.hpp', 'SDL_EVENT_JOYSTICK_ADDED', 'hotplug add event') +require('src/input_manager.hpp', 'SDL_EVENT_JOYSTICK_REMOVED', 'hotplug remove event') +# 6. Automatic backend can see a Windows FFB wheel before SDL starts. +require('src/input_manager.cpp', 'has_attached_ffb_wheel()', 'DirectInput pre-SDL FFB probe') +# 7. The imported simple FFB backend must not compete with our COM engine. +forbid('src/input_manager.cpp', 'WheelForceFeedback::', 'single FFB owner in input manager') +forbid('src/overlay/input_bindings_ui.cpp', 'WheelForceFeedback::', 'single FFB owner in UI') +# 8. Quick Setup/calibration remains exposed in the in-game binding UI. +require('src/overlay/input_bindings_ui.cpp', 'Quick Setup', 'quick setup UI') +require('src/overlay/input_bindings_ui.cpp', 'Save calibration', 'calibration UI') +require('src/overlay/input_bindings_ui.cpp', 'Multi-device input architecture adapted from hyp36rmax (MIT)', 'source attribution') +# 9. Advanced FFB controls are combined with the multi-device UI. +require('src/overlay/input_bindings_ui.cpp', 'Advanced DirectInput COM FFB', 'advanced FFB binding tab') +require('src/overlay/input_bindings_ui.cpp', 'Hardware GUID_Damper', 'hardware damper UI') +# 10. New input is preferred, while exact GUID + legacy fallback remain available. +forbid('src/dllmain.cpp', 'Settings::UseNewInput = false;', 'no forced legacy startup') +require('src/dllmain.cpp', 'multi-device SDL raw input enabled', 'new-input startup diagnostic') +require('src/hooks_wheel_ffb.cpp', 'WheelFFBDeviceGuid', 'exact FFB GUID setting') +require('src/overlay/wheel_setup_ui.cpp', 'WheelUniversalDeviceGuid', 'exact setup GUID setting') + +print('Multi-device hybrid 10-pass verification complete') diff --git a/tools/verify_vr_architecture.py b/tools/verify_vr_architecture.py new file mode 100644 index 000000000..d7094d1e5 --- /dev/null +++ b/tools/verify_vr_architecture.py @@ -0,0 +1,450 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def text(rel: str) -> str: + path = ROOT / rel + if not path.is_file(): + raise SystemExit(f"missing architecture file: {rel}") + return path.read_text(encoding="utf-8") + + +def require(rel: str, *markers: str) -> str: + data = text(rel) + for marker in markers: + if marker not in data: + raise SystemExit(f"missing architecture invariant: {rel} :: {marker}") + return data + + +# Final authoritative implementation graph. Intermediate implementation files +# are intentionally kept because the R23 wrappers include the validated lower +# layers, but cmake must never compile those included bodies independently. +required = [ + "src/vr/settings.cpp", + "src/vr/runtime_eligibility.hpp", + "src/vr/game/outrun_renderer.cpp", + "src/vr/game/outrun_renderer_r13.cpp", + "src/vr/game/outrun_renderer_r23.cpp", + "src/vr/game/outrun_renderer_r29.cpp", + "src/vr/d3d9/stereo_renderer.cpp", + "src/vr/d3d9/stereo_renderer_r13.cpp", + "src/vr/d3d9/stereo_renderer_r20.cpp", + "src/vr/d3d9/stereo_renderer_r21.cpp", + "src/vr/d3d9/stereo_renderer_r22.cpp", + "src/vr/d3d9/stereo_renderer_r23.cpp", + "src/vr/d3d9/stereo_renderer_r29.cpp", + "src/vr/d3d9/stereo_renderer_r30.cpp", + "src/vr/d3d9/stereo_renderer_r31.cpp", + "src/vr/d3d9/ex_device_upgrade.cpp", + "src/vr/d3d9/ex_device_upgrade_r13.cpp", + "src/vr/d3d9/ex_device_upgrade_r14.cpp", + "src/vr/d3d9/r13_bridge.hpp", + "src/vr/d3d9/vr_pass_policy.hpp", + "src/vr/ipc/protocol.hpp", + "src/vr/ipc/protocol_v3.hpp", + "src/vr/ipc/direct_ack_r13.hpp", + "src/vr/core/frame_types.hpp", + "src/vr/core/matrix.hpp", + "src/vr/core/transport.hpp", + "src/vr/game/game_adapter.hpp", + "src/vr/d3d9/stereo_backend.hpp", + "vrhost/src/main.cpp", + "vrhost/src/main_r23.cpp", + "vrhost/src/runtime/d3d9ex_direct_passthrough.hpp", + "vrhost/src/runtime/r23_runtime_hardening.hpp", + "vrhost/src/runtime/r23_verified_bundle.hpp", + "vrhost/tests/runtime_eligibility_smoke.cpp", + "vrhost/tests/r23_verified_bundle_smoke.cpp", + "docs/VR_ARCHITECTURE.md", +] +for rel in required: + if not (ROOT / rel).is_file(): + raise SystemExit(f"missing architecture file: {rel}") + +forbidden = [ + "src/hooks_vr.cpp", + "src/vr_renderer_probe.cpp", + "src/vr_stereo.cpp", + "vrhost/main.cpp", + "vrhost/main_compat.cpp", + "vrhost/main_compat_v2.cpp", + "vrhost/main_compat_v3.cpp", + "vrhost/main_stereo.cpp", +] +for rel in forbidden: + if (ROOT / rel).exists(): + raise SystemExit(f"legacy/prototype artifact still present: {rel}") + +# v2 remains the live transport ABI while v3 is mirrored for migration and +# diagnostics. Do not silently steal reserved words or pointer-width fields. +protocol_v2 = require( + "src/vr/ipc/protocol.hpp", + "SharedProtocolVersion = 2", + "RenderFrameProtocolVersion = 2", + "RenderFrameRingSize = 4", + "HostAdapterLuidValid", + "hostDirectConsumedFrameId", + "SharedRenderFrameRing", +) +protocol_v3 = require( + "src/vr/ipc/protocol_v3.hpp", + "ProtocolVersion = 3", + "HostStateName", + "ClientStateName", + "FrameRingName", + "AckStateName", + "using WireHandle = std::uint64_t", + "WireHandle leftHandle", + "WireHandle rightHandle", +) +for forbidden_marker in ( + "std::uintptr_t interopProbeHandle", + "std::uintptr_t leftHandle", + "std::uintptr_t rightHandle", +): + if forbidden_marker in protocol_v3: + raise SystemExit(f"v3 protocol reintroduced pointer-width ABI debt: {forbidden_marker}") + +require( + "src/vr/ipc/direct_ack_r13.hpp", + "DirectGpuAckName", + "DirectGpuAckRingSize = 4", + "transportGeneration", + "completedFrameId[DirectGpuAckRingSize]", +) + +# Shared eligibility is a state machine, not a collection of independent bools. +require( + "src/vr/runtime_eligibility.hpp", + "enum class InstallState", + "Pending = 0", + "Ready = 1", + "Failed = 2", + "SafetyOverlayReady", + "MarkSafetyOverlayInstalled", + "MarkSafetyOverlayUnavailable", + "BaselineVerified", + "MayInjectStereo", +) + +# Render-pass policy must keep orthographic/UI and fragile camera-facing alpha +# effects out of the head-tracked WVP while retaining opaque world stereo. +require( + "src/vr/d3d9/vr_pass_policy.hpp", + "ClassifyProjectionSignature", + "Perspective3D", + "Orthographic2D", + "EffectStereoPolicy", + "ZeroDisparity", + "ClassifyEffectStereo", + "UnsafeSingleExecution", +) +require( + "src/vr/game/outrun_renderer_r13.cpp", + "R13FragileEffectNeedsZeroDisparity", + "D3DRS_ALPHABLENDENABLE", + "D3DRS_ZWRITEENABLE", + "shadow/billboard/panel pass kept stock", + "InvalidateVerifiedWvp", + "InlineHook::StartDisabled", +) + +# R29/R31 correctness overlays must preserve constant registers surrounding a +# partial c64..c67 write, pair fast-path WVP with its captured projection, and +# make StateBlock bypasses an explicit full-cache generation boundary. +require( + "src/vr/game/outrun_renderer_r29.cpp", + "R29BuildCoherentUploadEnvelope", + "envelopeStart", + "envelopeCount", + "IsGameStateBlockRecording", + "IsStateBlockTrackingReliable", + "recorded stock", + "R29InvalidateRendererStateAfterExternalRestore", +) +require( + "src/vr/d3d9/stereo_renderer_r31.cpp", + "GetR28VerifiedProjection", + "projectionGeneration != generation", + "BeginStateBlockDestR31", + "R31StateBlockTrackingReliable", + "per-draw live WVP/shader/render-state validation", + "R31 fast left-eye c64 rollback", + "R31 HUD left-eye c64 rollback", +) + +# R14 shadows are resource-lifetime-bound and fail closed to one direct path on +# any write route that cannot be mirrored. A lower-mip fallback must copy that +# exact level rather than relying on UpdateTexture's level-zero dirty rules. +r14 = require( + "src/vr/d3d9/ex_device_upgrade_r14.cpp", + "std::unordered_map", + "TextureReleaseDestR14", + "InstallManagedResourceCompatR14", + "R14AdoptCompatDevice", + "TextureGenerateMipSubLevelsDestR14", + "TextureGetSurfaceLevelDestR14", + "UpdateSurfaceDestR14", + "UpdateTextureDestR14", + "validMask", + "dirtyMask", + "R14CopyWholeLevelByLock", + "exact mip CPU-shadow upload failed", +) +if "R14ShadowCapacity" in r14 or "R14ShadowCursor" in r14: + raise SystemExit("R14 reintroduced fixed-capacity live-shadow eviction") + +settings = require( + "src/vr/settings.cpp", + 'VRPreferD3D9Ex{ "VR", "PreferD3D9Ex", true', + 'VRDirectGpuOnly{ "VR", "DirectGpuOnly", true', + 'VRDisableDesktopDuplication{ "VR", "DisableDesktopDuplication", false', + 'VRTargetRefreshRateHz{ "VR", "TargetRefreshRateHz", 0.0f', + 'VRFrameCadenceMode{ "VR", "FrameCadenceMode", 1', + 'VRFrameCadenceTargetHz{ "VR", "FrameCadenceTargetHz", 0.0f', + 'VRFrameCadenceMaxHz{ "VR", "FrameCadenceMaxHz", 120.0f', + "OUTRUN_VR_DIRECT_TRANSPORT", + "OUTRUN_VR_DIRECT_ONLY", + "OUTRUN_VR_DISABLE_DESKTOP_DUPLICATION", + "OUTRUN_VR_TARGET_REFRESH_HZ", + "OUTRUN_VR_CADENCE_MODE", + "OUTRUN_VR_CADENCE_TARGET_HZ", + "OUTRUN_VR_CADENCE_MAX_HZ", +) + +# R23 installer workers may only request cleanup. Live OutRun camera/projection +# writes are serviced from D3D render callbacks; recovery pose warmup keeps the +# stock camera/WVP visible until the next-frame authoritative seed is accepted. +renderer_r23 = require( + "src/vr/game/outrun_renderer_r23.cpp", + "R23RenderThreadCleanupRequested", + "R23RequestFailClosedCleanup", + "R23ServiceRenderThreadCleanup", + "R23DropIneligibleLatchedPoseOnRenderThread", + "R23KeepWarmupPoseStockOnRenderThread", + "recovery pose warmup is stock-visible", + "RendererInjectionAllowed.store(false", +) +installer_start = renderer_r23.find("DWORD WINAPI R23RendererInstallThread") +installer_end = renderer_r23.find("class VRRendererR23EligibilityHook") +if installer_start >= 0 and installer_end > installer_start: + worker = renderer_r23[installer_start:installer_end] + if "RestoreCullingCamera()" in worker: + raise SystemExit("R23 installer thread must not mutate live camera/projection memory") + +# R20-R23 installation must fail fast and publish READY only after disabled-first +# hook transactions are enabled. +require( + "src/vr/d3d9/stereo_renderer_r20.cpp", + "R20InstallState", + "using State = OutRunVR::RuntimeEligibility::InstallState", + "State::Pending", + "State::Failed", + "InlineHook::StartDisabled", +) +require( + "src/vr/d3d9/stereo_renderer_r21.cpp", + "R21InstallState", + "IsFailed(R20InstallState)", + "InlineHook::StartDisabled", +) +require( + "src/vr/d3d9/stereo_renderer_r22.cpp", + "R22InstallState", + "R22ShadowState", + "R22SetScissorRectHook", + "R22SetRenderStateHook", + "R22PrimeShadowState", + "InlineHook::StartDisabled", + "per-draw GetViewport/GetScissorRect/GetRenderState eliminated", +) + +# R23/R25 is the single recovery/baseline authority. Recovery Clear must remain +# a passive original call, while live viewport/scissor, full game draw serial, +# MRT safety and a fresh next-frame pose gate the first stereo initialization. +require( + "src/vr/d3d9/stereo_renderer_r23.cpp", + "#include \"stereo_renderer_r22.cpp\"", + "R23InstallState", + "IsFailed(R22InstallState)", + "R23RecoveryNeedsBaseline", + "R23CaptureActualGameState", + "R23GameDrawSerial", + "R23DiagnoseHostFreshness", + "passive original Clear only", + "authoritative first seed", + "mrtActive", + "MarkSafetyOverlayInstalled", +) + +# Async installer status must be publishable back to the hook overlay/UI. +require( + "src/hook_mgr.hpp", + "std::atomic is_active_", + "std::atomic has_error_", + "ReportAsyncResult", +) +require( + "src/hook_mgr.cpp", + "HookManager::ReportAsyncResult", + "async installer", +) + +# Shadow v3 diagnostics may not run 1-2 ms busy polling or republish client +# state forever after the first stereo frame. +require( + "src/vr/ipc/v3_game_shadow_bridge.cpp", + "ShadowIdlePollMs = 8", + "ShadowRetryMs = 250", + "lastClientPublishedFrameId", + "clientChanged", +) +require( + "vrhost/src/ipc/v3_shadow_bridge.cpp", + "ShadowIdlePollMs = 8", + "ShadowWriterRetryMs = 250", + "ackWriter.reset()", +) +require( + "vrhost/src/ipc/host_state_v3_writer.hpp", + "Reset(false)", + "A constructor that throws never runs this object's destructor", +) + +# Direct shared-eye transport must copy to host-owned resources, prove GPU copy +# completion, and bind actual texture format/size to committed Frame.v2 metadata. +require( + "vrhost/src/runtime/d3d9ex_direct_passthrough.hpp", + "CopyFenceTimeoutMs = 8", + "ExpectedDeclaredFormat", + "case D3DFMT_A8R8G8B8", + "DXGI_FORMAT_B8G8R8A8_UNORM", + "desc[0].Format == declared", + "host-owned GPU eye copies + completion ACK active", + "SafeTransportGeneration", +) +require( + "vrhost/src/runtime/r23_runtime_hardening.hpp", + "ExpectedDirectDxgiFormat", + "case D3DFMT_A8R8G8B8", + "DXGI_FORMAT_B8G8R8A8_UNORM", + "DirectSafeEyeMatchesCommittedFrame", + "SafeEyeFormat == expected", + "width == frame.backbufferWidth", +) +require( + "vrhost/src/runtime/r23_verified_bundle.hpp", + "MaxPresentationAgeMs", + "ReadFresh", + "Matches", +) + +# main_r23 must commit only a stable full Frame.v2 snapshot. If stereo metadata +# is not ready, it may refresh Desktop Duplication only for the theater fallback; +# that mono source must never become a validated stereo projection. +require( + "vrhost/src/main_r23.cpp", + "#include \"main.cpp\"", + "R23FrameUnchanged", + "R23CommitDirectAfterValidation", + "R23CommitClassicAfterValidation", + "R23RefreshTheaterFallbackCapture", + "if (c.disableDesktopDuplication_)", + "R37BootstrapSubmittedFrame", + "R37BootstrapSubmittedGeneration", + "R37FrameIdBefore", + "DirectGPU latest-frame-wins active", + "PublishCompletedFrame(frame)", + "!R37FrameIdBefore(", + "lastProcessedStereoFrame", + "allowInitialWarmupWait", + "theater-only fallback", + "OutRunVrR23VerifiedBundle::Publish", + "std::memcmp(after.eye, before.eye", + "std::memcmp(after.reserved, before.reserved", +) + +require( + "vrhost/src/main.cpp", + "directTransportOnly_", + "desktopDuplication=", + "disabled-direct-only", + "DXGI_FORMAT_B8G8R8A8_UNORM", +) +require( + "src/vr/d3d9/stereo_renderer.cpp", + "R9ReadBoolEnvironment", + "R9DirectOnlyTransport", + "Settings::VRDirectGpuOnly.get()", + "inherits OUTRUN_VR_DIRECT_ONLY through CreateProcess", + "SAFE modes stuck in the default direct-only path", +) +require( + "src/vr/d3d9/stereo_renderer_r26.cpp", + "R37DepthDisabledFragileOverlay", + "R13EffectSnapshot effect = R13CaptureDrawTimeEffect", + "unknown state fails closed", +) +require( + "src/vr/d3d9/stereo_renderer_r30_r26_safe.cpp", + '#include "stereo_renderer_r26.cpp"', + "R30SafeStereoBase", + "VR R26+HUD SAFE TEST", +) +require( + "src/vr/d3d9/stereo_renderer_r30.cpp", + "state.depthTestEnabled &&", + "state.rhwDepthEvidence", +) + +# R41 DirectGPU latency invariant: the newest complete slot is the only frame +# selected for sampling. Older occupied slots are never rendered later; they are +# ACKed immediately because no D3D11 work references them. +_history = [(1, 0), (2, 1), (3, 2), (4, 3)] +_selected = max(_history, key=lambda item: item[0]) +_skipped = [item for item in _history if item != _selected] +if _selected != (4, 3) or [fid for fid, _ in _skipped] != [1, 2, 3]: + raise SystemExit( + f"R41 direct latest-frame model regressed: selected={_selected} skipped={_skipped}" + ) + +# Once frame 4 is displayed, stale ring contents 1..3 must never be selected +# again while the producer is recycling those ACKed slots. +_last_processed = _selected[0] +_remaining_new = [item for item in _history if item[0] > _last_processed] +if _remaining_new: + raise SystemExit( + f"R41 direct stale-frame rewind model regressed: {_remaining_new}" + ) + +# New regressions are required in the host build graph. +require( + "vrhost/CMakeLists.txt", + "src/main_r23.cpp", + "outrun-vr-runtime-eligibility-smoke", + "outrun-vr-r23-verified-bundle-smoke", + "r23_runtime_hardening.hpp", +) + +# Root cmkr source ownership: only final wrappers compile independently. +cmake_root = require( + "cmake.toml", + "stereo_renderer_r20.cpp", + "stereo_renderer_r21.cpp", + "stereo_renderer_r22.cpp", + "outrun_renderer_r13.cpp", + "HEADER_FILE_ONLY TRUE", + 'compile-options = ["/GS"', +) + +# Renderer-independent interfaces stay clean of graphics/runtime headers. +math_core = require("src/vr/core/matrix.hpp", "struct Matrix4", "InverseRigid", "Invert(") +if "#include " in math_core or "#include str: + path = ROOT / rel + if not path.is_file(): + raise SystemExit(f"R32/R33 verification missing file: {rel}") + return path.read_text(encoding="utf-8") + + +def require(rel: str, *markers: str) -> str: + data = load(rel) + for marker in markers: + if marker not in data: + raise SystemExit(f"R32/R33 invariant missing: {rel} :: {marker}") + return data + + +eligibility = require( + "src/vr/runtime_eligibility.hpp", + "ExternalSafetyBlock", + "SetExternalSafetyBlock", + "!ExternalSafetyBlock.load(std::memory_order_acquire)", +) +baseline_verified = eligibility.find("inline void BaselineVerified()") +baseline_block = eligibility.find( + "ExternalSafetyBlock.load(std::memory_order_acquire)", baseline_verified) +stereo_open = eligibility.find( + "StereoAllowed.store(true, std::memory_order_release)", baseline_verified) +if min(baseline_verified, baseline_block, stereo_open) < 0 or not (baseline_verified < baseline_block < stereo_open): + raise SystemExit( + "external ResetEx safety block must be checked before BaselineVerified reopens stereo") + +policy = require( + "src/vr/d3d9/r32_policy.hpp", + "RearmMonoSafetyEpoch", + "ForceZeroDisparity", + "ProducerFenceBudgetMs = 2", + "PendingFenceDecision", + "ClassifyPendingFence", +) + +r32 = require( + "src/vr/d3d9/stereo_renderer_r32.cpp", + '#include "stereo_renderer_r31.cpp"', + "R32ResetR22Hook", + "reinterpret_cast(&ResetDestR22)", + "R32ResetAfterGameReset", + "R32InvalidateResetCaches", + "R29MonoSafetyThroughEpoch = OutRunVR::R32::RearmMonoSafetyEpoch", + "R32EffectIsFragileLive", + "draw is forced to stock-WVP zero disparity", + "R32SetWvpBatch", + "OutRunWvpRegisterCount", + "R32WaitProducerFence", + "QueryPerformanceCounter", + "static const LONGLONG qpcFrequency", + "Budget starts before the FLUSH request", + "R32ProducerFencePending", + "R32DrainPendingProducerFence", + "timed-out producer EVENT remains pending", + "R32DirectIdentityMatches", + "R32DirectCopyPathRejected", + "A query error does not prove GPU completion", + "StretchRect commands are already queued", + "DirectGPU copy path is disabled until Reset/interop revalidation", + "R32EnsureDirectResources must run before this cached rejection", + "VR R32 PERF 5s", + "VR R32 REVIEW2", +) +if "R32ResetR13Hook" in r32: + raise SystemExit("R32 must no longer install a competing ResetDestR13 hook") +if "R22ShadowState = {};" in r32[r32.find("void R32ResetAfterGameReset"):]: + raise SystemExit("R32 successful Reset post-processing must preserve R22's freshly primed viewport/scissor shadow") +resolve_start = r32.find("bool ResolveDirectTransportR32") +ensure_direct = r32.find("R32EnsureDirectResources(device)", resolve_start) +copy_reject = r32.find("if (R32DirectCopyPathRejected)", resolve_start) +if min(resolve_start, ensure_direct, copy_reject) < 0 or ensure_direct > copy_reject: + raise SystemExit( + "R32 DirectGPU copy rejection must be checked only after host identity/interop revalidation") + +drain_start = r32.find("bool R32DrainPendingProducerFence") +drain_error = r32.find("A query error does not prove GPU completion", drain_start) +drain_end = r32.find("bool ResolveDirectTransportR32", drain_start) +if min(drain_start, drain_error, drain_end) < 0: + raise SystemExit("could not locate R32 pending-fence query-error quarantine") +if "R32ProducerFencePending[slotIndex] = false;" in r32[drain_error:drain_end]: + raise SystemExit( + "R32 query-error path must not mark a producer EVENT complete/reusable") + +issue_marker = r32.find("StretchRect commands are already queued", resolve_start) +issue_reject = r32.find("R32DirectCopyPathRejected = true;", issue_marker) +if min(issue_marker, issue_reject) < 0 or issue_reject < issue_marker: + raise SystemExit( + "R32 EVENT Issue failure must quarantine DirectGPU after queued eye copies") + +r33 = require( + "src/vr/d3d9/stereo_renderer_r33.cpp", + '#include "stereo_renderer_r32.cpp"', + "R31OwnedResult R33TryFastWorld", + "R31OwnedResult R33TryHud", + "R31ObserveDraw(device)", + "R32LowerFailClosed", + "R31DiscardUnreliableDrawCaches();", + "changing one tracked render state cannot", + "R30DrawPrimitiveR29Hook", + "R33ResetR32Hook.stdcall", + "R33 -> R32 -> R22", + "top-level telemetry counted once", +) +if "const HRESULT hr = ResetDestR22" in r33: + raise SystemExit("R33 must not bypass the corrected R32 Reset lifecycle") + +ex = require( + "src/vr/d3d9/ex_device_upgrade.cpp", + "#include ", + "#include ", + "TestCooperativeLevelCompatDest", + "deviceEx->CheckDeviceState(window)", + "S_PRESENT_MODE_CHANGED", + "D3DERR_DEVICENOTRESET", + "D3DERR_DEVICEHUNG", + "D3DERR_DEVICEREMOVED", + "D3DERR_DRIVERINTERNALERROR", + "EvictManagedResourcesCompatDest", + "CaptureClassicBaseline", + "RestoreClassicResetState", + "fresh-device classic state baseline", + "for (DWORD stage = 0; stage < 8; ++stage)", + "fullscreen.ScanLineOrdering = current.ScanLineOrdering", + "a second promoted game device was requested", + "bool allowNonDynamicFallback", + "failing closed hr=0x{:08X}", + "compatibility hook transaction was partial", + "const D3DPRESENT_PARAMETERS originalParams = *params", + "auto classicFallback = [&]() -> HRESULT", + "D3DCREATE_PUREDEVICE", + "FinalCompatOverlayReady", + "final R15 compatibility overlay is not ready", + "subsequent CreateDevice stays on classic D3D9", +) +if "for (DWORD stage = 0; stage < 16; ++stage)\n if (FAILED(device->SetTexture" in ex: + raise SystemExit("classic Reset replay must not treat invalid texture stages 8..15 as failures") + +bridge = require( + "src/vr/d3d9/r13_bridge.hpp", + "NormalizeLegacyPresentResult", + "ResetCompatDevice", +) + +ex_r13 = require( + "src/vr/d3d9/ex_device_upgrade_r13.cpp", + "ResetCompatDevice", + "deviceEx->ResetEx", + "UpdateCompatPresentationState(device, params)", + "RestoreClassicResetState(device)", + "ResetEx + classic-state replay", + "NormalizeLegacyPresentResult", + "S_PRESENT_MODE_CHANGED", + "S_PRESENT_OCCLUDED", + "CompatWindowed.load", + "legacy ResetEx shim retained through final R15 validation", +) +resetex = ex_r13.find("deviceEx->ResetEx") +restore = ex_r13.find("RestoreClassicResetState(device)", resetex) +if resetex < 0 or restore < resetex: + raise SystemExit("authoritative R13 ResetEx path must replay classic state after successful ResetEx") + +ex_r15 = require( + "src/vr/d3d9/ex_device_upgrade_r15.cpp", + "SetFinalCompatOverlayReady(false)", + "SetExternalSafetyBlock(true)", + "SetExternalSafetyBlock(!healthy)", + "SetFinalCompatOverlayReady(true)", + "DisarmLegacyResetHook()", + "InstallStereoHooksSynchronously(device)", + "synchronous CreateDevice-thread stereo handoff failed", + "R14AbandonCompatDevice(device)", +) +if ex_r15.find("DisarmLegacyResetHook()") > ex_r15.find("InstallStereoHooksSynchronously(device)"): + raise SystemExit("R15 must disarm the temporary Reset shim immediately before synchronous stereo ownership handoff") + +stereo_base = require( + "src/vr/d3d9/stereo_renderer_r7.inc", + "#include ", + "StereoBaseCallbacksArmed", + "InlineHook::StartDisabled", + "disabled-first callbacks armed atomically at the behavior boundary", + "std::mutex StereoInstallMutex", + "std::lock_guard installLock", + "InstallStereoHooksSynchronously", + "synchronous CreateDevice-thread base hook handoff READY", +) +worker_start = stereo_base.find("DWORD WINAPI StereoInstallThread") +worker_ready = stereo_base.find("StereoInstallState.load(std::memory_order_acquire)==StereoInstallReady", worker_start) +worker_device = stereo_base.find("Game::D3DDevice_ptr&&*Game::D3DDevice_ptr", worker_start) +if min(worker_start, worker_ready, worker_device) < 0 or worker_ready > worker_device: + raise SystemExit("stereo worker must exit for synchronously installed Ex devices before touching the game D3D device") + +r9 = require( + "src/vr/d3d9/stereo_renderer.cpp", + "rawPresentHr = PresentHook.stdcall", + "OutRunVRD3D9ExUpgradeR13::NormalizeLegacyPresentResult", + "if (composedStereo && SUCCEEDED(hr) && !FrameStereoIncomplete)", +) +raw_present = r9.find("rawPresentHr = PresentHook.stdcall") +normalized_present = r9.find("NormalizeLegacyPresentResult", raw_present) +present_success = r9.find("if (composedStereo && SUCCEEDED(hr)", normalized_present) +if min(raw_present, normalized_present, present_success) < 0 or not ( + raw_present < normalized_present < present_success): + raise SystemExit( + "legacy Present normalization must happen before stereo success/publication decisions") + +require( + "vrhost/src/runtime/d3d9ex_direct_passthrough_r32.hpp", + "R32SharedSlotCache", + "R32OpenSharedSlot", + "R32InactiveEye", + "R32SafeTimeoutPreserves", + "queued copy cannot mutate SafeFrameId image identity", + "EnsureSafeFrameR32", +) + +host_direct = require( + "vrhost/src/runtime/r32_direct_submit.hpp", + "CanFastSubmit", + "AckFaultGeneration", + "Completion is unknowable", + "SafeEye fallback perform a separately fenced copy/ACK", + "ProjectionMatchesSnapshot", + "ArmConsumptionFence", + "D3D11_ASYNC_GETDATA_DONOTFLUSH", + "flushIssued", + "deferred until actual direct-ring slot pressure", + "PublishCompletedFrame", + "AckedFrame", + "AckedGeneration", + "R32 direct PERF 5s", + "verified incoming DirectGPU projection submitted once", + "OutRunVrR26RecenterHardening::EndFrame", +) +# Flush remains permitted only in the slot-pressure escalation block. Reject the +# old unconditional End(query)+Flush() sequence. +if "Context->End(pending.fence);\n OutRunVrFinalTest::Context->Flush();" in host_direct: + raise SystemExit("R32 host must not Flush every direct frame") +query_error = host_direct.find("if (FAILED(hr))") +fault_generation = host_direct.find("AckFaultGeneration = generation", query_error) +fast_gate = host_direct.find("AckFaultGeneration == generation") +if min(query_error, fault_generation, fast_gate) < 0: + raise SystemExit("R32 host ACK query failure must disable fast-submit for that generation") +if not (query_error < fault_generation < fast_gate): + raise SystemExit("R32 host ACK fault must be recorded before the fast-submit generation gate") + +r34 = require( + "src/vr/d3d9/stereo_renderer_r34.cpp", + "SetExternalSafetyBlock(true)", + "SetExternalSafetyBlock(!healthy)", + "Install/state-sync", + "Present/pre", +) +if r34.find("Present/pre") > r34.find("R34PresentR33Hook.stdcall"): + raise SystemExit("R34 must reassert Reset replay fail-close before lower Present work") + +r31 = load("src/vr/d3d9/stereo_renderer_r31.cpp") +state_enable = r31.find("const bool stateHooks =") +end_enable = r31.find("R31EndStateBlockHook.enable()", state_enable) +begin_enable = r31.find("R31BeginStateBlockHook.enable()", state_enable) +create_enable = r31.find("R31CreateStateBlockHook.enable()", state_enable) +if min(state_enable, end_enable, begin_enable, create_enable) < 0 or not (end_enable < begin_enable < create_enable): + raise SystemExit("R31 StateBlock hooks must arm End before Begin before Create") + +host_main = require( + "vrhost/src/main.cpp", + "PrepareDirectStereoSource", + "CommitDirectStereoSource", + "legacy private snapshot + synchronous fence", +) +prepare_direct = host_main.find("bool PrepareDirectStereoSource") +legacy_commit = host_main.find("bool CommitDirectStereoSource", prepare_direct) +snapshot_use = host_main.find("directSnapshotLeft_", prepare_direct) +if min(prepare_direct, legacy_commit, snapshot_use) < 0 or snapshot_use < legacy_commit: + raise SystemExit( + "production DirectGPU prepare path must not execute the legacy private snapshot/fence") + +host_r23 = require( + "vrhost/src/main_r23.cpp", + "R23DirectHoldState", + "R23StageDirectHold", + "PrepareDirectStereoSource", + "single-copy production path active", + "srv[0] = R23DirectHold.srv[0]", +) +if "c.CommitDirectStereoSource(frame)" in host_r23: + raise SystemExit( + "R23 production DirectGPU path must bypass legacy snapshot/fence commit") + +r14 = require( + "src/vr/d3d9/ex_device_upgrade_r14.cpp", + "singleLevelTexture", + "entry.gpu->GetLevelCount() <= 1", + "R14EnsureSurfaceHooks", + "SurfaceLockRectDestR14", + "SurfaceGetDCDestR14", + "StretchRectDestR14", + "ColorFillDestR14", + "SUCCEEDED(hr) && destination && R14InternalUploadDepth == 0", + "keeping translated MANAGED texture on tracked DirectOnly compatibility path", + "hard fail-close retained because DirectOnly lifetime cannot be proven", + "R14TrackDirectOnly", + "return D3DERR_NOTAVAILABLE;", +) +if "external GetSurfaceLevel" in r14: + raise SystemExit("R14 must not retire the CPU shadow merely because GetSurfaceLevel borrowed a read-only alias") +create_r14 = r14.find("HRESULT __stdcall CreateTextureCompatDestR14") +direct_r14 = r14.find("R14TrackDirectOnly(device, *texture)", create_r14) +hard_fail_r14 = r14.find("return D3DERR_NOTAVAILABLE;", direct_r14) +if min(create_r14, direct_r14, hard_fail_r14) < 0 or \ + not (create_r14 < direct_r14 < hard_fail_r14): + raise SystemExit( + "R14 must degrade CPU-shadow allocation failure to tracked DirectOnly before the hard coverage fail-close") + +require( + "vrhost/tests/runtime_eligibility_smoke.cpp", + "SetExternalSafetyBlock(true)", + "ObserveSoftHostSuspend()", + "assert(!HostRenderable.load());", + "BaselineVerified();", + "assert(!MayInjectStereo());", +) + +require( + "src/vr/d3d9/stereo_renderer_r21.cpp", + "R21HostStatus::SoftSuspend", + "VR R21 SOFT-SUSPEND", + "without baseline reset", +) + +require( + "src/vr/d3d9/stereo_renderer_r23.cpp", + "SetRenderTarget implicit viewport/scissor transition observed", + "R23/SetRenderTarget/live-state-capture", +) + +require( + "src/vr/d3d9/stereo_renderer.cpp", + "R9LastMainDepthRestoreLogMs", + "totalRestores={}", +) + +effect_policy = require( + "src/vr/d3d9/vr_pass_policy.hpp", + "Depth testing is the decisive", + "if (!depthTestEnabled)", + "return EffectStereoPolicy::ZeroDisparity;", + "ClassifyEffectStereo(true, false, false, false, true)", + "EffectStereoPolicy::ZeroDisparity", +) +if "cullNone && alphaBlendEnabled && !depthWriteEnabled;" in effect_policy: + raise SystemExit( + "effect policy regressed to zero-disparity classification without the depth-test signal") + +r31_lazy = require( + "src/vr/d3d9/stereo_renderer_r31.cpp", + "R31StateBlockResyncPending", + "R31MarkStateBlockCachesDirty", + "R31FlushPendingStateBlockResync", + "lazily re-primed at the next actual draw", +) +r33_lazy = require( + "src/vr/d3d9/stereo_renderer_r33.cpp", + "R31FlushPendingStateBlockResync(device);", +) + +require( + "vrhost/tests/r32_policy_smoke.cpp", + "RearmMonoSafetyEpoch(1) == 3", + "EffectSnapshotDecision::ForceZeroDisparity", + "ProducerFenceBudgetMs <= 2", + "PendingFenceDecision::BlockReuse", + "PendingFenceDecision::QueryError", +) + +cmake = require( + "cmake.toml", + "R33/R14 keep the validated", + "stereo_renderer_r31.cpp", + "stereo_renderer_r32.cpp", + "PROPERTIES HEADER_FILE_ONLY TRUE", + "OUTRUN_VR_POST_REVIEW_FINAL_TUS", + "ex_device_upgrade_r15.cpp", + "stereo_renderer_r34.cpp", +) +included_start = cmake.find("set(OUTRUN_VR_INCLUDED_IMPL_TUS") +included_end = cmake.find( + "set_source_files_properties(${OUTRUN_VR_INCLUDED_IMPL_TUS}", + included_start) +final_start = cmake.find("set(OUTRUN_VR_FINAL_TUS", included_end) +final_end = cmake.find("foreach(_vr_source", final_start) +if min(included_start, included_end, final_start, final_end) < 0: + raise SystemExit("could not locate list-based VR source ownership blocks") +header_section = cmake[included_start:included_end] +final_section = cmake[final_start:final_end] +if "stereo_renderer_r31.cpp" not in header_section or \ + "stereo_renderer_r32.cpp" not in header_section: + raise SystemExit("R31/R32 must be include-only implementation TUs") +if "stereo_renderer_r33.cpp" in header_section: + raise SystemExit("R33 final TU must not be include-only") +if "stereo_renderer_r33.cpp" not in final_section or \ + "ex_device_upgrade_r14.cpp" not in final_section or \ + "outrun_renderer_r29.cpp" not in final_section: + raise SystemExit("final VR wrapper TU list is incomplete") + +generated = require( + "CMakeLists.txt", + "set(OUTRUN_VR_INCLUDED_IMPL_TUS", + "set(OUTRUN_VR_FINAL_TUS", + "stereo_renderer_r33.cpp", + "ex_device_upgrade_r14.cpp", + "outrun_renderer_r29.cpp", + "ex_device_upgrade_r15.cpp", + "stereo_renderer_r34.cpp", +) +for source in ( + "src/vr/d3d9/stereo_renderer_r31.cpp", + "src/vr/d3d9/stereo_renderer_r32.cpp", + "src/vr/d3d9/stereo_renderer_r33.cpp", + "src/vr/d3d9/ex_device_upgrade_r14.cpp", + "src/vr/game/outrun_renderer_r29.cpp", +): + if source not in generated: + raise SystemExit(f"generated CMakeLists is stale: missing {source}") + +source_list_start = generated.find("set(outrun2006tweaks_SOURCES") +source_list_end = generated.find( + ")\n\nadd_library(outrun2006tweaks SHARED)", source_list_start) +if source_list_start < 0 or source_list_end < 0: + raise SystemExit("could not locate generated outrun2006tweaks target source list") +target_sources = generated[source_list_start:source_list_end] +for source in ( + "src/vr/d3d9/ex_device_upgrade_r14.cpp", + "src/vr/d3d9/ex_device_upgrade_r15.cpp", + "src/vr/d3d9/stereo_renderer_r20.cpp", + "src/vr/d3d9/stereo_renderer_r21.cpp", + "src/vr/d3d9/stereo_renderer_r22.cpp", + "src/vr/d3d9/stereo_renderer_r23.cpp", + "src/vr/d3d9/stereo_renderer_r26.cpp", + "src/vr/d3d9/stereo_renderer_r29.cpp", + "src/vr/d3d9/stereo_renderer_r30.cpp", + "src/vr/d3d9/stereo_renderer_r31.cpp", + "src/vr/d3d9/stereo_renderer_r32.cpp", + "src/vr/d3d9/stereo_renderer_r33.cpp", + "src/vr/d3d9/stereo_renderer_r34.cpp", + "src/vr/game/outrun_renderer_r23.cpp", + "src/vr/game/outrun_renderer_r29.cpp", +): + if source not in target_sources: + raise SystemExit( + f"generated target source list is stale: missing {source}") + +host_cmake = require( + "vrhost/CMakeLists.txt", + "d3d9ex_direct_passthrough_r32.hpp", + "r32_direct_submit.hpp", + "outrun-vr-r32-policy-smoke", +) +if host_cmake.find("d3d9ex_direct_passthrough_r32.hpp") > \ + host_cmake.find("r22_runtime_hardening.hpp"): + raise SystemExit("R32 SafeEye A/B override must precede R22/R23/R24") +if host_cmake.find("r32_direct_submit.hpp") < \ + host_cmake.find("r26_recenter_hardening.hpp"): + raise SystemExit("R32 direct submit must be final xrEndFrame owner after R26") + +print("R32-R34 + R15 D3D9Ex compatibility/Present verification passed") diff --git a/tools/verify_wheel_effective_build.py b/tools/verify_wheel_effective_build.py new file mode 100644 index 000000000..6632e33e8 --- /dev/null +++ b/tools/verify_wheel_effective_build.py @@ -0,0 +1,113 @@ +from pathlib import Path +import hashlib + + +def read(path: str) -> str: + return Path(path).read_text(encoding="utf-8") + + +def require(path: str, needle: str, label: str) -> None: + text = read(path) + if needle not in text: + raise SystemExit(f"VERIFY FAILED [{label}]: {needle!r} not found in {path}") + print(f"VERIFY OK [{label}]") + + +def forbid(path: str, needle: str, label: str) -> None: + text = read(path) + if needle in text: + raise SystemExit(f"VERIFY FAILED [{label}]: stale {needle!r} still present in {path}") + print(f"VERIFY OK [{label}]") + + +def digest(path: str) -> None: + data = Path(path).read_bytes() + print(f"SHA256 {path} {hashlib.sha256(data).hexdigest()}") + + +# Effective FFB source after all build-time patches. +require("src/hooks_wheel_ffb.cpp", 'Setting WheelFFBUseHardwareDamper{', "GUID_Damper setting") +require("src/hooks_wheel_ffb.cpp", 'GUID_Damper, &effect, &damperEffect_', "GUID_Damper creation") +require("src/hooks_wheel_ffb.cpp", 'springStrategy_ = 1;', "spring persistent DIEP_START") +require("src/hooks_wheel_ffb.cpp", 'damperStrategy_ = 1;', "damper persistent DIEP_START") +require("src/hooks_wheel_ffb.cpp", 'int periodicStrategy_ = 1;', "periodic persistent DIEP_START") +require("src/hooks_wheel_ffb.cpp", 'bool deviceAcquired_ = false;', "DirectInput acquisition state") +require("src/hooks_wheel_ffb.cpp", 'if (device_ && !deviceAcquired_)', "menu/gameplay reacquire") +require("src/hooks_wheel_ffb.cpp", 'textureRoughness', "road baseline filtering") +require("src/hooks_wheel_ffb.cpp", 'bool ignoreName = false;', "strict-first FFB selection state") +require("src/hooks_wheel_ffb.cpp", 'no literal MOZA DirectInput name; trying first attached non-virtual FFB device', "R3 FFB fallback") +forbid("src/hooks_wheel_ffb.cpp", 'Keep the current software damper for this first comparison build.', "no stale software-damper block") + +# Live DD-wheel safety and F11 tuning behavior. +require("src/hooks_wheel_ffb.cpp", 'disabled live; all effects zeroed immediately', "immediate live FFB disable") +require("src/hooks_wheel_ffb.cpp", 'void apply_live_effect_gain()', "live effect-gain helper") +require("src/hooks_wheel_ffb.cpp", 'effect->SetParameters(¶ms, DIEP_GAIN)', "DirectInput DIEP_GAIN update") +require("src/hooks_wheel_ffb.cpp", 'if (hr == DIERR_EFFECTPLAYING)', "gain update handles non-dynamic drivers") +require("src/hooks_wheel_ffb.cpp", 'DIEP_GAIN | DIEP_START', "gain update explicit restart fallback") +require("src/hooks_wheel_ffb.cpp", 'lastEffectGain_ = configured_effect_gain();', "initial gain cache") +require("src/hooks_wheel_ffb.cpp", 'hardware periodic effects disabled live; using ConstantForce fallback', "live periodic backend disable") +require("src/hooks_wheel_ffb.cpp", 'roadState_.lastMagnitude != 0', "watchdog covers road periodic") +require("src/hooks_wheel_ffb.cpp", 'slipState_.lastMagnitude != 0', "watchdog covers tire periodic") +require("src/hooks_wheel_ffb.cpp", 'DWORD lastEffectGain_ = 0xFFFFFFFFu;', "gain recovery sentinel") + +# Second-round lifecycle/safety review. +require("src/hooks_wheel_ffb.cpp", 'std::fill_n(speedHistory_, SpeedHistoryCount, 0.0f);', "stale speed history reset") +require("src/hooks_wheel_ffb.cpp", 'lateralHistoryIndex_ = 0;', "stale lateral history reset") +require("src/hooks_wheel_ffb.cpp", 'prevCollisionFlags_ = 0;', "collision edge state reset") +require("src/hooks_wheel_ffb.cpp", 'if (!appActive_)', "background FFB reacquire gate") +require("src/hooks_wheel_ffb.cpp", 'self->device_->Unacquire();', "focus-loss exclusive release") +require("src/hooks_wheel_ffb.cpp", 'HWND subclassHwnd_ = nullptr;', "idempotent window subclass state") +require("src/hooks_wheel_ffb.cpp", 'if (!exitProcessHook_)', "idempotent ExitProcess hook") +require("src/hooks_wheel_ffb.cpp", 'DWORD nextGainRetryTick_ = 0;', "live gain failure backoff state") +require("src/hooks_wheel_ffb.cpp", 'nextGainRetryTick_ = GetTickCount() + 250;', "live gain retry backoff") +require("src/hooks_wheel_ffb.cpp", 'static_cast(Settings::WheelFFBRoadTexture) / 0.20f', "road-detail scales splash") + +# R3-specific legacy defaults proven by physical testing. +require("src/hooks_input.cpp", 'Setting SteeringDeadZone{ "Controls", "SteeringDeadZone", 0.0f,', "0-percent wheel deadzone source default") +require("src/hooks_input.cpp", 'Setting WheelAccelerationInvert{ "Controls", "WheelAccelerationInvert", false,', "R3 accelerator default") +require("src/hooks_wheel_input_compat_v2.hpp", '"Controls", "WheelPedalSplitFix", false,', "separate-pedal default") +require("OutRun2006Tweaks.ini", 'SteeringDeadZone = 0.0', "shipped 0-percent deadzone") +require("OutRun2006Tweaks.ini", 'WheelAccelerationInvert = false', "shipped accelerator INI") + +# Fixed R3 menu helpers must follow the same wheel, not the first random pad, +# and must recover after a stale/replugged DirectInput handle. +require("src/hooks_wheel_r3_menu_dpad.hpp", 'DIEDFL_ATTACHEDONLY | DIEDFL_FORCEFEEDBACK', "D-pad FFB-only enumeration") +require("src/hooks_wheel_r3_menu_dpad.hpp", 'no literal MOZA name; trying first attached FFB wheel', "D-pad R3 fallback") +require("src/hooks_wheel_r3_menu_dpad.hpp", 'retryAfter = now + 500;', "D-pad stale-handle recovery") +require("src/hooks_wheel_r3_menu_ab.hpp", 'DIEDFL_ATTACHEDONLY | DIEDFL_FORCEFEEDBACK', "A/B FFB-only enumeration") +require("src/hooks_wheel_r3_menu_ab.hpp", 'no literal MOZA name; trying first attached FFB wheel', "A/B R3 fallback") +require("src/hooks_wheel_r3_menu_ab.hpp", 'retryAfter = now + 500;', "A/B stale-handle recovery") +forbid("src/hooks_wheel_r3_device_autoselect.hpp", 'Settings::WheelFFBDeviceName = "";', "do not erase configured FFB name") +forbid("src/hooks_wheel_r3_device_autoselect.hpp", 'Settings::WheelMenuR3DeviceName = "";', "do not erase configured menu name") + +# Universal profile must own menu input when enabled and never silently jump to +# another cached device or assume its filtered combo index equals a game slot. +require("src/hooks_wheel_r3_menu_dpad.hpp", '!Settings::WheelUniversalSetupEnable &&', "universal excludes fixed R3 D-pad helper") +require("src/hooks_wheel_r3_menu_ab.hpp", '!Settings::WheelUniversalSetupEnable &&', "universal excludes fixed R3 A/B helper") +require("src/overlay/wheel_setup_ui.cpp", 'bool select_by_identity(const std::string& wantedGuid, const std::string& wantedName)', "GUID-aware universal selection") +require("src/overlay/wheel_setup_ui.cpp", 'if (index < 0 && guid.empty() && wantedName.empty() && !devices_.empty()) index=0;', "strict saved universal device") +require("src/overlay/wheel_setup_ui.cpp", 'release_device();\n devices_.clear();', "universal stale-device re-enumeration") +require("src/overlay/wheel_setup_ui.cpp", 'is_virtual_name(product) || is_virtual_name(instanceName)', "virtual instance filtering") +require("src/overlay/wheel_setup_ui.cpp", 'static int regular_device_count()', "legacy slot count") +require("src/overlay/wheel_setup_ui.cpp", 'ImGui::BeginCombo("Legacy input slot"', "explicit legacy input slot") +forbid("src/overlay/wheel_setup_ui.cpp", 'WheelUniversalLegacyDeviceIndex = std::clamp(index, 0, 2);', "do not equate filtered device index with game slot") +require("src/overlay/wheel_setup_ui.cpp", 'pure_universal_menu_query(switches)', "universal menu query scope") +require("src/overlay/wheel_setup_ui.cpp", '(menuHeld_ & switches) == switches', "universal SwitchNow exact mask") +require("src/overlay/wheel_setup_ui.cpp", '(menuPressed_ & switches) == switches', "universal SwitchOn exact mask") +require("src/overlay/wheel_setup_ui.cpp", 'const int regularCount = std::max(device_count() - 1, 0);', "live legacy-slot clamp") +require("src/overlay/wheel_setup_ui.cpp", 'Settings::WheelUniversalLegacyDeviceIndex = currentSlot;', "UI slot clamp persistence") +require("src/overlay/wheel_setup_ui.cpp", 'ImGui::SliderInt("Steering Deadzone", &deadzonePercent, 0, 20, "%d%%")', "0-percent wheel deadzone UI") +require("src/overlay/wheel_setup_ui.cpp", 'ImGui::SeparatorText("Simulation FFB")', "combined simulation FFB panel") + +for path in ( + "src/hooks_wheel_ffb.cpp", + "src/hooks_wheel_input_compat_v2.hpp", + "src/hooks_wheel_r3_menu_dpad.hpp", + "src/hooks_wheel_r3_menu_ab.hpp", + "src/hooks_wheel_r3_device_autoselect.hpp", + "src/overlay/wheel_setup_ui.cpp", + "OutRun2006Tweaks.ini", +): + digest(path) + +print("Wheel effective-build verification passed") diff --git a/tools/verify_wheel_ffb_current.py b/tools/verify_wheel_ffb_current.py new file mode 100644 index 000000000..f6427f6e2 --- /dev/null +++ b/tools/verify_wheel_ffb_current.py @@ -0,0 +1,498 @@ +from pathlib import Path +import re + +ROOT = Path(__file__).resolve().parent.parent + +def read(rel): + p = ROOT / rel + if not p.exists(): + raise SystemExit(f'CURRENT VERIFY FAILED [missing file]: {rel}') + return p.read_text(encoding='utf-8') + +def req(text, needle, label): + if needle not in text: + raise SystemExit(f'CURRENT VERIFY FAILED [{label}]: {needle!r}') + print(f'OK [{label}]') + +def forbid(text, needle, label): + if needle in text: + raise SystemExit(f'CURRENT VERIFY FAILED [{label}]: forbidden {needle!r}') + print(f'OK [{label}]') + +ffb = read('src/hooks_wheel_ffb.cpp') +vib = read('src/hooks_forcefeedback.cpp') +dyn = read('src/hooks_wheel_vehicle_dynamics.hpp') +math = read('src/wheel_ffb_math.hpp') +input_cpp = read('src/input_manager.cpp') +input_hpp = read('src/input_manager.hpp') +bind_ui = read('src/overlay/input_bindings_ui.cpp') +wheel_ui = read('src/overlay/wheel_setup_ui.cpp') +profiles = read('src/wheel_profile_store.hpp') +runtime = read('src/wheel_ffb_runtime.hpp') +build = read('src/hooks_wheel_ffb_build.cpp') +ini = read('OutRun2006Tweaks.ini') +workflow = read('.github/workflows/build.yml') + +if (ROOT / 'src/hooks_wheel_physics_sat.hpp').exists(): + raise SystemExit('CURRENT VERIFY FAILED [obsolete Physics SAT helper still active]') + +for rel, text in [ + ('src/hooks_wheel_ffb.cpp', ffb), + ('src/hooks_wheel_ffb_build.cpp', build), + ('src/hooks_forcefeedback.cpp', vib), + ('src/hooks_wheel_vehicle_dynamics.hpp', dyn), + ('src/input_manager.cpp', input_cpp), + ('src/overlay/input_bindings_ui.cpp', bind_ui), + ('src/overlay/wheel_setup_ui.cpp', wheel_ui), + ('src/wheel_profile_store.hpp', profiles), +]: + if '\x00' in text: + raise SystemExit(f'CURRENT VERIFY FAILED [NUL byte in source]: {rel}') + if text.count('{') != text.count('}'): + raise SystemExit(f'CURRENT VERIFY FAILED [brace balance]: {rel}') + print(f'OK [brace balance {rel}]') + +req(ini, 'UseNewInput = true', 'shipped SDL multi-device input default') +for rel in ( + 'src/wheel_profile_store.hpp', 'src/hooks_input.cpp', + 'src/overlay/settings_ui.cpp', 'src/overlay/overlay.cpp', + 'CMakeLists.txt', 'cmake.toml', 'README.md', 'WHEEL_FFB.md', + 'RELEASE_NOTES_v0.1.md', '.github/workflows/build.yml', +): + req(workflow, rel, f'source snapshot includes verifier dependency {rel}') +req(input_cpp, 'float InputManager_SteeringValue()', 'InputManager steering bridge exported') +req(ffb, 'const float steering = InputManager_SteeringValue();', 'SAT reads active InputManager steering') +req(input_cpp, 'SDL_GetJoysticks', 'SDL raw joystick enumeration') +req(bind_ui, 'Input Bindings', 'single input-binding UI') +req(wheel_ui, 'Force Feedback', 'dedicated FFB UI') +req(wheel_ui, 'Legacy Wheel Setup', 'legacy input kept behind compatibility mode') +req(wheel_ui, 'draw_device_combo("FFB Output", true', 'FFB selector filters to FFB-capable interfaces') +req(wheel_ui, 'autoMatchedFfbGuid', 'FFB selector mirrors DeviceName substring auto-match before GUID save') +req(wheel_ui, 'draw_device_combo("Legacy Input Wheel", false', 'legacy input identity selected separately') +req(input_hpp, 'Path is only a duplicate-device tie-breaker/fallback', 'USB path is not hard binding identity') +compat_v2 = read('src/hooks_wheel_input_compat_v2.hpp') +r3_dpad = read('src/hooks_wheel_r3_menu_dpad.hpp') +r3_ab = read('src/hooks_wheel_r3_menu_ab.hpp') +req(compat_v2, 'return Settings::WheelInputCompatibility && !Settings::UseNewInput;', 'legacy V2 installed for live universal switching') +req(wheel_ui, 'bool validate() override { return Settings::WheelInputCompatibility && !Settings::UseNewInput; }', 'universal legacy hook installed for live switching') +req(r3_dpad, '!Settings::UseNewInput &&\n Settings::WheelMenuR3DirectDPad;', 'R3 DPad hook can remain dormant under universal ownership') +req(r3_ab, '!Settings::UseNewInput &&\n Settings::WheelMenuR3DirectAB;', 'R3 AB hook can remain dormant under universal ownership') +forbid(wheel_ui, 'Settings::WheelMenuR3DirectDPad = false;', 'universal toggle preserves R3 DPad preference') +forbid(wheel_ui, 'Settings::WheelMenuR3DirectAB = false;', 'universal toggle preserves R3 AB preference') +forbid(wheel_ui, 'Restart after changing the physical wheel base', 'FFB selector does not demand unnecessary restart') + +req(ffb, 'GUID_ConstantForce', 'DirectInput ConstantForce effect') +req(ffb, '#include "Proxy.hpp"', 'FFB backend can access original proxy module') +req(ffb, 'GetProcAddress(proxy::origModule, "DirectInput8Create")', 'FFB backend uses original Windows DirectInput export') +forbid(ffb, 'createDirectInput = &::DirectInput8Create;', 'FFB backend never falls back through its own dinput8 proxy export') +req(ffb, 'std::vector failedInterfaces_;', 'FFB backend can quarantine multiple rejected interfaces') +req(ffb, 'active_failed_interface_count()', 'FFB backend walks all rejected candidates instead of only one sibling') +req(ffb, 'const bool ready = initialize();', 'FFB candidate probing performs one potentially expensive interface open per update tick') +req(ffb, 'next compatible interface will be probed on the next update tick', 'rejected sibling probing yields back to the game thread') +forbid(ffb, 'while (!ready)', 'FFB candidate probing never loops over multiple device opens in one frame') +forbid(ffb, 'MaxInterfaceProbes', 'FFB candidate probing has no arbitrary interface-count ceiling') +req(ffb, 'DIPROP_VIDPID', 'FFB sibling selection can prefer matching physical VID/PID') +req(ffb, 'requirePreferredProductGuid', 'FFB sibling selection prefers the DirectInput product GUID for one physical device') +req(ffb, 'instance->guidFFDriver', 'FFB sibling selection can use the force-feedback driver identity') +req(ffb, 'requirePreferredDriverVendor', 'FFB sibling selection can fall back to same FFB driver plus USB vendor') +req(ffb, 'requirePreferredVidPid', 'FFB sibling selection has a same-VID/PID pass before name fallback') +req(ffb, 'DWORD axes[2] = { DIJOFS_X, DIJOFS_Y };', 'ConstantForce tries canonical X/Y polar descriptor independent of actuator count') +req(ffb, 'detected actuator one-axis CARTESIAN descriptor', 'ConstantForce can fall back to the actual enumerated actuator axis') +req(ffb, 'for (const DWORD detectedAxis : actuatorAxes_)', 'ConstantForce probes every enumerated actuator axis after canonical X') +forbid(ffb, 'actuatorAxes_.size() < 2', 'FFB actuator enumeration is not arbitrarily capped at two objects') +req(ffb, 'zero-force live SetParameters probe', 'ConstantForce candidate must validate the live update path at zero torque') +req(ffb, 'mark_selected_interface_failed("ConstantForce live SetParameters", hr)', 'runtime ConstantForce failure quarantines the bad interface before reinit') +req(ffb, 'FFB_CONSTANT_LIVE_FAILURE_LIMIT = 3', 'runtime ConstantForce quarantine requires repeated live-output failure') +req(ffb, 'if (!record_constant_live_failure())', 'single ConstantForce live-output failure is retained as transient') +req(ffb, 'transient ConstantForce update failure', 'transient ConstantForce failures are logged without immediate interface blacklist') +req(ffb, 'if (!tick_before(retryNow, retryAfter_))', 'transient short retry is not overwritten by rejected-interface backoff') +req(ffb, 'previous rejected-interface quarantine must not', 'FFB disable hard-resets failed-interface recovery state') +req(ffb, 'if (!reacquire_after_input_loss("ConstantForce", hr) || !constantEffect_)', 'ConstantForce transient input loss keeps the recovery grace window') +req(ffb, 'if (!reacquire_after_input_loss("GUID_Spring", hr))', 'Spring transient input loss keeps the recovery grace window') +req(ffb, 'if (!reacquire_after_input_loss("GUID_Damper", hr))', 'Damper transient input loss keeps the recovery grace window') +req(ffb, 'if (!reacquire_after_input_loss("GUID_Sine periodic", hr))', 'Periodic transient input loss keeps the recovery grace window') +req(ffb, 'game window not available yet; retrying shortly', 'missing game window uses the short device retry') +req(ffb, 'Settings::write(Module::UserIniPath)', 'auto-selected working FFB GUID is persisted for the next launch') +req(ffb, 'saved FFB GUID unavailable or rejected; probing compatible sibling interfaces', 'saved FFB GUID can fall back to sibling interface') +req(ffb, 'FFB_DEVICE_FAILED_BACKOFF_MS = 10000', 'failed FFB interface retry is throttled') +req(ffb, 'device_->SendForceFeedbackCommand(DISFFC_RESET)', 'FFB interface reset before actuator enable') +req(ffb, 'GUID_Spring', 'DirectInput Spring effect') +req(ffb, 'GUID_Damper', 'DirectInput Damper effect') +req(ffb, 'WheelFFB_UpdateAfterPhysics(EVWORK_CAR* car)', 'post-physics FFB entry point') +forbid(ffb, 'CalcVibrationHook_', 'no competing CalcVibrationValues FFB hook') +req(vib, 'if (WheelFFB_IsOutputOwnerActive())', 'rumble suppression follows actual FFB ownership') +req(ffb, 'bool WheelFFB_IsOutputOwnerActive()', 'FFB ownership query exported') +req(ffb, 'deviceAcquired_ && !deviceReinitPending_ && !panicStopped_', 'FFB ownership requires acquired healthy output') +req(ffb, 'teardown_for_reinitialize("live disable")', 'live FFB disable fully releases output') +req(ffb, 'setting->hidden(true);', 'generic WheelFFB settings hidden') +req(vib, 'GamePlCar_Ctrl.call(car);\n WheelFFB_UpdateAfterPhysics(car);', 'post-physics execution order') + +calc = vib.find('CalcVibrationValues(car);') +ctrl = vib.find('GamePlCar_Ctrl.call(car);') +wheel = vib.find('WheelFFB_UpdateAfterPhysics(car);') +if not (0 <= calc < ctrl < wheel): + raise SystemExit('CURRENT VERIFY FAILED [vibration/physics/FFB ordering]') +print('OK [vibration before physics; wheel FFB after physics]') + +# Vehicle dynamics is now estimation-only; the main FFB engine owns force shaping. +req(ffb, '#include "hooks_wheel_vehicle_dynamics.hpp"', 'vehicle dynamics helper included') +req(dyn, 'class WheelVehicleDynamics', 'estimator separated from FFB torque') +forbid(dyn, 'satStrength', 'estimator owns no SAT strength') +forbid(dyn, 'gripLoss', 'estimator owns no grip tuning') +forbid(dyn, 'trailShape', 'estimator owns no tire-force curve') +req(dyn, 'roadWheelAngle - bodySlip_ - yawRate_ * yawLeadSeconds', 'front-slip proxy') +req(dyn, 'constexpr int CalibrationSamplesRequired = 12;', 'multi-sample basis calibration') +req(dyn, 'bestScore >= 0.85f && calibrationConfidence_ >= 0.25f', 'basis confidence gate') +req(dyn, 'motionScale > motionScaleEma_ * 5.0f', 'restart/warp discontinuity guard') +req(dyn, 'if (speedNorm <= 0.04f)', 'stopped-car dynamic state reset') +req(dyn, 'headingDelta * 60.0f', 'fixed 60 Hz yaw-rate derivative') +req(dyn, 'car->spd_mb_20.x', 'spd_mb telemetry retained') +req(dyn, 'if (invalidTicks_ > 4)\n clear_dynamic_state();', 'stale dynamics cleared after invalid telemetry') + +# field_264/268 are lateral load only. Slip/grip must come from dynamics. +req(ffb, 'const float lateralSum = car->field_264 + car->field_268;', 'OutRun lateral-G source retained') +req(ffb, 'const float lateralLoad = std::clamp(std::abs(latNorm), 0.0f, 1.0f);', 'lateral signal used as load magnitude') +req(ffb, 'vehicleDynamics_.update(car, steer, speedNorm, lateralLoadSmooth);', 'dynamics updated independently') +forbid(ffb, 'driftAmt', 'lateral-G drift detector removed') +forbid(ffb, 'gripFactor', 'shared lateral-G grip factor removed') +req(ffb, 'const float bodySlideT = std::clamp(', 'body slip drives chassis slide') +req(ffb, 'const float frontScrubT = std::clamp(', 'front slip drives tire scrub') +req(ffb, 'const float springStrength = std::clamp(\n static_cast(Settings::WheelFFBSpringStrength) * springSpeed,', 'spring independent of grip/slip') +req(ffb, 'const float damperSlipRelief = std::max(bodySlide, frontScrub * 0.75f);', 'damper reveals front scrub and chassis slide') +req(ffb, 'const float damperRelease = 1.0f - 0.55f * gripLoss * damperSlipRelief;', 'damper release uses real slip state') +req(ffb, 'frontScrub * (0.50f + 0.50f * lateralLoadSmooth) +\n bodySlide * 0.20f', 'tire slip driven mainly by front scrub') +req(ffb, 'const float naturalSlideRelief =\n 1.0f - 0.25f * gripLoss * bodySlide;', 'Natural SAT unloads from real slide') +req(ffb, 'const float rearSlideRelief = 1.0f - 0.15f * gripLoss * bodySlide;', 'Physics SAT avoids body-slip double unload') +req(ffb, 'WheelFFBMath::pneumatic_sat_shape(frontSlip, trailResponseSlip)', 'production pneumatic SAT curve separates force and trail transients') +req(ffb, 'WheelFFBMath::combined_sat_shape(\n frontSlip, trailResponseSlip, mechanicalTrailMix)', 'Physics SAT combines pneumatic and mechanical total trail') +req(ffb, '(frontSlip > 0.0f ? -1.0f : 1.0f)', 'Physics SAT direction follows front slip') +forbid(ffb, 'physicsGrip', 'old strong body-slip SAT double-unload removed') + +req(wheel_ui, 'Physics SAT (body slip + yaw)', 'versionless Physics SAT UI') +req(wheel_ui, 'Load MOZA R3 Physics SAT', 'versionless Physics SAT preset') +req(wheel_ui, 'Grip-loss Response', 'grip-loss UI reflects broader role') +req(wheel_ui, 'Test Left (20%)', 'safe left test') +req(wheel_ui, 'Test Right (20%)', 'safe right test') +req(ffb, 'DI_FFNOMINALMAX', 'nominal DirectInput device gain') +req(ffb, 'WheelFFB: scheduling DirectInput device reinitialization', 'device recovery path') +req(ffb, 'SnowIceRoadTextureScale = 0.04f', 'snow/ice periodic attenuation') +req(ffb, 'std::isfinite', 'non-finite input/output guards') +req(wheel_ui, 'Settings::WheelFFBInvertForce = true;', 'R3 ConstantForce direction baseline') +req(wheel_ui, 'Settings::WheelFFBInvertSpring = false;', 'R3 Spring direction baseline') +req(wheel_ui, 'Settings::VibrationMode = 0;', 'R3 preset disables gamepad rumble') +req(ffb, 'load={:.2f} slide={:.2f} scrub={:.2f}', 'separated load/slide/scrub diagnostics') +req(ffb, 'step={:.5f} spdLen={:.5f} spdCorr={:.2f}', 'velocity diagnostic telemetry') + +for pattern, label in [ + (r'(?m)^SteeringDeadZone\s*=\s*0\.0\s*$', 'zero steering deadzone'), + (r'(?m)^WheelAccelerationInvert\s*=\s*false\s*$', 'accelerator normal polarity'), + (r'(?m)^WheelBrakeInvert\s*=\s*false\s*$', 'brake normal polarity'), +]: + if not re.search(pattern, ini): + raise SystemExit(f'CURRENT VERIFY FAILED [{label}]') + print(f'OK [{label}]') + +# These are structural guards, not a substitute for the compiled numeric tests. +cmake = read('CMakeLists.txt') +toml = read('cmake.toml') +for text in (cmake, toml): + req(text, 'set_source_files_properties(src/hooks_wheel_ffb.cpp PROPERTIES HEADER_FILE_ONLY TRUE)', 'shim implementation is not a second TU') +for path in ('CMakeLists.txt', 'cmake.toml', '.github/workflows/build.yml'): + forbid(read(path), 'tools/archive/rounds/', 'archive outside build graph') + forbid(read(path), 'patch_wheel_round', 'no round patch invocation') +forbid(bind_ui, 'draw_force_feedback', 'no dead alternate FFB page') +forbid(ffb, 'WheelFFBLowSpeedSpring', 'unused low-speed setting removed') +forbid(ffb, 'WheelFFBSpringLoadBoost', 'unused load setting removed') +forbid(ffb, 'effect.dwDuration = INFINITE', 'effects have driver-side leases') +req(ffb, 'FFB_EFFECT_LEASE_US = 250000', '250ms driver lease') +req(ffb, 'FFB_EFFECT_REFRESH_MS = 100', '100ms lease renewal') +req(ffb, 'params.dwFlags = DIEFF_POLAR | DIEFF_OBJECTOFFSETS', 'explicit polar update descriptor') +req(read('src/hooks_framerate.cpp'), 'WheelFFB_ServiceSafety();', 'safety is serviced outside player-car callback') +req(wheel_ui, 'Save Force Feedback', 'dedicated FFB save action') +req(profiles, 'OutRun2006Tweaks.profiles', 'named wheel/FFB profile root') +req(profiles, 'Kind::Input ? "Input" : "FFB"', 'separate input and FFB profile folders') +req(profiles, 'InputBackend = ', 'wheel profile stores backend choice') +req(profiles, 'SteeringDeadZone = ', 'wheel profile stores steering deadzone') +req(profiles, 'BypassGameSensitivity = ', 'wheel profile stores sensitivity bypass') +req(profiles, 'FFBDeviceName = ', 'wheel profile stores FFB output name') +req(profiles, 'FFBDeviceGuid = ', 'wheel profile stores exact FFB output GUID') +forbid(profiles, 'UseNewInput = ', 'wheel profile never switches the input architecture live') +req(profiles, 'key != "DeviceName" && key != "DeviceGuid"', 'FFB profiles never reroute the output device') +req(profiles, 'key != "Telemetry" && key != "DebugLog"', 'FFB profiles keep diagnostics global') +req(profiles, 'load_ffb_profile(', 'transactional named FFB profile loader') +req(bind_ui, 'ImGui::BeginTabItem("Profiles")', 'input binding UI exposes wheel profiles') +req(bind_ui, 'WheelProfileStore::append_input_options', 'wheel profile saves wheel-specific input options') +req(bind_ui, 'manager.saveBindingIni(Module::BindingsIniPath)', 'loaded wheel profile is durable in canonical binding file') +req(bind_ui, 'wheel_profile_exists_cached', 'input profile overwrite check uses cached list') +req(bind_ui, 'WheelProfileStore::profile_exists(WheelProfileStore::Kind::Input, requestedName)', 'input profile rechecks overwrite on save click') +req(wheel_ui, 'ffb_profile_exists_cached', 'FFB profile overwrite check uses cached list') +req(wheel_ui, 'WheelProfileStore::profile_exists(WheelProfileStore::Kind::ForceFeedback, requested)', 'FFB profile rechecks overwrite on save click') +req(input_hpp, 'file.flush();', 'binding/profile writer checks buffered write errors') +req(wheel_ui, 'Force Feedback Profiles', 'FFB UI exposes named profiles') +req(wheel_ui, 'WheelProfileStore::save_ffb_profile', 'FFB UI saves named profile') +req(wheel_ui, 'WheelProfileStore::load_ffb_profile', 'FFB UI loads named profile') +req(wheel_ui, 'WheelFFB_RequestSettingsTransition();', 'profile/preset switching requests safe FFB transition') +req(wheel_ui, 'WheelFFBDamperStrength.ptr(), 0.0f, 1.0f', 'Dynamic Damping UI exposes full supported range') +req(ffb, 'void settings_transition()', 'FFB engine has profile transition safety path') +req(ffb, 'updateCounter_ = 59;', 'profile transition recreates newly enabled hardware effects promptly') +req(ffb, 'settings/profile transition; forces zeroed and warm-up restarted', 'profile transition is observable in logs') +req(ffb, 'void WheelFFB_RequestSettingsTransition()', 'FFB settings transition exported to UI') +req(dyn, 'rawFrontSlip_', 'raw front-slip telemetry retained') +req(dyn, 'frontSlipBlend_ = 0.24f + (0.58f - 0.24f) * transientT;', 'front-slip transient accelerates with speed') +req(dyn, 'bodySlipBlend_ = 0.18f + (0.34f - 0.18f) * transientT;', 'body-slip transient accelerates with speed') +req(dyn, 'yawRateBlend_ = 0.20f + (0.38f - 0.20f) * transientT;', 'yaw transient accelerates with speed') +req(math, 'lateral_force_shape(float alpha)', 'front lateral-force proxy curve') +req(math, 'pneumatic_trail_factor(float alpha)', 'pneumatic trail separated') +req(math, 'mechanical_sat_shape(float forceAlpha, float mechanicalTrailRatio)', 'mechanical trail is an explicit lateral-force term') +req(math, 'float forceAlpha, float trailAlpha, float mechanicalTrailRatio', 'combined SAT separates force and trail transients') +forbid(math, 'mix * fy * (1.0f - pneumatic)', 'mechanical trail is never gated as a pneumatic fallback') +req(ffb, 'WheelFFBMechanicalTrail', 'mechanical trail setting') +req(ffb, 'WheelFFBTrailResponseLead', 'pneumatic trail phase-lead setting') +req(ffb, 'trailResponseSlip = std::clamp(', 'pneumatic trail uses bounded phase-led slip') +req(ffb, 'WheelFFBMath::pneumatic_sat_shape(frontSlip, trailResponseSlip)', 'lateral force remains filtered while trail response can lead') +req(ffb, 'trailResponseSlip={} trailResponseLead={}', 'phase-lead state is visible in telemetry') +req(wheel_ui, 'Pneumatic Trail Response Lead', 'phase-lead tuning is reachable in FFB UI') +req(ffb, 'WheelFFB SATMODEL', 'raw/filtered and decomposed SAT telemetry') +req(runtime, 'WheelFFBHeadroomSnapshot', 'shared headroom snapshot API') +req(ffb, 'record_headroom(std::abs(total), headroomEligible);', 'structural headroom analyzer') +req(ffb, 'headroomSamples_ >= 600', 'gain recommendation waits for enough driving') +req(wheel_ui, 'FFB Headroom / Clipping', 'headroom UI exposed') +req(wheel_ui, 'Suggested Overall Strength', 'headroom gain recommendation exposed') +req(wheel_ui, 'ffbDirty_ = true;\n WheelFFB_ResetHeadroomStats();', 'live FFB edits reset stale headroom statistics') +req(wheel_ui, 'Physics / Structural', 'FFB UI groups structural physics') +req(wheel_ui, 'Steering Feel', 'FFB UI groups steering feel') +req(wheel_ui, 'Effects', 'FFB UI groups tactile effects') +req(wheel_ui, 'Mechanical / Caster Trail', 'mechanical trail tuning exposed') +req(ffb, 'WheelFFBResponseCorrection', 'optional response correction setting') +req(ffb, 'WheelFFBResponseLUT', 'per-wheel response LUT setting') +req(math, 'parse_response_lut', 'response LUT validation') +req(math, 'apply_response_lut', 'response LUT application') +req(ffb, 'apply_response_correction(levelBeforeResponse)', 'response correction is final ConstantForce mapping') +req(profiles, 'FFBResponseCorrection = ', 'wheel profile stores response correction enable') +req(profiles, 'FFBResponseLUT = ', 'wheel profile stores response LUT') +req(profiles, 'FFBMaxTorqueNm = ', 'wheel profile stores physical max torque') +req(profiles, 'key != "ResponseCorrection" && key != "ResponseLUT"', 'FFB feel profiles exclude hardware response curve') +req(profiles, 'key != "MaxTorqueNm"', 'FFB feel profiles exclude physical torque rating') +req(wheel_ui, 'Leave correction off for a linear DD wheel unless measured.', 'response correction UI defaults to measurement-first guidance') +req(ini, 'MechanicalTrail = 0.25', 'shipped mechanical trail default') +req(ini, 'TrailResponseLead = 0.25', 'shipped pneumatic trail phase-lead default') +req(ini, 'ResponseCorrection = false', 'shipped response correction disabled') +req(ini, 'MaxTorqueNm = 0.0', 'shipped wheel max torque unknown') +forbid(ffb, 'trying first attached non-virtual FFB device', 'no unrelated device fallback') +req(ffb, 'DIPROP_FFGAIN', 'device gain configured separately from effect gain') +forbid(ffb, 'apply_live_effect_gain', 'dead live effect-gain updater removed') +forbid(ffb, 'configured_effect_gain', 'effect gain fixed nominal at creation') +forbid(ffb, 'lastEffectGain_', 'dead live-gain state removed') +req(ffb, 'WheelFFB SAMPLE', '10Hz telemetry output') +forbid(dyn, 'sampleValid_ = calibrated_', 'stopped vehicle has no valid dynamic SAT sample') +req(dyn, 'headingValid_ = false; // The next heading', 'missing samples break derivative baseline') +req(ffb, 'const float physicsFallback = naturalSatTorque;', 'full Natural SAT fallback until Physics SAT is ready') +forbid(ffb, 'const float physicsFallback = vehicleDynamics_.calibrated()', 'no calibration-tick SAT hole') +req(math, 'inline float soft_saturate(float value)', 'linear-preserving C1 force limiter') +req(ffb, 'WheelFFBMath::soft_saturate(total)', 'production force path uses soft-knee limiter') +forbid(ffb, 'std::tanh(total)', 'ordinary SAT is not compressed by tanh') +req(ffb, 'bool steerSampleValid_ = false;', 'steering derivative has explicit baseline state') +req(ffb, 'smoothedSteerRate_ += (rawSteerRate - smoothedSteerRate_) * 0.45f;', 'steering-rate quantization filter') +req(ffb, 'steerRateRaw={} steerRateFiltered={}', 'raw and filtered steering-rate telemetry') +req(ffb, 'damperRequested={} damperRelease={} damperCoefficient={}', 'damper release visible in telemetry') +req(ffb, 'const LONG releaseMaxSlew = std::min(', 'stale torque has a dedicated faster release rate') +req(ffb, 'const bool oppositeTorqueDirection =', 'torque sign reversal is detected explicitly') +req(ffb, 'if (std::abs(prevStructuralLevel_) <= reversalReleaseMaxSlew)\n structuralLevel = 0;', 'sign reversal unloads old torque to zero before rebuilding') +req(ffb, 'unloadingStructural ? releaseMaxSlew : maxSlew', 'same-sign force unload remains faster than force build') +forbid(ffb, 'naturalSatTorque * 0.15f', 'Physics mode never falls back to weak 15 percent steering') +req(ffb, 'vehicleDynamics_.calibrated() && vehicleDynamics_.sampleValid()', 'Physics SAT requires a current valid motion sample') +req(ffb, 'const float physicsMix = vehicleDynamics_.sampleValid()', 'invalid dynamics falls back to Natural SAT immediately') +req(dyn, 'void reset_dynamic()', 'dynamic reset preserves calibrated vehicle basis') +req(ffb, 'vehicleDynamics_.reset_dynamic();', 'engine transitions preserve dynamics calibration') +req(dyn, 'calibrationSamples_ >= CalibrationSamplesRequired * 4', 'ambiguous basis calibration retries with a fresh window') +forbid(ffb, 'bypassSlew', 'events never bypass sustained steering slew') +req(ffb, 'const LONG eventLevel = static_cast', 'events have an independent immediate output path') +req(ffb, 'const float softwareSpringSign = Settings::WheelFFBInvertSpring', 'Reverse Spring also controls software fallback') +req(ffb, 'driver autocenter state could not be read; leaving it unchanged', 'unknown driver autocenter state is never mutated') +req(ffb, 'smoothedLongAccel_ +=', 'weight-transfer acceleration is filtered') +req(ffb, 'std::clamp(configuredWeightTransfer, 0.0f, 1.5f)', 'weight-transfer setting is finite and bounded') +req(ffb, 'const LONG vibrationHeadroom =', 'software vibration cannot clip steering torque') +req(ffb, 'const LONG levelBeforeResponse = baseSteeringLevel + vibrationLevel;', 'fallback vibration uses only remaining output headroom before wheel response correction') +req(ffb, 'periodicsActive_ && (updateCounter_ % 2) == 0', 'hardware periodic envelopes update at about 30 Hz') +dynamic_reset_start = dyn.find('void reset_dynamic()') +dynamic_reset_end = dyn.find('void update(', dynamic_reset_start) +if not (0 <= dynamic_reset_start < dynamic_reset_end): + raise SystemExit('CURRENT VERIFY FAILED [dynamic reset section]') +dynamic_reset = dyn[dynamic_reset_start:dynamic_reset_end] +req(dynamic_reset, 'motionScaleEma_ = 0.0f;', 'dynamic reset clears old motion-scale EMA') +req(dynamic_reset, 'motionScaleSamples_ = 0;', 'dynamic reset clears old motion-scale sample count') +req(ffb, 'if (!car)\n {\n if (initialized_)\n {\n zero_all_forces();', 'null car immediately clears active DD torque') +req(ffb, '? (vehicleDynamics_.sampleValid() ? "ACTIVE" : "FALLBACK")', 'diagnostics report Natural fallback instead of stale Physics hold') +forbid(ffb, '? (vehicleDynamics_.sampleValid() ? "ACTIVE" : "HOLD")', 'diagnostics never claim invalid Physics SAT is held') +req(wheel_ui, 'FFB selection and tuning apply live', 'F11 footer matches live FFB reinitialization') +forbid(wheel_ui, 'Restart after switching to a different physical wheel', 'new-input FFB page has no stale restart requirement') +forbid(wheel_ui, 'Restart after switching physical devices', 'legacy FFB page has no stale restart requirement') +forbid(ffb, 'if (!initialized_ && gameHwnd_ && GetForegroundWindow() == gameHwnd_)', 'foreground recovery is not gated on initialization state') +req(ffb, 'if (gameHwnd_ && GetForegroundWindow() == gameHwnd_)', 'foreground state can recover a missed activation message') +req(ffb, 'update_spring(0.0f);\n if (springEffect_)\n springEffect_->Stop();', 'live spring disable rechecks effect after zero update') +req(ffb, 'update_damper(0.0f);\n if (damperEffect_)\n damperEffect_->Stop();', 'live damper disable rechecks effect after zero update') +forbid(ffb, 'DWORD recreateHoldoffUntil_', 'critical ConstantForce recovery has no shared periodic holdoff') +req(ffb, 'DWORD constantRecreateHoldoffUntil_ = 0;', 'ConstantForce recreation has an independent holdoff') +req(ffb, 'DWORD periodicRecreateHoldoffUntil_ = 0;', 'periodic recreation has an independent holdoff') +req(ffb, 'tick_before(now, constantRecreateHoldoffUntil_)', 'ConstantForce retries use the steering-specific holdoff') +req(ffb, 'tick_reached(GetTickCount(), periodicRecreateHoldoffUntil_)', 'periodic retries use the periodic-specific holdoff') +req(ffb, 'constantEffectPolar_ = polar;\n prevConstantLevel_ = 0;\n lastConstantWriteTick_ = 0;', 'validated ConstantForce descriptor resets accepted-output cache') +req(bind_ui, 'Press Up on the wheel D-pad/POV, or another menu button.', 'Quick Setup captures menu Up') +req(bind_ui, 'Press Right on the wheel D-pad/POV, or another menu button.', 'Quick Setup captures menu Right') +req(bind_ui, 'Press Down on the wheel D-pad/POV, or another menu button.', 'Quick Setup captures menu Down') +req(bind_ui, 'Press Left on the wheel D-pad/POV, or another menu button.', 'Quick Setup captures menu Left') +req(bind_ui, 'same_source_family', 'Quick Setup preserves unrelated input source families') +forbid(bind_ui, 'return !existing.isKeyboard();', 'Quick Setup never erases every non-keyboard binding') +req(bind_ui, 'Skip this step', 'Quick Setup supports optional controls') +req(bind_ui, 'Keep & Fine-tune', 'Quick Setup can continue into manual calibration') +req(bind_ui, 'Save & Return to game', 'manual binding edits have an explicit persistence exit') +req(wheel_ui, 'Open Input Bindings', 'Force Feedback page links directly to input setup') +req(wheel_ui, 'Overlay::RequestBindingDialog = true;', 'FFB input shortcut uses the modal request path') +req(wheel_ui, 'Advanced FFB tuning', 'dedicated FFB page exposes advanced supported tuning') +req(wheel_ui, 'Settings::WheelFFBSpringSaturation.ptr()', 'spring saturation is reachable from FFB UI') +req(wheel_ui, 'Settings::WheelFFBWeightTransfer.ptr()', 'weight transfer is reachable from FFB UI') +req(wheel_ui, 'Settings::WheelFFBSlewRate.ptr()', 'force slew is reachable from FFB UI') +readme = read('README.md') +wheel_doc = read('WHEEL_FFB.md') +req(readme, 'Load Universal Physics SAT', 'README names current universal Physics preset') +req(readme, 'Save & Return to game', 'README documents durable binding exit') +forbid(readme, 'MOZA R3 v0.1 (default)', 'README has no obsolete preset button') +forbid(readme, 'Refresh connected wheels', 'README has no obsolete refresh label') +req(wheel_doc, 'Advanced FFB tuning', 'architecture doc covers reachable advanced tuning') +forbid(wheel_doc, 'MOZA R3 v0.1 (default)', 'architecture doc has no obsolete preset button') +req(bind_ui, 'if (isListeningForInput == ListenState::False && !unsavedChanges)', 'Back/B cannot bypass unsaved binding persistence choice') +forbid(wheel_ui, '}\n\n ImGui::SameLine();\n ImGui::Checkbox("Diagnostic logging"', 'advanced FFB header does not share a line with diagnostics') +req(bind_ui, 'existingDevice->instanceId == candidateDevice->instanceId', 'Quick Setup replaces raw bindings only on the captured physical device') +forbid(bind_ui, 'if (candidate.isRawDevice()) return existing.isRawDevice();', 'Quick Setup never erases every raw-device binding family') +req(bind_ui, 'Discard edits & load?##load', 'loading cannot silently discard unsaved binding edits') +req(bind_ui, 'Could not save bindings. Check folder permissions', 'binding save failures are visible in the UI') +req(wheel_ui, 'bool ffbDirty_ = false;', 'FFB page tracks unsaved live tuning') +req(wheel_ui, 'Save Force Feedback*', 'FFB save button marks unsaved tuning') +req(wheel_ui, 'Unsaved FFB changes are active now but will be lost after restart.', 'FFB page explains live versus persisted tuning') +req(wheel_ui, 'Loaded MOZA R3 Physics SAT for this session, but could not save user.ini.', 'Physics preset never falsely reports a failed save') +req(wheel_ui, 'Loaded MOZA R3 Natural SAT for this session, but could not save user.ini.', 'Natural preset never falsely reports a failed save') +req(read('src/input_manager.cpp'), 'Settings::WheelInputCompatibility.needs_restart();', 'legacy compatibility mode is explicitly restart-bound') +req(wheel_ui, 'constexpr std::string_view prefix = "WheelUniversal";', 'raw WheelUniversal settings are owned only by dedicated legacy setup') +req(read('src/hooks_input.cpp'), 'Settings::WheelMenuDirectionFilter.hidden(Settings::UseNewInput);', 'legacy menu filter is hidden from default modern controls') +req(read('src/hooks_wheel_input_compat_v2.hpp'), 'Settings::WheelPedalSplitFix.hidden(Settings::UseNewInput);', 'legacy pedal workaround is hidden from default modern controls') +req(read('src/hooks_wheel_r3_menu_dpad.hpp'), 'Settings::WheelMenuR3DirectDPad.hidden(Settings::UseNewInput);', 'R3 legacy D-pad helper is hidden from default modern controls') +req(read('src/hooks_wheel_r3_menu_ab.hpp'), 'Settings::WheelMenuR3DirectAB.hidden(Settings::UseNewInput);', 'R3 legacy A/B helper is hidden from default modern controls') +req(read('src/overlay/settings_ui.cpp'), 'matches_search("Input Bindings", search)', 'settings search can still surface Input Bindings') +req(read('src/overlay/overlay.cpp'), 'Wheel users: configure steering, pedals, buttons and menu controls in Input Bindings', 'first-run overlay explains wheel input ownership') +release_notes = read('RELEASE_NOTES_v0.1.md') +req(release_notes, 'Load Universal Physics SAT', 'release notes name the current universal Physics preset') +req(release_notes, 'Save Force Feedback', 'release notes explain FFB persistence') +req(readme, 'Engine Vibration', 'README documents optional engine vibration') +req(readme, 'OFF by default', 'README documents engine vibration default off') +req(release_notes, 'Engine Vibration', 'release notes document optional engine vibration') +req(release_notes, '기본 OFF', 'Korean release notes document engine vibration default off') +forbid(release_notes, 'MOZA R3 v0.1 (default)', 'release notes have no deleted preset button') +forbid(release_notes, 'Dynamic Damping** | **0.42', 'release notes have no obsolete damping table') +print('CURRENT WHEEL FFB STRUCTURE VERIFIED; run verify_wheel_ffb_math.py for numerical tests') + + +# Setup/FFB UX and compatibility review guards. +req(ffb, 'WheelFFBReversalReleaseRate', 'dedicated counter-steer stale-torque release rate') +req(ffb, 'GetEffectInfo(&info, effectGuid)', 'DirectInput effect dynamic capability query') +req(ffb, 'DIEP_TYPESPECIFICPARAMS', 'dynamic type-specific capability gate') +req(ffb, 'WheelFFBStatusSnapshot status_snapshot() const', 'runtime FFB status snapshot') +req(ffb, 'WheelFFBGraphSnapshot graph_snapshot() const', 'runtime rolling force graph') +req(ffb, 'record_graph_sample(', 'force pipeline graph recorder') +req(runtime, 'WheelFFB_GetStatusSnapshot', 'status API exported to UI') +req(runtime, 'WheelFFB_GetGraphSnapshot', 'graph API exported to UI') +req(wheel_ui, 'Ready to Drive', 'setup readiness summary') +req(wheel_ui, 'FFB Runtime Status', 'driver/effect status panel') +req(wheel_ui, 'recommended: steering device', 'VID/PID steering-to-FFB recommendation') +req(wheel_ui, 'Revert unsaved FFB', 'live FFB revert action') +req(wheel_ui, 'Recent FFB Pipeline', 'FFB rolling pipeline graph') +req(wheel_ui, 'Countersteer Release Rate', 'counter-steer release UI') +req(bind_ui, 'LISTENING...##addBinding', 'manual add-binding target highlight') +req(bind_ui, 'const bool listeningHere', 'manual rebind button target highlight') +req(bind_ui, 'binding_conflicts', 'duplicate-control conflict warning') +req(bind_ui, 'Guided axis calibration', 'Quick Setup integrated axis calibration') + +req(ffb, 'result.powerOff = (state & DIGFFS_POWEROFF) != 0;', 'FFB status distinguishes explicit power-off state') +req(wheel_ui, 'ffbStatus.powerOff || ffbStatus.safetySwitchOff || ffbStatus.userSwitchOff', 'FFB UI warns only on explicit driver OFF states') +forbid(ffb, 'rejecting this FFB interface', 'ConstantForce capability metadata cannot falsely reject the only actuator path') +req(wheel_ui, 'if (persisted)\n capture_saved_ffb();', 'loaded FFB profile updates revert baseline after persistence') +req(wheel_ui, 'listeningHere ? "LISTENING..." : "Bind"', 'legacy manual binding also highlights current target') + +# final-postfix-regression-guards +req(bind_ui, 'prepared.axisMinimum = prepared.axisRest;', 'fresh Quick Setup raw axes start uncalibrated') +req(bind_ui, 'const bool readyToSaveAndDrive = coreControlsPresent && guidedCalibrationReady;', 'Quick Setup gates Save & Drive on core controls and raw calibration') +req(bind_ui, 'WheelFFB_ResetDirectionTest();', 'loading a wheel profile invalidates the old direction test') +req(runtime, 'void WheelFFB_ResetDirectionTest();', 'direction-test reset API') +req(ffb, 'directionTested_ = false;\n request_device_reinitialize("configured wheel identity changed", S_OK);', 'external FFB device identity changes invalidate direction test') +req(wheel_ui, 'if (changedOutput)\n WheelFFB_ResetDirectionTest();', 'F11 FFB device selection invalidates direction test immediately') +req(wheel_ui, 'bool debugLog = true;', 'FFB revert baseline includes diagnostic logging') +req(wheel_ui, 'savedFfb_.telemetry = Settings::WheelFFBTelemetry;', 'FFB revert baseline includes telemetry') +req(wheel_ui, 'if (currentSaved)\n capture_saved_ffb();', 'saving a named FFB profile refreshes the revert baseline') +req(wheel_ui, 'return !known ? "unknown" : (dynamic ? "yes" : "no");', 'FFB capability UI distinguishes unknown from yes') +req(wheel_ui, 'pending(\"FFB device (starts in gameplay)\");', 'Ready checklist treats menu-time FFB runtime as pending, not failed') +forbid(wheel_ui, '#include \n#include \n#include ', 'duplicate cstdint include') + +# debug10-regression-guards +req(bind_ui, 'void arm_release_guard(const InputBinding& binding)', 'Quick Setup owns a raw-axis release guard independent of calibration span') +req(bind_ui, 'releaseGuardBinding->axisMinimum = -32768;', 'zero-span Quick Setup axis gets transient release range') +if bind_ui.count('arm_release_guard(*quickSetupCandidate);') != 2: + raise SystemExit('CURRENT VERIFY FAILED [Quick Setup confirm/retry both arm the axis release guard]') +print('OK [Quick Setup confirm/retry both arm the axis release guard]') +forbid(bind_ui, 'releaseGuardBinding = *quickSetupCandidate;', 'Quick Setup never uses zero-span candidate directly as release guard') +req(profiles, 'commit_staged_profile(', 'named profile writes use staged replacement') +req(bind_ui, 'WheelProfileStore::staged_profile_path(*profilePath)', 'input profile is fully staged before overwrite') +req(bind_ui, 'WheelProfileStore::commit_staged_profile(stagedPath, *profilePath, &error)', 'input profile overwrite preserves previous file until staged write completes') +req(profiles, 'const auto staged = staged_profile_path(*path);', 'FFB profile is fully staged before overwrite') +req(profiles, 'Failed while closing staged FFB profile.', 'FFB profile close failures are reported') +req(profiles, 'Failed while closing wheel-specific input options.', 'input profile option close failures are reported') +req(wheel_ui, 'capture_saved_ffb();\n status_ = "Saved to OutRun2006Tweaks.user.ini";', 'legacy full user.ini save refreshes FFB revert baseline') +req(wheel_ui, 'capture_saved_ffb();\n status_ = "Selected FFB output and saved its exact DirectInput GUID.', 'FFB output selection refreshes revert baseline after full user.ini persistence') +req(wheel_ui, 'WheelProfileStore::directory(WheelProfileStore::Kind::ForceFeedback).string()', 'FFB page shows the actual runtime profile folder') + + +# engine-vibration-v02-regression-guards +req(ffb, 'Setting WheelFFBEngineVibration', 'engine vibration has an independent live toggle') +req(math, 'EngineHapticEstimate estimate_engine_haptics(', 'engine RPM estimator is production math') +req(ffb, 'WheelFFBMath::estimate_engine_haptics(', 'FFB core consumes the common engine estimator') +req(ffb, 'enginePhase_, engineAmp * effectRampScale, engineFreq', 'engine haptic uses ConstantForce fallback layer') +req(ffb, 'smoothedEngineRpm_ = 0.0f;', 'engine haptic state resets on transitions/off') +forbid(ffb, 'else if (speedNorm < 0.03f && car->pedal_amount_34 > 0)', 'legacy low-speed-only engine rumble removed') +req(wheel_ui, 'Checkbox("Engine Vibration", Settings::WheelFFBEngineVibration.ptr())', 'F11 exposes engine vibration toggle') +req(wheel_ui, 'SliderFloat("Engine Vibration Strength", Settings::WheelFFBEngineIdle.ptr()', 'F11 exposes engine vibration strength') +forbid(wheel_ui, 'SliderFloat("Engine Idle"', 'old duplicate Engine Idle slider removed') +req(wheel_ui, 'WheelFFBRoadTexture.ptr(), 0.0f, 1.0f', 'Road Detail UI covers the 0.60 universal preset') + + +# v01-final-engine-haptic-guards +req(ffb, '"WheelFFB", "EngineVibration", false', 'engine vibration is opt-in by default') +req(ffb, '"WheelFFB", "EngineIdle", 0.20f', 'engine vibration default strength is 0.20') +req(ffb, 'engineStrength * 0.22f * amplitudeScale * outputStrength', 'engine strength is internally kept subtle') +req(ffb, 'smoothedEngineAmp_', 'engine vibration amplitude is smoothed') +req(ffb, 'smoothedEngineFreq_', 'engine vibration frequency is smoothed') +req(ffb, 'engineReserve = std::clamp(', 'engine haptic gets a small continuity reserve') +req(wheel_ui, 'Engine Vibration Strength', 'engine vibration strength remains user-adjustable') +req(ini, 'EngineVibration = false', 'shipped engine vibration stays disabled') +req(ini, 'EngineIdle = 0.20', 'shipped optional engine strength is 0.20') + + +# v01-snow-latch-route-fork-regression-guards +req(build, 'const bool mixedTouchesSnowBaseline =', 'snow curb latch starts only from a transition touching the snow baseline') +req(build, 'const bool mixedSurface = rawMixedSurface && genuinelyRough;', 'ordinary low-roughness route-fork material changes do not trigger tactile boost') +req(build, 'const bool tactileSurface = genuinelyRough || snowCurbHeld;', 'general tactile compatibility path requires real roughness or an active snow latch') +if build.count('snowCurbHoldUntil = now + SnowCurbHoldMs;') != 1: + raise SystemExit('CURRENT VERIFY FAILED [snow curb hold deadline is re-armed outside confirmed mixed snow contact]') +print('OK [snow curb hold deadline has one confirmed-contact re-arm site]') +forbid(build, 'Extend while all tyres remain on the identified curb/shoulder.', 'uniform curb contact cannot indefinitely refresh snow latch') + +# v0.3 hardware compatibility regression guards +req(profiles, 'std::filesystem::copy_file(', 'named profile save has rename-to-copy fallback for Windows driver/filter edge cases') +req(profiles, 'Profile installation could not be verified.', 'named profile save verifies the final file exists') +req(wheel_ui, 'Direction test (not run)', 'direction-test checklist distinguishes pending from failure') +req(wheel_ui, 'Waiting / released / inactive is normal outside gameplay.', 'runtime status explains menu-time idle state') +req(wheel_ui, 'FFB profile save failed:', 'profile persistence failures are explicit') +req(wheel_ui, 'spdlog::error("{}", status_);', 'profile persistence failure is logged for remote testers') + +# first-save profile regression guards +req(profiles, 'ec == std::errc::no_such_file_or_directory', 'missing profile is a normal first-save state') +req(profiles, 'inspect_regular_file(finalPath, finalExists, "the existing profile")', 'final profile existence check uses missing-safe helper') +req(profiles, 'inspect_regular_file(backup, backupExists, "the profile backup")', 'backup existence check uses missing-safe helper') +forbid(profiles, 'if (!finalExists && std::filesystem::is_regular_file(backup, ec) && !ec)', 'backup probe no longer treats missing backup as filesystem failure') diff --git a/tools/verify_wheel_ffb_math.py b/tools/verify_wheel_ffb_math.py new file mode 100644 index 000000000..26450b834 --- /dev/null +++ b/tools/verify_wheel_ffb_math.py @@ -0,0 +1,19 @@ +"""Compile and execute the current production estimator/curve, not a Python copy.""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +ROOT = Path(__file__).resolve().parent.parent +compiler = os.environ.get('CXX') or shutil.which('c++') or shutil.which('g++') or shutil.which('clang++') or shutil.which('cl') +if not compiler: + raise SystemExit('C++ compiler required: run in VS Developer Prompt or set CXX') +with tempfile.TemporaryDirectory(prefix='wheel-ffb-tests-') as tmp: + exe = Path(tmp) / ('test.exe' if os.name == 'nt' else 'test') + source = ROOT / 'tools/test_wheel_ffb_current.cpp' + if Path(compiler).name.lower() in ('cl', 'cl.exe'): + args = [compiler, '/nologo', '/EHsc', '/std:c++20', '/utf-8', '/I'+str(ROOT/'src'), str(source), '/Fe:'+str(exe)] + else: + args = [compiler, '-std=c++20', '-Wall', '-Wextra', '-pedantic', '-I'+str(ROOT/'src'), str(source), '-o', str(exe)] + subprocess.run(args, check=True, cwd=tmp) + subprocess.run([str(exe)], check=True) diff --git a/tools/verify_wheel_final_cleanup.py b/tools/verify_wheel_final_cleanup.py new file mode 100644 index 000000000..e8f6546bf --- /dev/null +++ b/tools/verify_wheel_final_cleanup.py @@ -0,0 +1,71 @@ +from pathlib import Path +import runpy + + +def read(path: str) -> str: + return Path(path).read_text(encoding='utf-8') + + +def require(path: str, needle: str, label: str) -> None: + data = read(path) + if needle not in data: + raise SystemExit(f'FINAL CLEANUP VERIFY FAILED [{label}]: {needle!r} missing from {path}') + print(f'FINAL CLEANUP VERIFY OK [{label}]') + + +def forbid(path: str, needle: str, label: str) -> None: + data = read(path) + if needle in data: + raise SystemExit(f'FINAL CLEANUP VERIFY FAILED [{label}]: stale {needle!r} remains in {path}') + print(f'FINAL CLEANUP VERIFY OK [{label}]') + + +# Reset-to-default must not restore the original game's 20% gamepad deadzone. +require( + 'src/overlay/input_bindings_ui.cpp', + 'Settings::SteeringDeadZone = 0.0f;', + 'multi-device reset preserves zero steering deadzone') +forbid( + 'src/overlay/input_bindings_ui.cpp', + 'Settings::SteeringDeadZone = 0.2f;', + 'no stale 20-percent reset path') + +# Shipped comments must agree with the shipped multi-device default. +require( + 'OutRun2006Tweaks.ini', + '# This is the wheel-build default; force feedback remains on the custom', + 'UseNewInput documentation matches hybrid architecture') +require( + 'OutRun2006Tweaks.ini', + 'UseNewInput = true', + 'multi-device input remains the shipped default') +forbid( + 'OutRun2006Tweaks.ini', + '# Leave false for steering wheels.', + 'remove obsolete wheel input guidance') + +# Safe 20% force tests must never survive a menu/F11 transition into gameplay. +require( + 'src/hooks_wheel_ffb.cpp', + 'WheelFFB: ignored direction test outside gameplay; no torque was queued', + 'non-gameplay direction tests are rejected') +require( + 'src/hooks_wheel_ffb.cpp', + 'manualTestFrames_ = 0;\n manualTestDirection_ = 1;\n\n // Menu/race transitions', + 'pending direction test is cleared on signal reset') +require( + 'src/hooks_wheel_ffb.cpp', + 'Game::current_mode && (*Game::current_mode == STATE_GAME);', + 'direction test explicitly checks gameplay state') + +print('Final wheel cleanup verification passed') + +# Verify the additional source-level hardening passes on the final effective +# source produced by the build pipeline. +runpy.run_path('tools/verify_wheel_round4_fivepass.py', run_name='__main__') +runpy.run_path('tools/verify_wheel_round5_fivepass.py', run_name='__main__') +runpy.run_path('tools/verify_wheel_round6_fivepass.py', run_name='__main__') +runpy.run_path('tools/verify_wheel_round7_final_safety.py', run_name='__main__') +runpy.run_path('tools/verify_wheel_round8_input_binding.py', run_name='__main__') +runpy.run_path('tools/verify_wheel_round9_release_v01.py', run_name='__main__') +runpy.run_path('tools/verify_wheel_round10_snow_texture.py', run_name='__main__') diff --git a/tools/vr_dxvk_poc.ps1 b/tools/vr_dxvk_poc.ps1 new file mode 100644 index 000000000..35b9a2498 --- /dev/null +++ b/tools/vr_dxvk_poc.ps1 @@ -0,0 +1,110 @@ +param( + [Parameter(Mandatory = $true)] + [string]$GameDir, + + [Parameter(Mandatory = $true)] + [string]$DxvkD3D9, + + [switch]$Run, + + [string]$GameArgs = "" +) + +$ErrorActionPreference = "Stop" + +function Get-PeMachine([string]$Path) { + $bytes = [System.IO.File]::ReadAllBytes($Path) + if ($bytes.Length -lt 256) { throw "PE file is too small: $Path" } + $peOffset = [BitConverter]::ToInt32($bytes, 0x3C) + if ($peOffset -lt 0 -or $peOffset + 6 -gt $bytes.Length) { throw "Invalid PE header: $Path" } + return [BitConverter]::ToUInt16($bytes, $peOffset + 4) +} + +$gameRoot = (Resolve-Path $GameDir).Path +$exe = Join-Path $gameRoot "OR2006C2C.EXE" +$dxvk = (Resolve-Path $DxvkD3D9).Path + +if (-not (Test-Path $exe)) { throw "OR2006C2C.EXE not found in $gameRoot" } +if (-not (Test-Path $dxvk)) { throw "DXVK d3d9.dll not found: $DxvkD3D9" } + +$gameMachine = Get-PeMachine $exe +$dxvkMachine = Get-PeMachine $dxvk +if ($gameMachine -ne 0x014C) { throw ("Expected 32-bit OutRun executable, PE machine=0x{0:X4}" -f $gameMachine) } +if ($dxvkMachine -ne 0x014C) { throw ("Use the x32 DXVK d3d9.dll, PE machine=0x{0:X4}" -f $dxvkMachine) } + +$target = Join-Path $gameRoot "d3d9.dll" +$backup = Join-Path $gameRoot "d3d9.outrun-vr-poc-backup.dll" +$logDir = Join-Path $gameRoot "vr-dxvk-poc-logs" + +Write-Host "OutRun executable : $exe" +Write-Host "DXVK x86 d3d9.dll: $dxvk" +Write-Host "Target : $target" +Write-Host "Log directory : $logDir" + +if (-not $Run) { + Write-Host "" + Write-Host "Probe-only checks passed. Nothing was copied or launched." + Write-Host "Run again with -Run to temporarily install x32 DXVK, launch OutRun, collect logs, and restore the original d3d9.dll." + exit 0 +} + +if (Test-Path $backup) { + throw "Safety backup already exists: $backup. Resolve it manually before another PoC run." +} + +New-Item -ItemType Directory -Force -Path $logDir | Out-Null +$hadOriginal = Test-Path $target +$process = $null +$restoreSafe = $true +$oldLogPath = $env:DXVK_LOG_PATH +$oldLogLevel = $env:DXVK_LOG_LEVEL +$oldHud = $env:DXVK_HUD + +try { + if ($hadOriginal) { + Move-Item -LiteralPath $target -Destination $backup + Write-Host "Backed up existing d3d9.dll -> $backup" + } + + Copy-Item -LiteralPath $dxvk -Destination $target + $env:DXVK_LOG_PATH = $logDir + $env:DXVK_LOG_LEVEL = "info" + $env:DXVK_HUD = "devinfo,fps,compiler" + + Write-Host "Launching isolated DXVK compatibility test. Close the game normally when the test is complete." + if ([string]::IsNullOrWhiteSpace($GameArgs)) { + $process = Start-Process -FilePath $exe -WorkingDirectory $gameRoot -PassThru + } else { + $process = Start-Process -FilePath $exe -WorkingDirectory $gameRoot -ArgumentList $GameArgs -PassThru + } + $process.WaitForExit() + Write-Host "OutRun exited with code $($process.ExitCode)." +} finally { + if ($process -and -not $process.HasExited) { + $restoreSafe = $false + Write-Warning "OutRun is still running. DXVK was left in place to avoid replacing a loaded DLL. Close the game, then restore $backup manually." + } + + if ($restoreSafe) { + if (Test-Path $target) { Remove-Item -LiteralPath $target -Force } + if ($hadOriginal -and (Test-Path $backup)) { + Move-Item -LiteralPath $backup -Destination $target + Write-Host "Restored original d3d9.dll." + } + } + + $env:DXVK_LOG_PATH = $oldLogPath + $env:DXVK_LOG_LEVEL = $oldLogLevel + $env:DXVK_HUD = $oldHud +} + +$logs = Get-ChildItem -Path $logDir -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending +if ($logs) { + Write-Host "" + Write-Host "DXVK logs:" + $logs | ForEach-Object { Write-Host " $($_.FullName)" } + Write-Host "" + Write-Host "PoC gate: confirm D3D9 initialization, gameplay rendering, Reset/alt-tab, HDR/window behavior, and absence of new input/FFB regressions before any Vulkan VR work." +} else { + Write-Warning "No DXVK log was produced. Treat the PoC as failed until the loader path is understood." +} diff --git a/vrhost/CMakeLists.txt b/vrhost/CMakeLists.txt new file mode 100644 index 000000000..3e1d38c03 --- /dev/null +++ b/vrhost/CMakeLists.txt @@ -0,0 +1,174 @@ +cmake_minimum_required(VERSION 3.20) +project(outrun-vr-host LANGUAGES C CXX) + +if(NOT WIN32) + message(FATAL_ERROR "outrun-vr-host currently supports Windows only") +endif() + +if(NOT CMAKE_SIZEOF_VOID_P EQUAL 8) + message(FATAL_ERROR "outrun-vr-host must be configured as x64 (-A x64)") +endif() + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +include(FetchContent) +set(BUILD_LOADER ON CACHE BOOL "" FORCE) +set(BUILD_API_LAYERS OFF CACHE BOOL "" FORCE) +set(BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(BUILD_CONFORMANCE_TESTS OFF CACHE BOOL "" FORCE) +set(BUILD_SDK_TESTS OFF CACHE BOOL "" FORCE) +set(BUILD_ALL_EXTENSIONS OFF CACHE BOOL "" FORCE) + +FetchContent_Declare( + openxr_sdk + GIT_REPOSITORY https://github.com/KhronosGroup/OpenXR-SDK.git + GIT_TAG release-1.1.63 + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(openxr_sdk) + +execute_process( + COMMAND git rev-parse --short=12 HEAD + WORKING_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}/.." + OUTPUT_VARIABLE OUTRUN_VR_GIT_SHA + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET +) +if(NOT OUTRUN_VR_GIT_SHA) + set(OUTRUN_VR_GIT_SHA "unknown") +endif() + +add_executable(outrun-vr-host + src/main_r23.cpp + src/ipc/v3_shadow_bridge.cpp + src/diagnostics/runtime_watchdog.cpp +) +target_include_directories(outrun-vr-host PRIVATE + "${CMAKE_CURRENT_LIST_DIR}/../src" + "${CMAKE_CURRENT_LIST_DIR}/src" +) +target_compile_definitions(outrun-vr-host PRIVATE + WIN32_LEAN_AND_MEAN NOMINMAX + OUTRUN_VR_BUILD_SHA="${OUTRUN_VR_GIT_SHA}" +) +if(MSVC) + # R23 remains the verified source/pose owner. R24 remains the visible + # fallback authority. R32 adds an A/B SafeEye fallback and a final verified + # DirectGPU single-projection path with asynchronous producer ACK; every + # uncertain case still falls through to R26/R24. + target_compile_options(outrun-vr-host PRIVATE + "/FI${CMAKE_CURRENT_LIST_DIR}/src/runtime/openxr_api_compat.hpp" + "/FI${CMAKE_CURRENT_LIST_DIR}/src/runtime/bounded_swapchain_wait.hpp" + "/FI${CMAKE_CURRENT_LIST_DIR}/src/runtime/sbs_capture_override.hpp" + "/FI${CMAKE_CURRENT_LIST_DIR}/src/runtime/d3d9ex_direct_passthrough.hpp" + "/FI${CMAKE_CURRENT_LIST_DIR}/src/runtime/d3d9ex_direct_passthrough_r32.hpp" + "/FI${CMAKE_CURRENT_LIST_DIR}/src/runtime/review_hardening.hpp" + "/FI${CMAKE_CURRENT_LIST_DIR}/src/runtime/r21_runtime_hardening.hpp" + "/FI${CMAKE_CURRENT_LIST_DIR}/src/runtime/r22_runtime_hardening.hpp" + "/FI${CMAKE_CURRENT_LIST_DIR}/src/runtime/r23_runtime_hardening.hpp" + "/FI${CMAKE_CURRENT_LIST_DIR}/src/runtime/r24_black_screen_guard.hpp" + "/FI${CMAKE_CURRENT_LIST_DIR}/src/runtime/r26_recenter_hardening.hpp" + "/FI${CMAKE_CURRENT_LIST_DIR}/src/runtime/r32_direct_submit.hpp" + "/FI${CMAKE_CURRENT_LIST_DIR}/src/runtime/capture_wait_guard.hpp" + "/FI${CMAKE_CURRENT_LIST_DIR}/src/runtime/r21_crash_diagnostics.hpp" + "$<$:/Zi>" + ) + target_link_options(outrun-vr-host PRIVATE + "$<$:/DEBUG:FULL>" + "$<$:/DEBUG:FULL>" + ) +endif() +target_link_libraries(outrun-vr-host PRIVATE openxr_loader d3d11 dxgi d3dcompiler user32 dbghelp) + +add_executable(outrun-vr-diagnostic + src/diagnostic_pattern.cpp +) +target_compile_definitions(outrun-vr-diagnostic PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) +target_link_libraries(outrun-vr-diagnostic PRIVATE openxr_loader d3d11 dxgi user32) + +add_executable(outrun-vr-shader-smoke tests/stereo_shader_smoke.cpp) +target_include_directories(outrun-vr-shader-smoke PRIVATE "${CMAKE_CURRENT_LIST_DIR}/src") +target_compile_definitions(outrun-vr-shader-smoke PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) +target_link_libraries(outrun-vr-shader-smoke PRIVATE d3dcompiler) + +add_executable(outrun-vr-protocol-v3-smoke tests/protocol_v3_smoke.cpp) +target_include_directories(outrun-vr-protocol-v3-smoke PRIVATE + "${CMAKE_CURRENT_LIST_DIR}/../src" + "${CMAKE_CURRENT_LIST_DIR}/src" +) +target_compile_definitions(outrun-vr-protocol-v3-smoke PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) + +add_executable(outrun-vr-core-math-smoke tests/core_math_smoke.cpp) +target_include_directories(outrun-vr-core-math-smoke PRIVATE + "${CMAKE_CURRENT_LIST_DIR}/../src" + "${CMAKE_CURRENT_LIST_DIR}/src" +) +target_compile_definitions(outrun-vr-core-math-smoke PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) + +add_executable(outrun-vr-host-state-v3-channel-smoke tests/host_state_v3_channel_smoke.cpp) +target_include_directories(outrun-vr-host-state-v3-channel-smoke PRIVATE + "${CMAKE_CURRENT_LIST_DIR}/../src" + "${CMAKE_CURRENT_LIST_DIR}/src" +) +target_compile_definitions(outrun-vr-host-state-v3-channel-smoke PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) + +add_executable(outrun-vr-v2-v3-shadow-conversion-smoke tests/v2_v3_shadow_conversion_smoke.cpp) +target_include_directories(outrun-vr-v2-v3-shadow-conversion-smoke PRIVATE + "${CMAKE_CURRENT_LIST_DIR}/../src" + "${CMAKE_CURRENT_LIST_DIR}/src" +) +target_compile_definitions(outrun-vr-v2-v3-shadow-conversion-smoke PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) + +add_executable(outrun-vr-host-pose-v3-validation-smoke tests/host_pose_v3_validation_smoke.cpp) +target_include_directories(outrun-vr-host-pose-v3-validation-smoke PRIVATE + "${CMAKE_CURRENT_LIST_DIR}/../src" + "${CMAKE_CURRENT_LIST_DIR}/src" +) +target_compile_definitions(outrun-vr-host-pose-v3-validation-smoke PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) + +# R23 behavior regressions. These are intentionally small enough to run in CI +# without a headset/game process and cover the fail-closed state machine plus +# host-owned verified-bundle identity/TTL semantics. +add_executable(outrun-vr-runtime-eligibility-smoke tests/runtime_eligibility_smoke.cpp) +target_include_directories(outrun-vr-runtime-eligibility-smoke PRIVATE + "${CMAKE_CURRENT_LIST_DIR}/../src" +) +target_compile_definitions(outrun-vr-runtime-eligibility-smoke PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) + +add_executable(outrun-vr-r23-verified-bundle-smoke tests/r23_verified_bundle_smoke.cpp) +target_include_directories(outrun-vr-r23-verified-bundle-smoke PRIVATE + "${CMAKE_CURRENT_LIST_DIR}/../src" + "${CMAKE_CURRENT_LIST_DIR}/src" +) +target_compile_definitions(outrun-vr-r23-verified-bundle-smoke PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) +target_link_libraries(outrun-vr-r23-verified-bundle-smoke PRIVATE + openxr_loader d3d11 dxgi d3dcompiler user32) + +# R32 review regressions are pure policy tests: no headset, game or graphics +# device is needed to validate reset-relative safety and fail-closed decisions. +add_executable(outrun-vr-r32-policy-smoke tests/r32_policy_smoke.cpp) +target_include_directories(outrun-vr-r32-policy-smoke PRIVATE + "${CMAKE_CURRENT_LIST_DIR}/../src" +) +target_compile_definitions(outrun-vr-r32-policy-smoke PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) + +foreach(target + outrun-vr-host + outrun-vr-diagnostic + outrun-vr-shader-smoke + outrun-vr-protocol-v3-smoke + outrun-vr-core-math-smoke + outrun-vr-host-state-v3-channel-smoke + outrun-vr-v2-v3-shadow-conversion-smoke + outrun-vr-host-pose-v3-validation-smoke + outrun-vr-runtime-eligibility-smoke + outrun-vr-r23-verified-bundle-smoke + outrun-vr-r32-policy-smoke) + set_target_properties(${target} PROPERTIES + RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}/bin" + RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_BINARY_DIR}/bin" + PDB_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}/bin" + PDB_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_BINARY_DIR}/bin" + ) +endforeach() \ No newline at end of file diff --git a/vrhost/capture-diagnostic/CMakeLists.txt b/vrhost/capture-diagnostic/CMakeLists.txt new file mode 100644 index 000000000..bfa8e7809 --- /dev/null +++ b/vrhost/capture-diagnostic/CMakeLists.txt @@ -0,0 +1,61 @@ +cmake_minimum_required(VERSION 3.20) +project(outrun-vr-capture-diagnostic LANGUAGES C CXX) + +if(NOT WIN32) + message(FATAL_ERROR "capture diagnostic currently supports Windows only") +endif() + +if(NOT CMAKE_SIZEOF_VOID_P EQUAL 8) + message(FATAL_ERROR "capture diagnostic must be configured as x64 (-A x64)") +endif() + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +include(FetchContent) +set(BUILD_LOADER ON CACHE BOOL "" FORCE) +set(BUILD_API_LAYERS OFF CACHE BOOL "" FORCE) +set(BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(BUILD_CONFORMANCE_TESTS OFF CACHE BOOL "" FORCE) +set(BUILD_SDK_TESTS OFF CACHE BOOL "" FORCE) +set(BUILD_ALL_EXTENSIONS OFF CACHE BOOL "" FORCE) + +FetchContent_Declare( + openxr_sdk + GIT_REPOSITORY https://github.com/KhronosGroup/OpenXR-SDK.git + GIT_TAG release-1.1.63 + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(openxr_sdk) + +add_executable(outrun-vr-capture-diagnostic + "${CMAKE_CURRENT_LIST_DIR}/../src/main.cpp" + "${CMAKE_CURRENT_LIST_DIR}/../src/ipc/v3_shadow_bridge.cpp" + "${CMAKE_CURRENT_LIST_DIR}/../src/diagnostics/runtime_watchdog.cpp" +) +target_include_directories(outrun-vr-capture-diagnostic PRIVATE + "${CMAKE_CURRENT_LIST_DIR}/../../src" + "${CMAKE_CURRENT_LIST_DIR}/../src" +) +target_compile_definitions(outrun-vr-capture-diagnostic PRIVATE + WIN32_LEAN_AND_MEAN + NOMINMAX + OUTRUN_VR_CAPTURE_DIAGNOSTIC=1 +) +if(MSVC) + target_compile_options(outrun-vr-capture-diagnostic PRIVATE + "/FI${CMAKE_CURRENT_LIST_DIR}/../src/runtime/openxr_api_compat.hpp" + "/FI${CMAKE_CURRENT_LIST_DIR}/../src/runtime/sbs_capture_override.hpp" + "/FI${CMAKE_CURRENT_LIST_DIR}/../src/runtime/d3d9ex_direct_passthrough.hpp" + "/FI${CMAKE_CURRENT_LIST_DIR}/../src/runtime/review_hardening.hpp" + "/FI${CMAKE_CURRENT_LIST_DIR}/../src/runtime/capture_chain_diagnostic.hpp" + "/FI${CMAKE_CURRENT_LIST_DIR}/../src/runtime/r18_host_pipeline_diagnostic.hpp" + ) +endif() +target_link_libraries(outrun-vr-capture-diagnostic PRIVATE + openxr_loader d3d11 dxgi d3dcompiler user32 +) +set_target_properties(outrun-vr-capture-diagnostic PROPERTIES + RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}/bin" + RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_BINARY_DIR}/bin" +) diff --git a/vrhost/src/diagnostic_pattern.cpp b/vrhost/src/diagnostic_pattern.cpp new file mode 100644 index 000000000..50b12adaf --- /dev/null +++ b/vrhost/src/diagnostic_pattern.cpp @@ -0,0 +1,996 @@ +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#define XR_USE_PLATFORM_WIN32 +#define XR_USE_GRAPHICS_API_D3D11 + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + constexpr const char* BuildId = "R16-openxr-direct-diagnostic-20260915"; + constexpr ULONGLONG PhaseDurationMs = 5000; + std::atomic KeepRunning{true}; + std::ofstream LogFile; + + void Log(const std::string& text) + { + SYSTEMTIME st{}; + GetLocalTime(&st); + std::ostringstream line; + line << std::setfill('0') + << "[" << std::setw(2) << st.wHour << ":" + << std::setw(2) << st.wMinute << ":" + << std::setw(2) << st.wSecond << "." + << std::setw(3) << st.wMilliseconds << "] " + << text; + std::cout << line.str() << std::endl; + if (LogFile) + { + LogFile << line.str() << std::endl; + LogFile.flush(); + } + } + + std::string XrCode(XrResult result) + { + return std::to_string(static_cast(result)); + } + + void CheckXr(XrResult result, const char* what) + { + if (XR_FAILED(result)) + throw std::runtime_error(std::string(what) + " failed XrResult=" + XrCode(result)); + } + + void CheckHr(HRESULT result, const char* what) + { + if (FAILED(result)) + { + std::ostringstream ss; + ss << what << " failed HRESULT=0x" << std::hex + << static_cast(result); + throw std::runtime_error(ss.str()); + } + } + + BOOL WINAPI ConsoleHandler(DWORD type) + { + if (type == CTRL_C_EVENT || type == CTRL_BREAK_EVENT || + type == CTRL_CLOSE_EVENT || type == CTRL_SHUTDOWN_EVENT) + { + KeepRunning.store(false); + return TRUE; + } + return FALSE; + } + + template + void ReleaseCom(T*& value) + { + if (value) + { + value->Release(); + value = nullptr; + } + } + + bool SameLuid(const LUID& a, const LUID& b) + { + return a.LowPart == b.LowPart && a.HighPart == b.HighPart; + } + + struct D3DState + { + ID3D11Device* device = nullptr; + ID3D11DeviceContext* context = nullptr; + + D3DState() = default; + D3DState(const D3DState&) = delete; + D3DState& operator=(const D3DState&) = delete; + + D3DState(D3DState&& other) noexcept + : device(other.device), context(other.context) + { + other.device = nullptr; + other.context = nullptr; + } + + D3DState& operator=(D3DState&& other) noexcept + { + if (this != &other) + { + ReleaseCom(context); + ReleaseCom(device); + device = other.device; + context = other.context; + other.device = nullptr; + other.context = nullptr; + } + return *this; + } + + ~D3DState() + { + ReleaseCom(context); + ReleaseCom(device); + } + }; + + D3DState CreateD3D(const XrGraphicsRequirementsD3D11KHR& req) + { + IDXGIFactory1* factory = nullptr; + CheckHr(CreateDXGIFactory1(__uuidof(IDXGIFactory1), + reinterpret_cast(&factory)), "CreateDXGIFactory1"); + + IDXGIAdapter1* match = nullptr; + DXGI_ADAPTER_DESC1 matchDesc{}; + for (UINT i = 0;; ++i) + { + IDXGIAdapter1* adapter = nullptr; + if (factory->EnumAdapters1(i, &adapter) == DXGI_ERROR_NOT_FOUND) + break; + DXGI_ADAPTER_DESC1 desc{}; + adapter->GetDesc1(&desc); + if (SameLuid(desc.AdapterLuid, req.adapterLuid)) + { + match = adapter; + matchDesc = desc; + break; + } + adapter->Release(); + } + factory->Release(); + + if (!match) + throw std::runtime_error("OpenXR-required D3D11 adapter not found"); + + std::wostringstream adapterName; + adapterName << L"[diag] OpenXR adapter=" << matchDesc.Description; + std::wcout << adapterName.str() << std::endl; + if (LogFile) + { + std::string narrow(matchDesc.Description, + matchDesc.Description + wcslen(matchDesc.Description)); + Log("[diag] OpenXR adapter=" + narrow); + } + + const std::array all{ + D3D_FEATURE_LEVEL_12_1, D3D_FEATURE_LEVEL_12_0, + D3D_FEATURE_LEVEL_11_1, D3D_FEATURE_LEVEL_11_0, + D3D_FEATURE_LEVEL_10_1, D3D_FEATURE_LEVEL_10_0, + D3D_FEATURE_LEVEL_9_3 + }; + std::vector levels; + for (auto level : all) + if (level >= req.minFeatureLevel) + levels.push_back(level); + if (levels.empty()) + levels.push_back(req.minFeatureLevel); + + D3DState out; + D3D_FEATURE_LEVEL selected{}; + const HRESULT hr = D3D11CreateDevice( + match, D3D_DRIVER_TYPE_UNKNOWN, nullptr, + D3D11_CREATE_DEVICE_BGRA_SUPPORT, + levels.data(), static_cast(levels.size()), + D3D11_SDK_VERSION, &out.device, &selected, &out.context); + match->Release(); + CheckHr(hr, "D3D11CreateDevice"); + + Log("[diag] D3D11 device created featureLevel=0x" + + [&] { + std::ostringstream ss; + ss << std::hex << static_cast(selected); + return ss.str(); + }()); + return out; + } + + struct Swapchain + { + std::string name; + XrSwapchain handle = XR_NULL_HANDLE; + std::uint32_t width = 0; + std::uint32_t height = 0; + std::uint32_t arraySize = 1; + DXGI_FORMAT format = DXGI_FORMAT_UNKNOWN; + std::vector images; + std::vector> rtvs; + + void Destroy() + { + for (auto& pair : rtvs) + { + ReleaseCom(pair[0]); + ReleaseCom(pair[1]); + } + rtvs.clear(); + images.clear(); + if (handle != XR_NULL_HANDLE) + { + xrDestroySwapchain(handle); + handle = XR_NULL_HANDLE; + } + } + + ~Swapchain() + { + Destroy(); + } + }; + + bool ContainsFormat(const std::vector& formats, DXGI_FORMAT format) + { + return std::find(formats.begin(), formats.end(), + static_cast(format)) != formats.end(); + } + + DXGI_FORMAT ChooseUnormFormat(const std::vector& formats) + { + for (DXGI_FORMAT f : { + DXGI_FORMAT_R8G8B8A8_UNORM, + DXGI_FORMAT_B8G8R8A8_UNORM }) + { + if (ContainsFormat(formats, f)) + return f; + } + return DXGI_FORMAT_UNKNOWN; + } + + DXGI_FORMAT ChooseSrgbFormat(const std::vector& formats) + { + for (DXGI_FORMAT f : { + DXGI_FORMAT_R8G8B8A8_UNORM_SRGB, + DXGI_FORMAT_B8G8R8A8_UNORM_SRGB }) + { + if (ContainsFormat(formats, f)) + return f; + } + return DXGI_FORMAT_UNKNOWN; + } + + bool CreateSwapchain(XrSession session, ID3D11Device* device, + Swapchain& out, const char* name, std::uint32_t width, + std::uint32_t height, std::uint32_t arraySize, DXGI_FORMAT format) + { + out.name = name; + out.width = width; + out.height = height; + out.arraySize = arraySize; + out.format = format; + + XrSwapchainCreateInfo ci{XR_TYPE_SWAPCHAIN_CREATE_INFO}; + ci.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT; + ci.format = static_cast(format); + ci.sampleCount = 1; + ci.width = width; + ci.height = height; + ci.faceCount = 1; + ci.arraySize = arraySize; + ci.mipCount = 1; + + XrResult xr = xrCreateSwapchain(session, &ci, &out.handle); + if (XR_FAILED(xr)) + { + Log("[diag] " + out.name + " xrCreateSwapchain FAILED result=" + XrCode(xr)); + out.handle = XR_NULL_HANDLE; + return false; + } + + std::uint32_t count = 0; + xr = xrEnumerateSwapchainImages(out.handle, 0, &count, nullptr); + if (XR_FAILED(xr) || count == 0) + { + Log("[diag] " + out.name + " enumerate image count FAILED result=" + XrCode(xr)); + out.Destroy(); + return false; + } + + out.images.resize(count); + for (auto& image : out.images) + image = {XR_TYPE_SWAPCHAIN_IMAGE_D3D11_KHR}; + xr = xrEnumerateSwapchainImages( + out.handle, count, &count, + reinterpret_cast(out.images.data())); + if (XR_FAILED(xr)) + { + Log("[diag] " + out.name + " enumerate images FAILED result=" + XrCode(xr)); + out.Destroy(); + return false; + } + + out.rtvs.resize(count); + for (std::uint32_t image = 0; image < count; ++image) + { + for (std::uint32_t slice = 0; slice < arraySize; ++slice) + { + D3D11_RENDER_TARGET_VIEW_DESC rd{}; + rd.Format = format; + if (arraySize > 1) + { + rd.ViewDimension = D3D11_RTV_DIMENSION_TEXTURE2DARRAY; + rd.Texture2DArray.MipSlice = 0; + rd.Texture2DArray.FirstArraySlice = slice; + rd.Texture2DArray.ArraySize = 1; + } + else + { + rd.ViewDimension = D3D11_RTV_DIMENSION_TEXTURE2D; + rd.Texture2D.MipSlice = 0; + } + + HRESULT hr = device->CreateRenderTargetView( + out.images[image].texture, &rd, &out.rtvs[image][slice]); + if (FAILED(hr) || !out.rtvs[image][slice]) + { + std::ostringstream ss; + ss << "[diag] " << out.name + << " CreateRenderTargetView FAILED image=" << image + << " slice=" << slice + << " hr=0x" << std::hex << static_cast(hr); + Log(ss.str()); + out.Destroy(); + return false; + } + } + } + + Log("[diag] " + out.name + " ready " + + std::to_string(width) + "x" + std::to_string(height) + + " array=" + std::to_string(arraySize) + + " format=" + std::to_string(static_cast(format)) + + " images=" + std::to_string(count)); + return true; + } + + struct RenderResult + { + bool ok = false; + XrResult xr = XR_SUCCESS; + std::uint32_t imageIndex = 0; + const char* stage = "none"; + }; + + RenderResult ClearSwapchain(Swapchain& swapchain, + ID3D11DeviceContext* context, + const std::array, 2>& colors) + { + RenderResult result{}; + if (swapchain.handle == XR_NULL_HANDLE || swapchain.images.empty()) + { + result.stage = "not-ready"; + return result; + } + + XrSwapchainImageAcquireInfo acquire{XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO}; + result.stage = "acquire"; + result.xr = xrAcquireSwapchainImage( + swapchain.handle, &acquire, &result.imageIndex); + if (XR_FAILED(result.xr)) + return result; + + XrSwapchainImageWaitInfo wait{XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO}; + wait.timeout = XR_INFINITE_DURATION; + result.stage = "wait"; + result.xr = xrWaitSwapchainImage(swapchain.handle, &wait); + if (XR_FAILED(result.xr)) + { + // The image is acquired but was never successfully waited. Do not + // fabricate a release; stop the diagnostic so the first failure is + // preserved instead of poisoning all later frames. + KeepRunning.store(false); + return result; + } + + if (result.imageIndex >= swapchain.rtvs.size()) + { + result.stage = "index"; + result.xr = XR_ERROR_RUNTIME_FAILURE; + XrSwapchainImageReleaseInfo release{XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO}; + xrReleaseSwapchainImage(swapchain.handle, &release); + KeepRunning.store(false); + return result; + } + + const std::uint32_t slices = std::min(swapchain.arraySize, 2); + for (std::uint32_t slice = 0; slice < slices; ++slice) + context->ClearRenderTargetView( + swapchain.rtvs[result.imageIndex][slice], colors[slice].data()); + context->Flush(); + + XrSwapchainImageReleaseInfo release{XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO}; + result.stage = "release"; + result.xr = xrReleaseSwapchainImage(swapchain.handle, &release); + if (XR_FAILED(result.xr)) + return result; + + result.ok = true; + result.stage = "complete"; + return result; + } + + void LogRenderFailure(const Swapchain& swapchain, const RenderResult& result) + { + Log("[diag] " + swapchain.name + + " render FAILED stage=" + result.stage + + " result=" + XrCode(result.xr) + + " image=" + std::to_string(result.imageIndex)); + } + + XrEnvironmentBlendMode ChooseBlendMode( + XrInstance instance, XrSystemId system) + { + std::uint32_t count = 0; + CheckXr(xrEnumerateEnvironmentBlendModes( + instance, system, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, + 0, &count, nullptr), "xrEnumerateEnvironmentBlendModes(count)"); + std::vector modes(count); + CheckXr(xrEnumerateEnvironmentBlendModes( + instance, system, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, + count, &count, modes.data()), "xrEnumerateEnvironmentBlendModes(list)"); + for (auto mode : modes) + if (mode == XR_ENVIRONMENT_BLEND_MODE_OPAQUE) + return mode; + if (modes.empty()) + throw std::runtime_error("runtime reported no environment blend modes"); + return modes.front(); + } + + const char* SessionStateName(XrSessionState state) + { + switch (state) + { + case XR_SESSION_STATE_UNKNOWN: return "UNKNOWN"; + case XR_SESSION_STATE_IDLE: return "IDLE"; + case XR_SESSION_STATE_READY: return "READY"; + case XR_SESSION_STATE_SYNCHRONIZED: return "SYNCHRONIZED"; + case XR_SESSION_STATE_VISIBLE: return "VISIBLE"; + case XR_SESSION_STATE_FOCUSED: return "FOCUSED"; + case XR_SESSION_STATE_STOPPING: return "STOPPING"; + case XR_SESSION_STATE_LOSS_PENDING: return "LOSS_PENDING"; + case XR_SESSION_STATE_EXITING: return "EXITING"; + default: return "OTHER"; + } + } + + const char* PhaseName(int phase) + { + switch (phase) + { + case 0: return "ARRAY-UNORM projection: LEFT RED / RIGHT GREEN"; + case 1: return "SPLIT-UNORM projection: LEFT BLUE / RIGHT YELLOW"; + case 2: return "VIEW-QUAD-UNORM: MAGENTA"; + case 3: return "ARRAY-SRGB projection: LEFT CYAN / RIGHT ORANGE"; + default: return "unknown"; + } + } +} + +int main() +{ + LogFile.open("outrun-vr-diagnostic.log", std::ios::out | std::ios::trunc); + SetConsoleCtrlHandler(ConsoleHandler, TRUE); + Log(std::string("[diag] build=") + BuildId); + Log("[diag] No game is required. Put on Quest 3, keep Virtual Desktop connected, then run this EXE."); + Log("[diag] Four phases repeat every 5 seconds. Press ESC or Ctrl+C to exit."); + + XrInstance instance = XR_NULL_HANDLE; + XrSession session = XR_NULL_HANDLE; + XrSpace localSpace = XR_NULL_HANDLE; + XrSpace viewSpace = XR_NULL_HANDLE; + + try + { + std::uint32_t extCount = 0; + CheckXr(xrEnumerateInstanceExtensionProperties( + nullptr, 0, &extCount, nullptr), + "xrEnumerateInstanceExtensionProperties(count)"); + std::vector extensions(extCount); + for (auto& ext : extensions) + ext = {XR_TYPE_EXTENSION_PROPERTIES}; + CheckXr(xrEnumerateInstanceExtensionProperties( + nullptr, extCount, &extCount, extensions.data()), + "xrEnumerateInstanceExtensionProperties(list)"); + + bool haveD3D11 = false; + for (const auto& ext : extensions) + if (std::string(ext.extensionName) == + XR_KHR_D3D11_ENABLE_EXTENSION_NAME) + haveD3D11 = true; + if (!haveD3D11) + throw std::runtime_error("runtime lacks XR_KHR_D3D11_enable"); + + const char* enabledExtensions[]{XR_KHR_D3D11_ENABLE_EXTENSION_NAME}; + XrInstanceCreateInfo ii{XR_TYPE_INSTANCE_CREATE_INFO}; + strcpy_s(ii.applicationInfo.applicationName, + "OutRun VR Direct Diagnostic"); + ii.applicationInfo.applicationVersion = 16; + strcpy_s(ii.applicationInfo.engineName, "OutRun2006Tweaks"); + ii.applicationInfo.engineVersion = 16; + ii.applicationInfo.apiVersion = XR_MAKE_VERSION(1, 0, 0); + ii.enabledExtensionCount = 1; + ii.enabledExtensionNames = enabledExtensions; + CheckXr(xrCreateInstance(&ii, &instance), "xrCreateInstance"); + + XrInstanceProperties props{XR_TYPE_INSTANCE_PROPERTIES}; + CheckXr(xrGetInstanceProperties(instance, &props), + "xrGetInstanceProperties"); + Log("[diag] runtime=" + std::string(props.runtimeName) + + " version=" + + std::to_string(XR_VERSION_MAJOR(props.runtimeVersion)) + "." + + std::to_string(XR_VERSION_MINOR(props.runtimeVersion)) + "." + + std::to_string(XR_VERSION_PATCH(props.runtimeVersion))); + + XrSystemGetInfo sgi{XR_TYPE_SYSTEM_GET_INFO}; + sgi.formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY; + XrSystemId system = XR_NULL_SYSTEM_ID; + CheckXr(xrGetSystem(instance, &sgi, &system), "xrGetSystem"); + + PFN_xrGetD3D11GraphicsRequirementsKHR getReq = nullptr; + CheckXr(xrGetInstanceProcAddr( + instance, "xrGetD3D11GraphicsRequirementsKHR", + reinterpret_cast(&getReq)), + "xrGetInstanceProcAddr(xrGetD3D11GraphicsRequirementsKHR)"); + XrGraphicsRequirementsD3D11KHR requirements{ + XR_TYPE_GRAPHICS_REQUIREMENTS_D3D11_KHR}; + CheckXr(getReq(instance, system, &requirements), + "xrGetD3D11GraphicsRequirementsKHR"); + + D3DState d3d = CreateD3D(requirements); + + XrGraphicsBindingD3D11KHR binding{ + XR_TYPE_GRAPHICS_BINDING_D3D11_KHR}; + binding.device = d3d.device; + XrSessionCreateInfo sci{XR_TYPE_SESSION_CREATE_INFO}; + sci.next = &binding; + sci.systemId = system; + CheckXr(xrCreateSession(instance, &sci, &session), + "xrCreateSession"); + Log("[diag] xrCreateSession OK"); + + XrPosef identity{}; + identity.orientation.w = 1.0f; + + XrReferenceSpaceCreateInfo localInfo{ + XR_TYPE_REFERENCE_SPACE_CREATE_INFO}; + localInfo.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL; + localInfo.poseInReferenceSpace = identity; + CheckXr(xrCreateReferenceSpace(session, &localInfo, &localSpace), + "xrCreateReferenceSpace(LOCAL)"); + + XrReferenceSpaceCreateInfo viewInfo{ + XR_TYPE_REFERENCE_SPACE_CREATE_INFO}; + viewInfo.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_VIEW; + viewInfo.poseInReferenceSpace = identity; + CheckXr(xrCreateReferenceSpace(session, &viewInfo, &viewSpace), + "xrCreateReferenceSpace(VIEW)"); + + std::uint32_t viewCount = 0; + CheckXr(xrEnumerateViewConfigurationViews( + instance, system, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, + 0, &viewCount, nullptr), + "xrEnumerateViewConfigurationViews(count)"); + if (viewCount < 2) + throw std::runtime_error("runtime reported fewer than two stereo views"); + + std::vector configs(viewCount); + for (auto& config : configs) + config = {XR_TYPE_VIEW_CONFIGURATION_VIEW}; + CheckXr(xrEnumerateViewConfigurationViews( + instance, system, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, + viewCount, &viewCount, configs.data()), + "xrEnumerateViewConfigurationViews(list)"); + Log("[diag] recommended per-eye=" + + std::to_string(configs[0].recommendedImageRectWidth) + "x" + + std::to_string(configs[0].recommendedImageRectHeight) + + " / " + + std::to_string(configs[1].recommendedImageRectWidth) + "x" + + std::to_string(configs[1].recommendedImageRectHeight)); + + std::uint32_t formatCount = 0; + CheckXr(xrEnumerateSwapchainFormats( + session, 0, &formatCount, nullptr), + "xrEnumerateSwapchainFormats(count)"); + std::vector formats(formatCount); + CheckXr(xrEnumerateSwapchainFormats( + session, formatCount, &formatCount, formats.data()), + "xrEnumerateSwapchainFormats(list)"); + + { + std::ostringstream ss; + ss << "[diag] swapchain formats:"; + for (auto format : formats) + ss << " " << format; + Log(ss.str()); + } + + DXGI_FORMAT unorm = ChooseUnormFormat(formats); + DXGI_FORMAT srgb = ChooseSrgbFormat(formats); + if (unorm == DXGI_FORMAT_UNKNOWN) + throw std::runtime_error("runtime exposes no R8/B8 8-bit UNORM swapchain"); + Log("[diag] selected UNORM format=" + + std::to_string(static_cast(unorm))); + if (srgb != DXGI_FORMAT_UNKNOWN) + Log("[diag] selected SRGB format=" + + std::to_string(static_cast(srgb))); + else + Log("[diag] SRGB 8-bit format unavailable; phase 4 will reuse UNORM"); + + const std::uint32_t eyeW = std::min( + 1024, std::max(configs[0].recommendedImageRectWidth, + configs[1].recommendedImageRectWidth)); + const std::uint32_t eyeH = std::min( + 1024, std::max(configs[0].recommendedImageRectHeight, + configs[1].recommendedImageRectHeight)); + + Swapchain arrayUnorm, splitLeft, splitRight, quadUnorm, arraySrgb; + if (!CreateSwapchain(session, d3d.device, arrayUnorm, + "array-unorm", eyeW, eyeH, 2, unorm)) + throw std::runtime_error("array UNORM swapchain creation failed"); + if (!CreateSwapchain(session, d3d.device, splitLeft, + "split-left-unorm", eyeW, eyeH, 1, unorm) || + !CreateSwapchain(session, d3d.device, splitRight, + "split-right-unorm", eyeW, eyeH, 1, unorm)) + throw std::runtime_error("split UNORM swapchain creation failed"); + if (!CreateSwapchain(session, d3d.device, quadUnorm, + "quad-unorm", 1024, 512, 1, unorm)) + throw std::runtime_error("quad UNORM swapchain creation failed"); + const DXGI_FORMAT phaseSrgb = + srgb != DXGI_FORMAT_UNKNOWN ? srgb : unorm; + if (!CreateSwapchain(session, d3d.device, arraySrgb, + "array-srgb", eyeW, eyeH, 2, phaseSrgb)) + throw std::runtime_error("array SRGB/fallback swapchain creation failed"); + + const XrEnvironmentBlendMode blend = + ChooseBlendMode(instance, system); + + bool sessionRunning = false; + bool quit = false; + XrSessionState sessionState = XR_SESSION_STATE_UNKNOWN; + ULONGLONG phaseOrigin = 0; + int lastPhase = -1; + std::uint64_t frameNumber = 0; + std::uint64_t submitSuccess[4]{}; + std::uint64_t renderFailures[4]{}; + + while (KeepRunning.load() && !quit) + { + if (GetAsyncKeyState(VK_ESCAPE) & 0x8000) + KeepRunning.store(false); + + XrEventDataBuffer event{XR_TYPE_EVENT_DATA_BUFFER}; + while (xrPollEvent(instance, &event) == XR_SUCCESS) + { + if (event.type == XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED) + { + const auto* changed = + reinterpret_cast( + &event); + sessionState = changed->state; + Log(std::string("[diag] session state=") + + SessionStateName(sessionState)); + + if (sessionState == XR_SESSION_STATE_READY && + !sessionRunning) + { + XrSessionBeginInfo begin{ + XR_TYPE_SESSION_BEGIN_INFO}; + begin.primaryViewConfigurationType = + XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO; + CheckXr(xrBeginSession(session, &begin), + "xrBeginSession"); + sessionRunning = true; + phaseOrigin = GetTickCount64(); + Log("[diag] xrBeginSession OK"); + } + else if (sessionState == XR_SESSION_STATE_STOPPING && + sessionRunning) + { + CheckXr(xrEndSession(session), "xrEndSession"); + sessionRunning = false; + } + else if (sessionState == XR_SESSION_STATE_EXITING || + sessionState == XR_SESSION_STATE_LOSS_PENDING) + { + quit = true; + } + } + event = {XR_TYPE_EVENT_DATA_BUFFER}; + } + + if (!sessionRunning) + { + Sleep(10); + continue; + } + + XrFrameWaitInfo waitInfo{XR_TYPE_FRAME_WAIT_INFO}; + XrFrameState frameState{XR_TYPE_FRAME_STATE}; + CheckXr(xrWaitFrame(session, &waitInfo, &frameState), + "xrWaitFrame"); + + XrFrameBeginInfo beginInfo{XR_TYPE_FRAME_BEGIN_INFO}; + CheckXr(xrBeginFrame(session, &beginInfo), "xrBeginFrame"); + + const ULONGLONG elapsed = + GetTickCount64() - phaseOrigin; + const int phase = static_cast( + (elapsed / PhaseDurationMs) % 4); + if (phase != lastPhase) + { + lastPhase = phase; + Log(std::string("[diag] PHASE ") + + std::to_string(phase + 1) + "/4 " + + PhaseName(phase)); + } + + XrFrameEndInfo end{XR_TYPE_FRAME_END_INFO}; + end.displayTime = frameState.predictedDisplayTime; + end.environmentBlendMode = blend; + + const XrCompositionLayerBaseHeader* layer = nullptr; + XrCompositionLayerProjection projection{ + XR_TYPE_COMPOSITION_LAYER_PROJECTION}; + std::array projectionViews{}; + XrCompositionLayerQuad quad{XR_TYPE_COMPOSITION_LAYER_QUAD}; + + bool ready = false; + if (frameState.shouldRender == XR_TRUE) + { + if (phase == 0 || phase == 1 || phase == 3) + { + std::array views{}; + for (auto& view : views) + view = {XR_TYPE_VIEW}; + XrViewState viewState{XR_TYPE_VIEW_STATE}; + XrViewLocateInfo locate{XR_TYPE_VIEW_LOCATE_INFO}; + locate.viewConfigurationType = + XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO; + locate.displayTime = frameState.predictedDisplayTime; + locate.space = localSpace; + std::uint32_t located = 0; + const XrResult locateResult = xrLocateViews( + session, &locate, &viewState, 2, &located, views.data()); + if (XR_FAILED(locateResult) || located < 2) + { + Log("[diag] xrLocateViews FAILED result=" + + XrCode(locateResult) + + " count=" + std::to_string(located)); + ++renderFailures[phase]; + } + else + { + RenderResult leftOrArray{}; + RenderResult right{}; + if (phase == 0) + { + leftOrArray = ClearSwapchain( + arrayUnorm, d3d.context, + {{{1.f, 0.f, 0.f, 1.f}, + {0.f, 1.f, 0.f, 1.f}}}); + if (!leftOrArray.ok) + LogRenderFailure(arrayUnorm, leftOrArray); + ready = leftOrArray.ok; + } + else if (phase == 1) + { + leftOrArray = ClearSwapchain( + splitLeft, d3d.context, + {{{0.f, 0.15f, 1.f, 1.f}, + {0.f, 0.15f, 1.f, 1.f}}}); + right = ClearSwapchain( + splitRight, d3d.context, + {{{1.f, 0.9f, 0.f, 1.f}, + {1.f, 0.9f, 0.f, 1.f}}}); + if (!leftOrArray.ok) + LogRenderFailure(splitLeft, leftOrArray); + if (!right.ok) + LogRenderFailure(splitRight, right); + ready = leftOrArray.ok && right.ok; + } + else + { + leftOrArray = ClearSwapchain( + arraySrgb, d3d.context, + {{{0.f, 1.f, 1.f, 1.f}, + {1.f, 0.25f, 0.f, 1.f}}}); + if (!leftOrArray.ok) + LogRenderFailure(arraySrgb, leftOrArray); + ready = leftOrArray.ok; + } + + if (ready) + { + for (int eye = 0; eye < 2; ++eye) + { + projectionViews[eye] = { + XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}; + projectionViews[eye].pose = views[eye].pose; + projectionViews[eye].fov = views[eye].fov; + if (phase == 1) + { + projectionViews[eye].subImage.swapchain = + eye == 0 ? splitLeft.handle : + splitRight.handle; + projectionViews[eye].subImage.imageArrayIndex = 0; + } + else + { + Swapchain& source = + phase == 0 ? arrayUnorm : arraySrgb; + projectionViews[eye].subImage.swapchain = + source.handle; + projectionViews[eye].subImage.imageArrayIndex = + static_cast(eye); + } + projectionViews[eye].subImage.imageRect.offset = + {0, 0}; + projectionViews[eye].subImage.imageRect.extent = + {static_cast(eyeW), + static_cast(eyeH)}; + } + projection.space = localSpace; + projection.viewCount = 2; + projection.views = projectionViews.data(); + layer = + reinterpret_cast( + &projection); + } + else + { + ++renderFailures[phase]; + } + } + } + else + { + const RenderResult quadResult = ClearSwapchain( + quadUnorm, d3d.context, + {{{1.f, 0.f, 1.f, 1.f}, + {1.f, 0.f, 1.f, 1.f}}}); + if (!quadResult.ok) + { + LogRenderFailure(quadUnorm, quadResult); + ++renderFailures[phase]; + } + else + { + quad.space = viewSpace; + quad.eyeVisibility = XR_EYE_VISIBILITY_BOTH; + quad.pose.orientation = {0.f, 0.f, 0.f, 1.f}; + quad.pose.position = {0.f, 0.f, -1.5f}; + quad.size = {1.6f, 0.8f}; + quad.subImage.swapchain = quadUnorm.handle; + quad.subImage.imageRect.offset = {0, 0}; + quad.subImage.imageRect.extent = {1024, 512}; + quad.subImage.imageArrayIndex = 0; + layer = + reinterpret_cast( + &quad); + ready = true; + } + } + } + + end.layerCount = ready ? 1u : 0u; + end.layers = ready ? &layer : nullptr; + const XrResult endResult = xrEndFrame(session, &end); + if (XR_FAILED(endResult)) + { + Log("[diag] xrEndFrame FAILED phase=" + + std::to_string(phase + 1) + + " result=" + XrCode(endResult) + + " layerCount=" + std::to_string(end.layerCount)); + ++renderFailures[phase]; + } + else if (ready) + { + ++submitSuccess[phase]; + } + + ++frameNumber; + if (frameNumber % 300 == 0) + { + Log("[diag] heartbeat frame=" + + std::to_string(frameNumber) + + " state=" + SessionStateName(sessionState) + + " shouldRender=" + + std::to_string(frameState.shouldRender == XR_TRUE ? 1 : 0) + + " phase=" + std::to_string(phase + 1) + + " submitOk=[" + + std::to_string(submitSuccess[0]) + "," + + std::to_string(submitSuccess[1]) + "," + + std::to_string(submitSuccess[2]) + "," + + std::to_string(submitSuccess[3]) + "] fail=[" + + std::to_string(renderFailures[0]) + "," + + std::to_string(renderFailures[1]) + "," + + std::to_string(renderFailures[2]) + "," + + std::to_string(renderFailures[3]) + "]"); + } + } + + Log("[diag] stopping. submitOk=[" + + std::to_string(submitSuccess[0]) + "," + + std::to_string(submitSuccess[1]) + "," + + std::to_string(submitSuccess[2]) + "," + + std::to_string(submitSuccess[3]) + "] fail=[" + + std::to_string(renderFailures[0]) + "," + + std::to_string(renderFailures[1]) + "," + + std::to_string(renderFailures[2]) + "," + + std::to_string(renderFailures[3]) + "]"); + + if (sessionRunning) + { + xrRequestExitSession(session); + for (int i = 0; i < 200 && sessionRunning; ++i) + { + XrEventDataBuffer event{XR_TYPE_EVENT_DATA_BUFFER}; + while (xrPollEvent(instance, &event) == XR_SUCCESS) + { + if (event.type == + XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED) + { + const auto* changed = + reinterpret_cast( + &event); + if (changed->state == XR_SESSION_STATE_STOPPING) + { + xrEndSession(session); + sessionRunning = false; + break; + } + } + event = {XR_TYPE_EVENT_DATA_BUFFER}; + } + Sleep(10); + } + } + + arraySrgb.Destroy(); + quadUnorm.Destroy(); + splitRight.Destroy(); + splitLeft.Destroy(); + arrayUnorm.Destroy(); + + if (viewSpace != XR_NULL_HANDLE) + xrDestroySpace(viewSpace); + if (localSpace != XR_NULL_HANDLE) + xrDestroySpace(localSpace); + if (session != XR_NULL_HANDLE) + xrDestroySession(session); + if (instance != XR_NULL_HANDLE) + xrDestroyInstance(instance); + + Log("[diag] clean exit"); + return 0; + } + catch (const std::exception& e) + { + Log(std::string("[diag] FATAL: ") + e.what()); + if (viewSpace != XR_NULL_HANDLE) + xrDestroySpace(viewSpace); + if (localSpace != XR_NULL_HANDLE) + xrDestroySpace(localSpace); + if (session != XR_NULL_HANDLE) + xrDestroySession(session); + if (instance != XR_NULL_HANDLE) + xrDestroyInstance(instance); + return 1; + } +} diff --git a/vrhost/src/diagnostics/runtime_watchdog.cpp b/vrhost/src/diagnostics/runtime_watchdog.cpp new file mode 100644 index 000000000..6d28dcf3c --- /dev/null +++ b/vrhost/src/diagnostics/runtime_watchdog.cpp @@ -0,0 +1,250 @@ +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "vr/ipc/protocol.hpp" +#include "vr/ipc/protocol_v3.hpp" +#include "vr/ipc/shadow_legacy_v2.hpp" +#include "vr/ipc/win32_channel.hpp" + +namespace OutRunVR::Host::Diagnostics +{ + namespace + { + std::string Timestamp() + { + SYSTEMTIME t{}; + GetSystemTime(&t); + std::ostringstream s; + s << std::setfill('0') << std::setw(4) << t.wYear << '-' + << std::setw(2) << t.wMonth << '-' << std::setw(2) << t.wDay << 'T' + << std::setw(2) << t.wHour << ':' << std::setw(2) << t.wMinute << ':' + << std::setw(2) << t.wSecond << '.' << std::setw(3) << t.wMilliseconds << 'Z'; + return s.str(); + } + + std::string BoundedString(const char* text, std::size_t capacity) + { + if (!text || !capacity) return {}; + std::size_t length = 0; + while (length < capacity && text[length] != '\0') ++length; + return std::string(text, length); + } + + class Log + { + public: + Log() { stream_.open("outrun-vr-watchdog.log", std::ios::out | std::ios::trunc); } + void Write(const char* level, const std::string& message) + { + if (!stream_.is_open()) return; + stream_ << Timestamp() << ' ' << level << ' ' << message << '\n'; + stream_.flush(); + } + private: + std::ofstream stream_; + }; + + template + bool StableReadState(const Ipc::ReadOnlyMapping& mapping, T& out) noexcept + { + return mapping.IsOpen() && Ipc::StableRead(mapping.Get(), out); + } + + bool StableReadFrameRing(const IpcV3::FrameRing* shared, IpcV3::FrameRing& out) noexcept + { + if (!shared) return false; + for (int attempt = 0; attempt < 6; ++attempt) + { + const std::uint32_t before = shared->publishSequence; + if (before & 1u) continue; + MemoryBarrier(); + std::memcpy(&out, shared, sizeof(out)); + MemoryBarrier(); + const std::uint32_t after = shared->publishSequence; + if (before == after && !(after & 1u)) return true; + } + return false; + } + + bool HeaderValid(const IpcV3::ClientState& s) noexcept + { + return s.magic == IpcV3::ClientMagic && s.version == IpcV3::ProtocolVersion && + s.structSize == sizeof(s); + } + + bool HeaderValid(const IpcV3::AckState& s) noexcept + { + return s.magic == IpcV3::AckMagic && s.version == IpcV3::ProtocolVersion && + s.structSize == sizeof(s); + } + + bool HeaderValid(const IpcV3::FrameRing& s) noexcept + { + return s.magic == IpcV3::FrameMagic && s.version == IpcV3::ProtocolVersion && + s.structSize == sizeof(s) && s.slotCount == IpcV3::RingSize; + } + + class RuntimeWatchdog + { + public: + RuntimeWatchdog() : thread_([this] { Run(); }) {} + ~RuntimeWatchdog() + { + stop_.store(true, std::memory_order_release); + if (thread_.joinable()) thread_.join(); + } + + private: + void Run() + { + Log log; + log.Write("INFO", "watchdog_start pid=" + std::to_string(GetCurrentProcessId())); + + Ipc::ReadOnlyMapping v2Pose; + Ipc::ReadOnlyMapping v2Frames; + Ipc::ReadOnlyMapping v3Client; + Ipc::ReadOnlyMapping v3Frames; + Ipc::ReadOnlyMapping v3Ack; + + ULONGLONG activeSince = 0; + ULONGLONG lastSummary = 0; + ULONGLONG lastPoseChange = 0; + ULONGLONG lastFrameChange = 0; + std::uint32_t lastPoseSequence = 0; + std::uint32_t lastHeartbeat = 0; + std::uint32_t lastFrameId = 0; + bool noClientWarned = false; + bool noStereoWarned = false; + bool poseStallWarned = false; + bool frameStallWarned = false; + + while (!stop_.load(std::memory_order_acquire)) + { + v2Pose.EnsureOpen(SharedMemoryName); + v2Frames.EnsureOpen(RenderFrameMemoryName); + v3Client.EnsureOpen(IpcV3::ClientStateName); + v3Frames.EnsureOpen(IpcV3::FrameRingName); + v3Ack.EnsureOpen(IpcV3::AckStateName); + + SharedPoseState pose{}; + const bool havePose = v2Pose.IsOpen() && + IpcV3::ShadowV2::StableReadPose(v2Pose.Get(), pose) && + pose.magic == SharedMagic && pose.protocolVersion == SharedProtocolVersion; + + SharedRenderFrameState frame{}; + bool haveFrame = false; + if (v2Frames.IsOpen()) + { + SharedRenderFrameRing ring{}; + if (IpcV3::ShadowV2::StableReadFrameRing(v2Frames.Get(), ring)) + haveFrame = IpcV3::ShadowV2::LatestFrame(ring, frame); + } + + IpcV3::ClientState client{}; + const bool haveClient = v3Client.IsOpen() && StableReadState(v3Client, client) && HeaderValid(client); + IpcV3::FrameRing frame3{}; + const bool haveFrame3 = v3Frames.IsOpen() && StableReadFrameRing(v3Frames.Get(), frame3) && HeaderValid(frame3); + IpcV3::AckState ack{}; + const bool haveAck = v3Ack.IsOpen() && StableReadState(v3Ack, ack) && HeaderValid(ack); + + const ULONGLONG now = GetTickCount64(); + if (havePose && pose.hostPid == GetCurrentProcessId() && !activeSince) + { + activeSince = now; + lastPoseChange = now; + lastFrameChange = now; + log.Write("INFO", "host_pose_active runtime=" + + BoundedString(pose.runtimeName, sizeof(pose.runtimeName))); + } + + if (havePose) + { + if (pose.sequence != lastPoseSequence) + { + lastPoseSequence = pose.sequence; + lastPoseChange = now; + poseStallWarned = false; + } + const std::uint32_t heartbeat = pose.reserved[ClientHeartbeatIndex]; + if (heartbeat != lastHeartbeat) + lastHeartbeat = heartbeat; + } + if (haveFrame && frame.frameId != lastFrameId) + { + lastFrameId = frame.frameId; + lastFrameChange = now; + frameStallWarned = false; + } + + if (activeSince) + { + if (!noClientWarned && now - activeSince > 7000 && (!havePose || pose.clientPid == 0 || lastHeartbeat == 0)) + { + noClientWarned = true; + log.Write("WARN", "game_client_missing: dinput8 VR hook heartbeat not observed after 7s"); + } + if (!noStereoWarned && now - activeSince > 12000 && lastFrameId == 0) + { + noStereoWarned = true; + log.Write("WARN", "stereo_frame_missing: no rendered stereo frame observed after 12s; enter gameplay if still in menus"); + } + if (!poseStallWarned && lastPoseSequence && now - lastPoseChange > 2000) + { + poseStallWarned = true; + log.Write("WARN", "pose_stalled: OpenXR host pose sequence has not advanced for 2s"); + } + if (!frameStallWarned && lastFrameId && now - lastFrameChange > 3000 && + haveClient && client.presentationMode == PresentationGameplay) + { + frameStallWarned = true; + log.Write("WARN", "frame_stalled: game reports gameplay but stereo frame id has not advanced for 3s"); + } + } + + if (now - lastSummary >= 5000) + { + lastSummary = now; + std::ostringstream s; + s << "summary hostPose=" << (havePose ? 1 : 0) + << " hostSeq=" << (havePose ? pose.sequence : 0) + << " hostFlags=0x" << std::hex << (havePose ? pose.flags : 0) << std::dec + << " clientPid=" << (havePose ? pose.clientPid : 0) + << " heartbeat=" << lastHeartbeat + << " stereoFrame=" << (haveFrame ? frame.frameId : 0) + << " stereoState=" << (haveFrame ? frame.state : 0) + << " failure=" << (haveFrame ? frame.failureReason : 0) + << " poseSeq=" << (haveFrame ? frame.sourcePoseSequence : 0) + << " direct=" << ((haveFrame && (frame.flags & RenderFrameDirectGpuTransport)) ? 1 : 0) + << " v3Client=" << (haveClient ? 1 : 0) + << " v3ClientSeq=" << (haveClient ? client.sequence : 0) + << " v3Frame=" << (haveFrame3 ? 1 : 0) + << " v3LatestSlot=" << (haveFrame3 ? frame3.latestSlot : 0) + << " v3Ack=" << (haveAck ? 1 : 0) + << " ackFrame=" << (haveAck ? ack.consumedFrameId : 0) + << " ackSlot=" << (haveAck ? ack.consumedSlot : 0); + log.Write("INFO", s.str()); + } + + Sleep(100); + } + + log.Write("INFO", "watchdog_stop"); + } + + std::atomic stop_{false}; + std::thread thread_; + }; + + RuntimeWatchdog g_runtimeWatchdog; + } +} diff --git a/vrhost/src/frame_source.hpp b/vrhost/src/frame_source.hpp new file mode 100644 index 000000000..6a765fca9 --- /dev/null +++ b/vrhost/src/frame_source.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include "vr/core/frame_types.hpp" +#include "vr/core/transport.hpp" + +namespace OutRunVR::Host +{ + class IFrameSource + { + public: + virtual ~IFrameSource() = default; + virtual Core::TransportKind kind() const noexcept = 0; + virtual bool available() const noexcept = 0; + virtual bool acquire(const Core::PresentedFrame& descriptor) noexcept = 0; + virtual void release(std::uint64_t frameId) noexcept = 0; + virtual void invalidate() noexcept = 0; + }; +} diff --git a/vrhost/src/ipc/host_state_v3_writer.hpp b/vrhost/src/ipc/host_state_v3_writer.hpp new file mode 100644 index 000000000..65280826a --- /dev/null +++ b/vrhost/src/ipc/host_state_v3_writer.hpp @@ -0,0 +1,205 @@ +#pragma once + +#include + +#include +#include +#include + +#include "vr/ipc/protocol_v3.hpp" +#include "vr/ipc/win32_channel.hpp" + +namespace OutRunVR::Host +{ + class HostStateV3Writer + { + public: + HostStateV3Writer(std::uint32_t adapterLuidLow, std::int32_t adapterLuidHigh) + : adapterLuidLow_(adapterLuidLow), adapterLuidHigh_(adapterLuidHigh) + { + mapping_ = CreateFileMappingW(INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE, 0, + static_cast(sizeof(IpcV3::HostState)), IpcV3::HostStateName); + if (!mapping_) + throw std::runtime_error("CreateFileMappingW HostState.v3 failed"); + + try + { + const bool existed = GetLastError() == ERROR_ALREADY_EXISTS; + state_ = static_cast(MapViewOfFile( + mapping_, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(IpcV3::HostState))); + if (!state_) + throw std::runtime_error("MapViewOfFile HostState.v3 failed"); + + if (!existed) + { + std::memset(state_, 0, sizeof(*state_)); + state_->version = IpcV3::ProtocolVersion; + state_->structSize = sizeof(*state_); + MemoryBarrier(); + state_->magic = IpcV3::HostMagic; + } + else + { + for (int i = 0; i < 200 && !HeaderValid(*state_); ++i) + Sleep(10); + if (!HeaderValid(*state_)) + throw std::runtime_error("existing HostState.v3 mapping is not ready"); + } + + AcquireOwnership(); + referenceSpaceGeneration_ = state_->referenceSpaceGeneration + 1u; + if (referenceSpaceGeneration_ == 0) + referenceSpaceGeneration_ = 1; + + IpcV3::HostState initial{}; + IpcV3::InitializeWireState(initial); + initial.hostPid = GetCurrentProcessId(); + initial.referenceSpaceGeneration = referenceSpaceGeneration_; + initial.adapterLuidLow = adapterLuidLow_; + initial.adapterLuidHigh = adapterLuidHigh_; + Publish(initial); + } + catch (...) + { + // A constructor that throws never runs this object's destructor. + // Release the mapped view/handle here so ownership/header races + // cannot leak a named mapping until process exit. + Reset(false); + throw; + } + } + + HostStateV3Writer(const HostStateV3Writer&) = delete; + HostStateV3Writer& operator=(const HostStateV3Writer&) = delete; + + ~HostStateV3Writer() + { + Reset(true); + } + + void ReferenceSpaceChanged() noexcept + { + if (++referenceSpaceGeneration_ == 0) + referenceSpaceGeneration_ = 1; + } + + void SyncReferenceSpaceGeneration(std::uint32_t generation) noexcept + { + referenceSpaceGeneration_ = generation ? generation : 1u; + } + + std::uint32_t ReferenceSpaceGeneration() const noexcept + { + return referenceSpaceGeneration_; + } + + std::uint32_t Publish(IpcV3::HostState next) noexcept + { + if (!state_ || !owns_) + return 0; + + next.magic = IpcV3::HostMagic; + next.version = IpcV3::ProtocolVersion; + next.structSize = sizeof(IpcV3::HostState); + next.hostPid = GetCurrentProcessId(); + next.referenceSpaceGeneration = referenceSpaceGeneration_; + next.adapterLuidLow = adapterLuidLow_; + next.adapterLuidHigh = adapterLuidHigh_; + + const std::uint32_t odd = Ipc::BeginSeqlockWrite(state_->sequence); + next.sequence = odd; + std::memcpy(state_, &next, sizeof(next)); + MemoryBarrier(); + return Ipc::EndSeqlockWrite(state_->sequence); + } + + private: + static bool HeaderValid(const IpcV3::HostState& state) noexcept + { + return state.magic == IpcV3::HostMagic && + state.version == IpcV3::ProtocolVersion && + state.structSize == sizeof(IpcV3::HostState); + } + + enum class ProcessLiveness : std::uint8_t + { + Dead, + Alive, + Unknown + }; + + static ProcessLiveness QueryProcessLiveness(DWORD pid) noexcept + { + if (!pid) + return ProcessLiveness::Dead; + HANDLE process = OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if (!process) + return GetLastError() == ERROR_INVALID_PARAMETER + ? ProcessLiveness::Dead + : ProcessLiveness::Unknown; + const DWORD wait = WaitForSingleObject(process, 0); + CloseHandle(process); + if (wait == WAIT_TIMEOUT) + return ProcessLiveness::Alive; + if (wait == WAIT_OBJECT_0) + return ProcessLiveness::Dead; + return ProcessLiveness::Unknown; + } + + void AcquireOwnership() + { + const LONG self = static_cast(GetCurrentProcessId()); + for (int i = 0; i < 100; ++i) + { + const LONG observed = static_cast(state_->hostPid); + if (observed == self) + { + owns_ = true; + return; + } + if (observed != 0 && + QueryProcessLiveness(static_cast(observed)) != ProcessLiveness::Dead) + throw std::runtime_error("another or unverified outrun-vr-host owns HostState.v3"); + if (InterlockedCompareExchange( + reinterpret_cast(&state_->hostPid), self, observed) == observed) + { + owns_ = true; + return; + } + Sleep(1); + } + throw std::runtime_error("failed to acquire HostState.v3 ownership"); + } + + void Reset(bool publishDead) noexcept + { + if (state_) + { + if (publishDead && owns_ && + state_->hostPid == GetCurrentProcessId()) + { + const std::uint32_t odd = Ipc::BeginSeqlockWrite(state_->sequence); + state_->sequence = odd; + state_->flags = 0; + state_->hostPid = 0; + Ipc::EndSeqlockWrite(state_->sequence); + } + UnmapViewOfFile(state_); + state_ = nullptr; + } + if (mapping_) + { + CloseHandle(mapping_); + mapping_ = nullptr; + } + owns_ = false; + } + + HANDLE mapping_ = nullptr; + IpcV3::HostState* state_ = nullptr; + bool owns_ = false; + std::uint32_t referenceSpaceGeneration_ = 1; + std::uint32_t adapterLuidLow_ = 0; + std::int32_t adapterLuidHigh_ = 0; + }; +} diff --git a/vrhost/src/ipc/v3_shadow_bridge.cpp b/vrhost/src/ipc/v3_shadow_bridge.cpp new file mode 100644 index 000000000..520c788dc --- /dev/null +++ b/vrhost/src/ipc/v3_shadow_bridge.cpp @@ -0,0 +1,509 @@ +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ipc/host_state_v3_writer.hpp" +#include "vr/ipc/protocol.hpp" +#include "vr/ipc/protocol_v3.hpp" +#include "vr/ipc/shadow_legacy_v2.hpp" +#include "vr/ipc/win32_channel.hpp" + +namespace OutRunVR::Host +{ + namespace + { + constexpr DWORD ShadowIdlePollMs = 8; + constexpr DWORD ShadowWriterRetryMs = 250; + + enum class ProcessLiveness : std::uint8_t + { + Dead, + Alive, + Unknown + }; + + ProcessLiveness QueryProcessLiveness(DWORD pid) noexcept + { + if (!pid) + return ProcessLiveness::Dead; + HANDLE process = OpenProcess( + SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if (!process) + { + return GetLastError() == ERROR_INVALID_PARAMETER + ? ProcessLiveness::Dead + : ProcessLiveness::Unknown; + } + const DWORD wait = WaitForSingleObject(process, 0); + CloseHandle(process); + if (wait == WAIT_TIMEOUT) + return ProcessLiveness::Alive; + if (wait == WAIT_OBJECT_0) + return ProcessLiveness::Dead; + return ProcessLiveness::Unknown; + } + + class JsonLog + { + public: + JsonLog() + { + stream_.open("outrun-vr-host-v3.log", std::ios::out | std::ios::trunc); + } + + void Write(const char* level, const char* event, + const std::string& message) noexcept + { + try + { + if (!stream_.is_open()) + return; + SYSTEMTIME now{}; + GetSystemTime(&now); + stream_ << "{\"time\":\"" + << now.wYear << '-' << now.wMonth << '-' << now.wDay << 'T' + << now.wHour << ':' << now.wMinute << ':' << now.wSecond << '.' + << now.wMilliseconds << "Z\",\"level\":\"" << Escape(level) + << "\",\"event\":\"" << Escape(event) + << "\",\"message\":\"" << Escape(message) << "\"}\n"; + stream_.flush(); + } + catch (...) + { + } + } + + private: + static std::string Escape(const std::string& value) + { + std::string out; + out.reserve(value.size() + 8); + for (const char c : value) + { + switch (c) + { + case '\\': out += "\\\\"; break; + case '"': out += "\\\""; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: out += c; break; + } + } + return out; + } + + std::ofstream stream_; + }; + + class AckStateWriter + { + public: + AckStateWriter() + { + mapping_ = CreateFileMappingW(INVALID_HANDLE_VALUE, nullptr, + PAGE_READWRITE, 0, static_cast(sizeof(IpcV3::AckState)), + IpcV3::AckStateName); + if (!mapping_) + return; + const bool existed = GetLastError() == ERROR_ALREADY_EXISTS; + state_ = static_cast(MapViewOfFile( + mapping_, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(IpcV3::AckState))); + if (!state_) + { + Reset(); + return; + } + if (!existed) + { + std::memset(state_, 0, sizeof(*state_)); + state_->version = IpcV3::ProtocolVersion; + state_->structSize = sizeof(*state_); + MemoryBarrier(); + state_->magic = IpcV3::AckMagic; + } + else + { + for (int i = 0; i < 100 && !HeaderValid(); ++i) + Sleep(2); + if (!HeaderValid()) + { + Reset(); + return; + } + } + owns_ = AcquireOwner(); + } + + ~AckStateWriter() + { + if (state_ && owns_ && + state_->consumerPid == GetCurrentProcessId()) + { + const std::uint32_t odd = + Ipc::BeginSeqlockWrite(state_->sequence); + state_->sequence = odd; + state_->flags = 0; + state_->consumerPid = 0; + Ipc::EndSeqlockWrite(state_->sequence); + } + Reset(); + } + + bool Ready() const noexcept { return state_ && owns_; } + + std::uint32_t Publish(IpcV3::AckState next) noexcept + { + if (!Ready()) + return 0; + next.magic = IpcV3::AckMagic; + next.version = IpcV3::ProtocolVersion; + next.structSize = sizeof(IpcV3::AckState); + next.consumerPid = GetCurrentProcessId(); + const std::uint32_t odd = + Ipc::BeginSeqlockWrite(state_->sequence); + next.sequence = odd; + std::memcpy(state_, &next, sizeof(next)); + MemoryBarrier(); + return Ipc::EndSeqlockWrite(state_->sequence); + } + + private: + bool HeaderValid() const noexcept + { + return state_ && state_->magic == IpcV3::AckMagic && + state_->version == IpcV3::ProtocolVersion && + state_->structSize == sizeof(IpcV3::AckState); + } + + bool AcquireOwner() noexcept + { + const LONG self = static_cast(GetCurrentProcessId()); + auto* pid = reinterpret_cast(&state_->consumerPid); + for (int i = 0; i < 100; ++i) + { + const LONG observed = *pid; + if (observed == self) + return true; + if (observed != 0 && + QueryProcessLiveness(static_cast(observed)) != + ProcessLiveness::Dead) + return false; + if (InterlockedCompareExchange(pid, self, observed) == observed) + return true; + Sleep(1); + } + return false; + } + + void Reset() noexcept + { + if (state_) + { + UnmapViewOfFile(state_); + state_ = nullptr; + } + if (mapping_) + { + CloseHandle(mapping_); + mapping_ = nullptr; + } + owns_ = false; + } + + HANDLE mapping_ = nullptr; + IpcV3::AckState* state_ = nullptr; + bool owns_ = false; + }; + + bool ReadClient(Ipc::ReadOnlyMapping& mapping, + IpcV3::ClientState& out) noexcept + { + if (!mapping.EnsureOpen(IpcV3::ClientStateName) || + !Ipc::StableRead(mapping.Get(), out)) + return false; + return out.magic == IpcV3::ClientMagic && + out.version == IpcV3::ProtocolVersion && + out.structSize == sizeof(IpcV3::ClientState); + } + + bool StableReadV3FrameRing(const IpcV3::FrameRing* shared, + IpcV3::FrameRing& out, int attempts = 8) noexcept + { + if (!shared) + return false; + for (int attempt = 0; attempt < attempts; ++attempt) + { + const std::uint32_t before = shared->publishSequence; + if (before & 1u) + continue; + MemoryBarrier(); + std::memcpy(&out, shared, sizeof(out)); + MemoryBarrier(); + const std::uint32_t after = shared->publishSequence; + if (before == after && !(after & 1u) && + out.magic == IpcV3::FrameMagic && + out.version == IpcV3::ProtocolVersion && + out.structSize == sizeof(IpcV3::FrameRing) && + out.slotCount == IpcV3::RingSize) + return true; + } + return false; + } + + bool LatestV3Frame(const IpcV3::FrameRing& ring, + IpcV3::FrameDescriptor& out) noexcept + { + if (ring.latestSlot >= IpcV3::RingSize) + return false; + out = ring.slots[ring.latestSlot]; + return out.frameId != 0; + } + + bool FrameParity(const SharedRenderFrameState& v2, + const IpcV3::FrameDescriptor& v3) noexcept + { + const auto expected = IpcV3::ShadowV2::FrameDescriptorFromV2(v2); + return std::memcmp(&expected, &v3, sizeof(expected)) == 0; + } + + class ShadowBridgeRuntime + { + public: + ShadowBridgeRuntime() : worker_([this] { Run(); }) {} + + ~ShadowBridgeRuntime() + { + stop_.store(true, std::memory_order_release); + if (worker_.joinable()) + worker_.join(); + } + + private: + void Run() + { + JsonLog log; + log.Write("INFO", "shadow_start", + "v3 shadow bridge started; bounded polling and retryable writers active; v2 remains runtime authority until parity is proven"); + + Ipc::ReadOnlyMapping legacyPoseMapping; + Ipc::ReadOnlyMapping legacyFrameMapping; + Ipc::ReadOnlyMapping clientMapping; + Ipc::ReadOnlyMapping v3FrameMapping; + std::unique_ptr hostWriter; + std::unique_ptr ackWriter; + + std::uint32_t lastPoseSequence = 0; + std::uint32_t lastAckToken = 0; + std::uint32_t lastConsumedFrame = 0; + std::uint64_t hostPublishes = 0; + std::uint64_t ackPublishes = 0; + std::uint64_t clientParityOk = 0; + std::uint64_t clientParityLag = 0; + std::uint64_t frameParityOk = 0; + std::uint64_t frameParityLag = 0; + ULONGLONG lastSummaryMs = 0; + ULONGLONG firstActiveMs = 0; + ULONGLONG nextWriterRetryMs = 0; + bool fallbackHintLogged = false; + bool readyLogged = false; + + while (!stop_.load(std::memory_order_acquire)) + { + if (!legacyPoseMapping.EnsureOpen(SharedMemoryName)) + { + Sleep(50); + continue; + } + + SharedPoseState pose{}; + if (!IpcV3::ShadowV2::StableReadPose( + legacyPoseMapping.Get(), pose)) + { + Sleep(ShadowIdlePollMs); + continue; + } + if (pose.hostPid != GetCurrentProcessId()) + { + Sleep(ShadowIdlePollMs); + continue; + } + + if (!firstActiveMs) + firstActiveMs = GetTickCount64(); + + SharedRenderFrameState latestFrame{}; + bool haveLatestFrame = false; + if (legacyFrameMapping.EnsureOpen(RenderFrameMemoryName)) + { + SharedRenderFrameRing ring{}; + if (IpcV3::ShadowV2::StableReadFrameRing( + legacyFrameMapping.Get(), ring)) + haveLatestFrame = + IpcV3::ShadowV2::LatestFrame(ring, latestFrame); + } + + const std::uint32_t directGeneration = haveLatestFrame && + (latestFrame.flags & RenderFrameDirectGpuTransport) + ? latestFrame.reserved[RenderFrameDirectGenerationIndex] : 0; + + const ULONGLONG now = GetTickCount64(); + const bool writerMissing = !hostWriter || !ackWriter || + (ackWriter && !ackWriter->Ready()); + if (writerMissing && now >= nextWriterRetryMs) + { + if (ackWriter && !ackWriter->Ready()) + ackWriter.reset(); + if (!hostWriter) + { + try + { + hostWriter = std::make_unique( + pose.hostAdapterLuidLow, + IpcV3::ShadowV2::SignedBits( + pose.hostAdapterLuidHigh)); + } + catch (const std::exception& error) + { + log.Write("ERROR", "host_v3_create_failed", + error.what()); + hostWriter.reset(); + } + } + if (hostWriter && !ackWriter) + ackWriter = std::make_unique(); + nextWriterRetryMs = now + ShadowWriterRetryMs; + + if (hostWriter && ackWriter && ackWriter->Ready() && + !readyLogged) + { + readyLogged = true; + std::ostringstream message; + message << "host v3 writer ready adapter=" + << pose.hostAdapterLuidHigh << ':' + << pose.hostAdapterLuidLow; + log.Write("INFO", "host_v3_ready", message.str()); + std::cout + << "[v3] live shadow HostState/AckState bridge ready.\n"; + } + } + + if (pose.sequence != lastPoseSequence && hostWriter) + { + hostWriter->SyncReferenceSpaceGeneration( + pose.reserved[HostReferenceSpaceGenerationIndex]); + auto state = IpcV3::ShadowV2::HostStateFromV2( + pose, directGeneration); + hostWriter->Publish(state); + lastPoseSequence = pose.sequence; + ++hostPublishes; + } + + if (ackWriter && ackWriter->Ready() && + (pose.hostInteropProbeAckToken != lastAckToken || + pose.hostDirectConsumedFrameId != lastConsumedFrame)) + { + auto ack = IpcV3::ShadowV2::AckStateFromV2( + pose, haveLatestFrame ? &latestFrame : nullptr, + GetCurrentProcessId()); + ackWriter->Publish(ack); + lastAckToken = pose.hostInteropProbeAckToken; + lastConsumedFrame = pose.hostDirectConsumedFrameId; + ++ackPublishes; + } + + IpcV3::ClientState client{}; + if (ReadClient(clientMapping, client)) + { + const auto expected = + IpcV3::ShadowV2::ClientStateFromV2( + pose, haveLatestFrame ? &latestFrame : nullptr); + if (client.clientPid == pose.clientPid && + client.flags == expected.flags && + client.presentationMode == expected.presentationMode && + client.gameState == expected.gameState && + client.stereoState == expected.stereoState && + client.lastFailure == expected.lastFailure && + client.lastPresentedFrameId == expected.lastPresentedFrameId && + client.lastRenderedPoseId == expected.lastRenderedPoseId) + ++clientParityOk; + else + ++clientParityLag; + } + + if (haveLatestFrame && + v3FrameMapping.EnsureOpen(IpcV3::FrameRingName)) + { + IpcV3::FrameRing ring{}; + IpcV3::FrameDescriptor frame{}; + if (StableReadV3FrameRing(v3FrameMapping.Get(), ring) && + LatestV3Frame(ring, frame)) + { + if (frame.frameId == latestFrame.frameId && + FrameParity(latestFrame, frame)) + ++frameParityOk; + else + ++frameParityLag; + } + } + + if (!fallbackHintLogged && firstActiveMs && + now - firstActiveMs > 5000 && + (pose.flags & HostDirectGpuTransport) && + !(pose.flags & HostDirectGpuReady)) + { + fallbackHintLogged = true; + log.Write("WARN", "direct_gpu_not_ready", + "D3D9Ex direct sharing is not ready after 5s; Desktop Duplication fallback remains active."); + } + + if (now - lastSummaryMs >= 5000) + { + lastSummaryMs = now; + std::ostringstream message; + message << "hostPub=" << hostPublishes + << " ackPub=" << ackPublishes + << " clientOk=" << clientParityOk + << " clientLag=" << clientParityLag + << " frameOk=" << frameParityOk + << " frameLag=" << frameParityLag + << " pose=" << pose.sequence + << " frame=" << (haveLatestFrame ? latestFrame.frameId : 0) + << " failure=" << (haveLatestFrame ? latestFrame.failureReason : 0) + << " hostFlags=0x" << std::hex << pose.flags << std::dec + << " directSupported=" + << ((pose.flags & HostDirectGpuTransport) ? 1 : 0) + << " directReady=" + << ((pose.flags & HostDirectGpuReady) ? 1 : 0) + << " probeHandle=" << pose.clientInteropProbeHandle + << " probeToken=" << pose.clientInteropProbeToken + << " probeAck=" << pose.hostInteropProbeAckToken + << " consumed=" << pose.hostDirectConsumedFrameId; + log.Write("INFO", "shadow_summary", message.str()); + std::cout << "[v3] " << message.str() << '\n'; + } + + Sleep(ShadowIdlePollMs); + } + + log.Write("INFO", "shadow_stop", "v3 shadow bridge stopped"); + } + + std::atomic stop_{false}; + std::thread worker_; + }; + + ShadowBridgeRuntime g_shadowBridgeRuntime; + } +} diff --git a/vrhost/src/main.cpp b/vrhost/src/main.cpp new file mode 100644 index 000000000..6ac44a156 --- /dev/null +++ b/vrhost/src/main.cpp @@ -0,0 +1,2586 @@ +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#define XR_USE_PLATFORM_WIN32 +#define XR_USE_GRAPHICS_API_D3D11 + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "vr_shared.hpp" +#include "stereo_shader.hpp" + +namespace +{ + constexpr wchar_t GameExeName[] = L"OR2006C2C.EXE"; + constexpr ULONGLONG StereoGraceMs = 250; + constexpr std::size_t ViewHistorySize = 128; + constexpr ULONGLONG DirectCopyFenceBudgetMs = 8; + + template + void ReleaseCom(T*& p) + { + if (p) { p->Release(); p = nullptr; } + } + + void CheckXr(XrResult r, const char* what) + { + if (XR_FAILED(r)) + throw std::runtime_error(std::string(what) + " failed, XrResult=" + std::to_string(r)); + } + + void CheckHr(HRESULT r, const char* what) + { + if (FAILED(r)) + throw std::runtime_error(std::string(what) + " failed, HRESULT=" + std::to_string(static_cast(r))); + } + + bool HasExtension(const char* wanted) + { + std::uint32_t count = 0; + CheckXr(xrEnumerateInstanceExtensionProperties(nullptr, 0, &count, nullptr), + "xrEnumerateInstanceExtensionProperties(count)"); + std::vector ext(count); + for (auto& e : ext) e = { XR_TYPE_EXTENSION_PROPERTIES }; + CheckXr(xrEnumerateInstanceExtensionProperties(nullptr, count, &count, ext.data()), + "xrEnumerateInstanceExtensionProperties(list)"); + for (const auto& e : ext) + if (std::strcmp(e.extensionName, wanted) == 0) return true; + return false; + } + + struct WindowSearch + { + DWORD pid = 0; + HWND best = nullptr; + long long bestArea = 0; + }; + + BOOL CALLBACK EnumGameWindows(HWND hwnd, LPARAM param) + { + auto* s = reinterpret_cast(param); + DWORD pid = 0; + GetWindowThreadProcessId(hwnd, &pid); + if (pid != s->pid || !IsWindowVisible(hwnd)) return TRUE; + RECT r{}; + if (!GetClientRect(hwnd, &r)) return TRUE; + const long long w = r.right - r.left; + const long long h = r.bottom - r.top; + const long long area = w * h; + if (w >= 320 && h >= 200 && area > s->bestArea) + { + s->best = hwnd; + s->bestArea = area; + } + return TRUE; + } + + bool ProcessAliveForSelection(DWORD pid) + { + if (!pid) + return false; + HANDLE process = OpenProcess( + SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if (!process) + return false; + const DWORD wait = WaitForSingleObject(process, 0); + CloseHandle(process); + return wait == WAIT_TIMEOUT; + } + + DWORD SharedClientPid() + { + HANDLE mapping = OpenFileMappingW(FILE_MAP_READ, FALSE, OutRunVR::SharedMemoryName); + if (!mapping) + return 0; + auto* state = static_cast( + MapViewOfFile(mapping, FILE_MAP_READ, 0, 0, sizeof(OutRunVR::SharedPoseState))); + DWORD pid = 0; + if (state) + { + if (state->magic == OutRunVR::SharedMagic && + state->protocolVersion == OutRunVR::SharedProtocolVersion && + state->structSize == sizeof(OutRunVR::SharedPoseState)) + pid = state->clientPid; + UnmapViewOfFile(state); + } + CloseHandle(mapping); + return pid; + } + + ULONGLONG ProcessCreationKey(DWORD pid) + { + HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if (!process) + return 0; + FILETIME created{}, exited{}, kernel{}, user{}; + const bool ok = GetProcessTimes(process, &created, &exited, &kernel, &user) != FALSE; + CloseHandle(process); + if (!ok) + return 0; + ULARGE_INTEGER value{}; + value.LowPart = created.dwLowDateTime; + value.HighPart = created.dwHighDateTime; + return value.QuadPart; + } + + DWORD FindGameProcess() + { + // Prefer the PID published by the x86 plugin. This prevents the host + // from binding to an older OR2006C2C.EXE that is still alive after a + // restart. The 2026-09-19 diagnostic captured exactly that failure: + // the host/watchdog followed one client PID while the active game log + // belonged to another process, leaving stereoFrame=0 indefinitely. + const DWORD sharedPid = SharedClientPid(); + if (sharedPid && ProcessAliveForSelection(sharedPid)) + return sharedPid; + + // No valid shared client yet (host launched before the game). If more + // than one copy exists, choose the newest process rather than the first + // Toolhelp entry, whose ordering is not a binding contract. + HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snap == INVALID_HANDLE_VALUE) return 0; + PROCESSENTRY32W e{}; + e.dwSize = sizeof(e); + DWORD pid = 0; + ULONGLONG newest = 0; + if (Process32FirstW(snap, &e)) + { + do + { + if (_wcsicmp(e.szExeFile, GameExeName) == 0) + { + const ULONGLONG created = ProcessCreationKey(e.th32ProcessID); + if (!pid || created >= newest) + { + newest = created; + pid = e.th32ProcessID; + } + } + } while (Process32NextW(snap, &e)); + } + CloseHandle(snap); + return pid; + } + + HWND FindGameWindow(DWORD pid) + { + WindowSearch s{}; + s.pid = pid; + EnumWindows(EnumGameWindows, reinterpret_cast(&s)); + return s.best; + } + + HWND WaitForGameWindow() + { + std::cout << "Waiting for OR2006C2C.EXE... Launch OutRun from the Virtual Desktop screen.\n"; + DWORD last = 0; + for (;;) + { + const DWORD pid = FindGameProcess(); + if (pid && pid != last) + { + std::cout << "OutRun process detected (pid=" << pid << ").\n"; + last = pid; + } + if (pid) + { + if (HWND hwnd = FindGameWindow(pid)) + { + std::cout << "OutRun window detected. Starting true-stereo OpenXR host.\n"; + return hwnd; + } + } + Sleep(100); + } + } + + bool SameLuid(const LUID& a, const LUID& b) + { + return a.LowPart == b.LowPart && a.HighPart == b.HighPart; + } + + IDXGIAdapter1* FindAdapter(const LUID& wanted) + { + IDXGIFactory1* f = nullptr; + CheckHr(CreateDXGIFactory1(__uuidof(IDXGIFactory1), reinterpret_cast(&f)), + "CreateDXGIFactory1"); + IDXGIAdapter1* match = nullptr; + for (UINT i = 0;; ++i) + { + IDXGIAdapter1* a = nullptr; + if (f->EnumAdapters1(i, &a) == DXGI_ERROR_NOT_FOUND) break; + DXGI_ADAPTER_DESC1 d{}; + a->GetDesc1(&d); + if (SameLuid(d.AdapterLuid, wanted)) + { + match = a; + break; + } + a->Release(); + } + f->Release(); + if (!match) throw std::runtime_error("OpenXR D3D11 adapter not found"); + return match; + } + + struct D3DObjects + { + ID3D11Device* device = nullptr; + ID3D11DeviceContext* context = nullptr; + D3DObjects() = default; + D3DObjects(const D3DObjects&) = delete; + D3DObjects& operator=(const D3DObjects&) = delete; + D3DObjects(D3DObjects&& other) noexcept + : device(std::exchange(other.device, nullptr)), + context(std::exchange(other.context, nullptr)) {} + D3DObjects& operator=(D3DObjects&& other) noexcept + { + if (this != &other) + { + ReleaseCom(context); + ReleaseCom(device); + device = std::exchange(other.device, nullptr); + context = std::exchange(other.context, nullptr); + } + return *this; + } + ~D3DObjects() + { + ReleaseCom(context); + ReleaseCom(device); + } + }; + + D3DObjects CreateD3D11Device(const XrGraphicsRequirementsD3D11KHR& req) + { + IDXGIAdapter1* adapter = FindAdapter(req.adapterLuid); + const std::array all{ + D3D_FEATURE_LEVEL_12_1, D3D_FEATURE_LEVEL_12_0, + D3D_FEATURE_LEVEL_11_1, D3D_FEATURE_LEVEL_11_0, + D3D_FEATURE_LEVEL_10_1, D3D_FEATURE_LEVEL_10_0, + D3D_FEATURE_LEVEL_9_3 + }; + std::vector levels; + for (auto l : all) if (l >= req.minFeatureLevel) levels.push_back(l); + if (levels.empty()) levels.push_back(req.minFeatureLevel); + + D3DObjects out; + D3D_FEATURE_LEVEL selected{}; + const HRESULT hr = D3D11CreateDevice(adapter, D3D_DRIVER_TYPE_UNKNOWN, nullptr, + D3D11_CREATE_DEVICE_BGRA_SUPPORT, levels.data(), static_cast(levels.size()), + D3D11_SDK_VERSION, &out.device, &selected, &out.context); + adapter->Release(); + CheckHr(hr, "D3D11CreateDevice"); + return out; + } + + XrVector3f RotateVector(const XrQuaternionf& q, const XrVector3f& v) + { + const XrVector3f u{ q.x, q.y, q.z }; + const XrVector3f t{ + 2.f * (u.y * v.z - u.z * v.y), + 2.f * (u.z * v.x - u.x * v.z), + 2.f * (u.x * v.y - u.y * v.x) + }; + return { + v.x + q.w * t.x + (u.y * t.z - u.z * t.y), + v.y + q.w * t.y + (u.z * t.x - u.x * t.z), + v.z + q.w * t.z + (u.x * t.y - u.y * t.x) + }; + } + + XrQuaternionf NormalizeQuaternion(XrQuaternionf q){const float l=q.x*q.x+q.y*q.y+q.z*q.z+q.w*q.w;if(!std::isfinite(l)||l<=1e-12f)return{0,0,0,1};const float i=1.0f/std::sqrt(l);q.x*=i;q.y*=i;q.z*=i;q.w*=i;return q;} + XrQuaternionf ConjugateQuaternion(XrQuaternionf q){q=NormalizeQuaternion(q);return{-q.x,-q.y,-q.z,q.w};} + XrQuaternionf MultiplyQuaternion(const XrQuaternionf&aIn,const XrQuaternionf&bIn){const auto a=NormalizeQuaternion(aIn),b=NormalizeQuaternion(bIn);return NormalizeQuaternion({a.w*b.x+a.x*b.w+a.y*b.z-a.z*b.y,a.w*b.y-a.x*b.z+a.y*b.w+a.z*b.x,a.w*b.z+a.x*b.y-a.y*b.x+a.z*b.w,a.w*b.w-a.x*b.x-a.y*b.y-a.z*b.z});} + + XrVector3f ToHeadLocal(const XrPosef& head, const XrVector3f& world) + { + XrVector3f delta{ + world.x - head.position.x, + world.y - head.position.y, + world.z - head.position.z + }; + return RotateVector(ConjugateQuaternion(head.orientation),delta); + } + XrQuaternionf ToHeadLocalOrientation(const XrPosef& head,const XrPosef& eye){return MultiplyQuaternion(ConjugateQuaternion(head.orientation),eye.orientation);} + + std::uint32_t FloatBits(float v) + { + std::uint32_t b = 0; + std::memcpy(&b, &v, sizeof(b)); + return b; + } + + std::int16_t PackSnorm16(float value) + { + const float clamped = std::clamp(value, -1.0f, 1.0f); + return static_cast(std::lround(clamped * 32767.0f)); + } + + void StorePackedEyeOrientations(char runtimeName[64], const XrQuaternionf eyeOrientation[2]) + { + std::int16_t packed[8]{}; + for (int eye = 0; eye < 2; ++eye) + { + const XrQuaternionf q = NormalizeQuaternion(eyeOrientation[eye]); + packed[eye * 4 + 0] = PackSnorm16(q.x); + packed[eye * 4 + 1] = PackSnorm16(q.y); + packed[eye * 4 + 2] = PackSnorm16(q.z); + packed[eye * 4 + 3] = PackSnorm16(q.w); + } + static_assert(sizeof(packed) == OutRunVR::PackedEyeOrientationBytes); + std::memcpy(runtimeName + OutRunVR::PackedEyeOrientationOffset, packed, sizeof(packed)); + } + + enum class ProcessLiveness : std::uint8_t + { + Dead, + Alive, + Unknown + }; + + ProcessLiveness QueryProcessLiveness(DWORD pid) + { + if (!pid) return ProcessLiveness::Dead; + HANDLE p = OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if (!p) + return GetLastError() == ERROR_INVALID_PARAMETER + ? ProcessLiveness::Dead + : ProcessLiveness::Unknown; + const DWORD wait = WaitForSingleObject(p, 0); + CloseHandle(p); + if (wait == WAIT_TIMEOUT) return ProcessLiveness::Alive; + if (wait == WAIT_OBJECT_0) return ProcessLiveness::Dead; + return ProcessLiveness::Unknown; + } + + struct ClientStereoMeta + { + bool valid = false; + std::uint32_t state = OutRunVR::StereoDisabled; + std::uint32_t frame = 0; + std::uint32_t poseSequence = 0; + }; + + class SharedWriter + { + public: + explicit SharedWriter(const LUID& adapterLuid) : adapterLuid_(adapterLuid) + { + mapping_ = CreateFileMappingW(INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE, 0, + static_cast(sizeof(OutRunVR::SharedPoseState)), OutRunVR::SharedMemoryName); + if (!mapping_) throw std::runtime_error("CreateFileMappingW failed"); + const bool existed = GetLastError() == ERROR_ALREADY_EXISTS; + state_ = static_cast(MapViewOfFile(mapping_, FILE_MAP_ALL_ACCESS, + 0, 0, sizeof(OutRunVR::SharedPoseState))); + if (!state_) throw std::runtime_error("MapViewOfFile failed"); + if (!existed) + { + std::memset(state_, 0, sizeof(*state_)); + state_->protocolVersion = OutRunVR::SharedProtocolVersion; + state_->structSize = sizeof(*state_); + MemoryBarrier(); + state_->magic = OutRunVR::SharedMagic; + } + else + { + for (int i = 0; i < 200 && !HeaderValid(); ++i) Sleep(10); + if (!HeaderValid()) throw std::runtime_error("existing VR shared mapping is not ready"); + } + AcquireOwnership(); + Begin(); + state_->hostAdapterLuidLow = adapterLuid_.LowPart; + state_->hostAdapterLuidHigh = static_cast(adapterLuid_.HighPart); + End(); + } + + ~SharedWriter() + { + if (state_) + { + if (owns_ && state_->hostPid == GetCurrentProcessId()) + { + Begin(); + state_->flags = 0; + state_->hostPid = 0; + End(); + } + UnmapViewOfFile(state_); + } + if (mapping_) CloseHandle(mapping_); + } + + void ReferenceSpaceChanged() + { + if (++referenceGeneration_ == 0) referenceGeneration_ = 1; + } + + OutRunVR::ClientPresentationMode Presentation() const + { + if (!HeaderValid()) return OutRunVR::PresentationTheater; + return state_->reserved[OutRunVR::ClientPresentationModeIndex] == OutRunVR::PresentationGameplay + ? OutRunVR::PresentationGameplay : OutRunVR::PresentationTheater; + } + + ClientStereoMeta ReadStereoMeta() const + { + ClientStereoMeta out{}; + if (!HeaderValid()) return out; + for (int attempt = 0; attempt < 4; ++attempt) + { + const std::uint32_t before = state_->reserved[OutRunVR::ClientStereoFrameIndex]; + MemoryBarrier(); + out.state = state_->reserved[OutRunVR::ClientStereoStateIndex]; + out.poseSequence = state_->clientStereoPoseSequence; + MemoryBarrier(); + const std::uint32_t after = state_->reserved[OutRunVR::ClientStereoFrameIndex]; + if (before == after) + { + out.frame = after; + out.valid = true; + return out; + } + } + return {}; + } + + + bool ServiceInteropProbe(ID3D11Device* device, ID3D11DeviceContext* context) + { + if (!HeaderValid() || !device || !context) return false; + const std::uint32_t handleValue = state_->clientInteropProbeHandle; + const std::uint32_t token = state_->clientInteropProbeToken; + if (!handleValue || !token) return false; + if (interopVerifiedToken_ == token && state_->hostInteropProbeAckToken == token) return true; + if (state_->clientAdapterLuidLow != adapterLuid_.LowPart || + state_->clientAdapterLuidHigh != static_cast(adapterLuid_.HighPart)) return false; + + ID3D11Resource* resource = nullptr; + ID3D11Texture2D* texture = nullptr; + ID3D11Texture2D* staging = nullptr; + const HANDLE handle = reinterpret_cast(static_cast(handleValue)); + bool verified = false; + if (SUCCEEDED(device->OpenSharedResource(handle, __uuidof(ID3D11Resource), reinterpret_cast(&resource))) && resource && + SUCCEEDED(resource->QueryInterface(__uuidof(ID3D11Texture2D), reinterpret_cast(&texture))) && texture) + { + D3D11_TEXTURE2D_DESC desc{}; + texture->GetDesc(&desc); + if (desc.Width == 1 && desc.Height == 1 && desc.SampleDesc.Count == 1 && + (desc.Format == DXGI_FORMAT_R8G8B8A8_UNORM || desc.Format == DXGI_FORMAT_B8G8R8A8_UNORM)) + { + D3D11_TEXTURE2D_DESC sd = desc; + sd.BindFlags = 0; + sd.MiscFlags = 0; + sd.Usage = D3D11_USAGE_STAGING; + sd.CPUAccessFlags = D3D11_CPU_ACCESS_READ; + if (SUCCEEDED(device->CreateTexture2D(&sd, nullptr, &staging)) && staging) + { + context->CopyResource(staging, texture); + D3D11_MAPPED_SUBRESOURCE mapped{}; + if (SUCCEEDED(context->Map(staging, 0, D3D11_MAP_READ, 0, &mapped)) && mapped.pData) + { + const auto* px = static_cast(mapped.pData); + verified = px[0] == 0x7B && px[1] == 0x7B && px[2] == 0x7B && px[3] == 0xFF; + context->Unmap(staging, 0); + } + } + } + } + ReleaseCom(staging); + ReleaseCom(texture); + ReleaseCom(resource); + if (verified) + { + interopVerifiedToken_ = token; + InterlockedExchange(reinterpret_cast(&state_->hostInteropProbeAckToken), static_cast(token)); + if (!interopVerifiedLogged_) + { + interopVerifiedLogged_ = true; + std::cout << "D3D9Ex/D3D11 interop verification pixel passed on the OpenXR adapter.\n"; + } + } + return verified; + } + + void AckDirectFrame(std::uint32_t frameId) + { + if (HeaderValid() && frameId) + InterlockedExchange(reinterpret_cast(&state_->hostDirectConsumedFrameId), static_cast(frameId)); + } + + std::uint32_t Write(const XrSpaceLocation& head, + const std::array& views, std::uint32_t viewCount, + const std::array& configs, + XrSessionState sessionState, XrViewStateFlags viewFlags, bool shouldRender, + bool directTransportEnabled, bool directTransportReady, const char* runtime) + { + LARGE_INTEGER qpc{}; + QueryPerformanceCounter(&qpc); + std::uint32_t flags = OutRunVR::HostAlive; + if (head.locationFlags & XR_SPACE_LOCATION_ORIENTATION_VALID_BIT) flags |= OutRunVR::OrientationValid; + if (head.locationFlags & XR_SPACE_LOCATION_POSITION_VALID_BIT) flags |= OutRunVR::PositionValid; + if (sessionState == XR_SESSION_STATE_VISIBLE || sessionState == XR_SESSION_STATE_FOCUSED) + flags |= OutRunVR::SessionVisible; + if (sessionState == XR_SESSION_STATE_FOCUSED) flags |= OutRunVR::SessionFocused; + if (shouldRender) flags |= OutRunVR::HostShouldRender; + if (directTransportEnabled) flags |= OutRunVR::HostDirectGpuTransport; + flags |= OutRunVR::HostAdapterLuidValid; + if (directTransportEnabled && directTransportReady) flags |= OutRunVR::HostDirectGpuReady; + + const XrViewStateFlags required = XR_VIEW_STATE_ORIENTATION_VALID_BIT | + XR_VIEW_STATE_POSITION_VALID_BIT; + const bool stereoValid = viewCount >= 2 && (viewFlags & required) == required && + (head.locationFlags & (XR_SPACE_LOCATION_ORIENTATION_VALID_BIT | + XR_SPACE_LOCATION_POSITION_VALID_BIT)) == + (XR_SPACE_LOCATION_ORIENTATION_VALID_BIT | XR_SPACE_LOCATION_POSITION_VALID_BIT); + if(stereoValid)flags|=OutRunVR::StereoViewsValid|OutRunVR::StereoEyeOrientationValid; + + Begin(); + state_->hostPid = GetCurrentProcessId(); + state_->flags = flags; + ++state_->heartbeat; + state_->sampleQpc = qpc.QuadPart; + state_->orientation[0] = head.pose.orientation.x; + state_->orientation[1] = head.pose.orientation.y; + state_->orientation[2] = head.pose.orientation.z; + state_->orientation[3] = head.pose.orientation.w; + state_->position[0] = head.pose.position.x; + state_->position[1] = head.pose.position.y; + state_->position[2] = head.pose.position.z; + state_->reserved[OutRunVR::HostReferenceSpaceGenerationIndex] = referenceGeneration_; + for (std::uint32_t eye = 0; eye < 2; ++eye) + { + state_->recommendedWidth[eye] = configs[eye].recommendedImageRectWidth; + state_->recommendedHeight[eye] = configs[eye].recommendedImageRectHeight; + if (eye < viewCount) + { + state_->eyeFov[eye] = { + views[eye].fov.angleLeft, views[eye].fov.angleRight, + views[eye].fov.angleUp, views[eye].fov.angleDown + }; + } + } + + std::memset(state_->runtimeName, 0, sizeof(state_->runtimeName)); + strncpy_s(state_->runtimeName, OutRunVR::PackedEyeOrientationOffset, + runtime ? runtime : "unknown", _TRUNCATE); + if (stereoValid) + { + const XrVector3f left = ToHeadLocal(head.pose, views[0].pose.position); + const XrVector3f right = ToHeadLocal(head.pose, views[1].pose.position); + const XrQuaternionf eyeOrientation[2]{ + ToHeadLocalOrientation(head.pose, views[0].pose), + ToHeadLocalOrientation(head.pose, views[1].pose) + }; + StorePackedEyeOrientations(state_->runtimeName, eyeOrientation); + state_->reserved[OutRunVR::HostEyeOffsetLeftXIndex] = FloatBits(left.x); + state_->reserved[OutRunVR::HostEyeOffsetLeftYIndex] = FloatBits(left.y); + state_->reserved[OutRunVR::HostEyeOffsetLeftZIndex] = FloatBits(left.z); + state_->reserved[OutRunVR::HostEyeOffsetRightXIndex] = FloatBits(right.x); + state_->reserved[OutRunVR::HostEyeOffsetRightYIndex] = FloatBits(right.y); + state_->reserved[OutRunVR::HostEyeOffsetRightZIndex] = FloatBits(right.z); + } + return End(); + } + + private: + bool HeaderValid() const + { + return state_ && state_->magic == OutRunVR::SharedMagic && + state_->protocolVersion == OutRunVR::SharedProtocolVersion && + state_->structSize == sizeof(*state_); + } + + void AcquireOwnership() + { + const LONG self = static_cast(GetCurrentProcessId()); + for (int i = 0; i < 100; ++i) + { + const LONG observed = static_cast(state_->hostPid); + if (observed == self) { owns_ = true; return; } + if (observed != 0 && + QueryProcessLiveness(static_cast(observed)) != ProcessLiveness::Dead) + throw std::runtime_error("another or unverified outrun-vr-host already owns the bridge"); + if (InterlockedCompareExchange(reinterpret_cast(&state_->hostPid), + self, observed) == observed) + { + owns_ = true; + return; + } + Sleep(1); + } + throw std::runtime_error("failed to acquire host ownership"); + } + + void Begin() + { + LONG s = InterlockedIncrement(reinterpret_cast(&state_->sequence)); + if ((s & 1) == 0) + InterlockedIncrement(reinterpret_cast(&state_->sequence)); + MemoryBarrier(); + } + + std::uint32_t End() + { + MemoryBarrier(); + LONG s = InterlockedIncrement(reinterpret_cast(&state_->sequence)); + if (s & 1) + s = InterlockedIncrement(reinterpret_cast(&state_->sequence)); + return static_cast(s); + } + + HANDLE mapping_ = nullptr; + OutRunVR::SharedPoseState* state_ = nullptr; + bool owns_ = false; + std::uint32_t referenceGeneration_ = 1; + LUID adapterLuid_{}; + std::uint32_t interopVerifiedToken_ = 0; + bool interopVerifiedLogged_ = false; + }; + + class RenderFrameReader + { + public: + RenderFrameReader() + { + mapping_ = CreateFileMappingW(INVALID_HANDLE_VALUE, nullptr, + PAGE_READWRITE, 0, + static_cast( + sizeof(OutRunVR::SharedRenderFrameRing)), + OutRunVR::RenderFrameMemoryName); + if (!mapping_) + throw std::runtime_error( + "CreateFileMappingW Frame.v2 failed"); + const bool existed = + GetLastError() == ERROR_ALREADY_EXISTS; + state_ = + static_cast( + MapViewOfFile(mapping_, FILE_MAP_ALL_ACCESS, + 0, 0, + sizeof(OutRunVR::SharedRenderFrameRing))); + if (!state_) + throw std::runtime_error( + "MapViewOfFile Frame.v2 failed"); + if (!existed) + { + std::memset(state_, 0, sizeof(*state_)); + state_->protocolVersion = + OutRunVR::RenderFrameProtocolVersion; + state_->structSize = sizeof(*state_); + state_->slotCount = OutRunVR::RenderFrameRingSize; + for (auto& slot : state_->slots) + { + slot.protocolVersion = + OutRunVR::RenderFrameProtocolVersion; + slot.structSize = sizeof(slot); + slot.magic = OutRunVR::RenderFrameMagic; + } + MemoryBarrier(); + state_->magic = OutRunVR::RenderFrameMagic; + } + } + + ~RenderFrameReader() + { + if (state_) UnmapViewOfFile(state_); + if (mapping_) CloseHandle(mapping_); + } + + bool Read(OutRunVR::SharedRenderFrameState& out) const + { + if (!ValidateRing()) + return false; + for (int attempt = 0; attempt < 6; ++attempt) + { + const std::uint32_t ringBefore = + state_->publishSequence; + if (ringBefore & 1u) + continue; + const std::uint32_t index = state_->latestSlot; + if (index >= OutRunVR::RenderFrameRingSize) + continue; + if (!ReadSlot(index, out)) + continue; + MemoryBarrier(); + const std::uint32_t ringAfter = + state_->publishSequence; + if (ringBefore == ringAfter && + !(ringAfter & 1u)) + return true; + } + return false; + } + + bool ReadFrame(std::uint32_t frameId, + OutRunVR::SharedRenderFrameState& out) const + { + if (!frameId || !ValidateRing()) + return false; + for (int attempt = 0; attempt < 6; ++attempt) + { + const std::uint32_t ringBefore = state_->publishSequence; + if (ringBefore & 1u) + continue; + + bool found = false; + OutRunVR::SharedRenderFrameState selected{}; + for (std::uint32_t i = 0; + i < OutRunVR::RenderFrameRingSize; ++i) + { + OutRunVR::SharedRenderFrameState candidate{}; + if (ReadSlot(i, candidate) && + candidate.frameId == frameId) + { + selected = candidate; + found = true; + break; + } + } + + MemoryBarrier(); + const std::uint32_t ringAfter = state_->publishSequence; + if (found && ringBefore == ringAfter && + !(ringAfter & 1u)) + { + out = selected; + return true; + } + } + return false; + } + + bool ReadHistory( + std::array& out, + std::size_t& count) const + { + count = 0; + if (!ValidateRing()) + return false; + + for (int attempt = 0; attempt < 6; ++attempt) + { + const std::uint32_t ringBefore = + state_->publishSequence; + if (ringBefore & 1u) + continue; + + std::array snapshot{}; + std::size_t snapshotCount = 0; + bool stable = true; + for (std::uint32_t i = 0; + i < OutRunVR::RenderFrameRingSize; ++i) + { + OutRunVR::SharedRenderFrameState frame{}; + if (!ReadSlot(i, frame)) + { + stable = false; + break; + } + snapshot[snapshotCount++] = frame; + } + + MemoryBarrier(); + const std::uint32_t ringAfter = + state_->publishSequence; + if (stable && ringBefore == ringAfter && + !(ringAfter & 1u)) + { + out = snapshot; + count = snapshotCount; + return true; + } + } + return false; + } + + private: + bool ValidateRing() const + { + return state_ && + state_->magic == OutRunVR::RenderFrameMagic && + state_->protocolVersion == + OutRunVR::RenderFrameProtocolVersion && + state_->structSize == sizeof(*state_) && + state_->slotCount == OutRunVR::RenderFrameRingSize; + } + + bool ReadSlot(std::uint32_t index, + OutRunVR::SharedRenderFrameState& out) const + { + if (!ValidateRing() || + index >= OutRunVR::RenderFrameRingSize) + return false; + + const auto& slot = state_->slots[index]; + for (int attempt = 0; attempt < 4; ++attempt) + { + const std::uint32_t before = slot.sequence; + if (before & 1u) + continue; + MemoryBarrier(); + std::memcpy(&out, &slot, sizeof(out)); + MemoryBarrier(); + const std::uint32_t after = slot.sequence; + if (before == after && !(after & 1u) && + out.magic == OutRunVR::RenderFrameMagic && + out.protocolVersion == + OutRunVR::RenderFrameProtocolVersion && + out.structSize == sizeof(out) && + OutRunVR::RenderFrameRunIdentityMatches(*state_, out)) + return true; + } + return false; + } + + HANDLE mapping_ = nullptr; + OutRunVR::SharedRenderFrameRing* state_ = nullptr; + }; + + struct ViewHistoryEntry + { + bool valid = false; + std::uint32_t sequence = 0; + std::array views{}; + }; + + class ViewHistory + { + public: + void Clear() + { + for (auto& e : entries_) e = {}; + cursor_ = 0; + } + + void Store(std::uint32_t sequence, const std::array& views) + { + if (!sequence) return; + auto& e = entries_[cursor_++ % entries_.size()]; + e.valid = true; + e.sequence = sequence; + e.views = views; + } + + bool Find(std::uint32_t sequence, std::array& views) const + { + if (!sequence) return false; + for (const auto& e : entries_) + { + if (e.valid && e.sequence == sequence) + { + views = e.views; + return true; + } + } + return false; + } + + private: + std::array entries_{}; + std::size_t cursor_ = 0; + }; + + float QuerySdrWhiteScale(HMONITOR monitor) + { + char value[64]{}; + const DWORD n = GetEnvironmentVariableA("OUTRUN_VR_SDR_WHITE_SCALE", value, sizeof(value)); + if (n > 0 && n < sizeof(value)) + { + char* end = nullptr; + const float v = std::strtof(value, &end); + if (end != value && std::isfinite(v) && v >= 0.25f && v <= 8.f) return v; + } + + MONITORINFOEXW mi{}; + mi.cbSize = sizeof(mi); + if (!monitor || !GetMonitorInfoW(monitor, &mi)) return 1.f; + UINT32 pc = 0, mc = 0; + if (GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &pc, &mc) != ERROR_SUCCESS) return 1.f; + std::vector paths(pc); + std::vector modes(mc); + if (QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS, &pc, paths.data(), &mc, modes.data(), nullptr) != ERROR_SUCCESS) + return 1.f; + for (UINT32 i = 0; i < pc; ++i) + { + DISPLAYCONFIG_SOURCE_DEVICE_NAME src{}; + src.header.type = DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME; + src.header.size = sizeof(src); + src.header.adapterId = paths[i].sourceInfo.adapterId; + src.header.id = paths[i].sourceInfo.id; + if (DisplayConfigGetDeviceInfo(&src.header) != ERROR_SUCCESS || + _wcsicmp(src.viewGdiDeviceName, mi.szDevice) != 0) continue; + + DISPLAYCONFIG_SDR_WHITE_LEVEL white{}; + white.header.type = DISPLAYCONFIG_DEVICE_INFO_GET_SDR_WHITE_LEVEL; + white.header.size = sizeof(white); + white.header.adapterId = paths[i].targetInfo.adapterId; + white.header.id = paths[i].targetInfo.id; + if (DisplayConfigGetDeviceInfo(&white.header) == ERROR_SUCCESS && white.SDRWhiteLevel > 0) + return std::clamp(static_cast(white.SDRWhiteLevel) / 1000.f, 0.25f, 8.f); + } + return 1.f; + } + + ID3DBlob* CompileShader(const char* entry, const char* target) + { + ID3DBlob* code = nullptr; + ID3DBlob* errors = nullptr; + const HRESULT hr = D3DCompile(OutRunStereoBlitShader, std::strlen(OutRunStereoBlitShader), + "OutRunStereoBlit", nullptr, nullptr, entry, target, + D3DCOMPILE_ENABLE_STRICTNESS | D3DCOMPILE_OPTIMIZATION_LEVEL3, + 0, &code, &errors); + if (FAILED(hr)) + { + std::string m = "D3DCompile failed"; + if (errors && errors->GetBufferPointer()) + m += std::string(": ") + static_cast(errors->GetBufferPointer()); + ReleaseCom(errors); + ReleaseCom(code); + throw std::runtime_error(m); + } + ReleaseCom(errors); + return code; + } + + struct UvRect { float x = 0, y = 0, w = 1, h = 1; }; + struct BlitParams + { + float uvScale[2]; + float uvOffset[2]; + float sdrWhiteScale; + float sourceIsScRgb; + float padding[2]; + }; + + struct MenuPlaneParams + { + float clip[4][4]{}; + }; + + struct SwapchainSet + { + XrSwapchain handle = XR_NULL_HANDLE; + std::uint32_t width = 0, height = 0, arraySize = 1; + DXGI_FORMAT format = DXGI_FORMAT_UNKNOWN; + std::vector images; + std::vector> rtvs; + + void Destroy() + { + for (auto& pair : rtvs) + { + ReleaseCom(pair[0]); + ReleaseCom(pair[1]); + } + rtvs.clear(); + images.clear(); + if (handle != XR_NULL_HANDLE) + { + xrDestroySwapchain(handle); + handle = XR_NULL_HANDLE; + } + } + + ~SwapchainSet() { Destroy(); } + }; + + struct CaptureStatus + { + bool available = false; + bool fresh = false; + std::int64_t lastPresentQpc = 0; + std::uint32_t lastPresentQpcLow = 0; + bool fullGameClientVisible = false; + bool gameRegionChanged = false; + std::uint32_t accumulatedFrames = 0; + }; + + class StereoCompositor + { + public: + StereoCompositor(XrSession session, ID3D11Device* device, ID3D11DeviceContext* context, + HWND hwnd, const std::array& configs, + bool directTransportEnabled, bool directTransportOnly, + bool disableDesktopDuplication, float renderScale) + : session_(session), device_(device), context_(context), hwnd_(hwnd), configs_(configs), + directTransportEnabled_(directTransportEnabled), + directTransportOnly_(directTransportOnly), + disableDesktopDuplication_(disableDesktopDuplication), renderScale_(renderScale) + { + device_->AddRef(); + context_->AddRef(); + GetWindowThreadProcessId(hwnd_, &gamePid_); + gameProcess_ = OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, FALSE, gamePid_); + } + + ~StereoCompositor() + { + Reset(); + ReleaseCom(context_); + ReleaseCom(device_); + if (gameProcess_) CloseHandle(gameProcess_); + } + + bool GameAlive() const + { + return gameProcess_ ? WaitForSingleObject(gameProcess_, 0) == WAIT_TIMEOUT : IsWindow(hwnd_) != FALSE; + } + + void ReferenceSpaceChanged() + { + theaterAnchorValid_ = false; + stereoSourceValid_ = false; + directFrameValid_ = false; + } + + void Shutdown() + { + Reset(); + session_ = XR_NULL_HANDLE; + } + + bool Initialize() + { + // R37: direct-only is a real transport-isolation mode. Do not + // create IDXGIOutputDuplication while measuring DirectGPU cadence. + if (!disableDesktopDuplication_ && !BindCaptureOutput(true)) + throw std::runtime_error("failed to bind game capture output"); + CreateShaders(); + ChooseSwapchainFormat(); + CreateProjectionSwapchain(); + CreateTheaterSwapchain(); + std::cout << "OpenXR true stereo ready: runtime recommended " + << std::max(configs_[0].recommendedImageRectWidth, configs_[1].recommendedImageRectWidth) << "x" + << std::max(configs_[0].recommendedImageRectHeight, configs_[1].recommendedImageRectHeight) + << " per eye; projection " << projection_.width << "x" << projection_.height << "x2 (scale=" << renderScale_ + << "); theater " << theater_.width << "x" << theater_.height + << "; directGPU=" << (directTransportEnabled_ ? "enabled" : "disabled") + << "; desktopDuplication=" << (directTransportOnly_ ? "disabled-direct-only" : "enabled") + << ".\n"; + return true; + } + + CaptureStatus Capture(DWORD timeoutMs=0) + { + if (disableDesktopDuplication_) + return {}; + if (!IsWindow(hwnd_)) + { + if (HWND replacement = FindGameWindow(gamePid_)) + hwnd_ = replacement; + } + const HMONITOR monitorNow = IsWindow(hwnd_) + ? MonitorFromWindow(hwnd_, MONITOR_DEFAULTTONEAREST) + : nullptr; + if (!duplication_ || + (monitorNow && monitorNow != targetMonitor_)) + BindCaptureOutput(false); + + CaptureStatus status{}; + status.available = haveFrame_; + status.lastPresentQpc = lastCapturePresentQpc_; + status.lastPresentQpcLow = lastCapturePresentQpcLow_; + RECT gameRect{}; + status.fullGameClientVisible = + GetGameClientOutputRect(gameRect); + + if (!duplication_ && !RecreateDuplication(false)) + return status; + + DXGI_OUTDUPL_FRAME_INFO fi{}; + IDXGIResource* res = nullptr; + if (!duplication_) + return status; + + const DWORD waitMs = haveFrame_ + ? timeoutMs : std::max(timeoutMs, 1000); + const HRESULT hr = + duplication_->AcquireNextFrame(waitMs, &fi, &res); + if (hr == DXGI_ERROR_WAIT_TIMEOUT) + return status; + if (FAILED(hr) || !res) + { + if (hr == DXGI_ERROR_ACCESS_LOST) + { + haveFrame_ = false; + stereoSourceValid_ = false; + BindCaptureOutput(false); + } + status.available = haveFrame_; + return status; + } + + // Desktop Duplication's LastPresentTime describes the output, not + // a particular application. Track dirty/move rectangles while the + // frame is acquired so a desktop update outside the game window + // cannot be mistaken for a new OutRun SBS image. + bool gameRegionChanged = false; + auto intersectsGame = [&](const RECT& r) noexcept + { + RECT intersection{}; + return status.fullGameClientVisible && + IntersectRect(&intersection, &r, &gameRect) != FALSE && + intersection.right > intersection.left && + intersection.bottom > intersection.top; + }; + + UINT dirtyBytes = 0; + HRESULT dirtyHr = + duplication_->GetFrameDirtyRects(0, nullptr, &dirtyBytes); + if ((dirtyHr == DXGI_ERROR_MORE_DATA || SUCCEEDED(dirtyHr)) && + dirtyBytes >= sizeof(RECT)) + { + try + { + std::vector dirty( + (dirtyBytes + sizeof(RECT) - 1) / sizeof(RECT)); + UINT actual = dirtyBytes; + if (SUCCEEDED(duplication_->GetFrameDirtyRects( + static_cast( + dirty.size() * sizeof(RECT)), + dirty.data(), &actual))) + { + const UINT count = + std::min( + static_cast(dirty.size()), + actual / sizeof(RECT)); + for (UINT i = 0; i < count; ++i) + { + if (intersectsGame(dirty[i])) + { + gameRegionChanged = true; + break; + } + } + } + } + catch (...) {} + } + + if (!gameRegionChanged) + { + UINT moveBytes = 0; + HRESULT moveHr = + duplication_->GetFrameMoveRects(0, nullptr, &moveBytes); + if ((moveHr == DXGI_ERROR_MORE_DATA || SUCCEEDED(moveHr)) && + moveBytes >= sizeof(DXGI_OUTDUPL_MOVE_RECT)) + { + try + { + std::vector moves( + (moveBytes + sizeof(DXGI_OUTDUPL_MOVE_RECT) - 1) / + sizeof(DXGI_OUTDUPL_MOVE_RECT)); + UINT actual = moveBytes; + if (SUCCEEDED(duplication_->GetFrameMoveRects( + static_cast( + moves.size() * + sizeof(DXGI_OUTDUPL_MOVE_RECT)), + moves.data(), &actual))) + { + const UINT count = + std::min( + static_cast(moves.size()), + actual / + sizeof(DXGI_OUTDUPL_MOVE_RECT)); + for (UINT i = 0; i < count; ++i) + { + if (intersectsGame( + moves[i].DestinationRect)) + { + gameRegionChanged = true; + break; + } + } + } + } + catch (...) {} + } + } + + // If multiple output frames accumulated, metadata can be coalesced + // and an individual dirty rect may no longer describe every update. + // It is still safe to regard the game region as changed only when + // the whole game client is visible. + if (!gameRegionChanged && status.fullGameClientVisible && + fi.AccumulatedFrames > 1) + gameRegionChanged = true; + + ID3D11Texture2D* tex = nullptr; + const HRESULT q = res->QueryInterface( + __uuidof(ID3D11Texture2D), + reinterpret_cast(&tex)); + res->Release(); + + bool copied = false; + if (SUCCEEDED(q) && tex) + { + D3D11_TEXTURE2D_DESC d{}; + tex->GetDesc(&d); + if ((d.Format == DXGI_FORMAT_B8G8R8A8_UNORM || + d.Format == DXGI_FORMAT_R16G16B16A16_FLOAT) && + EnsureSource(d)) + { + context_->CopyResource(source_, tex); + copied = true; + } + tex->Release(); + } + duplication_->ReleaseFrame(); + + if (copied) + { + haveFrame_ = true; + if (fi.LastPresentTime.QuadPart != 0) + { + lastCapturePresentQpc_ = + fi.LastPresentTime.QuadPart; + lastCapturePresentQpcLow_ = + static_cast( + fi.LastPresentTime.QuadPart); + } + status.available = true; + status.fresh = fi.AccumulatedFrames > 0; + status.lastPresentQpc = lastCapturePresentQpc_; + status.lastPresentQpcLow = + lastCapturePresentQpcLow_; + status.gameRegionChanged = gameRegionChanged; + status.accumulatedFrames = fi.AccumulatedFrames; + MaybeLogCapturePixels(); + } + return status; + } + + bool PrepareDirectStereoSource(const OutRunVR::SharedRenderFrameState& frame) + { + if (!directTransportEnabled_ || (frame.flags & OutRunVR::RenderFrameDirectGpuTransport) == 0) return false; + auto invalidateDirect = [&]() { directFrameValid_ = false; directTransportReady_ = false; }; + const std::uint32_t slot = frame.reserved[OutRunVR::RenderFrameDirectSlotIndex]; + const std::uint32_t leftHandleValue = frame.reserved[OutRunVR::RenderFrameDirectLeftHandleIndex]; + const std::uint32_t rightHandleValue = frame.reserved[OutRunVR::RenderFrameDirectRightHandleIndex]; + const std::uint32_t width = frame.reserved[OutRunVR::RenderFrameDirectWidthIndex]; + const std::uint32_t height = frame.reserved[OutRunVR::RenderFrameDirectHeightIndex]; + const std::uint32_t generation = frame.reserved[OutRunVR::RenderFrameDirectGenerationIndex]; + if (slot >= OutRunVR::RenderFrameRingSize || !leftHandleValue || !rightHandleValue || !width || !height || !generation){invalidateDirect();return false;} + const bool same = directLeft_[slot] && directRight_[slot] && directLeftSrv_[slot] && directRightSrv_[slot] && directLeftHandle_[slot] == leftHandleValue && directRightHandle_[slot] == rightHandleValue && directGeneration_[slot] == generation; + if (!same) + { + ReleaseCom(directLeftSrv_[slot]); ReleaseCom(directLeft_[slot]); ReleaseCom(directRightSrv_[slot]); ReleaseCom(directRight_[slot]); invalidateDirect(); + auto openOne = [&](std::uint32_t raw, ID3D11Texture2D** texture, ID3D11ShaderResourceView** srv) -> bool + { + ID3D11Resource* resource = nullptr; const HANDLE handle = reinterpret_cast(static_cast(raw)); + if (FAILED(device_->OpenSharedResource(handle, __uuidof(ID3D11Resource), reinterpret_cast(&resource))) || !resource) return false; + const HRESULT q = resource->QueryInterface(__uuidof(ID3D11Texture2D), reinterpret_cast(texture)); resource->Release(); if (FAILED(q) || !*texture) return false; + D3D11_TEXTURE2D_DESC d{}; (*texture)->GetDesc(&d); + if (d.Width != width || d.Height != height || d.SampleDesc.Count != 1 || (d.Format != DXGI_FORMAT_B8G8R8A8_UNORM && d.Format != DXGI_FORMAT_R8G8B8A8_UNORM && d.Format != DXGI_FORMAT_R10G10B10A2_UNORM && d.Format != DXGI_FORMAT_R16G16B16A16_FLOAT) || FAILED(device_->CreateShaderResourceView(*texture, nullptr, srv)) || !*srv){ReleaseCom(*texture);ReleaseCom(*srv);return false;} + return true; + }; + if (!openOne(leftHandleValue,&directLeft_[slot],&directLeftSrv_[slot]) || !openOne(rightHandleValue,&directRight_[slot],&directRightSrv_[slot])){ReleaseCom(directLeftSrv_[slot]);ReleaseCom(directLeft_[slot]);ReleaseCom(directRightSrv_[slot]);ReleaseCom(directRight_[slot]);directLeftHandle_[slot]=directRightHandle_[slot]=directGeneration_[slot]=0;directFormat_[slot]=DXGI_FORMAT_UNKNOWN;invalidateDirect();if(!directOpenFailureLogged_){directOpenFailureLogged_=true;std::cout<<"Direct GPU eye ring open failed; host will request SBS fallback.\n";}return false;} + D3D11_TEXTURE2D_DESC ld{}; directLeft_[slot]->GetDesc(&ld); D3D11_TEXTURE2D_DESC rd{}; directRight_[slot]->GetDesc(&rd); + if(ld.Format!=rd.Format){ReleaseCom(directLeftSrv_[slot]);ReleaseCom(directLeft_[slot]);ReleaseCom(directRightSrv_[slot]);ReleaseCom(directRight_[slot]);directLeftHandle_[slot]=directRightHandle_[slot]=directGeneration_[slot]=0;directFormat_[slot]=DXGI_FORMAT_UNKNOWN;invalidateDirect();std::cout<<"Direct GPU eye ring format mismatch; Ready cleared and SBS fallback requested.\n";return false;} + directLeftHandle_[slot]=leftHandleValue;directRightHandle_[slot]=rightHandleValue;directGeneration_[slot]=generation;directFormat_[slot]=ld.Format;std::cout<<"Direct GPU eye ring slot "< OpenXR "<GetDesc(&sourceDesc); + if(!directSnapshotLeft_||!directSnapshotRight_||directSnapshotWidth_!=sourceDesc.Width||directSnapshotHeight_!=sourceDesc.Height||directSnapshotFormat_!=sourceDesc.Format){ReleaseCom(directSnapshotLeftSrv_);ReleaseCom(directSnapshotLeft_);ReleaseCom(directSnapshotRightSrv_);ReleaseCom(directSnapshotRight_);D3D11_TEXTURE2D_DESC d=sourceDesc;d.MipLevels=1;d.ArraySize=1;d.SampleDesc.Count=1;d.SampleDesc.Quality=0;d.Usage=D3D11_USAGE_DEFAULT;d.BindFlags=D3D11_BIND_SHADER_RESOURCE;d.CPUAccessFlags=0;d.MiscFlags=0;if(FAILED(device_->CreateTexture2D(&d,nullptr,&directSnapshotLeft_))||FAILED(device_->CreateTexture2D(&d,nullptr,&directSnapshotRight_))||FAILED(device_->CreateShaderResourceView(directSnapshotLeft_,nullptr,&directSnapshotLeftSrv_))||FAILED(device_->CreateShaderResourceView(directSnapshotRight_,nullptr,&directSnapshotRightSrv_))){ReleaseCom(directSnapshotLeftSrv_);ReleaseCom(directSnapshotLeft_);ReleaseCom(directSnapshotRightSrv_);ReleaseCom(directSnapshotRight_);directSnapshotWidth_=directSnapshotHeight_=0;directSnapshotFormat_=DXGI_FORMAT_UNKNOWN;invalidateDirect();return false;}directSnapshotWidth_=sourceDesc.Width;directSnapshotHeight_=sourceDesc.Height;directSnapshotFormat_=sourceDesc.Format;} + context_->CopyResource(directSnapshotLeft_,directLeft_[slot]);context_->CopyResource(directSnapshotRight_,directRight_[slot]); + ID3D11Query* copyFence=nullptr;D3D11_QUERY_DESC q{};q.Query=D3D11_QUERY_EVENT;if(FAILED(device_->CreateQuery(&q,©Fence))||!copyFence){invalidateDirect();return false;}context_->End(copyFence);context_->Flush();const ULONGLONG deadline=GetTickCount64()+DirectCopyFenceBudgetMs;HRESULT ready=S_FALSE;do{ready=context_->GetData(copyFence,nullptr,0,D3D11_ASYNC_GETDATA_DONOTFLUSH);if(ready==S_OK||FAILED(ready))break;Sleep(0);}while(GetTickCount64()Release(); + if(ready!=S_OK){invalidateDirect();if(!directCopyFenceFailureLogged_){directCopyFenceFailureLogged_=true;std::cout<<"Direct GPU private snapshot copy did not complete inside the safety budget; ACK withheld and SBS fallback requested.\n";}return false;} + directActiveSlot_=slot;directFrameValid_=true;directTransportReady_=true;return true; + } + + bool DirectTransportReady() const { return directTransportEnabled_ && directTransportReady_; } + + bool CommitStereoSource() + { + if (!source_ || !sourceSrv_ || !sourceWidth_ || !sourceHeight_) return false; + if (!stereoSource_ || stereoSourceWidth_ != sourceWidth_ || + stereoSourceHeight_ != sourceHeight_ || stereoSourceFormat_ != sourceFormat_) + { + ReleaseCom(stereoSourceSrv_); + ReleaseCom(stereoSource_); + D3D11_TEXTURE2D_DESC desc{}; + desc.Width = sourceWidth_; + desc.Height = sourceHeight_; + desc.MipLevels = 1; + desc.ArraySize = 1; + desc.Format = sourceFormat_; + desc.SampleDesc.Count = 1; + desc.Usage = D3D11_USAGE_DEFAULT; + desc.BindFlags = D3D11_BIND_SHADER_RESOURCE; + if (FAILED(device_->CreateTexture2D(&desc, nullptr, &stereoSource_))) return false; + D3D11_SHADER_RESOURCE_VIEW_DESC view{}; + view.Format = desc.Format; + view.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D; + view.Texture2D.MipLevels = 1; + if (FAILED(device_->CreateShaderResourceView(stereoSource_, &view, &stereoSourceSrv_))) + { + ReleaseCom(stereoSource_); + return false; + } + stereoSourceWidth_ = sourceWidth_; + stereoSourceHeight_ = sourceHeight_; + stereoSourceFormat_ = sourceFormat_; + } + context_->CopyResource(stereoSource_, source_); + stereoSourceValid_ = true; + return true; + } + + bool HasStereoSource() const { return directFrameValid_ || (stereoSourceValid_ && stereoSourceSrv_); } + + bool RenderProjection(const std::array& views, + std::array& pv) + { + if (!HasStereoSource()) return false; + UvRect eyes[2]{}; + ID3D11ShaderResourceView* eyeSrv[2]{}; + DXGI_FORMAT eyeFormat[2]{ DXGI_FORMAT_UNKNOWN, DXGI_FORMAT_UNKNOWN }; + if (directFrameValid_ && directSnapshotLeftSrv_ && directSnapshotRightSrv_) + { + eyes[0] = eyes[1] = { 0.f, 0.f, 1.f, 1.f }; + eyeSrv[0] = directSnapshotLeftSrv_; eyeSrv[1] = directSnapshotRightSrv_; + eyeFormat[0] = eyeFormat[1] = directSnapshotFormat_; + } + else + { + UvRect whole{}; + if (!GetGameUv(whole, true)) return false; + eyes[0] = { whole.x, whole.y, whole.w * 0.5f, whole.h }; + eyes[1] = { whole.x + whole.w * 0.5f, whole.y, whole.w * 0.5f, whole.h }; + eyeSrv[0] = eyeSrv[1] = stereoSourceSrv_; + eyeFormat[0] = eyeFormat[1] = stereoSourceFormat_; + } + + std::uint32_t image = 0; + Acquire(projection_, image); + bool ok = RenderTo(projection_.rtvs[image][0], projection_.width, projection_.height, + eyes[0], eyeSrv[0], eyeFormat[0]); + ok = RenderTo(projection_.rtvs[image][1], projection_.width, projection_.height, + eyes[1], eyeSrv[1], eyeFormat[1]) && ok; + Release(projection_); + if (!ok) return false; + + for (int eye = 0; eye < 2; ++eye) + { + pv[eye] = { XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW }; + pv[eye].pose = views[eye].pose; + pv[eye].fov = views[eye].fov; + pv[eye].subImage.swapchain = projection_.handle; + pv[eye].subImage.imageRect.offset = { 0, 0 }; + pv[eye].subImage.imageRect.extent = { + static_cast(projection_.width), + static_cast(projection_.height) + }; + pv[eye].subImage.imageArrayIndex=eye; + } + return true; + } + + bool RenderMenuPlaneTo( + ID3D11RenderTargetView* rtv, std::uint32_t w, std::uint32_t h, + const UvRect& uv, ID3D11ShaderResourceView* sourceSrv, + DXGI_FORMAT sourceFormat, const float clip[4][4]) + { + if (!rtv || !sourceSrv || !constantBuffer_ || + !menuConstantBuffer_ || !menuVs_) + return false; + + D3D11_MAPPED_SUBRESOURCE map{}; + if (FAILED(context_->Map( + constantBuffer_, 0, D3D11_MAP_WRITE_DISCARD, 0, &map))) + return false; + auto* p = static_cast(map.pData); + p->uvScale[0] = uv.w; + p->uvScale[1] = uv.h; + p->uvOffset[0] = uv.x; + p->uvOffset[1] = uv.y; + p->sdrWhiteScale = sdrWhiteScale_; + p->sourceIsScRgb = + sourceFormat == DXGI_FORMAT_R16G16B16A16_FLOAT ? 1.f : 0.f; + p->padding[0] = p->padding[1] = 0; + context_->Unmap(constantBuffer_, 0); + + if (FAILED(context_->Map( + menuConstantBuffer_, 0, + D3D11_MAP_WRITE_DISCARD, 0, &map))) + return false; + auto* menu = static_cast(map.pData); + std::memcpy(menu->clip, clip, sizeof(menu->clip)); + context_->Unmap(menuConstantBuffer_, 0); + + D3D11_VIEWPORT vp{}; + vp.Width = static_cast(w); + vp.Height = static_cast(h); + vp.MaxDepth = 1.f; + context_->RSSetViewports(1, &vp); + + const float black[4]{ 0.f, 0.f, 0.f, 1.f }; + context_->ClearRenderTargetView(rtv, black); + context_->OMSetRenderTargets(1, &rtv, nullptr); + context_->IASetPrimitiveTopology( + D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP); + context_->VSSetShader(menuVs_, nullptr, 0); + context_->VSSetConstantBuffers(1, 1, &menuConstantBuffer_); + context_->PSSetShader(ps_, nullptr, 0); + context_->PSSetShaderResources(0, 1, &sourceSrv); + context_->PSSetSamplers(0, 1, &sampler_); + context_->PSSetConstantBuffers(0, 1, &constantBuffer_); + context_->Draw(4, 0); + + ID3D11ShaderResourceView* nullSrv = nullptr; + context_->PSSetShaderResources(0, 1, &nullSrv); + ID3D11RenderTargetView* nullRtv = nullptr; + context_->OMSetRenderTargets(1, &nullRtv, nullptr); + return true; + } + + bool RenderMenuProjection( + XrSpace viewSpace, XrSpace localSpace, XrTime displayTime, + const std::array& views, + std::array& pv) + { + if (!haveFrame_ || !sourceSrv_ || + !EnsureTheaterAnchor(viewSpace, localSpace, displayTime)) + return false; + + UvRect whole{}; + if (!GetGameUv(whole)) + return false; + + RECT cr{}; + GetClientRect(hwnd_, &cr); + const float aspect = (cr.bottom > cr.top) + ? static_cast(cr.right - cr.left) / + static_cast(cr.bottom - cr.top) + : 16.f / 9.f; + const float width = 2.0f; + const float height = width / std::max(0.5f, aspect); + const XrVector3f right = + RotateVector(theaterAnchor_.orientation, { 1.f, 0.f, 0.f }); + const XrVector3f up = + RotateVector(theaterAnchor_.orientation, { 0.f, 1.f, 0.f }); + const float sx[4]{ -1.f, 1.f, -1.f, 1.f }; + const float sy[4]{ 1.f, 1.f, -1.f, -1.f }; + XrVector3f corners[4]{}; + for (int i = 0; i < 4; ++i) + { + corners[i] = { + theaterAnchor_.position.x + + right.x * sx[i] * width * 0.5f + + up.x * sy[i] * height * 0.5f, + theaterAnchor_.position.y + + right.y * sx[i] * width * 0.5f + + up.y * sy[i] * height * 0.5f, + theaterAnchor_.position.z + + right.z * sx[i] * width * 0.5f + + up.z * sy[i] * height * 0.5f + }; + } + + std::uint32_t image = 0; + Acquire(projection_, image); + bool ok = image < projection_.rtvs.size(); + for (int eye = 0; eye < 2 && ok; ++eye) + { + float clip[4][4]{}; + const float tanLeft = std::tan(views[eye].fov.angleLeft); + const float tanRight = std::tan(views[eye].fov.angleRight); + const float tanDown = std::tan(views[eye].fov.angleDown); + const float tanUp = std::tan(views[eye].fov.angleUp); + const float spanX = tanRight - tanLeft; + const float spanY = tanUp - tanDown; + if (!std::isfinite(spanX) || !std::isfinite(spanY) || + spanX <= 0.05f || spanY <= 0.05f) + { + ok = false; + break; + } + + for (int corner = 0; corner < 4; ++corner) + { + const XrVector3f local = + ToHeadLocal(views[eye].pose, corners[corner]); + const float depth = -local.z; + if (!std::isfinite(depth) || depth <= 0.05f) + { + ok = false; + break; + } + const float slopeX = local.x / depth; + const float slopeY = local.y / depth; + const float ndcX = + 2.0f * (slopeX - tanLeft) / spanX - 1.0f; + const float ndcY = + 2.0f * (slopeY - tanDown) / spanY - 1.0f; + if (!std::isfinite(ndcX) || !std::isfinite(ndcY)) + { + ok = false; + break; + } + clip[corner][0] = ndcX; + clip[corner][1] = ndcY; + clip[corner][2] = 0.5f; + clip[corner][3] = 1.0f; + } + + if (ok) + ok = RenderMenuPlaneTo( + projection_.rtvs[image][eye], + projection_.width, projection_.height, + whole, sourceSrv_, sourceFormat_, clip); + } + Release(projection_); + if (!ok) + return false; + + for (int eye = 0; eye < 2; ++eye) + { + pv[eye] = { XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW }; + pv[eye].pose = views[eye].pose; + pv[eye].fov = views[eye].fov; + pv[eye].subImage.swapchain = projection_.handle; + pv[eye].subImage.imageRect.offset = { 0, 0 }; + pv[eye].subImage.imageRect.extent = { + static_cast(projection_.width), + static_cast(projection_.height) + }; + pv[eye].subImage.imageArrayIndex = eye; + } + return true; + } + + bool RenderTheater(XrSpace viewSpace, XrSpace localSpace, XrTime displayTime, + XrCompositionLayerQuad& quad) + { + if (!haveFrame_ || !sourceSrv_) return false; + UvRect whole{}; + if (!GetGameUv(whole)) return false; + std::uint32_t image = 0; + Acquire(theater_, image); + const bool ok = RenderTo(theater_.rtvs[image][0], theater_.width, theater_.height, + whole, sourceSrv_, sourceFormat_); + Release(theater_); + if (!ok || !EnsureTheaterAnchor(viewSpace, localSpace, displayTime)) return false; + + quad = { XR_TYPE_COMPOSITION_LAYER_QUAD }; + quad.space = localSpace; + quad.eyeVisibility = XR_EYE_VISIBILITY_BOTH; + quad.pose = theaterAnchor_; + quad.subImage.swapchain = theater_.handle; + quad.subImage.imageRect.offset = { 0, 0 }; + quad.subImage.imageRect.extent = { + static_cast(theater_.width), + static_cast(theater_.height) + }; + RECT cr{}; + GetClientRect(hwnd_, &cr); + const float aspect = (cr.bottom > cr.top) + ? static_cast(cr.right - cr.left) / static_cast(cr.bottom - cr.top) + : 16.f / 9.f; + quad.size.width = 2.f; + quad.size.height = 2.f / aspect; + return true; + } + + private: + static float HalfToFloat(std::uint16_t value) + { + const std::uint32_t sign = static_cast(value & 0x8000u) << 16; + std::uint32_t exponent = (value >> 10) & 0x1Fu; + std::uint32_t mantissa = value & 0x03FFu; + std::uint32_t bits = 0; + if (exponent == 0) + { + if (mantissa == 0) + bits = sign; + else + { + int unbiased = -14; + while ((mantissa & 0x0400u) == 0) + { + mantissa <<= 1; + --unbiased; + } + mantissa &= 0x03FFu; + bits = sign | + (static_cast(unbiased + 127) << 23) | + (mantissa << 13); + } + } + else if (exponent == 0x1Fu) + { + bits = sign | 0x7F800000u | (mantissa << 13); + } + else + { + bits = sign | ((exponent + (127u - 15u)) << 23) | (mantissa << 13); + } + float out = 0.0f; + std::memcpy(&out, &bits, sizeof(out)); + return out; + } + + void MaybeLogCapturePixels() + { + const ULONGLONG now = GetTickCount64(); + if (lastPixelDiagnosticMs_ != 0 && now - lastPixelDiagnosticMs_ < 5000) + return; + lastPixelDiagnosticMs_ = now; + if (!source_ || !context_ || !device_ || !sourceWidth_ || !sourceHeight_) + return; + if (sourceFormat_ != DXGI_FORMAT_B8G8R8A8_UNORM && + sourceFormat_ != DXGI_FORMAT_R16G16B16A16_FLOAT) + return; + + D3D11_TEXTURE2D_DESC sourceDesc{}; + source_->GetDesc(&sourceDesc); + const UINT sampleW = std::min(16, sourceDesc.Width); + const UINT sampleH = std::min(16, sourceDesc.Height); + if (!sampleW || !sampleH) + return; + + D3D11_TEXTURE2D_DESC stagingDesc{}; + stagingDesc.Width = sampleW; + stagingDesc.Height = sampleH; + stagingDesc.MipLevels = 1; + stagingDesc.ArraySize = 1; + stagingDesc.Format = sourceDesc.Format; + stagingDesc.SampleDesc.Count = 1; + stagingDesc.Usage = D3D11_USAGE_STAGING; + stagingDesc.CPUAccessFlags = D3D11_CPU_ACCESS_READ; + + ID3D11Texture2D* staging = nullptr; + if (FAILED(device_->CreateTexture2D(&stagingDesc, nullptr, &staging)) || !staging) + return; + + const UINT left = (sourceDesc.Width - sampleW) / 2; + const UINT top = (sourceDesc.Height - sampleH) / 2; + D3D11_BOX box{ left, top, 0, left + sampleW, top + sampleH, 1 }; + context_->CopySubresourceRegion(staging, 0, 0, 0, 0, source_, 0, &box); + + D3D11_MAPPED_SUBRESOURCE mapped{}; + if (FAILED(context_->Map(staging, 0, D3D11_MAP_READ, 0, &mapped)) || !mapped.pData) + { + ReleaseCom(staging); + return; + } + + float minRgb[3]{ 1.0e30f, 1.0e30f, 1.0e30f }; + float maxRgb[3]{ -1.0e30f, -1.0e30f, -1.0e30f }; + double sumRgb[3]{}; + std::uint32_t finitePixels = 0; + std::uint32_t nonFiniteValues = 0; + + for (UINT y = 0; y < sampleH; ++y) + { + const auto* row = static_cast(mapped.pData) + + static_cast(y) * mapped.RowPitch; + for (UINT x = 0; x < sampleW; ++x) + { + float rgb[3]{}; + if (sourceFormat_ == DXGI_FORMAT_B8G8R8A8_UNORM) + { + const auto* px = row + static_cast(x) * 4; + rgb[0] = static_cast(px[2]) / 255.0f; + rgb[1] = static_cast(px[1]) / 255.0f; + rgb[2] = static_cast(px[0]) / 255.0f; + } + else + { + const auto* px = reinterpret_cast( + row + static_cast(x) * 8); + rgb[0] = HalfToFloat(px[0]); + rgb[1] = HalfToFloat(px[1]); + rgb[2] = HalfToFloat(px[2]); + } + + bool pixelFinite = true; + for (int c = 0; c < 3; ++c) + { + if (!std::isfinite(rgb[c])) + { + ++nonFiniteValues; + pixelFinite = false; + continue; + } + minRgb[c] = std::min(minRgb[c], rgb[c]); + maxRgb[c] = std::max(maxRgb[c], rgb[c]); + sumRgb[c] += rgb[c]; + } + if (pixelFinite) + ++finitePixels; + } + } + context_->Unmap(staging, 0); + ReleaseCom(staging); + + UvRect uv{}; + const bool uvOk = GetGameUv(uv); + const double denom = finitePixels ? static_cast(finitePixels) : 1.0; + std::cout << "[R22 capture-pixel] source=" << sourceWidth_ << "x" << sourceHeight_ + << " fmt=" << static_cast(sourceFormat_) + << " sample=" << sampleW << "x" << sampleH + << " rgbMin=" << minRgb[0] << "," << minRgb[1] << "," << minRgb[2] + << " rgbMax=" << maxRgb[0] << "," << maxRgb[1] << "," << maxRgb[2] + << " rgbMean=" << (sumRgb[0] / denom) << "," << (sumRgb[1] / denom) << "," << (sumRgb[2] / denom) + << " nonFinite=" << nonFiniteValues + << " sdrWhiteScale=" << sdrWhiteScale_ + << " uv=" << (uvOk ? "ok" : "invalid") << "[" + << uv.x << "," << uv.y << "," << uv.w << "," << uv.h << "]\n"; + } + + void Reset() + { + projection_.Destroy(); + theater_.Destroy(); + ReleaseCom(stereoSourceSrv_); + ReleaseCom(stereoSource_); + for (std::uint32_t slot = 0; slot < OutRunVR::RenderFrameRingSize; ++slot) + { + ReleaseCom(directLeftSrv_[slot]); ReleaseCom(directLeft_[slot]); + ReleaseCom(directRightSrv_[slot]); ReleaseCom(directRight_[slot]); + directLeftHandle_[slot] = directRightHandle_[slot] = directGeneration_[slot] = 0; + directFormat_[slot] = DXGI_FORMAT_UNKNOWN; + } + ReleaseCom(directSnapshotLeftSrv_); ReleaseCom(directSnapshotLeft_); + ReleaseCom(directSnapshotRightSrv_); ReleaseCom(directSnapshotRight_); + directSnapshotWidth_ = directSnapshotHeight_ = 0; + directSnapshotFormat_ = DXGI_FORMAT_UNKNOWN; + directFrameValid_ = false; directTransportReady_ = false; + ReleaseCom(sourceSrv_); + ReleaseCom(source_); + ReleaseCom(menuConstantBuffer_); + ReleaseCom(constantBuffer_); + ReleaseCom(sampler_); + ReleaseCom(ps_); + ReleaseCom(menuVs_); + ReleaseCom(vs_); + ReleaseCom(duplication_); + ReleaseCom(output5_); + ReleaseCom(output1_); + haveFrame_ = false; + stereoSourceValid_ = false; + lastPixelDiagnosticMs_ = 0; + } + + void ChooseSwapchainFormat() + { + std::uint32_t count = 0; + CheckXr(xrEnumerateSwapchainFormats(session_, 0, &count, nullptr), + "xrEnumerateSwapchainFormats(count)"); + std::vector formats(count); + CheckXr(xrEnumerateSwapchainFormats(session_, count, &count, formats.data()), + "xrEnumerateSwapchainFormats(list)"); + const DXGI_FORMAT pref[]{ + DXGI_FORMAT_R8G8B8A8_UNORM_SRGB, + DXGI_FORMAT_B8G8R8A8_UNORM_SRGB, + DXGI_FORMAT_R8G8B8A8_UNORM, + DXGI_FORMAT_B8G8R8A8_UNORM + }; + for (auto p : pref) + { + if (std::find(formats.begin(), formats.end(), static_cast(p)) != formats.end()) + { + swapFormat_ = p; + return; + } + } + throw std::runtime_error("runtime has no supported 8-bit color swapchain"); + } + + void CreateProjectionSwapchain() + { + const std::uint32_t recommendedW = std::max(configs_[0].recommendedImageRectWidth, configs_[1].recommendedImageRectWidth); + const std::uint32_t recommendedH = std::max(configs_[0].recommendedImageRectHeight, configs_[1].recommendedImageRectHeight); + const std::uint32_t maxW = std::max(configs_[0].maxImageRectWidth, configs_[1].maxImageRectWidth); + const std::uint32_t maxH = std::max(configs_[0].maxImageRectHeight, configs_[1].maxImageRectHeight); + projection_.width = std::clamp(static_cast(std::lround(recommendedW * renderScale_)), 1u, maxW); + projection_.height = std::clamp(static_cast(std::lround(recommendedH * renderScale_)), 1u, maxH); + projection_.arraySize=2; + projection_.format = swapFormat_; + CreateSwapchain(projection_); + } + + void CreateTheaterSwapchain() + { + theater_.width = std::max(configs_[0].recommendedImageRectWidth, + configs_[1].recommendedImageRectWidth); + theater_.height = std::max(configs_[0].recommendedImageRectHeight, + configs_[1].recommendedImageRectHeight); + theater_.arraySize = 1; + theater_.format = swapFormat_; + CreateSwapchain(theater_); + } + + void CreateSwapchain(SwapchainSet& s) + { + XrSwapchainCreateInfo ci{ XR_TYPE_SWAPCHAIN_CREATE_INFO }; + ci.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT | XR_SWAPCHAIN_USAGE_SAMPLED_BIT; + ci.format = static_cast(s.format); + ci.sampleCount = 1; + ci.width = s.width; + ci.height = s.height; + ci.faceCount = 1; + ci.arraySize = s.arraySize; + ci.mipCount = 1; + CheckXr(xrCreateSwapchain(session_, &ci, &s.handle), "xrCreateSwapchain"); + + std::uint32_t n = 0; + CheckXr(xrEnumerateSwapchainImages(s.handle, 0, &n, nullptr), + "xrEnumerateSwapchainImages(count)"); + s.images.resize(n); + for (auto& i : s.images) i = { XR_TYPE_SWAPCHAIN_IMAGE_D3D11_KHR }; + CheckXr(xrEnumerateSwapchainImages(s.handle, n, &n, + reinterpret_cast(s.images.data())), + "xrEnumerateSwapchainImages(list)"); + s.rtvs.resize(n); + for (std::uint32_t i = 0; i < n; ++i) + { + for (std::uint32_t slice = 0; slice < s.arraySize; ++slice) + { + D3D11_RENDER_TARGET_VIEW_DESC rd{}; + rd.Format = s.format; + if (s.arraySize > 1) + { + rd.ViewDimension = D3D11_RTV_DIMENSION_TEXTURE2DARRAY; + rd.Texture2DArray.MipSlice = 0; + rd.Texture2DArray.FirstArraySlice = slice; + rd.Texture2DArray.ArraySize = 1; + } + else + { + rd.ViewDimension = D3D11_RTV_DIMENSION_TEXTURE2D; + rd.Texture2D.MipSlice = 0; + } + CheckHr(device_->CreateRenderTargetView(s.images[i].texture, &rd, &s.rtvs[i][slice]), + "CreateRenderTargetView"); + } + } + } + + void Acquire(SwapchainSet& s, std::uint32_t& image) + { + XrSwapchainImageAcquireInfo ai{ XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO }; + CheckXr(xrAcquireSwapchainImage(s.handle, &ai, &image), "xrAcquireSwapchainImage"); + XrSwapchainImageWaitInfo wi{ XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO }; + wi.timeout = XR_INFINITE_DURATION; + CheckXr(xrWaitSwapchainImage(s.handle, &wi), "xrWaitSwapchainImage"); + } + + void Release(SwapchainSet& s) + { + XrSwapchainImageReleaseInfo ri{ XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO }; + CheckXr(xrReleaseSwapchainImage(s.handle, &ri), "xrReleaseSwapchainImage"); + } + + void CreateShaders() + { + ID3DBlob* v = CompileShader("VSMain", "vs_5_0"); + ID3DBlob* mv = CompileShader("VSMenu", "vs_5_0"); + ID3DBlob* p = CompileShader("PSMain", "ps_5_0"); + CheckHr(device_->CreateVertexShader(v->GetBufferPointer(), v->GetBufferSize(), nullptr, &vs_), + "CreateVertexShader"); + CheckHr(device_->CreateVertexShader(mv->GetBufferPointer(), mv->GetBufferSize(), nullptr, &menuVs_), + "CreateVertexShader(VSMenu)"); + CheckHr(device_->CreatePixelShader(p->GetBufferPointer(), p->GetBufferSize(), nullptr, &ps_), + "CreatePixelShader"); + v->Release(); + mv->Release(); + p->Release(); + + D3D11_SAMPLER_DESC sd{}; + sd.Filter = D3D11_FILTER_MIN_MAG_MIP_LINEAR; + sd.AddressU = sd.AddressV = sd.AddressW = D3D11_TEXTURE_ADDRESS_CLAMP; + CheckHr(device_->CreateSamplerState(&sd, &sampler_), "CreateSamplerState"); + + D3D11_BUFFER_DESC bd{}; + bd.ByteWidth = sizeof(BlitParams); + bd.Usage = D3D11_USAGE_DYNAMIC; + bd.BindFlags = D3D11_BIND_CONSTANT_BUFFER; + bd.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE; + CheckHr(device_->CreateBuffer(&bd, nullptr, &constantBuffer_), "CreateBuffer"); + + D3D11_BUFFER_DESC menuBd = bd; + menuBd.ByteWidth = sizeof(MenuPlaneParams); + CheckHr(device_->CreateBuffer(&menuBd, nullptr, &menuConstantBuffer_), + "CreateBuffer(MenuPlaneParams)"); + } + + bool BindCaptureOutput(bool initial) + { + if(HWND replacement=FindGameWindow(gamePid_))hwnd_=replacement;if(!IsWindow(hwnd_))return false;targetMonitor_=MonitorFromWindow(hwnd_,MONITOR_DEFAULTTONEAREST);if(!targetMonitor_)return false;ReleaseCom(duplication_);ReleaseCom(output5_);ReleaseCom(output1_); + IDXGIDevice*dxgi=nullptr;if(FAILED(device_->QueryInterface(__uuidof(IDXGIDevice),reinterpret_cast(&dxgi)))||!dxgi)return false;IDXGIAdapter*adapter=nullptr;const HRESULT ah=dxgi->GetAdapter(&adapter);dxgi->Release();if(FAILED(ah)||!adapter)return false;IDXGIOutput*selected=nullptr;DXGI_OUTPUT_DESC desc{};for(UINT i=0;;++i){IDXGIOutput*out=nullptr;if(adapter->EnumOutputs(i,&out)==DXGI_ERROR_NOT_FOUND)break;DXGI_OUTPUT_DESC d{};out->GetDesc(&d);if(d.Monitor==targetMonitor_){selected=out;desc=d;break;}out->Release();}adapter->Release();if(!selected)return false;selected->QueryInterface(__uuidof(IDXGIOutput1),reinterpret_cast(&output1_));selected->QueryInterface(__uuidof(IDXGIOutput5),reinterpret_cast(&output5_));selected->Release();if(!output1_)return false;outputDesktop_=desc.DesktopCoordinates;sdrWhiteScale_=QuerySdrWhiteScale(targetMonitor_);haveFrame_=false;stereoSourceValid_=false;lastCapturePresentQpc_=0;lastCapturePresentQpcLow_=0;return RecreateDuplication(initial); + } + + bool RecreateDuplication(bool initial) + { + ReleaseCom(duplication_); + HRESULT hr = E_FAIL; + if (output5_) + { + const DXGI_FORMAT supported[]{ + DXGI_FORMAT_R16G16B16A16_FLOAT, + DXGI_FORMAT_B8G8R8A8_UNORM + }; + hr = output5_->DuplicateOutput1(device_, 0, 2, supported, &duplication_); + } + if ((FAILED(hr) || !duplication_) && output1_) + { + ReleaseCom(duplication_); + hr = output1_->DuplicateOutput(device_, &duplication_); + } + if (FAILED(hr) || !duplication_) + { + if (initial) throw std::runtime_error("DuplicateOutput failed"); + return false; + } + return true; + } + + bool EnsureSource(const D3D11_TEXTURE2D_DESC& d) + { + if (source_ && d.Width == sourceWidth_ && d.Height == sourceHeight_ && d.Format == sourceFormat_) + return true; + ReleaseCom(sourceSrv_); + ReleaseCom(source_); + D3D11_TEXTURE2D_DESC s{}; + s.Width = d.Width; + s.Height = d.Height; + s.MipLevels = 1; + s.ArraySize = 1; + s.Format = d.Format; + s.SampleDesc.Count = 1; + s.Usage = D3D11_USAGE_DEFAULT; + s.BindFlags = D3D11_BIND_SHADER_RESOURCE; + if (FAILED(device_->CreateTexture2D(&s, nullptr, &source_))) return false; + D3D11_SHADER_RESOURCE_VIEW_DESC vd{}; + vd.Format = s.Format; + vd.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D; + vd.Texture2D.MipLevels = 1; + if (FAILED(device_->CreateShaderResourceView(source_, &vd, &sourceSrv_))) return false; + sourceWidth_ = d.Width; + sourceHeight_ = d.Height; + sourceFormat_ = d.Format; + return true; + } + + bool GetGameClientOutputRect(RECT& rect) const + { + rect = {}; + if (!sourceWidth_ || !sourceHeight_ || !IsWindow(hwnd_)) + return false; + + RECT client{}; + if (!GetClientRect(hwnd_, &client)) + return false; + POINT tl{ client.left, client.top }; + POINT br{ client.right, client.bottom }; + if (!ClientToScreen(hwnd_, &tl) || + !ClientToScreen(hwnd_, &br)) + return false; + + const long left = tl.x - outputDesktop_.left; + const long top = tl.y - outputDesktop_.top; + const long right = br.x - outputDesktop_.left; + const long bottom = br.y - outputDesktop_.top; + rect = { left, top, right, bottom }; + + // Projection/SBS transport requires the complete client rectangle. + // Do not clamp a partially off-screen 4K window and then split the + // surviving pixels in half: that moves the true SBS centre seam. + return left >= 0 && top >= 0 && + right <= static_cast(sourceWidth_) && + bottom <= static_cast(sourceHeight_) && + right > left && bottom > top; + } + + bool GetGameUv(UvRect& uv, bool requireFullyVisible = false) + { + if (!sourceWidth_ || !sourceHeight_ || !IsWindow(hwnd_)) + return false; + + RECT client{}; + if (!GetClientRect(hwnd_, &client)) + return false; + POINT tl{ client.left, client.top }; + POINT br{ client.right, client.bottom }; + if (!ClientToScreen(hwnd_, &tl) || + !ClientToScreen(hwnd_, &br)) + return false; + + const long leftPx = tl.x - outputDesktop_.left; + const long topPx = tl.y - outputDesktop_.top; + const long rightPx = br.x - outputDesktop_.left; + const long bottomPx = br.y - outputDesktop_.top; + const bool fullyVisible = + leftPx >= 0 && topPx >= 0 && + rightPx <= static_cast(sourceWidth_) && + bottomPx <= static_cast(sourceHeight_) && + rightPx > leftPx && bottomPx > topPx; + if (requireFullyVisible && !fullyVisible) + return false; + + const float l = + static_cast(leftPx) / sourceWidth_; + const float t = + static_cast(topPx) / sourceHeight_; + const float r = + static_cast(rightPx) / sourceWidth_; + const float b = + static_cast(bottomPx) / sourceHeight_; + uv.x = std::clamp(l, 0.f, 1.f); + uv.y = std::clamp(t, 0.f, 1.f); + const float rr = std::clamp(r, 0.f, 1.f); + const float bb = std::clamp(b, 0.f, 1.f); + uv.w = rr - uv.x; + uv.h = bb - uv.y; + return uv.w > 0.01f && uv.h > 0.01f; + } + + bool RenderTo(ID3D11RenderTargetView* rtv, std::uint32_t w, std::uint32_t h, + const UvRect& uv, ID3D11ShaderResourceView* sourceSrv, DXGI_FORMAT sourceFormat) + { + if (!rtv || !sourceSrv || !constantBuffer_) return false; + D3D11_MAPPED_SUBRESOURCE map{}; + if (FAILED(context_->Map(constantBuffer_, 0, D3D11_MAP_WRITE_DISCARD, 0, &map))) + return false; + auto* p = static_cast(map.pData); + p->uvScale[0] = uv.w; + p->uvScale[1] = uv.h; + p->uvOffset[0] = uv.x; + p->uvOffset[1] = uv.y; + p->sdrWhiteScale = sdrWhiteScale_; + p->sourceIsScRgb = sourceFormat == DXGI_FORMAT_R16G16B16A16_FLOAT ? 1.f : 0.f; + p->padding[0] = p->padding[1] = 0; + context_->Unmap(constantBuffer_, 0); + + D3D11_VIEWPORT vp{}; + vp.Width = static_cast(w); + vp.Height = static_cast(h); + vp.MaxDepth = 1.f; + context_->RSSetViewports(1, &vp); + context_->OMSetRenderTargets(1, &rtv, nullptr); + context_->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST); + context_->VSSetShader(vs_, nullptr, 0); + context_->PSSetShader(ps_, nullptr, 0); + context_->PSSetShaderResources(0, 1, &sourceSrv); + context_->PSSetSamplers(0, 1, &sampler_); + context_->PSSetConstantBuffers(0, 1, &constantBuffer_); + context_->Draw(3, 0); + ID3D11ShaderResourceView* nullSrv = nullptr; + context_->PSSetShaderResources(0, 1, &nullSrv); + ID3D11RenderTargetView* nullRtv = nullptr; + context_->OMSetRenderTargets(1, &nullRtv, nullptr); + return true; + } + + bool EnsureTheaterAnchor(XrSpace viewSpace, XrSpace localSpace, XrTime time) + { + if (theaterAnchorValid_) return true; + XrSpaceLocation loc{ XR_TYPE_SPACE_LOCATION }; + if (XR_FAILED(xrLocateSpace(viewSpace, localSpace, time, &loc))) return false; + const XrSpaceLocationFlags need = XR_SPACE_LOCATION_ORIENTATION_VALID_BIT | + XR_SPACE_LOCATION_POSITION_VALID_BIT; + if ((loc.locationFlags & need) != need) return false; + theaterAnchor_ = loc.pose; + const XrVector3f f = RotateVector(loc.pose.orientation, { 0, 0, -1 }); + theaterAnchor_.position.x += f.x; + theaterAnchor_.position.y += f.y; + theaterAnchor_.position.z += f.z; + theaterAnchorValid_ = true; + return true; + } + + XrSession session_ = XR_NULL_HANDLE; + ID3D11Device* device_ = nullptr; + ID3D11DeviceContext* context_ = nullptr; + HWND hwnd_ = nullptr; + DWORD gamePid_ = 0; + HANDLE gameProcess_ = nullptr; + HMONITOR targetMonitor_ = nullptr; + RECT outputDesktop_{}; + IDXGIOutput1* output1_ = nullptr; + IDXGIOutput5* output5_ = nullptr; + IDXGIOutputDuplication* duplication_ = nullptr; + + ID3D11Texture2D* source_ = nullptr; + ID3D11ShaderResourceView* sourceSrv_ = nullptr; + std::uint32_t sourceWidth_ = 0, sourceHeight_ = 0; + DXGI_FORMAT sourceFormat_ = DXGI_FORMAT_UNKNOWN; + bool haveFrame_=false;std::int64_t lastCapturePresentQpc_=0;std::uint32_t lastCapturePresentQpcLow_=0; + ULONGLONG lastPixelDiagnosticMs_ = 0; + + ID3D11Texture2D* stereoSource_ = nullptr; + ID3D11ShaderResourceView* stereoSourceSrv_ = nullptr; + std::uint32_t stereoSourceWidth_ = 0, stereoSourceHeight_ = 0; + DXGI_FORMAT stereoSourceFormat_ = DXGI_FORMAT_UNKNOWN; + bool stereoSourceValid_ = false; + + std::array directLeft_{}; + std::array directRight_{}; + std::array directLeftSrv_{}; + std::array directRightSrv_{}; + std::array directLeftHandle_{}; + std::array directRightHandle_{}; + std::array directGeneration_{}; + std::array directFormat_{}; + ID3D11Texture2D* directSnapshotLeft_ = nullptr; + ID3D11Texture2D* directSnapshotRight_ = nullptr; + ID3D11ShaderResourceView* directSnapshotLeftSrv_ = nullptr; + ID3D11ShaderResourceView* directSnapshotRightSrv_ = nullptr; + std::uint32_t directSnapshotWidth_ = 0, directSnapshotHeight_ = 0; + DXGI_FORMAT directSnapshotFormat_ = DXGI_FORMAT_UNKNOWN; + std::uint32_t directActiveSlot_ = 0; + bool directTransportEnabled_ = true; + bool directTransportOnly_ = false; + bool disableDesktopDuplication_ = false; + bool directTransportReady_ = false; + bool directFrameValid_ = false; + bool directOpenFailureLogged_ = false; + bool directCopyFenceFailureLogged_ = false; + float renderScale_ = 1.0f; + + ID3D11VertexShader* vs_ = nullptr; + ID3D11VertexShader* menuVs_ = nullptr; + ID3D11PixelShader* ps_ = nullptr; + ID3D11SamplerState* sampler_ = nullptr; + ID3D11Buffer* constantBuffer_ = nullptr; + ID3D11Buffer* menuConstantBuffer_ = nullptr; + DXGI_FORMAT swapFormat_ = DXGI_FORMAT_UNKNOWN; + SwapchainSet projection_{}, theater_{}; + float sdrWhiteScale_ = 1.f; + std::array configs_{}; + XrPosef theaterAnchor_{}; + bool theaterAnchorValid_ = false; + }; + + bool QpcAtOrAfter(std::int64_t capture,std::int64_t present){return capture>0&&present>0&&capture>=present;} + struct TimingSeries{std::arraysamples{};std::size_t count=0,cursor=0;void Add(double ms){samples[cursor++%samples.size()]=ms;if(count(count-1,static_cast(std::ceil(p*count))-1);return c[i];}}; + struct HostTimings{TimingSeries wait,capture,render,end;LARGE_INTEGER f{};ULONGLONG last=0;HostTimings(){QueryPerformanceFrequency(&f);}double Ms(const LARGE_INTEGER&a,const LARGE_INTEGER&b)const{return f.QuadPart?double(b.QuadPart-a.QuadPart)*1000.0/double(f.QuadPart):0;}void MaybeLog(){const auto n=GetTickCount64();if(n-last<5000)return;last=n;std::cout<<"VR host timing ms p95/p99: wait "< modes(n); + CheckXr(xrEnumerateEnvironmentBlendModes(instance, system, + XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, n, &n, modes.data()), + "xrEnumerateEnvironmentBlendModes(list)"); + for (auto m : modes) if (m == XR_ENVIRONMENT_BLEND_MODE_OPAQUE) return m; + if (modes.empty()) throw std::runtime_error("no blend mode"); + return modes.front(); + } + + float ReadRenderScale(int argc, char** argv) + { + float scale = 1.0f; + char env[64]{}; + const DWORD n = GetEnvironmentVariableA("OUTRUN_VR_RENDER_SCALE", env, sizeof(env)); + if (n > 0 && n < sizeof(env)) + { + char* end = nullptr; const float v = std::strtof(env, &end); + if (end != env && std::isfinite(v)) scale = v; + } + for (int i = 1; i + 1 < argc; ++i) + if (std::strcmp(argv[i], "--render-scale") == 0) + { + const float v = std::strtof(argv[i + 1], nullptr); + if (std::isfinite(v)) scale = v; + } + return std::clamp(scale, 0.5f, 2.0f); + } + + bool ReadBoolEnvironment(const char* name, bool defaultValue) + { + char env[32]{}; + const DWORD n = GetEnvironmentVariableA(name, env, sizeof(env)); + if (n == 0 || n >= sizeof(env)) + return defaultValue; + if (env[0] == '0' || _stricmp(env, "false") == 0 || + _stricmp(env, "off") == 0 || _stricmp(env, "no") == 0) + return false; + if (env[0] == '1' || _stricmp(env, "true") == 0 || + _stricmp(env, "on") == 0 || _stricmp(env, "yes") == 0) + return true; + return defaultValue; + } + + bool DirectTransportEnabled() + { + return ReadBoolEnvironment("OUTRUN_VR_DIRECT_TRANSPORT", true); + } + + bool DirectTransportOnly() + { + return ReadBoolEnvironment("OUTRUN_VR_DIRECT_ONLY", true); + } + + bool DisableDesktopDuplication() + { + return ReadBoolEnvironment( + "OUTRUN_VR_DISABLE_DESKTOP_DUPLICATION", false); + } + + float RequestedRefreshRateHz() + { + char env[32]{}; + const DWORD n = GetEnvironmentVariableA( + "OUTRUN_VR_TARGET_REFRESH_HZ", env, sizeof(env)); + if (n == 0 || n >= sizeof(env)) + return 0.0f; + char* end = nullptr; + const float value = std::strtof(env, &end); + if (end == env || !std::isfinite(value)) + return 0.0f; + return std::clamp(value, 0.0f, 240.0f); + } + +#ifdef XR_FB_display_refresh_rate + bool TryRequestDisplayRefreshRate( + XrInstance instance, XrSession session, float requestedHz) + { + if (requestedHz <= 0.0f) + return false; + + PFN_xrEnumerateDisplayRefreshRatesFB enumerateRates = nullptr; + PFN_xrGetDisplayRefreshRateFB getRate = nullptr; + PFN_xrRequestDisplayRefreshRateFB requestRate = nullptr; + if (XR_FAILED(xrGetInstanceProcAddr( + instance, "xrEnumerateDisplayRefreshRatesFB", + reinterpret_cast(&enumerateRates))) || + XR_FAILED(xrGetInstanceProcAddr( + instance, "xrGetDisplayRefreshRateFB", + reinterpret_cast(&getRate))) || + XR_FAILED(xrGetInstanceProcAddr( + instance, "xrRequestDisplayRefreshRateFB", + reinterpret_cast(&requestRate))) || + !enumerateRates || !getRate || !requestRate) + { + std::cout + << "XR_FB_display_refresh_rate advertised but function lookup failed; keeping runtime cadence.\n"; + return false; + } + + std::uint32_t count = 0; + if (XR_FAILED(enumerateRates(session, 0, &count, nullptr)) || + count == 0) + { + std::cout + << "OpenXR runtime exposed no selectable display refresh rates.\n"; + return false; + } + + std::vector rates(count); + if (XR_FAILED(enumerateRates( + session, count, &count, rates.data())) || + count == 0) + return false; + rates.resize(count); + + float current = 0.0f; + getRate(session, ¤t); + + float chosen = rates.front(); + float bestDistance = std::fabs(chosen - requestedHz); + for (float rate : rates) + { + const float distance = std::fabs(rate - requestedHz); + if (distance < bestDistance) + { + chosen = rate; + bestDistance = distance; + } + } + + std::cout << "OpenXR refresh rates:"; + for (float rate : rates) + std::cout << " " << rate; + std::cout << " Hz; current=" << current + << " requested=" << requestedHz + << " selected=" << chosen << " Hz\n"; + + const XrResult result = requestRate(session, chosen); + if (XR_FAILED(result)) + { + std::cout + << "xrRequestDisplayRefreshRateFB failed result=" + << result << "; keeping runtime cadence.\n"; + return false; + } + return true; + } +#endif + + void ParseRuntimeOverride(int argc, char** argv) + { + for (int i = 1; i + 1 < argc; ++i) + { + if (std::strcmp(argv[i], "--runtime-json") == 0) + { + SetEnvironmentVariableA("XR_RUNTIME_JSON", argv[i + 1]); + std::cout << "XR_RUNTIME_JSON=" << argv[i + 1] << "\n"; + return; + } + } + } +} + +int main(int argc, char** argv) +{ + XrInstance instance = XR_NULL_HANDLE; + XrSession session = XR_NULL_HANDLE; + XrSpace localSpace = XR_NULL_HANDLE; + XrSpace viewSpace = XR_NULL_HANDLE; + + try + { + SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + ParseRuntimeOverride(argc, argv); + const float renderScale = ReadRenderScale(argc, argv); + const bool directTransportEnabled = DirectTransportEnabled(); + const bool directTransportOnly = + directTransportEnabled && DirectTransportOnly(); + const bool disableDesktopDuplication = DisableDesktopDuplication(); + HWND gameWindow = WaitForGameWindow(); + DWORD gamePid = 0; + GetWindowThreadProcessId(gameWindow, &gamePid); + + if (!HasExtension(XR_KHR_D3D11_ENABLE_EXTENSION_NAME)) + throw std::runtime_error("runtime lacks XR_KHR_D3D11_enable"); + const char* extensions[]{ XR_KHR_D3D11_ENABLE_EXTENSION_NAME }; + XrInstanceCreateInfo ii{ XR_TYPE_INSTANCE_CREATE_INFO }; + strncpy_s(ii.applicationInfo.applicationName, sizeof(ii.applicationInfo.applicationName), + "OutRun 2006 True Stereo VR", _TRUNCATE); + ii.applicationInfo.applicationVersion = 1; + strncpy_s(ii.applicationInfo.engineName, sizeof(ii.applicationInfo.engineName), + "OutRun2006Tweaks", _TRUNCATE); + ii.applicationInfo.engineVersion = 1; + ii.applicationInfo.apiVersion = XR_CURRENT_API_VERSION; + ii.enabledExtensionCount = 1; + ii.enabledExtensionNames = extensions; + CheckXr(xrCreateInstance(&ii, &instance), "xrCreateInstance"); + + XrInstanceProperties ip{ XR_TYPE_INSTANCE_PROPERTIES }; + CheckXr(xrGetInstanceProperties(instance, &ip), "xrGetInstanceProperties"); + std::cout << "OpenXR runtime: " << ip.runtimeName << " " + << XR_VERSION_MAJOR(ip.runtimeVersion) << "." + << XR_VERSION_MINOR(ip.runtimeVersion) << "." + << XR_VERSION_PATCH(ip.runtimeVersion) << "\n"; + + XrSystemGetInfo sgi{ XR_TYPE_SYSTEM_GET_INFO }; + sgi.formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY; + XrSystemId system = XR_NULL_SYSTEM_ID; + for (;;) + { + const XrResult r = xrGetSystem(instance, &sgi, &system); + if (r == XR_ERROR_FORM_FACTOR_UNAVAILABLE) { Sleep(1000); continue; } + CheckXr(r, "xrGetSystem"); + break; + } + + PFN_xrGetD3D11GraphicsRequirementsKHR getReq = nullptr; + CheckXr(xrGetInstanceProcAddr(instance, "xrGetD3D11GraphicsRequirementsKHR", + reinterpret_cast(&getReq)), "xrGetInstanceProcAddr"); + XrGraphicsRequirementsD3D11KHR req{ XR_TYPE_GRAPHICS_REQUIREMENTS_D3D11_KHR }; + CheckXr(getReq(instance, system, &req), "xrGetD3D11GraphicsRequirementsKHR"); + D3DObjects d3d = CreateD3D11Device(req); + + XrGraphicsBindingD3D11KHR binding{ XR_TYPE_GRAPHICS_BINDING_D3D11_KHR }; + binding.device = d3d.device; + XrSessionCreateInfo si{ XR_TYPE_SESSION_CREATE_INFO }; + si.next = &binding; + si.systemId = system; + CheckXr(xrCreateSession(instance, &si, &session), "xrCreateSession"); + + XrPosef identity{}; + identity.orientation.w = 1; + XrReferenceSpaceCreateInfo li{ XR_TYPE_REFERENCE_SPACE_CREATE_INFO }; + li.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL; + li.poseInReferenceSpace = identity; + CheckXr(xrCreateReferenceSpace(session, &li, &localSpace), "xrCreateReferenceSpace LOCAL"); + XrReferenceSpaceCreateInfo vi{ XR_TYPE_REFERENCE_SPACE_CREATE_INFO }; + vi.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_VIEW; + vi.poseInReferenceSpace = identity; + CheckXr(xrCreateReferenceSpace(session, &vi, &viewSpace), "xrCreateReferenceSpace VIEW"); + + std::uint32_t cc = 0; + CheckXr(xrEnumerateViewConfigurationViews(instance, system, + XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, 0, &cc, nullptr), + "xrEnumerateViewConfigurationViews count"); + if (cc < 2) throw std::runtime_error("runtime has fewer than two stereo views"); + std::vector cv(cc); + for (auto& c : cv) c = { XR_TYPE_VIEW_CONFIGURATION_VIEW }; + CheckXr(xrEnumerateViewConfigurationViews(instance, system, + XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, cc, &cc, cv.data()), + "xrEnumerateViewConfigurationViews list"); + std::array configs{ cv[0], cv[1] }; + + SharedWriter shared(req.adapterLuid);RenderFrameReader renderFrames;StereoCompositor compositor(session,d3d.device,d3d.context,gameWindow,configs,directTransportEnabled,directTransportOnly,disableDesktopDuplication,renderScale);compositor.Initialize();ViewHistory viewHistory;HostTimings timings; + const XrEnvironmentBlendMode blend = ChooseBlendMode(instance, system); + + bool running = false, quit = false, exitRequested = false; + ULONGLONG exitRequestMs = 0; + XrSessionState state = XR_SESSION_STATE_UNKNOWN; + OutRunVR::ClientPresentationMode lastPresentation = OutRunVR::PresentationUnknown; + std::array matchedViews{}; + bool matchedStereoValid = false; + std::uint32_t lastProcessedStereoFrame = 0; + ULONGLONG lastStereoMatchMs=0;bool pendingReferenceSpaceChange=false;XrTime pendingReferenceSpaceChangeTime=0; + + while (!quit) + { + if (!compositor.GameAlive() && !exitRequested) + { + exitRequested = true; + exitRequestMs = GetTickCount64(); + if (session != XR_NULL_HANDLE) xrRequestExitSession(session); + std::cout << "OutRun exited; requesting OpenXR shutdown.\n"; + } + + XrEventDataBuffer event{ XR_TYPE_EVENT_DATA_BUFFER }; + while (xrPollEvent(instance, &event) == XR_SUCCESS) + { + if (event.type == XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED) + { + auto* e = reinterpret_cast(&event); + state = e->state; + if (state == XR_SESSION_STATE_READY && !running) + { + XrSessionBeginInfo bi{ XR_TYPE_SESSION_BEGIN_INFO }; + bi.primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO; + CheckXr(xrBeginSession(session, &bi), "xrBeginSession"); + running = true; + } + else if (state == XR_SESSION_STATE_STOPPING && running) + { + CheckXr(xrEndSession(session), "xrEndSession"); + running = false; + viewHistory.Clear(); + matchedStereoValid = false; + lastStereoMatchMs = 0; + compositor.ReferenceSpaceChanged(); + OutRunVR::SharedRenderFrameState rf{}; + if (renderFrames.Read(rf)) + lastProcessedStereoFrame = rf.frameId; + } + else if (state == XR_SESSION_STATE_EXITING || state == XR_SESSION_STATE_LOSS_PENDING) + quit = true; + } + else if(event.type==XR_TYPE_EVENT_DATA_REFERENCE_SPACE_CHANGE_PENDING){const auto*e=reinterpret_cast(&event);if(e->referenceSpaceType==XR_REFERENCE_SPACE_TYPE_LOCAL){pendingReferenceSpaceChange=true;pendingReferenceSpaceChangeTime=e->changeTime;}} + event = { XR_TYPE_EVENT_DATA_BUFFER }; + } + + if (quit) break; + if (exitRequested && GetTickCount64() - exitRequestMs > 2000) break; + if (!running) { Sleep(10); continue; } + + XrFrameWaitInfo wi{ XR_TYPE_FRAME_WAIT_INFO }; + XrFrameState fs{ XR_TYPE_FRAME_STATE }; + LARGE_INTEGER ws{},we{};QueryPerformanceCounter(&ws);CheckXr(xrWaitFrame(session,&wi,&fs),"xrWaitFrame");QueryPerformanceCounter(&we);timings.wait.Add(timings.Ms(ws,we)); + if(pendingReferenceSpaceChange&&(pendingReferenceSpaceChangeTime==0||fs.predictedDisplayTime>=pendingReferenceSpaceChangeTime)){shared.ReferenceSpaceChanged();compositor.ReferenceSpaceChanged();viewHistory.Clear();matchedStereoValid=false;OutRunVR::SharedRenderFrameState rf{};lastProcessedStereoFrame=renderFrames.Read(rf)?rf.frameId:shared.ReadStereoMeta().frame;pendingReferenceSpaceChange=false;pendingReferenceSpaceChangeTime=0;} + XrFrameBeginInfo bi{ XR_TYPE_FRAME_BEGIN_INFO }; + CheckXr(xrBeginFrame(session, &bi), "xrBeginFrame"); + + XrSpaceLocation head{ XR_TYPE_SPACE_LOCATION }; + CheckXr(xrLocateSpace(viewSpace, localSpace, fs.predictedDisplayTime, &head), "xrLocateSpace"); + std::array views{}; + for (auto& v : views) v = { XR_TYPE_VIEW }; + XrViewState vs{ XR_TYPE_VIEW_STATE }; + XrViewLocateInfo vl{ XR_TYPE_VIEW_LOCATE_INFO }; + vl.viewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO; + vl.displayTime = fs.predictedDisplayTime; + vl.space = localSpace; + std::uint32_t vc = 0; + CheckXr(xrLocateViews(session, &vl, &vs, 2, &vc, views.data()), "xrLocateViews"); + + const std::uint32_t hostSequence = shared.Write(head, views, vc, configs, + state, vs.viewStateFlags, fs.shouldRender == XR_TRUE, directTransportEnabled, + compositor.DirectTransportReady(), ip.runtimeName); + if (directTransportEnabled) shared.ServiceInteropProbe(d3d.device, d3d.context); + const XrViewStateFlags neededViews = XR_VIEW_STATE_ORIENTATION_VALID_BIT | + XR_VIEW_STATE_POSITION_VALID_BIT; + if (vc >= 2 && (vs.viewStateFlags & neededViews) == neededViews) + viewHistory.Store(hostSequence, views); + + XrFrameEndInfo end{ XR_TYPE_FRAME_END_INFO }; + end.displayTime = fs.predictedDisplayTime; + end.environmentBlendMode = blend; + const XrCompositionLayerBaseHeader* layers[1]{}; + std::array pv{}; + XrCompositionLayerProjection projection{ XR_TYPE_COMPOSITION_LAYER_PROJECTION }; + XrCompositionLayerQuad quad{ XR_TYPE_COMPOSITION_LAYER_QUAD }; + + const auto presentation = shared.Presentation(); + if (presentation != lastPresentation) + { + compositor.ReferenceSpaceChanged(); + matchedStereoValid=false;OutRunVR::SharedRenderFrameState rf{};lastProcessedStereoFrame=renderFrames.Read(rf)?rf.frameId:shared.ReadStereoMeta().frame;lastPresentation=presentation; + std::cout << "VR presentation: " + << (presentation == OutRunVR::PresentationGameplay + ? "true stereo projection" : "LOCAL-fixed theater") << ".\n"; + } + + bool layerReady=false; + if(fs.shouldRender==XR_TRUE&&vc>=2){ + if(presentation==OutRunVR::PresentationGameplay){OutRunVR::SharedRenderFrameState before{};const bool have=renderFrames.Read(before);const std::uint32_t need=OutRunVR::RenderFrameStereoComplete|OutRunVR::RenderFrameWorldStereo|OutRunVR::RenderFrameDrawDuplicated|OutRunVR::RenderFrameEffectivePoseValid; + if(have&&(before.flags&OutRunVR::RenderFramePresentInFlight)==0&&before.state==OutRunVR::StereoSbsActive&&before.frameId&&before.frameId!=lastProcessedStereoFrame&&before.sourcePoseSequence&&(before.flags&need)==need){std::array history{};if(viewHistory.Find(before.sourcePoseSequence,history)){bool sourceReady=false;const bool directFrame=(before.flags&OutRunVR::RenderFrameDirectGpuTransport)!=0;LARGE_INTEGER cs{},ce{};QueryPerformanceCounter(&cs);if(directFrame){sourceReady=compositor.CommitDirectStereoSource(before);}else{const CaptureStatus capture=compositor.Capture(2);sourceReady=capture.available&&QpcAtOrAfter(capture.lastPresentQpc,before.presentQpc)&&compositor.CommitStereoSource();}QueryPerformanceCounter(&ce);timings.capture.Add(timings.Ms(cs,ce));OutRunVR::SharedRenderFrameState after{};const bool same=renderFrames.Read(after)&&(after.flags&OutRunVR::RenderFramePresentInFlight)==0&&after.state==before.state&&after.frameId==before.frameId&&after.sourcePoseSequence==before.sourcePoseSequence&&after.presentQpc==before.presentQpc&&after.flags==before.flags&&after.failureReason==before.failureReason;if(sourceReady&&same){if(directFrame)shared.AckDirectFrame(before.frameId);for(int eye=0;eye<2;++eye){matchedViews[eye]={XR_TYPE_VIEW};matchedViews[eye].pose.orientation={before.eye[eye].orientation[0],before.eye[eye].orientation[1],before.eye[eye].orientation[2],before.eye[eye].orientation[3]};matchedViews[eye].pose.position={before.eye[eye].position[0],before.eye[eye].position[1],before.eye[eye].position[2]};matchedViews[eye].fov={before.eye[eye].fov.angleLeft,before.eye[eye].fov.angleRight,before.eye[eye].fov.angleUp,before.eye[eye].fov.angleDown};}matchedStereoValid=true;lastProcessedStereoFrame=before.frameId;lastStereoMatchMs=GetTickCount64();}}} + const bool grace=matchedStereoValid&&compositor.HasStereoSource()&&GetTickCount64()-lastStereoMatchMs<=StereoGraceMs;LARGE_INTEGER rs{},re{};QueryPerformanceCounter(&rs);if(grace&&compositor.RenderProjection(matchedViews,pv)){projection.space=localSpace;projection.viewCount=2;projection.views=pv.data();layers[0]=reinterpret_cast(&projection);layerReady=true;}QueryPerformanceCounter(&re);timings.render.Add(timings.Ms(rs,re)); + }else{LARGE_INTEGER cs{},ce{};QueryPerformanceCounter(&cs);const CaptureStatus capture=compositor.Capture();QueryPerformanceCounter(&ce);timings.capture.Add(timings.Ms(cs,ce));LARGE_INTEGER rs{},re{};QueryPerformanceCounter(&rs);if(capture.available&&compositor.RenderTheater(viewSpace,localSpace,fs.predictedDisplayTime,quad)){layers[0]=reinterpret_cast(&quad);layerReady=true;}QueryPerformanceCounter(&re);timings.render.Add(timings.Ms(rs,re));}} + end.layerCount=layerReady?1:0;end.layers=layerReady?layers:nullptr;LARGE_INTEGER es{},ee{};QueryPerformanceCounter(&es);CheckXr(xrEndFrame(session,&end),"xrEndFrame");QueryPerformanceCounter(&ee);timings.end.Add(timings.Ms(es,ee));timings.MaybeLog(); + } + + compositor.Shutdown(); + if (viewSpace != XR_NULL_HANDLE) xrDestroySpace(viewSpace); + if (localSpace != XR_NULL_HANDLE) xrDestroySpace(localSpace); + if (session != XR_NULL_HANDLE) xrDestroySession(session); + if (instance != XR_NULL_HANDLE) xrDestroyInstance(instance); + return 0; + } + catch (const std::exception& e) + { + std::cerr << "OutRun VR host error: " << e.what() << "\n"; + if (viewSpace != XR_NULL_HANDLE) xrDestroySpace(viewSpace); + if (localSpace != XR_NULL_HANDLE) xrDestroySpace(localSpace); + if (session != XR_NULL_HANDLE) xrDestroySession(session); + if (instance != XR_NULL_HANDLE) xrDestroyInstance(instance); + return 1; + } +} diff --git a/vrhost/src/main_r23.cpp b/vrhost/src/main_r23.cpp new file mode 100644 index 000000000..70d25b6f9 --- /dev/null +++ b/vrhost/src/main_r23.cpp @@ -0,0 +1,3025 @@ +// R23 host entry point. +// +// Reuse the validated host implementation types, but replace the frame loop so +// a candidate capture/direct source is never made presentation-authoritative +// until the surrounding Frame.v2 metadata has been re-read and proven stable. +// Pixel diagnostics use reusable non-blocking staging resources and independently +// probe source, left/right projection output, and theater output. + +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#define XR_USE_PLATFORM_WIN32 +#define XR_USE_GRAPHICS_API_D3D11 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "vr_shared.hpp" +#include "stereo_shader.hpp" +#include "runtime/r23_verified_bundle.hpp" +#include "vr/ipc/cadence_v1.hpp" + +#ifndef OUTRUN_VR_BUILD_SHA +#define OUTRUN_VR_BUILD_SHA "unknown" +#endif + +// main.cpp keeps compositor implementation details private. R23 is an overlay +// TU over that exact implementation and needs access only to stage/commit +// candidate resources without duplicating the whole compositor. All headers are +// already included above, so this visibility override is confined to the class +// definitions in main.cpp and does not leak into STL/Windows headers. +#define private public +#define protected public +#define main OutRunVrLegacyMainR23 +#include "main.cpp" +#undef main +#undef protected +#undef private + +// sbs_capture_override.hpp used a legacy ReleaseFrame macro while preprocessing +// main.cpp. R23 performs the production publication explicitly below. +#ifdef ReleaseFrame +#undef ReleaseFrame +#endif + +namespace +{ + constexpr ULONGLONG R23PixelIntervalMs = 5000; + constexpr UINT R23SourceSamples = 8; + constexpr DWORD R23TheaterRefreshWaitMs = 2; + constexpr ULONGLONG R23TheaterRefreshLogIntervalMs = 5000; + // OpenXR/VDXR commonly runs at 90 Hz while OutRun produces stereo at + // roughly 60 Hz. Re-rendering the same Desktop Duplication source into the + // projection swapchain on every HMD tick wastes GPU work, while falling + // back to the theater quad exposes the raw SBS image. Keep the last + // successfully released projection alive for intermediate HMD frames and + // for short game-state/capture gaps. + constexpr ULONGLONG R23CachedProjectionHoldMs = 1000; + constexpr ULONGLONG R23PresentationDebounceMs = 750; + constexpr ULONGLONG R23CachedProjectionLogIntervalMs = 5000; + + bool R23UsableGameplayBootstrapFrame( + const OutRunVR::SharedRenderFrameState& frame) noexcept + { + constexpr std::uint32_t required = + OutRunVR::RenderFrameStereoComplete | + OutRunVR::RenderFrameWorldStereo | + OutRunVR::RenderFrameDrawDuplicated | + OutRunVR::RenderFrameEffectivePoseValid; + + if (!frame.frameId || !frame.sourcePoseSequence || + frame.presentationMode != OutRunVR::PresentationGameplay || + frame.state != OutRunVR::StereoSbsActive || + frame.failureReason != OutRunVR::StereoFailureNone || + frame.presentQpc <= 0 || + !frame.backbufferWidth || !frame.backbufferHeight || + (frame.flags & OutRunVR::RenderFramePresentInFlight) != 0 || + (frame.flags & required) != required) + return false; + + if ((frame.flags & OutRunVR::RenderFrameDirectGpuTransport) == 0) + return true; + + const std::uint32_t slot = + frame.reserved[OutRunVR::RenderFrameDirectSlotIndex]; + const std::uint32_t generation = + frame.reserved[OutRunVR::RenderFrameDirectGenerationIndex]; + const std::uint32_t width = + frame.reserved[OutRunVR::RenderFrameDirectWidthIndex]; + const std::uint32_t height = + frame.reserved[OutRunVR::RenderFrameDirectHeightIndex]; + const std::uint32_t leftHandle = + frame.reserved[OutRunVR::RenderFrameDirectLeftHandleIndex]; + const std::uint32_t rightHandle = + frame.reserved[OutRunVR::RenderFrameDirectRightHandleIndex]; + + return slot < OutRunVR::RenderFrameRingSize && + generation != 0 && + leftHandle != 0 && rightHandle != 0 && + width != 0 && height != 0 && + width == frame.backbufferWidth && + height == frame.backbufferHeight; + } + + std::uint64_t R23CachedProjectionSubmits = 0; + std::uint64_t R42SameFrameProjectionReuses = 0; + std::uint64_t R42ProjectionRefreshes = 0; + ULONGLONG R23LastCachedProjectionLogMs = 0; + bool R42FirstProjectionReuseLogged = false; + std::uint64_t R23PresentationGraceFrames = 0; + std::uint64_t R23TheaterRefreshAttempts = 0; + std::uint64_t R23TheaterRefreshFresh = 0; + ULONGLONG R23LastTheaterRefreshLogMs = 0; + std::uint64_t R23GameplayTheaterFallbacks = 0; + ULONGLONG R23LastGameplayFallbackLogMs = 0; + + + struct R23FinalCounters + { + std::uint64_t r32FastDirect = 0; + std::uint64_t exact = 0; + std::uint64_t softGrace = 0; + std::uint64_t direct = 0; + std::uint64_t liveTheater = 0; + std::uint64_t directFlat = 0; + std::uint64_t cached = 0; + std::uint64_t emergency = 0; + std::uint64_t empty = 0; + std::uint64_t mixed = 0; + }; + + R23FinalCounters R23ReadFinalCounters() noexcept + { + using namespace OutRunVrR24BlackScreenGuard; + return { + OutRunVrR32DirectSubmit::FastDirectSubmits, + ExactProjectionSubmits, + SoftGraceProjectionSubmits, + DirectSafeProjectionSubmits, + LiveTheaterFallbacks, + DirectFlatFallbacks, + CachedLayerFallbacks, + EmergencyLayerFallbacks, + EmptyFrameFallbacks, + MixedValidatedSubmits + }; + } + + const char* R23ActualFinalKind( + const R23FinalCounters& before, + const R23FinalCounters& after, + const char* requested) noexcept + { + if (after.r32FastDirect != before.r32FastDirect) + return "projection-direct-r32-fast"; + if (after.exact != before.exact) return "projection-exact"; + if (after.softGrace != before.softGrace) return "projection-soft-grace"; + if (after.direct != before.direct) return "projection-direct-safe"; + if (after.cached != before.cached) return "fallback-cached-image"; + if (after.directFlat != before.directFlat) return "fallback-direct-flat"; + if (after.liveTheater != before.liveTheater) return "fallback-live-theater"; + if (after.emergency != before.emergency) return "fallback-emergency"; + if (after.empty != before.empty) return "no-layer"; + if (after.mixed != before.mixed) return "mixed-validated"; + return requested ? requested : "unclassified"; + } + + struct R23MetricWindow + { + std::vector values; + double sum = 0.0; + double maximum = 0.0; + + void Add(double value) + { + if (!std::isfinite(value) || value < 0.0) + return; + values.push_back(value); + sum += value; + maximum = std::max(maximum, value); + } + + double Average() const noexcept + { + return values.empty() ? 0.0 : + sum / static_cast(values.size()); + } + + double P95() const + { + if (values.empty()) + return 0.0; + std::vector sorted = values; + std::sort(sorted.begin(), sorted.end()); + const std::size_t index = + std::min( + sorted.size() - 1, + static_cast( + std::ceil(sorted.size() * 0.95)) - 1); + return sorted[index]; + } + + void Reset() + { + values.clear(); + sum = 0.0; + maximum = 0.0; + } + }; + + struct R23PipelineWindow + { + std::unordered_map rejects; + std::unordered_map finals; + R23MetricWindow capture; + R23MetricWindow commit; + R23MetricWindow render; + R23MetricWindow endFrame; + std::uint64_t frames = 0; + + void Note(const char* rejectReason, const char* finalKind, + double captureMs, double commitMs, + double renderMs, double endMs) + { + ++frames; + if (rejectReason && *rejectReason && + std::strcmp(rejectReason, "not-evaluated") != 0 && + std::strcmp(rejectReason, "already-processed") != 0 && + std::strcmp(rejectReason, "committed-pending-render") != 0 && + std::strcmp(rejectReason, "displayed-fresh") != 0) + ++rejects[rejectReason]; + if (finalKind && *finalKind) + ++finals[finalKind]; + capture.Add(captureMs); + commit.Add(commitMs); + render.Add(renderMs); + endFrame.Add(endMs); + } + + static void AppendCounts( + std::ostringstream& out, + const std::unordered_map& counts) + { + bool first = true; + for (const auto& item : counts) + { + if (!first) out << ","; + first = false; + out << item.first << ":" << item.second; + } + if (first) out << "none"; + } + + void AppendAndReset(std::ostringstream& out) + { + out << " intervalFrames=" << frames + << " actualSubmits={"; + AppendCounts(out, finals); + out << "} rejectCounts={"; + AppendCounts(out, rejects); + out << "}" + << " captureAvgMaxP95=" + << capture.Average() << "/" + << capture.maximum << "/" << capture.P95() + << " commitAvgMaxP95=" + << commit.Average() << "/" + << commit.maximum << "/" << commit.P95() + << " renderAvgMaxP95=" + << render.Average() << "/" + << render.maximum << "/" << render.P95() + << " endAvgMaxP95=" + << endFrame.Average() << "/" + << endFrame.maximum << "/" << endFrame.P95(); + frames = 0; + rejects.clear(); + finals.clear(); + capture.Reset(); + commit.Reset(); + render.Reset(); + endFrame.Reset(); + } + }; + + struct R23DirectHoldState + { + ID3D11Texture2D* eye[2]{}; + ID3D11ShaderResourceView* srv[2]{}; + DXGI_FORMAT format = DXGI_FORMAT_UNKNOWN; + UINT width = 0; + UINT height = 0; + UINT mipLevels = 0; + UINT arraySize = 0; + std::uint32_t frameId = 0; + std::uint32_t generation = 0; + bool valid = false; + + ~R23DirectHoldState() + { + for (int eyeIndex = 0; eyeIndex < 2; ++eyeIndex) + { + if (srv[eyeIndex]) srv[eyeIndex]->Release(); + if (eye[eyeIndex]) eye[eyeIndex]->Release(); + } + } + }; + R23DirectHoldState R23DirectHold{}; + bool R23FirstDirectHoldLogged = false; + bool R36FirstDirectBootstrapLogged = false; + std::array + R37BootstrapSubmittedFrame{}; + std::array + R37BootstrapSubmittedGeneration{}; + + bool R37FrameIdBefore( + std::uint32_t candidate, std::uint32_t reference) noexcept + { + return candidate != reference && + static_cast(candidate - reference) < 0; + } + + void R23ReleaseDirectHoldResources() noexcept + { + for (int eye = 0; eye < 2; ++eye) + { + ReleaseCom(R23DirectHold.srv[eye]); + ReleaseCom(R23DirectHold.eye[eye]); + } + R23DirectHold.format = DXGI_FORMAT_UNKNOWN; + R23DirectHold.width = 0; + R23DirectHold.height = 0; + R23DirectHold.mipLevels = 0; + R23DirectHold.arraySize = 0; + R23DirectHold.frameId = 0; + R23DirectHold.generation = 0; + R23DirectHold.valid = false; + } + + void R23InvalidateDirectHold() noexcept + { + R23DirectHold.frameId = 0; + R23DirectHold.generation = 0; + R23DirectHold.valid = false; + } + + bool R23StageDirectHold(StereoCompositor& c, + const OutRunVR::SharedRenderFrameState& frame) + { + const std::uint32_t slot = + frame.reserved[OutRunVR::RenderFrameDirectSlotIndex]; + const std::uint32_t generation = + frame.reserved[OutRunVR::RenderFrameDirectGenerationIndex]; + if (!c.context_ || !c.device_ || slot >= OutRunVR::RenderFrameRingSize || + !frame.frameId || !generation || !c.directLeft_[slot] || + !c.directRight_[slot]) + return false; + + D3D11_TEXTURE2D_DESC left{}; + D3D11_TEXTURE2D_DESC right{}; + c.directLeft_[slot]->GetDesc(&left); + c.directRight_[slot]->GetDesc(&right); + if (!left.Width || !left.Height || left.Width != right.Width || + left.Height != right.Height || left.MipLevels != right.MipLevels || + left.ArraySize != right.ArraySize || left.Format != right.Format || + left.SampleDesc.Count != 1 || right.SampleDesc.Count != 1 || + left.Width != frame.backbufferWidth || + left.Height != frame.backbufferHeight) + return false; + + const bool recreate = + !R23DirectHold.eye[0] || !R23DirectHold.eye[1] || + !R23DirectHold.srv[0] || !R23DirectHold.srv[1] || + R23DirectHold.width != left.Width || + R23DirectHold.height != left.Height || + R23DirectHold.mipLevels != left.MipLevels || + R23DirectHold.arraySize != left.ArraySize || + R23DirectHold.format != left.Format; + if (recreate) + { + R23ReleaseDirectHoldResources(); + D3D11_TEXTURE2D_DESC hold = left; + hold.Usage = D3D11_USAGE_DEFAULT; + hold.BindFlags = D3D11_BIND_SHADER_RESOURCE; + hold.CPUAccessFlags = 0; + hold.MiscFlags = 0; + for (int eye = 0; eye < 2; ++eye) + { + if (FAILED(c.device_->CreateTexture2D( + &hold, nullptr, &R23DirectHold.eye[eye])) || + !R23DirectHold.eye[eye] || + FAILED(c.device_->CreateShaderResourceView( + R23DirectHold.eye[eye], nullptr, + &R23DirectHold.srv[eye])) || + !R23DirectHold.srv[eye]) + { + R23ReleaseDirectHoldResources(); + return false; + } + } + R23DirectHold.width = left.Width; + R23DirectHold.height = left.Height; + R23DirectHold.mipLevels = left.MipLevels; + R23DirectHold.arraySize = left.ArraySize; + R23DirectHold.format = left.Format; + } + + // Immediate-context ordering guarantees that both copies execute before + // the following projection draw samples this host-owned pair. The R32 + // EVENT fence then covers the copy + projection work before producer ACK. + c.context_->CopyResource(R23DirectHold.eye[0], c.directLeft_[slot]); + c.context_->CopyResource(R23DirectHold.eye[1], c.directRight_[slot]); + R23DirectHold.frameId = frame.frameId; + R23DirectHold.generation = generation; + R23DirectHold.valid = true; + if (!R23FirstDirectHoldLogged) + { + R23FirstDirectHoldLogged = true; + std::cout + << "DirectGPU single-copy production path active; legacy private snapshot/fence bypassed and grace projection samples only host-owned hold textures.\n"; + } + return true; + } + + const char* R23SourceKindName(OutRunVrR23VerifiedBundle::SourceKind kind) + { + using OutRunVrR23VerifiedBundle::SourceKind; + switch (kind) + { + case SourceKind::ClassicSbs: return "classic-sbs"; + case SourceKind::DirectGpu: return "direct-gpu"; + default: return "none"; + } + } + + std::string R23GameBuildTag( + const OutRunVR::SharedRenderFrameState& frame) + { + char tag[13]{}; + std::memcpy(tag, + &frame.reserved[ + OutRunVR::RenderFrameGameBuildTag0Index], + 12); + tag[12] = '\0'; + std::size_t length = 0; + while (length < 12 && tag[length] != '\0') + ++length; + return std::string(tag, length); + } + + float R23HalfToFloat(std::uint16_t value) + { + const std::uint32_t sign = static_cast(value & 0x8000u) << 16; + std::uint32_t exponent = (value >> 10) & 0x1Fu; + std::uint32_t mantissa = value & 0x03FFu; + std::uint32_t bits = 0; + if (exponent == 0) + { + if (mantissa == 0) bits = sign; + else + { + int unbiased = -14; + while ((mantissa & 0x0400u) == 0) { mantissa <<= 1; --unbiased; } + mantissa &= 0x03FFu; + bits = sign | (static_cast(unbiased + 127) << 23) | + (mantissa << 13); + } + } + else if (exponent == 0x1Fu) + bits = sign | 0x7F800000u | (mantissa << 13); + else + bits = sign | ((exponent + 112u) << 23) | (mantissa << 13); + float out = 0.0f; + std::memcpy(&out, &bits, sizeof(out)); + return out; + } + + bool R23DecodeRgb(const std::uint8_t* p, DXGI_FORMAT format, float rgb[3]) + { + if (!p || !rgb) return false; + if (format == DXGI_FORMAT_B8G8R8A8_UNORM || + format == DXGI_FORMAT_B8G8R8A8_UNORM_SRGB) + { + rgb[0] = p[2] / 255.0f; rgb[1] = p[1] / 255.0f; rgb[2] = p[0] / 255.0f; + return true; + } + if (format == DXGI_FORMAT_R8G8B8A8_UNORM || + format == DXGI_FORMAT_R8G8B8A8_UNORM_SRGB) + { + rgb[0] = p[0] / 255.0f; rgb[1] = p[1] / 255.0f; rgb[2] = p[2] / 255.0f; + return true; + } + if (format == DXGI_FORMAT_R16G16B16A16_FLOAT) + { + const auto* h = reinterpret_cast(p); + rgb[0] = R23HalfToFloat(h[0]); rgb[1] = R23HalfToFloat(h[1]); rgb[2] = R23HalfToFloat(h[2]); + return true; + } + return false; + } + + UINT R23BytesPerPixel(DXGI_FORMAT format) + { + return format == DXGI_FORMAT_R16G16B16A16_FLOAT ? 8u : 4u; + } + + struct R23AsyncPixelProbe + { + ID3D11Texture2D* sourceStage = nullptr; + ID3D11Texture2D* projectionStage = nullptr; + ID3D11Texture2D* theaterStage = nullptr; + DXGI_FORMAT sourceFormat = DXGI_FORMAT_UNKNOWN; + DXGI_FORMAT projectionFormat = DXGI_FORMAT_UNKNOWN; + DXGI_FORMAT theaterFormat = DXGI_FORMAT_UNKNOWN; + bool sourcePending = false; + bool projectionPending = false; + bool theaterPending = false; + ULONGLONG lastSourceScheduleMs = 0; + ULONGLONG lastProjectionScheduleMs = 0; + ULONGLONG lastTheaterScheduleMs = 0; + std::uint64_t sourceMapDefers = 0; + std::uint64_t projectionMapDefers = 0; + std::uint64_t theaterMapDefers = 0; + std::uint32_t projectionFrameId = 0; + OutRunVrR23VerifiedBundle::SourceKind projectionKind = + OutRunVrR23VerifiedBundle::SourceKind::None; + bool projectionFinalKnown = false; + bool projectionFinalSubmitted = false; + bool theaterFinalKnown = false; + bool theaterFinalSubmitted = false; + + ~R23AsyncPixelProbe() + { + ReleaseCom(sourceStage); + ReleaseCom(projectionStage); + ReleaseCom(theaterStage); + } + + bool EnsureStage(ID3D11Device* device, ID3D11Texture2D*& stage, + DXGI_FORMAT& cachedFormat, DXGI_FORMAT format, UINT width) + { + if (stage && cachedFormat == format) + return true; + ReleaseCom(stage); + cachedFormat = DXGI_FORMAT_UNKNOWN; + D3D11_TEXTURE2D_DESC d{}; + d.Width = width; d.Height = 1; d.MipLevels = 1; d.ArraySize = 1; + d.Format = format; d.SampleDesc.Count = 1; + d.Usage = D3D11_USAGE_STAGING; d.CPUAccessFlags = D3D11_CPU_ACCESS_READ; + const HRESULT hr = device->CreateTexture2D(&d, nullptr, &stage); + if (FAILED(hr) || !stage) + { + std::cerr << "[R23 pixel] CreateTexture2D staging failed hr=0x" + << std::hex << static_cast(hr) << std::dec << "\n"; + return false; + } + cachedFormat = format; + return true; + } + + void ConsumeSource(ID3D11DeviceContext* context) + { + if (!sourcePending || !sourceStage || !context) return; + D3D11_MAPPED_SUBRESOURCE mapped{}; + const HRESULT hr = context->Map(sourceStage, 0, D3D11_MAP_READ, + D3D11_MAP_FLAG_DO_NOT_WAIT, &mapped); + if (hr == DXGI_ERROR_WAS_STILL_DRAWING) { ++sourceMapDefers; return; } + if (FAILED(hr) || !mapped.pData) + { + std::cerr << "[R23 pixel] source-game-uv async Map failed hr=0x" + << std::hex << static_cast(hr) << std::dec << "\n"; + sourcePending = false; + return; + } + + float minRgb[3]{ 1.0e30f, 1.0e30f, 1.0e30f }; + float maxRgb[3]{ -1.0e30f, -1.0e30f, -1.0e30f }; + double sumRgb[3]{}; + std::uint32_t finitePixels = 0, nonFinitePixels = 0; + const UINT bpp = R23BytesPerPixel(sourceFormat); + const auto* row = static_cast(mapped.pData); + for (UINT i = 0; i < R23SourceSamples; ++i) + { + float rgb[3]{}; + if (!R23DecodeRgb(row + static_cast(i) * bpp, sourceFormat, rgb)) + continue; + const bool finite = std::isfinite(rgb[0]) && std::isfinite(rgb[1]) && std::isfinite(rgb[2]); + if (!finite) { ++nonFinitePixels; continue; } + ++finitePixels; + for (int c = 0; c < 3; ++c) + { + minRgb[c] = std::min(minRgb[c], rgb[c]); + maxRgb[c] = std::max(maxRgb[c], rgb[c]); + sumRgb[c] += rgb[c]; + } + } + context->Unmap(sourceStage, 0); + sourcePending = false; + const double denom = finitePixels ? static_cast(finitePixels) : 1.0; + std::cout << "[R23 pixel] source-game-uv samples=" << R23SourceSamples + << " finite=" << finitePixels << " nonFinitePixels=" << nonFinitePixels + << " rgbMin=" << minRgb[0] << "," << minRgb[1] << "," << minRgb[2] + << " rgbMax=" << maxRgb[0] << "," << maxRgb[1] << "," << maxRgb[2] + << " rgbMean=" << sumRgb[0] / denom << "," << sumRgb[1] / denom << "," << sumRgb[2] / denom + << " mapDefers=" << sourceMapDefers << "\n"; + } + + void ConsumeProjection(ID3D11DeviceContext* context) + { + if (!projectionPending || !projectionStage || !context) return; + D3D11_MAPPED_SUBRESOURCE mapped{}; + const HRESULT hr = context->Map(projectionStage, 0, D3D11_MAP_READ, + D3D11_MAP_FLAG_DO_NOT_WAIT, &mapped); + if (hr == DXGI_ERROR_WAS_STILL_DRAWING) { ++projectionMapDefers; return; } + if (FAILED(hr) || !mapped.pData) + { + std::cerr << "[R23 pixel] projection async Map failed hr=0x" + << std::hex << static_cast(hr) << std::dec << "\n"; + projectionPending = false; + return; + } + + const UINT bpp = R23BytesPerPixel(projectionFormat); + const auto* row = static_cast(mapped.pData); + float left[3]{}, right[3]{}; + const bool leftDecoded = R23DecodeRgb(row, projectionFormat, left); + const bool rightDecoded = R23DecodeRgb(row + bpp, projectionFormat, right); + const bool leftFinite = leftDecoded && std::isfinite(left[0]) && + std::isfinite(left[1]) && std::isfinite(left[2]); + const bool rightFinite = rightDecoded && std::isfinite(right[0]) && + std::isfinite(right[1]) && std::isfinite(right[2]); + context->Unmap(projectionStage, 0); + projectionPending = false; + + std::cout << "[R23 pixel] projection frame=" << projectionFrameId + << " source=" << R23SourceKindName(projectionKind) + << " finalSubmissionKnown=" << (projectionFinalKnown ? 1 : 0) + << " finalSubmitted=" << (projectionFinalSubmitted ? 1 : 0) + << " leftFinite=" << (leftFinite ? 1 : 0) + << " leftRgb=" << left[0] << "," << left[1] << "," << left[2] + << " rightFinite=" << (rightFinite ? 1 : 0) + << " rightRgb=" << right[0] << "," << right[1] << "," << right[2] + << " mapDefers=" << projectionMapDefers << "\n"; + } + + void ConsumeTheater(ID3D11DeviceContext* context) + { + if (!theaterPending || !theaterStage || !context) return; + D3D11_MAPPED_SUBRESOURCE mapped{}; + const HRESULT hr = context->Map(theaterStage, 0, D3D11_MAP_READ, + D3D11_MAP_FLAG_DO_NOT_WAIT, &mapped); + if (hr == DXGI_ERROR_WAS_STILL_DRAWING) { ++theaterMapDefers; return; } + if (FAILED(hr) || !mapped.pData) + { + std::cerr << "[R23 pixel] theater async Map failed hr=0x" + << std::hex << static_cast(hr) << std::dec << "\n"; + theaterPending = false; + return; + } + float rgb[3]{}; + const bool decoded = R23DecodeRgb( + static_cast(mapped.pData), theaterFormat, rgb); + const bool finite = decoded && std::isfinite(rgb[0]) && + std::isfinite(rgb[1]) && std::isfinite(rgb[2]); + context->Unmap(theaterStage, 0); + theaterPending = false; + std::cout << "[R23 pixel] theater finalSubmissionKnown=" + << (theaterFinalKnown ? 1 : 0) + << " finalSubmitted=" << (theaterFinalSubmitted ? 1 : 0) + << " finite=" << (finite ? 1 : 0) + << " rgb=" << rgb[0] << "," << rgb[1] << "," << rgb[2] + << " mapDefers=" << theaterMapDefers << "\n"; + } + + void TryConsume(ID3D11DeviceContext* context) + { + ConsumeSource(context); + ConsumeProjection(context); + ConsumeTheater(context); + } + + void NoteFinalSubmission() + { + const bool submitted = + OutRunVrR23RuntimeHardening::LastSubmittedLayer.load(std::memory_order_acquire); + const std::uint32_t frameId = + OutRunVrR23RuntimeHardening::LastSubmittedFrameId.load(std::memory_order_acquire); + const auto kind = static_cast( + OutRunVrR23RuntimeHardening::LastSubmittedKind.load(std::memory_order_acquire)); + + if (projectionPending && !projectionFinalKnown && + frameId == projectionFrameId && kind == projectionKind) + { + projectionFinalKnown = true; + projectionFinalSubmitted = submitted; + } + if (theaterPending && !theaterFinalKnown && + kind == OutRunVrR23VerifiedBundle::SourceKind::None) + { + theaterFinalKnown = true; + theaterFinalSubmitted = submitted; + } + } + + void ScheduleSource(StereoCompositor& c) + { + const ULONGLONG now = GetTickCount64(); + if (sourcePending || now - lastSourceScheduleMs < R23PixelIntervalMs || + !c.source_ || !c.device_ || !c.context_) return; + UvRect uv{}; + if (!c.GetGameUv(uv)) return; + if (!EnsureStage(c.device_, sourceStage, sourceFormat, + c.sourceFormat_, R23SourceSamples)) return; + + const float xs[4]{ 0.125f, 0.375f, 0.625f, 0.875f }; + const float ys[2]{ 0.35f, 0.65f }; + UINT index = 0; + for (float yFrac : ys) + { + for (float xFrac : xs) + { + const UINT sx = std::min(c.sourceWidth_ - 1, + static_cast((uv.x + uv.w * xFrac) * c.sourceWidth_)); + const UINT sy = std::min(c.sourceHeight_ - 1, + static_cast((uv.y + uv.h * yFrac) * c.sourceHeight_)); + D3D11_BOX box{ sx, sy, 0, sx + 1, sy + 1, 1 }; + c.context_->CopySubresourceRegion(sourceStage, 0, index++, 0, 0, + c.source_, 0, &box); + } + } + sourcePending = true; + lastSourceScheduleMs = now; + std::cout << "[R23 pixel] scheduled game-UV distributed source probe uv=[" + << uv.x << "," << uv.y << "," << uv.w << "," << uv.h << "]\n"; + } + + void ScheduleProjection(StereoCompositor& c, std::uint32_t image) + { + const ULONGLONG now = GetTickCount64(); + if (projectionPending || now - lastProjectionScheduleMs < R23PixelIntervalMs || + image >= c.projection_.images.size() || !c.device_ || !c.context_) return; + if (!EnsureStage(c.device_, projectionStage, projectionFormat, + c.projection_.format, 2)) return; + ID3D11Texture2D* texture = c.projection_.images[image].texture; + if (!texture) return; + for (UINT eye = 0; eye < 2; ++eye) + { + const UINT sx = c.projection_.width / 2; + const UINT sy = c.projection_.height / 2; + D3D11_BOX box{ sx, sy, 0, sx + 1, sy + 1, 1 }; + c.context_->CopySubresourceRegion(projectionStage, 0, eye, 0, 0, + texture, D3D11CalcSubresource(0, eye, 1), &box); + } + OutRunVrR23VerifiedBundle::Snapshot verified{}; + if (OutRunVrR23VerifiedBundle::ReadFresh(verified)) + { + projectionFrameId = verified.frameId; + projectionKind = verified.kind; + } + else + { + projectionFrameId = 0; + projectionKind = OutRunVrR23VerifiedBundle::SourceKind::None; + } + projectionFinalKnown = false; + projectionFinalSubmitted = false; + projectionPending = true; + lastProjectionScheduleMs = now; + } + + void ScheduleTheater(StereoCompositor& c, std::uint32_t image) + { + const ULONGLONG now = GetTickCount64(); + if (theaterPending || now - lastTheaterScheduleMs < R23PixelIntervalMs || + image >= c.theater_.images.size() || !c.device_ || !c.context_) return; + if (!EnsureStage(c.device_, theaterStage, theaterFormat, + c.theater_.format, 1)) return; + ID3D11Texture2D* texture = c.theater_.images[image].texture; + if (!texture) return; + const UINT sx = c.theater_.width / 2; + const UINT sy = c.theater_.height / 2; + D3D11_BOX box{ sx, sy, 0, sx + 1, sy + 1, 1 }; + c.context_->CopySubresourceRegion(theaterStage, 0, 0, 0, 0, + texture, 0, &box); + theaterFinalKnown = false; + theaterFinalSubmitted = false; + theaterPending = true; + lastTheaterScheduleMs = now; + } + }; + + R23AsyncPixelProbe R23Pixels{}; + + CaptureStatus R23Capture(StereoCompositor& c, DWORD timeoutMs = 0, + bool allowInitialWarmupWait = true) + { + // DirectGpuOnly is a gameplay source policy. Menus still need their + // mono theater source; only explicit diagnostic isolation disables + // Desktop Duplication globally. + if (c.disableDesktopDuplication_) + return {}; + R23Pixels.TryConsume(c.context_); + if (!IsWindow(c.hwnd_)) + if (HWND replacement = FindGameWindow(c.gamePid_)) + c.hwnd_ = replacement; + + const HMONITOR monitorNow = IsWindow(c.hwnd_) + ? MonitorFromWindow(c.hwnd_, MONITOR_DEFAULTTONEAREST) + : nullptr; + if (!c.duplication_ || + (monitorNow && monitorNow != c.targetMonitor_)) + c.BindCaptureOutput(false); + + CaptureStatus status{}; + status.available = c.haveFrame_; + status.lastPresentQpc = c.lastCapturePresentQpc_; + status.lastPresentQpcLow = c.lastCapturePresentQpcLow_; + RECT gameRect{}; + status.fullGameClientVisible = + c.GetGameClientOutputRect(gameRect); + + if (!c.duplication_ && !c.RecreateDuplication(false)) + return status; + + DXGI_OUTDUPL_FRAME_INFO fi{}; + IDXGIResource* resource = nullptr; + if (!c.duplication_) + return status; + + const DWORD waitMs = + (c.haveFrame_ || !allowInitialWarmupWait) + ? timeoutMs : std::max(timeoutMs, 1000); + const HRESULT hr = + c.duplication_->AcquireNextFrame(waitMs, &fi, &resource); + if (hr == DXGI_ERROR_WAIT_TIMEOUT) + return status; + if (FAILED(hr) || !resource) + { + if (hr == DXGI_ERROR_ACCESS_LOST) + { + c.haveFrame_ = false; + c.stereoSourceValid_ = false; + c.BindCaptureOutput(false); + } + status.available = c.haveFrame_; + return status; + } + + bool gameRegionChanged = false; + auto intersectsGame = [&](const RECT& rect) noexcept + { + RECT intersection{}; + return status.fullGameClientVisible && + IntersectRect(&intersection, &rect, &gameRect) != FALSE && + intersection.right > intersection.left && + intersection.bottom > intersection.top; + }; + + UINT dirtyBytes = 0; + HRESULT dirtyHr = + c.duplication_->GetFrameDirtyRects( + 0, nullptr, &dirtyBytes); + if ((dirtyHr == DXGI_ERROR_MORE_DATA || + SUCCEEDED(dirtyHr)) && + dirtyBytes >= sizeof(RECT)) + { + try + { + std::vector dirty( + (dirtyBytes + sizeof(RECT) - 1) / + sizeof(RECT)); + UINT actual = dirtyBytes; + if (SUCCEEDED(c.duplication_->GetFrameDirtyRects( + static_cast( + dirty.size() * sizeof(RECT)), + dirty.data(), &actual))) + { + const UINT count = std::min( + static_cast(dirty.size()), + actual / sizeof(RECT)); + for (UINT i = 0; i < count; ++i) + { + if (intersectsGame(dirty[i])) + { + gameRegionChanged = true; + break; + } + } + } + } + catch (...) {} + } + + if (!gameRegionChanged) + { + UINT moveBytes = 0; + HRESULT moveHr = + c.duplication_->GetFrameMoveRects( + 0, nullptr, &moveBytes); + if ((moveHr == DXGI_ERROR_MORE_DATA || + SUCCEEDED(moveHr)) && + moveBytes >= sizeof(DXGI_OUTDUPL_MOVE_RECT)) + { + try + { + std::vector moves( + (moveBytes + + sizeof(DXGI_OUTDUPL_MOVE_RECT) - 1) / + sizeof(DXGI_OUTDUPL_MOVE_RECT)); + UINT actual = moveBytes; + if (SUCCEEDED( + c.duplication_->GetFrameMoveRects( + static_cast( + moves.size() * + sizeof(DXGI_OUTDUPL_MOVE_RECT)), + moves.data(), &actual))) + { + const UINT count = std::min( + static_cast(moves.size()), + actual / + sizeof(DXGI_OUTDUPL_MOVE_RECT)); + for (UINT i = 0; i < count; ++i) + { + if (intersectsGame( + moves[i].DestinationRect)) + { + gameRegionChanged = true; + break; + } + } + } + } + catch (...) {} + } + } + + if (!gameRegionChanged && + status.fullGameClientVisible && + fi.AccumulatedFrames > 1) + gameRegionChanged = true; + + ID3D11Texture2D* texture = nullptr; + const HRESULT qi = resource->QueryInterface( + __uuidof(ID3D11Texture2D), + reinterpret_cast(&texture)); + resource->Release(); + + bool copied = false; + if (SUCCEEDED(qi) && texture) + { + D3D11_TEXTURE2D_DESC d{}; + texture->GetDesc(&d); + if ((d.Format == DXGI_FORMAT_B8G8R8A8_UNORM || + d.Format == DXGI_FORMAT_R16G16B16A16_FLOAT) && + c.EnsureSource(d)) + { + c.context_->CopyResource(c.source_, texture); + copied = true; + } + texture->Release(); + } + c.duplication_->ReleaseFrame(); + + if (copied) + { + c.haveFrame_ = true; + if (fi.LastPresentTime.QuadPart != 0) + { + c.lastCapturePresentQpc_ = + fi.LastPresentTime.QuadPart; + c.lastCapturePresentQpcLow_ = + static_cast( + fi.LastPresentTime.QuadPart); + } + status.available = true; + status.fresh = fi.AccumulatedFrames > 0; + status.lastPresentQpc = + c.lastCapturePresentQpc_; + status.lastPresentQpcLow = + c.lastCapturePresentQpcLow_; + status.gameRegionChanged = gameRegionChanged; + status.accumulatedFrames = fi.AccumulatedFrames; + OutRunVrSbsCaptureOverride::PublishProductionCapture( + c.source_, c.outputDesktop_, c.targetMonitor_, + c.sdrWhiteScale_, + fi.LastPresentTime.QuadPart); + R23Pixels.ScheduleSource(c); + } + return status; + } + + CaptureStatus R23RefreshTheaterFallbackCapture(StereoCompositor& c) + { + ++R23TheaterRefreshAttempts; + const CaptureStatus status = R23Capture( + c, R23TheaterRefreshWaitMs, false); + if (status.fresh) + ++R23TheaterRefreshFresh; + + const ULONGLONG now = GetTickCount64(); + if (R23LastTheaterRefreshLogMs == 0 || + now - R23LastTheaterRefreshLogMs >= R23TheaterRefreshLogIntervalMs) + { + R23LastTheaterRefreshLogMs = now; + const ULONGLONG publishedAt = + OutRunVrSbsCaptureOverride::LastProductionPublishMs; + const long long publishAgeMs = publishedAt && now >= publishedAt + ? static_cast(now - publishedAt) : -1; + std::cout + << "[R23 mono] gameplay stereo pending: bounded Desktop Duplication refresh for theater-only fallback" + << " attempts=" << R23TheaterRefreshAttempts + << " fresh=" << R23TheaterRefreshFresh + << " available=" << (status.available ? 1 : 0) + << " captureQpc=" << status.lastPresentQpc + << " publishAgeMs=" << publishAgeMs + << " waitMs=" << R23TheaterRefreshWaitMs << "\n"; + } + return status; + } + + bool R23FrameUnchanged(RenderFrameReader& reader, + const OutRunVR::SharedRenderFrameState& before) + { + OutRunVR::SharedRenderFrameState after{}; + return reader.ReadFrame(before.frameId, after) && + (after.flags & OutRunVR::RenderFramePresentInFlight) == 0 && + after.sequence == before.sequence && + after.clientPid == before.clientPid && + after.state == before.state && after.frameId == before.frameId && + after.sourcePoseSequence == before.sourcePoseSequence && + after.presentationMode == before.presentationMode && + after.presentQpc == before.presentQpc && after.flags == before.flags && + after.failureReason == before.failureReason && + after.backbufferWidth == before.backbufferWidth && + after.backbufferHeight == before.backbufferHeight && + std::memcmp(after.eye, before.eye, sizeof(before.eye)) == 0 && + std::memcmp(after.reserved, before.reserved, sizeof(before.reserved)) == 0; + } + + + bool R23SelectClassicFrameForCapture( + RenderFrameReader& reader, const CaptureStatus& capture, + std::uint32_t lastProcessedFrame, + std::uint32_t requiredFlags, + OutRunVR::SharedRenderFrameState& selected, + std::int64_t maxSkewTicks) + { + selected = {}; + if (!capture.available || !capture.fresh || + !capture.fullGameClientVisible || + capture.lastPresentQpc <= 0) + return false; + + // R35.3: DXGI Desktop Duplication dirty/move metadata is advisory. + // Borderless D3D9Ex presents can advance LastPresentTime while the dirty + // rectangles do not intersect the OutRun client. The frame ring gives + // us a stronger correlation key: accept the capture only when a stable + // completed SBS frame exists at/before this output Present and inside + // maxSkewTicks. This prevents healthy stereo from collapsing into the + // giant recovery theater solely because dirty metadata was incomplete. + + std::array history{}; + std::size_t count = 0; + if (!reader.ReadHistory(history, count)) + return false; + + bool found = false; + std::int64_t bestPresentQpc = 0; + for (std::size_t i = 0; i < count; ++i) + { + const auto& frame = history[i]; + if ((frame.flags & OutRunVR::RenderFramePresentInFlight) != 0 || + (frame.flags & OutRunVR::RenderFrameDirectGpuTransport) != 0 || + frame.state != OutRunVR::StereoSbsActive || + !frame.frameId || + frame.frameId == lastProcessedFrame || + !frame.sourcePoseSequence || + (frame.flags & requiredFlags) != requiredFlags || + frame.presentQpc <= 0 || + frame.presentQpc > capture.lastPresentQpc) + continue; + + const std::int64_t skew = + capture.lastPresentQpc - frame.presentQpc; + if (maxSkewTicks > 0 && skew > maxSkewTicks) + continue; + + if (!found || frame.presentQpc > bestPresentQpc || + (frame.presentQpc == bestPresentQpc && + frame.frameId > selected.frameId)) + { + selected = frame; + bestPresentQpc = frame.presentQpc; + found = true; + } + } + + // The ring may advance while Desktop Duplication copies the output. + // That is fine as long as the exact frameId chosen above is still + // present and byte-for-byte stable. We no longer reject merely because + // latestSlot moved to the next game frame. + return found && R23FrameUnchanged(reader, selected); + } + + bool R23ValidateDirectResourceSize(StereoCompositor& c, + const OutRunVR::SharedRenderFrameState& frame) + { + const std::uint32_t slot = frame.reserved[OutRunVR::RenderFrameDirectSlotIndex]; + const std::uint32_t width = frame.reserved[OutRunVR::RenderFrameDirectWidthIndex]; + const std::uint32_t height = frame.reserved[OutRunVR::RenderFrameDirectHeightIndex]; + if (slot >= OutRunVR::RenderFrameRingSize || !width || !height || + width != frame.backbufferWidth || height != frame.backbufferHeight || + !c.directLeft_[slot] || !c.directRight_[slot]) return false; + D3D11_TEXTURE2D_DESC l{}, r{}; + c.directLeft_[slot]->GetDesc(&l); c.directRight_[slot]->GetDesc(&r); + return l.Width == width && l.Height == height && r.Width == width && r.Height == height && + l.Format == r.Format && l.SampleDesc.Count == 1 && r.SampleDesc.Count == 1; + } + + void R23InvalidateDirect(StereoCompositor& c) + { + c.directFrameValid_ = false; + c.directTransportReady_ = false; + R23InvalidateDirectHold(); + } + + bool R23CommitDirectAfterValidation(StereoCompositor& c, + const OutRunVR::SharedRenderFrameState& frame) + { + if (!c.PrepareDirectStereoSource(frame) || + !R23ValidateDirectResourceSize(c, frame) || + !R23StageDirectHold(c, frame)) + { + R23InvalidateDirect(c); + return false; + } + c.directTransportReady_ = true; + c.directFrameValid_ = true; + c.stereoSourceValid_ = false; + return true; + } + + bool R23CommitClassicAfterValidation(StereoCompositor& c) + { + c.directFrameValid_ = false; + if (!c.CommitStereoSource()) return false; + c.stereoSourceValid_ = true; + return true; + } + + bool R23RenderProjection(StereoCompositor& c, + const std::array& views, + std::array& pv) + { + R23Pixels.TryConsume(c.context_); + if (!c.HasStereoSource()) return false; + UvRect eyes[2]{}; + ID3D11ShaderResourceView* srv[2]{}; + DXGI_FORMAT fmt[2]{ DXGI_FORMAT_UNKNOWN, DXGI_FORMAT_UNKNOWN }; + if (c.directFrameValid_ && R23DirectHold.valid && + R23DirectHold.srv[0] && R23DirectHold.srv[1]) + { + eyes[0] = eyes[1] = { 0.f, 0.f, 1.f, 1.f }; + srv[0] = R23DirectHold.srv[0]; + srv[1] = R23DirectHold.srv[1]; + fmt[0] = fmt[1] = R23DirectHold.format; + } + else + { + UvRect whole{}; + if (!c.GetGameUv(whole)) return false; + eyes[0] = { whole.x, whole.y, whole.w * 0.5f, whole.h }; + eyes[1] = { whole.x + whole.w * 0.5f, whole.y, whole.w * 0.5f, whole.h }; + srv[0] = srv[1] = c.stereoSourceSrv_; + fmt[0] = fmt[1] = c.stereoSourceFormat_; + } + + std::uint32_t image = 0; + c.Acquire(c.projection_, image); + bool ok = c.RenderTo(c.projection_.rtvs[image][0], c.projection_.width, + c.projection_.height, eyes[0], srv[0], fmt[0]); + ok = c.RenderTo(c.projection_.rtvs[image][1], c.projection_.width, + c.projection_.height, eyes[1], srv[1], fmt[1]) && ok; + if (ok) R23Pixels.ScheduleProjection(c, image); + c.Release(c.projection_); + if (!ok) return false; + + for (int eye = 0; eye < 2; ++eye) + { + pv[eye] = { XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW }; + pv[eye].pose = views[eye].pose; + pv[eye].fov = views[eye].fov; + pv[eye].subImage.swapchain = c.projection_.handle; + pv[eye].subImage.imageRect.offset = { 0, 0 }; + pv[eye].subImage.imageRect.extent = { + static_cast(c.projection_.width), + static_cast(c.projection_.height) }; + pv[eye].subImage.imageArrayIndex = eye; + } + return true; + } + + bool R23RenderWorldLockedMenuProjection( + StereoCompositor& c, const std::array& views, + const XrPosef& menuAnchor, + std::array& pv) + { + R23Pixels.TryConsume(c.context_); + if (!c.haveFrame_ || !c.sourceSrv_) return false; + UvRect whole{}; + if (!c.GetGameUv(whole)) return false; + + XrFovf commonFov{}; + commonFov.angleLeft = 0.5f * + (views[0].fov.angleLeft + views[1].fov.angleLeft); + commonFov.angleRight = 0.5f * + (views[0].fov.angleRight + views[1].fov.angleRight); + commonFov.angleUp = 0.5f * + (views[0].fov.angleUp + views[1].fov.angleUp); + commonFov.angleDown = 0.5f * + (views[0].fov.angleDown + views[1].fov.angleDown); + + const float tanLeft = std::tan(commonFov.angleLeft); + const float tanRight = std::tan(commonFov.angleRight); + const float tanDown = std::tan(commonFov.angleDown); + const float tanUp = std::tan(commonFov.angleUp); + const float spanX = tanRight - tanLeft; + const float spanY = tanUp - tanDown; + if (!std::isfinite(spanX) || !std::isfinite(spanY) || + spanX <= 0.05f || spanY <= 0.05f) + return false; + + RECT cr{}; + GetClientRect(c.hwnd_, &cr); + const float sourceAspect = (cr.bottom > cr.top) + ? static_cast(cr.right - cr.left) / + static_cast(cr.bottom - cr.top) + : 16.0f / 9.0f; + const float fovAspect = spanX / spanY; + if (!std::isfinite(sourceAspect) || !std::isfinite(fovAspect) || + sourceAspect <= 0.1f || fovAspect <= 0.1f) + return false; + + // R41: preserve the desktop/menu aspect inside the HMD projection. + // This is a pure 2D clip-space rectangle shared by both eyes; unlike + // R39 there is no world plane, head pose or per-eye disparity to shear. + float halfWidth = 1.0f; + float halfHeight = 1.0f; + if (sourceAspect >= fovAspect) + halfHeight = std::clamp(fovAspect / sourceAspect, 0.05f, 1.0f); + else + halfWidth = std::clamp(sourceAspect / fovAspect, 0.05f, 1.0f); + const float sx[4]{ -halfWidth, halfWidth, -halfWidth, halfWidth }; + const float sy[4]{ halfHeight, halfHeight, -halfHeight, -halfHeight }; + float clip[4][4]{}; + for (int corner = 0; corner < 4; ++corner) + { + clip[corner][0] = sx[corner]; + clip[corner][1] = sy[corner]; + clip[corner][2] = 0.5f; + clip[corner][3] = 1.0f; + } + + std::uint32_t image = 0; + c.Acquire(c.projection_, image); + bool ok = image < c.projection_.rtvs.size(); + for (int eye = 0; eye < 2 && ok; ++eye) + { + ok = c.RenderMenuPlaneTo( + c.projection_.rtvs[image][eye], + c.projection_.width, c.projection_.height, + whole, c.sourceSrv_, c.sourceFormat_, clip); + } + if (ok) R23Pixels.ScheduleProjection(c, image); + c.Release(c.projection_); + if (!ok) return false; + + // R45: both eyes share the same LOCAL-space camera anchor (zero virtual + // IPD), captured from the HMD center when entering a mono menu or when + // a recenter/reference-space request is applied. R43 used identity LOCAL + // pose, which was world-fixed but had nothing for F12 to update. + for (int eye = 0; eye < 2; ++eye) + { + pv[eye] = { XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW }; + pv[eye].pose = menuAnchor; + pv[eye].fov = commonFov; + pv[eye].subImage.swapchain = c.projection_.handle; + pv[eye].subImage.imageRect.offset = { 0, 0 }; + pv[eye].subImage.imageRect.extent = { + static_cast(c.projection_.width), + static_cast(c.projection_.height) }; + pv[eye].subImage.imageArrayIndex = eye; + } + return true; + } + + bool R23RenderTheater(StereoCompositor& c, XrSpace viewSpace, + XrSpace localSpace, XrTime displayTime, XrCompositionLayerQuad& quad, + bool sourceIsSbs) + { + R23Pixels.TryConsume(c.context_); + if (!c.haveFrame_ || !c.sourceSrv_) return false; + UvRect whole{}; + if (!c.GetGameUv(whole)) return false; + UvRect theaterUv = whole; + if (sourceIsSbs) + theaterUv.w *= 0.5f; + if (theaterUv.w <= 0.0f) + return false; + std::uint32_t image = 0; + c.Acquire(c.theater_, image); + const bool ok = c.RenderTo(c.theater_.rtvs[image][0], c.theater_.width, + c.theater_.height, theaterUv, c.sourceSrv_, c.sourceFormat_); + if (ok) R23Pixels.ScheduleTheater(c, image); + c.Release(c.theater_); + if (!ok || !c.EnsureTheaterAnchor(viewSpace, localSpace, displayTime)) return false; + + quad = { XR_TYPE_COMPOSITION_LAYER_QUAD }; + quad.space = localSpace; + quad.eyeVisibility = XR_EYE_VISIBILITY_BOTH; + quad.pose = c.theaterAnchor_; + quad.subImage.swapchain = c.theater_.handle; + quad.subImage.imageRect.offset = { 0, 0 }; + quad.subImage.imageRect.extent = { + static_cast(c.theater_.width), static_cast(c.theater_.height) }; + RECT cr{}; + GetClientRect(c.hwnd_, &cr); + const float aspect = (cr.bottom > cr.top) + ? (static_cast(cr.right - cr.left) / + static_cast(cr.bottom - cr.top)) * + (sourceIsSbs ? 0.5f : 1.0f) + : 16.f / 9.f; + quad.size.width = 2.f; + quad.size.height = 2.f / aspect; + return true; + } + + void R23CopyMatchedViews(const OutRunVR::SharedRenderFrameState& frame, + std::array& matchedViews) + { + for (int eye = 0; eye < 2; ++eye) + { + matchedViews[eye] = { XR_TYPE_VIEW }; + matchedViews[eye].pose.orientation = { + frame.eye[eye].orientation[0], frame.eye[eye].orientation[1], + frame.eye[eye].orientation[2], frame.eye[eye].orientation[3] }; + matchedViews[eye].pose.position = { + frame.eye[eye].position[0], frame.eye[eye].position[1], frame.eye[eye].position[2] }; + matchedViews[eye].fov = { + frame.eye[eye].fov.angleLeft, frame.eye[eye].fov.angleRight, + frame.eye[eye].fov.angleUp, frame.eye[eye].fov.angleDown }; + } + } + + int R35ReadEnvInt(const char* name, int fallback) noexcept + { + const char* value = std::getenv(name); + if (!value || !*value) + return fallback; + char* end = nullptr; + const long parsed = std::strtol(value, &end, 10); + return end && end != value ? static_cast(parsed) : fallback; + } + + float R35ReadEnvFloat(const char* name, float fallback) noexcept + { + const char* value = std::getenv(name); + if (!value || !*value) + return fallback; + char* end = nullptr; + const float parsed = std::strtof(value, &end); + return end && end != value && std::isfinite(parsed) + ? parsed : fallback; + } + + class R35CadenceHost + { + public: + struct Request + { + std::uint32_t id = 0; + bool issued = false; + }; + + R35CadenceHost(int mode, float targetHz, float maxRenderHz, + std::uint32_t timeoutMs, float requestedRefreshHz) + : mode_(std::clamp(mode, 0, 2)), + configuredTargetHz_(std::clamp(targetHz, 0.0f, 120.0f)), + maxRenderHz_(std::clamp(maxRenderHz, 60.0f, 120.0f)), + timeoutMs_(std::clamp(timeoutMs, 5u, 100u)), + requestedRefreshHz_(std::max(0.0f, requestedRefreshHz)) + { + if (mode_ == 0) + return; + + hostMapping_ = CreateFileMappingW( + INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE, 0, + static_cast(sizeof(OutRunVR::CadenceV1::HostState)), + OutRunVR::CadenceV1::HostStateName); + if (!hostMapping_) + throw std::runtime_error("CreateFileMappingW Cadence.v1 host failed"); + host_ = static_cast( + MapViewOfFile(hostMapping_, FILE_MAP_ALL_ACCESS, 0, 0, + sizeof(OutRunVR::CadenceV1::HostState))); + if (!host_) + throw std::runtime_error("MapViewOfFile Cadence.v1 host failed"); + + requestEvent_ = CreateEventW( + nullptr, FALSE, FALSE, OutRunVR::CadenceV1::RequestEventName); + presentedEvent_ = CreateEventW( + nullptr, FALSE, FALSE, OutRunVR::CadenceV1::PresentedEventName); + if (!requestEvent_ || !presentedEvent_) + throw std::runtime_error("CreateEventW Cadence.v1 failed"); + + std::memset(host_, 0, sizeof(*host_)); + host_->version = OutRunVR::CadenceV1::ProtocolVersion; + host_->structSize = sizeof(*host_); + host_->hostPid = GetCurrentProcessId(); + MemoryBarrier(); + host_->magic = OutRunVR::CadenceV1::HostMagic; + Publish(); + } + + ~R35CadenceHost() + { + if (host_) + { + running_ = false; + enabled_ = false; + Publish(); + UnmapViewOfFile(host_); + } + if (client_) + UnmapViewOfFile(client_); + if (clientMapping_) + CloseHandle(clientMapping_); + if (requestEvent_) + CloseHandle(requestEvent_); + if (presentedEvent_) + CloseHandle(presentedEvent_); + if (hostMapping_) + CloseHandle(hostMapping_); + } + + bool Enabled() const noexcept { return mode_ != 0 && enabled_; } + int Mode() const noexcept { return mode_; } + float TargetHz() const noexcept { return activeRenderHz_; } + std::uint32_t TimeoutMs() const noexcept { return timeoutMs_; } + + void SetRunning(bool running) noexcept + { + running_ = running; + enabled_ = mode_ != 0; + Publish(); + } + + static float QuantizeRuntimeHz(double hz) noexcept + { + constexpr float modes[]{ 60.0f, 72.0f, 80.0f, 90.0f, 120.0f }; + float best = modes[0]; + double bestError = std::fabs(hz - static_cast(best)); + for (float mode : modes) + { + const double error = + std::fabs(hz - static_cast(mode)); + if (error < bestError) + { + best = mode; + bestError = error; + } + } + return bestError <= 4.0 ? best : + static_cast(std::clamp(hz, 60.0, 120.0)); + } + + void UpdateActiveRenderHz() noexcept + { + float desired = configuredTargetHz_ > 0.0f + ? configuredTargetHz_ + : QuantizeRuntimeHz(actualXrHz_); + desired = std::min(desired, maxRenderHz_); + if (desired <= 0.0f || !std::isfinite(desired)) + return; + + if (activeRenderHz_ <= 0.0f) + { + activeRenderHz_ = desired; + nextRequestDisplayTime_ = 0; + return; + } + + if (std::fabs(activeRenderHz_ - desired) < 0.5f) + { + pendingRenderHz_ = 0.0f; + pendingRenderHzSamples_ = 0; + return; + } + + if (std::fabs(pendingRenderHz_ - desired) < 0.5f) + ++pendingRenderHzSamples_; + else + { + pendingRenderHz_ = desired; + pendingRenderHzSamples_ = 1; + } + + // About a quarter second at common Quest rates. This filters + // xrWaitFrame period noise but still follows a deliberate VD + // 72/80/90/120 Hz mode switch quickly. + if (pendingRenderHzSamples_ >= 20) + { + activeRenderHz_ = pendingRenderHz_; + pendingRenderHz_ = 0.0f; + pendingRenderHzSamples_ = 0; + nextRequestDisplayTime_ = 0; + std::cout + << "[R36 cadence] activeRenderHz=" << activeRenderHz_ + << " actualXrHz=" << actualXrHz_ + << " maxRenderHz=" << maxRenderHz_ + << " policy=" + << (configuredTargetHz_ > 0.0f ? "fixed" : "auto-native") + << "\n"; + } + } + + Request IssueIfDue(const XrFrameState& frame, + std::uint32_t poseSequence) noexcept + { + ++intervalXrFrames_; + ObserveRuntimeRefresh(frame.predictedDisplayPeriod); + UpdateActiveRenderHz(); + if (!Enabled() || !running_ || + frame.predictedDisplayPeriod <= 0 || + frame.predictedDisplayTime <= 0 || + activeRenderHz_ <= 0.0f) + return { requestId_, false }; + + // In Auto mode, if the cap permits the runtime's native mode, + // release exactly one game render per xrWaitFrame. This avoids + // 60->90 3:2 cadence and the accumulated rounding drift of an + // independently generated target timeline. + const float nativeHz = QuantizeRuntimeHz(actualXrHz_); + const bool nativeOneToOne = + configuredTargetHz_ <= 0.0f && + maxRenderHz_ + 0.5f >= nativeHz && + std::fabs(activeRenderHz_ - nativeHz) < 0.5f; + + if (!nativeOneToOne) + { + const XrTime targetPeriod = static_cast( + std::llround(1000000000.0 / + static_cast(activeRenderHz_))); + if (nextRequestDisplayTime_ == 0) + nextRequestDisplayTime_ = frame.predictedDisplayTime; + + if (frame.predictedDisplayTime < nextRequestDisplayTime_) + return { requestId_, false }; + + do + { + nextRequestDisplayTime_ += targetPeriod; + } while (nextRequestDisplayTime_ <= frame.predictedDisplayTime); + } + + if (++requestId_ == 0) + ++requestId_; + poseSequence_ = poseSequence; + predictedDisplayTime_ = frame.predictedDisplayTime; + predictedDisplayPeriod_ = frame.predictedDisplayPeriod; + LARGE_INTEGER now{}; + QueryPerformanceCounter(&now); + requestQpc_ = now.QuadPart; + Publish(); + SetEvent(requestEvent_); + ++intervalRequests_; + return { requestId_, true }; + } + + double WaitForPresented(const Request& request, + const XrFrameState& frame) noexcept + { + if (mode_ < 2 || !request.issued || !request.id) + return 0.0; + + OutRunVR::CadenceV1::ClientState client{}; + if (ReadClient(client) && + client.presentedRequestId == request.id) + return 0.0; + + const double periodMs = frame.predictedDisplayPeriod > 0 + ? static_cast(frame.predictedDisplayPeriod) / 1000000.0 + : 0.0; + const DWORD budgetMs = static_cast(std::clamp( + periodMs > 0.0 ? periodMs * 0.65 : 2.0, + 1.0, static_cast(timeoutMs_))); + + LARGE_INTEGER start{}, end{}, frequency{}; + QueryPerformanceCounter(&start); + QueryPerformanceFrequency(&frequency); + WaitForSingleObject(presentedEvent_, budgetMs); + QueryPerformanceCounter(&end); + const double waitedMs = + frequency.QuadPart > 0 && end.QuadPart >= start.QuadPart + ? static_cast(end.QuadPart - start.QuadPart) * + 1000.0 / static_cast(frequency.QuadPart) + : 0.0; + + ++intervalSerialWaitCount_; + intervalSerialWaitMs_ += waitedMs; + intervalSerialWaitMaxMs_ = + std::max(intervalSerialWaitMaxMs_, waitedMs); + + if (!ReadClient(client) || + client.presentedRequestId != request.id) + ++intervalSerialTimeouts_; + else + ++intervalSerializedMatches_; + return waitedMs; + } + + void NoteFrame(bool freshProjection, bool cachedProjection, + bool layerReady, std::uint32_t candidateRequestId) noexcept + { + if (freshProjection) + ++intervalFresh_; + else if (cachedProjection) + ++intervalCached_; + else if (!layerReady) + ++intervalNoLayer_; + else + ++intervalOtherLayer_; + + lastCandidateRequestId_ = candidateRequestId; + if (candidateRequestId) + { + ++intervalTaggedCandidates_; + if (candidateRequestId == requestId_) + ++intervalLatestRequestMatches_; + } + } + + void AppendAndReset(std::ostringstream& out) + { + OutRunVR::CadenceV1::ClientState client{}; + const bool haveClient = ReadClient(client); + const double ratio = activeRenderHz_ > 0.0f + ? actualXrHz_ / static_cast(activeRenderHz_) : 0.0; + const double nearestInteger = std::round(ratio); + const bool integerCadence = ratio >= 1.0 && + nearestInteger >= 1.0 && + std::fabs(ratio - nearestInteger) <= 0.02; + out << " cadence={mode:" << mode_ + << ",configuredTargetHz:" << configuredTargetHz_ + << ",maxRenderHz:" << maxRenderHz_ + << ",activeRenderHz:" << activeRenderHz_ + << ",requestedRefreshHz:" << requestedRefreshHz_ + << ",actualXrHz:" << actualXrHz_ + << ",xrPeriodMs:" << actualDisplayPeriodMs_ + << ",xrGameRatio:" << ratio + << ",ratioKind:" << (integerCadence ? "integer" : "fractional") + << ",xr:" << intervalXrFrames_ + << ",req:" << intervalRequests_ + << ",fresh:" << intervalFresh_ + << ",cached:" << intervalCached_ + << ",other:" << intervalOtherLayer_ + << ",noLayer:" << intervalNoLayer_ + << ",tagged:" << intervalTaggedCandidates_ + << ",latestMatch:" << intervalLatestRequestMatches_ + << ",latestReq:" << requestId_ + << ",candidateReq:" << lastCandidateRequestId_ + << ",clientAccepted:" + << (haveClient ? client.acceptedRequestId : 0u) + << ",clientPresented:" + << (haveClient ? client.presentedRequestId : 0u) + << ",clientTimeouts:" + << (haveClient ? client.timeoutCount : 0u) + << ",clientWaitUs:" + << (haveClient ? client.lastWaitUs : 0u) + << ",serialMatch:" << intervalSerializedMatches_ + << ",serialTimeout:" << intervalSerialTimeouts_ + << ",serialWaitAvgMax:"; + const double avg = intervalSerialWaitCount_ + ? intervalSerialWaitMs_ / + static_cast(intervalSerialWaitCount_) + : 0.0; + out << avg << "/" << intervalSerialWaitMaxMs_ << "}"; + + intervalXrFrames_ = 0; + intervalRequests_ = 0; + intervalFresh_ = 0; + intervalCached_ = 0; + intervalOtherLayer_ = 0; + intervalNoLayer_ = 0; + intervalTaggedCandidates_ = 0; + intervalLatestRequestMatches_ = 0; + intervalSerializedMatches_ = 0; + intervalSerialTimeouts_ = 0; + intervalSerialWaitCount_ = 0; + intervalSerialWaitMs_ = 0.0; + intervalSerialWaitMaxMs_ = 0.0; + } + + private: + void ObserveRuntimeRefresh(XrDuration predictedDisplayPeriod) noexcept + { + if (predictedDisplayPeriod <= 0) + return; + const double periodMs = + static_cast(predictedDisplayPeriod) / 1000000.0; + const double hz = + 1000000000.0 / static_cast(predictedDisplayPeriod); + if (!std::isfinite(periodMs) || !std::isfinite(hz) || + hz < 30.0 || hz > 180.0) + return; + + actualDisplayPeriodMs_ = periodMs; + // Small EWMA filters occasional runtime period noise without hiding + // a real 72/80/90/120 Hz mode switch. + actualXrHz_ = actualXrHz_ > 0.0 + ? actualXrHz_ * 0.90 + hz * 0.10 + : hz; + + if (lastLoggedXrHz_ <= 0.0 || + std::fabs(actualXrHz_ - lastLoggedXrHz_) >= 0.75) + { + lastLoggedXrHz_ = actualXrHz_; + const double ratio = activeRenderHz_ > 0.0f + ? actualXrHz_ / static_cast(activeRenderHz_) : 0.0; + const double nearestInteger = std::round(ratio); + const bool integerCadence = ratio >= 1.0 && + nearestInteger >= 1.0 && + std::fabs(ratio - nearestInteger) <= 0.02; + std::cout + << "[R35 refresh] actualXrHz=" << actualXrHz_ + << " displayPeriodMs=" << actualDisplayPeriodMs_ + << " requestedOverrideHz=" << requestedRefreshHz_ + << " configuredTargetHz=" << configuredTargetHz_ + << " maxRenderHz=" << maxRenderHz_ + << " activeRenderHz=" << activeRenderHz_ + << " ratio=" << ratio + << " ratioKind=" + << (integerCadence ? "integer" : "fractional") + << "\n"; + } + } + + void Publish() noexcept + { + if (!host_) + return; + LONG seq = InterlockedIncrement( + reinterpret_cast(&host_->sequence)); + if ((seq & 1) == 0) + InterlockedIncrement( + reinterpret_cast(&host_->sequence)); + MemoryBarrier(); + host_->magic = OutRunVR::CadenceV1::HostMagic; + host_->version = OutRunVR::CadenceV1::ProtocolVersion; + host_->structSize = sizeof(*host_); + host_->hostPid = GetCurrentProcessId(); + host_->flags = + (enabled_ ? OutRunVR::CadenceV1::HostEnabled : 0u) | + (running_ ? OutRunVR::CadenceV1::HostRunning : 0u) | + (mode_ >= 2 + ? OutRunVR::CadenceV1::HostSerializedProbe : 0u); + host_->requestId = requestId_; + host_->poseSequence = poseSequence_; + host_->targetHzMilli = static_cast( + std::lround(std::max(0.0f, activeRenderHz_) * 1000.0f)); + host_->timeoutMs = timeoutMs_; + host_->requestQpc = requestQpc_; + host_->predictedDisplayTime = predictedDisplayTime_; + host_->predictedDisplayPeriod = predictedDisplayPeriod_; + MemoryBarrier(); + seq = InterlockedIncrement( + reinterpret_cast(&host_->sequence)); + if (seq & 1) + InterlockedIncrement( + reinterpret_cast(&host_->sequence)); + } + + bool EnsureClient() noexcept + { + if (client_) + return true; + clientMapping_ = OpenFileMappingW( + FILE_MAP_READ, FALSE, OutRunVR::CadenceV1::ClientStateName); + if (!clientMapping_) + return false; + client_ = static_cast( + MapViewOfFile(clientMapping_, FILE_MAP_READ, 0, 0, + sizeof(OutRunVR::CadenceV1::ClientState))); + if (!client_) + { + CloseHandle(clientMapping_); + clientMapping_ = nullptr; + return false; + } + return true; + } + + bool ReadClient(OutRunVR::CadenceV1::ClientState& out) noexcept + { + if (!EnsureClient()) + return false; + for (int attempt = 0; attempt < 4; ++attempt) + { + const std::uint32_t before = client_->sequence; + if (before & 1u) + continue; + MemoryBarrier(); + std::memcpy(&out, client_, sizeof(out)); + MemoryBarrier(); + const std::uint32_t after = client_->sequence; + if (before == after && !(after & 1u) && + out.magic == OutRunVR::CadenceV1::ClientMagic && + out.version == OutRunVR::CadenceV1::ProtocolVersion && + out.structSize == sizeof(out)) + return true; + } + return false; + } + + int mode_ = 0; + float configuredTargetHz_ = 0.0f; + float maxRenderHz_ = 120.0f; + float activeRenderHz_ = 0.0f; + float pendingRenderHz_ = 0.0f; + std::uint32_t pendingRenderHzSamples_ = 0; + std::uint32_t timeoutMs_ = 35; + float requestedRefreshHz_ = 0.0f; + double actualXrHz_ = 0.0; + double actualDisplayPeriodMs_ = 0.0; + double lastLoggedXrHz_ = 0.0; + bool enabled_ = false; + bool running_ = false; + HANDLE hostMapping_ = nullptr; + OutRunVR::CadenceV1::HostState* host_ = nullptr; + HANDLE clientMapping_ = nullptr; + const OutRunVR::CadenceV1::ClientState* client_ = nullptr; + HANDLE requestEvent_ = nullptr; + HANDLE presentedEvent_ = nullptr; + std::uint32_t requestId_ = 0; + std::uint32_t poseSequence_ = 0; + std::int64_t requestQpc_ = 0; + XrTime predictedDisplayTime_ = 0; + XrDuration predictedDisplayPeriod_ = 0; + XrTime nextRequestDisplayTime_ = 0; + + std::uint64_t intervalXrFrames_ = 0; + std::uint64_t intervalRequests_ = 0; + std::uint64_t intervalFresh_ = 0; + std::uint64_t intervalCached_ = 0; + std::uint64_t intervalOtherLayer_ = 0; + std::uint64_t intervalNoLayer_ = 0; + std::uint64_t intervalTaggedCandidates_ = 0; + std::uint64_t intervalLatestRequestMatches_ = 0; + std::uint64_t intervalSerializedMatches_ = 0; + std::uint64_t intervalSerialTimeouts_ = 0; + std::uint64_t intervalSerialWaitCount_ = 0; + double intervalSerialWaitMs_ = 0.0; + double intervalSerialWaitMaxMs_ = 0.0; + std::uint32_t lastCandidateRequestId_ = 0; + }; + +} + +int main(int argc, char** argv) +{ + XrInstance instance = XR_NULL_HANDLE; + XrSession session = XR_NULL_HANDLE; + XrSpace localSpace = XR_NULL_HANDLE; + XrSpace viewSpace = XR_NULL_HANDLE; + + try + { + SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + ParseRuntimeOverride(argc, argv); + const float renderScale = ReadRenderScale(argc, argv); + const bool directTransportEnabled = DirectTransportEnabled(); + const bool directTransportOnly = + directTransportEnabled && DirectTransportOnly(); + const bool disableDesktopDuplication = DisableDesktopDuplication(); + const float targetRefreshRateHz = RequestedRefreshRateHz(); + const int cadenceMode = std::clamp( + R35ReadEnvInt("OUTRUN_VR_CADENCE_MODE", 1), 0, 2); + const float cadenceTargetHz = std::clamp( + R35ReadEnvFloat("OUTRUN_VR_CADENCE_TARGET_HZ", 0.0f), + 0.0f, 120.0f); + const float cadenceMaxHz = std::clamp( + R35ReadEnvFloat("OUTRUN_VR_CADENCE_MAX_HZ", 120.0f), + 60.0f, 120.0f); + const std::uint32_t cadenceTimeoutMs = + static_cast(std::clamp( + R35ReadEnvFloat("OUTRUN_VR_CADENCE_TIMEOUT_MS", 35.0f), + 5.0f, 100.0f)); + HWND gameWindow = WaitForGameWindow(); + DWORD gamePid = 0; GetWindowThreadProcessId(gameWindow, &gamePid); + + if (!HasExtension(XR_KHR_D3D11_ENABLE_EXTENSION_NAME)) + throw std::runtime_error("runtime lacks XR_KHR_D3D11_enable"); + + std::vector extensions{ + XR_KHR_D3D11_ENABLE_EXTENSION_NAME + }; + bool displayRefreshExtensionEnabled = false; +#ifdef XR_FB_display_refresh_rate + if (targetRefreshRateHz > 0.0f && + HasExtension(XR_FB_DISPLAY_REFRESH_RATE_EXTENSION_NAME)) + { + extensions.push_back( + XR_FB_DISPLAY_REFRESH_RATE_EXTENSION_NAME); + displayRefreshExtensionEnabled = true; + } +#endif + + XrInstanceCreateInfo ii{ XR_TYPE_INSTANCE_CREATE_INFO }; + strncpy_s(ii.applicationInfo.applicationName, sizeof(ii.applicationInfo.applicationName), + "OutRun 2006 True Stereo VR", _TRUNCATE); + ii.applicationInfo.applicationVersion = 1; + strncpy_s(ii.applicationInfo.engineName, sizeof(ii.applicationInfo.engineName), + "OutRun2006Tweaks", _TRUNCATE); + ii.applicationInfo.engineVersion = 1; + ii.applicationInfo.apiVersion = XR_CURRENT_API_VERSION; + ii.enabledExtensionCount = + static_cast(extensions.size()); + ii.enabledExtensionNames = extensions.data(); + CheckXr(xrCreateInstance(&ii, &instance), "xrCreateInstance"); + + XrInstanceProperties ip{ XR_TYPE_INSTANCE_PROPERTIES }; + CheckXr(xrGetInstanceProperties(instance, &ip), "xrGetInstanceProperties"); + std::cout << "OpenXR runtime: " << ip.runtimeName << " " + << XR_VERSION_MAJOR(ip.runtimeVersion) << "." + << XR_VERSION_MINOR(ip.runtimeVersion) << "." + << XR_VERSION_PATCH(ip.runtimeVersion) << "\n"; + + XrSystemGetInfo sgi{ XR_TYPE_SYSTEM_GET_INFO }; + sgi.formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY; + XrSystemId system = XR_NULL_SYSTEM_ID; + for (;;) + { + const XrResult r = xrGetSystem(instance, &sgi, &system); + if (r == XR_ERROR_FORM_FACTOR_UNAVAILABLE) { Sleep(1000); continue; } + CheckXr(r, "xrGetSystem"); break; + } + + PFN_xrGetD3D11GraphicsRequirementsKHR getReq = nullptr; + CheckXr(xrGetInstanceProcAddr(instance, "xrGetD3D11GraphicsRequirementsKHR", + reinterpret_cast(&getReq)), "xrGetInstanceProcAddr"); + XrGraphicsRequirementsD3D11KHR req{ XR_TYPE_GRAPHICS_REQUIREMENTS_D3D11_KHR }; + CheckXr(getReq(instance, system, &req), "xrGetD3D11GraphicsRequirementsKHR"); + D3DObjects d3d = CreateD3D11Device(req); + + XrGraphicsBindingD3D11KHR binding{ XR_TYPE_GRAPHICS_BINDING_D3D11_KHR }; + binding.device = d3d.device; + XrSessionCreateInfo si{ XR_TYPE_SESSION_CREATE_INFO }; + si.next = &binding; si.systemId = system; + CheckXr(xrCreateSession(instance, &si, &session), "xrCreateSession"); + + std::cout + << "VR transport: directEnabled=" + << (directTransportEnabled ? 1 : 0) + << " directOnly=" << (directTransportOnly ? 1 : 0) + << " refreshOverrideHz=" << targetRefreshRateHz + << " refreshPolicy=" + << (targetRefreshRateHz > 0.0f ? "explicit-request" : "runtime-owned") + << " cadenceMode=" << cadenceMode + << " cadenceTargetHz=" << cadenceTargetHz + << " cadenceMaxHz=" << cadenceMaxHz + << " cadenceTimeoutMs=" << cadenceTimeoutMs + << "\n"; +#ifdef XR_FB_display_refresh_rate + if (displayRefreshExtensionEnabled) + TryRequestDisplayRefreshRate( + instance, session, targetRefreshRateHz); + else if (targetRefreshRateHz > 0.0f) + std::cout + << "XR_FB_display_refresh_rate unavailable; runtime refresh rate unchanged.\n"; +#else + if (targetRefreshRateHz > 0.0f) + std::cout + << "OpenXR headers do not expose XR_FB_display_refresh_rate; runtime refresh rate unchanged.\n"; +#endif + + XrPosef identity{}; identity.orientation.w = 1; + XrReferenceSpaceCreateInfo li{ XR_TYPE_REFERENCE_SPACE_CREATE_INFO }; + li.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL; li.poseInReferenceSpace = identity; + CheckXr(xrCreateReferenceSpace(session, &li, &localSpace), "xrCreateReferenceSpace LOCAL"); + XrReferenceSpaceCreateInfo vi{ XR_TYPE_REFERENCE_SPACE_CREATE_INFO }; + vi.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_VIEW; vi.poseInReferenceSpace = identity; + CheckXr(xrCreateReferenceSpace(session, &vi, &viewSpace), "xrCreateReferenceSpace VIEW"); + + std::uint32_t cc = 0; + CheckXr(xrEnumerateViewConfigurationViews(instance, system, + XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, 0, &cc, nullptr), + "xrEnumerateViewConfigurationViews count"); + if (cc < 2) throw std::runtime_error("runtime has fewer than two stereo views"); + std::vector cv(cc); + for (auto& c : cv) c = { XR_TYPE_VIEW_CONFIGURATION_VIEW }; + CheckXr(xrEnumerateViewConfigurationViews(instance, system, + XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, cc, &cc, cv.data()), + "xrEnumerateViewConfigurationViews list"); + std::array configs{ cv[0], cv[1] }; + + SharedWriter shared(req.adapterLuid); + R35CadenceHost cadence(cadenceMode, cadenceTargetHz, + cadenceMaxHz, cadenceTimeoutMs, targetRefreshRateHz); + RenderFrameReader renderFrames; + StereoCompositor compositor(session, d3d.device, d3d.context, gameWindow, + configs, directTransportEnabled, directTransportOnly, + disableDesktopDuplication, renderScale); + compositor.Initialize(); + ViewHistory viewHistory; + HostTimings timings; + std::ofstream pipelineLog("outrun-vr-host-pipeline.log", + std::ios::out | std::ios::trunc); + LARGE_INTEGER qpcFrequency{}; + QueryPerformanceFrequency(&qpcFrequency); + ULONGLONG lastPipelineTelemetryMs = 0; + R23PipelineWindow pipelineWindow; + const XrEnvironmentBlendMode blend = ChooseBlendMode(instance, system); + + bool running = false, quit = false, exitRequested = false; + ULONGLONG exitRequestMs = 0; + XrSessionState state = XR_SESSION_STATE_UNKNOWN; + LARGE_INTEGER lastXrWaitReturnQpc{}; + OutRunVR::ClientPresentationMode lastPresentation = OutRunVR::PresentationUnknown; + std::array matchedViews{}; + bool matchedStereoValid = false; + std::uint32_t lastProcessedStereoFrame = 0; + ULONGLONG lastStereoMatchMs = 0; + std::array + cachedProjectionViews{}; + bool cachedProjectionValid = false; + ULONGLONG cachedProjectionRenderedMs = 0; + std::array + cachedMenuProjectionViews{}; + bool cachedMenuProjectionValid = false; + bool awaitingFirstGameplayStereo = false; + XrPosef menuProjectionAnchor{}; + menuProjectionAnchor.orientation.w = 1.0f; + bool menuProjectionAnchorValid = false; + bool refreshMenuAnchorAfterLocate = true; + bool pendingReferenceSpaceChange = false; + XrTime pendingReferenceSpaceChangeTime = 0; + + while (!quit) + { + // A global legacy bridge can outlive a fast game restart. If a new + // live x86 client takes ownership while this host is still bound to + // an older game window, shut down cleanly so the game's auto-launch + // helper can start a host attached to the new PID. + const DWORD publishedClientPid = SharedClientPid(); + if (!exitRequested && publishedClientPid && + publishedClientPid != gamePid && + QueryProcessLiveness(publishedClientPid) == ProcessLiveness::Alive && + FindGameWindow(publishedClientPid)) + { + exitRequested = true; + exitRequestMs = GetTickCount64(); + OutRunVrR23VerifiedBundle::Invalidate(); + if (session != XR_NULL_HANDLE) + xrRequestExitSession(session); + std::cout << "VR client changed from pid=" << gamePid + << " to pid=" << publishedClientPid + << "; restarting host binding.\n"; + } + + if (!compositor.GameAlive() && !exitRequested) + { + exitRequested = true; exitRequestMs = GetTickCount64(); + OutRunVrR23VerifiedBundle::Invalidate(); + if (session != XR_NULL_HANDLE) xrRequestExitSession(session); + std::cout << "OutRun exited; requesting OpenXR shutdown.\n"; + } + + XrEventDataBuffer event{ XR_TYPE_EVENT_DATA_BUFFER }; + while (xrPollEvent(instance, &event) == XR_SUCCESS) + { + if (event.type == XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED) + { + auto* e = reinterpret_cast(&event); + state = e->state; + if (state == XR_SESSION_STATE_READY && !running) + { + XrSessionBeginInfo begin{ XR_TYPE_SESSION_BEGIN_INFO }; + begin.primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO; + CheckXr(xrBeginSession(session, &begin), "xrBeginSession"); + running = true; + cadence.SetRunning(true); + } + else if (state == XR_SESSION_STATE_STOPPING && running) + { + CheckXr(xrEndSession(session), "xrEndSession"); + running = false; cadence.SetRunning(false); viewHistory.Clear(); matchedStereoValid = false; + lastStereoMatchMs = 0; cachedProjectionValid = false; + cachedProjectionRenderedMs = 0; + cachedMenuProjectionValid = false; + awaitingFirstGameplayStereo = false; + menuProjectionAnchorValid = false; + refreshMenuAnchorAfterLocate = true; + compositor.ReferenceSpaceChanged(); + OutRunVrR23VerifiedBundle::Invalidate(); + OutRunVR::SharedRenderFrameState rf{}; + if (renderFrames.Read(rf)) lastProcessedStereoFrame = rf.frameId; + } + else if (state == XR_SESSION_STATE_EXITING || state == XR_SESSION_STATE_LOSS_PENDING) + quit = true; + } + else if (event.type == XR_TYPE_EVENT_DATA_REFERENCE_SPACE_CHANGE_PENDING) + { + const auto* e = reinterpret_cast(&event); + if (e->referenceSpaceType == XR_REFERENCE_SPACE_TYPE_LOCAL) + { pendingReferenceSpaceChange = true; pendingReferenceSpaceChangeTime = e->changeTime; } + } + event = { XR_TYPE_EVENT_DATA_BUFFER }; + } + + if (quit) break; + if (exitRequested && GetTickCount64() - exitRequestMs > 2000) break; + if (!running) { Sleep(10); continue; } + + XrFrameWaitInfo wi{ XR_TYPE_FRAME_WAIT_INFO }; + XrFrameState fs{ XR_TYPE_FRAME_STATE }; + LARGE_INTEGER ws{}, we{}; QueryPerformanceCounter(&ws); + CheckXr(xrWaitFrame(session, &wi, &fs), "xrWaitFrame"); + QueryPerformanceCounter(&we); + const double xrWaitFrameMs = timings.Ms(ws, we); + timings.wait.Add(xrWaitFrameMs); + const double xrFrameIntervalMs = + lastXrWaitReturnQpc.QuadPart > 0 && qpcFrequency.QuadPart > 0 && + we.QuadPart >= lastXrWaitReturnQpc.QuadPart + ? static_cast(we.QuadPart - lastXrWaitReturnQpc.QuadPart) * + 1000.0 / static_cast(qpcFrequency.QuadPart) + : 0.0; + lastXrWaitReturnQpc = we; + + if (pendingReferenceSpaceChange && + (pendingReferenceSpaceChangeTime == 0 || fs.predictedDisplayTime >= pendingReferenceSpaceChangeTime)) + { + shared.ReferenceSpaceChanged(); compositor.ReferenceSpaceChanged(); viewHistory.Clear(); + matchedStereoValid = false; cachedProjectionValid = false; + cachedProjectionRenderedMs = 0; + cachedMenuProjectionValid = false; + awaitingFirstGameplayStereo = false; + menuProjectionAnchorValid = false; + refreshMenuAnchorAfterLocate = true; + OutRunVrR23VerifiedBundle::Invalidate(); + OutRunVR::SharedRenderFrameState rf{}; + lastProcessedStereoFrame = renderFrames.Read(rf) ? rf.frameId : shared.ReadStereoMeta().frame; + pendingReferenceSpaceChange = false; pendingReferenceSpaceChangeTime = 0; + } + + XrFrameBeginInfo bi{ XR_TYPE_FRAME_BEGIN_INFO }; + CheckXr(xrBeginFrame(session, &bi), "xrBeginFrame"); + // F10/focus recenter is an application-space operation, not merely + // cache invalidation. Apply it before every pose/view locate so the + // current frame and all submitted layer spaces share one origin. + OutRunVrR26RecenterHardening::ApplyPendingApplicationRecenter( + session, viewSpace, localSpace, fs.predictedDisplayTime); + XrSpaceLocation head{ XR_TYPE_SPACE_LOCATION }; + CheckXr(xrLocateSpace(viewSpace, localSpace, fs.predictedDisplayTime, &head), "xrLocateSpace"); + std::array views{}; for (auto& v : views) v = { XR_TYPE_VIEW }; + XrViewState vs{ XR_TYPE_VIEW_STATE }; + XrViewLocateInfo vl{ XR_TYPE_VIEW_LOCATE_INFO }; + vl.viewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO; + vl.displayTime = fs.predictedDisplayTime; vl.space = localSpace; + std::uint32_t vc = 0; + CheckXr(xrLocateViews(session, &vl, &vs, 2, &vc, views.data()), "xrLocateViews"); + + constexpr XrSpaceLocationFlags R45MenuAnchorFlags = + XR_SPACE_LOCATION_ORIENTATION_VALID_BIT | + XR_SPACE_LOCATION_POSITION_VALID_BIT; + if (refreshMenuAnchorAfterLocate && + (head.locationFlags & R45MenuAnchorFlags) == + R45MenuAnchorFlags) + { + menuProjectionAnchor = head.pose; + menuProjectionAnchorValid = true; + refreshMenuAnchorAfterLocate = false; + cachedMenuProjectionValid = false; + std::cout + << "[R45 menu] LOCAL projection anchor captured from current HMD center\n"; + } + + const std::uint32_t hostSequence = shared.Write(head, views, vc, configs, + state, vs.viewStateFlags, fs.shouldRender == XR_TRUE, directTransportEnabled, + compositor.DirectTransportReady(), ip.runtimeName); + if (directTransportEnabled) shared.ServiceInteropProbe(d3d.device, d3d.context); + const XrViewStateFlags neededViews = XR_VIEW_STATE_ORIENTATION_VALID_BIT | + XR_VIEW_STATE_POSITION_VALID_BIT; + if (vc >= 2 && (vs.viewStateFlags & neededViews) == neededViews) + viewHistory.Store(hostSequence, views); + + // R35: xrBeginFrame has already succeeded and the due pose has + // been published. Only now release the next game frame. + const auto cadenceRequest = + cadence.IssueIfDue(fs, hostSequence); + const double cadenceSerialWaitMs = + cadence.WaitForPresented(cadenceRequest, fs); + + XrFrameEndInfo end{ XR_TYPE_FRAME_END_INFO }; + end.displayTime = fs.predictedDisplayTime; end.environmentBlendMode = blend; + const XrCompositionLayerBaseHeader* layers[1]{}; + std::array pv{}; + XrCompositionLayerProjection projection{ XR_TYPE_COMPOSITION_LAYER_PROJECTION }; + XrCompositionLayerQuad quad{ XR_TYPE_COMPOSITION_LAYER_QUAD }; + + const auto requestedPresentation = shared.Presentation(); + auto presentation = requestedPresentation; + + // T0/R46+P0: Gameplay can be announced before a releasable + // stereo packet exists, and Frame.v2 may survive a fast game restart. + // RenderFrameReader already rejects stale run identities; require the + // same completed world/pose invariants used by production selection + // before leaving the visible menu/loading projection. + if (requestedPresentation == OutRunVR::PresentationGameplay && + lastPresentation != OutRunVR::PresentationGameplay) + { + OutRunVR::SharedRenderFrameState bootstrapFrame{}; + const bool haveBootstrapFrame = + renderFrames.Read(bootstrapFrame) && + R23UsableGameplayBootstrapFrame(bootstrapFrame); + if (!haveBootstrapFrame) + presentation = OutRunVR::PresentationTheater; + } + + // R45: the game now publishes presentation from an explicit + // GameState whitelist. Do not retain a stale stereo projection for + // 750 ms after it says Theater; that hold was enough to transform + // selector/result frames as gameplay and corrupt the preview car. + + if (presentation != lastPresentation) + { + compositor.ReferenceSpaceChanged(); matchedStereoValid = false; + OutRunVrR23VerifiedBundle::Invalidate(); + OutRunVR::SharedRenderFrameState rf{}; + lastProcessedStereoFrame = renderFrames.Read(rf) ? rf.frameId : shared.ReadStereoMeta().frame; + if (presentation != OutRunVR::PresentationGameplay) + { + cachedProjectionValid = false; + cachedProjectionRenderedMs = 0; + cachedMenuProjectionValid = false; + awaitingFirstGameplayStereo = false; + if ((head.locationFlags & R45MenuAnchorFlags) == + R45MenuAnchorFlags) + { + menuProjectionAnchor = head.pose; + menuProjectionAnchorValid = true; + refreshMenuAnchorAfterLocate = false; + } + else + { + menuProjectionAnchorValid = false; + refreshMenuAnchorAfterLocate = true; + } + } + else + { + // Keep the last visible menu/loading projection only until + // the first actual gameplay stereo projection is released. + awaitingFirstGameplayStereo = true; + } + lastPresentation = presentation; + std::cout << "VR presentation R45: " + << (presentation == OutRunVR::PresentationGameplay ? "true stereo projection" : "LOCAL-fixed mono 2D projection menu") + << ".\n"; + } + + bool layerReady = false; + bool intentionalMonoProjection = false; + const char* finalLayerKind = "none"; + const char* candidateRejectReason = "not-evaluated"; + std::uint32_t candidateGameFrameId = 0; + std::uint32_t candidatePoseSequence = 0; + std::int64_t candidateGamePresentQpc = 0; + std::uint32_t candidateCadenceRequestId = 0; + double candidateConsumeAgeMs = -1.0; + bool frameFreshProjection = false; + bool frameCachedProjection = false; + std::int64_t candidateCaptureQpc = 0; + double candidateCaptureAgeMs = -1.0; + double candidateCaptureMs = 0.0; + double candidateCommitCopyMs = 0.0; + double frameRenderMs = 0.0; + if (fs.shouldRender == XR_TRUE && vc >= 2) + { + if (presentation == OutRunVR::PresentationGameplay) + { + bool productionCaptureAttempted = false; + bool newStereoCommitted = false; + bool pendingBundlePublish = false; + OutRunVR::SharedRenderFrameState pendingBundleFrame{}; + OutRunVrR23VerifiedBundle::SourceKind pendingBundleSource = + OutRunVrR23VerifiedBundle::SourceKind::None; + std::int64_t pendingBundleCaptureQpc = 0; + OutRunVR::SharedRenderFrameState before{}; + bool have = renderFrames.Read(before); + + // R41 DirectGPU latest-frame-wins. R37 walked the four + // producer slots oldest-first, which made VDXR display a + // permanent ~4-frame / 40-50 ms history behind the newest + // game Present. Select the newest complete frame in the + // current transport generation. Frames skipped without ever + // being sampled by D3D11 are immediately per-slot ACKed so + // the producer can recycle them; the selected frame keeps + // R32's GPU EVENT completion ACK. + if (directTransportOnly) + { + std::array history{}; + std::size_t historyCount = 0; + OutRunVR::SharedRenderFrameState selectedDirect{}; + bool foundDirect = false; + if (renderFrames.ReadHistory(history, historyCount)) + { + std::uint32_t currentGeneration = 0; + std::uint32_t newestDirectFrame = 0; + bool haveCurrentGeneration = false; + for (std::size_t i = 0; i < historyCount; ++i) + { + const auto& frame = history[i]; + const std::uint32_t generation = + frame.reserved[OutRunVR::RenderFrameDirectGenerationIndex]; + if (!frame.frameId || !generation || + frame.state != OutRunVR::StereoSbsActive || + (frame.flags & OutRunVR::RenderFramePresentInFlight) != 0 || + (frame.flags & OutRunVR::RenderFrameDirectGpuTransport) == 0) + continue; + if (!haveCurrentGeneration || + R37FrameIdBefore( + newestDirectFrame, frame.frameId)) + { + newestDirectFrame = frame.frameId; + currentGeneration = generation; + haveCurrentGeneration = true; + } + } + + for (std::size_t i = 0; i < historyCount; ++i) + { + const auto& frame = history[i]; + const std::uint32_t slot = + frame.reserved[OutRunVR::RenderFrameDirectSlotIndex]; + const std::uint32_t generation = + frame.reserved[OutRunVR::RenderFrameDirectGenerationIndex]; + if (!frame.frameId || + (lastProcessedStereoFrame != 0 && + !R37FrameIdBefore( + lastProcessedStereoFrame, + frame.frameId)) || + frame.state != OutRunVR::StereoSbsActive || + (frame.flags & OutRunVR::RenderFramePresentInFlight) != 0 || + (frame.flags & OutRunVR::RenderFrameDirectGpuTransport) == 0 || + slot >= OutRunVR::RenderFrameRingSize || + generation == 0 || + !haveCurrentGeneration || + generation != currentGeneration) + continue; + + if (!foundDirect || + R37FrameIdBefore( + selectedDirect.frameId, frame.frameId)) + { + selectedDirect = frame; + foundDirect = true; + } + } + + if (foundDirect) + { + for (std::size_t i = 0; i < historyCount; ++i) + { + const auto& frame = history[i]; + const std::uint32_t slot = + frame.reserved[OutRunVR::RenderFrameDirectSlotIndex]; + const std::uint32_t generation = + frame.reserved[OutRunVR::RenderFrameDirectGenerationIndex]; + if (!frame.frameId || + frame.frameId == selectedDirect.frameId || + (lastProcessedStereoFrame != 0 && + !R37FrameIdBefore( + lastProcessedStereoFrame, + frame.frameId)) || + frame.state != OutRunVR::StereoSbsActive || + (frame.flags & OutRunVR::RenderFramePresentInFlight) != 0 || + (frame.flags & OutRunVR::RenderFrameDirectGpuTransport) == 0 || + slot >= OutRunVR::RenderFrameRingSize || + generation != currentGeneration) + continue; + + // No D3D11 draw/copy references this skipped + // frame, so producer reuse is safe immediately. + if (OutRunVrD3D9ExDirectPassthrough:: + PublishCompletedFrame(frame)) + { + R37BootstrapSubmittedFrame[slot] = + frame.frameId; + R37BootstrapSubmittedGeneration[slot] = + generation; + } + } + + before = selectedDirect; + have = true; + if (!R36FirstDirectBootstrapLogged) + { + R36FirstDirectBootstrapLogged = true; + std::cout + << "[R41] DirectGPU latest-frame-wins active; selected frame=" + << before.frameId + << " slot=" + << before.reserved[OutRunVR::RenderFrameDirectSlotIndex] + << " generation=" + << before.reserved[OutRunVR::RenderFrameDirectGenerationIndex] + << "; older unsampled ring frames are ACKed immediately.\n"; + } + } + else + { + // R41: direct-only mode must never fall back to + // renderFrames.Read(before), because that snapshot + // can be an already displayed older ring entry. + have = false; + } + } + else + { + have = false; + } + } + + constexpr std::uint32_t need = OutRunVR::RenderFrameStereoComplete | + OutRunVR::RenderFrameWorldStereo | OutRunVR::RenderFrameDrawDuplicated | + OutRunVR::RenderFrameEffectivePoseValid; + if (have) + { + candidateGameFrameId = before.frameId; + candidatePoseSequence = before.sourcePoseSequence; + candidateGamePresentQpc = before.presentQpc; + candidateCadenceRequestId = + before.reserved[OutRunVR::RenderFrameCadenceRequestIndex]; + } + if (!have) + candidateRejectReason = "no-frame-state"; + else if ((before.flags & OutRunVR::RenderFramePresentInFlight) != 0) + candidateRejectReason = "present-in-flight"; + else if (before.state != OutRunVR::StereoSbsActive) + candidateRejectReason = "not-sbs-active"; + else if (!before.frameId) + candidateRejectReason = "zero-frame-id"; + else if (before.frameId == lastProcessedStereoFrame) + candidateRejectReason = "already-processed"; + else if (!before.sourcePoseSequence) + candidateRejectReason = "zero-pose-sequence"; + else if ((before.flags & need) != need) + candidateRejectReason = "incomplete-frame-flags"; + else + candidateRejectReason = "candidate"; + + if (have && + (before.flags & OutRunVR::RenderFramePresentInFlight) == 0 && + before.state == OutRunVR::StereoSbsActive && + before.frameId && + before.frameId != lastProcessedStereoFrame && + before.sourcePoseSequence && + (before.flags & need) == need) + { + OutRunVR::SharedRenderFrameState candidate = before; + bool directFrame = + (candidate.flags & + OutRunVR::RenderFrameDirectGpuTransport) != 0; + bool candidateReady = directFrame; + CaptureStatus capture{}; + + LARGE_INTEGER cs{}, ce{}; + QueryPerformanceCounter(&cs); + if (!directFrame && directTransportOnly) + { + candidateReady = false; + candidateRejectReason = + "direct-only-classic-rejected"; + } + else if (!directFrame) + { + productionCaptureAttempted = true; + capture = R23Capture(compositor, 2); + candidateCaptureQpc = capture.lastPresentQpc; + + // LastPresentTime is output-global. Require the + // complete OutRun client to be visible and require a + // dirty/move update that intersects that client. + // Then correlate against the four-entry Frame.v2 + // history and choose the closest game Present at or + // before the captured output frame. + const std::int64_t maxSkewTicks = + qpcFrequency.QuadPart > 0 + ? qpcFrequency.QuadPart / 10 // 100 ms + : 0; + candidateReady = + R23SelectClassicFrameForCapture( + renderFrames, capture, + lastProcessedStereoFrame, + need, candidate, maxSkewTicks); + if (candidateReady) + { + candidateGameFrameId = candidate.frameId; + candidatePoseSequence = + candidate.sourcePoseSequence; + candidateGamePresentQpc = + candidate.presentQpc; + } + } + QueryPerformanceCounter(&ce); + candidateCaptureMs = timings.Ms(cs, ce); + timings.capture.Add(candidateCaptureMs); + + if (!directFrame && candidateCaptureQpc > 0 && + qpcFrequency.QuadPart > 0) + { + LARGE_INTEGER qpcNow{}; + QueryPerformanceCounter(&qpcNow); + if (qpcNow.QuadPart >= candidateCaptureQpc) + { + candidateCaptureAgeMs = + static_cast( + qpcNow.QuadPart - + candidateCaptureQpc) * + 1000.0 / + static_cast( + qpcFrequency.QuadPart); + } + } + + if (!candidateReady) + { + if (!directFrame && directTransportOnly) + { + candidateRejectReason = + "direct-only-classic-rejected"; + } + else if (!directFrame) + { + if (!capture.available) + candidateRejectReason = + "capture-unavailable"; + else if (!capture.fullGameClientVisible) + candidateRejectReason = + "sbs-client-clipped"; + else if (!capture.gameRegionChanged) + candidateRejectReason = + "desktop-update-outside-game"; + else + candidateRejectReason = + "capture-frame-history-miss"; + } + } + else + { + std::array history{}; + if (!viewHistory.Find( + candidate.sourcePoseSequence, + history)) + { + candidateReady = false; + candidateRejectReason = + "pose-history-miss"; + } + } + + // DirectGPU already carries image, frameId, pose and + // resource generation in one ring entry. Classic SBS + // was re-selected from stable frame history above. + const bool same = + candidateReady && + R23FrameUnchanged( + renderFrames, candidate); + if (candidateReady && !same) + candidateRejectReason = + "selected-frame-mutated"; + + bool committed = false; + if (same) + { + LARGE_INTEGER cms{}, cme{}; + QueryPerformanceCounter(&cms); + committed = directFrame + ? R23CommitDirectAfterValidation( + compositor, candidate) + : R23CommitClassicAfterValidation( + compositor); + QueryPerformanceCounter(&cme); + candidateCommitCopyMs = + timings.Ms(cms, cme); + if (!committed) + candidateRejectReason = + "commit-copy-failed"; + } + + if (committed) + { + // Do not publish the legacy global consumed-frame + // ACK here. D3D11 has only queued the sampling/copy + // work at this point. R32 arms an EVENT after the + // actual projection commands and publishes the + // dedicated per-slot GPU-completion ACK only when + // GetData reports completion. + R23CopyMatchedViews(candidate, matchedViews); + matchedStereoValid = true; + newStereoCommitted = true; + candidateRejectReason = "committed-pending-render"; + if (candidate.presentQpc > 0 && qpcFrequency.QuadPart > 0) + { + LARGE_INTEGER consumedNow{}; + QueryPerformanceCounter(&consumedNow); + if (consumedNow.QuadPart >= candidate.presentQpc) + candidateConsumeAgeMs = + static_cast(consumedNow.QuadPart - candidate.presentQpc) * + 1000.0 / static_cast(qpcFrequency.QuadPart); + } + + // Do not replace the verified/display authority yet. + // The source becomes authoritative only after a new + // projection swapchain image was rendered + // successfully. If rendering fails, the previously + // released cached projection and bundle stay paired. + pendingBundlePublish = true; + pendingBundleFrame = candidate; + pendingBundleSource = directFrame + ? OutRunVrR23VerifiedBundle::SourceKind::DirectGpu + : OutRunVrR23VerifiedBundle::SourceKind::ClassicSbs; + pendingBundleCaptureQpc = + directFrame ? 0 : candidateCaptureQpc; + } + } + + const ULONGLONG projectionNow = GetTickCount64(); + const bool grace = + newStereoCommitted || + (matchedStereoValid && + compositor.HasStereoSource() && + projectionNow >= lastStereoMatchMs && + projectionNow - lastStereoMatchMs <= StereoGraceMs); + // T0/R47: while Gameplay remains active, never age + // out the last successfully released stereo projection. + // Presentation/session/reference-space transitions already + // invalidate this cache, so a producer stall freezes the + // last good stereo image instead of submitting a solid or + // empty compositor frame. + const bool cachedHold = cachedProjectionValid; + + LARGE_INTEGER rs{}, re{}; QueryPerformanceCounter(&rs); + + // R42: projection pixels are refreshed only when a new game + // source was committed (or when no released projection + // exists yet). Intermediate xrWaitFrame ticks resubmit the + // same released projection image without another pair of + // full-eye blits. R32 keeps the exact producer slot protected + // by the original async EVENT, so same-frame resubmission no + // longer needs the expensive SafeEye recovery path. + const bool hadCachedProjection = cachedProjectionValid; + const bool projectionRefreshNeeded = + newStereoCommitted || !cachedProjectionValid; + if (grace && projectionRefreshNeeded && + R23RenderProjection(compositor, matchedViews, pv)) + { + ++R42ProjectionRefreshes; + projection.space = localSpace; + projection.viewCount = 2; + projection.views = pv.data(); + layers[0] = + reinterpret_cast( + &projection); + layerReady = true; + finalLayerKind = "projection-fresh"; + frameFreshProjection = true; + cachedProjectionViews = pv; + cachedProjectionValid = true; + // Track source freshness, not repeated XR redraws. This + // lets gameplay->menu/loading debounce expire normally. + if (newStereoCommitted || !hadCachedProjection) + cachedProjectionRenderedMs = projectionNow; + if (pendingBundlePublish) + { + OutRunVrR23VerifiedBundle::Publish( + pendingBundleFrame, + pendingBundleSource, + pendingBundleCaptureQpc); + if (pendingBundleSource == + OutRunVrR23VerifiedBundle::SourceKind::DirectGpu) + { + const std::uint32_t slot = + pendingBundleFrame.reserved[ + OutRunVR::RenderFrameDirectSlotIndex]; + const std::uint32_t generation = + pendingBundleFrame.reserved[ + OutRunVR::RenderFrameDirectGenerationIndex]; + if (slot < OutRunVR::RenderFrameRingSize && + generation != 0) + { + R37BootstrapSubmittedFrame[slot] = + pendingBundleFrame.frameId; + R37BootstrapSubmittedGeneration[slot] = + generation; + } + } + lastProcessedStereoFrame = + pendingBundleFrame.frameId; + lastStereoMatchMs = projectionNow; + candidateRejectReason = "displayed-fresh"; + pendingBundlePublish = false; + } + } + else if (cachedProjectionValid && (grace || cachedHold)) + { + projection.space = localSpace; + projection.viewCount = 2; + projection.views = cachedProjectionViews.data(); + layers[0] = + reinterpret_cast( + &projection); + layerReady = true; + finalLayerKind = "projection-cached"; + frameCachedProjection = true; + ++R23CachedProjectionSubmits; + if (grace && !newStereoCommitted) + { + ++R42SameFrameProjectionReuses; + if (!R42FirstProjectionReuseLogged) + { + R42FirstProjectionReuseLogged = true; + std::cout + << "[R42 projection-reuse] unchanged game frame reuses the released projection; duplicate per-XR-tick eye blits disabled.\n"; + } + } + + if (R23LastCachedProjectionLogMs == 0 || + projectionNow - R23LastCachedProjectionLogMs >= + R23CachedProjectionLogIntervalMs) + { + R23LastCachedProjectionLogMs = projectionNow; + std::cout + << "[R23 projection-hold] reusing last released stereo projection instead of theater/SBS fallback" + << " count=" << R23CachedProjectionSubmits + << " newFrame=" << (newStereoCommitted ? 1 : 0) + << " grace=" << (grace ? 1 : 0) + << " presentationGraceFrames=" + << R23PresentationGraceFrames + << " sameFrameReuse=" + << R42SameFrameProjectionReuses + << " sourceRefresh=" + << R42ProjectionRefreshes + << "\n"; + } + } + QueryPerformanceCounter(&re); + frameRenderMs += timings.Ms(rs, re); + timings.render.Add(timings.Ms(rs, re)); + + if (frameFreshProjection) + { + awaitingFirstGameplayStereo = false; + cachedMenuProjectionValid = false; + } + + // If Gameplay was announced before a releasable stereo + // projection, keep the last LOCAL-fixed menu/loading image + // visible in direct-only mode. This is strictly a bootstrap + // bridge; the first fresh gameplay projection disables it. + if (!layerReady && directTransportOnly && + awaitingFirstGameplayStereo && + cachedMenuProjectionValid) + { + pv = cachedMenuProjectionViews; + projection.space = localSpace; + projection.viewCount = 2; + projection.views = pv.data(); + layers[0] = + reinterpret_cast( + &projection); + layerReady = true; + intentionalMonoProjection = true; + finalLayerKind = "gameplay-bootstrap-menu-hold"; + } + + // Never submit a zero-layer frame just because Desktop + // Duplication missed the stereo grace window. VDXR can show a + // solid compositor colour / severe HMD stutter even while the + // desktop game keeps running normally. + if (!layerReady && directTransportOnly) + { + finalLayerKind = "direct-only-no-classic-fallback"; + } + else if (!layerReady) + { + LARGE_INTEGER cs{}, ce{}; QueryPerformanceCounter(&cs); + const CaptureStatus fallbackCapture = + R23RefreshTheaterFallbackCapture(compositor); + QueryPerformanceCounter(&ce); timings.capture.Add(timings.Ms(cs, ce)); + + LARGE_INTEGER frs{}, fre{}; QueryPerformanceCounter(&frs); + // Only crop the left half when the shared frame state says + // the desktop source is a completed SBS frame. If stereo + // never became active (for example SAFE transport policy + // mismatch), the desktop contains a normal full-width mono + // frame; cropping it caused the observed half-screen zoom. + const bool fallbackSourceIsSbs = have && + before.state == OutRunVR::StereoSbsActive && + (before.flags & OutRunVR::RenderFrameStereoComplete) != 0; + if (fallbackCapture.available && + R23RenderTheater(compositor, viewSpace, localSpace, + fs.predictedDisplayTime, quad, fallbackSourceIsSbs)) + { + layers[0] = + reinterpret_cast(&quad); + layerReady = true; + finalLayerKind = fallbackSourceIsSbs + ? "recovery-left-eye-theater" + : "recovery-full-mono-theater"; + ++R23GameplayTheaterFallbacks; + const ULONGLONG now = GetTickCount64(); + if (R23LastGameplayFallbackLogMs == 0 || + now - R23LastGameplayFallbackLogMs >= 5000) + { + R23LastGameplayFallbackLogMs = now; + std::cout + << "[R23 fallback] no reusable stereo projection remained; using " + << (fallbackSourceIsSbs ? "left-eye SBS crop" : "full-width mono") + << " LOCAL-fixed theater until fresh stereo returns" + << " count=" << R23GameplayTheaterFallbacks + << " productionAttempted=" << (productionCaptureAttempted ? 1 : 0) + << "\n"; + } + } + QueryPerformanceCounter(&fre); + frameRenderMs += timings.Ms(frs, fre); + timings.render.Add(timings.Ms(frs, fre)); + } + } + else + { + LARGE_INTEGER cs{}, ce{}; QueryPerformanceCounter(&cs); + const CaptureStatus capture = R23Capture(compositor); + QueryPerformanceCounter(&ce); timings.capture.Add(timings.Ms(cs, ce)); + LARGE_INTEGER rs{}, re{}; QueryPerformanceCounter(&rs); + + // R43: keep the efficient projection-layer menu, but anchor + // it in LOCAL space. R41 intentionally used VIEW space and + // therefore followed the headset exactly. Both eyes still + // receive identical desktop pixels and zero virtual IPD; + // only the reference space changes so the menu remains at + // the recentered world orientation while the head moves. + if (capture.available && + menuProjectionAnchorValid && + (!cachedMenuProjectionValid || capture.fresh)) + { + std::array + refreshed{}; + if (R23RenderWorldLockedMenuProjection( + compositor, views, menuProjectionAnchor, + refreshed)) + { + cachedMenuProjectionViews = refreshed; + cachedMenuProjectionValid = true; + } + } + if (cachedMenuProjectionValid) + { + pv = cachedMenuProjectionViews; + projection.space = localSpace; + projection.viewCount = 2; + projection.views = pv.data(); + layers[0] = + reinterpret_cast( + &projection); + layerReady = true; + intentionalMonoProjection = true; + finalLayerKind = capture.fresh + ? "menu-local-fixed-projection-fresh" + : "menu-local-fixed-projection-cached"; + } + QueryPerformanceCounter(&re); + frameRenderMs += timings.Ms(rs, re); + timings.render.Add(timings.Ms(rs, re)); + } + } + + end.layerCount = layerReady ? 1 : 0; + end.layers = layerReady ? layers : nullptr; + const R23FinalCounters finalBefore = + R23ReadFinalCounters(); + OutRunVrR24BlackScreenGuard::IntentionalMonoProjection.store( + intentionalMonoProjection, std::memory_order_release); + LARGE_INTEGER es{}, ee{}; + QueryPerformanceCounter(&es); + const XrResult endResult = xrEndFrame(session, &end); + QueryPerformanceCounter(&ee); + OutRunVrR24BlackScreenGuard::IntentionalMonoProjection.store( + false, std::memory_order_release); + const R23FinalCounters finalAfter = + R23ReadFinalCounters(); + const char* actualFinalLayerKind = + R23ActualFinalKind( + finalBefore, finalAfter, finalLayerKind); + const std::uint32_t actualSubmittedFrameId = + OutRunVrR23RuntimeHardening::LastSubmittedFrameId.load( + std::memory_order_acquire); + const std::uint32_t actualSubmittedSourceKind = + OutRunVrR23RuntimeHardening::LastSubmittedKind.load( + std::memory_order_acquire); + const bool actualSubmittedLayer = + OutRunVrR23RuntimeHardening::LastSubmittedLayer.load( + std::memory_order_acquire); + R23Pixels.NoteFinalSubmission(); + CheckXr(endResult, "xrEndFrame"); + const double endFrameMs = timings.Ms(es, ee); + timings.end.Add(endFrameMs); + cadence.NoteFrame(frameFreshProjection, + frameCachedProjection, layerReady, + candidateCadenceRequestId); + pipelineWindow.Note(candidateRejectReason, + actualFinalLayerKind, candidateCaptureMs, + candidateCommitCopyMs, frameRenderMs, endFrameMs); + + const ULONGLONG pipelineNowMs = GetTickCount64(); + if (lastPipelineTelemetryMs == 0 || + pipelineNowMs - lastPipelineTelemetryMs >= 5000) + { + lastPipelineTelemetryMs = pipelineNowMs; + OutRunVrR23VerifiedBundle::Snapshot bundle{}; + const bool haveBundle = + OutRunVrR23VerifiedBundle::Read(bundle); + const long long bundleAgeMs = + haveBundle && bundle.publishedAtMs && + pipelineNowMs >= bundle.publishedAtMs + ? static_cast( + pipelineNowMs - bundle.publishedAtMs) + : -1; + const std::string gameBuildTag = + haveBundle ? R23GameBuildTag(bundle.frame) : + std::string("unknown"); + const DWORD hostPid = GetCurrentProcessId(); + const DWORD activeGamePid = gamePid; + std::ostringstream pipelineLine; + pipelineLine + << "[R23 pipeline] hostBuild=" << OUTRUN_VR_BUILD_SHA + << " gameBuild=" << gameBuildTag + << " hostPid=" << hostPid + << " gamePid=" << activeGamePid + << " runKey=" << hostPid << "-" << activeGamePid + << " gameFrameId=" << candidateGameFrameId + << " gamePresentQpc=" << candidateGamePresentQpc + << " captureQpc=" << candidateCaptureQpc + << " captureAgeMs=" << candidateCaptureAgeMs + << " committedFrameId=" << lastProcessedStereoFrame + << " sourcePoseSequence=" << candidatePoseSequence + << " rejectReason=" << candidateRejectReason + << " requestedLayer=" << finalLayerKind + << " actualFinal=" << actualFinalLayerKind + << " actualFrameId=" << actualSubmittedFrameId + << " actualSourceKind=" << actualSubmittedSourceKind + << " actualLayer=" << (actualSubmittedLayer ? 1 : 0) + << " bundleFrameId=" + << (haveBundle ? bundle.frameId : 0u) + << " bundleAgeMs=" << bundleAgeMs + << " captureMs=" << candidateCaptureMs + << " commitCopyMs=" << candidateCommitCopyMs + << " renderMs=" << frameRenderMs + << " xrWaitFrameMs=" << xrWaitFrameMs + << " xrFrameIntervalMs=" << xrFrameIntervalMs + << " cadenceSerialWaitMs=" << cadenceSerialWaitMs + << " candidateCadenceReq=" << candidateCadenceRequestId + << " gamePresentToConsumeMs=" << candidateConsumeAgeMs + << " xrEndFrameMs=" << endFrameMs + << " displayPeriodMs=" + << (static_cast(fs.predictedDisplayPeriod) / 1000000.0); + pipelineWindow.AppendAndReset(pipelineLine); + cadence.AppendAndReset(pipelineLine); + pipelineLine << "\n"; + const std::string line = pipelineLine.str(); + std::cout << line; + if (pipelineLog.is_open()) + { + // Let the stream buffer coalesce periodic telemetry writes. + // Per-frame forced flushes can serialize the XR loop on disk. + pipelineLog << line; + } + } + timings.MaybeLog(); + } + + OutRunVrR23VerifiedBundle::Invalidate(); + compositor.Shutdown(); + if (viewSpace != XR_NULL_HANDLE) xrDestroySpace(viewSpace); + if (localSpace != XR_NULL_HANDLE) xrDestroySpace(localSpace); + if (session != XR_NULL_HANDLE) xrDestroySession(session); + if (instance != XR_NULL_HANDLE) xrDestroyInstance(instance); + return 0; + } + catch (const std::exception& e) + { + std::cerr << "OutRun VR host error: " << e.what() << "\n"; + OutRunVrR23VerifiedBundle::Invalidate(); + if (viewSpace != XR_NULL_HANDLE) xrDestroySpace(viewSpace); + if (localSpace != XR_NULL_HANDLE) xrDestroySpace(localSpace); + if (session != XR_NULL_HANDLE) xrDestroySession(session); + if (instance != XR_NULL_HANDLE) xrDestroyInstance(instance); + return 1; + } +} diff --git a/vrhost/src/runtime/bounded_swapchain_wait.hpp b/vrhost/src/runtime/bounded_swapchain_wait.hpp new file mode 100644 index 000000000..2aa5c119f --- /dev/null +++ b/vrhost/src/runtime/bounded_swapchain_wait.hpp @@ -0,0 +1,54 @@ +#pragma once + +// Bound XR_INFINITE_DURATION waits so a wedged runtime/driver cannot leave the +// host process alive forever while the headset shows only black. Finite waits +// requested by callers are preserved exactly. Infinite waits are sliced into +// short waits and converted to XR_ERROR_RUNTIME_FAILURE after the total budget; +// callers that can degrade return to their fallback path, while the main host +// exits through its existing exception/cleanup path instead of hanging. + +#include +#include + +#include +#include +#include + +namespace OutRunVrBoundedSwapchainWait +{ + inline constexpr XrDuration SliceNs = 20'000'000; // 20 ms + inline constexpr ULONGLONG TotalBudgetMs = 250; + inline std::atomic FirstBudgetExceededLogged{ false }; + + inline XrResult XRAPI_CALL WaitSwapchainImage( + XrSwapchain swapchain, + const XrSwapchainImageWaitInfo* info) noexcept + { + if (!info || info->timeout != XR_INFINITE_DURATION) + return ::xrWaitSwapchainImage(swapchain, info); + + const ULONGLONG start = GetTickCount64(); + for (;;) + { + XrSwapchainImageWaitInfo bounded = *info; + bounded.timeout = SliceNs; + const XrResult result = ::xrWaitSwapchainImage(swapchain, &bounded); + if (result != XR_TIMEOUT_EXPIRED) + return result; + + if (GetTickCount64() - start >= TotalBudgetMs) + { + if (!FirstBudgetExceededLogged.exchange( + true, std::memory_order_acq_rel)) + { + std::cerr + << "[R24] xrWaitSwapchainImage exceeded 250ms; aborting the wait so the host can recover/exit instead of hanging on a black frame\n"; + } + return XR_ERROR_RUNTIME_FAILURE; + } + SwitchToThread(); + } + } +} + +#define xrWaitSwapchainImage OutRunVrBoundedSwapchainWait::WaitSwapchainImage diff --git a/vrhost/src/runtime/capture_chain_diagnostic.hpp b/vrhost/src/runtime/capture_chain_diagnostic.hpp new file mode 100644 index 000000000..f5d077374 --- /dev/null +++ b/vrhost/src/runtime/capture_chain_diagnostic.hpp @@ -0,0 +1,582 @@ +#pragma once + +// R17 capture-chain diagnostic. +// This header is forced-included only by the dedicated diagnostic executable. +// It deliberately reuses the production R10 Desktop Duplication, crop, shader, +// swapchain and projection helpers, then submits controlled phases directly to +// the OpenXR loader. The normal outrun-vr-host target never includes this file. + +#ifdef xrEndFrame +#undef xrEndFrame +#endif + +#include +#include +#include + +namespace OutRunVrCaptureChainDiagnostic +{ + inline constexpr const char* BuildId = "R17-capture-chain-diagnostic-20260915"; + inline constexpr ULONGLONG PhaseDurationMs = 5000; + inline constexpr int PhaseCount = 5; + + inline ULONGLONG StartMs = 0; + inline ULONGLONG LastProbeMs = 0; + inline int LastPhase = -1; + inline std::uint64_t Frames = 0; + inline std::array PhaseOk{}; + inline std::array PhaseFail{}; + inline std::ofstream LogFile; + + inline OutRunVrSbsCaptureOverride::Swapchain DiagArray{}; + inline ID3D11Texture2D* InternalTexture = nullptr; + inline ID3D11ShaderResourceView* InternalSrv = nullptr; + inline ID3D11Texture2D* ProbeStaging = nullptr; + inline std::uint32_t ProbeWidth = 0; + inline std::uint32_t ProbeHeight = 0; + inline DXGI_FORMAT ProbeFormat = DXGI_FORMAT_UNKNOWN; + + inline void EnsureLog() + { + if (!LogFile.is_open()) + LogFile.open("outrun-vr-capture-diagnostic.log", + std::ios::out | std::ios::trunc); + } + + inline void Log(const std::string& text) + { + EnsureLog(); + SYSTEMTIME st{}; + GetLocalTime(&st); + std::ostringstream ss; + ss << std::setfill('0') + << "[" << std::setw(2) << st.wHour << ":" + << std::setw(2) << st.wMinute << ":" + << std::setw(2) << st.wSecond << "." + << std::setw(3) << st.wMilliseconds << "] " + << "[R17] " << text; + std::cerr << ss.str() << "\n"; + if (LogFile) + { + LogFile << ss.str() << "\n"; + LogFile.flush(); + } + } + + inline const char* PhaseName(int phase) + { + switch (phase) + { + case 0: return "1/5 INTERNAL-BLIT: synthetic LEFT RED / RIGHT GREEN through production shader"; + case 1: return "2/5 DESKTOP-WHOLE: duplicated monitor shown identically to both eyes"; + case 2: return "3/5 GAME-CROP: game client crop shown identically to both eyes"; + case 3: return "4/5 GAME-SBS-EYE: game crop split left/right into eye-specific quads"; + case 4: return "5/5 EXACT-PROJECTION: production R10 capture + SBS split + projection override"; + default: return "unknown"; + } + } + + inline float HalfToFloat(std::uint16_t h) + { + const std::uint32_t sign = (static_cast(h & 0x8000u)) << 16; + std::uint32_t exp = (h >> 10) & 0x1fu; + std::uint32_t mant = h & 0x03ffu; + std::uint32_t bits = 0; + if (exp == 0) + { + if (mant == 0) + { + bits = sign; + } + else + { + int e = -14; + while ((mant & 0x0400u) == 0) + { + mant <<= 1; + --e; + } + mant &= 0x03ffu; + bits = sign | + (static_cast(e + 127) << 23) | + (mant << 13); + } + } + else if (exp == 31) + { + bits = sign | 0x7f800000u | (mant << 13); + } + else + { + bits = sign | ((exp + (127 - 15)) << 23) | (mant << 13); + } + + float value = 0.f; + std::memcpy(&value, &bits, sizeof(value)); + return value; + } + + struct Pixel + { + float r = 0.f; + float g = 0.f; + float b = 0.f; + float a = 1.f; + }; + + inline Pixel ReadPixel(const D3D11_MAPPED_SUBRESOURCE& mapped, + std::uint32_t x, std::uint32_t y, DXGI_FORMAT format) + { + Pixel p{}; + const auto* row = static_cast(mapped.pData) + + static_cast(y) * mapped.RowPitch; + if (format == DXGI_FORMAT_B8G8R8A8_UNORM) + { + const auto* s = row + static_cast(x) * 4u; + p.b = static_cast(s[0]) / 255.f; + p.g = static_cast(s[1]) / 255.f; + p.r = static_cast(s[2]) / 255.f; + p.a = static_cast(s[3]) / 255.f; + } + else if (format == DXGI_FORMAT_R16G16B16A16_FLOAT) + { + const auto* s = reinterpret_cast( + row + static_cast(x) * 8u); + p.r = HalfToFloat(s[0]); + p.g = HalfToFloat(s[1]); + p.b = HalfToFloat(s[2]); + p.a = HalfToFloat(s[3]); + } + return p; + } + + inline bool EnsureProbeStaging() + { + using namespace OutRunVrSbsCaptureOverride; + if (!Source || !OutRunVrFinalTest::Device) + return false; + + D3D11_TEXTURE2D_DESC src{}; + Source->GetDesc(&src); + if (ProbeStaging && ProbeWidth == src.Width && + ProbeHeight == src.Height && ProbeFormat == src.Format) + return true; + + ReleaseCom(ProbeStaging); + D3D11_TEXTURE2D_DESC d = src; + d.MipLevels = 1; + d.ArraySize = 1; + d.SampleDesc.Count = 1; + d.SampleDesc.Quality = 0; + d.Usage = D3D11_USAGE_STAGING; + d.BindFlags = 0; + d.CPUAccessFlags = D3D11_CPU_ACCESS_READ; + d.MiscFlags = 0; + if (FAILED(OutRunVrFinalTest::Device->CreateTexture2D( + &d, nullptr, &ProbeStaging)) || !ProbeStaging) + return false; + + ProbeWidth = d.Width; + ProbeHeight = d.Height; + ProbeFormat = d.Format; + return true; + } + + inline std::string PixelText(const Pixel& p) + { + std::ostringstream ss; + ss << std::fixed << std::setprecision(3) + << "(" << p.r << "," << p.g << "," << p.b << ")"; + return ss.str(); + } + + inline void ProbeCapture() + { + using namespace OutRunVrSbsCaptureOverride; + const ULONGLONG now = GetTickCount64(); + if (now - LastProbeMs < 1000 || !Source || !HaveSource || + !OutRunVrFinalTest::Context || !EnsureProbeStaging()) + return; + LastProbeMs = now; + + OutRunVrFinalTest::Context->CopyResource(ProbeStaging, Source); + D3D11_MAPPED_SUBRESOURCE mapped{}; + const HRESULT hr = OutRunVrFinalTest::Context->Map( + ProbeStaging, 0, D3D11_MAP_READ, 0, &mapped); + if (FAILED(hr)) + { + std::ostringstream ss; + ss << "capture probe Map failed HRESULT=0x" + << std::hex << static_cast(hr); + Log(ss.str()); + return; + } + + UvRect game{}; + const bool haveGameUv = GetGameUv(game); + const std::uint32_t desktopX = SourceWidth / 2u; + const std::uint32_t desktopY = SourceHeight / 2u; + Pixel desktop = ReadPixel(mapped, + std::min(desktopX, SourceWidth - 1u), + std::min(desktopY, SourceHeight - 1u), SourceFormat); + + Pixel gameCenter{}; + if (haveGameUv) + { + const float gx = game.x + game.w * 0.5f; + const float gy = game.y + game.h * 0.5f; + const auto px = std::min( + static_cast(gx * SourceWidth), SourceWidth - 1u); + const auto py = std::min( + static_cast(gy * SourceHeight), SourceHeight - 1u); + gameCenter = ReadPixel(mapped, px, py, SourceFormat); + } + + int nonBlack = 0; + constexpr int gridX = 9; + constexpr int gridY = 5; + const UvRect sample = haveGameUv ? game : UvRect{ 0.f, 0.f, 1.f, 1.f }; + for (int y = 0; y < gridY; ++y) + { + for (int x = 0; x < gridX; ++x) + { + const float u = sample.x + sample.w * + ((static_cast(x) + 0.5f) / gridX); + const float v = sample.y + sample.h * + ((static_cast(y) + 0.5f) / gridY); + const auto px = std::min( + static_cast(u * SourceWidth), SourceWidth - 1u); + const auto py = std::min( + static_cast(v * SourceHeight), SourceHeight - 1u); + const Pixel p = ReadPixel(mapped, px, py, SourceFormat); + if (std::max({ std::fabs(p.r), std::fabs(p.g), std::fabs(p.b) }) > 0.005f) + ++nonBlack; + } + } + OutRunVrFinalTest::Context->Unmap(ProbeStaging, 0); + + std::ostringstream ss; + ss << "capture probe source=" << SourceWidth << "x" << SourceHeight + << " fmt=" << static_cast(SourceFormat) + << " desktopCenter=" << PixelText(desktop); + if (haveGameUv) + { + ss << " gameCenter=" << PixelText(gameCenter) + << " gameUv=" << std::fixed << std::setprecision(4) + << game.x << "," << game.y << "," << game.w << "," << game.h; + } + else + { + ss << " gameUv=UNAVAILABLE"; + } + ss << " nonBlackGrid=" << nonBlack << "/" << (gridX * gridY) + << " allBlack=" << (nonBlack == 0 ? "YES" : "NO"); + Log(ss.str()); + } + + inline bool EnsureInternalSource() + { + if (InternalTexture && InternalSrv) + return true; + if (!OutRunVrFinalTest::Device) + return false; + + constexpr std::uint32_t width = 512; + constexpr std::uint32_t height = 256; + std::vector pixels(width * height); + for (std::uint32_t y = 0; y < height; ++y) + { + for (std::uint32_t x = 0; x < width; ++x) + { + // BGRA8 in little endian: red=0xFFFF0000, green=0xFF00FF00. + pixels[static_cast(y) * width + x] = + x < width / 2u ? 0xFFFF0000u : 0xFF00FF00u; + } + } + + D3D11_TEXTURE2D_DESC d{}; + d.Width = width; + d.Height = height; + d.MipLevels = 1; + d.ArraySize = 1; + d.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + d.SampleDesc.Count = 1; + d.Usage = D3D11_USAGE_IMMUTABLE; + d.BindFlags = D3D11_BIND_SHADER_RESOURCE; + + D3D11_SUBRESOURCE_DATA data{}; + data.pSysMem = pixels.data(); + data.SysMemPitch = width * 4u; + if (FAILED(OutRunVrFinalTest::Device->CreateTexture2D( + &d, &data, &InternalTexture)) || !InternalTexture) + return false; + if (FAILED(OutRunVrFinalTest::Device->CreateShaderResourceView( + InternalTexture, nullptr, &InternalSrv)) || !InternalSrv) + { + OutRunVrSbsCaptureOverride::ReleaseCom(InternalTexture); + return false; + } + return true; + } + + inline bool RenderWithSource(ID3D11RenderTargetView* rtv, + std::uint32_t width, std::uint32_t height, + const OutRunVrSbsCaptureOverride::UvRect& uv, + ID3D11ShaderResourceView* srv, DXGI_FORMAT format, float whiteScale) + { + using namespace OutRunVrSbsCaptureOverride; + ID3D11ShaderResourceView* oldSrv = SourceSrv; + const DXGI_FORMAT oldFormat = SourceFormat; + const float oldWhite = SdrWhiteScale; + SourceSrv = srv; + SourceFormat = format; + SdrWhiteScale = whiteScale; + const bool ok = RenderTo(rtv, width, height, uv); + SourceSrv = oldSrv; + SourceFormat = oldFormat; + SdrWhiteScale = oldWhite; + return ok; + } + + inline bool RenderDiagnosticArray(XrSession session, + const OutRunVrSbsCaptureOverride::UvRect& leftUv, + const OutRunVrSbsCaptureOverride::UvRect& rightUv, + bool useInternal) + { + using namespace OutRunVrSbsCaptureOverride; + if (!EnsureViewSpace(session) || + !EnsureSwapchain(DiagArray, session, 1600, 1000, 2)) + return false; + + std::uint32_t image = 0; + if (!Acquire(DiagArray, image)) + return false; + if (image >= DiagArray.rtvs.size()) + { + Release(DiagArray); + return false; + } + + bool ok = false; + if (useInternal) + { + if (EnsureInternalSource()) + { + ok = RenderWithSource(DiagArray.rtvs[image][0], + DiagArray.width, DiagArray.height, leftUv, + InternalSrv, DXGI_FORMAT_B8G8R8A8_UNORM, 1.f); + ok = RenderWithSource(DiagArray.rtvs[image][1], + DiagArray.width, DiagArray.height, rightUv, + InternalSrv, DXGI_FORMAT_B8G8R8A8_UNORM, 1.f) && ok; + } + } + else if (SourceSrv && HaveSource) + { + ok = RenderTo(DiagArray.rtvs[image][0], + DiagArray.width, DiagArray.height, leftUv); + ok = RenderTo(DiagArray.rtvs[image][1], + DiagArray.width, DiagArray.height, rightUv) && ok; + } + + const bool released = Release(DiagArray); + return ok && released; + } + + inline void FillQuad(XrCompositionLayerQuad& quad, + XrEyeVisibility eye, std::uint32_t slice, float aspect) + { + using namespace OutRunVrSbsCaptureOverride; + quad = { XR_TYPE_COMPOSITION_LAYER_QUAD }; + quad.space = ViewSpace; + quad.eyeVisibility = eye; + quad.pose.orientation.w = 1.f; + quad.pose.position.z = -1.5f; + quad.subImage.swapchain = DiagArray.handle; + quad.subImage.imageRect.offset = { 0, 0 }; + quad.subImage.imageRect.extent = { + static_cast(DiagArray.width), + static_cast(DiagArray.height) + }; + quad.subImage.imageArrayIndex = slice; + quad.size.width = 1.8f; + quad.size.height = 1.8f / std::clamp(aspect, 0.5f, 3.0f); + } + + inline float UvAspect(const OutRunVrSbsCaptureOverride::UvRect& uv) + { + using namespace OutRunVrSbsCaptureOverride; + if (!SourceWidth || !SourceHeight || uv.h <= 0.f) + return 16.f / 9.f; + return (uv.w * static_cast(SourceWidth)) / + (uv.h * static_cast(SourceHeight)); + } + + inline bool BuildQuadPhase(XrSession session, int phase, + std::array& quads, std::uint32_t& layerCount) + { + using namespace OutRunVrSbsCaptureOverride; + layerCount = 0; + + if (phase == 0) + { + const UvRect left{ 0.f, 0.f, 0.5f, 1.f }; + const UvRect right{ 0.5f, 0.f, 0.5f, 1.f }; + if (!RenderDiagnosticArray(session, left, right, true)) + return false; + FillQuad(quads[0], XR_EYE_VISIBILITY_LEFT, 0, 1.f); + FillQuad(quads[1], XR_EYE_VISIBILITY_RIGHT, 1, 1.f); + layerCount = 2; + return true; + } + + if (!CaptureDesktop(8) || !HaveSource || !SourceSrv) + return false; + ProbeCapture(); + + UvRect whole{ 0.f, 0.f, 1.f, 1.f }; + UvRect game{}; + if ((phase == 2 || phase == 3) && !GetGameUv(game)) + return false; + + if (phase == 1) + { + if (!RenderDiagnosticArray(session, whole, whole, false)) + return false; + FillQuad(quads[0], XR_EYE_VISIBILITY_BOTH, 0, + static_cast(SourceWidth) / std::max(1u, SourceHeight)); + layerCount = 1; + return true; + } + + if (phase == 2) + { + if (!RenderDiagnosticArray(session, game, game, false)) + return false; + FillQuad(quads[0], XR_EYE_VISIBILITY_BOTH, 0, UvAspect(game)); + layerCount = 1; + return true; + } + + if (phase == 3) + { + const UvRect left{ game.x, game.y, game.w * 0.5f, game.h }; + const UvRect right{ game.x + game.w * 0.5f, game.y, + game.w * 0.5f, game.h }; + if (!RenderDiagnosticArray(session, left, right, false)) + return false; + FillQuad(quads[0], XR_EYE_VISIBILITY_LEFT, 0, UvAspect(left)); + FillQuad(quads[1], XR_EYE_VISIBILITY_RIGHT, 1, UvAspect(right)); + layerCount = 2; + return true; + } + + return false; + } + + inline void MaybeSummary() + { + if ((Frames % 300u) != 0u) + return; + std::ostringstream ss; + ss << "summary frames=" << Frames << " ok=["; + for (int i = 0; i < PhaseCount; ++i) + { + if (i) ss << ","; + ss << PhaseOk[i]; + } + ss << "] fail=["; + for (int i = 0; i < PhaseCount; ++i) + { + if (i) ss << ","; + ss << PhaseFail[i]; + } + ss << "] R10capture[fresh=" << OutRunVrSbsCaptureOverride::CaptureFresh + << ",timeout=" << OutRunVrSbsCaptureOverride::CaptureTimeout + << ",fail=" << OutRunVrSbsCaptureOverride::CaptureFailure + << "] source=" << OutRunVrSbsCaptureOverride::SourceWidth + << "x" << OutRunVrSbsCaptureOverride::SourceHeight + << " fmt=" << static_cast(OutRunVrSbsCaptureOverride::SourceFormat); + Log(ss.str()); + } + + inline XrResult XRAPI_CALL EndFrame( + XrSession session, const XrFrameEndInfo* endInfo) + { + ++Frames; + if (!StartMs) + { + StartMs = GetTickCount64(); + Log(std::string("build=") + BuildId); + Log("uses production R10 DuplicateOutput/GetGameUv/RenderTo/Projection helpers"); + Log("phase duration=5s; phases repeat. Keep the game running and enter gameplay."); + } + + const int phase = static_cast( + ((GetTickCount64() - StartMs) / PhaseDurationMs) % PhaseCount); + if (phase != LastPhase) + { + LastPhase = phase; + Log(std::string("PHASE ") + PhaseName(phase)); + } + + if (!endInfo) + return ::xrEndFrame(session, endInfo); + + XrFrameEndInfo patched = *endInfo; + const XrCompositionLayerBaseHeader* layerPtrs[2]{}; + std::array quads{}; + std::uint32_t layerCount = 0; + bool ready = false; + + XrCompositionLayerProjection projection{}; + std::array projectionViews{}; + + if (phase < 4) + { + ready = BuildQuadPhase(session, phase, quads, layerCount); + if (ready) + { + for (std::uint32_t i = 0; i < layerCount; ++i) + layerPtrs[i] = + reinterpret_cast(&quads[i]); + patched.layerCount = layerCount; + patched.layers = layerPtrs; + } + } + else + { + // This is the exact production R10 path: capture desktop, crop the + // game, split SBS, render through the production shader and submit + // a two-slice projection layer using incoming or Frame.v2 poses. + ready = OutRunVrSbsCaptureOverride::RenderProjectionOverride( + session, endInfo, projection, projectionViews); + ProbeCapture(); + if (ready) + { + layerPtrs[0] = + reinterpret_cast(&projection); + patched.layerCount = 1; + patched.layers = layerPtrs; + } + else if ((Frames % 90u) == 0u) + { + std::ostringstream ss; + ss << "exact projection not ready frameMeta=" + << (OutRunVrSbsCaptureOverride::LastStereoFrameValid ? 1 : 0) + << " haveSource=" << (OutRunVrSbsCaptureOverride::HaveSource ? 1 : 0) + << " mainLayerCount=" << endInfo->layerCount; + Log(ss.str()); + } + } + + if (ready) + ++PhaseOk[phase]; + else + ++PhaseFail[phase]; + + MaybeSummary(); + return ::xrEndFrame(session, ready ? &patched : endInfo); + } +} + +#define xrEndFrame OutRunVrCaptureChainDiagnostic::EndFrame diff --git a/vrhost/src/runtime/capture_wait_guard.hpp b/vrhost/src/runtime/capture_wait_guard.hpp new file mode 100644 index 000000000..0b25f6521 --- /dev/null +++ b/vrhost/src/runtime/capture_wait_guard.hpp @@ -0,0 +1,33 @@ +#pragma once + +// R24 black-screen recovery: Desktop Duplication is sampled from inside the +// OpenXR frame loop. The legacy compositor intentionally used a 1000 ms wait +// before its first successful capture, which can starve xrEndFrame long enough +// for the HMD compositor to show only black. Keep zero/short waits unchanged but +// clamp any long AcquireNextFrame request to a tiny VR-safe budget. +// +// dxgi1_5.h is included before the macro is installed so COM interface method +// declarations are never rewritten. Subsequent main.cpp/main_r23.cpp call sites +// are the only tokens affected by the compatibility macro below. + +#include +#include + +#include + +namespace OutRunVrCaptureWaitGuard +{ + inline constexpr DWORD MaxAcquireWaitMs = 2; + + inline DWORD ClampDesktopDuplicationWait(DWORD requested) noexcept + { + return std::min(requested, MaxAcquireWaitMs); + } +} + +// Self-token suppression keeps the underlying COM method name intact while +// replacing only its timeout argument, matching the established ReleaseFrame +// instrumentation technique used by the capture fallback layer. +#define AcquireNextFrame(timeoutMs, frameInfo, resource) \ + AcquireNextFrame(OutRunVrCaptureWaitGuard::ClampDesktopDuplicationWait(timeoutMs), \ + frameInfo, resource) diff --git a/vrhost/src/runtime/d3d9ex_direct_passthrough.hpp b/vrhost/src/runtime/d3d9ex_direct_passthrough.hpp new file mode 100644 index 000000000..513d310ad --- /dev/null +++ b/vrhost/src/runtime/d3d9ex_direct_passthrough.hpp @@ -0,0 +1,790 @@ +#pragma once + +// R13 arbitration between the Desktop-Duplication fallback and the D3D9Ex +// shared-eye ring. Shared producer textures are copied into host-owned D3D11 +// textures before the producer slot is acknowledged for reuse. + +#include "sbs_capture_override.hpp" +#include "../../../src/vr/ipc/direct_ack_r13.hpp" + +#ifdef xrEndFrame +#undef xrEndFrame +#endif +#ifdef xrDestroySession +#undef xrDestroySession +#endif + +#include +#include +#include +#include +#include + +namespace OutRunVrD3D9ExDirectPassthrough +{ + inline constexpr const char* BuildId = "D3D9Ex-direct-gpu-copy-R23-20260916"; + inline constexpr const char* LegacyBuildId = "D3D9Ex-direct-passthrough-20260915"; + inline constexpr ULONGLONG FallbackSourceMaxAgeMs = 250; + + // 90 Hz gives roughly 11.1 ms for a complete frame. A 25 ms synchronous + // GPU fence wait could consume more than two frames before fallback. Keep + // this bounded so a delayed direct copy fails closed to the classic path. + inline constexpr ULONGLONG CopyFenceTimeoutMs = 8; + + inline std::uint64_t DirectPassFrames = 0; + inline std::uint64_t FallbackFrames = 0; + inline std::uint64_t DirectCandidateRejected = 0; + inline std::uint64_t SafeCopySuccess = 0; + inline std::uint64_t SafeCopyFailure = 0; + inline std::uint64_t CopyFenceTimeout = 0; + inline std::uint64_t StaleFallbackInvalidations = 0; + inline bool FirstDirectPassLogged = false; + inline bool FirstDirectRejectLogged = false; + inline bool FirstGpuSafeAckLogged = false; + inline bool FirstAckMappingLogged = false; + inline bool FirstCopyFenceTimeoutLogged = false; + inline bool FirstDeclaredFormatMismatchLogged = false; + + inline HANDLE PoseMapping = nullptr; + inline const OutRunVR::SharedPoseState* PoseState = nullptr; + inline HANDLE FrameMapping = nullptr; + inline const OutRunVR::SharedRenderFrameRing* FrameRing = nullptr; + inline HANDLE DirectAckMapping = nullptr; + inline OutRunVR::R13::DirectGpuAckState* DirectAckState = nullptr; + + inline ID3D11Query* CopyFence = nullptr; + inline ID3D11Texture2D* SafeEye[2]{}; + inline ID3D11ShaderResourceView* SafeEyeSrv[2]{}; + inline DXGI_FORMAT SafeEyeFormat = DXGI_FORMAT_UNKNOWN; + inline std::uint32_t SafeEyeWidth = 0; + inline std::uint32_t SafeEyeHeight = 0; + inline std::uint32_t SafeFrameId = 0; + inline std::uint32_t SafeTransportGeneration = 0; + + inline std::uint64_t LastObservedCaptureFresh = 0; + inline ULONGLONG LastCaptureFreshMs = 0; + + template + inline void ReleaseCom(T*& value) + { + if (value) + { + value->Release(); + value = nullptr; + } + } + + struct DirectHostState + { + bool valid = false; + std::uint32_t flags = 0; + std::uint32_t hostPid = 0; + std::uint32_t openedFrame = 0; + }; + + inline DXGI_FORMAT ExpectedDeclaredFormat(std::uint32_t raw) noexcept + { + switch (static_cast(raw)) + { + case D3DFMT_A8R8G8B8: + return DXGI_FORMAT_B8G8R8A8_UNORM; + case D3DFMT_A8B8G8R8: + return DXGI_FORMAT_R8G8B8A8_UNORM; + case D3DFMT_A2B10G10R10: + return DXGI_FORMAT_R10G10B10A2_UNORM; + case D3DFMT_A16B16G16R16F: + return DXGI_FORMAT_R16G16B16A16_FLOAT; + default: + return DXGI_FORMAT_UNKNOWN; + } + } + + inline void ResetSafeEyes() noexcept + { + ReleaseCom(SafeEyeSrv[0]); + ReleaseCom(SafeEye[0]); + ReleaseCom(SafeEyeSrv[1]); + ReleaseCom(SafeEye[1]); + SafeEyeFormat = DXGI_FORMAT_UNKNOWN; + SafeEyeWidth = SafeEyeHeight = 0; + SafeFrameId = 0; + SafeTransportGeneration = 0; + } + + inline void CloseDirectAckMapping() noexcept + { + if (DirectAckState) + { + UnmapViewOfFile(DirectAckState); + DirectAckState = nullptr; + } + if (DirectAckMapping) + { + CloseHandle(DirectAckMapping); + DirectAckMapping = nullptr; + } + } + + inline void CloseReadMappings() noexcept + { + if (PoseState) + { + UnmapViewOfFile(PoseState); + PoseState = nullptr; + } + if (PoseMapping) + { + CloseHandle(PoseMapping); + PoseMapping = nullptr; + } + if (FrameRing) + { + UnmapViewOfFile(FrameRing); + FrameRing = nullptr; + } + if (FrameMapping) + { + CloseHandle(FrameMapping); + FrameMapping = nullptr; + } + } + + inline bool EnsurePoseState() noexcept + { + if (PoseState && PoseState->magic == OutRunVR::SharedMagic && + PoseState->protocolVersion == OutRunVR::SharedProtocolVersion && + PoseState->structSize == sizeof(OutRunVR::SharedPoseState)) + return true; + + if (PoseState) + { + UnmapViewOfFile(PoseState); + PoseState = nullptr; + } + if (PoseMapping) + { + CloseHandle(PoseMapping); + PoseMapping = nullptr; + } + + PoseMapping = OpenFileMappingW(FILE_MAP_READ, FALSE, OutRunVR::SharedMemoryName); + if (!PoseMapping) + return false; + PoseState = static_cast(MapViewOfFile( + PoseMapping, FILE_MAP_READ, 0, 0, sizeof(OutRunVR::SharedPoseState))); + if (!PoseState) + { + CloseHandle(PoseMapping); + PoseMapping = nullptr; + return false; + } + if (PoseState->magic != OutRunVR::SharedMagic || + PoseState->protocolVersion != OutRunVR::SharedProtocolVersion || + PoseState->structSize != sizeof(OutRunVR::SharedPoseState)) + { + UnmapViewOfFile(PoseState); + PoseState = nullptr; + CloseHandle(PoseMapping); + PoseMapping = nullptr; + return false; + } + return true; + } + + inline bool EnsureFrameRing() noexcept + { + if (FrameRing && FrameRing->magic == OutRunVR::RenderFrameMagic && + FrameRing->protocolVersion == OutRunVR::RenderFrameProtocolVersion && + FrameRing->structSize == sizeof(OutRunVR::SharedRenderFrameRing) && + FrameRing->slotCount == OutRunVR::RenderFrameRingSize) + return true; + + if (FrameRing) + { + UnmapViewOfFile(FrameRing); + FrameRing = nullptr; + } + if (FrameMapping) + { + CloseHandle(FrameMapping); + FrameMapping = nullptr; + } + FrameMapping = OpenFileMappingW(FILE_MAP_READ, FALSE, + OutRunVR::RenderFrameMemoryName); + if (!FrameMapping) + return false; + FrameRing = static_cast(MapViewOfFile( + FrameMapping, FILE_MAP_READ, 0, 0, + sizeof(OutRunVR::SharedRenderFrameRing))); + if (!FrameRing) + { + CloseHandle(FrameMapping); + FrameMapping = nullptr; + return false; + } + if (FrameRing->magic != OutRunVR::RenderFrameMagic || + FrameRing->protocolVersion != OutRunVR::RenderFrameProtocolVersion || + FrameRing->structSize != sizeof(OutRunVR::SharedRenderFrameRing) || + FrameRing->slotCount != OutRunVR::RenderFrameRingSize) + { + CloseReadMappings(); + return false; + } + return true; + } + + inline bool BeginAckWrite() noexcept + { + if (!DirectAckState) + return false; + LONG seq = InterlockedIncrement( + reinterpret_cast(&DirectAckState->sequence)); + if ((seq & 1) == 0) + InterlockedIncrement( + reinterpret_cast(&DirectAckState->sequence)); + MemoryBarrier(); + return true; + } + + inline void EndAckWrite() noexcept + { + MemoryBarrier(); + LONG seq = InterlockedIncrement( + reinterpret_cast(&DirectAckState->sequence)); + if (seq & 1) + InterlockedIncrement( + reinterpret_cast(&DirectAckState->sequence)); + } + + inline bool EnsureDirectAckState() noexcept + { + if (DirectAckState && + DirectAckState->magic == OutRunVR::R13::DirectGpuAckMagic && + DirectAckState->version == OutRunVR::R13::DirectGpuAckVersion && + DirectAckState->structSize == sizeof(OutRunVR::R13::DirectGpuAckState) && + DirectAckState->hostPid == GetCurrentProcessId()) + return true; + + CloseDirectAckMapping(); + DirectAckMapping = CreateFileMappingW( + INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE, 0, + static_cast(sizeof(OutRunVR::R13::DirectGpuAckState)), + OutRunVR::R13::DirectGpuAckName); + if (!DirectAckMapping) + return false; + + const bool existed = GetLastError() == ERROR_ALREADY_EXISTS; + DirectAckState = static_cast(MapViewOfFile( + DirectAckMapping, FILE_MAP_ALL_ACCESS, 0, 0, + sizeof(OutRunVR::R13::DirectGpuAckState))); + if (!DirectAckState) + { + CloseDirectAckMapping(); + return false; + } + + const bool validExisting = existed && + DirectAckState->magic == OutRunVR::R13::DirectGpuAckMagic && + DirectAckState->version == OutRunVR::R13::DirectGpuAckVersion && + DirectAckState->structSize == sizeof(OutRunVR::R13::DirectGpuAckState); + if (existed && !validExisting) + { + std::cerr + << "[D3D9Ex R23] incompatible DirectGpuAck mapping; direct path disabled\n"; + CloseDirectAckMapping(); + return false; + } + + if (!existed) + { + std::memset(DirectAckState, 0, sizeof(*DirectAckState)); + DirectAckState->version = OutRunVR::R13::DirectGpuAckVersion; + DirectAckState->structSize = sizeof(*DirectAckState); + DirectAckState->hostPid = GetCurrentProcessId(); + MemoryBarrier(); + DirectAckState->magic = OutRunVR::R13::DirectGpuAckMagic; + } + else + { + BeginAckWrite(); + DirectAckState->magic = OutRunVR::R13::DirectGpuAckMagic; + DirectAckState->version = OutRunVR::R13::DirectGpuAckVersion; + DirectAckState->structSize = sizeof(*DirectAckState); + DirectAckState->hostPid = GetCurrentProcessId(); + DirectAckState->transportGeneration = 0; + std::memset(DirectAckState->completedFrameId, 0, + sizeof(DirectAckState->completedFrameId)); + EndAckWrite(); + } + + if (!FirstAckMappingLogged) + { + FirstAckMappingLogged = true; + std::cerr + << "[D3D9Ex R23] dedicated per-slot DirectGpuAck mapping ready\n"; + } + return true; + } + + inline DirectHostState ReadDirectHostState() noexcept + { + DirectHostState out{}; + if (!EnsurePoseState()) + return out; + + for (int attempt = 0; attempt < 4; ++attempt) + { + const std::uint32_t before = PoseState->sequence; + if (before & 1u) + continue; + MemoryBarrier(); + out.flags = PoseState->flags; + out.hostPid = PoseState->hostPid; + out.openedFrame = PoseState->hostDirectConsumedFrameId; + MemoryBarrier(); + const std::uint32_t after = PoseState->sequence; + if (before != after || (after & 1u)) + continue; + + constexpr std::uint32_t required = + OutRunVR::HostAlive | + OutRunVR::HostDirectGpuTransport | + OutRunVR::HostDirectGpuReady; + out.valid = out.hostPid == GetCurrentProcessId() && + (out.flags & required) == required; + return out; + } + return {}; + } + + inline bool ReadFrameById(std::uint32_t frameId, + OutRunVR::SharedRenderFrameState& out) noexcept + { + if (!frameId || !EnsureFrameRing()) + return false; + + for (int ringAttempt = 0; ringAttempt < 6; ++ringAttempt) + { + const std::uint32_t ringBefore = FrameRing->publishSequence; + if (ringBefore & 1u) + continue; + + bool found = false; + OutRunVR::SharedRenderFrameState selected{}; + for (std::uint32_t i = 0; i < OutRunVR::RenderFrameRingSize && !found; ++i) + { + const auto& slot = FrameRing->slots[i]; + for (int attempt = 0; attempt < 4; ++attempt) + { + const std::uint32_t before = slot.sequence; + if (before & 1u) + continue; + MemoryBarrier(); + OutRunVR::SharedRenderFrameState candidate{}; + std::memcpy(&candidate, &slot, sizeof(candidate)); + MemoryBarrier(); + const std::uint32_t after = slot.sequence; + if (before == after && !(after & 1u) && + candidate.magic == OutRunVR::RenderFrameMagic && + candidate.protocolVersion == OutRunVR::RenderFrameProtocolVersion && + candidate.structSize == sizeof(candidate) && + OutRunVR::RenderFrameRunIdentityMatches(*FrameRing, candidate) && + candidate.frameId == frameId && + candidate.state == OutRunVR::StereoSbsActive && + (candidate.flags & OutRunVR::RenderFrameDirectGpuTransport) != 0 && + (candidate.flags & OutRunVR::RenderFramePresentInFlight) == 0) + { + selected = candidate; + found = true; + break; + } + } + } + + MemoryBarrier(); + const std::uint32_t ringAfter = FrameRing->publishSequence; + if (found && ringBefore == ringAfter && !(ringAfter & 1u)) + { + out = selected; + return true; + } + } + return false; + } + + inline bool IncomingProjectionValid(const XrFrameEndInfo* endInfo) noexcept + { + return endInfo && endInfo->layerCount > 0 && endInfo->layers && + endInfo->layers[0] && + endInfo->layers[0]->type == XR_TYPE_COMPOSITION_LAYER_PROJECTION; + } + + inline bool EnsureCopyFence() noexcept + { + if (CopyFence) + return true; + if (!OutRunVrFinalTest::Device) + return false; + D3D11_QUERY_DESC desc{}; + desc.Query = D3D11_QUERY_EVENT; + return SUCCEEDED(OutRunVrFinalTest::Device->CreateQuery(&desc, &CopyFence)) && + CopyFence; + } + + inline bool WaitForCopyFence() noexcept + { + if (!CopyFence || !OutRunVrFinalTest::Context) + return false; + OutRunVrFinalTest::Context->End(CopyFence); + OutRunVrFinalTest::Context->Flush(); + const ULONGLONG start = GetTickCount64(); + for (;;) + { + const HRESULT hr = OutRunVrFinalTest::Context->GetData( + CopyFence, nullptr, 0, D3D11_ASYNC_GETDATA_DONOTFLUSH); + if (hr == S_OK) + return true; + if (FAILED(hr) || GetTickCount64() - start >= CopyFenceTimeoutMs) + { + ++CopyFenceTimeout; + if (!FirstCopyFenceTimeoutLogged) + { + FirstCopyFenceTimeoutLogged = true; + std::cerr + << "[D3D9Ex R23] direct copy fence exceeded " + << CopyFenceTimeoutMs + << "ms VR budget; dropping direct candidate to fallback\n"; + } + return false; + } + SwitchToThread(); + } + } + + inline bool EnsureSafeEyes(const D3D11_TEXTURE2D_DESC& desc) noexcept + { + if (SafeEye[0] && SafeEye[1] && SafeEyeSrv[0] && SafeEyeSrv[1] && + SafeEyeWidth == desc.Width && SafeEyeHeight == desc.Height && + SafeEyeFormat == desc.Format) + return true; + + ResetSafeEyes(); + D3D11_TEXTURE2D_DESC safe = desc; + safe.MipLevels = 1; + safe.ArraySize = 1; + safe.SampleDesc.Count = 1; + safe.SampleDesc.Quality = 0; + safe.Usage = D3D11_USAGE_DEFAULT; + safe.BindFlags = D3D11_BIND_SHADER_RESOURCE; + safe.CPUAccessFlags = 0; + safe.MiscFlags = 0; + for (int eye = 0; eye < 2; ++eye) + { + if (FAILED(OutRunVrFinalTest::Device->CreateTexture2D( + &safe, nullptr, &SafeEye[eye])) || !SafeEye[eye] || + FAILED(OutRunVrFinalTest::Device->CreateShaderResourceView( + SafeEye[eye], nullptr, &SafeEyeSrv[eye])) || !SafeEyeSrv[eye]) + { + ResetSafeEyes(); + return false; + } + } + SafeEyeWidth = safe.Width; + SafeEyeHeight = safe.Height; + SafeEyeFormat = safe.Format; + return true; + } + + inline bool PublishCompletedFrame( + const OutRunVR::SharedRenderFrameState& frame) noexcept + { + if (!EnsureDirectAckState()) + return false; + const std::uint32_t slot = + frame.reserved[OutRunVR::RenderFrameDirectSlotIndex]; + const std::uint32_t generation = + frame.reserved[OutRunVR::RenderFrameDirectGenerationIndex]; + if (slot >= OutRunVR::R13::DirectGpuAckRingSize || !generation || + !frame.frameId) + return false; + + BeginAckWrite(); + if (DirectAckState->transportGeneration != generation) + { + DirectAckState->transportGeneration = generation; + std::memset(DirectAckState->completedFrameId, 0, + sizeof(DirectAckState->completedFrameId)); + } + DirectAckState->hostPid = GetCurrentProcessId(); + DirectAckState->completedFrameId[slot] = frame.frameId; + EndAckWrite(); + return true; + } + + inline bool CopySharedFrameToSafeEyes( + const OutRunVR::SharedRenderFrameState& frame) noexcept + { + if (!OutRunVrFinalTest::Device || !OutRunVrFinalTest::Context || + !EnsureCopyFence()) + return false; + + const std::uint32_t leftRaw = + frame.reserved[OutRunVR::RenderFrameDirectLeftHandleIndex]; + const std::uint32_t rightRaw = + frame.reserved[OutRunVR::RenderFrameDirectRightHandleIndex]; + const std::uint32_t width = + frame.reserved[OutRunVR::RenderFrameDirectWidthIndex]; + const std::uint32_t height = + frame.reserved[OutRunVR::RenderFrameDirectHeightIndex]; + const DXGI_FORMAT declared = ExpectedDeclaredFormat( + frame.reserved[OutRunVR::RenderFrameDirectFormatIndex]); + if (!leftRaw || !rightRaw || !width || !height || + declared == DXGI_FORMAT_UNKNOWN) + return false; + + ID3D11Texture2D* shared[2]{}; + bool ok = true; + const std::uint32_t handles[2]{ leftRaw, rightRaw }; + for (int eye = 0; eye < 2 && ok; ++eye) + { + ID3D11Resource* resource = nullptr; + const HANDLE handle = reinterpret_cast( + static_cast(handles[eye])); + if (FAILED(OutRunVrFinalTest::Device->OpenSharedResource( + handle, __uuidof(ID3D11Resource), + reinterpret_cast(&resource))) || !resource) + { + ok = false; + break; + } + const HRESULT qi = resource->QueryInterface( + __uuidof(ID3D11Texture2D), reinterpret_cast(&shared[eye])); + resource->Release(); + if (FAILED(qi) || !shared[eye]) + ok = false; + } + + D3D11_TEXTURE2D_DESC desc[2]{}; + if (ok) + { + shared[0]->GetDesc(&desc[0]); + shared[1]->GetDesc(&desc[1]); + ok = desc[0].Width == width && desc[0].Height == height && + desc[1].Width == width && desc[1].Height == height && + desc[0].Format == desc[1].Format && + desc[0].Format == declared && + desc[0].SampleDesc.Count == 1 && + desc[1].SampleDesc.Count == 1 && + EnsureSafeEyes(desc[0]); + if (!ok && desc[0].Format != declared && + !FirstDeclaredFormatMismatchLogged) + { + FirstDeclaredFormatMismatchLogged = true; + std::cerr + << "[D3D9Ex R23] Frame.v2 declared direct format does not match opened D3D11 texture; candidate rejected\n"; + } + } + + if (ok) + { + OutRunVrFinalTest::Context->CopyResource(SafeEye[0], shared[0]); + OutRunVrFinalTest::Context->CopyResource(SafeEye[1], shared[1]); + ok = WaitForCopyFence(); + } + + ReleaseCom(shared[0]); + ReleaseCom(shared[1]); + if (!ok || !PublishCompletedFrame(frame)) + { + ++SafeCopyFailure; + return false; + } + + SafeFrameId = frame.frameId; + SafeTransportGeneration = + frame.reserved[OutRunVR::RenderFrameDirectGenerationIndex]; + ++SafeCopySuccess; + if (!FirstGpuSafeAckLogged) + { + FirstGpuSafeAckLogged = true; + std::cerr + << "[D3D9Ex R23] host-owned GPU eye copies + completion ACK active frame=" + << frame.frameId + << "; dedicated per-slot ACK allows shared ring reuse\n"; + } + return true; + } + + inline bool EnsureSafeFrame(std::uint32_t frameId) noexcept + { + OutRunVR::SharedRenderFrameState frame{}; + if (!ReadFrameById(frameId, frame)) + return false; + const std::uint32_t generation = + frame.reserved[OutRunVR::RenderFrameDirectGenerationIndex]; + if (frameId && generation && SafeFrameId == frameId && + SafeTransportGeneration == generation && + SafeEyeSrv[0] && SafeEyeSrv[1]) + return true; + return CopySharedFrameToSafeEyes(frame); + } + + inline bool RenderSafeProjection(XrSession session, + const XrFrameEndInfo* endInfo, + XrCompositionLayerProjection& projection, + std::array& views) noexcept + { + using namespace OutRunVrSbsCaptureOverride; + if (!IncomingProjectionValid(endInfo) || !SafeEyeSrv[0] || !SafeEyeSrv[1]) + return false; + const auto* incoming = reinterpret_cast( + endInfo->layers[0]); + if (incoming->viewCount < 2 || !incoming->views) + return false; + + const std::uint32_t width = std::max( + 1, incoming->views[0].subImage.imageRect.extent.width); + const std::uint32_t height = std::max( + 1, incoming->views[0].subImage.imageRect.extent.height); + if (!EnsureSwapchain(Projection, session, width, height, 2) || + !CreateShaders()) + return false; + + std::uint32_t image = 0; + if (!Acquire(Projection, image)) + return false; + if (image >= Projection.rtvs.size()) + { + Release(Projection); + return false; + } + + ID3D11ShaderResourceView* savedSrv = SourceSrv; + const DXGI_FORMAT savedFormat = SourceFormat; + bool ok = true; + for (int eye = 0; eye < 2; ++eye) + { + SourceSrv = SafeEyeSrv[eye]; + SourceFormat = SafeEyeFormat; + ok = RenderTo(Projection.rtvs[image][eye], Projection.width, + Projection.height, UvRect{ 0.f, 0.f, 1.f, 1.f }) && ok; + } + SourceSrv = savedSrv; + SourceFormat = savedFormat; + if (OutRunVrFinalTest::Context) + OutRunVrFinalTest::Context->Flush(); + Release(Projection); + if (!ok) + return false; + + projection = *incoming; + for (int eye = 0; eye < 2; ++eye) + { + views[eye] = incoming->views[eye]; + views[eye].subImage.swapchain = Projection.handle; + views[eye].subImage.imageRect.offset = { 0, 0 }; + views[eye].subImage.imageRect.extent = { + static_cast(Projection.width), + static_cast(Projection.height) + }; + views[eye].subImage.imageArrayIndex = eye; + } + projection.viewCount = 2; + projection.views = views.data(); + return true; + } + + inline void ObserveCaptureFreshness() noexcept + { + using namespace OutRunVrSbsCaptureOverride; + if (CaptureFresh != LastObservedCaptureFresh) + { + LastObservedCaptureFresh = CaptureFresh; + LastCaptureFreshMs = GetTickCount64(); + } + } + + inline void InvalidateStaleFallbackSource() noexcept + { + using namespace OutRunVrSbsCaptureOverride; + ObserveCaptureFreshness(); + if (HaveSource && LastCaptureFreshMs != 0 && + GetTickCount64() - LastCaptureFreshMs > FallbackSourceMaxAgeMs) + { + HaveSource = false; + ++StaleFallbackInvalidations; + std::cerr << "[R23] stale Desktop Duplication source invalidated after " + << FallbackSourceMaxAgeMs << "ms without a fresh capture\n"; + } + } + + inline void ResetR13HostState() noexcept + { + ResetSafeEyes(); + ReleaseCom(CopyFence); + CloseDirectAckMapping(); + CloseReadMappings(); + LastObservedCaptureFresh = 0; + LastCaptureFreshMs = 0; + } + + inline XrResult XRAPI_CALL EndFrame(XrSession session, + const XrFrameEndInfo* endInfo) + { + const DirectHostState state = ReadDirectHostState(); + const bool directCandidate = IncomingProjectionValid(endInfo) && + state.valid && state.openedFrame != 0; + + if (directCandidate && EnsureSafeFrame(state.openedFrame)) + { + XrCompositionLayerProjection projection{}; + std::array views{}; + if (RenderSafeProjection(session, endInfo, projection, views)) + { + const XrCompositionLayerBaseHeader* layer = + reinterpret_cast(&projection); + XrFrameEndInfo patched = *endInfo; + patched.layerCount = 1; + patched.layers = &layer; + ++DirectPassFrames; + if (!FirstDirectPassLogged) + { + FirstDirectPassLogged = true; + std::cerr + << "[D3D9Ex] DIRECT GPU-COPY projection passthrough ACTIVE build=" + << BuildId << " legacy=" << LegacyBuildId + << " frame=" << state.openedFrame + << "; shared eyes copied to host-owned GPU textures\n"; + } + return OutRunVrFinalTest::EndFrame(session, &patched); + } + } + + if (directCandidate) + { + ++DirectCandidateRejected; + if (!FirstDirectRejectLogged) + { + FirstDirectRejectLogged = true; + std::cerr + << "[D3D9Ex R23] direct frame could not be staged/rendered safely; keeping Desktop Duplication fallback\n"; + } + } + + ++FallbackFrames; + InvalidateStaleFallbackSource(); + const XrResult result = + OutRunVrSbsCaptureOverride::EndFrame(session, endInfo); + ObserveCaptureFreshness(); + return result; + } + + inline XrResult XRAPI_CALL DestroySession(XrSession session) + { + ResetR13HostState(); + return OutRunVrSbsCaptureOverride::DestroySession(session); + } +} + +#define xrEndFrame OutRunVrD3D9ExDirectPassthrough::EndFrame +#define xrDestroySession OutRunVrD3D9ExDirectPassthrough::DestroySession diff --git a/vrhost/src/runtime/d3d9ex_direct_passthrough_r32.hpp b/vrhost/src/runtime/d3d9ex_direct_passthrough_r32.hpp new file mode 100644 index 000000000..8be504fdd --- /dev/null +++ b/vrhost/src/runtime/d3d9ex_direct_passthrough_r32.hpp @@ -0,0 +1,291 @@ +#pragma once + +// R32 host-side DirectGPU safety staging. +// +// The R23 SafeEye path copied into the single active pair before waiting for the +// GPU fence. If that wait timed out, the queued copy could complete later while +// SafeFrameId still named the previous frame. R32 copies into an inactive pair +// and swaps it into the active SafeEye only after completion + ACK. Shared +// D3D9Ex handles are cached per ring slot/generation so fallback staging no +// longer re-opens the same resources every frame. + +#include "d3d9ex_direct_passthrough.hpp" + +#include +#include +#include + +namespace OutRunVrD3D9ExDirectPassthrough +{ + inline constexpr const char* R32SafeEyeBuildId = + "D3D9Ex-safe-eye-ab-cache-R32-20260917"; + + struct R32SharedSlotCache + { + std::uint32_t generation = 0; + std::uint32_t leftHandle = 0; + std::uint32_t rightHandle = 0; + std::uint32_t width = 0; + std::uint32_t height = 0; + DXGI_FORMAT format = DXGI_FORMAT_UNKNOWN; + ID3D11Texture2D* eye[2]{}; + }; + + inline std::array R32SharedSlots{}; + inline ID3D11Texture2D* R32InactiveEye[2]{}; + inline ID3D11ShaderResourceView* R32InactiveSrv[2]{}; + inline DXGI_FORMAT R32InactiveFormat = DXGI_FORMAT_UNKNOWN; + inline std::uint32_t R32InactiveWidth = 0; + inline std::uint32_t R32InactiveHeight = 0; + + inline std::uint64_t R32SharedCacheHits = 0; + inline std::uint64_t R32SharedCacheMisses = 0; + inline std::uint64_t R32SafeSwaps = 0; + inline std::uint64_t R32SafeTimeoutPreserves = 0; + inline bool R32FirstSafeSwapLogged = false; + inline bool R32FirstTimeoutPreserveLogged = false; + + inline void R32ReleaseSharedSlot(R32SharedSlotCache& slot) noexcept + { + ReleaseCom(slot.eye[0]); + ReleaseCom(slot.eye[1]); + slot = {}; + } + + inline void R32ReleaseInactiveEyes() noexcept + { + ReleaseCom(R32InactiveSrv[0]); + ReleaseCom(R32InactiveEye[0]); + ReleaseCom(R32InactiveSrv[1]); + ReleaseCom(R32InactiveEye[1]); + R32InactiveFormat = DXGI_FORMAT_UNKNOWN; + R32InactiveWidth = 0; + R32InactiveHeight = 0; + } + + inline void R32ResetDirectCaches() noexcept + { + for (auto& slot : R32SharedSlots) + R32ReleaseSharedSlot(slot); + R32ReleaseInactiveEyes(); + R32SharedCacheHits = 0; + R32SharedCacheMisses = 0; + R32SafeSwaps = 0; + R32SafeTimeoutPreserves = 0; + } + + inline bool R32OpenSharedSlot( + const OutRunVR::SharedRenderFrameState& frame, + R32SharedSlotCache*& out) noexcept + { + out = nullptr; + if (!OutRunVrFinalTest::Device) + return false; + + const std::uint32_t slotIndex = + frame.reserved[OutRunVR::RenderFrameDirectSlotIndex]; + const std::uint32_t generation = + frame.reserved[OutRunVR::RenderFrameDirectGenerationIndex]; + const std::uint32_t left = + frame.reserved[OutRunVR::RenderFrameDirectLeftHandleIndex]; + const std::uint32_t right = + frame.reserved[OutRunVR::RenderFrameDirectRightHandleIndex]; + const std::uint32_t width = + frame.reserved[OutRunVR::RenderFrameDirectWidthIndex]; + const std::uint32_t height = + frame.reserved[OutRunVR::RenderFrameDirectHeightIndex]; + const DXGI_FORMAT declared = ExpectedDeclaredFormat( + frame.reserved[OutRunVR::RenderFrameDirectFormatIndex]); + + if (slotIndex >= R32SharedSlots.size() || !generation || !left || + !right || !width || !height || declared == DXGI_FORMAT_UNKNOWN) + return false; + + auto& cache = R32SharedSlots[slotIndex]; + if (cache.eye[0] && cache.eye[1] && + cache.generation == generation && + cache.leftHandle == left && cache.rightHandle == right && + cache.width == width && cache.height == height && + cache.format == declared) + { + ++R32SharedCacheHits; + out = &cache; + return true; + } + + ++R32SharedCacheMisses; + R32ReleaseSharedSlot(cache); + + const std::uint32_t handles[2]{ left, right }; + for (int eye = 0; eye < 2; ++eye) + { + ID3D11Resource* resource = nullptr; + const HANDLE handle = reinterpret_cast( + static_cast(handles[eye])); + if (FAILED(OutRunVrFinalTest::Device->OpenSharedResource( + handle, __uuidof(ID3D11Resource), + reinterpret_cast(&resource))) || !resource) + { + R32ReleaseSharedSlot(cache); + return false; + } + const HRESULT qi = resource->QueryInterface( + __uuidof(ID3D11Texture2D), + reinterpret_cast(&cache.eye[eye])); + resource->Release(); + if (FAILED(qi) || !cache.eye[eye]) + { + R32ReleaseSharedSlot(cache); + return false; + } + } + + D3D11_TEXTURE2D_DESC desc[2]{}; + cache.eye[0]->GetDesc(&desc[0]); + cache.eye[1]->GetDesc(&desc[1]); + const bool valid = + desc[0].Width == width && desc[0].Height == height && + desc[1].Width == width && desc[1].Height == height && + desc[0].Format == declared && desc[1].Format == declared && + desc[0].SampleDesc.Count == 1 && desc[1].SampleDesc.Count == 1; + if (!valid) + { + R32ReleaseSharedSlot(cache); + return false; + } + + cache.generation = generation; + cache.leftHandle = left; + cache.rightHandle = right; + cache.width = width; + cache.height = height; + cache.format = declared; + out = &cache; + return true; + } + + inline bool R32EnsureInactiveEyes( + const D3D11_TEXTURE2D_DESC& source) noexcept + { + if (R32InactiveEye[0] && R32InactiveEye[1] && + R32InactiveSrv[0] && R32InactiveSrv[1] && + R32InactiveWidth == source.Width && + R32InactiveHeight == source.Height && + R32InactiveFormat == source.Format) + return true; + + R32ReleaseInactiveEyes(); + D3D11_TEXTURE2D_DESC safe = source; + safe.MipLevels = 1; + safe.ArraySize = 1; + safe.SampleDesc.Count = 1; + safe.SampleDesc.Quality = 0; + safe.Usage = D3D11_USAGE_DEFAULT; + safe.BindFlags = D3D11_BIND_SHADER_RESOURCE; + safe.CPUAccessFlags = 0; + safe.MiscFlags = 0; + + for (int eye = 0; eye < 2; ++eye) + { + if (FAILED(OutRunVrFinalTest::Device->CreateTexture2D( + &safe, nullptr, &R32InactiveEye[eye])) || + !R32InactiveEye[eye] || + FAILED(OutRunVrFinalTest::Device->CreateShaderResourceView( + R32InactiveEye[eye], nullptr, &R32InactiveSrv[eye])) || + !R32InactiveSrv[eye]) + { + R32ReleaseInactiveEyes(); + return false; + } + } + R32InactiveWidth = safe.Width; + R32InactiveHeight = safe.Height; + R32InactiveFormat = safe.Format; + return true; + } + + inline bool CopySharedFrameToSafeEyesR32( + const OutRunVR::SharedRenderFrameState& frame) noexcept + { + if (!OutRunVrFinalTest::Context || !EnsureCopyFence()) + return false; + + R32SharedSlotCache* source = nullptr; + if (!R32OpenSharedSlot(frame, source) || !source) + return false; + + D3D11_TEXTURE2D_DESC desc{}; + source->eye[0]->GetDesc(&desc); + if (!R32EnsureInactiveEyes(desc)) + return false; + + OutRunVrFinalTest::Context->CopyResource( + R32InactiveEye[0], source->eye[0]); + OutRunVrFinalTest::Context->CopyResource( + R32InactiveEye[1], source->eye[1]); + + if (!WaitForCopyFence()) + { + // The copy may still finish later, but it targets only the inactive + // pair. SafeEye + SafeFrameId continue to describe the old image. + ++R32SafeTimeoutPreserves; + ++SafeCopyFailure; + if (!R32FirstTimeoutPreserveLogged) + { + R32FirstTimeoutPreserveLogged = true; + std::cerr + << "[D3D9Ex R32] SafeEye fence timeout preserved the previous active pair; queued copy cannot mutate SafeFrameId image identity\n"; + } + return false; + } + + if (!PublishCompletedFrame(frame)) + { + ++SafeCopyFailure; + return false; + } + + for (int eye = 0; eye < 2; ++eye) + { + std::swap(SafeEye[eye], R32InactiveEye[eye]); + std::swap(SafeEyeSrv[eye], R32InactiveSrv[eye]); + } + std::swap(SafeEyeFormat, R32InactiveFormat); + std::swap(SafeEyeWidth, R32InactiveWidth); + std::swap(SafeEyeHeight, R32InactiveHeight); + + SafeFrameId = frame.frameId; + SafeTransportGeneration = + frame.reserved[OutRunVR::RenderFrameDirectGenerationIndex]; + ++SafeCopySuccess; + ++R32SafeSwaps; + if (!R32FirstSafeSwapLogged) + { + R32FirstSafeSwapLogged = true; + std::cerr + << "[D3D9Ex R32] SafeEye A/B staging + per-slot shared-resource cache ACTIVE; host-owned GPU eye copies + completion ACK active build=" + << R32SafeEyeBuildId << "\n"; + } + return true; + } + + inline bool EnsureSafeFrameR32(std::uint32_t frameId) noexcept + { + OutRunVR::SharedRenderFrameState frame{}; + if (!ReadFrameById(frameId, frame)) + return false; + const std::uint32_t generation = + frame.reserved[OutRunVR::RenderFrameDirectGenerationIndex]; + if (frameId && generation && SafeFrameId == frameId && + SafeTransportGeneration == generation && + SafeEyeSrv[0] && SafeEyeSrv[1]) + return true; + return CopySharedFrameToSafeEyesR32(frame); + } +} + +// Later R22/R23/R24 headers call EnsureSafeFrame unqualified after importing the +// DirectPassthrough namespace. Redirect those final safety paths to R32 A/B +// staging while leaving the validated R23 implementation available as fallback. +#define EnsureSafeFrame EnsureSafeFrameR32 diff --git a/vrhost/src/runtime/openxr_api_compat.hpp b/vrhost/src/runtime/openxr_api_compat.hpp new file mode 100644 index 000000000..83b2ab9ed --- /dev/null +++ b/vrhost/src/runtime/openxr_api_compat.hpp @@ -0,0 +1,265 @@ +#pragma once + +// R20 production OpenXR compatibility/state shim. +// +// VirtualDesktopXR 1.0.x is the primary runtime for this project. Keep the +// application API request on the OpenXR 1.0 baseline and retain only the small +// amount of state required by the production SBS capture layer. The old R9 +// alternating-color no-layer test swapchain is intentionally gone: production +// xrEndFrame is a direct pass-through here and sbs_capture_override.hpp owns the +// only production end-frame replacement. + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif + +#include +#include + +#ifndef XR_USE_PLATFORM_WIN32 +#define OUTRUN_R20_DEFINED_XR_PLATFORM 1 +#define XR_USE_PLATFORM_WIN32 +#endif +#ifndef XR_USE_GRAPHICS_API_D3D11 +#define OUTRUN_R20_DEFINED_XR_D3D11 1 +#define XR_USE_GRAPHICS_API_D3D11 +#endif + +#include +#include + +#ifdef OUTRUN_R20_DEFINED_XR_PLATFORM +#undef XR_USE_PLATFORM_WIN32 +#undef OUTRUN_R20_DEFINED_XR_PLATFORM +#endif +#ifdef OUTRUN_R20_DEFINED_XR_D3D11 +#undef XR_USE_GRAPHICS_API_D3D11 +#undef OUTRUN_R20_DEFINED_XR_D3D11 +#endif + +#include +#include +#include + +#include "vr_shared.hpp" + +#ifdef XR_CURRENT_API_VERSION +#undef XR_CURRENT_API_VERSION +#endif +#define XR_CURRENT_API_VERSION XR_MAKE_VERSION(1, 0, 0) + +static_assert(XR_VERSION_MAJOR(XR_CURRENT_API_VERSION) == 1 && + XR_VERSION_MINOR(XR_CURRENT_API_VERSION) == 0, + "OutRun VR host must request the OpenXR 1.0 application API baseline"); + +namespace OutRunVrFinalTest +{ + // Name retained because the R19 production capture layer consumed this + // helper namespace. The contents are production-only in R20. + inline constexpr const char* BuildId = "R20-production-host-20260916"; + + inline XrSession Session = XR_NULL_HANDLE; + // BaseLocalSpace is the runtime's original LOCAL origin. LocalSpace is the + // application-visible origin and may be replaced by user recenter. + inline XrSpace BaseLocalSpace = XR_NULL_HANDLE; + inline XrSpace LocalSpace = XR_NULL_HANDLE; + inline ID3D11Device* Device = nullptr; + inline ID3D11DeviceContext* Context = nullptr; + + inline HANDLE FrameMapping = nullptr; + inline const OutRunVR::SharedRenderFrameRing* FrameRing = nullptr; + + inline void CloseFrameMapping() noexcept + { + if (FrameRing) + { + UnmapViewOfFile(FrameRing); + FrameRing = nullptr; + } + if (FrameMapping) + { + CloseHandle(FrameMapping); + FrameMapping = nullptr; + } + } + + inline void ReleaseGraphicsBinding() noexcept + { + if (Context) + { + Context->Release(); + Context = nullptr; + } + if (Device) + { + Device->Release(); + Device = nullptr; + } + } + + inline void EnsureFrameMapping() noexcept + { + if (FrameRing) + return; + FrameMapping = OpenFileMappingW( + FILE_MAP_READ, FALSE, OutRunVR::RenderFrameMemoryName); + if (!FrameMapping) + return; + FrameRing = static_cast( + MapViewOfFile(FrameMapping, FILE_MAP_READ, 0, 0, + sizeof(OutRunVR::SharedRenderFrameRing))); + if (!FrameRing) + { + CloseHandle(FrameMapping); + FrameMapping = nullptr; + } + } + + inline bool ReadLatestFrame(OutRunVR::SharedRenderFrameState& out, + std::uint32_t& publishSequence) noexcept + { + EnsureFrameMapping(); + if (!FrameRing || FrameRing->magic != OutRunVR::RenderFrameMagic || + FrameRing->protocolVersion != OutRunVR::RenderFrameProtocolVersion || + FrameRing->structSize != sizeof(OutRunVR::SharedRenderFrameRing) || + FrameRing->slotCount != OutRunVR::RenderFrameRingSize) + return false; + + for (int attempt = 0; attempt < 4; ++attempt) + { + const std::uint32_t before = FrameRing->publishSequence; + if (before & 1u) + continue; + MemoryBarrier(); + const std::uint32_t slot = FrameRing->latestSlot; + if (slot >= OutRunVR::RenderFrameRingSize) + return false; + const auto& src = FrameRing->slots[slot]; + const std::uint32_t frameBefore = src.sequence; + if (frameBefore & 1u) + continue; + MemoryBarrier(); + std::memcpy(&out, &src, sizeof(out)); + MemoryBarrier(); + const std::uint32_t frameAfter = src.sequence; + const std::uint32_t after = FrameRing->publishSequence; + if (before == after && frameBefore == frameAfter && + !(after & 1u) && !(frameAfter & 1u)) + { + publishSequence = after; + return out.magic == OutRunVR::RenderFrameMagic && + out.protocolVersion == OutRunVR::RenderFrameProtocolVersion && + out.structSize == sizeof(out) && + OutRunVR::RenderFrameRunIdentityMatches(*FrameRing, out); + } + } + return false; + } + + inline XrResult XRAPI_CALL CreateInstance( + const XrInstanceCreateInfo* info, XrInstance* instance) + { + const XrVersion v = info ? info->applicationInfo.apiVersion : 0; + std::cerr << "[R20] build=" << BuildId + << " requestedApi=" << XR_VERSION_MAJOR(v) << "." + << XR_VERSION_MINOR(v) << "." << XR_VERSION_PATCH(v) + << " testPattern=removed\n"; + const XrResult result = ::xrCreateInstance(info, instance); + if (XR_SUCCEEDED(result) && instance && *instance != XR_NULL_HANDLE) + { + XrInstanceProperties props{ XR_TYPE_INSTANCE_PROPERTIES }; + if (XR_SUCCEEDED(::xrGetInstanceProperties(*instance, &props))) + { + std::cerr << "[R20] runtime=" << props.runtimeName + << " runtimeVersion=" + << XR_VERSION_MAJOR(props.runtimeVersion) << "." + << XR_VERSION_MINOR(props.runtimeVersion) << "." + << XR_VERSION_PATCH(props.runtimeVersion) << "\n"; + } + } + return result; + } + + inline XrResult XRAPI_CALL CreateSession(XrInstance instance, + const XrSessionCreateInfo* info, XrSession* session) + { + const XrResult result = ::xrCreateSession(instance, info, session); + if (XR_FAILED(result) || !session) + return result; + + ReleaseGraphicsBinding(); + Session = *session; + const XrBaseInStructure* next = info + ? reinterpret_cast(info->next) : nullptr; + while (next) + { + if (next->type == XR_TYPE_GRAPHICS_BINDING_D3D11_KHR) + { + const auto* binding = + reinterpret_cast(next); + if (binding->device) + { + Device = binding->device; + Device->AddRef(); + Device->GetImmediateContext(&Context); + } + break; + } + next = next->next; + } + std::cerr << "[R20] xrCreateSession ok d3d11=" + << (Device && Context ? "yes" : "no") << "\n"; + return result; + } + + inline XrResult XRAPI_CALL CreateReferenceSpace(XrSession session, + const XrReferenceSpaceCreateInfo* info, XrSpace* space) + { + const XrResult result = ::xrCreateReferenceSpace(session, info, space); + if (XR_SUCCEEDED(result) && info && space && + info->referenceSpaceType == XR_REFERENCE_SPACE_TYPE_LOCAL) + { + if (BaseLocalSpace == XR_NULL_HANDLE) + BaseLocalSpace = *space; + LocalSpace = *space; + } + return result; + } + + inline XrResult XRAPI_CALL DestroySession(XrSession session) + { + CloseFrameMapping(); + // main.cpp destroys the active LocalSpace before the session. If a user + // recenter replaced it, the immutable base LOCAL is a second handle and + // remains ours to release here. + if (BaseLocalSpace != XR_NULL_HANDLE && + BaseLocalSpace != LocalSpace) + ::xrDestroySpace(BaseLocalSpace); + BaseLocalSpace = XR_NULL_HANDLE; + LocalSpace = XR_NULL_HANDLE; + Session = XR_NULL_HANDLE; + ReleaseGraphicsBinding(); + return ::xrDestroySession(session); + } + + inline XrResult XRAPI_CALL EndFrame(XrSession session, + const XrFrameEndInfo* endInfo) + { + // Production R20 has no fallback diagnostic layer here. The R19/R20 SBS + // capture override may patch a valid game/theater layer, then calls this + // function to submit it directly to the OpenXR loader. + return ::xrEndFrame(session, endInfo); + } +} + +// Redirect only the host translation units compiled with this forced include. +// sbs_capture_override.hpp intentionally undefines xrEndFrame/xrDestroySession +// and then installs its production wrappers above these compatibility helpers. +#define xrCreateInstance OutRunVrFinalTest::CreateInstance +#define xrCreateSession OutRunVrFinalTest::CreateSession +#define xrCreateReferenceSpace OutRunVrFinalTest::CreateReferenceSpace +#define xrDestroySession OutRunVrFinalTest::DestroySession +#define xrEndFrame OutRunVrFinalTest::EndFrame diff --git a/vrhost/src/runtime/r18_host_pipeline_diagnostic.hpp b/vrhost/src/runtime/r18_host_pipeline_diagnostic.hpp new file mode 100644 index 000000000..3cc36632b --- /dev/null +++ b/vrhost/src/runtime/r18_host_pipeline_diagnostic.hpp @@ -0,0 +1,358 @@ +#pragma once + +// R18 host-pipeline A/B diagnostic. Forced-included only by the dedicated +// capture-diagnostic target, after R17. It keeps the normal host D3D11/OpenXR +// session but replaces final submission with deterministic probes that expose +// exact XrResult/HRESULT failure stages. + +#ifdef xrEndFrame +#undef xrEndFrame +#endif + +#include +#include +#include +#include +#include +#include +#include + +namespace OutRunVrR18Diagnostic +{ + inline constexpr const char* BuildId = "R18-host-pipeline-ab-20260915"; + inline constexpr ULONGLONG PhaseDurationMs = 5000; + inline constexpr int PhaseCount = 5; + + inline std::ofstream LogFile; + inline ULONGLONG StartMs = 0; + inline ULONGLONG LastDupProbeMs = 0; + inline int LastPhase = -1; + inline std::uint64_t Frames = 0; + inline std::array Ok{}; + inline std::array Fail{}; + inline bool FormatsLogged = false; + + inline void Log(const std::string& text) + { + if (!LogFile.is_open()) + LogFile.open("outrun-vr-r18-host-pipeline.log", std::ios::out | std::ios::trunc); + SYSTEMTIME st{}; GetLocalTime(&st); + std::ostringstream ss; + ss << std::setfill('0') << "[" << std::setw(2) << st.wHour << ":" + << std::setw(2) << st.wMinute << ":" << std::setw(2) << st.wSecond + << "." << std::setw(3) << st.wMilliseconds << "] [R18] " << text; + std::cerr << ss.str() << "\n"; + if (LogFile) { LogFile << ss.str() << "\n"; LogFile.flush(); } + } + + inline std::string Hr(HRESULT hr) + { + std::ostringstream ss; ss << "0x" << std::hex << std::uppercase + << static_cast(hr); return ss.str(); + } + + inline const char* PhaseName(int p) + { + switch (p) + { + case 0: return "PREFERRED-CLEAR (production format preference, no shader)"; + case 1: return "UNORM-CLEAR (R16-known-good format family, no shader)"; + case 2: return "SRGB-CLEAR (production-preferred format family, no shader)"; + case 3: return "UNORM-PRODUCTION-SHADER (synthetic source through R10 RenderTo)"; + case 4: return "DUPLICATION-PROBE (UNORM clear + exact DuplicateOutput HRESULT)"; + default: return "UNKNOWN"; + } + } + + template inline void ReleaseCom(T*& p) + { + if (p) { p->Release(); p = nullptr; } + } + + struct ProbeSwapchain + { + const char* name = "unset"; + XrSwapchain handle = XR_NULL_HANDLE; + DXGI_FORMAT format = DXGI_FORMAT_UNKNOWN; + std::uint32_t width = 1600, height = 1000, arraySize = 2; + std::vector images; + std::vector> rtvs; + bool attempted = false; + bool ready = false; + + void Destroy() + { + for (auto& r : rtvs) { ReleaseCom(r[0]); ReleaseCom(r[1]); } + rtvs.clear(); images.clear(); + if (handle != XR_NULL_HANDLE) { ::xrDestroySwapchain(handle); handle = XR_NULL_HANDLE; } + ready = false; + } + }; + + inline ProbeSwapchain Preferred{"preferred"}; + inline ProbeSwapchain Unorm{"unorm"}; + inline ProbeSwapchain Srgb{"srgb"}; + + inline bool Contains(const std::vector& f, DXGI_FORMAT v) + { + return std::find(f.begin(), f.end(), static_cast(v)) != f.end(); + } + + inline std::vector Formats(XrSession session) + { + std::uint32_t count = 0; + const XrResult a = ::xrEnumerateSwapchainFormats(session, 0, &count, nullptr); + if (XR_FAILED(a) || !count) { Log("xrEnumerateSwapchainFormats(count) failed result=" + std::to_string(a)); return {}; } + std::vector out(count); + const XrResult b = ::xrEnumerateSwapchainFormats(session, count, &count, out.data()); + if (XR_FAILED(b)) { Log("xrEnumerateSwapchainFormats(list) failed result=" + std::to_string(b)); return {}; } + if (!FormatsLogged) + { + FormatsLogged = true; + std::ostringstream ss; ss << "runtime swapchain formats:"; + for (auto v : out) ss << " " << v; + Log(ss.str()); + } + return out; + } + + inline DXGI_FORMAT ChooseUnorm(XrSession s) + { + const auto f = Formats(s); + for (DXGI_FORMAT v : {DXGI_FORMAT_R8G8B8A8_UNORM, DXGI_FORMAT_B8G8R8A8_UNORM}) + if (Contains(f, v)) return v; + return DXGI_FORMAT_UNKNOWN; + } + + inline DXGI_FORMAT ChooseSrgb(XrSession s) + { + const auto f = Formats(s); + for (DXGI_FORMAT v : {DXGI_FORMAT_R8G8B8A8_UNORM_SRGB, DXGI_FORMAT_B8G8R8A8_UNORM_SRGB}) + if (Contains(f, v)) return v; + return DXGI_FORMAT_UNKNOWN; + } + + inline bool EnsureSwapchain(ProbeSwapchain& s, XrSession session, DXGI_FORMAT format) + { + if (s.ready) return true; + if (s.attempted) return false; + s.attempted = true; s.format = format; + if (!OutRunVrFinalTest::Device) { Log(std::string(s.name) + " fail: finaltest Device=null"); return false; } + if (format == DXGI_FORMAT_UNKNOWN) { Log(std::string(s.name) + " fail: requested format unavailable"); return false; } + + XrSwapchainCreateInfo ci{XR_TYPE_SWAPCHAIN_CREATE_INFO}; + ci.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT | XR_SWAPCHAIN_USAGE_SAMPLED_BIT; + ci.format = static_cast(format); + ci.sampleCount = 1; ci.width = s.width; ci.height = s.height; + ci.faceCount = 1; ci.arraySize = s.arraySize; ci.mipCount = 1; + XrResult xr = ::xrCreateSwapchain(session, &ci, &s.handle); + if (XR_FAILED(xr)) { Log(std::string(s.name) + " xrCreateSwapchain FAIL result=" + std::to_string(xr) + " format=" + std::to_string((int)format)); return false; } + + std::uint32_t count = 0; + xr = ::xrEnumerateSwapchainImages(s.handle, 0, &count, nullptr); + if (XR_FAILED(xr) || !count) { Log(std::string(s.name) + " enumerate-count FAIL result=" + std::to_string(xr)); s.Destroy(); return false; } + s.images.resize(count); + for (auto& i : s.images) i = {XR_TYPE_SWAPCHAIN_IMAGE_D3D11_KHR}; + xr = ::xrEnumerateSwapchainImages(s.handle, count, &count, + reinterpret_cast(s.images.data())); + if (XR_FAILED(xr)) { Log(std::string(s.name) + " enumerate-images FAIL result=" + std::to_string(xr)); s.Destroy(); return false; } + + D3D11_TEXTURE2D_DESC td{}; s.images[0].texture->GetDesc(&td); + { + std::ostringstream ss; ss << s.name << " image0 desc requestedFmt=" << (int)format + << " actualFmt=" << (int)td.Format << " bind=0x" << std::hex << td.BindFlags + << " misc=0x" << td.MiscFlags << std::dec << " images=" << count; + Log(ss.str()); + } + + s.rtvs.resize(count); + for (std::uint32_t i = 0; i < count; ++i) + { + for (std::uint32_t slice = 0; slice < 2; ++slice) + { + D3D11_RENDER_TARGET_VIEW_DESC rd{}; + rd.Format = format; rd.ViewDimension = D3D11_RTV_DIMENSION_TEXTURE2DARRAY; + rd.Texture2DArray.MipSlice = 0; rd.Texture2DArray.FirstArraySlice = slice; rd.Texture2DArray.ArraySize = 1; + const HRESULT hr = OutRunVrFinalTest::Device->CreateRenderTargetView(s.images[i].texture, &rd, &s.rtvs[i][slice]); + if (FAILED(hr) || !s.rtvs[i][slice]) + { + Log(std::string(s.name) + " CreateRTV FAIL image=" + std::to_string(i) + " slice=" + std::to_string(slice) + " hr=" + Hr(hr)); + s.Destroy(); return false; + } + } + } + s.ready = true; + Log(std::string(s.name) + " READY format=" + std::to_string((int)format)); + return true; + } + + inline bool RenderClear(ProbeSwapchain& s, std::uint32_t& image) + { + if (!s.ready || !OutRunVrFinalTest::Context) return false; + XrSwapchainImageAcquireInfo ai{XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO}; + XrResult xr = ::xrAcquireSwapchainImage(s.handle, &ai, &image); + if (XR_FAILED(xr)) { Log(std::string(s.name) + " acquire FAIL result=" + std::to_string(xr)); return false; } + XrSwapchainImageWaitInfo wi{XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO}; wi.timeout = XR_INFINITE_DURATION; + xr = ::xrWaitSwapchainImage(s.handle, &wi); + if (XR_FAILED(xr)) { Log(std::string(s.name) + " wait FAIL result=" + std::to_string(xr)); return false; } + if (image >= s.rtvs.size()) { Log(std::string(s.name) + " image index OOB=" + std::to_string(image)); return false; } + const float left[4]{0.95f,0.03f,0.03f,1.f}; + const float right[4]{0.03f,0.95f,0.03f,1.f}; + OutRunVrFinalTest::Context->ClearRenderTargetView(s.rtvs[image][0], left); + OutRunVrFinalTest::Context->ClearRenderTargetView(s.rtvs[image][1], right); + OutRunVrFinalTest::Context->Flush(); + XrSwapchainImageReleaseInfo ri{XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO}; + xr = ::xrReleaseSwapchainImage(s.handle, &ri); + if (XR_FAILED(xr)) { Log(std::string(s.name) + " release FAIL result=" + std::to_string(xr)); return false; } + return true; + } + + inline bool RenderShader(ProbeSwapchain& s, std::uint32_t& image) + { + if (!s.ready || !OutRunVrFinalTest::Context) return false; + using namespace OutRunVrSbsCaptureOverride; + if (!CreateShaders()) + { + Log("production CreateShaders FAIL Vs=" + std::to_string(Vs != nullptr) + " Ps=" + std::to_string(Ps != nullptr) + " Sampler=" + std::to_string(Sampler != nullptr) + " CB=" + std::to_string(ConstantBuffer != nullptr)); + return false; + } + if (!OutRunVrCaptureChainDiagnostic::EnsureInternalSource()) { Log("EnsureInternalSource FAIL"); return false; } + + XrSwapchainImageAcquireInfo ai{XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO}; + XrResult xr = ::xrAcquireSwapchainImage(s.handle, &ai, &image); + if (XR_FAILED(xr)) { Log("shader acquire FAIL result=" + std::to_string(xr)); return false; } + XrSwapchainImageWaitInfo wi{XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO}; wi.timeout = XR_INFINITE_DURATION; + xr = ::xrWaitSwapchainImage(s.handle, &wi); + if (XR_FAILED(xr)) { Log("shader wait FAIL result=" + std::to_string(xr)); return false; } + if (image >= s.rtvs.size()) return false; + const UvRect l{0.f,0.f,0.5f,1.f}, r{0.5f,0.f,0.5f,1.f}; + bool ok = OutRunVrCaptureChainDiagnostic::RenderWithSource(s.rtvs[image][0], s.width, s.height, l, + OutRunVrCaptureChainDiagnostic::InternalSrv, DXGI_FORMAT_B8G8R8A8_UNORM, 1.f); + ok = OutRunVrCaptureChainDiagnostic::RenderWithSource(s.rtvs[image][1], s.width, s.height, r, + OutRunVrCaptureChainDiagnostic::InternalSrv, DXGI_FORMAT_B8G8R8A8_UNORM, 1.f) && ok; + if (!ok) Log("production RenderTo returned false"); + OutRunVrFinalTest::Context->Flush(); + XrSwapchainImageReleaseInfo ri{XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO}; + xr = ::xrReleaseSwapchainImage(s.handle, &ri); + if (XR_FAILED(xr)) { Log("shader release FAIL result=" + std::to_string(xr)); return false; } + return ok; + } + + inline void FillQuad(XrCompositionLayerQuad& q, XrSwapchain swap, std::uint32_t slice, XrEyeVisibility eye) + { + q = {XR_TYPE_COMPOSITION_LAYER_QUAD}; + q.space = OutRunVrSbsCaptureOverride::ViewSpace; + q.eyeVisibility = eye; q.pose.orientation.w = 1.f; q.pose.position.z = -1.5f; + q.subImage.swapchain = swap; q.subImage.imageRect.offset = {0,0}; + q.subImage.imageRect.extent = {1600,1000}; q.subImage.imageArrayIndex = slice; + q.size = {1.8f,1.125f}; + } + + inline bool SubmitProbe(XrSession session, ProbeSwapchain& s, bool shader, + XrFrameEndInfo& patched, std::array& quads, + const XrCompositionLayerBaseHeader* (&layers)[2]) + { + if (!OutRunVrSbsCaptureOverride::EnsureViewSpace(session)) { Log("EnsureViewSpace FAIL"); return false; } + std::uint32_t image = 0; + const bool rendered = shader ? RenderShader(s, image) : RenderClear(s, image); + if (!rendered) return false; + FillQuad(quads[0], s.handle, 0, XR_EYE_VISIBILITY_LEFT); + FillQuad(quads[1], s.handle, 1, XR_EYE_VISIBILITY_RIGHT); + layers[0] = reinterpret_cast(&quads[0]); + layers[1] = reinterpret_cast(&quads[1]); + patched.layerCount = 2; patched.layers = layers; + return true; + } + + inline void ProbeDuplicateOutput() + { + const ULONGLONG now = GetTickCount64(); + if (now - LastDupProbeMs < 1000) return; + LastDupProbeMs = now; + using namespace OutRunVrSbsCaptureOverride; + if (!OutRunVrFinalTest::Device) { Log("dup probe Device=null"); return; } + HWND hwnd = FindGameWindow(); + if (!hwnd) { Log("dup probe game window unavailable"); return; } + HMONITOR mon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); + IDXGIDevice* dxgi = nullptr; + HRESULT hr = OutRunVrFinalTest::Device->QueryInterface(__uuidof(IDXGIDevice), reinterpret_cast(&dxgi)); + if (FAILED(hr) || !dxgi) { Log("dup probe QI IDXGIDevice FAIL hr=" + Hr(hr)); return; } + IDXGIAdapter* adapter = nullptr; hr = dxgi->GetAdapter(&adapter); dxgi->Release(); + if (FAILED(hr) || !adapter) { Log("dup probe GetAdapter FAIL hr=" + Hr(hr)); return; } + IDXGIOutput* selected = nullptr; DXGI_OUTPUT_DESC od{}; + for (UINT i=0;;++i) { IDXGIOutput* o=nullptr; if (adapter->EnumOutputs(i,&o)==DXGI_ERROR_NOT_FOUND) break; DXGI_OUTPUT_DESC d{}; o->GetDesc(&d); if (d.Monitor==mon) { selected=o; od=d; break; } o->Release(); } + adapter->Release(); + if (!selected) { Log("dup probe matching output unavailable"); return; } + IDXGIOutput1* o1=nullptr; IDXGIOutput5* o5=nullptr; + selected->QueryInterface(__uuidof(IDXGIOutput1), reinterpret_cast(&o1)); + selected->QueryInterface(__uuidof(IDXGIOutput5), reinterpret_cast(&o5)); + selected->Release(); + IDXGIOutputDuplication* dup5=nullptr; IDXGIOutputDuplication* dup1=nullptr; + HRESULT h5=E_NOINTERFACE, h1=E_NOINTERFACE; + if (o5) { const DXGI_FORMAT fmts[]{DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_B8G8R8A8_UNORM}; h5=o5->DuplicateOutput1(OutRunVrFinalTest::Device,0,2,fmts,&dup5); } + if (o1) h1=o1->DuplicateOutput(OutRunVrFinalTest::Device,&dup1); + std::ostringstream ss; ss << "dup probe output=" << (od.DesktopCoordinates.right-od.DesktopCoordinates.left) << "x" << (od.DesktopCoordinates.bottom-od.DesktopCoordinates.top) + << " DuplicateOutput1=" << Hr(h5) << " ptr=" << (dup5?1:0) + << " DuplicateOutput=" << Hr(h1) << " ptr=" << (dup1?1:0) + << " R10existingDup=" << (Duplication?1:0); + Log(ss.str()); + ReleaseCom(dup5); ReleaseCom(dup1); ReleaseCom(o5); ReleaseCom(o1); + } + + inline XrResult XRAPI_CALL EndFrame(XrSession session, const XrFrameEndInfo* endInfo) + { + ++Frames; + if (!StartMs) + { + StartMs = GetTickCount64(); + Log(std::string("build=") + BuildId); + Log("normal host session/device retained; final layer replaced only by R18 probes"); + } + if (!endInfo) return ::xrEndFrame(session, endInfo); + const int phase = static_cast(((GetTickCount64()-StartMs)/PhaseDurationMs)%PhaseCount); + if (phase != LastPhase) { LastPhase = phase; Log(std::string("PHASE ") + PhaseName(phase)); } + + XrFrameEndInfo patched = *endInfo; + std::array quads{}; + const XrCompositionLayerBaseHeader* layers[2]{}; + bool ready = false; + + if (phase == 0) + { + const DXGI_FORMAT fmt = OutRunVrSbsCaptureOverride::ChooseSwapchainFormat(session); + if (!Preferred.attempted) Log("production preferred format=" + std::to_string((int)fmt)); + if (EnsureSwapchain(Preferred, session, fmt)) ready = SubmitProbe(session, Preferred, false, patched, quads, layers); + } + else if (phase == 1) + { + if (EnsureSwapchain(Unorm, session, ChooseUnorm(session))) ready = SubmitProbe(session, Unorm, false, patched, quads, layers); + } + else if (phase == 2) + { + if (EnsureSwapchain(Srgb, session, ChooseSrgb(session))) ready = SubmitProbe(session, Srgb, false, patched, quads, layers); + } + else if (phase == 3) + { + if (EnsureSwapchain(Unorm, session, ChooseUnorm(session))) ready = SubmitProbe(session, Unorm, true, patched, quads, layers); + } + else + { + ProbeDuplicateOutput(); + if (EnsureSwapchain(Unorm, session, ChooseUnorm(session))) ready = SubmitProbe(session, Unorm, false, patched, quads, layers); + } + + if (ready) ++Ok[phase]; else ++Fail[phase]; + if ((Frames % 300u) == 0u) + { + std::ostringstream ss; ss << "summary frames=" << Frames << " ok=["; + for (int i=0;i +#include + +#include +#include +#include + +namespace OutRunVrR21CrashDiagnostics +{ + inline constexpr const char* BuildId = "R23-crash-text-first-20260916"; + + struct DumpResult + { + bool success = false; + DWORD error = ERROR_SUCCESS; + wchar_t path[MAX_PATH]{}; + }; + + inline void BuildSiblingPath(const wchar_t* fileName, + wchar_t out[MAX_PATH]) noexcept + { + out[0] = L'\0'; + DWORD n = GetModuleFileNameW(nullptr, out, MAX_PATH); + if (!n || n >= MAX_PATH) + { + wcsncpy_s(out, MAX_PATH, fileName, _TRUNCATE); + return; + } + wchar_t* slash = wcsrchr(out, L'\\'); + if (!slash) + { + wcsncpy_s(out, MAX_PATH, fileName, _TRUNCATE); + return; + } + *(slash + 1) = L'\0'; + wcsncat_s(out, MAX_PATH, fileName, _TRUNCATE); + } + + inline void BuildCrashBaseName(wchar_t out[128]) noexcept + { + SYSTEMTIME st{}; + GetSystemTime(&st); + _snwprintf_s(out, 128, _TRUNCATE, + L"outrun-vr-host-crash-%04u%02u%02u-%02u%02u%02u-%03u-pid%lu", + static_cast(st.wYear), + static_cast(st.wMonth), + static_cast(st.wDay), + static_cast(st.wHour), + static_cast(st.wMinute), + static_cast(st.wSecond), + static_cast(st.wMilliseconds), + GetCurrentProcessId()); + } + + inline void BuildFaultTextPath(const wchar_t* baseName, + wchar_t path[MAX_PATH]) noexcept + { + wchar_t fileName[160]{}; + _snwprintf_s(fileName, 160, _TRUNCATE, L"%s.txt", baseName); + BuildSiblingPath(fileName, path); + } + + inline void WriteInitialFaultText(EXCEPTION_POINTERS* ep, + const wchar_t* baseName) noexcept + { + wchar_t path[MAX_PATH]{}; + BuildFaultTextPath(baseName, path); + HANDLE file = CreateFileW(path, GENERIC_WRITE, FILE_SHARE_READ, + nullptr, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr); + if (file == INVALID_HANDLE_VALUE) + return; + + const EXCEPTION_RECORD* er = ep ? ep->ExceptionRecord : nullptr; + const void* exceptionAddress = er ? er->ExceptionAddress : nullptr; + MEMORY_BASIC_INFORMATION mbi{}; + HMODULE module = nullptr; + if (exceptionAddress && + VirtualQuery(exceptionAddress, &mbi, sizeof(mbi)) == sizeof(mbi)) + module = static_cast(mbi.AllocationBase); + + wchar_t modulePath[MAX_PATH]{}; + if (module) + GetModuleFileNameW(module, modulePath, MAX_PATH); + + char moduleUtf8[MAX_PATH * 3]{}; + if (modulePath[0]) + WideCharToMultiByte(CP_UTF8, 0, modulePath, -1, + moduleUtf8, static_cast(sizeof(moduleUtf8)), nullptr, nullptr); + + const std::uintptr_t address = + reinterpret_cast(exceptionAddress); + const std::uintptr_t base = reinterpret_cast(module); + const std::uintptr_t rva = base && address >= base ? address - base : 0; + + unsigned long long operation = ~0ull; + unsigned long long target = 0; + if (er && er->ExceptionCode == EXCEPTION_ACCESS_VIOLATION && + er->NumberParameters >= 2) + { + operation = static_cast(er->ExceptionInformation[0]); + target = static_cast(er->ExceptionInformation[1]); + } + + char text[2560]{}; + const int len = _snprintf_s(text, sizeof(text), _TRUNCATE, + "build=%s\r\n" + "pid=%lu tid=%lu\r\n" + "exception=0x%08lX\r\n" + "exceptionAddress=0x%016llX\r\n" + "module=%s\r\n" + "moduleBase=0x%016llX\r\n" + "rva=0x%llX\r\n" + "avOperation=%llu (0=read,1=write,8=execute)\r\n" + "avTarget=0x%016llX\r\n" + "dumpStatus=pending\r\n", + BuildId, + GetCurrentProcessId(), GetCurrentThreadId(), + er ? static_cast(er->ExceptionCode) : 0ul, + static_cast(address), + moduleUtf8[0] ? moduleUtf8 : "", + static_cast(base), + static_cast(rva), + operation, target); + + if (len > 0) + { + DWORD written = 0; + WriteFile(file, text, static_cast(len), &written, nullptr); + // This flush intentionally happens before MiniDumpWriteDump. If the + // dump call deadlocks, the minimum exception record is still durable. + FlushFileBuffers(file); + } + CloseHandle(file); + } + + inline DumpResult WriteMiniDump(EXCEPTION_POINTERS* ep, + const wchar_t* baseName) noexcept + { + DumpResult result{}; + wchar_t fileName[160]{}; + _snwprintf_s(fileName, 160, _TRUNCATE, L"%s.dmp", baseName); + BuildSiblingPath(fileName, result.path); + + HANDLE file = CreateFileW(result.path, GENERIC_WRITE, FILE_SHARE_READ, + nullptr, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr); + if (file == INVALID_HANDLE_VALUE) + { + result.error = GetLastError(); + return result; + } + + MINIDUMP_EXCEPTION_INFORMATION exceptionInfo{}; + exceptionInfo.ThreadId = GetCurrentThreadId(); + exceptionInfo.ExceptionPointers = ep; + exceptionInfo.ClientPointers = FALSE; + + const MINIDUMP_TYPE type = static_cast( + MiniDumpWithThreadInfo | + MiniDumpWithIndirectlyReferencedMemory | + MiniDumpWithUnloadedModules); + SetLastError(ERROR_SUCCESS); + const BOOL ok = MiniDumpWriteDump( + GetCurrentProcess(), GetCurrentProcessId(), file, + type, ep ? &exceptionInfo : nullptr, nullptr, nullptr); + result.success = ok != FALSE; + result.error = result.success ? ERROR_SUCCESS : GetLastError(); + FlushFileBuffers(file); + CloseHandle(file); + return result; + } + + inline void AppendDumpResult(const wchar_t* baseName, + const DumpResult& dump) noexcept + { + wchar_t path[MAX_PATH]{}; + BuildFaultTextPath(baseName, path); + HANDLE file = CreateFileW(path, FILE_APPEND_DATA, FILE_SHARE_READ, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (file == INVALID_HANDLE_VALUE) + return; + + char dumpUtf8[MAX_PATH * 3]{}; + if (dump.path[0]) + WideCharToMultiByte(CP_UTF8, 0, dump.path, -1, + dumpUtf8, static_cast(sizeof(dumpUtf8)), nullptr, nullptr); + + char text[1024]{}; + const int len = _snprintf_s(text, sizeof(text), _TRUNCATE, + "dumpStatus=completed\r\n" + "dumpSuccess=%u\r\n" + "dumpError=%lu\r\n" + "dumpPath=%s\r\n", + dump.success ? 1u : 0u, + static_cast(dump.error), + dumpUtf8[0] ? dumpUtf8 : ""); + if (len > 0) + { + DWORD written = 0; + WriteFile(file, text, static_cast(len), &written, nullptr); + FlushFileBuffers(file); + } + CloseHandle(file); + } + + inline LONG WINAPI UnhandledExceptionFilter(EXCEPTION_POINTERS* ep) noexcept + { + wchar_t baseName[128]{}; + BuildCrashBaseName(baseName); + WriteInitialFaultText(ep, baseName); + const DumpResult dump = WriteMiniDump(ep, baseName); + AppendDumpResult(baseName, dump); + return EXCEPTION_CONTINUE_SEARCH; + } + + struct Installer + { + Installer() noexcept + { + SetUnhandledExceptionFilter(UnhandledExceptionFilter); + } + }; + + inline Installer InstallOnce{}; +} diff --git a/vrhost/src/runtime/r21_runtime_hardening.hpp b/vrhost/src/runtime/r21_runtime_hardening.hpp new file mode 100644 index 000000000..a13cbcf9a --- /dev/null +++ b/vrhost/src/runtime/r21_runtime_hardening.hpp @@ -0,0 +1,258 @@ +#pragma once + +// R21 runtime hardening. +// +// Goals: +// 1. Never issue a write-capable interlocked operation against the read-only +// SharedPoseState mapping used by the x64 host. +// 2. Do not touch direct-transport state at all when the current frame is not +// a direct frame (or direct transport is disabled). +// 3. Keep the existing R15 ownership policy, but make the final xrEndFrame +// branch explicit and bind the legacy R19 blit constant buffer to VS before +// any R19/direct fallback rendering. +// 4. Source-review hardening: never synthesize a fallback layer when the host +// says shouldRender=false, and require the opened direct frame to match the +// exact latest direct frame before safe staging/presentation. +// +// The main StereoCompositor shader is fixed separately so its UV transform is +// evaluated in PS (where the constant buffer is already bound). + +#include "review_hardening.hpp" + +#ifdef xrEndFrame +#undef xrEndFrame +#endif +#ifdef xrDestroySession +#undef xrDestroySession +#endif + +#include +#include +#include + +namespace OutRunVrR21RuntimeHardening +{ + inline constexpr const char* BuildId = "R21-readonly-ipc-vs-cbuffer-20260916"; + inline constexpr const char* ReviewFixBuildId = "R22-source-review-fixes-20260916"; + + inline bool FirstActiveLogged = false; + inline bool FirstReadonlyDirectReadLogged = false; + inline bool FirstLegacyVsBindingLogged = false; + inline bool FirstDirectDisabledSkipLogged = false; + inline bool FirstShouldRenderBlockLogged = false; + inline bool FirstDirectExactFrameBlockLogged = false; + + inline bool DirectTransportRequested() noexcept + { + const char* value = std::getenv("OUTRUN_VR_DIRECT_TRANSPORT"); + return !value || (std::strcmp(value, "0") != 0 && + _stricmp(value, "false") != 0 && _stricmp(value, "off") != 0); + } + + inline bool HostShouldRenderReadonly() noexcept + { + using namespace OutRunVrD3D9ExDirectPassthrough; + if (!EnsurePoseState()) + return false; + + for (int attempt = 0; attempt < 4; ++attempt) + { + const std::uint32_t before = PoseState->sequence; + if (before & 1u) + continue; + MemoryBarrier(); + const std::uint32_t flags = PoseState->flags; + const std::uint32_t hostPid = PoseState->hostPid; + MemoryBarrier(); + const std::uint32_t after = PoseState->sequence; + if (before != after || (after & 1u)) + continue; + + constexpr std::uint32_t required = + OutRunVR::HostAlive | + OutRunVR::SessionVisible | + OutRunVR::HostShouldRender; + return hostPid == GetCurrentProcessId() && + (flags & required) == required; + } + return false; + } + + inline OutRunVrD3D9ExDirectPassthrough::DirectHostState + ReadDirectHostStateReadonly() noexcept + { + using namespace OutRunVrD3D9ExDirectPassthrough; + DirectHostState out{}; + if (!EnsurePoseState()) + return out; + + // SharedPoseState is mapped FILE_MAP_READ. Use only aligned volatile + // 32-bit loads under the existing sequence lock; no const_cast and no + // InterlockedCompareExchange are permitted on this view. + for (int attempt = 0; attempt < 4; ++attempt) + { + const std::uint32_t before = PoseState->sequence; + if (before & 1u) + continue; + MemoryBarrier(); + out.flags = PoseState->flags; + out.hostPid = PoseState->hostPid; + out.openedFrame = PoseState->hostDirectConsumedFrameId; + MemoryBarrier(); + const std::uint32_t after = PoseState->sequence; + if (before != after || (after & 1u)) + continue; + + constexpr std::uint32_t required = + OutRunVR::HostAlive | + OutRunVR::HostDirectGpuTransport | + OutRunVR::HostDirectGpuReady; + out.valid = out.hostPid == GetCurrentProcessId() && + (out.flags & required) == required; + + if (!FirstReadonlyDirectReadLogged) + { + FirstReadonlyDirectReadLogged = true; + std::cerr + << "[R21] direct host state uses read-only seqlock load; " + "write-capable CAS removed from final presentation path\n"; + } + return out; + } + return {}; + } + + inline bool BindLegacyBlitConstantBufferToVs() noexcept + { + using namespace OutRunVrSbsCaptureOverride; + if (!OutRunVrFinalTest::Context || !CreateShaders() || !ConstantBuffer) + return false; + OutRunVrFinalTest::Context->VSSetConstantBuffers(0, 1, &ConstantBuffer); + if (!FirstLegacyVsBindingLogged) + { + FirstLegacyVsBindingLogged = true; + std::cerr + << "[R21] legacy R19/direct blit constant buffer bound to VS+PS; " + "UVScale/UvOffset now reach VSMain\n"; + } + return true; + } + + inline XrResult XRAPI_CALL EndFrame(XrSession session, + const XrFrameEndInfo* endInfo) + { + if (!FirstActiveLogged) + { + FirstActiveLogged = true; + std::cerr + << "[R21] final OpenXR presentation owner ACTIVE build=" << BuildId + << " reviewFix=" << ReviewFixBuildId + << "; readonly IPC + deterministic classic/direct presentation policy\n"; + } + + // Menu/theater content is already a deliberate core-compositor layer. + // Preserve it exactly as R15 did. + if (OutRunVrReviewHardening::HasIncomingNonProjectionLayer(endInfo)) + return OutRunVrFinalTest::EndFrame(session, endInfo); + + // Main.cpp intentionally submits zero layers when OpenXR says + // shouldRender=false. Do not turn that deliberate no-layer frame into a + // stale desktop/direct fallback merely because Frame.v2 is still fresh. + if (!HostShouldRenderReadonly()) + { + if (!FirstShouldRenderBlockLogged) + { + FirstShouldRenderBlockLogged = true; + std::cerr + << "[R22] host shouldRender/session-visible gate closed; preserving incoming/no-layer frame without fallback synthesis\n"; + } + return OutRunVrFinalTest::EndFrame(session, endInfo); + } + + OutRunVR::SharedRenderFrameState latest{}; + const bool latestDirect = + OutRunVrReviewHardening::LatestCompleteDirectFrame(latest); + const bool directRequested = DirectTransportRequested(); + + // Critical R21 ordering: when this is a classic frame, do not even map + // or read direct-only state. + if (latestDirect && directRequested) + { + const auto directState = ReadDirectHostStateReadonly(); + const bool exactIncoming = + OutRunVrReviewHardening::IncomingProjectionValid(endInfo); + const bool directCandidate = directState.valid && exactIncoming && + directState.openedFrame != 0 && + directState.openedFrame == latest.frameId; + + if (directCandidate && + OutRunVrD3D9ExDirectPassthrough::EnsureSafeFrame(latest.frameId)) + { + // RenderSafeProjection reuses R19 RenderTo(). Bind its shared + // BlitParams buffer to VS before the draw; RenderTo already binds + // the same buffer to PS. + BindLegacyBlitConstantBufferToVs(); + XrCompositionLayerProjection projection{}; + std::array views{}; + if (OutRunVrD3D9ExDirectPassthrough::RenderSafeProjection( + session, endInfo, projection, views)) + { + const XrCompositionLayerBaseHeader* layer = + reinterpret_cast( + &projection); + XrFrameEndInfo patched = *endInfo; + patched.layerCount = 1; + patched.layers = &layer; + return OutRunVrFinalTest::EndFrame(session, &patched); + } + } + + if (!directCandidate && !FirstDirectExactFrameBlockLogged) + { + FirstDirectExactFrameBlockLogged = true; + std::cerr + << "[R22] direct frame rejected because host-opened frame is not the exact latest direct frame; stale Ready/openedFrame state cannot present an older eye pair\n"; + } + + // A frame explicitly marked as direct must never be reinterpreted as + // desktop SBS if safe direct staging/rendering is unavailable. + return OutRunVrFinalTest::EndFrame(session, endInfo); + } + + if (latestDirect && !directRequested) + { + if (!FirstDirectDisabledSkipLogged) + { + FirstDirectDisabledSkipLogged = true; + std::cerr + << "[R21] direct frame observed while direct transport disabled; " + "direct-only IPC skipped and incoming layer preserved\n"; + } + return OutRunVrFinalTest::EndFrame(session, endInfo); + } + + // A valid core projection is authoritative for classic D3D9. + if (OutRunVrReviewHardening::IncomingProjectionValid(endInfo)) + return OutRunVrFinalTest::EndFrame(session, endInfo); + + // Only a fresh, complete classic frame may enter the R19 Desktop + // Duplication fallback. Ensure its VS sees BlitParams first. + if (OutRunVrReviewHardening::FreshClassicFallbackAvailable()) + { + BindLegacyBlitConstantBufferToVs(); + return OutRunVrSbsCaptureOverride::EndFrame(session, endInfo); + } + + return OutRunVrFinalTest::EndFrame(session, endInfo); + } + + inline XrResult XRAPI_CALL DestroySession(XrSession session) + { + // Preserve the established cleanup chain for R13 direct resources, + // R19 swapchains/capture snapshots and the base OpenXR session state. + return OutRunVrReviewHardening::DestroySession(session); + } +} + +#define xrEndFrame OutRunVrR21RuntimeHardening::EndFrame +#define xrDestroySession OutRunVrR21RuntimeHardening::DestroySession diff --git a/vrhost/src/runtime/r22_runtime_hardening.hpp b/vrhost/src/runtime/r22_runtime_hardening.hpp new file mode 100644 index 000000000..5521fc4ab --- /dev/null +++ b/vrhost/src/runtime/r22_runtime_hardening.hpp @@ -0,0 +1,236 @@ +#pragma once + +// R22 final OpenXR presentation guard. +// +// The core compositor may prepare a texture before its second Frame.v2 read has +// proved that metadata stayed stable. R22 therefore treats the final OpenXR +// submission as the transaction boundary: a gameplay projection is submitted +// only when its eye pose/FOV matches the latest complete classic Frame.v2. If +// the producer changed underneath the candidate, the frame is dropped rather +// than pairing an old pose with a newly copied texture. The old QPC-only R19 +// gameplay fallback is deliberately not synthesized here. + +#include "r21_runtime_hardening.hpp" + +#ifdef xrEndFrame +#undef xrEndFrame +#endif +#ifdef xrDestroySession +#undef xrDestroySession +#endif + +#include +#include +#include + +namespace OutRunVrR22RuntimeHardening +{ + inline constexpr const char* BuildId = + "R22-verified-frame-bundle-20260916"; + + inline bool FirstActiveLogged = false; + inline bool FirstBundleRejectLogged = false; + inline bool FirstClassicFallbackBlockedLogged = false; + inline bool FirstDirectRecoveryLogged = false; + inline bool FirstClassicAfterDirectBlockedLogged = false; + + inline bool Near(float a, float b, float epsilon = 1.0e-4f) noexcept + { + return std::isfinite(a) && std::isfinite(b) && + std::fabs(a - b) <= epsilon; + } + + inline bool ProjectionMatchesFrame( + const XrFrameEndInfo* endInfo, + const OutRunVR::SharedRenderFrameState& frame) noexcept + { + if (!OutRunVrReviewHardening::IncomingProjectionValid(endInfo) || + frame.state != OutRunVR::StereoSbsActive || !frame.frameId || + (frame.flags & OutRunVR::RenderFramePresentInFlight) != 0 || + (frame.flags & OutRunVR::RenderFrameDirectGpuTransport) != 0) + return false; + + constexpr std::uint32_t required = + OutRunVR::RenderFrameStereoComplete | + OutRunVR::RenderFrameWorldStereo | + OutRunVR::RenderFrameDrawDuplicated | + OutRunVR::RenderFrameEffectivePoseValid; + if ((frame.flags & required) != required) + return false; + + const auto* projection = + reinterpret_cast( + endInfo->layers[0]); + if (!projection || projection->viewCount < 2 || !projection->views) + return false; + + for (int eye = 0; eye < 2; ++eye) + { + const auto& view = projection->views[eye]; + const auto& wire = frame.eye[eye]; + if (!Near(view.pose.orientation.x, wire.orientation[0]) || + !Near(view.pose.orientation.y, wire.orientation[1]) || + !Near(view.pose.orientation.z, wire.orientation[2]) || + !Near(view.pose.orientation.w, wire.orientation[3]) || + !Near(view.pose.position.x, wire.position[0]) || + !Near(view.pose.position.y, wire.position[1]) || + !Near(view.pose.position.z, wire.position[2]) || + !Near(view.fov.angleLeft, wire.fov.angleLeft) || + !Near(view.fov.angleRight, wire.fov.angleRight) || + !Near(view.fov.angleUp, wire.fov.angleUp) || + !Near(view.fov.angleDown, wire.fov.angleDown)) + return false; + } + return true; + } + + inline XrResult SubmitNoLayer(XrSession session, + const XrFrameEndInfo* endInfo) noexcept + { + if (!endInfo) + return OutRunVrFinalTest::EndFrame(session, endInfo); + XrFrameEndInfo safe = *endInfo; + safe.layerCount = 0; + safe.layers = nullptr; + return OutRunVrFinalTest::EndFrame(session, &safe); + } + + inline bool DirectOpenMatchesLatest( + const OutRunVrD3D9ExDirectPassthrough::DirectHostState& state, + const OutRunVR::SharedRenderFrameState& latest) noexcept + { + constexpr std::uint32_t required = + OutRunVR::HostAlive | OutRunVR::HostDirectGpuTransport; + return state.hostPid == GetCurrentProcessId() && + (state.flags & required) == required && + state.openedFrame != 0 && state.openedFrame == latest.frameId; + } + + inline XrResult RenderExactDirect(XrSession session, + const XrFrameEndInfo* endInfo, + const OutRunVR::SharedRenderFrameState& latest) + { + using namespace OutRunVrD3D9ExDirectPassthrough; + if (!OutRunVrR21RuntimeHardening::DirectTransportRequested() || + !IncomingProjectionValid(endInfo)) + return SubmitNoLayer(session, endInfo); + + // hostDirectConsumedFrameId is written only after main.cpp opened the + // frame and its before/after Frame.v2 metadata check succeeded. That + // exact-frame acknowledgement is stronger evidence than a stale + // HostDirectGpuReady bit and also lets a valid cached slot recover after + // an earlier candidate failure cleared Ready. + const auto state = + OutRunVrR21RuntimeHardening::ReadDirectHostStateReadonly(); + if (!DirectOpenMatchesLatest(state, latest) || + !EnsureSafeFrame(latest.frameId)) + return SubmitNoLayer(session, endInfo); + + OutRunVrR21RuntimeHardening::BindLegacyBlitConstantBufferToVs(); + XrCompositionLayerProjection projection{}; + std::array views{}; + if (!RenderSafeProjection(session, endInfo, projection, views)) + return SubmitNoLayer(session, endInfo); + + const XrCompositionLayerBaseHeader* layer = + reinterpret_cast(&projection); + XrFrameEndInfo patched = *endInfo; + patched.layerCount = 1; + patched.layers = &layer; + if (!FirstDirectRecoveryLogged && !state.valid) + { + FirstDirectRecoveryLogged = true; + std::cerr + << "[R22] direct cached-slot recovery accepted by exact openedFrame==latest.frameId evidence even though Ready bit was cleared\n"; + } + return OutRunVrFinalTest::EndFrame(session, &patched); + } + + inline XrResult XRAPI_CALL EndFrame(XrSession session, + const XrFrameEndInfo* endInfo) + { + if (!FirstActiveLogged) + { + FirstActiveLogged = true; + std::cerr + << "[R22] final verified-frame bundle guard ACTIVE build=" + << BuildId + << "; gameplay projection requires latest Frame.v2 pose/FOV match; " + "QPC-only classic fallback disabled\n"; + } + + if (OutRunVrReviewHardening::HasIncomingNonProjectionLayer(endInfo)) + return OutRunVrFinalTest::EndFrame(session, endInfo); + + if (!OutRunVrR21RuntimeHardening::HostShouldRenderReadonly()) + return OutRunVrFinalTest::EndFrame(session, endInfo); + + OutRunVR::SharedRenderFrameState latest{}; + std::uint32_t publish = 0; + const bool haveLatest = + OutRunVrFinalTest::ReadLatestFrame(latest, publish); + + if (haveLatest && + (latest.flags & OutRunVR::RenderFrameDirectGpuTransport) != 0) + return RenderExactDirect(session, endInfo, latest); + + if (OutRunVrReviewHardening::IncomingProjectionValid(endInfo)) + { + if (haveLatest && ProjectionMatchesFrame(endInfo, latest)) + { + // When direct transport is enabled and has already consumed an + // older direct frame, main.cpp's legacy directFrameValid_ can + // otherwise win over a newly committed classic source. Until + // the core compositor owns an explicit source-kind state, never + // submit that ambiguous direct->classic transition. Current + // production testing runs with direct transport disabled, so + // ordinary classic/SBS projection is unaffected. + if (OutRunVrR21RuntimeHardening::DirectTransportRequested()) + { + const auto directState = + OutRunVrR21RuntimeHardening::ReadDirectHostStateReadonly(); + if (directState.openedFrame != 0 && + directState.openedFrame != latest.frameId) + { + if (!FirstClassicAfterDirectBlockedLogged) + { + FirstClassicAfterDirectBlockedLogged = true; + std::cerr + << "[R22] classic projection after an older direct-open frame dropped; stale directFrameValid cannot override classic source\n"; + } + return SubmitNoLayer(session, endInfo); + } + } + return OutRunVrFinalTest::EndFrame(session, endInfo); + } + + if (!FirstBundleRejectLogged) + { + FirstBundleRejectLogged = true; + std::cerr + << "[R22] gameplay projection dropped: incoming pose/FOV is not " + "the latest complete classic Frame.v2 bundle; stale pose/new " + "texture pairing is fail-closed\n"; + } + return SubmitNoLayer(session, endInfo); + } + + if (!FirstClassicFallbackBlockedLogged && + OutRunVrReviewHardening::FreshClassicFallbackAvailable()) + { + FirstClassicFallbackBlockedLogged = true; + std::cerr + << "[R22] fresh R19 classic capture intentionally not synthesized: " + "QPC-only matching cannot prove exact frame/source identity\n"; + } + return OutRunVrFinalTest::EndFrame(session, endInfo); + } + + inline XrResult XRAPI_CALL DestroySession(XrSession session) + { + return OutRunVrR21RuntimeHardening::DestroySession(session); + } +} + +#define xrEndFrame OutRunVrR22RuntimeHardening::EndFrame +#define xrDestroySession OutRunVrR22RuntimeHardening::DestroySession diff --git a/vrhost/src/runtime/r23_runtime_hardening.hpp b/vrhost/src/runtime/r23_runtime_hardening.hpp new file mode 100644 index 000000000..a8484f9e9 --- /dev/null +++ b/vrhost/src/runtime/r23_runtime_hardening.hpp @@ -0,0 +1,373 @@ +#pragma once + +// R23 final OpenXR presentation authority. +// A gameplay layer is allowed only when the host loop has committed one stable +// Frame.v2 + image-source + pose/FOV bundle. Once committed, that host-owned +// bundle remains authoritative for its short grace lifetime even if the game +// publishes the next Frame.v2 before xrEndFrame is called. + +#include "r22_runtime_hardening.hpp" +#include "r23_verified_bundle.hpp" + +#ifdef xrEndFrame +#undef xrEndFrame +#endif +#ifdef xrDestroySession +#undef xrDestroySession +#endif + +#include +#include +#include +#include +#include + +namespace OutRunVrR23RuntimeHardening +{ + inline constexpr const char* BuildId = "R23-owned-verified-bundle-submit-20260916"; + inline bool FirstActiveLogged = false; + inline bool FirstBundleBlockLogged = false; + inline bool FirstFallbackLogged = false; + inline bool FirstFallbackSourceMismatchLogged = false; + inline bool FirstDirectSafeReuseLogged = false; + inline bool FirstDirectFormatMismatchLogged = false; + inline bool FirstMixedLayerBlockLogged = false; + + inline std::atomic LastSubmittedFrameId{ 0 }; + inline std::atomic LastSubmittedKind{ + static_cast(OutRunVrR23VerifiedBundle::SourceKind::None) }; + inline std::atomic LastSubmittedLayer{ false }; + inline std::atomic SubmissionSerial{ 0 }; + inline std::atomic LastSubmissionLogMs{ 0 }; + inline constexpr std::uint64_t SubmissionLogIntervalMs = 2000; + + inline void RecordFinalSubmission(std::uint32_t frameId, + OutRunVrR23VerifiedBundle::SourceKind kind, bool hasLayer) noexcept + { + LastSubmittedFrameId.store(frameId, std::memory_order_relaxed); + LastSubmittedKind.store(static_cast(kind), + std::memory_order_relaxed); + LastSubmittedLayer.store(hasLayer, std::memory_order_relaxed); + const std::uint64_t serial = + SubmissionSerial.fetch_add(1, std::memory_order_release) + 1; + + const std::uint64_t now = GetTickCount64(); + std::uint64_t previous = + LastSubmissionLogMs.load(std::memory_order_relaxed); + if (now >= previous + SubmissionLogIntervalMs && + LastSubmissionLogMs.compare_exchange_strong( + previous, now, std::memory_order_relaxed)) + { + std::cerr + << "[R23 final-submit] serial=" << serial + << " frameId=" << frameId + << " sourceKind=" << static_cast(kind) + << " layer=" << (hasLayer ? 1 : 0) + << "\n"; + } + } + + inline XrResult SubmitNoLayer(XrSession session, + const XrFrameEndInfo* endInfo) noexcept + { + if (!endInfo) + return OutRunVrFinalTest::EndFrame(session, endInfo); + XrFrameEndInfo safe = *endInfo; + safe.layerCount = 0; + safe.layers = nullptr; + return OutRunVrFinalTest::EndFrame(session, &safe); + } + + inline bool IncomingContainsProjectionLayer( + const XrFrameEndInfo* endInfo) noexcept + { + if (!endInfo || !endInfo->layers) + return false; + for (std::uint32_t i = 0; i < endInfo->layerCount; ++i) + { + const auto* layer = endInfo->layers[i]; + if (layer && layer->type == XR_TYPE_COMPOSITION_LAYER_PROJECTION) + return true; + } + return false; + } + + inline XrResult SubmitNonProjectionOnly(XrSession session, + const XrFrameEndInfo* endInfo) noexcept + { + if (!endInfo || !endInfo->layers) + return SubmitNoLayer(session, endInfo); + + constexpr std::size_t MaxPreservedLayers = 16; + std::array layers{}; + std::uint32_t count = 0; + for (std::uint32_t i = 0; i < endInfo->layerCount; ++i) + { + const auto* layer = endInfo->layers[i]; + if (!layer || layer->type == XR_TYPE_COMPOSITION_LAYER_PROJECTION) + continue; + if (count >= layers.size()) + return SubmitNoLayer(session, endInfo); + layers[count++] = layer; + } + + XrFrameEndInfo safe = *endInfo; + safe.layerCount = count; + safe.layers = count ? layers.data() : nullptr; + return OutRunVrFinalTest::EndFrame(session, &safe); + } + + inline bool Near(float a, float b, float epsilon = 1.0e-4f) noexcept + { + return std::isfinite(a) && std::isfinite(b) && + std::fabs(a - b) <= epsilon; + } + + inline bool IncomingPoseMatchesCommittedFrame( + const XrFrameEndInfo* endInfo, + const OutRunVR::SharedRenderFrameState& frame) noexcept + { + if (!OutRunVrReviewHardening::IncomingProjectionValid(endInfo)) + return false; + const auto* projection = reinterpret_cast( + endInfo->layers[0]); + if (!projection || projection->viewCount < 2 || !projection->views) + return false; + + for (int eye = 0; eye < 2; ++eye) + { + const auto& view = projection->views[eye]; + const auto& wire = frame.eye[eye]; + if (!Near(view.pose.orientation.x, wire.orientation[0]) || + !Near(view.pose.orientation.y, wire.orientation[1]) || + !Near(view.pose.orientation.z, wire.orientation[2]) || + !Near(view.pose.orientation.w, wire.orientation[3]) || + !Near(view.pose.position.x, wire.position[0]) || + !Near(view.pose.position.y, wire.position[1]) || + !Near(view.pose.position.z, wire.position[2]) || + !Near(view.fov.angleLeft, wire.fov.angleLeft) || + !Near(view.fov.angleRight, wire.fov.angleRight) || + !Near(view.fov.angleUp, wire.fov.angleUp) || + !Near(view.fov.angleDown, wire.fov.angleDown)) + return false; + } + return true; + } + + inline DXGI_FORMAT ExpectedDirectDxgiFormat(std::uint32_t declared) noexcept + { + switch (static_cast(declared)) + { + case D3DFMT_A8R8G8B8: + return DXGI_FORMAT_B8G8R8A8_UNORM; + case D3DFMT_A8B8G8R8: + return DXGI_FORMAT_R8G8B8A8_UNORM; + case D3DFMT_A2B10G10R10: + return DXGI_FORMAT_R10G10B10A2_UNORM; + case D3DFMT_A16B16G16R16F: + return DXGI_FORMAT_R16G16B16A16_FLOAT; + default: + return DXGI_FORMAT_UNKNOWN; + } + } + + inline bool DirectSafeEyeMatchesCommittedFrame( + const OutRunVR::SharedRenderFrameState& frame) noexcept + { + using namespace OutRunVrD3D9ExDirectPassthrough; + const auto expected = ExpectedDirectDxgiFormat( + frame.reserved[OutRunVR::RenderFrameDirectFormatIndex]); + const std::uint32_t width = + frame.reserved[OutRunVR::RenderFrameDirectWidthIndex]; + const std::uint32_t height = + frame.reserved[OutRunVR::RenderFrameDirectHeightIndex]; + const bool match = expected != DXGI_FORMAT_UNKNOWN && + SafeEyeFormat == expected && width != 0 && height != 0 && + SafeEyeWidth == width && SafeEyeHeight == height && + width == frame.backbufferWidth && height == frame.backbufferHeight; + if (!match && !FirstDirectFormatMismatchLogged) + { + FirstDirectFormatMismatchLogged = true; + std::cerr + << "[R23] direct safe-eye rejected: actual resource format/size does not match committed Frame.v2 declaration\n"; + } + return match; + } + + inline XrResult RenderCommittedDirect(XrSession session, + const XrFrameEndInfo* endInfo, + const OutRunVrR23VerifiedBundle::Snapshot& verified) + { + using namespace OutRunVrD3D9ExDirectPassthrough; + const auto kind = OutRunVrR23VerifiedBundle::SourceKind::DirectGpu; + const auto& frame = verified.frame; + + if (!OutRunVrR21RuntimeHardening::DirectTransportRequested() || + !IncomingPoseMatchesCommittedFrame(endInfo, frame)) + { + RecordFinalSubmission(frame.frameId, kind, false); + return SubmitNoLayer(session, endInfo); + } + + const auto state = OutRunVrR21RuntimeHardening::ReadDirectHostStateReadonly(); + if (!OutRunVrR22RuntimeHardening::DirectOpenMatchesLatest(state, frame)) + { + RecordFinalSubmission(frame.frameId, kind, false); + return SubmitNoLayer(session, endInfo); + } + + // Once CopySharedFrameToSafeEyes completed, SafeEye is host-owned and + // independent of producer ring reuse. Reuse it directly even when the + // original Frame.v2 slot has already advanced/been overwritten. + const bool safeAlreadyOwned = SafeFrameId == frame.frameId && + SafeTransportGeneration == + frame.reserved[OutRunVR::RenderFrameDirectGenerationIndex] && + SafeEyeSrv[0] && SafeEyeSrv[1]; + if (!safeAlreadyOwned && !EnsureSafeFrame(frame.frameId)) + { + RecordFinalSubmission(frame.frameId, kind, false); + return SubmitNoLayer(session, endInfo); + } + if (!DirectSafeEyeMatchesCommittedFrame(frame)) + { + RecordFinalSubmission(frame.frameId, kind, false); + return SubmitNoLayer(session, endInfo); + } + if (safeAlreadyOwned && !FirstDirectSafeReuseLogged) + { + FirstDirectSafeReuseLogged = true; + std::cerr + << "[R23] reusing host-owned direct safe-eye bundle after producer advanced; frame=" + << frame.frameId << "\n"; + } + + OutRunVrR21RuntimeHardening::BindLegacyBlitConstantBufferToVs(); + XrCompositionLayerProjection projection{}; + std::array views{}; + if (!RenderSafeProjection(session, endInfo, projection, views)) + { + RecordFinalSubmission(frame.frameId, kind, false); + return SubmitNoLayer(session, endInfo); + } + + const XrCompositionLayerBaseHeader* layer = + reinterpret_cast(&projection); + XrFrameEndInfo patched = *endInfo; + patched.layerCount = 1; + patched.layers = &layer; + RecordFinalSubmission(frame.frameId, kind, true); + return OutRunVrFinalTest::EndFrame(session, &patched); + } + + inline XrResult XRAPI_CALL EndFrame(XrSession session, + const XrFrameEndInfo* endInfo) + { + using OutRunVrR23VerifiedBundle::SourceKind; + + if (!FirstActiveLogged) + { + FirstActiveLogged = true; + std::cerr + << "[R23] final presentation authority ACTIVE build=" << BuildId + << "; committed host-owned bundle survives newer producer frames for " + << OutRunVrR23VerifiedBundle::MaxPresentationAgeMs << "ms\n"; + } + + const bool hasNonProjection = + OutRunVrReviewHardening::HasIncomingNonProjectionLayer(endInfo); + const bool hasProjection = IncomingContainsProjectionLayer(endInfo); + if (hasNonProjection && !hasProjection) + { + RecordFinalSubmission(0, SourceKind::None, + endInfo && endInfo->layerCount > 0); + return OutRunVrFinalTest::EndFrame(session, endInfo); + } + if (hasNonProjection && hasProjection) + { + if (!FirstMixedLayerBlockLogged) + { + FirstMixedLayerBlockLogged = true; + std::cerr + << "[R23] mixed projection/non-projection frame detected; dropping unverified projection while preserving non-projection layers\n"; + } + RecordFinalSubmission(0, SourceKind::None, true); + return SubmitNonProjectionOnly(session, endInfo); + } + + if (!OutRunVrR21RuntimeHardening::HostShouldRenderReadonly()) + { + RecordFinalSubmission(0, SourceKind::None, false); + return OutRunVrFinalTest::EndFrame(session, endInfo); + } + + OutRunVrR23VerifiedBundle::Snapshot verified{}; + if (!OutRunVrR23VerifiedBundle::ReadFresh(verified) || + !OutRunVrSbsCaptureOverride::FrameComplete(verified.frame)) + { + if (!FirstBundleBlockLogged) + { + FirstBundleBlockLogged = true; + std::cerr + << "[R23] gameplay layer blocked: no fresh committed host-owned frame/source/pose bundle\n"; + } + RecordFinalSubmission(0, SourceKind::None, false); + return SubmitNoLayer(session, endInfo); + } + + if (verified.kind == SourceKind::DirectGpu) + return RenderCommittedDirect(session, endInfo, verified); + + if (verified.kind != SourceKind::ClassicSbs) + { + RecordFinalSubmission(verified.frameId, verified.kind, false); + return SubmitNoLayer(session, endInfo); + } + + if (OutRunVrReviewHardening::IncomingProjectionValid(endInfo)) + { + if (IncomingPoseMatchesCommittedFrame(endInfo, verified.frame)) + { + RecordFinalSubmission(verified.frameId, verified.kind, true); + return OutRunVrFinalTest::EndFrame(session, endInfo); + } + RecordFinalSubmission(verified.frameId, verified.kind, false); + return SubmitNoLayer(session, endInfo); + } + + const bool exactClassicSource = verified.sourceCaptureQpc > 0 && + OutRunVrSbsCaptureOverride::LastProductionPresentQpc == + verified.sourceCaptureQpc; + if (exactClassicSource && + OutRunVrReviewHardening::FreshClassicFallbackAvailable()) + { + OutRunVrR21RuntimeHardening::BindLegacyBlitConstantBufferToVs(); + if (!FirstFallbackLogged) + { + FirstFallbackLogged = true; + std::cerr + << "[R23] R19 classic fallback allowed for exact committed source/frame bundle only\n"; + } + RecordFinalSubmission(verified.frameId, verified.kind, true); + return OutRunVrSbsCaptureOverride::EndFrame(session, endInfo); + } + + if (!exactClassicSource && !FirstFallbackSourceMismatchLogged) + { + FirstFallbackSourceMismatchLogged = true; + std::cerr + << "[R23] classic fallback blocked: current production capture is not the committed bundle source\n"; + } + RecordFinalSubmission(verified.frameId, verified.kind, false); + return OutRunVrFinalTest::EndFrame(session, endInfo); + } + + inline XrResult XRAPI_CALL DestroySession(XrSession session) + { + OutRunVrR23VerifiedBundle::Invalidate(); + RecordFinalSubmission(0, OutRunVrR23VerifiedBundle::SourceKind::None, false); + return OutRunVrR22RuntimeHardening::DestroySession(session); + } +} + +#define xrEndFrame OutRunVrR23RuntimeHardening::EndFrame +#define xrDestroySession OutRunVrR23RuntimeHardening::DestroySession diff --git a/vrhost/src/runtime/r23_verified_bundle.hpp b/vrhost/src/runtime/r23_verified_bundle.hpp new file mode 100644 index 000000000..258230cb8 --- /dev/null +++ b/vrhost/src/runtime/r23_verified_bundle.hpp @@ -0,0 +1,128 @@ +#pragma once + +#include +#include +#include + +#include "sbs_capture_override.hpp" +#include "vr_shared.hpp" + +namespace OutRunVrR23VerifiedBundle +{ + enum class SourceKind : std::uint32_t + { + None = 0, + ClassicSbs = 1, + DirectGpu = 2, + }; + + // Keep the same grace budget as the host compositor. A newer game frame may + // be published while this bundle is still valid; that must not invalidate a + // texture/pose pair the host already owns and is about to submit. + inline constexpr std::uint64_t MaxPresentationAgeMs = 1000; + + struct Snapshot + { + OutRunVR::SharedRenderFrameState frame{}; + std::uint32_t frameId{}; + std::uint32_t poseSequence{}; + std::int64_t presentQpc{}; + std::int64_t sourceCaptureQpc{}; + std::uint64_t publishedAtMs{}; + SourceKind kind{ SourceKind::None }; + }; + + inline OutRunVR::SharedRenderFrameState Frame{}; + inline std::atomic FrameId{ 0 }; + inline std::atomic PoseSequence{ 0 }; + inline std::atomic PresentQpc{ 0 }; + inline std::atomic SourceCaptureQpc{ 0 }; + inline std::atomic PublishedAtMs{ 0 }; + inline std::atomic Kind{ static_cast(SourceKind::None) }; + inline std::atomic PublishSequence{ 0 }; + + inline void Invalidate() noexcept + { + PublishSequence.fetch_add(1, std::memory_order_acq_rel); + std::memset(&Frame, 0, sizeof(Frame)); + Kind.store(static_cast(SourceKind::None), std::memory_order_relaxed); + FrameId.store(0, std::memory_order_relaxed); + PoseSequence.store(0, std::memory_order_relaxed); + PresentQpc.store(0, std::memory_order_relaxed); + SourceCaptureQpc.store(0, std::memory_order_relaxed); + PublishedAtMs.store(0, std::memory_order_relaxed); + PublishSequence.fetch_add(1, std::memory_order_release); + } + + inline void Publish(const OutRunVR::SharedRenderFrameState& frame, + SourceKind kind, std::int64_t sourceCaptureQpc = 0) noexcept + { + // main_r23 publishes the production capture before it promotes a classic + // candidate. Snapshot that exact source as part of the committed bundle. + if (kind == SourceKind::ClassicSbs && sourceCaptureQpc <= 0) + sourceCaptureQpc = OutRunVrSbsCaptureOverride::LastProductionPresentQpc; + + PublishSequence.fetch_add(1, std::memory_order_acq_rel); + std::memcpy(&Frame, &frame, sizeof(Frame)); + FrameId.store(frame.frameId, std::memory_order_relaxed); + PoseSequence.store(frame.sourcePoseSequence, std::memory_order_relaxed); + PresentQpc.store(frame.presentQpc, std::memory_order_relaxed); + SourceCaptureQpc.store(sourceCaptureQpc, std::memory_order_relaxed); + PublishedAtMs.store(GetTickCount64(), std::memory_order_relaxed); + Kind.store(static_cast(kind), std::memory_order_relaxed); + PublishSequence.fetch_add(1, std::memory_order_release); + } + + inline bool Read(Snapshot& out) noexcept + { + for (int attempt = 0; attempt < 4; ++attempt) + { + const std::uint32_t before = PublishSequence.load(std::memory_order_acquire); + if (before & 1u) continue; + + Snapshot s{}; + std::memcpy(&s.frame, &Frame, sizeof(s.frame)); + s.frameId = FrameId.load(std::memory_order_relaxed); + s.poseSequence = PoseSequence.load(std::memory_order_relaxed); + s.presentQpc = PresentQpc.load(std::memory_order_relaxed); + s.sourceCaptureQpc = SourceCaptureQpc.load(std::memory_order_relaxed); + s.publishedAtMs = PublishedAtMs.load(std::memory_order_relaxed); + s.kind = static_cast(Kind.load(std::memory_order_relaxed)); + + const std::uint32_t after = PublishSequence.load(std::memory_order_acquire); + if (before == after && !(after & 1u)) + { + out = s; + return true; + } + } + return false; + } + + inline bool IsFresh(const Snapshot& s, + std::uint64_t nowMs = GetTickCount64()) noexcept + { + return s.kind != SourceKind::None && s.frameId != 0 && + s.frame.frameId == s.frameId && + s.frame.sourcePoseSequence == s.poseSequence && + s.frame.presentQpc == s.presentQpc && + s.publishedAtMs != 0 && nowMs >= s.publishedAtMs && + nowMs - s.publishedAtMs <= MaxPresentationAgeMs; + } + + inline bool ReadFresh(Snapshot& out) noexcept + { + return Read(out) && IsFresh(out); + } + + inline bool Matches(const OutRunVR::SharedRenderFrameState& frame, + SourceKind requiredKind, Snapshot* snapshot = nullptr) noexcept + { + Snapshot s{}; + if (!ReadFresh(s)) return false; + const bool match = s.kind == requiredKind && s.frameId == frame.frameId && + s.poseSequence == frame.sourcePoseSequence && s.presentQpc == frame.presentQpc; + if (match && snapshot) *snapshot = s; + return match; + } +} diff --git a/vrhost/src/runtime/r24_black_screen_guard.hpp b/vrhost/src/runtime/r24_black_screen_guard.hpp new file mode 100644 index 000000000..f87904a4e --- /dev/null +++ b/vrhost/src/runtime/r24_black_screen_guard.hpp @@ -0,0 +1,662 @@ +#pragma once + +// R24 final visible-presentation guard. +// +// R23 deliberately fails closed to layerCount=0 whenever a verified +// frame/source/pose bundle cannot be proven. That is safe for pose/texture +// ownership, but on a headset it is indistinguishable from a broken renderer. +// R24 keeps the same stereo-safety rules while adding a visible degradation +// ladder: +// 1. preserve validated classic projection + non-projection layers; +// 2. render DirectGPU only from host-owned SafeEye copies and completion ACK; +// 3. allow a short display-only grace for an already committed bundle; +// 4. prefer the last released projection image, then a host-owned Direct +// SafeEye flat view, then a left-eye-only theater recovery capture, then an +// emergency visible quad instead of ever exposing the raw SBS desktop. +// +// The display-only grace never reopens game-side WVP/stereo injection. It only +// prevents a 250ms boundary race from turning a frame that was already rendered +// from a committed source into a black OpenXR frame. + +#include "r23_runtime_hardening.hpp" + +#ifdef xrEndFrame +#undef xrEndFrame +#endif +#ifdef xrDestroySession +#undef xrDestroySession +#endif + +#include +#include +#include +#include + +namespace OutRunVrR24BlackScreenGuard +{ + // Keep the production marker stable because release CI and field logs use it + // to prove that the final R24 owner is present in the shipped host binary. + inline constexpr const char* BuildId = + "R24-visible-fallback-final-20260916"; + inline constexpr ULONGLONG DisplayOnlyGraceMs = + static_cast( + OutRunVrR23VerifiedBundle::MaxPresentationAgeMs); + + inline std::uint64_t ExactProjectionSubmits = 0; + inline std::uint64_t SoftGraceProjectionSubmits = 0; + inline std::uint64_t DirectSafeProjectionSubmits = 0; + inline std::uint64_t LiveTheaterFallbacks = 0; + inline std::uint64_t DirectFlatFallbacks = 0; + inline std::uint64_t CachedLayerFallbacks = 0; + inline std::uint64_t EmergencyLayerFallbacks = 0; + inline std::uint64_t EmptyFrameFallbacks = 0; + inline std::uint64_t MixedValidatedSubmits = 0; + inline std::uint64_t IntentionalMonoProjectionSubmits = 0; + inline std::atomic IntentionalMonoProjection{ false }; + + inline bool FirstExactProjectionLogged = false; + inline bool FirstSoftGraceLogged = false; + inline bool FirstDirectSafeLogged = false; + inline bool FirstLiveTheaterLogged = false; + inline bool FirstDirectFlatLogged = false; + inline bool FirstCachedLayerLogged = false; + inline bool FirstEmergencyLayerLogged = false; + inline bool FirstEmptyFallbackLogged = false; + inline bool FirstMixedValidatedLogged = false; + + // A swapchain handle alone does not prove that any image was ever rendered + // and successfully released. Track only images R24 itself has committed. + inline std::uint64_t ProjectionCommittedGeneration = 0; + inline std::uint64_t TheaterCommittedGeneration = 0; + + struct ProjectionSelection + { + const XrCompositionLayerBaseHeader* header = nullptr; + std::uint32_t count = 0; + }; + + inline ProjectionSelection FindProjection( + const XrFrameEndInfo* endInfo) noexcept + { + ProjectionSelection out{}; + if (!endInfo || !endInfo->layers) + return out; + for (std::uint32_t i = 0; i < endInfo->layerCount; ++i) + { + const auto* layer = endInfo->layers[i]; + if (layer && layer->type == XR_TYPE_COMPOSITION_LAYER_PROJECTION) + { + if (!out.header) + out.header = layer; + ++out.count; + } + } + return out; + } + + inline bool ProjectionMatchesSnapshot( + const XrFrameEndInfo* endInfo, + const OutRunVrR23VerifiedBundle::Snapshot& snapshot) noexcept + { + const auto projection = FindProjection(endInfo); + if (!projection.header || projection.count != 1) + return false; + const XrCompositionLayerBaseHeader* oneLayer = projection.header; + XrFrameEndInfo single = *endInfo; + single.layerCount = 1; + single.layers = &oneLayer; + return OutRunVrR23RuntimeHardening::IncomingPoseMatchesCommittedFrame( + &single, snapshot.frame); + } + + inline bool ReadDisplayGraceSnapshot( + OutRunVrR23VerifiedBundle::Snapshot& out) noexcept + { + using namespace OutRunVrR23VerifiedBundle; + if (!Read(out) || out.kind == SourceKind::None || !out.frameId || + out.frame.frameId != out.frameId || + out.frame.sourcePoseSequence != out.poseSequence || + out.frame.presentQpc != out.presentQpc || + !OutRunVrSbsCaptureOverride::FrameComplete(out.frame) || + !out.publishedAtMs) + return false; + const ULONGLONG now = GetTickCount64(); + return now >= out.publishedAtMs && + now - out.publishedAtMs <= DisplayOnlyGraceMs; + } + + inline XrResult SubmitOriginal( + XrSession session, const XrFrameEndInfo* endInfo, + const OutRunVrR23VerifiedBundle::Snapshot& snapshot, + bool softGrace = false) noexcept + { + OutRunVrR23RuntimeHardening::RecordFinalSubmission( + snapshot.frameId, snapshot.kind, true); + if (softGrace) + { + ++SoftGraceProjectionSubmits; + if (!FirstSoftGraceLogged) + { + FirstSoftGraceLogged = true; + std::cerr + << "[R24] display-only grace accepted an already-rendered committed classic projection; game-side stereo eligibility remains unchanged build=" + << BuildId << "\n"; + } + } + else + { + ++ExactProjectionSubmits; + if (!FirstExactProjectionLogged) + { + FirstExactProjectionLogged = true; + std::cerr + << "[R24] exact committed classic projection submitted directly; no redundant black-frame gate build=" + << BuildId << "\n"; + } + } + return OutRunVrFinalTest::EndFrame(session, endInfo); + } + + inline void BuildViewQuad(XrSwapchain handle, std::uint32_t width, + std::uint32_t height, std::uint32_t arrayIndex, + XrCompositionLayerQuad& quad) noexcept + { + using namespace OutRunVrSbsCaptureOverride; + quad = { XR_TYPE_COMPOSITION_LAYER_QUAD }; + quad.space = ViewSpace; + quad.eyeVisibility = XR_EYE_VISIBILITY_BOTH; + quad.pose.orientation.w = 1.0f; + quad.pose.position.z = -1.5f; + quad.subImage.swapchain = handle; + quad.subImage.imageRect.offset = { 0, 0 }; + quad.subImage.imageRect.extent = { + static_cast(width), + static_cast(height) + }; + quad.subImage.imageArrayIndex = arrayIndex; + const float aspect = height ? static_cast(width) / + static_cast(height) : (16.0f / 9.0f); + quad.size.width = 2.0f; + quad.size.height = 2.0f / std::max(0.5f, aspect); + } + + // R13's legacy RenderSafeProjection did not propagate xrReleaseSwapchainImage + // failure. R24 owns the final visible path, so duplicate the small blit here + // and treat acquire/wait/render/release as one transaction. + inline bool RenderSafeProjectionChecked( + XrSession session, const XrFrameEndInfo* endInfo, + XrCompositionLayerProjection& projection, + std::array& views) noexcept + { + using namespace OutRunVrSbsCaptureOverride; + using namespace OutRunVrD3D9ExDirectPassthrough; + + const auto selected = FindProjection(endInfo); + if (!selected.header || selected.count != 1 || + !SafeEyeSrv[0] || !SafeEyeSrv[1]) + return false; + const auto* incoming = + reinterpret_cast(selected.header); + if (incoming->viewCount < 2 || !incoming->views) + return false; + + const std::uint32_t width = std::max( + 1, incoming->views[0].subImage.imageRect.extent.width); + const std::uint32_t height = std::max( + 1, incoming->views[0].subImage.imageRect.extent.height); + if (!EnsureSwapchain(Projection, session, width, height, 2) || + !CreateShaders()) + return false; + + std::uint32_t image = 0; + if (!Acquire(Projection, image)) + return false; + if (image >= Projection.rtvs.size()) + { + Release(Projection); + return false; + } + + ID3D11ShaderResourceView* savedSrv = SourceSrv; + const DXGI_FORMAT savedFormat = SourceFormat; + bool ok = true; + for (int eye = 0; eye < 2; ++eye) + { + SourceSrv = SafeEyeSrv[eye]; + SourceFormat = SafeEyeFormat; + ok = RenderTo(Projection.rtvs[image][eye], Projection.width, + Projection.height, UvRect{ 0.f, 0.f, 1.f, 1.f }) && ok; + } + SourceSrv = savedSrv; + SourceFormat = savedFormat; + if (OutRunVrFinalTest::Context) + OutRunVrFinalTest::Context->Flush(); + + const bool released = Release(Projection); + if (!ok || !released) + return false; + + ProjectionCommittedGeneration = OutRunVrSbsCaptureOverride::Projection.generation; + projection = *incoming; + for (int eye = 0; eye < 2; ++eye) + { + views[eye] = incoming->views[eye]; + views[eye].subImage.swapchain = Projection.handle; + views[eye].subImage.imageRect.offset = { 0, 0 }; + views[eye].subImage.imageRect.extent = { + static_cast(Projection.width), + static_cast(Projection.height) + }; + views[eye].subImage.imageArrayIndex = eye; + } + projection.viewCount = 2; + projection.views = views.data(); + return true; + } + + inline bool TryBuildDirectSafeProjection( + XrSession session, const XrFrameEndInfo* endInfo, + const OutRunVrR23VerifiedBundle::Snapshot& snapshot, + XrCompositionLayerProjection& projection, + std::array& views) noexcept + { + using namespace OutRunVrD3D9ExDirectPassthrough; + if (snapshot.kind != OutRunVrR23VerifiedBundle::SourceKind::DirectGpu || + !ProjectionMatchesSnapshot(endInfo, snapshot) || + !OutRunVrR21RuntimeHardening::DirectTransportRequested()) + return false; + + const auto state = + OutRunVrR21RuntimeHardening::ReadDirectHostStateReadonly(); + if (!OutRunVrR22RuntimeHardening::DirectOpenMatchesLatest( + state, snapshot.frame)) + return false; + + const auto generation = snapshot.frame.reserved[ + OutRunVR::RenderFrameDirectGenerationIndex]; + const bool safeAlreadyOwned = + SafeFrameId == snapshot.frameId && + SafeTransportGeneration == generation && + SafeEyeSrv[0] && SafeEyeSrv[1]; + if (!safeAlreadyOwned && !EnsureSafeFrame(snapshot.frameId)) + return false; + if (!OutRunVrR23RuntimeHardening::DirectSafeEyeMatchesCommittedFrame( + snapshot.frame)) + return false; + + if (!OutRunVrR21RuntimeHardening::BindLegacyBlitConstantBufferToVs()) + return false; + return RenderSafeProjectionChecked(session, endInfo, projection, views); + } + + inline bool TrySubmitDirectSafeProjection( + XrSession session, const XrFrameEndInfo* endInfo, + const OutRunVrR23VerifiedBundle::Snapshot& snapshot, + XrResult& result) noexcept + { + XrCompositionLayerProjection projection{}; + std::array views{}; + if (!TryBuildDirectSafeProjection( + session, endInfo, snapshot, projection, views)) + return false; + + const XrCompositionLayerBaseHeader* layer = + reinterpret_cast(&projection); + XrFrameEndInfo patched = *endInfo; + patched.layerCount = 1; + patched.layers = &layer; + OutRunVrR23RuntimeHardening::RecordFinalSubmission( + snapshot.frameId, snapshot.kind, true); + ++DirectSafeProjectionSubmits; + if (!FirstDirectSafeLogged) + { + FirstDirectSafeLogged = true; + std::cerr + << "[R24] DirectGPU final submit uses host-owned SafeEye + checked acquire/wait/render/release transaction build=" + << BuildId << "\n"; + } + result = OutRunVrFinalTest::EndFrame(session, &patched); + return true; + } + + inline bool RenderDirectFlatFallback( + XrSession session, XrCompositionLayerQuad& quad) noexcept + { + using namespace OutRunVrSbsCaptureOverride; + using namespace OutRunVrD3D9ExDirectPassthrough; + + if (!SafeFrameId || !SafeEyeSrv[0] || !SafeEyeWidth || !SafeEyeHeight || + !EnsureViewSpace(session) || !CreateShaders() || + !EnsureSwapchain(Theater, session, 1920, 1080, 1)) + return false; + + std::uint32_t image = 0; + if (!Acquire(Theater, image)) + return false; + if (image >= Theater.rtvs.size()) + { + Release(Theater); + return false; + } + + ID3D11ShaderResourceView* savedSrv = SourceSrv; + const DXGI_FORMAT savedFormat = SourceFormat; + SourceSrv = SafeEyeSrv[0]; + SourceFormat = SafeEyeFormat; + const bool ok = RenderTo(Theater.rtvs[image][0], Theater.width, + Theater.height, UvRect{ 0.f, 0.f, 1.f, 1.f }); + SourceSrv = savedSrv; + SourceFormat = savedFormat; + if (OutRunVrFinalTest::Context) + OutRunVrFinalTest::Context->Flush(); + const bool released = Release(Theater); + if (!ok || !released) + return false; + + TheaterCommittedGeneration = OutRunVrSbsCaptureOverride::Theater.generation; + BuildViewQuad(Theater.handle, Theater.width, Theater.height, 0, quad); + const float aspect = SafeEyeHeight + ? static_cast(SafeEyeWidth) / static_cast(SafeEyeHeight) + : (16.0f / 9.0f); + quad.size.height = quad.size.width / std::max(0.5f, aspect); + return true; + } + + inline bool BuildCachedVisibleQuad( + XrSession session, XrCompositionLayerQuad& quad) noexcept + { + using namespace OutRunVrSbsCaptureOverride; + if (!EnsureViewSpace(session)) + return false; + + // Never prefer a previously committed theater image over a + // released projection image. The theater source may be a desktop SBS + // capture, while Projection is already separated per eye. + if (Projection.handle != XR_NULL_HANDLE && Projection.width && + Projection.height && Projection.generation != 0 && + Projection.committedGeneration == Projection.generation && + ProjectionCommittedGeneration == Projection.generation) + { + BuildViewQuad(Projection.handle, Projection.width, Projection.height, 0, quad); + return true; + } + if (Theater.handle != XR_NULL_HANDLE && Theater.width && Theater.height && + Theater.generation != 0 && + Theater.committedGeneration == Theater.generation && + TheaterCommittedGeneration == Theater.generation) + { + BuildViewQuad(Theater.handle, Theater.width, Theater.height, 0, quad); + return true; + } + return false; + } + + inline bool BuildEmergencyVisibleQuad( + XrSession session, XrCompositionLayerQuad& quad) noexcept + { + using namespace OutRunVrSbsCaptureOverride; + if (!OutRunVrFinalTest::Context || !EnsureViewSpace(session) || + !EnsureSwapchain(Theater, session, 1920, 1080, 1)) + return false; + + std::uint32_t image = 0; + if (!Acquire(Theater, image)) + return false; + if (image >= Theater.rtvs.size()) + { + Release(Theater); + return false; + } + + const float visibleError[4]{ 0.12f, 0.025f, 0.025f, 1.0f }; + OutRunVrFinalTest::Context->ClearRenderTargetView( + Theater.rtvs[image][0], visibleError); + OutRunVrFinalTest::Context->Flush(); + if (!Release(Theater)) + return false; + + TheaterCommittedGeneration = OutRunVrSbsCaptureOverride::Theater.generation; + BuildViewQuad(Theater.handle, Theater.width, Theater.height, 0, quad); + return true; + } + + inline XrResult SubmitVisibleFallback( + XrSession session, const XrFrameEndInfo* endInfo, + const char* reason) noexcept + { + XrCompositionLayerQuad quad{}; + + // Degradation order is stereo-safe first: keep a released image + // before attempting any new desktop/theater capture. This keeps a short + // capture gap from exposing the monitor's raw SBS layout in the HMD. + const bool cached = BuildCachedVisibleQuad(session, quad); + const bool directFlat = !cached && RenderDirectFlatFallback(session, quad); + + // The legacy R19 shader consumes UVScale/UvOffset in VSMain. Do not + // depend on inherited D3D11 state when R24 invokes it directly. + OutRunVrR21RuntimeHardening::BindLegacyBlitConstantBufferToVs(); + const bool live = !cached && !directFlat && + OutRunVrSbsCaptureOverride::RenderTheaterOverride(session, quad); + if (live) + TheaterCommittedGeneration = OutRunVrSbsCaptureOverride::Theater.generation; + + const bool emergency = !cached && !directFlat && !live && + BuildEmergencyVisibleQuad(session, quad); + + if (!cached && !directFlat && !live && !emergency) + { + ++EmptyFrameFallbacks; + if (!FirstEmptyFallbackLogged) + { + FirstEmptyFallbackLogged = true; + std::cerr + << "[R24] every visible fallback failed; submitting no layer reason=" + << reason << " build=" << BuildId << "\n"; + } + OutRunVrR23RuntimeHardening::RecordFinalSubmission( + 0, OutRunVrR23VerifiedBundle::SourceKind::None, false); + return OutRunVrR23RuntimeHardening::SubmitNoLayer(session, endInfo); + } + + const XrCompositionLayerBaseHeader* layer = + reinterpret_cast(&quad); + XrFrameEndInfo patched = *endInfo; + patched.layerCount = 1; + patched.layers = &layer; + OutRunVrR23RuntimeHardening::RecordFinalSubmission( + 0, OutRunVrR23VerifiedBundle::SourceKind::None, true); + + if (cached) + { + ++CachedLayerFallbacks; + if (!FirstCachedLayerLogged) + { + FirstCachedLayerLogged = true; + std::cerr + << "[R24] last successfully released projection/theater image preserved visibility while stereo recovers reason=" + << reason << " build=" << BuildId << "\n"; + } + } + else if (directFlat) + { + ++DirectFlatFallbacks; + if (!FirstDirectFlatLogged) + { + FirstDirectFlatLogged = true; + std::cerr + << "[R24] host-owned Direct SafeEye shown as a flat view while stereo projection recovers reason=" + << reason << " build=" << BuildId << "\n"; + } + } + else if (live) + { + ++LiveTheaterFallbacks; + if (!FirstLiveTheaterLogged) + { + FirstLiveTheaterLogged = true; + std::cerr + << "[R24] live theater fallback replaced an unsafe/absent gameplay projection instead of submitting black reason=" + << reason << " build=" << BuildId << "\n"; + } + } + + else + { + ++EmergencyLayerFallbacks; + if (!FirstEmergencyLayerLogged) + { + FirstEmergencyLayerLogged = true; + std::cerr + << "[R24] emergency visible quad submitted because no game image was safely displayable; black/no-layer loop avoided reason=" + << reason << " build=" << BuildId << "\n"; + } + } + return OutRunVrFinalTest::EndFrame(session, &patched); + } + + inline XrResult XRAPI_CALL EndFrame( + XrSession session, const XrFrameEndInfo* endInfo) noexcept + { + using OutRunVrR23VerifiedBundle::SourceKind; + + if (!endInfo || !OutRunVrR21RuntimeHardening::HostShouldRenderReadonly()) + return OutRunVrR23RuntimeHardening::EndFrame(session, endInfo); + + const auto projection = FindProjection(endInfo); + const bool hasNonProjection = + OutRunVrReviewHardening::HasIncomingNonProjectionLayer(endInfo); + + // Menus use one full mono desktop image in both projection eyes so VDXR + // stays on its normal projection cadence instead of the slower theater + // quad path. R23 arms this only for PresentationTheater/menu frames. + if (IntentionalMonoProjection.load(std::memory_order_acquire) && + projection.count == 1 && !hasNonProjection) + { + ++IntentionalMonoProjectionSubmits; + OutRunVrR23RuntimeHardening::RecordFinalSubmission( + 0, SourceKind::None, true); + return OutRunVrFinalTest::EndFrame(session, endInfo); + } + + // Theater/menu is already visible and deliberate; do not replace it. + if (hasNonProjection && projection.count == 0) + { + OutRunVrR23RuntimeHardening::RecordFinalSubmission( + 0, SourceKind::None, endInfo->layerCount > 0); + return OutRunVrFinalTest::EndFrame(session, endInfo); + } + + OutRunVrR23VerifiedBundle::Snapshot verified{}; + const bool exactBundle = + OutRunVrR23VerifiedBundle::ReadFresh(verified) && + OutRunVrSbsCaptureOverride::FrameComplete(verified.frame); + + // Mixed classic layers are valid when the single projection independently + // matches the committed bundle. DirectGPU must still go through SafeEye. + if (hasNonProjection && projection.count == 1 && exactBundle && + verified.kind == SourceKind::ClassicSbs && + ProjectionMatchesSnapshot(endInfo, verified)) + { + ++MixedValidatedSubmits; + if (!FirstMixedValidatedLogged) + { + FirstMixedValidatedLogged = true; + std::cerr + << "[R24] mixed classic frame preserved after independent projection bundle validation build=" + << BuildId << "\n"; + } + OutRunVrR23RuntimeHardening::RecordFinalSubmission( + verified.frameId, verified.kind, true); + return OutRunVrFinalTest::EndFrame(session, endInfo); + } + if (hasNonProjection) + return OutRunVrR23RuntimeHardening::SubmitNonProjectionOnly( + session, endInfo); + + if (exactBundle) + { + if (verified.kind == SourceKind::DirectGpu) + { + XrResult directResult = XR_SUCCESS; + if (TrySubmitDirectSafeProjection( + session, endInfo, verified, directResult)) + return directResult; + return SubmitVisibleFallback( + session, endInfo, "direct-safe-copy/render rejected"); + } + + if (verified.kind == SourceKind::ClassicSbs && + ProjectionMatchesSnapshot(endInfo, verified)) + return SubmitOriginal(session, endInfo, verified, false); + + const bool exactClassicSource = + verified.kind == SourceKind::ClassicSbs && + verified.sourceCaptureQpc > 0 && + OutRunVrSbsCaptureOverride::LastProductionPresentQpc == + verified.sourceCaptureQpc; + if (exactClassicSource && + OutRunVrReviewHardening::FreshClassicFallbackAvailable()) + { + OutRunVrR21RuntimeHardening::BindLegacyBlitConstantBufferToVs(); + OutRunVrR23RuntimeHardening::RecordFinalSubmission( + verified.frameId, verified.kind, true); + return OutRunVrSbsCaptureOverride::EndFrame(session, endInfo); + } + + return SubmitVisibleFallback( + session, endInfo, "exact bundle has no safe projection source"); + } + + // If the core rendered a projection just before the 250ms R23 TTL + // boundary, allow only that already-rendered pose-matched source for + // another short interval. DirectGPU still performs SafeEye copy + ACK. + OutRunVrR23VerifiedBundle::Snapshot grace{}; + if (ReadDisplayGraceSnapshot(grace)) + { + if (grace.kind == SourceKind::DirectGpu) + { + XrResult directResult = XR_SUCCESS; + if (TrySubmitDirectSafeProjection( + session, endInfo, grace, directResult)) + { + ++SoftGraceProjectionSubmits; + return directResult; + } + } + else if (grace.kind == SourceKind::ClassicSbs && + ProjectionMatchesSnapshot(endInfo, grace)) + { + return SubmitOriginal(session, endInfo, grace, true); + } + } + + // A fresh classic fallback remains preferable to a frozen image. + if (OutRunVrReviewHardening::FreshClassicFallbackAvailable()) + { + OutRunVrR21RuntimeHardening::BindLegacyBlitConstantBufferToVs(); + return OutRunVrSbsCaptureOverride::EndFrame(session, endInfo); + } + + return SubmitVisibleFallback( + session, endInfo, "no fresh verified gameplay bundle"); + } + + inline XrResult XRAPI_CALL DestroySession(XrSession session) noexcept + { + ExactProjectionSubmits = 0; + SoftGraceProjectionSubmits = 0; + DirectSafeProjectionSubmits = 0; + LiveTheaterFallbacks = 0; + DirectFlatFallbacks = 0; + CachedLayerFallbacks = 0; + EmergencyLayerFallbacks = 0; + EmptyFrameFallbacks = 0; + MixedValidatedSubmits = 0; + ProjectionCommittedGeneration = 0; + TheaterCommittedGeneration = 0; + return OutRunVrR23RuntimeHardening::DestroySession(session); + } +} + +#define xrEndFrame OutRunVrR24BlackScreenGuard::EndFrame +#define xrDestroySession OutRunVrR24BlackScreenGuard::DestroySession diff --git a/vrhost/src/runtime/r26_recenter_hardening.hpp b/vrhost/src/runtime/r26_recenter_hardening.hpp new file mode 100644 index 000000000..4c3f632dc --- /dev/null +++ b/vrhost/src/runtime/r26_recenter_hardening.hpp @@ -0,0 +1,405 @@ +#pragma once + +// R26/R28 Quest/VDXR theater recenter bridge. +// +// Runtime reference-space changes and focus-return events normalize into the +// existing LOCAL theater-anchor invalidation path. R28 keeps an F10 request +// pending until a visible frame has actually been submitted. When the normal +// host has no layer yet (the startup/menu failure mode), R28 reuses R19's live +// captured texture but submits it on a persistent LOCAL anchor instead of the +// old head-locked VIEW-space fallback, so F10 has a visible effect before the +// first 3D gameplay/car-select projection ever appears. + +#include +#include "vr/ipc/recenter_request.hpp" + +#include +#include +#include +#include +#include + +#ifdef xrPollEvent +#undef xrPollEvent +#endif +#ifdef xrEndFrame +#undef xrEndFrame +#endif + +namespace OutRunVrR26RecenterHardening +{ + inline bool PendingFocusRecenter = false; + inline XrSession PendingFocusSession = XR_NULL_HANDLE; + inline std::atomic PendingGameRequestId{ 0 }; + inline std::atomic PendingApplicationRecenter{ false }; + inline std::atomic ApplicationSpaceGeneration{ 0 }; + inline std::atomic PendingGameTargetGeneration{ 0 }; + inline bool FallbackAnchorValid = false; + inline XrPosef FallbackAnchor{}; + inline std::uint64_t ReferenceChangesNormalized = 0; + inline std::uint64_t FocusRecentersQueued = 0; + inline std::uint64_t GameRequestsReceived = 0; + inline std::uint64_t GameRequestsApplied = 0; + inline std::uint64_t AnchoredStartupFallbacks = 0; + inline bool FirstReferenceChangeLogged = false; + inline bool FirstFocusRecenterLogged = false; + inline bool FirstAnchoredFallbackLogged = false; + + inline XrVector3f RotateVector(const XrQuaternionf& q, + const XrVector3f& v) noexcept + { + const XrVector3f u{ q.x, q.y, q.z }; + const XrVector3f t{ + 2.0f * (u.y * v.z - u.z * v.y), + 2.0f * (u.z * v.x - u.x * v.z), + 2.0f * (u.x * v.y - u.y * v.x) + }; + return { + v.x + q.w * t.x + (u.y * t.z - u.z * t.y), + v.y + q.w * t.y + (u.z * t.x - u.x * t.z), + v.z + q.w * t.z + (u.x * t.y - u.y * t.x) + }; + } + + inline void InvalidateFallbackAnchor() noexcept + { + FallbackAnchorValid = false; + FallbackAnchor = {}; + } + + inline void QueueApplicationRecenter() noexcept + { + PendingApplicationRecenter.store(true, std::memory_order_release); + InvalidateFallbackAnchor(); + } + + inline bool ApplicationRecenterAppliedForPendingGameRequest() noexcept + { + const std::uint64_t target = + PendingGameTargetGeneration.load(std::memory_order_acquire); + return target == 0 || + ApplicationSpaceGeneration.load(std::memory_order_acquire) >= target; + } + + // Apply a real application-space recenter. The new LOCAL space is created + // relative to the immutable runtime LOCAL origin at the current HMD pose, + // making the current headset pose the application's new origin. Keeping the + // original base LOCAL alive also makes repeated recenter requests stable. + inline bool ApplyPendingApplicationRecenter(XrSession session, + XrSpace viewSpace, XrSpace& localSpace, XrTime displayTime) noexcept + { + if (!PendingApplicationRecenter.load(std::memory_order_acquire)) + return true; + const XrSpace base = OutRunVrFinalTest::BaseLocalSpace != XR_NULL_HANDLE + ? OutRunVrFinalTest::BaseLocalSpace : localSpace; + if (session == XR_NULL_HANDLE || viewSpace == XR_NULL_HANDLE || + base == XR_NULL_HANDLE || displayTime == 0) + return false; + + XrSpaceLocation location{ XR_TYPE_SPACE_LOCATION }; + if (XR_FAILED(::xrLocateSpace(viewSpace, base, displayTime, &location))) + return false; + constexpr XrSpaceLocationFlags need = + XR_SPACE_LOCATION_ORIENTATION_VALID_BIT | + XR_SPACE_LOCATION_POSITION_VALID_BIT; + if ((location.locationFlags & need) != need) + return false; + + XrReferenceSpaceCreateInfo create{ XR_TYPE_REFERENCE_SPACE_CREATE_INFO }; + create.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL; + create.poseInReferenceSpace = location.pose; + XrSpace recentered = XR_NULL_HANDLE; + if (XR_FAILED(::xrCreateReferenceSpace(session, &create, &recentered)) || + recentered == XR_NULL_HANDLE) + return false; + + const XrSpace previous = localSpace; + localSpace = recentered; + OutRunVrFinalTest::LocalSpace = recentered; + std::uint64_t generation = + ApplicationSpaceGeneration.fetch_add(1, std::memory_order_acq_rel) + 1; + if (generation == 0) + generation = + ApplicationSpaceGeneration.fetch_add(1, std::memory_order_acq_rel) + 1; + PendingApplicationRecenter.store(false, std::memory_order_release); + InvalidateFallbackAnchor(); + + // Never destroy the immutable runtime LOCAL. Old application-created + // recenter spaces can be retired immediately after all future locates + // are redirected to the new handle. + if (previous != XR_NULL_HANDLE && previous != base) + ::xrDestroySpace(previous); + + std::cerr + << "[R46 recenter] application LOCAL origin recreated generation=" + << generation << " current headset pose is now application center\n"; + return true; + } + + inline bool EnsureFallbackAnchor(XrSession session, + XrTime displayTime) noexcept + { + if (FallbackAnchorValid) + return true; + if (session == XR_NULL_HANDLE || displayTime == 0 || + OutRunVrFinalTest::LocalSpace == XR_NULL_HANDLE || + !OutRunVrSbsCaptureOverride::EnsureViewSpace(session) || + OutRunVrSbsCaptureOverride::ViewSpace == XR_NULL_HANDLE) + return false; + + XrSpaceLocation location{ XR_TYPE_SPACE_LOCATION }; + if (XR_FAILED(::xrLocateSpace( + OutRunVrSbsCaptureOverride::ViewSpace, + OutRunVrFinalTest::LocalSpace, displayTime, &location))) + return false; + constexpr XrSpaceLocationFlags need = + XR_SPACE_LOCATION_ORIENTATION_VALID_BIT | + XR_SPACE_LOCATION_POSITION_VALID_BIT; + if ((location.locationFlags & need) != need) + return false; + + FallbackAnchor = location.pose; + const XrVector3f forward = RotateVector( + location.pose.orientation, { 0.0f, 0.0f, -1.5f }); + FallbackAnchor.position.x += forward.x; + FallbackAnchor.position.y += forward.y; + FallbackAnchor.position.z += forward.z; + FallbackAnchorValid = true; + return true; + } + + inline bool TrySubmitAnchoredStartupFallback(XrSession session, + const XrFrameEndInfo* endInfo, XrResult& result) noexcept + { + if (!endInfo || endInfo->layerCount != 0 || + !EnsureFallbackAnchor(session, endInfo->displayTime)) + return false; + + XrCompositionLayerQuad quad{}; + if (!OutRunVrSbsCaptureOverride::RenderTheaterOverride(session, quad)) + return false; + + // R19 renders the correct live texture but normally returns a VIEW-space + // head-locked quad. Keep its swapchain/size and replace only its spatial + // interpretation with the current LOCAL anchor. + quad.space = OutRunVrFinalTest::LocalSpace; + quad.pose = FallbackAnchor; + const XrCompositionLayerBaseHeader* layer = + reinterpret_cast(&quad); + XrFrameEndInfo patched = *endInfo; + patched.layerCount = 1; + patched.layers = &layer; + OutRunVrR23RuntimeHardening::RecordFinalSubmission( + 0, OutRunVrR23VerifiedBundle::SourceKind::None, true); + result = OutRunVrFinalTest::EndFrame(session, &patched); + if (XR_SUCCEEDED(result)) + { + ++AnchoredStartupFallbacks; + if (!FirstAnchoredFallbackLogged) + { + FirstAnchoredFallbackLogged = true; + std::cerr + << "[R28 recenter] startup/menu no-layer fallback is now LOCAL-anchored and recenterable before first gameplay projection\n"; + } + } + return true; + } + + inline void WriteSyntheticLocalChange(XrEventDataBuffer* eventData, + XrSession session) noexcept + { + XrEventDataReferenceSpaceChangePending synthetic{ + XR_TYPE_EVENT_DATA_REFERENCE_SPACE_CHANGE_PENDING }; + synthetic.session = session; + synthetic.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL; + synthetic.changeTime = 0; + synthetic.poseValid = XR_FALSE; + std::memset(eventData, 0, sizeof(*eventData)); + std::memcpy(eventData, &synthetic, sizeof(synthetic)); + } + + inline XrResult XRAPI_CALL PollEvent(XrInstance instance, + XrEventDataBuffer* eventData) noexcept + { + if (eventData) + { + LONG requestId = 0; + DWORD requesterPid = 0; + auto& channel = OutRunVR::RecenterIpc::SharedChannel(); + if (channel.Pending(requestId, requesterPid)) + { + // Received means only that the host accepted ownership. Applied + // is deliberately deferred until EndFrame succeeds after the + // LOCAL anchor invalidation/rebuild cycle. + const std::uint64_t targetGeneration = + ApplicationSpaceGeneration.load(std::memory_order_acquire) + 1; + PendingGameTargetGeneration.store( + targetGeneration, std::memory_order_release); + QueueApplicationRecenter(); + WriteSyntheticLocalChange(eventData, XR_NULL_HANDLE); + channel.MarkReceived(requestId); + PendingGameRequestId.store(requestId, std::memory_order_release); + ++GameRequestsReceived; + std::cerr + << "[R28 recenter] F10 request received requestId=" + << requestId << " pid=" << requesterPid + << "; pending until visible post-reanchor submission\n"; + return XR_SUCCESS; + } + } + + if (PendingFocusRecenter && eventData) + { + QueueApplicationRecenter(); + WriteSyntheticLocalChange(eventData, PendingFocusSession); + PendingFocusRecenter = false; + PendingFocusSession = XR_NULL_HANDLE; + return XR_SUCCESS; + } + + const XrResult result = ::xrPollEvent(instance, eventData); + if (result != XR_SUCCESS || !eventData) + return result; + + if (eventData->type == XR_TYPE_EVENT_DATA_REFERENCE_SPACE_CHANGE_PENDING) + { + auto* change = reinterpret_cast( + eventData); + + // The existing host loop already has the correct invalidation and + // re-anchor path; make every runtime reference-space change enter it. + change->referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL; + InvalidateFallbackAnchor(); + ++ReferenceChangesNormalized; + if (!FirstReferenceChangeLogged) + { + FirstReferenceChangeLogged = true; + std::cerr + << "[R26 recenter] OpenXR reference-space changes now reanchor the theater regardless of runtime space type\n"; + } + } + else if (eventData->type == XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED) + { + const auto* state = + reinterpret_cast(eventData); + if (state->state == XR_SESSION_STATE_FOCUSED) + { + PendingFocusRecenter = true; + PendingFocusSession = state->session; + ++FocusRecentersQueued; + if (!FirstFocusRecenterLogged) + { + FirstFocusRecenterLogged = true; + std::cerr + << "[R26 recenter] session FOCUSED queues a theater reanchor for Quest/VDXR recenter and focus return\n"; + } + } + } + + return result; + } + + inline const char* IncomingPath(const XrFrameEndInfo* endInfo) noexcept + { + if (!endInfo || endInfo->layerCount == 0 || !endInfo->layers) + return "no-layer"; + for (std::uint32_t i = 0; i < endInfo->layerCount; ++i) + { + const auto* layer = endInfo->layers[i]; + if (!layer) continue; + if (layer->type == XR_TYPE_COMPOSITION_LAYER_PROJECTION) + return "projection"; + if (layer->type == XR_TYPE_COMPOSITION_LAYER_QUAD) + return "theater-quad"; + } + return "other-layer"; + } + + inline bool CompletePendingGameRequestAfterVisibleProjection() noexcept + { + const LONG pending = + PendingGameRequestId.load(std::memory_order_acquire); + if (pending == 0 || + !ApplicationRecenterAppliedForPendingGameRequest()) + return false; + + auto& channel = OutRunVR::RecenterIpc::SharedChannel(); + channel.MarkApplied(pending); + LONG expected = pending; + const bool cleared = PendingGameRequestId.compare_exchange_strong( + expected, 0, std::memory_order_acq_rel, + std::memory_order_acquire); + if (cleared) + { + ++GameRequestsApplied; + PendingGameTargetGeneration.store(0, std::memory_order_release); + InvalidateFallbackAnchor(); + std::cerr + << "[R45 recenter] requestId=" << pending + << " completed by fresh visible DirectGPU projection; fast path remained live\n"; + } + return cleared; + } + + inline XrResult XRAPI_CALL EndFrame(XrSession session, + const XrFrameEndInfo* endInfo) noexcept + { + const LONG pending = PendingGameRequestId.load(std::memory_order_acquire); + + XrResult result = XR_SUCCESS; + bool anchoredStartup = false; + if (endInfo && endInfo->layerCount == 0) + anchoredStartup = TrySubmitAnchoredStartupFallback( + session, endInfo, result); + + const std::uint64_t liveBefore = + OutRunVrR24BlackScreenGuard::LiveTheaterFallbacks; + const std::uint64_t flatBefore = + OutRunVrR24BlackScreenGuard::DirectFlatFallbacks; + const std::uint64_t cachedBefore = + OutRunVrR24BlackScreenGuard::CachedLayerFallbacks; + const std::uint64_t emergencyBefore = + OutRunVrR24BlackScreenGuard::EmergencyLayerFallbacks; + + if (!anchoredStartup) + result = OutRunVrR24BlackScreenGuard::EndFrame(session, endInfo); + + const bool r24ViewFallback = !anchoredStartup && + (OutRunVrR24BlackScreenGuard::LiveTheaterFallbacks != liveBefore || + OutRunVrR24BlackScreenGuard::DirectFlatFallbacks != flatBefore || + OutRunVrR24BlackScreenGuard::CachedLayerFallbacks != cachedBefore || + OutRunVrR24BlackScreenGuard::EmergencyLayerFallbacks != emergencyBefore); + + if (pending != 0 && ApplicationRecenterAppliedForPendingGameRequest() && + XR_SUCCEEDED(result) && !r24ViewFallback && + (anchoredStartup || (endInfo && endInfo->layerCount > 0))) + { + auto& channel = OutRunVR::RecenterIpc::SharedChannel(); + channel.MarkApplied(pending); + LONG expected = pending; + PendingGameRequestId.compare_exchange_strong( + expected, 0, std::memory_order_acq_rel, + std::memory_order_acquire); + ++GameRequestsApplied; + PendingGameTargetGeneration.store(0, std::memory_order_release); + std::cerr + << "[R28 recenter] requestId=" << pending + << " path=" << (anchoredStartup ? + "local-live-fallback" : IncomingPath(endInfo)) + << " anchorUpdated=1 submitSuccess=1 appliedId=" + << pending << "\n"; + } + else if (pending != 0 && r24ViewFallback) + { + // Do not claim completion for a head-locked R24 emergency/fallback + // quad. Keep the request pending until a LOCAL/projection path or the + // anchored startup fallback is actually submitted. + InvalidateFallbackAnchor(); + } + + return result; + } +} + +#define xrPollEvent OutRunVrR26RecenterHardening::PollEvent +#define xrEndFrame OutRunVrR26RecenterHardening::EndFrame diff --git a/vrhost/src/runtime/r32_direct_submit.hpp b/vrhost/src/runtime/r32_direct_submit.hpp new file mode 100644 index 000000000..572cc883d --- /dev/null +++ b/vrhost/src/runtime/r32_direct_submit.hpp @@ -0,0 +1,497 @@ +#pragma once + +// R32 final DirectGPU submission fast path. +// +// main_r23 already rendered a validated DirectGPU source into the incoming +// OpenXR projection before xrEndFrame. R24 deliberately rebuilt that image from +// SafeEye, which added two full-eye CopyResource operations, a synchronous 8 ms +// fence budget and a second pair of projection blits. R32 submits the already +// verified incoming projection once and places a D3D11 EVENT after the source +// sampling commands. Producer-ring ACK is published asynchronously only after +// that event completes. Any uncertainty falls through to R26/R24, preserving +// SafeEye A/B and visible fallback behavior. +// +// Review-2 hardening removes the unconditional per-frame D3D11 Flush. A Flush is +// issued only when a producer slot is actually blocked by a still-pending ACK, +// so normal command batching is preserved without weakening reuse safety. + +#include "r26_recenter_hardening.hpp" + +#ifdef xrEndFrame +#undef xrEndFrame +#endif +#ifdef xrDestroySession +#undef xrDestroySession +#endif + +#include +#include +#include + +namespace OutRunVrR32DirectSubmit +{ + inline constexpr const char* BuildId = + "R45-direct-recenter-live-projection-20260920"; + + enum class FastRejectReason : std::uint8_t + { + None, + NoEndInfo, + PendingRecenter, + PendingGameRequest, + NonProjectionLayer, + BundleNotFresh, + NotDirectGpu, + FrameIncomplete, + MetadataInvalid, + ProjectionMismatch, + TransportDisabled, + GenerationFault, + AckBusy, + Count + }; + + struct PendingAck + { + ID3D11Query* fence = nullptr; + bool armed = false; + bool flushIssued = false; + OutRunVR::SharedRenderFrameState frame{}; + }; + + inline std::array Pending{}; + inline std::array AckedFrame{}; + inline std::array AckedGeneration{}; + inline std::uint64_t FastDirectSubmits = 0; + inline std::uint64_t FastDirectRejects = 0; + inline std::uint64_t AckArmed = 0; + inline std::uint64_t AckCompleted = 0; + inline std::uint64_t AckPublishRetry = 0; + inline std::uint64_t AckSlotBusy = 0; + inline std::uint64_t AckFlushEscalations = 0; + inline std::uint64_t AckQueryErrors = 0; + inline std::uint64_t AckSameFramePendingReuse = 0; + inline std::array(FastRejectReason::Count)> RejectReasons{}; + inline std::uint32_t AckFaultGeneration = 0; + inline std::uint32_t ActiveAckGeneration = 0; + inline ULONGLONG LastPerfLogMs = 0; + inline bool FirstFastSubmitLogged = false; + inline bool FirstAsyncAckLogged = false; + inline bool FirstDeferredFlushLogged = false; + + struct PerfSnapshot + { + std::uint64_t fastSubmit = 0; + std::uint64_t reject = 0; + std::uint64_t ackArmed = 0; + std::uint64_t ackCompleted = 0; + std::uint64_t ackRetry = 0; + std::uint64_t ackSlotBusy = 0; + std::uint64_t flushEscalations = 0; + std::uint64_t ackQueryError = 0; + std::uint64_t sameFramePendingReuse = 0; + std::array(FastRejectReason::Count)> rejectReasons{}; + std::uint64_t safeCacheHit = 0; + std::uint64_t safeCacheMiss = 0; + std::uint64_t safeSwap = 0; + std::uint64_t timeoutPreserve = 0; + }; + inline PerfSnapshot Perf{}; + + inline void ReleasePending() noexcept + { + for (auto& pending : Pending) + { + if (pending.fence) + { + pending.fence->Release(); + pending.fence = nullptr; + } + pending.armed = false; + pending.flushIssued = false; + pending.frame = {}; + } + AckedFrame.fill(0); + AckedGeneration.fill(0); + AckFaultGeneration = 0; + ActiveAckGeneration = 0; + AckSameFramePendingReuse = 0; + RejectReasons.fill(0); + } + + inline void ObserveGeneration(std::uint32_t generation) noexcept + { + if (!generation || ActiveAckGeneration == generation) + return; + ActiveAckGeneration = generation; + AckedFrame.fill(0); + AckedGeneration.fill(0); + } + + inline bool EnsureFence(std::uint32_t slot) noexcept + { + if (slot >= Pending.size() || !OutRunVrFinalTest::Device) + return false; + auto& pending = Pending[slot]; + if (pending.fence) + return true; + D3D11_QUERY_DESC desc{}; + desc.Query = D3D11_QUERY_EVENT; + return SUCCEEDED(OutRunVrFinalTest::Device->CreateQuery( + &desc, &pending.fence)) && pending.fence; + } + + inline void PollCompletedAcks() noexcept + { + if (!OutRunVrFinalTest::Context) + return; + for (auto& pending : Pending) + { + if (!pending.armed || !pending.fence) + continue; + const HRESULT hr = OutRunVrFinalTest::Context->GetData( + pending.fence, nullptr, 0, D3D11_ASYNC_GETDATA_DONOTFLUSH); + if (hr == S_FALSE) + continue; + if (FAILED(hr)) + { + // Completion is unknowable, so never ACK this producer frame. + // Disable fast-submit for its transport generation and let the + // SafeEye fallback perform a separately fenced copy/ACK. + const std::uint32_t generation = + pending.frame.reserved[ + OutRunVR::RenderFrameDirectGenerationIndex]; + if (generation) + AckFaultGeneration = generation; + ++AckQueryErrors; + pending.armed = false; + pending.flushIssued = false; + pending.frame = {}; + pending.fence->Release(); + pending.fence = nullptr; + continue; + } + const std::uint32_t completedGeneration = + pending.frame.reserved[ + OutRunVR::RenderFrameDirectGenerationIndex]; + if (ActiveAckGeneration != 0 && + completedGeneration != ActiveAckGeneration) + { + // Late completion from a superseded shared-eye generation is + // safe to forget, but must never roll the global ACK generation + // backwards and stall the producer's new ring. + pending.armed = false; + pending.flushIssued = false; + pending.frame = {}; + ++AckCompleted; + continue; + } + if (!OutRunVrD3D9ExDirectPassthrough::PublishCompletedFrame( + pending.frame)) + { + ++AckPublishRetry; + continue; + } + const std::uint32_t slot = + pending.frame.reserved[OutRunVR::RenderFrameDirectSlotIndex]; + const std::uint32_t generation = + pending.frame.reserved[OutRunVR::RenderFrameDirectGenerationIndex]; + if (slot < AckedFrame.size()) + { + AckedFrame[slot] = pending.frame.frameId; + AckedGeneration[slot] = generation; + } + pending.armed = false; + pending.flushIssued = false; + pending.frame = {}; + ++AckCompleted; + if (!FirstAsyncAckLogged) + { + FirstAsyncAckLogged = true; + std::cerr + << "[R32 direct] asynchronous GPU-consumption ACK active; producer slots are released without an xrEndFrame CPU fence wait\n"; + } + } + } + + inline bool ArmConsumptionFence( + const OutRunVR::SharedRenderFrameState& frame) noexcept + { + if (!OutRunVrFinalTest::Context) + return false; + const std::uint32_t slot = + frame.reserved[OutRunVR::RenderFrameDirectSlotIndex]; + const std::uint32_t generation = + frame.reserved[OutRunVR::RenderFrameDirectGenerationIndex]; + if (slot >= Pending.size() || !generation || !EnsureFence(slot)) + return false; + + if (AckedGeneration[slot] == generation && + AckedFrame[slot] == frame.frameId) + return true; + if (AckedGeneration[slot] != 0 && AckedGeneration[slot] != generation) + { + AckedGeneration[slot] = 0; + AckedFrame[slot] = 0; + } + + auto& pending = Pending[slot]; + if (pending.armed && + pending.frame.frameId == frame.frameId && + pending.frame.reserved[ + OutRunVR::RenderFrameDirectGenerationIndex] == generation) + { + // R42: xrWaitFrame can submit the same already-rendered projection + // more than once before the first EVENT is observed complete. The + // existing EVENT already protects the only shared-source sampling + // commands for this producer frame, so do not demote a harmless + // cached projection tick into the synchronous SafeEye fallback. + ++AckSameFramePendingReuse; + return true; + } + if (pending.armed) + { + PollCompletedAcks(); + if (pending.armed && !pending.flushIssued) + { + OutRunVrFinalTest::Context->Flush(); + pending.flushIssued = true; + ++AckFlushEscalations; + if (!FirstDeferredFlushLogged) + { + FirstDeferredFlushLogged = true; + std::cerr + << "[R32 direct] D3D11 Flush is deferred until actual direct-ring slot pressure; steady frames keep driver batching intact\n"; + } + PollCompletedAcks(); + } + if (pending.armed) + { + ++AckSlotBusy; + return false; + } + } + + if (AckedGeneration[slot] == generation && + AckedFrame[slot] == frame.frameId) + return true; + + OutRunVrFinalTest::Context->End(pending.fence); + pending.frame = frame; + pending.armed = true; + pending.flushIssued = false; + ++AckArmed; + return true; + } + + inline bool MetadataValid( + const OutRunVR::SharedRenderFrameState& frame) noexcept + { + const std::uint32_t slot = + frame.reserved[OutRunVR::RenderFrameDirectSlotIndex]; + const std::uint32_t generation = + frame.reserved[OutRunVR::RenderFrameDirectGenerationIndex]; + const std::uint32_t width = + frame.reserved[OutRunVR::RenderFrameDirectWidthIndex]; + const std::uint32_t height = + frame.reserved[OutRunVR::RenderFrameDirectHeightIndex]; + const auto format = + OutRunVrD3D9ExDirectPassthrough::ExpectedDeclaredFormat( + frame.reserved[OutRunVR::RenderFrameDirectFormatIndex]); + return slot < Pending.size() && generation != 0 && width != 0 && + height != 0 && width == frame.backbufferWidth && + height == frame.backbufferHeight && format != DXGI_FORMAT_UNKNOWN; + } + + inline bool CanFastSubmit( + const XrFrameEndInfo* endInfo, + OutRunVrR23VerifiedBundle::Snapshot& verified, + FastRejectReason& reject) noexcept + { + using OutRunVrR23VerifiedBundle::SourceKind; + reject = FastRejectReason::None; + if (!endInfo) { reject = FastRejectReason::NoEndInfo; return false; } + if (OutRunVrR26RecenterHardening::PendingFocusRecenter) + { reject = FastRejectReason::PendingRecenter; return false; } + // R45: a game recenter request must not disable the only fresh + // DirectGPU presentation path. R44/R42 fell through to R24 cached-image + // fallback here; R26 then refused to mark a cached fallback as applied, + // leaving PendingGameRequestId set forever and permanently disabling + // fast submit. The synthetic LOCAL-change event is consumed before this + // frame is rendered, so a successful fresh projection is a valid + // post-recenter visible submission and completes the request below. + if (OutRunVrReviewHardening::HasIncomingNonProjectionLayer(endInfo)) + { reject = FastRejectReason::NonProjectionLayer; return false; } + if (!OutRunVrR23VerifiedBundle::ReadFresh(verified)) + { reject = FastRejectReason::BundleNotFresh; return false; } + if (verified.kind != SourceKind::DirectGpu) + { reject = FastRejectReason::NotDirectGpu; return false; } + if (!OutRunVrSbsCaptureOverride::FrameComplete(verified.frame)) + { reject = FastRejectReason::FrameIncomplete; return false; } + if (!MetadataValid(verified.frame)) + { reject = FastRejectReason::MetadataInvalid; return false; } + if (!OutRunVrR24BlackScreenGuard::ProjectionMatchesSnapshot( + endInfo, verified)) + { reject = FastRejectReason::ProjectionMismatch; return false; } + if (!OutRunVrR21RuntimeHardening::DirectTransportRequested()) + { reject = FastRejectReason::TransportDisabled; return false; } + + const std::uint32_t generation = + verified.frame.reserved[OutRunVR::RenderFrameDirectGenerationIndex]; + if (generation != 0 && AckFaultGeneration == generation) + { reject = FastRejectReason::GenerationFault; return false; } + + // main_r23 has already staged this exact immutable slot and rendered the + // verified projection. R42 permits repeated submission of that same + // projection while its original async EVENT is still pending. + return true; + } + + inline void CapturePerfSnapshot() noexcept + { + Perf.fastSubmit = FastDirectSubmits; + Perf.reject = FastDirectRejects; + Perf.ackArmed = AckArmed; + Perf.ackCompleted = AckCompleted; + Perf.ackRetry = AckPublishRetry; + Perf.ackSlotBusy = AckSlotBusy; + Perf.flushEscalations = AckFlushEscalations; + Perf.ackQueryError = AckQueryErrors; + Perf.sameFramePendingReuse = AckSameFramePendingReuse; + Perf.rejectReasons = RejectReasons; + Perf.safeCacheHit = OutRunVrD3D9ExDirectPassthrough::R32SharedCacheHits; + Perf.safeCacheMiss = OutRunVrD3D9ExDirectPassthrough::R32SharedCacheMisses; + Perf.safeSwap = OutRunVrD3D9ExDirectPassthrough::R32SafeSwaps; + Perf.timeoutPreserve = OutRunVrD3D9ExDirectPassthrough::R32SafeTimeoutPreserves; + } + + inline void MaybeLogPerf() noexcept + { + const ULONGLONG now = GetTickCount64(); + if (LastPerfLogMs == 0) + { + LastPerfLogMs = now; + CapturePerfSnapshot(); + return; + } + if (now - LastPerfLogMs < 5000) + return; + LastPerfLogMs = now; + std::cerr + << "[R32 direct PERF 5s] fastSubmit=" << FastDirectSubmits - Perf.fastSubmit + << " reject=" << FastDirectRejects - Perf.reject + << " ackArmed=" << AckArmed - Perf.ackArmed + << " ackCompleted=" << AckCompleted - Perf.ackCompleted + << " ackRetry=" << AckPublishRetry - Perf.ackRetry + << " ackSlotBusy=" << AckSlotBusy - Perf.ackSlotBusy + << " deferredFlush=" << AckFlushEscalations - Perf.flushEscalations + << " ackQueryError=" << AckQueryErrors - Perf.ackQueryError + << " sameFrameAckReuse=" + << AckSameFramePendingReuse - Perf.sameFramePendingReuse + << " rejectReason[projection=" + << RejectReasons[static_cast( + FastRejectReason::ProjectionMismatch)] - + Perf.rejectReasons[static_cast( + FastRejectReason::ProjectionMismatch)] + << ",ackBusy=" + << RejectReasons[static_cast( + FastRejectReason::AckBusy)] - + Perf.rejectReasons[static_cast( + FastRejectReason::AckBusy)] + << ",bundle=" + << RejectReasons[static_cast( + FastRejectReason::BundleNotFresh)] - + Perf.rejectReasons[static_cast( + FastRejectReason::BundleNotFresh)] + << ",recenter=" + << RejectReasons[static_cast( + FastRejectReason::PendingRecenter)] - + Perf.rejectReasons[static_cast( + FastRejectReason::PendingRecenter)] + << "]" + << " safeCacheHit=" + << OutRunVrD3D9ExDirectPassthrough::R32SharedCacheHits - Perf.safeCacheHit + << " safeCacheMiss=" + << OutRunVrD3D9ExDirectPassthrough::R32SharedCacheMisses - Perf.safeCacheMiss + << " safeSwap=" + << OutRunVrD3D9ExDirectPassthrough::R32SafeSwaps - Perf.safeSwap + << " timeoutPreserve=" + << OutRunVrD3D9ExDirectPassthrough::R32SafeTimeoutPreserves - Perf.timeoutPreserve + << "\n"; + CapturePerfSnapshot(); + } + + inline XrResult XRAPI_CALL EndFrame( + XrSession session, const XrFrameEndInfo* endInfo) noexcept + { + // Learn the newest committed DirectGPU generation before polling older + // EVENT queries. This closes the reset/recreation ACK rollback window. + OutRunVrR23VerifiedBundle::Snapshot observed{}; + if (OutRunVrR23VerifiedBundle::ReadFresh(observed) && + observed.kind == + OutRunVrR23VerifiedBundle::SourceKind::DirectGpu && + MetadataValid(observed.frame)) + { + ObserveGeneration(observed.frame.reserved[ + OutRunVR::RenderFrameDirectGenerationIndex]); + } + PollCompletedAcks(); + + OutRunVrR23VerifiedBundle::Snapshot verified{}; + FastRejectReason reject = FastRejectReason::None; + const bool fastEligible = CanFastSubmit(endInfo, verified, reject); + const bool ackReady = + fastEligible && ArmConsumptionFence(verified.frame); + if (fastEligible && ackReady) + { + const XrResult result = + OutRunVrFinalTest::EndFrame(session, endInfo); + const bool submitted = XR_SUCCEEDED(result) && + endInfo && endInfo->layerCount > 0; + OutRunVrR23RuntimeHardening::RecordFinalSubmission( + verified.frameId, verified.kind, submitted); + ++FastDirectSubmits; + if (submitted) + OutRunVrR26RecenterHardening:: + CompletePendingGameRequestAfterVisibleProjection(); + if (!FirstFastSubmitLogged) + { + FirstFastSubmitLogged = true; + std::cerr + << "[R32 direct] verified incoming DirectGPU projection submitted once; rendered projection is presentation-authoritative while its exact producer slot stays protected by asynchronous GPU ACK; redundant SafeEye copy + second projection removed; R42 same-frame pending EVENT reuse prevents cached XR ticks from entering SafeEye fallback build=" + << BuildId << "\n"; + } + MaybeLogPerf(); + return result; + } + + if (fastEligible && !ackReady) + reject = FastRejectReason::AckBusy; + if (reject != FastRejectReason::None) + { + ++FastDirectRejects; + const auto index = static_cast(reject); + if (index < RejectReasons.size()) + ++RejectReasons[index]; + } + const XrResult result = + OutRunVrR26RecenterHardening::EndFrame(session, endInfo); + MaybeLogPerf(); + return result; + } + + inline XrResult XRAPI_CALL DestroySession(XrSession session) noexcept + { + ReleasePending(); + OutRunVrD3D9ExDirectPassthrough::R32ResetDirectCaches(); + return OutRunVrR24BlackScreenGuard::DestroySession(session); + } +} + +#ifdef EnsureSafeFrame +#undef EnsureSafeFrame +#endif +#define xrEndFrame OutRunVrR32DirectSubmit::EndFrame +#define xrDestroySession OutRunVrR32DirectSubmit::DestroySession \ No newline at end of file diff --git a/vrhost/src/runtime/review_hardening.hpp b/vrhost/src/runtime/review_hardening.hpp new file mode 100644 index 000000000..8f2f055c2 --- /dev/null +++ b/vrhost/src/runtime/review_hardening.hpp @@ -0,0 +1,236 @@ +#pragma once + +// Final runtime ownership policy after the source review passes. +// +// Classic D3D9: +// 1. The main host's frame/QPC/pose-matched projection is authoritative. +// 2. R10 Desktop Duplication is only a fallback when no exact projection was +// produced and a NEW complete classic frame has advanced recently. +// 3. A stale Frame.v2 mapping can no longer refresh fallback freshness merely +// because xrEndFrame keeps reading the same frame id. +// 4. Fallback freshness is additionally bounded by the producer's presentQpc; +// first observing an old frame after a mode/session transition cannot make +// that frame fresh again. +// 5. An incoming non-projection layer (notably the theater quad) is owned by +// the main host and must never be replaced by an old gameplay SBS frame. +// +// D3D9Ex direct: +// 1. R13 host-owned SafeEye copies remain the only final projection source. +// 2. If safe staging/rendering fails, never split the desktop as SBS: the game +// may have intentionally skipped ComposeSbs while direct transport was ready. +// 3. Fall back to the already-built incoming OpenXR layer (or no layer), while +// keeping the producer slot untrusted rather than fabricating two eyes. + +#include "openxr_api_compat.hpp" + +#ifdef xrEndFrame +#undef xrEndFrame +#endif +#ifdef xrDestroySession +#undef xrDestroySession +#endif + +#include +#include +#include + +namespace OutRunVrReviewHardening +{ + inline constexpr const char* BuildId = "R14-review-hardening-20260915"; + inline constexpr const char* Review10BuildId = "R15-source-review-10pass-20260915"; + inline constexpr ULONGLONG ClassicFallbackFreshMs = 500; + + inline std::uint32_t LastClassicFrameId = 0; + inline ULONGLONG LastClassicAdvanceMs = 0; + inline std::uint64_t ExactCoreProjectionFrames = 0; + inline std::uint64_t PreservedNonProjectionLayers = 0; + inline std::uint64_t ClassicFallbackFrames = 0; + inline std::uint64_t StaleClassicFallbackBlocks = 0; + inline std::uint64_t DirectSafeFrames = 0; + inline std::uint64_t DirectUnsafeFallbackBlocks = 0; + inline bool FirstCoreAuthorityLogged = false; + inline bool FirstNonProjectionPreserveLogged = false; + inline bool FirstStaleBlockLogged = false; + inline bool FirstDirectAuthorityLogged = false; + inline bool FirstDirectBlockLogged = false; + + inline bool IncomingProjectionValid(const XrFrameEndInfo* endInfo) noexcept + { + return endInfo && endInfo->layerCount > 0 && endInfo->layers && + endInfo->layers[0] && + endInfo->layers[0]->type == XR_TYPE_COMPOSITION_LAYER_PROJECTION; + } + + inline bool HasIncomingNonProjectionLayer(const XrFrameEndInfo* endInfo) noexcept + { + if (!endInfo || endInfo->layerCount == 0 || !endInfo->layers) + return false; + for (std::uint32_t i = 0; i < endInfo->layerCount; ++i) + { + const XrCompositionLayerBaseHeader* layer = endInfo->layers[i]; + if (layer && layer->type != XR_TYPE_COMPOSITION_LAYER_PROJECTION) + return true; + } + return false; + } + + inline bool ReadLatestFrame(OutRunVR::SharedRenderFrameState& frame) noexcept + { + std::uint32_t publish = 0; + return OutRunVrFinalTest::ReadLatestFrame(frame, publish); + } + + inline bool LatestCompleteDirectFrame(OutRunVR::SharedRenderFrameState& frame) noexcept + { + if (!ReadLatestFrame(frame) || + !OutRunVrSbsCaptureOverride::FrameComplete(frame)) + return false; + return (frame.flags & OutRunVR::RenderFrameDirectGpuTransport) != 0; + } + + inline bool ProducerPresentFresh( + const OutRunVR::SharedRenderFrameState& frame, + std::int64_t maxAgeMs = static_cast(ClassicFallbackFreshMs)) noexcept + { + if (frame.presentQpc <= 0 || maxAgeMs <= 0) + return false; + LARGE_INTEGER now{}; + LARGE_INTEGER frequency{}; + if (!QueryPerformanceCounter(&now) || !QueryPerformanceFrequency(&frequency) || + frequency.QuadPart <= 0) + return false; + const std::int64_t age = now.QuadPart - frame.presentQpc; + const std::int64_t maxAge = (frequency.QuadPart * maxAgeMs) / 1000; + return age >= 0 && age <= maxAge; + } + + inline bool FreshClassicFallbackAvailable() noexcept + { + OutRunVR::SharedRenderFrameState frame{}; + if (!ReadLatestFrame(frame) || + !OutRunVrSbsCaptureOverride::FrameComplete(frame) || + (frame.flags & OutRunVR::RenderFrameDirectGpuTransport) != 0 || + !ProducerPresentFresh(frame)) + return false; + + const ULONGLONG now = GetTickCount64(); + if (frame.frameId != LastClassicFrameId) + { + LastClassicFrameId = frame.frameId; + LastClassicAdvanceMs = now; + } + return LastClassicAdvanceMs != 0 && + now - LastClassicAdvanceMs <= ClassicFallbackFreshMs; + } + + inline XrResult XRAPI_CALL EndFrame(XrSession session, + const XrFrameEndInfo* endInfo) + { + // A quad (theater/menu) or any other non-projection layer is already a + // deliberate main-host presentation decision. Never reinterpret a stale + // gameplay Frame.v2 entry as authority over that layer. + if (HasIncomingNonProjectionLayer(endInfo)) + { + ++PreservedNonProjectionLayers; + if (!FirstNonProjectionPreserveLogged) + { + FirstNonProjectionPreserveLogged = true; + std::cerr + << "[R15] incoming non-projection layer preserved; classic/direct " + "gameplay fallback cannot replace theater/menu content build=" + << Review10BuildId << "\n"; + } + return OutRunVrFinalTest::EndFrame(session, endInfo); + } + + OutRunVR::SharedRenderFrameState latest{}; + const bool latestDirect = LatestCompleteDirectFrame(latest); + const auto directState = + OutRunVrD3D9ExDirectPassthrough::ReadDirectHostState(); + const bool exactIncoming = IncomingProjectionValid(endInfo); + const bool directCandidate = latestDirect && directState.valid && + directState.openedFrame != 0 && exactIncoming; + + if (latestDirect) + { + if (directCandidate && + OutRunVrD3D9ExDirectPassthrough::EnsureSafeFrame( + directState.openedFrame)) + { + XrCompositionLayerProjection projection{}; + std::array views{}; + if (OutRunVrD3D9ExDirectPassthrough::RenderSafeProjection( + session, endInfo, projection, views)) + { + const XrCompositionLayerBaseHeader* layer = + reinterpret_cast( + &projection); + XrFrameEndInfo patched = *endInfo; + patched.layerCount = 1; + patched.layers = &layer; + ++DirectSafeFrames; + if (!FirstDirectAuthorityLogged) + { + FirstDirectAuthorityLogged = true; + std::cerr + << "[R14] D3D9Ex final authority: host-owned SafeEye projection; " + "desktop SBS fallback disabled for direct frames build=" + << BuildId << "\n"; + } + return OutRunVrFinalTest::EndFrame(session, &patched); + } + } + + ++DirectUnsafeFallbackBlocks; + if (!FirstDirectBlockLogged) + { + FirstDirectBlockLogged = true; + std::cerr + << "[R14] direct frame could not be staged/rendered safely; " + "refusing R10 desktop split and preserving incoming layer/no-layer\n"; + } + return OutRunVrFinalTest::EndFrame(session, endInfo); + } + + if (exactIncoming) + { + ++ExactCoreProjectionFrames; + if (!FirstCoreAuthorityLogged) + { + FirstCoreAuthorityLogged = true; + std::cerr + << "[R14] classic D3D9 exact core projection is authoritative; " + "R10 recapture will not overwrite a matched frame build=" + << BuildId << "\n"; + } + return OutRunVrFinalTest::EndFrame(session, endInfo); + } + + if (FreshClassicFallbackAvailable()) + { + ++ClassicFallbackFrames; + return OutRunVrSbsCaptureOverride::EndFrame(session, endInfo); + } + + ++StaleClassicFallbackBlocks; + if (!FirstStaleBlockLogged && LastClassicFrameId != 0) + { + FirstStaleBlockLogged = true; + std::cerr + << "[R15] stale classic SBS frame blocked; fallback requires both a new " + "Frame.v2 id and producer presentQpc freshness build=" + << Review10BuildId << "\n"; + } + return OutRunVrFinalTest::EndFrame(session, endInfo); + } + + inline XrResult XRAPI_CALL DestroySession(XrSession session) + { + LastClassicFrameId = 0; + LastClassicAdvanceMs = 0; + return OutRunVrD3D9ExDirectPassthrough::DestroySession(session); + } +} + +#define xrEndFrame OutRunVrReviewHardening::EndFrame +#define xrDestroySession OutRunVrReviewHardening::DestroySession \ No newline at end of file diff --git a/vrhost/src/runtime/sbs_capture_override.hpp b/vrhost/src/runtime/sbs_capture_override.hpp new file mode 100644 index 000000000..1be09ed27 --- /dev/null +++ b/vrhost/src/runtime/sbs_capture_override.hpp @@ -0,0 +1,1014 @@ +#pragma once + +// R19 single-owner classic-D3D9 SBS fallback. +// +// R18 proved the OpenXR swapchain, RTV, acquire/wait/release sequence and the +// production blit shader are healthy, while a second Desktop Duplication object +// created by the old R10 fallback fails with E_INVALIDARG. The production +// StereoCompositor already owns the one and only IDXGIOutputDuplication object, +// so this layer no longer creates or acquires a second duplication at all. +// +// Production path: +// StereoCompositor::Capture() +// Desktop Duplication -> production source_ (host-owned D3D11 texture) +// -> PublishProductionCapture() -> R19 Source (host-owned D3D11 texture) +// -> split left/right -> dedicated OpenXR projection swapchain. +// +// The extra CopyResource is intentional: the fallback owns a stable snapshot +// and never depends on IDXGIResource/AcquireNextFrame lifetime. The snapshot is +// accepted for gameplay only when its Desktop Duplication LastPresentTime QPC is +// at or after the complete game's Frame.v2 presentQpc. + +#include "openxr_api_compat.hpp" + +#ifdef xrEndFrame +#undef xrEndFrame +#endif +#ifdef xrDestroySession +#undef xrDestroySession +#endif + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace OutRunVrSbsCaptureOverride +{ + inline constexpr const char* BuildId = "R19-production-capture-reuse-20260916"; + inline constexpr wchar_t GameExeName[] = L"OR2006C2C.EXE"; + inline constexpr ULONGLONG PublishedSourceMaxAgeMs = 500; + + template + inline void ReleaseCom(T*& value) + { + if (value) + { + value->Release(); + value = nullptr; + } + } + + inline bool Enabled() + { + const char* value = std::getenv("OUTRUN_VR_CAPTURE_OVERRIDE"); + return !value || (std::strcmp(value, "0") != 0 && + _stricmp(value, "false") != 0 && _stricmp(value, "off") != 0); + } + + inline DWORD FindGamePid() + { + HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snap == INVALID_HANDLE_VALUE) + return 0; + PROCESSENTRY32W entry{}; + entry.dwSize = sizeof(entry); + DWORD pid = 0; + if (Process32FirstW(snap, &entry)) + { + do + { + if (_wcsicmp(entry.szExeFile, GameExeName) == 0) + { + pid = entry.th32ProcessID; + break; + } + } while (Process32NextW(snap, &entry)); + } + CloseHandle(snap); + return pid; + } + + struct WindowSearch + { + DWORD pid = 0; + HWND best = nullptr; + long long area = 0; + }; + + inline BOOL CALLBACK EnumWindowsProc(HWND hwnd, LPARAM param) + { + auto* state = reinterpret_cast(param); + DWORD pid = 0; + GetWindowThreadProcessId(hwnd, &pid); + if (pid != state->pid || !IsWindowVisible(hwnd)) + return TRUE; + RECT r{}; + if (!GetClientRect(hwnd, &r)) + return TRUE; + const long long w = r.right - r.left; + const long long h = r.bottom - r.top; + const long long area = w * h; + if (w >= 320 && h >= 200 && area > state->area) + { + state->best = hwnd; + state->area = area; + } + return TRUE; + } + + inline HWND FindGameWindow() + { + WindowSearch state{}; + state.pid = FindGamePid(); + if (!state.pid) + return nullptr; + EnumWindows(EnumWindowsProc, reinterpret_cast(&state)); + return state.best; + } + + struct UvRect + { + float x = 0.f; + float y = 0.f; + float w = 1.f; + float h = 1.f; + }; + + struct BlitParams + { + float uvScale[2]; + float uvOffset[2]; + float sdrWhiteScale; + float sourceIsScRgb; + float padding[2]; + }; + + inline constexpr const char* BlitShader = R"HLSL( +Texture2D SourceTexture : register(t0); +SamplerState SourceSampler : register(s0); +cbuffer BlitParams : register(b0) +{ + float2 UvScale; + float2 UvOffset; + float SdrWhiteScale; + float SourceIsScRgb; + float2 Padding; +}; +struct VSOut { float4 position : SV_Position; float2 uv : TEXCOORD0; }; +VSOut VSMain(uint id : SV_VertexID) +{ + VSOut o; + float2 uv = float2((id << 1) & 2, id & 2); + o.position = float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0); + o.uv = uv * UvScale + UvOffset; + return o; +} +float3 SrgbToLinear(float3 c) +{ + return float3( + c.r <= 0.04045 ? c.r / 12.92 : pow((c.r + 0.055) / 1.055, 2.4), + c.g <= 0.04045 ? c.g / 12.92 : pow((c.g + 0.055) / 1.055, 2.4), + c.b <= 0.04045 ? c.b / 12.92 : pow((c.b + 0.055) / 1.055, 2.4)); +} +float4 PSMain(VSOut input) : SV_Target +{ + float4 src = SourceTexture.Sample(SourceSampler, input.uv); + float3 linearColor = SourceIsScRgb > 0.5 + ? max(src.rgb, 0.0) / max(SdrWhiteScale, 0.001) + : SrgbToLinear(saturate(src.rgb)); + return float4(saturate(linearColor), 1.0); +} +)HLSL"; + + inline std::uint64_t NextSwapchainGeneration = 0; + + struct Swapchain + { + XrSwapchain handle = XR_NULL_HANDLE; + std::uint64_t generation = 0; + std::uint64_t committedGeneration = 0; + std::uint32_t width = 0; + std::uint32_t height = 0; + std::uint32_t arraySize = 1; + DXGI_FORMAT format = DXGI_FORMAT_UNKNOWN; + std::vector images; + std::vector> rtvs; + bool acquired = false; + bool waited = false; + std::uint32_t acquiredImage = 0; + + void Destroy() + { + for (auto& pair : rtvs) + { + ReleaseCom(pair[0]); + ReleaseCom(pair[1]); + } + rtvs.clear(); + images.clear(); + if (handle != XR_NULL_HANDLE) + { + ::xrDestroySwapchain(handle); + handle = XR_NULL_HANDLE; + } + generation = 0; + committedGeneration = 0; + width = height = 0; + arraySize = 1; + format = DXGI_FORMAT_UNKNOWN; + acquired = false; + waited = false; + acquiredImage = 0; + } + }; + + inline HWND GameWindow = nullptr; + inline HMONITOR TargetMonitor = nullptr; + inline RECT OutputDesktop{}; + inline ID3D11Texture2D* Source = nullptr; + inline ID3D11ShaderResourceView* SourceSrv = nullptr; + inline std::uint32_t SourceWidth = 0; + inline std::uint32_t SourceHeight = 0; + inline DXGI_FORMAT SourceFormat = DXGI_FORMAT_UNKNOWN; + inline bool HaveSource = false; + inline float SdrWhiteScale = 1.f; + inline ULONGLONG LastProductionPublishMs = 0; + inline std::int64_t LastProductionPresentQpc = 0; + + inline ID3D11VertexShader* Vs = nullptr; + inline ID3D11PixelShader* Ps = nullptr; + inline ID3D11SamplerState* Sampler = nullptr; + inline ID3D11Buffer* ConstantBuffer = nullptr; + inline Swapchain Projection{}; + inline Swapchain Theater{}; + inline XrSpace ViewSpace = XR_NULL_HANDLE; + + inline OutRunVR::SharedRenderFrameState LastStereoFrame{}; + inline bool LastStereoFrameValid = false; + inline ULONGLONG LastStereoFrameMs = 0; + + inline std::uint64_t EndFrames = 0; + inline std::uint64_t CaptureFresh = 0; + inline std::uint64_t CaptureTimeout = 0; + inline std::uint64_t CaptureFailure = 0; + inline std::uint64_t ProductionPublishes = 0; + inline std::uint64_t SourceQpcRejects = 0; + inline std::uint64_t ProjectionAttempts = 0; + inline std::uint64_t ProjectionSuccess = 0; + inline std::uint64_t TheaterAttempts = 0; + inline std::uint64_t TheaterSuccess = 0; + inline std::uint64_t MainProjectionSeen = 0; + inline std::uint64_t MainQuadSeen = 0; + inline ULONGLONG LastSummaryMs = 0; + inline bool CaptureInfoLogged = false; + inline bool ProjectionLogged = false; + inline bool TheaterLogged = false; + inline bool FirstQpcRejectLogged = false; + + inline void ResetCapture() + { + ReleaseCom(SourceSrv); + ReleaseCom(Source); + TargetMonitor = nullptr; + OutputDesktop = {}; + SourceWidth = SourceHeight = 0; + SourceFormat = DXGI_FORMAT_UNKNOWN; + HaveSource = false; + LastProductionPublishMs = 0; + LastProductionPresentQpc = 0; + CaptureInfoLogged = false; + } + + inline void ResetAll() + { + Projection.Destroy(); + Theater.Destroy(); + if (ViewSpace != XR_NULL_HANDLE) + { + ::xrDestroySpace(ViewSpace); + ViewSpace = XR_NULL_HANDLE; + } + ResetCapture(); + ReleaseCom(ConstantBuffer); + ReleaseCom(Sampler); + ReleaseCom(Ps); + ReleaseCom(Vs); + LastStereoFrame = {}; + LastStereoFrameValid = false; + LastStereoFrameMs = 0; + } + + inline bool EnsureSource(const D3D11_TEXTURE2D_DESC& desc) + { + if (!OutRunVrFinalTest::Device) + return false; + if (Source && SourceSrv && SourceWidth == desc.Width && + SourceHeight == desc.Height && SourceFormat == desc.Format) + return true; + + ReleaseCom(SourceSrv); + ReleaseCom(Source); + D3D11_TEXTURE2D_DESC d{}; + d.Width = desc.Width; + d.Height = desc.Height; + d.MipLevels = 1; + d.ArraySize = 1; + d.Format = desc.Format; + d.SampleDesc.Count = 1; + d.Usage = D3D11_USAGE_DEFAULT; + d.BindFlags = D3D11_BIND_SHADER_RESOURCE; + if (FAILED(OutRunVrFinalTest::Device->CreateTexture2D(&d, nullptr, &Source)) || !Source) + return false; + + D3D11_SHADER_RESOURCE_VIEW_DESC vd{}; + vd.Format = d.Format; + vd.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D; + vd.Texture2D.MipLevels = 1; + if (FAILED(OutRunVrFinalTest::Device->CreateShaderResourceView(Source, &vd, &SourceSrv)) || !SourceSrv) + { + ReleaseCom(Source); + return false; + } + SourceWidth = d.Width; + SourceHeight = d.Height; + SourceFormat = d.Format; + CaptureInfoLogged = false; + return true; + } + + // Called from the production StereoCompositor immediately after its sole + // IDXGIOutputDuplication frame has been copied into production source_. + // No duplication COM object crosses this boundary. + inline bool PublishProductionCapture(ID3D11Texture2D* productionSource, + const RECT& outputDesktop, HMONITOR monitor, float sdrWhiteScale, + std::int64_t lastPresentQpc) noexcept + { + if (!productionSource || !OutRunVrFinalTest::Device || !OutRunVrFinalTest::Context) + return false; + + D3D11_TEXTURE2D_DESC desc{}; + productionSource->GetDesc(&desc); + if (desc.SampleDesc.Count != 1 || + (desc.Format != DXGI_FORMAT_B8G8R8A8_UNORM && + desc.Format != DXGI_FORMAT_R16G16B16A16_FLOAT)) + { + ++CaptureFailure; + return false; + } + if (!EnsureSource(desc)) + { + ++CaptureFailure; + return false; + } + + OutRunVrFinalTest::Context->CopyResource(Source, productionSource); + TargetMonitor = monitor; + OutputDesktop = outputDesktop; + SdrWhiteScale = std::clamp(sdrWhiteScale, 0.25f, 8.f); + LastProductionPresentQpc = lastPresentQpc; + LastProductionPublishMs = GetTickCount64(); + HaveSource = true; + ++ProductionPublishes; + ++CaptureFresh; + + if (!CaptureInfoLogged) + { + CaptureInfoLogged = true; + std::cerr << "[R19] production capture reuse ACTIVE source=" + << SourceWidth << "x" << SourceHeight + << " fmt=" << static_cast(SourceFormat) + << " qpc=" << LastProductionPresentQpc + << " duplicateOwner=StereoCompositor-only\n"; + } + return true; + } + + // Compatibility name retained for the R10/R13 call graph. R19 never calls + // DuplicateOutput*, AcquireNextFrame or ReleaseFrame here; it only consumes + // the latest host-owned texture published by StereoCompositor::Capture(). + inline bool CaptureDesktop(DWORD /*timeoutMs*/) + { + if (!HaveSource || !Source || !SourceSrv || !LastProductionPublishMs) + return false; + if (GetTickCount64() - LastProductionPublishMs > PublishedSourceMaxAgeMs) + { + ++CaptureTimeout; + return false; + } + return true; + } + + inline bool CreateShaders() + { + if (Vs && Ps && Sampler && ConstantBuffer) + return true; + if (!OutRunVrFinalTest::Device) + return false; + + ID3DBlob* vsCode = nullptr; + ID3DBlob* psCode = nullptr; + ID3DBlob* errors = nullptr; + HRESULT hr = D3DCompile(BlitShader, std::strlen(BlitShader), "OutRunR19Blit", + nullptr, nullptr, "VSMain", "vs_5_0", + D3DCOMPILE_ENABLE_STRICTNESS | D3DCOMPILE_OPTIMIZATION_LEVEL3, + 0, &vsCode, &errors); + ReleaseCom(errors); + if (FAILED(hr) || !vsCode) + return false; + hr = D3DCompile(BlitShader, std::strlen(BlitShader), "OutRunR19Blit", + nullptr, nullptr, "PSMain", "ps_5_0", + D3DCOMPILE_ENABLE_STRICTNESS | D3DCOMPILE_OPTIMIZATION_LEVEL3, + 0, &psCode, &errors); + ReleaseCom(errors); + if (FAILED(hr) || !psCode) + { + ReleaseCom(vsCode); + return false; + } + + hr = OutRunVrFinalTest::Device->CreateVertexShader(vsCode->GetBufferPointer(), + vsCode->GetBufferSize(), nullptr, &Vs); + if (SUCCEEDED(hr)) + hr = OutRunVrFinalTest::Device->CreatePixelShader(psCode->GetBufferPointer(), + psCode->GetBufferSize(), nullptr, &Ps); + ReleaseCom(vsCode); + ReleaseCom(psCode); + if (FAILED(hr) || !Vs || !Ps) + return false; + + D3D11_SAMPLER_DESC sd{}; + sd.Filter = D3D11_FILTER_MIN_MAG_MIP_LINEAR; + sd.AddressU = sd.AddressV = sd.AddressW = D3D11_TEXTURE_ADDRESS_CLAMP; + if (FAILED(OutRunVrFinalTest::Device->CreateSamplerState(&sd, &Sampler))) + return false; + + D3D11_BUFFER_DESC bd{}; + bd.ByteWidth = sizeof(BlitParams); + bd.Usage = D3D11_USAGE_DYNAMIC; + bd.BindFlags = D3D11_BIND_CONSTANT_BUFFER; + bd.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE; + return SUCCEEDED(OutRunVrFinalTest::Device->CreateBuffer(&bd, nullptr, &ConstantBuffer)) && + ConstantBuffer; + } + + inline bool GetGameUv(UvRect& uv) + { + if (!SourceWidth || !SourceHeight) + return false; + if (!GameWindow || !IsWindow(GameWindow)) + GameWindow = FindGameWindow(); + if (!GameWindow) + return false; + + RECT client{}; + if (!GetClientRect(GameWindow, &client)) + return false; + POINT tl{ client.left, client.top }; + POINT br{ client.right, client.bottom }; + if (!ClientToScreen(GameWindow, &tl) || !ClientToScreen(GameWindow, &br)) + return false; + + const long outputW = OutputDesktop.right - OutputDesktop.left; + const long outputH = OutputDesktop.bottom - OutputDesktop.top; + const long clientW = br.x - tl.x; + const long clientH = br.y - tl.y; + if (outputW > 0 && outputH > 0 && + clientW >= static_cast(outputW * 0.90) && + clientH >= static_cast(outputH * 0.90)) + { + uv = { 0.f, 0.f, 1.f, 1.f }; + return true; + } + + const float l = static_cast(tl.x - OutputDesktop.left) / static_cast(SourceWidth); + const float t = static_cast(tl.y - OutputDesktop.top) / static_cast(SourceHeight); + const float r = static_cast(br.x - OutputDesktop.left) / static_cast(SourceWidth); + const float b = static_cast(br.y - OutputDesktop.top) / static_cast(SourceHeight); + uv.x = std::clamp(l, 0.f, 1.f); + uv.y = std::clamp(t, 0.f, 1.f); + const float rr = std::clamp(r, 0.f, 1.f); + const float bb = std::clamp(b, 0.f, 1.f); + uv.w = rr - uv.x; + uv.h = bb - uv.y; + return uv.w > 0.01f && uv.h > 0.01f; + } + + inline DXGI_FORMAT ChooseSwapchainFormat(XrSession session) + { + std::uint32_t count = 0; + if (XR_FAILED(::xrEnumerateSwapchainFormats(session, 0, &count, nullptr)) || !count) + return DXGI_FORMAT_UNKNOWN; + std::vector formats(count); + if (XR_FAILED(::xrEnumerateSwapchainFormats(session, count, &count, formats.data()))) + return DXGI_FORMAT_UNKNOWN; + const DXGI_FORMAT preferred[]{ + DXGI_FORMAT_R8G8B8A8_UNORM_SRGB, + DXGI_FORMAT_B8G8R8A8_UNORM_SRGB, + DXGI_FORMAT_R8G8B8A8_UNORM, + DXGI_FORMAT_B8G8R8A8_UNORM + }; + for (const auto format : preferred) + if (std::find(formats.begin(), formats.end(), static_cast(format)) != formats.end()) + return format; + return DXGI_FORMAT_UNKNOWN; + } + + inline bool EnsureSwapchain(Swapchain& swapchain, XrSession session, + std::uint32_t width, std::uint32_t height, std::uint32_t arraySize) + { + if (swapchain.handle != XR_NULL_HANDLE && swapchain.width == width && + swapchain.height == height && swapchain.arraySize == arraySize && + !swapchain.images.empty()) + return true; + + swapchain.Destroy(); + const DXGI_FORMAT format = ChooseSwapchainFormat(session); + if (format == DXGI_FORMAT_UNKNOWN || !width || !height || + (arraySize != 1 && arraySize != 2)) + return false; + + XrSwapchainCreateInfo create{ XR_TYPE_SWAPCHAIN_CREATE_INFO }; + create.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT | + XR_SWAPCHAIN_USAGE_SAMPLED_BIT; + create.format = static_cast(format); + create.sampleCount = 1; + create.width = width; + create.height = height; + create.faceCount = 1; + create.arraySize = arraySize; + create.mipCount = 1; + if (XR_FAILED(::xrCreateSwapchain(session, &create, &swapchain.handle))) + return false; + + std::uint32_t imageCount = 0; + if (XR_FAILED(::xrEnumerateSwapchainImages(swapchain.handle, 0, &imageCount, nullptr)) || + !imageCount) + { + swapchain.Destroy(); + return false; + } + swapchain.images.resize(imageCount); + for (auto& image : swapchain.images) + image = { XR_TYPE_SWAPCHAIN_IMAGE_D3D11_KHR }; + if (XR_FAILED(::xrEnumerateSwapchainImages(swapchain.handle, imageCount, &imageCount, + reinterpret_cast(swapchain.images.data())))) + { + swapchain.Destroy(); + return false; + } + + swapchain.rtvs.resize(imageCount); + for (std::uint32_t i = 0; i < imageCount; ++i) + { + for (std::uint32_t slice = 0; slice < arraySize; ++slice) + { + D3D11_RENDER_TARGET_VIEW_DESC rd{}; + rd.Format = format; + if (arraySize == 2) + { + rd.ViewDimension = D3D11_RTV_DIMENSION_TEXTURE2DARRAY; + rd.Texture2DArray.MipSlice = 0; + rd.Texture2DArray.FirstArraySlice = slice; + rd.Texture2DArray.ArraySize = 1; + } + else + { + rd.ViewDimension = D3D11_RTV_DIMENSION_TEXTURE2D; + rd.Texture2D.MipSlice = 0; + } + if (FAILED(OutRunVrFinalTest::Device->CreateRenderTargetView( + swapchain.images[i].texture, &rd, &swapchain.rtvs[i][slice]))) + { + swapchain.Destroy(); + return false; + } + } + } + + std::uint64_t generation = ++NextSwapchainGeneration; + if (generation == 0) + generation = ++NextSwapchainGeneration; + swapchain.generation = generation; + swapchain.committedGeneration = 0; + swapchain.width = width; + swapchain.height = height; + swapchain.arraySize = arraySize; + swapchain.format = format; + return true; + } + + inline bool RenderTo(ID3D11RenderTargetView* rtv, std::uint32_t width, + std::uint32_t height, const UvRect& uv) + { + if (!rtv || !SourceSrv || !ConstantBuffer || !CreateShaders() || + !OutRunVrFinalTest::Context) + return false; + + D3D11_MAPPED_SUBRESOURCE mapped{}; + if (FAILED(OutRunVrFinalTest::Context->Map(ConstantBuffer, 0, + D3D11_MAP_WRITE_DISCARD, 0, &mapped))) + return false; + auto* params = static_cast(mapped.pData); + params->uvScale[0] = uv.w; + params->uvScale[1] = uv.h; + params->uvOffset[0] = uv.x; + params->uvOffset[1] = uv.y; + params->sdrWhiteScale = SdrWhiteScale; + params->sourceIsScRgb = SourceFormat == DXGI_FORMAT_R16G16B16A16_FLOAT ? 1.f : 0.f; + params->padding[0] = params->padding[1] = 0.f; + OutRunVrFinalTest::Context->Unmap(ConstantBuffer, 0); + + D3D11_VIEWPORT viewport{}; + viewport.Width = static_cast(width); + viewport.Height = static_cast(height); + viewport.MaxDepth = 1.f; + OutRunVrFinalTest::Context->RSSetViewports(1, &viewport); + OutRunVrFinalTest::Context->OMSetRenderTargets(1, &rtv, nullptr); + OutRunVrFinalTest::Context->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST); + OutRunVrFinalTest::Context->VSSetShader(Vs, nullptr, 0); + OutRunVrFinalTest::Context->PSSetShader(Ps, nullptr, 0); + OutRunVrFinalTest::Context->PSSetShaderResources(0, 1, &SourceSrv); + OutRunVrFinalTest::Context->PSSetSamplers(0, 1, &Sampler); + OutRunVrFinalTest::Context->PSSetConstantBuffers(0, 1, &ConstantBuffer); + OutRunVrFinalTest::Context->Draw(3, 0); + ID3D11ShaderResourceView* nullSrv = nullptr; + OutRunVrFinalTest::Context->PSSetShaderResources(0, 1, &nullSrv); + ID3D11RenderTargetView* nullRtv = nullptr; + OutRunVrFinalTest::Context->OMSetRenderTargets(1, &nullRtv, nullptr); + return true; + } + + inline bool Acquire(Swapchain& swapchain, std::uint32_t& image) + { + if (!swapchain.acquired) + { + XrSwapchainImageAcquireInfo acquire{ XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO }; + if (XR_FAILED(::xrAcquireSwapchainImage(swapchain.handle, &acquire, + &swapchain.acquiredImage))) + return false; + swapchain.acquired = true; + swapchain.waited = false; + } + image = swapchain.acquiredImage; + if (swapchain.waited) + return true; + + XrSwapchainImageWaitInfo wait{ XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO }; + wait.timeout = XR_INFINITE_DURATION; + for (;;) + { + const XrResult result = ::xrWaitSwapchainImage(swapchain.handle, &wait); + if (result == XR_TIMEOUT_EXPIRED) + continue; // The same acquired image must be waited again; it cannot be released yet. + if (XR_FAILED(result)) + { + // Preserve acquiredImage. A later attempt must wait this oldest + // acquired image again instead of violating acquire/wait order. + return false; + } + swapchain.waited = true; + return true; + } + } + + inline bool Release(Swapchain& swapchain) + { + if (!swapchain.acquired || !swapchain.waited) + return false; + XrSwapchainImageReleaseInfo release{ XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO }; + const XrResult result = ::xrReleaseSwapchainImage(swapchain.handle, &release); + if (XR_SUCCEEDED(result)) + { + swapchain.acquired = false; + swapchain.waited = false; + swapchain.acquiredImage = 0; + // A released image is valid only for the exact swapchain creation + // that produced it. Recreated handles never inherit this evidence. + swapchain.committedGeneration = swapchain.generation; + return true; + } + return false; + } + + inline bool FrameComplete(const OutRunVR::SharedRenderFrameState& frame) + { + constexpr std::uint32_t need = OutRunVR::RenderFrameStereoComplete | + OutRunVR::RenderFrameWorldStereo | OutRunVR::RenderFrameDrawDuplicated | + OutRunVR::RenderFrameEffectivePoseValid; + return frame.state == OutRunVR::StereoSbsActive && frame.frameId && + frame.sourcePoseSequence && + (frame.flags & OutRunVR::RenderFramePresentInFlight) == 0 && + (frame.flags & need) == need; + } + + inline void UpdateStereoFrameCache() + { + OutRunVR::SharedRenderFrameState frame{}; + std::uint32_t publish = 0; + if (OutRunVrFinalTest::ReadLatestFrame(frame, publish) && FrameComplete(frame)) + { + if (!LastStereoFrameValid || frame.frameId != LastStereoFrame.frameId || + frame.presentQpc != LastStereoFrame.presentQpc) + LastStereoFrameMs = GetTickCount64(); + LastStereoFrame = frame; + LastStereoFrameValid = true; + } + } + + inline bool PublishedSourceMatches(const OutRunVR::SharedRenderFrameState& frame) + { + const bool qpcOk = LastProductionPresentQpc > 0 && frame.presentQpc > 0 && + LastProductionPresentQpc >= frame.presentQpc; + const bool ageOk = LastProductionPublishMs != 0 && + GetTickCount64() - LastProductionPublishMs <= PublishedSourceMaxAgeMs; + if (qpcOk && ageOk) + return true; + ++SourceQpcRejects; + if (!FirstQpcRejectLogged) + { + FirstQpcRejectLogged = true; + std::cerr << "[R19] published source rejected: captureQpc=" + << LastProductionPresentQpc << " framePresentQpc=" << frame.presentQpc + << " ageMs=" << (LastProductionPublishMs ? + GetTickCount64() - LastProductionPublishMs : 0) + << " (fallback requires production capture at/after game present)\n"; + } + return false; + } + + inline bool RenderProjectionOverride(XrSession session, + const XrFrameEndInfo* endInfo, XrCompositionLayerProjection& projectionLayer, + std::array& views) + { + ++ProjectionAttempts; + UpdateStereoFrameCache(); + if (!LastStereoFrameValid || GetTickCount64() - LastStereoFrameMs > 500) + return false; + if (!CaptureDesktop(0) || !HaveSource || !SourceSrv || + !PublishedSourceMatches(LastStereoFrame) || !CreateShaders()) + return false; + + const XrCompositionLayerProjection* incoming = nullptr; + if (endInfo && endInfo->layerCount > 0 && endInfo->layers && + endInfo->layers[0] && + endInfo->layers[0]->type == XR_TYPE_COMPOSITION_LAYER_PROJECTION) + { + incoming = reinterpret_cast(endInfo->layers[0]); + ++MainProjectionSeen; + } + + std::uint32_t width = 1600; + std::uint32_t height = 1600; + if (incoming && incoming->viewCount >= 2 && incoming->views) + { + width = std::max(1, incoming->views[0].subImage.imageRect.extent.width); + height = std::max(1, incoming->views[0].subImage.imageRect.extent.height); + } + if (!EnsureSwapchain(Projection, session, width, height, 2)) + return false; + + UvRect whole{}; + if (!GetGameUv(whole)) + return false; + const UvRect eyeUv[2]{ + { whole.x, whole.y, whole.w * 0.5f, whole.h }, + { whole.x + whole.w * 0.5f, whole.y, whole.w * 0.5f, whole.h } + }; + + std::uint32_t image = 0; + if (!Acquire(Projection, image)) + return false; + if (image >= Projection.rtvs.size()) + { + Release(Projection); + return false; + } + bool ok = RenderTo(Projection.rtvs[image][0], Projection.width, + Projection.height, eyeUv[0]); + ok = RenderTo(Projection.rtvs[image][1], Projection.width, + Projection.height, eyeUv[1]) && ok; + const bool released = Release(Projection); + if (!ok || !released) + return false; + + for (int eye = 0; eye < 2; ++eye) + { + views[eye] = { XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW }; + if (incoming && incoming->viewCount >= 2 && incoming->views) + { + views[eye].pose = incoming->views[eye].pose; + views[eye].fov = incoming->views[eye].fov; + } + else + { + views[eye].pose.orientation = { + LastStereoFrame.eye[eye].orientation[0], + LastStereoFrame.eye[eye].orientation[1], + LastStereoFrame.eye[eye].orientation[2], + LastStereoFrame.eye[eye].orientation[3] + }; + views[eye].pose.position = { + LastStereoFrame.eye[eye].position[0], + LastStereoFrame.eye[eye].position[1], + LastStereoFrame.eye[eye].position[2] + }; + views[eye].fov = { + LastStereoFrame.eye[eye].fov.angleLeft, + LastStereoFrame.eye[eye].fov.angleRight, + LastStereoFrame.eye[eye].fov.angleUp, + LastStereoFrame.eye[eye].fov.angleDown + }; + } + views[eye].subImage.swapchain = Projection.handle; + views[eye].subImage.imageRect.offset = { 0, 0 }; + views[eye].subImage.imageRect.extent = { + static_cast(Projection.width), + static_cast(Projection.height) + }; + views[eye].subImage.imageArrayIndex = eye; + } + + projectionLayer = { XR_TYPE_COMPOSITION_LAYER_PROJECTION }; + projectionLayer.space = incoming && incoming->space != XR_NULL_HANDLE + ? incoming->space : OutRunVrFinalTest::LocalSpace; + projectionLayer.viewCount = 2; + projectionLayer.views = views.data(); + ++ProjectionSuccess; + if (!ProjectionLogged) + { + ProjectionLogged = true; + std::cerr << "[R19] GAME projection fallback ACTIVE productionSource=" + << SourceWidth << "x" << SourceHeight + << " eyeSwapchain=" << Projection.width << "x" << Projection.height + << " captureQpc=" << LastProductionPresentQpc + << " framePresentQpc=" << LastStereoFrame.presentQpc << "\n"; + } + return true; + } + + inline bool EnsureViewSpace(XrSession session) + { + if (ViewSpace != XR_NULL_HANDLE) + return true; + XrReferenceSpaceCreateInfo info{ XR_TYPE_REFERENCE_SPACE_CREATE_INFO }; + info.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_VIEW; + info.poseInReferenceSpace.orientation.w = 1.f; + return XR_SUCCEEDED(OutRunVrFinalTest::CreateReferenceSpace(session, &info, &ViewSpace)); + } + + inline bool RenderTheaterOverride(XrSession session, XrCompositionLayerQuad& quad) + { + ++TheaterAttempts; + if (!CaptureDesktop(0) || !HaveSource || !SourceSrv || + !CreateShaders() || !EnsureViewSpace(session)) + return false; + + UvRect whole{}; + if (!GetGameUv(whole)) + return false; + + // This override is reached from an SBS capture path. A theater quad is + // visible to both eyes, so showing the complete game UV would expose + // left+right side-by-side as a flat panel in the HMD. Recovery theater + // is intentionally mono: show only the left-eye half until projection + // stereo is valid again. + UvRect theaterUv = whole; + theaterUv.w *= 0.5f; + if (theaterUv.w <= 0.0f) + return false; + + if (!EnsureSwapchain(Theater, session, 1920, 1080, 1)) + return false; + + std::uint32_t image = 0; + if (!Acquire(Theater, image)) + return false; + if (image >= Theater.rtvs.size()) + { + Release(Theater); + return false; + } + const bool ok = RenderTo(Theater.rtvs[image][0], Theater.width, + Theater.height, theaterUv); + const bool released = Release(Theater); + if (!ok || !released) + return false; + + quad = { XR_TYPE_COMPOSITION_LAYER_QUAD }; + quad.space = ViewSpace; + quad.eyeVisibility = XR_EYE_VISIBILITY_BOTH; + quad.pose.orientation.w = 1.f; + quad.pose.position.z = -1.5f; + quad.subImage.swapchain = Theater.handle; + quad.subImage.imageRect.offset = { 0, 0 }; + quad.subImage.imageRect.extent = { + static_cast(Theater.width), + static_cast(Theater.height) + }; + quad.subImage.imageArrayIndex = 0; + const float aspect = theaterUv.h > 0.f + ? (theaterUv.w * SourceWidth) / + (theaterUv.h * SourceHeight) + : (16.f / 9.f); + quad.size.width = 2.f; + quad.size.height = 2.f / std::max(0.5f, aspect); + ++TheaterSuccess; + if (!TheaterLogged) + { + TheaterLogged = true; + std::cerr << "[R19] MENU theater fallback ACTIVE left-eye-only source=" + << SourceWidth << "x" << SourceHeight << "\n"; + } + return true; + } + + inline void MaybeLogSummary() + { + const ULONGLONG now = GetTickCount64(); + if (now - LastSummaryMs < 5000) + return; + LastSummaryMs = now; + std::cerr << "[R19] summary build=" << BuildId + << " end=" << EndFrames + << " capture[published=" << ProductionPublishes + << ",fresh=" << CaptureFresh << ",stale=" << CaptureTimeout + << ",fail=" << CaptureFailure << ",qpcReject=" << SourceQpcRejects << "]" + << " projection[try=" << ProjectionAttempts << ",ok=" << ProjectionSuccess + << ",mainSeen=" << MainProjectionSeen << "]" + << " theater[try=" << TheaterAttempts << ",ok=" << TheaterSuccess + << ",mainSeen=" << MainQuadSeen << "]" + << " source=" << SourceWidth << "x" << SourceHeight + << " fmt=" << static_cast(SourceFormat) + << " captureQpc=" << LastProductionPresentQpc + << " frame=" << (LastStereoFrameValid ? LastStereoFrame.frameId : 0) + << "\n"; + } + + inline XrResult XRAPI_CALL EndFrame(XrSession session, const XrFrameEndInfo* endInfo) + { + ++EndFrames; + if (!Enabled() || !endInfo || !OutRunVrFinalTest::Device || + !OutRunVrFinalTest::Context) + return OutRunVrFinalTest::EndFrame(session, endInfo); + + UpdateStereoFrameCache(); + const bool gameplay = LastStereoFrameValid && + GetTickCount64() - LastStereoFrameMs <= 500; + + XrFrameEndInfo patched = *endInfo; + const XrCompositionLayerBaseHeader* layerPtr = nullptr; + XrCompositionLayerProjection projection{}; + std::array views{}; + XrCompositionLayerQuad quad{}; + bool replaced = false; + + if (gameplay) + { + if (RenderProjectionOverride(session, endInfo, projection, views)) + { + layerPtr = reinterpret_cast(&projection); + patched.layerCount = 1; + patched.layers = &layerPtr; + replaced = true; + } + } + else + { + if (endInfo->layerCount > 0 && endInfo->layers && endInfo->layers[0] && + endInfo->layers[0]->type == XR_TYPE_COMPOSITION_LAYER_QUAD) + ++MainQuadSeen; + if (RenderTheaterOverride(session, quad)) + { + layerPtr = reinterpret_cast(&quad); + patched.layerCount = 1; + patched.layers = &layerPtr; + replaced = true; + } + } + + MaybeLogSummary(); + return OutRunVrFinalTest::EndFrame(session, replaced ? &patched : endInfo); + } + + inline XrResult XRAPI_CALL DestroySession(XrSession session) + { + ResetAll(); + return OutRunVrFinalTest::DestroySession(session); + } +} + +#define xrEndFrame OutRunVrSbsCaptureOverride::EndFrame +#define xrDestroySession OutRunVrSbsCaptureOverride::DestroySession + +// The production StereoCompositor is defined later in main.cpp. That source has +// exactly one IDXGIOutputDuplication::ReleaseFrame() call. Because this header is +// MSVC forced-included before main.cpp, instrument that one call to publish the +// already-copied production source_ into the R19 fallback snapshot. The macro's +// self-token is not recursively expanded during its own replacement. +// +// IMPORTANT: this does not create, acquire, release or retain a second +// IDXGIOutputDuplication object; it only performs a D3D11 CopyResource from the +// production-owned source_ after the production duplication frame is released. +#define ReleaseFrame() ReleaseFrame(); \ + OutRunVrSbsCaptureOverride::PublishProductionCapture( \ + copied ? source_ : nullptr, outputDesktop_, targetMonitor_, sdrWhiteScale_, \ + fi.LastPresentTime.QuadPart) diff --git a/vrhost/src/runtime/vr_runtime.hpp b/vrhost/src/runtime/vr_runtime.hpp new file mode 100644 index 000000000..b26080f5b --- /dev/null +++ b/vrhost/src/runtime/vr_runtime.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include "vr/core/frame_types.hpp" +#include "vr/core/transport.hpp" + +#include + +namespace OutRunVR::Host +{ + struct RuntimeFrame + { + Core::RenderPose pose{}; + bool sessionRunning{}; + bool visible{}; + bool focused{}; + }; + + class IVrRuntime + { + public: + virtual ~IVrRuntime() = default; + virtual Core::AdapterId requiredAdapter() const noexcept = 0; + virtual bool beginFrame(RuntimeFrame& outFrame) noexcept = 0; + virtual bool submitProjection(const Core::PresentedFrame& frame) noexcept = 0; + virtual bool submitTheater(const Core::PresentedFrame& frame) noexcept = 0; + virtual void endFrame() noexcept = 0; + virtual void invalidateSessionResources() noexcept = 0; + }; +} diff --git a/vrhost/src/stereo_shader.hpp b/vrhost/src/stereo_shader.hpp new file mode 100644 index 000000000..576567dde --- /dev/null +++ b/vrhost/src/stereo_shader.hpp @@ -0,0 +1,70 @@ +#pragma once + +// R21: BlitParams is intentionally consumed by PSMain. The host RenderTo() +// path already binds b0 to the pixel shader, while R20 never bound it to VS. +// Keeping the fullscreen-triangle VS free of constant-buffer dependencies makes +// the core compositor deterministic and removes the all-black single-sample UV +// failure mode without relying on inherited VS bindings. +inline constexpr const char* OutRunStereoBlitShader = R"HLSL( +Texture2D SourceTexture : register(t0); +SamplerState SourceSampler : register(s0); + +cbuffer BlitParams : register(b0) +{ + float2 UvScale; + float2 UvOffset; + float SdrWhiteScale; + float SourceIsScRgb; + float2 Padding; +}; + +cbuffer MenuPlaneParams : register(b1) +{ + float4 MenuClip[4]; +}; + +struct VSOut +{ + float4 position : SV_Position; + float2 uv : TEXCOORD0; +}; + +VSOut VSMain(uint id : SV_VertexID) +{ + VSOut o; + float2 uv = float2((id << 1) & 2, id & 2); + o.position = float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0); + o.uv = uv; + return o; +} + +VSOut VSMenu(uint id : SV_VertexID) +{ + VSOut o; + const uint corner = min(id, 3u); + o.position = MenuClip[corner]; + o.uv = float2((corner & 1u) ? 1.0 : 0.0, + (corner & 2u) ? 1.0 : 0.0); + return o; +} + +float3 SrgbToLinear(float3 c) +{ + return float3( + c.r <= 0.04045 ? c.r / 12.92 : pow((c.r + 0.055) / 1.055, 2.4), + c.g <= 0.04045 ? c.g / 12.92 : pow((c.g + 0.055) / 1.055, 2.4), + c.b <= 0.04045 ? c.b / 12.92 : pow((c.b + 0.055) / 1.055, 2.4)); +} + +float4 PSMain(VSOut input) : SV_Target +{ + const float2 sampleUv = input.uv * UvScale + UvOffset; + float4 src = SourceTexture.Sample(SourceSampler, sampleUv); + float3 linearColor; + if (SourceIsScRgb > 0.5) + linearColor = max(src.rgb, 0.0) / max(SdrWhiteScale, 0.001); + else + linearColor = SrgbToLinear(saturate(src.rgb)); + return float4(saturate(linearColor), 1.0); +} +)HLSL"; diff --git a/vrhost/tests/core_math_smoke.cpp b/vrhost/tests/core_math_smoke.cpp new file mode 100644 index 000000000..c4a779cf4 --- /dev/null +++ b/vrhost/tests/core_math_smoke.cpp @@ -0,0 +1,62 @@ +#include "vr/core/matrix.hpp" + +#include + +namespace +{ + bool Near(float a, float b, float epsilon = 1.0e-5f) + { + return std::fabs(a - b) <= epsilon; + } +} + +int main() +{ + using namespace OutRunVR::Core; + + Matrix4 base{}; + base[0][0] = 1.3f; + base[1][1] = 1.7f; + base[2][2] = -1.001f; + base[2][3] = -1.0f; + base[3][2] = -0.1f; + + Fov symmetric{-0.7f, 0.7f, 0.6f, -0.6f}; + Matrix4 projection{}; + if (!ProjectionFromOpenXrFov(base, symmetric, projection)) + return 1; + if (!Near(projection[2][0], 0.0f) || !Near(projection[2][1], 0.0f)) + return 2; + if (!Near(projection[2][2], base[2][2]) || + !Near(projection[2][3], base[2][3]) || + !Near(projection[3][2], base[3][2])) + return 3; + + Quaternion q{}; + Vector3 p{1.0f, 2.0f, -3.0f}; + const Matrix4 rigid = RigidTransform(q, p, 2.0f); + if (!Near(rigid[3][0], 2.0f) || !Near(rigid[3][1], 4.0f) || !Near(rigid[3][2], -6.0f)) + return 4; + + const Matrix4 inverseRigid = InverseRigid(rigid); + const Matrix4 identity = Multiply(rigid, inverseRigid); + for (int r = 0; r < 4; ++r) + for (int c = 0; c < 4; ++c) + if (!Near(identity[r][c], r == c ? 1.0f : 0.0f)) + return 5; + + Matrix4 inverse{}; + if (!Invert(rigid, inverse)) + return 6; + const Matrix4 identity2 = Multiply(rigid, inverse); + for (int r = 0; r < 4; ++r) + for (int c = 0; c < 4; ++c) + if (!Near(identity2[r][c], r == c ? 1.0f : 0.0f)) + return 7; + + Fov invalid{}; + if (ProjectionFromOpenXrFov(base, invalid, projection)) + return 8; + + return 0; +} diff --git a/vrhost/tests/host_pose_v3_validation_smoke.cpp b/vrhost/tests/host_pose_v3_validation_smoke.cpp new file mode 100644 index 000000000..91430eaab --- /dev/null +++ b/vrhost/tests/host_pose_v3_validation_smoke.cpp @@ -0,0 +1,81 @@ +#include +#include +#include + +#include "vr/ipc/host_pose_v3.hpp" + +using namespace OutRunVR::IpcV3; + +namespace +{ + HostState MakeValid() + { + HostState state{}; + InitializeWireState(state); + state.hostPid = 42; + state.flags = HostAlive | OrientationValid | PositionValid | + SessionVisible | SessionFocused | StereoViewsValid | HostShouldRender; + state.poseId = 1234; + state.sampleQpc = 9'950'000; + state.headOrientation[3] = 1.0f; + state.headPositionMeters[0] = 1.0f; + state.headPositionMeters[1] = 2.0f; + state.headPositionMeters[2] = 3.0f; + for (int eye = 0; eye < 2; ++eye) + { + state.eyes[eye].orientation[3] = 1.0f; + state.eyes[eye].positionMeters[0] = eye == 0 ? -0.032f : 0.032f; + state.eyes[eye].fov.angleLeft = -0.8f; + state.eyes[eye].fov.angleRight = 0.8f; + state.eyes[eye].fov.angleUp = 0.9f; + state.eyes[eye].fov.angleDown = -0.9f; + } + return state; + } + + bool Expect(bool condition, const char* name) + { + if (!condition) + std::cerr << "FAILED: " << name << '\n'; + return condition; + } +} + +int main() +{ + constexpr std::int64_t now = 10'000'000; + constexpr std::int64_t frequency = 1'000'000; + bool ok = true; + + auto state = MakeValid(); + ok &= Expect(HostStateUsable(state, now, frequency), "valid pose accepted"); + + state = MakeValid(); + state.sampleQpc = now - frequency; + ok &= Expect(!HostStateUsable(state, now, frequency), "stale pose rejected"); + + state = MakeValid(); + state.headOrientation[0] = std::nanf(""); + ok &= Expect(!HostStateUsable(state, now, frequency), "NaN quaternion rejected"); + + state = MakeValid(); + state.eyes[0].positionMeters[0] = -0.5f; + ok &= Expect(!HostStateUsable(state, now, frequency), "implausible eye offset rejected"); + + state = MakeValid(); + state.eyes[1].fov.angleRight = state.eyes[1].fov.angleLeft + 0.01f; + ok &= Expect(!HostStateUsable(state, now, frequency), "degenerate FOV rejected"); + + state = MakeValid(); + state.flags &= ~HostShouldRender; + ok &= Expect(!HostStateUsable(state, now, frequency), "non-renderable host rejected"); + + state = MakeValid(); + state.flags &= ~StereoViewsValid; + ok &= Expect(HostStateUsable(state, now, frequency), "mono-valid pose remains usable"); + + if (!ok) + return 1; + std::cout << "HostState.v3 pose validation rules passed.\n"; + return 0; +} diff --git a/vrhost/tests/host_state_v3_channel_smoke.cpp b/vrhost/tests/host_state_v3_channel_smoke.cpp new file mode 100644 index 000000000..0c3ba8659 --- /dev/null +++ b/vrhost/tests/host_state_v3_channel_smoke.cpp @@ -0,0 +1,125 @@ +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include + +#include +#include +#include +#include + +#include "ipc/host_state_v3_writer.hpp" +#include "vr/ipc/host_state_v3_reader.hpp" + +namespace +{ + bool Near(float a, float b, float epsilon = 1.0e-6f) + { + return std::fabs(a - b) <= epsilon; + } + + bool Check(bool condition, const char* message) + { + if (!condition) + std::cerr << "host_state_v3_channel_smoke: " << message << "\n"; + return condition; + } +} + +int main() +{ + using namespace OutRunVR; + + constexpr std::uint32_t luidLow = 0x12345678u; + constexpr std::int32_t luidHigh = -17; + + Host::HostStateV3Writer writer(luidLow, luidHigh); + IpcV3::HostStateReader reader; + + IpcV3::HostState first{}; + IpcV3::InitializeWireState(first); + first.flags = IpcV3::HostAlive | + IpcV3::OrientationValid | + IpcV3::PositionValid | + IpcV3::SessionVisible | + IpcV3::SessionFocused | + IpcV3::StereoViewsValid | + IpcV3::HostShouldRender | + IpcV3::DirectGpuTransportSupported | + IpcV3::AdapterLuidValid; + first.directTransportGeneration = 7; + first.poseId = 0x1122334455667788ull; + first.predictedDisplayTime = 123456789; + first.sampleQpc = 987654321; + first.headOrientation[0] = 0.1f; + first.headOrientation[1] = -0.2f; + first.headOrientation[2] = 0.3f; + first.headOrientation[3] = 0.9f; + first.headPositionMeters[0] = 1.0f; + first.headPositionMeters[1] = 2.0f; + first.headPositionMeters[2] = -3.0f; + first.recommendedWidth[0] = 2016; + first.recommendedWidth[1] = 2016; + first.recommendedHeight[0] = 2208; + first.recommendedHeight[1] = 2208; + + for (int eye = 0; eye < 2; ++eye) + { + first.eyes[eye].orientation[3] = 1.0f; + first.eyes[eye].positionMeters[0] = eye == 0 ? -0.032f : 0.032f; + first.eyes[eye].fov.angleLeft = -0.8f; + first.eyes[eye].fov.angleRight = 0.8f; + first.eyes[eye].fov.angleUp = 0.9f; + first.eyes[eye].fov.angleDown = -0.9f; + } + std::strncpy(first.runtimeName, "v3-channel-smoke", sizeof(first.runtimeName) - 1); + + const std::uint32_t sequence1 = writer.Publish(first); + if (!Check(sequence1 != 0 && (sequence1 & 1u) == 0, "first publish did not finish on an even sequence")) + return 1; + + IpcV3::HostState observed{}; + if (!Check(reader.Read(observed), "reader could not open/read HostState.v3")) + return 2; + if (!Check((observed.sequence & 1u) == 0, "reader observed an odd sequence")) + return 3; + if (!Check(observed.hostPid == GetCurrentProcessId(), "host pid mismatch")) + return 4; + if (!Check(observed.poseId == first.poseId, "pose id mismatch")) + return 5; + if (!Check(observed.predictedDisplayTime == first.predictedDisplayTime, "predicted display time mismatch")) + return 6; + if (!Check(observed.sampleQpc == first.sampleQpc, "sample QPC mismatch")) + return 7; + if (!Check(observed.adapterLuidLow == luidLow && observed.adapterLuidHigh == luidHigh, "adapter LUID mismatch")) + return 8; + if (!Check(observed.referenceSpaceGeneration == writer.ReferenceSpaceGeneration(), "reference generation mismatch")) + return 9; + if (!Check(observed.flags == first.flags, "host flags mismatch")) + return 10; + if (!Check(Near(observed.eyes[0].positionMeters[0], -0.032f) && + Near(observed.eyes[1].positionMeters[0], 0.032f), "eye offsets mismatch")) + return 11; + if (!Check(std::strcmp(observed.runtimeName, "v3-channel-smoke") == 0, "runtime name mismatch")) + return 12; + + const std::uint32_t generation1 = writer.ReferenceSpaceGeneration(); + writer.ReferenceSpaceChanged(); + first.poseId++; + first.sampleQpc++; + const std::uint32_t sequence2 = writer.Publish(first); + if (!Check(sequence2 > sequence1 && (sequence2 & 1u) == 0, "second publish sequence did not advance")) + return 13; + + IpcV3::HostState observed2{}; + if (!Check(reader.Read(observed2), "reader failed after reference-space change")) + return 14; + if (!Check(observed2.poseId == first.poseId, "second pose id mismatch")) + return 15; + if (!Check(observed2.referenceSpaceGeneration != generation1 && + observed2.referenceSpaceGeneration == writer.ReferenceSpaceGeneration(), + "reference-space generation did not advance")) + return 16; + + std::cout << "HostState.v3 named mapping + seqlock round-trip passed.\n"; + return 0; +} diff --git a/vrhost/tests/protocol_v3_smoke.cpp b/vrhost/tests/protocol_v3_smoke.cpp new file mode 100644 index 000000000..9e6564837 --- /dev/null +++ b/vrhost/tests/protocol_v3_smoke.cpp @@ -0,0 +1,24 @@ +#include "vr/ipc/protocol_v3.hpp" + +#include +#include + +int main() +{ + using namespace OutRunVR::IpcV3; + + static_assert(ProtocolVersion == 3); + static_assert(RingSize == 4); + static_assert(sizeof(WireHandle) == 8); + static_assert(sizeof(WireFov) == 16); + static_assert(sizeof(WireEyeView) == 48); + static_assert(sizeof(FrameRing) == 32 + sizeof(FrameDescriptor) * RingSize); + + HostState host{}; + ClientState client{}; + FrameRing frames{}; + AckState ack{}; + + return host.magic == HostMagic && client.magic == ClientMagic && + frames.magic == FrameMagic && ack.magic == AckMagic ? 0 : 1; +} diff --git a/vrhost/tests/r23_verified_bundle_smoke.cpp b/vrhost/tests/r23_verified_bundle_smoke.cpp new file mode 100644 index 000000000..0c53cd512 --- /dev/null +++ b/vrhost/tests/r23_verified_bundle_smoke.cpp @@ -0,0 +1,49 @@ +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include + +#include "runtime/r23_verified_bundle.hpp" + +#include + +int main() +{ + using namespace OutRunVrR23VerifiedBundle; + + Invalidate(); + Snapshot snapshot{}; + assert(Read(snapshot)); + assert(!IsFresh(snapshot)); + + OutRunVR::SharedRenderFrameState frame{}; + frame.magic = OutRunVR::RenderFrameMagic; + frame.protocolVersion = OutRunVR::RenderFrameProtocolVersion; + frame.structSize = sizeof(frame); + frame.state = OutRunVR::StereoSbsActive; + frame.frameId = 42; + frame.sourcePoseSequence = 77; + frame.presentQpc = 123456; + + Publish(frame, SourceKind::DirectGpu, 0); + assert(ReadFresh(snapshot)); + assert(snapshot.kind == SourceKind::DirectGpu); + assert(snapshot.frameId == 42); + assert(snapshot.poseSequence == 77); + assert(snapshot.presentQpc == 123456); + assert(Matches(frame, SourceKind::DirectGpu)); + assert(!Matches(frame, SourceKind::ClassicSbs)); + + // Identity fields inside the copied Frame.v2 must agree with the atomic + // bundle identity. A mixed texture/pose publication must fail freshness. + snapshot.frame.frameId = 43; + assert(!IsFresh(snapshot, snapshot.publishedAtMs)); + + assert(Read(snapshot)); + assert(IsFresh(snapshot, snapshot.publishedAtMs)); + assert(!IsFresh(snapshot, + snapshot.publishedAtMs + MaxPresentationAgeMs + 1)); + + Invalidate(); + assert(!ReadFresh(snapshot)); + return 0; +} diff --git a/vrhost/tests/r32_policy_smoke.cpp b/vrhost/tests/r32_policy_smoke.cpp new file mode 100644 index 000000000..6c60db101 --- /dev/null +++ b/vrhost/tests/r32_policy_smoke.cpp @@ -0,0 +1,41 @@ +#include "vr/d3d9/r32_policy.hpp" + +#include +#include +#include + +int main() +{ + using namespace OutRunVR::R32; + + // Reset after a long run must not retain an old absolute mono-safety epoch. + assert(RearmMonoSafetyEpoch(1) == 3); + assert(RearmMonoSafetyEpoch(42, 2) == 44); + assert(RearmMonoSafetyEpoch(0, 2) == 3); + + const auto maxValue = (std::numeric_limits::max)(); + assert(RearmMonoSafetyEpoch(maxValue - 1, 4) == maxValue); + + assert(EffectSnapshotResult(true) == + EffectSnapshotDecision::UseCapturedPolicy); + assert(EffectSnapshotResult(false) == + EffectSnapshotDecision::ForceZeroDisparity); + + static_assert(ProducerFenceBudgetMs <= 2, + "R32 producer fence must stay below the old 12 ms synchronous budget"); + + // A timed-out D3D9 producer copy cannot make its ring slot reusable until + // the old EVENT query has actually completed. + assert(ClassifyPendingFence(false, false, false, false) == + PendingFenceDecision::ReuseSlot); + assert(ClassifyPendingFence(true, true, true, false) == + PendingFenceDecision::ReuseSlot); + assert(ClassifyPendingFence(true, true, false, true) == + PendingFenceDecision::BlockReuse); + assert(ClassifyPendingFence(true, false, false, false) == + PendingFenceDecision::QueryError); + assert(ClassifyPendingFence(true, true, false, false) == + PendingFenceDecision::QueryError); + + return 0; +} diff --git a/vrhost/tests/runtime_eligibility_smoke.cpp b/vrhost/tests/runtime_eligibility_smoke.cpp new file mode 100644 index 000000000..3d25a7b4b --- /dev/null +++ b/vrhost/tests/runtime_eligibility_smoke.cpp @@ -0,0 +1,84 @@ +#include "vr/runtime_eligibility.hpp" + +#include + +int main() +{ + using namespace OutRunVR::RuntimeEligibility; + + MarkSafetyOverlayUnavailable(); + assert(!SafetyOverlayReady.load()); + assert(!HostFresh.load()); + assert(!StereoAllowed.load()); + assert(RecoveryPending.load()); + assert(!MayInjectStereo()); + + // Host freshness alone must never reopen stereo after a stall. + ObserveFreshHost(); + assert(HostFresh.load()); + assert(!MayInjectStereo()); + + // A baseline observed before the final safety overlay is installed is not + // authoritative and therefore may not reopen stereo. + BaselineVerified(); + assert(!MayInjectStereo()); + + MarkSafetyOverlayInstalled(); + assert(SafetyOverlayReady.load()); + assert(!HostFresh.load()); + assert(RecoveryPending.load()); + assert(!MayInjectStereo()); + + ObserveFreshHost(); + assert(!MayInjectStereo()); + BaselineVerified(); + assert(MayInjectStereo()); + + // A transient shouldRender=false frame pauses injection but preserves the + // proven baseline. Fresh renderability resumes immediately without a new + // recovery seed. + ObserveSoftHostSuspend(); + assert(HostFresh.load()); + assert(!HostRenderable.load()); + assert(StereoAllowed.load()); + assert(!RecoveryPending.load()); + assert(!MayInjectStereo()); + ObserveFreshHost(); + assert(HostRenderable.load()); + assert(MayInjectStereo()); + + // Host death immediately closes the gate and recovery again requires both + // a fresh host observation and a new baseline. + FailClosed(); + assert(!MayInjectStereo()); + ObserveFreshHost(); + assert(!MayInjectStereo()); + BaselineVerified(); + assert(MayInjectStereo()); + + // ResetEx/classic-state health is an independent persistent safety gate. + // A later baseline must not reopen stereo until the compatibility owner + // explicitly clears the block. + SetExternalSafetyBlock(true); + assert(ExternalSafetyBlock.load()); + assert(!StereoAllowed.load()); + assert(RecoveryPending.load()); + assert(!MayInjectStereo()); + BaselineVerified(); + assert(!MayInjectStereo()); + + SetExternalSafetyBlock(false); + assert(!ExternalSafetyBlock.load()); + assert(!MayInjectStereo()); + BaselineVerified(); + assert(MayInjectStereo()); + + std::atomic state{ InstallState::Pending }; + assert(!IsReady(state)); + assert(!IsFailed(state)); + state.store(InstallState::Ready); + assert(IsReady(state)); + state.store(InstallState::Failed); + assert(IsFailed(state)); + return 0; +} diff --git a/vrhost/tests/stereo_shader_smoke.cpp b/vrhost/tests/stereo_shader_smoke.cpp new file mode 100644 index 000000000..a6e8ce0d0 --- /dev/null +++ b/vrhost/tests/stereo_shader_smoke.cpp @@ -0,0 +1,34 @@ +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include +#include +#include +#include "stereo_shader.hpp" + +static bool Compile(const char* entry, const char* target) +{ + ID3DBlob* code = nullptr; + ID3DBlob* errors = nullptr; + const HRESULT hr = D3DCompile(OutRunStereoBlitShader, std::strlen(OutRunStereoBlitShader), + "OutRunStereoBlit", nullptr, nullptr, entry, target, + D3DCOMPILE_ENABLE_STRICTNESS | D3DCOMPILE_OPTIMIZATION_LEVEL3, 0, &code, &errors); + if (FAILED(hr)) + { + std::cerr << entry << "/" << target << " failed"; + if (errors && errors->GetBufferPointer()) + std::cerr << ": " << static_cast(errors->GetBufferPointer()); + std::cerr << "\n"; + } + if (errors) errors->Release(); + if (code) code->Release(); + return SUCCEEDED(hr); +} + +int main() +{ + if (!Compile("VSMain", "vs_5_0")) return 1; + if (!Compile("PSMain", "ps_5_0")) return 2; + std::cout << "stereo shader smoke: VSMain/PSMain compiled successfully\n"; + return 0; +} diff --git a/vrhost/tests/v2_v3_shadow_conversion_smoke.cpp b/vrhost/tests/v2_v3_shadow_conversion_smoke.cpp new file mode 100644 index 000000000..783f03776 --- /dev/null +++ b/vrhost/tests/v2_v3_shadow_conversion_smoke.cpp @@ -0,0 +1,198 @@ +#include +#include +#include +#include + +#include "vr/ipc/shadow_legacy_v2.hpp" + +namespace +{ + bool Near(float a, float b, float epsilon = 1.0e-4f) + { + return std::fabs(a - b) <= epsilon; + } + + std::uint32_t FloatBits(float value) + { + std::uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + return bits; + } + + std::uint32_t SignedBits(std::int32_t value) + { + std::uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + return bits; + } + + std::int16_t PackSnorm16(float value) + { + if (value > 1.0f) value = 1.0f; + if (value < -1.0f) value = -1.0f; + return static_cast(std::lround(value * 32767.0f)); + } + + void PackEyeOrientations(OutRunVR::SharedPoseState& pose) + { + const float eyes[2][4]{ + {0.0f, 0.05f, 0.0f, 0.9987492f}, + {0.0f, -0.05f, 0.0f, 0.9987492f} + }; + std::int16_t packed[8]{}; + for (int eye = 0; eye < 2; ++eye) + for (int c = 0; c < 4; ++c) + packed[eye * 4 + c] = PackSnorm16(eyes[eye][c]); + std::memcpy(pose.runtimeName + OutRunVR::PackedEyeOrientationOffset, + packed, sizeof(packed)); + } +} + +int main() +{ + using namespace OutRunVR; + using namespace OutRunVR::IpcV3; + + SharedPoseState pose{}; + pose.magic = SharedMagic; + pose.protocolVersion = SharedProtocolVersion; + pose.structSize = sizeof(pose); + pose.sequence = 42; + pose.hostPid = 1234; + pose.clientPid = 5678; + pose.flags = OutRunVR::HostAlive | OutRunVR::OrientationValid | + OutRunVR::PositionValid | OutRunVR::SessionVisible | + OutRunVR::SessionFocused | OutRunVR::StereoViewsValid | + OutRunVR::StereoEyeOrientationValid | OutRunVR::HostShouldRender | + OutRunVR::HostDirectGpuTransport | OutRunVR::HostDirectGpuReady | + OutRunVR::HostAdapterLuidValid; + pose.sampleQpc = 0x1122334455667788LL; + pose.orientation[0] = 0.1f; + pose.orientation[1] = -0.2f; + pose.orientation[2] = 0.3f; + pose.orientation[3] = 0.92736185f; + pose.position[0] = 0.25f; + pose.position[1] = 1.61f; + pose.position[2] = -0.4f; + pose.eyeFov[0] = {-0.80f, 0.72f, 0.84f, -0.77f}; + pose.eyeFov[1] = {-0.73f, 0.81f, 0.83f, -0.78f}; + pose.recommendedWidth[0] = pose.recommendedWidth[1] = 2016; + pose.recommendedHeight[0] = pose.recommendedHeight[1] = 2208; + pose.hostAdapterLuidLow = 0x89ABCDEFu; + pose.hostAdapterLuidHigh = SignedBits(-17); + pose.clientAdapterLuidLow = pose.hostAdapterLuidLow; + pose.clientAdapterLuidHigh = pose.hostAdapterLuidHigh; + pose.clientInteropProbeHandle = 0x12345678u; + pose.clientInteropProbeToken = 91; + pose.hostInteropProbeAckToken = 91; + pose.hostDirectConsumedFrameId = 77; + std::memcpy(pose.runtimeName, "VirtualDesktopXR test runtime", 29); + pose.reserved[HostReferenceSpaceGenerationIndex] = 9; + pose.reserved[HostEyeOffsetLeftXIndex] = FloatBits(-0.032f); + pose.reserved[HostEyeOffsetLeftYIndex] = FloatBits(0.001f); + pose.reserved[HostEyeOffsetLeftZIndex] = FloatBits(0.002f); + pose.reserved[HostEyeOffsetRightXIndex] = FloatBits(0.032f); + pose.reserved[HostEyeOffsetRightYIndex] = FloatBits(0.001f); + pose.reserved[HostEyeOffsetRightZIndex] = FloatBits(0.002f); + pose.reserved[ClientHeartbeatIndex] = 321; + pose.reserved[ClientFlagsIndex] = ClientHookAlive | ClientHostPoseValid | + ClientRendererWvpVerified | ClientRendererPoseInjected | ClientFrameCompleted | + ClientStereoActive | ClientStereoWorldDraw | ClientStereoDrawDuplicated; + pose.reserved[ClientPresentationModeIndex] = PresentationGameplay; + pose.reserved[ClientGameStateIndex] = 6; + pose.reserved[ClientStereoStateIndex] = StereoSbsActive; + pose.reserved[ClientStereoBackbufferWidthIndex] = 3440; + pose.reserved[ClientStereoBackbufferHeightIndex] = 1440; + PackEyeOrientations(pose); + + const HostState host = ShadowV2::HostStateFromV2(pose, 15); + if (host.poseId != 42 || host.hostPid != 1234 || host.referenceSpaceGeneration != 9 || + host.directTransportGeneration != 15 || host.sampleQpc != pose.sampleQpc || + host.adapterLuidLow != pose.hostAdapterLuidLow || host.adapterLuidHigh != -17 || + host.recommendedWidth[0] != 2016 || host.recommendedHeight[1] != 2208 || + !(host.flags & DirectGpuTransportSupported) || !(host.flags & DirectGpuTransportReady) || + !(host.flags & AdapterLuidValid) || !Near(host.eyes[0].positionMeters[0], -0.032f) || + !Near(host.eyes[1].positionMeters[0], 0.032f) || !Near(host.eyes[0].fov.angleLeft, -0.80f) || + host.eyes[0].orientation[3] < 0.99f || host.eyes[1].orientation[3] < 0.99f) + { + std::cerr << "HostState v2->v3 conversion failed\n"; + return 1; + } + + SharedRenderFrameState frame{}; + frame.magic = RenderFrameMagic; + frame.protocolVersion = RenderFrameProtocolVersion; + frame.structSize = sizeof(frame); + frame.sequence = 10; + frame.clientPid = pose.clientPid; + frame.state = StereoSbsActive; + frame.frameId = 77; + frame.sourcePoseSequence = pose.sequence; + frame.presentationMode = PresentationGameplay; + frame.flags = RenderFrameStereoComplete | RenderFrameWorldStereo | + RenderFrameDrawDuplicated | RenderFrameEffectivePoseValid | + RenderFrameDirectGpuTransport; + frame.failureReason = StereoFailureNone; + frame.backbufferWidth = 3440; + frame.backbufferHeight = 1440; + frame.presentQpc = 0x0102030405060708LL; + frame.reserved[RenderFrameDirectLeftHandleIndex] = 0x11112222u; + frame.reserved[RenderFrameDirectRightHandleIndex] = 0x33334444u; + frame.reserved[RenderFrameDirectWidthIndex] = 2016; + frame.reserved[RenderFrameDirectHeightIndex] = 2208; + frame.reserved[RenderFrameDirectFormatIndex] = 21; + frame.reserved[RenderFrameDirectGenerationIndex] = 15; + frame.reserved[RenderFrameDirectSlotIndex] = 3; + + for (int eye = 0; eye < 2; ++eye) + { + frame.eye[eye].orientation[3] = 1.0f; + frame.eye[eye].position[0] = eye == 0 ? -0.032f : 0.032f; + frame.eye[eye].fov = pose.eyeFov[eye]; + } + + const FrameDescriptor descriptor = ShadowV2::FrameDescriptorFromV2(frame); + if (descriptor.frameId != 77 || descriptor.renderPoseId != 42 || + descriptor.transportKind != static_cast(Core::TransportKind::D3D9ExShared) || + descriptor.transportGeneration != 15 || descriptor.transportSlot != 3 || + descriptor.width != 2016 || descriptor.height != 2208 || descriptor.format != 21 || + descriptor.leftHandle != 0x11112222ULL || descriptor.rightHandle != 0x33334444ULL || + descriptor.presentQpc != frame.presentQpc || descriptor.flags != frame.flags) + { + std::cerr << "FrameDescriptor v2->v3 conversion failed\n"; + return 2; + } + + const ClientState client = ShadowV2::ClientStateFromV2(pose, &frame); + if (client.clientPid != 5678 || client.flags != pose.reserved[ClientFlagsIndex] || + client.presentationMode != PresentationGameplay || client.stereoState != StereoSbsActive || + client.interopProbeHandle != 0x12345678ULL || client.interopProbeToken != 91 || + client.backbufferWidth != 3440 || client.backbufferHeight != 1440 || + client.lastPresentedFrameId != 77 || client.lastRenderedPoseId != 42 || + client.lastFailure != StereoFailureNone) + { + std::cerr << "ClientState v2->v3 conversion failed\n"; + return 3; + } + + const AckState ack = ShadowV2::AckStateFromV2(pose, &frame, 9999); + if (ack.consumerPid != 9999 || ack.acceptedProbeToken != 91 || + ack.consumedFrameId != 77 || ack.transportGeneration != 15 || ack.consumedSlot != 3) + { + std::cerr << "AckState v2->v3 conversion failed\n"; + return 4; + } + + frame.flags &= ~RenderFrameDirectGpuTransport; + const FrameDescriptor fallback = ShadowV2::FrameDescriptorFromV2(frame); + if (fallback.transportKind != static_cast(Core::TransportKind::DesktopDuplication) || + fallback.leftHandle != 0 || fallback.rightHandle != 0 || + fallback.width != 3440 || fallback.height != 1440) + { + std::cerr << "Desktop Duplication fallback conversion failed\n"; + return 5; + } + + std::cout << "v2->v3 host/client/frame/ack conversion smoke passed.\n"; + return 0; +}