mirror of
https://github.com/patchzyy/wiicompiled
synced 2026-09-12 09:45:04 -04:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a22de1079 | |||
| c0359b05a7 | |||
| 6c1d4faaf7 | |||
| 3fb7373c4f | |||
| 8dd689c6d2 | |||
| 69be584cc5 | |||
| 8d569ef77b | |||
| 36cc6abe24 | |||
| 806f4127bb | |||
| fb9b101de7 | |||
| 64ea7b7401 | |||
| 4f4716be5a | |||
| c0ed2bfbeb | |||
| f09590aa5d | |||
| edc5fa1dd3 | |||
| c5db4d0e8e | |||
| e3c4028b50 | |||
| 48c4df342f | |||
| c93f82e938 | |||
| 9f799fe12b | |||
| 02df7ef9ee | |||
| 88ab029c74 | |||
| a0b54b874b | |||
| f63d0c84b5 | |||
| 6d836dab3e | |||
| 6bffedf028 | |||
| 09ca4e98f4 | |||
| 6dc4e59052 | |||
| 82b295b20c | |||
| eb1721fd72 | |||
| 129c714f02 | |||
| 2794024d3a | |||
| b5e5858e1d | |||
| 4897e7e27d | |||
| 7ebda6bf7f | |||
| 0403f176bd | |||
| 1912292c80 | |||
| e498e62622 | |||
| f73739f248 | |||
| 3389fe35fc | |||
| e11062a1ba | |||
| 1d1d064d37 | |||
| 9d1f3db1d5 | |||
| 251529d66e | |||
| 987b666296 | |||
| 260022b4ba | |||
| f1c2b60df1 | |||
| 5193dd9749 | |||
| ec9bd92415 | |||
| dbf6d05625 | |||
| 8ba44162fa | |||
| 9ee14e582d | |||
| 0c349a9296 | |||
| 21b57f08a4 | |||
| e146b10af8 | |||
| edd03f8991 | |||
| 9c4f4b738a | |||
| 8d5d93f02d | |||
| c75b482224 | |||
| e5ae29b27e |
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
+3
-3
@@ -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)
|
||||
+12
-6
@@ -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 <iso>`. 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}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
+6
-54
@@ -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;
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -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.");
|
||||
+17
-110
@@ -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();
|
||||
}
|
||||
}
|
||||
+18
-16
@@ -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:<id></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();
|
||||
}
|
||||
@@ -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
-1
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
namespace WiiCompiled.Setup;
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
internal enum AppMode
|
||||
{
|
||||
+2
-1
@@ -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,
|
||||
+4
-3
@@ -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
-1
@@ -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();
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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
-1
@@ -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
|
||||
+3
-1
@@ -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
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
namespace WiiCompiled.Setup;
|
||||
using WiiCompiled.Setup.Common;
|
||||
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
internal enum InstallTransactionEntryKind
|
||||
{
|
||||
+3
-1
@@ -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
|
||||
+2
-2
@@ -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"
|
||||
];
|
||||
}
|
||||
+19
-12
@@ -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)
|
||||
+2
-1
@@ -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")]
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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
|
||||
+2
-2
@@ -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
|
||||
+2
-1
@@ -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
|
||||
+3
-1
@@ -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.
|
||||
+9
-8
@@ -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
-1
@@ -1,6 +1,6 @@
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace WiiCompiled.Setup;
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
internal static class ShellIntegration
|
||||
{
|
||||
+4
-3
@@ -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);
|
||||
+2
-1
@@ -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
-2
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
namespace WiiCompiled.Setup;
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
|
||||
internal static class WorkspaceTimestamps
|
||||
@@ -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"
|
||||
Executable
+425
@@ -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"
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)))
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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));
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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));
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user