diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml
index 9956ec1..964447d 100644
--- a/.github/workflows/ci-build.yml
+++ b/.github/workflows/ci-build.yml
@@ -1,103 +1,126 @@
name: CI Build
-# Autobuild that runs on every push and pull request, plus manual triggers.
-# Complements release.yml (which only runs when a GitHub Release is published).
-# Produces a runnable x64 package as a downloadable build artifact.
-
on:
push:
- branches: [ "**" ]
- tags-ignore: [ "**" ] # tag pushes are handled by release.yml
+ branches: ["**"]
+ tags-ignore: ["**"]
pull_request:
- branches: [ "**" ]
+ branches: ["**"]
workflow_dispatch:
permissions:
contents: read
-jobs:
- test:
- runs-on: windows-2022
- steps:
- - uses: actions/checkout@v4
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
- - name: Setup .NET
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: 8.0.x
+env:
+ DOTNET_NOLOGO: "1"
+ DOTNET_CLI_TELEMETRY_OPTOUT: "1"
- - name: Restore dependencies
- run: dotnet restore
-
- - name: Run tests (x64)
- # NOTE: Three tests are excluded below. They are exact-string XML round-trip
- # "snapshot" tests whose hardcoded expected XML predates several fields the
- # current serializer emits (ProcessPriority, ProfileChangedNotification,
- # UseMoonlight, DebouncingMs, LeftStickDriftXAxis, ...) and whose fixture
- # source files use LF while the serializer writes CRLF. They fail on a clean
- # checkout of upstream, independently of any feature change, so they are not
- # used to gate the build. All other tests run and must pass.
- # Remove the --filter once the upstream snapshot fixtures are regenerated.
- run: dotnet test .\DS4WindowsTests\DS4WindowsTests.csproj -c Release -p:Platform=x64 --verbosity normal --filter "Name!=CheckSettingsSave&Name!=CheckWriteProfile&Name!=CheckJaysProfileRead"
-
- build:
+jobs:
+ verify:
runs-on: windows-2022
- needs: test
- strategy:
- fail-fast: false
- matrix:
- platform: [ x64 ]
+ timeout-minutes: 45
steps:
- - uses: actions/checkout@v4
+ - name: Checkout exact source
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ persist-credentials: false
- - name: Setup .NET
- uses: actions/setup-dotnet@v4
+ - name: Setup .NET 8
+ uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
dotnet-version: 8.0.x
- - name: Set up Python 3.10
- uses: actions/setup-python@v5
+ - name: Setup Python
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
- python-version: "3.10"
-
- - name: Restore dependencies
- run: dotnet restore
+ python-version: "3.12"
- - name: Compute version
- id: ver
- shell: bash
+ - name: Restore managed projects
+ shell: pwsh
run: |
- VERSION="$(date +%Y.%m.%d)-ci-${GITHUB_SHA::7}"
- echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+ dotnet restore .\DS4WindowsTests\DS4WindowsTests.csproj
+ if ($LASTEXITCODE -ne 0) { throw "Test restore failed." }
+ dotnet restore .\installer\DS4Windows.SetupActions\DS4Windows.SetupActions.csproj
+ if ($LASTEXITCODE -ne 0) { throw "SetupActions restore failed." }
+ dotnet restore .\installer\DS4Windows.Bootstrapper\DS4Windows.Bootstrapper.csproj
+ if ($LASTEXITCODE -ne 0) { throw "Bootstrapper restore failed." }
+
+ - name: Validate native package contract (Windows PowerShell 5.1)
+ shell: powershell
+ run: .\extras\Test-ViiperNativePackageContract.ps1
+
+ - name: Validate native package contract (PowerShell 7)
+ shell: pwsh
+ run: .\extras\Test-ViiperNativePackageContract.ps1
+
+ - name: Validate Windows 11 bundle contract (Windows PowerShell 5.1)
+ shell: powershell
+ run: .\extras\validation\Test-ViiperWin11ValidationContract.ps1
+
+ - name: Validate Windows 11 bundle contract (PowerShell 7)
+ shell: pwsh
+ run: .\extras\validation\Test-ViiperWin11ValidationContract.ps1
- - name: Build ${{ matrix.platform }}
- run: dotnet publish .\DS4Windows\DS4WinWPF.csproj -c Release /p:platform=${{ matrix.platform }} -o .\bin\${{ matrix.platform }}\Release\output
+ - name: Validate installer security contracts (Windows PowerShell 5.1)
+ shell: powershell
+ run: .\installer\Test-InstallerSecurityContracts.ps1
- - name: Package ${{ matrix.platform }}
- run: python .\utils\post-build.py .\bin\${{ matrix.platform }}\Release\output . ${{ env.VERSION }}
+ - name: Validate installer security contracts (PowerShell 7)
+ shell: pwsh
+ run: .\installer\Test-InstallerSecurityContracts.ps1
- - name: Stage ${{ matrix.platform }} artifact folder
+ - name: Validate installer source and state machine
shell: pwsh
run: |
- $artifactRoot = ".\bin\${{ matrix.platform }}\Release\artifact"
- New-Item -ItemType Directory -Path $artifactRoot -Force | Out-Null
- Copy-Item -Path ".\bin\${{ matrix.platform }}\Release\DS4Windows" -Destination $artifactRoot -Recurse -Force
+ python .\utils\test-installer-state-machine.py
+ if ($LASTEXITCODE -ne 0) { throw "Installer state-machine tests failed." }
+ python .\utils\validate-installer.py --source-only --bundle-source .\installer\DS4Windows.Bundle\Bundle.wxs --setup-actions-source .\installer\DS4Windows.SetupActions\Program.cs --bootstrapper-source .\installer\DS4Windows.Bootstrapper\InstallerApplication.cs
+ if ($LASTEXITCODE -ne 0) { throw "Installer source validation failed." }
- - name: Upload ${{ matrix.platform }} artifact
- id: upload
- uses: actions/upload-artifact@v4
- with:
- name: DS4Windows_${{ env.VERSION }}_${{ matrix.platform }}
- path: .\bin\${{ matrix.platform }}\Release\artifact
- if-no-files-found: error
+ - name: Prove production build fails without signing credentials
+ shell: pwsh
+ run: |
+ foreach ($name in @("DS4W_SIGN_CERT_PATH", "DS4W_SIGN_CERT_PASSWORD", "DS4W_SIGN_CERTIFICATE_SHA256", "DS4W_SIGN_TIMESTAMP_URL")) {
+ Remove-Item -LiteralPath ("Env:\" + $name) -ErrorAction SilentlyContinue
+ }
+ try {
+ & .\installer\build-installer.ps1 -PublishRoot (Join-Path $env:RUNNER_TEMP "must-not-publish")
+ throw "Production build unexpectedly accepted missing signing credentials."
+ } catch {
+ if ($_.Exception.Message -notmatch "requires environment variable") { throw }
+ }
+
+ - name: Compile installer executables
+ shell: pwsh
+ run: |
+ dotnet build .\installer\DS4Windows.SetupActions\DS4Windows.SetupActions.csproj -c Release -p:Platform=x64 --no-restore
+ if ($LASTEXITCODE -ne 0) { throw "SetupActions build failed." }
+ dotnet build .\installer\DS4Windows.Bootstrapper\DS4Windows.Bootstrapper.csproj -c Release -p:Platform=x64 --no-restore
+ if ($LASTEXITCODE -ne 0) { throw "Bootstrapper build failed." }
- - name: Publish artifact link to run summary
- shell: bash
+ - name: Compile disposable MSI and Burn composition
+ shell: pwsh
+ run: |
+ $publishRoot = Join-Path $env:RUNNER_TEMP "ds4windows-installer-composition-publish"
+ dotnet publish .\DS4Windows\DS4WinWPF.csproj -c Release -p:Platform=x64 -o $publishRoot
+ if ($LASTEXITCODE -ne 0) { throw "Composition publish failed." }
+ & .\installer\Test-InstallerComposition.ps1 -PublishRoot $publishRoot
+
+ - name: Run DS4Windows tests (x64)
+ shell: pwsh
run: |
- echo "### DS4Windows ${{ matrix.platform }} build" >> "$GITHUB_STEP_SUMMARY"
- echo "" >> "$GITHUB_STEP_SUMMARY"
- echo "Version: \`${{ env.VERSION }}\`" >> "$GITHUB_STEP_SUMMARY"
- echo "" >> "$GITHUB_STEP_SUMMARY"
- echo "[Download DS4Windows_${{ env.VERSION }}_${{ matrix.platform }} (contains DS4Windows folder)](${{ steps.upload.outputs.artifact-url }})" >> "$GITHUB_STEP_SUMMARY"
+ dotnet test .\DS4WindowsTests\DS4WindowsTests.csproj -c Release -p:Platform=x64 --no-restore --verbosity normal
+ if ($LASTEXITCODE -ne 0) { throw "DS4Windows tests failed." }
+ - name: Verify source checkout remains unchanged
+ shell: pwsh
+ run: |
+ $changes = @(git status --short)
+ if ($changes.Count -ne 0) {
+ $changes | Write-Host
+ throw "CI validation changed tracked or untracked source."
+ }
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3112f07..d302258 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,67 +1,232 @@
-name: .NET Release
+name: Signed Installer Release
on:
- release:
- types: [published]
+ push:
+ tags:
+ - "v*.*.*"
permissions:
- contents: write
+ contents: read
+
+concurrency:
+ group: release-${{ github.ref }}
+ cancel-in-progress: false
+
+env:
+ DOTNET_NOLOGO: "1"
+ DOTNET_CLI_TELEMETRY_OPTOUT: "1"
jobs:
- release:
+ test:
runs-on: windows-2022
- #this makes it not run if tests failed.
- #needs: test
+ timeout-minutes: 45
+ steps:
+ - name: Checkout tagged source
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Setup .NET 8
+ uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
+ with:
+ dotnet-version: 8.0.x
+
+ - name: Setup Python
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
+ with:
+ python-version: "3.12"
+
+ - name: Verify immutable tag provenance
+ shell: pwsh
+ run: |
+ $tag = $env:GITHUB_REF_NAME
+ if ($tag -cnotmatch '^v\d+\.\d+\.\d+(?:\.\d+)?$') {
+ throw "Release tag must use a numeric Burn-compatible version: $tag"
+ }
+ $tagObjectType = (git cat-file -t ("refs/tags/" + $tag)).Trim()
+ if ($tagObjectType -cne "tag") { throw "Release requires an annotated tag." }
+ $tagCommit = (git rev-list -n 1 $tag).Trim()
+ $headCommit = (git rev-parse HEAD).Trim()
+ if ($tagCommit -cne $headCommit -or $headCommit -cne $env:GITHUB_SHA) {
+ throw "Checked-out source does not equal the tagged commit."
+ }
+ git fetch --no-tags origin main
+ if ($LASTEXITCODE -ne 0) { throw "Could not fetch the protected main branch." }
+ git merge-base --is-ancestor $headCommit origin/main
+ if ($LASTEXITCODE -ne 0) { throw "Release tag is not reachable from main." }
+ if (@(git status --short).Count -ne 0) { throw "Tagged checkout is dirty." }
+
+ - name: Restore and test managed code
+ shell: pwsh
+ run: |
+ dotnet restore .\DS4WindowsTests\DS4WindowsTests.csproj
+ if ($LASTEXITCODE -ne 0) { throw "Restore failed." }
+ dotnet test .\DS4WindowsTests\DS4WindowsTests.csproj -c Release -p:Platform=x64 --no-restore --verbosity normal
+ if ($LASTEXITCODE -ne 0) { throw "DS4Windows tests failed." }
+
+ - name: Validate native and installer source contracts
+ shell: pwsh
+ run: |
+ powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File .\extras\Test-ViiperNativePackageContract.ps1
+ if ($LASTEXITCODE -ne 0) { throw "Windows PowerShell native contract failed." }
+ .\extras\Test-ViiperNativePackageContract.ps1
+ powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File .\extras\validation\Test-ViiperWin11ValidationContract.ps1
+ if ($LASTEXITCODE -ne 0) { throw "Windows PowerShell validation-bundle contract failed." }
+ .\extras\validation\Test-ViiperWin11ValidationContract.ps1
+ powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File .\installer\Test-InstallerSecurityContracts.ps1
+ if ($LASTEXITCODE -ne 0) { throw "Windows PowerShell installer security contract failed." }
+ .\installer\Test-InstallerSecurityContracts.ps1
+ python .\utils\test-installer-state-machine.py
+ if ($LASTEXITCODE -ne 0) { throw "Installer state-machine tests failed." }
+ python .\utils\validate-installer.py --source-only --bundle-source .\installer\DS4Windows.Bundle\Bundle.wxs --setup-actions-source .\installer\DS4Windows.SetupActions\Program.cs --bootstrapper-source .\installer\DS4Windows.Bootstrapper\InstallerApplication.cs
+ if ($LASTEXITCODE -ne 0) { throw "Installer source validation failed." }
+
+ - name: Compile protected installer callers
+ shell: pwsh
+ run: |
+ dotnet build .\installer\DS4Windows.SetupActions\DS4Windows.SetupActions.csproj -c Release -p:Platform=x64
+ if ($LASTEXITCODE -ne 0) { throw "SetupActions build failed." }
+ dotnet build .\installer\DS4Windows.Bootstrapper\DS4Windows.Bootstrapper.csproj -c Release -p:Platform=x64
+ if ($LASTEXITCODE -ne 0) { throw "Bootstrapper build failed." }
+
+ release:
+ needs: test
+ runs-on: [self-hosted, Windows, X64, ds4w-release]
+ environment: production
+ timeout-minutes: 90
+ permissions:
+ contents: write
+ id-token: write
+ attestations: write
steps:
- - uses: actions/checkout@v4
- with:
- ref: ${{ github.event.release.tag_name }}
- - name: Setup .NET
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: 8.0.x
- - name: Set up Python 3.10
- uses: actions/setup-python@v3
- with:
- python-version: "3.10"
- - name: Restore dependencies
- run: dotnet restore
- - name: Set envars for versions
- run: |
- TAG=${{ github.event.release.tag_name }}
- VERSION=${TAG#v}
- PROJECT_ASSEMBLY_VERSION=$(python -c "import xml.etree.ElementTree as ET; print(ET.parse('DS4Windows/DS4WinWPF.csproj').find('.//AssemblyVersion').text)")
- PROJECT_VERSION=$(python -c "import xml.etree.ElementTree as ET; print(ET.parse('DS4Windows/DS4WinWPF.csproj').find('.//Version').text)")
- if [[ "$VERSION" =~ ^[0-9]+(\.[0-9]+){1,3}(-.*)?$ ]]; then
- BINARY_VERSION=${VERSION%%-*}
- PACKAGE_VERSION=$VERSION
- else
- BINARY_VERSION=$PROJECT_ASSEMBLY_VERSION
- PACKAGE_VERSION=$PROJECT_VERSION
- fi
- echo "Release: $VERSION; binary: $BINARY_VERSION; package: $PACKAGE_VERSION"
- echo "VERSION=$VERSION" >> $GITHUB_ENV
- echo "BINARY_VERSION=$BINARY_VERSION" >> $GITHUB_ENV
- echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> $GITHUB_ENV
- shell: bash
- - name: Build X64
- run: dotnet publish .\DS4Windows\DS4WinWPF.csproj -c Release /p:platform=x64 /p:AssemblyVersion=${{ env.BINARY_VERSION }} /p:FileVersion=${{ env.BINARY_VERSION }} /p:Version=${{ env.PACKAGE_VERSION }} /p:InformationalVersion=${{ env.VERSION }} -o .\bin\x64\Release\output
- - name: Post-Build script X64
- run: python .\utils\post-build.py .\bin\x64\Release\output . ${{env.VERSION}}
- - name: Name release asset
- shell: pwsh
- run: |
- $source = ".\bin\x64\Release\DS4Windows_${{ env.VERSION }}_x64.zip"
- $asset = if ("${{ env.VERSION }}" -like "VIIPER*") {
- ".\bin\x64\Release\DS4Windows_VIIPER_x64.zip"
- } else {
- $source
- }
- if ($asset -ne $source) {
- Move-Item -LiteralPath $source -Destination $asset -Force
- }
- "RELEASE_ASSET=$asset" >> $env:GITHUB_ENV
- - name: Publish release Build X64
- run: gh release upload ${{github.event.release.tag_name}} ${{ env.RELEASE_ASSET }} --clobber
- env:
- GITHUB_TOKEN: ${{ github.TOKEN }}
+ - name: Checkout the tested tag
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Re-verify exact release checkout
+ shell: pwsh
+ run: |
+ $tagCommit = (git rev-list -n 1 $env:GITHUB_REF_NAME).Trim()
+ $headCommit = (git rev-parse HEAD).Trim()
+ if ($tagCommit -cne $headCommit -or $headCommit -cne $env:GITHUB_SHA) {
+ throw "Release worker checkout does not equal the tested tagged commit."
+ }
+ if (@(git status --short).Count -ne 0) { throw "Release worker checkout is dirty." }
+
+ - name: Setup .NET 8
+ uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
+ with:
+ dotnet-version: 8.0.x
+
+ - name: Setup Python
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
+ with:
+ python-version: "3.12"
+
+ - name: Import protected production native package
+ shell: pwsh
+ env:
+ DS4W_NATIVE_PACKAGE_ROOT: ${{ vars.DS4W_NATIVE_PACKAGE_ROOT }}
+ DS4W_NATIVE_PACKAGE_PROVENANCE: ${{ vars.DS4W_NATIVE_PACKAGE_PROVENANCE }}
+ DS4W_NATIVE_PACKAGE_PROVENANCE_SHA256: ${{ vars.DS4W_NATIVE_PACKAGE_PROVENANCE_SHA256 }}
+ run: |
+ & .\installer\Import-ProductionNativePackage.ps1 -SourceRoot $env:DS4W_NATIVE_PACKAGE_ROOT -ProvenanceManifest $env:DS4W_NATIVE_PACKAGE_PROVENANCE -ExpectedProvenanceSha256 $env:DS4W_NATIVE_PACKAGE_PROVENANCE_SHA256
+ if ($LASTEXITCODE -ne 0) { throw "Production native package import failed." }
+ powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File .\extras\Test-ViiperNativePackageContract.ps1 -RequireProduction -RequirePackage
+ if ($LASTEXITCODE -ne 0) { throw "Windows PowerShell production package gate failed." }
+ .\extras\Test-ViiperNativePackageContract.ps1 -RequireProduction -RequirePackage
+
+ - name: Materialize protected Authenticode credential
+ shell: pwsh
+ env:
+ DS4W_SIGN_PFX_BASE64: ${{ secrets.DS4W_SIGN_PFX_BASE64 }}
+ run: |
+ if ([string]::IsNullOrWhiteSpace($env:DS4W_SIGN_PFX_BASE64)) {
+ throw "DS4W_SIGN_PFX_BASE64 is required."
+ }
+ try {
+ $bytes = [Convert]::FromBase64String($env:DS4W_SIGN_PFX_BASE64)
+ } catch {
+ throw "DS4W_SIGN_PFX_BASE64 is malformed."
+ }
+ if ($bytes.Length -eq 0) { throw "Signing certificate is empty." }
+ $path = Join-Path $env:RUNNER_TEMP "ds4windows-release-signing.pfx"
+ [IO.File]::WriteAllBytes($path, $bytes)
+ "DS4W_SIGN_CERT_PATH=$path" >> $env:GITHUB_ENV
+
+ - name: Build, sign, and verify production installer
+ shell: pwsh
+ env:
+ DS4W_SIGN_CERT_PASSWORD: ${{ secrets.DS4W_SIGN_CERT_PASSWORD }}
+ DS4W_SIGN_CERTIFICATE_SHA256: ${{ secrets.DS4W_SIGN_CERTIFICATE_SHA256 }}
+ DS4W_SIGN_TIMESTAMP_URL: ${{ secrets.DS4W_SIGN_TIMESTAMP_URL }}
+ run: |
+ $version = $env:GITHUB_REF_NAME.Substring(1)
+ $productVersion = ($version -split '-', 2)[0]
+ & .\installer\build-installer.ps1 -PublishRoot .\bin\x64\Release\installer-publish -OutputDirectory .\bin\x64\Release\installer -ProductVersion $productVersion -BundleVersion $version -DisplayVersion $version
+ if ($LASTEXITCODE -ne 0) { throw "Signed installer build failed." }
+
+ - name: Remove ephemeral signing credential
+ if: always()
+ shell: pwsh
+ run: |
+ $runnerTemp = [IO.Path]::GetFullPath($env:RUNNER_TEMP).TrimEnd('\')
+ $path = [IO.Path]::GetFullPath((Join-Path $runnerTemp "ds4windows-release-signing.pfx"))
+ if ((Split-Path -Parent $path) -cne $runnerTemp) {
+ throw "Refusing unsafe signing credential cleanup."
+ }
+ if (Test-Path -LiteralPath $path -PathType Leaf) {
+ Remove-Item -LiteralPath $path -Force
+ }
+
+ - name: Assemble immutable release inventory
+ shell: pwsh
+ env:
+ DS4W_NATIVE_PACKAGE_PROVENANCE: ${{ vars.DS4W_NATIVE_PACKAGE_PROVENANCE }}
+ DS4W_NATIVE_PACKAGE_PROVENANCE_SHA256: ${{ vars.DS4W_NATIVE_PACKAGE_PROVENANCE_SHA256 }}
+ run: |
+ $releaseRoot = Resolve-Path .\bin\x64\Release\installer
+ $nativeProvenance = Join-Path $releaseRoot "VIIPER-native-package.provenance.json"
+ Copy-Item -LiteralPath $env:DS4W_NATIVE_PACKAGE_PROVENANCE -Destination $nativeProvenance
+ if ((Get-FileHash -LiteralPath $nativeProvenance -Algorithm SHA256).Hash -cne $env:DS4W_NATIVE_PACKAGE_PROVENANCE_SHA256.ToUpperInvariant()) {
+ throw "Copied native provenance manifest differs from its release pin."
+ }
+ $files = @(Get-ChildItem -LiteralPath $releaseRoot -File | Sort-Object Name)
+ if ($files.Count -ne 3) { throw "Release inventory must contain installer and two provenance manifests before checksums." }
+ $checksumPath = Join-Path $releaseRoot "SHA256SUMS.txt"
+ $lines = @($files | ForEach-Object {
+ $hash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
+ $hash + " *" + $_.Name
+ })
+ [IO.File]::WriteAllLines($checksumPath, $lines, [Text.UTF8Encoding]::new($false))
+
+ - name: Attest release provenance
+ uses: actions/attest-build-provenance@96b4a1ef7235a096b17240c259729fdd70c83d45 # v2
+ with:
+ subject-path: bin/x64/Release/installer/*
+
+ - name: Retain signed release inventory
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: DS4Windows_${{ github.ref_name }}_signed-installer
+ path: bin/x64/Release/installer/*
+ if-no-files-found: error
+ retention-days: 90
+
+ - name: Publish new immutable GitHub release
+ shell: pwsh
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ $tag = $env:GITHUB_REF_NAME
+ gh release view $tag *> $null
+ if ($LASTEXITCODE -eq 0) { throw "Release already exists; assets will not be overwritten." }
+ $assets = @(Get-ChildItem -LiteralPath .\bin\x64\Release\installer -File | ForEach-Object FullName)
+ if ($assets.Count -ne 4) { throw "Release asset inventory changed after attestation." }
+ gh release create $tag @assets --draft --verify-tag --title ("DS4Windows " + $tag.Substring(1)) --generate-notes
+ if ($LASTEXITCODE -ne 0) { throw "GitHub draft release creation or asset upload failed." }
+ gh release edit $tag --draft=false
+ if ($LASTEXITCODE -ne 0) { throw "GitHub release remained a non-public draft." }
diff --git a/.gitignore b/.gitignore
index 2bad0e5..5062994 100644
--- a/.gitignore
+++ b/.gitignore
@@ -333,3 +333,10 @@ ASALocalRun/
!DS4Windows/libs/x64/
!DS4Windows/libs/x86/
+# Native VIIPER driver/broker media is supplied by the signed release pipeline.
+# Never commit local-test certificates or package binaries into DS4Windows.
+extras/viiper-native-package/
+
+# Deterministic WiX inventory generated from the signed publish tree.
+installer/DS4Windows.Package/GeneratedFiles.wxs
+
diff --git a/DS4Windows/DS4Control/ControlService.cs b/DS4Windows/DS4Control/ControlService.cs
index 2cedc14..3a1167f 100644
--- a/DS4Windows/DS4Control/ControlService.cs
+++ b/DS4Windows/DS4Control/ControlService.cs
@@ -752,27 +752,11 @@ public void ShutDown()
private void DS4Devices_RequestElevation(RequestElevationArgs args)
{
- // Launches an elevated child process to re-enable device
- ProcessStartInfo startInfo =
- new ProcessStartInfo(Global.exelocation);
- startInfo.Verb = "runas";
- startInfo.Arguments = "re-enabledevice " + args.InstanceId;
- startInfo.UseShellExecute = true;
-
- try
- {
- Process child = Process.Start(startInfo);
- if (!child.WaitForExit(30000))
- {
- child.Kill();
- }
- else
- {
- args.StatusCode = child.ExitCode;
- }
- child.Dispose();
- }
- catch { }
+ // Never elevate the mutable portable executable. The caller keeps
+ // the default failure status and falls back to non-exclusive input.
+ // A signed, machine-installed maintenance entry owns privileged
+ // device repair in production builds.
+ args.StatusCode = RequestElevationArgs.STATUS_INIT_FAILURE;
}
public void CheckHidHidePresence(string ExePath = "", string ExeName = "Autoprofile Exe", bool AddExe = true) // Default value for D4W Startup
@@ -1187,11 +1171,12 @@ private void EnsureHidHideForVirtualOutput(int index, DS4Device device, OutContT
}
///
- /// A VIIPER Sony output is a complete USB/IP HID, so an instance path
- /// accidentally retained in HidHide's persistent blacklist makes the
- /// virtual controller healthy and writable inside DS4Windows while it
- /// is invisible to games. Remove only the exact before/after paths that
- /// this process just created; physical Sony controllers stay cloaked.
+ /// A VIIPER Sony output is a complete USB composite device, so an
+ /// instance path accidentally retained in HidHide's persistent
+ /// blacklist makes the virtual controller healthy and writable inside
+ /// DS4Windows while it remains invisible to games. Remove only paths
+ /// already correlated to the output's exact PnP lifetime; physical
+ /// Sony controllers stay cloaked.
///
private void EnsureHidHideDoesNotCloakVirtualSonyOutputs(
IReadOnlyCollection devicePaths)
@@ -1710,15 +1695,14 @@ public bool Start(bool showlog = true)
AssignInitialDevices();
StartupDiag("AssignInitialDevices end");
- // A force-closed prior development build can leave its
- // USB/IP output imported. Remove those ports before HID
- // discovery or DS4Windows will ingest its own VIIPER DS4,
- // create a second output/UAC endpoint, and recurse.
- ViiperUsbipPortManager.DetachStaleLocalViiperPorts();
- // Let usbccgp/HID finish publishing removal before the
- // first input snapshot; otherwise a detached interface can
- // remain enumerable for one final discovery pass.
- Thread.Sleep(250);
+ if (ViiperTransportSettings.GetManagedMode() ==
+ ViiperTransportMode.Usbip)
+ {
+ // USB/IP is an explicit ABBA/dev-validation mode. Only
+ // that mode may inspect or detach legacy imported ports.
+ ViiperUsbipPortManager.DetachStaleLocalViiperPorts();
+ Thread.Sleep(250);
+ }
StartupDiag("DS4Devices.findControllers dispatch begin");
eventDispatcher.Invoke(() =>
@@ -2441,7 +2425,8 @@ private ViiperOutDevice EnsurePlayStationFeatureOutput(
ViiperOutDevice existing =
playStationFeatureOutputDevices[index];
if (existing?.IsRuntimeConnected == true &&
- existing.OutputType == desiredSidecar)
+ existing.OutputType == desiredSidecar &&
+ ViiperPnPOwnershipRegistry.GetToken(existing) > 0)
{
existing.BindPhysicalController(index);
return existing;
@@ -2450,7 +2435,7 @@ private ViiperOutDevice EnsurePlayStationFeatureOutput(
if (existing != null)
{
playStationFeatureOutputDevices[index] = null;
- existing.Disconnect();
+ DisconnectOwnedViiperSource(existing);
}
ViiperOutDevice sidecar = new ViiperOutDevice(
@@ -2463,15 +2448,30 @@ private ViiperOutDevice EnsurePlayStationFeatureOutput(
StartupDiag(
$"PlayStation audio sidecar connect begin index={index} type={desiredSidecar}");
sidecar.Connect();
+ int ownerToken = ViiperPnPOwnershipRegistry.
+ AttachOrUpdate(sidecar);
+ if (ownerToken <= 0)
+ {
+ throw new InvalidOperationException(
+ "VIIPER did not return an exact native PnP identity for the audio sidecar.");
+ }
sidecar.BindPhysicalController(index);
playStationFeatureOutputDevices[index] = sidecar;
StartupDiag(
- $"PlayStation audio sidecar ready index={index} type={desiredSidecar} port={sidecar.DirectSpeakerUsbipPort}");
+ $"PlayStation audio sidecar ready index={index} type={desiredSidecar} ownerToken={ownerToken}");
return sidecar;
}
catch (Exception ex)
{
- sidecar.Disconnect();
+ try
+ {
+ DisconnectOwnedViiperSource(sidecar);
+ }
+ catch (Exception disconnectException)
+ {
+ StartupDiag(
+ $"PlayStation audio sidecar rollback disconnect failed index={index} {disconnectException.GetType().Name}: {disconnectException.Message}");
+ }
AppLogger.LogToGui(
$"Could not create the {desiredSidecar.ToDisplayName()} audio interface for controller #{index + 1}: {ex.Message}",
true);
@@ -2499,7 +2499,20 @@ private void DisconnectPlayStationFeatureOutput(int index)
{
StartupDiag(
$"PlayStation audio sidecar disconnect index={index} type={sidecar.OutputType}");
- sidecar.Disconnect();
+ DisconnectOwnedViiperSource(sidecar);
+ }
+ }
+
+ private static void DisconnectOwnedViiperSource(
+ ViiperOutDevice source)
+ {
+ try
+ {
+ source?.Disconnect();
+ }
+ finally
+ {
+ ViiperPnPOwnershipRegistry.Detach(source);
}
}
@@ -2685,7 +2698,8 @@ public void CheckProfileOptions(int ind, DS4Device device, bool startUp = false)
Global.store.audioHapticsSettings[ind],
playStationFeatureOutputType,
DualSenseAudioSpeakerEndpointId[ind],
- playStationFeatureOutput?.DirectSpeakerUsbipPort ?? -1);
+ ViiperPnPOwnershipRegistry.GetToken(
+ playStationFeatureOutput));
if (!startUp)
{
@@ -3285,7 +3299,7 @@ private OutputDevice GetReportOutputDevice(int index)
// The companion pointer is published before routing becomes active
// and routing is disabled before the pointer is withdrawn. This
// keeps the report path valid throughout VIIPER's comparatively
- // slow USB/IP plug and unplug operations.
+ // slow virtual-device PnP plug and unplug operations.
if (Volatile.Read(ref gameBarCompatibilityRoutingActive[index]) == 1)
{
OutputDevice compatibilityOutput = Volatile.Read(
@@ -3410,7 +3424,7 @@ private void ActivateGameBarCompatibilityOutputCore(int index)
ref gameBarCompatibilityOutputDevices[index], compatibilityOutput);
// Commit routing only after the companion is fully connected
// and published. The native output continues receiving reports
- // during the whole USB/IP creation interval.
+ // during the whole virtual-device creation interval.
Interlocked.Exchange(ref gameBarCompatibilityRoutingActive[index], 1);
try
{
diff --git a/DS4Windows/DS4Control/DualSenseAudioPassthrough.cs b/DS4Windows/DS4Control/DualSenseAudioPassthrough.cs
index 642fa2f..23cf755 100644
--- a/DS4Windows/DS4Control/DualSenseAudioPassthrough.cs
+++ b/DS4Windows/DS4Control/DualSenseAudioPassthrough.cs
@@ -53,6 +53,7 @@ public sealed class DualSenseAudioPassthrough : IDisposable
private WaveFormat captureFormat;
private string captureEndpointId = string.Empty;
private ControllerAudioEndpointKind captureEndpointKind;
+ private int captureOwnerToken = -1;
private bool disposed;
public ControllerRuntimeLaneState GetStatus(int slot)
@@ -91,6 +92,9 @@ public void Start(int slot, DualSenseDevice dualSenseDevice, byte speakerVolume,
return;
}
+ int virtualOwnerToken = ViiperPnPOwnershipRegistry.GetToken(
+ directSpeakerSource);
+
lock (syncRoot)
{
startFailed[slot] = false;
@@ -140,8 +144,11 @@ public void Start(int slot, DualSenseDevice dualSenseDevice, byte speakerVolume,
return;
}
+ Global.TryInspectViiperPnPTopology(
+ dualSenseDevice?.HidDevice?.DevicePath,
+ out ViiperPnPTopologyIdentity physicalTopology);
MMDevice endpoint = FindControllerEndpoint(slot,
- requestedSpeakerEndpointId);
+ requestedSpeakerEndpointId, physicalTopology);
if (endpoint == null)
{
AppLogger.LogToGui(
@@ -164,7 +171,8 @@ public void Start(int slot, DualSenseDevice dualSenseDevice, byte speakerVolume,
slots[slot].SpeakerVolume = speakerVolume;
EnsureCaptureStarted(requestedCaptureEndpointId,
endpoint.ID,
- GetEndpointKind(emulatedControllerType));
+ GetEndpointKind(emulatedControllerType),
+ virtualOwnerToken);
return;
}
else
@@ -192,7 +200,8 @@ public void Start(int slot, DualSenseDevice dualSenseDevice, byte speakerVolume,
provider, outputFormat, speakerVolume);
EnsureCaptureStarted(requestedCaptureEndpointId,
endpoint.ID,
- GetEndpointKind(emulatedControllerType));
+ GetEndpointKind(emulatedControllerType),
+ virtualOwnerToken);
AppLogger.LogToGui(
$"DualSense audio passthrough started for controller {slot + 1}: {endpoint.FriendlyName}",
false);
@@ -336,12 +345,15 @@ private void StartBluetooth(int slot, DualSenseDevice device, byte speakerVolume
{
requestedCaptureEndpointId ??= string.Empty;
ControllerAudioEndpointKind endpointKind = GetEndpointKind(emulatedControllerType);
+ int virtualOwnerToken = ViiperPnPOwnershipRegistry.GetToken(
+ directSpeakerSource);
DirectSpeakerRouteDecision initialRoute =
EvaluateDirectSpeakerRoute(requestedCaptureEndpointId,
endpointKind, directSpeakerSource);
if (initialRoute == DirectSpeakerRouteDecision.Loopback)
{
directSpeakerSource = null;
+ virtualOwnerToken = -1;
}
int generation;
@@ -359,7 +371,7 @@ private void StartBluetooth(int slot, DualSenseDevice device, byte speakerVolume
if (bluetoothSlots[slot]?.Matches(device, speakerVolume,
speakerCompression, speakerBassBoost,
requestedCaptureEndpointId, endpointKind,
- directSpeakerSource) == true)
+ directSpeakerSource, virtualOwnerToken) == true)
{
return;
}
@@ -383,13 +395,15 @@ private void StartBluetooth(int slot, DualSenseDevice device, byte speakerVolume
_ = Task.Run(() => StartBluetoothWithRetry(slot, device, speakerVolume,
speakerCompression, speakerBassBoost, requestedCaptureEndpointId,
- endpointKind, directSpeakerSource, generation));
+ endpointKind, directSpeakerSource, virtualOwnerToken,
+ generation));
}
private void StartBluetoothWithRetry(int slot, DualSenseDevice device, byte speakerVolume,
DualSenseSpeakerCompression speakerCompression, byte speakerBassBoost,
string requestedCaptureEndpointId, ControllerAudioEndpointKind endpointKind,
- ViiperOutDevice directSpeakerSource, int generation)
+ ViiperOutDevice directSpeakerSource, int virtualOwnerToken,
+ int generation)
{
Exception lastError = null;
for (int attempt = 0; attempt < BluetoothStartRetryAttempts;
@@ -398,7 +412,8 @@ private void StartBluetoothWithRetry(int slot, DualSenseDevice device, byte spea
if (TryStartBluetoothOnce(slot, device, speakerVolume,
speakerCompression, speakerBassBoost,
requestedCaptureEndpointId, endpointKind,
- directSpeakerSource, generation, out lastError))
+ directSpeakerSource, virtualOwnerToken, generation,
+ out lastError))
{
return;
}
@@ -436,7 +451,8 @@ private bool TryStartBluetoothOnce(int slot,
DualSenseSpeakerCompression speakerCompression,
byte speakerBassBoost, string requestedCaptureEndpointId,
ControllerAudioEndpointKind endpointKind,
- ViiperOutDevice directSpeakerSource, int generation,
+ ViiperOutDevice directSpeakerSource, int virtualOwnerToken,
+ int generation,
out Exception lastError)
{
lastError = null;
@@ -456,6 +472,8 @@ private bool TryStartBluetoothOnce(int slot,
DirectSpeakerRouteDecision route =
EvaluateDirectSpeakerRoute(requestedCaptureEndpointId,
endpointKind, directSpeakerSource);
+ virtualOwnerToken = ViiperPnPOwnershipRegistry.GetToken(
+ directSpeakerSource);
ViiperOutDevice activeDirectSpeakerSource =
route == DirectSpeakerRouteDecision.Direct ?
@@ -463,7 +481,7 @@ private bool TryStartBluetoothOnce(int slot,
var bluetoothPlayback = new DualSenseBluetoothSpeakerPassthrough(device,
speakerVolume, speakerCompression, speakerBassBoost,
requestedCaptureEndpointId, endpointKind,
- activeDirectSpeakerSource);
+ activeDirectSpeakerSource, virtualOwnerToken);
try
{
bluetoothPlayback.Start();
@@ -516,12 +534,14 @@ private void StopBluetooth(int slot)
}
private void EnsureCaptureStarted(string requestedCaptureEndpointId,
- string speakerEndpointId, ControllerAudioEndpointKind endpointKind)
+ string speakerEndpointId, ControllerAudioEndpointKind endpointKind,
+ int virtualOwnerToken)
{
requestedCaptureEndpointId ??= string.Empty;
speakerEndpointId ??= string.Empty;
if (capture != null && captureEndpointKind == endpointKind &&
+ captureOwnerToken == virtualOwnerToken &&
string.Equals(captureEndpointId, requestedCaptureEndpointId, StringComparison.Ordinal))
{
return;
@@ -536,6 +556,7 @@ private void EnsureCaptureStarted(string requestedCaptureEndpointId,
automaticSlot);
captureEndpointId = requestedCaptureEndpointId;
captureEndpointKind = endpointKind;
+ captureOwnerToken = virtualOwnerToken;
captureFormat = capture.WaveFormat;
capture.DataAvailable += Capture_DataAvailable;
capture.RecordingStopped += Capture_RecordingStopped;
@@ -552,6 +573,7 @@ private void EnsureCaptureStarted(string requestedCaptureEndpointId,
capture = new ProcessLoopbackWaveCapture(processId);
captureEndpointId = requestedCaptureEndpointId;
captureEndpointKind = endpointKind;
+ captureOwnerToken = virtualOwnerToken;
captureFormat = capture.WaveFormat;
capture.DataAvailable += Capture_DataAvailable;
capture.RecordingStopped += Capture_RecordingStopped;
@@ -570,7 +592,7 @@ private void EnsureCaptureStarted(string requestedCaptureEndpointId,
}
MMDevice sourceEndpoint = FindCaptureEndpoint(requestedCaptureEndpointId,
- speakerEndpointId, endpointKind);
+ speakerEndpointId, endpointKind, virtualOwnerToken);
bool expectsControllerEndpoint =
string.Equals(requestedCaptureEndpointId,
AutoDetectGameAudioEndpointId, StringComparison.Ordinal) ||
@@ -593,6 +615,7 @@ private void EnsureCaptureStarted(string requestedCaptureEndpointId,
capture = sourceEndpoint != null ? new WasapiLoopbackCapture(sourceEndpoint) : new WasapiLoopbackCapture();
captureEndpointId = requestedCaptureEndpointId;
captureEndpointKind = endpointKind;
+ captureOwnerToken = virtualOwnerToken;
captureFormat = capture.WaveFormat;
capture.DataAvailable += Capture_DataAvailable;
capture.RecordingStopped += Capture_RecordingStopped;
@@ -609,6 +632,7 @@ private void StopCapture()
captureFormat = null;
captureEndpointId = string.Empty;
captureEndpointKind = ControllerAudioEndpointKind.Any;
+ captureOwnerToken = -1;
if (oldCapture == null)
{
@@ -657,7 +681,9 @@ private void Capture_DataAvailable(object sender, WaveInEventArgs e)
}
}
- private MMDevice FindControllerEndpoint(int slot, string requestedSpeakerEndpointId)
+ private MMDevice FindControllerEndpoint(int slot,
+ string requestedSpeakerEndpointId,
+ ViiperPnPTopologyIdentity physicalControllerTopology)
{
HashSet usedIds = slots
.Where((item, index) => item != null && index != slot)
@@ -670,7 +696,10 @@ private MMDevice FindControllerEndpoint(int slot, string requestedSpeakerEndpoin
try
{
MMDevice requested = enumerator.GetDevice(requestedSpeakerEndpointId);
- if (requested.State == DeviceState.Active && !usedIds.Contains(requested.ID))
+ if (requested.State == DeviceState.Active &&
+ !usedIds.Contains(requested.ID) &&
+ EndpointMatchesUsbDevice(requested,
+ physicalControllerTopology))
{
return requested;
}
@@ -681,9 +710,14 @@ private MMDevice FindControllerEndpoint(int slot, string requestedSpeakerEndpoin
}
}
- MMDevice autoEndpoint = enumerator
+ List matchingEndpoints = enumerator
.EnumerateAudioEndPoints(DataFlow.Render, DeviceState.Active)
- .FirstOrDefault(device => !usedIds.Contains(device.ID) && IsDualSenseEndpoint(device));
+ .Where(device => !usedIds.Contains(device.ID) &&
+ IsDualSenseEndpoint(device) &&
+ EndpointMatchesUsbDevice(device,
+ physicalControllerTopology)).ToList();
+ MMDevice autoEndpoint = matchingEndpoints.Count == 1 ?
+ matchingEndpoints[0] : null;
if (autoEndpoint == null)
{
@@ -702,8 +736,10 @@ private MMDevice FindControllerEndpoint(int slot, string requestedSpeakerEndpoin
return autoEndpoint;
}
- private static MMDevice FindCaptureEndpoint(string endpointId, string speakerEndpointId,
- ControllerAudioEndpointKind endpointKind)
+ private static MMDevice FindCaptureEndpoint(string endpointId,
+ string speakerEndpointId,
+ ControllerAudioEndpointKind endpointKind,
+ int virtualOwnerToken)
{
bool useSystemDefault = string.Equals(endpointId,
DefaultSystemAudioEndpointId, StringComparison.Ordinal) ||
@@ -721,13 +757,21 @@ private static MMDevice FindCaptureEndpoint(string endpointId, string speakerEnd
string.Equals(endpointId, AutoDetectGameAudioEndpointId,
StringComparison.Ordinal);
MMDevice endpoint = autoDetect ?
- FindActiveGameAudioEndpoint(enumerator, null, endpointKind) :
+ FindActiveGameAudioEndpoint(enumerator, null, endpointKind,
+ virtualOwnerToken) :
enumerator.GetDevice(endpointId);
if (endpoint?.State != DeviceState.Active)
{
return null;
}
+ if (IsControllerAudioEndpoint(endpoint) &&
+ !EndpointMatchesOwnerToken(endpoint,
+ virtualOwnerToken))
+ {
+ return null;
+ }
+
if (string.Equals(endpoint.ID, speakerEndpointId, StringComparison.Ordinal))
{
AppLogger.LogToGui("DualSense audio passthrough capture source cannot be the same as the speaker endpoint. Falling back to default audio endpoint.", true);
@@ -834,13 +878,13 @@ private static bool LooksLikeAmbiguousControllerIdentity(string identity)
internal static MMDevice FindActiveGameAudioEndpoint(MMDeviceEnumerator enumerator,
string previousEndpointId = null,
ControllerAudioEndpointKind preferredKind = ControllerAudioEndpointKind.Any,
- int preferredUsbipPort = -1,
+ int preferredOwnerToken = -1,
bool requireActive = true)
{
DeviceState endpointState = requireActive ? DeviceState.Active : DeviceState.All;
- IEnumerable endpoints = enumerator
+ List endpoints = enumerator
.EnumerateAudioEndPoints(DataFlow.Render, endpointState)
- .Where(IsControllerAudioEndpoint);
+ .Where(IsControllerAudioEndpoint).ToList();
if (preferredKind != ControllerAudioEndpointKind.Any)
{
@@ -848,38 +892,85 @@ internal static MMDevice FindActiveGameAudioEndpoint(MMDeviceEnumerator enumerat
{
ControllerAudioEndpointKind kind = ClassifyEndpoint(endpoint);
return kind == preferredKind || kind == ControllerAudioEndpointKind.Any;
- });
+ }).ToList();
+ }
+
+ if (preferredOwnerToken > 0)
+ {
+ List ownedEndpoints = endpoints.Where(endpoint =>
+ EndpointMatchesOwnerToken(endpoint,
+ preferredOwnerToken)).ToList();
+ // The source contract binds to controller root + UdeCx port,
+ // not to a Windows MMDevice generation. During endpoint
+ // replacement both generations can briefly share that anchor;
+ // do not let a stale saved endpoint ID choose between them.
+ return SelectUnambiguousControllerEndpoint(ownedEndpoints,
+ null, null);
}
- return endpoints
- .OrderByDescending(endpoint =>
- EndpointMatchesUsbipPort(endpoint, preferredUsbipPort))
- .ThenByDescending(endpoint => EndpointScore(endpoint,
- preferredKind, previousEndpointId))
- .FirstOrDefault();
+ return SelectUnambiguousControllerEndpoint(endpoints,
+ endpoint => string.Equals(endpoint.ID, previousEndpointId,
+ StringComparison.Ordinal),
+ endpoint => !string.IsNullOrEmpty(previousEndpointId) &&
+ EndpointReplaces(endpoint, previousEndpointId));
}
- private static bool EndpointMatchesUsbipPort(MMDevice endpoint,
- int preferredUsbipPort)
+ // A concrete endpoint ID is exact even when multiple physical and
+ // virtual Sony devices coexist. A replacement-history match is safe
+ // only when exactly one candidate claims it. With no exact evidence,
+ // retain the historical single-controller behavior but fail closed as
+ // soon as the machine has an ambiguous second Sony endpoint.
+ internal static T SelectUnambiguousControllerEndpoint(
+ IReadOnlyList endpoints, Func exactIdMatch,
+ Func replacementMatch) where T : class
{
- if (preferredUsbipPort < 0 || endpoint == null)
+ if (endpoints == null || endpoints.Count == 0)
{
- return false;
+ return null;
}
- string interfacePath = GetEndpointProperty(endpoint,
- PropertyKeys.PKEY_Device_InterfaceKey);
- int pathStart = interfacePath.IndexOf(@"\\?\",
- StringComparison.Ordinal);
- if (pathStart > 0)
+ List exact = exactIdMatch == null ? new List() :
+ endpoints.Where(endpoint => endpoint != null &&
+ exactIdMatch(endpoint)).ToList();
+ if (exact.Count != 0)
{
- interfacePath = interfacePath.Substring(pathStart);
+ return exact.Count == 1 ? exact[0] : null;
}
- return !string.IsNullOrEmpty(interfacePath) &&
- Global.TryResolveUsbIpWin2Device(interfacePath,
- out bool usbIpAncestor, out int endpointPort) &&
- usbIpAncestor && endpointPort == preferredUsbipPort;
+ List replacements = replacementMatch == null ?
+ new List() : endpoints.Where(endpoint =>
+ endpoint != null && replacementMatch(endpoint)).ToList();
+ if (replacements.Count != 0)
+ {
+ return replacements.Count == 1 ? replacements[0] : null;
+ }
+
+ // Never use a missing (-1) owner token as "any Sony endpoint".
+ return endpoints.Count == 1 ? endpoints[0] : null;
+ }
+
+ internal static bool EndpointMatchesOwnerToken(MMDevice endpoint,
+ int preferredOwnerToken)
+ {
+ if (preferredOwnerToken <= 0 || endpoint == null)
+ {
+ return false;
+ }
+
+ return TryGetEndpointPnPTopology(endpoint, out _,
+ out ViiperPnPTopologyIdentity topology) &&
+ topology.IsResolved &&
+ ViiperPnPOwnershipRegistry.Matches(preferredOwnerToken,
+ topology);
+ }
+
+ private static bool EndpointMatchesUsbDevice(MMDevice endpoint,
+ ViiperPnPTopologyIdentity expectedUsbDevice)
+ {
+ return expectedUsbDevice.IsUsbDeviceResolved &&
+ TryGetEndpointPnPTopology(endpoint, out _,
+ out ViiperPnPTopologyIdentity endpointTopology) &&
+ expectedUsbDevice.IsSameUsbDevice(endpointTopology);
}
internal static ControllerAudioEndpointKind GetEndpointKind(OutContType outputType)
@@ -945,7 +1036,7 @@ internal static DirectSpeakerRouteDecision DecideDirectSpeakerRoute(
if (automatic)
{
return directStreamActive ? DirectSpeakerRouteDecision.Direct :
- DirectSpeakerRouteDecision.Loopback;
+ DirectSpeakerRouteDecision.Pending;
}
return explicitEndpointOwnership switch
@@ -953,10 +1044,10 @@ internal static DirectSpeakerRouteDecision DecideDirectSpeakerRoute(
DirectSpeakerEndpointOwnership.Owned when directStreamActive =>
DirectSpeakerRouteDecision.Direct,
DirectSpeakerEndpointOwnership.Owned =>
- DirectSpeakerRouteDecision.Loopback,
+ DirectSpeakerRouteDecision.Pending,
DirectSpeakerEndpointOwnership.Unowned =>
DirectSpeakerRouteDecision.Loopback,
- _ => DirectSpeakerRouteDecision.Loopback,
+ _ => DirectSpeakerRouteDecision.Pending,
};
}
@@ -1008,47 +1099,44 @@ private static DirectSpeakerEndpointOwnership
StringComparison.Ordinal));
if (exactEndpoint != null)
{
- if (IsControllerEndpointSelection(
- ClassifyEndpoint(exactEndpoint), endpointKind))
- {
- return DirectSpeakerEndpointOwnership.Owned;
- }
-
return ResolveEndpointOwnership(exactEndpoint,
endpointKind, directSpeakerSource);
}
- DirectSpeakerEndpointOwnership replacementResult =
- DirectSpeakerEndpointOwnership.Unresolved;
+ int ownedReplacementCount = 0;
+ bool hasUnownedReplacement = false;
foreach (MMDevice candidate in activeEndpoints.Where(
endpoint => EndpointReplaces(endpoint, endpointId)))
{
- if (IsControllerEndpointSelection(
- ClassifyEndpoint(candidate), endpointKind))
- {
- return DirectSpeakerEndpointOwnership.Owned;
- }
-
DirectSpeakerEndpointOwnership candidateResult =
ResolveEndpointOwnership(candidate, endpointKind,
directSpeakerSource);
if (candidateResult ==
DirectSpeakerEndpointOwnership.Owned)
{
- return candidateResult;
+ ownedReplacementCount++;
}
- if (candidateResult ==
+ else if (candidateResult ==
DirectSpeakerEndpointOwnership.Unowned)
{
- replacementResult = candidateResult;
+ hasUnownedReplacement = true;
}
}
- if (replacementResult !=
- DirectSpeakerEndpointOwnership.Unresolved)
+ if (ownedReplacementCount == 1)
+ {
+ return DirectSpeakerEndpointOwnership.Owned;
+ }
+
+ if (ownedReplacementCount > 1)
+ {
+ return DirectSpeakerEndpointOwnership.Unresolved;
+ }
+
+ if (hasUnownedReplacement)
{
- return replacementResult;
+ return DirectSpeakerEndpointOwnership.Unowned;
}
}
finally
@@ -1063,14 +1151,7 @@ private static DirectSpeakerEndpointOwnership
{
using MMDevice savedEndpoint =
enumerator.GetDevice(endpointId);
- if (savedEndpoint != null &&
- IsControllerEndpointSelection(
- ClassifyEndpoint(savedEndpoint), endpointKind))
- {
- return DirectSpeakerEndpointOwnership.Owned;
- }
-
- if (savedEndpoint?.State == DeviceState.Active)
+ if (savedEndpoint != null)
{
return ResolveEndpointOwnership(savedEndpoint,
endpointKind, directSpeakerSource);
@@ -1113,34 +1194,25 @@ private static DirectSpeakerEndpointOwnership ResolveEndpointOwnership(
ViiperOutDevice directSpeakerSource)
{
bool identityMatches = EndpointKindMatches(endpoint, endpointKind);
- string interfacePath = GetEndpointProperty(endpoint,
- PropertyKeys.PKEY_Device_InterfaceKey);
- int pathStart = interfacePath.IndexOf(@"\\?\",
- StringComparison.Ordinal);
- if (pathStart > 0)
- {
- interfacePath = interfacePath.Substring(pathStart);
- }
-
- bool interfacePathAvailable =
- !string.IsNullOrEmpty(interfacePath);
- int endpointPort = -1;
- bool usbIpAncestor = false;
- bool usbIpQueryResolved = interfacePathAvailable &&
- Global.TryResolveUsbIpWin2Device(interfacePath,
- out usbIpAncestor, out endpointPort);
+ bool topologyAvailable = TryGetEndpointPnPTopology(endpoint,
+ out bool ancestryComplete,
+ out ViiperPnPTopologyIdentity topology) &&
+ topology.IsResolved;
+ int ownerToken = ViiperPnPOwnershipRegistry.GetToken(
+ directSpeakerSource);
+ bool exactOwnerMatch = topologyAvailable &&
+ ViiperPnPOwnershipRegistry.Matches(ownerToken, topology);
return ClassifyDirectSpeakerEndpointOwnership(
endpoint?.State == DeviceState.Active, identityMatches,
- interfacePathAvailable, usbIpQueryResolved, usbIpAncestor,
- endpointPort, directSpeakerSource?.DirectSpeakerUsbipPort ?? -1);
+ ownerToken > 0 && ancestryComplete && topologyAvailable,
+ exactOwnerMatch);
}
internal static DirectSpeakerEndpointOwnership
ClassifyDirectSpeakerEndpointOwnership(bool endpointActive,
- bool controllerIdentityMatches, bool interfacePathAvailable,
- bool usbIpQueryResolved, bool usbIpAncestor, int endpointPort,
- int sourcePort)
+ bool controllerIdentityMatches, bool pnpIdentityResolved,
+ bool exactOwnerMatch)
{
if (!endpointActive)
{
@@ -1152,27 +1224,12 @@ internal static DirectSpeakerEndpointOwnership
return DirectSpeakerEndpointOwnership.Unowned;
}
- if (!interfacePathAvailable)
- {
- return DirectSpeakerEndpointOwnership.Unresolved;
- }
-
- if (!usbIpQueryResolved)
- {
- return DirectSpeakerEndpointOwnership.Unresolved;
- }
-
- if (!usbIpAncestor)
- {
- return DirectSpeakerEndpointOwnership.Unowned;
- }
-
- if (endpointPort < 0 || sourcePort < 0)
+ if (!pnpIdentityResolved)
{
return DirectSpeakerEndpointOwnership.Unresolved;
}
- return endpointPort == sourcePort ?
+ return exactOwnerMatch ?
DirectSpeakerEndpointOwnership.Owned :
DirectSpeakerEndpointOwnership.Unowned;
}
@@ -1264,51 +1321,85 @@ private static string GetEndpointProperty(MMDevice endpoint,
}
}
- private static void AddEndpointProperty(List values, MMDevice endpoint,
- PropertyKey propertyKey)
+ private static bool TryGetEndpointPnPTopology(MMDevice endpoint,
+ out bool ancestryComplete,
+ out ViiperPnPTopologyIdentity topology)
{
- try
+ ancestryComplete = false;
+ topology = default;
+ if (endpoint == null)
{
- object value = endpoint.Properties[propertyKey]?.Value;
- if (value != null)
- {
- values.Add(value.ToString());
- }
+ return false;
}
- catch
+
+ string[] candidates =
{
- // Endpoint property availability differs by Windows audio driver.
+ GetEndpointProperty(endpoint,
+ PropertyKeys.PKEY_Device_InterfaceKey),
+ GetEndpointProperty(endpoint,
+ PropertyKeys.PKEY_Device_ControllerDeviceId),
+ GetEndpointProperty(endpoint,
+ PropertyKeys.PKEY_Device_InstanceId),
+ };
+
+ foreach (string rawCandidate in candidates)
+ {
+ string candidate = NormalizeEndpointPnPPath(rawCandidate);
+ if (string.IsNullOrEmpty(candidate))
+ {
+ continue;
+ }
+
+ if (!Global.TryInspectViiperPnPTopology(candidate,
+ out ViiperPnPTopologyIdentity inspected))
+ {
+ continue;
+ }
+
+ ancestryComplete = true;
+ if (inspected.IsUsbDeviceResolved)
+ {
+ topology = inspected;
+ return true;
+ }
}
+
+ return ancestryComplete;
}
- private static int EndpointScore(MMDevice endpoint,
- ControllerAudioEndpointKind preferredKind, string previousEndpointId)
+ private static string NormalizeEndpointPnPPath(string value)
{
- int score = 0;
- ControllerAudioEndpointKind actualKind = ClassifyEndpoint(endpoint);
- if (preferredKind != ControllerAudioEndpointKind.Any && actualKind == preferredKind)
+ if (string.IsNullOrWhiteSpace(value))
{
- score += 100;
+ return string.Empty;
}
- else if (actualKind != ControllerAudioEndpointKind.Any)
+
+ string result = value.Trim().TrimEnd('\0');
+ int pathStart = result.IndexOf(@"\\?\",
+ StringComparison.Ordinal);
+ if (pathStart > 0)
{
- score += 20;
+ result = result.Substring(pathStart);
}
- string identity = GetEndpointIdentity(endpoint);
- if (identity.IndexOf("VIIPER", StringComparison.OrdinalIgnoreCase) >= 0)
+ return result;
+ }
+
+ private static void AddEndpointProperty(List values, MMDevice endpoint,
+ PropertyKey propertyKey)
+ {
+ try
{
- score += 10;
+ object value = endpoint.Properties[propertyKey]?.Value;
+ if (value != null)
+ {
+ values.Add(value.ToString());
+ }
}
-
- if (!string.IsNullOrEmpty(previousEndpointId) &&
- (string.Equals(endpoint.ID, previousEndpointId, StringComparison.Ordinal) ||
- EndpointReplaces(endpoint, previousEndpointId)))
+ catch
{
- score += 1000;
+ // Endpoint property availability differs by Windows audio driver.
}
-
- return score;
}
private static bool EndpointReplaces(MMDevice endpoint, string previousEndpointId)
diff --git a/DS4Windows/DS4Control/DualSenseBluetoothSpeakerPassthrough.cs b/DS4Windows/DS4Control/DualSenseBluetoothSpeakerPassthrough.cs
index c60a5d5..147715a 100644
--- a/DS4Windows/DS4Control/DualSenseBluetoothSpeakerPassthrough.cs
+++ b/DS4Windows/DS4Control/DualSenseBluetoothSpeakerPassthrough.cs
@@ -460,6 +460,7 @@ internal sealed class DualSenseBluetoothSpeakerPassthrough : IDisposable
private readonly byte speakerBassBoost;
private readonly DualSenseSpeakerProcessor speakerProcessor;
private readonly ViiperOutDevice directSpeakerSource;
+ private readonly int virtualOwnerToken;
private readonly int directSpeakerSampleRate;
private readonly DualSensePcm16SourceRateConverter directPcmRateConverter;
private readonly DualSenseSpeakerFrameResampler speakerFrameResampler =
@@ -565,7 +566,8 @@ internal sealed class DualSenseBluetoothSpeakerPassthrough : IDisposable
public DualSenseBluetoothSpeakerPassthrough(DualSenseDevice device, byte speakerVolume,
DualSenseSpeakerCompression speakerCompression, byte speakerBassBoost,
string sourceEndpointId, ControllerAudioEndpointKind sourceEndpointKind,
- ViiperOutDevice directSpeakerSource = null)
+ ViiperOutDevice directSpeakerSource = null,
+ int virtualOwnerToken = -1)
{
this.device = device ?? throw new ArgumentNullException(nameof(device));
speakerSessionId = this.device.CreateBluetoothSpeakerSession();
@@ -580,6 +582,7 @@ public DualSenseBluetoothSpeakerPassthrough(DualSenseDevice device, byte speaker
this.sourceEndpointId = sourceEndpointId ?? string.Empty;
this.sourceEndpointKind = sourceEndpointKind;
this.directSpeakerSource = directSpeakerSource;
+ this.virtualOwnerToken = virtualOwnerToken;
directSpeakerSampleRate = directSpeakerSource?.DirectSpeakerPcmSampleRate ?? 0;
if (directSpeakerSampleRate > 0)
{
@@ -634,7 +637,8 @@ public bool Matches(DualSenseDevice candidateDevice, byte candidateVolume,
DualSenseSpeakerCompression candidateCompression, byte candidateBassBoost,
string candidateSourceEndpointId,
ControllerAudioEndpointKind candidateSourceEndpointKind,
- ViiperOutDevice candidateDirectSpeakerSource = null)
+ ViiperOutDevice candidateDirectSpeakerSource = null,
+ int candidateVirtualOwnerToken = -1)
{
return !stopping && ReferenceEquals(device, candidateDevice) &&
speakerVolume == candidateVolume &&
@@ -644,6 +648,7 @@ public bool Matches(DualSenseDevice candidateDevice, byte candidateVolume,
speakerBassBoost == Math.Min(candidateBassBoost,
DualSenseSpeakerProcessor.MaximumBassBoostDb) &&
sourceEndpointKind == candidateSourceEndpointKind &&
+ virtualOwnerToken == candidateVirtualOwnerToken &&
ReferenceEquals(directSpeakerSource,
candidateDirectSpeakerSource) &&
string.Equals(sourceEndpointId, candidateSourceEndpointId ?? string.Empty,
@@ -715,7 +720,7 @@ public void Start()
}
capture = CreateCapture(sourceEndpointId, sourceEndpointKind,
- out string sourceName);
+ virtualOwnerToken, out string sourceName);
isGameAudioEndpoint = IsLikelyGameAudioEndpoint(sourceName);
captureBuffer = new BufferedWaveProvider(capture.WaveFormat)
{
@@ -764,7 +769,8 @@ public void Start()
}
private static IWaveIn CreateCapture(string endpointId,
- ControllerAudioEndpointKind endpointKind, out string sourceName)
+ ControllerAudioEndpointKind endpointKind, int virtualOwnerToken,
+ out string sourceName)
{
if (ProcessLoopbackWaveCapture.TryParseAutomaticEndpointId(
endpointId, out int automaticSlot))
@@ -817,6 +823,15 @@ private static IWaveIn CreateCapture(string endpointId,
endpoint?.Dispose();
endpoint = null;
}
+ else if (DualSenseAudioPassthrough.
+ IsControllerAudioEndpoint(endpoint) &&
+ !DualSenseAudioPassthrough.
+ EndpointMatchesOwnerToken(endpoint,
+ virtualOwnerToken))
+ {
+ endpoint.Dispose();
+ endpoint = null;
+ }
}
catch (COMException)
{
@@ -827,7 +842,8 @@ private static IWaveIn CreateCapture(string endpointId,
if (endpoint == null)
{
endpoint = DualSenseAudioPassthrough.FindActiveGameAudioEndpoint(enumerator,
- autoDetectGameAudio ? null : endpointId, endpointKind);
+ autoDetectGameAudio ? null : endpointId, endpointKind,
+ virtualOwnerToken);
}
if (endpoint == null)
diff --git a/DS4Windows/DS4Control/DualShock4AudioPassthrough.cs b/DS4Windows/DS4Control/DualShock4AudioPassthrough.cs
index 6edf7c5..35b0348 100644
--- a/DS4Windows/DS4Control/DualShock4AudioPassthrough.cs
+++ b/DS4Windows/DS4Control/DualShock4AudioPassthrough.cs
@@ -60,25 +60,29 @@ public void Start(int slot, DS4Device device, byte speakerVolume,
int generation;
ControllerAudioEndpointKind endpointKind =
DualSenseAudioPassthrough.GetEndpointKind(emulatedControllerType);
+ int virtualOwnerToken = ViiperPnPOwnershipRegistry.GetToken(
+ directSpeakerSource);
DirectSpeakerRouteDecision initialRoute =
DualSenseAudioPassthrough.EvaluateDirectSpeakerRoute(
captureEndpointId, endpointKind, directSpeakerSource);
if (initialRoute == DirectSpeakerRouteDecision.Loopback)
{
directSpeakerSource = null;
+ virtualOwnerToken = -1;
}
lock (syncRoot)
{
if (slots[slot]?.Matches(device, speakerVolume, compression,
bassBoost, captureEndpointId, endpointKind,
- directSpeakerSource) == true)
+ directSpeakerSource, virtualOwnerToken) == true)
{
return;
}
if (pendingStarts[slot]?.Matches(device, speakerVolume,
compression, bassBoost, captureEndpointId,
- endpointKind, directSpeakerSource) == true)
+ endpointKind, directSpeakerSource,
+ virtualOwnerToken) == true)
{
return;
}
@@ -89,7 +93,7 @@ public void Start(int slot, DS4Device device, byte speakerVolume,
generation = ++startGenerations[slot];
pendingStarts[slot] = new StartRequest(device, speakerVolume,
compression, bassBoost, captureEndpointId, endpointKind,
- directSpeakerSource, generation);
+ directSpeakerSource, virtualOwnerToken, generation);
}
AppLogger.LogToGui(
@@ -103,7 +107,7 @@ public void Start(int slot, DS4Device device, byte speakerVolume,
previous?.Dispose();
StartWorker(slot, device, speakerVolume, compression,
bassBoost, captureEndpointId, endpointKind,
- directSpeakerSource, generation);
+ directSpeakerSource, virtualOwnerToken, generation);
}
}, $"DualShock 4 audio startup {slot + 1}");
}
@@ -186,7 +190,8 @@ public void Dispose()
private void StartWorker(int slot, DS4Device device, byte speakerVolume,
DualSenseSpeakerCompression compression, byte bassBoost,
string captureEndpointId, ControllerAudioEndpointKind endpointKind,
- ViiperOutDevice directSpeakerSource, int generation)
+ ViiperOutDevice directSpeakerSource, int virtualOwnerToken,
+ int generation)
{
const int attempts = 20;
Exception lastError = null;
@@ -216,12 +221,15 @@ private void StartWorker(int slot, DS4Device device, byte speakerVolume,
DirectSpeakerRouteDecision route =
DualSenseAudioPassthrough.EvaluateDirectSpeakerRoute(
captureEndpointId, endpointKind, directSpeakerSource);
+ virtualOwnerToken = ViiperPnPOwnershipRegistry.GetToken(
+ directSpeakerSource);
ViiperOutDevice activeDirectSpeakerSource =
route == DirectSpeakerRouteDecision.Direct ?
directSpeakerSource : null;
var playback = new DualShock4BluetoothSpeakerPassthrough(device,
speakerVolume, compression, bassBoost, captureEndpointId,
- endpointKind, activeDirectSpeakerSource);
+ endpointKind, activeDirectSpeakerSource,
+ virtualOwnerToken);
try
{
playback.Start();
@@ -308,12 +316,14 @@ private sealed class StartRequest
private readonly string sourceEndpointId;
private readonly ControllerAudioEndpointKind sourceEndpointKind;
private readonly ViiperOutDevice directSpeakerSource;
+ private readonly int virtualOwnerToken;
public StartRequest(DS4Device device, byte speakerVolume,
DualSenseSpeakerCompression compression, byte bassBoost,
string sourceEndpointId,
ControllerAudioEndpointKind sourceEndpointKind,
- ViiperOutDevice directSpeakerSource, int generation)
+ ViiperOutDevice directSpeakerSource, int virtualOwnerToken,
+ int generation)
{
this.device = device;
this.speakerVolume = speakerVolume;
@@ -326,6 +336,7 @@ public StartRequest(DS4Device device, byte speakerVolume,
this.sourceEndpointId = sourceEndpointId ?? string.Empty;
this.sourceEndpointKind = sourceEndpointKind;
this.directSpeakerSource = directSpeakerSource;
+ this.virtualOwnerToken = virtualOwnerToken;
Generation = generation;
}
@@ -335,7 +346,8 @@ public bool Matches(DS4Device candidate, byte candidateVolume,
DualSenseSpeakerCompression candidateCompression,
byte candidateBassBoost, string candidateSourceEndpointId,
ControllerAudioEndpointKind candidateSourceEndpointKind,
- ViiperOutDevice candidateDirectSpeakerSource)
+ ViiperOutDevice candidateDirectSpeakerSource,
+ int candidateVirtualOwnerToken)
{
return ReferenceEquals(device, candidate) &&
speakerVolume == candidateVolume &&
@@ -347,6 +359,7 @@ public bool Matches(DS4Device candidate, byte candidateVolume,
bassBoost == Math.Min(candidateBassBoost,
DualSenseSpeakerProcessor.MaximumBassBoostDb) &&
sourceEndpointKind == candidateSourceEndpointKind &&
+ virtualOwnerToken == candidateVirtualOwnerToken &&
ReferenceEquals(directSpeakerSource,
candidateDirectSpeakerSource) &&
string.Equals(sourceEndpointId,
diff --git a/DS4Windows/DS4Control/DualShock4BluetoothSpeakerPassthrough.cs b/DS4Windows/DS4Control/DualShock4BluetoothSpeakerPassthrough.cs
index 1315d0f..7cd07f3 100644
--- a/DS4Windows/DS4Control/DualShock4BluetoothSpeakerPassthrough.cs
+++ b/DS4Windows/DS4Control/DualShock4BluetoothSpeakerPassthrough.cs
@@ -285,6 +285,7 @@ private enum CreditBufferedSubmissionResult
private readonly string sourceEndpointId;
private readonly ControllerAudioEndpointKind sourceEndpointKind;
private readonly ViiperOutDevice directSpeakerSource;
+ private readonly int virtualOwnerToken;
private readonly int directSpeakerSampleRate;
private readonly DualShock4AudioDriftMode directDriftMode;
private readonly DualShock4AudioTransportMode directTransportMode;
@@ -432,7 +433,8 @@ private readonly StereoPcm16FractionalResampler
public DualShock4BluetoothSpeakerPassthrough(DS4Device device, byte speakerVolume,
DualSenseSpeakerCompression compression, byte bassBoost,
string sourceEndpointId, ControllerAudioEndpointKind sourceEndpointKind,
- ViiperOutDevice directSpeakerSource = null)
+ ViiperOutDevice directSpeakerSource = null,
+ int virtualOwnerToken = -1)
{
this.device = device ?? throw new ArgumentNullException(nameof(device));
this.speakerVolume = speakerVolume;
@@ -444,6 +446,7 @@ public DualShock4BluetoothSpeakerPassthrough(DS4Device device, byte speakerVolum
this.sourceEndpointId = sourceEndpointId ?? string.Empty;
this.sourceEndpointKind = sourceEndpointKind;
this.directSpeakerSource = directSpeakerSource;
+ this.virtualOwnerToken = virtualOwnerToken;
directSpeakerSampleRate = directSpeakerSource?.
DirectSpeakerPcmSampleRate ?? 0;
directDriftMode = DualShock4AudioDriftSettings.Parse(
@@ -477,7 +480,8 @@ public bool Matches(DS4Device candidate, byte candidateVolume,
DualSenseSpeakerCompression candidateCompression, byte candidateBassBoost,
string candidateSourceEndpointId,
ControllerAudioEndpointKind candidateSourceEndpointKind,
- ViiperOutDevice candidateDirectSpeakerSource = null)
+ ViiperOutDevice candidateDirectSpeakerSource = null,
+ int candidateVirtualOwnerToken = -1)
{
return !stopping && ReferenceEquals(device, candidate) &&
speakerVolume == candidateVolume &&
@@ -487,6 +491,7 @@ public bool Matches(DS4Device candidate, byte candidateVolume,
bassBoost == Math.Min(candidateBassBoost,
DualSenseSpeakerProcessor.MaximumBassBoostDb) &&
sourceEndpointKind == candidateSourceEndpointKind &&
+ virtualOwnerToken == candidateVirtualOwnerToken &&
ReferenceEquals(directSpeakerSource,
candidateDirectSpeakerSource) &&
string.Equals(sourceEndpointId, candidateSourceEndpointId ?? string.Empty,
@@ -554,7 +559,7 @@ public void Start()
try
{
capture = CreateCapture(sourceEndpointId, sourceEndpointKind,
- out string sourceName);
+ virtualOwnerToken, out string sourceName);
captureBuffer = new BufferedWaveProvider(capture.WaveFormat)
{
BufferDuration = TimeSpan.FromMilliseconds(CaptureBufferMs),
@@ -595,7 +600,8 @@ public void Start()
}
private static WasapiCapture CreateCapture(string endpointId,
- ControllerAudioEndpointKind endpointKind, out string sourceName)
+ ControllerAudioEndpointKind endpointKind, int virtualOwnerToken,
+ out string sourceName)
{
bool useSystemDefault = string.Equals(endpointId,
DualSenseAudioPassthrough.DefaultSystemAudioEndpointId,
@@ -624,6 +630,15 @@ private static WasapiCapture CreateCapture(string endpointId,
endpoint?.Dispose();
endpoint = null;
}
+ else if (DualSenseAudioPassthrough.
+ IsControllerAudioEndpoint(endpoint) &&
+ !DualSenseAudioPassthrough.
+ EndpointMatchesOwnerToken(endpoint,
+ virtualOwnerToken))
+ {
+ endpoint.Dispose();
+ endpoint = null;
+ }
}
catch (COMException)
{
@@ -634,7 +649,8 @@ private static WasapiCapture CreateCapture(string endpointId,
if (endpoint == null)
{
endpoint = DualSenseAudioPassthrough.FindActiveGameAudioEndpoint(
- enumerator, autoDetect ? null : endpointId, endpointKind);
+ enumerator, autoDetect ? null : endpointId, endpointKind,
+ virtualOwnerToken);
}
if (endpoint == null)
diff --git a/DS4Windows/DS4Control/OutputSlotManager.cs b/DS4Windows/DS4Control/OutputSlotManager.cs
index 846e848..774bd0b 100644
--- a/DS4Windows/DS4Control/OutputSlotManager.cs
+++ b/DS4Windows/DS4Control/OutputSlotManager.cs
@@ -173,15 +173,15 @@ public void DeferredPlugin(OutputDevice outputDevice, int inIdx, string inDispla
ControlService.StartupDiag($"OutputSlotManager.DeferredPlugin emptySlot={slot + 1} inIdx={inIdx} contType={contType}");
if (slot != -1)
{
- // Record every VIIPER Sony output so its complete USB/IP
- // HID cannot be re-ingested as a physical input.
- HashSet beforeVirtualSony = null;
- if (contType == OutContType.ViiperDS4 ||
+ // Fence Sony creation until its authoritative VIIPER
+ // lifetime can be registered. This prevents the arriving
+ // native HID from racing input discovery.
+ bool registerVirtualSony =
+ contType == OutContType.ViiperDS4 ||
contType == OutContType.ViiperDualSense ||
- contType == OutContType.ViiperDualSenseEdge)
+ contType == OutContType.ViiperDualSenseEdge;
+ if (registerVirtualSony)
{
- beforeVirtualSony = DS4Devices.
- SnapshotBeforeOwnVirtualSony();
DS4Devices.BeginOwnVirtualSonyConnect();
}
@@ -198,7 +198,7 @@ public void DeferredPlugin(OutputDevice outputDevice, int inIdx, string inDispla
//queuedTasks--;
AppLogger.LogToGui($"Failed to plug in virtual {contType.ToDisplayName()} controller: {e.Message}", true);
- if (beforeVirtualSony != null)
+ if (registerVirtualSony)
{
DS4Devices.EndOwnVirtualSonyConnect();
}
@@ -208,7 +208,7 @@ public void DeferredPlugin(OutputDevice outputDevice, int inIdx, string inDispla
catch (Exception e)
{
AppLogger.LogToGui($"Failed to plug in virtual {contType.ToDisplayName()} controller: {e.Message}", true);
- if (beforeVirtualSony != null)
+ if (registerVirtualSony)
{
DS4Devices.EndOwnVirtualSonyConnect();
}
@@ -216,13 +216,38 @@ public void DeferredPlugin(OutputDevice outputDevice, int inIdx, string inDispla
return;
}
- if (beforeVirtualSony != null)
+ if (registerVirtualSony)
{
- DS4Devices.RegisterOwnVirtualSonyAsync(
- beforeVirtualSony, virtualSonyRegisteredCallback);
+ ViiperOutDevice viiperSource =
+ outputDevice as ViiperOutDevice;
+ int ownerToken =
+ ViiperPnPOwnershipRegistry.AttachOrUpdate(
+ viiperSource);
+ if (ownerToken <= 0)
+ {
+ try
+ {
+ outputDevice.Disconnect();
+ }
+ catch (Exception disconnectException)
+ {
+ ControlService.StartupDiag(
+ $"OutputSlotManager exact-identity rollback failed slot={slot + 1} type={contType} {disconnectException.GetType().Name}: {disconnectException.Message}");
+ }
+
+ ViiperPnPOwnershipRegistry.Detach(viiperSource);
+ DS4Devices.EndOwnVirtualSonyConnect();
+ AppLogger.LogToGui(
+ $"Failed to plug in virtual {contType.ToDisplayName()} controller: VIIPER did not return the exact native PnP identity required to contain its Sony HID and audio interfaces.",
+ true);
+ return;
+ }
+
+ DS4Devices.RegisterOwnVirtualSonyAsync(viiperSource,
+ virtualSonyRegisteredCallback);
// The asynchronous registration worker now owns the
// matching EndOwnVirtualSonyConnect call.
- beforeVirtualSony = null;
+ registerVirtualSony = false;
}
AppLogger.LogToGui($"Plugging in virtual {contType.ToDisplayName()} Controller in output slot #{slot + 1}", false);
@@ -271,7 +296,7 @@ public void DeferredRemoval(OutputDevice outputDevice, int inIdx,
outputDevice.RemoveFeedbacks();
ControlService.StartupDiag($"OutputSlotManager.RemoveFeedbacks end slot={slot + 1}");
ControlService.StartupDiag($"OutputSlotManager.Disconnect begin slot={slot + 1}");
- outputDevice.Disconnect();
+ DisconnectAndDetachOwnership(outputDevice);
ControlService.StartupDiag($"OutputSlotManager.Disconnect end slot={slot + 1}");
if (inIdx != -1)
@@ -387,7 +412,7 @@ public void UnplugRemainingControllers(bool immediate=false)
if (device.OutputDevice != null)
{
outputDevices[slotIdx] = null;
- device.OutputDevice.Disconnect();
+ DisconnectAndDetachOwnership(device.OutputDevice);
device.DetachDevice();
SlotUnassigned?.Invoke(this, slotIdx, outputSlots[slotIdx]);
@@ -403,5 +428,19 @@ public void UnplugRemainingControllers(bool immediate=false)
//queuedTasks--;
}
+
+ private static void DisconnectAndDetachOwnership(
+ OutputDevice outputDevice)
+ {
+ try
+ {
+ outputDevice?.Disconnect();
+ }
+ finally
+ {
+ ViiperPnPOwnershipRegistry.Detach(
+ outputDevice as ViiperOutDevice);
+ }
+ }
}
}
diff --git a/DS4Windows/DS4Control/ScpUtil.cs b/DS4Windows/DS4Control/ScpUtil.cs
index 68fc1a1..792db60 100644
--- a/DS4Windows/DS4Control/ScpUtil.cs
+++ b/DS4Windows/DS4Control/ScpUtil.cs
@@ -1346,92 +1346,213 @@ public static bool CheckIfVirtualDevice(string devicePath)
return result;
}
- // VIIPER exposes its USB devices through usbip-win2's emulated host
- // controller. Moonlight mode deliberately accepts some virtual DS4s,
- // so CheckIfVirtualDevice alone cannot be used to reject our own
- // VIIPER output. Keep this identity check separate and explicit.
- public static bool CheckIfUsbIpWin2Device(string devicePath)
+ private const string ViiperNativeUdeHardwareId = @"ROOT\VIIPERUDE";
+ private const string UsbIpWin2HardwareId = @"ROOT\USBIP_WIN2\UDE";
+
+ // Moonlight mode deliberately accepts some virtual DS4s, so the broad
+ // virtual-device classifier cannot reject DS4Windows' own output. Walk
+ // the complete PnP ancestry and retain the exact emulated USB device,
+ // controller root, and port instead. The result is transport-neutral:
+ // native UdeCx is the production path and USB/IP remains available only
+ // to the explicit legacy validation path.
+ internal static bool TryResolveViiperPnPTopology(string devicePath,
+ out ViiperPnPTopologyIdentity topology)
{
- return TryResolveUsbIpWin2Device(devicePath,
- out bool usbIpWin2AncestorFound, out _) &&
- usbIpWin2AncestorFound;
+ return TryInspectViiperPnPTopology(devicePath, out topology) &&
+ topology.IsResolved;
}
- internal static bool TryGetUsbIpWin2Port(string devicePath, out int port)
+ internal static bool TryInspectViiperPnPTopology(string devicePath,
+ out ViiperPnPTopologyIdentity topology)
{
- return TryResolveUsbIpWin2Device(devicePath,
- out bool usbIpWin2AncestorFound, out port) &&
- usbIpWin2AncestorFound && port >= 0;
+ topology = default;
+ if (string.IsNullOrWhiteSpace(devicePath))
+ {
+ return false;
+ }
+
+ string testInstanceId = LooksLikeDeviceInstanceId(devicePath) ?
+ devicePath : GetInstanceIdFromDevicePath(devicePath);
+ if (string.IsNullOrWhiteSpace(testInstanceId))
+ {
+ return false;
+ }
+
+ var ancestry = new List();
+ bool complete = false;
+ for (int depth = 0; depth < 32 &&
+ !string.IsNullOrWhiteSpace(testInstanceId); depth++)
+ {
+ string[] hardwareIds = GetStringArrayDeviceProperty(
+ testInstanceId, NativeMethods.DEVPKEY_Device_HardwareIds) ??
+ Array.Empty();
+ string location = GetStringDeviceProperty(testInstanceId,
+ NativeMethods.DEVPKEY_Device_LocationInfo);
+ string parentInstanceId = GetStringDeviceProperty(
+ testInstanceId, NativeMethods.DEVPKEY_Device_Parent);
+
+ ancestry.Add(new ViiperPnPAncestryNode(testInstanceId,
+ parentInstanceId, hardwareIds, location));
+
+ if (parentInstanceId.Equals(@"HTREE\ROOT\0",
+ StringComparison.OrdinalIgnoreCase))
+ {
+ complete = true;
+ break;
+ }
+
+ if (string.IsNullOrWhiteSpace(parentInstanceId))
+ {
+ // A disappearing interface or a PnP tree still being
+ // published is unresolved, not a physical controller.
+ return false;
+ }
+
+ testInstanceId = parentInstanceId;
+ }
+
+ if (!complete)
+ {
+ return false;
+ }
+
+ TryClassifyViiperPnPAncestry(ancestry, out topology);
+ return true;
}
- internal static bool TryResolveUsbIpWin2Device(string devicePath,
- out bool usbIpWin2AncestorFound, out int port)
+ internal static bool TryClassifyViiperPnPAncestry(
+ IEnumerable ancestry,
+ out ViiperPnPTopologyIdentity topology)
{
- const string usbIpWin2HardwareId = @"ROOT\USBIP_WIN2\UDE";
- string testInstanceId = GetInstanceIdFromDevicePath(devicePath);
- usbIpWin2AncestorFound = false;
- port = -1;
- if (string.IsNullOrEmpty(testInstanceId))
+ topology = default;
+ if (ancestry == null)
{
return false;
}
- int discoveredPort = -1;
+ string usbDeviceInstanceId = string.Empty;
+ int usbPort = -1;
+ string rootInstanceId = string.Empty;
+ ViiperPnPTransport transport = ViiperPnPTransport.Unknown;
- for (int depth = 0; depth < 16 && !string.IsNullOrEmpty(testInstanceId); depth++)
+ foreach (ViiperPnPAncestryNode node in ancestry)
{
- string[] hardwareIds = GetStringArrayDeviceProperty(testInstanceId,
- NativeMethods.DEVPKEY_Device_HardwareIds);
- if (hardwareIds != null && hardwareIds.Any(id =>
- string.Equals(id, usbIpWin2HardwareId,
- StringComparison.OrdinalIgnoreCase)))
+ string instanceId = node.InstanceId ?? string.Empty;
+ if (string.IsNullOrEmpty(rootInstanceId) &&
+ string.Equals(node.ParentInstanceId, @"HTREE\ROOT\0",
+ StringComparison.OrdinalIgnoreCase))
{
- usbIpWin2AncestorFound = true;
- port = discoveredPort;
- return true;
+ rootInstanceId = instanceId;
}
- if (discoveredPort < 0 &&
- testInstanceId.StartsWith(@"USB\VID_",
+ if (string.IsNullOrEmpty(usbDeviceInstanceId) &&
+ instanceId.StartsWith(@"USB\VID_",
StringComparison.OrdinalIgnoreCase) &&
- testInstanceId.IndexOf("&MI_",
+ instanceId.IndexOf("&MI_",
StringComparison.OrdinalIgnoreCase) < 0)
{
- string location = GetStringDeviceProperty(testInstanceId,
- NativeMethods.DEVPKEY_Device_LocationInfo);
- Match match = Regex.Match(location ?? string.Empty,
+ usbDeviceInstanceId = instanceId;
+ Match match = Regex.Match(node.LocationInfo ?? string.Empty,
@"Port_#(?\d+)", RegexOptions.IgnoreCase);
if (match.Success && int.TryParse(
- match.Groups["port"].Value,
- NumberStyles.None, CultureInfo.InvariantCulture,
- out int parsedPort))
+ match.Groups["port"].Value, NumberStyles.None,
+ CultureInfo.InvariantCulture, out int parsedPort))
{
- discoveredPort = parsedPort;
+ usbPort = parsedPort;
}
}
- string parentInstanceId = GetStringDeviceProperty(testInstanceId,
- NativeMethods.DEVPKEY_Device_Parent);
- if (parentInstanceId.Equals(@"HTREE\ROOT\0",
- StringComparison.OrdinalIgnoreCase))
+ if (NodeHasIdentity(node, ViiperNativeUdeHardwareId))
{
- port = discoveredPort;
- return true;
+ transport = ViiperPnPTransport.NativeUdeCx;
+ rootInstanceId = instanceId;
+ break;
}
- if (string.IsNullOrEmpty(parentInstanceId))
+ if (NodeHasIdentity(node, UsbIpWin2HardwareId))
{
- // A missing parent before HTREE\ROOT means the PnP tree is
- // not ready (or the interface vanished mid-query). Do not
- // misclassify that transient as a physical USB endpoint.
- return false;
+ transport = ViiperPnPTransport.LegacyUsbIp;
+ rootInstanceId = instanceId;
+ break;
}
+ }
- testInstanceId = parentInstanceId;
+ topology = new ViiperPnPTopologyIdentity(transport,
+ rootInstanceId, usbDeviceInstanceId, usbPort);
+ return topology.IsResolved;
+ }
+
+ private static bool NodeHasIdentity(ViiperPnPAncestryNode node,
+ string hardwareId)
+ {
+ string instanceId = node.InstanceId ?? string.Empty;
+ if (string.Equals(instanceId, hardwareId,
+ StringComparison.OrdinalIgnoreCase) ||
+ instanceId.StartsWith(hardwareId + @"\",
+ StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
}
- // Depth exhaustion is not a completed ancestry query.
- return false;
+ return node.HardwareIds != null && node.HardwareIds.Any(id =>
+ string.Equals(id?.TrimEnd('\0'), hardwareId,
+ StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static bool LooksLikeDeviceInstanceId(string value)
+ {
+ return value.StartsWith(@"HID\",
+ StringComparison.OrdinalIgnoreCase) ||
+ value.StartsWith(@"USB\",
+ StringComparison.OrdinalIgnoreCase) ||
+ value.StartsWith(@"ROOT\",
+ StringComparison.OrdinalIgnoreCase) ||
+ value.StartsWith(@"SWD\",
+ StringComparison.OrdinalIgnoreCase);
+ }
+
+ public static bool CheckIfViiperNativeUdeDevice(string devicePath)
+ {
+ return TryResolveViiperPnPTopology(devicePath,
+ out ViiperPnPTopologyIdentity topology) &&
+ topology.Transport == ViiperPnPTransport.NativeUdeCx;
+ }
+
+ // Compatibility wrappers for the explicitly selected legacy USB/IP
+ // ABBA/dev-validation path. Production ownership does not call these.
+ public static bool CheckIfUsbIpWin2Device(string devicePath)
+ {
+ return TryResolveUsbIpWin2Device(devicePath,
+ out bool usbIpWin2AncestorFound, out _) &&
+ usbIpWin2AncestorFound;
+ }
+
+ internal static bool TryGetUsbIpWin2Port(string devicePath, out int port)
+ {
+ return TryResolveUsbIpWin2Device(devicePath,
+ out bool usbIpWin2AncestorFound, out port) &&
+ usbIpWin2AncestorFound && port >= 0;
+ }
+
+ internal static bool TryResolveUsbIpWin2Device(string devicePath,
+ out bool usbIpWin2AncestorFound, out int port)
+ {
+ usbIpWin2AncestorFound = false;
+ port = -1;
+ if (!TryInspectViiperPnPTopology(devicePath,
+ out ViiperPnPTopologyIdentity topology))
+ {
+ return false;
+ }
+
+ usbIpWin2AncestorFound =
+ topology.Transport == ViiperPnPTransport.LegacyUsbIp;
+ if (usbIpWin2AncestorFound)
+ {
+ port = topology.UsbPortNumber;
+ }
+
+ return true;
}
public static void FindConfigLocation()
diff --git a/DS4Windows/DS4Control/Util.cs b/DS4Windows/DS4Control/Util.cs
index 346f7e1..048833e 100644
--- a/DS4Windows/DS4Control/Util.cs
+++ b/DS4Windows/DS4Control/Util.cs
@@ -287,48 +287,6 @@ public static void LogAssistBackgroundTask(Task task)
});
}
- public static int ElevatedCopyUpdater(string tmpUpdaterPath, bool deleteUpdatesDir=false)
- {
- int result = -1;
- string tmpPath = Path.Combine(Path.GetTempPath(), "updatercopy.bat");
- //string tmpPath = Path.GetTempFileName();
- // Create temporary bat script that will later be executed
- using (StreamWriter w = new StreamWriter(new FileStream(tmpPath,
- FileMode.Create, FileAccess.Write)))
- {
- w.WriteLine("@echo off"); // Turn off echo
- w.WriteLine("@echo Attempting to replace updater, please wait...");
- // Move temp downloaded file to destination
- w.WriteLine($"@mov /Y \"{tmpUpdaterPath}\" \"{Global.exedirpath}\\DS4Updater.exe\"");
- if (deleteUpdatesDir)
- {
- w.WriteLine($"@del /S \"{Global.exedirpath}\\Update Files\\DS4Windows\"");
- }
-
- w.Close();
- }
-
- // Execute temp batch script with admin privileges
- ProcessStartInfo startInfo = new ProcessStartInfo();
- startInfo.FileName = tmpPath;
- startInfo.WindowStyle = ProcessWindowStyle.Hidden;
- startInfo.Verb = "runas";
- startInfo.UseShellExecute = true;
- startInfo.CreateNoWindow = true;
- try
- {
- // Launch process, wait and then save exit code
- using (Process temp = Process.Start(startInfo))
- {
- temp.WaitForExit();
- result = temp.ExitCode;
- }
- }
- catch { }
-
- return result;
- }
-
public static string GetOSProductName()
{
string productName =
diff --git a/DS4Windows/DS4Control/Viiper/ViiperAuthenticatedStream.cs b/DS4Windows/DS4Control/Viiper/ViiperAuthenticatedStream.cs
new file mode 100644
index 0000000..495415d
--- /dev/null
+++ b/DS4Windows/DS4Control/Viiper/ViiperAuthenticatedStream.cs
@@ -0,0 +1,667 @@
+/*
+ DS4Windows
+ Copyright (C) 2026 hbashton
+
+ 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.
+*/
+
+using System;
+using System.Buffers.Binary;
+using System.IO;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading;
+
+namespace DS4Windows
+{
+ ///
+ /// Implements the current VIIPER authentication handshake. The constants,
+ /// byte order, and key schedule are shared with
+ /// internal/server/api/auth in the VIIPER source tree.
+ ///
+ internal static class ViiperAuthProtocol
+ {
+ internal const int NonceSize = 32;
+ internal const int SessionKeySize = 32;
+ internal const int Pbkdf2Iterations = 100000;
+ internal const string HandshakeMagic = "eVI1\0";
+ internal const string AuthenticationContext = "VIIPER-Auth-v1";
+ internal const string SessionContext = "VIIPER-Session-v1";
+ internal const string Pbkdf2Salt = "VIIPER-Key-v1";
+
+ internal static Stream AuthenticateClient(Stream transport,
+ string password)
+ {
+ byte[] clientNonce = new byte[NonceSize];
+ RandomNumberGenerator.Fill(clientNonce);
+ return AuthenticateClient(transport, password, clientNonce);
+ }
+
+ internal static Stream AuthenticateClient(Stream transport,
+ string password, byte[] clientNonce)
+ {
+ if (transport == null)
+ {
+ throw new ArgumentNullException(nameof(transport));
+ }
+ if (string.IsNullOrEmpty(password))
+ {
+ throw new IOException("The VIIPER API credential is empty.");
+ }
+ if (clientNonce == null || clientNonce.Length != NonceSize)
+ {
+ throw new ArgumentException(
+ $"The VIIPER client nonce must be exactly {NonceSize} bytes.",
+ nameof(clientNonce));
+ }
+
+ byte[] passwordKey = DerivePasswordKey(password);
+ byte[] authenticationData = new byte[
+ Encoding.UTF8.GetByteCount(AuthenticationContext) + NonceSize];
+ byte[] handshake = new byte[
+ Encoding.UTF8.GetByteCount(HandshakeMagic) + NonceSize +
+ SessionKeySize];
+ byte[] serverNonce = new byte[NonceSize];
+ byte[] sessionKey = null;
+ try
+ {
+ int contextLength = Encoding.UTF8.GetBytes(
+ AuthenticationContext, authenticationData);
+ Buffer.BlockCopy(clientNonce, 0, authenticationData,
+ contextLength, NonceSize);
+
+ byte[] authenticationTag = HMACSHA256.HashData(passwordKey,
+ authenticationData);
+ try
+ {
+ int magicLength = Encoding.UTF8.GetBytes(HandshakeMagic,
+ handshake);
+ Buffer.BlockCopy(clientNonce, 0, handshake, magicLength,
+ NonceSize);
+ Buffer.BlockCopy(authenticationTag, 0, handshake,
+ magicLength + NonceSize, authenticationTag.Length);
+ transport.Write(handshake, 0, handshake.Length);
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(authenticationTag);
+ }
+
+ byte[] responsePrefix = new byte[3];
+ ReadExactly(transport, responsePrefix, 0,
+ responsePrefix.Length,
+ "VIIPER closed during the authentication response.");
+ if (responsePrefix[0] != (byte)'O' ||
+ responsePrefix[1] != (byte)'K' || responsePrefix[2] != 0)
+ {
+ throw ReadAuthenticationError(transport, responsePrefix);
+ }
+
+ ReadExactly(transport, serverNonce, 0, serverNonce.Length,
+ "VIIPER closed before returning its server nonce.");
+ sessionKey = DeriveSessionKey(passwordKey, serverNonce,
+ clientNonce);
+ Stream encrypted = new ViiperEncryptedStream(transport,
+ sessionKey);
+ CryptographicOperations.ZeroMemory(sessionKey);
+ sessionKey = null;
+ return encrypted;
+ }
+ catch
+ {
+ transport.Dispose();
+ throw;
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(passwordKey);
+ CryptographicOperations.ZeroMemory(authenticationData);
+ CryptographicOperations.ZeroMemory(handshake);
+ CryptographicOperations.ZeroMemory(serverNonce);
+ if (sessionKey != null)
+ {
+ CryptographicOperations.ZeroMemory(sessionKey);
+ }
+ }
+ }
+
+ internal static byte[] DerivePasswordKey(string password)
+ {
+ if (string.IsNullOrEmpty(password))
+ {
+ throw new ArgumentException("Password cannot be empty.",
+ nameof(password));
+ }
+
+ return Rfc2898DeriveBytes.Pbkdf2(
+ Encoding.UTF8.GetBytes(password),
+ Encoding.UTF8.GetBytes(Pbkdf2Salt),
+ Pbkdf2Iterations,
+ HashAlgorithmName.SHA256,
+ SessionKeySize);
+ }
+
+ internal static byte[] DeriveSessionKey(byte[] passwordKey,
+ byte[] serverNonce, byte[] clientNonce)
+ {
+ if (passwordKey == null || passwordKey.Length != SessionKeySize)
+ {
+ throw new ArgumentException(
+ $"The VIIPER password key must be {SessionKeySize} bytes.",
+ nameof(passwordKey));
+ }
+ if (serverNonce == null || serverNonce.Length != NonceSize)
+ {
+ throw new ArgumentException(
+ $"The VIIPER server nonce must be {NonceSize} bytes.",
+ nameof(serverNonce));
+ }
+ if (clientNonce == null || clientNonce.Length != NonceSize)
+ {
+ throw new ArgumentException(
+ $"The VIIPER client nonce must be {NonceSize} bytes.",
+ nameof(clientNonce));
+ }
+
+ byte[] context = Encoding.UTF8.GetBytes(SessionContext);
+ byte[] material = new byte[passwordKey.Length +
+ serverNonce.Length + clientNonce.Length + context.Length];
+ try
+ {
+ int offset = 0;
+ Buffer.BlockCopy(passwordKey, 0, material, offset,
+ passwordKey.Length);
+ offset += passwordKey.Length;
+ Buffer.BlockCopy(serverNonce, 0, material, offset,
+ serverNonce.Length);
+ offset += serverNonce.Length;
+ Buffer.BlockCopy(clientNonce, 0, material, offset,
+ clientNonce.Length);
+ offset += clientNonce.Length;
+ Buffer.BlockCopy(context, 0, material, offset,
+ context.Length);
+ return SHA256.HashData(material);
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(material);
+ }
+ }
+
+ private static ViiperAuthenticationException ReadAuthenticationError(
+ Stream transport,
+ byte[] prefix)
+ {
+ const int maximumErrorBytes = 64 * 1024;
+ using MemoryStream response = new MemoryStream();
+ response.Write(prefix, 0, prefix.Length);
+ byte[] buffer = new byte[1024];
+ try
+ {
+ while (response.Length < maximumErrorBytes)
+ {
+ int read = transport.Read(buffer, 0, Math.Min(buffer.Length,
+ maximumErrorBytes - (int)response.Length));
+ if (read == 0)
+ {
+ break;
+ }
+ response.Write(buffer, 0, read);
+ }
+ }
+ catch (IOException)
+ {
+ // Preserve the server bytes already received. Authentication
+ // still fails closed even if the diagnostic tail was cut off.
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(buffer);
+ }
+
+ string detail = Encoding.UTF8.GetString(response.ToArray())
+ .TrimEnd('\0', '\r', '\n');
+ return new ViiperAuthenticationException(
+ string.IsNullOrEmpty(detail) ?
+ "VIIPER rejected the authentication handshake." :
+ $"VIIPER rejected the authentication handshake: {detail}");
+ }
+
+ private static void ReadExactly(Stream stream, byte[] buffer,
+ int offset, int count, string closedMessage)
+ {
+ int total = 0;
+ while (total < count)
+ {
+ int read = stream.Read(buffer, offset + total, count - total);
+ if (read == 0)
+ {
+ throw new IOException(closedMessage);
+ }
+ total += read;
+ }
+ }
+ }
+
+ ///
+ /// VIIPER authenticated record stream. Client records use nonce domain 0;
+ /// server records must use nonce domain 1. Both directions require an
+ /// exact monotonically increasing 64-bit counter.
+ ///
+ internal sealed class ViiperEncryptedStream : Stream
+ {
+ internal const int MaximumRecordSize = 2 * 1024 * 1024;
+ internal const int NonceSize = 12;
+ internal const int TagSize = 16;
+ internal const int RecordOverhead = NonceSize + TagSize;
+ internal const int MaximumPlaintextSize = MaximumRecordSize -
+ RecordOverhead;
+ internal const uint ClientNoncePrefix = 0;
+ internal const uint ServerNoncePrefix = 1;
+
+ private readonly Stream transport;
+ private readonly byte[] sessionKey;
+ private readonly object sendLock = new object();
+ private readonly object receiveLock = new object();
+ private ChaCha20Poly1305 sendCipher;
+ private ChaCha20Poly1305 receiveCipher;
+ private byte[] sendRecord = Array.Empty();
+ private readonly byte[] receiveHeader = new byte[4];
+ private byte[] receiveRecord = Array.Empty();
+ private byte[] receivePlaintext = Array.Empty();
+ private int receiveHeaderRead;
+ private int receiveRecordRead;
+ private int receiveRecordLength;
+ private int receivePlaintextOffset;
+ private int receivePlaintextLength;
+ private ulong sendCounter;
+ private ulong receiveCounter;
+ private bool sendExhausted;
+ private bool receiveExhausted;
+ private Exception sendError;
+ private Exception receiveError;
+ private int disposed;
+
+ internal ViiperEncryptedStream(Stream transport, byte[] sessionKey)
+ : this(transport, sessionKey, 0, 0)
+ {
+ }
+
+ internal ViiperEncryptedStream(Stream transport, byte[] sessionKey,
+ ulong sendCounter, ulong receiveCounter)
+ {
+ this.transport = transport ??
+ throw new ArgumentNullException(nameof(transport));
+ if (sessionKey == null ||
+ sessionKey.Length != ViiperAuthProtocol.SessionKeySize)
+ {
+ throw new ArgumentException(
+ $"The VIIPER session key must be {ViiperAuthProtocol.SessionKeySize} bytes.",
+ nameof(sessionKey));
+ }
+
+ this.sessionKey = (byte[])sessionKey.Clone();
+ this.sendCounter = sendCounter;
+ this.receiveCounter = receiveCounter;
+ sendCipher = new ChaCha20Poly1305(this.sessionKey);
+ receiveCipher = new ChaCha20Poly1305(this.sessionKey);
+ }
+
+ public override bool CanRead => Volatile.Read(ref disposed) == 0 &&
+ transport.CanRead;
+
+ public override bool CanSeek => false;
+
+ public override bool CanWrite => Volatile.Read(ref disposed) == 0 &&
+ transport.CanWrite;
+
+ public override long Length => throw new NotSupportedException();
+
+ public override long Position
+ {
+ get => throw new NotSupportedException();
+ set => throw new NotSupportedException();
+ }
+
+ public override void Flush()
+ {
+ ThrowIfDisposed();
+ transport.Flush();
+ }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ ValidateArrayArguments(buffer, offset, count);
+ if (count == 0)
+ {
+ return 0;
+ }
+
+ lock (receiveLock)
+ {
+ ThrowIfDisposed();
+ if (receiveError != null)
+ {
+ throw new IOException(
+ "The VIIPER authenticated receive lane has failed.",
+ receiveError);
+ }
+
+ while (receivePlaintextOffset == receivePlaintextLength)
+ {
+ if (!ReadRecord())
+ {
+ return 0;
+ }
+ }
+
+ int copied = Math.Min(count,
+ receivePlaintextLength - receivePlaintextOffset);
+ Buffer.BlockCopy(receivePlaintext, receivePlaintextOffset,
+ buffer, offset, copied);
+ CryptographicOperations.ZeroMemory(
+ receivePlaintext.AsSpan(receivePlaintextOffset, copied));
+ receivePlaintextOffset += copied;
+ if (receivePlaintextOffset == receivePlaintextLength)
+ {
+ receivePlaintextOffset = 0;
+ receivePlaintextLength = 0;
+ }
+ return copied;
+ }
+ }
+
+ public override void Write(byte[] buffer, int offset, int count)
+ {
+ ValidateArrayArguments(buffer, offset, count);
+ lock (sendLock)
+ {
+ ThrowIfDisposed();
+ if (sendError != null)
+ {
+ throw new IOException(
+ "The VIIPER authenticated send lane has failed.",
+ sendError);
+ }
+ if (sendExhausted)
+ {
+ throw new IOException(
+ "The VIIPER authenticated send nonce space is exhausted.");
+ }
+ if (count > MaximumPlaintextSize)
+ {
+ throw new IOException(
+ "The VIIPER authenticated stream packet is too large.");
+ }
+
+ int recordLength = RecordOverhead + count;
+ int wireLength = sizeof(uint) + recordLength;
+ if (sendRecord.Length < wireLength)
+ {
+ sendRecord = new byte[wireLength];
+ }
+ Span record = sendRecord.AsSpan(0, wireLength);
+ BinaryPrimitives.WriteUInt32BigEndian(record,
+ (uint)recordLength);
+ Span nonce = record.Slice(sizeof(uint), NonceSize);
+ BinaryPrimitives.WriteUInt32BigEndian(nonce,
+ ClientNoncePrefix);
+ BinaryPrimitives.WriteUInt64BigEndian(nonce.Slice(4),
+ sendCounter);
+ Span ciphertext = record.Slice(sizeof(uint) + NonceSize,
+ count);
+ Span tag = record.Slice(sizeof(uint) + NonceSize + count,
+ TagSize);
+ try
+ {
+ sendCipher.Encrypt(nonce,
+ buffer.AsSpan(offset, count), ciphertext, tag);
+ transport.Write(sendRecord, 0, wireLength);
+ AdvanceSendCounter();
+ }
+ catch (Exception ex) when (ex is IOException ||
+ ex is ObjectDisposedException ||
+ ex is CryptographicException)
+ {
+ throw LatchSendFailure(ex);
+ }
+ }
+ }
+
+ public override long Seek(long offset, SeekOrigin origin) =>
+ throw new NotSupportedException();
+
+ public override void SetLength(long value) =>
+ throw new NotSupportedException();
+
+ private bool ReadRecord()
+ {
+ try
+ {
+ if (receiveHeaderRead < receiveHeader.Length &&
+ !FillReceiveBuffer(receiveHeader,
+ ref receiveHeaderRead, receiveHeader.Length,
+ allowCleanEndOfStream: receiveHeaderRead == 0))
+ {
+ return false;
+ }
+
+ if (receiveRecordLength == 0)
+ {
+ uint encodedLength =
+ BinaryPrimitives.ReadUInt32BigEndian(receiveHeader);
+ if (encodedLength < RecordOverhead)
+ {
+ throw new InvalidDataException(
+ "The VIIPER authenticated stream packet is too short.");
+ }
+ if (encodedLength > MaximumRecordSize)
+ {
+ throw new InvalidDataException(
+ "The VIIPER authenticated stream packet is too large.");
+ }
+
+ receiveRecordLength = checked((int)encodedLength);
+ if (receiveRecord.Length < receiveRecordLength)
+ {
+ if (receiveRecord.Length != 0)
+ {
+ CryptographicOperations.ZeroMemory(receiveRecord);
+ }
+ receiveRecord = new byte[receiveRecordLength];
+ }
+ }
+ FillReceiveBuffer(receiveRecord, ref receiveRecordRead,
+ receiveRecordLength, allowCleanEndOfStream: false);
+
+ int plaintextLength = receiveRecordLength - RecordOverhead;
+ if (receivePlaintext.Length < plaintextLength)
+ {
+ if (receivePlaintext.Length != 0)
+ {
+ CryptographicOperations.ZeroMemory(receivePlaintext);
+ }
+ receivePlaintext = new byte[plaintextLength];
+ }
+ ReadOnlySpan nonce = receiveRecord.AsSpan(0,
+ NonceSize);
+ ReadOnlySpan ciphertext = receiveRecord.AsSpan(
+ NonceSize, plaintextLength);
+ ReadOnlySpan tag = receiveRecord.AsSpan(
+ NonceSize + plaintextLength, TagSize);
+ receiveCipher.Decrypt(nonce, ciphertext, tag,
+ receivePlaintext.AsSpan(0, plaintextLength));
+
+ uint prefix = BinaryPrimitives.ReadUInt32BigEndian(nonce);
+ ulong counter = BinaryPrimitives.ReadUInt64BigEndian(
+ nonce.Slice(4));
+ if (prefix != ServerNoncePrefix)
+ {
+ throw new InvalidDataException(
+ $"VIIPER authenticated stream nonce direction={prefix}, expected {ServerNoncePrefix}.");
+ }
+ if (receiveExhausted)
+ {
+ throw new InvalidDataException(
+ "The VIIPER authenticated receive nonce space is exhausted.");
+ }
+ if (counter != receiveCounter)
+ {
+ throw new InvalidDataException(
+ $"VIIPER authenticated stream nonce counter={counter}, expected {receiveCounter}.");
+ }
+
+ AdvanceReceiveCounter();
+ receiveHeaderRead = 0;
+ receiveRecordRead = 0;
+ receiveRecordLength = 0;
+ receivePlaintextOffset = 0;
+ receivePlaintextLength = plaintextLength;
+ return true;
+ }
+ catch (Exception ex) when (ex is InvalidDataException ||
+ ex is CryptographicException || ex is OverflowException)
+ {
+ if (receivePlaintext.Length != 0)
+ {
+ CryptographicOperations.ZeroMemory(receivePlaintext);
+ }
+ receivePlaintextOffset = 0;
+ receivePlaintextLength = 0;
+ receiveError = ex;
+ throw new IOException(
+ "The VIIPER authenticated receive record was rejected.",
+ ex);
+ }
+ }
+
+ private bool FillReceiveBuffer(byte[] buffer, ref int offset,
+ int count, bool allowCleanEndOfStream)
+ {
+ while (offset < count)
+ {
+ int read = transport.Read(buffer, offset, count - offset);
+ if (read == 0)
+ {
+ if (allowCleanEndOfStream && offset == 0)
+ {
+ return false;
+ }
+ throw new EndOfStreamException(
+ "VIIPER closed in the middle of an authenticated record.");
+ }
+ offset += read;
+ }
+ return true;
+ }
+
+ private void AdvanceSendCounter()
+ {
+ if (sendCounter == ulong.MaxValue)
+ {
+ sendExhausted = true;
+ }
+ else
+ {
+ sendCounter++;
+ }
+ }
+
+ private void AdvanceReceiveCounter()
+ {
+ if (receiveCounter == ulong.MaxValue)
+ {
+ receiveExhausted = true;
+ }
+ else
+ {
+ receiveCounter++;
+ }
+ }
+
+ private IOException LatchSendFailure(Exception failure)
+ {
+ sendError ??= failure;
+ try
+ {
+ transport.Dispose();
+ }
+ catch
+ {
+ }
+ return failure as IOException ?? new IOException(
+ "The VIIPER authenticated send record failed.", failure);
+ }
+
+ private static void ValidateArrayArguments(byte[] buffer, int offset,
+ int count)
+ {
+ if (buffer == null)
+ {
+ throw new ArgumentNullException(nameof(buffer));
+ }
+ if (offset < 0 || count < 0 || offset > buffer.Length - count)
+ {
+ throw new ArgumentOutOfRangeException(
+ offset < 0 ? nameof(offset) : nameof(count));
+ }
+ }
+
+ private void ThrowIfDisposed()
+ {
+ if (Volatile.Read(ref disposed) != 0)
+ {
+ throw new ObjectDisposedException(nameof(ViiperEncryptedStream));
+ }
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (!disposing || Interlocked.Exchange(ref disposed, 1) != 0)
+ {
+ base.Dispose(disposing);
+ return;
+ }
+
+ // Close the transport before joining the lanes. This wakes a
+ // blocked network read/write without racing cipher cleanup.
+ try
+ {
+ transport.Dispose();
+ }
+ catch
+ {
+ }
+
+ lock (sendLock)
+ {
+ lock (receiveLock)
+ {
+ sendCipher?.Dispose();
+ receiveCipher?.Dispose();
+ sendCipher = null;
+ receiveCipher = null;
+ CryptographicOperations.ZeroMemory(sessionKey);
+ CryptographicOperations.ZeroMemory(sendRecord);
+ CryptographicOperations.ZeroMemory(receiveHeader);
+ CryptographicOperations.ZeroMemory(receiveRecord);
+ CryptographicOperations.ZeroMemory(receivePlaintext);
+ sendRecord = Array.Empty();
+ receiveRecord = Array.Empty();
+ receivePlaintext = Array.Empty();
+ receiveHeaderRead = 0;
+ receiveRecordRead = 0;
+ receiveRecordLength = 0;
+ sendError ??= new ObjectDisposedException(
+ nameof(ViiperEncryptedStream));
+ receiveError ??= new ObjectDisposedException(
+ nameof(ViiperEncryptedStream));
+ }
+ }
+ base.Dispose(disposing);
+ }
+ }
+}
diff --git a/DS4Windows/DS4Control/Viiper/ViiperBackendDebugger.cs b/DS4Windows/DS4Control/Viiper/ViiperBackendDebugger.cs
index 9189dfc..73e6199 100644
--- a/DS4Windows/DS4Control/Viiper/ViiperBackendDebugger.cs
+++ b/DS4Windows/DS4Control/Viiper/ViiperBackendDebugger.cs
@@ -203,9 +203,10 @@ private void RunPrerequisiteProbe()
{
ViiperPrerequisiteStatus status = ViiperSetupManager.GetStatus(tryStartServer: true);
Log($"Prerequisite status ready={status.Ready} display='{status.DisplayText}'");
- Log($"VIIPER installed={status.ViiperInstalled} path='{status.ViiperPath}'");
- Log($"usbip-win2 installed={status.UsbipInstalled}");
- Log($"VIIPER server running={status.ServerRunning} endpoint={ViiperSetupManager.ApiHost}:{ViiperSetupManager.ApiPort}");
+ Log($"Native metadata found={status.MetadataFound} eligible={status.MetadataEligible} localTest={status.LocalTestMetadata} path='{status.MetadataPath}'");
+ Log($"Native broker installed={status.BrokerInstalled} exactHash={status.BrokerHashMatches} path='{status.BrokerPath}'");
+ Log($"VIIPERNativeBroker installed={status.BrokerServiceInstalled} configured={status.BrokerServiceConfigured} running={status.BrokerServiceRunning}");
+ Log($"Protected credential readable={status.CredentialReadable} authenticatedPing={status.AuthenticatedPingSucceeded} identityCompatible={status.RuntimeContractCompatible} endpoint={ViiperSetupManager.ApiHost}:{ViiperSetupManager.ApiPort}");
Log($"Bundled setup script found={status.SetupScriptFound} path='{status.SetupScriptPath}'");
}
@@ -228,6 +229,7 @@ private void RunDeviceProbe(ViiperVirtualDeviceType type, CancellationToken canc
using ViiperDeviceStream stream = client.CreateDeviceAndOpenStream(type);
Log($"Device={type} create/open stream OK");
+ LogDeviceIdentity(type, stream.VirtualDeviceIdentity);
WritePacket(stream, type, "neutral", ViiperStatePacketBuilder.CreateNeutralState(), cancellationToken);
WritePacket(stream, type, "buttons", BuildButtonState(type), cancellationToken);
@@ -238,6 +240,32 @@ private void RunDeviceProbe(ViiperVirtualDeviceType type, CancellationToken canc
}, cancellationToken);
}
+ private void LogDeviceIdentity(ViiperVirtualDeviceType type,
+ ViiperVirtualDeviceIdentity identity)
+ {
+ if (identity == null)
+ {
+ Log($"Device={type} authoritative virtual-device identity unavailable");
+ return;
+ }
+
+ Log($"Device={type} transport={identity.TransportMode} busId={identity.BusId} devId={identity.DevId} deviceType={identity.DeviceType} vid={identity.Vid} pid={identity.Pid} logicalLifetime={identity.LogicalLifetimeId} streamGeneration={identity.StreamGeneration}");
+ if (identity.TransportMode == ViiperTransportMode.NativeUde)
+ {
+ ViiperNativePnpAnchor anchor = identity.NativePnpAnchor;
+ if (anchor == null)
+ {
+ Log($"Device={type} native PnP anchor unavailable");
+ return;
+ }
+
+ Log($"Device={type} nativeDeviceId={anchor.NativeDeviceId} driverGeneration={anchor.NativeDeviceGeneration} controllerSessionId={anchor.ControllerSessionId} controllerInstance='{anchor.ControllerInstanceId}' usb20Port={anchor.Usb20PortNumber} usb30Port={anchor.Usb30PortNumber} exact={anchor.IsExact}");
+ return;
+ }
+
+ Log($"Device={type} legacyUsbipPort={identity.LegacyUsbipPort} legacyOwnerIdentityPresent={!string.IsNullOrWhiteSpace(identity.LegacyUsbipOwnerSerial)}");
+ }
+
private void WritePacket(ViiperDeviceStream stream, ViiperVirtualDeviceType type, string label, DS4State state, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
diff --git a/DS4Windows/DS4Control/Viiper/ViiperLiveValidation.cs b/DS4Windows/DS4Control/Viiper/ViiperLiveValidation.cs
new file mode 100644
index 0000000..fbf81b3
--- /dev/null
+++ b/DS4Windows/DS4Control/Viiper/ViiperLiveValidation.cs
@@ -0,0 +1,118 @@
+/*
+DS4Windows
+Copyright (C) 2026 hbashton
+
+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.
+*/
+
+using System;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace DS4Windows
+{
+ ///
+ /// Capability token for the separately-built, headless native live gate.
+ /// The ordinary DS4Windows process never creates one. Requiring the same
+ /// canonical nonce at the command line and in the environment prevents an
+ /// accidental invocation from acquiring validation-only hooks.
+ ///
+ internal sealed class ViiperLiveValidationLease
+ {
+ internal const string NonceEnvironmentVariable =
+ "DS4WINDOWS_VIIPER_LIVE_VALIDATION_NONCE";
+ internal const int NonceLength = 64;
+
+ private ViiperLiveValidationLease(byte[] nonceFingerprint)
+ {
+ NonceFingerprint = nonceFingerprint;
+ }
+
+ internal byte[] NonceFingerprint { get; }
+
+ internal static ViiperLiveValidationLease Create(string commandNonce)
+ {
+ string environmentNonce = Environment.GetEnvironmentVariable(
+ NonceEnvironmentVariable);
+ ValidateNonce(commandNonce, "command-line");
+ ValidateNonce(environmentNonce, NonceEnvironmentVariable);
+
+ byte[] command = Encoding.ASCII.GetBytes(commandNonce);
+ byte[] environment = Encoding.ASCII.GetBytes(environmentNonce);
+ try
+ {
+ if (!CryptographicOperations.FixedTimeEquals(command,
+ environment))
+ {
+ throw new ViiperIdentityException(
+ $"The command-line live-validation nonce does not match {NonceEnvironmentVariable}.");
+ }
+
+ return new ViiperLiveValidationLease(
+ SHA256.HashData(command));
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(command);
+ CryptographicOperations.ZeroMemory(environment);
+ }
+ }
+
+ private static void ValidateNonce(string value, string source)
+ {
+ if (value == null || value.Length != NonceLength ||
+ value.Any(character =>
+ !(character >= '0' && character <= '9') &&
+ !(character >= 'a' && character <= 'f')))
+ {
+ throw new ViiperIdentityException(
+ $"The {source} live-validation nonce must be exactly {NonceLength} lowercase hexadecimal characters.");
+ }
+ }
+ }
+
+ internal sealed class ViiperLiveValidationSnapshot
+ {
+ internal string HandlerName { get; init; }
+ internal string StreamProtocol { get; init; }
+ internal byte StreamFrameVersion { get; init; }
+ internal bool Connected { get; init; }
+ internal bool SupportsMicrophone { get; init; }
+ internal bool SupportsDirectSpeaker { get; init; }
+ internal bool SupportsAtomicAudioHaptics { get; init; }
+ internal ViiperNativeBackendIdentity BackendIdentity { get; init; }
+ internal ViiperVirtualDeviceIdentity DeviceIdentity { get; init; }
+ internal long StatePacketsSubmitted { get; init; }
+ internal long StatePacketsWritten { get; init; }
+ internal long StatePacketsCoalesced { get; init; }
+ internal int StreamRecoveryAttempts { get; init; }
+ internal long FeedbackFramesObserved { get; init; }
+ internal byte[] LastFeedbackPayload { get; init; }
+ internal long ValidationMicrophoneFramesSubmitted { get; init; }
+ internal long ValidationMicrophoneBytesSubmitted { get; init; }
+ internal long ValidationTransportInterruptions { get; init; }
+ internal long ValidationStreamRecoveriesCompleted { get; init; }
+ internal long SpeakerFramesEnqueued { get; init; }
+ internal long SpeakerFramesDequeued { get; init; }
+ internal long SpeakerFramesDropped { get; init; }
+ internal long SpeakerFramesExpired { get; init; }
+ internal long SpeakerFramesDelivered { get; init; }
+ internal long SpeakerFramesStale { get; init; }
+ internal long SpeakerNoSubscriberDeferrals { get; init; }
+ internal long SpeakerCallbackFailures { get; init; }
+ internal long ControlFramesEnqueued { get; init; }
+ internal long ControlFramesDequeued { get; init; }
+ internal long ControlFramesDropped { get; init; }
+ internal long OrderedControlFramesEnqueued { get; init; }
+ internal long OrderedControlFramesDequeued { get; init; }
+ internal long OrderedControlFramesDropped { get; init; }
+ internal long OrderedControlFramesExpired { get; init; }
+ internal long ControlFramesDelivered { get; init; }
+ internal long ControlFramesStale { get; init; }
+ internal long ControlCallbackFailures { get; init; }
+ }
+}
diff --git a/DS4Windows/DS4Control/Viiper/ViiperNativeRuntime.cs b/DS4Windows/DS4Control/Viiper/ViiperNativeRuntime.cs
new file mode 100644
index 0000000..24ebb8a
--- /dev/null
+++ b/DS4Windows/DS4Control/Viiper/ViiperNativeRuntime.cs
@@ -0,0 +1,1348 @@
+/*
+ DS4Windows
+ Copyright (C) 2026 hbashton
+
+ 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.
+*/
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Threading;
+
+namespace DS4Windows
+{
+ internal enum ViiperTransportMode
+ {
+ NativeUde,
+ Usbip,
+ }
+
+ internal static class ViiperTransportSettings
+ {
+ internal const string TransportEnvironmentVariable =
+ "DS4WINDOWS_VIIPER_TRANSPORT";
+ internal const string LocalTestEnvironmentVariable =
+ "DS4WINDOWS_VIIPER_ALLOW_LOCAL_TEST";
+
+ internal static ViiperTransportMode GetManagedMode()
+ {
+ return Parse(Environment.GetEnvironmentVariable(
+ TransportEnvironmentVariable));
+ }
+
+ internal static ViiperTransportMode Parse(string value)
+ {
+ string normalized = value?.Trim();
+ if (string.IsNullOrEmpty(normalized) ||
+ string.Equals(normalized, "native-ude",
+ StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(normalized, "native",
+ StringComparison.OrdinalIgnoreCase))
+ {
+ return ViiperTransportMode.NativeUde;
+ }
+ if (string.Equals(normalized, "usbip",
+ StringComparison.OrdinalIgnoreCase))
+ {
+ return ViiperTransportMode.Usbip;
+ }
+
+ throw new ViiperNativeMetadataException(
+ $"Unsupported {TransportEnvironmentVariable} value '{normalized}'. Expected native-ude or usbip.");
+ }
+
+ internal static bool AllowsLocalTestMetadata(string value = null)
+ {
+ value ??= Environment.GetEnvironmentVariable(
+ LocalTestEnvironmentVariable);
+ return string.Equals(value, "1", StringComparison.Ordinal);
+ }
+ }
+
+ internal sealed class ViiperNativeMetadataException : IOException
+ {
+ internal ViiperNativeMetadataException(string message,
+ Exception inner = null) : base(message, inner)
+ {
+ }
+ }
+
+ internal sealed class ViiperCredentialException : IOException
+ {
+ internal ViiperCredentialException(string message,
+ Exception inner = null) : base(message, inner)
+ {
+ }
+ }
+
+ internal sealed class ViiperAuthenticationException : IOException
+ {
+ internal ViiperAuthenticationException(string message,
+ Exception inner = null) : base(message, inner)
+ {
+ }
+ }
+
+ internal sealed class ViiperIdentityException : IOException
+ {
+ internal ViiperIdentityException(string message,
+ Exception inner = null) : base(message, inner)
+ {
+ }
+ }
+
+ internal sealed class ViiperNativeControllerRegistration
+ {
+ internal string Type { get; init; }
+ internal string DefaultVid { get; init; }
+ internal string DefaultPid { get; init; }
+ internal string Ds4WindowsPid { get; init; }
+ internal string InterfaceProfile { get; init; }
+ internal string StreamProtocol { get; init; }
+
+ internal ushort Ds4WindowsPidValue => ushort.Parse(
+ Ds4WindowsPid.AsSpan(2), NumberStyles.AllowHexSpecifier,
+ CultureInfo.InvariantCulture);
+
+ internal bool HasSameContract(
+ ViiperNativeControllerRegistration other)
+ {
+ return other != null &&
+ string.Equals(Type, other.Type, StringComparison.Ordinal) &&
+ string.Equals(DefaultVid, other.DefaultVid,
+ StringComparison.Ordinal) &&
+ string.Equals(DefaultPid, other.DefaultPid,
+ StringComparison.Ordinal) &&
+ string.Equals(Ds4WindowsPid, other.Ds4WindowsPid,
+ StringComparison.Ordinal) &&
+ string.Equals(InterfaceProfile, other.InterfaceProfile,
+ StringComparison.Ordinal) &&
+ string.Equals(StreamProtocol, other.StreamProtocol,
+ StringComparison.Ordinal);
+ }
+ }
+
+ internal sealed class ViiperNativeRuntimeMetadata
+ {
+ internal const string FileName = "ViiperNativeRuntimeMetadata.json";
+ internal const string ProductionEligibility = "production";
+ internal const string LocalTestEligibility =
+ "local-test-evidence-only";
+
+ internal int SchemaVersion { get; init; }
+ internal string SourcePath { get; init; }
+ internal string SourceRevision { get; init; }
+ internal string ReleaseEligibility { get; init; }
+ internal string DriverPackageVersion { get; init; }
+ internal ushort AbiMajor { get; init; }
+ internal ushort AbiMinor { get; init; }
+ internal uint RequiredCapabilities { get; init; }
+ internal string RequiredCapabilitiesHex { get; init; }
+ internal string LoadedDriverBuildIdentity { get; init; }
+ internal IReadOnlyDictionary ControllerApiContract
+ { get; init; }
+
+ internal static ViiperNativeRuntimeMetadata LoadBundled(
+ string baseDirectory = null, string localTestOptIn = null)
+ {
+ baseDirectory = string.IsNullOrWhiteSpace(baseDirectory) ?
+ AppContext.BaseDirectory : Path.GetFullPath(baseDirectory);
+ string[] candidates =
+ {
+ Path.Combine(baseDirectory, "extras", FileName),
+ Path.Combine(baseDirectory, FileName),
+ };
+ string[] existing = candidates.Where(File.Exists)
+ .Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+ if (existing.Length == 0)
+ {
+ throw new ViiperNativeMetadataException(
+ $"Bundled {FileName} is missing. Native VIIPER admission requires package-generated metadata.");
+ }
+
+ ViiperNativeRuntimeMetadata selected = Parse(existing[0],
+ localTestOptIn);
+ for (int index = 1; index < existing.Length; index++)
+ {
+ ViiperNativeRuntimeMetadata duplicate = Parse(existing[index],
+ localTestOptIn);
+ if (!selected.HasSameContract(duplicate))
+ {
+ throw new ViiperNativeMetadataException(
+ $"Conflicting native VIIPER metadata files were bundled at '{selected.SourcePath}' and '{duplicate.SourcePath}'.");
+ }
+ }
+ return selected;
+ }
+
+ internal static ViiperNativeRuntimeMetadata Parse(string path,
+ string localTestOptIn = null)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ throw new ArgumentException("A metadata path is required.",
+ nameof(path));
+ }
+
+ string fullPath = Path.GetFullPath(path);
+ try
+ {
+ using FileStream file = new FileStream(fullPath,
+ FileMode.Open, FileAccess.Read, FileShare.Read,
+ 4096, FileOptions.SequentialScan);
+ using JsonDocument document = JsonDocument.Parse(file,
+ new JsonDocumentOptions
+ {
+ AllowTrailingCommas = false,
+ CommentHandling = JsonCommentHandling.Disallow,
+ MaxDepth = 32,
+ });
+ JsonElement root = document.RootElement;
+ if (root.ValueKind != JsonValueKind.Object)
+ {
+ throw new ViiperNativeMetadataException(
+ "Native VIIPER metadata must be a JSON object.");
+ }
+
+ int schemaVersion = GetRequiredInt32(root, "schemaVersion");
+ if (schemaVersion != 1)
+ {
+ throw new ViiperNativeMetadataException(
+ $"Unsupported native VIIPER metadata schemaVersion={schemaVersion}.");
+ }
+ string eligibility = GetRequiredString(root,
+ "releaseEligibility");
+ string localTestOptInEnvironment = GetRequiredString(root,
+ "localTestOptInEnvironment");
+ if (!string.Equals(localTestOptInEnvironment,
+ ViiperTransportSettings.LocalTestEnvironmentVariable,
+ StringComparison.Ordinal))
+ {
+ throw new ViiperNativeMetadataException(
+ "Native VIIPER metadata names an unexpected local-test opt-in boundary.");
+ }
+ bool eligibilityAccepted = string.Equals(eligibility,
+ ProductionEligibility, StringComparison.Ordinal);
+ if (string.Equals(eligibility, LocalTestEligibility,
+ StringComparison.Ordinal))
+ {
+ eligibilityAccepted =
+ ViiperTransportSettings.AllowsLocalTestMetadata(
+ localTestOptIn);
+ if (!eligibilityAccepted)
+ {
+ throw new ViiperNativeMetadataException(
+ $"Bundled native VIIPER metadata is '{LocalTestEligibility}'. Set {ViiperTransportSettings.LocalTestEnvironmentVariable}=1 only inside the disposable VM/laptop test environment.");
+ }
+ }
+ if (!eligibilityAccepted)
+ {
+ throw new ViiperNativeMetadataException(
+ $"Native VIIPER metadata releaseEligibility='{eligibility}' is not admissible.");
+ }
+
+ JsonElement abi = GetRequiredObject(root, "driverAbi");
+ int abiMajor = GetRequiredInt32(abi, "major");
+ int abiMinor = GetRequiredInt32(abi, "minor");
+ uint capabilities = GetRequiredUInt32(root,
+ "requiredCapabilities");
+ string capabilitiesHex = GetRequiredString(root,
+ "requiredCapabilitiesHex");
+ string packageVersion = GetRequiredString(root,
+ "driverPackageVersion");
+ string buildIdentity = GetRequiredString(root,
+ "loadedDriverBuildIdentity");
+ string sourceRevision = GetRequiredString(root,
+ "sourceRevision");
+ if (abiMajor <= 0 || abiMajor > ushort.MaxValue ||
+ abiMinor < 0 || abiMinor > ushort.MaxValue)
+ {
+ throw new ViiperNativeMetadataException(
+ "Native VIIPER metadata contains an invalid driver ABI.");
+ }
+ if (capabilities == 0)
+ {
+ throw new ViiperNativeMetadataException(
+ "Native VIIPER metadata requires zero capabilities.");
+ }
+ string canonicalCapabilitiesHex = string.Format(
+ CultureInfo.InvariantCulture, "0x{0:x8}", capabilities);
+ if (!string.Equals(capabilitiesHex,
+ canonicalCapabilitiesHex, StringComparison.Ordinal))
+ {
+ throw new ViiperNativeMetadataException(
+ $"Native VIIPER metadata requiredCapabilitiesHex='{capabilitiesHex}' does not exactly encode requiredCapabilities as '{canonicalCapabilitiesHex}'.");
+ }
+ ValidatePackageVersion(packageVersion);
+ ValidateLowercaseSha256(buildIdentity,
+ "loadedDriverBuildIdentity");
+ ValidateSourceRevision(sourceRevision);
+ ValidateManagedBroker(root);
+ IReadOnlyDictionary controllerApi =
+ ParseControllerApiContract(root, sourceRevision);
+
+ return new ViiperNativeRuntimeMetadata
+ {
+ SchemaVersion = schemaVersion,
+ SourcePath = fullPath,
+ SourceRevision = sourceRevision,
+ ReleaseEligibility = eligibility,
+ DriverPackageVersion = packageVersion,
+ AbiMajor = (ushort)abiMajor,
+ AbiMinor = (ushort)abiMinor,
+ RequiredCapabilities = capabilities,
+ RequiredCapabilitiesHex = capabilitiesHex,
+ LoadedDriverBuildIdentity = buildIdentity,
+ ControllerApiContract = controllerApi,
+ };
+ }
+ catch (ViiperNativeMetadataException)
+ {
+ throw;
+ }
+ catch (Exception ex) when (ex is IOException ||
+ ex is UnauthorizedAccessException || ex is JsonException ||
+ ex is FormatException || ex is OverflowException)
+ {
+ throw new ViiperNativeMetadataException(
+ $"Could not read native VIIPER metadata '{fullPath}': {ex.Message}",
+ ex);
+ }
+ }
+
+ private bool HasSameContract(ViiperNativeRuntimeMetadata other)
+ {
+ return other != null && SchemaVersion == other.SchemaVersion &&
+ string.Equals(SourceRevision, other.SourceRevision,
+ StringComparison.Ordinal) &&
+ string.Equals(ReleaseEligibility, other.ReleaseEligibility,
+ StringComparison.Ordinal) &&
+ string.Equals(DriverPackageVersion,
+ other.DriverPackageVersion, StringComparison.Ordinal) &&
+ AbiMajor == other.AbiMajor && AbiMinor == other.AbiMinor &&
+ RequiredCapabilities == other.RequiredCapabilities &&
+ string.Equals(RequiredCapabilitiesHex,
+ other.RequiredCapabilitiesHex, StringComparison.Ordinal) &&
+ string.Equals(LoadedDriverBuildIdentity,
+ other.LoadedDriverBuildIdentity,
+ StringComparison.Ordinal) &&
+ HasSameControllerApiContract(other);
+ }
+
+ private bool HasSameControllerApiContract(
+ ViiperNativeRuntimeMetadata other)
+ {
+ if (ControllerApiContract == null ||
+ other.ControllerApiContract == null ||
+ ControllerApiContract.Count !=
+ other.ControllerApiContract.Count)
+ {
+ return false;
+ }
+ foreach (KeyValuePair entry in
+ ControllerApiContract)
+ {
+ if (!other.ControllerApiContract.TryGetValue(entry.Key,
+ out ViiperNativeControllerRegistration candidate) ||
+ !entry.Value.HasSameContract(candidate))
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static JsonElement GetRequiredObject(JsonElement parent,
+ string name)
+ {
+ if (!parent.TryGetProperty(name, out JsonElement value) ||
+ value.ValueKind != JsonValueKind.Object)
+ {
+ throw new ViiperNativeMetadataException(
+ $"Native VIIPER metadata field '{name}' must be an object.");
+ }
+ return value;
+ }
+
+ private static void ValidateManagedBroker(JsonElement root)
+ {
+ JsonElement broker = GetRequiredObject(root, "managedBroker");
+ bool valid = string.Equals(GetRequiredString(broker,
+ "serviceName"), "VIIPERNativeBroker",
+ StringComparison.Ordinal) &&
+ string.Equals(GetRequiredString(broker, "serviceAccount"),
+ "LocalSystem", StringComparison.Ordinal) &&
+ string.Equals(GetRequiredString(broker, "startMode"),
+ "automatic", StringComparison.Ordinal) &&
+ string.Equals(GetRequiredString(broker, "transport"),
+ "native-ude", StringComparison.Ordinal) &&
+ string.Equals(GetRequiredString(broker, "apiHost"),
+ "127.0.0.1", StringComparison.Ordinal) &&
+ GetRequiredInt32(broker, "apiPort") == 3242 &&
+ string.Equals(GetRequiredString(broker, "credentialPath"),
+ "%ProgramData%/VIIPER/viiper.key.txt",
+ StringComparison.Ordinal);
+ if (!valid)
+ {
+ throw new ViiperNativeMetadataException(
+ "Native VIIPER metadata managedBroker does not match the protected loopback service contract.");
+ }
+ }
+
+ private static IReadOnlyDictionary ParseControllerApiContract(
+ JsonElement root, string sourceRevision)
+ {
+ JsonElement contract = GetRequiredObject(root,
+ "controllerApiContract");
+ if (GetRequiredInt32(contract, "schemaVersion") != 1 ||
+ !string.Equals(GetRequiredString(contract,
+ "sourceRevision"), sourceRevision,
+ StringComparison.Ordinal))
+ {
+ throw new ViiperNativeMetadataException(
+ "Native VIIPER controller API contract is not bound to the package source revision.");
+ }
+ _ = GetRequiredString(contract, "implementation");
+ if (!contract.TryGetProperty("registrations",
+ out JsonElement registrations) ||
+ registrations.ValueKind != JsonValueKind.Array ||
+ registrations.GetArrayLength() == 0)
+ {
+ throw new ViiperNativeMetadataException(
+ "Native VIIPER controller API contract has no registrations.");
+ }
+
+ var result = new Dictionary(StringComparer.Ordinal);
+ foreach (JsonElement registration in registrations.EnumerateArray())
+ {
+ if (registration.ValueKind != JsonValueKind.Object)
+ {
+ throw new ViiperNativeMetadataException(
+ "Native VIIPER controller API registration must be an object.");
+ }
+ string type = GetRequiredString(registration, "type");
+ string defaultVid = GetRequiredString(registration,
+ "defaultVid");
+ string defaultPid = GetRequiredString(registration,
+ "defaultPid");
+ string clientPid = GetRequiredString(registration,
+ "ds4WindowsPid");
+ ValidateControllerType(type);
+ ValidateUsbId(defaultVid, "defaultVid");
+ ValidateUsbId(defaultPid, "defaultPid");
+ ValidateUsbId(clientPid, "ds4WindowsPid");
+ var parsed = new ViiperNativeControllerRegistration
+ {
+ Type = type,
+ DefaultVid = defaultVid,
+ DefaultPid = defaultPid,
+ Ds4WindowsPid = clientPid,
+ InterfaceProfile = GetRequiredString(registration,
+ "interfaceProfile"),
+ StreamProtocol = GetRequiredString(registration,
+ "streamProtocol"),
+ };
+ if (!result.TryAdd(type, parsed))
+ {
+ throw new ViiperNativeMetadataException(
+ $"Native VIIPER controller API type '{type}' is duplicated.");
+ }
+ }
+ return result;
+ }
+
+ private static void ValidateControllerType(string value)
+ {
+ if (value.Length > 64 || value.Any(character =>
+ !(character >= 'a' && character <= 'z') &&
+ !(character >= '0' && character <= '9')))
+ {
+ throw new ViiperNativeMetadataException(
+ $"Native VIIPER controller API type '{value}' is not canonical.");
+ }
+ }
+
+ private static void ValidateUsbId(string value, string fieldName)
+ {
+ if (value.Length != 6 || value[0] != '0' || value[1] != 'x' ||
+ !value.AsSpan(2).ToArray().All(IsLowerHex))
+ {
+ throw new ViiperNativeMetadataException(
+ $"Native VIIPER controller API {fieldName} '{value}' is not canonical 0xhhhh.");
+ }
+ }
+
+ private static string GetRequiredString(JsonElement parent,
+ string name)
+ {
+ if (!parent.TryGetProperty(name, out JsonElement value) ||
+ value.ValueKind != JsonValueKind.String ||
+ string.IsNullOrWhiteSpace(value.GetString()))
+ {
+ throw new ViiperNativeMetadataException(
+ $"Native VIIPER metadata field '{name}' must be a non-empty string.");
+ }
+ string text = value.GetString();
+ if (!string.Equals(text, text.Trim(), StringComparison.Ordinal))
+ {
+ throw new ViiperNativeMetadataException(
+ $"Native VIIPER metadata field '{name}' contains surrounding whitespace.");
+ }
+ return text;
+ }
+
+ private static int GetRequiredInt32(JsonElement parent, string name)
+ {
+ if (!parent.TryGetProperty(name, out JsonElement value) ||
+ value.ValueKind != JsonValueKind.Number ||
+ !value.TryGetInt32(out int result))
+ {
+ throw new ViiperNativeMetadataException(
+ $"Native VIIPER metadata field '{name}' must be a 32-bit integer.");
+ }
+ return result;
+ }
+
+ private static uint GetRequiredUInt32(JsonElement parent, string name)
+ {
+ if (!parent.TryGetProperty(name, out JsonElement value) ||
+ value.ValueKind != JsonValueKind.Number ||
+ !value.TryGetUInt32(out uint result))
+ {
+ throw new ViiperNativeMetadataException(
+ $"Native VIIPER metadata field '{name}' must be a 32-bit unsigned integer.");
+ }
+ return result;
+ }
+
+ private static void ValidatePackageVersion(string value)
+ {
+ string[] components = value.Split('.');
+ if (components.Length != 4 || components.Any(component =>
+ component.Length == 0 || !uint.TryParse(component,
+ NumberStyles.None, CultureInfo.InvariantCulture,
+ out _)))
+ {
+ throw new ViiperNativeMetadataException(
+ "Native VIIPER metadata driverPackageVersion must contain four numeric parts.");
+ }
+ }
+
+ private static void ValidateSourceRevision(string value)
+ {
+ if ((value.Length != 40 && value.Length != 64) ||
+ !value.All(IsLowerHex))
+ {
+ throw new ViiperNativeMetadataException(
+ "Native VIIPER metadata sourceRevision must be 40 or 64 lowercase hexadecimal digits.");
+ }
+ }
+
+ internal static void ValidateLowercaseSha256(string value,
+ string fieldName)
+ {
+ if (value == null || value.Length != 64 ||
+ !value.All(IsLowerHex) || value.All(character => character == '0'))
+ {
+ throw new ViiperIdentityException(
+ $"VIIPER {fieldName} must be a non-zero lowercase SHA-256 value.");
+ }
+ }
+
+ private static bool IsLowerHex(char value) =>
+ value >= '0' && value <= '9' || value >= 'a' && value <= 'f';
+ }
+
+ internal sealed class ViiperNativeBackendIdentity
+ {
+ internal string Server { get; init; }
+ internal string Version { get; init; }
+ internal string Transport { get; init; }
+ internal ushort AbiMajor { get; init; }
+ internal ushort AbiMinor { get; init; }
+ internal uint Capabilities { get; init; }
+ internal string DriverPackageVersion { get; init; }
+ internal string DriverBuildIdentity { get; init; }
+ internal string ControllerInstanceId { get; init; }
+ internal ulong ControllerSessionId { get; init; }
+
+ internal bool HasSameGeneration(
+ ViiperNativeBackendIdentity other)
+ {
+ return other != null &&
+ string.Equals(Server, other.Server, StringComparison.Ordinal) &&
+ string.Equals(Version, other.Version, StringComparison.Ordinal) &&
+ string.Equals(Transport, other.Transport,
+ StringComparison.Ordinal) &&
+ AbiMajor == other.AbiMajor && AbiMinor == other.AbiMinor &&
+ Capabilities == other.Capabilities &&
+ string.Equals(DriverPackageVersion,
+ other.DriverPackageVersion, StringComparison.Ordinal) &&
+ string.Equals(DriverBuildIdentity,
+ other.DriverBuildIdentity, StringComparison.Ordinal) &&
+ string.Equals(ControllerInstanceId,
+ other.ControllerInstanceId, StringComparison.Ordinal) &&
+ ControllerSessionId == other.ControllerSessionId;
+ }
+ }
+
+ internal sealed class ViiperNativePingResponse
+ {
+ [JsonPropertyName("server")]
+ public string Server { get; set; }
+
+ [JsonPropertyName("version")]
+ public string Version { get; set; }
+
+ [JsonPropertyName("transport")]
+ public string Transport { get; set; }
+
+ [JsonPropertyName("ready")]
+ public bool? Ready { get; set; }
+
+ [JsonPropertyName("nativeUde")]
+ public ViiperNativeUdePingInfo NativeUde { get; set; }
+ }
+
+ internal sealed class ViiperNativeUdePingInfo
+ {
+ [JsonPropertyName("abiMajor")]
+ public ushort AbiMajor { get; set; }
+
+ [JsonPropertyName("abiMinor")]
+ public ushort AbiMinor { get; set; }
+
+ [JsonPropertyName("capabilities")]
+ public uint Capabilities { get; set; }
+
+ [JsonPropertyName("expectedDriverPackageVersion")]
+ public string ExpectedDriverPackageVersion { get; set; }
+
+ [JsonPropertyName("loadedDriverBuildIdentity")]
+ public string LoadedDriverBuildIdentity { get; set; }
+
+ [JsonPropertyName("controllerInstanceId")]
+ public string ControllerInstanceId { get; set; }
+
+ [JsonPropertyName("controllerSessionId")]
+ public string ControllerSessionId { get; set; }
+ }
+
+ internal sealed class ViiperNativeRuntimeContract
+ {
+ private static readonly JsonSerializerOptions JsonOptions =
+ new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = false,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
+ };
+
+ internal ViiperNativeRuntimeContract(
+ ViiperNativeRuntimeMetadata metadata)
+ {
+ Metadata = metadata ??
+ throw new ArgumentNullException(nameof(metadata));
+ }
+
+ internal ViiperNativeRuntimeMetadata Metadata { get; }
+
+ internal ViiperNativeControllerRegistration GetControllerRegistration(
+ string type)
+ {
+ if (type == null || Metadata.ControllerApiContract == null ||
+ !Metadata.ControllerApiContract.TryGetValue(type,
+ out ViiperNativeControllerRegistration registration))
+ {
+ throw new ViiperIdentityException(
+ $"VIIPER native controller type '{type ?? ""}' is not in the source-bound controller API contract.");
+ }
+ return registration;
+ }
+
+ internal ushort ValidateControllerRequest(string type,
+ ushort? requestedPid)
+ {
+ ViiperNativeControllerRegistration registration =
+ GetControllerRegistration(type);
+ ushort expected = registration.Ds4WindowsPidValue;
+ if (requestedPid.HasValue && requestedPid.Value != expected)
+ {
+ throw new ViiperIdentityException(
+ $"VIIPER native controller type '{type}' cannot override its source-bound DS4Windows product ID.");
+ }
+ return expected;
+ }
+
+ internal bool HasExactControllerIdentity(string type, string vid,
+ string pid)
+ {
+ ViiperNativeControllerRegistration registration =
+ GetControllerRegistration(type);
+ return string.Equals(vid, registration.DefaultVid,
+ StringComparison.Ordinal) &&
+ string.Equals(pid, registration.Ds4WindowsPid,
+ StringComparison.Ordinal);
+ }
+
+ internal ViiperNativeBackendIdentity ValidatePing(string raw)
+ {
+ ViiperNativePingResponse ping;
+ try
+ {
+ ValidateNoDuplicateJsonProperties(raw, "ping");
+ ping = JsonSerializer.Deserialize(
+ raw, JsonOptions);
+ }
+ catch (JsonException ex)
+ {
+ throw new ViiperIdentityException(
+ "VIIPER ping was not valid JSON.", ex);
+ }
+ if (ping == null)
+ {
+ throw new ViiperIdentityException(
+ "VIIPER returned an empty ping identity.");
+ }
+ if (!string.Equals(ping.Server, "VIIPER",
+ StringComparison.Ordinal))
+ {
+ throw new ViiperIdentityException(
+ $"Unexpected VIIPER server identity '{ping.Server ?? ""}'.");
+ }
+ if (string.IsNullOrWhiteSpace(ping.Version))
+ {
+ throw new ViiperIdentityException(
+ "VIIPER ping omitted the broker version.");
+ }
+ if (!string.Equals(ping.Transport, "native-ude",
+ StringComparison.Ordinal))
+ {
+ throw new ViiperIdentityException(
+ $"VIIPER transport is '{ping.Transport ?? ""}', expected native-ude. USB/IP responses are not admitted on the managed native path.");
+ }
+ if (ping.Ready != true)
+ {
+ throw new ViiperIdentityException(
+ "VIIPER native-ude transport is not ready.");
+ }
+ if (ping.NativeUde == null)
+ {
+ throw new ViiperIdentityException(
+ "VIIPER ping omitted the nativeUde contract.");
+ }
+
+ ViiperNativeUdePingInfo native = ping.NativeUde;
+ if (native.AbiMajor != Metadata.AbiMajor ||
+ native.AbiMinor != Metadata.AbiMinor)
+ {
+ throw new ViiperIdentityException(
+ $"VIIPER native ABI is {native.AbiMajor}.{native.AbiMinor}, expected {Metadata.AbiMajor}.{Metadata.AbiMinor} from bundled metadata.");
+ }
+ if (native.Capabilities != Metadata.RequiredCapabilities)
+ {
+ throw new ViiperIdentityException(
+ $"VIIPER native capabilities are 0x{native.Capabilities:x}, expected exact 0x{Metadata.RequiredCapabilities:x} from bundled metadata.");
+ }
+ if (!string.Equals(native.ExpectedDriverPackageVersion,
+ Metadata.DriverPackageVersion, StringComparison.Ordinal))
+ {
+ throw new ViiperIdentityException(
+ $"VIIPER expects driver package '{native.ExpectedDriverPackageVersion ?? ""}', bundled metadata expects '{Metadata.DriverPackageVersion}'.");
+ }
+ ViiperNativeRuntimeMetadata.ValidateLowercaseSha256(
+ native.LoadedDriverBuildIdentity,
+ "loadedDriverBuildIdentity");
+ if (!CryptographicOperations.FixedTimeEquals(
+ Encoding.ASCII.GetBytes(native.LoadedDriverBuildIdentity),
+ Encoding.ASCII.GetBytes(
+ Metadata.LoadedDriverBuildIdentity)))
+ {
+ throw new ViiperIdentityException(
+ "VIIPER loaded-driver build identity does not match bundled metadata.");
+ }
+ ValidateControllerInstanceId(native.ControllerInstanceId);
+ ulong controllerSessionId = ParseCanonicalNonZeroUInt64(
+ native.ControllerSessionId,
+ "nativeUde.controllerSessionId");
+
+ return new ViiperNativeBackendIdentity
+ {
+ Server = ping.Server,
+ Version = ping.Version,
+ Transport = ping.Transport,
+ AbiMajor = native.AbiMajor,
+ AbiMinor = native.AbiMinor,
+ Capabilities = native.Capabilities,
+ DriverPackageVersion = native.ExpectedDriverPackageVersion,
+ DriverBuildIdentity = native.LoadedDriverBuildIdentity,
+ ControllerInstanceId = native.ControllerInstanceId,
+ ControllerSessionId = controllerSessionId,
+ };
+ }
+
+ internal static void ValidateNoDuplicateJsonProperties(string raw,
+ string responseName)
+ {
+ try
+ {
+ using JsonDocument document = JsonDocument.Parse(raw,
+ new JsonDocumentOptions
+ {
+ AllowTrailingCommas = false,
+ CommentHandling = JsonCommentHandling.Disallow,
+ MaxDepth = 32,
+ });
+ ValidateNoDuplicateJsonProperties(document.RootElement,
+ responseName ?? "response");
+ }
+ catch (JsonException ex)
+ {
+ throw new ViiperIdentityException(
+ $"VIIPER {responseName ?? "response"} was not valid JSON.",
+ ex);
+ }
+ }
+
+ private static void ValidateNoDuplicateJsonProperties(
+ JsonElement element, string path)
+ {
+ if (element.ValueKind == JsonValueKind.Object)
+ {
+ var names = new HashSet(StringComparer.Ordinal);
+ foreach (JsonProperty property in element.EnumerateObject())
+ {
+ if (!names.Add(property.Name))
+ {
+ throw new ViiperIdentityException(
+ $"VIIPER {path} contains duplicate JSON property '{property.Name}'.");
+ }
+ ValidateNoDuplicateJsonProperties(property.Value,
+ path + "." + property.Name);
+ }
+ }
+ else if (element.ValueKind == JsonValueKind.Array)
+ {
+ int index = 0;
+ foreach (JsonElement item in element.EnumerateArray())
+ {
+ ValidateNoDuplicateJsonProperties(item,
+ $"{path}[{index++}]");
+ }
+ }
+ }
+
+ internal static ulong ParseCanonicalNonZeroUInt64(string value,
+ string fieldName)
+ {
+ if (!ulong.TryParse(value, NumberStyles.None,
+ CultureInfo.InvariantCulture, out ulong parsed) ||
+ parsed == 0 ||
+ !string.Equals(parsed.ToString(CultureInfo.InvariantCulture),
+ value, StringComparison.Ordinal))
+ {
+ throw new ViiperIdentityException(
+ $"VIIPER {fieldName} is not canonical non-zero decimal uint64 text.");
+ }
+ return parsed;
+ }
+
+ internal static void ValidateControllerInstanceId(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value) ||
+ !string.Equals(value, value.Trim(), StringComparison.Ordinal) ||
+ value.IndexOf('/') >= 0 || value.IndexOf('\0') >= 0 ||
+ !value.StartsWith(@"ROOT\VIIPERUDE\",
+ StringComparison.OrdinalIgnoreCase) ||
+ !string.Equals(value, value.ToUpperInvariant(),
+ StringComparison.Ordinal))
+ {
+ throw new ViiperIdentityException(
+ "VIIPER nativeUde.controllerInstanceId is not a canonical VIIPER UdeCx controller instance ID.");
+ }
+ }
+ }
+
+ internal sealed class ViiperCredential : IDisposable
+ {
+ private int disposed;
+
+ internal ViiperCredential(string password, byte[] fingerprint)
+ {
+ Password = password ?? throw new ArgumentNullException(
+ nameof(password));
+ Fingerprint = fingerprint ?? throw new ArgumentNullException(
+ nameof(fingerprint));
+ }
+
+ internal string Password { get; private set; }
+ internal byte[] Fingerprint { get; private set; }
+
+ public void Dispose()
+ {
+ if (Interlocked.Exchange(ref disposed, 1) != 0)
+ {
+ return;
+ }
+ if (Fingerprint != null)
+ {
+ CryptographicOperations.ZeroMemory(Fingerprint);
+ Fingerprint = null;
+ }
+ Password = null;
+ }
+ }
+
+ internal interface IViiperCredentialProvider
+ {
+ ViiperCredential Read();
+ }
+
+ internal sealed class ViiperProgramDataCredentialProvider :
+ IViiperCredentialProvider
+ {
+ internal const string CredentialFileName = "viiper.key.txt";
+ internal const int ManagedCredentialLength = 16;
+
+ internal ViiperProgramDataCredentialProvider(string path = null)
+ {
+ CredentialPath = string.IsNullOrWhiteSpace(path) ?
+ Path.Combine(Environment.GetFolderPath(
+ Environment.SpecialFolder.CommonApplicationData),
+ "VIIPER", CredentialFileName) : Path.GetFullPath(path);
+ }
+
+ internal string CredentialPath { get; }
+
+ public ViiperCredential Read()
+ {
+ byte[] bytes = null;
+ try
+ {
+ FileAttributes attributes = File.GetAttributes(CredentialPath);
+ if ((attributes & FileAttributes.ReparsePoint) != 0 ||
+ (attributes & FileAttributes.Directory) != 0)
+ {
+ throw new ViiperCredentialException(
+ "The managed VIIPER credential is not a regular file.");
+ }
+ using FileStream file = new FileStream(CredentialPath,
+ FileMode.Open, FileAccess.Read, FileShare.Read,
+ ManagedCredentialLength,
+ FileOptions.SequentialScan);
+ if (file.Length != ManagedCredentialLength)
+ {
+ throw new ViiperCredentialException(
+ $"The managed VIIPER credential must be exactly {ManagedCredentialLength} bytes.");
+ }
+ bytes = new byte[ManagedCredentialLength];
+ int total = 0;
+ while (total < bytes.Length)
+ {
+ int read = file.Read(bytes, total, bytes.Length - total);
+ if (read == 0)
+ {
+ throw new ViiperCredentialException(
+ "The managed VIIPER credential changed while it was read.");
+ }
+ total += read;
+ }
+ if (file.ReadByte() != -1)
+ {
+ throw new ViiperCredentialException(
+ "The managed VIIPER credential changed while it was read.");
+ }
+ if (bytes.Any(value => !IsBase62(value)))
+ {
+ throw new ViiperCredentialException(
+ "The managed VIIPER credential is not canonical base62.");
+ }
+ string password = Encoding.ASCII.GetString(bytes);
+ byte[] fingerprint = SHA256.HashData(bytes);
+ return new ViiperCredential(password, fingerprint);
+ }
+ catch (ViiperCredentialException)
+ {
+ throw;
+ }
+ catch (Exception ex) when (ex is IOException ||
+ ex is UnauthorizedAccessException)
+ {
+ throw new ViiperCredentialException(
+ $"The managed VIIPER credential is missing or unreadable at '{CredentialPath}'.",
+ ex);
+ }
+ finally
+ {
+ if (bytes != null)
+ {
+ CryptographicOperations.ZeroMemory(bytes);
+ }
+ }
+ }
+
+ private static bool IsBase62(byte value) =>
+ value >= (byte)'0' && value <= (byte)'9' ||
+ value >= (byte)'A' && value <= (byte)'Z' ||
+ value >= (byte)'a' && value <= (byte)'z';
+ }
+
+ internal sealed class ViiperNativeSession
+ {
+ private readonly object sessionLock = new object();
+ private readonly object authenticationLock = new object();
+ private readonly IViiperCredentialProvider credentialProvider;
+ private readonly ViiperNativeRuntimeContract contract;
+ private byte[] credentialFingerprint;
+ private ViiperNativeBackendIdentity identity;
+ private Exception fatalError;
+ private bool hasAuthenticatedConnection;
+
+ internal ViiperNativeSession(ViiperNativeRuntimeContract contract,
+ IViiperCredentialProvider credentialProvider)
+ {
+ this.contract = contract ??
+ throw new ArgumentNullException(nameof(contract));
+ this.credentialProvider = credentialProvider ??
+ throw new ArgumentNullException(nameof(credentialProvider));
+ }
+
+ internal ViiperNativeBackendIdentity Identity
+ {
+ get
+ {
+ lock (sessionLock)
+ {
+ return identity;
+ }
+ }
+ }
+
+ internal ViiperNativeRuntimeContract Contract => contract;
+
+ internal bool HasAuthenticatedConnection
+ {
+ get
+ {
+ lock (sessionLock)
+ {
+ return hasAuthenticatedConnection;
+ }
+ }
+ }
+
+ internal Stream Authenticate(Stream transport)
+ {
+ lock (authenticationLock)
+ {
+ return AuthenticateSerialized(transport);
+ }
+ }
+
+ private Stream AuthenticateSerialized(Stream transport)
+ {
+ lock (sessionLock)
+ {
+ ThrowIfFatal();
+ }
+ using ViiperCredential credential = credentialProvider.Read();
+ lock (sessionLock)
+ {
+ ThrowIfFatal();
+ if (credentialFingerprint == null)
+ {
+ credentialFingerprint =
+ (byte[])credential.Fingerprint.Clone();
+ }
+ else if (!CryptographicOperations.FixedTimeEquals(
+ credentialFingerprint,
+ credential.Fingerprint))
+ {
+ ViiperAuthenticationException changed = new
+ ViiperAuthenticationException(
+ "The managed VIIPER credential generation changed during an active controller lifetime.");
+ fatalError = changed;
+ throw changed;
+ }
+ }
+
+ try
+ {
+ Stream authenticated =
+ ViiperAuthProtocol.AuthenticateClient(transport,
+ credential.Password);
+ lock (sessionLock)
+ {
+ try
+ {
+ ThrowIfFatal();
+ hasAuthenticatedConnection = true;
+ }
+ catch
+ {
+ authenticated.Dispose();
+ throw;
+ }
+ }
+ return authenticated;
+ }
+ catch (ViiperAuthenticationException ex)
+ {
+ lock (sessionLock)
+ {
+ fatalError ??= ex;
+ }
+ throw;
+ }
+ catch (Exception ex) when (ex is IOException ||
+ ex is CryptographicException ||
+ ex is UnauthorizedAccessException)
+ {
+ ViiperAuthenticationException wrapped = new
+ ViiperAuthenticationException(
+ "VIIPER connection authentication failed.", ex);
+ throw wrapped;
+ }
+ }
+
+ internal void InvalidateIdentity(Exception failure)
+ {
+ if (failure == null)
+ {
+ throw new ArgumentNullException(nameof(failure));
+ }
+ lock (sessionLock)
+ {
+ fatalError ??= failure;
+ }
+ }
+
+ internal ViiperNativeBackendIdentity AdmitPing(string raw,
+ bool reconnect)
+ {
+ ViiperNativeBackendIdentity candidate;
+ try
+ {
+ candidate = contract.ValidatePing(raw);
+ }
+ catch (Exception ex) when (ex is ViiperIdentityException ||
+ ex is JsonException)
+ {
+ lock (sessionLock)
+ {
+ fatalError ??= ex;
+ }
+ throw;
+ }
+
+ lock (sessionLock)
+ {
+ ThrowIfFatal();
+ if (identity == null)
+ {
+ identity = candidate;
+ }
+ else if (!identity.HasSameGeneration(candidate))
+ {
+ ViiperIdentityException changed = new
+ ViiperIdentityException(
+ "VIIPER backend identity changed during an active controller lifetime.");
+ fatalError = changed;
+ throw changed;
+ }
+ else if (reconnect && credentialFingerprint == null)
+ {
+ ViiperIdentityException changed = new
+ ViiperIdentityException(
+ "VIIPER reconnect has no pinned credential generation.");
+ fatalError = changed;
+ throw changed;
+ }
+ return identity;
+ }
+ }
+
+ private void ThrowIfFatal()
+ {
+ if (fatalError != null)
+ {
+ throw new ViiperIdentityException(
+ "The VIIPER native session is permanently invalid after a credential, authentication, or identity failure.",
+ fatalError);
+ }
+ }
+ }
+
+ internal sealed class ViiperNativeStatusProbe
+ {
+ internal bool Ready { get; init; }
+ internal bool MetadataPresent { get; init; }
+ internal bool MetadataEligible { get; init; }
+ internal bool CredentialReadable { get; init; }
+ internal bool Authenticated { get; init; }
+ internal bool IdentityValid { get; init; }
+ internal string FailureReason { get; init; }
+ internal ViiperNativeBackendIdentity Identity { get; init; }
+ }
+
+ internal static class ViiperNativeRuntime
+ {
+ internal static ViiperNativeStatusProbe GetStatusProbe(string host,
+ int port)
+ {
+ ViiperNativeRuntimeMetadata metadata;
+ bool metadataPresent = MetadataExists();
+ try
+ {
+ metadata = ViiperNativeRuntimeMetadata.LoadBundled();
+ }
+ catch (Exception ex)
+ {
+ return new ViiperNativeStatusProbe
+ {
+ MetadataPresent = metadataPresent,
+ MetadataEligible = false,
+ FailureReason = ex.Message,
+ };
+ }
+
+ IViiperCredentialProvider credentialProvider =
+ new ViiperProgramDataCredentialProvider();
+ try
+ {
+ using ViiperCredential ignored = credentialProvider.Read();
+ }
+ catch (Exception ex)
+ {
+ return new ViiperNativeStatusProbe
+ {
+ MetadataPresent = true,
+ MetadataEligible = true,
+ FailureReason = ex.Message,
+ };
+ }
+
+ ViiperClient client = null;
+ try
+ {
+ client = new ViiperClient(host, port,
+ ViiperTransportMode.NativeUde, metadata,
+ credentialProvider);
+ ViiperNativeBackendIdentity identity =
+ client.ValidateNativeBackend();
+ return new ViiperNativeStatusProbe
+ {
+ Ready = true,
+ MetadataPresent = true,
+ MetadataEligible = true,
+ CredentialReadable = true,
+ Authenticated = true,
+ IdentityValid = true,
+ Identity = identity,
+ };
+ }
+ catch (ViiperAuthenticationException ex)
+ {
+ return new ViiperNativeStatusProbe
+ {
+ MetadataPresent = true,
+ MetadataEligible = true,
+ CredentialReadable = true,
+ FailureReason = ex.Message,
+ };
+ }
+ catch (Exception ex)
+ {
+ return new ViiperNativeStatusProbe
+ {
+ MetadataPresent = true,
+ MetadataEligible = true,
+ CredentialReadable = true,
+ Authenticated = client?.HasAuthenticatedNativeConnection ==
+ true,
+ FailureReason = ex.Message,
+ };
+ }
+ }
+
+ private static bool MetadataExists()
+ {
+ return File.Exists(Path.Combine(AppContext.BaseDirectory,
+ ViiperNativeRuntimeMetadata.FileName)) ||
+ File.Exists(Path.Combine(AppContext.BaseDirectory, "extras",
+ ViiperNativeRuntimeMetadata.FileName));
+ }
+ }
+
+ internal sealed class ViiperNativePnpAnchor
+ {
+ internal ulong NativeDeviceId { get; init; }
+ internal uint NativeDeviceGeneration { get; init; }
+ internal ulong ControllerSessionId { get; init; }
+ internal string PnpRootInstanceId => ControllerInstanceId;
+ internal string PnpUsbDeviceInstanceId { get; init; }
+ internal string ControllerInstanceId { get; init; }
+ internal uint Usb20PortNumber { get; init; }
+ internal uint Usb30PortNumber { get; init; }
+ internal uint UdecxUsbPortNumber => Usb20PortNumber != 0 ?
+ Usb20PortNumber : Usb30PortNumber;
+
+ internal bool IsExact => NativeDeviceId != 0 &&
+ NativeDeviceGeneration != 0 &&
+ ControllerSessionId != 0 &&
+ !string.IsNullOrWhiteSpace(ControllerInstanceId) &&
+ (Usb20PortNumber != 0 ^ Usb30PortNumber != 0);
+ }
+
+ internal sealed class ViiperVirtualDeviceIdentity
+ {
+ internal ViiperTransportMode TransportMode { get; init; }
+ internal uint BusId { get; init; }
+ internal string DevId { get; init; }
+ internal string DeviceType { get; init; }
+ internal string Vid { get; init; }
+ internal string Pid { get; init; }
+ internal string DeviceSerialNumber { get; init; }
+ internal string BrokerBuildIdentity { get; init; }
+ internal string LogicalLifetimeId { get; init; }
+ internal long StreamGeneration { get; init; }
+ internal int LegacyUsbipPort { get; init; } = -1;
+ internal string LegacyUsbipOwnerSerial { get; init; }
+ internal ViiperNativePnpAnchor NativePnpAnchor { get; init; }
+
+ internal ViiperVirtualDeviceIdentity WithStreamGeneration(
+ long streamGeneration)
+ {
+ return new ViiperVirtualDeviceIdentity
+ {
+ TransportMode = TransportMode,
+ BusId = BusId,
+ DevId = DevId,
+ DeviceType = DeviceType,
+ Vid = Vid,
+ Pid = Pid,
+ DeviceSerialNumber = DeviceSerialNumber,
+ BrokerBuildIdentity = BrokerBuildIdentity,
+ LogicalLifetimeId = LogicalLifetimeId,
+ StreamGeneration = streamGeneration,
+ LegacyUsbipPort = LegacyUsbipPort,
+ LegacyUsbipOwnerSerial = LegacyUsbipOwnerSerial,
+ NativePnpAnchor = NativePnpAnchor,
+ };
+ }
+ }
+}
diff --git a/DS4Windows/DS4Control/Viiper/ViiperOutDevice.cs b/DS4Windows/DS4Control/Viiper/ViiperOutDevice.cs
index 150f4d8..2c686e7 100644
--- a/DS4Windows/DS4Control/Viiper/ViiperOutDevice.cs
+++ b/DS4Windows/DS4Control/Viiper/ViiperOutDevice.cs
@@ -12,7 +12,9 @@ it under the terms of the GNU General Public License as published by
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Diagnostics;
+using System.Globalization;
using System.IO;
+using System.Linq;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
@@ -127,6 +129,8 @@ public sealed class ViiperOutDevice : OutputDevice
private const int DualSenseCombinedBluetoothReportLength = 398;
private const int DualSenseCombinedBluetoothReportOffset = DualSenseNativeOutputReportOffset + DualSenseNativeOutputReportLength;
private const int DualSenseCombinedExtendedFeedbackLength = DualSenseCombinedBluetoothReportOffset + DualSenseCombinedBluetoothReportLength;
+ private const int DualSenseV5SpeakerPcmLength =
+ 480 * sizeof(short) * 2;
internal const int DualSenseAtomicFeedbackLength =
DualSenseCombinedExtendedFeedbackLength;
private const int DualSenseMicrophoneOpusFrameLength = 71;
@@ -151,9 +155,11 @@ public sealed class ViiperOutDevice : OutputDevice
private const byte ViiperStreamFrameOutputState = 0x81;
private const byte ViiperStreamFrameSpeakerPcm = 0x82;
private const byte ViiperStreamFrameAtomicAudioHaptics = 0x83;
+ private const byte ViiperStreamFrameRealtimeHaptics = 0x84;
private const byte ViiperStreamFrameVersionV2 = 0x02;
private const byte ViiperStreamFrameVersionV3 = 0x03;
private const byte ViiperStreamFrameVersionV4 = 0x04;
+ private const byte ViiperStreamFrameVersionV5 = 0x05;
private const byte FeedbackSpeakerKindPcm = 0;
private const byte FeedbackSpeakerKindAtomicAudioHaptics = 1;
private const int AtomicAudioHapticsFeedbackLengthPrefix = 2;
@@ -183,6 +189,8 @@ public sealed class ViiperOutDevice : OutputDevice
private readonly ViiperVirtualDeviceType viiperType;
private readonly bool audioOnlySidecar;
private readonly ViiperClient client;
+ private readonly ViiperLiveValidationLease liveValidationLease;
+ private readonly object liveValidationObservationLock = new object();
private readonly object pendingPacketLock = new object();
private readonly object microphoneQueueLock = new object();
private readonly object microphoneProcessingLock = new object();
@@ -343,6 +351,12 @@ private readonly MicrophoneDisableRetryTracker
private bool physicalDualSenseIdentityVerified;
private readonly byte[] lastR2TriggerFeedback = new byte[DualSenseTriggerEffectLength];
private readonly byte[] lastL2TriggerFeedback = new byte[DualSenseTriggerEffectLength];
+ private byte[] liveValidationLastFeedback;
+ private long liveValidationFeedbackFrames;
+ private long liveValidationMicrophoneFramesSubmitted;
+ private long liveValidationMicrophoneBytesSubmitted;
+ private long liveValidationTransportInterruptions;
+ private long liveValidationStreamRecoveriesCompleted;
private enum MicrophoneCodec : byte
{
@@ -369,10 +383,24 @@ public PendingMicrophoneFrame(MicrophoneCodec codec, byte[] data,
public ViiperOutDevice(OutContType outputType,
ViiperVirtualDeviceType viiperType, bool audioOnlySidecar = false)
+ : this(outputType, viiperType, audioOnlySidecar, null, null)
{
+ }
+
+ internal ViiperOutDevice(OutContType outputType,
+ ViiperVirtualDeviceType viiperType, bool audioOnlySidecar,
+ ViiperLiveValidationLease validationLease,
+ ViiperNativeRuntimeMetadata validationMetadata)
+ {
+ if ((validationLease == null) != (validationMetadata == null))
+ {
+ throw new ArgumentException(
+ "A live-validation lease and its exact metadata must be supplied together.");
+ }
this.outputType = outputType;
this.viiperType = viiperType;
this.audioOnlySidecar = audioOnlySidecar;
+ liveValidationLease = validationLease;
feedbackDispatchBuffer = new ViiperFeedbackDispatchBuffer(
// The buffer implementation requires one preallocated slot.
// Non-audio devices never enqueue it; their public policy is
@@ -385,7 +413,170 @@ public ViiperOutDevice(OutContType outputType,
GetFeedbackSpeakerMaximumAgeMilliseconds(viiperType),
IsDualSenseVirtualType(viiperType) ?
FeedbackOrderedControlMaximumAgeMilliseconds : 0);
- client = new ViiperClient(DefaultHost, DefaultPort);
+ client = validationLease == null ?
+ new ViiperClient(DefaultHost, DefaultPort) :
+ new ViiperClient(DefaultHost, DefaultPort,
+ ViiperTransportMode.NativeUde, validationMetadata);
+ }
+
+ internal ViiperLiveValidationSnapshot GetLiveValidationSnapshot(
+ ViiperLiveValidationLease lease)
+ {
+ DemandLiveValidationLease(lease);
+ byte[] feedback;
+ lock (liveValidationObservationLock)
+ {
+ feedback = liveValidationLastFeedback == null ? null :
+ (byte[])liveValidationLastFeedback.Clone();
+ }
+
+ return new ViiperLiveValidationSnapshot
+ {
+ HandlerName = GetLiveValidationHandlerName(),
+ StreamProtocol = activeStreamFrameVersion switch
+ {
+ ViiperStreamFrameVersionV3 => "framed-v3",
+ ViiperStreamFrameVersionV5 => "framed-v5",
+ _ => "inactive",
+ },
+ StreamFrameVersion = activeStreamFrameVersion,
+ Connected = connected,
+ SupportsMicrophone = activeStreamSupportsMicrophone,
+ SupportsDirectSpeaker = activeStreamSupportsDirectSpeaker,
+ SupportsAtomicAudioHaptics =
+ activeStreamSupportsAtomicAudioHaptics,
+ BackendIdentity = client.NativeBackendIdentity,
+ DeviceIdentity = VirtualDeviceIdentity,
+ StatePacketsSubmitted = Interlocked.Read(
+ ref submittedPacketCount),
+ StatePacketsWritten = Interlocked.Read(ref writtenPacketCount),
+ StatePacketsCoalesced = Interlocked.Read(
+ ref replacedPendingPacketCount),
+ StreamRecoveryAttempts = Volatile.Read(
+ ref streamRecoveryAttempts),
+ FeedbackFramesObserved = Interlocked.Read(
+ ref liveValidationFeedbackFrames),
+ LastFeedbackPayload = feedback,
+ ValidationMicrophoneFramesSubmitted = Interlocked.Read(
+ ref liveValidationMicrophoneFramesSubmitted),
+ ValidationMicrophoneBytesSubmitted = Interlocked.Read(
+ ref liveValidationMicrophoneBytesSubmitted),
+ ValidationTransportInterruptions = Interlocked.Read(
+ ref liveValidationTransportInterruptions),
+ ValidationStreamRecoveriesCompleted = Interlocked.Read(
+ ref liveValidationStreamRecoveriesCompleted),
+ SpeakerFramesEnqueued = feedbackDispatchBuffer.SpeakerEnqueued,
+ SpeakerFramesDequeued = feedbackDispatchBuffer.SpeakerDequeued,
+ SpeakerFramesDropped = feedbackDispatchBuffer.SpeakerDropped,
+ SpeakerFramesExpired = feedbackDispatchBuffer.SpeakerExpired,
+ SpeakerFramesDelivered = Interlocked.Read(
+ ref feedbackSpeakerDelivered),
+ SpeakerFramesStale = Interlocked.Read(
+ ref feedbackSpeakerStale),
+ SpeakerNoSubscriberDeferrals = Interlocked.Read(
+ ref feedbackSpeakerNoSubscriberDeferrals),
+ SpeakerCallbackFailures = Interlocked.Read(
+ ref feedbackSpeakerCallbackFailures),
+ ControlFramesEnqueued = feedbackDispatchBuffer.ControlEnqueued,
+ ControlFramesDequeued = feedbackDispatchBuffer.ControlDequeued,
+ ControlFramesDropped = feedbackDispatchBuffer.ControlDropped,
+ OrderedControlFramesEnqueued =
+ feedbackDispatchBuffer.OrderedControlEnqueued,
+ OrderedControlFramesDequeued =
+ feedbackDispatchBuffer.OrderedControlDequeued,
+ OrderedControlFramesDropped =
+ feedbackDispatchBuffer.OrderedControlDropped,
+ OrderedControlFramesExpired =
+ feedbackDispatchBuffer.OrderedControlExpired,
+ ControlFramesDelivered = Interlocked.Read(
+ ref feedbackControlDelivered),
+ ControlFramesStale = Interlocked.Read(
+ ref feedbackControlStale),
+ ControlCallbackFailures = Interlocked.Read(
+ ref feedbackControlCallbackFailures),
+ };
+ }
+
+ internal void SubmitLiveValidationMicrophonePcm(
+ ViiperLiveValidationLease lease, byte[] pcm)
+ {
+ DemandLiveValidationLease(lease);
+ int expectedLength = viiperType ==
+ ViiperVirtualDeviceType.DualShock4 ?
+ DualShock4VirtualMicrophonePcmFrameLength :
+ DualSenseMicrophonePcmFrameLength;
+ if (!connected || !activeStreamSupportsMicrophone ||
+ !activeStreamUsesFramedProtocol || pcm == null ||
+ pcm.Length != expectedLength || pcm.All(value => value == 0))
+ {
+ throw new ViiperIdentityException(
+ $"Live validation requires one non-silent {expectedLength}-byte microphone PCM frame on an active framed PlayStation stream.");
+ }
+
+ ViiperDeviceStream stream = Volatile.Read(ref deviceStream) ??
+ throw new ObjectDisposedException(nameof(ViiperDeviceStream));
+ stream.WriteFrame(activeStreamFrameVersion,
+ ViiperStreamFrameMicrophonePcm, pcm);
+ Interlocked.Increment(
+ ref liveValidationMicrophoneFramesSubmitted);
+ Interlocked.Add(ref liveValidationMicrophoneBytesSubmitted,
+ pcm.Length);
+ }
+
+ internal void InterruptLiveValidationTransport(
+ ViiperLiveValidationLease lease)
+ {
+ DemandLiveValidationLease(lease);
+ ViiperDeviceStream stream = Volatile.Read(ref deviceStream) ??
+ throw new ObjectDisposedException(nameof(ViiperDeviceStream));
+ Interlocked.Increment(ref liveValidationTransportInterruptions);
+ stream.CloseTransport();
+ }
+
+ private void DemandLiveValidationLease(
+ ViiperLiveValidationLease lease)
+ {
+ if (liveValidationLease == null ||
+ !ReferenceEquals(liveValidationLease, lease))
+ {
+ throw new ViiperIdentityException(
+ "The VIIPER live-validation hook is unavailable without the exact opt-in lease.");
+ }
+ }
+
+ private string GetLiveValidationHandlerName()
+ {
+ return viiperType switch
+ {
+ ViiperVirtualDeviceType.DualShock4 =>
+ audioOnlySidecar ? "dualshock4audioonlyduplexv3" :
+ "dualshock4audioduplexv3",
+ ViiperVirtualDeviceType.DualSense =>
+ audioOnlySidecar ? "dualsenseaudioonlyduplexv5" :
+ "dualsensecombinedaudioduplexv5",
+ ViiperVirtualDeviceType.DualSenseEdge =>
+ "dualsenseedgecombinedaudioduplexv5",
+ _ => ViiperStatePacketBuilder.GetViiperDeviceName(viiperType),
+ };
+ }
+
+ private void ObserveLiveValidationFeedback(byte[] feedback,
+ int feedbackLength)
+ {
+ if (liveValidationLease == null || feedback == null ||
+ feedbackLength <= 0 || feedbackLength > feedback.Length ||
+ feedbackLength > DualSenseCombinedExtendedFeedbackLength)
+ {
+ return;
+ }
+
+ byte[] copy = new byte[feedbackLength];
+ Buffer.BlockCopy(feedback, 0, copy, 0, feedbackLength);
+ lock (liveValidationObservationLock)
+ {
+ liveValidationLastFeedback = copy;
+ }
+ Interlocked.Increment(ref liveValidationFeedbackFrames);
}
internal static int GetFeedbackSpeakerQueueCapacity(
@@ -461,6 +652,14 @@ internal static bool TryGetAtomicAudioHapticsLayout(byte[] payload,
(speakerPcmLength & (sizeof(short) * 2 - 1)) == 0;
}
+ internal static bool IsValidV5RealtimeHapticsFrame(byte version,
+ byte frameType, int payloadLength)
+ {
+ return version == ViiperStreamFrameVersionV5 &&
+ frameType == ViiperStreamFrameRealtimeHaptics &&
+ payloadLength == DualSenseCombinedExtendedFeedbackLength;
+ }
+
private Action
virtualSpeakerPcmReceived;
private ViiperAtomicAudioHapticsHandler
@@ -532,6 +731,9 @@ private ViiperAtomicAudioHapticsHandler
internal bool IsRuntimeConnected =>
connected && Volatile.Read(ref deviceStream) != null;
+ internal ViiperVirtualDeviceIdentity VirtualDeviceIdentity =>
+ Volatile.Read(ref deviceStream)?.VirtualDeviceIdentity;
+
internal bool SupportsAtomicAudioHaptics =>
connected && activeStreamSupportsAtomicAudioHaptics;
@@ -548,8 +750,15 @@ internal void ApplyAtomicAudioHapticsFeedback(byte[] feedback,
SupportsDirectSpeakerPcm ?
GetVirtualSpeakerPcmSampleRate(viiperType) : 0;
- internal int DirectSpeakerUsbipPort =>
- Volatile.Read(ref deviceStream)?.UsbipPort ?? -1;
+ internal int DirectSpeakerUsbipPort
+ {
+ get
+ {
+ ViiperDeviceStream stream = Volatile.Read(ref deviceStream);
+ return stream?.TransportMode == ViiperTransportMode.Usbip ?
+ stream.UsbipPort : -1;
+ }
+ }
internal bool SupportsActiveVirtualMicrophone =>
connected && activeStreamSupportsMicrophone;
@@ -580,11 +789,9 @@ public override void Connect()
{
Disconnect();
- ViiperPrerequisiteStatus status = ViiperSetupManager.GetStatus(tryStartServer: true);
- if (!status.Ready)
+ if (client.TransportMode == ViiperTransportMode.NativeUde)
{
- throw new IOException(
- $"{status.DisplayText}. Use Settings > VIIPER Virtual Controller Support to install or repair VIIPER and usbip-win2.");
+ client.ValidateNativeBackend();
}
deviceStream = CreateDeviceStreamWithServerFallback();
@@ -653,6 +860,19 @@ public override void Connect()
Interlocked.Exchange(ref feedbackControlDelivered, 0);
Interlocked.Exchange(ref feedbackControlStale, 0);
Interlocked.Exchange(ref feedbackControlCallbackFailures, 0);
+ Interlocked.Exchange(ref liveValidationFeedbackFrames, 0);
+ Interlocked.Exchange(
+ ref liveValidationMicrophoneFramesSubmitted, 0);
+ Interlocked.Exchange(
+ ref liveValidationMicrophoneBytesSubmitted, 0);
+ Interlocked.Exchange(
+ ref liveValidationTransportInterruptions, 0);
+ Interlocked.Exchange(
+ ref liveValidationStreamRecoveriesCompleted, 0);
+ lock (liveValidationObservationLock)
+ {
+ liveValidationLastFeedback = null;
+ }
feedbackDispatchBuffer.Reset();
lock (physicalDualSenseIdentityLock)
{
@@ -702,277 +922,56 @@ private ViiperDeviceStream CreateDeviceStream()
if (viiperType == ViiperVirtualDeviceType.DualSense)
{
- if (audioOnlySidecar)
- {
- try
- {
- ViiperDeviceStream stream = client.CreateDeviceAndOpenStream(
- "dualsenseaudioonlyduplexv4");
- activeFeedbackLength = DualSenseCombinedExtendedFeedbackLength;
- activeStreamUsesFramedProtocol = true;
- activeStreamSupportsMicrophone = true;
- activeStreamSupportsDirectSpeaker = true;
- activeStreamSupportsAtomicAudioHaptics = true;
- activeStreamUsesAudioOnlyDescriptor = true;
- activeStreamFrameVersion = ViiperStreamFrameVersionV4;
- return stream;
- }
- catch (IOException ex)
- {
- AppLogger.LogToGui(
- $"VIIPER DualSense audio-only sidecar V4 unavailable, trying V3: {ex.Message}",
- false);
- }
-
- try
- {
- ViiperDeviceStream stream = client.CreateDeviceAndOpenStream(
- "dualsenseaudioonlyduplexv3");
- activeFeedbackLength = DualSenseCombinedExtendedFeedbackLength;
- activeStreamUsesFramedProtocol = true;
- activeStreamSupportsMicrophone = true;
- activeStreamSupportsDirectSpeaker = true;
- activeStreamUsesAudioOnlyDescriptor = true;
- activeStreamFrameVersion = ViiperStreamFrameVersionV3;
- return stream;
- }
- catch (IOException ex)
- {
- AppLogger.LogToGui(
- $"VIIPER DualSense audio-only sidecar unavailable: {ex.Message}",
- true);
- throw new IOException(
- "The installed VIIPER build does not support the DualSense audio-only interface. Update VIIPER from Settings before using PlayStation audio with an Xbox or Switch output.",
- ex);
- }
- }
-
- try
- {
- ViiperDeviceStream stream = client.CreateDeviceAndOpenStream(
- "dualsensecombinedaudioduplexv4");
- activeFeedbackLength = DualSenseCombinedExtendedFeedbackLength;
- activeStreamUsesFramedProtocol = true;
- activeStreamSupportsMicrophone = true;
- activeStreamSupportsDirectSpeaker = true;
- activeStreamSupportsAtomicAudioHaptics = true;
- activeStreamFrameVersion = ViiperStreamFrameVersionV4;
- return stream;
- }
- catch (IOException ex)
- {
- AppLogger.LogToGui(
- $"VIIPER DualSense atomic audio/haptics stream unavailable, trying V3: {ex.Message}",
- false);
- }
-
- try
- {
- ViiperDeviceStream stream = client.CreateDeviceAndOpenStream(
- "dualsensecombinedaudioduplexv3");
- activeFeedbackLength = DualSenseCombinedExtendedFeedbackLength;
- activeStreamUsesFramedProtocol = true;
- activeStreamSupportsMicrophone = true;
- activeStreamSupportsDirectSpeaker = true;
- activeStreamFrameVersion = ViiperStreamFrameVersionV3;
- return stream;
- }
- catch (IOException ex)
- {
- AppLogger.LogToGui(
- $"VIIPER DualSense direct speaker stream unavailable, trying microphone V2: {ex.Message}",
- false);
- }
-
- try
- {
- ViiperDeviceStream stream = client.CreateDeviceAndOpenStream("dualsensecombinedmicv2");
- activeFeedbackLength = DualSenseCombinedExtendedFeedbackLength;
- activeStreamUsesFramedProtocol = true;
- activeStreamSupportsMicrophone = true;
- activeStreamFrameVersion = ViiperStreamFrameVersionV2;
- return stream;
- }
- catch (IOException ex)
- {
- AppLogger.LogToGui($"VIIPER DualSense microphone input unavailable, continuing without mic-in: {ex.Message}", false);
- }
-
- try
- {
- ViiperDeviceStream stream = client.CreateDeviceAndOpenStream("dualsensecombinedext");
- activeFeedbackLength = DualSenseCombinedExtendedFeedbackLength;
- return stream;
- }
- catch (IOException ex)
- {
- try
- {
- AppLogger.LogToGui($"VIIPER DualSense combined haptics feedback unavailable, using legacy extended feedback: {ex.Message}", false);
- ViiperDeviceStream stream = client.CreateDeviceAndOpenStream("dualsenseext");
- activeFeedbackLength = DualSenseExtendedFeedbackLength;
- return stream;
- }
- catch (IOException legacyEx)
- {
- AppLogger.LogToGui($"VIIPER DualSense adaptive trigger feedback unavailable, falling back to base DualSense output: {legacyEx.Message}", false);
- activeFeedbackLength = DualSenseBaseFeedbackLength;
- return client.CreateDeviceAndOpenStream("dualsense");
- }
- }
+ ViiperDeviceStream stream = client.CreateDeviceAndOpenStream(
+ audioOnlySidecar ? "dualsenseaudioonlyduplexv5" :
+ "dualsensecombinedaudioduplexv5");
+ ConfigureDualSenseV5Stream(audioOnlySidecar);
+ return stream;
}
if (viiperType == ViiperVirtualDeviceType.DualSenseEdge)
{
- try
- {
- ViiperDeviceStream stream = client.CreateDeviceAndOpenStream(
- "dualsenseedgecombinedaudioduplexv4");
- activeFeedbackLength = DualSenseCombinedExtendedFeedbackLength;
- activeStreamUsesFramedProtocol = true;
- activeStreamSupportsMicrophone = true;
- activeStreamSupportsDirectSpeaker = true;
- activeStreamSupportsAtomicAudioHaptics = true;
- activeStreamFrameVersion = ViiperStreamFrameVersionV4;
- return stream;
- }
- catch (IOException ex)
- {
- AppLogger.LogToGui(
- $"VIIPER DualSense Edge atomic audio/haptics stream unavailable, trying V3: {ex.Message}",
- false);
- }
-
- try
- {
- ViiperDeviceStream stream = client.CreateDeviceAndOpenStream(
- "dualsenseedgecombinedaudioduplexv3");
- activeFeedbackLength = DualSenseCombinedExtendedFeedbackLength;
- activeStreamUsesFramedProtocol = true;
- activeStreamSupportsMicrophone = true;
- activeStreamSupportsDirectSpeaker = true;
- activeStreamFrameVersion = ViiperStreamFrameVersionV3;
- return stream;
- }
- catch (IOException ex)
- {
- AppLogger.LogToGui(
- $"VIIPER DualSense Edge direct speaker stream unavailable, trying microphone V2: {ex.Message}",
- false);
- }
-
- try
- {
- ViiperDeviceStream stream = client.CreateDeviceAndOpenStream("dualsenseedgecombinedmicv2");
- activeFeedbackLength = DualSenseCombinedExtendedFeedbackLength;
- activeStreamUsesFramedProtocol = true;
- activeStreamSupportsMicrophone = true;
- activeStreamFrameVersion = ViiperStreamFrameVersionV2;
- return stream;
- }
- catch (IOException ex)
- {
- AppLogger.LogToGui($"VIIPER DualSense Edge microphone input unavailable, continuing without mic-in: {ex.Message}", false);
- }
-
- try
- {
- ViiperDeviceStream stream = client.CreateDeviceAndOpenStream("dualsenseedgecombinedext");
- activeFeedbackLength = DualSenseCombinedExtendedFeedbackLength;
- return stream;
- }
- catch (IOException ex)
+ if (audioOnlySidecar)
{
- try
- {
- AppLogger.LogToGui($"VIIPER DualSense Edge combined haptics feedback unavailable, using legacy extended feedback: {ex.Message}", false);
- ViiperDeviceStream stream = client.CreateDeviceAndOpenStream("dualsenseedgeext");
- activeFeedbackLength = DualSenseExtendedFeedbackLength;
- return stream;
- }
- catch (IOException legacyEx)
- {
- AppLogger.LogToGui($"VIIPER DualSense Edge adaptive trigger feedback unavailable, falling back to base DualSense Edge output: {legacyEx.Message}", false);
- activeFeedbackLength = DualSenseBaseFeedbackLength;
- return client.CreateDeviceAndOpenStream("dualsenseedge");
- }
+ throw new ViiperIdentityException(
+ "The authoritative VIIPER contract does not expose a DualSense Edge audio-only V5 handler.");
}
+ ViiperDeviceStream stream = client.CreateDeviceAndOpenStream(
+ "dualsenseedgecombinedaudioduplexv5");
+ ConfigureDualSenseV5Stream(audioOnly: false);
+ return stream;
}
if (viiperType == ViiperVirtualDeviceType.DualShock4)
{
- if (audioOnlySidecar)
- {
- try
- {
- ViiperDeviceStream stream = client.CreateDeviceAndOpenStream(
- "dualshock4audioonlyduplexv3", 0x05C4);
- activeFeedbackLength = ViiperStatePacketBuilder.GetFeedbackLength(
- viiperType);
- activeStreamUsesFramedProtocol = true;
- activeStreamSupportsMicrophone = true;
- activeStreamSupportsDirectSpeaker = true;
- activeStreamUsesAudioOnlyDescriptor = true;
- activeStreamFrameVersion = ViiperStreamFrameVersionV3;
- return stream;
- }
- catch (IOException ex)
- {
- AppLogger.LogToGui(
- $"VIIPER DualShock 4 audio-only sidecar unavailable: {ex.Message}",
- true);
- throw new IOException(
- "The installed VIIPER build does not support the DualShock 4 audio-only interface. Update VIIPER from Settings before using PlayStation audio with an Xbox or Switch output.",
- ex);
- }
- }
-
- try
- {
- ViiperDeviceStream stream = client.CreateDeviceAndOpenStream(
- "dualshock4audioduplexv3", 0x05C4);
- activeFeedbackLength = ViiperStatePacketBuilder.GetFeedbackLength(
- viiperType);
- activeStreamUsesFramedProtocol = true;
- activeStreamSupportsMicrophone = true;
- activeStreamSupportsDirectSpeaker = true;
- activeStreamFrameVersion = ViiperStreamFrameVersionV3;
- return stream;
- }
- catch (IOException ex)
- {
- AppLogger.LogToGui(
- $"VIIPER DualShock 4 direct speaker stream unavailable, trying microphone V2: {ex.Message}",
- false);
- }
-
- try
- {
- ViiperDeviceStream stream = client.CreateDeviceAndOpenStream(
- "dualshock4micv2", 0x05C4);
- activeFeedbackLength = ViiperStatePacketBuilder.GetFeedbackLength(
- viiperType);
- activeStreamUsesFramedProtocol = true;
- activeStreamSupportsMicrophone = true;
- activeStreamFrameVersion = ViiperStreamFrameVersionV2;
- return stream;
- }
- catch (IOException ex)
- {
- AppLogger.LogToGui(
- $"VIIPER DualShock 4 microphone input unavailable, continuing without mic-in: {ex.Message}",
- false);
- }
-
+ ViiperDeviceStream stream = client.CreateDeviceAndOpenStream(
+ audioOnlySidecar ? "dualshock4audioonlyduplexv3" :
+ "dualshock4audioduplexv3", 0x05C4);
activeFeedbackLength = ViiperStatePacketBuilder.GetFeedbackLength(
viiperType);
- return client.CreateDeviceAndOpenStream("dualshock4", 0x05C4);
+ activeStreamUsesFramedProtocol = true;
+ activeStreamSupportsMicrophone = true;
+ activeStreamSupportsDirectSpeaker = true;
+ activeStreamUsesAudioOnlyDescriptor = audioOnlySidecar;
+ activeStreamFrameVersion = ViiperStreamFrameVersionV3;
+ return stream;
}
activeFeedbackLength = ViiperStatePacketBuilder.GetFeedbackLength(viiperType);
return client.CreateDeviceAndOpenStream(viiperType);
}
+ private void ConfigureDualSenseV5Stream(bool audioOnly)
+ {
+ activeFeedbackLength = DualSenseCombinedExtendedFeedbackLength;
+ activeStreamUsesFramedProtocol = true;
+ activeStreamSupportsMicrophone = true;
+ activeStreamSupportsDirectSpeaker = true;
+ activeStreamSupportsAtomicAudioHaptics = true;
+ activeStreamUsesAudioOnlyDescriptor = audioOnly;
+ activeStreamFrameVersion = ViiperStreamFrameVersionV5;
+ }
+
private ViiperDeviceStream CreateDeviceStreamWithServerFallback()
{
try
@@ -981,13 +980,9 @@ private ViiperDeviceStream CreateDeviceStreamWithServerFallback()
}
catch (IOException first)
{
- ViiperPrerequisiteStatus status = ViiperSetupManager.GetStatus(tryStartServer: true);
- if (!status.Ready)
- {
- throw;
- }
-
- AppLogger.LogToGui($"VIIPER {viiperType} stream open failed once; server is available, retrying: {first.Message}", false);
+ AppLogger.LogToGui(
+ $"VIIPER {viiperType} stream open failed once; retrying the selected {client.TransportMode} transport: {first.Message}",
+ false);
Thread.Sleep(250);
return CreateDeviceStream();
}
@@ -2062,8 +2057,9 @@ private bool TryRecoverStream(string reason, long failedStreamGeneration,
$"VIIPER {viiperType} stream interrupted; reopening the existing virtual device: {reason}",
true);
- // Closing only the TCP transport wakes the old feedback reader
- // without detaching usbip or removing the virtual controller.
+ // Closing only the authenticated/raw TCP transport wakes the
+ // old feedback reader without releasing transport ownership or
+ // removing the virtual controller.
// Keep the published generation and lifetime intact until a
// replacement transport has actually opened.
interruptedStream.CloseTransport();
@@ -2091,7 +2087,6 @@ private bool TryRecoverStream(string reason, long failedStreamGeneration,
client.OpenExistingDeviceStream(
interruptedStream.BusId,
interruptedStream.DevId,
- interruptedStream.UsbipPort,
interruptedStream.DeviceLifetime);
if (writerStopRequested || !connected)
{
@@ -2104,6 +2099,11 @@ private bool TryRecoverStream(string reason, long failedStreamGeneration,
{
deviceStream = replacement;
Interlocked.Increment(ref streamGeneration);
+ if (liveValidationLease != null)
+ {
+ Interlocked.Increment(ref
+ liveValidationStreamRecoveriesCompleted);
+ }
Interlocked.Exchange(ref streamRecoveryAttempts, 0);
// Publish the new timeline only after every old
// callback and reader admission has left its read
@@ -2842,7 +2842,8 @@ private void FeedbackReadLoop(int feedbackLength,
break;
}
- if (frameType == ViiperStreamFrameOutputState)
+ if (frameType == ViiperStreamFrameOutputState &&
+ payloadLength == feedbackLength)
{
int targetDeviceIndex = Volatile.Read(
ref lastInputDeviceIndex);
@@ -2863,6 +2864,8 @@ private void FeedbackReadLoop(int feedbackLength,
}
else if (frameType ==
ViiperStreamFrameSpeakerPcm &&
+ activeStreamFrameVersion ==
+ ViiperStreamFrameVersionV3 &&
payloadLength > 0 && payloadLength %
(sizeof(short) * 2) == 0)
{
@@ -2878,6 +2881,8 @@ private void FeedbackReadLoop(int feedbackLength,
else if (frameType ==
ViiperStreamFrameAtomicAudioHaptics &&
activeStreamSupportsAtomicAudioHaptics &&
+ activeStreamFrameVersion ==
+ ViiperStreamFrameVersionV5 &&
payloadLength >
AtomicAudioHapticsFeedbackLengthPrefix)
{
@@ -2888,12 +2893,15 @@ private void FeedbackReadLoop(int feedbackLength,
int speakerPcmLength = payloadLength -
AtomicAudioHapticsFeedbackLengthPrefix -
atomicFeedbackLength;
- if (atomicFeedbackLength ==
- DualSenseCombinedExtendedFeedbackLength &&
- speakerPcmLength > 0 &&
- (speakerPcmLength &
- (sizeof(short) * 2 - 1)) == 0 &&
- feedbackDispatchBuffer.TryEnqueueSpeaker(
+ if (atomicFeedbackLength !=
+ DualSenseCombinedExtendedFeedbackLength ||
+ speakerPcmLength !=
+ DualSenseV5SpeakerPcmLength)
+ {
+ throw new IOException(
+ "VIIPER returned a malformed V5 atomic audio/haptics payload.");
+ }
+ if (feedbackDispatchBuffer.TryEnqueueSpeaker(
framedPayload, payloadLength,
readStreamGeneration,
FeedbackSpeakerKindAtomicAudioHaptics,
@@ -2902,6 +2910,25 @@ private void FeedbackReadLoop(int feedbackLength,
feedbackSpeakerSignal.Set();
}
}
+ else if (IsValidV5RealtimeHapticsFrame(
+ activeStreamFrameVersion, frameType,
+ payloadLength))
+ {
+ if (feedbackDispatchBuffer
+ .TryEnqueueOrderedControl(
+ framedPayload, payloadLength,
+ readStreamGeneration,
+ Volatile.Read(
+ ref lastInputDeviceIndex)))
+ {
+ feedbackControlSignal.Set();
+ }
+ }
+ else
+ {
+ throw new IOException(
+ $"VIIPER returned an unsupported {activeStreamFrameVersion:X2}/{frameType:X2} framed feedback packet of {payloadLength} bytes.");
+ }
}
finally
{
@@ -2956,8 +2983,14 @@ private void ApplyFeedback(byte[] feedback, int feedbackLength,
int expectedDeviceIndex = -1)
{
int deviceIndex = Volatile.Read(ref lastInputDeviceIndex);
- if ((expectedDeviceIndex >= 0 &&
- expectedDeviceIndex != deviceIndex) ||
+ if (expectedDeviceIndex >= 0 &&
+ expectedDeviceIndex != deviceIndex)
+ {
+ return;
+ }
+
+ ObserveLiveValidationFeedback(feedback, feedbackLength);
+ if (
deviceIndex < 0 ||
Program.rootHub == null ||
deviceIndex >= Program.rootHub.DS4Controllers.Length ||
@@ -4554,13 +4587,111 @@ internal sealed class ViiperClient
PropertyNameCaseInsensitive = true,
};
+ private static readonly JsonSerializerOptions NativeJsonOptions =
+ new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = false,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
+ };
+
private readonly string host;
private readonly int port;
+ private readonly object nativeSessionLock = new object();
+ private readonly ViiperNativeRuntimeMetadata deferredNativeMetadata;
+ private readonly IViiperCredentialProvider deferredCredentialProvider;
+ private ViiperNativeSession nativeSession;
public ViiperClient(string host, int port)
+ : this(host, port, ViiperTransportSettings.GetManagedMode(), null,
+ null)
+ {
+ }
+
+ internal ViiperClient(string host, int port,
+ ViiperTransportMode transportMode,
+ ViiperNativeRuntimeMetadata metadata = null,
+ IViiperCredentialProvider credentialProvider = null)
{
- this.host = host;
+ this.host = string.IsNullOrWhiteSpace(host) ?
+ throw new ArgumentException("A VIIPER host is required.",
+ nameof(host)) : host.Trim();
+ if (port <= 0 || port > ushort.MaxValue)
+ {
+ throw new ArgumentOutOfRangeException(nameof(port));
+ }
this.port = port;
+ TransportMode = transportMode;
+ if (transportMode == ViiperTransportMode.NativeUde)
+ {
+ if (!string.Equals(this.host, "localhost",
+ StringComparison.OrdinalIgnoreCase) &&
+ (!System.Net.IPAddress.TryParse(this.host,
+ out System.Net.IPAddress address) ||
+ !System.Net.IPAddress.IsLoopback(address)))
+ {
+ throw new ViiperIdentityException(
+ "Managed native VIIPER connections must use a loopback address.");
+ }
+ deferredNativeMetadata = metadata;
+ deferredCredentialProvider = credentialProvider;
+ }
+ }
+
+ internal ViiperTransportMode TransportMode { get; }
+
+ internal bool HasAuthenticatedNativeConnection =>
+ nativeSession?.HasAuthenticatedConnection == true;
+
+ internal ViiperNativeBackendIdentity NativeBackendIdentity =>
+ nativeSession?.Identity;
+
+ private ViiperNativeSession GetNativeSession()
+ {
+ if (TransportMode != ViiperTransportMode.NativeUde)
+ {
+ throw new ViiperIdentityException(
+ "An authenticated native session was requested for the explicit USB/IP client.");
+ }
+ lock (nativeSessionLock)
+ {
+ if (nativeSession == null)
+ {
+ ViiperNativeRuntimeMetadata metadata =
+ deferredNativeMetadata ??
+ ViiperNativeRuntimeMetadata.LoadBundled();
+ nativeSession = new ViiperNativeSession(
+ new ViiperNativeRuntimeContract(metadata),
+ deferredCredentialProvider ??
+ new ViiperProgramDataCredentialProvider());
+ }
+ return nativeSession;
+ }
+ }
+
+ internal ViiperNativeBackendIdentity ValidateNativeBackend()
+ {
+ if (TransportMode != ViiperTransportMode.NativeUde)
+ {
+ throw new ViiperIdentityException(
+ "Native VIIPER validation was requested for the explicit USB/IP client.");
+ }
+ string raw = SendRequestRawCore("ping", null);
+ return GetNativeSession().AdmitPing(raw, reconnect: false);
+ }
+
+ private ViiperNativeBackendIdentity RevalidateNativeBackend()
+ {
+ string raw = SendRequestRawCore("ping", null);
+ return GetNativeSession().AdmitPing(raw, reconnect: true);
+ }
+
+ private void EnsureNativeBackendValidated()
+ {
+ if (TransportMode == ViiperTransportMode.NativeUde &&
+ nativeSession?.Identity == null)
+ {
+ ValidateNativeBackend();
+ }
}
public ViiperDeviceStream CreateDeviceAndOpenStream(ViiperVirtualDeviceType deviceType)
@@ -4571,35 +4702,100 @@ public ViiperDeviceStream CreateDeviceAndOpenStream(ViiperVirtualDeviceType devi
public ViiperDeviceStream CreateDeviceAndOpenStream(string deviceName,
ushort? idProduct = null)
{
- ViiperUsbipPortManager.DetachStaleLocalViiperPorts();
+ EnsureNativeBackendValidated();
+ ushort? effectiveProductId = idProduct;
+ if (TransportMode == ViiperTransportMode.NativeUde)
+ {
+ effectiveProductId = GetNativeSession().Contract
+ .ValidateControllerRequest(deviceName, idProduct);
+ }
+ if (TransportMode == ViiperTransportMode.Usbip)
+ {
+ ViiperUsbipPortManager.DetachStaleLocalViiperPorts();
+ }
- ViiperBusCreateResponse bus = SendRequest("bus/create", "0");
+ ViiperBusCreateResponse bus;
+ try
+ {
+ bus = SendRequest("bus/create", "0");
+ if (bus == null || bus.BusId == 0)
+ {
+ throw new ViiperIdentityException(
+ "VIIPER returned an invalid bus identity.");
+ }
+ }
+ catch (Exception failure) when (
+ TransportMode == ViiperTransportMode.NativeUde &&
+ (failure is ViiperIdentityException || failure is JsonException))
+ {
+ GetNativeSession().InvalidateIdentity(failure);
+ throw;
+ }
ViiperDeviceResponse device = null;
+ ViiperVirtualDeviceIdentity nativeIdentity = null;
int usbipPort = -1;
try
{
string payload = JsonSerializer.Serialize(new ViiperDeviceCreateRequest
{
Type = deviceName,
- IdProduct = idProduct,
+ IdProduct = effectiveProductId,
}, JsonOptions);
device = SendRequest($"bus/{bus.BusId}/add", payload);
- usbipPort = ViiperUsbipPortManager.FindLocalViiperPort(bus.BusId, device.DevId);
+ if (TransportMode == ViiperTransportMode.NativeUde)
+ {
+ nativeIdentity =
+ ValidateNativeDeviceIdentity(bus.BusId, device,
+ deviceName);
+ var lifetime = new ViiperVirtualDeviceLifetime(
+ nativeIdentity, RemoveNativeDevice);
+ return OpenStream(bus.BusId, device.DevId, lifetime);
+ }
+
+ ValidateUsbipDeviceResponse(device);
+ usbipPort = ViiperUsbipPortManager.FindLocalViiperPort(
+ bus.BusId, device.DevId);
ViiperUsbipPortManager.RegisterActivePort(usbipPort);
- ViiperUsbipPortManager.DetachDuplicateLocalViiperPorts(bus.BusId, device.DevId, usbipPort);
- return OpenStream(bus.BusId, device.DevId, usbipPort);
+ ViiperUsbipPortManager.DetachDuplicateLocalViiperPorts(
+ bus.BusId, device.DevId, usbipPort);
+ ViiperVirtualDeviceIdentity usbipIdentity =
+ CreateUsbipDeviceIdentity(bus.BusId, device, deviceName,
+ usbipPort);
+ var usbipLifetime = new ViiperVirtualDeviceLifetime(
+ usbipIdentity, RemoveDevice);
+ return OpenStream(bus.BusId, device.DevId, usbipLifetime);
}
- catch
+ catch (Exception failure)
{
- ViiperUsbipPortManager.UnregisterActivePort(usbipPort);
+ if (TransportMode == ViiperTransportMode.Usbip)
+ {
+ ViiperUsbipPortManager.UnregisterActivePort(usbipPort);
+ }
- if (device != null && !string.IsNullOrEmpty(device.DevId))
+ if (TransportMode == ViiperTransportMode.Usbip &&
+ device != null && !string.IsNullOrEmpty(device.DevId))
{
TryRemoveDevice(bus.BusId, device.DevId);
}
- TryRemoveBus(bus.BusId);
+ if (TransportMode == ViiperTransportMode.Usbip)
+ {
+ TryRemoveBus(bus.BusId);
+ }
+ else if (nativeIdentity != null)
+ {
+ TryRemoveNativeDevice(nativeIdentity);
+ }
+ if (TransportMode == ViiperTransportMode.NativeUde &&
+ (failure is ViiperIdentityException ||
+ failure is JsonException))
+ {
+ // Keep the authenticated session usable long enough to
+ // remove any partially-created topology, then permanently
+ // fence it before control returns to the caller.
+ GetNativeSession().InvalidateIdentity(failure);
+ }
throw;
}
}
@@ -4684,6 +4880,12 @@ internal ViiperMicrophoneInterfaceStatus GetMicrophoneInterfaceStatus(
public ViiperDeviceStream OpenExistingDeviceStream(uint busId,
string devId, int usbipPort)
{
+ if (TransportMode != ViiperTransportMode.Usbip)
+ {
+ throw new ArgumentException(
+ "A USB/IP port may only be supplied to an explicit USB/IP VIIPER client.",
+ nameof(usbipPort));
+ }
return OpenExistingDeviceStream(busId, devId, usbipPort, null);
}
@@ -4691,6 +4893,11 @@ internal ViiperDeviceStream OpenExistingDeviceStream(uint busId,
string devId, int usbipPort,
ViiperVirtualDeviceLifetime deviceLifetime)
{
+ if (TransportMode != ViiperTransportMode.Usbip)
+ {
+ throw new ViiperIdentityException(
+ "Native VIIPER stream reopen requires the exact captured virtual-device lifetime receipt.");
+ }
if (string.IsNullOrWhiteSpace(devId))
{
throw new ArgumentException(
@@ -4708,25 +4915,75 @@ internal ViiperDeviceStream OpenExistingDeviceStream(uint busId,
nameof(deviceLifetime));
}
- return OpenStream(busId, devId, usbipPort, deviceLifetime);
+ deviceLifetime ??= new ViiperVirtualDeviceLifetime(busId,
+ devId, usbipPort, RemoveDevice);
+ return OpenStream(busId, devId, deviceLifetime);
+ }
+
+ internal ViiperDeviceStream OpenExistingDeviceStream(uint busId,
+ string devId, ViiperVirtualDeviceLifetime deviceLifetime)
+ {
+ if (deviceLifetime == null)
+ {
+ throw new ArgumentNullException(nameof(deviceLifetime));
+ }
+ if (string.IsNullOrWhiteSpace(devId) ||
+ deviceLifetime.BusId != busId ||
+ !string.Equals(deviceLifetime.DevId, devId,
+ StringComparison.Ordinal) ||
+ deviceLifetime.TransportMode != TransportMode)
+ {
+ throw new ArgumentException(
+ "The VIIPER stream identity must match its virtual-device lifetime.",
+ nameof(deviceLifetime));
+ }
+
+ if (TransportMode == ViiperTransportMode.NativeUde)
+ {
+ try
+ {
+ RevalidateNativeBackend();
+ ValidateExistingNativeDevice(deviceLifetime);
+ }
+ catch (Exception failure) when (
+ failure is ViiperIdentityException ||
+ failure is JsonException)
+ {
+ GetNativeSession().InvalidateIdentity(failure);
+ throw;
+ }
+ }
+ return OpenStream(busId, devId, deviceLifetime);
}
private ViiperDeviceStream OpenStream(uint busId, string devId,
- int usbipPort,
- ViiperVirtualDeviceLifetime deviceLifetime = null)
+ ViiperVirtualDeviceLifetime deviceLifetime)
{
- TcpClient tcp = Connect(StreamReceiveTimeoutMs);
+ TcpClient tcp = Connect(TransportMode ==
+ ViiperTransportMode.NativeUde ? ApiReceiveTimeoutMs :
+ StreamReceiveTimeoutMs);
+ Stream stream = null;
try
{
- NetworkStream stream = tcp.GetStream();
+ stream = tcp.GetStream();
+ if (TransportMode == ViiperTransportMode.NativeUde)
+ {
+ stream = GetNativeSession().Authenticate(stream);
+ tcp.ReceiveTimeout = StreamReceiveTimeoutMs;
+ }
byte[] request = Encoding.UTF8.GetBytes($"bus/{busId}/{devId}\0");
stream.Write(request, 0, request.Length);
- deviceLifetime ??= new ViiperVirtualDeviceLifetime(busId,
- devId, usbipPort, RemoveDevice);
- return new ViiperDeviceStream(tcp, stream, deviceLifetime);
+ return new ViiperDeviceStream(stream, tcp, deviceLifetime);
}
catch
{
+ try
+ {
+ stream?.Dispose();
+ }
+ catch
+ {
+ }
tcp.Dispose();
throw;
}
@@ -4738,6 +4995,113 @@ private void RemoveDevice(uint busId, string devId)
TryRemoveBus(busId);
}
+ private void RemoveNativeDevice(
+ ViiperVirtualDeviceIdentity identity)
+ {
+ if (identity?.TransportMode != ViiperTransportMode.NativeUde ||
+ identity.NativePnpAnchor?.IsExact != true ||
+ identity.BusId == 0 ||
+ string.IsNullOrWhiteSpace(identity.DevId))
+ {
+ throw new ViiperIdentityException(
+ "Exact native VIIPER identity is required for conditional removal.");
+ }
+
+ ViiperNativePnpAnchor anchor = identity.NativePnpAnchor;
+ string payload = JsonSerializer.Serialize(
+ new ViiperNativeRemoveRequest
+ {
+ DevId = identity.DevId,
+ Transport = "native-ude",
+ NativeUde = new ViiperNativeDeviceResponse
+ {
+ DeviceId = anchor.NativeDeviceId.ToString(
+ CultureInfo.InvariantCulture),
+ DeviceGeneration = anchor.NativeDeviceGeneration,
+ ControllerSessionId =
+ anchor.ControllerSessionId.ToString(
+ CultureInfo.InvariantCulture),
+ ControllerInstanceId = anchor.ControllerInstanceId,
+ Usb20PortNumber = anchor.Usb20PortNumber,
+ Usb30PortNumber = anchor.Usb30PortNumber,
+ },
+ }, NativeJsonOptions);
+ string path = $"bus/{identity.BusId}/remove-native";
+ string raw = SendRequestRaw(path, payload);
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ throw new IOException(
+ "VIIPER returned an empty native removal response.");
+ }
+
+ ViiperNativeRuntimeContract.ValidateNoDuplicateJsonProperties(
+ raw, path);
+ using JsonDocument document = JsonDocument.Parse(raw,
+ new JsonDocumentOptions
+ {
+ AllowTrailingCommas = false,
+ CommentHandling = JsonCommentHandling.Disallow,
+ MaxDepth = 8,
+ });
+ JsonElement root = document.RootElement;
+ if (root.ValueKind != JsonValueKind.Object)
+ {
+ throw new IOException(
+ "VIIPER returned a non-object native removal response.");
+ }
+
+ JsonProperty[] properties = root.EnumerateObject().ToArray();
+ if (root.TryGetProperty("status", out JsonElement status))
+ {
+ if (properties.Length == 3 &&
+ properties.All(property => property.Name is "status" or
+ "title" or "detail") &&
+ status.ValueKind == JsonValueKind.Number &&
+ status.TryGetInt32(out int statusCode) &&
+ statusCode == 409 &&
+ root.TryGetProperty("title", out JsonElement title) &&
+ title.ValueKind == JsonValueKind.String &&
+ !string.IsNullOrWhiteSpace(title.GetString()) &&
+ root.TryGetProperty("detail", out JsonElement detail) &&
+ detail.ValueKind == JsonValueKind.String)
+ {
+ // The receipt is stale and a successor owns this bus/dev
+ // key. Never retry the legacy ID-only endpoint.
+ return;
+ }
+ throw new IOException(
+ "VIIPER native removal returned a non-canonical API error.");
+ }
+
+ if (properties.Length != 2 ||
+ properties.Any(property => property.Name is not "busId" and
+ not "devId") ||
+ !root.TryGetProperty("busId", out JsonElement returnedBus) ||
+ returnedBus.ValueKind != JsonValueKind.Number ||
+ !returnedBus.TryGetUInt32(out uint returnedBusId) ||
+ returnedBusId != identity.BusId ||
+ !root.TryGetProperty("devId", out JsonElement returnedDevice) ||
+ returnedDevice.ValueKind != JsonValueKind.String ||
+ !string.Equals(returnedDevice.GetString(), identity.DevId,
+ StringComparison.Ordinal))
+ {
+ throw new IOException(
+ "VIIPER native removal success did not echo the exact bus/device identity.");
+ }
+ }
+
+ private void TryRemoveNativeDevice(
+ ViiperVirtualDeviceIdentity identity)
+ {
+ try
+ {
+ RemoveNativeDevice(identity);
+ }
+ catch
+ {
+ }
+ }
+
private void TryRemoveDevice(uint busId, string devId)
{
try
@@ -4768,19 +5132,39 @@ private T SendRequest(string path, string payload = null)
throw new IOException("VIIPER returned an empty response.");
}
- ViiperApiError apiError = JsonSerializer.Deserialize(raw, JsonOptions);
+ JsonSerializerOptions responseOptions = TransportMode ==
+ ViiperTransportMode.NativeUde ? NativeJsonOptions :
+ JsonOptions;
+ if (TransportMode == ViiperTransportMode.NativeUde)
+ {
+ ViiperNativeRuntimeContract.ValidateNoDuplicateJsonProperties(
+ raw, path);
+ }
+ ViiperApiError apiError = JsonSerializer.Deserialize(
+ raw, responseOptions);
if (apiError != null && (apiError.Status != 0 || !string.IsNullOrEmpty(apiError.Title)))
{
throw new IOException($"VIIPER API error {apiError.Status} {apiError.Title}: {apiError.Detail}");
}
- return JsonSerializer.Deserialize(raw, JsonOptions);
+ return JsonSerializer.Deserialize(raw, responseOptions);
}
private string SendRequestRaw(string path, string payload = null)
+ {
+ if (!string.Equals(path, "ping", StringComparison.Ordinal))
+ {
+ EnsureNativeBackendValidated();
+ }
+ return SendRequestRawCore(path, payload);
+ }
+
+ private string SendRequestRawCore(string path, string payload)
{
using TcpClient tcp = Connect(ApiReceiveTimeoutMs);
- NetworkStream stream = tcp.GetStream();
+ using Stream stream = TransportMode == ViiperTransportMode.NativeUde ?
+ GetNativeSession().Authenticate(tcp.GetStream()) :
+ tcp.GetStream();
string request = string.IsNullOrEmpty(payload) ? path : $"{path} {payload}";
byte[] requestBytes = Encoding.UTF8.GetBytes(request + "\0");
stream.Write(requestBytes, 0, requestBytes.Length);
@@ -4833,8 +5217,81 @@ private sealed class ViiperBusCreateResponse
private sealed class ViiperDeviceResponse
{
+ private int? usbipPort;
+ private string usbipOwnerSerial;
+
+ [JsonPropertyName("busId")]
+ public uint BusId { get; set; }
+
[JsonPropertyName("devId")]
public string DevId { get; set; }
+
+ [JsonPropertyName("vid")]
+ public string Vid { get; set; }
+
+ [JsonPropertyName("pid")]
+ public string Pid { get; set; }
+
+ [JsonPropertyName("type")]
+ public string Type { get; set; }
+
+ [JsonPropertyName("transport")]
+ public string Transport { get; set; }
+
+ [JsonPropertyName("deviceSpecific")]
+ public JsonElement DeviceSpecific { get; set; }
+
+ [JsonPropertyName("usbipPort")]
+ public int? UsbipPort
+ {
+ get => usbipPort;
+ set
+ {
+ HasUsbipPortProperty = true;
+ usbipPort = value;
+ }
+ }
+
+ [JsonIgnore]
+ public bool HasUsbipPortProperty { get; private set; }
+
+ [JsonPropertyName("usbipOwnerSerial")]
+ public string UsbipOwnerSerial
+ {
+ get => usbipOwnerSerial;
+ set
+ {
+ HasUsbipOwnerSerialProperty = true;
+ usbipOwnerSerial = value;
+ }
+ }
+
+ [JsonIgnore]
+ public bool HasUsbipOwnerSerialProperty { get; private set; }
+
+ [JsonPropertyName("nativeUde")]
+ public ViiperNativeDeviceResponse NativeUde { get; set; }
+ }
+
+ private sealed class ViiperNativeDeviceResponse
+ {
+ [JsonPropertyName("deviceId")]
+ public string DeviceId { get; set; }
+
+ [JsonPropertyName("deviceGeneration")]
+ public uint DeviceGeneration { get; set; }
+
+ [JsonPropertyName("controllerSessionId")]
+ public string ControllerSessionId { get; set; }
+
+ [JsonPropertyName("controllerInstanceId")]
+ public string ControllerInstanceId { get; set; }
+
+ [JsonPropertyName("usb20PortNumber")]
+ public uint Usb20PortNumber { get; set; }
+
+ [JsonPropertyName("usb30PortNumber")]
+ public uint Usb30PortNumber { get; set; }
}
private sealed class ViiperDeviceCreateRequest
@@ -4847,6 +5304,18 @@ private sealed class ViiperDeviceCreateRequest
public ushort? IdProduct { get; set; }
}
+ private sealed class ViiperNativeRemoveRequest
+ {
+ [JsonPropertyName("devId")]
+ public string DevId { get; set; }
+
+ [JsonPropertyName("transport")]
+ public string Transport { get; set; }
+
+ [JsonPropertyName("nativeUde")]
+ public ViiperNativeDeviceResponse NativeUde { get; set; }
+ }
+
private sealed class ViiperBusDevicesResponse
{
[JsonPropertyName("devices")]
@@ -4855,9 +5324,55 @@ private sealed class ViiperBusDevicesResponse
private sealed class ViiperListedDevice
{
+ private int? usbipPort;
+ private string usbipOwnerSerial;
+
[JsonPropertyName("devId")]
public string DevId { get; set; }
+ [JsonPropertyName("transport")]
+ public string Transport { get; set; }
+
+ [JsonPropertyName("vid")]
+ public string Vid { get; set; }
+
+ [JsonPropertyName("pid")]
+ public string Pid { get; set; }
+
+ [JsonPropertyName("type")]
+ public string Type { get; set; }
+
+ [JsonPropertyName("nativeUde")]
+ public ViiperNativeDeviceResponse NativeUde { get; set; }
+
+ [JsonPropertyName("usbipPort")]
+ public int? UsbipPort
+ {
+ get => usbipPort;
+ set
+ {
+ HasUsbipPortProperty = true;
+ usbipPort = value;
+ }
+ }
+
+ [JsonIgnore]
+ public bool HasUsbipPortProperty { get; private set; }
+
+ [JsonPropertyName("usbipOwnerSerial")]
+ public string UsbipOwnerSerial
+ {
+ get => usbipOwnerSerial;
+ set
+ {
+ HasUsbipOwnerSerialProperty = true;
+ usbipOwnerSerial = value;
+ }
+ }
+
+ [JsonIgnore]
+ public bool HasUsbipOwnerSerialProperty { get; private set; }
+
[JsonPropertyName("deviceSpecific")]
public JsonElement DeviceSpecific { get; set; }
}
@@ -4882,6 +5397,242 @@ private sealed class ViiperApiError
[JsonPropertyName("detail")]
public string Detail { get; set; }
}
+
+ private ViiperVirtualDeviceIdentity ValidateNativeDeviceIdentity(
+ uint busId, ViiperDeviceResponse device, string expectedType)
+ {
+ if (device == null || string.IsNullOrWhiteSpace(device.DevId))
+ {
+ throw new ViiperIdentityException(
+ "VIIPER native add response omitted the device ID.");
+ }
+ if (device.BusId != busId)
+ {
+ throw new ViiperIdentityException(
+ "VIIPER native add response returned the wrong bus ID.");
+ }
+ if (!string.Equals(device.Transport, "native-ude",
+ StringComparison.Ordinal))
+ {
+ throw new ViiperIdentityException(
+ $"VIIPER native add response transport is '{device.Transport ?? ""}'.");
+ }
+ if (device.HasUsbipPortProperty ||
+ device.HasUsbipOwnerSerialProperty)
+ {
+ throw new ViiperIdentityException(
+ "VIIPER native add response contained USB/IP ownership fields.");
+ }
+ if (!string.Equals(device.Type, expectedType,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ throw new ViiperIdentityException(
+ "VIIPER native add response returned the wrong device type.");
+ }
+ if (!string.Equals(device.Type, expectedType,
+ StringComparison.Ordinal) ||
+ !GetNativeSession().Contract.HasExactControllerIdentity(
+ expectedType, device.Vid, device.Pid))
+ {
+ throw new ViiperIdentityException(
+ "VIIPER native add response did not match the source-bound controller type/VID/PID identity.");
+ }
+ if (!uint.TryParse(device.DevId, NumberStyles.None,
+ CultureInfo.InvariantCulture, out uint numericDevId) ||
+ numericDevId == 0 ||
+ !string.Equals(numericDevId.ToString(
+ CultureInfo.InvariantCulture), device.DevId,
+ StringComparison.Ordinal))
+ {
+ throw new ViiperIdentityException(
+ "VIIPER native add response devId is not canonical decimal.");
+ }
+
+ ViiperNativeDeviceResponse native = device.NativeUde ??
+ throw new ViiperIdentityException(
+ "VIIPER native add response omitted nativeUde identity.");
+ if (!ulong.TryParse(native.DeviceId, NumberStyles.None,
+ CultureInfo.InvariantCulture, out ulong nativeDeviceId) ||
+ nativeDeviceId == 0 ||
+ !string.Equals(nativeDeviceId.ToString(
+ CultureInfo.InvariantCulture), native.DeviceId,
+ StringComparison.Ordinal))
+ {
+ throw new ViiperIdentityException(
+ "VIIPER nativeUde.deviceId is not canonical decimal.");
+ }
+ ulong expectedNativeDeviceId = (ulong)busId << 32 | numericDevId;
+ if (nativeDeviceId != expectedNativeDeviceId)
+ {
+ throw new ViiperIdentityException(
+ $"VIIPER nativeUde.deviceId={nativeDeviceId} does not match bus/dev identity {expectedNativeDeviceId}.");
+ }
+ if (native.DeviceGeneration == 0)
+ {
+ throw new ViiperIdentityException(
+ "VIIPER nativeUde.deviceGeneration is zero.");
+ }
+ ulong controllerSessionId =
+ ViiperNativeRuntimeContract.ParseCanonicalNonZeroUInt64(
+ native.ControllerSessionId,
+ "nativeUde.controllerSessionId");
+ ViiperNativeRuntimeContract.ValidateControllerInstanceId(
+ native.ControllerInstanceId);
+ ViiperNativeBackendIdentity backend = GetNativeSession().Identity ??
+ throw new ViiperIdentityException(
+ "VIIPER native backend identity is not pinned.");
+ if (!string.Equals(native.ControllerInstanceId,
+ backend.ControllerInstanceId, StringComparison.Ordinal))
+ {
+ throw new ViiperIdentityException(
+ "VIIPER device controller instance differs from the authenticated ping identity.");
+ }
+ if (controllerSessionId != backend.ControllerSessionId)
+ {
+ throw new ViiperIdentityException(
+ "VIIPER device controller session differs from the authenticated ping identity.");
+ }
+ if (!(native.Usb20PortNumber != 0 ^
+ native.Usb30PortNumber != 0))
+ {
+ throw new ViiperIdentityException(
+ "VIIPER native device identity must contain exactly one non-zero UdeCx USB port number.");
+ }
+
+ return new ViiperVirtualDeviceIdentity
+ {
+ TransportMode = ViiperTransportMode.NativeUde,
+ BusId = busId,
+ DevId = device.DevId,
+ DeviceType = device.Type,
+ Vid = device.Vid,
+ Pid = device.Pid,
+ DeviceSerialNumber = GetDeviceSerialNumber(
+ device.DeviceSpecific),
+ BrokerBuildIdentity = backend.DriverBuildIdentity,
+ LogicalLifetimeId = Guid.NewGuid().ToString("N"),
+ NativePnpAnchor = new ViiperNativePnpAnchor
+ {
+ NativeDeviceId = nativeDeviceId,
+ NativeDeviceGeneration = native.DeviceGeneration,
+ ControllerSessionId = controllerSessionId,
+ ControllerInstanceId = native.ControllerInstanceId,
+ Usb20PortNumber = native.Usb20PortNumber,
+ Usb30PortNumber = native.Usb30PortNumber,
+ },
+ };
+ }
+
+ private static void ValidateUsbipDeviceResponse(
+ ViiperDeviceResponse device)
+ {
+ if (device == null || string.IsNullOrWhiteSpace(device.DevId))
+ {
+ throw new IOException(
+ "VIIPER USB/IP add response omitted the device ID.");
+ }
+ if (!string.IsNullOrEmpty(device.Transport) &&
+ !string.Equals(device.Transport, "usbip",
+ StringComparison.Ordinal))
+ {
+ throw new IOException(
+ $"VIIPER explicit USB/IP add response returned transport '{device.Transport}'.");
+ }
+ if (device.NativeUde != null)
+ {
+ throw new IOException(
+ "VIIPER explicit USB/IP response unexpectedly contained nativeUde identity.");
+ }
+ }
+
+ private ViiperVirtualDeviceIdentity CreateUsbipDeviceIdentity(
+ uint busId, ViiperDeviceResponse device, string expectedType,
+ int usbipPort)
+ {
+ return new ViiperVirtualDeviceIdentity
+ {
+ TransportMode = ViiperTransportMode.Usbip,
+ BusId = busId,
+ DevId = device.DevId,
+ DeviceType = string.IsNullOrEmpty(device.Type) ?
+ expectedType : device.Type,
+ Vid = device.Vid,
+ Pid = device.Pid,
+ DeviceSerialNumber = GetDeviceSerialNumber(
+ device.DeviceSpecific),
+ LogicalLifetimeId = Guid.NewGuid().ToString("N"),
+ LegacyUsbipPort = usbipPort,
+ LegacyUsbipOwnerSerial = device.UsbipOwnerSerial,
+ };
+ }
+
+ private void ValidateExistingNativeDevice(
+ ViiperVirtualDeviceLifetime lifetime)
+ {
+ ViiperBusDevicesResponse response =
+ SendRequest(
+ $"bus/{lifetime.BusId}/list");
+ ViiperListedDevice[] matches = response?.Devices?.Where(
+ candidate => string.Equals(candidate.DevId,
+ lifetime.DevId, StringComparison.Ordinal)).ToArray();
+ ViiperListedDevice listed = matches?.Length == 1 ? matches[0] :
+ null;
+ if (listed == null ||
+ !string.Equals(listed.Transport, "native-ude",
+ StringComparison.Ordinal) || listed.NativeUde == null ||
+ listed.HasUsbipPortProperty ||
+ listed.HasUsbipOwnerSerialProperty ||
+ !string.Equals(listed.Type,
+ lifetime.VirtualDeviceIdentity.DeviceType,
+ StringComparison.Ordinal) ||
+ !string.Equals(listed.Vid,
+ lifetime.VirtualDeviceIdentity.Vid,
+ StringComparison.Ordinal) ||
+ !string.Equals(listed.Pid,
+ lifetime.VirtualDeviceIdentity.Pid,
+ StringComparison.Ordinal))
+ {
+ throw new ViiperIdentityException(
+ "VIIPER reconnect could not prove the original native device topology.");
+ }
+ ViiperNativePnpAnchor expected =
+ lifetime.VirtualDeviceIdentity.NativePnpAnchor;
+ ViiperNativeDeviceResponse actual = listed.NativeUde;
+ if (!ulong.TryParse(actual.DeviceId, NumberStyles.None,
+ CultureInfo.InvariantCulture, out ulong deviceId) ||
+ deviceId != expected.NativeDeviceId ||
+ !string.Equals(deviceId.ToString(
+ CultureInfo.InvariantCulture), actual.DeviceId,
+ StringComparison.Ordinal) ||
+ actual.DeviceGeneration != expected.NativeDeviceGeneration ||
+ !ulong.TryParse(actual.ControllerSessionId,
+ NumberStyles.None, CultureInfo.InvariantCulture,
+ out ulong controllerSessionId) ||
+ !string.Equals(controllerSessionId.ToString(
+ CultureInfo.InvariantCulture),
+ actual.ControllerSessionId, StringComparison.Ordinal) ||
+ controllerSessionId != expected.ControllerSessionId ||
+ !string.Equals(actual.ControllerInstanceId,
+ expected.ControllerInstanceId, StringComparison.Ordinal) ||
+ actual.Usb20PortNumber != expected.Usb20PortNumber ||
+ actual.Usb30PortNumber != expected.Usb30PortNumber)
+ {
+ throw new ViiperIdentityException(
+ "VIIPER native device identity changed across stream reconnect.");
+ }
+ }
+
+ private static string GetDeviceSerialNumber(JsonElement specific)
+ {
+ if (specific.ValueKind == JsonValueKind.Object &&
+ specific.TryGetProperty("serial_number",
+ out JsonElement serial) &&
+ serial.ValueKind == JsonValueKind.String)
+ {
+ return serial.GetString();
+ }
+ return null;
+ }
}
internal static class ViiperUsbipPortManager
@@ -5228,13 +5979,14 @@ public UsbipPortBlock(int port, string block)
internal sealed class ViiperVirtualDeviceLifetime : IDisposable
{
- private readonly uint busId;
- private readonly string devId;
- private readonly int usbipPort;
+ private readonly ViiperVirtualDeviceIdentity virtualDeviceIdentity;
private readonly Action detachPort;
private readonly Action unregisterPort;
- private readonly Action removeDevice;
+ private readonly Action removeUsbipDevice;
+ private readonly Action
+ removeNativeDevice;
private readonly Action detachStalePorts;
+ private long streamGeneration;
private int disposed;
internal ViiperVirtualDeviceLifetime(uint busId, string devId,
@@ -5242,65 +5994,169 @@ internal ViiperVirtualDeviceLifetime(uint busId, string devId,
Action detachPort = null,
Action unregisterPort = null,
Action detachStalePorts = null)
+ : this(new ViiperVirtualDeviceIdentity
+ {
+ TransportMode = ViiperTransportMode.Usbip,
+ BusId = busId,
+ DevId = devId,
+ LogicalLifetimeId = Guid.NewGuid().ToString("N"),
+ LegacyUsbipPort = usbipPort,
+ }, removeDevice, null, detachPort, unregisterPort,
+ detachStalePorts)
{
- this.busId = busId;
- this.devId = devId ?? throw new ArgumentNullException(nameof(devId));
- this.usbipPort = usbipPort;
- this.removeDevice = removeDevice;
- this.detachPort = detachPort ?? ViiperUsbipPortManager.DetachPort;
- this.unregisterPort = unregisterPort ??
- ViiperUsbipPortManager.UnregisterActivePort;
- this.detachStalePorts = detachStalePorts ??
- ViiperUsbipPortManager.DetachStaleLocalViiperPorts;
}
- internal uint BusId => busId;
-
- internal string DevId => devId;
-
- internal int UsbipPort => usbipPort;
+ internal ViiperVirtualDeviceLifetime(
+ ViiperVirtualDeviceIdentity virtualDeviceIdentity,
+ Action removeDevice,
+ Action detachPort = null,
+ Action unregisterPort = null,
+ Action detachStalePorts = null)
+ : this(virtualDeviceIdentity, removeDevice, null, detachPort,
+ unregisterPort, detachStalePorts)
+ {
+ }
- internal bool IsDisposed => Volatile.Read(ref disposed) == 1;
+ internal ViiperVirtualDeviceLifetime(
+ ViiperVirtualDeviceIdentity virtualDeviceIdentity,
+ Action removeNativeDevice,
+ Action detachPort = null,
+ Action unregisterPort = null,
+ Action detachStalePorts = null)
+ : this(virtualDeviceIdentity, null, removeNativeDevice,
+ detachPort, unregisterPort, detachStalePorts)
+ {
+ }
- public void Dispose()
+ private ViiperVirtualDeviceLifetime(
+ ViiperVirtualDeviceIdentity virtualDeviceIdentity,
+ Action removeUsbipDevice,
+ Action removeNativeDevice,
+ Action detachPort,
+ Action unregisterPort,
+ Action detachStalePorts)
{
- if (Interlocked.Exchange(ref disposed, 1) == 1)
+ this.virtualDeviceIdentity = virtualDeviceIdentity ??
+ throw new ArgumentNullException(nameof(virtualDeviceIdentity));
+ if (virtualDeviceIdentity.BusId == 0 ||
+ string.IsNullOrWhiteSpace(virtualDeviceIdentity.DevId) ||
+ string.IsNullOrWhiteSpace(
+ virtualDeviceIdentity.LogicalLifetimeId))
{
- return;
+ throw new ArgumentException(
+ "A complete VIIPER logical device identity is required.",
+ nameof(virtualDeviceIdentity));
}
-
- try
+ if (virtualDeviceIdentity.TransportMode ==
+ ViiperTransportMode.NativeUde &&
+ (virtualDeviceIdentity.NativePnpAnchor?.IsExact != true ||
+ removeNativeDevice == null || removeUsbipDevice != null))
{
- detachPort?.Invoke(usbipPort,
- "DS4Windows VIIPER device stopped");
+ throw new ArgumentException(
+ "Native VIIPER lifetime identity requires exact UdeCx correlation and a receipt-conditioned removal callback.",
+ nameof(virtualDeviceIdentity));
}
- catch
+ if (virtualDeviceIdentity.TransportMode ==
+ ViiperTransportMode.Usbip)
{
+ if (removeNativeDevice != null)
+ {
+ throw new ArgumentException(
+ "USB/IP lifetime identity cannot use native conditional removal.",
+ nameof(removeNativeDevice));
+ }
+ this.removeUsbipDevice = removeUsbipDevice;
+ this.detachPort = detachPort ??
+ ViiperUsbipPortManager.DetachPort;
+ this.unregisterPort = unregisterPort ??
+ ViiperUsbipPortManager.UnregisterActivePort;
+ this.detachStalePorts = detachStalePorts ??
+ ViiperUsbipPortManager.DetachStaleLocalViiperPorts;
}
-
- try
+ else
{
- unregisterPort?.Invoke(usbipPort);
+ this.removeNativeDevice = removeNativeDevice;
}
- catch
+ }
+
+ internal uint BusId => virtualDeviceIdentity.BusId;
+
+ internal string DevId => virtualDeviceIdentity.DevId;
+
+ internal int UsbipPort => virtualDeviceIdentity.LegacyUsbipPort;
+
+ internal ViiperTransportMode TransportMode =>
+ virtualDeviceIdentity.TransportMode;
+
+ internal ViiperVirtualDeviceIdentity VirtualDeviceIdentity =>
+ virtualDeviceIdentity.WithStreamGeneration(
+ Interlocked.Read(ref streamGeneration));
+
+ internal bool IsDisposed => Volatile.Read(ref disposed) == 1;
+
+ internal ViiperVirtualDeviceIdentity NextStreamIdentity()
+ {
+ if (IsDisposed)
{
+ throw new ObjectDisposedException(
+ nameof(ViiperVirtualDeviceLifetime));
}
+ long generation = Interlocked.Increment(ref streamGeneration);
+ return virtualDeviceIdentity.WithStreamGeneration(generation);
+ }
- try
+ public void Dispose()
+ {
+ if (Interlocked.Exchange(ref disposed, 1) == 1)
{
- removeDevice?.Invoke(busId, devId);
+ return;
}
- catch
+
+ if (TransportMode == ViiperTransportMode.Usbip)
{
+ try
+ {
+ detachPort?.Invoke(UsbipPort,
+ "DS4Windows VIIPER device stopped");
+ }
+ catch
+ {
+ }
+
+ try
+ {
+ unregisterPort?.Invoke(UsbipPort);
+ }
+ catch
+ {
+ }
}
try
{
- detachStalePorts?.Invoke();
+ if (TransportMode == ViiperTransportMode.NativeUde)
+ {
+ removeNativeDevice?.Invoke(virtualDeviceIdentity);
+ }
+ else
+ {
+ removeUsbipDevice?.Invoke(BusId, DevId);
+ }
}
catch
{
}
+
+ if (TransportMode == ViiperTransportMode.Usbip)
+ {
+ try
+ {
+ detachStalePorts?.Invoke();
+ }
+ catch
+ {
+ }
+ }
}
}
@@ -5310,6 +6166,7 @@ internal sealed class ViiperDeviceStream : IDisposable
private readonly IDisposable transport;
private readonly Stream stream;
private readonly ViiperVirtualDeviceLifetime deviceLifetime;
+ private readonly ViiperVirtualDeviceIdentity virtualDeviceIdentity;
private readonly object writeLock = new object();
private readonly byte[] incomingFrameHeader =
new byte[FrameV2HeaderLength];
@@ -5331,6 +6188,7 @@ internal sealed class ViiperDeviceStream : IDisposable
private const byte FrameVersionV2 = 0x02;
private const byte FrameVersionV3 = 0x03;
private const byte FrameVersionV4 = 0x04;
+ private const byte FrameVersionV5 = 0x05;
public ViiperDeviceStream(TcpClient tcp, Stream stream,
ViiperVirtualDeviceLifetime deviceLifetime)
@@ -5345,6 +6203,7 @@ internal ViiperDeviceStream(Stream stream, IDisposable transport,
this.transport = transport ?? throw new ArgumentNullException(nameof(transport));
this.deviceLifetime = deviceLifetime ??
throw new ArgumentNullException(nameof(deviceLifetime));
+ virtualDeviceIdentity = deviceLifetime.NextStreamIdentity();
}
public uint BusId => deviceLifetime.BusId;
@@ -5353,6 +6212,12 @@ internal ViiperDeviceStream(Stream stream, IDisposable transport,
public int UsbipPort => deviceLifetime.UsbipPort;
+ internal ViiperTransportMode TransportMode =>
+ virtualDeviceIdentity.TransportMode;
+
+ internal ViiperVirtualDeviceIdentity VirtualDeviceIdentity =>
+ virtualDeviceIdentity;
+
internal ViiperVirtualDeviceLifetime DeviceLifetime => deviceLifetime;
internal bool IsTransportClosed =>
@@ -5387,7 +6252,7 @@ public void WriteFrame(byte version, byte frameType, byte[] data)
throw new ArgumentOutOfRangeException(nameof(data));
}
if (version != FrameVersionV2 && version != FrameVersionV3 &&
- version != FrameVersionV4)
+ version != FrameVersionV4 && version != FrameVersionV5)
{
throw new ArgumentOutOfRangeException(nameof(version));
}
@@ -5637,8 +6502,9 @@ public static string GetViiperDeviceName(ViiperVirtualDeviceType type)
{
ViiperVirtualDeviceType.Xbox360 => "xbox360",
ViiperVirtualDeviceType.DualShock4 => "dualshock4",
- ViiperVirtualDeviceType.DualSense => "dualsenseext",
- ViiperVirtualDeviceType.DualSenseEdge => "dualsenseedgeext",
+ ViiperVirtualDeviceType.DualSense => "dualsensegamepadv5",
+ ViiperVirtualDeviceType.DualSenseEdge =>
+ "dualsenseedgegamepadv5",
ViiperVirtualDeviceType.Switch2Pro => "ns2pro",
_ => "xbox360",
};
diff --git a/DS4Windows/DS4Control/Viiper/ViiperPnPOwnership.cs b/DS4Windows/DS4Control/Viiper/ViiperPnPOwnership.cs
new file mode 100644
index 0000000..f6d847d
--- /dev/null
+++ b/DS4Windows/DS4Control/Viiper/ViiperPnPOwnership.cs
@@ -0,0 +1,688 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Threading;
+
+namespace DS4Windows
+{
+ ///
+ /// Identifies the Windows transport which owns a VIIPER-created USB
+ /// device. Unknown is intentionally not a wildcard.
+ ///
+ internal enum ViiperPnPTransport
+ {
+ Unknown,
+ NativeUdeCx,
+ LegacyUsbIp,
+ }
+
+ ///
+ /// The exact portion of a Windows PnP ancestry used to correlate HID and
+ /// UAC interfaces which belong to the same emulated USB device.
+ ///
+ internal readonly struct ViiperPnPTopologyIdentity :
+ IEquatable
+ {
+ internal ViiperPnPTopologyIdentity(ViiperPnPTransport transport,
+ string rootInstanceId, string usbDeviceInstanceId,
+ int usbPortNumber)
+ {
+ Transport = transport;
+ RootInstanceId = Normalize(rootInstanceId);
+ UsbDeviceInstanceId = Normalize(usbDeviceInstanceId);
+ UsbPortNumber = usbPortNumber >= 0 ? usbPortNumber : -1;
+ }
+
+ internal ViiperPnPTransport Transport { get; }
+ internal string RootInstanceId { get; }
+ internal string UsbDeviceInstanceId { get; }
+ internal int UsbPortNumber { get; }
+
+ internal bool IsResolved =>
+ Transport != ViiperPnPTransport.Unknown &&
+ !string.IsNullOrEmpty(RootInstanceId) &&
+ !string.IsNullOrEmpty(UsbDeviceInstanceId);
+
+ internal bool IsUsbDeviceResolved =>
+ !string.IsNullOrEmpty(UsbDeviceInstanceId);
+
+ internal bool IsSameUsbDevice(
+ ViiperPnPTopologyIdentity other)
+ {
+ if (!IsUsbDeviceResolved || !other.IsUsbDeviceResolved)
+ {
+ return false;
+ }
+
+ if (!string.Equals(UsbDeviceInstanceId,
+ other.UsbDeviceInstanceId,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ return string.IsNullOrEmpty(RootInstanceId) ||
+ string.IsNullOrEmpty(other.RootInstanceId) ||
+ string.Equals(RootInstanceId, other.RootInstanceId,
+ StringComparison.OrdinalIgnoreCase);
+ }
+
+ public bool Equals(ViiperPnPTopologyIdentity other)
+ {
+ return Transport == other.Transport &&
+ UsbPortNumber == other.UsbPortNumber &&
+ string.Equals(RootInstanceId, other.RootInstanceId,
+ StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(UsbDeviceInstanceId,
+ other.UsbDeviceInstanceId,
+ StringComparison.OrdinalIgnoreCase);
+ }
+
+ public override bool Equals(object obj)
+ {
+ return obj is ViiperPnPTopologyIdentity other && Equals(other);
+ }
+
+ public override int GetHashCode()
+ {
+ return HashCode.Combine(Transport,
+ StringComparer.OrdinalIgnoreCase.GetHashCode(
+ RootInstanceId ?? string.Empty),
+ StringComparer.OrdinalIgnoreCase.GetHashCode(
+ UsbDeviceInstanceId ?? string.Empty),
+ UsbPortNumber);
+ }
+
+ private static string Normalize(string value)
+ {
+ return string.IsNullOrWhiteSpace(value) ? string.Empty :
+ value.Trim().TrimEnd('\0');
+ }
+ }
+
+ ///
+ /// Source-provided identity of one VIIPER virtual-device lifetime. A
+ /// native identity is publishable only when it carries the exclusive
+ /// controller-session nonce, driver's exact device ID/generation, and an
+ /// exact PnP correlation anchor. Stream reconnect counters are not valid
+ /// substitutes for any of those source-bound fields.
+ ///
+ internal readonly struct ViiperPnPCorrelation :
+ IEquatable
+ {
+ internal ViiperPnPCorrelation(ViiperPnPTransport transport,
+ ulong nativeDeviceId, uint deviceGeneration,
+ ulong controllerSessionId,
+ string rootInstanceId, string usbDeviceInstanceId,
+ int usbPortNumber, string legacyOwnerSerial = null)
+ {
+ Transport = transport;
+ NativeDeviceId = nativeDeviceId;
+ DeviceGeneration = deviceGeneration;
+ ControllerSessionId = controllerSessionId;
+ RootInstanceId = Normalize(rootInstanceId);
+ UsbDeviceInstanceId = Normalize(usbDeviceInstanceId);
+ UsbPortNumber = usbPortNumber >= 0 ? usbPortNumber : -1;
+ LegacyOwnerSerial = Normalize(legacyOwnerSerial);
+ }
+
+ internal ViiperPnPTransport Transport { get; }
+ internal ulong NativeDeviceId { get; }
+ internal uint DeviceGeneration { get; }
+ internal ulong ControllerSessionId { get; }
+ internal string RootInstanceId { get; }
+ internal string UsbDeviceInstanceId { get; }
+ internal int UsbPortNumber { get; }
+ internal string LegacyOwnerSerial { get; }
+
+ internal bool IsExact
+ {
+ get
+ {
+ bool hasRoot = !string.IsNullOrEmpty(RootInstanceId);
+ return Transport switch
+ {
+ ViiperPnPTransport.NativeUdeCx =>
+ NativeDeviceId != 0 && DeviceGeneration != 0 &&
+ ControllerSessionId != 0 &&
+ hasRoot && UsbPortNumber > 0,
+ ViiperPnPTransport.LegacyUsbIp =>
+ UsbPortNumber >= 0 &&
+ !string.IsNullOrEmpty(LegacyOwnerSerial),
+ _ => false,
+ };
+ }
+ }
+
+ internal bool Matches(ViiperPnPTopologyIdentity candidate)
+ {
+ if (!IsExact || !candidate.IsResolved ||
+ Transport != candidate.Transport)
+ {
+ return false;
+ }
+
+ if (Transport == ViiperPnPTransport.NativeUdeCx)
+ {
+ if (!string.Equals(RootInstanceId,
+ candidate.RootInstanceId,
+ StringComparison.OrdinalIgnoreCase) ||
+ candidate.UsbPortNumber != UsbPortNumber)
+ {
+ return false;
+ }
+
+ // The authenticated create result's controller instance and
+ // UdeCx port are the source-bound ownership key. A concrete
+ // top-level USB instance, when a future contract supplies it,
+ // is an additional constraint rather than a port substitute.
+ return string.IsNullOrEmpty(UsbDeviceInstanceId) ||
+ string.Equals(UsbDeviceInstanceId,
+ candidate.UsbDeviceInstanceId,
+ StringComparison.OrdinalIgnoreCase);
+ }
+
+ return UsbPortNumber >= 0 &&
+ candidate.UsbPortNumber == UsbPortNumber;
+ }
+
+ public bool Equals(ViiperPnPCorrelation other)
+ {
+ return Transport == other.Transport &&
+ NativeDeviceId == other.NativeDeviceId &&
+ DeviceGeneration == other.DeviceGeneration &&
+ ControllerSessionId == other.ControllerSessionId &&
+ UsbPortNumber == other.UsbPortNumber &&
+ string.Equals(RootInstanceId, other.RootInstanceId,
+ StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(UsbDeviceInstanceId,
+ other.UsbDeviceInstanceId,
+ StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(LegacyOwnerSerial, other.LegacyOwnerSerial,
+ StringComparison.OrdinalIgnoreCase);
+ }
+
+ public override bool Equals(object obj)
+ {
+ return obj is ViiperPnPCorrelation other && Equals(other);
+ }
+
+ public override int GetHashCode()
+ {
+ return HashCode.Combine(Transport, NativeDeviceId,
+ DeviceGeneration, ControllerSessionId,
+ StringComparer.OrdinalIgnoreCase.GetHashCode(
+ RootInstanceId ?? string.Empty),
+ StringComparer.OrdinalIgnoreCase.GetHashCode(
+ UsbDeviceInstanceId ?? string.Empty),
+ UsbPortNumber,
+ StringComparer.OrdinalIgnoreCase.GetHashCode(
+ LegacyOwnerSerial ?? string.Empty));
+ }
+
+ private static string Normalize(string value)
+ {
+ return string.IsNullOrWhiteSpace(value) ? string.Empty :
+ value.Trim().TrimEnd('\0');
+ }
+ }
+
+ ///
+ /// Testable projection of one node in a child-to-root PnP ancestry walk.
+ ///
+ internal readonly struct ViiperPnPAncestryNode
+ {
+ internal ViiperPnPAncestryNode(string instanceId,
+ string parentInstanceId, IEnumerable hardwareIds,
+ string locationInfo)
+ {
+ InstanceId = instanceId ?? string.Empty;
+ ParentInstanceId = parentInstanceId ?? string.Empty;
+ HardwareIds = hardwareIds == null ? Array.Empty() :
+ new List(hardwareIds).ToArray();
+ LocationInfo = locationInfo ?? string.Empty;
+ }
+
+ internal string InstanceId { get; }
+ internal string ParentInstanceId { get; }
+ internal IReadOnlyList HardwareIds { get; }
+ internal string LocationInfo { get; }
+ }
+
+ ///
+ /// Generation-fenced table which turns exact PnP correlations into small
+ /// positive owner tokens. The int token is retained as a temporary adapter
+ /// for existing audio-haptics call sites; zero and negative values always
+ /// fail closed and never mean "any controller".
+ ///
+ internal sealed class ViiperPnPOwnershipTable
+ {
+ private readonly object syncRoot = new object();
+ private readonly Dictionary entries =
+ new Dictionary();
+ private int nextToken;
+
+ internal int AllocateToken()
+ {
+ for (;;)
+ {
+ int token = Interlocked.Increment(ref nextToken);
+ if (token > 0)
+ {
+ return token;
+ }
+
+ Interlocked.CompareExchange(ref nextToken, 0, token);
+ }
+ }
+
+ internal bool Publish(int token, ViiperPnPCorrelation correlation)
+ {
+ if (token <= 0 || !correlation.IsExact)
+ {
+ return false;
+ }
+
+ lock (syncRoot)
+ {
+ if (!entries.TryGetValue(token,
+ out ViiperPnPCorrelation current))
+ {
+ entries[token] = correlation;
+ return true;
+ }
+
+ if (correlation.Transport != current.Transport ||
+ (correlation.Transport ==
+ ViiperPnPTransport.NativeUdeCx &&
+ (correlation.ControllerSessionId !=
+ current.ControllerSessionId ||
+ correlation.NativeDeviceId !=
+ current.NativeDeviceId)))
+ {
+ // A positive token names one created device in one
+ // exclusive controller-file session. Never recycle it
+ // across a broker/controller restart or another device.
+ return false;
+ }
+
+ if (correlation.DeviceGeneration < current.DeviceGeneration)
+ {
+ return false;
+ }
+
+ if (correlation.DeviceGeneration == current.DeviceGeneration)
+ {
+ // A stream reconnect may republish the same exact lifetime.
+ // The same generation may never silently change its PnP
+ // anchor or logical device identity.
+ return current.Equals(correlation);
+ }
+
+ entries[token] = correlation;
+ return true;
+ }
+ }
+
+ internal bool Matches(int token,
+ ViiperPnPTopologyIdentity candidate)
+ {
+ if (token <= 0)
+ {
+ return false;
+ }
+
+ lock (syncRoot)
+ {
+ return entries.TryGetValue(token,
+ out ViiperPnPCorrelation correlation) &&
+ correlation.Matches(candidate);
+ }
+ }
+
+ internal bool MatchesAny(ViiperPnPTopologyIdentity candidate)
+ {
+ if (!candidate.IsResolved)
+ {
+ return false;
+ }
+
+ lock (syncRoot)
+ {
+ foreach (ViiperPnPCorrelation correlation in entries.Values)
+ {
+ if (correlation.Matches(candidate))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
+
+ internal bool TryGet(int token, out ViiperPnPCorrelation correlation)
+ {
+ if (token <= 0)
+ {
+ correlation = default;
+ return false;
+ }
+
+ lock (syncRoot)
+ {
+ return entries.TryGetValue(token, out correlation);
+ }
+ }
+
+ internal void Remove(int token)
+ {
+ if (token <= 0)
+ {
+ return;
+ }
+
+ lock (syncRoot)
+ {
+ entries.Remove(token);
+ }
+ }
+ }
+
+ internal static class ViiperPnPOwnershipRegistry
+ {
+ private static readonly ViiperPnPOwnershipTable Table =
+ new ViiperPnPOwnershipTable();
+ private static readonly object SourcesLock = new object();
+ private static readonly ConditionalWeakTable Sources = new ConditionalWeakTable<
+ ViiperOutDevice, SourceRegistration>();
+ private static readonly Dictionary> TokenSources = new Dictionary>();
+
+ private sealed class SourceRegistration
+ {
+ internal SourceRegistration(int token)
+ {
+ Token = token;
+ }
+
+ internal int Token { get; }
+ }
+
+ internal static int AllocateToken()
+ {
+ return Table.AllocateToken();
+ }
+
+ internal static bool Publish(int token,
+ ViiperPnPCorrelation correlation)
+ {
+ return Table.Publish(token, correlation);
+ }
+
+ internal static bool Matches(int token,
+ ViiperPnPTopologyIdentity candidate)
+ {
+ RefreshLiveSourceIdentities();
+ return Table.Matches(token, candidate);
+ }
+
+ internal static bool MatchesAny(
+ ViiperPnPTopologyIdentity candidate)
+ {
+ RefreshLiveSourceIdentities();
+ return Table.MatchesAny(candidate);
+ }
+
+ internal static int AttachOrUpdate(ViiperOutDevice source,
+ ViiperPnPCorrelation correlation)
+ {
+ if (source == null || !correlation.IsExact)
+ {
+ return -1;
+ }
+
+ lock (SourcesLock)
+ {
+ if (!Sources.TryGetValue(source,
+ out SourceRegistration registration))
+ {
+ registration = new SourceRegistration(
+ Table.AllocateToken());
+ if (!Table.Publish(registration.Token, correlation))
+ {
+ return -1;
+ }
+
+ Sources.Add(source, registration);
+ TokenSources[registration.Token] =
+ new WeakReference(source);
+ return registration.Token;
+ }
+
+ if (Table.TryGet(registration.Token,
+ out ViiperPnPCorrelation current) &&
+ RequiresNewOwnerToken(current, correlation))
+ {
+ // A new controller session or native device is a new
+ // creation lifetime even if its generation/root/port were
+ // numerically reused. Rotate the adapter token and retire
+ // the prior correlation before publishing the replacement.
+ Sources.Remove(source);
+ Table.Remove(registration.Token);
+ TokenSources.Remove(registration.Token);
+ var replacement = new SourceRegistration(
+ Table.AllocateToken());
+ if (!Table.Publish(replacement.Token, correlation))
+ {
+ return -1;
+ }
+
+ Sources.Add(source, replacement);
+ TokenSources[replacement.Token] =
+ new WeakReference(source);
+ return replacement.Token;
+ }
+
+ if (Table.Publish(registration.Token, correlation))
+ {
+ TokenSources[registration.Token] =
+ new WeakReference(source);
+ return registration.Token;
+ }
+
+ // The source now claims an older or conflicting lifetime.
+ // Retaining its previous table entry would keep stale HID/UAC
+ // interfaces owned even though callers correctly received a
+ // failed token. Withdraw the entire registration fail-closed;
+ // a later authoritative identity may attach as a new token.
+ Sources.Remove(source);
+ Table.Remove(registration.Token);
+ TokenSources.Remove(registration.Token);
+ return -1;
+ }
+ }
+
+ private static void RefreshLiveSourceIdentities()
+ {
+ var liveSources = new List();
+ var expiredTokens = new List();
+ lock (SourcesLock)
+ {
+ foreach (KeyValuePair> entry in TokenSources)
+ {
+ if (entry.Value.TryGetTarget(
+ out ViiperOutDevice source))
+ {
+ liveSources.Add(source);
+ }
+ else
+ {
+ expiredTokens.Add(entry.Key);
+ }
+ }
+
+ foreach (int token in expiredTokens)
+ {
+ TokenSources.Remove(token);
+ Table.Remove(token);
+ }
+ }
+
+ foreach (ViiperOutDevice source in liveSources)
+ {
+ // A null identity is the expected brief state during a
+ // transport-only reconnect; the created USB device and its
+ // existing correlation remain valid. Any newly published
+ // authoritative identity is immediately generation/session
+ // fenced by AttachOrUpdate.
+ if (source.VirtualDeviceIdentity != null)
+ {
+ GetToken(source);
+ }
+ }
+ }
+
+ private static bool RequiresNewOwnerToken(
+ ViiperPnPCorrelation current,
+ ViiperPnPCorrelation replacement)
+ {
+ if (current.Transport != replacement.Transport)
+ {
+ return true;
+ }
+
+ return replacement.Transport == ViiperPnPTransport.NativeUdeCx &&
+ (current.ControllerSessionId !=
+ replacement.ControllerSessionId ||
+ current.NativeDeviceId != replacement.NativeDeviceId);
+ }
+
+ internal static int AttachOrUpdate(ViiperOutDevice source)
+ {
+ if (!TryCreateCorrelation(source?.VirtualDeviceIdentity,
+ out ViiperPnPCorrelation correlation))
+ {
+ return -1;
+ }
+
+ return AttachOrUpdate(source, correlation);
+ }
+
+ internal static int GetToken(ViiperOutDevice source)
+ {
+ if (source == null)
+ {
+ return -1;
+ }
+
+ ViiperVirtualDeviceIdentity identity =
+ source.VirtualDeviceIdentity;
+ if (identity != null)
+ {
+ if (TryCreateCorrelation(identity,
+ out ViiperPnPCorrelation correlation))
+ {
+ return AttachOrUpdate(source, correlation);
+ }
+
+ // A reconnect which has not yet published its authoritative
+ // device generation must not retain the old endpoint binding.
+ Detach(source);
+ return -1;
+ }
+
+ lock (SourcesLock)
+ {
+ return Sources.TryGetValue(source,
+ out SourceRegistration registration) ?
+ registration.Token : -1;
+ }
+ }
+
+ internal static void Detach(ViiperOutDevice source)
+ {
+ if (source == null)
+ {
+ return;
+ }
+
+ lock (SourcesLock)
+ {
+ if (!Sources.TryGetValue(source,
+ out SourceRegistration registration))
+ {
+ return;
+ }
+
+ Sources.Remove(source);
+ Table.Remove(registration.Token);
+ TokenSources.Remove(registration.Token);
+ }
+ }
+
+ private static bool TryCreateCorrelation(
+ ViiperVirtualDeviceIdentity identity,
+ out ViiperPnPCorrelation correlation)
+ {
+ correlation = default;
+ if (identity == null)
+ {
+ return false;
+ }
+
+ if (identity.TransportMode == ViiperTransportMode.NativeUde)
+ {
+ ViiperNativePnpAnchor anchor = identity.NativePnpAnchor;
+ if (anchor?.IsExact != true ||
+ anchor.UdecxUsbPortNumber > int.MaxValue)
+ {
+ return false;
+ }
+
+ correlation = new ViiperPnPCorrelation(
+ ViiperPnPTransport.NativeUdeCx,
+ anchor.NativeDeviceId,
+ anchor.NativeDeviceGeneration,
+ anchor.ControllerSessionId,
+ anchor.ControllerInstanceId,
+ // The authoritative contract intentionally binds by the
+ // exclusive controller instance plus its exact UdeCx port;
+ // it does not claim a top-level USB PnP instance string.
+ string.Empty,
+ (int)anchor.UdecxUsbPortNumber);
+ return correlation.IsExact;
+ }
+
+ if (identity.TransportMode == ViiperTransportMode.Usbip)
+ {
+ correlation = new ViiperPnPCorrelation(
+ ViiperPnPTransport.LegacyUsbIp, 0, 0, 0,
+ string.Empty, string.Empty,
+ identity.LegacyUsbipPort,
+ identity.LegacyUsbipOwnerSerial);
+ return correlation.IsExact;
+ }
+
+ return false;
+ }
+
+ internal static bool TryGet(int token,
+ out ViiperPnPCorrelation correlation)
+ {
+ return Table.TryGet(token, out correlation);
+ }
+
+ internal static void Remove(int token)
+ {
+ lock (SourcesLock)
+ {
+ TokenSources.Remove(token);
+ Table.Remove(token);
+ }
+ }
+ }
+}
diff --git a/DS4Windows/DS4Control/Viiper/ViiperSetupManager.cs b/DS4Windows/DS4Control/Viiper/ViiperSetupManager.cs
index 104e4c0..f8f65c4 100644
--- a/DS4Windows/DS4Control/Viiper/ViiperSetupManager.cs
+++ b/DS4Windows/DS4Control/Viiper/ViiperSetupManager.cs
@@ -10,26 +10,55 @@ it under the terms of the GNU General Public License as published by
using Microsoft.Win32;
using System;
-using System.Diagnostics;
+using System.Collections.Generic;
using System.IO;
-using System.Linq;
-using System.Net.Sockets;
-using System.Text;
-using System.Threading;
+using System.Runtime.InteropServices;
+using System.Security.Cryptography;
+using System.Security.Principal;
+using System.Text.Json;
using System.Windows;
namespace DS4Windows
{
public sealed class ViiperPrerequisiteStatus
{
- public bool ViiperInstalled { get; set; }
- public bool ServerRunning { get; set; }
- public bool UsbipInstalled { get; set; }
+ public bool MetadataFound { get; set; }
+ public bool MetadataEligible { get; set; }
+ public bool LocalTestMetadata { get; set; }
+ public bool PackageBundleFound { get; set; }
+ public bool BrokerInstalled { get; set; }
+ public bool BrokerHashMatches { get; set; }
+ public bool BrokerServiceInstalled { get; set; }
+ public bool BrokerServiceConfigured { get; set; }
+ public bool BrokerServiceRunning { get; set; }
+ public bool CredentialReadable { get; set; }
+ public bool AuthenticatedPingSucceeded { get; set; }
+ public bool RuntimeContractCompatible { get; set; }
public bool SetupScriptFound { get; set; }
- public string ViiperPath { get; set; }
+ public string BrokerPath { get; set; }
+ public string CredentialPath { get; set; }
+ public string MetadataPath { get; set; }
public string SetupScriptPath { get; set; }
-
- public bool Ready => ServerRunning && UsbipInstalled;
+ public string Detail { get; set; }
+
+ // Source-compatible diagnostic aliases. USB/IP is deliberately not a
+ // prerequisite for native output and is never reported as installed.
+ public bool ViiperInstalled => BrokerInstalled;
+ public bool ServerRunning =>
+ BrokerServiceRunning && AuthenticatedPingSucceeded;
+ public bool UsbipInstalled => false;
+ public string ViiperPath => BrokerPath;
+
+ public bool Ready =>
+ MetadataEligible &&
+ BrokerInstalled &&
+ BrokerHashMatches &&
+ BrokerServiceInstalled &&
+ BrokerServiceConfigured &&
+ BrokerServiceRunning &&
+ CredentialReadable &&
+ AuthenticatedPingSucceeded &&
+ RuntimeContractCompatible;
public string DisplayText
{
@@ -37,25 +66,47 @@ public string DisplayText
{
if (Ready)
{
- return "VIIPER ready";
+ return LocalTestMetadata
+ ? "VIIPER native UDE ready (disposable-VM local test)"
+ : "VIIPER native UDE ready";
}
-
- if (!UsbipInstalled && !ViiperInstalled)
+ if (!MetadataFound)
{
- return "VIIPER and usbip-win2 need setup";
+ return "VIIPER native runtime metadata missing";
}
-
- if (!UsbipInstalled)
+ if (!MetadataEligible)
{
- return "usbip-win2 driver missing";
+ return LocalTestMetadata
+ ? "VIIPER bundle is local-test evidence only"
+ : "VIIPER native runtime is not production eligible";
}
-
- if (!ViiperInstalled)
+ if (!BrokerInstalled || !BrokerServiceInstalled)
{
- return "VIIPER helper missing";
+ return "VIIPER native UDE package is not installed";
}
-
- return ServerRunning ? "VIIPER status unknown" : "VIIPER server not running";
+ if (!BrokerHashMatches || !BrokerServiceConfigured)
+ {
+ return "VIIPER native broker installation does not match this build";
+ }
+ if (!CredentialReadable)
+ {
+ return "VIIPER protected API credential is unavailable";
+ }
+ if (!BrokerServiceRunning)
+ {
+ return "VIIPERNativeBroker service is not running";
+ }
+ if (!AuthenticatedPingSucceeded)
+ {
+ return "VIIPER native broker authentication failed";
+ }
+ if (!RuntimeContractCompatible)
+ {
+ return "VIIPER native driver contract is incompatible";
+ }
+ return string.IsNullOrWhiteSpace(Detail)
+ ? "VIIPER native UDE status unknown"
+ : Detail;
}
}
}
@@ -64,273 +115,664 @@ public static class ViiperSetupManager
{
public const string ApiHost = "127.0.0.1";
public const int ApiPort = 3242;
- public const string UsbipWin2ReleasesUrl = "https://github.com/vadimgrn/usbip-win2/releases";
- public const string ViiperReleasesUrl = "https://github.com/hbashton/VIIPER/releases";
-
- private const string InstallerScriptName = "install-viiper-backend.ps1";
- private static readonly object serverStartLock = new object();
- private static DateTime lastServerStartAttemptUtc = DateTime.MinValue;
+ public const string NativeBrokerServiceName = "VIIPERNativeBroker";
+ public const string LocalTestOptInEnvironment =
+ "DS4WINDOWS_VIIPER_ALLOW_LOCAL_TEST";
+
+ private const string InstallerScriptName =
+ "manage-viiper-native-package.ps1";
+ private const string MetadataFileName =
+ "ViiperNativeRuntimeMetadata.json";
private static int promptShownThisSession;
- public static bool IsViiperOutputType(OutContType type) => ViiperOutDevice.IsViiperType(type);
+ public static bool IsViiperOutputType(OutContType type) =>
+ ViiperOutDevice.IsViiperType(type);
- public static ViiperPrerequisiteStatus GetStatus(bool tryStartServer = false)
+ public static ViiperPrerequisiteStatus GetStatus(
+ bool tryStartServer = false)
{
- string viiperPath = GetViiperExePath();
+ // VIIPERNativeBroker is auto-started and owned by SCM. Never start
+ // a second per-user broker in response to a status query.
+ _ = tryStartServer;
+
+ string brokerPath = GetInstalledBrokerPath();
+ string credentialPath = GetCredentialPath();
+ string metadataPath = GetMetadataPath();
string setupScriptPath = GetSetupScriptPath();
- ViiperPrerequisiteStatus status = new ViiperPrerequisiteStatus
+ NativeMetadataStatus metadata = InspectMetadata(metadataPath,
+ brokerPath);
+ NativeServiceStatus service = InspectNativeBrokerService(
+ brokerPath, credentialPath);
+ ViiperNativeStatusProbe probe =
+ ViiperNativeRuntime.GetStatusProbe(ApiHost, ApiPort);
+
+ return new ViiperPrerequisiteStatus
{
- ViiperPath = viiperPath,
- SetupScriptPath = setupScriptPath,
- ViiperInstalled = File.Exists(viiperPath),
+ MetadataFound = probe.MetadataPresent && metadata.Found,
+ MetadataEligible = probe.MetadataEligible && metadata.Eligible,
+ LocalTestMetadata = metadata.LocalTest,
+ PackageBundleFound = metadata.PackageBundleFound,
+ BrokerInstalled = File.Exists(brokerPath),
+ BrokerHashMatches = metadata.BrokerHashMatches,
+ BrokerServiceInstalled = service.Installed,
+ BrokerServiceConfigured = service.Configured,
+ BrokerServiceRunning = service.Running,
+ CredentialReadable = probe.CredentialReadable,
+ AuthenticatedPingSucceeded = probe.Authenticated,
+ RuntimeContractCompatible = probe.IdentityValid,
SetupScriptFound = File.Exists(setupScriptPath),
- UsbipInstalled = IsUsbipWin2Installed(),
- ServerRunning = CanPingServer(),
+ BrokerPath = brokerPath,
+ CredentialPath = credentialPath,
+ MetadataPath = metadataPath,
+ SetupScriptPath = setupScriptPath,
+ Detail = FirstNonEmpty(probe.FailureReason, service.Detail,
+ metadata.Detail),
};
-
- if (tryStartServer && !status.ServerRunning && status.ViiperInstalled)
- {
- TryStartServerOnce(viiperPath);
- status.ServerRunning = CanPingServer();
- }
-
- return status;
}
- public static bool EnsureReadyWithPrompt(Window owner, bool forcePrompt = false)
+ public static bool EnsureReadyWithPrompt(Window owner,
+ bool forcePrompt = false)
{
- ViiperPrerequisiteStatus status = GetStatus(tryStartServer: true);
+ ViiperPrerequisiteStatus status = GetStatus();
if (status.Ready)
{
return true;
}
-
- if (Volatile.Read(ref promptShownThisSession) == 1 && !forcePrompt)
+ if (System.Threading.Volatile.Read(ref promptShownThisSession) == 1 &&
+ !forcePrompt)
{
return false;
}
- Interlocked.Exchange(ref promptShownThisSession, 1);
+ System.Threading.Interlocked.Exchange(
+ ref promptShownThisSession, 1);
string message =
- "This profile uses a VIIPER virtual controller output.\n\n" +
- "DS4Windows needs two pieces installed before this can work:\n" +
- "- VIIPER helper/server\n" +
- "- usbip-win2 Windows USB/IP driver\n\n" +
+ "This profile uses a VIIPER native UDE virtual controller.\n\n" +
+ "DS4Windows requires the signed native package, including " +
+ "the UdeCx driver and the managed LocalSystem " +
+ "VIIPERNativeBroker service.\n\n" +
$"Current status: {status.DisplayText}\n\n" +
- "Install or repair VIIPER support now?";
-
- MessageBoxResult result = owner != null
- ? MessageBox.Show(owner, message, "VIIPER virtual controller setup", MessageBoxButton.YesNo, MessageBoxImage.Information)
- : MessageBox.Show(message, "VIIPER virtual controller setup", MessageBoxButton.YesNo, MessageBoxImage.Information);
+ "Install or repair it with the signed DS4Windows installer " +
+ "or its installed maintenance entry, then restart " +
+ "DS4Windows. The portable runtime never elevates bundled " +
+ "scripts or package files.";
+ ShowSetupMessage(owner, message, MessageBoxImage.Information);
+ return false;
+ }
- if (result != MessageBoxResult.Yes)
+ public static bool LaunchInstaller(
+ ViiperPrerequisiteStatus status = null, Window owner = null)
+ {
+ status ??= GetStatus();
+ if (status.LocalTestMetadata)
{
+ string scriptDetail = status.SetupScriptFound
+ ? "A developer may run the bundled package manager " +
+ "manually"
+ : "The source-bound package manager is absent, so " +
+ "this build must be replaced";
+ ShowSetupMessage(owner,
+ "Local-test evidence is never installed by the normal UI. " +
+ scriptDetail + " " +
+ "with the environment opt-in plus -AllowLocalTest and " +
+ "-AcknowledgeDisposableTestMachine on a disposable VM.",
+ MessageBoxImage.Warning);
return false;
}
-
- return LaunchInstaller(status, owner);
+ if (!status.MetadataFound || !status.MetadataEligible ||
+ !status.PackageBundleFound)
+ {
+ string detail = status.LocalTestMetadata
+ ? "This build contains verified local-test evidence, not " +
+ "production driver media. It can only be installed " +
+ "manually on a disposable VM with the explicit " +
+ $"{LocalTestOptInEnvironment}=1 developer opt-in."
+ : "This build does not contain the exact production " +
+ "HLK/WHCP runtime bundle. Installation is blocked " +
+ "instead of downloading or substituting a driver.";
+ ShowSetupMessage(owner, detail, MessageBoxImage.Warning);
+ return false;
+ }
+ ShowSetupMessage(owner,
+ "The portable DS4Windows runtime never elevates its mutable " +
+ "package directory. Install or repair VIIPER through the " +
+ "signed DS4Windows installer or its machine-installed, " +
+ "signed maintenance entry, then restart DS4Windows.",
+ MessageBoxImage.Information);
+ return false;
}
- public static bool LaunchInstaller(ViiperPrerequisiteStatus status = null, Window owner = null)
+ public static bool LaunchUninstaller(
+ ViiperPrerequisiteStatus status = null, Window owner = null)
{
status ??= GetStatus();
- if (!status.SetupScriptFound)
+ if (!status.MetadataEligible || !status.PackageBundleFound ||
+ status.LocalTestMetadata)
{
- string message =
- "DS4Windows could not find the bundled VIIPER setup script.\n\n" +
- "Opening the VIIPER and usbip-win2 release pages instead.";
- if (owner != null)
- {
- MessageBox.Show(owner, message, "VIIPER setup", MessageBoxButton.OK, MessageBoxImage.Warning);
- }
- else
- {
- MessageBox.Show(message, "VIIPER setup", MessageBoxButton.OK, MessageBoxImage.Warning);
- }
-
- Util.StartProcessHelper(ViiperReleasesUrl);
- Util.StartProcessHelper(UsbipWin2ReleasesUrl);
+ ShowSetupMessage(owner,
+ "Exact signed VIIPER package metadata and helper media " +
+ "are required for transactional removal.",
+ MessageBoxImage.Error);
return false;
}
- try
+ ShowSetupMessage(owner,
+ "Remove VIIPER through the signed DS4Windows installer or " +
+ "its machine-installed, signed maintenance entry. The " +
+ "portable runtime will not elevate bundled scripts or " +
+ "helper media.", MessageBoxImage.Information);
+ return false;
+ }
+
+ private static void ShowSetupMessage(Window owner, string message,
+ MessageBoxImage image)
+ {
+ if (owner != null)
{
- ProcessStartInfo startInfo = new ProcessStartInfo
- {
- FileName = "powershell.exe",
- Arguments = $"-NoProfile -ExecutionPolicy Bypass -File \"{status.SetupScriptPath}\"",
- UseShellExecute = true,
- Verb = "runas",
- };
- Process.Start(startInfo);
- return true;
+ MessageBox.Show(owner, message, "VIIPER native UDE setup",
+ MessageBoxButton.OK, image);
}
- catch (Exception ex)
+ else
{
- string message = $"Could not launch VIIPER setup: {ex.Message}";
- if (owner != null)
- {
- MessageBox.Show(owner, message, "VIIPER setup", MessageBoxButton.OK, MessageBoxImage.Error);
- }
- else
- {
- MessageBox.Show(message, "VIIPER setup", MessageBoxButton.OK, MessageBoxImage.Error);
- }
-
- return false;
+ MessageBox.Show(message, "VIIPER native UDE setup",
+ MessageBoxButton.OK, image);
}
}
- private static string GetViiperExePath()
+ private static string GetInstalledBrokerPath()
{
- string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
- return Path.Combine(localAppData, "VIIPER", "viiper.exe");
+ string programFiles = Environment.GetFolderPath(
+ Environment.SpecialFolder.ProgramFiles);
+ return Path.Combine(programFiles, "VIIPER", "viiper.exe");
}
- private static string GetSetupScriptPath()
+ private static string GetCredentialPath()
{
- return Path.Combine(Global.exedirpath, "extras", InstallerScriptName);
+ string programData = Environment.GetFolderPath(
+ Environment.SpecialFolder.CommonApplicationData);
+ return Path.Combine(programData, "VIIPER", "viiper.key.txt");
}
- private static bool TryStartServerOnce(string viiperPath)
+ private static string GetMetadataPath() =>
+ Path.Combine(Global.exedirpath, MetadataFileName);
+
+ private static string GetSetupScriptPath() =>
+ Path.Combine(Global.exedirpath, "extras", InstallerScriptName);
+
+ private static NativeMetadataStatus InspectMetadata(string path,
+ string installedBrokerPath)
{
- lock (serverStartLock)
+ if (!File.Exists(path))
{
- if (CanPingServer())
+ return new NativeMetadataStatus
{
- return true;
+ Detail = "Native runtime metadata file is absent.",
+ };
+ }
+
+ try
+ {
+ using FileStream stream = new FileStream(path, FileMode.Open,
+ FileAccess.Read, FileShare.Read);
+ using JsonDocument document = JsonDocument.Parse(stream);
+ JsonElement root = document.RootElement;
+ string eligibility = GetRequiredString(root,
+ "releaseEligibility");
+ bool localTest = string.Equals(eligibility,
+ "local-test-evidence-only", StringComparison.Ordinal);
+ bool eligible = string.Equals(eligibility, "production",
+ StringComparison.Ordinal) ||
+ (localTest && string.Equals(
+ Environment.GetEnvironmentVariable(
+ LocalTestOptInEnvironment), "1",
+ StringComparison.Ordinal));
+ if (root.GetProperty("schemaVersion").GetInt32() != 1)
+ {
+ throw new InvalidDataException(
+ "Unsupported native metadata schema.");
}
+ if (!string.Equals(GetRequiredString(root,
+ "localTestOptInEnvironment"),
+ LocalTestOptInEnvironment, StringComparison.Ordinal))
+ {
+ throw new InvalidDataException(
+ "Native metadata local-test opt-in is invalid.");
+ }
+ JsonElement managedBroker = root.GetProperty(
+ "managedBroker");
+ if (!string.Equals(GetRequiredString(managedBroker,
+ "serviceName"), NativeBrokerServiceName,
+ StringComparison.Ordinal) ||
+ !string.Equals(GetRequiredString(managedBroker,
+ "serviceAccount"), "LocalSystem",
+ StringComparison.Ordinal) ||
+ !string.Equals(GetRequiredString(managedBroker,
+ "startMode"), "automatic",
+ StringComparison.Ordinal) ||
+ !string.Equals(GetRequiredString(managedBroker,
+ "transport"), "native-ude",
+ StringComparison.Ordinal) ||
+ !string.Equals(GetRequiredString(managedBroker,
+ "apiHost"), ApiHost,
+ StringComparison.Ordinal) ||
+ managedBroker.GetProperty("apiPort").GetInt32() !=
+ ApiPort ||
+ !string.Equals(GetRequiredString(managedBroker,
+ "credentialPath"),
+ "%ProgramData%/VIIPER/viiper.key.txt",
+ StringComparison.Ordinal))
+ {
+ throw new InvalidDataException(
+ "Native metadata managed-broker contract is invalid.");
+ }
+ ValidateControllerApiContract(root);
- DateTime now = DateTime.UtcNow;
- if ((now - lastServerStartAttemptUtc).TotalSeconds < 3)
+ JsonElement broker = GetUniqueArtifact(root, "broker");
+ bool packageFound = true;
+ foreach (string role in new[]
{
- return false;
+ "broker", "driver-helper", "submission-manifest",
+ "driver-inf", "driver-sys", "driver-cat",
+ })
+ {
+ JsonElement artifact = GetUniqueArtifact(root, role);
+ string packagePath = ResolveBundledArtifactPath(
+ GetRequiredString(artifact, "relativePath"));
+ packageFound &= File.Exists(packagePath) &&
+ FileMatchesArtifact(packagePath, artifact);
}
+ bool installedHashMatches = File.Exists(installedBrokerPath) &&
+ FileMatchesArtifact(installedBrokerPath, broker);
- lastServerStartAttemptUtc = now;
- return TryStartServer(viiperPath);
+ return new NativeMetadataStatus
+ {
+ Found = true,
+ Eligible = eligible,
+ LocalTest = localTest,
+ PackageBundleFound = packageFound,
+ BrokerHashMatches = installedHashMatches,
+ Detail = eligible ? string.Empty :
+ "Native metadata is not eligible for this runtime mode.",
+ };
+ }
+ catch (Exception ex) when (ex is IOException ||
+ ex is UnauthorizedAccessException ||
+ ex is JsonException ||
+ ex is InvalidDataException ||
+ ex is CryptographicException ||
+ ex is KeyNotFoundException ||
+ ex is InvalidOperationException)
+ {
+ return new NativeMetadataStatus
+ {
+ Found = true,
+ Detail = $"Native runtime metadata is invalid: {ex.Message}",
+ };
}
}
- private static bool TryStartServer(string viiperPath)
+ private static string ResolveBundledArtifactPath(string relativePath)
{
- try
+ if (string.IsNullOrWhiteSpace(relativePath) ||
+ Path.IsPathRooted(relativePath))
{
- ProcessStartInfo startInfo = new ProcessStartInfo
- {
- FileName = viiperPath,
- Arguments = "server",
- CreateNoWindow = true,
- WindowStyle = ProcessWindowStyle.Hidden,
- UseShellExecute = false,
- };
- Process.Start(startInfo);
- System.Threading.Thread.Sleep(750);
- return true;
+ throw new InvalidDataException(
+ "Native artifact path must be relative.");
}
- catch
+ string extrasRoot = Path.GetFullPath(Path.Combine(
+ Global.exedirpath, "extras"));
+ string candidate = Path.GetFullPath(Path.Combine(extrasRoot,
+ relativePath.Replace('/', Path.DirectorySeparatorChar)));
+ string prefix = extrasRoot.TrimEnd(Path.DirectorySeparatorChar) +
+ Path.DirectorySeparatorChar;
+ if (!candidate.StartsWith(prefix,
+ StringComparison.OrdinalIgnoreCase))
{
- return false;
+ throw new InvalidDataException(
+ "Native artifact path escaped the extras directory.");
}
+ return candidate;
}
- private static bool CanPingServer()
+ private static void ValidateControllerApiContract(JsonElement root)
{
- try
+ Dictionary expected = new(
+ StringComparer.Ordinal)
{
- using TcpClient tcp = new TcpClient
- {
- NoDelay = true,
- SendTimeout = 500,
- ReceiveTimeout = 1000,
- };
+ ["xbox360"] =
+ "xbox360|0x045e|0x028e|0x028e|xusb-composite|fixed",
+ ["dualshock4"] =
+ "dualshock4|0x054c|0x09cc|0x05c4|hid-audio-duplex|fixed",
+ ["dualshock4audioduplexv3"] =
+ "dualshock4|0x054c|0x09cc|0x05c4|hid-audio-duplex|framed-v3",
+ ["dualshock4audioonlyduplexv3"] =
+ "dualshock4|0x054c|0x09cc|0x05c4|audio-duplex-only|framed-v3",
+ ["dualsensecombinedaudioduplexv5"] =
+ "dualsense|0x054c|0x0ce6|0x0ce6|hid-audio-duplex|framed-v5",
+ ["dualsenseaudioonlyduplexv5"] =
+ "dualsense|0x054c|0x0ce6|0x0ce6|audio-duplex-only|framed-v5",
+ ["dualsensegamepadv5"] =
+ "dualsense|0x054c|0x0ce6|0x0ce6|hid-gamepad-only|framed-v5",
+ ["dualsenseedgecombinedaudioduplexv5"] =
+ "dualsense-edge|0x054c|0x0df2|0x0df2|hid-audio-duplex|framed-v5",
+ ["dualsenseedgegamepadv5"] =
+ "dualsense-edge|0x054c|0x0df2|0x0df2|hid-gamepad-only|framed-v5",
+ ["ns2pro"] =
+ "switch2-pro|0x057e|0x2069|0x2069|hid-vendor-bulk|fixed",
+ };
+ JsonElement contract = root.GetProperty(
+ "controllerApiContract");
+ if (contract.GetProperty("schemaVersion").GetInt32() != 1 ||
+ !string.Equals(GetRequiredString(contract, "sourceRevision"),
+ GetRequiredString(root, "sourceRevision"),
+ StringComparison.Ordinal))
+ {
+ throw new InvalidDataException(
+ "Controller API contract is not source-bound.");
+ }
- IAsyncResult result = tcp.BeginConnect(ApiHost, ApiPort, null, null);
- if (!result.AsyncWaitHandle.WaitOne(TimeSpan.FromMilliseconds(750)))
+ HashSet found = new(StringComparer.Ordinal);
+ foreach (JsonElement registration in contract.GetProperty(
+ "registrations").EnumerateArray())
+ {
+ string type = GetRequiredString(registration, "type");
+ string signature = string.Join("|",
+ GetRequiredString(registration, "persona"),
+ GetRequiredString(registration, "defaultVid"),
+ GetRequiredString(registration, "defaultPid"),
+ GetRequiredString(registration, "ds4WindowsPid"),
+ GetRequiredString(registration, "interfaceProfile"),
+ GetRequiredString(registration, "streamProtocol"));
+ if (!found.Add(type) || !expected.TryGetValue(type,
+ out string expectedSignature) ||
+ !string.Equals(signature, expectedSignature,
+ StringComparison.Ordinal))
{
- return false;
+ throw new InvalidDataException(
+ $"Controller API type {type} does not match the " +
+ "VIIPER HID/interface implementation.");
}
+ }
+ if (found.Count != expected.Count)
+ {
+ throw new InvalidDataException(
+ "Controller API contract omits a DS4Windows persona.");
+ }
+ }
- tcp.EndConnect(result);
- NetworkStream stream = tcp.GetStream();
- byte[] request = Encoding.UTF8.GetBytes("ping\0");
- stream.Write(request, 0, request.Length);
+ private static bool FileMatchesArtifact(string path,
+ JsonElement artifact)
+ {
+ FileInfo info = new FileInfo(path);
+ if ((info.Attributes & FileAttributes.ReparsePoint) != 0 ||
+ info.Length != artifact.GetProperty("length").GetInt64())
+ {
+ return false;
+ }
+ string expected = GetRequiredString(artifact, "sha256");
+ if (expected.Length != 64)
+ {
+ return false;
+ }
+ using FileStream stream = new FileStream(path, FileMode.Open,
+ FileAccess.Read, FileShare.Read);
+ string actual = Convert.ToHexString(SHA256.HashData(stream))
+ .ToLowerInvariant();
+ return string.Equals(actual, expected,
+ StringComparison.Ordinal);
+ }
- byte[] buffer = new byte[256];
- int read = stream.Read(buffer, 0, buffer.Length);
- if (read <= 0)
+ private static JsonElement GetUniqueArtifact(JsonElement root,
+ string role)
+ {
+ JsonElement selected = default;
+ int matches = 0;
+ foreach (JsonElement artifact in
+ root.GetProperty("artifacts").EnumerateArray())
+ {
+ if (string.Equals(GetRequiredString(artifact, "role"), role,
+ StringComparison.Ordinal))
{
- return false;
+ selected = artifact;
+ matches++;
}
-
- string response = Encoding.UTF8.GetString(buffer, 0, read);
- return response.IndexOf("VIIPER", StringComparison.OrdinalIgnoreCase) >= 0;
}
- catch
+ if (matches != 1)
{
- return false;
+ throw new InvalidDataException(
+ $"Native metadata requires exactly one {role} artifact.");
}
+ return selected;
}
- private static bool IsUsbipWin2Installed()
+ private static string GetRequiredString(JsonElement element,
+ string name)
{
- string driverPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "drivers", "usbip2_ude.sys");
- if (File.Exists(driverPath))
+ string value = element.GetProperty(name).GetString();
+ if (string.IsNullOrWhiteSpace(value))
{
- return true;
+ throw new InvalidDataException(
+ $"Native metadata property {name} is empty.");
}
-
- return RegistryUninstallContains("USB/IP") ||
- RegistryUninstallContains("USBip") ||
- RegistryServiceExists("usbip2_ude") ||
- RegistryServiceExists("usbip2_filter");
+ return value;
}
- private static bool RegistryServiceExists(string serviceName)
+ private static NativeServiceStatus InspectNativeBrokerService(
+ string brokerPath, string credentialPath)
{
try
{
- using RegistryKey key = Registry.LocalMachine.OpenSubKey($@"SYSTEM\CurrentControlSet\Services\{serviceName}");
- return key != null;
+ using RegistryKey key = Registry.LocalMachine.OpenSubKey(
+ $@"SYSTEM\CurrentControlSet\Services\{NativeBrokerServiceName}");
+ if (key == null)
+ {
+ return new NativeServiceStatus();
+ }
+
+ string imagePath = key.GetValue("ImagePath") as string;
+ string objectName = key.GetValue("ObjectName") as string;
+ int start = Convert.ToInt32(key.GetValue("Start", -1));
+ int type = Convert.ToInt32(key.GetValue("Type", -1));
+ string logPath = Path.Combine(
+ Path.GetDirectoryName(credentialPath),
+ "viiper-native-broker.log");
+ bool configured = start == 2 &&
+ type == NativeMethods.ServiceWin32OwnProcess &&
+ IsLocalSystemAccount(objectName) &&
+ NativeMethods.CommandLineMatches(imagePath,
+ brokerPath, credentialPath, logPath);
+ return new NativeServiceStatus
+ {
+ Installed = true,
+ Configured = configured,
+ Running = NativeMethods.IsServiceRunning(
+ NativeBrokerServiceName),
+ Detail = configured ? string.Empty :
+ "VIIPERNativeBroker SCM configuration is not exact.",
+ };
}
- catch
+ catch (Exception ex) when (ex is UnauthorizedAccessException ||
+ ex is IOException || ex is InvalidOperationException ||
+ ex is System.ComponentModel.Win32Exception)
{
- return false;
+ return new NativeServiceStatus
+ {
+ Detail = $"Could not inspect VIIPERNativeBroker: {ex.Message}",
+ };
}
}
- private static bool RegistryUninstallContains(string displayName)
+ private static bool IsLocalSystemAccount(string value) =>
+ string.Equals(value, "LocalSystem",
+ StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(value, @"NT AUTHORITY\SYSTEM",
+ StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(value, @".\LocalSystem",
+ StringComparison.OrdinalIgnoreCase);
+
+ private static string FirstNonEmpty(params string[] values)
{
- return RegistryHiveUninstallContains(RegistryView.Registry64, displayName) ||
- RegistryHiveUninstallContains(RegistryView.Registry32, displayName);
+ foreach (string value in values)
+ {
+ if (!string.IsNullOrWhiteSpace(value))
+ {
+ return value;
+ }
+ }
+ return string.Empty;
}
- private static bool RegistryHiveUninstallContains(RegistryView view, string displayName)
+ private sealed class NativeMetadataStatus
{
- try
+ internal bool Found { get; set; }
+ internal bool Eligible { get; set; }
+ internal bool LocalTest { get; set; }
+ internal bool PackageBundleFound { get; set; }
+ internal bool BrokerHashMatches { get; set; }
+ internal string Detail { get; set; }
+ }
+
+ private sealed class NativeServiceStatus
+ {
+ internal bool Installed { get; set; }
+ internal bool Configured { get; set; }
+ internal bool Running { get; set; }
+ internal string Detail { get; set; }
+ }
+
+ private static class NativeMethods
+ {
+ internal const int ServiceWin32OwnProcess = 0x10;
+ private const uint ScManagerConnect = 0x0001;
+ private const uint ServiceQueryStatus = 0x0004;
+ private const int ScStatusProcessInfo = 0;
+ private const uint ServiceRunning = 0x00000004;
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct ServiceStatusProcess
+ {
+ internal uint ServiceType;
+ internal uint CurrentState;
+ internal uint ControlsAccepted;
+ internal uint Win32ExitCode;
+ internal uint ServiceSpecificExitCode;
+ internal uint CheckPoint;
+ internal uint WaitHint;
+ internal uint ProcessId;
+ internal uint ServiceFlags;
+ }
+
+ [DllImport("advapi32.dll", CharSet = CharSet.Unicode,
+ SetLastError = true)]
+ private static extern IntPtr OpenSCManager(string machineName,
+ string databaseName, uint desiredAccess);
+
+ [DllImport("advapi32.dll", CharSet = CharSet.Unicode,
+ SetLastError = true)]
+ private static extern IntPtr OpenService(IntPtr manager,
+ string serviceName, uint desiredAccess);
+
+ [DllImport("advapi32.dll", SetLastError = true)]
+ private static extern bool QueryServiceStatusEx(IntPtr service,
+ int infoLevel, out ServiceStatusProcess status,
+ int bufferSize, out int bytesNeeded);
+
+ [DllImport("advapi32.dll")]
+ private static extern bool CloseServiceHandle(IntPtr handle);
+
+ [DllImport("shell32.dll", CharSet = CharSet.Unicode,
+ SetLastError = true)]
+ private static extern IntPtr CommandLineToArgvW(
+ string commandLine, out int argumentCount);
+
+ [DllImport("kernel32.dll")]
+ private static extern IntPtr LocalFree(IntPtr memory);
+
+ internal static bool IsServiceRunning(string serviceName)
{
- using RegistryKey baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view);
- using RegistryKey uninstallKey = baseKey.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall");
- if (uninstallKey == null)
+ IntPtr manager = IntPtr.Zero;
+ IntPtr service = IntPtr.Zero;
+ try
{
- return false;
+ manager = OpenSCManager(null, null, ScManagerConnect);
+ if (manager == IntPtr.Zero)
+ {
+ return false;
+ }
+ service = OpenService(manager, serviceName,
+ ServiceQueryStatus);
+ if (service == IntPtr.Zero)
+ {
+ return false;
+ }
+ bool ok = QueryServiceStatusEx(service,
+ ScStatusProcessInfo, out ServiceStatusProcess status,
+ Marshal.SizeOf(), out _);
+ return ok && status.CurrentState == ServiceRunning;
}
+ finally
+ {
+ if (service != IntPtr.Zero)
+ {
+ CloseServiceHandle(service);
+ }
+ if (manager != IntPtr.Zero)
+ {
+ CloseServiceHandle(manager);
+ }
+ }
+ }
- return uninstallKey.GetSubKeyNames()
- .Select(name => uninstallKey.OpenSubKey(name))
- .Where(key => key != null)
- .Any(key =>
+ internal static bool CommandLineMatches(string commandLine,
+ string brokerPath, string credentialPath, string logPath)
+ {
+ if (string.IsNullOrWhiteSpace(commandLine))
+ {
+ return false;
+ }
+ IntPtr arguments = CommandLineToArgvW(commandLine,
+ out int count);
+ if (arguments == IntPtr.Zero)
+ {
+ return false;
+ }
+ try
+ {
+ if (count != 8)
{
- using (key)
+ return false;
+ }
+ string[] expected =
+ {
+ brokerPath,
+ "service",
+ "--transport",
+ "native-ude",
+ "--key-file",
+ credentialPath,
+ "--log.file",
+ logPath,
+ };
+ for (int i = 0; i < expected.Length; i++)
+ {
+ IntPtr value = Marshal.ReadIntPtr(arguments,
+ i * IntPtr.Size);
+ string actual = Marshal.PtrToStringUni(value);
+ StringComparison comparison = i == 0 || i == 5 ||
+ i == 7
+ ? StringComparison.OrdinalIgnoreCase
+ : StringComparison.Ordinal;
+ if (!string.Equals(actual, expected[i], comparison))
{
- string value = key.GetValue("DisplayName") as string;
- return value?.IndexOf(displayName, StringComparison.OrdinalIgnoreCase) >= 0;
+ return false;
}
- });
- }
- catch
- {
- return false;
+ }
+ return true;
+ }
+ finally
+ {
+ LocalFree(arguments);
+ }
}
}
}
diff --git a/DS4Windows/DS4Forms/MainWindow.xaml b/DS4Windows/DS4Forms/MainWindow.xaml
index 4e5b2c0..2db3434 100644
--- a/DS4Windows/DS4Forms/MainWindow.xaml
+++ b/DS4Windows/DS4Forms/MainWindow.xaml
@@ -567,7 +567,7 @@
-
+
+
-
{
MessageBox.Show(Properties.Resources.PleaseDownloadUpdater);
- if (!string.IsNullOrEmpty(newUpdaterVersion))
- {
- Util.StartProcessHelper(
- $"https://github.com/hbashton/DS4Updater/releases/tag/v{newUpdaterVersion}");
- }
+ Util.StartProcessHelper(
+ $"https://github.com/hbashton/DS4Windows/releases/tag/{version}");
});
}
}
@@ -381,10 +378,8 @@ private void Check_Version(bool showstatus = false)
Dispatcher.Invoke(() =>
{
MessageBox.Show(Properties.Resources.PleaseDownloadUpdater);
- if (!string.IsNullOrEmpty(newUpdaterVersion))
- {
- Util.StartProcessHelper($"https://github.com/hbashton/DS4Updater/releases/tag/v{newUpdaterVersion}");
- }
+ Util.StartProcessHelper(
+ $"https://github.com/hbashton/DS4Windows/releases/tag/{newversion}");
});
}
}
@@ -2049,33 +2044,14 @@ private void ControlPanelBtn_Click(object sender, RoutedEventArgs e)
Process.Start("control", "joy.cpl");
}
- private async void DriverSetupBtn_Click(object sender, RoutedEventArgs e)
+ private void DriverSetupBtn_Click(object sender, RoutedEventArgs e)
{
- StartStopBtn.IsEnabled = false;
- await Task.Run(() =>
- {
- if (App.rootHub.running)
- App.rootHub.Stop();
- });
-
- StartStopBtn.IsEnabled = true;
- ProcessStartInfo startInfo = new ProcessStartInfo();
- startInfo.FileName = Global.exelocation;
- startInfo.Arguments = "-driverinstall";
- startInfo.Verb = "runas";
- startInfo.UseShellExecute = true;
- try
- {
- using (Process temp = Process.Start(startInfo))
- {
- temp.WaitForExit();
- Global.RefreshHidHideInfo();
- Global.RefreshFakerInputInfo();
-
- settingsWrapVM.DriverCheckRefresh();
- }
- }
- catch { }
+ MessageBox.Show(this,
+ "The portable DS4Windows runtime does not elevate itself or " +
+ "mutable driver media. Install or repair system components " +
+ "through the signed DS4Windows installer or its installed " +
+ "maintenance entry.", "DS4Windows driver setup",
+ MessageBoxButton.OK, MessageBoxImage.Information);
}
private void ViiperSetupBtn_Click(object sender, RoutedEventArgs e)
@@ -2088,6 +2064,12 @@ private void ViiperRefreshBtn_Click(object sender, RoutedEventArgs e)
RefreshViiperStatusText();
}
+ private void ViiperRemoveBtn_Click(object sender, RoutedEventArgs e)
+ {
+ ViiperSetupManager.LaunchUninstaller(
+ ViiperSetupManager.GetStatus(), this);
+ }
+
private void RefreshViiperStatusText()
{
if (viiperStatusText == null)
@@ -2097,9 +2079,13 @@ private void RefreshViiperStatusText()
ViiperPrerequisiteStatus status = ViiperSetupManager.GetStatus(tryStartServer: false);
viiperStatusText.Text = $"{status.DisplayText}. " +
- $"VIIPER helper: {(status.ViiperInstalled ? "installed" : "missing")}; " +
- $"usbip-win2: {(status.UsbipInstalled ? "installed" : "missing")}; " +
- $"server: {(status.ServerRunning ? "running" : "not running")}.";
+ $"bundle: {(status.PackageBundleFound ? "exact" : "not bundled")}; " +
+ $"installed broker: {(status.BrokerHashMatches ? "exact" : "missing/mismatched")}; " +
+ $"service: {(status.BrokerServiceConfigured ? "configured" : "missing/mismatched")}/" +
+ $"{(status.BrokerServiceRunning ? "running" : "stopped")}; " +
+ $"credential: {(status.CredentialReadable ? "readable" : "unavailable")}; " +
+ $"authenticated ping: {(status.AuthenticatedPingSucceeded ? "ready" : "not ready")}; " +
+ $"driver identity: {(status.RuntimeContractCompatible ? "compatible" : "unverified")}.";
}
private void CheckUpdatesBtn_Click(object sender, RoutedEventArgs e)
diff --git a/DS4Windows/DS4Forms/ViewModels/MainWindowsViewModel.cs b/DS4Windows/DS4Forms/ViewModels/MainWindowsViewModel.cs
index d389051..4ad865d 100644
--- a/DS4Windows/DS4Forms/ViewModels/MainWindowsViewModel.cs
+++ b/DS4Windows/DS4Forms/ViewModels/MainWindowsViewModel.cs
@@ -774,65 +774,14 @@ public string ProfileEditorSectionDescription
}
public event EventHandler ProfileEditorSectionDescriptionChanged;
- public string updaterExe = Environment.Is64BitProcess ? "DS4Updater.exe" : "DS4Updater_x86.exe";
-
- private string DownloadUpstreamUpdaterVersion()
- {
- Uri url = new Uri("https://api.github.com/repos/hbashton/DS4Updater/releases/latest");
-
- Task requestTask = App.requestClient.GetAsync(url.ToString());
- requestTask.Wait();
- if (requestTask.Result.IsSuccessStatusCode)
- {
- var gitHubReleaseTask = requestTask.Result.Content.ReadFromJsonAsync();
- gitHubReleaseTask.Wait();
- if (!gitHubReleaseTask.IsFaulted &&
- gitHubReleaseTask.Result is not null &&
- Changelog.TryParseReleaseVersion(gitHubReleaseTask.Result.TagName, out var updaterVersion))
- {
- return updaterVersion.ToString();
- }
- }
- return string.Empty;
- }
-
public bool RunUpdaterCheck(bool launch, out string upstreamVersion)
{
- string destPath = Path.Combine(Global.exedirpath, "DS4Updater.exe");
- bool updaterExists = File.Exists(destPath);
- upstreamVersion = DownloadUpstreamUpdaterVersion();
- if (string.IsNullOrEmpty(upstreamVersion)) return false;
-
- if (!updaterExists ||
- (!string.IsNullOrEmpty(upstreamVersion) && FileVersionInfo.GetVersionInfo(destPath).FileVersion.CompareTo(upstreamVersion) != 0))
- {
- launch = false;
- Uri url2 = new Uri($"https://github.com/hbashton/DS4Updater/releases/download/v{upstreamVersion}/{updaterExe}");
- string filename = Path.Combine(Path.GetTempPath(), "DS4Updater.exe");
- using (var downloadStream = new FileStream(filename, FileMode.Create))
- {
- Task temp =
- App.requestClient.GetAsync(url2.ToString(), downloadStream);
- temp.Wait();
- if (temp.Result.IsSuccessStatusCode) launch = true;
- }
-
- if (launch)
- {
- if (Global.AdminNeeded())
- {
- int copyStatus = DS4Windows.Util.ElevatedCopyUpdater(filename);
- if (copyStatus != 0) launch = false;
- }
- else
- {
- if (updaterExists) File.Delete(destPath);
- File.Move(filename, destPath);
- }
- }
- }
-
- return launch;
+ // A portable, user-writable process is not an update trust root.
+ // The signed installer/maintenance entry owns authenticated
+ // replacement; callers fall back to the exact DS4Windows release
+ // page without downloading or elevating an updater payload.
+ upstreamVersion = string.Empty;
+ return false;
}
public void DownloadUpstreamVersionInfo()
@@ -879,40 +828,7 @@ public void CheckDrivers()
public bool LauchDS4Updater(string releaseTag = null)
{
- bool launch = false;
- using (Process p = new Process())
- {
- p.StartInfo.FileName = Path.Combine(Global.exedirpath, "DS4Updater.exe");
- bool isAdmin = Global.IsAdministrator();
- List argList = new List();
- argList.Add("-autolaunch");
- if (!isAdmin)
- {
- argList.Add("-user");
- }
-
- if (!string.IsNullOrWhiteSpace(releaseTag))
- {
- argList.Add("--releaseTag");
- argList.Add(releaseTag);
- }
-
- // Specify current exe to have DS4Updater launch
- argList.Add("--launchExe");
- argList.Add(Global.exeFileName);
-
- foreach (string argument in argList)
- {
- p.StartInfo.ArgumentList.Add(argument);
- }
- if (Global.AdminNeeded())
- p.StartInfo.Verb = "runas";
-
- try { launch = p.Start(); }
- catch (InvalidOperationException) { }
- }
-
- return launch;
+ return false;
}
public bool IsNET8Available()
diff --git a/DS4Windows/DS4Forms/WelcomeDialog.xaml b/DS4Windows/DS4Forms/WelcomeDialog.xaml
index 7cfa947..0187634 100644
--- a/DS4Windows/DS4Forms/WelcomeDialog.xaml
+++ b/DS4Windows/DS4Forms/WelcomeDialog.xaml
@@ -23,9 +23,9 @@
-
+
+ Text="Required virtual-controller backend. DS4Windows requests administrator access to install the exact signed VIIPER UdeCx package and managed LocalSystem broker. Builds without production HLK/WHCP media fail closed." />
diff --git a/DS4Windows/DS4Forms/WelcomeDialog.xaml.cs b/DS4Windows/DS4Forms/WelcomeDialog.xaml.cs
index c0fd65e..36a18a5 100644
--- a/DS4Windows/DS4Forms/WelcomeDialog.xaml.cs
+++ b/DS4Windows/DS4Forms/WelcomeDialog.xaml.cs
@@ -10,20 +10,16 @@ it under the terms of the GNU General Public License as published by
using System;
using System.Diagnostics;
-using System.IO;
-using System.Threading.Tasks;
using System.Windows;
namespace DS4WinWPF.DS4Forms
{
public partial class WelcomeDialog : Window
{
- private const string HidHideInstaller =
- "https://github.com/nefarius/HidHide/releases/download/v1.5.230.0/HidHide_1.5.230_x64.exe";
- private const string FakerInputX64 =
- "https://github.com/Ryochan7/FakerInput/releases/download/v0.1.0/FakerInput_0.1.0_x64.msi";
- private const string FakerInputX86 =
- "https://github.com/Ryochan7/FakerInput/releases/download/v0.1.0/FakerInput_0.1.0_x86.msi";
+ private const string HidHideReleasePage =
+ "https://github.com/nefarius/HidHide/releases";
+ private const string FakerInputReleasePage =
+ "https://github.com/Ryochan7/FakerInput/releases";
public WelcomeDialog(bool loadConfig = false)
{
@@ -41,7 +37,7 @@ public WelcomeDialog(bool loadConfig = false)
DS4Windows.ViiperSetupManager.GetStatus(tryStartServer: true);
if (status.Ready)
{
- viiperInstallBtn.Content = "VIIPER is ready";
+ viiperInstallBtn.Content = "VIIPER Native UDE is ready";
}
}
@@ -51,7 +47,7 @@ private void ViiperInstallBtn_Click(object sender, RoutedEventArgs e)
DS4Windows.ViiperSetupManager.GetStatus(tryStartServer: true);
if (status.Ready)
{
- viiperInstallBtn.Content = "VIIPER is ready";
+ viiperInstallBtn.Content = "VIIPER Native UDE is ready";
return;
}
@@ -61,73 +57,38 @@ private void ViiperInstallBtn_Click(object sender, RoutedEventArgs e)
: "VIIPER setup needs attention";
}
- private async void HidHideInstall_Click(object sender, RoutedEventArgs e)
+ private void HidHideInstall_Click(object sender, RoutedEventArgs e)
{
- await DownloadAndRunInstallerAsync(HidHideInstaller,
- hidHideInstallBtn, "HidHide");
+ OpenExternalDriverReleasePage(HidHideReleasePage, "HidHide");
}
- private async void FakerInputInstallBtn_Click(object sender, RoutedEventArgs e)
+ private void FakerInputInstallBtn_Click(object sender, RoutedEventArgs e)
{
- string url = Environment.Is64BitOperatingSystem ?
- FakerInputX64 : FakerInputX86;
- await DownloadAndRunInstallerAsync(url, fakerInputInstallBtn,
- "FakerInput");
+ OpenExternalDriverReleasePage(FakerInputReleasePage, "FakerInput");
}
- private async Task DownloadAndRunInstallerAsync(string url,
- System.Windows.Controls.Button button, string componentName)
+ private void OpenExternalDriverReleasePage(string url,
+ string componentName)
{
- string target = Path.Combine(Path.GetTempPath(),
- Path.GetFileName(new Uri(url).AbsolutePath));
try
{
- SetInstallerControlsEnabled(false);
- button.Content = $"Downloading {componentName}…";
- byte[] payload = await App.requestClient.GetByteArrayAsync(url);
- await File.WriteAllBytesAsync(target, payload);
-
- button.Content = $"Installing {componentName}…";
- using Process process = Process.Start(new ProcessStartInfo
- {
- FileName = target,
- UseShellExecute = true,
- Verb = "runas",
- });
- if (process != null)
- {
- await process.WaitForExitAsync();
- }
-
- DS4Windows.Global.RefreshHidHideInfo();
- button.Content = $"{componentName} setup complete";
+ DS4Windows.Util.StartProcessHelper(url);
+ MessageBox.Show(this,
+ $"The portable DS4Windows runtime does not download or " +
+ $"elevate mutable {componentName} installers. Verify the " +
+ "publisher and signature on the official release page, " +
+ "or use the signed DS4Windows installer when available.",
+ $"{componentName} setup", MessageBoxButton.OK,
+ MessageBoxImage.Information);
}
catch (Exception ex)
{
- button.Content = $"{componentName} setup failed";
MessageBox.Show(this,
- $"Could not install {componentName}: {ex.Message}",
+ $"Could not open the {componentName} release page: " +
+ ex.Message,
$"{componentName} setup", MessageBoxButton.OK,
MessageBoxImage.Error);
}
- finally
- {
- try
- {
- if (File.Exists(target)) File.Delete(target);
- }
- catch { }
-
- SetInstallerControlsEnabled(true);
- }
- }
-
- private void SetInstallerControlsEnabled(bool enabled)
- {
- viiperInstallBtn.IsEnabled = enabled;
- step4HidHidePanel.IsEnabled = enabled && IsHidHideCompatible();
- step5FakerInputPanel.IsEnabled = enabled &&
- DS4Windows.Global.IsWin8OrGreater();
}
private static bool IsHidHideCompatible() =>
diff --git a/DS4Windows/DS4Library/DS4Devices.cs b/DS4Windows/DS4Library/DS4Devices.cs
index 9dc09ba..af0b504 100644
--- a/DS4Windows/DS4Library/DS4Devices.cs
+++ b/DS4Windows/DS4Library/DS4Devices.cs
@@ -187,9 +187,11 @@ public class DS4Devices
};
- // Registry of Sony HID paths created by DS4Windows through VIIPER. These
- // complete USB/IP devices look physical to Windows and must never be
- // re-ingested as input, regardless of the selected virtual Sony persona.
+ // Cache of Sony HID paths which have already been correlated to an
+ // exact VIIPER virtual-device lifetime. The authoritative ownership
+ // record is transport-neutral and lives in ViiperPnPOwnershipRegistry;
+ // this path cache records enumeration progress for visibility cleanup;
+ // it is never sufficient to prove current ownership by itself.
private static readonly object ownVirtualLock = new object();
private static readonly HashSet ownVirtualSonyPaths =
new HashSet(StringComparer.OrdinalIgnoreCase);
@@ -217,10 +219,6 @@ private static HashSet SnapshotVirtualSonyPaths()
foreach (HidDevice d in HidDevices.Enumerate(SONY_VID,
ViiperSonyPids))
{
- // VIIPER exposes a complete USB/IP composite device, so
- // Windows does not classify it as a software-enumerated
- // virtual HID. Snapshot every supported Sony path and identify
- // our output by the before/after difference instead.
set.Add(d.DevicePath);
}
}
@@ -228,15 +226,6 @@ private static HashSet SnapshotVirtualSonyPaths()
return set;
}
- // Capture every supported Sony controller immediately before VIIPER
- // creates one of our outputs. The before/after delta remains a fallback
- // for transient PnP states where the USB/IP port cannot yet be resolved.
- public static HashSet SnapshotBeforeOwnVirtualSony()
- {
- HashSet before = SnapshotVirtualSonyPaths();
- return before;
- }
-
public static void BeginOwnVirtualSonyConnect()
{
lock (ownVirtualLock)
@@ -274,13 +263,12 @@ private static bool IsOwnVirtualSonyConnectPending()
}
}
- // VIIPER presents a complete USB composite device through USBIP. Windows can
- // take several seconds to bind usbccgp, HID, and UAC after the API call has
- // returned, so registration must outlive Connect() without blocking startup.
- // The pending guard rejects the arriving virtual HID immediately; the path
- // registry then keeps rejecting it for the rest of that output's lifetime.
+ // Windows can take several seconds to bind usbccgp, HID, and UAC after
+ // VIIPER returns from device creation. Resolve only paths whose exact
+ // controller-instance and UdeCx port match this source's authoritative
+ // create result. A VID/PID or before/after delta is never ownership.
public static void RegisterOwnVirtualSonyAsync(
- HashSet beforePaths,
+ ViiperOutDevice source,
Action> registeredCallback = null)
{
var worker = new System.Threading.Thread(() =>
@@ -288,40 +276,40 @@ public static void RegisterOwnVirtualSonyAsync(
try
{
HashSet after = null;
- bool foundNew = false;
- for (int attempt = 0; attempt < 300 && !foundNew; attempt++)
+ List registeredPaths = null;
+ for (int attempt = 0; attempt < 300; attempt++)
{
+ int ownerToken =
+ ViiperPnPOwnershipRegistry.GetToken(source);
after = SnapshotVirtualSonyPaths();
- foreach (string path in after)
+ registeredPaths = ownerToken > 0 ?
+ after.Where(path =>
+ Global.TryResolveViiperPnPTopology(path,
+ out ViiperPnPTopologyIdentity topology) &&
+ ViiperPnPOwnershipRegistry.Matches(ownerToken,
+ topology)).ToList() : new List();
+ if (registeredPaths.Count > 0)
{
- if (beforePaths == null || !beforePaths.Contains(path))
- {
- foundNew = true;
- break;
- }
+ break;
}
- if (!foundNew)
- {
- System.Threading.Thread.Sleep(50);
- }
+ System.Threading.Thread.Sleep(50);
}
- if (after == null)
+ if (after == null || registeredPaths == null ||
+ registeredPaths.Count == 0)
{
+ AppLogger.LogToGui(
+ "VIIPER's exact virtual Sony HID identity did not enumerate before the registration deadline.",
+ true);
return;
}
- List registeredPaths = new List();
lock (ownVirtualLock)
{
- foreach (string path in after)
+ foreach (string path in registeredPaths)
{
- if (beforePaths == null || !beforePaths.Contains(path))
- {
- ownVirtualSonyPaths.Add(path);
- registeredPaths.Add(path);
- }
+ ownVirtualSonyPaths.Add(path);
}
ownVirtualSonyPaths.RemoveWhere(path =>
@@ -364,9 +352,9 @@ public static void RegisterOwnVirtualSonyAsync(
}
}
- // True if devicePath belongs to a currently owned VIIPER Sony output.
- // Match the active USB/IP port first so delayed HID enumeration and
- // DualSense/Edge personas cannot bypass the path-delta fallback.
+ // True only when devicePath belongs to an exact, currently registered
+ // VIIPER virtual-device lifetime. Missing identities and unregistered
+ // devices never broaden into "any Sony controller".
public static bool IsOwnVirtualDevice(string devicePath)
{
if (string.IsNullOrEmpty(devicePath))
@@ -374,17 +362,13 @@ public static bool IsOwnVirtualDevice(string devicePath)
return false;
}
- lock (ownVirtualLock)
- {
- if (ownVirtualSonyPaths.Count > 0 &&
- ownVirtualSonyPaths.Contains(devicePath))
- {
- return true;
- }
- }
-
- return Global.TryGetUsbIpWin2Port(devicePath, out int port) &&
- ViiperUsbipPortManager.IsActivePort(port);
+ // Always revalidate the current generation-fenced correlation.
+ // A cached path can outlive device removal or be recycled by PnP,
+ // so treating the cache as authority would resurrect stale HID
+ // ownership after the source has been detached.
+ return Global.TryResolveViiperPnPTopology(devicePath,
+ out ViiperPnPTopologyIdentity topology) &&
+ ViiperPnPOwnershipRegistry.MatchesAny(topology);
}
private static bool HasMoonlightVirtualDS4Identity(HidDevice hDevice, string serial)
@@ -408,7 +392,10 @@ private static bool IsRealDS4(HidDevice hDevice)
if (hDevice.Attributes.VendorId == SONY_VID &&
IsViiperSonyProductId(hDevice.Attributes.ProductId) &&
- IsOwnVirtualSonyConnectPending())
+ IsOwnVirtualSonyConnectPending() &&
+ Global.TryResolveViiperPnPTopology(hDevice.DevicePath,
+ out ViiperPnPTopologyIdentity pendingTopology) &&
+ pendingTopology.Transport != ViiperPnPTransport.Unknown)
{
return false;
}
diff --git a/DS4Windows/DS4WinWPF.csproj b/DS4Windows/DS4WinWPF.csproj
index ae952a7..1a6dfe1 100644
--- a/DS4Windows/DS4WinWPF.csproj
+++ b/DS4Windows/DS4WinWPF.csproj
@@ -73,9 +73,20 @@
PreserveNewest
-
- extras\install-viiper-backend.ps1
+
+ extras\manage-viiper-native-package.ps1
PreserveNewest
+ PreserveNewest
+
+
+ ViiperNativeRuntimeMetadata.json
+ PreserveNewest
+ PreserveNewest
+
+
+ extras\viiper-native-package\%(RecursiveDir)%(Filename)%(Extension)
+ PreserveNewest
+ PreserveNewest
PreserveNewest
diff --git a/DS4Windows/Properties/AssemblyInfo.cs b/DS4Windows/Properties/AssemblyInfo.cs
index 37d95e5..0ac18d2 100644
--- a/DS4Windows/Properties/AssemblyInfo.cs
+++ b/DS4Windows/Properties/AssemblyInfo.cs
@@ -3,6 +3,7 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("DS4WindowsTests")]
+[assembly: InternalsVisibleTo("DS4Windows.ViiperLiveValidation")]
[assembly: ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
diff --git a/DS4WindowsTests/AppSettingsTests.cs b/DS4WindowsTests/AppSettingsTests.cs
index 648777e..745378d 100644
--- a/DS4WindowsTests/AppSettingsTests.cs
+++ b/DS4WindowsTests/AppSettingsTests.cs
@@ -49,6 +49,7 @@ public AppSettingsTests()
550
0
0
+ 0
Default
Default
Default
@@ -60,10 +61,14 @@ public AppSettingsTests()
12/05/2023 00:24:15
24
2
+ False
False
True
False
+ false
+ false
+ false
False
False
diff --git a/DS4WindowsTests/ControllerAudioEndpointAmbiguityTests.cs b/DS4WindowsTests/ControllerAudioEndpointAmbiguityTests.cs
new file mode 100644
index 0000000..3406c1c
--- /dev/null
+++ b/DS4WindowsTests/ControllerAudioEndpointAmbiguityTests.cs
@@ -0,0 +1,79 @@
+using DS4Windows;
+
+namespace DS4WindowsTests
+{
+ [TestClass]
+ public class ControllerAudioEndpointAmbiguityTests
+ {
+ private sealed class Candidate
+ {
+ internal Candidate(string id, bool replaces = false)
+ {
+ Id = id;
+ Replaces = replaces;
+ }
+
+ internal string Id { get; }
+ internal bool Replaces { get; }
+ }
+
+ [TestMethod]
+ public void MissingOwnerIdentityRejectsTwoSonyEndpoints()
+ {
+ Candidate[] endpoints =
+ {
+ new("physical-sony"),
+ new("viiper-sony"),
+ };
+
+ Candidate selected = DualSenseAudioPassthrough.
+ SelectUnambiguousControllerEndpoint(endpoints,
+ _ => false, _ => false);
+
+ Assert.IsNull(selected);
+ }
+
+ [TestMethod]
+ public void ExactSavedEndpointSurvivesPhysicalVirtualCoexistence()
+ {
+ Candidate physical = new("physical-sony");
+ Candidate viiper = new("viiper-sony");
+ Candidate[] endpoints = { physical, viiper };
+
+ Candidate selected = DualSenseAudioPassthrough.
+ SelectUnambiguousControllerEndpoint(endpoints,
+ candidate => candidate.Id == "viiper-sony",
+ _ => false);
+
+ Assert.AreSame(viiper, selected);
+ }
+
+ [TestMethod]
+ public void AmbiguousEndpointRecreationHistoryFailsClosed()
+ {
+ Candidate[] endpoints =
+ {
+ new("first-replacement", replaces: true),
+ new("second-replacement", replaces: true),
+ };
+
+ Candidate selected = DualSenseAudioPassthrough.
+ SelectUnambiguousControllerEndpoint(endpoints,
+ _ => false, candidate => candidate.Replaces);
+
+ Assert.IsNull(selected);
+ }
+
+ [TestMethod]
+ public void UniqueSingleControllerFallbackRemainsAvailable()
+ {
+ Candidate only = new("only-sony");
+
+ Candidate selected = DualSenseAudioPassthrough.
+ SelectUnambiguousControllerEndpoint(new[] { only },
+ _ => false, _ => false);
+
+ Assert.AreSame(only, selected);
+ }
+ }
+}
diff --git a/DS4WindowsTests/ControllerAudioEndpointTests.cs b/DS4WindowsTests/ControllerAudioEndpointTests.cs
index 8928f5c..e6731a6 100644
--- a/DS4WindowsTests/ControllerAudioEndpointTests.cs
+++ b/DS4WindowsTests/ControllerAudioEndpointTests.cs
@@ -93,37 +93,29 @@ public void DirectSpeakerRouteRetriesTransientEnumerationAndRecovery(
}
[DataTestMethod]
- [DataRow(true, true, true, true, true, 1, 1,
+ [DataRow(true, true, true, true,
(int)DirectSpeakerEndpointOwnership.Owned)]
- [DataRow(true, true, true, true, true, 2, 1,
+ [DataRow(true, true, true, false,
(int)DirectSpeakerEndpointOwnership.Unowned)]
- [DataRow(true, true, true, true, false, -1, 1,
- (int)DirectSpeakerEndpointOwnership.Unowned)]
- [DataRow(true, true, false, false, false, -1, 1,
- (int)DirectSpeakerEndpointOwnership.Unresolved)]
- [DataRow(true, true, true, false, false, -1, 1,
- (int)DirectSpeakerEndpointOwnership.Unresolved)]
- [DataRow(true, true, true, true, true, -1, 1,
+ [DataRow(true, true, false, false,
(int)DirectSpeakerEndpointOwnership.Unresolved)]
- [DataRow(false, true, true, true, true, 1, 1,
+ [DataRow(false, true, true, true,
(int)DirectSpeakerEndpointOwnership.Unresolved)]
- [DataRow(true, false, true, true, true, 1, 1,
+ [DataRow(true, false, true, true,
(int)DirectSpeakerEndpointOwnership.Unowned)]
- public void DirectSpeakerOwnershipRequiresExactActiveUsbipPort(
+ public void DirectSpeakerOwnershipRequiresExactActivePnpIdentity(
bool endpointActive, bool identityMatches,
- bool interfacePathAvailable, bool usbIpQueryResolved,
- bool usbIpAncestor, int endpointPort, int sourcePort, int expected)
+ bool pnpIdentityResolved, bool exactOwnerMatch, int expected)
{
Assert.AreEqual((DirectSpeakerEndpointOwnership)expected,
DualSenseAudioPassthrough.
ClassifyDirectSpeakerEndpointOwnership(endpointActive,
- identityMatches, interfacePathAvailable,
- usbIpQueryResolved, usbIpAncestor, endpointPort,
- sourcePort));
+ identityMatches, pnpIdentityResolved,
+ exactOwnerMatch));
}
[TestMethod]
- public void ViiperUsbipOwnershipTracksOnlyRegisteredActivePort()
+ public void ExplicitLegacyUsbipModeTracksOnlyRegisteredActivePort()
{
const int ownedPort = 7331;
const int unrelatedPort = 7332;
@@ -838,7 +830,8 @@ public async Task OpenExistingDeviceStreamTargetsOriginalBusAndDevice()
try
{
Task accept = listener.AcceptTcpClientAsync();
- var client = new ViiperClient("127.0.0.1", port);
+ var client = new ViiperClient("127.0.0.1", port,
+ ViiperTransportMode.Usbip);
opened = client.OpenExistingDeviceStream(42, "9", 6,
lifetime);
using TcpClient accepted = await accept.WaitAsync(
diff --git a/DS4WindowsTests/DS4WindowsTests.csproj b/DS4WindowsTests/DS4WindowsTests.csproj
index 46929b9..9464e32 100644
--- a/DS4WindowsTests/DS4WindowsTests.csproj
+++ b/DS4WindowsTests/DS4WindowsTests.csproj
@@ -19,6 +19,7 @@
+
diff --git a/DS4WindowsTests/ProfileMigrationTests.cs b/DS4WindowsTests/ProfileMigrationTests.cs
index 1cc02c6..03796ec 100644
--- a/DS4WindowsTests/ProfileMigrationTests.cs
+++ b/DS4WindowsTests/ProfileMigrationTests.cs
@@ -89,6 +89,13 @@ public ProfileMigrationTests()
0
True
0,0,255
+ 0
+ 0
+ 0
+ 0
+ 0
+ false
+ false
100
0
DS4Win
@@ -187,6 +194,10 @@ public ProfileMigrationTests()
True
100
+ False
+ False
+
+
False
False
Mouse
@@ -293,6 +304,21 @@ public ProfileMigrationTests()
False
0
+
+ false
+ false
+ 128
+ 0
+ 0
+ 128
+ 128
+ 1
+
+
+ false
+
+
+
linear
Disabled
@@ -300,7 +326,11 @@ public ProfileMigrationTests()
100
100
None
+ 0
+ 0
None
+ 0
+ 0
linear
linear
linear
@@ -341,7 +371,7 @@ public ProfileMigrationTests()
0
True
- X360
+ ViiperX360
Disconnect Controller
diff --git a/DS4WindowsTests/ProfileTests.cs b/DS4WindowsTests/ProfileTests.cs
index c8d7e1c..0f02949 100644
--- a/DS4WindowsTests/ProfileTests.cs
+++ b/DS4WindowsTests/ProfileTests.cs
@@ -42,6 +42,13 @@ public ProfileTests()
0
True
0,0,255
+ 0
+ 0
+ 0
+ 0
+ 0
+ false
+ false
100
0
DS4Win
@@ -140,6 +147,10 @@ public ProfileTests()
False
100
+ False
+ False
+
+
False
False
Controls
@@ -248,6 +259,21 @@ public ProfileTests()
False
0
+
+ false
+ false
+ 128
+ 0
+ 0
+ 128
+ 128
+ 1
+
+
+ false
+
+
+
linear
@@ -256,7 +282,11 @@ public ProfileTests()
100
100
None
+ 0
+ 0
None
+ 0
+ 0
linear
linear
@@ -300,7 +330,7 @@ public ProfileTests()
0
True
- X360
+ ViiperX360
Disconnect Controller
diff --git a/DS4WindowsTests/ViiperAuthenticatedStreamTests.cs b/DS4WindowsTests/ViiperAuthenticatedStreamTests.cs
new file mode 100644
index 0000000..4714f00
--- /dev/null
+++ b/DS4WindowsTests/ViiperAuthenticatedStreamTests.cs
@@ -0,0 +1,313 @@
+using DS4Windows;
+using System.Buffers.Binary;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace DS4WindowsTests
+{
+ [TestClass]
+ public class ViiperAuthenticatedStreamTests
+ {
+ private const string GoVectorPassword = "Ds4WGoVector2026";
+ private const string GoPasswordKey =
+ "120eb5e837779ba78c47fa0fe9a5591a13812af6570252c2dab36a353daab210";
+ private const string GoAuthenticationTag =
+ "4fbe205fe7d3ae403e26577a41b43b505330a496f9a698b6196a57dd81fe57ef";
+ private const string GoSessionKey =
+ "a0693dc0f7979b2b6b7676e3615706b968ad04dbb6aad8f77b5a84d7921e95dc";
+ private const string GoClientRecordZero =
+ "00000020000000000000000000000000d4a5432c838aa6a038940289cafd32605ffbce46";
+ private const string GoServerRecordZero =
+ "0000002f00000001000000000000000066092a7b649a10f88a99fc8c1e87b4b9fcdbe9a76ea5b8bc9b1d3790b73b9127b401c0";
+
+ [TestMethod]
+ public void FrozenGoSourceVectorsMatchHandshakeAndBothRecordDomains()
+ {
+ // Generated by VIIPER internal/server/api/auth at source revision
+ // 7270672f92e3ce935452490e35554bd21b1cd141.
+ byte[] clientNonce = Enumerable.Range(0, 32)
+ .Select(value => (byte)value).ToArray();
+ byte[] serverNonce = Enumerable.Range(32, 32)
+ .Select(value => (byte)value).ToArray();
+ byte[] passwordKey = ViiperAuthProtocol.DerivePasswordKey(
+ GoVectorPassword);
+ CollectionAssert.AreEqual(Convert.FromHexString(GoPasswordKey),
+ passwordKey);
+ byte[] sessionKey = ViiperAuthProtocol.DeriveSessionKey(
+ passwordKey, serverNonce, clientNonce);
+ CollectionAssert.AreEqual(Convert.FromHexString(GoSessionKey),
+ sessionKey);
+
+ byte[] response = Combine(Encoding.ASCII.GetBytes("OK\0"),
+ serverNonce, Convert.FromHexString(GoServerRecordZero));
+ var transport = new ScriptedDuplexStream(response, readChunk: 1);
+ using Stream stream = ViiperAuthProtocol.AuthenticateClient(
+ transport, GoVectorPassword, clientNonce);
+
+ byte[] expectedHandshake = Combine(
+ Encoding.UTF8.GetBytes(ViiperAuthProtocol.HandshakeMagic),
+ clientNonce, Convert.FromHexString(GoAuthenticationTag));
+ CollectionAssert.AreEqual(expectedHandshake,
+ transport.Written.Take(expectedHandshake.Length).ToArray());
+
+ byte[] ping = Encoding.ASCII.GetBytes("ping");
+ stream.Write(ping, 0, ping.Length);
+ CollectionAssert.AreEqual(Convert.FromHexString(
+ GoClientRecordZero),
+ transport.Written.Skip(expectedHandshake.Length).ToArray());
+
+ byte[] received = new byte[64];
+ int length = stream.Read(received, 0, received.Length);
+ Assert.AreEqual("{\"server\":\"VIIPER\"}",
+ Encoding.ASCII.GetString(received, 0, length));
+ }
+
+ [TestMethod]
+ public void EmptyRecordIsNotMisreportedAsEndOfStream()
+ {
+ byte[] key = Convert.FromHexString(GoSessionKey);
+ byte[] wire = Combine(BuildRecord(key, 1, 0, Array.Empty()),
+ BuildRecord(key, 1, 1, Encoding.ASCII.GetBytes("next")));
+ using var encrypted = new ViiperEncryptedStream(
+ new ScriptedDuplexStream(wire, readChunk: 1), key);
+ byte[] result = new byte[4];
+ Assert.AreEqual(4, encrypted.Read(result, 0, result.Length));
+ Assert.AreEqual("next", Encoding.ASCII.GetString(result));
+ }
+
+ [DataTestMethod]
+ [DataRow(2u, 0ul, "unknown direction")]
+ [DataRow(1u, 1ul, "out of order")]
+ [DataRow(0u, 0ul, "client direction")]
+ public void InvalidReceiveNonceFailsClosedAndLatches(uint prefix,
+ ulong counter, string scenario)
+ {
+ byte[] key = Convert.FromHexString(GoSessionKey);
+ byte[] wire = BuildRecord(key, prefix, counter,
+ Encoding.ASCII.GetBytes(scenario));
+ using var encrypted = new ViiperEncryptedStream(
+ new ScriptedDuplexStream(wire), key);
+ byte[] output = new byte[128];
+ Assert.ThrowsException(() =>
+ encrypted.Read(output, 0, output.Length));
+ Assert.ThrowsException(() =>
+ encrypted.Read(output, 0, output.Length));
+ }
+
+ [TestMethod]
+ public void ReplayAfterValidRecordFailsClosed()
+ {
+ byte[] key = Convert.FromHexString(GoSessionKey);
+ byte[] wire = Combine(
+ BuildRecord(key, 1, 0, new byte[] { 1 }),
+ BuildRecord(key, 1, 0, new byte[] { 2 }));
+ using var encrypted = new ViiperEncryptedStream(
+ new ScriptedDuplexStream(wire), key);
+ byte[] output = new byte[1];
+ Assert.AreEqual(1, encrypted.Read(output, 0, 1));
+ Assert.AreEqual((byte)1, output[0]);
+ Assert.ThrowsException(() =>
+ encrypted.Read(output, 0, 1));
+ }
+
+ [TestMethod]
+ public void TruncationCannotBecomeANewRecordOnRetry()
+ {
+ byte[] key = Convert.FromHexString(GoSessionKey);
+ byte[] complete = BuildRecord(key, 1, 0,
+ Encoding.ASCII.GetBytes("truncated"));
+ byte[] truncated = complete.Take(complete.Length - 3).ToArray();
+ using var encrypted = new ViiperEncryptedStream(
+ new ScriptedDuplexStream(truncated, readChunk: 2), key);
+ byte[] output = new byte[32];
+ Assert.ThrowsException(() =>
+ encrypted.Read(output, 0, output.Length));
+ Assert.ThrowsException(() =>
+ encrypted.Read(output, 0, output.Length));
+ }
+
+ [DataTestMethod]
+ [DataRow(27u)]
+ [DataRow(2097153u)]
+ public void InvalidRecordLengthFailsClosed(uint encodedLength)
+ {
+ byte[] header = new byte[4];
+ BinaryPrimitives.WriteUInt32BigEndian(header, encodedLength);
+ using var encrypted = new ViiperEncryptedStream(
+ new ScriptedDuplexStream(header),
+ Convert.FromHexString(GoSessionKey));
+ Assert.ThrowsException(() =>
+ encrypted.Read(new byte[1], 0, 1));
+ }
+
+ [TestMethod]
+ public void ConcurrentWritesAreSerializedWithUniqueMonotonicNonces()
+ {
+ byte[] key = Convert.FromHexString(GoSessionKey);
+ var transport = new ScriptedDuplexStream(Array.Empty());
+ using var encrypted = new ViiperEncryptedStream(transport, key);
+ Parallel.For(0, 64, value =>
+ {
+ byte[] payload = BitConverter.GetBytes(value);
+ encrypted.Write(payload, 0, payload.Length);
+ });
+
+ byte[] wire = transport.Written;
+ int offset = 0;
+ var values = new HashSet();
+ for (ulong counter = 0; counter < 64; counter++)
+ {
+ uint length = BinaryPrimitives.ReadUInt32BigEndian(
+ wire.AsSpan(offset, 4));
+ Assert.AreEqual(32u, length);
+ ReadOnlySpan record = wire.AsSpan(offset + 4,
+ checked((int)length));
+ Assert.AreEqual(0u,
+ BinaryPrimitives.ReadUInt32BigEndian(record));
+ Assert.AreEqual(counter,
+ BinaryPrimitives.ReadUInt64BigEndian(record.Slice(4)));
+ byte[] plaintext = DecryptRecord(key, record);
+ Assert.IsTrue(values.Add(BitConverter.ToInt32(plaintext)));
+ offset += 4 + checked((int)length);
+ }
+ Assert.AreEqual(wire.Length, offset);
+ Assert.AreEqual(64, values.Count);
+ }
+
+ [TestMethod]
+ public void MaximumInclusiveRecordAndSenderExhaustionAreEnforced()
+ {
+ byte[] key = Convert.FromHexString(GoSessionKey);
+ var transport = new ScriptedDuplexStream(Array.Empty());
+ using var encrypted = new ViiperEncryptedStream(transport, key,
+ ulong.MaxValue, 0);
+ byte[] maximum = new byte[
+ ViiperEncryptedStream.MaximumPlaintextSize];
+ encrypted.Write(maximum, 0, maximum.Length);
+ Assert.AreEqual(4 + ViiperEncryptedStream.MaximumRecordSize,
+ transport.Written.Length);
+ Assert.ThrowsException(() =>
+ encrypted.Write(new byte[1], 0, 1));
+
+ using var ordinary = new ViiperEncryptedStream(
+ new ScriptedDuplexStream(Array.Empty()), key);
+ Assert.ThrowsException(() => ordinary.Write(
+ new byte[ViiperEncryptedStream.MaximumPlaintextSize + 1],
+ 0, ViiperEncryptedStream.MaximumPlaintextSize + 1));
+ }
+
+ [TestMethod]
+ public void AuthenticationRejectionUsesDedicatedFailureType()
+ {
+ var transport = new ScriptedDuplexStream(
+ Encoding.UTF8.GetBytes("NO\0invalid password\n"));
+ Assert.ThrowsException(() =>
+ ViiperAuthProtocol.AuthenticateClient(transport,
+ GoVectorPassword, new byte[32]));
+ }
+
+ private static byte[] BuildRecord(byte[] key, uint prefix,
+ ulong counter, byte[] plaintext)
+ {
+ byte[] nonce = new byte[12];
+ BinaryPrimitives.WriteUInt32BigEndian(nonce, prefix);
+ BinaryPrimitives.WriteUInt64BigEndian(nonce.AsSpan(4), counter);
+ byte[] ciphertext = new byte[plaintext.Length];
+ byte[] tag = new byte[16];
+ using (var cipher = new ChaCha20Poly1305(key))
+ {
+ cipher.Encrypt(nonce, plaintext, ciphertext, tag);
+ }
+ byte[] wire = new byte[4 + nonce.Length + ciphertext.Length +
+ tag.Length];
+ BinaryPrimitives.WriteUInt32BigEndian(wire,
+ (uint)(wire.Length - 4));
+ Buffer.BlockCopy(nonce, 0, wire, 4, nonce.Length);
+ Buffer.BlockCopy(ciphertext, 0, wire, 16, ciphertext.Length);
+ Buffer.BlockCopy(tag, 0, wire, 16 + ciphertext.Length,
+ tag.Length);
+ return wire;
+ }
+
+ private static byte[] DecryptRecord(byte[] key,
+ ReadOnlySpan record)
+ {
+ ReadOnlySpan nonce = record.Slice(0, 12);
+ ReadOnlySpan ciphertext = record.Slice(12,
+ record.Length - 28);
+ ReadOnlySpan tag = record.Slice(record.Length - 16);
+ byte[] plaintext = new byte[ciphertext.Length];
+ using var cipher = new ChaCha20Poly1305(key);
+ cipher.Decrypt(nonce, ciphertext, tag, plaintext);
+ return plaintext;
+ }
+
+ private static byte[] Combine(params byte[][] pieces)
+ {
+ byte[] result = new byte[pieces.Sum(piece => piece.Length)];
+ int offset = 0;
+ foreach (byte[] piece in pieces)
+ {
+ Buffer.BlockCopy(piece, 0, result, offset, piece.Length);
+ offset += piece.Length;
+ }
+ return result;
+ }
+
+ private sealed class ScriptedDuplexStream : Stream
+ {
+ private readonly byte[] input;
+ private readonly int readChunk;
+ private readonly MemoryStream written = new MemoryStream();
+ private int inputOffset;
+ private bool disposed;
+
+ internal ScriptedDuplexStream(byte[] input,
+ int readChunk = int.MaxValue)
+ {
+ this.input = input ?? throw new ArgumentNullException(
+ nameof(input));
+ this.readChunk = readChunk;
+ }
+
+ internal byte[] Written => written.ToArray();
+ public override bool CanRead => !disposed;
+ public override bool CanSeek => false;
+ public override bool CanWrite => !disposed;
+ public override long Length => throw new NotSupportedException();
+ public override long Position
+ {
+ get => throw new NotSupportedException();
+ set => throw new NotSupportedException();
+ }
+ public override void Flush() { }
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ ObjectDisposedException.ThrowIf(disposed, this);
+ int available = input.Length - inputOffset;
+ if (available == 0)
+ {
+ return 0;
+ }
+ int length = Math.Min(Math.Min(count, readChunk), available);
+ Buffer.BlockCopy(input, inputOffset, buffer, offset, length);
+ inputOffset += length;
+ return length;
+ }
+ public override void Write(byte[] buffer, int offset, int count)
+ {
+ ObjectDisposedException.ThrowIf(disposed, this);
+ written.Write(buffer, offset, count);
+ }
+ public override long Seek(long offset, SeekOrigin origin) =>
+ throw new NotSupportedException();
+ public override void SetLength(long value) =>
+ throw new NotSupportedException();
+ protected override void Dispose(bool disposing)
+ {
+ disposed = true;
+ base.Dispose(disposing);
+ }
+ }
+ }
+}
diff --git a/DS4WindowsTests/ViiperHidUacOwnershipParityTests.cs b/DS4WindowsTests/ViiperHidUacOwnershipParityTests.cs
new file mode 100644
index 0000000..14586ee
--- /dev/null
+++ b/DS4WindowsTests/ViiperHidUacOwnershipParityTests.cs
@@ -0,0 +1,184 @@
+using DS4Windows;
+
+namespace DS4WindowsTests
+{
+ [TestClass]
+ public class ViiperHidUacOwnershipParityTests
+ {
+ private const string NativeRoot = @"ROOT\VIIPERUDE\0000";
+ private const ulong NativeSession = 0xB16B00B5;
+
+ // VIIPER's composite PlayStation descriptors use MI_00 for UAC
+ // control, MI_01 for render, MI_02 for capture, and MI_03 for HID.
+ // DS4Windows explicitly supports both DS4 product IDs, plus DS5 and
+ // Edge, so ownership must remain persona/PID-neutral.
+ [DataTestMethod]
+ [DataRow("05C4")]
+ [DataRow("09CC")]
+ [DataRow("0CE6")]
+ [DataRow("0DF2")]
+ public void CompositeHidRenderAndCaptureInterfacesShareExactOwner(
+ string productId)
+ {
+ const int port = 2;
+ string usbDevice =
+ $@"USB\VID_054C&PID_{productId}\6&VIIPER_A";
+ ViiperPnPTopologyIdentity hid = ResolveViiperInterface(
+ productId, "03", "HID", usbDevice, port);
+ ViiperPnPTopologyIdentity render = ResolveViiperInterface(
+ productId, "01", "RENDER", usbDevice, port);
+ ViiperPnPTopologyIdentity capture = ResolveViiperInterface(
+ productId, "02", "CAPTURE", usbDevice, port);
+
+ Assert.IsTrue(hid.IsSameUsbDevice(render));
+ Assert.IsTrue(hid.IsSameUsbDevice(capture));
+
+ var table = new ViiperPnPOwnershipTable();
+ int token = table.AllocateToken();
+ Assert.IsTrue(table.Publish(token, new ViiperPnPCorrelation(
+ ViiperPnPTransport.NativeUdeCx, 0x100000001, 17,
+ NativeSession,
+ NativeRoot, string.Empty, port)));
+
+ Assert.IsTrue(table.Matches(token, hid));
+ Assert.IsTrue(table.Matches(token, render));
+ Assert.IsTrue(table.Matches(token, capture));
+
+ ViiperPnPTopologyIdentity samePersonaOtherPort =
+ ResolveViiperInterface(productId, "01", "OTHER",
+ $@"USB\VID_054C&PID_{productId}\6&VIIPER_B", 3);
+ Assert.IsFalse(table.Matches(token, samePersonaOtherPort));
+ }
+
+ [DataTestMethod]
+ [DataRow("09CC")]
+ [DataRow("0CE6")]
+ [DataRow("0DF2")]
+ public void PhysicalSonyHidAndUacShareTheirUsbParentButAreNeverOwned(
+ string productId)
+ {
+ string usbDevice =
+ $@"USB\VID_054C&PID_{productId}\5&PHYSICAL";
+ ViiperPnPTopologyIdentity hid = ResolvePhysicalInterface(
+ productId, "03", "HID", usbDevice, 4);
+ ViiperPnPTopologyIdentity render = ResolvePhysicalInterface(
+ productId, "01", "RENDER", usbDevice, 4);
+ ViiperPnPTopologyIdentity capture = ResolvePhysicalInterface(
+ productId, "02", "CAPTURE", usbDevice, 4);
+
+ Assert.AreEqual(ViiperPnPTransport.Unknown, hid.Transport);
+ Assert.IsTrue(hid.IsSameUsbDevice(render));
+ Assert.IsTrue(hid.IsSameUsbDevice(capture));
+
+ var table = new ViiperPnPOwnershipTable();
+ int token = table.AllocateToken();
+ Assert.IsTrue(table.Publish(token, new ViiperPnPCorrelation(
+ ViiperPnPTransport.NativeUdeCx, 0x100000002, 18,
+ NativeSession,
+ NativeRoot, string.Empty, 4)));
+ Assert.IsFalse(table.Matches(token, hid));
+ Assert.IsFalse(table.Matches(token, render));
+ Assert.IsFalse(table.Matches(token, capture));
+ }
+
+ [TestMethod]
+ public void TwoIdenticalNativePersonasKeepHidAndAudioOnTheirOwnPorts()
+ {
+ const string productId = "0CE6";
+ ViiperPnPTopologyIdentity firstHid = ResolveViiperInterface(
+ productId, "03", "FIRST_HID",
+ @"USB\VID_054C&PID_0CE6\6&FIRST", 1);
+ ViiperPnPTopologyIdentity firstRender = ResolveViiperInterface(
+ productId, "01", "FIRST_RENDER",
+ @"USB\VID_054C&PID_0CE6\6&FIRST", 1);
+ ViiperPnPTopologyIdentity secondHid = ResolveViiperInterface(
+ productId, "03", "SECOND_HID",
+ @"USB\VID_054C&PID_0CE6\6&SECOND", 2);
+ ViiperPnPTopologyIdentity secondCapture = ResolveViiperInterface(
+ productId, "02", "SECOND_CAPTURE",
+ @"USB\VID_054C&PID_0CE6\6&SECOND", 2);
+
+ var table = new ViiperPnPOwnershipTable();
+ int firstToken = table.AllocateToken();
+ int secondToken = table.AllocateToken();
+ Assert.IsTrue(table.Publish(firstToken, new ViiperPnPCorrelation(
+ ViiperPnPTransport.NativeUdeCx, 0x200000001, 21,
+ NativeSession,
+ NativeRoot, string.Empty, 1)));
+ Assert.IsTrue(table.Publish(secondToken, new ViiperPnPCorrelation(
+ ViiperPnPTransport.NativeUdeCx, 0x200000002, 22,
+ NativeSession,
+ NativeRoot, string.Empty, 2)));
+
+ Assert.IsTrue(table.Matches(firstToken, firstHid));
+ Assert.IsTrue(table.Matches(firstToken, firstRender));
+ Assert.IsFalse(table.Matches(firstToken, secondHid));
+ Assert.IsFalse(table.Matches(firstToken, secondCapture));
+ Assert.IsTrue(table.Matches(secondToken, secondHid));
+ Assert.IsTrue(table.Matches(secondToken, secondCapture));
+ Assert.IsFalse(table.Matches(secondToken, firstHid));
+ Assert.IsFalse(table.Matches(secondToken, firstRender));
+ }
+
+ private static ViiperPnPTopologyIdentity ResolveViiperInterface(
+ string productId, string interfaceNumber, string childSuffix,
+ string usbDevice, int port)
+ {
+ ViiperPnPAncestryNode[] ancestry = BuildInterfaceAncestry(
+ productId, interfaceNumber, childSuffix, usbDevice, port,
+ @"USB\ROOT_HUB30\5&VIIPER_HUB", NativeRoot,
+ new[] { @"ROOT\VIIPERUDE" });
+ Assert.IsTrue(Global.TryClassifyViiperPnPAncestry(ancestry,
+ out ViiperPnPTopologyIdentity topology));
+ return topology;
+ }
+
+ private static ViiperPnPTopologyIdentity ResolvePhysicalInterface(
+ string productId, string interfaceNumber, string childSuffix,
+ string usbDevice, int port)
+ {
+ const string physicalRoot =
+ @"PCI\VEN_1022&DEV_43F7\4&PHYSICAL_CONTROLLER";
+ ViiperPnPAncestryNode[] ancestry = BuildInterfaceAncestry(
+ productId, interfaceNumber, childSuffix, usbDevice, port,
+ @"USB\ROOT_HUB30\5&PHYSICAL_HUB", physicalRoot,
+ new[] { @"PCI\VEN_1022&DEV_43F7" });
+ Assert.IsFalse(Global.TryClassifyViiperPnPAncestry(ancestry,
+ out ViiperPnPTopologyIdentity topology));
+ Assert.IsTrue(topology.IsUsbDeviceResolved);
+ return topology;
+ }
+
+ private static ViiperPnPAncestryNode[] BuildInterfaceAncestry(
+ string productId, string interfaceNumber, string childSuffix,
+ string usbDevice, int port, string hub, string root,
+ string[] rootHardwareIds)
+ {
+ string usbInterface =
+ $@"USB\VID_054C&PID_{productId}&MI_{interfaceNumber}\7&{childSuffix}";
+ bool hid = string.Equals(interfaceNumber, "03",
+ StringComparison.Ordinal);
+ string child = hid ?
+ $@"HID\VID_054C&PID_{productId}&MI_03\8&{childSuffix}" :
+ $@"SWD\MMDEVAPI\{{0.0.0.00000000}}.{childSuffix}";
+ string[] interfaceHardwareIds = hid ?
+ new[] { @"USB\Class_03&SubClass_00" } :
+ new[] { @"USB\Class_01&SubClass_02" };
+
+ return new[]
+ {
+ new ViiperPnPAncestryNode(child, usbInterface,
+ Array.Empty(), string.Empty),
+ new ViiperPnPAncestryNode(usbInterface, usbDevice,
+ interfaceHardwareIds, string.Empty),
+ new ViiperPnPAncestryNode(usbDevice, hub,
+ new[] { $@"USB\VID_054C&PID_{productId}" },
+ $"Port_#{port:0000}.Hub_#0001"),
+ new ViiperPnPAncestryNode(hub, root,
+ new[] { @"USB\ROOT_HUB30" }, string.Empty),
+ new ViiperPnPAncestryNode(root, @"HTREE\ROOT\0",
+ rootHardwareIds, string.Empty),
+ };
+ }
+ }
+}
diff --git a/DS4WindowsTests/ViiperLiveValidationRunnerTests.cs b/DS4WindowsTests/ViiperLiveValidationRunnerTests.cs
new file mode 100644
index 0000000..99a961d
--- /dev/null
+++ b/DS4WindowsTests/ViiperLiveValidationRunnerTests.cs
@@ -0,0 +1,649 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+
+using DS4Windows;
+using DS4Windows.ViiperLiveValidation;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace DS4WindowsTests
+{
+ [TestClass]
+ [DoNotParallelize]
+ public class ViiperLiveValidationRunnerTests
+ {
+ [TestMethod]
+ public void ValidationLeaseRequiresMatchingCanonicalDualConsent()
+ {
+ string nonce = new string('a',
+ ViiperLiveValidationLease.NonceLength);
+ string variable = ViiperLiveValidationLease
+ .NonceEnvironmentVariable;
+ string original = Environment.GetEnvironmentVariable(variable);
+ try
+ {
+ Environment.SetEnvironmentVariable(variable, nonce);
+ ViiperLiveValidationLease lease =
+ ViiperLiveValidationLease.Create(nonce);
+ CollectionAssert.AreEqual(SHA256.HashData(
+ Encoding.ASCII.GetBytes(nonce)), lease.NonceFingerprint);
+
+ Environment.SetEnvironmentVariable(variable,
+ new string('b', nonce.Length));
+ Assert.ThrowsException(() =>
+ ViiperLiveValidationLease.Create(nonce));
+ Environment.SetEnvironmentVariable(variable,
+ nonce.ToUpperInvariant());
+ Assert.ThrowsException(() =>
+ ViiperLiveValidationLease.Create(
+ nonce.ToUpperInvariant()));
+ Environment.SetEnvironmentVariable(variable, nonce[..^1]);
+ Assert.ThrowsException(() =>
+ ViiperLiveValidationLease.Create(nonce[..^1]));
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(variable, original);
+ }
+ }
+
+ [TestMethod]
+ public void OrdinaryOutputDeviceCannotAcquireValidationHooks()
+ {
+ var device = new ViiperOutDevice(OutContType.ViiperDS4,
+ ViiperVirtualDeviceType.DualShock4);
+ Assert.ThrowsException(() =>
+ device.GetLiveValidationSnapshot(null));
+ Assert.ThrowsException(() =>
+ device.SubmitLiveValidationMicrophonePcm(null,
+ new byte[320]));
+ Assert.ThrowsException(() =>
+ device.InterruptLiveValidationTransport(null));
+ }
+
+ [TestMethod]
+ public void RunnerUsesExactProductionPlayStationHandlers()
+ {
+ CollectionAssert.AreEqual(new[]
+ {
+ "dualshock4audioduplexv3",
+ "dualsensecombinedaudioduplexv5",
+ "dualsenseedgecombinedaudioduplexv5",
+ }, ControllerSpec.All.Select(spec => spec.Handler).ToArray());
+ CollectionAssert.AreEqual(new[]
+ {
+ "0x05c4", "0x0ce6", "0x0df2",
+ }, ControllerSpec.All.Select(spec => spec.Pid).ToArray());
+ CollectionAssert.AreEqual(new[]
+ {
+ "framed-v3", "framed-v5", "framed-v5",
+ }, ControllerSpec.All.Select(spec => spec.StreamProtocol)
+ .ToArray());
+ }
+
+ [TestMethod]
+ public void RunnerInvokesOutputDeviceRatherThanDirectClient()
+ {
+ string root = FindRepositoryRoot();
+ string runner = File.ReadAllText(Path.Combine(root, "tools",
+ "DS4Windows.ViiperLiveValidation",
+ "LiveValidationRunner.cs"));
+ string outputDevice = File.ReadAllText(Path.Combine(root,
+ "DS4Windows", "DS4Control", "Viiper",
+ "ViiperOutDevice.cs"));
+ StringAssert.Contains(runner, "new ViiperOutDevice(");
+ Assert.IsFalse(runner.Contains("new ViiperClient(",
+ StringComparison.Ordinal));
+ StringAssert.Contains(outputDevice,
+ "\"dualshock4audioduplexv3\", 0x05C4");
+ StringAssert.Contains(outputDevice,
+ "\"dualsensecombinedaudioduplexv5\"");
+ StringAssert.Contains(outputDevice,
+ "\"dualsenseedgecombinedaudioduplexv5\"");
+ }
+
+ [TestMethod]
+ public void FeedbackWitnessMatchesIndependentProbeMarkers()
+ {
+ byte[] ds4 = LiveValidationRunner.ExpectedFeedback(
+ ControllerSpec.All[0]);
+ CollectionAssert.AreEqual(new byte[]
+ {
+ 0x23, 0xA7, 0x11, 0x52, 0xC3, 0x04, 0x09,
+ }, ds4);
+
+ byte[] dualSense = LiveValidationRunner.ExpectedFeedback(
+ ControllerSpec.All[1]);
+ Assert.AreEqual(ViiperOutDevice.DualSenseAtomicFeedbackLength,
+ dualSense.Length);
+ Assert.AreEqual(0x22, dualSense[0]);
+ Assert.AreEqual(0x88, dualSense[1]);
+ Assert.AreEqual(0x21, dualSense[6]);
+ Assert.AreEqual(0x55, dualSense[26]);
+ Assert.AreEqual(0x02, dualSense[28]);
+ Assert.AreEqual(0x24, dualSense[28 + 44]);
+ Assert.IsTrue(dualSense.AsSpan(28 + 48).ToArray()
+ .All(value => value == 0));
+ }
+
+ [TestMethod]
+ public void LatencySummaryUsesBoundedLongTailGate()
+ {
+ var passing = Enumerable.Range(0, 32).Select(index =>
+ new InputSampleEvidence
+ {
+ LatencyMicroseconds = index == 31 ? 7900 : 3000,
+ }).ToArray();
+ LatencySummaryEvidence pass = ProbeRunner.Summarize(passing);
+ Assert.IsTrue(pass.Passed);
+ Assert.AreEqual(7900, pass.MaximumMicroseconds);
+
+ passing[^1].LatencyMicroseconds = 20001;
+ LatencySummaryEvidence fail = ProbeRunner.Summarize(passing);
+ Assert.IsFalse(fail.Passed);
+ Assert.AreEqual(20001, fail.MaximumMicroseconds);
+ }
+
+ [TestMethod]
+ public void ProbeMetricReceiptIsSortedAndRejectsDuplicates()
+ {
+ const string metrics =
+ "z=1 a=2 b=3 c=4 d=5 e=6 f=7 g=8";
+ SortedDictionary parsed =
+ ProbeRunner.ParseMetrics(metrics);
+ CollectionAssert.AreEqual(new[]
+ {
+ "a", "b", "c", "d", "e", "f", "g", "z",
+ }, parsed.Keys.ToArray());
+ Assert.ThrowsException(() =>
+ ProbeRunner.ParseMetrics(
+ "a=1 a=2 b=3 c=4 d=5 e=6 f=7 g=8"));
+ }
+
+ [TestMethod]
+ public void OptionsAndFailureEvidenceRemainBoundedWithoutLiveState()
+ {
+ string nonce = new string('c', 64);
+ LiveValidationOptions options = LiveValidationOptions.Parse(
+ new[]
+ {
+ "--nonce", nonce,
+ "--output", "evidence.json",
+ "--metadata", "metadata.json",
+ "--artifact-root", "artifacts",
+ "--samples", "32",
+ "--media-seconds", "1",
+ });
+ Assert.AreEqual(32, options.Samples);
+ Assert.AreEqual(1, options.MediaSeconds);
+
+ var evidence = new EvidenceDocument
+ {
+ CurrentStage = new string('s', 1024),
+ };
+ evidence.RecordFailure(new InvalidOperationException(
+ new string('x', 10000)));
+ evidence.Finalized = true;
+ string json = EvidenceWriter.Serialize(evidence);
+ Assert.AreEqual(2, evidence.SchemaVersion);
+ Assert.IsTrue(Encoding.UTF8.GetByteCount(json) <
+ EvidenceLimits.MaximumJsonBytes);
+ Assert.AreEqual(256, evidence.Failures[0].Stage.Length);
+ Assert.AreEqual(4096, evidence.Failures[0].Message.Length);
+ }
+
+ [TestMethod]
+ public async Task ExistingMetadataOutputCollisionNeverOverwrites()
+ {
+ string root = Path.Combine(Path.GetTempPath(),
+ "DS4Windows-runner-output-test-" +
+ Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+ string metadata = Path.Combine(root,
+ "ViiperNativeRuntimeMetadata.json");
+ const string original =
+ "{\"sentinel\":\"must-not-be-overwritten\"}\n";
+ File.WriteAllText(metadata, original, Encoding.UTF8);
+ try
+ {
+ RawRunnerResult result = await RunRunnerRawAsync(new[]
+ {
+ "--nonce", "invalid",
+ "--output", metadata,
+ });
+ Assert.AreEqual(1, result.ExitCode);
+ Assert.AreEqual(original,
+ File.ReadAllText(metadata, Encoding.UTF8));
+ using JsonDocument document = JsonDocument.Parse(
+ result.StandardOutput);
+ Assert.AreEqual("evidence-output-reservation",
+ document.RootElement.GetProperty("failureStage")
+ .GetString());
+ Assert.AreEqual("failure",
+ document.RootElement.GetProperty("status").GetString());
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ [TestMethod]
+ public async Task RawChildStdoutExactlyMatchesBoundedEvidenceBytes()
+ {
+ string root = Path.Combine(Path.GetTempPath(),
+ "DS4Windows-runner-raw-stdout-test-" +
+ Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+ string output = Path.Combine(root, "evidence.json");
+ try
+ {
+ RawRunnerResult result = await RunRunnerRawAsync(new[]
+ {
+ "--nonce", new string('a', 64),
+ "--output", output,
+ });
+ Assert.AreEqual(1, result.ExitCode);
+ byte[] evidenceBytes = File.ReadAllBytes(output);
+ CollectionAssert.AreEqual(evidenceBytes,
+ result.StandardOutput);
+ Assert.IsTrue(evidenceBytes.Length <=
+ EvidenceLimits.MaximumJsonBytes);
+ Assert.AreEqual((byte)'\n', evidenceBytes[^1]);
+ Assert.AreNotEqual((byte)'\r', evidenceBytes[^2]);
+
+ string exactJson = "\"" + new string('x',
+ EvidenceLimits.MaximumJsonBytes - 3) + "\"";
+ byte[] exactBoundary =
+ EvidenceWriter.EncodeFinalizedJson(exactJson);
+ Assert.AreEqual(EvidenceLimits.MaximumJsonBytes,
+ exactBoundary.Length);
+ Assert.AreEqual((byte)'\n', exactBoundary[^1]);
+ string overflowJson = "\"" + new string('x',
+ EvidenceLimits.MaximumJsonBytes - 2) + "\"";
+ Assert.ThrowsException(() =>
+ EvidenceWriter.EncodeFinalizedJson(overflowJson));
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ [TestMethod]
+ public async Task EvidenceReservationIsCreateNewAndWriteOnce()
+ {
+ string root = Path.Combine(Path.GetTempPath(),
+ "DS4Windows-runner-create-new-test-" +
+ Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+ string output = Path.Combine(root, "evidence.json");
+ try
+ {
+ using (EvidenceOutputReservation reservation =
+ EvidenceOutputReservation.Create(output))
+ {
+ Assert.ThrowsException(() =>
+ EvidenceOutputReservation.Create(output));
+ await reservation.WriteOnceAsync(
+ Encoding.UTF8.GetBytes("first\n"));
+ await Assert.ThrowsExceptionAsync(() =>
+ reservation.WriteOnceAsync(
+ Encoding.UTF8.GetBytes("second\n")));
+ }
+ Assert.AreEqual("first\n", File.ReadAllText(output));
+ Assert.ThrowsException(() =>
+ EvidenceOutputReservation.Create(output));
+ Assert.AreEqual("first\n", File.ReadAllText(output));
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ [TestMethod]
+ public async Task ProbeLaunchRetainsLockedExactFileIdentity()
+ {
+ string root = Path.Combine(Path.GetTempPath(),
+ "DS4Windows-runner-probe-lock-test-" +
+ Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+ string command = Environment.GetEnvironmentVariable("ComSpec") ??
+ Path.Combine(Environment.SystemDirectory, "cmd.exe");
+ string probe = Path.Combine(root, "ExactProbe.exe");
+ string replacement = Path.Combine(root, "replacement.exe");
+ File.Copy(command, probe);
+ File.Copy(command, replacement);
+ try
+ {
+ FileBindingEvidence binding = SourceBindings.BindFile(
+ "input-probe", probe);
+ using (var executable =
+ new ImmutableProbeExecutable(binding))
+ {
+ Assert.ThrowsException(() =>
+ File.WriteAllBytes(probe, new byte[] { 1, 2, 3 }));
+ Assert.ThrowsException(() =>
+ File.Move(replacement, probe, overwrite: true));
+ ProbeResult result = await ProbeRunner.RunAsync(executable,
+ new[] { "/d", "/c", "echo", "EXACT" },
+ TimeSpan.FromSeconds(10), CancellationToken.None);
+ Assert.AreEqual(0, result.ExitCode);
+ StringAssert.Contains(result.StandardOutput, "EXACT");
+ Assert.AreEqual(1,
+ executable.Evidence.LaunchCount);
+ Assert.IsTrue(executable.Evidence.AllLaunchesExact);
+ Assert.AreEqual(
+ executable.Evidence.LockedFileIdentity,
+ executable.Evidence.LastProcessFileIdentity);
+ executable.Revalidate();
+ }
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ [TestMethod]
+ public void InstalledRuntimeMustMatchRunningAndDriverStoreBytes()
+ {
+ BindingEvidence package = CreatePackageBindings();
+ InstalledRuntimeEvidence installed =
+ CreateInstalledRuntimeBindings(package);
+ InstalledRuntimeBindings.ValidateExactPackage(package,
+ installed);
+ Assert.IsTrue(installed.ExactPackageMatch);
+
+ installed.Broker.RunningImage.Sha256 = new string('f', 64);
+ installed.Broker.RunningImage.ExactMatch = true;
+ Assert.ThrowsException(() =>
+ InstalledRuntimeBindings.ValidateExactPackage(package,
+ installed));
+
+ installed = CreateInstalledRuntimeBindings(package);
+ installed.Driver.DriverStoreCat.Sha256 = new string('e', 64);
+ installed.Driver.DriverStoreCat.ExactMatch = true;
+ Assert.ThrowsException(() =>
+ InstalledRuntimeBindings.ValidateExactPackage(package,
+ installed));
+ }
+
+ [TestMethod]
+ public void InstalledRuntimeChangeDuringValidationFailsClosed()
+ {
+ BindingEvidence package = CreatePackageBindings();
+ InstalledRuntimeEvidence initial =
+ CreateInstalledRuntimeBindings(package);
+ InstalledRuntimeEvidence final =
+ CreateInstalledRuntimeBindings(package);
+ final.Broker.ProcessId++;
+ Assert.ThrowsException(() =>
+ InstalledRuntimeBindings.RequireUnchanged(initial, final));
+
+ final = CreateInstalledRuntimeBindings(package);
+ final.Driver.PublishedInfName = "oem999.inf";
+ Assert.ThrowsException(() =>
+ InstalledRuntimeBindings.RequireUnchanged(initial, final));
+ }
+
+ [TestMethod]
+ public void NativeRuntimeAndLaptopHarnessKeepAuthoritativeContracts()
+ {
+ string root = FindRepositoryRoot();
+ string native = File.ReadAllText(Path.Combine(root, "tools",
+ "DS4Windows.ViiperLiveValidation",
+ "InstalledRuntimeBindings.cs"));
+ StringAssert.Contains(native, "QueryServiceStatusEx(");
+ StringAssert.Contains(native,
+ "SetupGetInfDriverStoreLocation(");
+ StringAssert.Contains(native,
+ "FileShare.Read, 128 * 1024");
+ StringAssert.Contains(native,
+ "InstalledRuntimeBindings.RequireUnchanged");
+
+ string harness = File.ReadAllText(Path.Combine(root, "tools",
+ "DS4Windows.ViiperLiveValidation",
+ "Invoke-ViiperDs4WindowsLaptopValidation.ps1"));
+ StringAssert.Contains(harness,
+ "$IUnderstandThisExercisesLiveControllers");
+ StringAssert.Contains(harness,
+ "Refusing to overwrite existing evidence or input");
+ StringAssert.Contains(harness,
+ "DS4WINDOWS_VIIPER_LIVE_VALIDATION_NONCE");
+ StringAssert.Contains(harness, "consentNonceSha256");
+ StringAssert.Contains(harness, "-RunnerBinding $runnerBinding");
+ StringAssert.Contains(harness,
+ "New-ViiperLockedFileBinding -Path $output");
+ StringAssert.Contains(harness,
+ "New-ViiperStdoutEvidenceReceipt");
+ StringAssert.Contains(harness,
+ "Assert-ViiperStdoutEvidenceContinuity");
+ string common = File.ReadAllText(Path.Combine(root, "tools",
+ "DS4Windows.ViiperLiveValidation",
+ "ViiperLaptopValidation.Common.psm1"));
+ StringAssert.Contains(common,
+ "Duplicate JSON property: " );
+ StringAssert.Contains(common,
+ "Evidence timestamps are malformed, inconsistent, or stale.");
+ StringAssert.Contains(common,
+ "Assert-ViiperEvidenceFileBinding");
+ StringAssert.Contains(common,
+ "Get-ViiperRequiredProperty $bindings 'installedRuntime'");
+ StringAssert.Contains(common,
+ "Locked evidence is not byte-identical to the exact child stdout receipt.");
+ }
+
+ [TestMethod]
+ public void LaptopHarnessAdversarialPowerShellContractPasses()
+ {
+ string script = Path.Combine(FindRepositoryRoot(), "tools",
+ "DS4Windows.ViiperLiveValidation",
+ "Test-ViiperLaptopValidationHarness.ps1");
+ var start = new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = "powershell.exe",
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true,
+ };
+ foreach (string argument in new[]
+ {
+ "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script,
+ })
+ {
+ start.ArgumentList.Add(argument);
+ }
+ using System.Diagnostics.Process process =
+ System.Diagnostics.Process.Start(start);
+ Assert.IsNotNull(process);
+ string stdout = process.StandardOutput.ReadToEnd();
+ string stderr = process.StandardError.ReadToEnd();
+ Assert.IsTrue(process.WaitForExit(30000),
+ "Harness contract test exceeded 30 seconds.");
+ Assert.AreEqual(0, process.ExitCode, stderr);
+ StringAssert.Contains(stdout, "\"status\":\"pass\"");
+ }
+
+ private static BindingEvidence CreatePackageBindings()
+ {
+ return new BindingEvidence
+ {
+ DriverPackageVersion = "0.1.0.38",
+ PackageArtifacts = new List
+ {
+ PackageFile("broker", 100, new string('a', 64)),
+ PackageFile("driver-inf", 200, new string('b', 64)),
+ PackageFile("driver-cat", 300, new string('c', 64)),
+ PackageFile("driver-sys", 400, new string('d', 64)),
+ },
+ };
+ }
+
+ private static InstalledRuntimeEvidence CreateInstalledRuntimeBindings(
+ BindingEvidence package)
+ {
+ FileBindingEvidence broker = package.PackageArtifacts.Single(
+ file => file.Role == "broker");
+ FileBindingEvidence inf = package.PackageArtifacts.Single(
+ file => file.Role == "driver-inf");
+ FileBindingEvidence cat = package.PackageArtifacts.Single(
+ file => file.Role == "driver-cat");
+ FileBindingEvidence sys = package.PackageArtifacts.Single(
+ file => file.Role == "driver-sys");
+ return new InstalledRuntimeEvidence
+ {
+ Broker = new BrokerServiceEvidence
+ {
+ ServiceName = "VIIPERNativeBroker",
+ State = "running",
+ ProcessId = 42,
+ ServiceType = 0x10,
+ StartType = 2,
+ ServiceAccount = "LocalSystem",
+ ConfiguredImagePath = @"C:\Program Files\VIIPER\viiper.exe",
+ RunningImage = ObservedFile(
+ "installed-running-broker",
+ @"C:\Program Files\VIIPER\viiper.exe", broker),
+ ConfiguredImageIsRunningImage = true,
+ ExactPackageMatch = true,
+ },
+ Driver = new InstalledDriverEvidence
+ {
+ HardwareId = @"ROOT\VIIPER\UDE",
+ InstanceId = @"ROOT\VIIPER\0000",
+ ServiceName = "ViiperUde",
+ ServiceState = "running",
+ ServiceType = 1,
+ ServiceStartType = 3,
+ Started = true,
+ ProblemCode = 0,
+ DriverVersion = "0.1.0.38",
+ PublishedInfName = "oem42.inf",
+ PublishedInf = ObservedFile("installed-published-inf",
+ @"C:\Windows\INF\oem42.inf", inf),
+ DriverStoreInf = ObservedFile(
+ "installed-driver-store-inf",
+ @"C:\Windows\System32\DriverStore\FileRepository\viiperude.inf_amd64_test\ViiperUde.inf",
+ inf),
+ DriverStoreCat = ObservedFile(
+ "installed-driver-store-cat",
+ @"C:\Windows\System32\DriverStore\FileRepository\viiperude.inf_amd64_test\ViiperUde.cat",
+ cat),
+ DriverStoreSys = ObservedFile(
+ "installed-driver-store-sys",
+ @"C:\Windows\System32\DriverStore\FileRepository\viiperude.inf_amd64_test\ViiperUde.sys",
+ sys),
+ LoadedServiceImage = ObservedFile(
+ "installed-driver-service-image",
+ @"C:\Windows\System32\DriverStore\FileRepository\viiperude.inf_amd64_test\ViiperUde.sys",
+ sys),
+ ExactPackageMatch = true,
+ },
+ ExactPackageMatch = true,
+ };
+ }
+
+ private static FileBindingEvidence PackageFile(string role,
+ long length, string sha256) => new()
+ {
+ Role = role,
+ Path = role,
+ Length = length,
+ Sha256 = sha256,
+ ExactMatch = true,
+ };
+
+ private static FileBindingEvidence ObservedFile(string role,
+ string path, FileBindingEvidence package) => new()
+ {
+ Role = role,
+ Path = path,
+ Length = package.Length,
+ Sha256 = package.Sha256,
+ ExpectedLength = package.Length,
+ ExpectedSha256 = package.Sha256,
+ ExactMatch = true,
+ };
+
+ private static async Task RunRunnerRawAsync(
+ IEnumerable arguments)
+ {
+ string root = FindRepositoryRoot();
+ string configuration = typeof(ViiperLiveValidationRunnerTests)
+ .Assembly.Location.Split(Path.DirectorySeparatorChar)
+ .Any(part => part == "Debug") ? "Debug" : "Release";
+ string runner = Path.Combine(root, "tools",
+ "DS4Windows.ViiperLiveValidation", "bin", "x64",
+ configuration, "net8.0-windows10.0.19041.0", "win-x64",
+ "DS4Windows.ViiperLiveValidation.exe");
+ Assert.IsTrue(File.Exists(runner),
+ $"The exact runner apphost was not built: '{runner}'.");
+ var start = new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = runner,
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true,
+ };
+ start.Environment.Remove(
+ ViiperLiveValidationLease.NonceEnvironmentVariable);
+ foreach (string argument in arguments)
+ {
+ start.ArgumentList.Add(argument);
+ }
+ using System.Diagnostics.Process process =
+ System.Diagnostics.Process.Start(start);
+ Assert.IsNotNull(process);
+ using var stdout = new MemoryStream();
+ Task stdoutTask = process.StandardOutput.BaseStream.CopyToAsync(
+ stdout);
+ Task stderrTask = process.StandardError.ReadToEndAsync();
+ using var timeout = new CancellationTokenSource(
+ TimeSpan.FromSeconds(30));
+ try
+ {
+ await process.WaitForExitAsync(timeout.Token);
+ await stdoutTask;
+ }
+ catch (OperationCanceledException)
+ {
+ process.Kill(entireProcessTree: true);
+ throw new TimeoutException(
+ "Raw runner stdout contract test exceeded 30 seconds.");
+ }
+ return new RawRunnerResult(process.ExitCode, stdout.ToArray(),
+ await stderrTask);
+ }
+
+ private sealed record RawRunnerResult(int ExitCode,
+ byte[] StandardOutput, string StandardError);
+
+ private static string FindRepositoryRoot()
+ {
+ foreach (string startingPoint in new[]
+ {
+ Environment.CurrentDirectory,
+ AppContext.BaseDirectory,
+ })
+ {
+ DirectoryInfo cursor = new(startingPoint);
+ while (cursor != null)
+ {
+ if (File.Exists(Path.Combine(cursor.FullName,
+ "DS4WindowsWPF.sln")))
+ {
+ return cursor.FullName;
+ }
+ cursor = cursor.Parent;
+ }
+ }
+ Assert.Fail("Could not locate the DS4Windows repository root.");
+ return string.Empty;
+ }
+ }
+}
diff --git a/DS4WindowsTests/ViiperNativeLoopbackBrokerTests.cs b/DS4WindowsTests/ViiperNativeLoopbackBrokerTests.cs
new file mode 100644
index 0000000..f04df4f
--- /dev/null
+++ b/DS4WindowsTests/ViiperNativeLoopbackBrokerTests.cs
@@ -0,0 +1,447 @@
+using DS4Windows;
+using System.Buffers.Binary;
+using System.Net;
+using System.Net.Sockets;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+
+namespace DS4WindowsTests
+{
+ [TestClass]
+ public class ViiperNativeLoopbackBrokerTests
+ {
+ private const string Password = "Ds4WNativeKey001";
+
+ [TestMethod]
+ public async Task AuthenticatedPingAddAndStreamUseNativeIdentityWithoutUsbip()
+ {
+ await using var broker = new FrozenNativeBroker(Password);
+ var client = new ViiperClient("127.0.0.1", broker.Port,
+ ViiperTransportMode.NativeUde,
+ ViiperNativeRuntimeTests.CreateMetadata(),
+ new FixedCredentialProvider(Password));
+
+ ViiperDeviceStream stream = client.CreateDeviceAndOpenStream(
+ "dualsensecombinedaudioduplexv5");
+ ViiperVirtualDeviceIdentity identity =
+ stream.VirtualDeviceIdentity;
+ Assert.AreEqual(ViiperTransportMode.NativeUde,
+ identity.TransportMode);
+ Assert.AreEqual(-1, identity.LegacyUsbipPort);
+ Assert.AreEqual(42u, identity.BusId);
+ Assert.AreEqual("7", identity.DevId);
+ Assert.AreEqual((42ul << 32) | 7,
+ identity.NativePnpAnchor.NativeDeviceId);
+ Assert.AreEqual(23u,
+ identity.NativePnpAnchor.NativeDeviceGeneration);
+ Assert.AreEqual(555ul,
+ identity.NativePnpAnchor.ControllerSessionId);
+ Assert.AreEqual(8u,
+ identity.NativePnpAnchor.UdecxUsbPortNumber);
+
+ byte[] state = ViiperStatePacketBuilder.BuildNeutral(
+ ViiperVirtualDeviceType.DualSense);
+ stream.WriteFrame(0x05, 0x01, state);
+ stream.Dispose();
+ await broker.Completion.WaitAsync(TimeSpan.FromSeconds(10));
+
+ byte[] frame = broker.StreamFrame;
+ Assert.IsNotNull(frame);
+ CollectionAssert.AreEqual(new byte[] { (byte)'V', (byte)'P',
+ (byte)'C', (byte)'M', 0x05, 0x01 }, frame.Take(6).ToArray());
+ CollectionAssert.AreEqual(state, frame.Skip(16).ToArray());
+ Assert.IsTrue(broker.RemovedDevice);
+ Assert.IsTrue(broker.RemovedBus);
+ Assert.IsTrue(broker.ConditionalRemoveRequested);
+ Assert.IsFalse(
+ broker.UnexpectedConnectionAfterConditionalRemove);
+ }
+
+ [TestMethod]
+ public async Task NativeAddIdentityMismatchDoesNotGuessCleanupAndFencesSession()
+ {
+ await using var broker = new FrozenNativeBroker(Password,
+ returnMismatchedAddIdentity: true);
+ var client = new ViiperClient("127.0.0.1", broker.Port,
+ ViiperTransportMode.NativeUde,
+ ViiperNativeRuntimeTests.CreateMetadata(),
+ new FixedCredentialProvider(Password));
+
+ Assert.ThrowsException(() =>
+ client.CreateDeviceAndOpenStream(
+ "dualsensecombinedaudioduplexv5"));
+ await broker.Completion.WaitAsync(TimeSpan.FromSeconds(10));
+ Assert.IsFalse(broker.ConditionalRemoveRequested);
+ Assert.IsFalse(broker.RemovedDevice);
+ Assert.IsFalse(broker.RemovedBus);
+
+ ViiperIdentityException fenced =
+ Assert.ThrowsException(() =>
+ client.ValidateNativeBackend());
+ StringAssert.Contains(fenced.Message, "permanently invalid");
+ }
+
+ [TestMethod]
+ public async Task NativeAddExplicitNullUsbipFieldDoesNotGuessCleanup()
+ {
+ await using var broker = new FrozenNativeBroker(Password,
+ returnForbiddenUsbipField: true);
+ var client = new ViiperClient("127.0.0.1", broker.Port,
+ ViiperTransportMode.NativeUde,
+ ViiperNativeRuntimeTests.CreateMetadata(),
+ new FixedCredentialProvider(Password));
+
+ Assert.ThrowsException(() =>
+ client.CreateDeviceAndOpenStream(
+ "dualsensecombinedaudioduplexv5"));
+ await broker.Completion.WaitAsync(TimeSpan.FromSeconds(10));
+ Assert.IsFalse(broker.ConditionalRemoveRequested);
+ Assert.IsFalse(broker.RemovedDevice);
+ Assert.IsFalse(broker.RemovedBus);
+ }
+
+ [TestMethod]
+ public async Task StaleNativeReceiptPreservesSuccessorWithoutLegacyRetry()
+ {
+ await using var broker = new FrozenNativeBroker(Password,
+ returnStaleRemoveConflict: true);
+ var client = new ViiperClient("127.0.0.1", broker.Port,
+ ViiperTransportMode.NativeUde,
+ ViiperNativeRuntimeTests.CreateMetadata(),
+ new FixedCredentialProvider(Password));
+
+ using ViiperDeviceStream stream =
+ client.CreateDeviceAndOpenStream(
+ "dualsensecombinedaudioduplexv5");
+ byte[] state = ViiperStatePacketBuilder.BuildNeutral(
+ ViiperVirtualDeviceType.DualSense);
+ stream.WriteFrame(0x05, 0x01, state);
+ stream.Dispose();
+ await broker.Completion.WaitAsync(TimeSpan.FromSeconds(10));
+
+ Assert.IsTrue(broker.ConditionalRemoveRequested);
+ Assert.IsTrue(broker.SuccessorPreserved);
+ Assert.IsFalse(broker.RemovedDevice);
+ Assert.IsFalse(broker.RemovedBus);
+ Assert.IsFalse(
+ broker.UnexpectedConnectionAfterConditionalRemove);
+ }
+
+ private sealed class FixedCredentialProvider :
+ IViiperCredentialProvider
+ {
+ private readonly string password;
+ internal FixedCredentialProvider(string password) =>
+ this.password = password;
+ public ViiperCredential Read()
+ {
+ byte[] bytes = Encoding.ASCII.GetBytes(password);
+ return new ViiperCredential(password, SHA256.HashData(bytes));
+ }
+ }
+
+ private sealed class FrozenNativeBroker : IAsyncDisposable
+ {
+ private readonly string password;
+ private readonly bool returnMismatchedAddIdentity;
+ private readonly bool returnForbiddenUsbipField;
+ private readonly bool returnStaleRemoveConflict;
+ private readonly TcpListener listener;
+ private readonly Task completion;
+
+ internal FrozenNativeBroker(string password,
+ bool returnMismatchedAddIdentity = false,
+ bool returnForbiddenUsbipField = false,
+ bool returnStaleRemoveConflict = false)
+ {
+ this.password = password;
+ this.returnMismatchedAddIdentity =
+ returnMismatchedAddIdentity;
+ this.returnForbiddenUsbipField =
+ returnForbiddenUsbipField;
+ this.returnStaleRemoveConflict =
+ returnStaleRemoveConflict;
+ listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ Port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ completion = Task.Run(Run);
+ }
+
+ internal int Port { get; }
+ internal Task Completion => completion;
+ internal byte[] StreamFrame { get; private set; }
+ internal bool ConditionalRemoveRequested { get; private set; }
+ internal bool RemovedDevice { get; private set; }
+ internal bool RemovedBus { get; private set; }
+ internal bool SuccessorPreserved { get; private set; }
+ internal bool UnexpectedConnectionAfterConditionalRemove
+ { get; private set; }
+
+ private async Task Run()
+ {
+ bool rejectAdd = returnMismatchedAddIdentity ||
+ returnForbiddenUsbipField;
+ int connectionCount = rejectAdd ? 3 : 5;
+ for (int connection = 0; connection < connectionCount;
+ connection++)
+ {
+ using TcpClient tcp = await listener.AcceptTcpClientAsync();
+ NetworkStream transport = tcp.GetStream();
+ byte[] sessionKey = await Authenticate(transport,
+ connection);
+ byte[] request = await ReadRecord(transport, sessionKey,
+ expectedPrefix: 0, expectedCounter: 0);
+ string text = Encoding.UTF8.GetString(request)
+ .TrimEnd('\0');
+
+ switch (connection)
+ {
+ case 0:
+ Assert.AreEqual("ping", text);
+ await WriteResponse(transport, sessionKey,
+ ViiperNativeRuntimeTests.PingJson(
+ controllerSessionId: "555"));
+ break;
+ case 1:
+ Assert.AreEqual("bus/create 0", text);
+ await WriteResponse(transport, sessionKey,
+ "{\"busId\":42}");
+ break;
+ case 2:
+ StringAssert.StartsWith(text, "bus/42/add ");
+ using (JsonDocument requestJson =
+ JsonDocument.Parse(text.Substring(
+ "bus/42/add ".Length)))
+ {
+ Assert.AreEqual(
+ "dualsensecombinedaudioduplexv5",
+ requestJson.RootElement.GetProperty(
+ "type").GetString());
+ Assert.AreEqual(0x0ce6,
+ requestJson.RootElement.GetProperty(
+ "idProduct").GetInt32());
+ }
+ string addResponse = JsonSerializer.Serialize(new
+ {
+ busId = 42,
+ devId = "7",
+ vid = "0x054c",
+ pid = "0x0ce6",
+ type =
+ "dualsensecombinedaudioduplexv5",
+ transport = "native-ude",
+ deviceSpecific = new
+ {
+ serial_number =
+ "E55700GTD1190A500",
+ },
+ nativeUde = new
+ {
+ deviceId = ((42ul << 32) | 7)
+ .ToString(),
+ deviceGeneration = 23,
+ controllerSessionId =
+ returnMismatchedAddIdentity ?
+ "556" : "555",
+ controllerInstanceId =
+ ViiperNativeRuntimeTests
+ .ControllerInstance,
+ usb20PortNumber = 0,
+ usb30PortNumber = 8,
+ },
+ });
+ if (returnForbiddenUsbipField)
+ {
+ addResponse = addResponse.Insert(
+ addResponse.Length - 1,
+ ",\"usbipPort\":null");
+ }
+ await WriteResponse(transport, sessionKey,
+ addResponse);
+ break;
+ case 3:
+ Assert.AreEqual("bus/42/7", text);
+ StreamFrame = await ReadRecord(transport,
+ sessionKey, expectedPrefix: 0,
+ expectedCounter: 1);
+ break;
+ case 4:
+ ValidateConditionalRemove(text);
+ ConditionalRemoveRequested = true;
+ if (returnStaleRemoveConflict)
+ {
+ SuccessorPreserved = true;
+ await WriteResponse(transport, sessionKey,
+ "{\"status\":409,\"title\":\"native receipt mismatch\",\"detail\":\"successor preserved\"}");
+ }
+ else
+ {
+ RemovedDevice = true;
+ RemovedBus = true;
+ await WriteResponse(transport, sessionKey,
+ "{\"busId\":42,\"devId\":\"7\"}");
+ }
+ break;
+ }
+ CryptographicOperations.ZeroMemory(sessionKey);
+ }
+
+ if (!rejectAdd)
+ {
+ Task unexpected =
+ listener.AcceptTcpClientAsync();
+ Task completed = await Task.WhenAny(unexpected,
+ Task.Delay(250));
+ if (ReferenceEquals(completed, unexpected))
+ {
+ using TcpClient ignored = await unexpected;
+ UnexpectedConnectionAfterConditionalRemove = true;
+ }
+ }
+ }
+
+ private static void ValidateConditionalRemove(string text)
+ {
+ const string prefix = "bus/42/remove-native ";
+ StringAssert.StartsWith(text, prefix);
+ using JsonDocument document = JsonDocument.Parse(
+ text.Substring(prefix.Length));
+ JsonElement root = document.RootElement;
+ Assert.AreEqual(3, root.EnumerateObject().Count());
+ Assert.AreEqual("7", root.GetProperty("devId").GetString());
+ Assert.AreEqual("native-ude",
+ root.GetProperty("transport").GetString());
+ JsonElement native = root.GetProperty("nativeUde");
+ Assert.AreEqual(6, native.EnumerateObject().Count());
+ Assert.AreEqual(((42ul << 32) | 7).ToString(),
+ native.GetProperty("deviceId").GetString());
+ Assert.AreEqual(23u,
+ native.GetProperty("deviceGeneration").GetUInt32());
+ Assert.AreEqual("555",
+ native.GetProperty("controllerSessionId").GetString());
+ Assert.AreEqual(ViiperNativeRuntimeTests.ControllerInstance,
+ native.GetProperty("controllerInstanceId").GetString());
+ Assert.AreEqual(0u,
+ native.GetProperty("usb20PortNumber").GetUInt32());
+ Assert.AreEqual(8u,
+ native.GetProperty("usb30PortNumber").GetUInt32());
+ }
+
+ private async Task Authenticate(NetworkStream transport,
+ int connection)
+ {
+ byte[] handshake = new byte[
+ Encoding.UTF8.GetByteCount(
+ ViiperAuthProtocol.HandshakeMagic) + 64];
+ await ReadExactly(transport, handshake);
+ byte[] magic = Encoding.UTF8.GetBytes(
+ ViiperAuthProtocol.HandshakeMagic);
+ CollectionAssert.AreEqual(magic,
+ handshake.Take(magic.Length).ToArray());
+ byte[] clientNonce = handshake.Skip(magic.Length).Take(32)
+ .ToArray();
+ byte[] receivedTag = handshake.Skip(magic.Length + 32)
+ .ToArray();
+ byte[] passwordKey = ViiperAuthProtocol.DerivePasswordKey(
+ password);
+ byte[] authData = Combine(Encoding.UTF8.GetBytes(
+ ViiperAuthProtocol.AuthenticationContext), clientNonce);
+ byte[] expectedTag = HMACSHA256.HashData(passwordKey,
+ authData);
+ Assert.IsTrue(CryptographicOperations.FixedTimeEquals(
+ expectedTag, receivedTag));
+
+ byte[] serverNonce = Enumerable.Range(0, 32).Select(value =>
+ (byte)(value + connection + 1)).ToArray();
+ await transport.WriteAsync(Combine(
+ Encoding.ASCII.GetBytes("OK\0"), serverNonce));
+ byte[] sessionKey = ViiperAuthProtocol.DeriveSessionKey(
+ passwordKey, serverNonce, clientNonce);
+ CryptographicOperations.ZeroMemory(passwordKey);
+ return sessionKey;
+ }
+
+ private static async Task ReadRecord(
+ NetworkStream transport, byte[] key, uint expectedPrefix,
+ ulong expectedCounter)
+ {
+ byte[] header = new byte[4];
+ await ReadExactly(transport, header);
+ int length = checked((int)
+ BinaryPrimitives.ReadUInt32BigEndian(header));
+ Assert.IsTrue(length >= 28 && length <= 2 * 1024 * 1024);
+ byte[] record = new byte[length];
+ await ReadExactly(transport, record);
+ Assert.AreEqual(expectedPrefix,
+ BinaryPrimitives.ReadUInt32BigEndian(record));
+ Assert.AreEqual(expectedCounter,
+ BinaryPrimitives.ReadUInt64BigEndian(record.AsSpan(4)));
+ byte[] plaintext = new byte[length - 28];
+ using var cipher = new ChaCha20Poly1305(key);
+ cipher.Decrypt(record.AsSpan(0, 12),
+ record.AsSpan(12, plaintext.Length),
+ record.AsSpan(length - 16), plaintext);
+ return plaintext;
+ }
+
+ private static async Task WriteResponse(NetworkStream transport,
+ byte[] key, string response)
+ {
+ byte[] plaintext = Encoding.UTF8.GetBytes(response + "\n");
+ byte[] nonce = new byte[12];
+ BinaryPrimitives.WriteUInt32BigEndian(nonce, 1);
+ byte[] ciphertext = new byte[plaintext.Length];
+ byte[] tag = new byte[16];
+ using (var cipher = new ChaCha20Poly1305(key))
+ {
+ cipher.Encrypt(nonce, plaintext, ciphertext, tag);
+ }
+ byte[] record = Combine(nonce, ciphertext, tag);
+ byte[] header = new byte[4];
+ BinaryPrimitives.WriteUInt32BigEndian(header,
+ (uint)record.Length);
+ await transport.WriteAsync(Combine(header, record));
+ }
+
+ private static async Task ReadExactly(Stream stream,
+ byte[] buffer)
+ {
+ int offset = 0;
+ while (offset < buffer.Length)
+ {
+ int read = await stream.ReadAsync(buffer.AsMemory(offset));
+ if (read == 0)
+ {
+ throw new EndOfStreamException();
+ }
+ offset += read;
+ }
+ }
+
+ private static byte[] Combine(params byte[][] pieces)
+ {
+ byte[] result = new byte[pieces.Sum(piece => piece.Length)];
+ int offset = 0;
+ foreach (byte[] piece in pieces)
+ {
+ Buffer.BlockCopy(piece, 0, result, offset, piece.Length);
+ offset += piece.Length;
+ }
+ return result;
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ listener.Stop();
+ try
+ {
+ await completion.WaitAsync(TimeSpan.FromSeconds(2));
+ }
+ catch when (!completion.IsCompletedSuccessfully)
+ {
+ }
+ }
+ }
+ }
+}
diff --git a/DS4WindowsTests/ViiperNativeRuntimeTests.cs b/DS4WindowsTests/ViiperNativeRuntimeTests.cs
new file mode 100644
index 0000000..86c3845
--- /dev/null
+++ b/DS4WindowsTests/ViiperNativeRuntimeTests.cs
@@ -0,0 +1,415 @@
+using DS4Windows;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+
+namespace DS4WindowsTests
+{
+ [TestClass]
+ public class ViiperNativeRuntimeTests
+ {
+ internal const string BuildIdentity =
+ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
+ internal const string ControllerInstance = @"ROOT\VIIPERUDE\0000";
+
+ [TestMethod]
+ public void ManagedTransportDefaultsNativeAndUsbipRequiresExplicitOptIn()
+ {
+ Assert.AreEqual(ViiperTransportMode.NativeUde,
+ ViiperTransportSettings.Parse(null));
+ Assert.AreEqual(ViiperTransportMode.NativeUde,
+ ViiperTransportSettings.Parse("native-ude"));
+ Assert.AreEqual(ViiperTransportMode.Usbip,
+ ViiperTransportSettings.Parse("usbip"));
+ Assert.ThrowsException(() =>
+ ViiperTransportSettings.Parse("automatic"));
+ }
+
+ [TestMethod]
+ public void MetadataEligibilityAndHexCapabilitiesFailClosed()
+ {
+ string directory = Path.Combine(Path.GetTempPath(),
+ "ds4w-viiper-metadata-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(directory);
+ try
+ {
+ string production = Path.Combine(directory, "production.json");
+ File.WriteAllText(production, MetadataJson("production",
+ "0x0000003d"));
+ ViiperNativeRuntimeMetadata parsed =
+ ViiperNativeRuntimeMetadata.Parse(production);
+ Assert.AreEqual((ushort)14, parsed.AbiMinor);
+ Assert.AreEqual(61u, parsed.RequiredCapabilities);
+ Assert.AreEqual((ushort)0x0ce6,
+ parsed.ControllerApiContract[
+ "dualsensecombinedaudioduplexv5"]
+ .Ds4WindowsPidValue);
+
+ string local = Path.Combine(directory, "local.json");
+ File.WriteAllText(local, MetadataJson(
+ "local-test-evidence-only", "0x0000003d"));
+ Assert.ThrowsException(() =>
+ ViiperNativeRuntimeMetadata.Parse(local, "0"));
+ Assert.IsNotNull(ViiperNativeRuntimeMetadata.Parse(local,
+ "1"));
+
+ string conflicting = Path.Combine(directory,
+ "conflicting.json");
+ File.WriteAllText(conflicting, MetadataJson("production",
+ "0x0000003c"));
+ Assert.ThrowsException(() =>
+ ViiperNativeRuntimeMetadata.Parse(conflicting));
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [TestMethod]
+ public void AuthenticatedPingIsComparedToDynamicMetadataIdentity()
+ {
+ var contract = new ViiperNativeRuntimeContract(CreateMetadata());
+ ViiperNativeBackendIdentity identity = contract.ValidatePing(
+ PingJson(controllerSessionId: "987654321"));
+ Assert.AreEqual((ushort)14, identity.AbiMinor);
+ Assert.AreEqual(61u, identity.Capabilities);
+ Assert.AreEqual(987654321ul, identity.ControllerSessionId);
+
+ Assert.ThrowsException(() =>
+ contract.ValidatePing(PingJson(transport: "usbip")));
+ Assert.ThrowsException(() =>
+ contract.ValidatePing(PingJson(ready: false)));
+ Assert.ThrowsException(() =>
+ contract.ValidatePing(PingJson(abiMinor: 13)));
+ Assert.ThrowsException(() =>
+ contract.ValidatePing(PingJson(capabilities: 29)));
+ Assert.ThrowsException(() =>
+ contract.ValidatePing(PingJson(packageVersion: "0.1.0.39")));
+ Assert.ThrowsException(() =>
+ contract.ValidatePing(PingJson(buildIdentity:
+ new string('c', 64))));
+ Assert.ThrowsException(() =>
+ contract.ValidatePing(PingJson(controllerSessionId: "01")));
+
+ string valid = PingJson();
+ string duplicate = valid.Insert(valid.LastIndexOf('}'),
+ ",\"transport\":\"native-ude\"");
+ Assert.ThrowsException(() =>
+ contract.ValidatePing(duplicate));
+ }
+
+ [TestMethod]
+ public void ReconnectRejectsBackendGenerationAndControllerSessionChange()
+ {
+ var session = new ViiperNativeSession(
+ new ViiperNativeRuntimeContract(CreateMetadata()),
+ new MutableCredentialProvider("Ds4WNativeKey001"));
+ session.AdmitPing(PingJson(controllerSessionId: "41"),
+ reconnect: false);
+ Assert.ThrowsException(() =>
+ session.AdmitPing(PingJson(controllerSessionId: "42"),
+ reconnect: true));
+ Assert.ThrowsException(() =>
+ session.AdmitPing(PingJson(controllerSessionId: "41"),
+ reconnect: true));
+ }
+
+ [TestMethod]
+ public void CredentialGenerationChangePermanentlyInvalidatesSession()
+ {
+ var provider = new MutableCredentialProvider("Ds4WNativeKey001");
+ var session = new ViiperNativeSession(
+ new ViiperNativeRuntimeContract(CreateMetadata()), provider);
+ using (Stream authenticated = session.Authenticate(
+ SuccessfulHandshakeTransport()))
+ {
+ }
+ provider.Password = "Ds4WNativeKey002";
+ Assert.ThrowsException(() =>
+ session.Authenticate(SuccessfulHandshakeTransport()));
+ Assert.ThrowsException(() =>
+ session.Authenticate(SuccessfulHandshakeTransport()));
+ }
+
+ [TestMethod]
+ public void TransientHandshakeEofDoesNotPoisonLaterAuthentication()
+ {
+ var session = new ViiperNativeSession(
+ new ViiperNativeRuntimeContract(CreateMetadata()),
+ new MutableCredentialProvider("Ds4WNativeKey001"));
+ Assert.IsFalse(session.HasAuthenticatedConnection);
+ Assert.ThrowsException(() =>
+ session.Authenticate(new DuplexMemoryStream(
+ Array.Empty())));
+ Assert.IsFalse(session.HasAuthenticatedConnection);
+ using Stream authenticated = session.Authenticate(
+ SuccessfulHandshakeTransport());
+ Assert.IsNotNull(authenticated);
+ Assert.IsTrue(session.HasAuthenticatedConnection);
+ }
+
+ [TestMethod]
+ public void NativeLifetimeNeverInvokesUsbipOwnershipCallbacks()
+ {
+ int removed = 0;
+ int detached = 0;
+ int unregistered = 0;
+ int stale = 0;
+ var identity = new ViiperVirtualDeviceIdentity
+ {
+ TransportMode = ViiperTransportMode.NativeUde,
+ BusId = 17,
+ DevId = "4",
+ DeviceType = "dualsensecombinedaudioduplexv5",
+ LogicalLifetimeId = "native-lifetime",
+ NativePnpAnchor = new ViiperNativePnpAnchor
+ {
+ NativeDeviceId = (17ul << 32) | 4,
+ NativeDeviceGeneration = 3,
+ ControllerSessionId = 99,
+ ControllerInstanceId = ControllerInstance,
+ Usb20PortNumber = 6,
+ },
+ };
+ using (var lifetime = new ViiperVirtualDeviceLifetime(identity,
+ captured =>
+ {
+ Assert.AreSame(identity, captured);
+ removed++;
+ }, (_, _) => detached++,
+ _ => unregistered++, () => stale++))
+ {
+ Assert.AreEqual(-1, lifetime.UsbipPort);
+ Assert.AreEqual(1,
+ lifetime.NextStreamIdentity().StreamGeneration);
+ }
+ Assert.AreEqual(1, removed);
+ Assert.AreEqual(0, detached);
+ Assert.AreEqual(0, unregistered);
+ Assert.AreEqual(0, stale);
+ }
+
+ [TestMethod]
+ public void SourceBoundControllerContractRejectsRemovedAliases()
+ {
+ var contract = new ViiperNativeRuntimeContract(CreateMetadata());
+ Assert.AreEqual((ushort)0x05c4,
+ contract.ValidateControllerRequest(
+ "dualshock4audioduplexv3", null));
+ Assert.IsTrue(contract.HasExactControllerIdentity(
+ "dualshock4audioduplexv3", "0x054c", "0x05c4"));
+ Assert.ThrowsException(() =>
+ contract.ValidateControllerRequest(
+ "dualsensecombinedaudioduplexv4", null));
+ Assert.ThrowsException(() =>
+ contract.ValidateControllerRequest(
+ "dualshock4audioduplexv3", 0x09cc));
+ Assert.AreEqual("dualsensegamepadv5",
+ ViiperStatePacketBuilder.GetViiperDeviceName(
+ ViiperVirtualDeviceType.DualSense));
+ Assert.AreEqual("dualsenseedgegamepadv5",
+ ViiperStatePacketBuilder.GetViiperDeviceName(
+ ViiperVirtualDeviceType.DualSenseEdge));
+ }
+
+ [TestMethod]
+ public void V5RealtimeHapticsRequiresExactVersionTypeAndPayload()
+ {
+ Assert.IsTrue(ViiperOutDevice.IsValidV5RealtimeHapticsFrame(
+ 0x05, 0x84, ViiperOutDevice.DualSenseAtomicFeedbackLength));
+ Assert.IsFalse(ViiperOutDevice.IsValidV5RealtimeHapticsFrame(
+ 0x04, 0x84, ViiperOutDevice.DualSenseAtomicFeedbackLength));
+ Assert.IsFalse(ViiperOutDevice.IsValidV5RealtimeHapticsFrame(
+ 0x05, 0x83, ViiperOutDevice.DualSenseAtomicFeedbackLength));
+ Assert.IsFalse(ViiperOutDevice.IsValidV5RealtimeHapticsFrame(
+ 0x05, 0x84,
+ ViiperOutDevice.DualSenseAtomicFeedbackLength - 1));
+ }
+
+ internal static ViiperNativeRuntimeMetadata CreateMetadata()
+ {
+ var registrations = new Dictionary(StringComparer.Ordinal);
+ AddRegistration(registrations, "xbox360", "0x045e",
+ "0x028e", "fixed");
+ AddRegistration(registrations, "ns2pro", "0x057e",
+ "0x2069", "fixed");
+ AddRegistration(registrations, "dualshock4", "0x054c",
+ "0x05c4", "fixed", "0x09cc");
+ AddRegistration(registrations, "dualshock4audioduplexv3",
+ "0x054c", "0x05c4", "framed-v3", "0x09cc");
+ AddRegistration(registrations,
+ "dualshock4audioonlyduplexv3", "0x054c", "0x05c4",
+ "framed-v3", "0x09cc");
+ AddRegistration(registrations,
+ "dualsensecombinedaudioduplexv5", "0x054c", "0x0ce6",
+ "framed-v5");
+ AddRegistration(registrations,
+ "dualsenseaudioonlyduplexv5", "0x054c", "0x0ce6",
+ "framed-v5");
+ AddRegistration(registrations, "dualsensegamepadv5",
+ "0x054c", "0x0ce6", "framed-v5");
+ AddRegistration(registrations,
+ "dualsenseedgecombinedaudioduplexv5", "0x054c",
+ "0x0df2", "framed-v5");
+ AddRegistration(registrations, "dualsenseedgegamepadv5",
+ "0x054c", "0x0df2", "framed-v5");
+ return new ViiperNativeRuntimeMetadata
+ {
+ SchemaVersion = 1,
+ SourceRevision = new string('a', 40),
+ ReleaseEligibility = "production",
+ DriverPackageVersion = "0.1.0.38",
+ AbiMajor = 1,
+ AbiMinor = 14,
+ RequiredCapabilities = 61,
+ RequiredCapabilitiesHex = "0x0000003d",
+ LoadedDriverBuildIdentity = BuildIdentity,
+ ControllerApiContract = registrations,
+ };
+ }
+
+ private static void AddRegistration(Dictionary registrations, string type,
+ string vid, string clientPid, string streamProtocol,
+ string defaultPid = null)
+ {
+ registrations.Add(type, new ViiperNativeControllerRegistration
+ {
+ Type = type,
+ DefaultVid = vid,
+ DefaultPid = defaultPid ?? clientPid,
+ Ds4WindowsPid = clientPid,
+ InterfaceProfile = "test-profile",
+ StreamProtocol = streamProtocol,
+ });
+ }
+
+ internal static string PingJson(string transport = "native-ude",
+ bool ready = true, ushort abiMinor = 14,
+ uint capabilities = 61,
+ string packageVersion = "0.1.0.38",
+ string buildIdentity = BuildIdentity,
+ string controllerSessionId = "987654321")
+ {
+ return JsonSerializer.Serialize(new
+ {
+ server = "VIIPER",
+ version = "0.1.0-test",
+ transport,
+ ready,
+ nativeUde = new
+ {
+ abiMajor = 1,
+ abiMinor,
+ capabilities,
+ expectedDriverPackageVersion = packageVersion,
+ loadedDriverBuildIdentity = buildIdentity,
+ controllerInstanceId = ControllerInstance,
+ controllerSessionId,
+ },
+ });
+ }
+
+ private static string MetadataJson(string eligibility,
+ string capabilitiesHex)
+ {
+ return $$"""
+ {
+ "schemaVersion": 1,
+ "releaseEligibility": "{{eligibility}}",
+ "localTestOptInEnvironment": "DS4WINDOWS_VIIPER_ALLOW_LOCAL_TEST",
+ "sourceRevision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "driverPackageVersion": "0.1.0.38",
+ "driverAbi": { "major": 1, "minor": 14 },
+ "requiredCapabilities": 61,
+ "requiredCapabilitiesHex": "{{capabilitiesHex}}",
+ "loadedDriverBuildIdentity": "{{BuildIdentity}}",
+ "managedBroker": {
+ "serviceName": "VIIPERNativeBroker",
+ "serviceAccount": "LocalSystem",
+ "startMode": "automatic",
+ "transport": "native-ude",
+ "apiHost": "127.0.0.1",
+ "apiPort": 3242,
+ "credentialPath": "%ProgramData%/VIIPER/viiper.key.txt"
+ },
+ "controllerApiContract": {
+ "schemaVersion": 1,
+ "sourceRevision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "implementation": "test fixture",
+ "registrations": [{
+ "type": "dualsensecombinedaudioduplexv5",
+ "defaultVid": "0x054c",
+ "defaultPid": "0x0ce6",
+ "ds4WindowsPid": "0x0ce6",
+ "interfaceProfile": "hid-audio-duplex",
+ "streamProtocol": "framed-v5"
+ }]
+ }
+ }
+ """;
+ }
+
+ private static Stream SuccessfulHandshakeTransport()
+ {
+ return new DuplexMemoryStream(Encoding.ASCII.GetBytes("OK\0")
+ .Concat(Enumerable.Range(0, 32).Select(value =>
+ (byte)value)).ToArray());
+ }
+
+ private sealed class MutableCredentialProvider :
+ IViiperCredentialProvider
+ {
+ internal MutableCredentialProvider(string password)
+ {
+ Password = password;
+ }
+
+ internal string Password { get; set; }
+
+ public ViiperCredential Read()
+ {
+ byte[] bytes = Encoding.ASCII.GetBytes(Password);
+ return new ViiperCredential(Password, SHA256.HashData(bytes));
+ }
+ }
+
+ private sealed class DuplexMemoryStream : Stream
+ {
+ private readonly byte[] input;
+ private int offset;
+ internal DuplexMemoryStream(byte[] input) => this.input = input;
+ public override bool CanRead => true;
+ public override bool CanSeek => false;
+ public override bool CanWrite => true;
+ public override long Length => throw new NotSupportedException();
+ public override long Position
+ {
+ get => throw new NotSupportedException();
+ set => throw new NotSupportedException();
+ }
+ public override void Flush() { }
+ public override int Read(byte[] buffer, int bufferOffset,
+ int count)
+ {
+ int length = Math.Min(count, input.Length - offset);
+ if (length > 0)
+ {
+ Buffer.BlockCopy(input, offset, buffer, bufferOffset,
+ length);
+ offset += length;
+ }
+ return length;
+ }
+ public override void Write(byte[] buffer, int offset, int count)
+ {
+ }
+ public override long Seek(long offset, SeekOrigin origin) =>
+ throw new NotSupportedException();
+ public override void SetLength(long value) =>
+ throw new NotSupportedException();
+ }
+ }
+}
diff --git a/DS4WindowsTests/ViiperNativeSetupContractTests.cs b/DS4WindowsTests/ViiperNativeSetupContractTests.cs
new file mode 100644
index 0000000..c941685
--- /dev/null
+++ b/DS4WindowsTests/ViiperNativeSetupContractTests.cs
@@ -0,0 +1,402 @@
+using DS4Windows;
+using System.Text.Json;
+
+namespace DS4WindowsTests
+{
+ [TestClass]
+ public class ViiperNativeSetupContractTests
+ {
+ [TestMethod]
+ public void ReadinessRequiresEveryManagedNativeProof()
+ {
+ ViiperPrerequisiteStatus status = ReadyStatus();
+ Assert.IsTrue(status.Ready);
+ Assert.IsFalse(status.UsbipInstalled);
+
+ foreach (Action removeProof in new Action[]
+ {
+ value => value.MetadataEligible = false,
+ value => value.BrokerInstalled = false,
+ value => value.BrokerHashMatches = false,
+ value => value.BrokerServiceInstalled = false,
+ value => value.BrokerServiceConfigured = false,
+ value => value.BrokerServiceRunning = false,
+ value => value.CredentialReadable = false,
+ value => value.AuthenticatedPingSucceeded = false,
+ value => value.RuntimeContractCompatible = false,
+ })
+ {
+ status = ReadyStatus();
+ removeProof(status);
+ Assert.IsFalse(status.Ready);
+ }
+ }
+
+ [TestMethod]
+ public void StatusCopyNamesNativeServiceAndLocalTestBoundary()
+ {
+ ViiperPrerequisiteStatus localTest = ReadyStatus();
+ localTest.LocalTestMetadata = true;
+ Assert.IsTrue(localTest.DisplayText.Contains("disposable-VM",
+ StringComparison.Ordinal));
+
+ ViiperPrerequisiteStatus stopped = ReadyStatus();
+ stopped.BrokerServiceRunning = false;
+ Assert.IsTrue(stopped.DisplayText.Contains(
+ ViiperSetupManager.NativeBrokerServiceName,
+ StringComparison.Ordinal));
+
+ ViiperPrerequisiteStatus unauthenticated = ReadyStatus();
+ unauthenticated.AuthenticatedPingSucceeded = false;
+ Assert.IsTrue(unauthenticated.DisplayText.Contains(
+ "authentication", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [TestMethod]
+ public void BundledMetadataIsExplicitLocalTestEvidenceWithExactArtifacts()
+ {
+ string root = FindRepositoryRoot();
+ string path = Path.Combine(root, "extras",
+ "ViiperNativeRuntimeMetadata.json");
+ using JsonDocument document = JsonDocument.Parse(
+ File.ReadAllText(path));
+ JsonElement metadata = document.RootElement;
+
+ Assert.AreEqual(1,
+ metadata.GetProperty("schemaVersion").GetInt32());
+ Assert.AreEqual("local-test-evidence-only",
+ metadata.GetProperty("releaseEligibility").GetString());
+ Assert.AreEqual(
+ ViiperSetupManager.LocalTestOptInEnvironment,
+ metadata.GetProperty("localTestOptInEnvironment").GetString());
+ Assert.IsTrue(metadata.GetProperty("requiredCapabilities")
+ .GetUInt32() != 0);
+ Assert.AreEqual(64, metadata.GetProperty(
+ "loadedDriverBuildIdentity").GetString().Length);
+ JsonElement brokerContract = metadata.GetProperty(
+ "managedBroker");
+ Assert.AreEqual(ViiperSetupManager.NativeBrokerServiceName,
+ brokerContract.GetProperty("serviceName").GetString());
+ Assert.AreEqual("LocalSystem", brokerContract.GetProperty(
+ "serviceAccount").GetString());
+ Assert.AreEqual("native-ude", brokerContract.GetProperty(
+ "transport").GetString());
+ Assert.AreEqual(ViiperSetupManager.ApiPort,
+ brokerContract.GetProperty("apiPort").GetInt32());
+
+ JsonElement controllerContract = metadata.GetProperty(
+ "controllerApiContract");
+ Assert.AreEqual(1, controllerContract.GetProperty(
+ "schemaVersion").GetInt32());
+ Assert.AreEqual(metadata.GetProperty("sourceRevision")
+ .GetString(), controllerContract.GetProperty(
+ "sourceRevision").GetString());
+ Dictionary expectedControllers = new(
+ StringComparer.Ordinal)
+ {
+ ["xbox360"] =
+ "xbox360|0x045e|0x028e|0x028e|xusb-composite|fixed",
+ ["dualshock4"] =
+ "dualshock4|0x054c|0x09cc|0x05c4|hid-audio-duplex|fixed",
+ ["dualshock4audioduplexv3"] =
+ "dualshock4|0x054c|0x09cc|0x05c4|hid-audio-duplex|framed-v3",
+ ["dualshock4audioonlyduplexv3"] =
+ "dualshock4|0x054c|0x09cc|0x05c4|audio-duplex-only|framed-v3",
+ ["dualsensecombinedaudioduplexv5"] =
+ "dualsense|0x054c|0x0ce6|0x0ce6|hid-audio-duplex|framed-v5",
+ ["dualsenseaudioonlyduplexv5"] =
+ "dualsense|0x054c|0x0ce6|0x0ce6|audio-duplex-only|framed-v5",
+ ["dualsensegamepadv5"] =
+ "dualsense|0x054c|0x0ce6|0x0ce6|hid-gamepad-only|framed-v5",
+ ["dualsenseedgecombinedaudioduplexv5"] =
+ "dualsense-edge|0x054c|0x0df2|0x0df2|hid-audio-duplex|framed-v5",
+ ["dualsenseedgegamepadv5"] =
+ "dualsense-edge|0x054c|0x0df2|0x0df2|hid-gamepad-only|framed-v5",
+ ["ns2pro"] =
+ "switch2-pro|0x057e|0x2069|0x2069|hid-vendor-bulk|fixed",
+ };
+ HashSet controllerTypes = new(
+ StringComparer.Ordinal);
+ foreach (JsonElement registration in controllerContract
+ .GetProperty("registrations").EnumerateArray())
+ {
+ string type = registration.GetProperty("type").GetString();
+ Assert.IsTrue(controllerTypes.Add(type), type);
+ string signature = string.Join("|",
+ registration.GetProperty("persona").GetString(),
+ registration.GetProperty("defaultVid").GetString(),
+ registration.GetProperty("defaultPid").GetString(),
+ registration.GetProperty("ds4WindowsPid").GetString(),
+ registration.GetProperty("interfaceProfile").GetString(),
+ registration.GetProperty("streamProtocol").GetString());
+ Assert.IsTrue(expectedControllers.TryGetValue(type,
+ out string expectedSignature), type);
+ Assert.AreEqual(expectedSignature, signature, type);
+ }
+ Assert.AreEqual(expectedControllers.Count,
+ controllerTypes.Count);
+
+ using JsonDocument template = JsonDocument.Parse(
+ File.ReadAllText(Path.Combine(root, "extras",
+ "ViiperControllerApiContract.json")));
+ Assert.AreEqual(
+ JsonSerializer.Serialize(template.RootElement),
+ JsonSerializer.Serialize(controllerContract));
+
+ HashSet roles = new HashSet(
+ StringComparer.Ordinal);
+ foreach (JsonElement artifact in metadata.GetProperty(
+ "artifacts").EnumerateArray())
+ {
+ Assert.IsTrue(roles.Add(artifact.GetProperty("role")
+ .GetString()));
+ Assert.IsTrue(artifact.GetProperty("length").GetInt64() > 0);
+ Assert.AreEqual(64,
+ artifact.GetProperty("sha256").GetString().Length);
+ Assert.IsTrue(artifact.GetProperty("relativePath")
+ .GetString().StartsWith("viiper-native-package/",
+ StringComparison.Ordinal));
+ }
+
+ foreach (string required in new[]
+ {
+ "broker", "driver-helper", "submission-manifest",
+ "driver-inf", "driver-sys", "driver-cat",
+ "signed-driver-inf", "signed-driver-sys",
+ "signed-driver-cat", "local-test-package-lock",
+ "local-test-certificate-evidence",
+ })
+ {
+ Assert.IsTrue(roles.Contains(required), required);
+ }
+ }
+
+ [TestMethod]
+ public void NativePackageManagerIsOfflineExactAndParameterized()
+ {
+ string root = FindRepositoryRoot();
+ string source = File.ReadAllText(Path.Combine(root, "extras",
+ "manage-viiper-native-package.ps1"));
+
+ foreach (string required in new[]
+ {
+ "native-package-install",
+ "'uninstall', '--yes'",
+ "--expected-broker-sha-256",
+ "--expected-helper-sha-256",
+ "--expected-manifest-sha-256",
+ "--expected-inf-sha-256",
+ "--expected-sys-sha-256",
+ "--expected-cat-sha-256",
+ "--target-user-sid",
+ "--driver-validation-mode",
+ "DS4WINDOWS_VIIPER_NATIVE_RESULT",
+ "'not-started'",
+ "'safely-settled'",
+ "'unverified-see-transaction-log'",
+ "AcknowledgeDisposableTestMachine",
+ "$driverPackageVersion",
+ "$driverABIMajor",
+ "$driverCapabilities",
+ "controllerApiContract",
+ "dualsensecombinedaudioduplexv5",
+ "dualsenseedgecombinedaudioduplexv5",
+ "local-test-certificate-evidence",
+ "Assert-LocalTestBootAdmission",
+ "testsigning\\s+Yes",
+ "New-ProtectedLocalTestTrustCapability",
+ "viiper.native.local-test-trust-capability/v1",
+ "viiper.native.local-test-trust-ownership/v1",
+ "--local-test-trust-capability",
+ "--expected-trust-capability-sha-256",
+ "--local-test-certificate-path",
+ "--expected-local-test-certificate-sha-256",
+ "--expected-local-test-package-lock-sha-256",
+ "New-ProtectedFailedInstallRecoveryCapability",
+ "native-package-recover",
+ "Invoke-JoinedNativeProcess",
+ "Started ([ref]$processStarted)",
+ "$script:transactionStarted = $processStarted",
+ "AggregateException",
+ })
+ {
+ StringAssert.Contains(source, required);
+ }
+
+ foreach (string forbidden in new[]
+ {
+ "usbip-win2", "RunVIIPER", "Invoke-WebRequest",
+ "Invoke-RestMethod", "api.github.com",
+ "Get-ExactMachineCertificateCount",
+ "Ensure-ExactLocalTestTrust",
+ "Remove-NewLocalTestTrust",
+ "X509Store",
+ "$script:trustCleanupFailed",
+ })
+ {
+ Assert.IsFalse(source.Contains(forbidden,
+ StringComparison.OrdinalIgnoreCase), forbidden);
+ }
+
+ int trustCapability = source.LastIndexOf(
+ "New-ProtectedLocalTestTrustCapability",
+ StringComparison.Ordinal);
+ int transactionAdmission = source.LastIndexOf(
+ "Invoke-JoinedNativeProcess", StringComparison.Ordinal);
+ Assert.IsTrue(trustCapability >= 0 &&
+ transactionAdmission > trustCapability,
+ "The parent-bound native trust capability must be issued before transaction admission.");
+ StringAssert.Contains(source,
+ "-not $script:transactionStarted");
+ Assert.IsFalse(source.Contains("& $stagedBroker @arguments",
+ StringComparison.Ordinal));
+
+ int finalStageCleanup = source.LastIndexOf(
+ "Remove-ProtectedStage -StagePath $stagePath",
+ StringComparison.Ordinal);
+ int finalOutcomePublication = source.LastIndexOf(
+ "Write-StructuredOutcome -RequestedOperation $Operation -ExitCode $exitCode",
+ StringComparison.Ordinal);
+ Assert.IsTrue(finalStageCleanup >= 0 &&
+ finalOutcomePublication > finalStageCleanup,
+ "Success must be published only after protected stage cleanup succeeds.");
+
+ string setupManager = File.ReadAllText(Path.Combine(root,
+ "DS4Windows", "DS4Control", "Viiper",
+ "ViiperSetupManager.cs"));
+ StringAssert.Contains(setupManager,
+ "portable runtime never elevates");
+ StringAssert.Contains(setupManager,
+ "machine-installed, signed maintenance entry");
+ Assert.IsFalse(setupManager.Contains(
+ "LaunchPackageManager", StringComparison.Ordinal));
+ Assert.IsFalse(setupManager.Contains(
+ "FileName = \"powershell.exe\"", StringComparison.Ordinal));
+ Assert.IsFalse(setupManager.Contains(
+ "Verb = \"runas\"", StringComparison.Ordinal));
+
+ string welcomeDialog = File.ReadAllText(Path.Combine(root,
+ "DS4Windows", "DS4Forms", "WelcomeDialog.xaml.cs"));
+ StringAssert.Contains(welcomeDialog,
+ "portable DS4Windows runtime does not download or");
+ StringAssert.Contains(welcomeDialog,
+ "OpenExternalDriverReleasePage");
+ Assert.IsFalse(welcomeDialog.Contains(
+ "GetByteArrayAsync", StringComparison.Ordinal));
+ Assert.IsFalse(welcomeDialog.Contains(
+ "WriteAllBytesAsync", StringComparison.Ordinal));
+ Assert.IsFalse(welcomeDialog.Contains(
+ "Verb = \"runas\"", StringComparison.Ordinal));
+
+ string updaterViewModel = File.ReadAllText(Path.Combine(root,
+ "DS4Windows", "DS4Forms", "ViewModels",
+ "MainWindowsViewModel.cs"));
+ StringAssert.Contains(updaterViewModel,
+ "portable, user-writable process is not an update trust root");
+ Assert.IsFalse(updaterViewModel.Contains(
+ "DS4Updater/releases/download", StringComparison.Ordinal));
+ Assert.IsFalse(updaterViewModel.Contains(
+ "ElevatedCopyUpdater", StringComparison.Ordinal));
+ Assert.IsFalse(updaterViewModel.Contains(
+ "Verb = \"runas\"", StringComparison.Ordinal));
+
+ string controlService = File.ReadAllText(Path.Combine(root,
+ "DS4Windows", "DS4Control", "ControlService.cs"));
+ int elevationHandler = controlService.IndexOf(
+ "private void DS4Devices_RequestElevation",
+ StringComparison.Ordinal);
+ int nextMethod = controlService.IndexOf(
+ "public void CheckHidHidePresence", elevationHandler,
+ StringComparison.Ordinal);
+ Assert.IsTrue(elevationHandler >= 0 && nextMethod > elevationHandler);
+ string elevationBody = controlService.Substring(elevationHandler,
+ nextMethod - elevationHandler);
+ StringAssert.Contains(elevationBody,
+ "signed, machine-installed maintenance entry");
+ Assert.IsFalse(elevationBody.Contains(
+ "Process.Start", StringComparison.Ordinal));
+ Assert.IsFalse(elevationBody.Contains(
+ "runas", StringComparison.OrdinalIgnoreCase));
+
+ string utilSource = File.ReadAllText(Path.Combine(root,
+ "DS4Windows", "DS4Control", "Util.cs"));
+ Assert.IsFalse(utilSource.Contains(
+ "ElevatedCopyUpdater", StringComparison.Ordinal));
+ Assert.IsFalse(utilSource.Contains(
+ "updatercopy.bat", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [TestMethod]
+ public void ReleaseWorkflowRequiresProductionRuntimeBeforePublish()
+ {
+ string root = FindRepositoryRoot();
+ string release = File.ReadAllText(Path.Combine(root, ".github",
+ "workflows", "release.yml"));
+ StringAssert.Contains(release,
+ "Test-ViiperNativePackageContract.ps1 -RequireProduction");
+
+ string project = File.ReadAllText(Path.Combine(root,
+ "DS4Windows", "DS4WinWPF.csproj"));
+ StringAssert.Contains(project,
+ "ViiperNativeRuntimeMetadata.json");
+ StringAssert.Contains(project,
+ "manage-viiper-native-package.ps1");
+ Assert.IsFalse(project.Contains("install-viiper-backend.ps1",
+ StringComparison.OrdinalIgnoreCase));
+
+ string generator = File.ReadAllText(Path.Combine(root,
+ "extras", "New-ViiperNativeRuntimeMetadata.ps1"));
+ StringAssert.Contains(generator, "$ControllerContractPath");
+ StringAssert.Contains(generator,
+ "[string]$submission.driverPackageVersion");
+ string contract = File.ReadAllText(Path.Combine(root,
+ "extras", "Test-ViiperNativePackageContract.ps1"));
+ StringAssert.Contains(contract, "[switch]$RequirePackage");
+ string documentation = File.ReadAllText(Path.Combine(root,
+ "docs", "viiper-backend-upgrade-path.md"));
+ StringAssert.Contains(documentation,
+ "Test-ViiperNativePackageContract.ps1 -RequirePackage");
+ Assert.IsFalse(System.Text.RegularExpressions.Regex.IsMatch(
+ generator, @"(? new()
+ {
+ MetadataFound = true,
+ MetadataEligible = true,
+ PackageBundleFound = true,
+ BrokerInstalled = true,
+ BrokerHashMatches = true,
+ BrokerServiceInstalled = true,
+ BrokerServiceConfigured = true,
+ BrokerServiceRunning = true,
+ CredentialReadable = true,
+ AuthenticatedPingSucceeded = true,
+ RuntimeContractCompatible = true,
+ SetupScriptFound = true,
+ };
+
+ private static string FindRepositoryRoot()
+ {
+ foreach (string startingPoint in new[]
+ {
+ Environment.CurrentDirectory,
+ AppContext.BaseDirectory,
+ })
+ {
+ DirectoryInfo cursor = new DirectoryInfo(startingPoint);
+ while (cursor != null)
+ {
+ if (File.Exists(Path.Combine(cursor.FullName,
+ "DS4WindowsWPF.sln")))
+ {
+ return cursor.FullName;
+ }
+ cursor = cursor.Parent;
+ }
+ }
+ Assert.Fail("Could not locate the DS4Windows repository root.");
+ return null;
+ }
+ }
+}
diff --git a/DS4WindowsTests/ViiperPnPOwnershipTests.cs b/DS4WindowsTests/ViiperPnPOwnershipTests.cs
new file mode 100644
index 0000000..18a89ee
--- /dev/null
+++ b/DS4WindowsTests/ViiperPnPOwnershipTests.cs
@@ -0,0 +1,350 @@
+using DS4Windows;
+
+namespace DS4WindowsTests
+{
+ [TestClass]
+ public class ViiperPnPOwnershipTests
+ {
+ private const string NativeRoot = @"ROOT\VIIPERUDE\0000";
+ private const ulong NativeSession = 0xA11CE;
+
+ [TestMethod]
+ public void NativeAncestryResolvesTheExactCompositeUsbParent()
+ {
+ ViiperPnPAncestryNode[] ancestry =
+ {
+ new(@"HID\VID_054C&PID_0CE6&MI_03\8&GAMEPAD",
+ @"USB\VID_054C&PID_0CE6&MI_03\7&INTERFACE",
+ new[] { @"HID_DEVICE_SYSTEM_GAME" }, string.Empty),
+ new(@"USB\VID_054C&PID_0CE6&MI_03\7&INTERFACE",
+ @"USB\VID_054C&PID_0CE6\6&CONTROLLER_A",
+ new[] { @"USB\Class_03&SubClass_00" }, string.Empty),
+ new(@"USB\VID_054C&PID_0CE6\6&CONTROLLER_A",
+ @"USB\ROOT_HUB30\5&HUB",
+ new[] { @"USB\VID_054C&PID_0CE6" },
+ "Port_#0002.Hub_#0001"),
+ new(@"USB\ROOT_HUB30\5&HUB", NativeRoot,
+ new[] { @"USB\ROOT_HUB30" }, string.Empty),
+ new(NativeRoot, @"HTREE\ROOT\0",
+ new[] { @"ROOT\VIIPERUDE" }, string.Empty),
+ };
+
+ Assert.IsTrue(Global.TryClassifyViiperPnPAncestry(ancestry,
+ out ViiperPnPTopologyIdentity topology));
+ Assert.AreEqual(ViiperPnPTransport.NativeUdeCx,
+ topology.Transport);
+ Assert.AreEqual(NativeRoot, topology.RootInstanceId);
+ Assert.AreEqual(@"USB\VID_054C&PID_0CE6\6&CONTROLLER_A",
+ topology.UsbDeviceInstanceId);
+ Assert.AreEqual(2, topology.UsbPortNumber);
+ }
+
+ [TestMethod]
+ public void SimilarRootNameIsNotClassifiedAsViiperNativeUde()
+ {
+ ViiperPnPAncestryNode[] ancestry =
+ {
+ new(@"HID\VID_054C&PID_0CE6&MI_03\8&GAMEPAD",
+ @"USB\VID_054C&PID_0CE6\6&CONTROLLER",
+ new[] { @"HID_DEVICE_SYSTEM_GAME" }, string.Empty),
+ new(@"USB\VID_054C&PID_0CE6\6&CONTROLLER",
+ @"ROOT\VIIPERUDE_FAKE\0000",
+ new[] { @"USB\VID_054C&PID_0CE6" },
+ "Port_#0002.Hub_#0001"),
+ new(@"ROOT\VIIPERUDE_FAKE\0000", @"HTREE\ROOT\0",
+ new[] { @"ROOT\UNRELATED" }, string.Empty),
+ };
+
+ Assert.IsFalse(Global.TryClassifyViiperPnPAncestry(ancestry,
+ out ViiperPnPTopologyIdentity topology));
+ Assert.AreEqual(ViiperPnPTransport.Unknown,
+ topology.Transport);
+ }
+
+ [TestMethod]
+ public void TwoNativeControllersAndPhysicalSonyRemainDisjoint()
+ {
+ var table = new ViiperPnPOwnershipTable();
+ int firstToken = table.AllocateToken();
+ int secondToken = table.AllocateToken();
+ var first = new ViiperPnPCorrelation(
+ ViiperPnPTransport.NativeUdeCx, 0x100000001, 11,
+ NativeSession,
+ NativeRoot,
+ @"USB\VID_054C&PID_0CE6\6&CONTROLLER_A", 1);
+ var second = new ViiperPnPCorrelation(
+ ViiperPnPTransport.NativeUdeCx, 0x100000002, 12,
+ NativeSession,
+ NativeRoot,
+ @"USB\VID_054C&PID_0CE6\6&CONTROLLER_B", 2);
+
+ Assert.IsTrue(table.Publish(firstToken, first));
+ Assert.IsTrue(table.Publish(secondToken, second));
+
+ var firstHid = new ViiperPnPTopologyIdentity(
+ ViiperPnPTransport.NativeUdeCx, NativeRoot,
+ @"USB\VID_054C&PID_0CE6\6&CONTROLLER_A", 1);
+ var secondAudio = new ViiperPnPTopologyIdentity(
+ ViiperPnPTransport.NativeUdeCx, NativeRoot,
+ @"USB\VID_054C&PID_0CE6\6&CONTROLLER_B", 2);
+ var physicalSony = new ViiperPnPTopologyIdentity(
+ ViiperPnPTransport.Unknown,
+ @"PCI\VEN_1022&DEV_43F7\USB_CONTROLLER",
+ @"USB\VID_054C&PID_0CE6\5&PHYSICAL", 4);
+
+ Assert.IsTrue(table.Matches(firstToken, firstHid));
+ Assert.IsFalse(table.Matches(firstToken, secondAudio));
+ Assert.IsTrue(table.Matches(secondToken, secondAudio));
+ Assert.IsFalse(table.Matches(secondToken, firstHid));
+ Assert.IsFalse(table.Matches(firstToken, physicalSony));
+ Assert.IsFalse(table.Matches(secondToken, physicalSony));
+ }
+
+ [TestMethod]
+ public void OwnHidRejectionRequiresARegisteredExactIdentity()
+ {
+ var table = new ViiperPnPOwnershipTable();
+ int token = table.AllocateToken();
+ var ownHid = new ViiperPnPTopologyIdentity(
+ ViiperPnPTransport.NativeUdeCx, NativeRoot,
+ @"USB\VID_054C&PID_09CC\6&OWN_DS4", 3);
+
+ Assert.IsFalse(table.Matches(-1, ownHid));
+ Assert.IsFalse(table.Matches(0, ownHid));
+ Assert.IsFalse(table.Publish(token, new ViiperPnPCorrelation(
+ ViiperPnPTransport.NativeUdeCx, 0x100000003, 0,
+ NativeSession,
+ NativeRoot,
+ @"USB\VID_054C&PID_09CC\6&OWN_DS4", 3)));
+ Assert.IsFalse(table.Publish(token, new ViiperPnPCorrelation(
+ ViiperPnPTransport.NativeUdeCx, 0x100000003, 21, 0,
+ NativeRoot,
+ @"USB\VID_054C&PID_09CC\6&OWN_DS4", 3)));
+ Assert.IsFalse(table.Matches(token, ownHid));
+
+ Assert.IsTrue(table.Publish(token, new ViiperPnPCorrelation(
+ ViiperPnPTransport.NativeUdeCx, 0x100000003, 21,
+ NativeSession,
+ NativeRoot,
+ @"USB\VID_054C&PID_09CC\6&OWN_DS4", 3)));
+ Assert.IsTrue(table.Matches(token, ownHid));
+ Assert.IsFalse(table.Matches(token,
+ new ViiperPnPTopologyIdentity(
+ ViiperPnPTransport.NativeUdeCx, NativeRoot,
+ @"USB\VID_054C&PID_09CC\6&OWN_DS4", 4)));
+
+ var samePersonaDifferentDevice = new ViiperPnPTopologyIdentity(
+ ViiperPnPTransport.NativeUdeCx, NativeRoot,
+ @"USB\VID_054C&PID_09CC\6&OTHER_DS4", 4);
+ Assert.IsFalse(table.Matches(token, samePersonaDifferentDevice));
+ }
+
+ [TestMethod]
+ public void EndpointReconnectAdvancesGenerationAndRetiresOldAnchor()
+ {
+ var table = new ViiperPnPOwnershipTable();
+ int token = table.AllocateToken();
+ var generationSeven = new ViiperPnPCorrelation(
+ ViiperPnPTransport.NativeUdeCx, 0x200000001, 7,
+ NativeSession,
+ NativeRoot, string.Empty, 1);
+ var generationEight = new ViiperPnPCorrelation(
+ ViiperPnPTransport.NativeUdeCx, 0x200000001, 8,
+ NativeSession,
+ NativeRoot, string.Empty, 2);
+
+ Assert.IsTrue(table.Publish(token, generationSeven));
+ Assert.IsTrue(table.Publish(token, generationEight));
+ Assert.IsFalse(table.Publish(token, generationSeven));
+
+ Assert.IsFalse(table.Matches(token,
+ new ViiperPnPTopologyIdentity(
+ ViiperPnPTransport.NativeUdeCx, NativeRoot,
+ @"USB\VID_054C&PID_0DF2\6&EDGE_OLD", 1)));
+ Assert.IsTrue(table.Matches(token,
+ new ViiperPnPTopologyIdentity(
+ ViiperPnPTransport.NativeUdeCx, NativeRoot,
+ @"USB\VID_054C&PID_0DF2\6&EDGE_NEW", 2)));
+ }
+
+ [TestMethod]
+ public void SameGenerationCannotBeReboundToAnotherEndpoint()
+ {
+ var table = new ViiperPnPOwnershipTable();
+ int token = table.AllocateToken();
+ var original = new ViiperPnPCorrelation(
+ ViiperPnPTransport.NativeUdeCx, 0x300000001, 4,
+ NativeSession,
+ NativeRoot,
+ @"USB\VID_054C&PID_0CE6\6&ORIGINAL", 1);
+ var conflicting = new ViiperPnPCorrelation(
+ ViiperPnPTransport.NativeUdeCx, 0x300000001, 4,
+ NativeSession,
+ NativeRoot,
+ @"USB\VID_054C&PID_0CE6\6&CONFLICT", 2);
+
+ Assert.IsTrue(table.Publish(token, original));
+ Assert.IsTrue(table.Publish(token, original));
+ Assert.IsFalse(table.Publish(token, conflicting));
+ Assert.IsTrue(table.Matches(token,
+ new ViiperPnPTopologyIdentity(
+ ViiperPnPTransport.NativeUdeCx, NativeRoot,
+ original.UsbDeviceInstanceId, 1)));
+ }
+
+ [TestMethod]
+ public void RegistryWithdrawsStaleOwnershipAfterConflictingRebind()
+ {
+ var source = new ViiperOutDevice(OutContType.ViiperDualSense,
+ ViiperVirtualDeviceType.DualSense);
+ var original = new ViiperPnPCorrelation(
+ ViiperPnPTransport.NativeUdeCx, 0x310000001, 4,
+ NativeSession,
+ NativeRoot,
+ @"USB\VID_054C&PID_0CE6\6®ISTRY_ORIGINAL", 1);
+ var conflicting = new ViiperPnPCorrelation(
+ ViiperPnPTransport.NativeUdeCx, 0x310000001, 4,
+ NativeSession,
+ NativeRoot,
+ @"USB\VID_054C&PID_0CE6\6®ISTRY_CONFLICT", 2);
+
+ try
+ {
+ int token = ViiperPnPOwnershipRegistry.AttachOrUpdate(source,
+ original);
+ Assert.IsTrue(token > 0);
+ Assert.AreEqual(-1,
+ ViiperPnPOwnershipRegistry.AttachOrUpdate(source,
+ conflicting));
+ Assert.IsFalse(ViiperPnPOwnershipRegistry.Matches(token,
+ new ViiperPnPTopologyIdentity(
+ ViiperPnPTransport.NativeUdeCx, NativeRoot,
+ original.UsbDeviceInstanceId, 1)));
+ }
+ finally
+ {
+ ViiperPnPOwnershipRegistry.Detach(source);
+ }
+ }
+
+ [TestMethod]
+ public void ControllerSessionRestartRotatesTokenAndRetiresCollision()
+ {
+ var source = new ViiperOutDevice(OutContType.ViiperDualSense,
+ ViiperVirtualDeviceType.DualSense);
+ var firstSession = new ViiperPnPCorrelation(
+ ViiperPnPTransport.NativeUdeCx, 0x320000001, 1,
+ 0x1111111111111111, NativeRoot, string.Empty, 2);
+ var restartedSession = new ViiperPnPCorrelation(
+ ViiperPnPTransport.NativeUdeCx, 0x320000001, 1,
+ 0x2222222222222222, NativeRoot, string.Empty, 2);
+ var reusedTopology = new ViiperPnPTopologyIdentity(
+ ViiperPnPTransport.NativeUdeCx, NativeRoot,
+ @"USB\VID_054C&PID_0CE6\6&REUSED_AFTER_RESTART", 2);
+
+ try
+ {
+ int firstToken = ViiperPnPOwnershipRegistry.AttachOrUpdate(
+ source, firstSession);
+ int restartedToken = ViiperPnPOwnershipRegistry.
+ AttachOrUpdate(source, restartedSession);
+
+ Assert.IsTrue(firstToken > 0);
+ Assert.IsTrue(restartedToken > 0);
+ Assert.AreNotEqual(firstToken, restartedToken);
+ Assert.IsFalse(ViiperPnPOwnershipRegistry.Matches(
+ firstToken, reusedTopology));
+ Assert.IsTrue(ViiperPnPOwnershipRegistry.Matches(
+ restartedToken, reusedTopology));
+ }
+ finally
+ {
+ ViiperPnPOwnershipRegistry.Detach(source);
+ }
+ }
+
+ [TestMethod]
+ public void ControllerSessionParticipatesInEqualityHashAndTokenFence()
+ {
+ var table = new ViiperPnPOwnershipTable();
+ int token = table.AllocateToken();
+ var firstSession = new ViiperPnPCorrelation(
+ ViiperPnPTransport.NativeUdeCx, 0x325000001, 1,
+ 0x1111111122222222, NativeRoot, string.Empty, 2);
+ var restartedSession = new ViiperPnPCorrelation(
+ ViiperPnPTransport.NativeUdeCx, 0x325000001, 1,
+ 0x3333333344444444, NativeRoot, string.Empty, 2);
+
+ Assert.IsTrue(firstSession.IsExact);
+ Assert.IsTrue(restartedSession.IsExact);
+ Assert.AreNotEqual(firstSession, restartedSession);
+ Assert.AreNotEqual(firstSession.GetHashCode(),
+ restartedSession.GetHashCode());
+ Assert.IsTrue(table.Publish(token, firstSession));
+ Assert.IsFalse(table.Publish(token, restartedSession));
+ Assert.IsTrue(table.TryGet(token,
+ out ViiperPnPCorrelation published));
+ Assert.AreEqual(firstSession, published);
+ }
+
+ [TestMethod]
+ public void NewNativeDeviceIdRotatesTokenWithinControllerSession()
+ {
+ var source = new ViiperOutDevice(OutContType.ViiperDualSenseEdge,
+ ViiperVirtualDeviceType.DualSenseEdge);
+ var firstDevice = new ViiperPnPCorrelation(
+ ViiperPnPTransport.NativeUdeCx, 0x330000001, 9,
+ NativeSession, NativeRoot, string.Empty, 1);
+ var replacementDevice = new ViiperPnPCorrelation(
+ ViiperPnPTransport.NativeUdeCx, 0x330000002, 1,
+ NativeSession, NativeRoot, string.Empty, 3);
+
+ try
+ {
+ int firstToken = ViiperPnPOwnershipRegistry.AttachOrUpdate(
+ source, firstDevice);
+ int replacementToken = ViiperPnPOwnershipRegistry.
+ AttachOrUpdate(source, replacementDevice);
+
+ Assert.IsTrue(firstToken > 0);
+ Assert.IsTrue(replacementToken > 0);
+ Assert.AreNotEqual(firstToken, replacementToken);
+ Assert.IsFalse(ViiperPnPOwnershipRegistry.Matches(firstToken,
+ new ViiperPnPTopologyIdentity(
+ ViiperPnPTransport.NativeUdeCx, NativeRoot,
+ @"USB\VID_054C&PID_0DF2\6&OLD_DEVICE", 1)));
+ Assert.IsTrue(ViiperPnPOwnershipRegistry.Matches(
+ replacementToken, new ViiperPnPTopologyIdentity(
+ ViiperPnPTransport.NativeUdeCx, NativeRoot,
+ @"USB\VID_054C&PID_0DF2\6&NEW_DEVICE", 3)));
+ }
+ finally
+ {
+ ViiperPnPOwnershipRegistry.Detach(source);
+ }
+ }
+
+ [TestMethod]
+ public void LegacyUsbIpRequiresExplicitOwnerSerialAndExactPort()
+ {
+ var table = new ViiperPnPOwnershipTable();
+ int token = table.AllocateToken();
+ const string legacyRoot = @"ROOT\USB\0001";
+
+ Assert.IsFalse(table.Publish(token, new ViiperPnPCorrelation(
+ ViiperPnPTransport.LegacyUsbIp, 0, 0, 0, legacyRoot,
+ string.Empty, 5)));
+ Assert.IsTrue(table.Publish(token, new ViiperPnPCorrelation(
+ ViiperPnPTransport.LegacyUsbIp, 0, 0, 0, legacyRoot,
+ string.Empty, 5, "VIIPER-ABBA-OWNER")));
+ Assert.IsTrue(table.Matches(token,
+ new ViiperPnPTopologyIdentity(
+ ViiperPnPTransport.LegacyUsbIp, legacyRoot,
+ @"USB\VID_054C&PID_0CE6\6&LEGACY", 5)));
+ Assert.IsFalse(table.Matches(token,
+ new ViiperPnPTopologyIdentity(
+ ViiperPnPTransport.LegacyUsbIp, legacyRoot,
+ @"USB\VID_054C&PID_0CE6\6&UNRELATED", 6)));
+ }
+ }
+}
diff --git a/DS4WindowsWPF.sln b/DS4WindowsWPF.sln
index 627be20..8ef0f41 100644
--- a/DS4WindowsWPF.sln
+++ b/DS4WindowsWPF.sln
@@ -7,6 +7,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DS4WinWPF", "DS4Windows\DS4
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DS4WindowsTests", "DS4WindowsTests\DS4WindowsTests.csproj", "{6BEB9062-56E7-4A0D-9243-BE43F09A711D}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DS4Windows.Bootstrapper", "installer\DS4Windows.Bootstrapper\DS4Windows.Bootstrapper.csproj", "{37C3BD54-92AB-46CE-B05E-5450800209F0}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DS4Windows.SetupActions", "installer\DS4Windows.SetupActions\DS4Windows.SetupActions.csproj", "{E070E2FB-BD27-46E1-9990-442612240D2A}"
+EndProject
+Project("{930C7802-8A8C-48F9-8165-68863BCCD9DD}") = "DS4Windows.Package", "installer\DS4Windows.Package\DS4Windows.Package.wixproj", "{A189C577-7B58-49C1-949F-435BE1AB9DA2}"
+EndProject
+Project("{930C7802-8A8C-48F9-8165-68863BCCD9DD}") = "DS4Windows.Bundle", "installer\DS4Windows.Bundle\DS4Windows.Bundle.wixproj", "{88B15C8C-18BE-41D6-A0DE-1BFF73402565}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|x64 = Debug|x64
@@ -29,6 +37,26 @@ Global
{6BEB9062-56E7-4A0D-9243-BE43F09A711D}.Debug|x86.Build.0 = Debug|x86
{6BEB9062-56E7-4A0D-9243-BE43F09A711D}.Release|x64.ActiveCfg = Release|x64
{6BEB9062-56E7-4A0D-9243-BE43F09A711D}.Release|x86.ActiveCfg = Release|x86
+ {37C3BD54-92AB-46CE-B05E-5450800209F0}.Debug|x64.ActiveCfg = Debug|x64
+ {37C3BD54-92AB-46CE-B05E-5450800209F0}.Debug|x64.Build.0 = Debug|x64
+ {37C3BD54-92AB-46CE-B05E-5450800209F0}.Debug|x86.ActiveCfg = Debug|x64
+ {37C3BD54-92AB-46CE-B05E-5450800209F0}.Release|x64.ActiveCfg = Release|x64
+ {37C3BD54-92AB-46CE-B05E-5450800209F0}.Release|x64.Build.0 = Release|x64
+ {37C3BD54-92AB-46CE-B05E-5450800209F0}.Release|x86.ActiveCfg = Release|x64
+ {E070E2FB-BD27-46E1-9990-442612240D2A}.Debug|x64.ActiveCfg = Debug|x64
+ {E070E2FB-BD27-46E1-9990-442612240D2A}.Debug|x64.Build.0 = Debug|x64
+ {E070E2FB-BD27-46E1-9990-442612240D2A}.Debug|x86.ActiveCfg = Debug|x64
+ {E070E2FB-BD27-46E1-9990-442612240D2A}.Release|x64.ActiveCfg = Release|x64
+ {E070E2FB-BD27-46E1-9990-442612240D2A}.Release|x64.Build.0 = Release|x64
+ {E070E2FB-BD27-46E1-9990-442612240D2A}.Release|x86.ActiveCfg = Release|x64
+ {A189C577-7B58-49C1-949F-435BE1AB9DA2}.Debug|x64.ActiveCfg = Debug|x64
+ {A189C577-7B58-49C1-949F-435BE1AB9DA2}.Debug|x86.ActiveCfg = Debug|x64
+ {A189C577-7B58-49C1-949F-435BE1AB9DA2}.Release|x64.ActiveCfg = Release|x64
+ {A189C577-7B58-49C1-949F-435BE1AB9DA2}.Release|x86.ActiveCfg = Release|x64
+ {88B15C8C-18BE-41D6-A0DE-1BFF73402565}.Debug|x64.ActiveCfg = Debug|x64
+ {88B15C8C-18BE-41D6-A0DE-1BFF73402565}.Debug|x86.ActiveCfg = Debug|x64
+ {88B15C8C-18BE-41D6-A0DE-1BFF73402565}.Release|x64.ActiveCfg = Release|x64
+ {88B15C8C-18BE-41D6-A0DE-1BFF73402565}.Release|x86.ActiveCfg = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/README.md b/README.md
index 02d0531..57fce8c 100644
--- a/README.md
+++ b/README.md
@@ -26,10 +26,10 @@ Most users should start with the stable build.
4. Run `DS4Windows.exe`. Do not run it from inside the ZIP archive.
5. Complete the first-run driver prompts, connect a controller, and select or create a profile.
-For a one-file stable installer, download and run
-[`ds4w.bat`](https://raw.githubusercontent.com/hbashton/DS4Windows/main/ds4w.bat).
-It installs the latest stable hbashton release to `%LOCALAPPDATA%\DS4Windows`
-and creates a desktop shortcut.
+The legacy `ds4w.bat` downloader is retired: it did not provide the signature,
+hash, provenance, transactional update, or uninstall guarantees required for a
+driver-capable application. Use the release ZIP until the signed DS4Windows
+installer is published; never run an unverified third-party installer script.
### DualSense and VIIPER preview
@@ -44,26 +44,48 @@ features.
> x86 DS4Windows build. Install the x64 DS4Windows package on a 64-bit Windows
> system before enabling any VIIPER output profile.
-After installing a VIIPER-capable DS4Windows build:
+After installing a production VIIPER-capable DS4Windows build:
1. Open **Settings**.
-2. Under **VIIPER Virtual Controller Support**, click **Install / Repair VIIPER**.
-3. Accept the administrator prompt. The setup installs the hbashton VIIPER
- backend and the required `usbip-win2` driver.
-4. Restart Windows if the setup installed or updated `usbip-win2`.
+2. Under **VIIPER Native Virtual Controller Support**, click
+ **Install / Repair Native UDE**.
+3. Accept the administrator prompt. The setup verifies and installs the exact
+ bundled UdeCx driver, registers the `VIIPERNativeBroker` LocalSystem
+ service, provisions its protected per-user API credential, and verifies an
+ authenticated native identity ping.
+4. Restart Windows only when the transactional setup reports the structured
+ `rebootRequired` outcome, then rerun the same repair once.
5. Edit a profile and select **DualSense**, **DualSense Edge**, **DualShock 4**,
**Xbox 360**, or **Switch 2 Pro**. VIIPER is the backend for every virtual
controller type; it is not repeated in the device names.
-The installer also registers a hidden `RunVIIPER` task at sign-in. It starts
-the backend elevated without a recurring console or UAC popup. DS4Windows
-checks the backend at startup, starts it when possible, and opens a guided,
-self-elevating repair flow when VIIPER or usbip-win2 is missing.
+Native VIIPER does not install a `RunVIIPER` task or start a per-user server.
+Windows Service Control Manager owns the broker lifecycle. DS4Windows requires
+the exact bundled metadata, protected credential, authenticated ping, ABI,
+capability mask, driver package version, and loaded-driver build identity
+before it creates a controller.
+
+The metadata also binds the exact VIIPER controller API and descriptor family:
+DualShock 4 fixed/V3 streams, DualSense V5 streams, and DualSense Edge V5
+streams with their existing full, audio-only, or HID-only interfaces and exact
+VID/PID identities. DS4Windows does not guess an older type name or silently
+substitute a different HID/audio descriptor when that contract disagrees.
+
+Current checked-in metadata records a verified local-test package only; it is
+not production media. CI builds therefore show truthful status and block the
+normal install flow unless a release job replaces that evidence with an exact
+Microsoft HLK/WHCP-signed runtime bundle. Local-test media is accepted only
+with `DS4WINDOWS_VIIPER_ALLOW_LOCAL_TEST=1` plus the package manager's explicit
+disposable-machine switches. Use that route only on a snapshot-capable VM or a
+wipeable spare Windows 11 machine; the exact commands and rollback boundary are
+documented in [the VIIPER backend architecture](docs/viiper-backend-upgrade-path.md#disposable-windows-11-validation).
The matching VIIPER backend is published at
-[hbashton/VIIPER](https://github.com/hbashton/VIIPER). Use DS4Windows' built-in
-installer when possible so the backend and driver are placed and started
-correctly.
+[hbashton/VIIPER](https://github.com/hbashton/VIIPER). Production setup belongs
+to the signed DS4Windows installer or its machine-installed signed maintenance
+entry. The portable runtime never elevates mutable scripts, metadata, or
+package files. Local-test setup remains an explicit manual operation on a
+disposable machine.
## What this fork adds
@@ -236,7 +258,10 @@ and backend tools remain available under the advanced sections.
- [Microsoft Visual C++ 2015-2022 Redistributable](https://aka.ms/vs/17/release/vc_redist.x64.exe).
- [HidHide](https://github.com/nefarius/HidHide) is strongly recommended to
prevent games from seeing both the physical and virtual controllers.
-- `usbip-win2` and [hbashton/VIIPER](https://github.com/hbashton/VIIPER), installed through the built-in guided setup.
+- A matching [hbashton/VIIPER](https://github.com/hbashton/VIIPER) native UDE
+ production bundle, installed through the built-in guided setup. The release
+ bundle must contain its exact Microsoft HLK/WHCP-signed driver media; the app
+ will not download or substitute a driver.
Supported physical inputs include first-party DualShock 4, DualSense,
DualSense Edge, DualShock 3, Switch Pro, and Joy-Con controllers. Some compatible
@@ -256,8 +281,10 @@ rejects its own VIIPER outputs to prevent recursive virtual controllers.
5. Keep **Hide DS4 Controller** enabled when a game would otherwise see both the real and virtual devices.
6. Disable overlapping PlayStation or Xbox remapping in Steam for games managed entirely by DS4Windows.
-Xbox Game Bar companion support requires DS4Windows to run elevated. VIIPER and
-HidHide setup may also require administrator approval.
+Xbox Game Bar companion support requires DS4Windows to run elevated. VIIPER
+native package installation/removal and HidHide setup also require
+administrator approval; day-to-day DS4Windows-to-broker traffic is
+authenticated with the protected user-readable credential.
## Updating
@@ -273,7 +300,7 @@ For a manual update:
Profiles and logs are stored separately under `%APPDATA%\DS4Windows`, so
replacing the application folder does not normally remove user profiles. When
-updating a VIIPER preview, run **Install / Repair VIIPER** again if the release
+updating a VIIPER preview, run **Install / Repair Native UDE** again if the release
notes call for a matching backend update.
## Troubleshooting
@@ -282,8 +309,9 @@ notes call for a matching backend update.
administrator, and confirm the physical controller is hidden while the
virtual controller remains visible.
- **A VIIPER profile will not create an output:** open **Settings**, refresh the
- VIIPER status, run **Install / Repair VIIPER**, and reboot once if
- `usbip-win2` was installed.
+ VIIPER status, run **Install / Repair Native UDE**, and inspect the package,
+ service, authenticated-ping, and driver-identity fields. Reboot only when
+ setup explicitly reports `rebootRequired`.
- **Controller speaker or microphone is missing:** confirm you are using matching
DS4Windows and VIIPER preview releases and that the profile uses a VIIPER
DualSense, DualSense Edge, or DualShock 4 output.
@@ -305,6 +333,12 @@ requests should keep stable behavior intact when adding preview backends and
should include focused tests for profile persistence, controller state, or
transport changes where practical.
+The normal Windows path is native UDE. The historical USB/IP route remains an
+explicit developer/ABBA validation mode only: set
+`DS4WINDOWS_VIIPER_TRANSPORT=usbip` and provision its prerequisites separately.
+It is never selected as an install fallback, does not reuse native readiness,
+and must not be used for native latency claims.
+
## License
DS4Windows is licensed under the GNU General Public License version 3. See
diff --git a/docs/INSTALLER_VALIDATION_STRATEGY.md b/docs/INSTALLER_VALIDATION_STRATEGY.md
new file mode 100644
index 0000000..c8dc622
--- /dev/null
+++ b/docs/INSTALLER_VALIDATION_STRATEGY.md
@@ -0,0 +1,163 @@
+# Signed native installer validation strategy
+
+The production installer is a per-machine WiX/Burn transaction. It contains no
+portable elevation route and performs no network retrieval. Its only privileged
+native-backend caller is the signed `DS4Windows.SetupActions.exe` cached by
+Burn.
+
+## Trust and ownership boundary
+
+1. Burn captures the initiating interactive SID before elevation. A successful
+ native install records that same SID in 64-bit HKLM. Repair, upgrade,
+ reboot-resume, and direct uninstall restore it; they never replace it with
+ an over-the-shoulder administrator.
+2. The MSI installs the application, package manifest, native metadata,
+ manager, and complete native package under
+ `C:\Program Files\DS4Windows`.
+3. The elevated helper rejects a reparse path, an untrusted owner, or any
+ install-media write grant outside trusted installer principals. It always verifies the manager
+ and metadata against the deterministic MSI manifest. Install/repair also
+ verifies the application executable and exact native package inventory;
+ uninstall delegates its exact broker/helper media checks to the already
+ verified native manager so a damaged unrelated app/package file cannot make
+ the machine permanently unremovable.
+4. The helper invokes only the installed manager with `-Operation Install`,
+ `-Operation Uninstall`, and the exact persisted SID. Repair maps to Install.
+ Production never passes local-test switches.
+5. The helper requires exactly one
+ `DS4WINDOWS_VIIPER_NATIVE_RESULT` JSON record. Schema, operation, process
+ exit, success, reboot, rollback, and manual-recovery values must agree.
+6. VIIPER alone owns service, broker image, driver package, credential ACL,
+ legacy-owner retirement, durable journal, rollback, readiness, and exact
+ cleanup.
+ The manager creates its ProgramData transaction stage atomically with a
+ protected SYSTEM/Administrators DACL, locks and hashes the source broker
+ through its exact copy, and holds a deny-write/delete handle on the hashed
+ staged broker through process creation and join. The installed service
+ broker is exact `C:\Program Files\VIIPER\viiper.exe`; only its credential
+ and log live under ProgramData.
+7. Exit `3010` is accepted only with a safely-settled receipt. Burn persists
+ its plan and SID and resumes the same protected transaction after restart.
+8. Burn's reverse uninstall order runs a tail preflight and native removal
+ helper before the MSI removes Program Files. If later MSI removal fails,
+ the helper package's rollback direction calls native Install to restore the
+ backend.
+
+## Locally provable gates
+
+These gates run without changing machine driver or service state:
+
+- Windows PowerShell 5.1 and PowerShell 7 parse and run
+ `Test-ViiperNativePackageContract.ps1`.
+- Both runtimes run `Test-InstallerSecurityContracts.ps1`, which exercises
+ default Program Files ReadAndExecute ACLs, atomic protected-directory
+ creation, and deterministic source-copy and hash-to-launch write/delete
+ races without starting the broker or changing machine service/driver state.
+- Release composition additionally requires `-RequireProduction
+ -RequirePackage`, exact package inventory, and a Microsoft WHCP catalog
+ signer.
+- x64 DS4Windows tests pass.
+- Both .NET Framework 4.8 installer executables compile with zero warnings.
+- The deterministic package generator rejects reparse points, path escapes,
+ and case-insensitive duplicates, then binds every publish file by length and
+ SHA-256.
+- Source validation enforces the exact five-package chain, per-machine MSI and
+ common shortcuts, minimal persisted variables, protected manager arguments,
+ reverse uninstall order, SID preservation, one-plan concurrency gate, and
+ absence of online or portable execution paths.
+- State-machine tests cover clean install, idempotent repair, deferred related
+ upgrade, isolated native recovery, outgoing related uninstall suppression,
+ direct uninstall ordering and rollback direction, reboot resume, missing SID
+ failure, alternate-admin maintenance, and valid/invalid structured receipts.
+- The production builder has no skip-signing option. It requires a pinned
+ certificate SHA-256, private key credential, HTTPS timestamp endpoint, and
+ Windows SDK verification. It signs inner application media first, then
+ helper/bootstrapper, MSI, and Burn. Every output must be valid,
+ timestamped, and signed by the pinned certificate before atomic publication.
+- The final sidecar binds the DS4Windows commit, VIIPER source revision, driver
+ package/build identity, inner hashes, signer identity, bundle length, and
+ bundle SHA-256.
+- CI may compile an unsigned synthetic bundle only as a disposable
+ non-release composition test. The production build entry remains
+ fail-closed and such output is never uploaded.
+
+## Offline Windows VM gates
+
+Run these on clean supported Windows 10 and Windows 11 x64 VMs. Use a snapshot
+and do not use a developer workstation as the first live target.
+
+### Identity and install
+
+- Install once as a standard user using same-account UAC.
+- Restore the snapshot and install with over-the-shoulder administrator
+ credentials.
+- Confirm Burn/MSI/helper signatures and Program Files ACLs.
+- Confirm the persisted target SID is the initiating user in both cases.
+- Confirm `VIIPERNativeBroker` is an automatic own-process LocalSystem service
+ with its canonical command, exact manifest-bound broker hash, protected
+ credential owned for the intended SID, authenticated ping, ABI/capability
+ mask, build identity, driver identity, and root device.
+- Confirm the application launches unelevated as the initiating user.
+
+### Repair and tamper containment
+
+Independently tamper with or remove the manager, metadata, native package file,
+manifest, installed broker, service registration, credential, and driver
+device. Repair must either restore the exact production contract or fail before
+untrusted bytes execute. Reparse and low-privilege ACL variants must fail
+closed.
+
+Run two repairs concurrently; exactly one owns the Burn transaction mutex and
+the other returns Windows Installer busy. Terminate setup at each durable
+VIIPER journal phase and verify retry converges without a second owner or
+orphaned device.
+
+### Reboot, upgrade, and uninstall
+
+- Exercise every `3010` boundary. Verify the cached signed bundle resumes with
+ the original SID and does not expose a second confirmation transaction.
+- Upgrade from the preceding signed bundle. The outgoing related bundle must
+ not remove the incoming native backend; the isolated recovery pass must
+ validate the final machine state.
+- Direct uninstall must stop the app, remove the exact native service, broker,
+ credential, driver/root device, transaction stages, and installer markers
+ before MSI media disappears.
+- Inject an MSI-uninstall failure after native teardown and verify Burn rollback
+ reinstalls the native package.
+- Profiles, settings, logs outside installer ownership, plugins, and
+ user-created files remain intact.
+- Free space after uninstall must return to the pre-install baseline within
+ expected Windows Installer/DriverStore bookkeeping. No repeated stage,
+ cache, log, or dump growth is allowed.
+
+## Physical Windows 11 laptop gates
+
+After VM convergence, use the designated laptop for hardware evidence:
+
+- DualShock 4, DualSense, and DualSense Edge input/output;
+- native HID identity and media interfaces, speaker/microphone endpoints,
+ audio passthrough, haptics, lightbar, rumble, adaptive triggers, and battery;
+- wired/Bluetooth reconnect, sleep/resume, broker crash/restart, app
+ crash/restart, rapid plug/unplug, and multiple controllers;
+- DS4Windows start/stop and output-slot lifecycle with no phantom devices;
+- authenticated reconnect after service or machine restart; and
+- repeatable latency/continuity capture compared with the approved reference
+ implementation and acceptance threshold.
+
+No source-only or VM result substitutes for physical controller, audio, sleep,
+or latency evidence.
+
+## External release evidence
+
+Repository tests cannot create or prove these artifacts:
+
+- Windows Hardware Lab Kit results on every supported Windows release;
+- Microsoft Hardware Dashboard/WHCP signature and submission provenance for
+ the exact INF/SYS/CAT shipped by the installer;
+- production Authenticode certificate custody, timestamp service response, and
+ release-worker audit record; and
+- GitHub artifact attestation and immutable tag-to-commit provenance.
+
+A public installer is blocked until all local, VM, laptop, and external gates
+are attached to the same DS4Windows commit, VIIPER source revision, native
+metadata hash, package manifest hash, and final bundle SHA-256.
diff --git a/docs/viiper-backend-upgrade-path.md b/docs/viiper-backend-upgrade-path.md
index a035614..c0dd5fe 100644
--- a/docs/viiper-backend-upgrade-path.md
+++ b/docs/viiper-backend-upgrade-path.md
@@ -1,17 +1,110 @@
# VIIPER backend architecture
-VIIPER is DS4Windows' only virtual-controller backend. It exposes Xbox 360,
-DualShock 4, DualSense, DualSense Edge, and Switch 2 Pro devices through
-usbip-win2 as complete USB devices, including the applicable Sony audio
-interfaces.
+VIIPER is DS4Windows' virtual-controller backend. The normal Windows path uses
+the VIIPER UdeCx driver to expose Xbox 360, DualShock 4, DualSense, DualSense
+Edge, and Switch 2 Pro devices as complete local USB devices, including the
+applicable Sony audio interfaces.
## User setup
-DS4Windows checks VIIPER and usbip-win2 at startup. When either component is
-missing, the app offers its bundled self-elevating setup. Setup installs both
-components, registers a hidden `RunVIIPER` logon task, starts the server, and
-verifies its local API. Settings also provides Install / Repair and Refresh
-actions.
+DS4Windows admits native output only when all of these checks pass:
+
+1. Bundled `ViiperNativeRuntimeMetadata.json` is production eligible, or is
+ explicit local-test evidence with the disposable-machine opt-in.
+2. The installed broker bytes match the manifest-bound SHA-256.
+3. `VIIPERNativeBroker` is an automatic, own-process LocalSystem service with
+ the exact native UDE command line and protected credential/log paths.
+4. The target user can read the protected 16-byte credential.
+5. An authenticated ping reports `transport=native-ude`, ready state, and the
+ exact metadata-bound ABI, capability mask, package version, loaded-driver
+ build identity, and controller instance ID.
+
+The same metadata is source-bound to VIIPER's registered controller handlers,
+not to invented DS4Windows aliases. The current contract uses DS4 fixed/V3
+handlers (`dualshock4`, `dualshock4audioduplexv3`, and
+`dualshock4audioonlyduplexv3`), DualSense V5 combined/audio-only/gamepad
+handlers, and DualSense Edge V5 combined/gamepad handlers. It also binds the
+native descriptor identities (DS4 `054c:09cc`, with DS4Windows' intentional
+`054c:05c4` client override; DualSense `054c:0ce6`; Edge `054c:0df2`) and the
+full, audio-only, or HID-only interface profile for every type. Xbox 360 and
+Switch 2 Pro remain their existing `xbox360` and `ns2pro` handlers. The package
+gate rejects missing, extra, renamed, or descriptor-divergent registrations.
+
+The portable runtime is deliberately not self-elevating. Production setup and
+removal belong to the signed DS4Windows installer or its machine-installed,
+signed maintenance entry; a user-writable portable directory is never treated
+as an elevation trust root. That installer invokes VIIPER's hidden
+`native-package-install` or exact `uninstall --yes` boundary with its
+source-bound media, the interactive target-user SID, and production validation
+mode. Exit `3010` remains a safe reboot boundary; other nonzero outcomes require
+review of the durable transaction/recovery log. The bundled PowerShell manager
+is only the explicit disposable-machine local-test route described below, not a
+production UI elevation mechanism.
+
+The installed broker is owned by Service Control Manager. Native setup never
+creates `RunVIIPER`, launches a per-user server, downloads a backend, installs
+USB/IP, or detaches USB/IP ports.
+
+The repository currently checks in metadata for a verified local-test package
+only and does not check in its test certificate or runtime tree. Normal UI
+installation therefore fails closed. A production release job must place the
+exact Microsoft HLK/WHCP runtime tree under
+`extras/viiper-native-package`, regenerate metadata with
+`New-ViiperNativeRuntimeMetadata.ps1`, update the source-bound
+`ViiperControllerApiContract.json` only when the corresponding VIIPER handlers
+and DS4Windows client change together, and pass
+`Test-ViiperNativePackageContract.ps1 -RequireProduction` before publishing.
+
+### Disposable Windows 11 validation
+
+Do not use the local-test route on a primary laptop. Use a snapshot-capable VM,
+or a spare Windows 11 machine that can be wiped. Place the one exact generated
+local-test package under `extras\viiper-native-package`; do not substitute a
+certificate, broker, helper, INF, SYS, CAT, or submission manifest. First run
+the normal contract gate. Then, from an elevated 64-bit PowerShell in the
+DS4Windows directory, enable Windows test-signing and restart once before the
+first installation:
+
+```powershell
+bcdedit.exe /set testsigning on
+```
+
+Microsoft warns that BCDEdit changes can make a machine unbootable and that
+Secure Boot can reject `TESTSIGNING`; BitLocker can also affect the change.
+On a spare physical laptop, first verify that its BitLocker recovery key is
+available and that you have local console/recovery access. Do not disable
+Secure Boot or alter BitLocker on a primary machine merely to make this test
+work—use the VM route instead. See Microsoft's
+[test-signed driver guidance](https://learn.microsoft.com/windows-hardware/drivers/install/the-testsigning-boot-configuration-option).
+
+After the restart, verify `bcdedit.exe /enum '{current}'` reports
+`testsigning Yes`, then make the two independent local-test acknowledgements
+explicit:
+
+```powershell
+& .\extras\Test-ViiperNativePackageContract.ps1 -RequirePackage
+$targetSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
+$env:DS4WINDOWS_VIIPER_ALLOW_LOCAL_TEST = '1'
+& .\extras\manage-viiper-native-package.ps1 `
+ -Operation Install `
+ -TargetUserSID $targetSid `
+ -AllowLocalTest `
+ -AcknowledgeDisposableTestMachine
+```
+
+The manager admits only the metadata-bound public `ViiperUdeTest.cer`. Before
+the driver transaction it verifies the exact bytes and installs them in the
+Local Machine `Root` and `TrustedPublisher` stores if absent. If setup fails
+before transaction admission, it removes only certificates it added; once a
+transaction starts, it retains trust so the durable recovery path can still
+load the exact prior/candidate driver. Restoring the VM snapshot or wiping the
+spare machine removes this intentionally persistent test trust.
+
+Preserve the emitted `DS4WINDOWS_VIIPER_NATIVE_RESULT` record. Exit `3010`
+means restart at the declared safe boundary and rerun the identical command;
+it is not permission to improvise a repair. After testing, run the same manager
+with `-Operation Uninstall` and the same local-test acknowledgements, then
+restore the VM snapshot or wipe the spare test machine.
## Profile migration
@@ -26,6 +119,11 @@ enumeration and rejects them as physical inputs. Moonlight/Sunshine virtual
controllers use a separate opt-in admission policy, so accepting streamed
controllers cannot make DS4Windows recursively ingest its own output.
+The historical USB/IP path remains available only for explicit developer/ABBA
+validation through `DS4WINDOWS_VIIPER_TRANSPORT=usbip`. It has separate
+prerequisites and ownership, is never an automatic fallback, and cannot satisfy
+native readiness or performance evidence.
+
## Feedback and audio
VIIPER feedback is read by `ViiperOutDevice` and routed to the currently bound
diff --git a/ds4w.bat b/ds4w.bat
index 9a18460..574d92f 100644
--- a/ds4w.bat
+++ b/ds4w.bat
@@ -1,81 +1,9 @@
@echo off
-setlocal enabledelayedexpansion
-
-:: Define the installation path in AppData local folder
-set INSTALL_PATH=%LOCALAPPDATA%\DS4Windows
-
-:: Prompt for version choice
-set /p VERSION_CHOICE="Do you want to install the latest version? (y/n): "
-if /i "%VERSION_CHOICE%"=="y" (
- :: Get the latest version using GitHub API
- for /f "delims=" %%A in ('powershell -Command "(Invoke-WebRequest -Uri 'https://api.github.com/repos/hbashton/DS4Windows/releases/latest' -Headers @{ 'User-Agent' = 'DS4Windows-Installer' }).Content | ConvertFrom-Json | Select-Object -ExpandProperty tag_name"') do (
- set LATEST_VERSION=%%A
- )
-
- :: Ensure the version starts with 'v' and remove only the leading 'v'
- if "!LATEST_VERSION:~0,1!"=="v" (
- set LATEST_VERSION=!LATEST_VERSION:~1!
- )
-
- echo Latest version is !LATEST_VERSION!.
- set VERSION=!LATEST_VERSION!
-) else (
- :: Prompt for specific version
- :prompt_version
- set /p VERSION="Enter the version (e.g., x.x.x): "
- if "!VERSION!"=="" (
- echo Version cannot be empty. Please try again.
- goto prompt_version
- )
-)
-
-:: Prompt for architecture
-:prompt_arch
-set /p ARCH="Enter the architecture (x64 or x86, default is x64): "
-if "%ARCH%"=="" (
- set ARCH=x64
-)
-
-set BASE_URL=https://github.com/hbashton/DS4Windows/releases/download
-set DOWNLOAD_URL=%BASE_URL%/v!VERSION!/DS4Windows_!VERSION!_!ARCH!.zip
-
-:: Download the file
-echo Downloading !DOWNLOAD_URL! ...
-powershell -Command "Invoke-WebRequest -Uri '!DOWNLOAD_URL!' -OutFile 'DS4Windows_!VERSION!_!ARCH!.zip'"
-
-if %errorLevel% neq 0 (
- echo Download failed. Please check the version and architecture.
- exit /b
-)
-
-echo Download completed.
-
-:: Remove the previous version if it exists
-if exist "%INSTALL_PATH%" (
- echo Removing previous version from %INSTALL_PATH%...
- rmdir /S /Q "%INSTALL_PATH%"
-)
-
-:: Unpack the ZIP file
-echo Unpacking the ZIP file...
-powershell -Command "Expand-Archive -Path 'DS4Windows_!VERSION!_!ARCH!.zip' -DestinationPath 'DS4Windows'"
-
-:: Move the folder to AppData local
-echo Moving DS4Windows folder to %INSTALL_PATH%...
-move /Y "DS4Windows\DS4Windows" "%INSTALL_PATH%"
-
-:: Create a shortcut on the desktop
-set SHORTCUT_PATH="%USERPROFILE%\Desktop\DS4Windows.lnk"
-powershell -Command "$s = New-Object -COMObject WScript.Shell; $shortcut = $s.CreateShortcut('%SHORTCUT_PATH%'); $shortcut.TargetPath = '%INSTALL_PATH%\DS4Windows.exe'; $shortcut.IconLocation = '%INSTALL_PATH%\DS4Windows.exe'; $shortcut.Save()"
-
-:: Clean up downloaded and unpacked files
-echo Cleaning up...
-del /Q "DS4Windows_!VERSION!_!ARCH!.zip"
-rmdir /S /Q "DS4Windows"
-
-echo Installation completed.
-
-:: Wait for user input before closing
-echo Press any key to exit...
-pause >nul
-endlocal
+echo This legacy downloader has been retired because it cannot authenticate or
+echo transactionally install a driver-capable DS4Windows release.
+echo.
+echo Download the x64 ZIP or signed installer directly from:
+echo https://github.com/hbashton/DS4Windows/releases/latest
+echo.
+echo Verify the publisher/signature and release provenance before running it.
+exit /b 1
diff --git a/extras/New-ViiperNativeRuntimeMetadata.ps1 b/extras/New-ViiperNativeRuntimeMetadata.ps1
new file mode 100644
index 0000000..a491660
--- /dev/null
+++ b/extras/New-ViiperNativeRuntimeMetadata.ps1
@@ -0,0 +1,245 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)][string]$PackageRoot,
+ [ValidateSet('production', 'local-test-evidence-only')]
+ [string]$ReleaseEligibility = 'production',
+ [string]$ControllerContractPath =
+ (Join-Path $PSScriptRoot 'ViiperControllerApiContract.json'),
+ [string]$OutputPath = (Join-Path $PSScriptRoot 'ViiperNativeRuntimeMetadata.json')
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$root = (Resolve-Path -LiteralPath $PackageRoot -ErrorAction Stop).Path.TrimEnd('\')
+$rootItem = Get-Item -LiteralPath $root -Force
+if (-not $rootItem.PSIsContainer -or
+ ($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ throw 'PackageRoot must be an ordinary directory.'
+}
+
+$manifestPath = Join-Path $root 'submission-manifest.json'
+$submission = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 |
+ ConvertFrom-Json -ErrorAction Stop
+$sourceRevision = [string]$submission.sourceRevision
+$packageVersion = [string]$submission.driverPackageVersion
+$abiMajor = [int]$submission.driverABIMajor
+$abiMinor = [int]$submission.driverABIMinor
+$capabilitiesHex = ([string]$submission.driverCapabilities).ToLowerInvariant()
+$buildIdentity = ([string]$submission.driverBuildIdentity).ToLowerInvariant()
+if ($sourceRevision -cnotmatch '^[0-9a-f]{40}$|^[0-9a-f]{64}$' -or
+ $packageVersion -cnotmatch '^[0-9]+(?:\.[0-9]+){3}$' -or
+ $abiMajor -le 0 -or $abiMajor -gt 65535 -or
+ $abiMinor -lt 0 -or $abiMinor -gt 65535 -or
+ $capabilitiesHex -cnotmatch '^0x[0-9a-f]{8}$' -or
+ $buildIdentity -cnotmatch '^[0-9a-f]{64}$') {
+ throw 'The source-bound submission manifest has invalid native identity fields.'
+}
+$capabilities = [Convert]::ToUInt32($capabilitiesHex.Substring(2), 16)
+if ($capabilities -eq 0) {
+ throw 'The source-bound submission manifest advertises no native capabilities.'
+}
+
+$controllerContractFile = (Resolve-Path -LiteralPath $ControllerContractPath -ErrorAction Stop).Path
+$controllerContractItem = Get-Item -LiteralPath $controllerContractFile -Force
+if ($controllerContractItem.PSIsContainer -or
+ ($controllerContractItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ throw 'ControllerContractPath must be an ordinary JSON file.'
+}
+$controllerContractInput = Get-Content -LiteralPath $controllerContractFile -Raw -Encoding UTF8 |
+ ConvertFrom-Json -ErrorAction Stop
+if ([int]$controllerContractInput.schemaVersion -ne 1 -or
+ [string]$controllerContractInput.sourceRevision -cne $sourceRevision -or
+ [string]::IsNullOrWhiteSpace(
+ [string]$controllerContractInput.implementation)) {
+ throw 'The controller API contract is not bound to this VIIPER source revision.'
+}
+
+$registrations = [Collections.Generic.List[object]]::new()
+$seenTypes = [Collections.Generic.HashSet[string]]::new(
+ [StringComparer]::Ordinal)
+$seenPersonas = [Collections.Generic.HashSet[string]]::new(
+ [StringComparer]::Ordinal)
+foreach ($registration in @($controllerContractInput.registrations)) {
+ $type = [string]$registration.type
+ $persona = [string]$registration.persona
+ $defaultVid = [string]$registration.defaultVid
+ $defaultPid = [string]$registration.defaultPid
+ $ds4WindowsPid = [string]$registration.ds4WindowsPid
+ $interfaceProfile = [string]$registration.interfaceProfile
+ $streamProtocol = [string]$registration.streamProtocol
+ if ($type -cnotmatch '^[a-z0-9]+$' -or -not $seenTypes.Add($type) -or
+ $persona -cnotmatch '^[a-z0-9]+(?:-[a-z0-9]+)*$' -or
+ $defaultVid -cnotmatch '^0x[0-9a-f]{4}$' -or
+ $defaultPid -cnotmatch '^0x[0-9a-f]{4}$' -or
+ $ds4WindowsPid -cnotmatch '^0x[0-9a-f]{4}$' -or
+ $interfaceProfile -cnotmatch '^[a-z0-9]+(?:-[a-z0-9]+)*$' -or
+ $streamProtocol -cnotmatch '^(?:fixed|framed-v[1-9][0-9]*)$') {
+ throw "Invalid or duplicate controller registration '$type'."
+ }
+ [void]$seenPersonas.Add($persona)
+ $registrations.Add([ordered]@{
+ type = $type
+ persona = $persona
+ defaultVid = $defaultVid
+ defaultPid = $defaultPid
+ ds4WindowsPid = $ds4WindowsPid
+ interfaceProfile = $interfaceProfile
+ streamProtocol = $streamProtocol
+ })
+}
+foreach ($requiredPersona in @('xbox360', 'dualshock4', 'dualsense',
+ 'dualsense-edge', 'switch2-pro')) {
+ if (-not $seenPersonas.Contains($requiredPersona)) {
+ throw "Controller API contract omits required DS4Windows persona '$requiredPersona'."
+ }
+}
+$controllerContract = [ordered]@{
+ schemaVersion = 1
+ sourceRevision = $sourceRevision
+ implementation = [string]$controllerContractInput.implementation
+ registrations = $registrations
+}
+
+if ($ReleaseEligibility -ceq 'production') {
+ if ($submission.releaseEligible -ne $true -or
+ [string]$submission.signingRoute -notmatch 'HLK|WHCP|Microsoft') {
+ throw 'Production metadata requires a release-eligible HLK/WHCP submission manifest.'
+ }
+ if (Test-Path -LiteralPath (Join-Path $root 'ViiperUdeTest.cer')) {
+ throw 'Production package roots must not contain the local-test certificate.'
+ }
+} elseif ($submission.releaseEligible -ne $false -or
+ [string]$submission.signingRoute -cne 'LocalTest') {
+ throw 'Local-test metadata requires the non-release-eligible LocalTest submission manifest.'
+}
+
+$definitions = @(
+ @{ role = 'broker'; path = 'viiper.exe'; required = $true },
+ @{ role = 'driver-helper'; path = 'ViiperUdeCtl.exe'; required = $true },
+ @{ role = 'media-probe'; path = 'ViiperUdeMediaProbe.exe'; required = $false },
+ @{ role = 'input-probe'; path = 'ViiperUdeInputProbe.exe'; required = $false },
+ @{ role = 'live-probe-manifest'; path = 'ViiperUdeLiveProbes.manifest.json'; required = $false },
+ @{ role = 'submission-manifest'; path = 'submission-manifest.json'; required = $true },
+ @{ role = 'driver-inf'; path = 'driver/ViiperUde.inf'; required = $true },
+ @{ role = 'driver-sys'; path = 'driver/ViiperUde.sys'; required = $true },
+ @{ role = 'driver-cat'; path = 'driver/ViiperUde.cat'; required = $true },
+ @{ role = 'driver-pdb'; path = 'signed-package/ViiperUde.pdb'; required = $false },
+ @{ role = 'signed-driver-inf'; path = 'signed-package/ViiperUde.inf'; required = $false },
+ @{ role = 'signed-driver-sys'; path = 'signed-package/ViiperUde.sys'; required = $false },
+ @{ role = 'signed-driver-cat'; path = 'signed-package/ViiperUde.cat'; required = $false }
+)
+if ($ReleaseEligibility -ceq 'local-test-evidence-only') {
+ $definitions += @(
+ @{
+ role = 'local-test-package-lock'
+ path = 'local-test-package.lock.json'
+ required = $true
+ },
+ @{
+ role = 'local-test-certificate-evidence'
+ path = 'ViiperUdeTest.cer'
+ required = $true
+ }
+ )
+}
+
+$artifacts = [Collections.Generic.List[object]]::new()
+foreach ($definition in $definitions) {
+ $relative = [string]$definition.path
+ $path = Join-Path $root $relative
+ if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
+ if ([bool]$definition.required) {
+ throw "Required native package artifact is missing: '$path'."
+ }
+ continue
+ }
+ $item = Get-Item -LiteralPath $path -Force
+ if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ throw "Native package artifacts must not be reparse points: '$path'."
+ }
+ $artifacts.Add([ordered]@{
+ role = [string]$definition.role
+ relativePath = 'viiper-native-package/' + $relative.Replace('\', '/')
+ length = [long]$item.Length
+ sha256 = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant()
+ })
+}
+
+if ($ReleaseEligibility -ceq 'local-test-evidence-only') {
+ $lockPath = Join-Path $root 'local-test-package.lock.json'
+ $lock = Get-Content -LiteralPath $lockPath -Raw -Encoding UTF8 |
+ ConvertFrom-Json -ErrorAction Stop
+ $certificateArtifact = @($artifacts | Where-Object {
+ [string]$_.role -ceq 'local-test-certificate-evidence'
+ })
+ if ([int]$lock.schema -ne 1 -or
+ [string]$lock.sourceRevision -cne $sourceRevision -or
+ [string]$lock.driverPackageVersion -cne $packageVersion -or
+ [string]$lock.driverBuildIdentity -cne $buildIdentity -or
+ $certificateArtifact.Count -ne 1 -or
+ [string]$lock.testSignerCertificateSha256 -cne
+ [string]$certificateArtifact[0].sha256) {
+ throw 'Local-test package lock disagrees with the source-bound submission metadata.'
+ }
+ $lockFiles = @($lock.files)
+ $boundArtifacts = @($artifacts | Where-Object {
+ [string]$_.role -cne 'local-test-package-lock'
+ })
+ if ($lockFiles.Count -ne $boundArtifacts.Count) {
+ throw 'Local-test package lock does not bind the complete package inventory.'
+ }
+ foreach ($artifact in $boundArtifacts) {
+ $relativePath = ([string]$artifact.relativePath).Substring(
+ 'viiper-native-package/'.Length)
+ $matches = @($lockFiles | Where-Object {
+ [string]$_.path -ceq $relativePath
+ })
+ if ($matches.Count -ne 1 -or
+ [long]$matches[0].length -ne [long]$artifact.length -or
+ [string]$matches[0].sha256 -cne [string]$artifact.sha256) {
+ throw "Local-test package lock disagrees with '$relativePath'."
+ }
+ }
+}
+
+$metadata = [ordered]@{
+ schemaVersion = 1
+ releaseEligibility = $ReleaseEligibility
+ localTestOptInEnvironment = 'DS4WINDOWS_VIIPER_ALLOW_LOCAL_TEST'
+ sourceRevision = $sourceRevision
+ driverPackageVersion = $packageVersion
+ driverAbi = [ordered]@{ major = $abiMajor; minor = $abiMinor }
+ requiredCapabilities = $capabilities
+ requiredCapabilitiesHex = $capabilitiesHex
+ loadedDriverBuildIdentity = $buildIdentity
+ productionSigningRoute = 'HLK/WHCP dashboard signing'
+ managedBroker = [ordered]@{
+ serviceName = 'VIIPERNativeBroker'
+ serviceAccount = 'LocalSystem'
+ startMode = 'automatic'
+ transport = 'native-ude'
+ apiHost = '127.0.0.1'
+ apiPort = 3242
+ credentialPath = '%ProgramData%/VIIPER/viiper.key.txt'
+ }
+ controllerApiContract = $controllerContract
+ artifacts = $artifacts
+}
+
+$output = [IO.Path]::GetFullPath($OutputPath)
+$outputDirectory = Split-Path -Parent $output
+[IO.Directory]::CreateDirectory($outputDirectory) | Out-Null
+$temporary = Join-Path $outputDirectory (
+ '.' + [IO.Path]::GetFileName($output) + '.' + [Guid]::NewGuid().ToString('N') + '.tmp')
+try {
+ $json = $metadata | ConvertTo-Json -Depth 8
+ [IO.File]::WriteAllText($temporary, $json + [Environment]::NewLine,
+ [Text.UTF8Encoding]::new($false))
+ Move-Item -LiteralPath $temporary -Destination $output -Force
+} finally {
+ if (Test-Path -LiteralPath $temporary) {
+ Remove-Item -LiteralPath $temporary -Force
+ }
+}
+Write-Host "Wrote exact VIIPER runtime metadata: $output"
diff --git a/extras/Test-ViiperNativePackageContract.ps1 b/extras/Test-ViiperNativePackageContract.ps1
new file mode 100644
index 0000000..d47f9bf
--- /dev/null
+++ b/extras/Test-ViiperNativePackageContract.ps1
@@ -0,0 +1,432 @@
+[CmdletBinding()]
+param(
+ [switch]$RequireProduction,
+ [switch]$RequirePackage
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$metadataPath = Join-Path $PSScriptRoot 'ViiperNativeRuntimeMetadata.json'
+$managerPath = Join-Path $PSScriptRoot 'manage-viiper-native-package.ps1'
+$generatorPath = Join-Path $PSScriptRoot 'New-ViiperNativeRuntimeMetadata.ps1'
+$controllerContractPath = Join-Path $PSScriptRoot 'ViiperControllerApiContract.json'
+foreach ($path in @($metadataPath, $managerPath, $generatorPath,
+ $controllerContractPath)) {
+ if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
+ throw "Missing native UDE integration input '$path'."
+ }
+}
+
+foreach ($scriptPath in @($managerPath, $generatorPath)) {
+ $parseErrors = $null
+ [void][Management.Automation.Language.Parser]::ParseFile(
+ $scriptPath, [ref]$null, [ref]$parseErrors)
+ if ($parseErrors.Count -ne 0) {
+ throw "Native package script '$scriptPath' has PowerShell syntax errors: $($parseErrors -join '; ')"
+ }
+}
+
+$managerSource = Get-Content -LiteralPath $managerPath -Raw
+foreach ($required in @(
+ 'native-package-install',
+ 'native-package-recover',
+ "'uninstall', '--yes'",
+ '--expected-broker-sha-256',
+ '--expected-helper-sha-256',
+ '--expected-manifest-sha-256',
+ '--expected-inf-sha-256',
+ '--expected-sys-sha-256',
+ '--expected-cat-sha-256',
+ '--target-user-sid',
+ '--driver-validation-mode',
+ 'DS4WINDOWS_VIIPER_NATIVE_RESULT',
+ "'not-started'",
+ "'safely-settled'",
+ "'unverified-see-transaction-log'",
+ 'AcknowledgeDisposableTestMachine',
+ 'local-test-certificate-evidence',
+ 'Assert-LocalTestBootAdmission',
+ 'testsigning\s+Yes',
+ 'Open-VerifiedProtectedCapabilityReadLease',
+ 'New-ProtectedLocalTestTrustCapability',
+ "schema = 'viiper.native.local-test-trust-capability/v1'",
+ 'certificatePath = [IO.Path]::GetFullPath($CertificatePath)',
+ "trustJournalSchema = 'viiper.native.local-test-trust-ownership/v1'",
+ 'trustJournalDirectory = [IO.Path]::GetFullPath($TrustJournalDirectory)',
+ '--local-test-certificate-path',
+ '--expected-local-test-certificate-sha-256',
+ '--expected-local-test-package-lock-sha-256',
+ 'Invoke-JoinedNativeProcess',
+ 'Started ([ref]$processStarted)',
+ '$script:transactionStarted = $processStarted',
+ '$creationStream.Dispose()',
+ '[IO.FileAccess]::Read, [IO.FileShare]::Read',
+ 'Stream = $readLease',
+ 'AggregateException'
+)) {
+ if ($managerSource.IndexOf($required, [StringComparison]::Ordinal) -lt 0) {
+ throw "Native package manager omitted required contract token '$required'."
+ }
+}
+$managerMain = $managerSource.Substring($managerSource.IndexOf(
+ '$programDataRoot =', [StringComparison]::Ordinal))
+foreach ($forbiddenMutation in @(
+ 'Open-ProtectedTrustManagerLease',
+ 'Enter-LocalTestTrustInstallJournal',
+ 'Enter-LocalTestTrustUninstallJournal',
+ 'Complete-LocalTestTrustJournal',
+ 'Ensure-ExactLocalTestTrust',
+ 'Remove-NewLocalTestTrust',
+ '[Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite',
+ '$store.Add(', '$store.Remove(')) {
+ if ($managerMain.IndexOf($forbiddenMutation,
+ [StringComparison]::Ordinal) -ge 0) {
+ throw "Native package manager main flow still mutates parent-side trust via '$forbiddenMutation'."
+ }
+}
+if ($managerSource.IndexOf('$script:trustCleanupFailed',
+ [StringComparison]::Ordinal) -ge 0) {
+ throw 'Native package manager retained obsolete parent-side trust cleanup state.'
+}
+if ($managerSource.IndexOf('& $stagedBroker @arguments',
+ [StringComparison]::Ordinal) -ge 0) {
+ throw 'Native package manager must distinguish process creation from transaction admission.'
+}
+$finalStageCleanup = $managerSource.LastIndexOf(
+ 'Remove-ProtectedStage -StagePath $stagePath',
+ [StringComparison]::Ordinal)
+$finalOutcomePublication = $managerSource.LastIndexOf(
+ 'Write-StructuredOutcome -RequestedOperation $Operation -ExitCode $exitCode',
+ [StringComparison]::Ordinal)
+if ($finalStageCleanup -lt 0 -or
+ $finalOutcomePublication -le $finalStageCleanup) {
+ throw 'Native package success must be published only after protected stage cleanup succeeds.'
+}
+foreach ($forbidden in @('usbip-win2', 'RunVIIPER', 'Invoke-WebRequest',
+ 'Invoke-RestMethod', 'DownloadFile', 'api.github.com')) {
+ if ($managerSource.IndexOf($forbidden, [StringComparison]::OrdinalIgnoreCase) -ge 0) {
+ throw "Native package manager contains forbidden legacy/download token '$forbidden'."
+ }
+}
+
+# FileShare is reciprocal on Windows. Prove the capability handoff invariant
+# without loading the manager: a write-capable creation handle prevents the
+# eventual read/FileShare.Read lease from opening, while the retained read
+# lease permits readers and denies a new writer.
+$shareContractRoot = Join-Path ([IO.Path]::GetTempPath()) (
+ 'viiper-capability-share-contract-' + [Guid]::NewGuid().ToString('N'))
+[void][IO.Directory]::CreateDirectory($shareContractRoot)
+$shareContractPath = Join-Path $shareContractRoot 'capability.json'
+$creationWriter = $null
+$readLease = $null
+$secondReader = $null
+$unexpectedHandle = $null
+try {
+ $creationWriter = [IO.FileStream]::new(
+ $shareContractPath, [IO.FileMode]::CreateNew,
+ [IO.FileAccess]::Write, [IO.FileShare]::Read)
+ $creationWriter.WriteByte(0x7b)
+ $creationWriter.Flush($true)
+
+ $readWhileWriterRejected = $false
+ try {
+ $unexpectedHandle = [IO.FileStream]::new(
+ $shareContractPath, [IO.FileMode]::Open,
+ [IO.FileAccess]::Read, [IO.FileShare]::Read)
+ }
+ catch [IO.IOException] { $readWhileWriterRejected = $true }
+ finally {
+ if ($null -ne $unexpectedHandle) {
+ $unexpectedHandle.Dispose()
+ $unexpectedHandle = $null
+ }
+ }
+ if (-not $readWhileWriterRejected) {
+ throw 'Capability read lease opened before the write-capable creation handle closed.'
+ }
+
+ $creationWriter.Dispose()
+ $creationWriter = $null
+ $readLease = [IO.FileStream]::new(
+ $shareContractPath, [IO.FileMode]::Open,
+ [IO.FileAccess]::Read, [IO.FileShare]::Read)
+ if (-not $readLease.CanRead -or $readLease.CanWrite -or
+ $readLease.Position -ne 0) {
+ throw 'Capability lease is not a read-only stream positioned at zero.'
+ }
+
+ $secondReader = [IO.FileStream]::new(
+ $shareContractPath, [IO.FileMode]::Open,
+ [IO.FileAccess]::Read, [IO.FileShare]::Read)
+ if (-not $secondReader.CanRead -or $secondReader.CanWrite) {
+ throw 'Reciprocal read sharing did not admit a second read-only lease.'
+ }
+
+ $writerWhileReadLeaseRejected = $false
+ try {
+ $unexpectedHandle = [IO.FileStream]::new(
+ $shareContractPath, [IO.FileMode]::Open,
+ [IO.FileAccess]::Write, [IO.FileShare]::Read)
+ }
+ catch [IO.IOException] { $writerWhileReadLeaseRejected = $true }
+ finally {
+ if ($null -ne $unexpectedHandle) {
+ $unexpectedHandle.Dispose()
+ $unexpectedHandle = $null
+ }
+ }
+ if (-not $writerWhileReadLeaseRejected) {
+ throw 'Retained capability read lease admitted a write-capable open.'
+ }
+}
+finally {
+ if ($null -ne $secondReader) { $secondReader.Dispose() }
+ if ($null -ne $readLease) { $readLease.Dispose() }
+ if ($null -ne $creationWriter) { $creationWriter.Dispose() }
+ if ($null -ne $unexpectedHandle) { $unexpectedHandle.Dispose() }
+
+ $resolvedShareContractRoot = [IO.Path]::GetFullPath($shareContractRoot)
+ $systemTemporary =
+ [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\'
+ if (-not $resolvedShareContractRoot.StartsWith(
+ $systemTemporary, [StringComparison]::OrdinalIgnoreCase) -or
+ [IO.Path]::GetFileName($resolvedShareContractRoot) -cnotlike
+ 'viiper-capability-share-contract-*') {
+ throw "Refusing to remove unverified share-contract root '$resolvedShareContractRoot'."
+ }
+ Remove-Item -LiteralPath $resolvedShareContractRoot -Recurse -Force
+}
+
+$generatorSource = Get-Content -LiteralPath $generatorPath -Raw
+foreach ($required in @(
+ '[string]$submission.driverPackageVersion',
+ '[int]$submission.driverABIMajor',
+ '[int]$submission.driverABIMinor',
+ '[string]$submission.driverCapabilities',
+ '[string]$submission.driverBuildIdentity',
+ '$ControllerContractPath',
+ 'local-test-package.lock.json'
+)) {
+ if ($generatorSource.IndexOf($required,
+ [StringComparison]::Ordinal) -lt 0) {
+ throw "Native metadata generator omitted dynamic contract token '$required'."
+ }
+}
+if ($generatorSource -match '(?&1 | Out-String)
+ if ($LASTEXITCODE -ne 0 -or
+ $bcdOutput -notmatch '(?im)^\s*testsigning\s+Yes\s*$') {
+ throw "The current boot entry does not report 'testsigning Yes'. Enable TESTSIGNING and reboot before local-test installation.`n$bcdOutput"
+ }
+}
+
+function ConvertTo-WindowsProcessArgument {
+ param([AllowEmptyString()][Parameter(Mandatory = $true)][string]$Value)
+
+ if ($Value.IndexOf([char]0) -ge 0) {
+ throw 'Native process argument contains NUL.'
+ }
+ if ($Value.Length -ne 0 -and $Value -notmatch '[\s"]') {
+ return $Value
+ }
+ $builder = [Text.StringBuilder]::new()
+ [void]$builder.Append([char]34)
+ $slashes = 0
+ foreach ($character in $Value.ToCharArray()) {
+ if ($character -eq [char]92) {
+ ++$slashes
+ continue
+ }
+ if ($character -eq [char]34) {
+ [void]$builder.Append([char]92, (2 * $slashes) + 1)
+ [void]$builder.Append([char]34)
+ $slashes = 0
+ continue
+ }
+ if ($slashes -ne 0) {
+ [void]$builder.Append([char]92, $slashes)
+ $slashes = 0
+ }
+ [void]$builder.Append($character)
+ }
+ if ($slashes -ne 0) {
+ [void]$builder.Append([char]92, 2 * $slashes)
+ }
+ [void]$builder.Append([char]34)
+ return $builder.ToString()
+}
+
+function Set-ExactProcessArguments {
+ param(
+ [Parameter(Mandatory = $true)][Diagnostics.ProcessStartInfo]$StartInfo,
+ [Parameter(Mandatory = $true)][string[]]$Arguments
+ )
+
+ if ($null -ne $StartInfo.PSObject.Properties['ArgumentList']) {
+ foreach ($argument in $Arguments) {
+ $StartInfo.ArgumentList.Add($argument)
+ }
+ return
+ }
+ $StartInfo.Arguments = (($Arguments | ForEach-Object {
+ ConvertTo-WindowsProcessArgument -Value $_
+ }) -join ' ')
+}
+
+function Invoke-JoinedNativeProcess {
+ param(
+ [Parameter(Mandatory = $true)][string]$FileName,
+ [Parameter(Mandatory = $true)][string[]]$Arguments,
+ [Parameter(Mandatory = $true)][string]$WorkingDirectory,
+ [Parameter(Mandatory = $true)][ref]$Started
+ )
+
+ $Started.Value = $false
+ $startInfo = [Diagnostics.ProcessStartInfo]::new()
+ $startInfo.FileName = $FileName
+ $startInfo.WorkingDirectory = $WorkingDirectory
+ $startInfo.UseShellExecute = $false
+ $startInfo.CreateNoWindow = $true
+ $startInfo.RedirectStandardOutput = $true
+ $startInfo.RedirectStandardError = $true
+ Set-ExactProcessArguments -StartInfo $startInfo -Arguments $Arguments
+ $process = [Diagnostics.Process]::new()
+ $process.StartInfo = $startInfo
+ $joined = $false
+ try {
+ if (-not $process.Start()) {
+ throw 'The protected native broker process was not created.'
+ }
+ $Started.Value = $true
+ $stdoutTask = $process.StandardOutput.ReadToEndAsync()
+ $stderrTask = $process.StandardError.ReadToEndAsync()
+ while (-not $joined) {
+ try {
+ $process.WaitForExit()
+ $joined = $true
+ }
+ catch {
+ # Never unwind while the exact mutating child may remain alive.
+ Start-Sleep -Milliseconds 250
+ }
+ }
+ $stdout = $stdoutTask.GetAwaiter().GetResult()
+ $stderr = $stderrTask.GetAwaiter().GetResult()
+ $combined = @($stdout, $stderr) -join [Environment]::NewLine
+ return [pscustomobject]@{
+ ExitCode = $process.ExitCode
+ Output = @($combined -split '\r?\n' | Where-Object {
+ $_.Length -ne 0
+ })
+ }
+ }
+ finally {
+ if ($Started.Value -and -not $joined) {
+ while (-not $joined) {
+ try {
+ $process.WaitForExit()
+ $joined = $true
+ }
+ catch {
+ Start-Sleep -Milliseconds 250
+ }
+ }
+ }
+ $process.Dispose()
+ }
+}
+
+function Resolve-SingleMetadataPath {
+ $outputRoot = Split-Path -Parent $PSScriptRoot
+ $candidates = @(
+ (Join-Path $outputRoot 'ViiperNativeRuntimeMetadata.json'),
+ (Join-Path $PSScriptRoot 'ViiperNativeRuntimeMetadata.json')
+ ) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf }
+
+ $resolved = @($candidates | ForEach-Object {
+ (Resolve-Path -LiteralPath $_ -ErrorAction Stop).Path
+ } | Select-Object -Unique)
+ if ($resolved.Count -ne 1) {
+ throw "Expected exactly one bundled ViiperNativeRuntimeMetadata.json; found $($resolved.Count)."
+ }
+ $item = Get-Item -LiteralPath $resolved[0] -Force
+ if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ throw "Runtime metadata must not be a reparse point: '$($item.FullName)'."
+ }
+ return $item.FullName
+}
+
+function Get-UniqueArtifact {
+ param(
+ [Parameter(Mandatory = $true)]$Metadata,
+ [Parameter(Mandatory = $true)][string]$Role
+ )
+
+ $matches = @($Metadata.artifacts | Where-Object { [string]$_.role -ceq $Role })
+ if ($matches.Count -ne 1) {
+ throw "Runtime metadata must contain exactly one '$Role' artifact; found $($matches.Count)."
+ }
+ return $matches[0]
+}
+
+function Assert-NoReparseDirectoryChain {
+ param(
+ [Parameter(Mandatory = $true)][string]$Root,
+ [Parameter(Mandatory = $true)][string]$FilePath
+ )
+
+ $rootPath = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+ $cursor = Split-Path -Parent ([IO.Path]::GetFullPath($FilePath))
+ while ($cursor.Length -ge $rootPath.Length) {
+ $item = Get-Item -LiteralPath $cursor -Force -ErrorAction Stop
+ if (-not $item.PSIsContainer -or
+ ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ throw "Native package directory chain is not an ordinary directory: '$cursor'."
+ }
+ if ($cursor -ceq $rootPath) {
+ return
+ }
+ $parent = Split-Path -Parent $cursor
+ if ($parent -ceq $cursor) {
+ break
+ }
+ $cursor = $parent
+ }
+ throw "Artifact escaped the native package root: '$FilePath'."
+}
+
+function Resolve-VerifiedArtifact {
+ param(
+ [Parameter(Mandatory = $true)]$Artifact,
+ [Parameter(Mandatory = $true)][string]$PackageRoot
+ )
+
+ $relativePath = [string]$Artifact.relativePath
+ $sha256 = ([string]$Artifact.sha256).ToLowerInvariant()
+ $expectedLength = [long]$Artifact.length
+ if ([string]::IsNullOrWhiteSpace($relativePath) -or
+ [IO.Path]::IsPathRooted($relativePath) -or
+ $relativePath.IndexOf([char]0) -ge 0 -or
+ $sha256 -cnotmatch '^[0-9a-f]{64}$' -or
+ $expectedLength -le 0) {
+ throw "Artifact '$([string]$Artifact.role)' has invalid path, length, or SHA-256 metadata."
+ }
+
+ $root = [IO.Path]::GetFullPath($PackageRoot).TrimEnd('\')
+ $candidate = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot $relativePath))
+ $rootPrefix = $root + [IO.Path]::DirectorySeparatorChar
+ if (-not $candidate.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) {
+ throw "Artifact '$relativePath' escapes '$root'."
+ }
+ Assert-NoReparseDirectoryChain -Root $root -FilePath $candidate
+ $item = Get-Item -LiteralPath $candidate -Force -ErrorAction Stop
+ if ($item.PSIsContainer -or
+ ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ throw "Artifact must be an ordinary file: '$candidate'."
+ }
+ if ($item.Length -ne $expectedLength) {
+ throw "Artifact '$relativePath' length is $($item.Length); expected $expectedLength."
+ }
+ $actualHash = (Get-FileHash -LiteralPath $candidate -Algorithm SHA256).Hash.ToLowerInvariant()
+ if ($actualHash -cne $sha256) {
+ throw "Artifact '$relativePath' SHA-256 is $actualHash; expected $sha256."
+ }
+ return $item.FullName
+}
+
+function Write-StructuredOutcome {
+ param(
+ [Parameter(Mandatory = $true)][string]$RequestedOperation,
+ [Parameter(Mandatory = $true)][int]$ExitCode
+ )
+
+ $outcome = [ordered]@{
+ schemaVersion = 1
+ operation = $RequestedOperation.ToLowerInvariant()
+ exitCode = $ExitCode
+ succeeded = ($ExitCode -eq 0)
+ rebootRequired = ($ExitCode -eq 3010)
+ rollbackStatus = if ($ExitCode -eq 0) {
+ 'not-required'
+ } elseif ($ExitCode -eq 3010) {
+ 'safely-settled'
+ } elseif (-not $script:transactionStarted) {
+ 'not-started'
+ } else {
+ 'unverified-see-transaction-log'
+ }
+ manualRecoveryRequired = $script:transactionStarted -and
+ $ExitCode -notin @(0, 3010)
+ }
+ Write-Host ('DS4WINDOWS_VIIPER_NATIVE_RESULT ' +
+ ($outcome | ConvertTo-Json -Compress))
+ $script:structuredOutcomeWritten = $true
+}
+
+function Initialize-ProtectedStage {
+ param([Parameter(Mandatory = $true)][string]$ProgramDataRoot)
+
+ $programData = [IO.Path]::GetFullPath($ProgramDataRoot).TrimEnd('\')
+ $programDataItem = Get-Item -LiteralPath $programData -Force
+ if (-not $programDataItem.PSIsContainer -or
+ ($programDataItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ throw "ProgramData is not a safe staging parent: '$programData'."
+ }
+ $stage = Join-Path $programData (
+ 'VIIPER.DS4WindowsStage.' + [Guid]::NewGuid().ToString('N'))
+ if (Test-Path -LiteralPath $stage) {
+ throw "Refusing to reuse protected staging directory '$stage'."
+ }
+
+ # Apply the protected DACL as part of directory creation. Creating with
+ # inherited ProgramData permissions and tightening them afterward leaves
+ # a write/reparse race before Set-Acl. Windows PowerShell exposes the
+ # Directory.CreateDirectory ACL overload; modern PowerShell exposes the
+ # equivalent FileSystemAclExtensions API.
+ $expectedSecurity = [Security.AccessControl.DirectorySecurity]::new()
+ $expectedSecurity.SetSecurityDescriptorSddlForm(
+ 'O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)',
+ [Security.AccessControl.AccessControlSections]::All)
+ if ($PSVersionTable.PSEdition -ceq 'Desktop') {
+ $directory = [IO.Directory]::CreateDirectory(
+ $stage, $expectedSecurity)
+ $directory.SetAccessControl($expectedSecurity)
+ } else {
+ $directory = [IO.DirectoryInfo]::new($stage)
+ [IO.FileSystemAclExtensions]::Create(
+ $directory, $expectedSecurity)
+ [IO.FileSystemAclExtensions]::SetAccessControl(
+ $directory, $expectedSecurity)
+ }
+ Assert-ProtectedStage -StagePath $stage
+ return $stage
+}
+
+function Assert-ProtectedStage {
+ param([Parameter(Mandatory = $true)][string]$StagePath)
+
+ $directory = Get-Item -LiteralPath $StagePath -Force -ErrorAction Stop
+ if (-not $directory.PSIsContainer -or
+ ($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ throw "Protected staging directory is missing, not a directory, or a reparse point: '$StagePath'."
+ }
+ $security = Get-Acl -LiteralPath $directory.FullName
+ if (-not $security.AreAccessRulesProtected) {
+ throw "Protected staging directory inherited an unsafe DACL: '$StagePath'."
+ }
+ $owner = $security.GetOwner(
+ [Security.Principal.SecurityIdentifier]).Value
+ if ($owner -cne 'S-1-5-32-544') {
+ throw "Protected staging directory has an unexpected owner: '$StagePath'."
+ }
+ $rules = @($security.GetAccessRules(
+ $true, $true, [Security.Principal.SecurityIdentifier]))
+ if ($rules.Count -ne 2) {
+ throw "Protected staging directory has an unexpected access-rule count: '$StagePath'."
+ }
+ $expectedInheritance =
+ [Security.AccessControl.InheritanceFlags]::ContainerInherit -bor
+ [Security.AccessControl.InheritanceFlags]::ObjectInherit
+ foreach ($expectedSid in @('S-1-5-18', 'S-1-5-32-544')) {
+ $matches = @($rules | Where-Object {
+ $_.IdentityReference.Value -ceq $expectedSid
+ })
+ if ($matches.Count -ne 1) {
+ throw "Protected staging directory is missing an exact trusted principal: '$StagePath'."
+ }
+ $rule = $matches[0]
+ if ($rule.IsInherited -or
+ $rule.AccessControlType -ne
+ [Security.AccessControl.AccessControlType]::Allow -or
+ $rule.FileSystemRights -ne
+ [Security.AccessControl.FileSystemRights]::FullControl -or
+ $rule.InheritanceFlags -ne $expectedInheritance -or
+ $rule.PropagationFlags -ne
+ [Security.AccessControl.PropagationFlags]::None) {
+ throw "Protected staging directory has an unexpected access rule: '$StagePath'."
+ }
+ }
+}
+
+function Open-VerifiedStagedBroker {
+ param(
+ [Parameter(Mandatory = $true)][string]$SourcePath,
+ [Parameter(Mandatory = $true)][string]$DestinationDirectory,
+ [Parameter(Mandatory = $true)][long]$ExpectedLength,
+ [Parameter(Mandatory = $true)][string]$ExpectedSHA256
+ )
+
+ $destinationPath = Join-Path $DestinationDirectory 'viiper.exe'
+ $sourceStream = [IO.FileStream]::new(
+ $SourcePath, [IO.FileMode]::Open, [IO.FileAccess]::Read,
+ [IO.FileShare]::Read)
+ try {
+ if ($sourceStream.Length -ne $ExpectedLength) {
+ throw 'The manifest-bound broker changed before protected staging.'
+ }
+ $sourceAlgorithm = [Security.Cryptography.SHA256]::Create()
+ try {
+ $sourceDigest = ([BitConverter]::ToString(
+ $sourceAlgorithm.ComputeHash($sourceStream))).Replace(
+ '-', '').ToLowerInvariant()
+ }
+ finally {
+ $sourceAlgorithm.Dispose()
+ }
+ if ($sourceDigest -cne $ExpectedSHA256) {
+ throw 'The manifest-bound broker changed before protected staging.'
+ }
+ $sourceStream.Position = 0
+ $destinationStream = [IO.FileStream]::new(
+ $destinationPath, [IO.FileMode]::CreateNew,
+ [IO.FileAccess]::Write, [IO.FileShare]::None,
+ 1MB, [IO.FileOptions]::WriteThrough)
+ try {
+ $sourceStream.CopyTo($destinationStream)
+ $destinationStream.Flush($true)
+ }
+ finally {
+ $destinationStream.Dispose()
+ }
+ }
+ finally {
+ $sourceStream.Dispose()
+ }
+
+ $launchLock = $null
+ try {
+ # Hash the same open file object that remains locked through process
+ # creation and join. FileShare.Read lets the image loader read it but
+ # denies write and delete/rename opens, closing the hash-to-launch
+ # pathname race.
+ $launchLock = [IO.FileStream]::new(
+ $destinationPath, [IO.FileMode]::Open,
+ [IO.FileAccess]::Read, [IO.FileShare]::Read)
+ $staged = Get-Item -LiteralPath $destinationPath -Force -ErrorAction Stop
+ if ($staged.PSIsContainer -or
+ ($staged.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
+ $launchLock.Length -ne $ExpectedLength) {
+ throw 'The protected staged broker is not the expected ordinary file.'
+ }
+ $stagedAlgorithm = [Security.Cryptography.SHA256]::Create()
+ try {
+ $stagedDigest = ([BitConverter]::ToString(
+ $stagedAlgorithm.ComputeHash($launchLock))).Replace(
+ '-', '').ToLowerInvariant()
+ }
+ finally {
+ $stagedAlgorithm.Dispose()
+ }
+ if ($stagedDigest -cne $ExpectedSHA256) {
+ throw 'The protected staged broker failed exact verification.'
+ }
+ $launchLock.Position = 0
+ return [pscustomobject]@{
+ Path = $destinationPath
+ LaunchLock = $launchLock
+ }
+ }
+ catch {
+ if ($null -ne $launchLock) {
+ $launchLock.Dispose()
+ }
+ throw
+ }
+}
+
+function Open-VerifiedProtectedCapabilityReadLease {
+ param(
+ [Parameter(Mandatory = $true)][string]$Path,
+ [Parameter(Mandatory = $true)][string]$StagePath,
+ [Parameter(Mandatory = $true)][long]$ExpectedLength,
+ [Parameter(Mandatory = $true)][string]$ExpectedSHA256
+ )
+
+ $expectedPath = [IO.Path]::GetFullPath($Path)
+ $expectedStage = [IO.Path]::GetFullPath($StagePath).TrimEnd('\')
+ if (-not [string]::Equals(
+ [IO.Path]::GetFullPath((Split-Path -Parent $expectedPath)),
+ $expectedStage, [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'Protected capability path escaped its exact staging directory.'
+ }
+
+ $readLease = $null
+ try {
+ # The creation handle is intentionally gone before this open. This
+ # retained handle is read-only, permits only reciprocal readers, and
+ # therefore denies subsequent write/delete/rename opens through the
+ # joined child lifetime without lending the child a write capability.
+ $readLease = [IO.FileStream]::new(
+ $expectedPath, [IO.FileMode]::Open,
+ [IO.FileAccess]::Read, [IO.FileShare]::Read)
+
+ $handlePath = [ViiperLocalTestTrustLeaseNative]::FinalPath(
+ $readLease.SafeFileHandle)
+ if ($handlePath.StartsWith(
+ '\\?\UNC\', [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'Protected capability unexpectedly resolved to a UNC path.'
+ }
+ if ($handlePath.StartsWith('\\?\', [StringComparison]::Ordinal)) {
+ $handlePath = $handlePath.Substring(4)
+ }
+
+ Assert-ProtectedStage -StagePath $expectedStage
+ Assert-ExactProtectedTrustObjectSecurity `
+ -Path $expectedPath -Directory $false
+ $item = Get-Item -LiteralPath $expectedPath -Force -ErrorAction Stop
+ $itemPath = [IO.Path]::GetFullPath($item.FullName)
+ if ($item.PSIsContainer -or
+ ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
+ -not [string]::Equals(
+ $itemPath, $expectedPath,
+ [StringComparison]::OrdinalIgnoreCase) -or
+ -not [string]::Equals(
+ [IO.Path]::GetFullPath($handlePath), $expectedPath,
+ [StringComparison]::OrdinalIgnoreCase) -or
+ -not [string]::Equals(
+ [IO.Path]::GetFullPath($item.DirectoryName), $expectedStage,
+ [StringComparison]::OrdinalIgnoreCase) -or
+ $item.Length -ne $ExpectedLength -or
+ $readLease.Length -ne $ExpectedLength -or
+ [ViiperLocalTestTrustLeaseNative]::LinkCount(
+ $readLease.SafeFileHandle) -ne 1 -or
+ -not $readLease.CanRead -or $readLease.CanWrite) {
+ throw 'The protected capability read lease is not the exact expected file.'
+ }
+
+ $algorithm = [Security.Cryptography.SHA256]::Create()
+ try {
+ $digest = ([BitConverter]::ToString(
+ $algorithm.ComputeHash($readLease))).Replace(
+ '-', '').ToLowerInvariant()
+ }
+ finally {
+ $algorithm.Dispose()
+ }
+ if ($digest -cne $ExpectedSHA256.ToLowerInvariant()) {
+ throw 'The protected capability read lease failed exact hash verification.'
+ }
+ $readLease.Position = 0
+ if ($readLease.Position -ne 0) {
+ throw 'The protected capability read lease could not be rewound.'
+ }
+ return $readLease
+ }
+ catch {
+ if ($null -ne $readLease) { $readLease.Dispose() }
+ throw
+ }
+}
+
+function New-ProtectedLocalTestTrustCapability {
+ param(
+ [Parameter(Mandatory = $true)][string]$StagePath,
+ [Parameter(Mandatory = $true)][string]$SourceRevision,
+ [Parameter(Mandatory = $true)][string]$CertificatePath,
+ [Parameter(Mandatory = $true)][string]$CertificateSHA256,
+ [Parameter(Mandatory = $true)][string]$PackageLockSHA256,
+ [Parameter(Mandatory = $true)][string]$TrustJournalDirectory
+ )
+
+ Assert-ProtectedStage -StagePath $StagePath
+ $path = Join-Path $StagePath 'local-test-trust-capability.json'
+ if ((Split-Path -Parent $path) -ine $StagePath -or
+ (Test-Path -LiteralPath $path)) {
+ throw 'Refusing to reuse or redirect a local-test trust capability.'
+ }
+ $parentProcess = Get-Process -Id $PID -ErrorAction Stop
+ $parentCreationFileTime = [uint64]$parentProcess.StartTime.ToUniversalTime().ToFileTimeUtc()
+ if ($parentCreationFileTime -eq 0) {
+ throw 'The local-test trust capability parent creation time is invalid.'
+ }
+ $value = [ordered]@{
+ schema = 'viiper.native.local-test-trust-capability/v1'
+ nonce = [Guid]::NewGuid().ToString('N')
+ parentPid = [uint32]$PID
+ parentCreationFileTime = $parentCreationFileTime
+ sourceRevision = $SourceRevision.ToLowerInvariant()
+ certificatePath = [IO.Path]::GetFullPath($CertificatePath)
+ certificateSha256 = $CertificateSHA256.ToLowerInvariant()
+ packageLockSha256 = $PackageLockSHA256.ToLowerInvariant()
+ trustJournalSchema = 'viiper.native.local-test-trust-ownership/v1'
+ trustJournalDirectory = [IO.Path]::GetFullPath($TrustJournalDirectory)
+ }
+ $json = $value | ConvertTo-Json -Compress
+ if ($json.IndexOf("`r") -ge 0 -or $json.IndexOf("`n") -ge 0) {
+ throw 'The local-test trust capability is not single-line canonical JSON.'
+ }
+ $bytes = [Text.UTF8Encoding]::new($false, $true).GetBytes($json)
+ $algorithm = [Security.Cryptography.SHA256]::Create()
+ try {
+ $hash = ([BitConverter]::ToString(
+ $algorithm.ComputeHash($bytes))).Replace('-', '').ToLowerInvariant()
+ }
+ finally {
+ $algorithm.Dispose()
+ }
+ $security = [Security.AccessControl.FileSecurity]::new()
+ $security.SetSecurityDescriptorSddlForm(
+ 'O:BAG:BAD:P(A;;FA;;;SY)(A;;FA;;;BA)',
+ [Security.AccessControl.AccessControlSections]::All)
+ $creationStream = $null
+ $readLease = $null
+ try {
+ if ($PSVersionTable.PSEdition -ceq 'Desktop') {
+ $creationStream = [IO.FileStream]::new(
+ $path, [IO.FileMode]::CreateNew,
+ [Security.AccessControl.FileSystemRights]::FullControl,
+ [IO.FileShare]::Read, 4096,
+ [IO.FileOptions]::WriteThrough, $security)
+ } else {
+ $creationStream = [IO.FileSystemAclExtensions]::Create(
+ [IO.FileInfo]::new($path), [IO.FileMode]::CreateNew,
+ [Security.AccessControl.FileSystemRights]::FullControl,
+ [IO.FileShare]::Read, 4096,
+ [IO.FileOptions]::WriteThrough, $security)
+ }
+ $creationStream.Write($bytes, 0, $bytes.Length)
+ $creationStream.Flush($true)
+ Assert-ExactProtectedTrustObjectSecurity -Path $path -Directory $false
+ if ($creationStream.Length -ne $bytes.Length -or
+ [ViiperLocalTestTrustLeaseNative]::LinkCount(
+ $creationStream.SafeFileHandle) -ne 1) {
+ throw 'The protected local-test trust capability changed after creation.'
+ }
+ $creationStream.Dispose()
+ $creationStream = $null
+ $readLease = Open-VerifiedProtectedCapabilityReadLease `
+ -Path $path -StagePath $StagePath -ExpectedLength $bytes.Length `
+ -ExpectedSHA256 $hash
+ return [pscustomobject]@{
+ Path = $path
+ SHA256 = $hash
+ Stream = $readLease
+ }
+ }
+ catch {
+ if ($null -ne $readLease) { $readLease.Dispose() }
+ if ($null -ne $creationStream) { $creationStream.Dispose() }
+ throw
+ }
+}
+
+function Assert-ExactRecoveryJsonObjectProperties {
+ param(
+ [Parameter(Mandatory = $true)]$Value,
+ [Parameter(Mandatory = $true)][string[]]$ExpectedNames,
+ [Parameter(Mandatory = $true)][string]$Label
+ )
+
+ if ($null -eq $Value -or
+ $Value -isnot [Management.Automation.PSCustomObject]) {
+ throw "Recovery authorization $Label is not one exact JSON object."
+ }
+ $actualNames = @($Value.PSObject.Properties |
+ ForEach-Object { [string]$_.Name })
+ $expected = [Collections.Generic.HashSet[string]]::new(
+ [StringComparer]::Ordinal)
+ foreach ($name in $ExpectedNames) {
+ if (-not $expected.Add($name)) {
+ throw "Recovery authorization $Label contract repeats '$name'."
+ }
+ }
+ if ($actualNames.Count -ne $ExpectedNames.Count) {
+ throw "Recovery authorization $Label has missing or unknown fields."
+ }
+ foreach ($name in $actualNames) {
+ if (-not $expected.Contains($name)) {
+ throw "Recovery authorization $Label has unknown field '$name'."
+ }
+ }
+}
+
+function Assert-ExactR4FailedInstallRecoveryAuthorization {
+ param(
+ [Parameter(Mandatory = $true)][string]$AuthorizationText,
+ [Parameter(Mandatory = $true)]$Authorization,
+ [Parameter(Mandatory = $true)][string]$CurrentViiperSourceRevision,
+ [Parameter(Mandatory = $true)][string]$CurrentPackageLockSHA256,
+ [Parameter(Mandatory = $true)][string]$CurrentBundleManifestSHA256,
+ [Parameter(Mandatory = $true)][string]$CurrentCertificateSHA256,
+ [Parameter(Mandatory = $true)][string]$ExpectedMachine,
+ [Parameter(Mandatory = $true)][string]$ExpectedTargetUserSID,
+ [Parameter(Mandatory = $true)][bool]$Resume
+ )
+
+ $topNames = @(
+ 'schema', 'status', 'retryPermitted', 'firstAuthorizedUtc',
+ 'currentBundleManifestSha256', 'currentViiperSourceRevision',
+ 'currentPackageLockSha256', 'predecessor',
+ 'predecessorCertificateSha256', 'machine', 'targetUserSid',
+ 'trustBeforeNativeAttempt', 'resume', 'updatedUtc'
+ )
+ if ($Resume) { $topNames += 'recoveryRootAuthorizationSha256' }
+ $predecessorNames = @(
+ 'predecessorEvidenceRoot', 'installEvidenceDirectory', 'statePath',
+ 'stateSha256', 'commandSha256', 'resultSha256', 'stdoutSha256',
+ 'stderrSha256', 'bundleManifestSha256', 'viiperSourceRevision',
+ 'ds4WindowsSourceRevision', 'packageLockSha256'
+ )
+ $trustNames = @('Root', 'TrustedPublisher')
+
+ # ConvertFrom-Json can collapse duplicate properties. Scan the same locked
+ # UTF-8 text before trusting its object model and require every simple,
+ # canonical property name exactly once across this fixed schema.
+ $allNames = @($topNames + $predecessorNames + $trustNames)
+ $allowedNames = [Collections.Generic.HashSet[string]]::new(
+ [StringComparer]::Ordinal)
+ foreach ($name in $allNames) { [void]$allowedNames.Add($name) }
+ $seenNames = [Collections.Generic.HashSet[string]]::new(
+ [StringComparer]::Ordinal)
+ $keyPattern = '"(?(?:\\["\\/bfnrt]|\\u[0-9a-fA-F]{4}|[^"\\\x00-\x1f])*)"\s*:'
+ $keyMatches = [regex]::Matches(
+ $AuthorizationText, $keyPattern,
+ [Text.RegularExpressions.RegexOptions]::CultureInvariant)
+ foreach ($match in $keyMatches) {
+ $name = [string]$match.Groups['name'].Value
+ if ($name.IndexOf('\') -ge 0 -or
+ -not $allowedNames.Contains($name) -or
+ -not $seenNames.Add($name)) {
+ throw "Recovery authorization has an escaped, unknown, or duplicate JSON field '$name'."
+ }
+ }
+ if ($keyMatches.Count -ne $allNames.Count -or
+ $seenNames.Count -ne $allNames.Count) {
+ throw 'Recovery authorization has missing, unknown, or duplicate JSON fields.'
+ }
+
+ Assert-ExactRecoveryJsonObjectProperties -Value $Authorization `
+ -ExpectedNames $topNames -Label 'root'
+ Assert-ExactRecoveryJsonObjectProperties -Value $Authorization.predecessor `
+ -ExpectedNames $predecessorNames -Label 'predecessor'
+ Assert-ExactRecoveryJsonObjectProperties `
+ -Value $Authorization.trustBeforeNativeAttempt `
+ -ExpectedNames $trustNames -Label 'trust admission'
+
+ $r4 = [ordered]@{
+ predecessorEvidenceRoot = 'C:\Users\hbash\Documents\Codex\2026-08-15\the\outputs\VIIPER-Win11-9481f9d-272f6a0-r4'
+ installEvidenceDirectory = 'C:\Users\hbash\Documents\Codex\2026-08-15\the\outputs\VIIPER-Win11-9481f9d-272f6a0-r4\steps\20260816T034608909Z-install-27fffa05b7e544feb3c5a415ebd1f6c4'
+ statePath = 'C:\Users\hbash\Documents\Codex\2026-08-15\the\outputs\VIIPER-Win11-9481f9d-272f6a0-r4\state\validation-state.json'
+ stateSha256 = 'e13c686a0cddcf66620940005568b3a7a9a41abb277f61977dd88994863d8cda'
+ commandSha256 = 'c38579b1504c8851dd72317d49f4439d14b7878b4e19907ebe864c8ad986e3f7'
+ resultSha256 = '1095194f448455f746b5af92b89ae4f08f8f69a7ba9fac1d17a90d73e8a971b0'
+ # This exact stdout digest binds changed=0, rebootRequired=0,
+ # rollback=not-needed, exitCode=4, phase=install-journal-broker-image-hash,
+ # win32Error=23, and the immutable-broker-digest failure message.
+ stdoutSha256 = 'ca95fac3b8bd6fe7871a7f42400031f01ea946dc88786e9e9a746084144c205b'
+ stderrSha256 = '2610d56f76be3c1aea4f6b3dd4e4b38d134a1d311133ac46f389a28f8faeb520'
+ bundleManifestSha256 = '765de4fe822004e97940fa66ba73602dafd68194d14fd64e20b388444cd4c247'
+ viiperSourceRevision = '9481f9dbfde64af99905fa325546e50b5ea03d6e'
+ ds4WindowsSourceRevision = '272f6a05f1476d5aa9c055a234e61c292d3c1556'
+ packageLockSha256 = '16e08c31bb1c240a3612a6c4ddc8219b040d0e2dec5773e39f363d045113ab8c'
+ certificateSha256 = '09ca0c2d4d3da29268eff59cf85b6c1347d4a28ddc098b8640381694ad74c517'
+ }
+ $predecessor = $Authorization.predecessor
+ if ([string]$Authorization.schema -cne
+ 'viiper.windows11.failed-install-recovery-progress/v1' -or
+ [string]$Authorization.status -cne 'native-attempt' -or
+ $Authorization.retryPermitted -isnot [bool] -or
+ $Authorization.retryPermitted -ne $true -or
+ $Authorization.resume -isnot [bool] -or
+ [bool]$Authorization.resume -ne $Resume -or
+ [string]$Authorization.currentViiperSourceRevision -cne
+ $CurrentViiperSourceRevision.ToLowerInvariant() -or
+ [string]$Authorization.currentPackageLockSha256 -cne
+ $CurrentPackageLockSHA256.ToLowerInvariant() -or
+ [string]$Authorization.currentBundleManifestSha256 -cne
+ $CurrentBundleManifestSHA256.ToLowerInvariant() -or
+ [string]$Authorization.predecessorCertificateSha256 -cne
+ $r4.certificateSha256 -or
+ [string]$Authorization.predecessorCertificateSha256 -cne
+ $CurrentCertificateSHA256.ToLowerInvariant() -or
+ [string]$Authorization.machine -cne $ExpectedMachine -or
+ [string]$Authorization.targetUserSid -cne $ExpectedTargetUserSID -or
+ [string]$predecessor.predecessorEvidenceRoot -ine
+ $r4.predecessorEvidenceRoot -or
+ [string]$predecessor.installEvidenceDirectory -ine
+ $r4.installEvidenceDirectory -or
+ [string]$predecessor.statePath -ine $r4.statePath -or
+ [string]$predecessor.stateSha256 -cne $r4.stateSha256 -or
+ [string]$predecessor.commandSha256 -cne $r4.commandSha256 -or
+ [string]$predecessor.resultSha256 -cne $r4.resultSha256 -or
+ [string]$predecessor.stdoutSha256 -cne $r4.stdoutSha256 -or
+ [string]$predecessor.stderrSha256 -cne $r4.stderrSha256 -or
+ [string]$predecessor.bundleManifestSha256 -cne
+ $r4.bundleManifestSha256 -or
+ [string]$predecessor.viiperSourceRevision -cne
+ $r4.viiperSourceRevision -or
+ [string]$predecessor.ds4WindowsSourceRevision -cne
+ $r4.ds4WindowsSourceRevision -or
+ [string]$predecessor.packageLockSha256 -cne
+ $r4.packageLockSha256) {
+ throw 'Recovery authorization does not bind the exact manifest-known R4 failed-install predecessor and failure proof.'
+ }
+
+ foreach ($timestampName in @('firstAuthorizedUtc', 'updatedUtc')) {
+ try {
+ $timestampValue = $Authorization.$timestampName
+ if ($timestampValue -is [DateTime]) {
+ [void]([DateTime]$timestampValue).ToUniversalTime()
+ } elseif ($timestampValue -is [DateTimeOffset]) {
+ [void]([DateTimeOffset]$timestampValue).ToUniversalTime()
+ } else {
+ [void][DateTimeOffset]::ParseExact(
+ [string]$timestampValue, 'o',
+ [Globalization.CultureInfo]::InvariantCulture,
+ [Globalization.DateTimeStyles]::RoundtripKind)
+ }
+ }
+ catch {
+ throw "Recovery authorization has invalid '$timestampName'."
+ }
+ }
+ $trust = $Authorization.trustBeforeNativeAttempt
+ if (($trust.Root -isnot [int] -and $trust.Root -isnot [long]) -or
+ ($trust.TrustedPublisher -isnot [int] -and
+ $trust.TrustedPublisher -isnot [long])) {
+ throw 'Recovery authorization trust admission is not integral.'
+ }
+ if (-not $Resume -and
+ ([int]$trust.Root -ne 1 -or [int]$trust.TrustedPublisher -ne 1)) {
+ throw 'Initial recovery authorization does not bind exact trust 1/1.'
+ }
+ if ($Resume -and
+ ([int]$trust.Root -notin @(0, 1) -or
+ [int]$trust.TrustedPublisher -notin @(0, 1) -or
+ [string]$Authorization.recoveryRootAuthorizationSha256 -cnotmatch
+ '^[0-9a-f]{64}$')) {
+ throw 'Recovery retry authorization has invalid trust or root authority.'
+ }
+}
+
+function Open-ExactR4FailedInstallEvidenceLeases {
+ param(
+ [Parameter(Mandatory = $true)]$Authorization,
+ [Parameter(Mandatory = $true)][string]$ExpectedMachine,
+ [Parameter(Mandatory = $true)][string]$ExpectedTargetUserSID
+ )
+
+ $predecessor = $Authorization.predecessor
+ $evidence = @(
+ [pscustomobject]@{
+ Label = 'state'
+ Path = [string]$predecessor.statePath
+ SHA256 = [string]$predecessor.stateSha256
+ State = $true
+ },
+ [pscustomobject]@{
+ Label = 'command'
+ Path = Join-Path ([string]$predecessor.installEvidenceDirectory) `
+ 'command.json'
+ SHA256 = [string]$predecessor.commandSha256
+ State = $false
+ },
+ [pscustomobject]@{
+ Label = 'result'
+ Path = Join-Path ([string]$predecessor.installEvidenceDirectory) `
+ 'result.json'
+ SHA256 = [string]$predecessor.resultSha256
+ State = $false
+ },
+ [pscustomobject]@{
+ Label = 'stdout'
+ Path = Join-Path ([string]$predecessor.installEvidenceDirectory) `
+ 'stdout.log'
+ SHA256 = [string]$predecessor.stdoutSha256
+ State = $false
+ },
+ [pscustomobject]@{
+ Label = 'stderr'
+ Path = Join-Path ([string]$predecessor.installEvidenceDirectory) `
+ 'stderr.log'
+ SHA256 = [string]$predecessor.stderrSha256
+ State = $false
+ }
+ )
+ $streams = [Collections.Generic.List[IO.FileStream]]::new()
+ try {
+ foreach ($entry in $evidence) {
+ $item = Get-Item -LiteralPath $entry.Path -Force -ErrorAction Stop
+ if ($item.PSIsContainer -or
+ ($item.Attributes -band
+ [IO.FileAttributes]::ReparsePoint) -ne 0 -or
+ $item.Length -le 0 -or $item.Length -gt 16777216) {
+ throw "Exact R4 predecessor $($entry.Label) evidence is not one bounded ordinary file."
+ }
+ $stream = [IO.FileStream]::new(
+ $item.FullName, [IO.FileMode]::Open,
+ [IO.FileAccess]::Read, [IO.FileShare]::Read)
+ $streams.Add($stream)
+ if ([ViiperLocalTestTrustLeaseNative]::LinkCount(
+ $stream.SafeFileHandle) -ne 1) {
+ throw "Exact R4 predecessor $($entry.Label) evidence has multiple hard links."
+ }
+ $algorithm = [Security.Cryptography.SHA256]::Create()
+ try {
+ $digest = ([BitConverter]::ToString(
+ $algorithm.ComputeHash($stream))).Replace(
+ '-', '').ToLowerInvariant()
+ }
+ finally {
+ $algorithm.Dispose()
+ }
+ $stream.Position = 0
+ if ($digest -cne ([string]$entry.SHA256).ToLowerInvariant()) {
+ throw "Exact R4 predecessor $($entry.Label) evidence differs from its compiled digest."
+ }
+ if (-not [bool]$entry.State) { continue }
+ $stateBytes = [byte[]]::new([int]$stream.Length)
+ $offset = 0
+ while ($offset -lt $stateBytes.Length) {
+ $read = $stream.Read(
+ $stateBytes, $offset, $stateBytes.Length - $offset)
+ if ($read -le 0) {
+ throw 'Exact R4 predecessor state ended before its locked length.'
+ }
+ $offset += $read
+ }
+ $stream.Position = 0
+ $stateText = [Text.UTF8Encoding]::new(
+ $false, $true).GetString($stateBytes)
+ $state = $stateText | ConvertFrom-Json -ErrorAction Stop
+ if ([string]$state.schema -cne
+ 'viiper.windows11.validation-state/v1' -or
+ [string]$state.machine -cne $ExpectedMachine -or
+ [string]$state.targetUserSid -cne $ExpectedTargetUserSID) {
+ throw 'Exact R4 predecessor state does not bind this machine and target user.'
+ }
+ }
+ return [IO.FileStream[]]$streams.ToArray()
+ }
+ catch {
+ for ($index = $streams.Count - 1; $index -ge 0; --$index) {
+ $streams[$index].Dispose()
+ }
+ throw
+ }
+}
+
+function Close-ExactR4FailedInstallEvidenceLeases {
+ param([IO.FileStream[]]$Streams)
+
+ if ($null -eq $Streams) { return }
+ for ($index = $Streams.Count - 1; $index -ge 0; --$index) {
+ if ($null -ne $Streams[$index]) { $Streams[$index].Dispose() }
+ }
+}
+
+function New-ProtectedFailedInstallRecoveryCapability {
+ param(
+ [Parameter(Mandatory = $true)][string]$StagePath,
+ [Parameter(Mandatory = $true)][string]$LeasePath,
+ [Parameter(Mandatory = $true)][string]$SourceRevision,
+ [Parameter(Mandatory = $true)][string]$HelperSHA256,
+ [Parameter(Mandatory = $true)][string]$CertificateSHA256,
+ [Parameter(Mandatory = $true)][string]$AuthorizationSHA256,
+ [Parameter(Mandatory = $true)][string]$RootAuthorizationSHA256,
+ [Parameter(Mandatory = $true)][string]$PackageLockSHA256,
+ [Parameter(Mandatory = $true)][string]$BundleManifestSHA256,
+ [Parameter(Mandatory = $true)][bool]$AllowPartialCertificateState
+ )
+
+ Assert-ProtectedStage -StagePath $StagePath
+ $path = Join-Path $StagePath 'failed-install-recovery-capability.json'
+ if ((Split-Path -Parent $path) -ine $StagePath -or
+ (Test-Path -LiteralPath $path)) {
+ throw 'Refusing to reuse or redirect a failed-install recovery capability.'
+ }
+ $parentProcess = Get-Process -Id $PID -ErrorAction Stop
+ $parentCreationFileTime =
+ [uint64]$parentProcess.StartTime.ToUniversalTime().ToFileTimeUtc()
+ if ($parentCreationFileTime -eq 0) {
+ throw 'The failed-install recovery capability parent creation time is invalid.'
+ }
+ $value = [ordered]@{
+ schema = 'viiper.native.failed-install-recovery-capability/v1'
+ nonce = [Guid]::NewGuid().ToString('N')
+ parentPid = [uint32]$PID
+ parentCreationFileTime = $parentCreationFileTime
+ leasePath = [IO.Path]::GetFullPath($LeasePath)
+ sourceRevision = $SourceRevision.ToLowerInvariant()
+ helperSha256 = $HelperSHA256.ToLowerInvariant()
+ certificateSha256 = $CertificateSHA256.ToLowerInvariant()
+ recoveryAuthorizationSha256 = $AuthorizationSHA256.ToLowerInvariant()
+ recoveryRootAuthorizationSha256 =
+ $RootAuthorizationSHA256.ToLowerInvariant()
+ packageLockSha256 = $PackageLockSHA256.ToLowerInvariant()
+ bundleManifestSha256 = $BundleManifestSHA256.ToLowerInvariant()
+ allowPartialCertificateState = $AllowPartialCertificateState
+ }
+ $json = $value | ConvertTo-Json -Compress
+ if ($json.IndexOf("`r") -ge 0 -or $json.IndexOf("`n") -ge 0) {
+ throw 'The failed-install recovery capability is not single-line canonical JSON.'
+ }
+ $bytes = [Text.UTF8Encoding]::new($false, $true).GetBytes($json)
+ $algorithm = [Security.Cryptography.SHA256]::Create()
+ try {
+ $hash = ([BitConverter]::ToString(
+ $algorithm.ComputeHash($bytes))).Replace('-', '').ToLowerInvariant()
+ }
+ finally {
+ $algorithm.Dispose()
+ }
+ $security = [Security.AccessControl.FileSecurity]::new()
+ $security.SetSecurityDescriptorSddlForm(
+ 'O:BAG:BAD:P(A;;FA;;;SY)(A;;FA;;;BA)',
+ [Security.AccessControl.AccessControlSections]::All)
+ $creationStream = $null
+ $readLease = $null
+ try {
+ if ($PSVersionTable.PSEdition -ceq 'Desktop') {
+ $creationStream = [IO.FileStream]::new(
+ $path, [IO.FileMode]::CreateNew,
+ [Security.AccessControl.FileSystemRights]::FullControl,
+ [IO.FileShare]::Read, 4096,
+ [IO.FileOptions]::WriteThrough, $security)
+ } else {
+ $creationStream = [IO.FileSystemAclExtensions]::Create(
+ [IO.FileInfo]::new($path), [IO.FileMode]::CreateNew,
+ [Security.AccessControl.FileSystemRights]::FullControl,
+ [IO.FileShare]::Read, 4096,
+ [IO.FileOptions]::WriteThrough, $security)
+ }
+ $creationStream.Write($bytes, 0, $bytes.Length)
+ $creationStream.Flush($true)
+ Assert-ExactProtectedTrustObjectSecurity -Path $path -Directory $false
+ if ($creationStream.Length -ne $bytes.Length -or
+ [ViiperLocalTestTrustLeaseNative]::LinkCount(
+ $creationStream.SafeFileHandle) -ne 1) {
+ throw 'The protected failed-install recovery capability changed after creation.'
+ }
+ $creationStream.Dispose()
+ $creationStream = $null
+ $readLease = Open-VerifiedProtectedCapabilityReadLease `
+ -Path $path -StagePath $StagePath -ExpectedLength $bytes.Length `
+ -ExpectedSHA256 $hash
+ return [pscustomobject]@{
+ Path = $path
+ SHA256 = $hash
+ Stream = $readLease
+ }
+ }
+ catch {
+ if ($null -ne $readLease) { $readLease.Dispose() }
+ if ($null -ne $creationStream) { $creationStream.Dispose() }
+ throw
+ }
+}
+
+function Remove-ProtectedStage {
+ param(
+ [Parameter(Mandatory = $true)][string]$StagePath,
+ [Parameter(Mandatory = $true)][string]$ProgramDataRoot
+ )
+
+ $stage = [IO.Path]::GetFullPath($StagePath).TrimEnd('\')
+ $programData = [IO.Path]::GetFullPath($ProgramDataRoot).TrimEnd('\')
+ $prefix = $programData + [IO.Path]::DirectorySeparatorChar +
+ 'VIIPER.DS4WindowsStage.'
+ if (-not $stage.StartsWith(
+ $prefix, [StringComparison]::OrdinalIgnoreCase) -or
+ (Split-Path -Parent $stage) -ine $programData -or
+ (Split-Path -Leaf $stage) -cnotmatch
+ '^VIIPER\.DS4WindowsStage\.[0-9a-f]{32}$') {
+ throw "Refusing to remove an unverified staging directory: '$stage'."
+ }
+ if (Test-Path -LiteralPath $stage) {
+ Assert-ProtectedStage -StagePath $stage
+ $children = @(Get-ChildItem -LiteralPath $stage -Force)
+ $allowed = @(
+ 'local-test-trust-capability.json',
+ 'failed-install-recovery-capability.json',
+ 'viiper.exe')
+ if ($children.Count -gt 2 -or @($children | Where-Object {
+ $_.PSIsContainer -or
+ ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
+ $_.Name -cnotin $allowed
+ }).Count -ne 0) {
+ throw "Refusing protected staging cleanup with unexpected entries: '$stage'."
+ }
+ foreach ($child in $children) {
+ [IO.File]::Delete($child.FullName)
+ }
+ [IO.Directory]::Delete($stage, $false)
+ }
+}
+
+trap {
+ $primaryFailure = $_.Exception
+ if ($null -ne $script:recoveryPredecessorLeases) {
+ Close-ExactR4FailedInstallEvidenceLeases `
+ -Streams $script:recoveryPredecessorLeases
+ $script:recoveryPredecessorLeases = $null
+ }
+ if ($null -ne $script:recoveryAuthorizationLease) {
+ $script:recoveryAuthorizationLease.Dispose()
+ $script:recoveryAuthorizationLease = $null
+ }
+ if (-not $script:structuredOutcomeWritten) {
+ Write-StructuredOutcome -RequestedOperation $Operation -ExitCode 1
+ }
+ Write-Host ("VIIPER native UDE setup stopped: " +
+ $primaryFailure.Message) -ForegroundColor Red
+ if ($null -ne $script:localTestCertificate) {
+ $script:localTestCertificate.Dispose()
+ $script:localTestCertificate = $null
+ }
+ exit 1
+}
+
+if (-not [Environment]::Is64BitOperatingSystem -or
+ -not [Environment]::Is64BitProcess) {
+ throw 'VIIPER native UDE setup requires a 64-bit DS4Windows process on 64-bit Windows.'
+}
+if (-not (Test-IsAdministrator)) {
+ throw 'VIIPER native UDE setup must run from the administrator prompt started by DS4Windows.'
+}
+
+$sid = [Security.Principal.SecurityIdentifier]::new($TargetUserSID)
+if ($sid.IsWellKnown([Security.Principal.WellKnownSidType]::LocalSystemSid) -or
+ $sid.IsWellKnown([Security.Principal.WellKnownSidType]::BuiltinAdministratorsSid)) {
+ throw 'TargetUserSID must name the interactive DS4Windows user, not a system principal.'
+}
+
+$metadataPath = Resolve-SingleMetadataPath
+$metadata = Get-Content -LiteralPath $metadataPath -Raw -Encoding UTF8 |
+ ConvertFrom-Json -ErrorAction Stop
+$sourceRevision = [string]$metadata.sourceRevision
+$driverPackageVersion = [string]$metadata.driverPackageVersion
+$driverABIMajor = [int]$metadata.driverAbi.major
+$driverABIMinor = [int]$metadata.driverAbi.minor
+$driverCapabilities = [uint32]$metadata.requiredCapabilities
+$driverCapabilitiesHex = '0x{0:x8}' -f $driverCapabilities
+$driverBuildIdentity = [string]$metadata.loadedDriverBuildIdentity
+if ([int]$metadata.schemaVersion -ne $expectedSchema -or
+ [string]$metadata.localTestOptInEnvironment -cne
+ $localTestOptInEnvironment -or
+ $sourceRevision -cnotmatch '^[0-9a-f]{40}$|^[0-9a-f]{64}$' -or
+ $driverPackageVersion -cnotmatch '^[0-9]+(?:\.[0-9]+){3}$' -or
+ $driverABIMajor -le 0 -or $driverABIMajor -gt 65535 -or
+ $driverABIMinor -lt 0 -or $driverABIMinor -gt 65535 -or
+ $driverCapabilities -eq 0 -or
+ [string]$metadata.requiredCapabilitiesHex -cne $driverCapabilitiesHex -or
+ $driverBuildIdentity -cnotmatch '^[0-9a-f]{64}$') {
+ throw 'Bundled VIIPER metadata has an invalid source/package/ABI/capability/build-identity contract.'
+}
+if ([string]$metadata.managedBroker.serviceName -cne 'VIIPERNativeBroker' -or
+ [string]$metadata.managedBroker.serviceAccount -cne 'LocalSystem' -or
+ [string]$metadata.managedBroker.startMode -cne 'automatic' -or
+ [string]$metadata.managedBroker.transport -cne 'native-ude' -or
+ [string]$metadata.managedBroker.apiHost -cne '127.0.0.1' -or
+ [int]$metadata.managedBroker.apiPort -ne 3242 -or
+ [string]$metadata.managedBroker.credentialPath -cne
+ '%ProgramData%/VIIPER/viiper.key.txt') {
+ throw 'Bundled VIIPER metadata has an invalid managed LocalSystem broker contract.'
+}
+
+$controllerContract = $metadata.controllerApiContract
+$expectedControllerRegistrations = [ordered]@{
+ xbox360 = 'xbox360|0x045e|0x028e|0x028e|xusb-composite|fixed'
+ dualshock4 = 'dualshock4|0x054c|0x09cc|0x05c4|hid-audio-duplex|fixed'
+ dualshock4audioduplexv3 = 'dualshock4|0x054c|0x09cc|0x05c4|hid-audio-duplex|framed-v3'
+ dualshock4audioonlyduplexv3 = 'dualshock4|0x054c|0x09cc|0x05c4|audio-duplex-only|framed-v3'
+ dualsensecombinedaudioduplexv5 = 'dualsense|0x054c|0x0ce6|0x0ce6|hid-audio-duplex|framed-v5'
+ dualsenseaudioonlyduplexv5 = 'dualsense|0x054c|0x0ce6|0x0ce6|audio-duplex-only|framed-v5'
+ dualsensegamepadv5 = 'dualsense|0x054c|0x0ce6|0x0ce6|hid-gamepad-only|framed-v5'
+ dualsenseedgecombinedaudioduplexv5 = 'dualsense-edge|0x054c|0x0df2|0x0df2|hid-audio-duplex|framed-v5'
+ dualsenseedgegamepadv5 = 'dualsense-edge|0x054c|0x0df2|0x0df2|hid-gamepad-only|framed-v5'
+ ns2pro = 'switch2-pro|0x057e|0x2069|0x2069|hid-vendor-bulk|fixed'
+}
+$actualControllerTypes = [Collections.Generic.HashSet[string]]::new(
+ [StringComparer]::Ordinal)
+if ([int]$controllerContract.schemaVersion -ne 1 -or
+ [string]$controllerContract.sourceRevision -cne $sourceRevision) {
+ throw 'Bundled VIIPER metadata has an invalid source-bound controller API contract.'
+}
+foreach ($registration in @($controllerContract.registrations)) {
+ $type = [string]$registration.type
+ if (-not $actualControllerTypes.Add($type) -or
+ -not $expectedControllerRegistrations.Contains($type)) {
+ throw "Bundled controller API contract has unexpected or duplicate type '$type'."
+ }
+ $signature = @(
+ [string]$registration.persona,
+ [string]$registration.defaultVid,
+ [string]$registration.defaultPid,
+ [string]$registration.ds4WindowsPid,
+ [string]$registration.interfaceProfile,
+ [string]$registration.streamProtocol
+ ) -join '|'
+ if ($signature -cne [string]$expectedControllerRegistrations[$type]) {
+ throw "Bundled controller API type '$type' diverges from its VIIPER HID/interface implementation."
+ }
+}
+if ($actualControllerTypes.Count -ne $expectedControllerRegistrations.Count) {
+ throw 'Bundled controller API contract omits a DS4Windows controller persona.'
+}
+
+$eligibility = [string]$metadata.releaseEligibility
+$driverValidationMode = 'production'
+if ($eligibility -ceq 'production') {
+ if ($AllowLocalTest -or $AcknowledgeDisposableTestMachine) {
+ throw 'Local-test switches cannot be combined with production VIIPER metadata.'
+ }
+} elseif ($eligibility -ceq 'local-test-evidence-only') {
+ if (-not $AllowLocalTest -or -not $AcknowledgeDisposableTestMachine -or
+ [Environment]::GetEnvironmentVariable($localTestOptInEnvironment) -cne '1') {
+ throw "This bundle is local-test evidence only. A developer must set $localTestOptInEnvironment=1 and pass both -AllowLocalTest and -AcknowledgeDisposableTestMachine on a disposable VM."
+ }
+ $driverValidationMode = 'local-test'
+} else {
+ throw "Unsupported VIIPER release eligibility '$eligibility'."
+}
+if ($Operation -cne 'Recover' -and
+ (-not [string]::IsNullOrWhiteSpace($RecoveryAuthorizationPath) -or
+ -not [string]::IsNullOrWhiteSpace($ExpectedRecoveryAuthorizationSHA256) -or
+ $RecoveryResume)) {
+ throw 'Recovery authorization parameters are valid only for Operation Recover.'
+}
+
+$packageRoot = Join-Path $PSScriptRoot 'viiper-native-package'
+$packageRootItem = Get-Item -LiteralPath $packageRoot -Force -ErrorAction Stop
+if (-not $packageRootItem.PSIsContainer -or
+ ($packageRootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ throw "The bundled VIIPER native package root is missing or unsafe: '$packageRoot'."
+}
+
+if (@('Install', 'Recover') -ccontains $Operation) {
+ $boundPackageFiles = [Collections.Generic.HashSet[string]]::new(
+ [StringComparer]::OrdinalIgnoreCase)
+ $boundRoles = [Collections.Generic.HashSet[string]]::new(
+ [StringComparer]::Ordinal)
+ foreach ($artifact in @($metadata.artifacts)) {
+ $role = [string]$artifact.role
+ if ($role -cnotmatch '^[a-z0-9-]+$' -or -not $boundRoles.Add($role)) {
+ throw "Native metadata has invalid or duplicate artifact role '$role'."
+ }
+ $verifiedPath = Resolve-VerifiedArtifact -Artifact $artifact -PackageRoot $packageRoot
+ if (-not $boundPackageFiles.Add($verifiedPath)) {
+ throw "Native metadata binds duplicate package path '$verifiedPath'."
+ }
+ }
+ foreach ($directory in @(Get-ChildItem -LiteralPath $packageRoot -Directory -Recurse -Force)) {
+ if (($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ throw "Native package contains unsafe directory '$($directory.FullName)'."
+ }
+ }
+ $actualPackageFiles = @(Get-ChildItem -LiteralPath $packageRoot -File -Recurse -Force)
+ if ($actualPackageFiles.Count -ne $boundPackageFiles.Count) {
+ throw "Native package inventory has $($actualPackageFiles.Count) files but metadata binds $($boundPackageFiles.Count)."
+ }
+ foreach ($file in $actualPackageFiles) {
+ if (($file.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
+ -not $boundPackageFiles.Contains($file.FullName)) {
+ throw "Native package contains unbound or unsafe file '$($file.FullName)'."
+ }
+ }
+}
+
+$brokerArtifact = Get-UniqueArtifact -Metadata $metadata -Role 'broker'
+$helperArtifact = Get-UniqueArtifact -Metadata $metadata -Role 'driver-helper'
+$brokerPath = Resolve-VerifiedArtifact -Artifact $brokerArtifact -PackageRoot $packageRoot
+$helperPath = Resolve-VerifiedArtifact -Artifact $helperArtifact -PackageRoot $packageRoot
+$helperHash = ([string]$helperArtifact.sha256).ToLowerInvariant()
+if ((Split-Path -Leaf $brokerPath) -cne 'viiper.exe' -or
+ (Split-Path -Leaf $helperPath) -cne 'ViiperUdeCtl.exe') {
+ throw 'Native metadata must bind viiper.exe and ViiperUdeCtl.exe by their canonical names.'
+}
+
+$programDataRoot = [Environment]::GetFolderPath(
+ [Environment+SpecialFolder]::CommonApplicationData)
+$trustJournalDirectory = Join-Path $programDataRoot 'VIIPER-TrustManager'
+$trustManagerLeasePath = Join-Path $trustJournalDirectory 'lease-v1.lock'
+# PowerShell only binds these fixed paths into parent capabilities. The native
+# child initializes and lifetime-owns Trust -> Package -> Service, the durable
+# ownership journal, and all LocalMachine certificate-store mutation.
+
+$arguments = @()
+if (@('Install', 'Recover') -ccontains $Operation) {
+ $manifestArtifact = Get-UniqueArtifact -Metadata $metadata -Role 'submission-manifest'
+ $infArtifact = Get-UniqueArtifact -Metadata $metadata -Role 'driver-inf'
+ $sysArtifact = Get-UniqueArtifact -Metadata $metadata -Role 'driver-sys'
+ $catArtifact = Get-UniqueArtifact -Metadata $metadata -Role 'driver-cat'
+ $manifestPath = Resolve-VerifiedArtifact -Artifact $manifestArtifact -PackageRoot $packageRoot
+ $infPath = Resolve-VerifiedArtifact -Artifact $infArtifact -PackageRoot $packageRoot
+ $sysPath = Resolve-VerifiedArtifact -Artifact $sysArtifact -PackageRoot $packageRoot
+ $catPath = Resolve-VerifiedArtifact -Artifact $catArtifact -PackageRoot $packageRoot
+ $driverDirectory = Split-Path -Parent $infPath
+ if ((Split-Path -Leaf $manifestPath) -cne 'submission-manifest.json' -or
+ (Split-Path -Leaf $infPath) -cne 'ViiperUde.inf' -or
+ (Split-Path -Leaf $sysPath) -cne 'ViiperUde.sys' -or
+ (Split-Path -Leaf $catPath) -cne 'ViiperUde.cat' -or
+ (Split-Path -Parent $sysPath) -ine $driverDirectory -or
+ (Split-Path -Parent $catPath) -ine $driverDirectory) {
+ throw 'Native metadata must bind the canonical submission manifest and one co-located INF/SYS/CAT driver package.'
+ }
+
+ $submission = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 |
+ ConvertFrom-Json -ErrorAction Stop
+ if ([string]$submission.sourceRevision -cne $sourceRevision -or
+ [string]$submission.driverPackageVersion -cne $driverPackageVersion -or
+ [int]$submission.driverABIMajor -ne $driverABIMajor -or
+ [int]$submission.driverABIMinor -ne $driverABIMinor -or
+ [string]$submission.driverCapabilities -cne $driverCapabilitiesHex -or
+ [string]$submission.driverBuildIdentity -cne $driverBuildIdentity) {
+ throw 'The source-bound submission manifest disagrees with bundled runtime metadata.'
+ }
+ if ($driverValidationMode -ceq 'production') {
+ if ($submission.releaseEligible -ne $true -or
+ [string]$submission.signingRoute -notmatch 'HLK|WHCP|Microsoft') {
+ throw 'Production installation requires a release-eligible HLK/WHCP submission manifest.'
+ }
+ if (@($metadata.artifacts | Where-Object {
+ [string]$_.role -eq 'local-test-certificate-evidence'
+ }).Count -ne 0) {
+ throw 'Production runtime metadata must not reference a local-test certificate.'
+ }
+ $catalogSignature = Get-AuthenticodeSignature -LiteralPath $catPath
+ if ($catalogSignature.Status -ne [Management.Automation.SignatureStatus]::Valid -or
+ $null -eq $catalogSignature.SignerCertificate -or
+ $catalogSignature.SignerCertificate.Subject -notmatch
+ 'Microsoft Windows Hardware Compatibility Publisher') {
+ throw 'Production ViiperUde.cat is not signed by Microsoft Windows Hardware Compatibility Publisher.'
+ }
+ } elseif ($submission.releaseEligible -ne $false -or
+ [string]$submission.signingRoute -cne 'LocalTest') {
+ throw 'Local-test installation requires the non-release-eligible LocalTest submission manifest.'
+ }
+
+ if ($driverValidationMode -ceq 'local-test') {
+ $certificateArtifact = Get-UniqueArtifact -Metadata $metadata `
+ -Role 'local-test-certificate-evidence'
+ $localTestPackageLockArtifact = Get-UniqueArtifact -Metadata $metadata `
+ -Role 'local-test-package-lock'
+ $localTestPackageLockPath = Resolve-VerifiedArtifact `
+ -Artifact $localTestPackageLockArtifact -PackageRoot $packageRoot
+ $certificatePath = Resolve-VerifiedArtifact `
+ -Artifact $certificateArtifact -PackageRoot $packageRoot
+ if ((Split-Path -Leaf $localTestPackageLockPath) -cne
+ 'local-test-package.lock.json' -or
+ (Split-Path -Leaf $certificatePath) -cne 'ViiperUdeTest.cer' -or
+ [string]$submission.testSignerCertificateSha256 -cne
+ ([string]$certificateArtifact.sha256).ToLowerInvariant()) {
+ throw 'The local-test signer certificate disagrees with the source-bound package evidence.'
+ }
+ $script:localTestCertificate =
+ [Security.Cryptography.X509Certificates.X509Certificate2]::new(
+ $certificatePath)
+ if ($script:localTestCertificate.HasPrivateKey) {
+ throw 'The local-test package must contain only the public signer certificate.'
+ }
+ $certificateAlgorithm =
+ [Security.Cryptography.SHA256]::Create()
+ try {
+ $certificateSha256 = ([BitConverter]::ToString(
+ $certificateAlgorithm.ComputeHash(
+ $script:localTestCertificate.RawData))).Replace(
+ '-', '').ToLowerInvariant()
+ }
+ finally {
+ $certificateAlgorithm.Dispose()
+ }
+ if ($certificateSha256 -cne
+ ([string]$certificateArtifact.sha256).ToLowerInvariant()) {
+ throw 'The parsed local-test signer certificate bytes differ from their package hash.'
+ }
+ $localTestPackageLockSha256 =
+ ([string]$localTestPackageLockArtifact.sha256).ToLowerInvariant()
+ if ($Operation -ceq 'Install') {
+ Assert-LocalTestBootAdmission
+ }
+ }
+
+ if ($Operation -ceq 'Install') {
+ $arguments = @(
+ 'native-package-install',
+ '--package-directory', $driverDirectory,
+ '--submission-manifest', $manifestPath,
+ '--source-revision', $sourceRevision,
+ '--driver-helper', $helperPath,
+ '--expected-broker-sha-256', ([string]$brokerArtifact.sha256).ToLowerInvariant(),
+ '--expected-helper-sha-256', $helperHash,
+ '--expected-manifest-sha-256', ([string]$manifestArtifact.sha256).ToLowerInvariant(),
+ '--expected-inf-sha-256', ([string]$infArtifact.sha256).ToLowerInvariant(),
+ '--expected-sys-sha-256', ([string]$sysArtifact.sha256).ToLowerInvariant(),
+ '--expected-cat-sha-256', ([string]$catArtifact.sha256).ToLowerInvariant(),
+ '--target-user-sid', $TargetUserSID,
+ '--driver-validation-mode', $driverValidationMode
+ )
+ }
+ else {
+ # Recovery is deliberately journal-only. The source-bound package is
+ # still validated in full above, but the broker may invoke only the
+ # exact verified helper's recover path.
+ if ($driverValidationMode -cne 'local-test') {
+ throw 'Operation Recover is restricted to an exact local-test failed-install certificate rollback.'
+ }
+ if ([string]::IsNullOrWhiteSpace($RecoveryAuthorizationPath) -or
+ [string]::IsNullOrWhiteSpace(
+ $ExpectedRecoveryAuthorizationSHA256)) {
+ throw 'Operation Recover requires one exact recovery authorization path and SHA-256.'
+ }
+ $authorizationItem = Get-Item -LiteralPath $RecoveryAuthorizationPath `
+ -Force -ErrorAction Stop
+ if ($authorizationItem.PSIsContainer -or
+ ($authorizationItem.Attributes -band
+ [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ throw 'Recovery authorization must be an ordinary local file.'
+ }
+ $authorizationPath = $authorizationItem.FullName
+ if ((Split-Path -Leaf $authorizationPath) -cne
+ 'failed-install-recovery-progress.json') {
+ throw 'Recovery authorization has the wrong canonical file name.'
+ }
+ $script:recoveryAuthorizationLease = [IO.FileStream]::new(
+ $authorizationPath, [IO.FileMode]::Open,
+ [IO.FileAccess]::Read, [IO.FileShare]::Read)
+ if ($script:recoveryAuthorizationLease.Length -le 0 -or
+ $script:recoveryAuthorizationLease.Length -gt 262144) {
+ throw 'Recovery authorization length is outside its exact bounded contract.'
+ }
+ $authorizationBytes = [byte[]]::new(
+ [int]$script:recoveryAuthorizationLease.Length)
+ $authorizationOffset = 0
+ while ($authorizationOffset -lt $authorizationBytes.Length) {
+ $read = $script:recoveryAuthorizationLease.Read(
+ $authorizationBytes, $authorizationOffset,
+ $authorizationBytes.Length - $authorizationOffset)
+ if ($read -le 0) {
+ throw 'Recovery authorization ended before its locked length.'
+ }
+ $authorizationOffset += $read
+ }
+ $authorizationAlgorithm = [Security.Cryptography.SHA256]::Create()
+ try {
+ $authorizationHash = ([BitConverter]::ToString(
+ $authorizationAlgorithm.ComputeHash(
+ $authorizationBytes))).Replace('-', '').ToLowerInvariant()
+ }
+ finally {
+ $authorizationAlgorithm.Dispose()
+ }
+ if ($authorizationHash -cne
+ $ExpectedRecoveryAuthorizationSHA256.ToLowerInvariant()) {
+ throw 'Recovery authorization bytes differ from their caller-bound SHA-256.'
+ }
+ $authorizationText = [Text.UTF8Encoding]::new(
+ $false, $true).GetString($authorizationBytes)
+ $authorization = $authorizationText |
+ ConvertFrom-Json -ErrorAction Stop
+ $packageLockArtifact = Get-UniqueArtifact -Metadata $metadata `
+ -Role 'local-test-package-lock'
+ $bundleManifestCandidate = Join-Path (Split-Path -Parent $PSScriptRoot) `
+ 'bundle-manifest.json'
+ $bundleManifestItem = Get-Item -LiteralPath $bundleManifestCandidate `
+ -Force -ErrorAction Stop
+ if ($bundleManifestItem.PSIsContainer -or
+ ($bundleManifestItem.Attributes -band
+ [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ throw 'Current recovery bundle manifest must be an ordinary file.'
+ }
+ $bundleManifestHash = (Get-FileHash `
+ -LiteralPath $bundleManifestItem.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
+ Assert-ExactR4FailedInstallRecoveryAuthorization `
+ -AuthorizationText $authorizationText `
+ -Authorization $authorization `
+ -CurrentViiperSourceRevision $sourceRevision `
+ -CurrentPackageLockSHA256 (
+ ([string]$packageLockArtifact.sha256).ToLowerInvariant()) `
+ -CurrentBundleManifestSHA256 $bundleManifestHash `
+ -CurrentCertificateSHA256 (
+ ([string]$certificateArtifact.sha256).ToLowerInvariant()) `
+ -ExpectedMachine $env:COMPUTERNAME `
+ -ExpectedTargetUserSID $TargetUserSID `
+ -Resume ([bool]$RecoveryResume)
+ $script:recoveryPredecessorLeases = @(
+ Open-ExactR4FailedInstallEvidenceLeases `
+ -Authorization $authorization `
+ -ExpectedMachine $env:COMPUTERNAME `
+ -ExpectedTargetUserSID $TargetUserSID)
+ if ($script:recoveryPredecessorLeases.Count -ne 5) {
+ throw 'Recovery did not retain all five exact R4 predecessor evidence leases.'
+ }
+ $rootAuthorizationHash = if ($RecoveryResume) {
+ ([string]$authorization.recoveryRootAuthorizationSha256).ToLowerInvariant()
+ } else {
+ $authorizationHash
+ }
+ if ($rootAuthorizationHash -cnotmatch '^[0-9a-f]{64}$') {
+ throw 'Recovery authorization has an invalid stable root-authorization binding.'
+ }
+ $arguments = @(
+ 'native-package-recover',
+ '--driver-helper', $helperPath,
+ '--expected-helper-sha-256', $helperHash,
+ '--certificate-path', $certificatePath,
+ '--expected-certificate-sha-256',
+ ([string]$certificateArtifact.sha256).ToLowerInvariant(),
+ '--recovery-authorization', $authorizationPath,
+ '--expected-recovery-authorization-sha-256', $authorizationHash,
+ '--recovery-root-authorization-sha-256', $rootAuthorizationHash,
+ '--source-revision', $sourceRevision,
+ '--current-package-lock-sha-256',
+ ([string]$packageLockArtifact.sha256).ToLowerInvariant(),
+ '--current-bundle-manifest-sha-256', $bundleManifestHash
+ )
+ if ($RecoveryResume) {
+ $arguments += '--allow-partial-certificate-state'
+ }
+ }
+} else {
+ $arguments = @(
+ 'uninstall', '--yes',
+ '--target-user-sid', $TargetUserSID,
+ '--driver-helper', $helperPath,
+ '--expected-helper-sha-256', $helperHash
+ )
+}
+
+if ($Operation -ceq 'Uninstall' -and
+ $driverValidationMode -ceq 'local-test') {
+ $certificateArtifact = Get-UniqueArtifact -Metadata $metadata `
+ -Role 'local-test-certificate-evidence'
+ $localTestPackageLockArtifact = Get-UniqueArtifact -Metadata $metadata `
+ -Role 'local-test-package-lock'
+ $certificatePath = Resolve-VerifiedArtifact `
+ -Artifact $certificateArtifact -PackageRoot $packageRoot
+ $localTestPackageLockPath = Resolve-VerifiedArtifact `
+ -Artifact $localTestPackageLockArtifact -PackageRoot $packageRoot
+ if ((Split-Path -Leaf $certificatePath) -cne 'ViiperUdeTest.cer' -or
+ (Split-Path -Leaf $localTestPackageLockPath) -cne
+ 'local-test-package.lock.json') {
+ throw 'Local-test Uninstall requires the canonical certificate and package-lock artifacts.'
+ }
+ $script:localTestCertificate =
+ [Security.Cryptography.X509Certificates.X509Certificate2]::new(
+ $certificatePath)
+ if ($script:localTestCertificate.HasPrivateKey) {
+ throw 'The local-test package must contain only the public signer certificate.'
+ }
+ $certificateAlgorithm = [Security.Cryptography.SHA256]::Create()
+ try {
+ $certificateSha256 = ([BitConverter]::ToString(
+ $certificateAlgorithm.ComputeHash(
+ $script:localTestCertificate.RawData))).Replace(
+ '-', '').ToLowerInvariant()
+ }
+ finally {
+ $certificateAlgorithm.Dispose()
+ }
+ if ($certificateSha256 -cne
+ ([string]$certificateArtifact.sha256).ToLowerInvariant()) {
+ throw 'The parsed Uninstall certificate differs from its source-bound artifact hash.'
+ }
+ $localTestPackageLockSha256 =
+ ([string]$localTestPackageLockArtifact.sha256).ToLowerInvariant()
+ $arguments += @(
+ '--source-revision', $sourceRevision,
+ '--local-test-certificate-path', $certificatePath,
+ '--expected-local-test-certificate-sha-256', $certificateSha256,
+ '--expected-local-test-package-lock-sha-256',
+ $localTestPackageLockSha256
+ )
+}
+
+$stagePath = $null
+$stagedBrokerLease = $null
+$trustCapability = $null
+$recoveryCapability = $null
+$exitCode = 1
+try {
+ $stagePath = Initialize-ProtectedStage -ProgramDataRoot $programDataRoot
+ $stagedBrokerLease = Open-VerifiedStagedBroker `
+ -SourcePath $brokerPath -DestinationDirectory $stagePath `
+ -ExpectedLength ([long]$brokerArtifact.length) `
+ -ExpectedSHA256 ([string]$brokerArtifact.sha256).ToLowerInvariant()
+ if ($Operation -ceq 'Install' -and
+ $driverValidationMode -ceq 'local-test') {
+ $trustCapability = New-ProtectedLocalTestTrustCapability `
+ -StagePath $stagePath `
+ -SourceRevision $sourceRevision `
+ -CertificatePath $certificatePath `
+ -CertificateSHA256 $certificateSha256 `
+ -PackageLockSHA256 $localTestPackageLockSha256 `
+ -TrustJournalDirectory $trustJournalDirectory
+ $arguments += @(
+ '--local-test-trust-capability', $trustCapability.Path,
+ '--expected-trust-capability-sha-256', $trustCapability.SHA256,
+ '--local-test-certificate-path', $certificatePath,
+ '--expected-local-test-certificate-sha-256', $certificateSha256,
+ '--expected-local-test-package-lock-sha-256',
+ $localTestPackageLockSha256
+ )
+ } elseif ($Operation -ceq 'Recover') {
+ $recoveryCapability = New-ProtectedFailedInstallRecoveryCapability `
+ -StagePath $stagePath -LeasePath $trustManagerLeasePath `
+ -SourceRevision $sourceRevision -HelperSHA256 $helperHash `
+ -CertificateSHA256 $certificateSha256 `
+ -AuthorizationSHA256 $authorizationHash `
+ -RootAuthorizationSHA256 $rootAuthorizationHash `
+ -PackageLockSHA256 $localTestPackageLockSha256 `
+ -BundleManifestSHA256 $bundleManifestHash `
+ -AllowPartialCertificateState ([bool]$RecoveryResume)
+ $arguments += @(
+ '--recovery-capability', $recoveryCapability.Path,
+ '--expected-recovery-capability-sha-256',
+ $recoveryCapability.SHA256
+ )
+ }
+
+ Write-Host "Running VIIPER native UDE $($Operation.ToLowerInvariant()) transaction..."
+ $processStarted = $false
+ try {
+ $processResult = Invoke-JoinedNativeProcess `
+ -FileName $stagedBrokerLease.Path -Arguments $arguments `
+ -WorkingDirectory $stagePath -Started ([ref]$processStarted)
+ }
+ finally {
+ $script:transactionStarted = $processStarted
+ }
+ $output = @($processResult.Output)
+ $exitCode = [int]$processResult.ExitCode
+ $output | ForEach-Object { Write-Host ([string]$_) }
+} finally {
+ $stageCleanupErrors = [Collections.Generic.List[Exception]]::new()
+ if ($null -ne $recoveryCapability) {
+ try {
+ $recoveryCapability.Stream.Dispose()
+ }
+ catch {
+ $stageCleanupErrors.Add([InvalidOperationException]::new(
+ 'Failed to release the parent-bound recovery capability.',
+ $_.Exception))
+ }
+ $recoveryCapability = $null
+ }
+ if ($null -ne $trustCapability) {
+ try {
+ $trustCapability.Stream.Dispose()
+ }
+ catch {
+ $stageCleanupErrors.Add([InvalidOperationException]::new(
+ 'Failed to release the parent-bound trust capability.',
+ $_.Exception))
+ }
+ $trustCapability = $null
+ }
+ if ($null -ne $stagedBrokerLease) {
+ try {
+ $stagedBrokerLease.LaunchLock.Dispose()
+ }
+ catch {
+ $stageCleanupErrors.Add([InvalidOperationException]::new(
+ 'Failed to release the verified staged-broker launch lock.',
+ $_.Exception))
+ }
+ $stagedBrokerLease = $null
+ }
+ if ($null -ne $stagePath) {
+ try {
+ Remove-ProtectedStage -StagePath $stagePath `
+ -ProgramDataRoot $programDataRoot
+ }
+ catch {
+ $stageCleanupErrors.Add([InvalidOperationException]::new(
+ 'Failed to remove the exact protected broker stage.',
+ $_.Exception))
+ }
+ }
+ if ($stageCleanupErrors.Count -eq 1) {
+ throw $stageCleanupErrors[0]
+ }
+ if ($stageCleanupErrors.Count -gt 1) {
+ throw [AggregateException]::new(
+ 'Protected broker stage cleanup had multiple failures.',
+ [Exception[]]$stageCleanupErrors.ToArray())
+ }
+}
+
+if ($null -ne $script:recoveryAuthorizationLease) {
+ $script:recoveryAuthorizationLease.Dispose()
+ $script:recoveryAuthorizationLease = $null
+}
+if ($null -ne $script:recoveryPredecessorLeases) {
+ Close-ExactR4FailedInstallEvidenceLeases `
+ -Streams $script:recoveryPredecessorLeases
+ $script:recoveryPredecessorLeases = $null
+}
+
+Write-StructuredOutcome -RequestedOperation $Operation -ExitCode $exitCode
+if ($null -ne $script:localTestCertificate) {
+ $script:localTestCertificate.Dispose()
+ $script:localTestCertificate = $null
+}
+
+if ($exitCode -eq 0) {
+ if ($Operation -ceq 'Recover') {
+ Write-Host 'VIIPER retained native journals were reconciled and the recovery admission proved no current or successor VIIPER topology.'
+ }
+ else {
+ Write-Host 'VIIPER native UDE transaction completed and authenticated service readiness was verified by the package transaction.'
+ }
+} elseif ($exitCode -eq 3010) {
+ Write-Warning 'VIIPER stopped at a safe reboot boundary before mutation or after successful rollback. Restart Windows, then rerun this identical transaction.'
+} else {
+ Write-Warning "VIIPER native UDE transaction failed with exit code $exitCode. Review the protected transaction/recovery logs before retrying."
+}
+exit $exitCode
diff --git a/extras/validation/Invoke-ViiperWin11Validation.ps1 b/extras/validation/Invoke-ViiperWin11Validation.ps1
new file mode 100644
index 0000000..c737c54
--- /dev/null
+++ b/extras/validation/Invoke-ViiperWin11Validation.ps1
@@ -0,0 +1,1303 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)]
+ [ValidateSet('Status', 'RecoverFailedInstall', 'Preflight', 'Install', 'Repair', 'RebootResume',
+ 'ManualChecks', 'EnableVerifier', 'VerifierResume', 'Live',
+ 'Performance', 'LatencyMatrix', 'CollectDumps', 'Uninstall')]
+ [string]$Phase,
+ [Parameter(Mandatory = $true)]
+ [ValidatePattern('^[0-9a-fA-F]{64}$')]
+ [string]$ExpectedBundleManifestSHA256,
+ [Parameter(Mandatory = $true)][string]$EvidenceRoot,
+ [Parameter(Mandatory = $true)]
+ [ValidatePattern('^S-1-5-21-(?:[0-9]+-){3}[0-9]+$')]
+ [string]$TargetUserSID,
+ [Parameter(Mandatory = $true)][string]$GitExecutable,
+ [Parameter(Mandatory = $true)][string]$GoExecutable,
+ [ValidateRange(1, 100)][int]$Iterations = 3,
+ [ValidateRange(1, 300)][int]$MediaDurationSeconds = 180,
+ [switch]$AcknowledgePhysicalHotplug,
+ [switch]$AcknowledgeSleepWake,
+ [switch]$AcknowledgeHibernateWake,
+ [switch]$AcknowledgeManualReboot,
+ [string]$PredecessorEvidenceRoot,
+ [string]$PredecessorInstallStepDirectory,
+ [ValidatePattern('^[0-9a-fA-F]{64}$')]
+ [string]$ExpectedPredecessorStateSHA256,
+ [ValidatePattern('^[0-9a-fA-F]{64}$')]
+ [string]$ExpectedPredecessorInstallCommandSHA256,
+ [ValidatePattern('^[0-9a-fA-F]{64}$')]
+ [string]$ExpectedPredecessorInstallResultSHA256,
+ [ValidatePattern('^[0-9a-fA-F]{64}$')]
+ [string]$ExpectedPredecessorInstallStdoutSHA256,
+ [ValidatePattern('^[0-9a-fA-F]{64}$')]
+ [string]$ExpectedPredecessorInstallStderrSHA256,
+ [ValidatePattern('^[0-9a-fA-F]{64}$')]
+ [string]$ExpectedPredecessorBundleManifestSHA256,
+ [ValidatePattern('^[0-9a-fA-F]{40,64}$')]
+ [string]$ExpectedPredecessorViiperSourceRevision,
+ [ValidatePattern('^[0-9a-fA-F]{40,64}$')]
+ [string]$ExpectedPredecessorDS4WindowsSourceRevision,
+ [ValidatePattern('^[0-9a-fA-F]{64}$')]
+ [string]$ExpectedPredecessorPackageLockSHA256,
+ [ValidatePattern('^[0-9a-fA-F]{64}$')]
+ [string]$ExpectedPredecessorCertificateSHA256
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$bundleRoot = (Resolve-Path -LiteralPath $PSScriptRoot -ErrorAction Stop).Path
+$manifestPath = Join-Path $bundleRoot 'bundle-manifest.json'
+$manifestItem = Get-Item -LiteralPath $manifestPath -Force -ErrorAction Stop
+if ($manifestItem.PSIsContainer -or
+ ($manifestItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
+ $manifestItem.Length -le 0) {
+ throw "Bundle manifest is not a non-empty regular file: '$manifestPath'."
+}
+$actualManifestHash = (Get-FileHash -LiteralPath $manifestPath -Algorithm SHA256).Hash.ToLowerInvariant()
+if ($actualManifestHash -cne $ExpectedBundleManifestSHA256.ToLowerInvariant()) {
+ throw "Bundle manifest SHA-256 '$actualManifestHash' does not match the explicit out-of-band digest."
+}
+$manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop
+if ([string]$manifest.schema -cne 'viiper.windows11.validation-bundle/v1' -or
+ $manifest.localTestOnly -ne $true -or $manifest.noWebDownload -ne $true -or
+ $manifest.ds4Windows.integrationEvidenceOnly -ne $true -or
+ $manifest.ds4Windows.endToEndValidated -ne $false -or
+ $manifest.claims.ds4WindowsEndToEnd -ne $false -or
+ $manifest.claims.nativeVersusUsbipAbba -ne $false -or
+ $manifest.claims.nativeLatencySuperiority -ne $false) {
+ throw 'Bundle manifest does not have the exact local-test, no-download, no-claim contract.'
+}
+
+$preImportFiles = @(
+ 'Invoke-ViiperWin11Validation.ps1',
+ 'ViiperWin11Validation.Common.psm1'
+)
+foreach ($relative in $preImportFiles) {
+ $matches = @($manifest.files | Where-Object { [string]$_.path -ceq $relative })
+ if ($matches.Count -ne 1) { throw "Bundle manifest must bind exactly one '$relative'." }
+ $path = Join-Path $bundleRoot $relative
+ $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop
+ if ($item.PSIsContainer -or
+ ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
+ $item.Length -ne [long]$matches[0].length -or
+ (Get-FileHash -LiteralPath $item.FullName -Algorithm SHA256).Hash.ToLowerInvariant() -cne
+ [string]$matches[0].sha256) {
+ throw "Bundle bootstrap file '$relative' does not match the out-of-band-bound manifest."
+ }
+}
+Import-Module -Name (Join-Path $bundleRoot 'ViiperWin11Validation.Common.psm1') -Force -ErrorAction Stop
+
+function Assert-Administrator {
+ $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
+ $principal = [Security.Principal.WindowsPrincipal]::new($identity)
+ if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
+ throw 'This disposable-machine validation phase requires an elevated 64-bit PowerShell session.'
+ }
+ if (-not [Environment]::Is64BitProcess -or -not [Environment]::Is64BitOperatingSystem) {
+ throw 'Validation requires 64-bit PowerShell on 64-bit Windows 11.'
+ }
+}
+
+function ConvertTo-WindowsCommandLineArgument {
+ param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value)
+
+ if ($Value.Length -gt 0 -and $Value -notmatch '[\s"]') { return $Value }
+ $builder = [Text.StringBuilder]::new()
+ [void]$builder.Append('"')
+ $backslashes = 0
+ foreach ($character in $Value.ToCharArray()) {
+ if ($character -eq '\') {
+ $backslashes++
+ continue
+ }
+ if ($character -eq '"') {
+ [void]$builder.Append(('\' * (($backslashes * 2) + 1)))
+ [void]$builder.Append('"')
+ }
+ else {
+ if ($backslashes -gt 0) { [void]$builder.Append(('\' * $backslashes)) }
+ [void]$builder.Append($character)
+ }
+ $backslashes = 0
+ }
+ if ($backslashes -gt 0) { [void]$builder.Append(('\' * ($backslashes * 2))) }
+ [void]$builder.Append('"')
+ return $builder.ToString()
+}
+
+function New-StepDirectory {
+ param([Parameter(Mandatory = $true)][string]$Name)
+
+ $safe = $Name.ToLowerInvariant() -replace '[^a-z0-9-]', '-'
+ $path = Join-Path (Join-Path $script:evidence 'steps') (
+ ([DateTime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ')) + '-' + $safe + '-' +
+ [Guid]::NewGuid().ToString('N'))
+ [void][IO.Directory]::CreateDirectory($path)
+ return $path
+}
+
+function Invoke-CapturedPowerShell {
+ param(
+ [Parameter(Mandatory = $true)][string]$Name,
+ [Parameter(Mandatory = $true)][string]$ScriptPath,
+ [Parameter(Mandatory = $true)][string[]]$Arguments,
+ [string]$StepDirectory
+ )
+
+ $scriptFile = Resolve-ViiperRegularFile -Path $ScriptPath -Label "$Name script"
+ if ([string]::IsNullOrWhiteSpace($StepDirectory)) {
+ $StepDirectory = New-StepDirectory -Name $Name
+ }
+ $powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
+ $powershell = Resolve-ViiperRegularFile -Path $powershell -Label 'Inbox Windows PowerShell'
+ $childArguments = @('-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy',
+ 'Bypass', '-File', $scriptFile) + $Arguments
+ $command = [ordered]@{
+ schema = 'viiper.windows11.captured-command/v1'
+ name = $Name
+ startedUtc = [DateTime]::UtcNow.ToString('o')
+ executable = [ordered]@{
+ path = $powershell
+ sha256 = Get-ViiperSha256 -Path $powershell
+ }
+ script = [ordered]@{
+ path = $scriptFile
+ sha256 = Get-ViiperSha256 -Path $scriptFile
+ }
+ arguments = $childArguments
+ }
+ Write-ViiperJsonAtomic -Path (Join-Path $StepDirectory 'command.json') -Value $command
+
+ $joinedArguments = (($childArguments | ForEach-Object {
+ ConvertTo-WindowsCommandLineArgument -Value ([string]$_)
+ }) -join ' ')
+ $stdoutPath = Join-Path $StepDirectory 'stdout.log'
+ $stderrPath = Join-Path $StepDirectory 'stderr.log'
+ $started = $false
+ $exitCode = -1
+ $failure = $null
+ try {
+ Write-Host "Starting $Name; live output is retained in '$StepDirectory'."
+ $process = Start-Process -FilePath $powershell -ArgumentList $joinedArguments `
+ -WorkingDirectory $StepDirectory -NoNewWindow -Wait -PassThru `
+ -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath `
+ -ErrorAction Stop
+ $started = $true
+ $exitCode = $process.ExitCode
+ }
+ catch {
+ $failure = $_.Exception.Message
+ }
+ finally {
+ if (-not (Test-Path -LiteralPath $stdoutPath -PathType Leaf)) {
+ [IO.File]::WriteAllText($stdoutPath, '', [Text.UTF8Encoding]::new($false))
+ }
+ if (-not (Test-Path -LiteralPath $stderrPath -PathType Leaf)) {
+ [IO.File]::WriteAllText($stderrPath, '', [Text.UTF8Encoding]::new($false))
+ }
+ }
+ $result = [ordered]@{
+ schema = 'viiper.windows11.captured-result/v1'
+ name = $Name
+ completedUtc = [DateTime]::UtcNow.ToString('o')
+ started = $started
+ exitCode = [int]$exitCode
+ success = ($started -and $exitCode -eq 0 -and $null -eq $failure)
+ launchFailure = $failure
+ evidenceDirectory = $StepDirectory
+ }
+ Write-ViiperJsonAtomic -Path (Join-Path $StepDirectory 'result.json') -Value $result
+ return [pscustomobject]$result
+}
+
+function Assert-CapturedSuccess {
+ param([Parameter(Mandatory = $true)]$Result, [Parameter(Mandatory = $true)][string]$Label)
+ if (-not [bool]$Result.success) {
+ throw "$Label failed with exit code '$($Result.exitCode)'. Evidence: '$($Result.evidenceDirectory)'."
+ }
+}
+
+function Save-State {
+ param(
+ [Parameter(Mandatory = $true)][string]$Lifecycle,
+ [string]$PendingTransaction,
+ [string]$RequiredBootChangeFrom,
+ [string]$Note
+ )
+
+ $script:state.lifecycle = $Lifecycle
+ $script:state.pendingTransaction = $PendingTransaction
+ $script:state.requiredBootChangeFrom = $RequiredBootChangeFrom
+ $script:state.lastUpdatedUtc = [DateTime]::UtcNow.ToString('o')
+ $entry = [ordered]@{
+ utc = $script:state.lastUpdatedUtc
+ phase = $Phase
+ lifecycle = $Lifecycle
+ bootIdentity = Get-ViiperBootIdentity
+ note = $Note
+ }
+ $script:state.history = @($script:state.history) + @($entry)
+ Write-ViiperJsonAtomic -Path $script:statePath -Value $script:state
+}
+
+function Assert-Lifecycle {
+ param([Parameter(Mandatory = $true)][string[]]$Allowed)
+ if ($Allowed -cnotcontains [string]$script:state.lifecycle) {
+ throw "Phase '$Phase' is not valid from lifecycle '$($script:state.lifecycle)'; expected: $($Allowed -join ', ')."
+ }
+}
+
+function Get-ExactLocalTestTrustCount {
+ param([string]$StoreName, [string]$CertificatePath)
+
+ $certificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new($CertificatePath)
+ $store = [Security.Cryptography.X509Certificates.X509Store]::new(
+ $StoreName, [Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine)
+ $matches = $null
+ try {
+ $store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly)
+ $matches = $store.Certificates.Find(
+ [Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint,
+ $certificate.Thumbprint, $false)
+ $expected = [Convert]::ToBase64String($certificate.RawData)
+ $exact = @($matches | Where-Object {
+ [Convert]::ToBase64String($_.RawData) -ceq $expected
+ })
+ if ($matches.Count -ne $exact.Count -or $exact.Count -gt 1) {
+ throw "Certificate collision in LocalMachine\$StoreName."
+ }
+ return [int]$exact.Count
+ }
+ finally {
+ if ($null -ne $matches) { foreach ($match in $matches) { $match.Dispose() } }
+ $store.Close()
+ $certificate.Dispose()
+ }
+}
+
+function Invoke-InstallTransaction {
+ param([string]$OperationName)
+
+ $installer = Join-Path $script:viiperRoot 'native\udecx\tools\Install-ViiperUdeLocalTest.ps1'
+ return Invoke-CapturedPowerShell -Name $OperationName -ScriptPath $installer -Arguments @(
+ '-PackageRoot', $script:packageRoot,
+ '-ExpectedSourceRevision', [string]$manifest.viiper.sourceRevision,
+ '-ExpectedPackageLockSHA256', [string]$manifest.package.lockSha256,
+ '-TargetUserSID', $TargetUserSID,
+ '-AcknowledgeDisposableTestMachine'
+ )
+}
+
+function Invoke-UninstallTransaction {
+ param([string]$OperationName = 'uninstall')
+
+ $manager = Join-Path $bundleRoot ([string]$manifest.ds4Windows.packageManagerRelativePath).Replace('/', '\')
+ $priorOptIn = [Environment]::GetEnvironmentVariable('DS4WINDOWS_VIIPER_ALLOW_LOCAL_TEST', 'Process')
+ try {
+ $env:DS4WINDOWS_VIIPER_ALLOW_LOCAL_TEST = '1'
+ return Invoke-CapturedPowerShell -Name $OperationName -ScriptPath $manager -Arguments @(
+ '-Operation', 'Uninstall', '-TargetUserSID', $TargetUserSID,
+ '-AllowLocalTest', '-AcknowledgeDisposableTestMachine'
+ )
+ }
+ finally {
+ [Environment]::SetEnvironmentVariable(
+ 'DS4WINDOWS_VIIPER_ALLOW_LOCAL_TEST', $priorOptIn, 'Process')
+ }
+}
+
+function Invoke-FailedInstallRecoveryTransaction {
+ param(
+ [Parameter(Mandatory = $true)][string]$AuthorizationPath,
+ [Parameter(Mandatory = $true)][string]$AuthorizationSHA256,
+ [switch]$Resume
+ )
+
+ $manager = Join-Path $bundleRoot `
+ ([string]$manifest.ds4Windows.packageManagerRelativePath).Replace('/', '\')
+ $priorOptIn = [Environment]::GetEnvironmentVariable(
+ 'DS4WINDOWS_VIIPER_ALLOW_LOCAL_TEST', 'Process')
+ try {
+ $env:DS4WINDOWS_VIIPER_ALLOW_LOCAL_TEST = '1'
+ $arguments = @(
+ '-Operation', 'Recover', '-TargetUserSID', $TargetUserSID,
+ '-AllowLocalTest', '-AcknowledgeDisposableTestMachine',
+ '-RecoveryAuthorizationPath', $AuthorizationPath,
+ '-ExpectedRecoveryAuthorizationSHA256', $AuthorizationSHA256
+ )
+ if ($Resume) { $arguments += '-RecoveryResume' }
+ return Invoke-CapturedPowerShell -Name 'recover-failed-install' `
+ -ScriptPath $manager -Arguments $arguments
+ }
+ finally {
+ [Environment]::SetEnvironmentVariable(
+ 'DS4WINDOWS_VIIPER_ALLOW_LOCAL_TEST', $priorOptIn, 'Process')
+ }
+}
+
+function Complete-UninstallCleanup {
+ $crashState = Join-Path $script:evidence 'state\crash-policy-backup.json'
+ if (Test-Path -LiteralPath $crashState -PathType Leaf) {
+ $restore = Invoke-CapturedPowerShell -Name 'restore-crash-diagnostics' `
+ -ScriptPath (Join-Path $script:viiperRoot 'native\udecx\tools\Set-ViiperCrashDiagnostics.ps1') `
+ -Arguments @('-Mode', 'Restore', '-StatePath', $crashState)
+ Assert-CapturedSuccess -Result $restore -Label 'Crash-diagnostic policy restore'
+ }
+ # The source-bound native child lifetime-owns Trust -> Package -> Service
+ # and restores its durable journal baseline before releasing Trust. This
+ # orchestrator is verification-only: a successor may depend on the same
+ # certificate immediately after the native transaction returns.
+ $cleanup = [ordered]@{}
+ foreach ($storeName in @('Root', 'TrustedPublisher')) {
+ $expected = [int]$script:state.trustBeforeInstall.$storeName
+ $actual = Get-ExactLocalTestTrustCount -StoreName $storeName `
+ -CertificatePath $script:certificatePath
+ if ($actual -ne $expected) {
+ throw "Native Uninstall did not restore the exact LocalMachine\$storeName preflight trust baseline."
+ }
+ $cleanup[$storeName] = "verified-baseline-$expected"
+ }
+ Write-ViiperJsonAtomic -Path (Join-Path $script:evidence 'state\uninstall-cleanup.json') -Value $cleanup
+}
+
+# Verify every critical file before importing or invoking any bound tool.
+$boundPaths = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+foreach ($entry in @($manifest.files)) {
+ $relative = [string]$entry.path
+ if ($relative -cnotmatch '^[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*$' -or
+ -not $boundPaths.Add($relative) -or [long]$entry.length -le 0 -or
+ [string]$entry.sha256 -cnotmatch '^[0-9a-f]{64}$') {
+ throw "Bundle manifest has an unsafe or duplicate file entry '$relative'."
+ }
+ $path = Join-Path $bundleRoot $relative.Replace('/', [IO.Path]::DirectorySeparatorChar)
+ $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop
+ if ($item.PSIsContainer -or
+ ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
+ $item.Length -ne [long]$entry.length -or
+ (Get-ViiperSha256 -Path $item.FullName) -cne [string]$entry.sha256) {
+ throw "Bundle-bound file '$relative' does not match the manifest."
+ }
+}
+$gitPath = Resolve-ViiperRegularFile -Path $GitExecutable -Label 'Git executable'
+$goPath = Resolve-ViiperRegularFile -Path $GoExecutable -Label 'Go executable'
+if ((Split-Path -Leaf $gitPath) -ine 'git.exe' -or
+ (Split-Path -Leaf $goPath) -ine 'go.exe') {
+ throw 'Explicit source-bound tools must retain the canonical names git.exe and go.exe.'
+}
+if ((Get-ViiperSha256 -Path $gitPath) -cne [string]$manifest.tools.git.sha256 -or
+ (Get-ViiperSha256 -Path $goPath) -cne [string]$manifest.tools.go.sha256) {
+ throw 'The explicit Git or Go executable differs from the bundle-bound tool identity.'
+}
+$gitVersionOutput = @(& $gitPath --version 2>&1)
+if ($LASTEXITCODE -ne 0 -or $gitVersionOutput.Count -ne 1 -or
+ ([string]$gitVersionOutput[0]).Trim() -cne [string]$manifest.tools.git.version) {
+ throw 'The explicit Git executable version output differs from the bundle identity.'
+}
+$oldIdentityGoToolchain = [Environment]::GetEnvironmentVariable('GOTOOLCHAIN', 'Process')
+$goVersionExitCode = -1
+$goVersionOutput = @()
+try {
+ $env:GOTOOLCHAIN = 'local'
+ $goVersionOutput = @(& $goPath version 2>&1)
+ $goVersionExitCode = $LASTEXITCODE
+}
+finally {
+ [Environment]::SetEnvironmentVariable('GOTOOLCHAIN', $oldIdentityGoToolchain, 'Process')
+}
+if ($goVersionExitCode -ne 0 -or $goVersionOutput.Count -ne 1 -or
+ ([string]$goVersionOutput[0]).Trim() -cne [string]$manifest.tools.go.version) {
+ throw 'The explicit Go executable version output differs from the bundle identity.'
+}
+$viiperRelative = [string]$manifest.viiper.repositoryRelativePath
+$packageRelative = [string]$manifest.package.relativePath
+$managerRelative = [string]$manifest.ds4Windows.packageManagerRelativePath
+$runtimeMetadataRelative = [string]$manifest.ds4Windows.runtimeMetadataRelativePath
+$ds4ArtifactRelative = [string]$manifest.ds4Windows.artifactRelativePath
+$ds4ExecutableRelative = [string]$manifest.ds4Windows.executableRelativePath
+$ds4LiveRunnerRelative = [string]$manifest.ds4Windows.liveRunnerRelativePath
+$ds4LiveHarnessRelative = [string]$manifest.ds4Windows.liveHarnessRelativePath
+$sdlBinaryRelative = [string]$manifest.latency.sdlBinaryRelativePath
+foreach ($relativeInput in @($viiperRelative, $packageRelative, $managerRelative,
+ $runtimeMetadataRelative, $ds4ArtifactRelative, $ds4ExecutableRelative, $ds4LiveRunnerRelative,
+ $ds4LiveHarnessRelative, $sdlBinaryRelative)) {
+ if ($relativeInput -cnotmatch '^[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*$') {
+ throw "Bundle manifest has unsafe relative input '$relativeInput'."
+ }
+}
+$viiperRoot = Join-Path $bundleRoot $viiperRelative.Replace('/', '\')
+[void](Test-ViiperGitIdentity -RepositoryRoot $viiperRoot `
+ -ExpectedRevision ([string]$manifest.viiper.sourceRevision) `
+ -GitExecutable $gitPath -Label 'Bundled VIIPER source checkout')
+$packageRoot = Join-Path $bundleRoot $packageRelative.Replace('/', '\')
+[void](Test-ViiperLocalTestPackage -PackageRoot $packageRoot `
+ -ExpectedSourceRevision ([string]$manifest.viiper.sourceRevision) `
+ -ExpectedPackageLockSHA256 ([string]$manifest.package.lockSha256))
+$certificatePath = Join-Path $packageRoot 'ViiperUdeTest.cer'
+$runtimeMetadataPath = Resolve-ViiperRegularFile `
+ -Path (Join-Path $bundleRoot $runtimeMetadataRelative.Replace('/', '\')) `
+ -Label 'Bound DS4Windows runtime metadata'
+$runnerArtifactRoot = Split-Path -Parent $runtimeMetadataPath
+$ds4ArtifactRoot = Join-Path $bundleRoot $ds4ArtifactRelative.Replace('/', '\')
+$ds4ArtifactRoot = Resolve-ViiperSafeDirectory -Path $ds4ArtifactRoot `
+ -Label 'Bound DS4Windows published artifact'
+$unsafeDs4Directories = @(Get-ChildItem -LiteralPath $ds4ArtifactRoot -Directory -Recurse -Force |
+ Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 })
+if ($unsafeDs4Directories.Count -ne 0) {
+ throw "DS4Windows artifact contains reparse directory '$($unsafeDs4Directories[0].FullName)'."
+}
+$actualDs4Files = @(Get-ChildItem -LiteralPath $ds4ArtifactRoot -File -Recurse -Force)
+$expectedDs4Files = @($manifest.files | Where-Object {
+ ([string]$_.path).StartsWith($ds4ArtifactRelative.TrimEnd('/') + '/',
+ [StringComparison]::Ordinal)
+})
+if ($actualDs4Files.Count -ne [int]$manifest.ds4Windows.artifactFileCount -or
+ $expectedDs4Files.Count -ne $actualDs4Files.Count) {
+ throw 'DS4Windows artifact has missing or extra files relative to its exact bundle inventory.'
+}
+foreach ($file in $actualDs4Files) {
+ $relative = $file.FullName.Substring($bundleRoot.TrimEnd('\').Length + 1).Replace('\', '/')
+ if (-not $boundPaths.Contains($relative)) {
+ throw "DS4Windows artifact contains unbound file '$relative'."
+ }
+}
+$ds4ExecutablePath = Resolve-ViiperRegularFile `
+ -Path (Join-Path $ds4ArtifactRoot $ds4ExecutableRelative.Replace('/', '\')) `
+ -Label 'Bound DS4Windows executable'
+if ((Get-ViiperSha256 -Path $ds4ExecutablePath) -cne
+ [string]$manifest.ds4Windows.executableSha256) {
+ throw 'DS4Windows executable differs from its exact bundle identity.'
+}
+$ds4LiveRunnerPath = Resolve-ViiperRegularFile `
+ -Path (Join-Path $ds4ArtifactRoot $ds4LiveRunnerRelative.Replace('/', '\')) `
+ -Label 'DS4Windows live-validation runner'
+$ds4LiveHarnessPath = Resolve-ViiperRegularFile `
+ -Path (Join-Path $ds4ArtifactRoot $ds4LiveHarnessRelative.Replace('/', '\')) `
+ -Label 'DS4Windows live-validation harness'
+$sdlBinaryPath = Resolve-ViiperRegularFile `
+ -Path (Join-Path $viiperRoot $sdlBinaryRelative.Replace('/', '\')) `
+ -Label 'source-built SDL3 latency binary'
+if ((Get-ViiperSha256 -Path $ds4LiveRunnerPath) -cne
+ [string]$manifest.ds4Windows.liveRunnerSha256 -or
+ (Get-ViiperSha256 -Path $ds4LiveHarnessPath) -cne
+ [string]$manifest.ds4Windows.liveHarnessSha256 -or
+ (Get-ViiperSha256 -Path $sdlBinaryPath) -cne
+ [string]$manifest.latency.sdlBinarySha256) {
+ throw 'DS4Windows live-runner or SDL latency input differs from its exact bundle identity.'
+}
+
+$evidence = [IO.Path]::GetFullPath($EvidenceRoot)
+$bundlePrefix = $bundleRoot.TrimEnd('\') + '\'
+if ($evidence -ieq $bundleRoot -or
+ $evidence.StartsWith($bundlePrefix, [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'EvidenceRoot must be outside the immutable source-bound validation bundle.'
+}
+if (-not (Test-Path -LiteralPath $evidence)) {
+ [void][IO.Directory]::CreateDirectory($evidence)
+}
+$evidence = Resolve-ViiperSafeDirectory -Path $evidence -Label 'Evidence root'
+[void][IO.Directory]::CreateDirectory((Join-Path $evidence 'steps'))
+[void][IO.Directory]::CreateDirectory((Join-Path $evidence 'state'))
+$statePath = Join-Path $evidence 'state\validation-state.json'
+$state = $null
+if (Test-Path -LiteralPath $statePath -PathType Leaf) {
+ $state = Get-Content -LiteralPath $statePath -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop
+ if ([string]$state.schema -cne 'viiper.windows11.validation-state/v1' -or
+ [string]$state.bundleManifestSha256 -cne $actualManifestHash -or
+ [string]$state.targetUserSid -cne $TargetUserSID -or
+ [string]$state.machine -cne $env:COMPUTERNAME -or
+ [string]$state.viiperSourceRevision -cne [string]$manifest.viiper.sourceRevision -or
+ [string]$state.ds4WindowsSourceRevision -cne [string]$manifest.ds4Windows.sourceRevision -or
+ [string]$state.packageLockSha256 -cne [string]$manifest.package.lockSha256 -or
+ [string]$state.ds4WindowsExecutableSha256 -cne
+ [string]$manifest.ds4Windows.executableSha256) {
+ throw 'Existing evidence state belongs to a different bundle, user, machine, or schema.'
+ }
+}
+elseif ($Phase -notin @('Preflight', 'Status', 'RecoverFailedInstall')) {
+ throw "Phase '$Phase' requires a successful Preflight state in '$statePath'."
+}
+
+try {
+if ($Phase -ceq 'Status') {
+ [ordered]@{
+ result = 'status'
+ bundleManifestSha256 = $actualManifestHash
+ viiperSourceRevision = [string]$manifest.viiper.sourceRevision
+ ds4WindowsSourceRevision = [string]$manifest.ds4Windows.sourceRevision
+ ds4WindowsExecutableSha256 = [string]$manifest.ds4Windows.executableSha256
+ packageLockSha256 = [string]$manifest.package.lockSha256
+ ds4WindowsExecutable = [ordered]@{
+ path = $ds4ExecutablePath
+ sha256 = [string]$manifest.ds4Windows.executableSha256
+ }
+ tools = $manifest.tools
+ claims = $manifest.claims
+ state = $state
+ phaseModel = Get-ViiperValidationPhaseModel
+ } | ConvertTo-Json -Depth 20
+ return
+}
+
+Assert-Administrator
+$currentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
+if ($currentIdentity -cne $TargetUserSID) {
+ throw "Elevated session user SID '$currentIdentity' differs from TargetUserSID '$TargetUserSID'. Use same-account elevation."
+}
+
+if ($Phase -ceq 'RecoverFailedInstall') {
+ if ($null -ne $state) {
+ throw 'RecoverFailedInstall requires an EvidenceRoot without a current validation-state.json so a fresh Preflight remains possible.'
+ }
+ $requiredRecoveryInputs = [ordered]@{
+ PredecessorEvidenceRoot = $PredecessorEvidenceRoot
+ PredecessorInstallStepDirectory = $PredecessorInstallStepDirectory
+ ExpectedPredecessorStateSHA256 = $ExpectedPredecessorStateSHA256
+ ExpectedPredecessorInstallCommandSHA256 = $ExpectedPredecessorInstallCommandSHA256
+ ExpectedPredecessorInstallResultSHA256 = $ExpectedPredecessorInstallResultSHA256
+ ExpectedPredecessorInstallStdoutSHA256 = $ExpectedPredecessorInstallStdoutSHA256
+ ExpectedPredecessorInstallStderrSHA256 = $ExpectedPredecessorInstallStderrSHA256
+ ExpectedPredecessorBundleManifestSHA256 = $ExpectedPredecessorBundleManifestSHA256
+ ExpectedPredecessorViiperSourceRevision = $ExpectedPredecessorViiperSourceRevision
+ ExpectedPredecessorDS4WindowsSourceRevision = $ExpectedPredecessorDS4WindowsSourceRevision
+ ExpectedPredecessorPackageLockSHA256 = $ExpectedPredecessorPackageLockSHA256
+ ExpectedPredecessorCertificateSHA256 = $ExpectedPredecessorCertificateSHA256
+ }
+ $missingRecoveryInputs = @($requiredRecoveryInputs.GetEnumerator() |
+ Where-Object { [string]::IsNullOrWhiteSpace([string]$_.Value) })
+ if ($missingRecoveryInputs.Count -ne 0) {
+ throw "RecoverFailedInstall is missing source-bound input '$($missingRecoveryInputs[0].Key)'."
+ }
+ $predecessor = Test-ViiperFailedInstallRecoveryEvidence `
+ -PredecessorEvidenceRoot $PredecessorEvidenceRoot `
+ -PredecessorInstallStepDirectory $PredecessorInstallStepDirectory `
+ -ExpectedStateSHA256 $ExpectedPredecessorStateSHA256 `
+ -ExpectedInstallCommandSHA256 $ExpectedPredecessorInstallCommandSHA256 `
+ -ExpectedInstallResultSHA256 $ExpectedPredecessorInstallResultSHA256 `
+ -ExpectedInstallStdoutSHA256 $ExpectedPredecessorInstallStdoutSHA256 `
+ -ExpectedInstallStderrSHA256 $ExpectedPredecessorInstallStderrSHA256 `
+ -ExpectedBundleManifestSHA256 $ExpectedPredecessorBundleManifestSHA256 `
+ -ExpectedViiperSourceRevision $ExpectedPredecessorViiperSourceRevision `
+ -ExpectedDS4WindowsSourceRevision $ExpectedPredecessorDS4WindowsSourceRevision `
+ -ExpectedPackageLockSHA256 $ExpectedPredecessorPackageLockSHA256 `
+ -ExpectedMachine $env:COMPUTERNAME -ExpectedTargetUserSID $TargetUserSID
+ $predecessorCertificateHash = $ExpectedPredecessorCertificateSHA256.ToLowerInvariant()
+ if ((Get-ViiperSha256 -Path $certificatePath) -cne $predecessorCertificateHash) {
+ throw 'The current manifest-bound certificate is not byte-identical to the exact predecessor certificate authorized for cleanup.'
+ }
+
+ $recoveryReceiptPath = Join-Path $evidence 'state\failed-install-recovery.json'
+ $recoveryProgressPath = Join-Path $evidence `
+ 'state\failed-install-recovery-progress.json'
+ if (Test-Path -LiteralPath $recoveryReceiptPath -PathType Leaf) {
+ $receiptPath = Resolve-ViiperRegularFile -Path $recoveryReceiptPath `
+ -Label 'Failed-install recovery receipt'
+ $receipt = Get-Content -LiteralPath $receiptPath -Raw -Encoding UTF8 |
+ ConvertFrom-Json -ErrorAction Stop
+ if ([string]$receipt.schema -cne 'viiper.windows11.failed-install-recovery/v1' -or
+ [string]$receipt.result -cne 'complete' -or
+ [string]$receipt.machine -cne $env:COMPUTERNAME -or
+ [string]$receipt.targetUserSid -cne $TargetUserSID -or
+ [string]$receipt.currentBundleManifestSha256 -cne $actualManifestHash -or
+ [string]$receipt.predecessor.stateSha256 -cne [string]$predecessor.stateSha256 -or
+ [string]$receipt.predecessor.commandSha256 -cne [string]$predecessor.commandSha256 -or
+ [string]$receipt.predecessor.resultSha256 -cne [string]$predecessor.resultSha256 -or
+ [string]$receipt.predecessor.stdoutSha256 -cne [string]$predecessor.stdoutSha256 -or
+ [string]$receipt.predecessor.stderrSha256 -cne [string]$predecessor.stderrSha256 -or
+ [string]$receipt.predecessorCertificateSha256 -cne $predecessorCertificateHash -or
+ [int]$receipt.trustAfterRecovery.Root -ne 0 -or
+ [int]$receipt.trustAfterRecovery.TrustedPublisher -ne 0) {
+ throw 'Existing failed-install recovery receipt does not match the exact current and predecessor identities.'
+ }
+ if ((Get-ExactLocalTestTrustCount -StoreName 'Root' `
+ -CertificatePath $certificatePath) -ne 0 -or
+ (Get-ExactLocalTestTrustCount -StoreName 'TrustedPublisher' `
+ -CertificatePath $certificatePath) -ne 0) {
+ throw 'Completed recovery receipt exists but exact predecessor trust was reintroduced; refusing to claim idempotent completion.'
+ }
+ Write-Host "RecoverFailedInstall already completed with exact receipt '$receiptPath'. Next phase: Preflight."
+ return
+ }
+
+ $recoveryResume = $false
+ $firstAuthorizedUtc = [DateTime]::UtcNow.ToString('o')
+ $rootAuthorizationHash = $null
+ if (Test-Path -LiteralPath $recoveryProgressPath -PathType Leaf) {
+ $recoveryResume = $true
+ $progressPath = Resolve-ViiperRegularFile -Path $recoveryProgressPath `
+ -Label 'Failed-install recovery progress receipt'
+ $progress = Get-Content -LiteralPath $progressPath -Raw -Encoding UTF8 |
+ ConvertFrom-Json -ErrorAction Stop
+ if ([string]$progress.schema -cne
+ 'viiper.windows11.failed-install-recovery-progress/v1' -or
+ [string]$progress.status -cne 'native-attempt' -or
+ [string]$progress.currentBundleManifestSha256 -cne $actualManifestHash -or
+ [string]$progress.currentViiperSourceRevision -cne
+ [string]$manifest.viiper.sourceRevision -or
+ [string]$progress.machine -cne $env:COMPUTERNAME -or
+ [string]$progress.targetUserSid -cne $TargetUserSID -or
+ [string]$progress.predecessor.stateSha256 -cne
+ [string]$predecessor.stateSha256 -or
+ [string]$progress.predecessor.commandSha256 -cne
+ [string]$predecessor.commandSha256 -or
+ [string]$progress.predecessor.resultSha256 -cne
+ [string]$predecessor.resultSha256 -or
+ [string]$progress.predecessor.stdoutSha256 -cne
+ [string]$predecessor.stdoutSha256 -or
+ [string]$progress.predecessor.stderrSha256 -cne
+ [string]$predecessor.stderrSha256 -or
+ [string]$progress.predecessorCertificateSha256 -cne
+ $predecessorCertificateHash -or
+ [string]$progress.currentPackageLockSha256 -cne
+ [string]$manifest.package.lockSha256 -or
+ $progress.retryPermitted -ne $true -or
+ [bool]$progress.resume -ne $false -and
+ [string]::IsNullOrWhiteSpace(
+ [string]$progress.recoveryRootAuthorizationSha256)) {
+ throw 'Existing failed-install recovery progress does not match the exact current and predecessor identities.'
+ }
+ $firstAuthorizedUtc = [string]$progress.firstAuthorizedUtc
+ if ([string]::IsNullOrWhiteSpace($firstAuthorizedUtc)) {
+ throw 'Existing failed-install recovery progress lost its first authorization time.'
+ }
+ $existingProgressHash = Get-ViiperSha256 -Path $progressPath
+ $rootAuthorizationHash = if ([string]::IsNullOrWhiteSpace(
+ [string]$progress.recoveryRootAuthorizationSha256)) {
+ $existingProgressHash
+ } else {
+ ([string]$progress.recoveryRootAuthorizationSha256).ToLowerInvariant()
+ }
+ if ($rootAuthorizationHash -cnotmatch '^[0-9a-f]{64}$') {
+ throw 'Existing failed-install recovery progress has an invalid root authorization SHA-256.'
+ }
+ }
+ $trustAtAdmission = [ordered]@{
+ Root = Get-ExactLocalTestTrustCount -StoreName 'Root' `
+ -CertificatePath $certificatePath
+ TrustedPublisher = Get-ExactLocalTestTrustCount `
+ -StoreName 'TrustedPublisher' -CertificatePath $certificatePath
+ }
+ if (-not $recoveryResume -and
+ ([int]$trustAtAdmission.Root -ne 1 -or
+ [int]$trustAtAdmission.TrustedPublisher -ne 1)) {
+ throw 'Initial failed-install recovery requires exactly one matching predecessor certificate in both machine stores; no trust was changed.'
+ }
+ if ($recoveryResume -and
+ ([int]$trustAtAdmission.Root -notin @(0, 1) -or
+ [int]$trustAtAdmission.TrustedPublisher -notin @(0, 1))) {
+ throw 'Bound failed-install recovery retry permits only zero or one exact matching certificate per machine store; no trust was changed.'
+ }
+
+ # This immutable-identity attempt receipt is published before native entry.
+ # Its prior existence is the only authority to accept a cut between the two
+ # exact store deletions on a later invocation. Native code hashes and locks
+ # these bytes while it holds package -> service mutexes.
+ $progressValue = [ordered]@{
+ schema = 'viiper.windows11.failed-install-recovery-progress/v1'
+ status = 'native-attempt'
+ retryPermitted = $true
+ firstAuthorizedUtc = $firstAuthorizedUtc
+ currentBundleManifestSha256 = $actualManifestHash
+ currentViiperSourceRevision = [string]$manifest.viiper.sourceRevision
+ currentPackageLockSha256 = [string]$manifest.package.lockSha256
+ predecessor = $predecessor
+ predecessorCertificateSha256 = $predecessorCertificateHash
+ machine = $env:COMPUTERNAME
+ targetUserSid = $TargetUserSID
+ trustBeforeNativeAttempt = $trustAtAdmission
+ resume = $recoveryResume
+ updatedUtc = [DateTime]::UtcNow.ToString('o')
+ }
+ if ($recoveryResume) {
+ $progressValue.recoveryRootAuthorizationSha256 = $rootAuthorizationHash
+ }
+ Write-ViiperJsonAtomic -Path $recoveryProgressPath -Value $progressValue
+ $progressPath = Resolve-ViiperRegularFile -Path $recoveryProgressPath `
+ -Label 'Published failed-install recovery authorization'
+ $progressHash = Get-ViiperSha256 -Path $progressPath
+ if (-not $recoveryResume) {
+ $rootAuthorizationHash = $progressHash
+ }
+
+ # The manifest-bound manager invokes only the verify-only recordless helper.
+ # It never calls generic recover, remove, or uninstall, and it withholds its
+ # admission proof unless all C++ and Go broker journals are absent and the
+ # broker service, driver service,
+ # root/devnode, and Driver Store package topology are all absent. It removes
+ # the exact failed-install DER from both machine stores before releasing its
+ # package -> service mutexes, closing the successor-install race.
+ $transaction = Invoke-FailedInstallRecoveryTransaction `
+ -AuthorizationPath $progressPath -AuthorizationSHA256 $progressHash `
+ -Resume:$recoveryResume
+ Assert-CapturedSuccess -Result $transaction `
+ -Label 'Manifest-bound verify-only failed-install recovery'
+
+ $transactionResultPath = Resolve-ViiperRegularFile `
+ -Path (Join-Path ([string]$transaction.evidenceDirectory) 'result.json') `
+ -Label 'Failed-install recovery transaction result'
+ $transactionStdoutPath = Resolve-ViiperRegularFile `
+ -Path (Join-Path ([string]$transaction.evidenceDirectory) 'stdout.log') `
+ -Label 'Failed-install recovery transaction stdout'
+ $transactionStderrPath = Join-Path ([string]$transaction.evidenceDirectory) 'stderr.log'
+ $transactionStderrItem = Get-Item -LiteralPath $transactionStderrPath `
+ -Force -ErrorAction Stop
+ if ($transactionStderrItem.PSIsContainer -or
+ ($transactionStderrItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ throw 'Failed-install recovery transaction stderr is not a regular file.'
+ }
+ $transactionStdout = Get-Content -LiteralPath $transactionStdoutPath `
+ -Raw -Encoding UTF8
+ $resumeReceiptValue = if ($recoveryResume) { '1' } else { '0' }
+ $nativeReceiptPrefix = 'recovery-receipt operation=native-package-recover ' +
+ 'activeJournal=0 devices=0 packages=0 brokerService=0 ' +
+ 'driverService=0 successor=0 '
+ $nativeReceiptPattern = '(?m)^' + [regex]::Escape($nativeReceiptPrefix) +
+ 'trustRootBefore=(?[01]) ' +
+ 'trustTrustedPublisherBefore=(?[01]) ' +
+ 'trustRootAfter=0 trustTrustedPublisherAfter=0 ' +
+ 'marker=settled markerSha256=(?[0-9a-f]{64}) ' +
+ 'rootAuthorizationSha256=' + [regex]::Escape($rootAuthorizationHash) + ' ' +
+ 'authorizationSha256=' + [regex]::Escape($progressHash) + ' ' +
+ 'certificateSha256=' + [regex]::Escape($predecessorCertificateHash) +
+ ' sourceRevision=' + [regex]::Escape(
+ [string]$manifest.viiper.sourceRevision) +
+ ' resume=' + $resumeReceiptValue + '\r?$'
+ $nativeReceiptMatches = [regex]::Matches(
+ $transactionStdout, $nativeReceiptPattern)
+ if ($nativeReceiptMatches.Count -ne 1 -or
+ $transactionStdout -match '(?m)^result=.* operation=remove ') {
+ throw 'Journal recovery did not return one exact locked successor-absence and trust-cleanup receipt.'
+ }
+ $nativeTrustBefore = [ordered]@{
+ Root = [int]$nativeReceiptMatches[0].Groups['root'].Value
+ TrustedPublisher = [int]$nativeReceiptMatches[0].Groups['publisher'].Value
+ }
+ if (-not $recoveryResume -and
+ ([int]$nativeTrustBefore.Root -ne 1 -or
+ [int]$nativeTrustBefore.TrustedPublisher -ne 1)) {
+ throw 'Initial native recovery receipt lost its exact one-per-store trust admission.'
+ }
+ $nativeReceiptLine = $nativeReceiptMatches[0].Value.TrimEnd("`r")
+ $nativeReconciliation = [ordered]@{
+ evidenceDirectory = [string]$transaction.evidenceDirectory
+ resultSha256 = Get-ViiperSha256 -Path $transactionResultPath
+ stdoutSha256 = Get-ViiperSha256 -Path $transactionStdoutPath
+ stderrSha256 = Get-ViiperSha256 -Path $transactionStderrPath
+ authorizationSha256 = $progressHash
+ rootAuthorizationSha256 = $rootAuthorizationHash
+ settledMarkerSha256 = $nativeReceiptMatches[0].Groups['marker'].Value
+ lockedTrustCleanupReceipt = $nativeReceiptLine
+ }
+ $trustAfterRecovery = [ordered]@{
+ Root = Get-ExactLocalTestTrustCount -StoreName 'Root' `
+ -CertificatePath $certificatePath
+ TrustedPublisher = Get-ExactLocalTestTrustCount `
+ -StoreName 'TrustedPublisher' -CertificatePath $certificatePath
+ }
+ if ([int]$trustAfterRecovery.Root -ne 0 -or
+ [int]$trustAfterRecovery.TrustedPublisher -ne 0) {
+ throw 'Native locked recovery receipt was emitted but exact predecessor trust is not absent.'
+ }
+ $cleanup = [ordered]@{
+ performedBy = 'native-package-recover'
+ heldPackageThenServiceMutexes = $true
+ authorizationSha256 = $progressHash
+ trustBefore = $nativeTrustBefore
+ trustAfter = $trustAfterRecovery
+ }
+ $receiptValue = [ordered]@{
+ schema = 'viiper.windows11.failed-install-recovery/v1'
+ result = 'complete'
+ completedUtc = [DateTime]::UtcNow.ToString('o')
+ machine = $env:COMPUTERNAME
+ targetUserSid = $TargetUserSID
+ currentBundleManifestSha256 = $actualManifestHash
+ currentViiperSourceRevision = [string]$manifest.viiper.sourceRevision
+ currentPackageLockSha256 = [string]$manifest.package.lockSha256
+ predecessorCertificateSha256 = $predecessorCertificateHash
+ predecessor = $predecessor
+ nativeReconciliation = $nativeReconciliation
+ trustCleanup = $cleanup
+ trustAfterRecovery = $trustAfterRecovery
+ bootIdentity = Get-ViiperBootIdentity
+ testSigningWasNotChanged = $true
+ programDataWasNotDeletedByOrchestrator = $true
+ }
+ Write-ViiperJsonAtomic -Path $recoveryReceiptPath -Value $receiptValue
+ $publishedReceipt = Resolve-ViiperRegularFile -Path $recoveryReceiptPath `
+ -Label 'Published failed-install recovery receipt'
+ Write-Host "RecoverFailedInstall completed. Receipt: '$publishedReceipt'. Next phase: Preflight with the same current bundle and EvidenceRoot."
+ return
+}
+
+if ($Phase -ceq 'Preflight') {
+ if ($null -ne $state) { throw 'Preflight refuses to overwrite existing validation state.' }
+ $snapshot = Get-ViiperMachineEvidenceSnapshot
+ $snapshotPath = Join-Path (New-StepDirectory -Name 'machine-preflight') 'machine-snapshot.json'
+ Write-ViiperJsonAtomic -Path $snapshotPath -Value $snapshot
+ if ($null -eq $snapshot.operatingSystem -or
+ [uint32]$snapshot.operatingSystem.productType -ne 1 -or
+ [int]$snapshot.operatingSystem.buildNumber -lt 22000 -or
+ -not [Environment]::Is64BitOperatingSystem) {
+ throw "Preflight requires a 64-bit Windows 11 client. Snapshot: '$snapshotPath'."
+ }
+ if ($null -eq $snapshot.bootConfiguration -or
+ $snapshot.bootConfiguration.testSigning -ne $true) {
+ Write-Warning 'MANUAL REBOOT PROMPT: enable TESTSIGNING from an elevated prompt, reboot this disposable laptop, then rerun Preflight with the identical bundle digest and paths.'
+ throw "Local-test TESTSIGNING is not active. Snapshot: '$snapshotPath'."
+ }
+ Assert-ViiperPreflightPendingReboot `
+ -Snapshot $snapshot -SnapshotPath $snapshotPath
+ $drive = [IO.DriveInfo]::new([IO.Path]::GetPathRoot($evidence))
+ if ([uint64]$drive.AvailableFreeSpace -lt 10GB) {
+ throw 'Evidence volume needs at least 10 GB free before lifecycle, ETL, and dump collection.'
+ }
+ $preflight = Invoke-CapturedPowerShell -Name 'package-preflight' `
+ -ScriptPath (Join-Path $viiperRoot 'native\udecx\tools\Install-ViiperUdeLocalTest.ps1') `
+ -Arguments @(
+ '-PackageRoot', $packageRoot,
+ '-ExpectedSourceRevision', [string]$manifest.viiper.sourceRevision,
+ '-ExpectedPackageLockSHA256', [string]$manifest.package.lockSha256,
+ '-TargetUserSID', $TargetUserSID,
+ '-AcknowledgeDisposableTestMachine', '-PreflightOnly'
+ )
+ Assert-CapturedSuccess -Result $preflight -Label 'Exact local-test package preflight'
+ $state = [pscustomobject][ordered]@{
+ schema = 'viiper.windows11.validation-state/v1'
+ machine = $env:COMPUTERNAME
+ targetUserSid = $TargetUserSID
+ bundleManifestSha256 = $actualManifestHash
+ viiperSourceRevision = [string]$manifest.viiper.sourceRevision
+ ds4WindowsSourceRevision = [string]$manifest.ds4Windows.sourceRevision
+ packageLockSha256 = [string]$manifest.package.lockSha256
+ ds4WindowsExecutableSha256 = [string]$manifest.ds4Windows.executableSha256
+ createdUtc = [DateTime]::UtcNow.ToString('o')
+ lastUpdatedUtc = [DateTime]::UtcNow.ToString('o')
+ lifecycle = 'new'
+ pendingTransaction = $null
+ requiredBootChangeFrom = $null
+ ds4WindowsLiveEvidence = $null
+ latencyMatrixEvidence = $null
+ trustBeforeInstall = [ordered]@{
+ Root = Get-ExactLocalTestTrustCount -StoreName 'Root' -CertificatePath $certificatePath
+ TrustedPublisher = Get-ExactLocalTestTrustCount -StoreName 'TrustedPublisher' -CertificatePath $certificatePath
+ }
+ history = @()
+ }
+ Save-State -Lifecycle 'preflight-complete' -Note 'Exact identities and Windows 11 machine snapshot passed.'
+ Write-Host "Preflight passed. Evidence: '$evidence'. Next phase: Install."
+ return
+}
+
+if ($Phase -in @('Install', 'Repair')) {
+ if ($Phase -ceq 'Install') {
+ Assert-Lifecycle -Allowed @('preflight-complete', 'transaction-running',
+ 'transaction-failed')
+ }
+ else {
+ Assert-Lifecycle -Allowed @('installed', 'manual-complete', 'verifier-ready',
+ 'live-complete', 'performance-complete', 'latency-complete', 'transaction-running',
+ 'transaction-failed')
+ }
+ if ([string]$state.lifecycle -in @('transaction-running', 'transaction-failed') -and
+ [string]$state.pendingTransaction -cne $Phase) {
+ throw "A '$($state.pendingTransaction)' transaction is pending; '$Phase' cannot replace it."
+ }
+ Save-State -Lifecycle 'transaction-running' -PendingTransaction $Phase `
+ -RequiredBootChangeFrom (Get-ViiperBootIdentity) `
+ -Note "$Phase transaction child is starting."
+ $transaction = Invoke-InstallTransaction -OperationName $Phase.ToLowerInvariant()
+ if ([int]$transaction.exitCode -eq 3010) {
+ Save-State -Lifecycle 'awaiting-transaction-reboot' -PendingTransaction $Phase `
+ -RequiredBootChangeFrom (Get-ViiperBootIdentity) `
+ -Note 'Transaction stopped at the VIIPER safe reboot boundary.'
+ Write-Warning "MANUAL REBOOT PROMPT: restart Windows, then run Phase=RebootResume with identical inputs. Evidence: '$($transaction.evidenceDirectory)'."
+ return
+ }
+ if (-not [bool]$transaction.success) {
+ Save-State -Lifecycle 'transaction-failed' -PendingTransaction $Phase `
+ -Note "$Phase child failed; inspect its retained evidence before retrying."
+ }
+ Assert-CapturedSuccess -Result $transaction -Label "$Phase transaction"
+ Save-State -Lifecycle 'installed' -Note "$Phase transaction completed with exit code 0."
+ Write-Host "$Phase completed. Next phase: ManualChecks."
+ return
+}
+
+if ($Phase -ceq 'RebootResume') {
+ Assert-Lifecycle -Allowed @('awaiting-transaction-reboot', 'transaction-running')
+ $boot = Get-ViiperBootIdentity
+ if ([string]$state.requiredBootChangeFrom -ceq $boot) {
+ throw 'Required reboot has not occurred; boot identity is unchanged.'
+ }
+ $pending = [string]$state.pendingTransaction
+ if ($pending -in @('Install', 'Repair')) {
+ $transaction = Invoke-InstallTransaction -OperationName ('resume-' + $pending.ToLowerInvariant())
+ }
+ elseif ($pending -ceq 'Uninstall') {
+ $transaction = Invoke-UninstallTransaction
+ }
+ else { throw "Unknown pending transaction '$pending'." }
+ if ([int]$transaction.exitCode -eq 3010) {
+ Save-State -Lifecycle 'awaiting-transaction-reboot' -PendingTransaction $pending `
+ -RequiredBootChangeFrom $boot -Note 'Transaction requires another safe reboot boundary.'
+ Write-Warning 'MANUAL REBOOT PROMPT: restart Windows again, then rerun RebootResume with identical inputs.'
+ return
+ }
+ if (-not [bool]$transaction.success) {
+ Save-State -Lifecycle 'transaction-failed' -PendingTransaction $pending `
+ -Note "Resumed $pending child failed; inspect its retained evidence before retrying."
+ }
+ Assert-CapturedSuccess -Result $transaction -Label "Resumed $pending transaction"
+ if ($pending -ceq 'Uninstall') {
+ Save-State -Lifecycle 'uninstall-cleanup-pending' `
+ -Note 'Resumed uninstall completed; crash policy and test-trust cleanup remain.'
+ Complete-UninstallCleanup
+ Save-State -Lifecycle 'uninstalled' -Note 'Uninstall and local-test cleanup completed.'
+ Write-Warning 'MANUAL FINAL REBOOT PROMPT: restart Windows to apply restored diagnostics/pagefile policy, then archive the immutable bundle, state JSON, logs, ETL, and dumps.'
+ }
+ else {
+ Save-State -Lifecycle 'installed' -Note "Resumed $pending transaction completed."
+ }
+ return
+}
+
+if ($Phase -ceq 'ManualChecks') {
+ if ([string]$state.lifecycle -ceq 'awaiting-manual-reboot') {
+ if (-not $AcknowledgeManualReboot) {
+ Write-Warning 'MANUAL REBOOT PROMPT: perform a full Windows restart, then rerun ManualChecks with -AcknowledgeManualReboot.'
+ return
+ }
+ if ([string]$state.requiredBootChangeFrom -ceq (Get-ViiperBootIdentity)) {
+ throw 'Manual reboot acknowledgment was supplied but the boot identity is unchanged.'
+ }
+ Save-State -Lifecycle 'manual-complete' -Note 'Operator acknowledged full reboot and boot identity changed.'
+ Write-Host 'Manual lifecycle checks are complete. Next phase: EnableVerifier.'
+ return
+ }
+ Assert-Lifecycle -Allowed @('installed')
+ if (-not $AcknowledgePhysicalHotplug) {
+ Write-Warning "MANUAL HOTPLUG PROMPT: run only the bound DS4Windows executable '$ds4ExecutablePath'; disconnect and reconnect each physical DS4/DS5 over every intended USB/Bluetooth path; confirm reacquisition without duplicate virtual devices."
+ }
+ if (-not $AcknowledgeSleepWake) {
+ Write-Warning 'MANUAL SLEEP PROMPT: enter Windows sleep, wake the laptop, then confirm physical input, virtual HID, feedback, and audio endpoints recover.'
+ }
+ if (-not $AcknowledgeHibernateWake) {
+ Write-Warning 'MANUAL HIBERNATE PROMPT: hibernate and resume the laptop, then confirm physical input, virtual HID, feedback, and audio endpoints recover.'
+ }
+ if (-not ($AcknowledgePhysicalHotplug -and $AcknowledgeSleepWake -and
+ $AcknowledgeHibernateWake)) {
+ Write-Host 'Rerun ManualChecks with the three acknowledgments only after completing each physical check.'
+ return
+ }
+ $boot = Get-ViiperBootIdentity
+ Save-State -Lifecycle 'awaiting-manual-reboot' -RequiredBootChangeFrom $boot `
+ -Note 'Hotplug, sleep, and hibernate checks acknowledged; full reboot remains.'
+ Write-Warning 'MANUAL REBOOT PROMPT: perform a full Windows restart, then rerun ManualChecks with -AcknowledgeManualReboot.'
+ return
+}
+
+if ($Phase -ceq 'EnableVerifier') {
+ Assert-Lifecycle -Allowed @('manual-complete', 'enabling-verifier')
+ if ([string]$state.lifecycle -ceq 'manual-complete') {
+ Save-State -Lifecycle 'enabling-verifier' `
+ -Note 'Crash diagnostics and one-boot Driver Verifier setup started.'
+ }
+ $crashState = Join-Path $evidence 'state\crash-policy-backup.json'
+ $crashReady = $false
+ if (Test-Path -LiteralPath $crashState -PathType Leaf) {
+ $savedCrashPolicy = Get-Content -LiteralPath $crashState -Raw -Encoding UTF8 |
+ ConvertFrom-Json -ErrorAction Stop
+ if ([int]$savedCrashPolicy.schema -ne 1 -or
+ [string]$savedCrashPolicy.machine -cne $env:COMPUTERNAME) {
+ throw 'Existing crash-policy backup belongs to a different machine or schema.'
+ }
+ $currentCrashPolicy = Get-ItemProperty `
+ -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\CrashControl' `
+ -ErrorAction Stop
+ $dumpEnabledProperty = $currentCrashPolicy.PSObject.Properties['CrashDumpEnabled']
+ $keepProperty = $currentCrashPolicy.PSObject.Properties['AlwaysKeepMemoryDump']
+ $overwriteProperty = $currentCrashPolicy.PSObject.Properties['Overwrite']
+ $crashReady = $null -ne $dumpEnabledProperty -and
+ $null -ne $keepProperty -and $null -ne $overwriteProperty -and
+ [int]$dumpEnabledProperty.Value -eq 7 -and
+ [int]$keepProperty.Value -eq 1 -and [int]$overwriteProperty.Value -eq 1
+ if (-not $crashReady) {
+ $restoreAttempt = Invoke-CapturedPowerShell `
+ -Name 'restore-incomplete-crash-diagnostics-attempt' `
+ -ScriptPath (Join-Path $viiperRoot 'native\udecx\tools\Set-ViiperCrashDiagnostics.ps1') `
+ -Arguments @('-Mode', 'Restore', '-StatePath', $crashState)
+ Assert-CapturedSuccess -Result $restoreAttempt `
+ -Label 'Incomplete crash-diagnostic attempt restore'
+ $archivedCrashState = Join-Path $evidence ('state\crash-policy-failed-attempt-' +
+ [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ') + '.json')
+ Move-Item -LiteralPath $crashState -Destination $archivedCrashState -ErrorAction Stop
+ }
+ }
+ if (-not $crashReady) {
+ $crash = Invoke-CapturedPowerShell -Name 'enable-crash-diagnostics' `
+ -ScriptPath (Join-Path $viiperRoot 'native\udecx\tools\Set-ViiperCrashDiagnostics.ps1') `
+ -Arguments @('-Mode', 'Enable', '-DumpType', 'Automatic', '-StatePath', $crashState,
+ '-AcknowledgeDiskUse')
+ Assert-CapturedSuccess -Result $crash -Label 'Crash diagnostics enablement'
+ }
+ $verifier = Invoke-CapturedPowerShell -Name 'enable-driver-verifier' `
+ -ScriptPath (Join-Path $viiperRoot 'native\udecx\tools\Enable-ViiperUdeVerifierForNextBoot.ps1') `
+ -Arguments @(
+ '-SignedPackageDirectory', (Join-Path $packageRoot 'signed-package'),
+ '-SubmissionManifestPath', (Join-Path $packageRoot 'submission-manifest.json'),
+ '-ExpectedSourceRevision', [string]$manifest.viiper.sourceRevision,
+ '-SignatureValidationMode', 'LocalTest',
+ '-LocalTestCertificatePath', $certificatePath,
+ '-DisposableTestMachine')
+ Assert-CapturedSuccess -Result $verifier -Label 'Driver Verifier one-boot setup'
+ Save-State -Lifecycle 'awaiting-verifier-reboot' `
+ -RequiredBootChangeFrom (Get-ViiperBootIdentity) `
+ -Note 'Automatic crash diagnostics and one-boot Driver Verifier are configured.'
+ Write-Warning 'MANUAL VERIFIER REBOOT PROMPT: restart the disposable laptop. If it boot-loops, enter Safe Mode, run verifier.exe /reset, reboot, and collect dumps. Otherwise run VerifierResume.'
+ return
+}
+
+if ($Phase -ceq 'VerifierResume') {
+ Assert-Lifecycle -Allowed @('awaiting-verifier-reboot')
+ if ([string]$state.requiredBootChangeFrom -ceq (Get-ViiperBootIdentity)) {
+ throw 'Driver Verifier reboot has not occurred; boot identity is unchanged.'
+ }
+ $step = New-StepDirectory -Name 'verifier-resume-query'
+ $query = & (Join-Path $env:SystemRoot 'System32\verifier.exe') /query 2>&1 | Out-String
+ $queryExit = $LASTEXITCODE
+ [IO.File]::WriteAllText((Join-Path $step 'verifier-query.txt'), $query,
+ [Text.UTF8Encoding]::new($false))
+ if ($queryExit -ne 0 -or $query -notmatch '(?im)\bViiperUde\.sys\b') {
+ throw "Driver Verifier is not active for ViiperUde.sys. Evidence: '$step'."
+ }
+ Save-State -Lifecycle 'verifier-ready' -Note 'Boot changed and Driver Verifier query names ViiperUde.sys.'
+ Write-Host 'Verifier is active. Next phase: Live.'
+ return
+}
+
+if ($Phase -in @('Live', 'Performance')) {
+ if ($Phase -ceq 'Live') { Assert-Lifecycle -Allowed @('verifier-ready') }
+ else { Assert-Lifecycle -Allowed @('live-complete') }
+ $oldPath = $env:Path
+ try {
+ $env:Path = (Split-Path -Parent $goPath) + ';' +
+ (Split-Path -Parent $gitPath) + ';' + $oldPath
+ $commonArguments = @(
+ '-SignedPackageDirectory', (Join-Path $packageRoot 'signed-package'),
+ '-SubmissionManifestPath', (Join-Path $packageRoot 'submission-manifest.json'),
+ '-ExpectedSourceRevision', [string]$manifest.viiper.sourceRevision,
+ '-SignatureValidationMode', 'LocalTest',
+ '-LocalTestCertificatePath', $certificatePath,
+ '-Iterations', [string]$Iterations,
+ '-MediaProbePath', (Join-Path $packageRoot 'ViiperUdeMediaProbe.exe'),
+ '-InputProbePath', (Join-Path $packageRoot 'ViiperUdeInputProbe.exe'),
+ '-ProbeManifestPath', (Join-Path $packageRoot 'ViiperUdeLiveProbes.manifest.json'),
+ '-MediaDurationSeconds', [string]$MediaDurationSeconds,
+ '-RequireDriverVerifier', '-RestartRootDevice', '-DisposableTestMachine',
+ '-ManageInstalledBrokerService')
+ if ($Phase -ceq 'Live') {
+ $arguments = $commonArguments + @('-RepositoryRoot', $viiperRoot)
+ $result = Invoke-CapturedPowerShell -Name 'live-validation' `
+ -ScriptPath (Join-Path $viiperRoot 'native\udecx\tools\Invoke-ViiperUdeLiveValidation.ps1') `
+ -Arguments $arguments
+ }
+ else {
+ $step = New-StepDirectory -Name 'performance-validation'
+ $trace = Join-Path $step 'viiper-localtest-performance.etl'
+ $arguments = $commonArguments + @('-OutputPath', $trace)
+ $result = Invoke-CapturedPowerShell -Name 'performance-validation' `
+ -ScriptPath (Join-Path $viiperRoot 'native\udecx\tools\Invoke-ViiperUdePerformanceValidation.ps1') `
+ -Arguments $arguments -StepDirectory $step
+ }
+ }
+ finally { $env:Path = $oldPath }
+ Assert-CapturedSuccess -Result $result -Label "$Phase validation"
+ if ($Phase -ceq 'Live') {
+ $ds4Step = New-StepDirectory -Name 'ds4windows-live-validation'
+ $ds4EvidencePath = Join-Path $ds4Step 'ds4windows-live-validation.json'
+ $ds4Receipts = @(& $ds4LiveHarnessPath `
+ -RunnerPath $ds4LiveRunnerPath `
+ -MetadataPath $runtimeMetadataPath `
+ -ArtifactRoot $runnerArtifactRoot `
+ -OutputPath $ds4EvidencePath `
+ -Samples 256 `
+ -MediaSeconds 10 `
+ -AllowLocalTestPackage `
+ -IUnderstandThisExercisesLiveControllers)
+ if ($ds4Receipts.Count -ne 1 -or
+ [string]$ds4Receipts[0].status -cne 'pass' -or
+ [string]$ds4Receipts[0].evidencePath -cne $ds4EvidencePath) {
+ throw "DS4Windows live validation did not return one exact pass receipt. Evidence: '$ds4Step'."
+ }
+ $ds4Evidence = Resolve-ViiperRegularFile -Path $ds4EvidencePath `
+ -Label 'DS4Windows live-validation evidence'
+ $ds4EvidenceHash = Get-ViiperSha256 -Path $ds4Evidence
+ if ($ds4EvidenceHash -cne [string]$ds4Receipts[0].evidenceSha256) {
+ throw 'DS4Windows live-validation evidence changed after its exact receipt.'
+ }
+ $script:state.ds4WindowsLiveEvidence = [ordered]@{
+ path = $ds4Evidence
+ sha256 = $ds4EvidenceHash
+ consentNonceSha256 = [string]$ds4Receipts[0].consentNonceSha256
+ runnerSha256 = [string]$ds4Receipts[0].runnerSha256
+ metadataSha256 = [string]$ds4Receipts[0].metadataSha256
+ installedBrokerSha256 = [string]$ds4Receipts[0].installedBrokerSha256
+ driverStoreInfSha256 = [string]$ds4Receipts[0].driverStoreInfSha256
+ driverStoreCatSha256 = [string]$ds4Receipts[0].driverStoreCatSha256
+ driverStoreSysSha256 = [string]$ds4Receipts[0].driverStoreSysSha256
+ loadedDriverSha256 = [string]$ds4Receipts[0].loadedDriverSha256
+ }
+ Save-State -Lifecycle 'live-complete' `
+ -Note 'Reference and DS4Windows source-bound lifecycle/HID/media/reconnect validation passed.'
+ Write-Host 'Live validation passed. Next phase: Performance.'
+ }
+ else {
+ Save-State -Lifecycle 'performance-complete' `
+ -Note 'Local-test WPR performance capture passed; no native-versus-USB/IP claim is made.'
+ Write-Host "Performance capture completed. This is not ABBA evidence. Next phase: LatencyMatrix. Evidence: '$($result.evidenceDirectory)'."
+ }
+ return
+}
+
+if ($Phase -ceq 'LatencyMatrix') {
+ Assert-Lifecycle -Allowed @('performance-complete')
+ if ([string]$manifest.latency.packageValidationMode -cne 'LocalTest' -or
+ [int]$manifest.latency.cyclesPerPriority -lt 6 -or
+ ([int]$manifest.latency.cyclesPerPriority % 2) -ne 0 -or
+ [int]$manifest.latency.samplePairsPerTransition -lt 256) {
+ throw 'Bundle latency policy is not the exact LocalTest balanced-cycle policy.'
+ }
+ $step = New-StepDirectory -Name 'latency-matrix'
+ $matrixEvidenceRoot = Join-Path $step 'matrix'
+ [void][IO.Directory]::CreateDirectory($matrixEvidenceRoot)
+ $matrixResult = Invoke-CapturedPowerShell -Name 'latency-matrix' `
+ -ScriptPath (Join-Path $viiperRoot '_testing\e2e\scripts\Invoke-ViiperE2ELatencyMatrix.ps1') `
+ -Arguments @(
+ '-SignedPackageDirectory', (Join-Path $packageRoot 'signed-package'),
+ '-SubmissionManifestPath', (Join-Path $packageRoot 'submission-manifest.json'),
+ '-PackageValidationMode', 'LocalTest',
+ '-LocalTestCertificatePath', $certificatePath,
+ '-ExpectedSourceRevision', [string]$manifest.viiper.sourceRevision,
+ '-SDLBinarySHA256', [string]$manifest.latency.sdlBinarySha256,
+ '-EvidenceDirectory', $matrixEvidenceRoot,
+ '-Samples', [string]$manifest.latency.samplePairsPerTransition,
+ '-CyclesPerPriority', [string]$manifest.latency.cyclesPerPriority,
+ '-RepositoryRoot', $viiperRoot,
+ '-GitExecutable', $gitPath,
+ '-GoExecutable', $goPath
+ ) -StepDirectory $step
+ Assert-CapturedSuccess -Result $matrixResult -Label 'Source-bound latency matrix'
+ $matrixPath = Resolve-ViiperRegularFile `
+ -Path (Join-Path $matrixEvidenceRoot 'viiper-latency-priority-matrix.json') `
+ -Label 'latency priority matrix'
+ $superiorityPath = Resolve-ViiperRegularFile `
+ -Path (Join-Path $matrixEvidenceRoot 'viiper-latency-superiority.json') `
+ -Label 'latency superiority evidence'
+ $superiority = Get-Content -LiteralPath $superiorityPath -Raw -Encoding UTF8 |
+ ConvertFrom-Json -ErrorAction Stop
+ $certificateHash = Get-ViiperSha256 -Path $certificatePath
+ if ([string]$superiority.verdict -cne 'pass' -or
+ [string]$superiority.analysis.verdict -cne 'pass' -or
+ [string]$superiority.analysis.source_revision -cne [string]$manifest.viiper.sourceRevision -or
+ [string]$superiority.analysis.native_package_validation_mode -cne 'local-test' -or
+ [string]$superiority.analysis.native_local_test_certificate_sha256 -cne $certificateHash -or
+ [string]$superiority.analysis.native_package_manifest_sha256 -cne
+ (Get-ViiperSha256 -Path (Join-Path $packageRoot 'submission-manifest.json')) -or
+ [string]$superiority.analysis.native_driver_build_identity -cne
+ [string]$manifest.package.driverBuildIdentity) {
+ throw 'Latency analyzer returned a contradictory source/package/signer-bound result.'
+ }
+ $script:state.latencyMatrixEvidence = [ordered]@{
+ matrixPath = $matrixPath
+ matrixSha256 = Get-ViiperSha256 -Path $matrixPath
+ superiorityPath = $superiorityPath
+ superioritySha256 = Get-ViiperSha256 -Path $superiorityPath
+ cycleId = [string]$superiority.analysis.cycle_id
+ cycleCount = [int]$superiority.analysis.cycle_count
+ inferenceScope = [string]$superiority.analysis.inference_scope
+ verdict = 'pass'
+ }
+ Save-State -Lifecycle 'latency-complete' `
+ -Note 'Native latency was lower in every observed balanced cycle for this exact machine session.'
+ Write-Host "Latency matrix passed for this exact machine session. Evidence: '$superiorityPath'."
+ return
+}
+
+if ($Phase -ceq 'CollectDumps') {
+ Assert-Lifecycle -Allowed @('installed', 'manual-complete', 'awaiting-verifier-reboot',
+ 'enabling-verifier', 'verifier-ready', 'live-complete', 'performance-complete',
+ 'latency-complete', 'uninstall-cleanup-pending')
+ $destination = Join-Path (Join-Path $evidence 'dumps') (
+ [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ'))
+ $result = Invoke-CapturedPowerShell -Name 'collect-crash-dumps' `
+ -ScriptPath (Join-Path $viiperRoot 'native\udecx\tools\Copy-ViiperCrashDumps.ps1') `
+ -Arguments @('-Destination', $destination, '-MaxMiniDumps', '10',
+ '-GrantReadToSID', $TargetUserSID)
+ Assert-CapturedSuccess -Result $result -Label 'Crash-dump collection'
+ Write-Host "Crash dumps and their hash manifest were retained at '$destination'."
+ return
+}
+
+if ($Phase -ceq 'Uninstall') {
+ Assert-Lifecycle -Allowed @('installed', 'manual-complete', 'awaiting-verifier-reboot',
+ 'enabling-verifier', 'verifier-ready', 'live-complete', 'performance-complete',
+ 'latency-complete', 'uninstall-cleanup-pending', 'transaction-running', 'transaction-failed')
+ if ([string]$state.lifecycle -in @('transaction-running', 'transaction-failed') -and
+ [string]$state.pendingTransaction -cne 'Uninstall') {
+ throw "A '$($state.pendingTransaction)' transaction is pending; Uninstall cannot replace it."
+ }
+ if ([string]$state.lifecycle -cne 'uninstall-cleanup-pending') {
+ Save-State -Lifecycle 'transaction-running' -PendingTransaction 'Uninstall' `
+ -RequiredBootChangeFrom (Get-ViiperBootIdentity) `
+ -Note 'Uninstall transaction child is starting.'
+ $transaction = Invoke-UninstallTransaction
+ if ([int]$transaction.exitCode -eq 3010) {
+ Save-State -Lifecycle 'awaiting-transaction-reboot' -PendingTransaction 'Uninstall' `
+ -RequiredBootChangeFrom (Get-ViiperBootIdentity) `
+ -Note 'Uninstall stopped at the VIIPER safe reboot boundary.'
+ Write-Warning 'MANUAL REBOOT PROMPT: restart Windows, then run RebootResume to finish uninstall and local-test cleanup.'
+ return
+ }
+ if (-not [bool]$transaction.success) {
+ Save-State -Lifecycle 'transaction-failed' -PendingTransaction 'Uninstall' `
+ -Note 'Uninstall child failed; inspect its retained evidence before retrying.'
+ }
+ Assert-CapturedSuccess -Result $transaction -Label 'Uninstall transaction'
+ Save-State -Lifecycle 'uninstall-cleanup-pending' `
+ -Note 'VIIPER uninstall completed; crash policy and test-trust cleanup remain.'
+ }
+ Complete-UninstallCleanup
+ Save-State -Lifecycle 'uninstalled' `
+ -Note 'VIIPER uninstall, crash-policy restore, and non-preexisting test trust cleanup completed.'
+ Write-Warning 'MANUAL FINAL REBOOT PROMPT: restart Windows to apply restored diagnostics/pagefile policy, then archive the immutable bundle, state JSON, logs, ETL, and dumps.'
+ return
+}
+
+throw "Unhandled validation phase '$Phase'."
+}
+catch {
+ $phaseFailure = $_
+ try {
+ $failureStep = New-StepDirectory -Name ('orchestrator-' + $Phase + '-failure')
+ [IO.File]::WriteAllText((Join-Path $failureStep 'stdout.log'), '',
+ [Text.UTF8Encoding]::new($false))
+ [IO.File]::WriteAllText((Join-Path $failureStep 'stderr.log'),
+ ($phaseFailure | Out-String), [Text.UTF8Encoding]::new($false))
+ Write-ViiperJsonAtomic -Path (Join-Path $failureStep 'result.json') -Value ([ordered]@{
+ schema = 'viiper.windows11.phase-result/v1'
+ phase = $Phase
+ result = 'error'
+ completedUtc = [DateTime]::UtcNow.ToString('o')
+ message = $phaseFailure.Exception.Message
+ bundleManifestSha256 = $actualManifestHash
+ evidenceDirectory = $failureStep
+ })
+ Write-Warning "Phase failure evidence retained at '$failureStep'."
+ }
+ catch {
+ Write-Warning "Could not write secondary phase-failure receipt: $($_.Exception.Message)"
+ }
+ throw $phaseFailure
+}
diff --git a/extras/validation/New-ViiperWin11ValidationBundle.ps1 b/extras/validation/New-ViiperWin11ValidationBundle.ps1
new file mode 100644
index 0000000..64a2589
--- /dev/null
+++ b/extras/validation/New-ViiperWin11ValidationBundle.ps1
@@ -0,0 +1,342 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)][string]$ViiperSourceRoot,
+ [Parameter(Mandatory = $true)][string]$PackageRoot,
+ [Parameter(Mandatory = $true)][string]$DS4WindowsSourceRoot,
+ [Parameter(Mandatory = $true)][string]$DS4WindowsArtifactRoot,
+ [Parameter(Mandatory = $true)][string]$DS4WindowsExecutableRelativePath,
+ [Parameter(Mandatory = $true)][string]$DS4WindowsLiveRunnerRelativePath,
+ [Parameter(Mandatory = $true)][string]$DS4WindowsLiveHarnessRelativePath,
+ [Parameter(Mandatory = $true)][string]$OutputDirectory,
+ [Parameter(Mandatory = $true)]
+ [ValidatePattern('^[0-9a-fA-F]{40}$')]
+ [string]$ExpectedViiperSourceRevision,
+ [Parameter(Mandatory = $true)]
+ [ValidatePattern('^[0-9a-fA-F]{40}$')]
+ [string]$ExpectedDS4WindowsSourceRevision,
+ [Parameter(Mandatory = $true)]
+ [ValidatePattern('^[0-9a-fA-F]{64}$')]
+ [string]$ExpectedPackageLockSHA256,
+ [Parameter(Mandatory = $true)][string]$GitExecutable,
+ [Parameter(Mandatory = $true)][string]$GoExecutable
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$modulePath = Join-Path $PSScriptRoot 'ViiperWin11Validation.Common.psm1'
+Import-Module -Name $modulePath -Force -ErrorAction Stop
+
+function Assert-NoReparseEntries {
+ param([Parameter(Mandatory = $true)][string]$Root, [string]$Label)
+
+ $unsafe = @(Get-ChildItem -LiteralPath $Root -Force -Recurse -ErrorAction Stop |
+ Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 })
+ if ($unsafe.Count -ne 0) {
+ throw "$Label contains reparse entries; first unsafe path is '$($unsafe[0].FullName)'."
+ }
+}
+
+function Copy-DirectoryContents {
+ param([Parameter(Mandatory = $true)][string]$Source, [Parameter(Mandatory = $true)][string]$Destination)
+
+ [void][IO.Directory]::CreateDirectory($Destination)
+ Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop | ForEach-Object {
+ Copy-Item -LiteralPath $_.FullName -Destination $Destination -Recurse -Force -ErrorAction Stop
+ }
+}
+
+function New-BoundFileEntry {
+ param([Parameter(Mandatory = $true)][string]$BundleRoot, [Parameter(Mandatory = $true)][string]$RelativePath)
+
+ $path = Join-Path $BundleRoot $RelativePath.Replace('/', [IO.Path]::DirectorySeparatorChar)
+ $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop
+ if ($item.PSIsContainer -or
+ ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
+ $item.Length -le 0) {
+ throw "Bundle-bound file is unsafe or empty: '$RelativePath'."
+ }
+ return [ordered]@{
+ path = $RelativePath
+ length = [long]$item.Length
+ sha256 = Get-ViiperSha256 -Path $item.FullName
+ }
+}
+
+$viiperRevision = $ExpectedViiperSourceRevision.ToLowerInvariant()
+$ds4Revision = $ExpectedDS4WindowsSourceRevision.ToLowerInvariant()
+$packageLockSha256 = $ExpectedPackageLockSHA256.ToLowerInvariant()
+$gitPath = Resolve-ViiperRegularFile -Path $GitExecutable -Label 'Git executable'
+$goPath = Resolve-ViiperRegularFile -Path $GoExecutable -Label 'Go executable'
+if ((Split-Path -Leaf $gitPath) -ine 'git.exe' -or
+ (Split-Path -Leaf $goPath) -ine 'go.exe') {
+ throw 'Explicit source-bound tools must retain the canonical names git.exe and go.exe.'
+}
+$gitVersionOutput = @(& $gitPath --version 2>&1)
+if ($LASTEXITCODE -ne 0 -or $gitVersionOutput.Count -ne 1) {
+ throw "Explicit Git executable did not return one version line.`n$($gitVersionOutput -join [Environment]::NewLine)"
+}
+$oldGoToolchain = [Environment]::GetEnvironmentVariable('GOTOOLCHAIN', 'Process')
+$goVersionExitCode = -1
+$goVersionOutput = @()
+try {
+ $env:GOTOOLCHAIN = 'local'
+ $goVersionOutput = @(& $goPath version 2>&1)
+ $goVersionExitCode = $LASTEXITCODE
+}
+finally {
+ [Environment]::SetEnvironmentVariable('GOTOOLCHAIN', $oldGoToolchain, 'Process')
+}
+if ($goVersionExitCode -ne 0 -or $goVersionOutput.Count -ne 1) {
+ throw "Explicit Go executable did not return one version line.`n$($goVersionOutput -join [Environment]::NewLine)"
+}
+$viiperIdentity = Test-ViiperGitIdentity -RepositoryRoot $ViiperSourceRoot `
+ -ExpectedRevision $viiperRevision -GitExecutable $gitPath -Label 'VIIPER source checkout'
+$ds4Identity = Test-ViiperGitIdentity -RepositoryRoot $DS4WindowsSourceRoot `
+ -ExpectedRevision $ds4Revision -GitExecutable $gitPath -Label 'DS4Windows source checkout'
+$packageIdentity = Test-ViiperLocalTestPackage -PackageRoot $PackageRoot `
+ -ExpectedSourceRevision $viiperRevision -ExpectedPackageLockSHA256 $packageLockSha256
+$ds4Artifact = Resolve-ViiperSafeDirectory -Path $DS4WindowsArtifactRoot `
+ -Label 'DS4Windows published artifact root'
+if ($DS4WindowsExecutableRelativePath -cnotmatch
+ '^[A-Za-z0-9_.-]+(?:[\\/][A-Za-z0-9_.-]+)*$') {
+ throw "DS4Windows executable relative path is unsafe: '$DS4WindowsExecutableRelativePath'."
+}
+$ds4EntrypointRelative = $DS4WindowsExecutableRelativePath.Replace('\', '/')
+$ds4Entrypoint = Resolve-ViiperRegularFile `
+ -Path (Join-Path $ds4Artifact $DS4WindowsExecutableRelativePath) `
+ -Label 'DS4Windows published executable'
+$ds4LiveRunnerRelative = $DS4WindowsLiveRunnerRelativePath.Replace('\', '/')
+$ds4LiveHarnessRelative = $DS4WindowsLiveHarnessRelativePath.Replace('\', '/')
+foreach ($relative in @($ds4LiveRunnerRelative, $ds4LiveHarnessRelative)) {
+ if ($relative -cnotmatch '^[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*$') {
+ throw "DS4Windows live-validation relative path is unsafe: '$relative'."
+ }
+}
+$ds4LiveRunner = Resolve-ViiperRegularFile `
+ -Path (Join-Path $ds4Artifact $DS4WindowsLiveRunnerRelativePath) `
+ -Label 'DS4Windows live-validation runner'
+$ds4LiveHarness = Resolve-ViiperRegularFile `
+ -Path (Join-Path $ds4Artifact $DS4WindowsLiveHarnessRelativePath) `
+ -Label 'DS4Windows live-validation harness'
+if ((Split-Path -Leaf $ds4LiveRunner) -cne 'DS4Windows.ViiperLiveValidation.exe' -or
+ (Split-Path -Leaf $ds4LiveHarness) -cne 'Invoke-ViiperDs4WindowsLaptopValidation.ps1') {
+ throw 'DS4Windows live-validation inputs do not retain their canonical names.'
+}
+$sdlBinaryRelative = '_testing/e2e/deps/SDL/build/Debug/SDL3.dll'
+$sdlBinary = Resolve-ViiperRegularFile `
+ -Path (Join-Path $viiperIdentity.root $sdlBinaryRelative.Replace('/', '\')) `
+ -Label 'source-built SDL3 latency binary'
+
+$output = [IO.Path]::GetFullPath($OutputDirectory)
+if (Test-Path -LiteralPath $output) {
+ throw "Refusing to overwrite validation bundle output '$output'."
+}
+$outputParent = Split-Path -Parent $output
+if (-not (Test-Path -LiteralPath $outputParent -PathType Container)) {
+ throw "Validation bundle parent must already exist: '$outputParent'."
+}
+$outputParent = Resolve-ViiperSafeDirectory -Path $outputParent -Label 'Validation bundle parent'
+foreach ($sourceRoot in @($viiperIdentity.root, $ds4Identity.root, $packageIdentity.root,
+ $ds4Artifact)) {
+ $sourcePrefix = $sourceRoot.TrimEnd('\') + '\'
+ if ($output.StartsWith($sourcePrefix, [StringComparison]::OrdinalIgnoreCase) -or
+ $output -ieq $sourceRoot) {
+ throw "Validation bundle output must be outside every bound input: '$output'."
+ }
+}
+
+Assert-NoReparseEntries -Root $viiperIdentity.root -Label 'VIIPER source checkout'
+Assert-NoReparseEntries -Root $packageIdentity.root -Label 'Local-test package'
+Assert-NoReparseEntries -Root $ds4Artifact -Label 'DS4Windows published artifact'
+$ds4ArtifactFiles = @(Get-ChildItem -LiteralPath $ds4Artifact -File -Recurse -Force)
+if ($ds4ArtifactFiles.Count -eq 0) {
+ throw 'DS4Windows published artifact root is empty.'
+}
+
+$installerSource = Join-Path $viiperIdentity.root 'native\udecx\tools\Install-ViiperUdeLocalTest.ps1'
+$packageLock = Get-Content -LiteralPath $packageIdentity.lockPath -Raw -Encoding UTF8 |
+ ConvertFrom-Json -ErrorAction Stop
+$installerHash = Get-ViiperSha256 -Path $installerSource
+if ($installerHash -cne [string]$packageLock.installerScriptSha256) {
+ throw "The bound VIIPER checkout's local-test installer is not the one bound by the package lock."
+}
+
+$ds4ManagerSource = Join-Path $ds4Identity.root 'extras\manage-viiper-native-package.ps1'
+$ds4MetadataSource = Join-Path $ds4Identity.root 'extras\ViiperNativeRuntimeMetadata.json'
+$ds4ManagerSource = Resolve-ViiperRegularFile -Path $ds4ManagerSource -Label 'DS4Windows package manager'
+$ds4MetadataSource = Resolve-ViiperRegularFile -Path $ds4MetadataSource -Label 'DS4Windows runtime metadata'
+$metadata = Get-Content -LiteralPath $ds4MetadataSource -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop
+if ([int]$metadata.schemaVersion -ne 1 -or
+ [string]$metadata.releaseEligibility -cne 'local-test-evidence-only' -or
+ [string]$metadata.sourceRevision -cne $viiperRevision -or
+ [string]$metadata.loadedDriverBuildIdentity -cne $packageIdentity.driverBuildIdentity) {
+ throw 'DS4Windows runtime metadata is not bound to the explicit local-test VIIPER identity.'
+}
+$metadataLock = @($metadata.artifacts | Where-Object {
+ [string]$_.role -ceq 'local-test-package-lock'
+})
+if ($metadataLock.Count -ne 1 -or
+ [string]$metadataLock[0].sha256 -cne $packageLockSha256) {
+ throw 'DS4Windows runtime metadata is not bound to the explicit package lock digest.'
+}
+
+$runtimeScriptSource = Resolve-ViiperRegularFile `
+ -Path (Join-Path $PSScriptRoot 'Invoke-ViiperWin11Validation.ps1') `
+ -Label 'Validation orchestrator'
+$readmeSource = Resolve-ViiperRegularFile -Path (Join-Path $PSScriptRoot 'README.md') `
+ -Label 'Validation bundle guide'
+$commonSource = Resolve-ViiperRegularFile -Path $modulePath -Label 'Validation common module'
+
+$staging = Join-Path $outputParent (
+ ([IO.Path]::GetFileName($output)) + '.incomplete.' + [Guid]::NewGuid().ToString('N'))
+[void][IO.Directory]::CreateDirectory($staging)
+try {
+ Copy-Item -LiteralPath $runtimeScriptSource -Destination (Join-Path $staging 'Invoke-ViiperWin11Validation.ps1')
+ Copy-Item -LiteralPath $commonSource -Destination (Join-Path $staging 'ViiperWin11Validation.Common.psm1')
+ Copy-Item -LiteralPath $readmeSource -Destination (Join-Path $staging 'README.md')
+
+ $ds4ArtifactDestination = Join-Path $staging 'ds4-artifact'
+ Copy-DirectoryContents -Source $ds4Artifact -Destination $ds4ArtifactDestination
+ [void](Resolve-ViiperRegularFile `
+ -Path (Join-Path $ds4ArtifactDestination $DS4WindowsExecutableRelativePath) `
+ -Label 'Copied DS4Windows published executable')
+ $copiedDs4Files = @(Get-ChildItem -LiteralPath $ds4ArtifactDestination -File -Recurse -Force)
+ if ($copiedDs4Files.Count -ne $ds4ArtifactFiles.Count) {
+ throw 'Copied DS4Windows artifact has a different file count from its explicit input.'
+ }
+ foreach ($sourceFile in $ds4ArtifactFiles) {
+ $relative = $sourceFile.FullName.Substring($ds4Artifact.TrimEnd('\').Length + 1)
+ $copiedFile = Get-Item -LiteralPath (Join-Path $ds4ArtifactDestination $relative) `
+ -Force -ErrorAction Stop
+ if ($copiedFile.PSIsContainer -or $copiedFile.Length -ne $sourceFile.Length -or
+ (Get-ViiperSha256 -Path $copiedFile.FullName) -cne
+ (Get-ViiperSha256 -Path $sourceFile.FullName)) {
+ throw "Copied DS4Windows artifact differs at '$relative'."
+ }
+ }
+
+ $viiperDestination = Join-Path $staging 'viiper-source'
+ Copy-DirectoryContents -Source $viiperIdentity.root -Destination $viiperDestination
+ [void](Test-ViiperGitIdentity -RepositoryRoot $viiperDestination `
+ -ExpectedRevision $viiperRevision -GitExecutable $gitPath -Label 'Copied VIIPER source checkout')
+
+ $managerDestination = Join-Path $staging 'ds4-manager'
+ [void][IO.Directory]::CreateDirectory($managerDestination)
+ Copy-Item -LiteralPath $ds4ManagerSource -Destination (Join-Path $managerDestination 'manage-viiper-native-package.ps1')
+ Copy-Item -LiteralPath $ds4MetadataSource -Destination (Join-Path $managerDestination 'ViiperNativeRuntimeMetadata.json')
+ Copy-DirectoryContents -Source $packageIdentity.root `
+ -Destination (Join-Path $managerDestination 'viiper-native-package')
+ [void](Test-ViiperLocalTestPackage `
+ -PackageRoot (Join-Path $managerDestination 'viiper-native-package') `
+ -ExpectedSourceRevision $viiperRevision `
+ -ExpectedPackageLockSHA256 $packageLockSha256)
+
+ $criticalPaths = @(
+ 'Invoke-ViiperWin11Validation.ps1',
+ 'ViiperWin11Validation.Common.psm1',
+ 'README.md',
+ 'ds4-manager/manage-viiper-native-package.ps1',
+ 'ds4-manager/ViiperNativeRuntimeMetadata.json',
+ 'ds4-manager/viiper-native-package/local-test-package.lock.json',
+ 'viiper-source/native/udecx/tools/Install-ViiperUdeLocalTest.ps1',
+ 'viiper-source/native/udecx/tools/Invoke-ViiperUdeLiveValidation.ps1',
+ 'viiper-source/native/udecx/tools/Invoke-ViiperUdePerformanceValidation.ps1',
+ 'viiper-source/_testing/e2e/scripts/Invoke-ViiperE2ELatencyGate.ps1',
+ 'viiper-source/_testing/e2e/scripts/Invoke-ViiperE2ELatencyMatrix.ps1',
+ 'viiper-source/_testing/e2e/cmd/verifylatencymatrix/main.go',
+ 'viiper-source/native/udecx/tools/Enable-ViiperUdeVerifierForNextBoot.ps1',
+ 'viiper-source/native/udecx/tools/Set-ViiperCrashDiagnostics.ps1',
+ 'viiper-source/native/udecx/tools/Copy-ViiperCrashDumps.ps1',
+ 'viiper-source/native/udecx/tools/Test-ViiperUdeSignedPackage.ps1',
+ 'viiper-source/_testing/e2e/deps/SDL/build/Debug/SDL3.dll'
+ )
+ $boundFiles = @($criticalPaths | ForEach-Object {
+ New-BoundFileEntry -BundleRoot $staging -RelativePath $_
+ })
+ $boundFiles += @(Get-ChildItem -LiteralPath $ds4ArtifactDestination -File -Recurse -Force |
+ Sort-Object FullName | ForEach-Object {
+ $relative = $_.FullName.Substring($staging.TrimEnd('\').Length + 1).Replace('\', '/')
+ New-BoundFileEntry -BundleRoot $staging -RelativePath $relative
+ })
+ $manifest = [ordered]@{
+ schema = 'viiper.windows11.validation-bundle/v1'
+ createdUtc = [DateTime]::UtcNow.ToString('o')
+ localTestOnly = $true
+ disposableWindows11MachineRequired = $true
+ noWebDownload = $true
+ viiper = [ordered]@{
+ sourceRevision = $viiperRevision
+ repositoryRelativePath = 'viiper-source'
+ submodules = @($viiperIdentity.submodules)
+ }
+ package = [ordered]@{
+ relativePath = 'ds4-manager/viiper-native-package'
+ lockSha256 = $packageLockSha256
+ driverPackageVersion = $packageIdentity.driverPackageVersion
+ driverBuildIdentity = $packageIdentity.driverBuildIdentity
+ }
+ ds4Windows = [ordered]@{
+ sourceRevision = $ds4Revision
+ packageManagerRelativePath = 'ds4-manager/manage-viiper-native-package.ps1'
+ runtimeMetadataRelativePath = 'ds4-manager/ViiperNativeRuntimeMetadata.json'
+ artifactRelativePath = 'ds4-artifact'
+ executableRelativePath = $ds4EntrypointRelative
+ executableSha256 = Get-ViiperSha256 -Path $ds4Entrypoint
+ liveRunnerRelativePath = $ds4LiveRunnerRelative
+ liveRunnerSha256 = Get-ViiperSha256 -Path $ds4LiveRunner
+ liveHarnessRelativePath = $ds4LiveHarnessRelative
+ liveHarnessSha256 = Get-ViiperSha256 -Path $ds4LiveHarness
+ artifactFileCount = $ds4ArtifactFiles.Count
+ integrationEvidenceOnly = $true
+ endToEndValidated = $false
+ }
+ latency = [ordered]@{
+ sdlBinaryRelativePath = $sdlBinaryRelative
+ sdlBinarySha256 = Get-ViiperSha256 -Path $sdlBinary
+ packageValidationMode = 'LocalTest'
+ cyclesPerPriority = 8
+ samplePairsPerTransition = 10000
+ claim = 'descriptive for this exact source-bound machine session only'
+ }
+ tools = [ordered]@{
+ git = [ordered]@{
+ sha256 = Get-ViiperSha256 -Path $gitPath
+ version = ([string]$gitVersionOutput[0]).Trim()
+ }
+ go = [ordered]@{
+ sha256 = Get-ViiperSha256 -Path $goPath
+ version = ([string]$goVersionOutput[0]).Trim()
+ }
+ }
+ claims = [ordered]@{
+ ds4WindowsEndToEnd = $false
+ nativeVersusUsbipAbba = $false
+ nativeLatencySuperiority = $false
+ }
+ files = $boundFiles
+ }
+ $manifestPath = Join-Path $staging 'bundle-manifest.json'
+ Write-ViiperJsonAtomic -Path $manifestPath -Value $manifest
+ $manifestSha256 = Get-ViiperSha256 -Path $manifestPath
+ [IO.Directory]::Move($staging, $output)
+ $staging = $null
+
+ $receipt = [ordered]@{
+ result = 'success'
+ bundle = $output
+ manifest = Join-Path $output 'bundle-manifest.json'
+ manifestSha256 = $manifestSha256
+ viiperSourceRevision = $viiperRevision
+ ds4WindowsSourceRevision = $ds4Revision
+ packageLockSha256 = $packageLockSha256
+ next = 'Transfer the bundle plus this out-of-band manifest SHA-256 to the disposable Windows 11 laptop.'
+ }
+ $receipt | ConvertTo-Json -Depth 6
+}
+catch {
+ if ($null -ne $staging) {
+ Write-Warning "Incomplete bundle was retained for forensic inspection at '$staging'."
+ }
+ throw
+}
diff --git a/extras/validation/README.md b/extras/validation/README.md
new file mode 100644
index 0000000..51cec67
--- /dev/null
+++ b/extras/validation/README.md
@@ -0,0 +1,250 @@
+# VIIPER Windows 11 local-test validation bundle
+
+This directory builds and drives a source-bound, boot-resumable validation run
+on a disposable Windows 11 laptop. It is deliberately a local-test workflow.
+Building the bundle makes no runtime claim. A successful `Live` phase requires
+both the VIIPER reference gate and the real DS4Windows HID/media/reconnect
+runner. A successful `LatencyMatrix` phase records only that native latency was
+lower in every observed balanced cycle on that exact machine session; it makes
+no iid, confidence, population, or cross-machine claim.
+
+No step downloads anything. The builder requires explicit paths for an exact
+clean VIIPER Git checkout, exact local-test package, exact clean DS4Windows Git
+checkout, an explicit published DS4Windows artifact and entry point, Git
+executable, and Go executable. It copies the complete VIIPER
+checkout (including `.git` and initialized submodules), the exact package, and
+the DS4Windows package-maintenance boundary. The generated manifest binds every
+published DS4Windows artifact file, its executable, the critical scripts, and
+both tool executables. Transfer its printed manifest
+SHA-256 separately from the bundle and supply it on every laptop invocation.
+The DS4Windows artifact input must contain both the published app and the
+published `DS4Windows.ViiperLiveValidation` runner/harness.
+
+## Build the bundle
+
+Run the deterministic contract first in both available PowerShell hosts:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File `
+ .\extras\validation\Test-ViiperWin11ValidationContract.ps1
+
+pwsh.exe -NoProfile -File `
+ .\extras\validation\Test-ViiperWin11ValidationContract.ps1
+```
+
+Build the source-pinned SDL binary used by the latency gate before creating the
+bundle:
+
+```powershell
+$viiperSourceRoot = ''
+cmake -S (Join-Path $viiperSourceRoot '_testing\e2e\deps\SDL') `
+ -B (Join-Path $viiperSourceRoot '_testing\e2e\deps\SDL\build') -A x64
+cmake --build (Join-Path $viiperSourceRoot '_testing\e2e\deps\SDL\build') `
+ --config Debug
+```
+
+After these files are committed and the DS4Windows checkout is clean, build to
+an empty path outside every input checkout. Every identity is mandatory; there
+is no implicit package or network fallback.
+
+```powershell
+& .\extras\validation\New-ViiperWin11ValidationBundle.ps1 `
+ -ViiperSourceRoot $viiperSourceRoot `
+ -PackageRoot '' `
+ -DS4WindowsSourceRoot '' `
+ -DS4WindowsArtifactRoot '' `
+ -DS4WindowsExecutableRelativePath 'app\DS4Windows.exe' `
+ -DS4WindowsLiveRunnerRelativePath 'runner\DS4Windows.ViiperLiveValidation.exe' `
+ -DS4WindowsLiveHarnessRelativePath 'runner\Invoke-ViiperDs4WindowsLaptopValidation.ps1' `
+ -OutputDirectory '' `
+ -ExpectedViiperSourceRevision '' `
+ -ExpectedDS4WindowsSourceRevision '' `
+ -ExpectedPackageLockSHA256 '' `
+ -GitExecutable '' `
+ -GoExecutable ''
+```
+
+The laptop must have byte-identical Git and Go executables available at paths
+you explicitly pass. The script prepends only those validated executable
+directories for source-bound live work. It never searches for or downloads a
+replacement.
+
+## Run on the disposable laptop
+
+Use same-account elevation: the administrator prompt must belong to the same
+interactive SID that will run DS4Windows. Keep the bundle read-only after
+transfer, choose an evidence directory outside it, and retain the builder's
+manifest SHA-256 out of band.
+
+Every invocation repeats the same mandatory identity arguments:
+
+```powershell
+$common = @{
+ ExpectedBundleManifestSHA256 = ''
+ EvidenceRoot = 'E:\VIIPER-evidence'
+ TargetUserSID = 'S-1-5-21-...'
+ GitExecutable = 'C:\Program Files\Git\cmd\git.exe'
+ GoExecutable = 'C:\Tools\go\bin\go.exe'
+}
+
+& .\Invoke-ViiperWin11Validation.ps1 @common -Phase Status
+& .\Invoke-ViiperWin11Validation.ps1 @common -Phase Preflight
+& .\Invoke-ViiperWin11Validation.ps1 @common -Phase Install
+```
+
+### Recover an exact zero-change failed predecessor Install
+
+`RecoverFailedInstall` is a narrow migration boundary for a predecessor run
+whose immutable state and captured child evidence prove the exact
+`install-journal-broker-image-hash` rejection with `changed=0`, no reboot, and
+zero trust before Preflight. Use a newly manifest-bound bundle extracted
+outside OneDrive or any other sync/placeholder tree. Preserve the predecessor
+bundle and evidence read-only. Supply every reported predecessor digest; none
+is discovered or substituted:
+
+The source-bound R4 stdout proof is exactly 582 bytes of UTF-8 without a BOM:
+these five lines in this fixed order, each terminated by LF (including the
+last line). Its SHA-256 is
+`ca95fac3b8bd6fe7871a7f42400031f01ea946dc88786e9e9a746084144c205b`.
+
+```text
+local-test-trust store=Root action=add result=added
+local-test-trust store=Root action=verify-add result=present
+local-test-trust store=TrustedPublisher action=add result=added
+local-test-trust store=TrustedPublisher action=verify-add result=present
+VIIPER: error: install native driver and broker transaction: native driver helper failed with exit 4: exit status 4: result=error operation=install changed=0 rebootRequired=0 rollback=not-needed exitCode=4 phase="install-journal-broker-image-hash" win32Error=23 message="protected broker evidence differs from its immutable digest"
+```
+
+The bare canonical helper outcome is intentionally not an alternate proof.
+Arbitrary prefixes or suffixes, another VIIPER command context, near-miss
+fields, duplicate wrappers, any extra native result outcome, CRLF line endings,
+and a UTF-8 BOM are rejected.
+
+```powershell
+& .\Invoke-ViiperWin11Validation.ps1 @common -Phase RecoverFailedInstall `
+ -PredecessorEvidenceRoot '' `
+ -PredecessorInstallStepDirectory '' `
+ -ExpectedPredecessorStateSHA256 '' `
+ -ExpectedPredecessorInstallCommandSHA256 '' `
+ -ExpectedPredecessorInstallResultSHA256 '' `
+ -ExpectedPredecessorInstallStdoutSHA256 '' `
+ -ExpectedPredecessorInstallStderrSHA256 '' `
+ -ExpectedPredecessorBundleManifestSHA256 '' `
+ -ExpectedPredecessorViiperSourceRevision '' `
+ -ExpectedPredecessorDS4WindowsSourceRevision '' `
+ -ExpectedPredecessorPackageLockSHA256 '' `
+ -ExpectedPredecessorCertificateSHA256 ''
+```
+
+The phase invokes only the current manifest-bound recovery manager; it never
+manually deletes the protected ProgramData journal or certificate stores. The
+native child lifetime-owns Trust -> Package -> Service, verifies the exact
+recordless predecessor topology, and removes only the exact certificate bytes
+whose predecessor Preflight counts establish were newly introduced. Its atomic
+`state\failed-install-recovery.json` receipt makes cuts retryable without
+creating `validation-state.json`, so a successful recovery is followed by a
+fresh `Preflight` using the same current bundle and EvidenceRoot. TESTSIGNING is
+not changed, and this verify-only recordless recovery has no reboot-success
+boundary: any nonzero native result remains a hard stop.
+
+### Generic Run4 predecessor parser boundary (design only)
+
+The exact R4 parser above remains the only implemented failed-install
+admission path. A future DS4-side generic Run4 predecessor parser is currently
+a contract design, not recovery authority: it is not connected to a validation
+phase, manager operation, native command, argument, or capability schema.
+
+Before that API can be wired, one canonical source-bound input must name an
+exact predecessor evidence root and exact evidence files, with independently
+supplied hashes for every file. The parser must bind the predecessor manifest,
+VIIPER and DS4Windows revisions, package lock, machine, same-account target SID,
+lifecycle and pending transaction, exact command argv, captured result, and the
+complete stdout/stderr bytes. It must reject missing, extra, reordered,
+duplicated, prefixed, suffixed, malformed, or contradictory native outcomes and
+retain verified read-only, reciprocal-`FileShare.Read` leases while authority
+is consumed. Callers may provide expected identities; they may not manufacture
+authority by selecting a directory, digest, substring, or outcome grammar.
+
+No generic Run4 recovery invocation is permitted until the recovery API and
+its exact schemas are frozen together. That future contract must be introduced
+with executable PS5/PS7 adversarial fixtures before any native argv is added;
+it must not broaden or reinterpret the frozen 582-byte R4 proof.
+
+`Preflight` captures Windows build and architecture, boot identity/uptime,
+TESTSIGNING and pending reboot state, active power plan, AC/battery state,
+VBS/HVCI/hypervisor state, free disks, and a background-process snapshot. It
+also records read-only installed USB/IP comparator provenance: matching
+services and image hashes/signatures, signed-driver provider/version/signer,
+published INF hashes, and root hardware/instance IDs. This is provenance only,
+not comparative latency evidence.
+
+If Install, Repair, or Uninstall stops at VIIPER's safe reboot boundary, reboot
+manually and run `-Phase RebootResume` with the identical arguments. The state
+file checks that the boot identity changed before rerunning the exact pending
+transaction.
+
+After installation, perform the physical checks only when prompted:
+
+```powershell
+& .\Invoke-ViiperWin11Validation.ps1 @common -Phase ManualChecks `
+ -AcknowledgePhysicalHotplug -AcknowledgeSleepWake `
+ -AcknowledgeHibernateWake
+
+# Perform the prompted full reboot, then:
+& .\Invoke-ViiperWin11Validation.ps1 @common -Phase ManualChecks `
+ -AcknowledgeManualReboot
+```
+
+The prompts require physical DS4/DS5 disconnect/reconnect, sleep/wake,
+hibernate/wake, and a full reboot. A switch records the operator's completed
+check; it does not simulate hardware or power transitions. Start only the
+manifest-bound DS4Windows executable named by `-Phase Status` for these manual
+integration observations.
+
+Continue with crash diagnostics and one-boot Driver Verifier:
+
+```powershell
+& .\Invoke-ViiperWin11Validation.ps1 @common -Phase EnableVerifier
+# Reboot when prompted.
+& .\Invoke-ViiperWin11Validation.ps1 @common -Phase VerifierResume
+& .\Invoke-ViiperWin11Validation.ps1 @common -Phase Live
+& .\Invoke-ViiperWin11Validation.ps1 @common -Phase Performance
+& .\Invoke-ViiperWin11Validation.ps1 @common -Phase LatencyMatrix
+```
+
+`Live` invokes the VIIPER reference lifecycle/HID/media harness with exact
+LocalTest signature flags, source-built probes, owner-crash recovery, root
+restart recovery, and active Driver Verifier, then invokes the manifest-bound
+DS4Windows runner against the actual installed broker and Driver Store images.
+`Performance` retains WPR ETL and its evidence JSON; it is not an ABBA
+comparison. `LatencyMatrix` runs eight ABBA/BAAB cycles at each of Normal and
+High priority, binds raw ETL/decoded markers/package/test signer/USB-IP runtime,
+and requires native mean/p95/p99 to be lower for every controller transition in
+every observed cycle. Use `CollectDumps` after a crash (including after Safe
+Mode recovery):
+
+```powershell
+& .\Invoke-ViiperWin11Validation.ps1 @common -Phase CollectDumps
+```
+
+Finally, uninstall and restore the prior crash policy:
+
+```powershell
+& .\Invoke-ViiperWin11Validation.ps1 @common -Phase Uninstall
+```
+
+The uninstall phase uses the manifest-bound DS4Windows maintenance script. Its
+native child holds Trust -> Package -> Service, durably marks the exact trust
+owner `uninstalling` before topology mutation, restores only the recorded
+certificate baseline after exact topology absence, and then publishes
+`cleared`. The orchestrator only verifies that baseline and restores the
+recorded crash policy. Follow its final reboot prompt.
+
+## Evidence behavior
+
+Each child invocation gets a unique step directory containing `command.json`,
+`stdout.log`, `stderr.log`, and `result.json`. ETL, trace evidence JSON, machine
+snapshots, state transitions, uninstall cleanup, and copied dumps remain under
+the explicit evidence root. Failures keep their step directory and do not
+overwrite earlier evidence. A sudden verifier crash may interrupt the wrapper;
+after reboot, preserve dumps first and inspect the last state/history entry.
diff --git a/extras/validation/Test-ViiperWin11ValidationContract.ps1 b/extras/validation/Test-ViiperWin11ValidationContract.ps1
new file mode 100644
index 0000000..445af76
--- /dev/null
+++ b/extras/validation/Test-ViiperWin11ValidationContract.ps1
@@ -0,0 +1,1163 @@
+[CmdletBinding()]
+param()
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$modulePath = Join-Path $PSScriptRoot 'ViiperWin11Validation.Common.psm1'
+$builderPath = Join-Path $PSScriptRoot 'New-ViiperWin11ValidationBundle.ps1'
+$orchestratorPath = Join-Path $PSScriptRoot 'Invoke-ViiperWin11Validation.ps1'
+$managerPath = Join-Path (Split-Path -Parent $PSScriptRoot) `
+ 'manage-viiper-native-package.ps1'
+$readmePath = Join-Path $PSScriptRoot 'README.md'
+$r4FixturePath = Join-Path $PSScriptRoot `
+ 'fixtures\viiper-r4-failed-install.json'
+
+foreach ($path in @($modulePath, $builderPath, $orchestratorPath,
+ $managerPath, $readmePath, $r4FixturePath)) {
+ $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop
+ if ($item.PSIsContainer -or
+ ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
+ $item.Length -le 0) {
+ throw "Validation contract input is unsafe or empty: '$path'."
+ }
+}
+
+foreach ($path in @($modulePath, $builderPath, $orchestratorPath, $managerPath)) {
+ $tokens = $null
+ $parseErrors = $null
+ [void][Management.Automation.Language.Parser]::ParseFile(
+ $path, [ref]$tokens, [ref]$parseErrors)
+ if (@($parseErrors).Count -ne 0) {
+ throw "PowerShell parse failed for '$path': $(@($parseErrors | ForEach-Object Message) -join '; ')"
+ }
+}
+
+$validationModule = Import-Module -Name $modulePath -Force `
+ -PassThru -ErrorAction Stop
+$phaseModel = @(Get-ViiperValidationPhaseModel)
+$expectedPhases = @('RecoverFailedInstall', 'Preflight', 'Install', 'Repair', 'RebootResume', 'ManualChecks',
+ 'EnableVerifier', 'VerifierResume', 'Live', 'Performance', 'LatencyMatrix',
+ 'CollectDumps', 'Uninstall', 'Status')
+if ($phaseModel.Count -ne $expectedPhases.Count -or
+ @(Compare-Object -ReferenceObject $expectedPhases `
+ -DifferenceObject @($phaseModel | ForEach-Object { [string]$_.phase }) `
+ -CaseSensitive).Count -ne 0) {
+ throw 'Validation phase model is missing a boot-resume, repair, evidence, or uninstall phase.'
+}
+
+# The PendingFileRenameOperations value is optional. Exercise the private
+# property-bag helper in module scope so both Windows PowerShell 5.1 and
+# PowerShell 7 prove that absence/emptiness is benign while content is pending.
+$optionalRegistryCases = @(
+ [pscustomobject]@{
+ Label = 'absent optional value'
+ RegistryObject = [pscustomobject]@{ OtherValue = 1 }
+ Expected = $false
+ },
+ [pscustomobject]@{
+ Label = 'null optional value'
+ RegistryObject = [pscustomobject]@{
+ PendingFileRenameOperations = $null
+ }
+ Expected = $false
+ },
+ [pscustomobject]@{
+ Label = 'empty optional value collection'
+ RegistryObject = [pscustomobject]@{
+ PendingFileRenameOperations = [string[]]@()
+ }
+ Expected = $false
+ },
+ [pscustomobject]@{
+ Label = 'empty optional string'
+ RegistryObject = [pscustomobject]@{
+ PendingFileRenameOperations = ''
+ }
+ Expected = $false
+ },
+ [pscustomobject]@{
+ Label = 'nonempty optional value'
+ RegistryObject = [pscustomobject]@{
+ PendingFileRenameOperations = [string[]]@('', '\??\C:\pending.tmp')
+ }
+ Expected = $true
+ }
+)
+foreach ($case in $optionalRegistryCases) {
+ $actual = & $validationModule {
+ param($RegistryObject)
+ Test-ViiperNonemptyOptionalRegistryProperty `
+ -RegistryObject $RegistryObject `
+ -Name 'PendingFileRenameOperations'
+ } $case.RegistryObject
+ if ($actual -isnot [bool] -or $actual -ne [bool]$case.Expected) {
+ throw "Optional pending-rename registry contract failed: $($case.Label)."
+ }
+}
+
+$builder = Get-Content -LiteralPath $builderPath -Raw -Encoding UTF8
+$orchestrator = Get-Content -LiteralPath $orchestratorPath -Raw -Encoding UTF8
+$module = Get-Content -LiteralPath $modulePath -Raw -Encoding UTF8
+$manager = Get-Content -LiteralPath $managerPath -Raw -Encoding UTF8
+$readme = Get-Content -LiteralPath $readmePath -Raw -Encoding UTF8
+
+foreach ($fragment in @(
+ '[string]$ViiperSourceRoot', '[string]$PackageRoot',
+ '[string]$DS4WindowsSourceRoot', '[string]$ExpectedViiperSourceRevision',
+ '[string]$DS4WindowsArtifactRoot', '[string]$DS4WindowsExecutableRelativePath',
+ '[string]$DS4WindowsLiveRunnerRelativePath', '[string]$DS4WindowsLiveHarnessRelativePath',
+ '[string]$ExpectedDS4WindowsSourceRevision', '[string]$ExpectedPackageLockSHA256',
+ '[string]$GitExecutable', '[string]$GoExecutable',
+ "schema = 'viiper.windows11.validation-bundle/v1'",
+ 'endToEndValidated = $false', 'artifactRelativePath', 'executableSha256',
+ 'liveRunnerSha256', 'liveHarnessSha256', 'sdlBinarySha256',
+ 'nativeVersusUsbipAbba = $false',
+ 'nativeLatencySuperiority = $false', 'noWebDownload = $true',
+ 'Test-ViiperGitIdentity', 'Test-ViiperLocalTestPackage')) {
+ if (-not $builder.Contains($fragment)) {
+ throw "Bundle builder lost required fail-closed fragment '$fragment'."
+ }
+}
+
+$manifestCheck = $orchestrator.IndexOf('actualManifestHash', [StringComparison]::Ordinal)
+$moduleImport = $orchestrator.IndexOf('Import-Module -Name', [StringComparison]::Ordinal)
+if ($manifestCheck -lt 0 -or $moduleImport -le $manifestCheck) {
+ throw 'Orchestrator imports mutable bundle code before checking the out-of-band manifest digest.'
+}
+foreach ($fragment in @(
+ "'-SignatureValidationMode', 'LocalTest'",
+ "'-LocalTestCertificatePath'", "'-DisposableTestMachine'",
+ "'-ManageInstalledBrokerService'", "'-RequireDriverVerifier'",
+ "'-RestartRootDevice'", "'-PreflightOnly'",
+ "'stdout.log'", "'stderr.log'", "'result.json'", "'command.json'",
+ 'viiper-localtest-performance.etl', 'crash-policy-backup.json',
+ 'MANUAL HOTPLUG PROMPT', 'MANUAL SLEEP PROMPT',
+ 'MANUAL HIBERNATE PROMPT', 'MANUAL REBOOT PROMPT',
+ 'MANUAL VERIFIER REBOOT PROMPT', 'MANUAL FINAL REBOOT PROMPT',
+ "'DS4WINDOWS_VIIPER_ALLOW_LOCAL_TEST'", "'-Operation', 'Uninstall'",
+ 'ds4WindowsLiveEvidence', 'latencyMatrixEvidence',
+ '$ds4LiveHarnessPath',
+ 'Invoke-ViiperE2ELatencyMatrix.ps1',
+ "'-PackageValidationMode', 'LocalTest'",
+ 'Assert-ViiperPreflightPendingReboot',
+ "'RecoverFailedInstall'", 'Test-ViiperFailedInstallRecoveryEvidence',
+ "'-Operation', 'Recover'", "'-RecoveryAuthorizationPath'",
+ "'viiper.windows11.failed-install-recovery/v1'",
+ 'lockedTrustCleanupReceipt', 'heldPackageThenServiceMutexes = $true',
+ 'programDataWasNotDeletedByOrchestrator = $true',
+ 'This is not ABBA evidence')) {
+ if (-not $orchestrator.Contains($fragment)) {
+ throw "Orchestrator lost required lifecycle/evidence fragment '$fragment'."
+ }
+}
+$recoveryPhaseStart = $orchestrator.IndexOf(
+ "if (`$Phase -ceq 'RecoverFailedInstall')", [StringComparison]::Ordinal)
+$preflightPhaseStart = $orchestrator.IndexOf(
+ "if (`$Phase -ceq 'Preflight')", [StringComparison]::Ordinal)
+if ($recoveryPhaseStart -lt 0 -or $preflightPhaseStart -le $recoveryPhaseStart) {
+ throw 'RecoverFailedInstall phase region is malformed.'
+}
+$recoveryPhase = $orchestrator.Substring(
+ $recoveryPhaseStart, $preflightPhaseStart - $recoveryPhaseStart)
+if ($recoveryPhase.Contains('Remove-NewLocalTestTrust') -or
+ -not $recoveryPhase.Contains('recovery-receipt operation=native-package-recover') -or
+ -not $recoveryPhase.Contains('-Resume:$recoveryResume')) {
+ throw 'RecoverFailedInstall must verify native locked trust cleanup and must never delete trust itself.'
+}
+$transactionPhaseStart = $orchestrator.IndexOf(
+ "if (`$Phase -in @('Install', 'Repair'))", $preflightPhaseStart,
+ [StringComparison]::Ordinal)
+if ($transactionPhaseStart -le $preflightPhaseStart) {
+ throw 'Preflight phase region is malformed.'
+}
+$preflightPhase = $orchestrator.Substring(
+ $preflightPhaseStart, $transactionPhaseStart - $preflightPhaseStart)
+$snapshotPublication = $preflightPhase.IndexOf(
+ 'Write-ViiperJsonAtomic -Path $snapshotPath -Value $snapshot',
+ [StringComparison]::Ordinal)
+$pendingRebootGate = $preflightPhase.IndexOf(
+ 'Assert-ViiperPreflightPendingReboot', [StringComparison]::Ordinal)
+$packagePreflight = $preflightPhase.IndexOf(
+ "Invoke-CapturedPowerShell -Name 'package-preflight'",
+ [StringComparison]::Ordinal)
+$stateCreation = $preflightPhase.IndexOf(
+ '$state = [pscustomobject][ordered]@{', [StringComparison]::Ordinal)
+$statePublication = $preflightPhase.IndexOf(
+ "Save-State -Lifecycle 'preflight-complete'", [StringComparison]::Ordinal)
+if ($snapshotPublication -lt 0 -or
+ $pendingRebootGate -le $snapshotPublication -or
+ $packagePreflight -le $pendingRebootGate -or
+ $stateCreation -le $pendingRebootGate -or
+ $statePublication -le $stateCreation -or
+ $preflightPhase.Contains('$null -ne $snapshot.pendingReboot')) {
+ throw 'Preflight pending-reboot rejection must precede package work and validation-state creation.'
+}
+foreach ($fragment in @(
+ "[ValidateSet('Install', 'Recover', 'Uninstall')]",
+ "if (@('Install', 'Recover') -ccontains `$Operation)",
+ "'native-package-recover'", "'--certificate-path'",
+ "'--recovery-authorization'", "'--allow-partial-certificate-state'",
+ 'Assert-ExactR4FailedInstallRecoveryAuthorization',
+ 'Open-ExactR4FailedInstallEvidenceLeases',
+ '-Resume ([bool]$RecoveryResume)',
+ "-Role 'local-test-package-lock'",
+ '$Authorization.currentBundleManifestSha256',
+ 'viiper.native.failed-install-recovery-capability/v1',
+ 'failed-install-recovery-capability.json',
+ "'--recovery-capability'", "'--expected-recovery-capability-sha-256'",
+ "'--current-package-lock-sha-256'",
+ "'--current-bundle-manifest-sha-256'")) {
+ if (-not $manager.Contains($fragment)) {
+ throw "Package manager lost failed-install recovery fragment '$fragment'."
+ }
+}
+$eligibilityEnd = $manager.IndexOf(
+ "Unsupported VIIPER release eligibility", [StringComparison]::Ordinal)
+$recoveryCommand = $manager.IndexOf("'native-package-recover'",
+ [StringComparison]::Ordinal)
+if ($eligibilityEnd -lt 0 -or $recoveryCommand -le $eligibilityEnd) {
+ throw 'Recovery operation selection is incorrectly chained into eligibility validation.'
+}
+$capabilityReadLeaseStart = $manager.IndexOf(
+ 'function Open-VerifiedProtectedCapabilityReadLease',
+ [StringComparison]::Ordinal)
+$capabilityReadLeaseEnd = $manager.IndexOf(
+ 'function New-ProtectedLocalTestTrustCapability',
+ $capabilityReadLeaseStart, [StringComparison]::Ordinal)
+if ($capabilityReadLeaseStart -lt 0 -or
+ $capabilityReadLeaseEnd -le $capabilityReadLeaseStart) {
+ throw 'Protected capability read-lease verifier is malformed.'
+}
+$capabilityReadLeaseRegion = $manager.Substring(
+ $capabilityReadLeaseStart,
+ $capabilityReadLeaseEnd - $capabilityReadLeaseStart)
+foreach ($fragment in @(
+ '[IO.FileAccess]::Read, [IO.FileShare]::Read',
+ '[ViiperLocalTestTrustLeaseNative]::FinalPath(',
+ 'Assert-ProtectedStage -StagePath $expectedStage',
+ 'Assert-ExactProtectedTrustObjectSecurity',
+ '$item.Length -ne $ExpectedLength',
+ '$readLease.Length -ne $ExpectedLength',
+ '[ViiperLocalTestTrustLeaseNative]::LinkCount(',
+ '$algorithm.ComputeHash($readLease)',
+ '$readLease.Position = 0',
+ '-not $readLease.CanRead -or $readLease.CanWrite')) {
+ if (-not $capabilityReadLeaseRegion.Contains($fragment)) {
+ throw "Protected capability read lease lost exact verifier '$fragment'."
+ }
+}
+$recoveryCapabilityFunctionStart = $manager.IndexOf(
+ 'function New-ProtectedFailedInstallRecoveryCapability',
+ [StringComparison]::Ordinal)
+$recoveryCapabilityFunctionEnd = $manager.IndexOf(
+ 'function Remove-ProtectedStage', $recoveryCapabilityFunctionStart,
+ [StringComparison]::Ordinal)
+if ($recoveryCapabilityFunctionStart -lt 0 -or
+ $recoveryCapabilityFunctionEnd -le $recoveryCapabilityFunctionStart) {
+ throw 'Parent-bound failed-install recovery capability function is malformed.'
+}
+$recoveryCapabilityRegion = $manager.Substring(
+ $recoveryCapabilityFunctionStart,
+ $recoveryCapabilityFunctionEnd - $recoveryCapabilityFunctionStart)
+$capabilityValueStart = $recoveryCapabilityRegion.IndexOf(
+ '$value = [ordered]@{', [StringComparison]::Ordinal)
+if ($capabilityValueStart -lt 0) {
+ throw 'Recovery capability is not built from one ordered value.'
+}
+$recoveryCapabilityValueRegion =
+ $recoveryCapabilityRegion.Substring($capabilityValueStart)
+$capabilityPosition = -1
+foreach ($field in @(
+ "schema = 'viiper.native.failed-install-recovery-capability/v1'",
+ 'nonce =', 'parentPid =', 'parentCreationFileTime =', 'leasePath =',
+ 'sourceRevision =', 'helperSha256 =', 'certificateSha256 =',
+ 'recoveryAuthorizationSha256 =', 'recoveryRootAuthorizationSha256 =',
+ 'packageLockSha256 =', 'bundleManifestSha256 =',
+ 'allowPartialCertificateState =')) {
+ $next = $recoveryCapabilityValueRegion.IndexOf(
+ $field, [StringComparison]::Ordinal)
+ if ($next -le $capabilityPosition) {
+ throw "Recovery capability lost canonical ordered field '$field'."
+ }
+ $capabilityPosition = $next
+}
+
+foreach ($fragment in @(
+ 'viiper.native.local-test-trust-capability/v1',
+ 'viiper.native.local-test-trust-ownership/v1',
+ 'certificatePath = [IO.Path]::GetFullPath($CertificatePath)',
+ 'certificateSha256 = $CertificateSHA256.ToLowerInvariant()',
+ 'packageLockSha256 = $PackageLockSHA256.ToLowerInvariant()',
+ 'trustJournalSchema =', 'trustJournalDirectory =',
+ "'--local-test-certificate-path'",
+ "'--expected-local-test-certificate-sha-256'",
+ "'--expected-local-test-package-lock-sha-256'")) {
+ if (-not $manager.Contains($fragment)) {
+ throw "Package manager lost native-owned local-test trust binding '$fragment'."
+ }
+}
+$localCapabilityFunctionStart = $manager.IndexOf(
+ 'function New-ProtectedLocalTestTrustCapability',
+ [StringComparison]::Ordinal)
+$localCapabilityFunctionEnd = $manager.IndexOf(
+ 'function New-ProtectedFailedInstallRecoveryCapability',
+ $localCapabilityFunctionStart, [StringComparison]::Ordinal)
+if ($localCapabilityFunctionStart -lt 0 -or
+ $localCapabilityFunctionEnd -le $localCapabilityFunctionStart) {
+ throw 'Parent-bound local-test trust capability function is malformed.'
+}
+$localCapabilityRegion = $manager.Substring(
+ $localCapabilityFunctionStart,
+ $localCapabilityFunctionEnd - $localCapabilityFunctionStart)
+$localCapabilityValueStart = $localCapabilityRegion.IndexOf(
+ '$value = [ordered]@{', [StringComparison]::Ordinal)
+if ($localCapabilityValueStart -lt 0) {
+ throw 'Local-test trust capability is not built from one ordered value.'
+}
+$localCapabilityValueRegion =
+ $localCapabilityRegion.Substring($localCapabilityValueStart)
+$localCapabilityPosition = -1
+foreach ($field in @(
+ "schema = 'viiper.native.local-test-trust-capability/v1'",
+ 'nonce =', 'parentPid =', 'parentCreationFileTime =',
+ 'sourceRevision =', 'certificatePath =', 'certificateSha256 =',
+ 'packageLockSha256 =', 'trustJournalSchema =',
+ 'trustJournalDirectory =')) {
+ $next = $localCapabilityValueRegion.IndexOf(
+ $field, [StringComparison]::Ordinal)
+ if ($next -le $localCapabilityPosition) {
+ throw "Local-test trust capability lost canonical ordered field '$field'."
+ }
+ $localCapabilityPosition = $next
+}
+foreach ($capabilityContract in @(
+ [pscustomobject]@{
+ Label = 'local-test trust'
+ Region = $localCapabilityRegion
+ },
+ [pscustomobject]@{
+ Label = 'failed-install recovery'
+ Region = $recoveryCapabilityRegion
+ })) {
+ $creationHandleClose = $capabilityContract.Region.IndexOf(
+ '$creationStream.Dispose()', [StringComparison]::Ordinal)
+ $creationHandleRelease = $capabilityContract.Region.IndexOf(
+ '$creationStream = $null', $creationHandleClose,
+ [StringComparison]::Ordinal)
+ $readLeaseOpen = $capabilityContract.Region.IndexOf(
+ '$readLease = Open-VerifiedProtectedCapabilityReadLease',
+ [StringComparison]::Ordinal)
+ $readLeaseReturn = $capabilityContract.Region.IndexOf(
+ 'Stream = $readLease', [StringComparison]::Ordinal)
+ if ($creationHandleClose -lt 0 -or
+ $creationHandleRelease -le $creationHandleClose -or
+ $readLeaseOpen -le $creationHandleRelease -or
+ $readLeaseReturn -le $readLeaseOpen -or
+ $capabilityContract.Region.Contains('Stream = $creationStream')) {
+ throw "Protected $($capabilityContract.Label) capability does not close its write-capable creation handle before retaining only the verified read lease."
+ }
+}
+$managerMainIndex = $manager.IndexOf('$programDataRoot =', [StringComparison]::Ordinal)
+$managerMain = $manager.Substring($managerMainIndex)
+$localCapabilityCreation = $managerMain.IndexOf(
+ '$trustCapability = New-ProtectedLocalTestTrustCapability',
+ [StringComparison]::Ordinal)
+$recoveryCapabilityCreation = $managerMain.IndexOf(
+ '$recoveryCapability = New-ProtectedFailedInstallRecoveryCapability',
+ [StringComparison]::Ordinal)
+$recoveryEvidenceLease = $managerMain.IndexOf(
+ '$script:recoveryPredecessorLeases = @(',
+ [StringComparison]::Ordinal)
+$uninstallTrustBinding = $managerMain.IndexOf(
+ "'--local-test-certificate-path', `$certificatePath",
+ [StringComparison]::Ordinal)
+$joinedChild = $managerMain.IndexOf(
+ '$processResult = Invoke-JoinedNativeProcess',
+ [StringComparison]::Ordinal)
+$recoveryEvidenceRelease = $managerMain.IndexOf(
+ 'Close-ExactR4FailedInstallEvidenceLeases', $joinedChild,
+ [StringComparison]::Ordinal)
+if ($managerMainIndex -lt 0 -or $localCapabilityCreation -lt 0 -or
+ $recoveryCapabilityCreation -lt 0 -or $recoveryEvidenceLease -lt 0 -or
+ $uninstallTrustBinding -lt 0 -or
+ $joinedChild -le $localCapabilityCreation -or
+ $joinedChild -le $recoveryCapabilityCreation -or
+ $joinedChild -le $recoveryEvidenceLease -or
+ $joinedChild -le $uninstallTrustBinding -or
+ $recoveryEvidenceRelease -le $joinedChild) {
+ throw 'Package manager lost capability/uninstall identity staging before the joined native trust owner.'
+}
+foreach ($forbiddenMutation in @(
+ 'Open-ProtectedTrustManagerLease',
+ 'Enter-LocalTestTrustInstallJournal',
+ 'Enter-LocalTestTrustUninstallJournal',
+ 'Complete-LocalTestTrustJournal',
+ 'Ensure-ExactLocalTestTrust', 'Remove-NewLocalTestTrust',
+ '[Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite',
+ '$store.Add(', '$store.Remove(')) {
+ if ($managerMain.Contains($forbiddenMutation)) {
+ throw "Package manager main flow retained parent-side trust mutation '$forbiddenMutation'."
+ }
+}
+if ($orchestrator.Contains('Remove-NewLocalTestTrust')) {
+ throw 'Validation orchestrator must not mutate trust after package-manager Uninstall releases its lease.'
+}
+$cleanupStart = $orchestrator.IndexOf(
+ 'function Complete-UninstallCleanup', [StringComparison]::Ordinal)
+$cleanupEnd = $orchestrator.IndexOf(
+ '# Verify every critical file before importing', $cleanupStart,
+ [StringComparison]::Ordinal)
+if ($cleanupStart -lt 0 -or $cleanupEnd -le $cleanupStart) {
+ throw 'Validation Uninstall cleanup region is malformed.'
+}
+$cleanupRegion = $orchestrator.Substring($cleanupStart, $cleanupEnd - $cleanupStart)
+if ($cleanupRegion.Contains('ReadWrite') -or $cleanupRegion.Contains('.Remove(') -or
+ -not $cleanupRegion.Contains('verified-baseline-')) {
+ throw 'Validation Uninstall cleanup must be trust-verification-only.'
+}
+
+$liveStateInitialization = $orchestrator.IndexOf('ds4WindowsLiveEvidence = $null',
+ [StringComparison]::Ordinal)
+$liveStateAssignment = $orchestrator.IndexOf('$script:state.ds4WindowsLiveEvidence =',
+ [StringComparison]::Ordinal)
+$latencyStateInitialization = $orchestrator.IndexOf('latencyMatrixEvidence = $null',
+ [StringComparison]::Ordinal)
+$latencyStateAssignment = $orchestrator.IndexOf('$script:state.latencyMatrixEvidence =',
+ [StringComparison]::Ordinal)
+if ($liveStateInitialization -lt 0 -or $liveStateInitialization -ge $liveStateAssignment -or
+ $latencyStateInitialization -lt 0 -or
+ $latencyStateInitialization -ge $latencyStateAssignment) {
+ throw 'Strict validation state does not declare live/latency evidence before assignment.'
+}
+if ([regex]::Matches($orchestrator, 'MANUAL FINAL REBOOT PROMPT').Count -ne 2) {
+ throw 'Direct and reboot-resumed uninstall must both emit the final reboot prompt.'
+}
+foreach ($fragment in @(
+ "schema = 'viiper.windows11.machine-snapshot/v1'", 'activePowerPlan',
+ 'battery = $battery', 'lastBootUpUtc', 'hypervisorPresent',
+ 'virtualizationBasedSecurityStatus', 'hvciRegistry', 'testSigning',
+ 'pendingReboot', 'fixedDisks', 'backgroundProcesses', 'usbipComparator',
+ 'usbipServices', 'usbipSignedDrivers', 'usbipDeviceInstances',
+ 'driverStoreEnumeration', "@('/enum-drivers', '/files')",
+ 'publishedInf', 'hardwareIds', 'instanceId', 'signer', 'driverVersion',
+ 'provenance-only; no ABBA or latency-superiority claim')) {
+ if (-not $module.Contains($fragment)) {
+ throw "Machine provenance snapshot lost required fragment '$fragment'."
+ }
+}
+foreach ($forbidden in @('Invoke-WebRequest', 'Invoke-RestMethod', 'Start-BitsTransfer',
+ 'System.Net.WebClient', 'Verb = "runas"')) {
+ if (($builder + $orchestrator + $module).IndexOf($forbidden,
+ [StringComparison]::OrdinalIgnoreCase) -ge 0) {
+ throw "Validation bundle contains forbidden download/elevation/claim path '$forbidden'."
+ }
+}
+foreach ($fragment in @('Building the bundle makes no runtime claim',
+ 'DS4Windows HID/media/reconnect',
+ 'lower in every observed balanced cycle on that exact machine session',
+ 'no iid, confidence, population, or cross-machine claim',
+ 'No step downloads anything',
+ 'bare canonical helper outcome is intentionally not an alternate proof',
+ 'duplicate wrappers, any extra native result outcome',
+ 'Generic Run4 predecessor parser boundary (design only)',
+ 'a contract design, not recovery authority',
+ 'manager operation, native command, argument, or capability schema',
+ 'must not broaden or reinterpret the frozen 582-byte R4 proof')) {
+ if ($readme.IndexOf($fragment, [StringComparison]::OrdinalIgnoreCase) -lt 0) {
+ throw "Validation guide lost scope boundary '$fragment'."
+ }
+}
+
+# Preserve the exact operator-reported R4 identities and path spellings that
+# this recovery phase was introduced to admit. The fixture is evidence input,
+# not a substitute for the laptop's independently hashed retained files.
+$r4Fixture = Get-Content -LiteralPath $r4FixturePath -Raw -Encoding UTF8 |
+ ConvertFrom-Json -ErrorAction Stop
+$expectedR4 = [ordered]@{
+ schema = 'viiper.windows11.failed-install-fixture/v1'
+ predecessorEvidenceRoot = 'C:\Users\hbash\Documents\Codex\2026-08-15\the\outputs\VIIPER-Win11-9481f9d-272f6a0-r4'
+ predecessorInstallStepDirectory = 'C:\Users\hbash\Documents\Codex\2026-08-15\the\outputs\VIIPER-Win11-9481f9d-272f6a0-r4\steps\20260816T034608909Z-install-27fffa05b7e544feb3c5a415ebd1f6c4'
+ stateSha256 = 'e13c686a0cddcf66620940005568b3a7a9a41abb277f61977dd88994863d8cda'
+ installCommandSha256 = 'c38579b1504c8851dd72317d49f4439d14b7878b4e19907ebe864c8ad986e3f7'
+ installResultSha256 = '1095194f448455f746b5af92b89ae4f08f8f69a7ba9fac1d17a90d73e8a971b0'
+ installStdoutSha256 = 'ca95fac3b8bd6fe7871a7f42400031f01ea946dc88786e9e9a746084144c205b'
+ installStderrSha256 = '2610d56f76be3c1aea4f6b3dd4e4b38d134a1d311133ac46f389a28f8faeb520'
+ bundleManifestSha256 = '765de4fe822004e97940fa66ba73602dafd68194d14fd64e20b388444cd4c247'
+ viiperSourceRevision = '9481f9dbfde64af99905fa325546e50b5ea03d6e'
+ ds4WindowsSourceRevision = '272f6a05f1476d5aa9c055a234e61c292d3c1556'
+ packageLockSha256 = '16e08c31bb1c240a3612a6c4ddc8219b040d0e2dec5773e39f363d045113ab8c'
+ certificateSha256 = '09ca0c2d4d3da29268eff59cf85b6c1347d4a28ddc098b8640381694ad74c517'
+}
+foreach ($entry in $expectedR4.GetEnumerator()) {
+ if ([string]$r4Fixture.($entry.Key) -cne [string]$entry.Value) {
+ throw "R4 failed-install fixture lost exact '$($entry.Key)'."
+ }
+}
+if ([string]$r4Fixture.failure.phase -cne
+ 'install-journal-broker-image-hash' -or
+ [int]$r4Fixture.failure.exitCode -ne 4 -or
+ [int]$r4Fixture.failure.changed -ne 0 -or
+ [int]$r4Fixture.failure.rebootRequired -ne 0 -or
+ [string]$r4Fixture.failure.rollback -cne 'not-needed') {
+ throw 'R4 failed-install fixture lost its exact zero-change failure tuple.'
+}
+
+# Execute the manager's pure authorization functions without invoking its main
+# flow. These tests are non-elevated and never open a certificate store, driver,
+# service, mutex, or protected ProgramData path.
+$managerTokens = $null
+$managerParseErrors = $null
+$managerAst = [Management.Automation.Language.Parser]::ParseFile(
+ $managerPath, [ref]$managerTokens, [ref]$managerParseErrors)
+$authorizationFunctionNames = @(
+ 'Assert-ExactRecoveryJsonObjectProperties',
+ 'Assert-ExactR4FailedInstallRecoveryAuthorization',
+ 'Open-ExactR4FailedInstallEvidenceLeases'
+)
+foreach ($functionName in $authorizationFunctionNames) {
+ $definitions = @($managerAst.FindAll({
+ param($node)
+ $node -is [Management.Automation.Language.FunctionDefinitionAst] -and
+ $node.Name -ceq $functionName
+ }, $true))
+ if ($definitions.Count -ne 1) {
+ throw "Manager lost unique pure recovery function '$functionName'."
+ }
+ Invoke-Expression ([string]$definitions[0].Extent.Text)
+}
+
+$contractCurrentSource = ('a' * 40)
+$contractCurrentLock = ('b' * 64)
+$contractCurrentManifest = ('c' * 64)
+$contractMachine = 'VIIPER-R4-CONTRACT'
+$contractTargetSid = 'S-1-5-21-1-2-3-1001'
+$contractAuthorizationValue = [ordered]@{
+ schema = 'viiper.windows11.failed-install-recovery-progress/v1'
+ status = 'native-attempt'
+ retryPermitted = $true
+ firstAuthorizedUtc = [DateTime]::UtcNow.ToString('o')
+ currentBundleManifestSha256 = $contractCurrentManifest
+ currentViiperSourceRevision = $contractCurrentSource
+ currentPackageLockSha256 = $contractCurrentLock
+ predecessor = [ordered]@{
+ predecessorEvidenceRoot = [string]$r4Fixture.predecessorEvidenceRoot
+ installEvidenceDirectory = [string]$r4Fixture.predecessorInstallStepDirectory
+ statePath = Join-Path ([string]$r4Fixture.predecessorEvidenceRoot) `
+ 'state\validation-state.json'
+ stateSha256 = [string]$r4Fixture.stateSha256
+ commandSha256 = [string]$r4Fixture.installCommandSha256
+ resultSha256 = [string]$r4Fixture.installResultSha256
+ stdoutSha256 = [string]$r4Fixture.installStdoutSha256
+ stderrSha256 = [string]$r4Fixture.installStderrSha256
+ bundleManifestSha256 = [string]$r4Fixture.bundleManifestSha256
+ viiperSourceRevision = [string]$r4Fixture.viiperSourceRevision
+ ds4WindowsSourceRevision = [string]$r4Fixture.ds4WindowsSourceRevision
+ packageLockSha256 = [string]$r4Fixture.packageLockSha256
+ }
+ predecessorCertificateSha256 = [string]$r4Fixture.certificateSha256
+ machine = $contractMachine
+ targetUserSid = $contractTargetSid
+ trustBeforeNativeAttempt = [ordered]@{ Root = 1; TrustedPublisher = 1 }
+ resume = $false
+ updatedUtc = [DateTime]::UtcNow.ToString('o')
+}
+$contractAuthorizationText = $contractAuthorizationValue |
+ ConvertTo-Json -Depth 20 -Compress
+$contractAuthorization = $contractAuthorizationText |
+ ConvertFrom-Json -ErrorAction Stop
+$contractAuthorizationArguments = @{
+ CurrentViiperSourceRevision = $contractCurrentSource
+ CurrentPackageLockSHA256 = $contractCurrentLock
+ CurrentBundleManifestSHA256 = $contractCurrentManifest
+ CurrentCertificateSHA256 = [string]$r4Fixture.certificateSha256
+ ExpectedMachine = $contractMachine
+ ExpectedTargetUserSID = $contractTargetSid
+ Resume = $false
+}
+Assert-ExactR4FailedInstallRecoveryAuthorization `
+ -AuthorizationText $contractAuthorizationText `
+ -Authorization $contractAuthorization @contractAuthorizationArguments
+$contractResumeAuthorization = $contractAuthorizationText |
+ ConvertFrom-Json -ErrorAction Stop
+$contractResumeAuthorization.resume = $true
+$contractResumeAuthorization.trustBeforeNativeAttempt.Root = 0
+$contractResumeAuthorization | Add-Member `
+ -NotePropertyName 'recoveryRootAuthorizationSha256' `
+ -NotePropertyValue ('d' * 64)
+$contractResumeAuthorizationText = $contractResumeAuthorization |
+ ConvertTo-Json -Depth 20 -Compress
+$contractResumeArguments = $contractAuthorizationArguments.Clone()
+$contractResumeArguments.Resume = $true
+Assert-ExactR4FailedInstallRecoveryAuthorization `
+ -AuthorizationText $contractResumeAuthorizationText `
+ -Authorization $contractResumeAuthorization @contractResumeArguments
+
+function Assert-ContractRecoveryAuthorizationRejected {
+ param(
+ [Parameter(Mandatory = $true)][string]$Text,
+ [Parameter(Mandatory = $true)]$Value,
+ [Parameter(Mandatory = $true)][string]$Label
+ )
+ $rejected = $false
+ try {
+ Assert-ExactR4FailedInstallRecoveryAuthorization `
+ -AuthorizationText $Text -Authorization $Value `
+ @contractAuthorizationArguments
+ }
+ catch { $rejected = $true }
+ if (-not $rejected) {
+ throw "Manager admitted adversarial recovery authorization: $Label."
+ }
+}
+
+$forgedPredecessor = $contractAuthorizationText |
+ ConvertFrom-Json -ErrorAction Stop
+$forgedPredecessor.predecessor.stateSha256 = ('f' * 64)
+$forgedPredecessorText = $forgedPredecessor |
+ ConvertTo-Json -Depth 20 -Compress
+Assert-ContractRecoveryAuthorizationRejected -Text $forgedPredecessorText `
+ -Value $forgedPredecessor -Label 'fabricated predecessor'
+
+$missingField = $contractAuthorizationText | ConvertFrom-Json -ErrorAction Stop
+$missingField.predecessor.PSObject.Properties.Remove('stdoutSha256')
+$missingFieldText = $missingField | ConvertTo-Json -Depth 20 -Compress
+Assert-ContractRecoveryAuthorizationRejected -Text $missingFieldText `
+ -Value $missingField -Label 'missing predecessor field'
+
+$unknownField = $contractAuthorizationText | ConvertFrom-Json -ErrorAction Stop
+$unknownField | Add-Member -NotePropertyName 'unboundAuthority' `
+ -NotePropertyValue ('d' * 64)
+$unknownFieldText = $unknownField | ConvertTo-Json -Depth 20 -Compress
+Assert-ContractRecoveryAuthorizationRejected -Text $unknownFieldText `
+ -Value $unknownField -Label 'unknown field'
+
+$duplicateFieldText = $contractAuthorizationText.Replace(
+ '"status":"native-attempt"',
+ '"status":"native-attempt","status":"native-attempt"')
+Assert-ContractRecoveryAuthorizationRejected -Text $duplicateFieldText `
+ -Value $contractAuthorization -Label 'duplicate field'
+
+$otherMachine = $contractAuthorizationText | ConvertFrom-Json -ErrorAction Stop
+$otherMachine.machine = 'OTHER-MACHINE'
+$otherMachineText = $otherMachine | ConvertTo-Json -Depth 20 -Compress
+Assert-ContractRecoveryAuthorizationRejected -Text $otherMachineText `
+ -Value $otherMachine -Label 'other machine'
+
+$missingEvidence = $contractAuthorizationText | ConvertFrom-Json -ErrorAction Stop
+$missingEvidence.predecessor.statePath = Join-Path ([IO.Path]::GetTempPath()) `
+ ('viiper-r4-missing-' + [Guid]::NewGuid().ToString('N') + '.json')
+$missingEvidence.predecessor.installEvidenceDirectory = Join-Path `
+ ([IO.Path]::GetTempPath()) ('viiper-r4-missing-' +
+ [Guid]::NewGuid().ToString('N'))
+$missingEvidenceRejected = $false
+try {
+ [void](Open-ExactR4FailedInstallEvidenceLeases `
+ -Authorization $missingEvidence -ExpectedMachine $contractMachine `
+ -ExpectedTargetUserSID $contractTargetSid)
+}
+catch { $missingEvidenceRejected = $true }
+if (-not $missingEvidenceRejected) {
+ throw 'Manager admitted R4 recovery without retained predecessor evidence.'
+}
+
+# Exercise the package/hash model with deterministic synthetic bytes. This is
+# intentionally non-elevated and performs no driver, service, registry, BCD,
+# verifier, WPR, power, or device mutation.
+$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) (
+ 'viiper-validation-contract-' + [Guid]::NewGuid().ToString('N'))
+[void][IO.Directory]::CreateDirectory($temporaryRoot)
+try {
+ # Exercise both publication branches. Windows PowerShell converts a literal
+ # $null string argument to String.Empty, so an existing destination is the
+ # required regression cut for File.Replace.
+ $atomicPath = Join-Path $temporaryRoot 'atomic-state.json'
+ Write-ViiperJsonAtomic -Path $atomicPath -Value ([ordered]@{ sequence = 1 })
+ Write-ViiperJsonAtomic -Path $atomicPath -Value ([ordered]@{ sequence = 2 })
+ $atomicState = Get-Content -LiteralPath $atomicPath -Raw -Encoding UTF8 |
+ ConvertFrom-Json -ErrorAction Stop
+ if ([int]$atomicState.sequence -ne 2) {
+ throw 'Atomic JSON replacement did not publish the second state.'
+ }
+ $atomicResidue = @(Get-ChildItem -LiteralPath $temporaryRoot -File -Force |
+ Where-Object { $_.Name -like 'atomic-state.json.*.tmp' })
+ if ($atomicResidue.Count -ne 0) {
+ throw 'Atomic JSON replacement retained a temporary state file.'
+ }
+ Remove-Item -LiteralPath $atomicPath -Force
+
+ # Model the only permitted state-publication order: the fail-closed
+ # pending-reboot gate runs first, and validation-state.json is written only
+ # after it succeeds. Every rejection below must leave that file absent.
+ $contractPreflightStatePath = Join-Path $temporaryRoot `
+ 'validation-state.json'
+ function New-ContractPendingRebootSnapshot {
+ return [ordered]@{
+ pendingReboot = [ordered]@{
+ componentBasedServicing = $false
+ windowsUpdate = $false
+ pendingFileRenameOperations = $false
+ pendingComputerRename = $false
+ }
+ collectionErrors = @()
+ }
+ }
+ function Invoke-ContractPendingRebootThenPublishState {
+ param([Parameter(Mandatory = $true)]$Snapshot)
+
+ Assert-ViiperPreflightPendingReboot -Snapshot $Snapshot `
+ -SnapshotPath (Join-Path $temporaryRoot 'machine-snapshot.json')
+ Write-ViiperJsonAtomic -Path $contractPreflightStatePath `
+ -Value ([ordered]@{ lifecycle = 'preflight-complete' })
+ }
+ function Assert-ContractPendingRebootRejectedWithoutState {
+ param(
+ [Parameter(Mandatory = $true)]$Snapshot,
+ [Parameter(Mandatory = $true)][string]$Label
+ )
+
+ if (Test-Path -LiteralPath $contractPreflightStatePath) {
+ throw "Pending-reboot contract started '$Label' with unexpected state."
+ }
+ $gatePassed = $false
+ try {
+ Assert-ViiperPreflightPendingReboot -Snapshot $Snapshot `
+ -SnapshotPath (Join-Path $temporaryRoot 'machine-snapshot.json')
+ $gatePassed = $true
+ Write-ViiperJsonAtomic -Path $contractPreflightStatePath `
+ -Value ([ordered]@{ lifecycle = 'preflight-complete' })
+ }
+ catch {}
+ if ($gatePassed) {
+ throw "Preflight admitted rejected pending-reboot evidence: $Label."
+ }
+ if (Test-Path -LiteralPath $contractPreflightStatePath) {
+ throw "Preflight created validation state after rejecting: $Label."
+ }
+ }
+
+ $clearPendingSnapshot = New-ContractPendingRebootSnapshot
+ Invoke-ContractPendingRebootThenPublishState `
+ -Snapshot $clearPendingSnapshot
+ if (-not (Test-Path -LiteralPath $contractPreflightStatePath `
+ -PathType Leaf)) {
+ throw 'All-clear pending-reboot evidence did not admit state publication.'
+ }
+ Remove-Item -LiteralPath $contractPreflightStatePath -Force
+
+ $nullPendingSnapshot = New-ContractPendingRebootSnapshot
+ $nullPendingSnapshot['pendingReboot'] = $null
+ Assert-ContractPendingRebootRejectedWithoutState `
+ -Snapshot $nullPendingSnapshot -Label 'null pendingReboot'
+
+ $collectionErrorSnapshot = New-ContractPendingRebootSnapshot
+ $collectionErrorSnapshot['collectionErrors'] = @([ordered]@{
+ section = 'pendingReboot'
+ message = 'synthetic collection failure'
+ })
+ Assert-ContractPendingRebootRejectedWithoutState `
+ -Snapshot $collectionErrorSnapshot `
+ -Label 'pendingReboot collection error'
+
+ $nullAndCollectionErrorSnapshot = New-ContractPendingRebootSnapshot
+ $nullAndCollectionErrorSnapshot['pendingReboot'] = $null
+ $nullAndCollectionErrorSnapshot['collectionErrors'] = @([ordered]@{
+ section = 'pendingReboot'
+ message = 'synthetic collection failure with null section result'
+ })
+ Assert-ContractPendingRebootRejectedWithoutState `
+ -Snapshot $nullAndCollectionErrorSnapshot `
+ -Label 'null pendingReboot plus collection error'
+
+ foreach ($pendingFlag in @(
+ 'componentBasedServicing', 'windowsUpdate',
+ 'pendingFileRenameOperations', 'pendingComputerRename')) {
+ $pendingSnapshot = New-ContractPendingRebootSnapshot
+ $pendingSnapshot['pendingReboot'][$pendingFlag] = $true
+ Assert-ContractPendingRebootRejectedWithoutState `
+ -Snapshot $pendingSnapshot -Label "true $pendingFlag"
+ }
+
+ $payloadPath = Join-Path $temporaryRoot 'payload.bin'
+ [IO.File]::WriteAllBytes($payloadPath, [byte[]](1, 2, 3, 4, 5))
+ $sourceRevision = '0123456789abcdef0123456789abcdef01234567'
+ $payload = Get-Item -LiteralPath $payloadPath
+ $lock = [ordered]@{
+ schema = 1
+ sourceRevision = $sourceRevision
+ driverPackageVersion = '1.2.3.4'
+ driverBuildIdentity = ('a' * 64)
+ testSignerCertificateSha256 = ('b' * 64)
+ installerScriptSha256 = ('c' * 64)
+ files = @([ordered]@{
+ path = 'payload.bin'
+ length = [long]$payload.Length
+ sha256 = Get-ViiperSha256 -Path $payloadPath
+ })
+ }
+ $lockPath = Join-Path $temporaryRoot 'local-test-package.lock.json'
+ Write-ViiperJsonAtomic -Path $lockPath -Value $lock
+ $lockHash = Get-ViiperSha256 -Path $lockPath
+ $identity = Test-ViiperLocalTestPackage -PackageRoot $temporaryRoot `
+ -ExpectedSourceRevision $sourceRevision -ExpectedPackageLockSHA256 $lockHash
+ if ([string]$identity.lockSha256 -cne $lockHash -or [int]$identity.fileCount -ne 1) {
+ throw 'Synthetic exact-package model returned the wrong identity.'
+ }
+
+ # Prove that recovery admission is bound to the predecessor state plus the
+ # exact failed child evidence, not merely to a caller-selected directory.
+ # This remains read-only with respect to drivers, services, trust, BCD, and
+ # ProgramData and therefore runs identically under Windows PowerShell 5.1
+ # and PowerShell 7.
+ $predecessorRoot = Join-Path $temporaryRoot 'predecessor-evidence'
+ $predecessorStateDirectory = Join-Path $predecessorRoot 'state'
+ $predecessorStepsDirectory = Join-Path $predecessorRoot 'steps'
+ $predecessorInstallStep = Join-Path $predecessorStepsDirectory `
+ '20260816T034608909Z-install-contract'
+ [void][IO.Directory]::CreateDirectory($predecessorStateDirectory)
+ [void][IO.Directory]::CreateDirectory($predecessorInstallStep)
+ $predecessorManifestHash = ('d' * 64)
+ $predecessorPackageLockHash = ('e' * 64)
+ $predecessorDs4Revision = ('f' * 40)
+ $predecessorTargetSid = 'S-1-5-21-1-2-3-1001'
+ $predecessorMachine = 'VIIPER-CONTRACT-MACHINE'
+ $predecessorStatePath = Join-Path $predecessorStateDirectory `
+ 'validation-state.json'
+ Write-ViiperJsonAtomic -Path $predecessorStatePath -Value ([ordered]@{
+ schema = 'viiper.windows11.validation-state/v1'
+ machine = $predecessorMachine
+ targetUserSid = $predecessorTargetSid
+ bundleManifestSha256 = $predecessorManifestHash
+ viiperSourceRevision = $sourceRevision
+ ds4WindowsSourceRevision = $predecessorDs4Revision
+ packageLockSha256 = $predecessorPackageLockHash
+ lifecycle = 'transaction-failed'
+ pendingTransaction = 'Install'
+ trustBeforeInstall = [ordered]@{ Root = 0; TrustedPublisher = 0 }
+ history = @([ordered]@{
+ phase = 'Install'; lifecycle = 'transaction-failed'
+ })
+ })
+ $predecessorCommandPath = Join-Path $predecessorInstallStep 'command.json'
+ Write-ViiperJsonAtomic -Path $predecessorCommandPath -Value ([ordered]@{
+ schema = 'viiper.windows11.captured-command/v1'
+ name = 'install'
+ arguments = @(
+ '-NoProfile', '-File', 'Install-ViiperUdeLocalTest.ps1',
+ '-PackageRoot', 'C:\contract\package',
+ '-ExpectedSourceRevision', $sourceRevision,
+ '-ExpectedPackageLockSHA256', $predecessorPackageLockHash,
+ '-TargetUserSID', $predecessorTargetSid,
+ '-AcknowledgeDisposableTestMachine'
+ )
+ })
+ $predecessorResultPath = Join-Path $predecessorInstallStep 'result.json'
+ Write-ViiperJsonAtomic -Path $predecessorResultPath -Value ([ordered]@{
+ schema = 'viiper.windows11.captured-result/v1'
+ name = 'install'
+ started = $true
+ exitCode = 1
+ success = $false
+ launchFailure = $null
+ evidenceDirectory = $predecessorInstallStep
+ })
+ $predecessorStdoutPath = Join-Path $predecessorInstallStep 'stdout.log'
+ $predecessorTrustProofs = [string[]]@(
+ 'local-test-trust store=Root action=add result=added',
+ 'local-test-trust store=Root action=verify-add result=present',
+ 'local-test-trust store=TrustedPublisher action=add result=added',
+ 'local-test-trust store=TrustedPublisher action=verify-add result=present'
+ )
+ $bareR4FailureOutcome = 'result=error operation=install changed=0 ' +
+ 'rebootRequired=0 rollback=not-needed exitCode=4 ' +
+ 'phase="install-journal-broker-image-hash" win32Error=23 ' +
+ 'message="protected broker evidence differs from its immutable digest"'
+ $r4FailureWrapperPrefix = 'VIIPER: error: install native driver and broker ' +
+ 'transaction: native driver helper failed with exit 4: exit status 4: '
+ $exactR4FailureWrapper = $r4FailureWrapperPrefix + $bareR4FailureOutcome
+ function Write-ContractPredecessorStdout {
+ param(
+ [Parameter(Mandatory = $true)]
+ [AllowEmptyString()]
+ [AllowEmptyCollection()]
+ [string[]]$OutcomeLines,
+ [string[]]$TrustLines = $predecessorTrustProofs,
+ [string]$LineEnding = "`n",
+ [switch]$Utf8Bom,
+ [switch]$OmitFinalLineEnding
+ )
+ $lines = [string[]]@($TrustLines + $OutcomeLines)
+ $text = [string]::Join($LineEnding, $lines)
+ if (-not $OmitFinalLineEnding) { $text += $LineEnding }
+ [IO.File]::WriteAllText($predecessorStdoutPath, $text,
+ [Text.UTF8Encoding]::new([bool]$Utf8Bom))
+ }
+ Write-ContractPredecessorStdout -OutcomeLines @($exactR4FailureWrapper)
+ $exactR4StdoutHash =
+ 'ca95fac3b8bd6fe7871a7f42400031f01ea946dc88786e9e9a746084144c205b'
+ if ((Get-Item -LiteralPath $predecessorStdoutPath).Length -ne 582 -or
+ (Get-ViiperSha256 -Path $predecessorStdoutPath) -cne
+ $exactR4StdoutHash) {
+ throw 'Synthetic predecessor stdout does not reproduce the exact retained R4 bytes.'
+ }
+ $exactR4StdoutBytes = [IO.File]::ReadAllBytes($predecessorStdoutPath)
+ $predecessorStderrPath = Join-Path $predecessorInstallStep 'stderr.log'
+ [IO.File]::WriteAllText($predecessorStderrPath,
+ 'Local VIIPER driver transaction failed with exit code 4.',
+ [Text.UTF8Encoding]::new($false))
+ $recoveryEvidenceArguments = @{
+ PredecessorEvidenceRoot = $predecessorRoot
+ PredecessorInstallStepDirectory = $predecessorInstallStep
+ ExpectedStateSHA256 = Get-ViiperSha256 -Path $predecessorStatePath
+ ExpectedInstallCommandSHA256 = Get-ViiperSha256 -Path $predecessorCommandPath
+ ExpectedInstallResultSHA256 = Get-ViiperSha256 -Path $predecessorResultPath
+ ExpectedInstallStdoutSHA256 = Get-ViiperSha256 -Path $predecessorStdoutPath
+ ExpectedInstallStderrSHA256 = Get-ViiperSha256 -Path $predecessorStderrPath
+ ExpectedBundleManifestSHA256 = $predecessorManifestHash
+ ExpectedViiperSourceRevision = $sourceRevision
+ ExpectedDS4WindowsSourceRevision = $predecessorDs4Revision
+ ExpectedPackageLockSHA256 = $predecessorPackageLockHash
+ ExpectedMachine = $predecessorMachine
+ ExpectedTargetUserSID = $predecessorTargetSid
+ }
+ $recoveryIdentity = Test-ViiperFailedInstallRecoveryEvidence `
+ @recoveryEvidenceArguments
+ if ([string]$recoveryIdentity.stateSha256 -cne
+ [string]$recoveryEvidenceArguments.ExpectedStateSHA256 -or
+ [string]$recoveryIdentity.stdoutSha256 -cne
+ [string]$recoveryEvidenceArguments.ExpectedInstallStdoutSHA256) {
+ throw 'Synthetic failed-install recovery evidence returned the wrong identity.'
+ }
+ Add-Content -LiteralPath $predecessorStdoutPath -Value 'tampered' `
+ -Encoding UTF8
+ $recoveryTamperRejected = $false
+ try {
+ [void](Test-ViiperFailedInstallRecoveryEvidence `
+ @recoveryEvidenceArguments)
+ }
+ catch { $recoveryTamperRejected = $true }
+ if (-not $recoveryTamperRejected) {
+ throw 'Failed-install recovery admitted changed predecessor evidence.'
+ }
+
+ # Exercise the same semantic proof parser in Windows PowerShell 5.1 and
+ # PowerShell 7. The exact R4 wrapper is accepted above. The helper's bare
+ # canonical outcome is intentionally not an alternate proof: R4 captured
+ # the VIIPER command and its deterministic Kong/Go error context.
+ function Assert-ContractRecoveryStdoutRejected {
+ param(
+ [Parameter(Mandatory = $true)]
+ [AllowEmptyString()]
+ [AllowEmptyCollection()]
+ [string[]]$OutcomeLines,
+ [Parameter(Mandatory = $true)][string]$Label,
+ [string[]]$TrustLines = $predecessorTrustProofs,
+ [string]$LineEnding = "`n",
+ [switch]$Utf8Bom,
+ [switch]$OmitFinalLineEnding
+ )
+ Write-ContractPredecessorStdout -OutcomeLines $OutcomeLines `
+ -TrustLines $TrustLines -LineEnding $LineEnding `
+ -Utf8Bom:$Utf8Bom `
+ -OmitFinalLineEnding:$OmitFinalLineEnding
+ $arguments = $recoveryEvidenceArguments.Clone()
+ $arguments.ExpectedInstallStdoutSHA256 = Get-ViiperSha256 `
+ -Path $predecessorStdoutPath
+ $rejected = $false
+ try {
+ [void](Test-ViiperFailedInstallRecoveryEvidence @arguments)
+ }
+ catch { $rejected = $true }
+ if (-not $rejected) {
+ throw "Failed-install recovery admitted adversarial stdout: $Label."
+ }
+ }
+ function Assert-ContractRecoveryStdoutBytesRejected {
+ param(
+ [Parameter(Mandatory = $true)][byte[]]$Bytes,
+ [Parameter(Mandatory = $true)][string]$Label
+ )
+ [IO.File]::WriteAllBytes($predecessorStdoutPath, $Bytes)
+ $arguments = $recoveryEvidenceArguments.Clone()
+ $arguments.ExpectedInstallStdoutSHA256 = Get-ViiperSha256 `
+ -Path $predecessorStdoutPath
+ $rejected = $false
+ try {
+ [void](Test-ViiperFailedInstallRecoveryEvidence @arguments)
+ }
+ catch { $rejected = $true }
+ if (-not $rejected) {
+ throw "Failed-install recovery admitted adversarial bytes: $Label."
+ }
+ }
+ Assert-ContractRecoveryStdoutRejected `
+ -OutcomeLines @($bareR4FailureOutcome) `
+ -Label 'bare canonical helper outcome'
+ Assert-ContractRecoveryStdoutRejected `
+ -OutcomeLines @(('untrusted-prefix: ' + $exactR4FailureWrapper)) `
+ -Label 'exact wrapper as an arbitrary substring'
+ Assert-ContractRecoveryStdoutRejected `
+ -OutcomeLines @(($exactR4FailureWrapper +
+ ' recoveryRecord="C:\\ProgramData\\VIIPER\\UdeCx\\active-v2"' +
+ ' recoveryRecordWritten=1')) `
+ -Label 'exact wrapper with an unreported diagnostic suffix'
+ Assert-ContractRecoveryStdoutRejected `
+ -OutcomeLines @($exactR4FailureWrapper.Replace(
+ 'install native driver and broker transaction',
+ 'repair native driver and broker transaction')) `
+ -Label 'wrong VIIPER command context'
+ Assert-ContractRecoveryStdoutRejected `
+ -OutcomeLines @($exactR4FailureWrapper.Replace(
+ 'rebootRequired=0', 'rebootRequired=1')) `
+ -Label 'near-miss zero-change outcome'
+ Assert-ContractRecoveryStdoutRejected `
+ -OutcomeLines @($exactR4FailureWrapper, $exactR4FailureWrapper) `
+ -Label 'duplicate exact wrapper outcomes'
+ Assert-ContractRecoveryStdoutRejected `
+ -OutcomeLines @($exactR4FailureWrapper,
+ 'result=success operation=status changed=0') `
+ -Label 'extra native result outcome'
+ Assert-ContractRecoveryStdoutRejected `
+ -OutcomeLines @($exactR4FailureWrapper) `
+ -LineEnding "`r`n" `
+ -Label 'CRLF line endings'
+ Assert-ContractRecoveryStdoutRejected `
+ -OutcomeLines @($exactR4FailureWrapper) `
+ -Utf8Bom `
+ -Label 'UTF-8 BOM'
+ Assert-ContractRecoveryStdoutRejected `
+ -OutcomeLines @($exactR4FailureWrapper) `
+ -OmitFinalLineEnding `
+ -Label 'missing final LF'
+
+ $reorderedTrustProofs = [string[]]@(
+ $predecessorTrustProofs[1],
+ $predecessorTrustProofs[0],
+ $predecessorTrustProofs[2],
+ $predecessorTrustProofs[3]
+ )
+ Assert-ContractRecoveryStdoutRejected `
+ -OutcomeLines @($exactR4FailureWrapper) `
+ -TrustLines $reorderedTrustProofs `
+ -Label 'reordered trust lines'
+ Assert-ContractRecoveryStdoutRejected `
+ -OutcomeLines @($exactR4FailureWrapper) `
+ -TrustLines ([string[]]$predecessorTrustProofs[0..2]) `
+ -Label 'missing trust line'
+ Assert-ContractRecoveryStdoutRejected `
+ -OutcomeLines @($exactR4FailureWrapper) `
+ -TrustLines ([string[]]@($predecessorTrustProofs +
+ $predecessorTrustProofs[3])) `
+ -Label 'duplicated trust line'
+
+ Assert-ContractRecoveryStdoutRejected `
+ -OutcomeLines @('', $exactR4FailureWrapper) `
+ -Label 'extra blank line'
+ Assert-ContractRecoveryStdoutRejected `
+ -OutcomeLines @((' ' + $exactR4FailureWrapper)) `
+ -Label 'leading whitespace'
+ Assert-ContractRecoveryStdoutRejected `
+ -OutcomeLines @(($exactR4FailureWrapper + ' ')) `
+ -Label 'trailing whitespace'
+ Assert-ContractRecoveryStdoutRejected `
+ -OutcomeLines @($exactR4FailureWrapper.Insert(7, [char]0x00ad)) `
+ -Label 'culture-ignorable soft hyphen with recomputed digest'
+ Assert-ContractRecoveryStdoutRejected `
+ -OutcomeLines @($exactR4FailureWrapper.Insert(7, [char]0x0000)) `
+ -Label 'embedded NUL with recomputed digest'
+
+ $semanticMutations = @(
+ [pscustomobject]@{
+ Label = 'helper exit disagreement'
+ Value = $exactR4FailureWrapper.Replace(
+ 'helper failed with exit 4', 'helper failed with exit 5')
+ },
+ [pscustomobject]@{
+ Label = 'wrapper exit-status disagreement'
+ Value = $exactR4FailureWrapper.Replace(
+ 'exit status 4', 'exit status 5')
+ },
+ [pscustomobject]@{
+ Label = 'structured exit disagreement'
+ Value = $exactR4FailureWrapper.Replace('exitCode=4', 'exitCode=5')
+ },
+ [pscustomobject]@{
+ Label = 'altered result field'
+ Value = $exactR4FailureWrapper.Replace('result=error', 'result=success')
+ },
+ [pscustomobject]@{
+ Label = 'altered operation field'
+ Value = $exactR4FailureWrapper.Replace(
+ 'operation=install', 'operation=uninstall')
+ },
+ [pscustomobject]@{
+ Label = 'altered changed field'
+ Value = $exactR4FailureWrapper.Replace('changed=0', 'changed=1')
+ },
+ [pscustomobject]@{
+ Label = 'altered rollback field'
+ Value = $exactR4FailureWrapper.Replace(
+ 'rollback=not-needed', 'rollback=failed')
+ },
+ [pscustomobject]@{
+ Label = 'altered phase field'
+ Value = $exactR4FailureWrapper.Replace(
+ 'phase="install-journal-broker-image-hash"',
+ 'phase="install-journal-write"')
+ },
+ [pscustomobject]@{
+ Label = 'altered win32 field'
+ Value = $exactR4FailureWrapper.Replace('win32Error=23', 'win32Error=24')
+ },
+ [pscustomobject]@{
+ Label = 'altered message field'
+ Value = $exactR4FailureWrapper.Replace(
+ 'protected broker evidence differs from its immutable digest',
+ 'protected broker evidence is missing')
+ },
+ [pscustomobject]@{
+ Label = 'altered field order'
+ Value = $exactR4FailureWrapper.Replace(
+ 'operation=install changed=0 rebootRequired=0',
+ 'operation=install rebootRequired=0 changed=0')
+ }
+ )
+ foreach ($mutation in $semanticMutations) {
+ Assert-ContractRecoveryStdoutRejected `
+ -OutcomeLines @([string]$mutation.Value) `
+ -Label ([string]$mutation.Label)
+ }
+
+ $contradictoryRawOutcome = $bareR4FailureOutcome.Replace(
+ 'result=error', 'result=success').Replace(
+ 'changed=0', 'changed=1').Replace('exitCode=4', 'exitCode=0')
+ Assert-ContractRecoveryStdoutRejected `
+ -OutcomeLines @($exactR4FailureWrapper, $contradictoryRawOutcome) `
+ -Label 'wrapped failure plus contradictory raw outcome'
+
+ [byte[]]$malformedUtf8 = $exactR4StdoutBytes.Clone()
+ $malformedUtf8[0] = 0xc3
+ $malformedUtf8[1] = 0x28
+ Assert-ContractRecoveryStdoutBytesRejected -Bytes $malformedUtf8 `
+ -Label 'malformed UTF-8 with recomputed digest'
+
+ [IO.File]::WriteAllBytes($payloadPath, [byte[]](1, 2, 3, 4, 6))
+ $tamperRejected = $false
+ try {
+ [void](Test-ViiperLocalTestPackage -PackageRoot $temporaryRoot `
+ -ExpectedSourceRevision $sourceRevision -ExpectedPackageLockSHA256 $lockHash)
+ }
+ catch { $tamperRejected = $true }
+ if (-not $tamperRejected) { throw 'Synthetic package tamper was not rejected.' }
+}
+finally {
+ $resolvedTemporary = [IO.Path]::GetFullPath($temporaryRoot)
+ $systemTemporary = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\'
+ if ($resolvedTemporary.StartsWith($systemTemporary,
+ [StringComparison]::OrdinalIgnoreCase) -and
+ [IO.Path]::GetFileName($resolvedTemporary) -like 'viiper-validation-contract-*') {
+ Remove-Item -LiteralPath $resolvedTemporary -Recurse -Force
+ }
+}
+
+Write-Host 'VIIPER Windows 11 validation source contract and deterministic package model passed.'
diff --git a/extras/validation/ViiperWin11Validation.Common.psm1 b/extras/validation/ViiperWin11Validation.Common.psm1
new file mode 100644
index 0000000..82a4ebe
--- /dev/null
+++ b/extras/validation/ViiperWin11Validation.Common.psm1
@@ -0,0 +1,845 @@
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+function Get-ViiperSha256 {
+ param([Parameter(Mandatory = $true)][string]$Path)
+
+ return (Get-FileHash -LiteralPath $Path -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
+}
+
+function Resolve-ViiperRegularFile {
+ param(
+ [Parameter(Mandatory = $true)][string]$Path,
+ [Parameter(Mandatory = $true)][string]$Label
+ )
+
+ $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
+ if ($item.PSIsContainer -or
+ ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
+ $item.Length -le 0) {
+ throw "$Label is not a non-empty regular file: '$Path'."
+ }
+ return $item.FullName
+}
+
+function Resolve-ViiperSafeDirectory {
+ param(
+ [Parameter(Mandatory = $true)][string]$Path,
+ [Parameter(Mandatory = $true)][string]$Label
+ )
+
+ $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
+ if (-not $item.PSIsContainer -or
+ ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ throw "$Label is not a non-reparse directory: '$Path'."
+ }
+ return $item.FullName
+}
+
+function Write-ViiperJsonAtomic {
+ param(
+ [Parameter(Mandatory = $true)][string]$Path,
+ [Parameter(Mandatory = $true)]$Value
+ )
+
+ $fullPath = [IO.Path]::GetFullPath($Path)
+ $parent = Split-Path -Parent $fullPath
+ if (-not (Test-Path -LiteralPath $parent -PathType Container)) {
+ [void][IO.Directory]::CreateDirectory($parent)
+ }
+ $temporary = $fullPath + '.' + [Guid]::NewGuid().ToString('N') + '.tmp'
+ try {
+ [IO.File]::WriteAllText(
+ $temporary, ($Value | ConvertTo-Json -Depth 20),
+ [Text.UTF8Encoding]::new($false))
+ if (Test-Path -LiteralPath $fullPath -PathType Leaf) {
+ [IO.File]::Replace(
+ $temporary, $fullPath,
+ [Management.Automation.Language.NullString]::Value, $true)
+ }
+ else {
+ [IO.File]::Move($temporary, $fullPath)
+ }
+ }
+ finally {
+ if (Test-Path -LiteralPath $temporary -PathType Leaf) {
+ Remove-Item -LiteralPath $temporary -Force
+ }
+ }
+}
+
+function Test-ViiperGitIdentity {
+ param(
+ [Parameter(Mandatory = $true)][string]$RepositoryRoot,
+ [Parameter(Mandatory = $true)][string]$ExpectedRevision,
+ [Parameter(Mandatory = $true)][string]$GitExecutable,
+ [Parameter(Mandatory = $true)][string]$Label
+ )
+
+ $repository = Resolve-ViiperSafeDirectory -Path $RepositoryRoot -Label $Label
+ $git = Resolve-ViiperRegularFile -Path $GitExecutable -Label 'Git executable'
+ $headOutput = @(& $git -C $repository rev-parse --verify HEAD 2>&1)
+ if ($LASTEXITCODE -ne 0 -or $headOutput.Count -eq 0) {
+ throw "$Label is not an exact Git checkout.`n$($headOutput -join [Environment]::NewLine)"
+ }
+ $head = ([string]$headOutput[0]).Trim().ToLowerInvariant()
+ if ($head -cne $ExpectedRevision.ToLowerInvariant()) {
+ throw "$Label is revision '$head', not '$ExpectedRevision'."
+ }
+ $status = @(& $git -C $repository status --porcelain=v1 --untracked-files=all 2>&1)
+ if ($LASTEXITCODE -ne 0) {
+ throw "Could not inspect $Label.`n$($status -join [Environment]::NewLine)"
+ }
+ if ($status.Count -ne 0) {
+ throw "$Label is dirty; refusing unbound source:`n$($status -join [Environment]::NewLine)"
+ }
+ $submodules = @(& $git -C $repository submodule status --recursive 2>&1)
+ if ($LASTEXITCODE -ne 0 -or
+ @($submodules | Where-Object { $_ -match '^[\-+U]' }).Count -ne 0) {
+ throw "$Label has an unbound submodule state.`n$($submodules -join [Environment]::NewLine)"
+ }
+ return [ordered]@{
+ root = $repository
+ revision = $head
+ submodules = @($submodules | ForEach-Object { ([string]$_).Trim() })
+ }
+}
+
+function Test-ViiperLocalTestPackage {
+ param(
+ [Parameter(Mandatory = $true)][string]$PackageRoot,
+ [Parameter(Mandatory = $true)][string]$ExpectedSourceRevision,
+ [Parameter(Mandatory = $true)][string]$ExpectedPackageLockSHA256
+ )
+
+ $root = Resolve-ViiperSafeDirectory -Path $PackageRoot -Label 'Local-test package root'
+ $unsafeDirectories = @(Get-ChildItem -LiteralPath $root -Directory -Recurse -Force |
+ Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 })
+ if ($unsafeDirectories.Count -ne 0) {
+ throw "The local-test package contains reparse directory '$($unsafeDirectories[0].FullName)'."
+ }
+ $lockPath = Resolve-ViiperRegularFile -Path (Join-Path $root 'local-test-package.lock.json') `
+ -Label 'Local-test package lock'
+ $actualLockHash = Get-ViiperSha256 -Path $lockPath
+ if ($actualLockHash -cne $ExpectedPackageLockSHA256.ToLowerInvariant()) {
+ throw "The local-test package lock SHA-256 is '$actualLockHash', not the explicit expected digest."
+ }
+ $lock = Get-Content -LiteralPath $lockPath -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop
+ if ([int]$lock.schema -ne 1 -or
+ [string]$lock.sourceRevision -cne $ExpectedSourceRevision.ToLowerInvariant() -or
+ [string]$lock.driverBuildIdentity -cnotmatch '^[0-9a-f]{64}$') {
+ throw 'The local-test package lock has the wrong schema, source revision, or build identity.'
+ }
+ $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+ foreach ($entry in @($lock.files)) {
+ $relative = [string]$entry.path
+ if ($relative -cnotmatch '^[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*$' -or
+ -not $seen.Add($relative) -or [long]$entry.length -le 0 -or
+ [string]$entry.sha256 -cnotmatch '^[0-9a-f]{64}$') {
+ throw "The local-test package lock has an unsafe or duplicate entry '$relative'."
+ }
+ $path = Join-Path $root $relative.Replace('/', [IO.Path]::DirectorySeparatorChar)
+ $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop
+ if ($item.PSIsContainer -or
+ ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
+ $item.Length -ne [long]$entry.length -or
+ (Get-ViiperSha256 -Path $item.FullName) -cne [string]$entry.sha256) {
+ throw "The local-test package entry '$relative' does not match its lock."
+ }
+ }
+ $actualFiles = @(Get-ChildItem -LiteralPath $root -File -Recurse -Force | Where-Object {
+ $_.FullName -cne $lockPath
+ })
+ if ($actualFiles.Count -ne $seen.Count) {
+ throw "The local-test package has $($actualFiles.Count) payload files but the lock binds $($seen.Count)."
+ }
+ foreach ($file in $actualFiles) {
+ $relative = $file.FullName.Substring($root.TrimEnd('\').Length + 1).Replace('\', '/')
+ if (-not $seen.Contains($relative)) {
+ throw "The local-test package contains unbound file '$relative'."
+ }
+ }
+ return [ordered]@{
+ root = $root
+ lockPath = $lockPath
+ lockSha256 = $actualLockHash
+ sourceRevision = [string]$lock.sourceRevision
+ driverPackageVersion = [string]$lock.driverPackageVersion
+ driverBuildIdentity = [string]$lock.driverBuildIdentity
+ fileCount = $seen.Count
+ }
+}
+
+function Get-ViiperBootIdentity {
+ $os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
+ $rawBoot = $os.LastBootUpTime
+ if ($rawBoot -is [DateTime]) {
+ $boot = [DateTime]$rawBoot
+ }
+ else {
+ $boot = [Management.ManagementDateTimeConverter]::ToDateTime([string]$rawBoot)
+ }
+ return $boot.ToUniversalTime().ToString('o')
+}
+
+function Invoke-ViiperReadOnlyCommand {
+ param(
+ [Parameter(Mandatory = $true)][string]$FilePath,
+ [Parameter(Mandatory = $true)][string[]]$Arguments
+ )
+
+ $savedPreference = $ErrorActionPreference
+ $ErrorActionPreference = 'Continue'
+ try {
+ $lines = @(& $FilePath @Arguments 2>&1)
+ $exitCode = $LASTEXITCODE
+ }
+ catch {
+ $lines = @($_.Exception.Message)
+ $exitCode = -1
+ }
+ finally {
+ $ErrorActionPreference = $savedPreference
+ }
+ return [ordered]@{
+ exitCode = [int]$exitCode
+ output = @($lines | ForEach-Object { [string]$_ })
+ }
+}
+
+function Resolve-ViiperServiceImage {
+ param([string]$ImagePath)
+
+ if ([string]::IsNullOrWhiteSpace($ImagePath)) { return $null }
+ $expanded = [Environment]::ExpandEnvironmentVariables($ImagePath.Trim())
+ if ($expanded.StartsWith('"')) {
+ $match = [regex]::Match($expanded, '^"(?[^"]+)"')
+ }
+ else {
+ $match = [regex]::Match($expanded, '^(?\S+)')
+ }
+ if (-not $match.Success) { return $null }
+ $path = $match.Groups['path'].Value
+ if ($path.StartsWith('\??\', [StringComparison]::Ordinal)) {
+ $path = $path.Substring(4)
+ }
+ if ($path.StartsWith('\SystemRoot\', [StringComparison]::OrdinalIgnoreCase)) {
+ $path = Join-Path $env:SystemRoot $path.Substring('\SystemRoot\'.Length)
+ }
+ elseif ($path.StartsWith('System32\', [StringComparison]::OrdinalIgnoreCase)) {
+ $path = Join-Path $env:SystemRoot $path
+ }
+ if (-not [IO.Path]::IsPathRooted($path) -or
+ -not (Test-Path -LiteralPath $path -PathType Leaf)) { return $null }
+ return (Resolve-Path -LiteralPath $path -ErrorAction Stop).Path
+}
+
+function Get-ViiperFileProvenance {
+ param([string]$Path)
+
+ if ([string]::IsNullOrWhiteSpace($Path)) { return $null }
+ $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
+ $signature = Get-AuthenticodeSignature -LiteralPath $item.FullName
+ return [ordered]@{
+ path = $item.FullName
+ length = [long]$item.Length
+ sha256 = Get-ViiperSha256 -Path $item.FullName
+ signatureStatus = [string]$signature.Status
+ signerSubject = if ($null -ne $signature.SignerCertificate) {
+ [string]$signature.SignerCertificate.Subject
+ } else { $null }
+ fileVersion = [string]$item.VersionInfo.FileVersion
+ productVersion = [string]$item.VersionInfo.ProductVersion
+ }
+}
+
+function Test-ViiperNonemptyOptionalRegistryProperty {
+ param(
+ [Parameter(Mandatory = $true)]$RegistryObject,
+ [Parameter(Mandatory = $true)][string]$Name
+ )
+
+ # Get-ItemProperty returns an object even when an optional named value is
+ # absent. Inspect the property bag instead of dereferencing the missing
+ # property under StrictMode. Empty/null REG_MULTI_SZ elements do not prove
+ # a queued rename; any non-empty element does.
+ $property = $RegistryObject.PSObject.Properties[$Name]
+ if ($null -eq $property) { return $false }
+ foreach ($value in @($property.Value)) {
+ if ($null -ne $value -and
+ -not [string]::IsNullOrEmpty([string]$value)) {
+ return $true
+ }
+ }
+ return $false
+}
+
+function Get-ViiperMachineEvidenceSnapshot {
+ $errors = [Collections.Generic.List[object]]::new()
+ function Capture-Section {
+ param([string]$Name, [scriptblock]$Action)
+ try { return & $Action }
+ catch {
+ $errors.Add([ordered]@{ section = $Name; message = $_.Exception.Message })
+ return $null
+ }
+ }
+
+ $os = Capture-Section 'operatingSystem' {
+ $value = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
+ $capturedUtc = [DateTime]::UtcNow
+ $bootIdentity = Get-ViiperBootIdentity
+ $bootUtc = [DateTime]::Parse($bootIdentity, [Globalization.CultureInfo]::InvariantCulture,
+ [Globalization.DateTimeStyles]::RoundtripKind)
+ [ordered]@{
+ caption = [string]$value.Caption
+ version = [string]$value.Version
+ buildNumber = [string]$value.BuildNumber
+ productType = [uint32]$value.ProductType
+ osArchitecture = [string]$value.OSArchitecture
+ bootIdentity = $bootIdentity
+ lastBootUpUtc = $bootIdentity
+ uptimeSeconds = [uint64][Math]::Floor(($capturedUtc - $bootUtc).TotalSeconds)
+ localDateTimeUtc = $capturedUtc.ToString('o')
+ }
+ }
+ $computer = Capture-Section 'computerSystem' {
+ $value = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
+ [ordered]@{
+ manufacturer = [string]$value.Manufacturer
+ model = [string]$value.Model
+ hypervisorPresent = [bool]$value.HypervisorPresent
+ totalPhysicalMemoryBytes = [uint64]$value.TotalPhysicalMemory
+ }
+ }
+ $powerCfg = Capture-Section 'activePowerPlan' {
+ Invoke-ViiperReadOnlyCommand -FilePath (Join-Path $env:SystemRoot 'System32\powercfg.exe') `
+ -Arguments @('/getactivescheme')
+ }
+ $battery = Capture-Section 'battery' {
+ Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
+ $status = [Windows.Forms.SystemInformation]::PowerStatus
+ [ordered]@{
+ powerLineStatus = [string]$status.PowerLineStatus
+ batteryChargeStatus = [string]$status.BatteryChargeStatus
+ batteryLifePercent = [double]$status.BatteryLifePercent
+ batteryLifeRemainingSeconds = [int]$status.BatteryLifeRemaining
+ systemBatteryDevices = @((Get-CimInstance Win32_Battery -ErrorAction SilentlyContinue) |
+ ForEach-Object {
+ [ordered]@{
+ name = [string]$_.Name
+ status = [string]$_.Status
+ batteryStatus = [uint16]$_.BatteryStatus
+ estimatedChargeRemainingPercent = [uint16]$_.EstimatedChargeRemaining
+ }
+ })
+ }
+ }
+ $bcd = Capture-Section 'bootConfiguration' {
+ $result = Invoke-ViiperReadOnlyCommand `
+ -FilePath (Join-Path $env:SystemRoot 'System32\bcdedit.exe') `
+ -Arguments @('/enum', '{current}')
+ [ordered]@{
+ testSigning = [bool](($result.output -join [Environment]::NewLine) -match
+ '(?im)^\s*testsigning\s+Yes\s*$')
+ command = $result
+ }
+ }
+ $deviceGuard = Capture-Section 'deviceGuard' {
+ $value = Get-CimInstance -Namespace root\Microsoft\Windows\DeviceGuard `
+ -ClassName Win32_DeviceGuard -ErrorAction Stop
+ [ordered]@{
+ virtualizationBasedSecurityStatus = [uint32]$value.VirtualizationBasedSecurityStatus
+ securityServicesConfigured = @($value.SecurityServicesConfigured | ForEach-Object { [uint32]$_ })
+ securityServicesRunning = @($value.SecurityServicesRunning | ForEach-Object { [uint32]$_ })
+ requiredSecurityProperties = @($value.RequiredSecurityProperties | ForEach-Object { [uint32]$_ })
+ availableSecurityProperties = @($value.AvailableSecurityProperties | ForEach-Object { [uint32]$_ })
+ }
+ }
+ $hvciRegistry = Capture-Section 'hvciRegistry' {
+ $path = 'HKLM:\SYSTEM\CurrentControlSet\Control\DeviceGuard\Scenarios\HypervisorEnforcedCodeIntegrity'
+ if (Test-Path -LiteralPath $path) {
+ $value = Get-ItemProperty -LiteralPath $path -ErrorAction Stop
+ $enabledProperty = $value.PSObject.Properties['Enabled']
+ $lockedProperty = $value.PSObject.Properties['Locked']
+ [ordered]@{
+ present = $true
+ enabled = if ($null -ne $enabledProperty) { [int]$enabledProperty.Value } else { $null }
+ locked = if ($null -ne $lockedProperty) { [int]$lockedProperty.Value } else { $null }
+ }
+ }
+ else { [ordered]@{ present = $false; enabled = $null; locked = $null } }
+ }
+ $pendingReboot = Capture-Section 'pendingReboot' {
+ $sessionManager = Get-ItemProperty `
+ -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' `
+ -ErrorAction Stop
+ $activeComputerName = Get-ItemProperty `
+ -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\ComputerName\ActiveComputerName' `
+ -Name ComputerName -ErrorAction Stop
+ $pendingComputerName = Get-ItemProperty `
+ -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\ComputerName\ComputerName' `
+ -Name ComputerName -ErrorAction Stop
+ [ordered]@{
+ componentBasedServicing = Test-Path -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending'
+ windowsUpdate = Test-Path -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired'
+ pendingFileRenameOperations =
+ Test-ViiperNonemptyOptionalRegistryProperty `
+ -RegistryObject $sessionManager `
+ -Name 'PendingFileRenameOperations'
+ pendingComputerRename = -not [string]::Equals(
+ [string]$activeComputerName.ComputerName,
+ [string]$pendingComputerName.ComputerName,
+ [StringComparison]::OrdinalIgnoreCase)
+ }
+ }
+ $disks = Capture-Section 'disks' {
+ @((Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3' -ErrorAction Stop) |
+ ForEach-Object {
+ [ordered]@{
+ deviceId = [string]$_.DeviceID
+ volumeName = [string]$_.VolumeName
+ sizeBytes = [uint64]$_.Size
+ freeBytes = [uint64]$_.FreeSpace
+ }
+ })
+ }
+ $processes = Capture-Section 'backgroundProcesses' {
+ @((Get-CimInstance Win32_Process -ErrorAction Stop) | Sort-Object Name, ProcessId |
+ ForEach-Object {
+ [ordered]@{
+ name = [string]$_.Name
+ processId = [uint32]$_.ProcessId
+ parentProcessId = [uint32]$_.ParentProcessId
+ threadCount = [uint32]$_.ThreadCount
+ workingSetBytes = [uint64]$_.WorkingSetSize
+ executablePath = [string]$_.ExecutablePath
+ }
+ })
+ }
+
+ $usbipServices = Capture-Section 'usbipServices' {
+ $candidates = @(
+ @(Get-CimInstance Win32_Service -ErrorAction Stop | ForEach-Object {
+ [pscustomobject]@{
+ Kind = 'Win32_Service'; Name = $_.Name; DisplayName = $_.DisplayName
+ State = $_.State; StartMode = $_.StartMode; StartName = $_.StartName
+ PathName = $_.PathName
+ }
+ })
+ @(Get-CimInstance Win32_SystemDriver -ErrorAction Stop | ForEach-Object {
+ [pscustomobject]@{
+ Kind = 'Win32_SystemDriver'; Name = $_.Name; DisplayName = $_.DisplayName
+ State = $_.State; StartMode = $_.StartMode; StartName = $_.StartName
+ PathName = $_.PathName
+ }
+ })
+ )
+ @(($candidates | Where-Object {
+ (@($_.Name, $_.DisplayName, $_.PathName) -join ' ') -match '(?i)usbip|usb/ip|vhci'
+ }) | Sort-Object Kind, Name -Unique | ForEach-Object {
+ $resolvedImage = Resolve-ViiperServiceImage -ImagePath ([string]$_.PathName)
+ [ordered]@{
+ kind = [string]$_.Kind
+ name = [string]$_.Name
+ displayName = [string]$_.DisplayName
+ state = [string]$_.State
+ startMode = [string]$_.StartMode
+ startName = [string]$_.StartName
+ rawPathName = [string]$_.PathName
+ image = Get-ViiperFileProvenance -Path $resolvedImage
+ }
+ })
+ }
+ $usbipDrivers = Capture-Section 'usbipSignedDrivers' {
+ @((Get-CimInstance Win32_PnPSignedDriver -ErrorAction Stop | Where-Object {
+ (@($_.DeviceName, $_.DriverProviderName, $_.InfName, $_.DeviceID, $_.Signer) -join ' ') -match
+ '(?i)usbip|usb/ip|vhci'
+ }) | Sort-Object DeviceID | ForEach-Object {
+ $infPath = if ([string]$_.InfName -match '^oem[0-9]+\.inf$') {
+ Join-Path (Join-Path $env:SystemRoot 'INF') ([string]$_.InfName)
+ } else { $null }
+ [ordered]@{
+ deviceName = [string]$_.DeviceName
+ deviceId = [string]$_.DeviceID
+ infName = [string]$_.InfName
+ publishedInf = if ($null -ne $infPath -and (Test-Path -LiteralPath $infPath -PathType Leaf)) {
+ Get-ViiperFileProvenance -Path $infPath
+ } else { $null }
+ provider = [string]$_.DriverProviderName
+ driverVersion = [string]$_.DriverVersion
+ driverDate = [string]$_.DriverDate
+ signer = [string]$_.Signer
+ isSigned = [bool]$_.IsSigned
+ }
+ })
+ }
+ $usbipDevices = Capture-Section 'usbipDeviceInstances' {
+ @((Get-CimInstance Win32_PnPEntity -ErrorAction Stop | Where-Object {
+ (@($_.Name, $_.Description, $_.PNPDeviceID,
+ (@($_.HardwareID) -join ' ')) -join ' ') -match
+ '(?i)usbip|usb/ip|vhci'
+ }) | Sort-Object PNPDeviceID | ForEach-Object {
+ [ordered]@{
+ name = [string]$_.Name
+ description = [string]$_.Description
+ instanceId = [string]$_.PNPDeviceID
+ hardwareIds = @($_.HardwareID | ForEach-Object { [string]$_ })
+ service = [string]$_.Service
+ status = [string]$_.Status
+ problemCode = [uint32]$_.ConfigManagerErrorCode
+ }
+ })
+ }
+ $driverStoreEnumeration = Capture-Section 'driverStoreEnumeration' {
+ Invoke-ViiperReadOnlyCommand -FilePath (Join-Path $env:SystemRoot 'System32\pnputil.exe') `
+ -Arguments @('/enum-drivers', '/files')
+ }
+
+ return [ordered]@{
+ schema = 'viiper.windows11.machine-snapshot/v1'
+ capturedUtc = [DateTime]::UtcNow.ToString('o')
+ observationalOnly = $true
+ operatingSystem = $os
+ computerSystem = $computer
+ activePowerPlan = $powerCfg
+ battery = $battery
+ bootConfiguration = $bcd
+ deviceGuard = $deviceGuard
+ hvciRegistry = $hvciRegistry
+ pendingReboot = $pendingReboot
+ fixedDisks = @($disks)
+ backgroundProcesses = @($processes)
+ usbipComparator = [ordered]@{
+ claim = 'provenance-only; no ABBA or latency-superiority claim'
+ services = @($usbipServices)
+ signedDrivers = @($usbipDrivers)
+ deviceInstances = @($usbipDevices)
+ driverStoreEnumeration = $driverStoreEnumeration
+ }
+ collectionErrors = @($errors)
+ }
+}
+
+function Assert-ViiperPreflightPendingReboot {
+ param(
+ [ValidateNotNull()]
+ [Parameter(Mandatory = $true)]$Snapshot,
+ [Parameter(Mandatory = $true)][string]$SnapshotPath
+ )
+
+ function Get-RequiredPendingRebootProperty {
+ param(
+ [Parameter(Mandatory = $true)]$Object,
+ [Parameter(Mandatory = $true)][string]$Name
+ )
+
+ if ($Object -is [Collections.IDictionary]) {
+ if (-not $Object.Contains($Name)) {
+ throw "Pending-reboot evidence is missing '$Name'."
+ }
+ $value = $Object[$Name]
+ }
+ else {
+ $property = $Object.PSObject.Properties[$Name]
+ if ($null -eq $property) {
+ throw "Pending-reboot evidence is missing '$Name'."
+ }
+ $value = $property.Value
+ }
+ return [pscustomobject]@{ Value = $value }
+ }
+
+ try {
+ $collectionErrors = (Get-RequiredPendingRebootProperty `
+ -Object $Snapshot -Name 'collectionErrors').Value
+ if ($null -eq $collectionErrors) {
+ throw 'Pending-reboot collection error inventory is null.'
+ }
+ foreach ($collectionError in @($collectionErrors)) {
+ if ($null -eq $collectionError) {
+ throw 'Pending-reboot collection error inventory is malformed.'
+ }
+ $section = (Get-RequiredPendingRebootProperty `
+ -Object $collectionError -Name 'section').Value
+ if ([string]$section -ceq 'pendingReboot') {
+ throw 'Pending-reboot collection reported an error.'
+ }
+ }
+
+ $pendingReboot = (Get-RequiredPendingRebootProperty `
+ -Object $Snapshot -Name 'pendingReboot').Value
+ if ($null -eq $pendingReboot) {
+ throw 'Pending-reboot evidence is null.'
+ }
+
+ $hasPendingReboot = $false
+ foreach ($name in @(
+ 'componentBasedServicing', 'windowsUpdate',
+ 'pendingFileRenameOperations', 'pendingComputerRename')) {
+ $flag = (Get-RequiredPendingRebootProperty `
+ -Object $pendingReboot -Name $name).Value
+ if ($flag -isnot [bool]) {
+ throw "Pending-reboot evidence '$name' is not Boolean."
+ }
+ if ($flag) { $hasPendingReboot = $true }
+ }
+ }
+ catch {
+ throw "Preflight could not establish the pending-reboot baseline. Snapshot: '$SnapshotPath'. $($_.Exception.Message)"
+ }
+
+ if ($hasPendingReboot) {
+ Write-Warning 'MANUAL REBOOT PROMPT: Windows reports a pending reboot. Restart, rerun Preflight, and preserve the same inputs.'
+ throw "Preflight refuses a pending-reboot baseline. Snapshot: '$SnapshotPath'."
+ }
+}
+
+function Test-ViiperFailedInstallRecoveryEvidence {
+ param(
+ [Parameter(Mandatory = $true)][string]$PredecessorEvidenceRoot,
+ [Parameter(Mandatory = $true)][string]$PredecessorInstallStepDirectory,
+ [Parameter(Mandatory = $true)][string]$ExpectedStateSHA256,
+ [Parameter(Mandatory = $true)][string]$ExpectedInstallCommandSHA256,
+ [Parameter(Mandatory = $true)][string]$ExpectedInstallResultSHA256,
+ [Parameter(Mandatory = $true)][string]$ExpectedInstallStdoutSHA256,
+ [Parameter(Mandatory = $true)][string]$ExpectedInstallStderrSHA256,
+ [Parameter(Mandatory = $true)][string]$ExpectedBundleManifestSHA256,
+ [Parameter(Mandatory = $true)][string]$ExpectedViiperSourceRevision,
+ [Parameter(Mandatory = $true)][string]$ExpectedDS4WindowsSourceRevision,
+ [Parameter(Mandatory = $true)][string]$ExpectedPackageLockSHA256,
+ [Parameter(Mandatory = $true)][string]$ExpectedMachine,
+ [Parameter(Mandatory = $true)][string]$ExpectedTargetUserSID
+ )
+
+ $digests = @(
+ $ExpectedStateSHA256, $ExpectedInstallCommandSHA256,
+ $ExpectedInstallResultSHA256, $ExpectedInstallStdoutSHA256,
+ $ExpectedInstallStderrSHA256, $ExpectedBundleManifestSHA256,
+ $ExpectedPackageLockSHA256
+ )
+ if (@($digests | Where-Object { $_ -cnotmatch '^[0-9a-fA-F]{64}$' }).Count -ne 0 -or
+ $ExpectedViiperSourceRevision -cnotmatch '^[0-9a-fA-F]{40,64}$' -or
+ $ExpectedDS4WindowsSourceRevision -cnotmatch '^[0-9a-fA-F]{40,64}$' -or
+ $ExpectedTargetUserSID -cnotmatch '^S-1-5-21-(?:[0-9]+-){3}[0-9]+$') {
+ throw 'Failed-install recovery identities are not canonical hashes, revisions, and a user SID.'
+ }
+
+ $predecessorRoot = Resolve-ViiperSafeDirectory `
+ -Path $PredecessorEvidenceRoot -Label 'Predecessor evidence root'
+ $predecessorSteps = Resolve-ViiperSafeDirectory `
+ -Path (Join-Path $predecessorRoot 'steps') `
+ -Label 'Predecessor evidence steps directory'
+ $installStep = Resolve-ViiperSafeDirectory `
+ -Path $PredecessorInstallStepDirectory `
+ -Label 'Predecessor Install evidence directory'
+ if ((Split-Path -Parent $installStep) -ine $predecessorSteps) {
+ throw 'Predecessor Install evidence must be one direct child of its retained steps directory.'
+ }
+
+ $statePath = Resolve-ViiperRegularFile `
+ -Path (Join-Path $predecessorRoot 'state\validation-state.json') `
+ -Label 'Predecessor validation state'
+ $commandPath = Resolve-ViiperRegularFile `
+ -Path (Join-Path $installStep 'command.json') `
+ -Label 'Predecessor Install command evidence'
+ $resultPath = Resolve-ViiperRegularFile `
+ -Path (Join-Path $installStep 'result.json') `
+ -Label 'Predecessor Install result evidence'
+ $stdoutPath = Resolve-ViiperRegularFile `
+ -Path (Join-Path $installStep 'stdout.log') `
+ -Label 'Predecessor Install stdout evidence'
+ $stderrPath = Resolve-ViiperRegularFile `
+ -Path (Join-Path $installStep 'stderr.log') `
+ -Label 'Predecessor Install stderr evidence'
+
+ $expectedFiles = [ordered]@{
+ $statePath = $ExpectedStateSHA256
+ $commandPath = $ExpectedInstallCommandSHA256
+ $resultPath = $ExpectedInstallResultSHA256
+ $stdoutPath = $ExpectedInstallStdoutSHA256
+ $stderrPath = $ExpectedInstallStderrSHA256
+ }
+ $lockedStreams = [Collections.Generic.List[IO.FileStream]]::new()
+ $lockedEvidence = @{}
+ try {
+ foreach ($entry in $expectedFiles.GetEnumerator()) {
+ $path = [string]$entry.Key
+ $stream = [IO.FileStream]::new(
+ $path, [IO.FileMode]::Open, [IO.FileAccess]::Read,
+ [IO.FileShare]::Read)
+ $lockedStreams.Add($stream)
+ if ($stream.Length -le 0 -or $stream.Length -gt 16777216) {
+ throw "Predecessor recovery evidence length is outside its bound: '$path'."
+ }
+ $bytes = [byte[]]::new([int]$stream.Length)
+ $offset = 0
+ while ($offset -lt $bytes.Length) {
+ $read = $stream.Read($bytes, $offset, $bytes.Length - $offset)
+ if ($read -le 0) {
+ throw "Predecessor recovery evidence ended before its locked length: '$path'."
+ }
+ $offset += $read
+ }
+ $algorithm = [Security.Cryptography.SHA256]::Create()
+ try {
+ $digest = ([BitConverter]::ToString(
+ $algorithm.ComputeHash($bytes))).Replace(
+ '-', '').ToLowerInvariant()
+ }
+ finally {
+ $algorithm.Dispose()
+ }
+ if ($digest -cne ([string]$entry.Value).ToLowerInvariant()) {
+ throw "Predecessor recovery evidence hash mismatch: '$path'."
+ }
+ $textOffset = if ($bytes.Length -ge 3 -and
+ $bytes[0] -eq 0xef -and $bytes[1] -eq 0xbb -and
+ $bytes[2] -eq 0xbf) { 3 } else { 0 }
+ $text = [Text.UTF8Encoding]::new($false, $true).GetString(
+ $bytes, $textOffset, $bytes.Length - $textOffset)
+ $lockedEvidence[$path] = [pscustomobject]@{
+ Hash = $digest
+ Text = $text
+ HadUtf8Bom = $textOffset -ne 0
+ }
+ }
+
+ $state = $lockedEvidence[$statePath].Text |
+ ConvertFrom-Json -ErrorAction Stop
+ $history = @($state.history)
+ if ([string]$state.schema -cne 'viiper.windows11.validation-state/v1' -or
+ [string]$state.bundleManifestSha256 -cne
+ $ExpectedBundleManifestSHA256.ToLowerInvariant() -or
+ [string]$state.viiperSourceRevision -cne
+ $ExpectedViiperSourceRevision.ToLowerInvariant() -or
+ [string]$state.ds4WindowsSourceRevision -cne
+ $ExpectedDS4WindowsSourceRevision.ToLowerInvariant() -or
+ [string]$state.packageLockSha256 -cne
+ $ExpectedPackageLockSHA256.ToLowerInvariant() -or
+ [string]$state.machine -cne $ExpectedMachine -or
+ [string]$state.targetUserSid -cne $ExpectedTargetUserSID -or
+ [string]$state.lifecycle -cne 'transaction-failed' -or
+ [string]$state.pendingTransaction -cne 'Install' -or
+ [int]$state.trustBeforeInstall.Root -ne 0 -or
+ [int]$state.trustBeforeInstall.TrustedPublisher -ne 0 -or
+ $history.Count -eq 0 -or
+ [string]$history[-1].phase -cne 'Install' -or
+ [string]$history[-1].lifecycle -cne 'transaction-failed') {
+ throw 'Predecessor state is not the exact zero-prior-trust failed Install authorized for recovery.'
+ }
+
+ $command = $lockedEvidence[$commandPath].Text |
+ ConvertFrom-Json -ErrorAction Stop
+ $arguments = @($command.arguments | ForEach-Object { [string]$_ })
+ if ([string]$command.schema -cne 'viiper.windows11.captured-command/v1' -or
+ [string]$command.name -cne 'install') {
+ throw 'Predecessor command evidence is not the captured Install command.'
+ }
+ function Assert-UniquePredecessorArgument {
+ param([string]$Name, [string]$ExpectedValue)
+ $indexes = @()
+ for ($index = 0; $index -lt $arguments.Count; ++$index) {
+ if ($arguments[$index] -ceq $Name) { $indexes += $index }
+ }
+ if ($indexes.Count -ne 1 -or $indexes[0] + 1 -ge $arguments.Count -or
+ $arguments[$indexes[0] + 1] -cne $ExpectedValue) {
+ throw "Predecessor Install command lost its unique '$Name' identity."
+ }
+ }
+ Assert-UniquePredecessorArgument '-ExpectedSourceRevision' `
+ $ExpectedViiperSourceRevision.ToLowerInvariant()
+ Assert-UniquePredecessorArgument '-ExpectedPackageLockSHA256' `
+ $ExpectedPackageLockSHA256.ToLowerInvariant()
+ Assert-UniquePredecessorArgument '-TargetUserSID' $ExpectedTargetUserSID
+ if (@($arguments | Where-Object {
+ $_ -ceq '-AcknowledgeDisposableTestMachine'
+ }).Count -ne 1) {
+ throw 'Predecessor Install command lacks its unique disposable-machine acknowledgement.'
+ }
+
+ $result = $lockedEvidence[$resultPath].Text |
+ ConvertFrom-Json -ErrorAction Stop
+ if ([string]$result.schema -cne 'viiper.windows11.captured-result/v1' -or
+ [string]$result.name -cne 'install' -or
+ $result.started -ne $true -or [int]$result.exitCode -ne 1 -or
+ $result.success -ne $false -or $null -ne $result.launchFailure -or
+ [string]$result.evidenceDirectory -ine $installStep) {
+ throw 'Predecessor result does not prove one launched, failed Install child.'
+ }
+
+ $stdout = [string]$lockedEvidence[$stdoutPath].Text
+ # R4 captured the privileged VIIPER command, not the helper directly. Bind
+ # the complete observed stdout envelope: four trust proofs followed by the
+ # deterministic Kong/Go wrapper, each LF-terminated in UTF-8 without a BOM.
+ # Exact ordinal equality prevents a caller-selected hash from authorizing a
+ # bare outcome, arbitrary context, diagnostics, CRLF, or another result.
+ $r4TrustProofs = [string[]]@(
+ 'local-test-trust store=Root action=add result=added',
+ 'local-test-trust store=Root action=verify-add result=present',
+ 'local-test-trust store=TrustedPublisher action=add result=added',
+ 'local-test-trust store=TrustedPublisher action=verify-add result=present'
+ )
+ $r4FailureOutcome = 'result=error operation=install changed=0 ' +
+ 'rebootRequired=0 rollback=not-needed exitCode=4 ' +
+ 'phase="install-journal-broker-image-hash" win32Error=23 ' +
+ 'message="protected broker evidence differs from its immutable digest"'
+ $r4FailureWrapper = 'VIIPER: error: install native driver and broker ' +
+ 'transaction: native driver helper failed with exit 4: exit status 4: ' +
+ $r4FailureOutcome
+ $expectedR4Stdout = [string]::Join(
+ "`n", [string[]]@($r4TrustProofs + $r4FailureWrapper)) + "`n"
+ if ([bool]$lockedEvidence[$stdoutPath].HadUtf8Bom -or
+ -not [string]::Equals($stdout, $expectedR4Stdout,
+ [StringComparison]::Ordinal)) {
+ throw 'Predecessor stdout is not the exact five-line LF-terminated R4 trust and wrapped zero-change failure proof.'
+ }
+
+ return [ordered]@{
+ predecessorEvidenceRoot = $predecessorRoot
+ installEvidenceDirectory = $installStep
+ statePath = $statePath
+ stateSha256 = [string]$lockedEvidence[$statePath].Hash
+ commandSha256 = [string]$lockedEvidence[$commandPath].Hash
+ resultSha256 = [string]$lockedEvidence[$resultPath].Hash
+ stdoutSha256 = [string]$lockedEvidence[$stdoutPath].Hash
+ stderrSha256 = [string]$lockedEvidence[$stderrPath].Hash
+ bundleManifestSha256 = $ExpectedBundleManifestSHA256.ToLowerInvariant()
+ viiperSourceRevision = $ExpectedViiperSourceRevision.ToLowerInvariant()
+ ds4WindowsSourceRevision = $ExpectedDS4WindowsSourceRevision.ToLowerInvariant()
+ packageLockSha256 = $ExpectedPackageLockSHA256.ToLowerInvariant()
+ }
+ }
+ finally {
+ for ($index = $lockedStreams.Count - 1; $index -ge 0; --$index) {
+ $lockedStreams[$index].Dispose()
+ }
+ }
+}
+
+function Get-ViiperValidationPhaseModel {
+ return @(
+ [ordered]@{ phase = 'RecoverFailedInstall'; predecessor = 'exact predecessor transaction-failed Install evidence'; mutatesMachine = $true; next = 'fresh-preflight-ready-or-reboot' },
+ [ordered]@{ phase = 'Preflight'; predecessor = 'new'; mutatesMachine = $false; next = 'preflight-complete' },
+ [ordered]@{ phase = 'Install'; predecessor = 'preflight-complete'; mutatesMachine = $true; next = 'installed-or-reboot' },
+ [ordered]@{ phase = 'Repair'; predecessor = 'installed'; mutatesMachine = $true; next = 'installed-or-reboot' },
+ [ordered]@{ phase = 'RebootResume'; predecessor = 'awaiting-transaction-reboot-or-interrupted-running-transaction'; mutatesMachine = $true; next = 'installed-or-reboot' },
+ [ordered]@{ phase = 'ManualChecks'; predecessor = 'installed'; mutatesMachine = $false; next = 'manual-complete-or-reboot' },
+ [ordered]@{ phase = 'EnableVerifier'; predecessor = 'manual-complete'; mutatesMachine = $true; next = 'awaiting-verifier-reboot' },
+ [ordered]@{ phase = 'VerifierResume'; predecessor = 'awaiting-verifier-reboot'; mutatesMachine = $false; next = 'verifier-ready' },
+ [ordered]@{ phase = 'Live'; predecessor = 'verifier-ready'; mutatesMachine = $true; next = 'live-complete'; includes = 'VIIPER reference plus DS4Windows HID/media/reconnect runner' },
+ [ordered]@{ phase = 'Performance'; predecessor = 'live-complete'; mutatesMachine = $true; next = 'performance-complete' },
+ [ordered]@{ phase = 'LatencyMatrix'; predecessor = 'performance-complete'; mutatesMachine = $true; next = 'latency-complete'; claim = 'descriptive exact-machine-session only' },
+ [ordered]@{ phase = 'CollectDumps'; predecessor = 'any-after-install'; mutatesMachine = $false; next = 'unchanged' },
+ [ordered]@{ phase = 'Uninstall'; predecessor = 'any-after-install'; mutatesMachine = $true; next = 'uninstalled-or-reboot' },
+ [ordered]@{ phase = 'Status'; predecessor = 'any'; mutatesMachine = $false; next = 'unchanged' }
+ )
+}
+
+Export-ModuleMember -Function @(
+ 'Get-ViiperSha256', 'Resolve-ViiperRegularFile', 'Resolve-ViiperSafeDirectory',
+ 'Write-ViiperJsonAtomic', 'Test-ViiperGitIdentity', 'Test-ViiperLocalTestPackage',
+ 'Test-ViiperFailedInstallRecoveryEvidence',
+ 'Get-ViiperBootIdentity', 'Get-ViiperMachineEvidenceSnapshot',
+ 'Assert-ViiperPreflightPendingReboot', 'Get-ViiperValidationPhaseModel'
+)
diff --git a/extras/validation/fixtures/viiper-r4-failed-install.json b/extras/validation/fixtures/viiper-r4-failed-install.json
new file mode 100644
index 0000000..e651494
--- /dev/null
+++ b/extras/validation/fixtures/viiper-r4-failed-install.json
@@ -0,0 +1,27 @@
+{
+ "schema": "viiper.windows11.failed-install-fixture/v1",
+ "reportedDate": "2026-08-15 America/Chicago",
+ "predecessorEvidenceRoot": "C:\\Users\\hbash\\Documents\\Codex\\2026-08-15\\the\\outputs\\VIIPER-Win11-9481f9d-272f6a0-r4",
+ "predecessorInstallStepDirectory": "C:\\Users\\hbash\\Documents\\Codex\\2026-08-15\\the\\outputs\\VIIPER-Win11-9481f9d-272f6a0-r4\\steps\\20260816T034608909Z-install-27fffa05b7e544feb3c5a415ebd1f6c4",
+ "stateSha256": "e13c686a0cddcf66620940005568b3a7a9a41abb277f61977dd88994863d8cda",
+ "installCommandSha256": "c38579b1504c8851dd72317d49f4439d14b7878b4e19907ebe864c8ad986e3f7",
+ "installResultSha256": "1095194f448455f746b5af92b89ae4f08f8f69a7ba9fac1d17a90d73e8a971b0",
+ "installStdoutSha256": "ca95fac3b8bd6fe7871a7f42400031f01ea946dc88786e9e9a746084144c205b",
+ "installStderrSha256": "2610d56f76be3c1aea4f6b3dd4e4b38d134a1d311133ac46f389a28f8faeb520",
+ "bundleManifestSha256": "765de4fe822004e97940fa66ba73602dafd68194d14fd64e20b388444cd4c247",
+ "viiperSourceRevision": "9481f9dbfde64af99905fa325546e50b5ea03d6e",
+ "ds4WindowsSourceRevision": "272f6a05f1476d5aa9c055a234e61c292d3c1556",
+ "packageLockSha256": "16e08c31bb1c240a3612a6c4ddc8219b040d0e2dec5773e39f363d045113ab8c",
+ "certificateSha256": "09ca0c2d4d3da29268eff59cf85b6c1347d4a28ddc098b8640381694ad74c517",
+ "certificateThumbprintSha1": "1DB216A879BB695B28EE876946048836FEF86EEA",
+ "failure": {
+ "operation": "install",
+ "changed": 0,
+ "rebootRequired": 0,
+ "rollback": "not-needed",
+ "exitCode": 4,
+ "phase": "install-journal-broker-image-hash",
+ "win32Error": 23,
+ "message": "protected broker evidence differs from its immutable digest"
+ }
+}
diff --git a/installer/DS4Windows.Bootstrapper/DS4Windows.Bootstrapper.csproj b/installer/DS4Windows.Bootstrapper/DS4Windows.Bootstrapper.csproj
new file mode 100644
index 0000000..a2571dd
--- /dev/null
+++ b/installer/DS4Windows.Bootstrapper/DS4Windows.Bootstrapper.csproj
@@ -0,0 +1,24 @@
+
+
+ WinExe
+ net48
+ x64
+ x64
+ win-x64
+ true
+ true
+ DS4Windows.Bootstrapper
+ DS4Windows.Bootstrapper
+ ..\..\DS4Windows\DS4W.ico
+ app.manifest
+
+
+
+
+
+
+
+
+
+
+
diff --git a/installer/DS4Windows.Bootstrapper/InfrastructureProbe.cs b/installer/DS4Windows.Bootstrapper/InfrastructureProbe.cs
new file mode 100644
index 0000000..214991f
--- /dev/null
+++ b/installer/DS4Windows.Bootstrapper/InfrastructureProbe.cs
@@ -0,0 +1,346 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Security.Cryptography;
+using System.Security.Principal;
+using System.ServiceProcess;
+using System.Runtime.Serialization;
+using System.Runtime.Serialization.Json;
+using System.Text;
+using Microsoft.Win32;
+
+namespace DS4Windows.Bootstrapper
+{
+ internal static class InfrastructureProbe
+ {
+ private const string RegistryKeyPath = @"SOFTWARE\DS4Windows";
+ private const string ServiceName = "VIIPERNativeBroker";
+
+ internal static bool IsHealthy()
+ {
+ try
+ {
+ var installRoot = Path.GetFullPath(Path.Combine(
+ Environment.GetFolderPath(
+ Environment.SpecialFolder.ProgramFiles),
+ "DS4Windows"));
+ var metadataPath = Path.Combine(installRoot,
+ "ViiperNativeRuntimeMetadata.json");
+ if (!IsOrdinaryFile(metadataPath) ||
+ !DirectoryPathHasNoReparsePoints(installRoot))
+ {
+ return false;
+ }
+
+ string receipt;
+ string targetSid;
+ string expectedMetadataHash;
+ using (var machine = RegistryKey.OpenBaseKey(
+ RegistryHive.LocalMachine,
+ RegistryView.Registry64))
+ using (var key = machine.OpenSubKey(RegistryKeyPath))
+ {
+ receipt = key?.GetValue(
+ "NativePackageReceipt") as string;
+ targetSid = key?.GetValue(
+ "NativePackageTargetUserSid") as string;
+ expectedMetadataHash = key?.GetValue(
+ "NativePackageMetadataSha256") as string;
+ }
+ if (!string.Equals(receipt, "Installed",
+ StringComparison.Ordinal) ||
+ !IsValidInteractiveSid(targetSid) ||
+ string.IsNullOrWhiteSpace(expectedMetadataHash) ||
+ !string.Equals(ComputeSha256(metadataPath),
+ expectedMetadataHash,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ NativeMetadata metadata;
+ var serializer = new DataContractJsonSerializer(
+ typeof(NativeMetadata));
+ using (var stream = new FileStream(metadataPath,
+ FileMode.Open, FileAccess.Read,
+ FileShare.Read))
+ {
+ metadata = (NativeMetadata)
+ serializer.ReadObject(stream);
+ }
+ if (metadata == null || metadata.schemaVersion != 1 ||
+ !string.Equals(metadata.releaseEligibility,
+ "production", StringComparison.Ordinal) ||
+ metadata.managedBroker == null ||
+ !string.Equals(metadata.managedBroker.serviceName,
+ ServiceName, StringComparison.Ordinal) ||
+ !string.Equals(metadata.managedBroker.serviceAccount,
+ "LocalSystem", StringComparison.Ordinal) ||
+ !string.Equals(metadata.managedBroker.startMode,
+ "automatic", StringComparison.Ordinal) ||
+ !string.Equals(metadata.managedBroker.transport,
+ "native-ude", StringComparison.Ordinal) ||
+ !string.Equals(metadata.managedBroker.apiHost,
+ "127.0.0.1", StringComparison.Ordinal) ||
+ metadata.managedBroker.apiPort != 3242 ||
+ !string.Equals(metadata.managedBroker.credentialPath,
+ "%ProgramData%/VIIPER/viiper.key.txt",
+ StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ var brokers = (metadata.artifacts ??
+ new List())
+ .Where(artifact => artifact != null &&
+ string.Equals(artifact.role, "broker",
+ StringComparison.Ordinal))
+ .ToList();
+ if (brokers.Count != 1 ||
+ !string.Equals(brokers[0].relativePath,
+ "viiper-native-package/viiper.exe",
+ StringComparison.Ordinal) ||
+ brokers[0].length <= 0 ||
+ string.IsNullOrWhiteSpace(brokers[0].sha256))
+ {
+ return false;
+ }
+
+ var brokerPath = Path.Combine(
+ Environment.GetFolderPath(
+ Environment.SpecialFolder.ProgramFiles),
+ "VIIPER", "viiper.exe");
+ var credentialPath = Path.Combine(
+ Environment.GetFolderPath(
+ Environment.SpecialFolder.CommonApplicationData),
+ "VIIPER", "viiper.key.txt");
+ var logPath = Path.Combine(
+ Environment.GetFolderPath(
+ Environment.SpecialFolder.CommonApplicationData),
+ "VIIPER", "viiper-native-broker.log");
+ if (!IsOrdinaryFile(brokerPath) ||
+ !IsOrdinaryFile(credentialPath) ||
+ new FileInfo(brokerPath).Length != brokers[0].length ||
+ !string.Equals(ComputeSha256(brokerPath),
+ brokers[0].sha256,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+ return IsServiceConfiguredAndRunning(brokerPath,
+ credentialPath, logPath);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static bool IsServiceConfiguredAndRunning(
+ string brokerPath, string credentialPath, string logPath)
+ {
+ using (var machine = RegistryKey.OpenBaseKey(
+ RegistryHive.LocalMachine,
+ RegistryView.Registry64))
+ using (var key = machine.OpenSubKey(
+ @"SYSTEM\CurrentControlSet\Services\" +
+ ServiceName))
+ {
+ if (key == null ||
+ Convert.ToInt32(key.GetValue("Start", -1)) != 2 ||
+ Convert.ToInt32(key.GetValue("Type", -1)) != 16 ||
+ !IsLocalSystem(key.GetValue("ObjectName") as string) ||
+ !CommandLineMatches(
+ key.GetValue("ImagePath") as string,
+ brokerPath, credentialPath, logPath))
+ {
+ return false;
+ }
+ }
+
+ using (var service = new ServiceController(ServiceName))
+ {
+ return service.Status ==
+ ServiceControllerStatus.Running;
+ }
+ }
+
+ private static bool IsLocalSystem(string account)
+ {
+ return string.Equals(account, "LocalSystem",
+ StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(account, @"NT AUTHORITY\SYSTEM",
+ StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(account, @".\LocalSystem",
+ StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool CommandLineMatches(string commandLine,
+ string brokerPath, string credentialPath, string logPath)
+ {
+ if (string.IsNullOrWhiteSpace(commandLine)) return false;
+ var arguments = CommandLineToArgvW(commandLine, out var count);
+ if (arguments == IntPtr.Zero) return false;
+ try
+ {
+ var expected = new[]
+ {
+ brokerPath,
+ "service",
+ "--transport",
+ "native-ude",
+ "--key-file",
+ credentialPath,
+ "--log.file",
+ logPath,
+ };
+ if (count != expected.Length) return false;
+ for (var index = 0; index < expected.Length; index++)
+ {
+ var pointer = Marshal.ReadIntPtr(arguments,
+ index * IntPtr.Size);
+ var actual = Marshal.PtrToStringUni(pointer);
+ var comparison = index == 0 || index == 5 ||
+ index == 7
+ ? StringComparison.OrdinalIgnoreCase
+ : StringComparison.Ordinal;
+ if (!string.Equals(actual, expected[index],
+ comparison))
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+ finally
+ {
+ LocalFree(arguments);
+ }
+ }
+
+ [DllImport("shell32.dll", SetLastError = true)]
+ private static extern IntPtr CommandLineToArgvW(
+ [MarshalAs(UnmanagedType.LPWStr)] string commandLine,
+ out int argumentCount);
+
+ [DllImport("kernel32.dll")]
+ private static extern IntPtr LocalFree(IntPtr memory);
+
+ private static bool IsValidInteractiveSid(string value)
+ {
+ try
+ {
+ var sid = new SecurityIdentifier(value);
+ return !sid.IsWellKnown(
+ WellKnownSidType.LocalSystemSid) &&
+ !sid.IsWellKnown(
+ WellKnownSidType.BuiltinAdministratorsSid) &&
+ !sid.IsWellKnown(
+ WellKnownSidType.LocalServiceSid) &&
+ !sid.IsWellKnown(
+ WellKnownSidType.NetworkServiceSid);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static bool IsOrdinaryFile(string path)
+ {
+ if (!File.Exists(path)) return false;
+ if (!DirectoryPathHasNoReparsePoints(
+ Path.GetDirectoryName(Path.GetFullPath(path))))
+ {
+ return false;
+ }
+ return (File.GetAttributes(path) &
+ FileAttributes.ReparsePoint) == 0;
+ }
+
+ private static bool DirectoryPathHasNoReparsePoints(string path)
+ {
+ var resolved = Path.GetFullPath(path)
+ .TrimEnd(Path.DirectorySeparatorChar);
+ var root = Path.GetPathRoot(resolved);
+ if (string.IsNullOrWhiteSpace(root)) return false;
+ var cursor = root;
+ foreach (var component in resolved.Substring(root.Length)
+ .Split(new[]
+ {
+ Path.DirectorySeparatorChar,
+ Path.AltDirectorySeparatorChar,
+ }, StringSplitOptions.RemoveEmptyEntries))
+ {
+ cursor = Path.Combine(cursor, component);
+ if (!Directory.Exists(cursor)) return false;
+ var attributes = File.GetAttributes(cursor);
+ if ((attributes & FileAttributes.ReparsePoint) != 0 ||
+ (attributes & FileAttributes.Directory) == 0)
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static string ComputeSha256(string path)
+ {
+ using (var algorithm = SHA256.Create())
+ using (var stream = new FileStream(path, FileMode.Open,
+ FileAccess.Read, FileShare.Read))
+ {
+ return BitConverter.ToString(
+ algorithm.ComputeHash(stream))
+ .Replace("-", string.Empty);
+ }
+ }
+
+ [DataContract]
+ private sealed class NativeMetadata
+ {
+ [DataMember(Name = "schemaVersion", IsRequired = true)]
+ public int schemaVersion { get; set; }
+ [DataMember(Name = "releaseEligibility", IsRequired = true)]
+ public string releaseEligibility { get; set; }
+ [DataMember(Name = "managedBroker", IsRequired = true)]
+ public ManagedBroker managedBroker { get; set; }
+ [DataMember(Name = "artifacts", IsRequired = true)]
+ public List artifacts { get; set; }
+ }
+
+ [DataContract]
+ private sealed class ManagedBroker
+ {
+ [DataMember(Name = "serviceName", IsRequired = true)]
+ public string serviceName { get; set; }
+ [DataMember(Name = "serviceAccount", IsRequired = true)]
+ public string serviceAccount { get; set; }
+ [DataMember(Name = "startMode", IsRequired = true)]
+ public string startMode { get; set; }
+ [DataMember(Name = "transport", IsRequired = true)]
+ public string transport { get; set; }
+ [DataMember(Name = "apiHost", IsRequired = true)]
+ public string apiHost { get; set; }
+ [DataMember(Name = "apiPort", IsRequired = true)]
+ public int apiPort { get; set; }
+ [DataMember(Name = "credentialPath", IsRequired = true)]
+ public string credentialPath { get; set; }
+ }
+
+ [DataContract]
+ private sealed class NativeArtifact
+ {
+ [DataMember(Name = "role", IsRequired = true)]
+ public string role { get; set; }
+ [DataMember(Name = "relativePath", IsRequired = true)]
+ public string relativePath { get; set; }
+ [DataMember(Name = "length", IsRequired = true)]
+ public long length { get; set; }
+ [DataMember(Name = "sha256", IsRequired = true)]
+ public string sha256 { get; set; }
+ }
+ }
+}
diff --git a/installer/DS4Windows.Bootstrapper/InstallerApplication.cs b/installer/DS4Windows.Bootstrapper/InstallerApplication.cs
new file mode 100644
index 0000000..a323995
--- /dev/null
+++ b/installer/DS4Windows.Bootstrapper/InstallerApplication.cs
@@ -0,0 +1,783 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Security.Principal;
+using System.Text;
+using System.Threading;
+using System.Windows;
+using System.Windows.Interop;
+using System.Windows.Threading;
+using WixToolset.BootstrapperApplicationApi;
+
+namespace DS4Windows.Bootstrapper
+{
+ internal sealed class InstallerApplication : BootstrapperApplication
+ {
+ private readonly Dictionary packageStates = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ private InstallerWindow window;
+ private IBootstrapperCommand command;
+ private RegistrationType registrationType;
+ private LaunchAction plannedAction = LaunchAction.Install;
+ private int result;
+ private string lastError;
+ private bool infrastructureHealthy;
+ private bool infrastructureFailed;
+ private bool closingProgrammatically;
+ private bool applyCompleted;
+ private bool failureShown;
+ private bool relatedUpgradeDetected;
+ private string newerRelatedBundleVersion;
+ private bool deferInfrastructureUntilUpgradeCompletes;
+ private bool infrastructureRecoveryPass;
+ private Mutex bundleMutex;
+ private bool bundleMutexOwned;
+ private int planStarted;
+ private bool targetUserSidPrepared;
+
+ internal IEngine Engine => engine;
+
+ protected override void OnCreate(CreateEventArgs args)
+ {
+ base.OnCreate(args);
+ command = args.Command;
+ }
+
+ protected override void Run()
+ {
+ try
+ {
+ try
+ {
+ HookEvents();
+ window = new InstallerWindow(this);
+ if (command.Display == Display.Full || command.Display == Display.Passive)
+ {
+ window.Show();
+ }
+
+ engine.CloseSplashScreen();
+ engine.Detect();
+ Dispatcher.Run();
+ }
+ catch (Exception ex)
+ {
+ result = 1;
+ try
+ {
+ engine.Log(LogLevel.Error,
+ "Unhandled bootstrapper failure: " + ex);
+ }
+ catch { }
+ try { engine.CloseSplashScreen(); } catch { }
+ }
+ }
+ finally
+ {
+ ReleaseBundleMutex();
+ }
+ engine.Quit(NormalizeExitCode(result));
+ }
+
+ private bool PrepareTargetUserSid()
+ {
+ if (targetUserSidPrepared) return true;
+
+ string sid = null;
+ try
+ {
+ sid = engine.GetVariableString(
+ "InstalledTargetUserSid");
+ }
+ catch { }
+
+ if (string.IsNullOrWhiteSpace(sid))
+ {
+ try { sid = engine.GetVariableString("TargetUserSid"); }
+ catch { }
+ }
+
+ // A first install captures the unelevated bootstrapper user's SID.
+ // Maintenance, upgrade, and reboot resume instead preserve the SID
+ // that owns the installed protected broker credential.
+ if (string.IsNullOrWhiteSpace(sid) &&
+ command.Resume != ResumeType.Reboot &&
+ command.Relation != RelationType.Upgrade &&
+ registrationType == RegistrationType.None &&
+ command.Action != LaunchAction.Uninstall)
+ {
+ using (var identity = WindowsIdentity.GetCurrent())
+ {
+ sid = identity.User?.Value;
+ }
+ }
+
+ if (!IsInteractiveUserSid(sid))
+ {
+ ShowFailure(1,
+ "Setup could not recover the exact DS4Windows user " +
+ "identity required by the native broker credential. " +
+ "Use the signed installer that last installed this " +
+ "machine, or reinstall from the intended user account.");
+ return false;
+ }
+ engine.SetVariableString("TargetUserSid", sid, true);
+ targetUserSidPrepared = true;
+ return true;
+ }
+
+ private static bool IsInteractiveUserSid(string value)
+ {
+ try
+ {
+ var sid = new SecurityIdentifier(value);
+ return !sid.IsWellKnown(
+ WellKnownSidType.LocalSystemSid) &&
+ !sid.IsWellKnown(
+ WellKnownSidType.BuiltinAdministratorsSid) &&
+ !sid.IsWellKnown(
+ WellKnownSidType.LocalServiceSid) &&
+ !sid.IsWellKnown(
+ WellKnownSidType.NetworkServiceSid);
+ }
+ catch { return false; }
+ }
+
+ internal void Begin(LaunchAction action)
+ {
+ // The incoming Burn engine owns the transaction mutex for its
+ // complete package chain, including related-bundle removal. An
+ // outgoing bundle launched by that engine must not compete with
+ // its parent for the same lock; all top-level invocations still
+ // have to acquire it before planning anything.
+ var parentOwnedRelatedUninstall =
+ command.Relation == RelationType.Upgrade &&
+ action == LaunchAction.Uninstall;
+ if (!parentOwnedRelatedUninstall && !EnsureBundleMutex()) return;
+ StartPlan(action, () =>
+ {
+ deferInfrastructureUntilUpgradeCompletes =
+ !infrastructureRecoveryPass &&
+ (action == LaunchAction.Install ||
+ action == LaunchAction.Repair) &&
+ relatedUpgradeDetected;
+ if (deferInfrastructureUntilUpgradeCompletes)
+ {
+ engine.Log(LogLevel.Standard,
+ "Deferring the native package transaction until older related bundles are removed.");
+ }
+ });
+ }
+
+ private bool StartPlan(LaunchAction action, Action configure = null)
+ {
+ if (Interlocked.CompareExchange(ref planStarted, 1, 0) != 0)
+ {
+ engine.Log(LogLevel.Standard,
+ "Ignoring a duplicate installer plan request while the current transaction is active.");
+ return false;
+ }
+
+ try
+ {
+ plannedAction = action;
+ configure?.Invoke();
+ if (command.Display == Display.Full)
+ {
+ Ui(() => window.ShowPlanning());
+ }
+ engine.Plan(action);
+ return true;
+ }
+ catch
+ {
+ Interlocked.Exchange(ref planStarted, 0);
+ throw;
+ }
+ }
+
+ private bool TryAcquireBundleMutex(int waitMilliseconds)
+ {
+ if (bundleMutexOwned) return true;
+ try
+ {
+ bundleMutex = new Mutex(false,
+ @"Global\DS4Windows-Installer-Transaction");
+ try
+ {
+ bundleMutexOwned = bundleMutex.WaitOne(waitMilliseconds);
+ }
+ catch (AbandonedMutexException)
+ {
+ bundleMutexOwned = true;
+ }
+ }
+ catch (Exception ex)
+ {
+ engine.Log(LogLevel.Error,
+ "Could not inspect the installer transaction mutex: " +
+ ex.Message);
+ bundleMutexOwned = false;
+ }
+
+ if (!bundleMutexOwned)
+ {
+ bundleMutex?.Dispose();
+ bundleMutex = null;
+ }
+ return bundleMutexOwned;
+ }
+
+ private bool EnsureBundleMutex(int waitMilliseconds = 0)
+ {
+ if (TryAcquireBundleMutex(waitMilliseconds)) return true;
+ ShowFailure(1618,
+ "Another DS4Windows installation or repair is already running. Close it, then choose Retry.");
+ return false;
+ }
+
+ private void ReleaseBundleMutex()
+ {
+ if (bundleMutexOwned)
+ {
+ try { bundleMutex.ReleaseMutex(); } catch { }
+ }
+ bundleMutexOwned = false;
+ bundleMutex?.Dispose();
+ bundleMutex = null;
+ }
+
+ internal void Retry()
+ {
+ result = 0;
+ lastError = null;
+ infrastructureFailed = false;
+ infrastructureRecoveryPass = false;
+ deferInfrastructureUntilUpgradeCompletes = false;
+ applyCompleted = false;
+ failureShown = false;
+ Interlocked.Exchange(ref planStarted, 0);
+ engine.Detect();
+ }
+
+ internal void CloseWithCurrentResult() => Close(result);
+
+ internal void LaunchDs4Windows()
+ {
+ // The bootstrapper application remains in the initiating user's
+ // unelevated process while Burn's engine applies the per-machine
+ // chain. Launch directly from that original user token; no task,
+ // secondary elevation hop, mutable helper, or alternate administrator profile is
+ // involved.
+ var path = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),
+ "DS4Windows", "DS4Windows.exe");
+ if (File.Exists(path))
+ {
+ try
+ {
+ Process.Start(new ProcessStartInfo(path)
+ { UseShellExecute = true })?.Dispose();
+ }
+ catch (Exception ex)
+ {
+ engine.Log(LogLevel.Error,
+ "Could not launch DS4Windows: " + ex.Message);
+ }
+ }
+ }
+
+ internal void OpenLog()
+ {
+ try
+ {
+ // setup-actions.log is appended before target-user validation
+ // and before the child PowerShell script starts. It therefore
+ // cannot be a stale log from a previous child transaction when
+ // an early helper failure occurs.
+ var path = infrastructureFailed ? SetupActionsLogPath : null;
+ if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
+ {
+ path = engine.GetVariableString("WixBundleLog");
+ }
+ if (!string.IsNullOrWhiteSpace(path))
+ {
+ Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
+ }
+ }
+ catch { }
+ }
+
+ internal string Diagnostics()
+ {
+ string log = null;
+ try { log = engine.GetVariableString("WixBundleLog"); } catch { }
+ var helperLog = SetupActionsLogPath;
+ return "DS4Windows Setup\r\n" +
+ "Action: " + plannedAction + "\r\n" +
+ "Registered: " + registrationType + "\r\n" +
+ "Native package healthy: " + infrastructureHealthy + "\r\n" +
+ "Error: " + (lastError ?? "none") + "\r\n" +
+ "Bundle log: " + (log ?? "unavailable") + "\r\n" +
+ "Setup helper log: " + helperLog + "\r\n\r\n" +
+ "--- Setup helper tail ---\r\n" +
+ ReadLogTail(helperLog, 12000);
+ }
+
+ internal void Close(int exitCode = 0)
+ {
+ if (window != null && !window.Dispatcher.CheckAccess())
+ {
+ window.Dispatcher.BeginInvoke(new Action(() => Close(exitCode)));
+ return;
+ }
+ result = exitCode;
+ closingProgrammatically = true;
+ window?.Close();
+ Dispatcher.ExitAllFrames();
+ }
+
+ internal void OnWindowClosed()
+ {
+ if (!closingProgrammatically && !applyCompleted && !failureShown)
+ {
+ result = 1223;
+ }
+ Dispatcher.ExitAllFrames();
+ }
+
+ internal bool RestartWindows()
+ {
+ try
+ {
+ var shutdown = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.Windows),
+ "System32", "shutdown.exe");
+ Process.Start(new ProcessStartInfo(shutdown, "/r /t 0")
+ {
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ })?.Dispose();
+ return true;
+ }
+ catch (Exception ex)
+ {
+ engine.Log(LogLevel.Error,
+ "Could not restart Windows: " + ex.Message);
+ return false;
+ }
+ }
+
+ private void HookEvents()
+ {
+ DetectBegin += (_, e) =>
+ {
+ registrationType = e.RegistrationType;
+ packageStates.Clear();
+ relatedUpgradeDetected = false;
+ newerRelatedBundleVersion = null;
+ };
+ DetectRelatedBundle += (_, e) =>
+ {
+ if (e.RelationType == RelationType.Upgrade)
+ {
+ var currentVersion = engine.GetVariableVersion(
+ "WixBundleVersion");
+ if (IsRelatedBundleNewer(e.Version, currentVersion))
+ {
+ newerRelatedBundleVersion = e.Version;
+ }
+ else
+ {
+ relatedUpgradeDetected = true;
+ }
+ }
+ };
+ DetectPackageComplete += (_, e) => packageStates[e.PackageId] = e.State;
+ DetectComplete += OnDetectComplete;
+ PlanPackageBegin += OnPlanPackageBegin;
+ PlanRelatedBundle += (_, e) =>
+ {
+ if (infrastructureRecoveryPass ||
+ (command.Relation == RelationType.Upgrade &&
+ plannedAction == LaunchAction.Uninstall))
+ {
+ // The incoming primary engine owns related-bundle
+ // ordering. An outgoing bundle only removes itself, and
+ // the isolated recovery pass only repairs infrastructure;
+ // neither may recursively launch sibling bundle engines.
+ e.State = RequestState.None;
+ }
+ };
+ PlanComplete += OnPlanComplete;
+ ApplyBegin += (_, __) => Ui(() => window.ShowApplying());
+ ExecutePackageBegin += (_, e) =>
+ {
+ Ui(() => window.SetCurrentPackage(e.PackageId));
+ };
+ ExecutePackageComplete += (_, e) =>
+ {
+ if ((string.Equals(e.PackageId, "ViiperNativeSetup",
+ StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(e.PackageId, "ViiperNativeRemove",
+ StringComparison.OrdinalIgnoreCase)) &&
+ e.Status < 0)
+ {
+ infrastructureFailed = true;
+ }
+ };
+ ExecuteProgress += (_, e) => Ui(() => window.SetProgress(e.OverallPercentage));
+ Error += (_, e) =>
+ {
+ lastError = string.IsNullOrWhiteSpace(e.ErrorMessage) ? "Setup error " + e.ErrorCode : e.ErrorMessage;
+ engine.Log(LogLevel.Error, lastError);
+ };
+ ApplyComplete += OnApplyComplete;
+ }
+
+ private void OnDetectComplete(object sender, DetectCompleteEventArgs e)
+ {
+ if (e.Status < 0)
+ {
+ ShowFailure(e.Status, "Setup could not inspect the current installation.");
+ return;
+ }
+
+ if (!string.IsNullOrWhiteSpace(newerRelatedBundleVersion))
+ {
+ ShowFailure(1638,
+ "A newer DS4Windows installer (" +
+ newerRelatedBundleVersion +
+ ") is already installed. This older package will not " +
+ "remove or replace it.");
+ return;
+ }
+
+ if (!PrepareTargetUserSid()) return;
+
+ infrastructureHealthy = InfrastructureProbe.IsHealthy();
+
+ // Burn removes related bundles after it executes this bundle's
+ // package chain. Older DS4Windows bundles own older infrastructure
+ // helpers, so installing VIIPER before those bundles are removed
+ // lets their uninstall overwrite or delete the new helper. Finish
+ // the app upgrade first, then run one isolated infrastructure
+ // recovery pass against the final machine state.
+ if (infrastructureRecoveryPass)
+ {
+ if (!EnsureBundleMutex()) return;
+ StartPlan(LaunchAction.Repair);
+ return;
+ }
+
+ var mode = registrationType == RegistrationType.Full ? InstallerMode.Repair : InstallerMode.Install;
+ if (command.Action == LaunchAction.Uninstall)
+ {
+ mode = InstallerMode.Uninstall;
+ }
+ else if (registrationType == RegistrationType.None && packageStates.TryGetValue("DS4WindowsMsi", out var msiState) && msiState == PackageState.Present)
+ {
+ mode = InstallerMode.Update;
+ }
+ else if (registrationType == RegistrationType.None &&
+ relatedUpgradeDetected)
+ {
+ mode = InstallerMode.Update;
+ }
+
+ // Burn persists the chain state and re-launches the cached bundle
+ // after a package-requested reboot. Resume that saved action
+ // immediately, just as WixStdBA does, instead of presenting a new
+ // confirmation page and leaving the chain half-finished.
+ if (command.Resume == ResumeType.Reboot)
+ {
+ if (!EnsureBundleMutex()) return;
+ var resumeAction = command.Action == LaunchAction.Unknown
+ ? LaunchAction.Install
+ : command.Action;
+ StartPlan(resumeAction);
+ return;
+ }
+
+ Ui(() => window.ShowConfirmation(mode, packageStates, infrastructureHealthy));
+
+ if (command.Display != Display.Full)
+ {
+ var action = command.Action == LaunchAction.Unknown ? LaunchAction.Install : command.Action;
+ if (action == LaunchAction.Layout)
+ {
+ var layoutDirectory = string.IsNullOrWhiteSpace(command.LayoutDirectory)
+ ? Environment.CurrentDirectory
+ : command.LayoutDirectory;
+ engine.SetVariableString("WixBundleLayoutDirectory", layoutDirectory, false);
+ }
+ Begin(action);
+ }
+ }
+
+ private void OnPlanPackageBegin(object sender, PlanPackageBeginEventArgs e)
+ {
+ var packageId = e.PackageId ?? string.Empty;
+ if (string.Equals(e.PackageId, "CloseRunningApplications",
+ StringComparison.OrdinalIgnoreCase))
+ {
+ // Quiesce managed processes before forward install/repair
+ // execution. Uninstall uses the dedicated tail preflight
+ // below because Burn unwinds its package chain in reverse.
+ e.State = !infrastructureRecoveryPass &&
+ (plannedAction == LaunchAction.Install ||
+ plannedAction == LaunchAction.Repair)
+ ? RequestState.Present
+ : RequestState.None;
+ return;
+ }
+
+ var outgoingRelatedUninstall =
+ command.Relation == RelationType.Upgrade &&
+ plannedAction == LaunchAction.Uninstall;
+ if (string.Equals(e.PackageId,
+ "CloseRunningApplicationsForUninstall",
+ StringComparison.OrdinalIgnoreCase))
+ {
+ // Burn uninstalls in reverse chain order. This tail package
+ // is therefore the first executable action during direct or
+ // related-bundle uninstall, before infrastructure or MSI
+ // ownership is removed.
+ e.State = !infrastructureRecoveryPass &&
+ plannedAction == LaunchAction.Uninstall &&
+ !outgoingRelatedUninstall
+ ? RequestState.Present
+ : RequestState.None;
+ return;
+ }
+
+ if (string.Equals(packageId, "ViiperNativeRemove",
+ StringComparison.OrdinalIgnoreCase))
+ {
+ // This install-direction package is deliberately planned
+ // Present during a direct bundle uninstall. Burn unwinds the
+ // chain from the tail, so it calls the protected manager
+ // before the MSI removes Program Files media. Outgoing related
+ // bundles never tear down infrastructure owned by the incoming
+ // upgrade.
+ e.State = !infrastructureRecoveryPass &&
+ plannedAction == LaunchAction.Uninstall &&
+ !outgoingRelatedUninstall
+ ? RequestState.Present
+ : RequestState.None;
+ return;
+ }
+
+ if (infrastructureRecoveryPass)
+ {
+ if (string.Equals(packageId, "ViiperNativeSetup",
+ StringComparison.OrdinalIgnoreCase))
+ {
+ e.State = e.CurrentState == PackageState.Present
+ ? RequestState.Repair
+ : RequestState.Present;
+ }
+ else
+ {
+ // The MSI already completed in the primary transaction.
+ // The recovery pass is deliberately limited to the native
+ // package manager.
+ e.State = RequestState.None;
+ }
+ return;
+ }
+
+ if (string.Equals(packageId, "ViiperNativeSetup",
+ StringComparison.OrdinalIgnoreCase) &&
+ (deferInfrastructureUntilUpgradeCompletes ||
+ plannedAction == LaunchAction.Uninstall))
+ {
+ e.State = RequestState.None;
+ return;
+ }
+
+ if (string.Equals(packageId, "ViiperNativeSetup",
+ StringComparison.OrdinalIgnoreCase) &&
+ (plannedAction == LaunchAction.Install ||
+ plannedAction == LaunchAction.Repair))
+ {
+ // Always run the signed helper after MSI mutation. It verifies
+ // the installed manifest and lets VIIPER's own transaction
+ // enforce exact hashes, ABI, driver, service, credential, and
+ // authenticated readiness contracts.
+ e.State = e.CurrentState == PackageState.Present
+ ? RequestState.Repair
+ : RequestState.Present;
+ }
+ }
+
+ private void OnPlanComplete(object sender, PlanCompleteEventArgs e)
+ {
+ if (e.Status < 0)
+ {
+ ShowFailure(e.Status, "Setup could not create a safe installation plan.");
+ return;
+ }
+ Ui(() => engine.Apply(new WindowInteropHelper(window).EnsureHandle()));
+ }
+
+ private void OnApplyComplete(object sender, ApplyCompleteEventArgs e)
+ {
+ result = e.Status;
+ applyCompleted = true;
+ if (e.Status < 0)
+ {
+ var detail = infrastructureFailed ?
+ InfrastructureFailureSummary() : null;
+ var message = lastError ?? "Setup did not complete.";
+ if (!string.IsNullOrWhiteSpace(detail))
+ {
+ message += "\r\n\r\n" + detail;
+ }
+ ShowFailure(e.Status, message);
+ return;
+ }
+
+ var restartRequired =
+ e.Restart == ApplyRestart.RestartRequired ||
+ e.Restart == ApplyRestart.RestartInitiated;
+ if (restartRequired)
+ {
+ // Quiet/passive callers must receive the same reboot contract
+ // as the full UI rather than a misleading zero exit code.
+ result = 3010;
+ }
+
+ if (!restartRequired &&
+ (plannedAction == LaunchAction.Install ||
+ plannedAction == LaunchAction.Repair) &&
+ !InfrastructureProbe.IsHealthy())
+ {
+ if (infrastructureRecoveryPass)
+ {
+ ShowFailure(1,
+ "DS4Windows installed, but the native VIIPER package did not pass " +
+ "the final post-upgrade health check.");
+ return;
+ }
+
+ infrastructureRecoveryPass = true;
+ applyCompleted = false;
+ infrastructureFailed = false;
+ engine.Log(LogLevel.Standard,
+ "Starting the isolated post-upgrade native package recovery pass.");
+ Ui(() => window.ShowPlanning());
+ Interlocked.Exchange(ref planStarted, 0);
+ engine.Detect();
+ return;
+ }
+
+ if (command.Display != Display.Full)
+ {
+ Close(result);
+ return;
+ }
+
+ Ui(() =>
+ {
+ if (restartRequired)
+ {
+ window.ShowRestart();
+ }
+ else
+ {
+ window.ShowComplete(plannedAction);
+ }
+ });
+ }
+
+ private void ShowFailure(int status, string message)
+ {
+ result = status;
+ failureShown = true;
+ lastError = message + " (0x" + status.ToString("X8") + ")";
+ if (command.Display != Display.Full)
+ {
+ Close(status);
+ return;
+ }
+ Ui(() => window.ShowFailure(lastError));
+ }
+
+ private void Ui(Action action)
+ {
+ if (window == null) return;
+ if (window.Dispatcher.CheckAccess()) action();
+ else window.Dispatcher.BeginInvoke(action);
+ }
+
+ private static int NormalizeExitCode(int exitCode)
+ {
+ return (exitCode & unchecked((int)0xFFFF0000)) == unchecked((int)0x80070000) ? exitCode & 0xFFFF : exitCode;
+ }
+
+ internal static bool IsRelatedBundleNewer(string relatedVersion,
+ string currentVersion)
+ {
+ Version related;
+ Version current;
+ return Version.TryParse(relatedVersion, out related) &&
+ Version.TryParse(currentVersion, out current) &&
+ related > current;
+ }
+
+ private static string SetupActionsLogPath => Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
+ "DS4Windows", "Installer", "setup-actions.log");
+
+ private static string InfrastructureFailureSummary()
+ {
+ var tail = ReadLogTail(SetupActionsLogPath, 12000);
+ if (string.IsNullOrWhiteSpace(tail)) return null;
+ var invocationMarker = "=== DS4Windows setup invocation ";
+ var invocationStart = tail.LastIndexOf(invocationMarker,
+ StringComparison.Ordinal);
+ if (invocationStart >= 0)
+ {
+ tail = tail.Substring(invocationStart);
+ }
+ var marker = "Setup could not finish:";
+ var index = tail.LastIndexOf(marker, StringComparison.OrdinalIgnoreCase);
+ if (index >= 0)
+ {
+ var end = tail.IndexOfAny(new[] { '\r', '\n' }, index);
+ var summary = end < 0 ? tail.Substring(index) :
+ tail.Substring(index, end - index);
+ return summary + "\r\nOpen Log includes the complete diagnostic record.";
+ }
+ return "Native VIIPER setup failed. Open Log includes the detailed child-process diagnostics.";
+ }
+
+ private static string ReadLogTail(string path, int maximumBytes)
+ {
+ try
+ {
+ if (!File.Exists(path)) return string.Empty;
+ using (var stream = new FileStream(path, FileMode.Open,
+ FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
+ {
+ var length = (int)Math.Min(stream.Length, maximumBytes);
+ stream.Seek(-length, SeekOrigin.End);
+ var buffer = new byte[length];
+ var read = stream.Read(buffer, 0, length);
+ return Encoding.UTF8.GetString(buffer, 0, read).Trim();
+ }
+ }
+ catch { return string.Empty; }
+ }
+ }
+
+ internal enum InstallerMode
+ {
+ Install,
+ Update,
+ Repair,
+ Uninstall,
+ }
+}
diff --git a/installer/DS4Windows.Bootstrapper/InstallerWindow.xaml b/installer/DS4Windows.Bootstrapper/InstallerWindow.xaml
new file mode 100644
index 0000000..0f5d78b
--- /dev/null
+++ b/installer/DS4Windows.Bootstrapper/InstallerWindow.xaml
@@ -0,0 +1,169 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/installer/DS4Windows.Bootstrapper/InstallerWindow.xaml.cs b/installer/DS4Windows.Bootstrapper/InstallerWindow.xaml.cs
new file mode 100644
index 0000000..3d75946
--- /dev/null
+++ b/installer/DS4Windows.Bootstrapper/InstallerWindow.xaml.cs
@@ -0,0 +1,203 @@
+using Microsoft.Win32;
+using System;
+using System.Collections.Generic;
+using System.Windows;
+using System.Windows.Media;
+using WixToolset.BootstrapperApplicationApi;
+
+namespace DS4Windows.Bootstrapper
+{
+ public partial class InstallerWindow : Window
+ {
+ private readonly InstallerApplication application;
+ private InstallerMode mode;
+ private bool applying;
+
+ internal InstallerWindow(InstallerApplication application)
+ {
+ this.application = application;
+ InitializeComponent();
+ ApplyWindowsTheme();
+ Closing += (_, e) =>
+ {
+ if (applying) e.Cancel = true;
+ };
+ Closed += (_, __) => application.OnWindowClosed();
+ }
+
+ internal void ShowConfirmation(InstallerMode detectedMode, IReadOnlyDictionary packages, bool infrastructureHealthy)
+ {
+ mode = detectedMode;
+ HidePages();
+ ConfirmationPage.Visibility = Visibility.Visible;
+ applying = false;
+
+ switch (mode)
+ {
+ case InstallerMode.Update:
+ ModeTitle.Text = "Update DS4Windows";
+ ModeDescription.Text = "A managed DS4Windows installation was found. Only package-owned files will be replaced.";
+ ActionButton.Content = "Update";
+ break;
+ case InstallerMode.Repair:
+ ModeTitle.Text = "Repair DS4Windows";
+ ModeDescription.Text = "This version is already installed. Setup will verify and repair its managed components.";
+ ActionButton.Content = "Repair";
+ break;
+ case InstallerMode.Uninstall:
+ ModeTitle.Text = "Uninstall DS4Windows";
+ ModeDescription.Text = "DS4Windows and its exact native VIIPER package will be removed. Profiles and settings are preserved.";
+ ActionButton.Content = "Uninstall";
+ break;
+ default:
+ ModeTitle.Text = "Install DS4Windows";
+ ModeDescription.Text = "Everything needed for a standard x64 installation is included and works offline.";
+ ActionButton.Content = "Install";
+ break;
+ }
+
+ Ds4Status.Text = PackageStatus(packages, "DS4WindowsMsi");
+ ViiperStatus.Text = infrastructureHealthy ? "Ready" : "Will install or repair";
+ }
+
+ internal void ShowPlanning()
+ {
+ HidePages();
+ ProgressPage.Visibility = Visibility.Visible;
+ ProgressTitle.Text = "Preparing installation…";
+ ProgressDetail.Text = "Building a safe installation plan";
+ OverallProgress.IsIndeterminate = true;
+ applying = true;
+ }
+
+ internal void ShowApplying()
+ {
+ OverallProgress.IsIndeterminate = false;
+ ProgressTitle.Text = mode == InstallerMode.Uninstall ? "Removing DS4Windows…" : "Installing DS4Windows…";
+ ProgressDetail.Text = "Administrator permission is requested once";
+ }
+
+ internal void SetCurrentPackage(string packageId)
+ {
+ switch (packageId)
+ {
+ case "CloseRunningApplications":
+ case "CloseRunningApplicationsForUninstall": ProgressDetail.Text = "Closing recognized DS4Windows processes"; break;
+ case "DS4WindowsMsi": ProgressDetail.Text = "Installing DS4Windows"; break;
+ case "ViiperNativeSetup": ProgressDetail.Text = "Installing and verifying the native VIIPER package"; break;
+ case "ViiperNativeRemove": ProgressDetail.Text = "Removing the exact native VIIPER package"; break;
+ default: ProgressDetail.Text = "Verifying installation"; break;
+ }
+ }
+
+ internal void SetProgress(int percent)
+ {
+ OverallProgress.Value = Math.Max(0, Math.Min(100, percent));
+ ProgressPercent.Text = percent + "%";
+ }
+
+ internal void ShowComplete(LaunchAction action)
+ {
+ HidePages();
+ CompletePage.Visibility = Visibility.Visible;
+ LaunchCheckBox.Visibility = Visibility.Visible;
+ applying = false;
+ if (action == LaunchAction.Uninstall)
+ {
+ CompleteTitle.Text = "DS4Windows was removed";
+ CompleteDescription.Text = "Profiles and settings were preserved.";
+ LaunchCheckBox.Visibility = Visibility.Collapsed;
+ }
+ }
+
+ internal void ShowRestart()
+ {
+ HidePages();
+ RestartPage.Visibility = Visibility.Visible;
+ RestartDescription.Text = "Windows must restart before setup can safely continue. Setup will resume after you sign in.";
+ RestartNowButton.IsEnabled = true;
+ applying = false;
+ }
+
+ internal void ShowFailure(string message)
+ {
+ HidePages();
+ FailurePage.Visibility = Visibility.Visible;
+ FailureMessage.Text = message;
+ applying = false;
+ }
+
+ private void Action_Click(object sender, RoutedEventArgs e)
+ {
+ var action = mode == InstallerMode.Uninstall ? LaunchAction.Uninstall :
+ mode == InstallerMode.Repair ? LaunchAction.Repair : LaunchAction.Install;
+ application.Begin(action);
+ }
+
+ private void Cancel_Click(object sender, RoutedEventArgs e) => application.Close(1223);
+ private void CloseFailure_Click(object sender, RoutedEventArgs e) => application.CloseWithCurrentResult();
+ private void Retry_Click(object sender, RoutedEventArgs e) { HidePages(); DetectingPage.Visibility = Visibility.Visible; application.Retry(); }
+ private void OpenLog_Click(object sender, RoutedEventArgs e) => application.OpenLog();
+ private void CopyDiagnostics_Click(object sender, RoutedEventArgs e)
+ {
+ try { Clipboard.SetText(application.Diagnostics()); }
+ catch
+ {
+ FailureMessage.Text += "\r\n\r\nWindows could not access the clipboard. Use Open log instead.";
+ }
+ }
+ private void RestartLater_Click(object sender, RoutedEventArgs e) => application.Close(3010);
+ private void RestartNow_Click(object sender, RoutedEventArgs e)
+ {
+ if (application.RestartWindows())
+ {
+ application.Close(3010);
+ }
+ else
+ {
+ RestartDescription.Text = "Windows could not start the restart automatically. Restart manually; setup will resume after you sign in.";
+ RestartNowButton.IsEnabled = false;
+ }
+ }
+ private void Finish_Click(object sender, RoutedEventArgs e)
+ {
+ if (LaunchCheckBox.Visibility == Visibility.Visible && LaunchCheckBox.IsChecked == true) application.LaunchDs4Windows();
+ application.Close();
+ }
+
+ private void HidePages()
+ {
+ DetectingPage.Visibility = Visibility.Collapsed;
+ ConfirmationPage.Visibility = Visibility.Collapsed;
+ ProgressPage.Visibility = Visibility.Collapsed;
+ CompletePage.Visibility = Visibility.Collapsed;
+ RestartPage.Visibility = Visibility.Collapsed;
+ FailurePage.Visibility = Visibility.Collapsed;
+ }
+
+ private static string PackageStatus(IReadOnlyDictionary packages, string id)
+ {
+ return packages.TryGetValue(id, out var state) && state == PackageState.Present ? "Installed" : "Will install";
+ }
+
+ private void ApplyWindowsTheme()
+ {
+ var light = true;
+ try
+ {
+ using (var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"))
+ {
+ light = Convert.ToInt32(key?.GetValue("AppsUseLightTheme", 1)) != 0;
+ }
+ }
+ catch { }
+
+ Resources["WindowBrush"] = new SolidColorBrush((Color)ColorConverter.ConvertFromString(light ? "#F4F7FB" : "#08121F"));
+ Resources["CardBrush"] = new SolidColorBrush((Color)ColorConverter.ConvertFromString(light ? "#FFFFFF" : "#0E1B2A"));
+ Resources["HoverBrush"] = new SolidColorBrush((Color)ColorConverter.ConvertFromString(light ? "#EAF2FC" : "#17283B"));
+ Resources["BorderBrush"] = new SolidColorBrush((Color)ColorConverter.ConvertFromString(light ? "#D8E2EE" : "#22354A"));
+ Resources["TextBrush"] = new SolidColorBrush((Color)ColorConverter.ConvertFromString(light ? "#101D2D" : "#F4F8FC"));
+ Resources["MutedBrush"] = new SolidColorBrush((Color)ColorConverter.ConvertFromString(light ? "#56708D" : "#9FB8D3"));
+ }
+ }
+}
diff --git a/installer/DS4Windows.Bootstrapper/Program.cs b/installer/DS4Windows.Bootstrapper/Program.cs
new file mode 100644
index 0000000..52fea87
--- /dev/null
+++ b/installer/DS4Windows.Bootstrapper/Program.cs
@@ -0,0 +1,26 @@
+using WixToolset.BootstrapperApplicationApi;
+
+namespace DS4Windows.Bootstrapper
+{
+ internal static class Program
+ {
+ private static int Main()
+ {
+ try
+ {
+ ManagedBootstrapperApplication.Run(new InstallerApplication());
+ return 0;
+ }
+ catch (System.Exception ex)
+ {
+ try
+ {
+ var path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "DS4Windows.Bootstrapper.failure.log");
+ System.IO.File.WriteAllText(path, ex.ToString());
+ }
+ catch { }
+ return ex.HResult;
+ }
+ }
+ }
+}
diff --git a/installer/DS4Windows.Bootstrapper/app.manifest b/installer/DS4Windows.Bootstrapper/app.manifest
new file mode 100644
index 0000000..0beaa76
--- /dev/null
+++ b/installer/DS4Windows.Bootstrapper/app.manifest
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/installer/DS4Windows.Bundle/Bundle.wxs b/installer/DS4Windows.Bundle/Bundle.wxs
new file mode 100644
index 0000000..a87cfec
--- /dev/null
+++ b/installer/DS4Windows.Bundle/Bundle.wxs
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/installer/DS4Windows.Bundle/DS4Windows.Bundle.wixproj b/installer/DS4Windows.Bundle/DS4Windows.Bundle.wixproj
new file mode 100644
index 0000000..f455700
--- /dev/null
+++ b/installer/DS4Windows.Bundle/DS4Windows.Bundle.wixproj
@@ -0,0 +1,21 @@
+
+
+ Bundle
+ x64
+ 5.0.2.0
+ $(BundleVersion)
+ DS4Windows_$(DisplayVersion)_Setup_x64
+ $(MSBuildThisFileDirectory)..\DS4Windows.Package\bin\x64\Release\DS4Windows_$(BundleVersion)_x64.msi
+ $(MSBuildThisFileDirectory)..\DS4Windows.Bootstrapper\bin\x64\Release\net48\win-x64
+ $(MSBuildThisFileDirectory)..\DS4Windows.SetupActions\bin\x64\Release\net48\DS4Windows.SetupActions.exe
+ $(MSBuildThisFileDirectory)..\..
+ BundleVersion=$(BundleVersion);DisplayVersion=$(DisplayVersion);MsiPath=$(MsiPath);BootstrapperRoot=$(BootstrapperRoot);SetupActionsPath=$(SetupActionsPath);SetupActionsHash=$(SetupActionsHash);RepositoryRoot=$(RepositoryRoot)
+
+
+
+
+
+
+
+
diff --git a/installer/DS4Windows.Package/DS4Windows.Package.wixproj b/installer/DS4Windows.Package/DS4Windows.Package.wixproj
new file mode 100644
index 0000000..cf944c7
--- /dev/null
+++ b/installer/DS4Windows.Package/DS4Windows.Package.wixproj
@@ -0,0 +1,14 @@
+
+
+ x64
+ Package
+ DS4Windows_$(ProductVersion)_x64
+ 5.0.2.0
+ $(MSBuildThisFileDirectory)..\..\bin\x64\Release\output
+ ProductVersion=$(ProductVersion);PublishRoot=$(PublishRoot)
+
+ ICE61
+
+
diff --git a/installer/DS4Windows.Package/Product.wxs b/installer/DS4Windows.Package/Product.wxs
new file mode 100644
index 0000000..b680cf8
--- /dev/null
+++ b/installer/DS4Windows.Package/Product.wxs
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/installer/DS4Windows.SetupActions/DS4Windows.SetupActions.csproj b/installer/DS4Windows.SetupActions/DS4Windows.SetupActions.csproj
new file mode 100644
index 0000000..8a02fc1
--- /dev/null
+++ b/installer/DS4Windows.SetupActions/DS4Windows.SetupActions.csproj
@@ -0,0 +1,16 @@
+
+
+ WinExe
+ net48
+ x64
+ x64
+ DS4Windows.SetupActions
+ DS4Windows.SetupActions
+ app.manifest
+ true
+
+
+
+
+
+
diff --git a/installer/DS4Windows.SetupActions/Program.cs b/installer/DS4Windows.SetupActions/Program.cs
new file mode 100644
index 0000000..936b693
--- /dev/null
+++ b/installer/DS4Windows.SetupActions/Program.cs
@@ -0,0 +1,1358 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Security.AccessControl;
+using System.Security.Cryptography;
+using System.Security.Principal;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Web.Script.Serialization;
+using System.Runtime.InteropServices;
+using Microsoft.Win32.SafeHandles;
+using Microsoft.Win32;
+
+namespace DS4Windows.SetupActions
+{
+ internal static class Program
+ {
+ private const string RegistryKeyPath = @"SOFTWARE\DS4Windows";
+ private const string ManagerRelativePath =
+ @"extras\manage-viiper-native-package.ps1";
+ private const string MetadataRelativePath =
+ "ViiperNativeRuntimeMetadata.json";
+ private const string NativePackageRelativePath =
+ @"extras\viiper-native-package";
+ private const string ManifestFileName = "package-manifest.json";
+ private const string ResultPrefix =
+ "DS4WINDOWS_VIIPER_NATIVE_RESULT ";
+ private const string NativeReceiptValue = "NativePackageReceipt";
+ private const string NativeSidValue = "NativePackageTargetUserSid";
+ private const string NativeMetadataHashValue =
+ "NativePackageMetadataSha256";
+ private const string NativeUpdatedValue = "NativePackageUpdatedUtc";
+
+ private static readonly object LogSync = new object();
+ private static FileStream logStream;
+ private static List logDirectoryLocks;
+
+ private const uint OpenExisting = 3;
+ private const uint FileListDirectory = 0x00000001;
+ private const uint FileFlagOpenReparsePoint = 0x00200000;
+ private const uint FileFlagBackupSemantics = 0x02000000;
+
+ [STAThread]
+ private static int Main(string[] args)
+ {
+ try
+ {
+ InitializeProtectedLog();
+ WriteLog("=== DS4Windows native setup invocation " +
+ DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture) +
+ " ===");
+
+ RequireElevated64BitProcess();
+ if (args == null || args.Length == 0)
+ {
+ throw new InvalidOperationException(
+ "A setup action is required.");
+ }
+
+ var action = args[0].ToLowerInvariant();
+ if (action == "preflight")
+ {
+ RequireExactArgumentCount(args, 1);
+ return RunWithSetupMutex(() =>
+ {
+ QuiesceDs4Windows();
+ return 0;
+ });
+ }
+
+ if (action != "install" && action != "repair" &&
+ action != "uninstall")
+ {
+ throw new InvalidOperationException(
+ "Unknown setup action: " + args[0]);
+ }
+
+ RequireExactArgumentCount(args, 3);
+ if (!string.Equals(args[1], "--target-user-sid",
+ StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ "The only accepted setup argument is " +
+ "--target-user-sid.");
+ }
+ var targetSid = ValidateTargetUserSid(args[2]);
+
+ return RunWithSetupMutex(() =>
+ RunNativeTransaction(action, targetSid));
+ }
+ catch (Exception ex)
+ {
+ WriteLog("Setup could not finish: " + ex);
+ return 1;
+ }
+ finally
+ {
+ DisposeProtectedLog();
+ }
+ }
+
+ private static int RunNativeTransaction(string action,
+ string targetSid)
+ {
+ QuiesceDs4Windows();
+ var installRoot = ValidateManagedInstallRoot();
+ var operation = action == "uninstall" ? "Uninstall" : "Install";
+ var verified = VerifyProtectedNativeMedia(installRoot,
+ operation == "Install");
+
+ WriteLog("Invoking the manifest-bound native package manager for " +
+ operation.ToLowerInvariant() + ".");
+ var child = InvokeNativeManager(verified.ManagerPath, operation,
+ targetSid);
+ var receipt = ParseAndValidateReceipt(child.OutputLines,
+ operation.ToLowerInvariant(), child.ExitCode);
+
+ if (child.ExitCode == 0)
+ {
+ if (operation == "Install")
+ {
+ RecordInstalledNativePackage(targetSid,
+ verified.MetadataSha256);
+ }
+ else
+ {
+ ClearInstalledNativePackage();
+ }
+ }
+
+ WriteLog("Native package manager returned " + child.ExitCode +
+ "; rollbackStatus=" + receipt.RollbackStatus +
+ "; manualRecoveryRequired=" +
+ receipt.ManualRecoveryRequired.ToString(
+ CultureInfo.InvariantCulture).ToLowerInvariant() + ".");
+
+ if (child.ExitCode != 0 && child.ExitCode != 3010)
+ {
+ throw new NativeManagerException(child.ExitCode,
+ "The native package transaction failed. " +
+ "rollbackStatus=" + receipt.RollbackStatus +
+ ", manualRecoveryRequired=" +
+ receipt.ManualRecoveryRequired.ToString(
+ CultureInfo.InvariantCulture).ToLowerInvariant() + ".");
+ }
+ return child.ExitCode;
+ }
+
+ private static void RequireExactArgumentCount(string[] args,
+ int expected)
+ {
+ if (args.Length != expected)
+ {
+ throw new InvalidOperationException(
+ "Unexpected setup arguments were supplied.");
+ }
+ }
+
+ private static void RequireElevated64BitProcess()
+ {
+ if (!Environment.Is64BitOperatingSystem ||
+ !Environment.Is64BitProcess)
+ {
+ throw new InvalidOperationException(
+ "Native setup requires a 64-bit process on 64-bit Windows.");
+ }
+
+ using (var identity = WindowsIdentity.GetCurrent())
+ {
+ var principal = new WindowsPrincipal(identity);
+ if (!principal.IsInRole(
+ WindowsBuiltInRole.Administrator))
+ {
+ throw new UnauthorizedAccessException(
+ "Native setup must be launched by the elevated " +
+ "per-machine installer engine.");
+ }
+ }
+ }
+
+ private static string ValidateTargetUserSid(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value) ||
+ !Regex.IsMatch(value, @"^S-\d(?:-\d+){2,14}$",
+ RegexOptions.CultureInvariant))
+ {
+ throw new InvalidOperationException(
+ "The target user SID is malformed.");
+ }
+
+ var sid = new SecurityIdentifier(value);
+ if (!string.Equals(sid.Value, value,
+ StringComparison.OrdinalIgnoreCase) ||
+ sid.IsWellKnown(WellKnownSidType.LocalSystemSid) ||
+ sid.IsWellKnown(
+ WellKnownSidType.BuiltinAdministratorsSid) ||
+ sid.IsWellKnown(WellKnownSidType.LocalServiceSid) ||
+ sid.IsWellKnown(WellKnownSidType.NetworkServiceSid))
+ {
+ throw new InvalidOperationException(
+ "The target SID must identify the interactive " +
+ "DS4Windows user.");
+ }
+
+ using (var machine = RegistryKey.OpenBaseKey(
+ RegistryHive.LocalMachine, RegistryView.Registry64))
+ using (var profile = machine.OpenSubKey(
+ @"SOFTWARE\Microsoft\Windows NT\CurrentVersion\" +
+ @"ProfileList\" + sid.Value))
+ {
+ if (string.IsNullOrWhiteSpace(
+ profile?.GetValue("ProfileImagePath") as string))
+ {
+ throw new InvalidOperationException(
+ "Windows has no registered profile for the target SID.");
+ }
+ }
+ return sid.Value;
+ }
+
+ private static string ValidateManagedInstallRoot()
+ {
+ var programFiles = Path.GetFullPath(
+ Environment.GetFolderPath(
+ Environment.SpecialFolder.ProgramFiles))
+ .TrimEnd(Path.DirectorySeparatorChar);
+ var installRoot = Path.GetFullPath(Path.Combine(programFiles,
+ "DS4Windows"))
+ .TrimEnd(Path.DirectorySeparatorChar);
+
+ if (!Directory.Exists(installRoot))
+ {
+ throw new DirectoryNotFoundException(
+ "The protected DS4Windows installation is unavailable: " +
+ installRoot);
+ }
+ EnsureDirectoryPathHasNoReparsePoints(installRoot);
+ RequireProtectedDirectoryAcl(installRoot);
+ return installRoot;
+ }
+
+ private static void RequireProtectedDirectoryAcl(string path)
+ {
+ RequireProtectedAcl(Directory.GetAccessControl(path,
+ AccessControlSections.Owner |
+ AccessControlSections.Access), path);
+ }
+
+ private static void RequireProtectedFileAcl(string path)
+ {
+ RequireProtectedAcl(File.GetAccessControl(path,
+ AccessControlSections.Owner |
+ AccessControlSections.Access), path);
+ }
+
+ private static void RequireProtectedAcl(
+ FileSystemSecurity security, string path)
+ {
+ var trustedWriteSids = new HashSet(
+ StringComparer.OrdinalIgnoreCase)
+ {
+ new SecurityIdentifier(WellKnownSidType.LocalSystemSid,
+ null).Value,
+ new SecurityIdentifier(
+ WellKnownSidType.BuiltinAdministratorsSid,
+ null).Value,
+ // NT SERVICE\TrustedInstaller
+ "S-1-5-80-956008885-3418522649-1831038044-" +
+ "1853292631-2271478464",
+ };
+ // Use only atomic mutation and ACL-control bits here. Composite
+ // values such as Write, Modify, and FullControl also contain
+ // ordinary read/synchronize bits; intersecting those composites
+ // would incorrectly reject the default Program Files
+ // ReadAndExecute grants.
+ const FileSystemRights mutationOrAclControlRights =
+ FileSystemRights.WriteData |
+ FileSystemRights.AppendData |
+ FileSystemRights.WriteExtendedAttributes |
+ FileSystemRights.DeleteSubdirectoriesAndFiles |
+ FileSystemRights.WriteAttributes |
+ FileSystemRights.Delete |
+ FileSystemRights.ChangePermissions |
+ FileSystemRights.TakeOwnership;
+ const long genericWrite = 0x40000000L;
+ const long genericAll = 0x10000000L;
+
+ var owner = ((SecurityIdentifier)security.GetOwner(
+ typeof(SecurityIdentifier))).Value;
+ if (!trustedWriteSids.Contains(owner))
+ {
+ throw new UnauthorizedAccessException(
+ "The protected installer path has an untrusted " +
+ "owner: " + path);
+ }
+
+ var rules = security.GetAccessRules(true, true,
+ typeof(SecurityIdentifier))
+ .Cast();
+ foreach (var rule in rules)
+ {
+ var sid = ((SecurityIdentifier)rule.IdentityReference).Value;
+ var rights = (long)rule.FileSystemRights;
+ var grantsWrite =
+ (rights & (long)mutationOrAclControlRights) != 0 ||
+ (rights & genericWrite) != 0 ||
+ (rights & genericAll) != 0;
+ var creatorOwnerInheritOnly =
+ string.Equals(sid,
+ new SecurityIdentifier(
+ WellKnownSidType.CreatorOwnerSid,
+ null).Value,
+ StringComparison.OrdinalIgnoreCase) &&
+ (rule.PropagationFlags &
+ PropagationFlags.InheritOnly) != 0;
+ if (rule.AccessControlType == AccessControlType.Allow &&
+ grantsWrite && !trustedWriteSids.Contains(sid) &&
+ !creatorOwnerInheritOnly)
+ {
+ throw new UnauthorizedAccessException(
+ "The managed Program Files directory grants " +
+ "write access outside the trusted installer " +
+ "principals: " + path);
+ }
+ }
+ }
+
+ private static VerifiedMedia VerifyProtectedNativeMedia(
+ string installRoot, bool requireCompleteInstallMedia)
+ {
+ var manifestPath = Path.Combine(installRoot, ManifestFileName);
+ RequireOrdinaryFile(manifestPath);
+ var serializer = new JavaScriptSerializer
+ { MaxJsonLength = 16 * 1024 * 1024 };
+ PackageManifest manifest;
+ try
+ {
+ manifest = serializer.Deserialize(
+ File.ReadAllText(manifestPath, Encoding.UTF8));
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidDataException(
+ "The installed package manifest is malformed.", ex);
+ }
+
+ if (manifest == null || manifest.schema != 1 ||
+ !string.Equals(manifest.product, "DS4Windows",
+ StringComparison.Ordinal) ||
+ !string.Equals(manifest.architecture, "x64",
+ StringComparison.Ordinal) ||
+ string.IsNullOrWhiteSpace(manifest.version) ||
+ manifest.files == null || manifest.files.Count == 0)
+ {
+ throw new InvalidDataException(
+ "The installed package manifest contract is invalid.");
+ }
+
+ var entries = new Dictionary(
+ StringComparer.OrdinalIgnoreCase);
+ foreach (var entry in manifest.files)
+ {
+ if (entry == null || entry.size < 0 ||
+ !Regex.IsMatch(entry.sha256 ?? string.Empty,
+ "^[0-9A-Fa-f]{64}$",
+ RegexOptions.CultureInvariant))
+ {
+ throw new InvalidDataException(
+ "The installed package manifest contains an " +
+ "invalid file record.");
+ }
+ var relative = ValidateRelativeManifestPath(entry.path);
+ if (entries.ContainsKey(relative))
+ {
+ throw new InvalidDataException(
+ "The installed package manifest contains a " +
+ "case-insensitive duplicate path: " + relative);
+ }
+ entries.Add(relative, entry);
+ }
+
+ var required = new[]
+ {
+ ManagerRelativePath,
+ MetadataRelativePath,
+ };
+ foreach (var relative in required)
+ {
+ VerifyManifestEntry(installRoot, relative, entries);
+ }
+ if (requireCompleteInstallMedia)
+ {
+ VerifyManifestEntry(installRoot, "DS4Windows.exe", entries);
+ }
+
+ var packageRoot = Path.Combine(installRoot,
+ NativePackageRelativePath);
+ if (!Directory.Exists(packageRoot))
+ {
+ throw new DirectoryNotFoundException(
+ "The installed native package tree is missing.");
+ }
+ EnsureDirectoryPathHasNoReparsePoints(packageRoot);
+ RequireProtectedDirectoryAcl(packageRoot);
+
+ var actualPackageFiles = EnumerateOrdinaryFiles(packageRoot)
+ .Select(path => RelativePath(installRoot, path))
+ .OrderBy(path => path,
+ StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ var manifestPackageFiles = entries.Keys
+ .Where(path => path.StartsWith(
+ NativePackageRelativePath +
+ Path.DirectorySeparatorChar,
+ StringComparison.OrdinalIgnoreCase))
+ .OrderBy(path => path,
+ StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ var manifestPackageSet = new HashSet(
+ manifestPackageFiles, StringComparer.OrdinalIgnoreCase);
+ if (actualPackageFiles.Any(path =>
+ !manifestPackageSet.Contains(path)))
+ {
+ throw new InvalidDataException(
+ "The installed native package contains an unbound file.");
+ }
+
+ if (requireCompleteInstallMedia)
+ {
+ if (actualPackageFiles.Count == 0)
+ {
+ throw new InvalidDataException(
+ "The installed native package tree is empty.");
+ }
+ if (!actualPackageFiles.SequenceEqual(manifestPackageFiles,
+ StringComparer.OrdinalIgnoreCase))
+ {
+ throw new InvalidDataException(
+ "The installed native package inventory does not " +
+ "match the signed MSI manifest.");
+ }
+ foreach (var relative in manifestPackageFiles)
+ {
+ VerifyManifestEntry(installRoot, relative, entries);
+ }
+ }
+
+ var managerPath = Path.Combine(installRoot,
+ ManagerRelativePath);
+ var metadataPath = Path.Combine(installRoot,
+ MetadataRelativePath);
+ return new VerifiedMedia(managerPath,
+ ComputeSha256(metadataPath));
+ }
+
+ private static string ValidateRelativeManifestPath(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path) ||
+ path.IndexOf('\\') >= 0 || path.IndexOf(':') >= 0 ||
+ path.StartsWith("/", StringComparison.Ordinal) ||
+ path.EndsWith("/", StringComparison.Ordinal))
+ {
+ throw new InvalidDataException(
+ "The package manifest contains an unsafe path.");
+ }
+ var components = path.Split('/');
+ if (components.Any(component =>
+ string.IsNullOrWhiteSpace(component) ||
+ component == "." || component == ".."))
+ {
+ throw new InvalidDataException(
+ "The package manifest contains an unsafe path.");
+ }
+ return string.Join(Path.DirectorySeparatorChar.ToString(),
+ components);
+ }
+
+ private static void VerifyManifestEntry(string installRoot,
+ string relativePath,
+ IDictionary entries)
+ {
+ var normalized = relativePath.Replace('/',
+ Path.DirectorySeparatorChar);
+ if (!entries.TryGetValue(normalized, out var entry))
+ {
+ throw new InvalidDataException(
+ "The signed MSI manifest does not bind " +
+ relativePath + ".");
+ }
+
+ var fullPath = Path.GetFullPath(Path.Combine(installRoot,
+ normalized));
+ var prefix = installRoot.TrimEnd(
+ Path.DirectorySeparatorChar) +
+ Path.DirectorySeparatorChar;
+ if (!fullPath.StartsWith(prefix,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidDataException(
+ "A package manifest path escapes Program Files.");
+ }
+ RequireOrdinaryFile(fullPath);
+ var info = new FileInfo(fullPath);
+ if (info.Length != entry.size ||
+ !string.Equals(ComputeSha256(fullPath), entry.sha256,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidDataException(
+ "Installed package media failed its signed hash " +
+ "contract: " + relativePath);
+ }
+ }
+
+ private static IList EnumerateOrdinaryFiles(string root)
+ {
+ var files = new List();
+ var pending = new Stack();
+ pending.Push(new DirectoryInfo(root));
+ while (pending.Count > 0)
+ {
+ var directory = pending.Pop();
+ if ((directory.Attributes &
+ FileAttributes.ReparsePoint) != 0)
+ {
+ throw new InvalidDataException(
+ "The native package contains a reparse-point " +
+ "directory: " + directory.FullName);
+ }
+ RequireProtectedDirectoryAcl(directory.FullName);
+ foreach (var entry in directory.GetFileSystemInfos())
+ {
+ if ((entry.Attributes &
+ FileAttributes.ReparsePoint) != 0)
+ {
+ throw new InvalidDataException(
+ "The native package contains a reparse point: " +
+ entry.FullName);
+ }
+ var childDirectory = entry as DirectoryInfo;
+ if (childDirectory != null)
+ {
+ pending.Push(childDirectory);
+ }
+ else
+ {
+ RequireOrdinaryFile(entry.FullName);
+ files.Add(entry.FullName);
+ }
+ }
+ }
+ return files;
+ }
+
+ private static string RelativePath(string root, string path)
+ {
+ var prefix = Path.GetFullPath(root)
+ .TrimEnd(Path.DirectorySeparatorChar) +
+ Path.DirectorySeparatorChar;
+ var fullPath = Path.GetFullPath(path);
+ if (!fullPath.StartsWith(prefix,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidDataException(
+ "An installed file escapes the managed root.");
+ }
+ return fullPath.Substring(prefix.Length);
+ }
+
+ private static ChildResult InvokeNativeManager(string managerPath,
+ string operation, string targetSid)
+ {
+ var powerShell = Path.Combine(Environment.SystemDirectory,
+ @"WindowsPowerShell\v1.0\powershell.exe");
+ RequireOrdinaryFile(powerShell);
+
+ var arguments =
+ "-NoLogo -NoProfile -NonInteractive " +
+ "-ExecutionPolicy Bypass -File " + Quote(managerPath) +
+ " -Operation " + operation +
+ " -TargetUserSID " + Quote(targetSid);
+ var lines = new List();
+ var outputSync = new object();
+
+ using (var process = new Process())
+ {
+ process.StartInfo = new ProcessStartInfo(powerShell,
+ arguments)
+ {
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ WorkingDirectory = Path.GetDirectoryName(managerPath),
+ WindowStyle = ProcessWindowStyle.Hidden,
+ };
+ process.OutputDataReceived += (_, e) =>
+ {
+ if (e.Data == null) return;
+ lock (outputSync) lines.Add(e.Data);
+ WriteLog("[manager:stdout] " + e.Data);
+ };
+ process.ErrorDataReceived += (_, e) =>
+ {
+ if (e.Data == null) return;
+ lock (outputSync) lines.Add(e.Data);
+ WriteLog("[manager:stderr] " + e.Data);
+ };
+
+ if (!process.Start())
+ {
+ throw new InvalidOperationException(
+ "PowerShell could not start the native manager.");
+ }
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+ process.WaitForExit();
+ process.WaitForExit();
+ lock (outputSync)
+ {
+ return new ChildResult(process.ExitCode,
+ new List(lines));
+ }
+ }
+ }
+
+ private static NativeReceipt ParseAndValidateReceipt(
+ IEnumerable lines, string expectedOperation,
+ int actualExitCode)
+ {
+ var records = lines
+ .Where(line => line != null &&
+ line.StartsWith(ResultPrefix,
+ StringComparison.Ordinal))
+ .Select(line => line.Substring(ResultPrefix.Length))
+ .ToList();
+ if (records.Count != 1)
+ {
+ throw new InvalidDataException(
+ "The native manager emitted " + records.Count +
+ " structured result records; exactly one is required.");
+ }
+
+ var match = Regex.Match(records[0],
+ "^\\{\"schemaVersion\":1,\"operation\":\"" +
+ "(install|uninstall)\",\"exitCode\":([0-9]+)," +
+ "\"succeeded\":(true|false)," +
+ "\"rebootRequired\":(true|false)," +
+ "\"rollbackStatus\":\"([a-z-]+)\"," +
+ "\"manualRecoveryRequired\":(true|false)\\}$",
+ RegexOptions.CultureInvariant);
+ if (!match.Success)
+ {
+ throw new InvalidDataException(
+ "The native manager result record is malformed or " +
+ "contains an unexpected schema.");
+ }
+
+ var receipt = new NativeReceipt
+ {
+ Operation = match.Groups[1].Value,
+ ExitCode = int.Parse(match.Groups[2].Value,
+ CultureInfo.InvariantCulture),
+ Succeeded = bool.Parse(match.Groups[3].Value),
+ RebootRequired = bool.Parse(match.Groups[4].Value),
+ RollbackStatus = match.Groups[5].Value,
+ ManualRecoveryRequired =
+ bool.Parse(match.Groups[6].Value),
+ };
+ if (!string.Equals(receipt.Operation, expectedOperation,
+ StringComparison.Ordinal) ||
+ receipt.ExitCode != actualExitCode)
+ {
+ throw new InvalidDataException(
+ "The native manager result does not match the " +
+ "requested operation or process exit code.");
+ }
+
+ if (actualExitCode == 0)
+ {
+ if (!receipt.Succeeded || receipt.RebootRequired ||
+ receipt.ManualRecoveryRequired ||
+ receipt.RollbackStatus != "not-required")
+ {
+ throw new InvalidDataException(
+ "The success receipt is internally inconsistent.");
+ }
+ }
+ else if (actualExitCode == 3010)
+ {
+ if (receipt.Succeeded || !receipt.RebootRequired ||
+ receipt.ManualRecoveryRequired ||
+ receipt.RollbackStatus != "safely-settled")
+ {
+ throw new InvalidDataException(
+ "The reboot receipt is internally inconsistent.");
+ }
+ }
+ else if (receipt.Succeeded || receipt.RebootRequired ||
+ (receipt.RollbackStatus == "not-started" &&
+ receipt.ManualRecoveryRequired) ||
+ (receipt.RollbackStatus ==
+ "unverified-see-transaction-log" &&
+ !receipt.ManualRecoveryRequired) ||
+ (receipt.RollbackStatus != "not-started" &&
+ receipt.RollbackStatus !=
+ "unverified-see-transaction-log"))
+ {
+ throw new InvalidDataException(
+ "The failure receipt is internally inconsistent.");
+ }
+ return receipt;
+ }
+
+ private static void RecordInstalledNativePackage(string targetSid,
+ string metadataHash)
+ {
+ using (var machine = RegistryKey.OpenBaseKey(
+ RegistryHive.LocalMachine,
+ RegistryView.Registry64))
+ using (var key = machine.CreateSubKey(RegistryKeyPath,
+ writable: true))
+ {
+ if (key == null)
+ {
+ throw new InvalidOperationException(
+ "The installer coordination registry key is " +
+ "unavailable.");
+ }
+ key.SetValue(NativeReceiptValue, "Installed",
+ RegistryValueKind.String);
+ key.SetValue(NativeSidValue, targetSid,
+ RegistryValueKind.String);
+ key.SetValue(NativeMetadataHashValue, metadataHash,
+ RegistryValueKind.String);
+ key.SetValue(NativeUpdatedValue,
+ DateTime.UtcNow.ToString("O",
+ CultureInfo.InvariantCulture),
+ RegistryValueKind.String);
+ }
+ }
+
+ private static void ClearInstalledNativePackage()
+ {
+ using (var machine = RegistryKey.OpenBaseKey(
+ RegistryHive.LocalMachine,
+ RegistryView.Registry64))
+ using (var key = machine.OpenSubKey(RegistryKeyPath,
+ writable: true))
+ {
+ key?.DeleteValue(NativeReceiptValue, false);
+ key?.DeleteValue(NativeSidValue, false);
+ key?.DeleteValue(NativeMetadataHashValue, false);
+ key?.DeleteValue(NativeUpdatedValue, false);
+ }
+ }
+
+ private static int RunWithSetupMutex(Func action)
+ {
+ using (var setupMutex = new Mutex(false,
+ @"Global\DS4Windows-VIIPER-Native-Setup"))
+ {
+ var owned = false;
+ try
+ {
+ try
+ {
+ owned = setupMutex.WaitOne(0);
+ }
+ catch (AbandonedMutexException)
+ {
+ owned = true;
+ }
+ if (!owned)
+ {
+ WriteLog("Another native setup transaction owns " +
+ "the global mutex.");
+ return 1618;
+ }
+ return action();
+ }
+ finally
+ {
+ if (owned)
+ {
+ try { setupMutex.ReleaseMutex(); } catch { }
+ }
+ }
+ }
+ }
+
+ private static void QuiesceDs4Windows()
+ {
+ var expectedPath = Path.GetFullPath(Path.Combine(
+ Environment.GetFolderPath(
+ Environment.SpecialFolder.ProgramFiles),
+ "DS4Windows", "DS4Windows.exe"));
+ var blocked = new List();
+ foreach (var process in Process.GetProcessesByName(
+ "DS4Windows"))
+ {
+ try
+ {
+ string path;
+ try
+ {
+ path = Path.GetFullPath(
+ process.MainModule?.FileName ??
+ string.Empty);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException(
+ "Could not authenticate a running " +
+ "DS4Windows process before setup.", ex);
+ }
+
+ if (process.CloseMainWindow() &&
+ process.WaitForExit(5000))
+ {
+ continue;
+ }
+ if (string.Equals(path, expectedPath,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ process.Kill();
+ if (!process.WaitForExit(5000))
+ {
+ blocked.Add(path);
+ }
+ }
+ else
+ {
+ blocked.Add(path);
+ }
+ }
+ finally
+ {
+ process.Dispose();
+ }
+ }
+ if (blocked.Count != 0)
+ {
+ throw new InvalidOperationException(
+ "Close every portable or managed DS4Windows process " +
+ "before continuing. Still running: " +
+ string.Join(", ", blocked.Distinct(
+ StringComparer.OrdinalIgnoreCase)));
+ }
+ }
+
+ private static void EnsureDirectoryPathHasNoReparsePoints(
+ string path)
+ {
+ var resolved = Path.GetFullPath(path)
+ .TrimEnd(Path.DirectorySeparatorChar);
+ var root = Path.GetPathRoot(resolved);
+ if (string.IsNullOrWhiteSpace(root))
+ {
+ throw new InvalidOperationException(
+ "A rooted directory path is required.");
+ }
+ var cursor = root;
+ var relative = resolved.Substring(root.Length);
+ foreach (var component in relative.Split(
+ new[]
+ {
+ Path.DirectorySeparatorChar,
+ Path.AltDirectorySeparatorChar,
+ },
+ StringSplitOptions.RemoveEmptyEntries))
+ {
+ cursor = Path.Combine(cursor, component);
+ if (!Directory.Exists(cursor) &&
+ !File.Exists(cursor))
+ {
+ continue;
+ }
+ var attributes = File.GetAttributes(cursor);
+ if ((attributes & FileAttributes.ReparsePoint) != 0 ||
+ (attributes & FileAttributes.Directory) == 0)
+ {
+ throw new InvalidOperationException(
+ "A protected path traverses a reparse point or " +
+ "ordinary file: " + cursor);
+ }
+ }
+ }
+
+ private static void RequireOrdinaryFile(string path)
+ {
+ var fullPath = Path.GetFullPath(path);
+ var directory = Path.GetDirectoryName(fullPath);
+ if (string.IsNullOrWhiteSpace(directory))
+ {
+ throw new InvalidOperationException(
+ "A rooted file path is required.");
+ }
+ EnsureDirectoryPathHasNoReparsePoints(directory);
+ if (!File.Exists(fullPath) ||
+ (File.GetAttributes(fullPath) &
+ FileAttributes.ReparsePoint) != 0)
+ {
+ throw new FileNotFoundException(
+ "A required ordinary file is unavailable.", fullPath);
+ }
+ RequireProtectedFileAcl(fullPath);
+ }
+
+ private static string ComputeSha256(string path)
+ {
+ using (var algorithm = SHA256.Create())
+ using (var stream = new FileStream(path, FileMode.Open,
+ FileAccess.Read, FileShare.Read))
+ {
+ return BitConverter.ToString(
+ algorithm.ComputeHash(stream))
+ .Replace("-", string.Empty);
+ }
+ }
+
+ private static string Quote(string value)
+ {
+ if (value == null || value.IndexOf('\0') >= 0)
+ {
+ throw new InvalidOperationException(
+ "A process argument is invalid.");
+ }
+ if (value.IndexOf('"') >= 0)
+ {
+ throw new InvalidOperationException(
+ "A process argument contains a quote.");
+ }
+ return "\"" + value + "\"";
+ }
+
+ private static void InitializeProtectedLog()
+ {
+ var programData = Path.GetFullPath(
+ Environment.GetFolderPath(
+ Environment.SpecialFolder.CommonApplicationData))
+ .TrimEnd(Path.DirectorySeparatorChar);
+ EnsureDirectoryPathHasNoReparsePoints(programData);
+
+ var directorySecurity = CreateProtectedDirectorySecurity();
+ var fileSecurity = CreateProtectedFileSecurity();
+ var acquiredDirectoryLocks = new List();
+ FileStream acquiredLogStream = null;
+ try
+ {
+ acquiredDirectoryLocks.Add(
+ OpenAndValidateOrdinaryDirectory(programData));
+ var productDirectory = Path.Combine(programData,
+ "DS4Windows");
+ acquiredDirectoryLocks.Add(
+ CreateOrLockProtectedDirectory(productDirectory,
+ directorySecurity));
+ var installerDirectory = Path.Combine(productDirectory,
+ "Installer");
+ acquiredDirectoryLocks.Add(
+ CreateOrLockProtectedDirectory(installerDirectory,
+ directorySecurity));
+
+ acquiredLogStream = OpenOrCreateProtectedLogFile(
+ Path.Combine(installerDirectory,
+ "setup-actions.log"), fileSecurity);
+ logDirectoryLocks = acquiredDirectoryLocks;
+ logStream = acquiredLogStream;
+ acquiredLogStream = null;
+ acquiredDirectoryLocks = null;
+ }
+ finally
+ {
+ acquiredLogStream?.Dispose();
+ if (acquiredDirectoryLocks != null)
+ {
+ for (var index = acquiredDirectoryLocks.Count - 1;
+ index >= 0; index--)
+ {
+ acquiredDirectoryLocks[index].Dispose();
+ }
+ }
+ }
+ }
+
+ private static DirectorySecurity CreateProtectedDirectorySecurity()
+ {
+ var security = new DirectorySecurity();
+ ConfigureProtectedSecurity(security,
+ InheritanceFlags.ContainerInherit |
+ InheritanceFlags.ObjectInherit);
+ return security;
+ }
+
+ private static FileSecurity CreateProtectedFileSecurity()
+ {
+ var security = new FileSecurity();
+ ConfigureProtectedSecurity(security, InheritanceFlags.None);
+ return security;
+ }
+
+ private static void ConfigureProtectedSecurity(
+ FileSystemSecurity security,
+ InheritanceFlags inheritance)
+ {
+ var administrators = new SecurityIdentifier(
+ WellKnownSidType.BuiltinAdministratorsSid, null);
+ var system = new SecurityIdentifier(
+ WellKnownSidType.LocalSystemSid, null);
+ security.SetOwner(administrators);
+ security.SetGroup(administrators);
+ security.SetAccessRuleProtection(true, false);
+ foreach (var sid in new[] { administrators, system })
+ {
+ security.AddAccessRule(new FileSystemAccessRule(
+ sid, FileSystemRights.FullControl,
+ inheritance, PropagationFlags.None,
+ AccessControlType.Allow));
+ }
+ }
+
+ private static SafeFileHandle OpenAndValidateOrdinaryDirectory(
+ string path)
+ {
+ var handle = OpenDirectoryWithoutDeleteSharing(path);
+ try
+ {
+ RequireOrdinaryDirectory(path);
+ return handle;
+ }
+ catch
+ {
+ handle.Dispose();
+ throw;
+ }
+ }
+
+ private static SafeFileHandle CreateOrLockProtectedDirectory(
+ string path, DirectorySecurity expectedSecurity)
+ {
+ if (!PathEntryExists(path))
+ {
+ // The ACL is supplied to CreateDirectory itself. Never create
+ // an inherited directory and tighten it afterward.
+ Directory.CreateDirectory(path, expectedSecurity);
+ }
+
+ var handle = OpenDirectoryWithoutDeleteSharing(path);
+ try
+ {
+ RequireOrdinaryDirectory(path);
+ var actualSecurity = Directory.GetAccessControl(path,
+ AccessControlSections.Owner |
+ AccessControlSections.Group |
+ AccessControlSections.Access);
+ RequireExactLogSecurity(actualSecurity, path,
+ InheritanceFlags.ContainerInherit |
+ InheritanceFlags.ObjectInherit);
+ return handle;
+ }
+ catch
+ {
+ handle.Dispose();
+ throw;
+ }
+ }
+
+ private static SafeFileHandle OpenDirectoryWithoutDeleteSharing(
+ string path)
+ {
+ var handle = CreateFileW(path, FileListDirectory,
+ FileShare.Read | FileShare.Write, IntPtr.Zero,
+ OpenExisting,
+ FileFlagBackupSemantics | FileFlagOpenReparsePoint,
+ IntPtr.Zero);
+ if (handle.IsInvalid)
+ {
+ var error = Marshal.GetLastWin32Error();
+ handle.Dispose();
+ throw new Win32Exception(error,
+ "Could not lock the protected setup-log directory: " +
+ path);
+ }
+ return handle;
+ }
+
+ private static FileStream OpenOrCreateProtectedLogFile(
+ string path, FileSecurity expectedSecurity)
+ {
+ FileStream stream = null;
+ SafeFileHandle existingHandle = null;
+ try
+ {
+ if (PathEntryExists(path))
+ {
+ existingHandle = CreateFileW(path,
+ (uint)(FileSystemRights.Read |
+ FileSystemRights.Write),
+ FileShare.Read, IntPtr.Zero, OpenExisting,
+ FileFlagOpenReparsePoint, IntPtr.Zero);
+ if (existingHandle.IsInvalid)
+ {
+ var error = Marshal.GetLastWin32Error();
+ throw new Win32Exception(error,
+ "Could not exclusively lock the existing " +
+ "setup log.");
+ }
+ stream = new FileStream(existingHandle,
+ FileAccess.ReadWrite, 4096, false);
+ existingHandle = null;
+ }
+ else
+ {
+ stream = new FileStream(path, FileMode.CreateNew,
+ FileSystemRights.Read | FileSystemRights.Write,
+ FileShare.Read, 4096, FileOptions.WriteThrough,
+ expectedSecurity);
+ }
+
+ var attributes = File.GetAttributes(path);
+ if ((attributes & FileAttributes.Directory) != 0 ||
+ (attributes & FileAttributes.ReparsePoint) != 0)
+ {
+ throw new InvalidOperationException(
+ "The setup log is not an ordinary file.");
+ }
+ var actualSecurity = stream.GetAccessControl();
+ RequireExactLogSecurity(actualSecurity, path,
+ InheritanceFlags.None);
+
+ // Truncate only through the verified, no-share-delete handle;
+ // never delete and recreate a path that can be swapped.
+ stream.SetLength(0);
+ stream.Position = 0;
+ stream.Flush(true);
+ var result = stream;
+ stream = null;
+ return result;
+ }
+ finally
+ {
+ stream?.Dispose();
+ existingHandle?.Dispose();
+ }
+ }
+
+ private static void RequireExactLogSecurity(
+ FileSystemSecurity security, string path,
+ InheritanceFlags expectedInheritance)
+ {
+ var administrators = new SecurityIdentifier(
+ WellKnownSidType.BuiltinAdministratorsSid, null);
+ var system = new SecurityIdentifier(
+ WellKnownSidType.LocalSystemSid, null);
+ var expectedSids = new HashSet(
+ StringComparer.Ordinal)
+ {
+ administrators.Value,
+ system.Value,
+ };
+ var owner = ((SecurityIdentifier)security.GetOwner(
+ typeof(SecurityIdentifier))).Value;
+ var group = ((SecurityIdentifier)security.GetGroup(
+ typeof(SecurityIdentifier))).Value;
+ if (!string.Equals(owner, administrators.Value,
+ StringComparison.Ordinal) ||
+ !string.Equals(group, administrators.Value,
+ StringComparison.Ordinal) ||
+ !security.AreAccessRulesProtected)
+ {
+ throw new UnauthorizedAccessException(
+ "The setup-log path has an unexpected owner, group, " +
+ "or inherited DACL: " + path);
+ }
+
+ var rules = security.GetAccessRules(true, true,
+ typeof(SecurityIdentifier))
+ .Cast()
+ .ToList();
+ if (rules.Count != 2)
+ {
+ throw new UnauthorizedAccessException(
+ "The setup-log path has an unexpected access-rule " +
+ "count: " + path);
+ }
+ foreach (var rule in rules)
+ {
+ var sid = ((SecurityIdentifier)
+ rule.IdentityReference).Value;
+ if (!expectedSids.Remove(sid) || rule.IsInherited ||
+ rule.AccessControlType != AccessControlType.Allow ||
+ rule.FileSystemRights != FileSystemRights.FullControl ||
+ rule.InheritanceFlags != expectedInheritance ||
+ rule.PropagationFlags != PropagationFlags.None)
+ {
+ throw new UnauthorizedAccessException(
+ "The setup-log path has an unexpected access " +
+ "rule: " + path);
+ }
+ }
+ if (expectedSids.Count != 0)
+ {
+ throw new UnauthorizedAccessException(
+ "The setup-log path is missing a trusted principal: " +
+ path);
+ }
+ }
+
+ private static void RequireOrdinaryDirectory(string path)
+ {
+ var attributes = File.GetAttributes(path);
+ if ((attributes & FileAttributes.Directory) == 0 ||
+ (attributes & FileAttributes.ReparsePoint) != 0)
+ {
+ throw new InvalidOperationException(
+ "The setup-log path is not an ordinary directory: " +
+ path);
+ }
+ }
+
+ private static bool PathEntryExists(string path)
+ {
+ try
+ {
+ File.GetAttributes(path);
+ return true;
+ }
+ catch (FileNotFoundException)
+ {
+ return false;
+ }
+ catch (DirectoryNotFoundException)
+ {
+ return false;
+ }
+ }
+
+ private static void WriteLog(string message)
+ {
+ try
+ {
+ lock (LogSync)
+ {
+ if (logStream == null) return;
+ var bytes = new UTF8Encoding(false).GetBytes(
+ DateTime.UtcNow.ToString("O",
+ CultureInfo.InvariantCulture) + " " +
+ message + Environment.NewLine);
+ logStream.Write(bytes, 0, bytes.Length);
+ logStream.Flush(true);
+ }
+ }
+ catch { }
+ }
+
+ private static void DisposeProtectedLog()
+ {
+ lock (LogSync)
+ {
+ try
+ {
+ logStream?.Flush(true);
+ }
+ catch { }
+ try
+ {
+ logStream?.Dispose();
+ }
+ catch { }
+ logStream = null;
+ if (logDirectoryLocks != null)
+ {
+ for (var index = logDirectoryLocks.Count - 1;
+ index >= 0; index--)
+ {
+ try { logDirectoryLocks[index].Dispose(); }
+ catch { }
+ }
+ logDirectoryLocks = null;
+ }
+ }
+ }
+
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode,
+ SetLastError = true, ExactSpelling = true)]
+ private static extern SafeFileHandle CreateFileW(
+ string fileName, uint desiredAccess, FileShare shareMode,
+ IntPtr securityAttributes, uint creationDisposition,
+ uint flagsAndAttributes, IntPtr templateFile);
+
+ private sealed class PackageManifest
+ {
+ public int schema { get; set; }
+ public string product { get; set; }
+ public string version { get; set; }
+ public string architecture { get; set; }
+ public List files { get; set; }
+ }
+
+ private sealed class ManifestFile
+ {
+ public string path { get; set; }
+ public long size { get; set; }
+ public string sha256 { get; set; }
+ }
+
+ private sealed class VerifiedMedia
+ {
+ internal VerifiedMedia(string managerPath,
+ string metadataSha256)
+ {
+ ManagerPath = managerPath;
+ MetadataSha256 = metadataSha256;
+ }
+
+ internal string ManagerPath { get; }
+ internal string MetadataSha256 { get; }
+ }
+
+ private sealed class ChildResult
+ {
+ internal ChildResult(int exitCode,
+ IList outputLines)
+ {
+ ExitCode = exitCode;
+ OutputLines = outputLines;
+ }
+
+ internal int ExitCode { get; }
+ internal IList OutputLines { get; }
+ }
+
+ private sealed class NativeReceipt
+ {
+ internal string Operation { get; set; }
+ internal int ExitCode { get; set; }
+ internal bool Succeeded { get; set; }
+ internal bool RebootRequired { get; set; }
+ internal string RollbackStatus { get; set; }
+ internal bool ManualRecoveryRequired { get; set; }
+ }
+
+ private sealed class NativeManagerException : Exception
+ {
+ internal NativeManagerException(int exitCode,
+ string message) : base(message)
+ {
+ ExitCode = exitCode;
+ }
+
+ internal int ExitCode { get; }
+ }
+ }
+}
diff --git a/installer/DS4Windows.SetupActions/app.manifest b/installer/DS4Windows.SetupActions/app.manifest
new file mode 100644
index 0000000..2e63482
--- /dev/null
+++ b/installer/DS4Windows.SetupActions/app.manifest
@@ -0,0 +1,11 @@
+
+
+