mirror of
https://github.com/patchzyy/wiicompiled
synced 2026-10-25 15:18:26 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cbb769e831 | |||
| 03575bf00f | |||
| f372bd7086 |
@@ -278,24 +278,6 @@ $packagedRuntimeFingerprint = (Get-Content -LiteralPath (Join-Path $workspace 'D
|
||||
if ($stagedRuntimeFingerprint -ne $packagedRuntimeFingerprint) {
|
||||
throw "The staged runtime ($stagedRuntimeFingerprint) does not match the precompiled runtime archive ($packagedRuntimeFingerprint)."
|
||||
}
|
||||
# Users' builds run LocalBuild.ps1 under Windows PowerShell 5.1, whatever shell built this
|
||||
# installer, so the staged package is accepted (or not) exactly the way theirs will be.
|
||||
$prebuiltGate = Join-Path $workRoot 'native-prebuilt-gate.ps1'
|
||||
@'
|
||||
param([string]$Workspace, [string]$Toolkit)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version 3.0
|
||||
. (Join-Path $Workspace 'NativeBuildFlags.ps1')
|
||||
$package = Resolve-MkwNativePrebuiltDirectory (Join-Path $Workspace 'Dependencies') `
|
||||
(Join-Path $Toolkit 'llvm-mingw\bin') (Join-Path $Workspace 'aurora-main') (Join-Path $Workspace 'runtime\third_party')
|
||||
if (-not $package) { exit 3 }
|
||||
'@ | Set-Content -LiteralPath $prebuiltGate -Encoding UTF8
|
||||
& (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') -NoLogo -NoProfile -NonInteractive `
|
||||
-ExecutionPolicy Bypass -File $prebuiltGate -Workspace $workspace -Toolkit $toolkit
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Windows PowerShell rejects the staged precompiled aurora package (exit $LASTEXITCODE); every installation would build aurora from source."
|
||||
}
|
||||
Remove-Item -LiteralPath $prebuiltGate -Force
|
||||
|
||||
[IO.Directory]::CreateDirectory((Join-Path $payloadRoot 'host')) | Out-Null
|
||||
Copy-Item $setupHost (Join-Path $payloadRoot 'host\WiiCompiled-Setup.exe')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- The one product version. Release tags must match it. -->
|
||||
<Version>0.9.3</Version>
|
||||
<Version>0.9.1</Version>
|
||||
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
+71
-26
@@ -34,6 +34,65 @@ Set-StrictMode -Version 3.0
|
||||
# See NativeBuildFlags.ps1; Build-Installer.ps1 stages it beside this script.
|
||||
. (Join-Path $PSScriptRoot 'NativeBuildFlags.ps1')
|
||||
|
||||
function Resolve-NativePrebuiltDirectory([string]$DependenciesDirectory, [string]$ToolchainBin, [string]$AuroraDirectory, [string]$ThirdPartyDirectory) {
|
||||
<#
|
||||
The shipped precompiled aurora/third-party package, verified against its recorded provenance
|
||||
(exact compiler, exact flags) before use. Anything unexpected falls back to building from source.
|
||||
#>
|
||||
$package = Join-Path $DependenciesDirectory 'native_prebuilt'
|
||||
$provenancePath = Join-Path $package 'provenance.json'
|
||||
if (-not (Test-Path -LiteralPath $provenancePath -PathType Leaf)) {
|
||||
Write-Host 'MKWCBUILD: No precompiled aurora package was shipped; building it from source'
|
||||
return ''
|
||||
}
|
||||
try {
|
||||
$provenance = Get-Content -LiteralPath $provenancePath -Raw | ConvertFrom-Json
|
||||
$compiler = Join-Path $ToolchainBin 'clang-22.exe'
|
||||
$compilerHash = Get-MkwFileSha256 $compiler
|
||||
if ($provenance.CompilerSha256 -ne $compilerHash) {
|
||||
Write-Host 'MKWCBUILD: The precompiled aurora package was built by a different compiler; building it from source'
|
||||
return ''
|
||||
}
|
||||
if ($provenance.FlagFingerprint -ne (Get-MkwNativeFlagFingerprint)) {
|
||||
Write-Host 'MKWCBUILD: The precompiled aurora package was built with different flags; building it from source'
|
||||
return ''
|
||||
}
|
||||
# The archives embed Dawn's headers and link its import library, so a
|
||||
# dawn_prebuilt tree that is not the one they were harvested against is
|
||||
# an ABI mismatch that only shows up as a crash inside the GPU backend.
|
||||
$recordedDawn = $provenance.PSObject.Properties['DawnRuntimeSha256']
|
||||
if ($null -eq $recordedDawn -or
|
||||
$recordedDawn.Value -ne (Get-MkwDawnRuntimeSha256 $DependenciesDirectory)) {
|
||||
Write-Host 'MKWCBUILD: The precompiled aurora package was built against a different Dawn; building it from source'
|
||||
return ''
|
||||
}
|
||||
# The archives are aurora; the runtime compiled here calls its headers.
|
||||
# If this workspace's aurora sources are not the ones that were harvested
|
||||
# (a newer toolkit, or a locally modified tree), linking the package would
|
||||
# mix two versions of aurora, so build it from source instead.
|
||||
$recordedAurora = $provenance.PSObject.Properties['AuroraSourceFingerprint']
|
||||
if ($null -eq $recordedAurora -or
|
||||
$recordedAurora.Value -ne (Get-MkwAuroraSourceFingerprint $AuroraDirectory)) {
|
||||
Write-Host 'MKWCBUILD: The precompiled aurora package was built from different aurora sources; building it from source'
|
||||
return ''
|
||||
}
|
||||
# Same reasoning for the vendored Crypto++ the package also carries:
|
||||
# first-party TUs compile against this workspace's third_party headers,
|
||||
# so a package harvested from an older tree must not be linked here.
|
||||
$recordedThirdParty = $provenance.PSObject.Properties['ThirdPartySourceFingerprint']
|
||||
if ($null -eq $recordedThirdParty -or
|
||||
$recordedThirdParty.Value -ne (Get-MkwThirdPartySourceFingerprint $ThirdPartyDirectory)) {
|
||||
Write-Host 'MKWCBUILD: The precompiled aurora package was built from different vendored third-party sources; building it from source'
|
||||
return ''
|
||||
}
|
||||
} catch {
|
||||
Write-Host "MKWCBUILD: The precompiled aurora package could not be validated ($($_.Exception.Message)); building it from source"
|
||||
return ''
|
||||
}
|
||||
Write-Host 'MKWCBUILD: Using the precompiled aurora and third-party libraries'
|
||||
return $package
|
||||
}
|
||||
|
||||
function Write-MkwBuildStep([string]$StepId, [string]$Message) {
|
||||
<#
|
||||
Announces a build step. The identifier is the contract (BuildStepIds in
|
||||
@@ -101,8 +160,6 @@ if (-not [string]::IsNullOrWhiteSpace($RetroRewindPackageDirectory) -and
|
||||
$RetroRewindPackageDirectory = $Workspace + $RetroRewindPackageDirectory.Substring($realWorkspace.Length)
|
||||
}
|
||||
$translator = Join-Path $Toolkit 'Translator\Translator.Cli.exe'
|
||||
# A translator step takes 3-19 s on 16 threads; past this it has hung, and failing lets the clean retry run.
|
||||
$translatorTimeoutSeconds = 1800
|
||||
$toolchain = Get-MkwBuildSafePath $Toolkit 'toolchain' 'CMake\bin\cmake.exe'
|
||||
$cmake = Join-Path $toolchain 'CMake\bin\cmake.exe'
|
||||
$ninja = Join-Path $toolchain 'Ninja\ninja.exe'
|
||||
@@ -151,9 +208,8 @@ if ($Parallel -gt 0) {
|
||||
$globalJobs = $Parallel
|
||||
} else {
|
||||
$translatorThreads = [math]::Max(1, [math]::Min([Environment]::ProcessorCount, 16))
|
||||
# A translated TU peaks around 0.26 GB in clang; 0.5 GiB each after a 2 GiB reserve.
|
||||
$translatedJobs = [math]::Max(1, [math]::Min([Environment]::ProcessorCount,
|
||||
[math]::Floor(($memoryGiB - 2) / 0.5)))
|
||||
[math]::Floor($memoryGiB / 2)))
|
||||
$globalJobs = [math]::Max($translatedJobs, [Environment]::ProcessorCount)
|
||||
}
|
||||
|
||||
@@ -230,18 +286,11 @@ try {
|
||||
# same translated functions produces the same base tree. Only the translator can
|
||||
# decide that - it takes parsing the Kamek patch set against the recorded translated
|
||||
# function ranges - and it fails closed, so anything but exit 0 retranslates.
|
||||
$modAware = $true
|
||||
try {
|
||||
Invoke-Checked $translator @(
|
||||
'check-base-mod-awareness', '--project', $project, '--profile', 'retro-rewind',
|
||||
'--translation-output-metadata', $baseMetadata, '--code-pul', $retroCodePul
|
||||
) 'Checking whether the base translation covers the new Code.pul' `
|
||||
-TimeoutSeconds $translatorTimeoutSeconds
|
||||
} catch {
|
||||
Write-Host "MKWCBUILD: $($_.Exception.Message)"
|
||||
$modAware = $false
|
||||
}
|
||||
if (-not $modAware) {
|
||||
& $translator @(
|
||||
'check-base-mod-awareness', '--project', $project, '--profile', 'retro-rewind',
|
||||
'--translation-output-metadata', $baseMetadata, '--code-pul', $retroCodePul
|
||||
) | Write-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-MkwBuildStep 'retranslate-base' 'The base translation is stale; retranslating the base game for the new Code.pul'
|
||||
$reuseBase = $false
|
||||
}
|
||||
@@ -264,14 +313,12 @@ try {
|
||||
'--outdir', $functions, '--output-metadata', $baseMetadata,
|
||||
'--production-source-bundle', (Join-Path $generated 'base_translation_sources.bin'),
|
||||
'--no-function-files', '--prune-stale', '--threads', $translatorThreads
|
||||
) 'Translating the user-owned base game' -StepId 'translate-base' `
|
||||
-TimeoutSeconds $translatorTimeoutSeconds
|
||||
) 'Translating the user-owned base game' -StepId 'translate-base'
|
||||
|
||||
Invoke-Checked $translator @(
|
||||
'emit-base-manifest', '--project', $project, '--out', $baseManifestDir,
|
||||
'--functions-dir', $functions, '--translation-output-metadata', $baseMetadata, '--region', $pins.Region
|
||||
) 'Creating the local base translation manifest' -StepId 'emit-base-manifest' `
|
||||
-TimeoutSeconds $translatorTimeoutSeconds
|
||||
) 'Creating the local base translation manifest' -StepId 'emit-base-manifest'
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($TranslationFingerprint)) {
|
||||
[ordered]@{ SchemaVersion = 1; TranslationFingerprint = $TranslationFingerprint; DolSha256 = $pins.DolSha256 } |
|
||||
@@ -301,12 +348,11 @@ try {
|
||||
throw 'Retro Rewind requires either -RetroWfcOfflineDirectory or -SkipRetroWfcPayload.'
|
||||
}
|
||||
Invoke-Checked $translator $translateModArguments 'Translating the selected Retro Rewind Code.pul' `
|
||||
-StepId 'translate-mod' -TimeoutSeconds $translatorTimeoutSeconds
|
||||
-StepId 'translate-mod'
|
||||
}
|
||||
|
||||
Invoke-Checked $translator @('generate-data-init', '--project', $project) `
|
||||
'Generating local game data initialization' -StepId 'generate-data-init' `
|
||||
-TimeoutSeconds $translatorTimeoutSeconds
|
||||
'Generating local game data initialization' -StepId 'generate-data-init'
|
||||
|
||||
$shardArgs = @(
|
||||
'emit-build-shards', '--project', $project, '--base-metadata', $baseMetadata,
|
||||
@@ -318,8 +364,7 @@ try {
|
||||
$shardArgs += @('--resolved-profile', (Join-Path $retroOut 'resolved_dispatch_profile.json'),
|
||||
'--retro-cpp-dir', (Join-Path $retroOut 'cpp'))
|
||||
}
|
||||
Invoke-Checked $translator $shardArgs 'Preparing local native build shards' -StepId 'emit-build-shards' `
|
||||
-TimeoutSeconds $translatorTimeoutSeconds
|
||||
Invoke-Checked $translator $shardArgs 'Preparing local native build shards' -StepId 'emit-build-shards'
|
||||
|
||||
# The build directory is kept whenever the exact toolchain that filled it is the one
|
||||
# installed now and its CMake cache still belongs to this workspace path. Object files are
|
||||
@@ -351,7 +396,7 @@ try {
|
||||
}
|
||||
}
|
||||
$dependencies = Join-Path $Workspace 'Dependencies'
|
||||
$nativePrebuilt = Resolve-MkwNativePrebuiltDirectory $dependencies $toolchainBin `
|
||||
$nativePrebuilt = Resolve-NativePrebuiltDirectory $dependencies $toolchainBin `
|
||||
(Join-Path $Workspace 'aurora-main') (Join-Path $Workspace 'runtime\third_party')
|
||||
if ($X86CpuProfile -eq 'v2') {
|
||||
Write-Host 'MKWCBUILD: This CPU does not support x86-64-v3; building for x86-64-v2'
|
||||
|
||||
+54
-118
@@ -157,8 +157,7 @@ function Resolve-MkwProjectForAssets([string]$Workspace, [string]$AssetsDirector
|
||||
}
|
||||
|
||||
function Invoke-Checked([string]$FilePath, [string[]]$Arguments, [string]$Description,
|
||||
[string]$LogPrefix = 'MKWCBUILD', [string]$StepId = '', [bool]$WaitForProcessTree = $true,
|
||||
[int]$TimeoutSeconds = 0) {
|
||||
[string]$LogPrefix = 'MKWCBUILD', [string]$StepId = '', [bool]$WaitForProcessTree = $true) {
|
||||
<#
|
||||
Runs a build tool and turns a non-zero exit code into a described failure. By default,
|
||||
Start-Process -Wait waits for the whole process tree, since a .NET single-file bundle host may
|
||||
@@ -167,7 +166,6 @@ function Invoke-Checked([string]$FilePath, [string[]]$Arguments, [string]$Descri
|
||||
Start-Process doesn't publish $LASTEXITCODE, so this sets it manually for callers that check it.
|
||||
-StepId emits the machine-readable form the installer's progress bar consumes (BuildStepIds in
|
||||
WiiCompiled.Setup/InstallProgress.cs); the human sentence stays on the same log line.
|
||||
-TimeoutSeconds waits on the process itself and kills its tree when time runs out.
|
||||
#>
|
||||
if ($StepId) { Write-Host "${LogPrefix}:STEP:${StepId} $Description" }
|
||||
else { Write-Host "${LogPrefix}: $Description" }
|
||||
@@ -176,17 +174,7 @@ function Invoke-Checked([string]$FilePath, [string[]]$Arguments, [string]$Descri
|
||||
# Backslashes directly before the closing quote would otherwise escape it.
|
||||
'"' + ($_ -replace '(\\+)$', '$1$1') + '"'
|
||||
})
|
||||
if ($TimeoutSeconds -gt 0) {
|
||||
$process = Start-Process -FilePath $FilePath -ArgumentList $quotedArguments -NoNewWindow -PassThru
|
||||
# Holding the handle keeps ExitCode readable once the process has exited.
|
||||
$null = $process.Handle
|
||||
if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
|
||||
# taskkill's stderr would end the script under 'Stop' before the message below.
|
||||
try { & taskkill.exe /T /F /PID $process.Id 2>&1 | Out-Null } catch {}
|
||||
throw "$Description did not finish within $TimeoutSeconds seconds and was stopped."
|
||||
}
|
||||
$exitCode = $process.ExitCode
|
||||
} elseif ($WaitForProcessTree) {
|
||||
if ($WaitForProcessTree) {
|
||||
$process = Start-Process -FilePath $FilePath -ArgumentList $quotedArguments `
|
||||
-NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.ExitCode
|
||||
@@ -248,121 +236,69 @@ function Get-MkwDawnRuntimeSha256([string]$DependenciesDirectory) {
|
||||
return Get-MkwFileSha256 $runtime
|
||||
}
|
||||
|
||||
function ConvertTo-MkwHex([byte[]]$Bytes) {
|
||||
return [BitConverter]::ToString($Bytes).Replace('-', '').ToLowerInvariant()
|
||||
}
|
||||
|
||||
function Get-MkwSourceTreeFingerprint([string]$Directory, [string[]]$ExcludedTopDirectories = @()) {
|
||||
function Get-MkwThirdPartySourceFingerprint([string]$ThirdPartyDirectory) {
|
||||
<#
|
||||
SHA-256 over "relative-path content-sha256" lines of a source tree. It must come out the same
|
||||
under powershell.exe 5.1 and pwsh 7 and for any checkout's line endings, because the package is
|
||||
harvested on a release machine and re-checked on every user's: paths are sorted ordinally (not
|
||||
with Sort-Object, whose culture collation differs between NLS and ICU) and text content is hashed
|
||||
with CRLF folded to LF. Dot-paths are skipped by name, not by the hidden attribute, which the
|
||||
payload zip drops. Returns '' when the tree is absent.
|
||||
SHA-256 over runtime/third_party, the vendored sources behind the prebuilt package. The
|
||||
precompiled Crypto++ archive is linked while first-party TUs compile against the same headers,
|
||||
so a workspace whose vendored tree moved on must not consume archives harvested from an older
|
||||
one (same reasoning as the aurora fingerprint below), skipping dot-paths by name the same way.
|
||||
Returns '' when absent.
|
||||
#>
|
||||
if ([string]::IsNullOrWhiteSpace($Directory)) { return '' }
|
||||
if (-not (Test-Path -LiteralPath $Directory -PathType Container)) { return '' }
|
||||
$root = [IO.Path]::GetFullPath($Directory).TrimEnd('\')
|
||||
$relatives = [Collections.Generic.List[string]]::new()
|
||||
foreach ($file in [IO.Directory]::EnumerateFiles($root, '*', [IO.SearchOption]::AllDirectories)) {
|
||||
$relative = $file.Substring($root.Length + 1).Replace('\', '/')
|
||||
$parts = $relative.Split('/')
|
||||
if ($parts.Length -gt 1 -and $ExcludedTopDirectories -contains $parts[0]) { continue }
|
||||
$hidden = $false
|
||||
foreach ($part in $parts) { if ($part.StartsWith('.', [StringComparison]::Ordinal)) { $hidden = $true; break } }
|
||||
if (-not $hidden) { $relatives.Add($relative) }
|
||||
}
|
||||
$sorted = $relatives.ToArray()
|
||||
[Array]::Sort($sorted, [StringComparer]::Ordinal)
|
||||
|
||||
$latin1 = [Text.Encoding]::GetEncoding(28591)
|
||||
if ([string]::IsNullOrWhiteSpace($ThirdPartyDirectory)) { return '' }
|
||||
if (-not (Test-Path -LiteralPath $ThirdPartyDirectory -PathType Container)) { return '' }
|
||||
$root = [IO.Path]::GetFullPath($ThirdPartyDirectory).TrimEnd('\')
|
||||
$files = Get-ChildItem -LiteralPath $root -Recurse -File -Force |
|
||||
Where-Object { -not ($_.FullName.Substring($root.Length + 1) -split '\\' | Where-Object { $_.StartsWith('.') }) } |
|
||||
Sort-Object { $_.FullName.Substring($root.Length + 1).Replace('\', '/') }
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$builder = [Text.StringBuilder]::new()
|
||||
foreach ($relative in $sorted) {
|
||||
$bytes = [IO.File]::ReadAllBytes($root + '\' + $relative.Replace('/', '\'))
|
||||
# Latin-1 maps bytes 1:1, so the round trip only changes the line endings.
|
||||
$text = $latin1.GetString($bytes)
|
||||
if ($text.IndexOf([char]0) -lt 0 -and $text.Contains("`r`n")) {
|
||||
$bytes = $latin1.GetBytes($text.Replace("`r`n", "`n"))
|
||||
}
|
||||
[void]$builder.Append($relative).Append(' ').Append((ConvertTo-MkwHex $sha.ComputeHash($bytes))).Append("`n")
|
||||
foreach ($file in $files) {
|
||||
$relative = $file.FullName.Substring($root.Length + 1).Replace('\', '/')
|
||||
$content = Get-MkwFileSha256 $file.FullName
|
||||
[void]$builder.Append($relative).Append(' ').Append($content).Append("`n")
|
||||
}
|
||||
return ConvertTo-MkwHex $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($builder.ToString()))
|
||||
$bytes = $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($builder.ToString()))
|
||||
} finally { $sha.Dispose() }
|
||||
}
|
||||
|
||||
function Get-MkwThirdPartySourceFingerprint([string]$ThirdPartyDirectory) {
|
||||
<#
|
||||
runtime/third_party, the vendored sources behind the prebuilt package. The precompiled Crypto++
|
||||
archive is linked while first-party TUs compile against the same headers, so a workspace whose
|
||||
vendored tree moved on must not consume archives harvested from an older one.
|
||||
#>
|
||||
return Get-MkwSourceTreeFingerprint $ThirdPartyDirectory
|
||||
return (($bytes | ForEach-Object { $_.ToString('x2') }) -join '')
|
||||
}
|
||||
|
||||
function Get-MkwAuroraSourceFingerprint([string]$AuroraDirectory) {
|
||||
<#
|
||||
The aurora sources the precompiled archives were harvested from. The compiler, flag, and Dawn
|
||||
digests only prove the package agrees with its target environment; none notice aurora itself
|
||||
moving on, and since the runtime calls aurora headers directly, a package harvested before an
|
||||
aurora change links old code against new call sites. extern/ is excluded because the payload
|
||||
ships those trees separately.
|
||||
SHA-256 over the aurora sources the precompiled archives were harvested from. The compiler,
|
||||
flag, and Dawn digests only prove the package agrees with its target environment; none notice
|
||||
aurora itself moving on, and since the runtime calls aurora headers directly, a package
|
||||
harvested before an aurora change links old code against new call sites (undefined symbol at
|
||||
best, silently stale renderer at worst). extern/ is excluded because the payload ships those
|
||||
trees separately. Returns '' when the tree is absent.
|
||||
#>
|
||||
return Get-MkwSourceTreeFingerprint $AuroraDirectory @('extern', 'build')
|
||||
}
|
||||
|
||||
function Resolve-MkwNativePrebuiltDirectory([string]$DependenciesDirectory, [string]$ToolchainBin,
|
||||
[string]$AuroraDirectory, [string]$ThirdPartyDirectory) {
|
||||
<#
|
||||
The shipped precompiled aurora/third-party package, verified against its recorded provenance
|
||||
(exact compiler, exact flags, sources) before use. Anything unexpected falls back to building
|
||||
from source. Shared with Build-Installer.ps1, which runs it against the staged payload under
|
||||
powershell.exe so a package users would reject never ships.
|
||||
#>
|
||||
$package = Join-Path $DependenciesDirectory 'native_prebuilt'
|
||||
$provenancePath = Join-Path $package 'provenance.json'
|
||||
if (-not (Test-Path -LiteralPath $provenancePath -PathType Leaf)) {
|
||||
Write-Host 'MKWCBUILD: No precompiled aurora package was shipped; building it from source'
|
||||
return ''
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($AuroraDirectory)) { return '' }
|
||||
if (-not (Test-Path -LiteralPath $AuroraDirectory -PathType Container)) { return '' }
|
||||
$root = [IO.Path]::GetFullPath($AuroraDirectory).TrimEnd('\')
|
||||
$excludedPrefixes = @(
|
||||
(Join-Path $root 'extern') + '\',
|
||||
(Join-Path $root 'build') + '\'
|
||||
)
|
||||
# Dot-paths (the submodule's .git pointer) are skipped by name, not by the hidden attribute:
|
||||
# the payload zip drops that attribute, so the installed side would otherwise hash them.
|
||||
$files = Get-ChildItem -LiteralPath $root -Recurse -File -Force |
|
||||
Where-Object {
|
||||
$full = $_.FullName
|
||||
-not ($excludedPrefixes | Where-Object { $full.StartsWith($_, [StringComparison]::OrdinalIgnoreCase) }) -and
|
||||
-not ($full.Substring($root.Length + 1) -split '\\' | Where-Object { $_.StartsWith('.') })
|
||||
} |
|
||||
Sort-Object { $_.FullName.Substring($root.Length + 1).Replace('\', '/') }
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$provenance = Get-Content -LiteralPath $provenancePath -Raw | ConvertFrom-Json
|
||||
if ($provenance.CompilerSha256 -ne (Get-MkwFileSha256 (Join-Path $ToolchainBin 'clang-22.exe'))) {
|
||||
Write-Host 'MKWCBUILD: The precompiled aurora package was built by a different compiler; building it from source'
|
||||
return ''
|
||||
$builder = [Text.StringBuilder]::new()
|
||||
foreach ($file in $files) {
|
||||
$relative = $file.FullName.Substring($root.Length + 1).Replace('\', '/')
|
||||
$content = Get-MkwFileSha256 $file.FullName
|
||||
[void]$builder.Append($relative).Append(' ').Append($content).Append("`n")
|
||||
}
|
||||
if ($provenance.FlagFingerprint -ne (Get-MkwNativeFlagFingerprint)) {
|
||||
Write-Host 'MKWCBUILD: The precompiled aurora package was built with different flags; building it from source'
|
||||
return ''
|
||||
}
|
||||
# The archives embed Dawn's headers and link its import library; a different Dawn is an
|
||||
# ABI mismatch that only shows up as a crash inside the GPU backend.
|
||||
$recordedDawn = $provenance.PSObject.Properties['DawnRuntimeSha256']
|
||||
if ($null -eq $recordedDawn -or
|
||||
$recordedDawn.Value -ne (Get-MkwDawnRuntimeSha256 $DependenciesDirectory)) {
|
||||
Write-Host 'MKWCBUILD: The precompiled aurora package was built against a different Dawn; building it from source'
|
||||
return ''
|
||||
}
|
||||
$recordedAurora = $provenance.PSObject.Properties['AuroraSourceFingerprint']
|
||||
if ($null -eq $recordedAurora -or
|
||||
$recordedAurora.Value -ne (Get-MkwAuroraSourceFingerprint $AuroraDirectory)) {
|
||||
Write-Host 'MKWCBUILD: The precompiled aurora package was built from different aurora sources; building it from source'
|
||||
return ''
|
||||
}
|
||||
$recordedThirdParty = $provenance.PSObject.Properties['ThirdPartySourceFingerprint']
|
||||
if ($null -eq $recordedThirdParty -or
|
||||
$recordedThirdParty.Value -ne (Get-MkwThirdPartySourceFingerprint $ThirdPartyDirectory)) {
|
||||
Write-Host 'MKWCBUILD: The precompiled aurora package was built from different vendored third-party sources; building it from source'
|
||||
return ''
|
||||
}
|
||||
} catch {
|
||||
Write-Host "MKWCBUILD: The precompiled aurora package could not be validated ($($_.Exception.Message)); building it from source"
|
||||
return ''
|
||||
}
|
||||
Write-Host 'MKWCBUILD: Using the precompiled aurora and third-party libraries'
|
||||
return $package
|
||||
$bytes = $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($builder.ToString()))
|
||||
} finally { $sha.Dispose() }
|
||||
return (($bytes | ForEach-Object { $_.ToString('x2') }) -join '')
|
||||
}
|
||||
|
||||
function ConvertTo-MkwCMakePath([string]$Path) {
|
||||
@@ -411,9 +347,9 @@ function Get-MkwNativeConfigureArguments {
|
||||
}
|
||||
}
|
||||
|
||||
# Always set, so a reused CMake cache can never keep a package this run rejected.
|
||||
$arguments += if ([string]::IsNullOrWhiteSpace($NativePrebuiltDirectory)) { '-DMKW_NATIVE_PREBUILT_DIR=' }
|
||||
else { "-DMKW_NATIVE_PREBUILT_DIR=$(ConvertTo-MkwCMakePath $NativePrebuiltDirectory)" }
|
||||
if (-not [string]::IsNullOrWhiteSpace($NativePrebuiltDirectory)) {
|
||||
$arguments += "-DMKW_NATIVE_PREBUILT_DIR=$(ConvertTo-MkwCMakePath $NativePrebuiltDirectory)"
|
||||
}
|
||||
|
||||
$arguments += $AdditionalArguments
|
||||
return $arguments
|
||||
|
||||
+1
-1
Submodule aurora-main updated: 726f58d3eb...4aacffd3da
@@ -157,7 +157,7 @@ public:
|
||||
#define MKW_DETAIL_CAT(a, b) a##b
|
||||
#define MKW_DETAIL_MAKE_UNIQUE(a, b) MKW_DETAIL_CAT(a, b)
|
||||
|
||||
// Hand-written registration macros, given the PAL address. Generated code never registers
|
||||
// Three hand-written registration macros, given the PAL address. Generated code never registers
|
||||
// per function; every translated function reaches the registry via the bulk
|
||||
// BulkTranslatedFunctionRecord tables in the translator's *_registration TUs.
|
||||
#define REGISTER_TRANSLATED_FUNCTION(address, fn) \
|
||||
@@ -168,9 +168,3 @@ public:
|
||||
|
||||
#define REGISTER_NATIVE_FUNCTION_AS(address, fn, pretty_name) \
|
||||
static AbiTrampoline<decltype(fn)> MKW_DETAIL_MAKE_UNIQUE(_abi_native_trampoline_named_, __COUNTER__)(::GuestRegion::Translate(address), pretty_name, fn, FunctionKind::Native, false, kPpcAllNonvolatileFprMask, 0, 0, &AbiRawCpuThunk<&fn>::Invoke)
|
||||
|
||||
// A native that calls through with MKW_GUEST_NEXT, so a mod overlay at its address still runs.
|
||||
#define REGISTER_NATIVE_WRAPPER_AS(address, fn, pretty_name) REGISTER_NATIVE_FUNCTION_AS(address, fn, pretty_name)
|
||||
|
||||
// A native that deliberately takes over its address from any mod overlay there.
|
||||
#define REGISTER_NATIVE_REPLACEMENT_AS(address, fn, pretty_name) REGISTER_NATIVE_FUNCTION_AS(address, fn, pretty_name)
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
// Base translations the runtime calls directly, named by PAL identity and resolved through the
|
||||
// function registry on first use.
|
||||
// MKW_GUEST_FUNC(801A0620)(ctx);
|
||||
// A native registered with REGISTER_NATIVE_WRAPPER_AS calls through with MKW_GUEST_NEXT instead,
|
||||
// so a mod overlay at the same address still runs.
|
||||
|
||||
#include "guest_abi/cpu_context.h"
|
||||
#include "guest_abi/guest_region.h"
|
||||
@@ -14,10 +12,6 @@ using Entry = void (*)(CpuContext*);
|
||||
|
||||
// The base translation at a guest address, also where a native or mod overrides it.
|
||||
Entry BaseTranslation(uint32_t address);
|
||||
|
||||
// The translation a native wrapper sits on top of: the winning mod overlay, else the base
|
||||
// translation.
|
||||
Entry NextTranslation(uint32_t address);
|
||||
} // namespace GuestFunctions
|
||||
|
||||
#define MKW_GUEST_FUNC(pal) \
|
||||
@@ -25,9 +19,3 @@ Entry NextTranslation(uint32_t address);
|
||||
static const ::GuestFunctions::Entry entry = ::GuestFunctions::BaseTranslation(MKW_GADDR(pal)); \
|
||||
return entry; \
|
||||
}())
|
||||
|
||||
#define MKW_GUEST_NEXT(pal) \
|
||||
(*[] { \
|
||||
static const ::GuestFunctions::Entry entry = ::GuestFunctions::NextTranslation(MKW_GADDR(pal)); \
|
||||
return entry; \
|
||||
}())
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
// MKW_PERF_STATS=1: logs the game thread's busy time per presented frame, i.e. wall time minus the
|
||||
// time it spends blocked on VI pacing, the GX worker or the audio mixer.
|
||||
// MKW_FRAME_TRACE=<csv path> also records those per-frame timings in Aurora's trace.
|
||||
namespace PerfStats {
|
||||
|
||||
bool Enabled() noexcept;
|
||||
|
||||
+14
-41
@@ -426,10 +426,20 @@ const TranslatedFunctionInfo* TranslatedFunctionRegistry::FindByAddressPtrSlow(u
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
[[noreturn]] void ReportMissingGuestFunction(uint32_t address) {
|
||||
RT_LOG(RT_TAG_RUNTIME) << "ERROR: no translation registered at 0x" << std::hex << address << std::dec
|
||||
GuestFunctions::Entry GuestFunctions::BaseTranslation(uint32_t address) {
|
||||
std::unique_lock<std::mutex> lock(RegistryMutex(), std::defer_lock);
|
||||
if (!RegistryFrozen().load(std::memory_order_acquire)) {
|
||||
lock.lock();
|
||||
}
|
||||
for (const auto& entry : Registry()) {
|
||||
if (entry.address == address && entry.kind == FunctionKind::BaseTranslated && entry.rawCpuInvoker) {
|
||||
return entry.rawCpuInvoker;
|
||||
}
|
||||
}
|
||||
if (lock.owns_lock()) {
|
||||
lock.unlock();
|
||||
}
|
||||
RT_LOG(RT_TAG_RUNTIME) << "ERROR: no base translation registered at 0x" << std::hex << address << std::dec
|
||||
<< std::endl;
|
||||
std::ostringstream message;
|
||||
message << "The runtime calls the translated function at guest address 0x" << std::hex << address
|
||||
@@ -441,43 +451,6 @@ namespace {
|
||||
std::exit(EXIT_FAILURE);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
GuestFunctions::Entry GuestFunctions::BaseTranslation(uint32_t address) {
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(RegistryMutex(), std::defer_lock);
|
||||
if (!RegistryFrozen().load(std::memory_order_acquire)) {
|
||||
lock.lock();
|
||||
}
|
||||
for (const auto& entry : Registry()) {
|
||||
if (entry.address == address && entry.kind == FunctionKind::BaseTranslated && entry.rawCpuInvoker) {
|
||||
return entry.rawCpuInvoker;
|
||||
}
|
||||
}
|
||||
}
|
||||
ReportMissingGuestFunction(address);
|
||||
}
|
||||
|
||||
GuestFunctions::Entry GuestFunctions::NextTranslation(uint32_t address) {
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(RegistryMutex(), std::defer_lock);
|
||||
if (!RegistryFrozen().load(std::memory_order_acquire)) {
|
||||
lock.lock();
|
||||
}
|
||||
const TranslatedFunctionInfo* best = nullptr;
|
||||
for (const auto& entry : Registry()) {
|
||||
if (entry.address == address && entry.kind != FunctionKind::Native && entry.rawCpuInvoker &&
|
||||
(!best || IsBetterCandidate(entry, *best))) {
|
||||
best = &entry;
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
return best->rawCpuInvoker;
|
||||
}
|
||||
}
|
||||
ReportMissingGuestFunction(address);
|
||||
}
|
||||
|
||||
const RawDispatchRecord* TranslatedFunctionRegistry::FindRawByAddressPtrSlow(uint32_t address) {
|
||||
if (!RegistryFrozen().load(std::memory_order_acquire)) {
|
||||
return nullptr;
|
||||
|
||||
@@ -503,20 +503,19 @@ int64_t ConsumeAudioPollDeltaMicros()
|
||||
using Clock = std::chrono::steady_clock;
|
||||
static thread_local Clock::time_point lastPoll = Clock::now();
|
||||
|
||||
const Clock::time_point now = Clock::now();
|
||||
auto elapsed = std::chrono::duration_cast<std::chrono::microseconds>(now - lastPoll).count();
|
||||
lastPoll = now;
|
||||
|
||||
if (elapsed < 0) {
|
||||
elapsed = 0;
|
||||
}
|
||||
|
||||
// Cap the catch-up interval after a debugger pause or host stall; backlog drains via
|
||||
// kMaxBlocksPerTick per pass. Never return early on a zero delta: two scheduler passes
|
||||
// can land in the same microsecond and the backlog still needs servicing.
|
||||
constexpr std::chrono::microseconds kMaxPollDelta{100'000};
|
||||
|
||||
const Clock::time_point now = Clock::now();
|
||||
const auto elapsed = std::chrono::duration_cast<std::chrono::microseconds>(now - lastPoll);
|
||||
if (elapsed.count() < 0 || elapsed > kMaxPollDelta) {
|
||||
lastPoll = now;
|
||||
return elapsed.count() < 0 ? 0 : kMaxPollDelta.count();
|
||||
}
|
||||
// Keep the sub-microsecond remainder for the next poll.
|
||||
lastPoll += elapsed;
|
||||
return elapsed.count();
|
||||
constexpr int64_t kMaxPollDeltaMicros = 100'000;
|
||||
return std::min(elapsed, kMaxPollDeltaMicros);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
#include "guest_function_imports.h"
|
||||
#include "memory.h"
|
||||
#include "runtime_log.h"
|
||||
#include <aurora/aurora.h>
|
||||
|
||||
#include <array>
|
||||
#include <charconv>
|
||||
@@ -163,7 +162,7 @@ REGISTER_NATIVE_FUNCTION_AS(0x805B9344, ArgumentParser_Parse_805b9344, "Argument
|
||||
// RacedataScenario::InitScreens; local racers keep no real controller channel.
|
||||
extern "C" void RacedataScenario_InitScreens_8052f4e8(CpuContext* ctx) {
|
||||
if (!g_cpuDriven) {
|
||||
MKW_GUEST_NEXT(8052F4E8)(ctx);
|
||||
MKW_GUEST_FUNC(8052F4E8)(ctx);
|
||||
return;
|
||||
}
|
||||
const uint32_t players = ctx->gpr[3] + kPlayersOffset;
|
||||
@@ -171,7 +170,7 @@ extern "C" void RacedataScenario_InitScreens_8052f4e8(CpuContext* ctx) {
|
||||
for (uint32_t i = 0; i < kPlayerCount; ++i) {
|
||||
channels[i] = Memory::Read8(players + i * kPlayerSize + kPlayerControllerChannelOffset);
|
||||
}
|
||||
MKW_GUEST_NEXT(8052F4E8)(ctx);
|
||||
MKW_GUEST_FUNC(8052F4E8)(ctx);
|
||||
for (uint32_t i = 0; i < kPlayerCount; ++i) {
|
||||
const uint32_t player = players + i * kPlayerSize;
|
||||
if (Memory::Read32(player + kPlayerTypeOffset) == kPlayerRealLocal) {
|
||||
@@ -179,17 +178,12 @@ extern "C" void RacedataScenario_InitScreens_8052f4e8(CpuContext* ctx) {
|
||||
}
|
||||
}
|
||||
}
|
||||
REGISTER_NATIVE_WRAPPER_AS(0x8052F4E8, RacedataScenario_InitScreens_8052f4e8, "RacedataScenario_InitScreens_8052f4e8");
|
||||
REGISTER_NATIVE_FUNCTION_AS(0x8052F4E8, RacedataScenario_InitScreens_8052f4e8, "RacedataScenario_InitScreens_8052f4e8");
|
||||
|
||||
// KartAIController::IsCPU
|
||||
extern "C" void KartAIController_IsCPU_8072624c(CpuContext* ctx) {
|
||||
MKW_GUEST_FUNC(8072624C)(ctx);
|
||||
if (g_cpuDriven) {
|
||||
static bool benchmarkReady = false;
|
||||
if (!benchmarkReady) {
|
||||
benchmarkReady = true;
|
||||
aurora_report_benchmark_ready();
|
||||
}
|
||||
ctx->gpr[3] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,7 +436,7 @@ extern "C" void NAND_IOS_OpenBody_HLE_801938FC(CpuContext* ctx) {
|
||||
ctx->gpr[3] = static_cast<uint32_t>(result);
|
||||
ctx->gpr[1] = ctx->gpr[1] + 32u;
|
||||
}
|
||||
REGISTER_NATIVE_REPLACEMENT_AS(0x801938FC, NAND_IOS_OpenBody_HLE_801938FC, "NAND_IOS_OpenBody_HLE_801938FC");
|
||||
REGISTER_NATIVE_FUNCTION_AS(0x801938FC, NAND_IOS_OpenBody_HLE_801938FC, "NAND_IOS_OpenBody_HLE_801938FC");
|
||||
|
||||
extern "C" int32_t NAND_IOS_Close_HLE(uint32_t fd) {
|
||||
if (fd == ISFS_DEV_FD) {
|
||||
|
||||
+6
-10
@@ -169,7 +169,6 @@ std::atomic_flag g_abortSignalHandled = ATOMIC_FLAG_INIT;
|
||||
std::atomic_bool g_fatalErrorReported{false};
|
||||
std::atomic_bool g_fatalPopupShown{false};
|
||||
bool g_headless = false;
|
||||
bool g_hidden = false;
|
||||
std::atomic<int> g_lastExitCode{0};
|
||||
std::atomic_bool g_exitCodeSet{false};
|
||||
|
||||
@@ -776,7 +775,7 @@ void MarkFatalErrorReported() {
|
||||
}
|
||||
|
||||
void ShowRuntimeFatalPopup(std::string_view category, std::string_view details) noexcept {
|
||||
if (g_headless || g_hidden || g_fatalPopupShown.exchange(true, std::memory_order_acq_rel)) {
|
||||
if (g_headless || g_fatalPopupShown.exchange(true, std::memory_order_acq_rel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1351,19 +1350,16 @@ int RuntimeMain(int argc, char** argv) {
|
||||
// `--headless`: with a boot race, run without a window, sound or dialogs.
|
||||
g_headless = std::any_of(argv + 1, argv + argc,
|
||||
[](const char* arg) { return std::string_view(arg) == "--headless"; });
|
||||
g_hidden = std::any_of(argv + 1, argv + argc,
|
||||
[](const char* arg) { return std::string_view(arg) == "--hidden"; });
|
||||
|
||||
try {
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
if (std::string_view(argv[i]) != "--headless" && std::string_view(argv[i]) != "--hidden" &&
|
||||
!BootRace::ParseArgument(argv[i])) {
|
||||
if (std::string_view(argv[i]) != "--headless" && !BootRace::ParseArgument(argv[i])) {
|
||||
throw std::invalid_argument(std::string("Unknown command-line option '") + argv[i] +
|
||||
"'; the game runtime only accepts --race[=<course id>], --race-cpu, --hidden and --headless, use Config.toml through the installed host.");
|
||||
"'; the game runtime only accepts --race[=<course id>], --race-cpu and --headless, use Config.toml through the installed host.");
|
||||
}
|
||||
}
|
||||
if ((g_headless || g_hidden) && !BootRace::Requested()) {
|
||||
throw std::invalid_argument("--headless and --hidden need --race[=<course id>] or --race-cpu");
|
||||
if (g_headless && !BootRace::Requested()) {
|
||||
throw std::invalid_argument("--headless needs --race[=<course id>] or --race-cpu");
|
||||
}
|
||||
if (g_headless) {
|
||||
AudioBackend::Instance().SetSilenced(true);
|
||||
@@ -1404,7 +1400,7 @@ int RuntimeMain(int argc, char** argv) {
|
||||
auroraConfig.hasWindowPosition = RuntimeConfigFile::WindowPosition(
|
||||
auroraConfig.windowPosX, auroraConfig.windowPosY);
|
||||
auroraConfig.allowJoystickBackgroundEvents = !g_headless;
|
||||
auroraConfig.startHidden = g_hidden || g_headless;
|
||||
auroraConfig.startHidden = g_headless;
|
||||
auroraConfig.disableCopyFilter = RuntimeConfigFile::DisableCopyFilter(true);
|
||||
// Dolphin-style custom textures. Aurora indexes <userPath>/texture_replacements
|
||||
// once during aurora_initialize, so both knobs only take effect on the next launch.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#include "perf_stats.h"
|
||||
|
||||
#include "runtime_log.h"
|
||||
#include <aurora/aurora.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
@@ -25,20 +24,12 @@ struct FrameWindow {
|
||||
|
||||
FrameWindow g_window;
|
||||
|
||||
bool LogEnabled() noexcept {
|
||||
static const bool enabled = [] {
|
||||
const char* value = std::getenv("MKW_PERF_STATS");
|
||||
return value != nullptr && std::strcmp(value, "0") != 0;
|
||||
}();
|
||||
return enabled;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool Enabled() noexcept {
|
||||
static const bool enabled = [] {
|
||||
const char* trace = std::getenv("MKW_FRAME_TRACE");
|
||||
return LogEnabled() || (trace != nullptr && *trace);
|
||||
const char* value = std::getenv("MKW_PERF_STATS");
|
||||
return value != nullptr && std::strcmp(value, "0") != 0;
|
||||
}();
|
||||
return enabled;
|
||||
}
|
||||
@@ -57,11 +48,7 @@ void OnFramePresented() noexcept {
|
||||
return;
|
||||
}
|
||||
const auto busy = (now - g_window.last) - wait;
|
||||
aurora_report_producer_timing(
|
||||
std::chrono::duration_cast<std::chrono::nanoseconds>(now - g_window.last).count(),
|
||||
std::chrono::duration_cast<std::chrono::nanoseconds>(wait).count());
|
||||
g_window.last = now;
|
||||
if (!LogEnabled()) return;
|
||||
g_window.busyMs[g_window.count++] = std::chrono::duration<double, std::milli>(busy).count();
|
||||
if (g_window.count < kWindow) {
|
||||
return;
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
"""Analyze MKW_FRAME_TRACE CSV files. No third-party packages required."""
|
||||
import argparse
|
||||
import bisect
|
||||
import collections
|
||||
import csv
|
||||
import json
|
||||
import math
|
||||
import io
|
||||
import re
|
||||
import statistics
|
||||
from pathlib import Path
|
||||
|
||||
NS_MS = 1_000_000
|
||||
NATIVE_FPS = 60
|
||||
COLUMNS = ("event", "timestamp_ns", "frame_id", "slot", "duration_ns", "deadline_ns",
|
||||
"interval_ns", "detail")
|
||||
INTEGER_COLUMNS = COLUMNS[1:]
|
||||
SIGNED_COLUMNS = {"slot"}
|
||||
DROP_SIGNALS = {"drop_late": "late_slot_dropped", "drop_display": "display_slot_dropped",
|
||||
"present_unavailable": "present_unavailable", "present_failed": "present_failed"}
|
||||
ANCHORS = ("auto", "first_row", "first_real_present", "first_transform", "benchmark_ready")
|
||||
DISPLAY_LIMITED_INVALID_FRACTION = .01
|
||||
|
||||
|
||||
class TraceFormatError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def percentile(values, fraction):
|
||||
if not values:
|
||||
return 0.0
|
||||
values = sorted(values)
|
||||
return values[max(0, math.ceil(len(values) * fraction) - 1)]
|
||||
|
||||
|
||||
def distribution(values):
|
||||
return {"count": len(values), "median_ms": percentile(values, .5),
|
||||
"p95_ms": percentile(values, .95), "p99_ms": percentile(values, .99),
|
||||
"max_ms": max(values, default=0)}
|
||||
|
||||
|
||||
def parse_trace(path):
|
||||
"""Returns (rows, lost_samples, truncated_tail); rows exclude trace_lost markers."""
|
||||
path = Path(path)
|
||||
try:
|
||||
content = path.read_bytes().decode("utf-8-sig")
|
||||
except UnicodeDecodeError as error:
|
||||
raise TraceFormatError(f"{path}: not UTF-8 text ({error})") from None
|
||||
truncated_tail = bool(content) and not content.endswith("\n")
|
||||
if truncated_tail:
|
||||
content = content[:content.rfind("\n") + 1]
|
||||
reader = csv.reader(io.StringIO(content, newline=""))
|
||||
try:
|
||||
return _parse_rows(path, reader, truncated_tail)
|
||||
except csv.Error as error:
|
||||
raise TraceFormatError(f"{path}: line {reader.line_num}: {error}") from None
|
||||
|
||||
|
||||
def _parse_rows(path, reader, truncated_tail):
|
||||
header = next(reader, None)
|
||||
if header is None:
|
||||
raise TraceFormatError(f"{path}: no complete header line")
|
||||
duplicate = sorted({name for name in header if header.count(name) > 1})
|
||||
missing = [name for name in COLUMNS if name not in header]
|
||||
foreign = sorted({name for name in header if name not in COLUMNS})
|
||||
if duplicate or missing or foreign:
|
||||
problems = [f"{label} {names}" for label, names in
|
||||
(("duplicate columns", duplicate), ("missing columns", missing),
|
||||
("unexpected columns", foreign)) if names]
|
||||
raise TraceFormatError(f"{path}: line 1: not a frame trace header: {'; '.join(problems)}")
|
||||
index = {name: header.index(name) for name in COLUMNS}
|
||||
rows, lost = [], 0
|
||||
for fields in reader:
|
||||
line = reader.line_num
|
||||
if len(fields) != len(header):
|
||||
raise TraceFormatError(f"{path}: line {line}: expected {len(header)} fields, got {len(fields)}")
|
||||
row = {"event": fields[index["event"]], "line": line}
|
||||
if not row["event"]:
|
||||
raise TraceFormatError(f"{path}: line {line}: empty event name")
|
||||
for name in INTEGER_COLUMNS:
|
||||
text = fields[index[name]]
|
||||
if not re.fullmatch(r"-?[0-9]{1,20}", text):
|
||||
raise TraceFormatError(f"{path}: line {line}: column '{name}' is not an integer: {text!r}")
|
||||
row[name] = int(text)
|
||||
if row[name] < 0 and name not in SIGNED_COLUMNS:
|
||||
raise TraceFormatError(f"{path}: line {line}: column '{name}' must not be negative: {text}")
|
||||
if row["event"] == "trace_lost":
|
||||
lost += row["detail"]
|
||||
else:
|
||||
rows.append(row)
|
||||
return rows, lost, truncated_tail
|
||||
|
||||
|
||||
def trace_state(path):
|
||||
"""Cheap readiness probe for a trace still being written; None until the header is complete."""
|
||||
if b"\n" not in Path(path).read_bytes():
|
||||
return None
|
||||
rows = parse_trace(path)[0]
|
||||
state = {"benchmark_ready_ns": None, "real_present_ns": None, "transform_ns": None,
|
||||
"display_limited": False}
|
||||
for row in rows:
|
||||
if row["event"] == "benchmark_ready" and state["benchmark_ready_ns"] is None:
|
||||
state["benchmark_ready_ns"] = row["timestamp_ns"]
|
||||
elif row["event"] == "present_real" and state["real_present_ns"] is None:
|
||||
state["real_present_ns"] = row["timestamp_ns"]
|
||||
elif row["event"] == "interp_uniforms" and row["detail"] and state["transform_ns"] is None:
|
||||
state["transform_ns"] = row["timestamp_ns"]
|
||||
elif row["event"] == "display_limited":
|
||||
state["display_limited"] = True
|
||||
return state
|
||||
|
||||
|
||||
def native_period_ms(rows):
|
||||
"""Scheduled native VI period from the seal events; 60 Hz only when the trace has none."""
|
||||
intervals = [row["interval_ns"] for row in rows if row["event"] == "seal" and row["interval_ns"]]
|
||||
return statistics.median(intervals) / NS_MS if intervals else 1000 / NATIVE_FPS
|
||||
|
||||
|
||||
def timing_anchor(rows, requested):
|
||||
if requested == "auto":
|
||||
requested = "benchmark_ready"
|
||||
matches = {"first_row": lambda row: True,
|
||||
"benchmark_ready": lambda row: row["event"] == "benchmark_ready",
|
||||
"first_real_present": lambda row: row["event"] == "present_real",
|
||||
"first_transform": lambda row: row["event"] == "interp_uniforms" and row["detail"] > 0}
|
||||
for used in dict.fromkeys((requested, "first_real_present", "first_row")):
|
||||
row = next((row for row in rows if matches[used](row)), None)
|
||||
if row:
|
||||
return {"requested": requested, "used": used, "start_ns": row["timestamp_ns"],
|
||||
"found": used == requested}
|
||||
raise AssertionError("first_row always matches")
|
||||
|
||||
|
||||
def analyze(path, warmup=15, fps=60, anchor="auto"):
|
||||
if not math.isfinite(warmup) or warmup < 0:
|
||||
raise ValueError(f"warmup must be a finite number of seconds >= 0, got {warmup}")
|
||||
if isinstance(fps, bool) or not isinstance(fps, int) or fps <= 0:
|
||||
raise ValueError(f"fps must be a positive integer, got {fps!r}")
|
||||
if anchor not in ANCHORS:
|
||||
raise ValueError(f"anchor must be one of {ANCHORS}, got {anchor!r}")
|
||||
rows, lost, truncated_tail = parse_trace(path)
|
||||
if not rows:
|
||||
raise TraceFormatError(f"{path}: no timing samples ({lost} trace_lost samples, no events)")
|
||||
rows.sort(key=lambda row: row["timestamp_ns"])
|
||||
start = rows[0]["timestamp_ns"]
|
||||
timing = timing_anchor(rows, anchor)
|
||||
native_ms = native_period_ms(rows)
|
||||
cutoff = timing["start_ns"] + warmup * 1_000_000_000
|
||||
measured = [row for row in rows if row["timestamp_ns"] >= cutoff]
|
||||
frames = collections.defaultdict(list)
|
||||
for row in rows:
|
||||
frames[row["frame_id"]].append(row)
|
||||
counts = collections.Counter(row["event"] for row in measured)
|
||||
stages = collections.defaultdict(list)
|
||||
for row in measured:
|
||||
if row["duration_ns"]:
|
||||
stages[row["event"]].append(row["duration_ns"] / NS_MS)
|
||||
drops = [row for row in measured if row["event"] in DROP_SIGNALS]
|
||||
drop_times = [row["timestamp_ns"] for row in drops]
|
||||
limited_ms = {}
|
||||
for row in rows:
|
||||
if row["event"] == "display_limited" and row["detail"]:
|
||||
limited_ms[row["frame_id"]] = max(limited_ms.get(row["frame_id"], 0),
|
||||
row["interval_ns"] / NS_MS or native_ms)
|
||||
|
||||
def evidence(frame_ids, window, attributed):
|
||||
timings = collections.defaultdict(float)
|
||||
pending = 0
|
||||
wake_late = 0
|
||||
signals = []
|
||||
window_drops = drops[bisect.bisect_left(drop_times, window[0]):bisect.bisect_right(drop_times, window[1])]
|
||||
for row in window_drops:
|
||||
attributed.add(id(row))
|
||||
signals.append(DROP_SIGNALS[row["event"]])
|
||||
for frame_id in frame_ids:
|
||||
for row in frames[frame_id]:
|
||||
timings[row["event"]] = max(timings[row["event"]], row["duration_ns"] / NS_MS)
|
||||
if row["event"] == "pipelines":
|
||||
pending = max(pending, row["detail"])
|
||||
if row["event"] == "pacer_wait" and row["duration_ns"] > NS_MS and row["deadline_ns"]:
|
||||
wake_late = max(wake_late, (row["timestamp_ns"] - row["deadline_ns"]) / NS_MS)
|
||||
for stage, threshold, label in [
|
||||
("surface_wait", 2, "surface_or_driver_wait"),
|
||||
("seal", 2, "interpolation_seal"),
|
||||
("pipeline_wait", 1, "pipeline_compilation_wait"),
|
||||
("worker_queue", 2, "render_worker_queue_wait"),
|
||||
("resource_wait", 2, "frame_resources_wait"),
|
||||
("replay_encode", 2, "interpolation_replay_encode"),
|
||||
("present_work", 2, "submit_or_present_cpu_work"),
|
||||
("produce", native_ms * 1.25, "producer_wall_time"),
|
||||
("game_busy", native_ms, "game_thread_busy_over_budget"),
|
||||
]:
|
||||
if timings[stage] > threshold:
|
||||
signals.append(label)
|
||||
if pending:
|
||||
signals.append("pipelines_pending")
|
||||
if wake_late > 1:
|
||||
signals.append("pacer_woke_late")
|
||||
for frame_id in frame_ids:
|
||||
for row in frames[frame_id]:
|
||||
if row["event"] in DROP_SIGNALS:
|
||||
attributed.add(id(row))
|
||||
signals.append(DROP_SIGNALS[row["event"]])
|
||||
if row["event"] == "display_limited":
|
||||
signals.append("display_refresh_limited")
|
||||
if row["event"] == "interp_health" and fps > NATIVE_FPS:
|
||||
health = row["detail"]
|
||||
if not health & (1 << 18):
|
||||
signals.append("producer_missed_retrace")
|
||||
if not health & (1 << 17):
|
||||
signals.append("interpolation_replay_unsafe")
|
||||
if not health & (1 << 16):
|
||||
signals.append("interpolation_ineligible")
|
||||
if row["event"] == "interp_matches" and fps > NATIVE_FPS:
|
||||
matchable, matches = row["detail"] >> 32, row["detail"] & 0xffffffff
|
||||
if matchable and matches * 100 < matchable * 55:
|
||||
signals.append("low_transform_match_coverage")
|
||||
signals = sorted(set(signals))
|
||||
return {"signals": signals or ["unattributed"], "stage_ms": dict(timings),
|
||||
"queued_pipelines": pending, "pacer_wake_late_ms": wake_late}
|
||||
|
||||
def cadence(real_only):
|
||||
presents = [row for row in rows if row["event"] in (
|
||||
("present_real",) if real_only else ("present_real", "present_interp"))]
|
||||
budget = native_ms if real_only or fps <= NATIVE_FPS else min(native_ms, 1000 / fps)
|
||||
tolerance = max(1, budget * .25)
|
||||
intervals, spikes, effective_spikes, lateness = [], [], [], []
|
||||
transitions = collections.defaultdict(list)
|
||||
attributed = set()
|
||||
limited_gaps = 0
|
||||
effective_budget = budget
|
||||
for previous, row in zip(presents, presents[1:]):
|
||||
if previous["timestamp_ns"] < cutoff:
|
||||
continue
|
||||
gap = (row["timestamp_ns"] - previous["timestamp_ns"]) / NS_MS
|
||||
intervals.append(gap)
|
||||
transitions[previous["event"] + "->" + row["event"]].append(gap)
|
||||
if row["deadline_ns"]:
|
||||
lateness.append(max(0, row["timestamp_ns"] - row["deadline_ns"]) / NS_MS)
|
||||
limit = max(limited_ms.get(previous["frame_id"], 0), limited_ms.get(row["frame_id"], 0))
|
||||
expected = max(budget, limit)
|
||||
limited_gaps += expected > budget
|
||||
effective_budget = max(effective_budget, expected)
|
||||
if gap > budget + tolerance:
|
||||
nearby = set()
|
||||
spike = {"seconds": (row["timestamp_ns"] - start) / 1e9,
|
||||
"frame_id": row["frame_id"], "kind": row["event"],
|
||||
"gap_ms": gap, "excess_ms": gap - budget,
|
||||
**evidence({previous["frame_id"], row["frame_id"]},
|
||||
(previous["timestamp_ns"], row["timestamp_ns"]), nearby)}
|
||||
spikes.append(spike)
|
||||
if gap > expected + max(1, expected * .25):
|
||||
effective_spikes.append({**spike, "excess_ms": gap - expected})
|
||||
attributed |= nearby
|
||||
worst = lambda found: sorted(found, key=lambda item: item["gap_ms"], reverse=True)[:20]
|
||||
counted = lambda found: dict(collections.Counter(
|
||||
signal for spike in found for signal in spike["signals"]))
|
||||
return {**distribution(intervals), "budget_ms": budget, "tolerance_ms": tolerance,
|
||||
"stutters": len(spikes), "signals": counted(spikes),
|
||||
"effective_budget_ms": effective_budget, "display_limited_gaps": limited_gaps,
|
||||
"effective_stutters": len(effective_spikes),
|
||||
"effective_signals": counted(effective_spikes),
|
||||
"transitions": {key: distribution(values) for key, values in transitions.items()},
|
||||
"deadline_lateness": distribution(lateness),
|
||||
"worst": worst(spikes), "effective_worst": worst(effective_spikes)}, attributed
|
||||
|
||||
real, _ = cadence(True)
|
||||
all_presentations, attributed = cadence(False)
|
||||
isolated = [row for row in drops if id(row) not in attributed]
|
||||
match_ratios = [100 * (row["detail"] & 0xffffffff) / (row["detail"] >> 32)
|
||||
for row in measured if row["event"] == "interp_matches" and row["detail"] >> 32]
|
||||
real_presents = counts["present_real"]
|
||||
limited_frames = len({row["frame_id"] for row in measured
|
||||
if row["event"] == "display_limited" and row["detail"]})
|
||||
limited_fraction = limited_frames / real_presents if real_presents else 0.0
|
||||
if fps <= NATIVE_FPS:
|
||||
status = "not_requested"
|
||||
elif not real_presents:
|
||||
status = "no_real_presents"
|
||||
elif limited_fraction >= DISPLAY_LIMITED_INVALID_FRACTION:
|
||||
status = "display_limited"
|
||||
elif not any(row["event"] == "interp_uniforms" and row["detail"] > 0 for row in measured):
|
||||
status = "no_transform_work"
|
||||
elif not counts["present_interp"]:
|
||||
status = "no_interpolated_presents"
|
||||
else:
|
||||
status = "ok"
|
||||
return {"trace": str(path), "warmup_seconds": warmup, "target_fps": fps,
|
||||
"timing_anchor": timing, "native_period_ms": native_ms,
|
||||
"native_period_source": "seal" if any(row["event"] == "seal" and row["interval_ns"]
|
||||
for row in rows) else "default_60hz",
|
||||
"interpolation": {"status": status, "target_valid": status in ("ok", "not_requested"),
|
||||
"display_limited": limited_fraction >= DISPLAY_LIMITED_INVALID_FRACTION,
|
||||
"display_limited_frames": limited_frames,
|
||||
"display_limited_fraction": limited_fraction,
|
||||
"interpolated_presents": counts["present_interp"]},
|
||||
"measured_seconds": max(0, (rows[-1]["timestamp_ns"] - cutoff) / 1e9),
|
||||
"lost_trace_samples": lost, "truncated_final_row": truncated_tail, "events": dict(counts),
|
||||
"isolated_drops": len(isolated),
|
||||
"isolated_drop_examples": [{"seconds": (row["timestamp_ns"] - start) / 1e9, "event": row["event"],
|
||||
"frame_id": row["frame_id"], "slot": row["slot"]}
|
||||
for row in isolated[:5]],
|
||||
"unassociated_game_timing_samples": sum(row["event"] == "game_busy" and row["frame_id"] == 0
|
||||
for row in measured),
|
||||
"real": real, "all_presentations": all_presentations,
|
||||
"stages": {key: distribution(values) for key, values in stages.items()},
|
||||
"transform_matches": {"frames": len(match_ratios),
|
||||
"low_match_frames": sum(value < 55 for value in match_ratios),
|
||||
"median_percent": percentile(match_ratios, .5),
|
||||
"p05_percent": percentile(match_ratios, .05)},
|
||||
"limitations": ["Present timestamps measure CPU surface submission, not physical scanout or GPU execution.",
|
||||
"Signals correlate slow stages with gaps; they do not prove causation.",
|
||||
"Producer wall time includes guest waits. Hidden-window results can differ from visible VSync.",
|
||||
"A forced stop can discard the final 100 ms of trace samples.",
|
||||
"stutters/signals use the requested fps budget; effective_* also accept deliberate "
|
||||
"display-limited suppression. Check interpolation.target_valid before comparing targets."]}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("trace", type=Path)
|
||||
parser.add_argument("--warmup", type=float, default=15)
|
||||
parser.add_argument("--fps", type=int, default=60)
|
||||
parser.add_argument("--anchor", choices=ANCHORS, default="auto",
|
||||
help="warmup start: auto uses the renderer's benchmark_ready event (first CPU racer "
|
||||
"initialized, independent of --fps), else the first real present")
|
||||
parser.add_argument("--output", type=Path)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
result = analyze(args.trace, args.warmup, args.fps, args.anchor)
|
||||
except (OSError, ValueError) as error:
|
||||
raise SystemExit(f"error: {error}")
|
||||
if args.output:
|
||||
args.output.write_text(json.dumps(result, indent=2), encoding="utf-8")
|
||||
for key in ("real", "all_presentations"):
|
||||
entry = result[key]
|
||||
print(f"{key}: {entry['count']} intervals, p99 {entry['p99_ms']:.3f} ms, "
|
||||
f"max {entry['max_ms']:.3f} ms, {entry['stutters']} requested / "
|
||||
f"{entry['effective_stutters']} effective stutters; {entry['effective_signals']}")
|
||||
interpolation = result["interpolation"]
|
||||
print(f"interpolation: {interpolation['status']}, anchor {result['timing_anchor']['used']}")
|
||||
print(f"late slots: {result['events'].get('drop_late', 0)}, "
|
||||
f"display drops: {result['events'].get('drop_display', 0)}, "
|
||||
f"isolated drops: {result['isolated_drops']}, trace loss: {result['lost_trace_samples']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,352 +0,0 @@
|
||||
"""Windows hidden-race benchmark with inherited affinity and isolated portable settings.
|
||||
|
||||
Every case gets its own portable directory, seeded from one pristine snapshot of the NAND and
|
||||
(optionally) the cache, so a case never inherits another case's changed cache or saves.
|
||||
"""
|
||||
import argparse
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
import itertools
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import tomllib
|
||||
|
||||
from analyze_stutters import analyze, trace_state
|
||||
|
||||
SUPPORTED_RESOLUTIONS = (0, 1, 1.5, 2, 3, 4, 6, 8)
|
||||
GRAPHICS_APIS = ("auto", "d3d12", "vulkan")
|
||||
FPS_TARGETS = (0, 120, 180)
|
||||
HELPER_COUNTS = ("auto", *map(str, range(7)))
|
||||
STARTUP_TIMEOUT = 60
|
||||
|
||||
|
||||
class ConfigError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def cpu_topology():
|
||||
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel.GetCurrentProcess.restype = wintypes.HANDLE
|
||||
kernel.GetProcessAffinityMask.argtypes = [wintypes.HANDLE, ctypes.POINTER(ctypes.c_size_t),
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
kernel.SetProcessAffinityMask.argtypes = [wintypes.HANDLE, ctypes.c_size_t]
|
||||
process, system = ctypes.c_size_t(), ctypes.c_size_t()
|
||||
handle = kernel.GetCurrentProcess()
|
||||
if not kernel.GetProcessAffinityMask(handle, ctypes.byref(process), ctypes.byref(system)):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
|
||||
# SYSTEM_LOGICAL_PROCESSOR_INFORMATION has an affinity mask, relationship enum,
|
||||
# and a 16-byte union. RelationProcessorCore (0) gives each core's SMT mask.
|
||||
class ProcessorInfo(ctypes.Structure):
|
||||
_fields_ = [("mask", ctypes.c_size_t), ("relationship", ctypes.c_int),
|
||||
("data", ctypes.c_ulonglong * 2)]
|
||||
size = wintypes.DWORD()
|
||||
kernel.GetLogicalProcessorInformation(None, ctypes.byref(size))
|
||||
buffer = ctypes.create_string_buffer(size.value)
|
||||
if not kernel.GetLogicalProcessorInformation(buffer, ctypes.byref(size)):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
cores = []
|
||||
for offset in range(0, size.value, ctypes.sizeof(ProcessorInfo)):
|
||||
info = ProcessorInfo.from_buffer(buffer, offset)
|
||||
available = info.mask & process.value
|
||||
if info.relationship == 0 and available:
|
||||
cores.append(available & -available) # one logical processor per physical core
|
||||
return kernel, handle, process.value, cores
|
||||
|
||||
|
||||
def quoted(value):
|
||||
# JSON basic strings are valid TOML except for raw DEL.
|
||||
return json.dumps(str(value), ensure_ascii=False).replace("\x7f", "\\u007f")
|
||||
|
||||
|
||||
def interpolation_helpers(core_count, requested):
|
||||
if requested != "auto":
|
||||
return int(requested)
|
||||
if not core_count:
|
||||
return None
|
||||
return min(core_count - 1, 6) if core_count > 2 else 0
|
||||
|
||||
|
||||
def parse_list(text, name, parse, allowed=None):
|
||||
"""Comma-separated matrix axis; rejects malformed, unsupported and duplicate entries."""
|
||||
values = []
|
||||
for item in text.split(","):
|
||||
try:
|
||||
values.append(parse(item.strip()))
|
||||
except ValueError:
|
||||
raise ValueError(f"--{name}: invalid entry {item!r}") from None
|
||||
if allowed is not None and (bad := [value for value in values if value not in allowed]):
|
||||
raise ValueError(f"--{name}: unsupported {bad}; allowed {list(allowed)}")
|
||||
if len(set(values)) != len(values):
|
||||
raise ValueError(f"--{name}: duplicate entries in {text!r}")
|
||||
return values
|
||||
|
||||
|
||||
def parse_helper(text):
|
||||
return text if text == "auto" else str(int(text))
|
||||
|
||||
|
||||
class SourceConfig:
|
||||
def __init__(self, dvd_root, retro_rewind_root, nand_root, graphics_api):
|
||||
self.dvd_root, self.retro_rewind_root = dvd_root, retro_rewind_root
|
||||
self.nand_root, self.graphics_api = nand_root, graphics_api
|
||||
|
||||
|
||||
def load_source_config(config_path):
|
||||
"""Reads and validates the user's Config.toml before anything is staged."""
|
||||
config_path = Path(config_path)
|
||||
try:
|
||||
with config_path.open("rb") as source:
|
||||
config = tomllib.load(source)
|
||||
except OSError as error:
|
||||
raise ConfigError(f"cannot read config {config_path}: {error}") from None
|
||||
except (tomllib.TOMLDecodeError, UnicodeDecodeError) as error:
|
||||
raise ConfigError(f"{config_path}: invalid TOML: {error}") from None
|
||||
|
||||
def table(name):
|
||||
value = config.get(name, {})
|
||||
if not isinstance(value, dict):
|
||||
raise ConfigError(f"{config_path}: [{name}] must be a table, got {type(value).__name__}")
|
||||
return value
|
||||
|
||||
paths, video = table("paths"), table("video")
|
||||
base = config_path.resolve().parent
|
||||
|
||||
def source_path(key, directory=False):
|
||||
value = paths.get(key)
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise ConfigError(f"{config_path}: paths.{key} must be a string, got {type(value).__name__}")
|
||||
path = Path(value)
|
||||
path = path if path.is_absolute() else (base / path).resolve()
|
||||
if not (path.is_dir() if directory else path.exists()):
|
||||
raise ConfigError(f"{config_path}: paths.{key} {'is not a directory' if directory else 'does not exist'}: {path}")
|
||||
return path
|
||||
|
||||
api = video.get("graphics_api", "auto")
|
||||
if not isinstance(api, str) or api not in GRAPHICS_APIS:
|
||||
raise ConfigError(f"{config_path}: video.graphics_api must be one of {list(GRAPHICS_APIS)}, got {api!r}")
|
||||
return SourceConfig(source_path("dvd_root"), source_path("retro_rewind_root"),
|
||||
source_path("nand_root", directory=True), api)
|
||||
|
||||
|
||||
def isolated_config_text(source, nand_root, fps, resolution, vsync):
|
||||
lines = ["[paths]"]
|
||||
for key in ("dvd_root", "retro_rewind_root"):
|
||||
if path := getattr(source, key):
|
||||
lines.append(f"{key} = {quoted(path)}")
|
||||
lines.append(f"nand_root = {quoted(nand_root)}")
|
||||
# No physical controller scans or presence/network traffic during measurements.
|
||||
# Keep the audio mixer running; --hidden retains normal audio behavior.
|
||||
lines += ["[controller]", "wii_remotes = false", "[network]", "enabled = false",
|
||||
"[discord]", "enabled = false", "[audio]", "volume = 0.0",
|
||||
"attenuate_music_when_media_plays = false",
|
||||
"[video]", "window_width = 854", "window_height = 480", 'display_mode = "windowed"',
|
||||
f"graphics_api = {quoted(source.graphics_api)}",
|
||||
f"resolution_multiplier = {float(resolution)}", f"frame_interpolation_fps = {fps}",
|
||||
f"vsync = {'true' if vsync else 'false'}", "show_fps = false"]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def wait_ready(process, trace, label):
|
||||
"""Waits for the renderer's benchmark_ready marker (first CPU racer initialized)."""
|
||||
deadline = time.monotonic() + STARTUP_TIMEOUT
|
||||
while True:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError(f"{label}: game exited during startup ({process.returncode}); see log")
|
||||
state = trace_state(trace) if trace.exists() else None
|
||||
if state and state["benchmark_ready_ns"] is not None:
|
||||
return state
|
||||
if time.monotonic() >= deadline:
|
||||
raise RuntimeError(f"{label}: no benchmark_ready trace event within {STARTUP_TIMEOUT}s of "
|
||||
"startup; the race did not start or the executable predates the marker; see log")
|
||||
time.sleep(.25)
|
||||
|
||||
|
||||
def stop_process(process, label):
|
||||
if process.poll() is not None:
|
||||
return
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError(f"{label}: game did not exit after terminate and kill") from None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--exe", type=Path, required=True)
|
||||
parser.add_argument("--config", type=Path,
|
||||
default=Path(os.environ.get("LOCALAPPDATA", ".")) / "WiiCompiled/Config.toml")
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--cpus", default="0,4", help="physical cores without SMT; 0 keeps full inherited affinity")
|
||||
parser.add_argument("--fps", default="0,120")
|
||||
parser.add_argument("--helpers", default="auto", help="auto or comma-separated counts 0..6")
|
||||
parser.add_argument("--seconds", type=float, default=60, help="measurement time, excluding warmup")
|
||||
parser.add_argument("--warmup", type=float, default=15)
|
||||
parser.add_argument("--repeats", type=int, default=1)
|
||||
parser.add_argument("--course", type=lambda value: int(value, 0), default=8)
|
||||
parser.add_argument("--resolution", type=float, default=1)
|
||||
parser.add_argument("--vsync", action="store_true")
|
||||
parser.add_argument("--no-shared-cache-affinity", action="store_true",
|
||||
help="keep all threads within the process mask, without additional cache-domain pinning")
|
||||
parser.add_argument("--cache-from", type=Path,
|
||||
help="snapshot this Cache directory once and seed every case from the snapshot; "
|
||||
"without it every case starts cold")
|
||||
parser.add_argument("--allow-display-limited", action="store_true",
|
||||
help="exit 0 even when a requested interpolation target was suppressed by the display")
|
||||
args = parser.parse_args()
|
||||
if os.name != "nt":
|
||||
parser.error("The runner requires Windows; the trace analyzer is cross-platform.")
|
||||
try:
|
||||
cpus = parse_list(args.cpus, "cpus", int)
|
||||
fps_targets = parse_list(args.fps, "fps", int, FPS_TARGETS)
|
||||
helpers = parse_list(args.helpers, "helpers", parse_helper, HELPER_COUNTS)
|
||||
except ValueError as error:
|
||||
parser.error(str(error))
|
||||
if not (math.isfinite(args.seconds) and args.seconds > 0 and math.isfinite(args.warmup) and args.warmup >= 0):
|
||||
parser.error("seconds must be finite and > 0; warmup must be finite and >= 0")
|
||||
if args.repeats < 1 or not 0 <= args.course <= 31:
|
||||
parser.error("invalid repeats or course")
|
||||
if not math.isfinite(args.resolution) or args.resolution not in SUPPORTED_RESOLUTIONS:
|
||||
parser.error(f"resolution must be one of {list(SUPPORTED_RESOLUTIONS)}")
|
||||
kernel, handle, original_mask, cores = cpu_topology()
|
||||
if any(value < 0 or value > len(cores) for value in cpus):
|
||||
parser.error(f"cpus must be between 0 and {len(cores)}")
|
||||
try:
|
||||
exe = args.exe.resolve(strict=True)
|
||||
source = load_source_config(args.config)
|
||||
except (OSError, ConfigError) as error:
|
||||
parser.error(str(error))
|
||||
output = args.output.resolve()
|
||||
if source.nand_root and output.is_relative_to(source.nand_root.resolve()):
|
||||
parser.error("--output must not be inside the source nand_root; the NAND snapshot would copy itself")
|
||||
cache_from = args.cache_from.resolve() if args.cache_from else None
|
||||
if cache_from and (not cache_from.is_dir() or output.is_relative_to(cache_from)
|
||||
or cache_from.is_relative_to(output)):
|
||||
parser.error("--cache-from must be an existing directory outside the output directory")
|
||||
if (output / "portable").exists() or (output / "seeds").exists():
|
||||
parser.error("output already contains portable/ or seeds/; use a fresh output directory")
|
||||
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
seeds = output / "seeds"
|
||||
nand_seed, cache_seed = seeds / "NAND", seeds / "Cache"
|
||||
if source.nand_root:
|
||||
shutil.copytree(source.nand_root, nand_seed, ignore=shutil.ignore_patterns("*.nandsafe.tmp"))
|
||||
else:
|
||||
nand_seed.mkdir(parents=True)
|
||||
if cache_from:
|
||||
shutil.copytree(cache_from, cache_seed)
|
||||
cache_mode = "seeded" if cache_from else "cold"
|
||||
print(f"cache: {cache_mode}" + (f" (every case copies the snapshot of {cache_from})" if cache_from
|
||||
else " (every case starts with an empty cache; results include shader/pipeline compilation)"),
|
||||
flush=True)
|
||||
runtime_files = [exe, *sorted(itertools.chain(exe.parent.glob("*.dll"), exe.parent.glob("*.bin"),
|
||||
exe.parent.glob("initial_pipeline_cache.db")))]
|
||||
|
||||
summaries, invalid = [], []
|
||||
for repeat, core_count, fps, helper in itertools.product(range(args.repeats), cpus, fps_targets, helpers):
|
||||
effective_helpers = interpolation_helpers(core_count, helper)
|
||||
label = f"r{repeat}-cpu{core_count}-fps{fps}-helpers{helper}"
|
||||
mask = original_mask if core_count == 0 else sum(cores[:core_count])
|
||||
trace = output / f"{label}.csv"
|
||||
stage = output / "portable" / label
|
||||
user = stage / "UserData"
|
||||
user.mkdir(parents=True)
|
||||
(stage / "portable.txt").write_text("stutter benchmark\n")
|
||||
for runtime_file in dict.fromkeys(runtime_files):
|
||||
shutil.copy2(runtime_file, stage / runtime_file.name)
|
||||
shutil.copytree(nand_seed, user / "NAND")
|
||||
if cache_from:
|
||||
shutil.copytree(cache_seed, user / "Cache")
|
||||
(user / "Config.toml").write_text(
|
||||
isolated_config_text(source, user / "NAND", fps, args.resolution, args.vsync), encoding="utf-8")
|
||||
print(f"running {label}, affinity=0x{mask:x}, cache={cache_mode}", flush=True)
|
||||
env = os.environ.copy()
|
||||
env["MKW_FRAME_TRACE"] = str(trace)
|
||||
env["MKW_PERF_STATS"] = "1"
|
||||
env.pop("MKW_INTERPOLATION_HELPERS", None)
|
||||
env.pop("MKW_SHARED_CACHE_AFFINITY", None)
|
||||
if args.no_shared_cache_affinity:
|
||||
env["MKW_SHARED_CACHE_AFFINITY"] = "0"
|
||||
if effective_helpers is not None:
|
||||
env["MKW_INTERPOLATION_HELPERS"] = str(effective_helpers)
|
||||
with (output / f"{label}.log").open("wb") as log:
|
||||
try:
|
||||
if not kernel.SetProcessAffinityMask(handle, mask):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
# The child inherits affinity at creation, before any worker pool sizes itself.
|
||||
process = subprocess.Popen([str(stage / exe.name), "--hidden", "--race-cpu",
|
||||
f"--race={args.course}"], cwd=stage, env=env,
|
||||
stdout=log, stderr=subprocess.STDOUT,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW)
|
||||
finally:
|
||||
if not kernel.SetProcessAffinityMask(handle, original_mask):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
forced_stop = False
|
||||
try:
|
||||
# The analyzer anchors warmup at the same event for every fps target, so
|
||||
# boot and loading never consume the warmup/measurement interval.
|
||||
started = time.monotonic()
|
||||
ready = wait_ready(process, trace, label)
|
||||
startup_seconds = time.monotonic() - started
|
||||
process.wait(timeout=args.warmup + args.seconds + 1)
|
||||
raise RuntimeError(f"{label}: game exited early ({process.returncode}); see log")
|
||||
except subprocess.TimeoutExpired:
|
||||
forced_stop = True
|
||||
finally:
|
||||
stop_process(process, label)
|
||||
result = analyze(trace, args.warmup, fps or 60)
|
||||
log_text = (output / f"{label}.log").read_text(encoding="utf-8", errors="replace")
|
||||
affinity_counts = {name: int(count) for name, count in re.findall(
|
||||
r"\[aurora::thread\] (.+?): (\d+) available CPUs after affinity", log_text)}
|
||||
if result["real"]["count"] == 0:
|
||||
raise RuntimeError(f"{label}: no real frame presentations after warmup; see log")
|
||||
interpolation = result["interpolation"]
|
||||
if fps and interpolation["status"] == "no_transform_work":
|
||||
raise RuntimeError(f"{label}: no transform interpolation work after warmup; verify the race loaded")
|
||||
if result["measured_seconds"] < args.seconds * .9:
|
||||
raise RuntimeError(f"{label}: measured only {result['measured_seconds']:.1f}s of {args.seconds}s; see log")
|
||||
if fps and not interpolation["target_valid"]:
|
||||
invalid.append(label)
|
||||
print(f" WARNING {label}: requested {fps} fps target is {interpolation['status']} "
|
||||
f"({interpolation['display_limited_fraction']:.0%} of frames display-limited); "
|
||||
"use effective_* statistics and do not compare it with a real 120/180 fps run", flush=True)
|
||||
result.update(label=label, affinity_mask=hex(mask), physical_core_limit=core_count,
|
||||
helpers=helper, repeat=repeat, forced_stop=forced_stop,
|
||||
effective_helpers=effective_helpers, host_logical_processors=os.cpu_count(),
|
||||
resolution=args.resolution, vsync=args.vsync, course=args.course,
|
||||
shared_cache_affinity=not args.no_shared_cache_affinity,
|
||||
cache_mode=cache_mode, cache_seed=str(cache_from) if cache_from else None,
|
||||
startup_seconds=startup_seconds, startup_ready=ready,
|
||||
thread_affinity_cpu_counts=affinity_counts,
|
||||
affinity_narrowed=bool(core_count and any(
|
||||
count < core_count for count in affinity_counts.values())),
|
||||
executable=str(exe), available_core_masks=[hex(core) for core in cores])
|
||||
(output / f"{label}.json").write_text(json.dumps(result, indent=2), encoding="utf-8")
|
||||
summaries.append(result)
|
||||
(output / "summary.json").write_text(json.dumps(summaries, indent=2), encoding="utf-8")
|
||||
for kind in ("real", "all_presentations"):
|
||||
entry = result[kind]
|
||||
print(f" {kind}: p99={entry['p99_ms']:.2f} max={entry['max_ms']:.2f} ms "
|
||||
f"stutters={entry['stutters']} effective={entry['effective_stutters']} "
|
||||
f"signals={entry['effective_signals']}", flush=True)
|
||||
print(f" interpolation={interpolation['status']} isolated_drops={result['isolated_drops']}", flush=True)
|
||||
if invalid and not args.allow_display_limited:
|
||||
raise SystemExit(f"display-limited or invalid interpolation targets: {', '.join(invalid)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,332 +0,0 @@
|
||||
import csv
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from analyze_stutters import COLUMNS, TraceFormatError, analyze, trace_state
|
||||
|
||||
HEADER = ",".join(COLUMNS) + "\n"
|
||||
|
||||
|
||||
class AnalyzeStuttersTests(unittest.TestCase):
|
||||
def analyze_rows(self, rows, fps=120, warmup=0, tail="", **options):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "trace.csv"
|
||||
with path.open("w", newline="") as stream:
|
||||
writer = csv.writer(stream)
|
||||
writer.writerow(["event", "timestamp_ns", "frame_id", "slot", "duration_ns",
|
||||
"deadline_ns", "interval_ns", "detail"])
|
||||
writer.writerows(rows)
|
||||
stream.write(tail)
|
||||
return analyze(path, warmup=warmup, fps=fps, **options)
|
||||
|
||||
def analyze_text(self, text, fps=60, warmup=0):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "trace.csv"
|
||||
path.write_bytes(text.encode("utf-8"))
|
||||
return analyze(path, warmup=warmup, fps=fps)
|
||||
|
||||
@staticmethod
|
||||
def present(ms, frame, real=True):
|
||||
at = round(ms * 1_000_000)
|
||||
return ["present_real" if real else "present_interp", at, frame, 0, 0, at, 8_333_333, 0]
|
||||
|
||||
@staticmethod
|
||||
def limited(ms, frame):
|
||||
return ["display_limited", round(ms * 1_000_000), frame, -1, 0, 0, 16_666_667, 1]
|
||||
|
||||
@staticmethod
|
||||
def seal(ms, frame, period_ns):
|
||||
return ["seal", round(ms * 1_000_000), frame, -1, 1_000_000, 0, period_ns, 0]
|
||||
|
||||
@staticmethod
|
||||
def ready(ms):
|
||||
return ["benchmark_ready", round(ms * 1_000_000), 0, -1, 0, 0, 0, 0]
|
||||
|
||||
def pal_rows(self, gaps_ms, start=100):
|
||||
rows, at = [], start
|
||||
for frame, gap in enumerate([0, *gaps_ms], 1):
|
||||
at += gap
|
||||
rows += [self.present(at, frame), self.seal(at - 1, frame, 20_000_000)]
|
||||
return rows
|
||||
|
||||
def display_limited_rows(self, frames=40):
|
||||
rows = []
|
||||
for i in range(frames):
|
||||
rows += [self.present(100 + i * 1000 / 60, i + 1), self.limited(100 + i * 1000 / 60, i + 1)]
|
||||
return rows
|
||||
|
||||
def test_regular_interpolation_has_no_stutters(self):
|
||||
rows = [self.present(100 + i * 1000 / 120, i // 2, i % 2 == 1) for i in range(40)]
|
||||
result = self.analyze_rows(rows)
|
||||
self.assertEqual(result["real"]["stutters"], 0)
|
||||
self.assertEqual(result["all_presentations"]["stutters"], 0)
|
||||
self.assertAlmostEqual(result["all_presentations"]["p99_ms"], 1000 / 120, places=5)
|
||||
|
||||
def test_missing_interpolation_slot_does_not_mean_real_frame_stutter(self):
|
||||
rows = [self.present(100, 1), self.present(108.333333, 2, False),
|
||||
self.present(116.666667, 2), self.present(133.333333, 3)]
|
||||
rows.append(["seal", 130_000_000, 3, -1, 4_000_000, 0, 0, 0])
|
||||
rows.append(["drop_late", 131_000_000, 3, 0, 0, 125_000_000, 8_333_333, 0])
|
||||
result = self.analyze_rows(rows)
|
||||
self.assertEqual(result["real"]["stutters"], 0)
|
||||
self.assertEqual(result["all_presentations"]["stutters"], 1)
|
||||
self.assertEqual(result["all_presentations"]["effective_stutters"], 1)
|
||||
self.assertIn("interpolation_seal", result["all_presentations"]["signals"])
|
||||
self.assertIn("late_slot_dropped", result["all_presentations"]["effective_signals"])
|
||||
self.assertEqual(result["events"]["drop_late"], 1)
|
||||
self.assertEqual(result["isolated_drops"], 0)
|
||||
|
||||
def test_real_frame_gap_and_driver_wait(self):
|
||||
rows = [self.present(100, 1), self.present(140, 2),
|
||||
["surface_wait", 140_000_001, 2, 0, 20_000_000, 0, 0, 0]]
|
||||
result = self.analyze_rows(rows, fps=60)
|
||||
self.assertEqual(result["real"]["stutters"], 1)
|
||||
self.assertIn("surface_or_driver_wait", result["real"]["signals"])
|
||||
self.assertEqual(result["interpolation"]["status"], "not_requested")
|
||||
self.assertTrue(result["interpolation"]["target_valid"])
|
||||
|
||||
def test_game_work_is_distinct_from_gx_producer_wall_time(self):
|
||||
rows = [self.present(100, 1), self.present(140, 2),
|
||||
["game_busy", 139_000_000, 2, -1, 30_000_000, 0, 40_000_000, 10_000_000],
|
||||
["produce", 138_000_000, 2, -1, 5_000_000, 0, 0, 0]]
|
||||
result = self.analyze_rows(rows, fps=60)
|
||||
self.assertIn("game_thread_busy_over_budget", result["real"]["signals"])
|
||||
self.assertNotIn("producer_wall_time", result["real"]["signals"])
|
||||
self.assertEqual(result["unassociated_game_timing_samples"], 0)
|
||||
|
||||
def test_warmup_excludes_cross_boundary_gap(self):
|
||||
rows = [self.present(100, 1), self.present(2100, 2), self.present(2116.666667, 3)]
|
||||
result = self.analyze_rows(rows, fps=60, warmup=1)
|
||||
self.assertEqual(result["real"]["count"], 1)
|
||||
self.assertEqual(result["real"]["stutters"], 0)
|
||||
|
||||
def test_loss_and_incomplete_final_write_are_visible(self):
|
||||
rows = [self.present(100, 1), self.present(116.666667, 2),
|
||||
["trace_lost", 0, 0, -1, 0, 0, 0, 3]]
|
||||
result = self.analyze_rows(rows, fps=60, tail="present_real,123")
|
||||
self.assertEqual(result["lost_trace_samples"], 3)
|
||||
self.assertTrue(result["truncated_final_row"])
|
||||
|
||||
def test_display_limit_is_reported_separately_from_cpu_signals(self):
|
||||
rows = [self.present(100, 1), self.present(116.666667, 2),
|
||||
["display_limited", 116_000_000, 2, -1, 0, 0, 16_666_667, 1]]
|
||||
result = self.analyze_rows(rows)
|
||||
self.assertIn("display_refresh_limited", result["all_presentations"]["signals"])
|
||||
|
||||
def test_average_fps_does_not_hide_alternating_intervals(self):
|
||||
rows = [self.present(100, 1), self.present(115, 2, False),
|
||||
self.present(116.666667, 2), self.present(131.666667, 3, False),
|
||||
self.present(133.333333, 3)]
|
||||
result = self.analyze_rows(rows)
|
||||
self.assertEqual(result["real"]["stutters"], 0)
|
||||
self.assertEqual(result["all_presentations"]["stutters"], 2)
|
||||
transitions = result["all_presentations"]["transitions"]
|
||||
self.assertAlmostEqual(transitions["present_real->present_interp"]["median_ms"], 15)
|
||||
self.assertAlmostEqual(transitions["present_interp->present_real"]["median_ms"], 1000 / 600, places=5)
|
||||
|
||||
def test_low_transform_coverage_is_visible_even_with_regular_presents(self):
|
||||
rows = [self.present(100, 1), self.present(108.333333, 2, False),
|
||||
self.present(116.666667, 2),
|
||||
["interp_matches", 116_000_000, 2, -1, 0, 0, 0, (100 << 32) | 20]]
|
||||
result = self.analyze_rows(rows)
|
||||
self.assertEqual(result["all_presentations"]["stutters"], 0)
|
||||
self.assertEqual(result["transform_matches"]["low_match_frames"], 1)
|
||||
self.assertEqual(result["transform_matches"]["median_percent"], 20)
|
||||
|
||||
def test_healthy_60hz_display_requested_at_120_is_not_a_stutter_regression(self):
|
||||
result = self.analyze_rows(self.display_limited_rows())
|
||||
self.assertEqual(result["real"]["stutters"], 0)
|
||||
self.assertGreater(result["all_presentations"]["stutters"], 0)
|
||||
self.assertEqual(result["all_presentations"]["effective_stutters"], 0)
|
||||
self.assertEqual(result["all_presentations"]["effective_signals"], {})
|
||||
self.assertGreater(result["all_presentations"]["display_limited_gaps"], 0)
|
||||
self.assertEqual(result["interpolation"]["status"], "display_limited")
|
||||
self.assertTrue(result["interpolation"]["display_limited"])
|
||||
self.assertFalse(result["interpolation"]["target_valid"])
|
||||
self.assertEqual(result["isolated_drops"], 0)
|
||||
|
||||
def test_real_gap_on_display_limited_stream_is_still_a_stutter(self):
|
||||
rows = self.display_limited_rows(10)
|
||||
rows += [self.present(100 + 9 * 1000 / 60 + 50, 11), self.limited(100 + 9 * 1000 / 60 + 50, 11)]
|
||||
result = self.analyze_rows(rows)
|
||||
self.assertEqual(result["all_presentations"]["effective_stutters"], 1)
|
||||
self.assertEqual(result["real"]["stutters"], 1)
|
||||
|
||||
def test_isolated_drop_late_survives_healthy_cadence(self):
|
||||
rows = [self.present(100 + i * 1000 / 120, i // 2, i % 2 == 1) for i in range(40)]
|
||||
rows.append(["drop_late", 150_000_000, 5, 0, 0, 149_000_000, 8_333_333, 0])
|
||||
result = self.analyze_rows(rows)
|
||||
self.assertEqual(result["all_presentations"]["effective_stutters"], 0)
|
||||
self.assertEqual(result["isolated_drops"], 1)
|
||||
self.assertEqual(result["isolated_drop_examples"][0]["event"], "drop_late")
|
||||
|
||||
def test_display_and_present_failures_map_to_signals_in_the_gap_window(self):
|
||||
rows = [self.present(100, 1), self.present(150, 4),
|
||||
["drop_display", 110_000_000, 2, 0, 0, 0, 0, 0],
|
||||
["present_unavailable", 120_000_000, 3, -1, 0, 0, 0, 0],
|
||||
["present_failed", 130_000_000, 3, -1, 0, 0, 0, 0]]
|
||||
result = self.analyze_rows(rows, fps=60)
|
||||
signals = result["all_presentations"]["effective_signals"]
|
||||
for name in ("display_slot_dropped", "present_unavailable", "present_failed"):
|
||||
self.assertIn(name, signals)
|
||||
self.assertEqual(result["isolated_drops"], 0)
|
||||
|
||||
def test_first_transform_task_anchors_warmup(self):
|
||||
rows = [self.present(100 + i * 1000 / 60, i) for i in range(600)]
|
||||
rows += [["interp_uniforms", 200_000_000, 0, -1, 1000, 0, 0, 0],
|
||||
["interp_uniforms", 5_000_000_000, 0, -1, 1000, 0, 0, 12]]
|
||||
result = self.analyze_rows(rows, warmup=1, anchor="first_transform")
|
||||
self.assertEqual(result["timing_anchor"]["used"], "first_transform")
|
||||
self.assertEqual(result["timing_anchor"]["start_ns"], 5_000_000_000)
|
||||
self.assertLess(result["measured_seconds"], 4.2)
|
||||
|
||||
def test_missing_transform_falls_back_and_is_flagged(self):
|
||||
rows = [self.present(100 + i * 1000 / 120, i // 2, i % 2 == 1) for i in range(40)]
|
||||
result = self.analyze_rows(rows)
|
||||
self.assertEqual(result["timing_anchor"]["used"], "first_real_present")
|
||||
self.assertFalse(result["timing_anchor"]["found"])
|
||||
self.assertEqual(result["interpolation"]["status"], "no_transform_work")
|
||||
self.assertTrue(self.analyze_rows(rows, anchor="first_row")["timing_anchor"]["found"])
|
||||
|
||||
def test_benchmark_ready_anchors_auto_for_every_fps(self):
|
||||
rows = [self.present(100 + i * 1000 / 60, i) for i in range(600)]
|
||||
rows += [self.ready(3000), ["interp_uniforms", 5_000_000_000, 0, -1, 1000, 0, 0, 12]]
|
||||
for fps in (60, 120, 180):
|
||||
with self.subTest(fps=fps):
|
||||
timing = self.analyze_rows(rows, fps=fps, warmup=1)["timing_anchor"]
|
||||
self.assertEqual((timing["used"], timing["start_ns"], timing["found"]),
|
||||
("benchmark_ready", 3_000_000_000, True))
|
||||
|
||||
def test_explicit_anchors_still_select_their_event(self):
|
||||
rows = [self.present(100 + i * 1000 / 60, i) for i in range(600)]
|
||||
rows += [self.ready(3000), ["interp_uniforms", 5_000_000_000, 0, -1, 1000, 0, 0, 12]]
|
||||
for anchor, start in (("first_transform", 5_000_000_000), ("first_row", 100_000_000),
|
||||
("first_real_present", 100_000_000), ("benchmark_ready", 3_000_000_000)):
|
||||
with self.subTest(anchor=anchor):
|
||||
timing = self.analyze_rows(rows, warmup=1, anchor=anchor)["timing_anchor"]
|
||||
self.assertEqual((timing["used"], timing["start_ns"], timing["found"]), (anchor, start, True))
|
||||
|
||||
def test_missing_benchmark_ready_falls_back_for_legacy_traces(self):
|
||||
rows = [self.present(100 + i * 1000 / 60, i) for i in range(20)]
|
||||
for anchor in ("auto", "benchmark_ready"):
|
||||
with self.subTest(anchor=anchor):
|
||||
timing = self.analyze_rows(rows, fps=60, anchor=anchor)["timing_anchor"]
|
||||
self.assertEqual((timing["used"], timing["found"]), ("first_real_present", False))
|
||||
|
||||
def test_transform_work_status_does_not_depend_on_the_anchor(self):
|
||||
rows = [self.ready(90), *[self.present(100 + i * 1000 / 120, i // 2, i % 2 == 1) for i in range(40)]]
|
||||
self.assertEqual(self.analyze_rows(rows)["interpolation"]["status"], "no_transform_work")
|
||||
rows.append(["interp_uniforms", 150_000_000, 0, -1, 1000, 0, 0, 12])
|
||||
result = self.analyze_rows(rows)
|
||||
self.assertEqual(result["timing_anchor"]["used"], "benchmark_ready")
|
||||
self.assertEqual(result["interpolation"]["status"], "ok")
|
||||
|
||||
def test_native_period_comes_from_seal_intervals_not_present_gaps(self):
|
||||
result = self.analyze_rows(self.pal_rows([17, 23, 19, 22, 18, 21]), fps=60)
|
||||
self.assertEqual(result["native_period_ms"], 20)
|
||||
self.assertEqual(result["native_period_source"], "seal")
|
||||
self.assertEqual(result["real"]["budget_ms"], 20)
|
||||
self.assertEqual(self.analyze_rows([self.present(100, 1)], fps=60)["native_period_source"], "default_60hz")
|
||||
|
||||
def test_pal_cadence_is_healthy_and_a_late_frame_is_not(self):
|
||||
result = self.analyze_rows(self.pal_rows([20] * 20), fps=60)
|
||||
self.assertEqual(result["real"]["stutters"], 0)
|
||||
rows = self.pal_rows([20] * 10 + [45] + [20] * 10)
|
||||
rows.append(["game_busy", 330_000_000, 12, -1, 19_000_000, 0, 20_000_000, 1_000_000])
|
||||
late = self.analyze_rows(rows, fps=60)["real"]
|
||||
self.assertEqual(late["stutters"], 1)
|
||||
self.assertEqual(late["worst"][0]["gap_ms"], 45)
|
||||
self.assertEqual(late["signals"], {"unattributed": 1})
|
||||
|
||||
def test_pal_budgets_scale_stage_thresholds(self):
|
||||
rows = self.pal_rows([20, 45])
|
||||
rows += [["game_busy", 164_000_000, 3, -1, 30_000_000, 0, 0, 0],
|
||||
["produce", 160_000_000, 3, -1, 22_000_000, 0, 0, 0]]
|
||||
signals = self.analyze_rows(rows, fps=60)["real"]["signals"]
|
||||
self.assertIn("game_thread_busy_over_budget", signals)
|
||||
self.assertNotIn("producer_wall_time", signals)
|
||||
|
||||
def test_pal_display_limit_without_interval_uses_the_native_period(self):
|
||||
rows = []
|
||||
for i in range(40):
|
||||
at = 100 + i * 20
|
||||
rows += [self.present(at, i + 1), self.seal(at - 1, i + 1, 20_000_000),
|
||||
["display_limited", at * 1_000_000, i + 1, -1, 0, 0, 0, 1]]
|
||||
result = self.analyze_rows(rows, fps=120)
|
||||
self.assertEqual(result["all_presentations"]["budget_ms"], 1000 / 120)
|
||||
self.assertEqual(result["all_presentations"]["effective_budget_ms"], 20)
|
||||
self.assertEqual(result["all_presentations"]["effective_stutters"], 0)
|
||||
self.assertEqual(result["interpolation"]["status"], "display_limited")
|
||||
|
||||
def test_oversized_csv_field_is_a_line_numbered_format_error(self):
|
||||
text = HEADER + "present_real,1,1,0,0,0,0,0\n" + "present_real,2,2,0,0,0,0," + "9" * 200_000 + "\n"
|
||||
with self.assertRaisesRegex(TraceFormatError, r"line 3.*field larger"):
|
||||
self.analyze_text(text)
|
||||
|
||||
def test_overlong_integer_is_a_line_numbered_format_error(self):
|
||||
with self.assertRaisesRegex(TraceFormatError, r"line 2.*'detail' is not an integer"):
|
||||
self.analyze_text(HEADER + "present_real,1,1,0,0,0,0," + "9" * 5000 + "\n")
|
||||
|
||||
def test_invalid_arguments_are_rejected(self):
|
||||
rows = [self.present(100, 1)]
|
||||
for options in ({"warmup": float("nan")}, {"warmup": -1}, {"fps": 0}, {"fps": 60.5}):
|
||||
with self.assertRaises(ValueError, msg=str(options)):
|
||||
self.analyze_rows(rows, **{"fps": 60, **options})
|
||||
with self.assertRaises(ValueError):
|
||||
self.analyze_rows(rows, anchor="bogus")
|
||||
|
||||
def test_header_problems_name_line_one(self):
|
||||
cases = {
|
||||
"duplicate": (HEADER.strip() + ",detail\n", r"line 1.*duplicate columns \['detail'\]"),
|
||||
"missing": (HEADER.replace(",detail", ""), r"line 1.*missing columns \['detail'\]"),
|
||||
"foreign": ("a,b,c\n1,2,3\n", r"line 1.*missing columns.*unexpected columns \['a', 'b', 'c'\]"),
|
||||
}
|
||||
for name, (text, pattern) in cases.items():
|
||||
with self.subTest(name), self.assertRaisesRegex(TraceFormatError, pattern):
|
||||
self.analyze_text(text)
|
||||
|
||||
def test_row_problems_name_the_physical_line(self):
|
||||
cases = {
|
||||
"non-integer": ("present_real,1,1,0,0,0,0,0\npresent_real,x,2,0,0,0,0,0\npresent_real,3,3,0,0,0,0,0\n",
|
||||
r"line 3.*'timestamp_ns'.*'x'"),
|
||||
"short": ("present_real,1,1\npresent_real,2,2,0,0,0,0,0\n", r"line 2.*expected 8 fields, got 3"),
|
||||
"negative": ("present_real,1,1,0,-5,0,0,0\npresent_real,2,2,0,0,0,0,0\n",
|
||||
r"line 2.*'duration_ns'.*negative"),
|
||||
"blank": ("present_real,1,1,0,0,0,0,0\n\npresent_real,2,2,0,0,0,0,0\n", r"line 3.*got 0"),
|
||||
"empty event": (",1,1,0,0,0,0,0\n", r"line 2.*empty event"),
|
||||
}
|
||||
for name, (rows, pattern) in cases.items():
|
||||
with self.subTest(name), self.assertRaisesRegex(TraceFormatError, pattern):
|
||||
self.analyze_text(HEADER + rows)
|
||||
|
||||
def test_trace_without_timing_samples_is_rejected(self):
|
||||
for name, text in {"lost only": HEADER + "trace_lost,0,0,-1,0,0,0,3\n", "header only": HEADER}.items():
|
||||
with self.subTest(name), self.assertRaisesRegex(TraceFormatError, "no timing samples"):
|
||||
self.analyze_text(text)
|
||||
for name, text in {"empty": "", "partial header": "event,timestamp_ns"}.items():
|
||||
with self.subTest(name), self.assertRaisesRegex(TraceFormatError, "no complete header"):
|
||||
self.analyze_text(text)
|
||||
|
||||
def test_truncated_final_row_is_ignored_even_when_malformed(self):
|
||||
result = self.analyze_text(HEADER + "present_real,100,1,0,0,0,0,0\npresent_real,2x")
|
||||
self.assertTrue(result["truncated_final_row"])
|
||||
self.assertEqual(result["events"]["present_real"], 1)
|
||||
|
||||
def test_trace_state_reports_readiness(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "trace.csv"
|
||||
path.write_bytes(b"event,timestamp_ns")
|
||||
self.assertIsNone(trace_state(path))
|
||||
path.write_bytes((HEADER + "present_real,5,1,0,0,0,0,0\ninterp_uniforms,9,0,-1,1,0,0,0\n"
|
||||
"interp_uniforms,11,0,-1,1,0,0,4\ndisplay_limited,12,1,-1,0,0,0,1\n"
|
||||
"present_real,13").encode())
|
||||
self.assertEqual(trace_state(path), {"benchmark_ready_ns": None, "real_present_ns": 5,
|
||||
"transform_ns": 11, "display_limited": True})
|
||||
path.write_bytes((HEADER + "present_real,5,1,0,0,0,0,0\nbenchmark_ready,7,0,-1,0,0,0,0\n"
|
||||
"benchmark_ready,8,0,-1,0,0,0,0\n").encode())
|
||||
self.assertEqual(trace_state(path)["benchmark_ready_ns"], 7)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,163 +0,0 @@
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import tomllib
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import benchmark_stutters
|
||||
from benchmark_stutters import (ConfigError, SourceConfig, isolated_config_text, load_source_config,
|
||||
interpolation_helpers, parse_helper, parse_list, quoted, stop_process,
|
||||
wait_ready)
|
||||
from analyze_stutters import COLUMNS
|
||||
|
||||
HEADER = ",".join(COLUMNS) + "\n"
|
||||
|
||||
|
||||
class QuotedTests(unittest.TestCase):
|
||||
def test_unicode_and_control_characters_roundtrip_through_toml(self):
|
||||
for value in ["C:\\Users\\Jürgen\\日本語\\Wii", 'a"b', "tab\there", "del\x7fchar", "\u2028 end", ""]:
|
||||
with self.subTest(value=value):
|
||||
self.assertEqual(tomllib.loads(f"x = {quoted(value)}")["x"], value)
|
||||
|
||||
def test_isolated_config_roundtrips_paths_and_video_settings(self):
|
||||
source = SourceConfig(Path("C:/Spiele/Mario Kart é"), Path("C:/RR/日本"), None, "vulkan")
|
||||
text = isolated_config_text(source, Path("C:/Out/ünï/NAND"), 120, 1.5, True)
|
||||
config = tomllib.loads(text)
|
||||
self.assertEqual(config["paths"]["dvd_root"], str(source.dvd_root))
|
||||
self.assertEqual(config["paths"]["retro_rewind_root"], str(source.retro_rewind_root))
|
||||
self.assertEqual(config["paths"]["nand_root"], str(Path("C:/Out/ünï/NAND")))
|
||||
self.assertEqual(config["video"]["frame_interpolation_fps"], 120)
|
||||
self.assertEqual(config["video"]["resolution_multiplier"], 1.5)
|
||||
self.assertEqual(config["video"]["graphics_api"], "vulkan")
|
||||
self.assertTrue(config["video"]["vsync"])
|
||||
self.assertEqual(config["network"], {"enabled": False})
|
||||
|
||||
def test_optional_paths_are_omitted(self):
|
||||
config = tomllib.loads(isolated_config_text(SourceConfig(None, None, None, "auto"), Path("N"), 0, 1, False))
|
||||
self.assertEqual(config["paths"], {"nand_root": "N"})
|
||||
|
||||
|
||||
class ParseListTests(unittest.TestCase):
|
||||
def test_valid_lists_keep_order(self):
|
||||
self.assertEqual(parse_list("4, 0,2", "cpus", int), [4, 0, 2])
|
||||
self.assertEqual(parse_list("auto,2", "helpers", parse_helper, ("auto", "2")), ["auto", "2"])
|
||||
|
||||
def test_rejects_duplicates_including_equivalent_spellings(self):
|
||||
for text, parse in [("0,0", int), ("1,01", int)]:
|
||||
with self.subTest(text), self.assertRaisesRegex(ValueError, "duplicate"):
|
||||
parse_list(text, "cpus", parse)
|
||||
with self.assertRaisesRegex(ValueError, "duplicate"):
|
||||
parse_list("auto,0,00", "helpers", parse_helper, ("auto", "0"))
|
||||
|
||||
def test_rejects_malformed_and_unsupported_entries(self):
|
||||
for text in ("1,", "", "a"):
|
||||
with self.subTest(text), self.assertRaisesRegex(ValueError, "invalid entry"):
|
||||
parse_list(text, "cpus", int)
|
||||
with self.assertRaisesRegex(ValueError, "unsupported"):
|
||||
parse_list("0,60", "fps", int, (0, 120, 180))
|
||||
with self.assertRaisesRegex(ValueError, "unsupported"):
|
||||
parse_list("7", "helpers", parse_helper, ("auto", "0"))
|
||||
|
||||
|
||||
class SourceConfigTests(unittest.TestCase):
|
||||
def load(self, text, create=()):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
for name in create:
|
||||
(root / name).mkdir()
|
||||
path = root / "Config.toml"
|
||||
path.write_text(text.replace("{root}", quoted(root)[1:-1]), encoding="utf-8")
|
||||
return load_source_config(path), root
|
||||
|
||||
def test_relative_paths_resolve_against_the_config_directory(self):
|
||||
source, root = self.load('[paths]\ndvd_root = "game"\nnand_root = "nand"\n', create=("game", "nand"))
|
||||
self.assertEqual(source.dvd_root, (root / "game").resolve())
|
||||
self.assertEqual(source.nand_root, (root / "nand").resolve())
|
||||
self.assertEqual(source.graphics_api, "auto")
|
||||
|
||||
def test_unicode_directory_names_roundtrip(self):
|
||||
name = "Wii é日本"
|
||||
source, root = self.load('[paths]\ndvd_root = "{root}\\\\' + name + '"\n', create=(name,))
|
||||
self.assertEqual(source.dvd_root, root / name)
|
||||
|
||||
def test_empty_and_missing_values_are_unset(self):
|
||||
source, _ = self.load('[paths]\ndvd_root = ""\n')
|
||||
self.assertIsNone(source.dvd_root)
|
||||
self.assertIsNone(source.nand_root)
|
||||
|
||||
def test_odd_config_types_and_values_are_rejected(self):
|
||||
cases = {
|
||||
"paths not table": ('paths = "x"\n', "\\[paths\\] must be a table"),
|
||||
"video not table": ("video = 3\n", "\\[video\\] must be a table"),
|
||||
"path not string": ("[paths]\ndvd_root = 5\n", "paths.dvd_root must be a string"),
|
||||
"path list": ('[paths]\nnand_root = ["a"]\n', "paths.nand_root must be a string"),
|
||||
"missing path": ('[paths]\ndvd_root = "nope"\n', "does not exist"),
|
||||
"api not string": ("[video]\ngraphics_api = 3\n", "graphics_api"),
|
||||
"api unsupported": ('[video]\ngraphics_api = "dx11"\n', "graphics_api"),
|
||||
"invalid toml": ("[paths\n", "invalid TOML"),
|
||||
}
|
||||
for name, (text, pattern) in cases.items():
|
||||
with self.subTest(name), self.assertRaisesRegex(ConfigError, pattern):
|
||||
self.load(text)
|
||||
|
||||
def test_nand_root_must_be_a_directory(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / "nand").write_text("file")
|
||||
(root / "Config.toml").write_text('[paths]\nnand_root = "nand"\n')
|
||||
with self.assertRaisesRegex(ConfigError, "not a directory"):
|
||||
load_source_config(root / "Config.toml")
|
||||
|
||||
def test_missing_config_file_is_a_config_error(self):
|
||||
with tempfile.TemporaryDirectory() as directory, self.assertRaisesRegex(ConfigError, "cannot read config"):
|
||||
load_source_config(Path(directory) / "missing.toml")
|
||||
|
||||
|
||||
class HelperCountTests(unittest.TestCase):
|
||||
def test_limited_runs_use_the_requested_cpu_capacity(self):
|
||||
for count, expected in ((1, 0), (2, 0), (3, 2), (4, 3), (12, 6)):
|
||||
with self.subTest(count=count):
|
||||
self.assertEqual(interpolation_helpers(count, "auto"), expected)
|
||||
|
||||
def test_full_cpu_runs_keep_the_runtime_default(self):
|
||||
self.assertIsNone(interpolation_helpers(0, "auto"))
|
||||
|
||||
def test_explicit_helper_controls_are_preserved(self):
|
||||
self.assertEqual(interpolation_helpers(4, "6"), 6)
|
||||
self.assertEqual(interpolation_helpers(4, "0"), 0)
|
||||
|
||||
|
||||
class WaitReadyTests(unittest.TestCase):
|
||||
@staticmethod
|
||||
def running():
|
||||
return mock.Mock(**{"poll.return_value": None})
|
||||
|
||||
def test_requires_benchmark_ready_regardless_of_other_events(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
trace = Path(directory) / "trace.csv"
|
||||
trace.write_text(HEADER + "present_real,5,1,0,0,0,0,0\ninterp_uniforms,9,0,-1,1,0,0,4\n")
|
||||
with mock.patch.object(benchmark_stutters, "STARTUP_TIMEOUT", 0), \
|
||||
self.assertRaisesRegex(RuntimeError, "no benchmark_ready"):
|
||||
wait_ready(self.running(), trace, "case")
|
||||
trace.write_text(HEADER + "benchmark_ready,7,0,-1,0,0,0,0\n")
|
||||
self.assertEqual(wait_ready(self.running(), trace, "case")["benchmark_ready_ns"], 7)
|
||||
|
||||
|
||||
class StopProcessTests(unittest.TestCase):
|
||||
def test_kills_after_terminate_times_out(self):
|
||||
process = mock.Mock(**{"poll.return_value": None})
|
||||
process.wait.side_effect = [subprocess.TimeoutExpired("game", 10), 0]
|
||||
stop_process(process, "case")
|
||||
process.terminate.assert_called_once()
|
||||
process.kill.assert_called_once()
|
||||
|
||||
def test_unkillable_process_is_a_clear_failure(self):
|
||||
process = mock.Mock(**{"poll.return_value": None})
|
||||
process.wait.side_effect = subprocess.TimeoutExpired("game", 10)
|
||||
with self.assertRaisesRegex(RuntimeError, "did not exit after terminate and kill"):
|
||||
stop_process(process, "case")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -139,15 +139,6 @@ TranslationOptions WithProjectFrontEndPolicy(
|
||||
LeafInliningBlockedTargets = leafInliningBlockedTargets.Value
|
||||
};
|
||||
string? translateModInputCacheDirectory = null;
|
||||
// translate-mod reads the same (large) base metadata from several places.
|
||||
var baseMetadataCache = new Dictionary<string, BaseTranslationOutputMetadata>(StringComparer.OrdinalIgnoreCase);
|
||||
BaseTranslationOutputMetadata ReadBaseMetadataCached(string path)
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
if (!baseMetadataCache.TryGetValue(fullPath, out var metadata))
|
||||
baseMetadataCache[fullPath] = metadata = BaseTranslationOutputMetadataFile.Read(fullPath);
|
||||
return metadata;
|
||||
}
|
||||
string? translateModPublishedOutputDirectory = null;
|
||||
List<AddressRange>? _executableRanges = null;
|
||||
// Callback discovery now runs on the discovery worker pool, so the range cache
|
||||
@@ -481,6 +472,10 @@ int RunTranslateRecursive(string[] argsTail)
|
||||
var pruneStale = HasFlag(argsTail, "--prune-stale");
|
||||
var threadsOption = OptionValue(argsTail, "--threads");
|
||||
var maxThreads = threadsOption != null ? Math.Max(1, ParseInt(threadsOption)) : Math.Max(1, Environment.ProcessorCount);
|
||||
// Discovery batches: cheap front-end-only work items, so the batch is sized
|
||||
// generously to keep queue overhead off the hot path. Deliberately larger
|
||||
// than the mod emission wave below, which carries whole C++ emissions.
|
||||
var batchSize = Math.Max(64, maxThreads * 8);
|
||||
Console.WriteLine($"[translator] Using up to {maxThreads} worker thread(s).");
|
||||
|
||||
// Log start time
|
||||
@@ -498,6 +493,7 @@ int RunTranslateRecursive(string[] argsTail)
|
||||
// callees are decoded separately and never appear here). Feeds the
|
||||
// fall-through interior-entry detection for the base manifest.
|
||||
var translatedFlowCoverage = new Dictionary<uint, uint[]>();
|
||||
var gqrUnknownEntryRoots = new HashSet<uint> { startAddr };
|
||||
var translatedFunctionEnds = new Dictionary<uint, uint>();
|
||||
// Every map entry is a function start, so the whole boundary set is known
|
||||
// before the first decode instead of accumulating as discovery proceeds.
|
||||
@@ -569,15 +565,13 @@ int RunTranslateRecursive(string[] argsTail)
|
||||
|
||||
void ProcessQueue(bool speculative = false)
|
||||
{
|
||||
// Each round decodes the whole frontier: the output depends only on the final
|
||||
// boundary set (boundary repair re-decodes stale owners), never on batching.
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var batch = new List<(uint Address, int Depth, string Name)>(queue.Count);
|
||||
var batch = new List<(uint Address, int Depth, string Name)>(batchSize);
|
||||
var batchAddresses = new HashSet<uint>();
|
||||
var candidates = QueueBatch.Dequeue(
|
||||
queue,
|
||||
queue.Count,
|
||||
batchSize,
|
||||
next => ShouldSkip(next) || !batchAddresses.Add(next.addr));
|
||||
foreach (var next in candidates)
|
||||
{
|
||||
@@ -604,19 +598,23 @@ int RunTranslateRecursive(string[] argsTail)
|
||||
}
|
||||
}
|
||||
|
||||
// Everything here is speculative, so a "call" decoded from an unreached map entry
|
||||
// isn't proven code; treat failures as soft, not fatal.
|
||||
// Batches drain one at a time so a callee of a prior speculative entry arrives with a known
|
||||
// caller/GQR state instead of as another unknown root. Everything here is speculative, so a
|
||||
// "call" decoded from an unreached map entry isn't proven code; treat failures as soft, not fatal.
|
||||
void ProcessSpeculativeSeeds()
|
||||
{
|
||||
while (speculativeSeeds.Count > 0)
|
||||
{
|
||||
var batch = new List<(uint Address, int Depth, string Name)>(speculativeSeeds.Count);
|
||||
var batch = new List<(uint Address, int Depth, string Name)>(batchSize);
|
||||
var candidates = QueueBatch.Dequeue(
|
||||
speculativeSeeds,
|
||||
speculativeSeeds.Count,
|
||||
batchSize,
|
||||
address => translated.Contains(address) || !visited.Add(address));
|
||||
foreach (var address in candidates)
|
||||
{
|
||||
// Nothing calls this from the translated set, so its entry
|
||||
// GQR state is unconstrained.
|
||||
gqrUnknownEntryRoots.Add(address);
|
||||
batch.Add((address, 0, ResolveFunctionName(address, preferred: null, $"func_{address:X8}")));
|
||||
}
|
||||
|
||||
@@ -654,42 +652,22 @@ int RunTranslateRecursive(string[] argsTail)
|
||||
// decoded out of one - is dropped with a counted warning.
|
||||
void TranslateBatch(List<(uint Address, int Depth, string Name)> batch, bool speculative = false)
|
||||
{
|
||||
var metrics = new TranslationMetrics?[batch.Count];
|
||||
var entryPoints = new uint[batch.Count];
|
||||
var discoveryTargets = new uint[batch.Count][];
|
||||
var swallowedSiblingEntries = new uint[batch.Count][];
|
||||
var summaries = new GqrFunctionSummary[batch.Count];
|
||||
var functionEnds = new uint[batch.Count];
|
||||
var flowCoverage = new uint[batch.Count][];
|
||||
|
||||
// Everything kept from a decode is derived in the decode's own pass, so the full
|
||||
// result is never retained; the few results a newly revealed boundary invalidates
|
||||
// are simply decoded again. Commits stay serial and in batch order.
|
||||
void Decode(int i, bool findSwallowedSiblings)
|
||||
{
|
||||
var result = Attempt(batch[i], speculative);
|
||||
metrics[i] = result?.Metrics;
|
||||
if (result is null)
|
||||
{
|
||||
canonicalIrStore.Remove(batch[i].Address);
|
||||
return;
|
||||
}
|
||||
entryPoints[i] = result.EntryPoint;
|
||||
if (findSwallowedSiblings)
|
||||
swallowedSiblingEntries[i] = DiscoverSwallowedSiblingEntries(result).ToArray();
|
||||
discoveryTargets[i] = DiscoverDiscoveryTranslationTargets(result).ToArray();
|
||||
summaries[i] = GqrFunctionSummary.Create(result.Ssa.Function);
|
||||
canonicalIrStore.Put(batch[i].Address, result.Ssa.Function);
|
||||
functionEnds[i] = GetFunctionInstructionEnd(result);
|
||||
flowCoverage[i] = CoalesceInstructionRanges(result);
|
||||
}
|
||||
|
||||
IndexedParallel.For(batch.Count, parallelOptions, i => Decode(i, findSwallowedSiblings: true));
|
||||
var results = new FunctionDiscoveryResult?[batch.Count];
|
||||
IndexedParallel.For(batch.Count, parallelOptions, i => results[i] = Attempt(batch[i], speculative));
|
||||
|
||||
// Re-run only translations that swallowed a newly revealed sibling entry, or direct
|
||||
// branches could bypass a mod overlay registered there. The dedup filter keeps first sighting.
|
||||
var newlyKnownEntries = Enumerable.Range(0, batch.Count)
|
||||
.Where(i => metrics[i] is not null)
|
||||
// branches could bypass a mod overlay registered there. Both scans are pure per-result
|
||||
// so they run on the same worker pool; the dedup filter below keeps first sighting.
|
||||
var discoveryTargets = new uint[results.Length][];
|
||||
var swallowedSiblingEntries = new uint[results.Length][];
|
||||
IndexedParallel.For(results.Length, parallelOptions, i =>
|
||||
{
|
||||
if (results[i] is not { } result) return;
|
||||
discoveryTargets[i] = DiscoverDiscoveryTranslationTargets(result).ToArray();
|
||||
swallowedSiblingEntries[i] = DiscoverSwallowedSiblingEntries(result).ToArray();
|
||||
});
|
||||
var newlyKnownEntries = Enumerable.Range(0, results.Length)
|
||||
.Where(i => results[i] is not null)
|
||||
.SelectMany(i => discoveryTargets[i].Concat(swallowedSiblingEntries[i]))
|
||||
.Where(knownBaseFunctionEntryPoints.Add)
|
||||
.ToHashSet();
|
||||
@@ -697,39 +675,49 @@ int RunTranslateRecursive(string[] argsTail)
|
||||
{
|
||||
// The boundary set is final for this batch once the filter above has run,
|
||||
// so the affected re-translations are independent of one another.
|
||||
var sortedNewEntries = newlyKnownEntries.Order().ToArray();
|
||||
var affected = Enumerable.Range(0, batch.Count)
|
||||
.Where(i => metrics[i] is not null &&
|
||||
CoversInteriorInstruction(flowCoverage[i], entryPoints[i], sortedNewEntries))
|
||||
.ToArray();
|
||||
IndexedParallel.For(affected.Length, parallelOptions, k => Decode(affected[k], findSwallowedSiblings: false));
|
||||
var affected = new List<int>();
|
||||
for (var i = 0; i < results.Length; i++)
|
||||
{
|
||||
if (results[i] is not { } result) continue;
|
||||
var entry = result.EntryPoint;
|
||||
if (result.Instructions.Any(instruction =>
|
||||
instruction.Address != entry && newlyKnownEntries.Contains(instruction.Address)))
|
||||
{
|
||||
affected.Add(i);
|
||||
}
|
||||
}
|
||||
|
||||
IndexedParallel.For(affected.Count, parallelOptions, k =>
|
||||
{
|
||||
var i = affected[k];
|
||||
results[i] = Attempt(batch[i], speculative);
|
||||
if (results[i] is { } result)
|
||||
{
|
||||
discoveryTargets[i] = DiscoverDiscoveryTranslationTargets(result).ToArray();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The compact GQR projection is a pure walk of the final SSA graph and the
|
||||
// canonical store serializes its own dictionary, so both move off the serial
|
||||
// commit. Every address in a batch is distinct and untranslated, so the store
|
||||
// sees one Put per key regardless of completion order.
|
||||
var summaries = new GqrFunctionSummary[results.Length];
|
||||
IndexedParallel.For(results.Length, parallelOptions, i =>
|
||||
{
|
||||
if (results[i] is not { } result) return;
|
||||
summaries[i] = GqrFunctionSummary.Create(result.Ssa.Function);
|
||||
canonicalIrStore.Put(batch[i].Address, result.Ssa.Function);
|
||||
});
|
||||
|
||||
// Update state sequentially (thread-safe)
|
||||
for (var i = 0; i < batch.Count; i++)
|
||||
{
|
||||
if (metrics[i] is not { } functionMetrics) continue;
|
||||
CommitResult(batch[i], functionMetrics, discoveryTargets[i], summaries[i], functionEnds[i], flowCoverage[i]);
|
||||
if (results[i] is not { } result) continue;
|
||||
CommitResult(batch[i], result, discoveryTargets[i], summaries[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// True when an instruction of the coalesced [start, end) coverage, other than the
|
||||
// entry, starts at one of the sorted addresses.
|
||||
static bool CoversInteriorInstruction(uint[] coverage, uint entry, uint[] sortedAddresses)
|
||||
{
|
||||
for (var r = 0; r < coverage.Length; r += 2)
|
||||
{
|
||||
var index = Array.BinarySearch(sortedAddresses, coverage[r]);
|
||||
for (index = index >= 0 ? index : ~index;
|
||||
index < sortedAddresses.Length && sortedAddresses[index] < coverage[r + 1];
|
||||
index++)
|
||||
{
|
||||
var address = sortedAddresses[index];
|
||||
if (address != entry && (address & 3u) == 0) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
FunctionDiscoveryResult? Attempt((uint Address, int Depth, string Name) work, bool speculative)
|
||||
{
|
||||
if (!speculative)
|
||||
@@ -785,18 +773,16 @@ int RunTranslateRecursive(string[] argsTail)
|
||||
|
||||
void CommitResult(
|
||||
(uint Address, int Depth, string Name) work,
|
||||
TranslationMetrics functionMetrics,
|
||||
FunctionDiscoveryResult result,
|
||||
IReadOnlyList<uint> discoveryTargets,
|
||||
GqrFunctionSummary summary,
|
||||
uint functionEnd,
|
||||
uint[] flowCoverage)
|
||||
GqrFunctionSummary summary)
|
||||
{
|
||||
// File writing moved to parallel section above
|
||||
totals.Add(functionMetrics);
|
||||
totals.Add(result.Metrics);
|
||||
count++;
|
||||
translated.Add(work.Address);
|
||||
translatedFunctionEnds[work.Address] = functionEnd;
|
||||
translatedFlowCoverage[work.Address] = flowCoverage;
|
||||
translatedFunctionEnds[work.Address] = GetFunctionInstructionEnd(result);
|
||||
translatedFlowCoverage[work.Address] = CoalesceInstructionRanges(result);
|
||||
|
||||
foreach (var target in discoveryTargets)
|
||||
{
|
||||
@@ -835,7 +821,6 @@ int RunTranslateRecursive(string[] argsTail)
|
||||
// list that existed to paper over the addresses they still missed.
|
||||
ProcessQueue();
|
||||
var walkReachable = count;
|
||||
var walkTranslated = translated.ToHashSet();
|
||||
ProcessSpeculativeSeeds();
|
||||
if (functionMap is not null)
|
||||
{
|
||||
@@ -879,31 +864,26 @@ int RunTranslateRecursive(string[] argsTail)
|
||||
if (boundaryRepairAddresses.Length != 0)
|
||||
{
|
||||
Console.WriteLine($"[translator] Reanalyzing {boundaryRepairAddresses.Length:N0} function(s) affected by later-discovered boundaries...");
|
||||
var repairedSummaries = new GqrFunctionSummary[boundaryRepairAddresses.Length];
|
||||
var repairedEnds = new uint[boundaryRepairAddresses.Length];
|
||||
var repairedCoverage = new uint[boundaryRepairAddresses.Length][];
|
||||
var repaired = new FunctionDiscoveryResult[boundaryRepairAddresses.Length];
|
||||
IndexedParallel.For(boundaryRepairAddresses.Length, parallelOptions, i =>
|
||||
{
|
||||
var address = boundaryRepairAddresses[i];
|
||||
var work = baseWork[address];
|
||||
var repaired = translator.Value.Discover(address, WithProjectFrontEndPolicy(new TranslationOptions(
|
||||
repaired[i] = translator.Value.Discover(address, WithProjectFrontEndPolicy(new TranslationOptions(
|
||||
work.Name,
|
||||
MaxInstructions: work.MaxInstructions,
|
||||
MaxBytes: work.MaxBytes,
|
||||
AllowUnsupportedInstructions: work.AllowUnsupported,
|
||||
KnownFunctionEntryPoints: knownBaseFunctionEntryPoints),
|
||||
RequireProject().Translation));
|
||||
repairedSummaries[i] = GqrFunctionSummary.Create(repaired.Ssa.Function);
|
||||
canonicalIrStore.Put(address, repaired.Ssa.Function);
|
||||
repairedEnds[i] = GetFunctionInstructionEnd(repaired);
|
||||
repairedCoverage[i] = CoalesceInstructionRanges(repaired);
|
||||
});
|
||||
for (var i = 0; i < boundaryRepairAddresses.Length; i++)
|
||||
{
|
||||
var address = boundaryRepairAddresses[i];
|
||||
baseSummaries[address] = repairedSummaries[i];
|
||||
translatedFunctionEnds[address] = repairedEnds[i];
|
||||
translatedFlowCoverage[address] = repairedCoverage[i];
|
||||
baseSummaries[address] = GqrFunctionSummary.Create(repaired[i].Ssa.Function);
|
||||
canonicalIrStore.Put(address, repaired[i].Ssa.Function);
|
||||
translatedFunctionEnds[address] = GetFunctionInstructionEnd(repaired[i]);
|
||||
translatedFlowCoverage[address] = CoalesceInstructionRanges(repaired[i]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -926,12 +906,6 @@ int RunTranslateRecursive(string[] argsTail)
|
||||
}
|
||||
|
||||
Console.WriteLine("[translator] Computing interprocedural GQR constants and callee write summaries...");
|
||||
// Map entries the walk never reached have an unconstrained entry GQR state unless some
|
||||
// translated function calls them directly.
|
||||
var gqrUnknownEntryRoots = GqrInterproceduralAnalysis.SelectUnknownEntryRoots(
|
||||
baseSummaries,
|
||||
[startAddr],
|
||||
(functionMap?.Addresses ?? Array.Empty<uint>()).Where(address => !walkTranslated.Contains(address)));
|
||||
var gqrAnalysis = GqrInterproceduralAnalysis.Analyze(
|
||||
baseSummaries,
|
||||
gqrUnknownEntryRoots);
|
||||
@@ -1209,7 +1183,7 @@ int RunTranslateRecursive(string[] argsTail)
|
||||
// Populate the recursive ABI inference cache once, in stable global order, before
|
||||
// refinement and parallel emission, so cyclic call graphs don't depend on thread count.
|
||||
LogPhase("state-free selection + leaf residency");
|
||||
ReportAbiPrewarm(inferredGuestAbi.Value.Prewarm(emissionAddresses, maxThreads));
|
||||
ReportAbiPrewarm(inferredGuestAbi.Value.Prewarm(emissionAddresses));
|
||||
LogPhase("ABI prewarm");
|
||||
|
||||
// State-free entries use the transformed leaf's exact live-at-entry
|
||||
@@ -1430,7 +1404,6 @@ int RunTranslateRecursive(string[] argsTail)
|
||||
// source visible. `completionPulse` only wakes the writer; it carries
|
||||
// no state of its own.
|
||||
var emissionDone = new bool[pendingSources.Length];
|
||||
var unsupportedByIndex = new int[emissionAddresses.Length];
|
||||
using var completionPulse = new ManualResetEventSlim(false);
|
||||
var emissionAborted = false;
|
||||
Task? pendingBundleWrite = null;
|
||||
@@ -1486,8 +1459,6 @@ int RunTranslateRecursive(string[] argsTail)
|
||||
var options = BuildEmissionOptions(address, stateFreeAbiContracts);
|
||||
if (!canonicalIrStore.TryGet(address, out var canonicalSsa))
|
||||
throw new InvalidOperationException($"Missing canonical SSA for final lowering at 0x{address:X8}.");
|
||||
unsupportedByIndex[i] = canonicalSsa.Blocks.Sum(block =>
|
||||
block.Instructions.Count(static instruction => instruction is IrUndefined));
|
||||
var emitted = translator.Value.LowerCanonical(address, canonicalSsa, options);
|
||||
var path = work.Path;
|
||||
if (outputMetadata is not null)
|
||||
@@ -1546,7 +1517,16 @@ int RunTranslateRecursive(string[] argsTail)
|
||||
: Path.GetRelativePath(
|
||||
Path.GetDirectoryName(Path.GetFullPath(outputMetadataPath!))!,
|
||||
Path.GetFullPath(productionSourceBundlePath)).Replace('\\', '/');
|
||||
var metadataUnsupportedInstructionCount = unsupportedByIndex.Sum();
|
||||
var metadataUnsupportedInstructionCount = emissionAddresses.Sum(address =>
|
||||
{
|
||||
if (!canonicalIrStore.TryGet(address, out var canonicalSsa))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Missing canonical SSA while computing translation quality for 0x{address:X8}.");
|
||||
}
|
||||
return canonicalSsa.Blocks.Sum(block =>
|
||||
block.Instructions.Count(static instruction => instruction is IrUndefined));
|
||||
});
|
||||
completedOutputMetadata = BaseTranslationOutputMetadata.Create(
|
||||
outputMetadata,
|
||||
new TranslationQualityMetadata(
|
||||
@@ -1721,7 +1701,7 @@ int RunTranslateModCore(string[] argsTail, string? outputDirectoryOverride)
|
||||
// win (this shipped once as a character-select LinkList.h:573 panic).
|
||||
if (File.Exists(baseTranslationOutputMetadataPath) && File.Exists(codePul))
|
||||
{
|
||||
var guardedBaseMetadata = ReadBaseMetadataCached(baseTranslationOutputMetadataPath);
|
||||
var guardedBaseMetadata = BaseTranslationOutputMetadataFile.Read(baseTranslationOutputMetadataPath);
|
||||
var codePulSha256 = ChecksumUtilities.Sha256HexOfFile(codePul);
|
||||
var baseKnowsThisPul = guardedBaseMetadata.ModPatchAwareness is { } awareness &&
|
||||
awareness.Any(entry => string.Equals(entry.CodePulSha256, codePulSha256, StringComparison.OrdinalIgnoreCase));
|
||||
@@ -1800,8 +1780,6 @@ int RunTranslateModCore(string[] argsTail, string? outputDirectoryOverride)
|
||||
|
||||
var pul = KamekPulFile.Load(codePul);
|
||||
var selected = pul.SelectRegion(region);
|
||||
// The server validates the hash of the unmodified chunk.
|
||||
var sourceKamekCodeSha1 = SHA1.HashData(selected.CodeBlob);
|
||||
if (string.Equals(profile?.Name, "retro-rewind", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var corrected = pul.IsCombined && !string.Equals(region, "P", StringComparison.OrdinalIgnoreCase)
|
||||
@@ -2010,7 +1988,7 @@ int RunTranslateModCore(string[] argsTail, string? outputDirectoryOverride)
|
||||
baseManifest,
|
||||
dvdOverlayRoots,
|
||||
relocatedModuleImage,
|
||||
sourceKamekCodeSha1,
|
||||
SHA1.HashData(selected.CodeBlob),
|
||||
retroWfcLoweringPlan?.StaticPointers,
|
||||
selected.CodeSize,
|
||||
selected.BssSize,
|
||||
@@ -2099,7 +2077,7 @@ void WriteResolvedDispatchProfile(
|
||||
var metadataPath = baseTranslationOutputMetadataPath;
|
||||
if (File.Exists(metadataPath))
|
||||
{
|
||||
var metadata = ReadBaseMetadataCached(metadataPath);
|
||||
var metadata = BaseTranslationOutputMetadataFile.Read(metadataPath);
|
||||
if (!string.IsNullOrWhiteSpace(metadata.SourceBundlePath))
|
||||
{
|
||||
var bundlePath = Path.IsPathRooted(metadata.SourceBundlePath)
|
||||
@@ -2174,7 +2152,7 @@ void WriteResolvedDispatchProfile(
|
||||
BaseTranslationOutputMetadata? metadata = null;
|
||||
if (File.Exists(baseTranslationOutputMetadataPath))
|
||||
{
|
||||
metadata = ReadBaseMetadataCached(baseTranslationOutputMetadataPath);
|
||||
metadata = BaseTranslationOutputMetadataFile.Read(baseTranslationOutputMetadataPath);
|
||||
}
|
||||
if (metadata is null || metadata.Functions.Any(static function => function.Build is null))
|
||||
{
|
||||
@@ -2278,21 +2256,12 @@ void WriteResolvedDispatchProfile(
|
||||
// functions the runtime must own (hardware, the strap scene). But when the mod's whole point
|
||||
// is replacing that function, e.g. Retro Rewind's DVDOpen override at 0x8015E2BC, the overlay
|
||||
// is silently dropped. Name every such address so the tradeoff is a choice, not an accident.
|
||||
// Wrapper natives still run the overlay, and replacement natives already made that choice.
|
||||
var nativesKeepingModOverlayDecision = runtimeNativeIndex.Value.Registrations
|
||||
.Where(static registration => registration.ModOverlay != NativeModOverlay.Unspecified)
|
||||
.Select(static registration => (registration.Address, registration.Symbol))
|
||||
.ToHashSet();
|
||||
var nativeOverriddenModPatches = groups
|
||||
.Where(group =>
|
||||
{
|
||||
var winner = group.OrderByDescending(entry => entry.Priority)
|
||||
.ThenByDescending(entry => KindRank(entry.Kind))
|
||||
.First();
|
||||
return group.Any(entry => entry.Kind == "rr") &&
|
||||
winner.Kind == "native" &&
|
||||
!nativesKeepingModOverlayDecision.Contains((winner.Address, winner.Symbol));
|
||||
})
|
||||
group.Any(entry => entry.Kind == "rr") &&
|
||||
group.OrderByDescending(entry => entry.Priority)
|
||||
.ThenByDescending(entry => KindRank(entry.Kind))
|
||||
.First().Kind == "native")
|
||||
.Select(group => new
|
||||
{
|
||||
Address = group.Key,
|
||||
@@ -2355,9 +2324,12 @@ int EmitModCpp(
|
||||
: new CompositeGuestFunctionAbiProvider(inferredAbi);
|
||||
var modTranslator = new FunctionTranslator(modImage, abiProvider);
|
||||
var parallelOptions = new ParallelOptions { MaxDegreeOfParallelism = maxThreads };
|
||||
// Each wave drains its whole queue: a wave is a barrier that publishes newly discovered
|
||||
// entries, and draining everything makes the output independent of any wave size.
|
||||
Console.WriteLine($" translating mod C++ with up to {maxThreads} worker thread(s)");
|
||||
// Mod emission waves: each item is a full translate+emit, and each wave is a
|
||||
// barrier that also drains newly discovered continuations, so it is
|
||||
// deliberately smaller than the discovery batch above - a long wave delays
|
||||
// the discoveries the next wave has to translate.
|
||||
var waveSize = Math.Max(16, maxThreads * 4);
|
||||
Console.WriteLine($" translating mod C++ with up to {maxThreads} worker thread(s) in waves of {waveSize}");
|
||||
|
||||
var cppRoot = Path.Combine(outDir, "cpp");
|
||||
if (Directory.Exists(cppRoot))
|
||||
@@ -2436,7 +2408,7 @@ int EmitModCpp(
|
||||
// Some dynamically discovered module/continuation waves bypass
|
||||
// ProcessBoundedWaves. Prewarm every parallel emission boundary as a
|
||||
// final deterministic guard; phase-prewarmed entries are cheap hits.
|
||||
ReportAbiPrewarm(inferredAbi.Prewarm(wave.Select(static work => work.Address), maxThreads));
|
||||
ReportAbiPrewarm(inferredAbi.Prewarm(wave.Select(static work => work.Address)));
|
||||
return ModTranslationWaveRunner.Translate(() => modTranslator, wave, parallelOptions);
|
||||
}
|
||||
|
||||
@@ -2473,7 +2445,7 @@ int EmitModCpp(
|
||||
// Populate the recursive ABI cache in a stable global order before any worker emits C++,
|
||||
// so shared closure reuse and cyclic call graphs never depend on thread count, wave size,
|
||||
// or worker order.
|
||||
ReportAbiPrewarm(inferredAbi.Prewarm(ordered.Select(static work => work.Address), maxThreads));
|
||||
ReportAbiPrewarm(inferredAbi.Prewarm(ordered.Select(static work => work.Address)));
|
||||
// Commit callbacks here only append to the discovered-continuation queue that a later
|
||||
// phase drains, so nothing in this phase reads back its own commits; wave barriers would
|
||||
// only cost idle workers, so it runs as one parallel batch and the runner re-sorts by the
|
||||
@@ -2618,7 +2590,7 @@ int EmitModCpp(
|
||||
{
|
||||
while (discoveredContinuationQueue.Count > 0)
|
||||
{
|
||||
var wave = QueueBatch.Dequeue(discoveredContinuationQueue, discoveredContinuationQueue.Count)
|
||||
var wave = QueueBatch.Dequeue(discoveredContinuationQueue, waveSize)
|
||||
.Select(CreateContinuationWork)
|
||||
.ToArray();
|
||||
CommitWave(TranslateWave(wave), (result, work) =>
|
||||
@@ -2637,10 +2609,10 @@ int EmitModCpp(
|
||||
var emittedModuleStarts = new HashSet<uint>();
|
||||
while (moduleQueue.Count > 0)
|
||||
{
|
||||
var wave = new List<ModTranslationWork>(moduleQueue.Count);
|
||||
var wave = new List<ModTranslationWork>(waveSize);
|
||||
foreach (var start in QueueBatch.Dequeue(
|
||||
moduleQueue,
|
||||
moduleQueue.Count,
|
||||
waveSize,
|
||||
start => !emittedModuleStarts.Add(start.Address)))
|
||||
{
|
||||
var name = ModuleFunctionName(start.Address);
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<ServerGarbageCollection>true</ServerGarbageCollection>
|
||||
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
|
||||
<TieredPGO>false</TieredPGO>
|
||||
<VersionPrefix>0.5.0</VersionPrefix>
|
||||
<Company>WiiCompiled</Company>
|
||||
<Product>Mario Kart Wii Static Translator</Product>
|
||||
|
||||
@@ -102,7 +102,12 @@ public static class GuestMemoryRangeLowering
|
||||
.Any(group => group.Count() >= minimumAccesses))
|
||||
return function;
|
||||
|
||||
var graph = BlockGraph.Build(function);
|
||||
var cfg = IrCfg.Build(function);
|
||||
var dominators = ComputeDominators(cfg);
|
||||
var reachability = ComputeReachability(cfg);
|
||||
var cyclicBlocks = cfg.Blocks.Keys.Where(label =>
|
||||
cfg.Successors(label).Any(successor => reachability[successor].Contains(label)))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var plans = new Dictionary<(int Block, int Instruction), Plan>();
|
||||
var resolves = new Dictionary<int, List<PlacedResolve>>();
|
||||
@@ -124,35 +129,24 @@ public static class GuestMemoryRangeLowering
|
||||
if (definitionSites.Count != 1) continue;
|
||||
definition = definitionSites[0];
|
||||
var defined = definitionSites[0];
|
||||
var definitionBlock = function.Blocks[defined.BlockIndex].Label;
|
||||
if (remaining.Any(candidate =>
|
||||
!graph.Dominates(defined.BlockIndex, candidate.BlockIndex) ||
|
||||
!dominators[candidate.AccessBlockLabel(function)].Contains(definitionBlock) ||
|
||||
candidate.BlockIndex == defined.BlockIndex &&
|
||||
candidate.Access.Index <= defined.InstructionIndex))
|
||||
continue;
|
||||
}
|
||||
// The pair predicate is invariant across rounds, so memoize it.
|
||||
var groupIndex = new Dictionary<FunctionAccess, int>(ReferenceEqualityComparer.Instance);
|
||||
foreach (var access in remaining) groupIndex.Add(access, groupIndex.Count);
|
||||
var compatible = remaining.Count <= 4096 ? new sbyte[remaining.Count * remaining.Count] : null;
|
||||
bool Compatible(FunctionAccess anchor, FunctionAccess candidate)
|
||||
{
|
||||
if (compatible is null)
|
||||
return DominatesAccess(graph, anchor, candidate) &&
|
||||
!HasInvalidatingBoundaryOnPath(graph, invalidators, anchor, candidate);
|
||||
var slot = groupIndex[anchor] * groupIndex.Count + groupIndex[candidate];
|
||||
if (compatible[slot] != 0) return compatible[slot] > 0;
|
||||
var value = DominatesAccess(graph, anchor, candidate) &&
|
||||
!HasInvalidatingBoundaryOnPath(graph, invalidators, anchor, candidate);
|
||||
compatible[slot] = value ? (sbyte)1 : (sbyte)-1;
|
||||
return value;
|
||||
}
|
||||
while (remaining.Count >= minimumAccesses)
|
||||
{
|
||||
FunctionAccess? bestAnchor = null;
|
||||
FunctionAccess[] bestMembers = [];
|
||||
foreach (var anchor in remaining)
|
||||
{
|
||||
var members = remaining.Where(candidate => Compatible(anchor, candidate))
|
||||
var members = remaining.Where(candidate =>
|
||||
DominatesAccess(function, dominators, anchor, candidate) &&
|
||||
!HasInvalidatingBoundaryOnPath(
|
||||
function, reachability, cyclicBlocks,
|
||||
invalidators, anchor, candidate))
|
||||
.OrderBy(candidate => candidate.Access.Index)
|
||||
.ToList();
|
||||
// Keep a bounded affine window around the real anchor. A
|
||||
@@ -254,13 +248,15 @@ public static class GuestMemoryRangeLowering
|
||||
}
|
||||
|
||||
private static bool DominatesAccess(
|
||||
BlockGraph graph,
|
||||
IrFunction function,
|
||||
IReadOnlyDictionary<string, HashSet<string>> dominators,
|
||||
FunctionAccess anchor,
|
||||
FunctionAccess consumer)
|
||||
{
|
||||
if (anchor.BlockIndex == consumer.BlockIndex)
|
||||
return anchor.Access.Index <= consumer.Access.Index;
|
||||
return graph.Dominates(anchor.BlockIndex, consumer.BlockIndex);
|
||||
return dominators[consumer.AccessBlockLabel(function)].Contains(
|
||||
anchor.AccessBlockLabel(function));
|
||||
}
|
||||
|
||||
private static bool HasOverlappingOppositeAccess(
|
||||
@@ -277,25 +273,27 @@ public static class GuestMemoryRangeLowering
|
||||
}
|
||||
|
||||
private static bool HasInvalidatingBoundaryOnPath(
|
||||
BlockGraph graph,
|
||||
IrFunction function,
|
||||
IReadOnlyDictionary<string, HashSet<string>> reachability,
|
||||
IReadOnlySet<string> cyclicBlocks,
|
||||
IReadOnlyList<InstructionSite> invalidators,
|
||||
FunctionAccess anchor,
|
||||
FunctionAccess consumer)
|
||||
{
|
||||
var anchorBlock = anchor.BlockIndex;
|
||||
var consumerBlock = consumer.BlockIndex;
|
||||
var consumerReentered = anchorBlock != consumerBlock && graph.Cyclic[consumerBlock];
|
||||
var anchorLabel = anchor.AccessBlockLabel(function);
|
||||
var consumerLabel = consumer.AccessBlockLabel(function);
|
||||
foreach (var invalidator in invalidators)
|
||||
{
|
||||
var boundaryBlock = invalidator.BlockIndex;
|
||||
var afterAnchor = boundaryBlock == anchorBlock
|
||||
var boundaryLabel = function.Blocks[invalidator.BlockIndex].Label;
|
||||
var afterAnchor = invalidator.BlockIndex == anchor.BlockIndex
|
||||
? invalidator.InstructionIndex > anchor.Access.Index
|
||||
: graph.Reaches(anchorBlock, boundaryBlock);
|
||||
if (!afterAnchor) continue;
|
||||
var beforeConsumer = boundaryBlock == consumerBlock
|
||||
? invalidator.InstructionIndex < consumer.Access.Index || consumerReentered
|
||||
: graph.Reaches(boundaryBlock, consumerBlock);
|
||||
if (beforeConsumer) return true;
|
||||
: reachability[anchorLabel].Contains(boundaryLabel);
|
||||
var beforeConsumer = invalidator.BlockIndex == consumer.BlockIndex
|
||||
? invalidator.InstructionIndex < consumer.Access.Index ||
|
||||
anchor.BlockIndex != consumer.BlockIndex &&
|
||||
cyclicBlocks.Contains(consumerLabel)
|
||||
: reachability[boundaryLabel].Contains(consumerLabel);
|
||||
if (afterAnchor && beforeConsumer) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -317,117 +315,54 @@ public static class GuestMemoryRangeLowering
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Dominator and reachability sets as bitsets over block ordinals.</summary>
|
||||
private sealed class BlockGraph
|
||||
private static Dictionary<string, HashSet<string>> ComputeDominators(IrCfg cfg)
|
||||
{
|
||||
private readonly ulong[][] _dominators;
|
||||
private readonly ulong[][] _reach;
|
||||
|
||||
private BlockGraph(ulong[][] dominators, ulong[][] reach, bool[] cyclic)
|
||||
var comparer = StringComparer.OrdinalIgnoreCase;
|
||||
var labels = cfg.Blocks.Keys.ToArray();
|
||||
var all = labels.ToHashSet(comparer);
|
||||
var result = labels.ToDictionary(label => label,
|
||||
label => label.Equals(cfg.Entry, StringComparison.OrdinalIgnoreCase)
|
||||
? new HashSet<string>([label], comparer)
|
||||
: new HashSet<string>(all, comparer), comparer);
|
||||
bool changed;
|
||||
do
|
||||
{
|
||||
_dominators = dominators;
|
||||
_reach = reach;
|
||||
Cyclic = cyclic;
|
||||
}
|
||||
changed = false;
|
||||
foreach (var label in labels)
|
||||
{
|
||||
if (label.Equals(cfg.Entry, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
var predecessors = cfg.Predecessors(label);
|
||||
var next = predecessors.Count == 0
|
||||
? new HashSet<string>(comparer)
|
||||
: new HashSet<string>(result[predecessors[0]], comparer);
|
||||
foreach (var predecessor in predecessors.Skip(1)) next.IntersectWith(result[predecessor]);
|
||||
next.Add(label);
|
||||
if (result[label].SetEquals(next)) continue;
|
||||
result[label] = next;
|
||||
changed = true;
|
||||
}
|
||||
} while (changed);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Blocks that can reach themselves again through a successor.</summary>
|
||||
public bool[] Cyclic { get; }
|
||||
|
||||
public bool Dominates(int dominator, int block) =>
|
||||
(_dominators[block][dominator >> 6] & (1UL << (dominator & 63))) != 0;
|
||||
|
||||
/// <summary>Reflexive: every block reaches itself.</summary>
|
||||
public bool Reaches(int from, int to) =>
|
||||
(_reach[from][to >> 6] & (1UL << (to & 63))) != 0;
|
||||
|
||||
public static BlockGraph Build(IrFunction function)
|
||||
private static Dictionary<string, HashSet<string>> ComputeReachability(IrCfg cfg)
|
||||
{
|
||||
var comparer = StringComparer.OrdinalIgnoreCase;
|
||||
var result = new Dictionary<string, HashSet<string>>(comparer);
|
||||
foreach (var start in cfg.Blocks.Keys)
|
||||
{
|
||||
var cfg = IrCfg.Build(function);
|
||||
var count = function.Blocks.Count;
|
||||
var words = (count + 63) >> 6;
|
||||
var index = new Dictionary<string, int>(count, StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 0; i < count; i++) index.Add(function.Blocks[i].Label, i);
|
||||
var successors = new int[count][];
|
||||
var predecessors = new int[count][];
|
||||
for (var i = 0; i < count; i++)
|
||||
var reachable = new HashSet<string>(comparer);
|
||||
var pending = new Stack<string>();
|
||||
pending.Push(start);
|
||||
while (pending.Count != 0)
|
||||
{
|
||||
var label = function.Blocks[i].Label;
|
||||
successors[i] = cfg.Successors(label).Select(successor => index[successor]).ToArray();
|
||||
predecessors[i] = cfg.Predecessors(label).Select(predecessor => index[predecessor]).ToArray();
|
||||
var current = pending.Pop();
|
||||
if (!reachable.Add(current)) continue;
|
||||
foreach (var successor in cfg.Successors(current)) pending.Push(successor);
|
||||
}
|
||||
|
||||
var reach = new ulong[count][];
|
||||
var pending = new Stack<int>();
|
||||
for (var start = 0; start < count; start++)
|
||||
{
|
||||
var row = new ulong[words];
|
||||
pending.Push(start);
|
||||
while (pending.Count != 0)
|
||||
{
|
||||
var current = pending.Pop();
|
||||
ref var word = ref row[current >> 6];
|
||||
var bit = 1UL << (current & 63);
|
||||
if ((word & bit) != 0) continue;
|
||||
word |= bit;
|
||||
foreach (var successor in successors[current]) pending.Push(successor);
|
||||
}
|
||||
reach[start] = row;
|
||||
}
|
||||
|
||||
var cyclic = new bool[count];
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
foreach (var successor in successors[i])
|
||||
{
|
||||
if ((reach[successor][i >> 6] & (1UL << (i & 63))) == 0) continue;
|
||||
cyclic[i] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var entry = index[cfg.Entry];
|
||||
var dominators = new ulong[count][];
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
dominators[i] = new ulong[words];
|
||||
if (i == entry)
|
||||
{
|
||||
dominators[i][i >> 6] = 1UL << (i & 63);
|
||||
continue;
|
||||
}
|
||||
Array.Fill(dominators[i], ulong.MaxValue);
|
||||
if ((count & 63) != 0) dominators[i][words - 1] = (1UL << (count & 63)) - 1;
|
||||
}
|
||||
var next = new ulong[words];
|
||||
bool changed;
|
||||
do
|
||||
{
|
||||
changed = false;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
if (i == entry) continue;
|
||||
var preds = predecessors[i];
|
||||
if (preds.Length == 0)
|
||||
{
|
||||
Array.Clear(next);
|
||||
}
|
||||
else
|
||||
{
|
||||
Array.Copy(dominators[preds[0]], next, words);
|
||||
for (var p = 1; p < preds.Length; p++)
|
||||
{
|
||||
var other = dominators[preds[p]];
|
||||
for (var w = 0; w < words; w++) next[w] &= other[w];
|
||||
}
|
||||
}
|
||||
next[i >> 6] |= 1UL << (i & 63);
|
||||
if (next.AsSpan().SequenceEqual(dominators[i])) continue;
|
||||
next.AsSpan().CopyTo(dominators[i]);
|
||||
changed = true;
|
||||
}
|
||||
} while (changed);
|
||||
return new BlockGraph(dominators, reach, cyclic);
|
||||
result[start] = reachable;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IrInstruction ToResolved(IrInstruction instruction, Access access, Plan plan)
|
||||
|
||||
@@ -3,7 +3,6 @@ using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Translator.Core.Analysis;
|
||||
@@ -73,9 +72,6 @@ public sealed class InferredGuestFunctionAbiProvider : IGuestFunctionAbiProvider
|
||||
/// <summary>Reusable backing list for <see cref="FrameTaint"/>; phase A is never reentrant.</summary>
|
||||
public readonly List<bool> FrameTaintBuffer = new();
|
||||
|
||||
/// <summary>Address per <see cref="FrameTaint"/> entry, innermost last.</summary>
|
||||
public readonly List<uint> FrameAddresses = new();
|
||||
|
||||
/// <summary>Addresses already known tainted, shared by every phase-A worker of the current Prewarm
|
||||
/// call and reset each invocation. Without this memoization an unpublishable address gets
|
||||
/// re-analyzed, subtree and all, by every caller that reaches it, which compounds combinatorially
|
||||
@@ -99,21 +95,14 @@ public sealed class InferredGuestFunctionAbiProvider : IGuestFunctionAbiProvider
|
||||
return false;
|
||||
}
|
||||
|
||||
var state = _threadState.Value!;
|
||||
var frameTaint = state.FrameTaint;
|
||||
if (frameTaint is not null && state.FrameAddresses.Count > 0 && state.FrameAddresses[^1] == address)
|
||||
{
|
||||
// Direct self-recursion sees what a sequential run would, even once another thread has published it.
|
||||
abi = GuestFunctionAbi.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_cache.TryGetValue(address, out abi!))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var state = _threadState.Value!;
|
||||
var inProgress = state.InProgress;
|
||||
var frameTaint = state.FrameTaint;
|
||||
|
||||
if (frameTaint is not null && state.TaintedAddresses!.ContainsKey(address))
|
||||
{
|
||||
@@ -162,7 +151,6 @@ public sealed class InferredGuestFunctionAbiProvider : IGuestFunctionAbiProvider
|
||||
|
||||
// Prewarm phase A: track taint for this frame and publish only if it stayed clean.
|
||||
frameTaint.Add(false);
|
||||
state.FrameAddresses.Add(address);
|
||||
var frameIndex = frameTaint.Count - 1;
|
||||
bool succeeded;
|
||||
bool tainted;
|
||||
@@ -184,7 +172,6 @@ public sealed class InferredGuestFunctionAbiProvider : IGuestFunctionAbiProvider
|
||||
finally
|
||||
{
|
||||
inProgress.Remove(address);
|
||||
state.FrameAddresses.RemoveAt(frameIndex);
|
||||
tainted = frameTaint[frameIndex];
|
||||
frameTaint.RemoveAt(frameIndex);
|
||||
if (tainted && frameIndex > 0)
|
||||
@@ -287,7 +274,6 @@ public sealed class InferredGuestFunctionAbiProvider : IGuestFunctionAbiProvider
|
||||
var state = _threadState.Value!;
|
||||
var frames = state.FrameTaintBuffer;
|
||||
frames.Clear();
|
||||
state.FrameAddresses.Clear();
|
||||
state.TaintedAddresses = taintedAddresses;
|
||||
state.FrameTaint = frames;
|
||||
try
|
||||
@@ -432,102 +418,82 @@ public sealed class InferredGuestFunctionAbiProvider : IGuestFunctionAbiProvider
|
||||
private HashSet<string> InferArgumentRegisters(IrFunction function, IrCfg cfg)
|
||||
{
|
||||
var comparer = StringComparer.OrdinalIgnoreCase;
|
||||
// Interned register ids so the liveness fixpoint runs over bitsets.
|
||||
var ids = new Dictionary<string, int>(comparer);
|
||||
var names = new List<string>();
|
||||
int Id(string name)
|
||||
{
|
||||
if (!ids.TryGetValue(name, out var id))
|
||||
{
|
||||
id = names.Count;
|
||||
ids.Add(name, id);
|
||||
names.Add(name);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
var blockUses = new Dictionary<string, HashSet<string>>(comparer);
|
||||
var blockDefs = new Dictionary<string, HashSet<string>>(comparer);
|
||||
|
||||
var blockCount = function.Blocks.Count;
|
||||
var useLists = new List<int>[blockCount];
|
||||
var defLists = new List<int>[blockCount];
|
||||
var labelIndex = new Dictionary<string, int>(blockCount, comparer);
|
||||
for (var b = 0; b < blockCount; b++)
|
||||
foreach (var block in function.Blocks)
|
||||
{
|
||||
var block = function.Blocks[b];
|
||||
var uses = new List<int>();
|
||||
var defs = new List<int>();
|
||||
var defined = new HashSet<int>();
|
||||
var uses = new HashSet<string>(comparer);
|
||||
var defs = new HashSet<string>(comparer);
|
||||
foreach (var instruction in block.Instructions)
|
||||
{
|
||||
foreach (var use in InstructionRegisterUsesForArgumentInference(instruction))
|
||||
{
|
||||
var id = Id(use);
|
||||
if (!defined.Contains(id))
|
||||
uses.Add(id);
|
||||
if (!defs.Contains(use))
|
||||
{
|
||||
uses.Add(use);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var def in InstructionRegisterDefsForArgumentInference(instruction))
|
||||
{
|
||||
var id = Id(def);
|
||||
if (defined.Add(id))
|
||||
defs.Add(id);
|
||||
defs.Add(def);
|
||||
}
|
||||
}
|
||||
useLists[b] = uses;
|
||||
defLists[b] = defs;
|
||||
|
||||
blockUses[block.Label] = uses;
|
||||
blockDefs[block.Label] = defs;
|
||||
}
|
||||
|
||||
for (var b = 0; b < blockCount; b++)
|
||||
labelIndex.Add(function.Blocks[b].Label, b);
|
||||
var liveIn = function.Blocks.ToDictionary(
|
||||
static block => block.Label,
|
||||
_ => new HashSet<string>(comparer),
|
||||
comparer);
|
||||
var liveOut = function.Blocks.ToDictionary(
|
||||
static block => block.Label,
|
||||
_ => new HashSet<string>(comparer),
|
||||
comparer);
|
||||
|
||||
var words = Math.Max(1, (names.Count + 63) >> 6);
|
||||
var useBits = new ulong[blockCount * words];
|
||||
var keepBits = new ulong[blockCount * words];
|
||||
Array.Fill(keepBits, ulong.MaxValue);
|
||||
var successors = new int[blockCount][];
|
||||
for (var b = 0; b < blockCount; b++)
|
||||
{
|
||||
foreach (var id in useLists[b]) useBits[b * words + (id >> 6)] |= 1UL << (id & 63);
|
||||
foreach (var id in defLists[b]) keepBits[b * words + (id >> 6)] &= ~(1UL << (id & 63));
|
||||
var targets = new List<int>();
|
||||
foreach (var successor in cfg.Successors(function.Blocks[b].Label))
|
||||
if (labelIndex.TryGetValue(successor, out var index))
|
||||
targets.Add(index);
|
||||
successors[b] = targets.ToArray();
|
||||
}
|
||||
|
||||
var liveIn = new ulong[blockCount * words];
|
||||
var changed = true;
|
||||
while (changed)
|
||||
{
|
||||
changed = false;
|
||||
for (var b = blockCount - 1; b >= 0; b--)
|
||||
for (var blockIndex = function.Blocks.Count - 1; blockIndex >= 0; blockIndex--)
|
||||
{
|
||||
var row = b * words;
|
||||
for (var w = 0; w < words; w++)
|
||||
var block = function.Blocks[blockIndex];
|
||||
var newOut = new HashSet<string>(comparer);
|
||||
foreach (var successor in cfg.Successors(block.Label))
|
||||
{
|
||||
ulong liveOut = 0;
|
||||
foreach (var successor in successors[b])
|
||||
liveOut |= liveIn[successor * words + w];
|
||||
var value = useBits[row + w] | (liveOut & keepBits[row + w]);
|
||||
if (value != liveIn[row + w])
|
||||
if (liveIn.TryGetValue(successor, out var successorLiveIn))
|
||||
{
|
||||
liveIn[row + w] = value;
|
||||
changed = true;
|
||||
newOut.UnionWith(successorLiveIn);
|
||||
}
|
||||
}
|
||||
|
||||
var newIn = new HashSet<string>(blockUses[block.Label], comparer);
|
||||
var outMinusDefs = new HashSet<string>(newOut, comparer);
|
||||
outMinusDefs.ExceptWith(blockDefs[block.Label]);
|
||||
newIn.UnionWith(outMinusDefs);
|
||||
|
||||
if (!newOut.SetEquals(liveOut[block.Label]))
|
||||
{
|
||||
liveOut[block.Label] = newOut;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!newIn.SetEquals(liveIn[block.Label]))
|
||||
{
|
||||
liveIn[block.Label] = newIn;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var result = new HashSet<string>(comparer);
|
||||
if (labelIndex.TryGetValue(function.EntryLabel, out var entry))
|
||||
{
|
||||
for (var id = 0; id < names.Count; id++)
|
||||
{
|
||||
if ((liveIn[entry * words + (id >> 6)] & (1UL << (id & 63))) != 0 && IsGprOrFprRegister(names[id]))
|
||||
result.Add(names[id]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
return liveIn.TryGetValue(function.EntryLabel, out var entryLive)
|
||||
? entryLive
|
||||
.Where(IsGprOrFprRegister)
|
||||
.ToHashSet(comparer)
|
||||
: new HashSet<string>(comparer);
|
||||
}
|
||||
|
||||
private IEnumerable<string> InstructionRegisterUsesForArgumentInference(IrInstruction instruction)
|
||||
@@ -789,74 +755,45 @@ public sealed class InferredGuestFunctionAbiProvider : IGuestFunctionAbiProvider
|
||||
private HashSet<string> InferScalarFloatArguments(IrFunction function, IrCfg cfg)
|
||||
{
|
||||
var scalarArgs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var keys = new AliasKeys(function);
|
||||
var blockCount = function.Blocks.Count;
|
||||
var blockIndex = new Dictionary<string, int>(blockCount, StringComparer.OrdinalIgnoreCase);
|
||||
for (var b = 0; b < blockCount; b++)
|
||||
blockIndex[function.Blocks[b].Label] = b;
|
||||
var predecessors = new int[blockCount][];
|
||||
var isEntry = new bool[blockCount];
|
||||
for (var b = 0; b < blockCount; b++)
|
||||
{
|
||||
var block = function.Blocks[b];
|
||||
isEntry[b] = block.Label.Equals(function.EntryLabel, StringComparison.OrdinalIgnoreCase);
|
||||
var list = new List<int>();
|
||||
foreach (var predecessor in cfg.Predecessors(block.Label))
|
||||
if (blockIndex.TryGetValue(predecessor, out var index))
|
||||
list.Add(index);
|
||||
predecessors[b] = list.ToArray();
|
||||
}
|
||||
|
||||
// Per alias key: -1 when absent, otherwise the mask of ABI float argument sources.
|
||||
var initial = keys.InitialState();
|
||||
var inMap = new int[blockCount][];
|
||||
var outMap = new int[blockCount][];
|
||||
for (var b = 0; b < blockCount; b++)
|
||||
{
|
||||
inMap[b] = isEntry[b] ? (int[])initial.Clone() : keys.EmptyState();
|
||||
outMap[b] = isEntry[b] ? (int[])initial.Clone() : keys.EmptyState();
|
||||
}
|
||||
var comparer = StringComparer.OrdinalIgnoreCase;
|
||||
var inMap = function.Blocks.ToDictionary(
|
||||
static b => b.Label,
|
||||
b => b.Label.Equals(function.EntryLabel, StringComparison.OrdinalIgnoreCase)
|
||||
? CreateInitialArgumentAliasState()
|
||||
: new Dictionary<string, HashSet<string>>(comparer),
|
||||
comparer);
|
||||
var outMap = function.Blocks.ToDictionary(
|
||||
static b => b.Label,
|
||||
b => b.Label.Equals(function.EntryLabel, StringComparison.OrdinalIgnoreCase)
|
||||
? CreateInitialArgumentAliasState()
|
||||
: new Dictionary<string, HashSet<string>>(comparer),
|
||||
comparer);
|
||||
|
||||
var changed = true;
|
||||
while (changed)
|
||||
{
|
||||
changed = false;
|
||||
for (var b = 0; b < blockCount; b++)
|
||||
foreach (var block in function.Blocks)
|
||||
{
|
||||
int[] newIn;
|
||||
if (isEntry[b])
|
||||
var newIn = block.Label.Equals(function.EntryLabel, StringComparison.OrdinalIgnoreCase)
|
||||
? CreateInitialArgumentAliasState()
|
||||
: UnionPredecessorStates(cfg.Predecessors(block.Label), outMap);
|
||||
|
||||
var newOut = CopyAliasState(newIn);
|
||||
foreach (var ins in block.Instructions)
|
||||
{
|
||||
newIn = (int[])initial.Clone();
|
||||
}
|
||||
else
|
||||
{
|
||||
newIn = keys.EmptyState();
|
||||
foreach (var predecessor in predecessors[b])
|
||||
{
|
||||
var predState = outMap[predecessor];
|
||||
for (var key = 0; key < newIn.Length; key++)
|
||||
{
|
||||
if (predState[key] < 0) continue;
|
||||
newIn[key] = newIn[key] < 0 ? predState[key] : newIn[key] | predState[key];
|
||||
}
|
||||
}
|
||||
ProcessScalarArgumentInstruction(ins, newOut, scalarArgs);
|
||||
}
|
||||
|
||||
var newOut = (int[])newIn.Clone();
|
||||
foreach (var ins in function.Blocks[b].Instructions)
|
||||
if (!AliasStatesEqual(newIn, inMap[block.Label]))
|
||||
{
|
||||
ProcessScalarArgumentInstruction(ins, keys, newOut, scalarArgs);
|
||||
}
|
||||
|
||||
if (!newIn.AsSpan().SequenceEqual(inMap[b]))
|
||||
{
|
||||
inMap[b] = newIn;
|
||||
inMap[block.Label] = newIn;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!newOut.AsSpan().SequenceEqual(outMap[b]))
|
||||
if (!AliasStatesEqual(newOut, outMap[block.Label]))
|
||||
{
|
||||
outMap[b] = newOut;
|
||||
outMap[block.Label] = newOut;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
@@ -865,153 +802,63 @@ public sealed class InferredGuestFunctionAbiProvider : IGuestFunctionAbiProvider
|
||||
return scalarArgs;
|
||||
}
|
||||
|
||||
/// <summary>Interned float register names the scalar-argument alias analysis can track.
|
||||
/// Ids 0..12 are the ABI float argument registers, so a source set is a 13-bit mask.</summary>
|
||||
private sealed class AliasKeys
|
||||
{
|
||||
private readonly Dictionary<string, int> _ids = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public AliasKeys(IrFunction function)
|
||||
{
|
||||
foreach (var register in AbiFloatArgumentRegisters)
|
||||
_ids.Add(register, _ids.Count);
|
||||
foreach (var block in function.Blocks)
|
||||
{
|
||||
foreach (var instruction in block.Instructions)
|
||||
{
|
||||
switch (instruction)
|
||||
{
|
||||
case IrAssign assign:
|
||||
AddName(assign.Destination);
|
||||
AddValue(assign.Value);
|
||||
break;
|
||||
case IrBinary binary:
|
||||
AddValue(binary.Left);
|
||||
AddValue(binary.Right);
|
||||
AddName(binary.Destination);
|
||||
break;
|
||||
case IrLoad load:
|
||||
AddName(load.Destination);
|
||||
break;
|
||||
case IrStore store:
|
||||
AddValue(store.Source);
|
||||
break;
|
||||
case IrCall call:
|
||||
foreach (var argument in call.Arguments) AddValue(argument);
|
||||
AddName(call.Destination);
|
||||
break;
|
||||
case IrIndirectCall indirectCall:
|
||||
foreach (var argument in indirectCall.Arguments) AddValue(argument);
|
||||
AddName(indirectCall.Destination);
|
||||
break;
|
||||
case IrSetCrField setCr:
|
||||
AddValue(setCr.Left);
|
||||
AddValue(setCr.Right);
|
||||
break;
|
||||
case IrPhi phi:
|
||||
AddName(phi.Destination);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int Count => _ids.Count;
|
||||
|
||||
public int[] EmptyState()
|
||||
{
|
||||
var state = new int[_ids.Count];
|
||||
Array.Fill(state, -1);
|
||||
return state;
|
||||
}
|
||||
|
||||
public int[] InitialState()
|
||||
{
|
||||
var state = EmptyState();
|
||||
for (var i = 0; i < AbiFloatArgumentRegisters.Length; i++)
|
||||
state[i] = 1 << i;
|
||||
return state;
|
||||
}
|
||||
|
||||
public bool TryGet(string baseName, out int id) => _ids.TryGetValue(baseName, out id);
|
||||
|
||||
private void AddValue(IrValue? value)
|
||||
{
|
||||
if (value is { Kind: "register", RegisterName: { } name })
|
||||
AddName(name);
|
||||
}
|
||||
|
||||
private void AddName(string? name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name)) return;
|
||||
var baseName = Base(name);
|
||||
if (IsFloatRegister(baseName))
|
||||
_ids.TryAdd(baseName, _ids.Count);
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessScalarArgumentInstruction(
|
||||
IrInstruction ins,
|
||||
AliasKeys keys,
|
||||
int[] aliases,
|
||||
Dictionary<string, HashSet<string>> aliases,
|
||||
HashSet<string> scalarArgs)
|
||||
{
|
||||
switch (ins)
|
||||
{
|
||||
case IrAssign assign:
|
||||
PropagateFloatAlias(assign.Destination, assign.Value, keys, aliases);
|
||||
PropagateFloatAlias(assign.Destination, assign.Value, aliases);
|
||||
break;
|
||||
|
||||
case IrBinary bin:
|
||||
MarkScalarUse(bin.Left, keys, aliases, scalarArgs);
|
||||
MarkScalarUse(bin.Right, keys, aliases, scalarArgs);
|
||||
KillFloatAlias(bin.Destination, keys, aliases);
|
||||
MarkScalarUse(bin.Left, aliases, scalarArgs);
|
||||
MarkScalarUse(bin.Right, aliases, scalarArgs);
|
||||
KillFloatAlias(bin.Destination, aliases);
|
||||
break;
|
||||
|
||||
case IrLoad load:
|
||||
KillFloatAlias(load.Destination, keys, aliases);
|
||||
KillFloatAlias(load.Destination, aliases);
|
||||
break;
|
||||
|
||||
case IrStore store:
|
||||
if (store.SizeBytes is 4 or 8)
|
||||
{
|
||||
MarkScalarUse(store.Source, keys, aliases, scalarArgs);
|
||||
MarkScalarUse(store.Source, aliases, scalarArgs);
|
||||
}
|
||||
break;
|
||||
|
||||
case IrCall call:
|
||||
ProcessScalarArgumentCall(call.Target, call.Arguments, keys, aliases, scalarArgs);
|
||||
KillFloatAlias(call.Destination, keys, aliases);
|
||||
ProcessScalarArgumentCall(call.Target, call.Arguments, aliases, scalarArgs);
|
||||
KillFloatAlias(call.Destination, aliases);
|
||||
break;
|
||||
|
||||
case IrIndirectCall icall:
|
||||
foreach (var arg in icall.Arguments)
|
||||
{
|
||||
MarkScalarUse(arg, keys, aliases, scalarArgs);
|
||||
MarkScalarUse(arg, aliases, scalarArgs);
|
||||
}
|
||||
aliases[F1AliasKey] = -1;
|
||||
KillFloatAlias(icall.Destination, keys, aliases);
|
||||
aliases.Remove("f1");
|
||||
KillFloatAlias(icall.Destination, aliases);
|
||||
break;
|
||||
|
||||
case IrSetCrField setCr:
|
||||
MarkScalarUse(setCr.Left, keys, aliases, scalarArgs);
|
||||
MarkScalarUse(setCr.Right, keys, aliases, scalarArgs);
|
||||
MarkScalarUse(setCr.Left, aliases, scalarArgs);
|
||||
MarkScalarUse(setCr.Right, aliases, scalarArgs);
|
||||
break;
|
||||
|
||||
case IrPhi phi:
|
||||
KillFloatAlias(phi.Destination, keys, aliases);
|
||||
KillFloatAlias(phi.Destination, aliases);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Alias key of f1, the first ABI float argument register.</summary>
|
||||
private const int F1AliasKey = 0;
|
||||
|
||||
private void ProcessScalarArgumentCall(
|
||||
string target,
|
||||
IReadOnlyList<IrValue> arguments,
|
||||
AliasKeys keys,
|
||||
int[] aliases,
|
||||
Dictionary<string, HashSet<string>> aliases,
|
||||
HashSet<string> scalarArgs)
|
||||
{
|
||||
if (IsGuestCallTarget(target))
|
||||
@@ -1026,14 +873,14 @@ public sealed class InferredGuestFunctionAbiProvider : IGuestFunctionAbiProvider
|
||||
arg.RegisterName != null &&
|
||||
abi.HasScalarFloatArgument(Base(arg.RegisterName)))
|
||||
{
|
||||
MarkScalarUse(arg, keys, aliases, scalarArgs);
|
||||
MarkScalarUse(arg, aliases, scalarArgs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (writesFloatReturnRegister)
|
||||
{
|
||||
aliases[F1AliasKey] = -1;
|
||||
aliases.Remove("f1");
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1045,7 +892,7 @@ public sealed class InferredGuestFunctionAbiProvider : IGuestFunctionAbiProvider
|
||||
|
||||
foreach (var arg in arguments)
|
||||
{
|
||||
MarkScalarUse(arg, keys, aliases, scalarArgs);
|
||||
MarkScalarUse(arg, aliases, scalarArgs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1236,8 +1083,7 @@ public sealed class InferredGuestFunctionAbiProvider : IGuestFunctionAbiProvider
|
||||
|
||||
private static void MarkScalarUse(
|
||||
IrValue value,
|
||||
AliasKeys keys,
|
||||
int[] aliases,
|
||||
IReadOnlyDictionary<string, HashSet<string>> aliases,
|
||||
HashSet<string> scalarArgs)
|
||||
{
|
||||
if (value.Kind != "register" || value.RegisterName == null)
|
||||
@@ -1246,22 +1092,24 @@ public sealed class InferredGuestFunctionAbiProvider : IGuestFunctionAbiProvider
|
||||
}
|
||||
|
||||
var baseName = Base(value.RegisterName);
|
||||
if (!IsFloatRegister(baseName) || !keys.TryGet(baseName, out var key) || aliases[key] < 0)
|
||||
if (!IsFloatRegister(baseName) || !aliases.TryGetValue(baseName, out var sources))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (var sources = aliases[key]; sources != 0; sources &= sources - 1)
|
||||
foreach (var source in sources)
|
||||
{
|
||||
scalarArgs.Add(AbiFloatArgumentRegisters[BitOperations.TrailingZeroCount(sources)]);
|
||||
if (AbiFloatArgumentSet.Contains(source))
|
||||
{
|
||||
scalarArgs.Add(source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void PropagateFloatAlias(
|
||||
string destination,
|
||||
IrValue value,
|
||||
AliasKeys keys,
|
||||
int[] aliases)
|
||||
Dictionary<string, HashSet<string>> aliases)
|
||||
{
|
||||
var destBase = Base(destination);
|
||||
if (!IsFloatRegister(destBase))
|
||||
@@ -1269,27 +1117,24 @@ public sealed class InferredGuestFunctionAbiProvider : IGuestFunctionAbiProvider
|
||||
return;
|
||||
}
|
||||
|
||||
keys.TryGet(destBase, out var destKey);
|
||||
if (value.Kind == "register" &&
|
||||
value.RegisterName != null &&
|
||||
IsFloatRegister(Base(value.RegisterName)) &&
|
||||
keys.TryGet(Base(value.RegisterName), out var sourceKey) &&
|
||||
aliases[sourceKey] >= 0)
|
||||
aliases.TryGetValue(Base(value.RegisterName), out var srcAliases))
|
||||
{
|
||||
aliases[destKey] = aliases[sourceKey];
|
||||
aliases[destBase] = new HashSet<string>(srcAliases, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
else
|
||||
{
|
||||
aliases[destKey] = -1;
|
||||
aliases.Remove(destBase);
|
||||
}
|
||||
}
|
||||
|
||||
private static void KillFloatAlias(string destination, AliasKeys keys, int[] aliases)
|
||||
private static void KillFloatAlias(string destination, Dictionary<string, HashSet<string>> aliases)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(destination) && IsFloatRegister(Base(destination)) &&
|
||||
keys.TryGet(Base(destination), out var key))
|
||||
if (!string.IsNullOrWhiteSpace(destination) && IsFloatRegister(Base(destination)))
|
||||
{
|
||||
aliases[key] = -1;
|
||||
aliases.Remove(Base(destination));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1301,6 +1146,70 @@ public sealed class InferredGuestFunctionAbiProvider : IGuestFunctionAbiProvider
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, HashSet<string>> CreateInitialArgumentAliasState()
|
||||
{
|
||||
return AbiFloatArgumentRegisters.ToDictionary(
|
||||
static r => r,
|
||||
static r => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { r },
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static Dictionary<string, HashSet<string>> UnionPredecessorStates(
|
||||
IReadOnlyList<string> predecessors,
|
||||
IReadOnlyDictionary<string, Dictionary<string, HashSet<string>>> outMap)
|
||||
{
|
||||
var result = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pred in predecessors)
|
||||
{
|
||||
if (!outMap.TryGetValue(pred, out var predState))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var (register, sources) in predState)
|
||||
{
|
||||
if (!result.TryGetValue(register, out var destSources))
|
||||
{
|
||||
destSources = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
result[register] = destSources;
|
||||
}
|
||||
|
||||
destSources.UnionWith(sources);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Dictionary<string, HashSet<string>> CopyAliasState(
|
||||
IReadOnlyDictionary<string, HashSet<string>> state)
|
||||
{
|
||||
return state.ToDictionary(
|
||||
static kv => kv.Key,
|
||||
static kv => new HashSet<string>(kv.Value, StringComparer.OrdinalIgnoreCase),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool AliasStatesEqual(
|
||||
IReadOnlyDictionary<string, HashSet<string>> left,
|
||||
IReadOnlyDictionary<string, HashSet<string>> right)
|
||||
{
|
||||
if (left.Count != right.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var (key, leftSet) in left)
|
||||
{
|
||||
if (!right.TryGetValue(key, out var rightSet) || !leftSet.SetEquals(rightSet))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsGuestCallTarget(string target)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(target))
|
||||
|
||||
@@ -83,11 +83,9 @@ public static partial class GeneratedMarkers
|
||||
|
||||
/// <summary>
|
||||
/// <c>REGISTER_NATIVE_FUNCTION(0x…, symbol)</c> and its <c>_AS</c> alias form;
|
||||
/// the <c>as</c> group tells the two apart. <c>REGISTER_NATIVE_WRAPPER_AS</c> and
|
||||
/// <c>REGISTER_NATIVE_REPLACEMENT_AS</c> are alias forms too, named by the
|
||||
/// <c>modOverlay</c> group.
|
||||
/// the <c>as</c> group tells the two apart.
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"REGISTER_NATIVE_(?:FUNCTION(?<as>_AS)?|(?<modOverlay>WRAPPER|REPLACEMENT)_AS)\s*\(\s*0x(?<address>[0-9A-Fa-f]{8})\s*,\s*(?<symbol>[A-Za-z_][A-Za-z0-9_]*)", RegexOptions.CultureInvariant)]
|
||||
[GeneratedRegex(@"REGISTER_NATIVE_FUNCTION(?<as>_AS)?\s*\(\s*0x(?<address>[0-9A-Fa-f]{8})\s*,\s*(?<symbol>[A-Za-z_][A-Za-z0-9_]*)", RegexOptions.CultureInvariant)]
|
||||
public static partial Regex NativeFunctionRegistrationPattern();
|
||||
|
||||
[GeneratedRegex(@"REGISTER_TRANSLATED_FUNCTION\s*\(\s*0x(?<address>[0-9A-Fa-f]{8})\s*,\s*(?<symbol>[A-Za-z_][A-Za-z0-9_]*)", RegexOptions.CultureInvariant)]
|
||||
|
||||
@@ -11,19 +11,7 @@ public sealed record RuntimeNativeRegistration(
|
||||
string Symbol,
|
||||
string SourceFile,
|
||||
bool IsTranslatedOverride,
|
||||
bool ExcludesBaseTranslation,
|
||||
NativeModOverlay ModOverlay = NativeModOverlay.Unspecified);
|
||||
|
||||
/// <summary>What happens to a mod overlay at a native's address.</summary>
|
||||
public enum NativeModOverlay
|
||||
{
|
||||
/// <summary>Plain registration: the overlay is lost, and reported as such.</summary>
|
||||
Unspecified,
|
||||
/// <summary>The native calls through to the overlay (<c>MKW_GUEST_NEXT</c>).</summary>
|
||||
Wrapper,
|
||||
/// <summary>The native deliberately takes over the overlay's address.</summary>
|
||||
Replacement
|
||||
}
|
||||
bool ExcludesBaseTranslation);
|
||||
|
||||
public sealed record RuntimeNativeAbiEntry(
|
||||
uint Address,
|
||||
@@ -131,17 +119,7 @@ public static class RuntimeNativeIndexBuilder
|
||||
{
|
||||
var source = sourceFile.Content;
|
||||
foreach (Match match in GeneratedMarkers.NativeFunctionRegistrationPattern().Matches(source))
|
||||
{
|
||||
var modOverlay = match.Groups["modOverlay"];
|
||||
Add(match, match.Groups["symbol"].Value, false,
|
||||
!match.Groups["as"].Success && !modOverlay.Success,
|
||||
modOverlay.Value switch
|
||||
{
|
||||
"WRAPPER" => NativeModOverlay.Wrapper,
|
||||
"REPLACEMENT" => NativeModOverlay.Replacement,
|
||||
_ => NativeModOverlay.Unspecified
|
||||
});
|
||||
}
|
||||
Add(match, match.Groups["symbol"].Value, false, !match.Groups["as"].Success);
|
||||
foreach (Match match in GeneratedMarkers.TranslatedFunctionRegistrationPattern().Matches(source))
|
||||
Add(match, match.Groups["symbol"].Value, true, true);
|
||||
foreach (Match match in GeneratedMarkers.NativeOverridePattern().Matches(source))
|
||||
@@ -149,11 +127,10 @@ public static class RuntimeNativeIndexBuilder
|
||||
foreach (Match match in GeneratedMarkers.FatalStubPattern().Matches(source))
|
||||
Add(match, $"GX_FATAL_STUB_{match.Groups["address"].Value}", false, true);
|
||||
|
||||
void Add(Match match, string symbol, bool translated, bool excludesBase,
|
||||
NativeModOverlay modOverlay = NativeModOverlay.Unspecified) =>
|
||||
void Add(Match match, string symbol, bool translated, bool excludesBase) =>
|
||||
registrations.Add(new RuntimeNativeRegistration(
|
||||
ParseAddress(match.Groups["address"].Value), symbol, sourceFile.RelativePath,
|
||||
translated, excludesBase, modOverlay));
|
||||
translated, excludesBase));
|
||||
}
|
||||
|
||||
return registrations
|
||||
@@ -162,8 +139,7 @@ public static class RuntimeNativeIndexBuilder
|
||||
.ThenBy(static registration => registration.Symbol, StringComparer.Ordinal)
|
||||
.ThenBy(static registration => registration.SourceFile, StringComparer.Ordinal)
|
||||
.ThenBy(static registration => registration.IsTranslatedOverride)
|
||||
.ThenBy(static registration => registration.ExcludesBaseTranslation)
|
||||
.ThenBy(static registration => registration.ModOverlay);
|
||||
.ThenBy(static registration => registration.ExcludesBaseTranslation);
|
||||
}
|
||||
|
||||
private static uint ParseAddress(string value) => GuestTargetParser.ParseHexAddress(value);
|
||||
|
||||
@@ -14,9 +14,9 @@ namespace Translator.Core;
|
||||
public static class RuntimeNativeIndexFile
|
||||
{
|
||||
public const string Format = "wiicompiled-runtime-native-index";
|
||||
public const int FormatVersion = 2;
|
||||
public const int FormatVersion = 1;
|
||||
// Bump whenever the scanner or the ABI/effect analyzers can produce different output for the same sources.
|
||||
public const int ExtractorVersion = 2;
|
||||
public const int ExtractorVersion = 1;
|
||||
public const string DefaultFileName = "native-index.json";
|
||||
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
@@ -59,15 +59,13 @@ public static class RuntimeNativeIndexFile
|
||||
.ThenBy(static registration => registration.SourceFile, StringComparer.Ordinal)
|
||||
.ThenBy(static registration => registration.IsTranslatedOverride)
|
||||
.ThenBy(static registration => registration.ExcludesBaseTranslation)
|
||||
.ThenBy(static registration => registration.ModOverlay)
|
||||
.Select(static registration => new RegistrationDto
|
||||
{
|
||||
Address = FormatAddress(registration.Address),
|
||||
Symbol = registration.Symbol,
|
||||
SourceFile = registration.SourceFile,
|
||||
IsTranslatedOverride = registration.IsTranslatedOverride,
|
||||
ExcludesBaseTranslation = registration.ExcludesBaseTranslation,
|
||||
ModOverlay = registration.ModOverlay.ToString()
|
||||
ExcludesBaseTranslation = registration.ExcludesBaseTranslation
|
||||
})
|
||||
.ToList(),
|
||||
VoidStubAbis = index.VoidStubAbis
|
||||
@@ -162,8 +160,7 @@ public static class RuntimeNativeIndexFile
|
||||
Present(registration.Symbol, "symbol"),
|
||||
Present(registration.SourceFile, "sourceFile"),
|
||||
registration.IsTranslatedOverride,
|
||||
registration.ExcludesBaseTranslation,
|
||||
ParseModOverlay(Present(registration.ModOverlay, "modOverlay"))))
|
||||
registration.ExcludesBaseTranslation))
|
||||
.ToArray(),
|
||||
Present(document.VoidStubAbis, "voidStubAbis")
|
||||
.Select(static entry => new RuntimeNativeAbiEntry(
|
||||
@@ -188,11 +185,6 @@ public static class RuntimeNativeIndexFile
|
||||
|
||||
private static string FormatAddress(uint address) => $"0x{address:X8}";
|
||||
|
||||
private static NativeModOverlay ParseModOverlay(string text) =>
|
||||
Enum.TryParse<NativeModOverlay>(text, ignoreCase: false, out var value) && Enum.IsDefined(value)
|
||||
? value
|
||||
: throw new FormatException($"Unknown modOverlay '{text}'.");
|
||||
|
||||
private static uint ParseAddress(string text) => GuestTargetParser.ParseHexAddress(text);
|
||||
|
||||
private static T Present<T>(T? value, string name) where T : class =>
|
||||
@@ -252,7 +244,6 @@ public static class RuntimeNativeIndexFile
|
||||
public required string SourceFile { get; init; }
|
||||
public required bool IsTranslatedOverride { get; init; }
|
||||
public required bool ExcludesBaseTranslation { get; init; }
|
||||
public required string ModOverlay { get; init; }
|
||||
}
|
||||
|
||||
private sealed class AbiDto
|
||||
|
||||
@@ -30,14 +30,6 @@ public sealed class CanonicalIrStore : ICanonicalIrProvider
|
||||
}
|
||||
}
|
||||
|
||||
public void Remove(uint entryPoint)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_entries.Remove(entryPoint);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGet(uint entryPoint, out IrFunction function)
|
||||
{
|
||||
byte[]? encoded;
|
||||
@@ -63,16 +55,12 @@ internal static class CanonicalIrBinaryCodec
|
||||
IndirectJump, JumpTable, Return, Comment, TracePpc, Undefined
|
||||
}
|
||||
|
||||
// Reused per thread so encoding doesn't regrow (and re-fault) a fresh buffer per function.
|
||||
[ThreadStatic] private static MemoryStream? _encodeStream;
|
||||
|
||||
public static byte[] Encode(IrFunction function)
|
||||
{
|
||||
var strings = BuildStringTable(function);
|
||||
var ids = strings.Select((value, index) => (value, index))
|
||||
.ToDictionary(static pair => pair.value, static pair => pair.index, StringComparer.Ordinal);
|
||||
var stream = _encodeStream ??= new MemoryStream();
|
||||
stream.SetLength(0);
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true);
|
||||
writer.Write(Magic);
|
||||
WriteVarUInt(writer, (uint)CanonicalIrStore.FormatVersion);
|
||||
@@ -97,11 +85,9 @@ internal static class CanonicalIrBinaryCodec
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
public static IrFunction Decode(ReadOnlySpan<byte> encoded) => Decode(encoded.ToArray());
|
||||
|
||||
public static IrFunction Decode(byte[] encoded)
|
||||
public static IrFunction Decode(ReadOnlySpan<byte> encoded)
|
||||
{
|
||||
using var stream = new MemoryStream(encoded, writable: false);
|
||||
using var stream = new MemoryStream(encoded.ToArray(), writable: false);
|
||||
using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true);
|
||||
if (!reader.ReadBytes(Magic.Length).SequenceEqual(Magic) ||
|
||||
ReadVarUInt(reader) != CanonicalIrStore.FormatVersion)
|
||||
|
||||
@@ -497,52 +497,12 @@ public static class GqrInterproceduralAnalysis
|
||||
return masks;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unknown-entry roots that depend only on the final call graph, never on discovery order:
|
||||
/// <paramref name="fixedRoots"/>, every candidate without a direct caller, then (ascending) any
|
||||
/// candidate the roots still don't reach, so no candidate is left without an entry fact source.
|
||||
/// </summary>
|
||||
public static HashSet<uint> SelectUnknownEntryRoots(
|
||||
IReadOnlyDictionary<uint, GqrFunctionSummary> functions,
|
||||
IEnumerable<uint> fixedRoots,
|
||||
IEnumerable<uint> candidates)
|
||||
{
|
||||
var called = new HashSet<uint>();
|
||||
foreach (var summary in functions.Values)
|
||||
foreach (var target in summary.DirectGuestTargets)
|
||||
called.Add(target);
|
||||
var ordered = candidates.Where(functions.ContainsKey).Distinct().OrderBy(static a => a).ToArray();
|
||||
var roots = fixedRoots.Where(functions.ContainsKey).ToHashSet();
|
||||
foreach (var address in ordered)
|
||||
if (!called.Contains(address))
|
||||
roots.Add(address);
|
||||
var reachable = ComputeCompactReachable(functions, roots);
|
||||
foreach (var address in ordered)
|
||||
{
|
||||
if (reachable.Contains(address)) continue;
|
||||
roots.Add(address);
|
||||
ExtendCompactReachable(functions, reachable, address);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
private static HashSet<uint> ComputeCompactReachable(
|
||||
IReadOnlyDictionary<uint, GqrFunctionSummary> functions,
|
||||
IReadOnlySet<uint> roots)
|
||||
{
|
||||
var reachable = new HashSet<uint>();
|
||||
foreach (var root in roots)
|
||||
ExtendCompactReachable(functions, reachable, root);
|
||||
return reachable;
|
||||
}
|
||||
|
||||
private static void ExtendCompactReachable(
|
||||
IReadOnlyDictionary<uint, GqrFunctionSummary> functions,
|
||||
HashSet<uint> reachable,
|
||||
uint root)
|
||||
{
|
||||
var queue = new Queue<uint>();
|
||||
queue.Enqueue(root);
|
||||
var queue = new Queue<uint>(roots);
|
||||
while (queue.TryDequeue(out var address))
|
||||
{
|
||||
if (!reachable.Add(address)) continue;
|
||||
@@ -550,6 +510,7 @@ public static class GqrInterproceduralAnalysis
|
||||
if (functions.ContainsKey(target) && !reachable.Contains(target))
|
||||
queue.Enqueue(target);
|
||||
}
|
||||
return reachable;
|
||||
}
|
||||
|
||||
private static void TransferCompact(
|
||||
|
||||
@@ -167,31 +167,6 @@ public sealed class GqrConstantPropagationTests
|
||||
instruction => instruction is IrCall { Target: "PPC_PsqLKnown_00070007" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownEntryRootsDependOnlyOnTheCallGraph()
|
||||
{
|
||||
IrFunction Calls(params uint[] targets) => Function(targets
|
||||
.Select(target => (IrInstruction)new IrCall(string.Empty, $"0x{target:X8}", Array.Empty<IrValue>()))
|
||||
.Append(new IrReturn(null))
|
||||
.ToArray());
|
||||
var functions = new Dictionary<uint, IrFunction>
|
||||
{
|
||||
[0x80001000] = Calls(),
|
||||
[0x80002000] = Calls(0x80003000),
|
||||
[0x80003000] = Calls(),
|
||||
[0x80004000] = Calls(0x80005000),
|
||||
[0x80005000] = Calls(0x80004000)
|
||||
};
|
||||
var summaries = functions.ToDictionary(pair => pair.Key, pair => GqrFunctionSummary.Create(pair.Value));
|
||||
uint[] candidates = [0x80002000, 0x80003000, 0x80004000, 0x80005000];
|
||||
|
||||
var roots = GqrInterproceduralAnalysis.SelectUnknownEntryRoots(summaries, [0x80001000u], candidates);
|
||||
var reversed = GqrInterproceduralAnalysis.SelectUnknownEntryRoots(summaries, [0x80001000u], candidates.Reverse());
|
||||
|
||||
Assert.Equal(new uint[] { 0x80001000, 0x80002000, 0x80004000 }, roots.Order());
|
||||
Assert.Equal(roots.Order(), reversed.Order());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InterproceduralAnalysisIntersectsConflictingCallers()
|
||||
{
|
||||
|
||||
@@ -129,7 +129,7 @@ public sealed class GuestAddressTableTests
|
||||
var calls = Directory.EnumerateFiles(Path.Combine(root, "runtime"), "*.*", SearchOption.AllDirectories)
|
||||
.Where(path => (path.EndsWith(".cpp", StringComparison.Ordinal) || path.EndsWith(".h", StringComparison.Ordinal)) &&
|
||||
!path.Contains(thirdParty, StringComparison.Ordinal))
|
||||
.SelectMany(static path => Regex.Matches(File.ReadAllText(path), @"MKW_GUEST_(?:FUNC|NEXT)\(\s*([0-9A-Fa-f]{8})\s*\)")
|
||||
.SelectMany(static path => Regex.Matches(File.ReadAllText(path), @"MKW_GUEST_FUNC\(\s*([0-9A-Fa-f]{8})\s*\)")
|
||||
.Select(static match => Convert.ToUInt32(match.Groups[1].Value, 16)))
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
@@ -145,7 +145,7 @@ public sealed class GuestAddressTableTests
|
||||
foreach (var (region, starts) in entries)
|
||||
{
|
||||
var address = table.Map(pal, region);
|
||||
Assert.True(starts.Contains(address), $"MKW_GUEST_FUNC/NEXT({pal:X8}) is not a {region} function entry (0x{address:X8})");
|
||||
Assert.True(starts.Contains(address), $"MKW_GUEST_FUNC({pal:X8}) is not a {region} function entry (0x{address:X8})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Translator.Core;
|
||||
using Translator.Core.CodeGen;
|
||||
using Xunit;
|
||||
@@ -41,24 +40,14 @@ public sealed class RuntimeNativeIndexTests
|
||||
PPC_NATIVE_OVERRIDE_VOID(80000040, Stub, (void), ());
|
||||
GX_FATAL_STUB(80000050, "Fatal")
|
||||
// REGISTER_NATIVE_FUNCTION(0x80000060, CommentedOut);
|
||||
REGISTER_NATIVE_WRAPPER_AS(0x80000070, Wrapper, "wrapper");
|
||||
REGISTER_NATIVE_REPLACEMENT_AS(0x80000080, Replacement, "replacement");
|
||||
""");
|
||||
|
||||
var registrations = RuntimeNativeIndexBuilder.Build(directory).Registrations;
|
||||
Assert.Equal(7, registrations.Length);
|
||||
Assert.Equal(5, registrations.Length);
|
||||
Assert.Contains(registrations, static entry =>
|
||||
entry.Address == 0x80000010u && entry.ExcludesBaseTranslation &&
|
||||
entry.ModOverlay == NativeModOverlay.Unspecified);
|
||||
entry.Address == 0x80000010u && entry.ExcludesBaseTranslation);
|
||||
Assert.Contains(registrations, static entry =>
|
||||
entry.Address == 0x80000020u && !entry.ExcludesBaseTranslation &&
|
||||
entry.ModOverlay == NativeModOverlay.Unspecified);
|
||||
Assert.Contains(registrations, static entry =>
|
||||
entry.Address == 0x80000070u && entry.Symbol == "Wrapper" && !entry.ExcludesBaseTranslation &&
|
||||
entry.ModOverlay == NativeModOverlay.Wrapper);
|
||||
Assert.Contains(registrations, static entry =>
|
||||
entry.Address == 0x80000080u && entry.Symbol == "Replacement" && !entry.ExcludesBaseTranslation &&
|
||||
entry.ModOverlay == NativeModOverlay.Replacement);
|
||||
entry.Address == 0x80000020u && !entry.ExcludesBaseTranslation);
|
||||
Assert.Contains(registrations, static entry =>
|
||||
entry.Address == 0x80000030u && entry.IsTranslatedOverride);
|
||||
Assert.Contains(registrations, static entry =>
|
||||
@@ -119,8 +108,6 @@ public sealed class RuntimeNativeIndexTests
|
||||
var sources = Path.Combine(ProjectPathsForTests.FindRepositoryRoot(), "runtime", "src");
|
||||
var built = RuntimeNativeIndexBuilder.Build(sources);
|
||||
Assert.NotEmpty(built.Registrations);
|
||||
Assert.Contains(built.Registrations, static entry => entry.ModOverlay == NativeModOverlay.Wrapper);
|
||||
Assert.Contains(built.Registrations, static entry => entry.ModOverlay == NativeModOverlay.Replacement);
|
||||
Assert.NotEmpty(built.VoidStubAbis);
|
||||
Assert.NotEmpty(built.Effects);
|
||||
|
||||
@@ -139,26 +126,6 @@ public sealed class RuntimeNativeIndexTests
|
||||
}
|
||||
}
|
||||
|
||||
// A wrapper that calls through with MKW_GUEST_FUNC would silently drop the mod overlay it promises to run.
|
||||
[Fact]
|
||||
public void RepositoryWrapperNativesCallThroughToTheNextTranslation()
|
||||
{
|
||||
var sources = Path.Combine(ProjectPathsForTests.FindRepositoryRoot(), "runtime", "src");
|
||||
var wrappers = RuntimeNativeIndexBuilder.Build(sources).Registrations
|
||||
.Where(static entry => entry.ModOverlay == NativeModOverlay.Wrapper)
|
||||
.ToArray();
|
||||
Assert.NotEmpty(wrappers);
|
||||
foreach (var wrapper in wrappers)
|
||||
{
|
||||
var source = File.ReadAllText(Path.Combine(sources, wrapper.SourceFile));
|
||||
var pal = $"{wrapper.Address:X8}";
|
||||
Assert.True(Regex.IsMatch(source, $@"MKW_GUEST_NEXT\(\s*{pal}\s*\)", RegexOptions.IgnoreCase),
|
||||
$"{wrapper.Symbol} is a wrapper but never calls MKW_GUEST_NEXT({pal})");
|
||||
Assert.False(Regex.IsMatch(source, $@"MKW_GUEST_FUNC\(\s*{pal}\s*\)", RegexOptions.IgnoreCase),
|
||||
$"{wrapper.Symbol} is a wrapper but calls MKW_GUEST_FUNC({pal})");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveUsesAMatchingIndexAndKeepsWorkingWithoutSources()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user