import setting.txt (#169)

* import setting.txt

* coderabbit ugh
This commit is contained in:
patchzyy
2026-09-06 11:14:54 +02:00
committed by GitHub
parent 8e57cc162f
commit 2d9dc4e0f2
4 changed files with 289 additions and 5 deletions
+2
View File
@@ -278,6 +278,8 @@ 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")
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)
+14 -1
View File
@@ -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
+153 -2
View File
@@ -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
+120 -2
View File
@@ -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;