mirror of
https://github.com/patchzyy/wiicompiled
synced 2026-09-10 09:11:52 -04:00
Treat empty MKW save as missing rather than corrupt (#168)
* treat empty mkw save as missing first-run format zero-fills rksys.dat before any real save; a quit before the first save left an all-zero file that read back as corrupt and trapped the user in a delete/recreate loop. read opens now treat an all-zero rksys.dat as absent (a real save always begins with the RKSD0006 header), so the game recreates it from scratch. also ignore native build output. * shorten * I dont really want to change this to be honest. * extra safety --------- Co-authored-by: patchzyy <64382339+patchzyy@users.noreply.github.com>
This commit is contained in:
@@ -26,6 +26,8 @@ Code.pul
|
||||
/build/
|
||||
/build-*/
|
||||
/native-build/
|
||||
/native-build-macos/
|
||||
/local-products/
|
||||
/dist/
|
||||
/out/
|
||||
[Bb]in/
|
||||
|
||||
@@ -277,6 +277,11 @@ target_link_libraries(mkw_platform_paths_tests PRIVATE mkw_platform)
|
||||
target_compile_features(mkw_platform_paths_tests PRIVATE cxx_std_17)
|
||||
add_test(NAME mkw_platform_paths_tests COMMAND mkw_platform_paths_tests)
|
||||
|
||||
add_executable(mkw_nand_save_tests "${CMAKE_CURRENT_LIST_DIR}/tests/nand_save_tests.cpp")
|
||||
target_include_directories(mkw_nand_save_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
|
||||
target_compile_features(mkw_nand_save_tests PRIVATE cxx_std_17)
|
||||
add_test(NAME mkw_nand_save_tests COMMAND mkw_nand_save_tests)
|
||||
|
||||
add_executable(mkw_nand_settings_tests "${CMAKE_CURRENT_LIST_DIR}/tests/nand_settings_tests.cpp")
|
||||
find_package(Threads REQUIRED)
|
||||
target_link_libraries(mkw_nand_settings_tests PRIVATE Threads::Threads)
|
||||
|
||||
@@ -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
|
||||
@@ -93,6 +93,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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user