1 Commits

Author SHA1 Message Date
Michael G c16f1533e5 (feat) Apple Silicon macOS CI building and Setup.pkg documentation (#118)
* docs(macos): document Setup.pkg installation

Add Apple Silicon macOS CI coverage for runtime configuration, substrate tests, and Setup.pkg packaging alongside the macOS installation instructions.

* macos: pin Apple Silicon deployment target

* fix(macos): restore Retro Rewind local builds
2026-09-05 10:13:55 +02:00
41 changed files with 337 additions and 1085 deletions
+31 -4
View File
@@ -14,10 +14,6 @@ concurrency:
cancel-in-progress: true
jobs:
recompilation:
name: Recompilation test
uses: ./.github/workflows/recomp-test.yml
translator:
name: Translator (build + test)
runs-on: windows-latest
@@ -38,3 +34,34 @@ jobs:
- name: Test
run: dotnet test translator/Translator.sln -c Release --no-build --verbosity normal
macos_substrate:
name: macOS arm64 (configure + substrate tests)
runs-on: macos-14
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Configure native runtime
shell: bash
run: |
test "$(uname -m)" = arm64
cmake -S runtime -B build-macos -G Ninja \
-DCMAKE_BUILD_TYPE=Release -DMKW_BUILD_PRODUCTS=OFF
grep -qx 'CMAKE_OSX_DEPLOYMENT_TARGET:STRING=14.0' \
build-macos/CMakeCache.txt
- name: Build macOS portability targets
shell: bash
run: |
cmake --build build-macos --target \
mkw_platform_paths_tests \
mkw_macos_native_compile \
mkw_macos_context_abi_tests \
mkw_macos_host_context_tests \
mkw_macos_guest_flat_memory_tests
- name: Test execution substrate
shell: bash
run: ctest --test-dir build-macos --output-on-failure
+96 -10
View File
@@ -1,8 +1,8 @@
name: Package installers
# Builds the per-platform installer/setup tool (WiiCompiled-Setup.exe /
# WiiCompiled-Setup-x86_64.AppImage) via Launcher/Build-Installer.ps1 and
# Launcher/build-appimage.sh respectively - the same scripts a maintainer runs by hand today to
# WiiCompiled-Setup-x86_64.AppImage / WiiCompiled-Setup.pkg) via the platform packaging scripts -
# the same scripts a maintainer runs by hand today to
# produce a GitHub Release asset. This does NOT build the actual translated game executable:
# that step requires the end user's own Mario Kart Wii dump (Assets/main.dol, Assets/StaticR.rel),
# which is proprietary and not present in this repository or in CI.
@@ -11,6 +11,11 @@ on:
tags:
- '*'
workflow_dispatch:
inputs:
version:
description: Package version
required: true
type: string
permissions:
contents: read
@@ -20,10 +25,6 @@ concurrency:
cancel-in-progress: true
jobs:
recompilation:
name: Recompilation test
uses: ./.github/workflows/recomp-test.yml
linux-appimage:
name: Linux (AppImage, ${{ matrix.arch }})
strategy:
@@ -90,14 +91,99 @@ jobs:
if-no-files-found: error
archive: false
macos-setup-package:
name: macOS (Setup.pkg, Apple Silicon)
runs-on: macos-14
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-dotnet@v6
with:
dotnet-version: '8.0.x'
- name: Verify Apple Silicon runner tools
shell: bash
run: |
test "$(uname -m)" = arm64
xcode-select -p
command -v ninja
file "$(command -v ninja)" | grep -q arm64
- name: Download pinned nodtool release
shell: bash
run: |
mkdir -p Launcher/artifacts/macos
nodtool_version=v2.0.0-alpha.10
nodtool_asset=nodtool-macos-arm64
nodtool_sha256=e23ca466999b720c55e6d29c9683fce8cc74451ba64ead2e543d50129f24528a
curl -fsSL --retry 3 \
"https://github.com/encounter/nod/releases/download/${nodtool_version}/${nodtool_asset}" \
-o Launcher/artifacts/macos/nodtool
printf '%s %s\n' "$nodtool_sha256" Launcher/artifacts/macos/nodtool | shasum -a 256 -c -
chmod +x Launcher/artifacts/macos/nodtool
Launcher/artifacts/macos/nodtool --version
- name: Publish self-contained translator
shell: bash
run: |
dotnet publish translator/src/Translator.Cli/Translator.Cli.csproj \
-c Release -r osx-arm64 --self-contained true \
-p:PublishSingleFile=true \
-o Launcher/artifacts/macos/translator
- name: Download pinned portable CMake
shell: bash
run: |
cmake_version=4.4.3
archive="cmake-${cmake_version}-macos-universal.tar.gz"
base_url="https://github.com/Kitware/CMake/releases/download/v${cmake_version}"
expected_sha256=0c5d65251c14cc884bfa16bdbed3c263ce5bffe2e21c0d0d00962cb0610464fa
curl -fsSL --retry 3 "$base_url/$archive" -o "$archive"
printf '%s %s\n' "$expected_sha256" "$archive" | shasum -a 256 -c -
tar -xzf "$archive"
mv "cmake-${cmake_version}-macos-universal/CMake.app/Contents" Launcher/artifacts/macos/cmake
- name: Build Setup.pkg
env:
PACKAGE_VERSION: ${{ inputs.version }}
TAG_VERSION: ${{ github.ref_name }}
shell: bash
run: |
package_version="$PACKAGE_VERSION"
if [[ -z "$package_version" ]]; then package_version="${TAG_VERSION#v}"; fi
mkdir -p Launcher/dist
Launcher/macos/build-setup-pkg.command \
--nodtool Launcher/artifacts/macos/nodtool \
--translator Launcher/artifacts/macos/translator/Translator.Cli \
--cmake-root Launcher/artifacts/macos/cmake \
--ninja "$(command -v ninja)" \
--output Launcher/dist/WiiCompiled-Setup.pkg \
--version "$package_version"
- name: Verify package boundary
shell: bash
run: |
pkgutil --check-signature Launcher/dist/WiiCompiled-Setup.pkg
! pkgutil --payload-files Launcher/dist/WiiCompiled-Setup.pkg | \
grep -E '/(Assets|generated|PulsarPacks|WiiCompiled.app|RetroRewind.app)(/|$)'
- uses: actions/upload-artifact@v7
with:
name: WiiCompiled-Setup-macos-arm64
path: Launcher/dist/WiiCompiled-Setup.pkg
if-no-files-found: error
archive: false
# Publishes the packaged installers as a GitHub Release whenever a v* tag is pushed. Wheel Wizard
# discovers updates from these releases, so the contract it relies on is enforced here: a full
# (non-prerelease) release whose tag is v<semver>, carrying an asset named exactly
# WiiCompiled-Setup.exe, produced by a setup host that reports that same version.
# (non-prerelease) release whose tag is v<semver>, carrying the expected platform assets,
# produced by setup hosts that report that same version.
release:
name: Publish GitHub Release
if: startsWith(github.ref, 'refs/tags/v')
needs: [linux-appimage, windows-installer, recompilation]
needs: [linux-appimage, windows-installer, macos-setup-package]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -143,7 +229,7 @@ jobs:
set -euo pipefail
ls -lR artifacts
assets=()
for name in WiiCompiled-Setup.exe WiiCompiled-Setup-x86_64.AppImage WiiCompiled-Setup-aarch64.AppImage; do
for name in WiiCompiled-Setup.exe WiiCompiled-Setup-x86_64.AppImage WiiCompiled-Setup-aarch64.AppImage WiiCompiled-Setup.pkg; do
found="$(find artifacts -type f -name "$name" | head -n 1)"
[ -n "$found" ] && [ -s "$found" ] || { echo "::error::missing release asset $name"; exit 1; }
assets+=("$found")
-41
View File
@@ -1,41 +0,0 @@
name: Synthetic recompilation
on:
workflow_call:
workflow_dispatch:
permissions:
contents: read
jobs:
windows:
name: Windows runtime (synthetic DOL)
runs-on: windows-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-dotnet@v6
with:
dotnet-version: '8.0.x'
# Cache downloads only. Preparation still validates pins, and every run
# compiles current Aurora, runtime, and generated sources from scratch.
- uses: actions/cache@v5
with:
path: Launcher/artifacts/downloads
key: windows-recomp-downloads-${{ hashFiles('Launcher/Prepare-PortableTools.ps1', 'Launcher/Prepare-Dependencies.ps1') }}
- name: Prepare the shipped Windows toolchain
shell: pwsh
run: ./Launcher/Prepare-PortableTools.ps1
- name: Prepare pinned native dependencies
shell: pwsh
run: ./Launcher/Prepare-Dependencies.ps1
- name: Translate, compile the full runtime, and link
shell: pwsh
run: ./Launcher/Test-Recompilation.ps1 -Parallel 3
-2
View File
@@ -26,8 +26,6 @@ Code.pul
/build/
/build-*/
/native-build/
/native-build-macos/
/local-products/
/dist/
/out/
[Bb]in/
+1 -1
View File
@@ -281,7 +281,7 @@ foreach ($required in @('ToolkitFingerprint','TranslationFingerprint','NativeToo
$manifest = [ordered]@{
SchemaVersion = 2
ProductVersion = '0.2.31'
ProductVersion = '0.2.27'
ExpectedGameId = $pins.GameId
ExpectedDolSha256 = $pins.DolSha256
ExpectedRelSha256 = $pins.RelSha256
+1 -1
View File
@@ -88,7 +88,7 @@ assert_file "$ninja_bin" "Portable Ninja"
assert_file "$cc" "Portable C compiler"
assert_file "$cxx" "Portable C++ compiler"
assert_dir "$aurora_source" "aurora-main source tree"
clang_binary=$(normalize "$toolchain_dir/bin/clang-22")
clang_binary=$(normalize "$toolchain_dir/bin/clang-23")
assert_file "$clang_binary" "Portable clang driver binary"
(( parallel > 0 )) || parallel=$(nproc)
-146
View File
@@ -1,146 +0,0 @@
# Build the real Windows runtime with translated, entirely synthetic PowerPC code.
# No game dump, game symbol map, REL, mod download, or existing generated/ output is used.
[CmdletBinding()]
param(
[string]$PortableToolsDirectory = 'Launcher/artifacts/portable-tools',
[string]$DependencySourceDirectory = 'Launcher/artifacts/dependencies',
[string]$StageDirectory = 'build/recomp-test',
[ValidateRange(1, 64)] [int]$Parallel = 3
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version 3.0
. (Join-Path $PSScriptRoot 'NativeBuildFlags.ps1')
$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
function Full([string]$Path) {
if ([IO.Path]::IsPathRooted($Path)) { return [IO.Path]::GetFullPath($Path) }
return [IO.Path]::GetFullPath((Join-Path $repoRoot $Path))
}
$portableTools = Full $PortableToolsDirectory
$dependencies = Full $DependencySourceDirectory
$stage = Full $StageDirectory
$dotnet = (Get-Command dotnet -CommandType Application).Source
$cmake = Join-Path $portableTools 'CMake/bin/cmake.exe'
$ninja = Join-Path $portableTools 'Ninja/ninja.exe'
$compilerBin = Join-Path $portableTools 'llvm-mingw/bin'
Assert-File $cmake 'Pinned CMake (run Prepare-PortableTools.ps1 first)'
Assert-File $ninja 'Pinned Ninja'
Assert-File (Join-Path $dependencies 'cppwinrt/winrt/base.h') 'Pinned dependencies (run Prepare-Dependencies.ps1 first)'
# Refuse reuse so a developer's game translation or an earlier build cannot make
# the test pass. Keep the staging tree after the run for diagnostics.
if (Test-Path -LiteralPath $stage) { throw "Test stage already exists; choose a fresh -StageDirectory: $stage" }
[IO.Directory]::CreateDirectory($stage) | Out-Null
Write-Host "Synthetic recompilation workspace: $stage"
# Copy current sources, including uncommitted edits, but no ignored build output.
# An isolated workspace preserves the developer's real generated/ directory.
$sourceFiles = & git -C $repoRoot -c core.quotepath=false ls-files --cached --others --exclude-standard -- runtime aurora-main
if ($LASTEXITCODE -ne 0) { throw 'Could not enumerate runtime and Aurora sources.' }
foreach ($relative in $sourceFiles | Sort-Object -Unique) {
$destination = Join-Path $stage $relative
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) | Out-Null
Copy-Item -LiteralPath (Join-Path $repoRoot $relative) -Destination $destination
}
# One synthetic text section: li r3,40; addi r3,r3,2; nop; blr.
# Native HLE wrappers also call these eight guest symbols directly. Give each
# its own generated blr function so the real product can link without game code.
# Keep this list explicit: a new unresolved guest dependency must fail the test.
[uint32]$entry = 0x80001000L
[uint32[]]$guestCallbacks = @(
0x8012B830L, 0x801A0620L, 0x801A1ED8L, 0x801A961CL,
0x801AADE0L, 0x801D8D30L, 0x801D9E94L, 0x8055531CL
)
$textSize = [int]($guestCallbacks[-1] - $entry + 4)
$dataOffset = 0x100 + $textSize
$dol = [byte[]]::new($dataOffset + 4)
function Write-BigEndian32([int]$Offset, [uint32]$Value) {
$dol[$Offset] = [byte](($Value -shr 24) -band 255)
$dol[$Offset + 1] = [byte](($Value -shr 16) -band 255)
$dol[$Offset + 2] = [byte](($Value -shr 8) -band 255)
$dol[$Offset + 3] = [byte]($Value -band 255)
}
Write-BigEndian32 0x00 0x100 # text[0] file offset
Write-BigEndian32 0x48 $entry # text[0] guest address
Write-BigEndian32 0x90 $textSize # text[0] length (unreachable gaps are zero)
Write-BigEndian32 0x1C $dataOffset # data[0] file offset
Write-BigEndian32 0x64 0x80600000L # data[0] guest address
Write-BigEndian32 0xAC 4 # data[0] length
Write-BigEndian32 0xD8 0x80601000L # BSS address
Write-BigEndian32 0xDC 32 # BSS length
Write-BigEndian32 0xE0 $entry # entry point
Write-BigEndian32 0x100 0x38600028 # li r3,40
Write-BigEndian32 0x104 0x38630002 # addi r3,r3,2
Write-BigEndian32 0x108 0x60000000 # nop
Write-BigEndian32 0x10C 0x4E800020 # blr
foreach ($address in $guestCallbacks) {
Write-BigEndian32 ([int](0x100 + $address - $entry)) 0x4E800020
}
Write-BigEndian32 $dataOffset 0x12345678
[IO.File]::WriteAllBytes((Join-Path $stage 'synthetic.dol'), $dol)
$entryPoints = (@($entry) + $guestCallbacks | ForEach-Object { '0x{0:X8}' -f $_ }) -join ', '
$functionMap = (@($entry) + $guestCallbacks | ForEach-Object { '{0:X8} func_{0:X8}' -f $_ }) -join "`n"
[IO.File]::WriteAllText((Join-Path $stage 'synthetic-functions.txt'), $functionMap)
$manifest = Join-Path $stage 'recomp.yml'
[IO.File]::WriteAllText($manifest, @"
schema_version: 1
workspace_root: .
project:
id: ci-synthetic-dol
display_name: CI Synthetic DOL
memory:
base: 0x80000000
size: 0x01800000
sda_base: 0x80600000
sda2_base: 0x80600000
inputs:
dol:
path: synthetic.dol
translation:
entry_points: [$entryPoints]
function_map:
path: synthetic-functions.txt
allow_unsupported_instructions: false
runtime:
native_abi_directories: []
native_registration_root: runtime/src
output:
root: generated
"@)
$translatorProject = Join-Path $repoRoot 'translator/src/Translator.Cli/Translator.Cli.csproj'
Invoke-Checked $dotnet @('build', $translatorProject, '-c', 'Release', '--disable-build-servers') 'Building the translator'
$translator = Join-Path $repoRoot 'translator/src/Translator.Cli/bin/Release/net8.0/Translator.Cli.dll'
$metadata = Join-Path $stage 'generated/base_translation_output.json'
Invoke-Checked $dotnet @($translator, 'translate-recursive', '0x80001000', '--project', $manifest,
'--output-metadata', $metadata, '--threads', "$Parallel") `
'Translating the synthetic DOL'
# Function-map seeds can be skipped by discovery; do not accept a partial fixture.
$translated = Get-Content -LiteralPath $metadata -Raw | ConvertFrom-Json
foreach ($address in @($entry) + $guestCallbacks) {
if ($address -notin $translated.functions.entryPoint) {
throw ('Synthetic function 0x{0:X8} was not translated.' -f $address)
}
}
Invoke-Checked $dotnet @($translator, 'generate-data-init', '--project', $manifest) 'Generating synthetic data and runtime configuration'
Invoke-Checked $dotnet @($translator, 'emit-build-shards', '--project', $manifest) 'Emitting the production build graph'
$nativeBuild = Join-Path $stage 'native-build'
$oldPath = $env:PATH
try {
$env:PATH = Get-MkwToolchainPath $portableTools
$configure = Get-MkwNativeConfigureArguments -SourceDirectory (Join-Path $stage 'runtime') -BuildDirectory $nativeBuild `
-Ninja $ninja -CCompiler (Join-Path $compilerBin 'x86_64-w64-mingw32-clang.exe') `
-CxxCompiler (Join-Path $compilerBin 'x86_64-w64-mingw32-clang++.exe') `
-ResourceCompiler (Join-Path $compilerBin 'x86_64-w64-mingw32-windres.exe') `
-DependenciesDirectory $dependencies -AdditionalArguments @('-DMKW_BUILD_PRODUCTS=ON')
Invoke-Checked $cmake $configure 'Configuring the production Windows runtime'
Invoke-Checked $cmake @('--build', $nativeBuild, '--target', 'WiiCompiled', '--parallel', "$Parallel") `
'Compiling and linking the synthetic product with the full runtime'
Assert-File (Join-Path $nativeBuild 'WiiCompiled.exe') 'Linked synthetic product'
} finally {
$env:PATH = $oldPath
}
Write-Host 'Synthetic recompilation passed (translation, data generation, runtime compilation, and product link).'
@@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<RootNamespace>WiiCompiled.Setup.Common.Cli</RootNamespace>
<AssemblyName>WiiCompiled.Setup.Common.Cli</AssemblyName>
<Version>0.2.31</Version>
<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>
@@ -5,7 +5,7 @@
<Nullable>enable</Nullable>
<RootNamespace>WiiCompiled.Setup.Common</RootNamespace>
<AssemblyName>WiiCompiled.Setup.Common</AssemblyName>
<Version>0.2.31</Version>
<Version>0.2.22</Version>
<Authors>patchzy</Authors>
<Product>WiiCompiled</Product>
<Description>Shared nodtool/Retro-WFC-payload logic used by both the Windows and Linux installers</Description>
+1 -1
View File
@@ -3,7 +3,7 @@ namespace WiiCompiled.Setup.Linux;
internal static class ProductInfo
{
public const string Name = "WiiCompiled";
public const string Version = "0.2.31";
public const string Version = "0.2.27";
}
/// <summary>One installed product's record inside install-state.json.</summary>
@@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<AssemblyName>WiiCompiled.Setup.Linux</AssemblyName>
<RootNamespace>WiiCompiled.Setup.Linux</RootNamespace>
<Version>0.2.31</Version>
<Version>0.2.22</Version>
<Authors>patchzy</Authors>
<Product>WiiCompiled</Product>
<Description>Command-line installer and launcher for WiiCompiled on Linux</Description>
@@ -121,7 +121,7 @@ internal static class PlatformChecks
internal static class ProductInfo
{
public const string Name = "WiiCompiled";
public const string Version = "0.2.31";
public const string Version = "0.2.27";
/// <summary>
/// The setup executable is copied into the installation under this name. It is the launcher and
@@ -7,7 +7,7 @@
<AssemblyName>WiiCompiled.Setup</AssemblyName>
<RootNamespace>WiiCompiled.Setup.Windows</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<Version>0.2.31</Version>
<Version>0.2.27</Version>
<Authors>patchzy</Authors>
<Product>WiiCompiled</Product>
<Description>Command-line installer and launcher for WiiCompiled</Description>
+3 -1
View File
@@ -131,7 +131,9 @@ if (( builds_retro )); then args+=(--resolved-profile "$mod_out/resolved_dispatc
step emit-build-shards 'Preparing native build shards'; translator "${args[@]}"
step configure-native 'Configuring the native toolchain'
"$cmake_bin" -S "$workspace/runtime" -B "$native_build" -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_MAKE_PROGRAM="$ninja_bin" -DMKW_TRANSLATED_COMPILE_JOBS="$translated_jobs"
# Use Aurora's pinned SDL3 source on macOS. A system SDL3 can be older than
# Aurora's required API even when find_package() succeeds.
"$cmake_bin" -S "$workspace/runtime" -B "$native_build" -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_MAKE_PROGRAM="$ninja_bin" -DMKW_TRANSLATED_COMPILE_JOBS="$translated_jobs" -DAURORA_SDL3_PROVIDER=vendor
targets=(); [[ "$profile" != retro-rewind ]] && targets+=(WiiCompiled); [[ "$profile" != base ]] && targets+=(RetroRewind)
step compile "Compiling ${targets[*]} locally"; "$cmake_bin" --build "$native_build" --target "${targets[@]}" --parallel "$global_jobs"
if [[ "$profile" != retro-rewind ]]; then "$script_dir/macos/publish-app.command" --build-dir "$native_build" --product WiiCompiled --output-dir "${base_output_dir:-$output_dir}"; fi
+3
View File
@@ -103,6 +103,9 @@ copy_clean "$workspace/Launcher/local-build-macos.command" "$resources/workspace
copy_clean "$workspace/Launcher/macos/extract-disc.command" "$resources/workspace/Launcher/macos/extract-disc.command"
copy_clean "$workspace/Launcher/macos/publish-app.command" "$resources/workspace/Launcher/macos/publish-app.command"
chmod +x "$resources/workspace/Launcher/local-build-macos.command" "$resources/workspace/Launcher/macos/"*.command
# setup.command uses this marker to update source inputs in an existing user
# workspace without replacing extracted game assets or Retro Rewind files.
printf '%s\n' "$version" > "$resources/workspace/.bundle-version"
mkdir -p "$resources/tools/cmake"
copy_clean "$nodtool" "$resources/tools/nodtool"; chmod +x "$resources/tools/nodtool"
copy_clean "$translator" "$resources/tools/Translator.Cli"; chmod +x "$resources/tools/Translator.Cli"
+23 -3
View File
@@ -62,10 +62,30 @@ if ! /usr/bin/xcode-select -p >/dev/null 2>&1; then
fi
mkdir -p "$support_root" "$products"
if [[ ! -d "$workspace/.git" && ! -f "$workspace/projects/mkwii/recomp.yml" ]]; then
source_bundle_version="$workspace_source/.bundle-version"
workspace_bundle_version="$workspace/.bundle-version"
needs_workspace_refresh=0
if [[ ! -f "$workspace/projects/mkwii/recomp.yml" ]]; then
needs_workspace_refresh=1
elif [[ -f "$source_bundle_version" ]] && [[ ! -f "$workspace_bundle_version" || "$(<"$source_bundle_version")" != "$(<"$workspace_bundle_version")" ]]; then
needs_workspace_refresh=1
fi
if (( needs_workspace_refresh )); then
printf 'Preparing the local build workspace...\n'
rm -rf "$workspace"
/usr/bin/ditto "$workspace_source" "$workspace"
if [[ ! -d "$workspace" ]]; then
/usr/bin/ditto "$workspace_source" "$workspace"
else
# Refresh only packaged source inputs. Assets and the staged Retro
# Rewind package belong to the user and stay in place.
for source in aurora-main projects runtime translator Launcher; do
/usr/bin/ditto "$workspace_source/$source" "$workspace/$source"
done
/usr/bin/ditto "$source_bundle_version" "$workspace_bundle_version"
# A dependency provider can be cached in this directory, so make the
# refreshed sources configure from a clean native build tree.
rm -rf "$workspace/native-build-macos"
fi
fi
profile=base
+19 -19
View File
@@ -25,7 +25,7 @@ set -euo pipefail
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
workspace=$(cd "$script_dir/.." && pwd)
llvm_version=22.1.8
llvm_version=23.1.0
cmake_version=4.3.3
ninja_version=1.13.2
destination="$script_dir/artifacts/portable-tools"
@@ -52,13 +52,13 @@ done
case "$arch" in
x86_64) llvm_release_arch=X64; target_triple=x86_64-unknown-linux-gnu
llvm_release_sha256=df0e1ecf16caf3489a272a5eea4eec9b0d82878f6477fa309504f918a0006384
llvm_release_sha256=18da30f77f475688a18f7704d23f9f155ae007ed9922dbed6850a9419d9fec8c
cmake_release_arch=x86_64
cmake_sha256=927b2368a946c37269c3a66225ab00544e756459cdd0b5d0da438694fb9ff802
ninja_asset=ninja-linux.zip
ninja_sha256=5749cbc4e668273514150a80e387a957f933c6ed3f5f11e03fb30955e2bbead6 ;;
aarch64) llvm_release_arch=ARM64; target_triple=aarch64-unknown-linux-gnu
llvm_release_sha256=805efad2bb91cb4967fa569e0881d10c0f69c04461cf671cccbae19f547acc34
llvm_release_sha256=cfb31bfc713ef453248bf5bd026312f838ad6c52c25623e987cb6a340f3050d4
cmake_release_arch=aarch64
cmake_sha256=9ea38356dbd3e32e51029a3e09a0f2f8e117ef4fbcaad7a21ffb36409bbd5cb4
ninja_asset=ninja-linux-aarch64.zip
@@ -120,9 +120,9 @@ echo "prepare-portable-tools.sh: pruning to the minimal compile+link toolchain..
# clang: the real driver executable plus the clang/clang++ symlinks CMake/local-build.sh invoke.
# Stripped: debug symbols are dead weight for a bundled compiler nobody will debug.
cp -a "$src/bin/clang-22" "$work/bin/"
strip "$work/bin/clang-22"
ln -s clang-22 "$work/bin/clang"
cp -a "$src/bin/clang-23" "$work/bin/"
strip "$work/bin/clang-23"
ln -s clang-23 "$work/bin/clang"
ln -s clang "$work/bin/clang++"
# lld: linked via -fuse-ld=lld, which clang resolves by looking for ld.lld next to itself first -
@@ -201,10 +201,10 @@ Ninja $ninja_version
Apache License 2.0
EOF
echo "prepare-portable-tools.sh: testing the toolchain..."
test_dir=$(mktemp -d)
trap 'rm -rf "$test_dir"' EXIT
cat > "$test_dir/t.cpp" <<'EOF'
echo "prepare-portable-tools.sh: smoke-testing the toolchain..."
smoke_dir=$(mktemp -d)
trap 'rm -rf "$smoke_dir"' EXIT
cat > "$smoke_dir/t.cpp" <<'EOF'
#include <vector>
#include <cstdio>
int main() {
@@ -214,23 +214,23 @@ int main() {
return sum == 6 ? 0 : 1;
}
EOF
"$work/bin/clang++" -std=c++20 -fuse-ld=lld "$test_dir/t.cpp" -o "$test_dir/t"
"$test_dir/t"
"$work/bin/clang++" -std=c++20 -fuse-ld=lld "$smoke_dir/t.cpp" -o "$smoke_dir/t"
"$smoke_dir/t"
# Also exercised together through CMake+Ninja, exactly how local-build.sh drives them - a plain
# clang++ invocation above would not catch a broken CMAKE_ROOT (Modules/Templates) or a Ninja that
# can't find the compiler.
cat > "$test_dir/CMakeLists.txt" <<'EOF'
cat > "$smoke_dir/CMakeLists.txt" <<'EOF'
cmake_minimum_required(VERSION 3.16)
project(test CXX)
add_executable(test t.cpp)
project(smoke CXX)
add_executable(smoke t.cpp)
EOF
"$work/bin/cmake" -S "$test_dir" -B "$test_dir/build" -G Ninja \
"$work/bin/cmake" -S "$smoke_dir" -B "$smoke_dir/build" -G Ninja \
-DCMAKE_MAKE_PROGRAM="$work/bin/ninja" -DCMAKE_CXX_COMPILER="$work/bin/clang++" >/dev/null
"$work/bin/cmake" --build "$test_dir/build" >/dev/null
"$test_dir/build/test"
"$work/bin/cmake" --build "$smoke_dir/build" >/dev/null
"$smoke_dir/build/smoke"
rm -rf "$test_dir"
rm -rf "$smoke_dir"
trap - EXIT
mv "$work" "$toolchain_dir"
+15
View File
@@ -111,6 +111,21 @@ image under Settings, turn on **WiiCompiled (beta)**, and hit install from the H
Wheel Wizard downloads the setup tool from this repo and walks you through install, updates and
launching. The backend itself is deliberately command-line only, Wheel Wizard is a wrapper around it.
### macOS
Download `WiiCompiled-Setup.pkg` from this repository's Releases page and open it. It requires an
Apple Silicon Mac because its bundled nodtool and Translator.Cli executables are arm64. It installs
**WiiCompiled Setup** in Applications; open that app, choose your clean PAL `RMCP01` disc image,
and select either the base game or Retro Rewind. For Retro Rewind, choose the `RetroRewind6` folder
or its parent folder.
Setup verifies and extracts the image locally, then translates and compiles the native app on your
Mac. On a first run it may ask macOS to install Xcode Command Line Tools; complete Apple's installer,
then open Setup again. When the build completes, Setup asks for administrator approval once to install
`WiiCompiled.app` (and, if selected, `RetroRewind.app`) in `/Applications`.
Setup opens Terminal while it works, so the extraction and build progress—and any error that needs
reporting—remain visible.
> [!CAUTION]
> Only take builds from this repository's
+5 -2
View File
@@ -41,7 +41,10 @@ if (_aurora_sdl3_provider STREQUAL "auto")
set(_aurora_sdl3_provider "package")
else ()
set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL ON)
find_package(SDL3 QUIET)
# Aurora uses APIs from the SDL version pinned by AURORA_SDL3_VERSION.
# Do not silently select an older system package and fail later while
# compiling its headers.
find_package(SDL3 ${AURORA_SDL3_VERSION} QUIET)
set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL OFF)
if (SDL3_FOUND)
set(_aurora_sdl3_provider "system")
@@ -58,7 +61,7 @@ if (_aurora_sdl3_provider STREQUAL "system")
message(STATUS "aurora: Using system SDL3 (provider=system)")
if (NOT SDL3_FOUND)
set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL ON)
find_package(SDL3 REQUIRED)
find_package(SDL3 ${AURORA_SDL3_VERSION} REQUIRED)
set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL OFF)
endif ()
_aurora_sdl3_select_target()
+4 -1
View File
@@ -1438,7 +1438,10 @@ void __PADWriteDeadZones(SDL_IOStream* file, // NOLINT(*-reserved-identifier)
void PADSerializeMappings() {
const std::filesystem::path basePath = fs_path_from_string(aurora::g_config.userPath);
for (auto& controller : aurora::input::g_GameControllers | std::views::values) {
// Avoid std::views::values here: older Apple libc++ releases implement the
// C++20 ranges algorithms we use but not this adaptor.
for (auto& entry : aurora::input::g_GameControllers) {
auto& controller = entry.second;
EnsureMappingLoaded(&controller);
const auto filePath =
basePath / fmt::format("{}_{:04X}_{:04X}.controller", aurora::input::controller_name(controller.m_index),
-9
View File
@@ -401,8 +401,6 @@ SDL_JoystickID add_controller(SDL_JoystickID which) noexcept {
return -1;
}
controller.m_isGameCube = controller.m_vid == 0x057E && controller.m_pid == 0x0337;
const char* serial = SDL_GetGamepadSerial(ctrl);
controller.m_gameCubeUseOrdinaryStop = controller.m_isGameCube && serial && "GCP+"sv == serial;
if (controller.m_isGameCube ||
(SDL_GetGamepadType(ctrl) == SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO && controller.m_pid == 0x2073)) {
controller.m_deadZones.emulateTriggers = false;
@@ -483,13 +481,6 @@ bool controller_has_rumble(Uint32 instance) noexcept {
void controller_rumble(uint32_t instance, uint16_t low_freq_intensity, uint16_t high_freq_intensity,
uint16_t duration_ms) noexcept {
if (auto it = g_GameControllers.find(instance); it != g_GameControllers.end()) {
// GC Pocket+ has been observed continuing to vibrate after a hard stop;
// an ordinary stop cleared it. With GAMECUBE_RUMBLE_BRAKE enabled, SDL
// encodes (0, 1) as adapter command 0, whereas (0, 0) sends command 2.
// Apply the workaround here so shutdown uses the same stop as PAD calls.
if (it->second.m_gameCubeUseOrdinaryStop && low_freq_intensity == 0 && high_freq_intensity == 0) {
high_freq_intensity = 1;
}
SDL_RumbleGamepad(it->second.m_controller, low_freq_intensity, high_freq_intensity, duration_ms);
}
}
-1
View File
@@ -17,7 +17,6 @@ extern Module Log;
struct GameController {
SDL_Gamepad* m_controller = nullptr;
bool m_isGameCube = false;
bool m_gameCubeUseOrdinaryStop = false;
Sint32 m_index = -1;
Sint32 m_playerIndex = -1;
bool m_hasRumble = false;
+1 -1
View File
@@ -235,7 +235,7 @@ std::string GetOSVersion() {
constexpr auto name = "iOS";
#elif TARGET_OS_TV
constexpr auto name = "tvOS";
#elif
#else
constexpr auto name = Unknown;
#endif
+10 -17
View File
@@ -17,6 +17,16 @@ else()
message(FATAL_ERROR
"WiiCompiled supports 64-bit LLVM-MinGW Clang on Windows, native Linux x86_64/aarch64, or Apple Clang on macOS arm64")
endif()
# Do not inherit the host SDK's deployment target (macOS 26 on current
# toolchains). The supported Apple Silicon release is macOS 14 and the app
# bundle advertises that same minimum. This remains arm64-only until the Intel
# support work is merged.
if(MKW_PLATFORM_MACOS)
set(CMAKE_OSX_DEPLOYMENT_TARGET "14.0" CACHE STRING
"Minimum macOS version for the Apple Silicon build" FORCE)
endif()
if(NOT CMAKE_BUILD_TYPE STREQUAL "Release")
message(FATAL_ERROR "WiiCompiled only supports Release builds")
endif()
@@ -277,23 +287,6 @@ target_link_libraries(mkw_platform_paths_tests PRIVATE mkw_platform)
target_compile_features(mkw_platform_paths_tests PRIVATE cxx_std_17)
add_test(NAME mkw_platform_paths_tests COMMAND mkw_platform_paths_tests)
add_executable(mkw_nand_save_tests "${CMAKE_CURRENT_LIST_DIR}/tests/nand_save_tests.cpp")
target_include_directories(mkw_nand_save_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
target_compile_features(mkw_nand_save_tests PRIVATE cxx_std_17)
add_test(NAME mkw_nand_save_tests COMMAND mkw_nand_save_tests)
add_executable(mkw_nand_settings_tests "${CMAKE_CURRENT_LIST_DIR}/tests/nand_settings_tests.cpp")
find_package(Threads REQUIRED)
target_link_libraries(mkw_nand_settings_tests PRIVATE Threads::Threads)
target_include_directories(mkw_nand_settings_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
target_compile_features(mkw_nand_settings_tests PRIVATE cxx_std_17)
add_test(NAME mkw_nand_settings_tests COMMAND mkw_nand_settings_tests)
add_executable(mkw_sc_serial_tests "${CMAKE_CURRENT_LIST_DIR}/tests/sc_serial_tests.cpp")
target_include_directories(mkw_sc_serial_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
target_compile_features(mkw_sc_serial_tests PRIVATE cxx_std_17)
add_test(NAME mkw_sc_serial_tests COMMAND mkw_sc_serial_tests)
# The input expression engine is self-contained, so it can be exercised without
# linking the runtime or SDL.
add_executable(mkw_input_expr_tests
+89 -20
View File
@@ -1,28 +1,38 @@
#pragma once
#include "nand_path.h"
#include "nand_settings.h"
#include "runtime_config.h"
#include <algorithm>
#include <array>
#include <cctype>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <optional>
#include <random>
#include <sstream>
#include <string>
#include <string_view>
#include <utility>
namespace RuntimeConsoleIdentity {
struct Identity {
std::string serial;
std::string productCode;
std::string area;
std::string gameRegion;
std::array<uint8_t, 6> mac;
};
inline bool IsValidSerial(const std::string& serial) {
return serial.size() == 9 &&
serial != "000000000" &&
std::all_of(serial.begin(), serial.end(),
[](unsigned char value) { return std::isdigit(value) != 0; });
}
inline Identity FromSerial(std::string serial) {
// Keep Nintendo's Wii OUI. The suffix is derived from the NAND serial
// Keep Nintendo's Wii OUI. The suffix is derived from the persisted serial
// so every API exposes one coherent, stable virtual-console identity.
uint32_t hash = 2166136261u;
for (const unsigned char value : serial) {
@@ -36,7 +46,6 @@ inline Identity FromSerial(std::string serial) {
return {
std::move(serial),
{}, {}, {},
{
0x00,
0x09,
@@ -48,23 +57,83 @@ inline Identity FromSerial(std::string serial) {
};
}
inline Identity LoadFromNand() {
const auto root = RuntimeNandPath::DiscoverNandRootPath();
const auto settings = RuntimeNandSettings::Read(root);
if (!settings || !RuntimeNandSettings::HasIdentity(*settings)) {
RuntimeNandPath::FailNandRoot(
"NAND setting.txt is missing or has invalid console identity fields (SERNO, CODE, AREA, GAME)",
root / "title/00000001/00000002/data/setting.txt");
inline std::optional<std::string> ReadSerial(const std::filesystem::path& path) {
std::ifstream input(path);
std::string line;
if (!input || !std::getline(input, line)) {
return std::nullopt;
}
Identity identity = FromSerial(settings->at("SERNO"));
identity.productCode = settings->at("CODE");
identity.area = settings->at("AREA");
identity.gameRegion = settings->at("GAME");
return identity;
constexpr std::string_view prefix = "serial=";
if (line.rfind(prefix, 0) != 0) {
return std::nullopt;
}
std::string serial = line.substr(prefix.size());
if (!IsValidSerial(serial)) {
return std::nullopt;
}
return serial;
}
inline bool WriteSerial(const std::filesystem::path& path, const std::string& serial) {
std::error_code ec;
std::filesystem::create_directories(path.parent_path(), ec);
if (ec) {
return false;
}
std::filesystem::path temporary = path;
temporary += ".tmp";
{
std::ofstream output(temporary, std::ios::trunc);
if (!output) {
return false;
}
output << "serial=" << serial << '\n';
output.close();
if (!output) {
return false;
}
}
std::filesystem::rename(temporary, path, ec);
if (!ec) {
return true;
}
std::filesystem::remove(temporary, ec);
return false;
}
inline std::string GenerateSerial() {
std::random_device entropy;
std::seed_seq seed{
entropy(),
entropy(),
entropy(),
entropy(),
};
std::mt19937 generator(seed);
std::uniform_int_distribution<uint32_t> distribution(100000000u, 999999999u);
return std::to_string(distribution(generator));
}
inline Identity LoadOrCreate(const std::filesystem::path& path) {
if (const auto serial = ReadSerial(path)) {
return FromSerial(*serial);
}
const std::string generated = GenerateSerial();
if (WriteSerial(path, generated)) {
return FromSerial(generated);
}
// Remain operational in a read-only environment. This fallback matches
// Dolphin's deterministic serial while keeping the same valid identity shape.
return FromSerial("123456789");
}
inline const Identity& Current() {
static const Identity identity = LoadFromNand();
static const Identity identity =
LoadOrCreate(RuntimeConfigFile::ApplicationDataDirectory() / "ConsoleIdentity.txt");
return identity;
}
+1 -14
View File
@@ -1,7 +1,6 @@
#pragma once
#include "runtime_config.h"
#include "nand_settings.h"
#include "runtime_log.h"
#include "system_bridge.h"
@@ -164,7 +163,7 @@ inline std::filesystem::path CreateManagedNandRoot() {
return root;
}
inline std::filesystem::path ResolveNandRootPath() {
inline std::filesystem::path DiscoverNandRootPath() {
const std::string configPath = RuntimeConfigFile::NandRoot();
if (!configPath.empty()) {
const auto path = ResolveConfiguredPath(configPath);
@@ -180,16 +179,4 @@ inline std::filesystem::path ResolveNandRootPath() {
return CreateManagedNandRoot();
}
inline std::filesystem::path DiscoverNandRootPath() {
static const auto root = [] {
const auto resolved = ResolveNandRootPath();
std::string error;
if (!RuntimeNandSettings::Ensure(resolved, error)) {
FailNandRoot(error.c_str(), RuntimeNandSettings::FilePath(resolved));
}
return resolved;
}();
return root;
}
} // namespace RuntimeNandPath
-59
View File
@@ -1,59 +0,0 @@
#pragma once
#include <filesystem>
#include <fstream>
#include <istream>
namespace RuntimeNandSave {
enum class Contents { Missing, Blank, Nonzero, Error };
enum class ReadAction { Proceed, Missing, Error, RecoveryNeeded };
// A failed read is not evidence that a save is blank. Check badbit before EOF:
// an I/O failure may set both, whereas a successful short final read sets EOF.
inline Contents InspectStream(std::istream& input) {
if (!input) return Contents::Error;
char block[4096];
for (;;) {
input.read(block, sizeof(block));
if (input.bad() || (input.fail() && !input.eof())) return Contents::Error;
for (std::streamsize i = 0; i < input.gcount(); ++i) {
if (block[i] != 0) return Contents::Nonzero;
}
if (input.eof()) return Contents::Blank;
}
}
inline Contents InspectFile(const std::filesystem::path& path) {
std::error_code ec;
const auto status = std::filesystem::symlink_status(path, ec);
if (ec && ec != std::errc::no_such_file_or_directory) return Contents::Error;
if (!std::filesystem::exists(status)) return Contents::Missing;
if (!std::filesystem::is_regular_file(path, ec) || ec) return Contents::Error;
std::ifstream input(path, std::ios::binary);
return InspectStream(input);
}
// Probe only read-only opens of the actual save and its exact write shadow.
// No probe writes, removes, or repairs data, and backups are not save aliases.
inline ReadAction CheckRead(const std::filesystem::path& path, int mode) {
const auto name = path.filename();
const bool isMain = name == "rksys.dat";
if (mode != 1 || (!isMain && name != "rksys.dat.nandsafe.tmp")) return ReadAction::Proceed;
const auto contents = InspectFile(path);
if (contents == Contents::Error) return ReadAction::Error;
if (contents == Contents::Nonzero) return ReadAction::Proceed;
if (isMain) {
auto shadow = path;
shadow += ".nandsafe.tmp";
const auto shadowContents = InspectFile(shadow);
if (shadowContents == Contents::Error) return ReadAction::Error;
// The next write normally discards an old shadow. Preserve a possible
// recovery source when there is no usable original, without promoting
// an uncommitted (and potentially incomplete) shadow to the real save.
if (shadowContents == Contents::Nonzero) return ReadAction::RecoveryNeeded;
}
return contents == Contents::Blank ? ReadAction::Missing : ReadAction::Proceed;
}
} // namespace RuntimeNandSave
-220
View File
@@ -1,220 +0,0 @@
#pragma once
#include <array>
#include <atomic>
#include <chrono>
#include <ctime>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <map>
#include <optional>
#include <string>
#include <utility>
#ifdef _WIN32
#include <windows.h>
#else
#include <unistd.h>
#endif
namespace RuntimeNandSettings {
using Settings = std::map<std::string, std::string>;
inline std::filesystem::path FilePath(const std::filesystem::path& root) {
return root / "title/00000001/00000002/data/setting.txt";
}
// Wii setting.txt is a 256-byte buffer encrypted with a rotating XOR key.
inline std::optional<Settings> Read(const std::filesystem::path& nandRoot) {
std::ifstream input(FilePath(nandRoot), std::ios::binary);
std::array<uint8_t, 256> bytes{};
if (!input.read(reinterpret_cast<char*>(bytes.data()), bytes.size())) {
return std::nullopt;
}
uint32_t key = 0x73B5DBFAu;
std::string decoded;
for (const uint8_t byte : bytes) {
const char value = static_cast<char>(byte ^ static_cast<uint8_t>(key));
key = (key << 1) | (key >> 31);
if (value == '\0') {
break;
}
if (value != '\r') {
decoded += value;
}
}
Settings settings;
for (size_t start = 0; start < decoded.size();) {
const size_t end = decoded.find('\n', start);
const std::string line = decoded.substr(start, end - start);
const size_t equals = line.find('=');
if (equals != std::string::npos && equals != 0) {
settings.emplace(line.substr(0, equals), line.substr(equals + 1));
}
if (end == std::string::npos) {
break;
}
start = end + 1;
}
return settings;
}
inline bool HasIdentity(const Settings& settings) {
const auto serial = settings.find("SERNO");
if (serial == settings.end() || serial->second.empty() || serial->second.size() > 9 ||
serial->second.find_first_not_of("0123456789") != std::string::npos ||
serial->second.find_first_not_of('0') == std::string::npos) {
return false;
}
for (const auto& field : {std::pair{"CODE", 5u}, {"AREA", 3u}, {"GAME", 2u}}) {
const auto value = settings.find(field.first);
if (value == settings.end() || value->second.empty() ||
value->second.size() > field.second) {
return false;
}
}
return true;
}
// Dolphin's normal (non-deterministic) first-boot algorithm. It is independent
// of the ES device ID. Matching another NAND requires that NAND's saved serial.
inline std::string GenerateSerial(std::time_t now) {
if (now < 0) {
return {};
}
const auto digits = std::to_string(now % 1000000000);
return std::string(9 - digits.size(), '0') + digits;
}
// This recompilation targets the European disc. These are Dolphin's PAL boot
// defaults; an existing setting.txt always takes precedence, in every region.
inline std::optional<std::array<uint8_t, 256>> EncodeNew(const std::string& serial) {
const Settings identity{{"SERNO", serial}, {"CODE", "LEH"}, {"AREA", "EUR"}, {"GAME", "EU"}};
if (!HasIdentity(identity)) {
return std::nullopt;
}
std::array<uint8_t, 256> bytes{};
size_t position = 0;
uint32_t key = 0x73B5DBFAu;
const auto writeByte = [&](char value) {
bytes[position++] = static_cast<uint8_t>(value) ^ static_cast<uint8_t>(key);
key = (key << 1) | (key >> 31);
};
for (const std::string& line : {std::string("AREA=EUR\r\n"), std::string("MODEL=RVL-001(EUR)\r\n"),
std::string("DVD=0\r\n"), std::string("MPCH=0x7FFE\r\n"), std::string("CODE=LEH\r\n"),
"SERNO=" + serial + "\r\n", std::string("VIDEO=PAL\r\n"), std::string("GAME=EU\r\n")}) {
for (;;) {
if (position + line.size() > bytes.size()) {
return std::nullopt;
}
const auto start = position;
const auto savedKey = key;
bool hasNull = false;
for (const char value : line) {
writeByte(value);
hasNull |= bytes[position - 1] == 0;
}
if (!hasNull) {
break;
}
// Nintendo stops at an encoded NUL. Dolphin inserts an extra LF
// before this line and retries with the shifted encryption key.
position = start;
key = savedKey;
writeByte('\n');
}
}
return bytes; // The unused tail stays raw zero, as in Dolphin.
}
// Atomically claim our own scratch directory. A collision belongs to another
// launch (or a previous crashed launch); leave it untouched and try another name.
inline std::optional<std::filesystem::path> CreateScratchDirectory(
const std::filesystem::path& parent, const std::string& token, std::error_code& ec) {
for (unsigned attempt = 0; attempt < 128; ++attempt) {
const auto candidate = parent / (".setting-init-" + token + "-" + std::to_string(attempt));
ec.clear();
if (std::filesystem::create_directory(candidate, ec)) return candidate;
if (ec && ec != std::errc::file_exists) return std::nullopt;
}
ec = std::make_error_code(std::errc::file_exists);
return std::nullopt;
}
// Never replace an existing file, including an unreadable or damaged one.
// Publish a complete file atomically so simultaneous launches use one identity.
inline bool Ensure(const std::filesystem::path& root, std::string& error,
std::time_t now = std::time(nullptr)) {
const auto path = FilePath(root);
std::error_code ec;
const auto status = std::filesystem::symlink_status(path, ec);
if (ec && ec != std::errc::no_such_file_or_directory) {
error = "Cannot inspect NAND setting.txt: " + ec.message();
return false;
}
if (std::filesystem::exists(status)) {
const auto existing = Read(root);
if (existing && HasIdentity(*existing)) {
return true;
}
error = "Existing NAND setting.txt is unreadable or invalid; restore it from this console's backup";
return false;
}
const auto bytes = EncodeNew(GenerateSerial(now));
if (!bytes) {
error = "Cannot initialize NAND settings: invalid system clock";
return false;
}
ec.clear();
std::filesystem::create_directories(path.parent_path(), ec);
if (ec) {
error = "Cannot create NAND settings directory: " + ec.message();
return false;
}
static std::atomic<unsigned> sequence{0};
#ifdef _WIN32
const auto processId = GetCurrentProcessId();
#else
const auto processId = getpid();
#endif
const auto scratch = CreateScratchDirectory(path.parent_path(),
std::to_string(processId) + "-" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()) + "-" +
std::to_string(sequence++), ec);
if (!scratch) {
error = "Cannot create temporary NAND settings directory: " + ec.message();
return false;
}
const auto temporary = *scratch / "setting.txt";
bool written = false;
{
std::ofstream output(temporary, std::ios::binary);
output.write(reinterpret_cast<const char*>(bytes->data()), bytes->size());
output.close();
written = static_cast<bool>(output);
}
bool published = false;
if (written) {
#ifdef _WIN32
published = MoveFileExW(temporary.c_str(), path.c_str(), MOVEFILE_WRITE_THROUGH) != 0;
#else
published = ::link(temporary.c_str(), path.c_str()) == 0;
#endif
}
std::filesystem::remove(temporary, ec);
std::filesystem::remove(*scratch, ec);
// A competing launcher may have published its settings first. Always read
// the winner from NAND rather than using our unpersisted candidate serial.
const auto persisted = Read(root);
if (persisted && HasIdentity(*persisted)) {
return true;
}
error = published ? "Cannot read newly initialized NAND setting.txt" :
"Cannot persist NAND setting.txt; check NAND directory permissions";
return false;
}
} // namespace RuntimeNandSettings
-26
View File
@@ -1,26 +0,0 @@
#pragma once
#include <charconv>
#include <cstddef>
#include <cstdint>
#include <string_view>
#include <system_error>
namespace RuntimeScSerial {
// SCGetProductSN's output is a u32, not a character buffer. DWC loads
// that word and formats it with the product code to construct csnum.
template <typename RangeValidator, typename WordWriter>
uint32_t Write(std::string_view serial, uint32_t address,
RangeValidator&& contains, WordWriter&& write32) {
if (serial.empty() || serial.size() > 9 ||
serial.find_first_not_of("0123456789") != std::string_view::npos) return 0;
uint32_t number = 0;
const auto parsed = std::from_chars(serial.data(), serial.data() + serial.size(), number);
if (parsed.ec != std::errc{} || parsed.ptr != serial.data() + serial.size() ||
!address || !contains(address, sizeof(uint32_t))) return 0;
write32(address, number);
return 1;
}
} // namespace RuntimeScSerial
+2 -6
View File
@@ -256,12 +256,8 @@ bool GuestFiberManager::CreateGuestFiber(uint32_t guestThreadAddr, uint32_t entr
gf.cpuContext.srr0 = entryPoint;
// The host stack models only translated host calls; the guest stack starts
// at stackBase in the CPU context above. 64 KiB is too small for deep
// translated/HLE call chains (notably NW4R's sound worker), and on macOS
// it can exhaust the guarded coroutine stack as unrelated host work (such
// as a window resize) adds a little more nesting. Keep enough headroom for
// those chains while the guest stack remains separately bounded.
constexpr size_t kHostStackSize = 1024 * 1024;
// at stackBase in the CPU context above.
constexpr size_t kHostStackSize = 64 * 1024;
gf.fiber = HostContext::Create(kHostStackSize, FiberProc,
reinterpret_cast<void*>(static_cast<uintptr_t>(guestThreadAddr)));
+1 -1
View File
@@ -499,7 +499,7 @@ int32_t HandleIpTopIoctlv(uint32_t cmd, const std::vector<IoVector>& in, const s
// Nonblocking sockets get -SO_EAGAIN immediately (Dolphin's retry predicate
// short-circuits on nonBlock/forceNonBlock, IOS/Network/Socket.cpp:715-718);
// waiting here anyway stalled the whole emulation thread on every empty read.
constexpr int kStreamRecvWaitMs = 1000;
constexpr int kStreamRecvWaitMs = 250;
const int streamWaitMs = (forceNonBlock || s->nonblocking) ? 0 : kStreamRecvWaitMs;
const bool waited = ret < 0 && !fromPtr && s->type == SOCK_STREAM &&
IsWouldBlockError(nativeErr) && WaitForReadable(s->native, streamWaitMs);
+21 -33
View File
@@ -1,7 +1,6 @@
#include "hle_stubs.h"
#include "console_identity.h"
#include "sc_serial_contract.h"
#include <cstdlib>
#include <cstddef>
#include <cstdint>
@@ -13,25 +12,7 @@
namespace {
// Use the SDK's own value tables, including its unknown-region result.
uint32_t LookupProductRegion(uint32_t table, uint32_t stride, uint32_t count,
const std::string& value) {
for (uint32_t index = 0; index < count; ++index) {
const uint32_t entry = table + index * stride;
if (!Memory::Contains(entry, stride)) {
break;
}
const auto* bytes = static_cast<const uint8_t*>(Memory::GetPointer(entry, stride));
if (bytes[0] == 0xFF) {
break;
}
if (value.size() < stride - 1 &&
std::memcmp(bytes + 1, value.c_str(), value.size() + 1) == 0) {
return bytes[0];
}
}
return 0xFFFFFFFFu;
}
constexpr uint32_t kPalProductRegion = 2;
} // namespace
@@ -69,12 +50,16 @@ extern "C" uint32_t SCGetEuRgb60Mode_HLE()
PPC_NATIVE_OVERRIDE(801B1CAC, SCGetEuRgb60Mode_HLE, uint32_t, (), ());
// Expose the selected emulated NAND identity through the SDK SC APIs.
// The managed NAND intentionally starts without a console-owned setting.txt.
// DWC nevertheless requires the Wii product code and serial number so it can
// include csnum in NAS authentication. Expose one stable virtual-console
// identity without requiring or mutating a user's real NAND.
extern "C" uint32_t SCGetProductArea_HLE()
{
return LookupProductRegion(0x8029CEB0u, 5, 13,
RuntimeConsoleIdentity::Current().area);
// The PAL setting.txt AREA value is "EUR". The SDK's lookup table at
// 0x8029CEB0 maps JPN=0, USA=1, EUR=2.
return kPalProductRegion;
}
PPC_NATIVE_OVERRIDE(801B23A0, SCGetProductArea_HLE, uint32_t, (), ());
@@ -83,13 +68,12 @@ extern "C" uint32_t SCGetProductCode_HLE()
{
// Original PAL SC storage for the six-byte CODE value.
constexpr uint32_t kProductCodeAddress = 0x803869E0u;
const std::string& productCode = RuntimeConsoleIdentity::Current().productCode;
const size_t size = productCode.size() + 1;
if (!Memory::Contains(kProductCodeAddress, size)) {
static constexpr char kProductCode[] = "LEH";
if (!Memory::Contains(kProductCodeAddress, sizeof(kProductCode))) {
return 0;
}
std::memcpy(Memory::GetPointer(kProductCodeAddress, size),
productCode.c_str(), size);
std::memcpy(Memory::GetPointer(kProductCodeAddress, sizeof(kProductCode)),
kProductCode, sizeof(kProductCode));
return kProductCodeAddress;
}
@@ -98,17 +82,21 @@ PPC_NATIVE_OVERRIDE(801B2424, SCGetProductCode_HLE, uint32_t, (), ());
extern "C" uint32_t SCGetProductSN_HLE(uint32_t serialAddress)
{
const std::string& serial = RuntimeConsoleIdentity::Current().serial;
return RuntimeScSerial::Write(serial, serialAddress,
[](uint32_t address, size_t size) { return Memory::Contains(address, size); },
[](uint32_t address, uint32_t value) { Memory::Write32(address, value); });
if (!serialAddress || !Memory::Contains(serialAddress, serial.size() + 1)) {
return 0;
}
std::memcpy(Memory::GetPointer(serialAddress, serial.size() + 1),
serial.c_str(), serial.size() + 1);
return 1;
}
PPC_NATIVE_OVERRIDE(801B2460, SCGetProductSN_HLE, uint32_t, (uint32_t serialAddress), (serialAddress));
extern "C" uint32_t SCGetProductGameRegion_HLE()
{
return LookupProductRegion(0x8029CEF8u, 4, 4,
RuntimeConsoleIdentity::Current().gameRegion);
// The PAL setting.txt GAME value is "EU". The SDK's own lookup table at
// 0x8029CEF8 maps JP=0, US=1, EU=2.
return kPalProductRegion;
}
PPC_NATIVE_OVERRIDE(801B24C8, SCGetProductGameRegion_HLE, uint32_t, (), ());
-3
View File
@@ -93,9 +93,6 @@ extern "C" int32_t NANDOpen_HLE(uint32_t pathPtr, uint32_t fileInfoPtr, uint32_t
const std::filesystem::path hostPath = TranslateNandPath(path);
if (const auto result = NandCheckSystemSaveRead("NANDOpen", hostPath, mode))
return *result;
// Existing-file write opens go through a shadow copy seeded from the original, so a
// crash between NANDWrite and NANDClose cannot leave a torn file (the game patches
// sub-ranges, e.g. ghost saves at a non-zero offset). New files still create in place.
-2
View File
@@ -411,8 +411,6 @@ 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.
if (const auto result = NandCheckSystemSaveRead("NANDSafeOpen", hostPath, mode))
return *result;
FILE* file = NandFopen(hostPath, "rb");
if (!file && IsFaceLibResourcePath(path) && SeedFaceLibResource(hostPath)) {
file = NandFopen(hostPath, "rb");
-20
View File
@@ -411,26 +411,6 @@ bool IsFaceLibResourcePath(const char* path) {
return std::strcmp(path, "/shared2/menu/FaceLib/RFL_Res.dat") == 0;
}
std::optional<int32_t> NandCheckSystemSaveRead(const char* who,
const std::filesystem::path& hostPath, int mode, bool ios) {
const auto action = RuntimeNandSave::CheckRead(hostPath, mode);
if (action == RuntimeNandSave::ReadAction::Proceed) return std::nullopt;
if (action == RuntimeNandSave::ReadAction::Missing) {
LogNandWarning(who, "treating empty or zero-filled system save '%s' as missing",
HostPathText(hostPath).c_str());
return ios ? ISFS_ENOENT : NAND_RESULT_NOEXISTS;
}
if (action == RuntimeNandSave::ReadAction::RecoveryNeeded) {
LogNandError(who, "system save '%s' is missing or blank but its .nandsafe.tmp contains data; "
"back up both files before attempting recovery",
HostPathText(hostPath).c_str());
} else {
LogNandError(who, "could not inspect system save '%s' or its write shadow; leaving data untouched",
HostPathText(hostPath).c_str());
}
return ios ? ISFS_EIO : NAND_RESULT_UNKNOWN;
}
// Create directories recursively
bool CreateDirectoryPath(const std::filesystem::path& path) {
if (path.empty()) {
-7
View File
@@ -9,7 +9,6 @@
#include "hle/runtime_parse_helpers.h"
#include "memory.h"
#include "nand_path.h"
#include "nand_save_probe.h"
#include "hle/net/network.h"
#include "recomp_mod_loader.h"
#include "runtime_config.h"
@@ -27,7 +26,6 @@
#include <deque>
#include <map>
#include <mutex>
#include <optional>
#include <vector>
#include <filesystem>
#include <string>
@@ -58,11 +56,6 @@ constexpr uint32_t kNandTitleIdLo = 0x524D4350; // "RMCP" fallback
void LogNandError(const char* func, const char* fmt, ...);
void LogNandWarning(const char* func, const char* fmt, ...);
// An empty optional means continue opening normally; otherwise return the
// supplied NAND/IOS error without exposing a failed scan as a missing save.
std::optional<int32_t> NandCheckSystemSaveRead(const char* who,
const std::filesystem::path& hostPath, int mode, bool ios = false);
// ============================================================================
// File Descriptor Management
// ============================================================================
-3
View File
@@ -391,9 +391,6 @@ extern "C" int32_t NAND_IOS_Open_HLE(uint32_t pathPtr, uint32_t mode) {
// It's a NAND file path
const std::filesystem::path hostPath = TranslateNandPath(path);
if (const auto result = NandCheckSystemSaveRead("IOS_Open", hostPath, mode, true))
return *result;
// Seed FaceLib resources before the existence check so every open mode can
// still find them on a fresh managed NAND.
+5
View File
@@ -483,6 +483,11 @@ void DrawRumbleSettings() {
PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD,
};
PADControlAllMotors(stopAll.data());
#if defined(_WIN32)
for (uint32_t port = 0; port < PAD_MAX_CONTROLLERS; ++port) {
Wup028Adapter::SetRumble(port, false);
}
#endif
}
}
if (ImGui::IsItemHovered()) {
-142
View File
@@ -1,142 +0,0 @@
#include "nand_save_probe.h"
#include <algorithm>
#include <chrono>
#include <iostream>
#include <sstream>
#include <stdexcept>
#ifdef _WIN32
#include <windows.h>
#endif
namespace fs = std::filesystem;
using RuntimeNandSave::ReadAction;
using RuntimeNandSave::Contents;
static void Require(bool condition, const char* message) {
if (!condition) throw std::runtime_error(message);
}
static void Write(const fs::path& path, const std::string& bytes) {
fs::create_directories(path.parent_path());
std::ofstream output(path, std::ios::binary);
output.write(bytes.data(), bytes.size());
output.close();
Require(static_cast<bool>(output), "Fixture write failed");
}
static std::string Read(const fs::path& path) {
std::ifstream input(path, std::ios::binary);
Require(static_cast<bool>(input), "Fixture read failed");
return {std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
}
// A disk error after zero-filled blocks must not look like a blank file's EOF.
class FailingDisk : public std::streambuf {
int blocks;
public:
explicit FailingDisk(int zeroBlocks) : blocks(zeroBlocks) {}
std::streamsize xsgetn(char* buffer, std::streamsize length) override {
if (blocks-- <= 0) throw std::runtime_error("injected read failure");
std::fill(buffer, buffer + length, '\0');
return length;
}
};
int main() {
const auto root = fs::temp_directory_path() / ("wiicomp-save-scenarios-" +
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
try {
const auto save = root / "title/00010004/524d4350/data/rksys.dat";
const auto shadow = fs::path(save.native() + fs::path(".nandsafe.tmp").native());
// Save inspection must leave unrelated NAND data alone. Settings
// initialization is covered separately by nand_settings_tests.
const auto settingsPath = root / "title/00000001/00000002/data/setting.txt";
const std::string identity(256, '\x5a');
Write(settingsPath, identity);
Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::Proceed, "Fresh profile follows normal missing-file handling");
Require(!fs::exists(save), "Probing fresh profile must not create a save");
// First launch interrupted before save initialization, including block
// boundaries and a full-sized synthetic zero-filled allocation.
for (const size_t size : {size_t(0), size_t(1), size_t(4095), size_t(4096), size_t(4097), size_t(3 * 1024 * 1024)}) {
const std::string bytes(size, '\0');
Write(save, bytes);
Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::Missing, "Blank save should be offered first-save recovery");
Require(Read(save) == bytes, "Blank-save detection must not modify the file");
for (int mode : {2, 3}) {
Require(RuntimeNandSave::CheckRead(save, mode) == ReadAction::Proceed, "Write opens must remain available for initialization");
}
}
// Existing saves, imported saves, partial/corrupt saves, and a zero
// prefix with data only in the final byte are all left to the game.
std::string existing(3 * 1024 * 1024, '\0');
existing.replace(0, 8, "RKSD0006");
existing[10000] = 42;
for (const std::string& bytes : {existing, std::string("RKSD"), std::string("damaged-header"),
std::string(8192, '\0') + "x", std::string(8191, '\0') + "x"}) {
Write(save, bytes);
Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::Proceed, "Never hide a save containing any data");
Require(Read(save) == bytes, "Existing/partial save must be byte-identical after inspection");
}
// Interrupted replacement: retain a committed original regardless of
// whether the shadow is blank, partial, or contains a complete header.
Write(save, existing);
for (const std::string& bytes : {std::string(), std::string(4096, '\0'), std::string("RKSD"), existing}) {
Write(shadow, bytes);
Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::Proceed, "Committed original takes precedence over write shadow");
Require(Read(save) == existing && Read(shadow) == bytes, "Probe must preserve both sides of an interrupted write");
}
// No usable original: do not let missing-save recovery discard the
// only possible recovery source, and do not auto-promote that shadow.
for (const bool mainExists : {false, true}) {
fs::remove(save);
if (mainExists) Write(save, std::string(4096, '\0'));
Write(shadow, existing);
Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::RecoveryNeeded, "Preserve recovery candidate when original is missing or blank");
Require(Read(shadow) == existing, "Recovery candidate must remain unchanged");
Require(fs::exists(save) == mainExists, "Do not promote shadow automatically");
}
Write(shadow, std::string(4096, '\0'));
Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::Missing, "Two blank files may use first-save recovery");
fs::remove(shadow);
for (const char* name : {"rksys.dat.bak", "rksys.dat.backup", "rksys.dat2", "banner.bin", "setting.txt"}) {
const auto unrelated = save.parent_path() / name;
Write(unrelated, std::string(4096, '\0'));
Require(RuntimeNandSave::CheckRead(unrelated, 1) == ReadAction::Proceed, "Do not classify backups or unrelated files as missing saves");
}
for (int blocks : {0, 1, 2}) {
FailingDisk disk(blocks);
std::istream input(&disk);
Require(RuntimeNandSave::InspectStream(input) == Contents::Error, "Read failure must remain an error, including after zero-filled blocks");
}
std::istringstream badEof;
badEof.setstate(std::ios::badbit | std::ios::eofbit);
Require(RuntimeNandSave::InspectStream(badEof) == Contents::Error, "Badbit plus EOF must not imply a blank save");
#ifdef _WIN32
Write(save, existing);
const HANDLE locked = CreateFileW(save.c_str(), GENERIC_READ, 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
Require(locked != INVALID_HANDLE_VALUE, "Could not lock fixture");
const auto lockedResult = RuntimeNandSave::CheckRead(save, 1);
CloseHandle(locked);
Require(lockedResult == ReadAction::Error, "Sharing/access failure must not report a missing save");
Require(Read(save) == existing, "Locked save must survive inspection unchanged");
Require(SetFileAttributesW(save.c_str(), FILE_ATTRIBUTE_READONLY) != 0, "Set fixture read-only");
const auto readOnlyResult = RuntimeNandSave::CheckRead(save, 1);
SetFileAttributesW(save.c_str(), FILE_ATTRIBUTE_NORMAL);
Require(readOnlyResult == ReadAction::Proceed && Read(save) == existing, "Readable read-only save remains available");
#endif
Require(Read(settingsPath) == identity, "Save inspection must not change NAND settings");
fs::remove_all(root);
std::cout << "NAND save startup, preservation, interrupted-write and I/O failure scenarios passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << " (fixtures retained at " << root << ")\n";
return 1;
}
}
-183
View File
@@ -1,183 +0,0 @@
#include "nand_settings.h"
#include <chrono>
#include <iostream>
#include <stdexcept>
#include <thread>
#include <vector>
static void Require(bool condition, const char* message = "NAND settings check failed") {
if (!condition) {
throw std::runtime_error(message);
}
}
static std::string ReadBytes(const std::filesystem::path& path) {
std::ifstream input(path, std::ios::binary);
return {std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
}
int main() {
const auto root = std::filesystem::temp_directory_path() /
("wiicomp-nand-settings-" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()));
const auto path = root / "title/00000001/00000002/data/setting.txt";
try {
using namespace RuntimeNandSettings;
Require(GenerateSerial(1800000123) == "800000123", "Dolphin timestamp modulo");
Require(GenerateSerial(1000000001) == "000000001", "Dolphin leading zero padding");
Require(GenerateSerial(-1).empty(), "Invalid clock must not supply an identity");
// Golden bytes generated by Dolphin's unmodified SettingsHandler.cpp
// (upstream 2026-09-06), PAL boot fields and synthetic serial 000000001.
// Everything after this prefix is raw zero padding to 256 bytes.
const std::string goldenHex =
"bba6ac929a0bc96b7eed83d27f33a1e7e73d9b836d8b47c59ee23df6b275baab"
"bec9d9dead03cc7a3bdafee50c30ab9fb86194e119fe4ba19eff62d5ec3aacb3"
"b5c9d9e3977eac0943d7ff903120a49ef024eafe1cf77be79cf6229a823aabf0f0";
std::array<uint8_t, 256> golden{};
for (size_t i = 0; i < goldenHex.size() / 2; ++i) {
golden[i] = static_cast<uint8_t>(std::stoul(goldenHex.substr(i * 2, 2), nullptr, 16));
}
Require(EncodeNew("000000001") == golden, "Exact Dolphin writer golden fixture");
std::string error;
Require(!RuntimeNandSettings::Read(root));
Require(!std::filesystem::exists(root));
std::filesystem::create_directories(path.parent_path());
const auto scratchParent = root / "scratch-collisions";
std::filesystem::create_directories(scratchParent / ".setting-init-fixed-0");
const auto sentinel = scratchParent / ".setting-init-fixed-0" / "setting.txt";
{ std::ofstream output(sentinel); output << "another launch owns this"; }
const auto occupiedFile = scratchParent / ".setting-init-fixed-1";
{ std::ofstream output(occupiedFile); output << "leave this file alone"; }
std::error_code scratchError;
const auto claimed = CreateScratchDirectory(scratchParent, "fixed", scratchError);
Require(claimed && *claimed == scratchParent / ".setting-init-fixed-2" && !scratchError,
"Retry collisions with both existing directories and files");
Require(ReadBytes(sentinel) == "another launch owns this" &&
ReadBytes(occupiedFile) == "leave this file alone", "Never modify another launch's scratch data");
Require(!CreateScratchDirectory(occupiedFile / "not-a-directory", "fixed", scratchError) && scratchError,
"Real filesystem errors must fail rather than retry indefinitely");
// Force all claimants to use the same token; this deterministically
// exercises the collision path even when host clock precision is high.
std::array<std::optional<std::filesystem::path>, 16> claims;
std::vector<std::thread> claimants;
for (size_t i = 0; i < claims.size(); ++i) {
claimants.emplace_back([&, i] {
std::error_code ec;
claims[i] = CreateScratchDirectory(scratchParent, "shared", ec);
});
}
for (auto& claimant : claimants) claimant.join();
for (size_t i = 0; i < claims.size(); ++i) {
Require(claims[i].has_value(), "Every concurrent claimant must acquire a scratch directory");
for (size_t j = 0; j < i; ++j) {
Require(claims[i] != claims[j], "Concurrent claimants must own different scratch directories");
}
}
const std::string plain = "AREA=USA\r\n\nCODE=LU\r\nSERNO=987654321\r\nGAME=US\r\n";
std::array<uint8_t, 256> fixture{};
for (size_t i = 0; i < fixture.size(); ++i) {
const unsigned shift = i % 32;
const uint32_t key = shift == 0 ? 0x73B5DBFAu :
(0x73B5DBFAu << shift) | (0x73B5DBFAu >> (32 - shift));
fixture[i] = static_cast<uint8_t>(key) ^ (i < plain.size() ? plain[i] : 0);
}
{
std::ofstream output(path, std::ios::binary);
output.write(reinterpret_cast<const char*>(fixture.data()), fixture.size());
}
auto settings = RuntimeNandSettings::Read(root);
Require(settings && RuntimeNandSettings::HasIdentity(*settings));
Require(settings->at("SERNO") == "987654321" && settings->at("CODE") == "LU");
Require(settings->at("AREA") == "USA" && settings->at("GAME") == "US");
Require(Ensure(root, error, 1800000123), "Existing imported NAND must work");
std::array<uint8_t, 256> after{};
{
std::ifstream input(path, std::ios::binary);
input.read(reinterpret_cast<char*>(after.data()), after.size());
}
Require(after == fixture);
for (const auto serial : {"", "000000000", "1234567890", "123ABC789"}) {
(*settings)["SERNO"] = serial;
Require(!RuntimeNandSettings::HasIdentity(*settings));
}
(*settings)["SERNO"] = "012345678";
Require(RuntimeNandSettings::HasIdentity(*settings));
(*settings)["CODE"] = "TOOLONG";
Require(!RuntimeNandSettings::HasIdentity(*settings));
(*settings)["CODE"] = "LEH";
settings->erase("GAME");
Require(!RuntimeNandSettings::HasIdentity(*settings));
std::filesystem::resize_file(path, 128);
Require(!RuntimeNandSettings::Read(root));
const auto damaged = ReadBytes(path);
Require(!Ensure(root, error, 1800000123), "Do not replace a truncated identity");
Require(ReadBytes(path) == damaged, "Damaged file must remain untouched");
const auto fresh = root / "fresh";
Require(Ensure(fresh, error, 1800000123), "Missing setting.txt must initialize");
const auto generated = Read(fresh);
Require(generated && HasIdentity(*generated), "Generated file must be readable");
Require(generated->at("SERNO") == "800000123", "Persist Dolphin-generated serial");
Require(generated->at("CODE") == "LEH" && generated->at("AREA") == "EUR" &&
generated->at("GAME") == "EU", "PAL first-boot fields");
Require(generated->at("MODEL") == "RVL-001(EUR)" && generated->at("VIDEO") == "PAL" &&
generated->at("DVD") == "0" && generated->at("MPCH") == "0x7FFE",
"Complete Dolphin boot settings");
const auto firstBoot = ReadBytes(FilePath(fresh));
Require(firstBoot.size() == 256 && firstBoot.back() == 0, "Dolphin buffer size and raw zero padding");
Require(Ensure(fresh, error, 1900000999), "Second boot");
Require(ReadBytes(FilePath(fresh)) == firstBoot, "Second boot must not change any bytes");
const auto blocked = root / "blocked";
{ std::ofstream output(blocked); output << "file obstructing NAND directory"; }
Require(!Ensure(blocked, error, 1800000123), "Write failure must not return an ephemeral identity");
Require(!Ensure(root / "bad-clock", error, -1), "Clock failure must not initialize");
const auto concurrent = root / "concurrent";
std::array<bool, 16> results{};
std::vector<std::thread> workers;
for (size_t i = 0; i < results.size(); ++i) {
workers.emplace_back([&, i] {
std::string detail;
results[i] = Ensure(concurrent, detail, 1800000001 + i);
});
}
for (auto& worker : workers) worker.join();
for (const bool result : results) Require(result, "Concurrent boot must read the persisted winner");
const auto winner = ReadBytes(FilePath(concurrent));
Require(Read(concurrent) && HasIdentity(*Read(concurrent)), "Concurrent boot must persist valid settings");
Require(Ensure(concurrent, error, 1900000999), "Boot after concurrent initialization");
Require(ReadBytes(FilePath(concurrent)) == winner, "Concurrent winner must remain stable");
// Independently decode as Nintendo does: stop at the first encoded NUL.
// Exercise serials that force Dolphin's extra-LF escaping, not only
// values that happen to work with a plain rotating-XOR encoder.
bool sawExtraLf = false;
for (int serial = 1; serial <= 10000; ++serial) {
const auto number = GenerateSerial(1000000000 + serial);
const auto encoded = EncodeNew(number);
Require(encoded.has_value(), "Serial encoding must fit");
std::string decoded;
for (size_t i = 0; i < encoded->size() && (*encoded)[i] != 0; ++i) {
const unsigned shift = i % 32;
const uint32_t key = shift == 0 ? 0x73B5DBFAu :
(0x73B5DBFAu << shift) | (0x73B5DBFAu >> (32 - shift));
decoded += static_cast<char>((*encoded)[i] ^ static_cast<uint8_t>(key));
}
Require(decoded.find("SERNO=" + number + "\r\n") != std::string::npos &&
decoded.find("GAME=EU\r\n") != std::string::npos,
"Encoded NUL must not truncate settings");
sawExtraLf |= decoded.find("\r\n\n") != std::string::npos;
}
Require(sawExtraLf, "Exercise Dolphin LF escape path");
std::filesystem::remove_all(root);
std::cout << "NAND settings checks passed\n";
return 0;
} catch (const std::exception& error) {
std::filesystem::remove_all(root);
std::cerr << error.what() << '\n';
return 1;
}
}
-81
View File
@@ -1,81 +0,0 @@
#include "sc_serial_contract.h"
#include "nand_settings.h"
#include <algorithm>
#include <array>
#include <iostream>
#include <iomanip>
#include <stdexcept>
#include <string>
static void Require(bool condition, const char* message) {
if (!condition) throw std::runtime_error(message);
}
static uint32_t ReadWord(const unsigned char* bytes) {
return (uint32_t(bytes[0]) << 24) | (uint32_t(bytes[1]) << 16) |
(uint32_t(bytes[2]) << 8) | uint32_t(bytes[3]);
}
int main(int argc, char** argv) {
try {
// Feed real generator + SC ABI outputs to the upstream bot decoder.
// Usage: mkw_sc_serial_tests --timestamp-vectors <unix-seconds> ...
if (argc > 1 && std::string(argv[1]) == "--timestamp-vectors") {
for (int i = 2; i < argc; ++i) {
const auto timestamp = std::stoll(argv[i]);
const auto serial = RuntimeNandSettings::GenerateSerial(static_cast<std::time_t>(timestamp));
std::array<unsigned char, 4> output{};
Require(RuntimeScSerial::Write(serial, 4,
[](uint32_t address, size_t size) { return address == 4 && size == 4; },
[&](uint32_t, uint32_t value) {
for (unsigned j = 0; j < 4; ++j)
output[j] = static_cast<unsigned char>(value >> (24 - 8 * j));
}) == 1, "Generated serial must pass SC ABI");
std::cout << timestamp << '\t' << serial << "\tLEH"
<< std::setfill('0') << std::setw(9) << ReadWord(output.data()) << '\n';
}
return 0;
}
// Reproduce the reported csnums from the old string-writing override.
const unsigned char old7886[] = {'7', '8', '8', '6'};
const unsigned char old7618[] = {'7', '6', '1', '8'};
Require(ReadWord(old7886) == 926431286, "Reproduce shared LEH926431286");
Require(ReadWord(old7618) == 926298424, "Reproduce shared LEH926298424");
std::array<unsigned char, 16> memory;
size_t available = 4;
unsigned writes = 0;
const auto contains = [&](uint32_t address, size_t size) {
return address == 4 && size <= available;
};
const auto write32 = [&](uint32_t address, uint32_t value) {
++writes;
for (unsigned i = 0; i < 4; ++i)
memory[address + i] = static_cast<unsigned char>(value >> (24 - 8 * i));
};
for (const auto& pair : {std::pair{"788600001", 788600001u}, {"788699999", 788699999u},
{"761800001", 761800001u}, {"761899999", 761899999u},
{"012345678", 12345678u}, {"000000001", 1u}, {"999999999", 999999999u}}) {
memory.fill(0xa5);
writes = 0;
Require(RuntimeScSerial::Write(pair.first, 4, contains, write32) == 1, "Accept an exactly four-byte output buffer");
Require(writes == 1 && ReadWord(memory.data() + 4) == pair.second, "Return full numeric serial, including digits after common prefix");
for (size_t i = 0; i < memory.size(); ++i)
if (i < 4 || i >= 8) Require(memory[i] == 0xa5, "Do not overwrite adjacent guest stack data");
}
for (const char* serial : {"", "1234567890", "7886x1234", "-12345678", "+12345678"}) {
writes = 0;
Require(RuntimeScSerial::Write(serial, 4, contains, write32) == 0 && writes == 0, "Reject malformed serial without a write");
}
writes = 0;
Require(RuntimeScSerial::Write("788600001", 0, contains, write32) == 0 && writes == 0, "Reject null output");
available = 3;
Require(RuntimeScSerial::Write("788600001", 4, contains, write32) == 0 && writes == 0, "Reject undersized output");
std::cout << "SC serial collision reproduction, numeric output and memory-boundary tests passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}