mirror of
https://github.com/patchzyy/wiicompiled
synced 2026-09-26 07:01:08 -04:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 987aef0b6a | |||
| c92e45c2e7 | |||
| 583e702547 | |||
| b59e035b87 | |||
| 6f14bde26a | |||
| 83463764b8 | |||
| 8008d885ad | |||
| 7e6604c415 | |||
| 6fb593749e | |||
| 8ec3a1b752 | |||
| 8705e957c7 | |||
| 8e0cc96898 | |||
| 6458ec6abe | |||
| 209405dfb7 | |||
| 4bdaff01fc | |||
| b555ede2d3 | |||
| 53d8f71c68 | |||
| 149cfef608 | |||
| 25c69ae28e | |||
| 0bb15f0a44 | |||
| 466d06d7db | |||
| 8769cf6dea | |||
| 452b478bb3 | |||
| 407f8a7190 | |||
| c2289e4ba4 | |||
| a135beb201 |
@@ -3,3 +3,4 @@
|
||||
|
||||
# Patch files must stay LF: git apply matches context bytes against LF upstream sources
|
||||
*.patch -text
|
||||
translator/tests/Translator.Tests/TestAssets/**/*.bin binary
|
||||
|
||||
@@ -30,6 +30,14 @@ jobs:
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: ${{ runner.os }}-nuget-${{ hashFiles('translator/Translator.sln', '**/*.csproj', '**/*.props', '**/*.targets', '**/packages.lock.json', 'global.json', 'NuGet.config', 'nuget.config') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-nuget-
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore translator/Translator.sln
|
||||
|
||||
|
||||
@@ -28,6 +28,19 @@ jobs:
|
||||
path: Launcher/artifacts/downloads
|
||||
key: windows-recomp-downloads-${{ hashFiles('Launcher/Prepare-PortableTools.ps1', 'Launcher/Prepare-Dependencies.ps1') }}
|
||||
|
||||
# Install sccache.
|
||||
- name: Run sccache-action
|
||||
uses: mozilla/sccache-action@v0.0.11
|
||||
|
||||
# Tell CMake to use sccache and use GitHub's API.
|
||||
- name: Configure sccache environment
|
||||
shell: pwsh
|
||||
run: |
|
||||
"SCCACHE_GHA_ENABLED=true" | Add-Content -Path $env:GITHUB_ENV
|
||||
"ACTIONS_CACHE_SERVICE_V2=on" | Add-Content -Path $env:GITHUB_ENV
|
||||
"CMAKE_C_COMPILER_LAUNCHER=$env:SCCACHE_PATH" | Add-Content -Path $env:GITHUB_ENV
|
||||
"CMAKE_CXX_COMPILER_LAUNCHER=$env:SCCACHE_PATH" | Add-Content -Path $env:GITHUB_ENV
|
||||
|
||||
- name: Prepare the shipped Windows toolchain
|
||||
shell: pwsh
|
||||
run: ./Launcher/Prepare-PortableTools.ps1
|
||||
@@ -36,6 +49,13 @@ jobs:
|
||||
shell: pwsh
|
||||
run: ./Launcher/Prepare-Dependencies.ps1
|
||||
|
||||
# Expose cache token context to the build script.
|
||||
- name: Translate, compile the full runtime, and link
|
||||
shell: pwsh
|
||||
run: ./Launcher/Test-Recompilation.ps1 -Parallel 3
|
||||
run: ./Launcher/Test-Recompilation.ps1 -Parallel 4
|
||||
|
||||
# Print cache results (even if the build fails)
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: sccache --show-stats
|
||||
|
||||
@@ -72,3 +72,5 @@ project.lock.json
|
||||
*.log
|
||||
output.txt
|
||||
|
||||
# Operating System
|
||||
.DS_Store
|
||||
|
||||
@@ -281,7 +281,7 @@ foreach ($required in @('ToolkitFingerprint','TranslationFingerprint','NativeToo
|
||||
|
||||
$manifest = [ordered]@{
|
||||
SchemaVersion = 2
|
||||
ProductVersion = '0.2.31'
|
||||
ProductVersion = '0.2.32'
|
||||
ExpectedGameId = $pins.GameId
|
||||
ExpectedDolSha256 = $pins.DolSha256
|
||||
ExpectedRelSha256 = $pins.RelSha256
|
||||
|
||||
@@ -117,12 +117,13 @@ function Get-MkwProjectPins([string]$ProjectFile) {
|
||||
}
|
||||
|
||||
function Invoke-Checked([string]$FilePath, [string[]]$Arguments, [string]$Description,
|
||||
[string]$LogPrefix = 'MKWCBUILD', [string]$StepId = '') {
|
||||
[string]$LogPrefix = 'MKWCBUILD', [string]$StepId = '', [bool]$WaitForProcessTree = $true) {
|
||||
<#
|
||||
Runs a build tool and turns a non-zero exit code into a described failure. Start-Process -Wait
|
||||
is deliberate: it waits for the whole process tree, since a .NET single-file bundle host may
|
||||
hand off to an extracted child that PowerShell's call operator would not wait for. Start-Process
|
||||
doesn't publish $LASTEXITCODE, so this sets it manually for callers that check it.
|
||||
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
|
||||
hand off to an extracted child that PowerShell's call operator would not wait for. Callers that
|
||||
need to avoid waiting on unrelated descendants can opt into the call-operator path.
|
||||
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.
|
||||
#>
|
||||
@@ -132,9 +133,14 @@ function Invoke-Checked([string]$FilePath, [string[]]$Arguments, [string]$Descri
|
||||
if ($_.Contains('"')) { throw "A native build argument contains an unsupported quote: $_" }
|
||||
'"' + $_ + '"'
|
||||
})
|
||||
$process = Start-Process -FilePath $FilePath -ArgumentList $quotedArguments `
|
||||
-NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.ExitCode
|
||||
if ($WaitForProcessTree) {
|
||||
$process = Start-Process -FilePath $FilePath -ArgumentList $quotedArguments `
|
||||
-NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.ExitCode
|
||||
} else {
|
||||
& $FilePath @Arguments
|
||||
$exitCode = $LASTEXITCODE
|
||||
}
|
||||
$global:LASTEXITCODE = $exitCode
|
||||
if ($exitCode -ne 0) { throw "$Description failed with exit code $exitCode." }
|
||||
}
|
||||
|
||||
@@ -39,9 +39,9 @@ $packages = @(
|
||||
},
|
||||
[pscustomobject]@{
|
||||
Name = 'dawn_prebuilt'; File = 'dawn-v20260603.191052-windows-amd64.tar.gz'
|
||||
Uris = @('https://github.com/encounter/dawn-build/releases/download/v20260603.191052/dawn-windows-amd64.tar.gz')
|
||||
Uris = @('https://github.com/theofficialgman/dawn-build/releases/download/v20260603.191052/dawn-windows-amd64.tar.gz')
|
||||
Pins = @(@{ File = $auroraCMake; Text = 'set(AURORA_DAWN_VERSION "v20260603.191052"' },
|
||||
@{ File = $auroraDawn; Text = 'SHA256=7785373d569b3b0237918ec9c523239f7d0667857c5ea8242e3cdfde95e6aeab' })
|
||||
@{ File = $auroraDawn; Text = 'SHA256=13be9cff8b9b179c42dcd16aeabb6effcc8f0dfdcc14463eda2a5caeda225142' })
|
||||
},
|
||||
[pscustomobject]@{
|
||||
Name = 'fmt'; File = 'fmt-11.1.4.tar.gz'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Fails the release build when a fact duplicated across the repo stops agreeing with the copy
|
||||
# that owns it (recomp.yml). Scripts read pinned facts through Get-MkwProjectPins, but three
|
||||
# consumers can't read YAML (the C++ runtime header, the C# constants, hand-written lists on
|
||||
# consumers can't read YAML (the C++ runtime header, the C# constants, shell scripts, and hand-written lists on
|
||||
# both sides of the C#/PowerShell boundary), so those are checked here instead.
|
||||
[CmdletBinding()]
|
||||
param([string]$RepositoryRoot)
|
||||
@@ -58,6 +58,12 @@ $hostUri = Get-CapturedValue $retroWfcPayload 'CurrentRetroWfcPayloadUri\s*=\s*"
|
||||
if ($hostUri -cne $pins.RetroWfcPayloadUri) {
|
||||
Add-Failure "InputValidation.CurrentRetroWfcPayloadUri is '$hostUri' but recomp.yml pins '$($pins.RetroWfcPayloadUri)'."
|
||||
}
|
||||
$macosSetup = Read-SourceFile (Join-Path $launcher 'macos\setup.command') 'macOS setup.command'
|
||||
$macosUri = Get-CapturedValue $macosSetup "'([^']*/api/wfc/payload\?g=RMCPD00)'" `
|
||||
'The macOS Retro-WFC endpoint'
|
||||
if ($macosUri -cne $pins.RetroWfcPayloadUri) {
|
||||
Add-Failure "macOS setup.command downloads '$macosUri' but recomp.yml pins '$($pins.RetroWfcPayloadUri)'."
|
||||
}
|
||||
|
||||
# --- The game identity: the manifest carries it, but the host also compiles a fallback for a
|
||||
# --- manifest that predates the field, and that fallback decides which disc is accepted.
|
||||
|
||||
@@ -136,9 +136,11 @@ try {
|
||||
-CxxCompiler (Join-Path $compilerBin 'x86_64-w64-mingw32-clang++.exe') `
|
||||
-ResourceCompiler (Join-Path $compilerBin 'x86_64-w64-mingw32-windres.exe') `
|
||||
-DependenciesDirectory $dependencies -AdditionalArguments @('-DMKW_BUILD_PRODUCTS=ON')
|
||||
Invoke-Checked $cmake $configure 'Configuring the production Windows runtime'
|
||||
Invoke-Checked $cmake $configure 'Configuring the production Windows runtime' `
|
||||
-WaitForProcessTree $false
|
||||
Invoke-Checked $cmake @('--build', $nativeBuild, '--target', 'WiiCompiled', '--parallel', "$Parallel") `
|
||||
'Compiling and linking the synthetic product with the full runtime'
|
||||
'Compiling and linking the synthetic product with the full runtime' `
|
||||
-WaitForProcessTree $false
|
||||
Assert-File (Join-Path $nativeBuild 'WiiCompiled.exe') 'Linked synthetic product'
|
||||
} finally {
|
||||
$env:PATH = $oldPath
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>WiiCompiled.Setup.Common.Cli</RootNamespace>
|
||||
<AssemblyName>WiiCompiled.Setup.Common.Cli</AssemblyName>
|
||||
<Version>0.2.31</Version>
|
||||
<Version>0.2.32</Version>
|
||||
<Authors>patchzy</Authors>
|
||||
<Product>WiiCompiled</Product>
|
||||
<Description>Packaging-time helper: resolves (downloading if needed) the nodtool binary bundled by build-appimage.sh and Build-Installer.ps1</Description>
|
||||
|
||||
@@ -24,7 +24,7 @@ public static class RetroWfcPayload
|
||||
private static readonly TimeSpan RetroWfcDownloadTimeout = TimeSpan.FromSeconds(30);
|
||||
private static readonly TimeSpan RetroWfcRetryDelay = TimeSpan.FromSeconds(1);
|
||||
|
||||
public const string CurrentRetroWfcPayloadUri = "http://nas.play.rwfc.net/payload?g=RMCPD00";
|
||||
public const string CurrentRetroWfcPayloadUri = "https://rwfc.net/api/wfc/payload?g=RMCPD00";
|
||||
private static readonly string RetroWfcOfflinePayloadFile =
|
||||
Path.Combine("binary", "payload.RMCPD00.bin");
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>WiiCompiled.Setup.Common</RootNamespace>
|
||||
<AssemblyName>WiiCompiled.Setup.Common</AssemblyName>
|
||||
<Version>0.2.31</Version>
|
||||
<Version>0.2.32</Version>
|
||||
<Authors>patchzy</Authors>
|
||||
<Product>WiiCompiled</Product>
|
||||
<Description>Shared nodtool/Retro-WFC-payload logic used by both the Windows and Linux installers</Description>
|
||||
|
||||
@@ -13,7 +13,8 @@ internal static class BuildRunner
|
||||
string workspace, string profile, string outputDir, string? baseOutputDir,
|
||||
string? retroDir, string? retroWfcOfflineDir, bool skipRetroWfcPayload,
|
||||
bool forceCleanBuild, string? translatorBin, string? ccBin, string? cxxBin, string? fuseLd,
|
||||
string? cmakeBin, string? ninjaBin, string? nativePrebuiltDir, IInstallReporter reporter,
|
||||
string? cmakeBin, string? ninjaBin, string? nativePrebuiltDir, string? sysroot,
|
||||
IInstallReporter reporter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var script = Path.Combine(workspace, "Launcher", "local-build.sh");
|
||||
@@ -77,6 +78,10 @@ internal static class BuildRunner
|
||||
{
|
||||
startInfo.ArgumentList.Add("--native-prebuilt-dir"); startInfo.ArgumentList.Add(nativePrebuiltDir);
|
||||
}
|
||||
if (!string.IsNullOrEmpty(sysroot))
|
||||
{
|
||||
startInfo.ArgumentList.Add("--sysroot"); startInfo.ArgumentList.Add(sysroot);
|
||||
}
|
||||
|
||||
using var process = new Process { StartInfo = startInfo };
|
||||
var window = new BuildProgressWindow(reporter, InstallStages.Build, start: 6, end: 96);
|
||||
|
||||
@@ -3,7 +3,7 @@ namespace WiiCompiled.Setup.Linux;
|
||||
internal static class ProductInfo
|
||||
{
|
||||
public const string Name = "WiiCompiled";
|
||||
public const string Version = "0.2.31";
|
||||
public const string Version = "0.2.32";
|
||||
}
|
||||
|
||||
/// <summary>One installed product's record inside install-state.json.</summary>
|
||||
|
||||
@@ -143,6 +143,15 @@ internal static class Program
|
||||
retroWfcOfflineDir = cacheDir;
|
||||
}
|
||||
|
||||
var sysroot = flags.GetValueOrDefault("sysroot");
|
||||
// --sysroot explicitly provided (even as bare flag at end of argv, which ParseArgs
|
||||
// stores as null) must carry a path; omitting --sysroot entirely is fine (local-build.sh
|
||||
// adds -UCMAKE_SYSROOT to clear any stale cached value from a prior configure).
|
||||
if (flags.ContainsKey("sysroot") && string.IsNullOrWhiteSpace(sysroot))
|
||||
{
|
||||
throw new ArgumentException("--sysroot requires a non-empty directory path.");
|
||||
}
|
||||
|
||||
await BuildRunner.RunAsync(
|
||||
workspace, profile, installDir, baseInstallDir,
|
||||
retroDir,
|
||||
@@ -156,6 +165,7 @@ internal static class Program
|
||||
flags.GetValueOrDefault("cmake"),
|
||||
flags.GetValueOrDefault("ninja"),
|
||||
flags.GetValueOrDefault("native-prebuilt-dir"),
|
||||
sysroot,
|
||||
reporter, token);
|
||||
|
||||
reporter.Progress(InstallStages.Shortcuts, "Creating shortcuts", 98);
|
||||
@@ -324,7 +334,7 @@ internal static class Program
|
||||
{--download-retro-wfc-payload | --skip-retro-wfc-payload}]
|
||||
[--force-clean-build] [--translator-bin PATH] [--disc-tool-bin PATH]
|
||||
[--cc PATH] [--cxx PATH] [--fuse-ld NAME_OR_PATH] [--cmake PATH] [--ninja PATH]
|
||||
[--native-prebuilt-dir DIR] [--progress-json] [--workspace DIR]
|
||||
[--native-prebuilt-dir DIR] [--sysroot PATH] [--progress-json] [--workspace DIR]
|
||||
uninstall
|
||||
launch-base
|
||||
launch-retro
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>WiiCompiled.Setup.Linux</AssemblyName>
|
||||
<RootNamespace>WiiCompiled.Setup.Linux</RootNamespace>
|
||||
<Version>0.2.31</Version>
|
||||
<Version>0.2.32</Version>
|
||||
<Authors>patchzy</Authors>
|
||||
<Product>WiiCompiled</Product>
|
||||
<Description>Command-line installer and launcher for WiiCompiled on Linux</Description>
|
||||
|
||||
@@ -121,7 +121,7 @@ internal static class PlatformChecks
|
||||
internal static class ProductInfo
|
||||
{
|
||||
public const string Name = "WiiCompiled";
|
||||
public const string Version = "0.2.31";
|
||||
public const string Version = "0.2.32";
|
||||
|
||||
/// <summary>
|
||||
/// The setup executable is copied into the installation under this name. It is the launcher and
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<AssemblyName>WiiCompiled.Setup</AssemblyName>
|
||||
<RootNamespace>WiiCompiled.Setup.Windows</RootNamespace>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<Version>0.2.31</Version>
|
||||
<Version>0.2.32</Version>
|
||||
<Authors>patchzy</Authors>
|
||||
<Product>WiiCompiled</Product>
|
||||
<Description>Command-line installer and launcher for WiiCompiled</Description>
|
||||
|
||||
+39
-1
@@ -65,6 +65,7 @@ translator_dll_override=""
|
||||
translator_bin_override=""
|
||||
fuse_ld_override=""
|
||||
native_prebuilt_dir=""
|
||||
sysroot=""
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
@@ -87,6 +88,8 @@ Usage: local-build.sh --output-dir DIR [options]
|
||||
--translator-bin PATH Self-contained Translator.Cli executable (skips building AND needs no dotnet at all)
|
||||
--native-prebuilt-dir DIR Precompiled aurora/third-party package (see Prepare-NativePrebuilt.sh);
|
||||
skips compiling aurora-main from source entirely
|
||||
--sysroot PATH Passed to CMake as -DCMAKE_SYSROOT: where the compiler resolves
|
||||
standard headers/startup files
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -110,6 +113,7 @@ while [[ $# -gt 0 ]]; do
|
||||
--translator-dll) translator_dll_override=$2; shift 2 ;;
|
||||
--translator-bin) translator_bin_override=$2; shift 2 ;;
|
||||
--native-prebuilt-dir) native_prebuilt_dir=$2; shift 2 ;;
|
||||
--sysroot) sysroot=$2; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) fail "unknown argument: $1" ;;
|
||||
esac
|
||||
@@ -186,6 +190,30 @@ assert_file "$project" "Translation project"
|
||||
assert_file "$assets/main.dol" "Extracted main.dol (see translator/README.md - owning the game is required)"
|
||||
assert_file "$assets/StaticR.rel" "Extracted StaticR.rel (see translator/README.md - owning the game is required)"
|
||||
|
||||
# The AppImage bundles Clang, but Linux startup objects and the C/C++ link runtimes
|
||||
# still come from the host. Check them before the expensive translation so a missing
|
||||
# development package produces a useful error instead of CMake's generic exit 1.
|
||||
link_probe_dir=$(mktemp -d)
|
||||
link_probe_flags=()
|
||||
[[ -z "$sysroot" ]] || link_probe_flags+=(--sysroot="$sysroot")
|
||||
[[ -z "$fuse_ld_override" ]] || link_probe_flags+=(-fuse-ld="$fuse_ld_override")
|
||||
printf 'int main(void) { return 0; }\n' > "$link_probe_dir/probe.c"
|
||||
cat > "$link_probe_dir/probe.cpp" <<'EOF'
|
||||
#include <vector>
|
||||
int main() { std::vector<int> values{1}; return values.front() - 1; }
|
||||
EOF
|
||||
if ! "$cc_bin" "${link_probe_flags[@]}" "$link_probe_dir/probe.c" -o "$link_probe_dir/probe-c" > "$link_probe_dir/error" 2>&1; then
|
||||
cat "$link_probe_dir/error" >&2
|
||||
rm -rf "$link_probe_dir"
|
||||
fail "The C compiler cannot link a test program. Linux needs C development files (glibc startup objects and a compiler runtime) in addition to bundled Clang. Install your distribution's development packages, or on SteamOS run WiiCompiled through the Wheel Wizard Flatpak."
|
||||
fi
|
||||
if ! "$cxx_bin" "${link_probe_flags[@]}" "$link_probe_dir/probe.cpp" -o "$link_probe_dir/probe-cxx" > "$link_probe_dir/error" 2>&1; then
|
||||
cat "$link_probe_dir/error" >&2
|
||||
rm -rf "$link_probe_dir"
|
||||
fail "The C++ compiler cannot link a test program. Install your distribution's C++ development packages, or on SteamOS run WiiCompiled through the Wheel Wizard Flatpak."
|
||||
fi
|
||||
rm -rf "$link_probe_dir"
|
||||
|
||||
# Literal line matching against the manifest's fixed shape, not a YAML dependency - the same
|
||||
# approach NativeBuildFlags.ps1's Get-MkwProjectPins uses on Windows, kept here only for the one
|
||||
# field this script actually needs from the manifest.
|
||||
@@ -414,6 +442,14 @@ fi
|
||||
if [[ -n "$native_prebuilt_dir" ]]; then
|
||||
configure_args+=(-DMKW_NATIVE_PREBUILT_DIR="$native_prebuilt_dir")
|
||||
fi
|
||||
if [[ -n "$sysroot" ]]; then
|
||||
configure_args+=(-DCMAKE_SYSROOT="$sysroot")
|
||||
else
|
||||
# Explicitly clear any cached CMAKE_SYSROOT from a prior configure so an
|
||||
# incremental build that transitions from one sysroot to none does not
|
||||
# silently keep the stale cached path.
|
||||
configure_args+=(-UCMAKE_SYSROOT)
|
||||
fi
|
||||
|
||||
log_step configure-native "Configuring the native toolchain"
|
||||
"$cmake_bin" "${configure_args[@]}"
|
||||
@@ -445,7 +481,9 @@ publish_built_product() {
|
||||
local exe=$build/$target
|
||||
assert_file "$exe" "Locally compiled game executable"
|
||||
cp -f "$exe" "$destination/$target"
|
||||
for name in dsp_coef.bin initial_pipeline_cache.db; do
|
||||
# cacert.pem is the TLS root bundle the mbed TLS path looks up beside the executable
|
||||
# (runtime/src/hle/net/network_ssl.cpp); without it HTTPS fails at runtime.
|
||||
for name in dsp_coef.bin initial_pipeline_cache.db cacert.pem; do
|
||||
[[ -f "$build/$name" ]] && cp -f "$build/$name" "$destination/"
|
||||
done
|
||||
[[ -d "$build/wii_bootstrap" ]] && cp -rf "$build/wii_bootstrap" "$destination/"
|
||||
|
||||
@@ -27,7 +27,7 @@ done
|
||||
[[ "$product" == WiiCompiled || "$product" == RetroRewind ]] || fail '--product must be WiiCompiled or RetroRewind'
|
||||
for tool in codesign ditto install_name_tool otool; do command -v "$tool" >/dev/null || fail "required macOS tool is unavailable: $tool"; done
|
||||
[[ -x "$build_dir/$product" ]] || fail "missing compiled product: $build_dir/$product"
|
||||
for asset in dsp_coef.bin initial_pipeline_cache.db wii_bootstrap; do [[ -e "$build_dir/$asset" ]] || fail "missing runtime asset: $build_dir/$asset"; done
|
||||
for asset in dsp_coef.bin initial_pipeline_cache.db cacert.pem wii_bootstrap; do [[ -e "$build_dir/$asset" ]] || fail "missing runtime asset: $build_dir/$asset"; done
|
||||
|
||||
app="$output_dir/$product.app"
|
||||
macos="$app/Contents/MacOS"
|
||||
@@ -52,7 +52,7 @@ cat > "$app/Contents/Info.plist" <<EOF
|
||||
</dict></plist>
|
||||
EOF
|
||||
ditto "$build_dir/$product" "$macos/$product"
|
||||
for asset in dsp_coef.bin initial_pipeline_cache.db wii_bootstrap; do
|
||||
for asset in dsp_coef.bin initial_pipeline_cache.db cacert.pem wii_bootstrap; do
|
||||
ditto "$build_dir/$asset" "$resources/$asset"
|
||||
ln -s "../Resources/$asset" "$macos/$asset"
|
||||
done
|
||||
|
||||
@@ -90,7 +90,7 @@ if [[ -n "$retro_dir" ]]; then
|
||||
trap 'rm -rf "$payload_stage"' EXIT
|
||||
/usr/bin/curl --fail --silent --show-error --connect-timeout 10 --max-time 30 \
|
||||
--retry 1 --output "$temporary_payload" \
|
||||
'http://nas.play.rwfc.net/payload?g=RMCPD00' || fail 'could not download the Retro-WFC payload needed for online play'
|
||||
'https://rwfc.net/api/wfc/payload?g=RMCPD00' || fail 'could not download the Retro-WFC payload needed for online play'
|
||||
"$translator" validate-retro-wfc-payload --directory "$payload_stage" || \
|
||||
fail 'downloaded Retro-WFC payload failed signature validation'
|
||||
mkdir -p "$retro_wfc_dir/binary"
|
||||
|
||||
@@ -52,13 +52,13 @@ done
|
||||
|
||||
case "$arch" in
|
||||
x86_64) llvm_release_arch=X64; target_triple=x86_64-unknown-linux-gnu
|
||||
llvm_release_sha256=df0e1ecf16caf3489a272a5eea4eec9b0d82878f6477fa309504f918a0006384
|
||||
llvm_release_sha256=fccecb1906e7ddf5ec040aec5b646b650e2daaafa4423b41341c4717db5bdec0
|
||||
cmake_release_arch=x86_64
|
||||
cmake_sha256=927b2368a946c37269c3a66225ab00544e756459cdd0b5d0da438694fb9ff802
|
||||
ninja_asset=ninja-linux.zip
|
||||
ninja_sha256=5749cbc4e668273514150a80e387a957f933c6ed3f5f11e03fb30955e2bbead6 ;;
|
||||
aarch64) llvm_release_arch=ARM64; target_triple=aarch64-unknown-linux-gnu
|
||||
llvm_release_sha256=805efad2bb91cb4967fa569e0881d10c0f69c04461cf671cccbae19f547acc34
|
||||
llvm_release_sha256=d431eff9f064c86ee7c4c94af570a8f74fcccd1f74c6f0da3af32ce34a1e1b05
|
||||
cmake_release_arch=aarch64
|
||||
cmake_sha256=9ea38356dbd3e32e51029a3e09a0f2f8e117ef4fbcaad7a21ffb36409bbd5cb4
|
||||
ninja_asset=ninja-linux-aarch64.zip
|
||||
@@ -101,11 +101,14 @@ rm -rf "$work"
|
||||
mkdir -p "$work/bin" "$work/lib/$target_triple" "$work/include/$target_triple/c++/v1"
|
||||
|
||||
# --- clang/lld/llvm-ar, pruned from the official LLVM release ---
|
||||
|
||||
llvm_archive_name="LLVM-$llvm_version-Linux-$llvm_release_arch.tar.xz"
|
||||
# built from PR https://github.com/llvm/llvm-project/pull/222821 on official LLVM Github Actions Runner
|
||||
# only switch to an official stable LLVM release again once:
|
||||
# - this PR has merged https://github.com/llvm/llvm-project/pull/221365 and been backported to LLVM stable branch
|
||||
# - this bug has been fixed with a workaround in the Wiicompiled translator https://github.com/patchzyy/Wiicompiled/issues/208 or in LLVM and been backported to LLVM stable branch
|
||||
llvm_archive_name="LLVM-PR222821-5ae1c7c43a11b4cdc5ce4dd483c28357bab7dae2-Linux-$llvm_release_arch.tar.xz"
|
||||
llvm_archive="$downloads/$llvm_archive_name"
|
||||
download_verified "$llvm_archive" \
|
||||
"https://github.com/llvm/llvm-project/releases/download/llvmorg-$llvm_version/$llvm_archive_name" \
|
||||
"https://github.com/theofficialgman/llvm-project/releases/download/llvmorg-22.1.8-patched/$llvm_archive_name" \
|
||||
"$llvm_release_sha256"
|
||||
|
||||
extract_root="$script_dir/artifacts/.extract-clang-$arch"
|
||||
@@ -113,7 +116,7 @@ rm -rf "$extract_root"
|
||||
mkdir -p "$extract_root"
|
||||
echo "prepare-portable-tools.sh: extracting $llvm_archive_name (this is the full ~1.9 GiB release; only a fraction is kept)..."
|
||||
tar -xf "$llvm_archive" -C "$extract_root"
|
||||
src="$extract_root/LLVM-$llvm_version-Linux-$llvm_release_arch"
|
||||
src="$extract_root/${llvm_archive_name%.tar.xz}"
|
||||
[[ -d "$src" ]] || { echo "prepare-portable-tools.sh: unexpected archive layout, expected $src" >&2; exit 1; }
|
||||
|
||||
echo "prepare-portable-tools.sh: pruning to the minimal compile+link toolchain..."
|
||||
@@ -165,7 +168,8 @@ rm -rf "$cmake_extract_root"
|
||||
mkdir -p "$cmake_extract_root"
|
||||
echo "prepare-portable-tools.sh: extracting $cmake_archive_name..."
|
||||
tar -xzf "$cmake_archive" -C "$cmake_extract_root"
|
||||
cmake_src="$cmake_extract_root/cmake-$cmake_version-linux-$cmake_release_arch"
|
||||
|
||||
cmake_src="$cmake_extract_root/${cmake_archive_name%.tar.gz}"
|
||||
[[ -d "$cmake_src" ]] || { echo "prepare-portable-tools.sh: unexpected archive layout, expected $cmake_src" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$work/share/cmake-$cmake_share_version"
|
||||
@@ -233,5 +237,6 @@ EOF
|
||||
rm -rf "$test_dir"
|
||||
trap - EXIT
|
||||
|
||||
rm -rf "$toolchain_dir"
|
||||
mv "$work" "$toolchain_dir"
|
||||
echo "prepare-portable-tools.sh: toolchain ready at $toolchain_dir ($(du -sh "$toolchain_dir" | cut -f1))"
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
<img width="4190" height="1232" alt="wiicomplogofinalfinalfinalev2MADEBY_INKWRECK_plzcredit" src="https://github.com/user-attachments/assets/df7a3f2e-5336-479a-b4c0-968dd578726d" />
|
||||
|
||||
# WiiCompiled
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/patchzyy/Wiicompiled/releases"><img alt="Windows 10 / 11, x64" src="https://img.shields.io/badge/Windows-10%20%2F%2011%20%C2%B7%20x64-0078D4"></a>
|
||||
<a href="https://github.com/patchzyy/Wiicompiled/releases"><img alt="Linux, x64 / ARM64" src="https://img.shields.io/badge/Linux-x64%20%2F%20ARM64-FCC624?logo=linux&logoColor=white"></a>
|
||||
<a href="https://github.com/patchzyy/Wiicompiled/releases"><img alt="macOS 14+, Apple Silicon" src="https://img.shields.io/badge/macOS-14%2B%20%C2%B7%20Apple%20Silicon-0A84FF?logo=apple&logoColor=white"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="#building-from-source"><img alt="PowerPC static recompilation" src="https://img.shields.io/badge/PowerPC-static%20recompilation-FF9F0A"></a>
|
||||
<a href="#retro-rewind"><img alt="Retro Rewind supported" src="https://img.shields.io/badge/Retro%20Rewind-supported-FF375F"></a>
|
||||
<a href="https://github.com/TeamWheelWizard/WheelWizard/releases"><img alt="Install with Wheel Wizard" src="https://img.shields.io/badge/install%20with-Wheel%20Wizard-8B5CF6"></a>
|
||||
<a href="LICENSE"><img alt="License: GPLv3" src="https://img.shields.io/badge/license-GPLv3-2EA44F?logo=gnu&logoColor=white"></a>
|
||||
</p>
|
||||
|
||||
A native PC port of Mario Kart Wii, made with static recompilation.
|
||||
|
||||
There's no emulator in the loop, no interpreter, no JIT, no PowerPC
|
||||
@@ -53,12 +66,6 @@ Press **F10** while the game window has focus:
|
||||
|
||||
Everything you change is saved to `Config.toml` on the spot and restored next launch.
|
||||
|
||||
**Real controller support.**
|
||||
Controllers are fed to the game as a GameCube controller.
|
||||
Mappings are positional (`south`, `east`, `west`, `north`) rather than Xbox-labelled, so the
|
||||
same config makes sense on Xbox, PlayStation, Nintendo and generic SDL pads alike, and extra
|
||||
inputs like paddles, touchpads and share buttons show up when the hardware reports them.
|
||||
|
||||
**Dolphin-compatible input expressions.**
|
||||
Each GameCube control can carry an expression in Dolphin's input syntax, with the same operators
|
||||
and the same functions.
|
||||
@@ -150,7 +157,9 @@ The default test suite needs no binaries and no host C++ compiler, so you can ha
|
||||
translator without any game data around.
|
||||
|
||||
For everything beyond that, feeding in your own `main.dol`/`StaticR.rel`, running the
|
||||
translation, generating the manifest and build graph, and compiling. see [`translator/README.md`](translator/README.md).
|
||||
translation, generating the manifest and build graph, and compiling, see [`translator/README.md`](translator/README.md).
|
||||
|
||||
For a step-by-step guide on compiling both WiiCompiled and Retro Rewind from source on macOS (Apple Silicon), see the [macOS Build Guide](docs/building-macos.md).
|
||||
|
||||
## FAQ
|
||||
|
||||
@@ -199,7 +208,7 @@ AI coding tools were used during development of this project.
|
||||
All translated output is verified against real hardware behavior and most importantly, physics accuracy is proven synced across Wii, Dolphin, and WiiCompiled (see FAQ).
|
||||
|
||||
## Credits
|
||||
|
||||
- **inkwreck** - making the logo
|
||||
- **[aurora](https://github.com/encounter/aurora)** - the GX rendering/windowing backend this
|
||||
project's whole graphics layer sits on. MIT licensed.
|
||||
- **[Dawn](https://dawn.googlesource.com/dawn)** - Google's WebGPU implementation, powering
|
||||
|
||||
@@ -114,14 +114,16 @@ Source: <https://github.com/higan-emu/libco>. Full license text:
|
||||
|
||||
## Fetched at build time and redistributed in release builds
|
||||
|
||||
These are pinned in `aurora-main/extern/CMakeLists.txt`, `aurora-main/CMakeLists.txt` and
|
||||
`aurora-main/cmake/AuroraDawnProvider.cmake`. They are not stored in this repository; the build
|
||||
downloads them, and release installers carry the resulting binaries. Their license texts are
|
||||
These are pinned in `aurora-main/extern/CMakeLists.txt`, `aurora-main/CMakeLists.txt`,
|
||||
`aurora-main/cmake/AuroraDawnProvider.cmake`, and (for Mbed TLS) `runtime/CMakeLists.txt`. They are
|
||||
not stored in this repository; the build downloads them - each fetch is pinned to an exact version
|
||||
with a checked SHA-256 - and links or redistributes the resulting binaries. Their license texts are
|
||||
included in the installer's `licenses/` folder. The Windows installer bundles the pinned source
|
||||
trees themselves (fetched by `Launcher/Prepare-Dependencies.ps1`) so end-user builds run offline.
|
||||
|
||||
| Component | Version | License | Upstream |
|
||||
| --- | --- | --- | --- |
|
||||
| Mbed TLS | 3.6.7 | Apache-2.0 / GPL-2.0-or-later | <https://github.com/Mbed-TLS/mbedtls> |
|
||||
| Dawn (WebGPU) | `v20260603.191052` prebuilt | BSD-3-Clause | <https://dawn.googlesource.com/dawn> |
|
||||
| Tint (part of Dawn) | with Dawn | BSD-3-Clause | <https://dawn.googlesource.com/dawn> |
|
||||
| DirectXShaderCompiler (`dxcompiler.dll`) | with Dawn | NCSA / University of Illinois Open Source | <https://github.com/microsoft/DirectXShaderCompiler> |
|
||||
|
||||
@@ -151,15 +151,36 @@ elseif (_aurora_dawn_provider STREQUAL "package")
|
||||
endif ()
|
||||
endif ()
|
||||
set(AURORA_DAWN_PACKAGE_URL
|
||||
"https://github.com/encounter/dawn-build/releases/download/${AURORA_DAWN_VERSION}/dawn-${_dawn_system}-${_dawn_arch}.tar.gz")
|
||||
"https://github.com/theofficialgman/dawn-build/releases/download/${AURORA_DAWN_VERSION}/dawn-${_dawn_system}-${_dawn_arch}.tar.gz")
|
||||
|
||||
# A release asset is mutable: the same tag has already served two different windows-amd64 archives,
|
||||
# and a cached extraction is never re-verified. Pin the digest for the combinations we ship.
|
||||
if (NOT AURORA_DAWN_PACKAGE_URL_HASH
|
||||
AND AURORA_DAWN_VERSION STREQUAL "v20260603.191052"
|
||||
AND _dawn_system STREQUAL "windows" AND _dawn_arch STREQUAL "amd64")
|
||||
set(AURORA_DAWN_PACKAGE_URL_HASH
|
||||
"SHA256=7785373d569b3b0237918ec9c523239f7d0667857c5ea8242e3cdfde95e6aeab")
|
||||
if (NOT AURORA_DAWN_PACKAGE_URL_HASH AND AURORA_DAWN_VERSION STREQUAL "v20260603.191052")
|
||||
if (_dawn_system STREQUAL "windows" AND _dawn_arch STREQUAL "amd64")
|
||||
set(AURORA_DAWN_PACKAGE_URL_HASH
|
||||
"SHA256=13be9cff8b9b179c42dcd16aeabb6effcc8f0dfdcc14463eda2a5caeda225142")
|
||||
elseif (_dawn_system STREQUAL "windows" AND _dawn_arch STREQUAL "arm64")
|
||||
set(AURORA_DAWN_PACKAGE_URL_HASH
|
||||
"SHA256=bf2d921110f14a1d6553f673c5597988e66c02af5587e4a1fee167937d247734")
|
||||
elseif (_dawn_system STREQUAL "linux" AND _dawn_arch STREQUAL "x86_64")
|
||||
set(AURORA_DAWN_PACKAGE_URL_HASH
|
||||
"SHA256=7adcf241bb2a24ec0c576609f2d67203e0e65db9c5a286ca2bbb6281fa644b35")
|
||||
elseif (_dawn_system STREQUAL "linux" AND _dawn_arch STREQUAL "aarch64")
|
||||
set(AURORA_DAWN_PACKAGE_URL_HASH
|
||||
"SHA256=2415e253d46f91b2d72fc73bf6055fb31b98b67c773dc546e1991b1cf019732f")
|
||||
elseif (_dawn_system STREQUAL "darwin" AND _dawn_arch STREQUAL "arm64")
|
||||
set(AURORA_DAWN_PACKAGE_URL_HASH
|
||||
"SHA256=0a8ea8eb0159fc0ba1083c52155d9376fb173cffe690b400464a6ad8881bb461")
|
||||
elseif (_dawn_system STREQUAL "darwin" AND _dawn_arch STREQUAL "x86_64")
|
||||
set(AURORA_DAWN_PACKAGE_URL_HASH
|
||||
"SHA256=5fe2c7a2a8b4cb82acee4af16779a83ae333c7657b9dc1a5008f5fd1f5ad5f80")
|
||||
elseif (_dawn_system STREQUAL "ios" AND _dawn_arch STREQUAL "arm64")
|
||||
set(AURORA_DAWN_PACKAGE_URL_HASH
|
||||
"SHA256=f97701d26fd1f25bbcc260b4c31736ede134c730c12556029e2470fde967f424")
|
||||
elseif (_dawn_system STREQUAL "android" AND _dawn_arch STREQUAL "aarch64")
|
||||
set(AURORA_DAWN_PACKAGE_URL_HASH
|
||||
"SHA256=0e63e8cbf53551f703f582d1306f4257c0380353f66b53369d96952ce6d9f934")
|
||||
endif ()
|
||||
endif ()
|
||||
endif ()
|
||||
message(STATUS "aurora: Fetching prebuilt Dawn package from ${AURORA_DAWN_PACKAGE_URL}")
|
||||
|
||||
@@ -127,12 +127,20 @@ typedef struct {
|
||||
const char* pipelineCachePath;
|
||||
} AuroraConfig;
|
||||
|
||||
typedef enum {
|
||||
AURORA_INITIALIZATION_SUCCESS = 0,
|
||||
AURORA_INITIALIZATION_GRAPHICS_UNAVAILABLE = 1,
|
||||
} AuroraInitializationStatus;
|
||||
|
||||
typedef struct {
|
||||
AuroraBackend backend;
|
||||
const char* userPath;
|
||||
const char* cachePath;
|
||||
SDL_Window* window;
|
||||
AuroraWindowSize windowSize;
|
||||
AuroraInitializationStatus initializationStatus;
|
||||
// On failure, owned by SDL on the calling thread. Copy before another SDL call.
|
||||
const char* initializationError;
|
||||
} AuroraInfo;
|
||||
|
||||
AuroraInfo aurora_initialize(int argc, char* argv[], const AuroraConfig* config);
|
||||
|
||||
@@ -171,6 +171,22 @@ typedef struct PADButtonMapping {
|
||||
PADButton padButton;
|
||||
} PADButtonMapping;
|
||||
|
||||
// Explicitly disabled, unlike INVALID which permits default L/R trigger input.
|
||||
#define PAD_NATIVE_BUTTON_DISABLED 0xfffffffeu
|
||||
|
||||
// Axis-to-button bindings share the persisted nativeButton field without
|
||||
// changing the binary layout of existing controller mapping files.
|
||||
constexpr u32 PADEncodeAxisButton(u32 axis, bool negative, u32 threshold = 50) {
|
||||
return 0x10000u | axis | (negative ? 0x80u : 0u) | (threshold << 8);
|
||||
}
|
||||
constexpr bool PADIsAxisButton(u32 binding) { return (binding & 0xffff0000u) == 0x10000u; }
|
||||
constexpr u32 PADAxisButtonThreshold(u32 binding) { return (binding >> 8) & 0xffu; }
|
||||
constexpr u32 PADAxisButtonAxis(u32 binding) { return binding & 0x7fu; }
|
||||
constexpr bool PADAxisButtonNegative(u32 binding) { return (binding & 0x80u) != 0; }
|
||||
constexpr u32 PADAxisButtonIdentity(u32 binding) {
|
||||
return PADIsAxisButton(binding) ? (binding & ~0xff00u) : binding;
|
||||
}
|
||||
|
||||
typedef struct PADAxisMapping {
|
||||
PADSignedNativeAxis nativeAxis;
|
||||
s32 nativeButton;
|
||||
|
||||
+31
-17
@@ -225,7 +225,7 @@ enum class ImGuiFramePolicy {
|
||||
bool begin_frame_impl(bool pumpEvents, ImGuiFramePolicy imguiPolicy = ImGuiFramePolicy::Immediate,
|
||||
bool* imguiNewFrameOwed = nullptr) noexcept;
|
||||
bool begin_frame_render_state_impl(ImGuiFramePolicy imguiPolicy, bool* imguiNewFrameOwed) noexcept;
|
||||
void end_frame_impl(bool pumpEvents, bool drainFifo) noexcept;
|
||||
void end_frame_impl(bool pumpEvents, bool drainFifo);
|
||||
|
||||
// The two publication points of a frame-worker cycle, cleared together under `mutex`. Sealed:
|
||||
// producer-shared renderer state is free again. Done: slots encoded, presented, ImGui restarted.
|
||||
@@ -689,15 +689,23 @@ AuroraInfo initialize(int argc, char* argv[], const AuroraConfig& config) noexce
|
||||
const AuroraBackend requestedBackend = config.desiredBackend;
|
||||
AuroraBackend selectedBackend = requestedBackend;
|
||||
bool windowCreated = false;
|
||||
std::string firstGraphicsError;
|
||||
const auto rememberGraphicsError = [&] {
|
||||
if (firstGraphicsError.empty() && SDL_GetError()[0] != '\0') {
|
||||
firstGraphicsError = SDL_GetError();
|
||||
}
|
||||
};
|
||||
if (selectedBackend != BACKEND_AUTO) {
|
||||
Log.info("Requested graphics backend: {}", backend_name(selectedBackend));
|
||||
if (window::create_window(selectedBackend)) {
|
||||
if (webgpu::initialize(selectedBackend)) {
|
||||
windowCreated = true;
|
||||
} else {
|
||||
rememberGraphicsError();
|
||||
window::destroy_window();
|
||||
}
|
||||
} else {
|
||||
rememberGraphicsError();
|
||||
Log.error("Failed to create a window for backend {}: {}", backend_name(selectedBackend),
|
||||
SDL_GetError());
|
||||
}
|
||||
@@ -714,18 +722,28 @@ AuroraInfo initialize(int argc, char* argv[], const AuroraConfig& config) noexce
|
||||
for (const auto backendType : PreferredBackendOrder) {
|
||||
selectedBackend = backendType;
|
||||
if (!window::create_window(selectedBackend)) {
|
||||
rememberGraphicsError();
|
||||
continue;
|
||||
}
|
||||
if (webgpu::initialize(selectedBackend)) {
|
||||
windowCreated = true;
|
||||
break;
|
||||
} else {
|
||||
rememberGraphicsError();
|
||||
window::destroy_window();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ASSERT(windowCreated, "Error creating window: {}", SDL_GetError());
|
||||
if (!windowCreated) {
|
||||
if (firstGraphicsError.empty()) firstGraphicsError = "No supported graphics backend is available";
|
||||
SDL_SetError("%s", firstGraphicsError.c_str());
|
||||
Log.error("Graphics initialization failed: {}", firstGraphicsError);
|
||||
return {
|
||||
.initializationStatus = AURORA_INITIALIZATION_GRAPHICS_UNAVAILABLE,
|
||||
.initializationError = SDL_GetError(),
|
||||
};
|
||||
}
|
||||
if (requestedBackend != BACKEND_AUTO && selectedBackend != requestedBackend) {
|
||||
Log.error("Graphics backend fallback in effect: video.graphics_api requested {}, "
|
||||
"running on {}",
|
||||
@@ -1661,7 +1679,7 @@ bool run_frame_worker_cycle(gfx::SealedFrame& sealedFrame) noexcept {
|
||||
|
||||
// Synchronous frame submission: seal, encode and present inline on the calling thread. Used when
|
||||
// the frame worker is disabled (RenderDoc captures) and on the boot path.
|
||||
void end_frame_impl(bool pumpEvents, bool drainFifo) noexcept {
|
||||
void end_frame_impl(bool pumpEvents, bool drainFifo) {
|
||||
ZoneScoped;
|
||||
#ifdef AURORA_ENABLE_GX
|
||||
webgpu::fail_if_device_lost();
|
||||
@@ -1671,11 +1689,9 @@ void end_frame_impl(bool pumpEvents, bool drainFifo) noexcept {
|
||||
gfx::SealedFrame sealedFrame;
|
||||
SealedFrameContext ctx;
|
||||
std::vector<PresentationJob> presentationJobs;
|
||||
if (drainFifo) gx::fifo::drain();
|
||||
{
|
||||
std::lock_guard gpuLock(g_rendererGpuMutex);
|
||||
if (drainFifo) {
|
||||
gx::fifo::drain();
|
||||
}
|
||||
seal_frame_locked(sealedFrame, ctx);
|
||||
presentationJobs = encode_sealed_frame(sealedFrame, ctx);
|
||||
}
|
||||
@@ -1752,7 +1768,7 @@ bool begin_frame() noexcept {
|
||||
return prepared;
|
||||
}
|
||||
|
||||
void end_frame() noexcept {
|
||||
void end_frame() {
|
||||
#ifdef AURORA_ENABLE_GX
|
||||
webgpu::fail_if_device_lost();
|
||||
#endif
|
||||
@@ -1768,10 +1784,7 @@ void end_frame() noexcept {
|
||||
|
||||
// Seal all current GX work on the CPU while the renderer is known ready.
|
||||
// Later FIFO writes belong exclusively to the next frame.
|
||||
{
|
||||
std::lock_guard gpuLock(g_rendererGpuMutex);
|
||||
gx::fifo::drain();
|
||||
}
|
||||
gx::fifo::drain();
|
||||
{
|
||||
std::lock_guard lock(g_frameWorker.mutex);
|
||||
g_frameWorker.framePrepared = false;
|
||||
@@ -1797,6 +1810,10 @@ bool wait_for_frame_worker_for(std::chrono::microseconds timeout) noexcept {
|
||||
return wait_for_frame_worker_private_for(FrameWorkerPhase::Done, timeout);
|
||||
}
|
||||
std::recursive_mutex& renderer_gpu_mutex() noexcept { return g_rendererGpuMutex; }
|
||||
void submit_staging_commands(const wgpu::CommandBuffer& commands) {
|
||||
std::lock_guard submitLock(g_queueSubmitMutex);
|
||||
webgpu::g_queue.Submit(1, &commands);
|
||||
}
|
||||
} // namespace aurora
|
||||
|
||||
// C API bindings
|
||||
@@ -1859,10 +1876,6 @@ bool aurora_flush_efb_copies_to_ram() {
|
||||
if (!aurora::gfx::efb_ram::has_pending()) {
|
||||
return true;
|
||||
}
|
||||
if (!aurora::gfx::efb_ram::prepare_downloads()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// This finalizes the frame still being recorded, on the producer thread, so join the whole cycle
|
||||
// first: the encode phase owns the previous passes, EFB targets and image pool.
|
||||
aurora::wait_for_frame_worker();
|
||||
@@ -1870,6 +1883,7 @@ bool aurora_flush_efb_copies_to_ram() {
|
||||
// suffix cannot safely be replayed against the same mutable EFB resources.
|
||||
aurora::gx::mark_frame_interpolation_replay_unsafe();
|
||||
aurora::gx::fifo::drain();
|
||||
if (!aurora::gfx::efb_ram::prepare_downloads()) return false;
|
||||
const wgpu::CommandEncoderDescriptor encoderDescriptor{
|
||||
.label = "GX CPU-visible EFB copy encoder",
|
||||
};
|
||||
@@ -1895,8 +1909,7 @@ bool aurora_flush_efb_copies_to_ram() {
|
||||
}
|
||||
bool aurora_flush_efb_copy_to_ram(void* dest) {
|
||||
#ifdef AURORA_ENABLE_GX
|
||||
if (dest == nullptr || !aurora::gfx::efb_ram::has_pending(dest) ||
|
||||
!aurora::gfx::efb_ram::prepare_downloads(dest)) {
|
||||
if (dest == nullptr || !aurora::gfx::efb_ram::has_pending(dest)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1907,6 +1920,7 @@ bool aurora_flush_efb_copy_to_ram(void* dest) {
|
||||
// image instead of replaying this split frame.
|
||||
aurora::gx::mark_frame_interpolation_replay_unsafe();
|
||||
aurora::gx::fifo::drain();
|
||||
if (!aurora::gfx::efb_ram::prepare_downloads(dest)) return false;
|
||||
const wgpu::CommandEncoderDescriptor encoderDescriptor{
|
||||
.label = "GX demanded EFB copy encoder",
|
||||
};
|
||||
|
||||
@@ -2,12 +2,43 @@
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#include <SDL3/SDL_metal.h>
|
||||
#include <SDL3/SDL_properties.h>
|
||||
#include <SDL3/SDL_video.h>
|
||||
|
||||
namespace aurora::webgpu::utils {
|
||||
namespace {
|
||||
constexpr const char* MetalViewProperty = "aurora.window.metal_view";
|
||||
|
||||
void SDLCALL DestroyMetalView(void*, void* value) {
|
||||
SDL_Metal_DestroyView(value);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::shared_ptr<wgpu::ChainedStruct> SetupWindowAndGetSurfaceDescriptorCocoa(SDL_Window* window) {
|
||||
SDL_MetalView view = SDL_Metal_CreateView(window);
|
||||
std::shared_ptr<wgpu::SurfaceSourceMetalLayer> desc = std::make_shared<wgpu::SurfaceSourceMetalLayer>();
|
||||
const auto properties = SDL_GetWindowProperties(window);
|
||||
if (!properties) {
|
||||
return nullptr;
|
||||
}
|
||||
auto view = SDL_GetPointerProperty(properties, MetalViewProperty, nullptr);
|
||||
if (!view) {
|
||||
view = SDL_Metal_CreateView(window);
|
||||
if (!view) {
|
||||
return nullptr;
|
||||
}
|
||||
// Own one view per window, not per WebGPU surface. Surface recovery must
|
||||
// preserve the UIKit root and its controls (and the Cocoa Metal subview).
|
||||
// SDL cleans window properties before destroying its native window.
|
||||
// The cleanup callback also runs if setting the property fails.
|
||||
if (!SDL_SetPointerPropertyWithCleanup(properties, MetalViewProperty, view, DestroyMetalView, nullptr)) {
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
auto desc = std::make_shared<wgpu::SurfaceSourceMetalLayer>();
|
||||
desc->layer = SDL_Metal_GetLayer(view);
|
||||
return std::move(desc);
|
||||
if (!desc->layer) {
|
||||
SDL_ClearProperty(properties, MetalViewProperty);
|
||||
return nullptr;
|
||||
}
|
||||
return desc;
|
||||
}
|
||||
} // namespace aurora::webgpu::utils
|
||||
|
||||
@@ -520,9 +520,11 @@ void GXCopyTex(void* dest, GXBool clear) {
|
||||
clearState.clearAlpha = clear && alphaUpdate;
|
||||
}
|
||||
const auto copyFilter = combined_copy_filter_coefficients(g_gxState.copyFilterVFilter);
|
||||
// Skip only recurring color copies so one-shot copies are never lost.
|
||||
const bool producedConsecutively = handle.revision != 0 && currentFrame - handle.lastProducedFrame <= 1;
|
||||
const bool persistentCopy = !aurora::gx::is_depth_format(texCopyFmt) && !producedConsecutively;
|
||||
// Every GXCopyTex is observable texture data. Reusing a destination in this
|
||||
// or the previous frame does not guarantee another redraw: menu thumbnail
|
||||
// scratch targets can be reused and then retained. Depth copies have the
|
||||
// same requirement. Only display presentation may skip unfinished draws.
|
||||
const bool persistentCopy = true;
|
||||
aurora::gfx::resolve_pass(handle.handle, rect, clearState.clearColor, clearState.clearAlpha, clearState.clearDepth,
|
||||
clearState.clearColorValue, aurora::gx::clear_depth_value(), resolveFmt,
|
||||
&sourceRect.sampleRect, g_gxState.texCopyHalfScale, ©Filter, forceOpaqueAlpha,
|
||||
|
||||
@@ -319,6 +319,18 @@ std::array<bool, PAD_CHANMAX> g_suppressLeftTrigger{};
|
||||
std::array<bool, PAD_CHANMAX> g_suppressRightTrigger{};
|
||||
|
||||
bool is_mouse_scancode(const s32 scancode) { return scancode < PAD_KEY_INVALID; }
|
||||
bool is_native_binding_pressed(SDL_Gamepad* gamepad, u32 binding) {
|
||||
if (PADIsAxisButton(binding)) {
|
||||
const u32 axis = PADAxisButtonAxis(binding);
|
||||
const u32 threshold = PADAxisButtonThreshold(binding);
|
||||
if (axis >= SDL_GAMEPAD_AXIS_COUNT || threshold < 1 || threshold > 100) return false;
|
||||
int value = SDL_GetGamepadAxis(gamepad, static_cast<SDL_GamepadAxis>(axis));
|
||||
if (PADAxisButtonNegative(binding)) value = -value;
|
||||
return value > 0 && value * 100 >= static_cast<int>(threshold) * 32767;
|
||||
}
|
||||
return binding < SDL_GAMEPAD_BUTTON_COUNT &&
|
||||
SDL_GetGamepadButton(gamepad, static_cast<SDL_GamepadButton>(binding));
|
||||
}
|
||||
bool is_mouse_button_pressed(const s32 scancode) {
|
||||
const int32_t buttonNum = -(scancode + 1);
|
||||
if (buttonNum < 1 || buttonNum > 5) {
|
||||
@@ -724,10 +736,10 @@ u32 PADRead(PADStatus* status) {
|
||||
}
|
||||
|
||||
status[i].err = PAD_ERR_NONE;
|
||||
if (g_keyboardBindings[i].m_mappingsSet) {
|
||||
if (g_keyboardBindings[i].m_mappingsSet && SDL_GetKeyboardFocus() != nullptr) {
|
||||
std::ranges::for_each(
|
||||
g_keyboardBindings[i].m_buttonMapping, [&kbState, &i, &status](const PADKeyButtonBinding& mapping) {
|
||||
if (mapping.scancode > PAD_KEY_INVALID && kbState[mapping.scancode]) {
|
||||
g_keyboardBindings[i].m_buttonMapping, [&kbState, &numKeys, &i, &status](const PADKeyButtonBinding& mapping) {
|
||||
if (mapping.scancode > PAD_KEY_INVALID && mapping.scancode < numKeys && kbState[mapping.scancode]) {
|
||||
status[i].button |= mapping.padButton;
|
||||
} else if (is_mouse_scancode(mapping.scancode) && is_mouse_button_pressed(mapping.scancode)) {
|
||||
status[i].button |= mapping.padButton;
|
||||
@@ -788,7 +800,7 @@ u32 PADRead(PADStatus* status) {
|
||||
status[i].triggerRight = static_cast<u8>(std::min(static_cast<int>(status[i].triggerRight) + tr, 255));
|
||||
}
|
||||
|
||||
if (controller) {
|
||||
if (controller && !g_keyboardBindings[i].m_mappingsSet) {
|
||||
EnsureMappingLoaded(controller);
|
||||
|
||||
// Wii U Pro Controller raw D-pad fallback. SDL's HIDAPI Wii driver posts
|
||||
@@ -835,7 +847,7 @@ u32 PADRead(PADStatus* status) {
|
||||
bool rightTriggerSet = false;
|
||||
std::ranges::for_each(controller->m_buttonMapping, [&controller, &i, &status, &leftTriggerSet,
|
||||
&rightTriggerSet](const auto& mapping) {
|
||||
if (SDL_GetGamepadButton(controller->m_controller, static_cast<SDL_GamepadButton>(mapping.nativeButton))) {
|
||||
if (is_native_binding_pressed(controller->m_controller, mapping.nativeButton)) {
|
||||
status[i].button |= mapping.padButton;
|
||||
}
|
||||
|
||||
@@ -852,7 +864,7 @@ u32 PADRead(PADStatus* status) {
|
||||
if (mapping.nativeButton == PAD_NATIVE_BUTTON_INVALID) {
|
||||
return;
|
||||
}
|
||||
if (SDL_GetGamepadButton(controller->m_controller, static_cast<SDL_GamepadButton>(mapping.nativeButton))) {
|
||||
if (is_native_binding_pressed(controller->m_controller, mapping.nativeButton)) {
|
||||
status[i].button |= mapping.padButton;
|
||||
}
|
||||
|
||||
@@ -946,6 +958,17 @@ u32 PADRead(PADStatus* status) {
|
||||
Sint16 tl = std::max(static_cast<Sint16>(0), _get_axis_value(controller, PAD_AXIS_TRIGGER_L));
|
||||
Sint16 tr = std::max(static_cast<Sint16>(0), _get_axis_value(controller, PAD_AXIS_TRIGGER_R));
|
||||
|
||||
// Games can read either the digital L/R bits or their analog pressure.
|
||||
// An explicit button binding must drive both, otherwise the original
|
||||
// L2/R2 axis still activates L/R even when it was rebound to L1/R1.
|
||||
// Real GC pads retain independent analog travel and end-stop clicks.
|
||||
if (!(controller->m_isGameCube ||
|
||||
(SDL_GetGamepadType(controller->m_controller) == SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO &&
|
||||
controller->m_pid == 0x2073))) {
|
||||
if (leftTriggerSet) tl = (status[i].button & PAD_TRIGGER_L) != 0 ? 32767 : 0;
|
||||
if (rightTriggerSet) tr = (status[i].button & PAD_TRIGGER_R) != 0 ? 32767 : 0;
|
||||
}
|
||||
|
||||
if (controller->m_deadZones.emulateTriggers) {
|
||||
if (!leftTriggerSet && tl > controller->m_deadZones.leftTriggerActivationZone) {
|
||||
status[i].button |= PAD_TRIGGER_L;
|
||||
@@ -990,12 +1013,13 @@ void PADControlMotor(const u32 chan, const u32 cmd) {
|
||||
}
|
||||
|
||||
if (controller->m_isGameCube) {
|
||||
if (cmd == PAD_MOTOR_STOP) {
|
||||
aurora::input::controller_rumble(instance, 0, 1, 0);
|
||||
if (cmd == PAD_MOTOR_STOP || cmd == PAD_MOTOR_STOP_HARD) {
|
||||
// Use an unambiguous motor-off request. The (0, 1) coast encoding
|
||||
// requires SDL's GameCube brake mode; other backends or an overridden
|
||||
// hint interpret it as rumble and can leave the controller vibrating.
|
||||
aurora::input::controller_rumble(instance, 0, 0, 0);
|
||||
} else if (cmd == PAD_MOTOR_RUMBLE) {
|
||||
aurora::input::controller_rumble(instance, 1, 1, 0);
|
||||
} else if (cmd == PAD_MOTOR_STOP_HARD) {
|
||||
aurora::input::controller_rumble(instance, 0, 0, 0);
|
||||
}
|
||||
} else {
|
||||
if (cmd == PAD_MOTOR_STOP) {
|
||||
@@ -1278,6 +1302,11 @@ BOOL PADSetKeyButtonBindings(const u32 port, PADKeyButtonBinding bindings[PAD_BU
|
||||
}
|
||||
|
||||
PADKeyButtonBinding* PADGetKeyButtonBindings(const u32 port, u32* buttonCount) {
|
||||
PADInit();
|
||||
if (!g_keyboardBindingsLoaded) {
|
||||
g_keyboardBindingsLoaded = true;
|
||||
load_keyboard_bindings();
|
||||
}
|
||||
if (port >= PAD_MAX_CONTROLLERS || !g_keyboardBindings[port].m_mappingsSet) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <optional>
|
||||
#include <mutex>
|
||||
|
||||
namespace aurora::vi {
|
||||
std::optional<GXRenderModeObj> g_renderMode;
|
||||
namespace {
|
||||
std::atomic<float> g_presentAspectCorrection{1.f};
|
||||
std::mutex g_renderModeMutex;
|
||||
|
||||
float calculate_present_aspect_correction(const GXRenderModeObj& rm) noexcept {
|
||||
if (rm.viWidth == 0 || rm.viHeight == 0) {
|
||||
@@ -29,9 +31,8 @@ float calculate_present_aspect_correction(const GXRenderModeObj& rm) noexcept {
|
||||
const float verticalFill = static_cast<float>(rm.viHeight) / nominalActiveHeight;
|
||||
return horizontalFill / verticalFill;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
Vec2<uint32_t> render_mode_size() noexcept {
|
||||
Vec2<uint32_t> render_mode_size_locked() noexcept {
|
||||
if (!g_renderMode) {
|
||||
return {640, 528};
|
||||
}
|
||||
@@ -40,18 +41,31 @@ Vec2<uint32_t> render_mode_size() noexcept {
|
||||
return {std::max<uint32_t>(g_renderMode->fbWidth, 640), std::max<uint32_t>(g_renderMode->efbHeight, 528)};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Vec2<uint32_t> render_mode_size() noexcept {
|
||||
std::lock_guard lock(g_renderModeMutex);
|
||||
return render_mode_size_locked();
|
||||
}
|
||||
|
||||
void configure(const GXRenderModeObj* rm) noexcept {
|
||||
const auto oldSize = render_mode_size();
|
||||
if (rm == nullptr) {
|
||||
g_renderMode.reset();
|
||||
} else {
|
||||
g_renderMode = *rm;
|
||||
g_presentAspectCorrection.store(calculate_present_aspect_correction(*rm), std::memory_order_release);
|
||||
bool sizeChanged = false;
|
||||
{
|
||||
std::lock_guard lock(g_renderModeMutex);
|
||||
const auto oldSize = render_mode_size_locked();
|
||||
if (rm == nullptr) {
|
||||
g_renderMode.reset();
|
||||
} else {
|
||||
g_renderMode = *rm;
|
||||
g_presentAspectCorrection.store(calculate_present_aspect_correction(*rm), std::memory_order_release);
|
||||
}
|
||||
if (rm == nullptr) {
|
||||
g_presentAspectCorrection.store(1.f, std::memory_order_release);
|
||||
}
|
||||
sizeChanged = render_mode_size_locked() != oldSize;
|
||||
}
|
||||
if (rm == nullptr) {
|
||||
g_presentAspectCorrection.store(1.f, std::memory_order_release);
|
||||
}
|
||||
if (render_mode_size() != oldSize) {
|
||||
// Never hold the mode lock across a resize request or a renderer callback.
|
||||
if (sizeChanged) {
|
||||
window::request_frame_buffer_resize();
|
||||
}
|
||||
}
|
||||
@@ -61,6 +75,7 @@ Vec2<uint32_t> configured_fb_size() noexcept {
|
||||
}
|
||||
|
||||
Vec2<uint32_t> visible_fb_size() noexcept {
|
||||
std::lock_guard lock(g_renderModeMutex);
|
||||
if (!g_renderMode) {
|
||||
return {640, 528};
|
||||
}
|
||||
|
||||
+197
-53
@@ -1,4 +1,5 @@
|
||||
#include "common.hpp"
|
||||
#include "staging_map.hpp"
|
||||
#include "../gx/shader_info.hpp"
|
||||
|
||||
#include "clear.hpp"
|
||||
@@ -36,10 +37,13 @@ using webgpu::g_device;
|
||||
using webgpu::g_instance;
|
||||
using webgpu::g_queue;
|
||||
|
||||
struct DebugFrameData {
|
||||
#ifdef AURORA_GFX_DEBUG_GROUPS
|
||||
std::vector<std::string> g_debugGroupStack;
|
||||
std::vector<std::string> g_debugMarkers;
|
||||
std::vector<std::string> groups;
|
||||
std::vector<std::string> markers;
|
||||
#endif
|
||||
};
|
||||
DebugFrameData g_debugFrame;
|
||||
|
||||
constexpr uint64_t StagingBufferSize = UniformBufferSize + VertexBufferSize + IndexBufferSize + StorageBufferSize +
|
||||
(UseTextureBuffer ? TextureUploadSize : 0);
|
||||
@@ -128,12 +132,7 @@ wgpu::Buffer g_storageBuffer;
|
||||
constexpr size_t FrameSlotCount = 3;
|
||||
static std::array<wgpu::Buffer, FrameSlotCount> g_stagingBuffers;
|
||||
static size_t currentStagingBuffer = 0;
|
||||
enum class BufferMapState {
|
||||
Unmapped,
|
||||
Mapping,
|
||||
Mapped,
|
||||
};
|
||||
static std::atomic s_mappingState{BufferMapState::Unmapped};
|
||||
static StagingMapState s_mappingState;
|
||||
static wgpu::Limits g_cachedLimits;
|
||||
// Advanced once per logical frame in the seal prologue, under the renderer GPU mutex and with the
|
||||
// producer blocked, so every later reader sees a value that no longer moves.
|
||||
@@ -168,7 +167,12 @@ struct RenderPass {
|
||||
Range resolveUniformRange;
|
||||
std::array<u32, 3> resolveCopyFilterCoefficients{0, 64, 0};
|
||||
Vec4<float> clearColorValue{0.f, 0.f, 0.f, 0.f};
|
||||
float clearDepthValue = 1.f;
|
||||
// 1.f is the forward-Z "farthest" clear value; under UseReversedZ farthest is 0.f instead (see
|
||||
// gx::clear_depth_value(), which the main render pass explicitly overrides this default with -
|
||||
// any OTHER pass that keeps this default, e.g. an offscreen render-to-texture pass composited
|
||||
// later, needs the same reversed-Z-aware value or its depth buffer starts "already nearest",
|
||||
// failing every subsequent depth test and making whatever's drawn into it vanish).
|
||||
float clearDepthValue = gx::UseReversedZ ? 0.f : 1.f;
|
||||
CommandList commands;
|
||||
bool clearColor = true;
|
||||
bool clearDepth = true;
|
||||
@@ -229,6 +233,8 @@ static void recycle_render_passes(std::vector<RenderPass>& passes) noexcept {
|
||||
}
|
||||
|
||||
struct SealedFrameData {
|
||||
depth_peek::FrameMapping depthMapping;
|
||||
DebugFrameData debug;
|
||||
std::vector<RenderPass> passes;
|
||||
};
|
||||
|
||||
@@ -250,6 +256,51 @@ static std::atomic_bool g_inOffscreen{false};
|
||||
static std::optional<RenderPass> g_suspendedEfbPass;
|
||||
static Viewport g_suspendedEfbViewport;
|
||||
static ClipRect g_suspendedEfbScissor;
|
||||
// Prefix referenced by a suspended EFB pass. Preserve its offsets across an
|
||||
// offscreen split, without rendering it before the bake it may sample finishes.
|
||||
static StagingSizes g_suspendedEfbBytes{};
|
||||
static constexpr StagingSizes PhysicalStagingCapacity{
|
||||
VertexBufferSize, UniformBufferSize, IndexBufferSize, StorageBufferSize};
|
||||
static StagingSizes g_stagingCapacity = PhysicalStagingCapacity;
|
||||
static uint64_t g_stagingEpoch = 0;
|
||||
static uint64_t g_stagingSplitCount = 0;
|
||||
static StagingSizes g_stagingHighWater{};
|
||||
|
||||
StagingSizes staging_usage() noexcept {
|
||||
return {g_verts.size(), g_uniforms.size(), g_indices.size(), g_storage.size()};
|
||||
}
|
||||
StagingSizes staging_high_water() noexcept { return g_stagingHighWater; }
|
||||
uint64_t staging_epoch() noexcept { return g_stagingEpoch; }
|
||||
uint64_t staging_split_count() noexcept { return g_stagingSplitCount; }
|
||||
uint64_t staging_uniform_bytes(uint64_t bytes) {
|
||||
return staging_padded(bytes, g_cachedLimits.minUniformBufferOffsetAlignment);
|
||||
}
|
||||
uint64_t staging_storage_bytes(uint64_t bytes) {
|
||||
return staging_padded(bytes, g_cachedLimits.minStorageBufferOffsetAlignment);
|
||||
}
|
||||
void set_staging_capacity_limits_for_testing(const StagingSizes& limits) {
|
||||
for (unsigned i = 0; i < limits.size(); ++i) {
|
||||
if (limits[i] > PhysicalStagingCapacity[i])
|
||||
throw StagingCapacityError("Test staging capacity exceeds physical buffer");
|
||||
}
|
||||
g_stagingCapacity = limits;
|
||||
g_stagingHighWater = {};
|
||||
}
|
||||
bool staging_has_space(const StagingSizes& demand) {
|
||||
// Async readback preparation runs in the worker's noexcept seal prologue.
|
||||
// Reserve all 32 slots plus the uniform binding's 3840-byte trailing window.
|
||||
const StagingSizes tail{0, gx::MaxUniformSize + efb_ram::MaxAsyncReadbackSlots * staging_uniform_bytes(48), 0, 0};
|
||||
const StagingSizes retained = g_suspendedEfbPass ? g_suspendedEfbBytes : StagingSizes{};
|
||||
if (!staging_fits(retained, demand, tail, g_stagingCapacity))
|
||||
throw StagingCapacityError("GPU operation exceeds staging capacity including retained EFB data");
|
||||
return staging_fits(staging_usage(), demand, tail, g_stagingCapacity);
|
||||
}
|
||||
void ensure_staging_space(const StagingSizes& demand) {
|
||||
if (staging_has_space(demand)) return;
|
||||
split_staging_batch();
|
||||
if (!staging_has_space(demand))
|
||||
throw StagingCapacityError("GPU operation still exceeds staging capacity after submission");
|
||||
}
|
||||
|
||||
static void discard_suspended_efb_pass() noexcept {
|
||||
if (g_suspendedEfbPass) {
|
||||
@@ -274,7 +325,8 @@ static size_t g_recordingSnapshotSlot = 0;
|
||||
static TextureHandle new_resolve_source_snapshot(wgpu::Extent3D size, wgpu::TextureFormat format) noexcept {
|
||||
const wgpu::TextureDescriptor textureDescriptor{
|
||||
.label = "GX Copy Source Snapshot",
|
||||
.usage = wgpu::TextureUsage::TextureBinding | wgpu::TextureUsage::CopyDst,
|
||||
.usage = wgpu::TextureUsage::TextureBinding | wgpu::TextureUsage::CopySrc |
|
||||
wgpu::TextureUsage::CopyDst,
|
||||
.dimension = wgpu::TextureDimension::e2D,
|
||||
.size = size,
|
||||
.format = format,
|
||||
@@ -420,7 +472,7 @@ static inline void push_command(CommandType type, const Command::Data& data) {
|
||||
g_renderPasses[g_currentRenderPass].commands.push_back({
|
||||
.type = type,
|
||||
#ifdef AURORA_GFX_DEBUG_GROUPS
|
||||
.debugGroupStack = g_debugGroupStack,
|
||||
.debugGroupStack = g_debugFrame.groups,
|
||||
#endif
|
||||
.data = data,
|
||||
});
|
||||
@@ -480,6 +532,7 @@ void set_scissor(const ClipRect& cmd) noexcept {
|
||||
template <>
|
||||
void push_draw_command(clear::DrawData data) {
|
||||
if (data.uniformRange.size == 0) {
|
||||
ensure_staging_space({0, staging_uniform_bytes(16), 0, 0});
|
||||
const std::array clearUniform{
|
||||
std::clamp(data.depth, 0.f, 1.f),
|
||||
0.f,
|
||||
@@ -506,6 +559,7 @@ void resolve_pass(TextureHandle texture, ClipRect rect, bool clearColor, bool cl
|
||||
Log.warn("Dropping resolve pass without an active render pass");
|
||||
return;
|
||||
}
|
||||
ensure_staging_space({0, 2 * staging_uniform_bytes(48), 0, 0});
|
||||
auto& prevPass = g_renderPasses[g_currentRenderPass];
|
||||
const auto targetWidth = static_cast<int32_t>(prevPass.targetSize.width);
|
||||
const auto targetHeight = static_cast<int32_t>(prevPass.targetSize.height);
|
||||
@@ -538,7 +592,7 @@ void resolve_pass(TextureHandle texture, ClipRect rect, bool clearColor, bool cl
|
||||
sourceRect = {srcLeft, srcTop, std::max(srcRight - srcLeft, 1.0f), std::max(srcBottom - srcTop, 1.0f)};
|
||||
}
|
||||
prevPass.resolveTarget = std::move(texture);
|
||||
prevPass.requireReadyPipelines = persistentCopy;
|
||||
prevPass.requireReadyPipelines |= persistentCopy;
|
||||
prevPass.resolveRect = rect;
|
||||
prevPass.resolveSourceRect = sourceRect;
|
||||
prevPass.resolveFormat = resolveFormat;
|
||||
@@ -734,6 +788,7 @@ void begin_offscreen(uint32_t width, uint32_t height) {
|
||||
if (!g_inOffscreen) {
|
||||
auto& currentPass = g_renderPasses[g_currentRenderPass];
|
||||
if (!currentPass.resolveTarget) {
|
||||
g_suspendedEfbBytes = staging_usage();
|
||||
g_suspendedEfbPass = std::move(currentPass);
|
||||
g_renderPasses.pop_back();
|
||||
--g_currentRenderPass;
|
||||
@@ -757,7 +812,9 @@ void begin_offscreen(uint32_t width, uint32_t height) {
|
||||
.targetSize = {width, height, 1},
|
||||
.msaaSamples = 1,
|
||||
.clearColorValue = {0.f, 0.f, 0.f, 0.f},
|
||||
.clearDepthValue = 1.f,
|
||||
// See the RenderPass::clearDepthValue default's comment: this offscreen pass gets its own
|
||||
// depth buffer, and the farthest clear value is 0.f, not 1.f, under UseReversedZ.
|
||||
.clearDepthValue = gx::UseReversedZ ? 0.f : 1.f,
|
||||
.clearColor = true,
|
||||
.clearDepth = true,
|
||||
};
|
||||
@@ -844,7 +901,7 @@ void initialize() {
|
||||
label.c_str());
|
||||
}
|
||||
currentStagingBuffer = 0;
|
||||
s_mappingState.store(BufferMapState::Unmapped, std::memory_order_release);
|
||||
s_mappingState.reset();
|
||||
map_staging_buffer();
|
||||
|
||||
{
|
||||
@@ -950,6 +1007,8 @@ void shutdown() {
|
||||
g_uniformBuffer = {};
|
||||
g_indexBuffer = {};
|
||||
g_storageBuffer = {};
|
||||
// Invalidate outstanding callbacks before releasing their buffers.
|
||||
s_mappingState.reset();
|
||||
g_stagingBuffers.fill({});
|
||||
for (auto& pool : g_resolveSourceSnapshotPools) {
|
||||
pool.entry.reset();
|
||||
@@ -968,37 +1027,36 @@ void shutdown() {
|
||||
g_inOffscreen = false;
|
||||
g_frameIndex = UINT32_MAX;
|
||||
currentStagingBuffer = 0;
|
||||
s_mappingState.store(BufferMapState::Unmapped, std::memory_order_release);
|
||||
}
|
||||
|
||||
void map_staging_buffer() {
|
||||
auto expected = BufferMapState::Unmapped;
|
||||
if (!s_mappingState.compare_exchange_strong(expected, BufferMapState::Mapping, std::memory_order_acq_rel,
|
||||
std::memory_order_acquire)) {
|
||||
const auto generation = s_mappingState.request();
|
||||
if (generation == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
g_stagingBuffers[currentStagingBuffer].MapAsync(
|
||||
wgpu::MapMode::Write, 0, StagingBufferSize, wgpu::CallbackMode::AllowSpontaneous,
|
||||
[](wgpu::MapAsyncStatus status, wgpu::StringView message) {
|
||||
[generation](wgpu::MapAsyncStatus status, wgpu::StringView message) {
|
||||
const auto result = status == wgpu::MapAsyncStatus::Success
|
||||
? BufferMapState::Mapped : BufferMapState::Unmapped;
|
||||
if (!s_mappingState.complete(generation, result)) return;
|
||||
if (status == wgpu::MapAsyncStatus::CallbackCancelled || status == wgpu::MapAsyncStatus::Aborted) {
|
||||
Log.warn("Buffer mapping {}: {}", magic_enum::enum_name(status), message);
|
||||
s_mappingState.store(BufferMapState::Unmapped, std::memory_order_release);
|
||||
return;
|
||||
}
|
||||
ASSERT(status == wgpu::MapAsyncStatus::Success, "Buffer mapping failed: {} {}", magic_enum::enum_name(status),
|
||||
message);
|
||||
s_mappingState.store(BufferMapState::Mapped, std::memory_order_release);
|
||||
});
|
||||
}
|
||||
|
||||
static bool begin_frame_impl(bool clearEfb) {
|
||||
static bool begin_frame_impl(bool clearEfb, bool capacityResume = false) {
|
||||
ZoneScoped;
|
||||
{
|
||||
ZoneScopedN("Wait for buffer map");
|
||||
map_staging_buffer();
|
||||
while (true) {
|
||||
const auto mappingState = s_mappingState.load(std::memory_order_acquire);
|
||||
const auto mappingState = s_mappingState.state();
|
||||
if (mappingState == BufferMapState::Mapped) {
|
||||
break;
|
||||
}
|
||||
@@ -1014,8 +1072,11 @@ static bool begin_frame_impl(bool clearEfb) {
|
||||
return false;
|
||||
}
|
||||
g_instance.ProcessEvents();
|
||||
webgpu::fail_if_device_lost();
|
||||
s_mappingState.wait_for_progress();
|
||||
}
|
||||
}
|
||||
++g_stagingEpoch;
|
||||
g_recordingSnapshotSlot = currentStagingBuffer;
|
||||
size_t bufferOffset = 0;
|
||||
const auto& stagingBuf = g_stagingBuffers[currentStagingBuffer];
|
||||
@@ -1040,7 +1101,7 @@ static bool begin_frame_impl(bool clearEfb) {
|
||||
gx::begin_frame_interpolation();
|
||||
}
|
||||
discard_suspended_efb_pass();
|
||||
webgpu::clear_present_source_override();
|
||||
if (!capacityResume) webgpu::clear_present_source_override();
|
||||
|
||||
push_render_pass(RenderPass{});
|
||||
set_efb_targets(g_renderPasses[0]);
|
||||
@@ -1079,12 +1140,12 @@ void abort_frame() noexcept {
|
||||
g_textureUploads.clear();
|
||||
g_textureUpload.release();
|
||||
}
|
||||
if (s_mappingState.load(std::memory_order_acquire) == BufferMapState::Mapped) {
|
||||
if (s_mappingState.state() == BufferMapState::Mapped) {
|
||||
// Pending interpolation tasks hold raw pointers into the mapped staging
|
||||
// range; they must be dropped before the buffer is unmapped and rotated.
|
||||
gx::drop_pending_frame_interpolation_uniforms();
|
||||
g_stagingBuffers[currentStagingBuffer].Unmap();
|
||||
s_mappingState.store(BufferMapState::Unmapped, std::memory_order_release);
|
||||
s_mappingState.reset();
|
||||
currentStagingBuffer = (currentStagingBuffer + 1) % g_stagingBuffers.size();
|
||||
map_staging_buffer();
|
||||
}
|
||||
@@ -1101,7 +1162,7 @@ void abort_frame() noexcept {
|
||||
|
||||
static void end_batch_impl(const wgpu::CommandEncoder& cmd, bool advanceFrame) {
|
||||
ZoneScoped;
|
||||
ASSERT(!g_inOffscreen, "end_frame called while offscreen rendering is active");
|
||||
ASSERT(!advanceFrame || !g_inOffscreen, "end_frame called while offscreen rendering is active");
|
||||
if (advanceFrame) {
|
||||
gx::finalize_frame_interpolation();
|
||||
} else {
|
||||
@@ -1110,6 +1171,8 @@ static void end_batch_impl(const wgpu::CommandEncoder& cmd, bool advanceFrame) {
|
||||
gx::drop_pending_frame_interpolation_uniforms();
|
||||
}
|
||||
g_uniforms.append_zeroes(gx::MaxUniformSize); // Pad the end of the buffer
|
||||
const auto used = staging_usage();
|
||||
for (unsigned i = 0; i < used.size(); ++i) g_stagingHighWater[i] = std::max(g_stagingHighWater[i], used[i]);
|
||||
uint64_t bufferOffset = 0;
|
||||
const auto writeBuffer = [&](ByteBuffer& buf, wgpu::Buffer& out, uint64_t size, std::string_view label) {
|
||||
const auto writeSize = buf.size(); // Only need to copy this many bytes
|
||||
@@ -1121,7 +1184,7 @@ static void end_batch_impl(const wgpu::CommandEncoder& cmd, bool advanceFrame) {
|
||||
return writeSize;
|
||||
};
|
||||
g_stagingBuffers[currentStagingBuffer].Unmap();
|
||||
s_mappingState.store(BufferMapState::Unmapped, std::memory_order_release);
|
||||
s_mappingState.reset();
|
||||
g_stats.drawCallCount = g_drawCallCount;
|
||||
g_stats.mergedDrawCallCount = g_mergedDrawCallCount;
|
||||
g_stats.lastVertSize = writeBuffer(g_verts, g_vertexBuffer, VertexBufferSize, "Vertex");
|
||||
@@ -1164,6 +1227,68 @@ void end_frame(const wgpu::CommandEncoder& cmd) { end_batch_impl(cmd, true); }
|
||||
|
||||
void end_batch(const wgpu::CommandEncoder& cmd) { end_batch_impl(cmd, false); }
|
||||
|
||||
void split_staging_batch() {
|
||||
// Never called under the decoder's renderer lock: the worker needs that lock
|
||||
// to reach DONE. FIFO admission yields its unconsumed command first.
|
||||
aurora::wait_for_frame_worker();
|
||||
std::lock_guard gpuLock(aurora::renderer_gpu_mutex());
|
||||
if (!has_current_render_pass())
|
||||
throw StagingCapacityError("Cannot split staging outside an active render pass");
|
||||
gx::mark_frame_interpolation_replay_unsafe();
|
||||
const bool offscreen = g_inOffscreen;
|
||||
const auto viewport = g_cachedViewport;
|
||||
const auto scissor = g_cachedScissor;
|
||||
const auto renderViewport = gx::g_gxState.renderViewport;
|
||||
const auto renderScissor = gx::g_gxState.renderScissor;
|
||||
const auto& active = g_renderPasses[g_currentRenderPass];
|
||||
RenderPass continuation{
|
||||
.colorView = active.colorView, .resolveView = active.resolveView,
|
||||
.depthView = active.depthView, .copySourceTexture = active.copySourceTexture,
|
||||
.copySourceView = active.copySourceView, .copySourceDepthView = active.copySourceDepthView,
|
||||
.targetSize = active.targetSize, .msaaSamples = active.msaaSamples,
|
||||
.clearColor = false, .clearDepth = false,
|
||||
.requireReadyPipelines = active.requireReadyPipelines || offscreen,
|
||||
};
|
||||
auto suspended = std::move(g_suspendedEfbPass);
|
||||
g_suspendedEfbPass.reset();
|
||||
std::array<std::vector<uint8_t>, 4> retained;
|
||||
std::array<ByteBuffer*, 4> buffers{&g_verts, &g_uniforms, &g_indices, &g_storage};
|
||||
if (suspended) {
|
||||
for (unsigned i = 0; i < buffers.size(); ++i) {
|
||||
if (g_suspendedEfbBytes[i])
|
||||
retained[i].assign(buffers[i]->data(), buffers[i]->data() + g_suspendedEfbBytes[i]);
|
||||
}
|
||||
}
|
||||
// GXCopyTex can capture the ordinary EFB as well as an explicit offscreen
|
||||
// target. Its command may arrive in the next batch, after this prefix has
|
||||
// already been submitted. Preserve every prefix; target kind cannot tell
|
||||
// us whether the guest will later retain these pixels in a texture.
|
||||
for (auto& pass : g_renderPasses) pass.requireReadyPipelines = true;
|
||||
auto encoder = g_device.CreateCommandEncoder();
|
||||
end_batch(encoder);
|
||||
render(encoder);
|
||||
aurora::submit_staging_commands(encoder.Finish());
|
||||
after_submit();
|
||||
if (!begin_frame_impl(false, true))
|
||||
throw StagingCapacityError("Staging remap failed after capacity submission");
|
||||
recycle_render_passes(g_renderPasses);
|
||||
push_render_pass(std::move(continuation));
|
||||
g_currentRenderPass = 0;
|
||||
g_suspendedEfbPass = std::move(suspended);
|
||||
for (unsigned i = 0; i < buffers.size(); ++i) {
|
||||
if (!retained[i].empty()) buffers[i]->append(retained[i].data(), retained[i].size());
|
||||
}
|
||||
g_inOffscreen = offscreen;
|
||||
g_cachedViewport = viewport;
|
||||
g_cachedScissor = scissor;
|
||||
gx::g_gxState.renderViewport = renderViewport;
|
||||
gx::g_gxState.renderScissor = renderScissor;
|
||||
gx::g_gxState.stateDirty = true;
|
||||
push_command(CommandType::SetViewport, Command::Data{.setViewport = viewport});
|
||||
push_command(CommandType::SetScissor, Command::Data{.setScissor = scissor});
|
||||
++g_stagingSplitCount;
|
||||
}
|
||||
|
||||
uint32_t current_frame() noexcept { return g_frameIndex; }
|
||||
|
||||
// The only place that erases from g_cachedBindGroups, whose handles the frame being encoded still
|
||||
@@ -1196,10 +1321,10 @@ static const char* render_pass_label(u32 index) noexcept {
|
||||
}
|
||||
|
||||
static void render_pass_impl(const wgpu::RenderPassEncoder& pass, const std::vector<RenderPass>& passes, u32 idx,
|
||||
int32_t interpolatedFrame);
|
||||
int32_t interpolatedFrame, DebugFrameData& debugFrame);
|
||||
|
||||
static void render_impl(std::vector<RenderPass>& renderPasses, wgpu::CommandEncoder& cmd, int32_t interpolatedFrame,
|
||||
bool finalize) {
|
||||
bool finalize, DebugFrameData& debugFrame, const depth_peek::FrameMapping& depthMapping) {
|
||||
ZoneScoped;
|
||||
// Palette conversions, MSAA resolves and EFB copies depend on sealed frame state, not on the
|
||||
// interpolation weight, so encode them on the native render and let replay slots sample them.
|
||||
@@ -1249,11 +1374,11 @@ static void render_impl(std::vector<RenderPass>& renderPasses, wgpu::CommandEnco
|
||||
};
|
||||
|
||||
auto pass = cmd.BeginRenderPass(&renderPassDescriptor);
|
||||
render_pass_impl(pass, renderPasses, i, interpolatedFrame);
|
||||
render_pass_impl(pass, renderPasses, i, interpolatedFrame, debugFrame);
|
||||
pass.End();
|
||||
|
||||
if (finalize && i == renderPasses.size() - 1) {
|
||||
depth_peek::encode_frame_snapshot(cmd, passInfo.copySourceDepthView, passInfo.targetSize, passInfo.msaaSamples);
|
||||
depth_peek::encode_frame_snapshot(cmd, passInfo.copySourceDepthView, passInfo.targetSize, passInfo.msaaSamples, depthMapping);
|
||||
}
|
||||
|
||||
if (passInfo.resolveTarget) {
|
||||
@@ -1327,20 +1452,21 @@ static void render_impl(std::vector<RenderPass>& renderPasses, wgpu::CommandEnco
|
||||
}
|
||||
|
||||
#if defined(AURORA_GFX_DEBUG_GROUPS)
|
||||
if (finalize && !g_debugGroupStack.empty()) {
|
||||
for (auto& it : std::ranges::reverse_view(g_debugGroupStack)) {
|
||||
if (finalize && !debugFrame.groups.empty()) {
|
||||
for (auto& it : std::ranges::reverse_view(debugFrame.groups)) {
|
||||
Log.warn("Debug group was not popped at end of frame: {}", it);
|
||||
}
|
||||
g_debugGroupStack.clear();
|
||||
debugFrame.groups.clear();
|
||||
}
|
||||
|
||||
if (finalize && g_debugMarkers.size() > 0) {
|
||||
g_debugMarkers.clear();
|
||||
if (finalize && debugFrame.markers.size() > 0) {
|
||||
debugFrame.markers.clear();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void seal_frame(SealedFrame& out) noexcept {
|
||||
out.data().depthMapping = depth_peek::capture_frame_mapping();
|
||||
ZoneScoped;
|
||||
// The encode that could still have been holding these has completed: the
|
||||
// producer joins the worker's DONE phase before it seals another frame.
|
||||
@@ -1350,15 +1476,24 @@ void seal_frame(SealedFrame& out) noexcept {
|
||||
// capacity included, back to the producer.
|
||||
recycle_render_passes(passes);
|
||||
passes.swap(g_renderPasses);
|
||||
#ifdef AURORA_GFX_DEBUG_GROUPS
|
||||
// Marker indices and unmatched-group warnings belong to these detached passes.
|
||||
// The next producer frame must not modify strings still read by this encoder.
|
||||
auto& debug = out.data().debug;
|
||||
debug.groups.clear();
|
||||
debug.markers.clear();
|
||||
debug.groups.swap(g_debugFrame.groups);
|
||||
debug.markers.swap(g_debugFrame.markers);
|
||||
#endif
|
||||
g_currentRenderPass = UINT32_MAX;
|
||||
}
|
||||
|
||||
void render(SealedFrame& frame, wgpu::CommandEncoder& cmd, int32_t interpolatedFrame, bool finalize) {
|
||||
render_impl(frame.data().passes, cmd, interpolatedFrame, finalize);
|
||||
render_impl(frame.data().passes, cmd, interpolatedFrame, finalize, frame.data().debug, frame.data().depthMapping);
|
||||
}
|
||||
|
||||
void render(wgpu::CommandEncoder& cmd, int32_t interpolatedFrame, bool finalize) {
|
||||
render_impl(g_renderPasses, cmd, interpolatedFrame, finalize);
|
||||
render_impl(g_renderPasses, cmd, interpolatedFrame, finalize, g_debugFrame, depth_peek::capture_frame_mapping());
|
||||
if (finalize) {
|
||||
g_currentRenderPass = UINT32_MAX;
|
||||
expire_bind_group_cache();
|
||||
@@ -1376,7 +1511,7 @@ void after_submit() noexcept {
|
||||
}
|
||||
|
||||
static void render_pass_impl(const wgpu::RenderPassEncoder& pass, const std::vector<RenderPass>& renderPasses, u32 idx,
|
||||
int32_t interpolatedFrame) {
|
||||
int32_t interpolatedFrame, DebugFrameData& debugFrame) {
|
||||
// Per-invocation, not per-process: two encoders can be recording at once.
|
||||
gx::DrawEncodeState encodeState{};
|
||||
encodeState.boundTextureBindGroup = gx::g_emptyTextureBindGroup.Get();
|
||||
@@ -1410,10 +1545,19 @@ static void render_pass_impl(const wgpu::RenderPassEncoder& pass, const std::vec
|
||||
switch (cmd.type) {
|
||||
case CommandType::SetViewport: {
|
||||
const auto& vp = cmd.data.setViewport;
|
||||
// WebGPU requires 0 <= minDepth <= maxDepth <= 1, and the guest's (near, far) order is already
|
||||
// reproduced in clip space. Passing the raw swapped pair diverged per backend in release builds.
|
||||
const float minDepth = std::clamp(std::min(vp.znear, vp.zfar), 0.0f, 1.0f);
|
||||
const float maxDepth = std::clamp(std::max(vp.znear, vp.zfar), 0.0f, 1.0f);
|
||||
// WebGPU requires 0 <= minDepth <= maxDepth <= 1. vp.znear/vp.zfar are in GX's own distance
|
||||
// terms (0 = near); under UseReversedZ the host depth-buffer storage direction is flipped
|
||||
// (near = 1, far = 0), so this range has to be remapped through 1-x the same way the
|
||||
// projection matrix, depth compare function, and clear value all are - a plain min/max clamp
|
||||
// (the previous code here) maps a *restricted* range (e.g. a viewport deliberately narrowed
|
||||
// to force something to draw "in front of everything") to the wrong end of the buffer: what
|
||||
// should land near the near-storage-extreme (1.0) instead lands near the far-storage-extreme
|
||||
// (0.0), so anything else drawn afterward at its true depth wins the compare test and the
|
||||
// "in front" geometry silently vanishes. A full [0,1] viewport is unaffected either way,
|
||||
// which is why this only broke specific elements, not the whole scene. Matches upstream
|
||||
// aurora's apply_viewport (lib/gfx/encoding.cpp) exactly.
|
||||
const float minDepth = gx::UseReversedZ ? 1.0f - vp.zfar : vp.znear;
|
||||
const float maxDepth = gx::UseReversedZ ? 1.0f - vp.znear : vp.zfar;
|
||||
pass.SetViewport(vp.left, vp.top, vp.width, vp.height, minDepth, maxDepth);
|
||||
} break;
|
||||
case CommandType::SetScissor: {
|
||||
@@ -1447,7 +1591,7 @@ static void render_pass_impl(const wgpu::RenderPassEncoder& pass, const std::vec
|
||||
} break;
|
||||
case CommandType::DebugMarker: {
|
||||
#if defined(AURORA_GFX_DEBUG_GROUPS)
|
||||
pass.InsertDebugMarker(wgpu::StringView(g_debugMarkers[cmd.data.debugMarkerIndex]));
|
||||
pass.InsertDebugMarker(wgpu::StringView(debugFrame.markers[cmd.data.debugMarkerIndex]));
|
||||
#endif
|
||||
} break;
|
||||
}
|
||||
@@ -1470,8 +1614,8 @@ bool bind_pipeline(PipelineRef ref, const wgpu::RenderPassEncoder& pass, Pipelin
|
||||
if (!skip_unready_pipelines()) {
|
||||
pipelineReady = wait_pipeline(ref, pipeline);
|
||||
} else if (requireReady) {
|
||||
// The pass resolves into a persistent texture (a one-shot bake such as MKW's minimap), so a
|
||||
// skipped draw would never be re-issued. These run behind loads, not mid-race.
|
||||
// Texture copies and capacity prefixes must retain complete draw results.
|
||||
// A future display frame cannot repair a texture that already captured them.
|
||||
pipelineReady = wait_pipeline_for_persistent_pass(ref, pipeline);
|
||||
} else {
|
||||
pipelineReady = try_pipeline(ref, pipeline);
|
||||
@@ -1600,8 +1744,8 @@ uint32_t align_uniform(uint32_t value) { return AURORA_ALIGN(value, g_cachedLimi
|
||||
|
||||
void insert_debug_marker(std::string label) {
|
||||
#if defined(AURORA_GFX_DEBUG_GROUPS)
|
||||
auto idx = g_debugMarkers.size();
|
||||
g_debugMarkers.emplace_back(std::move(label));
|
||||
auto idx = g_debugFrame.markers.size();
|
||||
g_debugFrame.markers.emplace_back(std::move(label));
|
||||
push_command(CommandType::DebugMarker, {.debugMarkerIndex = idx});
|
||||
#endif
|
||||
}
|
||||
@@ -1610,22 +1754,22 @@ void insert_debug_marker(std::string label) {
|
||||
|
||||
void aurora::gfx::push_debug_group(std::string label) {
|
||||
#if defined(AURORA_GFX_DEBUG_GROUPS)
|
||||
g_debugGroupStack.push_back(std::move(label));
|
||||
g_debugFrame.groups.push_back(std::move(label));
|
||||
#endif
|
||||
}
|
||||
void aurora_push_debug_group(const char* label) {
|
||||
#ifdef AURORA_GFX_DEBUG_GROUPS
|
||||
aurora::gfx::g_debugGroupStack.emplace_back(label);
|
||||
aurora::gfx::g_debugFrame.groups.emplace_back(label);
|
||||
#endif
|
||||
}
|
||||
void aurora_pop_debug_group() {
|
||||
#ifdef AURORA_GFX_DEBUG_GROUPS
|
||||
if (aurora::gfx::g_debugGroupStack.empty()) {
|
||||
if (aurora::gfx::g_debugFrame.groups.empty()) {
|
||||
aurora::gfx::Log.error("Debug group stack underflowed!");
|
||||
return;
|
||||
}
|
||||
|
||||
aurora::gfx::g_debugGroupStack.pop_back();
|
||||
aurora::gfx::g_debugFrame.groups.pop_back();
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#pragma once
|
||||
#include "staging_capacity.hpp"
|
||||
|
||||
#include "../internal.hpp"
|
||||
#include "../webgpu/gpu.hpp"
|
||||
@@ -394,6 +395,20 @@ wgpu::BindGroup& find_bind_group(BindGroupRef id);
|
||||
wgpu::Sampler& sampler_ref(const wgpu::SamplerDescriptor& descriptor);
|
||||
|
||||
uint32_t align_uniform(uint32_t value);
|
||||
uint64_t staging_uniform_bytes(uint64_t bytes);
|
||||
uint64_t staging_storage_bytes(uint64_t bytes);
|
||||
// Admission does not allocate. A false result requires a producer-side split.
|
||||
// Oversized operations fail before mutating the current draw/pass.
|
||||
bool staging_has_space(const StagingSizes& demand);
|
||||
void ensure_staging_space(const StagingSizes& demand);
|
||||
void split_staging_batch();
|
||||
uint64_t staging_epoch() noexcept;
|
||||
StagingSizes staging_usage() noexcept;
|
||||
StagingSizes staging_high_water() noexcept;
|
||||
uint64_t staging_split_count() noexcept;
|
||||
// Internal integration-test seam: never increases the physical allocation.
|
||||
void set_staging_capacity_limits_for_testing(const StagingSizes& limits);
|
||||
|
||||
|
||||
Vec2<uint32_t> get_render_target_size() noexcept;
|
||||
// Same value as get_render_target_size() outside a render pass, but never
|
||||
|
||||
@@ -92,7 +92,7 @@ struct Params {
|
||||
|
||||
constexpr std::string_view ReversedZBody = R"(
|
||||
fn gx_z24(depth: f32) -> u32 {
|
||||
return min(u32(clamp(depth, 0.0, 1.0) * 16777216.0), 0x00ffffffu);
|
||||
return min(u32(clamp(1.0 - depth, 0.0, 1.0) * 16777215.0 + 0.5), 0x00ffffffu);
|
||||
}
|
||||
)"sv;
|
||||
|
||||
@@ -196,7 +196,8 @@ wgpu::BindGroupLayout create_bind_group_layout(const char* label) {
|
||||
return g_device.CreateBindGroupLayout(&descriptor);
|
||||
}
|
||||
|
||||
Params make_params(wgpu::Extent3D sourceSize, Vec2<uint32_t> dstSize) noexcept {
|
||||
Params make_params(wgpu::Extent3D sourceSize, const FrameMapping& mapping) noexcept {
|
||||
const auto dstSize = mapping.logicalSize;
|
||||
Params params{
|
||||
.dstWidth = dstSize.x,
|
||||
.dstHeight = dstSize.y,
|
||||
@@ -204,16 +205,16 @@ Params make_params(wgpu::Extent3D sourceSize, Vec2<uint32_t> dstSize) noexcept {
|
||||
.srcHeight = sourceSize.height,
|
||||
};
|
||||
|
||||
if (gx::g_gxState.viewportPolicy == AURORA_VIEWPORT_NATIVE) {
|
||||
if (mapping.viewportPolicy == AURORA_VIEWPORT_NATIVE) {
|
||||
return params;
|
||||
}
|
||||
|
||||
const auto logicalSize = vi::configured_fb_size();
|
||||
const auto logicalSize = mapping.logicalSize;
|
||||
if (logicalSize.x == 0 || logicalSize.y == 0 || sourceSize.width == 0 || sourceSize.height == 0) {
|
||||
return params;
|
||||
}
|
||||
|
||||
const bool stretch = gx::g_gxState.viewportPolicy == AURORA_VIEWPORT_STRETCH;
|
||||
const bool stretch = mapping.viewportPolicy == AURORA_VIEWPORT_STRETCH;
|
||||
const float scaleX = static_cast<float>(sourceSize.width) / static_cast<float>(logicalSize.x);
|
||||
const float scaleY = static_cast<float>(sourceSize.height) / static_cast<float>(logicalSize.y);
|
||||
const float scale = std::min(scaleX, scaleY);
|
||||
@@ -336,8 +337,12 @@ void poll() noexcept {
|
||||
}
|
||||
}
|
||||
|
||||
FrameMapping capture_frame_mapping() noexcept {
|
||||
return {vi::configured_fb_size(), gx::g_gxState.viewportPolicy};
|
||||
}
|
||||
|
||||
void encode_frame_snapshot(const wgpu::CommandEncoder& cmd, const wgpu::TextureView& depthView,
|
||||
wgpu::Extent3D sourceSize, uint32_t msaaSamples) noexcept {
|
||||
wgpu::Extent3D sourceSize, uint32_t msaaSamples, const FrameMapping& mapping) noexcept {
|
||||
ZoneScoped;
|
||||
const auto now = Clock::now();
|
||||
{
|
||||
@@ -349,7 +354,7 @@ void encode_frame_snapshot(const wgpu::CommandEncoder& cmd, const wgpu::TextureV
|
||||
g_nextSnapshotTime = now + SnapshotInterval;
|
||||
}
|
||||
|
||||
const auto dstSize = vi::configured_fb_size();
|
||||
const auto dstSize = mapping.logicalSize;
|
||||
if (!depthView || dstSize.x == 0 || dstSize.y == 0 || sourceSize.width == 0 || sourceSize.height == 0) {
|
||||
return;
|
||||
}
|
||||
@@ -357,7 +362,7 @@ void encode_frame_snapshot(const wgpu::CommandEncoder& cmd, const wgpu::TextureV
|
||||
Log.fatal("Depth Peek from multisampled EFB targets is not supported");
|
||||
}
|
||||
|
||||
const Params params = make_params(sourceSize, dstSize);
|
||||
const Params params = make_params(sourceSize, mapping);
|
||||
wgpu::Buffer storageBuffer;
|
||||
wgpu::Buffer readbackBuffer;
|
||||
wgpu::Buffer paramsBuffer;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "common.hpp"
|
||||
#include <dolphin/gx/GXAurora.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
@@ -13,8 +14,15 @@ void request_snapshot() noexcept;
|
||||
bool read_latest(uint16_t x, uint16_t y, uint32_t& z) noexcept;
|
||||
void poll() noexcept;
|
||||
|
||||
// Captured before SEALED; the producer may configure the next frame during encode.
|
||||
struct FrameMapping {
|
||||
Vec2<uint32_t> logicalSize{};
|
||||
AuroraViewportPolicy viewportPolicy = AURORA_VIEWPORT_FIT;
|
||||
};
|
||||
FrameMapping capture_frame_mapping() noexcept;
|
||||
|
||||
void encode_frame_snapshot(const wgpu::CommandEncoder& cmd, const wgpu::TextureView& depthView,
|
||||
wgpu::Extent3D sourceSize, uint32_t msaaSamples) noexcept;
|
||||
wgpu::Extent3D sourceSize, uint32_t msaaSamples, const FrameMapping& mapping) noexcept;
|
||||
void after_submit() noexcept;
|
||||
|
||||
namespace testing {
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
@@ -27,7 +29,7 @@ using webgpu::g_instance;
|
||||
constexpr size_t kAsyncReadbackMaxBytes = 256;
|
||||
// Each destination keeps its readback buffer forever. Only a handful are expected, and the cap
|
||||
// stops an unexpected pattern of one-shot destinations from leaking GPU buffers.
|
||||
constexpr size_t kMaxAsyncSlots = 32;
|
||||
constexpr size_t kMaxAsyncSlots = MaxAsyncReadbackSlots;
|
||||
|
||||
struct PendingCopy {
|
||||
void* dest = nullptr;
|
||||
@@ -37,6 +39,7 @@ struct PendingCopy {
|
||||
TextureHandle texture;
|
||||
TextureHandle nativeTexture;
|
||||
Range nativeBlitUniform;
|
||||
uint64_t nativeUniformEpoch = 0;
|
||||
};
|
||||
|
||||
struct Download {
|
||||
@@ -81,6 +84,7 @@ std::vector<PendingCopy> g_asyncSealed;
|
||||
std::mutex g_asyncMutex;
|
||||
std::unordered_map<void*, AsyncSlot> g_asyncSlots;
|
||||
uint32_t g_asyncMapsInFlight = 0;
|
||||
uint64_t g_asyncGeneration = 1;
|
||||
|
||||
uint32_t align_to(uint32_t value, uint32_t alignment) noexcept { return (value + alignment - 1) & ~(alignment - 1); }
|
||||
|
||||
@@ -90,10 +94,10 @@ void ensure_native_texture(PendingCopy& pending, TextureHandle* cache = nullptr)
|
||||
if (pending.texture->size.width == pending.width && pending.texture->size.height == pending.height) {
|
||||
return;
|
||||
}
|
||||
if (pending.nativeTexture && pending.nativeUniformEpoch == staging_epoch()) return;
|
||||
if (pending.nativeTexture) {
|
||||
return;
|
||||
}
|
||||
if (cache != nullptr && *cache && (*cache)->size.width == pending.width &&
|
||||
// Keep the texture; its old staging range belongs to a submitted batch.
|
||||
} else if (cache != nullptr && *cache && (*cache)->size.width == pending.width &&
|
||||
(*cache)->size.height == pending.height) {
|
||||
pending.nativeTexture = *cache;
|
||||
} else {
|
||||
@@ -102,10 +106,12 @@ void ensure_native_texture(PendingCopy& pending, TextureHandle* cache = nullptr)
|
||||
*cache = pending.nativeTexture;
|
||||
}
|
||||
}
|
||||
// The shared blit shader clamps Y to flags.z/w; preserve the full source.
|
||||
const std::array nativeBlitUniform{
|
||||
0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 64.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f,
|
||||
0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 64.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f,
|
||||
};
|
||||
pending.nativeBlitUniform = push_uniform(nativeBlitUniform);
|
||||
pending.nativeUniformEpoch = staging_epoch();
|
||||
}
|
||||
|
||||
void encode_native_blit(const wgpu::CommandEncoder& encoder, const PendingCopy& pending) noexcept {
|
||||
@@ -125,16 +131,17 @@ HostPixelOrder texture_pixel_order(const TextureHandle& texture) noexcept {
|
||||
return texture->format == wgpu::TextureFormat::BGRA8Unorm ? HostPixelOrder::BGRA : HostPixelOrder::RGBA;
|
||||
}
|
||||
|
||||
void complete_async_slot(void* dest, wgpu::MapAsyncStatus status, wgpu::StringView message) noexcept {
|
||||
void complete_async_slot(void* dest, uint64_t generation, wgpu::MapAsyncStatus status,
|
||||
wgpu::StringView message) noexcept {
|
||||
std::lock_guard lock{g_asyncMutex};
|
||||
if (g_asyncMapsInFlight > 0) {
|
||||
--g_asyncMapsInFlight;
|
||||
}
|
||||
if (generation != g_asyncGeneration) return;
|
||||
const auto it = g_asyncSlots.find(dest);
|
||||
if (it == g_asyncSlots.end()) {
|
||||
return;
|
||||
}
|
||||
auto& slot = it->second;
|
||||
if (slot.state != AsyncState::MapPending) return;
|
||||
if (g_asyncMapsInFlight > 0) --g_asyncMapsInFlight;
|
||||
if (status == wgpu::MapAsyncStatus::Success) {
|
||||
const auto* pixels = static_cast<const uint8_t*>(slot.buffer.GetConstMappedRange(0, slot.bufferSize));
|
||||
if (pixels != nullptr) {
|
||||
@@ -227,7 +234,14 @@ bool has_pending(void* dest) noexcept {
|
||||
[dest](const Download& download) { return download.copy.dest == dest; });
|
||||
}
|
||||
|
||||
bool prepare_downloads(void* dest) noexcept {
|
||||
bool prepare_downloads(void* dest) {
|
||||
uint64_t copies = 0;
|
||||
for (const auto& pending : g_pending) {
|
||||
if (dest != nullptr && pending.dest != dest) continue;
|
||||
if (pending.texture->size.width != pending.width || pending.texture->size.height != pending.height) ++copies;
|
||||
}
|
||||
// Reserve all copies, even already-prepared ones: a split retires their ranges.
|
||||
ensure_staging_space({0, copies * staging_uniform_bytes(48), 0, 0});
|
||||
bool found = false;
|
||||
for (auto& pending : g_pending) {
|
||||
if (dest != nullptr && pending.dest != dest) continue;
|
||||
@@ -290,15 +304,35 @@ void encode_downloads(const wgpu::CommandEncoder& encoder, void* dest) noexcept
|
||||
bool complete_downloads() noexcept {
|
||||
bool success = true;
|
||||
for (auto& download : g_downloads) {
|
||||
wgpu::MapAsyncStatus mapStatus = wgpu::MapAsyncStatus::CallbackCancelled;
|
||||
wgpu::StringView mapMessage{};
|
||||
// WaitAny may time out before Dawn delivers cancellation. The callback must
|
||||
// own its result rather than retaining references to this stack frame.
|
||||
struct MapResult {
|
||||
std::mutex mutex;
|
||||
wgpu::MapAsyncStatus status = wgpu::MapAsyncStatus::CallbackCancelled;
|
||||
std::string message;
|
||||
};
|
||||
const auto result = std::make_shared<MapResult>();
|
||||
const auto future =
|
||||
download.buffer.MapAsync(wgpu::MapMode::Read, 0, download.bufferSize, wgpu::CallbackMode::WaitAnyOnly,
|
||||
[&mapStatus, &mapMessage](wgpu::MapAsyncStatus status, wgpu::StringView message) {
|
||||
mapStatus = status;
|
||||
mapMessage = message;
|
||||
[result](wgpu::MapAsyncStatus status, wgpu::StringView message) {
|
||||
std::lock_guard lock{result->mutex};
|
||||
result->status = status;
|
||||
if (message.data != nullptr) {
|
||||
size_t length = 0;
|
||||
while (length < 512 && length < message.length && message.data[length] != '\0') {
|
||||
++length;
|
||||
}
|
||||
result->message.assign(message.data, length);
|
||||
}
|
||||
});
|
||||
const auto waitStatus = g_instance.WaitAny(future, 5000000000);
|
||||
wgpu::MapAsyncStatus mapStatus;
|
||||
std::string mapMessage;
|
||||
{
|
||||
std::lock_guard lock{result->mutex};
|
||||
mapStatus = result->status;
|
||||
mapMessage = result->message;
|
||||
}
|
||||
if (waitStatus != wgpu::WaitStatus::Success || mapStatus != wgpu::MapAsyncStatus::Success) {
|
||||
Log.error("EFB RAM readback failed wait={} map={} message={}", magic_enum::enum_name(waitStatus),
|
||||
magic_enum::enum_name(mapStatus), mapMessage);
|
||||
@@ -412,6 +446,7 @@ void after_submit() noexcept {
|
||||
void* dest;
|
||||
wgpu::Buffer buffer;
|
||||
uint64_t bufferSize;
|
||||
uint64_t generation;
|
||||
};
|
||||
std::vector<PendingMap> pendingMaps;
|
||||
{
|
||||
@@ -422,14 +457,15 @@ void after_submit() noexcept {
|
||||
}
|
||||
slot.state = AsyncState::MapPending;
|
||||
++g_asyncMapsInFlight;
|
||||
pendingMaps.push_back({dest, slot.buffer, slot.bufferSize});
|
||||
pendingMaps.push_back({dest, slot.buffer, slot.bufferSize, g_asyncGeneration});
|
||||
}
|
||||
}
|
||||
|
||||
for (const auto& pending : pendingMaps) {
|
||||
pending.buffer.MapAsync(wgpu::MapMode::Read, 0, pending.bufferSize, wgpu::CallbackMode::AllowSpontaneous,
|
||||
[dest = pending.dest](wgpu::MapAsyncStatus status, wgpu::StringView message) {
|
||||
complete_async_slot(dest, status, message);
|
||||
[dest = pending.dest, generation = pending.generation](wgpu::MapAsyncStatus status,
|
||||
wgpu::StringView message) {
|
||||
complete_async_slot(dest, generation, status, message);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -443,9 +479,15 @@ void abort_async() noexcept { g_asyncSealed.clear(); }
|
||||
void shutdown() noexcept {
|
||||
cancel();
|
||||
g_asyncSealed.clear();
|
||||
std::lock_guard lock{g_asyncMutex};
|
||||
g_asyncSlots.clear();
|
||||
g_asyncMapsInFlight = 0;
|
||||
// Retire callbacks before releasing buffers, and release outside their mutex:
|
||||
// destruction may itself deliver an AllowSpontaneous cancellation callback.
|
||||
decltype(g_asyncSlots) retiredSlots;
|
||||
{
|
||||
std::lock_guard lock{g_asyncMutex};
|
||||
++g_asyncGeneration;
|
||||
retiredSlots.swap(g_asyncSlots);
|
||||
g_asyncMapsInFlight = 0;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace aurora::gfx::efb_ram
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
|
||||
namespace aurora::gfx::efb_ram {
|
||||
|
||||
inline constexpr size_t MaxAsyncReadbackSlots = 32;
|
||||
|
||||
void schedule(void* dest, uint32_t width, uint32_t height, GXTexFmt format, TextureHandle texture) noexcept;
|
||||
bool has_pending(void* dest = nullptr) noexcept;
|
||||
bool prepare_downloads(void* dest = nullptr) noexcept;
|
||||
bool prepare_downloads(void* dest = nullptr);
|
||||
void encode_downloads(const wgpu::CommandEncoder& encoder, void* dest = nullptr) noexcept;
|
||||
bool complete_downloads() noexcept;
|
||||
void cancel() noexcept;
|
||||
|
||||
@@ -396,6 +396,7 @@ static PendingPipeline* touch_pending_pipeline(PipelineRef hash, bool prioritize
|
||||
|
||||
g_priorityPipelines.emplace_back(std::move(*backgroundIt));
|
||||
g_backgroundPipelines.erase(backgroundIt);
|
||||
g_pipelineCv.notify_all();
|
||||
return &g_priorityPipelines.back();
|
||||
}
|
||||
|
||||
@@ -530,7 +531,8 @@ static PipelineRef find_pipeline_impl(ShaderType type, const PipelineConfig& con
|
||||
}
|
||||
|
||||
if (notifyWorker) {
|
||||
g_pipelineCv.notify_one();
|
||||
// Compiler workers and renderer waiters share this condition variable.
|
||||
g_pipelineCv.notify_all();
|
||||
}
|
||||
if (notifyWaiters) {
|
||||
g_pipelineCv.notify_all();
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace aurora::gfx {
|
||||
// Byte counts after each allocation's own trailing alignment, in V/U/I/S order.
|
||||
using StagingSizes = std::array<uint64_t, 4>;
|
||||
class StagingCapacityError : public std::runtime_error {
|
||||
public:
|
||||
using std::runtime_error::runtime_error;
|
||||
};
|
||||
struct StagingBatchFull {};
|
||||
inline uint64_t staging_padded(uint64_t bytes, uint64_t alignment) {
|
||||
if (!bytes) return alignment;
|
||||
const auto remainder = alignment ? bytes % alignment : 0;
|
||||
const auto padding = remainder ? alignment - remainder : 0;
|
||||
if (bytes > UINT64_MAX - padding) throw StagingCapacityError("Staging allocation size overflow");
|
||||
return bytes + padding;
|
||||
}
|
||||
inline bool staging_fits(const StagingSizes& used, const StagingSizes& demand,
|
||||
const StagingSizes& tail, const StagingSizes& capacity) noexcept {
|
||||
for (unsigned i = 0; i < used.size(); ++i) {
|
||||
const auto limit = capacity[i] < UINT32_MAX ? capacity[i] : UINT32_MAX;
|
||||
// The final GPU copy rounds to four bytes. Subtractions avoid wraparound.
|
||||
const auto alignedLimit = limit & ~uint64_t(3);
|
||||
if (tail[i] > alignedLimit || used[i] > alignedLimit - tail[i] ||
|
||||
demand[i] > alignedLimit - tail[i] - used[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} // namespace aurora::gfx
|
||||
@@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
|
||||
namespace aurora::gfx {
|
||||
|
||||
enum class BufferMapState { Unmapped, Mapping, Mapped };
|
||||
|
||||
// The renderer owns request/reset; Dawn may complete a request on another thread.
|
||||
// An old callback must never publish readiness for a different staging slot.
|
||||
class StagingMapState {
|
||||
mutable std::mutex mutex_;
|
||||
std::condition_variable changed_;
|
||||
uint64_t generation_ = 0;
|
||||
BufferMapState state_ = BufferMapState::Unmapped;
|
||||
|
||||
public:
|
||||
uint64_t request() {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (state_ != BufferMapState::Unmapped) return 0;
|
||||
state_ = BufferMapState::Mapping;
|
||||
return ++generation_;
|
||||
}
|
||||
|
||||
bool complete(uint64_t generation, BufferMapState state) {
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
if (generation != generation_ || state_ != BufferMapState::Mapping) return false;
|
||||
state_ = state;
|
||||
}
|
||||
changed_.notify_all();
|
||||
return true;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
++generation_;
|
||||
state_ = BufferMapState::Unmapped;
|
||||
}
|
||||
changed_.notify_all();
|
||||
}
|
||||
|
||||
BufferMapState state() const {
|
||||
std::lock_guard lock(mutex_);
|
||||
return state_;
|
||||
}
|
||||
|
||||
void wait_for_progress() {
|
||||
std::unique_lock lock(mutex_);
|
||||
// ProcessEvents is still serviced between waits for implementations that
|
||||
// need it. A spontaneous completion wakes immediately, without polling.
|
||||
changed_.wait_for(lock, std::chrono::milliseconds(1),
|
||||
[&] { return state_ != BufferMapState::Mapping; });
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace aurora::gfx
|
||||
@@ -137,7 +137,7 @@ fn gx_z24_at_coord(unclamped_coord: vec2i) -> u32 {
|
||||
let tex_size = vec2i(textureDimensions(src));
|
||||
let coord = clamp(unclamped_coord, vec2i(0), tex_size - vec2i(1));
|
||||
let depth = textureLoad(src, coord, 0);
|
||||
return min(u32(clamp(depth, 0.0, 1.0) * 16777216.0), 0x00ffffffu);
|
||||
return min(u32(clamp(1.0 - depth, 0.0, 1.0) * 16777215.0 + 0.5), 0x00ffffffu);
|
||||
}
|
||||
)"s
|
||||
: R"(
|
||||
@@ -373,7 +373,7 @@ static wgpu::BindGroupLayout g_depthBindGroupLayout;
|
||||
static wgpu::Sampler g_nearestSampler;
|
||||
static wgpu::Sampler g_linearSampler;
|
||||
static absl::flat_hash_map<GXTexFmt, wgpu::RenderPipeline> g_pipelines;
|
||||
static wgpu::RenderPipeline g_blitPipeline;
|
||||
static absl::flat_hash_map<wgpu::TextureFormat, wgpu::RenderPipeline> g_blitPipelines;
|
||||
|
||||
static wgpu::RenderPipeline create_pipeline(const ConvPipeline& conv, const std::string_view shaderPreamble,
|
||||
const wgpu::BindGroupLayout& bindGroupLayout) {
|
||||
@@ -487,9 +487,12 @@ void initialize() {
|
||||
};
|
||||
g_depthBindGroupLayout = g_device.CreateBindGroupLayout(&depthBindGroupLayoutDescriptor);
|
||||
|
||||
g_blitPipeline = create_pipeline(
|
||||
{GX_TF_RGBA8, FragPassthrough, webgpu::g_graphicsConfig.surfaceConfiguration.format, "TexCopyConv Blit"},
|
||||
ShaderPreamble, g_bindGroupLayout);
|
||||
// Native RAM readback uses RGBA even when the EFB/surface uses BGRA.
|
||||
// Build both variants here; frame workers only read the completed map.
|
||||
for (const auto format : {wgpu::TextureFormat::RGBA8Unorm, wgpu::TextureFormat::BGRA8Unorm}) {
|
||||
g_blitPipelines[format] = create_pipeline(
|
||||
{GX_TF_RGBA8, FragPassthrough, format, "TexCopyConv Blit"}, ShaderPreamble, g_bindGroupLayout);
|
||||
}
|
||||
for (const auto& conv : ConvPipelines) {
|
||||
g_pipelines[conv.fmt] = create_pipeline(conv, ShaderPreamble, g_bindGroupLayout);
|
||||
if (conv.outputFormat != to_wgpu(conv.fmt)) {
|
||||
@@ -520,7 +523,7 @@ void initialize() {
|
||||
|
||||
void shutdown() {
|
||||
g_pipelines.clear();
|
||||
g_blitPipeline = {};
|
||||
g_blitPipelines.clear();
|
||||
g_bindGroupLayout = {};
|
||||
g_depthBindGroupLayout = {};
|
||||
g_nearestSampler = {};
|
||||
@@ -602,6 +605,12 @@ void run(const wgpu::CommandEncoder& cmd, const ConvRequest& req) {
|
||||
execute(cmd, req, it->second);
|
||||
}
|
||||
|
||||
void blit(const wgpu::CommandEncoder& cmd, const ConvRequest& req) { execute(cmd, req, g_blitPipeline); }
|
||||
void blit(const wgpu::CommandEncoder& cmd, const ConvRequest& req) {
|
||||
const auto it = g_blitPipelines.find(req.dst->format);
|
||||
if (it == g_blitPipelines.end()) {
|
||||
Log.fatal("Unsupported blit destination format {}", static_cast<int>(req.dst->format));
|
||||
}
|
||||
execute(cmd, req, it->second);
|
||||
}
|
||||
|
||||
} // namespace aurora::gfx::tex_copy_conv
|
||||
|
||||
@@ -30,10 +30,11 @@ using IndexBuffer = std::vector<u16>;
|
||||
static u32 prepare_idx_template(IndexBuffer& buf, GXPrimitive prim, u16 vtxCount) {
|
||||
size_t writePos = 0;
|
||||
if (prim == GX_QUADS) {
|
||||
// Retain the existing incomplete-quad behavior: every started group emits a complete six-index quad.
|
||||
buf.resize(((static_cast<u32>(vtxCount) + 3u) / 4u) * 6u);
|
||||
// GX renders a three-vertex remainder as a triangle. One/two are ignored.
|
||||
const u32 completeVertices = static_cast<u32>(vtxCount) & ~3u;
|
||||
buf.resize((completeVertices / 4u) * 6u + (vtxCount % 4u == 3u ? 3u : 0u));
|
||||
|
||||
for (u16 v = 0; v < vtxCount; v += 4) {
|
||||
for (u32 v = 0; v < completeVertices; v += 4) {
|
||||
const u16 idx0 = v;
|
||||
const u16 idx1 = static_cast<u16>(v + 1);
|
||||
const u16 idx2 = static_cast<u16>(v + 2);
|
||||
@@ -45,15 +46,21 @@ static u32 prepare_idx_template(IndexBuffer& buf, GXPrimitive prim, u16 vtxCount
|
||||
buf[writePos++] = idx3;
|
||||
buf[writePos++] = idx0;
|
||||
}
|
||||
if (vtxCount % 4u == 3u) {
|
||||
buf[writePos++] = static_cast<u16>(completeVertices);
|
||||
buf[writePos++] = static_cast<u16>(completeVertices + 1u);
|
||||
buf[writePos++] = static_cast<u16>(completeVertices + 2u);
|
||||
}
|
||||
} else if (prim == GX_TRIANGLES) {
|
||||
buf.resize(vtxCount);
|
||||
for (u16 v = 0; v < vtxCount; ++v) {
|
||||
const u32 completeVertices = (static_cast<u32>(vtxCount) / 3u) * 3u;
|
||||
buf.resize(completeVertices);
|
||||
for (u32 v = 0; v < completeVertices; ++v) {
|
||||
buf[writePos++] = v;
|
||||
}
|
||||
} else if (prim == GX_TRIANGLEFAN) {
|
||||
const u32 indexCount = vtxCount <= 3 ? vtxCount : 3u + (static_cast<u32>(vtxCount) - 3u) * 3u;
|
||||
const u32 indexCount = vtxCount < 3 ? 0u : (static_cast<u32>(vtxCount) - 2u) * 3u;
|
||||
buf.resize(indexCount);
|
||||
for (u16 v = 0; v < vtxCount; ++v) {
|
||||
for (u32 v = 0; indexCount != 0 && v < vtxCount; ++v) {
|
||||
if (v < 3) {
|
||||
buf[writePos++] = v;
|
||||
continue;
|
||||
@@ -63,9 +70,9 @@ static u32 prepare_idx_template(IndexBuffer& buf, GXPrimitive prim, u16 vtxCount
|
||||
buf[writePos++] = v;
|
||||
}
|
||||
} else if (prim == GX_TRIANGLESTRIP) {
|
||||
const u32 indexCount = vtxCount <= 3 ? vtxCount : 3u + (static_cast<u32>(vtxCount) - 3u) * 3u;
|
||||
const u32 indexCount = vtxCount < 3 ? 0u : (static_cast<u32>(vtxCount) - 2u) * 3u;
|
||||
buf.resize(indexCount);
|
||||
for (u16 v = 0; v < vtxCount; ++v) {
|
||||
for (u32 v = 0; indexCount != 0 && v < vtxCount; ++v) {
|
||||
if (v < 3) {
|
||||
buf[writePos++] = v;
|
||||
continue;
|
||||
@@ -88,6 +95,13 @@ static u32 prepare_idx_template(IndexBuffer& buf, GXPrimitive prim, u16 vtxCount
|
||||
return static_cast<u32>(writePos);
|
||||
}
|
||||
|
||||
// Empty/incomplete draws consume FIFO bytes but cannot produce a primitive.
|
||||
static bool has_complete_primitive(GXPrimitive prim, u16 count) {
|
||||
if (prim == GX_POINTS) return count >= 1;
|
||||
if (prim == GX_LINES || prim == GX_LINESTRIP) return count >= 2;
|
||||
return count >= 3;
|
||||
}
|
||||
|
||||
// GX FIFO opcodes - use CP_ prefix to avoid clashing with GXCommandList.h macros
|
||||
static constexpr u8 CP_CMD_NOP = GX_NOP;
|
||||
static constexpr u8 CP_CMD_LOAD_CP_REG = GX_LOAD_CP_REG;
|
||||
@@ -466,13 +480,14 @@ static void handle_xf(const u8* data, u32& pos, u32 size, bool bigEndian);
|
||||
static bool handle_draw(u8 cmd, const u8* data, u32& pos, u32 size, bool bigEndian);
|
||||
static bool handle_aurora(const u8* data, u32& pos, u32 size, bool bigEndian);
|
||||
|
||||
void process(const u8* data, u32 size, bool bigEndian) {
|
||||
uint32_t process(const u8* data, u32 size, bool bigEndian) {
|
||||
ZoneScoped;
|
||||
// Everything decoded here mutates renderer state (GX state, the recorded command lists and the mapped staging buffers), so take the renderer GPU mutex once for the whole drain rather than once per draw command.
|
||||
std::lock_guard gpuLock(aurora::renderer_gpu_mutex());
|
||||
u32 pos = 0;
|
||||
|
||||
while (pos < size) {
|
||||
const u32 commandStart = pos;
|
||||
u8 cmd = data[pos++];
|
||||
u8 opcode = cmd & CP_OPCODE_MASK;
|
||||
// Log.warn("Processing opcode {:02x} at pos {} (size {})", opcode, pos - 1, size);
|
||||
@@ -551,12 +566,16 @@ void process(const u8* data, u32 size, bool bigEndian) {
|
||||
for (int i = GX_VA_POS; i <= GX_VA_TEX7; ++i) {
|
||||
g_gxState.arrays[i].cachedRange = {};
|
||||
}
|
||||
// A merged draw retains its previous array uploads. Force a new draw so
|
||||
// handle_draw_unmerged observes the invalidation and uploads fresh data.
|
||||
// Pipeline configuration itself did not change.
|
||||
g_gxState.stateDirty = true;
|
||||
break;
|
||||
}
|
||||
|
||||
case GX_LOAD_AURORA: {
|
||||
if (!handle_aurora(data, pos, size, bigEndian)) {
|
||||
return;
|
||||
return size;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -564,8 +583,10 @@ void process(const u8* data, u32 size, bool bigEndian) {
|
||||
default:
|
||||
// Draw commands occupy the full 0x80-0xBF range.
|
||||
if (is_draw_cmd(cmd)) {
|
||||
if (!handle_draw(cmd, data, pos, size, bigEndian)) {
|
||||
return;
|
||||
try {
|
||||
if (!handle_draw(cmd, data, pos, size, bigEndian)) return size;
|
||||
} catch (const gfx::StagingBatchFull&) {
|
||||
return commandStart;
|
||||
}
|
||||
} else {
|
||||
static u32 unknownLogCount = 0;
|
||||
@@ -588,6 +609,7 @@ void process(const u8* data, u32 size, bool bigEndian) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
// Helper to extract bit fields from a 32-bit register
|
||||
@@ -1848,6 +1870,10 @@ static u32 calculate_last_vtx_size(GXVtxFmt fmt) {
|
||||
|
||||
g_gxState.lastVtxFmt = fmt;
|
||||
g_gxState.lastVtxSize = vtxSize;
|
||||
// The format is selected by the draw opcode, without a register write.
|
||||
// Even equal-stride formats may decode bytes differently, so do not merge
|
||||
// into a draw using the previous format's shader and uniform layout.
|
||||
g_gxState.stateDirty = true;
|
||||
|
||||
return vtxSize;
|
||||
}
|
||||
@@ -2080,6 +2106,22 @@ static const CachedPipelineState& resolve_pipeline_state(GXPrimitive prim, GXVtx
|
||||
return state;
|
||||
}
|
||||
|
||||
static bool admit_draw(GXPrimitive prim, GXVtxFmt fmt, u16 count, uint32_t vertexBytes, bool merged = false) {
|
||||
const auto& indexTemplate = cached_index_template(prim, count);
|
||||
gfx::StagingSizes demand{vertexBytes, 0, indexTemplate.indices.size() * sizeof(u16), 0};
|
||||
if (merged) return gfx::staging_has_space(demand);
|
||||
const auto& info = resolve_pipeline_state(prim, fmt).shaderInfo;
|
||||
demand[1] = gfx::staging_uniform_bytes(info.uniformSize);
|
||||
if (frame_interpolation_identity_needed() && frame_interpolation_replay_safe())
|
||||
demand[1] *= 1 + MaxInterpolatedFrames;
|
||||
for (int i = GX_VA_POS; i <= GX_VA_TEX7; ++i) {
|
||||
if ((g_gxState.vtxDesc[i] == GX_INDEX8 || g_gxState.vtxDesc[i] == GX_INDEX16) &&
|
||||
g_gxState.arrays[i].cachedRange.size == 0)
|
||||
demand[3] += gfx::staging_storage_bytes(g_gxState.arrays[i].size);
|
||||
}
|
||||
return gfx::staging_has_space(demand);
|
||||
}
|
||||
|
||||
bool submit_raw_draw(GXPrimitive prim, GXVtxFmt fmt, const uint8_t* vertices, uint16_t vtxCount,
|
||||
uint32_t vertexBytes) {
|
||||
ZoneScoped;
|
||||
@@ -2112,8 +2154,17 @@ bool submit_raw_draw(GXPrimitive prim, GXVtxFmt fmt, const uint8_t* vertices, ui
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!has_complete_primitive(prim, vtxCount)) return true;
|
||||
|
||||
// This entry point bypasses process(), so it owns the renderer lock itself.
|
||||
std::lock_guard gpuLock(aurora::renderer_gpu_mutex());
|
||||
std::unique_lock gpuLock(aurora::renderer_gpu_mutex());
|
||||
if (!admit_draw(prim, fmt, vtxCount, vertexBytes)) {
|
||||
gpuLock.unlock();
|
||||
gfx::split_staging_batch();
|
||||
gpuLock.lock();
|
||||
if (!admit_draw(prim, fmt, vtxCount, vertexBytes))
|
||||
throw gfx::StagingCapacityError("Raw draw does not fit after capacity submission");
|
||||
}
|
||||
const gfx::Range vertRange = gfx::push_verts(vertices, vertexBytes);
|
||||
const bool interpolationIdentityActive = frame_interpolation_identity_needed();
|
||||
const PnMtxUsage matrixUsage = interpolationIdentityActive
|
||||
@@ -2151,17 +2202,32 @@ static bool handle_draw(u8 cmd, const u8* data, u32& pos, u32 size, bool bigEndi
|
||||
}
|
||||
|
||||
|
||||
// Push raw vertex data to buffer
|
||||
const uint8_t* vertices = data + pos;
|
||||
gfx::Range vertRange = gfx::push_verts(vertices, totalVtxBytes);
|
||||
pos += totalVtxBytes;
|
||||
if (!has_complete_primitive(prim, vtxCount)) {
|
||||
pos += totalVtxBytes;
|
||||
return true;
|
||||
}
|
||||
|
||||
DrawData* mergeTarget = nullptr;
|
||||
// Decide admission before allocating anything. The merged path needs only
|
||||
// vertices and indices; it must not resolve pipelines or upload arrays.
|
||||
// Try to merge with previous draw call
|
||||
if (!g_gxState.stateDirty) LIKELY {
|
||||
auto* lastDraw = gfx::get_last_draw_command<DrawData>();
|
||||
// Only if the previous draw call was a single instance draw (no lines/points handling)
|
||||
// Expanded lines/points have different vertex interpretation even with one instance.
|
||||
// Triangle-list output has no restart index; index 65535 is usable.
|
||||
// Overflow would address earlier vertices instead of the appended geometry.
|
||||
if (lastDraw != nullptr && prim != GX_LINES && prim != GX_LINESTRIP && prim != GX_POINTS &&
|
||||
lastDraw->instanceCount == 1) LIKELY {
|
||||
!lastDraw->expandedPrimitive && lastDraw->instanceCount == 1 &&
|
||||
uint64_t(lastDraw->vtxCount) +
|
||||
vtxCount <= 65536u) LIKELY {
|
||||
mergeTarget = lastDraw;
|
||||
}
|
||||
}
|
||||
if (!admit_draw(prim, fmt, vtxCount, totalVtxBytes, mergeTarget != nullptr)) throw gfx::StagingBatchFull{};
|
||||
const uint8_t* vertices = data + pos;
|
||||
gfx::Range vertRange = gfx::push_verts(vertices, totalVtxBytes);
|
||||
pos += totalVtxBytes;
|
||||
if (auto* lastDraw = mergeTarget) {
|
||||
const auto& indexTemplate = cached_index_template(prim, vtxCount);
|
||||
const auto indices = offset_index_template(indexTemplate, lastDraw->vtxCount);
|
||||
const u32 numIndices = indexTemplate.indexCount;
|
||||
@@ -2182,7 +2248,6 @@ static bool handle_draw(u8 cmd, const u8* data, u32& pos, u32 size, bool bigEndi
|
||||
extend_interpolation_draw(pn_mtx_mask(vertices, vtxCount, vtxSize));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const bool interpolationIdentityActive = frame_interpolation_identity_needed();
|
||||
@@ -2278,6 +2343,7 @@ static void handle_draw_unmerged(GXPrimitive prim, GXVtxFmt fmt, u16 vtxCount,
|
||||
.vtxCount = vtxCount,
|
||||
.indexCount = numIndices,
|
||||
.instanceCount = instanceCount,
|
||||
.expandedPrimitive = prim == GX_LINES || prim == GX_LINESTRIP || prim == GX_POINTS,
|
||||
.bindGroups = bindGroups,
|
||||
.dstAlpha = pipelineState.dstAlpha,
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace aurora::gx::fifo {
|
||||
void reset_cp_register_cache();
|
||||
|
||||
// Process a buffer of GX FIFO commands
|
||||
void process(const uint8_t* data, uint32_t size, bool bigEndian);
|
||||
uint32_t process(const uint8_t* data, uint32_t size, bool bigEndian);
|
||||
|
||||
// Submit already-packed direct vertex bytes against the current GX state.
|
||||
bool submit_raw_draw(GXPrimitive prim, GXVtxFmt fmt, const uint8_t* vertices, uint16_t vtxCount,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "fifo.hpp"
|
||||
#include "command_processor.hpp"
|
||||
#include "../gfx/common.hpp"
|
||||
#include "../internal.hpp"
|
||||
|
||||
#include <chrono>
|
||||
@@ -81,7 +82,18 @@ void drain() {
|
||||
if (detail::sBufferSize == 0) {
|
||||
return;
|
||||
}
|
||||
process(detail::sBufferData, detail::sBufferSize, true);
|
||||
uint32_t consumed = 0;
|
||||
bool retried = false;
|
||||
while (consumed < detail::sBufferSize) {
|
||||
const auto count = process(detail::sBufferData + consumed, detail::sBufferSize - consumed, true);
|
||||
if (count == 0 && retried)
|
||||
throw gfx::StagingCapacityError("FIFO draw does not fit after capacity submission");
|
||||
consumed += count;
|
||||
if (consumed == detail::sBufferSize) break;
|
||||
// process returned with its renderer lock released. No recursive drain.
|
||||
gfx::split_staging_batch();
|
||||
retried = true;
|
||||
}
|
||||
detail::sBufferSize = 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -143,6 +143,7 @@ private:
|
||||
};
|
||||
struct FrameTransformSnapshot {
|
||||
Mat4x4<float> projection{};
|
||||
HashType viewportIdentity = 0;
|
||||
Mat3x4<float> position{};
|
||||
Mat3x4<float> normal{};
|
||||
uint16_t usedMatrixMask = 1;
|
||||
@@ -1185,7 +1186,8 @@ void finalize_frame_interpolation() noexcept {
|
||||
if ((transform.usedMatrixMask & (1u << slot)) == 0) {
|
||||
continue;
|
||||
}
|
||||
paletteSlotKeys.push_back({transform.indexedMatrices->slotHash[slot], palette, slot});
|
||||
paletteSlotKeys.push_back({combine_identity(transform.indexedMatrices->slotHash[slot],
|
||||
transform.viewportIdentity), palette, slot});
|
||||
}
|
||||
}
|
||||
std::sort(paletteSlotKeys.begin(), paletteSlotKeys.end(),
|
||||
@@ -1446,10 +1448,21 @@ void extend_interpolation_draw(uint16_t usedPnMtxMask) noexcept {
|
||||
}
|
||||
|
||||
std::array<gfx::Range, MaxInterpolatedFrames> record_interpolation_draw(
|
||||
const FrameInterpolationDrawIdentity& identity, const Mat4x4<float>& projection,
|
||||
const FrameInterpolationDrawIdentity& drawIdentity, const Mat4x4<float>& projection,
|
||||
uint16_t usedPnMtxMask, const InterpolatedUniformLayout& uniformLayout) noexcept {
|
||||
// Split-screen cameras can draw identical meshes in unrelated view spaces.
|
||||
// Scope exact, material-only and sibling-palette history to the guest viewport.
|
||||
// Logical coordinates keep render-scale changes out of the camera identity.
|
||||
const auto& viewport = g_gxState.logicalViewport;
|
||||
const std::array viewportValues{viewport.left, viewport.top, viewport.width,
|
||||
viewport.height, viewport.znear, viewport.zfar};
|
||||
const HashType viewportIdentity = xxh3_hash_s(viewportValues.data(), sizeof(viewportValues));
|
||||
auto identity = drawIdentity;
|
||||
identity.combined = combine_identity(identity.combined, viewportIdentity);
|
||||
identity.pipeline = combine_identity(identity.pipeline, viewportIdentity);
|
||||
FrameTransformSnapshot snapshot{
|
||||
.projection = projection,
|
||||
.viewportIdentity = viewportIdentity,
|
||||
.usedMatrixMask = usedPnMtxMask,
|
||||
};
|
||||
if (uniformLayout.indexedMatrices) {
|
||||
|
||||
@@ -1416,23 +1416,32 @@ static inline GXBlendFactor remove_dst_alpha_usage(GXBlendFactor fac) {
|
||||
}
|
||||
}
|
||||
|
||||
// GX_LEQUAL etc. describe "pass if this pixel is closer than/equal to what's stored" in GX's own
|
||||
// distance terms, independent of how that distance is encoded as a host depth value. Under
|
||||
// UseReversedZ the encoding is flipped (near=1, far=0), so "closer" now corresponds to a *larger*
|
||||
// stored value, not a smaller one - the ordered compare functions (LESS/LEQUAL/GREATER/GEQUAL)
|
||||
// must invert to match, or the depth test silently runs backwards (verified directly: this was
|
||||
// the actual cause of a bug report after the projection/shader half of the reverse-Z fix
|
||||
// eliminated the double-negation that used to accidentally keep the unreversed comparisons
|
||||
// correct - LEQUAL now needs GreaterEqual, not LessEqual, once the encoding it's testing against
|
||||
// is genuinely reversed). Matches upstream aurora's to_compare_function exactly.
|
||||
static inline wgpu::CompareFunction to_compare_function(GXCompare func) {
|
||||
switch (func) {
|
||||
DEFAULT_FATAL("invalid depth fn {}", underlying(func));
|
||||
case GX_NEVER:
|
||||
return wgpu::CompareFunction::Never;
|
||||
case GX_LESS:
|
||||
return wgpu::CompareFunction::Less;
|
||||
return UseReversedZ ? wgpu::CompareFunction::Greater : wgpu::CompareFunction::Less;
|
||||
case GX_EQUAL:
|
||||
return wgpu::CompareFunction::Equal;
|
||||
case GX_LEQUAL:
|
||||
return wgpu::CompareFunction::LessEqual;
|
||||
return UseReversedZ ? wgpu::CompareFunction::GreaterEqual : wgpu::CompareFunction::LessEqual;
|
||||
case GX_GREATER:
|
||||
return wgpu::CompareFunction::Greater;
|
||||
return UseReversedZ ? wgpu::CompareFunction::Less : wgpu::CompareFunction::Greater;
|
||||
case GX_NEQUAL:
|
||||
return wgpu::CompareFunction::NotEqual;
|
||||
case GX_GEQUAL:
|
||||
return wgpu::CompareFunction::GreaterEqual;
|
||||
return UseReversedZ ? wgpu::CompareFunction::LessEqual : wgpu::CompareFunction::GreaterEqual;
|
||||
case GX_ALWAYS:
|
||||
return wgpu::CompareFunction::Always;
|
||||
}
|
||||
|
||||
@@ -436,6 +436,8 @@ struct GXState {
|
||||
u32 pipelineStateGeneration = next_gx_state_epoch();
|
||||
std::array<u32, 0x100> bpRegCache = [] {
|
||||
std::array<u32, 0x100> regs{};
|
||||
// Force the first GEN_MODE decode without changing its masked reset value.
|
||||
regs[0x00] = 0xFF000000;
|
||||
regs[0xFE] = 0x00FFFFFF;
|
||||
return regs;
|
||||
}();
|
||||
@@ -485,7 +487,14 @@ const gfx::TextureBind& get_texture(GXTexMapID id) noexcept;
|
||||
void resolve_sampled_textures(const ShaderInfo& info) noexcept;
|
||||
|
||||
inline float clear_depth_value() {
|
||||
return std::min(static_cast<float>(g_gxState.clearDepth) / 16777216.f, 16777215.f / 16777216.f);
|
||||
// g_gxState.clearDepth is in GX's own distance terms (0 = near, larger = farther), independent of
|
||||
// how UseReversedZ encodes that as a host depth value - it must be re-mapped the same way the
|
||||
// projection matrix and depth compare function are, or the buffer clears to the wrong extreme
|
||||
// (verified directly: matches upstream aurora's clear_depth_value, which does this same inversion
|
||||
// and was the second missing piece alongside to_compare_function's compare-op inversion).
|
||||
const float normalizedDepth =
|
||||
std::min(static_cast<float>(g_gxState.clearDepth) / 16777216.f, 16777215.f / 16777216.f);
|
||||
return UseReversedZ ? (1.f - normalizedDepth) : normalizedDepth;
|
||||
}
|
||||
|
||||
inline bool render_target_has_alpha(GXPixelFmt pixelFmt) noexcept { return pixelFmt == GX_PF_RGBA6_Z24; }
|
||||
|
||||
@@ -13,6 +13,7 @@ struct DrawData {
|
||||
uint32_t vtxCount;
|
||||
uint32_t indexCount;
|
||||
uint32_t instanceCount;
|
||||
bool expandedPrimitive;
|
||||
GXBindGroups bindGroups;
|
||||
uint32_t dstAlpha;
|
||||
};
|
||||
|
||||
@@ -993,11 +993,13 @@ wgpu::ShaderModule build_shader(const ShaderConfig& config) noexcept {
|
||||
"\n let clip_base = select(clip_a, clip_b, use_b);"
|
||||
"\n out.pos = vec4f(clip_base.xy + offset_ndc * clip_base.w, clip_base.zw);";
|
||||
}
|
||||
if constexpr (UseReversedZ) {
|
||||
vtxXfrAttrsPre += "\n out.pos.z = -out.pos.z;";
|
||||
} else {
|
||||
vtxXfrAttrsPre += "\n out.pos.z += out.pos.w;";
|
||||
}
|
||||
// The near/far depth correction used to be applied here per-vertex (out.pos.z = -out.pos.z for
|
||||
// reversed, or += out.pos.w for forward), redundantly on top of the same correction already
|
||||
// folded into ubuf.proj by effective_projection() (shader_info.cpp) - applying it twice canceled
|
||||
// out for the common case (any draw where effective_projection() decides to flip), silently
|
||||
// making "reversed" Z behave identically to forward Z. It is now applied exactly once, in the
|
||||
// projection matrix alone (matching upstream aurora commit 1dde08fa: "Move depth correction to
|
||||
// projection matrix"), so nothing needs to happen to out.pos.z here.
|
||||
// GX rasterizes at a 7/12 pixel center when antialiasing is disabled, while WebGPU rasterizes at 1/2.
|
||||
vtxXfrAttrsPre +=
|
||||
"\n let gx_pixel_center_correction = "
|
||||
@@ -1465,7 +1467,14 @@ wgpu::ShaderModule build_shader(const ShaderConfig& config) noexcept {
|
||||
textureDependency.texMapId, uvIn);
|
||||
}
|
||||
|
||||
std::string fogDepthExpr = UseReversedZ ? "in.pos.z" : "(1.0 - in.pos.z)";
|
||||
// in.pos.z is the host NDC z (forward: 0=near/1=far; reversed: 1=near/0=far post-fix), but this
|
||||
// expression needs to produce GX's own native distance term (always 0=near/1=far, matching how
|
||||
// g_gxState.clearDepth/clear_depth_value() are interpreted before their own UseReversedZ
|
||||
// inversion) - forward already matches directly; reversed needs the same 1-x flip everything
|
||||
// else reversed-Z-aware uses. This was backwards (verified directly against upstream aurora's
|
||||
// identical expression in build_shader_source), which fed both fog density and the GX_ZT_ADD
|
||||
// z-texture path the wrong distance value.
|
||||
std::string fogDepthExpr = UseReversedZ ? "(1.0 - in.pos.z)" : "in.pos.z";
|
||||
std::string fogZCoordExpr =
|
||||
fmt::format("u32(round(clamp({}, 0.0, 1.0) * 16777216.0))", fogDepthExpr);
|
||||
if (usesZTextureDepth) {
|
||||
@@ -1498,7 +1507,7 @@ wgpu::ShaderModule build_shader(const ShaderConfig& config) noexcept {
|
||||
fragmentFn += fmt::format(
|
||||
"\n let oldZ = u32(round(clamp({0}, 0.0, 1.0) * 16777216.0));"
|
||||
"\n ztexCoord = (ztexCoord + oldZ) & 0x00ffffffu;",
|
||||
UseReversedZ ? "in.pos.z" : "(1.0 - in.pos.z)");
|
||||
UseReversedZ ? "(1.0 - in.pos.z)" : "in.pos.z");
|
||||
}
|
||||
fragmentFn += "\n let ztexDepth = f32(ztexCoord) / 16777216.0;";
|
||||
fogZCoordExpr = "ztexCoord";
|
||||
@@ -1639,7 +1648,13 @@ wgpu::ShaderModule build_shader(const ShaderConfig& config) noexcept {
|
||||
" @builtin(frag_depth) depth: f32,\n"
|
||||
"};";
|
||||
|
||||
fragmentFn += fmt::format("\n let fragDepth = {}ztexDepth;", UseReversedZ ? "" : "1.0 - ");
|
||||
// ztexDepth is in GX's native distance terms (0=near/1=far, see fogDepthExpr's comment above),
|
||||
// but frag_depth must be written in the same host NDC-z convention in.pos.z itself uses -
|
||||
// forward matches directly (no change), reversed needs the same 1-x flip. This was backwards
|
||||
// the same way fogDepthExpr was (verified by the same derivation, since aurora upstream has no
|
||||
// directly equivalent line here to cross-check against - this z-texture-depth-output path
|
||||
// appears to be specific to this fork).
|
||||
fragmentFn += fmt::format("\n let fragDepth = {}ztexDepth;", UseReversedZ ? "1.0 - " : "");
|
||||
fragmentReturnType = "FragmentOutput";
|
||||
fragmentReturn =
|
||||
" var out: FragmentOutput;\n"
|
||||
@@ -1693,8 +1708,22 @@ fn load_u16(p: ptr<storage, array<u32>>, byte_off: u32, le: bool) -> u32 {{
|
||||
return bswap16(raw, le);
|
||||
}}
|
||||
|
||||
fn load_u24_raw(p: ptr<storage, array<u32>>, byte_off: u32) -> u32 {{
|
||||
let word_idx = byte_off >> 2u;
|
||||
let sub = byte_off & 3u;
|
||||
let word = p[word_idx];
|
||||
// Three bytes at offsets zero or one fit entirely in this word. Do not
|
||||
// access the next word: this attribute may end at the binding boundary.
|
||||
if (sub <= 1u) {{
|
||||
return (word >> (sub * 8u)) & 0x00FFFFFFu;
|
||||
}}
|
||||
let next = p[word_idx + 1u];
|
||||
let shift = sub * 8u;
|
||||
return ((word >> shift) | (next << (32u - shift))) & 0x00FFFFFFu;
|
||||
}}
|
||||
|
||||
fn load_u24(p: ptr<storage, array<u32>>, byte_off: u32, le: bool) -> u32 {{
|
||||
let raw = load_u32_raw(p, byte_off) & 0x00FFFFFFu;
|
||||
let raw = load_u24_raw(p, byte_off);
|
||||
if (le) {{
|
||||
return raw;
|
||||
}}
|
||||
@@ -1734,7 +1763,7 @@ fn raw_fetch_u8_2(p: ptr<storage, array<u32>>, byte_off: u32) -> vec2u {{
|
||||
}}
|
||||
|
||||
fn raw_fetch_u8_3(p: ptr<storage, array<u32>>, byte_off: u32) -> vec3u {{
|
||||
let raw = load_u32_raw(p, byte_off);
|
||||
let raw = load_u24_raw(p, byte_off);
|
||||
return vec3u(
|
||||
extractBits(raw, 0u, 8u),
|
||||
extractBits(raw, 8u, 8u),
|
||||
|
||||
@@ -548,14 +548,22 @@ constexpr size_t kStagedUniformBytes =
|
||||
96 + sizeof(Mat4x4<float>) + sizeof(Mat3x4<float>) * (MaxPostexMtx + MaxPnMtx);
|
||||
|
||||
// The host viewport always receives the normalized GX depth window (render_pass_impl clamps to minDepth <= maxDepth).
|
||||
//
|
||||
// Folds the near/far depth correction the vertex shader used to apply per-vertex directly into the
|
||||
// projection matrix instead (matching upstream aurora commit 1dde08fa, "Move depth correction to
|
||||
// projection matrix") - valid because the correction is a linear combination of the z/w rows, so
|
||||
// applying it once here to the row is equivalent to applying it once per-vertex to the dot product,
|
||||
// and it must be applied exactly once: doing it here AND in the shader (the previous bug) canceled
|
||||
// the negation out for `flip`, silently making "reversed" Z behave identically to forward Z.
|
||||
// `flip` decides which of the two single-application forms this draw needs: true bakes in the
|
||||
// reversed-Z inversion (z' = -z), false bakes in the forward-Z near/far combination (z' = z + w) -
|
||||
// exactly one always applies, never both, and never neither.
|
||||
static Mat4x4<float> effective_projection() noexcept {
|
||||
const auto& vp = g_gxState.renderViewport;
|
||||
const bool flip = (vp.znear <= vp.zfar) == UseReversedZ;
|
||||
Mat4x4<float> proj = g_gxState.proj;
|
||||
if (flip) {
|
||||
for (size_t i = 0; i < 4; ++i) {
|
||||
proj.m2.m[i] = -(proj.m2.m[i] + proj.m3.m[i]);
|
||||
}
|
||||
for (size_t i = 0; i < 4; ++i) {
|
||||
proj.m2.m[i] = flip ? -proj.m2.m[i] : (proj.m2.m[i] + proj.m3.m[i]);
|
||||
}
|
||||
return proj;
|
||||
}
|
||||
|
||||
@@ -75,18 +75,30 @@ void initialize() noexcept {
|
||||
|
||||
void shutdown() noexcept {
|
||||
ZoneScoped;
|
||||
if (g_useSdlRenderer) {
|
||||
ImGui_ImplSDLRenderer3_Shutdown();
|
||||
} else {
|
||||
ImGui_ImplWGPU_Shutdown();
|
||||
// Startup can fail before either backend initializes. A context alone does
|
||||
// not mean its renderer/platform backend owns resources to release.
|
||||
if (ImGui::GetCurrentContext() != nullptr) {
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
if (io.BackendRendererUserData != nullptr) {
|
||||
if (g_useSdlRenderer) {
|
||||
ImGui_ImplSDLRenderer3_Shutdown();
|
||||
} else {
|
||||
ImGui_ImplWGPU_Shutdown();
|
||||
}
|
||||
}
|
||||
if (io.BackendPlatformUserData != nullptr) {
|
||||
ImGui_ImplSDL3_Shutdown();
|
||||
}
|
||||
ImGui::DestroyContext();
|
||||
}
|
||||
ImGui_ImplSDL3_Shutdown();
|
||||
ImGui::DestroyContext();
|
||||
for (const auto& texture : g_sdlTextures) {
|
||||
SDL_DestroyTexture(texture);
|
||||
}
|
||||
g_sdlTextures.clear();
|
||||
g_wgpuTextures.clear();
|
||||
g_useSdlRenderer = false;
|
||||
g_scale = 0.f;
|
||||
g_frameDataBuilt = false;
|
||||
}
|
||||
|
||||
void process_event(const SDL_Event& event) noexcept {
|
||||
|
||||
@@ -122,7 +122,10 @@ auto underlying(T value) -> std::underlying_type_t<T> {
|
||||
|
||||
#define UNIMPLEMENTED() FATAL("UNIMPLEMENTED: {}", __FUNCTION__)
|
||||
|
||||
namespace wgpu { class CommandBuffer; }
|
||||
|
||||
namespace aurora {
|
||||
void submit_staging_commands(const wgpu::CommandBuffer& commands);
|
||||
extern AuroraConfig g_config;
|
||||
extern uint32_t g_sdlCustomEventsStart;
|
||||
extern char g_gameName[4];
|
||||
|
||||
@@ -570,12 +570,16 @@ bool initialize(AuroraBackend auroraBackend) {
|
||||
g_adapter = std::move(adapter);
|
||||
} else {
|
||||
Log.warn("Adapter request failed: {}", message);
|
||||
const std::string_view reason{message};
|
||||
SDL_SetError("Graphics adapter unavailable: %.*s",
|
||||
static_cast<int>(std::min<size_t>(reason.size(), 512)), reason.data());
|
||||
}
|
||||
});
|
||||
const auto status = g_instance.WaitAny(future, 5000000000);
|
||||
if (status != wgpu::WaitStatus::Success) {
|
||||
Log.error("Failed to create {} adapter: {}", magic_enum::enum_name(backend),
|
||||
magic_enum::enum_name(status));
|
||||
SDL_SetError("Graphics adapter request did not complete within its startup deadline");
|
||||
return false;
|
||||
}
|
||||
if (!g_adapter) {
|
||||
@@ -738,11 +742,15 @@ bool initialize(AuroraBackend auroraBackend) {
|
||||
g_device = std::move(device);
|
||||
} else {
|
||||
Log.warn("Device request failed: {}", message);
|
||||
const std::string_view reason{message};
|
||||
SDL_SetError("Graphics device unavailable: %.*s",
|
||||
static_cast<int>(std::min<size_t>(reason.size(), 512)), reason.data());
|
||||
}
|
||||
});
|
||||
const auto status = g_instance.WaitAny(future, 5000000000);
|
||||
if (status != wgpu::WaitStatus::Success) {
|
||||
Log.error("Failed to create device: {}", magic_enum::enum_name(status));
|
||||
SDL_SetError("Graphics device request did not complete within its startup deadline");
|
||||
return false;
|
||||
}
|
||||
if (!g_device) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <mutex>
|
||||
#include <limits>
|
||||
#include <string>
|
||||
#include <filesystem>
|
||||
#include <vector>
|
||||
@@ -286,8 +287,33 @@ size_t load_from_cache(void const* key, size_t keySize, void* value, size_t valu
|
||||
if (ret == SQLITE_ROW) {
|
||||
// Hit
|
||||
const auto foundPtr = sqlite3_column_blob(load_stmt, 0);
|
||||
foundSize = sqlite3_column_int64(load_stmt, 1);
|
||||
const bool compressed = sqlite3_column_int(load_stmt, 2) != 0;
|
||||
const auto declaredSize = sqlite3_column_int64(load_stmt, 1);
|
||||
const auto storedSize = sqlite3_column_bytes(load_stmt, 0);
|
||||
const auto compression = sqlite3_column_int(load_stmt, 2);
|
||||
const bool compressed = compression == 1;
|
||||
// Dawn asks for the size before allocating its destination. Validate here,
|
||||
// not only during the copy: corrupt metadata must become a cache miss.
|
||||
bool valid = declaredSize > 0 &&
|
||||
static_cast<uint64_t>(declaredSize) <= std::numeric_limits<size_t>::max() &&
|
||||
foundPtr != nullptr && storedSize > 0 && (compression == 0 || compression == 1);
|
||||
if (valid && compressed) {
|
||||
#if defined(AURORA_CACHE_USE_ZSTD)
|
||||
// Our writer uses ZSTD_compress, which records the original content size.
|
||||
const auto frameSize = ZSTD_getFrameContentSize(foundPtr, static_cast<size_t>(storedSize));
|
||||
valid = frameSize != ZSTD_CONTENTSIZE_ERROR && frameSize != ZSTD_CONTENTSIZE_UNKNOWN &&
|
||||
frameSize == static_cast<uint64_t>(declaredSize);
|
||||
#else
|
||||
valid = false;
|
||||
#endif
|
||||
} else if (valid) {
|
||||
valid = declaredSize == storedSize;
|
||||
}
|
||||
if (!valid) {
|
||||
Log.error("Ignoring cache entry with inconsistent size or compression metadata");
|
||||
check(sqlite3_reset(load_stmt));
|
||||
return 0;
|
||||
}
|
||||
foundSize = static_cast<size_t>(declaredSize);
|
||||
if (value == nullptr) {
|
||||
g_hits.fetch_add(1, std::memory_order_relaxed);
|
||||
} else {
|
||||
|
||||
@@ -18,6 +18,7 @@ if (AURORA_ENABLE_GX)
|
||||
gx_fifo_test.cpp
|
||||
gx_test_stubs.cpp
|
||||
texture_bind_group_cache_key_test.cpp
|
||||
renderer_regression_test.cpp
|
||||
../lib/gfx/efb_ram_encoder.cpp
|
||||
# GX API implementations (encoders)
|
||||
../lib/dolphin/gx/GXBump.cpp
|
||||
@@ -66,6 +67,18 @@ if (AURORA_ENABLE_GX)
|
||||
)
|
||||
|
||||
gtest_discover_tests(gx_fifo_tests)
|
||||
|
||||
option(AURORA_BUILD_GPU_TESTS "Build renderer pixel tests requiring a graphics device" OFF)
|
||||
if (AURORA_BUILD_GPU_TESTS)
|
||||
add_executable(gx_readback_tests gpu_readback_test.cpp)
|
||||
target_compile_features(gx_readback_tests PRIVATE cxx_std_20)
|
||||
target_include_directories(gx_readback_tests PRIVATE ../lib)
|
||||
target_link_libraries(gx_readback_tests PRIVATE
|
||||
aurora::core aurora::gx aurora::pad aurora::vi aurora::mtx aurora::si
|
||||
dawn::dawncpp_headers absl::flat_hash_map absl::btree TracyClient)
|
||||
add_test(NAME gx_readback_tests COMMAND gx_readback_tests "${CMAKE_CURRENT_BINARY_DIR}/readback-cache")
|
||||
set_tests_properties(gx_readback_tests PROPERTIES TIMEOUT 90)
|
||||
endif ()
|
||||
endif () # AURORA_ENABLE_GX
|
||||
|
||||
# DVD API tests
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
// ROM-free integration probe. Links the actual maintained Aurora renderer.
|
||||
#include "gfx/common.hpp"
|
||||
#include "gfx/clear.hpp"
|
||||
#include "gfx/efb_ram_copy.hpp"
|
||||
#include "gfx/pipeline_cache.hpp"
|
||||
#include "gfx/texture.hpp"
|
||||
#include "gx/gx.hpp"
|
||||
#include "gx/fifo.hpp"
|
||||
#include "gx/command_processor.hpp"
|
||||
#include "gx/frame_interpolation.hpp"
|
||||
#include <dolphin/gx.h>
|
||||
#include <aurora/aurora.h>
|
||||
#include <array>
|
||||
#include <bit>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
using namespace aurora;
|
||||
std::atomic<unsigned> errors{};
|
||||
std::atomic<unsigned> guestWrites{};
|
||||
// Keep destinations alive through shutdown, including any failing wait.
|
||||
std::array<uint8_t, 16 * 16 * 4 + 32> guarded;
|
||||
std::array<uint8_t, 16 * 16 * 4 + 32> guardedBake;
|
||||
void require(bool value, const char* message) {
|
||||
if (!value) throw std::runtime_error(message);
|
||||
}
|
||||
void submit(bool final, bool download = false, bool async = false) {
|
||||
auto encoder = webgpu::g_device.CreateCommandEncoder();
|
||||
if (final) gfx::end_frame(encoder); else gfx::end_batch(encoder);
|
||||
gfx::render(encoder);
|
||||
if (download) gfx::efb_ram::encode_downloads(encoder);
|
||||
if (async) gfx::efb_ram::encode_async_downloads(encoder);
|
||||
auto commands = encoder.Finish();
|
||||
webgpu::g_queue.Submit(1, &commands);
|
||||
if (download) require(gfx::efb_ram::complete_downloads(), "EFB readback failed");
|
||||
gfx::after_submit();
|
||||
if (!final) require(gfx::resume_frame(), "Batch resume failed");
|
||||
}
|
||||
|
||||
constexpr std::array<std::array<uint8_t, 4>, 4> colors{{
|
||||
{255, 0, 0, 255}, {0, 255, 0, 255}, {0, 0, 255, 255}, {255, 255, 0, 255}}};
|
||||
using Pixels = std::vector<uint8_t>;
|
||||
Pixels expected(unsigned extent) {
|
||||
Pixels bytes(extent * extent * 4);
|
||||
// GX RGBA8: 4x4 tiles, sixteen A/R pairs followed by sixteen G/B pairs.
|
||||
for (unsigned y = 0; y < extent; ++y) for (unsigned x = 0; x < extent; ++x) {
|
||||
const auto color = colors[y * 4 / extent];
|
||||
const auto tile = ((y / 4) * (extent / 4) + x / 4) * 64;
|
||||
const auto pair = ((y % 4) * 4 + x % 4) * 2;
|
||||
bytes[tile + pair] = color[3]; bytes[tile + pair + 1] = color[0];
|
||||
bytes[tile + 32 + pair] = color[1]; bytes[tile + 33 + pair] = color[2];
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
Pixels run(unsigned splitEvery, bool async = false, bool offscreen = false, unsigned geometry = 0, bool capacityStress = false, bool interpolate = false, bool frameWorker = false, unsigned copyCase = 0) {
|
||||
require(!async || !offscreen, "Combined probe mode is not supported");
|
||||
guardedBake.fill(0xa5);
|
||||
gx::g_gxState.clearColor = {0.f, 0.f, 0.f, 1.f};
|
||||
require(frameWorker ? aurora_begin_frame() : gfx::begin_frame(), "Frame begin failed");
|
||||
std::array<std::array<float, 3>, 4> positions{};
|
||||
if (geometry) {
|
||||
alignas(32) static std::array<uint8_t, 32768> fifo;
|
||||
GXInit(fifo.data(), fifo.size());
|
||||
gx::g_gxState.viewportPolicy = AURORA_VIEWPORT_NATIVE;
|
||||
GXSetViewport(0.f, 0.f, 64.f, 64.f, 0.f, 1.f);
|
||||
GXSetScissor(0, 0, 64, 64);
|
||||
GXSetCullMode(GX_CULL_NONE);
|
||||
GXSetZMode(false, GX_ALWAYS, false);
|
||||
GXSetBlendMode(GX_BM_NONE, GX_BL_ONE, GX_BL_ZERO, GX_LO_COPY);
|
||||
GXSetColorUpdate(true); GXSetAlphaUpdate(true);
|
||||
GXSetNumTexGens(0); GXSetNumChans(1); GXSetNumTevStages(copyCase ? copyCase : 1);
|
||||
for (unsigned stage = 1; stage < copyCase; ++stage) {
|
||||
GXSetTevOrder(static_cast<GXTevStageID>(stage), GX_TEXCOORD_NULL, GX_TEXMAP_NULL, GX_COLOR0A0);
|
||||
GXSetTevOp(static_cast<GXTevStageID>(stage), GX_PASSCLR);
|
||||
}
|
||||
GXSetTevOrder(GX_TEVSTAGE0, GX_TEXCOORD_NULL, GX_TEXMAP_NULL, GX_COLOR0A0);
|
||||
GXSetTevOp(GX_TEVSTAGE0, GX_PASSCLR);
|
||||
GXSetChanCtrl(GX_COLOR0A0, false, GX_SRC_REG, GX_SRC_VTX, GX_LIGHT_NULL, GX_DF_NONE, GX_AF_NONE);
|
||||
const float projection[]{interpolate ? 0.f : 1.f, 1.f, 0.f, 1.f, 0.f, 0.f, -0.5f};
|
||||
GXSetProjectionv(projection);
|
||||
GXClearVtxDesc();
|
||||
GXSetVtxDesc(GX_VA_POS, geometry == 2 ? GX_INDEX8 : GX_DIRECT);
|
||||
GXSetVtxDesc(GX_VA_CLR0, GX_DIRECT);
|
||||
if (geometry == 2) GXSetArray(GX_VA_POS, positions.data(), sizeof(positions), sizeof(positions[0]), true);
|
||||
GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_POS, GX_POS_XYZ, GX_F32, 0);
|
||||
GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_CLR0, GX_CLR_RGBA, GX_RGBA8, 0);
|
||||
gx::fifo::drain();
|
||||
}
|
||||
const auto pipeline = gfx::pipeline_ref(gfx::clear::PipelineConfig{});
|
||||
for (unsigned band = 0; band < 4; ++band) {
|
||||
const auto c = colors[band];
|
||||
if (geometry) {
|
||||
const float top = 1.f - band * 0.5f;
|
||||
const float bottom = top - 0.5f;
|
||||
const float z = interpolate ? -1.f : 0.f;
|
||||
positions = {{{-1.f, top, z}, {1.f, top, z}, {1.f, bottom, z}, {-1.f, bottom, z}}};
|
||||
// Keep the same array address/format and change only its bytes between draws.
|
||||
if (geometry == 2) GXInvalidateVtxCache();
|
||||
if (geometry == 3) {
|
||||
std::array<uint8_t, 64> raw{};
|
||||
for (unsigned index = 0; index < positions.size(); ++index) {
|
||||
for (unsigned axis = 0; axis < 3; ++axis) {
|
||||
const auto bits = std::bit_cast<uint32_t>(positions[index][axis]);
|
||||
for (unsigned byte = 0; byte < 4; ++byte)
|
||||
raw[index * 16 + axis * 4 + byte] = bits >> (24 - byte * 8);
|
||||
}
|
||||
std::copy(c.begin(), c.end(), raw.begin() + index * 16 + 12);
|
||||
}
|
||||
require(gx::fifo::submit_raw_draw(GX_QUADS, GX_VTXFMT0, raw.data(), 4, raw.size()),
|
||||
"Raw bridge rejected valid quad");
|
||||
} else {
|
||||
GXBegin(GX_QUADS, GX_VTXFMT0, 4);
|
||||
for (unsigned index = 0; index < positions.size(); ++index) {
|
||||
if (geometry == 2) GXPosition1x8(index);
|
||||
else GXPosition3f32(positions[index][0], positions[index][1], positions[index][2]);
|
||||
GXColor4u8(c[0], c[1], c[2], 255);
|
||||
}
|
||||
GXEnd();
|
||||
}
|
||||
} else {
|
||||
gfx::push_draw_command(gfx::clear::DrawData{
|
||||
.pipeline = pipeline,
|
||||
.color = {c[0] / 255., c[1] / 255., c[2] / 255., 1.},
|
||||
.depth = 0.5f,
|
||||
.useScissor = true,
|
||||
.scissor = {0, static_cast<int32_t>(band * 16), 64, 16}});
|
||||
}
|
||||
if (offscreen && band == 0) {
|
||||
// Suspend a partially recorded EFB, bake an independently observable copy,
|
||||
// then resume it before a possible capacity-boundary submission.
|
||||
gfx::begin_offscreen(64, 64);
|
||||
gfx::push_draw_command(gfx::clear::DrawData{
|
||||
.pipeline = pipeline, .color = {1., 0., 1., 1.}, .depth = 0.25f});
|
||||
if (capacityStress) for (unsigned draw = 0; draw < 24; ++draw) {
|
||||
gfx::push_draw_command(gfx::clear::DrawData{
|
||||
.pipeline = pipeline, .color = {1., 0., 1., 1.}, .depth = 0.25f,
|
||||
.useScissor = true, .scissor = {0, 0, 4, 4}});
|
||||
}
|
||||
auto baked = gfx::new_render_texture(64, 64, GX_TF_RGBA8, "Aurora probe offscreen bake");
|
||||
gfx::resolve_pass(baked, {0, 0, 64, 64}, false, false, false,
|
||||
{0.f, 0.f, 0.f, 1.f}, 1.f, GX_TF_RGBA8, nullptr, false,
|
||||
nullptr, false, 1.f, false, false, true);
|
||||
gfx::efb_ram::schedule(guardedBake.data() + 16, 16, 16, GX_TF_RGBA8, baked);
|
||||
gfx::end_offscreen();
|
||||
if (capacityStress) {
|
||||
require(gfx::efb_ram::prepare_downloads(), "Early bake readback preparation failed");
|
||||
for (unsigned draw = 0; draw < 24; ++draw) {
|
||||
gfx::push_draw_command(gfx::clear::DrawData{
|
||||
.pipeline = pipeline, .color = {1., 0., 0., 1.}, .depth = 0.5f,
|
||||
.useScissor = true, .scissor = {0, 0, 4, 4}});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (splitEvery && band < 3 && (band + 1) % splitEvery == 0) submit(false);
|
||||
}
|
||||
auto texture = gfx::new_render_texture(64, 64, GX_TF_RGBA8, "Aurora probe persistent copy");
|
||||
static std::array<uint8_t, 64 * 64 * 4> copyDestination;
|
||||
if (copyCase) {
|
||||
gx::fifo::drain();
|
||||
GXSetTexCopySrc(0, 0, 64, 64);
|
||||
GXSetTexCopyDst(64, 64, GX_TF_RGBA8, GX_FALSE);
|
||||
GXCopyTex(copyDestination.data(), GX_FALSE);
|
||||
texture = gx::g_gxState.copyTextures.at(copyDestination.data()).handle;
|
||||
} else {
|
||||
// A partial clear forces the real snapshot and clear-uniform paths after the copy.
|
||||
gfx::resolve_pass(texture, {0, 0, 64, 64}, true, true, true, {0.f, 0.f, 0.f, 1.f},
|
||||
1.f, GX_TF_RGBA8, nullptr, false, nullptr, false, 1.f, false, false, true);
|
||||
}
|
||||
guarded.fill(0xa5);
|
||||
const unsigned extent = async ? 4 : 16;
|
||||
const unsigned bytes = extent * extent * 4;
|
||||
gfx::efb_ram::schedule(guarded.data() + 16, extent, extent, GX_TF_RGBA8, texture);
|
||||
const auto before = guestWrites.load(std::memory_order_acquire);
|
||||
if (async) gfx::efb_ram::seal_async_downloads();
|
||||
else require(gfx::efb_ram::prepare_downloads(), "Readback preparation failed");
|
||||
if (frameWorker) {
|
||||
require(!async && aurora_flush_efb_copies_to_ram(), "Worker-mode EFB readback failed");
|
||||
aurora_end_frame();
|
||||
} else submit(true, !async, async);
|
||||
if (async) {
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
|
||||
while (guestWrites.load(std::memory_order_acquire) == before) {
|
||||
webgpu::g_instance.ProcessEvents();
|
||||
require(std::chrono::steady_clock::now() < deadline, "Async readback did not complete");
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
}
|
||||
}
|
||||
require(std::all_of(guarded.begin(), guarded.begin() + 16, [](auto b) { return b == 0xa5; }) &&
|
||||
std::all_of(guarded.begin() + 16 + bytes, guarded.end(), [](auto b) { return b == 0xa5; }),
|
||||
"Readback wrote outside its destination");
|
||||
if (offscreen) {
|
||||
require(std::all_of(guardedBake.begin(), guardedBake.begin() + 16, [](auto b) { return b == 0xa5; }) &&
|
||||
std::all_of(guardedBake.end() - 16, guardedBake.end(), [](auto b) { return b == 0xa5; }),
|
||||
"Offscreen readback wrote outside its destination");
|
||||
for (unsigned tile = 0; tile < 16; ++tile) for (unsigned pair = 0; pair < 16; ++pair) {
|
||||
const auto offset = 16 + tile * 64 + pair * 2;
|
||||
require(guardedBake[offset] == 255 && guardedBake[offset + 1] == 255 &&
|
||||
guardedBake[offset + 32] == 0 && guardedBake[offset + 33] == 255,
|
||||
"Offscreen bake did not preserve expected magenta pixels");
|
||||
}
|
||||
}
|
||||
Pixels pixels(guarded.begin() + 16, guarded.begin() + 16 + bytes);
|
||||
return pixels;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc != 2) return 2;
|
||||
std::filesystem::create_directories(argv[1]);
|
||||
AuroraConfig config{};
|
||||
config.appName = "Aurora readback regression tests";
|
||||
config.userPath = argv[1];
|
||||
config.cachePath = argv[1];
|
||||
config.resourcesPath = argv[1];
|
||||
config.desiredBackend = BACKEND_AUTO;
|
||||
config.windowWidth = 64;
|
||||
config.windowHeight = 64;
|
||||
config.msaa = 1;
|
||||
config.maxTextureAnisotropy = 1;
|
||||
config.logLevel = LOG_INFO;
|
||||
config.logCallback = [](AuroraLogLevel level, const char* module, const char* message, unsigned size) {
|
||||
if (level >= LOG_ERROR) ++errors;
|
||||
std::fprintf(stderr, "[%s] %.*s\n", module, static_cast<int>(size), message);
|
||||
};
|
||||
const auto initialized = aurora_initialize(1, argv, &config);
|
||||
if (initialized.initializationStatus != AURORA_INITIALIZATION_SUCCESS) return 3;
|
||||
aurora_set_skip_unready_pipelines(true);
|
||||
aurora_set_guest_write_hooks(nullptr, [](const void*, size_t) {
|
||||
guestWrites.fetch_add(1, std::memory_order_release);
|
||||
});
|
||||
try {
|
||||
const auto prewarmQueued = gfx::queued_pipeline_count();
|
||||
const auto prewarmDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(60);
|
||||
while (gfx::queued_pipeline_count() != 0) {
|
||||
require(std::chrono::steady_clock::now() < prewarmDeadline, "Seeded pipeline prewarm did not finish");
|
||||
webgpu::g_instance.ProcessEvents();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
}
|
||||
std::printf("Actual Aurora completed seeded startup queue (%u observed pending)\n", prewarmQueued);
|
||||
const auto control = run(0);
|
||||
for (unsigned band = 0; band < 4; ++band) {
|
||||
const auto tile = band * 4 * 64;
|
||||
std::fprintf(stderr, "band=%u ARGB=%u,%u,%u,%u\n", band, control[tile],
|
||||
control[tile + 1], control[tile + 32], control[tile + 33]);
|
||||
}
|
||||
require(control == expected(16), "Unsplit pixels differ from independently expected GX data");
|
||||
for (unsigned iteration = 0; iteration < 9; ++iteration) {
|
||||
const auto splitEvery = iteration % 3 + 1;
|
||||
require(run(splitEvery) == control, "Split pixels differ from unsplit control");
|
||||
std::printf("Actual Aurora split=%u iteration=%u matched native tiled readback\n", splitEvery, iteration);
|
||||
}
|
||||
for (unsigned iteration = 0; iteration < 9; ++iteration) {
|
||||
require(run(iteration % 3 + 1, true) == expected(4), "Async pixels differ from expected GX data");
|
||||
std::printf("Actual Aurora async iteration=%u matched native tiled readback\n", iteration);
|
||||
}
|
||||
for (unsigned splitEvery = 0; splitEvery < 4; ++splitEvery) {
|
||||
require(run(splitEvery, false, true) == expected(16), "Offscreen interlude changed the suspended EFB");
|
||||
std::printf("Actual Aurora offscreen split=%u preserved bake and suspended EFB\n", splitEvery);
|
||||
}
|
||||
for (unsigned splitEvery = 0; splitEvery < 4; ++splitEvery) {
|
||||
require(run(splitEvery, false, false, true) == expected(16), "GX FIFO quad pixels differ from expected output");
|
||||
std::printf("Actual Aurora GX FIFO split=%u preserved direct vertices, indices and uniforms\n", splitEvery);
|
||||
}
|
||||
for (unsigned splitEvery = 0; splitEvery < 4; ++splitEvery) {
|
||||
require(run(splitEvery, false, false, 2) == expected(16), "Invalidated GX array pixels differ from expected output");
|
||||
std::printf("Actual Aurora GX invalidation split=%u refreshed the same array address\n", splitEvery);
|
||||
}
|
||||
const gfx::StagingSizes physical{gfx::VertexBufferSize, gfx::UniformBufferSize,
|
||||
gfx::IndexBufferSize, gfx::StorageBufferSize};
|
||||
const auto uniformTail = gx::MaxUniformSize + 32 * gfx::staging_uniform_bytes(48);
|
||||
for (unsigned buffer = 0; buffer < 4; ++buffer) {
|
||||
auto limits = physical;
|
||||
limits[buffer] = buffer == 0 ? 128 : buffer == 1 ? uniformTail + 512 :
|
||||
buffer == 2 ? 24 : 2 * gfx::staging_storage_bytes(48);
|
||||
gfx::set_staging_capacity_limits_for_testing(limits);
|
||||
const auto before = gfx::staging_split_count();
|
||||
require(run(0, false, false, buffer == 1 ? 0 : buffer == 3 ? 2 : 1) == expected(16),
|
||||
"Automatic capacity split changed pixels");
|
||||
require(gfx::staging_split_count() > before, "Forced capacity did not split");
|
||||
const auto highWater = gfx::staging_high_water();
|
||||
for (unsigned i = 0; i < limits.size(); ++i)
|
||||
require(highWater[i] <= limits[i], "Actual staging usage exceeded admission budget");
|
||||
std::printf("Actual staging high-water V/U/I/S=%llu/%llu/%llu/%llu bytes\n",
|
||||
static_cast<unsigned long long>(highWater[0]), static_cast<unsigned long long>(highWater[1]),
|
||||
static_cast<unsigned long long>(highWater[2]), static_cast<unsigned long long>(highWater[3]));
|
||||
std::printf("Actual Aurora automatic capacity buffer=%u splits=%llu matched pixels\n", buffer,
|
||||
static_cast<unsigned long long>(gfx::staging_split_count() - before));
|
||||
}
|
||||
auto limits = physical;
|
||||
limits[0] = 128;
|
||||
gfx::set_staging_capacity_limits_for_testing(limits);
|
||||
require(run(0, false, false, 3) == expected(16), "Raw bridge capacity split changed pixels");
|
||||
std::puts("Actual Aurora raw bridge capacity split preserved direct quad pixels");
|
||||
limits = physical;
|
||||
limits[1] = uniformTail + 768;
|
||||
gfx::set_staging_capacity_limits_for_testing(limits);
|
||||
const auto beforeBake = gfx::staging_split_count();
|
||||
require(run(0, false, true, 0, true) == expected(16),
|
||||
"Automatic offscreen split changed bake or suspended EFB");
|
||||
require(gfx::staging_split_count() - beforeBake >= 9, "Offscreen test did not reuse all staging slots");
|
||||
std::printf("Actual Aurora automatic offscreen/readback splits=%llu preserved all pixels\n",
|
||||
static_cast<unsigned long long>(gfx::staging_split_count() - beforeBake));
|
||||
gfx::set_staging_capacity_limits_for_testing(physical);
|
||||
aurora_set_frame_interpolation_fps(120);
|
||||
for (unsigned frame = 0; frame < 3; ++frame)
|
||||
require(run(0, false, false, 2, false, true) == expected(16), "Perspective warmup changed pixels");
|
||||
AuroraFrameInterpolationDiagnostics interpolation{};
|
||||
gx::get_frame_interpolation_diagnostics(interpolation);
|
||||
require(interpolation.matchable > 0 && interpolation.activeSamples > 0,
|
||||
"Interpolation probe did not establish matching perspective draws");
|
||||
limits = physical;
|
||||
limits[3] = 2 * gfx::staging_storage_bytes(48);
|
||||
gfx::set_staging_capacity_limits_for_testing(limits);
|
||||
require(run(0, false, false, 2, false, true) == expected(16), "Interpolated split changed native pixels");
|
||||
gx::get_frame_interpolation_diagnostics(interpolation);
|
||||
require(!interpolation.replaySafe, "Split frame incorrectly retained interpolation replay");
|
||||
aurora_set_frame_interpolation_fps(0);
|
||||
std::puts("Actual Aurora matched perspective interpolation survived capacity split and disabled replay");
|
||||
limits = physical;
|
||||
limits[0] = 32; // One quad needs 64 bytes: typed rejection before any draw allocation.
|
||||
gfx::set_staging_capacity_limits_for_testing(limits);
|
||||
bool oversized = false;
|
||||
try { run(0, false, false, 1); }
|
||||
catch (const gfx::StagingCapacityError&) { oversized = true; }
|
||||
require(oversized, "Oversized primitive was not rejected");
|
||||
require(gfx::staging_usage() == gfx::StagingSizes{}, "Oversized primitive partially allocated");
|
||||
gx::fifo::clear_buffer();
|
||||
gfx::abort_frame();
|
||||
gfx::set_staging_capacity_limits_for_testing(physical);
|
||||
std::puts("Actual Aurora oversized primitive rejected before staging mutation");
|
||||
require(run(0, false, false, 1) == expected(16), "Renderer failed after rejected primitive cleanup");
|
||||
limits = physical;
|
||||
limits[3] = 2 * gfx::staging_storage_bytes(48);
|
||||
gfx::set_staging_capacity_limits_for_testing(limits);
|
||||
aurora_set_frame_interpolation_fps(120);
|
||||
for (unsigned frame = 0; frame < 16; ++frame)
|
||||
require(run(0, false, false, 2, false, true, true) == expected(16),
|
||||
"Frame-worker capacity split changed pixels");
|
||||
// Grant preparation of the next frame before joining DONE, exactly as the
|
||||
// real producer does; leave no worker waiting for a future begin_frame.
|
||||
require(aurora_begin_frame(), "Final worker frame preparation failed");
|
||||
aurora::wait_for_frame_worker();
|
||||
gfx::abort_frame();
|
||||
aurora_set_frame_interpolation_fps(0);
|
||||
gfx::set_staging_capacity_limits_for_testing(physical);
|
||||
std::puts("Actual Aurora frame worker completed 16 capacity-split perspective frames");
|
||||
require(errors == 0, "Renderer reported an error");
|
||||
} catch (const std::exception& error) {
|
||||
std::fprintf(stderr, "FAIL: %s\n", error.what());
|
||||
aurora_shutdown();
|
||||
return 4;
|
||||
}
|
||||
aurora_shutdown();
|
||||
if (errors != 0) return 4;
|
||||
std::puts("Actual Aurora clear/resolve/snapshot/downsample/readback batches passed");
|
||||
}
|
||||
@@ -462,8 +462,18 @@ TEST(FrameInterpolationContract, IndexedPaletteHistoryKeepsAbsoluteVertexSlots)
|
||||
std::array<uint8_t, uniformSize> changedSource{};
|
||||
aurora::gx::begin_frame_interpolation();
|
||||
const auto changedRanges = recordFrame(changedTopology, 91.0f, 9.0f, changedSource);
|
||||
EXPECT_EQ(changedRanges[0].size, 0u);
|
||||
// Staging may reserve a copy for sibling matching; the correctness contract
|
||||
// is that an unmatched topology receives the current pose unchanged.
|
||||
const auto expectedCurrent = changedSource;
|
||||
aurora::gx::finalize_frame_interpolation();
|
||||
EXPECT_EQ(changedSource, expectedCurrent);
|
||||
if (changedRanges[0].size != 0) {
|
||||
// No replacement range also correctly selects the original current uniform.
|
||||
ASSERT_EQ(changedRanges[0].size, uniformSize);
|
||||
const auto& duplicated = aurora::gfx::testing::uniform_allocation(changedRanges[0].offset);
|
||||
ASSERT_EQ(duplicated.size(), expectedCurrent.size());
|
||||
EXPECT_EQ(std::memcmp(duplicated.data(), expectedCurrent.data(), expectedCurrent.size()), 0);
|
||||
}
|
||||
|
||||
aurora::gx::set_frame_interpolation_fps(0);
|
||||
aurora::gx::begin_frame_interpolation();
|
||||
@@ -646,12 +656,34 @@ TEST(TevRegisterLivenessContract, PacksOneUniformWhenBothHalvesNeedInitialValue)
|
||||
auto config = baseline;
|
||||
config.tevStages[0].colorPass.a = GX_CC_C0;
|
||||
config.tevStages[0].alphaPass.a = GX_CA_A0;
|
||||
config.tevStages[0].colorPass.b = GX_CC_KONST;
|
||||
config.tevStages[0].kcSel = GX_TEV_KCSEL_K0;
|
||||
|
||||
const auto baselineInfo = aurora::gx::build_shader_info(baseline);
|
||||
const auto info = aurora::gx::build_shader_info(config);
|
||||
EXPECT_TRUE(info.loadsTevRegRgb.test(GX_TEVREG0));
|
||||
EXPECT_TRUE(info.loadsTevRegAlpha.test(GX_TEVREG0));
|
||||
EXPECT_EQ(info.uniformSize, baselineInfo.uniformSize + sizeof(aurora::Vec4<float>));
|
||||
// The final allocation is alignment-rounded, so adding one register need
|
||||
// not increase it. Verify actual packing with a distinct following K color.
|
||||
const auto savedReg = g_gxState.colorRegs[GX_TEVREG0];
|
||||
const auto savedKColor = g_gxState.kcolors[GX_KCOLOR0];
|
||||
g_gxState.colorRegs[GX_TEVREG0] = {11.f, 22.f, 33.f, 44.f};
|
||||
g_gxState.kcolors[GX_KCOLOR0] = {55.f, 66.f, 77.f, 88.f};
|
||||
EXPECT_TRUE(info.sampledKColors.test(GX_KCOLOR0));
|
||||
aurora::gfx::testing::reset_uniform_allocations();
|
||||
aurora::gx::build_uniform(info, 0, {}, {}, false);
|
||||
const auto expectedReg = g_gxState.colorRegs[GX_TEVREG0];
|
||||
const auto expectedKColor = g_gxState.kcolors[GX_KCOLOR0];
|
||||
g_gxState.colorRegs[GX_TEVREG0] = savedReg;
|
||||
g_gxState.kcolors[GX_KCOLOR0] = savedKColor;
|
||||
const auto& bytes = aurora::gfx::testing::uniform_allocation(0);
|
||||
const auto* reg = reinterpret_cast<const uint8_t*>(&expectedReg);
|
||||
const auto found = std::search(bytes.begin(), bytes.end(), reg, reg + sizeof(aurora::Vec4<float>));
|
||||
ASSERT_NE(found, bytes.end());
|
||||
const size_t offset = static_cast<size_t>(found - bytes.begin());
|
||||
ASSERT_LE(offset + 2 * sizeof(aurora::Vec4<float>), bytes.size());
|
||||
EXPECT_EQ(std::memcmp(bytes.data() + offset + sizeof(aurora::Vec4<float>),
|
||||
&expectedKColor, sizeof(aurora::Vec4<float>)), 0);
|
||||
aurora::gfx::testing::reset_uniform_allocations();
|
||||
}
|
||||
|
||||
// BP registers (direct FIFO writes, no dirty state flush needed)
|
||||
@@ -708,6 +740,52 @@ TEST_F(GXFifoTest, BlendMode_Logic) {
|
||||
EXPECT_EQ(g_gxState.blendOp, GX_LO_XOR);
|
||||
}
|
||||
|
||||
|
||||
TEST_F(GXFifoTest, GenMode_FirstZeroWriteDecodesAndRepeatDeduplicates) {
|
||||
reset_gx_state();
|
||||
const auto before = g_gxState.pipelineStateGeneration;
|
||||
decode_fifo(bp_cmd(0, 0));
|
||||
EXPECT_EQ(g_gxState.numTevStages, 1u);
|
||||
EXPECT_EQ(g_gxState.cullMode, GX_CULL_NONE);
|
||||
EXPECT_EQ(g_gxState.numChans, 0u);
|
||||
EXPECT_EQ(g_gxState.numTexGens, 0u);
|
||||
EXPECT_EQ(g_gxState.numIndStages, 0u);
|
||||
EXPECT_EQ(g_gxState.bpRegCache[0], 0u);
|
||||
EXPECT_NE(g_gxState.pipelineStateGeneration, before);
|
||||
const auto decoded = g_gxState.pipelineStateGeneration;
|
||||
decode_fifo(bp_cmd(0, 0));
|
||||
EXPECT_EQ(g_gxState.pipelineStateGeneration, decoded);
|
||||
}
|
||||
|
||||
TEST_F(GXFifoTest, GenMode_FirstMaskedWritePreservesZeroResetBits) {
|
||||
for (const u32 mask : {0u, 1u << 10}) {
|
||||
reset_gx_state();
|
||||
const auto before = g_gxState.pipelineStateGeneration;
|
||||
decode_fifo(bp_cmd(0xFE, mask));
|
||||
decode_fifo(bp_cmd(0, 0xFFFFFF));
|
||||
EXPECT_EQ(g_gxState.bpRegCache[0], mask);
|
||||
EXPECT_EQ(g_gxState.bpRegCache[0xFE], 0xFFFFFFu);
|
||||
EXPECT_EQ(g_gxState.numTevStages, mask ? 2u : 1u);
|
||||
EXPECT_EQ(g_gxState.cullMode, GX_CULL_NONE);
|
||||
EXPECT_NE(g_gxState.pipelineStateGeneration, before);
|
||||
decode_fifo(bp_cmd(0, 0));
|
||||
EXPECT_EQ(g_gxState.numTevStages, 1u);
|
||||
EXPECT_EQ(g_gxState.bpRegCache[0], 0u);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(GXFifoTest, GenMode_ColdSingleStageApiSetupDecodes) {
|
||||
reset_gx_state();
|
||||
GXSetNumTevStages(1);
|
||||
GXSetNumTexGens(0);
|
||||
GXSetNumChans(0);
|
||||
GXSetCullMode(GX_CULL_NONE);
|
||||
const auto bytes = flush_and_capture();
|
||||
decode_fifo(bytes);
|
||||
EXPECT_EQ(g_gxState.numTevStages, 1u);
|
||||
EXPECT_EQ(g_gxState.cullMode, GX_CULL_NONE);
|
||||
}
|
||||
|
||||
TEST_F(GXFifoTest, BpMask_AppliesOnlyToNextWrite) {
|
||||
std::vector<u8> bytes;
|
||||
auto mask = bp_cmd(0xFE, 1u << 19);
|
||||
@@ -2179,6 +2257,7 @@ TEST_F(GXFifoTest, DrawTopologyTemplatesPreserveExactGxIndexOrder) {
|
||||
const auto decodeAndReadIndices = [&](GXPrimitive primitive, u16 count) {
|
||||
std::vector<u8> fifo;
|
||||
append_test_draw(fifo, primitive, count);
|
||||
aurora::gfx::testing::reset_vertex_push_record();
|
||||
decode_fifo(fifo);
|
||||
return aurora::gfx::testing::last_pushed_indices();
|
||||
};
|
||||
@@ -2193,7 +2272,7 @@ TEST_F(GXFifoTest, DrawTopologyTemplatesPreserveExactGxIndexOrder) {
|
||||
(std::vector<u16>{0, 1, 2, 0, 2, 3, 0, 3, 4}));
|
||||
g_gxState.stateDirty = true;
|
||||
EXPECT_EQ(decodeAndReadIndices(GX_TRIANGLEFAN, 2),
|
||||
(std::vector<u16>{0, 1}));
|
||||
(std::vector<u16>{}));
|
||||
g_gxState.stateDirty = true;
|
||||
EXPECT_EQ(decodeAndReadIndices(GX_TRIANGLESTRIP, 6),
|
||||
(std::vector<u16>{0, 1, 2, 2, 1, 3, 2, 3, 4, 4, 3, 5}));
|
||||
@@ -4166,7 +4245,9 @@ TEST_F(GXFifoTest, CopyTexClearTruePassesScratchRectAndUpdateMasksToResolve) {
|
||||
EXPECT_NEAR(resolve.clearColorValue.y(), 128.f / 255.f, 1.f / 255.f);
|
||||
EXPECT_NEAR(resolve.clearColorValue.z(), 192.f / 255.f, 1.f / 255.f);
|
||||
EXPECT_NEAR(resolve.clearColorValue.w(), 32.f / 255.f, 1.f / 255.f);
|
||||
EXPECT_NEAR(resolve.clearDepthValue, 0x123456 / 16777216.f, 1.f / 16777216.f);
|
||||
const float gxDepth = 0x123456 / 16777216.f;
|
||||
EXPECT_NEAR(resolve.clearDepthValue, aurora::gx::UseReversedZ ? 1.f - gxDepth : gxDepth,
|
||||
1.f / 16777216.f);
|
||||
EXPECT_EQ(resolve.resolveFormat, GX_TF_RGBA8);
|
||||
EXPECT_FALSE(resolve.halfScale);
|
||||
EXPECT_FALSE(resolve.forceOpaqueAlpha);
|
||||
@@ -4186,7 +4267,7 @@ TEST_F(GXFifoTest, CopyTexColorFormatMarksResolvePersistent) {
|
||||
EXPECT_TRUE(records.front().persistentCopy);
|
||||
}
|
||||
|
||||
TEST_F(GXFifoTest, RecurringColorCopyKeepsLaterResolveSkippable) {
|
||||
TEST_F(GXFifoTest, RecurringColorCopyPreservesEveryResolve) {
|
||||
std::array<u8, 152 * 114 * 4> image{};
|
||||
gxState().pixelFmt = GX_PF_RGBA6_Z24;
|
||||
|
||||
@@ -4200,7 +4281,7 @@ TEST_F(GXFifoTest, RecurringColorCopyKeepsLaterResolveSkippable) {
|
||||
const auto& records = aurora::gfx::testing::resolve_pass_records();
|
||||
ASSERT_EQ(records.size(), 2u);
|
||||
EXPECT_TRUE(records[0].persistentCopy);
|
||||
EXPECT_FALSE(records[1].persistentCopy);
|
||||
EXPECT_TRUE(records[1].persistentCopy);
|
||||
}
|
||||
|
||||
TEST_F(GXFifoTest, ColorCopyAfterFrameGapRegainsPersistentProtection) {
|
||||
@@ -4220,7 +4301,7 @@ TEST_F(GXFifoTest, ColorCopyAfterFrameGapRegainsPersistentProtection) {
|
||||
EXPECT_TRUE(records[1].persistentCopy);
|
||||
}
|
||||
|
||||
TEST_F(GXFifoTest, CopyTexDepthFormatKeepsResolveSkippable) {
|
||||
TEST_F(GXFifoTest, CopyTexDepthFormatPreservesResolve) {
|
||||
std::array<u8, 4 * 4 * 4> image{};
|
||||
gxState().pixelFmt = GX_PF_RGBA6_Z24;
|
||||
|
||||
@@ -4230,7 +4311,7 @@ TEST_F(GXFifoTest, CopyTexDepthFormatKeepsResolveSkippable) {
|
||||
|
||||
const auto& records = aurora::gfx::testing::resolve_pass_records();
|
||||
ASSERT_EQ(records.size(), 1u);
|
||||
EXPECT_FALSE(records.front().persistentCopy);
|
||||
EXPECT_TRUE(records.front().persistentCopy);
|
||||
}
|
||||
|
||||
TEST_F(GXFifoTest, CopyDispResolveIsNotPersistent) {
|
||||
|
||||
@@ -299,6 +299,10 @@ std::pair<ByteBuffer, Range> copy_uniform(Range source) {
|
||||
return map_uniform(source.size);
|
||||
}
|
||||
uint32_t align_uniform(uint32_t value) { return (value + 255u) & ~255u; }
|
||||
uint64_t staging_uniform_bytes(uint64_t value) { return staging_padded(value, 256); }
|
||||
uint64_t staging_storage_bytes(uint64_t value) { return staging_padded(value, 256); }
|
||||
bool staging_has_space(const StagingSizes&) { return true; }
|
||||
void split_staging_batch() { throw StagingCapacityError("Unexpected split in FIFO unit test"); }
|
||||
|
||||
Vec2<uint32_t> get_render_target_size() noexcept { return s_renderTargetSize; }
|
||||
Vec2<uint32_t> get_frame_buffer_size() noexcept { return s_renderTargetSize; }
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
#include "gx_test_common.hpp"
|
||||
#include "gfx/staging_map.hpp"
|
||||
#include "gx/pipeline.hpp"
|
||||
|
||||
#include <thread>
|
||||
|
||||
using aurora::gx::g_gxState;
|
||||
|
||||
namespace {
|
||||
std::vector<u8> draw(GXPrimitive primitive, u16 count, GXVtxFmt format = GX_VTXFMT0) {
|
||||
std::vector<u8> bytes{static_cast<u8>(primitive | format), static_cast<u8>(count >> 8),
|
||||
static_cast<u8>(count)};
|
||||
bytes.resize(3 + count);
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(GXFifoTest, MaximumQuadCountTerminatesWithoutOutOfRangeIndices) {
|
||||
g_gxState.lastVtxFmt = GX_VTXFMT0;
|
||||
g_gxState.lastVtxSize = 1;
|
||||
for (const u16 count : {65532, 65533, 65534, 65535}) {
|
||||
g_gxState.stateDirty = true;
|
||||
decode_fifo(draw(GX_QUADS, count));
|
||||
const auto& indices = aurora::gfx::testing::last_pushed_indices();
|
||||
ASSERT_EQ(indices.size(), (count / 4) * 6 + (count % 4 == 3 ? 3 : 0));
|
||||
for (const auto index : indices) ASSERT_LT(index, count);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(GXFifoTest, IncompletePrimitivesNeverJoinAcrossDraws) {
|
||||
g_gxState.lastVtxFmt = GX_VTXFMT0;
|
||||
g_gxState.lastVtxSize = 1;
|
||||
aurora::gfx::testing::use_draw_command_tracking(true);
|
||||
decode_fifo(draw(GX_TRIANGLES, 4));
|
||||
EXPECT_EQ(aurora::gfx::testing::last_pushed_indices(), (std::vector<u16>{0, 1, 2}));
|
||||
decode_fifo(draw(GX_TRIANGLES, 5));
|
||||
EXPECT_EQ(aurora::gfx::testing::last_pushed_indices(), (std::vector<u16>{4, 5, 6}));
|
||||
const auto before = aurora::gfx::testing::last_pushed_indices();
|
||||
decode_fifo(draw(GX_TRIANGLEFAN, 2));
|
||||
EXPECT_EQ(aurora::gfx::testing::last_pushed_indices(), before);
|
||||
}
|
||||
|
||||
TEST_F(GXFifoTest, MergeStopsBeforeSixteenBitIndexOverflow) {
|
||||
g_gxState.lastVtxFmt = GX_VTXFMT0;
|
||||
g_gxState.lastVtxSize = 1;
|
||||
aurora::gfx::testing::use_draw_command_tracking(true);
|
||||
decode_fifo(draw(GX_TRIANGLES, 65535));
|
||||
decode_fifo(draw(GX_TRIANGLES, 3));
|
||||
EXPECT_EQ(aurora::gfx::g_mergedDrawCallCount, 0u);
|
||||
EXPECT_EQ(aurora::gfx::testing::last_pushed_indices(), (std::vector<u16>{0, 1, 2}));
|
||||
}
|
||||
|
||||
TEST_F(GXFifoTest, VertexCacheInvalidationBreaksDrawMerging) {
|
||||
g_gxState.lastVtxFmt = GX_VTXFMT0;
|
||||
g_gxState.lastVtxSize = 1;
|
||||
aurora::gfx::testing::use_draw_command_tracking(true);
|
||||
decode_fifo(draw(GX_TRIANGLES, 3));
|
||||
decode_fifo({GX_CMD_INVL_VC});
|
||||
EXPECT_TRUE(g_gxState.stateDirty);
|
||||
decode_fifo(draw(GX_TRIANGLES, 3));
|
||||
EXPECT_EQ(aurora::gfx::g_mergedDrawCallCount, 0u);
|
||||
}
|
||||
|
||||
TEST_F(GXFifoTest, EqualStrideVertexFormatChangeBreaksDrawMerging) {
|
||||
aurora::gfx::testing::use_real_vertex_format_helpers(true);
|
||||
g_gxState.vtxDesc[GX_VA_POS] = GX_DIRECT;
|
||||
for (const auto format : {GX_VTXFMT0, GX_VTXFMT1}) {
|
||||
g_gxState.vtxFmts[format].attrs[GX_VA_POS].cnt = GX_POS_XY;
|
||||
g_gxState.vtxFmts[format].attrs[GX_VA_POS].type = GX_U8;
|
||||
}
|
||||
g_gxState.vtxFmts[GX_VTXFMT1].attrs[GX_VA_POS].frac = 1;
|
||||
aurora::gfx::testing::use_draw_command_tracking(true);
|
||||
for (const auto format : {GX_VTXFMT0, GX_VTXFMT1}) {
|
||||
auto bytes = draw(GX_TRIANGLES, 3, format);
|
||||
bytes.resize(9);
|
||||
decode_fifo(bytes);
|
||||
}
|
||||
EXPECT_EQ(aurora::gfx::g_mergedDrawCallCount, 0u);
|
||||
}
|
||||
|
||||
TEST_F(GXFifoTest, SingleExpandedPrimitiveCannotMergeWithTriangles) {
|
||||
g_gxState.lastVtxFmt = GX_VTXFMT0;
|
||||
g_gxState.lastVtxSize = 1;
|
||||
aurora::gfx::testing::use_draw_command_tracking(true);
|
||||
decode_fifo(draw(GX_POINTS, 1));
|
||||
decode_fifo(draw(GX_TRIANGLES, 3));
|
||||
EXPECT_EQ(aurora::gfx::g_mergedDrawCallCount, 0u);
|
||||
EXPECT_EQ(aurora::gfx::testing::last_pushed_indices(), (std::vector<u16>{0, 1, 2}));
|
||||
}
|
||||
|
||||
TEST(StagingMapping, RetiredCallbacksCannotPublishAnotherBuffersReadiness) {
|
||||
using namespace aurora::gfx;
|
||||
StagingMapState state;
|
||||
const auto old = state.request();
|
||||
EXPECT_EQ(state.request(), 0u);
|
||||
state.reset();
|
||||
const auto current = state.request();
|
||||
EXPECT_FALSE(state.complete(old, BufferMapState::Mapped));
|
||||
EXPECT_FALSE(state.complete(old, BufferMapState::Unmapped));
|
||||
EXPECT_EQ(state.state(), BufferMapState::Mapping);
|
||||
EXPECT_TRUE(state.complete(current, BufferMapState::Mapped));
|
||||
EXPECT_FALSE(state.complete(current, BufferMapState::Unmapped));
|
||||
EXPECT_EQ(state.state(), BufferMapState::Mapped);
|
||||
}
|
||||
|
||||
TEST(StagingMapping, AsyncCompletionWakesWaiters) {
|
||||
using namespace aurora::gfx;
|
||||
StagingMapState state;
|
||||
const auto generation = state.request();
|
||||
std::thread callback([&] {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
state.complete(generation, BufferMapState::Mapped);
|
||||
});
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
|
||||
while (state.state() == BufferMapState::Mapping && std::chrono::steady_clock::now() < deadline)
|
||||
state.wait_for_progress();
|
||||
callback.join();
|
||||
EXPECT_EQ(state.state(), BufferMapState::Mapped);
|
||||
}
|
||||
|
||||
TEST(StagingCapacity, ReservesPaddingAndRejectsOverflow) {
|
||||
using namespace aurora::gfx;
|
||||
EXPECT_EQ(staging_padded(257, 256), 512u);
|
||||
EXPECT_THROW(staging_padded(UINT64_MAX, 256), StagingCapacityError);
|
||||
const StagingSizes used{0, 256, 0, 0}, demand{0, 256, 0, 0}, tail{0, 3840, 0, 0};
|
||||
EXPECT_TRUE(staging_fits(used, demand, tail, {4, 4352, 4, 4}));
|
||||
EXPECT_FALSE(staging_fits(used, demand, tail, {4, 4351, 4, 4}));
|
||||
EXPECT_FALSE(staging_fits({UINT64_MAX, 0, 0, 0}, {1, 0, 0, 0}, {},
|
||||
{UINT64_MAX, UINT64_MAX, UINT64_MAX, UINT64_MAX}));
|
||||
}
|
||||
|
||||
TEST(FrameInterpolationContract, IdenticalMeshesInDifferentViewportsDoNotShareHistory) {
|
||||
using namespace aurora;
|
||||
const auto savedViewport = gx::g_gxState.logicalViewport;
|
||||
constexpr size_t positionOffset = sizeof(Mat4x4<float>);
|
||||
constexpr size_t normalOffset = positionOffset + gx::MaxPnMtx * sizeof(Mat3x4<float>);
|
||||
constexpr size_t uniformSize = normalOffset + gx::MaxPnMtx * sizeof(Mat3x4<float>);
|
||||
const gx::FrameInterpolationDrawIdentity identity{0x1234, 0x5678, 0x9abc, 0xdef0};
|
||||
const Mat4x4<float> projection{};
|
||||
const auto record = [&](float x, std::array<uint8_t, uniformSize>& source) {
|
||||
gx::g_gxState.pnMtx[0].pos = {{1.f, 0.f, 0.f, x}, {0.f, 1.f, 0.f, 0.f}, {0.f, 0.f, 1.f, 0.f}};
|
||||
gx::g_gxState.pnMtx[0].nrm = {{1.f, 0.f, 0.f, 0.f}, {0.f, 1.f, 0.f, 0.f}, {0.f, 0.f, 1.f, 0.f}};
|
||||
std::memcpy(source.data() + positionOffset, &gx::g_gxState.pnMtx[0].pos, sizeof(Mat3x4<float>));
|
||||
std::memcpy(source.data() + normalOffset, &gx::g_gxState.pnMtx[0].nrm, sizeof(Mat3x4<float>));
|
||||
return gx::record_interpolation_draw(identity, projection, 1, {
|
||||
.sourceUniformData = source.data(), .uniformSize = source.size(), .projectionOffset = 0,
|
||||
.positionOffset = positionOffset, .normalOffset = normalOffset, .currentMatrix = 0,
|
||||
.indexedMatrices = true});
|
||||
};
|
||||
gx::set_frame_interpolation_fps(0);
|
||||
gx::begin_frame_interpolation();
|
||||
gx::set_frame_interpolation_fps(120);
|
||||
gx::g_gxState.logicalViewport = {0.f, 0.f, 640.f, 240.f, 0.f, 1.f};
|
||||
std::array<uint8_t, uniformSize> previous{};
|
||||
gx::begin_frame_interpolation();
|
||||
record(0.f, previous);
|
||||
gx::finalize_frame_interpolation();
|
||||
gfx::testing::reset_uniform_allocations();
|
||||
gx::g_gxState.logicalViewport.top = 240.f;
|
||||
std::array<uint8_t, uniformSize> current{};
|
||||
gx::begin_frame_interpolation();
|
||||
const auto ranges = record(20.f, current);
|
||||
const auto expected = current;
|
||||
gx::finalize_frame_interpolation();
|
||||
EXPECT_EQ(current, expected);
|
||||
if (ranges[0].size) {
|
||||
const auto& duplicate = gfx::testing::uniform_allocation(ranges[0].offset);
|
||||
ASSERT_EQ(duplicate.size(), expected.size());
|
||||
EXPECT_EQ(std::memcmp(duplicate.data(), expected.data(), expected.size()), 0);
|
||||
}
|
||||
gx::g_gxState.logicalViewport = savedViewport;
|
||||
gx::set_frame_interpolation_fps(0);
|
||||
gx::begin_frame_interpolation();
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
# Building WiiCompiled and Retro Rewind on macOS
|
||||
|
||||
This guide covers building **WiiCompiled** (base game) and **Retro Rewind** from source on macOS for Apple Silicon (`arm64`). Follow these instructions to compile the native executables directly.
|
||||
|
||||
> [!NOTE]
|
||||
> If you only want to build the base game (**WiiCompiled**), look for sections marked **`(Skip if only building WiiCompiled)`** to bypass Retro Rewind and online payload steps.
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
### System Requirements
|
||||
- **Hardware**: Apple Silicon Mac (M1/M2/M3/M4)
|
||||
- **Operating System**: macOS 14 (Sonoma) or later
|
||||
- **Xcode Command Line Tools**:
|
||||
```bash
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
### Toolchain Dependencies
|
||||
Install the required tools using [Homebrew](https://brew.sh):
|
||||
```bash
|
||||
brew install cmake ninja
|
||||
brew install --cask dotnet-sdk@8
|
||||
```
|
||||
|
||||
Verify that Clang, CMake, Ninja, and the .NET 8 runtime are available:
|
||||
```bash
|
||||
clang --version
|
||||
cmake --version
|
||||
ninja --version
|
||||
dotnet --list-runtimes # Must list Microsoft.NETCore.App 8.x
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Required Game and Mod Assets
|
||||
|
||||
Due to legal requirements, no proprietary Nintendo assets or code are included in this repository. You must provide your own legally dumped game files.
|
||||
|
||||
1. **Mario Kart Wii PAL (`RMCP01`) Disc Image** *(Required)*:
|
||||
- Supported formats: `.iso`, `.wbfs`, `.ciso`, `.rvz`, `.gcm`, `.gcz`.
|
||||
2. **nodtool** *(Required for disc extraction)*:
|
||||
- Download the macOS Apple Silicon binary of [nodtool](https://github.com/encounter/nod/releases):
|
||||
```bash
|
||||
curl -fsSL "https://github.com/encounter/nod/releases/download/v2.0.0-alpha.10/nodtool-macos-arm64" -o nodtool
|
||||
chmod +x nodtool
|
||||
```
|
||||
3. **Retro Rewind Distribution** *(Skip if only building WiiCompiled)*:
|
||||
- Download the [Retro Rewind](https://wiki.tockdom.com/wiki/Retro_Rewind) release package. You will need the `RetroRewind6` folder (which contains `Binaries/Code.pul`).
|
||||
4. **Retro-WFC Payload** *(Skip if only building WiiCompiled or building offline)*:
|
||||
- Required for online multiplayer on Retro Rewind. Downloaded during setup from `https://rwfc.net/api/wfc/payload?g=RMCPD00`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Step 1: Extract Disc Assets
|
||||
|
||||
Extract your clean PAL `RMCP01` disc into the `Assets/` directory of the repository:
|
||||
|
||||
```bash
|
||||
# Using nodtool directly into a temporary scratch directory
|
||||
mkdir -p /tmp/mkw-extract
|
||||
./nodtool extract /path/to/RMCP01.iso /tmp/mkw-extract
|
||||
|
||||
# Copy extracted assets into the repository Assets directory
|
||||
rm -rf Assets/DATA/files Assets/DATA/sys
|
||||
mkdir -p Assets/DATA
|
||||
cp /tmp/mkw-extract/*/sys/main.dol Assets/main.dol
|
||||
cp /tmp/mkw-extract/*/files/rel/StaticR.rel Assets/StaticR.rel
|
||||
cp -R /tmp/mkw-extract/*/files Assets/DATA/files
|
||||
cp -R /tmp/mkw-extract/*/sys Assets/DATA/sys
|
||||
|
||||
# Clean up temporary files
|
||||
rm -rf /tmp/mkw-extract
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> Alternatively, you can use the repository's helper script:
|
||||
> ```bash
|
||||
> Launcher/macos/extract-disc.command --game /path/to/RMCP01.iso --assets-dir Assets --nodtool ./nodtool
|
||||
> ```
|
||||
|
||||
### Verify Extracted Asset Hashes
|
||||
Confirm that the extracted files match the expected clean PAL revision:
|
||||
```bash
|
||||
shasum -a 256 Assets/main.dol Assets/StaticR.rel
|
||||
```
|
||||
- `Assets/main.dol`: `80d18895b39c63bd80f457398bfcbb91b7d16ac116a41a88967e954080155b05`
|
||||
- `Assets/StaticR.rel`: `16d9d146112541fefea701ecb5bc1a496f9d50e4a752fbb5b6778e7c6399f67d`
|
||||
|
||||
---
|
||||
|
||||
## 4. Step 2: Build the Translator CLI
|
||||
|
||||
Compile the static recompiler CLI:
|
||||
|
||||
```bash
|
||||
dotnet build translator/src/Translator.Cli/Translator.Cli.csproj -c Release
|
||||
```
|
||||
|
||||
Define a shell function to invoke the translator (ensuring paths with spaces are handled safely):
|
||||
```bash
|
||||
translator() {
|
||||
dotnet "$(pwd)/translator/src/Translator.Cli/bin/Release/net8.0/Translator.Cli.dll" "$@"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Step 3: Translation
|
||||
|
||||
### A. Translate Base Game Functions
|
||||
```bash
|
||||
mkdir -p generated/functions build/base
|
||||
|
||||
translator translate-recursive 0x800060A4 \
|
||||
--project projects/mkwii/recomp.yml \
|
||||
--outdir generated/functions \
|
||||
--output-metadata generated/base_translation_output.json \
|
||||
--production-source-bundle generated/base_translation_sources.bin \
|
||||
--no-function-files \
|
||||
--prune-stale \
|
||||
--threads $(sysctl -n hw.ncpu)
|
||||
```
|
||||
|
||||
### B. Emit Base Manifest
|
||||
```bash
|
||||
translator emit-base-manifest \
|
||||
--project projects/mkwii/recomp.yml \
|
||||
--out build/base \
|
||||
--functions-dir generated/functions \
|
||||
--translation-output-metadata generated/base_translation_output.json \
|
||||
--region P
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### C. Stage and Translate Retro Rewind *(Skip this step if you only want to build WiiCompiled)*
|
||||
|
||||
1. Stage `Code.pul`:
|
||||
```bash
|
||||
RETRO_DIR="/path/to/RetroRewind6"
|
||||
mkdir -p PulsarPacks/completed/RetroRewind/RetroRewind6/Binaries
|
||||
cp "$RETRO_DIR/Binaries/Code.pul" PulsarPacks/completed/RetroRewind/RetroRewind6/Binaries/Code.pul
|
||||
```
|
||||
|
||||
2. **Retro-WFC Payload Setup (for Online Multiplayer)**:
|
||||
Online play in Retro Rewind requires the shared Retro-WFC payload. Download and validate it:
|
||||
```bash
|
||||
mkdir -p build/retro-wfc/binary
|
||||
curl -fsSL --retry 3 "https://rwfc.net/api/wfc/payload?g=RMCPD00" \
|
||||
-o build/retro-wfc/binary/payload.RMCPD00.bin
|
||||
|
||||
# Validate payload signature and integrity
|
||||
translator validate-retro-wfc-payload --directory build/retro-wfc
|
||||
```
|
||||
|
||||
3. Run Retro Rewind translation:
|
||||
```bash
|
||||
mkdir -p build/mods/retro_rewind_full_cpp
|
||||
|
||||
translator translate-mod \
|
||||
--project projects/mkwii/recomp.yml \
|
||||
--profile retro-rewind \
|
||||
--base-manifest build/base/mkwii_base_manifest.json \
|
||||
--base-translation-output-metadata generated/base_translation_output.json \
|
||||
--code-pul "$RETRO_DIR/Binaries/Code.pul" \
|
||||
--mod-root "$RETRO_DIR" \
|
||||
--mod-name "Retro Rewind" \
|
||||
--region P \
|
||||
--out build/mods/retro_rewind_full_cpp \
|
||||
--prefer-cached-inputs \
|
||||
--emit-cpp \
|
||||
--threads $(sysctl -n hw.ncpu) \
|
||||
--retro-wfc-payload build/retro-wfc/binary/payload.RMCPD00.bin
|
||||
```
|
||||
> [!TIP]
|
||||
> If you do not want online play or do not have an internet connection, replace `--retro-wfc-payload ...` with `--skip-retro-wfc`.
|
||||
|
||||
---
|
||||
|
||||
### D. Generate Data Initialization and Build Shards
|
||||
|
||||
First, generate the embedded game data initializer:
|
||||
```bash
|
||||
translator generate-data-init --project projects/mkwii/recomp.yml
|
||||
```
|
||||
|
||||
Next, generate the CMake build shards using **one** of the following options:
|
||||
|
||||
#### Option 1: Base Game Only (WiiCompiled)
|
||||
```bash
|
||||
mkdir -p generated/build_shards
|
||||
translator emit-build-shards \
|
||||
--project projects/mkwii/recomp.yml \
|
||||
--base-metadata generated/base_translation_output.json \
|
||||
--base-functions-dir generated/functions \
|
||||
--native-source-dir runtime/src \
|
||||
--out generated/build_shards
|
||||
```
|
||||
|
||||
#### Option 2: Base Game + Retro Rewind
|
||||
```bash
|
||||
mkdir -p generated/build_shards
|
||||
translator emit-build-shards \
|
||||
--project projects/mkwii/recomp.yml \
|
||||
--base-metadata generated/base_translation_output.json \
|
||||
--base-functions-dir generated/functions \
|
||||
--native-source-dir runtime/src \
|
||||
--out generated/build_shards \
|
||||
--resolved-profile build/mods/retro_rewind_full_cpp/resolved_dispatch_profile.json \
|
||||
--retro-cpp-dir build/mods/retro_rewind_full_cpp/cpp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Step 4: Configure and Compile with CMake & Ninja
|
||||
|
||||
Configure the native C++ build targeting Apple Silicon:
|
||||
|
||||
```bash
|
||||
cmake -S runtime -B build-macos -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_C_COMPILER=clang \
|
||||
-DCMAKE_CXX_COMPILER=clang++ \
|
||||
-DAURORA_SDL3_PROVIDER=vendor
|
||||
```
|
||||
|
||||
Compile the desired target:
|
||||
|
||||
```bash
|
||||
# To build WiiCompiled only:
|
||||
cmake --build build-macos --target WiiCompiled --parallel $(sysctl -n hw.ncpu)
|
||||
|
||||
# OR to build both WiiCompiled and Retro Rewind:
|
||||
cmake --build build-macos --target WiiCompiled RetroRewind --parallel $(sysctl -n hw.ncpu)
|
||||
```
|
||||
|
||||
Once compilation completes, the executables are ready in your build directory:
|
||||
- `build-macos/WiiCompiled`
|
||||
- `build-macos/RetroRewind` (if built)
|
||||
|
||||
During the build, CMake automatically copies the required runtime assets into `build-macos/`:
|
||||
- `build-macos/dsp_coef.bin`
|
||||
- `build-macos/initial_pipeline_cache.db`
|
||||
- `build-macos/wii_bootstrap/`
|
||||
|
||||
---
|
||||
|
||||
## 7. Step 5: Running Executables from the Build Folder
|
||||
|
||||
### Configure `Config.toml`
|
||||
The runtime reads configuration from `~/Library/Application Support/WiiCompiled/Config.toml`.
|
||||
|
||||
Create the directory and configuration file:
|
||||
|
||||
```bash
|
||||
mkdir -p "$HOME/Library/Application Support/WiiCompiled"
|
||||
```
|
||||
|
||||
#### For Base Game Only (WiiCompiled):
|
||||
```toml
|
||||
# ~/Library/Application Support/WiiCompiled/Config.toml
|
||||
[video]
|
||||
widescreen = true
|
||||
resolution_multiplier = 1.0
|
||||
graphics_api = "metal"
|
||||
|
||||
[paths]
|
||||
dvd_root = "/absolute/path/to/Wiicompiled/Assets/DATA"
|
||||
```
|
||||
|
||||
#### For Base Game and Retro Rewind:
|
||||
```toml
|
||||
# ~/Library/Application Support/WiiCompiled/Config.toml
|
||||
[video]
|
||||
widescreen = true
|
||||
resolution_multiplier = 1.0
|
||||
graphics_api = "metal"
|
||||
|
||||
[paths]
|
||||
dvd_root = "/absolute/path/to/Wiicompiled/Assets/DATA"
|
||||
retro_rewind_root = "/path/to/RetroRewind6"
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Ensure `dvd_root` points to the directory containing `files` and `sys/fst.bin`.
|
||||
|
||||
### Launching the Game
|
||||
Run the compiled binaries directly from your terminal or by double clicking:
|
||||
|
||||
```bash
|
||||
# Run base WiiCompiled
|
||||
./build-macos/WiiCompiled
|
||||
|
||||
# Run Retro Rewind
|
||||
./build-macos/RetroRewind
|
||||
```
|
||||
|
||||
|
||||
|
||||
Press **F10** in-game at any time to open the configuration bar (controls, resolution, display settings, audio).
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: Automated Helper Script
|
||||
|
||||
The repository provides a script (`Launcher/local-build-macos.command`) that handles extraction, translation, and compilation in a single command.
|
||||
|
||||
### Building Base Game Only:
|
||||
```bash
|
||||
Launcher/local-build-macos.command \
|
||||
--profile base \
|
||||
--output-dir build-macos/Products \
|
||||
--game /path/to/RMCP01.iso \
|
||||
--nodtool ./nodtool
|
||||
```
|
||||
|
||||
### Building Both (with Online Retro-WFC Payload):
|
||||
```bash
|
||||
# 1. Download Retro-WFC payload into a staging directory:
|
||||
mkdir -p build/retro-wfc/binary
|
||||
curl -fsSL --retry 3 "https://rwfc.net/api/wfc/payload?g=RMCPD00" \
|
||||
-o build/retro-wfc/binary/payload.RMCPD00.bin
|
||||
|
||||
# 2. Run the automated build with the payload directory:
|
||||
Launcher/local-build-macos.command \
|
||||
--profile both \
|
||||
--output-dir build-macos/Products \
|
||||
--base-output-dir build-macos/Products \
|
||||
--game /path/to/RMCP01.iso \
|
||||
--nodtool ./nodtool \
|
||||
--retro-rewind-package-dir /path/to/RetroRewind6 \
|
||||
--retro-wfc-offline-dir build/retro-wfc
|
||||
```
|
||||
|
||||
### Building Both (Offline, Skipping Payload):
|
||||
```bash
|
||||
Launcher/local-build-macos.command \
|
||||
--profile both \
|
||||
--output-dir build-macos/Products \
|
||||
--base-output-dir build-macos/Products \
|
||||
--game /path/to/RMCP01.iso \
|
||||
--nodtool ./nodtool \
|
||||
--retro-rewind-package-dir /path/to/RetroRewind6 \
|
||||
--skip-retro-wfc-payload
|
||||
```
|
||||
|
||||
When finished, the compiled executables reside in `native-build-macos/` and the bundled `.app` packages are placed in `build-macos/Products/`.
|
||||
@@ -58,7 +58,7 @@ profiles:
|
||||
module_link_base: 0x803992E0
|
||||
output: build/mods/retro_rewind_full_cpp
|
||||
enable_retro_wfc: true
|
||||
retro_wfc_payload: http://nas.play.rwfc.net/payload?g=RMCPD00
|
||||
retro_wfc_payload: https://rwfc.net/api/wfc/payload?g=RMCPD00
|
||||
retro_wfc_legacy_bootstrap_hook: 0x800ED6E8
|
||||
riivolution:
|
||||
xml: xml/RetroRewind6.xml
|
||||
|
||||
@@ -107,6 +107,43 @@ if(NOT MKW_NATIVE_PREBUILT_DIR)
|
||||
set_target_properties(mkw_cryptopp PROPERTIES UNITY_BUILD OFF)
|
||||
endif()
|
||||
|
||||
# TLS for non-Windows guest network HLE (runtime/src/hle/net/network_ssl.cpp) - the Windows path
|
||||
# uses Schannel (a Windows-only OS API), which has no equivalent on Linux/Android, so this project
|
||||
# needs its own TLS library there. mbed TLS was chosen over OpenSSL specifically because it cross-
|
||||
# compiles cleanly for Android with nothing beyond a plain C toolchain (no perl/asm build-script
|
||||
# dependency the way OpenSSL's build has), matching how this project already prefers toolchain-
|
||||
# simple libraries (see Crypto++ above, similarly stripped of ASM/SIMD for portability).
|
||||
# Fetched at build time from a pinned upstream release tarball with a checked SHA-256, the same way
|
||||
# aurora-main's own dependencies (SDL, zlib, etc.) are pulled in - not committed as a vendored
|
||||
# source tree, so the repository ships the compiled dependency rather than ~280 tracked upstream
|
||||
# files. Bump MKW_MBEDTLS_VERSION/MKW_MBEDTLS_SHA256 together when updating; the hash comes from
|
||||
# upstream's own signed `mbedtls-<version>-sha256sum.txt` release asset.
|
||||
#
|
||||
# The alias exists on every platform so the link lines in cmake/PublicProducts.cmake stay
|
||||
# platform-independent, but it is only populated where network_ssl.cpp actually compiles the mbed
|
||||
# TLS path (`#ifndef _WIN32`). Windows keeps Schannel and must not fetch anything: its builds run
|
||||
# with FETCHCONTENT_FULLY_DISCONNECTED=ON against the offline dependency set prepared by
|
||||
# Launcher/Prepare-Dependencies.ps1, so an unconditional fetch would fail a clean configure there
|
||||
# and would also add a dependency Windows never links.
|
||||
add_library(mkw_mbedtls INTERFACE)
|
||||
add_library(mkw::mbedtls ALIAS mkw_mbedtls)
|
||||
if(NOT MKW_PLATFORM_WINDOWS)
|
||||
include(FetchContent)
|
||||
set(MKW_MBEDTLS_VERSION "3.6.7")
|
||||
set(MKW_MBEDTLS_SHA256 "a7e8bcbec0e6f761b4af24f25677626b35f762f68eef79c08677a363212d11f6")
|
||||
FetchContent_Declare(mkw_mbedtls_upstream
|
||||
URL "https://github.com/Mbed-TLS/mbedtls/releases/download/mbedtls-${MKW_MBEDTLS_VERSION}/mbedtls-${MKW_MBEDTLS_VERSION}.tar.bz2"
|
||||
URL_HASH SHA256=${MKW_MBEDTLS_SHA256})
|
||||
# Subproject mode already defaults ENABLE_TESTING off and skips codegen (GEN_FILES), but
|
||||
# ENABLE_PROGRAMS defaults on and installation/package-config isn't wanted for a linked-in copy.
|
||||
set(ENABLE_PROGRAMS OFF CACHE BOOL "" FORCE)
|
||||
set(ENABLE_TESTING OFF CACHE BOOL "" FORCE)
|
||||
set(MBEDTLS_FATAL_WARNINGS OFF CACHE BOOL "" FORCE)
|
||||
set(DISABLE_PACKAGE_CONFIG_AND_INSTALL ON CACHE BOOL "" FORCE)
|
||||
FetchContent_MakeAvailable(mkw_mbedtls_upstream)
|
||||
target_link_libraries(mkw_mbedtls INTERFACE MbedTLS::mbedtls MbedTLS::mbedx509 MbedTLS::mbedcrypto)
|
||||
endif()
|
||||
|
||||
set(MKW_TRANSLATED_COMPILE_JOBS 0 CACHE STRING
|
||||
"Cap on concurrently compiling translated shard TUs via a Ninja job pool (0 = uncapped). \
|
||||
Scheduling only - never affects output bytes, so it is deliberately outside the canonical flag fingerprint.")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -81,7 +81,7 @@ target_compile_definitions(mkw_runtime_common PRIVATE
|
||||
_DISABLE_STRING_ANNOTATION _DISABLE_VECTOR_ANNOTATION)
|
||||
target_link_libraries(mkw_runtime_common PRIVATE
|
||||
aurora::gx aurora::pad aurora::si aurora::vi aurora::mtx)
|
||||
target_link_libraries(mkw_runtime_common PRIVATE mkw_platform mkw::pugixml mkw::toml11 mkw::cryptopp)
|
||||
target_link_libraries(mkw_runtime_common PRIVATE mkw_platform mkw::pugixml mkw::toml11 mkw::cryptopp mkw::mbedtls)
|
||||
if(MKW_PLATFORM_WINDOWS)
|
||||
target_link_libraries(mkw_runtime_common PRIVATE shell32 windowsapp)
|
||||
elseif(MKW_PLATFORM_LINUX)
|
||||
@@ -199,7 +199,7 @@ function(mkw_configure_product target)
|
||||
# include the same fat translated headers; bound them by the same pool.
|
||||
mkw_bound_translated_compiles(${target})
|
||||
target_link_libraries(${target} PRIVATE
|
||||
mkw_platform mkw_base_shared mkw::pugixml mkw::toml11 mkw::cryptopp)
|
||||
mkw_platform mkw_base_shared mkw::pugixml mkw::toml11 mkw::cryptopp mkw::mbedtls)
|
||||
|
||||
target_link_libraries(${target} PRIVATE
|
||||
aurora::gx aurora::pad aurora::si aurora::vi aurora::mtx)
|
||||
@@ -286,6 +286,21 @@ function(mkw_configure_product target)
|
||||
add_custom_command(TARGET ${target} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${MKW_INITIAL_PIPELINE_CACHE}"
|
||||
"$<TARGET_FILE_DIR:${target}>/initial_pipeline_cache.db")
|
||||
|
||||
# Non-Windows TLS (runtime/src/hle/net/network_ssl.cpp's mbed TLS path) needs a trusted root
|
||||
# CA bundle to verify server certificates against - Windows gets this for free from the OS via
|
||||
# Schannel, mbed TLS does not ship one itself. Not SHA256-pinned like the DSP ROM above: unlike
|
||||
# a fixed hardware ROM, this bundle is expected to be refreshed periodically as CAs rotate.
|
||||
# Windows gets its trust store from Schannel, so only the platforms that actually build the
|
||||
# mbed TLS path need the bundle beside the executable.
|
||||
if(NOT MKW_PLATFORM_WINDOWS)
|
||||
set(MKW_CA_CERTIFICATE_BUNDLE "${MKW_RUNTIME_SOURCE_DIR}/assets/certs/cacert.pem")
|
||||
if(NOT EXISTS "${MKW_CA_CERTIFICATE_BUNDLE}")
|
||||
message(FATAL_ERROR "Missing TLS root CA bundle: ${MKW_CA_CERTIFICATE_BUNDLE}")
|
||||
endif()
|
||||
add_custom_command(TARGET ${target} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${MKW_CA_CERTIFICATE_BUNDLE}" "$<TARGET_FILE_DIR:${target}>/cacert.pem")
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
add_executable(WiiCompiled "${MKW_BASE_PRODUCT_SOURCE}" ${MKW_BASE_REGISTRATION_SOURCES})
|
||||
|
||||
@@ -58,6 +58,7 @@ inline void Flush(bool force = false) {
|
||||
// still active. A window close is an intentional successful exit, so end the
|
||||
// process directly and do not run the crash/atexit paths.
|
||||
[[noreturn]] inline void ExitForAuroraWindowClose() noexcept {
|
||||
settings_overlay::ReleaseControllers();
|
||||
WindowPlacementPersistence::Flush(true);
|
||||
#if defined(_WIN32)
|
||||
::ExitProcess(0);
|
||||
|
||||
@@ -48,8 +48,19 @@ struct NativeButtonItem {
|
||||
uint32_t nativeButton;
|
||||
};
|
||||
|
||||
inline constexpr std::array<NativeButtonItem, SDL_GAMEPAD_BUTTON_COUNT + 1> kNativeButtons = {{
|
||||
{"unmapped", "Unmapped / analog trigger", PAD_NATIVE_BUTTON_INVALID},
|
||||
inline constexpr auto kNativeButtons = std::to_array<NativeButtonItem>({
|
||||
{"disabled", "Unmapped", PAD_NATIVE_BUTTON_DISABLED},
|
||||
{"left_trigger", "Left trigger (LT / L2)", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_LEFT_TRIGGER, false)},
|
||||
{"right_trigger", "Right trigger (RT / R2)", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, false)},
|
||||
{"left_stick_left", "Left stick left", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_LEFTX, true)},
|
||||
{"left_stick_right", "Left stick right", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_LEFTX, false)},
|
||||
{"left_stick_up", "Left stick up", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_LEFTY, true)},
|
||||
{"left_stick_down", "Left stick down", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_LEFTY, false)},
|
||||
{"right_stick_left", "Right stick left", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_RIGHTX, true)},
|
||||
{"right_stick_right", "Right stick right", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_RIGHTX, false)},
|
||||
{"right_stick_up", "Right stick up", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_RIGHTY, true)},
|
||||
{"right_stick_down", "Right stick down", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_RIGHTY, false)},
|
||||
{"unmapped", "Default", PAD_NATIVE_BUTTON_INVALID},
|
||||
{"south", "South (A / Cross)", SDL_GAMEPAD_BUTTON_SOUTH},
|
||||
{"east", "East (B / Circle)", SDL_GAMEPAD_BUTTON_EAST},
|
||||
{"west", "West (X / Square)", SDL_GAMEPAD_BUTTON_WEST},
|
||||
@@ -76,7 +87,7 @@ inline constexpr std::array<NativeButtonItem, SDL_GAMEPAD_BUTTON_COUNT + 1> kNat
|
||||
{"misc4", "Misc 4 / GC R click", SDL_GAMEPAD_BUTTON_MISC4},
|
||||
{"misc5", "Misc 5", SDL_GAMEPAD_BUTTON_MISC5},
|
||||
{"misc6", "Misc 6", SDL_GAMEPAD_BUTTON_MISC6},
|
||||
}};
|
||||
});
|
||||
|
||||
inline std::string TrimToken(std::string_view token) {
|
||||
const size_t begin = token.find_first_not_of(" \t");
|
||||
@@ -88,7 +99,7 @@ inline std::string TrimToken(std::string_view token) {
|
||||
}
|
||||
|
||||
inline const NativeButtonItem* FindNativeButton(std::string_view configName) {
|
||||
const std::string name = TrimToken(configName);
|
||||
const std::string name = TrimToken(configName.substr(0, configName.find('@')));
|
||||
const auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(),
|
||||
[&](const NativeButtonItem& item) { return name == item.configName; });
|
||||
return it == kNativeButtons.end() ? nullptr : &*it;
|
||||
@@ -97,8 +108,8 @@ inline const NativeButtonItem* FindNativeButton(std::string_view configName) {
|
||||
// Falls back to the "unmapped" entry so callers always have a label to draw.
|
||||
inline const NativeButtonItem& NativeButtonForValue(uint32_t nativeButton) {
|
||||
const auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(),
|
||||
[&](const NativeButtonItem& item) { return nativeButton == item.nativeButton; });
|
||||
return it == kNativeButtons.end() ? kNativeButtons.front() : *it;
|
||||
[&](const NativeButtonItem& item) { return PADAxisButtonIdentity(nativeButton) == PADAxisButtonIdentity(item.nativeButton); });
|
||||
return it == kNativeButtons.end() ? *FindNativeButton("unmapped") : *it;
|
||||
}
|
||||
|
||||
inline const GameCubeButtonItem* FindGameCubeButton(std::string_view configKey) {
|
||||
|
||||
@@ -91,6 +91,7 @@ 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;
|
||||
std::optional<int32_t> muteHotkey;
|
||||
std::map<std::string, std::string> controllerExpressions;
|
||||
};
|
||||
|
||||
@@ -411,6 +412,9 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) {
|
||||
}
|
||||
|
||||
config.rumbleEnabled = FindConfigValue<bool>(document, "controller", "rumble");
|
||||
if (auto value = FindConfigInt(document, "audio", "mute_key")) {
|
||||
config.muteHotkey = *value;
|
||||
}
|
||||
|
||||
if (const auto* section = document.contains("controller") ? &document.at("controller") : nullptr;
|
||||
section != nullptr && section->is_table()) {
|
||||
@@ -710,6 +714,15 @@ inline bool SetRumbleEnabled(bool value) {
|
||||
return WriteSetting("controller", "rumble", value ? "true" : "false");
|
||||
}
|
||||
|
||||
inline int32_t MuteHotkey(int32_t fallback) {
|
||||
return Get().muteHotkey.value_or(fallback);
|
||||
}
|
||||
|
||||
inline bool SetMuteHotkey(int32_t value) {
|
||||
Mutable().muteHotkey = value;
|
||||
return WriteSetting("audio", "mute_key", std::to_string(value));
|
||||
}
|
||||
|
||||
inline bool SetAudioVolume(float value) {
|
||||
value = std::clamp(value, 0.0f, 1.0f);
|
||||
Mutable().audioVolume = value;
|
||||
|
||||
@@ -12,4 +12,6 @@ void Draw() noexcept;
|
||||
bool StartupScreenVisible() noexcept;
|
||||
void NotifyStrapInputAccepted() noexcept;
|
||||
void AdvancePresentedFrame() noexcept;
|
||||
// Put host controllers back to a neutral state before the process ends.
|
||||
void ReleaseControllers() noexcept;
|
||||
} // namespace settings_overlay
|
||||
|
||||
@@ -648,9 +648,10 @@ extern "C" int32_t Network_HLE_OpenDevice(const char* path, uint32_t mode) {
|
||||
if (!path) {
|
||||
return -101;
|
||||
}
|
||||
if (!RuntimeConfigFile::NetworkEnabled(true)) {
|
||||
// The guest opens several /dev/net nodes at boot and retries; report the
|
||||
// reason online will not work exactly once.
|
||||
const bool isIpTop = std::strcmp(path, "/dev/net/ip/top") == 0;
|
||||
const bool isSsl = std::strcmp(path, "/dev/net/ssl") == 0;
|
||||
// KD and NCD provide local identity/configuration services even offline.
|
||||
if ((isIpTop || isSsl) && !RuntimeConfigFile::NetworkEnabled(true)) {
|
||||
static bool reported = false;
|
||||
if (!reported) {
|
||||
reported = true;
|
||||
@@ -665,10 +666,10 @@ extern "C" int32_t Network_HLE_OpenDevice(const char* path, uint32_t mode) {
|
||||
kind = DeviceKind::KdTime;
|
||||
} else if (std::strcmp(path, "/dev/net/ncd/manage") == 0) {
|
||||
kind = DeviceKind::NcdManage;
|
||||
} else if (std::strcmp(path, "/dev/net/ip/top") == 0) {
|
||||
} else if (isIpTop) {
|
||||
kind = DeviceKind::IpTop;
|
||||
EnsureSocketRuntime();
|
||||
} else if (std::strcmp(path, "/dev/net/ssl") == 0) {
|
||||
} else if (isSsl) {
|
||||
kind = DeviceKind::Ssl;
|
||||
EnsureSocketRuntime();
|
||||
} else {
|
||||
|
||||
@@ -242,6 +242,7 @@ void WritePollResults(uint32_t outAddress,
|
||||
const std::vector<NetworkPollContract::CopiedDescriptor>& descriptors);
|
||||
|
||||
// network_socket.cpp
|
||||
int32_t DeleteWiiSocket(uint32_t fd);
|
||||
void CleanupAllWiiSockets();
|
||||
sockaddr_in ReadWiiSockAddr(uint32_t addr);
|
||||
int32_t HandleIpTopIoctl(uint32_t cmd, uint32_t inBuf, uint32_t inLen, uint32_t outBuf,
|
||||
|
||||
@@ -21,7 +21,7 @@ static int32_t NewWiiSocket(uint32_t af, uint32_t type, uint32_t protocol) {
|
||||
return wiiFd;
|
||||
}
|
||||
|
||||
static int32_t DeleteWiiSocket(uint32_t fd) {
|
||||
int32_t DeleteWiiSocket(uint32_t fd) {
|
||||
WiiSocket* s = GetWiiSocket(fd);
|
||||
if (!s) {
|
||||
return -SO_EBADF;
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
#include "network_internal.h"
|
||||
#include "runtime_config.h"
|
||||
#include "runtime_log.h"
|
||||
|
||||
#ifndef _WIN32
|
||||
#include <mbedtls/ctr_drbg.h>
|
||||
#include <mbedtls/entropy.h>
|
||||
#include <mbedtls/error.h>
|
||||
#include <mbedtls/net_sockets.h>
|
||||
#include <mbedtls/ssl.h>
|
||||
#include <mbedtls/x509_crt.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
#endif
|
||||
|
||||
namespace NetworkHle {
|
||||
|
||||
@@ -56,6 +72,11 @@ struct SslSession {
|
||||
CredHandle cred{};
|
||||
CtxtHandle context{};
|
||||
SecPkgContext_StreamSizes sizes{};
|
||||
#else
|
||||
bool haveSsl = false;
|
||||
mbedtls_ssl_context sslContext{};
|
||||
mbedtls_ssl_config sslConfig{};
|
||||
mbedtls_net_context netContext{};
|
||||
#endif
|
||||
};
|
||||
|
||||
@@ -539,20 +560,282 @@ static int32_t SslRead(SslSession& ssl, uint8_t* out, uint32_t size) {
|
||||
return copied == 0 ? SSL_ERR_ZERO : static_cast<int32_t>(copied);
|
||||
}
|
||||
#else
|
||||
// Windows gets TLS for free from the OS (Schannel, above) - mbed TLS is this project's own
|
||||
// vendored equivalent for everywhere else (runtime/third_party/mbedtls, see runtime/CMakeLists.txt
|
||||
// for why mbed TLS specifically). The CA chain and RNG are expensive to set up (parsing ~150 root
|
||||
// certificates, seeding entropy) and read-only once built, so they're shared process-wide instead
|
||||
// of being redone per SSL session.
|
||||
static bool g_mbedtlsCaLoaded = false;
|
||||
static mbedtls_x509_crt g_mbedtlsCaChain;
|
||||
static mbedtls_entropy_context g_mbedtlsEntropy;
|
||||
static mbedtls_ctr_drbg_context g_mbedtlsCtrDrbg;
|
||||
|
||||
static ssize_t SendSslSocket(NativeSocket socket, const uint8_t* data, size_t size) {
|
||||
#ifdef __APPLE__
|
||||
const int noSigPipe = 1;
|
||||
if (setsockopt(socket, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, sizeof(noSigPipe)) != 0) {
|
||||
return -1;
|
||||
}
|
||||
return send(socket, data, size, 0);
|
||||
#else
|
||||
return send(socket, data, size, MSG_NOSIGNAL);
|
||||
#endif
|
||||
}
|
||||
|
||||
static int MbedtlsSend(void* context, const unsigned char* data, size_t size) {
|
||||
const auto* net = static_cast<mbedtls_net_context*>(context);
|
||||
const ssize_t result = SendSslSocket(net->fd, data, size);
|
||||
if (result >= 0) {
|
||||
return static_cast<int>(result);
|
||||
}
|
||||
if (errno == EINTR) {
|
||||
return MBEDTLS_ERR_SSL_WANT_WRITE;
|
||||
}
|
||||
if (errno == EPIPE || errno == ECONNRESET) {
|
||||
return MBEDTLS_ERR_NET_CONN_RESET;
|
||||
}
|
||||
return MBEDTLS_ERR_NET_SEND_FAILED;
|
||||
}
|
||||
|
||||
static int MbedtlsRecv(void* context, unsigned char* data, size_t size) {
|
||||
const int result = mbedtls_net_recv(context, data, size);
|
||||
// Blocking socket timeouts must leave the TLS session retryable.
|
||||
if (result == MBEDTLS_ERR_NET_RECV_FAILED && (errno == EAGAIN || errno == EWOULDBLOCK)) {
|
||||
return MBEDTLS_ERR_SSL_WANT_READ;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Mirrors ax_mix.cpp's FindDspCoefficientRom exactly - same three places a bundled asset can live
|
||||
// depending on platform and how the binary was launched (next to the desktop executable, the
|
||||
// Android app's own data directory, or a source-tree checkout during development).
|
||||
static std::optional<std::filesystem::path> FindCaCertificateBundle() {
|
||||
if (const auto executableDirectory = RuntimeConfigFile::ExecutableDirectory()) {
|
||||
const auto adjacent = *executableDirectory / "cacert.pem";
|
||||
if (std::filesystem::is_regular_file(adjacent)) {
|
||||
return adjacent;
|
||||
}
|
||||
}
|
||||
|
||||
#if defined(__ANDROID__)
|
||||
const auto androidAsset = RuntimeConfigFile::ApplicationDataDirectory() / "cacert.pem";
|
||||
if (std::filesystem::is_regular_file(androidAsset)) {
|
||||
return androidAsset;
|
||||
}
|
||||
#endif
|
||||
|
||||
for (auto base = std::filesystem::current_path(); !base.empty();) {
|
||||
const auto sourceTreeAsset = base / "runtime" / "assets" / "certs" / "cacert.pem";
|
||||
if (std::filesystem::is_regular_file(sourceTreeAsset)) {
|
||||
return sourceTreeAsset;
|
||||
}
|
||||
const auto parent = base.parent_path();
|
||||
if (parent == base) {
|
||||
break;
|
||||
}
|
||||
base = parent;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// Lazy, once-per-process: the first real SSL use pays for parsing the CA bundle and seeding the
|
||||
// RNG, every session after that reuses the result. Returns false (logging once) if the bundle is
|
||||
// missing or unparseable - callers treat that as a normal handshake failure, not a crash, since a
|
||||
// missing TLS root store shouldn't take down gameplay that never touches the network.
|
||||
static bool EnsureMbedtlsGlobalsInitialized() {
|
||||
static const bool initialized = [] {
|
||||
mbedtls_x509_crt_init(&g_mbedtlsCaChain);
|
||||
mbedtls_entropy_init(&g_mbedtlsEntropy);
|
||||
mbedtls_ctr_drbg_init(&g_mbedtlsCtrDrbg);
|
||||
|
||||
const char* personalization = "wiicompiled_ssl";
|
||||
if (mbedtls_ctr_drbg_seed(&g_mbedtlsCtrDrbg, mbedtls_entropy_func, &g_mbedtlsEntropy,
|
||||
reinterpret_cast<const unsigned char*>(personalization),
|
||||
std::strlen(personalization)) != 0) {
|
||||
NetFail("ssl: failed to seed TLS random number generator");
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto bundle = FindCaCertificateBundle();
|
||||
if (!bundle) {
|
||||
NetFail("ssl: missing TLS root CA bundle (cacert.pem) - HTTPS connections will fail");
|
||||
return false;
|
||||
}
|
||||
const int parseRet = mbedtls_x509_crt_parse_file(&g_mbedtlsCaChain, bundle->string().c_str());
|
||||
if (parseRet < 0) {
|
||||
char errorBuffer[256];
|
||||
mbedtls_strerror(parseRet, errorBuffer, sizeof(errorBuffer));
|
||||
NetFail("ssl: failed to parse CA bundle %s: %s", bundle->string().c_str(), errorBuffer);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}();
|
||||
g_mbedtlsCaLoaded = initialized;
|
||||
return initialized;
|
||||
}
|
||||
|
||||
// Builds this session's mbed TLS handshake state exactly once - a second call (e.g. the handshake
|
||||
// re-running after DOHANDSHAKE was already satisfied) is a no-op via ssl.haveSsl.
|
||||
static int32_t EnsureMbedtlsSession(SslSession& ssl) {
|
||||
if (ssl.haveSsl) {
|
||||
return SSL_OK;
|
||||
}
|
||||
if (!EnsureMbedtlsGlobalsInitialized()) {
|
||||
return SSL_ERR_FAILED;
|
||||
}
|
||||
|
||||
mbedtls_ssl_init(&ssl.sslContext);
|
||||
mbedtls_ssl_config_init(&ssl.sslConfig);
|
||||
if (mbedtls_ssl_config_defaults(&ssl.sslConfig, MBEDTLS_SSL_IS_CLIENT, MBEDTLS_SSL_TRANSPORT_STREAM,
|
||||
MBEDTLS_SSL_PRESET_DEFAULT) != 0) {
|
||||
return SSL_ERR_FAILED;
|
||||
}
|
||||
// Real certificate validation, matching Schannel's SCH_CRED_AUTO_CRED_VALIDATION on the
|
||||
// Windows side above - a self-signed or wrong-hostname certificate must fail the handshake,
|
||||
// not just get logged.
|
||||
mbedtls_ssl_conf_authmode(&ssl.sslConfig, MBEDTLS_SSL_VERIFY_REQUIRED);
|
||||
mbedtls_ssl_conf_ca_chain(&ssl.sslConfig, &g_mbedtlsCaChain, nullptr);
|
||||
mbedtls_ssl_conf_rng(&ssl.sslConfig, mbedtls_ctr_drbg_random, &g_mbedtlsCtrDrbg);
|
||||
if (mbedtls_ssl_setup(&ssl.sslContext, &ssl.sslConfig) != 0) {
|
||||
return SSL_ERR_FAILED;
|
||||
}
|
||||
// The hostname drives both SNI (which certificate the server presents) and the CN/SAN check
|
||||
// mbedtls_ssl_conf_authmode enforces above - required, not optional, same reasoning as the
|
||||
// Windows path's own "refuse an empty hostname" check just above SslHandshakeImpl.
|
||||
mbedtls_ssl_set_hostname(&ssl.sslContext, ssl.hostname.c_str());
|
||||
|
||||
ssl.netContext.fd = static_cast<int>(ssl.native);
|
||||
mbedtls_ssl_set_bio(&ssl.sslContext, &ssl.netContext, MbedtlsSend, MbedtlsRecv, nullptr);
|
||||
|
||||
ssl.haveSsl = true;
|
||||
return SSL_OK;
|
||||
}
|
||||
|
||||
static void ClearSslSession(SslSession& ssl) {
|
||||
if (ssl.haveSsl) {
|
||||
mbedtls_ssl_free(&ssl.sslContext);
|
||||
mbedtls_ssl_config_free(&ssl.sslConfig);
|
||||
}
|
||||
ssl = {};
|
||||
}
|
||||
|
||||
static int32_t SslHandshakeImpl(SslSession&) {
|
||||
return SSL_ERR_FAILED;
|
||||
static int32_t SslHandshakeImpl(SslSession& ssl) {
|
||||
if (ssl.plaintextWfc) {
|
||||
ssl.handshaked = true;
|
||||
return SSL_OK;
|
||||
}
|
||||
if (ssl.handshaked) {
|
||||
return SSL_OK;
|
||||
}
|
||||
if (ssl.native == kInvalidSocket) {
|
||||
return SSL_ERR_SYSCALL;
|
||||
}
|
||||
// mbed TLS can authenticate a certificate chain without authenticating a server identity when
|
||||
// no hostname is set - refuse that ambiguous mode, matching the Windows path's own check.
|
||||
if (ssl.hostname.empty()) {
|
||||
return SSL_ERR_VCOMMONNAME;
|
||||
}
|
||||
|
||||
const int32_t setupRet = EnsureMbedtlsSession(ssl);
|
||||
if (setupRet != SSL_OK) {
|
||||
return setupRet;
|
||||
}
|
||||
|
||||
// Receive timeouts are retryable, but the handshake must still terminate.
|
||||
const auto handshakeDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(15);
|
||||
int handshakeRet;
|
||||
while ((handshakeRet = mbedtls_ssl_handshake(&ssl.sslContext)) != 0) {
|
||||
if (handshakeRet == MBEDTLS_ERR_SSL_WANT_READ || handshakeRet == MBEDTLS_ERR_SSL_WANT_WRITE) {
|
||||
if (std::chrono::steady_clock::now() >= handshakeDeadline) {
|
||||
NetFail("ssl handshake TIMED OUT host=%s", ssl.hostname.c_str());
|
||||
return SSL_ERR_FAILED;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
char errorBuffer[256];
|
||||
mbedtls_strerror(handshakeRet, errorBuffer, sizeof(errorBuffer));
|
||||
NetFail("ssl handshake FAILED host=%s mbedtls_err=%s", ssl.hostname.c_str(), errorBuffer);
|
||||
return handshakeRet == MBEDTLS_ERR_X509_CERT_VERIFY_FAILED ? SSL_ERR_VCOMMONNAME : SSL_ERR_FAILED;
|
||||
}
|
||||
ssl.handshaked = true;
|
||||
return SSL_OK;
|
||||
}
|
||||
|
||||
static int32_t SslWrite(SslSession&, const uint8_t*, uint32_t) {
|
||||
return SSL_ERR_FAILED;
|
||||
static int32_t SslWrite(SslSession& ssl, const uint8_t* data, uint32_t size) {
|
||||
if (!data || size == 0) {
|
||||
return SSL_ERR_ZERO;
|
||||
}
|
||||
const int32_t handshakeRet = SslHandshake(ssl);
|
||||
if (handshakeRet != SSL_OK) {
|
||||
return handshakeRet;
|
||||
}
|
||||
|
||||
if (ssl.plaintextWfc) {
|
||||
uint32_t total = 0;
|
||||
while (total < size) {
|
||||
const ssize_t sent = SendSslSocket(ssl.native, data + total, size - total);
|
||||
if (sent <= 0) {
|
||||
return SSL_ERR_SYSCALL;
|
||||
}
|
||||
total += static_cast<uint32_t>(sent);
|
||||
}
|
||||
return static_cast<int32_t>(total);
|
||||
}
|
||||
|
||||
// mbed TLS is allowed to write fewer bytes than requested in one call (e.g. when size exceeds
|
||||
// one TLS record) - the caller must resend the remainder starting from where it left off, so
|
||||
// loop here until every byte is actually written rather than returning the first partial count.
|
||||
uint32_t totalWritten = 0;
|
||||
const auto writeDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(15);
|
||||
while (totalWritten < size) {
|
||||
const int ret = mbedtls_ssl_write(&ssl.sslContext, data + totalWritten, size - totalWritten);
|
||||
if (ret > 0) {
|
||||
totalWritten += static_cast<uint32_t>(ret);
|
||||
continue;
|
||||
}
|
||||
if (ret == MBEDTLS_ERR_SSL_WANT_READ || ret == MBEDTLS_ERR_SSL_WANT_WRITE) {
|
||||
if (std::chrono::steady_clock::now() >= writeDeadline) {
|
||||
DeleteWiiSocket(ssl.socketFd);
|
||||
return SSL_ERR_FAILED;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return SSL_ERR_FAILED;
|
||||
}
|
||||
return static_cast<int32_t>(totalWritten);
|
||||
}
|
||||
|
||||
static int32_t SslRead(SslSession&, uint8_t*, uint32_t) {
|
||||
return SSL_ERR_FAILED;
|
||||
static int32_t SslRead(SslSession& ssl, uint8_t* out, uint32_t size) {
|
||||
if (!out || size == 0) {
|
||||
return SSL_ERR_ZERO;
|
||||
}
|
||||
const int32_t handshakeRet = SslHandshake(ssl);
|
||||
if (handshakeRet != SSL_OK) {
|
||||
return handshakeRet;
|
||||
}
|
||||
|
||||
if (ssl.plaintextWfc) {
|
||||
const ssize_t ret = recv(ssl.native, out, size, 0);
|
||||
if (ret == 0) {
|
||||
return SSL_ERR_ZERO;
|
||||
}
|
||||
if (ret < 0) {
|
||||
return SSL_ERR_RAGAIN;
|
||||
}
|
||||
return static_cast<int32_t>(ret);
|
||||
}
|
||||
|
||||
const int ret = mbedtls_ssl_read(&ssl.sslContext, out, size);
|
||||
if (ret == 0 || ret == MBEDTLS_ERR_SSL_PEER_CLOSE_NOTIFY) {
|
||||
return SSL_ERR_ZERO;
|
||||
}
|
||||
if (ret == MBEDTLS_ERR_SSL_WANT_READ || ret == MBEDTLS_ERR_SSL_WANT_WRITE) {
|
||||
return SSL_ERR_RAGAIN;
|
||||
}
|
||||
if (ret < 0) {
|
||||
return SSL_ERR_FAILED;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -640,6 +923,14 @@ int32_t HandleSslIoctlv(uint32_t cmd, const std::vector<IoVector>& in, const std
|
||||
const int timeoutMs = 15000;
|
||||
setsockopt(socket->native, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast<const char*>(&timeoutMs), sizeof(timeoutMs));
|
||||
setsockopt(socket->native, SOL_SOCKET, SO_SNDTIMEO, reinterpret_cast<const char*>(&timeoutMs), sizeof(timeoutMs));
|
||||
#else
|
||||
// Match the Windows 15s timeout so a peer that accepts the TCP connection but stalls
|
||||
// during the TLS handshake or a later read/write can't hang this thread forever. POSIX
|
||||
// takes a struct timeval here, not a plain millisecond count like Windows does.
|
||||
struct timeval timeout {};
|
||||
timeout.tv_sec = 15;
|
||||
setsockopt(socket->native, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
|
||||
setsockopt(socket->native, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
|
||||
#endif
|
||||
WriteSslReturn(in, SSL_OK);
|
||||
return 0;
|
||||
|
||||
@@ -222,7 +222,6 @@ bool ProcessAlarmQueue(CpuContext* cpu, int maxToProcess)
|
||||
throw;
|
||||
}
|
||||
DecrementSchedulerDisableCount();
|
||||
RunDeferredReschedule(cpu);
|
||||
}
|
||||
}
|
||||
} catch (const ::Memory::AccessViolation& e) {
|
||||
@@ -233,7 +232,7 @@ bool ProcessAlarmQueue(CpuContext* cpu, int maxToProcess)
|
||||
// Host DNS workers never touch guest memory. Commit their output here on
|
||||
// the scheduler thread, waking synchronous IOS waiters or queuing async IOS
|
||||
// callbacks before the callback drain below.
|
||||
bool completionNeedsReschedule = false;
|
||||
bool completionNeedsReschedule = handledAny;
|
||||
if (Network_HLE_ProcessCompletions(cpu)) {
|
||||
handledAny = true;
|
||||
completionNeedsReschedule = true;
|
||||
@@ -446,15 +445,18 @@ PPC_NATIVE_OVERRIDE_VOID(801A08E0, OS__SetPeriodicAlarm_801a08e0, (CpuContext* c
|
||||
// returning 0 when the manager pointer (0x80386298) is null.
|
||||
extern "C" uint32_t RFLiIsWorking_HLE_800bd860()
|
||||
{
|
||||
// Pump alarms/callbacks on the current guest thread when available. Using a
|
||||
// detached persistent context here can leave the busy loop waiting on work
|
||||
// that completed on the wrong scheduling context.
|
||||
CpuContext* cpu = TryGetCpuContext();
|
||||
if (!cpu) {
|
||||
cpu = &GetPersistentCpuContext();
|
||||
}
|
||||
// Alarm callbacks interrupt the caller; keep their register writes private.
|
||||
GuestInterruptCallbackContext interrupt;
|
||||
CpuContext* cpu = interrupt.get();
|
||||
EnsureSda1Base(cpu);
|
||||
ProcessAlarmQueue(cpu, 32);
|
||||
IncrementSchedulerDisableCount();
|
||||
try {
|
||||
ProcessAlarmQueue(cpu, 32);
|
||||
} catch (...) {
|
||||
DecrementSchedulerDisableCount();
|
||||
throw;
|
||||
}
|
||||
DecrementSchedulerDisableCount();
|
||||
|
||||
// Now return the actual "working" status
|
||||
constexpr uint32_t kRflManagerPtrAddr = 0x80386298u;
|
||||
|
||||
@@ -78,22 +78,38 @@ bool ProcessSleepTimers(CpuContext* cpu)
|
||||
{
|
||||
using Clock = std::chrono::steady_clock;
|
||||
|
||||
std::vector<SleepTimerEntry> dueTimers;
|
||||
// Pop and process ONE due timer at a time, straight from the shared table. Resuming a
|
||||
// sleeper re-enters the scheduler (OSResumeThread -> SelectThread) and can switch fibers
|
||||
// away from this call. Timers that had already been popped into a private list would then
|
||||
// sit on the suspended fiber's stack with their threads parked and no entry in the table:
|
||||
// exactly the "park-shaped with no pending wake timer" strand the reconciler below heals
|
||||
// 100ms late, followed by a "sleep-timer stale" drop when this fiber finally resumes.
|
||||
// Leaving unprocessed timers in the table keeps them visible to every other pump (idle
|
||||
// loop, other threads' SelectThread) while this one is switched away.
|
||||
bool processedAny = false;
|
||||
constexpr size_t kMaxTimersPerCall = 64;
|
||||
size_t processedCount = 0;
|
||||
const auto now = Clock::now();
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(gSleepTimerMutex);
|
||||
auto it = gSleepTimers.begin();
|
||||
while (it != gSleepTimers.end()) {
|
||||
if (it->deadline > now) {
|
||||
++it;
|
||||
continue;
|
||||
while (processedCount < kMaxTimersPerCall) {
|
||||
SleepTimerEntry timer{0, {}};
|
||||
bool found = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(gSleepTimerMutex);
|
||||
for (auto it = gSleepTimers.begin(); it != gSleepTimers.end(); ++it) {
|
||||
if (it->deadline <= now) {
|
||||
timer = *it;
|
||||
gSleepTimers.erase(it);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
dueTimers.push_back(*it);
|
||||
it = gSleepTimers.erase(it);
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
break;
|
||||
}
|
||||
++processedCount;
|
||||
processedAny = true;
|
||||
|
||||
for (const SleepTimerEntry& timer : dueTimers) {
|
||||
const uint32_t threadPtr = timer.threadPtr;
|
||||
if (threadPtr == 0 ||
|
||||
!Memory::Contains(threadPtr + kThreadSuspendOffset, sizeof(uint32_t))) {
|
||||
@@ -219,7 +235,7 @@ bool ProcessSleepTimers(CpuContext* cpu)
|
||||
}
|
||||
}
|
||||
|
||||
return !dueTimers.empty();
|
||||
return processedAny;
|
||||
}
|
||||
} // namespace OsHleInternal
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
|
||||
#include "nand_internal.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <cerrno>
|
||||
|
||||
#ifdef __linux__
|
||||
#include <linux/fs.h>
|
||||
#include <sys/syscall.h>
|
||||
#endif
|
||||
|
||||
// ============================================================================
|
||||
// Local helpers
|
||||
// ============================================================================
|
||||
@@ -44,6 +52,32 @@ static FileHandle* ResolveNandFileHandle(const char* who, uint32_t fileInfoPtr)
|
||||
// The synchronous RVL NAND* library
|
||||
// ============================================================================
|
||||
|
||||
static bool RenameNoReplace(const std::filesystem::path& from,
|
||||
const std::filesystem::path& to,
|
||||
std::error_code& error) {
|
||||
#ifdef _WIN32
|
||||
if (MoveFileExW(from.c_str(), to.c_str(), MOVEFILE_WRITE_THROUGH)) {
|
||||
error.clear();
|
||||
return true;
|
||||
}
|
||||
error = std::error_code(static_cast<int>(GetLastError()), std::system_category());
|
||||
return false;
|
||||
#elif defined(__linux__)
|
||||
const int result = syscall(SYS_renameat2, AT_FDCWD, from.c_str(), AT_FDCWD, to.c_str(), RENAME_NOREPLACE);
|
||||
if (result == 0) {
|
||||
error.clear();
|
||||
return true;
|
||||
}
|
||||
error = std::error_code(errno, std::generic_category());
|
||||
return false;
|
||||
#else
|
||||
(void)from;
|
||||
(void)to;
|
||||
error = std::make_error_code(std::errc::operation_not_supported);
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
extern "C" int32_t NANDInit_HLE(void) {
|
||||
// Initialize ISFS
|
||||
ISFS_OpenLib_Initialize(&GetPersistentCpuContext());
|
||||
@@ -348,6 +382,11 @@ extern "C" int32_t NANDCreateDir_HLE(uint32_t pathPtr, uint32_t perm, uint32_t a
|
||||
PPC_NATIVE_OVERRIDE(8019BBE0, NANDCreateDir_HLE, int32_t, (uint32_t pathPtr, uint32_t perm, uint32_t attr), (pathPtr, perm, attr));
|
||||
|
||||
extern "C" int32_t NANDMove_HLE(uint32_t srcPathPtr, uint32_t dstPathPtr) {
|
||||
// A cross-mount move is implemented as several host operations. Keep two
|
||||
// guest moves from interleaving those operations and corrupting recovery.
|
||||
static std::mutex moveMutex;
|
||||
std::lock_guard<std::mutex> lock(moveMutex);
|
||||
|
||||
const char* srcPath = srcPathPtr ? (const char*)Memory::GetPointer(srcPathPtr) : nullptr;
|
||||
const char* dstPath = dstPathPtr ? (const char*)Memory::GetPointer(dstPathPtr) : nullptr;
|
||||
|
||||
@@ -383,6 +422,112 @@ extern "C" int32_t NANDMove_HLE(uint32_t srcPathPtr, uint32_t dstPathPtr) {
|
||||
return NAND_RESULT_OK;
|
||||
}
|
||||
|
||||
// Flatpak can expose the managed NAND and an external Riivolution save
|
||||
// directory as separate mounts. Linux cannot rename across mounts, but
|
||||
// nandMove must still work for files such as banner.bin. Preserve the
|
||||
// operation's semantics with a copy followed by source removal.
|
||||
if (ec == std::errc::cross_device_link) {
|
||||
static std::atomic<uint64_t> moveSequence{0};
|
||||
#ifdef _WIN32
|
||||
const auto processId = GetCurrentProcessId();
|
||||
#else
|
||||
const auto processId = getpid();
|
||||
#endif
|
||||
std::filesystem::path scratchHost;
|
||||
std::error_code scratchEc;
|
||||
for (unsigned attempt = 0; attempt < 128; ++attempt) {
|
||||
const auto name = ".nandmove-" + std::to_string(processId) + "-" +
|
||||
std::to_string(moveSequence.fetch_add(1)) + "-" +
|
||||
std::to_string(attempt);
|
||||
const auto candidate = dstDirectoryHost / name;
|
||||
scratchEc.clear();
|
||||
if (std::filesystem::create_directory(candidate, scratchEc)) {
|
||||
scratchHost = candidate;
|
||||
break;
|
||||
}
|
||||
if (scratchEc && scratchEc != std::errc::file_exists) {
|
||||
LogNandError("NANDMove", "failed to claim temporary directory '%s': %s",
|
||||
HostPathText(candidate).c_str(), scratchEc.message().c_str());
|
||||
return NAND_RESULT_UNKNOWN;
|
||||
}
|
||||
}
|
||||
if (scratchHost.empty()) {
|
||||
LogNandError("NANDMove", "could not claim a unique temporary directory");
|
||||
return NAND_RESULT_UNKNOWN;
|
||||
}
|
||||
|
||||
const bool sourceIsDirectory = IsDirectory(srcHost);
|
||||
const std::filesystem::path tempHost = scratchHost / srcName;
|
||||
const auto cleanupScratch = [&]() {
|
||||
std::error_code cleanupEc;
|
||||
std::filesystem::remove_all(scratchHost, cleanupEc);
|
||||
if (cleanupEc) {
|
||||
LogNandError("NANDMove", "failed to clean up temporary directory '%s': %s",
|
||||
HostPathText(scratchHost).c_str(), cleanupEc.message().c_str());
|
||||
}
|
||||
};
|
||||
|
||||
std::error_code copyEc;
|
||||
if (sourceIsDirectory) {
|
||||
std::filesystem::copy(srcHost, tempHost,
|
||||
std::filesystem::copy_options::recursive, copyEc);
|
||||
} else {
|
||||
std::filesystem::copy_file(srcHost, tempHost, copyEc);
|
||||
}
|
||||
|
||||
if (copyEc) {
|
||||
LogNandError("NANDMove", "cross-mount copy failed: %s", copyEc.message().c_str());
|
||||
cleanupScratch();
|
||||
return NAND_RESULT_UNKNOWN;
|
||||
}
|
||||
|
||||
std::error_code publishEc;
|
||||
if (sourceIsDirectory) {
|
||||
RenameNoReplace(tempHost, dstHost, publishEc);
|
||||
} else {
|
||||
// link(2) and CreateHardLink do not replace an existing destination,
|
||||
// unlike rename(2) on POSIX. Both paths are already on the target
|
||||
// filesystem, so the link is a no-replace publication operation.
|
||||
std::filesystem::create_hard_link(tempHost, dstHost, publishEc);
|
||||
}
|
||||
if (publishEc) {
|
||||
LogNandError("NANDMove", "failed to publish cross-mount copy: %s",
|
||||
publishEc.message().c_str());
|
||||
cleanupScratch();
|
||||
return NAND_RESULT_UNKNOWN;
|
||||
}
|
||||
cleanupScratch();
|
||||
|
||||
std::error_code removeEc;
|
||||
std::filesystem::remove_all(srcHost, removeEc);
|
||||
if (!removeEc) {
|
||||
LogNandWarning("NANDMove", "used copy/remove fallback across mounts");
|
||||
return NAND_RESULT_OK;
|
||||
}
|
||||
|
||||
// Keep the source as the authoritative copy when cleanup fails. The
|
||||
// destination was published atomically on its own mount; regular files
|
||||
// are rolled back below, while directories keep the complete copy when
|
||||
// their source removal was only partial. Cross-mount moves cannot
|
||||
// provide crash-atomicity, so this is best effort.
|
||||
LogNandError("NANDMove", "copy succeeded but source removal failed: %s",
|
||||
removeEc.message().c_str());
|
||||
if (sourceIsDirectory) {
|
||||
// remove_all may have removed only part of a directory tree. Keep
|
||||
// the complete published copy rather than rolling it back to a
|
||||
// partially deleted source.
|
||||
LogNandWarning("NANDMove", "preserving published directory copy after partial source removal");
|
||||
} else {
|
||||
std::error_code rollbackEc;
|
||||
std::filesystem::remove_all(dstHost, rollbackEc);
|
||||
if (rollbackEc) {
|
||||
LogNandError("NANDMove", "failed to roll back destination '%s': %s",
|
||||
HostPathText(dstHost).c_str(), rollbackEc.message().c_str());
|
||||
}
|
||||
}
|
||||
return NAND_RESULT_UNKNOWN;
|
||||
}
|
||||
|
||||
LogNandError("NANDMove", "FAILED error=%d message='%s'", ec.value(), ec.message().c_str());
|
||||
return NAND_RESULT_UNKNOWN;
|
||||
}
|
||||
|
||||
@@ -94,7 +94,12 @@ double ReadInput(SDL_Gamepad* gamepad, const std::string& name) {
|
||||
// Fall back to this project's own positional names, so a binding written
|
||||
// here does not have to use Dolphin vocabulary.
|
||||
if (const auto* native = ControllerNames::FindNativeButton(name)) {
|
||||
if (native->nativeButton != PAD_NATIVE_BUTTON_INVALID) {
|
||||
if (PADIsAxisButton(native->nativeButton)) {
|
||||
const auto axis = static_cast<SDL_GamepadAxis>(PADAxisButtonAxis(native->nativeButton));
|
||||
const double sign = PADAxisButtonNegative(native->nativeButton) ? -1.0 : 1.0;
|
||||
return std::clamp(SDL_GetGamepadAxis(gamepad, axis) / 32767.0 * sign, 0.0, 1.0);
|
||||
}
|
||||
if (native->nativeButton < SDL_GAMEPAD_BUTTON_COUNT) {
|
||||
return SDL_GetGamepadButton(gamepad, static_cast<SDL_GamepadButton>(native->nativeButton)) ? 1.0
|
||||
: 0.0;
|
||||
}
|
||||
|
||||
@@ -1420,6 +1420,10 @@ int RuntimeMain(int argc, char** argv) {
|
||||
WiiRemoteInput::ConfigureSdlHints(RuntimeConfigFile::WiiRemotesEnabled(true));
|
||||
|
||||
const AuroraInfo auroraInfo = aurora_initialize(0, nullptr, &auroraConfig);
|
||||
if (auroraInfo.initializationStatus != AURORA_INITIALIZATION_SUCCESS) {
|
||||
throw std::runtime_error(auroraInfo.initializationError != nullptr
|
||||
? auroraInfo.initializationError : "No supported graphics backend is available");
|
||||
}
|
||||
if (requestedBackend != BACKEND_AUTO && auroraInfo.backend != requestedBackend) {
|
||||
RT_LOG(RT_TAG_RUNTIME) << "graphics_api=\"" << backend
|
||||
<< "\" is not available on this system; aurora fell back to \""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "settings_overlay.h"
|
||||
#include "audio_backend.h"
|
||||
#include "aurora_events.h"
|
||||
#include "controller_button_names.h"
|
||||
#include "controller_mapping_wizard.h"
|
||||
#include "input_bindings.h"
|
||||
@@ -15,11 +16,13 @@
|
||||
#include <SDL3/SDL_keyboard.h>
|
||||
#include <SDL3/SDL_mouse.h>
|
||||
#include <SDL3/SDL_scancode.h>
|
||||
#include <SDL3/SDL_timer.h>
|
||||
|
||||
#include <array>
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cctype>
|
||||
#include <charconv>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
@@ -69,6 +72,7 @@ const char* GraphicsApiDisplayName() {
|
||||
}
|
||||
|
||||
bool g_topBarVisible = false;
|
||||
bool g_exitPromptOpen = false;
|
||||
bool g_rumbleEnabled = RuntimeConfigFile::RumbleEnabled(true);
|
||||
int g_controllerPort = 0;
|
||||
float g_resolutionScale = RuntimeConfigFile::ResolutionMultiplier(1.0f);
|
||||
@@ -79,6 +83,7 @@ int g_soundEffectsVolumePercent =
|
||||
int g_uiVolumePercent = static_cast<int>(std::lround(RuntimeConfigFile::UiVolume(1.0f) * 100.0f));
|
||||
int g_voicesVolumePercent = static_cast<int>(std::lround(RuntimeConfigFile::VoicesVolume(1.0f) * 100.0f));
|
||||
bool g_audioMuted = RuntimeConfigFile::AudioMuted(false);
|
||||
int32_t g_muteHotkey = RuntimeConfigFile::MuteHotkey(SDL_SCANCODE_BACKSLASH);
|
||||
bool g_audioMixWorker = RuntimeConfigFile::AudioMixWorkerEnabled(true);
|
||||
bool g_attenuateMusicWhenMediaPlays = RuntimeConfigFile::AttenuateMusicWhenMediaPlays(false);
|
||||
int g_frameInterpolationMode = [] {
|
||||
@@ -186,6 +191,18 @@ void LimitResolutionForFrameRate() {
|
||||
|
||||
using ControllerNames::FindNativeButton;
|
||||
|
||||
uint32_t ConfiguredNativeButton(const NativeButtonItem& item, const std::string& token) {
|
||||
if (!PADIsAxisButton(item.nativeButton)) return item.nativeButton;
|
||||
const size_t separator = token.find('@');
|
||||
if (separator == std::string::npos) return item.nativeButton;
|
||||
uint32_t threshold = 0;
|
||||
const char* end = token.data() + token.size();
|
||||
const auto parsed = std::from_chars(token.data() + separator + 1, end, threshold);
|
||||
if (parsed.ec != std::errc{} || parsed.ptr != end || threshold < 1 || threshold > 100)
|
||||
return item.nativeButton;
|
||||
return PADAxisButtonIdentity(item.nativeButton) | (threshold << 8);
|
||||
}
|
||||
|
||||
struct ControllerBindingPair {
|
||||
std::string primary;
|
||||
std::string secondary;
|
||||
@@ -204,6 +221,13 @@ ControllerBindingPair SplitControllerBinding(const std::string& value) {
|
||||
|
||||
using ControllerNames::NativeButtonForValue;
|
||||
|
||||
std::string NativeBindingConfig(uint32_t binding) {
|
||||
std::string value = NativeButtonForValue(binding).configName;
|
||||
if (PADIsAxisButton(binding)) value += '@' + std::to_string(PADAxisButtonThreshold(binding));
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
void SetTopBarVisible(bool visible) {
|
||||
if (g_topBarVisible == visible) {
|
||||
return;
|
||||
@@ -242,7 +266,7 @@ void ApplyConfiguredMappings() {
|
||||
}
|
||||
const ControllerBindingPair binding = SplitControllerBinding(*configured);
|
||||
if (const NativeButtonItem* native = FindNativeButton(binding.primary)) {
|
||||
PADSetButtonMapping(port, PADButtonMapping{native->nativeButton, kControllerButtons[i].padButton});
|
||||
PADSetButtonMapping(port, PADButtonMapping{ConfiguredNativeButton(*native, binding.primary), kControllerButtons[i].padButton});
|
||||
} else {
|
||||
RT_LOG(RT_TAG_CONFIG) << "Unknown controller." << kControllerButtons[i].configKey
|
||||
<< " button '" << binding.primary << "'" << std::endl;
|
||||
@@ -250,7 +274,7 @@ void ApplyConfiguredMappings() {
|
||||
uint32_t altNative = PAD_NATIVE_BUTTON_INVALID;
|
||||
if (!binding.secondary.empty()) {
|
||||
if (const NativeButtonItem* native = FindNativeButton(binding.secondary)) {
|
||||
altNative = native->nativeButton;
|
||||
altNative = ConfiguredNativeButton(*native, binding.secondary);
|
||||
} else {
|
||||
RT_LOG(RT_TAG_CONFIG) << "Unknown controller." << kControllerButtons[i].configKey
|
||||
<< " secondary button '" << binding.secondary << "'" << std::endl;
|
||||
@@ -395,6 +419,228 @@ void DrawWiiRemoteSettings(uint32_t selectedGamePort) {
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
|
||||
const char* KeyBindingName(int scancode) {
|
||||
switch (scancode) {
|
||||
case PAD_KEY_MOUSE_LEFT: return "Mouse left";
|
||||
case PAD_KEY_MOUSE_RIGHT: return "Mouse right";
|
||||
case PAD_KEY_MOUSE_MIDDLE: return "Mouse middle";
|
||||
case PAD_KEY_MOUSE_X1: return "Mouse side 1";
|
||||
case PAD_KEY_MOUSE_X2: return "Mouse side 2";
|
||||
case PAD_KEY_INVALID: return "Unmapped";
|
||||
default:
|
||||
return scancode >= 0 && scancode < SDL_SCANCODE_COUNT
|
||||
? SDL_GetScancodeName(static_cast<SDL_Scancode>(scancode)) : "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
enum class RebindKind { KeyboardButton, KeyboardAxis, Controller, MuteHotkey };
|
||||
struct RebindState {
|
||||
bool active = false;
|
||||
bool openPopup = false;
|
||||
RebindKind kind{};
|
||||
uint32_t port = 0;
|
||||
uint16_t target = 0;
|
||||
bool secondary = false;
|
||||
SDL_JoystickID instance = 0;
|
||||
Clock::time_point deadline{};
|
||||
std::string label;
|
||||
std::array<bool, SDL_SCANCODE_COUNT> keys{};
|
||||
uint32_t mouse = 0;
|
||||
std::array<bool, SDL_GAMEPAD_BUTTON_COUNT> buttons{};
|
||||
std::array<bool, SDL_GAMEPAD_AXIS_COUNT> axesReady{};
|
||||
} g_rebind;
|
||||
|
||||
void BeginRebind(RebindKind kind, uint16_t target, const char* label, bool secondary = false) {
|
||||
g_rebind = {};
|
||||
g_rebind.active = true;
|
||||
g_rebind.openPopup = true;
|
||||
g_rebind.kind = kind;
|
||||
g_rebind.port = static_cast<uint32_t>(g_controllerPort);
|
||||
g_rebind.target = target;
|
||||
g_rebind.secondary = secondary;
|
||||
g_rebind.label = label;
|
||||
g_rebind.deadline = Clock::now() + std::chrono::seconds(10);
|
||||
int count = 0;
|
||||
const bool* keys = SDL_GetKeyboardState(&count);
|
||||
std::copy_n(keys, std::min(count, static_cast<int>(g_rebind.keys.size())), g_rebind.keys.begin());
|
||||
g_rebind.mouse = SDL_GetMouseState(nullptr, nullptr);
|
||||
const int index = PADGetIndexForPort(g_rebind.port);
|
||||
if (kind == RebindKind::Controller && index >= 0) {
|
||||
if (auto* pad = PADGetSDLGamepadForIndex(index)) {
|
||||
g_rebind.instance = SDL_GetGamepadID(pad);
|
||||
for (int i = 0; i < SDL_GAMEPAD_BUTTON_COUNT; ++i)
|
||||
g_rebind.buttons[i] = SDL_GetGamepadButton(pad, static_cast<SDL_GamepadButton>(i));
|
||||
for (int i = 0; i < SDL_GAMEPAD_AXIS_COUNT; ++i)
|
||||
g_rebind.axesReady[i] = std::abs(static_cast<int>(SDL_GetGamepadAxis(pad, static_cast<SDL_GamepadAxis>(i)))) < 8000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void CompleteRebind(uint32_t value) {
|
||||
const auto& capture = g_rebind;
|
||||
if (capture.kind == RebindKind::Controller) {
|
||||
const int index = PADGetIndexForPort(capture.port);
|
||||
auto* pad = index >= 0 ? PADGetSDLGamepadForIndex(index) : nullptr;
|
||||
if (pad == nullptr || SDL_GetGamepadID(pad) != capture.instance) {
|
||||
g_rebind.active = false;
|
||||
return;
|
||||
}
|
||||
if (capture.secondary) PADSetAltButtonMapping(capture.port, {value, capture.target});
|
||||
else PADSetButtonMapping(capture.port, {value, capture.target});
|
||||
uint32_t count = 0, altCount = 0;
|
||||
auto* primary = PADGetButtonMappings(capture.port, &count);
|
||||
auto* alternate = PADGetAltButtonMappings(capture.port, &altCount);
|
||||
uint32_t primaryValue = PAD_NATIVE_BUTTON_INVALID, alternateValue = PAD_NATIVE_BUTTON_INVALID;
|
||||
for (uint32_t i = 0; i < count; ++i)
|
||||
if (primary[i].padButton == capture.target) primaryValue = primary[i].nativeButton;
|
||||
for (uint32_t i = 0; i < altCount; ++i)
|
||||
if (alternate[i].padButton == capture.target) alternateValue = alternate[i].nativeButton;
|
||||
std::string config = NativeBindingConfig(primaryValue);
|
||||
if (alternateValue != PAD_NATIVE_BUTTON_INVALID) config += ',' + NativeBindingConfig(alternateValue);
|
||||
for (size_t i = 0; i < kControllerButtons.size(); ++i)
|
||||
if (kControllerButtons[i].padButton == capture.target) RuntimeConfigFile::SetControllerButton(i, config);
|
||||
} else if (capture.kind == RebindKind::MuteHotkey) {
|
||||
g_muteHotkey = static_cast<int32_t>(value);
|
||||
RuntimeConfigFile::SetMuteHotkey(g_muteHotkey);
|
||||
g_rebind.active = false;
|
||||
return;
|
||||
} else if (capture.kind == RebindKind::KeyboardButton) {
|
||||
PADSetKeyButtonBinding(capture.port, {static_cast<int32_t>(value), capture.target});
|
||||
} else {
|
||||
PADSetKeyAxisBinding(capture.port, {static_cast<int32_t>(value), capture.target, 1});
|
||||
}
|
||||
PADSerializeMappings();
|
||||
g_rebind.active = false;
|
||||
}
|
||||
|
||||
void DrawRebindPrompt() {
|
||||
if (g_rebind.openPopup) {
|
||||
ImGui::OpenPopup("Rebind input");
|
||||
g_rebind.openPopup = false;
|
||||
}
|
||||
if (!ImGui::BeginPopupModal("Rebind input", &g_rebind.active, ImGuiWindowFlags_AlwaysAutoResize)) {
|
||||
g_rebind.active = false;
|
||||
return;
|
||||
}
|
||||
if (g_rebind.active) {
|
||||
ImGui::Text("Rebind: %s", g_rebind.label.c_str());
|
||||
ImGui::TextUnformatted(g_rebind.kind == RebindKind::Controller
|
||||
? "Press a controller button, pull a trigger, or move a stick."
|
||||
: g_rebind.kind == RebindKind::MuteHotkey
|
||||
? "Press a keyboard key."
|
||||
: "Press a keyboard key or click a mouse button.");
|
||||
ImGui::TextUnformatted("Release any held input first. Backspace or Delete clears the mapping.");
|
||||
ImGui::TextUnformatted("Escape can be bound. F10 is reserved for settings.");
|
||||
const float remaining = std::chrono::duration<float>(g_rebind.deadline - Clock::now()).count();
|
||||
ImGui::Text("Unmapped in %d seconds", std::max(0, static_cast<int>(std::ceil(remaining))));
|
||||
const bool clear = ImGui::Button("Clear mapping");
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Cancel")) g_rebind.active = false;
|
||||
// UI clicks must not become mouse bindings (buttons activate on release).
|
||||
const bool overControl = ImGui::IsAnyItemHovered();
|
||||
if (g_rebind.active && (clear || remaining <= 0.0f)) {
|
||||
CompleteRebind(g_rebind.kind == RebindKind::Controller ? PAD_NATIVE_BUTTON_DISABLED
|
||||
: static_cast<uint32_t>(PAD_KEY_INVALID));
|
||||
} else if (g_rebind.active && SDL_GetKeyboardFocus() != nullptr && g_rebind.kind != RebindKind::Controller) {
|
||||
int count = 0;
|
||||
const bool* keys = SDL_GetKeyboardState(&count);
|
||||
for (int i = 1; i < std::min(count, static_cast<int>(SDL_SCANCODE_COUNT)) && g_rebind.active; ++i) {
|
||||
if (keys[i] && !g_rebind.keys[i] && i != SDL_SCANCODE_F10) CompleteRebind(i);
|
||||
g_rebind.keys[i] = keys[i];
|
||||
}
|
||||
const uint32_t mouse = SDL_GetMouseState(nullptr, nullptr);
|
||||
for (int i = 1; i <= 5 && g_rebind.active; ++i)
|
||||
if (!overControl && g_rebind.kind != RebindKind::MuteHotkey &&
|
||||
(mouse & ~g_rebind.mouse & (1u << (i - 1))) != 0) CompleteRebind(static_cast<uint32_t>(-i - 1));
|
||||
g_rebind.mouse = mouse;
|
||||
} else if (g_rebind.active && SDL_GetKeyboardFocus() != nullptr && g_rebind.kind == RebindKind::Controller) {
|
||||
auto* pad = SDL_GetGamepadFromID(g_rebind.instance);
|
||||
if (pad != nullptr) {
|
||||
for (int i = 0; i < SDL_GAMEPAD_BUTTON_COUNT && g_rebind.active; ++i) {
|
||||
const bool pressed = SDL_GetGamepadButton(pad, static_cast<SDL_GamepadButton>(i));
|
||||
if (pressed && !g_rebind.buttons[i]) CompleteRebind(i);
|
||||
g_rebind.buttons[i] = pressed;
|
||||
}
|
||||
for (int i = 0; i < SDL_GAMEPAD_AXIS_COUNT && g_rebind.active; ++i) {
|
||||
const int value = SDL_GetGamepadAxis(pad, static_cast<SDL_GamepadAxis>(i));
|
||||
if (std::abs(value) < 8000) g_rebind.axesReady[i] = true;
|
||||
if (g_rebind.axesReady[i] && std::abs(value) >= 16384)
|
||||
CompleteRebind(PADEncodeAxisButton(i, value < 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!g_rebind.active) ImGui::CloseCurrentPopup();
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
void DrawKeyBinding(const char* label, int scancode, RebindKind kind, uint16_t target,
|
||||
float width = 220.0f) {
|
||||
const std::string caption = std::string(KeyBindingName(scancode)) + "##binding";
|
||||
if (ImGui::Button(caption.c_str(), ImVec2(width, 0.0f))) BeginRebind(kind, target, label);
|
||||
ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x);
|
||||
ImGui::TextUnformatted(label);
|
||||
|
||||
}
|
||||
|
||||
bool DrawKeyboardSettings(uint32_t port) {
|
||||
uint32_t count = 0;
|
||||
auto* buttons = PADGetKeyButtonBindings(port, &count);
|
||||
bool enabled = buttons != nullptr;
|
||||
bool usePreset = false;
|
||||
if (ImGui::Checkbox("Keyboard and mouse", &enabled)) {
|
||||
PADSetKeyboardActive(port, enabled);
|
||||
PADSerializeMappings();
|
||||
buttons = PADGetKeyButtonBindings(port, &count);
|
||||
usePreset = enabled && std::all_of(buttons, buttons + count, [](const auto& binding) {
|
||||
return binding.scancode == PAD_KEY_INVALID;
|
||||
});
|
||||
}
|
||||
if (!enabled) return false;
|
||||
ImGui::TextDisabled("Replaces the gamepad on this port. F10 opens settings.");
|
||||
if (ImGui::Button("Use WASD + mouse preset") || usePreset) {
|
||||
const std::array<int, PAD_BUTTON_COUNT> keys = {
|
||||
PAD_KEY_MOUSE_LEFT, SDL_SCANCODE_SPACE, SDL_SCANCODE_E, SDL_SCANCODE_Q,
|
||||
SDL_SCANCODE_RETURN, PAD_KEY_MOUSE_MIDDLE, SDL_SCANCODE_LSHIFT, PAD_KEY_MOUSE_RIGHT,
|
||||
SDL_SCANCODE_UP, SDL_SCANCODE_DOWN, SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT,
|
||||
};
|
||||
for (size_t i = 0; i < keys.size(); ++i)
|
||||
PADSetKeyButtonBinding(port, {keys[i], kControllerButtons[i].padButton});
|
||||
const std::array<int, PAD_AXIS_COUNT> axes = {
|
||||
SDL_SCANCODE_D, SDL_SCANCODE_A, SDL_SCANCODE_W, SDL_SCANCODE_S,
|
||||
SDL_SCANCODE_L, SDL_SCANCODE_J, SDL_SCANCODE_I, SDL_SCANCODE_K,
|
||||
SDL_SCANCODE_LSHIFT, PAD_KEY_MOUSE_RIGHT,
|
||||
};
|
||||
uint32_t axisCount = 0;
|
||||
auto* mappings = PADGetKeyAxisBindings(port, &axisCount);
|
||||
for (uint32_t i = 0; i < axisCount; ++i)
|
||||
PADSetKeyAxisBinding(port, {axes[i], mappings[i].padAxis, 1});
|
||||
PADSerializeMappings();
|
||||
}
|
||||
ImGui::SeparatorText("Button mapping");
|
||||
for (uint32_t i = 0; i < count; ++i) {
|
||||
int key = buttons[i].scancode;
|
||||
ImGui::PushID(static_cast<int>(i));
|
||||
ImGui::SetNextItemWidth(220.0f);
|
||||
DrawKeyBinding(PADGetButtonName(buttons[i].padButton), key, RebindKind::KeyboardButton, buttons[i].padButton);
|
||||
ImGui::PopID();
|
||||
}
|
||||
ImGui::SeparatorText("Stick and trigger mapping");
|
||||
uint32_t axisCount = 0;
|
||||
auto* axes = PADGetKeyAxisBindings(port, &axisCount);
|
||||
for (uint32_t i = 0; i < axisCount; ++i) {
|
||||
int key = axes[i].scancode;
|
||||
ImGui::PushID(static_cast<int>(count + i));
|
||||
const char* direction = PADGetAxisDirectionLabel(axes[i].padAxis);
|
||||
const std::string label = std::string(PADGetAxisName(axes[i].padAxis)) + " " +
|
||||
(direction != nullptr ? direction : "");
|
||||
ImGui::SetNextItemWidth(220.0f);
|
||||
DrawKeyBinding(label.c_str(), key, RebindKind::KeyboardAxis, axes[i].padAxis);
|
||||
ImGui::PopID();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Controller settings menu: port selection, controller assignment and button mapping.
|
||||
int ExpressionResizeCallback(ImGuiInputTextCallbackData* data) {
|
||||
if (data->EventFlag == ImGuiInputTextFlags_CallbackResize) {
|
||||
@@ -501,6 +747,10 @@ void DrawControllerSettings() {
|
||||
|
||||
ImGui::Separator();
|
||||
const uint32_t selectedGamePort = static_cast<uint32_t>(g_controllerPort);
|
||||
if (DrawKeyboardSettings(selectedGamePort)) {
|
||||
return;
|
||||
}
|
||||
ImGui::Separator();
|
||||
const char* currentName = PADGetName(selectedGamePort);
|
||||
ImGui::Text("Assigned: %s", currentName != nullptr ? currentName : "None");
|
||||
if (ImGui::MenuItem("Unassign controller")) {
|
||||
@@ -542,10 +792,10 @@ void DrawControllerSettings() {
|
||||
PADGetAltButtonMappings(static_cast<uint32_t>(g_controllerPort), &altMappingCount);
|
||||
|
||||
const auto writeBinding = [](size_t index, uint32_t primaryNative, uint32_t altNative) {
|
||||
std::string value = NativeButtonForValue(primaryNative).configName;
|
||||
std::string value = NativeBindingConfig(primaryNative);
|
||||
if (altNative != PAD_NATIVE_BUTTON_INVALID) {
|
||||
value += ',';
|
||||
value += NativeButtonForValue(altNative).configName;
|
||||
value += NativeBindingConfig(altNative);
|
||||
}
|
||||
RuntimeConfigFile::SetControllerButton(index, value);
|
||||
};
|
||||
@@ -603,6 +853,11 @@ void DrawControllerSettings() {
|
||||
}
|
||||
|
||||
ImGui::SeparatorText("Button mapping");
|
||||
ImGui::TextDisabled("LT / L2 = left trigger. RT / R2 = right trigger.");
|
||||
ImGui::TextDisabled("LB / L1 = left shoulder. RB / R1 = right shoulder.");
|
||||
ImGui::TextDisabled("Click a binding, then press an input. No input for 10 seconds clears it.");
|
||||
const float bindingWidth = ImGui::CalcTextSize("Right shoulder (RB / R1)").x +
|
||||
ImGui::GetFrameHeight() + ImGui::GetStyle().FramePadding.x * 2.0f;
|
||||
for (size_t i = 0; i < kControllerButtons.size(); ++i) {
|
||||
auto mappingIt = std::find_if(mappings, mappings + mappingCount, [&](const PADButtonMapping& mapping) {
|
||||
return mapping.padButton == kControllerButtons[i].padButton;
|
||||
@@ -622,30 +877,40 @@ void DrawControllerSettings() {
|
||||
|
||||
const NativeButtonItem& current = NativeButtonForValue(mappingIt->nativeButton);
|
||||
ImGui::PushID(static_cast<int>(i));
|
||||
ImGui::SetNextItemWidth(190.0f);
|
||||
if (ImGui::BeginCombo("##primary", current.label)) {
|
||||
for (const auto& candidate : kNativeButtons) {
|
||||
const bool selected = candidate.nativeButton == mappingIt->nativeButton;
|
||||
if (ImGui::Selectable(candidate.label, selected)) {
|
||||
const uint32_t port = static_cast<uint32_t>(g_controllerPort);
|
||||
PADSetButtonMapping(port, PADButtonMapping{candidate.nativeButton, kControllerButtons[i].padButton});
|
||||
writeBinding(i, candidate.nativeButton,
|
||||
altIt != nullptr ? altIt->nativeButton : PAD_NATIVE_BUTTON_INVALID);
|
||||
PADSerializeMappings();
|
||||
mappings = PADGetButtonMappings(port, &mappingCount);
|
||||
}
|
||||
if (selected) {
|
||||
ImGui::SetItemDefaultFocus();
|
||||
}
|
||||
const auto drawThreshold = [&](PADButtonMapping* mapping, bool secondary) {
|
||||
if (!PADIsAxisButton(mapping->nativeButton)) return;
|
||||
int threshold = static_cast<int>(PADAxisButtonThreshold(mapping->nativeButton));
|
||||
ImGui::SetNextItemWidth(bindingWidth);
|
||||
if (ImGui::SliderInt(secondary ? "##altThreshold" : "##primaryThreshold", &threshold,
|
||||
1, 100, "Threshold: %d%%", ImGuiSliderFlags_AlwaysClamp)) {
|
||||
const PADButtonMapping updated = {
|
||||
PADAxisButtonIdentity(mapping->nativeButton) | (static_cast<uint32_t>(threshold) << 8),
|
||||
mapping->padButton,
|
||||
};
|
||||
if (secondary) PADSetAltButtonMapping(selectedGamePort, updated);
|
||||
else PADSetButtonMapping(selectedGamePort, updated);
|
||||
}
|
||||
ImGui::EndCombo();
|
||||
if (ImGui::IsItemDeactivatedAfterEdit()) {
|
||||
writeBinding(i, mappingIt->nativeButton,
|
||||
altIt != nullptr ? altIt->nativeButton : PAD_NATIVE_BUTTON_INVALID);
|
||||
PADSerializeMappings();
|
||||
}
|
||||
};
|
||||
ImGui::BeginGroup();
|
||||
ImGui::SetNextItemWidth(bindingWidth);
|
||||
const std::string primaryCaption = std::string(current.label) + "##primary";
|
||||
if (ImGui::Button(primaryCaption.c_str(), ImVec2(bindingWidth, 0.0f))) {
|
||||
BeginRebind(RebindKind::Controller, kControllerButtons[i].padButton, kControllerButtons[i].label);
|
||||
}
|
||||
drawThreshold(mappingIt, false);
|
||||
ImGui::EndGroup();
|
||||
if (altIt != nullptr) {
|
||||
const bool altBound = altIt->nativeButton != PAD_NATIVE_BUTTON_INVALID;
|
||||
if (!altBound && !altRowExpanded[i]) {
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("+")) {
|
||||
altRowExpanded[i] = true;
|
||||
BeginRebind(RebindKind::Controller, kControllerButtons[i].padButton, kControllerButtons[i].label, true);
|
||||
}
|
||||
if (ImGui::IsItemHovered()) {
|
||||
ImGui::SetTooltip("Add a second binding; pressing either one works");
|
||||
@@ -654,27 +919,15 @@ void DrawControllerSettings() {
|
||||
ImGui::SameLine();
|
||||
ImGui::TextUnformatted("or");
|
||||
ImGui::SameLine();
|
||||
ImGui::BeginGroup();
|
||||
const char* altLabel = altBound ? NativeButtonForValue(altIt->nativeButton).label : "None";
|
||||
ImGui::SetNextItemWidth(190.0f);
|
||||
if (ImGui::BeginCombo("##alt", altLabel)) {
|
||||
for (const auto& candidate : kNativeButtons) {
|
||||
const bool isNone = candidate.nativeButton == PAD_NATIVE_BUTTON_INVALID;
|
||||
const bool selected = candidate.nativeButton == altIt->nativeButton;
|
||||
if (ImGui::Selectable(isNone ? "None" : candidate.label, selected)) {
|
||||
const uint32_t port = static_cast<uint32_t>(g_controllerPort);
|
||||
PADSetAltButtonMapping(
|
||||
port, PADButtonMapping{candidate.nativeButton, kControllerButtons[i].padButton});
|
||||
writeBinding(i, mappingIt->nativeButton, candidate.nativeButton);
|
||||
if (isNone) {
|
||||
altRowExpanded[i] = false;
|
||||
}
|
||||
}
|
||||
if (selected) {
|
||||
ImGui::SetItemDefaultFocus();
|
||||
}
|
||||
}
|
||||
ImGui::EndCombo();
|
||||
ImGui::SetNextItemWidth(bindingWidth);
|
||||
const std::string altCaption = std::string(altLabel) + "##alt";
|
||||
if (ImGui::Button(altCaption.c_str(), ImVec2(bindingWidth, 0.0f))) {
|
||||
BeginRebind(RebindKind::Controller, kControllerButtons[i].padButton, kControllerButtons[i].label, true);
|
||||
}
|
||||
drawThreshold(altIt, true);
|
||||
ImGui::EndGroup();
|
||||
}
|
||||
}
|
||||
ImGui::SameLine();
|
||||
@@ -712,10 +965,14 @@ void DrawAudioSettings() {
|
||||
MusicAttenuation::SetVoicesVolume(volume);
|
||||
RuntimeConfigFile::SetVoicesVolume(volume);
|
||||
}
|
||||
const float labelColumn = ImGui::GetCursorPosX() + ImGui::CalcItemWidth();
|
||||
if (ImGui::Checkbox("Mute", &g_audioMuted)) {
|
||||
AudioBackend::Instance().SetMuted(g_audioMuted);
|
||||
RuntimeConfigFile::SetAudioMuted(g_audioMuted);
|
||||
}
|
||||
ImGui::SameLine();
|
||||
DrawKeyBinding("Mute shortcut", g_muteHotkey, RebindKind::MuteHotkey, 0,
|
||||
std::max(60.0f, labelColumn - ImGui::GetCursorPosX()));
|
||||
ImGui::Separator();
|
||||
if (ImGui::Checkbox("Mix audio on a worker thread", &g_audioMixWorker)) {
|
||||
// Applies immediately: SetMixWorkerEnabled joins any in-flight mix
|
||||
@@ -929,17 +1186,57 @@ void DrawStartupScreen() {
|
||||
const float startY = std::max(0.0f, (viewport->Size.y - titleSize.y) * 0.5f);
|
||||
ImGui::SetCursorPos(ImVec2(titleX, startY));
|
||||
ImGui::TextUnformatted(kTitle);
|
||||
constexpr const char* kHint = "Press F10 to open settings";
|
||||
const ImVec2 hintSize = ImGui::CalcTextSize(kHint);
|
||||
ImGui::SetWindowFontScale(0.8f);
|
||||
ImGui::SetCursorPos(ImVec2(std::max(0.0f, (viewport->Size.x - hintSize.x) * 0.5f),
|
||||
startY + titleSize.y + 12.0f));
|
||||
ImGui::TextUnformatted(kHint);
|
||||
}
|
||||
ImGui::End();
|
||||
ImGui::PopStyleVar();
|
||||
ImGui::PopStyleColor();
|
||||
}
|
||||
|
||||
void DrawExitPrompt() {
|
||||
constexpr const char* kTitle = "Exit";
|
||||
if (g_exitPromptOpen && !ImGui::IsPopupOpen(kTitle)) ImGui::OpenPopup(kTitle);
|
||||
if (!ImGui::BeginPopupModal(kTitle, &g_exitPromptOpen, ImGuiWindowFlags_AlwaysAutoResize)) return;
|
||||
ImGui::TextUnformatted("Quit the game?");
|
||||
if (ImGui::Button("Exit", ImVec2(120.0f, 0.0f))) ExitForAuroraWindowClose();
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Cancel", ImVec2(120.0f, 0.0f))) g_exitPromptOpen = false;
|
||||
if (!g_exitPromptOpen) ImGui::CloseCurrentPopup();
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
void DrawTopBar() {
|
||||
if (!g_topBarVisible || !ImGui::BeginMainMenuBar()) {
|
||||
if (!g_topBarVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ImGuiViewport* viewport = ImGui::GetMainViewport();
|
||||
ImGui::GetBackgroundDrawList()->AddRectFilled(viewport->Pos,
|
||||
ImVec2(viewport->Pos.x + viewport->Size.x, viewport->Pos.y + viewport->Size.y),
|
||||
IM_COL32(0, 0, 0, 70));
|
||||
constexpr float kHintMargin = 10.0f;
|
||||
ImGui::SetNextWindowPos(ImVec2(viewport->Pos.x + viewport->Size.x * 0.5f,
|
||||
viewport->Pos.y + ImGui::GetFrameHeight() + kHintMargin),
|
||||
ImGuiCond_Always, ImVec2(0.5f, 0.0f));
|
||||
ImGui::SetNextWindowBgAlpha(0.55f);
|
||||
if (ImGui::Begin("Settings input hint", nullptr,
|
||||
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize |
|
||||
ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoSavedSettings |
|
||||
ImGuiWindowFlags_NoFocusOnAppearing)) {
|
||||
for (const char* line : {"Settings open - game controls disabled.",
|
||||
"Press F10 to return to the game."}) {
|
||||
ImGui::SetCursorPosX((ImGui::GetWindowWidth() - ImGui::CalcTextSize(line).x) * 0.5f);
|
||||
ImGui::TextUnformatted(line);
|
||||
}
|
||||
}
|
||||
ImGui::End();
|
||||
if (!ImGui::BeginMainMenuBar()) return;
|
||||
|
||||
ImGui::TextUnformatted("WiiCompiled");
|
||||
ImGui::Separator();
|
||||
const auto resolutionIt = std::find_if(kResolutions.begin(), kResolutions.end(), [](const ResolutionItem& item) {
|
||||
@@ -968,6 +1265,9 @@ void DrawTopBar() {
|
||||
|
||||
if (ImGui::BeginMenu("Controller settings")) {
|
||||
DrawControllerSettings();
|
||||
// Nest capture under this menu so opening/closing the modal preserves
|
||||
// the settings popup and its current port and scroll position.
|
||||
DrawRebindPrompt();
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
|
||||
@@ -980,14 +1280,21 @@ void DrawTopBar() {
|
||||
const std::string audioMenuLabel = audioLabel + "###AudioSettingsMenu";
|
||||
if (ImGui::BeginMenu(audioMenuLabel.c_str())) {
|
||||
DrawAudioSettings();
|
||||
DrawRebindPrompt();
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
|
||||
const float hideWidth = ImGui::CalcTextSize("Hide (F10)").x + ImGui::GetStyle().FramePadding.x * 2.0f;
|
||||
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), ImGui::GetWindowWidth() - hideWidth - 8.0f));
|
||||
const ImGuiStyle& style = ImGui::GetStyle();
|
||||
const float hideWidth = ImGui::CalcTextSize("Hide (F10)").x + style.FramePadding.x * 2.0f;
|
||||
const float exitWidth = ImGui::CalcTextSize("X").x + style.FramePadding.x * 2.0f;
|
||||
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(),
|
||||
ImGui::GetWindowWidth() - hideWidth - exitWidth - style.ItemSpacing.x - 8.0f));
|
||||
if (ImGui::MenuItem("Hide (F10)")) {
|
||||
SetTopBarVisible(false);
|
||||
}
|
||||
if (ImGui::MenuItem("X")) {
|
||||
g_exitPromptOpen = true;
|
||||
}
|
||||
ImGui::EndMainMenuBar();
|
||||
}
|
||||
|
||||
@@ -1016,9 +1323,12 @@ void UpdateCursorAutoHide() {
|
||||
return;
|
||||
}
|
||||
g_cursorHidden = shouldHide;
|
||||
// ImGui_ImplSDL3_NewFrame calls SDL_ShowCursor every frame unless this flag is set.
|
||||
if (shouldHide) {
|
||||
ImGui::GetIO().ConfigFlags |= ImGuiConfigFlags_NoMouseCursorChange;
|
||||
SDL_HideCursor();
|
||||
} else {
|
||||
ImGui::GetIO().ConfigFlags &= ~ImGuiConfigFlags_NoMouseCursorChange;
|
||||
SDL_ShowCursor();
|
||||
}
|
||||
}
|
||||
@@ -1034,6 +1344,13 @@ void PersistDisplayModeIfChanged() {
|
||||
g_displayMode = active;
|
||||
RuntimeConfigFile::SetDisplayMode(std::string(kDisplayModeConfigNames[static_cast<size_t>(active)]));
|
||||
}
|
||||
|
||||
void ApplyInputBlockState() {
|
||||
const bool blocked = controller_mapping_wizard::IsActive() || g_rebind.active ||
|
||||
g_exitPromptOpen || g_topBarVisible;
|
||||
PADBlockInput(blocked);
|
||||
InputBindings::SetInputBlocked(blocked);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void InitializeRuntimeSettings() noexcept {
|
||||
@@ -1074,8 +1391,30 @@ void HandleEvents(const AuroraEvent* events) noexcept {
|
||||
continue;
|
||||
}
|
||||
controller_mapping_wizard::HandleSdlEvent(ev->sdl);
|
||||
if (IsToggleKey(ev->sdl, SDL_SCANCODE_F10)) {
|
||||
if (g_rebind.active && (IsToggleKey(ev->sdl, SDL_SCANCODE_BACKSPACE) ||
|
||||
IsToggleKey(ev->sdl, SDL_SCANCODE_DELETE))) {
|
||||
CompleteRebind(g_rebind.kind == RebindKind::Controller ? PAD_NATIVE_BUTTON_DISABLED
|
||||
: static_cast<uint32_t>(PAD_KEY_INVALID));
|
||||
}
|
||||
if (!g_rebind.active && IsToggleKey(ev->sdl, SDL_SCANCODE_F10)) {
|
||||
SetTopBarVisible(!g_topBarVisible);
|
||||
ApplyInputBlockState();
|
||||
}
|
||||
if (!g_rebind.active && g_muteHotkey != PAD_KEY_INVALID &&
|
||||
IsToggleKey(ev->sdl, static_cast<SDL_Scancode>(g_muteHotkey))) {
|
||||
g_audioMuted = !g_audioMuted;
|
||||
AudioBackend::Instance().SetMuted(g_audioMuted);
|
||||
RuntimeConfigFile::SetAudioMuted(g_audioMuted);
|
||||
}
|
||||
if (!g_rebind.active && IsToggleKey(ev->sdl, SDL_SCANCODE_ESCAPE)) {
|
||||
if (g_exitPromptOpen) {
|
||||
g_exitPromptOpen = false;
|
||||
} else if (g_topBarVisible) {
|
||||
SetTopBarVisible(false);
|
||||
} else {
|
||||
g_exitPromptOpen = true;
|
||||
}
|
||||
ApplyInputBlockState();
|
||||
}
|
||||
if (IsMouseActivity(ev->sdl)) {
|
||||
g_lastMouseActivity = Clock::now();
|
||||
@@ -1083,6 +1422,27 @@ void HandleEvents(const AuroraEvent* events) noexcept {
|
||||
}
|
||||
}
|
||||
|
||||
void ReleaseControllers() noexcept {
|
||||
// Aurora drives the LED white on first PADRead and never clears it, and the
|
||||
// exit paths terminate the process outright, so do it here.
|
||||
bool queued = false;
|
||||
for (uint32_t port = 0; port < PAD_MAX_CONTROLLERS; ++port) {
|
||||
const s32 index = PADGetIndexForPort(port);
|
||||
if (index < 0) continue;
|
||||
if (SDL_Gamepad* pad = PADGetSDLGamepadForIndex(static_cast<u32>(index))) {
|
||||
SDL_SetGamepadLED(pad, 0, 0, 0);
|
||||
queued = true;
|
||||
}
|
||||
}
|
||||
constexpr std::array<uint32_t, PAD_MAX_CONTROLLERS> stopAll{
|
||||
PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD};
|
||||
PADControlAllMotors(stopAll.data());
|
||||
// SDL hands LED and rumble reports to its own HIDAPI sender thread rather
|
||||
// than writing them here, so without this the process dies before the
|
||||
// controller ever receives them.
|
||||
if (queued) SDL_Delay(120);
|
||||
}
|
||||
|
||||
void Draw() noexcept {
|
||||
// Wait for the frame worker's DONE phase: it has replayed the previous frame's ImGui draw lists
|
||||
// and started the next ImGui frame, so all overlay callers can now safely issue ImGui commands.
|
||||
@@ -1100,11 +1460,9 @@ void Draw() noexcept {
|
||||
}
|
||||
DrawFpsOverlay();
|
||||
DrawTopBar();
|
||||
DrawExitPrompt();
|
||||
controller_mapping_wizard::Draw();
|
||||
// The wizard captures raw presses; keep them out of the game.
|
||||
const bool inputBlocked = controller_mapping_wizard::IsActive();
|
||||
PADBlockInput(inputBlocked);
|
||||
InputBindings::SetInputBlocked(inputBlocked);
|
||||
ApplyInputBlockState();
|
||||
DrawStartupScreen();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
@@ -1891,6 +1891,7 @@ int RunTranslateModCore(string[] argsTail, string? outputDirectoryOverride)
|
||||
overlayBuild,
|
||||
continuationPlan,
|
||||
retroWfcResolvedExecutableHooks,
|
||||
patchPlan,
|
||||
kamekFunctionStarts,
|
||||
moduleLinkBase,
|
||||
selected.CodeSize,
|
||||
@@ -2231,6 +2232,7 @@ int EmitModCpp(
|
||||
OverlayBuildResult overlayBuild,
|
||||
ContinuationPlan continuationPlan,
|
||||
IReadOnlyCollection<RetroWfcExecutableHookPlan>? retroWfcExecutableHooks,
|
||||
KamekPatchPlan patchPlan,
|
||||
IReadOnlyList<ModFunctionStart> kamekFunctionStarts,
|
||||
uint moduleLinkBase,
|
||||
uint moduleLinkedCodeSize,
|
||||
@@ -2272,14 +2274,53 @@ int EmitModCpp(
|
||||
.ToHashSet();
|
||||
var queuedContinuationAddresses = continuationPlan.Entries.Select(e => e.Address).ToHashSet();
|
||||
var discoveredContinuationQueue = new Queue<ContinuationEntry>();
|
||||
var linkedHookLrBasesByTarget = retroWfcExecutableHooks is null
|
||||
? new Dictionary<uint, uint[]>()
|
||||
: retroWfcExecutableHooks
|
||||
.Where(h => h.TargetAddress.HasValue && RetroWfcHookSetsLinkRegister(h))
|
||||
.GroupBy(h => h.TargetAddress!.Value)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Select(h => h.ContinuationAddress).Distinct().ToArray());
|
||||
var hookLrBases = new List<(uint TargetAddress, uint ContinuationAddress)>();
|
||||
if (retroWfcExecutableHooks is not null)
|
||||
{
|
||||
hookLrBases.AddRange(
|
||||
retroWfcExecutableHooks
|
||||
.Where(h => h.TargetAddress.HasValue && RetroWfcHookSetsLinkRegister(h))
|
||||
.Select(h => (h.TargetAddress!.Value, h.ContinuationAddress)));
|
||||
}
|
||||
var hookLrAnalysis = new Dictionary<uint, LrContinuationAnalysis>();
|
||||
var hookDiscoveryCache = new Dictionary<uint, IReadOnlyList<PpcInstruction>>();
|
||||
IReadOnlyList<PpcInstruction> DiscoverHookBody(uint target)
|
||||
{
|
||||
if (!hookDiscoveryCache.TryGetValue(target, out var instructions))
|
||||
{
|
||||
instructions = modTranslator.Discover(target,
|
||||
new TranslationOptions(KnownFunctionEntryPoints: knownFunctionEntryPoints)).Instructions;
|
||||
hookDiscoveryCache.Add(target, instructions);
|
||||
}
|
||||
return instructions;
|
||||
}
|
||||
|
||||
LrContinuationAnalysis AnalyzeHook(uint target)
|
||||
{
|
||||
if (!hookLrAnalysis.TryGetValue(target, out var analysis))
|
||||
{
|
||||
analysis = LrContinuationAnalysis.Analyze(target, DiscoverHookBody);
|
||||
hookLrAnalysis.Add(target, analysis);
|
||||
}
|
||||
return analysis;
|
||||
}
|
||||
|
||||
foreach (var patch in patchPlan.ExecutablePatches.Where(p => p.CommandId == KamekCommandId.BranchLink && p.Arguments.Count > 0))
|
||||
{
|
||||
var target = KamekAddress.Resolve(patch.Arguments[0], patchPlan.ModuleGuestBase);
|
||||
if (!AnalyzeHook(target).MaySkipReturn)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
hookLrBases.Add((target, checked(patch.CommandAddress + 4u)));
|
||||
}
|
||||
|
||||
var linkedHookLrBasesByTarget = hookLrBases
|
||||
.GroupBy(h => h.TargetAddress)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Select(h => h.ContinuationAddress).Distinct().ToArray());
|
||||
var lrContinuationCallTargets = linkedHookLrBasesByTarget.Keys.ToHashSet();
|
||||
var linkedCallFallthroughLrOverrides = retroWfcExecutableHooks is null
|
||||
? new Dictionary<uint, uint>()
|
||||
@@ -2374,50 +2415,42 @@ int EmitModCpp(
|
||||
}
|
||||
}
|
||||
|
||||
void RecordDiscoveredLrRelativeBaseContinuations(
|
||||
FunctionTranslationResult result,
|
||||
IReadOnlyList<uint> lrBases,
|
||||
string reason)
|
||||
void RecordHookContinuations(uint hookTarget, IReadOnlyList<uint> lrBases)
|
||||
{
|
||||
if (lrBases.Count == 0)
|
||||
var analysis = AnalyzeHook(hookTarget);
|
||||
foreach (var lrBase in lrBases)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var offset in DiscoverLrRelativeIndirectJumpOffsets(result).Distinct())
|
||||
{
|
||||
foreach (var lrBase in lrBases)
|
||||
var targets = analysis.Offsets.Select(offset => unchecked(lrBase + (uint)offset));
|
||||
if (analysis.WasTruncated && baseFunctions.FindContaining(lrBase - 4u) is { } caller)
|
||||
{
|
||||
// Unknown offsets can resume at any aligned instruction in this caller.
|
||||
targets = targets.Concat(Enumerable.Range(0, checked((int)((caller.End - caller.Start) / 4)))
|
||||
.Select(index => caller.Start + (uint)index * 4u));
|
||||
}
|
||||
foreach (var target in targets.Distinct())
|
||||
{
|
||||
var target = unchecked(lrBase + (uint)offset);
|
||||
var section = baseManifest.Sections.FirstOrDefault(s => target >= s.GuestStart && target < s.GuestEnd);
|
||||
if (section is null || !section.Executable)
|
||||
{
|
||||
if (section is null || !section.Executable || (target & 3u) != 0)
|
||||
continue;
|
||||
}
|
||||
|
||||
var containing = baseFunctions.FindContaining(target);
|
||||
if (containing is null || containing.Start == target)
|
||||
{
|
||||
if (containing is null || containing.Start == target || !queuedContinuationAddresses.Add(target))
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!queuedContinuationAddresses.Add(target))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
discoveredContinuationQueue.Enqueue(new ContinuationEntry(
|
||||
target,
|
||||
containing.Start,
|
||||
containing.End,
|
||||
section.Name,
|
||||
result.EntryPoint,
|
||||
hookTarget,
|
||||
KamekCommandId.Branch,
|
||||
$"{reason}; LR-relative jump offset {offset:+#;-#;0}"));
|
||||
$"LR-relative hook target 0x{hookTarget:X8}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (target, lrBases) in linkedHookLrBasesByTarget)
|
||||
RecordHookContinuations(target, lrBases);
|
||||
|
||||
ModTranslationWork CreateContinuationWork(ContinuationEntry continuation)
|
||||
{
|
||||
var name = $"rr_continue_{continuation.Address:X8}";
|
||||
@@ -2583,16 +2616,7 @@ int EmitModCpp(
|
||||
}
|
||||
|
||||
CommitWave(attempts, (result, work) =>
|
||||
{
|
||||
RecordDiscoveredBaseContinuations(result, $"base continuation discovered from module 0x{work.Address:X8}");
|
||||
if (linkedHookLrBasesByTarget.TryGetValue(work.Address, out var lrBases))
|
||||
{
|
||||
RecordDiscoveredLrRelativeBaseContinuations(
|
||||
result,
|
||||
lrBases,
|
||||
$"base continuation discovered from LR-relative hook target 0x{work.Address:X8}");
|
||||
}
|
||||
});
|
||||
RecordDiscoveredBaseContinuations(result, $"base continuation discovered from module 0x{work.Address:X8}"));
|
||||
}
|
||||
|
||||
DrainDiscoveredContinuations();
|
||||
@@ -2750,124 +2774,6 @@ IEnumerable<uint> DirectModuleTargets(FunctionTranslationResult result, uint mod
|
||||
}
|
||||
}
|
||||
|
||||
IEnumerable<int> DiscoverLrRelativeIndirectJumpOffsets(FunctionTranslationResult result)
|
||||
{
|
||||
var lrOffsets = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
int? ctrOffset = null;
|
||||
|
||||
foreach (var instruction in result.Instructions)
|
||||
{
|
||||
var mnemonic = instruction.Mnemonic.ToLowerInvariant();
|
||||
if (mnemonic == "mflr" && TryGetInstructionReg(instruction, 0, out var lrDest))
|
||||
{
|
||||
lrOffsets[lrDest] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((mnemonic == "mr" || mnemonic == "or") &&
|
||||
TryGetInstructionReg(instruction, 0, out var moveDest) &&
|
||||
TryGetInstructionReg(instruction, 1, out var moveSource) &&
|
||||
(mnemonic == "mr" ||
|
||||
(instruction.Operands.Count >= 3 &&
|
||||
instruction.Operands[2] is PpcRegisterOperand moveSource2 &&
|
||||
string.Equals(NormalizeInstructionReg(moveSource2.Name), moveSource, StringComparison.OrdinalIgnoreCase))))
|
||||
{
|
||||
if (lrOffsets.TryGetValue(moveSource, out var sourceOffset))
|
||||
{
|
||||
lrOffsets[moveDest] = sourceOffset;
|
||||
}
|
||||
else
|
||||
{
|
||||
lrOffsets.Remove(moveDest);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mnemonic == "addi" &&
|
||||
TryGetInstructionReg(instruction, 0, out var addDest) &&
|
||||
TryGetInstructionReg(instruction, 1, out var addBase) &&
|
||||
TryGetInstructionImm(instruction, 2, out var imm))
|
||||
{
|
||||
if (lrOffsets.TryGetValue(addBase, out var baseOffset))
|
||||
{
|
||||
lrOffsets[addDest] = checked(baseOffset + imm);
|
||||
}
|
||||
else
|
||||
{
|
||||
lrOffsets.Remove(addDest);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mnemonic == "mtctr" && TryGetInstructionReg(instruction, 0, out var ctrSource))
|
||||
{
|
||||
ctrOffset = lrOffsets.TryGetValue(ctrSource, out var sourceOffset) ? sourceOffset : null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mnemonic == "bctr")
|
||||
{
|
||||
if (ctrOffset.HasValue)
|
||||
{
|
||||
yield return ctrOffset.Value;
|
||||
}
|
||||
ctrOffset = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryInstructionWritesDest(instruction, out var dest))
|
||||
{
|
||||
lrOffsets.Remove(dest);
|
||||
}
|
||||
}
|
||||
|
||||
static bool TryGetInstructionReg(PpcInstruction instruction, int index, out string register)
|
||||
{
|
||||
if (instruction.Operands.Count > index && instruction.Operands[index] is PpcRegisterOperand operand)
|
||||
{
|
||||
register = NormalizeInstructionReg(operand.Name);
|
||||
return true;
|
||||
}
|
||||
|
||||
register = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool TryGetInstructionImm(PpcInstruction instruction, int index, out int immediate)
|
||||
{
|
||||
if (instruction.Operands.Count > index && instruction.Operands[index] is PpcImmediateOperand operand)
|
||||
{
|
||||
immediate = operand.Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
immediate = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool TryInstructionWritesDest(PpcInstruction instruction, out string destination)
|
||||
{
|
||||
destination = string.Empty;
|
||||
if (instruction.Operands.Count == 0 || instruction.Operands[0] is not PpcRegisterOperand operand)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var mnemonic = instruction.Mnemonic.ToLowerInvariant();
|
||||
if (mnemonic.StartsWith("st", StringComparison.Ordinal) ||
|
||||
mnemonic.StartsWith("b", StringComparison.Ordinal) ||
|
||||
mnemonic.StartsWith("cmp", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
destination = NormalizeInstructionReg(operand.Name);
|
||||
return true;
|
||||
}
|
||||
|
||||
static string NormalizeInstructionReg(string register) => register.ToLowerInvariant();
|
||||
}
|
||||
|
||||
static bool RetroWfcHookSetsLinkRegister(RetroWfcExecutableHookPlan hook) =>
|
||||
hook.TypeName is "call" or "branchCtrLink" ||
|
||||
hook.Intent.Contains("Call", StringComparison.Ordinal);
|
||||
|
||||
@@ -27,7 +27,8 @@ public sealed partial class CxxLinearCodeGenerator
|
||||
IReadOnlyDictionary<uint, GuestAbiContract> stateFreeAbiContracts,
|
||||
IReadOnlyDictionary<uint, string> stateFreeCallSymbols,
|
||||
IReadOnlyDictionary<GuestStateFreeCallSiteKey, GuestStateFreeCallVariant> stateFreeCallSiteVariants,
|
||||
IReadOnlySet<uint> modOverridableCallTargets)
|
||||
IReadOnlySet<uint> modOverridableCallTargets,
|
||||
bool shareLrContinuationDispatch)
|
||||
{
|
||||
if (ins is IrPhi)
|
||||
{
|
||||
@@ -410,11 +411,18 @@ public sealed partial class CxxLinearCodeGenerator
|
||||
fallbackPad = IndentPad(indent + 1);
|
||||
}
|
||||
|
||||
EmitLocalLrContinuationDispatch(sb, fallbackPad, labelNames);
|
||||
sb.AppendLine($"{fallbackPad}if (TranslatedFunctionRegistry::FindByAddressPtr(ctx->lr) != nullptr) {{");
|
||||
sb.AppendLine($"{fallbackPad} InvokeIndirectCpu(ctx->lr, ctx);");
|
||||
sb.AppendLine($"{fallbackPad}}}");
|
||||
sb.AppendLine($"{fallbackPad}return;");
|
||||
if (shareLrContinuationDispatch)
|
||||
{
|
||||
sb.AppendLine($"{fallbackPad}goto lr_continuation_dispatch;");
|
||||
}
|
||||
else
|
||||
{
|
||||
EmitLocalLrContinuationDispatch(sb, fallbackPad, labelNames);
|
||||
sb.AppendLine($"{fallbackPad}if (TranslatedFunctionRegistry::FindByAddressPtr(ctx->lr) != nullptr) {{");
|
||||
sb.AppendLine($"{fallbackPad} InvokeIndirectCpu(ctx->lr, ctx);");
|
||||
sb.AppendLine($"{fallbackPad}}}");
|
||||
sb.AppendLine($"{fallbackPad}return;");
|
||||
}
|
||||
if (localFallthroughLr.HasValue)
|
||||
{
|
||||
sb.AppendLine($"{pad}}}");
|
||||
|
||||
@@ -225,12 +225,14 @@ public sealed partial class CxxLinearCodeGenerator
|
||||
|
||||
var labelNames = func.Blocks.ToDictionary(b => b.Label, b => SanitizeLabel(b.Label), StringComparer.OrdinalIgnoreCase);
|
||||
var instructionContinuationLabels = new Dictionary<uint, string>();
|
||||
var needsInstructionContinuationLabels = func.Blocks
|
||||
var continuationCallCount = func.Blocks
|
||||
.SelectMany(static block => block.Instructions)
|
||||
.OfType<IrCall>()
|
||||
.Any(call =>
|
||||
.Count(call =>
|
||||
TryParseAddress(call.Target, out var target) &&
|
||||
(nonReturningCallTargets.Contains(target) || lrContinuationCallTargets.Contains(target)));
|
||||
var needsInstructionContinuationLabels = continuationCallCount > 0;
|
||||
var shareLrContinuationDispatch = continuationCallCount > 1;
|
||||
if (needsInstructionContinuationLabels)
|
||||
{
|
||||
foreach (var trace in func.Blocks.SelectMany(static block => block.Instructions).OfType<IrTracePpc>())
|
||||
@@ -389,7 +391,7 @@ public sealed partial class CxxLinearCodeGenerator
|
||||
_activeGpuFifoBurstSlot = gpuFifoBurstPlan.Slot(block.Label, i);
|
||||
try
|
||||
{
|
||||
EmitInstruction(block.Label, directCallOrdinal, block.Instructions[i], body, bufferBaseLength, 1, cfg, labelNames, types, signature, localPaired, _guestAbiProvider, knownConstants, localConstants, linkedAddressRemap, nonReturningCallTargets, lrContinuationCallTargets, stackFacts, inlineGuestThunkStackBase, localFallthroughLr, guestAbiContracts, stateFreeAbiContracts, stateFreeCallSymbols, stateFreeCallSiteVariants, modOverridableCallTargets);
|
||||
EmitInstruction(block.Label, directCallOrdinal, block.Instructions[i], body, bufferBaseLength, 1, cfg, labelNames, types, signature, localPaired, _guestAbiProvider, knownConstants, localConstants, linkedAddressRemap, nonReturningCallTargets, lrContinuationCallTargets, stackFacts, inlineGuestThunkStackBase, localFallthroughLr, guestAbiContracts, stateFreeAbiContracts, stateFreeCallSymbols, stateFreeCallSiteVariants, modOverridableCallTargets, shareLrContinuationDispatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -407,7 +409,7 @@ public sealed partial class CxxLinearCodeGenerator
|
||||
switch (term)
|
||||
{
|
||||
case IrUndefined undef:
|
||||
EmitInstruction(block.Label, -1, undef, body, bufferBaseLength, 1, cfg, labelNames, types, signature, localPaired, _guestAbiProvider, knownConstants, localConstants, linkedAddressRemap, nonReturningCallTargets, lrContinuationCallTargets, stackFacts, inlineGuestThunkStackBase: false, localFallthroughLr: null, guestAbiContracts, stateFreeAbiContracts, stateFreeCallSymbols, stateFreeCallSiteVariants, modOverridableCallTargets);
|
||||
EmitInstruction(block.Label, -1, undef, body, bufferBaseLength, 1, cfg, labelNames, types, signature, localPaired, _guestAbiProvider, knownConstants, localConstants, linkedAddressRemap, nonReturningCallTargets, lrContinuationCallTargets, stackFacts, inlineGuestThunkStackBase: false, localFallthroughLr: null, guestAbiContracts, stateFreeAbiContracts, stateFreeCallSymbols, stateFreeCallSiteVariants, modOverridableCallTargets, shareLrContinuationDispatch);
|
||||
AppendFlush(body, " ");
|
||||
body.AppendLine(" return;");
|
||||
break;
|
||||
@@ -493,6 +495,18 @@ public sealed partial class CxxLinearCodeGenerator
|
||||
body.AppendLine();
|
||||
}
|
||||
|
||||
if (shareLrContinuationDispatch)
|
||||
{
|
||||
// Every call site has already reloaded the callee's state.
|
||||
// Keep the complete local target set, but emit it only once.
|
||||
body.AppendLine(" return;");
|
||||
body.AppendLine("[[maybe_unused]] lr_continuation_dispatch:");
|
||||
EmitLocalLrContinuationDispatch(body, " ", labelNames);
|
||||
body.AppendLine(" if (TranslatedFunctionRegistry::FindByAddressPtr(ctx->lr) != nullptr) {");
|
||||
body.AppendLine(" InvokeIndirectCpu(ctx->lr, ctx);");
|
||||
body.AppendLine(" }");
|
||||
body.AppendLine(" return;");
|
||||
}
|
||||
body.Append('}');
|
||||
// The residency discovery pass only exists for its side effects
|
||||
// on the recorder; materializing its text costs a full copy of
|
||||
|
||||
@@ -1942,6 +1942,22 @@ public sealed partial class PpcLifter
|
||||
new IrCall(string.Empty, TargetLabel(ins, validAddresses, preferFallthrough: false), blArgs)
|
||||
};
|
||||
|
||||
case "bltl":
|
||||
{
|
||||
var crField = "cr0";
|
||||
if (ops.Count > 0 && ops[0] is PpcConditionRegisterOperand crOp)
|
||||
{
|
||||
crField = NormalizeRegister(crOp.Name);
|
||||
}
|
||||
|
||||
return new IrInstruction[]
|
||||
{
|
||||
new IrAssign("lr", IrValue.Imm((int)ins.EndAddress)),
|
||||
new IrBranch("blt", TargetLabel(ins, validAddresses, preferFallthrough: false),
|
||||
$"0x{ins.EndAddress:X8}", crField)
|
||||
};
|
||||
}
|
||||
|
||||
case "bcl":
|
||||
{
|
||||
var rawInstr = ReadRawInstruction(ins);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using Translator.Core.Disassembly;
|
||||
using Translator.Core.Parsing.Kamek;
|
||||
@@ -273,4 +274,541 @@ public static class ContinuationPlanner
|
||||
Or,
|
||||
AddSigned
|
||||
}
|
||||
|
||||
// Dropped states make a negative result inconclusive.
|
||||
private const int MaxStatesPerInstruction = 512;
|
||||
|
||||
public static IEnumerable<int> DiscoverLrRelativeIndirectJumpOffsets(
|
||||
IReadOnlyList<PpcInstruction> instructions,
|
||||
Action? onStateCapExceeded = null,
|
||||
Action? onUnresolvedExit = null)
|
||||
{
|
||||
if (instructions.Count == 0)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var indexByAddress = new Dictionary<uint, int>(instructions.Count);
|
||||
for (var i = 0; i < instructions.Count; i++)
|
||||
{
|
||||
indexByAddress.TryAdd(instructions[i].Address, i);
|
||||
}
|
||||
|
||||
var visited = new HashSet<PathState>[instructions.Count];
|
||||
for (var i = 0; i < instructions.Count; i++)
|
||||
{
|
||||
visited[i] = new HashSet<PathState>();
|
||||
}
|
||||
|
||||
var seenOffsets = new HashSet<int>();
|
||||
var worklist = new Queue<(int Index, PathState State)>();
|
||||
|
||||
void Enqueue(int targetIndex, PathState stateToEnqueue)
|
||||
{
|
||||
worklist.Enqueue((targetIndex, stateToEnqueue));
|
||||
}
|
||||
|
||||
int? GetFallthroughIndex(PpcInstruction instruction)
|
||||
{
|
||||
if (indexByAddress.TryGetValue(instruction.EndAddress, out var nextIndex))
|
||||
{
|
||||
return nextIndex;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Enqueue(0, PathState.Empty);
|
||||
|
||||
while (worklist.Count > 0)
|
||||
{
|
||||
var (idx, state) = worklist.Dequeue();
|
||||
if (!visited[idx].Add(state))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (visited[idx].Count > MaxStatesPerInstruction)
|
||||
{
|
||||
onStateCapExceeded?.Invoke();
|
||||
continue;
|
||||
}
|
||||
|
||||
var instruction = instructions[idx];
|
||||
var mnemonic = instruction.Mnemonic.ToLowerInvariant();
|
||||
var nextState = state;
|
||||
|
||||
if (mnemonic == "mflr" && TryGetInstructionReg(instruction, 0, out var lrDest))
|
||||
{
|
||||
if (lrDest == "r1")
|
||||
{
|
||||
nextState = nextState.WithClearedStackOffsets();
|
||||
}
|
||||
|
||||
nextState = nextState.LrReturnOffset.HasValue
|
||||
? nextState.WithLrOffset(lrDest, nextState.LrReturnOffset.Value)
|
||||
: nextState.WithoutLrOffset(lrDest);
|
||||
}
|
||||
else if ((mnemonic == "mr" || mnemonic == "or") &&
|
||||
TryGetInstructionReg(instruction, 0, out var moveDest) &&
|
||||
TryGetInstructionReg(instruction, 1, out var moveSource) &&
|
||||
(mnemonic == "mr" ||
|
||||
(instruction.Operands.Count >= 3 &&
|
||||
instruction.Operands[2] is PpcRegisterOperand moveSource2 &&
|
||||
string.Equals(NormalizeInstructionReg(moveSource2.Name), moveSource, StringComparison.OrdinalIgnoreCase))))
|
||||
{
|
||||
if (moveDest == "r1" && moveSource != "r1")
|
||||
{
|
||||
nextState = nextState.WithClearedStackOffsets();
|
||||
}
|
||||
nextState = nextState.LrOffsets.TryGetValue(moveSource, out var sourceOffset)
|
||||
? nextState.WithLrOffset(moveDest, sourceOffset)
|
||||
: nextState.WithoutLrOffset(moveDest);
|
||||
}
|
||||
else if ((mnemonic == "addi" || mnemonic == "addic") &&
|
||||
TryGetInstructionReg(instruction, 0, out var addDest) &&
|
||||
TryGetInstructionReg(instruction, 1, out var addBase) &&
|
||||
TryGetInstructionImm(instruction, 2, out var imm))
|
||||
{
|
||||
if (addDest == "r1")
|
||||
{
|
||||
nextState = addBase == "r1"
|
||||
? nextState.WithSpDelta(unchecked(nextState.SpDelta + imm))
|
||||
: nextState.WithClearedStackOffsets();
|
||||
}
|
||||
nextState = nextState.LrOffsets.TryGetValue(addBase, out var baseOffset)
|
||||
? nextState.WithLrOffset(addDest, unchecked(baseOffset + imm))
|
||||
: nextState.WithoutLrOffset(addDest);
|
||||
}
|
||||
else if (mnemonic == "mtctr" && TryGetInstructionReg(instruction, 0, out var ctrSource))
|
||||
{
|
||||
var newCtrOffset = nextState.LrOffsets.TryGetValue(ctrSource, out var sourceOffset) ? sourceOffset : (int?)null;
|
||||
nextState = nextState.WithCtrOffset(newCtrOffset);
|
||||
}
|
||||
else if (mnemonic == "mtlr" && TryGetInstructionReg(instruction, 0, out var lrSource))
|
||||
{
|
||||
var newLrReturnOffset = nextState.LrOffsets.TryGetValue(lrSource, out var sourceOffset) ? sourceOffset : (int?)null;
|
||||
nextState = nextState.WithLrReturnOffset(newLrReturnOffset);
|
||||
}
|
||||
else if (mnemonic == "stw" &&
|
||||
TryGetInstructionReg(instruction, 0, out var storeSrc) &&
|
||||
TryGetInstructionDisplacement(instruction, 1, out var storeDisp, out var storeBase, out _))
|
||||
{
|
||||
if (storeBase == "r1")
|
||||
{
|
||||
var targetSlot = nextState.SpDelta + storeDisp;
|
||||
nextState = nextState.LrOffsets.TryGetValue(storeSrc, out var offset)
|
||||
? nextState.WithStackOffset(targetSlot, offset)
|
||||
: nextState.WithoutStackOffset(targetSlot);
|
||||
}
|
||||
}
|
||||
else if (mnemonic == "stwu" &&
|
||||
TryGetInstructionReg(instruction, 0, out var stwuSrc) &&
|
||||
TryGetInstructionDisplacement(instruction, 1, out var stwuDisp, out var stwuBase, out _))
|
||||
{
|
||||
if (stwuBase == "r1")
|
||||
{
|
||||
var targetSlot = nextState.SpDelta + stwuDisp;
|
||||
nextState = nextState.LrOffsets.TryGetValue(stwuSrc, out var offset)
|
||||
? nextState.WithStackOffset(targetSlot, offset)
|
||||
: nextState.WithoutStackOffset(targetSlot);
|
||||
nextState = nextState.WithAdjustedStackPointer(stwuDisp);
|
||||
}
|
||||
else
|
||||
{
|
||||
nextState = nextState.WithoutLrOffset(stwuBase);
|
||||
}
|
||||
}
|
||||
else if (TryGetStackStoreRange(instruction, out var storeOffset, out var storeSize, out var updatesStackPointer))
|
||||
{
|
||||
nextState = nextState.WithoutStackOffsetsInRange(
|
||||
nextState.SpDelta + storeOffset,
|
||||
storeSize);
|
||||
if (updatesStackPointer)
|
||||
{
|
||||
nextState = nextState.WithAdjustedStackPointer(storeOffset);
|
||||
}
|
||||
}
|
||||
else if (mnemonic == "lwz" &&
|
||||
TryGetInstructionReg(instruction, 0, out var loadDest) &&
|
||||
TryGetInstructionDisplacement(instruction, 1, out var loadDisp, out var loadBase, out _))
|
||||
{
|
||||
if (loadBase == "r1")
|
||||
{
|
||||
var targetSlot = nextState.SpDelta + loadDisp;
|
||||
var hasStackOffset = nextState.StackOffsets.TryGetValue(targetSlot, out var offset);
|
||||
|
||||
if (loadDest == "r1")
|
||||
{
|
||||
nextState = nextState.WithClearedStackOffsets();
|
||||
}
|
||||
|
||||
nextState = hasStackOffset
|
||||
? nextState.WithLrOffset(loadDest, offset)
|
||||
: nextState.WithoutLrOffset(loadDest);
|
||||
}
|
||||
else
|
||||
{
|
||||
nextState = nextState.WithoutLrOffset(loadDest);
|
||||
if (loadDest == "r1")
|
||||
{
|
||||
nextState = nextState.WithClearedStackOffsets();
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (TryInstructionWritesDest(instruction, out var destinations))
|
||||
{
|
||||
foreach (var dest in destinations)
|
||||
{
|
||||
nextState = nextState.WithoutLrOffset(dest);
|
||||
if (dest == "r1")
|
||||
{
|
||||
nextState = nextState.WithClearedStackOffsets();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (instruction.IsCall || mnemonic == "bl" || mnemonic == "blrl")
|
||||
{
|
||||
nextState = nextState.WithLrReturnOffset(null).WithCtrOffset(null);
|
||||
for (var register = 0; register <= 12; register++)
|
||||
{
|
||||
if (register != 1 && register != 2)
|
||||
{
|
||||
nextState = nextState.WithoutLrOffset($"r{register}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mnemonic == "bctr")
|
||||
{
|
||||
if (state.CtrOffset.HasValue && seenOffsets.Add(state.CtrOffset.Value))
|
||||
{
|
||||
yield return state.CtrOffset.Value;
|
||||
}
|
||||
|
||||
if (!state.CtrOffset.HasValue && instruction.BranchTargets.Count == 0)
|
||||
onUnresolvedExit?.Invoke();
|
||||
|
||||
nextState = nextState.WithCtrOffset(null);
|
||||
if (instruction.BranchTargets.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var isReturn = !instruction.IsCall && (instruction.IsReturn || mnemonic == "blr" || mnemonic == "bclr" ||
|
||||
(mnemonic.StartsWith("b", StringComparison.Ordinal) && mnemonic.EndsWith("lr", StringComparison.Ordinal)));
|
||||
if (isReturn)
|
||||
{
|
||||
if (!state.LrReturnOffset.HasValue)
|
||||
onUnresolvedExit?.Invoke();
|
||||
if (state.LrReturnOffset.HasValue && state.LrReturnOffset.Value != 0 && seenOffsets.Add(state.LrReturnOffset.Value))
|
||||
{
|
||||
yield return state.LrReturnOffset.Value;
|
||||
}
|
||||
|
||||
if (!instruction.IsConditionalBranch)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (instruction.IsUnconditionalBranch)
|
||||
{
|
||||
foreach (var target in instruction.BranchTargets)
|
||||
{
|
||||
if (indexByAddress.TryGetValue(target, out var targetIndex))
|
||||
{
|
||||
Enqueue(targetIndex, nextState);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (instruction.IsConditionalBranch)
|
||||
{
|
||||
var fallthrough = GetFallthroughIndex(instruction);
|
||||
if (fallthrough.HasValue)
|
||||
{
|
||||
Enqueue(fallthrough.Value, nextState);
|
||||
}
|
||||
|
||||
if (!isReturn && !instruction.IsCall)
|
||||
{
|
||||
foreach (var target in instruction.BranchTargets)
|
||||
{
|
||||
if (indexByAddress.TryGetValue(target, out var targetIndex))
|
||||
{
|
||||
Enqueue(targetIndex, nextState);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var fallthrough = GetFallthroughIndex(instruction);
|
||||
if (fallthrough.HasValue)
|
||||
{
|
||||
Enqueue(fallthrough.Value, nextState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static bool TryGetInstructionReg(PpcInstruction instruction, int index, out string register)
|
||||
{
|
||||
if (instruction.Operands.Count > index && instruction.Operands[index] is PpcRegisterOperand operand)
|
||||
{
|
||||
register = NormalizeInstructionReg(operand.Name);
|
||||
return true;
|
||||
}
|
||||
|
||||
register = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool TryGetInstructionDisplacement(PpcInstruction instruction, int index, out int offset, out string baseRegister, out int baseRegisterNumber)
|
||||
{
|
||||
if (instruction.Operands.Count > index && instruction.Operands[index] is PpcDisplacementOperand operand)
|
||||
{
|
||||
offset = operand.Offset;
|
||||
baseRegister = NormalizeInstructionReg(operand.BaseRegister);
|
||||
baseRegisterNumber = operand.BaseRegisterNumber;
|
||||
return true;
|
||||
}
|
||||
|
||||
offset = 0;
|
||||
baseRegister = string.Empty;
|
||||
baseRegisterNumber = -1;
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool TryGetInstructionImm(PpcInstruction instruction, int index, out int immediate)
|
||||
{
|
||||
if (instruction.Operands.Count > index && instruction.Operands[index] is PpcImmediateOperand operand)
|
||||
{
|
||||
immediate = operand.Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
immediate = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool TryInstructionWritesDest(PpcInstruction instruction, out IReadOnlyList<string> destinations)
|
||||
{
|
||||
if (instruction.Operands.Count == 0 || instruction.Operands[0] is not PpcRegisterOperand operand)
|
||||
{
|
||||
destinations = Array.Empty<string>();
|
||||
return false;
|
||||
}
|
||||
|
||||
var mnemonic = instruction.Mnemonic.ToLowerInvariant();
|
||||
if (mnemonic.StartsWith("st", StringComparison.Ordinal) ||
|
||||
mnemonic.StartsWith("b", StringComparison.Ordinal) ||
|
||||
mnemonic.StartsWith("cmp", StringComparison.Ordinal))
|
||||
{
|
||||
destinations = Array.Empty<string>();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mnemonic == "lmw")
|
||||
{
|
||||
var startReg = Math.Clamp(operand.Number, 0, 31);
|
||||
var regs = new string[32 - startReg];
|
||||
for (var r = startReg; r <= 31; r++)
|
||||
{
|
||||
regs[r - startReg] = $"r{r}";
|
||||
}
|
||||
destinations = regs;
|
||||
return true;
|
||||
}
|
||||
|
||||
destinations = [NormalizeInstructionReg(operand.Name)];
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool TryGetStackStoreRange(PpcInstruction instruction, out int offset, out int size, out bool updatesStackPointer)
|
||||
{
|
||||
offset = 0;
|
||||
size = 0;
|
||||
updatesStackPointer = false;
|
||||
if (!TryGetInstructionDisplacement(instruction, 1, out offset, out var baseRegister, out _) ||
|
||||
baseRegister != "r1")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (instruction.Mnemonic.ToLowerInvariant())
|
||||
{
|
||||
case "stfs":
|
||||
size = 4;
|
||||
return true;
|
||||
case "stfsu":
|
||||
size = 4;
|
||||
updatesStackPointer = true;
|
||||
return true;
|
||||
case "stfd":
|
||||
size = 8;
|
||||
return true;
|
||||
case "stfdu":
|
||||
size = 8;
|
||||
updatesStackPointer = true;
|
||||
return true;
|
||||
case "stmw" when instruction.Operands[0] is PpcRegisterOperand register:
|
||||
size = checked((32 - Math.Clamp(register.Number, 0, 31)) * 4);
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static string NormalizeInstructionReg(string register) => register.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private sealed class PathState : IEquatable<PathState>
|
||||
{
|
||||
public ImmutableDictionary<string, int> LrOffsets { get; }
|
||||
public int? CtrOffset { get; }
|
||||
public int? LrReturnOffset { get; }
|
||||
public int SpDelta { get; }
|
||||
public ImmutableDictionary<int, int> StackOffsets { get; }
|
||||
|
||||
public PathState(
|
||||
ImmutableDictionary<string, int> lrOffsets,
|
||||
int? ctrOffset,
|
||||
int? lrReturnOffset,
|
||||
int spDelta,
|
||||
ImmutableDictionary<int, int> stackOffsets)
|
||||
{
|
||||
LrOffsets = lrOffsets;
|
||||
CtrOffset = ctrOffset;
|
||||
LrReturnOffset = lrReturnOffset;
|
||||
SpDelta = spDelta;
|
||||
StackOffsets = stackOffsets;
|
||||
}
|
||||
|
||||
public static readonly PathState Empty = new(
|
||||
ImmutableDictionary<string, int>.Empty.WithComparers(StringComparer.OrdinalIgnoreCase),
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
ImmutableDictionary<int, int>.Empty);
|
||||
|
||||
public PathState WithLrOffset(string register, int offset) =>
|
||||
LrOffsets.TryGetValue(register, out var cur) && cur == offset
|
||||
? this
|
||||
: new(LrOffsets.SetItem(register, offset), CtrOffset, LrReturnOffset, SpDelta, StackOffsets);
|
||||
|
||||
public PathState WithoutLrOffset(string register) =>
|
||||
LrOffsets.ContainsKey(register)
|
||||
? new(LrOffsets.Remove(register), CtrOffset, LrReturnOffset, SpDelta, StackOffsets)
|
||||
: this;
|
||||
|
||||
public PathState WithCtrOffset(int? ctrOffset) =>
|
||||
ctrOffset == CtrOffset
|
||||
? this
|
||||
: new(LrOffsets, ctrOffset, LrReturnOffset, SpDelta, StackOffsets);
|
||||
|
||||
public PathState WithLrReturnOffset(int? lrReturnOffset) =>
|
||||
lrReturnOffset == LrReturnOffset
|
||||
? this
|
||||
: new(LrOffsets, CtrOffset, lrReturnOffset, SpDelta, StackOffsets);
|
||||
|
||||
public PathState WithSpDelta(int spDelta) =>
|
||||
spDelta == SpDelta
|
||||
? this
|
||||
: new(LrOffsets, CtrOffset, LrReturnOffset, spDelta, StackOffsets);
|
||||
|
||||
public PathState WithAdjustedStackPointer(int displacement)
|
||||
{
|
||||
// r1 can hold an LR-relative address too. Update both relations;
|
||||
// guest address arithmetic wraps at 32 bits.
|
||||
var updated = WithSpDelta(unchecked(SpDelta + displacement));
|
||||
return LrOffsets.TryGetValue("r1", out var offset)
|
||||
? updated.WithLrOffset("r1", unchecked(offset + displacement))
|
||||
: updated;
|
||||
}
|
||||
|
||||
public PathState WithStackOffset(int slot, int offset) =>
|
||||
StackOffsets.TryGetValue(slot, out var cur) && cur == offset
|
||||
? this
|
||||
: new(LrOffsets, CtrOffset, LrReturnOffset, SpDelta, StackOffsets.SetItem(slot, offset));
|
||||
|
||||
public PathState WithoutStackOffset(int slot) =>
|
||||
StackOffsets.ContainsKey(slot)
|
||||
? new(LrOffsets, CtrOffset, LrReturnOffset, SpDelta, StackOffsets.Remove(slot))
|
||||
: this;
|
||||
|
||||
public PathState WithoutStackOffsetsInRange(int start, int size)
|
||||
{
|
||||
var end = checked(start + size);
|
||||
var remaining = StackOffsets;
|
||||
foreach (var slot in StackOffsets.Keys)
|
||||
{
|
||||
if (slot < end && start < checked(slot + 4))
|
||||
{
|
||||
remaining = remaining.Remove(slot);
|
||||
}
|
||||
}
|
||||
|
||||
return remaining.Count == StackOffsets.Count
|
||||
? this
|
||||
: new(LrOffsets, CtrOffset, LrReturnOffset, SpDelta, remaining);
|
||||
}
|
||||
|
||||
public PathState WithClearedStackOffsets() =>
|
||||
StackOffsets.IsEmpty
|
||||
? this
|
||||
: new(LrOffsets, CtrOffset, LrReturnOffset, SpDelta, ImmutableDictionary<int, int>.Empty);
|
||||
|
||||
public bool Equals(PathState? other)
|
||||
{
|
||||
if (ReferenceEquals(this, other)) return true;
|
||||
if (other is null) return false;
|
||||
if (CtrOffset != other.CtrOffset || LrReturnOffset != other.LrReturnOffset || SpDelta != other.SpDelta) return false;
|
||||
if (LrOffsets.Count != other.LrOffsets.Count || StackOffsets.Count != other.StackOffsets.Count) return false;
|
||||
foreach (var (k, v) in LrOffsets)
|
||||
{
|
||||
if (!other.LrOffsets.TryGetValue(k, out var otherV) || v != otherV)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
foreach (var (k, v) in StackOffsets)
|
||||
{
|
||||
if (!other.StackOffsets.TryGetValue(k, out var otherV) || v != otherV)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public override bool Equals(object? obj) => obj is PathState other && Equals(other);
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
var hash = new HashCode();
|
||||
hash.Add(CtrOffset);
|
||||
hash.Add(LrReturnOffset);
|
||||
hash.Add(SpDelta);
|
||||
hash.Add(LrOffsets.Count);
|
||||
var regHash = 0;
|
||||
foreach (var (k, v) in LrOffsets)
|
||||
{
|
||||
regHash ^= HashCode.Combine(StringComparer.OrdinalIgnoreCase.GetHashCode(k), v);
|
||||
}
|
||||
hash.Add(regHash);
|
||||
hash.Add(StackOffsets.Count);
|
||||
var stackHash = 0;
|
||||
foreach (var (k, v) in StackOffsets)
|
||||
{
|
||||
stackHash ^= HashCode.Combine(k, v);
|
||||
}
|
||||
hash.Add(stackHash);
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using Translator.Core.Disassembly;
|
||||
|
||||
namespace Translator.Core.Mods;
|
||||
|
||||
// Opaque exits prevent classification; truncated exploration may also hide offsets.
|
||||
public sealed record LrContinuationAnalysis(IReadOnlyList<int> Offsets, bool IsComplete, bool WasTruncated)
|
||||
{
|
||||
public bool MaySkipReturn => !IsComplete || Offsets.Count != 0;
|
||||
|
||||
public static LrContinuationAnalysis Analyze(
|
||||
uint entryPoint,
|
||||
Func<uint, IReadOnlyList<PpcInstruction>> discover)
|
||||
{
|
||||
const int maxFunctions = 256;
|
||||
const int maxInstructions = 65536;
|
||||
var instructions = new Dictionary<uint, PpcInstruction>();
|
||||
var pending = new Queue<uint>();
|
||||
var visited = new HashSet<uint>();
|
||||
var complete = true;
|
||||
var truncated = false;
|
||||
pending.Enqueue(entryPoint);
|
||||
|
||||
while (pending.TryDequeue(out var entry))
|
||||
{
|
||||
if (instructions.ContainsKey(entry) || !visited.Add(entry))
|
||||
continue;
|
||||
if (visited.Count > maxFunctions)
|
||||
{
|
||||
complete = false;
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
IReadOnlyList<PpcInstruction> body;
|
||||
try
|
||||
{
|
||||
body = discover(entry);
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidOperationException or ArgumentException
|
||||
or IndexOutOfRangeException or NotSupportedException or OverflowException)
|
||||
{
|
||||
complete = false;
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
if (!body.Any(instruction => instruction.Address == entry) ||
|
||||
instructions.Count + body.Count > maxInstructions)
|
||||
{
|
||||
complete = false;
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var instruction in body)
|
||||
instructions.TryAdd(instruction.Address, instruction);
|
||||
foreach (var instruction in body)
|
||||
{
|
||||
if (!instruction.IsCall)
|
||||
{
|
||||
foreach (var target in instruction.BranchTargets)
|
||||
if (!instructions.ContainsKey(target)) pending.Enqueue(target);
|
||||
}
|
||||
if ((!instruction.IsReturn && !instruction.IsUnconditionalBranch) ||
|
||||
instruction.IsConditionalBranch)
|
||||
{
|
||||
if (!instructions.ContainsKey(instruction.EndAddress))
|
||||
pending.Enqueue(instruction.EndAddress);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!instructions.TryGetValue(entryPoint, out var first))
|
||||
return new LrContinuationAnalysis([], false, true);
|
||||
|
||||
// Keep the original entry first, including when a tail target precedes it.
|
||||
var ordered = new[] { first }.Concat(instructions.Values
|
||||
.Where(instruction => instruction.Address != entryPoint)
|
||||
.OrderBy(instruction => instruction.Address)).ToArray();
|
||||
var offsets = ContinuationPlanner.DiscoverLrRelativeIndirectJumpOffsets(
|
||||
ordered, () => { complete = false; truncated = true; }, () => complete = false).ToArray();
|
||||
return new LrContinuationAnalysis(offsets, complete, truncated);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Linq;
|
||||
using Translator.Core.Disassembly;
|
||||
using Translator.Core.Mods;
|
||||
using Translator.Core.Mods.Mkwii;
|
||||
using Translator.Core.Parsing.Kamek;
|
||||
@@ -128,6 +130,54 @@ public class ContinuationPlannerTests
|
||||
Assert.Contains("Retro WFC executable hook continuation", entry.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoverLrRelativeIndirectJumpOffsets_DiscoversSkipReturnOffset()
|
||||
{
|
||||
var instructions = new[]
|
||||
{
|
||||
PpcDecoder.Decode(0x8180D8E8, 0x7FE802A6u), // mflr r31
|
||||
PpcDecoder.Decode(0x8180D8EC, 0x3BFF0014u), // addi r31, r31, 20
|
||||
PpcDecoder.Decode(0x8180D8F0, 0x7FE803A6u), // mtlr r31
|
||||
PpcDecoder.Decode(0x8180D8F4, 0x4E800020u), // blr
|
||||
};
|
||||
|
||||
var offsets = ContinuationPlanner.DiscoverLrRelativeIndirectJumpOffsets(instructions).ToArray();
|
||||
var offset = Assert.Single(offsets);
|
||||
Assert.Equal(20, offset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoverLrRelativeIndirectJumpOffsets_IgnoresStandardLrRestore()
|
||||
{
|
||||
var instructions = new[]
|
||||
{
|
||||
PpcDecoder.Decode(0x8180D8E8, 0x7FE802A6u), // mflr r31
|
||||
PpcDecoder.Decode(0x8180D8EC, 0x93E10008u), // stw r31, 8(r1)
|
||||
PpcDecoder.Decode(0x8180D8F0, 0x83E10008u), // lwz r31, 8(r1)
|
||||
PpcDecoder.Decode(0x8180D8F4, 0x7FE803A6u), // mtlr r31
|
||||
PpcDecoder.Decode(0x8180D8F8, 0x4E800020u), // blr
|
||||
};
|
||||
|
||||
var offsets = ContinuationPlanner.DiscoverLrRelativeIndirectJumpOffsets(instructions);
|
||||
Assert.Empty(offsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoverLrRelativeIndirectJumpOffsets_SupportsBctrOffset()
|
||||
{
|
||||
var instructions = new[]
|
||||
{
|
||||
PpcDecoder.Decode(0x8180D8E8, 0x7FE802A6u), // mflr r31
|
||||
PpcDecoder.Decode(0x8180D8EC, 0x397F0008u), // addi r11, r31, 8
|
||||
PpcDecoder.Decode(0x8180D8F0, 0x7D6903A6u), // mtctr r11
|
||||
PpcDecoder.Decode(0x8180D8F4, 0x4E800420u), // bctr
|
||||
};
|
||||
|
||||
var offsets = ContinuationPlanner.DiscoverLrRelativeIndirectJumpOffsets(instructions).ToArray();
|
||||
var offset = Assert.Single(offsets);
|
||||
Assert.Equal(8, offset);
|
||||
}
|
||||
|
||||
private static KamekChunk EmptyChunk() =>
|
||||
new(
|
||||
0,
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text.Json;
|
||||
using Translator.Cli.Configuration;
|
||||
using Translator.Core.Build;
|
||||
using Translator.Core.Mods;
|
||||
using Translator.Core.Parsing.Kamek;
|
||||
|
||||
namespace Translator.Tests;
|
||||
|
||||
public sealed class KamekLrContinuationIntegrationTests
|
||||
{
|
||||
private const uint Caller = 0x80004000;
|
||||
private const uint Module = 0x80010000;
|
||||
private const uint Hook = Module + 0x40;
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public void SkipReturnThroughKnownTailCallsRetainsCallerDispatch(int tailDepth)
|
||||
{
|
||||
var bundle = Translate(tailDepth, SkipReturn(20));
|
||||
AssertResumeDispatch(Source(bundle, Caller), Caller, Caller + 24);
|
||||
Assert.Contains(bundle.Entries, entry => entry.EntryPoint == Caller + 24 &&
|
||||
entry.VirtualPath.Contains("rr_continue_"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public void OrdinaryReturnsThroughKnownTailCallsStayLightweight(int tailDepth)
|
||||
{
|
||||
var bundle = Translate(tailDepth, [0x38630001u, 0x4E800020u]); // addi r3,r3,1; blr
|
||||
var caller = Source(bundle, Caller);
|
||||
Assert.DoesNotContain("switch (ctx->lr)", caller);
|
||||
Assert.DoesNotContain("if (ctx->lr !=", caller);
|
||||
Assert.DoesNotContain(bundle.Entries, entry => entry.VirtualPath.Contains("rr_continue_"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectSkipReturnRegistersTheAdjustedBaseAddress()
|
||||
{
|
||||
var bundle = Translate(0, SkipReturn(20));
|
||||
AssertResumeDispatch(Source(bundle, Caller), Caller, Caller + 24);
|
||||
Assert.Contains(bundle.Entries, entry => entry.EntryPoint == Caller + 24 &&
|
||||
entry.VirtualPath.Contains("rr_continue_"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CtrSkipRetainsDispatchAndRegistersItsContinuation()
|
||||
{
|
||||
var bundle = Translate(0,
|
||||
[
|
||||
0x7D8802A6u, // mflr r12
|
||||
0x398C0014u, // addi r12,r12,20
|
||||
0x7D8903A6u, // mtctr r12
|
||||
0x4E800420u // bctr
|
||||
]);
|
||||
AssertResumeDispatch(Source(bundle, Caller), Caller, Caller + 24);
|
||||
Assert.Contains(bundle.Entries, entry => entry.EntryPoint == Caller + 24 &&
|
||||
entry.VirtualPath.Contains("rr_continue_"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalReturnArmDoesNotHideTailCalledSkipReturn()
|
||||
{
|
||||
var bundle = Translate(1, SkipReturn(20), conditionalWrapper: true);
|
||||
AssertResumeDispatch(Source(bundle, Caller), Caller, Caller + 24);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(1)]
|
||||
public void SharedHookRetainsResumeDispatchAtEveryCallSite(int tailDepth)
|
||||
{
|
||||
var bundle = Translate(tailDepth, SkipReturn(20), sharedTarget: true);
|
||||
AssertResumeDispatch(Source(bundle, Caller), Caller, Caller + 24);
|
||||
AssertResumeDispatch(Source(bundle, Caller + 0x40), Caller + 0x40, Caller + 0x58);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(1)]
|
||||
public void SkipReturnHandlingDoesNotSpreadToAnOrdinaryPatchedCaller(int tailDepth)
|
||||
{
|
||||
var bundle = Translate(tailDepth, SkipReturn(20), companionBody: [0x38630001u, 0x4E800020u]);
|
||||
var ordinaryCaller = Source(bundle, Caller + 0x40);
|
||||
Assert.DoesNotContain("switch (ctx->lr)", ordinaryCaller);
|
||||
Assert.DoesNotContain("if (ctx->lr !=", ordinaryCaller);
|
||||
AssertResumeDispatch(Source(bundle, Caller), Caller, Caller + 24);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(20)]
|
||||
public void SavedAndRestoredLrDistinguishesOrdinaryAndSkipReturns(int offset)
|
||||
{
|
||||
var bundle = Translate(0,
|
||||
[
|
||||
0x7D8802A6u, // mflr r12
|
||||
0x91810004u, // stw r12,4(r1)
|
||||
0x81810004u, // lwz r12,4(r1)
|
||||
0x398C0000u | (ushort)offset, // addi r12,r12,offset
|
||||
0x7D8803A6u, // mtlr r12
|
||||
0x4E800020u // blr
|
||||
]);
|
||||
var caller = Source(bundle, Caller);
|
||||
if (offset == 0)
|
||||
Assert.DoesNotContain("if (ctx->lr !=", caller);
|
||||
else
|
||||
AssertResumeDispatch(caller, Caller, Caller + 4 + (uint)offset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CappedAnalysisDoesNotClassifyAHookAsAnOrdinaryReturn()
|
||||
{
|
||||
var bundle = Translate(0, SyntheticLrHookFactory.ManyOrdinaryReturnPaths());
|
||||
AssertResumeDispatch(Source(bundle, Caller), Caller, Caller + 24);
|
||||
Assert.Contains(bundle.Entries, entry => entry.EntryPoint == Caller + 24 &&
|
||||
entry.VirtualPath.Contains("rr_continue_"));
|
||||
}
|
||||
|
||||
private static void AssertResumeDispatch(string source, uint callSite, uint continuation)
|
||||
{
|
||||
var call = source.IndexOf($"InvokeDirectCpu<0x{Hook:X8}u>(ctx);", StringComparison.Ordinal);
|
||||
Assert.True(call >= 0, source);
|
||||
var guard = source.IndexOf($"if (ctx->lr != 0x{callSite + 4:X8}u)", call, StringComparison.Ordinal);
|
||||
Assert.True(guard > call, $"The hook must dispatch its adjusted return address.\n{source}");
|
||||
Assert.Contains($"case 0x{continuation:X8}u:", source[guard..]);
|
||||
Assert.Contains($"goto loc_{continuation:X8};", source[guard..]);
|
||||
Assert.Contains($"loc_{continuation:X8}:", source);
|
||||
}
|
||||
|
||||
private static uint[] SkipReturn(int offset) =>
|
||||
[
|
||||
0x7D8802A6u, // mflr r12
|
||||
0x398C0000u | (ushort)offset, // addi r12,r12,offset
|
||||
0x7D8803A6u, // mtlr r12
|
||||
0x4E800020u // blr
|
||||
];
|
||||
|
||||
private static string Source(TranslationSourceBundle bundle, uint address) =>
|
||||
Assert.Single(bundle.Entries.Where(entry => entry.EntryPoint == address &&
|
||||
entry.VirtualPath.StartsWith("overlays/", StringComparison.Ordinal))).Source;
|
||||
|
||||
private static TranslationSourceBundle Translate(int tailDepth, uint[] body,
|
||||
bool conditionalWrapper = false, bool sharedTarget = false, uint[]? companionBody = null)
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), $"kamek-lr-integration-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(root);
|
||||
try
|
||||
{
|
||||
uint[] caller = [0x60000000u, 0x38630001u, 0x38630001u, 0x38630001u,
|
||||
0x38630001u, 0x38630001u, 0x60000000u, 0x4E800020u];
|
||||
var hasSecondCaller = sharedTarget || companionBody is not null;
|
||||
var baseWords = Enumerable.Repeat(0x4E800020u, hasSecondCaller ? 24 : 8).ToArray();
|
||||
caller.CopyTo(baseWords, 0);
|
||||
if (hasSecondCaller) caller.CopyTo(baseWords, 16);
|
||||
var baseBytes = Words(baseWords);
|
||||
File.WriteAllBytes(Path.Combine(root, "main.dol"), SyntheticDolFactory.CreateBytes(
|
||||
Caller, sections: [SyntheticDolFactory.Text(0, Caller, baseWords)]));
|
||||
File.WriteAllBytes(Path.Combine(root, "base.bin"), baseBytes);
|
||||
// translate-mod requires a REL; this fixture has no REL code or relocations.
|
||||
File.WriteAllBytes(Path.Combine(root, "empty.rel"), new byte[0x48]);
|
||||
var functions = new List<BaseFunctionRangeMetadata>
|
||||
{
|
||||
new(Caller, Caller + 32, "caller", ".text", 0, "synthetic", ["Executable"])
|
||||
};
|
||||
if (hasSecondCaller)
|
||||
functions.Add(new(Caller + 0x40, Caller + 0x60, "second_caller", ".text", 0x40,
|
||||
"synthetic", ["Executable"]));
|
||||
var manifest = new BaseManifest("synthetic", 1, "TEST01", "P", "", 0,
|
||||
[new BaseSectionMetadata(".text", "main.dol", Caller, Caller + (uint)baseBytes.Length,
|
||||
true, false, "base.bin", 0)],
|
||||
functions, "ranges.json");
|
||||
var manifestPath = Path.Combine(root, "base.json");
|
||||
File.WriteAllText(manifestPath, JsonSerializer.Serialize(manifest));
|
||||
var projectPath = Path.Combine(root, "recomp.yml");
|
||||
Directory.CreateDirectory(Path.Combine(root, "native"));
|
||||
Directory.CreateDirectory(Path.Combine(root, "generated", "functions"));
|
||||
File.WriteAllText(projectPath, """
|
||||
schema_version: 1
|
||||
project:
|
||||
id: kamek-lr-test
|
||||
memory:
|
||||
base: 0x80000000
|
||||
size: 0x00020000
|
||||
sda_base: 0x80002000
|
||||
sda2_base: 0x80003000
|
||||
inputs:
|
||||
dol:
|
||||
path: main.dol
|
||||
rel:
|
||||
path: empty.rel
|
||||
load_address: 0x80008000
|
||||
runtime:
|
||||
native_registration_root: native
|
||||
output:
|
||||
root: generated
|
||||
""");
|
||||
|
||||
var moduleSize = companionBody is null ? 0x40 * (tailDepth + 1) + body.Length * 4
|
||||
: 0x300 + companionBody.Length * 4;
|
||||
var moduleWords = Enumerable.Repeat(0x4E800020u, moduleSize / 4).ToArray();
|
||||
body.CopyTo(moduleWords, (0x40 * (tailDepth + 1)) / 4);
|
||||
var commands = new List<uint> { ((uint)KamekCommandId.BranchLink << 24) | 0x00FFFFFEu, Caller, Hook - Module };
|
||||
if (hasSecondCaller)
|
||||
commands.AddRange([((uint)KamekCommandId.BranchLink << 24) | 0x00FFFFFEu,
|
||||
Caller + 0x40, sharedTarget ? Hook - Module : 0x300u]);
|
||||
companionBody?.CopyTo(moduleWords, 0x300 / 4);
|
||||
for (var depth = 0; depth < tailDepth; depth++)
|
||||
{
|
||||
var offset = 0x40u * (uint)(depth + 1);
|
||||
var branchOffset = offset;
|
||||
if (conditionalWrapper && depth == 0)
|
||||
{
|
||||
moduleWords[offset / 4] = 0x2C030000u; // cmpwi r3,0
|
||||
moduleWords[offset / 4 + 1] = 0x4D820020u; // beqlr
|
||||
branchOffset += 8;
|
||||
}
|
||||
// Explicit Kamek branch targets make every helper a known function boundary.
|
||||
commands.Add(((uint)KamekCommandId.Branch << 24) | branchOffset);
|
||||
commands.Add(offset + 0x40);
|
||||
}
|
||||
var code = Words(moduleWords);
|
||||
var commandBytes = Words(commands.ToArray());
|
||||
var pul = new byte[KamekChunk.HeaderSize + code.Length + commandBytes.Length];
|
||||
Write(pul, 0, KamekChunk.Magic0);
|
||||
Write(pul, 4, KamekChunk.Magic1);
|
||||
Write(pul, 12, (uint)code.Length);
|
||||
Write(pul, 24, (uint)pul.Length);
|
||||
code.CopyTo(pul, KamekChunk.HeaderSize);
|
||||
commandBytes.CopyTo(pul, KamekChunk.HeaderSize + code.Length);
|
||||
var pulPath = Path.Combine(root, "Code.pul");
|
||||
File.WriteAllBytes(pulPath, pul);
|
||||
var output = Path.Combine(root, "mod");
|
||||
string[] args = ["translate-mod", "--project", projectPath, "--code-pul", pulPath,
|
||||
"--base-manifest", manifestPath, "--out", output, "--module-guest-base", $"0x{Module:X8}",
|
||||
"--module-link-base", $"0x{Module:X8}", "--skip-retro-wfc", "--emit-cpp", "--threads", "1"];
|
||||
var entryPoint = typeof(TranslationProjectConfig).Assembly.EntryPoint!;
|
||||
var exitCode = Assert.IsType<int>(entryPoint.Invoke(null, [args]));
|
||||
Assert.Equal(0, exitCode);
|
||||
return TranslationSourceBundle.Read(Path.Combine(output, "translated_sources.bin"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] Words(uint[] words)
|
||||
{
|
||||
var bytes = new byte[words.Length * 4];
|
||||
for (var index = 0; index < words.Length; index++) Write(bytes, index * 4, words[index]);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static void Write(byte[] bytes, int offset, uint value) =>
|
||||
BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(offset, 4), value);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using Translator.Core.Disassembly;
|
||||
using Translator.Core.Mods;
|
||||
|
||||
namespace Translator.Tests;
|
||||
|
||||
public sealed class LrContinuationAnalysisTests
|
||||
{
|
||||
private const uint Entry = 0x80010000;
|
||||
private const uint Tail = Entry - 0x40;
|
||||
|
||||
[Fact]
|
||||
public void TailHelperUsesTheWrappersLrState()
|
||||
{
|
||||
var result = Analyze(new Dictionary<uint, uint[]>
|
||||
{
|
||||
[Entry] = [0x7D8802A6, 0x398C0014, 0x4BFFFFB8], // mflr; addi +20; b Tail
|
||||
[Tail] = [0x7D8803A6, 0x4E800020] // mtlr r12; blr
|
||||
});
|
||||
Assert.True(result.IsComplete);
|
||||
Assert.Equal(new[] { 20 }, result.Offsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TailHelperUsesTheWrappersStackFrame()
|
||||
{
|
||||
var result = Analyze(new Dictionary<uint, uint[]>
|
||||
{
|
||||
[Entry] = [0x7D8802A6, 0x9421FFF0, 0x91810014, 0x4BFFFFB4],
|
||||
[Tail] = [0x38210010, 0x81810004, 0x398C0014, 0x7D8803A6, 0x4E800020]
|
||||
});
|
||||
Assert.True(result.IsComplete);
|
||||
Assert.Equal(new[] { 20 }, result.Offsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrdinaryTailCycleTerminatesWithoutInventingOffsets()
|
||||
{
|
||||
var result = Analyze(new Dictionary<uint, uint[]>
|
||||
{
|
||||
[Entry] = [0x4BFFFFC0],
|
||||
[Tail] = [0x48000040]
|
||||
});
|
||||
Assert.True(result.IsComplete);
|
||||
Assert.False(result.MaySkipReturn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdjustingTailCycleReportsIncompleteAnalysis()
|
||||
{
|
||||
var result = Analyze(new Dictionary<uint, uint[]>
|
||||
{
|
||||
[Entry] = [0x7D8802A6, 0x4800003C],
|
||||
[Entry + 0x40] = [0x398C0004, 0x4BFFFFFC]
|
||||
});
|
||||
Assert.False(result.IsComplete);
|
||||
Assert.True(result.WasTruncated);
|
||||
Assert.True(result.MaySkipReturn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UndecodableTailDoesNotProveAnOrdinaryReturn()
|
||||
{
|
||||
var result = Analyze(new Dictionary<uint, uint[]> { [Entry] = [0x4BFFFFC0] });
|
||||
Assert.False(result.IsComplete);
|
||||
Assert.True(result.WasTruncated);
|
||||
Assert.True(result.MaySkipReturn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalCallDoesNotInheritItsCalleesSkipOffset()
|
||||
{
|
||||
var visited = new List<uint>();
|
||||
var result = LrContinuationAnalysis.Analyze(Entry, address =>
|
||||
{
|
||||
visited.Add(address);
|
||||
return Decode(address, [0x7FE802A6, 0x4BFFFFBD, 0x7FE803A6, 0x4E800020]);
|
||||
});
|
||||
Assert.Equal(new[] { Entry }, visited);
|
||||
Assert.True(result.IsComplete);
|
||||
Assert.False(result.MaySkipReturn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownIndirectTailIsIncomplete()
|
||||
{
|
||||
var result = Analyze(new Dictionary<uint, uint[]> { [Entry] = [0x7D8903A6, 0x4E800420] });
|
||||
Assert.False(result.IsComplete);
|
||||
Assert.False(result.WasTruncated);
|
||||
Assert.True(result.MaySkipReturn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConditionalCallDoesNotTraverseATargetAlsoUsedByATailBranch()
|
||||
{
|
||||
var result = Analyze(new Dictionary<uint, uint[]>
|
||||
{
|
||||
[Entry] = [0x7FE802A6, 0x4182FFBD, 0x3BE00000, 0x4BFFFFB4],
|
||||
[Tail] = [0x3BFF0014, 0x7FE803A6, 0x4E800020]
|
||||
});
|
||||
Assert.Empty(result.Offsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TailDiscoveryBudgetDoesNotProveAnOrdinaryReturn()
|
||||
{
|
||||
var calls = 0;
|
||||
var result = LrContinuationAnalysis.Analyze(Entry, address =>
|
||||
{
|
||||
calls++;
|
||||
return Decode(address, [0x48000040]);
|
||||
});
|
||||
Assert.InRange(calls, 1, 256);
|
||||
Assert.False(result.IsComplete);
|
||||
Assert.True(result.WasTruncated);
|
||||
Assert.True(result.MaySkipReturn);
|
||||
}
|
||||
|
||||
private static LrContinuationAnalysis Analyze(Dictionary<uint, uint[]> functions) =>
|
||||
LrContinuationAnalysis.Analyze(Entry, address => functions.TryGetValue(address, out var words)
|
||||
? Decode(address, words)
|
||||
: throw new InvalidOperationException("No synthetic function at this address."));
|
||||
|
||||
private static PpcInstruction[] Decode(uint address, uint[] words) =>
|
||||
words.Select((word, index) => PpcDecoder.Decode(address + (uint)index * 4, word)).ToArray();
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Translator.Core.Analysis.Ssa;
|
||||
using Translator.Core.Analysis.Representation;
|
||||
using Translator.Core.CodeGen;
|
||||
using Translator.Core.Ir;
|
||||
using Translator.Core.Representation;
|
||||
using Xunit;
|
||||
|
||||
namespace Translator.Tests;
|
||||
|
||||
// This binary-free code-generation regression must run in the default suite.
|
||||
public class LrContinuationCodeGenTests
|
||||
{
|
||||
[Fact]
|
||||
public void CodeGenerator_DispatchesGuestCallLrContinuationWithoutMarkingTargetNonReturning()
|
||||
{
|
||||
var function = new IrFunction(
|
||||
"lr_continuation_call",
|
||||
"0x800E591C",
|
||||
new[]
|
||||
{
|
||||
new IrBasicBlock("0x800E591C", new IrInstruction[]
|
||||
{
|
||||
new IrAssign("lr", IrValue.Imm(unchecked((int)0x800E5920u))),
|
||||
new IrCall(string.Empty, "0x8179AC3C", Array.Empty<IrValue>()),
|
||||
new IrAssign("r3", IrValue.Imm(8)),
|
||||
new IrReturn(null)
|
||||
}),
|
||||
new IrBasicBlock("0x800E5934", new IrInstruction[]
|
||||
{
|
||||
new IrAssign("r3", IrValue.Imm(1)),
|
||||
new IrReturn(null)
|
||||
})
|
||||
});
|
||||
|
||||
var types = new RepresentationEnvironment(new Dictionary<string, ValueRepresentation>
|
||||
{
|
||||
["lr"] = ValueRepresentation.UInt32,
|
||||
["r3"] = ValueRepresentation.UInt32
|
||||
});
|
||||
var signature = new FunctionAbiClassification("lr_continuation_call", ValueRepresentation.Void);
|
||||
|
||||
var ssa = new SsaTransformer().Convert(function);
|
||||
var code = new CxxLinearCodeGenerator().Emit(
|
||||
0x800E591C,
|
||||
ssa,
|
||||
signature,
|
||||
types,
|
||||
lrContinuationCallTargets: new HashSet<uint> { 0x8179AC3Cu });
|
||||
|
||||
var callIndex = code.IndexOf("InvokeDirectCpu<0x8179AC3Cu>(ctx);", StringComparison.Ordinal);
|
||||
var fallthroughGuardIndex = code.IndexOf("if (ctx->lr != 0x800E5920u)", callIndex, StringComparison.Ordinal);
|
||||
var localCaseIndex = code.IndexOf("case 0x800E5934u:", fallthroughGuardIndex, StringComparison.Ordinal);
|
||||
var returnIndex = code.IndexOf("return;", localCaseIndex, StringComparison.Ordinal);
|
||||
var fallthroughAssignmentIndex = code.IndexOf("r3 = 8;", callIndex, StringComparison.Ordinal);
|
||||
|
||||
Assert.True(callIndex >= 0);
|
||||
Assert.True(fallthroughGuardIndex > callIndex);
|
||||
Assert.True(localCaseIndex > fallthroughGuardIndex);
|
||||
Assert.True(returnIndex > localCaseIndex);
|
||||
Assert.True(fallthroughAssignmentIndex > returnIndex, code);
|
||||
Assert.Contains("goto loc_800E5934;", code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using Translator.Core.Disassembly;
|
||||
using Translator.Core.Mods;
|
||||
|
||||
namespace Translator.Tests;
|
||||
|
||||
public sealed class LrRelativeAnalysisCompletenessTests
|
||||
{
|
||||
[Fact]
|
||||
public void EmptyResultReportsWhenPathStatesWereDropped()
|
||||
{
|
||||
var capped = false;
|
||||
var offsets = Analyze(SyntheticLrHookFactory.ManyOrdinaryReturnPaths(), () => capped = true);
|
||||
|
||||
Assert.Empty(offsets);
|
||||
Assert.True(capped, "An empty result from capped exploration must not prove an ordinary return.");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(20)]
|
||||
public void ExhaustiveAnalysisDoesNotReportAStateCap(int offset)
|
||||
{
|
||||
var capped = false;
|
||||
var offsets = Analyze(
|
||||
[
|
||||
0x7D8802A6u, // mflr r12
|
||||
0x398C0000u | (ushort)offset, // addi r12,r12,offset
|
||||
0x7D8803A6u, // mtlr r12
|
||||
0x4E800020u // blr
|
||||
], () => capped = true);
|
||||
|
||||
Assert.False(capped);
|
||||
Assert.Equal(offset == 0 ? Array.Empty<int>() : [offset], offsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CappedArmDoesNotDiscardAnOffsetFoundOnAnotherArm()
|
||||
{
|
||||
var capped = false;
|
||||
var offsets = Analyze(
|
||||
[
|
||||
0x7D8802A6u, // +00: mflr r12
|
||||
0x2C030000u, // +04: cmpwi r3,0
|
||||
0x41820010u, // +08: beq +0x18
|
||||
0x398C0014u, // +0C: addi r12,r12,20
|
||||
0x7D8803A6u, // +10: mtlr r12
|
||||
0x4E800020u, // +14: blr
|
||||
.. SyntheticLrHookFactory.ManyOrdinaryReturnPaths() // +18
|
||||
], () => capped = true);
|
||||
|
||||
Assert.True(capped);
|
||||
Assert.Equal(new[] { 20 }, offsets);
|
||||
}
|
||||
|
||||
private static int[] Analyze(uint[] words, Action onStateCapExceeded)
|
||||
{
|
||||
var instructions = words.Select((word, index) =>
|
||||
PpcDecoder.Decode(0x80010000u + (uint)index * 4, word)).ToArray();
|
||||
return ContinuationPlanner.DiscoverLrRelativeIndirectJumpOffsets(instructions, onStateCapExceeded).ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
internal static class SyntheticLrHookFactory
|
||||
{
|
||||
public static uint[] ManyOrdinaryReturnPaths()
|
||||
{
|
||||
// 1,024 possible states exceed the 512-state cap without an unbounded loop.
|
||||
var words = new List<uint> { 0x7FE802A6u }; // mflr r31
|
||||
for (uint register = 3; register <= 12; register++)
|
||||
{
|
||||
words.Add(0x2C000000u | (register << 16)); // cmpwi rN,0
|
||||
words.Add(0x41820008u); // beq +8
|
||||
words.Add(0x38000004u | (register << 21) | (31u << 16)); // addi rN,r31,4
|
||||
}
|
||||
words.Add(0x4E800020u); // blr with unchanged LR
|
||||
return words.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
using System.Buffers.Binary;
|
||||
using Translator.Core.Disassembly;
|
||||
using Translator.Core.Loading;
|
||||
using Translator.Core.Mods;
|
||||
using Xunit;
|
||||
|
||||
namespace Translator.Tests;
|
||||
|
||||
public class LrRelativeContinuationTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(20, 40)]
|
||||
[InlineData(40, 20)]
|
||||
public void MutuallyExclusiveAdjustmentsKeepBothOffsets(int firstOffset, int secondOffset)
|
||||
{
|
||||
// Both arms start with the incoming LR and join at mtlr. Adding the
|
||||
// offsets together invents a continuation that neither arm can reach.
|
||||
var offsets = DiscoverOffsets(
|
||||
0x7FE802A6u, // +00: mflr r31
|
||||
0x2C030000u, // +04: cmpwi r3,0
|
||||
0x4182000Cu, // +08: beq +0x14
|
||||
AddiR31(firstOffset), // +0C: addi r31,r31,firstOffset
|
||||
0x48000008u, // +10: b +0x18
|
||||
AddiR31(secondOffset), // +14: addi r31,r31,secondOffset
|
||||
0x7FE803A6u, // +18: mtlr r31
|
||||
0x4E800020u);// +1C: blr
|
||||
|
||||
Assert.Equal(new[] { 20, 40 }, offsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalReturnArmDoesNotEraseSkipReturnAtSharedBlr()
|
||||
{
|
||||
// The normal arm writes the original LR; it must not overwrite the
|
||||
// other arm's LR + 20 in the analysis of the shared return.
|
||||
var offsets = DiscoverOffsets(
|
||||
0x7FE802A6u, // +00: mflr r31
|
||||
0x2C030000u, // +04: cmpwi r3,0
|
||||
0x41820010u, // +08: beq +0x18
|
||||
0x397F0014u, // +0C: addi r11,r31,20
|
||||
0x7D6803A6u, // +10: mtlr r11
|
||||
0x48000008u, // +14: b +0x1C
|
||||
0x7FE803A6u, // +18: mtlr r31
|
||||
0x4E800020u);// +1C: blr
|
||||
|
||||
Assert.Equal(new[] { 20 }, offsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConditionalNormalReturnStillDiscoversSkipOnFallthrough()
|
||||
{
|
||||
var offsets = DiscoverOffsets(
|
||||
0x7FE802A6u, // mflr r31
|
||||
0x2C030000u, // cmpwi r3,0
|
||||
0x4D820020u, // beqlr
|
||||
0x3BFF0014u, // addi r31,r31,20
|
||||
0x7FE803A6u, // mtlr r31
|
||||
0x4E800020u);// blr
|
||||
|
||||
Assert.Equal(new[] { 20 }, offsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SavedNonvolatileLrSurvivesHelperCall()
|
||||
{
|
||||
// r31 survives a normal ABI call even though the call replaces LR.
|
||||
var offsets = DiscoverOffsets(
|
||||
0x7FE802A6u, // mflr r31
|
||||
0x48000101u, // bl helper outside this function
|
||||
0x3BFF0014u, // addi r31,r31,20
|
||||
0x7FE803A6u, // mtlr r31
|
||||
0x4E800020u);// blr
|
||||
|
||||
Assert.Equal(new[] { 20 }, offsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReloadingSavedRegisterAfterMtlrDoesNotEraseSkipReturn()
|
||||
{
|
||||
// A hook epilogue restores the caller's r31 after committing its
|
||||
// adjusted return address to LR.
|
||||
var offsets = DiscoverOffsets(
|
||||
0x7FE802A6u, // mflr r31
|
||||
0x3BFF0014u, // addi r31,r31,20
|
||||
0x7FE803A6u, // mtlr r31
|
||||
0x83E10008u, // lwz r31,8(r1)
|
||||
0x4E800020u);// blr
|
||||
|
||||
Assert.Equal(new[] { 20 }, offsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownLrWriteReplacesEarlierSkipReturn()
|
||||
{
|
||||
var offsets = DiscoverOffsets(
|
||||
0x7FE802A6u, // mflr r31
|
||||
0x3BFF0014u, // addi r31,r31,20
|
||||
0x7FE803A6u, // mtlr r31
|
||||
0x80010008u, // lwz r0,8(r1)
|
||||
0x7C0803A6u, // mtlr r0
|
||||
0x4E800020u);// blr
|
||||
|
||||
Assert.Empty(offsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnadjustedRegisterReturnDoesNotAddAContinuation()
|
||||
{
|
||||
var offsets = DiscoverOffsets(
|
||||
0x7FE802A6u, // mflr r31
|
||||
0x7FE803A6u, // mtlr r31
|
||||
0x4E800020u);// blr
|
||||
|
||||
Assert.Empty(offsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadMultipleWordOverwritesSavedRegistersThroughR31()
|
||||
{
|
||||
var offsets = DiscoverOffsets(
|
||||
0x7FE802A6u, // mflr r31
|
||||
0x3BFF0014u, // addi r31,r31,20
|
||||
0xBB610008u, // lmw r30,8(r1)
|
||||
0x7FE803A6u, // mtlr r31
|
||||
0x4E800020u);// blr
|
||||
|
||||
Assert.Empty(offsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlrlCallIsNotTreatedAsReturn()
|
||||
{
|
||||
var offsets = DiscoverOffsets(
|
||||
0x7FE802A6u, // mflr r31
|
||||
0x4E800021u, // blrl
|
||||
0x3BFF0014u, // addi r31,r31,20
|
||||
0x7FE803A6u, // mtlr r31
|
||||
0x4E800020u);// blr
|
||||
|
||||
Assert.Equal(new[] { 20 }, offsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MflrAfterCallDoesNotTreatClobberedLrAsIncomingLr()
|
||||
{
|
||||
var offsets = DiscoverOffsets(
|
||||
0x48000101u, // bl helper
|
||||
0x7FE802A6u, // mflr r31
|
||||
0x3BFF0014u, // addi r31,r31,20
|
||||
0x7FE803A6u, // mtlr r31
|
||||
0x4E800020u);// blr
|
||||
|
||||
Assert.Empty(offsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestoredIncomingLrBeforeCtrSkipStillDiscoversOffset()
|
||||
{
|
||||
// The helper replaces LR, but the stack save/restore recovers the
|
||||
// incoming LR before the hook jumps to the caller's continuation.
|
||||
var offsets = DiscoverOffsets(
|
||||
0x7C0802A6u, // mflr r0
|
||||
0x90010004u, // stw r0,4(r1)
|
||||
0x9421FFF0u, // stwu r1,-16(r1)
|
||||
0x48000101u, // bl helper outside this function
|
||||
0x38210010u, // addi r1,r1,16
|
||||
0x80010004u, // lwz r0,4(r1)
|
||||
0x7C0803A6u, // mtlr r0
|
||||
0x7D6802A6u, // mflr r11
|
||||
0x396B0008u, // addi r11,r11,8
|
||||
0x7D6903A6u, // mtctr r11
|
||||
0x4E800420u);// bctr
|
||||
|
||||
Assert.Equal(new[] { 8 }, offsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BoundedLoopBeforeCtrSkipStillDiscoversOffset()
|
||||
{
|
||||
// Updating an LR-derived register in a two-iteration loop must not
|
||||
// starve analysis of the exit, whose target uses unchanged r31.
|
||||
var offsets = DiscoverOffsets(
|
||||
0x7FE802A6u, // mflr r31
|
||||
0x7FC802A6u, // mflr r30
|
||||
0x38600002u, // li r3,2
|
||||
0x7C6903A6u, // mtctr r3
|
||||
0x3BDE0004u, // addi r30,r30,4
|
||||
0x4200FFFCu, // bdnz -4
|
||||
0x397F0008u, // addi r11,r31,8
|
||||
0x7D6903A6u, // mtctr r11
|
||||
0x4E800420u);// bctr
|
||||
|
||||
Assert.Equal(new[] { 8 }, offsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UntrackedR1WriteInvalidatesStackTracking()
|
||||
{
|
||||
// If r1 is overwritten from an untracked source, previously saved stack slots
|
||||
// must not be used to recover LR state.
|
||||
var offsets = DiscoverOffsets(
|
||||
0x7FE802A6u, // mflr r31
|
||||
0x3BFF0014u, // addi r31,r31,20
|
||||
0x93E10008u, // stw r31,8(r1)
|
||||
0x80230000u, // lwz r1,0(r3)
|
||||
0x80010008u, // lwz r0,8(r1)
|
||||
0x7C0803A6u, // mtlr r0
|
||||
0x4E800020u);// blr
|
||||
|
||||
Assert.Empty(offsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LargeStraightLineHandlerStillDiscoversSkipReturn()
|
||||
{
|
||||
// The analyzer's global step budget is spent one step per (instruction,
|
||||
// state) pair, so a long enough handler exhausts it before reaching the
|
||||
// return and silently reports no continuation at all. Main's linear
|
||||
// scanner had no budget and always found the offset.
|
||||
var words = new List<uint>
|
||||
{
|
||||
0x7FE802A6u, // mflr r31
|
||||
0x3BFF0014u // addi r31,r31,20
|
||||
};
|
||||
for (var i = 0; i < 10_010; i++)
|
||||
{
|
||||
words.Add(0x60000000u); // nop
|
||||
}
|
||||
words.Add(0x7FE803A6u); // mtlr r31
|
||||
words.Add(0x4E800020u); // blr
|
||||
|
||||
Assert.Equal(new[] { 20 }, DiscoverOffsets(words.ToArray()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LargeBranchingHandlerStillDiscoversCtrSkip()
|
||||
{
|
||||
// Same budget, reached far sooner once the handler branches: this is the
|
||||
// bctr shape the pre-PR scanner discovered at any function size.
|
||||
var words = new List<uint> { 0x7FE802A6u }; // mflr r31
|
||||
for (var i = 0; i < 160; i++)
|
||||
{
|
||||
var displacement = (uint)((i + 1) * 4 & 0xFFFF);
|
||||
words.Add(0x2C030000u); // cmpwi r3,0
|
||||
words.Add(0x4182000Cu); // beq +0xC
|
||||
words.Add(0x3BDF0000u | displacement); // addi r30,r31,disp
|
||||
words.Add(0x48000008u); // b +8
|
||||
words.Add(0x3BBF0000u | displacement); // addi r29,r31,disp
|
||||
}
|
||||
words.Add(0x397F0008u); // addi r11,r31,8
|
||||
words.Add(0x7D6903A6u); // mtctr r11
|
||||
words.Add(0x4E800420u); // bctr
|
||||
|
||||
Assert.Equal(new[] { 8 }, DiscoverOffsets(words.ToArray()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FloatStoreOverSavedSlotInvalidatesStackTracking()
|
||||
{
|
||||
// stfd writes 0x10..0x17, which covers the slot the adjusted LR was
|
||||
// saved to. Only stw/stwu invalidate slots today, so the reload is
|
||||
// credited with a return address the stack no longer holds.
|
||||
var offsets = DiscoverOffsets(
|
||||
0x7FE802A6u, // mflr r31
|
||||
0x3BFF0014u, // addi r31,r31,20
|
||||
0x93E10014u, // stw r31,0x14(r1)
|
||||
0xD8410010u, // stfd f2,0x10(r1)
|
||||
0x80010014u, // lwz r0,0x14(r1)
|
||||
0x7C0803A6u, // mtlr r0
|
||||
0x4E800020u);// blr
|
||||
|
||||
Assert.Empty(offsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StoreMultipleOverSavedSlotInvalidatesStackTracking()
|
||||
{
|
||||
var offsets = DiscoverOffsets(
|
||||
0x7FE802A6u, // mflr r31
|
||||
0x3BFF0014u, // addi r31,r31,20
|
||||
0x93E10008u, // stw r31,8(r1)
|
||||
0xBFC10008u, // stmw r30,8(r1)
|
||||
0x80010008u, // lwz r0,8(r1)
|
||||
0x7C0803A6u, // mtlr r0
|
||||
0x4E800020u);// blr
|
||||
|
||||
Assert.Empty(offsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StackPointerUpdatePreservesAdjustedLrOffset()
|
||||
{
|
||||
var offsets = DiscoverOffsets(
|
||||
0x7C2802A6u, // mflr r1
|
||||
0xDC410004u, // stfdu f2,4(r1)
|
||||
0x7C2803A6u, // mtlr r1
|
||||
0x4E800020u);// blr
|
||||
|
||||
Assert.Equal(new[] { 4 }, offsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VolatileRegisterDoesNotSurviveHelperCall()
|
||||
{
|
||||
// r3 is caller-saved, so the callee is free to destroy the adjusted
|
||||
// return address this hook staged before the call.
|
||||
var offsets = DiscoverOffsets(
|
||||
0x7C6802A6u, // mflr r3
|
||||
0x38630014u, // addi r3,r3,20
|
||||
0x48000101u, // bl helper outside this function
|
||||
0x7C6803A6u, // mtlr r3
|
||||
0x4E800020u);// blr
|
||||
|
||||
Assert.Empty(offsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CtrDoesNotSurviveHelperCall()
|
||||
{
|
||||
// CTR is volatile across a call for the same reason.
|
||||
var offsets = DiscoverOffsets(
|
||||
0x7FE802A6u, // mflr r31
|
||||
0x3BFF0014u, // addi r31,r31,20
|
||||
0x7FE903A6u, // mtctr r31
|
||||
0x48000101u, // bl helper outside this function
|
||||
0x4E800420u);// bctr
|
||||
|
||||
Assert.Empty(offsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MflrR1InvalidatesOldStackSlots()
|
||||
{
|
||||
// After mflr r1, 8(r1) refers to incoming LR + 8, not the old
|
||||
// stack slot. Its contents are unknown; do not invent a +20 return.
|
||||
Assert.Empty(DiscoverOffsets(
|
||||
0x7FE802A6u, // mflr r31
|
||||
0x3BFF0014u, // addi r31,r31,20
|
||||
0x93E10008u, // stw r31,8(r1)
|
||||
0x7C2802A6u, // mflr r1
|
||||
0x80010008u, // lwz r0,8(r1)
|
||||
0x7C0803A6u, // mtlr r0
|
||||
0x4E800020u)); // blr
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddiR1UpdatesLrRelativeOffset()
|
||||
{
|
||||
// Like StackPointerUpdatePreservesAdjustedLrOffset, r1 holds incoming
|
||||
// LR here. Updating r1 must update that relation as well as stack state.
|
||||
Assert.Equal(new[] { 4 }, DiscoverOffsets(
|
||||
0x7C2802A6u, // mflr r1
|
||||
0x38210004u, // addi r1,r1,4
|
||||
0x7C2803A6u, // mtlr r1
|
||||
0x4E800020u)); // blr
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x38210004u, 4)] // addi r1,r1,4
|
||||
[InlineData(0x30210004u, 4)] // addic r1,r1,4
|
||||
[InlineData(0x94210004u, 4)] // stwu r1,4(r1)
|
||||
[InlineData(0xD4410004u, 4)] // stfsu f2,4(r1)
|
||||
[InlineData(0xDC410004u, 4)] // stfdu f2,4(r1)
|
||||
[InlineData(0x3821FFFCu, -4)] // addi r1,r1,-4
|
||||
public void StackPointerUpdatesPreserveLrRelation(uint update, int expectedOffset)
|
||||
{
|
||||
Assert.Equal(new[] { expectedOffset }, DiscoverOffsets(
|
||||
0x7C2802A6u, // mflr r1
|
||||
update,
|
||||
0x7C2803A6u, // mtlr r1
|
||||
0x4E800020u)); // blr
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x7FE1FB78u)] // mr r1,r31
|
||||
[InlineData(0x383F0000u)] // addi r1,r31,0
|
||||
public void CopyingLrIntoR1PreservesReturnButInvalidatesOldStack(uint copy)
|
||||
{
|
||||
var prefix = new uint[]
|
||||
{
|
||||
0x7FE802A6u, // mflr r31
|
||||
0x3BFF0014u, // addi r31,r31,20
|
||||
0x93E10008u, // stw r31,8(r1)
|
||||
copy,
|
||||
};
|
||||
Assert.Equal(new[] { 20 }, DiscoverOffsets(
|
||||
prefix.Concat(new uint[] { 0x7C2803A6u, 0x4E800020u }).ToArray()));
|
||||
Assert.Empty(DiscoverOffsets(prefix.Concat(new uint[]
|
||||
{
|
||||
0x80010008u, // lwz r0,8(r1): no longer the old stack slot
|
||||
0x7C0803A6u, // mtlr r0
|
||||
0x4E800020u,
|
||||
}).ToArray()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StackPointerSelfMovePreservesSavedLr()
|
||||
{
|
||||
Assert.Equal(new[] { 20 }, DiscoverOffsets(
|
||||
0x7FE802A6u, // mflr r31
|
||||
0x3BFF0014u, // addi r31,r31,20
|
||||
0x93E10008u, // stw r31,8(r1)
|
||||
0x7C210B78u, // mr r1,r1
|
||||
0x80010008u, // lwz r0,8(r1)
|
||||
0x7C0803A6u, // mtlr r0
|
||||
0x4E800020u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncompleteInstructionListDoesNotInventFallthroughAcrossGap()
|
||||
{
|
||||
// Defensive incomplete-input test, not a production disassembly trace:
|
||||
// the missing instruction could overwrite r31 or branch elsewhere.
|
||||
// Address sorting alone does not establish a fallthrough edge.
|
||||
var instructions = new[]
|
||||
{
|
||||
PpcDecoder.Decode(0x81800000u, 0x7FE802A6u), // mflr r31
|
||||
PpcDecoder.Decode(0x81800008u, 0x3BFF0014u), // addi r31,r31,20
|
||||
PpcDecoder.Decode(0x8180000Cu, 0x7FE803A6u), // mtlr r31
|
||||
PpcDecoder.Decode(0x81800010u, 0x4E800020u), // blr
|
||||
};
|
||||
|
||||
Assert.Empty(ContinuationPlanner.DiscoverLrRelativeIndirectJumpOffsets(instructions));
|
||||
}
|
||||
|
||||
private static uint AddiR31(int offset) => 0x3BFF0000u | (uint)(offset & 0xFFFF);
|
||||
|
||||
private static int[] DiscoverOffsets(params uint[] words)
|
||||
{
|
||||
const uint entry = 0x81800000u;
|
||||
var memory = new byte[words.Length * 4];
|
||||
for (var i = 0; i < words.Length; i++)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt32BigEndian(memory.AsSpan(i * 4, 4), words[i]);
|
||||
}
|
||||
|
||||
var range = AddressRange.FromStartAndSize(entry, (uint)memory.Length);
|
||||
var image = new ProgramImage(memory, range, range, default, "lr-continuation-test", entry);
|
||||
using var disassembler = new PpcDisassembler();
|
||||
// Use the production reachable-instruction traversal and ordering,
|
||||
// rather than handing the planner an artificial execution trace.
|
||||
var instructions = disassembler.DisassembleFunction(
|
||||
image, entry, maxInstructions: words.Length + 1, maxBytes: memory.Length);
|
||||
|
||||
return ContinuationPlanner.DiscoverLrRelativeIndirectJumpOffsets(instructions)
|
||||
.Distinct().OrderBy(offset => offset).ToArray();
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,23 @@ public class PpcLifterAdditionalCoverageTests
|
||||
private static PpcInstruction Instruction(uint raw, string mnemonic, params PpcOperand[] operands)
|
||||
=> PpcInstruction.Synthetic(0x80000000, raw, mnemonic, operands);
|
||||
|
||||
[Fact]
|
||||
public void LinkedConditionalBranchSetsLrBeforeEitherPath()
|
||||
{
|
||||
var branch = PpcDecoder.Decode(0x80004394, 0x41800029);
|
||||
var ir = Assert.Single(new PpcLifter().Lift(new[] { branch })).Ir;
|
||||
|
||||
Assert.Equal("bltl", branch.Mnemonic);
|
||||
var lr = Assert.IsType<IrAssign>(ir[0]);
|
||||
Assert.Equal("lr", lr.Destination);
|
||||
Assert.Equal(unchecked((int)0x80004398u), lr.Value.Constant);
|
||||
|
||||
var decision = Assert.IsType<IrBranch>(ir[1]);
|
||||
Assert.Equal("blt", decision.Condition);
|
||||
Assert.Equal("0x800043BC", decision.TrueLabel);
|
||||
Assert.Equal("0x80004398", decision.FalseLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LiftsAdditionalNonDotArithmeticAndLogicalForms()
|
||||
{
|
||||
|
||||
@@ -35,6 +35,39 @@ public class RetroWfcPayloadLoweringTests
|
||||
Assert.Equal("moduleFunction", pointer.TargetKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProductionPayloadValidatesAndTranslatesEverySupportedPatch()
|
||||
{
|
||||
var payloadRoot = Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"TestAssets",
|
||||
"RetroWfcPayload");
|
||||
WiiCompiled.Setup.Common.RetroWfcPayload.ValidateStagedRetroWfcPayloadDirectory(payloadRoot);
|
||||
var payloadPath = Path.Combine(
|
||||
payloadRoot,
|
||||
"binary",
|
||||
"payload.RMCPD00.bin");
|
||||
var payload = File.ReadAllBytes(payloadPath);
|
||||
|
||||
var result = RetroWfcPayload.Parse(
|
||||
payload,
|
||||
ProductionPayloadManifest(),
|
||||
0x81800000u,
|
||||
0x00200000u,
|
||||
"TestAssets/RetroWfcPayload/binary/payload.RMCPD00.bin");
|
||||
|
||||
Assert.Equal("RMCPD00", result.Summary.Game);
|
||||
Assert.Equal(payload.Length, result.Summary.PayloadImageSize);
|
||||
Assert.True(result.LoweringPlan.IsPlannable);
|
||||
Assert.Empty(result.LoweringPlan.Issues);
|
||||
Assert.NotEmpty(result.LoweringPlan.StaticBytePatches);
|
||||
Assert.NotEmpty(result.LoweringPlan.ExecutableHooks);
|
||||
Assert.NotEmpty(result.LoweringPlan.StaticPointers);
|
||||
Assert.All(result.LoweringPlan.ExecutableHooks, hook => Assert.NotNull(hook.TargetAddress));
|
||||
Assert.All(result.LoweringPlan.StaticPointers, pointer => Assert.NotNull(pointer.TargetAddress));
|
||||
Assert.NotEmpty(result.Summary.InitializationCallbacks);
|
||||
}
|
||||
|
||||
private static BaseManifest TestManifest() =>
|
||||
new(
|
||||
"test",
|
||||
@@ -52,6 +85,51 @@ public class RetroWfcPayloadLoweringTests
|
||||
],
|
||||
"ranges.json");
|
||||
|
||||
// The payload parser needs the base image's address classes and containing
|
||||
// function ranges to prove every patch can be lowered. A single synthetic
|
||||
// executable and writable ranges are sufficient here: the assertions above
|
||||
// test the real production payload without checking proprietary game bytes
|
||||
// into CI. The split also proves pointer patches lower as data writes.
|
||||
private static BaseManifest ProductionPayloadManifest() =>
|
||||
new(
|
||||
"test",
|
||||
1,
|
||||
"RMCP01",
|
||||
"P",
|
||||
"",
|
||||
0,
|
||||
[
|
||||
new BaseSectionMetadata(
|
||||
".synthetic-text",
|
||||
"synthetic.dol",
|
||||
0x80000000u,
|
||||
0x80800000u,
|
||||
true,
|
||||
false,
|
||||
"synthetic_text.bin",
|
||||
0),
|
||||
new BaseSectionMetadata(
|
||||
".synthetic-data",
|
||||
"synthetic.dol",
|
||||
0x80800000u,
|
||||
0x81000000u,
|
||||
false,
|
||||
true,
|
||||
"synthetic_data.bin",
|
||||
0)
|
||||
],
|
||||
[
|
||||
new BaseFunctionRangeMetadata(
|
||||
0x80000000u,
|
||||
0x80800000u,
|
||||
"synthetic_base",
|
||||
".synthetic-text",
|
||||
0,
|
||||
"test",
|
||||
["Executable"])
|
||||
],
|
||||
"ranges.json");
|
||||
|
||||
private static byte[] BuildSharedPayloadFixture()
|
||||
{
|
||||
var payload = new byte[0x240];
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using Translator.Core.Analysis.Ssa;
|
||||
using Translator.Core.Analysis.Representation;
|
||||
using Translator.Core.CodeGen;
|
||||
using Translator.Core.Ir;
|
||||
using Translator.Core.Representation;
|
||||
using Xunit;
|
||||
|
||||
namespace Translator.Tests;
|
||||
|
||||
public class SharedLrContinuationCodeGenTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(2)]
|
||||
[InlineData(20)]
|
||||
public void MultipleHooksShareOneCompleteDispatchAndKeepNormalFallthrough(int callCount)
|
||||
{
|
||||
var instructions = new List<IrInstruction>();
|
||||
for (var i = 0; i < callCount; i++)
|
||||
{
|
||||
instructions.Add(new IrAssign("lr", IrValue.Imm(unchecked((int)(0x80001004u + (uint)i * 4u)))));
|
||||
instructions.Add(new IrCall(string.Empty, "0x81800000", Array.Empty<IrValue>()));
|
||||
}
|
||||
instructions.Add(new IrAssign("r3", IrValue.Imm(8)));
|
||||
instructions.Add(new IrReturn(null));
|
||||
var function = new IrFunction("shared_lr_continuation", "0x80001000", new[]
|
||||
{
|
||||
new IrBasicBlock("0x80001000", instructions),
|
||||
new IrBasicBlock("0x80001100", new IrInstruction[]
|
||||
{
|
||||
new IrAssign("r3", IrValue.Imm(1)), new IrReturn(null)
|
||||
})
|
||||
});
|
||||
var types = new RepresentationEnvironment(new Dictionary<string, ValueRepresentation>
|
||||
{
|
||||
["lr"] = ValueRepresentation.UInt32, ["r3"] = ValueRepresentation.UInt32
|
||||
});
|
||||
var code = new CxxLinearCodeGenerator().Emit(0x80001000,
|
||||
new SsaTransformer().Convert(function),
|
||||
new FunctionAbiClassification("shared_lr_continuation", ValueRepresentation.Void), types,
|
||||
lrContinuationCallTargets: new HashSet<uint> { 0x81800000u });
|
||||
|
||||
Assert.Equal(1, code.Split("switch (ctx->lr)").Length - 1);
|
||||
Assert.Equal(callCount, code.Split("goto lr_continuation_dispatch;").Length - 1);
|
||||
Assert.Equal(callCount, code.Split("if (ctx->lr != ").Length - 1);
|
||||
Assert.Contains("case 0x80001100u:", code);
|
||||
Assert.Contains("goto loc_80001100;", code);
|
||||
Assert.Contains("InvokeIndirectCpu(ctx->lr, ctx);", code);
|
||||
var sharedLabel = code.IndexOf("lr_continuation_dispatch:", StringComparison.Ordinal);
|
||||
Assert.True(code.IndexOf("r3 = 8;", StringComparison.Ordinal) < sharedLabel);
|
||||
Assert.True(code.IndexOf("case 0x80001100u:", StringComparison.Ordinal) > sharedLabel);
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -26,6 +26,11 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Translator.Core\Translator.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\Translator.Cli\Translator.Cli.csproj" />
|
||||
<ProjectReference Include="..\..\..\Launcher\WiiCompiled.Setup.Common\WiiCompiled.Setup.Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="TestAssets\RetroWfcPayload\binary\payload.RMCPD00.bin" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
|
||||
Reference in New Issue
Block a user