diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index f258e14..267a34e 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -14,6 +14,10 @@ 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
diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml
index 742418f..b97c62a 100644
--- a/.github/workflows/package.yml
+++ b/.github/workflows/package.yml
@@ -20,6 +20,10 @@ concurrency:
cancel-in-progress: true
jobs:
+ recompilation:
+ name: Recompilation test
+ uses: ./.github/workflows/recomp-test.yml
+
linux-appimage:
name: Linux (AppImage, ${{ matrix.arch }})
strategy:
@@ -93,7 +97,7 @@ jobs:
release:
name: Publish GitHub Release
if: startsWith(github.ref, 'refs/tags/v')
- needs: [linux-appimage, windows-installer]
+ needs: [linux-appimage, windows-installer, recompilation]
runs-on: ubuntu-latest
permissions:
contents: write
diff --git a/.github/workflows/recomp-test.yml b/.github/workflows/recomp-test.yml
new file mode 100644
index 0000000..0908d15
--- /dev/null
+++ b/.github/workflows/recomp-test.yml
@@ -0,0 +1,41 @@
+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
diff --git a/.gitignore b/.gitignore
index 855cfed..59542aa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,6 +26,8 @@ Code.pul
/build/
/build-*/
/native-build/
+/native-build-macos/
+/local-products/
/dist/
/out/
[Bb]in/
diff --git a/Launcher/Build-Installer.ps1 b/Launcher/Build-Installer.ps1
index ec95fbf..eb70930 100644
--- a/Launcher/Build-Installer.ps1
+++ b/Launcher/Build-Installer.ps1
@@ -281,7 +281,7 @@ foreach ($required in @('ToolkitFingerprint','TranslationFingerprint','NativeToo
$manifest = [ordered]@{
SchemaVersion = 2
- ProductVersion = '0.2.28'
+ ProductVersion = '0.2.31'
ExpectedGameId = $pins.GameId
ExpectedDolSha256 = $pins.DolSha256
ExpectedRelSha256 = $pins.RelSha256
diff --git a/Launcher/Test-Recompilation.ps1 b/Launcher/Test-Recompilation.ps1
new file mode 100644
index 0000000..9a8d8a2
--- /dev/null
+++ b/Launcher/Test-Recompilation.ps1
@@ -0,0 +1,146 @@
+# 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).'
diff --git a/Launcher/WiiCompiled.Setup.Common.Cli/WiiCompiled.Setup.Common.Cli.csproj b/Launcher/WiiCompiled.Setup.Common.Cli/WiiCompiled.Setup.Common.Cli.csproj
index 4f7cad6..97e8ecb 100644
--- a/Launcher/WiiCompiled.Setup.Common.Cli/WiiCompiled.Setup.Common.Cli.csproj
+++ b/Launcher/WiiCompiled.Setup.Common.Cli/WiiCompiled.Setup.Common.Cli.csproj
@@ -6,7 +6,7 @@
enable
WiiCompiled.Setup.Common.Cli
WiiCompiled.Setup.Common.Cli
- 0.2.28
+ 0.2.31
patchzy
WiiCompiled
Packaging-time helper: resolves (downloading if needed) the nodtool binary bundled by build-appimage.sh and Build-Installer.ps1
diff --git a/Launcher/WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj b/Launcher/WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj
index 7cbc650..aebdc1b 100644
--- a/Launcher/WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj
+++ b/Launcher/WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj
@@ -5,7 +5,7 @@
enable
WiiCompiled.Setup.Common
WiiCompiled.Setup.Common
- 0.2.28
+ 0.2.31
patchzy
WiiCompiled
Shared nodtool/Retro-WFC-payload logic used by both the Windows and Linux installers
diff --git a/Launcher/WiiCompiled.Setup.Linux/Models.cs b/Launcher/WiiCompiled.Setup.Linux/Models.cs
index 59b4984..b8f8ea6 100644
--- a/Launcher/WiiCompiled.Setup.Linux/Models.cs
+++ b/Launcher/WiiCompiled.Setup.Linux/Models.cs
@@ -3,7 +3,7 @@ namespace WiiCompiled.Setup.Linux;
internal static class ProductInfo
{
public const string Name = "WiiCompiled";
- public const string Version = "0.2.28";
+ public const string Version = "0.2.31";
}
/// One installed product's record inside install-state.json.
diff --git a/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj b/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj
index 6c68fc0..84d3269 100644
--- a/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj
+++ b/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj
@@ -6,7 +6,7 @@
enable
WiiCompiled.Setup.Linux
WiiCompiled.Setup.Linux
- 0.2.28
+ 0.2.31
patchzy
WiiCompiled
Command-line installer and launcher for WiiCompiled on Linux
diff --git a/Launcher/WiiCompiled.Setup.Windows/Program.cs b/Launcher/WiiCompiled.Setup.Windows/Program.cs
index 42e5005..7995f17 100644
--- a/Launcher/WiiCompiled.Setup.Windows/Program.cs
+++ b/Launcher/WiiCompiled.Setup.Windows/Program.cs
@@ -121,7 +121,7 @@ internal static class PlatformChecks
internal static class ProductInfo
{
public const string Name = "WiiCompiled";
- public const string Version = "0.2.28";
+ public const string Version = "0.2.31";
///
/// The setup executable is copied into the installation under this name. It is the launcher and
diff --git a/Launcher/WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj b/Launcher/WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj
index af088e3..0efaa73 100644
--- a/Launcher/WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj
+++ b/Launcher/WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj
@@ -7,7 +7,7 @@
WiiCompiled.Setup
WiiCompiled.Setup.Windows
app.manifest
- 0.2.28
+ 0.2.31
patchzy
WiiCompiled
Command-line installer and launcher for WiiCompiled
diff --git a/Launcher/prepare-portable-tools.sh b/Launcher/prepare-portable-tools.sh
index 8e812ff..69ea00d 100755
--- a/Launcher/prepare-portable-tools.sh
+++ b/Launcher/prepare-portable-tools.sh
@@ -201,10 +201,10 @@ Ninja $ninja_version
Apache License 2.0
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'
+echo "prepare-portable-tools.sh: testing the toolchain..."
+test_dir=$(mktemp -d)
+trap 'rm -rf "$test_dir"' EXIT
+cat > "$test_dir/t.cpp" <<'EOF'
#include
#include
int main() {
@@ -214,23 +214,23 @@ int main() {
return sum == 6 ? 0 : 1;
}
EOF
-"$work/bin/clang++" -std=c++20 -fuse-ld=lld "$smoke_dir/t.cpp" -o "$smoke_dir/t"
-"$smoke_dir/t"
+"$work/bin/clang++" -std=c++20 -fuse-ld=lld "$test_dir/t.cpp" -o "$test_dir/t"
+"$test_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 > "$smoke_dir/CMakeLists.txt" <<'EOF'
+cat > "$test_dir/CMakeLists.txt" <<'EOF'
cmake_minimum_required(VERSION 3.16)
-project(smoke CXX)
-add_executable(smoke t.cpp)
+project(test CXX)
+add_executable(test t.cpp)
EOF
-"$work/bin/cmake" -S "$smoke_dir" -B "$smoke_dir/build" -G Ninja \
+"$work/bin/cmake" -S "$test_dir" -B "$test_dir/build" -G Ninja \
-DCMAKE_MAKE_PROGRAM="$work/bin/ninja" -DCMAKE_CXX_COMPILER="$work/bin/clang++" >/dev/null
-"$work/bin/cmake" --build "$smoke_dir/build" >/dev/null
-"$smoke_dir/build/smoke"
+"$work/bin/cmake" --build "$test_dir/build" >/dev/null
+"$test_dir/build/test"
-rm -rf "$smoke_dir"
+rm -rf "$test_dir"
trap - EXIT
mv "$work" "$toolchain_dir"
diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt
index be057c7..0bee6f1 100644
--- a/runtime/CMakeLists.txt
+++ b/runtime/CMakeLists.txt
@@ -277,6 +277,23 @@ 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
diff --git a/runtime/include/console_identity.h b/runtime/include/console_identity.h
index ef46679..c07fc30 100644
--- a/runtime/include/console_identity.h
+++ b/runtime/include/console_identity.h
@@ -1,38 +1,28 @@
#pragma once
-#include "runtime_config.h"
+#include "nand_path.h"
+#include "nand_settings.h"
-#include
#include
-#include
#include
#include
-#include
-#include
#include
-#include
-#include
#include
#include
-#include
#include
namespace RuntimeConsoleIdentity {
struct Identity {
std::string serial;
+ std::string productCode;
+ std::string area;
+ std::string gameRegion;
std::array 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 persisted serial
+ // Keep Nintendo's Wii OUI. The suffix is derived from the NAND serial
// so every API exposes one coherent, stable virtual-console identity.
uint32_t hash = 2166136261u;
for (const unsigned char value : serial) {
@@ -46,6 +36,7 @@ inline Identity FromSerial(std::string serial) {
return {
std::move(serial),
+ {}, {}, {},
{
0x00,
0x09,
@@ -57,83 +48,23 @@ inline Identity FromSerial(std::string serial) {
};
}
-inline std::optional ReadSerial(const std::filesystem::path& path) {
- std::ifstream input(path);
- std::string line;
- if (!input || !std::getline(input, line)) {
- return std::nullopt;
+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");
}
- 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 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");
+ Identity identity = FromSerial(settings->at("SERNO"));
+ identity.productCode = settings->at("CODE");
+ identity.area = settings->at("AREA");
+ identity.gameRegion = settings->at("GAME");
+ return identity;
}
inline const Identity& Current() {
- static const Identity identity =
- LoadOrCreate(RuntimeConfigFile::ApplicationDataDirectory() / "ConsoleIdentity.txt");
+ static const Identity identity = LoadFromNand();
return identity;
}
diff --git a/runtime/include/nand_path.h b/runtime/include/nand_path.h
index 709e4af..eb0fda8 100644
--- a/runtime/include/nand_path.h
+++ b/runtime/include/nand_path.h
@@ -1,6 +1,7 @@
#pragma once
#include "runtime_config.h"
+#include "nand_settings.h"
#include "runtime_log.h"
#include "system_bridge.h"
@@ -163,7 +164,7 @@ inline std::filesystem::path CreateManagedNandRoot() {
return root;
}
-inline std::filesystem::path DiscoverNandRootPath() {
+inline std::filesystem::path ResolveNandRootPath() {
const std::string configPath = RuntimeConfigFile::NandRoot();
if (!configPath.empty()) {
const auto path = ResolveConfiguredPath(configPath);
@@ -179,4 +180,16 @@ inline std::filesystem::path DiscoverNandRootPath() {
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
diff --git a/runtime/include/nand_save_probe.h b/runtime/include/nand_save_probe.h
new file mode 100644
index 0000000..b00b7d8
--- /dev/null
+++ b/runtime/include/nand_save_probe.h
@@ -0,0 +1,59 @@
+#pragma once
+
+#include
+#include
+#include
+
+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
diff --git a/runtime/include/nand_settings.h b/runtime/include/nand_settings.h
new file mode 100644
index 0000000..055f4c2
--- /dev/null
+++ b/runtime/include/nand_settings.h
@@ -0,0 +1,220 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include