From a8b473e712db10f971f364a19b7a5a9a96a0c774 Mon Sep 17 00:00:00 2001 From: jfpaardoo Date: Mon, 9 Nov 2026 20:54:11 +0100 Subject: [PATCH] ui: Add automatic update notification and allow playing with previous game code build (Fixes #606) --- runtime/gamelib.c | 66 +++++++++++---- runtime/gamelib.h | 4 + runtime/main.c | 2 + runtime/ui/i18n/de.inc | 3 + runtime/ui/i18n/es.inc | 3 + runtime/ui/i18n/fr.inc | 3 + runtime/ui/i18n/he.inc | 3 + runtime/ui/i18n/it.inc | 3 + runtime/ui/i18n/ja.inc | 3 + runtime/ui/i18n/ko.inc | 3 + runtime/ui/i18n/nl.inc | 3 + runtime/ui/i18n/pt.inc | 3 + runtime/ui/i18n/ru.inc | 3 + runtime/ui/i18n/th.inc | 3 + runtime/ui/i18n/zh-Hans.inc | 3 + runtime/ui/i18n/zh-Hant.inc | 3 + runtime/ui/launcher.mm | 31 ++++++- runtime/updater.c | 156 ++++++++++++++++++++++++++++++++++++ runtime/updater.h | 24 ++++++ 19 files changed, 307 insertions(+), 15 deletions(-) create mode 100644 runtime/updater.c create mode 100644 runtime/updater.h diff --git a/runtime/gamelib.c b/runtime/gamelib.c index 37e5c29e..001f93a7 100644 --- a/runtime/gamelib.c +++ b/runtime/gamelib.c @@ -154,16 +154,7 @@ static int candidates(const char *game_dir, char out[3][1100]) { return n; } -bool gamelib_present(const char *game_dir) { - if (g_tables) return true; - char c[3][1100]; - int n = candidates(game_dir, c); - for (int i = 0; i < n; i++) - if (exists(c[i]) && id_matches(c[i])) return true; - return false; -} - -static const GameLibTables *open_lib(const char *path) { +static const GameLibTables *open_lib(const char *path, bool allow_id_mismatch) { #ifdef _WIN32 HMODULE h = LoadLibraryA(path); if (!h) { snprintf(g_error, sizeof g_error, "can't load %s (error %lu)", path, GetLastError()); return NULL; } @@ -175,13 +166,56 @@ static const GameLibTables *open_lib(const char *path) { #endif if (!entry) { snprintf(g_error, sizeof g_error, "%s: not game code", path); return NULL; } const GameLibTables *t = entry(); - if (!t || t->abi != GAMELIB_ABI || !t->id || strcmp(t->id, gamelib_id())) { + if (!t || t->abi != GAMELIB_ABI) { snprintf(g_error, sizeof g_error, "%s was built for another version", path); return NULL; // (left loaded: nothing of it runs) } + if (!t->id || strcmp(t->id, gamelib_id())) { + if (!allow_id_mismatch) { + snprintf(g_error, sizeof g_error, "%s was built for another version", path); + return NULL; + } + LOG("[gamelib] note: using game code built for version ID %s (current runtime expects %s)\n", + t->id ? t->id : "?", gamelib_id()); + } return t; } +bool gamelib_abi_compatible(const char *game_dir) { + char c[3][1100]; + int n = candidates(game_dir, c); + for (int i = 0; i < n; i++) { + if (!exists(c[i])) continue; + const GameLibTables *t = open_lib(c[i], true); + if (t && take_sites(t)) return true; + } + return false; +} + +bool gamelib_exact_id(const char *game_dir) { + char c[3][1100]; + int n = candidates(game_dir, c); + for (int i = 0; i < n; i++) { + if (exists(c[i]) && id_matches(c[i])) return true; + } + return false; +} + +bool gamelib_present(const char *game_dir) { + if (g_tables) return true; + char c[3][1100]; + int n = candidates(game_dir, c); + const char *e = getenv("SAVAGEWIND_REUSE_GAMELIB"); + bool disallow_reuse = e && strcmp(e, "0") == 0; + for (int i = 0; i < n; i++) { + if (exists(c[i])) { + if (id_matches(c[i])) return true; + if (!disallow_reuse && gamelib_abi_compatible(game_dir)) return true; + } + } + return false; +} + bool gamelib_load(const char *game_dir, char *why, size_t why_size) { static bool tried; if (!tried) { @@ -190,17 +224,21 @@ bool gamelib_load(const char *game_dir, char *why, size_t why_size) { char c[3][1100]; int n = candidates(game_dir, c); const char *env = getenv("SAVAGEWIND_GAME_LIB"); + const char *reuse_env = getenv("SAVAGEWIND_REUSE_GAMELIB"); + bool allow_reuse = !reuse_env || strcmp(reuse_env, "0") != 0; for (int i = 0; i < n && !g_tables; i++) { if (!exists(c[i])) continue; bool given = i == 0 && env && *env; // SAVAGEWIND_GAME_LIB: loaded as given, the library's own id decides - if (!given && !id_matches(c[i])) { + bool matches = id_matches(c[i]); + if (!given && !matches && !allow_reuse) { snprintf(g_error, sizeof g_error, "%s was built for another version", c[i]); continue; } - g_tables = open_lib(c[i]); + g_tables = open_lib(c[i], given || allow_reuse); if (g_tables && !take_sites(g_tables)) g_tables = NULL; if (g_tables) { - LOG("[gamelib] %s (game version %s, %u sites)\n", c[i], g_game_version, *g_tables->nsites); + LOG("[gamelib] %s (game version %s, %u sites)%s\n", c[i], g_game_version, *g_tables->nsites, + matches ? "" : " [reused from previous build]"); #ifdef GAMELIB_ID_COMPAT LOG("[gamelib] target: %s\n", gamelib_compat() ? "compatible (" GAMELIB_ARCH_COMPAT ", no FMA instructions: slower)" diff --git a/runtime/gamelib.h b/runtime/gamelib.h index 551680f1..0e361658 100644 --- a/runtime/gamelib.h +++ b/runtime/gamelib.h @@ -72,6 +72,8 @@ const GameLibTables *gamelib_tables(void); void gamelib_sync(void); // True when a library usable with this build is in one of those places (without loading it). bool gamelib_present(const char *game_dir); +// True when the library matches the exact build ID of the current runtime. +bool gamelib_exact_id(const char *game_dir); // The game code's target on this CPU (#9). x86-64 builds the game code for x86-64-v3 (AVX2, FMA, BMI2: the Makefile's // GENARCH) where the CPU has those, else for the compatible target (GENARCH_COMPAT: x86-64-v2, no FMA instructions - @@ -84,6 +86,8 @@ const char *gamelib_id(void); // the id a library for this CPU has (GAMELIB_ID const char *gamelib_cpu_problem(void); // True when game_dir holds a library built for the other target (a game folder moved from a PC of the other kind) bool gamelib_other_cpu(const char *game_dir); +// True when game_dir holds an existing game library with matching ABI (reusable without full recompilation). +bool gamelib_abi_compatible(const char *game_dir); #ifdef __cplusplus } diff --git a/runtime/main.c b/runtime/main.c index 6557662b..53c87317 100644 --- a/runtime/main.c +++ b/runtime/main.c @@ -21,6 +21,7 @@ #include "kernel.h" #include "control.h" #include "launch_flags_boot.h" +#include "updater.h" const char *g_romfs_dir; uint64_t g_program_id; @@ -348,6 +349,7 @@ int main(int argc, char **argv) { fprintf(stderr, "[savagewind] Savage Wind %s (%s)\n", rt_version(), SW_SOURCE); launch_flags_boot(argc, argv); // the saved launch options (runtime/launch_flags.h): in the environment before anything reads it control_init(); + updater_init(); const char *root = "extracted"; // (in the data root) bool skip_launcher = getenv("SAVAGEWIND_NO_LAUNCHER") || getenv("SAVAGEWIND_INPUT"); // tests boot straight in bool config = false; diff --git a/runtime/ui/i18n/de.inc b/runtime/ui/i18n/de.inc index 8994da1b..7f3d463c 100644 --- a/runtime/ui/i18n/de.inc +++ b/runtime/ui/i18n/de.inc @@ -45,6 +45,9 @@ T("Make sure there's enough free space and try again. The log has the details.", T("Looking for the game files...", "Suche Spieldateien...") T("Copying the game files...", "Kopiere Spieldateien...") T("Game files ready.", "Spieldateien bereit.") +T("Game files ready (previous game build).", "Spieldateien bereit (vorherige Spielversion).") +T("Rebuild in Game Files anytime to update to latest patches.", "Jederzeit unter Spieldateien neu erstellen, um neueste Patches zu erhalten.") +T("Update available: %s", "Update verfügbar: %s") T("NSO", "") T("%d font file(s) imported, %d not recognized. Used from the next game start.", "%d Schriftdatei(en) importiert, %d nicht erkannt. Aktiv ab dem nächsten Spielstart.") T("%d font file(s) imported. Used from the next game start.", "%d Schriftdatei(en) importiert. Aktiv ab dem nächsten Spielstart.") diff --git a/runtime/ui/i18n/es.inc b/runtime/ui/i18n/es.inc index 2b0c450a..a90be649 100644 --- a/runtime/ui/i18n/es.inc +++ b/runtime/ui/i18n/es.inc @@ -45,6 +45,9 @@ T("Make sure there's enough free space and try again. The log has the details.", T("Looking for the game files...", "Buscando los archivos del juego...") T("Copying the game files...", "Copiando los archivos del juego...") T("Game files ready.", "Archivos del juego listos.") +T("Game files ready (previous game build).", "Archivos del juego listos (compilación previa).") +T("Rebuild in Game Files anytime to update to latest patches.", "Reconstruye en Archivos del juego en cualquier momento para aplicar los últimos parches.") +T("Update available: %s", "Actualización disponible: %s") T("NSO", "") T("%d font file(s) imported, %d not recognized. Used from the next game start.", "%d archivo(s) de fuente importado(s), %d no reconocido(s). Se usan desde el próximo inicio del juego.") T("%d font file(s) imported. Used from the next game start.", "%d archivo(s) de fuente importado(s). Se usan desde el próximo inicio del juego.") diff --git a/runtime/ui/i18n/fr.inc b/runtime/ui/i18n/fr.inc index 42ffb610..fa658a1c 100644 --- a/runtime/ui/i18n/fr.inc +++ b/runtime/ui/i18n/fr.inc @@ -45,6 +45,9 @@ T("Make sure there's enough free space and try again. The log has the details.", T("Looking for the game files...", "Recherche des fichiers du jeu...") T("Copying the game files...", "Copie des fichiers du jeu...") T("Game files ready.", "Fichiers du jeu prêts.") +T("Game files ready (previous game build).", "Fichiers du jeu prêts (version précédente).") +T("Rebuild in Game Files anytime to update to latest patches.", "Reconstruisez dans Fichiers du jeu à tout moment pour appliquer les derniers correctifs.") +T("Update available: %s", "Mise à jour disponible : %s") T("NSO", "") T("%d font file(s) imported, %d not recognized. Used from the next game start.", "%d fichier(s) de police importé(s), %d non reconnu(s). Utilisés dès le prochain lancement du jeu.") T("%d font file(s) imported. Used from the next game start.", "%d fichier(s) de police importé(s). Utilisés dès le prochain lancement du jeu.") diff --git a/runtime/ui/i18n/he.inc b/runtime/ui/i18n/he.inc index d2ca3064..9f22108b 100644 --- a/runtime/ui/i18n/he.inc +++ b/runtime/ui/i18n/he.inc @@ -242,6 +242,9 @@ T("Checking the game files...", "בודק את קובצי המשחק...") T("Copying the game files...", "מעתיק את קובצי המשחק...") T("Downloadable content ready.", "התוכן להורדה מוכן.") T("Game files ready.", "קובצי המשחק מוכנים.") +T("Game files ready (previous game build).", "קובצי המשחק מוכנים (בנייה קודמת).") +T("Rebuild in Game Files anytime to update to latest patches.", "בנה מחדש בקובצי משחק בכל עת כדי להחיל את הטלאים האחרונים.") +T("Update available: %s", "עדכון זמין: %s") T("%d font file(s) imported, %d not recognized. Used from the next game start.", "קובצי גופן שיובאו: %d, שלא זוהו: %d. בשימוש החל מההפעלה הבאה של המשחק.") T("%d font file(s) imported. Used from the next game start.", "קובצי גופן שיובאו: %d. בשימוש החל מההפעלה הבאה של המשחק.") T("Imported fonts removed: the substitute font is used.", "הגופנים המיובאים הוסרו: משתמשים בגופן החלופי.") diff --git a/runtime/ui/i18n/it.inc b/runtime/ui/i18n/it.inc index c1ee8300..70ac999b 100644 --- a/runtime/ui/i18n/it.inc +++ b/runtime/ui/i18n/it.inc @@ -45,6 +45,9 @@ T("Make sure there's enough free space and try again. The log has the details.", T("Looking for the game files...", "Ricerca dei file di gioco...") T("Copying the game files...", "Copia dei file di gioco...") T("Game files ready.", "File di gioco pronti.") +T("Game files ready (previous game build).", "File di gioco pronti (versione precedente).") +T("Rebuild in Game Files anytime to update to latest patches.", "Ricostruisci in File di gioco in qualsiasi momento per applicare le ultime patch.") +T("Update available: %s", "Aggiornamento disponibile: %s") T("NSO", "") T("%d font file(s) imported, %d not recognized. Used from the next game start.", "%d file di font importati, %d non riconosciuti. In uso dal prossimo avvio del gioco.") T("%d font file(s) imported. Used from the next game start.", "%d file di font importati. In uso dal prossimo avvio del gioco.") diff --git a/runtime/ui/i18n/ja.inc b/runtime/ui/i18n/ja.inc index 3d98d830..6abe8807 100644 --- a/runtime/ui/i18n/ja.inc +++ b/runtime/ui/i18n/ja.inc @@ -45,6 +45,9 @@ T("Make sure there's enough free space and try again. The log has the details.", T("Looking for the game files...", "ゲームファイルを探しています...") T("Copying the game files...", "ゲームファイルをコピーしています...") T("Game files ready.", "ゲームファイルの準備ができました。") +T("Game files ready (previous game build).", "ゲームファイルの準備ができました(以前のビルド)。") +T("Rebuild in Game Files anytime to update to latest patches.", "最新パッチを適用するには、いつでも「ゲームファイル」で再ビルドできます。") +T("Update available: %s", "アップデートが利用可能: %s") T("NSO", "") T("%d font file(s) imported, %d not recognized. Used from the next game start.", "%d個のフォントファイルをインポートしました(%d個は認識できませんでした)。次回のゲーム起動から使われます。") T("%d font file(s) imported. Used from the next game start.", "%d個のフォントファイルをインポートしました。次回のゲーム起動から使われます。") diff --git a/runtime/ui/i18n/ko.inc b/runtime/ui/i18n/ko.inc index 8555461d..14bea1d8 100644 --- a/runtime/ui/i18n/ko.inc +++ b/runtime/ui/i18n/ko.inc @@ -45,6 +45,9 @@ T("Make sure there's enough free space and try again. The log has the details.", T("Looking for the game files...", "게임 파일을 찾는 중...") T("Copying the game files...", "게임 파일을 복사하는 중...") T("Game files ready.", "게임 파일이 준비되었습니다.") +T("Game files ready (previous game build).", "게임 파일이 준비되었습니다 (이전 빌드).") +T("Rebuild in Game Files anytime to update to latest patches.", "최신 패치를 적용하려면 언제든지 게임 파일에서 다시 빌드할 수 있습니다.") +T("Update available: %s", "업데이트 사용 가능: %s") T("NSO", "") T("%d font file(s) imported, %d not recognized. Used from the next game start.", "글꼴 파일 %d개를 가져왔고 %d개는 인식하지 못했습니다. 다음 게임 시작부터 사용됩니다.") T("%d font file(s) imported. Used from the next game start.", "글꼴 파일 %d개를 가져왔습니다. 다음 게임 시작부터 사용됩니다.") diff --git a/runtime/ui/i18n/nl.inc b/runtime/ui/i18n/nl.inc index 043a8d8c..4f11d85a 100644 --- a/runtime/ui/i18n/nl.inc +++ b/runtime/ui/i18n/nl.inc @@ -45,6 +45,9 @@ T("Make sure there's enough free space and try again. The log has the details.", T("Looking for the game files...", "Spelbestanden zoeken...") T("Copying the game files...", "Spelbestanden kopiëren...") T("Game files ready.", "Spelbestanden klaar.") +T("Game files ready (previous game build).", "Spelbestanden klaar (vorige build).") +T("Rebuild in Game Files anytime to update to latest patches.", "Herbouw op elk moment in Spelbestanden om de nieuwste patches toe te passen.") +T("Update available: %s", "Update beschikbaar: %s") T("NSO", "") T("%d font file(s) imported, %d not recognized. Used from the next game start.", "%d lettertypebestand(en) geïmporteerd, %d niet herkend. Gebruikt vanaf de volgende spelstart.") T("%d font file(s) imported. Used from the next game start.", "%d lettertypebestand(en) geïmporteerd. Gebruikt vanaf de volgende spelstart.") diff --git a/runtime/ui/i18n/pt.inc b/runtime/ui/i18n/pt.inc index e989f7a1..aa730764 100644 --- a/runtime/ui/i18n/pt.inc +++ b/runtime/ui/i18n/pt.inc @@ -45,6 +45,9 @@ T("Make sure there's enough free space and try again. The log has the details.", T("Looking for the game files...", "Procurando os arquivos do jogo...") T("Copying the game files...", "Copiando os arquivos do jogo...") T("Game files ready.", "Arquivos do jogo prontos.") +T("Game files ready (previous game build).", "Arquivos do jogo prontos (versão anterior).") +T("Rebuild in Game Files anytime to update to latest patches.", "Reconstrua em Arquivos do jogo a qualquer momento para aplicar as atualizações mais recentes.") +T("Update available: %s", "Atualização disponível: %s") T("NSO", "") T("%d font file(s) imported, %d not recognized. Used from the next game start.", "%d arquivo(s) de fonte importado(s), %d não reconhecido(s). Usados a partir da próxima vez que o jogo iniciar.") T("%d font file(s) imported. Used from the next game start.", "%d arquivo(s) de fonte importado(s). Usados a partir da próxima vez que o jogo iniciar.") diff --git a/runtime/ui/i18n/ru.inc b/runtime/ui/i18n/ru.inc index 7aaaf822..2f3b6ae0 100644 --- a/runtime/ui/i18n/ru.inc +++ b/runtime/ui/i18n/ru.inc @@ -45,6 +45,9 @@ T("Make sure there's enough free space and try again. The log has the details.", T("Looking for the game files...", "Поиск файлов игры...") T("Copying the game files...", "Копирование файлов игры...") T("Game files ready.", "Файлы игры готовы.") +T("Game files ready (previous game build).", "Файлы игры готовы (предыдущая сборка).") +T("Rebuild in Game Files anytime to update to latest patches.", "Пересоберите в Файлы игры в любое время для применения последних патчей.") +T("Update available: %s", "Доступно обновление: %s") T("NSO", "") T("%d font file(s) imported, %d not recognized. Used from the next game start.", "Импортировано файлов шрифтов: %d, не распознано: %d. Используются со следующего запуска игры.") T("%d font file(s) imported. Used from the next game start.", "Импортировано файлов шрифтов: %d. Используются со следующего запуска игры.") diff --git a/runtime/ui/i18n/th.inc b/runtime/ui/i18n/th.inc index 60e38ac7..53038209 100644 --- a/runtime/ui/i18n/th.inc +++ b/runtime/ui/i18n/th.inc @@ -256,6 +256,9 @@ T("Looking for the game files...", "กำลังค้นหาไฟล์ T("Checking the game files...", "กำลังตรวจสอบไฟล์เกม...") T("Copying the game files...", "กำลังคัดลอกไฟล์เกม...") T("Game files ready.", "ไฟล์เกมพร้อมแล้ว") +T("Game files ready (previous game build).", "ไฟล์เกมพร้อมแล้ว (บิลด์ก่อนหน้า)") +T("Rebuild in Game Files anytime to update to latest patches.", "สร้างใหม่ในไฟล์เกมได้ทุกเมื่อเพื่ออัปเดตแพตช์ล่าสุด") +T("Update available: %s", "มีอัปเดตใหม่: %s") T("%d font file(s) imported, %d not recognized. Used from the next game start.", "นำเข้าไฟล์ฟอนต์ %d ไฟล์ ไม่รู้จัก %d ไฟล์ ใช้ตั้งแต่การเริ่มเกมครั้งถัดไป") T("%d font file(s) imported. Used from the next game start.", "นำเข้าไฟล์ฟอนต์ %d ไฟล์ ใช้ตั้งแต่การเริ่มเกมครั้งถัดไป") T("Imported fonts removed: the substitute font is used.", "ลบฟอนต์ที่นำเข้าแล้ว: ใช้ฟอนต์ทดแทน") diff --git a/runtime/ui/i18n/zh-Hans.inc b/runtime/ui/i18n/zh-Hans.inc index 6e1729e8..2606fa9e 100644 --- a/runtime/ui/i18n/zh-Hans.inc +++ b/runtime/ui/i18n/zh-Hans.inc @@ -45,6 +45,9 @@ T("Make sure there's enough free space and try again. The log has the details.", T("Looking for the game files...", "正在查找游戏文件...") T("Copying the game files...", "正在复制游戏文件...") T("Game files ready.", "游戏文件已就绪。") +T("Game files ready (previous game build).", "游戏文件已就绪(旧版本生成)。") +T("Rebuild in Game Files anytime to update to latest patches.", "可随时在游戏文件中重新生成以应用最新补丁。") +T("Update available: %s", "有可用更新:%s") T("NSO", "") T("%d font file(s) imported, %d not recognized. Used from the next game start.", "已导入%d个字体文件,%d个无法识别。下次启动游戏时生效。") T("%d font file(s) imported. Used from the next game start.", "已导入%d个字体文件。下次启动游戏时生效。") diff --git a/runtime/ui/i18n/zh-Hant.inc b/runtime/ui/i18n/zh-Hant.inc index 9c669925..e94b94bc 100644 --- a/runtime/ui/i18n/zh-Hant.inc +++ b/runtime/ui/i18n/zh-Hant.inc @@ -45,6 +45,9 @@ T("Make sure there's enough free space and try again. The log has the details.", T("Looking for the game files...", "正在尋找遊戲檔案...") T("Copying the game files...", "正在複製遊戲檔案...") T("Game files ready.", "遊戲檔案已就緒。") +T("Game files ready (previous game build).", "遊戲檔案已就緒(舊版本建置)。") +T("Rebuild in Game Files anytime to update to latest patches.", "可隨時在遊戲檔案中重新建置以套用最新修正。") +T("Update available: %s", "有可用更新:%s") T("NSO", "") T("%d font file(s) imported, %d not recognized. Used from the next game start.", "已匯入%d個字型檔案,%d個無法辨識。下次啟動遊戲時生效。") T("%d font file(s) imported. Used from the next game start.", "已匯入%d個字型檔案。下次啟動遊戲時生效。") diff --git a/runtime/ui/launcher.mm b/runtime/ui/launcher.mm index bc14e403..c5e8407a 100644 --- a/runtime/ui/launcher.mm +++ b/runtime/ui/launcher.mm @@ -49,6 +49,7 @@ #include "paths.h" #include "gamebuild.h" #include "gamelib.h" +#include "updater.h" #include "game_versions.h" #include "dlc_check.h" #include "romfs_build.h" @@ -1983,10 +1984,19 @@ static void left_shade(ImDrawList *bg, ImVec2 ds) { }; if (status.empty()) { draw_ready_mark(); - ImGui::TextColored(ImVec4(0.45f, 0.95f, 0.55f, 1), "%s", tr("Game files ready.")); + bool exact = gamelib_exact_id(g_root.c_str()); + if (exact) { + ImGui::TextColored(ImVec4(0.45f, 0.95f, 0.55f, 1), "%s", tr("Game files ready.")); + } else { + ImGui::TextColored(ImVec4(0.45f, 0.95f, 0.55f, 1), "%s", tr("Game files ready (previous game build).")); + } ImGui::SetCursorPosX(x0); if (main_id.empty() && !nso_build_id(g_root + "/exefs/main", &main_id)) main_id = "?"; ImGui::TextColored(ImVec4(0.66f, 0.76f, 0.98f, 1), tr("main build id %s"), main_id.c_str()); + if (!exact) { + ImGui::SetCursorPosX(x0); + ImGui::TextDisabled("%s", tr("Rebuild in Game Files anytime to update to latest patches.")); + } #ifndef __APPLE__ { // the precompiled shaders were made by another translator or driver: the first play stutters until // they are made again (Game Files > Precompile shaders says so too, but only there). Asked once. @@ -2031,6 +2041,25 @@ static void left_shade(ImDrawList *bg, ImVec2 ds) { ImVec2 p(right - vsz.x, y); for (float d : {2.5f, 1.5f, 0.8f}) dl->AddText(font, vs, ImVec2(p.x + d * s, p.y + d * s), IM_COL32(4, 8, 30, 150), ver); dl->AddText(font, vs, p, IM_COL32(255, 255, 255, 255), ver); + + if (updater_has_update()) { + char up_txt[128]; + snprintf(up_txt, sizeof up_txt, tr("Update available: %s"), updater_latest_version()); + ImVec2 up_sz = font->CalcTextSizeA(fs, FLT_MAX, 0, up_txt); + float up_y = y - up_sz.y - 14 * s; + ImGui::SetCursorScreenPos(ImVec2(right - up_sz.x - 16 * s, up_y)); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.12f, 0.50f, 0.25f, 0.85f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.18f, 0.65f, 0.32f, 0.95f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.10f, 0.40f, 0.20f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 4 * s); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(10 * s, 4 * s)); + if (ImGui::Button(up_txt)) { + const char *url = updater_release_url(); + if (url && url[0]) SDL_OpenURL(url); + } + ImGui::PopStyleVar(2); + ImGui::PopStyleColor(3); + } } ImGui::PopFont(); } diff --git a/runtime/updater.c b/runtime/updater.c new file mode 100644 index 00000000..f5767f0b --- /dev/null +++ b/runtime/updater.c @@ -0,0 +1,156 @@ +// Automatic update checking via GitHub releases API (runtime/updater.h). +#include "updater.h" + +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#endif + +extern const char *rt_version(void); + +static _Atomic bool g_has_update = false; +static char g_latest_version[64]; +static char g_release_url[256]; + +bool updater_has_update(void) { + return atomic_load(&g_has_update); +} + +const char *updater_latest_version(void) { + return g_latest_version; +} + +const char *updater_release_url(void) { + return g_release_url; +} + +static bool version_is_newer(const char *rem, const char *loc) { + if (rem[0] == 'v' || rem[0] == 'V') rem++; + if (loc[0] == 'v' || loc[0] == 'V') loc++; + int r_maj = 0, r_min = 0, r_pat = 0; + int l_maj = 0, l_min = 0, l_pat = 0; + sscanf(rem, "%d.%d.%d", &r_maj, &r_min, &r_pat); + sscanf(loc, "%d.%d.%d", &l_maj, &l_min, &l_pat); + if (r_maj != l_maj) return r_maj > l_maj; + if (r_min != l_min) return r_min > l_min; + if (r_pat != l_pat) return r_pat > l_pat; + + const char *r_alpha = strstr(rem, "alpha."); + const char *l_alpha = strstr(loc, "alpha."); + if (r_alpha && l_alpha) { + int ra = atoi(r_alpha + 6), la = atoi(l_alpha + 6); + return ra > la; + } + if (!r_alpha && l_alpha) return true; // remote is full release/beta, local is alpha + return strcmp(rem, loc) > 0; +} + +static void *update_thread(void *arg) { + (void)arg; + const char *no_check = getenv("SAVAGEWIND_NO_UPDATE_CHECK"); + if (no_check && *no_check && strcmp(no_check, "0") != 0) return NULL; + + char buf[4096] = {0}; +#ifdef _WIN32 + HMODULE wininet = LoadLibraryA("wininet.dll"); + if (!wininet) return NULL; + typedef void *HINTERNET; + typedef HINTERNET(WINAPI *pInternetOpenA)(LPCSTR, DWORD, LPCSTR, LPCSTR, DWORD); + typedef HINTERNET(WINAPI *pInternetOpenUrlA)(HINTERNET, LPCSTR, LPCSTR, DWORD, DWORD, DWORD_PTR); + typedef BOOL(WINAPI *pInternetReadFile)(HINTERNET, LPVOID, DWORD, LPDWORD); + typedef BOOL(WINAPI *pInternetCloseHandle)(HINTERNET); + + pInternetOpenA fnOpen = (pInternetOpenA)(void *)GetProcAddress(wininet, "InternetOpenA"); + pInternetOpenUrlA fnOpenUrl = (pInternetOpenUrlA)(void *)GetProcAddress(wininet, "InternetOpenUrlA"); + pInternetReadFile fnReadFile = (pInternetReadFile)(void *)GetProcAddress(wininet, "InternetReadFile"); + pInternetCloseHandle fnClose = (pInternetCloseHandle)(void *)GetProcAddress(wininet, "InternetCloseHandle"); + + if (!fnOpen || !fnOpenUrl || !fnReadFile || !fnClose) { + FreeLibrary(wininet); + return NULL; + } + + HINTERNET hNet = fnOpen("SavageWind", 1 /* INTERNET_OPEN_TYPE_DIRECT */, NULL, NULL, 0); + if (!hNet) { + FreeLibrary(wininet); + return NULL; + } + + HINTERNET hUrl = fnOpenUrl( + hNet, "https://api.github.com/repos/Revivedx/SavageWindRecomp/releases?per_page=1", + "User-Agent: SavageWind\r\nAccept: application/vnd.github+json\r\n", -1, + 0x80000000 /* INTERNET_FLAG_RELOAD */ | 0x04000000 /* INTERNET_FLAG_NO_CACHE_WRITE */ | 0x00800000 /* INTERNET_FLAG_SECURE */, + 0); + + DWORD bytes_read = 0; + if (hUrl) { + fnReadFile(hUrl, buf, sizeof(buf) - 1, &bytes_read); + fnClose(hUrl); + } + fnClose(hNet); + FreeLibrary(wininet); +#else + FILE *f = popen("curl -s -m 5 -H \"User-Agent: SavageWind\" -H \"Accept: application/vnd.github+json\" " + "https://api.github.com/repos/Revivedx/SavageWindRecomp/releases?per_page=1 2>/dev/null", "r"); + if (f) { + size_t n = fread(buf, 1, sizeof(buf) - 1, f); + (void)n; + pclose(f); + } +#endif + + if (!buf[0]) return NULL; + + const char *tag_key = "\"tag_name\":"; + char *p = strstr(buf, tag_key); + if (!p) return NULL; + p += strlen(tag_key); + while (*p == ' ' || *p == '\t') p++; + if (*p != '\"') return NULL; + p++; + char remote_tag[64] = {0}; + int idx = 0; + while (*p && *p != '\"' && idx < (int)sizeof(remote_tag) - 1) { + remote_tag[idx++] = *p++; + } + remote_tag[idx] = 0; + + const char *url_key = "\"html_url\":"; + char *u = strstr(buf, url_key); + char remote_url[256] = {0}; + if (u) { + u += strlen(url_key); + while (*u == ' ' || *u == '\t') u++; + if (*u == '\"') { + u++; + idx = 0; + while (*u && *u != '\"' && idx < (int)sizeof(remote_url) - 1) { + remote_url[idx++] = *u++; + } + remote_url[idx] = 0; + } + } + + const char *current = rt_version(); + if (version_is_newer(remote_tag, current)) { + snprintf(g_latest_version, sizeof g_latest_version, "%s", remote_tag); + snprintf(g_release_url, sizeof g_release_url, "%s", + remote_url[0] ? remote_url : "https://github.com/Revivedx/SavageWindRecomp/releases"); + atomic_store(&g_has_update, true); + fprintf(stderr, "[updater] New release available: %s (%s)\n", g_latest_version, g_release_url); + } + return NULL; +} + +void updater_init(void) { + pthread_t th; + if (pthread_create(&th, NULL, update_thread, NULL) == 0) { + pthread_detach(th); + } +} + diff --git a/runtime/updater.h b/runtime/updater.h new file mode 100644 index 00000000..8d80cca2 --- /dev/null +++ b/runtime/updater.h @@ -0,0 +1,24 @@ +// Automatic update checking via GitHub releases API (runtime/updater.h). +#pragma once +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Starts the asynchronous update check thread. +void updater_init(void); + +// Returns true if a newer release is available. +bool updater_has_update(void); + +// Returns the latest version tag (e.g., "v0.5.1" or "0.5.1"). +const char *updater_latest_version(void); + +// Returns the html URL of the latest release. +const char *updater_release_url(void); + +#ifdef __cplusplus +} +#endif +