diff --git a/client-sysmodule/source/gen1recomp.cpp b/client-sysmodule/source/gen1recomp.cpp new file mode 120000 index 0000000..d924723 --- /dev/null +++ b/client-sysmodule/source/gen1recomp.cpp @@ -0,0 +1 @@ +../../client/source/gen1recomp.cpp \ No newline at end of file diff --git a/client-sysmodule/source/gen1recomp.hpp b/client-sysmodule/source/gen1recomp.hpp new file mode 120000 index 0000000..8a6ba91 --- /dev/null +++ b/client-sysmodule/source/gen1recomp.hpp @@ -0,0 +1 @@ +../../client/source/gen1recomp.hpp \ No newline at end of file diff --git a/client-sysmodule/source/main.cpp b/client-sysmodule/source/main.cpp index 2d71bbc..37af412 100644 --- a/client-sysmodule/source/main.cpp +++ b/client-sysmodule/source/main.cpp @@ -26,9 +26,11 @@ #include "account.hpp" #include "fileio.hpp" +#include "gen1recomp.hpp" #include "http.hpp" #include "ini.hpp" #include "sync.hpp" +#include "utils.hpp" // 절대 함부로 올리지 말 것. @@ -822,7 +824,48 @@ bool ensureNetwork() } -// 한 바퀴. 올릴 것이 없으면 네트워크도 건드리지 않고 조용히 돌아간다. +// This is part of the same sysmodule round, but deliberately has no access to +// the native title/sync pipeline. A Gen1 failure cannot alter native results, +// state or scheduling. +void runGen1Backup(Config& config, const std::vector& targets) +{ + const std::string setting = config["homebrew"]["gen1recomp"].value; + if (!setting.empty() && !(bool)config["homebrew"]["gen1recomp"]) return; + if (!gen1recomp::present()) return; + + const std::string wanted = config["account"]["defaultAccountName"].value; + const Account* account = nullptr; + for (const Account& target : targets) + if (wanted == target.nickname) { account = ⌖ break; } + if (!account) + { + writeLog("Gen1Recomp: default account is not in this round"); + return; + } + + const SyncOptions normal = makeOptions(config, *account); + gen1recomp::Options options; + options.stagePath = normal.saveDataPath + "/" + + toHex(normal.uid.uid[0]) + toHex(normal.uid.uid[1]); + options.accountName = normal.nickname; + options.serverUrl = normal.serverUrl; + options.remoteEnabled = normal.remoteEnabled; + // Unlike native saves, an SD homebrew has no Horizon ownership lock. + // Any application appearing while reading it aborts the archive. + options.sourceBusy = [] { return isGameRunning(); }; + options.ensureNetwork = [] { return ensureNetwork(); }; + + const int ret = gen1recomp::runRound(options, [](const std::string& line) + { + writeLog(" " + line); + }); + if (ret != gen1recomp::OK && ret != gen1recomp::ABSENT) + writeLog(" Gen1Recomp: will retry next round"); +} + + +// 한 바퀴. 올릴 것이 없으면 네트워크는 건드리지 않지만, 시작과 끝은 반드시 +// 남긴다 - 아래 훑는 동안은 몇 분씩 아무 줄도 나오지 않기 때문이다. void runBackupRound(Config& config, const std::vector& targets) { int pending = 0; @@ -834,6 +877,12 @@ void runBackupRound(Config& config, const std::vector& targets) if (pending == 0) return; + if (pending == 0) + { + runGen1Backup(config, targets); + return; + } + writeLog("titles to upload: " + std::to_string(pending)); if (!ensureNetwork()) @@ -872,6 +921,10 @@ void runBackupRound(Config& config, const std::vector& targets) writeLog("backup finished with errors - will retry"); logHeapUsage("after round"); } + + // Same round, after native saves. Its failure is intentionally isolated: + // normal uNSS state and its next schedule have already been decided. + runGen1Backup(config, targets); } } // namespace diff --git a/client/source/fileio.cpp b/client/source/fileio.cpp index 888b04f..2be22cb 100644 --- a/client/source/fileio.cpp +++ b/client/source/fileio.cpp @@ -12,14 +12,22 @@ #include -int walk(const std::string& path, std::function callback) +int walk(const std::string& path, const std::function& callback, int maxDepth) { + if (maxDepth <= 0) + { + return -2; + } + DIR* dir = opendir(path.c_str()); if (dir == NULL) { return -1; } + // Propagate a depth failure; a partial traversal must not look complete. + int ret = 0; + dirent* entry; while ((entry = readdir(dir)) != NULL) { @@ -32,7 +40,7 @@ int walk(const std::string& path, std::functiond_type == DT_DIR) { - walk(fullPath, callback); + if (walk(fullPath, callback, maxDepth - 1) == -2) ret = -2; callback(fullPath, true); } else @@ -42,7 +50,7 @@ int walk(const std::string& path, std::function -int walk(const std::string& path, std::function callback); +// Recursively visit files, then directories. Keep one callback instance to +// avoid growing the small sysmodule stack with std::function copies. Return +// -2 when maxDepth is exceeded so callers never publish a partial traversal. +int walk(const std::string& path, const std::function& callback, int maxDepth = 64); int recursiveMkdir(const std::string& path, mode_t mode = 0777); diff --git a/client/source/gen1recomp.cpp b/client/source/gen1recomp.cpp new file mode 100644 index 0000000..e4e25c7 --- /dev/null +++ b/client/source/gen1recomp.cpp @@ -0,0 +1,236 @@ +#include "gen1recomp.hpp" + +#include +#include +#include +#include +#include + +#include "fileio.hpp" +#include "remote.hpp" +#include "utils.hpp" +#include "zipio.hpp" + +namespace +{ +constexpr u64 FNV_OFFSET = 14695981039346656037ULL; +constexpr u64 FNV_PRIME = 1099511628211ULL; +constexpr size_t MAX_FILES = 256; +constexpr off_t MAX_FILE_SIZE = 4 * 1024 * 1024; +constexpr u64 MAX_TOTAL_SIZE = 32ULL * 1024 * 1024; +constexpr int MAX_DEPTH = 8; + +struct Entry { std::string path; std::string name; }; + +bool safeName(const std::string& name) +{ + if (name.empty() || name.size() >= 256 || name.find('\\') != std::string::npos + || name.find(':') != std::string::npos) return false; + size_t at = 0; + while (at < name.size()) + { + const size_t slash = name.find('/', at); + const size_t end = slash == std::string::npos ? name.size() : slash; + if (end - at == 2 && name.compare(at, 2, "..") == 0) return false; + at = end + 1; + } + return true; +} + +// Do not follow a symlink at root or any of its parents. The selected source +// must stay below the configured SD path. +bool safeRoot(const std::string& root) +{ + if (root.empty()) return false; + std::string path = root; + while (!path.empty()) + { + struct stat st; + if (lstat(path.c_str(), &st) != 0 || S_ISLNK(st.st_mode)) return false; + if (path.size() >= 2 && path[path.size() - 2] == ':' && path.back() == '/') break; + const size_t slash = path.find_last_of('/'); + if (slash == std::string::npos) break; + if (slash == 0) { path = "/"; break; } + // Keep the slash in a libnx mount root ("sdmc:/"). + path.resize(path[slash - 1] == ':' ? slash + 1 : slash); + } + return true; +} + +int collect(const std::string& root, bool needOptions, std::vector& entries) +{ + if (!safeRoot(root)) return gen1recomp::FAILED; + const std::string saves = root + "/saves"; + struct stat st; + if (lstat(saves.c_str(), &st) != 0) + return errno == ENOENT ? gen1recomp::ABSENT : gen1recomp::FAILED; + if (!S_ISDIR(st.st_mode) || S_ISLNK(st.st_mode)) return gen1recomp::FAILED; + + u64 total = 0; + int ret = gen1recomp::OK; + const int walked = walk(saves, [&](const std::string& path, bool isDir) + { + if (isDir || ret != gen1recomp::OK) return; + struct stat item; + if (lstat(path.c_str(), &item) != 0 || S_ISLNK(item.st_mode) + || !S_ISREG(item.st_mode) || item.st_size <= 0 || item.st_size > MAX_FILE_SIZE + || entries.size() >= MAX_FILES) + { ret = gen1recomp::FAILED; return; } + const std::string name = "saves/" + path.substr(saves.size() + 1); + if (!safeName(name) || (total += (u64)item.st_size) > MAX_TOTAL_SIZE) + { ret = gen1recomp::FAILED; return; } + entries.push_back({path, name}); + }, MAX_DEPTH); + if (walked != 0 || ret != gen1recomp::OK) return gen1recomp::FAILED; + if (entries.empty()) return gen1recomp::ABSENT; + + const std::string options = root + "/options.lua"; + if (lstat(options.c_str(), &st) != 0) + return needOptions ? gen1recomp::FAILED : gen1recomp::OK; + if (!S_ISREG(st.st_mode) || S_ISLNK(st.st_mode) || st.st_size <= 0 || st.st_size > MAX_FILE_SIZE + || entries.size() >= MAX_FILES || (total += (u64)st.st_size) > MAX_TOTAL_SIZE) + return gen1recomp::FAILED; + entries.push_back({options, "options.lua"}); + return gen1recomp::OK; +} + +bool hashFile(const Entry& entry, u64& combined) +{ + FILE* fp = fopen(entry.path.c_str(), "rb"); + if (!fp) return false; + u64 value = FNV_OFFSET; + for (const char c : entry.name) { value ^= (unsigned char)c; value *= FNV_PRIME; } + char buffer[4096]; + for (size_t got; (got = fread(buffer, 1, sizeof(buffer), fp)) != 0;) + for (size_t i = 0; i < got; ++i) { value ^= (unsigned char)buffer[i]; value *= FNV_PRIME; } + const bool ok = !ferror(fp); + fclose(fp); + if (!ok) return false; + combined ^= value; + return true; +} + +u64 fingerprintEntries(const std::vector& entries) +{ + u64 combined = 0; + for (const Entry& entry : entries) if (!hashFile(entry, combined)) return 0; + return (combined ^ ((u64)entries.size() * FNV_PRIME)) | 1ULL; +} + +std::string statePath(const std::string& stage) { return stage + "/.syncstate.gen1"; } + +u64 readState(const std::string& stage) +{ + FILE* fp = fopen(statePath(stage).c_str(), "r"); + if (!fp) return 0; + unsigned long long id = 0, value = 0; + const int got = fscanf(fp, "%llx %llx", &id, &value); + fclose(fp); + return got == 2 && id == gen1recomp::TITLE_ID ? (u64)value : 0; +} + +bool writeState(const std::string& stage, u64 fingerprint) +{ + FILE* fp = fopen(statePath(stage).c_str(), "w"); + if (!fp) return false; + const bool ok = fprintf(fp, "%016llx %016llx\n", + (unsigned long long)gen1recomp::TITLE_ID, (unsigned long long)fingerprint) > 0; + fclose(fp); + return ok; +} + +bool transient(int result) +{ + // Keep v20's retry policy without pulling the HTTP client implementation + // into this adapter: -2 is malformed URL, -100 is unsupported. + return (result < 0 && result != -2 && result != -100) || result >= 500; +} +} + +namespace gen1recomp +{ +bool present(const std::string& root) +{ + struct stat st; + return lstat((root + "/saves").c_str(), &st) == 0 || errno != ENOENT; +} + +u64 fingerprint(const std::string& root) +{ + std::vector entries; + return collect(root, true, entries) == OK ? fingerprintEntries(entries) : 0; +} + +int archive(const std::string& root, const std::string& outSar, + const std::function& sourceBusy) +{ + std::vector entries; + const int collected = collect(root, true, entries); + if (collected != OK) return collected; + const u64 before = fingerprintEntries(entries); + if (!before) return FAILED; + + const std::string temporary = outSar + ".gen1.tmp"; + const std::string previous = outSar + ".gen1.previous"; + remove(temporary.c_str()); + remove(previous.c_str()); + ZipWriter zip; + if (!zip.open(temporary)) return FAILED; + bool ok = true; + bool busy = false; + for (const Entry& entry : entries) + { + busy = sourceBusy && sourceBusy(); + if (busy || !zip.add(entry.path, entry.name)) { ok = false; break; } + } + zip.close(); + if (!ok) + { remove(temporary.c_str()); return busy ? BUSY : FAILED; } + + std::vector afterEntries; + if (collect(root, true, afterEntries) != OK || fingerprintEntries(afterEntries) != before) + { remove(temporary.c_str()); return FAILED; } + + struct stat st; + const bool hadOld = lstat(outSar.c_str(), &st) == 0; + if (hadOld && rename(outSar.c_str(), previous.c_str()) != 0) + { remove(temporary.c_str()); return FAILED; } + if (rename(temporary.c_str(), outSar.c_str()) != 0) + { + if (hadOld) rename(previous.c_str(), outSar.c_str()); + remove(temporary.c_str()); + return FAILED; + } + if (hadOld) remove(previous.c_str()); + return OK; +} + +int runRound(const Options& options, const std::function& log) +{ + if (!present(options.root)) { log("Gen1Recomp: source save not found"); return OK; } + const u64 current = fingerprint(options.root); + if (!current) { log("Gen1Recomp: no valid SD save to back up"); return OK; } + if (current == readState(options.stagePath)) + { + log("Gen1Recomp: unchanged - backup already uploaded"); + return OK; + } + if (recursiveMkdir(options.stagePath) != 0) { log("Gen1Recomp: cannot create staging path"); return FAILED; } + const std::string sar = options.stagePath + "/" + toHex(TITLE_ID) + ".sar"; + const int archived = archive(options.root, sar, options.sourceBusy); + if (archived != OK) { log("Gen1Recomp: archive postponed/failed (" + std::to_string(archived) + ")"); return archived; } + if (!options.remoteEnabled) return writeState(options.stagePath, current) ? OK : FAILED; + if (options.ensureNetwork && !options.ensureNetwork()) { log("Gen1Recomp: no network"); return FAILED; } + HTTPRemoteStore remote(options.serverUrl, options.stagePath); + int ret = remote.push(options.accountName, TITLE_ID); + for (int retry = 1; ret != 0 && retry < 3 && transient(remote.getLastHttpResult()); ++retry) + { + svcSleepThread(5000000000ULL); + ret = remote.push(options.accountName, TITLE_ID); + } + if (ret != 0) { log("Gen1Recomp: upload failed"); return FAILED; } + if (!writeState(options.stagePath, current)) return FAILED; + log("Gen1Recomp: backup finished"); + return OK; +} +} diff --git a/client/source/gen1recomp.hpp b/client/source/gen1recomp.hpp new file mode 100644 index 0000000..e6fb59a --- /dev/null +++ b/client/source/gen1recomp.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include +#include + +#include + +// Isolated SD adapter. It deliberately does not use the native-save engine's +// walk, fingerprint, archive, or sync-state paths. +namespace gen1recomp +{ +constexpr u64 TITLE_ID = 0x484247454E315243ULL; // HBGEN1RC +constexpr const char* ROOT = "sdmc:/switch/gen1recomp/pokemon-love2d"; +constexpr const char* NAME = "Gen1Recomp (SD)"; + +enum Result +{ + OK = 0, + ABSENT = 1, + FAILED = 2, + BUSY = 3, +}; + +struct Options +{ + std::string root = ROOT; + std::string stagePath; + std::string accountName; + std::string serverUrl; + bool remoteEnabled = true; + std::function sourceBusy; + std::function ensureNetwork; +}; + +bool present(const std::string& root = ROOT); +u64 fingerprint(const std::string& root = ROOT); + +// Atomic: a failed/busy/mutating source never replaces outSar. +int archive(const std::string& root, const std::string& outSar, + const std::function& sourceBusy = {}); + +// Own state file (.syncstate.gen1), own archive and upload round. +// It must be called after the ordinary uNSS round; its result is independent. +int runRound(const Options& options, const std::function& log); +} diff --git a/client/source/gen1restore.cpp b/client/source/gen1restore.cpp new file mode 100644 index 0000000..eb57e71 --- /dev/null +++ b/client/source/gen1restore.cpp @@ -0,0 +1,247 @@ +#include "gen1restore.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "fileio.hpp" +#include "gen1recomp.hpp" +#include "miniz.h" +#include "remote.hpp" +#include "utils.hpp" + +namespace +{ +constexpr size_t MAX_FILES = 256; +constexpr mz_uint64 MAX_FILE_SIZE = 4 * 1024 * 1024; +constexpr mz_uint64 MAX_TOTAL_SIZE = 32ULL * 1024 * 1024; +constexpr int MAX_DEPTH = 8; + +bool exists(const std::string& path, struct stat* out = nullptr) +{ + struct stat st; + if (lstat(path.c_str(), &st) != 0) return false; + if (out) *out = st; + return true; +} + +bool safeArchiveName(const std::string& name) +{ + if (name.empty() || name.size() >= 256 || name.front() == '/' + || name.back() == '/' || name.find('\\') != std::string::npos + || name.find(':') != std::string::npos) return false; + + int depth = 0; + size_t at = 0; + while (at < name.size()) + { + const size_t slash = name.find('/', at); + const size_t end = slash == std::string::npos ? name.size() : slash; + const std::string part = name.substr(at, end - at); + if (part.empty() || part == "." || part == ".." || ++depth > MAX_DEPTH) + return false; + at = end + 1; + } + + return name == "options.lua" || name.compare(0, 6, "saves/") == 0; +} + +void removeControlledTree(const std::string& path) +{ + struct stat st; + if (lstat(path.c_str(), &st) != 0 || !S_ISDIR(st.st_mode) || S_ISLNK(st.st_mode)) + return; + walk(path, [](const std::string& item, bool isDir) + { + if (isDir) rmdir(item.c_str()); + else remove(item.c_str()); + }, MAX_DEPTH + 1); + rmdir(path.c_str()); +} + +bool extractValidated(const std::string& archive, const std::string& temporary, + const std::function& log) +{ + if (exists(temporary)) + { + log("Gen1Recomp: stale restore staging exists; refusing to overwrite it"); + return false; + } + if (recursiveMkdir(temporary) != 0) return false; + + mz_zip_archive zip = {}; + if (!mz_zip_reader_init_file(&zip, archive.c_str(), 0)) + { + removeControlledTree(temporary); + log("Gen1Recomp: downloaded backup is not a valid archive"); + return false; + } + + bool ok = true; + bool hasOptions = false; + bool hasSave = false; + mz_uint64 total = 0; + std::vector names; + const mz_uint count = mz_zip_reader_get_num_files(&zip); + if (count == 0 || count > MAX_FILES) ok = false; + + for (mz_uint i = 0; ok && i < count; ++i) + { + mz_zip_archive_file_stat item = {}; + if (!mz_zip_reader_file_stat(&zip, i, &item) + || mz_zip_reader_is_file_a_directory(&zip, i)) + { ok = false; break; } + + const std::string name(item.m_filename); + if (!safeArchiveName(name) || item.m_uncomp_size == 0 + || item.m_uncomp_size > MAX_FILE_SIZE + || total + item.m_uncomp_size > MAX_TOTAL_SIZE + || std::find(names.begin(), names.end(), name) != names.end()) + { ok = false; break; } + + total += item.m_uncomp_size; + names.push_back(name); + hasOptions = hasOptions || name == "options.lua"; + hasSave = hasSave || name.compare(0, 6, "saves/") == 0; + } + ok = ok && hasOptions && hasSave; + + for (mz_uint i = 0; ok && i < count; ++i) + { + mz_zip_archive_file_stat item = {}; + if (!mz_zip_reader_file_stat(&zip, i, &item)) { ok = false; break; } + const std::string destination = temporary + "/" + item.m_filename; + const size_t slash = destination.find_last_of('/'); + if (slash == std::string::npos + || recursiveMkdir(destination.substr(0, slash)) != 0 + || !mz_zip_reader_extract_to_file(&zip, i, destination.c_str(), 0)) + { ok = false; break; } + + struct stat extracted; + if (lstat(destination.c_str(), &extracted) != 0 || !S_ISREG(extracted.st_mode) + || S_ISLNK(extracted.st_mode) + || (mz_uint64)extracted.st_size != item.m_uncomp_size) + { ok = false; break; } + } + + mz_zip_reader_end(&zip); + if (!ok) + { + removeControlledTree(temporary); + log("Gen1Recomp: backup rejected; active saves were not changed"); + } + return ok; +} + +bool writeSyncState(const std::string& path, u64 fingerprint) +{ + FILE* fp = fopen(path.c_str(), "w"); + if (!fp) return false; + const bool ok = fprintf(fp, "%016llx %016llx\n", + (unsigned long long)gen1recomp::TITLE_ID, + (unsigned long long)fingerprint) > 0; + fclose(fp); + return ok; +} + +bool validExisting(const std::string& path, bool directory) +{ + struct stat st; + if (!exists(path, &st) || S_ISLNK(st.st_mode)) return false; + return directory ? S_ISDIR(st.st_mode) : S_ISREG(st.st_mode); +} +} + +namespace gen1restore +{ +int restoreLatest(const Options& options, + const std::function& log) +{ + if (recursiveMkdir(options.downloadPath) != 0) + { + log("Gen1Recomp: cannot create download staging"); + return 2; + } + + log("Gen1Recomp: downloading latest backup..."); + HTTPRemoteStore remote(options.serverUrl, options.downloadPath); + if (remote.pull(options.accountName, gen1recomp::TITLE_ID) != 0) + { + log("Gen1Recomp: download failed (HTTP " + + std::to_string(remote.getLastHttpResult()) + ")"); + return 2; + } + + const std::string archive = options.downloadPath + "/" + + toHex(gen1recomp::TITLE_ID) + ".sar"; + const std::string temporary = options.root + ".unss-restore-tmp"; + if (!extractValidated(archive, temporary, log)) return 2; + + if (recursiveMkdir(options.root) != 0) + { + removeControlledTree(temporary); + log("Gen1Recomp: cannot create game directory"); + return 2; + } + + const std::string saves = options.root + "/saves"; + const std::string settings = options.root + "/options.lua"; + const std::string oldSaves = options.root + "/saves.before-unss-restore"; + const std::string oldSettings = options.root + "/options.lua.before-unss-restore"; + const bool hadSaves = exists(saves); + const bool hadSettings = exists(settings); + + if ((hadSaves && !validExisting(saves, true)) + || (hadSettings && !validExisting(settings, false)) + || exists(oldSaves) || exists(oldSettings)) + { + removeControlledTree(temporary); + log("Gen1Recomp: safety copy already exists or live data is unsafe; refusing restore"); + return 2; + } + + bool movedSaves = false; + bool movedSettings = false; + if (hadSaves) + { + if (rename(saves.c_str(), oldSaves.c_str()) != 0) goto rollback; + movedSaves = true; + } + if (hadSettings) + { + if (rename(settings.c_str(), oldSettings.c_str()) != 0) goto rollback; + movedSettings = true; + } + if (rename((temporary + "/saves").c_str(), saves.c_str()) != 0) goto rollback; + if (rename((temporary + "/options.lua").c_str(), settings.c_str()) != 0) + { + rename(saves.c_str(), (temporary + "/saves").c_str()); + goto rollback; + } + + rmdir(temporary.c_str()); + { + const u64 restored = gen1recomp::fingerprint(options.root); + const size_t slash = options.syncStatePath.find_last_of('/'); + const bool stateParentReady = slash != std::string::npos + && recursiveMkdir(options.syncStatePath.substr(0, slash)) == 0; + if (restored && (!stateParentReady || !writeSyncState(options.syncStatePath, restored))) + log("Gen1Recomp: restored, but could not update sync state"); + } + log("Gen1Recomp: restore finished"); + if (movedSaves || movedSettings) + log("Previous files kept as *.before-unss-restore"); + return 0; + +rollback: + if (movedSettings) rename(oldSettings.c_str(), settings.c_str()); + if (movedSaves) rename(oldSaves.c_str(), saves.c_str()); + removeControlledTree(temporary); + log("Gen1Recomp: restore failed; previous files put back"); + return 2; +} +} diff --git a/client/source/gen1restore.hpp b/client/source/gen1restore.hpp new file mode 100644 index 0000000..2314332 --- /dev/null +++ b/client/source/gen1restore.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +namespace gen1restore +{ +struct Options +{ + std::string root; + std::string downloadPath; + std::string syncStatePath; + std::string accountName; + std::string serverUrl; +}; + +// Download and transactionally restore the latest Gen1Recomp backup. +// Only saves/ and options.lua are accepted. Existing data is preserved beside +// the live files and no native Horizon save APIs are used. +int restoreLatest(const Options& options, + const std::function& log); +} diff --git a/client/source/gui/ConfirmScreen.cpp b/client/source/gui/ConfirmScreen.cpp new file mode 100644 index 0000000..d1cd7fb --- /dev/null +++ b/client/source/gui/ConfirmScreen.cpp @@ -0,0 +1,52 @@ +#include "ConfirmScreen.hpp" + +#include + +namespace gui +{ +ConfirmScreen::ConfirmScreen(const std::string& title, + std::vector lines, + std::function onConfirm) + : title(title), lines(std::move(lines)), onConfirm(std::move(onConfirm)) +{ +} + +void ConfirmScreen::update(u64 kDown) +{ + if (kDown & HidNpadButton_B) + { + App::instance().popScreen(); + return; + } + if (kDown & HidNpadButton_X) + { + // Pop deletes this screen, so move the callback out before popping. + std::function action = std::move(onConfirm); + App::instance().popScreen(); + if (action) action(); + } +} + +void ConfirmScreen::render(Renderer& r) +{ + const int x = 80; + int y = 60; + r.drawText(title, x, y, 32, COLOR_ERROR); + y += 50; + r.drawRect(x, y, r.screenWidth() - x * 2, 2, COLOR_ERROR); + y += 30; + + for (const std::string& line : lines) + { + r.drawText(line, x, y, 22, COLOR_TEXT); + y += 36; + } + + y += 25; + r.drawText("Nothing happens until you press X.", x, y, 22, COLOR_ACCENT); + + const int fy = r.screenHeight() - 50; + r.drawRect(x, fy - 10, r.screenWidth() - x * 2, 2, {80, 80, 80, 255}); + r.drawText("X: Confirm restore B: Cancel +: Exit", x, fy, 18, COLOR_DIM); +} +} diff --git a/client/source/gui/ConfirmScreen.hpp b/client/source/gui/ConfirmScreen.hpp new file mode 100644 index 0000000..c06d916 --- /dev/null +++ b/client/source/gui/ConfirmScreen.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include "Gui.hpp" + +#include +#include +#include + +namespace gui +{ +class ConfirmScreen : public Screen +{ +public: + ConfirmScreen(const std::string& title, std::vector lines, + std::function onConfirm); + + void update(u64 kDown) override; + void render(Renderer& r) override; + +private: + std::string title; + std::vector lines; + std::function onConfirm; +}; +} diff --git a/client/source/gui/MainScreen.cpp b/client/source/gui/MainScreen.cpp index 5db2a7c..7d0addb 100644 --- a/client/source/gui/MainScreen.cpp +++ b/client/source/gui/MainScreen.cpp @@ -2,12 +2,15 @@ #include "ProgressScreen.hpp" #include "AccountScreen.hpp" #include "LogScreen.hpp" +#include "ConfirmScreen.hpp" #include "../title.hpp" #include "../savedata.hpp" #include "../remote.hpp" #include "../utils.hpp" #include "../fileio.hpp" +#include "../gen1restore.hpp" +#include "../gen1recomp.hpp" namespace gui @@ -89,6 +92,11 @@ void MainScreen::rebuildMenu() menuItems.push_back({"Push to Server", [this]() { startPush(); }, remoteEnabled}); menuItems.push_back({"Pull from Server", [this]() { startPull(); }, true}); + const std::string gen1Setting = config["homebrew"]["gen1recomp"].value; + const bool gen1Enabled = gen1Setting.empty() || (bool)config["homebrew"]["gen1recomp"]; + if (gen1Enabled && gen1recomp::present()) + menuItems.push_back({"Restore Gen1Recomp", [this]() { startGen1Restore(); }, remoteEnabled}); + // 첫 설치만 사용자가 직접 고르게 한다. 부팅 때 도는 프로세스가 // 생기는 일이라 몰래 해서는 안 된다. const sysmodule::State state = sysmodule::getState(); @@ -287,6 +295,9 @@ void MainScreen::render(Renderer& r) { r.drawText(std::string("Server: ") + (std::string)config["remote"]["serverUrl"], x, y, 18, COLOR_DIM); y += 28; + r.drawText("Version: v" + std::to_string(sysmodule::BUNDLED_VERSION) + , x, y, 18, COLOR_DIM); + y += 28; } y += 30; @@ -397,4 +408,40 @@ void MainScreen::startPull() App::instance().pushScreen(new ProgressScreen("Pull from Server", std::move(work))); } + +void MainScreen::startGen1Restore() +{ + if (isGameRunning()) + { + statusMessage = "Close the running game before restoring Gen1Recomp."; + return; + } + + const SyncOptions normal = buildSyncOptions(); + const std::string accountStage = normal.saveDataPath + "/" + + toHex(normal.uid.uid[0]) + toHex(normal.uid.uid[1]); + + gen1restore::Options options; + options.root = gen1recomp::ROOT; + options.downloadPath = "sdmc:/uNSS/restore-gen1"; + options.syncStatePath = accountStage + "/.syncstate.gen1"; + options.accountName = normal.nickname; + options.serverUrl = normal.serverUrl; + + auto work = [=](std::function log) -> int + { + return gen1restore::restoreLatest(options, log); + }; + + App::instance().pushScreen(new ConfirmScreen("Restore Gen1Recomp?", { + "This replaces active Gen1 saves and options.", + "Current files are kept as *.before-unss-restore.", + "Do not power off while the restore is running." + }, [work]() mutable + { + App::instance().pushScreen( + new ProgressScreen("Restore Gen1Recomp", std::move(work))); + })); +} + } // namespace gui diff --git a/client/source/gui/MainScreen.hpp b/client/source/gui/MainScreen.hpp index 38f4045..fa23fde 100644 --- a/client/source/gui/MainScreen.hpp +++ b/client/source/gui/MainScreen.hpp @@ -34,6 +34,7 @@ class MainScreen : public Screen void onAccountSelected(const Account& selected); void startPush(); void startPull(); + void startGen1Restore(); void switchAccount(); void rebuildMenu(); diff --git a/client/source/sysmodule.hpp b/client/source/sysmodule.hpp index 866fd42..4275c1e 100644 --- a/client/source/sysmodule.hpp +++ b/client/source/sysmodule.hpp @@ -66,7 +66,7 @@ constexpr const char* PROGRAM_ID = "4200000000554E53"; // 256 KB 에서는 같은 일에 245 KB 를 썼고, 압축과 이름 조회와 업로드가 // 한꺼번에 무너졌다. 계정 필터는 12 에서 확인됐으므로 그대로 둔다 // (올릴 타이틀이 78 개에서 26 개로 줄었다). -constexpr int BUNDLED_VERSION = 13; +constexpr int BUNDLED_VERSION = 14; enum class State diff --git a/client/tests/gen1_smoke.cpp b/client/tests/gen1_smoke.cpp new file mode 100644 index 0000000..bd331fb --- /dev/null +++ b/client/tests/gen1_smoke.cpp @@ -0,0 +1,108 @@ +#include "gen1recomp.hpp" +#include "remote.hpp" +#include +#include +#include +#include +#include +#include + +// Minimal host stand-ins (host build only). +#include +int walk(const std::string& path, const std::function& cb, int depth) +{ + if (depth <= 0) return -2; + DIR* d = opendir(path.c_str()); + if (!d) return -1; + int ret = 0; + for (struct dirent* e; (e = readdir(d));) + { + if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, "..")) continue; + const std::string p = path + "/" + e->d_name; + if (e->d_type == DT_DIR) + { + if (walk(p, cb, depth - 1) == -2) ret = -2; + cb(p, true); + } + else cb(p, false); + } + closedir(d); + return ret; +} +int recursiveMkdir(const std::string&, mode_t) { return 0; } +HTTPRemoteStore::HTTPRemoteStore(const std::string&, const std::string&) {} +HTTPRemoteStore::~HTTPRemoteStore() {} +int HTTPRemoteStore::push(const std::string, const u64) { return -1; } +int HTTPRemoteStore::pull(const std::string, const u64) { return -1; } +int HTTPRemoteStore::push(const std::string, const u64, const std::string&) { return -1; } +int HTTPRemoteStore::pull(const std::string, const u64, const std::string&) { return -1; } +extern "C" int __real_rename(const char*, const char*); +static bool failPublication = false; +extern "C" int __wrap_rename(const char* from, const char* to) +{ + struct stat st; + if (lstat(to, &st) == 0) { errno = EEXIST; return -1; } // Horizon no-overwrite + if (failPublication && std::string(to).size() >= 4 + && std::string(to).substr(std::string(to).size() - 4) == ".sar" + && std::string(from).find(".previous") == std::string::npos) + { errno = EIO; return -1; } // only the final publish fails, rollback works + return __real_rename(from, to); +} + +int main(int argc, char** argv) +{ + if (argc < 3) return 64; + const std::string mode = argv[1]; + const std::string root = argv[2]; + const std::string out = argc > 3 ? argv[3] : ""; + u64 fingerprint = 0; + int result; + failPublication = (mode == "publish-failure"); + if (mode == "fingerprint") + { + fingerprint = gen1recomp::fingerprint(root); + result = fingerprint ? gen1recomp::OK : gen1recomp::ABSENT; + } + else + { + std::function gate; + if (mode == "busy") gate = [] { return true; }; + if (mode == "busy-late") + { + int n = 0; gate = [&n] { return ++n == 1; }; + } + if (mode == "aba") + { + int n = 0; + std::string slot = root + "/saves/yellow/slot1.lua"; + std::string original; + gate = [&] + { + if (++n == 2 && original.empty()) + { + std::ifstream in(slot, std::ios::binary); + original.assign(std::istreambuf_iterator(in), std::istreambuf_iterator()); + if (!original.empty()) + { + std::string changed = original; + changed[0] ^= 1; + std::ofstream(slot, std::ios::binary).write(changed.data(), changed.size()); + } + } + return false; + }; + result = gen1recomp::archive(root, out, gate); + if (!original.empty()) + std::ofstream(slot, std::ios::binary).write(original.data(), original.size()); + } + else + { + result = gen1recomp::archive(root, out, gate); + fingerprint = gen1recomp::fingerprint(root); + } + } + const int status = result == gen1recomp::OK ? 1 : result == gen1recomp::ABSENT ? 0 + : result == gen1recomp::BUSY ? 2 : 3; + std::printf("%d %llu result=%d\n", status, (unsigned long long)fingerprint, result); + return status > 1 ? 1 : 0; +}