diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 77a3bc3c0e..318e7d3e32 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -99,8 +99,17 @@ hlslkit-generate-defines --log CommunityShaders.log # Scan for buffer conflicts across features hlslkit-buffer-scan --features-dir features/ + +# Prove a shader refactor changed no behavior (compiles base ref vs working tree, +# compares DXBC across VR x HDR_OUTPUT permutations; exit 0 identical / 2 differs) +pwsh tools/verify-shader-refactor.ps1 package/Shaders/Foo.hlsl # bash: tools/verify-shader-refactor.sh ``` +When refactoring an existing shader (especially the decompile-transcription shaders like +`ISTemporalAA.hlsl`), use `tools/verify-shader-refactor.ps1` to prove the change is +behavior-preserving: identical compiled bytecode means a provable no-op. See +`docs/development/shader-workflow.md` for details. + ### Custom CMake Targets **Package and Deployment Targets**: diff --git a/docs/development/shader-workflow.md b/docs/development/shader-workflow.md index f32233a73e..bf58da9431 100644 --- a/docs/development/shader-workflow.md +++ b/docs/development/shader-workflow.md @@ -8,8 +8,30 @@ cmake --build build/ALL --target COPY_SHADERS # Full deployment (DLL + tests + shaders) cmake --build build/ALL --target DEPLOY_ALL + +# Prove an HLSL refactor changed no behavior (compares compiled DXBC vs a git ref) +pwsh tools/verify-shader-refactor.ps1 package/Shaders/Foo.hlsl # or tools/verify-shader-refactor.sh ``` +## Verifying refactors + +`tools/verify-shader-refactor.ps1` (bash wrapper: `tools/verify-shader-refactor.sh`) +compiles a shader from a base git ref and from the working tree across the +`VR` × `HDR_OUTPUT` permutations, then compares the compiled bytecode. The base +ref's whole include tree is materialized (via `git archive`), so the base compiles +against base-ref `.hlsli` headers and the working tree against working headers — a +refactor that also edits a shared header is compared correctly, not masked: + +- **IDENTICAL** SHA-256 of the `.cso` ⇒ the refactor is a provable no-op (fxc emits + no timestamps without `/Zi`, so identical source ⇒ identical bytes). +- **DIFFERS** ⇒ it dumps and diffs the `/Fc` assembly so a legitimate-but-non-identical + change can be reviewed. + +Exit codes: `0` all identical, `2` some differ, `1` compile error. Defaults to comparing +the working tree against `merge-base(HEAD, origin/dev)`; pass `-BaseRef ` to override. +Requires `fxc.exe` from the Windows SDK. The permutation sweep is strong evidence, not the +full `shader-validation.yaml` matrix — pass `-Permutations` for exotic define combos. + ## Overview Two deployment targets for different workflows: diff --git a/tools/verify-shader-refactor.ps1 b/tools/verify-shader-refactor.ps1 new file mode 100644 index 0000000000..c18954f977 --- /dev/null +++ b/tools/verify-shader-refactor.ps1 @@ -0,0 +1,208 @@ +<# +.SYNOPSIS + Prove an HLSL refactor is behavior-preserving by comparing compiled bytecode. + +.DESCRIPTION + Compiles a shader from a base git revision and from the current working tree + across a set of preprocessor permutations, then compares the resulting DXBC. + The base ref's entire include tree (-IncludeDir) is materialized via git archive, + so the base compiles against base-ref headers and the working tree against working + headers -- a refactor that also edits a shared .hlsli is therefore compared correctly. + + Tier 1 (this script): identical SHA-256 of the compiled .cso == provably identical + GPU program. fxc emits no timestamps without /Zi, so same source -> same bytes. + + Tier 2 (this script, on mismatch): dumps /Fc assembly for both revisions and lists + the differing lines (base/work markers), so a legitimate-but-non-identical refactor + (e.g. register reorder) can be eyeballed. + + A refactor that is Tier-1 IDENTICAL on the swept permutations needs no further proof. + Note: the default sweep (VR x HDR_OUTPUT) is strong evidence, not the full build + matrix from shader-validation.yaml. Pass -Permutations for exotic define combos. + +.PARAMETER Shader + Path to the .hlsl file (repo-relative or absolute). + +.PARAMETER BaseRef + Git ref to treat as "before". Default: merge-base of HEAD and origin/dev. + +.PARAMETER IncludeDir + Shader include root passed to fxc /I. Default: package/Shaders. + +.PARAMETER Permutations + Optional explicit permutation list; each entry is a space-separated define set, + e.g. -Permutations "PSHADER","PSHADER VR". Overrides the auto sweep. + +.PARAMETER Entry + Shader entry point. Default: main. + +.PARAMETER Profile + fxc target profile. Default: auto (cs_5_0 for *CS.hlsl, else ps_5_0). + +.EXAMPLE + pwsh tools/verify-shader-refactor.ps1 package/Shaders/ISTemporalAA.hlsl + +.EXAMPLE + pwsh tools/verify-shader-refactor.ps1 package/Shaders/Foo.hlsl -BaseRef HEAD~1 +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true, Position = 0)] + [string]$Shader, + [string]$BaseRef, + [string]$IncludeDir = "package/Shaders", + [string[]]$Permutations, + [string]$Entry = "main", + [string]$Profile, + [string]$Fxc +) + +# Continue (not Stop): native git calls write warnings to stderr that would otherwise +# abort the run; control flow keys off explicit $LASTEXITCODE checks and `throw`. +$ErrorActionPreference = "Continue" + +function Resolve-Fxc { + if ($Fxc -and (Test-Path $Fxc)) { return $Fxc } + $cmd = Get-Command fxc.exe -ErrorAction SilentlyContinue + if ($cmd) { return $cmd.Source } + $roots = @("${env:ProgramFiles(x86)}\Windows Kits\10\bin", "${env:ProgramFiles}\Windows Kits\10\bin") + $found = foreach ($r in $roots) { + if (Test-Path $r) { + Get-ChildItem -Path $r -Recurse -Filter fxc.exe -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match "x64" } + } + } + $pick = $found | Sort-Object FullName -Descending | Select-Object -First 1 + if (-not $pick) { throw "fxc.exe not found. Install the Windows 10/11 SDK or pass -Fxc." } + return $pick.FullName +} + +# Resolve repo root so the script works from any cwd. +$repoRoot = (git rev-parse --show-toplevel 2>$null) +if (-not $repoRoot) { throw "Not inside a git repository." } +Push-Location $repoRoot +$work = $null +try { + $fxcPath = Resolve-Fxc + + # Normalize the shader path to repo-relative (forward slashes) for git. + # (Path.GetRelativePath is unavailable in Windows PowerShell 5.1 / .NET Framework.) + $shaderFull = (Resolve-Path $Shader).Path + $rootFull = (Resolve-Path $repoRoot).Path + if (-not $shaderFull.StartsWith($rootFull, [StringComparison]::OrdinalIgnoreCase)) { + throw "Shader '$Shader' resolves outside the repo root '$rootFull'." + } + $relPath = $shaderFull.Substring($rootFull.Length).TrimStart('\', '/').Replace('\', '/') + + if (-not $BaseRef) { + $BaseRef = (git merge-base HEAD origin/dev 2>$null) + if (-not $BaseRef) { $BaseRef = "HEAD" } + } + + if (-not $Profile) { + $Profile = if ($relPath -match 'CS\.hlsl$') { "cs_5_0" } else { "ps_5_0" } + } + $stageDefine = switch -Wildcard ($Profile) { + "cs_*" { "CSHADER" } + "vs_*" { "VSHADER" } + default { "PSHADER" } + } + + if (-not $Permutations -or $Permutations.Count -eq 0) { + $Permutations = @( + "$stageDefine", + "$stageDefine VR", + "$stageDefine HDR_OUTPUT", + "$stageDefine VR HDR_OUTPUT" + ) + } + + # Materialize the base revision's FULL include tree (not just the target shader) so a + # refactor that also touches a shared .hlsli is compared correctly: base compiles against + # base-ref headers, work compiles against working-tree headers. git archive -> tar (via a + # file, never a PS pipeline, which would corrupt the binary tar). + $work = Join-Path ([IO.Path]::GetTempPath()) ("shaderverify_" + [Guid]::NewGuid().ToString("N")) + $baseRoot = Join-Path $work "base" + New-Item -ItemType Directory -Force $baseRoot | Out-Null + $tar = Join-Path $work "base.tar" + git archive --format=tar -o $tar $BaseRef -- $IncludeDir $relPath 2>$null + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $tar)) { + throw "git archive failed for '$BaseRef' (paths: $IncludeDir, $relPath)." + } + tar -xf $tar -C $baseRoot + if ($LASTEXITCODE -ne 0) { throw "Failed to extract base archive." } + $baseFile = Join-Path $baseRoot $relPath + $baseInclude = Join-Path $baseRoot $IncludeDir + if (-not (Test-Path $baseFile)) { throw "'$relPath' not found at '$BaseRef'." } + + function Compile([string]$src, [string]$incDir, [string]$defs, [string]$outFile, [switch]$Asm) { + # Preserve explicit-valued defines (e.g. SHADOWFILTER=0); only bare names get =1. + $defArgs = @() + foreach ($d in ($defs -split '\s+' | Where-Object { $_ })) { + $defArgs += "/D" + $defArgs += $(if ($d -like '*=*') { $d } else { "$d=1" }) + } + $fmt = if ($Asm) { "/Fc" } else { "/Fo" } + $out = & $fxcPath /nologo /T $Profile /E $Entry @defArgs /I $incDir $src $fmt $outFile 2>&1 + return @{ Code = $LASTEXITCODE; Out = $out } + } + + Write-Host "Shader : $relPath" + Write-Host "Base ref : $BaseRef (full include tree materialized)" + Write-Host "Profile : $Profile (entry $Entry)" + Write-Host "Include : $IncludeDir" + Write-Host ("-" * 60) + + $allIdentical = $true + $anyError = $false + + foreach ($perm in $Permutations) { + $tag = $perm + $baseCso = Join-Path $work "base.cso" + $workCso = Join-Path $work "work.cso" + $rb = Compile $baseFile $baseInclude $perm $baseCso + $rw = Compile $shaderFull $IncludeDir $perm $workCso + + if ($rb.Code -ne 0 -or $rw.Code -ne 0) { + $anyError = $true + $which = if ($rb.Code -ne 0) { "BASE" } else { "WORK" } + Write-Host "[$tag] COMPILE-ERROR ($which)" -ForegroundColor Red + ($(if ($rb.Code -ne 0) { $rb.Out } else { $rw.Out }) | Where-Object { $_ -match 'error|warning' } | Select-Object -First 6) | + ForEach-Object { Write-Host " $_" } + continue + } + + $hb = (Get-FileHash $baseCso -Algorithm SHA256).Hash + $hw = (Get-FileHash $workCso -Algorithm SHA256).Hash + if ($hb -eq $hw) { + Write-Host "[$tag] IDENTICAL" -ForegroundColor Green + } else { + $allIdentical = $false + Write-Host "[$tag] DIFFERS base=$($hb.Substring(0,12)) work=$($hw.Substring(0,12))" -ForegroundColor Yellow + # Tier 2: assembly diff for inspection (Compare-Object avoids git's CRLF/exit noise). + $baseAsm = Join-Path $work "base.asm"; $workAsm = Join-Path $work "work.asm" + Compile $baseFile $baseInclude $perm $baseAsm -Asm | Out-Null + Compile $shaderFull $IncludeDir $perm $workAsm -Asm | Out-Null + $d = Compare-Object (Get-Content $baseAsm) (Get-Content $workAsm) + if ($d) { + $d | Select-Object -First 40 | ForEach-Object { + $mark = if ($_.SideIndicator -eq '=>') { 'work' } else { 'base' } + Write-Host (" [{0}] {1}" -f $mark, $_.InputObject) + } + if (@($d).Count -gt 40) { Write-Host (" ... (+{0} more asm lines)" -f (@($d).Count - 40)) } + } + } + } + + Write-Host ("-" * 60) + if ($anyError) { Write-Host "RESULT: compile error" -ForegroundColor Red; $exit = 1 } + elseif ($allIdentical) { Write-Host "RESULT: behavior-preserving (all permutations identical)" -ForegroundColor Green; $exit = 0 } + else { Write-Host "RESULT: bytecode differs - inspect asm diff above" -ForegroundColor Yellow; $exit = 2 } + + exit $exit +} +finally { + # Runs on normal exit and on throw, so the temp dir never leaks. + if ($work -and (Test-Path $work)) { Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue } + Pop-Location +} diff --git a/tools/verify-shader-refactor.sh b/tools/verify-shader-refactor.sh new file mode 100644 index 0000000000..6f87588956 --- /dev/null +++ b/tools/verify-shader-refactor.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Thin wrapper around verify-shader-refactor.ps1 for bash/WSL/git-bash users. +# fxc.exe is Windows-only and MSYS mangles its /switches, so the real work lives +# in PowerShell. This just forwards arguments verbatim. +# +# Usage: tools/verify-shader-refactor.sh package/Shaders/Foo.hlsl [-BaseRef HEAD~1] ... +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ps1="$here/verify-shader-refactor.ps1" + +if command -v pwsh >/dev/null 2>&1; then + exec pwsh -NoProfile -ExecutionPolicy Bypass -File "$ps1" "$@" +elif command -v powershell.exe >/dev/null 2>&1; then + exec powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$ps1" "$@" +else + echo "Need PowerShell (pwsh or powershell.exe) on PATH." >&2 + exit 1 +fi