Compare commits

..

17 Commits

Author SHA1 Message Date
patchzyy 37a0b68d85 keep steam desktop layout off while focused 2026-10-29 18:21:00 +01:00
FireDragon b748155cdb Update README.md to address NTSC-U and NTSC-J support added in v0.9.0 (#375)
* Update README.md

Added NTSC-U and NTSC-J to compatible ISOs. Changed "other regions" to NTSC-K

* Update README.md

Fixed typo and installation.

* Update README.md

Added NTSC-U and NTSC-J to line 130.

* Fixed "and" to "or" and line 130.
2026-10-29 16:08:09 +01:00
patchzyy eb99d8652b Fix wired PDP Afterglow Wave not being detected (#379)
* Fix wired PDP Afterglow Wave not being detected (#377)

Bumps aurora so the vendored SDL treats 0e6f:018b as input-only over USB
(patchzyy/aurora-wiicompiled#8).

* Point aurora at the merged PDP Afterglow Wave fix
2026-10-29 16:06:58 +01:00
patchzyy ee8443423d Add shake to trick (#378)
* add shake to trick

* address review

* add motion trick tests to ci

* reject bad sensor data under fast math

* harden bad data test

* widen motion trick test coverage
2026-10-29 14:41:06 +01:00
patchzyy 3645c4af1f Mask GX matrix indices to six bits (#376) 2026-10-29 11:17:53 +01:00
patchzyy ae54b6e082 skip escape exit prompt with valve controllers (#372) 2026-10-27 20:32:53 +01:00
Gitez af5a45e270 [Feature] Add aspect ratio selector for non-widescreen monitors (#339)
* Added aspect ratio selector

* Dirty 4:3 fix

* Fixed dynamic aspect in 4:3

* Cleaned up aspect ratio

* Fixed force aspect ratio

* More clean up

* use widescreen key, keep force_16_9 compat

* migrate legacy force_16_9 once at startup

---------

Co-authored-by: patchzyy <64382339+patchzyy@users.noreply.github.com>
2026-10-26 19:42:28 +01:00
Begood 896dcef9e3 macOS: pass explicit CMAKE_OSX_SYSROOT to the native configure (#362)
* macOS: pass explicit CMAKE_OSX_SYSROOT to the native configure

Fixes #353

## Problem
On recent macOS versions, the CMake bundled in Setup.app fails to detect the SDK, leaving `CMAKE_OSX_SYSROOT` empty. Regular compile steps still work (clang falls back to the default SDK), but libpng's `genout.cmake` step only passes `-isysroot` when `CMAKE_OSX_SYSROOT` is set, so `pnglibconf.out` generation fails with:

```
fatal error: 'sys/types.h' file not found
```

## Fix
Resolve the SDK path explicitly and pass it to the native configure step, so the build no longer depends on the bundled CMake's SDK detection. Also fails early with a clear message if the SDK cannot be resolved.

## Testing
Confirmed on MacBook Pro M1 (macOS 27.0.1): the workaround (setting `CMAKE_OSX_SYSROOT` manually) completes the build successfully; this PR bakes the same fix into the build script.

* fix sysroot line and check sdk dir

---------

Co-authored-by: patchzyy <64382339+patchzyy@users.noreply.github.com>
2026-10-26 17:14:54 +01:00
patchzyy f02d000ba8 update to 0.9.3 2026-10-25 14:01:50 +01:00
patchzyy 814aa304bb keep audio poll time remainder (#360) 2026-10-25 13:41:53 +01:00
patchzyy 48aa27d77e bump aurora for instant resolution changes 2026-10-25 12:51:59 +01:00
patchzyy 27e9be32ed let wrapper natives run mod overlays (#359) 2026-10-25 12:47:16 +01:00
patchzyy cc6abd49ab faster deterministic translation (#358) 2026-10-25 10:30:08 +01:00
patchzyy bb5e2b1166 Fix prebuilt aurora package being rejected (#357)
* fix prebuilt package rejected on user machines

* Time out a hung translator step

The translator can hang after a crash instead of exiting, and the
installer's clean retry runs only on a failed exit. Each translator
step now gets 1800 s; past that its process tree is killed and the
step fails, so the retry runs.

* time out mod awareness check too

---------

Co-authored-by: therenow9 <60898774+therenow9@users.noreply.github.com>
2026-10-25 09:47:03 +01:00
patchzyy 4993b2226c update to 0.9.2 2026-10-24 17:45:02 +02:00
patchzyy e15be3bf55 Fix online sha 2026-10-24 17:31:12 +02:00
patchzyy 85992f85b0 add --headless for boot races (#351)
* add headless race option

* bump aurora
2026-10-24 16:51:34 +02:00
46 changed files with 2556 additions and 641 deletions
+1
View File
@@ -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 \
+18
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2 -1
View File
@@ -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"
+6 -7
View File
@@ -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?**
+9
View File
@@ -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"
+7 -1
View File
@@ -157,7 +157,7 @@ public:
#define MKW_DETAIL_CAT(a, b) a##b
#define MKW_DETAIL_MAKE_UNIQUE(a, b) MKW_DETAIL_CAT(a, b)
// 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)
+3
View File
@@ -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;
};
+5 -3
View File
@@ -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() {
+1
View File
@@ -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
+12
View File
@@ -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; \
}())
+36
View File
@@ -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
+43
View File
@@ -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
+82 -8
View File
@@ -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);
}
+17
View File
@@ -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
View File
@@ -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;
+7 -1
View File
@@ -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;
+34 -54
View File
@@ -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);
}
+11 -10
View File
@@ -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
+7 -3
View File
@@ -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) {
+20 -16
View File
@@ -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
View File
@@ -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) {
+1 -1
View File
@@ -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, (), ());
+1 -1
View File
@@ -436,7 +436,7 @@ extern "C" void NAND_IOS_OpenBody_HLE_801938FC(CpuContext* ctx) {
ctx->gpr[3] = static_cast<uint32_t>(result);
ctx->gpr[1] = ctx->gpr[1] + 32u;
}
REGISTER_NATIVE_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
View File
@@ -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);
+227
View File
@@ -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
+80
View File
@@ -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
+95 -6
View File
@@ -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();
+603
View File
@@ -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
+247
View File
@@ -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;
}
+39
View File
@@ -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;
+134 -106
View File
@@ -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()
{