mirror of
https://github.com/patchzyy/wiicompiled
synced 2026-09-12 09:45:04 -04:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53d8f71c68 | |||
| 149cfef608 | |||
| 25c69ae28e | |||
| 0bb15f0a44 | |||
| 466d06d7db | |||
| 8769cf6dea | |||
| 452b478bb3 | |||
| 407f8a7190 | |||
| c2289e4ba4 | |||
| a135beb201 | |||
| 88b990b060 | |||
| e0e362bd99 | |||
| 5654d8f21b | |||
| 730e3122d5 | |||
| f424536d3b | |||
| 2d9dc4e0f2 | |||
| 8e57cc162f | |||
| 1c0a3edee9 | |||
| d1d80613cc | |||
| 5d67b229f6 | |||
| 56db6ba641 | |||
| a67069afd3 | |||
| 009697fb97 | |||
| 3f7fed48c9 | |||
| 6eba523d70 | |||
| 8c6c177857 | |||
| be153e0fa0 | |||
| e34f055b3a | |||
| 602348f905 |
@@ -3,3 +3,4 @@
|
|||||||
|
|
||||||
# Patch files must stay LF: git apply matches context bytes against LF upstream sources
|
# Patch files must stay LF: git apply matches context bytes against LF upstream sources
|
||||||
*.patch -text
|
*.patch -text
|
||||||
|
translator/tests/Translator.Tests/TestAssets/**/*.bin binary
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
recompilation:
|
||||||
|
name: Recompilation test
|
||||||
|
uses: ./.github/workflows/recomp-test.yml
|
||||||
|
|
||||||
translator:
|
translator:
|
||||||
name: Translator (build + test)
|
name: Translator (build + test)
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
@@ -26,6 +30,14 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
dotnet-version: '8.0.x'
|
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
|
- name: Restore
|
||||||
run: dotnet restore translator/Translator.sln
|
run: dotnet restore translator/Translator.sln
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
recompilation:
|
||||||
|
name: Recompilation test
|
||||||
|
uses: ./.github/workflows/recomp-test.yml
|
||||||
|
|
||||||
linux-appimage:
|
linux-appimage:
|
||||||
name: Linux (AppImage, ${{ matrix.arch }})
|
name: Linux (AppImage, ${{ matrix.arch }})
|
||||||
strategy:
|
strategy:
|
||||||
@@ -93,7 +97,7 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
name: Publish GitHub Release
|
name: Publish GitHub Release
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
needs: [linux-appimage, windows-installer]
|
needs: [linux-appimage, windows-installer, recompilation]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
name: Synthetic recompilation
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
windows:
|
||||||
|
name: Windows runtime (synthetic DOL)
|
||||||
|
runs-on: windows-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- uses: actions/setup-dotnet@v6
|
||||||
|
with:
|
||||||
|
dotnet-version: '8.0.x'
|
||||||
|
|
||||||
|
# Cache downloads only. Preparation still validates pins, and every run
|
||||||
|
# compiles current Aurora, runtime, and generated sources from scratch.
|
||||||
|
- uses: actions/cache@v5
|
||||||
|
with:
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Prepare pinned native dependencies
|
||||||
|
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 4
|
||||||
|
|
||||||
|
# Print cache results (even if the build fails)
|
||||||
|
- name: Show sccache stats
|
||||||
|
if: always()
|
||||||
|
shell: pwsh
|
||||||
|
run: sccache --show-stats
|
||||||
@@ -26,6 +26,8 @@ Code.pul
|
|||||||
/build/
|
/build/
|
||||||
/build-*/
|
/build-*/
|
||||||
/native-build/
|
/native-build/
|
||||||
|
/native-build-macos/
|
||||||
|
/local-products/
|
||||||
/dist/
|
/dist/
|
||||||
/out/
|
/out/
|
||||||
[Bb]in/
|
[Bb]in/
|
||||||
@@ -70,3 +72,5 @@ project.lock.json
|
|||||||
*.log
|
*.log
|
||||||
output.txt
|
output.txt
|
||||||
|
|
||||||
|
# Operating System
|
||||||
|
.DS_Store
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ foreach ($required in @('ToolkitFingerprint','TranslationFingerprint','NativeToo
|
|||||||
|
|
||||||
$manifest = [ordered]@{
|
$manifest = [ordered]@{
|
||||||
SchemaVersion = 2
|
SchemaVersion = 2
|
||||||
ProductVersion = '0.2.27'
|
ProductVersion = '0.2.31'
|
||||||
ExpectedGameId = $pins.GameId
|
ExpectedGameId = $pins.GameId
|
||||||
ExpectedDolSha256 = $pins.DolSha256
|
ExpectedDolSha256 = $pins.DolSha256
|
||||||
ExpectedRelSha256 = $pins.RelSha256
|
ExpectedRelSha256 = $pins.RelSha256
|
||||||
|
|||||||
@@ -117,12 +117,13 @@ function Get-MkwProjectPins([string]$ProjectFile) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Invoke-Checked([string]$FilePath, [string[]]$Arguments, [string]$Description,
|
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
|
Runs a build tool and turns a non-zero exit code into a described failure. By default,
|
||||||
is deliberate: it waits for the whole process tree, since a .NET single-file bundle host may
|
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. Start-Process
|
hand off to an extracted child that PowerShell's call operator would not wait for. Callers that
|
||||||
doesn't publish $LASTEXITCODE, so this sets it manually for callers that check it.
|
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
|
-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.
|
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: $_" }
|
if ($_.Contains('"')) { throw "A native build argument contains an unsupported quote: $_" }
|
||||||
'"' + $_ + '"'
|
'"' + $_ + '"'
|
||||||
})
|
})
|
||||||
|
if ($WaitForProcessTree) {
|
||||||
$process = Start-Process -FilePath $FilePath -ArgumentList $quotedArguments `
|
$process = Start-Process -FilePath $FilePath -ArgumentList $quotedArguments `
|
||||||
-NoNewWindow -Wait -PassThru
|
-NoNewWindow -Wait -PassThru
|
||||||
$exitCode = $process.ExitCode
|
$exitCode = $process.ExitCode
|
||||||
|
} else {
|
||||||
|
& $FilePath @Arguments
|
||||||
|
$exitCode = $LASTEXITCODE
|
||||||
|
}
|
||||||
$global:LASTEXITCODE = $exitCode
|
$global:LASTEXITCODE = $exitCode
|
||||||
if ($exitCode -ne 0) { throw "$Description failed with exit code $exitCode." }
|
if ($exitCode -ne 0) { throw "$Description failed with exit code $exitCode." }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ assert_file "$ninja_bin" "Portable Ninja"
|
|||||||
assert_file "$cc" "Portable C compiler"
|
assert_file "$cc" "Portable C compiler"
|
||||||
assert_file "$cxx" "Portable C++ compiler"
|
assert_file "$cxx" "Portable C++ compiler"
|
||||||
assert_dir "$aurora_source" "aurora-main source tree"
|
assert_dir "$aurora_source" "aurora-main source tree"
|
||||||
clang_binary=$(normalize "$toolchain_dir/bin/clang-23")
|
clang_binary=$(normalize "$toolchain_dir/bin/clang-22")
|
||||||
assert_file "$clang_binary" "Portable clang driver binary"
|
assert_file "$clang_binary" "Portable clang driver binary"
|
||||||
|
|
||||||
(( parallel > 0 )) || parallel=$(nproc)
|
(( parallel > 0 )) || parallel=$(nproc)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Fails the release build when a fact duplicated across the repo stops agreeing with the copy
|
# 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
|
# 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.
|
# both sides of the C#/PowerShell boundary), so those are checked here instead.
|
||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
param([string]$RepositoryRoot)
|
param([string]$RepositoryRoot)
|
||||||
@@ -58,6 +58,12 @@ $hostUri = Get-CapturedValue $retroWfcPayload 'CurrentRetroWfcPayloadUri\s*=\s*"
|
|||||||
if ($hostUri -cne $pins.RetroWfcPayloadUri) {
|
if ($hostUri -cne $pins.RetroWfcPayloadUri) {
|
||||||
Add-Failure "InputValidation.CurrentRetroWfcPayloadUri is '$hostUri' but recomp.yml pins '$($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
|
# --- 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.
|
# --- manifest that predates the field, and that fallback decides which disc is accepted.
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Build the real Windows runtime with translated, entirely synthetic PowerPC code.
|
||||||
|
# No game dump, game symbol map, REL, mod download, or existing generated/ output is used.
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$PortableToolsDirectory = 'Launcher/artifacts/portable-tools',
|
||||||
|
[string]$DependencySourceDirectory = 'Launcher/artifacts/dependencies',
|
||||||
|
[string]$StageDirectory = 'build/recomp-test',
|
||||||
|
[ValidateRange(1, 64)] [int]$Parallel = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
Set-StrictMode -Version 3.0
|
||||||
|
. (Join-Path $PSScriptRoot 'NativeBuildFlags.ps1')
|
||||||
|
|
||||||
|
$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
|
||||||
|
function Full([string]$Path) {
|
||||||
|
if ([IO.Path]::IsPathRooted($Path)) { return [IO.Path]::GetFullPath($Path) }
|
||||||
|
return [IO.Path]::GetFullPath((Join-Path $repoRoot $Path))
|
||||||
|
}
|
||||||
|
$portableTools = Full $PortableToolsDirectory
|
||||||
|
$dependencies = Full $DependencySourceDirectory
|
||||||
|
$stage = Full $StageDirectory
|
||||||
|
$dotnet = (Get-Command dotnet -CommandType Application).Source
|
||||||
|
$cmake = Join-Path $portableTools 'CMake/bin/cmake.exe'
|
||||||
|
$ninja = Join-Path $portableTools 'Ninja/ninja.exe'
|
||||||
|
$compilerBin = Join-Path $portableTools 'llvm-mingw/bin'
|
||||||
|
Assert-File $cmake 'Pinned CMake (run Prepare-PortableTools.ps1 first)'
|
||||||
|
Assert-File $ninja 'Pinned Ninja'
|
||||||
|
Assert-File (Join-Path $dependencies 'cppwinrt/winrt/base.h') 'Pinned dependencies (run Prepare-Dependencies.ps1 first)'
|
||||||
|
|
||||||
|
# Refuse reuse so a developer's game translation or an earlier build cannot make
|
||||||
|
# the test pass. Keep the staging tree after the run for diagnostics.
|
||||||
|
if (Test-Path -LiteralPath $stage) { throw "Test stage already exists; choose a fresh -StageDirectory: $stage" }
|
||||||
|
[IO.Directory]::CreateDirectory($stage) | Out-Null
|
||||||
|
Write-Host "Synthetic recompilation workspace: $stage"
|
||||||
|
|
||||||
|
# Copy current sources, including uncommitted edits, but no ignored build output.
|
||||||
|
# An isolated workspace preserves the developer's real generated/ directory.
|
||||||
|
$sourceFiles = & git -C $repoRoot -c core.quotepath=false ls-files --cached --others --exclude-standard -- runtime aurora-main
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Could not enumerate runtime and Aurora sources.' }
|
||||||
|
foreach ($relative in $sourceFiles | Sort-Object -Unique) {
|
||||||
|
$destination = Join-Path $stage $relative
|
||||||
|
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) | Out-Null
|
||||||
|
Copy-Item -LiteralPath (Join-Path $repoRoot $relative) -Destination $destination
|
||||||
|
}
|
||||||
|
|
||||||
|
# One synthetic text section: li r3,40; addi r3,r3,2; nop; blr.
|
||||||
|
# Native HLE wrappers also call these eight guest symbols directly. Give each
|
||||||
|
# its own generated blr function so the real product can link without game code.
|
||||||
|
# Keep this list explicit: a new unresolved guest dependency must fail the test.
|
||||||
|
[uint32]$entry = 0x80001000L
|
||||||
|
[uint32[]]$guestCallbacks = @(
|
||||||
|
0x8012B830L, 0x801A0620L, 0x801A1ED8L, 0x801A961CL,
|
||||||
|
0x801AADE0L, 0x801D8D30L, 0x801D9E94L, 0x8055531CL
|
||||||
|
)
|
||||||
|
$textSize = [int]($guestCallbacks[-1] - $entry + 4)
|
||||||
|
$dataOffset = 0x100 + $textSize
|
||||||
|
$dol = [byte[]]::new($dataOffset + 4)
|
||||||
|
function Write-BigEndian32([int]$Offset, [uint32]$Value) {
|
||||||
|
$dol[$Offset] = [byte](($Value -shr 24) -band 255)
|
||||||
|
$dol[$Offset + 1] = [byte](($Value -shr 16) -band 255)
|
||||||
|
$dol[$Offset + 2] = [byte](($Value -shr 8) -band 255)
|
||||||
|
$dol[$Offset + 3] = [byte]($Value -band 255)
|
||||||
|
}
|
||||||
|
Write-BigEndian32 0x00 0x100 # text[0] file offset
|
||||||
|
Write-BigEndian32 0x48 $entry # text[0] guest address
|
||||||
|
Write-BigEndian32 0x90 $textSize # text[0] length (unreachable gaps are zero)
|
||||||
|
Write-BigEndian32 0x1C $dataOffset # data[0] file offset
|
||||||
|
Write-BigEndian32 0x64 0x80600000L # data[0] guest address
|
||||||
|
Write-BigEndian32 0xAC 4 # data[0] length
|
||||||
|
Write-BigEndian32 0xD8 0x80601000L # BSS address
|
||||||
|
Write-BigEndian32 0xDC 32 # BSS length
|
||||||
|
Write-BigEndian32 0xE0 $entry # entry point
|
||||||
|
Write-BigEndian32 0x100 0x38600028 # li r3,40
|
||||||
|
Write-BigEndian32 0x104 0x38630002 # addi r3,r3,2
|
||||||
|
Write-BigEndian32 0x108 0x60000000 # nop
|
||||||
|
Write-BigEndian32 0x10C 0x4E800020 # blr
|
||||||
|
foreach ($address in $guestCallbacks) {
|
||||||
|
Write-BigEndian32 ([int](0x100 + $address - $entry)) 0x4E800020
|
||||||
|
}
|
||||||
|
Write-BigEndian32 $dataOffset 0x12345678
|
||||||
|
[IO.File]::WriteAllBytes((Join-Path $stage 'synthetic.dol'), $dol)
|
||||||
|
$entryPoints = (@($entry) + $guestCallbacks | ForEach-Object { '0x{0:X8}' -f $_ }) -join ', '
|
||||||
|
$functionMap = (@($entry) + $guestCallbacks | ForEach-Object { '{0:X8} func_{0:X8}' -f $_ }) -join "`n"
|
||||||
|
[IO.File]::WriteAllText((Join-Path $stage 'synthetic-functions.txt'), $functionMap)
|
||||||
|
$manifest = Join-Path $stage 'recomp.yml'
|
||||||
|
[IO.File]::WriteAllText($manifest, @"
|
||||||
|
schema_version: 1
|
||||||
|
workspace_root: .
|
||||||
|
project:
|
||||||
|
id: ci-synthetic-dol
|
||||||
|
display_name: CI Synthetic DOL
|
||||||
|
memory:
|
||||||
|
base: 0x80000000
|
||||||
|
size: 0x01800000
|
||||||
|
sda_base: 0x80600000
|
||||||
|
sda2_base: 0x80600000
|
||||||
|
inputs:
|
||||||
|
dol:
|
||||||
|
path: synthetic.dol
|
||||||
|
translation:
|
||||||
|
entry_points: [$entryPoints]
|
||||||
|
function_map:
|
||||||
|
path: synthetic-functions.txt
|
||||||
|
allow_unsupported_instructions: false
|
||||||
|
runtime:
|
||||||
|
native_abi_directories: []
|
||||||
|
native_registration_root: runtime/src
|
||||||
|
output:
|
||||||
|
root: generated
|
||||||
|
"@)
|
||||||
|
|
||||||
|
$translatorProject = Join-Path $repoRoot 'translator/src/Translator.Cli/Translator.Cli.csproj'
|
||||||
|
Invoke-Checked $dotnet @('build', $translatorProject, '-c', 'Release', '--disable-build-servers') 'Building the translator'
|
||||||
|
$translator = Join-Path $repoRoot 'translator/src/Translator.Cli/bin/Release/net8.0/Translator.Cli.dll'
|
||||||
|
$metadata = Join-Path $stage 'generated/base_translation_output.json'
|
||||||
|
Invoke-Checked $dotnet @($translator, 'translate-recursive', '0x80001000', '--project', $manifest,
|
||||||
|
'--output-metadata', $metadata, '--threads', "$Parallel") `
|
||||||
|
'Translating the synthetic DOL'
|
||||||
|
# Function-map seeds can be skipped by discovery; do not accept a partial fixture.
|
||||||
|
$translated = Get-Content -LiteralPath $metadata -Raw | ConvertFrom-Json
|
||||||
|
foreach ($address in @($entry) + $guestCallbacks) {
|
||||||
|
if ($address -notin $translated.functions.entryPoint) {
|
||||||
|
throw ('Synthetic function 0x{0:X8} was not translated.' -f $address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Invoke-Checked $dotnet @($translator, 'generate-data-init', '--project', $manifest) 'Generating synthetic data and runtime configuration'
|
||||||
|
Invoke-Checked $dotnet @($translator, 'emit-build-shards', '--project', $manifest) 'Emitting the production build graph'
|
||||||
|
|
||||||
|
$nativeBuild = Join-Path $stage 'native-build'
|
||||||
|
$oldPath = $env:PATH
|
||||||
|
try {
|
||||||
|
$env:PATH = Get-MkwToolchainPath $portableTools
|
||||||
|
$configure = Get-MkwNativeConfigureArguments -SourceDirectory (Join-Path $stage 'runtime') -BuildDirectory $nativeBuild `
|
||||||
|
-Ninja $ninja -CCompiler (Join-Path $compilerBin 'x86_64-w64-mingw32-clang.exe') `
|
||||||
|
-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' `
|
||||||
|
-WaitForProcessTree $false
|
||||||
|
Invoke-Checked $cmake @('--build', $nativeBuild, '--target', 'WiiCompiled', '--parallel', "$Parallel") `
|
||||||
|
'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
|
||||||
|
}
|
||||||
|
Write-Host 'Synthetic recompilation passed (translation, data generation, runtime compilation, and product link).'
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<RootNamespace>WiiCompiled.Setup.Common.Cli</RootNamespace>
|
<RootNamespace>WiiCompiled.Setup.Common.Cli</RootNamespace>
|
||||||
<AssemblyName>WiiCompiled.Setup.Common.Cli</AssemblyName>
|
<AssemblyName>WiiCompiled.Setup.Common.Cli</AssemblyName>
|
||||||
<Version>0.2.22</Version>
|
<Version>0.2.31</Version>
|
||||||
<Authors>patchzy</Authors>
|
<Authors>patchzy</Authors>
|
||||||
<Product>WiiCompiled</Product>
|
<Product>WiiCompiled</Product>
|
||||||
<Description>Packaging-time helper: resolves (downloading if needed) the nodtool binary bundled by build-appimage.sh and Build-Installer.ps1</Description>
|
<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 RetroWfcDownloadTimeout = TimeSpan.FromSeconds(30);
|
||||||
private static readonly TimeSpan RetroWfcRetryDelay = TimeSpan.FromSeconds(1);
|
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 =
|
private static readonly string RetroWfcOfflinePayloadFile =
|
||||||
Path.Combine("binary", "payload.RMCPD00.bin");
|
Path.Combine("binary", "payload.RMCPD00.bin");
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<RootNamespace>WiiCompiled.Setup.Common</RootNamespace>
|
<RootNamespace>WiiCompiled.Setup.Common</RootNamespace>
|
||||||
<AssemblyName>WiiCompiled.Setup.Common</AssemblyName>
|
<AssemblyName>WiiCompiled.Setup.Common</AssemblyName>
|
||||||
<Version>0.2.22</Version>
|
<Version>0.2.31</Version>
|
||||||
<Authors>patchzy</Authors>
|
<Authors>patchzy</Authors>
|
||||||
<Product>WiiCompiled</Product>
|
<Product>WiiCompiled</Product>
|
||||||
<Description>Shared nodtool/Retro-WFC-payload logic used by both the Windows and Linux installers</Description>
|
<Description>Shared nodtool/Retro-WFC-payload logic used by both the Windows and Linux installers</Description>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ namespace WiiCompiled.Setup.Linux;
|
|||||||
internal static class ProductInfo
|
internal static class ProductInfo
|
||||||
{
|
{
|
||||||
public const string Name = "WiiCompiled";
|
public const string Name = "WiiCompiled";
|
||||||
public const string Version = "0.2.22";
|
public const string Version = "0.2.31";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>One installed product's record inside install-state.json.</summary>
|
/// <summary>One installed product's record inside install-state.json.</summary>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<AssemblyName>WiiCompiled.Setup.Linux</AssemblyName>
|
<AssemblyName>WiiCompiled.Setup.Linux</AssemblyName>
|
||||||
<RootNamespace>WiiCompiled.Setup.Linux</RootNamespace>
|
<RootNamespace>WiiCompiled.Setup.Linux</RootNamespace>
|
||||||
<Version>0.2.22</Version>
|
<Version>0.2.31</Version>
|
||||||
<Authors>patchzy</Authors>
|
<Authors>patchzy</Authors>
|
||||||
<Product>WiiCompiled</Product>
|
<Product>WiiCompiled</Product>
|
||||||
<Description>Command-line installer and launcher for WiiCompiled on Linux</Description>
|
<Description>Command-line installer and launcher for WiiCompiled on Linux</Description>
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ internal sealed class InstallerEngine
|
|||||||
var existing = new Installation(installDirectory);
|
var existing = new Installation(installDirectory);
|
||||||
|
|
||||||
PortableInstallHealing.HealMovedInstall(existing, _reporter);
|
PortableInstallHealing.HealMovedInstall(existing, _reporter);
|
||||||
|
// Capture this before publishing anything. An existing installation may be repaired or
|
||||||
|
// updated by this invocation, but those operations must not recreate the user's shortcuts.
|
||||||
|
var firstInstall = !existing.IsPresent;
|
||||||
var previousState = existing.ReadInstallState();
|
var previousState = existing.ReadInstallState();
|
||||||
using var scratch = Directory.Exists(installDirectory)
|
using var scratch = Directory.Exists(installDirectory)
|
||||||
? InstallScratchSpace.CreateInsideInstall(installDirectory, _reporter)
|
? InstallScratchSpace.CreateInsideInstall(installDirectory, _reporter)
|
||||||
@@ -139,7 +142,7 @@ internal sealed class InstallerEngine
|
|||||||
updatedState.RetroRewindInstalled, candidateRuntimeAssetsFingerprint,
|
updatedState.RetroRewindInstalled, candidateRuntimeAssetsFingerprint,
|
||||||
remainingCancellation);
|
remainingCancellation);
|
||||||
Publish(staging, installDirectory, canonicalRetroRoot, updatedState,
|
Publish(staging, installDirectory, canonicalRetroRoot, updatedState,
|
||||||
releaseEntries, remainingCancellation);
|
releaseEntries, remainingCancellation, createShortcuts: firstInstall);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +164,8 @@ internal sealed class InstallerEngine
|
|||||||
|
|
||||||
await PublishToolkitAndReconcileProductsAsync(existing, staging, workspace, manifest,
|
await PublishToolkitAndReconcileProductsAsync(existing, staging, workspace, manifest,
|
||||||
previousState, options, canonicalRetroRoot, retroCompileInputs,
|
previousState, options, canonicalRetroRoot, retroCompileInputs,
|
||||||
publishGameAssets: reusableGameAssets is null, cancellationToken);
|
publishGameAssets: reusableGameAssets is null, createShortcuts: firstInstall,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -186,7 +190,7 @@ internal sealed class InstallerEngine
|
|||||||
string stagedWorkspace, PayloadManifest manifest, InstallState? previousState,
|
string stagedWorkspace, PayloadManifest manifest, InstallState? previousState,
|
||||||
InstallOptions options, string? canonicalRetroRoot,
|
InstallOptions options, string? canonicalRetroRoot,
|
||||||
RetroRewindCompileInputs? retroCompileInputs,
|
RetroRewindCompileInputs? retroCompileInputs,
|
||||||
bool publishGameAssets, CancellationToken cancellationToken)
|
bool publishGameAssets, bool createShortcuts, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var installDirectory = existing.Root;
|
var installDirectory = existing.Root;
|
||||||
|
|
||||||
@@ -214,7 +218,8 @@ internal sealed class InstallerEngine
|
|||||||
if (publishGameAssets) AddComponent(entries, staging, installDirectory, "GameAssets");
|
if (publishGameAssets) AddComponent(entries, staging, installDirectory, "GameAssets");
|
||||||
|
|
||||||
Publish(staging, installDirectory, canonicalRetroRoot, state,
|
Publish(staging, installDirectory, canonicalRetroRoot, state,
|
||||||
entries, cancellationToken, progressPercent: 8, completionPercent: 10);
|
entries, cancellationToken, progressPercent: 8, completionPercent: 10,
|
||||||
|
createShortcuts: createShortcuts);
|
||||||
|
|
||||||
_reporter.Progress(InstallStages.BuildBase,
|
_reporter.Progress(InstallStages.BuildBase,
|
||||||
"Producing the installed products with the published toolkit...", 11);
|
"Producing the installed products with the published toolkit...", 11);
|
||||||
@@ -277,7 +282,7 @@ internal sealed class InstallerEngine
|
|||||||
private void Publish(string staging, string installDirectory,
|
private void Publish(string staging, string installDirectory,
|
||||||
string? canonicalRetroRoot, InstallState state,
|
string? canonicalRetroRoot, InstallState state,
|
||||||
List<InstallTransactionEntry> entries, CancellationToken cancellationToken,
|
List<InstallTransactionEntry> entries, CancellationToken cancellationToken,
|
||||||
int progressPercent = 95, int completionPercent = 99)
|
int progressPercent = 95, int completionPercent = 99, bool createShortcuts = false)
|
||||||
{
|
{
|
||||||
entries.Add(InstallTransactionEntry.Directory(Path.Combine(staging, "licenses"),
|
entries.Add(InstallTransactionEntry.Directory(Path.Combine(staging, "licenses"),
|
||||||
Path.Combine(installDirectory, "licenses")));
|
Path.Combine(installDirectory, "licenses")));
|
||||||
@@ -322,6 +327,7 @@ internal sealed class InstallerEngine
|
|||||||
{
|
{
|
||||||
ShellIntegration.RegisterUninstaller(installDirectory, state.RetroRewindInstalled);
|
ShellIntegration.RegisterUninstaller(installDirectory, state.RetroRewindInstalled);
|
||||||
}
|
}
|
||||||
|
if (createShortcuts)
|
||||||
ShellIntegration.CreateShortcuts(installDirectory);
|
ShellIntegration.CreateShortcuts(installDirectory);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ internal static class PlatformChecks
|
|||||||
internal static class ProductInfo
|
internal static class ProductInfo
|
||||||
{
|
{
|
||||||
public const string Name = "WiiCompiled";
|
public const string Name = "WiiCompiled";
|
||||||
public const string Version = "0.2.27";
|
public const string Version = "0.2.31";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The setup executable is copied into the installation under this name. It is the launcher and
|
/// The setup executable is copied into the installation under this name. It is the launcher and
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<AssemblyName>WiiCompiled.Setup</AssemblyName>
|
<AssemblyName>WiiCompiled.Setup</AssemblyName>
|
||||||
<RootNamespace>WiiCompiled.Setup.Windows</RootNamespace>
|
<RootNamespace>WiiCompiled.Setup.Windows</RootNamespace>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
<Version>0.2.27</Version>
|
<Version>0.2.31</Version>
|
||||||
<Authors>patchzy</Authors>
|
<Authors>patchzy</Authors>
|
||||||
<Product>WiiCompiled</Product>
|
<Product>WiiCompiled</Product>
|
||||||
<Description>Command-line installer and launcher for WiiCompiled</Description>
|
<Description>Command-line installer and launcher for WiiCompiled</Description>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ if [[ -n "$retro_dir" ]]; then
|
|||||||
trap 'rm -rf "$payload_stage"' EXIT
|
trap 'rm -rf "$payload_stage"' EXIT
|
||||||
/usr/bin/curl --fail --silent --show-error --connect-timeout 10 --max-time 30 \
|
/usr/bin/curl --fail --silent --show-error --connect-timeout 10 --max-time 30 \
|
||||||
--retry 1 --output "$temporary_payload" \
|
--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" || \
|
"$translator" validate-retro-wfc-payload --directory "$payload_stage" || \
|
||||||
fail 'downloaded Retro-WFC payload failed signature validation'
|
fail 'downloaded Retro-WFC payload failed signature validation'
|
||||||
mkdir -p "$retro_wfc_dir/binary"
|
mkdir -p "$retro_wfc_dir/binary"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ set -euo pipefail
|
|||||||
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
workspace=$(cd "$script_dir/.." && pwd)
|
workspace=$(cd "$script_dir/.." && pwd)
|
||||||
|
|
||||||
llvm_version=23.1.0
|
llvm_version=22.1.8
|
||||||
cmake_version=4.3.3
|
cmake_version=4.3.3
|
||||||
ninja_version=1.13.2
|
ninja_version=1.13.2
|
||||||
destination="$script_dir/artifacts/portable-tools"
|
destination="$script_dir/artifacts/portable-tools"
|
||||||
@@ -52,13 +52,13 @@ done
|
|||||||
|
|
||||||
case "$arch" in
|
case "$arch" in
|
||||||
x86_64) llvm_release_arch=X64; target_triple=x86_64-unknown-linux-gnu
|
x86_64) llvm_release_arch=X64; target_triple=x86_64-unknown-linux-gnu
|
||||||
llvm_release_sha256=18da30f77f475688a18f7704d23f9f155ae007ed9922dbed6850a9419d9fec8c
|
llvm_release_sha256=df0e1ecf16caf3489a272a5eea4eec9b0d82878f6477fa309504f918a0006384
|
||||||
cmake_release_arch=x86_64
|
cmake_release_arch=x86_64
|
||||||
cmake_sha256=927b2368a946c37269c3a66225ab00544e756459cdd0b5d0da438694fb9ff802
|
cmake_sha256=927b2368a946c37269c3a66225ab00544e756459cdd0b5d0da438694fb9ff802
|
||||||
ninja_asset=ninja-linux.zip
|
ninja_asset=ninja-linux.zip
|
||||||
ninja_sha256=5749cbc4e668273514150a80e387a957f933c6ed3f5f11e03fb30955e2bbead6 ;;
|
ninja_sha256=5749cbc4e668273514150a80e387a957f933c6ed3f5f11e03fb30955e2bbead6 ;;
|
||||||
aarch64) llvm_release_arch=ARM64; target_triple=aarch64-unknown-linux-gnu
|
aarch64) llvm_release_arch=ARM64; target_triple=aarch64-unknown-linux-gnu
|
||||||
llvm_release_sha256=cfb31bfc713ef453248bf5bd026312f838ad6c52c25623e987cb6a340f3050d4
|
llvm_release_sha256=805efad2bb91cb4967fa569e0881d10c0f69c04461cf671cccbae19f547acc34
|
||||||
cmake_release_arch=aarch64
|
cmake_release_arch=aarch64
|
||||||
cmake_sha256=9ea38356dbd3e32e51029a3e09a0f2f8e117ef4fbcaad7a21ffb36409bbd5cb4
|
cmake_sha256=9ea38356dbd3e32e51029a3e09a0f2f8e117ef4fbcaad7a21ffb36409bbd5cb4
|
||||||
ninja_asset=ninja-linux-aarch64.zip
|
ninja_asset=ninja-linux-aarch64.zip
|
||||||
@@ -120,9 +120,9 @@ echo "prepare-portable-tools.sh: pruning to the minimal compile+link toolchain..
|
|||||||
|
|
||||||
# clang: the real driver executable plus the clang/clang++ symlinks CMake/local-build.sh invoke.
|
# clang: the real driver executable plus the clang/clang++ symlinks CMake/local-build.sh invoke.
|
||||||
# Stripped: debug symbols are dead weight for a bundled compiler nobody will debug.
|
# Stripped: debug symbols are dead weight for a bundled compiler nobody will debug.
|
||||||
cp -a "$src/bin/clang-23" "$work/bin/"
|
cp -a "$src/bin/clang-22" "$work/bin/"
|
||||||
strip "$work/bin/clang-23"
|
strip "$work/bin/clang-22"
|
||||||
ln -s clang-23 "$work/bin/clang"
|
ln -s clang-22 "$work/bin/clang"
|
||||||
ln -s clang "$work/bin/clang++"
|
ln -s clang "$work/bin/clang++"
|
||||||
|
|
||||||
# lld: linked via -fuse-ld=lld, which clang resolves by looking for ld.lld next to itself first -
|
# lld: linked via -fuse-ld=lld, which clang resolves by looking for ld.lld next to itself first -
|
||||||
@@ -201,10 +201,10 @@ Ninja $ninja_version
|
|||||||
Apache License 2.0
|
Apache License 2.0
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
echo "prepare-portable-tools.sh: smoke-testing the toolchain..."
|
echo "prepare-portable-tools.sh: testing the toolchain..."
|
||||||
smoke_dir=$(mktemp -d)
|
test_dir=$(mktemp -d)
|
||||||
trap 'rm -rf "$smoke_dir"' EXIT
|
trap 'rm -rf "$test_dir"' EXIT
|
||||||
cat > "$smoke_dir/t.cpp" <<'EOF'
|
cat > "$test_dir/t.cpp" <<'EOF'
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
int main() {
|
int main() {
|
||||||
@@ -214,24 +214,25 @@ int main() {
|
|||||||
return sum == 6 ? 0 : 1;
|
return sum == 6 ? 0 : 1;
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
"$work/bin/clang++" -std=c++20 -fuse-ld=lld "$smoke_dir/t.cpp" -o "$smoke_dir/t"
|
"$work/bin/clang++" -std=c++20 -fuse-ld=lld "$test_dir/t.cpp" -o "$test_dir/t"
|
||||||
"$smoke_dir/t"
|
"$test_dir/t"
|
||||||
|
|
||||||
# Also exercised together through CMake+Ninja, exactly how local-build.sh drives them - a plain
|
# Also exercised together through CMake+Ninja, exactly how local-build.sh drives them - a plain
|
||||||
# clang++ invocation above would not catch a broken CMAKE_ROOT (Modules/Templates) or a Ninja that
|
# clang++ invocation above would not catch a broken CMAKE_ROOT (Modules/Templates) or a Ninja that
|
||||||
# can't find the compiler.
|
# can't find the compiler.
|
||||||
cat > "$smoke_dir/CMakeLists.txt" <<'EOF'
|
cat > "$test_dir/CMakeLists.txt" <<'EOF'
|
||||||
cmake_minimum_required(VERSION 3.16)
|
cmake_minimum_required(VERSION 3.16)
|
||||||
project(smoke CXX)
|
project(test CXX)
|
||||||
add_executable(smoke t.cpp)
|
add_executable(test t.cpp)
|
||||||
EOF
|
EOF
|
||||||
"$work/bin/cmake" -S "$smoke_dir" -B "$smoke_dir/build" -G Ninja \
|
"$work/bin/cmake" -S "$test_dir" -B "$test_dir/build" -G Ninja \
|
||||||
-DCMAKE_MAKE_PROGRAM="$work/bin/ninja" -DCMAKE_CXX_COMPILER="$work/bin/clang++" >/dev/null
|
-DCMAKE_MAKE_PROGRAM="$work/bin/ninja" -DCMAKE_CXX_COMPILER="$work/bin/clang++" >/dev/null
|
||||||
"$work/bin/cmake" --build "$smoke_dir/build" >/dev/null
|
"$work/bin/cmake" --build "$test_dir/build" >/dev/null
|
||||||
"$smoke_dir/build/smoke"
|
"$test_dir/build/test"
|
||||||
|
|
||||||
rm -rf "$smoke_dir"
|
rm -rf "$test_dir"
|
||||||
trap - EXIT
|
trap - EXIT
|
||||||
|
|
||||||
|
rm -rf "$toolchain_dir"
|
||||||
mv "$work" "$toolchain_dir"
|
mv "$work" "$toolchain_dir"
|
||||||
echo "prepare-portable-tools.sh: toolchain ready at $toolchain_dir ($(du -sh "$toolchain_dir" | cut -f1))"
|
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
|
# 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.
|
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
|
There's no emulator in the loop, no interpreter, no JIT, no PowerPC
|
||||||
@@ -46,35 +59,46 @@ Press **F10** while the game window has focus:
|
|||||||
- Internal resolution
|
- Internal resolution
|
||||||
- FPS counter
|
- FPS counter
|
||||||
- Controller assignment for all four ports
|
- Controller assignment for all four ports
|
||||||
- Full per-controller button mapping
|
- Full per-controller button mapping, including the bumpers
|
||||||
|
- Dolphin-syntax input expressions and GCPadNew.ini import
|
||||||
|
- Controller vibration on/off
|
||||||
- Volume, instant mute, and the music ducking toggle
|
- Volume, instant mute, and the music ducking toggle
|
||||||
|
|
||||||
Everything you change is saved to `Config.toml` on the spot and restored next launch.
|
Everything you change is saved to `Config.toml` on the spot and restored next launch.
|
||||||
|
|
||||||
**Real controller support.**
|
**Real controller support.**
|
||||||
|
|
||||||
Controllers are fed to the game as a GameCube controller.
|
Controllers are fed to the game as a GameCube controller.
|
||||||
Mappings are positional (`south`, `east`, `west`, `north`) rather than Xbox-labelled, so the
|
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
|
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.
|
inputs like paddles, touchpads and share buttons show up when the hardware reports them.
|
||||||
|
Both button-binding slots also accept SDL triggers and stick directions. Selecting an analog
|
||||||
|
input shows a threshold slider beneath it (1–100%, default 50%); reaching that amount of travel
|
||||||
|
holds the chosen digital button. Each binding's threshold is saved independently in `Config.toml`
|
||||||
|
(for example, `a = "right_trigger@35,south"`).
|
||||||
|
|
||||||
|
**Keyboard and Mouse support.**
|
||||||
|
|
||||||
|
Keyboard and mouse are also available through **F10 > Controller settings > Keyboard and mouse**
|
||||||
|
for each port. Enabling this replaces that port's gamepad input. The default preset uses WASD
|
||||||
|
for the main stick, left mouse for A (accelerate), Space for B (brake), right mouse for R
|
||||||
|
(drift), middle mouse for Z (item), arrow keys for the D-pad (tricks), and Enter for Start.
|
||||||
|
Keys and mouse buttons can be remapped, including both sticks and triggers; mouse movement
|
||||||
|
is not used. These settings are saved in `keyboard_bindings.dat` and restored next launch.
|
||||||
|
|
||||||
|
**Dolphin-compatible input expressions.**
|
||||||
|
Each GameCube control can carry an expression in Dolphin's input syntax, with the same operators
|
||||||
|
and the same functions.
|
||||||
|
A Dolphin `GCPadNew.ini` can be imported directly from the F10 bar.
|
||||||
|
|
||||||
|
**Vibration toggle.**
|
||||||
|
Force feedback can be turned off for every port at once.
|
||||||
The official Wii U / Switch GameCube adapter (WUP-028) works too; as with Dolphin, on Windows the
|
The official Wii U / Switch GameCube adapter (WUP-028) works too; as with Dolphin, on Windows the
|
||||||
adapter must be switched to the WinUSB driver once (Zadig).
|
adapter must be switched to the WinUSB driver once (Zadig).
|
||||||
|
|
||||||
**Real Wii Remotes over Bluetooth.**
|
**Real Wii Remotes over Bluetooth.**
|
||||||
Pair a Wii Remote with Windows (Settings > Bluetooth > Add device, press 1+2 or SYNC, leave the
|
Pair a Wii Remote with Windows (Settings > Bluetooth > Add device, press 1+2 or SYNC, leave the
|
||||||
PIN empty) and the game reads it as an actual Wii Remote through KPAD: Wii Remote icons and
|
PIN empty)
|
||||||
prompts, Wii Wheel tilt steering, wheelies and tricks all come from the game's own motion code.
|
|
||||||
Nunchuk and Classic Controller are real Wii extensions too: the game gets the Nunchuk's stick,
|
|
||||||
C/Z and accelerometer, and the Classic Controller through `KPADGetUnifiedWpadStatus` with its own
|
|
||||||
layout and icons, so its buttons do what the game says they do and no mapping is involved. Plug an
|
|
||||||
extension in or pull it out mid-game and the game switches control scheme like on the console
|
|
||||||
(the runtime patches SDL's Wii driver, which otherwise loses the remote for good on an extension
|
|
||||||
change). Only the Wii U Pro Controller, which has no Wii-era equivalent, is fed to the game as a
|
|
||||||
GameCube pad with Nintendo's layout. If a remote drops out or was switched on after launch, the
|
|
||||||
runtime keeps rescanning Bluetooth until it comes back (F10 > Controller settings > Wii Remotes). SDL's read of
|
|
||||||
the remote's factory accelerometer calibration often times out over Bluetooth (`console.log`
|
|
||||||
then says "Using fallback accelerometer calibration") and it falls back to a nominal zero point,
|
|
||||||
so the same menu has a one-button calibration (remote flat, buttons up) that removes the small
|
|
||||||
tilt offset some remotes show.
|
|
||||||
|
|
||||||
Known limitations of the Wii Remote path:
|
Known limitations of the Wii Remote path:
|
||||||
- No IR pointer yet: menus are navigated with the D-pad and A (the game treats the remote as
|
- No IR pointer yet: menus are navigated with the D-pad and A (the game treats the remote as
|
||||||
@@ -153,7 +177,9 @@ The default test suite needs no binaries and no host C++ compiler, so you can ha
|
|||||||
translator without any game data around.
|
translator without any game data around.
|
||||||
|
|
||||||
For everything beyond that, feeding in your own `main.dol`/`StaticR.rel`, running the
|
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
|
## FAQ
|
||||||
|
|
||||||
@@ -202,7 +228,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).
|
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
|
## Credits
|
||||||
|
- **inkwreck** - making the logo
|
||||||
- **[aurora](https://github.com/encounter/aurora)** - the GX rendering/windowing backend this
|
- **[aurora](https://github.com/encounter/aurora)** - the GX rendering/windowing backend this
|
||||||
project's whole graphics layer sits on. MIT licensed.
|
project's whole graphics layer sits on. MIT licensed.
|
||||||
- **[Dawn](https://dawn.googlesource.com/dawn)** - Google's WebGPU implementation, powering
|
- **[Dawn](https://dawn.googlesource.com/dawn)** - Google's WebGPU implementation, powering
|
||||||
|
|||||||
@@ -171,6 +171,22 @@ typedef struct PADButtonMapping {
|
|||||||
PADButton padButton;
|
PADButton padButton;
|
||||||
} PADButtonMapping;
|
} 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 {
|
typedef struct PADAxisMapping {
|
||||||
PADSignedNativeAxis nativeAxis;
|
PADSignedNativeAxis nativeAxis;
|
||||||
s32 nativeButton;
|
s32 nativeButton;
|
||||||
|
|||||||
@@ -319,6 +319,18 @@ std::array<bool, PAD_CHANMAX> g_suppressLeftTrigger{};
|
|||||||
std::array<bool, PAD_CHANMAX> g_suppressRightTrigger{};
|
std::array<bool, PAD_CHANMAX> g_suppressRightTrigger{};
|
||||||
|
|
||||||
bool is_mouse_scancode(const s32 scancode) { return scancode < PAD_KEY_INVALID; }
|
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) {
|
bool is_mouse_button_pressed(const s32 scancode) {
|
||||||
const int32_t buttonNum = -(scancode + 1);
|
const int32_t buttonNum = -(scancode + 1);
|
||||||
if (buttonNum < 1 || buttonNum > 5) {
|
if (buttonNum < 1 || buttonNum > 5) {
|
||||||
@@ -724,10 +736,10 @@ u32 PADRead(PADStatus* status) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
status[i].err = PAD_ERR_NONE;
|
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(
|
std::ranges::for_each(
|
||||||
g_keyboardBindings[i].m_buttonMapping, [&kbState, &i, &status](const PADKeyButtonBinding& mapping) {
|
g_keyboardBindings[i].m_buttonMapping, [&kbState, &numKeys, &i, &status](const PADKeyButtonBinding& mapping) {
|
||||||
if (mapping.scancode > PAD_KEY_INVALID && kbState[mapping.scancode]) {
|
if (mapping.scancode > PAD_KEY_INVALID && mapping.scancode < numKeys && kbState[mapping.scancode]) {
|
||||||
status[i].button |= mapping.padButton;
|
status[i].button |= mapping.padButton;
|
||||||
} else if (is_mouse_scancode(mapping.scancode) && is_mouse_button_pressed(mapping.scancode)) {
|
} else if (is_mouse_scancode(mapping.scancode) && is_mouse_button_pressed(mapping.scancode)) {
|
||||||
status[i].button |= mapping.padButton;
|
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));
|
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);
|
EnsureMappingLoaded(controller);
|
||||||
|
|
||||||
// Wii U Pro Controller raw D-pad fallback. SDL's HIDAPI Wii driver posts
|
// 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;
|
bool rightTriggerSet = false;
|
||||||
std::ranges::for_each(controller->m_buttonMapping, [&controller, &i, &status, &leftTriggerSet,
|
std::ranges::for_each(controller->m_buttonMapping, [&controller, &i, &status, &leftTriggerSet,
|
||||||
&rightTriggerSet](const auto& mapping) {
|
&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;
|
status[i].button |= mapping.padButton;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -852,7 +864,7 @@ u32 PADRead(PADStatus* status) {
|
|||||||
if (mapping.nativeButton == PAD_NATIVE_BUTTON_INVALID) {
|
if (mapping.nativeButton == PAD_NATIVE_BUTTON_INVALID) {
|
||||||
return;
|
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;
|
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 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));
|
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 (controller->m_deadZones.emulateTriggers) {
|
||||||
if (!leftTriggerSet && tl > controller->m_deadZones.leftTriggerActivationZone) {
|
if (!leftTriggerSet && tl > controller->m_deadZones.leftTriggerActivationZone) {
|
||||||
status[i].button |= PAD_TRIGGER_L;
|
status[i].button |= PAD_TRIGGER_L;
|
||||||
@@ -990,12 +1013,13 @@ void PADControlMotor(const u32 chan, const u32 cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (controller->m_isGameCube) {
|
if (controller->m_isGameCube) {
|
||||||
if (cmd == PAD_MOTOR_STOP) {
|
if (cmd == PAD_MOTOR_STOP || cmd == PAD_MOTOR_STOP_HARD) {
|
||||||
aurora::input::controller_rumble(instance, 0, 1, 0);
|
// 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) {
|
} else if (cmd == PAD_MOTOR_RUMBLE) {
|
||||||
aurora::input::controller_rumble(instance, 1, 1, 0);
|
aurora::input::controller_rumble(instance, 1, 1, 0);
|
||||||
} else if (cmd == PAD_MOTOR_STOP_HARD) {
|
|
||||||
aurora::input::controller_rumble(instance, 0, 0, 0);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (cmd == PAD_MOTOR_STOP) {
|
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) {
|
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) {
|
if (port >= PAD_MAX_CONTROLLERS || !g_keyboardBindings[port].m_mappingsSet) {
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,7 +168,12 @@ struct RenderPass {
|
|||||||
Range resolveUniformRange;
|
Range resolveUniformRange;
|
||||||
std::array<u32, 3> resolveCopyFilterCoefficients{0, 64, 0};
|
std::array<u32, 3> resolveCopyFilterCoefficients{0, 64, 0};
|
||||||
Vec4<float> clearColorValue{0.f, 0.f, 0.f, 0.f};
|
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;
|
CommandList commands;
|
||||||
bool clearColor = true;
|
bool clearColor = true;
|
||||||
bool clearDepth = true;
|
bool clearDepth = true;
|
||||||
@@ -757,7 +762,9 @@ void begin_offscreen(uint32_t width, uint32_t height) {
|
|||||||
.targetSize = {width, height, 1},
|
.targetSize = {width, height, 1},
|
||||||
.msaaSamples = 1,
|
.msaaSamples = 1,
|
||||||
.clearColorValue = {0.f, 0.f, 0.f, 0.f},
|
.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,
|
.clearColor = true,
|
||||||
.clearDepth = true,
|
.clearDepth = true,
|
||||||
};
|
};
|
||||||
@@ -1410,10 +1417,19 @@ static void render_pass_impl(const wgpu::RenderPassEncoder& pass, const std::vec
|
|||||||
switch (cmd.type) {
|
switch (cmd.type) {
|
||||||
case CommandType::SetViewport: {
|
case CommandType::SetViewport: {
|
||||||
const auto& vp = cmd.data.setViewport;
|
const auto& vp = cmd.data.setViewport;
|
||||||
// WebGPU requires 0 <= minDepth <= maxDepth <= 1, and the guest's (near, far) order is already
|
// WebGPU requires 0 <= minDepth <= maxDepth <= 1. vp.znear/vp.zfar are in GX's own distance
|
||||||
// reproduced in clip space. Passing the raw swapped pair diverged per backend in release builds.
|
// terms (0 = near); under UseReversedZ the host depth-buffer storage direction is flipped
|
||||||
const float minDepth = std::clamp(std::min(vp.znear, vp.zfar), 0.0f, 1.0f);
|
// (near = 1, far = 0), so this range has to be remapped through 1-x the same way the
|
||||||
const float maxDepth = std::clamp(std::max(vp.znear, vp.zfar), 0.0f, 1.0f);
|
// 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);
|
pass.SetViewport(vp.left, vp.top, vp.width, vp.height, minDepth, maxDepth);
|
||||||
} break;
|
} break;
|
||||||
case CommandType::SetScissor: {
|
case CommandType::SetScissor: {
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ struct Params {
|
|||||||
|
|
||||||
constexpr std::string_view ReversedZBody = R"(
|
constexpr std::string_view ReversedZBody = R"(
|
||||||
fn gx_z24(depth: f32) -> u32 {
|
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;
|
)"sv;
|
||||||
|
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ fn gx_z24_at_coord(unclamped_coord: vec2i) -> u32 {
|
|||||||
let tex_size = vec2i(textureDimensions(src));
|
let tex_size = vec2i(textureDimensions(src));
|
||||||
let coord = clamp(unclamped_coord, vec2i(0), tex_size - vec2i(1));
|
let coord = clamp(unclamped_coord, vec2i(0), tex_size - vec2i(1));
|
||||||
let depth = textureLoad(src, coord, 0);
|
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
|
)"s
|
||||||
: R"(
|
: R"(
|
||||||
|
|||||||
@@ -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) {
|
static inline wgpu::CompareFunction to_compare_function(GXCompare func) {
|
||||||
switch (func) {
|
switch (func) {
|
||||||
DEFAULT_FATAL("invalid depth fn {}", underlying(func));
|
DEFAULT_FATAL("invalid depth fn {}", underlying(func));
|
||||||
case GX_NEVER:
|
case GX_NEVER:
|
||||||
return wgpu::CompareFunction::Never;
|
return wgpu::CompareFunction::Never;
|
||||||
case GX_LESS:
|
case GX_LESS:
|
||||||
return wgpu::CompareFunction::Less;
|
return UseReversedZ ? wgpu::CompareFunction::Greater : wgpu::CompareFunction::Less;
|
||||||
case GX_EQUAL:
|
case GX_EQUAL:
|
||||||
return wgpu::CompareFunction::Equal;
|
return wgpu::CompareFunction::Equal;
|
||||||
case GX_LEQUAL:
|
case GX_LEQUAL:
|
||||||
return wgpu::CompareFunction::LessEqual;
|
return UseReversedZ ? wgpu::CompareFunction::GreaterEqual : wgpu::CompareFunction::LessEqual;
|
||||||
case GX_GREATER:
|
case GX_GREATER:
|
||||||
return wgpu::CompareFunction::Greater;
|
return UseReversedZ ? wgpu::CompareFunction::Less : wgpu::CompareFunction::Greater;
|
||||||
case GX_NEQUAL:
|
case GX_NEQUAL:
|
||||||
return wgpu::CompareFunction::NotEqual;
|
return wgpu::CompareFunction::NotEqual;
|
||||||
case GX_GEQUAL:
|
case GX_GEQUAL:
|
||||||
return wgpu::CompareFunction::GreaterEqual;
|
return UseReversedZ ? wgpu::CompareFunction::LessEqual : wgpu::CompareFunction::GreaterEqual;
|
||||||
case GX_ALWAYS:
|
case GX_ALWAYS:
|
||||||
return wgpu::CompareFunction::Always;
|
return wgpu::CompareFunction::Always;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -485,7 +485,14 @@ const gfx::TextureBind& get_texture(GXTexMapID id) noexcept;
|
|||||||
void resolve_sampled_textures(const ShaderInfo& info) noexcept;
|
void resolve_sampled_textures(const ShaderInfo& info) noexcept;
|
||||||
|
|
||||||
inline float clear_depth_value() {
|
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; }
|
inline bool render_target_has_alpha(GXPixelFmt pixelFmt) noexcept { return pixelFmt == GX_PF_RGBA6_Z24; }
|
||||||
|
|||||||
@@ -993,11 +993,13 @@ wgpu::ShaderModule build_shader(const ShaderConfig& config) noexcept {
|
|||||||
"\n let clip_base = select(clip_a, clip_b, use_b);"
|
"\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);";
|
"\n out.pos = vec4f(clip_base.xy + offset_ndc * clip_base.w, clip_base.zw);";
|
||||||
}
|
}
|
||||||
if constexpr (UseReversedZ) {
|
// The near/far depth correction used to be applied here per-vertex (out.pos.z = -out.pos.z for
|
||||||
vtxXfrAttrsPre += "\n out.pos.z = -out.pos.z;";
|
// reversed, or += out.pos.w for forward), redundantly on top of the same correction already
|
||||||
} else {
|
// folded into ubuf.proj by effective_projection() (shader_info.cpp) - applying it twice canceled
|
||||||
vtxXfrAttrsPre += "\n out.pos.z += out.pos.w;";
|
// 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.
|
// GX rasterizes at a 7/12 pixel center when antialiasing is disabled, while WebGPU rasterizes at 1/2.
|
||||||
vtxXfrAttrsPre +=
|
vtxXfrAttrsPre +=
|
||||||
"\n let gx_pixel_center_correction = "
|
"\n let gx_pixel_center_correction = "
|
||||||
@@ -1465,7 +1467,14 @@ wgpu::ShaderModule build_shader(const ShaderConfig& config) noexcept {
|
|||||||
textureDependency.texMapId, uvIn);
|
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 =
|
std::string fogZCoordExpr =
|
||||||
fmt::format("u32(round(clamp({}, 0.0, 1.0) * 16777216.0))", fogDepthExpr);
|
fmt::format("u32(round(clamp({}, 0.0, 1.0) * 16777216.0))", fogDepthExpr);
|
||||||
if (usesZTextureDepth) {
|
if (usesZTextureDepth) {
|
||||||
@@ -1498,7 +1507,7 @@ wgpu::ShaderModule build_shader(const ShaderConfig& config) noexcept {
|
|||||||
fragmentFn += fmt::format(
|
fragmentFn += fmt::format(
|
||||||
"\n let oldZ = u32(round(clamp({0}, 0.0, 1.0) * 16777216.0));"
|
"\n let oldZ = u32(round(clamp({0}, 0.0, 1.0) * 16777216.0));"
|
||||||
"\n ztexCoord = (ztexCoord + oldZ) & 0x00ffffffu;",
|
"\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;";
|
fragmentFn += "\n let ztexDepth = f32(ztexCoord) / 16777216.0;";
|
||||||
fogZCoordExpr = "ztexCoord";
|
fogZCoordExpr = "ztexCoord";
|
||||||
@@ -1639,7 +1648,13 @@ wgpu::ShaderModule build_shader(const ShaderConfig& config) noexcept {
|
|||||||
" @builtin(frag_depth) depth: f32,\n"
|
" @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";
|
fragmentReturnType = "FragmentOutput";
|
||||||
fragmentReturn =
|
fragmentReturn =
|
||||||
" var out: FragmentOutput;\n"
|
" var out: FragmentOutput;\n"
|
||||||
|
|||||||
@@ -548,14 +548,22 @@ constexpr size_t kStagedUniformBytes =
|
|||||||
96 + sizeof(Mat4x4<float>) + sizeof(Mat3x4<float>) * (MaxPostexMtx + MaxPnMtx);
|
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).
|
// 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 {
|
static Mat4x4<float> effective_projection() noexcept {
|
||||||
const auto& vp = g_gxState.renderViewport;
|
const auto& vp = g_gxState.renderViewport;
|
||||||
const bool flip = (vp.znear <= vp.zfar) == UseReversedZ;
|
const bool flip = (vp.znear <= vp.zfar) == UseReversedZ;
|
||||||
Mat4x4<float> proj = g_gxState.proj;
|
Mat4x4<float> proj = g_gxState.proj;
|
||||||
if (flip) {
|
|
||||||
for (size_t i = 0; i < 4; ++i) {
|
for (size_t i = 0; i < 4; ++i) {
|
||||||
proj.m2.m[i] = -(proj.m2.m[i] + proj.m3.m[i]);
|
proj.m2.m[i] = flip ? -proj.m2.m[i] : (proj.m2.m[i] + proj.m3.m[i]);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return proj;
|
return proj;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,6 +401,8 @@ SDL_JoystickID add_controller(SDL_JoystickID which) noexcept {
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
controller.m_isGameCube = controller.m_vid == 0x057E && controller.m_pid == 0x0337;
|
controller.m_isGameCube = controller.m_vid == 0x057E && controller.m_pid == 0x0337;
|
||||||
|
const char* serial = SDL_GetGamepadSerial(ctrl);
|
||||||
|
controller.m_gameCubeUseOrdinaryStop = controller.m_isGameCube && serial && "GCP+"sv == serial;
|
||||||
if (controller.m_isGameCube ||
|
if (controller.m_isGameCube ||
|
||||||
(SDL_GetGamepadType(ctrl) == SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO && controller.m_pid == 0x2073)) {
|
(SDL_GetGamepadType(ctrl) == SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO && controller.m_pid == 0x2073)) {
|
||||||
controller.m_deadZones.emulateTriggers = false;
|
controller.m_deadZones.emulateTriggers = false;
|
||||||
@@ -481,6 +483,13 @@ bool controller_has_rumble(Uint32 instance) noexcept {
|
|||||||
void controller_rumble(uint32_t instance, uint16_t low_freq_intensity, uint16_t high_freq_intensity,
|
void controller_rumble(uint32_t instance, uint16_t low_freq_intensity, uint16_t high_freq_intensity,
|
||||||
uint16_t duration_ms) noexcept {
|
uint16_t duration_ms) noexcept {
|
||||||
if (auto it = g_GameControllers.find(instance); it != g_GameControllers.end()) {
|
if (auto it = g_GameControllers.find(instance); it != g_GameControllers.end()) {
|
||||||
|
// GC Pocket+ has been observed continuing to vibrate after a hard stop;
|
||||||
|
// an ordinary stop cleared it. With GAMECUBE_RUMBLE_BRAKE enabled, SDL
|
||||||
|
// encodes (0, 1) as adapter command 0, whereas (0, 0) sends command 2.
|
||||||
|
// Apply the workaround here so shutdown uses the same stop as PAD calls.
|
||||||
|
if (it->second.m_gameCubeUseOrdinaryStop && low_freq_intensity == 0 && high_freq_intensity == 0) {
|
||||||
|
high_freq_intensity = 1;
|
||||||
|
}
|
||||||
SDL_RumbleGamepad(it->second.m_controller, low_freq_intensity, high_freq_intensity, duration_ms);
|
SDL_RumbleGamepad(it->second.m_controller, low_freq_intensity, high_freq_intensity, duration_ms);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ extern Module Log;
|
|||||||
struct GameController {
|
struct GameController {
|
||||||
SDL_Gamepad* m_controller = nullptr;
|
SDL_Gamepad* m_controller = nullptr;
|
||||||
bool m_isGameCube = false;
|
bool m_isGameCube = false;
|
||||||
|
bool m_gameCubeUseOrdinaryStop = false;
|
||||||
Sint32 m_index = -1;
|
Sint32 m_index = -1;
|
||||||
Sint32 m_playerIndex = -1;
|
Sint32 m_playerIndex = -1;
|
||||||
bool m_hasRumble = false;
|
bool m_hasRumble = false;
|
||||||
|
|||||||
@@ -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 0x8000629c \
|
||||||
|
--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://nas.play.rwfc.net/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
|
module_link_base: 0x803992E0
|
||||||
output: build/mods/retro_rewind_full_cpp
|
output: build/mods/retro_rewind_full_cpp
|
||||||
enable_retro_wfc: true
|
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
|
retro_wfc_legacy_bootstrap_hook: 0x800ED6E8
|
||||||
riivolution:
|
riivolution:
|
||||||
xml: xml/RetroRewind6.xml
|
xml: xml/RetroRewind6.xml
|
||||||
|
|||||||
@@ -277,6 +277,32 @@ target_link_libraries(mkw_platform_paths_tests PRIVATE mkw_platform)
|
|||||||
target_compile_features(mkw_platform_paths_tests PRIVATE cxx_std_17)
|
target_compile_features(mkw_platform_paths_tests PRIVATE cxx_std_17)
|
||||||
add_test(NAME mkw_platform_paths_tests COMMAND mkw_platform_paths_tests)
|
add_test(NAME mkw_platform_paths_tests COMMAND mkw_platform_paths_tests)
|
||||||
|
|
||||||
|
add_executable(mkw_nand_save_tests "${CMAKE_CURRENT_LIST_DIR}/tests/nand_save_tests.cpp")
|
||||||
|
target_include_directories(mkw_nand_save_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
|
||||||
|
target_compile_features(mkw_nand_save_tests PRIVATE cxx_std_17)
|
||||||
|
add_test(NAME mkw_nand_save_tests COMMAND mkw_nand_save_tests)
|
||||||
|
|
||||||
|
add_executable(mkw_nand_settings_tests "${CMAKE_CURRENT_LIST_DIR}/tests/nand_settings_tests.cpp")
|
||||||
|
find_package(Threads REQUIRED)
|
||||||
|
target_link_libraries(mkw_nand_settings_tests PRIVATE Threads::Threads)
|
||||||
|
target_include_directories(mkw_nand_settings_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
|
||||||
|
target_compile_features(mkw_nand_settings_tests PRIVATE cxx_std_17)
|
||||||
|
add_test(NAME mkw_nand_settings_tests COMMAND mkw_nand_settings_tests)
|
||||||
|
|
||||||
|
add_executable(mkw_sc_serial_tests "${CMAKE_CURRENT_LIST_DIR}/tests/sc_serial_tests.cpp")
|
||||||
|
target_include_directories(mkw_sc_serial_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
|
||||||
|
target_compile_features(mkw_sc_serial_tests PRIVATE cxx_std_17)
|
||||||
|
add_test(NAME mkw_sc_serial_tests COMMAND mkw_sc_serial_tests)
|
||||||
|
|
||||||
|
# The input expression engine is self-contained, so it can be exercised without
|
||||||
|
# linking the runtime or SDL.
|
||||||
|
add_executable(mkw_input_expr_tests
|
||||||
|
"${CMAKE_CURRENT_LIST_DIR}/tests/test_expr.cpp"
|
||||||
|
"${CMAKE_CURRENT_LIST_DIR}/src/input_expr.cpp")
|
||||||
|
target_include_directories(mkw_input_expr_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
|
||||||
|
target_compile_features(mkw_input_expr_tests PRIVATE cxx_std_17)
|
||||||
|
add_test(NAME mkw_input_expr_tests COMMAND mkw_input_expr_tests)
|
||||||
|
|
||||||
# HostContext deliberately keeps the platform-specific context primitive out
|
# HostContext deliberately keeps the platform-specific context primitive out
|
||||||
# of fiber_manager.cpp. Exercise the Linux libco handoff directly so future
|
# of fiber_manager.cpp. Exercise the Linux libco handoff directly so future
|
||||||
# refactors cannot silently remove its headers, implementation, or link edge.
|
# refactors cannot silently remove its headers, implementation, or link edge.
|
||||||
|
|||||||
@@ -1,38 +1,28 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "runtime_config.h"
|
#include "nand_path.h"
|
||||||
|
#include "nand_settings.h"
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <cctype>
|
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <filesystem>
|
|
||||||
#include <fstream>
|
|
||||||
#include <iomanip>
|
#include <iomanip>
|
||||||
#include <optional>
|
|
||||||
#include <random>
|
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <string_view>
|
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
namespace RuntimeConsoleIdentity {
|
namespace RuntimeConsoleIdentity {
|
||||||
|
|
||||||
struct Identity {
|
struct Identity {
|
||||||
std::string serial;
|
std::string serial;
|
||||||
|
std::string productCode;
|
||||||
|
std::string area;
|
||||||
|
std::string gameRegion;
|
||||||
std::array<uint8_t, 6> mac;
|
std::array<uint8_t, 6> mac;
|
||||||
};
|
};
|
||||||
|
|
||||||
inline bool IsValidSerial(const std::string& serial) {
|
|
||||||
return serial.size() == 9 &&
|
|
||||||
serial != "000000000" &&
|
|
||||||
std::all_of(serial.begin(), serial.end(),
|
|
||||||
[](unsigned char value) { return std::isdigit(value) != 0; });
|
|
||||||
}
|
|
||||||
|
|
||||||
inline Identity FromSerial(std::string serial) {
|
inline Identity FromSerial(std::string serial) {
|
||||||
// Keep Nintendo's Wii OUI. The suffix is derived from the persisted serial
|
// Keep Nintendo's Wii OUI. The suffix is derived from the NAND serial
|
||||||
// so every API exposes one coherent, stable virtual-console identity.
|
// so every API exposes one coherent, stable virtual-console identity.
|
||||||
uint32_t hash = 2166136261u;
|
uint32_t hash = 2166136261u;
|
||||||
for (const unsigned char value : serial) {
|
for (const unsigned char value : serial) {
|
||||||
@@ -46,6 +36,7 @@ inline Identity FromSerial(std::string serial) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
std::move(serial),
|
std::move(serial),
|
||||||
|
{}, {}, {},
|
||||||
{
|
{
|
||||||
0x00,
|
0x00,
|
||||||
0x09,
|
0x09,
|
||||||
@@ -57,83 +48,23 @@ inline Identity FromSerial(std::string serial) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
inline std::optional<std::string> ReadSerial(const std::filesystem::path& path) {
|
inline Identity LoadFromNand() {
|
||||||
std::ifstream input(path);
|
const auto root = RuntimeNandPath::DiscoverNandRootPath();
|
||||||
std::string line;
|
const auto settings = RuntimeNandSettings::Read(root);
|
||||||
if (!input || !std::getline(input, line)) {
|
if (!settings || !RuntimeNandSettings::HasIdentity(*settings)) {
|
||||||
return std::nullopt;
|
RuntimeNandPath::FailNandRoot(
|
||||||
|
"NAND setting.txt is missing or has invalid console identity fields (SERNO, CODE, AREA, GAME)",
|
||||||
|
root / "title/00000001/00000002/data/setting.txt");
|
||||||
}
|
}
|
||||||
constexpr std::string_view prefix = "serial=";
|
Identity identity = FromSerial(settings->at("SERNO"));
|
||||||
if (line.rfind(prefix, 0) != 0) {
|
identity.productCode = settings->at("CODE");
|
||||||
return std::nullopt;
|
identity.area = settings->at("AREA");
|
||||||
}
|
identity.gameRegion = settings->at("GAME");
|
||||||
std::string serial = line.substr(prefix.size());
|
return identity;
|
||||||
if (!IsValidSerial(serial)) {
|
|
||||||
return std::nullopt;
|
|
||||||
}
|
|
||||||
return serial;
|
|
||||||
}
|
|
||||||
|
|
||||||
inline bool WriteSerial(const std::filesystem::path& path, const std::string& serial) {
|
|
||||||
std::error_code ec;
|
|
||||||
std::filesystem::create_directories(path.parent_path(), ec);
|
|
||||||
if (ec) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::filesystem::path temporary = path;
|
|
||||||
temporary += ".tmp";
|
|
||||||
{
|
|
||||||
std::ofstream output(temporary, std::ios::trunc);
|
|
||||||
if (!output) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
output << "serial=" << serial << '\n';
|
|
||||||
output.close();
|
|
||||||
if (!output) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::filesystem::rename(temporary, path, ec);
|
|
||||||
if (!ec) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
std::filesystem::remove(temporary, ec);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
inline std::string GenerateSerial() {
|
|
||||||
std::random_device entropy;
|
|
||||||
std::seed_seq seed{
|
|
||||||
entropy(),
|
|
||||||
entropy(),
|
|
||||||
entropy(),
|
|
||||||
entropy(),
|
|
||||||
};
|
|
||||||
std::mt19937 generator(seed);
|
|
||||||
std::uniform_int_distribution<uint32_t> distribution(100000000u, 999999999u);
|
|
||||||
return std::to_string(distribution(generator));
|
|
||||||
}
|
|
||||||
|
|
||||||
inline Identity LoadOrCreate(const std::filesystem::path& path) {
|
|
||||||
if (const auto serial = ReadSerial(path)) {
|
|
||||||
return FromSerial(*serial);
|
|
||||||
}
|
|
||||||
|
|
||||||
const std::string generated = GenerateSerial();
|
|
||||||
if (WriteSerial(path, generated)) {
|
|
||||||
return FromSerial(generated);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remain operational in a read-only environment. This fallback matches
|
|
||||||
// Dolphin's deterministic serial while keeping the same valid identity shape.
|
|
||||||
return FromSerial("123456789");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
inline const Identity& Current() {
|
inline const Identity& Current() {
|
||||||
static const Identity identity =
|
static const Identity identity = LoadFromNand();
|
||||||
LoadOrCreate(RuntimeConfigFile::ApplicationDataDirectory() / "ConsoleIdentity.txt");
|
|
||||||
return identity;
|
return identity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// The single vocabulary shared by everything that has to turn a Config.toml
|
||||||
|
// controller name into a real button: the F10 settings bar, the macro engine,
|
||||||
|
// and the startup mapping pass. Keeping one table here means a name that the
|
||||||
|
// settings bar offers is always a name the config parser accepts, and vice
|
||||||
|
// versa; the two used to drift because each side carried its own copy.
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#include <SDL3/SDL_gamepad.h>
|
||||||
|
#include <dolphin/pad.h>
|
||||||
|
|
||||||
|
namespace ControllerNames {
|
||||||
|
|
||||||
|
// A GameCube button as the game sees it, with the Config.toml key that selects
|
||||||
|
// it. Order matches RuntimeConfigFile::kControllerButtonKeys.
|
||||||
|
struct GameCubeButtonItem {
|
||||||
|
const char* configKey;
|
||||||
|
const char* label;
|
||||||
|
PADButton padButton;
|
||||||
|
};
|
||||||
|
|
||||||
|
inline constexpr std::array<GameCubeButtonItem, PAD_BUTTON_COUNT> kGameCubeButtons = {{
|
||||||
|
{"a", "A", PAD_BUTTON_A},
|
||||||
|
{"b", "B", PAD_BUTTON_B},
|
||||||
|
{"x", "X", PAD_BUTTON_X},
|
||||||
|
{"y", "Y", PAD_BUTTON_Y},
|
||||||
|
{"start", "Start", PAD_BUTTON_START},
|
||||||
|
{"z", "Z", PAD_TRIGGER_Z},
|
||||||
|
{"l", "L", PAD_TRIGGER_L},
|
||||||
|
{"r", "R", PAD_TRIGGER_R},
|
||||||
|
{"up", "D-pad Up", PAD_BUTTON_UP},
|
||||||
|
{"down", "D-pad Down", PAD_BUTTON_DOWN},
|
||||||
|
{"left", "D-pad Left", PAD_BUTTON_LEFT},
|
||||||
|
{"right", "D-pad Right", PAD_BUTTON_RIGHT},
|
||||||
|
}};
|
||||||
|
|
||||||
|
// A physical button on the host pad. Names are positional (south/east/...)
|
||||||
|
// rather than Xbox-labelled so one config reads the same on any hardware.
|
||||||
|
struct NativeButtonItem {
|
||||||
|
const char* configName;
|
||||||
|
const char* label;
|
||||||
|
uint32_t nativeButton;
|
||||||
|
};
|
||||||
|
|
||||||
|
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},
|
||||||
|
{"north", "North (Y / Triangle)", SDL_GAMEPAD_BUTTON_NORTH},
|
||||||
|
{"back", "Back / Select / Create", SDL_GAMEPAD_BUTTON_BACK},
|
||||||
|
{"guide", "Guide / Home / PS", SDL_GAMEPAD_BUTTON_GUIDE},
|
||||||
|
{"start", "Start / Options", SDL_GAMEPAD_BUTTON_START},
|
||||||
|
{"left_stick", "Left stick click (L3)", SDL_GAMEPAD_BUTTON_LEFT_STICK},
|
||||||
|
{"right_stick", "Right stick click (R3)", SDL_GAMEPAD_BUTTON_RIGHT_STICK},
|
||||||
|
{"left_shoulder", "Left bumper (LB / L1)", SDL_GAMEPAD_BUTTON_LEFT_SHOULDER},
|
||||||
|
{"right_shoulder", "Right bumper (RB / R1)", SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER},
|
||||||
|
{"dpad_up", "D-pad Up", SDL_GAMEPAD_BUTTON_DPAD_UP},
|
||||||
|
{"dpad_down", "D-pad Down", SDL_GAMEPAD_BUTTON_DPAD_DOWN},
|
||||||
|
{"dpad_left", "D-pad Left", SDL_GAMEPAD_BUTTON_DPAD_LEFT},
|
||||||
|
{"dpad_right", "D-pad Right", SDL_GAMEPAD_BUTTON_DPAD_RIGHT},
|
||||||
|
{"misc1", "Misc 1 / Share / Mic", SDL_GAMEPAD_BUTTON_MISC1},
|
||||||
|
{"right_paddle1", "Right paddle 1", SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1},
|
||||||
|
{"left_paddle1", "Left paddle 1", SDL_GAMEPAD_BUTTON_LEFT_PADDLE1},
|
||||||
|
{"right_paddle2", "Right paddle 2", SDL_GAMEPAD_BUTTON_RIGHT_PADDLE2},
|
||||||
|
{"left_paddle2", "Left paddle 2", SDL_GAMEPAD_BUTTON_LEFT_PADDLE2},
|
||||||
|
{"touchpad", "Touchpad click", SDL_GAMEPAD_BUTTON_TOUCHPAD},
|
||||||
|
{"misc2", "Misc 2", SDL_GAMEPAD_BUTTON_MISC2},
|
||||||
|
{"misc3", "Misc 3 / GC L click", SDL_GAMEPAD_BUTTON_MISC3},
|
||||||
|
{"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");
|
||||||
|
if (begin == std::string_view::npos) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const size_t end = token.find_last_not_of(" \t");
|
||||||
|
return std::string(token.substr(begin, end - begin + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
inline const NativeButtonItem* FindNativeButton(std::string_view 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 PADAxisButtonIdentity(nativeButton) == PADAxisButtonIdentity(item.nativeButton); });
|
||||||
|
return it == kNativeButtons.end() ? *FindNativeButton("unmapped") : *it;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline const GameCubeButtonItem* FindGameCubeButton(std::string_view configKey) {
|
||||||
|
const std::string key = TrimToken(configKey);
|
||||||
|
const auto it = std::find_if(kGameCubeButtons.begin(), kGameCubeButtons.end(),
|
||||||
|
[&](const GameCubeButtonItem& item) { return key == item.configKey; });
|
||||||
|
return it == kGameCubeButtons.end() ? nullptr : &*it;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "up" or "up,a" -> the OR of those GC button bits. Unknown names are skipped so
|
||||||
|
// a typo costs one button instead of the whole macro.
|
||||||
|
inline uint16_t GameCubeMaskFromKeys(std::string_view keys) {
|
||||||
|
uint16_t mask = 0;
|
||||||
|
size_t begin = 0;
|
||||||
|
while (begin <= keys.size()) {
|
||||||
|
const size_t comma = keys.find(',', begin);
|
||||||
|
const std::string_view token =
|
||||||
|
keys.substr(begin, comma == std::string_view::npos ? std::string_view::npos : comma - begin);
|
||||||
|
if (const GameCubeButtonItem* item = FindGameCubeButton(token)) {
|
||||||
|
mask |= static_cast<uint16_t>(item->padButton);
|
||||||
|
}
|
||||||
|
if (comma == std::string_view::npos) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
begin = comma + 1;
|
||||||
|
}
|
||||||
|
return mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline std::string GameCubeKeysFromMask(uint16_t mask) {
|
||||||
|
std::string keys;
|
||||||
|
for (const auto& item : kGameCubeButtons) {
|
||||||
|
if ((mask & static_cast<uint16_t>(item.padButton)) == 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!keys.empty()) {
|
||||||
|
keys += ',';
|
||||||
|
}
|
||||||
|
keys += item.configKey;
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline std::string GameCubeLabelsFromMask(uint16_t mask) {
|
||||||
|
std::string labels;
|
||||||
|
for (const auto& item : kGameCubeButtons) {
|
||||||
|
if ((mask & static_cast<uint16_t>(item.padButton)) == 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!labels.empty()) {
|
||||||
|
labels += " + ";
|
||||||
|
}
|
||||||
|
labels += item.label;
|
||||||
|
}
|
||||||
|
return labels.empty() ? std::string("None") : labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ControllerNames
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Per-port expression bindings for the GameCube controls, plus import of a
|
||||||
|
// Dolphin GCPadNew.ini.
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <dolphin/pad.h>
|
||||||
|
|
||||||
|
namespace InputBindings {
|
||||||
|
|
||||||
|
// The controls an expression can drive, in Dolphin's own naming so an
|
||||||
|
// imported config maps across without translation.
|
||||||
|
struct ControlInfo {
|
||||||
|
const char* dolphinName;
|
||||||
|
const char* label;
|
||||||
|
uint16_t padButton; // 0 for the analog-only controls below
|
||||||
|
int analog; // 0 none, 1 trigger L, 2 trigger R
|
||||||
|
};
|
||||||
|
|
||||||
|
inline constexpr std::array<ControlInfo, 14> kControls = {{
|
||||||
|
{"Buttons/A", "A", PAD_BUTTON_A, 0},
|
||||||
|
{"Buttons/B", "B", PAD_BUTTON_B, 0},
|
||||||
|
{"Buttons/X", "X", PAD_BUTTON_X, 0},
|
||||||
|
{"Buttons/Y", "Y", PAD_BUTTON_Y, 0},
|
||||||
|
{"Buttons/Z", "Z", PAD_TRIGGER_Z, 0},
|
||||||
|
{"Buttons/Start", "Start", PAD_BUTTON_START, 0},
|
||||||
|
{"D-Pad/Up", "D-pad Up", PAD_BUTTON_UP, 0},
|
||||||
|
{"D-Pad/Down", "D-pad Down", PAD_BUTTON_DOWN, 0},
|
||||||
|
{"D-Pad/Left", "D-pad Left", PAD_BUTTON_LEFT, 0},
|
||||||
|
{"D-Pad/Right", "D-pad Right", PAD_BUTTON_RIGHT, 0},
|
||||||
|
{"Triggers/L", "L", PAD_TRIGGER_L, 1},
|
||||||
|
{"Triggers/R", "R", PAD_TRIGGER_R, 2},
|
||||||
|
{"Triggers/L-Analog", "L analog", 0, 1},
|
||||||
|
{"Triggers/R-Analog", "R analog", 0, 2},
|
||||||
|
}};
|
||||||
|
|
||||||
|
void Reload() noexcept;
|
||||||
|
|
||||||
|
// The pad library has PADBlockInput but no matching query, so the settings
|
||||||
|
// overlay reports its own state here.
|
||||||
|
void SetInputBlocked(bool blocked) noexcept;
|
||||||
|
bool InputBlocked() noexcept;
|
||||||
|
|
||||||
|
// Mix expression output into a freshly read status set. Call once per guest
|
||||||
|
// PADRead, after every other input source has been merged.
|
||||||
|
void Apply(PADStatus* statuses) noexcept;
|
||||||
|
|
||||||
|
std::string GetExpression(uint32_t port, size_t control) noexcept;
|
||||||
|
// Returns false and fills error if the text does not parse; the binding is
|
||||||
|
// left unchanged in that case.
|
||||||
|
bool SetExpression(uint32_t port, size_t control, const std::string& text, std::string& error) noexcept;
|
||||||
|
|
||||||
|
// True while the control's expression is above the press threshold.
|
||||||
|
bool IsActive(uint32_t port, size_t control) noexcept;
|
||||||
|
|
||||||
|
// The default Dolphin config location on Windows, then next to the executable.
|
||||||
|
std::string DefaultDolphinConfigPath() noexcept;
|
||||||
|
|
||||||
|
// Imports [GCPad<padIndex>] into the given port. Returns the number of controls
|
||||||
|
// imported, or -1 on failure with error filled.
|
||||||
|
int ImportDolphinConfig(const std::string& path, int padIndex, uint32_t port,
|
||||||
|
std::string& summary, std::string& error) noexcept;
|
||||||
|
|
||||||
|
} // namespace InputBindings
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Dolphin-compatible input expressions.
|
||||||
|
//
|
||||||
|
// Values are doubles in Dolphin's ControlState style; a control counts as
|
||||||
|
// pressed above kConditionThreshold. Timing matches Dolphin: wall-clock
|
||||||
|
// seconds on a steady clock, so an expression copied from GCPadNew.ini
|
||||||
|
// behaves the same here as it does there.
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
#include <functional>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace InputExpr {
|
||||||
|
|
||||||
|
inline constexpr double kConditionThreshold = 0.5;
|
||||||
|
|
||||||
|
// Resolves a backtick-quoted input name to its current value.
|
||||||
|
using InputSource = std::function<double(const std::string&)>;
|
||||||
|
|
||||||
|
struct Node;
|
||||||
|
|
||||||
|
class Expression {
|
||||||
|
public:
|
||||||
|
Expression();
|
||||||
|
~Expression();
|
||||||
|
Expression(Expression&&) noexcept;
|
||||||
|
Expression& operator=(Expression&&) noexcept;
|
||||||
|
|
||||||
|
// Returns false and fills error on a syntax problem.
|
||||||
|
static bool Parse(const std::string& text, Expression& out, std::string& error);
|
||||||
|
|
||||||
|
bool Empty() const { return m_root == nullptr; }
|
||||||
|
double Evaluate(const InputSource& source) const;
|
||||||
|
|
||||||
|
// Input names the expression references, for diagnostics.
|
||||||
|
std::vector<std::string> ReferencedInputs() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::unique_ptr<Node> m_root;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parses a Dolphin GCPadNew.ini and returns the expression text for each
|
||||||
|
// control of the requested pad, keyed by Dolphin's own control names
|
||||||
|
// ("Buttons/A", "D-Pad/Up", "Triggers/L", ...). Returns false if the file
|
||||||
|
// cannot be read or the section is missing.
|
||||||
|
bool ReadDolphinConfig(const std::filesystem::path& path, int padIndex,
|
||||||
|
std::vector<std::pair<std::string, std::string>>& controls,
|
||||||
|
std::string& deviceName, std::string& error);
|
||||||
|
|
||||||
|
} // namespace InputExpr
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "runtime_config.h"
|
#include "runtime_config.h"
|
||||||
|
#include "nand_settings.h"
|
||||||
#include "runtime_log.h"
|
#include "runtime_log.h"
|
||||||
#include "system_bridge.h"
|
#include "system_bridge.h"
|
||||||
|
|
||||||
@@ -163,7 +164,7 @@ inline std::filesystem::path CreateManagedNandRoot() {
|
|||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
inline std::filesystem::path DiscoverNandRootPath() {
|
inline std::filesystem::path ResolveNandRootPath() {
|
||||||
const std::string configPath = RuntimeConfigFile::NandRoot();
|
const std::string configPath = RuntimeConfigFile::NandRoot();
|
||||||
if (!configPath.empty()) {
|
if (!configPath.empty()) {
|
||||||
const auto path = ResolveConfiguredPath(configPath);
|
const auto path = ResolveConfiguredPath(configPath);
|
||||||
@@ -179,4 +180,16 @@ inline std::filesystem::path DiscoverNandRootPath() {
|
|||||||
return CreateManagedNandRoot();
|
return CreateManagedNandRoot();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inline std::filesystem::path DiscoverNandRootPath() {
|
||||||
|
static const auto root = [] {
|
||||||
|
const auto resolved = ResolveNandRootPath();
|
||||||
|
std::string error;
|
||||||
|
if (!RuntimeNandSettings::Ensure(resolved, error)) {
|
||||||
|
FailNandRoot(error.c_str(), RuntimeNandSettings::FilePath(resolved));
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}();
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace RuntimeNandPath
|
} // namespace RuntimeNandPath
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <istream>
|
||||||
|
|
||||||
|
namespace RuntimeNandSave {
|
||||||
|
|
||||||
|
enum class Contents { Missing, Blank, Nonzero, Error };
|
||||||
|
enum class ReadAction { Proceed, Missing, Error, RecoveryNeeded };
|
||||||
|
|
||||||
|
// A failed read is not evidence that a save is blank. Check badbit before EOF:
|
||||||
|
// an I/O failure may set both, whereas a successful short final read sets EOF.
|
||||||
|
inline Contents InspectStream(std::istream& input) {
|
||||||
|
if (!input) return Contents::Error;
|
||||||
|
char block[4096];
|
||||||
|
for (;;) {
|
||||||
|
input.read(block, sizeof(block));
|
||||||
|
if (input.bad() || (input.fail() && !input.eof())) return Contents::Error;
|
||||||
|
for (std::streamsize i = 0; i < input.gcount(); ++i) {
|
||||||
|
if (block[i] != 0) return Contents::Nonzero;
|
||||||
|
}
|
||||||
|
if (input.eof()) return Contents::Blank;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline Contents InspectFile(const std::filesystem::path& path) {
|
||||||
|
std::error_code ec;
|
||||||
|
const auto status = std::filesystem::symlink_status(path, ec);
|
||||||
|
if (ec && ec != std::errc::no_such_file_or_directory) return Contents::Error;
|
||||||
|
if (!std::filesystem::exists(status)) return Contents::Missing;
|
||||||
|
if (!std::filesystem::is_regular_file(path, ec) || ec) return Contents::Error;
|
||||||
|
std::ifstream input(path, std::ios::binary);
|
||||||
|
return InspectStream(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe only read-only opens of the actual save and its exact write shadow.
|
||||||
|
// No probe writes, removes, or repairs data, and backups are not save aliases.
|
||||||
|
inline ReadAction CheckRead(const std::filesystem::path& path, int mode) {
|
||||||
|
const auto name = path.filename();
|
||||||
|
const bool isMain = name == "rksys.dat";
|
||||||
|
if (mode != 1 || (!isMain && name != "rksys.dat.nandsafe.tmp")) return ReadAction::Proceed;
|
||||||
|
const auto contents = InspectFile(path);
|
||||||
|
if (contents == Contents::Error) return ReadAction::Error;
|
||||||
|
if (contents == Contents::Nonzero) return ReadAction::Proceed;
|
||||||
|
if (isMain) {
|
||||||
|
auto shadow = path;
|
||||||
|
shadow += ".nandsafe.tmp";
|
||||||
|
const auto shadowContents = InspectFile(shadow);
|
||||||
|
if (shadowContents == Contents::Error) return ReadAction::Error;
|
||||||
|
// The next write normally discards an old shadow. Preserve a possible
|
||||||
|
// recovery source when there is no usable original, without promoting
|
||||||
|
// an uncommitted (and potentially incomplete) shadow to the real save.
|
||||||
|
if (shadowContents == Contents::Nonzero) return ReadAction::RecoveryNeeded;
|
||||||
|
}
|
||||||
|
return contents == Contents::Blank ? ReadAction::Missing : ReadAction::Proceed;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace RuntimeNandSave
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <ctime>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <map>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
#include <windows.h>
|
||||||
|
#else
|
||||||
|
#include <unistd.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace RuntimeNandSettings {
|
||||||
|
|
||||||
|
using Settings = std::map<std::string, std::string>;
|
||||||
|
|
||||||
|
inline std::filesystem::path FilePath(const std::filesystem::path& root) {
|
||||||
|
return root / "title/00000001/00000002/data/setting.txt";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wii setting.txt is a 256-byte buffer encrypted with a rotating XOR key.
|
||||||
|
inline std::optional<Settings> Read(const std::filesystem::path& nandRoot) {
|
||||||
|
std::ifstream input(FilePath(nandRoot), std::ios::binary);
|
||||||
|
std::array<uint8_t, 256> bytes{};
|
||||||
|
if (!input.read(reinterpret_cast<char*>(bytes.data()), bytes.size())) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
uint32_t key = 0x73B5DBFAu;
|
||||||
|
std::string decoded;
|
||||||
|
for (const uint8_t byte : bytes) {
|
||||||
|
const char value = static_cast<char>(byte ^ static_cast<uint8_t>(key));
|
||||||
|
key = (key << 1) | (key >> 31);
|
||||||
|
if (value == '\0') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (value != '\r') {
|
||||||
|
decoded += value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Settings settings;
|
||||||
|
for (size_t start = 0; start < decoded.size();) {
|
||||||
|
const size_t end = decoded.find('\n', start);
|
||||||
|
const std::string line = decoded.substr(start, end - start);
|
||||||
|
const size_t equals = line.find('=');
|
||||||
|
if (equals != std::string::npos && equals != 0) {
|
||||||
|
settings.emplace(line.substr(0, equals), line.substr(equals + 1));
|
||||||
|
}
|
||||||
|
if (end == std::string::npos) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
start = end + 1;
|
||||||
|
}
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool HasIdentity(const Settings& settings) {
|
||||||
|
const auto serial = settings.find("SERNO");
|
||||||
|
if (serial == settings.end() || serial->second.empty() || serial->second.size() > 9 ||
|
||||||
|
serial->second.find_first_not_of("0123456789") != std::string::npos ||
|
||||||
|
serial->second.find_first_not_of('0') == std::string::npos) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const auto& field : {std::pair{"CODE", 5u}, {"AREA", 3u}, {"GAME", 2u}}) {
|
||||||
|
const auto value = settings.find(field.first);
|
||||||
|
if (value == settings.end() || value->second.empty() ||
|
||||||
|
value->second.size() > field.second) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dolphin's normal (non-deterministic) first-boot algorithm. It is independent
|
||||||
|
// of the ES device ID. Matching another NAND requires that NAND's saved serial.
|
||||||
|
inline std::string GenerateSerial(std::time_t now) {
|
||||||
|
if (now < 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const auto digits = std::to_string(now % 1000000000);
|
||||||
|
return std::string(9 - digits.size(), '0') + digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This recompilation targets the European disc. These are Dolphin's PAL boot
|
||||||
|
// defaults; an existing setting.txt always takes precedence, in every region.
|
||||||
|
inline std::optional<std::array<uint8_t, 256>> EncodeNew(const std::string& serial) {
|
||||||
|
const Settings identity{{"SERNO", serial}, {"CODE", "LEH"}, {"AREA", "EUR"}, {"GAME", "EU"}};
|
||||||
|
if (!HasIdentity(identity)) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
std::array<uint8_t, 256> bytes{};
|
||||||
|
size_t position = 0;
|
||||||
|
uint32_t key = 0x73B5DBFAu;
|
||||||
|
const auto writeByte = [&](char value) {
|
||||||
|
bytes[position++] = static_cast<uint8_t>(value) ^ static_cast<uint8_t>(key);
|
||||||
|
key = (key << 1) | (key >> 31);
|
||||||
|
};
|
||||||
|
for (const std::string& line : {std::string("AREA=EUR\r\n"), std::string("MODEL=RVL-001(EUR)\r\n"),
|
||||||
|
std::string("DVD=0\r\n"), std::string("MPCH=0x7FFE\r\n"), std::string("CODE=LEH\r\n"),
|
||||||
|
"SERNO=" + serial + "\r\n", std::string("VIDEO=PAL\r\n"), std::string("GAME=EU\r\n")}) {
|
||||||
|
for (;;) {
|
||||||
|
if (position + line.size() > bytes.size()) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
const auto start = position;
|
||||||
|
const auto savedKey = key;
|
||||||
|
bool hasNull = false;
|
||||||
|
for (const char value : line) {
|
||||||
|
writeByte(value);
|
||||||
|
hasNull |= bytes[position - 1] == 0;
|
||||||
|
}
|
||||||
|
if (!hasNull) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Nintendo stops at an encoded NUL. Dolphin inserts an extra LF
|
||||||
|
// before this line and retries with the shifted encryption key.
|
||||||
|
position = start;
|
||||||
|
key = savedKey;
|
||||||
|
writeByte('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bytes; // The unused tail stays raw zero, as in Dolphin.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically claim our own scratch directory. A collision belongs to another
|
||||||
|
// launch (or a previous crashed launch); leave it untouched and try another name.
|
||||||
|
inline std::optional<std::filesystem::path> CreateScratchDirectory(
|
||||||
|
const std::filesystem::path& parent, const std::string& token, std::error_code& ec) {
|
||||||
|
for (unsigned attempt = 0; attempt < 128; ++attempt) {
|
||||||
|
const auto candidate = parent / (".setting-init-" + token + "-" + std::to_string(attempt));
|
||||||
|
ec.clear();
|
||||||
|
if (std::filesystem::create_directory(candidate, ec)) return candidate;
|
||||||
|
if (ec && ec != std::errc::file_exists) return std::nullopt;
|
||||||
|
}
|
||||||
|
ec = std::make_error_code(std::errc::file_exists);
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never replace an existing file, including an unreadable or damaged one.
|
||||||
|
// Publish a complete file atomically so simultaneous launches use one identity.
|
||||||
|
inline bool Ensure(const std::filesystem::path& root, std::string& error,
|
||||||
|
std::time_t now = std::time(nullptr)) {
|
||||||
|
const auto path = FilePath(root);
|
||||||
|
std::error_code ec;
|
||||||
|
const auto status = std::filesystem::symlink_status(path, ec);
|
||||||
|
if (ec && ec != std::errc::no_such_file_or_directory) {
|
||||||
|
error = "Cannot inspect NAND setting.txt: " + ec.message();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (std::filesystem::exists(status)) {
|
||||||
|
const auto existing = Read(root);
|
||||||
|
if (existing && HasIdentity(*existing)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
error = "Existing NAND setting.txt is unreadable or invalid; restore it from this console's backup";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto bytes = EncodeNew(GenerateSerial(now));
|
||||||
|
if (!bytes) {
|
||||||
|
error = "Cannot initialize NAND settings: invalid system clock";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ec.clear();
|
||||||
|
std::filesystem::create_directories(path.parent_path(), ec);
|
||||||
|
if (ec) {
|
||||||
|
error = "Cannot create NAND settings directory: " + ec.message();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
static std::atomic<unsigned> sequence{0};
|
||||||
|
#ifdef _WIN32
|
||||||
|
const auto processId = GetCurrentProcessId();
|
||||||
|
#else
|
||||||
|
const auto processId = getpid();
|
||||||
|
#endif
|
||||||
|
const auto scratch = CreateScratchDirectory(path.parent_path(),
|
||||||
|
std::to_string(processId) + "-" + std::to_string(
|
||||||
|
std::chrono::steady_clock::now().time_since_epoch().count()) + "-" +
|
||||||
|
std::to_string(sequence++), ec);
|
||||||
|
if (!scratch) {
|
||||||
|
error = "Cannot create temporary NAND settings directory: " + ec.message();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const auto temporary = *scratch / "setting.txt";
|
||||||
|
bool written = false;
|
||||||
|
{
|
||||||
|
std::ofstream output(temporary, std::ios::binary);
|
||||||
|
output.write(reinterpret_cast<const char*>(bytes->data()), bytes->size());
|
||||||
|
output.close();
|
||||||
|
written = static_cast<bool>(output);
|
||||||
|
}
|
||||||
|
bool published = false;
|
||||||
|
if (written) {
|
||||||
|
#ifdef _WIN32
|
||||||
|
published = MoveFileExW(temporary.c_str(), path.c_str(), MOVEFILE_WRITE_THROUGH) != 0;
|
||||||
|
#else
|
||||||
|
published = ::link(temporary.c_str(), path.c_str()) == 0;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
std::filesystem::remove(temporary, ec);
|
||||||
|
std::filesystem::remove(*scratch, ec);
|
||||||
|
// A competing launcher may have published its settings first. Always read
|
||||||
|
// the winner from NAND rather than using our unpersisted candidate serial.
|
||||||
|
const auto persisted = Read(root);
|
||||||
|
if (persisted && HasIdentity(*persisted)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
error = published ? "Cannot read newly initialized NAND setting.txt" :
|
||||||
|
"Cannot persist NAND setting.txt; check NAND directory permissions";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace RuntimeNandSettings
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
#include <iomanip>
|
#include <iomanip>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
|
#include <map>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -89,6 +90,8 @@ struct RuntimeUserConfig {
|
|||||||
// comma-separated SDL-style physical button names ("south", or
|
// comma-separated SDL-style physical button names ("south", or
|
||||||
// "dpad_up,left_shoulder") as values; pressing either bound button counts.
|
// "dpad_up,left_shoulder") as values; pressing either bound button counts.
|
||||||
std::array<std::optional<std::string>, 12> controllerButtons;
|
std::array<std::optional<std::string>, 12> controllerButtons;
|
||||||
|
std::optional<bool> rumbleEnabled;
|
||||||
|
std::map<std::string, std::string> controllerExpressions;
|
||||||
};
|
};
|
||||||
|
|
||||||
namespace RuntimeConfigFile {
|
namespace RuntimeConfigFile {
|
||||||
@@ -407,6 +410,17 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) {
|
|||||||
FindConfigValue<std::string>(document, "controller", buttonKeys[index]);
|
FindConfigValue<std::string>(document, "controller", buttonKeys[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
config.rumbleEnabled = FindConfigValue<bool>(document, "controller", "rumble");
|
||||||
|
|
||||||
|
if (const auto* section = document.contains("controller") ? &document.at("controller") : nullptr;
|
||||||
|
section != nullptr && section->is_table()) {
|
||||||
|
for (const auto& [key, value] : section->as_table()) {
|
||||||
|
if (key.rfind("expr_", 0) == 0 && value.is_string()) {
|
||||||
|
config.controllerExpressions[key] = value.as_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
config.widescreen = FindConfigValue<bool>(document, "video", "widescreen");
|
config.widescreen = FindConfigValue<bool>(document, "video", "widescreen");
|
||||||
config.windowPosX = FindConfigInt(document, "video", "window_x");
|
config.windowPosX = FindConfigInt(document, "video", "window_x");
|
||||||
config.windowPosY = FindConfigInt(document, "video", "window_y");
|
config.windowPosY = FindConfigInt(document, "video", "window_y");
|
||||||
@@ -677,6 +691,25 @@ inline bool SetControllerButton(size_t index, std::string value) {
|
|||||||
return WriteSetting("controller", kControllerButtonKeys[index], FormatString(value));
|
return WriteSetting("controller", kControllerButtonKeys[index], FormatString(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inline std::string ControllerExpression(const std::string& key) {
|
||||||
|
const auto it = Get().controllerExpressions.find(key);
|
||||||
|
return it == Get().controllerExpressions.end() ? std::string() : it->second;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool SetControllerExpression(const std::string& key, const std::string& value) {
|
||||||
|
Mutable().controllerExpressions[key] = value;
|
||||||
|
return WriteSetting("controller", key, FormatString(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool RumbleEnabled(bool fallback = true) {
|
||||||
|
return Get().rumbleEnabled.value_or(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool SetRumbleEnabled(bool value) {
|
||||||
|
Mutable().rumbleEnabled = value;
|
||||||
|
return WriteSetting("controller", "rumble", value ? "true" : "false");
|
||||||
|
}
|
||||||
|
|
||||||
inline bool SetAudioVolume(float value) {
|
inline bool SetAudioVolume(float value) {
|
||||||
value = std::clamp(value, 0.0f, 1.0f);
|
value = std::clamp(value, 0.0f, 1.0f);
|
||||||
Mutable().audioVolume = value;
|
Mutable().audioVolume = value;
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <charconv>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string_view>
|
||||||
|
#include <system_error>
|
||||||
|
|
||||||
|
namespace RuntimeScSerial {
|
||||||
|
|
||||||
|
// SCGetProductSN's output is a u32, not a character buffer. DWC loads
|
||||||
|
// that word and formats it with the product code to construct csnum.
|
||||||
|
template <typename RangeValidator, typename WordWriter>
|
||||||
|
uint32_t Write(std::string_view serial, uint32_t address,
|
||||||
|
RangeValidator&& contains, WordWriter&& write32) {
|
||||||
|
if (serial.empty() || serial.size() > 9 ||
|
||||||
|
serial.find_first_not_of("0123456789") != std::string_view::npos) return 0;
|
||||||
|
uint32_t number = 0;
|
||||||
|
const auto parsed = std::from_chars(serial.data(), serial.data() + serial.size(), number);
|
||||||
|
if (parsed.ec != std::errc{} || parsed.ptr != serial.data() + serial.size() ||
|
||||||
|
!address || !contains(address, sizeof(uint32_t))) return 0;
|
||||||
|
write32(address, number);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace RuntimeScSerial
|
||||||
@@ -256,8 +256,12 @@ bool GuestFiberManager::CreateGuestFiber(uint32_t guestThreadAddr, uint32_t entr
|
|||||||
gf.cpuContext.srr0 = entryPoint;
|
gf.cpuContext.srr0 = entryPoint;
|
||||||
|
|
||||||
// The host stack models only translated host calls; the guest stack starts
|
// The host stack models only translated host calls; the guest stack starts
|
||||||
// at stackBase in the CPU context above.
|
// at stackBase in the CPU context above. 64 KiB is too small for deep
|
||||||
constexpr size_t kHostStackSize = 64 * 1024;
|
// translated/HLE call chains (notably NW4R's sound worker), and on macOS
|
||||||
|
// it can exhaust the guarded coroutine stack as unrelated host work (such
|
||||||
|
// as a window resize) adds a little more nesting. Keep enough headroom for
|
||||||
|
// those chains while the guest stack remains separately bounded.
|
||||||
|
constexpr size_t kHostStackSize = 1024 * 1024;
|
||||||
gf.fiber = HostContext::Create(kHostStackSize, FiberProc,
|
gf.fiber = HostContext::Create(kHostStackSize, FiberProc,
|
||||||
reinterpret_cast<void*>(static_cast<uintptr_t>(guestThreadAddr)));
|
reinterpret_cast<void*>(static_cast<uintptr_t>(guestThreadAddr)));
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,75 @@
|
|||||||
#include "hle_stubs.h"
|
#include "hle_stubs.h"
|
||||||
#include "memory.h"
|
#include "memory.h"
|
||||||
#include "hle/controller_status_contract.h"
|
#include "hle/controller_status_contract.h"
|
||||||
|
#include "input_bindings.h"
|
||||||
#include "wii_remote_input.h"
|
#include "wii_remote_input.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <atomic>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
||||||
|
#include <SDL3/SDL_gamepad.h>
|
||||||
#include <dolphin/pad.h>
|
#include <dolphin/pad.h>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
|
std::atomic<bool> g_rumbleEnabled{true};
|
||||||
|
|
||||||
|
bool NativeButtonHeld(SDL_Gamepad* gamepad, uint32_t nativeButton) {
|
||||||
|
if (gamepad == nullptr || nativeButton == PAD_NATIVE_BUTTON_INVALID ||
|
||||||
|
nativeButton >= SDL_GAMEPAD_BUTTON_COUNT) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return SDL_GetGamepadButton(gamepad, static_cast<SDL_GamepadButton>(nativeButton));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A digital button bound to L or R has no analog travel of its own. On real
|
||||||
|
// hardware the click only engages at full depression, so report a full pull.
|
||||||
|
void FillTriggersHeldByButtons(PADStatus* statuses) {
|
||||||
|
if (InputBindings::InputBlocked()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (uint32_t port = 0; port < PAD_CHANMAX; ++port) {
|
||||||
|
if (statuses[port].err != PAD_ERR_NONE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const s32 index = PADGetIndexForPort(port);
|
||||||
|
if (index < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
SDL_Gamepad* gamepad = PADGetSDLGamepadForIndex(static_cast<u32>(index));
|
||||||
|
if (gamepad == nullptr) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const auto scan = [&](PADButtonMapping* mappings, u32 count) {
|
||||||
|
if (mappings == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (u32 i = 0; i < count; ++i) {
|
||||||
|
const PADButtonMapping& mapping = mappings[i];
|
||||||
|
if (mapping.padButton != PAD_TRIGGER_L && mapping.padButton != PAD_TRIGGER_R) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!NativeButtonHeld(gamepad, mapping.nativeButton)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (mapping.padButton == PAD_TRIGGER_L) {
|
||||||
|
statuses[port].triggerLeft = 255;
|
||||||
|
} else {
|
||||||
|
statuses[port].triggerRight = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
u32 count = 0;
|
||||||
|
scan(PADGetButtonMappings(port, &count), count);
|
||||||
|
count = 0;
|
||||||
|
scan(PADGetAltButtonMappings(port, &count), count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void WritePadStatus(uint32_t base, const PADStatus& status) {
|
void WritePadStatus(uint32_t base, const PADStatus& status) {
|
||||||
const auto guestStatus = PadStatusContract::Encode({
|
const auto guestStatus = PadStatusContract::Encode({
|
||||||
status.button,
|
status.button,
|
||||||
@@ -32,6 +89,11 @@ void WritePadStatus(uint32_t base, const PADStatus& status) {
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
extern "C" void PAD_HLE_SetRumbleEnabled(bool enabled)
|
||||||
|
{
|
||||||
|
g_rumbleEnabled.store(enabled, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
extern "C" uint32_t PAD__Init_HLE()
|
extern "C" uint32_t PAD__Init_HLE()
|
||||||
{
|
{
|
||||||
return PADInit() ? 1u : 0u;
|
return PADInit() ? 1u : 0u;
|
||||||
@@ -54,6 +116,9 @@ extern "C" uint32_t PAD__Read_HLE(uint32_t statusPtr)
|
|||||||
// between "connected" and "no controller" every time the overlay toggles.
|
// between "connected" and "no controller" every time the overlay toggles.
|
||||||
WiiRemoteInput::HideRemotesFromPad(statuses, PAD_CHANMAX);
|
WiiRemoteInput::HideRemotesFromPad(statuses, PAD_CHANMAX);
|
||||||
|
|
||||||
|
FillTriggersHeldByButtons(statuses);
|
||||||
|
InputBindings::Apply(statuses);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (uint32_t i = 0; i < PAD_CHANMAX; ++i) {
|
for (uint32_t i = 0; i < PAD_CHANMAX; ++i) {
|
||||||
WritePadStatus(statusPtr + static_cast<uint32_t>(i * PadStatusContract::kGuestStatusSize),
|
WritePadStatus(statusPtr + static_cast<uint32_t>(i * PadStatusContract::kGuestStatusSize),
|
||||||
@@ -81,6 +146,9 @@ PPC_NATIVE_OVERRIDE(801AF1E4, PAD__Recalibrate_HLE, uint32_t, (uint32_t mask), (
|
|||||||
|
|
||||||
extern "C" void PAD__ControlMotor_HLE(int32_t chan, uint32_t command)
|
extern "C" void PAD__ControlMotor_HLE(int32_t chan, uint32_t command)
|
||||||
{
|
{
|
||||||
|
if (command == PAD_MOTOR_RUMBLE && !g_rumbleEnabled.load(std::memory_order_relaxed)) {
|
||||||
|
command = PAD_MOTOR_STOP;
|
||||||
|
}
|
||||||
PADControlMotor(chan, command);
|
PADControlMotor(chan, command);
|
||||||
}
|
}
|
||||||
PPC_NATIVE_OVERRIDE_VOID(801AF908, PAD__ControlMotor_HLE, (int32_t chan, uint32_t command), (chan, command));
|
PPC_NATIVE_OVERRIDE_VOID(801AF908, PAD__ControlMotor_HLE, (int32_t chan, uint32_t command), (chan, command));
|
||||||
|
|||||||
@@ -499,7 +499,7 @@ int32_t HandleIpTopIoctlv(uint32_t cmd, const std::vector<IoVector>& in, const s
|
|||||||
// Nonblocking sockets get -SO_EAGAIN immediately (Dolphin's retry predicate
|
// Nonblocking sockets get -SO_EAGAIN immediately (Dolphin's retry predicate
|
||||||
// short-circuits on nonBlock/forceNonBlock, IOS/Network/Socket.cpp:715-718);
|
// short-circuits on nonBlock/forceNonBlock, IOS/Network/Socket.cpp:715-718);
|
||||||
// waiting here anyway stalled the whole emulation thread on every empty read.
|
// waiting here anyway stalled the whole emulation thread on every empty read.
|
||||||
constexpr int kStreamRecvWaitMs = 250;
|
constexpr int kStreamRecvWaitMs = 1000;
|
||||||
const int streamWaitMs = (forceNonBlock || s->nonblocking) ? 0 : kStreamRecvWaitMs;
|
const int streamWaitMs = (forceNonBlock || s->nonblocking) ? 0 : kStreamRecvWaitMs;
|
||||||
const bool waited = ret < 0 && !fromPtr && s->type == SOCK_STREAM &&
|
const bool waited = ret < 0 && !fromPtr && s->type == SOCK_STREAM &&
|
||||||
IsWouldBlockError(nativeErr) && WaitForReadable(s->native, streamWaitMs);
|
IsWouldBlockError(nativeErr) && WaitForReadable(s->native, streamWaitMs);
|
||||||
|
|||||||
@@ -78,22 +78,38 @@ bool ProcessSleepTimers(CpuContext* cpu)
|
|||||||
{
|
{
|
||||||
using Clock = std::chrono::steady_clock;
|
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();
|
const auto now = Clock::now();
|
||||||
|
while (processedCount < kMaxTimersPerCall) {
|
||||||
|
SleepTimerEntry timer{0, {}};
|
||||||
|
bool found = false;
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(gSleepTimerMutex);
|
std::lock_guard<std::mutex> lock(gSleepTimerMutex);
|
||||||
auto it = gSleepTimers.begin();
|
for (auto it = gSleepTimers.begin(); it != gSleepTimers.end(); ++it) {
|
||||||
while (it != gSleepTimers.end()) {
|
if (it->deadline <= now) {
|
||||||
if (it->deadline > now) {
|
timer = *it;
|
||||||
++it;
|
gSleepTimers.erase(it);
|
||||||
continue;
|
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;
|
const uint32_t threadPtr = timer.threadPtr;
|
||||||
if (threadPtr == 0 ||
|
if (threadPtr == 0 ||
|
||||||
!Memory::Contains(threadPtr + kThreadSuspendOffset, sizeof(uint32_t))) {
|
!Memory::Contains(threadPtr + kThreadSuspendOffset, sizeof(uint32_t))) {
|
||||||
@@ -219,7 +235,7 @@ bool ProcessSleepTimers(CpuContext* cpu)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return !dueTimers.empty();
|
return processedAny;
|
||||||
}
|
}
|
||||||
} // namespace OsHleInternal
|
} // namespace OsHleInternal
|
||||||
|
|
||||||
|
|||||||
+33
-21
@@ -1,6 +1,7 @@
|
|||||||
#include "hle_stubs.h"
|
#include "hle_stubs.h"
|
||||||
|
|
||||||
#include "console_identity.h"
|
#include "console_identity.h"
|
||||||
|
#include "sc_serial_contract.h"
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
@@ -12,7 +13,25 @@
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
constexpr uint32_t kPalProductRegion = 2;
|
// Use the SDK's own value tables, including its unknown-region result.
|
||||||
|
uint32_t LookupProductRegion(uint32_t table, uint32_t stride, uint32_t count,
|
||||||
|
const std::string& value) {
|
||||||
|
for (uint32_t index = 0; index < count; ++index) {
|
||||||
|
const uint32_t entry = table + index * stride;
|
||||||
|
if (!Memory::Contains(entry, stride)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const auto* bytes = static_cast<const uint8_t*>(Memory::GetPointer(entry, stride));
|
||||||
|
if (bytes[0] == 0xFF) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (value.size() < stride - 1 &&
|
||||||
|
std::memcmp(bytes + 1, value.c_str(), value.size() + 1) == 0) {
|
||||||
|
return bytes[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0xFFFFFFFFu;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
@@ -50,16 +69,12 @@ extern "C" uint32_t SCGetEuRgb60Mode_HLE()
|
|||||||
|
|
||||||
PPC_NATIVE_OVERRIDE(801B1CAC, SCGetEuRgb60Mode_HLE, uint32_t, (), ());
|
PPC_NATIVE_OVERRIDE(801B1CAC, SCGetEuRgb60Mode_HLE, uint32_t, (), ());
|
||||||
|
|
||||||
// The managed NAND intentionally starts without a console-owned setting.txt.
|
// Expose the selected emulated NAND identity through the SDK SC APIs.
|
||||||
// DWC nevertheless requires the Wii product code and serial number so it can
|
|
||||||
// include csnum in NAS authentication. Expose one stable virtual-console
|
|
||||||
// identity without requiring or mutating a user's real NAND.
|
|
||||||
|
|
||||||
extern "C" uint32_t SCGetProductArea_HLE()
|
extern "C" uint32_t SCGetProductArea_HLE()
|
||||||
{
|
{
|
||||||
// The PAL setting.txt AREA value is "EUR". The SDK's lookup table at
|
return LookupProductRegion(0x8029CEB0u, 5, 13,
|
||||||
// 0x8029CEB0 maps JPN=0, USA=1, EUR=2.
|
RuntimeConsoleIdentity::Current().area);
|
||||||
return kPalProductRegion;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PPC_NATIVE_OVERRIDE(801B23A0, SCGetProductArea_HLE, uint32_t, (), ());
|
PPC_NATIVE_OVERRIDE(801B23A0, SCGetProductArea_HLE, uint32_t, (), ());
|
||||||
@@ -68,12 +83,13 @@ extern "C" uint32_t SCGetProductCode_HLE()
|
|||||||
{
|
{
|
||||||
// Original PAL SC storage for the six-byte CODE value.
|
// Original PAL SC storage for the six-byte CODE value.
|
||||||
constexpr uint32_t kProductCodeAddress = 0x803869E0u;
|
constexpr uint32_t kProductCodeAddress = 0x803869E0u;
|
||||||
static constexpr char kProductCode[] = "LEH";
|
const std::string& productCode = RuntimeConsoleIdentity::Current().productCode;
|
||||||
if (!Memory::Contains(kProductCodeAddress, sizeof(kProductCode))) {
|
const size_t size = productCode.size() + 1;
|
||||||
|
if (!Memory::Contains(kProductCodeAddress, size)) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
std::memcpy(Memory::GetPointer(kProductCodeAddress, sizeof(kProductCode)),
|
std::memcpy(Memory::GetPointer(kProductCodeAddress, size),
|
||||||
kProductCode, sizeof(kProductCode));
|
productCode.c_str(), size);
|
||||||
return kProductCodeAddress;
|
return kProductCodeAddress;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,21 +98,17 @@ PPC_NATIVE_OVERRIDE(801B2424, SCGetProductCode_HLE, uint32_t, (), ());
|
|||||||
extern "C" uint32_t SCGetProductSN_HLE(uint32_t serialAddress)
|
extern "C" uint32_t SCGetProductSN_HLE(uint32_t serialAddress)
|
||||||
{
|
{
|
||||||
const std::string& serial = RuntimeConsoleIdentity::Current().serial;
|
const std::string& serial = RuntimeConsoleIdentity::Current().serial;
|
||||||
if (!serialAddress || !Memory::Contains(serialAddress, serial.size() + 1)) {
|
return RuntimeScSerial::Write(serial, serialAddress,
|
||||||
return 0;
|
[](uint32_t address, size_t size) { return Memory::Contains(address, size); },
|
||||||
}
|
[](uint32_t address, uint32_t value) { Memory::Write32(address, value); });
|
||||||
std::memcpy(Memory::GetPointer(serialAddress, serial.size() + 1),
|
|
||||||
serial.c_str(), serial.size() + 1);
|
|
||||||
return 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PPC_NATIVE_OVERRIDE(801B2460, SCGetProductSN_HLE, uint32_t, (uint32_t serialAddress), (serialAddress));
|
PPC_NATIVE_OVERRIDE(801B2460, SCGetProductSN_HLE, uint32_t, (uint32_t serialAddress), (serialAddress));
|
||||||
|
|
||||||
extern "C" uint32_t SCGetProductGameRegion_HLE()
|
extern "C" uint32_t SCGetProductGameRegion_HLE()
|
||||||
{
|
{
|
||||||
// The PAL setting.txt GAME value is "EU". The SDK's own lookup table at
|
return LookupProductRegion(0x8029CEF8u, 4, 4,
|
||||||
// 0x8029CEF8 maps JP=0, US=1, EU=2.
|
RuntimeConsoleIdentity::Current().gameRegion);
|
||||||
return kPalProductRegion;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PPC_NATIVE_OVERRIDE(801B24C8, SCGetProductGameRegion_HLE, uint32_t, (), ());
|
PPC_NATIVE_OVERRIDE(801B24C8, SCGetProductGameRegion_HLE, uint32_t, (), ());
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ extern "C" int32_t NANDOpen_HLE(uint32_t pathPtr, uint32_t fileInfoPtr, uint32_t
|
|||||||
|
|
||||||
const std::filesystem::path hostPath = TranslateNandPath(path);
|
const std::filesystem::path hostPath = TranslateNandPath(path);
|
||||||
|
|
||||||
|
if (const auto result = NandCheckSystemSaveRead("NANDOpen", hostPath, mode))
|
||||||
|
return *result;
|
||||||
|
|
||||||
// Existing-file write opens go through a shadow copy seeded from the original, so a
|
// Existing-file write opens go through a shadow copy seeded from the original, so a
|
||||||
// crash between NANDWrite and NANDClose cannot leave a torn file (the game patches
|
// crash between NANDWrite and NANDClose cannot leave a torn file (the game patches
|
||||||
// sub-ranges, e.g. ghost saves at a non-zero offset). New files still create in place.
|
// sub-ranges, e.g. ghost saves at a non-zero offset). New files still create in place.
|
||||||
|
|||||||
@@ -411,6 +411,8 @@ extern "C" int32_t NANDSafeOpen_HLE(uint32_t pathPtr, uint32_t fileInfoPtr, uint
|
|||||||
if (mode == 1) {
|
if (mode == 1) {
|
||||||
// Read-only safe open reads the original in place; the library builds no scratch
|
// Read-only safe open reads the original in place; the library builds no scratch
|
||||||
// copy for this case.
|
// copy for this case.
|
||||||
|
if (const auto result = NandCheckSystemSaveRead("NANDSafeOpen", hostPath, mode))
|
||||||
|
return *result;
|
||||||
FILE* file = NandFopen(hostPath, "rb");
|
FILE* file = NandFopen(hostPath, "rb");
|
||||||
if (!file && IsFaceLibResourcePath(path) && SeedFaceLibResource(hostPath)) {
|
if (!file && IsFaceLibResourcePath(path) && SeedFaceLibResource(hostPath)) {
|
||||||
file = NandFopen(hostPath, "rb");
|
file = NandFopen(hostPath, "rb");
|
||||||
|
|||||||
@@ -411,6 +411,26 @@ bool IsFaceLibResourcePath(const char* path) {
|
|||||||
return std::strcmp(path, "/shared2/menu/FaceLib/RFL_Res.dat") == 0;
|
return std::strcmp(path, "/shared2/menu/FaceLib/RFL_Res.dat") == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::optional<int32_t> NandCheckSystemSaveRead(const char* who,
|
||||||
|
const std::filesystem::path& hostPath, int mode, bool ios) {
|
||||||
|
const auto action = RuntimeNandSave::CheckRead(hostPath, mode);
|
||||||
|
if (action == RuntimeNandSave::ReadAction::Proceed) return std::nullopt;
|
||||||
|
if (action == RuntimeNandSave::ReadAction::Missing) {
|
||||||
|
LogNandWarning(who, "treating empty or zero-filled system save '%s' as missing",
|
||||||
|
HostPathText(hostPath).c_str());
|
||||||
|
return ios ? ISFS_ENOENT : NAND_RESULT_NOEXISTS;
|
||||||
|
}
|
||||||
|
if (action == RuntimeNandSave::ReadAction::RecoveryNeeded) {
|
||||||
|
LogNandError(who, "system save '%s' is missing or blank but its .nandsafe.tmp contains data; "
|
||||||
|
"back up both files before attempting recovery",
|
||||||
|
HostPathText(hostPath).c_str());
|
||||||
|
} else {
|
||||||
|
LogNandError(who, "could not inspect system save '%s' or its write shadow; leaving data untouched",
|
||||||
|
HostPathText(hostPath).c_str());
|
||||||
|
}
|
||||||
|
return ios ? ISFS_EIO : NAND_RESULT_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
// Create directories recursively
|
// Create directories recursively
|
||||||
bool CreateDirectoryPath(const std::filesystem::path& path) {
|
bool CreateDirectoryPath(const std::filesystem::path& path) {
|
||||||
if (path.empty()) {
|
if (path.empty()) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include "hle/runtime_parse_helpers.h"
|
#include "hle/runtime_parse_helpers.h"
|
||||||
#include "memory.h"
|
#include "memory.h"
|
||||||
#include "nand_path.h"
|
#include "nand_path.h"
|
||||||
|
#include "nand_save_probe.h"
|
||||||
#include "hle/net/network.h"
|
#include "hle/net/network.h"
|
||||||
#include "recomp_mod_loader.h"
|
#include "recomp_mod_loader.h"
|
||||||
#include "runtime_config.h"
|
#include "runtime_config.h"
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
#include <deque>
|
#include <deque>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
|
#include <optional>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -56,6 +58,11 @@ constexpr uint32_t kNandTitleIdLo = 0x524D4350; // "RMCP" fallback
|
|||||||
void LogNandError(const char* func, const char* fmt, ...);
|
void LogNandError(const char* func, const char* fmt, ...);
|
||||||
void LogNandWarning(const char* func, const char* fmt, ...);
|
void LogNandWarning(const char* func, const char* fmt, ...);
|
||||||
|
|
||||||
|
// An empty optional means continue opening normally; otherwise return the
|
||||||
|
// supplied NAND/IOS error without exposing a failed scan as a missing save.
|
||||||
|
std::optional<int32_t> NandCheckSystemSaveRead(const char* who,
|
||||||
|
const std::filesystem::path& hostPath, int mode, bool ios = false);
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// File Descriptor Management
|
// File Descriptor Management
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -392,6 +392,9 @@ extern "C" int32_t NAND_IOS_Open_HLE(uint32_t pathPtr, uint32_t mode) {
|
|||||||
// It's a NAND file path
|
// It's a NAND file path
|
||||||
const std::filesystem::path hostPath = TranslateNandPath(path);
|
const std::filesystem::path hostPath = TranslateNandPath(path);
|
||||||
|
|
||||||
|
if (const auto result = NandCheckSystemSaveRead("IOS_Open", hostPath, mode, true))
|
||||||
|
return *result;
|
||||||
|
|
||||||
// Seed FaceLib resources before the existence check so every open mode can
|
// Seed FaceLib resources before the existence check so every open mode can
|
||||||
// still find them on a fresh managed NAND.
|
// still find them on a fresh managed NAND.
|
||||||
if (!PathExists(hostPath) && IsFaceLibResourcePath(path)) {
|
if (!PathExists(hostPath) && IsFaceLibResourcePath(path)) {
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
#include "input_bindings.h"
|
||||||
|
|
||||||
|
#include "controller_button_names.h"
|
||||||
|
#include "input_expr.h"
|
||||||
|
#include "runtime_config.h"
|
||||||
|
#include "runtime_log.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <mutex>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
#include <SDL3/SDL_gamepad.h>
|
||||||
|
|
||||||
|
namespace InputBindings {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct Binding {
|
||||||
|
std::string text;
|
||||||
|
InputExpr::Expression expr;
|
||||||
|
bool active = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::mutex g_mutex;
|
||||||
|
std::array<std::array<Binding, kControls.size()>, PAD_CHANMAX> g_bindings;
|
||||||
|
bool g_anyBound = false;
|
||||||
|
bool g_inputBlocked = false;
|
||||||
|
|
||||||
|
// Dolphin input names, mapped onto SDL. XInput-style names are exact; DInput
|
||||||
|
// "Button <n>" indices follow the common PlayStation layout, which is what
|
||||||
|
// DInput reports for a DualShock/DualSense. Other pads may number differently.
|
||||||
|
const std::unordered_map<std::string, SDL_GamepadButton>& ButtonNames() {
|
||||||
|
static const std::unordered_map<std::string, SDL_GamepadButton> table = {
|
||||||
|
{"Button A", SDL_GAMEPAD_BUTTON_SOUTH}, {"Button B", SDL_GAMEPAD_BUTTON_EAST},
|
||||||
|
{"Button X", SDL_GAMEPAD_BUTTON_WEST}, {"Button Y", SDL_GAMEPAD_BUTTON_NORTH},
|
||||||
|
{"Shoulder L", SDL_GAMEPAD_BUTTON_LEFT_SHOULDER},
|
||||||
|
{"Shoulder R", SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER},
|
||||||
|
{"Thumb L", SDL_GAMEPAD_BUTTON_LEFT_STICK}, {"Thumb R", SDL_GAMEPAD_BUTTON_RIGHT_STICK},
|
||||||
|
{"Start", SDL_GAMEPAD_BUTTON_START}, {"Back", SDL_GAMEPAD_BUTTON_BACK},
|
||||||
|
{"Guide", SDL_GAMEPAD_BUTTON_GUIDE},
|
||||||
|
{"Pad N", SDL_GAMEPAD_BUTTON_DPAD_UP}, {"Pad S", SDL_GAMEPAD_BUTTON_DPAD_DOWN},
|
||||||
|
{"Pad W", SDL_GAMEPAD_BUTTON_DPAD_LEFT}, {"Pad E", SDL_GAMEPAD_BUTTON_DPAD_RIGHT},
|
||||||
|
{"Hat 0 N", SDL_GAMEPAD_BUTTON_DPAD_UP}, {"Hat 0 S", SDL_GAMEPAD_BUTTON_DPAD_DOWN},
|
||||||
|
{"Hat 0 W", SDL_GAMEPAD_BUTTON_DPAD_LEFT}, {"Hat 0 E", SDL_GAMEPAD_BUTTON_DPAD_RIGHT},
|
||||||
|
{"Button 0", SDL_GAMEPAD_BUTTON_WEST}, {"Button 1", SDL_GAMEPAD_BUTTON_SOUTH},
|
||||||
|
{"Button 2", SDL_GAMEPAD_BUTTON_EAST}, {"Button 3", SDL_GAMEPAD_BUTTON_NORTH},
|
||||||
|
{"Button 4", SDL_GAMEPAD_BUTTON_LEFT_SHOULDER},
|
||||||
|
{"Button 5", SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER},
|
||||||
|
{"Button 8", SDL_GAMEPAD_BUTTON_BACK}, {"Button 9", SDL_GAMEPAD_BUTTON_START},
|
||||||
|
{"Button 10", SDL_GAMEPAD_BUTTON_LEFT_STICK},
|
||||||
|
{"Button 11", SDL_GAMEPAD_BUTTON_RIGHT_STICK},
|
||||||
|
{"Button 12", SDL_GAMEPAD_BUTTON_GUIDE}, {"Button 13", SDL_GAMEPAD_BUTTON_TOUCHPAD},
|
||||||
|
};
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signed axis names: SDL axis plus the direction that counts as positive.
|
||||||
|
struct AxisRef {
|
||||||
|
SDL_GamepadAxis axis;
|
||||||
|
int sign;
|
||||||
|
};
|
||||||
|
|
||||||
|
const std::unordered_map<std::string, AxisRef>& AxisNames() {
|
||||||
|
static const std::unordered_map<std::string, AxisRef> table = {
|
||||||
|
{"Axis X-", {SDL_GAMEPAD_AXIS_LEFTX, -1}}, {"Axis X+", {SDL_GAMEPAD_AXIS_LEFTX, 1}},
|
||||||
|
{"Axis Y-", {SDL_GAMEPAD_AXIS_LEFTY, -1}}, {"Axis Y+", {SDL_GAMEPAD_AXIS_LEFTY, 1}},
|
||||||
|
{"Axis Z-", {SDL_GAMEPAD_AXIS_RIGHTX, -1}}, {"Axis Z+", {SDL_GAMEPAD_AXIS_RIGHTX, 1}},
|
||||||
|
{"Axis Zr-", {SDL_GAMEPAD_AXIS_RIGHTY, -1}},{"Axis Zr+", {SDL_GAMEPAD_AXIS_RIGHTY, 1}},
|
||||||
|
{"Left X-", {SDL_GAMEPAD_AXIS_LEFTX, -1}}, {"Left X+", {SDL_GAMEPAD_AXIS_LEFTX, 1}},
|
||||||
|
{"Left Y-", {SDL_GAMEPAD_AXIS_LEFTY, 1}}, {"Left Y+", {SDL_GAMEPAD_AXIS_LEFTY, -1}},
|
||||||
|
{"Right X-", {SDL_GAMEPAD_AXIS_RIGHTX, -1}},{"Right X+", {SDL_GAMEPAD_AXIS_RIGHTX, 1}},
|
||||||
|
{"Right Y-", {SDL_GAMEPAD_AXIS_RIGHTY, 1}}, {"Right Y+", {SDL_GAMEPAD_AXIS_RIGHTY, -1}},
|
||||||
|
{"Full Axis Xr+", {SDL_GAMEPAD_AXIS_LEFT_TRIGGER, 1}},
|
||||||
|
{"Full Axis Yr+", {SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, 1}},
|
||||||
|
{"Trigger L", {SDL_GAMEPAD_AXIS_LEFT_TRIGGER, 1}},
|
||||||
|
{"Trigger R", {SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, 1}},
|
||||||
|
};
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
double ReadInput(SDL_Gamepad* gamepad, const std::string& name) {
|
||||||
|
if (gamepad == nullptr) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
if (const auto it = ButtonNames().find(name); it != ButtonNames().end()) {
|
||||||
|
return SDL_GetGamepadButton(gamepad, it->second) ? 1.0 : 0.0;
|
||||||
|
}
|
||||||
|
if (const auto it = AxisNames().find(name); it != AxisNames().end()) {
|
||||||
|
const double raw = SDL_GetGamepadAxis(gamepad, it->second.axis) / 32767.0;
|
||||||
|
return std::clamp(raw * it->second.sign, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
// 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 (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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
SDL_Gamepad* GamepadForPort(uint32_t port) {
|
||||||
|
const s32 index = PADGetIndexForPort(port);
|
||||||
|
return index < 0 ? nullptr : PADGetSDLGamepadForIndex(static_cast<u32>(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t ControlIndexForDolphinName(const std::string& name) {
|
||||||
|
for (size_t i = 0; i < kControls.size(); ++i) {
|
||||||
|
if (name == kControls[i].dolphinName) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kControls.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RecomputeAnyBoundLocked() {
|
||||||
|
g_anyBound = false;
|
||||||
|
for (const auto& port : g_bindings) {
|
||||||
|
for (const auto& binding : port) {
|
||||||
|
if (!binding.expr.Empty()) {
|
||||||
|
g_anyBound = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string ConfigKey(uint32_t port, size_t control) {
|
||||||
|
std::string key = "expr_" + std::to_string(port + 1) + "_";
|
||||||
|
for (const char* c = kControls[control].dolphinName; *c != '\0'; ++c) {
|
||||||
|
key += (*c == '/' || *c == '-') ? '_' : static_cast<char>(std::tolower(*c));
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void SetInputBlocked(bool blocked) noexcept {
|
||||||
|
std::lock_guard<std::mutex> lock(g_mutex);
|
||||||
|
g_inputBlocked = blocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool InputBlocked() noexcept {
|
||||||
|
std::lock_guard<std::mutex> lock(g_mutex);
|
||||||
|
return g_inputBlocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Reload() noexcept {
|
||||||
|
std::lock_guard<std::mutex> lock(g_mutex);
|
||||||
|
for (uint32_t port = 0; port < PAD_CHANMAX; ++port) {
|
||||||
|
for (size_t control = 0; control < kControls.size(); ++control) {
|
||||||
|
Binding& binding = g_bindings[port][control];
|
||||||
|
binding = Binding{};
|
||||||
|
binding.text = RuntimeConfigFile::ControllerExpression(ConfigKey(port, control));
|
||||||
|
std::string error;
|
||||||
|
if (!binding.text.empty() &&
|
||||||
|
!InputExpr::Expression::Parse(binding.text, binding.expr, error)) {
|
||||||
|
RT_LOG(RT_TAG_CONFIG) << "expression for port " << (port + 1) << " "
|
||||||
|
<< kControls[control].dolphinName << ": " << error << std::endl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RecomputeAnyBoundLocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Apply(PADStatus* statuses) noexcept {
|
||||||
|
if (statuses == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::lock_guard<std::mutex> lock(g_mutex);
|
||||||
|
if (!g_anyBound) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bool blocked = g_inputBlocked;
|
||||||
|
for (uint32_t port = 0; port < PAD_CHANMAX; ++port) {
|
||||||
|
if (statuses[port].err != PAD_ERR_NONE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
SDL_Gamepad* gamepad = GamepadForPort(port);
|
||||||
|
const InputExpr::InputSource source = [gamepad](const std::string& name) {
|
||||||
|
return ReadInput(gamepad, name);
|
||||||
|
};
|
||||||
|
for (size_t control = 0; control < kControls.size(); ++control) {
|
||||||
|
Binding& binding = g_bindings[port][control];
|
||||||
|
if (binding.expr.Empty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (blocked) {
|
||||||
|
binding.active = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const double value = binding.expr.Evaluate(source);
|
||||||
|
binding.active = value > InputExpr::kConditionThreshold;
|
||||||
|
const ControlInfo& info = kControls[control];
|
||||||
|
if (info.padButton != 0 && binding.active) {
|
||||||
|
statuses[port].button |= info.padButton;
|
||||||
|
}
|
||||||
|
if (info.analog != 0) {
|
||||||
|
const double safe = std::isfinite(value) ? std::clamp(value, 0.0, 1.0) : 0.0;
|
||||||
|
const auto scaled = static_cast<uint8_t>(safe * 255.0);
|
||||||
|
uint8_t& target =
|
||||||
|
info.analog == 1 ? statuses[port].triggerLeft : statuses[port].triggerRight;
|
||||||
|
target = std::max(target, scaled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string GetExpression(uint32_t port, size_t control) noexcept {
|
||||||
|
if (port >= PAD_CHANMAX || control >= kControls.size()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
std::lock_guard<std::mutex> lock(g_mutex);
|
||||||
|
return g_bindings[port][control].text;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SetExpression(uint32_t port, size_t control, const std::string& text, std::string& error) noexcept {
|
||||||
|
if (port >= PAD_CHANMAX || control >= kControls.size()) {
|
||||||
|
error = "invalid control";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
InputExpr::Expression parsed;
|
||||||
|
if (!InputExpr::Expression::Parse(text, parsed, error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(g_mutex);
|
||||||
|
Binding& binding = g_bindings[port][control];
|
||||||
|
binding.text = text;
|
||||||
|
binding.expr = std::move(parsed);
|
||||||
|
binding.active = false;
|
||||||
|
RecomputeAnyBoundLocked();
|
||||||
|
}
|
||||||
|
RuntimeConfigFile::SetControllerExpression(ConfigKey(port, control), text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsActive(uint32_t port, size_t control) noexcept {
|
||||||
|
if (port >= PAD_CHANMAX || control >= kControls.size()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::lock_guard<std::mutex> lock(g_mutex);
|
||||||
|
return g_bindings[port][control].active;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string DefaultDolphinConfigPath() noexcept {
|
||||||
|
std::error_code ec;
|
||||||
|
if (const char* appdata = std::getenv("APPDATA"); appdata != nullptr) {
|
||||||
|
const std::filesystem::path roaming =
|
||||||
|
std::filesystem::path(appdata) / "Dolphin Emulator" / "Config" / "GCPadNew.ini";
|
||||||
|
if (std::filesystem::exists(roaming, ec)) {
|
||||||
|
return RuntimeConfigFile::PathToUtf8(roaming);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const auto executableDirectory = RuntimeConfigFile::ExecutableDirectory();
|
||||||
|
return RuntimeConfigFile::PathToUtf8(executableDirectory ? *executableDirectory / "GCPadNew.ini"
|
||||||
|
: std::filesystem::path("GCPadNew.ini"));
|
||||||
|
}
|
||||||
|
|
||||||
|
int ImportDolphinConfig(const std::string& path, int padIndex, uint32_t port, std::string& summary,
|
||||||
|
std::string& error) noexcept {
|
||||||
|
std::vector<std::pair<std::string, std::string>> controls;
|
||||||
|
std::string device;
|
||||||
|
if (!InputExpr::ReadDolphinConfig(RuntimeConfigFile::PathFromUtf8(path), padIndex, controls, device,
|
||||||
|
error)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int imported = 0;
|
||||||
|
std::vector<std::string> skipped;
|
||||||
|
for (const auto& [name, text] : controls) {
|
||||||
|
const size_t control = ControlIndexForDolphinName(name);
|
||||||
|
if (control == kControls.size()) {
|
||||||
|
if (name.rfind("Main Stick/", 0) == 0 || name.rfind("C-Stick/", 0) == 0) {
|
||||||
|
skipped.push_back(name);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
std::string parseError;
|
||||||
|
if (!SetExpression(port, control, text, parseError)) {
|
||||||
|
skipped.push_back(name);
|
||||||
|
RT_LOG(RT_TAG_CONFIG) << "import " << name << ": " << parseError << std::endl;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
++imported;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary = "Imported " + std::to_string(imported) + " controls";
|
||||||
|
if (!device.empty()) {
|
||||||
|
summary += " from " + device;
|
||||||
|
}
|
||||||
|
if (!skipped.empty()) {
|
||||||
|
summary += "; skipped " + std::to_string(skipped.size()) +
|
||||||
|
" (stick axes and unsupported inputs keep their existing mapping)";
|
||||||
|
}
|
||||||
|
return imported;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace InputBindings
|
||||||
@@ -0,0 +1,631 @@
|
|||||||
|
#include "input_expr.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <fstream>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
namespace InputExpr {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using Clock = std::chrono::steady_clock;
|
||||||
|
using FSec = std::chrono::duration<double>;
|
||||||
|
|
||||||
|
enum class Kind {
|
||||||
|
Literal, Input, Not, Add, Sub, Mul, Div, And, Or, Xor,
|
||||||
|
Greater, Less, Equal,
|
||||||
|
FnIf, FnMin, FnMax, FnClamp, FnAbs, FnSqrt, FnPow, FnSin, FnCos, FnTan,
|
||||||
|
FnDeadzone, FnTimer, FnToggle, FnHold, FnTap, FnPulse, FnSmooth, FnNot,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct FnInfo {
|
||||||
|
Kind kind;
|
||||||
|
int minArgs;
|
||||||
|
int maxArgs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const std::unordered_map<std::string, FnInfo>& FunctionTable() {
|
||||||
|
static const std::unordered_map<std::string, FnInfo> table = {
|
||||||
|
{"not", {Kind::FnNot, 1, 1}}, {"if", {Kind::FnIf, 3, 3}},
|
||||||
|
{"min", {Kind::FnMin, 2, 2}}, {"max", {Kind::FnMax, 2, 2}},
|
||||||
|
{"clamp", {Kind::FnClamp, 3, 3}}, {"abs", {Kind::FnAbs, 1, 1}},
|
||||||
|
{"sqrt", {Kind::FnSqrt, 1, 1}}, {"pow", {Kind::FnPow, 2, 2}},
|
||||||
|
{"sin", {Kind::FnSin, 1, 1}}, {"cos", {Kind::FnCos, 1, 1}},
|
||||||
|
{"tan", {Kind::FnTan, 1, 1}}, {"deadzone", {Kind::FnDeadzone, 2, 2}},
|
||||||
|
{"timer", {Kind::FnTimer, 1, 1}}, {"toggle", {Kind::FnToggle, 1, 2}},
|
||||||
|
{"hold", {Kind::FnHold, 2, 2}}, {"tap", {Kind::FnTap, 2, 3}},
|
||||||
|
{"pulse", {Kind::FnPulse, 2, 2}}, {"smooth", {Kind::FnSmooth, 2, 3}},
|
||||||
|
};
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
struct Node {
|
||||||
|
Kind kind;
|
||||||
|
double literal = 0.0;
|
||||||
|
std::string input;
|
||||||
|
std::vector<std::unique_ptr<Node>> args;
|
||||||
|
|
||||||
|
// Per-instance state for the stateful functions. Mutable because Evaluate
|
||||||
|
// is logically a read of current input state.
|
||||||
|
mutable bool released = false;
|
||||||
|
mutable bool state = false;
|
||||||
|
mutable unsigned taps = 0;
|
||||||
|
mutable double value = 0.0;
|
||||||
|
mutable Clock::time_point mark = Clock::now();
|
||||||
|
mutable bool marked = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// ---- tokenizer ----------------------------------------------------------
|
||||||
|
|
||||||
|
struct Token {
|
||||||
|
enum Type { End, Input, Number, Ident, Op, LParen, RParen, Comma } type = End;
|
||||||
|
std::string text;
|
||||||
|
};
|
||||||
|
|
||||||
|
class Lexer {
|
||||||
|
public:
|
||||||
|
explicit Lexer(const std::string& text) : m_text(text) {}
|
||||||
|
|
||||||
|
bool Next(Token& tok, std::string& error) {
|
||||||
|
while (m_pos < m_text.size() && std::isspace(static_cast<unsigned char>(m_text[m_pos]))) {
|
||||||
|
++m_pos;
|
||||||
|
}
|
||||||
|
if (m_pos >= m_text.size()) {
|
||||||
|
tok = Token{};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const char c = m_text[m_pos];
|
||||||
|
if (c == '`') {
|
||||||
|
const size_t close = m_text.find('`', m_pos + 1);
|
||||||
|
if (close == std::string::npos) {
|
||||||
|
error = "unterminated ` in expression";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
tok.type = Token::Input;
|
||||||
|
tok.text = m_text.substr(m_pos + 1, close - m_pos - 1);
|
||||||
|
m_pos = close + 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (std::isdigit(static_cast<unsigned char>(c)) || c == '.') {
|
||||||
|
size_t end = m_pos;
|
||||||
|
while (end < m_text.size() &&
|
||||||
|
(std::isdigit(static_cast<unsigned char>(m_text[end])) || m_text[end] == '.')) {
|
||||||
|
++end;
|
||||||
|
}
|
||||||
|
tok.type = Token::Number;
|
||||||
|
tok.text = m_text.substr(m_pos, end - m_pos);
|
||||||
|
m_pos = end;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (std::isalpha(static_cast<unsigned char>(c)) || c == '_') {
|
||||||
|
size_t end = m_pos;
|
||||||
|
while (end < m_text.size() &&
|
||||||
|
(std::isalnum(static_cast<unsigned char>(m_text[end])) || m_text[end] == '_' ||
|
||||||
|
m_text[end] == ' ')) {
|
||||||
|
++end;
|
||||||
|
}
|
||||||
|
// Trailing spaces belong to the separator, not the identifier.
|
||||||
|
while (end > m_pos && m_text[end - 1] == ' ') {
|
||||||
|
--end;
|
||||||
|
}
|
||||||
|
tok.type = Token::Ident;
|
||||||
|
tok.text = m_text.substr(m_pos, end - m_pos);
|
||||||
|
m_pos = end;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (c == '(') { tok.type = Token::LParen; ++m_pos; return true; }
|
||||||
|
if (c == ')') { tok.type = Token::RParen; ++m_pos; return true; }
|
||||||
|
if (c == ',') { tok.type = Token::Comma; ++m_pos; return true; }
|
||||||
|
if (std::string("!&|^+-*/><=").find(c) != std::string::npos) {
|
||||||
|
tok.type = Token::Op;
|
||||||
|
tok.text = std::string(1, c);
|
||||||
|
++m_pos;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
error = std::string("unexpected character '") + c + "' in expression";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t Position() const { return m_pos; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
const std::string& m_text;
|
||||||
|
size_t m_pos = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- parser -------------------------------------------------------------
|
||||||
|
|
||||||
|
using NodePtr = std::unique_ptr<Node>;
|
||||||
|
|
||||||
|
class Parser {
|
||||||
|
public:
|
||||||
|
explicit Parser(const std::string& text) : m_lexer(text) { Advance(); }
|
||||||
|
|
||||||
|
NodePtr ParseExpression(std::string& error) {
|
||||||
|
NodePtr node = ParseBinary(0, error);
|
||||||
|
if (!node) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
if (m_failed) {
|
||||||
|
error = m_lexError;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
if (m_tok.type != Token::End) {
|
||||||
|
error = "unexpected trailing input in expression";
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
void Advance() {
|
||||||
|
if (!m_lexer.Next(m_tok, m_lexError)) {
|
||||||
|
m_tok = Token{};
|
||||||
|
m_failed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int Precedence(const std::string& op) {
|
||||||
|
if (op == "|") return 1;
|
||||||
|
if (op == "^") return 2;
|
||||||
|
if (op == "&") return 3;
|
||||||
|
if (op == ">" || op == "<" || op == "=") return 4;
|
||||||
|
if (op == "+" || op == "-") return 5;
|
||||||
|
if (op == "*" || op == "/") return 6;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Kind BinaryKind(const std::string& op) {
|
||||||
|
if (op == "|") return Kind::Or;
|
||||||
|
if (op == "^") return Kind::Xor;
|
||||||
|
if (op == "&") return Kind::And;
|
||||||
|
if (op == ">") return Kind::Greater;
|
||||||
|
if (op == "<") return Kind::Less;
|
||||||
|
if (op == "=") return Kind::Equal;
|
||||||
|
if (op == "+") return Kind::Add;
|
||||||
|
if (op == "-") return Kind::Sub;
|
||||||
|
if (op == "*") return Kind::Mul;
|
||||||
|
return Kind::Div;
|
||||||
|
}
|
||||||
|
|
||||||
|
NodePtr ParseBinary(int minPrec, std::string& error) {
|
||||||
|
NodePtr lhs = ParseUnary(error);
|
||||||
|
if (!lhs) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
while (m_tok.type == Token::Op) {
|
||||||
|
const int prec = Precedence(m_tok.text);
|
||||||
|
if (prec < 0 || prec < minPrec) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const std::string op = m_tok.text;
|
||||||
|
Advance();
|
||||||
|
NodePtr rhs = ParseBinary(prec + 1, error);
|
||||||
|
if (!rhs) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
auto node = std::make_unique<Node>();
|
||||||
|
node->kind = BinaryKind(op);
|
||||||
|
node->args.push_back(std::move(lhs));
|
||||||
|
node->args.push_back(std::move(rhs));
|
||||||
|
lhs = std::move(node);
|
||||||
|
}
|
||||||
|
return lhs;
|
||||||
|
}
|
||||||
|
|
||||||
|
NodePtr ParseUnary(std::string& error) {
|
||||||
|
if (m_failed) {
|
||||||
|
error = m_lexError;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
if (m_tok.type == Token::Op && (m_tok.text == "!" || m_tok.text == "-" || m_tok.text == "+")) {
|
||||||
|
const std::string op = m_tok.text;
|
||||||
|
Advance();
|
||||||
|
NodePtr inner = ParseUnary(error);
|
||||||
|
if (!inner) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
if (op == "+") {
|
||||||
|
return inner;
|
||||||
|
}
|
||||||
|
auto node = std::make_unique<Node>();
|
||||||
|
if (op == "!") {
|
||||||
|
node->kind = Kind::Not;
|
||||||
|
node->args.push_back(std::move(inner));
|
||||||
|
} else {
|
||||||
|
node->kind = Kind::Sub;
|
||||||
|
auto zero = std::make_unique<Node>();
|
||||||
|
zero->kind = Kind::Literal;
|
||||||
|
node->args.push_back(std::move(zero));
|
||||||
|
node->args.push_back(std::move(inner));
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
return ParsePrimary(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
NodePtr ParsePrimary(std::string& error) {
|
||||||
|
if (m_failed) {
|
||||||
|
error = m_lexError;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
switch (m_tok.type) {
|
||||||
|
case Token::Input: {
|
||||||
|
auto node = std::make_unique<Node>();
|
||||||
|
node->kind = Kind::Input;
|
||||||
|
node->input = m_tok.text;
|
||||||
|
Advance();
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
case Token::Number: {
|
||||||
|
auto node = std::make_unique<Node>();
|
||||||
|
node->kind = Kind::Literal;
|
||||||
|
node->literal = std::strtod(m_tok.text.c_str(), nullptr);
|
||||||
|
Advance();
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
case Token::LParen: {
|
||||||
|
Advance();
|
||||||
|
NodePtr inner = ParseBinary(0, error);
|
||||||
|
if (!inner) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
if (m_tok.type != Token::RParen) {
|
||||||
|
error = "expected closing paren";
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
Advance();
|
||||||
|
return inner;
|
||||||
|
}
|
||||||
|
case Token::Ident: {
|
||||||
|
const std::string name = m_tok.text;
|
||||||
|
Advance();
|
||||||
|
if (m_tok.type != Token::LParen) {
|
||||||
|
// A bare identifier is an input name, as Dolphin allows for
|
||||||
|
// simple cases such as "Start" or "LSHIFT".
|
||||||
|
auto node = std::make_unique<Node>();
|
||||||
|
node->kind = Kind::Input;
|
||||||
|
node->input = name;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
const auto it = FunctionTable().find(name);
|
||||||
|
if (it == FunctionTable().end()) {
|
||||||
|
error = "unknown function '" + name + "'";
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
Advance();
|
||||||
|
auto node = std::make_unique<Node>();
|
||||||
|
node->kind = it->second.kind;
|
||||||
|
if (m_tok.type != Token::RParen) {
|
||||||
|
while (true) {
|
||||||
|
NodePtr arg = ParseBinary(0, error);
|
||||||
|
if (!arg) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
node->args.push_back(std::move(arg));
|
||||||
|
if (m_tok.type != Token::Comma) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Advance();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (m_tok.type != Token::RParen) {
|
||||||
|
error = "expected closing paren after " + name + " arguments";
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
Advance();
|
||||||
|
const int count = static_cast<int>(node->args.size());
|
||||||
|
if (count < it->second.minArgs || count > it->second.maxArgs) {
|
||||||
|
error = name + " takes " + std::to_string(it->second.minArgs) + " to " +
|
||||||
|
std::to_string(it->second.maxArgs) + " arguments";
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
error = "expected start of expression";
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Lexer m_lexer;
|
||||||
|
Token m_tok;
|
||||||
|
std::string m_lexError;
|
||||||
|
bool m_failed = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- evaluator ----------------------------------------------------------
|
||||||
|
|
||||||
|
double Eval(const Node& node, const InputSource& source);
|
||||||
|
|
||||||
|
double Arg(const Node& node, size_t index, const InputSource& source) {
|
||||||
|
return Eval(*node.args[index], source);
|
||||||
|
}
|
||||||
|
|
||||||
|
double Eval(const Node& node, const InputSource& source) {
|
||||||
|
switch (node.kind) {
|
||||||
|
case Kind::Literal: return node.literal;
|
||||||
|
case Kind::Input: return source ? source(node.input) : 0.0;
|
||||||
|
case Kind::Not:
|
||||||
|
case Kind::FnNot: return 1.0 - Arg(node, 0, source);
|
||||||
|
case Kind::Add: return Arg(node, 0, source) + Arg(node, 1, source);
|
||||||
|
case Kind::Sub: return Arg(node, 0, source) - Arg(node, 1, source);
|
||||||
|
case Kind::Mul: return Arg(node, 0, source) * Arg(node, 1, source);
|
||||||
|
case Kind::Div: {
|
||||||
|
// Both sides are evaluated even when the divisor is zero: the left
|
||||||
|
// subtree may hold stateful functions that need their frame update.
|
||||||
|
const double lhs = Arg(node, 0, source);
|
||||||
|
const double rhs = Arg(node, 1, source);
|
||||||
|
return rhs == 0.0 ? 0.0 : lhs / rhs;
|
||||||
|
}
|
||||||
|
case Kind::And: return std::min(Arg(node, 0, source), Arg(node, 1, source));
|
||||||
|
case Kind::Or: return std::max(Arg(node, 0, source), Arg(node, 1, source));
|
||||||
|
case Kind::Xor: {
|
||||||
|
const double a = Arg(node, 0, source);
|
||||||
|
const double b = Arg(node, 1, source);
|
||||||
|
return std::max(std::min(a, 1.0 - b), std::min(b, 1.0 - a));
|
||||||
|
}
|
||||||
|
case Kind::Greater: return Arg(node, 0, source) > Arg(node, 1, source) ? 1.0 : 0.0;
|
||||||
|
case Kind::Less: return Arg(node, 0, source) < Arg(node, 1, source) ? 1.0 : 0.0;
|
||||||
|
case Kind::Equal: return Arg(node, 0, source) == Arg(node, 1, source) ? 1.0 : 0.0;
|
||||||
|
case Kind::FnIf:
|
||||||
|
return Arg(node, 0, source) > kConditionThreshold ? Arg(node, 1, source) : Arg(node, 2, source);
|
||||||
|
case Kind::FnMin: return std::min(Arg(node, 0, source), Arg(node, 1, source));
|
||||||
|
case Kind::FnMax: return std::max(Arg(node, 0, source), Arg(node, 1, source));
|
||||||
|
case Kind::FnClamp: {
|
||||||
|
const double v = Arg(node, 0, source);
|
||||||
|
double lo = Arg(node, 1, source);
|
||||||
|
double hi = Arg(node, 2, source);
|
||||||
|
if (lo > hi) {
|
||||||
|
std::swap(lo, hi);
|
||||||
|
}
|
||||||
|
return std::clamp(v, lo, hi);
|
||||||
|
}
|
||||||
|
case Kind::FnAbs: return std::abs(Arg(node, 0, source));
|
||||||
|
case Kind::FnSqrt: return std::sqrt(Arg(node, 0, source));
|
||||||
|
case Kind::FnPow: return std::pow(Arg(node, 0, source), Arg(node, 1, source));
|
||||||
|
case Kind::FnSin: return std::sin(Arg(node, 0, source));
|
||||||
|
case Kind::FnCos: return std::cos(Arg(node, 0, source));
|
||||||
|
case Kind::FnTan: return std::tan(Arg(node, 0, source));
|
||||||
|
case Kind::FnDeadzone: {
|
||||||
|
const double v = Arg(node, 0, source);
|
||||||
|
const double dz = std::clamp(Arg(node, 1, source), 0.0, 0.999);
|
||||||
|
return std::copysign(std::max(0.0, std::abs(v) - dz) / (1.0 - dz), v);
|
||||||
|
}
|
||||||
|
case Kind::FnTimer: {
|
||||||
|
const auto now = Clock::now();
|
||||||
|
if (!node.marked) {
|
||||||
|
node.mark = now;
|
||||||
|
node.marked = true;
|
||||||
|
}
|
||||||
|
const double period = Arg(node, 0, source);
|
||||||
|
double progress = std::chrono::duration_cast<FSec>(now - node.mark).count() / period;
|
||||||
|
if (!std::isfinite(progress) || progress < 0.0) {
|
||||||
|
progress = 0.0;
|
||||||
|
node.mark = now;
|
||||||
|
} else if (progress >= 1.0) {
|
||||||
|
const double resets = std::floor(progress);
|
||||||
|
node.mark += std::chrono::duration_cast<Clock::duration>(FSec(period * resets));
|
||||||
|
progress -= resets;
|
||||||
|
}
|
||||||
|
return progress;
|
||||||
|
}
|
||||||
|
case Kind::FnToggle: {
|
||||||
|
const double inner = Arg(node, 0, source);
|
||||||
|
if (inner < kConditionThreshold) {
|
||||||
|
node.released = true;
|
||||||
|
} else if (node.released) {
|
||||||
|
node.released = false;
|
||||||
|
node.state = !node.state;
|
||||||
|
}
|
||||||
|
if (node.args.size() == 2 && Arg(node, 1, source) > kConditionThreshold) {
|
||||||
|
node.state = false;
|
||||||
|
}
|
||||||
|
return node.state ? 1.0 : 0.0;
|
||||||
|
}
|
||||||
|
case Kind::FnHold: {
|
||||||
|
const auto now = Clock::now();
|
||||||
|
if (!node.marked) {
|
||||||
|
node.mark = now;
|
||||||
|
node.marked = true;
|
||||||
|
}
|
||||||
|
const double input = Arg(node, 0, source);
|
||||||
|
if (input < kConditionThreshold) {
|
||||||
|
node.state = false;
|
||||||
|
node.mark = now;
|
||||||
|
} else if (!node.state) {
|
||||||
|
if (std::chrono::duration_cast<FSec>(now - node.mark).count() >= Arg(node, 1, source)) {
|
||||||
|
node.state = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return node.state ? 1.0 : 0.0;
|
||||||
|
}
|
||||||
|
case Kind::FnTap: {
|
||||||
|
const auto now = Clock::now();
|
||||||
|
if (!node.marked) {
|
||||||
|
node.mark = now;
|
||||||
|
node.marked = true;
|
||||||
|
}
|
||||||
|
const double elapsed = std::chrono::duration_cast<FSec>(now - node.mark).count();
|
||||||
|
const double input = Arg(node, 0, source);
|
||||||
|
const bool timeUp = elapsed > Arg(node, 1, source);
|
||||||
|
// The count is user authored, so a negative or huge value must not
|
||||||
|
// reach the unsigned conversion.
|
||||||
|
double requested = node.args.size() == 3 ? Arg(node, 2, source) : 2.0;
|
||||||
|
if (!std::isfinite(requested)) {
|
||||||
|
requested = 2.0;
|
||||||
|
}
|
||||||
|
const auto desired = static_cast<unsigned>(std::clamp(requested + 0.5, 1.0, 64.0));
|
||||||
|
if (input < kConditionThreshold) {
|
||||||
|
node.released = true;
|
||||||
|
if (node.taps > 0 && timeUp) {
|
||||||
|
node.taps = 0;
|
||||||
|
}
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
if (node.released) {
|
||||||
|
if (node.taps == 0) {
|
||||||
|
node.mark = now;
|
||||||
|
}
|
||||||
|
++node.taps;
|
||||||
|
node.released = false;
|
||||||
|
}
|
||||||
|
return desired == node.taps ? 1.0 : 0.0;
|
||||||
|
}
|
||||||
|
case Kind::FnPulse: {
|
||||||
|
const auto now = Clock::now();
|
||||||
|
const double input = Arg(node, 0, source);
|
||||||
|
if (input < kConditionThreshold) {
|
||||||
|
node.released = true;
|
||||||
|
} else if (node.released) {
|
||||||
|
node.released = false;
|
||||||
|
const double requested = Arg(node, 1, source);
|
||||||
|
const double safe = std::isfinite(requested) ? std::clamp(requested, 0.0, 3600.0) : 0.0;
|
||||||
|
const auto seconds = std::chrono::duration_cast<Clock::duration>(FSec(safe));
|
||||||
|
if (node.state) {
|
||||||
|
node.mark += seconds;
|
||||||
|
} else {
|
||||||
|
node.state = true;
|
||||||
|
node.mark = now + seconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.state && now >= node.mark) {
|
||||||
|
node.state = false;
|
||||||
|
}
|
||||||
|
return node.state ? 1.0 : 0.0;
|
||||||
|
}
|
||||||
|
case Kind::FnSmooth: {
|
||||||
|
const auto now = Clock::now();
|
||||||
|
if (!node.marked) {
|
||||||
|
node.mark = now;
|
||||||
|
node.marked = true;
|
||||||
|
}
|
||||||
|
const double elapsed = std::chrono::duration_cast<FSec>(now - node.mark).count();
|
||||||
|
node.mark = now;
|
||||||
|
const double desired = Arg(node, 0, source);
|
||||||
|
const double up = Arg(node, 1, source);
|
||||||
|
const double down = node.args.size() == 3 ? Arg(node, 2, source) : up;
|
||||||
|
const double rate = (desired < node.value) ? down : up;
|
||||||
|
const double maxMove = elapsed / rate;
|
||||||
|
if (!std::isfinite(maxMove)) {
|
||||||
|
node.value = desired;
|
||||||
|
} else {
|
||||||
|
const double diff = desired - node.value;
|
||||||
|
node.value += std::copysign(std::min(maxMove, std::abs(diff)), diff);
|
||||||
|
}
|
||||||
|
return node.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Collect(const Node& node, std::vector<std::string>& out) {
|
||||||
|
if (node.kind == Kind::Input) {
|
||||||
|
if (std::find(out.begin(), out.end(), node.input) == out.end()) {
|
||||||
|
out.push_back(node.input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const auto& arg : node.args) {
|
||||||
|
Collect(*arg, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Trim(const std::string& text) {
|
||||||
|
const size_t begin = text.find_first_not_of(" \t\r\n");
|
||||||
|
if (begin == std::string::npos) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return text.substr(begin, text.find_last_not_of(" \t\r\n") - begin + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Expression::Expression() = default;
|
||||||
|
Expression::~Expression() = default;
|
||||||
|
Expression::Expression(Expression&&) noexcept = default;
|
||||||
|
Expression& Expression::operator=(Expression&&) noexcept = default;
|
||||||
|
|
||||||
|
bool Expression::Parse(const std::string& text, Expression& out, std::string& error) {
|
||||||
|
out.m_root.reset();
|
||||||
|
if (Trim(text).empty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Parser parser(text);
|
||||||
|
NodePtr root = parser.ParseExpression(error);
|
||||||
|
if (!root) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
out.m_root = std::move(root);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
double Expression::Evaluate(const InputSource& source) const {
|
||||||
|
if (m_root == nullptr) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
const double value = Eval(*m_root, source);
|
||||||
|
return std::isfinite(value) ? value : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> Expression::ReferencedInputs() const {
|
||||||
|
std::vector<std::string> out;
|
||||||
|
if (m_root) {
|
||||||
|
Collect(*m_root, out);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ReadDolphinConfig(const std::filesystem::path& path, int padIndex,
|
||||||
|
std::vector<std::pair<std::string, std::string>>& controls,
|
||||||
|
std::string& deviceName, std::string& error) {
|
||||||
|
std::ifstream file(path);
|
||||||
|
if (!file) {
|
||||||
|
error = "could not open " + path.string();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const std::string wanted = "[GCPad" + std::to_string(padIndex) + "]";
|
||||||
|
bool inSection = false;
|
||||||
|
bool found = false;
|
||||||
|
std::string line;
|
||||||
|
controls.clear();
|
||||||
|
deviceName.clear();
|
||||||
|
while (std::getline(file, line)) {
|
||||||
|
const std::string trimmed = Trim(line);
|
||||||
|
if (trimmed.empty() || trimmed[0] == '#' || trimmed[0] == ';') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (trimmed.front() == '[') {
|
||||||
|
inSection = trimmed == wanted;
|
||||||
|
found = found || inSection;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!inSection) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const size_t eq = trimmed.find('=');
|
||||||
|
if (eq == std::string::npos) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const std::string key = Trim(trimmed.substr(0, eq));
|
||||||
|
const std::string value = Trim(trimmed.substr(eq + 1));
|
||||||
|
if (key == "Device") {
|
||||||
|
deviceName = value;
|
||||||
|
} else if (!value.empty()) {
|
||||||
|
controls.emplace_back(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!found) {
|
||||||
|
error = wanted + " not found in " + path.string();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace InputExpr
|
||||||
+436
-123
@@ -1,6 +1,8 @@
|
|||||||
#include "settings_overlay.h"
|
#include "settings_overlay.h"
|
||||||
#include "audio_backend.h"
|
#include "audio_backend.h"
|
||||||
|
#include "controller_button_names.h"
|
||||||
#include "controller_mapping_wizard.h"
|
#include "controller_mapping_wizard.h"
|
||||||
|
#include "input_bindings.h"
|
||||||
#include "game_graphics_options.h"
|
#include "game_graphics_options.h"
|
||||||
#include "music_attenuation.h"
|
#include "music_attenuation.h"
|
||||||
#include "runtime_config.h"
|
#include "runtime_config.h"
|
||||||
@@ -18,6 +20,7 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
|
#include <charconv>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
@@ -34,6 +37,8 @@
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#include <dolphin/pad.h>
|
#include <dolphin/pad.h>
|
||||||
|
|
||||||
|
extern "C" void PAD_HLE_SetRumbleEnabled(bool enabled);
|
||||||
#include <dolphin/vi.h>
|
#include <dolphin/vi.h>
|
||||||
#include <aurora/aurora.h>
|
#include <aurora/aurora.h>
|
||||||
#include <aurora/gfx.h>
|
#include <aurora/gfx.h>
|
||||||
@@ -65,6 +70,7 @@ const char* GraphicsApiDisplayName() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool g_topBarVisible = false;
|
bool g_topBarVisible = false;
|
||||||
|
bool g_rumbleEnabled = RuntimeConfigFile::RumbleEnabled(true);
|
||||||
int g_controllerPort = 0;
|
int g_controllerPort = 0;
|
||||||
float g_resolutionScale = RuntimeConfigFile::ResolutionMultiplier(1.0f);
|
float g_resolutionScale = RuntimeConfigFile::ResolutionMultiplier(1.0f);
|
||||||
int g_audioVolumePercent = static_cast<int>(std::lround(RuntimeConfigFile::AudioVolume(1.0f) * 100.0f));
|
int g_audioVolumePercent = static_cast<int>(std::lround(RuntimeConfigFile::AudioVolume(1.0f) * 100.0f));
|
||||||
@@ -106,62 +112,9 @@ std::array<int32_t, PAD_MAX_CONTROLLERS> g_configuredControllerIndices = [] {
|
|||||||
return indices;
|
return indices;
|
||||||
}();
|
}();
|
||||||
|
|
||||||
struct ControllerButtonItem {
|
using ControllerNames::kNativeButtons;
|
||||||
const char* configKey;
|
using ControllerNames::NativeButtonItem;
|
||||||
const char* label;
|
constexpr const auto& kControllerButtons = ControllerNames::kGameCubeButtons;
|
||||||
PADButton padButton;
|
|
||||||
};
|
|
||||||
|
|
||||||
constexpr std::array<ControllerButtonItem, PAD_BUTTON_COUNT> kControllerButtons = {{
|
|
||||||
{"a", "A", PAD_BUTTON_A},
|
|
||||||
{"b", "B", PAD_BUTTON_B},
|
|
||||||
{"x", "X", PAD_BUTTON_X},
|
|
||||||
{"y", "Y", PAD_BUTTON_Y},
|
|
||||||
{"start", "Start", PAD_BUTTON_START},
|
|
||||||
{"z", "Z", PAD_TRIGGER_Z},
|
|
||||||
{"l", "L", PAD_TRIGGER_L},
|
|
||||||
{"r", "R", PAD_TRIGGER_R},
|
|
||||||
{"up", "D-pad Up", PAD_BUTTON_UP},
|
|
||||||
{"down", "D-pad Down", PAD_BUTTON_DOWN},
|
|
||||||
{"left", "D-pad Left", PAD_BUTTON_LEFT},
|
|
||||||
{"right", "D-pad Right", PAD_BUTTON_RIGHT},
|
|
||||||
}};
|
|
||||||
|
|
||||||
struct NativeButtonItem {
|
|
||||||
const char* configName;
|
|
||||||
const char* label;
|
|
||||||
uint32_t nativeButton;
|
|
||||||
};
|
|
||||||
|
|
||||||
constexpr std::array<NativeButtonItem, SDL_GAMEPAD_BUTTON_COUNT + 1> kNativeButtons = {{
|
|
||||||
{"unmapped", "Unmapped / analog trigger", 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},
|
|
||||||
{"north", "North (Y / Triangle)", SDL_GAMEPAD_BUTTON_NORTH},
|
|
||||||
{"back", "Back / Select", SDL_GAMEPAD_BUTTON_BACK},
|
|
||||||
{"guide", "Guide / Home", SDL_GAMEPAD_BUTTON_GUIDE},
|
|
||||||
{"start", "Start / Options", SDL_GAMEPAD_BUTTON_START},
|
|
||||||
{"left_stick", "Left stick click", SDL_GAMEPAD_BUTTON_LEFT_STICK},
|
|
||||||
{"right_stick", "Right stick click", SDL_GAMEPAD_BUTTON_RIGHT_STICK},
|
|
||||||
{"left_shoulder", "Left shoulder", SDL_GAMEPAD_BUTTON_LEFT_SHOULDER},
|
|
||||||
{"right_shoulder", "Right shoulder", SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER},
|
|
||||||
{"dpad_up", "D-pad Up", SDL_GAMEPAD_BUTTON_DPAD_UP},
|
|
||||||
{"dpad_down", "D-pad Down", SDL_GAMEPAD_BUTTON_DPAD_DOWN},
|
|
||||||
{"dpad_left", "D-pad Left", SDL_GAMEPAD_BUTTON_DPAD_LEFT},
|
|
||||||
{"dpad_right", "D-pad Right", SDL_GAMEPAD_BUTTON_DPAD_RIGHT},
|
|
||||||
{"misc1", "Misc 1 / Share", SDL_GAMEPAD_BUTTON_MISC1},
|
|
||||||
{"right_paddle1", "Right paddle 1", SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1},
|
|
||||||
{"left_paddle1", "Left paddle 1", SDL_GAMEPAD_BUTTON_LEFT_PADDLE1},
|
|
||||||
{"right_paddle2", "Right paddle 2", SDL_GAMEPAD_BUTTON_RIGHT_PADDLE2},
|
|
||||||
{"left_paddle2", "Left paddle 2", SDL_GAMEPAD_BUTTON_LEFT_PADDLE2},
|
|
||||||
{"touchpad", "Touchpad", SDL_GAMEPAD_BUTTON_TOUCHPAD},
|
|
||||||
{"misc2", "Misc 2", SDL_GAMEPAD_BUTTON_MISC2},
|
|
||||||
{"misc3", "Misc 3 / GC L click", SDL_GAMEPAD_BUTTON_MISC3},
|
|
||||||
{"misc4", "Misc 4 / GC R click", SDL_GAMEPAD_BUTTON_MISC4},
|
|
||||||
{"misc5", "Misc 5", SDL_GAMEPAD_BUTTON_MISC5},
|
|
||||||
{"misc6", "Misc 6", SDL_GAMEPAD_BUTTON_MISC6},
|
|
||||||
}};
|
|
||||||
|
|
||||||
// Classic Controller Pro layout, indexed like kControllerButtons: the SNES-style
|
// Classic Controller Pro layout, indexed like kControllerButtons: the SNES-style
|
||||||
// diamond (A right, B bottom, X top, Y left) with digital bumpers driving the GC
|
// diamond (A right, B bottom, X top, Y left) with digital bumpers driving the GC
|
||||||
@@ -178,6 +131,13 @@ constexpr std::array<const char*, PAD_BUTTON_COUNT> kClassicProPreset = {
|
|||||||
"dpad_up", "dpad_down", "dpad_left", "dpad_right",
|
"dpad_up", "dpad_down", "dpad_left", "dpad_right",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// PlayStation layout: bumpers drive the GC triggers, Z moves to Create/Share.
|
||||||
|
constexpr std::array<const char*, PAD_BUTTON_COUNT> kPlayStationPreset = {
|
||||||
|
"south", "east", "west", "north", "start", "back",
|
||||||
|
"left_shoulder", "right_shoulder",
|
||||||
|
"dpad_up", "dpad_down", "dpad_left", "dpad_right",
|
||||||
|
};
|
||||||
|
|
||||||
struct ResolutionItem {
|
struct ResolutionItem {
|
||||||
const char* label;
|
const char* label;
|
||||||
float scale;
|
float scale;
|
||||||
@@ -225,11 +185,18 @@ void LimitResolutionForFrameRate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const NativeButtonItem* FindNativeButton(std::string value) {
|
using ControllerNames::FindNativeButton;
|
||||||
const auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(), [&](const NativeButtonItem& item) {
|
|
||||||
return value == item.configName;
|
uint32_t ConfiguredNativeButton(const NativeButtonItem& item, const std::string& token) {
|
||||||
});
|
if (!PADIsAxisButton(item.nativeButton)) return item.nativeButton;
|
||||||
return it == kNativeButtons.end() ? nullptr : &*it;
|
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 {
|
struct ControllerBindingPair {
|
||||||
@@ -237,32 +204,26 @@ struct ControllerBindingPair {
|
|||||||
std::string secondary;
|
std::string secondary;
|
||||||
};
|
};
|
||||||
|
|
||||||
std::string TrimBindingToken(const std::string& token) {
|
|
||||||
const size_t begin = token.find_first_not_of(" \t");
|
|
||||||
if (begin == std::string::npos) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
const size_t end = token.find_last_not_of(" \t");
|
|
||||||
return token.substr(begin, end - begin + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Config values hold up to two comma-separated button names ("dpad_up" or
|
// Config values hold up to two comma-separated button names ("dpad_up" or
|
||||||
// "dpad_up,left_shoulder"); pressing either one counts as the GC button.
|
// "dpad_up,left_shoulder"); pressing either one counts as the GC button.
|
||||||
ControllerBindingPair SplitControllerBinding(const std::string& value) {
|
ControllerBindingPair SplitControllerBinding(const std::string& value) {
|
||||||
const size_t comma = value.find(',');
|
const size_t comma = value.find(',');
|
||||||
if (comma == std::string::npos) {
|
if (comma == std::string::npos) {
|
||||||
return {TrimBindingToken(value), {}};
|
return {ControllerNames::TrimToken(value), {}};
|
||||||
}
|
}
|
||||||
return {TrimBindingToken(value.substr(0, comma)), TrimBindingToken(value.substr(comma + 1))};
|
return {ControllerNames::TrimToken(value.substr(0, comma)), ControllerNames::TrimToken(value.substr(comma + 1))};
|
||||||
}
|
}
|
||||||
|
|
||||||
const NativeButtonItem& NativeButtonForValue(uint32_t nativeButton) {
|
using ControllerNames::NativeButtonForValue;
|
||||||
const auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(), [&](const NativeButtonItem& item) {
|
|
||||||
return nativeButton == item.nativeButton;
|
std::string NativeBindingConfig(uint32_t binding) {
|
||||||
});
|
std::string value = NativeButtonForValue(binding).configName;
|
||||||
return it == kNativeButtons.end() ? kNativeButtons.front() : *it;
|
if (PADIsAxisButton(binding)) value += '@' + std::to_string(PADAxisButtonThreshold(binding));
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void SetTopBarVisible(bool visible) {
|
void SetTopBarVisible(bool visible) {
|
||||||
if (g_topBarVisible == visible) {
|
if (g_topBarVisible == visible) {
|
||||||
return;
|
return;
|
||||||
@@ -301,7 +262,7 @@ void ApplyConfiguredMappings() {
|
|||||||
}
|
}
|
||||||
const ControllerBindingPair binding = SplitControllerBinding(*configured);
|
const ControllerBindingPair binding = SplitControllerBinding(*configured);
|
||||||
if (const NativeButtonItem* native = FindNativeButton(binding.primary)) {
|
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 {
|
} else {
|
||||||
RT_LOG(RT_TAG_CONFIG) << "Unknown controller." << kControllerButtons[i].configKey
|
RT_LOG(RT_TAG_CONFIG) << "Unknown controller." << kControllerButtons[i].configKey
|
||||||
<< " button '" << binding.primary << "'" << std::endl;
|
<< " button '" << binding.primary << "'" << std::endl;
|
||||||
@@ -309,7 +270,7 @@ void ApplyConfiguredMappings() {
|
|||||||
uint32_t altNative = PAD_NATIVE_BUTTON_INVALID;
|
uint32_t altNative = PAD_NATIVE_BUTTON_INVALID;
|
||||||
if (!binding.secondary.empty()) {
|
if (!binding.secondary.empty()) {
|
||||||
if (const NativeButtonItem* native = FindNativeButton(binding.secondary)) {
|
if (const NativeButtonItem* native = FindNativeButton(binding.secondary)) {
|
||||||
altNative = native->nativeButton;
|
altNative = ConfiguredNativeButton(*native, binding.secondary);
|
||||||
} else {
|
} else {
|
||||||
RT_LOG(RT_TAG_CONFIG) << "Unknown controller." << kControllerButtons[i].configKey
|
RT_LOG(RT_TAG_CONFIG) << "Unknown controller." << kControllerButtons[i].configKey
|
||||||
<< " secondary button '" << binding.secondary << "'" << std::endl;
|
<< " secondary button '" << binding.secondary << "'" << std::endl;
|
||||||
@@ -393,10 +354,8 @@ void DrawWiiRemoteSettings(uint32_t selectedGamePort) {
|
|||||||
}
|
}
|
||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (WiiRemoteInput::IsScanning() && WiiRemoteInput::PeriodicRescanEnabled()) {
|
if (WiiRemoteInput::IsScanning()) {
|
||||||
ImGui::TextDisabled("Scanning... (%u so far) - press 1+2 on the remote", WiiRemoteInput::ScanCount());
|
ImGui::TextDisabled("Scanning... (%u so far) - press 1+2 on the remote", WiiRemoteInput::ScanCount());
|
||||||
} else if (WiiRemoteInput::IsScanning()) {
|
|
||||||
ImGui::TextDisabled("Waiting for a remote - press 1+2 on the remote");
|
|
||||||
} else {
|
} else {
|
||||||
ImGui::TextDisabled("Not scanning");
|
ImGui::TextDisabled("Not scanning");
|
||||||
}
|
}
|
||||||
@@ -456,7 +415,314 @@ void DrawWiiRemoteSettings(uint32_t selectedGamePort) {
|
|||||||
ImGui::EndMenu();
|
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 };
|
||||||
|
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::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."
|
||||||
|
: "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 && (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) {
|
||||||
|
const std::string caption = std::string(KeyBindingName(scancode)) + "##binding";
|
||||||
|
if (ImGui::Button(caption.c_str(), ImVec2(220.0f, 0.0f))) BeginRebind(kind, target, label);
|
||||||
|
ImGui::SameLine();
|
||||||
|
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.
|
// Controller settings menu: port selection, controller assignment and button mapping.
|
||||||
|
int ExpressionResizeCallback(ImGuiInputTextCallbackData* data) {
|
||||||
|
if (data->EventFlag == ImGuiInputTextFlags_CallbackResize) {
|
||||||
|
auto* text = static_cast<std::string*>(data->UserData);
|
||||||
|
text->resize(static_cast<size_t>(data->BufTextLen));
|
||||||
|
data->Buf = text->data();
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DrawExpressionSettings() {
|
||||||
|
ImGui::SeparatorText("Expressions (Dolphin syntax)");
|
||||||
|
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 440.0f);
|
||||||
|
ImGui::TextDisabled(
|
||||||
|
"Optional. An expression overrides nothing: its result is combined with the "
|
||||||
|
"button mapping above. Operators ! & | ^ and functions if, min, max, clamp, "
|
||||||
|
"timer, toggle, hold, tap, pulse, smooth, deadzone behave as they do in Dolphin.");
|
||||||
|
ImGui::PopTextWrapPos();
|
||||||
|
|
||||||
|
static std::array<std::string, InputBindings::kControls.size()> errors;
|
||||||
|
static std::array<std::string, InputBindings::kControls.size()> buffers;
|
||||||
|
static std::string importStatus;
|
||||||
|
static int loadedPort = -1;
|
||||||
|
static bool reloadBuffers = true;
|
||||||
|
const auto port = static_cast<uint32_t>(g_controllerPort);
|
||||||
|
|
||||||
|
if (loadedPort != g_controllerPort || reloadBuffers) {
|
||||||
|
for (size_t i = 0; i < buffers.size(); ++i) {
|
||||||
|
buffers[i] = InputBindings::GetExpression(port, i);
|
||||||
|
}
|
||||||
|
errors.fill(std::string());
|
||||||
|
loadedPort = g_controllerPort;
|
||||||
|
reloadBuffers = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ImGui::Button("Import from Dolphin")) {
|
||||||
|
const std::string path = InputBindings::DefaultDolphinConfigPath();
|
||||||
|
std::string summary;
|
||||||
|
std::string error;
|
||||||
|
if (InputBindings::ImportDolphinConfig(path, g_controllerPort + 1, port, summary, error) < 0) {
|
||||||
|
importStatus = error;
|
||||||
|
} else {
|
||||||
|
importStatus = summary;
|
||||||
|
errors.fill(std::string());
|
||||||
|
reloadBuffers = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ImGui::IsItemHovered()) {
|
||||||
|
ImGui::SetTooltip("Reads [GCPad%d] from %%APPDATA%%\\Dolphin Emulator\\Config\\GCPadNew.ini,\n"
|
||||||
|
"or GCPadNew.ini next to the executable.", g_controllerPort + 1);
|
||||||
|
}
|
||||||
|
if (!importStatus.empty()) {
|
||||||
|
ImGui::TextDisabled("%s", importStatus.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (size_t i = 0; i < InputBindings::kControls.size(); ++i) {
|
||||||
|
ImGui::PushID(static_cast<int>(i) + 2000);
|
||||||
|
std::string& text = buffers[i];
|
||||||
|
ImGui::SetNextItemWidth(300.0f);
|
||||||
|
if (ImGui::InputText(InputBindings::kControls[i].label, text.data(), text.capacity() + 1,
|
||||||
|
ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CallbackResize,
|
||||||
|
ExpressionResizeCallback, &text)) {
|
||||||
|
std::string error;
|
||||||
|
errors[i] = InputBindings::SetExpression(port, i, text, error) ? std::string() : error;
|
||||||
|
}
|
||||||
|
if (InputBindings::IsActive(port, i)) {
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImGui::TextColored(ImVec4(0.4f, 0.9f, 0.4f, 1.0f), "active");
|
||||||
|
}
|
||||||
|
if (!errors[i].empty()) {
|
||||||
|
ImGui::TextColored(ImVec4(1.0f, 0.65f, 0.3f, 1.0f), "%s", errors[i].c_str());
|
||||||
|
}
|
||||||
|
ImGui::PopID();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DrawRumbleSettings() {
|
||||||
|
ImGui::SeparatorText("Vibration");
|
||||||
|
if (ImGui::Checkbox("Controller vibration", &g_rumbleEnabled)) {
|
||||||
|
PAD_HLE_SetRumbleEnabled(g_rumbleEnabled);
|
||||||
|
RuntimeConfigFile::SetRumbleEnabled(g_rumbleEnabled);
|
||||||
|
if (!g_rumbleEnabled) {
|
||||||
|
// Stop whatever is already running: the game will not send another
|
||||||
|
// motor command until its own state machine decides to.
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ImGui::IsItemHovered()) {
|
||||||
|
ImGui::SetTooltip("Applies to every port.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void DrawControllerSettings() {
|
void DrawControllerSettings() {
|
||||||
for (int port = 0; port < PAD_MAX_CONTROLLERS; ++port) {
|
for (int port = 0; port < PAD_MAX_CONTROLLERS; ++port) {
|
||||||
const std::string label = "Port " + std::to_string(port + 1);
|
const std::string label = "Port " + std::to_string(port + 1);
|
||||||
@@ -468,6 +734,10 @@ void DrawControllerSettings() {
|
|||||||
|
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
const uint32_t selectedGamePort = static_cast<uint32_t>(g_controllerPort);
|
const uint32_t selectedGamePort = static_cast<uint32_t>(g_controllerPort);
|
||||||
|
if (DrawKeyboardSettings(selectedGamePort)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ImGui::Separator();
|
||||||
const char* currentName = PADGetName(selectedGamePort);
|
const char* currentName = PADGetName(selectedGamePort);
|
||||||
ImGui::Text("Assigned: %s", currentName != nullptr ? currentName : "None");
|
ImGui::Text("Assigned: %s", currentName != nullptr ? currentName : "None");
|
||||||
if (ImGui::MenuItem("Unassign controller")) {
|
if (ImGui::MenuItem("Unassign controller")) {
|
||||||
@@ -509,10 +779,10 @@ void DrawControllerSettings() {
|
|||||||
PADGetAltButtonMappings(static_cast<uint32_t>(g_controllerPort), &altMappingCount);
|
PADGetAltButtonMappings(static_cast<uint32_t>(g_controllerPort), &altMappingCount);
|
||||||
|
|
||||||
const auto writeBinding = [](size_t index, uint32_t primaryNative, uint32_t altNative) {
|
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) {
|
if (altNative != PAD_NATIVE_BUTTON_INVALID) {
|
||||||
value += ',';
|
value += ',';
|
||||||
value += NativeButtonForValue(altNative).configName;
|
value += NativeBindingConfig(altNative);
|
||||||
}
|
}
|
||||||
RuntimeConfigFile::SetControllerButton(index, value);
|
RuntimeConfigFile::SetControllerButton(index, value);
|
||||||
};
|
};
|
||||||
@@ -545,23 +815,36 @@ void DrawControllerSettings() {
|
|||||||
PADSerializeMappings();
|
PADSerializeMappings();
|
||||||
mappings = PADGetButtonMappings(port, &mappingCount);
|
mappings = PADGetButtonMappings(port, &mappingCount);
|
||||||
}
|
}
|
||||||
ImGui::SameLine();
|
const auto applyPreset = [&](const std::array<const char*, PAD_BUTTON_COUNT>& preset) {
|
||||||
if (ImGui::Button("Classic Controller Pro")) {
|
|
||||||
const uint32_t port = static_cast<uint32_t>(g_controllerPort);
|
const uint32_t port = static_cast<uint32_t>(g_controllerPort);
|
||||||
for (size_t i = 0; i < kControllerButtons.size(); ++i) {
|
for (size_t i = 0; i < kControllerButtons.size(); ++i) {
|
||||||
if (const NativeButtonItem* native = FindNativeButton(kClassicProPreset[i])) {
|
if (const NativeButtonItem* native = FindNativeButton(preset[i])) {
|
||||||
PADSetButtonMapping(port, PADButtonMapping{native->nativeButton, kControllerButtons[i].padButton});
|
PADSetButtonMapping(port, PADButtonMapping{native->nativeButton, kControllerButtons[i].padButton});
|
||||||
PADSetAltButtonMapping(port,
|
PADSetAltButtonMapping(port,
|
||||||
PADButtonMapping{PAD_NATIVE_BUTTON_INVALID, kControllerButtons[i].padButton});
|
PADButtonMapping{PAD_NATIVE_BUTTON_INVALID, kControllerButtons[i].padButton});
|
||||||
RuntimeConfigFile::SetControllerButton(i, kClassicProPreset[i]);
|
RuntimeConfigFile::SetControllerButton(i, preset[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
altRowExpanded.fill(false);
|
altRowExpanded.fill(false);
|
||||||
PADSerializeMappings();
|
PADSerializeMappings();
|
||||||
mappings = PADGetButtonMappings(port, &mappingCount);
|
mappings = PADGetButtonMappings(port, &mappingCount);
|
||||||
|
};
|
||||||
|
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (ImGui::Button("Classic Controller Pro")) {
|
||||||
|
applyPreset(kClassicProPreset);
|
||||||
|
}
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (ImGui::Button("PlayStation")) {
|
||||||
|
applyPreset(kPlayStationPreset);
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::SeparatorText("Button mapping");
|
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) {
|
for (size_t i = 0; i < kControllerButtons.size(); ++i) {
|
||||||
auto mappingIt = std::find_if(mappings, mappings + mappingCount, [&](const PADButtonMapping& mapping) {
|
auto mappingIt = std::find_if(mappings, mappings + mappingCount, [&](const PADButtonMapping& mapping) {
|
||||||
return mapping.padButton == kControllerButtons[i].padButton;
|
return mapping.padButton == kControllerButtons[i].padButton;
|
||||||
@@ -581,30 +864,40 @@ void DrawControllerSettings() {
|
|||||||
|
|
||||||
const NativeButtonItem& current = NativeButtonForValue(mappingIt->nativeButton);
|
const NativeButtonItem& current = NativeButtonForValue(mappingIt->nativeButton);
|
||||||
ImGui::PushID(static_cast<int>(i));
|
ImGui::PushID(static_cast<int>(i));
|
||||||
ImGui::SetNextItemWidth(190.0f);
|
const auto drawThreshold = [&](PADButtonMapping* mapping, bool secondary) {
|
||||||
if (ImGui::BeginCombo("##primary", current.label)) {
|
if (!PADIsAxisButton(mapping->nativeButton)) return;
|
||||||
for (const auto& candidate : kNativeButtons) {
|
int threshold = static_cast<int>(PADAxisButtonThreshold(mapping->nativeButton));
|
||||||
const bool selected = candidate.nativeButton == mappingIt->nativeButton;
|
ImGui::SetNextItemWidth(bindingWidth);
|
||||||
if (ImGui::Selectable(candidate.label, selected)) {
|
if (ImGui::SliderInt(secondary ? "##altThreshold" : "##primaryThreshold", &threshold,
|
||||||
const uint32_t port = static_cast<uint32_t>(g_controllerPort);
|
1, 100, "Threshold: %d%%", ImGuiSliderFlags_AlwaysClamp)) {
|
||||||
PADSetButtonMapping(port, PADButtonMapping{candidate.nativeButton, kControllerButtons[i].padButton});
|
const PADButtonMapping updated = {
|
||||||
writeBinding(i, candidate.nativeButton,
|
PADAxisButtonIdentity(mapping->nativeButton) | (static_cast<uint32_t>(threshold) << 8),
|
||||||
|
mapping->padButton,
|
||||||
|
};
|
||||||
|
if (secondary) PADSetAltButtonMapping(selectedGamePort, updated);
|
||||||
|
else PADSetButtonMapping(selectedGamePort, updated);
|
||||||
|
}
|
||||||
|
if (ImGui::IsItemDeactivatedAfterEdit()) {
|
||||||
|
writeBinding(i, mappingIt->nativeButton,
|
||||||
altIt != nullptr ? altIt->nativeButton : PAD_NATIVE_BUTTON_INVALID);
|
altIt != nullptr ? altIt->nativeButton : PAD_NATIVE_BUTTON_INVALID);
|
||||||
PADSerializeMappings();
|
PADSerializeMappings();
|
||||||
mappings = PADGetButtonMappings(port, &mappingCount);
|
|
||||||
}
|
}
|
||||||
if (selected) {
|
};
|
||||||
ImGui::SetItemDefaultFocus();
|
ImGui::BeginGroup();
|
||||||
}
|
ImGui::SetNextItemWidth(bindingWidth);
|
||||||
}
|
const std::string primaryCaption = std::string(current.label) + "##primary";
|
||||||
ImGui::EndCombo();
|
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) {
|
if (altIt != nullptr) {
|
||||||
const bool altBound = altIt->nativeButton != PAD_NATIVE_BUTTON_INVALID;
|
const bool altBound = altIt->nativeButton != PAD_NATIVE_BUTTON_INVALID;
|
||||||
if (!altBound && !altRowExpanded[i]) {
|
if (!altBound && !altRowExpanded[i]) {
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ImGui::SmallButton("+")) {
|
if (ImGui::SmallButton("+")) {
|
||||||
altRowExpanded[i] = true;
|
altRowExpanded[i] = true;
|
||||||
|
BeginRebind(RebindKind::Controller, kControllerButtons[i].padButton, kControllerButtons[i].label, true);
|
||||||
}
|
}
|
||||||
if (ImGui::IsItemHovered()) {
|
if (ImGui::IsItemHovered()) {
|
||||||
ImGui::SetTooltip("Add a second binding; pressing either one works");
|
ImGui::SetTooltip("Add a second binding; pressing either one works");
|
||||||
@@ -613,33 +906,23 @@ void DrawControllerSettings() {
|
|||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
ImGui::TextUnformatted("or");
|
ImGui::TextUnformatted("or");
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
|
ImGui::BeginGroup();
|
||||||
const char* altLabel = altBound ? NativeButtonForValue(altIt->nativeButton).label : "None";
|
const char* altLabel = altBound ? NativeButtonForValue(altIt->nativeButton).label : "None";
|
||||||
ImGui::SetNextItemWidth(190.0f);
|
ImGui::SetNextItemWidth(bindingWidth);
|
||||||
if (ImGui::BeginCombo("##alt", altLabel)) {
|
const std::string altCaption = std::string(altLabel) + "##alt";
|
||||||
for (const auto& candidate : kNativeButtons) {
|
if (ImGui::Button(altCaption.c_str(), ImVec2(bindingWidth, 0.0f))) {
|
||||||
const bool isNone = candidate.nativeButton == PAD_NATIVE_BUTTON_INVALID;
|
BeginRebind(RebindKind::Controller, kControllerButtons[i].padButton, kControllerButtons[i].label, true);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
drawThreshold(altIt, true);
|
||||||
|
ImGui::EndGroup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
ImGui::TextUnformatted(kControllerButtons[i].label);
|
ImGui::TextUnformatted(kControllerButtons[i].label);
|
||||||
ImGui::PopID();
|
ImGui::PopID();
|
||||||
}
|
}
|
||||||
|
DrawExpressionSettings();
|
||||||
|
DrawRumbleSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
void DrawAudioSettings() {
|
void DrawAudioSettings() {
|
||||||
@@ -893,10 +1176,27 @@ void DrawStartupScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void DrawTopBar() {
|
void DrawTopBar() {
|
||||||
if (!g_topBarVisible || !ImGui::BeginMainMenuBar()) {
|
if (!g_topBarVisible) {
|
||||||
return;
|
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));
|
||||||
|
ImGui::SetNextWindowPos(ImVec2(viewport->Pos.x + viewport->Size.x * 0.5f,
|
||||||
|
viewport->Pos.y + viewport->Size.y - 24.0f),
|
||||||
|
ImGuiCond_Always, ImVec2(0.5f, 1.0f));
|
||||||
|
ImGui::SetNextWindowBgAlpha(0.85f);
|
||||||
|
if (ImGui::Begin("Settings input hint", nullptr,
|
||||||
|
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize |
|
||||||
|
ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoSavedSettings |
|
||||||
|
ImGuiWindowFlags_NoFocusOnAppearing)) {
|
||||||
|
ImGui::TextUnformatted("Settings open - game controls disabled. Press F10 to return to the game.");
|
||||||
|
}
|
||||||
|
ImGui::End();
|
||||||
|
if (!ImGui::BeginMainMenuBar()) return;
|
||||||
|
|
||||||
ImGui::TextUnformatted("WiiCompiled");
|
ImGui::TextUnformatted("WiiCompiled");
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
const auto resolutionIt = std::find_if(kResolutions.begin(), kResolutions.end(), [](const ResolutionItem& item) {
|
const auto resolutionIt = std::find_if(kResolutions.begin(), kResolutions.end(), [](const ResolutionItem& item) {
|
||||||
@@ -925,6 +1225,9 @@ void DrawTopBar() {
|
|||||||
|
|
||||||
if (ImGui::BeginMenu("Controller settings")) {
|
if (ImGui::BeginMenu("Controller settings")) {
|
||||||
DrawControllerSettings();
|
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();
|
ImGui::EndMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -994,6 +1297,8 @@ void PersistDisplayModeIfChanged() {
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void InitializeRuntimeSettings() noexcept {
|
void InitializeRuntimeSettings() noexcept {
|
||||||
|
PAD_HLE_SetRumbleEnabled(g_rumbleEnabled);
|
||||||
|
InputBindings::Reload();
|
||||||
controller_mapping_wizard::LoadPersistedMappings();
|
controller_mapping_wizard::LoadPersistedMappings();
|
||||||
ApplyConfiguredMappings();
|
ApplyConfiguredMappings();
|
||||||
AudioBackend::Instance().SetMasterVolume(static_cast<float>(g_audioVolumePercent) / 100.0f);
|
AudioBackend::Instance().SetMasterVolume(static_cast<float>(g_audioVolumePercent) / 100.0f);
|
||||||
@@ -1014,6 +1319,7 @@ void InitializeRuntimeSettings() noexcept {
|
|||||||
g_strapInputAccepted.store(false, std::memory_order_relaxed);
|
g_strapInputAccepted.store(false, std::memory_order_relaxed);
|
||||||
g_startupDismissFrame.store(UINT64_MAX, std::memory_order_relaxed);
|
g_startupDismissFrame.store(UINT64_MAX, std::memory_order_relaxed);
|
||||||
PADBlockInput(false);
|
PADBlockInput(false);
|
||||||
|
InputBindings::SetInputBlocked(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
void HandleEvents(const AuroraEvent* events) noexcept {
|
void HandleEvents(const AuroraEvent* events) noexcept {
|
||||||
@@ -1028,7 +1334,12 @@ void HandleEvents(const AuroraEvent* events) noexcept {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
controller_mapping_wizard::HandleSdlEvent(ev->sdl);
|
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);
|
SetTopBarVisible(!g_topBarVisible);
|
||||||
}
|
}
|
||||||
if (IsMouseActivity(ev->sdl)) {
|
if (IsMouseActivity(ev->sdl)) {
|
||||||
@@ -1056,7 +1367,9 @@ void Draw() noexcept {
|
|||||||
DrawTopBar();
|
DrawTopBar();
|
||||||
controller_mapping_wizard::Draw();
|
controller_mapping_wizard::Draw();
|
||||||
// The wizard captures raw presses; keep them out of the game.
|
// The wizard captures raw presses; keep them out of the game.
|
||||||
PADBlockInput(controller_mapping_wizard::IsActive());
|
const bool inputBlocked = controller_mapping_wizard::IsActive() || g_rebind.active;
|
||||||
|
PADBlockInput(inputBlocked);
|
||||||
|
InputBindings::SetInputBlocked(inputBlocked);
|
||||||
DrawStartupScreen();
|
DrawStartupScreen();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
#include "nand_save_probe.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <chrono>
|
||||||
|
#include <iostream>
|
||||||
|
#include <sstream>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
#include <windows.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using RuntimeNandSave::ReadAction;
|
||||||
|
using RuntimeNandSave::Contents;
|
||||||
|
|
||||||
|
static void Require(bool condition, const char* message) {
|
||||||
|
if (!condition) throw std::runtime_error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Write(const fs::path& path, const std::string& bytes) {
|
||||||
|
fs::create_directories(path.parent_path());
|
||||||
|
std::ofstream output(path, std::ios::binary);
|
||||||
|
output.write(bytes.data(), bytes.size());
|
||||||
|
output.close();
|
||||||
|
Require(static_cast<bool>(output), "Fixture write failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::string Read(const fs::path& path) {
|
||||||
|
std::ifstream input(path, std::ios::binary);
|
||||||
|
Require(static_cast<bool>(input), "Fixture read failed");
|
||||||
|
return {std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A disk error after zero-filled blocks must not look like a blank file's EOF.
|
||||||
|
class FailingDisk : public std::streambuf {
|
||||||
|
int blocks;
|
||||||
|
public:
|
||||||
|
explicit FailingDisk(int zeroBlocks) : blocks(zeroBlocks) {}
|
||||||
|
std::streamsize xsgetn(char* buffer, std::streamsize length) override {
|
||||||
|
if (blocks-- <= 0) throw std::runtime_error("injected read failure");
|
||||||
|
std::fill(buffer, buffer + length, '\0');
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
const auto root = fs::temp_directory_path() / ("wiicomp-save-scenarios-" +
|
||||||
|
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||||
|
try {
|
||||||
|
const auto save = root / "title/00010004/524d4350/data/rksys.dat";
|
||||||
|
const auto shadow = fs::path(save.native() + fs::path(".nandsafe.tmp").native());
|
||||||
|
// Save inspection must leave unrelated NAND data alone. Settings
|
||||||
|
// initialization is covered separately by nand_settings_tests.
|
||||||
|
const auto settingsPath = root / "title/00000001/00000002/data/setting.txt";
|
||||||
|
const std::string identity(256, '\x5a');
|
||||||
|
Write(settingsPath, identity);
|
||||||
|
Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::Proceed, "Fresh profile follows normal missing-file handling");
|
||||||
|
Require(!fs::exists(save), "Probing fresh profile must not create a save");
|
||||||
|
|
||||||
|
// First launch interrupted before save initialization, including block
|
||||||
|
// boundaries and a full-sized synthetic zero-filled allocation.
|
||||||
|
for (const size_t size : {size_t(0), size_t(1), size_t(4095), size_t(4096), size_t(4097), size_t(3 * 1024 * 1024)}) {
|
||||||
|
const std::string bytes(size, '\0');
|
||||||
|
Write(save, bytes);
|
||||||
|
Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::Missing, "Blank save should be offered first-save recovery");
|
||||||
|
Require(Read(save) == bytes, "Blank-save detection must not modify the file");
|
||||||
|
for (int mode : {2, 3}) {
|
||||||
|
Require(RuntimeNandSave::CheckRead(save, mode) == ReadAction::Proceed, "Write opens must remain available for initialization");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing saves, imported saves, partial/corrupt saves, and a zero
|
||||||
|
// prefix with data only in the final byte are all left to the game.
|
||||||
|
std::string existing(3 * 1024 * 1024, '\0');
|
||||||
|
existing.replace(0, 8, "RKSD0006");
|
||||||
|
existing[10000] = 42;
|
||||||
|
for (const std::string& bytes : {existing, std::string("RKSD"), std::string("damaged-header"),
|
||||||
|
std::string(8192, '\0') + "x", std::string(8191, '\0') + "x"}) {
|
||||||
|
Write(save, bytes);
|
||||||
|
Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::Proceed, "Never hide a save containing any data");
|
||||||
|
Require(Read(save) == bytes, "Existing/partial save must be byte-identical after inspection");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interrupted replacement: retain a committed original regardless of
|
||||||
|
// whether the shadow is blank, partial, or contains a complete header.
|
||||||
|
Write(save, existing);
|
||||||
|
for (const std::string& bytes : {std::string(), std::string(4096, '\0'), std::string("RKSD"), existing}) {
|
||||||
|
Write(shadow, bytes);
|
||||||
|
Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::Proceed, "Committed original takes precedence over write shadow");
|
||||||
|
Require(Read(save) == existing && Read(shadow) == bytes, "Probe must preserve both sides of an interrupted write");
|
||||||
|
}
|
||||||
|
// No usable original: do not let missing-save recovery discard the
|
||||||
|
// only possible recovery source, and do not auto-promote that shadow.
|
||||||
|
for (const bool mainExists : {false, true}) {
|
||||||
|
fs::remove(save);
|
||||||
|
if (mainExists) Write(save, std::string(4096, '\0'));
|
||||||
|
Write(shadow, existing);
|
||||||
|
Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::RecoveryNeeded, "Preserve recovery candidate when original is missing or blank");
|
||||||
|
Require(Read(shadow) == existing, "Recovery candidate must remain unchanged");
|
||||||
|
Require(fs::exists(save) == mainExists, "Do not promote shadow automatically");
|
||||||
|
}
|
||||||
|
Write(shadow, std::string(4096, '\0'));
|
||||||
|
Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::Missing, "Two blank files may use first-save recovery");
|
||||||
|
fs::remove(shadow);
|
||||||
|
|
||||||
|
for (const char* name : {"rksys.dat.bak", "rksys.dat.backup", "rksys.dat2", "banner.bin", "setting.txt"}) {
|
||||||
|
const auto unrelated = save.parent_path() / name;
|
||||||
|
Write(unrelated, std::string(4096, '\0'));
|
||||||
|
Require(RuntimeNandSave::CheckRead(unrelated, 1) == ReadAction::Proceed, "Do not classify backups or unrelated files as missing saves");
|
||||||
|
}
|
||||||
|
for (int blocks : {0, 1, 2}) {
|
||||||
|
FailingDisk disk(blocks);
|
||||||
|
std::istream input(&disk);
|
||||||
|
Require(RuntimeNandSave::InspectStream(input) == Contents::Error, "Read failure must remain an error, including after zero-filled blocks");
|
||||||
|
}
|
||||||
|
std::istringstream badEof;
|
||||||
|
badEof.setstate(std::ios::badbit | std::ios::eofbit);
|
||||||
|
Require(RuntimeNandSave::InspectStream(badEof) == Contents::Error, "Badbit plus EOF must not imply a blank save");
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
Write(save, existing);
|
||||||
|
const HANDLE locked = CreateFileW(save.c_str(), GENERIC_READ, 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||||
|
Require(locked != INVALID_HANDLE_VALUE, "Could not lock fixture");
|
||||||
|
const auto lockedResult = RuntimeNandSave::CheckRead(save, 1);
|
||||||
|
CloseHandle(locked);
|
||||||
|
Require(lockedResult == ReadAction::Error, "Sharing/access failure must not report a missing save");
|
||||||
|
Require(Read(save) == existing, "Locked save must survive inspection unchanged");
|
||||||
|
Require(SetFileAttributesW(save.c_str(), FILE_ATTRIBUTE_READONLY) != 0, "Set fixture read-only");
|
||||||
|
const auto readOnlyResult = RuntimeNandSave::CheckRead(save, 1);
|
||||||
|
SetFileAttributesW(save.c_str(), FILE_ATTRIBUTE_NORMAL);
|
||||||
|
Require(readOnlyResult == ReadAction::Proceed && Read(save) == existing, "Readable read-only save remains available");
|
||||||
|
#endif
|
||||||
|
Require(Read(settingsPath) == identity, "Save inspection must not change NAND settings");
|
||||||
|
fs::remove_all(root);
|
||||||
|
std::cout << "NAND save startup, preservation, interrupted-write and I/O failure scenarios passed\n";
|
||||||
|
return 0;
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
std::cerr << error.what() << " (fixtures retained at " << root << ")\n";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
#include "nand_settings.h"
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <iostream>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
static void Require(bool condition, const char* message = "NAND settings check failed") {
|
||||||
|
if (!condition) {
|
||||||
|
throw std::runtime_error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::string ReadBytes(const std::filesystem::path& path) {
|
||||||
|
std::ifstream input(path, std::ios::binary);
|
||||||
|
return {std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
const auto root = std::filesystem::temp_directory_path() /
|
||||||
|
("wiicomp-nand-settings-" + std::to_string(
|
||||||
|
std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||||
|
const auto path = root / "title/00000001/00000002/data/setting.txt";
|
||||||
|
try {
|
||||||
|
using namespace RuntimeNandSettings;
|
||||||
|
Require(GenerateSerial(1800000123) == "800000123", "Dolphin timestamp modulo");
|
||||||
|
Require(GenerateSerial(1000000001) == "000000001", "Dolphin leading zero padding");
|
||||||
|
Require(GenerateSerial(-1).empty(), "Invalid clock must not supply an identity");
|
||||||
|
// Golden bytes generated by Dolphin's unmodified SettingsHandler.cpp
|
||||||
|
// (upstream 2026-09-06), PAL boot fields and synthetic serial 000000001.
|
||||||
|
// Everything after this prefix is raw zero padding to 256 bytes.
|
||||||
|
const std::string goldenHex =
|
||||||
|
"bba6ac929a0bc96b7eed83d27f33a1e7e73d9b836d8b47c59ee23df6b275baab"
|
||||||
|
"bec9d9dead03cc7a3bdafee50c30ab9fb86194e119fe4ba19eff62d5ec3aacb3"
|
||||||
|
"b5c9d9e3977eac0943d7ff903120a49ef024eafe1cf77be79cf6229a823aabf0f0";
|
||||||
|
std::array<uint8_t, 256> golden{};
|
||||||
|
for (size_t i = 0; i < goldenHex.size() / 2; ++i) {
|
||||||
|
golden[i] = static_cast<uint8_t>(std::stoul(goldenHex.substr(i * 2, 2), nullptr, 16));
|
||||||
|
}
|
||||||
|
Require(EncodeNew("000000001") == golden, "Exact Dolphin writer golden fixture");
|
||||||
|
std::string error;
|
||||||
|
Require(!RuntimeNandSettings::Read(root));
|
||||||
|
Require(!std::filesystem::exists(root));
|
||||||
|
std::filesystem::create_directories(path.parent_path());
|
||||||
|
const auto scratchParent = root / "scratch-collisions";
|
||||||
|
std::filesystem::create_directories(scratchParent / ".setting-init-fixed-0");
|
||||||
|
const auto sentinel = scratchParent / ".setting-init-fixed-0" / "setting.txt";
|
||||||
|
{ std::ofstream output(sentinel); output << "another launch owns this"; }
|
||||||
|
const auto occupiedFile = scratchParent / ".setting-init-fixed-1";
|
||||||
|
{ std::ofstream output(occupiedFile); output << "leave this file alone"; }
|
||||||
|
std::error_code scratchError;
|
||||||
|
const auto claimed = CreateScratchDirectory(scratchParent, "fixed", scratchError);
|
||||||
|
Require(claimed && *claimed == scratchParent / ".setting-init-fixed-2" && !scratchError,
|
||||||
|
"Retry collisions with both existing directories and files");
|
||||||
|
Require(ReadBytes(sentinel) == "another launch owns this" &&
|
||||||
|
ReadBytes(occupiedFile) == "leave this file alone", "Never modify another launch's scratch data");
|
||||||
|
Require(!CreateScratchDirectory(occupiedFile / "not-a-directory", "fixed", scratchError) && scratchError,
|
||||||
|
"Real filesystem errors must fail rather than retry indefinitely");
|
||||||
|
|
||||||
|
// Force all claimants to use the same token; this deterministically
|
||||||
|
// exercises the collision path even when host clock precision is high.
|
||||||
|
std::array<std::optional<std::filesystem::path>, 16> claims;
|
||||||
|
std::vector<std::thread> claimants;
|
||||||
|
for (size_t i = 0; i < claims.size(); ++i) {
|
||||||
|
claimants.emplace_back([&, i] {
|
||||||
|
std::error_code ec;
|
||||||
|
claims[i] = CreateScratchDirectory(scratchParent, "shared", ec);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (auto& claimant : claimants) claimant.join();
|
||||||
|
for (size_t i = 0; i < claims.size(); ++i) {
|
||||||
|
Require(claims[i].has_value(), "Every concurrent claimant must acquire a scratch directory");
|
||||||
|
for (size_t j = 0; j < i; ++j) {
|
||||||
|
Require(claims[i] != claims[j], "Concurrent claimants must own different scratch directories");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const std::string plain = "AREA=USA\r\n\nCODE=LU\r\nSERNO=987654321\r\nGAME=US\r\n";
|
||||||
|
std::array<uint8_t, 256> fixture{};
|
||||||
|
for (size_t i = 0; i < fixture.size(); ++i) {
|
||||||
|
const unsigned shift = i % 32;
|
||||||
|
const uint32_t key = shift == 0 ? 0x73B5DBFAu :
|
||||||
|
(0x73B5DBFAu << shift) | (0x73B5DBFAu >> (32 - shift));
|
||||||
|
fixture[i] = static_cast<uint8_t>(key) ^ (i < plain.size() ? plain[i] : 0);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
std::ofstream output(path, std::ios::binary);
|
||||||
|
output.write(reinterpret_cast<const char*>(fixture.data()), fixture.size());
|
||||||
|
}
|
||||||
|
auto settings = RuntimeNandSettings::Read(root);
|
||||||
|
Require(settings && RuntimeNandSettings::HasIdentity(*settings));
|
||||||
|
Require(settings->at("SERNO") == "987654321" && settings->at("CODE") == "LU");
|
||||||
|
Require(settings->at("AREA") == "USA" && settings->at("GAME") == "US");
|
||||||
|
Require(Ensure(root, error, 1800000123), "Existing imported NAND must work");
|
||||||
|
std::array<uint8_t, 256> after{};
|
||||||
|
{
|
||||||
|
std::ifstream input(path, std::ios::binary);
|
||||||
|
input.read(reinterpret_cast<char*>(after.data()), after.size());
|
||||||
|
}
|
||||||
|
Require(after == fixture);
|
||||||
|
for (const auto serial : {"", "000000000", "1234567890", "123ABC789"}) {
|
||||||
|
(*settings)["SERNO"] = serial;
|
||||||
|
Require(!RuntimeNandSettings::HasIdentity(*settings));
|
||||||
|
}
|
||||||
|
(*settings)["SERNO"] = "012345678";
|
||||||
|
Require(RuntimeNandSettings::HasIdentity(*settings));
|
||||||
|
(*settings)["CODE"] = "TOOLONG";
|
||||||
|
Require(!RuntimeNandSettings::HasIdentity(*settings));
|
||||||
|
(*settings)["CODE"] = "LEH";
|
||||||
|
settings->erase("GAME");
|
||||||
|
Require(!RuntimeNandSettings::HasIdentity(*settings));
|
||||||
|
std::filesystem::resize_file(path, 128);
|
||||||
|
Require(!RuntimeNandSettings::Read(root));
|
||||||
|
const auto damaged = ReadBytes(path);
|
||||||
|
Require(!Ensure(root, error, 1800000123), "Do not replace a truncated identity");
|
||||||
|
Require(ReadBytes(path) == damaged, "Damaged file must remain untouched");
|
||||||
|
|
||||||
|
const auto fresh = root / "fresh";
|
||||||
|
Require(Ensure(fresh, error, 1800000123), "Missing setting.txt must initialize");
|
||||||
|
const auto generated = Read(fresh);
|
||||||
|
Require(generated && HasIdentity(*generated), "Generated file must be readable");
|
||||||
|
Require(generated->at("SERNO") == "800000123", "Persist Dolphin-generated serial");
|
||||||
|
Require(generated->at("CODE") == "LEH" && generated->at("AREA") == "EUR" &&
|
||||||
|
generated->at("GAME") == "EU", "PAL first-boot fields");
|
||||||
|
Require(generated->at("MODEL") == "RVL-001(EUR)" && generated->at("VIDEO") == "PAL" &&
|
||||||
|
generated->at("DVD") == "0" && generated->at("MPCH") == "0x7FFE",
|
||||||
|
"Complete Dolphin boot settings");
|
||||||
|
const auto firstBoot = ReadBytes(FilePath(fresh));
|
||||||
|
Require(firstBoot.size() == 256 && firstBoot.back() == 0, "Dolphin buffer size and raw zero padding");
|
||||||
|
Require(Ensure(fresh, error, 1900000999), "Second boot");
|
||||||
|
Require(ReadBytes(FilePath(fresh)) == firstBoot, "Second boot must not change any bytes");
|
||||||
|
|
||||||
|
const auto blocked = root / "blocked";
|
||||||
|
{ std::ofstream output(blocked); output << "file obstructing NAND directory"; }
|
||||||
|
Require(!Ensure(blocked, error, 1800000123), "Write failure must not return an ephemeral identity");
|
||||||
|
Require(!Ensure(root / "bad-clock", error, -1), "Clock failure must not initialize");
|
||||||
|
|
||||||
|
const auto concurrent = root / "concurrent";
|
||||||
|
std::array<bool, 16> results{};
|
||||||
|
std::vector<std::thread> workers;
|
||||||
|
for (size_t i = 0; i < results.size(); ++i) {
|
||||||
|
workers.emplace_back([&, i] {
|
||||||
|
std::string detail;
|
||||||
|
results[i] = Ensure(concurrent, detail, 1800000001 + i);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (auto& worker : workers) worker.join();
|
||||||
|
for (const bool result : results) Require(result, "Concurrent boot must read the persisted winner");
|
||||||
|
const auto winner = ReadBytes(FilePath(concurrent));
|
||||||
|
Require(Read(concurrent) && HasIdentity(*Read(concurrent)), "Concurrent boot must persist valid settings");
|
||||||
|
Require(Ensure(concurrent, error, 1900000999), "Boot after concurrent initialization");
|
||||||
|
Require(ReadBytes(FilePath(concurrent)) == winner, "Concurrent winner must remain stable");
|
||||||
|
|
||||||
|
// Independently decode as Nintendo does: stop at the first encoded NUL.
|
||||||
|
// Exercise serials that force Dolphin's extra-LF escaping, not only
|
||||||
|
// values that happen to work with a plain rotating-XOR encoder.
|
||||||
|
bool sawExtraLf = false;
|
||||||
|
for (int serial = 1; serial <= 10000; ++serial) {
|
||||||
|
const auto number = GenerateSerial(1000000000 + serial);
|
||||||
|
const auto encoded = EncodeNew(number);
|
||||||
|
Require(encoded.has_value(), "Serial encoding must fit");
|
||||||
|
std::string decoded;
|
||||||
|
for (size_t i = 0; i < encoded->size() && (*encoded)[i] != 0; ++i) {
|
||||||
|
const unsigned shift = i % 32;
|
||||||
|
const uint32_t key = shift == 0 ? 0x73B5DBFAu :
|
||||||
|
(0x73B5DBFAu << shift) | (0x73B5DBFAu >> (32 - shift));
|
||||||
|
decoded += static_cast<char>((*encoded)[i] ^ static_cast<uint8_t>(key));
|
||||||
|
}
|
||||||
|
Require(decoded.find("SERNO=" + number + "\r\n") != std::string::npos &&
|
||||||
|
decoded.find("GAME=EU\r\n") != std::string::npos,
|
||||||
|
"Encoded NUL must not truncate settings");
|
||||||
|
sawExtraLf |= decoded.find("\r\n\n") != std::string::npos;
|
||||||
|
}
|
||||||
|
Require(sawExtraLf, "Exercise Dolphin LF escape path");
|
||||||
|
std::filesystem::remove_all(root);
|
||||||
|
std::cout << "NAND settings checks passed\n";
|
||||||
|
return 0;
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
std::filesystem::remove_all(root);
|
||||||
|
std::cerr << error.what() << '\n';
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
#include "sc_serial_contract.h"
|
||||||
|
#include "nand_settings.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <iostream>
|
||||||
|
#include <iomanip>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
static void Require(bool condition, const char* message) {
|
||||||
|
if (!condition) throw std::runtime_error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint32_t ReadWord(const unsigned char* bytes) {
|
||||||
|
return (uint32_t(bytes[0]) << 24) | (uint32_t(bytes[1]) << 16) |
|
||||||
|
(uint32_t(bytes[2]) << 8) | uint32_t(bytes[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
try {
|
||||||
|
// Feed real generator + SC ABI outputs to the upstream bot decoder.
|
||||||
|
// Usage: mkw_sc_serial_tests --timestamp-vectors <unix-seconds> ...
|
||||||
|
if (argc > 1 && std::string(argv[1]) == "--timestamp-vectors") {
|
||||||
|
for (int i = 2; i < argc; ++i) {
|
||||||
|
const auto timestamp = std::stoll(argv[i]);
|
||||||
|
const auto serial = RuntimeNandSettings::GenerateSerial(static_cast<std::time_t>(timestamp));
|
||||||
|
std::array<unsigned char, 4> output{};
|
||||||
|
Require(RuntimeScSerial::Write(serial, 4,
|
||||||
|
[](uint32_t address, size_t size) { return address == 4 && size == 4; },
|
||||||
|
[&](uint32_t, uint32_t value) {
|
||||||
|
for (unsigned j = 0; j < 4; ++j)
|
||||||
|
output[j] = static_cast<unsigned char>(value >> (24 - 8 * j));
|
||||||
|
}) == 1, "Generated serial must pass SC ABI");
|
||||||
|
std::cout << timestamp << '\t' << serial << "\tLEH"
|
||||||
|
<< std::setfill('0') << std::setw(9) << ReadWord(output.data()) << '\n';
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// Reproduce the reported csnums from the old string-writing override.
|
||||||
|
const unsigned char old7886[] = {'7', '8', '8', '6'};
|
||||||
|
const unsigned char old7618[] = {'7', '6', '1', '8'};
|
||||||
|
Require(ReadWord(old7886) == 926431286, "Reproduce shared LEH926431286");
|
||||||
|
Require(ReadWord(old7618) == 926298424, "Reproduce shared LEH926298424");
|
||||||
|
|
||||||
|
std::array<unsigned char, 16> memory;
|
||||||
|
size_t available = 4;
|
||||||
|
unsigned writes = 0;
|
||||||
|
const auto contains = [&](uint32_t address, size_t size) {
|
||||||
|
return address == 4 && size <= available;
|
||||||
|
};
|
||||||
|
const auto write32 = [&](uint32_t address, uint32_t value) {
|
||||||
|
++writes;
|
||||||
|
for (unsigned i = 0; i < 4; ++i)
|
||||||
|
memory[address + i] = static_cast<unsigned char>(value >> (24 - 8 * i));
|
||||||
|
};
|
||||||
|
for (const auto& pair : {std::pair{"788600001", 788600001u}, {"788699999", 788699999u},
|
||||||
|
{"761800001", 761800001u}, {"761899999", 761899999u},
|
||||||
|
{"012345678", 12345678u}, {"000000001", 1u}, {"999999999", 999999999u}}) {
|
||||||
|
memory.fill(0xa5);
|
||||||
|
writes = 0;
|
||||||
|
Require(RuntimeScSerial::Write(pair.first, 4, contains, write32) == 1, "Accept an exactly four-byte output buffer");
|
||||||
|
Require(writes == 1 && ReadWord(memory.data() + 4) == pair.second, "Return full numeric serial, including digits after common prefix");
|
||||||
|
for (size_t i = 0; i < memory.size(); ++i)
|
||||||
|
if (i < 4 || i >= 8) Require(memory[i] == 0xa5, "Do not overwrite adjacent guest stack data");
|
||||||
|
}
|
||||||
|
for (const char* serial : {"", "1234567890", "7886x1234", "-12345678", "+12345678"}) {
|
||||||
|
writes = 0;
|
||||||
|
Require(RuntimeScSerial::Write(serial, 4, contains, write32) == 0 && writes == 0, "Reject malformed serial without a write");
|
||||||
|
}
|
||||||
|
writes = 0;
|
||||||
|
Require(RuntimeScSerial::Write("788600001", 0, contains, write32) == 0 && writes == 0, "Reject null output");
|
||||||
|
available = 3;
|
||||||
|
Require(RuntimeScSerial::Write("788600001", 4, contains, write32) == 0 && writes == 0, "Reject undersized output");
|
||||||
|
std::cout << "SC serial collision reproduction, numeric output and memory-boundary tests passed\n";
|
||||||
|
return 0;
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
std::cerr << error.what() << '\n';
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
// Verifies the expression engine against Dolphin's documented semantics,
|
||||||
|
// including the exact line from the user's GCPadNew.ini.
|
||||||
|
|
||||||
|
#include "input_expr.h"
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <map>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
static int g_failures = 0;
|
||||||
|
static std::map<std::string, double> g_inputs;
|
||||||
|
|
||||||
|
static InputExpr::InputSource Source() {
|
||||||
|
return [](const std::string& name) {
|
||||||
|
const auto it = g_inputs.find(name);
|
||||||
|
return it == g_inputs.end() ? 0.0 : it->second;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Check(bool ok, const std::string& what) {
|
||||||
|
if (!ok) {
|
||||||
|
std::printf(" FAIL: %s\n", what.c_str());
|
||||||
|
++g_failures;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static InputExpr::Expression Compile(const std::string& text) {
|
||||||
|
InputExpr::Expression expr;
|
||||||
|
std::string error;
|
||||||
|
if (!InputExpr::Expression::Parse(text, expr, error)) {
|
||||||
|
std::printf(" FAIL: parse '%s': %s\n", text.c_str(), error.c_str());
|
||||||
|
++g_failures;
|
||||||
|
}
|
||||||
|
return expr;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool Pressed(const InputExpr::Expression& e) {
|
||||||
|
return e.Evaluate(Source()) > InputExpr::kConditionThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Sleep(int ms) { std::this_thread::sleep_for(std::chrono::milliseconds(ms)); }
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
std::printf("Dolphin expression engine\n");
|
||||||
|
|
||||||
|
// Operators: & is min, | is max, ! is 1-x, matching Dolphin.
|
||||||
|
g_inputs["A"] = 1.0;
|
||||||
|
g_inputs["B"] = 0.0;
|
||||||
|
Check(Pressed(Compile("`A`")), "bare input");
|
||||||
|
Check(!Pressed(Compile("!`A`")), "not");
|
||||||
|
Check(!Pressed(Compile("`A` & `B`")), "and is min");
|
||||||
|
Check(Pressed(Compile("`A` | `B`")), "or is max");
|
||||||
|
Check(Pressed(Compile("`A` ^ `B`")), "xor");
|
||||||
|
Check(!Pressed(Compile("`A` ^ `A`")), "xor of equal inputs is false");
|
||||||
|
|
||||||
|
// Precedence: & binds tighter than |, so this is A | (B & A).
|
||||||
|
g_inputs["B"] = 0.0;
|
||||||
|
Check(Pressed(Compile("`A` | `B` & `A`")), "& binds tighter than |");
|
||||||
|
|
||||||
|
// Parens and numeric literals.
|
||||||
|
Check(Pressed(Compile("(`B` | 1)")), "literal");
|
||||||
|
Check(Pressed(Compile("min(1, `A`)")), "min");
|
||||||
|
Check(!Pressed(Compile("min(0, `A`)")), "min with zero");
|
||||||
|
Check(Pressed(Compile("if(`A`, 1, 0)")), "if");
|
||||||
|
Check(Pressed(Compile("clamp(5, 0, 1)")), "clamp");
|
||||||
|
|
||||||
|
// toggle flips on each rising edge and holds between them.
|
||||||
|
auto toggle = Compile("toggle(`T`)");
|
||||||
|
g_inputs["T"] = 0.0;
|
||||||
|
toggle.Evaluate(Source());
|
||||||
|
g_inputs["T"] = 1.0;
|
||||||
|
Check(Pressed(toggle), "toggle on after first press");
|
||||||
|
g_inputs["T"] = 0.0;
|
||||||
|
Check(Pressed(toggle), "toggle stays on after release");
|
||||||
|
g_inputs["T"] = 1.0;
|
||||||
|
Check(!Pressed(toggle), "toggle off on second press");
|
||||||
|
|
||||||
|
// hold requires the input to be down for the full duration.
|
||||||
|
auto hold = Compile("hold(`H`, 0.05)");
|
||||||
|
g_inputs["H"] = 1.0;
|
||||||
|
Check(!Pressed(hold), "hold not satisfied immediately");
|
||||||
|
Sleep(70);
|
||||||
|
Check(Pressed(hold), "hold satisfied after the interval");
|
||||||
|
g_inputs["H"] = 0.0;
|
||||||
|
Check(!Pressed(hold), "hold clears on release");
|
||||||
|
|
||||||
|
// pulse fires for the given duration after a rising edge.
|
||||||
|
auto pulse = Compile("pulse(`P`, 0.05)");
|
||||||
|
g_inputs["P"] = 0.0;
|
||||||
|
pulse.Evaluate(Source());
|
||||||
|
g_inputs["P"] = 1.0;
|
||||||
|
Check(Pressed(pulse), "pulse fires on rising edge");
|
||||||
|
Sleep(80);
|
||||||
|
Check(!Pressed(pulse), "pulse expires");
|
||||||
|
|
||||||
|
// The timing-window idiom seen in shared Dolphin configs.
|
||||||
|
auto window = Compile("!pulse(`W`, 0.05) & pulse(`W`, 0.15)");
|
||||||
|
g_inputs["W"] = 0.0;
|
||||||
|
window.Evaluate(Source());
|
||||||
|
g_inputs["W"] = 1.0;
|
||||||
|
Check(!Pressed(window), "window closed before its start");
|
||||||
|
Sleep(90);
|
||||||
|
Check(Pressed(window), "window open between the two pulses");
|
||||||
|
Sleep(90);
|
||||||
|
Check(!Pressed(window), "window closed after its end");
|
||||||
|
|
||||||
|
// timer ramps 0..1 and wraps, so a threshold turns it into a square wave.
|
||||||
|
auto timer = Compile("`X` & timer(0.1)");
|
||||||
|
g_inputs["X"] = 1.0;
|
||||||
|
int high = 0;
|
||||||
|
int low = 0;
|
||||||
|
for (int i = 0; i < 40; ++i) {
|
||||||
|
(Pressed(timer) ? high : low)++;
|
||||||
|
Sleep(5);
|
||||||
|
}
|
||||||
|
Check(high > 5 && low > 5, "timer alternates high and low");
|
||||||
|
|
||||||
|
// The exact D-Pad/Up line from the user's GCPadNew.ini.
|
||||||
|
auto dolphinLine = Compile("`Hat 0 N` | `Button 4` & timer(0.01)");
|
||||||
|
g_inputs["Hat 0 N"] = 0.0;
|
||||||
|
g_inputs["Button 4"] = 0.0;
|
||||||
|
Check(!Pressed(dolphinLine), "idle with nothing held");
|
||||||
|
g_inputs["Hat 0 N"] = 1.0;
|
||||||
|
Check(Pressed(dolphinLine), "hat alone presses");
|
||||||
|
g_inputs["Hat 0 N"] = 0.0;
|
||||||
|
g_inputs["Button 4"] = 1.0;
|
||||||
|
high = low = 0;
|
||||||
|
for (int i = 0; i < 60; ++i) {
|
||||||
|
(Pressed(dolphinLine) ? high : low)++;
|
||||||
|
Sleep(2);
|
||||||
|
}
|
||||||
|
Check(high > 5 && low > 5, "LB alternates via timer(0.01)");
|
||||||
|
|
||||||
|
// Regression tests for the CodeRabbit findings on PR #89.
|
||||||
|
g_inputs["A"] = 1.0;
|
||||||
|
// clamp with reversed bounds: std::clamp is UB when lo > hi.
|
||||||
|
Check(Compile("clamp(0.5, 1, 0)").Evaluate(Source()) == 0.5, "clamp tolerates reversed bounds");
|
||||||
|
// deadzone(v, 1) would divide by zero.
|
||||||
|
{
|
||||||
|
const double v = Compile("deadzone(`A`, 1)").Evaluate(Source());
|
||||||
|
Check(std::isfinite(v), "deadzone with dz=1 stays finite");
|
||||||
|
}
|
||||||
|
// timer with a zero or negative period would produce inf or NaN.
|
||||||
|
for (const char* text : {"timer(0)", "timer(-1)"}) {
|
||||||
|
const double v = Compile(text).Evaluate(Source());
|
||||||
|
Check(std::isfinite(v), std::string(text) + " stays finite");
|
||||||
|
}
|
||||||
|
// Any non-finite result is squashed before it can reach the uint8_t cast.
|
||||||
|
for (const char* text : {"sqrt(0 - 1)", "pow(10, 10000)", "tan(1.5707963267948966)"}) {
|
||||||
|
const double v = Compile(text).Evaluate(Source());
|
||||||
|
Check(std::isfinite(v), std::string(text) + " is sanitised at the boundary");
|
||||||
|
}
|
||||||
|
|
||||||
|
// tap count is user authored; negative, huge and non-finite must not reach
|
||||||
|
// the unsigned conversion.
|
||||||
|
for (const char* text : {"tap(`A`, 0.2, -1)", "tap(`A`, 0.2, 999999999)", "tap(`A`, 0.2, 0)"}) {
|
||||||
|
InputExpr::Expression e;
|
||||||
|
std::string err;
|
||||||
|
Check(InputExpr::Expression::Parse(text, e, err), std::string("parse ") + text);
|
||||||
|
const double v = e.Evaluate(Source());
|
||||||
|
Check(std::isfinite(v), std::string(text) + " evaluates without UB");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exponent notation is not part of the number syntax, matching Dolphin's
|
||||||
|
// lexer; it is rejected rather than silently misparsed.
|
||||||
|
{
|
||||||
|
InputExpr::Expression e;
|
||||||
|
std::string err;
|
||||||
|
Check(!InputExpr::Expression::Parse("tap(`A`, 0.2, 1e30)", e, err), "exponent notation rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A zero divisor must not skip the left subtree: stateful functions there
|
||||||
|
// still need their per-frame update.
|
||||||
|
{
|
||||||
|
auto divToggle = Compile("toggle(`D`) / `Z`");
|
||||||
|
g_inputs["Z"] = 0.0;
|
||||||
|
g_inputs["D"] = 0.0;
|
||||||
|
divToggle.Evaluate(Source());
|
||||||
|
g_inputs["D"] = 1.0;
|
||||||
|
divToggle.Evaluate(Source()); // rising edge seen even though rhs is 0
|
||||||
|
g_inputs["D"] = 0.0;
|
||||||
|
g_inputs["Z"] = 1.0;
|
||||||
|
Check(divToggle.Evaluate(Source()) > InputExpr::kConditionThreshold,
|
||||||
|
"toggle still latched while the divisor was zero");
|
||||||
|
}
|
||||||
|
|
||||||
|
// smooth with a zero rate divides 0 by 0; NaN must not stick in the node.
|
||||||
|
{
|
||||||
|
auto sm = Compile("smooth(`A`, 0)");
|
||||||
|
g_inputs["A"] = 1.0;
|
||||||
|
sm.Evaluate(Source());
|
||||||
|
Sleep(5);
|
||||||
|
Check(std::isfinite(sm.Evaluate(Source())), "smooth with a zero rate stays finite");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Referenced inputs, used for diagnostics in the UI.
|
||||||
|
const auto refs = dolphinLine.ReferencedInputs();
|
||||||
|
Check(refs.size() == 2, "two referenced inputs");
|
||||||
|
|
||||||
|
// Errors are reported, not silently swallowed.
|
||||||
|
InputExpr::Expression bad;
|
||||||
|
std::string error;
|
||||||
|
Check(!InputExpr::Expression::Parse("`A` & ", bad, error), "trailing operator rejected");
|
||||||
|
Check(!InputExpr::Expression::Parse("nope(1)", bad, error), "unknown function rejected");
|
||||||
|
Check(!InputExpr::Expression::Parse("(`A`", bad, error), "missing paren rejected");
|
||||||
|
Check(!InputExpr::Expression::Parse("`A", bad, error), "unterminated backtick rejected");
|
||||||
|
Check(InputExpr::Expression::Parse("", bad, error) && bad.Empty(), "empty parses to empty");
|
||||||
|
Check(!InputExpr::Expression::Parse("hold(`A`)", bad, error), "wrong arg count rejected");
|
||||||
|
|
||||||
|
if (g_failures == 0) {
|
||||||
|
std::printf("all checks passed\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
std::printf("%d check(s) failed\n", g_failures);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Buffers.Binary;
|
using System.Buffers.Binary;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
@@ -1891,6 +1891,7 @@ int RunTranslateModCore(string[] argsTail, string? outputDirectoryOverride)
|
|||||||
overlayBuild,
|
overlayBuild,
|
||||||
continuationPlan,
|
continuationPlan,
|
||||||
retroWfcResolvedExecutableHooks,
|
retroWfcResolvedExecutableHooks,
|
||||||
|
patchPlan,
|
||||||
kamekFunctionStarts,
|
kamekFunctionStarts,
|
||||||
moduleLinkBase,
|
moduleLinkBase,
|
||||||
selected.CodeSize,
|
selected.CodeSize,
|
||||||
@@ -2231,6 +2232,7 @@ int EmitModCpp(
|
|||||||
OverlayBuildResult overlayBuild,
|
OverlayBuildResult overlayBuild,
|
||||||
ContinuationPlan continuationPlan,
|
ContinuationPlan continuationPlan,
|
||||||
IReadOnlyCollection<RetroWfcExecutableHookPlan>? retroWfcExecutableHooks,
|
IReadOnlyCollection<RetroWfcExecutableHookPlan>? retroWfcExecutableHooks,
|
||||||
|
KamekPatchPlan patchPlan,
|
||||||
IReadOnlyList<ModFunctionStart> kamekFunctionStarts,
|
IReadOnlyList<ModFunctionStart> kamekFunctionStarts,
|
||||||
uint moduleLinkBase,
|
uint moduleLinkBase,
|
||||||
uint moduleLinkedCodeSize,
|
uint moduleLinkedCodeSize,
|
||||||
@@ -2272,11 +2274,22 @@ int EmitModCpp(
|
|||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
var queuedContinuationAddresses = continuationPlan.Entries.Select(e => e.Address).ToHashSet();
|
var queuedContinuationAddresses = continuationPlan.Entries.Select(e => e.Address).ToHashSet();
|
||||||
var discoveredContinuationQueue = new Queue<ContinuationEntry>();
|
var discoveredContinuationQueue = new Queue<ContinuationEntry>();
|
||||||
var linkedHookLrBasesByTarget = retroWfcExecutableHooks is null
|
var hookLrBases = new List<(uint TargetAddress, uint ContinuationAddress)>();
|
||||||
? new Dictionary<uint, uint[]>()
|
if (retroWfcExecutableHooks is not null)
|
||||||
: retroWfcExecutableHooks
|
{
|
||||||
|
hookLrBases.AddRange(
|
||||||
|
retroWfcExecutableHooks
|
||||||
.Where(h => h.TargetAddress.HasValue && RetroWfcHookSetsLinkRegister(h))
|
.Where(h => h.TargetAddress.HasValue && RetroWfcHookSetsLinkRegister(h))
|
||||||
.GroupBy(h => h.TargetAddress!.Value)
|
.Select(h => (h.TargetAddress!.Value, h.ContinuationAddress)));
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
hookLrBases.Add((target, checked(patch.CommandAddress + 4u)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var linkedHookLrBasesByTarget = hookLrBases
|
||||||
|
.GroupBy(h => h.TargetAddress)
|
||||||
.ToDictionary(
|
.ToDictionary(
|
||||||
g => g.Key,
|
g => g.Key,
|
||||||
g => g.Select(h => h.ContinuationAddress).Distinct().ToArray());
|
g => g.Select(h => h.ContinuationAddress).Distinct().ToArray());
|
||||||
@@ -2750,123 +2763,8 @@ IEnumerable<uint> DirectModuleTargets(FunctionTranslationResult result, uint mod
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
IEnumerable<int> DiscoverLrRelativeIndirectJumpOffsets(FunctionTranslationResult result)
|
IEnumerable<int> DiscoverLrRelativeIndirectJumpOffsets(FunctionTranslationResult result) =>
|
||||||
{
|
ContinuationPlanner.DiscoverLrRelativeIndirectJumpOffsets(result.Instructions);
|
||||||
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) =>
|
static bool RetroWfcHookSetsLinkRegister(RetroWfcExecutableHookPlan hook) =>
|
||||||
hook.TypeName is "call" or "branchCtrLink" ||
|
hook.TypeName is "call" or "branchCtrLink" ||
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Buffers.Binary;
|
using System.Buffers.Binary;
|
||||||
|
using System.Collections.Immutable;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Translator.Core.Disassembly;
|
using Translator.Core.Disassembly;
|
||||||
using Translator.Core.Parsing.Kamek;
|
using Translator.Core.Parsing.Kamek;
|
||||||
@@ -273,4 +274,530 @@ public static class ContinuationPlanner
|
|||||||
Or,
|
Or,
|
||||||
AddSigned
|
AddSigned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static IEnumerable<int> DiscoverLrRelativeIndirectJumpOffsets(IReadOnlyList<PpcInstruction> instructions)
|
||||||
|
{
|
||||||
|
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)>();
|
||||||
|
const int MaxStatesPerInstruction = 16;
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 && 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)
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using System.Buffers.Binary;
|
using System.Buffers.Binary;
|
||||||
|
using System.Linq;
|
||||||
|
using Translator.Core.Disassembly;
|
||||||
using Translator.Core.Mods;
|
using Translator.Core.Mods;
|
||||||
using Translator.Core.Mods.Mkwii;
|
using Translator.Core.Mods.Mkwii;
|
||||||
using Translator.Core.Parsing.Kamek;
|
using Translator.Core.Parsing.Kamek;
|
||||||
@@ -128,6 +130,54 @@ public class ContinuationPlannerTests
|
|||||||
Assert.Contains("Retro WFC executable hook continuation", entry.Reason);
|
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() =>
|
private static KamekChunk EmptyChunk() =>
|
||||||
new(
|
new(
|
||||||
0,
|
0,
|
||||||
|
|||||||
@@ -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,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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,39 @@ public class RetroWfcPayloadLoweringTests
|
|||||||
Assert.Equal("moduleFunction", pointer.TargetKind);
|
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() =>
|
private static BaseManifest TestManifest() =>
|
||||||
new(
|
new(
|
||||||
"test",
|
"test",
|
||||||
@@ -52,6 +85,51 @@ public class RetroWfcPayloadLoweringTests
|
|||||||
],
|
],
|
||||||
"ranges.json");
|
"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()
|
private static byte[] BuildSharedPayloadFixture()
|
||||||
{
|
{
|
||||||
var payload = new byte[0x240];
|
var payload = new byte[0x240];
|
||||||
|
|||||||
BIN
Binary file not shown.
@@ -26,6 +26,11 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\Translator.Core\Translator.Core.csproj" />
|
<ProjectReference Include="..\..\src\Translator.Core\Translator.Core.csproj" />
|
||||||
<ProjectReference Include="..\..\src\Translator.Cli\Translator.Cli.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>
|
</ItemGroup>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
Reference in New Issue
Block a user