Compare commits

..

3 Commits

Author SHA1 Message Date
patchzyy cbb769e831 bump aurora 2026-10-24 16:52:26 +02:00
patchzyy 03575bf00f bump aurora 2026-10-24 16:42:19 +02:00
patchzyy f372bd7086 add headless race option 2026-10-24 16:33:38 +02:00
30 changed files with 536 additions and 2169 deletions
-18
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 -7
View File
@@ -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)
-12
View File
@@ -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; \
}())
-1
View File
@@ -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
View File
@@ -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;
+10 -11
View File
@@ -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
+3 -9
View File
@@ -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;
}
}
+1 -1
View File
@@ -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
View File
@@ -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.
+2 -15
View File
@@ -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;
-348
View File
@@ -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()
-352
View File
@@ -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()
-332
View File
@@ -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()
-163
View File
@@ -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()
+106 -134
View File
@@ -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()
{