60 Commits

Author SHA1 Message Date
patchzyy 9a22de1079 Bump version to 0.2.25 2026-08-30 11:41:22 +02:00
patchzyy c0359b05a7 Merge pull request #92 from patchzyy/proper-WUP
Enable libusb in SDL
2026-08-30 11:39:16 +02:00
patchzyy 6c1d4faaf7 Merge pull request #93 from patchzyy/Remove-f10-controller-block
Remove the f10 menu blocking controller inputs
2026-08-30 11:35:31 +02:00
patchzyy 3fb7373c4f Update AuroraLibUSB.cmake 2026-08-30 11:34:45 +02:00
patchzyy 8dd689c6d2 Update settings_overlay.cpp 2026-08-30 11:27:37 +02:00
patchzyy 69be584cc5 WUP 2026-08-30 11:21:10 +02:00
patchzyy 8d569ef77b Merge pull request #91 from patchzyy/Bring-back-auto-assignment
bring back auto-assignment
2026-08-30 10:49:10 +02:00
patchzyy 36cc6abe24 bring back auto-assignment 2026-08-30 10:39:40 +02:00
patchzyy 806f4127bb Merge pull request #37 from theofficialgman/main
Add Real (Native) Linux Support
2026-08-30 01:14:30 +02:00
patchzyy fb9b101de7 Update Test-NativeDependencies.ps1 2026-08-30 01:08:10 +02:00
theofficialgman 64ea7b7401 correct windows tests after refactor 2026-08-29 14:55:02 -04:00
theofficialgman 4f4716be5a remove WUP028 driver which is already handled by SDL3 on Linux
also restores automatic port assignment on non-Windows. This should be the default as https://github.com/patchzyy/Wiicompiled/pull/42 was never a necessary change
2026-08-29 13:54:25 -04:00
theofficialgman c0ed2bfbeb refactor WiiCompiled.Setup into WiiCompiled.Setup.Windows and add WiiCompiled.Setup.Common
the idea behind this is C# code that is OS agnostic can go in WiiCompiled.Setup.Common to be shared by any OS specific code (eg: WiiCompiled.Setup.Windows and WiiCompiled.Setup.Linux).
2026-08-29 12:06:58 -04:00
theofficialgman f09590aa5d switch to standalone nod tool rather than dolphin-tool 2026-08-29 12:06:58 -04:00
theofficialgman edc5fa1dd3 also bundle dotnet in translator for linux appimage 2026-08-29 12:06:58 -04:00
theofficialgman c5db4d0e8e add appimage buildscript for linux 2026-08-29 12:06:58 -04:00
theofficialgman e3c4028b50 partial linux setup/install scripting 2026-08-29 12:06:58 -04:00
theofficialgman 48c4df342f fix non-fatal linker warning on linux 2026-08-29 12:06:13 -04:00
theofficialgman c93f82e938 add linux local-build.sh script 2026-08-29 12:06:13 -04:00
theofficialgman 9f799fe12b final linux support necessary changes to build a working application 2026-08-29 12:06:13 -04:00
theofficialgman 02df7ef9ee correct type-alias difference on linux 2026-08-29 12:06:13 -04:00
theofficialgman 88ab029c74 add linux option for guest memory usage 2026-08-29 12:06:13 -04:00
theofficialgman a0b54b874b use libco on non-Win32 systems for guest OSThread scheduling 2026-08-29 12:04:08 -04:00
theofficialgman f63d0c84b5 add linux support to runtime build 2026-08-29 12:02:59 -04:00
patchzyy 6d836dab3e Bump version to 0.2.24 2026-08-29 15:22:41 +02:00
patchzyy 6bffedf028 Merge pull request #67 from patchzyy/fix-accented-paths
fix accented paths
2026-08-29 15:02:06 +02:00
patchzyy 09ca4e98f4 Update system_bridge.cpp 2026-08-29 15:01:51 +02:00
patchzyy 6dc4e59052 Coderabbit fixes 2026-08-29 11:56:35 +02:00
patchzyy 82b295b20c Merge pull request #77 from patchzyy/Fix-unsupported-locale
Fix paranthesees
2026-08-28 22:19:24 +02:00
patchzyy eb1721fd72 Update NativeBuildFlags.ps1 2026-08-28 20:45:46 +02:00
patchzyy 129c714f02 Update disk space 2026-08-28 20:42:20 +02:00
patchzyy 2794024d3a Fix paranthesees 2026-08-28 20:38:27 +02:00
patchzyy b5e5858e1d Use UTF-8-safe filesystem paths end-to-end 2026-08-28 19:01:14 +02:00
patchzyy 4897e7e27d Use safe UTF-8 path construction 2026-08-28 10:52:01 +02:00
patchzyy 7ebda6bf7f mangle path fix 2026-08-28 10:30:07 +02:00
patchzyy 0403f176bd fix accented paths 2026-08-28 01:25:13 +02:00
patchzyy 1912292c80 Merge pull request #42 from GalaxisBeast/main
add gamecube controller support for all 4 ports
2026-08-27 23:58:32 +02:00
patchzyy e498e62622 Merge pull request #63 from s5bug/fix-translator-readme-formatting
fix formatting of translator/README.md
2026-08-27 23:53:52 +02:00
Aly f73739f248 fix formatting of translator/README.md 2026-08-27 14:56:15 -06:00
patchzyy 3389fe35fc Merge pull request #57 from patchzyy/fix-ghosts
Implement missing ISFS_ReadDir
2026-08-27 19:45:42 +02:00
Cristian Boehm e11062a1ba Merge branch 'patchzyy:main' into main 2026-08-27 13:29:49 -04:00
patchzyy 1d1d064d37 Implement missing ISFS_ReadDir 2026-08-27 19:15:21 +02:00
patchzyy 9d1f3db1d5 Merge pull request #48 from patchzyy/fix-choking-logging
Update os_report.cpp
2026-08-27 14:43:06 +02:00
GalaxisBeast 251529d66e fix issues
fix timeout loop, fixed drift issues, fixed infinite rumble, fixed gamecube controller adapter taking over all ports, require assigning a virtual controller port specifically to the gamecube controller adapter, and block inputs from gamecube controller while settings menu (f10 menu) is open
2026-08-26 20:12:15 -04:00
patchzyy 987b666296 Update os_report.cpp 2026-08-27 00:59:40 +02:00
patchzyy 260022b4ba version update 2026-08-26 23:39:04 +02:00
GalaxisBeast f1c2b60df1 fix mistake where i literally deleted the code 2026-08-26 17:37:14 -04:00
Cristian Boehm 5193dd9749 Merge branch 'main' into main 2026-08-26 17:22:47 -04:00
patchzyy ec9bd92415 Merge pull request #43 from patchzyy/Unknown-Controller-support
Unknown controller support
2026-08-26 23:04:55 +02:00
patchzyy dbf6d05625 SDL cache + output stream 2026-08-26 22:50:20 +02:00
GalaxisBeast 8ba44162fa Update wup028_adapter.cpp 2026-08-26 15:55:57 -04:00
GalaxisBeast 9ee14e582d Update wup028_adapter.cpp 2026-08-26 15:41:00 -04:00
GalaxisBeast 0c349a9296 Update pad.cpp 2026-08-26 15:35:32 -04:00
patchzyy 21b57f08a4 Unknown controller support 2026-08-26 21:32:22 +02:00
GalaxisBeast e146b10af8 add gamecube controller support for all 4 ports 2026-08-26 15:17:28 -04:00
patchzyy edd03f8991 Merge pull request #23 from patchzyy/Premature-success-fix
fix underflow and crash on malformed stream
2026-08-24 21:50:19 +02:00
patchzyy 9c4f4b738a fix underflow and crash on malformed stream 2026-08-24 19:20:20 +02:00
patchzyy 8d5d93f02d version update 2026-08-24 13:40:44 +02:00
patchzyy c75b482224 Merge pull request #17 from patchzyy/decompress-overlow
Fix szs memory corruption
2026-08-24 12:03:50 +02:00
patchzyy e5ae29b27e Create egg_decomp.cpp 2026-08-24 11:30:11 +02:00
122 changed files with 12839 additions and 768 deletions
+40 -17
View File
@@ -1,7 +1,6 @@
[CmdletBinding(PositionalBinding = $false)]
param(
[string]$OutputDirectory = 'Launcher/dist',
[string]$DolphinToolPath,
[string]$PortableToolsDirectory = 'Launcher/artifacts/portable-tools',
[string]$DependencySourceDirectory = 'Launcher/artifacts/dependencies',
[string]$VcRuntimeDirectory,
@@ -15,10 +14,6 @@ Set-StrictMode -Version 3.0
# helpers shared with LocalBuild.ps1 and Prepare-NativePrebuilt.ps1.
. (Join-Path $PSScriptRoot 'NativeBuildFlags.ps1')
if ([string]::IsNullOrWhiteSpace($DolphinToolPath)) {
throw 'Build-Installer.ps1 requires -DolphinToolPath pointing to DolphinTool.exe.'
}
$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$outputRoot = [IO.Path]::GetFullPath((Join-Path $repoRoot $OutputDirectory))
$portableTools = [IO.Path]::GetFullPath((Join-Path $repoRoot $PortableToolsDirectory))
@@ -26,7 +21,7 @@ $dependencySources = [IO.Path]::GetFullPath((Join-Path $repoRoot $DependencySour
$workRoot = Join-Path $PSScriptRoot 'artifacts\installer-build'
$publish = Join-Path $workRoot 'publish'
$payloadRoot = Join-Path $workRoot 'payload'
$setupProject = Join-Path $PSScriptRoot 'WiiCompiled.Setup\WiiCompiled.Setup.csproj'
$setupProject = Join-Path $PSScriptRoot 'WiiCompiled.Setup.Windows\WiiCompiled.Setup.Windows.csproj'
$translatorProject = Join-Path $repoRoot 'translator\src\Translator.Cli\Translator.Cli.csproj'
$projectFile = Join-Path $repoRoot 'projects\mkwii\recomp.yml'
@@ -91,7 +86,6 @@ function Compress-Zip([string]$Source, [string]$Destination, [string[]]$Entries)
Assert-File $setupProject '.NET setup project'
Assert-File $translatorProject 'Translator CLI project'
Assert-File $DolphinToolPath 'DolphinTool'
if (-not (Test-Path -LiteralPath (Join-Path $portableTools 'llvm-mingw\bin\x86_64-w64-mingw32-clang++.exe'))) {
& (Join-Path $PSScriptRoot 'Prepare-PortableTools.ps1') -Destination $portableTools
@@ -104,7 +98,7 @@ Assert-Directory $dependencySources 'Pinned offline dependency sources'
# to compile (launcher/Prepare-NativePrebuilt.ps1).
# Kept in step with InstalledLayout.DependencyNames by Test-PinnedFacts.ps1: the installed host
# refuses to call a toolkit complete unless every one of these directories is present.
$requiredDependencies = @('abseil-cpp','cppwinrt','dawn_prebuilt','fmt','freetype','imgui','native_prebuilt','png','SDL','sqlite3','tracy','xxhash','zlib','zstd')
$requiredDependencies = @('abseil-cpp','cppwinrt','dawn_prebuilt','fmt','freetype','imgui','libusb','native_prebuilt','png','SDL','sqlite3','tracy','xxhash','zlib','zstd')
# The precompiled archives are only interchangeable with what the user's machine
# compiles if both came from this toolchain and this flag set, so a stale package
@@ -169,6 +163,15 @@ $translator = Join-Path $publish 'translator\Translator.Cli.exe'
Assert-File $setupHost 'Published setup host'
Assert-File $translator 'Self-contained translator'
# Resolved via the shared WiiCompiled.Setup.Common.Cli helper (also used by build-appimage.sh on
# Linux) rather than a separate download/version-pin copy here: it downloads and caches the same way
# NodToolProvider.cs always does (Launcher/artifacts/nodtool.exe), replacing the old manual
# -DolphinToolPath handoff with an automated, pinned acquisition step.
$nodToolCliProject = Join-Path $PSScriptRoot 'WiiCompiled.Setup.Common.Cli'
$nodTool = (& dotnet run --project $nodToolCliProject -c Release -- --workspace $repoRoot | Select-Object -Last 1)
if ($LASTEXITCODE -ne 0) { throw "nodtool resolution failed with exit code $LASTEXITCODE." }
Assert-File $nodTool 'Resolved nodtool'
Write-Host '[2/6] Staging the explicit, game-code-free payload allowlist...'
# The staged layout mirrors the installed layout exactly (Toolkit, BuildWorkspace): payload
# identities hash relative paths, so the names here are part of the fingerprint contract.
@@ -191,7 +194,7 @@ Get-ChildItem -LiteralPath (Join-Path $toolkit 'llvm-mingw\bin') -File |
Remove-Item -Force
[IO.Directory]::CreateDirectory((Join-Path $toolkit 'Translator')) | Out-Null
Copy-Item -LiteralPath $translator -Destination (Join-Path $toolkit 'Translator\Translator.Cli.exe')
Copy-Item -LiteralPath $DolphinToolPath -Destination (Join-Path $toolkit 'DolphinTool.exe')
Copy-Item -LiteralPath $nodTool -Destination (Join-Path $toolkit 'nodtool.exe')
[IO.Directory]::CreateDirectory((Join-Path $toolkit 'Redist')) | Out-Null
Copy-Item -Path (Join-Path $vcRuntime '*.dll') -Destination (Join-Path $toolkit 'Redist')
Copy-Item -Path (Join-Path $vcRuntime '*.dll') -Destination (Join-Path $toolkit 'CMake\bin')
@@ -225,18 +228,38 @@ Copy-Item (Join-Path $dependencySources 'cppwinrt\LICENSE.txt') (Join-Path $payl
# The precompiled aurora/third-party archives are built from the very sources
# already shipped under build-workspace\Dependencies and aurora-main, so they add
# no third-party component and therefore no new license obligation.
$dolphinLicense = Join-Path (Split-Path -Parent $DolphinToolPath) 'COPYING'
if (Test-Path $dolphinLicense) { Copy-Item $dolphinLicense (Join-Path $payloadRoot 'licenses\Dolphin-COPYING.txt') }
@"
DolphinTool source offer
nodtool (disc image extraction)
Project and complete corresponding source: https://github.com/dolphin-emu/dolphin
Dolphin is licensed under GPLv2+ with additional per-file SPDX licenses.
"@ | Set-Content (Join-Path $payloadRoot 'licenses\Dolphin-SOURCE.txt') -Encoding UTF8
Project: https://github.com/encounter/nod
Dual-licensed under MIT OR Apache-2.0.
MIT License
Copyright 2021 Luke Street.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"@ | Set-Content (Join-Path $payloadRoot 'licenses\nodtool-LICENSE-MIT.txt') -Encoding UTF8
@"
Microsoft Visual C++ Runtime
Redistributable x64 runtime DLLs are included app-locally for DolphinTool and third-party renderer DLLs.
Redistributable x64 runtime DLLs are included app-locally for nodtool and third-party renderer DLLs.
Microsoft license terms: https://visualstudio.microsoft.com/license-terms/
"@ | Set-Content (Join-Path $payloadRoot 'licenses\Microsoft-VC-Runtime.txt') -Encoding UTF8
# Compute the payload's content identities once, here, with the same code every installed host
@@ -253,7 +276,7 @@ foreach ($required in @('ToolkitFingerprint','TranslationFingerprint','NativeToo
$manifest = [ordered]@{
SchemaVersion = 2
ProductVersion = '0.2.21'
ProductVersion = '0.2.25'
ExpectedGameId = $pins.GameId
ExpectedDolSha256 = $pins.DolSha256
ExpectedRelSha256 = $pins.RelSha256
+5 -4
View File
@@ -151,9 +151,10 @@ if ($Profile -ne 'both' -and -not [string]::IsNullOrWhiteSpace($BaseOutputDirect
throw '-BaseOutputDirectory is valid only with -Profile both.'
}
$translator = Join-Path $Toolkit 'Translator\Translator.Cli.exe'
$cmake = Join-Path $Toolkit 'CMake\bin\cmake.exe'
$ninja = Join-Path $Toolkit 'Ninja\ninja.exe'
$toolchainBin = Join-Path $Toolkit 'llvm-mingw\bin'
$toolchain = Get-MkwShellSafeToolchainRoot $Toolkit
$cmake = Join-Path $toolchain 'CMake\bin\cmake.exe'
$ninja = Join-Path $toolchain 'Ninja\ninja.exe'
$toolchainBin = Join-Path $toolchain 'llvm-mingw\bin'
$cc = Join-Path $toolchainBin 'x86_64-w64-mingw32-clang.exe'
$cxx = Join-Path $toolchainBin 'x86_64-w64-mingw32-clang++.exe'
$windres = Join-Path $toolchainBin 'x86_64-w64-mingw32-windres.exe'
@@ -203,7 +204,7 @@ if ($Parallel -gt 0) {
$oldPath = $env:PATH
$oldDotnet = $env:DOTNET_ROOT
try {
$env:PATH = Get-MkwToolchainPath $Toolkit
$env:PATH = Get-MkwToolchainPath $toolchain
Remove-Item Env:DOTNET_ROOT -ErrorAction SilentlyContinue
Push-Location $Workspace
try {
+37
View File
@@ -40,6 +40,43 @@ function Get-MkwToolchainPath([string]$ToolchainRoot) {
) -join ';')
}
function Get-MkwShellSafeToolchainRoot([string]$ToolchainRoot) {
if ([string]::IsNullOrWhiteSpace($ToolchainRoot)) { throw 'A toolchain root is required.' }
$full = [IO.Path]::GetFullPath($ToolchainRoot)
# A drive root keeps its separator: "C:" is relative to the current directory on that drive.
if ($full -ne [IO.Path]::GetPathRoot($full)) { $full = $full.TrimEnd('\') }
if ($full -notmatch '[()&^%!]') { return $full }
$sha = [Security.Cryptography.SHA256]::Create()
try {
$bytes = $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($full.ToLowerInvariant()))
} finally { $sha.Dispose() }
$linkName = 'toolchain-' + ((($bytes[0..7]) | ForEach-Object { $_.ToString('x2') }) -join '')
$failures = @()
foreach ($base in @($env:ProgramData, $env:PUBLIC)) {
if ([string]::IsNullOrWhiteSpace($base) -or $base -match '[()&^%! ]') { continue }
$link = Join-Path (Join-Path $base 'WiiCompiled') $linkName
try {
[IO.Directory]::CreateDirectory((Split-Path -Parent $link)) | Out-Null
# The name already identifies the target, so an existing junction that still resolves is
# this one; only a broken leftover is replaced. Directory.Delete removes the reparse
# point itself, where Remove-Item -Recurse would delete the toolchain it points at.
if (-not (Test-Path -LiteralPath (Join-Path $link 'CMake\bin\cmake.exe') -PathType Leaf)) {
if (Test-Path -LiteralPath $link) { [IO.Directory]::Delete($link) }
New-Item -ItemType Junction -Path $link -Target $full -ErrorAction Stop | Out-Null
}
Write-Host "MKWCBUILD: Building through $link, because $full contains characters cmd.exe cannot parse"
return $link
} catch {
$failures += "$link ($($_.Exception.Message))"
}
}
throw ("The toolchain path $full contains a character (one of ( ) & ^ % !) that the compiler " +
'cannot be invoked through, and no junction to it could be created: ' + ($failures -join '; ') +
'. Install to a path without those characters.')
}
function Get-MkwProjectPins([string]$ProjectFile) {
<#
The Mario Kart Wii facts pinned by projects/mkwii/recomp.yml (game identity, clean input
-6
View File
@@ -1,6 +1,5 @@
[CmdletBinding(PositionalBinding = $false)]
param(
[string]$DolphinToolPath,
[string]$PortableToolsDirectory = 'Launcher/artifacts/portable-tools',
[string]$DependencySourceDirectory = 'Launcher/artifacts/dependencies',
[string]$VcRuntimeDirectory,
@@ -10,12 +9,7 @@ param(
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version 3.0
if ([string]::IsNullOrWhiteSpace($DolphinToolPath)) {
throw 'Prepare-Release.ps1 requires -DolphinToolPath pointing to DolphinTool.exe.'
}
$arguments = @{
DolphinToolPath = $DolphinToolPath
PortableToolsDirectory = $PortableToolsDirectory
DependencySourceDirectory = $DependencySourceDirectory
ToolkitReleaseTag = $ToolkitReleaseTag
+1 -1
View File
@@ -18,7 +18,7 @@ $llvmReadobj = [System.IO.Path]::GetFullPath($LlvmReadobjPath)
$systemDlls = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
@(
'advapi32.dll', 'authz.dll', 'bcrypt.dll', 'combase.dll', 'comdlg32.dll', 'crypt32.dll',
'advapi32.dll', 'authz.dll', 'bcrypt.dll', 'bcryptprimitives.dll', 'combase.dll', 'comdlg32.dll', 'crypt32.dll',
'd3d11.dll', 'd3d12.dll', 'dbghelp.dll', 'dcomp.dll', 'dwrite.dll', 'dwmapi.dll',
'dxgi.dll', 'gdi32.dll', 'imm32.dll',
'iphlpapi.dll', 'kernel32.dll', 'mf.dll', 'mfplat.dll', 'mfreadwrite.dll', 'mfuuid.dll',
+7 -4
View File
@@ -15,7 +15,8 @@ if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) {
}
$repoRoot = [IO.Path]::GetFullPath($RepositoryRoot)
$launcher = Join-Path $repoRoot 'Launcher'
$setup = Join-Path $launcher 'WiiCompiled.Setup'
$setup = Join-Path $launcher 'WiiCompiled.Setup.Windows'
$common = Join-Path $launcher 'WiiCompiled.Setup.Common'
$failures = [Collections.Generic.List[string]]::new()
function Add-Failure([string]$Message) { $failures.Add($Message) }
@@ -48,9 +49,11 @@ function Compare-Set([string[]]$Expected, [string[]]$Actual, [string]$ExpectedNa
$pins = Get-MkwProjectPins (Join-Path $repoRoot 'projects\mkwii\recomp.yml')
# --- The Retro-WFC endpoint: recomp.yml owns it; the installer host pins the same string so a
# --- redirected or rewritten endpoint cannot be fetched from.
$inputValidation = Read-SourceFile (Join-Path $setup 'InputValidation.cs') 'InputValidation.cs'
$hostUri = Get-CapturedValue $inputValidation 'CurrentRetroWfcPayloadUri\s*=\s*"([^"]+)"' `
# --- redirected or rewritten endpoint cannot be fetched from. The literal lives in
# --- WiiCompiled.Setup.Common (shared with WiiCompiled.Setup.Linux) - InputValidation.cs only
# --- re-exports it as `= RetroWfcPayload.CurrentRetroWfcPayloadUri;`, no literal to capture there.
$retroWfcPayload = Read-SourceFile (Join-Path $common 'RetroWfcPayload.cs') 'RetroWfcPayload.cs'
$hostUri = Get-CapturedValue $retroWfcPayload 'CurrentRetroWfcPayloadUri\s*=\s*"([^"]+)"' `
'The host Retro-WFC endpoint constant'
if ($hostUri -cne $pins.RetroWfcPayloadUri) {
Add-Failure "InputValidation.CurrentRetroWfcPayloadUri is '$hostUri' but recomp.yml pins '$($pins.RetroWfcPayloadUri)'."
@@ -0,0 +1,28 @@
using WiiCompiled.Setup.Common;
// A packaging-time-only helper - never shipped, never run by an end user. Both
// Launcher/build-appimage.sh and Launcher/Build-Installer.ps1 invoke this to obtain the nodtool
// binary they bundle, so there is exactly one place (NodToolProvider) that knows the pinned
// version/URL/platform-asset mapping, instead of a separate copy per packaging script.
//
// Usage: WiiCompiled.Setup.Common.Cli --workspace <repo-root>
// Prints the resolved nodtool path to stdout.
string? workspace = null;
for (var i = 0; i < args.Length; i++)
{
if (args[i] == "--workspace" && i + 1 < args.Length)
{
workspace = args[++i];
}
}
if (workspace is null)
{
Console.Error.WriteLine("Usage: WiiCompiled.Setup.Common.Cli --workspace <repo-root>");
return 1;
}
var path = await NodToolProvider.ResolveAsync(workspace, CancellationToken.None);
Console.WriteLine(path);
return 0;
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>WiiCompiled.Setup.Common.Cli</RootNamespace>
<AssemblyName>WiiCompiled.Setup.Common.Cli</AssemblyName>
<Version>0.2.22</Version>
<Authors>patchzy</Authors>
<Product>WiiCompiled</Product>
<Description>Packaging-time helper: resolves (downloading if needed) the nodtool binary bundled by build-appimage.sh and Build-Installer.ps1</Description>
<DebugType>embedded</DebugType>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\WiiCompiled.Setup.Common\WiiCompiled.Setup.Common.csproj" />
</ItemGroup>
</Project>
@@ -1,14 +1,14 @@
namespace WiiCompiled.Setup;
namespace WiiCompiled.Setup.Common;
/// <summary>
/// One entry of an exact regular directory tree. Directory topology is part of the content
/// contract everywhere this walker is used: an empty directory can be a runtime-visible asset just
/// as a regular file can be, so it must not disappear from a content identity or a staged copy.
/// </summary>
internal sealed record RegularTreeEntry(string RelativePath, string FullPath, bool IsDirectory,
public sealed record RegularTreeEntry(string RelativePath, string FullPath, bool IsDirectory,
bool IsEmptyDirectory, long Length);
internal static class FileSystemUtilities
public static class FileSystemUtilities
{
public static void CopyDirectory(string source, string destination,
CancellationToken cancellationToken = default)
@@ -1,9 +1,9 @@
using System.Text.Json;
namespace WiiCompiled.Setup;
namespace WiiCompiled.Setup.Common;
/// <summary>Reads and atomically writes the small JSON state documents kept inside an installation.</summary>
internal static class JsonState
/// <summary>Reads and atomically writes the small JSON state documents each installer keeps.</summary>
public static class JsonState
{
private static readonly JsonSerializerOptions ReadOptions = new() { PropertyNameCaseInsensitive = true };
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
@@ -17,11 +17,17 @@ internal static class JsonState
catch
{
// A truncated or hand-edited state document must degrade into "unknown", which every
// caller already treats as "assume stale and rebuild", not into a failed launch.
// caller already treats as "assume stale and rebuild", not into a crash.
return null;
}
}
public static void Write<T>(string path, T value) =>
FileSystemUtilities.WriteAtomic(path, JsonSerializer.Serialize(value, WriteOptions));
public static void Write<T>(string path, T value)
{
var directory = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
var tempPath = path + ".tmp-" + Guid.NewGuid().ToString("N");
File.WriteAllText(tempPath, JsonSerializer.Serialize(value, WriteOptions));
File.Move(tempPath, path, overwrite: true);
}
}
@@ -0,0 +1,38 @@
using System.Text.RegularExpressions;
namespace WiiCompiled.Setup.Common;
/// <summary>Disc metadata parsed from `nodtool info`'s stdout.</summary>
public sealed record NodToolDiscInfo(string GameId, string Title, int Revision);
/// <summary>
/// Parses the plain-text stdout of `nodtool info &lt;iso&gt;`. nodtool has no JSON output mode, but
/// prints one unconditional disc-level Title/Game ID/Disc-Revision block (via its own
/// `print_header`) before any per-partition breakdown - Wii discs also have differently-scoped
/// "Title"/"Game ID" lines per update/channel partition further down, so the first match of each
/// pattern is always the disc-level one both installers want.
/// </summary>
public static partial class NodToolInfoParser
{
public static NodToolDiscInfo Parse(string infoStdout)
{
var gameIdMatch = GameIdLine().Match(infoStdout);
if (!gameIdMatch.Success)
throw new InvalidOperationException("nodtool did not return disc metadata.");
var titleMatch = TitleLine().Match(infoStdout);
var revisionMatch = RevisionLine().Match(infoStdout);
return new NodToolDiscInfo(
GameId: gameIdMatch.Groups[1].Value,
Title: titleMatch.Success ? titleMatch.Groups[1].Value : "",
Revision: revisionMatch.Success ? int.Parse(revisionMatch.Groups[1].Value) : 0);
}
[GeneratedRegex(@"^Game ID: (\S+)", RegexOptions.Multiline)]
private static partial Regex GameIdLine();
[GeneratedRegex(@"^Title: (.+)$", RegexOptions.Multiline)]
private static partial Regex TitleLine();
[GeneratedRegex(@"^Disc \d+, Revision (\d+)", RegexOptions.Multiline)]
private static partial Regex RevisionLine();
}
@@ -0,0 +1,67 @@
using System.Runtime.InteropServices;
namespace WiiCompiled.Setup.Common;
/// <summary>
/// Resolves the `nodtool` binary both installers use for Wii disc validation/extraction (see
/// NodToolInfoParser.cs), replacing the earlier dependency on `dolphin-tool`/`DolphinTool.exe`. A
/// caller can supply one directly; otherwise this downloads the matching prebuilt release binary
/// from encounter/nod and caches it at Launcher/artifacts/nodtool[.exe].
///
/// Shared by: WiiCompiled.Setup.Linux/DiscTool.cs (falls back to this at end-user install time on
/// a plain git checkout), and WiiCompiled.Setup.Common.Cli (invoked once at packaging time by both
/// build-appimage.sh and Build-Installer.ps1 to acquire the copy each bundles).
/// </summary>
public static class NodToolProvider
{
public const string Version = "v2.0.0-alpha.10";
public static async Task<string> ResolveAsync(string workspace, CancellationToken cancellationToken)
{
var cacheName = OperatingSystem.IsWindows() ? "nodtool.exe" : "nodtool";
var cachePath = Path.Combine(workspace, "Launcher", "artifacts", cacheName);
if (File.Exists(cachePath)) return cachePath;
var url = $"https://github.com/encounter/nod/releases/download/{Version}/{AssetName()}";
Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!);
var tempPath = cachePath + ".tmp";
using (var http = new HttpClient())
using (var response = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken))
{
response.EnsureSuccessStatusCode();
await using var fileStream = File.Create(tempPath);
await response.Content.CopyToAsync(fileStream, cancellationToken);
}
File.Move(tempPath, cachePath, overwrite: true);
if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(cachePath,
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
}
return cachePath;
}
private static string AssetName()
{
if (OperatingSystem.IsWindows())
{
return RuntimeInformation.OSArchitecture switch
{
Architecture.X64 => "nodtool-windows-x86_64.exe",
Architecture.Arm64 => "nodtool-windows-arm64.exe",
Architecture.X86 => "nodtool-windows-x86.exe",
var other => throw new PlatformNotSupportedException($"No prebuilt nodtool release for Windows {other}"),
};
}
return RuntimeInformation.OSArchitecture switch
{
Architecture.X64 => "nodtool-linux-x86_64",
Architecture.Arm64 => "nodtool-linux-aarch64",
Architecture.X86 => "nodtool-linux-i686",
var other => throw new PlatformNotSupportedException($"No prebuilt nodtool release for Linux {other}"),
};
}
}
@@ -1,4 +1,4 @@
namespace WiiCompiled.Setup;
namespace WiiCompiled.Setup.Common;
/// <summary>
/// A portable installation is a self-contained directory tree the user can move or carry on removable media:
@@ -9,8 +9,11 @@ namespace WiiCompiled.Setup;
/// </code>
/// The runtime finds the same root independently (<c>runtime/include/runtime_config.h</c>,
/// <c>PortableRootDirectory</c>); this class must keep the same marker name, layout, and depth bound.
/// Shared by both installers - Linux's CLI has no <c>--portable</c> flag, so it only ever calls
/// <see cref="TryFind"/>/<see cref="UserDataDirectory"/>/<see cref="Contains"/> (always missing,
/// since it never creates a marker file), not <see cref="Create"/>.
/// </summary>
internal static class PortableRoot
public static class PortableRoot
{
public const string MarkerFileName = "portable.txt";
public const string UserDataDirectoryName = "UserData";
@@ -72,7 +75,7 @@ internal static class PortableRoot
if (!File.Exists(marker))
{
File.WriteAllText(marker,
$"{ProductInfo.Name} portable installation." + Environment.NewLine +
"WiiCompiled portable installation." + Environment.NewLine +
"This marker makes the runtime keep Config.toml, NAND, Cache, and Logs in UserData\\ " +
"beside it instead of in %LOCALAPPDATA%." + Environment.NewLine +
"Delete it to make this installation use per-user application data again." +
@@ -90,54 +93,3 @@ internal static class PortableRoot
private static string Normalize(string path) => FileSystemUtilities.NormalizePath(path);
}
/// <summary>
/// A portable root can be moved or renamed between operations. Every installed-host operation that
/// reads <c>install-state.json</c> passes through here first so exactly one place decides what a
/// moved installation means, and so a non-portable installation is never touched.
/// </summary>
internal static class PortableInstallHealing
{
/// <summary>
/// Reconciles a moved portable installation with its recorded location: the state file adopts the
/// directory it was actually found in, and the native build tree is discarded because its
/// CMake cache holds absolute paths from the old location. Returns whether anything was healed.
/// </summary>
public static bool HealMovedInstall(Installation installation, IInstallReporter? reporter = null)
{
// Guard: an ordinary installation that disagrees with its state file is a real problem for
// the operation to report, not something to silently rewrite.
if (PortableRoot.TryFind(installation.Root) is null) return false;
var state = installation.ReadInstallState();
if (state is not { SchemaVersion: 1 } || string.IsNullOrWhiteSpace(state.InstallDir)) return false;
string recorded;
try
{
recorded = FileSystemUtilities.NormalizePath(state.InstallDir);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
{
recorded = state.InstallDir;
}
if (recorded.Equals(installation.Root, StringComparison.OrdinalIgnoreCase)) return false;
var previous = state.InstallDir;
state.InstallDir = installation.Root;
JsonState.Write(installation.InstallStatePath, state);
// The configured native build directory bakes absolute source, toolchain, and output paths
// into CMakeCache.txt. After a move it is unusable and would fail the next configure rather
// than being reused, so it is removed and reconfigured from scratch on the next build.
var nativeBuild = Path.Combine(installation.WorkspaceDirectory, "native-build");
var hadNativeBuild = Directory.Exists(nativeBuild);
if (hadNativeBuild) FileSystemUtilities.DeleteDirectoryIfExists(nativeBuild);
reporter?.Diagnostic(
$"This portable installation moved from {previous} to {installation.Root}. " +
"The recorded location was updated" +
(hadNativeBuild ? " and the location-bound native build cache was discarded." : "."));
return true;
}
}
@@ -1,16 +1,17 @@
namespace WiiCompiled.Setup;
namespace WiiCompiled.Setup.Common;
/// <summary>
/// Resolves the one canonical Retro Rewind install. Wheel Wizard owns and passes it as
/// <c>--retro-dir</c>; the backend only resolves, reads, and records it, never packages or copies it.
/// <c>--retro-dir</c>; each installer only resolves, reads, and records it, never packages or
/// copies it.
/// </summary>
internal static class RetroRewindSource
public static class RetroRewindSource
{
/// <summary>
/// Resolves the <c>RetroRewind6</c> folder from a selection that may be the folder itself or a
/// parent containing exactly one <c>RetroRewind6/Binaries/Code.pul</c>.
/// </summary>
internal static string ResolveRetroRewind6(string selected)
public static string ResolveRetroRewind6(string selected)
{
if (string.IsNullOrWhiteSpace(selected))
throw new InvalidDataException("Choose the canonical Retro Rewind folder.");
@@ -1,20 +1,28 @@
using System.Diagnostics;
using System.Buffers.Binary;
using System.Net;
using System.Security.Cryptography;
using System.Text.Json;
namespace WiiCompiled.Setup;
namespace WiiCompiled.Setup.Common;
internal static class InputValidation
/// <summary>
/// One validated, content-identified download in operation-owned scratch space. Callers use this
/// exact directory for both the update decision and any resulting build.
/// </summary>
public sealed record RetroWfcPayloadSnapshot(string Directory, string Sha256, long ByteLength);
/// <summary>
/// Downloads and verifies the Retro-WFC payload (a small, RSA-signed blob served from a single
/// fixed endpoint). Shared by both installers - moved here from WiiCompiled.Setup.Windows's
/// InputValidation.cs, which keeps every one of these method names as thin forwarding wrappers so
/// its many existing call sites (ProductRepairService.cs, LocalBuildService.cs, Installation.cs,
/// SelfTests.cs) needed no changes.
/// </summary>
public static class RetroWfcPayload
{
private const long MaximumRetroWfcPayloadBytes = 16L * 1024 * 1024;
// The payload is tens of kilobytes from a single fixed endpoint 30s is good.
private static readonly TimeSpan RetroWfcDownloadTimeout = TimeSpan.FromSeconds(30);
private static readonly TimeSpan RetroWfcRetryDelay = TimeSpan.FromSeconds(1);
private static readonly HashSet<string> SupportedDiscImageExtensions = new(
[".iso", ".gcm", ".gcz", ".ciso", ".wbfs", ".wia", ".rvz"],
StringComparer.OrdinalIgnoreCase);
public const string CurrentRetroWfcPayloadUri = "http://nas.play.rwfc.net/payload?g=RMCPD00";
private static readonly string RetroWfcOfflinePayloadFile =
@@ -37,43 +45,6 @@ internal static class InputValidation
private const int RetroWfcPayloadSignatureOffset = 0x10;
private const int RetroWfcPayloadMinimumBytes = 0x130;
public static void ValidateExtension(string gamePath)
{
if (!File.Exists(gamePath))
throw new FileNotFoundException("The selected game image does not exist.", gamePath);
var extension = Path.GetExtension(gamePath);
if (!SupportedDiscImageExtensions.Contains(extension))
throw new InvalidDataException(
"Select a complete Wii disc image in ISO, GCM, GCZ, CISO, WBFS, WIA, or RVZ format.");
}
public static async Task<DiscHeader> ReadDiscHeaderAsync(string dolphinTool, string gamePath,
CancellationToken cancellationToken = default)
{
ValidateExtension(gamePath);
var result = await ProcessRunner.RunAsync(dolphinTool,
["header", "-i", Path.GetFullPath(gamePath), "-j"], null, cancellationToken);
if (result.ExitCode != 0)
throw new InvalidDataException("DolphinTool could not read this disc image. " + result.CombinedOutput.Trim());
var json = result.StandardOutput.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault(line => line.TrimStart().StartsWith('{'));
if (json is null)
throw new InvalidDataException("DolphinTool did not return disc metadata.");
return JsonSerializer.Deserialize<DiscHeader>(json)
?? throw new InvalidDataException("DolphinTool returned invalid disc metadata.");
}
public static void EnsureCompatibleDisc(DiscHeader header, PayloadManifest manifest)
{
if (!header.GameId.Equals(manifest.ExpectedGameId, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException(
$"This build supports Mario Kart Wii PAL ({manifest.ExpectedGameId}). " +
$"The selected image is {header.GameId} ({header.InternalName}, {header.Region}).");
}
}
public static string ValidateStagedRetroWfcPayloadDirectory(string stagedDirectory,
RSAParameters? signingKey = null)
{
@@ -186,7 +157,7 @@ internal static class InputValidation
}
}
internal static bool IsTransientRetroWfcDownloadFailure(Exception exception,
public static bool IsTransientRetroWfcDownloadFailure(Exception exception,
CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested) return false;
@@ -232,73 +203,9 @@ internal static class InputValidation
"The Retro-WFC payload is not signed by the pinned Retro-WFC signing key.");
}
public static string Sha256File(string path)
private static string Sha256File(string path)
{
using var stream = File.OpenRead(path);
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
}
}
internal sealed record ProcessResult(int ExitCode, string StandardOutput, string StandardError)
{
public string CombinedOutput => StandardOutput + Environment.NewLine + StandardError;
}
internal static class ProcessRunner
{
/// <summary>Runs a redirected child process to completion. <paramref name="configure"/> sets up a
/// working directory or scrubbed environment; <paramref name="capture"/> is off for callers that only
/// forward output live, so a build's output isn't buffered in memory for nobody to read.</summary>
public static async Task<ProcessResult> RunAsync(string executable, IReadOnlyList<string> arguments,
Action<string>? output, CancellationToken cancellationToken,
Action<ProcessStartInfo>? configure = null, bool capture = true,
Action<Exception>? onTerminationFailure = null)
{
var info = new ProcessStartInfo
{
FileName = executable,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
};
foreach (var argument in arguments) info.ArgumentList.Add(argument);
configure?.Invoke(info);
using var process = new Process { StartInfo = info, EnableRaisingEvents = true };
var stdout = new List<string>();
var stderr = new List<string>();
process.OutputDataReceived += (_, e) => { if (e.Data is not null) { if (capture) stdout.Add(e.Data); output?.Invoke(e.Data); } };
process.ErrorDataReceived += (_, e) => { if (e.Data is not null) { if (capture) stderr.Add(e.Data); output?.Invoke(e.Data); } };
if (!process.Start()) throw new InvalidOperationException($"Could not start {executable}.");
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await WaitForExitAsync(process, cancellationToken, onTerminationFailure);
return new ProcessResult(process.ExitCode, string.Join(Environment.NewLine, stdout),
string.Join(Environment.NewLine, stderr));
}
public static async Task WaitForExitAsync(Process process, CancellationToken cancellationToken,
Action<Exception>? onTerminationFailure = null)
{
try
{
await process.WaitForExitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
try
{
if (!process.HasExited) process.Kill(entireProcessTree: true);
}
catch (Exception ex)
{
onTerminationFailure?.Invoke(ex);
}
await process.WaitForExitAsync(CancellationToken.None);
process.WaitForExit();
throw;
}
process.WaitForExit();
}
}
@@ -1,16 +1,19 @@
using System.Globalization;
using System.Text;
namespace WiiCompiled.Setup;
namespace WiiCompiled.Setup.Common;
internal sealed record RuntimeConfigSnapshot(bool Existed, byte[] Contents);
public sealed record RuntimeConfigSnapshot(bool Existed, byte[] Contents);
/// <summary>
/// Reads and writes the runtime's <c>Config.toml</c>. Every entry point takes the file it operates on
/// since its location depends on the installation (portable <c>UserData</c> vs. per-user app data);
/// callers obtain it once via <see cref="ResolveConfigPath"/>.
/// callers obtain it once via <see cref="ResolveConfigPath"/>. Shared by both installers - Linux
/// never creates a <see cref="PortableRoot.MarkerFileName"/> marker file, so
/// <see cref="ResolveConfigPath"/>/<see cref="FormatPathValue"/>'s portable-root lookups always miss
/// there and this degrades to the same plain per-user-app-data, always-absolute-path behavior a
/// non-portable Windows install already gets.
/// </summary>
internal static class RuntimeConfiguration
public static class RuntimeConfiguration
{
public const string ConfigFileName = "Config.toml";
@@ -51,7 +54,7 @@ internal static class RuntimeConfiguration
File.Delete(configPath);
}
internal static void SetDvdRoot(string configPath, string dvdRoot) =>
public static void SetDvdRoot(string configPath, string dvdRoot) =>
SetPath(configPath, "dvd_root", dvdRoot);
/// <summary>
@@ -59,21 +62,21 @@ internal static class RuntimeConfiguration
/// asset overlay by scanning this directory live at launch, so an asset-only Retro Rewind update
/// needs no backend work: the next launch simply reads the new files.
/// </summary>
internal static void SetRetroRewindRoot(string configPath, string retroRewindRoot) =>
public static void SetRetroRewindRoot(string configPath, string retroRewindRoot) =>
SetPath(configPath, "retro_rewind_root", retroRewindRoot);
/// <summary>The canonical Retro Rewind root, or null when no installation has recorded one.</summary>
public static string? GetRetroRewindRoot(string configPath) =>
GetResolvedPath(configPath, "retro_rewind_root");
internal static void RemoveRetroRewindRootIfOwned(string configPath, string retroRewindRoot) =>
public static void RemoveRetroRewindRootIfOwned(string configPath, string retroRewindRoot) =>
RemovePathIfOwned(configPath, "retro_rewind_root", retroRewindRoot);
internal static void RemoveDvdRootIfOwned(string configPath, string dvdRoot) =>
public static void RemoveDvdRootIfOwned(string configPath, string dvdRoot) =>
RemovePathIfOwned(configPath, "dvd_root", dvdRoot);
/// <summary>The raw stored text of a <c>[paths]</c> key, exactly as the file holds it.</summary>
internal static string? GetPath(string configPath, string key) =>
public static string? GetPath(string configPath, string key) =>
TryUnquoteToml(GetRawValue(configPath, "paths", key) ?? "", out var value) ? value : null;
/// <summary>
@@ -81,17 +84,17 @@ internal static class RuntimeConfiguration
/// <c>[paths]</c> value against the directory holding <c>Config.toml</c> (never the working
/// directory), so the host must resolve it the same way before comparing or reading it.
/// </summary>
internal static string? GetResolvedPath(string configPath, string key)
public static string? GetResolvedPath(string configPath, string key)
{
var stored = GetPath(configPath, key);
return string.IsNullOrWhiteSpace(stored) ? null : ResolveAgainstConfig(configPath, stored);
}
internal static string ConfigDirectory(string configPath) =>
public static string ConfigDirectory(string configPath) =>
Path.GetDirectoryName(Path.GetFullPath(configPath))
?? throw new InvalidOperationException($"{configPath} has no containing directory.");
internal static string ResolveAgainstConfig(string configPath, string value) =>
public static string ResolveAgainstConfig(string configPath, string value) =>
Path.GetFullPath(value, ConfigDirectory(configPath));
private static void SetPath(string configPath, string key, string value)
@@ -150,7 +153,7 @@ internal static class RuntimeConfiguration
}
/// <summary>The raw TOML literal stored for a key, or null when the section or key is absent.</summary>
internal static string? GetRawValue(string configPath, string section, string key)
public static string? GetRawValue(string configPath, string section, string key)
{
if (!File.Exists(configPath)) return null;
var header = $"[{section}]";
@@ -265,7 +268,7 @@ internal static class RuntimeConfiguration
private static string QuoteToml(string value) =>
"\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
internal static bool TryUnquoteToml(string value, out string result)
public static bool TryUnquoteToml(string value, out string result)
{
result = "";
if (value.Length < 2) return false;
@@ -292,5 +295,4 @@ internal static class RuntimeConfiguration
result = builder.ToString();
return true;
}
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>WiiCompiled.Setup.Common</RootNamespace>
<AssemblyName>WiiCompiled.Setup.Common</AssemblyName>
<Version>0.2.22</Version>
<Authors>patchzy</Authors>
<Product>WiiCompiled</Product>
<Description>Shared nodtool/Retro-WFC-payload logic used by both the Windows and Linux installers</Description>
<DebugType>embedded</DebugType>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
</Project>
@@ -0,0 +1,81 @@
using System.Diagnostics;
namespace WiiCompiled.Setup.Linux;
/// <summary>
/// Invokes Launcher/local-build.sh and turns its stdout into progress reports. Replaces
/// LocalBuildService.cs's hardcoded Windows PowerShell 5.1 invocation - there is no PowerShell
/// dependency here at all, just bash.
/// </summary>
internal static class BuildRunner
{
public static async Task RunAsync(
string workspace, string profile, string outputDir, string? baseOutputDir,
string? retroDir, string? retroWfcOfflineDir, bool skipRetroWfcPayload,
bool forceCleanBuild, string? translatorBin, IInstallReporter reporter, CancellationToken cancellationToken)
{
var script = Path.Combine(workspace, "Launcher", "local-build.sh");
if (!File.Exists(script)) throw new FileNotFoundException("local-build.sh is missing", script);
var startInfo = new ProcessStartInfo("bash")
{
WorkingDirectory = workspace,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
startInfo.ArgumentList.Add(script);
startInfo.ArgumentList.Add("--profile"); startInfo.ArgumentList.Add(profile);
startInfo.ArgumentList.Add("--output-dir"); startInfo.ArgumentList.Add(outputDir);
if (!string.IsNullOrEmpty(baseOutputDir))
{
startInfo.ArgumentList.Add("--base-output-dir"); startInfo.ArgumentList.Add(baseOutputDir);
}
if (!string.IsNullOrEmpty(retroDir))
{
// Still forwarded to local-build.sh under its own internal name -
// --retro-rewind-package-dir - matching LocalBuild.ps1's own -RetroRewindPackageDirectory.
startInfo.ArgumentList.Add("--retro-rewind-package-dir"); startInfo.ArgumentList.Add(retroDir);
}
if (!string.IsNullOrEmpty(retroWfcOfflineDir))
{
startInfo.ArgumentList.Add("--retro-wfc-offline-dir"); startInfo.ArgumentList.Add(retroWfcOfflineDir);
}
if (skipRetroWfcPayload) startInfo.ArgumentList.Add("--skip-retro-wfc-payload");
if (forceCleanBuild) startInfo.ArgumentList.Add("--force-clean-build");
if (!string.IsNullOrEmpty(translatorBin))
{
startInfo.ArgumentList.Add("--translator-bin"); startInfo.ArgumentList.Add(translatorBin);
}
using var process = new Process { StartInfo = startInfo };
var window = new BuildProgressWindow(reporter, InstallStages.Build, start: 6, end: 96);
process.OutputDataReceived += (_, e) => { if (e.Data is not null) window.Observe(e.Data); };
process.ErrorDataReceived += (_, e) => { if (e.Data is not null) reporter.Diagnostic(e.Data); };
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
try
{
await process.WaitForExitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
KillProcessTree(process);
throw;
}
if (process.ExitCode != 0)
{
throw new InvalidOperationException($"local-build.sh failed (exit {process.ExitCode}). See diagnostics above.");
}
}
private static void KillProcessTree(Process process)
{
try { process.Kill(entireProcessTree: true); } catch { /* best-effort */ }
}
}
@@ -0,0 +1,40 @@
namespace WiiCompiled.Setup.Linux;
/// <summary>
/// freedesktop.org .desktop application-menu entries. Replaces ShellIntegration.cs's registry
/// uninstall entry (no Linux analogue for an unpackaged tool - Windows already skips that step for
/// portable installs, this just applies that same behavior universally) and .lnk shortcuts.
/// </summary>
internal static class DesktopEntry
{
private static string ApplicationsDirectory =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "applications");
private static string PathFor(string profile) =>
Path.Combine(ApplicationsDirectory, $"wiicompiled-{profile}.desktop");
public static void Create(string profile, string displayName, string exePath)
{
// exePath is the installed native runtime binary itself (e.g.
// .../Install/Base/WiiCompiled) - each profile already gets its own .desktop file here,
// so there is no need to route through the setup tool's own launch-base/launch-retro
// subcommand dispatch first. Unquoted: the Desktop Entry spec's Exec grammar doesn't take
// a bare '"'-wrapped path, and none is needed here anyway - the only part of this path
// that varies is the username, which Unix forbids containing whitespace.
Directory.CreateDirectory(ApplicationsDirectory);
var contents =
"[Desktop Entry]\n" +
"Type=Application\n" +
$"Name={displayName}\n" +
$"Exec={exePath}\n" +
"Categories=Game;\n" +
"Terminal=false\n";
File.WriteAllText(PathFor(profile), contents);
}
public static void Remove(string profile)
{
var path = PathFor(profile);
if (File.Exists(path)) File.Delete(path);
}
}
@@ -0,0 +1,116 @@
using System.Security.Cryptography;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Linux;
/// <summary>
/// Validates and extracts the user's own Mario Kart Wii disc via `nodtool` (see
/// WiiCompiled.Setup.Common/NodToolProvider.cs) - a prebuilt, MIT/Apache-2.0-licensed CLI from
/// encounter/nod, replacing the earlier dependency on a system-installed `dolphin-tool`
/// (GPL-2.0-or-later, and not reliably packaged standalone by every distro).
/// </summary>
internal static class DiscTool
{
public static async Task ValidateAndExtractAsync(
string isoPath, ProjectManifest manifest, string assetsDirectory, string workspace,
string? nodToolBin, IInstallReporter reporter, CancellationToken cancellationToken)
{
var nodTool = nodToolBin ?? await NodToolProvider.ResolveAsync(workspace, cancellationToken);
// `nodtool info` only decodes the disc/partition headers (milliseconds); `nodtool extract`
// copies the whole data partition to disk (tens of seconds for a custom-track-heavy MKWii
// ISO). Checking the game ID first, before extracting, means a wrong disc fails fast -
// matching the original dolphin-tool `header` step this replaces.
reporter.Progress(InstallStages.ExtractDisc, "Reading the disc header", 2);
var info = NodToolInfoParser.Parse(await RunInfoAsync(nodTool, isoPath, cancellationToken));
if (!string.Equals(info.GameId, manifest.GameId, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"This disc is '{info.GameId}', not the expected '{manifest.GameId}' (Mario Kart Wii, region {manifest.Region}). " +
"Only your own legally-owned copy of that exact game/region can be used.");
}
// Extracted straight into Assets/DATA (kept, not a scratch dir) - the runtime reads course/
// texture/audio data from this directory live via [paths] dvd_root, not just at translation
// time, so it has to survive past this install (see Program.cs, which points dvd_root here).
reporter.Progress(InstallStages.ExtractDisc, "Extracting the disc image", 4);
var dataDir = Path.Combine(assetsDirectory, "DATA");
if (Directory.Exists(dataDir)) Directory.Delete(dataDir, recursive: true);
await RunExtractAsync(nodTool, isoPath, dataDir, cancellationToken);
var dolPath = Path.Combine(dataDir, "sys", "main.dol");
var relPath = Path.Combine(dataDir, "files", "rel", "StaticR.rel");
if (!File.Exists(dolPath)) throw new FileNotFoundException("nodtool did not produce main.dol", dolPath);
if (!File.Exists(relPath)) throw new FileNotFoundException("nodtool did not produce StaticR.rel", relPath);
var dolSha = Sha256Of(dolPath);
var relSha = Sha256Of(relPath);
if (!string.Equals(dolSha, manifest.DolSha256, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"main.dol sha256 mismatch: expected {manifest.DolSha256}, got {dolSha}. " +
"This disc revision does not match what this project's manifest is pinned to.");
}
if (!string.Equals(relSha, manifest.RelSha256, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"StaticR.rel sha256 mismatch: expected {manifest.RelSha256}, got {relSha}. " +
"This disc revision does not match what this project's manifest is pinned to.");
}
Directory.CreateDirectory(assetsDirectory);
File.Copy(dolPath, Path.Combine(assetsDirectory, "main.dol"), overwrite: true);
File.Copy(relPath, Path.Combine(assetsDirectory, "StaticR.rel"), overwrite: true);
reporter.Progress(InstallStages.ExtractDisc, "Disc validated and extracted", 6);
}
private static async Task<string> RunInfoAsync(string nodTool, string isoPath, CancellationToken cancellationToken)
{
var startInfo = new System.Diagnostics.ProcessStartInfo(nodTool)
{
ArgumentList = { "info", isoPath },
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
using var process = System.Diagnostics.Process.Start(startInfo)
?? throw new InvalidOperationException($"Failed to start {nodTool}.");
var stdout = await process.StandardOutput.ReadToEndAsync(cancellationToken);
var stderr = await process.StandardError.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
if (process.ExitCode != 0)
{
throw new InvalidOperationException(
$"nodtool could not read this disc image (exit {process.ExitCode}): {stderr}{stdout}".Trim());
}
return stdout;
}
private static string Sha256Of(string path)
{
using var stream = File.OpenRead(path);
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
}
private static async Task RunExtractAsync(string nodTool, string isoPath, string outDir, CancellationToken cancellationToken)
{
var startInfo = new System.Diagnostics.ProcessStartInfo(nodTool)
{
ArgumentList = { "extract", isoPath, outDir, "-q" },
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
using var process = System.Diagnostics.Process.Start(startInfo)
?? throw new InvalidOperationException($"Failed to start {nodTool}.");
var stdout = await process.StandardOutput.ReadToEndAsync(cancellationToken);
var stderr = await process.StandardError.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
if (process.ExitCode != 0)
{
throw new InvalidOperationException(
$"nodtool extract {isoPath} failed (exit {process.ExitCode}): {stderr}{stdout}");
}
}
}
@@ -0,0 +1,209 @@
using System.Text.Json;
namespace WiiCompiled.Setup.Linux;
// Ported near-verbatim from Launcher/WiiCompiled.Setup/InstallProgress.cs: this whole file is
// platform-neutral (System.Text.Json + Console only), so the NDJSON --progress-json wire protocol
// stays byte-for-byte the same shape a future GUI already speaks on Windows.
/// <summary>
/// Stable stage identifiers reported by <c>--progress-json</c>. Kept intentionally small for this
/// lean Linux installer (no toolkit-extraction/publish-transaction stages, since there is no
/// bundled toolkit or staged workspace copy here - see the plan's "operate on a git checkout"
/// scoping decision).
/// </summary>
internal static class InstallStages
{
public const string Validate = "validate";
public const string ExtractDisc = "extract-disc";
public const string Build = "build";
public const string Shortcuts = "shortcuts";
}
/// <summary>
/// Where an installation reports what it is doing. Progress is coarse and monotonic; raw translator
/// and compiler output is a diagnostic, never progress, because it is unbounded and machine-hostile.
/// </summary>
internal interface IInstallReporter
{
void Progress(string stage, string message, int percent);
void Diagnostic(string line);
}
/// <summary>
/// The <c>--progress-json</c> protocol: one JSON object per line on stdout, nothing else on stdout,
/// diagnostics on stderr. The terminal <c>result</c> line is written exactly once.
/// </summary>
internal sealed class NdjsonInstallReporter : IInstallReporter
{
private static readonly JsonSerializerOptions Options = new() { WriteIndented = false };
private readonly object _gate = new();
private int _lastPercent;
private bool _finished;
public void Progress(string stage, string message, int percent)
{
lock (_gate)
{
if (_finished) return;
// Percentages are clamped monotonic: a caller's progress bar must never walk backwards
// because a later stage happened to estimate a lower number.
_lastPercent = Math.Clamp(Math.Max(percent, _lastPercent), 0, 99);
WriteLine(new { type = "progress", stage, message, percent = _lastPercent });
}
}
public void Diagnostic(string line) => Console.Error.WriteLine(line);
public void Success(string installDirectory)
{
lock (_gate)
{
if (_finished) return;
_finished = true;
WriteLine(new { type = "result", success = true, version = ProductInfo.Version, installDir = installDirectory });
}
}
public void Failure(string error)
{
lock (_gate)
{
if (_finished) return;
_finished = true;
WriteLine(new { type = "result", success = false, error });
}
}
/// <summary>
/// The terminal result line is the caller's only completion signal, so no exit path may skip it.
/// Callers invoke this from a finally block; it is a no-op once a result was already written.
/// </summary>
public void EnsureFinished(string errorIfUnfinished) => Failure(errorIfUnfinished);
private static void WriteLine(object value)
{
Console.Out.WriteLine(JsonSerializer.Serialize(value, Options));
Console.Out.Flush();
}
}
/// <summary>Plain-text console reporting for a run without <c>--progress-json</c>.</summary>
internal sealed class ConsoleInstallReporter : IInstallReporter
{
public void Progress(string stage, string message, int percent) =>
Console.Out.WriteLine($"[{percent,3}%] {message}");
public void Diagnostic(string line) => Console.Out.WriteLine(line);
}
/// <summary>
/// Build step identifiers from local-build.sh's <c>MKWCBUILD:STEP:&lt;id&gt;</c> lines - the id is
/// the contract, matched against Launcher/local-build.sh's log_step() call sites.
/// </summary>
internal static class BuildStepIds
{
public const string BuildTranslator = "build-translator";
public const string ReuseBaseTranslation = "reuse-base-translation";
public const string RetranslateBase = "retranslate-base";
public const string TranslateBase = "translate-base";
public const string EmitBaseManifest = "emit-base-manifest";
public const string TranslateMod = "translate-mod";
public const string GenerateDataInit = "generate-data-init";
public const string EmitBuildShards = "emit-build-shards";
public const string ConfigureNative = "configure-native";
public const string Compile = "compile";
}
/// <summary>
/// Maps one local-build.sh run onto a slice of the overall percentage. local-build.sh announces
/// every step it starts with an <c>MKWCBUILD:</c> prefix, so the slice can advance on real events
/// instead of on a timer.
/// </summary>
internal sealed class BuildProgressWindow
{
private const string Marker = "MKWCBUILD:";
private const string StepMarker = "STEP:";
/// <summary>The fraction the compile step reaches; beyond it, compiler output is a heartbeat.</summary>
private const double CompileFraction = 0.58;
private static readonly (string Id, double Fraction, string Message)[] Steps =
[
(BuildStepIds.BuildTranslator, 0.04, "Building the translator"),
(BuildStepIds.ReuseBaseTranslation, 0.30, "Reusing the completed base translation"),
(BuildStepIds.RetranslateBase, 0.08, "The base translation is stale; retranslating it"),
(BuildStepIds.TranslateBase, 0.10, "Translating Mario Kart Wii"),
(BuildStepIds.EmitBaseManifest, 0.34, "Creating the translation manifest"),
(BuildStepIds.TranslateMod, 0.38, "Translating the Retro Rewind Code.pul"),
(BuildStepIds.GenerateDataInit, 0.44, "Generating game data initialization"),
(BuildStepIds.EmitBuildShards, 0.48, "Preparing the native build"),
(BuildStepIds.ConfigureNative, 0.52, "Configuring the compiler"),
(BuildStepIds.Compile, CompileFraction, "Compiling the game. This is the longest step"),
];
private readonly IInstallReporter _reporter;
private readonly string _stage;
private readonly int _start;
private readonly int _end;
private double _fraction;
private string _message = "Preparing the local build";
private int _reportedPercent = -1;
public BuildProgressWindow(IInstallReporter reporter, string stage, int start, int end)
{
_reporter = reporter;
_stage = stage;
_start = start;
_end = end;
}
public void Observe(string line)
{
var index = line.IndexOf(Marker, StringComparison.Ordinal);
if (index >= 0)
{
var text = line[(index + Marker.Length)..].Trim();
if (text.StartsWith(StepMarker, StringComparison.Ordinal))
{
var identifier = text[StepMarker.Length..];
var end = identifier.IndexOf(' ');
if (end >= 0) identifier = identifier[..end];
foreach (var (id, fraction, message) in Steps)
{
if (!id.Equals(identifier, StringComparison.Ordinal)) continue;
if (fraction > _fraction)
{
_fraction = fraction;
_message = message;
Emit();
}
return;
}
}
}
// Anything else - a plain MKWCBUILD note, or raw tool output - stays a diagnostic and only
// feeds the heartbeat below.
_reporter.Diagnostic(line);
// Compilation announces itself once and then emits thousands of compiler lines. Treat that
// output as a heartbeat so the slice keeps creeping forward, but only publish a progress
// line when the rounded percentage actually changes.
if (_fraction >= CompileFraction)
{
_fraction = Math.Min(0.97, _fraction + 0.0015);
Emit();
}
}
private void Emit()
{
var percent = Interpolate(_fraction);
if (percent == _reportedPercent) return;
_reportedPercent = percent;
_reporter.Progress(_stage, _message, percent);
}
private int Interpolate(double fraction) =>
(int)Math.Round(_start + (_end - _start) * Math.Clamp(fraction, 0, 1));
}
@@ -0,0 +1,31 @@
namespace WiiCompiled.Setup.Linux;
internal static class ProductInfo
{
public const string Name = "WiiCompiled";
public const string Version = "0.2.22";
}
/// <summary>One installed product's record inside install-state.json.</summary>
internal sealed class ProductInstallRecord
{
public string Profile { get; set; } = "";
public string InstallDirectory { get; set; } = "";
public string ExecutableName { get; set; } = "";
public string DolSha256 { get; set; } = "";
public string RelSha256 { get; set; } = "";
public string BuiltUtc { get; set; } = "";
}
/// <summary>
/// The whole flat state document this tool keeps at ~/.local/share/WiiCompiled/install-state.json.
/// Deliberately not a fingerprint tree: local-build.sh already does its own incremental-rebuild
/// caching, so this only needs to remember where things were installed and what they were built
/// against, not decide when to rebuild.
/// </summary>
internal sealed class InstallState
{
public int SchemaVersion { get; set; } = 1;
public string Workspace { get; set; } = "";
public List<ProductInstallRecord> Products { get; set; } = new();
}
+328
View File
@@ -0,0 +1,328 @@
using System.Security.Cryptography;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Linux;
internal static class Program
{
private static async Task<int> Main(string[] args)
{
// Checked anywhere in argv, not just args[0]: AppRun (Launcher/build-appimage.sh) prepends
// --workspace <cache> ahead of whatever the caller passed, so these can't assume position 0.
if (args.Length == 0 || args.Contains("-h") || args.Contains("--help")) { PrintUsage(); return 0; }
if (args.Contains("--version")) { Console.WriteLine(ProductInfo.Version); return 0; }
using var cts = new CancellationTokenSource();
// Replaces CancellationSignal.cs's named-EventWaitHandle IPC (Windows-only): SIGINT/SIGTERM
// are the portable, standard way for a parent (Wheel Wizard or a shell) to cancel this
// process and the build it spawned.
using var sigint = System.Runtime.InteropServices.PosixSignalRegistration.Create(
System.Runtime.InteropServices.PosixSignal.SIGINT, context => { context.Cancel = true; cts.Cancel(); });
using var sigterm = System.Runtime.InteropServices.PosixSignalRegistration.Create(
System.Runtime.InteropServices.PosixSignal.SIGTERM, context => { context.Cancel = true; cts.Cancel(); });
return await RunAsync(args, cts);
}
private static async Task<int> RunAsync(string[] args, CancellationTokenSource cts)
{
// AppRun (Launcher/build-appimage.sh) invokes this as `wiicompiled-setup --workspace
// <cache> <command> [options]` - a global flag ahead of the subcommand - so the command
// word is whichever token isn't part of a --flag/value pair, not strictly args[0].
var (command, flags) = ParseArgs(args);
if (command is null) { PrintUsage(); return 1; }
var progressJson = flags.ContainsKey("progress-json");
IInstallReporter reporter = progressJson ? new NdjsonInstallReporter() : new ConsoleInstallReporter();
try
{
switch (command)
{
case "install":
await InstallAsync(flags, reporter, cts.Token);
break;
case "uninstall":
Uninstall();
break;
case "launch-base":
return Launch("base", flags);
case "launch-retro":
return Launch("retro-rewind", flags);
case "check-products":
CheckProducts();
break;
default:
Console.Error.WriteLine($"Unknown command: {command}");
PrintUsage();
return 1;
}
(reporter as NdjsonInstallReporter)?.Success(flags.GetValueOrDefault("install-dir") ?? "");
return 0;
}
catch (OperationCanceledException)
{
Console.Error.WriteLine("Cancelled.");
(reporter as NdjsonInstallReporter)?.Failure("cancelled");
return 130;
}
catch (Exception ex)
{
Console.Error.WriteLine($"error: {ex.Message}");
(reporter as NdjsonInstallReporter)?.Failure(ex.Message);
return 1;
}
}
private static async Task InstallAsync(Dictionary<string, string?> flags, IInstallReporter reporter, CancellationToken token)
{
var retroDir = flags.GetValueOrDefault("retro-dir");
var installsRetro = !string.IsNullOrEmpty(retroDir);
var downloadPayload = flags.ContainsKey("download-retro-wfc-payload");
var skipPayload = flags.ContainsKey("skip-retro-wfc-payload");
if (installsRetro)
{
if (downloadPayload == skipPayload)
throw new ArgumentException(
"Choose exactly one Retro-WFC mode: --download-retro-wfc-payload or --skip-retro-wfc-payload.");
}
else if (downloadPayload || skipPayload)
{
throw new ArgumentException("A Retro-WFC payload option is valid only with --retro-dir.");
}
// Canonicalizes to the exact RetroRewind6 folder (accepting a parent folder or a symlink),
// the same validation Windows applies via this same shared method - local-build.sh's own
// check further down is a simpler backstop, not the primary validation anymore.
if (installsRetro) retroDir = RetroRewindSource.ResolveRetroRewind6(retroDir!);
var workspace = flags.GetValueOrDefault("workspace") ?? WorkspaceLocator.FindFrom(AppContext.BaseDirectory);
var manifest = ProjectManifest.Load(Path.Combine(workspace, "projects", "mkwii", "recomp.yml"));
var assetsDir = Path.Combine(workspace, "Assets");
reporter.Progress(InstallStages.Validate, "Checking prerequisites", 1);
if (flags.TryGetValue("game", out var isoPath) && !string.IsNullOrEmpty(isoPath))
{
await DiscTool.ValidateAndExtractAsync(isoPath, manifest, assetsDir, workspace,
flags.GetValueOrDefault("disc-tool-bin"), reporter, token);
}
else
{
var dol = Path.Combine(assetsDir, "main.dol");
var rel = Path.Combine(assetsDir, "StaticR.rel");
if (!File.Exists(dol) || !File.Exists(rel))
{
throw new InvalidOperationException(
"No --game ISO was given and Assets/main.dol + Assets/StaticR.rel are not already present. " +
"Either pass --game <path-to-iso>, or extract them yourself first (see translator/README.md).");
}
}
var state = JsonState.TryRead<InstallState>(StatePath) ?? new InstallState { Workspace = workspace };
state.Workspace = workspace;
var profile = installsRetro ? "both" : "base";
var profiles = installsRetro ? new[] { "base", "retro-rewind" } : new[] { "base" };
var baseInstallDir = installsRetro ? DefaultInstallDir("base") : null;
var installDir = flags.GetValueOrDefault("install-dir") ?? DefaultInstallDir(installsRetro ? "retro-rewind" : "base");
string? retroWfcOfflineDir = null;
if (downloadPayload)
{
// Reused if a previous install already downloaded and it's still valid - matches
// Windows's own reuse-if-valid behavior instead of re-downloading on every install.
var cacheDir = Path.Combine(workspace, "generated", "retro-wfc-payload");
reporter.Progress(InstallStages.Validate, "Preparing the Retro-WFC payload", 1);
try
{
RetroWfcPayload.ValidateStagedRetroWfcPayloadDirectory(cacheDir);
}
catch (InvalidDataException)
{
await RetroWfcPayload.DownloadRetroWfcPayloadAsync(
RetroWfcPayload.CurrentRetroWfcPayloadUri, cacheDir, token);
}
retroWfcOfflineDir = cacheDir;
}
await BuildRunner.RunAsync(
workspace, profile, installDir, baseInstallDir,
retroDir,
retroWfcOfflineDir,
skipPayload,
flags.ContainsKey("force-clean-build"),
flags.GetValueOrDefault("translator-bin"),
reporter, token);
reporter.Progress(InstallStages.Shortcuts, "Creating shortcuts", 98);
var dolSha = Sha256Of(Path.Combine(assetsDir, "main.dol"));
var relSha = Sha256Of(Path.Combine(assetsDir, "StaticR.rel"));
foreach (var p in profiles)
{
var dir = p == "base" ? (baseInstallDir ?? installDir) : installDir;
var exeName = p == "base" ? "WiiCompiled" : "RetroRewind";
var displayName = p == "base" ? "WiiCompiled (base game)" : "WiiCompiled (Retro Rewind)";
state.Products.RemoveAll(r => r.Profile == p);
state.Products.Add(new ProductInstallRecord
{
Profile = p,
InstallDirectory = dir,
ExecutableName = exeName,
DolSha256 = dolSha,
RelSha256 = relSha,
BuiltUtc = DateTime.UtcNow.ToString("O"),
});
DesktopEntry.Create(p, displayName, Path.Combine(dir, exeName));
}
JsonState.Write(StatePath, state);
// The runtime reads course/texture/audio data live from dvd_root at every launch, not just
// at translation time - without this the game fatally errors the instant it needs any file
// that isn't main.dol/StaticR.rel. Linux has no --portable flag, so this is always the
// per-user Config.toml (RuntimeConfiguration.ResolveConfigPath's Windows-only portable-root
// lookup has nothing to find here either way).
var configPath = RuntimeConfiguration.ApplicationDataConfigPath;
var dataDir = Path.Combine(assetsDir, "DATA");
if (Directory.Exists(dataDir))
{
RuntimeConfiguration.SetDvdRoot(configPath, dataDir);
}
if (installsRetro)
{
RuntimeConfiguration.SetRetroRewindRoot(configPath, retroDir!);
}
reporter.Progress(InstallStages.Shortcuts, "Install complete", 99);
}
private static void Uninstall()
{
// Matches Windows: UninstallService.cs removes the whole install directory unconditionally -
// there is no partial-product uninstall on either platform.
var state = JsonState.TryRead<InstallState>(StatePath) ?? new InstallState();
foreach (var record in state.Products.ToList())
{
if (Directory.Exists(record.InstallDirectory))
{
Directory.Delete(record.InstallDirectory, recursive: true);
}
DesktopEntry.Remove(record.Profile);
state.Products.Remove(record);
Console.WriteLine($"Removed {record.Profile} from {record.InstallDirectory}");
}
JsonState.Write(StatePath, state);
}
private static int Launch(string profile, Dictionary<string, string?> flags)
{
var state = JsonState.TryRead<InstallState>(StatePath);
var record = state?.Products.FirstOrDefault(r => r.Profile == profile);
if (record is null)
{
var installHint = profile == "retro-rewind"
? "install --retro-dir <RetroRewind6> {--download-retro-wfc-payload | --skip-retro-wfc-payload}"
: $"install --profile {profile}";
Console.Error.WriteLine($"{profile} is not installed. Run '{installHint}' first.");
return 1;
}
var exePath = Path.Combine(record.InstallDirectory, record.ExecutableName);
if (!File.Exists(exePath))
{
Console.Error.WriteLine($"Installed executable is missing: {exePath}. Run 'install --profile {profile}' again.");
return 1;
}
var startInfo = new System.Diagnostics.ProcessStartInfo(exePath)
{
WorkingDirectory = record.InstallDirectory,
UseShellExecute = false,
};
using var process = System.Diagnostics.Process.Start(startInfo);
process?.WaitForExit();
return process?.ExitCode ?? 1;
}
private static void CheckProducts()
{
var state = JsonState.TryRead<InstallState>(StatePath);
if (state is null || state.Products.Count == 0)
{
Console.WriteLine("Nothing installed.");
return;
}
var assetsDir = Path.Combine(state.Workspace, "Assets");
var currentDol = Sha256IfExists(Path.Combine(assetsDir, "main.dol"));
var currentRel = Sha256IfExists(Path.Combine(assetsDir, "StaticR.rel"));
foreach (var record in state.Products)
{
var exePath = Path.Combine(record.InstallDirectory, record.ExecutableName);
var present = File.Exists(exePath);
var stale = present && (currentDol != record.DolSha256 || currentRel != record.RelSha256);
var status = !present ? "MISSING" : stale ? "STALE (game assets changed since last build)" : "current";
Console.WriteLine($"{record.Profile,-14} {status,-45} {record.InstallDirectory}");
}
}
private static string Sha256Of(string path)
{
using var stream = File.OpenRead(path);
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
}
private static string? Sha256IfExists(string path) => File.Exists(path) ? Sha256Of(path) : null;
private static string StatePath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "WiiCompiled", "install-state.json");
private static string DefaultInstallDir(string profile) => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "WiiCompiled", "Install",
profile == "base" ? "Base" : "RetroRewind");
/// <summary>
/// A single pass that finds both the command word and every --flag[=value] pair, regardless
/// of order - a --flag may appear before or after the command (see the AppRun caller note in
/// RunAsync). The first token that is neither a --flag nor a value already consumed by the
/// preceding --flag is taken as the command.
/// </summary>
private static (string? Command, Dictionary<string, string?> Flags) ParseArgs(string[] args)
{
string? command = null;
var flags = new Dictionary<string, string?>();
for (var i = 0; i < args.Length; i++)
{
var arg = args[i];
if (arg.StartsWith("--", StringComparison.Ordinal))
{
var name = arg[2..];
if (i + 1 < args.Length && !args[i + 1].StartsWith("--", StringComparison.Ordinal))
{
flags[name] = args[++i];
}
else
{
flags[name] = null; // boolean flag
}
}
else if (command is null)
{
command = arg;
}
}
return (command, flags);
}
private static void PrintUsage()
{
Console.WriteLine("""
Usage: wiicompiled-setup <command> [options]
install [--game ISO_PATH] [--install-dir DIR] [--retro-dir DIR
{--download-retro-wfc-payload | --skip-retro-wfc-payload}]
[--force-clean-build] [--translator-bin PATH] [--disc-tool-bin PATH]
[--progress-json] [--workspace DIR]
uninstall
launch-base
launch-retro
check-products
--version
""");
}
}
@@ -0,0 +1,70 @@
using System.Text.RegularExpressions;
namespace WiiCompiled.Setup.Linux;
/// <summary>
/// The handful of facts this tool needs out of projects/mkwii/recomp.yml. Parsed literally line by
/// line - the same approach Launcher/NativeBuildFlags.ps1's Get-MkwProjectPins and
/// Launcher/local-build.sh already use - rather than pulling in a YAML library, since the manifest
/// is machine-written with a fixed shape.
/// </summary>
internal sealed class ProjectManifest
{
public required string GameId { get; init; }
public required string Region { get; init; }
public required string DolSha256 { get; init; }
public required string RelSha256 { get; init; }
public static ProjectManifest Load(string path)
{
if (!File.Exists(path)) throw new FileNotFoundException("Translation project file is missing", path);
string? gameId = null, region = null, dolSha = null, relSha = null;
string section = "";
string inputKey = "";
foreach (var raw in File.ReadLines(path))
{
var line = Regex.Replace(raw, "#.*$", "");
if (string.IsNullOrWhiteSpace(line)) continue;
var sectionMatch = Regex.Match(line, "^([A-Za-z0-9_]+):");
if (sectionMatch.Success)
{
section = sectionMatch.Groups[1].Value;
inputKey = "";
continue;
}
if (section == "inputs")
{
var keyMatch = Regex.Match(line, @"^\s{2}([A-Za-z0-9_]+):\s*$");
if (keyMatch.Success) { inputKey = keyMatch.Groups[1].Value; continue; }
var shaMatch = Regex.Match(line, @"^\s*sha256:\s*([0-9a-fA-F]{64})\s*$");
if (shaMatch.Success)
{
var value = shaMatch.Groups[1].Value.ToLowerInvariant();
if (inputKey == "dol") dolSha = value;
else if (inputKey == "rel") relSha = value;
}
}
else if (section == "project")
{
var idMatch = Regex.Match(line, @"^\s*game_id:\s*(\S+)\s*$");
if (idMatch.Success) gameId = idMatch.Groups[1].Value;
var regionMatch = Regex.Match(line, @"^\s*region:\s*(\S+)\s*$");
if (regionMatch.Success) region = regionMatch.Groups[1].Value;
}
}
if (gameId is null || region is null || dolSha is null || relSha is null)
{
throw new InvalidDataException(
$"{path} does not pin game_id/region/dol.sha256/rel.sha256; the project file is not the shape this tool expects.");
}
return new ProjectManifest { GameId = gameId, Region = region, DolSha256 = dolSha, RelSha256 = relSha };
}
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>WiiCompiled.Setup.Linux</AssemblyName>
<RootNamespace>WiiCompiled.Setup.Linux</RootNamespace>
<Version>0.2.22</Version>
<Authors>patchzy</Authors>
<Product>WiiCompiled</Product>
<Description>Command-line installer and launcher for WiiCompiled on Linux</Description>
<DebugType>embedded</DebugType>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\WiiCompiled.Setup.Common\WiiCompiled.Setup.Common.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,27 @@
namespace WiiCompiled.Setup.Linux;
/// <summary>
/// Finds the repo checkout this tool is running from by walking up from its own directory looking
/// for Launcher/local-build.sh - this tool operates directly on a git checkout (no bundled/staged
/// workspace copy), so there is no installed "Toolkit" layout to anchor on the way the Windows
/// installer's Installation.cs does.
/// </summary>
internal static class WorkspaceLocator
{
private const int MaxSearchDepth = 6;
public static string FindFrom(string startDirectory)
{
var current = new DirectoryInfo(startDirectory);
for (var level = 0; level <= MaxSearchDepth && current is not null; level++, current = current.Parent)
{
if (File.Exists(Path.Combine(current.FullName, "Launcher", "local-build.sh")))
{
return current.FullName;
}
}
throw new InvalidOperationException(
"Could not find the WiiCompiled repository (looked for Launcher/local-build.sh walking up " +
$"from {startDirectory}). Pass --workspace <path-to-checkout> explicitly.");
}
}
@@ -1,4 +1,4 @@
namespace WiiCompiled.Setup;
namespace WiiCompiled.Setup.Windows;
/// <summary>
/// Bridges a frontend-owned, named Windows event into the cancellation token used by setup.
@@ -1,4 +1,4 @@
namespace WiiCompiled.Setup;
namespace WiiCompiled.Setup.Windows;
internal enum AppMode
{
@@ -1,8 +1,9 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup;
namespace WiiCompiled.Setup.Windows;
internal sealed record RetroRewindCompileInputs(
string RetroRewindRoot,
@@ -1,6 +1,7 @@
using System.Text.Json;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup;
namespace WiiCompiled.Setup.Windows;
internal static class ConsoleCommands
{
@@ -63,8 +64,8 @@ internal static class ConsoleCommands
{
using var payload = PayloadArchive.OpenCurrent();
var manifest = payload.ReadManifest();
var tool = Path.Combine(temp, "DolphinTool.exe");
payload.ExtractEntry(InstalledLayout.ToolkitEntryPrefix + "DolphinTool.exe", tool);
var tool = Path.Combine(temp, "nodtool.exe");
payload.ExtractEntry(InstalledLayout.ToolkitEntryPrefix + "nodtool.exe", tool);
payload.ExtractDirectory(InstalledLayout.ToolkitEntryPrefix + "Redist", temp);
reporter?.Progress(InstallStages.Validate, "Checking the Wii disc image...", 10);
var header = InputValidation.ReadDiscHeaderAsync(tool, command.GamePath!).GetAwaiter().GetResult();
@@ -1,6 +1,6 @@
using System.Diagnostics;
namespace WiiCompiled.Setup;
namespace WiiCompiled.Setup.Windows;
internal static class GameLaunchService
{
@@ -0,0 +1,165 @@
using System.Diagnostics;
using System.Buffers.Binary;
using System.Net;
using System.Security.Cryptography;
using System.Text.Json;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Windows;
internal static class InputValidation
{
private static readonly HashSet<string> SupportedDiscImageExtensions = new(
[".iso", ".gcm", ".gcz", ".ciso", ".wbfs", ".wia", ".rvz"],
StringComparer.OrdinalIgnoreCase);
public static void ValidateExtension(string gamePath)
{
if (!File.Exists(gamePath))
throw new FileNotFoundException("The selected game image does not exist.", gamePath);
var extension = Path.GetExtension(gamePath);
if (!SupportedDiscImageExtensions.Contains(extension))
throw new InvalidDataException(
"Select a complete Wii disc image in ISO, GCM, GCZ, CISO, WBFS, WIA, or RVZ format.");
}
public static async Task<DiscHeader> ReadDiscHeaderAsync(string nodTool, string gamePath,
CancellationToken cancellationToken = default)
{
ValidateExtension(gamePath);
var result = await ProcessRunner.RunAsync(nodTool,
["info", Path.GetFullPath(gamePath)], null, cancellationToken);
if (result.ExitCode != 0)
throw new InvalidDataException("nodtool could not read this disc image. " + result.CombinedOutput.Trim());
var info = NodToolInfoParser.Parse(result.StandardOutput);
return new DiscHeader
{
GameId = info.GameId,
InternalName = info.Title,
Region = RegionFromGameId(info.GameId),
Revision = info.Revision,
};
}
private static string RegionFromGameId(string gameId) => gameId.Length >= 4
? gameId[3] switch
{
'P' => "PAL",
'E' => "NTSC-U",
'J' => "NTSC-J",
'K' => "Korea",
'W' => "Taiwan",
_ => gameId[3].ToString(),
}
: "Unknown";
public static void EnsureCompatibleDisc(DiscHeader header, PayloadManifest manifest)
{
if (!header.GameId.Equals(manifest.ExpectedGameId, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException(
$"This build supports Mario Kart Wii PAL ({manifest.ExpectedGameId}). " +
$"The selected image is {header.GameId} ({header.InternalName}, {header.Region}).");
}
}
// Thin forwarding wrappers: the actual download/RSA-verification logic lives in
// WiiCompiled.Setup.Common.RetroWfcPayload (shared with WiiCompiled.Setup.Linux) so there's one
// copy of it, not two. Kept under these names so every existing call site here
// (ProductRepairService.cs, LocalBuildService.cs, Installation.cs, SelfTests.cs) is unchanged.
public const string CurrentRetroWfcPayloadUri = RetroWfcPayload.CurrentRetroWfcPayloadUri;
public static string ValidateStagedRetroWfcPayloadDirectory(string stagedDirectory,
RSAParameters? signingKey = null) =>
RetroWfcPayload.ValidateStagedRetroWfcPayloadDirectory(stagedDirectory, signingKey);
public static string ResolveRetroWfcPayloadFile(string stagedDirectory,
RSAParameters? signingKey = null) =>
RetroWfcPayload.ResolveRetroWfcPayloadFile(stagedDirectory, signingKey);
public static string ComputeRetroWfcPayloadSha256(string stagedDirectory,
RSAParameters? signingKey = null) =>
RetroWfcPayload.ComputeRetroWfcPayloadSha256(stagedDirectory, signingKey);
public static void ValidateRetroWfcPayloadUri(string uriText) =>
RetroWfcPayload.ValidateRetroWfcPayloadUri(uriText);
public static Task<RetroWfcPayloadSnapshot> DownloadRetroWfcPayloadAsync(string uriText,
string destinationDirectory, CancellationToken cancellationToken) =>
RetroWfcPayload.DownloadRetroWfcPayloadAsync(uriText, destinationDirectory, cancellationToken);
internal static bool IsTransientRetroWfcDownloadFailure(Exception exception,
CancellationToken cancellationToken) =>
RetroWfcPayload.IsTransientRetroWfcDownloadFailure(exception, cancellationToken);
public static string Sha256File(string path)
{
using var stream = File.OpenRead(path);
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
}
}
internal sealed record ProcessResult(int ExitCode, string StandardOutput, string StandardError)
{
public string CombinedOutput => StandardOutput + Environment.NewLine + StandardError;
}
internal static class ProcessRunner
{
/// <summary>Runs a redirected child process to completion. <paramref name="configure"/> sets up a
/// working directory or scrubbed environment; <paramref name="capture"/> is off for callers that only
/// forward output live, so a build's output isn't buffered in memory for nobody to read.</summary>
public static async Task<ProcessResult> RunAsync(string executable, IReadOnlyList<string> arguments,
Action<string>? output, CancellationToken cancellationToken,
Action<ProcessStartInfo>? configure = null, bool capture = true,
Action<Exception>? onTerminationFailure = null)
{
var info = new ProcessStartInfo
{
FileName = executable,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
};
foreach (var argument in arguments) info.ArgumentList.Add(argument);
configure?.Invoke(info);
using var process = new Process { StartInfo = info, EnableRaisingEvents = true };
var stdout = new List<string>();
var stderr = new List<string>();
process.OutputDataReceived += (_, e) => { if (e.Data is not null) { if (capture) stdout.Add(e.Data); output?.Invoke(e.Data); } };
process.ErrorDataReceived += (_, e) => { if (e.Data is not null) { if (capture) stderr.Add(e.Data); output?.Invoke(e.Data); } };
if (!process.Start()) throw new InvalidOperationException($"Could not start {executable}.");
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await WaitForExitAsync(process, cancellationToken, onTerminationFailure);
return new ProcessResult(process.ExitCode, string.Join(Environment.NewLine, stdout),
string.Join(Environment.NewLine, stderr));
}
public static async Task WaitForExitAsync(Process process, CancellationToken cancellationToken,
Action<Exception>? onTerminationFailure = null)
{
try
{
await process.WaitForExitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
try
{
if (!process.HasExited) process.Kill(entireProcessTree: true);
}
catch (Exception ex)
{
onTerminationFailure?.Invoke(ex);
}
await process.WaitForExitAsync(CancellationToken.None);
process.WaitForExit();
throw;
}
process.WaitForExit();
}
}
@@ -1,7 +1,8 @@
using System.Security.Cryptography;
using System.Text;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup;
namespace WiiCompiled.Setup.Windows;
/// <summary>
/// A fail-fast, cross-process lock covering install, repair and launch operations for one install
@@ -1,6 +1,6 @@
using System.Text.Json;
namespace WiiCompiled.Setup;
namespace WiiCompiled.Setup.Windows;
/// <summary>
/// Stable stage identifiers reported by <c>--progress-json</c>. These are part of the public
@@ -1,4 +1,6 @@
namespace WiiCompiled.Setup;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Windows;
/// <summary>
/// Owns one temporary directory for an install operation. The name carries the installation's scope
@@ -1,4 +1,6 @@
namespace WiiCompiled.Setup;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Windows;
internal enum InstallTransactionEntryKind
{
@@ -1,4 +1,6 @@
namespace WiiCompiled.Setup;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Windows;
/// <summary>Provenance written by the bundled build script next to every product it produces.</summary>
internal sealed class LocalBuildProvenance
@@ -1,4 +1,4 @@
namespace WiiCompiled.Setup;
namespace WiiCompiled.Setup.Windows;
/// <summary>
/// Names of the installed/staged layout. Not cosmetic: payload and toolkit identities hash relative paths
@@ -26,7 +26,7 @@ internal static class InstalledLayout
/// </summary>
public static readonly string[] DependencyNames =
[
"abseil-cpp", "cppwinrt", "dawn_prebuilt", "fmt", "freetype", "imgui", "native_prebuilt",
"abseil-cpp", "cppwinrt", "dawn_prebuilt", "fmt", "freetype", "imgui", "libusb", "native_prebuilt",
"png", "SDL", "sqlite3", "tracy", "xxhash", "zlib", "zstd"
];
}
@@ -1,4 +1,6 @@
namespace WiiCompiled.Setup;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Windows;
internal sealed class InstallerEngine
{
@@ -57,9 +59,9 @@ internal sealed class InstallerEngine
var runtimeAssetsCurrent = sameToolkit && RuntimeAssetsAreCurrent(existing,
candidateRuntimeAssetsFingerprint, cancellationToken);
var installedDolphinTool = Path.Combine(existing.ToolkitDirectory, "DolphinTool.exe");
var installedNodTool = Path.Combine(existing.ToolkitDirectory, "nodtool.exe");
var extractToolkit = MustRefreshToolkit(sameToolkit, samePackageContent,
File.Exists(installedDolphinTool));
File.Exists(installedNodTool));
var extractWorkspace = !sameToolkit || !runtimeAssetsCurrent;
_reporter.Progress(InstallStages.ExtractToolkit,
@@ -77,9 +79,9 @@ internal sealed class InstallerEngine
payload.ExtractEntry(InstalledLayout.PayloadManifestFileName,
Path.Combine(staging, InstalledLayout.PayloadManifestFileName));
var dolphinTool = extractToolkit ? Path.Combine(toolkit, "DolphinTool.exe") : installedDolphinTool;
var nodTool = extractToolkit ? Path.Combine(toolkit, "nodtool.exe") : installedNodTool;
_reporter.Progress(InstallStages.Validate, "Checking the Wii disc image...", 2);
var header = await InputValidation.ReadDiscHeaderAsync(dolphinTool, options.GamePath,
var header = await InputValidation.ReadDiscHeaderAsync(nodTool, options.GamePath,
cancellationToken);
InputValidation.EnsureCompatibleDisc(header, manifest);
var canonicalRetroRoot = options.RetroDirectoryPath is null
@@ -153,7 +155,7 @@ internal sealed class InstallerEngine
if (reusableGameAssets is null)
{
await ExtractGameAssetsAsync(dolphinTool, options.GamePath,
await ExtractGameAssetsAsync(nodTool, options.GamePath,
Path.Combine(staging, "GameAssets"), manifest, cancellationToken);
}
@@ -164,8 +166,8 @@ internal sealed class InstallerEngine
internal static bool MustRefreshToolkit(bool sameToolkit, bool samePackageContent,
bool dolphinToolPresent) =>
!sameToolkit || !samePackageContent || !dolphinToolPresent;
bool nodToolPresent) =>
!sameToolkit || !samePackageContent || !nodToolPresent;
private static void AddComponent(List<InstallTransactionEntry> entries, string staging,
string installDirectory, string name) =>
@@ -469,18 +471,23 @@ internal sealed class InstallerEngine
}
}
private async Task ExtractGameAssetsAsync(string dolphinTool, string gamePath, string destination,
private async Task ExtractGameAssetsAsync(string nodTool, string gamePath, string destination,
PayloadManifest manifest, CancellationToken cancellationToken)
{
_reporter.Progress(InstallStages.ExtractDisc,
"Extracting the game disc. This is the longest preparation step...", 6);
var extraction = await ProcessRunner.RunAsync(dolphinTool,
["extract", "-i", Path.GetFullPath(gamePath), "-o", destination, "-g", "-q"],
// Extracted straight into a "DATA" subfolder so the on-disk layout matches what
// Installation.GameDataDirectory and every other reader of it already expect - nodtool
// itself has no such wrapper (it extracts sys/+files/ directly to whatever <outdir> is
// given), so this is purely destination-side, not a nodtool convention.
var dataRoot = Path.Combine(destination, "DATA");
var extraction = await ProcessRunner.RunAsync(nodTool,
["extract", Path.GetFullPath(gamePath), dataRoot, "-q"],
line => { if (!string.IsNullOrWhiteSpace(line)) _reporter.Diagnostic(line); },
cancellationToken);
if (extraction.ExitCode != 0)
throw new InvalidDataException("Game extraction failed. " + extraction.CombinedOutput.Trim());
ValidateExtractedGame(Path.Combine(destination, "DATA"), manifest);
ValidateExtractedGame(dataRoot, manifest);
}
private static void ValidateExtractedGame(string dataRoot, PayloadManifest manifest)
@@ -1,6 +1,7 @@
using System.Diagnostics;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup;
namespace WiiCompiled.Setup.Windows;
/// <summary>
/// <see cref="Both"/> runs one retro-aware translation and compiles the two products from a single
@@ -1,6 +1,7 @@
using System.Text.Json.Serialization;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup;
namespace WiiCompiled.Setup.Windows;
internal enum RetroWfcPayloadMode
{
@@ -54,12 +55,6 @@ internal sealed class PayloadManifest
public string NativeToolchainFingerprint { get; set; } = "";
}
/// <summary>
/// One validated, content-identified download in operation-owned scratch space. Callers use this
/// exact directory for both the update decision and any resulting build.
/// </summary>
internal sealed record RetroWfcPayloadSnapshot(string Directory, string Sha256, long ByteLength);
internal sealed class DiscHeader
{
[JsonPropertyName("game_id")]
@@ -2,7 +2,7 @@ using System.IO.Compression;
using System.Text;
using System.Text.Json;
namespace WiiCompiled.Setup;
namespace WiiCompiled.Setup.Windows;
internal sealed class PayloadArchive : IDisposable
{
@@ -0,0 +1,54 @@
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Windows;
/// <summary>
/// A portable root can be moved or renamed between operations. Every installed-host operation that
/// reads <c>install-state.json</c> passes through here first so exactly one place decides what a
/// moved installation means, and so a non-portable installation is never touched.
/// </summary>
internal static class PortableInstallHealing
{
/// <summary>
/// Reconciles a moved portable installation with its recorded location: the state file adopts the
/// directory it was actually found in, and the native build tree is discarded because its
/// CMake cache holds absolute paths from the old location. Returns whether anything was healed.
/// </summary>
public static bool HealMovedInstall(Installation installation, IInstallReporter? reporter = null)
{
// Guard: an ordinary installation that disagrees with its state file is a real problem for
// the operation to report, not something to silently rewrite.
if (PortableRoot.TryFind(installation.Root) is null) return false;
var state = installation.ReadInstallState();
if (state is not { SchemaVersion: 1 } || string.IsNullOrWhiteSpace(state.InstallDir)) return false;
string recorded;
try
{
recorded = FileSystemUtilities.NormalizePath(state.InstallDir);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
{
recorded = state.InstallDir;
}
if (recorded.Equals(installation.Root, StringComparison.OrdinalIgnoreCase)) return false;
var previous = state.InstallDir;
state.InstallDir = installation.Root;
JsonState.Write(installation.InstallStatePath, state);
// The configured native build directory bakes absolute source, toolchain, and output paths
// into CMakeCache.txt. After a move it is unusable and would fail the next configure rather
// than being reused, so it is removed and reconfigured from scratch on the next build.
var nativeBuild = Path.Combine(installation.WorkspaceDirectory, "native-build");
var hadNativeBuild = Directory.Exists(nativeBuild);
if (hadNativeBuild) FileSystemUtilities.DeleteDirectoryIfExists(nativeBuild);
reporter?.Diagnostic(
$"This portable installation moved from {previous} to {installation.Root}. " +
"The recorded location was updated" +
(hadNativeBuild ? " and the location-bound native build cache was discarded." : "."));
return true;
}
}
@@ -1,4 +1,6 @@
namespace WiiCompiled.Setup;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Windows;
/// <summary>
/// Reconciles installed products against the canonical Retro Rewind install Wheel Wizard owns: the
@@ -1,4 +1,4 @@
namespace WiiCompiled.Setup;
namespace WiiCompiled.Setup.Windows;
using System.Runtime.InteropServices;
@@ -121,7 +121,7 @@ internal static class PlatformChecks
internal static class ProductInfo
{
public const string Name = "WiiCompiled";
public const string Version = "0.2.21";
public const string Version = "0.2.25";
/// <summary>
/// The setup executable is copied into the installation under this name. It is the launcher and
@@ -1,6 +1,7 @@
using System.Diagnostics;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup;
namespace WiiCompiled.Setup.Windows;
/// <summary>
/// Refuses to replace installed products while one of them is running: publishing renames the
@@ -1,4 +1,6 @@
namespace WiiCompiled.Setup;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Windows;
/// <summary>
/// The one path by which a product receives its copied runtime assets, shared by install and repair.
@@ -1,7 +1,8 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup;
namespace WiiCompiled.Setup.Windows;
internal static class SelfTests
{
@@ -143,17 +144,17 @@ internal static class SelfTests
private static void TestToolkitRefreshDecision()
{
if (!InstallerEngine.MustRefreshToolkit(sameToolkit: false, samePackageContent: true,
dolphinToolPresent: true))
nodToolPresent: true))
throw new Exception("A republished workspace kept the installed toolkit; the shipped " +
"translator and project file could come from different releases.");
if (!InstallerEngine.MustRefreshToolkit(sameToolkit: true, samePackageContent: false,
dolphinToolPresent: true))
nodToolPresent: true))
throw new Exception("Changed toolkit package content was not extracted.");
if (!InstallerEngine.MustRefreshToolkit(sameToolkit: true, samePackageContent: true,
dolphinToolPresent: false))
throw new Exception("A missing DolphinTool.exe did not force toolkit extraction.");
nodToolPresent: false))
throw new Exception("A missing nodtool.exe did not force toolkit extraction.");
if (InstallerEngine.MustRefreshToolkit(sameToolkit: true, samePackageContent: true,
dolphinToolPresent: true))
nodToolPresent: true))
throw new Exception("An unchanged toolkit was needlessly re-extracted.");
}
@@ -1010,7 +1011,7 @@ internal static class SelfTests
throw new Exception("The toolkit fingerprint is not stable.");
// A file that has nothing to do with generated code must not invalidate every install.
File.WriteAllText(Path.Combine(root, "Toolkit", "DolphinTool.exe"), "irrelevant");
File.WriteAllText(Path.Combine(root, "Toolkit", "nodtool.exe"), "irrelevant");
if (ToolkitFingerprint.Compute(root) != first)
throw new Exception("An unrelated toolkit file changed the fingerprint.");
@@ -1177,7 +1178,7 @@ internal static class SelfTests
"x86_64-w64-mingw32-clang++.exe", "x86_64-w64-mingw32-windres.exe"
})
File.WriteAllText(Path.Combine(root, "Toolkit", "llvm-mingw", "bin", executable), executable);
File.WriteAllText(Path.Combine(root, "Toolkit", "DolphinTool.exe"), "tool");
File.WriteAllText(Path.Combine(root, "Toolkit", "nodtool.exe"), "tool");
File.WriteAllText(Path.Combine(root, "BuildWorkspace", "LocalBuild.ps1"), "# build");
File.WriteAllText(Path.Combine(root, "BuildWorkspace", "NativeBuildFlags.ps1"), "# flags");
File.WriteAllText(Path.Combine(root, "BuildWorkspace", "projects", "mkwii", "recomp.yml"), "profiles: {}");
@@ -1,6 +1,6 @@
using Microsoft.Win32;
namespace WiiCompiled.Setup;
namespace WiiCompiled.Setup.Windows;
internal static class ShellIntegration
{
@@ -1,7 +1,8 @@
using System.Security.Cryptography;
using System.Text;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup;
namespace WiiCompiled.Setup.Windows;
/// <summary>
/// Content identity of everything that decides what the locally produced executables contain.
@@ -78,12 +79,12 @@ internal static class ToolkitFingerprint
var workspace = InstalledLayout.Workspace(root);
var entries = new SortedDictionary<string, string>(StringComparer.Ordinal);
// DolphinTool validates/extracts the user disc but does not influence generated products.
// nodtool validates/extracts the user disc but does not influence generated products.
// Everything else in Toolkit can affect translation, compilation, linking, or copied
// runtime support and therefore belongs to the compile identity.
AddDirectory(entries, root, toolkit, null,
cancellationToken,
file => !Path.GetFileName(file).Equals("DolphinTool.exe", StringComparison.OrdinalIgnoreCase));
file => !Path.GetFileName(file).Equals("nodtool.exe", StringComparison.OrdinalIgnoreCase));
AddFile(entries, root, Path.Combine(workspace, "LocalBuild.ps1"), cancellationToken);
AddFile(entries, root, Path.Combine(workspace, "NativeBuildFlags.ps1"), cancellationToken);
AddDirectory(entries, root, Path.Combine(workspace, "projects"), null, cancellationToken);
@@ -1,7 +1,8 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup;
namespace WiiCompiled.Setup.Windows;
internal static class UninstallService
{
@@ -5,13 +5,16 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>WiiCompiled.Setup</AssemblyName>
<RootNamespace>WiiCompiled.Setup</RootNamespace>
<RootNamespace>WiiCompiled.Setup.Windows</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<Version>0.2.21</Version>
<Version>0.2.25</Version>
<Authors>patchzy</Authors>
<Product>WiiCompiled</Product>
<Description>Command-line installer and launcher for WiiCompiled</Description>
<DebugType>embedded</DebugType>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\WiiCompiled.Setup.Common\WiiCompiled.Setup.Common.csproj" />
</ItemGroup>
</Project>
@@ -1,4 +1,4 @@
namespace WiiCompiled.Setup;
namespace WiiCompiled.Setup.Windows;
internal static class WorkspaceTimestamps
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env bash
# Packages Launcher/WiiCompiled.Setup.Linux as a self-contained AppImage: a single file Wheel
# Wizard (or anyone else) can fetch and execute with no git clone, no `dotnet` install, and no
# `dolphin-tool` package required at all. The installer and translator are published as
# self-contained binaries, and `nodtool` (a prebuilt MIT/Apache-2.0 CLI from encounter/nod, see
# NodToolProvider.cs) is downloaded and bundled too - AppRun passes --translator-bin and
# --disc-tool-bin so local-build.sh/DiscTool.cs skip their from-source/download fallbacks entirely.
# It still shells out to system clang/cmake/ninja - no C/C++ toolchain is bundled, matching
# Launcher/local-build.sh's own remaining prerequisites.
#
# An AppImage mounts read-only, but local-build.sh writes generated/, native-build/, Assets/, etc.
# into the workspace it's given. So AppRun (written below) copies the bundled workspace snapshot
# out to a writable cache directory on first run, and only ever re-syncs the bundled directories
# (runtime/, aurora-main/, projects/, local-build.sh) on a later run whose bundled version changed
# - generated/native-build/Assets/PulsarPacks live only in that writable cache and are never
# touched by the sync, so local-build.sh's own incremental caching survives across runs and across
# AppImage updates. translator/ isn't part of this snapshot at all: it's published as its own
# self-contained binary (usr/bin/translator-cli) below and never needs a writable copy.
set -euo pipefail
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
workspace=$(cd "$script_dir/.." && pwd)
output_dir="$workspace/Launcher/dist"
appimagetool_override=""
while [[ $# -gt 0 ]]; do
case "$1" in
--output-dir) output_dir=$2; shift 2 ;;
--appimagetool) appimagetool_override=$2; shift 2 ;;
-h|--help)
echo "Usage: build-appimage.sh [--output-dir DIR] [--appimagetool PATH]"
exit 0
;;
*) echo "build-appimage.sh: unknown argument: $1" >&2; exit 1 ;;
esac
done
appdir="$workspace/Launcher/artifacts/appimage-build/AppDir"
rm -rf "$appdir"
mkdir -p "$appdir/usr/bin" "$appdir/workspace/Launcher"
echo "Publishing the installer (self-contained linux-x64)..."
publish_tmp="$workspace/Launcher/artifacts/appimage-build/publish"
rm -rf "$publish_tmp"
dotnet publish "$workspace/Launcher/WiiCompiled.Setup.Linux" -c Release -r linux-x64 \
--self-contained -p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true \
-o "$publish_tmp"
cp "$publish_tmp/WiiCompiled.Setup.Linux" "$appdir/usr/bin/wiicompiled-setup"
chmod +x "$appdir/usr/bin/wiicompiled-setup"
# Published as a self-contained binary too, so an AppImage user never needs a `dotnet` SDK on
# PATH at all - local-build.sh is told about it via --translator-bin and skips its own
# dotnet-build-from-source step entirely (see local-build.sh's translator resolution branch).
echo "Publishing the translator (self-contained linux-x64)..."
translator_publish_tmp="$workspace/Launcher/artifacts/appimage-build/publish-translator"
rm -rf "$translator_publish_tmp"
dotnet publish "$workspace/translator/src/Translator.Cli" -c Release -r linux-x64 \
--self-contained -p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true \
-o "$translator_publish_tmp"
cp "$translator_publish_tmp/Translator.Cli" "$appdir/usr/bin/translator-cli"
chmod +x "$appdir/usr/bin/translator-cli"
# Resolved via the shared WiiCompiled.Setup.Common.Cli helper (also used by Build-Installer.ps1 on
# Windows) rather than a second curl/version-pin copy here: it downloads and caches the same way
# NodToolProvider.cs always does (Launcher/artifacts/nodtool), so there is exactly one place that
# knows the nodtool version/URL/platform-asset mapping.
echo "Resolving nodtool..."
nodtool_path=$(dotnet run --project "$workspace/Launcher/WiiCompiled.Setup.Common.Cli" -c Release -- \
--workspace "$workspace" | tail -n1)
cp "$nodtool_path" "$appdir/usr/bin/nodtool"
chmod +x "$appdir/usr/bin/nodtool"
echo "Staging the bundled workspace snapshot..."
for dir in runtime aurora-main projects; do
cp -r "$workspace/$dir" "$appdir/workspace/$dir"
done
# Mirrors Build-Installer.ps1's own staging exclusions exactly: aurora-main/extern/CMakeLists.txt
# is the real FetchContent driver and must ship, but any already-fetched dependency *subdirectory*
# a developer's local checkout accumulated under extern/ is stale/large build output, not a
# release input - only directories inside extern/ are stripped, never the file itself. runtime/build
# is a plain developer build directory.
find "$appdir/workspace/aurora-main/extern" -mindepth 1 -maxdepth 1 -type d -exec rm -rf {} +
rm -rf "$appdir/workspace/runtime/build"
cp "$workspace/Launcher/local-build.sh" "$appdir/workspace/Launcher/local-build.sh"
if git -C "$workspace" rev-parse HEAD >/dev/null 2>&1; then
git -C "$workspace" rev-parse HEAD > "$appdir/workspace/.bundle-version"
else
date -u +%s > "$appdir/workspace/.bundle-version"
fi
echo "Writing AppRun..."
cat > "$appdir/AppRun" <<'APPRUN'
#!/bin/bash
set -euo pipefail
HERE="$(dirname "$(readlink -f "$0")")"
CACHE="${XDG_DATA_HOME:-$HOME/.local/share}/WiiCompiled/workspace"
if [ ! -f "$CACHE/.bundle-version" ] || \
[ "$(cat "$HERE/workspace/.bundle-version")" != "$(cat "$CACHE/.bundle-version")" ]; then
mkdir -p "$CACHE/Launcher"
for dir in runtime aurora-main projects; do
rm -rf "$CACHE/$dir"
cp -r "$HERE/workspace/$dir" "$CACHE/$dir"
done
cp "$HERE/workspace/Launcher/local-build.sh" "$CACHE/Launcher/local-build.sh"
cp "$HERE/workspace/.bundle-version" "$CACHE/.bundle-version"
fi
exec "$HERE/usr/bin/wiicompiled-setup" --workspace "$CACHE" \
--translator-bin "$HERE/usr/bin/translator-cli" \
--disc-tool-bin "$HERE/usr/bin/nodtool" "$@"
APPRUN
chmod +x "$appdir/AppRun"
echo "Writing desktop entry and icon..."
cat > "$appdir/wiicompiled-setup.desktop" <<'DESKTOP'
[Desktop Entry]
Type=Application
Name=WiiCompiled Setup
Comment=Translate, compile, and launch Mario Kart Wii natively on Linux
Exec=AppRun
Icon=wiicompiled-setup
Categories=Game;
Terminal=true
DESKTOP
# No WiiCompiled logo/icon asset exists anywhere in this repo yet. appimagetool refuses to package
# without one, so this is a minimal solid-color placeholder - a one-line swap for real branding
# later (just replace this generated file with a real wiicompiled-setup.png before packaging).
python3 - "$appdir/wiicompiled-setup.png" <<'PY'
import struct
import sys
import zlib
path = sys.argv[1]
def chunk(tag: bytes, data: bytes) -> bytes:
return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data))
width = height = 256
row = b"\x00" + bytes([0x3A, 0x5F, 0x8F, 0xFF]) * width # filter byte + opaque blue-grey pixels
raw = row * height
ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
idat = zlib.compress(raw, 9)
with open(path, "wb") as handle:
handle.write(b"\x89PNG\r\n\x1a\n")
handle.write(chunk(b"IHDR", ihdr))
handle.write(chunk(b"IDAT", idat))
handle.write(chunk(b"IEND", b""))
PY
echo "Resolving appimagetool..."
appimagetool="$appimagetool_override"
if [[ -z "$appimagetool" ]]; then
appimagetool="$workspace/Launcher/artifacts/appimagetool"
if [[ ! -x "$appimagetool" ]]; then
echo "Downloading appimagetool..."
mkdir -p "$(dirname "$appimagetool")"
curl -fsSL "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage" \
-o "$appimagetool"
chmod +x "$appimagetool"
fi
fi
mkdir -p "$output_dir"
echo "Packaging..."
# appimagetool detects the target architecture from the first ELF executable it finds in the
# AppDir; AppRun here is a shell script, not ELF, so ARCH must be set explicitly.
ARCH=x86_64 "$appimagetool" "$appdir" "$output_dir/WiiCompiled-Setup-x86_64.AppImage"
echo "Built: $output_dir/WiiCompiled-Setup-x86_64.AppImage"
+425
View File
@@ -0,0 +1,425 @@
#!/usr/bin/env bash
# Linux build automation: translate -> emit build shards -> configure -> compile -> publish.
#
# This is the native-Linux counterpart to Launcher/LocalBuild.ps1. It is a from-scratch parallel
# implementation, not a port of NativeBuildFlags.ps1: that file's canonical flags and
# prebuilt-package fingerprinting exist only for the Windows/mingw toolchain (a precompiled
# aurora/third-party package, offline pinned dependencies) that this script does not build.
# Linux always builds aurora from source, letting its own CMake auto-detect Vulkan + vendor
# SDL3/Dawn via FetchContent - the same configuration already verified working by hand.
set -euo pipefail
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
log_step() {
# $1 = machine-readable step id, $2 = human sentence. Mirrors LocalBuild.ps1's
# Write-MkwBuildStep: the id is a stable marker a future installer could parse from the log,
# the sentence is for the human reading the terminal.
printf 'MKWCBUILD:STEP:%s %s\n' "$1" "$2"
}
fail() {
echo "local-build.sh: error: $*" >&2
exit 1
}
assert_file() {
[[ -f "$1" ]] || fail "$2 is missing: $1"
}
assert_dir() {
[[ -d "$1" ]] || fail "$2 is missing: $1"
}
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "required tool '$1' was not found on PATH (override with --$2)"
}
sha256_of() {
sha256sum "$1" | awk '{print $1}'
}
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
workspace=$(cd "$script_dir/.." && pwd)
profile=base
output_dir=""
base_output_dir=""
retro_rewind_package_dir=""
retro_wfc_offline_dir=""
skip_retro_wfc_payload=0
force_clean_build=0
parallel_override=0
cc_override=""
cxx_override=""
cmake_override=""
ninja_override=""
dotnet_override=""
translator_dll_override=""
translator_bin_override=""
usage() {
cat <<'EOF'
Usage: local-build.sh --output-dir DIR [options]
--workspace DIR Repository root (default: this script's parent directory)
--profile {base|retro-rewind|both} Build profile (default: base)
--output-dir DIR Where the built product is published (required)
--base-output-dir DIR Second output directory; required with --profile both
--retro-rewind-package-dir DIR Retro Rewind source tree (default: PulsarPacks/completed/RetroRewind/RetroRewind6)
--retro-wfc-offline-dir DIR Offline Retro-WFC payload directory
--skip-retro-wfc-payload Build Retro Rewind without a Retro-WFC payload
--force-clean-build Discard every translation/build cache first
--parallel N Pin translator threads, translated-shard job pool, and Ninja parallelism to N
--cc PATH / --cxx PATH C/C++ compiler (default: cc/c++ on PATH)
--cmake PATH / --ninja PATH Build tools (default: on PATH)
--dotnet PATH dotnet executable (default: on PATH)
--translator-dll PATH Pre-built Translator.Cli.dll (skips building the translator; still needs --dotnet to run it)
--translator-bin PATH Self-contained Translator.Cli executable (skips building AND needs no dotnet at all)
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--workspace) workspace=$(cd "$2" && pwd); shift 2 ;;
--profile) profile=$2; shift 2 ;;
--output-dir) output_dir=$2; shift 2 ;;
--base-output-dir) base_output_dir=$2; shift 2 ;;
--retro-rewind-package-dir) retro_rewind_package_dir=$2; shift 2 ;;
--retro-wfc-offline-dir) retro_wfc_offline_dir=$2; shift 2 ;;
--skip-retro-wfc-payload) skip_retro_wfc_payload=1; shift ;;
--force-clean-build) force_clean_build=1; shift ;;
--parallel) parallel_override=$2; shift 2 ;;
--cc) cc_override=$2; shift 2 ;;
--cxx) cxx_override=$2; shift 2 ;;
--cmake) cmake_override=$2; shift 2 ;;
--ninja) ninja_override=$2; shift 2 ;;
--dotnet) dotnet_override=$2; shift 2 ;;
--translator-dll) translator_dll_override=$2; shift 2 ;;
--translator-bin) translator_bin_override=$2; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) fail "unknown argument: $1" ;;
esac
done
[[ -n "$output_dir" ]] || { usage; fail "--output-dir is required"; }
case "$profile" in
base|retro-rewind|both) ;;
*) fail "--profile must be base, retro-rewind, or both" ;;
esac
builds_retro=0
[[ "$profile" == "retro-rewind" || "$profile" == "both" ]] && builds_retro=1
has_offline_retro_wfc=0
[[ -n "$retro_wfc_offline_dir" ]] && has_offline_retro_wfc=1
if [[ "$builds_retro" -eq 0 ]]; then
if [[ "$has_offline_retro_wfc" -eq 1 || "$skip_retro_wfc_payload" -eq 1 ]]; then
fail "Retro-WFC payload options are valid only for a Retro Rewind build."
fi
if [[ -n "$retro_rewind_package_dir" ]]; then
fail "--retro-rewind-package-dir is valid only for a Retro Rewind build."
fi
else
if [[ "$has_offline_retro_wfc" -eq "$skip_retro_wfc_payload" ]]; then
fail "Choose exactly one Retro-WFC mode: --retro-wfc-offline-dir or --skip-retro-wfc-payload."
fi
fi
if [[ "$profile" == "both" && -z "$base_output_dir" ]]; then
fail "--base-output-dir is required with --profile both; --output-dir receives the Retro Rewind product."
fi
if [[ "$profile" != "both" && -n "$base_output_dir" ]]; then
fail "--base-output-dir is valid only with --profile both."
fi
# ---------------------------------------------------------------------------
# Tool resolution and prerequisite checks
# ---------------------------------------------------------------------------
dotnet_bin=${dotnet_override:-dotnet}
cmake_bin=${cmake_override:-cmake}
ninja_bin=${ninja_override:-ninja}
cc_bin=${cc_override:-clang}
cxx_bin=${cxx_override:-clang++}
# A self-contained --translator-bin needs no dotnet at all (it bundles its own runtime); dotnet is
# only required when the translator has to be built from source or run as a plain .dll.
if [[ -z "$translator_bin_override" ]]; then
require_command "$dotnet_bin" dotnet
fi
require_command "$cmake_bin" cmake
require_command "$ninja_bin" ninja
require_command "$cc_bin" cc
require_command "$cxx_bin" cxx
project=$workspace/projects/mkwii/recomp.yml
assets=$workspace/Assets
generated=$workspace/generated
functions=$generated/functions
base_metadata=$generated/base_translation_output.json
base_manifest_dir=$workspace/build/base
base_manifest=$base_manifest_dir/mkwii_base_manifest.json
shards=$generated/build_shards
build=$workspace/native-build
translation_provenance=$generated/translation-provenance.json
toolchain_provenance=$build/toolchain-provenance.json
retro_root=${retro_rewind_package_dir:-$workspace/PulsarPacks/completed/RetroRewind/RetroRewind6}
assert_file "$project" "Translation project"
assert_file "$assets/main.dol" "Extracted main.dol (see translator/README.md - owning the game is required)"
assert_file "$assets/StaticR.rel" "Extracted StaticR.rel (see translator/README.md - owning the game is required)"
# Literal line matching against the manifest's fixed shape, not a YAML dependency - the same
# approach NativeBuildFlags.ps1's Get-MkwProjectPins uses on Windows, kept here only for the one
# field this script actually needs from the manifest.
entry_point=$(awk '
/^translation:/ { in_translation = 1 }
in_translation && /^[[:space:]]*-[[:space:]]*0[xX][0-9a-fA-F]+[[:space:]]*$/ {
gsub(/^[[:space:]]*-[[:space:]]*/, ""); gsub(/[[:space:]]*$/, ""); print; exit
}
' "$project")
[[ -n "$entry_point" ]] || fail "Could not find a translation entry point in $project"
translator_bin=$translator_bin_override
translator_dll=$translator_dll_override
if [[ -n "$translator_bin" ]]; then
assert_file "$translator_bin" "Translator.Cli executable"
translator() { "$translator_bin" "$@"; }
else
if [[ -z "$translator_dll" ]]; then
translator_dll=$workspace/translator/src/Translator.Cli/bin/Release/net8.0/Translator.Cli.dll
log_step build-translator "Building the translator"
"$dotnet_bin" build "$workspace/translator/src/Translator.Cli/Translator.Cli.csproj" -c Release
fi
assert_file "$translator_dll" "Translator.Cli.dll"
translator() { "$dotnet_bin" "$translator_dll" "$@"; }
fi
# ---------------------------------------------------------------------------
# Parallelism: three independent knobs, same reasoning as LocalBuild.ps1 -
# translator_threads (translation's own worker threads), translated_jobs (the real RAM guard,
# capping concurrent compiles of memory-hungry translated TUs via Ninja's MKW_TRANSLATED_COMPILE_JOBS
# pool), global_jobs (Ninja's overall parallelism). --parallel pins all three.
# ---------------------------------------------------------------------------
cpu_count=$(nproc)
mem_gib=$(( $(awk '/^MemTotal:/{print $2}' /proc/meminfo) / 1024 / 1024 ))
(( mem_gib < 1 )) && mem_gib=1
if (( parallel_override > 0 )); then
translator_threads=$parallel_override
translated_jobs=$parallel_override
global_jobs=$parallel_override
else
translator_threads=$(( cpu_count < 16 ? cpu_count : 16 ))
(( translator_threads < 1 )) && translator_threads=1
mem_based_cap=$(( mem_gib / 2 ))
(( mem_based_cap < 1 )) && mem_based_cap=1
translated_jobs=$(( cpu_count < mem_based_cap ? cpu_count : mem_based_cap ))
(( translated_jobs < 1 )) && translated_jobs=1
global_jobs=$(( translated_jobs > cpu_count ? translated_jobs : cpu_count ))
fi
# ---------------------------------------------------------------------------
# Translation cache: this script is the only owner of the reuse decision (unlike LocalBuild.ps1,
# which is handed caller-computed fingerprints by the Windows installer - there is no Linux
# installer yet to supply anything). Hash the game inputs the translation actually depends on;
# a match plus every expected output file present means the previous translation is still good.
# ---------------------------------------------------------------------------
if (( force_clean_build )); then
log_step force-clean "A clean build was requested; discarding every translation and build cache"
rm -rf "$generated" "$base_manifest_dir" "$build"
fi
translation_fingerprint=$(cat "$assets/main.dol" "$assets/StaticR.rel" "$project" | sha256sum | awk '{print $1}')
reuse_base=0
if [[ -f "$translation_provenance" ]]; then
recorded=$(grep -o '"TranslationFingerprint" *: *"[^"]*"' "$translation_provenance" 2>/dev/null | sed 's/.*"\([0-9a-f]*\)"$/\1/' || true)
if [[ "$recorded" == "$translation_fingerprint" && -f "$base_metadata" && -f "$base_manifest" ]]; then
reuse_base=1
fi
fi
if (( builds_retro )); then
# The translator discovers the mod through the project file's workspace-relative profile
# paths, and both the base and mod leg block leaf inlining at every address the profile
# patches - so the selected Code.pul must sit at the profile's mod_root before either leg runs.
source_pul=$retro_root/Binaries/Code.pul
assert_file "$source_pul" "Retro Rewind Code.pul"
staged_binaries=$workspace/PulsarPacks/completed/RetroRewind/RetroRewind6/Binaries
mkdir -p "$staged_binaries"
staged_pul=$staged_binaries/Code.pul
if [[ "$(cd "$(dirname "$source_pul")" && pwd)/$(basename "$source_pul")" != "$(cd "$(dirname "$staged_pul")" && pwd)/$(basename "$staged_pul")" ]]; then
cp -f "$source_pul" "$staged_pul"
fi
fi
if (( reuse_base )) && (( builds_retro )); then
# A base tree that never saw this Code.pul would silently bake vanilla code into the modded
# product - check-base-mod-awareness fails closed (anything but exit 0 forces a retranslation).
retro_code_pul=$retro_root/Binaries/Code.pul
assert_file "$retro_code_pul" "Retro Rewind Code.pul"
pul_sha=$(sha256_of "$retro_code_pul")
if ! grep -q "\"codePulSha256\":\"$pul_sha\"" "$base_metadata"; then
if ! translator check-base-mod-awareness --project "$project" --profile retro-rewind \
--translation-output-metadata "$base_metadata" --code-pul "$retro_code_pul"; then
log_step retranslate-base "The base translation is stale; retranslating the base game for the new Code.pul"
reuse_base=0
fi
fi
fi
if (( reuse_base )); then
log_step reuse-base-translation "Reusing the completed base translation"
else
rm -f "$translation_provenance"
mkdir -p "$generated" "$base_manifest_dir"
log_step translate-base "Translating the user-owned base game"
translator translate-recursive "$entry_point" --project "$project" \
--outdir "$functions" --output-metadata "$base_metadata" \
--production-source-bundle "$generated/base_translation_sources.bin" \
--no-function-files --prune-stale --threads "$translator_threads"
log_step emit-base-manifest "Creating the local base translation manifest"
translator emit-base-manifest --project "$project" --out "$base_manifest_dir" \
--functions-dir "$functions" --translation-output-metadata "$base_metadata" --region P
printf '{"SchemaVersion":1,"TranslationFingerprint":"%s"}' "$translation_fingerprint" \
> "$translation_provenance"
fi
if (( builds_retro )); then
code_pul=$retro_root/Binaries/Code.pul
assert_file "$code_pul" "Retro Rewind Code.pul"
retro_out=$workspace/build/mods/retro_rewind_full_cpp
translate_mod_args=(translate-mod --project "$project" --profile retro-rewind
--base-manifest "$base_manifest" --base-translation-output-metadata "$base_metadata"
--code-pul "$code_pul" --mod-root "$retro_root" --mod-name "Retro Rewind"
--region P --out "$retro_out" --prefer-cached-inputs --emit-cpp
--threads "$translator_threads")
if (( skip_retro_wfc_payload )); then
translate_mod_args+=(--skip-retro-wfc)
else
offline_payload=$retro_wfc_offline_dir/binary/payload.RMCPD00.bin
assert_file "$offline_payload" "Offline Retro-WFC shared payload"
translate_mod_args+=(--retro-wfc-payload "$offline_payload")
fi
log_step translate-mod "Translating the selected Retro Rewind Code.pul"
translator "${translate_mod_args[@]}"
fi
log_step generate-data-init "Generating local game data initialization"
translator generate-data-init --project "$project"
shard_args=(emit-build-shards --project "$project" --base-metadata "$base_metadata"
--base-functions-dir "$functions" --native-source-dir "$workspace/runtime/src" --out "$shards")
if (( builds_retro )); then
retro_out=$workspace/build/mods/retro_rewind_full_cpp
shard_args+=(--resolved-profile "$retro_out/resolved_dispatch_profile.json"
--retro-cpp-dir "$retro_out/cpp")
fi
log_step emit-build-shards "Preparing local native build shards"
translator "${shard_args[@]}"
# ---------------------------------------------------------------------------
# Native configure + build. Deliberately not passing -DAURORA_DAWN_PROVIDER=package or
# -DFETCHCONTENT_FULLY_DISCONNECTED=ON: those exist for the Windows prebuilt-package/offline-
# dependencies workflow this script does not build. aurora's own CMake auto-detects Linux and
# picks Vulkan + vendors SDL3/Dawn via FetchContent, exactly as already verified working by hand.
# ---------------------------------------------------------------------------
keep_native_build=0
if [[ -f "$build/CMakeCache.txt" ]]; then
expected_home=$workspace/runtime
cache_home=$(grep '^CMAKE_HOME_DIRECTORY:INTERNAL=' "$build/CMakeCache.txt" | cut -d= -f2- || true)
if [[ -n "$cache_home" && "$(cd "$cache_home" 2>/dev/null && pwd)" == "$expected_home" ]]; then
keep_native_build=1
fi
fi
if [[ -d "$build" && "$keep_native_build" -eq 0 ]]; then
echo "MKWCBUILD: The native build cache does not belong to this workspace path; rebuilding from scratch"
rm -rf "$build"
elif [[ "$keep_native_build" -eq 1 ]]; then
echo "MKWCBUILD: Reusing the incremental native build directory"
fi
log_step configure-native "Configuring the native toolchain"
"$cmake_bin" -S "$workspace/runtime" -B "$build" -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_C_COMPILER="$cc_bin" -DCMAKE_CXX_COMPILER="$cxx_bin" \
-DCMAKE_MAKE_PROGRAM="$ninja_bin" \
-DMKW_TRANSLATED_COMPILE_JOBS="$translated_jobs"
case "$profile" in
base) targets=(WiiCompiled) ;;
retro-rewind) targets=(RetroRewind) ;;
both) targets=(WiiCompiled RetroRewind) ;;
esac
build_args=(--build "$build")
for target in "${targets[@]}"; do build_args+=(--target "$target"); done
build_args+=(--parallel "$global_jobs")
log_step compile "Compiling ${targets[*]} locally"
"$cmake_bin" "${build_args[@]}"
# ---------------------------------------------------------------------------
# Publish: the Linux build statically links SDL3/Dawn/etc (verified this session), so unlike
# LocalBuild.ps1's DLL-copying dance there is nothing to copy beside the binary except the
# runtime's own first-run assets.
# ---------------------------------------------------------------------------
dol_sha=$(sha256_of "$assets/main.dol")
rel_sha=$(sha256_of "$assets/StaticR.rel")
compiler_version=$("$cxx_bin" --version | head -1)
publish_built_product() {
local target=$1 destination=$2 provenance_profile=$3
mkdir -p "$destination"
local exe=$build/$target
assert_file "$exe" "Locally compiled game executable"
cp -f "$exe" "$destination/$target"
for name in dsp_coef.bin initial_pipeline_cache.db; do
[[ -f "$build/$name" ]] && cp -f "$build/$name" "$destination/"
done
[[ -d "$build/wii_bootstrap" ]] && cp -rf "$build/wii_bootstrap" "$destination/"
local is_retro=0 code_pul_sha=null
if [[ "$provenance_profile" == "retro-rewind" ]]; then
is_retro=1
code_pul_sha=\"$(sha256_of "$retro_root/Binaries/Code.pul")\"
fi
local built_utc
built_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)
cat > "$destination/local-build.json" <<JSON
{
"SchemaVersion": 1,
"Profile": "$provenance_profile",
"BuiltUtc": "$built_utc",
"DolSha256": "$dol_sha",
"RelSha256": "$rel_sha",
"CodePulSha256": $code_pul_sha,
"Compiler": "$compiler_version"
}
JSON
}
case "$profile" in
both)
publish_built_product WiiCompiled "$base_output_dir" base
publish_built_product RetroRewind "$output_dir" retro-rewind
;;
retro-rewind)
publish_built_product RetroRewind "$output_dir" retro-rewind
;;
base)
publish_built_product WiiCompiled "$output_dir" base
;;
esac
echo "MKWCBUILD:OUTPUT=$output_dir"
+3 -1
View File
@@ -57,13 +57,15 @@ The port does NOT pretend to be a Wii Remote or Classic Controller.
Mappings are positional (`south`, `east`, `west`, `north`) rather than Xbox-labelled, so the
same config makes sense on Xbox, PlayStation, Nintendo and generic SDL pads alike, and extra
inputs like paddles, touchpads and share buttons show up when the hardware reports them.
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).
## Requirements
- Windows 10 or 11, 64-bit
- GPU: GTX 1650 / RX 6400 / Arc A310 or higher
- CPU: Intel Core i5-8400 / AMD Ryzen 5 2600 (4c/6c, ~3.5GHz+) or higher
- About 20 GB of free disk space during installation
- About 20 GB of free disk space during installation (Final game size ~5 GB)
- A clean, unmodified **PAL `RMCP01`** disc image of Mario Kart Wii, dumped by you. ISO, GCM,
GCZ, CISO, WBFS, WIA and RVZ are accepted.
+19 -7
View File
@@ -103,11 +103,22 @@ Source: <https://github.com/ToruNiina/toml11/tree/v4.4.0>. Full license text:
Copyright (c) Antoine Aubry and contributors.
Referenced by `translator/src/Translator.Core`. Source: <https://github.com/aaubry/YamlDotNet>
### libco - ISC (valgrind.h: BSD-style)
Copyright byuu and the higan team.
Non-Windows builds use libco's symmetric stackful coroutines in place of Win32 Fibers for guest
OSThread scheduling (`runtime/src/fiber_manager.cpp`). Vendored in full (all non-Windows
CPU-architecture backends - amd64, x86, arm, aarch64, ppc, ppc64v2, plus the portable sjlj
fallback - though this project's x86_64-only target only ever compiles amd64.c) in
`runtime/third_party/libco` from commit `e18e09d634d612a01781168ad4d76be10a7e3bad`.
Source: <https://github.com/higan-emu/libco>. Full license text:
`runtime/third_party/libco/LICENSE`.
---
## Fetched at build time and redistributed in release builds
These are pinned in `aurora-main/extern/CMakeLists.txt` and
These are pinned in `aurora-main/extern/CMakeLists.txt`, `aurora-main/CMakeLists.txt` and
`aurora-main/cmake/AuroraDawnProvider.cmake`. They are not stored in this repository; the build
downloads them, and release installers carry the resulting binaries. Their license texts are
included in the installer's `licenses/` folder.
@@ -118,6 +129,7 @@ included in the installer's `licenses/` folder.
| Tint (part of Dawn) | with Dawn | BSD-3-Clause | <https://dawn.googlesource.com/dawn> |
| DirectXShaderCompiler (`dxcompiler.dll`) | with Dawn | NCSA / University of Illinois Open Source | <https://github.com/microsoft/DirectXShaderCompiler> |
| SDL | 3.4.4 | zlib | <https://github.com/libsdl-org/SDL> |
| libusb (linked into SDL on Windows) | 1.0.30 | LGPL-2.1-or-later | <https://github.com/libusb/libusb> |
| Abseil | LTS 20240722.0 | Apache-2.0 | <https://github.com/abseil/abseil-cpp> |
| Dear ImGui | 1.91.9b-docking | MIT | <https://github.com/ocornut/imgui> |
| {fmt} | 11.1.4 | MIT | <https://github.com/fmtlib/fmt> |
@@ -129,6 +141,7 @@ included in the installer's `licenses/` folder.
| SQLite | 3.51.3 amalgamation | Public domain | <https://sqlite.org/> |
| Tracy Profiler | pinned commit | BSD-3-Clause | <https://github.com/wolfpld/tracy> |
| C++/WinRT | - | MIT (Microsoft) | <https://github.com/microsoft/cppwinrt> |
| nodtool (disc image extraction) | v2.0.0-alpha.10 | MIT OR Apache-2.0 | <https://github.com/encounter/nod> |
### Dual-licensed components - elections made by this project
@@ -153,16 +166,15 @@ unmodified, with their license texts, in the installer's `licenses/` folder.
| llvm-mingw (Clang, LLD, libc++, libunwind, MinGW-w64 runtime) | Apache-2.0 with LLVM Exception; MinGW-w64 runtime under its own permissive terms; bundled GNU utilities under GPL-2.0-or-later or GPL-3.0-or-later | <https://github.com/mstorsjo/llvm-mingw> |
| CMake | BSD-3-Clause | <https://cmake.org/> |
| Ninja | Apache-2.0 | <https://ninja-build.org/> |
| DolphinTool (disc image extraction) | GPL-2.0-or-later | <https://github.com/dolphin-emu/dolphin> |
| nodtool (disc image extraction) | MIT OR Apache-2.0 | <https://github.com/encounter/nod> |
| Microsoft Visual C++ Runtime (`vcruntime140.dll`, `vcruntime140_1.dll`, `msvcp140.dll`) | Microsoft redistributable terms | Microsoft Visual Studio |
| `dxil.dll` | Microsoft redistributable (proprietary signing library) | Microsoft |
> [!IMPORTANT]
> Several toolkit components are GPL-licensed (DolphinTool, and the GNU utilities inside
> llvm-mingw). Their complete corresponding source is available from the upstream projects linked
> above at their pinned versions, and this project will supply it on request for the exact versions
> shipped in any given release. Pins live in `Launcher/Prepare-PortableTools.ps1` and
> `Launcher/NativeBuildFlags.ps1`.
> The GNU utilities bundled inside llvm-mingw are GPL-licensed. Their complete corresponding source
> is available from the upstream project linked above at its pinned version, and this project will
> supply it on request for the exact version shipped in any given release. Pins live in
> `Launcher/Prepare-PortableTools.ps1` and `Launcher/NativeBuildFlags.ps1`.
---
+2
View File
@@ -16,6 +16,7 @@ option(AURORA_CACHE_USE_ZSTD "Compress WebGPU cache entries with zstd" ON)
set(AURORA_DAWN_VERSION "v20260603.191052" CACHE STRING "Dawn version tag (https://github.com/encounter/dawn-build/releases)")
set(AURORA_SDL3_VERSION "3.4.4" CACHE STRING "SDL3 version tag (https://github.com/libsdl-org/SDL/releases)")
set(AURORA_NOD_VERSION "v2.0.0-alpha.8" CACHE STRING "nod version tag (https://github.com/encounter/nod/releases)")
set(AURORA_LIBUSB_VERSION "1.0.30" CACHE STRING "libusb version tag (https://github.com/libusb/libusb/releases)")
# Platform-specific defaults
if (CMAKE_CROSSCOMPILING)
@@ -44,6 +45,7 @@ set(AURORA_SDL3_PROVIDER "${_default_provider}" CACHE STRING
set_property(CACHE AURORA_SDL3_PROVIDER PROPERTY STRINGS auto vendor system package)
set(AURORA_SDL3_LINKAGE "${_default_linkage}" CACHE STRING "SDL3 linkage type preference")
set_property(CACHE AURORA_SDL3_LINKAGE PROPERTY STRINGS shared static)
option(AURORA_SDL3_LIBUSB "Build the vendored SDL3 with libusb on Windows (official GameCube adapter support)" ON)
# nod (if AURORA_ENABLE_DVD)
set(AURORA_NOD_PROVIDER "${_default_provider}" CACHE STRING
+43
View File
@@ -0,0 +1,43 @@
# libusb for SDL3's HIDAPI joystick drivers on Windows.
#
# The official GameCube adapter (WUP-028) is a vendor-specific USB device, not HID, so SDL3
# can only reach it through libusb - which the official SDL3 packages leave out. The vendored
# SDL3 build compiles libusb from the pinned upstream release and links it in statically.
include(FetchContent)
FetchContent_Declare(libusb
URL "https://github.com/libusb/libusb/releases/download/v${AURORA_LIBUSB_VERSION}/libusb-${AURORA_LIBUSB_VERSION}.tar.bz2"
DOWNLOAD_EXTRACT_TIMESTAMP TRUE
)
# Upstream ships no CMakeLists.txt, so this only populates the source tree.
FetchContent_MakeAvailable(libusb)
set(_libusb_root "${libusb_SOURCE_DIR}/libusb")
add_library(usb-1.0 STATIC
"${_libusb_root}/core.c"
"${_libusb_root}/descriptor.c"
"${_libusb_root}/hotplug.c"
"${_libusb_root}/io.c"
"${_libusb_root}/strerror.c"
"${_libusb_root}/sync.c"
"${_libusb_root}/os/events_windows.c"
"${_libusb_root}/os/threads_windows.c"
"${_libusb_root}/os/windows_common.c"
"${_libusb_root}/os/windows_usbdk.c"
"${_libusb_root}/os/windows_winusb.c"
)
target_include_directories(usb-1.0
PUBLIC "${_libusb_root}"
PRIVATE "${CMAKE_CURRENT_LIST_DIR}/libusb" "${_libusb_root}/os"
)
set_target_properties(usb-1.0 PROPERTIES UNITY_BUILD OFF)
add_library(LibUSB::LibUSB ALIAS usb-1.0)
# SDL's FindLibUSB expects an installed copy. Satisfy its presence checks with this target
# instead: the alias pre-empts the imported target it would otherwise create, and the link
# probe is answered up front because the archive does not exist until build time.
set(LibUSB_INCLUDE_PATH "${_libusb_root}" CACHE PATH "" FORCE)
set(LibUSB_LIBRARY "usb-1.0" CACHE STRING "" FORCE)
set(HAVE_LIBUSB_H 1 CACHE INTERNAL "" FORCE)
set(SDL_HIDAPI_LIBUSB ON CACHE BOOL "" FORCE)
set(SDL_HIDAPI_LIBUSB_SHARED OFF CACHE BOOL "" FORCE)
@@ -129,6 +129,9 @@ elseif (_aurora_sdl3_provider STREQUAL "vendor")
endif ()
if (WIN32)
set(SDL_LIBC ON CACHE BOOL "Use the system C library" FORCE)
if (AURORA_SDL3_LIBUSB)
include("${CMAKE_CURRENT_LIST_DIR}/AuroraLibUSB.cmake")
endif ()
endif ()
include(FetchContent)
+8
View File
@@ -0,0 +1,8 @@
/* libusb build configuration for the Windows backend (MinGW/Clang). */
#pragma once
#define PLATFORM_WINDOWS 1
#define ENABLE_LOGGING 1
#define DEFAULT_VISIBILITY
#define HAVE_STRUCT_TIMESPEC 1
#define PRINTF_FORMAT(a, b) __attribute__((__format__(__printf__, a, b)))
+3 -1
View File
@@ -111,7 +111,9 @@ ECardResult CardGciFolder::createFile(const char* filename, size_t size, FileHan
}
gciFileHeader->swapEndian();
m_files.push_back({*gciFileHeader, fileSize, reinterpret_cast<const char8_t*>(gciFilename.c_str()), false}); // push non-endian swapped header first
// push non-endian swapped header first
m_files.push_back({*gciFileHeader, fileSize,
std::u8string(gciFilename.begin(), gciFilename.end()), false});
handleOut = FileHandle(m_files.size() - 1, 0);
return ECardResult::READY;
+1 -1
View File
@@ -175,7 +175,7 @@ void CARDInit(const char* game, const char* maker) {
std::filesystem::path cardWorkingDir;
if (aurora::g_config.userPath != nullptr)
cardWorkingDir = reinterpret_cast<const char8_t*>(aurora::g_config.userPath);
cardWorkingDir = fs_path_from_string(aurora::g_config.userPath);
else
cardWorkingDir = std::filesystem::current_path();
+21 -16
View File
@@ -1,3 +1,4 @@
#include "../../fs_helper.hpp"
#include "../../input.hpp"
#include "../../internal.hpp"
#include <dolphin/pad.h>
@@ -5,6 +6,7 @@
#include <SDL3/SDL_mouse.h>
#include <array>
#include <atomic>
#include <sys/stat.h>
#include <ranges>
@@ -283,7 +285,7 @@ constexpr PADCLampRegion ClampRegion{
bool g_initialized;
bool g_keyboardBindingsLoaded = false;
bool g_blockPAD = false;
std::atomic_bool g_blockPAD{false};
bool g_suppressHeldOnRead = false;
std::array<PADButton, PAD_CHANMAX> g_suppressedButtons{};
std::array<bool, PAD_CHANMAX> g_suppressLeftTrigger{};
@@ -491,7 +493,7 @@ void __PADLoadMapping(aurora::input::GameController* controller) /* NOLINT(*-re
return;
}
std::string basePath{aurora::g_config.userPath};
const std::filesystem::path basePath = fs_path_from_string(aurora::g_config.userPath);
if (!controller->m_mappingLoaded) {
__PADSetDefaultMapping(controller);
controller->m_axisMapping = g_defaultAxes;
@@ -499,8 +501,9 @@ void __PADLoadMapping(aurora::input::GameController* controller) /* NOLINT(*-re
controller->m_mappingLoaded = true;
const auto path = fmt::format("{}/{}_{:04X}_{:04X}.controller", basePath, PADGetName(playerIndex), controller->m_vid,
controller->m_pid);
const auto path = fs_path_to_string(
basePath / fmt::format("{}_{:04X}_{:04X}.controller", PADGetName(playerIndex), controller->m_vid,
controller->m_pid));
SDL_IOStream* file = SDL_IOFromFile(path.c_str(), "rb");
if (file == nullptr) {
return;
@@ -659,7 +662,8 @@ u32 PADRead(PADStatus* status) {
int numKeys = 0;
const bool* kbState = SDL_GetKeyboardState(&numKeys);
const bool captureHeldInput = g_suppressHeldOnRead && !g_blockPAD;
const bool inputBlocked = g_blockPAD.load(std::memory_order_acquire);
const bool captureHeldInput = g_suppressHeldOnRead && !inputBlocked;
g_suppressHeldOnRead = false;
uint32_t rumbleSupport = 0;
@@ -882,7 +886,7 @@ u32 PADRead(PADStatus* status) {
}
}
if (g_blockPAD) {
if (inputBlocked) {
neutralize_status(status[i]);
} else {
apply_unblock_suppression(status[i], i, captureHeldInput);
@@ -1247,8 +1251,8 @@ constexpr uint32_t k_keyboardMagic = SBIG('KBND');
constexpr int32_t k_keyboardVersion = 3;
static void load_keyboard_bindings() {
const auto filePath = std::filesystem::path{aurora::g_config.userPath} / "keyboard_bindings.dat";
SDL_IOStream* file = SDL_IOFromFile(filePath.string().c_str(), "rb");
const auto filePath = fs_path_from_string(aurora::g_config.userPath) / "keyboard_bindings.dat";
SDL_IOStream* file = SDL_IOFromFile(fs_path_to_string(filePath).c_str(), "rb");
if (file == nullptr) {
return;
}
@@ -1317,10 +1321,11 @@ static void load_keyboard_bindings() {
}
static void save_keyboard_bindings() {
const auto filePath = std::filesystem::path{aurora::g_config.userPath} / "keyboard_bindings.dat";
SDL_IOStream* file = SDL_IOFromFile(filePath.string().c_str(), "wb");
const auto filePath = fs_path_from_string(aurora::g_config.userPath) / "keyboard_bindings.dat";
const auto filePathStr = fs_path_to_string(filePath);
SDL_IOStream* file = SDL_IOFromFile(filePathStr.c_str(), "wb");
if (file == nullptr) {
aurora::input::Log.warn("save_keyboard_bindings: failed to open {} for writing", filePath.string());
aurora::input::Log.warn("save_keyboard_bindings: failed to open {} for writing", filePathStr);
return;
}
@@ -1344,14 +1349,14 @@ void __PADWriteDeadZones(SDL_IOStream* file, // NOLINT(*-reserved-identifier)
}
void PADSerializeMappings() {
const std::filesystem::path basePath{aurora::g_config.userPath};
const std::filesystem::path basePath = fs_path_from_string(aurora::g_config.userPath);
for (auto& controller : aurora::input::g_GameControllers | std::views::values) {
EnsureMappingLoaded(&controller);
const auto filePath =
basePath / fmt::format("{}_{:04X}_{:04X}.controller", aurora::input::controller_name(controller.m_index),
controller.m_vid, controller.m_pid);
std::string filePathStr = filePath.string();
std::string filePathStr = fs_path_to_string(filePath);
// don't truncate the file if it already exists
const char* openMode = std::filesystem::exists(filePath) ? "r+b" : "wb";
@@ -1370,7 +1375,7 @@ void PADSerializeMappings() {
// start writing data at next 32-byte aligned offset
const int64_t dataStart = SDL_TellIO(file) + 31 & ~31;
if (dataStart == -1) {
aurora::input::Log.warn("Unable to seek in controller bindings! Path: \"{}\"", filePath.string());
aurora::input::Log.warn("Unable to seek in controller bindings! Path: \"{}\"", filePathStr);
return;
}
SDL_SeekIO(file, dataStart, SDL_IO_SEEK_SET);
@@ -1530,12 +1535,12 @@ void PADRestoreDefaultMapping(const u32 port) {
}
void PADBlockInput(const bool block) {
if (g_blockPAD && !block) {
if (g_blockPAD.exchange(block, std::memory_order_acq_rel) && !block) {
g_suppressHeldOnRead = true;
}
g_blockPAD = block;
}
SDL_Gamepad* PADGetSDLGamepadForIndex(const u32 index) {
const auto* ctrl = __PADGetControllerForIndex(index);
if (ctrl == nullptr) {
+10 -2
View File
@@ -1,11 +1,19 @@
#pragma once
#include <filesystem>
#include <string>
#include <string_view>
/**
* Converts a std::filesystem::path to a std::string, UTF-8, without exploding on Windows.
* Narrow path strings crossing the aurora boundary are UTF-8. path::string() and the
* char path constructor go through the ANSI codepage on Windows, so they must not be
* used for anything the host handed us or hands back to SDL, sqlite or ImGui.
*/
inline std::string fs_path_to_string(const std::filesystem::path& path) {
const auto u8str = path.u8string();
return { reinterpret_cast<const char*>(u8str.c_str()) };
return { reinterpret_cast<const char*>(u8str.c_str()), u8str.size() };
}
inline std::filesystem::path fs_path_from_string(std::string_view utf8) {
return std::filesystem::path(std::u8string(utf8.begin(), utf8.end()));
}
+2 -1
View File
@@ -2,6 +2,7 @@
#include "clear.hpp"
#include "../gx/pipeline.hpp"
#include "../fs_helper.hpp"
#include "../sqlite_utils.hpp"
#include "../webgpu/gpu.hpp"
@@ -715,7 +716,7 @@ static bool prepare_pipeline_cache_db() {
return true;
}
const auto path = (std::filesystem::path{g_config.pipelineCachePath} / "pipeline_cache.db").string();
const auto path = fs_path_to_string(fs_path_from_string(g_config.pipelineCachePath) / "pipeline_cache.db");
auto ret = sqlite3_open(path.c_str(), &g_pipelineCacheDb);
if (ret != SQLITE_OK) {
Log.error("Failed to open pipeline cache database: {}", sqlite3_errmsg(g_pipelineCacheDb));
+2 -2
View File
@@ -506,8 +506,8 @@ void build_index() noexcept {
return;
}
auto userPath = std::filesystem::path{reinterpret_cast<const char8_t*>(g_config.userPath)};
auto cachePath = std::filesystem::path{reinterpret_cast<const char8_t*>(g_config.cachePath)};
auto userPath = fs_path_from_string(g_config.userPath);
auto cachePath = fs_path_from_string(g_config.cachePath);
s_replacementRoot = userPath / "texture_replacements";
s_dumpRoot = cachePath / "texture_dumps";
+2 -1
View File
@@ -10,6 +10,7 @@
#include <SDL3/SDL_events.h>
#include <SDL3/SDL_render.h>
#include "fs_helper.hpp"
#include "internal.hpp"
#include "webgpu/gpu.hpp"
#include "window.hpp"
@@ -37,7 +38,7 @@ void remove_legacy_ini_file(const char* basePath) noexcept {
}
std::error_code ec;
std::filesystem::remove(std::filesystem::path{basePath} / "imgui.ini", ec);
std::filesystem::remove(fs_path_from_string(basePath) / "imgui.ini", ec);
}
void create_context() noexcept {
+64 -4
View File
@@ -255,6 +255,18 @@ IdentityMatch identity_match(const ControllerIdentity& saved, const ControllerId
: IdentityMatch::None;
}
void assign_player_index(GameController& controller, int32_t port) {
SDL_SetGamepadPlayerIndex(controller.m_controller, port);
controller.m_playerIndex = port;
}
// SDL forgets the index for devices mapped after connect, so player_index() falls
// back to the cached copy; both have to move together or a port looks doubly taken.
int32_t effective_player_index(const GameController& controller) {
const int32_t player = SDL_GetGamepadPlayerIndex(controller.m_controller);
return player >= 0 ? player : controller.m_playerIndex;
}
bool is_instance_claimed(const std::array<Uint32, PAD_MAX_CONTROLLERS>& claimedControllers, size_t claimedCount,
Uint32 instance) {
return std::find(claimedControllers.begin(), claimedControllers.begin() + claimedCount, instance) !=
@@ -269,10 +281,10 @@ void apply_port_preferences() noexcept {
}
for (auto& [instance, controller] : g_GameControllers) {
const int32_t player = SDL_GetGamepadPlayerIndex(controller.m_controller);
const int32_t player = effective_player_index(controller);
if (player >= 0 && player < PAD_MAX_CONTROLLERS && g_portPreferences[player].state != PortPreferenceState::Unset) {
// Keep SDL's default player assignment from taking explicitly configured ports
SDL_SetGamepadPlayerIndex(controller.m_controller, -1);
assign_player_index(controller, -1);
}
}
@@ -293,7 +305,7 @@ void apply_port_preferences() noexcept {
switch (identity_match(preference.identity, controller_identity(controller))) {
case IdentityMatch::Exact:
SDL_SetGamepadPlayerIndex(controller.m_controller, static_cast<int32_t>(port));
assign_player_index(controller, static_cast<int32_t>(port));
claimedControllers[claimedCount++] = instance;
fallbackController = nullptr;
break;
@@ -311,11 +323,45 @@ void apply_port_preferences() noexcept {
}
if (fallbackController != nullptr) {
SDL_SetGamepadPlayerIndex(fallbackController->m_controller, static_cast<int32_t>(port));
assign_player_index(*fallbackController, static_cast<int32_t>(port));
claimedControllers[claimedCount++] = fallbackInstance;
}
}
}
// SDL only hands out a player index when the device already had a gamepad mapping
// at connect time, so anything mapped later (the setup wizard) stays at -1.
void ensure_player_index(GameController& controller) noexcept {
const int32_t player = SDL_GetGamepadPlayerIndex(controller.m_controller);
if (player >= 0) {
controller.m_playerIndex = player;
return;
}
if (controller.m_playerIndex >= 0) {
return;
}
ensure_port_preferences_loaded();
const auto claim = [&](bool skipConfiguredPorts) {
for (int32_t port = 0; port < PAD_MAX_CONTROLLERS; ++port) {
if (skipConfiguredPorts && g_portPreferences[port].state != PortPreferenceState::Unset) {
continue;
}
const bool taken = std::any_of(g_GameControllers.begin(), g_GameControllers.end(), [&](const auto& entry) {
return entry.second.m_controller != controller.m_controller && effective_player_index(entry.second) == port;
});
if (!taken) {
assign_player_index(controller, port);
return true;
}
}
return false;
};
// Explicitly configured ports are only used as a last resort so a hot-plugged
// controller cannot steal the port its preferred device will claim.
if (!claim(true)) {
claim(false);
}
}
} // namespace
GameController* get_controller_for_player(uint32_t player) noexcept {
@@ -364,6 +410,7 @@ SDL_JoystickID add_controller(SDL_JoystickID which) noexcept {
controller.m_hasRgbLed = SDL_GetBooleanProperty(props, SDL_PROP_GAMEPAD_CAP_RGB_LED_BOOLEAN, false);
SDL_JoystickID instance = SDL_GetJoystickID(SDL_GetGamepadJoystick(ctrl));
g_GameControllers[instance] = controller;
ensure_player_index(g_GameControllers[instance]);
apply_port_preferences();
return instance;
}
@@ -371,6 +418,19 @@ SDL_JoystickID add_controller(SDL_JoystickID which) noexcept {
return -1;
}
bool refresh_controller(SDL_JoystickID instance) noexcept {
const auto it = g_GameControllers.find(instance);
if (it == g_GameControllers.end()) {
return false;
}
// The SDL mapping changed underneath us; drop the cached PAD bindings so they
// are rebuilt from the new one.
it->second.m_mappingLoaded = false;
ensure_player_index(it->second);
apply_port_preferences();
return true;
}
void remove_controller(Uint32 instance) noexcept {
if (auto it = g_GameControllers.find(instance); it != g_GameControllers.end()) {
SDL_CloseGamepad(it->second.m_controller);
+1
View File
@@ -51,6 +51,7 @@ struct GameController {
GameController* get_controller_for_player(uint32_t player) noexcept;
Sint32 get_instance_for_player(uint32_t player) noexcept;
SDL_JoystickID add_controller(SDL_JoystickID which) noexcept;
bool refresh_controller(SDL_JoystickID instance) noexcept;
void remove_controller(Uint32 instance) noexcept;
Sint32 player_index(Uint32 instance) noexcept;
void set_player_index(Uint32 instance, Sint32 index) noexcept;
+7 -3
View File
@@ -137,7 +137,7 @@ static void prune_stale_rows() {
static bool cache_init_core() {
Log.debug("SQLite version {}", sqlite3_libversion());
const auto path = std::filesystem::path{reinterpret_cast<const char8_t*>(g_config.cachePath)} / "dawn_cache.db";
const auto path = fs_path_from_string(g_config.cachePath) / "dawn_cache.db";
std::string file = fs_path_to_string(path);
Log.debug("Using dawn cache at {}", file);
auto ret = sqlite3_open(file.c_str(), &db);
@@ -165,8 +165,12 @@ static bool cache_init_core() {
db = nullptr;
std::error_code ec;
std::filesystem::remove(path, ec);
std::filesystem::remove(std::filesystem::path{file + "-wal"}, ec);
std::filesystem::remove(std::filesystem::path{file + "-shm"}, ec);
auto wal = path;
wal += "-wal";
std::filesystem::remove(wal, ec);
auto shm = path;
shm += "-shm";
std::filesystem::remove(shm, ec);
ret = sqlite3_open(file.c_str(), &db);
if (ret != SQLITE_OK) {
Log.error("Failed to recreate database: {}", sqlite3_errmsg(db));
+9
View File
@@ -294,6 +294,15 @@ void process_event(SDL_Event& event) {
});
break;
}
case SDL_EVENT_GAMEPAD_REMAPPED: {
if (input::refresh_controller(event.gdevice.which)) {
g_events.push_back(AuroraEvent{
.type = AURORA_CONTROLLER_ADDED,
.controller = event.gdevice.which,
});
}
break;
}
case SDL_EVENT_GAMEPAD_REMOVED: {
input::remove_controller(event.gdevice.which);
g_events.push_back(AuroraEvent{
+35 -5
View File
@@ -1,10 +1,13 @@
cmake_minimum_required(VERSION 3.16)
project(mkw_recompiled)
if(NOT WIN32 OR NOT MINGW OR NOT CMAKE_CXX_COMPILER_ID STREQUAL "Clang" OR
if((NOT (WIN32 AND MINGW)) AND (NOT CMAKE_SYSTEM_NAME STREQUAL "Linux"))
message(FATAL_ERROR "WiiCompiled requires Windows (LLVM-MinGW) or native Linux")
endif()
if(NOT CMAKE_CXX_COMPILER_ID STREQUAL "Clang" OR
NOT CMAKE_SIZEOF_VOID_P EQUAL 8 OR
NOT CMAKE_SYSTEM_PROCESSOR MATCHES "^(AMD64|amd64|x86_64|X86_64)$")
message(FATAL_ERROR "WiiCompiled requires 64-bit LLVM-MinGW Clang on Windows")
message(FATAL_ERROR "WiiCompiled requires 64-bit Clang targeting x86_64")
endif()
if(NOT CMAKE_BUILD_TYPE STREQUAL "Release")
message(FATAL_ERROR "WiiCompiled only supports Release builds")
@@ -46,6 +49,23 @@ target_include_directories(mkw_pugixml PUBLIC third_party/pugixml)
target_compile_features(mkw_pugixml PUBLIC cxx_std_17)
set_target_properties(mkw_pugixml PROPERTIES UNITY_BUILD OFF)
# Non-Windows guest-fiber scheduling (runtime/src/fiber_manager.cpp) needs a symmetric
# stackful-coroutine primitive to stand in for Win32 Fibers. libco's co_switch() transfers
# directly to any other created coroutine, matching SwitchToFiber's semantics exactly (unlike
# asymmetric resume/yield coroutine libraries, which would need every call site restructured).
# Vendored from upstream (higan-emu/libco @ e18e09d, 2019-10-16, ISC license; valgrind.h is
# separately BSD-style licensed, see third_party/libco/LICENSE) - all of libco's non-Windows
# CPU-architecture backends are kept, even though libco.c's own preprocessor dispatch
# (__amd64__/__i386__/__arm__/__aarch64__/etc.) only ever selects amd64.c for this project's
# x86_64-only target (see the platform/arch check above). Windows keeps using native Fibers
# untouched, so this target is never built there.
if(NOT WIN32)
add_library(mkw_libco STATIC third_party/libco/libco.c)
add_library(mkw::libco ALIAS mkw_libco)
target_include_directories(mkw_libco PUBLIC third_party/libco)
set_target_properties(mkw_libco PROPERTIES UNITY_BUILD OFF)
endif()
# Runtime configuration is real TOML, parsed by toml11 rather than a project-
# specific line parser. Keep it header-only and vendored so disconnected release
# builds have exactly the same parser as developer builds.
@@ -110,12 +130,22 @@ else()
message(FATAL_ERROR "Requested aurora-main but ${MKW_AURORA_DIR} is missing")
endif()
set(DAWN_ENABLE_D3D11 OFF CACHE BOOL "" FORCE)
set(DAWN_ENABLE_D3D12 ON CACHE BOOL "" FORCE)
if(WIN32)
set(DAWN_ENABLE_D3D12 ON CACHE BOOL "" FORCE)
set(TINT_BUILD_HLSL_WRITER ON CACHE BOOL "" FORCE)
set(DAWN_USE_WINDOWS_UI OFF CACHE BOOL "" FORCE)
else()
# Non-Windows (Linux): mirrors aurora-main's own
# _aurora_dawn_set_platform_backends() choice for this platform - Vulkan only, no
# D3D/HLSL. Kept in sync here because this project's own CMake FORCEs these cache
# variables before aurora-main's add_subdirectory() runs, which pre-empts aurora's
# auto-detection (CACHE ... INTERNAL "" without FORCE never overrides an existing value).
set(DAWN_ENABLE_D3D12 OFF CACHE BOOL "" FORCE)
set(TINT_BUILD_HLSL_WRITER OFF CACHE BOOL "" FORCE)
endif()
set(DAWN_ENABLE_VULKAN ON CACHE BOOL "" FORCE)
set(TINT_BUILD_HLSL_WRITER ON CACHE BOOL "" FORCE)
set(DAWN_BUILD_SAMPLES OFF CACHE BOOL "" FORCE)
set(DAWN_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(DAWN_USE_WINDOWS_UI OFF CACHE BOOL "" FORCE)
# Provide a tiny stub for DXProgrammableCapture when the SDK/PIX headers are
# missing (common on MinGW). Dawn only includes the header; no symbols are
+36 -20
View File
@@ -77,7 +77,11 @@ target_compile_definitions(mkw_runtime_common PRIVATE
target_link_libraries(mkw_runtime_common PRIVATE
aurora::gx aurora::pad aurora::si aurora::vi aurora::mtx)
target_link_libraries(mkw_runtime_common PRIVATE mkw::pugixml mkw::toml11 mkw::cryptopp)
target_link_libraries(mkw_runtime_common PRIVATE shell32 windowsapp)
if(WIN32)
target_link_libraries(mkw_runtime_common PRIVATE shell32 windowsapp)
else()
target_link_libraries(mkw_runtime_common PRIVATE mkw::libco)
endif()
if(MKW_CPPWINRT_INCLUDE_DIR)
if(NOT EXISTS "${MKW_CPPWINRT_INCLUDE_DIR}/winrt/base.h")
message(FATAL_ERROR
@@ -204,26 +208,38 @@ function(mkw_configure_product target)
$<TARGET_FILE:sqlite3> $<TARGET_FILE_DIR:${target}>)
endif()
target_link_libraries(${target} PRIVATE
dbghelp user32 winmm ws2_32 iphlpapi secur32 crypt32 windowsapp)
if(WIN32)
target_link_libraries(${target} PRIVATE
dbghelp user32 winmm ws2_32 iphlpapi secur32 crypt32 windowsapp)
set_target_properties(${target} PROPERTIES WIN32_EXECUTABLE TRUE)
foreach(runtime_dll libc++.dll libunwind.dll)
execute_process(
COMMAND "${CMAKE_CXX_COMPILER}" "--print-file-name=${runtime_dll}"
OUTPUT_VARIABLE runtime_dll_path
OUTPUT_STRIP_TRAILING_WHITESPACE)
if(NOT EXISTS "${runtime_dll_path}")
get_filename_component(mkw_compiler_bin "${CMAKE_CXX_COMPILER}" DIRECTORY)
set(runtime_dll_path "${mkw_compiler_bin}/${runtime_dll}")
endif()
if(NOT EXISTS "${runtime_dll_path}")
message(FATAL_ERROR "llvm-mingw runtime DLL not found: ${runtime_dll}")
endif()
add_custom_command(TARGET ${target} POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${runtime_dll_path}" $<TARGET_FILE_DIR:${target}>)
endforeach()
set_target_properties(${target} PROPERTIES WIN32_EXECUTABLE TRUE)
else()
# mkw_runtime_common is an OBJECT library: WiiCompiled/RetroRewind only pull in its .o
# files via $<TARGET_OBJECTS:>, which does not propagate mkw_runtime_common's own
# target_link_libraries (object libraries don't carry usage requirements to a consumer
# that isn't itself linked against as a target). fiber_manager.cpp's co_* calls live in
# those objects, so the actual executable link needs mkw::libco directly, same as it
# needs it independently of that first `if(WIN32)` branch above.
target_link_libraries(${target} PRIVATE mkw::libco)
endif()
if(WIN32)
foreach(runtime_dll libc++.dll libunwind.dll)
execute_process(
COMMAND "${CMAKE_CXX_COMPILER}" "--print-file-name=${runtime_dll}"
OUTPUT_VARIABLE runtime_dll_path
OUTPUT_STRIP_TRAILING_WHITESPACE)
if(NOT EXISTS "${runtime_dll_path}")
get_filename_component(mkw_compiler_bin "${CMAKE_CXX_COMPILER}" DIRECTORY)
set(runtime_dll_path "${mkw_compiler_bin}/${runtime_dll}")
endif()
if(NOT EXISTS "${runtime_dll_path}")
message(FATAL_ERROR "llvm-mingw runtime DLL not found: ${runtime_dll}")
endif()
add_custom_command(TARGET ${target} POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${runtime_dll_path}" $<TARGET_FILE_DIR:${target}>)
endforeach()
endif()
set(MKW_WII_BOOTSTRAP_SOURCE_DIR "${MKW_RUNTIME_SOURCE_DIR}/assets/wii")
if(NOT EXISTS "${MKW_WII_BOOTSTRAP_SOURCE_DIR}/shared2/wc24")
+2 -1
View File
@@ -81,7 +81,8 @@ inline bool WriteSerial(const std::filesystem::path& path, const std::string& se
return false;
}
const std::filesystem::path temporary = path.string() + ".tmp";
std::filesystem::path temporary = path;
temporary += ".tmp";
{
std::ofstream output(temporary, std::ios::trunc);
if (!output) {
@@ -0,0 +1,21 @@
#pragma once
#include <SDL3/SDL_events.h>
// Press-to-bind setup for joysticks SDL either doesn't recognize as gamepads or
// recognizes with a mapping that lacks the analog stick (e.g. raphnet adapters).
// The wizard produces a standard SDL gamepad mapping, applies it live, and
// persists it to gamecontrollerdb.txt in the user data directory.
namespace controller_mapping_wizard {
void LoadPersistedMappings();
void HandleSdlEvent(const SDL_Event& event);
// Lists devices that need setup inside the controller settings menu.
void DrawSetupList();
// Draws the wizard window when active; call once per overlay frame.
void Draw();
bool IsActive();
} // namespace controller_mapping_wizard
+13 -1
View File
@@ -102,12 +102,24 @@ private:
static void CALLBACK FiberProc(void* param);
#else
static void FiberProc(void* param);
// libco's co_create() entry points take no argument (unlike CreateFiber's FiberProc(void*)),
// so this trampoline reads the guest thread address staged by CreateGuestFiber() and forwards
// into the (platform-neutral-bodied) FiberProc above. See fiber_manager.cpp.
static void FiberProcTrampoline();
#endif
// Switch from whichever fiber is currently active straight to the scheduler fiber, without
// the SwitchToThread bookkeeping (CPU context save/restore, s_currentGuestThread). Used for
// in-fiber yields that aren't a real guest thread switch: waiting out the EGG::Thread::start
// deferral loop, and returning control on natural thread exit.
static void SwitchToScheduler();
// Internal state
static std::mutex s_mutex;
static std::unordered_map<uint32_t, GuestFiber> s_fibers;
static std::vector<void*> s_fibersPendingDelete;
// The scheduler's own "fiber": a Windows HFIBER, or (non-Windows) libco's cothread_t for
// whichever native call stack first called GuestFiberManager::Initialize() - both are
// plain void* handles, so one field serves both platforms.
static void* s_schedulerFiber;
static uint32_t s_currentGuestThread;
static bool s_initialized;
+7 -3
View File
@@ -73,8 +73,12 @@ FaultCounters Counters();
void LogFaultSummary() noexcept;
// Returns true when the access violation was a guest-space fault this module
// resolved; the caller must then resume execution. `exceptionPointers` is a
// Windows EXCEPTION_POINTERS*.
bool HandleAccessViolation(void* exceptionPointers) noexcept;
// resolved; the caller must then resume execution. `faultAddress` is the raw
// host pointer the access violation trapped on (Windows: ExceptionInformation[1];
// POSIX: siginfo_t::si_addr) and `isWrite` is whether it was a write access
// (Windows: ExceptionInformation[0] != 0; POSIX: derived from the ucontext).
// The platform-specific handler that calls this is expected to have already
// done that extraction - this function only ever works with the parsed pair.
bool HandleAccessViolation(void* faultAddress, bool isWrite) noexcept;
} // namespace GuestFlat
+2 -2
View File
@@ -20,14 +20,14 @@
namespace DvdFstContract {
struct RegisteredFile {
std::string hostPath;
std::filesystem::path hostPath;
std::string dvdPath;
uint32_t size = 0;
uint32_t discOffsetWords = 0;
};
struct IndexedEntry {
std::string hostPath;
std::filesystem::path hostPath;
std::string dvdPath;
uint32_t size = 0;
uint32_t discOffsetWords = 0;
+12 -1
View File
@@ -11,11 +11,22 @@ inline constexpr bool MkwStateFreeAbiEnabled(uint32_t) noexcept
return true;
}
#if defined(_WIN32)
#define MKW_PPC_FORCE_INLINE __forceinline
#define MKW_PPC_NO_INLINE __declspec(noinline)
#define MKW_PPC_INTERNAL_CALL __regcall
#else
// __forceinline/__declspec are MS-extension keywords Clang only recognizes when targeting
// Windows (MSVC or mingw); native Linux Clang needs the GNU-attribute spellings instead.
// __regcall has no portable non-Windows equivalent worth chasing here - the extra register
// args it saves matter for the hot PPC interpreter loop on Windows, but plain calls are fine
// elsewhere.
#define MKW_PPC_FORCE_INLINE __attribute__((always_inline)) inline
#define MKW_PPC_NO_INLINE __attribute__((noinline))
#define MKW_PPC_INTERNAL_CALL
#endif
#define MKW_PPC_ALWAYS_INLINE_BODY __attribute__((always_inline))
#define MKW_PPC_COLD __attribute__((cold))
#define MKW_PPC_INTERNAL_CALL __regcall
using MkwStateFreeResult2 = uint64_t __attribute__((ext_vector_type(2)));
+7
View File
@@ -18,8 +18,15 @@ extern "C" {
}
namespace MemoryInline {
#if defined(_WIN32)
#define MKW_MEMORY_FORCE_INLINE __forceinline
#define MKW_MEMORY_NO_INLINE __declspec(noinline)
#else
// See runtime/include/isa/ppc_isa_config.h for why non-Windows Clang needs the GNU-attribute
// spellings instead of the MS-extension keywords.
#define MKW_MEMORY_FORCE_INLINE __attribute__((always_inline)) inline
#define MKW_MEMORY_NO_INLINE __attribute__((noinline))
#endif
#define MKW_MEMORY_COLD __attribute__((cold))
inline constexpr uint32_t kPageShift = 20;
inline constexpr uint32_t kPageSize = 1u << kPageShift;
+8 -20
View File
@@ -27,13 +27,14 @@ inline std::optional<std::filesystem::path> ExistingDirectory(const std::filesys
if (path.empty()) {
RT_LOGF(RT_TAG_NAND, "ERROR: %s\n", message);
} else {
RT_LOGF(RT_TAG_NAND, "ERROR: %s: %s\n", message, path.string().c_str());
RT_LOGF(RT_TAG_NAND, "ERROR: %s: %s\n", message,
RuntimeConfigFile::PathToUtf8(path).c_str());
}
RT_LOGF(RT_TAG_NAND, "Set [paths] nand_root in Config.toml.\n");
std::string details = message ? message : "The configured NAND could not be initialized.";
if (!path.empty()) {
details += "\n\nPath: ";
details += path.string();
details += RuntimeConfigFile::PathToUtf8(path);
}
details += "\n\nSet [paths] nand_root in Config.toml and try again.";
// Same fatal idiom as the DVD and OS paths: crash artifacts first so the run
@@ -51,18 +52,6 @@ inline std::filesystem::path ResolveConfiguredPath(const std::string& value) {
return RuntimeConfigFile::ResolveRelativeToConfig(value);
}
inline std::string PathStringWithoutTrailingSeparators(std::filesystem::path path) {
std::string text = path.string();
while (!text.empty()) {
const char tail = text.back();
if (tail != '\\' && tail != '/') {
break;
}
text.pop_back();
}
return text;
}
inline std::filesystem::path ManagedNandRootPath() {
return RuntimeConfigFile::ApplicationDataDirectory() / "NAND";
}
@@ -134,7 +123,9 @@ inline bool SeedMissingBootstrapFiles(const std::filesystem::path& root) {
const std::filesystem::path relativePath{std::string(file)};
ec.clear();
if (!CopyBootstrapFile(*payload, root, relativePath, ec)) {
RT_LOG(RT_TAG_NAND) << "could not create " << (root / relativePath).string() << std::endl;
RT_LOG(RT_TAG_NAND) << "could not create "
<< RuntimeConfigFile::PathToUtf8(root / relativePath)
<< std::endl;
return false;
}
}
@@ -167,7 +158,8 @@ inline std::filesystem::path CreateManagedNandRoot() {
}
}
RT_LOG(RT_TAG_NAND) << "using managed NAND root: " << root.string() << std::endl;
RT_LOG(RT_TAG_NAND) << "using managed NAND root: " << RuntimeConfigFile::PathToUtf8(root)
<< std::endl;
return root;
}
@@ -187,8 +179,4 @@ inline std::filesystem::path DiscoverNandRootPath() {
return CreateManagedNandRoot();
}
inline std::string DiscoverNandRootString() {
return PathStringWithoutTrailingSeparators(DiscoverNandRootPath());
}
} // namespace RuntimeNandPath
+4 -1
View File
@@ -3,6 +3,7 @@
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <string>
#include <string_view>
#include <vector>
@@ -67,8 +68,10 @@ void RunMemoryInitializers();
void RegisterPostRelInitializer(InitializerFn fn);
void RunPostRelInitializers();
// The generated call site passes a UTF-8 literal; it is decoded once here and
// stays a path from then on.
void RegisterDvdOverlayRoot(std::string root);
const std::vector<std::string>& DvdOverlayRoots();
const std::vector<std::filesystem::path>& DvdOverlayRoots();
// Riivolution settings pinned by the distribution's recomp.yml. The XML path is
// relative to the pack/overlay root; option selections use Riivolution's 1-based
+44 -5
View File
@@ -23,6 +23,9 @@
#endif
#include <windows.h>
#include <shlobj.h>
#else
#include <cstdlib>
#include <unistd.h>
#endif
struct RuntimeUserConfig {
@@ -64,6 +67,18 @@ struct RuntimeUserConfig {
namespace RuntimeConfigFile {
// Narrow path strings are UTF-8 everywhere in the runtime; string() and the
// char path constructor would use the ANSI codepage on Windows, which drops
// characters the codepage cannot represent.
inline std::string PathToUtf8(const std::filesystem::path& path) {
const std::u8string text = path.u8string();
return std::string(text.begin(), text.end());
}
inline std::filesystem::path PathFromUtf8(std::string_view text) {
return std::filesystem::path(std::u8string(text.begin(), text.end()));
}
inline constexpr const char* kConfigFileName = "Config.toml";
inline constexpr const char* kApplicationDirectoryName = "WiiCompiled";
@@ -153,7 +168,22 @@ inline std::optional<std::filesystem::path> ExecutableDirectory() {
buffer.resize(buffer.size() * 2);
}
#else
return std::nullopt;
// /proc/self/exe is a Linux-specific magic symlink to the running executable; readlink()
// does not NUL-terminate and silently truncates if the buffer is too small, so this grows
// the buffer until the result no longer fills it completely, the same doubling strategy as
// the Windows branch above uses for GetModuleFileNameW.
std::string buffer(256, '\0');
for (;;) {
const ssize_t length = readlink("/proc/self/exe", buffer.data(), buffer.size());
if (length < 0) {
return std::nullopt;
}
if (static_cast<size_t>(length) < buffer.size()) {
buffer.resize(static_cast<size_t>(length));
return std::filesystem::path(buffer).parent_path();
}
buffer.resize(buffer.size() * 2);
}
#endif
}
@@ -194,6 +224,15 @@ inline std::filesystem::path ApplicationDataDirectory() {
CoTaskMemFree(rawPath);
return directory;
}
#else
// XDG Base Directory spec equivalent of FOLDERID_LocalAppData: $XDG_DATA_HOME if set and
// non-empty, otherwise its default of $HOME/.local/share.
if (const char* xdgDataHome = std::getenv("XDG_DATA_HOME"); xdgDataHome && *xdgDataHome) {
return std::filesystem::path(xdgDataHome) / kApplicationDirectoryName;
}
if (const char* home = std::getenv("HOME"); home && *home) {
return std::filesystem::path(home) / ".local" / "share" / kApplicationDirectoryName;
}
#endif
return std::filesystem::current_path() / kApplicationDirectoryName;
}
@@ -410,7 +449,7 @@ inline RuntimeUserConfig ParseConfig(std::istream& input, std::string sourceName
inline RuntimeUserConfig LoadConfigFile() {
EnsureConfigFile();
std::ifstream file(ResolveConfigPath(), std::ios::binary);
return file ? ParseConfig(file, ResolveConfigPath().string()) : RuntimeUserConfig{};
return file ? ParseConfig(file, PathToUtf8(ResolveConfigPath())) : RuntimeUserConfig{};
}
inline const RuntimeUserConfig& Get() {
@@ -501,7 +540,7 @@ inline bool WriteSetting(std::string_view section, std::string_view key, std::st
}
std::ofstream output(path, std::ios::trunc);
if (!output) {
std::cerr << "[runtime-config] Unable to write " << path.string() << std::endl;
std::cerr << "[runtime-config] Unable to write " << PathToUtf8(path) << std::endl;
return false;
}
for (const auto& outputLine : lines) {
@@ -754,7 +793,7 @@ inline std::string DvdRoot(std::string fallback = "") {
// never to the process working directory (docs/WHEELWIZARD_CONTRACT.md).
inline std::filesystem::path ResolveRelativeTo(const std::filesystem::path& base,
const std::string& value) {
std::filesystem::path path(value);
std::filesystem::path path = PathFromUtf8(value);
if (path.is_relative()) {
path = base / path;
}
@@ -784,7 +823,7 @@ inline void LogLoadedConfig() {
static const bool logged = [] {
const auto& config = Get();
const auto configPath = ResolveConfigPath();
std::cout << "[runtime-config] " << configPath.string();
std::cout << "[runtime-config] " << PathToUtf8(configPath);
if (!std::filesystem::exists(configPath)) {
std::cout << " not found; using built-in defaults";
} else {
+16 -2
View File
@@ -1,6 +1,7 @@
#pragma once
#include <cstdint>
#include <filesystem>
#include <optional>
#include <ostream>
#include <string>
@@ -10,10 +11,23 @@
#include "memory.h"
// Windows' SehLogger longjmps out of a vectored exception handler, where plain setjmp/longjmp is
// the norm. A POSIX signal handler jumping back to here must use the sig-prefixed pair instead:
// only sigsetjmp/siglongjmp save and restore the process signal mask, which is what keeps SIGSEGV
// from staying blocked (and a second fault during the same ctor loop from escalating instead of
// trapping) after the first recovered fault.
#if defined(_WIN32)
using MkwJmpBuf = jmp_buf;
#define MKW_SETJMP(buf) setjmp(buf)
#else
using MkwJmpBuf = sigjmp_buf;
#define MKW_SETJMP(buf) sigsetjmp(buf, 1)
#endif
// Global flag to suppress SEH reporting (caught by system_bridge)
extern bool g_suppressSehReporting;
// Jump buffer for SEH recovery
extern thread_local jmp_buf* g_sehJumpTarget;
extern thread_local MkwJmpBuf* g_sehJumpTarget;
// SEH details for the most recent trapped exception (used during ctor execution).
extern thread_local uint32_t g_sehLastExceptionCode;
extern thread_local uintptr_t g_sehLastExceptionAddress;
@@ -72,5 +86,5 @@ public:
// `mem1Path` and MEM2 to `mem1Path + ".mem2"`, logging outcomes to `os`.
static void DumpCrashHeuristics(std::ostream& os, const struct CpuContext* cpu,
const uint32_t* missingGuestTarget);
static void WriteGuestMemorySnapshot(std::ostream& os, const char* mem1Path);
static void WriteGuestMemorySnapshot(std::ostream& os, const std::filesystem::path& mem1Path);
};
+477
View File
@@ -0,0 +1,477 @@
#include "controller_mapping_wizard.h"
#include "runtime_config.h"
#include "runtime_log.h"
#include <imgui.h>
#include <SDL3/SDL_gamepad.h>
#include <SDL3/SDL_joystick.h>
#include <algorithm>
#include <array>
#include <chrono>
#include <cstdint>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <optional>
#include <string>
#include <vector>
namespace controller_mapping_wizard {
namespace {
using Clock = std::chrono::steady_clock;
constexpr int16_t kStickThreshold = 16000;
constexpr int16_t kTriggerThreshold = 10000;
constexpr auto kCaptureDebounce = std::chrono::milliseconds(350);
enum class StepKind {
Button, // button or single-direction hat press
Trigger, // button press or axis pull
Stick, // axis motion in the prompted direction
};
struct Step {
const char* mappingKey;
const char* prompt;
StepKind kind;
};
// Prompts describe what the control does in-game; the SDL fields land on the
// right GC controls through pad.cpp's "standard" defaults (Z lives on
// rightshoulder, L/R on the trigger axes).
constexpr std::array<Step, 16> kSteps = {{
{"a", "Press the button for A (accelerate / select)", StepKind::Button},
{"b", "Press the button for B (brake / back)", StepKind::Button},
{"x", "Press the button for X", StepKind::Button},
{"y", "Press the button for Y", StepKind::Button},
{"start", "Press the button for pause (Start)", StepKind::Button},
{"rightshoulder", "Press the button for rear view (Z)", StepKind::Button},
{"lefttrigger", "Press or pull the control for using items (L)", StepKind::Trigger},
{"righttrigger", "Press or pull the control for hop / drift (R)", StepKind::Trigger},
{"dpup", "Press D-pad Up", StepKind::Button},
{"dpdown", "Press D-pad Down", StepKind::Button},
{"dpleft", "Press D-pad Left", StepKind::Button},
{"dpright", "Press D-pad Right", StepKind::Button},
{"leftx", "Move the Control Stick LEFT", StepKind::Stick},
{"lefty", "Move the Control Stick UP", StepKind::Stick},
{"rightx", "Move the C-Stick LEFT (or Skip)", StepKind::Stick},
{"righty", "Move the C-Stick UP (or Skip)", StepKind::Stick},
}};
struct WizardState {
bool active = false;
SDL_JoystickID instance = 0;
SDL_Joystick* joystick = nullptr;
bool ownsJoystick = false;
std::string deviceName;
size_t stepIndex = 0;
std::array<std::optional<std::string>, kSteps.size()> bindings{};
std::vector<int16_t> axisBaseline;
Clock::time_point acceptAfter{};
std::string status;
};
WizardState g_wizard;
std::filesystem::path MappingDbPath() {
return RuntimeConfigFile::ApplicationDataDirectory() / "gamecontrollerdb.txt";
}
std::string GuidString(SDL_JoystickID instance) {
char buf[33] = {};
SDL_GUIDToString(SDL_GetJoystickGUIDForID(instance), buf, sizeof(buf));
return buf;
}
bool BindingUsed(const std::string& value) {
return std::any_of(g_wizard.bindings.begin(), g_wizard.bindings.end(),
[&](const std::optional<std::string>& b) { return b && *b == value; });
}
void SnapshotAxes() {
g_wizard.axisBaseline.clear();
const int axes = SDL_GetNumJoystickAxes(g_wizard.joystick);
for (int i = 0; i < axes; ++i) {
g_wizard.axisBaseline.push_back(SDL_GetJoystickAxis(g_wizard.joystick, i));
}
}
void AdvanceStep(std::optional<std::string> value) {
g_wizard.bindings[g_wizard.stepIndex] = std::move(value);
++g_wizard.stepIndex;
g_wizard.acceptAfter = Clock::now() + kCaptureDebounce;
SnapshotAxes();
}
void StopWizard() {
if (g_wizard.ownsJoystick && g_wizard.joystick != nullptr) {
SDL_CloseJoystick(g_wizard.joystick);
}
g_wizard = WizardState{};
}
void StartWizard(SDL_JoystickID instance) {
StopWizard();
SDL_Joystick* joystick = nullptr;
bool owns = false;
if (SDL_Gamepad* gamepad = SDL_GetGamepadFromID(instance)) {
joystick = SDL_GetGamepadJoystick(gamepad);
} else {
joystick = SDL_OpenJoystick(instance);
owns = true;
}
if (joystick == nullptr) {
RT_LOG(RT_TAG_CONFIG) << "controller wizard: failed to open joystick " << instance << ": "
<< SDL_GetError() << std::endl;
return;
}
g_wizard.active = true;
g_wizard.instance = instance;
g_wizard.joystick = joystick;
g_wizard.ownsJoystick = owns;
const char* name = SDL_GetJoystickNameForID(instance);
g_wizard.deviceName = name != nullptr ? name : "Controller";
g_wizard.acceptAfter = Clock::now() + kCaptureDebounce;
SnapshotAxes();
}
std::string BuildMappingString() {
std::string name = g_wizard.deviceName;
std::replace(name.begin(), name.end(), ',', ' ');
std::string mapping = GuidString(g_wizard.instance) + "," + name + ",";
for (size_t i = 0; i < kSteps.size(); ++i) {
if (g_wizard.bindings[i]) {
mapping += std::string(kSteps[i].mappingKey) + ":" + *g_wizard.bindings[i] + ",";
}
}
mapping += "platform:Windows,";
return mapping;
}
bool PersistMapping(const std::string& guid, const std::string& mapping) {
const std::filesystem::path path = MappingDbPath();
std::vector<std::string> lines;
{
std::ifstream in(path);
std::string line;
while (std::getline(in, line)) {
if (line.rfind(guid + ",", 0) != 0) {
lines.push_back(line);
}
}
}
lines.push_back(mapping);
std::error_code ec;
std::filesystem::create_directories(path.parent_path(), ec);
std::ofstream out(path, std::ios::trunc);
if (!out) {
return false;
}
for (const auto& line : lines) {
out << line << '\n';
}
out.close();
return static_cast<bool>(out);
}
void FinishWizard() {
const std::string guid = GuidString(g_wizard.instance);
const std::string mapping = BuildMappingString();
if (SDL_AddGamepadMapping(mapping.c_str()) < 0) {
g_wizard.status = std::string("Failed to apply mapping: ") + SDL_GetError();
RT_LOG(RT_TAG_CONFIG) << "controller wizard: " << g_wizard.status << " (" << mapping << ")"
<< std::endl;
return;
}
if (!PersistMapping(guid, mapping)) {
g_wizard.status =
"Failed to save mapping to " + RuntimeConfigFile::PathToUtf8(MappingDbPath());
RT_LOG(RT_TAG_CONFIG) << "controller wizard: " << g_wizard.status << std::endl;
return;
}
RT_LOG(RT_TAG_CONFIG) << "controller wizard: applied mapping " << mapping << std::endl;
StopWizard();
}
struct SetupCandidate {
SDL_JoystickID id;
std::string name;
bool incompleteMapping;
};
// A device needs setup when SDL has no gamepad mapping for it at all, or when
// the mapping it matched has no analog stick even though the hardware reports
// axes (SDL's built-in raphnet WUSBMote entry is button-only).
std::vector<SetupCandidate> CollectCandidates() {
std::vector<SetupCandidate> candidates;
int count = 0;
SDL_JoystickID* ids = SDL_GetJoysticks(&count);
if (ids == nullptr) {
return candidates;
}
for (int i = 0; i < count; ++i) {
const SDL_JoystickID id = ids[i];
const char* rawName = SDL_GetJoystickNameForID(id);
const std::string name = rawName != nullptr ? rawName : "Unknown controller";
if (!SDL_IsGamepad(id)) {
candidates.push_back({id, name, false});
continue;
}
SDL_Gamepad* gamepad = SDL_GetGamepadFromID(id);
if (gamepad == nullptr) {
continue;
}
char* mapping = SDL_GetGamepadMappingForID(id);
if (mapping == nullptr) {
continue;
}
const std::string mappingStr = mapping;
SDL_free(mapping);
const bool hasStick = mappingStr.find("leftx:") != std::string::npos &&
mappingStr.find("lefty:") != std::string::npos;
SDL_Joystick* joystick = SDL_GetGamepadJoystick(gamepad);
if (!hasStick && joystick != nullptr && SDL_GetNumJoystickAxes(joystick) >= 2) {
candidates.push_back({id, name, true});
}
}
SDL_free(ids);
return candidates;
}
void HandleButtonDown(const SDL_JoyButtonEvent& event) {
const Step& step = kSteps[g_wizard.stepIndex];
if (step.kind == StepKind::Stick) {
return;
}
const std::string value = "b" + std::to_string(event.button);
if (BindingUsed(value)) {
g_wizard.status = "That button is already bound";
return;
}
g_wizard.status.clear();
AdvanceStep(value);
}
void HandleHatMotion(const SDL_JoyHatEvent& event) {
const Step& step = kSteps[g_wizard.stepIndex];
if (step.kind == StepKind::Stick) {
return;
}
// Only single-direction presses bind cleanly; diagonals are ignored.
if (event.value != SDL_HAT_UP && event.value != SDL_HAT_RIGHT && event.value != SDL_HAT_DOWN &&
event.value != SDL_HAT_LEFT) {
return;
}
const std::string value =
"h" + std::to_string(event.hat) + "." + std::to_string(static_cast<int>(event.value));
if (BindingUsed(value)) {
g_wizard.status = "That direction is already bound";
return;
}
g_wizard.status.clear();
AdvanceStep(value);
}
void HandleAxisMotion(const SDL_JoyAxisEvent& event) {
const Step& step = kSteps[g_wizard.stepIndex];
if (step.kind == StepKind::Button) {
return;
}
if (event.axis >= g_wizard.axisBaseline.size()) {
return;
}
const int32_t delta =
static_cast<int32_t>(event.value) - static_cast<int32_t>(g_wizard.axisBaseline[event.axis]);
const int16_t threshold = step.kind == StepKind::Stick ? kStickThreshold : kTriggerThreshold;
if (std::abs(delta) < threshold) {
return;
}
// Stick prompts ask for LEFT/UP, which SDL expects to be negative; triggers
// are expected to increase when pulled. A wrong-way delta means the raw
// axis is inverted, which the mapping expresses with a '~' suffix.
const bool expectNegative = step.kind == StepKind::Stick;
const bool inverted = expectNegative ? delta > 0 : delta < 0;
std::string value = "a" + std::to_string(event.axis);
// Reject reusing an axis already bound (with or without inversion).
if (BindingUsed(value) || BindingUsed(value + "~")) {
g_wizard.status = "That axis is already bound";
return;
}
if (inverted) {
value += "~";
}
g_wizard.status.clear();
AdvanceStep(value);
}
} // namespace
void LoadPersistedMappings() {
std::ifstream in(MappingDbPath());
if (!in) {
return;
}
std::string line;
int added = 0;
while (std::getline(in, line)) {
const std::string trimmed = RuntimeConfigFile::Trim(line);
if (trimmed.empty() || trimmed[0] == '#') {
continue;
}
if (SDL_AddGamepadMapping(trimmed.c_str()) >= 0) {
++added;
} else {
RT_LOG(RT_TAG_CONFIG) << "gamecontrollerdb.txt: rejected mapping: " << trimmed
<< " (" << SDL_GetError() << ")" << std::endl;
}
}
if (added > 0) {
RT_LOG(RT_TAG_CONFIG) << "gamecontrollerdb.txt: applied " << added << " custom mapping"
<< (added == 1 ? "" : "s") << std::endl;
}
}
void HandleSdlEvent(const SDL_Event& event) {
if (!g_wizard.active) {
return;
}
if (event.type == SDL_EVENT_JOYSTICK_REMOVED && event.jdevice.which == g_wizard.instance) {
StopWizard();
return;
}
if (event.type == SDL_EVENT_KEY_DOWN && event.key.scancode == SDL_SCANCODE_ESCAPE) {
StopWizard();
return;
}
if (g_wizard.stepIndex >= kSteps.size() || Clock::now() < g_wizard.acceptAfter) {
return;
}
switch (event.type) {
case SDL_EVENT_JOYSTICK_BUTTON_DOWN:
if (event.jbutton.which == g_wizard.instance) {
HandleButtonDown(event.jbutton);
}
break;
case SDL_EVENT_JOYSTICK_HAT_MOTION:
if (event.jhat.which == g_wizard.instance) {
HandleHatMotion(event.jhat);
}
break;
case SDL_EVENT_JOYSTICK_AXIS_MOTION:
if (event.jaxis.which == g_wizard.instance) {
HandleAxisMotion(event.jaxis);
}
break;
default:
break;
}
}
void DrawSetupList() {
const std::vector<SetupCandidate> candidates = CollectCandidates();
if (candidates.empty()) {
return;
}
ImGui::SeparatorText("Unrecognized controllers");
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
ImGui::TextDisabled(
"These devices have no usable gamepad mapping. Set one up by pressing "
"each control when asked.");
ImGui::PopTextWrapPos();
for (const auto& candidate : candidates) {
ImGui::PushID(static_cast<int>(candidate.id));
ImGui::TextUnformatted(candidate.name.c_str());
ImGui::SameLine();
if (ImGui::SmallButton(candidate.incompleteMapping ? "Fix mapping" : "Set up")) {
StartWizard(candidate.id);
}
if (candidate.incompleteMapping && ImGui::IsItemHovered()) {
ImGui::SetTooltip("SDL matched a mapping without an analog stick for this device");
}
ImGui::PopID();
}
}
void Draw() {
if (!g_wizard.active) {
return;
}
const ImGuiViewport* viewport = ImGui::GetMainViewport();
ImGui::SetNextWindowPos(ImVec2(viewport->Pos.x + viewport->Size.x * 0.5f,
viewport->Pos.y + viewport->Size.y * 0.5f),
ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
ImGui::SetNextWindowSize(ImVec2(420.0f, 0.0f), ImGuiCond_Appearing);
bool open = true;
if (ImGui::Begin("Controller setup", &open,
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoSavedSettings)) {
ImGui::TextUnformatted(g_wizard.deviceName.c_str());
ImGui::Separator();
if (g_wizard.stepIndex < kSteps.size()) {
ImGui::Text("Step %d of %d", static_cast<int>(g_wizard.stepIndex + 1),
static_cast<int>(kSteps.size()));
ImGui::Spacing();
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 400.0f);
ImGui::TextUnformatted(kSteps[g_wizard.stepIndex].prompt);
ImGui::PopTextWrapPos();
ImGui::Spacing();
if (ImGui::Button("Skip")) {
g_wizard.status.clear();
AdvanceStep(std::nullopt);
}
ImGui::SameLine();
ImGui::BeginDisabled(g_wizard.stepIndex == 0);
if (ImGui::Button("Back")) {
--g_wizard.stepIndex;
g_wizard.bindings[g_wizard.stepIndex].reset();
g_wizard.status.clear();
g_wizard.acceptAfter = Clock::now() + kCaptureDebounce;
SnapshotAxes();
}
ImGui::EndDisabled();
ImGui::SameLine();
if (ImGui::Button("Cancel")) {
open = false;
}
} else {
const size_t boundCount =
std::count_if(g_wizard.bindings.begin(), g_wizard.bindings.end(),
[](const std::optional<std::string>& b) { return b.has_value(); });
ImGui::Text("Captured %d of %d controls.", static_cast<int>(boundCount),
static_cast<int>(kSteps.size()));
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 400.0f);
ImGui::TextDisabled("Save applies the mapping now and remembers it for future launches.");
ImGui::PopTextWrapPos();
ImGui::Spacing();
ImGui::BeginDisabled(boundCount == 0);
if (ImGui::Button("Save")) {
FinishWizard();
}
ImGui::EndDisabled();
ImGui::SameLine();
if (ImGui::Button("Back")) {
--g_wizard.stepIndex;
g_wizard.bindings[g_wizard.stepIndex].reset();
g_wizard.acceptAfter = Clock::now() + kCaptureDebounce;
SnapshotAxes();
}
ImGui::SameLine();
if (ImGui::Button("Cancel")) {
open = false;
}
}
if (!g_wizard.status.empty()) {
ImGui::Spacing();
ImGui::TextColored(ImVec4(1.0f, 0.65f, 0.3f, 1.0f), "%s", g_wizard.status.c_str());
}
}
ImGui::End();
if (!open && g_wizard.active) {
StopWizard();
}
}
bool IsActive() { return g_wizard.active; }
} // namespace controller_mapping_wizard
+106 -26
View File
@@ -13,8 +13,24 @@
#include <iomanip>
#include <sstream>
#if !defined(_WIN32)
#include "libco.h"
#endif
namespace Fiber {
#if !defined(_WIN32)
namespace {
// libco's co_create() entry points take no argument, unlike CreateFiber(size, FiberProc, param).
// CreateGuestFiber() stages the guest thread address here immediately before the first co_switch
// into a freshly created cothread; FiberProcTrampoline reads it exactly once, at the top of the
// fiber's very first activation. Safe because guest fibers are strictly cooperative on a single
// OS thread: nothing else can run (and so nothing else can overwrite this) between the staging
// write and the trampoline's read of it.
thread_local uint32_t s_pendingFiberArg = 0;
} // namespace
#endif
std::mutex GuestFiberManager::s_mutex;
std::unordered_map<uint32_t, GuestFiber> GuestFiberManager::s_fibers;
std::vector<void*> GuestFiberManager::s_fibersPendingDelete;
@@ -24,18 +40,25 @@ bool GuestFiberManager::s_initialized = false;
thread_local CpuContext* GuestFiberManager::s_cpuContext = nullptr;
void GuestFiberManager::PurgePendingFibers() {
#if defined(_WIN32)
std::vector<void*> toDelete;
{
std::lock_guard<std::mutex> lock(s_mutex);
toDelete.swap(s_fibersPendingDelete);
}
#if defined(_WIN32)
const void* current = GetCurrentFiber();
for (void* f : toDelete) {
if (f && f != current) {
DeleteFiber(f);
}
}
#else
const void* current = co_active();
for (void* f : toDelete) {
if (f && f != current) {
co_delete(static_cast<cothread_t>(f));
}
}
#endif
}
@@ -204,10 +227,12 @@ void GuestFiberManager::Initialize() {
}
#else
RT_LOG(RT_TAG_OS) << "WARNING: Fiber support not available on this platform!" << std::endl;
s_schedulerFiber = nullptr;
// co_active() returns a handle for whichever native stack is currently running, creating one
// on first call if needed - the libco analogue of ConvertThreadToFiber(nullptr): it converts
// this call's own stack into a switchable target without altering control flow.
s_schedulerFiber = co_active();
#endif
s_currentGuestThread = 0;
s_initialized = true;
}
@@ -223,14 +248,25 @@ void GuestFiberManager::Shutdown() {
}
}
s_fibers.clear();
// Convert scheduler fiber back to thread
if (s_schedulerFiber) {
ConvertFiberToThread();
s_schedulerFiber = nullptr;
}
#else
for (auto& [addr, fiber] : s_fibers) {
if (fiber.fiber && !fiber.isSchedulerFiber) {
co_delete(static_cast<cothread_t>(fiber.fiber));
fiber.fiber = nullptr;
}
}
s_fibers.clear();
// Unlike ConvertFiberToThread, libco has no "undo" for co_active(): the scheduler's own
// stack was never separately allocated, so there is nothing to release here.
s_schedulerFiber = nullptr;
#endif
s_initialized = false;
}
@@ -250,12 +286,14 @@ bool GuestFiberManager::CreateGuestFiber(uint32_t guestThreadAddr, uint32_t entr
// Check if fiber already exists for this thread - if so, reset it
auto existingIt = s_fibers.find(guestThreadAddr);
if (existingIt != s_fibers.end()) {
#if defined(_WIN32)
// Delete the old fiber if it exists and is not the scheduler fiber
if (existingIt->second.fiber && !existingIt->second.isSchedulerFiber) {
#if defined(_WIN32)
DeleteFiber(existingIt->second.fiber);
}
#else
co_delete(static_cast<cothread_t>(existingIt->second.fiber));
#endif
}
s_fibers.erase(existingIt);
}
@@ -288,9 +326,18 @@ bool GuestFiberManager::CreateGuestFiber(uint32_t guestThreadAddr, uint32_t entr
return false;
}
#else
gf.fiber = nullptr;
// libco's co_create() entry point takes no argument; SwitchToThread() stages guestThreadAddr
// into s_pendingFiberArg immediately before the co_switch that first activates this handle.
constexpr unsigned int kHostStackSize = 64 * 1024;
gf.fiber = co_create(kHostStackSize, &FiberProcTrampoline);
if (!gf.fiber) {
RT_LOG(RT_TAG_OS) << "co_create failed for thread 0x"
<< std::hex << guestThreadAddr << std::dec << std::endl;
return false;
}
#endif
s_fibers[guestThreadAddr] = gf;
@@ -342,17 +389,27 @@ void GuestFiberManager::ExitGuestThread(uint32_t guestThreadAddr, ThreadState fi
s_currentGuestThread = 0;
}
#if defined(_WIN32)
if (it->second.fiber && !it->second.isSchedulerFiber) {
#if defined(_WIN32)
const void* current = GetCurrentFiber();
if (it->second.fiber == current) {
s_fibersPendingDelete.push_back(it->second.fiber);
} else {
DeleteFiber(it->second.fiber);
}
#else
const void* current = co_active();
if (it->second.fiber == current) {
// Deleting the coroutine we're currently executing on would free the very stack
// this call is running on; defer it (PurgePendingFibers) until some other fiber is
// active, exactly like the Windows branch above.
s_fibersPendingDelete.push_back(it->second.fiber);
} else {
co_delete(static_cast<cothread_t>(it->second.fiber));
}
#endif
it->second.fiber = nullptr;
}
#endif
}
void GuestFiberManager::SwitchToThread(uint32_t guestThreadAddr, CpuContext* cpu) {
@@ -415,10 +472,13 @@ void GuestFiberManager::SwitchToThread(uint32_t guestThreadAddr, CpuContext* cpu
// Store CPU context pointer for the target fiber to use
s_cpuContext = cpu;
#if defined(_WIN32)
// Check if we're already on the target fiber (e.g., switching to main thread
// when we're already on the scheduler fiber)
#if defined(_WIN32)
void* currentFiber = GetCurrentFiber();
#else
void* currentFiber = co_active();
#endif
if (currentFiber == fiberHandle) {
// Already executing on the target host fiber. This is common for the
// default guest thread, which also owns the scheduler fiber. Keep the
@@ -426,7 +486,7 @@ void GuestFiberManager::SwitchToThread(uint32_t guestThreadAddr, CpuContext* cpu
// from before the guest thread slept.
return;
}
if (cpu && haveTargetContext) {
*cpu = targetContext;
// FPSCR travels with the guest-thread context, and its NI bit is
@@ -436,8 +496,16 @@ void GuestFiberManager::SwitchToThread(uint32_t guestThreadAddr, CpuContext* cpu
}
// Switch to the target fiber (the target fiber will load its own context)
#if defined(_WIN32)
SwitchToFiber(fiberHandle);
#else
// Staged for FiberProcTrampoline's first (and only) read; a no-op for a fiber that has
// already started, since resuming it re-enters mid-function rather than through the
// trampoline's entry point.
s_pendingFiberArg = guestThreadAddr;
co_switch(static_cast<cothread_t>(fiberHandle));
#endif
// When we return here, the fiber that issued SwitchToThread has resumed.
// That does not automatically mean the previous guest thread became runnable
// again; a different thread may simply have yielded back to the scheduler.
@@ -476,7 +544,6 @@ void GuestFiberManager::SwitchToThread(uint32_t guestThreadAddr, CpuContext* cpu
s_currentGuestThread = 0;
}
}
#endif
}
uint32_t GuestFiberManager::GetCurrentGuestThread() {
@@ -551,6 +618,14 @@ void GuestFiberManager::ProcessTimerEvents(CpuContext* cpu) {
}
}
void GuestFiberManager::SwitchToScheduler() {
#if defined(_WIN32)
SwitchToFiber(s_schedulerFiber);
#else
co_switch(static_cast<cothread_t>(s_schedulerFiber));
#endif
}
#if defined(_WIN32)
void CALLBACK GuestFiberManager::FiberProc(void* param)
#else
@@ -558,9 +633,7 @@ void GuestFiberManager::FiberProc(void* param)
#endif
{
uint32_t guestThreadAddr = static_cast<uint32_t>(reinterpret_cast<uintptr_t>(param));
#if defined(_WIN32)
// Get our fiber info
GuestFiber* fiber = nullptr;
uint32_t entryPoint = 0;
@@ -571,7 +644,7 @@ void GuestFiberManager::FiberProc(void* param)
auto it = s_fibers.find(guestThreadAddr);
if (it == s_fibers.end()) {
RT_LOG(RT_TAG_OS) << "FiberProc: fiber not found!" << std::endl;
SwitchToFiber(s_schedulerFiber);
SwitchToScheduler();
return;
}
fiber = &it->second;
@@ -634,7 +707,7 @@ void GuestFiberManager::FiberProc(void* param)
<< ", fn=0x" << startFn << ") after retries; continuing anyway." << std::dec << std::endl;
break;
}
SwitchToFiber(s_schedulerFiber);
SwitchToScheduler();
}
// The deferral loop above yields to the scheduler and therefore can resume
@@ -691,11 +764,18 @@ void GuestFiberManager::FiberProc(void* param)
}
// Return to scheduler
SwitchToFiber(s_schedulerFiber);
#else
(void)guestThreadAddr;
RT_LOG(RT_TAG_OS) << "Fibers not supported on this platform!" << std::endl;
#endif
SwitchToScheduler();
}
#if !defined(_WIN32)
void GuestFiberManager::FiberProcTrampoline() {
const uint32_t guestThreadAddr = s_pendingFiberArg;
FiberProc(reinterpret_cast<void*>(static_cast<uintptr_t>(guestThreadAddr)));
// FiberProc always calls SwitchToScheduler() on every exit path and never falls off its own
// end; this is only a safety net in case that ever changes; falling off co_create's entry
// function is otherwise undefined behavior (libco's own crash() fallback aborts instead).
SwitchToScheduler();
}
#endif
} // namespace Fiber
+181 -31
View File
@@ -19,6 +19,7 @@
#include "runtime_log.h"
#include "system_bridge.h"
#if defined(_WIN32)
#ifndef NOMINMAX
#define NOMINMAX
#endif
@@ -26,28 +27,57 @@
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#else
#include <cerrno>
#include <cstring>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>
#endif
namespace GuestFlat {
namespace {
#if defined(_WIN32)
// Placeholder / view constants. Declared here so the build does not depend on
// the exact Windows SDK version that first shipped them.
constexpr DWORD kMemReplacePlaceholder = 0x00004000;
constexpr DWORD kMemReservePlaceholder = 0x00040000;
constexpr DWORD kMemPreservePlaceholder = 0x00000002;
#endif
constexpr size_t kAllocationGranularity = 0x10000; // 64 KiB
constexpr size_t kHostPageSize = 0x1000;
// Named, platform-neutral protection modes so every fault-interception call site below (the
// MMIO window, the executable-write guard, deferred-EFB-read protection, the on-demand
// unmapped-block commit) can stay identical text on both platforms; only ProtectRange() and
// CommitPlaceholder() below branch on VirtualProtect vs. mprotect.
#if defined(_WIN32)
using ProtectionFlags = DWORD;
constexpr ProtectionFlags kProtNone = PAGE_NOACCESS;
constexpr ProtectionFlags kProtRead = PAGE_READONLY;
constexpr ProtectionFlags kProtReadWrite = PAGE_READWRITE;
#else
using ProtectionFlags = int;
constexpr ProtectionFlags kProtNone = PROT_NONE;
constexpr ProtectionFlags kProtRead = PROT_READ;
constexpr ProtectionFlags kProtReadWrite = PROT_READ | PROT_WRITE;
#endif
#if defined(_WIN32)
using VirtualAlloc2Fn = PVOID(WINAPI*)(HANDLE, PVOID, SIZE_T, ULONG, ULONG, void*, ULONG);
using MapViewOfFile3Fn = PVOID(WINAPI*)(HANDLE, HANDLE, PVOID, ULONG64, SIZE_T, ULONG, ULONG, void*, ULONG);
VirtualAlloc2Fn g_virtualAlloc2 = nullptr;
MapViewOfFile3Fn g_mapViewOfFile3 = nullptr;
#endif
uint8_t* g_base = nullptr;
bool g_initialized = false;
std::vector<RegionRequest> g_activeRegions;
#if defined(_WIN32)
PVOID g_vectoredHandle = nullptr;
#endif
std::mutex& StateMutex() {
static std::mutex mutex;
@@ -70,7 +100,11 @@ struct SectionKeyHash {
};
struct Section {
#if defined(_WIN32)
HANDLE handle = nullptr;
#else
int fd = -1;
#endif
uint64_t size = 0;
uint8_t* hostView = nullptr;
};
@@ -114,6 +148,18 @@ std::vector<GuardedRange>& DeferredRanges() {
return ranges;
}
#if !defined(_WIN32)
// Windows disambiguates a racing "unmapped touch" fault via VirtualQuery (did some other thread
// already commit this 64 KiB block, and is it actually accessible enough to satisfy this access).
// mprotect has no query counterpart, so this tracks the same fact ourselves: one bit per 64 KiB
// block, set the first time this module ever commits it, checked-and-set under StateMutex() so
// two threads racing on the same never-yet-committed block still report/commit exactly once.
std::vector<uint8_t>& UnmappedCommittedBlocks() {
static std::vector<uint8_t> blocks(1u << 16, 0); // 2^32 / 64 KiB
return blocks;
}
#endif
std::atomic<uint32_t> g_countMmio{0};
std::atomic<uint32_t> g_countEfb{0};
std::atomic<uint32_t> g_countXGuard{0};
@@ -133,10 +179,26 @@ uint64_t RoundUp(uint64_t value, uint64_t alignment) {
std::string LastErrorText(const char* what) {
std::ostringstream oss;
#if defined(_WIN32)
oss << what << " failed (GetLastError=" << GetLastError() << ")";
#else
oss << what << " failed (" << std::strerror(errno) << ")";
#endif
return oss.str();
}
// Protects [address, address+size) with `protection`, bridging VirtualProtect (Windows) and
// mprotect (POSIX) so every fault-interception call site below can stay platform-neutral.
bool ProtectRange(uint8_t* address, uint64_t size, ProtectionFlags protection) {
#if defined(_WIN32)
DWORD previous = 0;
return VirtualProtect(address, static_cast<SIZE_T>(size), protection, &previous) != FALSE;
#else
return mprotect(address, static_cast<size_t>(size), protection) == 0;
#endif
}
#if defined(_WIN32)
void ResolvePlacementApi() {
if (g_virtualAlloc2 != nullptr && g_mapViewOfFile3 != nullptr) return;
HMODULE kernelBase = GetModuleHandleW(L"kernelbase.dll");
@@ -153,9 +215,11 @@ void ResolvePlacementApi() {
"are unavailable on this system).");
}
}
#endif
void EnsureReservation() {
if (g_base != nullptr) return;
#if defined(_WIN32)
ResolvePlacementApi();
void* requested = reinterpret_cast<void*>(kFixedFlatGuestBase);
@@ -178,25 +242,59 @@ void EnsureReservation() {
"usual cause.";
throw std::runtime_error(oss.str());
}
if (reserved != requested) {
throw std::runtime_error(
"The flat guest reservation did not land on the fixed base the translated code was "
"compiled against.");
}
#else
void* requested = reinterpret_cast<void*>(kFixedFlatGuestBase);
// No MAP_FIXED here (and deliberately no MAP_FIXED_NOREPLACE, which needs Linux 4.17+ -
// this must work on kernels as old as 4.9): `requested` is only a hint. The kernel's
// get_unmapped_area honors a page-aligned hint when the whole range is free, so this lands
// on the fixed base in the normal case; if anything already occupies part of the range, the
// kernel silently picks a different address instead of clobbering it, which the check below
// catches - same "something got there first" contract as the Windows path, without needing
// a specific kernel version.
void* reserved = mmap(requested, kGuestSpaceSize + kAllocationGranularity, kProtNone,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0);
if (reserved == MAP_FAILED) {
std::ostringstream oss;
oss << "Unable to reserve the 4 GiB flat guest address space at 0x" << std::hex
<< reinterpret_cast<uintptr_t>(requested) << std::dec
<< " (" << std::strerror(errno)
<< "). The translated code addresses guest memory through this fixed base, so it "
"cannot fall back to another one.";
throw std::runtime_error(oss.str());
}
if (reserved != requested) {
munmap(reserved, kGuestSpaceSize + kAllocationGranularity);
std::ostringstream oss;
oss << "Unable to reserve the 4 GiB flat guest address space at 0x" << std::hex
<< reinterpret_cast<uintptr_t>(requested) << std::dec
<< ". Something else in this process already occupies part of the 16 TiB region - "
"an injected library, an overlay or a debugging tool is the usual cause.";
throw std::runtime_error(oss.str());
}
#endif
g_base = static_cast<uint8_t*>(reserved);
}
#if defined(_WIN32)
// Carves `size` bytes out of the enclosing placeholder so a view or a private
// commit can replace it. Splitting an exact-size placeholder is a no-op that
// reports ERROR_INVALID_PARAMETER; the caller validates the replacement.
void SplitPlaceholder(uint8_t* address, uint64_t size) {
VirtualFree(address, static_cast<SIZE_T>(size), MEM_RELEASE | kMemPreservePlaceholder);
}
#endif
void MapGuestView(const Section& section, uint64_t sectionOffset, uint32_t guestBase,
uint64_t mappedSize) {
uint8_t* target = g_base + guestBase;
#if defined(_WIN32)
SplitPlaceholder(target, mappedSize);
void* view = g_mapViewOfFile3(section.handle, GetCurrentProcess(), target, sectionOffset,
static_cast<SIZE_T>(mappedSize), kMemReplacePlaceholder,
@@ -208,16 +306,37 @@ void MapGuestView(const Section& section, uint64_t sectionOffset, uint32_t guest
<< ")";
throw std::runtime_error(oss.str());
}
#else
// MAP_FIXED is safe (and needs no particular kernel version) here specifically because we're
// deliberately overwriting a sub-range of the PROT_NONE reservation this module already owns
// exclusively (see EnsureReservation) - unlike the initial reservation itself, there's no
// "something else might already be there" concern to guard against.
void* view = mmap(target, static_cast<size_t>(mappedSize), kProtReadWrite,
MAP_SHARED | MAP_FIXED, section.fd, static_cast<off_t>(sectionOffset));
if (view == MAP_FAILED) {
std::ostringstream oss;
oss << "Unable to map guest region 0x" << std::hex << guestBase << " (+0x" << mappedSize
<< ") into the flat reservation" << std::dec << " (" << std::strerror(errno) << ")";
throw std::runtime_error(oss.str());
}
#endif
}
// Replaces a placeholder with private committed memory. Used for the MMIO
// window (read-only zeros) and for on-demand commits of stray guest pages.
bool CommitPlaceholder(uint8_t* address, uint64_t size, DWORD protection) {
bool CommitPlaceholder(uint8_t* address, uint64_t size, ProtectionFlags protection) {
#if defined(_WIN32)
SplitPlaceholder(address, size);
void* result = g_virtualAlloc2(GetCurrentProcess(), address, static_cast<SIZE_T>(size),
MEM_RESERVE | MEM_COMMIT | kMemReplacePlaceholder, protection,
nullptr, 0);
return result != nullptr;
#else
// No separate reserve-vs-commit step is needed: the anonymous PROT_NONE reservation this
// range came from is already demand-zero backed, so mprotect() alone both "commits" and
// protects it.
return ProtectRange(address, size, protection);
#endif
}
// One definition of the two windows lives in memory_access.h; these are the
@@ -237,8 +356,7 @@ void ApplyExecutableProtectionLocked() {
for (uint64_t page = first; page < last; page += kHostPageSize) {
const uint32_t pageIndex = static_cast<uint32_t>(page >> 12);
if (protectedPages[pageIndex] != 0) continue;
DWORD previous = 0;
if (VirtualProtect(g_base + page, kHostPageSize, PAGE_READONLY, &previous) != FALSE) {
if (ProtectRange(g_base + page, kHostPageSize, kProtRead)) {
protectedPages[pageIndex] = 1;
}
}
@@ -284,8 +402,16 @@ void ZeroMappedStorage() {
}
}
#if defined(_WIN32)
LONG CALLBACK FlatGuestVectoredHandler(EXCEPTION_POINTERS* info) {
if (HandleAccessViolation(info)) {
const auto* record = info->ExceptionRecord;
if (record == nullptr || record->ExceptionCode != EXCEPTION_ACCESS_VIOLATION ||
record->NumberParameters < 2) {
return EXCEPTION_CONTINUE_SEARCH;
}
void* faultAddress = reinterpret_cast<void*>(record->ExceptionInformation[1]);
const bool isWrite = record->ExceptionInformation[0] != 0;
if (HandleAccessViolation(faultAddress, isWrite)) {
return EXCEPTION_CONTINUE_EXECUTION;
}
return EXCEPTION_CONTINUE_SEARCH;
@@ -298,6 +424,7 @@ void InstallVectoredHandler() {
throw std::runtime_error(LastErrorText("AddVectoredExceptionHandler"));
}
}
#endif
void ReportFatalGuestFault(const char* category, uint32_t guestAddress, bool isWrite,
const char* detail) {
@@ -377,9 +504,7 @@ void Initialize(const std::vector<RegionRequest>& regions) {
for (const auto& range : DeferredRanges()) {
const uint64_t first = static_cast<uint64_t>(range.start) & ~(kHostPageSize - 1u);
const uint64_t last = RoundUp(range.end, kHostPageSize);
DWORD previous = 0;
VirtualProtect(g_base + first, static_cast<SIZE_T>(last - first), PAGE_READWRITE,
&previous);
ProtectRange(g_base + first, last - first, kProtReadWrite);
}
DeferredRanges().clear();
ZeroMappedStorage();
@@ -408,6 +533,7 @@ void Initialize(const std::vector<RegionRequest>& regions) {
const uint64_t rounded = RoundUp(size, kAllocationGranularity);
Section section;
section.size = rounded;
#if defined(_WIN32)
section.handle = CreateFileMappingW(INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE,
static_cast<DWORD>(rounded >> 32),
static_cast<DWORD>(rounded & 0xFFFFFFFFu), nullptr);
@@ -419,6 +545,25 @@ void Initialize(const std::vector<RegionRequest>& regions) {
if (section.hostView == nullptr) {
throw std::runtime_error(LastErrorText("MapViewOfFile for the host guest-RAM alias"));
}
#else
// The section is an anonymous shared-memory object: the SAME physical pages get mapped
// twice below (once here as the always-accessible host view, once per-region as the
// guest view whose protection the fault handler controls), the same "one backing store,
// two VA aliases" trick CreateFileMapping/MapViewOfFile(3) gives Windows.
section.fd = memfd_create("wiicompiled-guest-ram", MFD_CLOEXEC);
if (section.fd < 0) {
throw std::runtime_error(LastErrorText("memfd_create for guest RAM"));
}
if (ftruncate(section.fd, static_cast<off_t>(rounded)) != 0) {
throw std::runtime_error(LastErrorText("ftruncate for guest RAM"));
}
section.hostView = static_cast<uint8_t*>(
mmap(nullptr, static_cast<size_t>(rounded), kProtReadWrite, MAP_SHARED, section.fd, 0));
if (section.hostView == MAP_FAILED) {
section.hostView = nullptr;
throw std::runtime_error(LastErrorText("mmap for the host guest-RAM alias"));
}
#endif
Sections()[key] = section;
}
@@ -435,12 +580,14 @@ void Initialize(const std::vector<RegionRequest>& regions) {
// MMIO stays inaccessible in both directions so the vectored handler can report missing HLE; the old
// PAGE_READONLY read window that returned zero turned missing devices into silent hangs instead.
if (!CommitPlaceholder(g_base + 0xCC000000u, 0x02000000u, PAGE_NOACCESS)) {
if (!CommitPlaceholder(g_base + 0xCC000000u, 0x02000000u, kProtNone)) {
throw std::runtime_error(LastErrorText("committing the no-access MMIO window"));
}
ApplyExecutableProtectionLocked();
#if defined(_WIN32)
InstallVectoredHandler();
#endif
// Freshly created section objects are demand-zero, so no explicit clear is
// needed on the first mapping (that would fault in all 152 MiB at startup).
@@ -470,9 +617,7 @@ void ProtectDeferredRange(uint32_t address, size_t length) {
std::lock_guard<std::mutex> lock(StateMutex());
const uint64_t first = static_cast<uint64_t>(address) & ~(kHostPageSize - 1u);
const uint64_t last = RoundUp(end, kHostPageSize);
DWORD previous = 0;
if (VirtualProtect(g_base + first, static_cast<SIZE_T>(last - first), PAGE_NOACCESS,
&previous) == FALSE) {
if (!ProtectRange(g_base + first, last - first, kProtNone)) {
// An unmapped destination cannot be trapped; the checked path still
// clears the readable bias, so nothing silently reads stale bytes.
return;
@@ -492,8 +637,7 @@ void UnprotectDeferredRange(uint32_t address, size_t length) {
ranges.erase(it);
const uint64_t first = static_cast<uint64_t>(address) & ~(kHostPageSize - 1u);
const uint64_t last = RoundUp(end, kHostPageSize);
DWORD previous = 0;
VirtualProtect(g_base + first, static_cast<SIZE_T>(last - first), PAGE_READWRITE, &previous);
ProtectRange(g_base + first, last - first, kProtReadWrite);
}
void RegisterExecutableRange(uint32_t start, uint32_t end) {
@@ -541,21 +685,14 @@ void LogFaultSummary() noexcept {
std::cerr.flush();
}
bool HandleAccessViolation(void* exceptionPointers) noexcept {
if (!g_initialized || exceptionPointers == nullptr) return false;
auto* info = static_cast<EXCEPTION_POINTERS*>(exceptionPointers);
const auto* record = info->ExceptionRecord;
if (record == nullptr || record->ExceptionCode != EXCEPTION_ACCESS_VIOLATION ||
record->NumberParameters < 2) {
return false;
}
bool HandleAccessViolation(void* faultAddress, bool isWrite) noexcept {
if (!g_initialized || faultAddress == nullptr) return false;
const uintptr_t fault = static_cast<uintptr_t>(record->ExceptionInformation[1]);
const uintptr_t fault = reinterpret_cast<uintptr_t>(faultAddress);
const uintptr_t base = reinterpret_cast<uintptr_t>(g_base);
if (fault < base || fault - base >= kGuestSpaceSize) return false;
const uint32_t guestAddress = static_cast<uint32_t>(fault - base);
const bool isWrite = record->ExceptionInformation[0] != 0;
// 1) Deferred (EFB) read: materialize the pending copy and drop the trap for the whole 4 KiB page span,
// not just the registered range, since protection is page-granular. Leaving a range registered but
@@ -581,9 +718,7 @@ bool HandleAccessViolation(void* exceptionPointers) noexcept {
ranges.erase(it);
spanFirst = static_cast<uint64_t>(rangeStart) & ~(kHostPageSize - 1u);
spanLast = RoundUp(rangeEnd, kHostPageSize);
DWORD previous = 0;
VirtualProtect(g_base + spanFirst, static_cast<SIZE_T>(spanLast - spanFirst),
PAGE_READWRITE, &previous);
ProtectRange(g_base + spanFirst, spanLast - spanFirst, kProtReadWrite);
}
}
if (covered) {
@@ -618,9 +753,8 @@ bool HandleAccessViolation(void* exceptionPointers) noexcept {
// Those arrive in bulk, so the page is opened permanently
// rather than trapping every relocation.
std::lock_guard<std::mutex> lock(StateMutex());
DWORD previous = 0;
if (VirtualProtect(g_base + (static_cast<uint64_t>(pageIndex) << 12), kHostPageSize,
PAGE_READWRITE, &previous) != FALSE) {
if (ProtectRange(g_base + (static_cast<uint64_t>(pageIndex) << 12), kHostPageSize,
kProtReadWrite)) {
ExecutableProtectedPages()[pageIndex] = 0;
}
}
@@ -665,6 +799,7 @@ bool HandleAccessViolation(void* exceptionPointers) noexcept {
bool committed = false;
{
std::lock_guard<std::mutex> lock(StateMutex());
#if defined(_WIN32)
MEMORY_BASIC_INFORMATION mbi{};
if (VirtualQuery(g_base + blockBase, &mbi, sizeof(mbi)) == 0) return false;
if (mbi.State == MEM_COMMIT) {
@@ -678,9 +813,24 @@ bool HandleAccessViolation(void* exceptionPointers) noexcept {
PAGE_EXECUTE)) != 0;
return isWrite ? writable : readable;
}
if (!CommitPlaceholder(g_base + blockBase, kAllocationGranularity, PAGE_READWRITE)) {
#else
// mprotect has no VirtualQuery counterpart to ask "is this block already committed and
// how", so this module tracks the same fact itself (UnmappedCommittedBlocks, checked and
// set under this same lock): once a block has been committed READ|WRITE by an earlier
// call here (this thread's or a racing one's), every subsequent fault on it is a no-op
// resume - there is no POSIX equivalent of "committed but insufficiently permissioned"
// for a block only this function ever touches.
const uint32_t blockIndex = static_cast<uint32_t>(blockBase / kAllocationGranularity);
if (UnmappedCommittedBlocks()[blockIndex] != 0) {
return true;
}
#endif
if (!CommitPlaceholder(g_base + blockBase, kAllocationGranularity, kProtReadWrite)) {
return false;
}
#if !defined(_WIN32)
UnmappedCommittedBlocks()[blockIndex] = 1;
#endif
committed = true;
}
if (committed) {
+4 -2
View File
@@ -695,12 +695,14 @@ private:
const auto path = FindDspCoefficientRom();
std::ifstream stream(path, std::ios::binary | std::ios::ate);
if (!stream || stream.tellg() != static_cast<std::streamoff>(m_coeffs.size() * 2)) {
throw std::runtime_error("Bundled Wii DSP coefficient ROM has an invalid size: " + path.string());
throw std::runtime_error("Bundled Wii DSP coefficient ROM has an invalid size: " +
RuntimeConfigFile::PathToUtf8(path));
}
stream.seekg(0);
std::array<uint8_t, kResamplingCoefficientCount * 2> bytes{};
if (!stream.read(reinterpret_cast<char*>(bytes.data()), bytes.size())) {
throw std::runtime_error("Failed to read bundled Wii DSP coefficient ROM: " + path.string());
throw std::runtime_error("Failed to read bundled Wii DSP coefficient ROM: " +
RuntimeConfigFile::PathToUtf8(path));
}
for (size_t i = 0; i < m_coeffs.size(); ++i) {
const uint16_t word = static_cast<uint16_t>(bytes[i * 2]) << 8 |
+73
View File
@@ -0,0 +1,73 @@
#include "hle_stubs.h"
#include <cstdint>
#include "memory.h"
#include "runtime_log.h"
// Native because a crafted Yaz0 run writes past the caller's buffer
// (github.com/vabold/szsHaxx)
// https://github.com/vabold/Kinoko/blob/main/source/egg/core/Decomp.cc
extern "C" uint32_t EGG_Decomp_decodeSZS_80218c2c(uint32_t src, uint32_t dst)
{
const uint32_t expandSize = (static_cast<uint32_t>(MemoryInline::FlatRead8(src + 4)) << 24) |
(static_cast<uint32_t>(MemoryInline::FlatRead8(src + 5)) << 16) |
(static_cast<uint32_t>(MemoryInline::FlatRead8(src + 6)) << 8) |
static_cast<uint32_t>(MemoryInline::FlatRead8(src + 7));
uint32_t srcIdx = 16;
uint32_t dstIdx = 0;
uint32_t mask = 0;
uint32_t flags = 0;
while (static_cast<int32_t>(dstIdx) < static_cast<int32_t>(expandSize)) {
if (mask == 0) {
flags = MemoryInline::FlatRead8(src + srcIdx++);
mask = 0x80;
}
if ((flags & mask) != 0) {
MemoryInline::FlatWrite8(dst + dstIdx++, MemoryInline::FlatRead8(src + srcIdx++));
} else {
const uint32_t high = MemoryInline::FlatRead8(src + srcIdx);
const uint32_t low = MemoryInline::FlatRead8(src + srcIdx + 1);
srcIdx += 2;
const uint32_t rep = (high << 8) | low;
// Without this check dstIdx - distance underflows and the
// copy leaks guest memory from before the destination buffer.
const uint32_t distance = (rep & 0xFFF) + 1;
if (distance > dstIdx) {
RT_LOG(RT_TAG_HLE) << "decodeSZS: malformed stream from 0x" << std::hex << src
<< std::dec << ", back-reference before output" << std::endl;
ShowRuntimeFatalPopup("corrupt compressed file",
"The game stopped decoding a malformed Yaz0 file.");
std::abort();
}
uint32_t copyIdx = dstIdx - distance;
uint32_t count = rep >> 12;
count = count != 0
? count + 2
: static_cast<uint32_t>(MemoryInline::FlatRead8(src + srcIdx++)) + 18;
for (uint32_t i = 0; i < count; ++i) {
if (dstIdx >= expandSize) {
RT_LOG(RT_TAG_HLE) << "decodeSZS: malformed stream from 0x" << std::hex << src
<< std::dec << ", output overran " << expandSize << " bytes"
<< std::endl;
ShowRuntimeFatalPopup("corrupt compressed file",
"The game stopped decoding a malformed Yaz0 file.");
std::abort();
}
MemoryInline::FlatWrite8(dst + dstIdx++, MemoryInline::FlatRead8(dst + copyIdx++));
}
}
mask >>= 1;
}
return expandSize;
}
PPC_NATIVE_OVERRIDE(80218C2C, EGG_Decomp_decodeSZS_80218c2c, uint32_t,
(uint32_t src, uint32_t dst), (src, dst));
+1 -1
View File
@@ -44,7 +44,7 @@ extern "C" uint32_t PAD__Read_HLE(uint32_t statusPtr)
}
PADStatus statuses[PAD_CHANMAX]{};
const uint32_t rumbleMask = PADRead(statuses);
uint32_t rumbleMask = PADRead(statuses);
try {
for (uint32_t i = 0; i < PAD_CHANMAX; ++i) {
+31 -7
View File
@@ -2,6 +2,7 @@
#include <cstdint>
#include <iostream>
#include <set>
#include <string>
#include <vector>
@@ -103,17 +104,26 @@ static void HLE_LogOSReport(CpuContext* cpu, const char* fmt)
[&state]() { return NextOsReportDouble(state); },
[](uint32_t address) { return ReadGuestStringForReport(address); });
// nw4r warnings arrive as "<file>:<line> Warning:" plus a bare newline, so
// consecutive identical messages never land back to back. Blank lines are
// transparent to the repeat tracker so the pair still collapses.
static thread_local std::string lastBuffer;
static thread_local size_t repeated = 0;
if (buffer == lastBuffer) {
const bool blank = buffer.find_first_not_of(" \t\r\n") == std::string::npos;
if (blank) {
if (repeated != 0) {
return;
}
} else if (buffer == lastBuffer) {
++repeated;
return;
} else {
if (repeated != 0) {
std::cout << "[OSReport] previous message repeated " << repeated << " time(s)" << std::endl;
repeated = 0;
}
lastBuffer = buffer;
}
if (repeated != 0) {
std::cout << "[OSReport] previous message repeated " << repeated << " time(s)" << std::endl;
repeated = 0;
}
lastBuffer = buffer;
std::cout << "[OSReport] " << buffer;
@@ -128,9 +138,23 @@ static void HLE_LogOSReport(CpuContext* cpu, const char* fmt)
// the guest caller because OS__Report is an HLE boundary. The context is
// synchronized at this boundary, so capture the guest backchain at the
// first warning/panic instead of attributing the later PPCHalt unwind.
//
// The dump is expensive and stdio is an unbuffered pipe, so a guest that
// warns every frame would stall the game thread on backpressure. One dump
// per distinct site, with an overall cap.
if (buffer.find(" Warning:") != std::string::npos ||
buffer.find(" Panic:") != std::string::npos) {
SystemBridge::DumpCpuState(cpu);
constexpr size_t kMaxWarningDumps = 8;
static thread_local std::set<std::string> dumpedSites;
static thread_local size_t dumpsEmitted = 0;
if (dumpsEmitted < kMaxWarningDumps && dumpedSites.insert(buffer).second) {
++dumpsEmitted;
SystemBridge::DumpCpuState(cpu);
if (dumpsEmitted == kMaxWarningDumps) {
std::cerr << "[runtime] guest warning context dumps capped at " << kMaxWarningDumps
<< "; further warnings log the message only." << std::endl;
}
}
}
}
+37 -38
View File
@@ -40,7 +40,7 @@ namespace fs = std::filesystem;
// The extracted ISO "DATA" folder. We map its "files" subfolder to the DVD
// root "/" and its "sys" subfolder to "/sys/". The root is user-owned input:
// it is never embedded into or copied by the public runtime.
static std::string g_dvdRoot;
static fs::path g_dvdRoot;
static std::once_flag g_dvdRootOnce;
static uint32_t CurrentDiscGameCode() {
@@ -66,7 +66,7 @@ static uint32_t CurrentDiscGameCode() {
#define DVD_FILEINFO_OFFSET_LEN 0x34
struct DVDFileEntry {
std::string hostPath; // Full Windows path
fs::path hostPath;
std::string dvdPath; // Virtual Wii path (e.g., "/Race/Course.szs")
uint32_t size;
uint32_t discOffsetWords = 0;
@@ -78,7 +78,7 @@ struct FstFileEntry {
uint32_t end;
uint32_t size;
std::string dvdPath;
std::string hostPath;
fs::path hostPath;
};
// Global State
@@ -111,11 +111,9 @@ static void CopyToGuestAsDma(uint32_t dest, const uint8_t* data, size_t size) {
GxNotifyGuestRamDmaWrite(dest, static_cast<uint32_t>(size));
}
static std::string NormalizeDvdHostPath(std::string path) {
while (!path.empty() && (path.back() == '\\' || path.back() == '/')) {
path.pop_back();
}
return path;
// Host path strings only ever leave this module as UTF-8 display text.
static std::string HostPathText(const fs::path& path) {
return RuntimeConfigFile::PathToUtf8(path);
}
static bool IsDvdDataRoot(const fs::path& path) {
@@ -140,7 +138,7 @@ static bool IsDvdDataRoot(const fs::path& path) {
[[noreturn]] static void FailDvdRoot(const char* source, const fs::path& path = {}) {
RT_LOGF(RT_TAG_DVD, "ERROR: %s", source);
if (!path.empty()) {
std::fprintf(stderr, ": %s", path.string().c_str());
std::fprintf(stderr, ": %s", HostPathText(path).c_str());
}
std::fprintf(stderr,
"\n[dvd] Set [paths] dvd_root in Config.toml "
@@ -148,14 +146,14 @@ static bool IsDvdDataRoot(const fs::path& path) {
std::string details = source ? source : "The configured DVD root could not be opened.";
if (!path.empty()) {
details += "\n\nPath: ";
details += path.string();
details += HostPathText(path);
}
details += "\n\nSet [paths] dvd_root in Config.toml to the extracted "
"Mario Kart Wii DATA directory.";
FailDvd("dvd_root", "DVD data is unavailable", details);
}
static const std::string& GetDvdRoot() {
static const fs::path& GetDvdRoot() {
std::call_once(g_dvdRootOnce, []() {
const fs::path path = RuntimeConfigFile::ResolvedDvdRoot();
if (path.empty()) {
@@ -164,14 +162,14 @@ static const std::string& GetDvdRoot() {
if (!IsDvdDataRoot(path)) {
FailDvdRoot("Configured DVD root is not an extracted DATA directory", path);
}
g_dvdRoot = NormalizeDvdHostPath(path.string());
g_dvdRoot = path;
});
return g_dvdRoot;
}
static std::string NormalizePath(const std::string& path);
static std::string ResolveDvdMappedHostPath(const std::string& dvdPath, const std::string& fallbackHostPath);
static fs::path ResolveDvdMappedHostPath(const std::string& dvdPath, const fs::path& fallbackHostPath);
static void InvokeDvdCallback(uint32_t callbackPtr, int32_t result, uint32_t fileInfoPtr) {
if (callbackPtr == 0) {
@@ -240,7 +238,7 @@ static void LoadFstIndex() {
}
g_fstLoaded = true;
fs::path fstPath = fs::path(GetDvdRoot()) / "sys" / "fst.bin";
const fs::path fstPath = GetDvdRoot() / "sys" / "fst.bin";
std::ifstream fstFile(fstPath, std::ios::binary);
if (!fstFile.is_open()) {
return;
@@ -333,7 +331,7 @@ static void LoadFstIndex() {
entry.end = endBytes;
entry.size = fileSize;
entry.dvdPath = "/" + relPath;
const std::string baseHostPath = (fs::path(GetDvdRoot()) / "files" / fs::path(relPath)).string();
const fs::path baseHostPath = GetDvdRoot() / "files" / fs::path(relPath);
entry.hostPath = ResolveDvdMappedHostPath(entry.dvdPath, baseHostPath);
g_fstFiles.push_back(std::move(entry));
}
@@ -408,7 +406,7 @@ static void RegisterFileEntry(std::string dvdPath, const fs::path& hostPath, uin
dvdPath = DvdFstContract::CanonicalizePath(dvdPath);
DVDFileEntry fileEntry;
fileEntry.hostPath = hostPath.string();
fileEntry.hostPath = hostPath;
fileEntry.dvdPath = dvdPath;
fileEntry.size = size;
@@ -445,7 +443,7 @@ static void WalkDirectory(const fs::path& root, bool recursive, bool announceErr
fs::recursive_directory_iterator it(root, fs::directory_options::skip_permission_denied, ec);
if (ec) {
if (announceErrors) {
RT_LOG(RT_TAG_DVD) << "WARNING: cannot enumerate " << root.string() << ": "
RT_LOG(RT_TAG_DVD) << "WARNING: cannot enumerate " << HostPathText(root) << ": "
<< ec.message() << std::endl;
}
return;
@@ -458,7 +456,7 @@ static void WalkDirectory(const fs::path& root, bool recursive, bool announceErr
it.increment(ec);
if (ec) {
if (announceErrors) {
RT_LOG(RT_TAG_DVD) << "WARNING: stopped enumerating " << root.string() << ": "
RT_LOG(RT_TAG_DVD) << "WARNING: stopped enumerating " << HostPathText(root) << ": "
<< ec.message() << std::endl;
return;
}
@@ -488,7 +486,7 @@ static void ScanDirectory(const fs::path& root, const std::string& virtualPrefix
const std::uintmax_t size = fs::file_size(entry.path(), entryEc);
if (entryEc) {
RT_LOG(RT_TAG_DVD) << "WARNING: skipping " << entry.path().string() << ": "
RT_LOG(RT_TAG_DVD) << "WARNING: skipping " << HostPathText(entry.path()) << ": "
<< entryEc.message() << std::endl;
return;
}
@@ -502,7 +500,7 @@ static void ScanDirectory(const fs::path& root, const std::string& virtualPrefix
if (prefix.back() != '/' && prefix.back() != '\\') {
prefix += "/";
}
std::string dvdPath = prefix + relative.string();
std::string dvdPath = prefix + HostPathText(relative);
if (!addNewFiles && !DvdEntryExists(dvdPath)) {
return;
}
@@ -536,7 +534,7 @@ static void ApplyFolderByNameMapping(const RuntimeRiivolution::Mapping& mapping)
if (!entry.is_regular_file(entryEc) || entryEc) {
return;
}
std::string name = entry.path().filename().string();
std::string name = HostPathText(entry.path().filename());
RuntimeHle::LowerInPlace(name);
const auto matches = discPathsByName.find(name);
if (matches == discPathsByName.end()) {
@@ -554,7 +552,7 @@ static void ApplyFolderByNameMapping(const RuntimeRiivolution::Mapping& mapping)
WalkDirectory(mapping.hostPath, mapping.recursive, /*announceErrors=*/false, applyEntry);
RT_LOG(RT_TAG_DVD) << mapping.hostPath.string() << ": replaced " << replaced
RT_LOG(RT_TAG_DVD) << HostPathText(mapping.hostPath) << ": replaced " << replaced
<< " disc file(s) by filename" << std::endl;
}
@@ -562,7 +560,7 @@ static void ScanOverlayRoot(const RuntimeRiivolution::Overlay& overlay) {
if (!overlay.patches) {
// Fallback for mod roots that mirror the disc filesystem directly (not a
// Riivolution pack, which wouldn't map anything useful this way).
RT_LOG(RT_TAG_DVD) << overlay.root.string()
RT_LOG(RT_TAG_DVD) << HostPathText(overlay.root)
<< ": no Riivolution XML found, treating the root as a disc-shaped overlay"
<< std::endl;
ScanDirectory(overlay.root, "/");
@@ -592,7 +590,7 @@ static void ScanOverlayRoot(const RuntimeRiivolution::Overlay& overlay) {
}
}
static std::string ResolveDvdMappedHostPath(const std::string& dvdPath, const std::string& fallbackHostPath) {
static fs::path ResolveDvdMappedHostPath(const std::string& dvdPath, const fs::path& fallbackHostPath) {
const std::string normalized = NormalizePath(dvdPath);
const auto it = g_pathToEntry.find(normalized);
if (it != g_pathToEntry.end() && it->second >= 0 &&
@@ -604,7 +602,7 @@ static std::string ResolveDvdMappedHostPath(const std::string& dvdPath, const st
const fs::path candidate = overlay.root / fs::path(normalized.substr(1));
std::error_code ec;
if (fs::is_regular_file(candidate, ec)) {
return candidate.string();
return candidate;
}
}
@@ -637,7 +635,8 @@ static void BuildAndPublishRuntimeFst() {
for (const DVDFileEntry& entry : g_fileEntries) {
if (entry.discOffsetWords != 0) {
nextFreeBytes = std::max(
nextFreeBytes, static_cast<uint64_t>(entry.discOffsetWords) * 4ull + entry.size);
nextFreeBytes, static_cast<uint64_t>(entry.discOffsetWords) * UINT64_C(4) +
static_cast<uint64_t>(entry.size));
}
}
for (DVDFileEntry& entry : g_fileEntries) {
@@ -740,7 +739,7 @@ extern "C" const char* DVDResolveHostPathForTest(const char* dvdPath)
return nullptr;
}
resolved = g_fileEntries[it->second].hostPath;
resolved = HostPathText(g_fileEntries[it->second].hostPath);
return resolved.c_str();
}
@@ -808,7 +807,7 @@ extern "C" void DVDInit_8015EA1C()
Memory::Write16(diskHeader + 0x04, 0x3031); // '01' (Maker)
Memory::Write8(diskHeader + 0x06, 0x01); // Disk #1
// 4. Scan Files
fs::path rootPath(GetDvdRoot());
const fs::path& rootPath = GetDvdRoot();
// Map "<dvd_root>/files" -> "/"
ScanDirectory(rootPath / "files", "/");
@@ -879,7 +878,7 @@ extern "C" int32_t DVDReadPrio_8015E834(uint32_t fileInfoPtr, uint32_t bufferPtr
const DVDFileEntry& entry = g_fileEntries[extent->entryIndex];
if (offset < 0 || length < 0) {
return DvdReadFatal(fileInfoPtr, entry.hostPath, offset,
return DvdReadFatal(fileInfoPtr, HostPathText(entry.hostPath), offset,
length > 0 ? static_cast<uint32_t>(length) : 0,
"negative DVD read offset or length");
}
@@ -889,7 +888,7 @@ extern "C" int32_t DVDReadPrio_8015E834(uint32_t fileInfoPtr, uint32_t bufferPtr
uint32_t uLength = (uint32_t)length;
if (requestedOffset >= entry.size) {
return DvdReadFatal(fileInfoPtr, entry.hostPath, offset, uLength,
return DvdReadFatal(fileInfoPtr, HostPathText(entry.hostPath), offset, uLength,
"read offset is outside the indexed DVD file");
}
const uint32_t uOffset = static_cast<uint32_t>(requestedOffset);
@@ -899,14 +898,14 @@ extern "C" int32_t DVDReadPrio_8015E834(uint32_t fileInfoPtr, uint32_t bufferPtr
}
if (uLength != 0 && !Memory::Contains(bufferPtr, uLength)) {
return DvdReadFatal(fileInfoPtr, entry.hostPath, offset, uLength,
return DvdReadFatal(fileInfoPtr, HostPathText(entry.hostPath), offset, uLength,
"DVD read destination is outside guest memory");
}
std::vector<uint8_t> tempBuf;
DvdReadContract::HostReadFailure failure;
if (!DvdReadContract::ReadExact(entry.hostPath, uOffset, uLength, tempBuf, failure)) {
return DvdReadFatal(fileInfoPtr, entry.hostPath, offset, uLength,
return DvdReadFatal(fileInfoPtr, HostPathText(entry.hostPath), offset, uLength,
DvdReadContract::Describe(failure));
}
@@ -962,11 +961,11 @@ extern "C" int32_t DVD__ReadAbsAsyncPrio_HLE_801628cc(uint32_t cmdBlockPtr,
requestedLength,
"absolute DVD read offset is not mapped to a host file");
} else if (requestedLength != 0 && readInfo.readLength != requestedLength) {
bytesRead = DvdReadFatal(cmdBlockPtr, readInfo.entry->hostPath,
bytesRead = DvdReadFatal(cmdBlockPtr, HostPathText(readInfo.entry->hostPath),
readInfo.fileOffset, requestedLength,
"requested range extends beyond the indexed DVD file");
} else if (requestedLength != 0 && !Memory::Contains(bufferPtr, requestedLength)) {
bytesRead = DvdReadFatal(cmdBlockPtr, readInfo.entry->hostPath,
bytesRead = DvdReadFatal(cmdBlockPtr, HostPathText(readInfo.entry->hostPath),
readInfo.fileOffset, requestedLength,
"DVD read destination is outside guest memory");
} else {
@@ -977,7 +976,7 @@ extern "C" int32_t DVD__ReadAbsAsyncPrio_HLE_801628cc(uint32_t cmdBlockPtr,
readInfo.readLength,
tempBuf,
failure)) {
bytesRead = DvdReadFatal(cmdBlockPtr, readInfo.entry->hostPath,
bytesRead = DvdReadFatal(cmdBlockPtr, HostPathText(readInfo.entry->hostPath),
readInfo.fileOffset, readInfo.readLength,
DvdReadContract::Describe(failure));
} else {
@@ -1076,12 +1075,12 @@ extern "C" int32_t DVDLowRead_80166330(uint32_t buffer, uint32_t length, uint32_
return finish(false);
}
if (readInfo.readLength != length) {
ReportDvdReadError(readInfo.entry->hostPath, readInfo.fileOffset, length,
ReportDvdReadError(HostPathText(readInfo.entry->hostPath), readInfo.fileOffset, length,
"requested range extends beyond the indexed DVD file");
return finish(false);
}
if (!Memory::Contains(buffer, length)) {
ReportDvdReadError(readInfo.entry->hostPath, readInfo.fileOffset, length,
ReportDvdReadError(HostPathText(readInfo.entry->hostPath), readInfo.fileOffset, length,
"DVD read destination is outside guest memory");
return finish(false);
}
@@ -1093,7 +1092,7 @@ extern "C" int32_t DVDLowRead_80166330(uint32_t buffer, uint32_t length, uint32_
readInfo.readLength,
tempBuf,
failure)) {
ReportDvdReadError(readInfo.entry->hostPath, readInfo.fileOffset,
ReportDvdReadError(HostPathText(readInfo.entry->hostPath), readInfo.fileOffset,
readInfo.readLength, DvdReadContract::Describe(failure));
return finish(false);
}
+22 -27
View File
@@ -91,7 +91,7 @@ extern "C" int32_t NANDOpen_HLE(uint32_t pathPtr, uint32_t fileInfoPtr, uint32_t
return NAND_RESULT_INVALID;
}
std::string hostPath = TranslateNandPath(path);
const std::filesystem::path hostPath = TranslateNandPath(path);
// 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
@@ -101,23 +101,23 @@ extern "C" int32_t NANDOpen_HLE(uint32_t pathPtr, uint32_t fileInfoPtr, uint32_t
// Another live handle already refers to this file. A shadow would hide the
// writes from that handle, so stay in place for this open.
LogNandWarning("NANDOpen", "WARNING: '%s' already has a live handle, writing in place",
hostPath.c_str());
HostPathText(hostPath).c_str());
} else {
const std::string tempPath = SafeTempPathFor(hostPath);
const std::filesystem::path tempPath = SafeTempPathFor(hostPath);
if (DiscardStaleSafeTemp(tempPath)) {
std::error_code ec;
std::filesystem::copy_file(hostPath, tempPath,
std::filesystem::copy_options::overwrite_existing, ec);
if (ec) {
LogNandWarning("NANDOpen", "WARNING: could not seed shadow '%s' (%s), writing in place",
tempPath.c_str(), ec.message().c_str());
std::remove(tempPath.c_str());
HostPathText(tempPath).c_str(), ec.message().c_str());
NandRemove(tempPath);
} else {
FILE* shadow = std::fopen(tempPath.c_str(), "r+b");
FILE* shadow = NandFopen(tempPath, "r+b");
if (!shadow) {
LogNandWarning("NANDOpen", "WARNING: could not open shadow '%s', writing in place",
tempPath.c_str());
std::remove(tempPath.c_str());
HostPathText(tempPath).c_str());
NandRemove(tempPath);
} else {
const int32_t shadowFd = AllocateFd(tempPath, shadow, static_cast<int32_t>(mode));
{
@@ -141,20 +141,20 @@ extern "C" int32_t NANDOpen_HLE(uint32_t pathPtr, uint32_t fileInfoPtr, uint32_t
else if (mode == 2) fopenMode = "r+b";
else if (mode == 3) fopenMode = "r+b";
FILE* file = std::fopen(hostPath.c_str(), fopenMode);
FILE* file = NandFopen(hostPath, fopenMode);
if (!file && mode >= 2) {
// Try creating for write modes
file = std::fopen(hostPath.c_str(), "w+b");
file = NandFopen(hostPath, "w+b");
}
// Create parent directories and retry
if (!file && CreateParentDirectories(hostPath)) {
file = std::fopen(hostPath.c_str(), mode >= 2 ? "w+b" : "rb");
file = NandFopen(hostPath, mode >= 2 ? "w+b" : "rb");
}
if (!file) {
if (IsFaceLibResourcePath(path) && SeedFaceLibResource(hostPath)) {
file = std::fopen(hostPath.c_str(), fopenMode);
file = NandFopen(hostPath, fopenMode);
}
if (!file) {
LogNandError("NANDOpen", "FAILED to open");
@@ -282,7 +282,7 @@ extern "C" int32_t NANDCreate_HLE(uint32_t pathPtr, uint32_t perm, uint32_t attr
return NAND_RESULT_INVALID;
}
std::string hostPath = TranslateNandPath(path);
const std::filesystem::path hostPath = TranslateNandPath(path);
CreateParentDirectories(hostPath);
// Check if file already exists
@@ -291,7 +291,7 @@ extern "C" int32_t NANDCreate_HLE(uint32_t pathPtr, uint32_t perm, uint32_t attr
}
// Create empty file
FILE* f = std::fopen(hostPath.c_str(), "wb");
FILE* f = NandFopen(hostPath, "wb");
if (!f) {
return NAND_RESULT_UNKNOWN;
}
@@ -307,13 +307,13 @@ extern "C" int32_t NANDDelete_HLE(uint32_t pathPtr) {
return NAND_RESULT_INVALID;
}
std::string hostPath = TranslateNandPath(path);
const std::filesystem::path hostPath = TranslateNandPath(path);
if (!PathExists(hostPath)) {
return NAND_RESULT_NOEXISTS;
}
if (std::remove(hostPath.c_str()) == 0) {
if (NandRemove(hostPath)) {
return NAND_RESULT_OK;
}
@@ -327,7 +327,7 @@ extern "C" int32_t NANDCreateDir_HLE(uint32_t pathPtr, uint32_t perm, uint32_t a
return NAND_RESULT_INVALID;
}
std::string hostPath = TranslateNandPath(path);
const std::filesystem::path hostPath = TranslateNandPath(path);
if (PathExists(hostPath)) {
if (IsDirectory(hostPath)) {
@@ -337,11 +337,6 @@ extern "C" int32_t NANDCreateDir_HLE(uint32_t pathPtr, uint32_t perm, uint32_t a
}
if (CreateDirectoryPath(hostPath)) {
#ifdef _WIN32
_mkdir(hostPath.c_str());
#else
mkdir(hostPath.c_str(), 0755);
#endif
return NAND_RESULT_OK;
}
@@ -369,13 +364,13 @@ extern "C" int32_t NANDMove_HLE(uint32_t srcPathPtr, uint32_t dstPathPtr) {
// filename (for example /tmp/banner.bin -> <title home>/banner.bin).
const std::filesystem::path dstHost = dstDirectoryHost / srcName;
if (!PathExists(srcHost.string())) {
if (!PathExists(srcHost)) {
return NAND_RESULT_NOEXISTS;
}
if (!IsDirectory(dstDirectoryHost.string())) {
if (!IsDirectory(dstDirectoryHost)) {
return NAND_RESULT_NOEXISTS;
}
if (PathExists(dstHost.string())) {
if (PathExists(dstHost)) {
return NAND_RESULT_EXISTS;
}
@@ -396,7 +391,7 @@ extern "C" int32_t NANDGetStatus_HLE(uint32_t pathPtr, uint32_t outStatusPtr) {
return NAND_RESULT_INVALID;
}
std::string hostPath = TranslateNandPath(path);
const std::filesystem::path hostPath = TranslateNandPath(path);
if (!PathExists(hostPath)) {
return NAND_RESULT_NOEXISTS;
@@ -417,7 +412,7 @@ extern "C" int32_t NANDGetType_HLE(uint32_t pathPtr, uint32_t outTypePtr) {
return NAND_RESULT_INVALID;
}
std::string hostPath = TranslateNandPath(path);
const std::filesystem::path hostPath = TranslateNandPath(path);
if (!PathExists(hostPath)) {
return NAND_RESULT_NOEXISTS;
+40 -32
View File
@@ -234,8 +234,10 @@ PPC_NATIVE_OVERRIDE(8019E7B4, NANDPrivateGetTypeAsync_HLE, int32_t,
static const char kNandSafeTempSuffix[] = ".nandsafe.tmp";
std::string SafeTempPathFor(const std::string& hostPath) {
return hostPath + kNandSafeTempSuffix;
std::filesystem::path SafeTempPathFor(const std::filesystem::path& hostPath) {
std::filesystem::path tempPath = hostPath;
tempPath += kNandSafeTempSuffix;
return tempPath;
}
// Push the CRT buffer out and then force the OS to put it on the platter, so the data is
@@ -264,24 +266,25 @@ static bool FlushFileToDisk(FILE* file) {
// Replace `targetPath` with `tempPath` in one step. Either the old or the new contents
// survive a crash; there is no window where the target is truncated or partial.
static bool AtomicReplaceHostFile(const char* who, const std::string& tempPath,
const std::string& targetPath) {
static bool AtomicReplaceHostFile(const char* who, const std::filesystem::path& tempPath,
const std::filesystem::path& targetPath) {
#ifdef _WIN32
if (MoveFileExA(tempPath.c_str(), targetPath.c_str(),
if (MoveFileExW(tempPath.c_str(), targetPath.c_str(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) {
return true;
}
LogNandError(who, "ERROR: MoveFileEx('%s' -> '%s') failed (err=%lu)",
tempPath.c_str(), targetPath.c_str(), static_cast<unsigned long>(GetLastError()));
HostPathText(tempPath).c_str(), HostPathText(targetPath).c_str(),
static_cast<unsigned long>(GetLastError()));
return false;
#else
if (std::rename(tempPath.c_str(), targetPath.c_str()) != 0) {
if (!NandRename(tempPath, targetPath)) {
LogNandError(who, "ERROR: rename('%s' -> '%s') failed",
tempPath.c_str(), targetPath.c_str());
HostPathText(tempPath).c_str(), HostPathText(targetPath).c_str());
return false;
}
// Durably record the directory entry so the rename itself survives a crash.
const std::string directory = std::filesystem::path(targetPath).parent_path().string();
const std::string directory = targetPath.parent_path().string();
const int dirFd = open(directory.c_str(), O_RDONLY);
if (dirFd >= 0) {
fsync(dirFd);
@@ -293,23 +296,24 @@ static bool AtomicReplaceHostFile(const char* who, const std::string& tempPath,
// Remove a scratch file left behind by a previous run that died between safe open and
// safe close. Its contents are worthless: the original was never replaced.
bool DiscardStaleSafeTemp(const std::string& tempPath) {
bool DiscardStaleSafeTemp(const std::filesystem::path& tempPath) {
if (!PathExists(tempPath)) {
return true;
}
LogNandWarning("nand-shadow", "WARNING: discarding stale scratch file '%s' from a previous run",
tempPath.c_str());
if (std::remove(tempPath.c_str()) == 0) {
HostPathText(tempPath).c_str());
if (NandRemove(tempPath)) {
return true;
}
LogNandError("nand-shadow", "FAILED to remove stale scratch file '%s'", tempPath.c_str());
LogNandError("nand-shadow", "FAILED to remove stale scratch file '%s'",
HostPathText(tempPath).c_str());
return false;
}
// True when any live handle already refers to `hostPath`, either directly or as the
// commit target of a shadow. Used to keep shadow writes from hiding data behind a second
// handle on the same file.
bool IsHostPathOpen(const std::string& hostPath) {
bool IsHostPathOpen(const std::filesystem::path& hostPath) {
std::lock_guard<std::mutex> lock(g_fdMutex);
for (const auto& entry : g_fileHandles) {
if (entry.second.path == hostPath || entry.second.safeCommitPath == hostPath) {
@@ -324,8 +328,8 @@ bool IsHostPathOpen(const std::string& hostPath) {
// dropped and the original is left exactly as it was, and the error is returned so the
// guest's close call fails instead of silently reporting success.
int32_t CommitAndCloseFd(const char* who, int32_t fd, bool missingFdIsError) {
std::string tempPath;
std::string commitPath;
std::filesystem::path tempPath;
std::filesystem::path commitPath;
FILE* file = nullptr;
int32_t mode = 0;
@@ -358,7 +362,7 @@ int32_t CommitAndCloseFd(const char* who, int32_t fd, bool missingFdIsError) {
if (!needsCommit) {
if (!flushed) {
LogNandError(who, "ERROR: flush of '%s' failed", tempPath.c_str());
LogNandError(who, "ERROR: flush of '%s' failed", HostPathText(tempPath).c_str());
return NAND_RESULT_UNKNOWN;
}
return NAND_RESULT_OK;
@@ -366,13 +370,13 @@ int32_t CommitAndCloseFd(const char* who, int32_t fd, bool missingFdIsError) {
if (!flushed) {
LogNandError(who, "ERROR: flush of '%s' failed, discarding it and leaving '%s' untouched",
tempPath.c_str(), commitPath.c_str());
std::remove(tempPath.c_str());
HostPathText(tempPath).c_str(), HostPathText(commitPath).c_str());
NandRemove(tempPath);
return NAND_RESULT_UNKNOWN;
}
if (!AtomicReplaceHostFile(who, tempPath, commitPath)) {
std::remove(tempPath.c_str());
NandRemove(tempPath);
return NAND_RESULT_UNKNOWN;
}
@@ -394,7 +398,7 @@ extern "C" int32_t NANDSafeOpen_HLE(uint32_t pathPtr, uint32_t fileInfoPtr, uint
return NAND_RESULT_INVALID;
}
const std::string hostPath = TranslateNandPath(path);
const std::filesystem::path hostPath = TranslateNandPath(path);
if (hostPath.empty()) {
LogNandError("NANDSafeOpen", "FAILED to translate path '%s'", path);
return NAND_RESULT_INVALID;
@@ -407,12 +411,13 @@ extern "C" int32_t NANDSafeOpen_HLE(uint32_t pathPtr, uint32_t fileInfoPtr, uint
if (mode == 1) {
// Read-only safe open reads the original in place; the library builds no scratch
// copy for this case.
FILE* file = std::fopen(hostPath.c_str(), "rb");
FILE* file = NandFopen(hostPath, "rb");
if (!file && IsFaceLibResourcePath(path) && SeedFaceLibResource(hostPath)) {
file = std::fopen(hostPath.c_str(), "rb");
file = NandFopen(hostPath, "rb");
}
if (!file) {
LogNandError("NANDSafeOpen", "FAILED to open '%s' for reading", hostPath.c_str());
LogNandError("NANDSafeOpen", "FAILED to open '%s' for reading",
HostPathText(hostPath).c_str());
return NAND_RESULT_NOEXISTS;
}
@@ -425,15 +430,16 @@ extern "C" int32_t NANDSafeOpen_HLE(uint32_t pathPtr, uint32_t fileInfoPtr, uint
// Write modes. The library queries the attributes of the original first, so a safe
// open of a file that does not exist fails instead of creating one.
if (!PathExists(hostPath)) {
LogNandError("NANDSafeOpen", "FAILED: '%s' does not exist, safe open never creates it", hostPath.c_str());
LogNandError("NANDSafeOpen", "FAILED: '%s' does not exist, safe open never creates it",
HostPathText(hostPath).c_str());
return NAND_RESULT_NOEXISTS;
}
if (IsDirectory(hostPath)) {
LogNandError("NANDSafeOpen", "FAILED: '%s' is a directory", hostPath.c_str());
LogNandError("NANDSafeOpen", "FAILED: '%s' is a directory", HostPathText(hostPath).c_str());
return NAND_RESULT_INVALID;
}
const std::string tempPath = SafeTempPathFor(hostPath);
const std::filesystem::path tempPath = SafeTempPathFor(hostPath);
if (!DiscardStaleSafeTemp(tempPath)) {
return NAND_RESULT_ACCESS;
}
@@ -445,15 +451,17 @@ extern "C" int32_t NANDSafeOpen_HLE(uint32_t pathPtr, uint32_t fileInfoPtr, uint
std::filesystem::copy_options::overwrite_existing, ec);
if (ec) {
LogNandError("NANDSafeOpen", "FAILED to seed scratch file '%s' from '%s': %s",
tempPath.c_str(), hostPath.c_str(), ec.message().c_str());
std::remove(tempPath.c_str());
HostPathText(tempPath).c_str(), HostPathText(hostPath).c_str(),
ec.message().c_str());
NandRemove(tempPath);
return NAND_RESULT_UNKNOWN;
}
FILE* file = std::fopen(tempPath.c_str(), "r+b");
FILE* file = NandFopen(tempPath, "r+b");
if (!file) {
LogNandError("NANDSafeOpen", "FAILED to open scratch file '%s'", tempPath.c_str());
std::remove(tempPath.c_str());
LogNandError("NANDSafeOpen", "FAILED to open scratch file '%s'",
HostPathText(tempPath).c_str());
NandRemove(tempPath);
return NAND_RESULT_UNKNOWN;
}
+69 -57
View File
@@ -12,7 +12,7 @@
// ============================================================================
// Base path for the host Wii NAND directory (resolved at runtime).
static std::string g_dolphinWiiBase;
static std::filesystem::path g_dolphinWiiBase;
static std::once_flag g_dolphinWiiBaseOnce;
// ============================================================================
@@ -47,7 +47,7 @@ std::map<int32_t, FileHandle> g_fileHandles;
static int32_t g_nextFd = 100; // Start at 100 to avoid confusion with stdio fds
std::mutex g_fdMutex;
int32_t AllocateFd(const std::string& path, FILE* file, int32_t mode) {
int32_t AllocateFd(const std::filesystem::path& path, FILE* file, int32_t mode) {
std::lock_guard<std::mutex> lock(g_fdMutex);
int32_t fd = g_nextFd++;
g_fileHandles[fd] = {file, path, mode, 0};
@@ -88,29 +88,50 @@ std::string CurrentNandDataDir() {
return path;
}
const std::string& GetNandBasePath() {
const std::filesystem::path& GetNandBasePath() {
std::call_once(g_dolphinWiiBaseOnce, []() {
g_dolphinWiiBase = RuntimeNandPath::DiscoverNandRootString();
g_dolphinWiiBase = RuntimeNandPath::DiscoverNandRootPath();
});
return g_dolphinWiiBase;
}
static std::string BuildHostNandPath(std::string wiiPathStr) {
std::string hostPath = GetNandBasePath();
for (char& c : wiiPathStr) {
if (c == '/') {
#ifdef _WIN32
c = '\\';
#endif
}
}
std::string HostPathText(const std::filesystem::path& path) {
return RuntimeConfigFile::PathToUtf8(path);
}
if (!wiiPathStr.empty() && (wiiPathStr[0] == '\\' || wiiPathStr[0] == '/')) {
hostPath += wiiPathStr;
} else {
hostPath += "\\";
hostPath += wiiPathStr;
FILE* NandFopen(const std::filesystem::path& path, const char* mode) {
#ifdef _WIN32
const std::wstring wideMode(mode, mode + std::strlen(mode));
return _wfopen(path.c_str(), wideMode.c_str());
#else
return std::fopen(path.c_str(), mode);
#endif
}
bool NandRemove(const std::filesystem::path& path) {
std::error_code ec;
return std::filesystem::remove(path, ec) && !ec;
}
bool NandRename(const std::filesystem::path& from, const std::filesystem::path& to) {
std::error_code ec;
std::filesystem::rename(from, to, ec);
return !ec;
}
// Guest paths are absolute and already lexically resolved against the NAND root, so
// they are appended as relative components instead of replacing the root.
static std::filesystem::path BuildHostNandPath(const std::string& wiiPathStr) {
std::filesystem::path hostPath = GetNandBasePath();
size_t cursor = 0;
while (cursor < wiiPathStr.size()) {
const size_t slash = wiiPathStr.find('/', cursor);
const size_t end = slash == std::string::npos ? wiiPathStr.size() : slash;
if (end != cursor) {
hostPath /= wiiPathStr.substr(cursor, end - cursor);
}
cursor = end + 1;
}
return hostPath;
}
@@ -169,7 +190,7 @@ static std::string NormalizeAbsoluteWiiPath(const char* wiiPath) {
struct RiivolutionSaveRedirect {
bool enabled = false;
bool clone = false;
std::string hostDirectory;
std::filesystem::path hostDirectory;
};
static std::once_flag g_riivolutionSaveRedirectOnce;
@@ -187,7 +208,7 @@ static const RiivolutionSaveRedirect& GetRiivolutionSaveRedirect() {
}
g_riivolutionSaveRedirect.enabled = true;
g_riivolutionSaveRedirect.clone = redirect->clone;
g_riivolutionSaveRedirect.hostDirectory = redirect->hostDirectory.string();
g_riivolutionSaveRedirect.hostDirectory = redirect->hostDirectory;
// Riivolution creates the redirect folder if it does not exist.
CreateDirectoryPath(g_riivolutionSaveRedirect.hostDirectory);
});
@@ -195,8 +216,8 @@ static const RiivolutionSaveRedirect& GetRiivolutionSaveRedirect() {
return g_riivolutionSaveRedirect;
}
static void CloneRiivolutionSaveIfNeeded(const std::string& sourceHostPath,
const std::string& redirectedHostPath,
static void CloneRiivolutionSaveIfNeeded(const std::filesystem::path& sourceHostPath,
const std::filesystem::path& redirectedHostPath,
const RiivolutionSaveRedirect& redirect) {
if (!redirect.clone || PathExists(redirectedHostPath) || !PathExists(sourceHostPath)) {
return;
@@ -209,11 +230,13 @@ static void CloneRiivolutionSaveIfNeeded(const std::string& sourceHostPath,
std::filesystem::copy_options::skip_existing, ec);
if (ec) {
LogNandWarning("RiivolutionSave", "WARNING: failed to clone '%s' -> '%s': %s",
sourceHostPath.c_str(), redirectedHostPath.c_str(), ec.message().c_str());
HostPathText(sourceHostPath).c_str(),
HostPathText(redirectedHostPath).c_str(), ec.message().c_str());
}
}
static bool ResolveRiivolutionSaveHostPath(const std::string& absoluteWiiPath, std::string& outHostPath) {
static bool ResolveRiivolutionSaveHostPath(const std::string& absoluteWiiPath,
std::filesystem::path& outHostPath) {
const RiivolutionSaveRedirect& redirect = GetRiivolutionSaveRedirect();
if (!redirect.enabled) {
return false;
@@ -232,23 +255,23 @@ static bool ResolveRiivolutionSaveHostPath(const std::string& absoluteWiiPath, s
relative = absoluteWiiPath.substr(dataDir.size() + 1);
}
std::filesystem::path redirected(redirect.hostDirectory);
std::filesystem::path redirected = redirect.hostDirectory;
if (!relative.empty()) {
redirected /= std::filesystem::path(relative);
}
outHostPath = redirected.string();
outHostPath = redirected;
CloneRiivolutionSaveIfNeeded(BuildHostNandPath(absoluteWiiPath), outHostPath, redirect);
return true;
}
std::string TranslateNandPath(const char* wiiPath) {
std::filesystem::path TranslateNandPath(const char* wiiPath) {
std::string wiiPathStr = NormalizeAbsoluteWiiPath(wiiPath);
if (wiiPathStr.empty()) {
return "";
return {};
}
std::string redirectedHostPath;
std::filesystem::path redirectedHostPath;
if (ResolveRiivolutionSaveHostPath(wiiPathStr, redirectedHostPath)) {
return redirectedHostPath;
}
@@ -266,7 +289,8 @@ struct U8Node {
uint32_t size;
};
static bool ExtractFromU8(const std::string& archivePath, const char* targetName, std::vector<uint8_t>& outData) {
static bool ExtractFromU8(const std::filesystem::path& archivePath, const char* targetName,
std::vector<uint8_t>& outData) {
std::ifstream file(archivePath, std::ios::binary);
if (!file) {
return false;
@@ -352,13 +376,13 @@ static bool ExtractFromU8(const std::string& archivePath, const char* targetName
// as an argument and the path predicate below hard-codes the same name.
static constexpr char kFaceLibResourceName[] = "RFL_Res.dat";
bool SeedFaceLibResource(const std::string& hostPath) {
bool SeedFaceLibResource(const std::filesystem::path& hostPath) {
std::vector<uint8_t> payload;
if (const auto dvdRoot = RuntimeConfigFile::ResolvedDvdRoot(); !dvdRoot.empty()) {
const auto arcPath = dvdRoot / "files" / "contents" / "RFLRes01.arc";
std::error_code ec;
if (std::filesystem::exists(arcPath, ec)) {
ExtractFromU8(arcPath.string(), kFaceLibResourceName, payload);
ExtractFromU8(arcPath, kFaceLibResourceName, payload);
}
}
@@ -371,12 +395,12 @@ bool SeedFaceLibResource(const std::string& hostPath) {
std::ofstream out(hostPath, std::ios::binary);
if (!out) {
LogNandError("FaceLibSeed", "Failed to create %s", hostPath.c_str());
LogNandError("FaceLibSeed", "Failed to create %s", HostPathText(hostPath).c_str());
return false;
}
out.write(reinterpret_cast<const char*>(payload.data()), static_cast<std::streamsize>(payload.size()));
if (!out) {
LogNandError("FaceLibSeed", "Failed to write %s", hostPath.c_str());
LogNandError("FaceLibSeed", "Failed to write %s", HostPathText(hostPath).c_str());
return false;
}
@@ -388,45 +412,33 @@ bool IsFaceLibResourcePath(const char* path) {
}
// Create directories recursively
bool CreateDirectoryPath(const std::string& path) {
bool CreateDirectoryPath(const std::filesystem::path& path) {
if (path.empty()) {
return true;
}
std::error_code ec;
std::filesystem::create_directories(path, ec);
return !ec || std::filesystem::is_directory(path);
return !ec || std::filesystem::is_directory(path, ec);
}
// Check if a path exists
bool PathExists(const std::string& path) {
#ifdef _WIN32
return GetFileAttributesA(path.c_str()) != INVALID_FILE_ATTRIBUTES;
#else
return access(path.c_str(), F_OK) == 0;
#endif
bool PathExists(const std::filesystem::path& path) {
std::error_code ec;
return std::filesystem::exists(path, ec) && !ec;
}
// Check if path is a directory
bool IsDirectory(const std::string& path) {
#ifdef _WIN32
DWORD attrs = GetFileAttributesA(path.c_str());
return attrs != INVALID_FILE_ATTRIBUTES && (attrs & FILE_ATTRIBUTE_DIRECTORY);
#else
struct stat st;
return stat(path.c_str(), &st) == 0 && S_ISDIR(st.st_mode);
#endif
bool IsDirectory(const std::filesystem::path& path) {
std::error_code ec;
return std::filesystem::is_directory(path, ec) && !ec;
}
bool CreateParentDirectories(const std::string& path) {
size_t lastSlash = path.rfind('\\');
if (lastSlash == std::string::npos) {
lastSlash = path.rfind('/');
}
if (lastSlash == std::string::npos) {
bool CreateParentDirectories(const std::filesystem::path& path) {
if (!path.has_parent_path()) {
return false;
}
CreateDirectoryPath(path.substr(0, lastSlash));
CreateDirectoryPath(path.parent_path());
return true;
}
+22 -18
View File
@@ -29,15 +29,10 @@
#include <vector>
#include <filesystem>
#include <string>
#include <sys/stat.h>
#ifdef _WIN32
#include <direct.h>
#include <io.h>
#include <windows.h>
#define mkdir(path, mode) _mkdir(path)
#define access _access
#define F_OK 0
#else
#include <fcntl.h>
#include <sys/types.h>
@@ -67,19 +62,19 @@ void LogNandWarning(const char* func, const char* fmt, ...);
struct FileHandle {
FILE* file = nullptr;
std::string path;
std::filesystem::path path;
int32_t mode = 0; // 1=read, 2=write, 3=read/write
uint32_t position = 0;
// Non-empty only for write-mode NANDSafeOpen handles. `path` then points at the
// sibling scratch file the guest is writing into, and this is the original file it
// atomically replaces on NANDSafeClose.
std::string safeCommitPath;
std::filesystem::path safeCommitPath;
};
extern std::map<int32_t, FileHandle> g_fileHandles;
extern std::mutex g_fdMutex;
int32_t AllocateFd(const std::string& path, FILE* file, int32_t mode);
int32_t AllocateFd(const std::filesystem::path& path, FILE* file, int32_t mode);
FileHandle* GetHandle(int32_t fd);
void CloseFd(int32_t fd);
@@ -89,18 +84,27 @@ void CloseFd(int32_t fd);
uint32_t CurrentMkwTitleIdLo();
std::string CurrentNandDataDir();
const std::string& GetNandBasePath();
std::string TranslateNandPath(const char* wiiPath);
const std::filesystem::path& GetNandBasePath();
std::filesystem::path TranslateNandPath(const char* wiiPath);
bool CreateDirectoryPath(const std::string& path);
bool PathExists(const std::string& path);
bool IsDirectory(const std::string& path);
bool CreateDirectoryPath(const std::filesystem::path& path);
bool PathExists(const std::filesystem::path& path);
bool IsDirectory(const std::filesystem::path& path);
// Create the directory that contains `path`. False when `path` has no directory
// component, i.e. there was nothing to create.
bool CreateParentDirectories(const std::string& path);
bool CreateParentDirectories(const std::filesystem::path& path);
bool SeedFaceLibResource(const std::string& hostPath);
// Host paths keep their native encoding end to end; these are the only places a NAND
// path is narrowed, and they narrow to UTF-8 for display.
std::string HostPathText(const std::filesystem::path& path);
// fopen takes an ANSI-codepage name on Windows, which cannot express every path.
FILE* NandFopen(const std::filesystem::path& path, const char* mode);
bool NandRemove(const std::filesystem::path& path);
bool NandRename(const std::filesystem::path& from, const std::filesystem::path& to);
bool SeedFaceLibResource(const std::filesystem::path& hostPath);
bool IsFaceLibResourcePath(const char* path);
// ============================================================================
@@ -178,9 +182,9 @@ enum ISFSResult {
int32_t ISFS_OpenLib_Initialize(CpuContext* ctx);
// Shadow-write machinery, defined with the NANDSafeOpen/NANDSafeClose section below.
std::string SafeTempPathFor(const std::string& hostPath);
bool DiscardStaleSafeTemp(const std::string& tempPath);
bool IsHostPathOpen(const std::string& hostPath);
std::filesystem::path SafeTempPathFor(const std::filesystem::path& hostPath);
bool DiscardStaleSafeTemp(const std::filesystem::path& tempPath);
bool IsHostPathOpen(const std::filesystem::path& hostPath);
int32_t CommitAndCloseFd(const char* who, int32_t fd, bool missingFdIsError);
// Synchronous NAND library entry points (defined in nand_api.cpp); the async
+98 -30
View File
@@ -329,7 +329,7 @@ extern "C" int32_t NAND_IOS_Open_HLE(uint32_t pathPtr, uint32_t mode) {
}
// It's a NAND file path
std::string hostPath = TranslateNandPath(path);
const std::filesystem::path hostPath = TranslateNandPath(path);
// Seed FaceLib resources before the existence check so every open mode can
// still find them on a fresh managed NAND.
@@ -346,16 +346,16 @@ extern "C" int32_t NAND_IOS_Open_HLE(uint32_t pathPtr, uint32_t mode) {
if (mode == 2 || mode == 3) {
if (!PathExists(hostPath)) {
LogNandWarning("IOS_Open", "'%s' does not exist; open mode %u never creates it",
hostPath.c_str(), mode);
HostPathText(hostPath).c_str(), mode);
return ISFS_ENOENT;
}
fopenMode = "r+b"; // Write-only opens still need read for seeks
}
FILE* file = std::fopen(hostPath.c_str(), fopenMode);
FILE* file = NandFopen(hostPath, fopenMode);
if (!file) {
LogNandError("IOS_Open", "FAILED to open '%s'", hostPath.c_str());
LogNandError("IOS_Open", "FAILED to open '%s'", HostPathText(hostPath).c_str());
return ISFS_ENOENT;
}
@@ -516,14 +516,9 @@ extern "C" int32_t NAND_IOS_Ioctl_HLE(
return ISFS_EINVAL;
}
const char* path = (const char*)Memory::GetPointer(inBufPtr + 6);
std::string hostPath = TranslateNandPath(path);
const std::filesystem::path hostPath = TranslateNandPath(path);
if (CreateDirectoryPath(hostPath)) {
#ifdef _WIN32
_mkdir(hostPath.c_str());
#else
mkdir(hostPath.c_str(), 0755);
#endif
return ISFS_OK;
}
return ISFS_EIO;
@@ -534,16 +529,11 @@ extern "C" int32_t NAND_IOS_Ioctl_HLE(
return ISFS_EINVAL;
}
const char* path = (const char*)Memory::GetPointer(inBufPtr);
std::string hostPath = TranslateNandPath(path);
const std::filesystem::path hostPath = TranslateNandPath(path);
if (IsDirectory(hostPath)) {
#ifdef _WIN32
if (RemoveDirectoryA(hostPath.c_str())) return ISFS_OK;
#else
if (rmdir(hostPath.c_str()) == 0) return ISFS_OK;
#endif
} else {
if (std::remove(hostPath.c_str()) == 0) return ISFS_OK;
// fs::remove refuses a non-empty directory, matching rmdir.
if (NandRemove(hostPath)) {
return ISFS_OK;
}
return ISFS_ENOENT;
}
@@ -553,7 +543,7 @@ extern "C" int32_t NAND_IOS_Ioctl_HLE(
return ISFS_EINVAL;
}
const char* path = (const char*)Memory::GetPointer(inBufPtr);
std::string hostPath = TranslateNandPath(path);
const std::filesystem::path hostPath = TranslateNandPath(path);
if (!PathExists(hostPath)) {
return ISFS_ENOENT;
@@ -582,11 +572,11 @@ extern "C" int32_t NAND_IOS_Ioctl_HLE(
return ISFS_EINVAL;
}
const char* path = (const char*)Memory::GetPointer(inBufPtr + 6);
std::string hostPath = TranslateNandPath(path);
const std::filesystem::path hostPath = TranslateNandPath(path);
CreateParentDirectories(hostPath);
// Create empty file
FILE* f = std::fopen(hostPath.c_str(), "wb");
FILE* f = NandFopen(hostPath, "wb");
if (f) {
std::fclose(f);
return ISFS_OK;
@@ -606,10 +596,10 @@ extern "C" int32_t NAND_IOS_Ioctl_HLE(
}
const char* srcPath = (const char*)Memory::GetPointer(inBufPtr);
const char* dstPath = (const char*)Memory::GetPointer(inBufPtr + 0x40);
std::string srcHost = TranslateNandPath(srcPath);
std::string dstHost = TranslateNandPath(dstPath);
const std::filesystem::path srcHost = TranslateNandPath(srcPath);
const std::filesystem::path dstHost = TranslateNandPath(dstPath);
if (std::rename(srcHost.c_str(), dstHost.c_str()) == 0) {
if (NandRename(srcHost, dstHost)) {
return ISFS_OK;
}
return ISFS_EIO;
@@ -805,11 +795,11 @@ int32_t ISFS_OpenLib_Initialize(CpuContext* ctx) {
g_isfsInitialized = true;
// Create the title data directory if it doesn't exist
char titlePath[256];
const std::string& base = GetNandBasePath();
std::snprintf(titlePath, sizeof(titlePath), "%s\\title\\%08x\\%08x\\data",
base.c_str(), kNandTitleIdHi, CurrentMkwTitleIdLo());
CreateDirectoryPath(titlePath);
char titleId[32];
std::snprintf(titleId, sizeof(titleId), "%08x", kNandTitleIdHi);
char gameId[32];
std::snprintf(gameId, sizeof(gameId), "%08x", CurrentMkwTitleIdLo());
CreateDirectoryPath(GetNandBasePath() / "title" / titleId / gameId / "data");
if (!ctx) {
return ISFS_OK;
@@ -899,6 +889,74 @@ REGISTER_NATIVE_FUNCTION_AS(0x80169BCC, ISFS_OpenLib_HLE_80169BCC, "ISFS_OpenLib
// IOS_Ioctlv HLE - Vector Ioctl for complex ISFS operations
// ============================================================================
static int32_t HandleIsfsReadDir(uint32_t numIn, uint32_t numOut, uint32_t vectorPtr) {
const bool countOnly = (numIn == 1 && numOut == 1);
if (!countOnly && !(numIn == 2 && numOut == 2)) {
LogNandWarning("IOS_Ioctlv", "READDIR unsupported vector shape numIn=%u numOut=%u",
numIn, numOut);
return ISFS_EINVAL;
}
const IosVector pathVec = ReadIosVector(vectorPtr, 0);
const std::string wiiPath = ReadGuestCString(pathVec.address, 64);
if (wiiPath.empty()) {
return ISFS_EINVAL;
}
const std::filesystem::path hostPath = TranslateNandPath(wiiPath.c_str());
if (!IsDirectory(hostPath)) {
return ISFS_ENOENT;
}
// NAND names are at most 12 characters; longer host names cannot exist on
// a real NAND (this also hides *.nandsafe.tmp write shadows).
constexpr size_t kMaxNandNameLength = 12;
std::vector<std::string> names;
std::error_code ec;
for (const auto& entry : std::filesystem::directory_iterator(hostPath, ec)) {
std::string name = HostPathText(entry.path().filename());
if (name.empty() || name.size() > kMaxNandNameLength) {
continue;
}
names.push_back(std::move(name));
}
std::sort(names.begin(), names.end());
if (countOnly) {
const IosVector countOut = ReadIosVector(vectorPtr, 1);
if (countOut.size < 4 || !Memory::Contains(countOut.address, 4)) {
return ISFS_EINVAL;
}
Memory::Write32(countOut.address, static_cast<uint32_t>(names.size()));
return ISFS_OK;
}
const IosVector maxVec = ReadIosVector(vectorPtr, 1);
const IosVector namesOut = ReadIosVector(vectorPtr, 2);
const IosVector countOut = ReadIosVector(vectorPtr, 3);
if (maxVec.size < 4 || !Memory::Contains(maxVec.address, 4) ||
countOut.size < 4 || !Memory::Contains(countOut.address, 4) ||
!IsValidGuestRange(namesOut.address, namesOut.size)) {
return ISFS_EINVAL;
}
const uint32_t maxCount = Memory::Read32(maxVec.address);
constexpr uint32_t kEntryWindow = 13; // 12 chars + terminator
uint32_t cursor = 0;
uint32_t written = 0;
for (const std::string& name : names) {
if (written >= maxCount || cursor + kEntryWindow > namesOut.size) {
break;
}
uint8_t* out = Memory::GetPointer(namesOut.address + cursor, kEntryWindow);
std::memset(out, 0, kEntryWindow);
std::memcpy(out, name.data(), name.size());
cursor += static_cast<uint32_t>(name.size()) + 1;
++written;
}
Memory::Write32(countOut.address, written);
return ISFS_OK;
}
extern "C" int32_t NAND_IOS_Ioctlv_HLE(
uint32_t fd,
uint32_t cmd,
@@ -919,6 +977,16 @@ extern "C" int32_t NAND_IOS_Ioctlv_HLE(
return HandleDolphinIoctlv(cmd, numIn, numOut, vectorPtr);
}
if (fd == ISFS_DEV_FD) {
if (!vectorPtr || !Memory::Contains(vectorPtr, static_cast<size_t>(numIn + numOut) * 8u)) {
return ISFS_EINVAL;
}
if (cmd == ISFS_IOCTL_READDIR) {
return HandleIsfsReadDir(numIn, numOut, vectorPtr);
}
return ISFS_OK;
}
if (fd == ES_DEV_FD) {
if (!vectorPtr || !Memory::Contains(vectorPtr, static_cast<size_t>(numIn + numOut) * 8u)) {
return ISFS_EINVAL;
+30 -16
View File
@@ -74,8 +74,18 @@ std::string RiivoGameId() {
return id;
}
// Every narrow path string here is UTF-8, including the ones the XML halves of
// resolved paths are concatenated with.
using RuntimeConfigFile::PathFromUtf8;
using RuntimeConfigFile::PathToUtf8;
std::string RiivoGenericText(const fs::path& path) {
const std::u8string text = path.generic_u8string();
return std::string(text.begin(), text.end());
}
std::string RiivoComparablePath(const fs::path& path) {
std::string text = path.lexically_normal().generic_string();
std::string text = RiivoGenericText(path.lexically_normal());
#ifdef _WIN32
RuntimeHle::LowerInPlace(text);
#endif
@@ -86,6 +96,8 @@ void RiivoAddRoot(std::vector<RuntimeRiivolution::Overlay>& overlays, fs::path r
const char* source) {
std::error_code ec;
if (!fs::is_directory(root, ec)) {
RT_LOG(RT_TAG_RIIVOLUTION) << "rejected overlay root (" << (source ? source : "unknown")
<< "): " << PathToUtf8(root) << " is not a reachable directory" << std::endl;
return;
}
@@ -104,7 +116,7 @@ void RiivoAddRoot(std::vector<RuntimeRiivolution::Overlay>& overlays, fs::path r
}
RT_LOG(RT_TAG_RIIVOLUTION) << "overlay root (" << (source ? source : "unknown")
<< "): " << normalized.string() << std::endl;
<< "): " << PathToUtf8(normalized) << std::endl;
overlays.push_back({std::move(normalized), std::nullopt});
}
@@ -133,7 +145,7 @@ std::vector<RuntimeRiivolution::Overlay> RiivoDiscoverRoots() {
}
for (const auto& root : RecompMod::DvdOverlayRoots()) {
RiivoAddRoot(overlays, fs::path(root), "recomp mod manifest");
RiivoAddRoot(overlays, root, "recomp mod manifest");
}
return overlays;
@@ -154,7 +166,7 @@ std::optional<RiivoXmlSet> RiivoFindXmls(const fs::path& overlayRoot) {
// <sd>/RetroRewind6), so externals resolve against the root's parent.
const std::string& configured = RecompMod::RiivolutionXml();
if (!configured.empty()) {
const fs::path configuredXml = overlayRoot / fs::path(configured);
const fs::path configuredXml = overlayRoot / PathFromUtf8(configured);
if (fs::is_regular_file(configuredXml, ec)) {
return RiivoXmlSet{overlayRoot.parent_path(), {configuredXml}};
}
@@ -211,7 +223,7 @@ void RiivoCollectMappings(const RiivolutionContract::Patch& patch, const std::st
++set.skippedExternals;
continue;
}
const fs::path hostFile(*resolved);
const fs::path hostFile = PathFromUtf8(*resolved);
if (!fs::is_regular_file(hostFile, ec)) {
++set.skippedExternals;
continue;
@@ -227,7 +239,7 @@ void RiivoCollectMappings(const RiivolutionContract::Patch& patch, const std::st
++set.skippedExternals;
continue;
}
const fs::path hostFolder(*resolved);
const fs::path hostFolder = PathFromUtf8(*resolved);
if (!fs::is_directory(hostFolder, ec)) {
++set.skippedExternals;
continue;
@@ -246,7 +258,7 @@ std::optional<RuntimeRiivolution::PatchSet> RiivoLoadPatchSet(const fs::path& ov
}
const std::string gameId = RiivoGameId();
const std::string sdRootGeneric = xmlSet->sdRoot.generic_string();
const std::string sdRootGeneric = RiivoGenericText(xmlSet->sdRoot);
const auto manifestSelections = RiivoManifestSelections();
// Dolphin-compatible remembered choices; a recomp.yml pin overrides them.
@@ -261,19 +273,20 @@ std::optional<RuntimeRiivolution::PatchSet> RiivoLoadPatchSet(const fs::path& ov
for (const fs::path& xmlFile : xmlSet->xmlFiles) {
const auto text = RiivoReadFile(xmlFile);
if (!text) {
RT_LOG(RT_TAG_RIIVOLUTION) << "WARNING: cannot read " << xmlFile.string() << std::endl;
RT_LOG(RT_TAG_RIIVOLUTION) << "WARNING: cannot read " << PathToUtf8(xmlFile)
<< std::endl;
continue;
}
auto disc = RiivolutionContract::ParseString(*text);
if (!disc) {
RT_LOG(RT_TAG_RIIVOLUTION) << "WARNING: " << xmlFile.string()
RT_LOG(RT_TAG_RIIVOLUTION) << "WARNING: " << PathToUtf8(xmlFile)
<< " is not a valid Riivolution XML (version 1 wiidisc); ignoring it"
<< std::endl;
continue;
}
if (!disc->IsValidForGame(gameId, std::nullopt, std::nullopt)) {
RT_LOG(RT_TAG_RIIVOLUTION) << xmlFile.string() << ": not valid for " << gameId
RT_LOG(RT_TAG_RIIVOLUTION) << PathToUtf8(xmlFile) << ": not valid for " << gameId
<< ", skipped" << std::endl;
continue;
}
@@ -284,7 +297,7 @@ std::optional<RuntimeRiivolution::PatchSet> RiivoLoadPatchSet(const fs::path& ov
RiivolutionContract::ApplySelections(*disc, manifestSelections);
const auto activePatches = disc->GeneratePatches(gameId);
const std::string xmlDirGeneric = xmlFile.parent_path().generic_string();
const std::string xmlDirGeneric = RiivoGenericText(xmlFile.parent_path());
const size_t before = set.mappings.size();
for (const auto& patch : activePatches) {
@@ -297,9 +310,10 @@ std::optional<RuntimeRiivolution::PatchSet> RiivoLoadPatchSet(const fs::path& ov
if (const auto resolvedSave = RiivolutionContract::MakeAbsoluteFromRelative(
sdRootGeneric, xmlDirGeneric, savegame->external)) {
state.saveRedirect =
RuntimeRiivolution::SaveRedirect{fs::path(*resolvedSave), savegame->clone};
RuntimeRiivolution::SaveRedirect{PathFromUtf8(*resolvedSave),
savegame->clone};
RT_LOG(RT_TAG_RIIVOLUTION) << "savegame redirect: "
<< state.saveRedirect->hostDirectory.string()
<< PathToUtf8(state.saveRedirect->hostDirectory)
<< (savegame->clone ? " (clone)" : "") << std::endl;
}
}
@@ -308,11 +322,11 @@ std::optional<RuntimeRiivolution::PatchSet> RiivoLoadPatchSet(const fs::path& ov
// A pack whose XML parses but activates nothing is the most confusing
// failure this layer has: the game boots, plays, and quietly shows
// vanilla content. Always say what happened.
RT_LOG(RT_TAG_RIIVOLUTION) << xmlFile.string() << ": " << activePatches.size()
RT_LOG(RT_TAG_RIIVOLUTION) << PathToUtf8(xmlFile) << ": " << activePatches.size()
<< " active patch(es), " << (set.mappings.size() - before) << " mapping(s)"
<< std::endl;
if (activePatches.empty()) {
RT_LOG(RT_TAG_RIIVOLUTION) << "WARNING: " << xmlFile.string()
RT_LOG(RT_TAG_RIIVOLUTION) << "WARNING: " << PathToUtf8(xmlFile)
<< " has no enabled options for " << gameId
<< "; check the riivolution option selections (recomp.yml) or "
<< sdRootGeneric << "/riivolution/config/" << gameId.substr(0, 4) << ".xml"
@@ -321,7 +335,7 @@ std::optional<RuntimeRiivolution::PatchSet> RiivoLoadPatchSet(const fs::path& ov
}
if (set.skippedExternals != 0) {
RT_LOG(RT_TAG_RIIVOLUTION) << overlayRoot.string() << ": skipped "
RT_LOG(RT_TAG_RIIVOLUTION) << PathToUtf8(overlayRoot) << ": skipped "
<< set.skippedExternals << " mapping(s) whose external path does not exist"
<< std::endl;
}

Some files were not shown because too many files have changed in this diff Show More