mirror of
https://github.com/patchzyy/wiicompiled
synced 2026-10-30 08:39:38 -04:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37a0b68d85 | |||
| b748155cdb | |||
| eb99d8652b | |||
| ee8443423d | |||
| 3645c4af1f | |||
| ae54b6e082 | |||
| af5a45e270 | |||
| 896dcef9e3 | |||
| f02d000ba8 | |||
| 814aa304bb | |||
| 48aa27d77e | |||
| 27e9be32ed | |||
| cc6abd49ab | |||
| bb5e2b1166 | |||
| 4993b2226c | |||
| e15be3bf55 | |||
| 85992f85b0 |
@@ -102,6 +102,7 @@ jobs:
|
||||
mkw_nand_settings_tests \
|
||||
mkw_sc_serial_tests \
|
||||
mkw_input_expr_tests \
|
||||
mkw_motion_trick_tests \
|
||||
mkw_bluetooth_controller_tests \
|
||||
mkw_macos_native_compile \
|
||||
mkw_macos_context_abi_tests \
|
||||
|
||||
@@ -278,6 +278,24 @@ $packagedRuntimeFingerprint = (Get-Content -LiteralPath (Join-Path $workspace 'D
|
||||
if ($stagedRuntimeFingerprint -ne $packagedRuntimeFingerprint) {
|
||||
throw "The staged runtime ($stagedRuntimeFingerprint) does not match the precompiled runtime archive ($packagedRuntimeFingerprint)."
|
||||
}
|
||||
# Users' builds run LocalBuild.ps1 under Windows PowerShell 5.1, whatever shell built this
|
||||
# installer, so the staged package is accepted (or not) exactly the way theirs will be.
|
||||
$prebuiltGate = Join-Path $workRoot 'native-prebuilt-gate.ps1'
|
||||
@'
|
||||
param([string]$Workspace, [string]$Toolkit)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version 3.0
|
||||
. (Join-Path $Workspace 'NativeBuildFlags.ps1')
|
||||
$package = Resolve-MkwNativePrebuiltDirectory (Join-Path $Workspace 'Dependencies') `
|
||||
(Join-Path $Toolkit 'llvm-mingw\bin') (Join-Path $Workspace 'aurora-main') (Join-Path $Workspace 'runtime\third_party')
|
||||
if (-not $package) { exit 3 }
|
||||
'@ | Set-Content -LiteralPath $prebuiltGate -Encoding UTF8
|
||||
& (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') -NoLogo -NoProfile -NonInteractive `
|
||||
-ExecutionPolicy Bypass -File $prebuiltGate -Workspace $workspace -Toolkit $toolkit
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Windows PowerShell rejects the staged precompiled aurora package (exit $LASTEXITCODE); every installation would build aurora from source."
|
||||
}
|
||||
Remove-Item -LiteralPath $prebuiltGate -Force
|
||||
|
||||
[IO.Directory]::CreateDirectory((Join-Path $payloadRoot 'host')) | Out-Null
|
||||
Copy-Item $setupHost (Join-Path $payloadRoot 'host\WiiCompiled-Setup.exe')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- The one product version. Release tags must match it. -->
|
||||
<Version>0.9.1</Version>
|
||||
<Version>0.9.3</Version>
|
||||
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
+26
-71
@@ -34,65 +34,6 @@ 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
|
||||
@@ -160,6 +101,8 @@ 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'
|
||||
@@ -208,8 +151,9 @@ 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)))
|
||||
[math]::Floor(($memoryGiB - 2) / 0.5)))
|
||||
$globalJobs = [math]::Max($translatedJobs, [Environment]::ProcessorCount)
|
||||
}
|
||||
|
||||
@@ -286,11 +230,18 @@ 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.
|
||||
& $translator @(
|
||||
'check-base-mod-awareness', '--project', $project, '--profile', 'retro-rewind',
|
||||
'--translation-output-metadata', $baseMetadata, '--code-pul', $retroCodePul
|
||||
) | Write-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
$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) {
|
||||
Write-MkwBuildStep 'retranslate-base' 'The base translation is stale; retranslating the base game for the new Code.pul'
|
||||
$reuseBase = $false
|
||||
}
|
||||
@@ -313,12 +264,14 @@ 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'
|
||||
) 'Translating the user-owned base game' -StepId 'translate-base' `
|
||||
-TimeoutSeconds $translatorTimeoutSeconds
|
||||
|
||||
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'
|
||||
) 'Creating the local base translation manifest' -StepId 'emit-base-manifest' `
|
||||
-TimeoutSeconds $translatorTimeoutSeconds
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($TranslationFingerprint)) {
|
||||
[ordered]@{ SchemaVersion = 1; TranslationFingerprint = $TranslationFingerprint; DolSha256 = $pins.DolSha256 } |
|
||||
@@ -348,11 +301,12 @@ try {
|
||||
throw 'Retro Rewind requires either -RetroWfcOfflineDirectory or -SkipRetroWfcPayload.'
|
||||
}
|
||||
Invoke-Checked $translator $translateModArguments 'Translating the selected Retro Rewind Code.pul' `
|
||||
-StepId 'translate-mod'
|
||||
-StepId 'translate-mod' -TimeoutSeconds $translatorTimeoutSeconds
|
||||
}
|
||||
|
||||
Invoke-Checked $translator @('generate-data-init', '--project', $project) `
|
||||
'Generating local game data initialization' -StepId 'generate-data-init'
|
||||
'Generating local game data initialization' -StepId 'generate-data-init' `
|
||||
-TimeoutSeconds $translatorTimeoutSeconds
|
||||
|
||||
$shardArgs = @(
|
||||
'emit-build-shards', '--project', $project, '--base-metadata', $baseMetadata,
|
||||
@@ -364,7 +318,8 @@ 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'
|
||||
Invoke-Checked $translator $shardArgs 'Preparing local native build shards' -StepId 'emit-build-shards' `
|
||||
-TimeoutSeconds $translatorTimeoutSeconds
|
||||
|
||||
# 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
|
||||
@@ -396,7 +351,7 @@ try {
|
||||
}
|
||||
}
|
||||
$dependencies = Join-Path $Workspace 'Dependencies'
|
||||
$nativePrebuilt = Resolve-NativePrebuiltDirectory $dependencies $toolchainBin `
|
||||
$nativePrebuilt = Resolve-MkwNativePrebuiltDirectory $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'
|
||||
|
||||
+118
-54
@@ -157,7 +157,8 @@ function Resolve-MkwProjectForAssets([string]$Workspace, [string]$AssetsDirector
|
||||
}
|
||||
|
||||
function Invoke-Checked([string]$FilePath, [string[]]$Arguments, [string]$Description,
|
||||
[string]$LogPrefix = 'MKWCBUILD', [string]$StepId = '', [bool]$WaitForProcessTree = $true) {
|
||||
[string]$LogPrefix = 'MKWCBUILD', [string]$StepId = '', [bool]$WaitForProcessTree = $true,
|
||||
[int]$TimeoutSeconds = 0) {
|
||||
<#
|
||||
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
|
||||
@@ -166,6 +167,7 @@ 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" }
|
||||
@@ -174,7 +176,17 @@ function Invoke-Checked([string]$FilePath, [string[]]$Arguments, [string]$Descri
|
||||
# Backslashes directly before the closing quote would otherwise escape it.
|
||||
'"' + ($_ -replace '(\\+)$', '$1$1') + '"'
|
||||
})
|
||||
if ($WaitForProcessTree) {
|
||||
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) {
|
||||
$process = Start-Process -FilePath $FilePath -ArgumentList $quotedArguments `
|
||||
-NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.ExitCode
|
||||
@@ -236,69 +248,121 @@ function Get-MkwDawnRuntimeSha256([string]$DependenciesDirectory) {
|
||||
return Get-MkwFileSha256 $runtime
|
||||
}
|
||||
|
||||
function Get-MkwThirdPartySourceFingerprint([string]$ThirdPartyDirectory) {
|
||||
function ConvertTo-MkwHex([byte[]]$Bytes) {
|
||||
return [BitConverter]::ToString($Bytes).Replace('-', '').ToLowerInvariant()
|
||||
}
|
||||
|
||||
function Get-MkwSourceTreeFingerprint([string]$Directory, [string[]]$ExcludedTopDirectories = @()) {
|
||||
<#
|
||||
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.
|
||||
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.
|
||||
#>
|
||||
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('\', '/') }
|
||||
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)
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$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")
|
||||
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")
|
||||
}
|
||||
$bytes = $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($builder.ToString()))
|
||||
return ConvertTo-MkwHex $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($builder.ToString()))
|
||||
} finally { $sha.Dispose() }
|
||||
return (($bytes | ForEach-Object { $_.ToString('x2') }) -join '')
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function Get-MkwAuroraSourceFingerprint([string]$AuroraDirectory) {
|
||||
<#
|
||||
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.
|
||||
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.
|
||||
#>
|
||||
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()
|
||||
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 ''
|
||||
}
|
||||
try {
|
||||
$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")
|
||||
$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 ''
|
||||
}
|
||||
$bytes = $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($builder.ToString()))
|
||||
} finally { $sha.Dispose() }
|
||||
return (($bytes | ForEach-Object { $_.ToString('x2') }) -join '')
|
||||
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
|
||||
}
|
||||
|
||||
function ConvertTo-MkwCMakePath([string]$Path) {
|
||||
@@ -347,9 +411,9 @@ function Get-MkwNativeConfigureArguments {
|
||||
}
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($NativePrebuiltDirectory)) {
|
||||
$arguments += "-DMKW_NATIVE_PREBUILT_DIR=$(ConvertTo-MkwCMakePath $NativePrebuiltDirectory)"
|
||||
}
|
||||
# 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)" }
|
||||
|
||||
$arguments += $AdditionalArguments
|
||||
return $arguments
|
||||
|
||||
@@ -64,6 +64,7 @@ done
|
||||
macos_arch=$(uname -m)
|
||||
case "$macos_arch" in arm64|x86_64) ;; *) fail "unsupported macOS architecture: $macos_arch" ;; esac
|
||||
macos_deployment_target=12.0
|
||||
macos_sysroot=$(xcrun --show-sdk-path 2>/dev/null) && [[ -d "$macos_sysroot" ]] || fail 'could not resolve the macOS SDK; install the Xcode Command Line Tools (xcode-select --install)'
|
||||
workspace=$(cd "$workspace" && pwd)
|
||||
if [[ -z "$setup_version" && -f "$workspace/Launcher/Directory.Build.props" ]]; then
|
||||
setup_version=$(sed -nE 's/.*<Version>([^<]+)<\/Version>.*/\1/p' "$workspace/Launcher/Directory.Build.props" | head -n 1)
|
||||
@@ -186,7 +187,7 @@ fi
|
||||
step configure-native 'Configuring the native toolchain'
|
||||
# Use Aurora's pinned SDL3 source on macOS. A system SDL3 can be older than
|
||||
# Aurora's required API even when find_package() succeeds.
|
||||
"$cmake_bin" -S "$workspace/runtime" -B "$native_build" -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER="$cc_bin" -DCMAKE_CXX_COMPILER="$cxx_bin" -DCMAKE_MAKE_PROGRAM="$ninja_bin" -DCMAKE_OSX_ARCHITECTURES="$macos_arch" -DCMAKE_OSX_DEPLOYMENT_TARGET="$macos_deployment_target" -DMKW_TRANSLATED_COMPILE_JOBS="$translated_jobs" -DMKW_X86_CPU_PROFILE="$x86_cpu_profile" -DAURORA_SDL3_PROVIDER=vendor -DMKW_SETUP_VERSION="$setup_version"
|
||||
"$cmake_bin" -S "$workspace/runtime" -B "$native_build" -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER="$cc_bin" -DCMAKE_CXX_COMPILER="$cxx_bin" -DCMAKE_MAKE_PROGRAM="$ninja_bin" -DCMAKE_OSX_ARCHITECTURES="$macos_arch" -DCMAKE_OSX_DEPLOYMENT_TARGET="$macos_deployment_target" -DCMAKE_OSX_SYSROOT="$macos_sysroot" -DMKW_TRANSLATED_COMPILE_JOBS="$translated_jobs" -DMKW_X86_CPU_PROFILE="$x86_cpu_profile" -DAURORA_SDL3_PROVIDER=vendor -DMKW_SETUP_VERSION="$setup_version"
|
||||
mkw_write_identity "$toolchain_provenance" ToolchainFingerprint "$toolchain_fingerprint"
|
||||
targets=(); [[ "$profile" != retro-rewind ]] && targets+=(WiiCompiled); [[ "$profile" != base ]] && targets+=(RetroRewind)
|
||||
step compile "Compiling ${targets[*]} locally"; "$cmake_bin" --build "$native_build" --target "${targets[@]}" --parallel "$global_jobs"
|
||||
|
||||
@@ -22,7 +22,7 @@ anywhere at runtime.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> There is no Nintendo code, no assets and no game data anywhere in this project or its releases.
|
||||
> You need your own legally dumped copy of the PAL version of the game. Setup only ships the
|
||||
> You need your own legally dumped copy of the PAL, NTSC-U, or NTSC-J version of the game. Setup only ships the
|
||||
> toolchain, the translation runs on your machine against your disc image, and nothing ever gets
|
||||
> uploaded.
|
||||
|
||||
@@ -103,14 +103,13 @@ Known limitations of the Wii Remote path:
|
||||
- About 20 GB of free disk space during installation (Final game size ~5 GB)
|
||||
- macOS 12 (Monterey) or later on Apple Silicon (`arm64`) or Intel (`x86_64-v3`); pre-Haswell Intel CPUs are unsupported
|
||||
- On macOS, a Metal-capable GPU and Apple Xcode Command Line Tools (Setup opens Apple's installer when they are missing)
|
||||
- A clean, unmodified **PAL `RMCP01`** disc image of Mario Kart Wii, dumped by you. ISO, GCM,
|
||||
- A clean, unmodified **PAL `RMCP01`**, **NTSC-U `RMCE01`**, or **NTSC-J `RMCJ01`** disc image of Mario Kart Wii, dumped by you. ISO, GCM,
|
||||
GCZ, CISO, WBFS, WIA and RVZ are accepted.
|
||||
|
||||
> [!NOTE]
|
||||
> GPU/CPU minimums are set by driver support and D3D12/Vulkan feature requirements, not by the game's actual demands.
|
||||
|
||||
Only the clean PAL revision will work. Anything else (other
|
||||
regions, patched executables) is rejected outright.
|
||||
Only the clean PAL, NTSC-U, and NTSC-J revisions will work. Anything else (NTSC-K, patched executables) is rejected outright.
|
||||
|
||||
> [!NOTE]
|
||||
> Nobody here will tell you where to get the game. Dumping your own disc is on you, and links to
|
||||
@@ -118,7 +117,7 @@ regions, patched executables) is rejected outright.
|
||||
|
||||
## Installing
|
||||
|
||||
For an easy experience, use [Wheel Wizard](https://github.com/TeamWheelWizard/WheelWizard). Pick your clean PAL `RMCP01`
|
||||
For an easy experience, use [Wheel Wizard](https://github.com/TeamWheelWizard/WheelWizard). Pick your clean PAL `RMCP01`, NTSC-U `RMCE01`, or NTSC-J `RMCJ01`
|
||||
image under Settings, turn on **WiiCompiled (beta)**, and hit install from the Home page.
|
||||
Wheel Wizard downloads the setup tool from this repo and walks you through install, updates and
|
||||
launching. The backend itself is deliberately command-line only, Wheel Wizard is a wrapper around it.
|
||||
@@ -128,7 +127,7 @@ launching. The backend itself is deliberately command-line only, Wheel Wizard is
|
||||
Download `WiiCompiled-Setup.pkg` from this repository's Releases page and open it. The universal
|
||||
package selects the appropriate bundled tools for the host architecture, supporting both Apple Silicon (`arm64`)
|
||||
and Intel (`x86_64`) Macs. It installs **WiiCompiled Setup** in Applications; open that app, choose
|
||||
your clean PAL `RMCP01` disc image, and select either the base game or Retro Rewind. For Retro Rewind, choose the `RetroRewind6` folder
|
||||
your clean PAL `RMCP01`, NTSC-U `RMCE01`, or NTSC-J `RMCJ01` disc image, and select either the base game or Retro Rewind. For Retro Rewind, choose the `RetroRewind6` folder
|
||||
or its parent folder.
|
||||
|
||||
Setup verifies and extracts the image locally, then translates and compiles the native app on your
|
||||
@@ -196,7 +195,7 @@ don't want to risk it right now, setup has to run a static recompiler over the w
|
||||
and then throw a C++ compiler at the result. It's a **one-time cost** on your machine.
|
||||
|
||||
**Which game version works?**
|
||||
Clean PAL `RMCP01`. Other regions and modified executables are **rejected**. Translating
|
||||
Clean PAL `RMCP01`, NTSC-U `RMCE01`, and NTSC-J `RMCJ01`. NTSC-K `RMCK01` and modified executables are **rejected**. Translating
|
||||
them against the wrong manifest would give you a subtly broken game that's miserable to debug for us.
|
||||
|
||||
**Can I recompile other GameCube/Wii games with it?**
|
||||
|
||||
+1
-1
Submodule aurora-main updated: 96bf4ac502...33067e34a1
@@ -63,6 +63,15 @@ target_include_directories(mkw_input_expr_tests PRIVATE "${MKW_TEST_RUNTIME_DIR}
|
||||
target_compile_features(mkw_input_expr_tests PRIVATE cxx_std_17)
|
||||
add_test(NAME mkw_input_expr_tests COMMAND mkw_input_expr_tests)
|
||||
|
||||
add_executable(mkw_motion_trick_tests
|
||||
"${MKW_TEST_RUNTIME_DIR}/tests/motion_trick_tests.cpp"
|
||||
"${MKW_TEST_RUNTIME_DIR}/src/motion_trick_detector.cpp")
|
||||
target_include_directories(mkw_motion_trick_tests PRIVATE "${MKW_TEST_RUNTIME_DIR}/include")
|
||||
target_compile_features(mkw_motion_trick_tests PRIVATE cxx_std_17)
|
||||
# Match the runtime's float model so the bad-data guard is tested as shipped.
|
||||
target_compile_options(mkw_motion_trick_tests PRIVATE $<$<NOT:$<CXX_COMPILER_ID:MSVC>>:-ffast-math>)
|
||||
add_test(NAME mkw_motion_trick_tests COMMAND mkw_motion_trick_tests)
|
||||
|
||||
# The emulated Bluetooth controller is pure host code.
|
||||
add_executable(mkw_bluetooth_controller_tests
|
||||
"${MKW_TEST_RUNTIME_DIR}/tests/bluetooth_controller_tests.cpp"
|
||||
|
||||
@@ -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)
|
||||
|
||||
// Three hand-written registration macros, given the PAL address. Generated code never registers
|
||||
// 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,3 +168,9 @@ 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)
|
||||
|
||||
@@ -22,6 +22,8 @@ public:
|
||||
// Applied to the final host output, covering both AX and direct AI DMA.
|
||||
void SetMasterVolume(float volume);
|
||||
void SetMuted(bool muted);
|
||||
// Mutes output without touching the user's persisted mute setting.
|
||||
void SetSilenced(bool silenced);
|
||||
|
||||
private:
|
||||
AudioBackend() = default;
|
||||
@@ -43,6 +45,7 @@ private:
|
||||
bool m_initialized = false;
|
||||
float m_masterVolume = 1.0f;
|
||||
bool m_muted = false;
|
||||
bool m_silenced = false;
|
||||
bool m_reportedDroppedBlock = false;
|
||||
std::vector<int16_t> m_convertBuffer;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "friend_code_keyboard.h"
|
||||
#include "settings_overlay.h"
|
||||
#include "steam_input_layout.h"
|
||||
#include "runtime_config.h"
|
||||
#include "gx_worker.h"
|
||||
#include "perf_stats.h"
|
||||
@@ -19,9 +20,8 @@
|
||||
#endif
|
||||
|
||||
extern "C" bool g_dynamicAspectRatioEnabled;
|
||||
void ConfigureMkwDynamicAspect(bool widescreen, bool forceAspect169, uint32_t surfaceWidth, uint32_t surfaceHeight);
|
||||
void SetMkwForceAspect169(bool enabled);
|
||||
bool MkwForceAspect169Requested();
|
||||
void ConfigureMkwDynamicAspect(bool widescreen, bool forceAspect, uint32_t surfaceWidth, uint32_t surfaceHeight);
|
||||
void SetMkwForceAspect(bool enabled);
|
||||
void UpdateMkwDynamicAspectSurface(uint32_t surfaceWidth, uint32_t surfaceHeight);
|
||||
// Arms the "keep EGG::Frustum's projection scale" flag on every screen that
|
||||
// renders to a fixed-size offscreen target. Cheap and idempotent; called from
|
||||
@@ -64,6 +64,7 @@ inline void Flush(bool force = false) {
|
||||
// process directly and do not run the crash/atexit paths.
|
||||
[[noreturn]] inline void ExitForAuroraWindowClose() noexcept {
|
||||
settings_overlay::ReleaseControllers();
|
||||
SteamInputLayout::Release();
|
||||
WindowPlacementPersistence::Flush(true);
|
||||
#if defined(_WIN32)
|
||||
::ExitProcess(0);
|
||||
@@ -121,6 +122,7 @@ inline void ProcessAuroraEvents(const AuroraEvent* events) {
|
||||
}
|
||||
settings_overlay::HandleEvents(events);
|
||||
FriendCodeKeyboard::HandleEvents(events);
|
||||
SteamInputLayout::HandleEvents(events);
|
||||
}
|
||||
|
||||
inline void ApplyPendingMkwDynamicAspectSurface() {
|
||||
|
||||
@@ -7,4 +7,5 @@
|
||||
namespace BootRace {
|
||||
// False when `arg` is not a boot-race option; throws on a malformed one.
|
||||
bool ParseArgument(std::string_view arg);
|
||||
bool Requested();
|
||||
} // namespace BootRace
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
// 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"
|
||||
@@ -12,6 +14,10 @@ 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) \
|
||||
@@ -19,3 +25,9 @@ Entry BaseTranslation(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; \
|
||||
}())
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
// Shake to trick: a flick of a controller's motion sensor briefly presses the
|
||||
// matching D-pad direction, which the game treats as a trick (or a wheelie on
|
||||
// bikes), like shaking a Wii Remote.
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include <dolphin/pad.h>
|
||||
|
||||
#include "motion_trick_detector.h"
|
||||
|
||||
namespace MotionTrick {
|
||||
|
||||
bool Enabled() noexcept;
|
||||
void SetEnabled(bool enabled) noexcept;
|
||||
int Sensitivity() noexcept;
|
||||
void SetSensitivity(int level) noexcept;
|
||||
|
||||
// The controller on this port reports a gyro or accelerometer of its own.
|
||||
bool PortHasMotion(uint32_t port) noexcept;
|
||||
|
||||
// Call once per guest PADRead, after every other input source has been merged.
|
||||
void Apply(PADStatus* statuses) noexcept;
|
||||
|
||||
struct PortStatus {
|
||||
float intensity = 0.0f; // decaying peak, 1 being the trigger threshold
|
||||
Direction lastTrick = Direction::None;
|
||||
uint64_t lastTrickUs = 0;
|
||||
bool sensorsUnavailable = false; // the controller refused to turn its sensors on
|
||||
};
|
||||
PortStatus Status(uint32_t port) noexcept;
|
||||
|
||||
uint64_t NowUs() noexcept;
|
||||
|
||||
} // namespace MotionTrick
|
||||
@@ -0,0 +1,43 @@
|
||||
#pragma once
|
||||
|
||||
// Shake detection for controllers with motion sensors. Pure host code, so it is
|
||||
// unit-testable without SDL.
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace MotionTrick {
|
||||
|
||||
enum class Direction : uint8_t { None, Up, Down, Left, Right };
|
||||
|
||||
inline constexpr int kMinSensitivity = 1;
|
||||
inline constexpr int kMaxSensitivity = 5;
|
||||
inline constexpr int kDefaultSensitivity = 3;
|
||||
|
||||
// SDL gamepad sensor axes: x right, y top, z toward the player.
|
||||
struct MotionSample {
|
||||
bool hasGyro = false;
|
||||
bool hasAccel = false;
|
||||
float gyro[3]{}; // rad/s: pitch, yaw, roll
|
||||
float accel[3]{}; // m/s^2, gravity included
|
||||
};
|
||||
|
||||
// Fed one sample per game frame; reports at most one trick per flick.
|
||||
class ShakeDetector {
|
||||
public:
|
||||
ShakeDetector() { SetSensitivity(kDefaultSensitivity); }
|
||||
|
||||
void SetSensitivity(int level);
|
||||
Direction Update(const MotionSample& sample, uint64_t nowUs);
|
||||
|
||||
// Strength of the last sample, 1 being the trigger threshold.
|
||||
float Intensity() const { return m_intensity; }
|
||||
|
||||
private:
|
||||
float m_gyroThreshold = 0.0f;
|
||||
float m_accelThreshold = 0.0f;
|
||||
float m_intensity = 0.0f;
|
||||
bool m_armed = true;
|
||||
uint64_t m_lastFireUs = 0;
|
||||
};
|
||||
|
||||
} // namespace MotionTrick
|
||||
@@ -33,7 +33,8 @@
|
||||
|
||||
struct RuntimeUserConfig {
|
||||
std::optional<bool> widescreen;
|
||||
std::optional<bool> forceAspect169;
|
||||
std::optional<bool> forceAspect;
|
||||
bool legacyForceAspect = false;
|
||||
std::optional<int32_t> windowPosX;
|
||||
std::optional<int32_t> windowPosY;
|
||||
std::optional<uint32_t> windowWidth;
|
||||
@@ -69,6 +70,10 @@ struct RuntimeUserConfig {
|
||||
std::optional<int32_t> wiiIrSensitivity;
|
||||
std::optional<int32_t> wiiSpeakerVolume;
|
||||
std::optional<bool> wiiRumble;
|
||||
// Outside a Steam launch, ask a running Steam client for a game controller layout while
|
||||
// the window has focus instead of its desktop one (buttons bound to mouse and keyboard).
|
||||
std::optional<bool> steamInputOverride;
|
||||
std::optional<uint32_t> steamInputAppId;
|
||||
std::optional<bool> networkEnabled;
|
||||
std::optional<bool> discordPresenceEnabled;
|
||||
// The application ID of the WiiCompiled Discord application. This is only
|
||||
@@ -86,6 +91,9 @@ struct RuntimeUserConfig {
|
||||
// "dpad_up,left_shoulder") as values; pressing either bound button counts.
|
||||
std::array<std::optional<std::string>, 12> controllerButtons;
|
||||
std::optional<bool> rumbleEnabled;
|
||||
// Shake a motion controller to press the D-pad, like shaking a Wii Remote.
|
||||
std::optional<bool> motionTrick;
|
||||
std::optional<int32_t> motionTrickSensitivity;
|
||||
std::optional<int32_t> muteHotkey;
|
||||
std::map<std::string, std::string> controllerExpressions;
|
||||
};
|
||||
@@ -271,7 +279,7 @@ inline void EnsureConfigFile() {
|
||||
"# Set paths.dvd_root to an extracted Mario Kart Wii DATA directory.\n\n"
|
||||
"[video]\n"
|
||||
"widescreen = true\n"
|
||||
"force_16_9 = false\n"
|
||||
"force_aspect = false\n"
|
||||
"resolution_multiplier = 1.0\n"
|
||||
"frame_interpolation_fps = 0\n"
|
||||
"display_mode = \"windowed\"\n"
|
||||
@@ -304,6 +312,12 @@ inline void EnsureConfigFile() {
|
||||
"mix_worker = true\n\n"
|
||||
"[network]\n"
|
||||
"enabled = true\n\n"
|
||||
"[controller]\n"
|
||||
"# Outside a Steam launch, Steam's desktop layout binds controller buttons\n"
|
||||
"# to mouse and keyboard. While the game has focus, ask a running Steam\n"
|
||||
"# client to use the game layout of steam_input_app_id instead.\n"
|
||||
"steam_input_override = true\n"
|
||||
"# steam_input_app_id = 480\n\n"
|
||||
"[discord]\n"
|
||||
"# Rich Presence talks only to a locally-running Discord client.\n"
|
||||
"# Retro Rewind supplies its official app ID automatically. Set this\n"
|
||||
@@ -389,6 +403,10 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) {
|
||||
}
|
||||
|
||||
config.rumbleEnabled = FindConfigValue<bool>(document, "controller", "rumble");
|
||||
config.motionTrick = FindConfigValue<bool>(document, "controller", "motion_trick");
|
||||
if (const auto value = FindConfigInt(document, "controller", "motion_trick_sensitivity"); value && *value >= 1 && *value <= 5) {
|
||||
config.motionTrickSensitivity = *value;
|
||||
}
|
||||
if (auto value = FindConfigInt(document, "audio", "mute_key")) {
|
||||
config.muteHotkey = *value;
|
||||
}
|
||||
@@ -403,7 +421,15 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) {
|
||||
}
|
||||
|
||||
config.widescreen = FindConfigValue<bool>(document, "video", "widescreen");
|
||||
config.forceAspect169 = FindConfigValue<bool>(document, "video", "force_16_9");
|
||||
config.forceAspect = FindConfigValue<bool>(document, "video", "force_aspect");
|
||||
if (!config.forceAspect) {
|
||||
// Legacy key; it always forced a 16:9 game.
|
||||
config.forceAspect = FindConfigValue<bool>(document, "video", "force_16_9");
|
||||
config.legacyForceAspect = config.forceAspect.has_value();
|
||||
if (config.forceAspect.value_or(false)) {
|
||||
config.widescreen = true;
|
||||
}
|
||||
}
|
||||
config.windowPosX = FindConfigInt(document, "video", "window_x");
|
||||
config.windowPosY = FindConfigInt(document, "video", "window_y");
|
||||
if (auto value = FindConfigUint(document, "video", "window_width"); value && *value != 0) {
|
||||
@@ -473,6 +499,10 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) {
|
||||
config.wiiSpeakerVolume = *value;
|
||||
}
|
||||
config.wiiRumble = FindConfigValue<bool>(document, "controller", "wii_rumble");
|
||||
config.steamInputOverride = FindConfigValue<bool>(document, "controller", "steam_input_override");
|
||||
if (const auto value = FindConfigUint(document, "controller", "steam_input_app_id"); value && *value != 0) {
|
||||
config.steamInputAppId = *value;
|
||||
}
|
||||
config.networkEnabled = FindConfigValue<bool>(document, "network", "enabled");
|
||||
config.discordPresenceEnabled = FindConfigValue<bool>(document, "discord", "enabled");
|
||||
config.discordClientId = FindConfigValue<std::string>(document, "discord", "client_id");
|
||||
@@ -692,6 +722,24 @@ inline bool SetRumbleEnabled(bool value) {
|
||||
return WriteSetting("controller", "rumble", value ? "true" : "false");
|
||||
}
|
||||
|
||||
inline bool MotionTrickEnabled(bool fallback = false) {
|
||||
return Get().motionTrick.value_or(fallback);
|
||||
}
|
||||
|
||||
inline bool SetMotionTrickEnabled(bool value) {
|
||||
Mutable().motionTrick = value;
|
||||
return WriteSetting("controller", "motion_trick", value ? "true" : "false");
|
||||
}
|
||||
|
||||
inline int32_t MotionTrickSensitivity(int32_t fallback = 3) {
|
||||
return Get().motionTrickSensitivity.value_or(fallback);
|
||||
}
|
||||
|
||||
inline bool SetMotionTrickSensitivity(int32_t value) {
|
||||
Mutable().motionTrickSensitivity = value;
|
||||
return WriteSetting("controller", "motion_trick_sensitivity", std::to_string(value));
|
||||
}
|
||||
|
||||
inline int32_t MuteHotkey(int32_t fallback) {
|
||||
return Get().muteHotkey.value_or(fallback);
|
||||
}
|
||||
@@ -760,13 +808,30 @@ inline bool WidescreenEnabled(bool fallback = false) {
|
||||
return Get().widescreen.value_or(fallback);
|
||||
}
|
||||
|
||||
inline bool ForceAspect169Enabled(bool fallback = false) {
|
||||
return Get().forceAspect169.value_or(fallback);
|
||||
inline bool ForceAspectEnabled(bool fallback = false) {
|
||||
return Get().forceAspect.value_or(fallback);
|
||||
}
|
||||
|
||||
inline bool SetForceAspect169(bool value) {
|
||||
Mutable().forceAspect169 = value;
|
||||
return WriteSetting("video", "force_16_9", value ? "true" : "false");
|
||||
inline bool SetForceAspect(bool value) {
|
||||
Mutable().forceAspect = value;
|
||||
return WriteSetting("video", "force_aspect", value ? "true" : "false");
|
||||
}
|
||||
|
||||
// The in-memory value stays as booted; SCGetAspectRatio must not change mid-session.
|
||||
inline bool SetWidescreen(bool value) {
|
||||
return WriteSetting("video", "widescreen", value ? "true" : "false");
|
||||
}
|
||||
|
||||
// Rewrites a legacy force_16_9 as force_aspect plus the widescreen it implied.
|
||||
inline void MigrateLegacyForceAspect() {
|
||||
if (!Get().legacyForceAspect) {
|
||||
return;
|
||||
}
|
||||
const bool force = ForceAspectEnabled();
|
||||
if ((!force || WriteSetting("video", "widescreen", "true")) &&
|
||||
WriteSetting("video", "force_aspect", force ? "true" : "false")) {
|
||||
Mutable().legacyForceAspect = false;
|
||||
}
|
||||
}
|
||||
|
||||
inline bool WindowPosition(int32_t& x, int32_t& y) {
|
||||
@@ -844,6 +909,15 @@ inline bool SetWiiRemotesEnabled(bool value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
inline bool SteamInputOverrideEnabled(bool fallback = true) {
|
||||
return Get().steamInputOverride.value_or(fallback);
|
||||
}
|
||||
|
||||
// Spacewar, Valve's Steamworks sample app, available to every Steam account.
|
||||
inline uint32_t SteamInputAppId(uint32_t fallback = 480) {
|
||||
return Get().steamInputAppId.value_or(fallback);
|
||||
}
|
||||
|
||||
inline bool WiiAutoPairEnabled(bool fallback = true) {
|
||||
return Get().wiiAutoPair.value_or(fallback);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
#include <aurora/event.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// Outside a Steam launch, Steam applies its desktop controller layout, which binds
|
||||
// controller buttons to mouse and keyboard actions. While the game window has focus,
|
||||
// a running Steam client is asked to apply the given app's game layout instead.
|
||||
namespace SteamInputLayout {
|
||||
|
||||
void Initialize(bool enabled, uint32_t appId);
|
||||
void HandleEvents(const AuroraEvent* events);
|
||||
// Restores the desktop layout. Idempotent; safe to call from any exit path.
|
||||
void Release() noexcept;
|
||||
|
||||
} // namespace SteamInputLayout
|
||||
+41
-14
@@ -426,20 +426,10 @@ const TranslatedFunctionInfo* TranslatedFunctionRegistry::FindByAddressPtrSlow(u
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
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
|
||||
namespace {
|
||||
|
||||
[[noreturn]] void ReportMissingGuestFunction(uint32_t address) {
|
||||
RT_LOG(RT_TAG_RUNTIME) << "ERROR: no 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
|
||||
@@ -451,6 +441,43 @@ GuestFunctions::Entry GuestFunctions::BaseTranslation(uint32_t address) {
|
||||
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;
|
||||
|
||||
@@ -15,7 +15,7 @@ AudioBackend& AudioBackend::Instance() {
|
||||
}
|
||||
|
||||
float AudioBackend::EffectiveGainLocked() const {
|
||||
return m_muted ? 0.0f : m_masterVolume;
|
||||
return m_muted || m_silenced ? 0.0f : m_masterVolume;
|
||||
}
|
||||
|
||||
void AudioBackend::ApplyGainLocked() {
|
||||
@@ -36,6 +36,12 @@ void AudioBackend::SetMuted(bool muted) {
|
||||
ApplyGainLocked();
|
||||
}
|
||||
|
||||
void AudioBackend::SetSilenced(bool silenced) {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
m_silenced = silenced;
|
||||
ApplyGainLocked();
|
||||
}
|
||||
|
||||
bool AudioBackend::EnsureInitializedLocked(uint32_t sampleRate, uint32_t channels) {
|
||||
if (m_initialized && m_sampleRate == sampleRate && m_channels == channels) {
|
||||
return true;
|
||||
|
||||
@@ -14,10 +14,9 @@ extern "C" int g_gxFrameCount;
|
||||
|
||||
namespace {
|
||||
|
||||
bool g_widescreenConfigured = false;
|
||||
bool g_widescreenSetting = false;
|
||||
bool g_forceAspect169 = false;
|
||||
std::atomic_bool g_requestedForceAspect169{false};
|
||||
bool g_widescreen = true;
|
||||
bool g_forceAspect = false;
|
||||
std::atomic_bool g_requestedForceAspect{false};
|
||||
bool g_policyDirty = false;
|
||||
uint32_t g_lastEggWidth43 = 0;
|
||||
uint32_t g_lastEggWidth169 = 0;
|
||||
@@ -130,19 +129,20 @@ void WriteEggScreenRecord(uint32_t recordAddr, uint32_t width) {
|
||||
|
||||
void ApplyEggScreenRecords(uint32_t surfaceWidth, uint32_t surfaceHeight) {
|
||||
using namespace MkwDynamicAspect;
|
||||
if (g_forceAspect) {
|
||||
surfaceWidth = g_widescreen ? 16u : 4u;
|
||||
surfaceHeight = g_widescreen ? 9u : 3u;
|
||||
}
|
||||
const float referenceAspect = g_widescreen ? kPresentationAspect169 : kPresentationAspect43;
|
||||
const uint32_t width43 =
|
||||
g_widescreenConfigured ? EggRecordWidth(kEggRecordWidth43, surfaceWidth, surfaceHeight)
|
||||
: kEggRecordWidth43;
|
||||
EggRecordWidth(kEggRecordWidth43, surfaceWidth, surfaceHeight, referenceAspect);
|
||||
const uint32_t width169 =
|
||||
g_widescreenConfigured ? EggRecordWidth(kEggRecordWidth169, surfaceWidth, surfaceHeight)
|
||||
: kEggRecordWidth169;
|
||||
EggRecordWidth(kEggRecordWidth169, surfaceWidth, surfaceHeight);
|
||||
WriteEggScreenRecord(kEggScreenRecord43, width43);
|
||||
WriteEggScreenRecord(kEggScreenRecord169, width169);
|
||||
Memory::WriteFloat32(kEggScreenProjScaleX, 1.0f);
|
||||
Memory::WriteFloat32(kEggScreenProjScaleY,
|
||||
g_widescreenConfigured
|
||||
? VerticalExpansion(surfaceWidth, surfaceHeight)
|
||||
: 1.0f);
|
||||
VerticalExpansion(surfaceWidth, surfaceHeight, referenceAspect));
|
||||
AssertOffscreenScreenBypass();
|
||||
if (width43 == g_lastEggWidth43 && width169 == g_lastEggWidth169) {
|
||||
return;
|
||||
@@ -172,58 +172,38 @@ void AssertMkwOffscreenScreenBypass() {
|
||||
}
|
||||
|
||||
void UpdateMkwDynamicAspectSurface(uint32_t surfaceWidth, uint32_t surfaceHeight) {
|
||||
const bool requestedForceAspect169 = g_requestedForceAspect169.load(std::memory_order_acquire);
|
||||
if (g_forceAspect169 != requestedForceAspect169) {
|
||||
g_forceAspect169 = requestedForceAspect169;
|
||||
g_widescreenConfigured = g_widescreenSetting || g_forceAspect169;
|
||||
g_dynamicAspectRatioEnabled = g_widescreenConfigured;
|
||||
const bool requestedForceAspect = g_requestedForceAspect.load(std::memory_order_acquire);
|
||||
if (g_forceAspect != requestedForceAspect) {
|
||||
g_forceAspect = requestedForceAspect;
|
||||
g_policyDirty = true;
|
||||
}
|
||||
if (surfaceWidth == 0 || surfaceHeight == 0) {
|
||||
return;
|
||||
}
|
||||
if (!g_widescreenConfigured) {
|
||||
if (g_policyDirty) {
|
||||
if (g_policyDirty) {
|
||||
if (g_forceAspect && !g_widescreen) {
|
||||
AuroraSetViewportPolicy(AURORA_VIEWPORT_FIT);
|
||||
VILockAspectRatio(4, 3);
|
||||
ApplyEggScreenRecords(surfaceWidth, surfaceHeight);
|
||||
g_policyDirty = false;
|
||||
} else {
|
||||
AuroraSetViewportPolicy(g_forceAspect ? AURORA_VIEWPORT_16_9 : AURORA_VIEWPORT_STRETCH);
|
||||
VIUnlockAspectRatio();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (g_policyDirty) {
|
||||
VIUnlockAspectRatio();
|
||||
g_policyDirty = false;
|
||||
}
|
||||
AuroraSetViewportPolicy(g_forceAspect169 ? AURORA_VIEWPORT_16_9 : AURORA_VIEWPORT_STRETCH);
|
||||
ApplyEggScreenRecords(g_forceAspect169 ? 16u : surfaceWidth,
|
||||
g_forceAspect169 ? 9u : surfaceHeight);
|
||||
}
|
||||
|
||||
void SetMkwForceAspect169(bool enabled) {
|
||||
g_requestedForceAspect169.store(enabled, std::memory_order_release);
|
||||
}
|
||||
|
||||
bool MkwForceAspect169Requested() {
|
||||
return g_requestedForceAspect169.load(std::memory_order_acquire);
|
||||
}
|
||||
|
||||
void ConfigureMkwDynamicAspect(bool widescreen, bool forceAspect169, uint32_t surfaceWidth, uint32_t surfaceHeight) {
|
||||
g_widescreenSetting = widescreen;
|
||||
g_widescreenConfigured = widescreen || forceAspect169;
|
||||
g_forceAspect169 = forceAspect169;
|
||||
g_requestedForceAspect169.store(forceAspect169, std::memory_order_relaxed);
|
||||
g_dynamicAspectRatioEnabled = g_widescreenConfigured;
|
||||
g_lastEggWidth43 = 0;
|
||||
g_lastEggWidth169 = 0;
|
||||
if (g_widescreenConfigured) {
|
||||
VIUnlockAspectRatio();
|
||||
ApplyEggScreenRecords(forceAspect169 ? 16u : surfaceWidth,
|
||||
forceAspect169 ? 9u : surfaceHeight);
|
||||
return;
|
||||
}
|
||||
|
||||
AuroraSetViewportPolicy(AURORA_VIEWPORT_FIT);
|
||||
VILockAspectRatio(4, 3);
|
||||
ApplyEggScreenRecords(surfaceWidth, surfaceHeight);
|
||||
}
|
||||
|
||||
void SetMkwForceAspect(bool enabled) {
|
||||
g_requestedForceAspect.store(enabled, std::memory_order_release);
|
||||
}
|
||||
|
||||
void ConfigureMkwDynamicAspect(bool widescreen, bool forceAspect, uint32_t surfaceWidth, uint32_t surfaceHeight) {
|
||||
g_widescreen = widescreen;
|
||||
g_forceAspect = forceAspect;
|
||||
g_requestedForceAspect.store(forceAspect, std::memory_order_relaxed);
|
||||
g_policyDirty = true;
|
||||
g_dynamicAspectRatioEnabled = true;
|
||||
g_lastEggWidth43 = 0;
|
||||
g_lastEggWidth169 = 0;
|
||||
ApplyEggScreenRecords(surfaceWidth, surfaceHeight);
|
||||
}
|
||||
|
||||
@@ -503,19 +503,20 @@ 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 int64_t kMaxPollDeltaMicros = 100'000;
|
||||
return std::min(elapsed, kMaxPollDeltaMicros);
|
||||
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();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -125,6 +125,10 @@ bool ParseArgument(std::string_view arg) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Requested() {
|
||||
return g_course.has_value();
|
||||
}
|
||||
|
||||
} // namespace BootRace
|
||||
|
||||
// ArgumentParser::parse, picks the boot section in SectionMgr::Init.
|
||||
@@ -158,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_FUNC(8052F4E8)(ctx);
|
||||
MKW_GUEST_NEXT(8052F4E8)(ctx);
|
||||
return;
|
||||
}
|
||||
const uint32_t players = ctx->gpr[3] + kPlayersOffset;
|
||||
@@ -166,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_FUNC(8052F4E8)(ctx);
|
||||
MKW_GUEST_NEXT(8052F4E8)(ctx);
|
||||
for (uint32_t i = 0; i < kPlayerCount; ++i) {
|
||||
const uint32_t player = players + i * kPlayerSize;
|
||||
if (Memory::Read32(player + kPlayerTypeOffset) == kPlayerRealLocal) {
|
||||
@@ -174,7 +178,7 @@ extern "C" void RacedataScenario_InitScreens_8052f4e8(CpuContext* ctx) {
|
||||
}
|
||||
}
|
||||
}
|
||||
REGISTER_NATIVE_FUNCTION_AS(0x8052F4E8, RacedataScenario_InitScreens_8052f4e8, "RacedataScenario_InitScreens_8052f4e8");
|
||||
REGISTER_NATIVE_WRAPPER_AS(0x8052F4E8, RacedataScenario_InitScreens_8052f4e8, "RacedataScenario_InitScreens_8052f4e8");
|
||||
|
||||
// KartAIController::IsCPU
|
||||
extern "C" void KartAIController_IsCPU_8072624c(CpuContext* ctx) {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
namespace MkwDynamicAspect {
|
||||
|
||||
inline constexpr float kPresentationAspect169 = 16.0f / 9.0f;
|
||||
inline constexpr float kPresentationAspect43 = 4.0f / 3.0f;
|
||||
inline constexpr float kEggFramebufferWidth = 608.0f;
|
||||
inline constexpr uint32_t kEggScreenHeight = 456u;
|
||||
inline constexpr uint32_t kEggRecordWidth43 = 608u;
|
||||
@@ -31,32 +32,35 @@ inline float SurfaceAspect(uint32_t width, uint32_t height) noexcept {
|
||||
static_cast<float>(width) / static_cast<float>(height));
|
||||
}
|
||||
|
||||
// surfaceAspect / (16:9). Above 1 the surface is wider than 16:9, below 1 it
|
||||
// is tighter (4:3, portrait).
|
||||
inline float AspectRatioFactor(uint32_t width, uint32_t height) noexcept {
|
||||
return SurfaceAspect(width, height) / kPresentationAspect169;
|
||||
// surfaceAspect / referenceAspect. Above 1 the surface is wider than the
|
||||
// reference, below 1 it is tighter (4:3, portrait).
|
||||
inline float AspectRatioFactor(uint32_t width, uint32_t height,
|
||||
float referenceAspect = kPresentationAspect169) noexcept {
|
||||
return SurfaceAspect(width, height) / referenceAspect;
|
||||
}
|
||||
|
||||
// Hor+ factor, clamped at 1 so a tighter-than-16:9 surface never narrows the
|
||||
// canvas (that would crop away scene the 16:9 view showed).
|
||||
inline float HorizontalExpansion(uint32_t width, uint32_t height) noexcept {
|
||||
return std::max(1.0f, AspectRatioFactor(width, height));
|
||||
// Hor+ factor, clamped at 1 so a tighter surface never narrows the canvas
|
||||
// (that would crop away scene the reference view showed).
|
||||
inline float HorizontalExpansion(uint32_t width, uint32_t height,
|
||||
float referenceAspect = kPresentationAspect169) noexcept {
|
||||
return std::max(1.0f, AspectRatioFactor(width, height, referenceAspect));
|
||||
}
|
||||
|
||||
// Vert+ factor, clamped at 1 so a wider-than-16:9 surface never shortens the
|
||||
// frustum. Exactly one of the two expansions is ever greater than 1.
|
||||
inline float VerticalExpansion(uint32_t width, uint32_t height) noexcept {
|
||||
const float factor = AspectRatioFactor(width, height);
|
||||
// Vert+ factor, clamped at 1 so a wider surface never shortens the frustum.
|
||||
// Exactly one of the two expansions is ever greater than 1.
|
||||
inline float VerticalExpansion(uint32_t width, uint32_t height,
|
||||
float referenceAspect = kPresentationAspect169) noexcept {
|
||||
const float factor = AspectRatioFactor(width, height, referenceAspect);
|
||||
if (!(factor > 0.0f)) {
|
||||
return 1.0f;
|
||||
}
|
||||
return std::clamp(1.0f / factor, 1.0f, kMaxVerticalExpansion);
|
||||
}
|
||||
|
||||
inline uint32_t EggRecordWidth(uint32_t baseWidth, uint32_t surfaceWidth,
|
||||
uint32_t surfaceHeight) noexcept {
|
||||
const float scaledWidth =
|
||||
static_cast<float>(baseWidth) * HorizontalExpansion(surfaceWidth, surfaceHeight);
|
||||
inline uint32_t EggRecordWidth(uint32_t baseWidth, uint32_t surfaceWidth, uint32_t surfaceHeight,
|
||||
float referenceAspect = kPresentationAspect169) noexcept {
|
||||
const float scaledWidth = static_cast<float>(baseWidth) *
|
||||
HorizontalExpansion(surfaceWidth, surfaceHeight, referenceAspect);
|
||||
return std::clamp<uint32_t>(
|
||||
static_cast<uint32_t>(std::lround(scaledWidth / 4.0f)) * 4u, 4u, 0xFFFCu);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#include "memory.h"
|
||||
#include "hle/controller_status_contract.h"
|
||||
#include "input_bindings.h"
|
||||
#include "motion_trick.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
@@ -116,6 +117,7 @@ extern "C" uint32_t PAD__Read_HLE(uint32_t statusPtr)
|
||||
|
||||
FillTriggersHeldByButtons(statuses);
|
||||
InputBindings::Apply(statuses);
|
||||
MotionTrick::Apply(statuses);
|
||||
|
||||
try {
|
||||
for (uint32_t i = 0; i < PAD_CHANMAX; ++i) {
|
||||
|
||||
@@ -53,7 +53,7 @@ PPC_NATIVE_OVERRIDE(801B0220, SCCheckStatus_HLE, uint32_t, (), ());
|
||||
// Returns: 0 = 4:3, 1 = 16:9
|
||||
extern "C" uint32_t SCGetAspectRatio_HLE()
|
||||
{
|
||||
return (RuntimeConfigFile::WidescreenEnabled(true) || MkwForceAspect169Requested()) ? 1u : 0u;
|
||||
return RuntimeConfigFile::WidescreenEnabled(true) ? 1u : 0u;
|
||||
}
|
||||
|
||||
PPC_NATIVE_OVERRIDE(801B1BE4, SCGetAspectRatio_HLE, uint32_t, (), ());
|
||||
|
||||
@@ -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_FUNCTION_AS(0x801938FC, NAND_IOS_OpenBody_HLE_801938FC, "NAND_IOS_OpenBody_HLE_801938FC");
|
||||
REGISTER_NATIVE_REPLACEMENT_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) {
|
||||
|
||||
+46
-12
@@ -41,6 +41,7 @@
|
||||
#include <mmsystem.h>
|
||||
#include <dbghelp.h>
|
||||
#else
|
||||
#include <fcntl.h>
|
||||
#include <signal.h>
|
||||
#if defined(__x86_64__)
|
||||
// Only the x86 POSIX fault path inspects ucontext_t to recover the page-fault
|
||||
@@ -57,6 +58,7 @@
|
||||
#endif
|
||||
|
||||
#include "abi_bridge.h"
|
||||
#include "audio_backend.h"
|
||||
#include "boot_race.h"
|
||||
#include "guest_abi/guest_region.h"
|
||||
#include "guest_flat_memory.h"
|
||||
@@ -67,6 +69,7 @@
|
||||
#include "aurora_events.h"
|
||||
#include "wiimote_host.h"
|
||||
#include "discord_presence.h"
|
||||
#include "steam_input_layout.h"
|
||||
#include "fiber_manager.h"
|
||||
#include "gx_worker.h"
|
||||
#include "hle_stubs.h"
|
||||
@@ -167,6 +170,7 @@ std::atomic_bool g_auroraInitialized{false};
|
||||
std::atomic_flag g_abortSignalHandled = ATOMIC_FLAG_INIT;
|
||||
std::atomic_bool g_fatalErrorReported{false};
|
||||
std::atomic_bool g_fatalPopupShown{false};
|
||||
bool g_headless = false;
|
||||
std::atomic<int> g_lastExitCode{0};
|
||||
std::atomic_bool g_exitCodeSet{false};
|
||||
|
||||
@@ -335,6 +339,9 @@ bool InstallTranscriptPipe(int& outReadFd, int& outWriteFd, int targetFd) {
|
||||
if (::pipe(pipeFds) != 0) {
|
||||
return false;
|
||||
}
|
||||
// Spawned helpers must not keep the transcript open; its reader waits for EOF on shutdown.
|
||||
::fcntl(pipeFds[0], F_SETFD, FD_CLOEXEC);
|
||||
::fcntl(pipeFds[1], F_SETFD, FD_CLOEXEC);
|
||||
#endif
|
||||
|
||||
outReadFd = pipeFds[0];
|
||||
@@ -773,7 +780,7 @@ void MarkFatalErrorReported() {
|
||||
}
|
||||
|
||||
void ShowRuntimeFatalPopup(std::string_view category, std::string_view details) noexcept {
|
||||
if (g_fatalPopupShown.exchange(true, std::memory_order_acq_rel)) {
|
||||
if (g_headless || g_fatalPopupShown.exchange(true, std::memory_order_acq_rel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1096,6 +1103,7 @@ LONG ReportFatalSehAndExit(EXCEPTION_POINTERS* info) {
|
||||
<< " at host address 0x" << record->ExceptionInformation[1];
|
||||
}
|
||||
popupDetails << ".\n\nThe process transcript and crash log contain the full CPU and stack diagnostics.";
|
||||
SteamInputLayout::Release();
|
||||
ShowRuntimeFatalPopup("a native crash occurred", popupDetails.str());
|
||||
DumpHostStackTrace();
|
||||
|
||||
@@ -1196,6 +1204,7 @@ void PosixMemoryFaultHandler(int sig, siginfo_t* info, void* ucontextVoid) {
|
||||
}
|
||||
popupDetails << ".\n\nThe process transcript and crash log contain the full CPU and stack "
|
||||
"diagnostics.";
|
||||
SteamInputLayout::Release();
|
||||
ShowRuntimeFatalPopup("a native crash occurred", popupDetails.str());
|
||||
DumpHostStackTrace();
|
||||
WriteFatalLogImpl(sig == SIGBUS ? "sigbus" : "sigsegv");
|
||||
@@ -1229,6 +1238,7 @@ void AbortSignalHandler(int signum) {
|
||||
// abort() bypasses atexit, so the guest-memory fault summary has to be
|
||||
// emitted here too. It is idempotent, so a later AtExitHandler is a no-op.
|
||||
GuestFlat::LogFaultSummary();
|
||||
SteamInputLayout::Release();
|
||||
|
||||
ShowRuntimeFatalPopup("a fatal internal error occurred",
|
||||
"The process called abort while running the game or Aurora renderer.\n\n"
|
||||
@@ -1264,6 +1274,7 @@ void SetRuntimeExitCode(int code) {
|
||||
|
||||
// Global handler called via atexit() to flush buffers before any exit
|
||||
static void AtExitHandler() {
|
||||
SteamInputLayout::Release();
|
||||
// End-of-run guest memory report. This runs before the fatal-report check
|
||||
// below because the counters describe the whole session and are just as
|
||||
// interesting after a crash as after a clean exit.
|
||||
@@ -1288,6 +1299,7 @@ static void AtExitHandler() {
|
||||
|
||||
// Global terminate handler for uncaught exceptions
|
||||
static void TerminateHandler() {
|
||||
SteamInputLayout::Release();
|
||||
// Skip detailed dump if already reported
|
||||
if (g_fatalErrorReported.load(std::memory_order_acquire)) {
|
||||
std::fflush(stderr);
|
||||
@@ -1345,15 +1357,25 @@ int RuntimeMain(int argc, char** argv) {
|
||||
|
||||
std::string currentEntryLabel;
|
||||
|
||||
// `--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"; });
|
||||
|
||||
try {
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
if (!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>] and --race-cpu, 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 && !BootRace::Requested()) {
|
||||
throw std::invalid_argument("--headless needs --race[=<course id>] or --race-cpu");
|
||||
}
|
||||
if (g_headless) {
|
||||
AudioBackend::Instance().SetSilenced(true);
|
||||
}
|
||||
RuntimeConfigFile::LogLoadedConfig();
|
||||
if (RuntimeConfigFile::DiscordPresenceEnabled()) {
|
||||
if (!g_headless && RuntimeConfigFile::DiscordPresenceEnabled()) {
|
||||
DiscordPresence::Initialize(RuntimeConfigFile::DiscordClientId(), "Mario Kart Wii");
|
||||
}
|
||||
SystemBridge::Initialize();
|
||||
@@ -1379,15 +1401,17 @@ int RuntimeMain(int argc, char** argv) {
|
||||
auroraConfig.cachePath = auroraCachePath.c_str();
|
||||
auroraConfig.logCallback = &RuntimeAuroraLogCallback;
|
||||
auroraConfig.logLevel = LOG_DEBUG;
|
||||
RuntimeConfigFile::MigrateLegacyForceAspect();
|
||||
const bool configWidescreen = RuntimeConfigFile::WidescreenEnabled(true);
|
||||
const bool forceAspect169 = RuntimeConfigFile::ForceAspect169Enabled();
|
||||
auroraConfig.windowWidth = (configWidescreen || forceAspect169) ? 854 : 640;
|
||||
const bool forceAspect = RuntimeConfigFile::ForceAspectEnabled();
|
||||
auroraConfig.windowWidth = configWidescreen ? 854 : 640;
|
||||
auroraConfig.windowHeight = 480;
|
||||
auroraConfig.windowWidth = RuntimeConfigFile::WindowWidth(auroraConfig.windowWidth);
|
||||
auroraConfig.windowHeight = RuntimeConfigFile::WindowHeight(auroraConfig.windowHeight);
|
||||
auroraConfig.hasWindowPosition = RuntimeConfigFile::WindowPosition(
|
||||
auroraConfig.windowPosX, auroraConfig.windowPosY);
|
||||
auroraConfig.allowJoystickBackgroundEvents = true;
|
||||
auroraConfig.allowJoystickBackgroundEvents = !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.
|
||||
@@ -1399,7 +1423,7 @@ int RuntimeMain(int argc, char** argv) {
|
||||
auroraConfig.vsync = RuntimeConfigFile::Vsync();
|
||||
auroraConfig.desiredBackend = BACKEND_AUTO;
|
||||
const float resolutionMultiplier = RuntimeConfigFile::ResolutionMultiplier(1.0f);
|
||||
ConfigureMkwDynamicAspect(configWidescreen, forceAspect169,
|
||||
ConfigureMkwDynamicAspect(configWidescreen, forceAspect,
|
||||
auroraConfig.windowWidth, auroraConfig.windowHeight);
|
||||
VISetFrameBufferScale(resolutionMultiplier);
|
||||
// One table for both directions. RuntimeConfigFile::IsSupportedGraphicsApi
|
||||
@@ -1466,16 +1490,23 @@ int RuntimeMain(int argc, char** argv) {
|
||||
UpdateMkwDynamicAspectSurface(auroraInfo.windowSize.native_fb_width,
|
||||
auroraInfo.windowSize.native_fb_height);
|
||||
settings_overlay::InitializeRuntimeSettings();
|
||||
if (!g_headless) {
|
||||
SteamInputLayout::Initialize(RuntimeConfigFile::SteamInputOverrideEnabled(),
|
||||
RuntimeConfigFile::SteamInputAppId());
|
||||
}
|
||||
if (BootRace::Requested()) {
|
||||
aurora_set_skip_unready_pipelines(false);
|
||||
}
|
||||
RT_LOG(RT_TAG_CONFIG) << "video.widescreen=" << (configWidescreen ? "true" : "false")
|
||||
<< " force_16_9=" << (forceAspect169 ? "true" : "false")
|
||||
<< " SCGetAspectRatio=" << (configWidescreen || forceAspect169 ? 1 : 0)
|
||||
<< " force_aspect=" << (forceAspect ? "true" : "false")
|
||||
<< " SCGetAspectRatio=" << (configWidescreen ? 1 : 0)
|
||||
<< " resolutionMultiplier=" << resolutionMultiplier
|
||||
<< " window=" << auroraInfo.windowSize.width << "x" << auroraInfo.windowSize.height
|
||||
<< " native=" << auroraInfo.windowSize.native_fb_width << "x"
|
||||
<< auroraInfo.windowSize.native_fb_height
|
||||
<< " viewportPolicy=" << (forceAspect169 ? "16:9" : (configWidescreen ? "stretch" : "fit"))
|
||||
<< " viewportPolicy=" << (forceAspect ? (configWidescreen ? "16:9" : "fit") : "stretch")
|
||||
<< " presentAspect="
|
||||
<< (forceAspect169 ? "16:9" : (configWidescreen ? "surface (dynamic EGG canvas)" : "4:3"))
|
||||
<< (forceAspect ? (configWidescreen ? "16:9" : "4:3") : "surface (dynamic EGG canvas)")
|
||||
<< std::endl;
|
||||
g_auroraInitialized.store(true, std::memory_order_release);
|
||||
GxWorker::SetWaitCallback(ServiceGuestTimingDuringAuroraFrameWait);
|
||||
@@ -1508,6 +1539,7 @@ int RuntimeMain(int argc, char** argv) {
|
||||
Fiber::GuestFiberManager::Shutdown();
|
||||
WindowPlacementPersistence::Flush(true);
|
||||
Wiimote::Shutdown();
|
||||
SteamInputLayout::Release();
|
||||
GxWorker::Stop();
|
||||
aurora_shutdown();
|
||||
DiscordPresence::Shutdown();
|
||||
@@ -1522,6 +1554,7 @@ int RuntimeMain(int argc, char** argv) {
|
||||
<< " len=0x" << ex.length()
|
||||
<< std::dec << std::nouppercase
|
||||
<< " reason=" << ex.reason();
|
||||
SteamInputLayout::Release();
|
||||
ShowRuntimeFatalPopup("a guest memory access was out of bounds", details.str());
|
||||
WriteFatalLogImpl("access_violation", details.str());
|
||||
SetRuntimeExitCodeImpl(1);
|
||||
@@ -1536,6 +1569,7 @@ int RuntimeMain(int argc, char** argv) {
|
||||
} catch (const std::exception& ex) {
|
||||
std::cerr << "Runtime error: " << ex.what() << std::endl;
|
||||
SystemBridge::DumpCpuState(TryGetCpuContext());
|
||||
SteamInputLayout::Release();
|
||||
ShowRuntimeFatalPopup("a runtime exception occurred", ex.what());
|
||||
WriteFatalLogImpl("exception");
|
||||
SetRuntimeExitCodeImpl(1);
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
#include "motion_trick.h"
|
||||
|
||||
#include "input_bindings.h"
|
||||
#include "runtime_config.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
|
||||
#include <SDL3/SDL_gamepad.h>
|
||||
#include <SDL3/SDL_keyboard.h>
|
||||
#include <SDL3/SDL_timer.h>
|
||||
|
||||
namespace MotionTrick {
|
||||
namespace {
|
||||
|
||||
// Long enough for the game to see the press, short enough not to hold a trick.
|
||||
constexpr int kPulseFrames = 4;
|
||||
// Sensors report zeros or stale data for a moment after being switched on.
|
||||
constexpr int kWarmupFrames = 6;
|
||||
constexpr float kIntensityDecay = 0.85f;
|
||||
// Real sensors always jitter; identical readings mean the stream was switched
|
||||
// off behind our back, so ask for it again a few times.
|
||||
constexpr int kStaleFrames = 30;
|
||||
constexpr uint64_t kResyncIntervalUs = 2'000'000;
|
||||
constexpr int kMaxResyncs = 3;
|
||||
|
||||
struct PortState {
|
||||
SDL_JoystickID id = 0;
|
||||
bool gyro = false;
|
||||
bool accel = false;
|
||||
int warmup = 0;
|
||||
MotionSample previous;
|
||||
int staleFrames = 0;
|
||||
int resyncs = 0;
|
||||
uint64_t lastResyncUs = 0;
|
||||
ShakeDetector detector;
|
||||
float displayIntensity = 0.0f;
|
||||
Direction pulse = Direction::None;
|
||||
int pulseFrames = 0;
|
||||
Direction lastTrick = Direction::None;
|
||||
uint64_t lastTrickUs = 0;
|
||||
};
|
||||
|
||||
std::atomic<bool> g_enabled{RuntimeConfigFile::MotionTrickEnabled(false)};
|
||||
std::atomic<int> g_sensitivity{RuntimeConfigFile::MotionTrickSensitivity(kDefaultSensitivity)};
|
||||
|
||||
std::mutex g_mutex;
|
||||
std::array<PortState, PAD_CHANMAX> g_ports;
|
||||
|
||||
// A keyboard player keeps their controller assigned, but aurora ignores it.
|
||||
SDL_Gamepad* GamepadForPort(uint32_t port) {
|
||||
uint32_t keyCount = 0;
|
||||
if (PADGetKeyButtonBindings(port, &keyCount) != nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
const s32 index = PADGetIndexForPort(port);
|
||||
return index < 0 ? nullptr : PADGetSDLGamepadForIndex(static_cast<u32>(index));
|
||||
}
|
||||
|
||||
uint16_t ButtonFor(Direction direction) {
|
||||
switch (direction) {
|
||||
case Direction::Up: return PAD_BUTTON_UP;
|
||||
case Direction::Down: return PAD_BUTTON_DOWN;
|
||||
case Direction::Left: return PAD_BUTTON_LEFT;
|
||||
case Direction::Right: return PAD_BUTTON_RIGHT;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
bool HeldByOtherPort(uint32_t port, SDL_JoystickID id) {
|
||||
for (uint32_t other = 0; other < g_ports.size(); ++other) {
|
||||
if (other != port && g_ports[other].id == id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void SetSensors(SDL_Gamepad* gamepad, const PortState& state, bool enabled) {
|
||||
if (state.gyro) SDL_SetGamepadSensorEnabled(gamepad, SDL_SENSOR_GYRO, enabled);
|
||||
if (state.accel) SDL_SetGamepadSensorEnabled(gamepad, SDL_SENSOR_ACCEL, enabled);
|
||||
}
|
||||
|
||||
void Attach(uint32_t port, SDL_Gamepad* gamepad) {
|
||||
PortState& state = g_ports[port];
|
||||
const SDL_JoystickID id = gamepad != nullptr ? SDL_GetGamepadID(gamepad) : 0;
|
||||
if (id == state.id) {
|
||||
return;
|
||||
}
|
||||
if (state.id != 0 && !HeldByOtherPort(port, state.id)) {
|
||||
if (SDL_Gamepad* previous = SDL_GetGamepadFromID(state.id)) {
|
||||
SetSensors(previous, state, false);
|
||||
}
|
||||
}
|
||||
state = PortState{};
|
||||
if (gamepad == nullptr) {
|
||||
return;
|
||||
}
|
||||
state.id = id;
|
||||
state.gyro = SDL_GamepadHasSensor(gamepad, SDL_SENSOR_GYRO) &&
|
||||
SDL_SetGamepadSensorEnabled(gamepad, SDL_SENSOR_GYRO, true);
|
||||
state.accel = SDL_GamepadHasSensor(gamepad, SDL_SENSOR_ACCEL) &&
|
||||
SDL_SetGamepadSensorEnabled(gamepad, SDL_SENSOR_ACCEL, true);
|
||||
state.warmup = kWarmupFrames;
|
||||
}
|
||||
|
||||
bool SameReading(const MotionSample& a, const MotionSample& b) {
|
||||
return a.hasGyro == b.hasGyro && a.hasAccel == b.hasAccel &&
|
||||
std::memcmp(a.gyro, b.gyro, sizeof(a.gyro)) == 0 && std::memcmp(a.accel, b.accel, sizeof(a.accel)) == 0;
|
||||
}
|
||||
|
||||
// Returns true when the sensors were restarted.
|
||||
bool ResyncIfStale(SDL_Gamepad* gamepad, PortState& state, const MotionSample& sample, uint64_t now) {
|
||||
const bool stale = SameReading(sample, state.previous) && SDL_GetKeyboardFocus() != nullptr;
|
||||
state.previous = sample;
|
||||
state.staleFrames = stale ? state.staleFrames + 1 : 0;
|
||||
if (!stale) {
|
||||
state.resyncs = 0;
|
||||
}
|
||||
if (state.staleFrames < kStaleFrames || state.resyncs >= kMaxResyncs ||
|
||||
now - state.lastResyncUs < kResyncIntervalUs) {
|
||||
return false;
|
||||
}
|
||||
SetSensors(gamepad, state, false);
|
||||
SetSensors(gamepad, state, true);
|
||||
state.staleFrames = 0;
|
||||
++state.resyncs;
|
||||
state.lastResyncUs = now;
|
||||
state.warmup = kWarmupFrames;
|
||||
return true;
|
||||
}
|
||||
|
||||
MotionSample ReadSample(SDL_Gamepad* gamepad, const PortState& state) {
|
||||
MotionSample sample;
|
||||
sample.hasGyro = state.gyro && SDL_GetGamepadSensorData(gamepad, SDL_SENSOR_GYRO, sample.gyro, 3);
|
||||
sample.hasAccel = state.accel && SDL_GetGamepadSensorData(gamepad, SDL_SENSOR_ACCEL, sample.accel, 3) &&
|
||||
(sample.accel[0] != 0.0f || sample.accel[1] != 0.0f || sample.accel[2] != 0.0f);
|
||||
return sample;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool Enabled() noexcept {
|
||||
return g_enabled.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void SetEnabled(bool enabled) noexcept {
|
||||
g_enabled.store(enabled, std::memory_order_relaxed);
|
||||
RuntimeConfigFile::SetMotionTrickEnabled(enabled);
|
||||
}
|
||||
|
||||
int Sensitivity() noexcept {
|
||||
return g_sensitivity.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void SetSensitivity(int level) noexcept {
|
||||
level = std::clamp(level, kMinSensitivity, kMaxSensitivity);
|
||||
g_sensitivity.store(level, std::memory_order_relaxed);
|
||||
RuntimeConfigFile::SetMotionTrickSensitivity(level);
|
||||
}
|
||||
|
||||
bool PortHasMotion(uint32_t port) noexcept {
|
||||
SDL_Gamepad* gamepad = GamepadForPort(port);
|
||||
return gamepad != nullptr &&
|
||||
(SDL_GamepadHasSensor(gamepad, SDL_SENSOR_GYRO) || SDL_GamepadHasSensor(gamepad, SDL_SENSOR_ACCEL));
|
||||
}
|
||||
|
||||
uint64_t NowUs() noexcept {
|
||||
return SDL_GetTicksNS() / 1000;
|
||||
}
|
||||
|
||||
void Apply(PADStatus* statuses) noexcept {
|
||||
if (statuses == nullptr) {
|
||||
return;
|
||||
}
|
||||
const bool enabled = Enabled();
|
||||
const int sensitivity = Sensitivity();
|
||||
const bool blocked = InputBindings::InputBlocked();
|
||||
const uint64_t now = NowUs();
|
||||
|
||||
std::lock_guard<std::mutex> lock(g_mutex);
|
||||
for (uint32_t port = 0; port < PAD_CHANMAX; ++port) {
|
||||
SDL_Gamepad* gamepad =
|
||||
enabled && statuses[port].err == PAD_ERR_NONE ? GamepadForPort(port) : nullptr;
|
||||
Attach(port, gamepad);
|
||||
PortState& state = g_ports[port];
|
||||
if (gamepad == nullptr || (!state.gyro && !state.accel)) {
|
||||
continue;
|
||||
}
|
||||
if (state.warmup > 0) {
|
||||
--state.warmup;
|
||||
} else if (const MotionSample sample = ReadSample(gamepad, state);
|
||||
!ResyncIfStale(gamepad, state, sample, now)) {
|
||||
state.detector.SetSensitivity(sensitivity);
|
||||
const Direction fired = state.detector.Update(sample, now);
|
||||
state.displayIntensity =
|
||||
std::max(state.detector.Intensity(), state.displayIntensity * kIntensityDecay);
|
||||
if (fired != Direction::None) {
|
||||
state.lastTrick = fired;
|
||||
state.lastTrickUs = now;
|
||||
state.pulse = fired;
|
||||
state.pulseFrames = kPulseFrames;
|
||||
}
|
||||
}
|
||||
if (blocked) {
|
||||
state.pulseFrames = 0;
|
||||
}
|
||||
if (state.pulseFrames > 0) {
|
||||
statuses[port].button |= ButtonFor(state.pulse);
|
||||
--state.pulseFrames;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PortStatus Status(uint32_t port) noexcept {
|
||||
if (port >= PAD_CHANMAX) {
|
||||
return {};
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(g_mutex);
|
||||
const PortState& state = g_ports[port];
|
||||
return {state.displayIntensity, state.lastTrick, state.lastTrickUs, state.id != 0 && !state.gyro && !state.accel};
|
||||
}
|
||||
|
||||
} // namespace MotionTrick
|
||||
@@ -0,0 +1,80 @@
|
||||
#include "motion_trick_detector.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace MotionTrick {
|
||||
namespace {
|
||||
|
||||
constexpr float kGravity = 9.80665f;
|
||||
constexpr float kRearmIntensity = 0.35f;
|
||||
constexpr uint64_t kCooldownUs = 400'000;
|
||||
|
||||
// Indexed by sensitivity level - 1.
|
||||
constexpr std::array<float, kMaxSensitivity> kGyroThresholds = {11.0f, 9.0f, 7.0f, 5.5f, 4.0f};
|
||||
constexpr std::array<float, kMaxSensitivity> kAccelThresholds = {20.0f, 16.0f, 13.0f, 10.0f, 8.0f};
|
||||
|
||||
// The runtime builds with -ffast-math, which may fold std::isfinite away.
|
||||
bool Finite(const float (&v)[3]) {
|
||||
for (const float value : v) {
|
||||
uint32_t bits = 0;
|
||||
std::memcpy(&bits, &value, sizeof(bits));
|
||||
if ((bits & 0x7f800000u) == 0x7f800000u) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
float Length(const float (&v)[3]) {
|
||||
return std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
|
||||
}
|
||||
|
||||
Direction GyroDirection(const float (&gyro)[3]) {
|
||||
const float pitch = gyro[0];
|
||||
const float yaw = gyro[1];
|
||||
const float roll = gyro[2];
|
||||
if (std::fabs(pitch) >= std::max(std::fabs(yaw), std::fabs(roll))) {
|
||||
return pitch >= 0.0f ? Direction::Up : Direction::Down;
|
||||
}
|
||||
const float lateral = std::fabs(roll) >= std::fabs(yaw) ? roll : yaw;
|
||||
return lateral >= 0.0f ? Direction::Left : Direction::Right;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void ShakeDetector::SetSensitivity(int level) {
|
||||
const size_t index = static_cast<size_t>(std::clamp(level, kMinSensitivity, kMaxSensitivity) - 1);
|
||||
m_gyroThreshold = kGyroThresholds[index];
|
||||
m_accelThreshold = kAccelThresholds[index];
|
||||
}
|
||||
|
||||
Direction ShakeDetector::Update(const MotionSample& sample, uint64_t nowUs) {
|
||||
// The accelerometer only stands in for a missing gyro: on a gyro pad it
|
||||
// mostly picks up bumps, and it carries no direction.
|
||||
const float(&values)[3] = sample.hasGyro ? sample.gyro : sample.accel;
|
||||
if ((!sample.hasGyro && !sample.hasAccel) || !Finite(values)) {
|
||||
m_intensity = 0.0f;
|
||||
return Direction::None;
|
||||
}
|
||||
m_intensity = sample.hasGyro ? Length(sample.gyro) / m_gyroThreshold
|
||||
: std::fabs(Length(sample.accel) - kGravity) / m_accelThreshold;
|
||||
|
||||
if (!m_armed) {
|
||||
if (m_intensity < kRearmIntensity && nowUs - m_lastFireUs >= kCooldownUs) {
|
||||
m_armed = true;
|
||||
}
|
||||
return Direction::None;
|
||||
}
|
||||
if (m_intensity < 1.0f) {
|
||||
return Direction::None;
|
||||
}
|
||||
m_armed = false;
|
||||
m_lastFireUs = nowUs;
|
||||
return sample.hasGyro ? GyroDirection(sample.gyro) : Direction::Up;
|
||||
}
|
||||
|
||||
} // namespace MotionTrick
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "controller_button_names.h"
|
||||
#include "controller_mapping_wizard.h"
|
||||
#include "input_bindings.h"
|
||||
#include "motion_trick.h"
|
||||
#include "game_graphics_options.h"
|
||||
#include "music_attenuation.h"
|
||||
#include "runtime_config.h"
|
||||
@@ -113,7 +114,8 @@ bool g_skipUnreadyPipelines = RuntimeConfigFile::SkipUnreadyPipelines(true);
|
||||
bool g_vsync = RuntimeConfigFile::Vsync();
|
||||
bool g_disableCopyFilter = RuntimeConfigFile::DisableCopyFilter(true);
|
||||
bool g_showFps = RuntimeConfigFile::ShowFps(true);
|
||||
bool g_forceAspect169 = RuntimeConfigFile::ForceAspect169Enabled();
|
||||
bool g_forceAspect = RuntimeConfigFile::ForceAspectEnabled();
|
||||
bool g_widescreen = RuntimeConfigFile::WidescreenEnabled(true);
|
||||
#if defined(__APPLE__)
|
||||
bool g_metalFxSpatialUpscaling = RuntimeConfigFile::MetalFxSpatialUpscaling(false);
|
||||
#endif
|
||||
@@ -744,6 +746,69 @@ void DrawRumbleSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
const char* TrickDirectionLabel(MotionTrick::Direction direction) {
|
||||
switch (direction) {
|
||||
case MotionTrick::Direction::Up: return "up";
|
||||
case MotionTrick::Direction::Down: return "down";
|
||||
case MotionTrick::Direction::Left: return "left";
|
||||
case MotionTrick::Direction::Right: return "right";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
void DrawMotionTrickSettings(uint32_t port) {
|
||||
const MotionTrick::PortStatus status = MotionTrick::Status(port);
|
||||
const bool hasMotion = MotionTrick::PortHasMotion(port) && !status.sensorsUnavailable;
|
||||
bool enabled = MotionTrick::Enabled();
|
||||
ImGui::BeginDisabled(!hasMotion && !enabled);
|
||||
if (ImGui::Checkbox("Shake to trick", &enabled)) {
|
||||
MotionTrick::SetEnabled(enabled);
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
|
||||
ImGui::SetTooltip(hasMotion
|
||||
? "Flick the controller to trick off ramps, or to wheelie on a bike, like shaking a Wii Remote.\n"
|
||||
"The flick direction picks the trick. The D-pad keeps working. Applies to every player."
|
||||
: "Steam Input can hide a controller's motion sensor; turn it off for this game to use it here.");
|
||||
}
|
||||
if (!hasMotion) {
|
||||
ImGui::SameLine();
|
||||
ImGui::TextDisabled("(no motion sensor)");
|
||||
return;
|
||||
}
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
ImGui::Indent();
|
||||
int sensitivity = MotionTrick::Sensitivity();
|
||||
ImGui::SetNextItemWidth(160.0f);
|
||||
if (ImGui::SliderInt("Sensitivity##motion", &sensitivity, MotionTrick::kMinSensitivity,
|
||||
MotionTrick::kMaxSensitivity, "%d", ImGuiSliderFlags_AlwaysClamp)) {
|
||||
MotionTrick::SetSensitivity(sensitivity);
|
||||
}
|
||||
if (ImGui::IsItemHovered()) {
|
||||
ImGui::SetTooltip("Higher reacts to smaller flicks. Lower it if you trick or wheelie by accident while steering.");
|
||||
}
|
||||
|
||||
constexpr float kMeterScale = 1.5f;
|
||||
const ImVec2 meterPos = ImGui::GetCursorScreenPos();
|
||||
const ImVec2 meterSize(160.0f, ImGui::GetFrameHeight() * 0.6f);
|
||||
ImGui::ProgressBar(std::min(status.intensity / kMeterScale, 1.0f), meterSize, "");
|
||||
const float thresholdX = meterPos.x + meterSize.x / kMeterScale;
|
||||
ImGui::GetWindowDrawList()->AddLine(ImVec2(thresholdX, meterPos.y), ImVec2(thresholdX, meterPos.y + meterSize.y),
|
||||
ImGui::GetColorU32(ImGuiCol_Text), 2.0f);
|
||||
ImGui::SameLine();
|
||||
const bool recent = status.lastTrick != MotionTrick::Direction::None &&
|
||||
MotionTrick::NowUs() - status.lastTrickUs < 700'000;
|
||||
if (recent) {
|
||||
ImGui::TextColored(ImVec4(0.4f, 0.9f, 0.4f, 1.0f), "Trick! (%s)", TrickDirectionLabel(status.lastTrick));
|
||||
} else {
|
||||
ImGui::TextDisabled("Flick to test");
|
||||
}
|
||||
ImGui::Unindent();
|
||||
}
|
||||
|
||||
void DrawGamepadMapping(uint32_t port) {
|
||||
uint32_t mappingCount = 0;
|
||||
PADButtonMapping* mappings = PADGetButtonMappings(port, &mappingCount);
|
||||
@@ -984,6 +1049,7 @@ void DrawControllerSettings() {
|
||||
}
|
||||
} else {
|
||||
ImGui::TextUnformatted("Ready to play.");
|
||||
DrawMotionTrickSettings(port);
|
||||
if (ImGui::CollapsingHeader("Customize buttons")) {
|
||||
ImGui::Indent();
|
||||
DrawGamepadMapping(port);
|
||||
@@ -1080,11 +1146,22 @@ void DrawAudioSettings() {
|
||||
|
||||
void DrawGraphicsSettings() {
|
||||
g_displayMode = static_cast<int>(aurora_get_display_mode());
|
||||
if (ImGui::Checkbox("Force 16:9", &g_forceAspect169)) {
|
||||
SetMkwForceAspect169(g_forceAspect169);
|
||||
RuntimeConfigFile::SetForceAspect169(g_forceAspect169);
|
||||
if (ImGui::BeginCombo("Aspect ratio (restart required)", g_widescreen ? "16:9" : "4:3")) {
|
||||
if (ImGui::Selectable("16:9", g_widescreen)) {
|
||||
g_widescreen = true;
|
||||
RuntimeConfigFile::SetWidescreen(true);
|
||||
}
|
||||
if (ImGui::Selectable("4:3", !g_widescreen)) {
|
||||
g_widescreen = false;
|
||||
RuntimeConfigFile::SetWidescreen(false);
|
||||
}
|
||||
ImGui::EndCombo();
|
||||
}
|
||||
ImGui::TextDisabled("Keep a 16:9 image with black bars when the window has another shape.");
|
||||
if (ImGui::Checkbox("Force aspect ratio", &g_forceAspect)) {
|
||||
SetMkwForceAspect(g_forceAspect);
|
||||
RuntimeConfigFile::SetForceAspect(g_forceAspect);
|
||||
}
|
||||
ImGui::TextDisabled("Keep the current aspect ratio with black bars when the window has another shape.");
|
||||
ImGui::Separator();
|
||||
struct EffectFlag {
|
||||
const char* label;
|
||||
@@ -1470,6 +1547,18 @@ void DrawTopBar() {
|
||||
ImGui::EndMainMenuBar();
|
||||
}
|
||||
|
||||
// Steam's desktop layout maps B to Escape on Valve controllers.
|
||||
bool ValveControllerConnected() {
|
||||
int count = 0;
|
||||
SDL_JoystickID* ids = SDL_GetJoysticks(&count);
|
||||
bool found = false;
|
||||
for (int i = 0; ids != nullptr && i < count && !found; ++i) {
|
||||
found = SDL_GetJoystickVendorForID(ids[i]) == 0x28DE && SDL_GetJoystickProductForID(ids[i]) != 0x11FF;
|
||||
}
|
||||
SDL_free(ids);
|
||||
return found;
|
||||
}
|
||||
|
||||
bool IsToggleKey(const SDL_Event& event, SDL_Scancode code) {
|
||||
return event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat && event.key.scancode == code;
|
||||
}
|
||||
@@ -1591,7 +1680,7 @@ void HandleEvents(const AuroraEvent* events) noexcept {
|
||||
g_exitPromptOpen = false;
|
||||
} else if (g_topBarVisible) {
|
||||
SetTopBarVisible(false);
|
||||
} else {
|
||||
} else if (!ValveControllerConnected()) {
|
||||
g_exitPromptOpen = true;
|
||||
}
|
||||
ApplyInputBlockState();
|
||||
|
||||
@@ -0,0 +1,603 @@
|
||||
#include "steam_input_layout.h"
|
||||
|
||||
#include "runtime_log.h"
|
||||
|
||||
#include <aurora/aurora.h>
|
||||
#include <SDL3/SDL_video.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdlib>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#if defined(_WIN32)
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#include <windows.h>
|
||||
|
||||
#include <cwchar>
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
#else
|
||||
#include <cerrno>
|
||||
#include <csignal>
|
||||
#include <fcntl.h>
|
||||
#include <spawn.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
#if defined(__APPLE__)
|
||||
#include <libproc.h>
|
||||
#include <sys/proc.h>
|
||||
#include <sys/proc_info.h>
|
||||
#else
|
||||
#include <dirent.h>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#endif
|
||||
|
||||
extern char** environ;
|
||||
#endif
|
||||
|
||||
namespace SteamInputLayout {
|
||||
namespace {
|
||||
|
||||
using Clock = std::chrono::steady_clock;
|
||||
|
||||
enum class Handoff { None, Unconfirmed, Confirmed };
|
||||
|
||||
constexpr std::chrono::milliseconds kHandoffTimeout{2000};
|
||||
#if defined(__linux__)
|
||||
// xdg-open can return before the Steam forwarder it started has delivered the URL.
|
||||
constexpr std::chrono::milliseconds kDeliverySpacing{1500};
|
||||
#else
|
||||
constexpr std::chrono::milliseconds kDeliverySpacing{0};
|
||||
#endif
|
||||
|
||||
struct State {
|
||||
std::mutex mutex;
|
||||
std::condition_variable wake;
|
||||
// Serializes requests to Steam so they arrive in order.
|
||||
std::timed_mutex delivery;
|
||||
bool enabled = false;
|
||||
bool stopping = false;
|
||||
bool desired = false;
|
||||
bool sent = false;
|
||||
bool maybeForced = false;
|
||||
uint32_t appId = 0;
|
||||
SDL_WindowID windowId = 0;
|
||||
std::atomic<Clock::rep> lastDelivery{0};
|
||||
std::atomic<bool> released{false};
|
||||
};
|
||||
|
||||
// Never destroyed: the detached worker and late exit paths may still touch it.
|
||||
std::atomic<State*> g_state{nullptr};
|
||||
thread_local bool t_isWorker = false;
|
||||
thread_local bool t_inRelease = false;
|
||||
|
||||
// The worker launches and publishes its request helper under this lock, so an exit that
|
||||
// can't wait for the worker can cancel it: either the helper is published, or it never starts.
|
||||
std::mutex g_helperMutex;
|
||||
std::atomic<bool> g_helperCancelled{false};
|
||||
#if defined(_WIN32)
|
||||
HANDLE g_helper = nullptr;
|
||||
#else
|
||||
pid_t g_helper = 0;
|
||||
#endif
|
||||
|
||||
// These locks are only held briefly; failing to get one means a crash inside it.
|
||||
bool TryLockBriefly(std::unique_lock<std::mutex>& lock,
|
||||
std::chrono::milliseconds timeout = std::chrono::milliseconds(250)) {
|
||||
const auto deadline = Clock::now() + timeout;
|
||||
while (!lock.try_lock()) {
|
||||
if (Clock::now() >= deadline) {
|
||||
return false;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Steam applies the launching app's own layout to everything it starts, children included.
|
||||
bool LaunchedBySteam() {
|
||||
for (const char* name : {"SteamGameId", "SteamAppId", "SteamClientLaunch"}) {
|
||||
const char* value = std::getenv(name);
|
||||
if (value != nullptr && *value != '\0') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
#if defined(_WIN32)
|
||||
if (GetModuleHandleW(L"GameOverlayRenderer64.dll") != nullptr) {
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
||||
#if defined(_WIN32)
|
||||
|
||||
std::vector<BYTE> TokenUserOf(HANDLE process) {
|
||||
std::vector<BYTE> user;
|
||||
HANDLE token = nullptr;
|
||||
if (!OpenProcessToken(process, TOKEN_QUERY, &token)) {
|
||||
return user;
|
||||
}
|
||||
DWORD size = 0;
|
||||
GetTokenInformation(token, TokenUser, nullptr, 0, &size);
|
||||
user.resize(size);
|
||||
if (size == 0 || !GetTokenInformation(token, TokenUser, user.data(), size, &size)) {
|
||||
user.clear();
|
||||
}
|
||||
CloseHandle(token);
|
||||
return user;
|
||||
}
|
||||
|
||||
// A stale pid can be reused by another signed-in user's Steam.
|
||||
bool OwnedByCurrentUser(HANDLE process) {
|
||||
const auto theirs = TokenUserOf(process);
|
||||
const auto ours = TokenUserOf(GetCurrentProcess());
|
||||
return !theirs.empty() && !ours.empty() &&
|
||||
EqualSid(reinterpret_cast<const TOKEN_USER*>(theirs.data())->User.Sid,
|
||||
reinterpret_cast<const TOKEN_USER*>(ours.data())->User.Sid);
|
||||
}
|
||||
|
||||
// ActiveProcess\pid outlives a Steam crash, so the process behind it is verified too.
|
||||
std::optional<std::wstring> RunningSteamExecutable() {
|
||||
DWORD pid = 0;
|
||||
DWORD size = sizeof(pid);
|
||||
if (RegGetValueW(HKEY_CURRENT_USER, L"Software\\Valve\\Steam\\ActiveProcess", L"pid",
|
||||
RRF_RT_REG_DWORD, nullptr, &pid, &size) != ERROR_SUCCESS ||
|
||||
pid == 0) {
|
||||
return std::nullopt;
|
||||
}
|
||||
HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
|
||||
if (process == nullptr) {
|
||||
return std::nullopt;
|
||||
}
|
||||
std::wstring path(32768, L'\0');
|
||||
DWORD length = static_cast<DWORD>(path.size());
|
||||
DWORD exitCode = 0;
|
||||
const bool alive = QueryFullProcessImageNameW(process, 0, path.data(), &length) &&
|
||||
GetExitCodeProcess(process, &exitCode) && exitCode == STILL_ACTIVE &&
|
||||
OwnedByCurrentUser(process);
|
||||
CloseHandle(process);
|
||||
if (!alive) {
|
||||
return std::nullopt;
|
||||
}
|
||||
path.resize(length);
|
||||
if (_wcsicmp(std::filesystem::path(path).filename().c_str(), L"steam.exe") != 0) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
// Same command line as Steam's registered steam:// handler, sent to the verified client.
|
||||
Handoff OpenSteamUrl(const std::string& url, bool bounded) {
|
||||
const auto steam = RunningSteamExecutable();
|
||||
if (!steam) {
|
||||
return Handoff::None;
|
||||
}
|
||||
std::wstring commandLine = L"\"" + *steam + L"\" -- \"" + std::wstring(url.begin(), url.end()) + L"\"";
|
||||
const std::wstring directory = std::filesystem::path(*steam).parent_path().wstring();
|
||||
STARTUPINFOW startup{};
|
||||
startup.cb = sizeof(startup);
|
||||
PROCESS_INFORMATION info{};
|
||||
std::unique_lock launch(g_helperMutex, std::defer_lock);
|
||||
if (!bounded) {
|
||||
launch.lock();
|
||||
if (g_helperCancelled) {
|
||||
return Handoff::None;
|
||||
}
|
||||
}
|
||||
// The worker's helper starts suspended so a cancellation that couldn't take the lock while
|
||||
// CreateProcessW ran still stops it before it can reach Steam.
|
||||
if (!CreateProcessW(steam->c_str(), commandLine.data(), nullptr, nullptr, FALSE,
|
||||
bounded ? 0 : CREATE_SUSPENDED, nullptr, directory.c_str(), &startup, &info)) {
|
||||
return Handoff::None;
|
||||
}
|
||||
if (!bounded) {
|
||||
g_helper = info.hProcess;
|
||||
launch.unlock();
|
||||
if (g_helperCancelled) {
|
||||
TerminateProcess(info.hProcess, 1);
|
||||
} else {
|
||||
ResumeThread(info.hThread);
|
||||
}
|
||||
}
|
||||
CloseHandle(info.hThread);
|
||||
const DWORD waited = WaitForSingleObject(info.hProcess, bounded ? static_cast<DWORD>(kHandoffTimeout.count()) : INFINITE);
|
||||
if (!bounded) {
|
||||
std::lock_guard lock(g_helperMutex);
|
||||
g_helper = nullptr;
|
||||
}
|
||||
DWORD exitCode = 1;
|
||||
const bool succeeded = waited == WAIT_OBJECT_0 && GetExitCodeProcess(info.hProcess, &exitCode) && exitCode == 0;
|
||||
CloseHandle(info.hProcess);
|
||||
return succeeded ? Handoff::Confirmed : Handoff::Unconfirmed;
|
||||
}
|
||||
|
||||
void CancelWorkerRequests() {
|
||||
g_helperCancelled = true;
|
||||
std::unique_lock lock(g_helperMutex, std::defer_lock);
|
||||
if (!TryLockBriefly(lock, kHandoffTimeout)) {
|
||||
return;
|
||||
}
|
||||
if (g_helper != nullptr && TerminateProcess(g_helper, 1)) {
|
||||
WaitForSingleObject(g_helper, static_cast<DWORD>(kHandoffTimeout.count()));
|
||||
}
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
// Only a live client of this user; a URL handler with no client running would start Steam.
|
||||
bool SteamRunning() {
|
||||
const uid_t uid = getuid();
|
||||
#if defined(__APPLE__)
|
||||
const int count = proc_listallpids(nullptr, 0);
|
||||
if (count <= 0) {
|
||||
return false;
|
||||
}
|
||||
std::vector<pid_t> pids(static_cast<size_t>(count) + 32);
|
||||
const int filled = proc_listallpids(pids.data(), static_cast<int>(pids.size() * sizeof(pid_t)));
|
||||
for (int i = 0; i < filled; ++i) {
|
||||
char name[2 * MAXCOMLEN + 1] = {};
|
||||
if (proc_name(pids[i], name, sizeof(name)) <= 0 || std::string(name) != "steam_osx") {
|
||||
continue;
|
||||
}
|
||||
proc_bsdinfo info{};
|
||||
if (proc_pidinfo(pids[i], PROC_PIDTBSDINFO, 0, &info, sizeof(info)) == sizeof(info) &&
|
||||
info.pbi_uid == uid && info.pbi_status != SZOMB) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
#else
|
||||
DIR* proc = opendir("/proc");
|
||||
if (proc == nullptr) {
|
||||
return false;
|
||||
}
|
||||
bool found = false;
|
||||
while (const dirent* entry = readdir(proc)) {
|
||||
if (entry->d_name[0] < '1' || entry->d_name[0] > '9') {
|
||||
continue;
|
||||
}
|
||||
std::ifstream status(std::string("/proc/") + entry->d_name + "/status");
|
||||
std::string line;
|
||||
bool named = false;
|
||||
bool live = false;
|
||||
bool owned = false;
|
||||
while (std::getline(status, line)) {
|
||||
if (line.rfind("Name:", 0) == 0) {
|
||||
named = line == "Name:\tsteam";
|
||||
} else if (line.rfind("State:", 0) == 0) {
|
||||
live = line.find_first_of("ZX") == std::string::npos;
|
||||
} else if (line.rfind("Uid:", 0) == 0) {
|
||||
std::istringstream fields(line.substr(4));
|
||||
uid_t real = 0;
|
||||
owned = static_cast<bool>(fields >> real) && real == uid;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (named && live && owned) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
closedir(proc);
|
||||
return found;
|
||||
#endif
|
||||
}
|
||||
|
||||
Handoff OpenSteamUrl(const std::string& url, bool bounded) {
|
||||
if (!SteamRunning()) {
|
||||
return Handoff::None;
|
||||
}
|
||||
// The worker's helper waits for a go-ahead on fd 3 before it runs the URL handler, so a
|
||||
// cancellation that couldn't take the lock while posix_spawn ran still stops it in time.
|
||||
static char gateScript[] = "read -r go <&3 || exit 1; exec 3<&-; exec \"$0\" \"$@\"";
|
||||
#if defined(__APPLE__)
|
||||
const char* program = "/usr/bin/open";
|
||||
char* argv[] = {const_cast<char*>("open"), const_cast<char*>("-g"), const_cast<char*>(url.c_str()), nullptr};
|
||||
char* gated[] = {const_cast<char*>("sh"), const_cast<char*>("-c"), gateScript, const_cast<char*>(program),
|
||||
const_cast<char*>("-g"), const_cast<char*>(url.c_str()), nullptr};
|
||||
constexpr int kNoSignal = 0;
|
||||
#else
|
||||
const char* program = "xdg-open";
|
||||
char* argv[] = {const_cast<char*>("xdg-open"), const_cast<char*>(url.c_str()), nullptr};
|
||||
char* gated[] = {const_cast<char*>("sh"), const_cast<char*>("-c"), gateScript, const_cast<char*>(program),
|
||||
const_cast<char*>(url.c_str()), nullptr};
|
||||
constexpr int kNoSignal = MSG_NOSIGNAL;
|
||||
#endif
|
||||
int gate[2] = {-1, -1};
|
||||
if (!bounded) {
|
||||
if (socketpair(AF_UNIX, SOCK_STREAM, 0, gate) != 0) {
|
||||
return Handoff::None;
|
||||
}
|
||||
// dup2(3, 3) keeps FD_CLOEXEC on macOS, which would close the gate in the child.
|
||||
if (gate[0] == 3) {
|
||||
std::swap(gate[0], gate[1]);
|
||||
}
|
||||
fcntl(gate[0], F_SETFD, FD_CLOEXEC);
|
||||
fcntl(gate[1], F_SETFD, FD_CLOEXEC);
|
||||
#if defined(__APPLE__)
|
||||
int noSigPipe = 1;
|
||||
setsockopt(gate[1], SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, sizeof(noSigPipe));
|
||||
#endif
|
||||
}
|
||||
const auto closeGate = [&gate] {
|
||||
for (int& end : gate) {
|
||||
if (end != -1) {
|
||||
close(end);
|
||||
end = -1;
|
||||
}
|
||||
}
|
||||
};
|
||||
posix_spawn_file_actions_t actions;
|
||||
if (posix_spawn_file_actions_init(&actions) != 0) {
|
||||
closeGate();
|
||||
return Handoff::None;
|
||||
}
|
||||
if (!bounded) {
|
||||
posix_spawn_file_actions_adddup2(&actions, gate[0], 3);
|
||||
}
|
||||
posix_spawn_file_actions_addopen(&actions, STDIN_FILENO, "/dev/null", O_RDONLY, 0);
|
||||
posix_spawn_file_actions_addopen(&actions, STDOUT_FILENO, "/dev/null", O_WRONLY, 0);
|
||||
posix_spawn_file_actions_addopen(&actions, STDERR_FILENO, "/dev/null", O_WRONLY, 0);
|
||||
// A crash handler can call this with signals blocked; the child should not inherit that.
|
||||
posix_spawnattr_t attributes;
|
||||
posix_spawnattr_init(&attributes);
|
||||
sigset_t noSignals;
|
||||
sigemptyset(&noSignals);
|
||||
posix_spawnattr_setsigmask(&attributes, &noSignals);
|
||||
posix_spawnattr_setflags(&attributes, POSIX_SPAWN_SETSIGMASK);
|
||||
pid_t child = 0;
|
||||
std::unique_lock launch(g_helperMutex, std::defer_lock);
|
||||
if (!bounded) {
|
||||
launch.lock();
|
||||
}
|
||||
int error = ECANCELED;
|
||||
if (bounded) {
|
||||
error = posix_spawnp(&child, program, &actions, &attributes, argv, environ);
|
||||
} else if (!g_helperCancelled) {
|
||||
error = posix_spawn(&child, "/bin/sh", &actions, &attributes, gated, environ);
|
||||
}
|
||||
posix_spawnattr_destroy(&attributes);
|
||||
posix_spawn_file_actions_destroy(&actions);
|
||||
if (error != 0) {
|
||||
closeGate();
|
||||
return Handoff::None;
|
||||
}
|
||||
int status = 0;
|
||||
if (!bounded) {
|
||||
g_helper = child;
|
||||
if (!g_helperCancelled) {
|
||||
send(gate[1], "1\n", 2, kNoSignal);
|
||||
}
|
||||
closeGate();
|
||||
launch.unlock();
|
||||
// Wait without reaping so the pid can't be reused while it is still published.
|
||||
siginfo_t info{};
|
||||
while (waitid(P_PID, static_cast<id_t>(child), &info, WEXITED | WNOWAIT) == -1 && errno == EINTR) {
|
||||
}
|
||||
{
|
||||
std::lock_guard lock(g_helperMutex);
|
||||
g_helper = 0;
|
||||
}
|
||||
}
|
||||
const auto deadline = Clock::now() + kHandoffTimeout;
|
||||
for (;;) {
|
||||
const pid_t waited = waitpid(child, &status, bounded ? WNOHANG : 0);
|
||||
if (waited == child) {
|
||||
return WIFEXITED(status) && WEXITSTATUS(status) == 0 ? Handoff::Confirmed : Handoff::Unconfirmed;
|
||||
}
|
||||
if (waited == -1 && errno != EINTR) {
|
||||
return Handoff::Unconfirmed;
|
||||
}
|
||||
if (waited == 0) {
|
||||
if (Clock::now() >= deadline) {
|
||||
try {
|
||||
std::thread([child] { waitpid(child, nullptr, 0); }).detach();
|
||||
} catch (...) {
|
||||
}
|
||||
return Handoff::Unconfirmed;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void CancelWorkerRequests() {
|
||||
g_helperCancelled = true;
|
||||
std::unique_lock lock(g_helperMutex, std::defer_lock);
|
||||
if (!TryLockBriefly(lock, kHandoffTimeout)) {
|
||||
return;
|
||||
}
|
||||
if (g_helper == 0 || kill(g_helper, SIGKILL) != 0) {
|
||||
return;
|
||||
}
|
||||
// The worker only reaps after clearing g_helper under this lock, so the pid stays ours.
|
||||
const auto deadline = Clock::now() + kHandoffTimeout;
|
||||
for (;;) {
|
||||
siginfo_t info{};
|
||||
if (waitid(P_PID, static_cast<id_t>(g_helper), &info, WEXITED | WNOWAIT | WNOHANG) != 0 ||
|
||||
info.si_pid != 0 || Clock::now() >= deadline) {
|
||||
return;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
// Caller holds state.delivery. Only exit paths bound the wait; the worker waits for each
|
||||
// request to finish so a slow one can never land after the next.
|
||||
Handoff Deliver(State& state, bool forced, uint32_t appId, bool bounded) {
|
||||
const auto earliest = Clock::time_point(Clock::duration(state.lastDelivery.load())) + kDeliverySpacing;
|
||||
if (const auto now = Clock::now(); now < earliest) {
|
||||
std::this_thread::sleep_for(earliest - now);
|
||||
}
|
||||
const Handoff delivered = OpenSteamUrl("steam://forceinputappid/" + std::to_string(forced ? appId : 0), bounded);
|
||||
if (delivered != Handoff::None) {
|
||||
state.lastDelivery = Clock::now().time_since_epoch().count();
|
||||
if (forced) {
|
||||
RT_LOG(RT_TAG_RUNTIME) << "Steam Input: applying the controller layout of app " << appId << std::endl;
|
||||
} else {
|
||||
RT_LOG(RT_TAG_RUNTIME) << "Steam Input: restored the desktop controller layout" << std::endl;
|
||||
}
|
||||
}
|
||||
return delivered;
|
||||
}
|
||||
|
||||
void Worker(State& state) {
|
||||
t_isWorker = true;
|
||||
std::unique_lock lock(state.mutex);
|
||||
for (;;) {
|
||||
state.wake.wait(lock, [&] { return state.stopping || state.desired != state.sent; });
|
||||
if (state.stopping) {
|
||||
return;
|
||||
}
|
||||
const bool target = state.desired;
|
||||
const uint32_t appId = state.appId;
|
||||
// A failed request is retried on the next focus change rather than in a loop.
|
||||
state.sent = target;
|
||||
lock.unlock();
|
||||
{
|
||||
std::lock_guard delivery(state.delivery);
|
||||
{
|
||||
// Release may have run while this thread waited for the delivery lock.
|
||||
std::lock_guard guard(state.mutex);
|
||||
if (state.stopping) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const Handoff delivered = Deliver(state, target, appId, false);
|
||||
std::lock_guard guard(state.mutex);
|
||||
state.maybeForced = target ? delivered != Handoff::None || state.maybeForced
|
||||
: state.maybeForced && delivered != Handoff::Confirmed;
|
||||
}
|
||||
lock.lock();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void Initialize(bool enabled, uint32_t appId) {
|
||||
if (!enabled || appId == 0 || g_state.load() != nullptr) {
|
||||
return;
|
||||
}
|
||||
if (LaunchedBySteam()) {
|
||||
RT_LOG(RT_TAG_RUNTIME) << "Steam Input: launched by Steam, leaving its controller layout alone" << std::endl;
|
||||
return;
|
||||
}
|
||||
SDL_Window* window = aurora_get_window();
|
||||
if (window == nullptr) {
|
||||
return;
|
||||
}
|
||||
auto* state = new State();
|
||||
state->enabled = true;
|
||||
state->appId = appId;
|
||||
state->windowId = SDL_GetWindowID(window);
|
||||
state->desired = (SDL_GetWindowFlags(window) & SDL_WINDOW_INPUT_FOCUS) != 0;
|
||||
try {
|
||||
std::thread(Worker, std::ref(*state)).detach();
|
||||
} catch (const std::exception& error) {
|
||||
RT_LOG(RT_TAG_RUNTIME) << "Steam Input: " << error.what() << std::endl;
|
||||
delete state;
|
||||
return;
|
||||
}
|
||||
g_state.store(state);
|
||||
}
|
||||
|
||||
void HandleEvents(const AuroraEvent* events) {
|
||||
State* state = g_state.load();
|
||||
if (state == nullptr || events == nullptr) {
|
||||
return;
|
||||
}
|
||||
int focus = -1;
|
||||
for (const AuroraEvent* event = events; event->type != AURORA_NONE; ++event) {
|
||||
if (event->type != AURORA_SDL_EVENT) {
|
||||
continue;
|
||||
}
|
||||
const SDL_Event& sdl = event->sdl;
|
||||
const bool gained = sdl.type == SDL_EVENT_WINDOW_FOCUS_GAINED;
|
||||
if ((gained || sdl.type == SDL_EVENT_WINDOW_FOCUS_LOST) && sdl.window.windowID == state->windowId) {
|
||||
focus = gained ? 1 : 0;
|
||||
}
|
||||
}
|
||||
if (focus < 0) {
|
||||
return;
|
||||
}
|
||||
std::lock_guard lock(state->mutex);
|
||||
if (state->enabled) {
|
||||
state->desired = focus == 1;
|
||||
state->wake.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
void Release() noexcept {
|
||||
State* state = g_state.load();
|
||||
if (state == nullptr) {
|
||||
return;
|
||||
}
|
||||
const auto releaseWait = kHandoffTimeout + kDeliverySpacing + std::chrono::milliseconds(500);
|
||||
try {
|
||||
{
|
||||
std::unique_lock lock(state->mutex, std::defer_lock);
|
||||
if (!TryLockBriefly(lock)) {
|
||||
return;
|
||||
}
|
||||
if (!state->enabled) {
|
||||
lock.unlock();
|
||||
// Another thread is restoring; exiting under it would kill its request.
|
||||
const auto deadline = Clock::now() + 2 * releaseWait;
|
||||
while (!t_inRelease && !state->released.load() && Clock::now() < deadline) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
}
|
||||
return;
|
||||
}
|
||||
t_inRelease = true;
|
||||
state->enabled = false;
|
||||
state->stopping = true;
|
||||
state->wake.notify_one();
|
||||
}
|
||||
// Let an in-flight request land first so the restore is the last one Steam sees.
|
||||
std::unique_lock delivery(state->delivery, std::defer_lock);
|
||||
const bool ordered = !t_isWorker && delivery.try_lock_for(releaseWait);
|
||||
bool maybeForced = true;
|
||||
if (ordered) {
|
||||
std::unique_lock lock(state->mutex, std::defer_lock);
|
||||
maybeForced = !TryLockBriefly(lock) || state->maybeForced;
|
||||
}
|
||||
// A request still in flight, or about to start, could otherwise land after the restore.
|
||||
CancelWorkerRequests();
|
||||
if (maybeForced) {
|
||||
// Discovery and spawning can block as well; they must not hold up the exit.
|
||||
auto done = std::make_shared<std::atomic<bool>>(false);
|
||||
try {
|
||||
std::thread([state, done] {
|
||||
Deliver(*state, false, 0, true);
|
||||
done->store(true);
|
||||
}).detach();
|
||||
} catch (const std::system_error&) {
|
||||
done->store(true);
|
||||
}
|
||||
const auto deadline = Clock::now() + releaseWait;
|
||||
while (!done->load() && Clock::now() < deadline) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
}
|
||||
}
|
||||
} catch (...) {
|
||||
}
|
||||
state->released.store(true);
|
||||
}
|
||||
|
||||
} // namespace SteamInputLayout
|
||||
@@ -0,0 +1,247 @@
|
||||
#include "motion_trick_detector.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using MotionTrick::Direction;
|
||||
using MotionTrick::MotionSample;
|
||||
using MotionTrick::ShakeDetector;
|
||||
|
||||
namespace {
|
||||
|
||||
int g_failures = 0;
|
||||
constexpr uint64_t kFrameUs = 16'667;
|
||||
|
||||
void Check(bool ok, const std::string& what) {
|
||||
if (!ok) {
|
||||
std::printf(" FAIL: %s\n", what.c_str());
|
||||
++g_failures;
|
||||
}
|
||||
}
|
||||
|
||||
// Written as raw bits so no float operation under -ffast-math can drop them.
|
||||
void SetBits(float& target, uint32_t bits) {
|
||||
std::memcpy(&target, &bits, sizeof(target));
|
||||
}
|
||||
|
||||
MotionSample Gyro(float pitch, float yaw, float roll) {
|
||||
MotionSample sample;
|
||||
sample.hasGyro = true;
|
||||
sample.gyro[0] = pitch;
|
||||
sample.gyro[1] = yaw;
|
||||
sample.gyro[2] = roll;
|
||||
sample.hasAccel = true;
|
||||
sample.accel[1] = 9.80665f;
|
||||
return sample;
|
||||
}
|
||||
|
||||
MotionSample AtRest() {
|
||||
return Gyro(0.0f, 0.0f, 0.0f);
|
||||
}
|
||||
|
||||
struct Run {
|
||||
ShakeDetector detector;
|
||||
uint64_t now = 0;
|
||||
std::vector<Direction> fired;
|
||||
|
||||
void Feed(const MotionSample& sample, int frames = 1) {
|
||||
for (int i = 0; i < frames; ++i) {
|
||||
const Direction direction = detector.Update(sample, now);
|
||||
if (direction != Direction::None) fired.push_back(direction);
|
||||
now += kFrameUs;
|
||||
}
|
||||
}
|
||||
|
||||
// Ramp up to the peak and back down over a few frames, like a real flick.
|
||||
void Flick(const MotionSample& peak) {
|
||||
for (const float scale : {0.3f, 0.7f, 1.0f, 0.6f, 0.2f}) {
|
||||
MotionSample sample = peak;
|
||||
for (float& value : sample.gyro) value *= scale;
|
||||
Feed(sample);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void TestRestNeverFires() {
|
||||
Run run;
|
||||
run.Feed(AtRest(), 600);
|
||||
Check(run.fired.empty(), "controller at rest fired a trick");
|
||||
}
|
||||
|
||||
void TestSteeringWobbleNeverFires() {
|
||||
Run run;
|
||||
for (int i = 0; i < 600; ++i) {
|
||||
run.Feed(Gyro(1.5f * std::sin(i * 0.2f), 2.0f * std::sin(i * 0.13f), 2.5f * std::cos(i * 0.17f)));
|
||||
}
|
||||
Check(run.fired.empty(), "steering wobble fired a trick");
|
||||
}
|
||||
|
||||
void TestOneFlickOneTrick() {
|
||||
Run run;
|
||||
run.Flick(Gyro(10.0f, 0.0f, 0.0f));
|
||||
run.Feed(AtRest(), 60);
|
||||
Check(run.fired.size() == 1, "one flick did not give exactly one trick");
|
||||
Check(!run.fired.empty() && run.fired[0] == Direction::Up, "upward flick was not Up");
|
||||
}
|
||||
|
||||
void TestReturnStrokeIsIgnored() {
|
||||
Run run;
|
||||
run.Flick(Gyro(10.0f, 0.0f, 0.0f));
|
||||
run.Flick(Gyro(-9.0f, 0.0f, 0.0f));
|
||||
run.Feed(AtRest(), 60);
|
||||
Check(run.fired.size() == 1, "bringing the controller back gave a second trick");
|
||||
}
|
||||
|
||||
void TestSeparateFlicksBothFire() {
|
||||
Run run;
|
||||
run.Flick(Gyro(10.0f, 0.0f, 0.0f));
|
||||
run.Feed(AtRest(), 40);
|
||||
run.Flick(Gyro(10.0f, 0.0f, 0.0f));
|
||||
run.Feed(AtRest(), 40);
|
||||
Check(run.fired.size() == 2, "two separate flicks did not give two tricks");
|
||||
}
|
||||
|
||||
void TestSustainedShakeFiresOnce() {
|
||||
Run run;
|
||||
for (int i = 0; i < 120; ++i) {
|
||||
run.Feed(Gyro(i % 2 == 0 ? 12.0f : -12.0f, 0.0f, 0.0f));
|
||||
}
|
||||
Check(run.fired.size() == 1, "continuous shaking fired more than once");
|
||||
}
|
||||
|
||||
void TestDirections() {
|
||||
const struct {
|
||||
MotionSample peak;
|
||||
Direction expected;
|
||||
const char* name;
|
||||
} cases[] = {
|
||||
{Gyro(10.0f, 0.0f, 0.0f), Direction::Up, "pitch up"},
|
||||
{Gyro(-10.0f, 0.0f, 0.0f), Direction::Down, "pitch down"},
|
||||
{Gyro(1.0f, 0.0f, 10.0f), Direction::Left, "roll left"},
|
||||
{Gyro(1.0f, 0.0f, -10.0f), Direction::Right, "roll right"},
|
||||
{Gyro(0.0f, 10.0f, 2.0f), Direction::Left, "yaw left"},
|
||||
{Gyro(0.0f, -10.0f, -2.0f), Direction::Right, "yaw right"},
|
||||
};
|
||||
for (const auto& c : cases) {
|
||||
Run run;
|
||||
run.Flick(c.peak);
|
||||
Check(run.fired.size() == 1 && run.fired[0] == c.expected, std::string("wrong direction for ") + c.name);
|
||||
}
|
||||
}
|
||||
|
||||
void TestSensitivity() {
|
||||
Run low;
|
||||
low.detector.SetSensitivity(1);
|
||||
low.Flick(Gyro(6.0f, 0.0f, 0.0f));
|
||||
Check(low.fired.empty(), "sensitivity 1 fired on a small flick");
|
||||
|
||||
Run high;
|
||||
high.detector.SetSensitivity(5);
|
||||
high.Flick(Gyro(6.0f, 0.0f, 0.0f));
|
||||
Check(high.fired.size() == 1, "sensitivity 5 missed a small flick");
|
||||
|
||||
Run clamped;
|
||||
clamped.detector.SetSensitivity(99);
|
||||
clamped.Flick(Gyro(6.0f, 0.0f, 0.0f));
|
||||
Check(clamped.fired.size() == 1, "out of range sensitivity was not clamped");
|
||||
}
|
||||
|
||||
void TestAccelOnlyController() {
|
||||
Run run;
|
||||
MotionSample rest;
|
||||
rest.hasAccel = true;
|
||||
rest.accel[1] = 9.80665f;
|
||||
run.Feed(rest, 120);
|
||||
Check(run.fired.empty(), "gravity alone fired a trick");
|
||||
|
||||
MotionSample jolt = rest;
|
||||
jolt.accel[1] = 35.0f;
|
||||
run.Feed(jolt);
|
||||
run.Feed(rest, 60);
|
||||
Check(run.fired.size() == 1 && run.fired[0] == Direction::Up, "accelerometer jolt did not give Up");
|
||||
}
|
||||
|
||||
void TestGyroOnlyController() {
|
||||
Run run;
|
||||
MotionSample flick = Gyro(10.0f, 0.0f, 0.0f);
|
||||
flick.hasAccel = false;
|
||||
MotionSample rest = AtRest();
|
||||
rest.hasAccel = false;
|
||||
run.Flick(flick);
|
||||
run.Feed(rest, 60);
|
||||
run.Flick(flick);
|
||||
run.Feed(rest, 60);
|
||||
Check(run.fired.size() == 2 && run.fired[0] == Direction::Up && run.fired[1] == Direction::Up,
|
||||
"repeated gyro-only flicks did not give two Up tricks");
|
||||
}
|
||||
|
||||
void TestGyroPadIgnoresAccel() {
|
||||
Run bump;
|
||||
MotionSample jolt = AtRest();
|
||||
jolt.accel[1] = 40.0f;
|
||||
bump.Feed(jolt, 3);
|
||||
bump.Feed(AtRest(), 60);
|
||||
Check(bump.fired.empty(), "an accelerometer bump on a gyro pad fired a trick");
|
||||
|
||||
Run flick;
|
||||
MotionSample sideways = Gyro(0.0f, 10.0f, 0.0f);
|
||||
sideways.accel[1] = 40.0f;
|
||||
flick.Feed(sideways);
|
||||
Check(flick.fired.size() == 1 && flick.fired[0] == Direction::Left,
|
||||
"a jolt changed the direction of a sideways flick");
|
||||
}
|
||||
|
||||
void TestNoSensorsNeverFires() {
|
||||
Run run;
|
||||
MotionSample none;
|
||||
none.gyro[0] = 50.0f;
|
||||
none.accel[1] = 80.0f;
|
||||
run.Feed(none, 60);
|
||||
Check(run.fired.empty(), "a sample without sensors fired a trick");
|
||||
}
|
||||
|
||||
void TestBadDataIgnored() {
|
||||
Run run;
|
||||
for (const uint32_t bits : {0x7fc00000u, 0x7f800000u}) {
|
||||
for (int axis = 0; axis < 3; ++axis) {
|
||||
MotionSample gyro = AtRest();
|
||||
SetBits(gyro.gyro[axis], bits);
|
||||
run.Feed(gyro, 10);
|
||||
|
||||
MotionSample accel;
|
||||
accel.hasAccel = true;
|
||||
SetBits(accel.accel[axis], bits);
|
||||
run.Feed(accel, 10);
|
||||
}
|
||||
}
|
||||
Check(run.fired.empty(), "NaN or infinite sensor data fired a trick");
|
||||
run.Flick(Gyro(10.0f, 0.0f, 0.0f));
|
||||
Check(run.fired.size() == 1, "detector did not recover after NaN data");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
TestRestNeverFires();
|
||||
TestSteeringWobbleNeverFires();
|
||||
TestOneFlickOneTrick();
|
||||
TestReturnStrokeIsIgnored();
|
||||
TestSeparateFlicksBothFire();
|
||||
TestSustainedShakeFiresOnce();
|
||||
TestDirections();
|
||||
TestSensitivity();
|
||||
TestAccelOnlyController();
|
||||
TestGyroOnlyController();
|
||||
TestGyroPadIgnoresAccel();
|
||||
TestNoSensorsNeverFires();
|
||||
TestBadDataIgnored();
|
||||
if (g_failures != 0) {
|
||||
std::printf("%d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
std::printf("motion trick tests passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -51,6 +51,45 @@ int main() {
|
||||
const RuntimeUserConfig config = RuntimeConfigFile::ParseConfig(input, "invalid.toml");
|
||||
if (!Require(!config.metalFxSpatialUpscaling, "invalid MetalFX setting was accepted")) return 1;
|
||||
}
|
||||
{
|
||||
std::istringstream input("[video]\nwidescreen = false\nforce_16_9 = true\n");
|
||||
const RuntimeUserConfig config = RuntimeConfigFile::ParseConfig(input, "legacy.toml");
|
||||
if (!Require(config.forceAspect == std::optional<bool>{true} &&
|
||||
config.widescreen == std::optional<bool>{true} && config.legacyForceAspect,
|
||||
"legacy force_16_9 did not force a 16:9 game")) return 1;
|
||||
}
|
||||
{
|
||||
std::istringstream input("[video]\nwidescreen = false\nforce_aspect = true\nforce_16_9 = true\n");
|
||||
const RuntimeUserConfig config = RuntimeConfigFile::ParseConfig(input, "migrated.toml");
|
||||
if (!Require(config.forceAspect == std::optional<bool>{true} &&
|
||||
config.widescreen == std::optional<bool>{false} && !config.legacyForceAspect,
|
||||
"legacy force_16_9 overrode force_aspect")) return 1;
|
||||
}
|
||||
{
|
||||
std::istringstream input("[controller]\nmotion_trick = true\nmotion_trick_sensitivity = 5\n");
|
||||
const RuntimeUserConfig config = RuntimeConfigFile::ParseConfig(input, "motion.toml");
|
||||
if (!Require(config.motionTrick == std::optional<bool>{true} &&
|
||||
config.motionTrickSensitivity == std::optional<int32_t>{5},
|
||||
"shake to trick settings were not loaded")) return 1;
|
||||
}
|
||||
{
|
||||
std::istringstream input("[controller]\nmotion_trick_sensitivity = 9\n");
|
||||
const RuntimeUserConfig config = RuntimeConfigFile::ParseConfig(input, "motion-range.toml");
|
||||
if (!Require(!config.motionTrick && !config.motionTrickSensitivity,
|
||||
"out of range shake to trick sensitivity was accepted")) return 1;
|
||||
}
|
||||
{
|
||||
std::istringstream input("[controller]\nsteam_input_override = false\nsteam_input_app_id = 1234\n");
|
||||
const RuntimeUserConfig config = RuntimeConfigFile::ParseConfig(input, "steam.toml");
|
||||
if (!Require(config.steamInputOverride == std::optional<bool>{false} &&
|
||||
config.steamInputAppId == std::optional<uint32_t>{1234},
|
||||
"Steam Input settings were not loaded")) return 1;
|
||||
}
|
||||
{
|
||||
std::istringstream input("[controller]\nsteam_input_app_id = 0\n");
|
||||
const RuntimeUserConfig config = RuntimeConfigFile::ParseConfig(input, "steam-zero.toml");
|
||||
if (!Require(!config.steamInputAppId, "zero Steam Input app id was accepted")) return 1;
|
||||
}
|
||||
if (!SetsFps("[video]\nshow_fps = false\n", "bare key was not replaced")) return 1;
|
||||
if (!SetsFps("[video]\n\"show_fps\" = false\n", "basic-quoted key was not replaced")) return 1;
|
||||
if (!SetsFps("[video]\n'show_fps'=false\n", "literal-quoted key was not replaced")) return 1;
|
||||
|
||||
@@ -139,6 +139,15 @@ 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
|
||||
@@ -472,10 +481,6 @@ 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
|
||||
@@ -493,7 +498,6 @@ 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.
|
||||
@@ -565,13 +569,15 @@ 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)>(batchSize);
|
||||
var batch = new List<(uint Address, int Depth, string Name)>(queue.Count);
|
||||
var batchAddresses = new HashSet<uint>();
|
||||
var candidates = QueueBatch.Dequeue(
|
||||
queue,
|
||||
batchSize,
|
||||
queue.Count,
|
||||
next => ShouldSkip(next) || !batchAddresses.Add(next.addr));
|
||||
foreach (var next in candidates)
|
||||
{
|
||||
@@ -598,23 +604,19 @@ int RunTranslateRecursive(string[] argsTail)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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)>(batchSize);
|
||||
var batch = new List<(uint Address, int Depth, string Name)>(speculativeSeeds.Count);
|
||||
var candidates = QueueBatch.Dequeue(
|
||||
speculativeSeeds,
|
||||
batchSize,
|
||||
speculativeSeeds.Count,
|
||||
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}")));
|
||||
}
|
||||
|
||||
@@ -652,22 +654,42 @@ 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 results = new FunctionDiscoveryResult?[batch.Count];
|
||||
IndexedParallel.For(batch.Count, parallelOptions, i => results[i] = Attempt(batch[i], speculative));
|
||||
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));
|
||||
|
||||
// Re-run only translations that swallowed a newly revealed sibling entry, or direct
|
||||
// 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)
|
||||
// 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)
|
||||
.SelectMany(i => discoveryTargets[i].Concat(swallowedSiblingEntries[i]))
|
||||
.Where(knownBaseFunctionEntryPoints.Add)
|
||||
.ToHashSet();
|
||||
@@ -675,49 +697,39 @@ 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 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();
|
||||
}
|
||||
});
|
||||
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));
|
||||
}
|
||||
|
||||
// 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 (results[i] is not { } result) continue;
|
||||
CommitResult(batch[i], result, discoveryTargets[i], summaries[i]);
|
||||
if (metrics[i] is not { } functionMetrics) continue;
|
||||
CommitResult(batch[i], functionMetrics, discoveryTargets[i], summaries[i], functionEnds[i], flowCoverage[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)
|
||||
@@ -773,16 +785,18 @@ int RunTranslateRecursive(string[] argsTail)
|
||||
|
||||
void CommitResult(
|
||||
(uint Address, int Depth, string Name) work,
|
||||
FunctionDiscoveryResult result,
|
||||
TranslationMetrics functionMetrics,
|
||||
IReadOnlyList<uint> discoveryTargets,
|
||||
GqrFunctionSummary summary)
|
||||
GqrFunctionSummary summary,
|
||||
uint functionEnd,
|
||||
uint[] flowCoverage)
|
||||
{
|
||||
// File writing moved to parallel section above
|
||||
totals.Add(result.Metrics);
|
||||
totals.Add(functionMetrics);
|
||||
count++;
|
||||
translated.Add(work.Address);
|
||||
translatedFunctionEnds[work.Address] = GetFunctionInstructionEnd(result);
|
||||
translatedFlowCoverage[work.Address] = CoalesceInstructionRanges(result);
|
||||
translatedFunctionEnds[work.Address] = functionEnd;
|
||||
translatedFlowCoverage[work.Address] = flowCoverage;
|
||||
|
||||
foreach (var target in discoveryTargets)
|
||||
{
|
||||
@@ -821,6 +835,7 @@ 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)
|
||||
{
|
||||
@@ -864,26 +879,31 @@ int RunTranslateRecursive(string[] argsTail)
|
||||
if (boundaryRepairAddresses.Length != 0)
|
||||
{
|
||||
Console.WriteLine($"[translator] Reanalyzing {boundaryRepairAddresses.Length:N0} function(s) affected by later-discovered boundaries...");
|
||||
var repaired = new FunctionDiscoveryResult[boundaryRepairAddresses.Length];
|
||||
var repairedSummaries = new GqrFunctionSummary[boundaryRepairAddresses.Length];
|
||||
var repairedEnds = new uint[boundaryRepairAddresses.Length];
|
||||
var repairedCoverage = new uint[boundaryRepairAddresses.Length][];
|
||||
IndexedParallel.For(boundaryRepairAddresses.Length, parallelOptions, i =>
|
||||
{
|
||||
var address = boundaryRepairAddresses[i];
|
||||
var work = baseWork[address];
|
||||
repaired[i] = translator.Value.Discover(address, WithProjectFrontEndPolicy(new TranslationOptions(
|
||||
var repaired = 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] = GqrFunctionSummary.Create(repaired[i].Ssa.Function);
|
||||
canonicalIrStore.Put(address, repaired[i].Ssa.Function);
|
||||
translatedFunctionEnds[address] = GetFunctionInstructionEnd(repaired[i]);
|
||||
translatedFlowCoverage[address] = CoalesceInstructionRanges(repaired[i]);
|
||||
baseSummaries[address] = repairedSummaries[i];
|
||||
translatedFunctionEnds[address] = repairedEnds[i];
|
||||
translatedFlowCoverage[address] = repairedCoverage[i];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -906,6 +926,12 @@ 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);
|
||||
@@ -1183,7 +1209,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));
|
||||
ReportAbiPrewarm(inferredGuestAbi.Value.Prewarm(emissionAddresses, maxThreads));
|
||||
LogPhase("ABI prewarm");
|
||||
|
||||
// State-free entries use the transformed leaf's exact live-at-entry
|
||||
@@ -1404,6 +1430,7 @@ 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;
|
||||
@@ -1459,6 +1486,8 @@ 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)
|
||||
@@ -1517,16 +1546,7 @@ int RunTranslateRecursive(string[] argsTail)
|
||||
: Path.GetRelativePath(
|
||||
Path.GetDirectoryName(Path.GetFullPath(outputMetadataPath!))!,
|
||||
Path.GetFullPath(productionSourceBundlePath)).Replace('\\', '/');
|
||||
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));
|
||||
});
|
||||
var metadataUnsupportedInstructionCount = unsupportedByIndex.Sum();
|
||||
completedOutputMetadata = BaseTranslationOutputMetadata.Create(
|
||||
outputMetadata,
|
||||
new TranslationQualityMetadata(
|
||||
@@ -1701,7 +1721,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 = BaseTranslationOutputMetadataFile.Read(baseTranslationOutputMetadataPath);
|
||||
var guardedBaseMetadata = ReadBaseMetadataCached(baseTranslationOutputMetadataPath);
|
||||
var codePulSha256 = ChecksumUtilities.Sha256HexOfFile(codePul);
|
||||
var baseKnowsThisPul = guardedBaseMetadata.ModPatchAwareness is { } awareness &&
|
||||
awareness.Any(entry => string.Equals(entry.CodePulSha256, codePulSha256, StringComparison.OrdinalIgnoreCase));
|
||||
@@ -1780,6 +1800,8 @@ 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)
|
||||
@@ -1988,7 +2010,7 @@ int RunTranslateModCore(string[] argsTail, string? outputDirectoryOverride)
|
||||
baseManifest,
|
||||
dvdOverlayRoots,
|
||||
relocatedModuleImage,
|
||||
SHA1.HashData(selected.CodeBlob),
|
||||
sourceKamekCodeSha1,
|
||||
retroWfcLoweringPlan?.StaticPointers,
|
||||
selected.CodeSize,
|
||||
selected.BssSize,
|
||||
@@ -2077,7 +2099,7 @@ void WriteResolvedDispatchProfile(
|
||||
var metadataPath = baseTranslationOutputMetadataPath;
|
||||
if (File.Exists(metadataPath))
|
||||
{
|
||||
var metadata = BaseTranslationOutputMetadataFile.Read(metadataPath);
|
||||
var metadata = ReadBaseMetadataCached(metadataPath);
|
||||
if (!string.IsNullOrWhiteSpace(metadata.SourceBundlePath))
|
||||
{
|
||||
var bundlePath = Path.IsPathRooted(metadata.SourceBundlePath)
|
||||
@@ -2152,7 +2174,7 @@ void WriteResolvedDispatchProfile(
|
||||
BaseTranslationOutputMetadata? metadata = null;
|
||||
if (File.Exists(baseTranslationOutputMetadataPath))
|
||||
{
|
||||
metadata = BaseTranslationOutputMetadataFile.Read(baseTranslationOutputMetadataPath);
|
||||
metadata = ReadBaseMetadataCached(baseTranslationOutputMetadataPath);
|
||||
}
|
||||
if (metadata is null || metadata.Functions.Any(static function => function.Build is null))
|
||||
{
|
||||
@@ -2256,12 +2278,21 @@ 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 =>
|
||||
group.Any(entry => entry.Kind == "rr") &&
|
||||
group.OrderByDescending(entry => entry.Priority)
|
||||
.ThenByDescending(entry => KindRank(entry.Kind))
|
||||
.First().Kind == "native")
|
||||
{
|
||||
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));
|
||||
})
|
||||
.Select(group => new
|
||||
{
|
||||
Address = group.Key,
|
||||
@@ -2324,12 +2355,9 @@ int EmitModCpp(
|
||||
: new CompositeGuestFunctionAbiProvider(inferredAbi);
|
||||
var modTranslator = new FunctionTranslator(modImage, abiProvider);
|
||||
var parallelOptions = new ParallelOptions { MaxDegreeOfParallelism = maxThreads };
|
||||
// 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}");
|
||||
// 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)");
|
||||
|
||||
var cppRoot = Path.Combine(outDir, "cpp");
|
||||
if (Directory.Exists(cppRoot))
|
||||
@@ -2408,7 +2436,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)));
|
||||
ReportAbiPrewarm(inferredAbi.Prewarm(wave.Select(static work => work.Address), maxThreads));
|
||||
return ModTranslationWaveRunner.Translate(() => modTranslator, wave, parallelOptions);
|
||||
}
|
||||
|
||||
@@ -2445,7 +2473,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)));
|
||||
ReportAbiPrewarm(inferredAbi.Prewarm(ordered.Select(static work => work.Address), maxThreads));
|
||||
// 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
|
||||
@@ -2590,7 +2618,7 @@ int EmitModCpp(
|
||||
{
|
||||
while (discoveredContinuationQueue.Count > 0)
|
||||
{
|
||||
var wave = QueueBatch.Dequeue(discoveredContinuationQueue, waveSize)
|
||||
var wave = QueueBatch.Dequeue(discoveredContinuationQueue, discoveredContinuationQueue.Count)
|
||||
.Select(CreateContinuationWork)
|
||||
.ToArray();
|
||||
CommitWave(TranslateWave(wave), (result, work) =>
|
||||
@@ -2609,10 +2637,10 @@ int EmitModCpp(
|
||||
var emittedModuleStarts = new HashSet<uint>();
|
||||
while (moduleQueue.Count > 0)
|
||||
{
|
||||
var wave = new List<ModTranslationWork>(waveSize);
|
||||
var wave = new List<ModTranslationWork>(moduleQueue.Count);
|
||||
foreach (var start in QueueBatch.Dequeue(
|
||||
moduleQueue,
|
||||
waveSize,
|
||||
moduleQueue.Count,
|
||||
start => !emittedModuleStarts.Add(start.Address)))
|
||||
{
|
||||
var name = ModuleFunctionName(start.Address);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<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,12 +102,7 @@ public static class GuestMemoryRangeLowering
|
||||
.Any(group => group.Count() >= minimumAccesses))
|
||||
return 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 graph = BlockGraph.Build(function);
|
||||
|
||||
var plans = new Dictionary<(int Block, int Instruction), Plan>();
|
||||
var resolves = new Dictionary<int, List<PlacedResolve>>();
|
||||
@@ -129,24 +124,35 @@ 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 =>
|
||||
!dominators[candidate.AccessBlockLabel(function)].Contains(definitionBlock) ||
|
||||
!graph.Dominates(defined.BlockIndex, candidate.BlockIndex) ||
|
||||
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 =>
|
||||
DominatesAccess(function, dominators, anchor, candidate) &&
|
||||
!HasInvalidatingBoundaryOnPath(
|
||||
function, reachability, cyclicBlocks,
|
||||
invalidators, anchor, candidate))
|
||||
var members = remaining.Where(candidate => Compatible(anchor, candidate))
|
||||
.OrderBy(candidate => candidate.Access.Index)
|
||||
.ToList();
|
||||
// Keep a bounded affine window around the real anchor. A
|
||||
@@ -248,15 +254,13 @@ public static class GuestMemoryRangeLowering
|
||||
}
|
||||
|
||||
private static bool DominatesAccess(
|
||||
IrFunction function,
|
||||
IReadOnlyDictionary<string, HashSet<string>> dominators,
|
||||
BlockGraph graph,
|
||||
FunctionAccess anchor,
|
||||
FunctionAccess consumer)
|
||||
{
|
||||
if (anchor.BlockIndex == consumer.BlockIndex)
|
||||
return anchor.Access.Index <= consumer.Access.Index;
|
||||
return dominators[consumer.AccessBlockLabel(function)].Contains(
|
||||
anchor.AccessBlockLabel(function));
|
||||
return graph.Dominates(anchor.BlockIndex, consumer.BlockIndex);
|
||||
}
|
||||
|
||||
private static bool HasOverlappingOppositeAccess(
|
||||
@@ -273,27 +277,25 @@ public static class GuestMemoryRangeLowering
|
||||
}
|
||||
|
||||
private static bool HasInvalidatingBoundaryOnPath(
|
||||
IrFunction function,
|
||||
IReadOnlyDictionary<string, HashSet<string>> reachability,
|
||||
IReadOnlySet<string> cyclicBlocks,
|
||||
BlockGraph graph,
|
||||
IReadOnlyList<InstructionSite> invalidators,
|
||||
FunctionAccess anchor,
|
||||
FunctionAccess consumer)
|
||||
{
|
||||
var anchorLabel = anchor.AccessBlockLabel(function);
|
||||
var consumerLabel = consumer.AccessBlockLabel(function);
|
||||
var anchorBlock = anchor.BlockIndex;
|
||||
var consumerBlock = consumer.BlockIndex;
|
||||
var consumerReentered = anchorBlock != consumerBlock && graph.Cyclic[consumerBlock];
|
||||
foreach (var invalidator in invalidators)
|
||||
{
|
||||
var boundaryLabel = function.Blocks[invalidator.BlockIndex].Label;
|
||||
var afterAnchor = invalidator.BlockIndex == anchor.BlockIndex
|
||||
var boundaryBlock = invalidator.BlockIndex;
|
||||
var afterAnchor = boundaryBlock == anchorBlock
|
||||
? invalidator.InstructionIndex > anchor.Access.Index
|
||||
: 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;
|
||||
: 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;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -315,54 +317,117 @@ public static class GuestMemoryRangeLowering
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Dictionary<string, HashSet<string>> ComputeDominators(IrCfg cfg)
|
||||
/// <summary>Dominator and reachability sets as bitsets over block ordinals.</summary>
|
||||
private sealed class BlockGraph
|
||||
{
|
||||
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
|
||||
{
|
||||
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;
|
||||
}
|
||||
private readonly ulong[][] _dominators;
|
||||
private readonly ulong[][] _reach;
|
||||
|
||||
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)
|
||||
private BlockGraph(ulong[][] dominators, ulong[][] reach, bool[] cyclic)
|
||||
{
|
||||
var reachable = new HashSet<string>(comparer);
|
||||
var pending = new Stack<string>();
|
||||
pending.Push(start);
|
||||
while (pending.Count != 0)
|
||||
{
|
||||
var current = pending.Pop();
|
||||
if (!reachable.Add(current)) continue;
|
||||
foreach (var successor in cfg.Successors(current)) pending.Push(successor);
|
||||
}
|
||||
result[start] = reachable;
|
||||
_dominators = dominators;
|
||||
_reach = reach;
|
||||
Cyclic = cyclic;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
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 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 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);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IrInstruction ToResolved(IrInstruction instruction, Access access, Plan plan)
|
||||
|
||||
@@ -3,6 +3,7 @@ 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;
|
||||
@@ -72,6 +73,9 @@ 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
|
||||
@@ -95,14 +99,21 @@ 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))
|
||||
{
|
||||
@@ -151,6 +162,7 @@ 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;
|
||||
@@ -172,6 +184,7 @@ public sealed class InferredGuestFunctionAbiProvider : IGuestFunctionAbiProvider
|
||||
finally
|
||||
{
|
||||
inProgress.Remove(address);
|
||||
state.FrameAddresses.RemoveAt(frameIndex);
|
||||
tainted = frameTaint[frameIndex];
|
||||
frameTaint.RemoveAt(frameIndex);
|
||||
if (tainted && frameIndex > 0)
|
||||
@@ -274,6 +287,7 @@ 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
|
||||
@@ -418,82 +432,102 @@ public sealed class InferredGuestFunctionAbiProvider : IGuestFunctionAbiProvider
|
||||
private HashSet<string> InferArgumentRegisters(IrFunction function, IrCfg cfg)
|
||||
{
|
||||
var comparer = StringComparer.OrdinalIgnoreCase;
|
||||
var blockUses = new Dictionary<string, HashSet<string>>(comparer);
|
||||
var blockDefs = new Dictionary<string, HashSet<string>>(comparer);
|
||||
|
||||
foreach (var block in function.Blocks)
|
||||
// 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)
|
||||
{
|
||||
var uses = new HashSet<string>(comparer);
|
||||
var defs = new HashSet<string>(comparer);
|
||||
if (!ids.TryGetValue(name, out var id))
|
||||
{
|
||||
id = names.Count;
|
||||
ids.Add(name, id);
|
||||
names.Add(name);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
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++)
|
||||
{
|
||||
var block = function.Blocks[b];
|
||||
var uses = new List<int>();
|
||||
var defs = new List<int>();
|
||||
var defined = new HashSet<int>();
|
||||
foreach (var instruction in block.Instructions)
|
||||
{
|
||||
foreach (var use in InstructionRegisterUsesForArgumentInference(instruction))
|
||||
{
|
||||
if (!defs.Contains(use))
|
||||
{
|
||||
uses.Add(use);
|
||||
}
|
||||
var id = Id(use);
|
||||
if (!defined.Contains(id))
|
||||
uses.Add(id);
|
||||
}
|
||||
|
||||
foreach (var def in InstructionRegisterDefsForArgumentInference(instruction))
|
||||
{
|
||||
defs.Add(def);
|
||||
var id = Id(def);
|
||||
if (defined.Add(id))
|
||||
defs.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
blockUses[block.Label] = uses;
|
||||
blockDefs[block.Label] = defs;
|
||||
useLists[b] = uses;
|
||||
defLists[b] = defs;
|
||||
}
|
||||
|
||||
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);
|
||||
for (var b = 0; b < blockCount; b++)
|
||||
labelIndex.Add(function.Blocks[b].Label, b);
|
||||
|
||||
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 blockIndex = function.Blocks.Count - 1; blockIndex >= 0; blockIndex--)
|
||||
for (var b = blockCount - 1; b >= 0; b--)
|
||||
{
|
||||
var block = function.Blocks[blockIndex];
|
||||
var newOut = new HashSet<string>(comparer);
|
||||
foreach (var successor in cfg.Successors(block.Label))
|
||||
var row = b * words;
|
||||
for (var w = 0; w < words; w++)
|
||||
{
|
||||
if (liveIn.TryGetValue(successor, out var successorLiveIn))
|
||||
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])
|
||||
{
|
||||
newOut.UnionWith(successorLiveIn);
|
||||
liveIn[row + w] = value;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return liveIn.TryGetValue(function.EntryLabel, out var entryLive)
|
||||
? entryLive
|
||||
.Where(IsGprOrFprRegister)
|
||||
.ToHashSet(comparer)
|
||||
: new HashSet<string>(comparer);
|
||||
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;
|
||||
}
|
||||
|
||||
private IEnumerable<string> InstructionRegisterUsesForArgumentInference(IrInstruction instruction)
|
||||
@@ -755,45 +789,74 @@ public sealed class InferredGuestFunctionAbiProvider : IGuestFunctionAbiProvider
|
||||
private HashSet<string> InferScalarFloatArguments(IrFunction function, IrCfg cfg)
|
||||
{
|
||||
var scalarArgs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
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 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 changed = true;
|
||||
while (changed)
|
||||
{
|
||||
changed = false;
|
||||
foreach (var block in function.Blocks)
|
||||
for (var b = 0; b < blockCount; 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)
|
||||
int[] newIn;
|
||||
if (isEntry[b])
|
||||
{
|
||||
ProcessScalarArgumentInstruction(ins, newOut, scalarArgs);
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!AliasStatesEqual(newIn, inMap[block.Label]))
|
||||
var newOut = (int[])newIn.Clone();
|
||||
foreach (var ins in function.Blocks[b].Instructions)
|
||||
{
|
||||
inMap[block.Label] = newIn;
|
||||
ProcessScalarArgumentInstruction(ins, keys, newOut, scalarArgs);
|
||||
}
|
||||
|
||||
if (!newIn.AsSpan().SequenceEqual(inMap[b]))
|
||||
{
|
||||
inMap[b] = newIn;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!AliasStatesEqual(newOut, outMap[block.Label]))
|
||||
if (!newOut.AsSpan().SequenceEqual(outMap[b]))
|
||||
{
|
||||
outMap[block.Label] = newOut;
|
||||
outMap[b] = newOut;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
@@ -802,63 +865,153 @@ 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,
|
||||
Dictionary<string, HashSet<string>> aliases,
|
||||
AliasKeys keys,
|
||||
int[] aliases,
|
||||
HashSet<string> scalarArgs)
|
||||
{
|
||||
switch (ins)
|
||||
{
|
||||
case IrAssign assign:
|
||||
PropagateFloatAlias(assign.Destination, assign.Value, aliases);
|
||||
PropagateFloatAlias(assign.Destination, assign.Value, keys, aliases);
|
||||
break;
|
||||
|
||||
case IrBinary bin:
|
||||
MarkScalarUse(bin.Left, aliases, scalarArgs);
|
||||
MarkScalarUse(bin.Right, aliases, scalarArgs);
|
||||
KillFloatAlias(bin.Destination, aliases);
|
||||
MarkScalarUse(bin.Left, keys, aliases, scalarArgs);
|
||||
MarkScalarUse(bin.Right, keys, aliases, scalarArgs);
|
||||
KillFloatAlias(bin.Destination, keys, aliases);
|
||||
break;
|
||||
|
||||
case IrLoad load:
|
||||
KillFloatAlias(load.Destination, aliases);
|
||||
KillFloatAlias(load.Destination, keys, aliases);
|
||||
break;
|
||||
|
||||
case IrStore store:
|
||||
if (store.SizeBytes is 4 or 8)
|
||||
{
|
||||
MarkScalarUse(store.Source, aliases, scalarArgs);
|
||||
MarkScalarUse(store.Source, keys, aliases, scalarArgs);
|
||||
}
|
||||
break;
|
||||
|
||||
case IrCall call:
|
||||
ProcessScalarArgumentCall(call.Target, call.Arguments, aliases, scalarArgs);
|
||||
KillFloatAlias(call.Destination, aliases);
|
||||
ProcessScalarArgumentCall(call.Target, call.Arguments, keys, aliases, scalarArgs);
|
||||
KillFloatAlias(call.Destination, keys, aliases);
|
||||
break;
|
||||
|
||||
case IrIndirectCall icall:
|
||||
foreach (var arg in icall.Arguments)
|
||||
{
|
||||
MarkScalarUse(arg, aliases, scalarArgs);
|
||||
MarkScalarUse(arg, keys, aliases, scalarArgs);
|
||||
}
|
||||
aliases.Remove("f1");
|
||||
KillFloatAlias(icall.Destination, aliases);
|
||||
aliases[F1AliasKey] = -1;
|
||||
KillFloatAlias(icall.Destination, keys, aliases);
|
||||
break;
|
||||
|
||||
case IrSetCrField setCr:
|
||||
MarkScalarUse(setCr.Left, aliases, scalarArgs);
|
||||
MarkScalarUse(setCr.Right, aliases, scalarArgs);
|
||||
MarkScalarUse(setCr.Left, keys, aliases, scalarArgs);
|
||||
MarkScalarUse(setCr.Right, keys, aliases, scalarArgs);
|
||||
break;
|
||||
|
||||
case IrPhi phi:
|
||||
KillFloatAlias(phi.Destination, aliases);
|
||||
KillFloatAlias(phi.Destination, keys, 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,
|
||||
Dictionary<string, HashSet<string>> aliases,
|
||||
AliasKeys keys,
|
||||
int[] aliases,
|
||||
HashSet<string> scalarArgs)
|
||||
{
|
||||
if (IsGuestCallTarget(target))
|
||||
@@ -873,14 +1026,14 @@ public sealed class InferredGuestFunctionAbiProvider : IGuestFunctionAbiProvider
|
||||
arg.RegisterName != null &&
|
||||
abi.HasScalarFloatArgument(Base(arg.RegisterName)))
|
||||
{
|
||||
MarkScalarUse(arg, aliases, scalarArgs);
|
||||
MarkScalarUse(arg, keys, aliases, scalarArgs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (writesFloatReturnRegister)
|
||||
{
|
||||
aliases.Remove("f1");
|
||||
aliases[F1AliasKey] = -1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -892,7 +1045,7 @@ public sealed class InferredGuestFunctionAbiProvider : IGuestFunctionAbiProvider
|
||||
|
||||
foreach (var arg in arguments)
|
||||
{
|
||||
MarkScalarUse(arg, aliases, scalarArgs);
|
||||
MarkScalarUse(arg, keys, aliases, scalarArgs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1083,7 +1236,8 @@ public sealed class InferredGuestFunctionAbiProvider : IGuestFunctionAbiProvider
|
||||
|
||||
private static void MarkScalarUse(
|
||||
IrValue value,
|
||||
IReadOnlyDictionary<string, HashSet<string>> aliases,
|
||||
AliasKeys keys,
|
||||
int[] aliases,
|
||||
HashSet<string> scalarArgs)
|
||||
{
|
||||
if (value.Kind != "register" || value.RegisterName == null)
|
||||
@@ -1092,24 +1246,22 @@ public sealed class InferredGuestFunctionAbiProvider : IGuestFunctionAbiProvider
|
||||
}
|
||||
|
||||
var baseName = Base(value.RegisterName);
|
||||
if (!IsFloatRegister(baseName) || !aliases.TryGetValue(baseName, out var sources))
|
||||
if (!IsFloatRegister(baseName) || !keys.TryGet(baseName, out var key) || aliases[key] < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var source in sources)
|
||||
for (var sources = aliases[key]; sources != 0; sources &= sources - 1)
|
||||
{
|
||||
if (AbiFloatArgumentSet.Contains(source))
|
||||
{
|
||||
scalarArgs.Add(source);
|
||||
}
|
||||
scalarArgs.Add(AbiFloatArgumentRegisters[BitOperations.TrailingZeroCount(sources)]);
|
||||
}
|
||||
}
|
||||
|
||||
private static void PropagateFloatAlias(
|
||||
string destination,
|
||||
IrValue value,
|
||||
Dictionary<string, HashSet<string>> aliases)
|
||||
AliasKeys keys,
|
||||
int[] aliases)
|
||||
{
|
||||
var destBase = Base(destination);
|
||||
if (!IsFloatRegister(destBase))
|
||||
@@ -1117,24 +1269,27 @@ public sealed class InferredGuestFunctionAbiProvider : IGuestFunctionAbiProvider
|
||||
return;
|
||||
}
|
||||
|
||||
keys.TryGet(destBase, out var destKey);
|
||||
if (value.Kind == "register" &&
|
||||
value.RegisterName != null &&
|
||||
IsFloatRegister(Base(value.RegisterName)) &&
|
||||
aliases.TryGetValue(Base(value.RegisterName), out var srcAliases))
|
||||
keys.TryGet(Base(value.RegisterName), out var sourceKey) &&
|
||||
aliases[sourceKey] >= 0)
|
||||
{
|
||||
aliases[destBase] = new HashSet<string>(srcAliases, StringComparer.OrdinalIgnoreCase);
|
||||
aliases[destKey] = aliases[sourceKey];
|
||||
}
|
||||
else
|
||||
{
|
||||
aliases.Remove(destBase);
|
||||
aliases[destKey] = -1;
|
||||
}
|
||||
}
|
||||
|
||||
private static void KillFloatAlias(string destination, Dictionary<string, HashSet<string>> aliases)
|
||||
private static void KillFloatAlias(string destination, AliasKeys keys, int[] aliases)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(destination) && IsFloatRegister(Base(destination)))
|
||||
if (!string.IsNullOrWhiteSpace(destination) && IsFloatRegister(Base(destination)) &&
|
||||
keys.TryGet(Base(destination), out var key))
|
||||
{
|
||||
aliases.Remove(Base(destination));
|
||||
aliases[key] = -1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1146,70 +1301,6 @@ 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,9 +83,11 @@ 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.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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)]
|
||||
[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)]
|
||||
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,7 +11,19 @@ public sealed record RuntimeNativeRegistration(
|
||||
string Symbol,
|
||||
string SourceFile,
|
||||
bool IsTranslatedOverride,
|
||||
bool ExcludesBaseTranslation);
|
||||
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
|
||||
}
|
||||
|
||||
public sealed record RuntimeNativeAbiEntry(
|
||||
uint Address,
|
||||
@@ -119,7 +131,17 @@ public static class RuntimeNativeIndexBuilder
|
||||
{
|
||||
var source = sourceFile.Content;
|
||||
foreach (Match match in GeneratedMarkers.NativeFunctionRegistrationPattern().Matches(source))
|
||||
Add(match, match.Groups["symbol"].Value, false, !match.Groups["as"].Success);
|
||||
{
|
||||
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
|
||||
});
|
||||
}
|
||||
foreach (Match match in GeneratedMarkers.TranslatedFunctionRegistrationPattern().Matches(source))
|
||||
Add(match, match.Groups["symbol"].Value, true, true);
|
||||
foreach (Match match in GeneratedMarkers.NativeOverridePattern().Matches(source))
|
||||
@@ -127,10 +149,11 @@ 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) =>
|
||||
void Add(Match match, string symbol, bool translated, bool excludesBase,
|
||||
NativeModOverlay modOverlay = NativeModOverlay.Unspecified) =>
|
||||
registrations.Add(new RuntimeNativeRegistration(
|
||||
ParseAddress(match.Groups["address"].Value), symbol, sourceFile.RelativePath,
|
||||
translated, excludesBase));
|
||||
translated, excludesBase, modOverlay));
|
||||
}
|
||||
|
||||
return registrations
|
||||
@@ -139,7 +162,8 @@ 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.ExcludesBaseTranslation)
|
||||
.ThenBy(static registration => registration.ModOverlay);
|
||||
}
|
||||
|
||||
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 = 1;
|
||||
public const int FormatVersion = 2;
|
||||
// Bump whenever the scanner or the ABI/effect analyzers can produce different output for the same sources.
|
||||
public const int ExtractorVersion = 1;
|
||||
public const int ExtractorVersion = 2;
|
||||
public const string DefaultFileName = "native-index.json";
|
||||
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
@@ -59,13 +59,15 @@ 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
|
||||
ExcludesBaseTranslation = registration.ExcludesBaseTranslation,
|
||||
ModOverlay = registration.ModOverlay.ToString()
|
||||
})
|
||||
.ToList(),
|
||||
VoidStubAbis = index.VoidStubAbis
|
||||
@@ -160,7 +162,8 @@ public static class RuntimeNativeIndexFile
|
||||
Present(registration.Symbol, "symbol"),
|
||||
Present(registration.SourceFile, "sourceFile"),
|
||||
registration.IsTranslatedOverride,
|
||||
registration.ExcludesBaseTranslation))
|
||||
registration.ExcludesBaseTranslation,
|
||||
ParseModOverlay(Present(registration.ModOverlay, "modOverlay"))))
|
||||
.ToArray(),
|
||||
Present(document.VoidStubAbis, "voidStubAbis")
|
||||
.Select(static entry => new RuntimeNativeAbiEntry(
|
||||
@@ -185,6 +188,11 @@ 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 =>
|
||||
@@ -244,6 +252,7 @@ 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,6 +30,14 @@ 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;
|
||||
@@ -55,12 +63,16 @@ 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);
|
||||
using var stream = new MemoryStream();
|
||||
var stream = _encodeStream ??= new MemoryStream();
|
||||
stream.SetLength(0);
|
||||
using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true);
|
||||
writer.Write(Magic);
|
||||
WriteVarUInt(writer, (uint)CanonicalIrStore.FormatVersion);
|
||||
@@ -85,9 +97,11 @@ internal static class CanonicalIrBinaryCodec
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
public static IrFunction Decode(ReadOnlySpan<byte> encoded)
|
||||
public static IrFunction Decode(ReadOnlySpan<byte> encoded) => Decode(encoded.ToArray());
|
||||
|
||||
public static IrFunction Decode(byte[] encoded)
|
||||
{
|
||||
using var stream = new MemoryStream(encoded.ToArray(), writable: false);
|
||||
using var stream = new MemoryStream(encoded, writable: false);
|
||||
using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true);
|
||||
if (!reader.ReadBytes(Magic.Length).SequenceEqual(Magic) ||
|
||||
ReadVarUInt(reader) != CanonicalIrStore.FormatVersion)
|
||||
|
||||
@@ -497,12 +497,52 @@ 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>();
|
||||
var queue = new Queue<uint>(roots);
|
||||
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);
|
||||
while (queue.TryDequeue(out var address))
|
||||
{
|
||||
if (!reachable.Add(address)) continue;
|
||||
@@ -510,7 +550,6 @@ public static class GqrInterproceduralAnalysis
|
||||
if (functions.ContainsKey(target) && !reachable.Contains(target))
|
||||
queue.Enqueue(target);
|
||||
}
|
||||
return reachable;
|
||||
}
|
||||
|
||||
private static void TransferCompact(
|
||||
|
||||
@@ -167,6 +167,31 @@ 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\(\s*([0-9A-Fa-f]{8})\s*\)")
|
||||
.SelectMany(static path => Regex.Matches(File.ReadAllText(path), @"MKW_GUEST_(?:FUNC|NEXT)\(\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({pal:X8}) is not a {region} function entry (0x{address:X8})");
|
||||
Assert.True(starts.Contains(address), $"MKW_GUEST_FUNC/NEXT({pal:X8}) is not a {region} function entry (0x{address:X8})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Translator.Core;
|
||||
using Translator.Core.CodeGen;
|
||||
using Xunit;
|
||||
@@ -40,14 +41,24 @@ 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(5, registrations.Length);
|
||||
Assert.Equal(7, registrations.Length);
|
||||
Assert.Contains(registrations, static entry =>
|
||||
entry.Address == 0x80000010u && entry.ExcludesBaseTranslation);
|
||||
entry.Address == 0x80000010u && entry.ExcludesBaseTranslation &&
|
||||
entry.ModOverlay == NativeModOverlay.Unspecified);
|
||||
Assert.Contains(registrations, static entry =>
|
||||
entry.Address == 0x80000020u && !entry.ExcludesBaseTranslation);
|
||||
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);
|
||||
Assert.Contains(registrations, static entry =>
|
||||
entry.Address == 0x80000030u && entry.IsTranslatedOverride);
|
||||
Assert.Contains(registrations, static entry =>
|
||||
@@ -108,6 +119,8 @@ 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);
|
||||
|
||||
@@ -126,6 +139,26 @@ public sealed class RuntimeNativeIndexTests
|
||||
}
|
||||
}
|
||||
|
||||
// A wrapper that calls through with MKW_GUEST_FUNC would silently drop the mod overlay it promises to run.
|
||||
[Fact]
|
||||
public void RepositoryWrapperNativesCallThroughToTheNextTranslation()
|
||||
{
|
||||
var sources = Path.Combine(ProjectPathsForTests.FindRepositoryRoot(), "runtime", "src");
|
||||
var wrappers = RuntimeNativeIndexBuilder.Build(sources).Registrations
|
||||
.Where(static entry => entry.ModOverlay == NativeModOverlay.Wrapper)
|
||||
.ToArray();
|
||||
Assert.NotEmpty(wrappers);
|
||||
foreach (var wrapper in wrappers)
|
||||
{
|
||||
var source = File.ReadAllText(Path.Combine(sources, wrapper.SourceFile));
|
||||
var pal = $"{wrapper.Address:X8}";
|
||||
Assert.True(Regex.IsMatch(source, $@"MKW_GUEST_NEXT\(\s*{pal}\s*\)", RegexOptions.IgnoreCase),
|
||||
$"{wrapper.Symbol} is a wrapper but never calls MKW_GUEST_NEXT({pal})");
|
||||
Assert.False(Regex.IsMatch(source, $@"MKW_GUEST_FUNC\(\s*{pal}\s*\)", RegexOptions.IgnoreCase),
|
||||
$"{wrapper.Symbol} is a wrapper but calls MKW_GUEST_FUNC({pal})");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveUsesAMatchingIndexAndKeepsWorkingWithoutSources()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user