mirror of
https://github.com/patchzyy/wiicompiled
synced 2026-09-12 01:38:50 -04:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4c53dad4b | |||
| e05e15bce5 | |||
| 9066cb3560 | |||
| 05d6a0b1ab | |||
| afb8f49866 | |||
| 58bfd32022 | |||
| a135beb201 | |||
| 88b990b060 | |||
| e0e362bd99 | |||
| 5654d8f21b | |||
| 730e3122d5 | |||
| f424536d3b | |||
| 2d9dc4e0f2 |
@@ -30,6 +30,14 @@ jobs:
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: ${{ runner.os }}-nuget-${{ hashFiles('translator/Translator.sln', '**/*.csproj', '**/*.props', '**/*.targets', '**/packages.lock.json', 'global.json', 'NuGet.config', 'nuget.config') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-nuget-
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore translator/Translator.sln
|
||||
|
||||
@@ -38,3 +46,22 @@ jobs:
|
||||
|
||||
- name: Test
|
||||
run: dotnet test translator/Translator.sln -c Release --no-build --verbosity normal
|
||||
|
||||
cpp:
|
||||
name: C++ runtime tests (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, ubuntu-latest, macos-14]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Configure
|
||||
run: cmake -S runtime/tests -B build/tests -DCMAKE_BUILD_TYPE=Release
|
||||
- name: Build
|
||||
run: cmake --build build/tests --config Release --parallel
|
||||
- name: Test
|
||||
run: ctest --test-dir build/tests -C Release --output-on-failure --no-tests=error
|
||||
|
||||
@@ -28,6 +28,19 @@ jobs:
|
||||
path: Launcher/artifacts/downloads
|
||||
key: windows-recomp-downloads-${{ hashFiles('Launcher/Prepare-PortableTools.ps1', 'Launcher/Prepare-Dependencies.ps1') }}
|
||||
|
||||
# Install sccache.
|
||||
- name: Run sccache-action
|
||||
uses: mozilla/sccache-action@v0.0.11
|
||||
|
||||
# Tell CMake to use sccache and use GitHub's API.
|
||||
- name: Configure sccache environment
|
||||
shell: pwsh
|
||||
run: |
|
||||
"SCCACHE_GHA_ENABLED=true" | Add-Content -Path $env:GITHUB_ENV
|
||||
"ACTIONS_CACHE_SERVICE_V2=on" | Add-Content -Path $env:GITHUB_ENV
|
||||
"CMAKE_C_COMPILER_LAUNCHER=$env:SCCACHE_PATH" | Add-Content -Path $env:GITHUB_ENV
|
||||
"CMAKE_CXX_COMPILER_LAUNCHER=$env:SCCACHE_PATH" | Add-Content -Path $env:GITHUB_ENV
|
||||
|
||||
- name: Prepare the shipped Windows toolchain
|
||||
shell: pwsh
|
||||
run: ./Launcher/Prepare-PortableTools.ps1
|
||||
@@ -36,6 +49,13 @@ jobs:
|
||||
shell: pwsh
|
||||
run: ./Launcher/Prepare-Dependencies.ps1
|
||||
|
||||
# Expose cache token context to the build script.
|
||||
- name: Translate, compile the full runtime, and link
|
||||
shell: pwsh
|
||||
run: ./Launcher/Test-Recompilation.ps1 -Parallel 3
|
||||
run: ./Launcher/Test-Recompilation.ps1 -Parallel 4
|
||||
|
||||
# Print cache results (even if the build fails)
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: sccache --show-stats
|
||||
|
||||
@@ -26,6 +26,8 @@ Code.pul
|
||||
/build/
|
||||
/build-*/
|
||||
/native-build/
|
||||
/native-build-macos/
|
||||
/local-products/
|
||||
/dist/
|
||||
/out/
|
||||
[Bb]in/
|
||||
@@ -70,3 +72,5 @@ project.lock.json
|
||||
*.log
|
||||
output.txt
|
||||
|
||||
# Operating System
|
||||
.DS_Store
|
||||
|
||||
@@ -281,7 +281,7 @@ foreach ($required in @('ToolkitFingerprint','TranslationFingerprint','NativeToo
|
||||
|
||||
$manifest = [ordered]@{
|
||||
SchemaVersion = 2
|
||||
ProductVersion = '0.2.29'
|
||||
ProductVersion = '0.2.31'
|
||||
ExpectedGameId = $pins.GameId
|
||||
ExpectedDolSha256 = $pins.DolSha256
|
||||
ExpectedRelSha256 = $pins.RelSha256
|
||||
|
||||
@@ -117,12 +117,13 @@ function Get-MkwProjectPins([string]$ProjectFile) {
|
||||
}
|
||||
|
||||
function Invoke-Checked([string]$FilePath, [string[]]$Arguments, [string]$Description,
|
||||
[string]$LogPrefix = 'MKWCBUILD', [string]$StepId = '') {
|
||||
[string]$LogPrefix = 'MKWCBUILD', [string]$StepId = '', [bool]$WaitForProcessTree = $true) {
|
||||
<#
|
||||
Runs a build tool and turns a non-zero exit code into a described failure. Start-Process -Wait
|
||||
is deliberate: it waits for the whole process tree, since a .NET single-file bundle host may
|
||||
hand off to an extracted child that PowerShell's call operator would not wait for. Start-Process
|
||||
doesn't publish $LASTEXITCODE, so this sets it manually for callers that check it.
|
||||
Runs a build tool and turns a non-zero exit code into a described failure. By default,
|
||||
Start-Process -Wait waits for the whole process tree, since a .NET single-file bundle host may
|
||||
hand off to an extracted child that PowerShell's call operator would not wait for. Callers that
|
||||
need to avoid waiting on unrelated descendants can opt into the call-operator path.
|
||||
Start-Process doesn't publish $LASTEXITCODE, so this sets it manually for callers that check it.
|
||||
-StepId emits the machine-readable form the installer's progress bar consumes (BuildStepIds in
|
||||
WiiCompiled.Setup/InstallProgress.cs); the human sentence stays on the same log line.
|
||||
#>
|
||||
@@ -132,9 +133,14 @@ function Invoke-Checked([string]$FilePath, [string[]]$Arguments, [string]$Descri
|
||||
if ($_.Contains('"')) { throw "A native build argument contains an unsupported quote: $_" }
|
||||
'"' + $_ + '"'
|
||||
})
|
||||
$process = Start-Process -FilePath $FilePath -ArgumentList $quotedArguments `
|
||||
-NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.ExitCode
|
||||
if ($WaitForProcessTree) {
|
||||
$process = Start-Process -FilePath $FilePath -ArgumentList $quotedArguments `
|
||||
-NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.ExitCode
|
||||
} else {
|
||||
& $FilePath @Arguments
|
||||
$exitCode = $LASTEXITCODE
|
||||
}
|
||||
$global:LASTEXITCODE = $exitCode
|
||||
if ($exitCode -ne 0) { throw "$Description failed with exit code $exitCode." }
|
||||
}
|
||||
|
||||
@@ -136,9 +136,11 @@ try {
|
||||
-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 $configure 'Configuring the production Windows runtime' `
|
||||
-WaitForProcessTree $false
|
||||
Invoke-Checked $cmake @('--build', $nativeBuild, '--target', 'WiiCompiled', '--parallel', "$Parallel") `
|
||||
'Compiling and linking the synthetic product with the full runtime'
|
||||
'Compiling and linking the synthetic product with the full runtime' `
|
||||
-WaitForProcessTree $false
|
||||
Assert-File (Join-Path $nativeBuild 'WiiCompiled.exe') 'Linked synthetic product'
|
||||
} finally {
|
||||
$env:PATH = $oldPath
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>WiiCompiled.Setup.Common.Cli</RootNamespace>
|
||||
<AssemblyName>WiiCompiled.Setup.Common.Cli</AssemblyName>
|
||||
<Version>0.2.29</Version>
|
||||
<Version>0.2.31</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.29</Version>
|
||||
<Version>0.2.31</Version>
|
||||
<Authors>patchzy</Authors>
|
||||
<Product>WiiCompiled</Product>
|
||||
<Description>Shared nodtool/Retro-WFC-payload logic used by both the Windows and Linux installers</Description>
|
||||
|
||||
@@ -3,7 +3,7 @@ namespace WiiCompiled.Setup.Linux;
|
||||
internal static class ProductInfo
|
||||
{
|
||||
public const string Name = "WiiCompiled";
|
||||
public const string Version = "0.2.29";
|
||||
public const string Version = "0.2.31";
|
||||
}
|
||||
|
||||
/// <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.29</Version>
|
||||
<Version>0.2.31</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.29";
|
||||
public const string Version = "0.2.31";
|
||||
|
||||
/// <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.29</Version>
|
||||
<Version>0.2.31</Version>
|
||||
<Authors>patchzy</Authors>
|
||||
<Product>WiiCompiled</Product>
|
||||
<Description>Command-line installer and launcher for WiiCompiled</Description>
|
||||
|
||||
+1
-61
@@ -269,68 +269,8 @@ target_include_directories(mkw_platform PUBLIC "${CMAKE_CURRENT_LIST_DIR}/includ
|
||||
target_compile_features(mkw_platform PUBLIC cxx_std_17)
|
||||
set_target_properties(mkw_platform PROPERTIES UNITY_BUILD OFF)
|
||||
|
||||
# Keep these independent from Aurora's BUILD_TESTING option: they validate the
|
||||
# project's host-platform contracts, not Aurora's third-party test suite.
|
||||
enable_testing()
|
||||
add_executable(mkw_platform_paths_tests "${CMAKE_CURRENT_LIST_DIR}/tests/platform_paths_tests.cpp")
|
||||
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_settings_tests "${CMAKE_CURRENT_LIST_DIR}/tests/nand_settings_tests.cpp")
|
||||
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)
|
||||
|
||||
# The input expression engine is self-contained, so it can be exercised without
|
||||
# linking the runtime or SDL.
|
||||
add_executable(mkw_input_expr_tests
|
||||
"${CMAKE_CURRENT_LIST_DIR}/tests/test_expr.cpp"
|
||||
"${CMAKE_CURRENT_LIST_DIR}/src/input_expr.cpp")
|
||||
target_include_directories(mkw_input_expr_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
|
||||
target_compile_features(mkw_input_expr_tests PRIVATE cxx_std_17)
|
||||
add_test(NAME mkw_input_expr_tests COMMAND mkw_input_expr_tests)
|
||||
|
||||
# HostContext deliberately keeps the platform-specific context primitive out
|
||||
# of fiber_manager.cpp. Exercise the Linux libco handoff directly so future
|
||||
# refactors cannot silently remove its headers, implementation, or link edge.
|
||||
if(MKW_PLATFORM_LINUX)
|
||||
add_executable(mkw_linux_host_context_tests
|
||||
"${CMAKE_CURRENT_LIST_DIR}/tests/host_context_tests.cpp"
|
||||
"${CMAKE_CURRENT_LIST_DIR}/src/host_context.cpp")
|
||||
target_include_directories(mkw_linux_host_context_tests PRIVATE
|
||||
"${CMAKE_CURRENT_LIST_DIR}/include"
|
||||
"${CMAKE_CURRENT_LIST_DIR}/third_party/libco")
|
||||
target_compile_features(mkw_linux_host_context_tests PRIVATE cxx_std_17)
|
||||
target_link_libraries(mkw_linux_host_context_tests PRIVATE mkw::libco)
|
||||
add_test(NAME mkw_linux_host_context_tests COMMAND mkw_linux_host_context_tests)
|
||||
endif()
|
||||
|
||||
if(MKW_PLATFORM_MACOS)
|
||||
# Exercise the Apple Silicon context ABI and the public host-memory
|
||||
# contracts separately from translated products.
|
||||
enable_language(ASM)
|
||||
add_executable(mkw_macos_context_abi_tests
|
||||
"${CMAKE_CURRENT_LIST_DIR}/tests/macos_context_abi_tests.cpp"
|
||||
"${CMAKE_CURRENT_LIST_DIR}/src/platform/macos/co_switch.S")
|
||||
target_compile_features(mkw_macos_context_abi_tests PRIVATE cxx_std_17)
|
||||
add_test(NAME mkw_macos_context_abi_tests COMMAND mkw_macos_context_abi_tests)
|
||||
|
||||
add_executable(mkw_macos_host_context_tests
|
||||
"${CMAKE_CURRENT_LIST_DIR}/tests/host_context_tests.cpp"
|
||||
"${CMAKE_CURRENT_LIST_DIR}/src/host_context.cpp"
|
||||
"${CMAKE_CURRENT_LIST_DIR}/src/platform/macos/co_switch.S")
|
||||
target_include_directories(mkw_macos_host_context_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
|
||||
target_compile_features(mkw_macos_host_context_tests PRIVATE cxx_std_17)
|
||||
add_test(NAME mkw_macos_host_context_tests COMMAND mkw_macos_host_context_tests)
|
||||
|
||||
add_executable(mkw_macos_guest_flat_memory_tests
|
||||
"${CMAKE_CURRENT_LIST_DIR}/tests/macos_guest_flat_memory_tests.cpp"
|
||||
"${CMAKE_CURRENT_LIST_DIR}/src/guest_flat_memory_macos.cpp")
|
||||
target_include_directories(mkw_macos_guest_flat_memory_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
|
||||
target_compile_features(mkw_macos_guest_flat_memory_tests PRIVATE cxx_std_17)
|
||||
add_test(NAME mkw_macos_guest_flat_memory_tests COMMAND mkw_macos_guest_flat_memory_tests)
|
||||
endif()
|
||||
add_subdirectory(tests)
|
||||
|
||||
# The translator emits the complete, content-addressed source graph. Consuming
|
||||
# this one manifest keeps configure independent of the 28k generated function
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <istream>
|
||||
|
||||
namespace RuntimeNandSave {
|
||||
|
||||
enum class Contents { Missing, Blank, Nonzero, Error };
|
||||
enum class ReadAction { Proceed, Missing, Error, RecoveryNeeded };
|
||||
|
||||
// A failed read is not evidence that a save is blank. Check badbit before EOF:
|
||||
// an I/O failure may set both, whereas a successful short final read sets EOF.
|
||||
inline Contents InspectStream(std::istream& input) {
|
||||
if (!input) return Contents::Error;
|
||||
char block[4096];
|
||||
for (;;) {
|
||||
input.read(block, sizeof(block));
|
||||
if (input.bad() || (input.fail() && !input.eof())) return Contents::Error;
|
||||
for (std::streamsize i = 0; i < input.gcount(); ++i) {
|
||||
if (block[i] != 0) return Contents::Nonzero;
|
||||
}
|
||||
if (input.eof()) return Contents::Blank;
|
||||
}
|
||||
}
|
||||
|
||||
inline Contents InspectFile(const std::filesystem::path& path) {
|
||||
std::error_code ec;
|
||||
const auto status = std::filesystem::symlink_status(path, ec);
|
||||
if (ec && ec != std::errc::no_such_file_or_directory) return Contents::Error;
|
||||
if (!std::filesystem::exists(status)) return Contents::Missing;
|
||||
if (!std::filesystem::is_regular_file(path, ec) || ec) return Contents::Error;
|
||||
std::ifstream input(path, std::ios::binary);
|
||||
return InspectStream(input);
|
||||
}
|
||||
|
||||
// Probe only read-only opens of the actual save and its exact write shadow.
|
||||
// No probe writes, removes, or repairs data, and backups are not save aliases.
|
||||
inline ReadAction CheckRead(const std::filesystem::path& path, int mode) {
|
||||
const auto name = path.filename();
|
||||
const bool isMain = name == "rksys.dat";
|
||||
if (mode != 1 || (!isMain && name != "rksys.dat.nandsafe.tmp")) return ReadAction::Proceed;
|
||||
const auto contents = InspectFile(path);
|
||||
if (contents == Contents::Error) return ReadAction::Error;
|
||||
if (contents == Contents::Nonzero) return ReadAction::Proceed;
|
||||
if (isMain) {
|
||||
auto shadow = path;
|
||||
shadow += ".nandsafe.tmp";
|
||||
const auto shadowContents = InspectFile(shadow);
|
||||
if (shadowContents == Contents::Error) return ReadAction::Error;
|
||||
// The next write normally discards an old shadow. Preserve a possible
|
||||
// recovery source when there is no usable original, without promoting
|
||||
// an uncommitted (and potentially incomplete) shadow to the real save.
|
||||
if (shadowContents == Contents::Nonzero) return ReadAction::RecoveryNeeded;
|
||||
}
|
||||
return contents == Contents::Blank ? ReadAction::Missing : ReadAction::Proceed;
|
||||
}
|
||||
|
||||
} // namespace RuntimeNandSave
|
||||
@@ -1,6 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <ctime>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
@@ -9,14 +12,23 @@
|
||||
#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(nandRoot / "title/00000001/00000002/data/setting.txt",
|
||||
std::ios::binary);
|
||||
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;
|
||||
@@ -66,4 +78,143 @@ inline bool HasIdentity(const Settings& settings) {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include <charconv>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string_view>
|
||||
#include <system_error>
|
||||
|
||||
namespace RuntimeScSerial {
|
||||
|
||||
// SCGetProductSN's output is a u32, not a character buffer. DWC loads
|
||||
// that word and formats it with the product code to construct csnum.
|
||||
template <typename RangeValidator, typename WordWriter>
|
||||
uint32_t Write(std::string_view serial, uint32_t address,
|
||||
RangeValidator&& contains, WordWriter&& write32) {
|
||||
if (serial.empty() || serial.size() > 9 ||
|
||||
serial.find_first_not_of("0123456789") != std::string_view::npos) return 0;
|
||||
uint32_t number = 0;
|
||||
const auto parsed = std::from_chars(serial.data(), serial.data() + serial.size(), number);
|
||||
if (parsed.ec != std::errc{} || parsed.ptr != serial.data() + serial.size() ||
|
||||
!address || !contains(address, sizeof(uint32_t))) return 0;
|
||||
write32(address, number);
|
||||
return 1;
|
||||
}
|
||||
|
||||
} // namespace RuntimeScSerial
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "hle_stubs.h"
|
||||
|
||||
#include "console_identity.h"
|
||||
#include "sc_serial_contract.h"
|
||||
#include <cstdlib>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
@@ -97,12 +98,9 @@ PPC_NATIVE_OVERRIDE(801B2424, SCGetProductCode_HLE, uint32_t, (), ());
|
||||
extern "C" uint32_t SCGetProductSN_HLE(uint32_t serialAddress)
|
||||
{
|
||||
const std::string& serial = RuntimeConsoleIdentity::Current().serial;
|
||||
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;
|
||||
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); });
|
||||
}
|
||||
|
||||
PPC_NATIVE_OVERRIDE(801B2460, SCGetProductSN_HLE, uint32_t, (uint32_t serialAddress), (serialAddress));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// Shared state and helpers live in nand_internal.h.
|
||||
|
||||
#include "nand_internal.h"
|
||||
#include "nand_file_ops.h"
|
||||
|
||||
// ============================================================================
|
||||
// Local helpers
|
||||
@@ -93,6 +94,9 @@ 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.
|
||||
@@ -375,11 +379,15 @@ extern "C" int32_t NANDMove_HLE(uint32_t srcPathPtr, uint32_t dstPathPtr) {
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::rename(srcHost, dstHost, ec);
|
||||
NandMove(srcHost, dstHost, ec);
|
||||
if (!ec) {
|
||||
return NAND_RESULT_OK;
|
||||
}
|
||||
|
||||
if (ec == std::errc::file_exists) {
|
||||
return NAND_RESULT_EXISTS;
|
||||
}
|
||||
|
||||
LogNandError("NANDMove", "FAILED error=%d message='%s'", ec.value(), ec.message().c_str());
|
||||
return NAND_RESULT_UNKNOWN;
|
||||
}
|
||||
|
||||
@@ -411,6 +411,8 @@ 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");
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
#include "nand_file_ops.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#elif defined(__linux__)
|
||||
#include <fcntl.h>
|
||||
#include <linux/fs.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <unistd.h>
|
||||
#elif defined(__APPLE__)
|
||||
#include <stdio.h>
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
// Unlike std::filesystem::rename on POSIX, this cannot overwrite a destination
|
||||
// created by another writer between checking it and publishing the move.
|
||||
void RenameNoReplace(const std::filesystem::path& source,
|
||||
const std::filesystem::path& destination, std::error_code& ec) {
|
||||
#ifdef _WIN32
|
||||
if (MoveFileExW(source.c_str(), destination.c_str(), 0)) ec.clear();
|
||||
else ec = std::error_code(GetLastError(), std::system_category());
|
||||
#else
|
||||
#ifdef __linux__
|
||||
const auto result = syscall(SYS_renameat2, AT_FDCWD, source.c_str(),
|
||||
AT_FDCWD, destination.c_str(), RENAME_NOREPLACE);
|
||||
#else
|
||||
const auto result = renamex_np(source.c_str(), destination.c_str(), RENAME_EXCL);
|
||||
#endif
|
||||
if (result == 0) {
|
||||
ec.clear();
|
||||
return;
|
||||
}
|
||||
ec = std::error_code(errno, std::generic_category());
|
||||
if (ec != std::errc::function_not_supported && ec != std::errc::invalid_argument &&
|
||||
ec != std::errc::operation_not_supported) return;
|
||||
|
||||
// Older filesystems may lack exclusive rename. Linking also publishes
|
||||
// without replacement; never fall back to an overwriting rename.
|
||||
std::filesystem::create_hard_link(source, destination, ec);
|
||||
if (!ec) std::filesystem::remove(source, ec);
|
||||
#endif
|
||||
}
|
||||
|
||||
struct StagedMove {
|
||||
std::filesystem::path directory;
|
||||
std::filesystem::path file;
|
||||
~StagedMove() {
|
||||
std::error_code ignored;
|
||||
std::filesystem::remove(file, ignored);
|
||||
std::filesystem::remove(directory, ignored);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// NANDMove does not replace an existing entry. Riivolution save redirects can
|
||||
// put the destination on a different filesystem from the guest's /tmp files.
|
||||
void NandMove(const std::filesystem::path& source, const std::filesystem::path& destination,
|
||||
std::error_code& ec) {
|
||||
namespace fs = std::filesystem;
|
||||
const auto status = fs::symlink_status(destination, ec);
|
||||
if (ec && ec != std::errc::no_such_file_or_directory) return;
|
||||
ec.clear();
|
||||
if (fs::exists(status)) {
|
||||
ec = std::make_error_code(std::errc::file_exists);
|
||||
return;
|
||||
}
|
||||
RenameNoReplace(source, destination, ec);
|
||||
if (ec != std::errc::cross_device_link) return;
|
||||
|
||||
// Do not turn a directory move into a partially completed recursive copy,
|
||||
// or follow a symlink and delete the link after copying its target.
|
||||
const auto sourceStatus = fs::symlink_status(source, ec);
|
||||
if (ec) return;
|
||||
if (!fs::is_regular_file(sourceStatus)) {
|
||||
ec = std::make_error_code(std::errc::cross_device_link);
|
||||
return;
|
||||
}
|
||||
|
||||
static std::atomic<unsigned long long> sequence{0};
|
||||
const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count();
|
||||
fs::path stagingDirectory;
|
||||
bool created = false;
|
||||
for (int attempt = 0; attempt < 64; ++attempt) {
|
||||
stagingDirectory = destination.parent_path() /
|
||||
(".nand-move-" + std::to_string(stamp) + "-" + std::to_string(sequence++));
|
||||
created = fs::create_directory(stagingDirectory, ec);
|
||||
if (created) break;
|
||||
if (ec && ec != std::errc::file_exists) return;
|
||||
}
|
||||
if (!created) {
|
||||
ec = std::make_error_code(std::errc::file_exists);
|
||||
return;
|
||||
}
|
||||
|
||||
const StagedMove staging{stagingDirectory, stagingDirectory / "data"};
|
||||
const auto& staged = staging.file;
|
||||
fs::copy_file(source, staged, fs::copy_options::none, ec);
|
||||
if (!ec) {
|
||||
// Publication is a same-filesystem rename: readers never see a partial
|
||||
// copy. Keep the source until the complete destination is in place.
|
||||
RenameNoReplace(staged, destination, ec);
|
||||
if (!ec) fs::remove(source, ec);
|
||||
}
|
||||
// A failed source removal leaves both complete copies and reports failure.
|
||||
// Staging cleanup preserves ec, including on exceptions.
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <system_error>
|
||||
|
||||
// Move without replacing an existing entry. Cross-filesystem regular files are
|
||||
// staged at the destination before removing the source. On failure the source
|
||||
// remains; a failed source removal can leave both complete copies.
|
||||
// The caller must keep the source stable for the duration of the move; NAND HLE
|
||||
// calls run synchronously on the cooperative guest thread without yielding.
|
||||
void NandMove(const std::filesystem::path& source, const std::filesystem::path& destination,
|
||||
std::error_code& ec);
|
||||
@@ -411,6 +411,26 @@ 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()) {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#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"
|
||||
@@ -26,6 +27,7 @@
|
||||
#include <deque>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
@@ -56,6 +58,11 @@ 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
|
||||
// ============================================================================
|
||||
|
||||
@@ -391,6 +391,9 @@ 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.
|
||||
|
||||
@@ -9,11 +9,24 @@
|
||||
#include <unordered_map>
|
||||
|
||||
namespace InputExpr {
|
||||
#ifdef MKW_INPUT_EXPR_TEST_CLOCK
|
||||
// Only the standalone test target supplies this clock; runtime builds use the
|
||||
// steady clock directly, with no mutable override or extra runtime state.
|
||||
std::chrono::steady_clock::time_point TestClockNow();
|
||||
#endif
|
||||
namespace {
|
||||
|
||||
using Clock = std::chrono::steady_clock;
|
||||
using FSec = std::chrono::duration<double>;
|
||||
|
||||
static Clock::time_point Now() {
|
||||
#ifdef MKW_INPUT_EXPR_TEST_CLOCK
|
||||
return TestClockNow();
|
||||
#else
|
||||
return Clock::now();
|
||||
#endif
|
||||
}
|
||||
|
||||
enum class Kind {
|
||||
Literal, Input, Not, Add, Sub, Mul, Div, And, Or, Xor,
|
||||
Greater, Less, Equal,
|
||||
@@ -56,7 +69,7 @@ struct Node {
|
||||
mutable bool state = false;
|
||||
mutable unsigned taps = 0;
|
||||
mutable double value = 0.0;
|
||||
mutable Clock::time_point mark = Clock::now();
|
||||
mutable Clock::time_point mark = Now();
|
||||
mutable bool marked = false;
|
||||
};
|
||||
|
||||
@@ -400,7 +413,7 @@ double Eval(const Node& node, const InputSource& source) {
|
||||
return std::copysign(std::max(0.0, std::abs(v) - dz) / (1.0 - dz), v);
|
||||
}
|
||||
case Kind::FnTimer: {
|
||||
const auto now = Clock::now();
|
||||
const auto now = Now();
|
||||
if (!node.marked) {
|
||||
node.mark = now;
|
||||
node.marked = true;
|
||||
@@ -431,7 +444,7 @@ double Eval(const Node& node, const InputSource& source) {
|
||||
return node.state ? 1.0 : 0.0;
|
||||
}
|
||||
case Kind::FnHold: {
|
||||
const auto now = Clock::now();
|
||||
const auto now = Now();
|
||||
if (!node.marked) {
|
||||
node.mark = now;
|
||||
node.marked = true;
|
||||
@@ -448,7 +461,7 @@ double Eval(const Node& node, const InputSource& source) {
|
||||
return node.state ? 1.0 : 0.0;
|
||||
}
|
||||
case Kind::FnTap: {
|
||||
const auto now = Clock::now();
|
||||
const auto now = Now();
|
||||
if (!node.marked) {
|
||||
node.mark = now;
|
||||
node.marked = true;
|
||||
@@ -480,7 +493,7 @@ double Eval(const Node& node, const InputSource& source) {
|
||||
return desired == node.taps ? 1.0 : 0.0;
|
||||
}
|
||||
case Kind::FnPulse: {
|
||||
const auto now = Clock::now();
|
||||
const auto now = Now();
|
||||
const double input = Arg(node, 0, source);
|
||||
if (input < kConditionThreshold) {
|
||||
node.released = true;
|
||||
@@ -502,7 +515,7 @@ double Eval(const Node& node, const InputSource& source) {
|
||||
return node.state ? 1.0 : 0.0;
|
||||
}
|
||||
case Kind::FnSmooth: {
|
||||
const auto now = Clock::now();
|
||||
const auto now = Now();
|
||||
if (!node.marked) {
|
||||
node.mark = now;
|
||||
node.marked = true;
|
||||
|
||||
@@ -46,6 +46,13 @@ std::optional<std::filesystem::path> ExecutableDirectory() noexcept {
|
||||
std::error_code ec;
|
||||
const auto resolved = std::filesystem::weakly_canonical(path, ec);
|
||||
return (ec ? std::filesystem::path(path) : resolved).parent_path();
|
||||
#elif defined(__linux__)
|
||||
std::error_code ec;
|
||||
const auto executable = std::filesystem::read_symlink("/proc/self/exe", ec);
|
||||
if (ec) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return executable.parent_path();
|
||||
#else
|
||||
return std::nullopt;
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
# Share the same test registrations with the full runtime build, while allowing
|
||||
# CI and developers to run them without translated game files or Aurora.
|
||||
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
|
||||
project(WiiCompiledRuntimeTests LANGUAGES C CXX)
|
||||
enable_testing()
|
||||
endif()
|
||||
|
||||
get_filename_component(runtime_root "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE)
|
||||
find_package(Threads REQUIRED)
|
||||
|
||||
function(mkw_add_test name)
|
||||
add_executable(${name} ${ARGN})
|
||||
target_include_directories(${name} PRIVATE "${runtime_root}/include")
|
||||
target_compile_features(${name} PRIVATE cxx_std_17)
|
||||
target_link_libraries(${name} PRIVATE Threads::Threads)
|
||||
add_test(NAME ${name} COMMAND ${name})
|
||||
endfunction()
|
||||
|
||||
if(NOT TARGET mkw_platform)
|
||||
add_library(mkw_platform STATIC "${runtime_root}/src/platform/host_platform.cpp")
|
||||
target_include_directories(mkw_platform PUBLIC "${runtime_root}/include")
|
||||
target_compile_features(mkw_platform PUBLIC cxx_std_17)
|
||||
endif()
|
||||
if(WIN32)
|
||||
target_link_libraries(mkw_platform PUBLIC shell32 ole32)
|
||||
endif()
|
||||
mkw_add_test(mkw_platform_paths_tests platform_paths_tests.cpp)
|
||||
target_link_libraries(mkw_platform_paths_tests PRIVATE mkw_platform)
|
||||
|
||||
mkw_add_test(mkw_nand_save_tests nand_save_tests.cpp)
|
||||
mkw_add_test(mkw_nand_move_tests nand_move_tests.cpp
|
||||
"${runtime_root}/src/hle/storage/nand_file_ops.cpp")
|
||||
target_include_directories(mkw_nand_move_tests PRIVATE "${runtime_root}/src/hle/storage")
|
||||
mkw_add_test(mkw_nand_settings_tests nand_settings_tests.cpp)
|
||||
mkw_add_test(mkw_sc_serial_tests sc_serial_tests.cpp)
|
||||
mkw_add_test(mkw_input_expr_tests test_expr.cpp "${runtime_root}/src/input_expr.cpp")
|
||||
target_compile_definitions(mkw_input_expr_tests PRIVATE MKW_INPUT_EXPR_TEST_CLOCK)
|
||||
|
||||
if(WIN32)
|
||||
mkw_add_test(mkw_windows_host_context_tests host_context_tests.cpp
|
||||
"${runtime_root}/src/host_context.cpp")
|
||||
elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux")
|
||||
if(NOT TARGET mkw::libco)
|
||||
add_library(mkw_libco STATIC "${runtime_root}/third_party/libco/libco.c")
|
||||
add_library(mkw::libco ALIAS mkw_libco)
|
||||
target_include_directories(mkw_libco PUBLIC "${runtime_root}/third_party/libco")
|
||||
endif()
|
||||
mkw_add_test(mkw_linux_host_context_tests host_context_tests.cpp
|
||||
"${runtime_root}/src/host_context.cpp")
|
||||
target_link_libraries(mkw_linux_host_context_tests PRIVATE mkw::libco)
|
||||
elseif(APPLE)
|
||||
if(NOT CMAKE_SYSTEM_PROCESSOR MATCHES "^(arm64|ARM64|aarch64)$")
|
||||
message(FATAL_ERROR "The macOS runtime tests require Apple Silicon")
|
||||
endif()
|
||||
enable_language(ASM)
|
||||
set(context_asm "${runtime_root}/src/platform/macos/co_switch.S")
|
||||
mkw_add_test(mkw_macos_context_abi_tests macos_context_abi_tests.cpp "${context_asm}")
|
||||
mkw_add_test(mkw_macos_host_context_tests host_context_tests.cpp
|
||||
"${runtime_root}/src/host_context.cpp" "${context_asm}")
|
||||
mkw_add_test(mkw_macos_guest_flat_memory_tests macos_guest_flat_memory_tests.cpp
|
||||
"${runtime_root}/src/guest_flat_memory_macos.cpp")
|
||||
endif()
|
||||
@@ -0,0 +1,181 @@
|
||||
#include "nand_file_ops.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
#include <iterator>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
|
||||
#ifdef __linux__
|
||||
#include <csignal>
|
||||
#include <sys/resource.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
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& data) {
|
||||
std::ofstream output(path, std::ios::binary);
|
||||
output << data;
|
||||
output.close();
|
||||
Require(bool(output), "Fixture write failed");
|
||||
}
|
||||
|
||||
static std::string Read(const fs::path& path) {
|
||||
std::ifstream input(path, std::ios::binary);
|
||||
Require(bool(input), "Fixture read failed");
|
||||
return {std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
|
||||
}
|
||||
|
||||
// Competing moves must have exactly one winner, retain the losing source, and
|
||||
// publish the winner's complete contents. Exercise both direct and staged moves.
|
||||
static void CheckCompetingMoves(const fs::path& sourceRoot, const fs::path& destinationRoot) {
|
||||
const auto left = sourceRoot / "race-left";
|
||||
const auto right = sourceRoot / "race-right";
|
||||
const auto target = destinationRoot / "race-target";
|
||||
const std::string leftBytes(65536, 'L'), rightBytes(65536, 'R');
|
||||
for (int attempt = 0; attempt < 128; ++attempt) {
|
||||
Write(left, leftBytes);
|
||||
Write(right, rightBytes);
|
||||
std::atomic<int> ready{0};
|
||||
std::error_code leftError, rightError;
|
||||
auto move = [&](const fs::path& source, std::error_code& ec) {
|
||||
++ready;
|
||||
while (ready.load() != 2) std::this_thread::yield();
|
||||
NandMove(source, target, ec);
|
||||
};
|
||||
std::thread first(move, std::cref(left), std::ref(leftError));
|
||||
std::thread second(move, std::cref(right), std::ref(rightError));
|
||||
first.join();
|
||||
second.join();
|
||||
Require(bool(leftError) != bool(rightError), "Competing moves must have exactly one winner");
|
||||
const bool leftWon = !leftError;
|
||||
Require((leftWon ? rightError : leftError) == std::errc::file_exists,
|
||||
"Losing move must report an existing destination");
|
||||
Require(Read(target) == (leftWon ? leftBytes : rightBytes), "Winner's contents were overwritten");
|
||||
Require(!fs::exists(leftWon ? left : right), "Winning source was not removed");
|
||||
Require(Read(leftWon ? right : left) == (leftWon ? rightBytes : leftBytes),
|
||||
"Losing source must remain intact");
|
||||
fs::remove(leftWon ? right : left);
|
||||
fs::remove(target);
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
const auto name = "wiicomp-nand-move-" +
|
||||
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count());
|
||||
const auto root = fs::temp_directory_path() / name;
|
||||
fs::path other;
|
||||
try {
|
||||
fs::create_directory(root);
|
||||
const auto source = root / "banner.bin";
|
||||
const auto destination = root / "moved.bin";
|
||||
std::error_code ec;
|
||||
Write(source, "banner");
|
||||
NandMove(source, destination, ec);
|
||||
Require(!ec && !fs::exists(source) && Read(destination) == "banner", "Same-device move failed");
|
||||
CheckCompetingMoves(root, root);
|
||||
Write(source, "keep source");
|
||||
NandMove(source, destination, ec);
|
||||
Require(ec == std::errc::file_exists && Read(source) == "keep source" && Read(destination) == "banner",
|
||||
"Existing destination must not be overwritten");
|
||||
NandMove(root / "missing", root / "absent", ec);
|
||||
Require(bool(ec) && !fs::exists(root / "absent"), "Missing source must fail");
|
||||
NandMove(source, root / "missing-parent/file", ec);
|
||||
Require(bool(ec) && Read(source) == "keep source", "Missing parent must preserve source");
|
||||
fs::create_directory(root / "directory");
|
||||
Write(root / "directory/child", "child");
|
||||
NandMove(root / "directory", root / "renamed-directory", ec);
|
||||
Require(!ec && Read(root / "renamed-directory/child") == "child", "Same-device directory move regressed");
|
||||
|
||||
#ifndef _WIN32
|
||||
fs::create_symlink(root / "missing", root / "same-link");
|
||||
NandMove(root / "same-link", root / "moved-link", ec);
|
||||
Require(!ec && fs::is_symlink(fs::symlink_status(root / "moved-link")) &&
|
||||
!fs::is_symlink(fs::symlink_status(root / "same-link")), "Same-device symlink move regressed");
|
||||
#endif
|
||||
|
||||
#ifdef __linux__
|
||||
// /dev/shm is a separate tmpfs on ordinary Linux systems, including CI
|
||||
// and WSL. Fail rather than silently passing without exercising EXDEV.
|
||||
other = fs::path("/dev/shm") / name;
|
||||
fs::create_directory(other);
|
||||
struct stat left{}, right{};
|
||||
Require(::stat(root.c_str(), &left) == 0 && ::stat(other.c_str(), &right) == 0 && left.st_dev != right.st_dev,
|
||||
"Cross-device test requires /tmp and /dev/shm on separate filesystems");
|
||||
CheckCompetingMoves(root, other);
|
||||
Require(fs::is_empty(other), "Competing moves left staging files behind");
|
||||
const auto target = other / "banner.bin";
|
||||
const std::string bytes = std::string(8192, '\0') + "banner payload";
|
||||
Write(source, bytes);
|
||||
fs::rename(source, target, ec);
|
||||
Require(ec == std::errc::cross_device_link, "Fixture must reproduce the original EXDEV failure");
|
||||
NandMove(source, target, ec);
|
||||
Require(!ec && !fs::exists(source) && Read(target) == bytes, "Cross-device move must preserve every byte");
|
||||
|
||||
Write(source, "do not overwrite");
|
||||
NandMove(source, target, ec);
|
||||
Require(ec == std::errc::file_exists && Read(source) == "do not overwrite" && Read(target) == bytes,
|
||||
"Cross-device move must preserve an existing destination");
|
||||
fs::remove(target);
|
||||
fs::create_symlink(other / "missing", target);
|
||||
NandMove(source, target, ec);
|
||||
Require(ec == std::errc::file_exists && fs::is_symlink(target) && Read(source) == "do not overwrite",
|
||||
"Dangling destination symlink must not be replaced");
|
||||
fs::remove(target);
|
||||
|
||||
NandMove(root / "renamed-directory", other / "directory", ec);
|
||||
Require(ec == std::errc::cross_device_link && Read(root / "renamed-directory/child") == "child" &&
|
||||
!fs::exists(other / "directory"), "Unsupported directory move must leave source intact");
|
||||
fs::create_symlink(source, root / "link");
|
||||
NandMove(root / "link", other / "link", ec);
|
||||
Require(ec == std::errc::cross_device_link && fs::is_symlink(root / "link") && !fs::exists(other / "link"),
|
||||
"Cross-device source symlinks must not be dereferenced");
|
||||
|
||||
// Force a real write failure after a partial copy without filling disk.
|
||||
Write(source, bytes);
|
||||
struct rlimit saved{}, limited{};
|
||||
Require(getrlimit(RLIMIT_FSIZE, &saved) == 0, "Cannot read file-size limit");
|
||||
limited = saved;
|
||||
limited.rlim_cur = 1024;
|
||||
const auto oldHandler = std::signal(SIGXFSZ, SIG_IGN);
|
||||
Require(setrlimit(RLIMIT_FSIZE, &limited) == 0, "Cannot set file-size limit");
|
||||
NandMove(source, target, ec);
|
||||
const auto copyError = ec;
|
||||
const auto restored = setrlimit(RLIMIT_FSIZE, &saved);
|
||||
std::signal(SIGXFSZ, oldHandler);
|
||||
Require(restored == 0, "Cannot restore file-size limit");
|
||||
Require(bool(copyError) && Read(source) == bytes && !fs::exists(target) && fs::is_empty(other),
|
||||
"Failed copy must retain source and remove partial staging files");
|
||||
|
||||
Require(geteuid() != 0, "Run permission tests as an unprivileged user");
|
||||
fs::permissions(root, fs::perms::owner_read | fs::perms::owner_exec);
|
||||
NandMove(source, target, ec);
|
||||
fs::permissions(root, fs::perms::owner_all);
|
||||
Require(bool(ec) && Read(source) == bytes && Read(target) == bytes,
|
||||
"Failed source deletion must leave both complete copies");
|
||||
Require(std::distance(fs::directory_iterator(other), fs::directory_iterator{}) == 1,
|
||||
"Move must clean up staging directory");
|
||||
fs::remove_all(other);
|
||||
#endif
|
||||
fs::remove_all(root);
|
||||
std::cout << "NAND move tests passed\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << '\n';
|
||||
std::error_code ignored;
|
||||
fs::permissions(root, fs::perms::owner_all, ignored);
|
||||
fs::remove_all(root, ignored);
|
||||
if (!other.empty()) fs::remove_all(other, ignored);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
#include "nand_save_probe.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <iostream>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#endif
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
using RuntimeNandSave::ReadAction;
|
||||
using RuntimeNandSave::Contents;
|
||||
|
||||
static void Require(bool condition, const char* message) {
|
||||
if (!condition) throw std::runtime_error(message);
|
||||
}
|
||||
|
||||
static void Write(const fs::path& path, const std::string& bytes) {
|
||||
fs::create_directories(path.parent_path());
|
||||
std::ofstream output(path, std::ios::binary);
|
||||
output.write(bytes.data(), bytes.size());
|
||||
output.close();
|
||||
Require(static_cast<bool>(output), "Fixture write failed");
|
||||
}
|
||||
|
||||
static std::string Read(const fs::path& path) {
|
||||
std::ifstream input(path, std::ios::binary);
|
||||
Require(static_cast<bool>(input), "Fixture read failed");
|
||||
return {std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
|
||||
}
|
||||
|
||||
// A disk error after zero-filled blocks must not look like a blank file's EOF.
|
||||
class FailingDisk : public std::streambuf {
|
||||
int blocks;
|
||||
public:
|
||||
explicit FailingDisk(int zeroBlocks) : blocks(zeroBlocks) {}
|
||||
std::streamsize xsgetn(char* buffer, std::streamsize length) override {
|
||||
if (blocks-- <= 0) throw std::runtime_error("injected read failure");
|
||||
std::fill(buffer, buffer + length, '\0');
|
||||
return length;
|
||||
}
|
||||
};
|
||||
|
||||
int main() {
|
||||
const auto root = fs::temp_directory_path() / ("wiicomp-save-scenarios-" +
|
||||
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
try {
|
||||
const auto save = root / "title/00010004/524d4350/data/rksys.dat";
|
||||
const auto shadow = fs::path(save.native() + fs::path(".nandsafe.tmp").native());
|
||||
// Save inspection must leave unrelated NAND data alone. Settings
|
||||
// initialization is covered separately by nand_settings_tests.
|
||||
const auto settingsPath = root / "title/00000001/00000002/data/setting.txt";
|
||||
const std::string identity(256, '\x5a');
|
||||
Write(settingsPath, identity);
|
||||
Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::Proceed, "Fresh profile follows normal missing-file handling");
|
||||
Require(!fs::exists(save), "Probing fresh profile must not create a save");
|
||||
|
||||
// First launch interrupted before save initialization, including block
|
||||
// boundaries and a full-sized synthetic zero-filled allocation.
|
||||
for (const size_t size : {size_t(0), size_t(1), size_t(4095), size_t(4096), size_t(4097), size_t(3 * 1024 * 1024)}) {
|
||||
const std::string bytes(size, '\0');
|
||||
Write(save, bytes);
|
||||
Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::Missing, "Blank save should be offered first-save recovery");
|
||||
Require(Read(save) == bytes, "Blank-save detection must not modify the file");
|
||||
for (int mode : {2, 3}) {
|
||||
Require(RuntimeNandSave::CheckRead(save, mode) == ReadAction::Proceed, "Write opens must remain available for initialization");
|
||||
}
|
||||
}
|
||||
|
||||
// Existing saves, imported saves, partial/corrupt saves, and a zero
|
||||
// prefix with data only in the final byte are all left to the game.
|
||||
std::string existing(3 * 1024 * 1024, '\0');
|
||||
existing.replace(0, 8, "RKSD0006");
|
||||
existing[10000] = 42;
|
||||
for (const std::string& bytes : {existing, std::string("RKSD"), std::string("damaged-header"),
|
||||
std::string(8192, '\0') + "x", std::string(8191, '\0') + "x"}) {
|
||||
Write(save, bytes);
|
||||
Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::Proceed, "Never hide a save containing any data");
|
||||
Require(Read(save) == bytes, "Existing/partial save must be byte-identical after inspection");
|
||||
}
|
||||
|
||||
// Interrupted replacement: retain a committed original regardless of
|
||||
// whether the shadow is blank, partial, or contains a complete header.
|
||||
Write(save, existing);
|
||||
for (const std::string& bytes : {std::string(), std::string(4096, '\0'), std::string("RKSD"), existing}) {
|
||||
Write(shadow, bytes);
|
||||
Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::Proceed, "Committed original takes precedence over write shadow");
|
||||
Require(Read(save) == existing && Read(shadow) == bytes, "Probe must preserve both sides of an interrupted write");
|
||||
}
|
||||
// No usable original: do not let missing-save recovery discard the
|
||||
// only possible recovery source, and do not auto-promote that shadow.
|
||||
for (const bool mainExists : {false, true}) {
|
||||
fs::remove(save);
|
||||
if (mainExists) Write(save, std::string(4096, '\0'));
|
||||
Write(shadow, existing);
|
||||
Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::RecoveryNeeded, "Preserve recovery candidate when original is missing or blank");
|
||||
Require(Read(shadow) == existing, "Recovery candidate must remain unchanged");
|
||||
Require(fs::exists(save) == mainExists, "Do not promote shadow automatically");
|
||||
}
|
||||
Write(shadow, std::string(4096, '\0'));
|
||||
Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::Missing, "Two blank files may use first-save recovery");
|
||||
fs::remove(shadow);
|
||||
|
||||
for (const char* name : {"rksys.dat.bak", "rksys.dat.backup", "rksys.dat2", "banner.bin", "setting.txt"}) {
|
||||
const auto unrelated = save.parent_path() / name;
|
||||
Write(unrelated, std::string(4096, '\0'));
|
||||
Require(RuntimeNandSave::CheckRead(unrelated, 1) == ReadAction::Proceed, "Do not classify backups or unrelated files as missing saves");
|
||||
}
|
||||
for (int blocks : {0, 1, 2}) {
|
||||
FailingDisk disk(blocks);
|
||||
std::istream input(&disk);
|
||||
Require(RuntimeNandSave::InspectStream(input) == Contents::Error, "Read failure must remain an error, including after zero-filled blocks");
|
||||
}
|
||||
std::istringstream badEof;
|
||||
badEof.setstate(std::ios::badbit | std::ios::eofbit);
|
||||
Require(RuntimeNandSave::InspectStream(badEof) == Contents::Error, "Badbit plus EOF must not imply a blank save");
|
||||
|
||||
#ifdef _WIN32
|
||||
Write(save, existing);
|
||||
const HANDLE locked = CreateFileW(save.c_str(), GENERIC_READ, 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
Require(locked != INVALID_HANDLE_VALUE, "Could not lock fixture");
|
||||
const auto lockedResult = RuntimeNandSave::CheckRead(save, 1);
|
||||
CloseHandle(locked);
|
||||
Require(lockedResult == ReadAction::Error, "Sharing/access failure must not report a missing save");
|
||||
Require(Read(save) == existing, "Locked save must survive inspection unchanged");
|
||||
Require(SetFileAttributesW(save.c_str(), FILE_ATTRIBUTE_READONLY) != 0, "Set fixture read-only");
|
||||
const auto readOnlyResult = RuntimeNandSave::CheckRead(save, 1);
|
||||
SetFileAttributesW(save.c_str(), FILE_ATTRIBUTE_NORMAL);
|
||||
Require(readOnlyResult == ReadAction::Proceed && Read(save) == existing, "Readable read-only save remains available");
|
||||
#endif
|
||||
Require(Read(settingsPath) == identity, "Save inspection must not change NAND settings");
|
||||
fs::remove_all(root);
|
||||
std::cout << "NAND save startup, preservation, interrupted-write and I/O failure scenarios passed\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << " (fixtures retained at " << root << ")\n";
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -3,22 +3,78 @@
|
||||
#include <chrono>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
static void Require(bool condition) {
|
||||
static void Require(bool condition, const char* message = "NAND settings check failed") {
|
||||
if (!condition) {
|
||||
throw std::runtime_error("NAND settings check failed");
|
||||
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) {
|
||||
@@ -35,6 +91,7 @@ int main() {
|
||||
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);
|
||||
@@ -54,6 +111,67 @@ int main() {
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
#include "sc_serial_contract.h"
|
||||
#include "nand_settings.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <iostream>
|
||||
#include <iomanip>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
static void Require(bool condition, const char* message) {
|
||||
if (!condition) throw std::runtime_error(message);
|
||||
}
|
||||
|
||||
static uint32_t ReadWord(const unsigned char* bytes) {
|
||||
return (uint32_t(bytes[0]) << 24) | (uint32_t(bytes[1]) << 16) |
|
||||
(uint32_t(bytes[2]) << 8) | uint32_t(bytes[3]);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
try {
|
||||
// Feed real generator + SC ABI outputs to the upstream bot decoder.
|
||||
// Usage: mkw_sc_serial_tests --timestamp-vectors <unix-seconds> ...
|
||||
if (argc > 1 && std::string(argv[1]) == "--timestamp-vectors") {
|
||||
for (int i = 2; i < argc; ++i) {
|
||||
const auto timestamp = std::stoll(argv[i]);
|
||||
const auto serial = RuntimeNandSettings::GenerateSerial(static_cast<std::time_t>(timestamp));
|
||||
std::array<unsigned char, 4> output{};
|
||||
Require(RuntimeScSerial::Write(serial, 4,
|
||||
[](uint32_t address, size_t size) { return address == 4 && size == 4; },
|
||||
[&](uint32_t, uint32_t value) {
|
||||
for (unsigned j = 0; j < 4; ++j)
|
||||
output[j] = static_cast<unsigned char>(value >> (24 - 8 * j));
|
||||
}) == 1, "Generated serial must pass SC ABI");
|
||||
std::cout << timestamp << '\t' << serial << "\tLEH"
|
||||
<< std::setfill('0') << std::setw(9) << ReadWord(output.data()) << '\n';
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
// Reproduce the reported csnums from the old string-writing override.
|
||||
const unsigned char old7886[] = {'7', '8', '8', '6'};
|
||||
const unsigned char old7618[] = {'7', '6', '1', '8'};
|
||||
Require(ReadWord(old7886) == 926431286, "Reproduce shared LEH926431286");
|
||||
Require(ReadWord(old7618) == 926298424, "Reproduce shared LEH926298424");
|
||||
|
||||
std::array<unsigned char, 16> memory;
|
||||
size_t available = 4;
|
||||
unsigned writes = 0;
|
||||
const auto contains = [&](uint32_t address, size_t size) {
|
||||
return address == 4 && size <= available;
|
||||
};
|
||||
const auto write32 = [&](uint32_t address, uint32_t value) {
|
||||
++writes;
|
||||
for (unsigned i = 0; i < 4; ++i)
|
||||
memory[address + i] = static_cast<unsigned char>(value >> (24 - 8 * i));
|
||||
};
|
||||
for (const auto& pair : {std::pair{"788600001", 788600001u}, {"788699999", 788699999u},
|
||||
{"761800001", 761800001u}, {"761899999", 761899999u},
|
||||
{"012345678", 12345678u}, {"000000001", 1u}, {"999999999", 999999999u}}) {
|
||||
memory.fill(0xa5);
|
||||
writes = 0;
|
||||
Require(RuntimeScSerial::Write(pair.first, 4, contains, write32) == 1, "Accept an exactly four-byte output buffer");
|
||||
Require(writes == 1 && ReadWord(memory.data() + 4) == pair.second, "Return full numeric serial, including digits after common prefix");
|
||||
for (size_t i = 0; i < memory.size(); ++i)
|
||||
if (i < 4 || i >= 8) Require(memory[i] == 0xa5, "Do not overwrite adjacent guest stack data");
|
||||
}
|
||||
for (const char* serial : {"", "1234567890", "7886x1234", "-12345678", "+12345678"}) {
|
||||
writes = 0;
|
||||
Require(RuntimeScSerial::Write(serial, 4, contains, write32) == 0 && writes == 0, "Reject malformed serial without a write");
|
||||
}
|
||||
writes = 0;
|
||||
Require(RuntimeScSerial::Write("788600001", 0, contains, write32) == 0 && writes == 0, "Reject null output");
|
||||
available = 3;
|
||||
Require(RuntimeScSerial::Write("788600001", 4, contains, write32) == 0 && writes == 0, "Reject undersized output");
|
||||
std::cout << "SC serial collision reproduction, numeric output and memory-boundary tests passed\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,14 @@
|
||||
#include <cstdio>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#ifndef MKW_INPUT_EXPR_TEST_CLOCK
|
||||
#error "Build this test through CMake so the deterministic clock is enabled"
|
||||
#endif
|
||||
|
||||
static std::chrono::steady_clock::time_point g_now{};
|
||||
namespace InputExpr {
|
||||
std::chrono::steady_clock::time_point TestClockNow() { return g_now; }
|
||||
}
|
||||
|
||||
static int g_failures = 0;
|
||||
static std::map<std::string, double> g_inputs;
|
||||
@@ -41,7 +48,7 @@ static bool Pressed(const InputExpr::Expression& e) {
|
||||
return e.Evaluate(Source()) > InputExpr::kConditionThreshold;
|
||||
}
|
||||
|
||||
static void Sleep(int ms) { std::this_thread::sleep_for(std::chrono::milliseconds(ms)); }
|
||||
static void AdvanceTime(int ms) { g_now += std::chrono::milliseconds(ms); }
|
||||
|
||||
int main() {
|
||||
std::printf("Dolphin expression engine\n");
|
||||
@@ -82,7 +89,7 @@ int main() {
|
||||
auto hold = Compile("hold(`H`, 0.05)");
|
||||
g_inputs["H"] = 1.0;
|
||||
Check(!Pressed(hold), "hold not satisfied immediately");
|
||||
Sleep(70);
|
||||
AdvanceTime(70);
|
||||
Check(Pressed(hold), "hold satisfied after the interval");
|
||||
g_inputs["H"] = 0.0;
|
||||
Check(!Pressed(hold), "hold clears on release");
|
||||
@@ -93,7 +100,7 @@ int main() {
|
||||
pulse.Evaluate(Source());
|
||||
g_inputs["P"] = 1.0;
|
||||
Check(Pressed(pulse), "pulse fires on rising edge");
|
||||
Sleep(80);
|
||||
AdvanceTime(80);
|
||||
Check(!Pressed(pulse), "pulse expires");
|
||||
|
||||
// The timing-window idiom seen in shared Dolphin configs.
|
||||
@@ -102,9 +109,9 @@ int main() {
|
||||
window.Evaluate(Source());
|
||||
g_inputs["W"] = 1.0;
|
||||
Check(!Pressed(window), "window closed before its start");
|
||||
Sleep(90);
|
||||
AdvanceTime(90);
|
||||
Check(Pressed(window), "window open between the two pulses");
|
||||
Sleep(90);
|
||||
AdvanceTime(90);
|
||||
Check(!Pressed(window), "window closed after its end");
|
||||
|
||||
// timer ramps 0..1 and wraps, so a threshold turns it into a square wave.
|
||||
@@ -114,7 +121,7 @@ int main() {
|
||||
int low = 0;
|
||||
for (int i = 0; i < 40; ++i) {
|
||||
(Pressed(timer) ? high : low)++;
|
||||
Sleep(5);
|
||||
AdvanceTime(5);
|
||||
}
|
||||
Check(high > 5 && low > 5, "timer alternates high and low");
|
||||
|
||||
@@ -130,7 +137,7 @@ int main() {
|
||||
high = low = 0;
|
||||
for (int i = 0; i < 60; ++i) {
|
||||
(Pressed(dolphinLine) ? high : low)++;
|
||||
Sleep(2);
|
||||
AdvanceTime(2);
|
||||
}
|
||||
Check(high > 5 && low > 5, "LB alternates via timer(0.01)");
|
||||
|
||||
@@ -192,7 +199,7 @@ int main() {
|
||||
auto sm = Compile("smooth(`A`, 0)");
|
||||
g_inputs["A"] = 1.0;
|
||||
sm.Evaluate(Source());
|
||||
Sleep(5);
|
||||
AdvanceTime(5);
|
||||
Check(std::isfinite(sm.Evaluate(Source())), "smooth with a zero rate stays finite");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user