From 4059ac228ca336d2149ee1cc3eda2c3b731e71df Mon Sep 17 00:00:00 2001 From: Margok1987 <122778321+Margok1987@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:49:57 +0200 Subject: [PATCH 1/5] fix: track save revisions with commit IDs --- client/source/sync.hpp | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/client/source/sync.hpp b/client/source/sync.hpp index c31f5cd..0ea9a3d 100644 --- a/client/source/sync.hpp +++ b/client/source/sync.hpp @@ -6,7 +6,6 @@ #include -// 동기화 진행 상황을 알리는 콜백. GUI 든 콘솔이든 동일하게 사용한다. using SyncLogFunc = std::function; @@ -18,47 +17,33 @@ struct SyncOptions std::string serverUrl; bool remoteEnabled = false; - // "created" 또는 "all" std::string archiveBy = "created"; std::string restoreBy = "all"; std::string excludedTitleIds; std::string excludedTitleNames; - // 마지막 업로드 이후 바뀌지 않은 타이틀은 건너뛴다. + // Skip titles whose Horizon SaveDataId + CommitId matches the last + // successfully uploaded revision for this account and title. bool skipUnchanged = true; }; -// 게임이 실행 중인가. 실행 중이면 세이브가 열려 있을 수 있어 -// 그 상태의 백업은 일관성을 보장하지 못한다. bool isGameRunning(); -// 세이브가 마지막 동기화 이후 바뀌었는지. 판단 근거는 세이브 안의 -// 가장 최근 수정 시각이다. 기록이 없으면 항상 true. +// Unknown metadata or missing state always returns true (fail-open). bool hasSaveDataChanged(const SyncOptions& options, u64 titleID); -// 올릴 것이 있는 타이틀 수. 네트워크를 열기 전에 물어볼 수 있다 - -// 파일 시각만 보기 때문이다. 목록을 못 읽으면 -1. -// -// 0 이면 정말로 할 일이 없다는 뜻이고, 그러면 소켓도 무선랜도 건드릴 -// 이유가 없다. 시스템 모듈에서는 그 차이가 크다. +// Number of titles whose SaveData revision differs from .syncstate-v2. +// Returns -1 when title enumeration fails. int countChangedTitles(const SyncOptions& options); -// 업로드에 성공한 뒤 현재 상태를 기록해 둔다. -void markSaveDataSynced(const SyncOptions& options, u64 titleID); - -// 모든 세이브를 아카이브한 뒤 서버로 업로드한다. int pushAllSaves(const SyncOptions& options, SyncLogFunc log); - -// 서버에서 내려받아 복원한다. remoteEnabled 가 false 면 로컬 아카이브에서 복원한다. int pullAllSaves(const SyncOptions& options, SyncLogFunc log); -// 마지막 자동 동기화 시각 (Unix time) 을 기록하는 파일. 없으면 0 을 반환한다. time_t readLastAutoSyncTime(const std::string& saveDataPath); void writeLastAutoSyncTime(const std::string& saveDataPath, time_t when); -// intervalHours 가 지났는지 확인한다. intervalHours <= 0 이면 항상 true. bool isAutoSyncDue(const std::string& saveDataPath, int intervalHours); From 4a740d206dae3cce81bfc7485ea69ead5bc705a7 Mon Sep 17 00:00:00 2001 From: Margok1987 <122778321+Margok1987@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:50:22 +0200 Subject: [PATCH 2/5] fix: replace mtime save detection with commit IDs --- client/source/sync.cpp | 322 +++++++++++++++++++++++++++++------------ 1 file changed, 226 insertions(+), 96 deletions(-) diff --git a/client/source/sync.cpp b/client/source/sync.cpp index 900e5a7..8209b2f 100644 --- a/client/source/sync.cpp +++ b/client/source/sync.cpp @@ -4,8 +4,6 @@ #include #include -#include - #include "fileio.hpp" #include "remote.hpp" #include "savedata.hpp" @@ -16,12 +14,32 @@ namespace { +struct SaveRevision +{ + bool valid = false; + u64 saveDataId = 0; + u64 commitId = 0; +}; + +struct PendingRevision +{ + u64 titleID = 0; + SaveRevision revision; +}; + + std::string lastSyncPath(const std::string& saveDataPath) { return saveDataPath + "/.lastautosync"; } +std::string syncStatePath(const std::string& saveDataPath) +{ + return saveDataPath + "/.syncstate-v2"; +} + + std::string titleNameOrUnknown(u64 titleID) { std::string titleName; @@ -31,66 +49,123 @@ std::string titleNameOrUnknown(u64 titleID) } -// 어떤 타이틀이 마지막으로 어떤 상태였는지 적어두는 파일. -// 한 줄에 "타이틀ID 시각" 형식. -std::string syncStatePath(const std::string& saveDataPath) +bool sameAccount(const AccountUid& left, const AccountUid& right) +{ + return left.uid[0] == right.uid[0] && left.uid[1] == right.uid[1]; +} + + +bool sameRevision(const SaveRevision& left, const SaveRevision& right) { - return saveDataPath + "/.syncstate"; + return left.valid && right.valid + && left.saveDataId == right.saveDataId + && left.commitId == right.commitId; } -std::string toHexId(u64 titleID) +std::string revisionKey(const AccountUid& uid, u64 titleID) { - char buffer[17]; - snprintf(buffer, sizeof(buffer), "%016lX", titleID); + char buffer[50]; + snprintf( + buffer, + sizeof(buffer), + "%016llX%016llX:%016llX", + (unsigned long long)uid.uid[0], + (unsigned long long)uid.uid[1], + (unsigned long long)titleID + ); return std::string(buffer); } -// 세이브 안에서 가장 최근 수정 시각을 찾는다. -// 마운트에 실패하면 0 을 돌려주고, 그 경우 호출한 쪽은 "바뀌었다" 로 본다. -u64 latestSaveDataTimestamp(const AccountUid uid, u64 titleID) +SaveRevision readSaveRevision(const AccountUid& uid, u64 titleID) { - const std::string mountPoint = "unsschk"; + SaveRevision revision; - if (mountSaveData(mountPoint, uid, titleID) != 0) - return 0; + FsSaveDataInfoReader reader; + Result rc = fsOpenSaveDataInfoReader(&reader, FsSaveDataSpaceId_User); + if (R_FAILED(rc)) + return revision; + + FsSaveDataInfo match{}; + int matches = 0; - u64 latest = 0; - walk(mountPoint + ":/", [&latest](const std::string& path, bool isDir) + while (true) { - if (isDir) return; + FsSaveDataInfo info{}; + s64 count = 0; + rc = fsSaveDataInfoReaderRead(&reader, &info, 1, &count); + if (R_FAILED(rc) || count == 0) + break; - struct stat st; - if (stat(path.c_str(), &st) == 0) + if (info.save_data_type == FsSaveDataType_Account + && info.application_id == titleID + && sameAccount(info.uid, uid)) { - const u64 mtime = (u64)st.st_mtime; - if (mtime > latest) latest = mtime; + match = info; + ++matches; } - }); + } - unmount(mountPoint); - return latest; + fsSaveDataInfoReaderClose(&reader); + + // A missing or ambiguous match is not a safe basis for skipping a backup. + if (R_FAILED(rc) || matches != 1) + return revision; + + FsSaveDataExtraData extra{}; + rc = fsReadSaveDataFileSystemExtraDataBySaveDataSpaceId( + &extra, + sizeof(extra), + (FsSaveDataSpaceId)match.save_data_space_id, + match.save_data_id + ); + if (R_FAILED(rc)) + return revision; + + revision.valid = true; + revision.saveDataId = match.save_data_id; + revision.commitId = extra.commit_id; + return revision; } -u64 readSyncedTimestamp(const std::string& saveDataPath, u64 titleID) +bool readSyncedRevision( + const std::string& saveDataPath, + const AccountUid& uid, + u64 titleID, + SaveRevision& revision) { FILE* fp = fopen(syncStatePath(saveDataPath).c_str(), "r"); - if (!fp) return 0; + if (!fp) return false; - const std::string wanted = toHexId(titleID); - char idBuffer[32]; - unsigned long long stamp = 0; - u64 found = 0; + const std::string wanted = revisionKey(uid, titleID); + bool found = false; + char line[256]; - while (fscanf(fp, "%31s %llu", idBuffer, &stamp) == 2) + while (fgets(line, sizeof(line), fp)) { - if (wanted == idBuffer) + char key[64]; + unsigned long long saveDataId = 0; + unsigned long long commitId = 0; + + if (sscanf(line, "%63s %llx %llx", key, &saveDataId, &commitId) != 3) + continue; + + if (wanted != key) + continue; + + // Duplicate records are ambiguous. Fail open rather than choosing one. + if (found) { - found = (u64)stamp; - break; + fclose(fp); + return false; } + + revision.valid = true; + revision.saveDataId = (u64)saveDataId; + revision.commitId = (u64)commitId; + found = true; } fclose(fp); @@ -98,76 +173,97 @@ u64 readSyncedTimestamp(const std::string& saveDataPath, u64 titleID) } -void writeSyncedTimestamp(const std::string& saveDataPath, u64 titleID, u64 stamp) +bool writeSyncedRevision( + const std::string& saveDataPath, + const AccountUid& uid, + u64 titleID, + const SaveRevision& revision) { - const std::string path = syncStatePath(saveDataPath); - const std::string wanted = toHexId(titleID); + if (!revision.valid) + return false; - // 통째로 읽어서 해당 줄만 갈아끼운다. 항목이 수십 개라 이 정도면 충분하다. + const std::string path = syncStatePath(saveDataPath); + const std::string wanted = revisionKey(uid, titleID); std::string rebuilt; + FILE* fp = fopen(path.c_str(), "r"); if (fp) { - char idBuffer[32]; - unsigned long long existing = 0; - while (fscanf(fp, "%31s %llu", idBuffer, &existing) == 2) + char line[256]; + while (fgets(line, sizeof(line), fp)) { - if (wanted == idBuffer) continue; - rebuilt += std::string(idBuffer) + " " + std::to_string(existing) + "\n"; + char key[64]; + unsigned long long saveDataId = 0; + unsigned long long commitId = 0; + + const bool parsed = sscanf(line, "%63s %llx %llx", key, &saveDataId, &commitId) == 3; + if (parsed && wanted == key) + continue; + + rebuilt += line; } fclose(fp); } - rebuilt += wanted + " " + std::to_string(stamp) + "\n"; - - FILE* out = fopen(path.c_str(), "w"); - if (!out) return; - fwrite(rebuilt.data(), 1, rebuilt.size(), out); - fclose(out); -} + char record[128]; + const int recordLength = snprintf( + record, + sizeof(record), + "%s %016llX %016llX\n", + wanted.c_str(), + (unsigned long long)revision.saveDataId, + (unsigned long long)revision.commitId + ); + if (recordLength <= 0 || (size_t)recordLength >= sizeof(record)) + return false; -} // namespace + rebuilt.append(record, (size_t)recordLength); + FILE* out = fopen(path.c_str(), "w"); + if (!out) return false; -bool isGameRunning() -{ - if (R_FAILED(pmdmntInitialize())) + const bool ok = fwrite(rebuilt.data(), 1, rebuilt.size(), out) == rebuilt.size(); + if (fclose(out) != 0) return false; - u64 pid = 0; - const Result rc = pmdmntGetApplicationProcessId(&pid); - pmdmntExit(); - - // 실행 중인 애플리케이션이 없으면 실패를 돌려준다. - return R_SUCCEEDED(rc) && pid != 0; + return ok; } -bool hasSaveDataChanged(const SyncOptions& options, u64 titleID) +bool saveRevisionChanged( + const SyncOptions& options, + u64 titleID, + SaveRevision* currentOut = nullptr) { - const u64 current = latestSaveDataTimestamp(options.uid, titleID); + const SaveRevision current = readSaveRevision(options.uid, titleID); + if (currentOut) *currentOut = current; + + // Unknown metadata must never suppress a backup. + if (!current.valid) + return true; - // 시각을 못 읽었으면 판단할 근거가 없다. 안전한 쪽으로 (업로드). - if (current == 0) return true; + SaveRevision synced; + if (!readSyncedRevision(options.saveDataPath, options.uid, titleID, synced)) + return true; - return current != readSyncedTimestamp(options.saveDataPath, titleID); + return !sameRevision(current, synced); } -void markSaveDataSynced(const SyncOptions& options, u64 titleID) +const SaveRevision* findPendingRevision( + const std::vector& pending, + u64 titleID) { - const u64 current = latestSaveDataTimestamp(options.uid, titleID); - if (current == 0) return; - - writeSyncedTimestamp(options.saveDataPath, titleID, current); + for (const PendingRevision& entry : pending) + { + if (entry.titleID == titleID) + return &entry.revision; + } + return nullptr; } -namespace -{ - -// 백업 대상 타이틀 목록. pushAllSaves 와 countChangedTitles 가 같은 기준을 -// 써야 "바뀐 게 없다" 와 "올릴 게 없다" 가 어긋나지 않는다. +// Target title enumeration shared by pushAllSaves and countChangedTitles. int collectTargetTitles(const SyncOptions& options, AccountUid uid, std::vector& titleIDs) { const int ret = options.archiveBy == "all" @@ -182,6 +278,25 @@ int collectTargetTitles(const SyncOptions& options, AccountUid uid, std::vector< } // namespace +bool isGameRunning() +{ + if (R_FAILED(pmdmntInitialize())) + return false; + + u64 pid = 0; + const Result rc = pmdmntGetApplicationProcessId(&pid); + pmdmntExit(); + + return R_SUCCEEDED(rc) && pid != 0; +} + + +bool hasSaveDataChanged(const SyncOptions& options, u64 titleID) +{ + return saveRevisionChanged(options, titleID); +} + + int countChangedTitles(const SyncOptions& options) { std::vector titleIDs; @@ -205,21 +320,31 @@ int pushAllSaves(const SyncOptions& options, SyncLogFunc log) HTTPRemoteStore remoteStore(options.serverUrl, options.saveDataPath); recursiveMkdir(options.saveDataPath.c_str()); + // Capture the exact revision that caused each title to be selected. This is + // also the revision the archive is expected to represent. After upload we + // only advance state if Horizon still reports the same revision. + std::vector pendingRevisions; + const ProbeTitlesFunc probeFunc = [&](const AccountUid probeUid, std::vector& titleIDs) -> int { const int ret = collectTargetTitles(options, probeUid, titleIDs); if (ret != 0) return ret; - // 안 바뀐 타이틀은 압축조차 하지 않는다. 여기서 걸러야 의미가 있다. if (options.skipUnchanged) { std::vector changed; changed.reserve(titleIDs.size()); + pendingRevisions.clear(); + pendingRevisions.reserve(titleIDs.size()); for (const u64 titleID : titleIDs) { - if (hasSaveDataChanged(options, titleID)) + SaveRevision current; + if (saveRevisionChanged(options, titleID, ¤t)) + { changed.push_back(titleID); + pendingRevisions.push_back({titleID, current}); + } } const size_t skipped = titleIDs.size() - changed.size(); @@ -232,10 +357,6 @@ int pushAllSaves(const SyncOptions& options, SyncLogFunc log) return 0; }; - // 개별 타이틀의 실패는 콜백 안에서만 보인다. archiveAllSaveData 는 목록을 - // 훑는 데 성공하면 OK 를 주기 때문에, 세어두지 않으면 서버가 아예 죽어 - // 있어도 이 함수는 0 을 돌려준다. 그러면 호출하는 쪽이 백업을 마쳤다고 - // 믿고 마지막 시각을 남기고, 24 시간 동안 다시 시도하지 않는다. int failures = 0; const int ret = archiveAllSaveData( @@ -251,9 +372,6 @@ int pushAllSaves(const SyncOptions& options, SyncLogFunc log) { if (ret == SAVEDATA_NO_SAVE_DATA) { - // 이 계정은 그 게임을 저장한 적이 없다. 실패가 아니므로 세지 - // 않는다 - 세면 한 바퀴가 늘 "오류로 끝남" 이 되고, 그러면 - // 마지막 성공 시각이 남지 않아 다음 바퀴가 전부를 다시 한다. log("No save data for this account - skipped"); } else if (ret != SAVEDATA_OK) @@ -266,18 +384,35 @@ int pushAllSaves(const SyncOptions& options, SyncLogFunc log) int pushRet = remoteStore.push(options.nickname, titleID); if (pushRet != 0) { - // ret 은 늘 -1 이라 아무것도 말해주지 않는다. 뒤의 값이 - // 진짜 원인이다: 음수면 연결 자체가 안 된 것 - // (HTTPCLIENT_ERROR_*, 예: -5 = TLS), 양수면 서버가 - // 돌려준 상태 코드다. log("Failed to push, ret=" + std::to_string(pushRet) + " http=" + std::to_string(remoteStore.getLastHttpResult())); ++failures; } else if (options.skipUnchanged) { - // 성공한 것만 기록한다. 실패한 타이틀은 다음에 다시 올라간다. - markSaveDataSynced(options, titleID); + const SaveRevision* pre = findPendingRevision(pendingRevisions, titleID); + const SaveRevision post = readSaveRevision(options.uid, titleID); + + if (!pre || !pre->valid || !post.valid) + { + // The backup itself succeeded, but there is no reliable + // token to suppress a future retry. Leave state unchanged. + log("Save revision unavailable - sync state not advanced"); + } + else if (!sameRevision(*pre, post)) + { + // A newer commit appeared while this backup was being + // archived/uploaded. Do not mark it as already backed up, + // and make the round fail so the sysmodule retries soon. + log("Save changed during upload - will retry"); + ++failures; + } + else if (!writeSyncedRevision(options.saveDataPath, options.uid, titleID, *pre)) + { + // State is only an optimisation. A write failure remains + // fail-open and therefore causes another backup later. + log("Failed to record sync state - title will be backed up again"); + } } } return true; @@ -285,10 +420,6 @@ int pushAllSaves(const SyncOptions& options, SyncLogFunc log) ); if (ret != 0) return ret; - - // 실패한 타이틀 수를 음수로 돌려준다. SAVEDATA_* 코드는 양수라 서로 - // 헷갈리지 않는다. 0 은 "하나도 빠짐없이 올라갔다" 는 뜻이고, - // 마지막 백업 시각은 그때만 남겨야 한다. return failures > 0 ? -failures : 0; } @@ -380,7 +511,6 @@ bool isAutoSyncDue(const std::string& saveDataPath, int intervalHours) if (last == 0) return true; const time_t now = time(NULL); - // 시스템 시계가 뒤로 간 경우 (RTC 재설정 등) 그냥 실행한다. if (now < last) return true; return (now - last) >= (time_t)intervalHours * 3600; From c9c4ed9674223283dc7bd2b8e9aade307c6a7f1e Mon Sep 17 00:00:00 2001 From: Margok1987 <122778321+Margok1987@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:50:38 +0200 Subject: [PATCH 3/5] ci: build save revision candidate --- .github/workflows/build-client-candidate.yml | 42 ++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/build-client-candidate.yml diff --git a/.github/workflows/build-client-candidate.yml b/.github/workflows/build-client-candidate.yml new file mode 100644 index 0000000..8f00dfe --- /dev/null +++ b/.github/workflows/build-client-candidate.yml @@ -0,0 +1,42 @@ +name: Build Switch client candidate + +on: + push: + branches: + - fix/save-revision-change-detection + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Check out candidate + uses: actions/checkout@v4 + + - name: Build devkitA64 image + run: docker build -t unss-client-builder:latest client + + - name: Build sysmodule and client + run: ./build-all.sh + + - name: Verify embedded sysmodule + run: | + test -f client-sysmodule/client-sysmodule.nsp + test -f client/romfs/exefs.nsp + test -f client/client.nro + test "$(sha256sum client-sysmodule/client-sysmodule.nsp | cut -d' ' -f1)" = "$(sha256sum client/romfs/exefs.nsp | cut -d' ' -f1)" + sha256sum client-sysmodule/client-sysmodule.nsp client/romfs/exefs.nsp client/client.nro | tee build-sha256.txt + + - name: Upload candidate + uses: actions/upload-artifact@v4 + with: + name: unss-save-revision-candidate + path: | + client/client.nro + client-sysmodule/client-sysmodule.nsp + client/romfs/exefs.nsp + build-sha256.txt + if-no-files-found: error From 7cbd4af97938fff5a4cf4f85a3ec3106c3451108 Mon Sep 17 00:00:00 2001 From: Margok1987 <122778321+Margok1987@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:52:57 +0200 Subject: [PATCH 4/5] style: keep sync header diff focused --- client/source/sync.hpp | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/client/source/sync.hpp b/client/source/sync.hpp index 0ea9a3d..52a331e 100644 --- a/client/source/sync.hpp +++ b/client/source/sync.hpp @@ -6,6 +6,7 @@ #include +// 동기화 진행 상황을 알리는 콜백. GUI 든 콘솔이든 동일하게 사용한다. using SyncLogFunc = std::function; @@ -17,33 +18,44 @@ struct SyncOptions std::string serverUrl; bool remoteEnabled = false; + // "created" 또는 "all" std::string archiveBy = "created"; std::string restoreBy = "all"; std::string excludedTitleIds; std::string excludedTitleNames; - // Skip titles whose Horizon SaveDataId + CommitId matches the last - // successfully uploaded revision for this account and title. + // 마지막 업로드와 SaveDataId + CommitId 가 같은 타이틀은 건너뛴다. bool skipUnchanged = true; }; +// 게임이 실행 중인가. 실행 중이면 세이브가 열려 있을 수 있어 +// 그 상태의 백업은 일관성을 보장하지 못한다. bool isGameRunning(); -// Unknown metadata or missing state always returns true (fail-open). +// 세이브가 마지막 동기화 이후 바뀌었는지. SaveDataId + CommitId 로 판단한다. +// 메타데이터나 기록이 없거나 애매하면 안전한 쪽으로 항상 true. bool hasSaveDataChanged(const SyncOptions& options, u64 titleID); -// Number of titles whose SaveData revision differs from .syncstate-v2. -// Returns -1 when title enumeration fails. +// 올릴 것이 있는 타이틀 수. 네트워크를 열기 전에 SaveData 메타데이터만 +// 읽어서 판단한다. 목록을 못 읽으면 -1. +// +// 0 이면 정말로 할 일이 없다는 뜻이고, 그러면 소켓도 무선랜도 건드릴 +// 이유가 없다. 시스템 모듈에서는 그 차이가 크다. int countChangedTitles(const SyncOptions& options); +// 모든 세이브를 아카이브한 뒤 서버로 업로드한다. int pushAllSaves(const SyncOptions& options, SyncLogFunc log); + +// 서버에서 내려받아 복원한다. remoteEnabled 가 false 면 로컬 아카이브에서 복원한다. int pullAllSaves(const SyncOptions& options, SyncLogFunc log); +// 마지막 자동 동기화 시각 (Unix time) 을 기록하는 파일. 없으면 0 을 반환한다. time_t readLastAutoSyncTime(const std::string& saveDataPath); void writeLastAutoSyncTime(const std::string& saveDataPath, time_t when); +// intervalHours 가 지났는지 확인한다. intervalHours <= 0 이면 항상 true. bool isAutoSyncDue(const std::string& saveDataPath, int intervalHours); From 96cfed4068384b69c82203d5301bc4c9328bfe4a Mon Sep 17 00:00:00 2001 From: Margok1987 <122778321+Margok1987@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:58:22 +0200 Subject: [PATCH 5/5] chore: remove temporary candidate build workflow --- .github/workflows/build-client-candidate.yml | 42 -------------------- 1 file changed, 42 deletions(-) delete mode 100644 .github/workflows/build-client-candidate.yml diff --git a/.github/workflows/build-client-candidate.yml b/.github/workflows/build-client-candidate.yml deleted file mode 100644 index 8f00dfe..0000000 --- a/.github/workflows/build-client-candidate.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Build Switch client candidate - -on: - push: - branches: - - fix/save-revision-change-detection - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Check out candidate - uses: actions/checkout@v4 - - - name: Build devkitA64 image - run: docker build -t unss-client-builder:latest client - - - name: Build sysmodule and client - run: ./build-all.sh - - - name: Verify embedded sysmodule - run: | - test -f client-sysmodule/client-sysmodule.nsp - test -f client/romfs/exefs.nsp - test -f client/client.nro - test "$(sha256sum client-sysmodule/client-sysmodule.nsp | cut -d' ' -f1)" = "$(sha256sum client/romfs/exefs.nsp | cut -d' ' -f1)" - sha256sum client-sysmodule/client-sysmodule.nsp client/romfs/exefs.nsp client/client.nro | tee build-sha256.txt - - - name: Upload candidate - uses: actions/upload-artifact@v4 - with: - name: unss-save-revision-candidate - path: | - client/client.nro - client-sysmodule/client-sysmodule.nsp - client/romfs/exefs.nsp - build-sha256.txt - if-no-files-found: error