From 3dc835d9d29196fc195a5e714e98d7806a163336 Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Tue, 3 Feb 2026 16:34:21 -0500 Subject: [PATCH 01/26] feat: Add RetroAchievements integration and in-game notifications Adds full RetroAchievements support for tg5040/tg5050 platforms with in-game toast notifications for achievements, save states, screenshots, and more. RetroAchievements Features: - Login via Settings menu with on-screen keyboard - Achievement unlock notifications with badge images - Progress tracking and leaderboard support - Hardcore mode toggle (disables save states/cheats) - Game hash identification with CHD disc image support - Automatic badge caching and async downloading - Rich presence support In-Game Notification System: - Toast notifications for save/load state operations - Screenshot confirmation notifications - Volume/brightness change indicators - Achievement unlock popups with game badges - Non-blocking async design Technical: - Integrates rcheevos library (cloned at build time) - Uses libchdr for CD-ROM/CHD image hashing - Async HTTP client for RA API communication - Thread-safe badge cache with memory management - Platform-conditional compilation (tg5040/tg5050 only) New files: - common/notification.{c,h} - Toast notification system - common/http.{c,h} - Async HTTP client wrapper - common/ra_auth.{c,h} - RA authentication handling - common/ra_badges.{c,h} - Badge download/caching - minarch/ra_integration.{c,h} - Core RA integration - minarch/chd_reader.{c,h} - CHD image support - rcheevos/makefile - rcheevos library build --- .github/workflows/dev.yaml | 3 +- .gitignore | 6 +- makefile | 4 + workspace/all/common/api.c | 210 ++- workspace/all/common/api.h | 28 + workspace/all/common/config.c | 293 ++++ workspace/all/common/config.h | 88 ++ workspace/all/common/generic_video.c | 116 ++ workspace/all/common/http.c | 393 ++++++ workspace/all/common/http.h | 92 ++ workspace/all/common/notification.c | 570 ++++++++ workspace/all/common/notification.h | 150 ++ workspace/all/common/ra_auth.c | 271 ++++ workspace/all/common/ra_auth.h | 63 + workspace/all/common/ra_badges.c | 416 ++++++ workspace/all/common/ra_badges.h | 128 ++ workspace/all/minarch/chd_reader.c | 467 +++++++ workspace/all/minarch/chd_reader.h | 68 + workspace/all/minarch/libchdr.makefile | 65 + workspace/all/minarch/makefile | 70 +- workspace/all/minarch/minarch.c | 810 ++++++++++- workspace/all/minarch/ra_consoles.h | 106 ++ workspace/all/minarch/ra_integration.c | 1471 ++++++++++++++++++++ workspace/all/minarch/ra_integration.h | 176 +++ workspace/all/rcheevos/makefile | 126 ++ workspace/all/settings/keyboardprompt.hpp | 6 + workspace/all/settings/makefile | 6 +- workspace/all/settings/menu.cpp | 26 + workspace/all/settings/menu.hpp | 31 + workspace/all/settings/settings.cpp | 164 +++ workspace/desktop/libmsettings/msettings.c | 22 +- 31 files changed, 6376 insertions(+), 69 deletions(-) create mode 100644 workspace/all/common/http.c create mode 100644 workspace/all/common/http.h create mode 100644 workspace/all/common/notification.c create mode 100644 workspace/all/common/notification.h create mode 100644 workspace/all/common/ra_auth.c create mode 100644 workspace/all/common/ra_auth.h create mode 100644 workspace/all/common/ra_badges.c create mode 100644 workspace/all/common/ra_badges.h create mode 100644 workspace/all/minarch/chd_reader.c create mode 100644 workspace/all/minarch/chd_reader.h create mode 100644 workspace/all/minarch/libchdr.makefile create mode 100644 workspace/all/minarch/ra_consoles.h create mode 100644 workspace/all/minarch/ra_integration.c create mode 100644 workspace/all/minarch/ra_integration.h create mode 100644 workspace/all/rcheevos/makefile diff --git a/.github/workflows/dev.yaml b/.github/workflows/dev.yaml index 55bdd4d70..e5f6966c2 100644 --- a/.github/workflows/dev.yaml +++ b/.github/workflows/dev.yaml @@ -25,7 +25,7 @@ jobs: - name: Install dependencies run: | - brew install gcc make sdl2_image sdl2_ttf libzip libsamplerate + brew install gcc make sdl2_image sdl2_ttf libzip libsamplerate cmake sudo ./workspace/desktop/macos_create_gcc_symlinks.sh - name: Setup @@ -48,6 +48,7 @@ jobs: sudo apt-get update sudo apt-get install -y libsdl2-image-dev libsdl2-ttf-dev sudo apt-get install -y libzip-dev liblzma-dev libzstd-dev libbz2-dev zlib1g-dev + sudo apt-get install -y cmake - name: Setup run: make setup diff --git a/.gitignore b/.gitignore index 70c8eb652..8058204ba 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,10 @@ build releases toolchains libretro-common +libchdr + +# rcheevos source (cloned at build time) +**/rcheevos/src **/cores/src **/cores/output @@ -36,4 +40,4 @@ audiomon.elf **/tmp workspace/hash.txt -workspace/readmes \ No newline at end of file +workspace/readmes diff --git a/makefile b/makefile index 2bb42df61..7a01c92d0 100644 --- a/makefile +++ b/makefile @@ -115,6 +115,8 @@ ifeq ($(PLATFORM), tg5040) cp ./workspace/all/minarch/build/$(PLATFORM)/libbz2.* ./build/SYSTEM/$(PLATFORM)/lib/ cp ./workspace/all/minarch/build/$(PLATFORM)/liblzma.* ./build/SYSTEM/$(PLATFORM)/lib/ cp ./workspace/all/minarch/build/$(PLATFORM)/libzstd.* ./build/SYSTEM/$(PLATFORM)/lib/ + # libchdr for RetroAchievements CHD hashing (use -L to dereference symlinks) + cp -L ./workspace/all/minarch/build/$(PLATFORM)/libchdr.so.0 ./build/SYSTEM/$(PLATFORM)/lib/ endif ifeq ($(PLATFORM), tg5050) cp ./workspace/all/ledcontrol/build/$(PLATFORM)/ledcontrol.elf ./build/EXTRAS/Tools/$(PLATFORM)/LedControl.pak/ @@ -127,6 +129,8 @@ ifeq ($(PLATFORM), tg5050) cp ./workspace/all/minarch/build/$(PLATFORM)/libbz2.* ./build/SYSTEM/$(PLATFORM)/lib/ cp ./workspace/all/minarch/build/$(PLATFORM)/liblzma.* ./build/SYSTEM/$(PLATFORM)/lib/ cp ./workspace/all/minarch/build/$(PLATFORM)/libzstd.* ./build/SYSTEM/$(PLATFORM)/lib/ + # libchdr for RetroAchievements CHD hashing (use -L to dereference symlinks) + cp -L ./workspace/all/minarch/build/$(PLATFORM)/libchdr.so.0 ./build/SYSTEM/$(PLATFORM)/lib/ endif diff --git a/workspace/all/common/api.c b/workspace/all/common/api.c index 18c947d45..fcef5b5ea 100644 --- a/workspace/all/common/api.c +++ b/workspace/all/common/api.c @@ -923,6 +923,89 @@ int GFX_wrapText(TTF_Font *font, char *str, int max_width, int max_lines) return max_line_width; } +int GFX_blitWrappedText(TTF_Font *font, const char *text, int max_width, int max_lines, SDL_Color color, SDL_Surface *screen, int center_x, int y) +{ + if (!text || !text[0]) + return y; + + char *text_copy = strdup(text); + if (!text_copy) + return y; + + char *words[256]; + int word_count = 0; + + // Split text into words + char *token = strtok(text_copy, " "); + while (token && word_count < 256) { + words[word_count++] = token; + token = strtok(NULL, " "); + } + + // Build and render lines + char line[512] = ""; + int line_num = 0; + for (int w = 0; w < word_count; w++) { + char test_line[512]; + if (line[0] == '\0') { + snprintf(test_line, sizeof(test_line), "%s", words[w]); + } else { + snprintf(test_line, sizeof(test_line), "%s %s", line, words[w]); + } + + int test_width, test_height; + TTF_SizeUTF8(font, test_line, &test_width, &test_height); + + if (test_width > max_width && line[0] != '\0') { + // Current line is full + if (!max_lines || line_num < max_lines - 1) { + // Render line and continue to next + SDL_Surface *line_surface = TTF_RenderUTF8_Blended(font, line, color); + if (line_surface) { + SDL_BlitSurface(line_surface, NULL, screen, &(SDL_Rect){ + center_x - line_surface->w / 2, y + }); + y += line_surface->h; + SDL_FreeSurface(line_surface); + } + line_num++; + snprintf(line, sizeof(line), "%s", words[w]); + } else { + // Last allowed line with more words remaining - add ellipsis + char truncated[512]; + snprintf(truncated, sizeof(truncated), "%s...", line); + SDL_Surface *line_surface = TTF_RenderUTF8_Blended(font, truncated, color); + if (line_surface) { + SDL_BlitSurface(line_surface, NULL, screen, &(SDL_Rect){ + center_x - line_surface->w / 2, y + }); + y += line_surface->h; + SDL_FreeSurface(line_surface); + } + line[0] = '\0'; // Mark as rendered + break; + } + } else { + snprintf(line, sizeof(line), "%s", test_line); + } + } + + // Render any remaining text + if (line[0] != '\0') { + SDL_Surface *line_surface = TTF_RenderUTF8_Blended(font, line, color); + if (line_surface) { + SDL_BlitSurface(line_surface, NULL, screen, &(SDL_Rect){ + center_x - line_surface->w / 2, y + }); + y += line_surface->h; + SDL_FreeSurface(line_surface); + } + } + + free(text_copy); + return y; +} + /////////////////////////////// // scale_blend (and supporting logic) from picoarch @@ -1785,65 +1868,96 @@ void GFX_blitBatteryAtPosition(SDL_Surface *dst, int x, int y) } } +// Helper function to render a hardware indicator (volume/brightness/colortemp) at a specific position. +// This is the reusable core extracted from GFX_blitHardwareGroup for use in notifications. +// indicator_type values: 1=brightness, 2=volume, 3=colortemp (matches show_setting from PWR_update) +int GFX_blitHardwareIndicator(SDL_Surface *dst, int x, int y, int indicator_type) +{ + int setting_value; + int setting_min; + int setting_max; + int asset; + + int ow = SCALE1(PILL_SIZE + SETTINGS_WIDTH + 10 + 4); + int ox = x; + int oy = y; + + // Draw the pill background + GFX_blitPillColor(ASSET_WHITE_PILL, dst, &(SDL_Rect){ox, oy, ow, SCALE1(PILL_SIZE)}, THEME_COLOR2, RGB_WHITE); + + // Determine which setting to display + // 1=brightness, 2=volume, 3=colortemp + if (indicator_type == 1) // brightness + { + setting_value = GetBrightness(); + setting_min = BRIGHTNESS_MIN; + setting_max = BRIGHTNESS_MAX; + asset = ASSET_BRIGHTNESS; + } + else if (indicator_type == 3) // colortemp + { + setting_value = GetColortemp(); + setting_min = COLORTEMP_MIN; + setting_max = COLORTEMP_MAX; + asset = ASSET_COLORTEMP; + } + else // volume (2 or any other value) + { + setting_value = GetVolume(); + setting_min = VOLUME_MIN; + setting_max = VOLUME_MAX; + if(GetAudioSink() == AUDIO_SINK_BLUETOOTH) + asset = (setting_value > 0 ? ASSET_BLUETOOTH : ASSET_BLUETOOTH_OFF); + else + asset = (setting_value > 0 ? ASSET_VOLUME : ASSET_VOLUME_MUTE); + } + + // Draw the icon + SDL_Rect asset_rect; + GFX_assetRect(asset, &asset_rect); + int ax = ox + (SCALE1(PILL_SIZE) - asset_rect.w) / 2; + int ay = oy + (SCALE1(PILL_SIZE) - asset_rect.h) / 2; + GFX_blitAssetColor(asset, NULL, dst, &(SDL_Rect){ax, ay}, THEME_COLOR6_255); + + // Draw the progress bar background + ox += SCALE1(PILL_SIZE); + int bar_y = y + SCALE1((PILL_SIZE - SETTINGS_SIZE) / 2); + GFX_blitPillColor(gfx.mode == MODE_MAIN ? ASSET_BAR_BG : ASSET_BAR_BG_MENU, dst, + &(SDL_Rect){ox, bar_y, SCALE1(SETTINGS_WIDTH), SCALE1(SETTINGS_SIZE)}, THEME_COLOR3, RGB_WHITE); + + // Draw the progress bar fill + float percent = ((float)(setting_value - setting_min) / (setting_max - setting_min)); + if (indicator_type == 1 || indicator_type == 3 || setting_value > 0) + { + GFX_blitPillDark(ASSET_BAR, dst, &(SDL_Rect){ox, bar_y, SCALE1(SETTINGS_WIDTH) * percent, SCALE1(SETTINGS_SIZE)}); + } + + return ow; +} + +SDL_Surface* GFX_createScreenFormatSurface(int width, int height) +{ + if (!gfx.screen) return NULL; + return SDL_CreateRGBSurfaceWithFormat( + 0, width, height, + gfx.screen->format->BitsPerPixel, + gfx.screen->format->format + ); +} + int GFX_blitHardwareGroup(SDL_Surface *dst, int show_setting) { int ox; int oy; int ow = 0; - int setting_value; - int setting_min; - int setting_max; - if (show_setting && !GetHDMI()) { - int asset; + // Use the helper function to render the indicator at the standard position ow = SCALE1(PILL_SIZE + SETTINGS_WIDTH + 10 + 4); ox = dst->w - SCALE1(PADDING) - ow; oy = SCALE1(PADDING); - GFX_blitPillColor(ASSET_WHITE_PILL, dst, &(SDL_Rect){ox, oy, ow, SCALE1(PILL_SIZE)}, THEME_COLOR2, RGB_WHITE); - - if (show_setting == 1) - { - setting_value = GetBrightness(); - setting_min = BRIGHTNESS_MIN; - setting_max = BRIGHTNESS_MAX; - asset = ASSET_BRIGHTNESS; - } - else if (show_setting == 3) - { - setting_value = GetColortemp(); - setting_min = COLORTEMP_MIN; - setting_max = COLORTEMP_MAX; - asset = ASSET_COLORTEMP; - } - else - { - setting_value = GetVolume(); - setting_min = VOLUME_MIN; - setting_max = VOLUME_MAX; - if(GetAudioSink() == AUDIO_SINK_BLUETOOTH) - asset = (setting_value > 0 ? ASSET_BLUETOOTH : ASSET_BLUETOOTH_OFF); - else - asset = (setting_value > 0 ? ASSET_VOLUME : ASSET_VOLUME_MUTE); - } - - SDL_Rect asset_rect; - GFX_assetRect(asset, &asset_rect); - int ax = ox + (SCALE1(PILL_SIZE) - asset_rect.w) / 2; - int ay = oy + (SCALE1(PILL_SIZE) - asset_rect.h) / 2; - GFX_blitAssetColor(asset, NULL, dst, &(SDL_Rect){ax, ay}, THEME_COLOR6_255); - - ox += SCALE1(PILL_SIZE); - oy += SCALE1((PILL_SIZE - SETTINGS_SIZE) / 2); - GFX_blitPillColor(gfx.mode == MODE_MAIN ? ASSET_BAR_BG : ASSET_BAR_BG_MENU, dst, &(SDL_Rect){ox, oy, SCALE1(SETTINGS_WIDTH), SCALE1(SETTINGS_SIZE)}, - THEME_COLOR3, RGB_WHITE); - - float percent = ((float)(setting_value - setting_min) / (setting_max - setting_min)); - if (show_setting == 1 || show_setting == 3 || setting_value > 0) - { - GFX_blitPillDark(ASSET_BAR, dst, &(SDL_Rect){ox, oy, SCALE1(SETTINGS_WIDTH) * percent, SCALE1(SETTINGS_SIZE)}); - } + GFX_blitHardwareIndicator(dst, ox, oy, show_setting); } else { diff --git a/workspace/all/common/api.h b/workspace/all/common/api.h index b518f2664..7e0c1c561 100644 --- a/workspace/all/common/api.h +++ b/workspace/all/common/api.h @@ -312,6 +312,7 @@ void GFX_scrollTextSurface(TTF_Font* font, const char* in_name, SDL_Surface** ou int GFX_getTextWidth(TTF_Font* font, const char* in_name, char* out_name, int max_width, int padding); // returns final width int GFX_getTextHeight(TTF_Font* font, const char* in_name, char* out_name, int max_width, int padding); // returns final width int GFX_wrapText(TTF_Font* font, char* str, int max_width, int max_lines); +int GFX_blitWrappedText(TTF_Font* font, const char* text, int max_width, int max_lines, SDL_Color color, SDL_Surface* screen, int center_x, int y); // returns new y position #define GFX_getScaler PLAT_getScaler // scaler_t:(GFX_Renderer* renderer) #define GFX_blitRenderer PLAT_blitRenderer // void:(GFX_Renderer* renderer) @@ -352,6 +353,28 @@ void GFX_blitMessage(TTF_Font* font, char* msg, SDL_Surface* dst, SDL_Rect* dst_ int GFX_blitHardwareGroup(SDL_Surface* dst, int show_setting); void GFX_blitHardwareHints(SDL_Surface* dst, int show_setting); + +/** + * Render a hardware indicator (volume/brightness/colortemp) at a specific position. + * This is the reusable helper extracted from GFX_blitHardwareGroup for in-game use. + * @param dst The destination surface + * @param x X position for the indicator + * @param y Y position for the indicator + * @param indicator_type 1=brightness, 2=volume, 3=colortemp (matches show_setting values) + * @return The width of the rendered indicator + */ +int GFX_blitHardwareIndicator(SDL_Surface* dst, int x, int y, int indicator_type); + +/** + * Create a surface with the same pixel format as gfx.screen. + * This is needed when rendering theme-colored content that will later be + * converted to another format (e.g., RGBA for GL overlays). + * @param width Surface width + * @param height Surface height + * @return A new SDL_Surface, or NULL on failure. Caller must free with SDL_FreeSurface. + */ +SDL_Surface* GFX_createScreenFormatSurface(int width, int height); + int GFX_blitButtonGroup(char** hints, int primary, SDL_Surface* dst, int align_right); void GFX_assetRect(int asset, SDL_Rect* dst_rect); @@ -589,6 +612,11 @@ void PLAT_setOffsetY(int y); void PLAT_drawOnLayer(SDL_Surface *inputSurface, int x, int y, int w, int h, float brightness, bool maintainAspectRatio,int layer); void PLAT_clearLayers(int layer); SDL_Surface* PLAT_captureRendererToSurface(); + +// Notification overlay for GL rendering (rendered on top of game during PLAT_GL_Swap) +void PLAT_setNotificationSurface(SDL_Surface* surface, int x, int y); +void PLAT_clearNotificationSurface(void); + void PLAT_animateSurface( SDL_Surface *inputSurface, int x, int y, diff --git a/workspace/all/common/config.c b/workspace/all/common/config.c index 6914fbbdb..f19843dda 100644 --- a/workspace/all/common/config.c +++ b/workspace/all/common/config.c @@ -73,6 +73,23 @@ void CFG_defaults(NextUISettings *cfg) .bluetooth = CFG_DEFAULT_BLUETOOTH, .bluetoothDiagnostics = CFG_DEFAULT_BLUETOOTH_DIAG, .bluetoothSamplerateLimit = CFG_DEFAULT_BLUETOOTH_MAXRATE, + + .notifyManualSave = CFG_DEFAULT_NOTIFY_MANUAL_SAVE, + .notifyLoad = CFG_DEFAULT_NOTIFY_LOAD, + .notifyScreenshot = CFG_DEFAULT_NOTIFY_SCREENSHOT, + .notifyAdjustments = CFG_DEFAULT_NOTIFY_ADJUSTMENTS, + .notifyDuration = CFG_DEFAULT_NOTIFY_DURATION, + + .raEnable = CFG_DEFAULT_RA_ENABLE, + .raUsername = CFG_DEFAULT_RA_USERNAME, + .raPassword = CFG_DEFAULT_RA_PASSWORD, + .raHardcoreMode = CFG_DEFAULT_RA_HARDCOREMODE, + .raToken = CFG_DEFAULT_RA_TOKEN, + .raAuthenticated = CFG_DEFAULT_RA_AUTHENTICATED, + .raShowNotifications = CFG_DEFAULT_RA_SHOW_NOTIFICATIONS, + .raNotificationDuration = CFG_DEFAULT_RA_NOTIFICATION_DURATION, + .raProgressNotificationDuration = CFG_DEFAULT_RA_PROGRESS_NOTIFICATION_DURATION, + .raAchievementSortOrder = CFG_DEFAULT_RA_ACHIEVEMENT_SORT_ORDER, }; *cfg = defaults; @@ -292,6 +309,87 @@ void CFG_init(FontLoad_callback_t cb, ColorSet_callback_t ccb) CFG_setCurrentTimezone(temp_value); continue; } + if (sscanf(line, "notifyManualSave=%i", &temp_value) == 1) + { + CFG_setNotifyManualSave((bool)temp_value); + continue; + } + if (sscanf(line, "notifyLoad=%i", &temp_value) == 1) + { + CFG_setNotifyLoad((bool)temp_value); + continue; + } + if (sscanf(line, "notifyScreenshot=%i", &temp_value) == 1) + { + CFG_setNotifyScreenshot((bool)temp_value); + continue; + } + if (sscanf(line, "notifyAdjustments=%i", &temp_value) == 1) + { + CFG_setNotifyAdjustments((bool)temp_value); + continue; + } + if (sscanf(line, "notifyDuration=%i", &temp_value) == 1) + { + CFG_setNotifyDuration(temp_value); + continue; + } + if (sscanf(line, "raEnable=%i", &temp_value) == 1) + { + CFG_setRAEnable((bool)temp_value); + continue; + } + if (strncmp(line, "raUsername=", 11) == 0) + { + char *value = line + 11; + value[strcspn(value, "\n")] = 0; + CFG_setRAUsername(value); + continue; + } + if (strncmp(line, "raPassword=", 11) == 0) + { + char *value = line + 11; + value[strcspn(value, "\n")] = 0; + CFG_setRAPassword(value); + continue; + } + if (sscanf(line, "raHardcoreMode=%i", &temp_value) == 1) + { + CFG_setRAHardcoreMode((bool)temp_value); + continue; + } + if (strncmp(line, "raToken=", 8) == 0) + { + char *value = line + 8; + value[strcspn(value, "\n")] = 0; + CFG_setRAToken(value); + continue; + } + if (sscanf(line, "raAuthenticated=%i", &temp_value) == 1) + { + CFG_setRAAuthenticated((bool)temp_value); + continue; + } + if (sscanf(line, "raShowNotifications=%i", &temp_value) == 1) + { + CFG_setRAShowNotifications((bool)temp_value); + continue; + } + if (sscanf(line, "raNotificationDuration=%i", &temp_value) == 1) + { + CFG_setRANotificationDuration(temp_value); + continue; + } + if (sscanf(line, "raProgressNotificationDuration=%i", &temp_value) == 1) + { + CFG_setRAProgressNotificationDuration(temp_value); + continue; + } + if (sscanf(line, "raAchievementSortOrder=%i", &temp_value) == 1) + { + CFG_setRAAchievementSortOrder(temp_value); + continue; + } } fclose(file); } @@ -721,6 +819,186 @@ void CFG_setCurrentTimezone(int index) CFG_sync(); } +bool CFG_getNotifyManualSave(void) +{ + return settings.notifyManualSave; +} + +void CFG_setNotifyManualSave(bool enable) +{ + settings.notifyManualSave = enable; + CFG_sync(); +} + +bool CFG_getNotifyLoad(void) +{ + return settings.notifyLoad; +} + +void CFG_setNotifyLoad(bool enable) +{ + settings.notifyLoad = enable; + CFG_sync(); +} + +bool CFG_getNotifyScreenshot(void) +{ + return settings.notifyScreenshot; +} + +void CFG_setNotifyScreenshot(bool enable) +{ + settings.notifyScreenshot = enable; + CFG_sync(); +} + +bool CFG_getNotifyAdjustments(void) +{ + return settings.notifyAdjustments; +} + +void CFG_setNotifyAdjustments(bool enable) +{ + settings.notifyAdjustments = enable; + CFG_sync(); +} + +int CFG_getNotifyDuration(void) +{ + return settings.notifyDuration; +} + +void CFG_setNotifyDuration(int seconds) +{ + settings.notifyDuration = (seconds < 1) ? 1 : (seconds > 3) ? 3 : seconds; + CFG_sync(); +} + +bool CFG_getRAEnable(void) +{ + return settings.raEnable; +} + +void CFG_setRAEnable(bool enable) +{ + settings.raEnable = enable; + CFG_sync(); +} + +const char* CFG_getRAUsername(void) +{ + return settings.raUsername; +} + +void CFG_setRAUsername(const char* username) +{ + if (username) { + strncpy(settings.raUsername, username, sizeof(settings.raUsername) - 1); + settings.raUsername[sizeof(settings.raUsername) - 1] = '\0'; + } else { + settings.raUsername[0] = '\0'; + } + CFG_sync(); +} + +const char* CFG_getRAPassword(void) +{ + return settings.raPassword; +} + +void CFG_setRAPassword(const char* password) +{ + if (password) { + strncpy(settings.raPassword, password, sizeof(settings.raPassword) - 1); + settings.raPassword[sizeof(settings.raPassword) - 1] = '\0'; + } else { + settings.raPassword[0] = '\0'; + } + CFG_sync(); +} + +bool CFG_getRAHardcoreMode(void) +{ + return settings.raHardcoreMode; +} + +void CFG_setRAHardcoreMode(bool enable) +{ + settings.raHardcoreMode = enable; + CFG_sync(); +} + +const char* CFG_getRAToken(void) +{ + return settings.raToken; +} + +void CFG_setRAToken(const char* token) +{ + if (token) { + strncpy(settings.raToken, token, sizeof(settings.raToken) - 1); + settings.raToken[sizeof(settings.raToken) - 1] = '\0'; + } else { + settings.raToken[0] = '\0'; + } + CFG_sync(); +} + +bool CFG_getRAAuthenticated(void) +{ + return settings.raAuthenticated; +} + +void CFG_setRAAuthenticated(bool authenticated) +{ + settings.raAuthenticated = authenticated; + CFG_sync(); +} + +bool CFG_getRAShowNotifications(void) +{ + return settings.raShowNotifications; +} + +void CFG_setRAShowNotifications(bool show) +{ + settings.raShowNotifications = show; + CFG_sync(); +} + +int CFG_getRANotificationDuration(void) +{ + return settings.raNotificationDuration; +} + +void CFG_setRANotificationDuration(int seconds) +{ + settings.raNotificationDuration = (seconds < 1) ? 1 : (seconds > 5) ? 5 : seconds; + CFG_sync(); +} + +int CFG_getRAProgressNotificationDuration(void) +{ + return settings.raProgressNotificationDuration; +} + +void CFG_setRAProgressNotificationDuration(int seconds) +{ + settings.raProgressNotificationDuration = (seconds < 0) ? 0 : (seconds > 5) ? 5 : seconds; + CFG_sync(); +} + +int CFG_getRAAchievementSortOrder(void) +{ + return settings.raAchievementSortOrder; +} + +void CFG_setRAAchievementSortOrder(int sortOrder) +{ + settings.raAchievementSortOrder = clamp(sortOrder, 0, RA_SORT_COUNT - 1); + CFG_sync(); +} + void CFG_get(const char *key, char *value) { if (strcmp(key, "font") == 0) @@ -943,6 +1221,21 @@ void CFG_sync(void) fprintf(file, "btMaxRate=%i\n", settings.bluetoothSamplerateLimit); fprintf(file, "ntp=%i\n", settings.ntp); fprintf(file, "currentTimezone=%i\n", settings.currentTimezone); + fprintf(file, "notifyManualSave=%i\n", settings.notifyManualSave); + fprintf(file, "notifyLoad=%i\n", settings.notifyLoad); + fprintf(file, "notifyScreenshot=%i\n", settings.notifyScreenshot); + fprintf(file, "notifyAdjustments=%i\n", settings.notifyAdjustments); + fprintf(file, "notifyDuration=%i\n", settings.notifyDuration); + fprintf(file, "raEnable=%i\n", settings.raEnable); + fprintf(file, "raUsername=%s\n", settings.raUsername); + fprintf(file, "raPassword=%s\n", settings.raPassword); + fprintf(file, "raHardcoreMode=%i\n", settings.raHardcoreMode); + fprintf(file, "raToken=%s\n", settings.raToken); + fprintf(file, "raAuthenticated=%i\n", settings.raAuthenticated); + fprintf(file, "raShowNotifications=%i\n", settings.raShowNotifications); + fprintf(file, "raNotificationDuration=%i\n", settings.raNotificationDuration); + fprintf(file, "raProgressNotificationDuration=%i\n", settings.raProgressNotificationDuration); + fprintf(file, "raAchievementSortOrder=%i\n", settings.raAchievementSortOrder); fclose(file); } diff --git a/workspace/all/common/config.h b/workspace/all/common/config.h index 7398c3a35..633474249 100644 --- a/workspace/all/common/config.h +++ b/workspace/all/common/config.h @@ -55,6 +55,22 @@ enum { SCREEN_OFF }; +// Achievement sort order options +enum { + RA_SORT_UNLOCKED_FIRST, + RA_SORT_DISPLAY_ORDER_FIRST, + RA_SORT_DISPLAY_ORDER_LAST, + RA_SORT_WON_BY_MOST, + RA_SORT_WON_BY_LEAST, + RA_SORT_POINTS_MOST, + RA_SORT_POINTS_LEAST, + RA_SORT_TITLE_AZ, + RA_SORT_TITLE_ZA, + RA_SORT_TYPE_ASC, + RA_SORT_TYPE_DESC, + RA_SORT_COUNT +}; + typedef struct { // Theme @@ -115,6 +131,25 @@ typedef struct bool bluetoothDiagnostics; int bluetoothSamplerateLimit; + // Notifications + bool notifyManualSave; + bool notifyLoad; + bool notifyScreenshot; + bool notifyAdjustments; + int notifyDuration; + + // RetroAchievements + bool raEnable; + char raUsername[64]; + char raPassword[128]; + bool raHardcoreMode; + char raToken[64]; // API token (stored after successful auth) + bool raAuthenticated; // Whether we have a valid token + bool raShowNotifications; // Show achievement unlock notifications + int raNotificationDuration; // Duration for achievement notifications (1-5 seconds) + int raProgressNotificationDuration; // Duration for progress notifications (0-5 seconds, 0 = disabled) + int raAchievementSortOrder; // Sort order for achievements list (RA_SORT_* enum) + } NextUISettings; #define CFG_DEFAULT_FONT_ID 1 // Next @@ -156,6 +191,25 @@ typedef struct #define CFG_DEFAULT_NTP false #define CFG_DEFAULT_TIMEZONE 320 // Europe/Berlin +// Notification defaults +#define CFG_DEFAULT_NOTIFY_MANUAL_SAVE true +#define CFG_DEFAULT_NOTIFY_LOAD true +#define CFG_DEFAULT_NOTIFY_SCREENSHOT true +#define CFG_DEFAULT_NOTIFY_ADJUSTMENTS true +#define CFG_DEFAULT_NOTIFY_DURATION 1 + +// RetroAchievements defaults +#define CFG_DEFAULT_RA_ENABLE false +#define CFG_DEFAULT_RA_USERNAME "" +#define CFG_DEFAULT_RA_PASSWORD "" +#define CFG_DEFAULT_RA_HARDCOREMODE false +#define CFG_DEFAULT_RA_TOKEN "" +#define CFG_DEFAULT_RA_AUTHENTICATED false +#define CFG_DEFAULT_RA_SHOW_NOTIFICATIONS true +#define CFG_DEFAULT_RA_NOTIFICATION_DURATION 3 +#define CFG_DEFAULT_RA_PROGRESS_NOTIFICATION_DURATION 1 +#define CFG_DEFAULT_RA_ACHIEVEMENT_SORT_ORDER RA_SORT_UNLOCKED_FIRST + void CFG_init(FontLoad_callback_t fontCallback, ColorSet_callback_t ccb); void CFG_print(void); void CFG_get(const char *key, char * value); @@ -268,6 +322,40 @@ void CFG_setNTP(bool on); int CFG_getCurrentTimezone(void); void CFG_setCurrentTimezone(int index); +// Notification settings +bool CFG_getNotifyManualSave(void); +void CFG_setNotifyManualSave(bool enable); +bool CFG_getNotifyLoad(void); +void CFG_setNotifyLoad(bool enable); +bool CFG_getNotifyScreenshot(void); +void CFG_setNotifyScreenshot(bool enable); +bool CFG_getNotifyAdjustments(void); +void CFG_setNotifyAdjustments(bool enable); +int CFG_getNotifyDuration(void); +void CFG_setNotifyDuration(int seconds); + +// RetroAchievements settings +bool CFG_getRAEnable(void); +void CFG_setRAEnable(bool enable); +const char* CFG_getRAUsername(void); +void CFG_setRAUsername(const char* username); +const char* CFG_getRAPassword(void); +void CFG_setRAPassword(const char* password); +bool CFG_getRAHardcoreMode(void); +void CFG_setRAHardcoreMode(bool enable); +const char* CFG_getRAToken(void); +void CFG_setRAToken(const char* token); +bool CFG_getRAAuthenticated(void); +void CFG_setRAAuthenticated(bool authenticated); +bool CFG_getRAShowNotifications(void); +void CFG_setRAShowNotifications(bool show); +int CFG_getRANotificationDuration(void); +void CFG_setRANotificationDuration(int seconds); +int CFG_getRAProgressNotificationDuration(void); +void CFG_setRAProgressNotificationDuration(int seconds); +int CFG_getRAAchievementSortOrder(void); +void CFG_setRAAchievementSortOrder(int sortOrder); + void CFG_sync(void); void CFG_quit(void); diff --git a/workspace/all/common/generic_video.c b/workspace/all/common/generic_video.c index a0b9f3624..eb5a1490a 100644 --- a/workspace/all/common/generic_video.c +++ b/workspace/all/common/generic_video.c @@ -20,6 +20,8 @@ #include "utils.h" #include #include +#include +#include #if defined(__has_feature) #if __has_feature(thread_sanitizer) @@ -107,6 +109,26 @@ static uint32_t SDL_transparentBlack = 0; static char* overlay_path = NULL; +// Notification surface for RA achievements overlay +static SDL_Surface* notification_surface = NULL; +static int notification_x = 0; +static int notification_y = 0; +static int notification_dirty = 0; +static GLuint notification_tex = 0; +static int notif_tex_w = 0, notif_tex_h = 0; + +void PLAT_setNotificationSurface(SDL_Surface* surface, int x, int y) { + notification_surface = surface; + notification_x = x; + notification_y = y; + notification_dirty = 1; +} + +void PLAT_clearNotificationSurface(void) { + notification_surface = NULL; + notification_dirty = 1; +} + #define MAX_SHADERLINE_LENGTH 512 int extractPragmaParameters(const char *shaderSource, ShaderParam *params, int maxParams) { @@ -427,6 +449,18 @@ void PLAT_initShaders() { g_noshader = link_program(vertex, fragment,"noshader.glsl"); LOG_info("default shaders loaded, %i\n\n",g_shader_default); + + // Pre-allocate notification texture to avoid frame skip on first notification + glGenTextures(1, ¬ification_tex); + glBindTexture(GL_TEXTURE_2D, notification_tex); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + // Allocate full-screen texture with transparent pixels + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, device_width, device_height, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL); + notif_tex_w = device_width; + notif_tex_h = device_height; } static void sdl_log_stdout( @@ -447,6 +481,70 @@ void PLAT_resetShaders() { shaderResetRequested = 1; } +char* PLAT_findFileInDir(const char *directory, const char *filename) { + char *filename_copy = strdup(filename); + if (!filename_copy) { + perror("strdup"); + return NULL; + } + + // Strip extension from filename + char *dot_pos = strrchr(filename_copy, '.'); + if (dot_pos) { + *dot_pos = '\0'; + } + + DIR *dir = opendir(directory); + if (!dir) { + perror("opendir"); + free(filename_copy); + return NULL; + } + + struct dirent *entry; + char *full_path = NULL; + + // Track the best (shortest) match to avoid prefix collisions. + // e.g., searching for "Advance Wars" should match "Advance Wars (USA).gba" + // over "Advance Wars 2 - Black Hole Rising (USA).gba" + char *best_match_name = NULL; + size_t best_match_len = SIZE_MAX; + + while ((entry = readdir(dir)) != NULL) { + // Strip extension from entry for comparison + char *entry_base = strdup(entry->d_name); + if (!entry_base) continue; + + char *entry_dot = strrchr(entry_base, '.'); + if (entry_dot) *entry_dot = '\0'; + + if (strstr(entry_base, filename_copy) == entry_base) { + // Prefer shorter matches (closer to exact match) + size_t entry_len = strlen(entry_base); + if (entry_len < best_match_len) { + free(best_match_name); + best_match_name = strdup(entry->d_name); + best_match_len = entry_len; + } + } + free(entry_base); + } + + closedir(dir); + + if (best_match_name) { + full_path = (char *)malloc(strlen(directory) + strlen(best_match_name) + 2); + if (full_path) { + snprintf(full_path, strlen(directory) + strlen(best_match_name) + 2, "%s/%s", directory, best_match_name); + LOG_info("PLAT_findFileInDir: matched '%s' for search '%s'\n", best_match_name, filename_copy); + } + free(best_match_name); + } + + free(filename_copy); + return full_path; +} + SDL_Surface* PLAT_initVideo(void) { #if NEXTUI_TSAN @@ -2098,6 +2196,24 @@ void PLAT_GL_Swap() { ); } + // Render notification overlay if present (texture pre-allocated in PLAT_initShaders) + if (notification_dirty && notification_surface) { + glBindTexture(GL_TEXTURE_2D, notification_tex); + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, notification_surface->w, notification_surface->h, GL_RGBA, GL_UNSIGNED_BYTE, notification_surface->pixels); + notification_dirty = 0; + } + + if (notification_tex && notification_surface) { + runShaderPass( + notification_tex, + g_shader_overlay, + NULL, + notification_x, notification_y, notif_tex_w, notif_tex_h, + &(Shader){.srcw = notif_tex_w, .srch = notif_tex_h, .texw = notif_tex_w, .texh = notif_tex_h}, + 1, GL_NONE + ); + } + SDL_GL_SwapWindow(vid.window); frame_count++; diff --git a/workspace/all/common/http.c b/workspace/all/common/http.c new file mode 100644 index 000000000..e24131b40 --- /dev/null +++ b/workspace/all/common/http.c @@ -0,0 +1,393 @@ +#include "http.h" +#include "defines.h" + +#include +#include +#include +#include +#include + +#include "sdl.h" + +// Build version info (defined in makefile) +#ifndef BUILD_HASH +#define BUILD_HASH "dev" +#endif + +// User agent string +#define HTTP_USER_AGENT_FMT "NextUI/%s (%s)" + +/***************************************************************************** + * Internal helpers + *****************************************************************************/ + +// Buffer for reading curl output +typedef struct { + char* data; + size_t size; + size_t capacity; +} HTTPBuffer; + +static int HTTPBuffer_init(HTTPBuffer* buf) { + buf->capacity = 4096; + buf->data = malloc(buf->capacity); + if (!buf->data) return -1; + buf->data[0] = '\0'; + buf->size = 0; + return 0; +} + +static int HTTPBuffer_append(HTTPBuffer* buf, const char* data, size_t len) { + if (buf->size + len + 1 > buf->capacity) { + size_t new_cap = buf->capacity * 2; + while (new_cap < buf->size + len + 1) { + new_cap *= 2; + } + if (new_cap > HTTP_MAX_RESPONSE_SIZE) { + return -1; // Too large + } + char* new_data = realloc(buf->data, new_cap); + if (!new_data) return -1; + buf->data = new_data; + buf->capacity = new_cap; + } + memcpy(buf->data + buf->size, data, len); + buf->size += len; + buf->data[buf->size] = '\0'; + return 0; +} + +static void HTTPBuffer_free(HTTPBuffer* buf) { + if (buf->data) { + free(buf->data); + buf->data = NULL; + } + buf->size = 0; + buf->capacity = 0; +} + +// Escape a string for shell use (single quotes) +static char* shell_escape(const char* str) { + if (!str) return strdup(""); + + // Count how many single quotes we need to escape + size_t len = strlen(str); + size_t quotes = 0; + for (size_t i = 0; i < len; i++) { + if (str[i] == '\'') quotes++; + } + + // Allocate: original + 3 chars per quote ('"'"') + 2 for surrounding quotes + 1 for null + char* escaped = malloc(len + quotes * 3 + 3); + if (!escaped) return NULL; + + char* p = escaped; + *p++ = '\''; + for (size_t i = 0; i < len; i++) { + if (str[i] == '\'') { + // End quote, escaped quote, start quote + *p++ = '\''; + *p++ = '"'; + *p++ = '\''; + *p++ = '"'; + *p++ = '\''; + } else { + *p++ = str[i]; + } + } + *p++ = '\''; + *p = '\0'; + + return escaped; +} + +// Execute curl and capture output +static HTTP_Response* execute_curl(const char* url, const char* post_data, const char* content_type) { + HTTP_Response* response = calloc(1, sizeof(HTTP_Response)); + if (!response) return NULL; + + response->http_status = -1; + + // Build curl command + // -s: silent (no progress) + // -S: show errors + // -k: insecure (skip SSL cert verification - needed on embedded devices without CA bundle) + // -w '%{http_code}': write HTTP status at end + // -o -: output to stdout + // --connect-timeout: connection timeout + // -m: max time + // -L: follow redirects + char cmd[4096]; + char user_agent[256]; + HTTP_getUserAgent(user_agent, sizeof(user_agent)); + + char* escaped_url = shell_escape(url); + char* escaped_ua = shell_escape(user_agent); + + if (!escaped_url || !escaped_ua) { + free(escaped_url); + free(escaped_ua); + response->error = strdup("Memory allocation failed"); + return response; + } + + if (post_data) { + char* escaped_data = shell_escape(post_data); + const char* ct = content_type ? content_type : "application/x-www-form-urlencoded"; + char* escaped_ct = shell_escape(ct); + + if (!escaped_data || !escaped_ct) { + free(escaped_url); + free(escaped_ua); + free(escaped_data); + free(escaped_ct); + response->error = strdup("Memory allocation failed"); + return response; + } + + snprintf(cmd, sizeof(cmd), + "curl -s -S -k -L --connect-timeout %d -m %d " + "-A %s " + "-H 'Content-Type: %s' " + "-d %s " + "-w '\\n%%{http_code}' " + "%s 2>&1", + HTTP_TIMEOUT_SECS, HTTP_TIMEOUT_SECS * 2, + escaped_ua, + ct, + escaped_data, + escaped_url); + + free(escaped_data); + free(escaped_ct); + } else { + snprintf(cmd, sizeof(cmd), + "curl -s -S -k -L --connect-timeout %d -m %d " + "-A %s " + "-w '\\n%%{http_code}' " + "%s 2>&1", + HTTP_TIMEOUT_SECS, HTTP_TIMEOUT_SECS * 2, + escaped_ua, + escaped_url); + } + + free(escaped_url); + free(escaped_ua); + + // Execute curl + FILE* pipe = popen(cmd, "r"); + if (!pipe) { + response->error = strdup("Failed to execute curl"); + return response; + } + + // Read output + HTTPBuffer buf; + if (HTTPBuffer_init(&buf) != 0) { + pclose(pipe); + response->error = strdup("Memory allocation failed"); + return response; + } + + char read_buf[4096]; + size_t bytes_read; + while ((bytes_read = fread(read_buf, 1, sizeof(read_buf), pipe)) > 0) { + if (HTTPBuffer_append(&buf, read_buf, bytes_read) != 0) { + HTTPBuffer_free(&buf); + pclose(pipe); + response->error = strdup("Response too large"); + return response; + } + } + + int exit_code = pclose(pipe); + + // Parse HTTP status from end of output + // Output format: \n + if (buf.size > 0) { + // Find last newline + char* last_newline = NULL; + for (size_t i = buf.size; i > 0; i--) { + if (buf.data[i-1] == '\n') { + last_newline = &buf.data[i-1]; + break; + } + } + + if (last_newline) { + // Parse status code after newline + int status = atoi(last_newline + 1); + if (status >= 100 && status < 600) { + response->http_status = status; + // Truncate buffer to remove status code + *last_newline = '\0'; + buf.size = last_newline - buf.data; + } + } + } + + // Check for curl errors + if (exit_code != 0 && response->http_status <= 0) { + // Curl failed - the output is likely an error message + response->error = buf.data; + buf.data = NULL; + HTTPBuffer_free(&buf); + return response; + } + + // Success - transfer ownership of buffer + response->data = buf.data; + response->size = buf.size; + buf.data = NULL; // Prevent free + + return response; +} + +/***************************************************************************** + * Async request handling + *****************************************************************************/ + +typedef struct { + char* url; + char* post_data; + char* content_type; + HTTP_Callback callback; + void* userdata; +} AsyncRequestData; + +static int async_request_thread(void* data) { + AsyncRequestData* req = (AsyncRequestData*)data; + + HTTP_Response* response; + if (req->post_data) { + response = execute_curl(req->url, req->post_data, req->content_type); + } else { + response = execute_curl(req->url, NULL, NULL); + } + + // Call callback on completion + if (req->callback) { + req->callback(response, req->userdata); + } else { + HTTP_freeResponse(response); + } + + // Cleanup request data + free(req->url); + free(req->post_data); + free(req->content_type); + free(req); + + return 0; +} + +static void start_async_request(const char* url, const char* post_data, + const char* content_type, HTTP_Callback callback, + void* userdata) { + AsyncRequestData* req = calloc(1, sizeof(AsyncRequestData)); + if (!req) { + // Callback with error + HTTP_Response* response = calloc(1, sizeof(HTTP_Response)); + if (response) { + response->http_status = -1; + response->error = strdup("Memory allocation failed"); + } + if (callback) callback(response, userdata); + return; + } + + req->url = strdup(url); + req->post_data = post_data ? strdup(post_data) : NULL; + req->content_type = content_type ? strdup(content_type) : NULL; + req->callback = callback; + req->userdata = userdata; + + if (!req->url) { + free(req->post_data); + free(req->content_type); + free(req); + HTTP_Response* response = calloc(1, sizeof(HTTP_Response)); + if (response) { + response->http_status = -1; + response->error = strdup("Memory allocation failed"); + } + if (callback) callback(response, userdata); + return; + } + + // Create thread + SDL_Thread* thread = SDL_CreateThread(async_request_thread, "HTTPRequest", req); + if (!thread) { + free(req->url); + free(req->post_data); + free(req->content_type); + free(req); + HTTP_Response* response = calloc(1, sizeof(HTTP_Response)); + if (response) { + response->http_status = -1; + response->error = strdup("Failed to create thread"); + } + if (callback) callback(response, userdata); + return; + } + + // Detach thread - it will clean itself up + SDL_DetachThread(thread); +} + +/***************************************************************************** + * Public API + *****************************************************************************/ + +HTTP_Response* HTTP_get(const char* url) { + return execute_curl(url, NULL, NULL); +} + +HTTP_Response* HTTP_post(const char* url, const char* post_data, const char* content_type) { + return execute_curl(url, post_data, content_type); +} + +void HTTP_getAsync(const char* url, HTTP_Callback callback, void* userdata) { + start_async_request(url, NULL, NULL, callback, userdata); +} + +void HTTP_postAsync(const char* url, const char* post_data, const char* content_type, + HTTP_Callback callback, void* userdata) { + start_async_request(url, post_data, content_type, callback, userdata); +} + +void HTTP_freeResponse(HTTP_Response* response) { + if (!response) return; + free(response->data); + free(response->error); + free(response); +} + +char* HTTP_urlEncode(const char* str) { + if (!str) return NULL; + + // Count required size (worst case: every char becomes %XX) + size_t len = strlen(str); + char* encoded = malloc(len * 3 + 1); + if (!encoded) return NULL; + + char* p = encoded; + for (size_t i = 0; i < len; i++) { + unsigned char c = (unsigned char)str[i]; + if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') { + *p++ = c; + } else if (c == ' ') { + *p++ = '+'; + } else { + sprintf(p, "%%%02X", c); + p += 3; + } + } + *p = '\0'; + + return encoded; +} + +void HTTP_getUserAgent(char* buffer, size_t buffer_size) { + snprintf(buffer, buffer_size, HTTP_USER_AGENT_FMT, BUILD_HASH, PLATFORM); +} diff --git a/workspace/all/common/http.h b/workspace/all/common/http.h new file mode 100644 index 000000000..81aef77c2 --- /dev/null +++ b/workspace/all/common/http.h @@ -0,0 +1,92 @@ +#ifndef __HTTP_H__ +#define __HTTP_H__ + +#include + +/** + * HTTP client wrapper for NextUI + * + * Uses curl subprocess for HTTP requests. Supports both synchronous + * and asynchronous (threaded) requests. + */ + +// Maximum response size (8MB) +#define HTTP_MAX_RESPONSE_SIZE (8 * 1024 * 1024) + +// HTTP timeout in seconds +#define HTTP_TIMEOUT_SECS 30 + +// HTTP response structure +typedef struct HTTP_Response { + char* data; // Response body (caller must free) + size_t size; // Response body size + int http_status; // HTTP status code (200, 404, etc.) or -1 on error + char* error; // Error message if failed (caller must free), NULL on success +} HTTP_Response; + +/** + * Callback for async HTTP requests. + * @param response The HTTP response (caller takes ownership, must call HTTP_freeResponse) + * @param userdata User-provided data passed to the async request + */ +typedef void (*HTTP_Callback)(HTTP_Response* response, void* userdata); + +/** + * Perform a synchronous HTTP GET request. + * @param url The URL to fetch + * @return HTTP_Response (caller must call HTTP_freeResponse) + */ +HTTP_Response* HTTP_get(const char* url); + +/** + * Perform a synchronous HTTP POST request. + * @param url The URL to post to + * @param post_data The POST body data (can be NULL for empty POST) + * @param content_type The Content-Type header (can be NULL for application/x-www-form-urlencoded) + * @return HTTP_Response (caller must call HTTP_freeResponse) + */ +HTTP_Response* HTTP_post(const char* url, const char* post_data, const char* content_type); + +/** + * Perform an asynchronous HTTP GET request. + * Spawns a background thread and calls callback when complete. + * @param url The URL to fetch + * @param callback Function to call with the response + * @param userdata User data to pass to callback + */ +void HTTP_getAsync(const char* url, HTTP_Callback callback, void* userdata); + +/** + * Perform an asynchronous HTTP POST request. + * Spawns a background thread and calls callback when complete. + * @param url The URL to post to + * @param post_data The POST body data (can be NULL for empty POST) + * @param content_type The Content-Type header (can be NULL) + * @param callback Function to call with the response + * @param userdata User data to pass to callback + */ +void HTTP_postAsync(const char* url, const char* post_data, const char* content_type, + HTTP_Callback callback, void* userdata); + +/** + * Free an HTTP response structure. + * @param response The response to free + */ +void HTTP_freeResponse(HTTP_Response* response); + +/** + * URL-encode a string for use in query parameters. + * @param str The string to encode + * @return Encoded string (caller must free), or NULL on error + */ +char* HTTP_urlEncode(const char* str); + +/** + * Build a User-Agent string for RetroAchievements. + * Format: "NextUI/ () Integration/" + * @param buffer Buffer to write User-Agent string to + * @param buffer_size Size of buffer + */ +void HTTP_getUserAgent(char* buffer, size_t buffer_size); + +#endif // __HTTP_H__ diff --git a/workspace/all/common/notification.c b/workspace/all/common/notification.c new file mode 100644 index 000000000..d645d2b58 --- /dev/null +++ b/workspace/all/common/notification.c @@ -0,0 +1,570 @@ +#include "notification.h" +#include "defines.h" +#include "api.h" +#include "config.h" +#include +#include + +/////////////////////////////// +// Internal state +/////////////////////////////// + +static Notification notifications[NOTIFICATION_MAX_QUEUE]; +static int notification_count = 0; +static int initialized = 0; + +// Screen dimensions for layer rendering +static int screen_width = 0; +static int screen_height = 0; + +// Visual constants (will be set after init with proper scaling) +static int notif_padding_x; +static int notif_padding_y; +static int notif_margin; +static int notif_stack_gap; +static int notif_icon_gap; + +// Track if we need to re-render (only when notifications change) +static int render_dirty = 1; +static int last_notification_count = 0; + +/////////////////////////////// +// System indicator state +/////////////////////////////// + +static SystemIndicatorType system_indicator_type = SYSTEM_INDICATOR_NONE; +static uint32_t system_indicator_start_time = 0; +static int system_indicator_dirty = 0; +static int last_system_indicator_type = SYSTEM_INDICATOR_NONE; + +/////////////////////////////// +// Progress indicator state +/////////////////////////////// + +#define PROGRESS_TITLE_MAX 48 +#define PROGRESS_STRING_MAX 16 + +static char progress_indicator_title[PROGRESS_TITLE_MAX]; +static char progress_indicator_progress[PROGRESS_STRING_MAX]; +static SDL_Surface* progress_indicator_icon = NULL; +static uint32_t progress_indicator_start_time = 0; +static int progress_indicator_active = 0; +static int progress_indicator_dirty = 0; + +/////////////////////////////// +// Rounded rectangle drawing +/////////////////////////////// + +// Draw a filled rounded rectangle (pill shape) on an RGBA surface +static void draw_rounded_rect(SDL_Surface* surface, int x, int y, int w, int h, int radius, Uint32 color) { + if (!surface || w <= 0 || h <= 0) return; + + // Clamp radius to half the smallest dimension + if (radius > w / 2) radius = w / 2; + if (radius > h / 2) radius = h / 2; + + Uint32* pixels = (Uint32*)surface->pixels; + int pitch = surface->pitch / 4; // pitch in pixels (32-bit) + + for (int py = 0; py < h; py++) { + for (int px = 0; px < w; px++) { + int draw = 1; + + // Check corners + if (px < radius && py < radius) { + // Top-left corner + int dx = radius - px - 1; + int dy = radius - py - 1; + if (dx * dx + dy * dy > radius * radius) draw = 0; + } else if (px >= w - radius && py < radius) { + // Top-right corner + int dx = px - (w - radius); + int dy = radius - py - 1; + if (dx * dx + dy * dy > radius * radius) draw = 0; + } else if (px < radius && py >= h - radius) { + // Bottom-left corner + int dx = radius - px - 1; + int dy = py - (h - radius); + if (dx * dx + dy * dy > radius * radius) draw = 0; + } else if (px >= w - radius && py >= h - radius) { + // Bottom-right corner + int dx = px - (w - radius); + int dy = py - (h - radius); + if (dx * dx + dy * dy > radius * radius) draw = 0; + } + + if (draw) { + pixels[(y + py) * pitch + (x + px)] = color; + } + } + } +} + +/////////////////////////////// +// Internal helpers +/////////////////////////////// + +static void remove_notification(int index) { + if (index < 0 || index >= notification_count) return; + + // Shift remaining notifications down + for (int i = index; i < notification_count - 1; i++) { + notifications[i] = notifications[i + 1]; + } + notification_count--; + render_dirty = 1; +} + +/////////////////////////////// +// Public API +/////////////////////////////// + +void Notification_init(void) { + notification_count = 0; + memset(notifications, 0, sizeof(notifications)); + + // Initialize scaled visual constants (compact pills) + notif_padding_x = SCALE1(8); + notif_padding_y = SCALE1(4); + notif_margin = SCALE1(12); + notif_stack_gap = SCALE1(6); + notif_icon_gap = SCALE1(4); // Gap between icon and text + + // Store screen dimensions for layer rendering + screen_width = FIXED_WIDTH; + screen_height = FIXED_HEIGHT; + + render_dirty = 1; + last_notification_count = 0; + initialized = 1; +} + +void Notification_push(NotificationType type, const char* message, SDL_Surface* icon) { + if (!initialized) { + return; + } + + // Check if notifications are enabled for this type + if (type == NOTIFICATION_ACHIEVEMENT && !CFG_getRAShowNotifications()) { + return; + } + + // If queue is full, remove oldest notification + if (notification_count >= NOTIFICATION_MAX_QUEUE) { + remove_notification(0); + } + + // Add new notification at end of queue + Notification* n = ¬ifications[notification_count]; + n->type = type; + strncpy(n->message, message, NOTIFICATION_MAX_MESSAGE - 1); + n->message[NOTIFICATION_MAX_MESSAGE - 1] = '\0'; + n->icon = icon; + n->start_time = SDL_GetTicks(); + + // Use RA-specific duration for achievement notifications + if (type == NOTIFICATION_ACHIEVEMENT) { + n->duration_ms = CFG_getRANotificationDuration() * 1000; + } else { + n->duration_ms = CFG_getNotifyDuration() * 1000; + } + n->state = NOTIFICATION_STATE_VISIBLE; + + notification_count++; + render_dirty = 1; +} + +void Notification_update(uint32_t now) { + if (!initialized) return; + + // Update system indicator timeout + if (system_indicator_type != SYSTEM_INDICATOR_NONE) { + uint32_t elapsed = now - system_indicator_start_time; + if (elapsed >= SYSTEM_INDICATOR_DURATION_MS) { + system_indicator_type = SYSTEM_INDICATOR_NONE; + system_indicator_dirty = 1; + } + } + + // Update progress indicator timeout + if (progress_indicator_active) { + uint32_t elapsed = now - progress_indicator_start_time; + int duration_seconds = CFG_getRAProgressNotificationDuration(); + if (duration_seconds > 0 && elapsed >= (uint32_t)(duration_seconds * 1000)) { + progress_indicator_active = 0; + progress_indicator_dirty = 1; + } + } + + // Check each notification for expiration + for (int i = 0; i < notification_count; i++) { + Notification* n = ¬ifications[i]; + uint32_t elapsed = now - n->start_time; + + if (n->state == NOTIFICATION_STATE_VISIBLE && elapsed >= n->duration_ms) { + n->state = NOTIFICATION_STATE_DONE; + } + } + + // Remove completed notifications (iterate backwards to avoid index issues) + for (int i = notification_count - 1; i >= 0; i--) { + if (notifications[i].state == NOTIFICATION_STATE_DONE) { + remove_notification(i); + } + } +} + +// Persistent surface for GL rendering +static SDL_Surface* gl_notification_surface = NULL; +static int needs_clear_frame = 0; + +void Notification_renderToLayer(int layer) { + (void)layer; // unused now, kept for API compatibility + + if (!initialized) { + PLAT_clearNotificationSurface(); + return; + } + + int has_notifications = notification_count > 0; + int has_system_indicator = system_indicator_type != SYSTEM_INDICATOR_NONE; + int has_progress_indicator = progress_indicator_active; + + if (!has_notifications && !has_system_indicator && !has_progress_indicator) { + // When all notifications and indicators are gone, render one final transparent frame + if (gl_notification_surface) { + if (needs_clear_frame) { + SDL_FillRect(gl_notification_surface, NULL, 0); + PLAT_setNotificationSurface(gl_notification_surface, 0, 0); + needs_clear_frame = 0; + render_dirty = 0; + system_indicator_dirty = 0; + progress_indicator_dirty = 0; + last_system_indicator_type = SYSTEM_INDICATOR_NONE; + return; + } + PLAT_clearNotificationSurface(); + SDL_FreeSurface(gl_notification_surface); + gl_notification_surface = NULL; + } + return; + } + + // We have notifications or indicators + needs_clear_frame = 1; + + // Check if anything changed + int notifications_changed = render_dirty || notification_count != last_notification_count; + int indicator_changed = system_indicator_dirty || system_indicator_type != last_system_indicator_type; + int progress_changed = progress_indicator_dirty; + + if (!notifications_changed && !indicator_changed && !progress_changed) { + // Nothing changed, just keep the existing surface + return; + } + + // Create surface if needed + if (!gl_notification_surface) { + gl_notification_surface = SDL_CreateRGBSurfaceWithFormat( + 0, screen_width, screen_height, 32, SDL_PIXELFORMAT_ABGR8888 + ); + if (!gl_notification_surface) { + return; + } + } + + // Clear to transparent + SDL_FillRect(gl_notification_surface, NULL, 0); + + // Render system indicator in top-right if active + if (has_system_indicator) { + // Calculate position: top-right corner with padding + int indicator_width = SCALE1(PILL_SIZE + SETTINGS_WIDTH + 10 + 4); + int indicator_height = SCALE1(PILL_SIZE); + int indicator_x = screen_width - SCALE1(PADDING) - indicator_width; + int indicator_y = SCALE1(PADDING); + + // Create a temporary surface with the SAME format as gfx.screen + // This is critical because theme colors (THEME_COLOR2, etc.) were mapped + // using SDL_MapRGB(gfx.screen->format, ...), so they only work correctly + // on surfaces with that same pixel format. + SDL_Surface* indicator_surface = GFX_createScreenFormatSurface(indicator_width, indicator_height); + if (indicator_surface) { + // Clear to transparent/black + SDL_FillRect(indicator_surface, NULL, 0); + + // Render the indicator at (0,0) on the temp surface + GFX_blitHardwareIndicator(indicator_surface, 0, 0, system_indicator_type); + + // Convert to RGBA for the notification overlay + SDL_Surface* converted = SDL_ConvertSurfaceFormat(indicator_surface, SDL_PIXELFORMAT_ABGR8888, 0); + if (converted) { + SDL_SetSurfaceBlendMode(converted, SDL_BLENDMODE_NONE); + SDL_Rect dst_rect = {indicator_x, indicator_y, indicator_width, indicator_height}; + SDL_BlitSurface(converted, NULL, gl_notification_surface, &dst_rect); + SDL_FreeSurface(converted); + } + SDL_FreeSurface(indicator_surface); + } + } + + // Render progress indicator in top-left if active + if (has_progress_indicator) { + // Get theme colors + SDL_Color text_color = uintToColour(THEME_COLOR1_255); // Main Color + SDL_Color bg_color_sdl = uintToColour(THEME_COLOR2_255); // Primary Accent Color + + // Format: "Title: Progress" (e.g., "Coin Collector: 50/100") + char progress_text[PROGRESS_TITLE_MAX + PROGRESS_STRING_MAX + 4]; + snprintf(progress_text, sizeof(progress_text), "%s: %s", + progress_indicator_title, progress_indicator_progress); + + // Calculate text size using tiny font + int text_w = 0, text_h = 0; + TTF_SizeUTF8(font.tiny, progress_text, &text_w, &text_h); + + // Calculate icon dimensions if present + int icon_w = 0; + int icon_h = 0; + int icon_total_w = 0; + if (progress_indicator_icon) { + icon_h = text_h; // Match text height + icon_w = (progress_indicator_icon->w * icon_h) / progress_indicator_icon->h; + icon_total_w = icon_w + notif_icon_gap; + } + + // Calculate pill dimensions + int pill_w = icon_total_w + text_w + (notif_padding_x * 2); + int pill_h = text_h + (notif_padding_y * 2); + int corner_radius = pill_h / 2; + + // Position: top-left corner with padding + int x = notif_margin; + int y = notif_margin; + + // Create temporary surface for the progress pill + SDL_Surface* progress_surface = SDL_CreateRGBSurfaceWithFormat( + 0, pill_w, pill_h, 32, SDL_PIXELFORMAT_ABGR8888 + ); + if (progress_surface) { + // Clear to transparent + SDL_FillRect(progress_surface, NULL, 0); + + // Draw rounded pill background + Uint32 bg_color = SDL_MapRGBA(progress_surface->format, + bg_color_sdl.r, bg_color_sdl.g, bg_color_sdl.b, 255); + draw_rounded_rect(progress_surface, 0, 0, pill_w, pill_h, corner_radius, bg_color); + + int content_x = notif_padding_x; + + // Draw icon if present + if (progress_indicator_icon && icon_w > 0 && icon_h > 0) { + SDL_Rect icon_dst = {content_x, notif_padding_y, icon_w, icon_h}; + SDL_SetSurfaceBlendMode(progress_indicator_icon, SDL_BLENDMODE_BLEND); + SDL_BlitScaled(progress_indicator_icon, NULL, progress_surface, &icon_dst); + content_x += icon_total_w; + } + + // Draw text + SDL_Surface* text_surf = TTF_RenderUTF8_Blended(font.tiny, progress_text, text_color); + if (text_surf) { + SDL_SetSurfaceBlendMode(text_surf, SDL_BLENDMODE_BLEND); + SDL_Rect text_dst = {content_x, notif_padding_y, text_surf->w, text_surf->h}; + SDL_BlitSurface(text_surf, NULL, progress_surface, &text_dst); + SDL_FreeSurface(text_surf); + } + + // Blit to the full notification surface + SDL_SetSurfaceBlendMode(progress_surface, SDL_BLENDMODE_NONE); + SDL_Rect dst_rect = {x, y, pill_w, pill_h}; + SDL_BlitSurface(progress_surface, NULL, gl_notification_surface, &dst_rect); + + SDL_FreeSurface(progress_surface); + } + } + + // Render text notifications (bottom-left by default) + if (has_notifications) { + // Get theme colors + SDL_Color text_color = uintToColour(THEME_COLOR1_255); // Main Color + SDL_Color bg_color_sdl = uintToColour(THEME_COLOR2_255); // Primary Accent Color + + int base_x = notif_margin; + int base_y = screen_height - notif_margin; + + for (int i = 0; i < notification_count; i++) { + Notification* n = ¬ifications[i]; + + // Calculate text size using tiny font + int text_w = 0, text_h = 0; + TTF_SizeUTF8(font.tiny, n->message, &text_w, &text_h); + + // Calculate icon dimensions if present + int icon_w = 0; + int icon_h = 0; + int icon_total_w = 0; // icon width + gap + if (n->icon) { + // Scale icon to fit notification height + icon_h = text_h; // Match text height + icon_w = (n->icon->w * icon_h) / n->icon->h; + icon_total_w = icon_w + notif_icon_gap; + } + + // Calculate pill dimensions (icon + text) + int pill_w = icon_total_w + text_w + (notif_padding_x * 2); + int pill_h = text_h + (notif_padding_y * 2); + int corner_radius = pill_h / 2; // Fully rounded ends (pill shape) + + // Position: stack upward from bottom + int stack_offset = 0; + for (int j = i + 1; j < notification_count; j++) { + int other_text_h = 0; + TTF_SizeUTF8(font.tiny, notifications[j].message, NULL, &other_text_h); + int other_icon_h = notifications[j].icon ? other_text_h : 0; + int other_pill_h = (other_text_h > other_icon_h ? other_text_h : other_icon_h) + (notif_padding_y * 2); + stack_offset += other_pill_h + notif_stack_gap; + } + + int x = base_x; + int y = base_y - pill_h - stack_offset; + + // Create temporary surface for this notification pill + SDL_Surface* notif_surface = SDL_CreateRGBSurfaceWithFormat( + 0, pill_w, pill_h, 32, SDL_PIXELFORMAT_ABGR8888 + ); + if (!notif_surface) continue; + + // Clear to transparent first + SDL_FillRect(notif_surface, NULL, 0); + + // Draw rounded pill background with accent color (fully opaque) + Uint32 bg_color = SDL_MapRGBA(notif_surface->format, bg_color_sdl.r, bg_color_sdl.g, bg_color_sdl.b, 255); + draw_rounded_rect(notif_surface, 0, 0, pill_w, pill_h, corner_radius, bg_color); + + int content_x = notif_padding_x; + + // Draw icon if present + if (n->icon && icon_w > 0 && icon_h > 0) { + // Scale and blit icon + SDL_Rect icon_dst = {content_x, notif_padding_y, icon_w, icon_h}; + SDL_SetSurfaceBlendMode(n->icon, SDL_BLENDMODE_BLEND); + SDL_BlitScaled(n->icon, NULL, notif_surface, &icon_dst); + content_x += icon_total_w; + } + + // Draw text with main color + SDL_Surface* text_surf = TTF_RenderUTF8_Blended(font.tiny, n->message, text_color); + if (text_surf) { + SDL_SetSurfaceBlendMode(text_surf, SDL_BLENDMODE_BLEND); + SDL_Rect text_dst = {content_x, notif_padding_y, text_surf->w, text_surf->h}; + SDL_BlitSurface(text_surf, NULL, notif_surface, &text_dst); + SDL_FreeSurface(text_surf); + } + + // Blit to the full notification surface + SDL_SetSurfaceBlendMode(notif_surface, SDL_BLENDMODE_NONE); + SDL_Rect dst_rect = {x, y, pill_w, pill_h}; + SDL_BlitSurface(notif_surface, NULL, gl_notification_surface, &dst_rect); + + SDL_FreeSurface(notif_surface); + } + } + + // Set the notification surface for GL rendering + PLAT_setNotificationSurface(gl_notification_surface, 0, 0); + + render_dirty = 0; + last_notification_count = notification_count; + system_indicator_dirty = 0; + progress_indicator_dirty = 0; + last_system_indicator_type = system_indicator_type; +} + +bool Notification_isActive(void) { + return initialized && notification_count > 0; +} + +void Notification_clear(void) { + notification_count = 0; + progress_indicator_active = 0; + progress_indicator_icon = NULL; + render_dirty = 1; + progress_indicator_dirty = 1; + PLAT_clearNotificationSurface(); + if (gl_notification_surface) { + SDL_FreeSurface(gl_notification_surface); + gl_notification_surface = NULL; + } +} + +void Notification_quit(void) { + Notification_clear(); + system_indicator_type = SYSTEM_INDICATOR_NONE; + progress_indicator_active = 0; + initialized = 0; +} + +/////////////////////////////// +// System Indicator Functions +/////////////////////////////// + +void Notification_showSystemIndicator(SystemIndicatorType type) { + if (!initialized) return; + if (type == SYSTEM_INDICATOR_NONE) return; + + // Update or start the indicator + system_indicator_type = type; + system_indicator_start_time = SDL_GetTicks(); + system_indicator_dirty = 1; +} + +bool Notification_hasSystemIndicator(void) { + return initialized && system_indicator_type != SYSTEM_INDICATOR_NONE; +} + +int Notification_getSystemIndicatorWidth(void) { + if (!initialized || system_indicator_type == SYSTEM_INDICATOR_NONE) { + return 0; + } + return SCALE1(PILL_SIZE + SETTINGS_WIDTH + 10 + 4); +} + +/////////////////////////////// +// Progress Indicator Functions +/////////////////////////////// + +void Notification_showProgressIndicator(const char* title, const char* progress, SDL_Surface* icon) { + if (!initialized) return; + + // Check if RA notifications are enabled + if (!CFG_getRAShowNotifications()) return; + + // Copy the title and progress strings + strncpy(progress_indicator_title, title, PROGRESS_TITLE_MAX - 1); + progress_indicator_title[PROGRESS_TITLE_MAX - 1] = '\0'; + + strncpy(progress_indicator_progress, progress, PROGRESS_STRING_MAX - 1); + progress_indicator_progress[PROGRESS_STRING_MAX - 1] = '\0'; + + // Store icon reference (caller retains ownership) + progress_indicator_icon = icon; + + // Activate and reset timer + progress_indicator_active = 1; + progress_indicator_start_time = SDL_GetTicks(); + progress_indicator_dirty = 1; +} + +void Notification_hideProgressIndicator(void) { + if (!initialized) return; + + if (progress_indicator_active) { + progress_indicator_active = 0; + progress_indicator_icon = NULL; + progress_indicator_dirty = 1; + } +} + +bool Notification_hasProgressIndicator(void) { + return initialized && progress_indicator_active; +} diff --git a/workspace/all/common/notification.h b/workspace/all/common/notification.h new file mode 100644 index 000000000..978450cc4 --- /dev/null +++ b/workspace/all/common/notification.h @@ -0,0 +1,150 @@ +#ifndef __NOTIFICATION_H__ +#define __NOTIFICATION_H__ + +#include "sdl.h" +#include +#include + +/////////////////////////////// +// Notification System +// Toast-style notifications for save states, achievements, etc. +// Also handles system indicators (volume/brightness/colortemp) during gameplay. +/////////////////////////////// + +#define NOTIFICATION_MAX_QUEUE 4 +#define NOTIFICATION_MAX_MESSAGE 64 + +// Duration for system indicators (in ms) - matches SETTING_DELAY +#define SYSTEM_INDICATOR_DURATION_MS 500 + +typedef enum { + NOTIFICATION_SAVE_STATE, + NOTIFICATION_LOAD_STATE, + NOTIFICATION_SETTING, // volume/brightness/colortemp adjustments + NOTIFICATION_ACHIEVEMENT, // RetroAchievements unlocks +} NotificationType; + +typedef enum { + NOTIFICATION_STATE_VISIBLE, // Fully visible, waiting + NOTIFICATION_STATE_DONE, // Ready for removal +} NotificationState; + +// System indicator types (volume/brightness/colortemp) +// These values match the show_setting values from PWR_update: 1=brightness, 2=volume, 3=colortemp +typedef enum { + SYSTEM_INDICATOR_NONE = 0, + SYSTEM_INDICATOR_BRIGHTNESS = 1, + SYSTEM_INDICATOR_VOLUME = 2, + SYSTEM_INDICATOR_COLORTEMP = 3, +} SystemIndicatorType; + +typedef struct { + NotificationType type; + char message[NOTIFICATION_MAX_MESSAGE]; + SDL_Surface* icon; // Optional, NULL for text-only (future use) + uint32_t start_time; // SDL_GetTicks() when notification started + uint32_t duration_ms; // How long to stay visible + NotificationState state; +} Notification; + +/** + * Initialize the notification system. + * Call once at startup after GFX is initialized. + */ +void Notification_init(void); + +/** + * Push a new notification to the queue. + * @param type The notification type + * @param message The message to display (copied internally) + * @param icon Optional icon surface (can be NULL). Caller retains ownership. + */ +void Notification_push(NotificationType type, const char* message, SDL_Surface* icon); + +/** + * Update notification timeouts. + * Call every frame with current tick count. + * @param now Current SDL_GetTicks() value + */ +void Notification_update(uint32_t now); + +/** + * Render all active notifications to a specific layer. + * Use this for OpenGL/layer-based rendering during gameplay. + * @param layer The layer number (1-5, 5 being topmost) + */ +void Notification_renderToLayer(int layer); + +/** + * Check if there are any active notifications. + * @return true if notifications are being displayed + */ +bool Notification_isActive(void); + +/** + * Clear all notifications immediately. + */ +void Notification_clear(void); + +/** + * Cleanup the notification system. + * Call at shutdown. + */ +void Notification_quit(void); + +/////////////////////////////// +// System Indicators (Volume/Brightness/Colortemp) +// Always displayed in top-right, matches GFX_blitHardwareGroup visual style. +/////////////////////////////// + +/** + * Show a system indicator (volume/brightness/colortemp). + * System indicators are always displayed in the top-right corner. + * They update in-place (no stacking) and have a short duration (~500ms). + * @param type The indicator type (SYSTEM_INDICATOR_BRIGHTNESS, VOLUME, or COLORTEMP) + */ +void Notification_showSystemIndicator(SystemIndicatorType type); + +/** + * Check if a system indicator is currently being displayed. + * @return true if a system indicator is active + */ +bool Notification_hasSystemIndicator(void); + +/** + * Get the width of the system indicator pill. + * Useful for calculating positions of other elements. + * @return The width in pixels, or 0 if no indicator is active + */ +int Notification_getSystemIndicatorWidth(void); + +/////////////////////////////// +// Achievement Progress Indicator +// Shows progress updates for measured achievements (e.g., "50/100 coins"). +// Displayed in top-left, updates in place, auto-hides after timeout. +// Duration is controlled by CFG_getRAProgressNotificationDuration() +/////////////////////////////// + +/** + * Show or update the achievement progress indicator. + * Progress indicators are displayed in the top-left corner. + * They update in-place and auto-hide after a timeout. + * @param title Achievement title (copied internally) + * @param progress Progress string like "50/100" (copied internally) + * @param icon Optional badge icon (can be NULL). Caller retains ownership. + */ +void Notification_showProgressIndicator(const char* title, const char* progress, SDL_Surface* icon); + +/** + * Hide the achievement progress indicator immediately. + * Called when RC_CLIENT_EVENT_ACHIEVEMENT_PROGRESS_INDICATOR_HIDE fires. + */ +void Notification_hideProgressIndicator(void); + +/** + * Check if a progress indicator is currently being displayed. + * @return true if a progress indicator is active + */ +bool Notification_hasProgressIndicator(void); + +#endif // __NOTIFICATION_H__ diff --git a/workspace/all/common/ra_auth.c b/workspace/all/common/ra_auth.c new file mode 100644 index 000000000..c687afdd1 --- /dev/null +++ b/workspace/all/common/ra_auth.c @@ -0,0 +1,271 @@ +#include "ra_auth.h" +#include "http.h" +#include "defines.h" + +#include +#include +#include + +// RetroAchievements API endpoints +#define RA_API_URL "https://retroachievements.org/dorequest.php" + +// Simple JSON parsing helpers (minimal implementation for RA responses) +// RA login response format: {"Success":true,"User":"username","Token":"token","Score":0,...} +// RA error format: {"Success":false,"Error":"error message"} + +static const char* find_json_string(const char* json, const char* key, char* out, size_t out_size) { + if (!json || !key || !out || out_size == 0) return NULL; + + // Search for "key":"value" pattern + char search[128]; + snprintf(search, sizeof(search), "\"%s\":\"", key); + + const char* start = strstr(json, search); + if (!start) { + // Try "key": "value" (with space) + snprintf(search, sizeof(search), "\"%s\": \"", key); + start = strstr(json, search); + if (!start) return NULL; + } + + start += strlen(search); + const char* end = strchr(start, '"'); + if (!end) return NULL; + + size_t len = end - start; + if (len >= out_size) len = out_size - 1; + + strncpy(out, start, len); + out[len] = '\0'; + + return out; +} + +static int find_json_bool(const char* json, const char* key) { + if (!json || !key) return -1; + + // Search for "key":true or "key":false + char search_true[128]; + char search_false[128]; + snprintf(search_true, sizeof(search_true), "\"%s\":true", key); + snprintf(search_false, sizeof(search_false), "\"%s\":false", key); + + if (strstr(json, search_true)) return 1; + if (strstr(json, search_false)) return 0; + + // Try with space after colon + snprintf(search_true, sizeof(search_true), "\"%s\": true", key); + snprintf(search_false, sizeof(search_false), "\"%s\": false", key); + + if (strstr(json, search_true)) return 1; + if (strstr(json, search_false)) return 0; + + return -1; +} + +// Parse RA login response +static void parse_login_response(const char* json, RA_AuthResponse* response) { + if (!json || !response) return; + + // Check for Success field + int success = find_json_bool(json, "Success"); + + if (success == 1) { + response->result = RA_AUTH_SUCCESS; + + // Extract Token + find_json_string(json, "Token", response->token, sizeof(response->token)); + + // Extract User (display name) + find_json_string(json, "User", response->display_name, sizeof(response->display_name)); + + if (strlen(response->token) == 0) { + // Token missing in success response - shouldn't happen but handle it + response->result = RA_AUTH_ERROR_PARSE; + strncpy(response->error_message, "Token missing in response", + sizeof(response->error_message) - 1); + } + } else if (success == 0) { + response->result = RA_AUTH_ERROR_INVALID; + + // Try to extract error message + if (!find_json_string(json, "Error", response->error_message, + sizeof(response->error_message))) { + strncpy(response->error_message, "Invalid credentials", + sizeof(response->error_message) - 1); + } + } else { + // Couldn't parse Success field + response->result = RA_AUTH_ERROR_PARSE; + strncpy(response->error_message, "Invalid response format", + sizeof(response->error_message) - 1); + } +} + +// Async authentication context +typedef struct { + RA_AuthCallback callback; + void* userdata; +} RA_AsyncAuthContext; + +// HTTP callback for async auth +static void ra_auth_http_callback(HTTP_Response* http_response, void* userdata) { + RA_AsyncAuthContext* ctx = (RA_AsyncAuthContext*)userdata; + RA_AuthResponse response = {0}; + + if (!http_response) { + response.result = RA_AUTH_ERROR_UNKNOWN; + strncpy(response.error_message, "No response received", + sizeof(response.error_message) - 1); + } else if (http_response->error) { + response.result = RA_AUTH_ERROR_NETWORK; + strncpy(response.error_message, http_response->error, + sizeof(response.error_message) - 1); + } else if (http_response->http_status != 200) { + response.result = RA_AUTH_ERROR_NETWORK; + snprintf(response.error_message, sizeof(response.error_message), + "HTTP error %d", http_response->http_status); + } else if (!http_response->data || http_response->size == 0) { + response.result = RA_AUTH_ERROR_PARSE; + strncpy(response.error_message, "Empty response", + sizeof(response.error_message) - 1); + } else { + // Parse the JSON response + parse_login_response(http_response->data, &response); + } + + // Call the user's callback + if (ctx->callback) { + ctx->callback(&response, ctx->userdata); + } + + // Cleanup + HTTP_freeResponse(http_response); + free(ctx); +} + +void RA_authenticate(const char* username, const char* password, + RA_AuthCallback callback, void* userdata) { + if (!username || !password) { + RA_AuthResponse response = {0}; + response.result = RA_AUTH_ERROR_INVALID; + strncpy(response.error_message, "Username and password required", + sizeof(response.error_message) - 1); + if (callback) callback(&response, userdata); + return; + } + + // URL-encode username and password + char* enc_username = HTTP_urlEncode(username); + char* enc_password = HTTP_urlEncode(password); + + if (!enc_username || !enc_password) { + free(enc_username); + free(enc_password); + RA_AuthResponse response = {0}; + response.result = RA_AUTH_ERROR_UNKNOWN; + strncpy(response.error_message, "Memory allocation failed", + sizeof(response.error_message) - 1); + if (callback) callback(&response, userdata); + return; + } + + // Build POST data: r=login&u=username&p=password + char post_data[512]; + snprintf(post_data, sizeof(post_data), "r=login&u=%s&p=%s", + enc_username, enc_password); + + free(enc_username); + free(enc_password); + + // Create async context + RA_AsyncAuthContext* ctx = calloc(1, sizeof(RA_AsyncAuthContext)); + if (!ctx) { + RA_AuthResponse response = {0}; + response.result = RA_AUTH_ERROR_UNKNOWN; + strncpy(response.error_message, "Memory allocation failed", + sizeof(response.error_message) - 1); + if (callback) callback(&response, userdata); + return; + } + + ctx->callback = callback; + ctx->userdata = userdata; + + // Make async POST request + HTTP_postAsync(RA_API_URL, post_data, NULL, ra_auth_http_callback, ctx); +} + +RA_AuthResult RA_authenticateSync(const char* username, const char* password, + RA_AuthResponse* response) { + if (!response) return RA_AUTH_ERROR_UNKNOWN; + memset(response, 0, sizeof(RA_AuthResponse)); + + if (!username || !password) { + response->result = RA_AUTH_ERROR_INVALID; + strncpy(response->error_message, "Username and password required", + sizeof(response->error_message) - 1); + return response->result; + } + + // URL-encode username and password + char* enc_username = HTTP_urlEncode(username); + char* enc_password = HTTP_urlEncode(password); + + if (!enc_username || !enc_password) { + free(enc_username); + free(enc_password); + response->result = RA_AUTH_ERROR_UNKNOWN; + strncpy(response->error_message, "Memory allocation failed", + sizeof(response->error_message) - 1); + return response->result; + } + + // Build POST data + char post_data[512]; + snprintf(post_data, sizeof(post_data), "r=login&u=%s&p=%s", + enc_username, enc_password); + + free(enc_username); + free(enc_password); + + // Make synchronous POST request + HTTP_Response* http_response = HTTP_post(RA_API_URL, post_data, NULL); + + if (!http_response) { + response->result = RA_AUTH_ERROR_UNKNOWN; + strncpy(response->error_message, "No response received", + sizeof(response->error_message) - 1); + return response->result; + } + + if (http_response->error) { + response->result = RA_AUTH_ERROR_NETWORK; + strncpy(response->error_message, http_response->error, + sizeof(response->error_message) - 1); + HTTP_freeResponse(http_response); + return response->result; + } + + if (http_response->http_status != 200) { + response->result = RA_AUTH_ERROR_NETWORK; + snprintf(response->error_message, sizeof(response->error_message), + "HTTP error %d", http_response->http_status); + HTTP_freeResponse(http_response); + return response->result; + } + + if (!http_response->data || http_response->size == 0) { + response->result = RA_AUTH_ERROR_PARSE; + strncpy(response->error_message, "Empty response", + sizeof(response->error_message) - 1); + HTTP_freeResponse(http_response); + return response->result; + } + + // Parse the JSON response + parse_login_response(http_response->data, response); + + HTTP_freeResponse(http_response); + return response->result; +} diff --git a/workspace/all/common/ra_auth.h b/workspace/all/common/ra_auth.h new file mode 100644 index 000000000..e6d88e3e0 --- /dev/null +++ b/workspace/all/common/ra_auth.h @@ -0,0 +1,63 @@ +#ifndef __RA_AUTH_H__ +#define __RA_AUTH_H__ + +/** + * RetroAchievements Authentication Helper + * + * Provides functions to authenticate with RetroAchievements servers + * and manage API tokens. + */ + +/** + * Authentication result codes + */ +typedef enum { + RA_AUTH_SUCCESS = 0, // Authentication successful + RA_AUTH_ERROR_NETWORK, // Network/connection error + RA_AUTH_ERROR_INVALID, // Invalid credentials + RA_AUTH_ERROR_PARSE, // Failed to parse response + RA_AUTH_ERROR_UNKNOWN // Unknown error +} RA_AuthResult; + +/** + * Authentication response data + */ +typedef struct { + RA_AuthResult result; + char token[64]; // API token on success + char display_name[64]; // Display name on success + char error_message[256]; // Error message on failure +} RA_AuthResponse; + +/** + * Callback for async authentication requests. + * @param response Authentication response (valid until callback returns) + * @param userdata User-provided data passed to the auth request + */ +typedef void (*RA_AuthCallback)(const RA_AuthResponse* response, void* userdata); + +/** + * Authenticate with RetroAchievements using username and password. + * This is an async operation - the callback will be called when complete. + * + * @param username RetroAchievements username + * @param password RetroAchievements password (will be URL-encoded) + * @param callback Function to call with the result + * @param userdata User data to pass to callback + */ +void RA_authenticate(const char* username, const char* password, + RA_AuthCallback callback, void* userdata); + +/** + * Synchronous authentication (blocks until complete). + * Useful for simpler contexts where async isn't needed. + * + * @param username RetroAchievements username + * @param password RetroAchievements password + * @param response Output: authentication response + * @return RA_AuthResult code + */ +RA_AuthResult RA_authenticateSync(const char* username, const char* password, + RA_AuthResponse* response); + +#endif // __RA_AUTH_H__ diff --git a/workspace/all/common/ra_badges.c b/workspace/all/common/ra_badges.c new file mode 100644 index 000000000..18b6b8349 --- /dev/null +++ b/workspace/all/common/ra_badges.c @@ -0,0 +1,416 @@ +#include "ra_badges.h" +#include "http.h" +#include "defines.h" +#include "sdl.h" + +#include +#include +#include +#include +#include +#include + +// Logging macro +#define BADGE_LOG(fmt, ...) printf("[RA_BADGES] " fmt, ##__VA_ARGS__) + +/***************************************************************************** + * Constants + *****************************************************************************/ + +#define RA_BADGE_BASE_URL "https://media.retroachievements.org/Badge/" +#define MAX_BADGE_NAME 32 +#define MAX_CACHED_BADGES 256 + +/***************************************************************************** + * Badge cache entry + *****************************************************************************/ + +typedef struct { + char badge_name[MAX_BADGE_NAME]; + bool locked; + RA_BadgeState state; + SDL_Surface* surface; + SDL_Surface* surface_scaled; // Pre-scaled for notifications +} BadgeCacheEntry; + +/***************************************************************************** + * Static state + *****************************************************************************/ + +static BadgeCacheEntry badge_cache[MAX_CACHED_BADGES]; +static int badge_cache_count = 0; +static SDL_mutex* badge_mutex = NULL; +static int pending_downloads = 0; +static bool initialized = false; + +/***************************************************************************** + * Internal helpers + *****************************************************************************/ + +// Find or create cache entry for a badge +static BadgeCacheEntry* find_or_create_entry(const char* badge_name, bool locked) { + // Search existing entries + for (int i = 0; i < badge_cache_count; i++) { + if (badge_cache[i].locked == locked && + strcmp(badge_cache[i].badge_name, badge_name) == 0) { + return &badge_cache[i]; + } + } + + // Create new entry if space available + if (badge_cache_count >= MAX_CACHED_BADGES) { + BADGE_LOG("Cache full, cannot add badge %s\n", badge_name); + return NULL; + } + + BadgeCacheEntry* entry = &badge_cache[badge_cache_count++]; + memset(entry, 0, sizeof(BadgeCacheEntry)); + strncpy(entry->badge_name, badge_name, MAX_BADGE_NAME - 1); + entry->locked = locked; + entry->state = RA_BADGE_STATE_UNKNOWN; + + return entry; +} + +// Create cache directory if it doesn't exist +static void ensure_cache_dir(void) { + char path[MAX_PATH]; + + // Create .cache directory + snprintf(path, sizeof(path), "%s/.cache", SDCARD_PATH); + mkdir(path, 0755); + + // Create .cache/ra directory + snprintf(path, sizeof(path), "%s/.cache/ra", SDCARD_PATH); + mkdir(path, 0755); + + // Create .cache/ra/badges directory + snprintf(path, sizeof(path), "%s%s", SDCARD_PATH, RA_BADGE_CACHE_DIR); + mkdir(path, 0755); +} + +// Check if cache file exists +static bool cache_file_exists(const char* path) { + struct stat st; + return stat(path, &st) == 0 && st.st_size > 0; +} + +// Save HTTP response data to cache file +static bool save_to_cache(const char* path, const char* data, size_t size) { + FILE* f = fopen(path, "wb"); + if (!f) { + BADGE_LOG("Failed to open cache file for writing: %s\n", path); + return false; + } + + size_t written = fwrite(data, 1, size, f); + fclose(f); + + if (written != size) { + BADGE_LOG("Failed to write cache file: %s\n", path); + unlink(path); + return false; + } + + return true; +} + +// Load badge image from cache +static SDL_Surface* load_from_cache(const char* path) { + SDL_Surface* surface = IMG_Load(path); + if (!surface) { + BADGE_LOG("Failed to load badge image: %s - %s\n", path, IMG_GetError()); + return NULL; + } + return surface; +} + +// Scale a surface to target size using SDL_BlitScaled for proper format handling +static SDL_Surface* scale_surface(SDL_Surface* src, int target_size) { + if (!src) return NULL; + + // Calculate scale factor to fit in target_size x target_size + float scale_x = (float)target_size / src->w; + float scale_y = (float)target_size / src->h; + float scale = (scale_x < scale_y) ? scale_x : scale_y; + + int new_w = (int)(src->w * scale); + int new_h = (int)(src->h * scale); + + // Create scaled surface with alpha support + SDL_Surface* scaled = SDL_CreateRGBSurfaceWithFormat( + 0, new_w, new_h, 32, SDL_PIXELFORMAT_RGBA32 + ); + if (!scaled) { + return NULL; + } + + // Clear to transparent + SDL_FillRect(scaled, NULL, 0); + + // Use SDL_BlitScaled which handles pixel format conversion properly + SDL_SetSurfaceBlendMode(src, SDL_BLENDMODE_NONE); + SDL_Rect dst_rect = {0, 0, new_w, new_h}; + SDL_BlitScaled(src, NULL, scaled, &dst_rect); + + return scaled; +} + +/***************************************************************************** + * Download callback + *****************************************************************************/ + +typedef struct { + char badge_name[MAX_BADGE_NAME]; + bool locked; + char cache_path[MAX_PATH]; +} DownloadContext; + +static void badge_download_callback(HTTP_Response* response, void* userdata) { + DownloadContext* ctx = (DownloadContext*)userdata; + + if (badge_mutex) SDL_LockMutex(badge_mutex); + + pending_downloads--; + if (pending_downloads < 0) pending_downloads = 0; + + BadgeCacheEntry* entry = find_or_create_entry(ctx->badge_name, ctx->locked); + + if (response && response->data && response->http_status == 200 && !response->error) { + // Save to cache + if (save_to_cache(ctx->cache_path, response->data, response->size)) { + // Load the image + if (entry) { + entry->surface = load_from_cache(ctx->cache_path); + if (entry->surface) { + entry->surface_scaled = scale_surface(entry->surface, RA_BADGE_NOTIFY_SIZE); + entry->state = RA_BADGE_STATE_CACHED; + } else { + entry->state = RA_BADGE_STATE_FAILED; + } + } + } else if (entry) { + entry->state = RA_BADGE_STATE_FAILED; + } + } else { + BADGE_LOG("Failed to download badge %s%s: %s\n", + ctx->badge_name, ctx->locked ? "_lock" : "", + response && response->error ? response->error : "HTTP error"); + if (entry) { + entry->state = RA_BADGE_STATE_FAILED; + } + } + + if (badge_mutex) SDL_UnlockMutex(badge_mutex); + + if (response) { + HTTP_freeResponse(response); + } + free(ctx); +} + +// Start downloading a badge +static void start_download(const char* badge_name, bool locked) { + if (!initialized) return; + + BadgeCacheEntry* entry = find_or_create_entry(badge_name, locked); + if (!entry) return; + + // Check if already downloading or cached + if (entry->state == RA_BADGE_STATE_DOWNLOADING || + entry->state == RA_BADGE_STATE_CACHED) { + return; + } + + // Build URL and cache path + char url[512]; + char cache_path[MAX_PATH]; + RA_Badges_getUrl(badge_name, locked, url, sizeof(url)); + RA_Badges_getCachePath(badge_name, locked, cache_path, sizeof(cache_path)); + + // Check if already cached on disk + if (cache_file_exists(cache_path)) { + entry->surface = load_from_cache(cache_path); + if (entry->surface) { + entry->surface_scaled = scale_surface(entry->surface, RA_BADGE_NOTIFY_SIZE); + entry->state = RA_BADGE_STATE_CACHED; + return; + } + } + + // Need to download + DownloadContext* ctx = (DownloadContext*)malloc(sizeof(DownloadContext)); + if (!ctx) return; + + strncpy(ctx->badge_name, badge_name, MAX_BADGE_NAME - 1); + ctx->badge_name[MAX_BADGE_NAME - 1] = '\0'; + ctx->locked = locked; + strncpy(ctx->cache_path, cache_path, MAX_PATH - 1); + ctx->cache_path[MAX_PATH - 1] = '\0'; + + entry->state = RA_BADGE_STATE_DOWNLOADING; + pending_downloads++; + + HTTP_getAsync(url, badge_download_callback, ctx); +} + +/***************************************************************************** + * Public API + *****************************************************************************/ + +void RA_Badges_init(void) { + if (initialized) return; + + badge_mutex = SDL_CreateMutex(); + badge_cache_count = 0; + pending_downloads = 0; + memset(badge_cache, 0, sizeof(badge_cache)); + + ensure_cache_dir(); + + initialized = true; +} + +void RA_Badges_quit(void) { + if (!initialized) return; + + RA_Badges_clearMemory(); + + if (badge_mutex) { + SDL_DestroyMutex(badge_mutex); + badge_mutex = NULL; + } + + initialized = false; +} + +void RA_Badges_clearMemory(void) { + if (!initialized) return; + + if (badge_mutex) SDL_LockMutex(badge_mutex); + + for (int i = 0; i < badge_cache_count; i++) { + if (badge_cache[i].surface) { + SDL_FreeSurface(badge_cache[i].surface); + badge_cache[i].surface = NULL; + } + if (badge_cache[i].surface_scaled) { + SDL_FreeSurface(badge_cache[i].surface_scaled); + badge_cache[i].surface_scaled = NULL; + } + } + badge_cache_count = 0; + + if (badge_mutex) SDL_UnlockMutex(badge_mutex); +} + +void RA_Badges_prefetch(const char** badge_names, size_t count) { + if (!initialized) return; + + if (badge_mutex) SDL_LockMutex(badge_mutex); + + for (size_t i = 0; i < count; i++) { + if (badge_names[i] && badge_names[i][0]) { + // Download both locked and unlocked versions + start_download(badge_names[i], false); + start_download(badge_names[i], true); + } + } + + if (badge_mutex) SDL_UnlockMutex(badge_mutex); +} + +void RA_Badges_prefetchOne(const char* badge_name, bool locked) { + if (!initialized || !badge_name || !badge_name[0]) return; + + if (badge_mutex) SDL_LockMutex(badge_mutex); + start_download(badge_name, locked); + if (badge_mutex) SDL_UnlockMutex(badge_mutex); +} + +SDL_Surface* RA_Badges_get(const char* badge_name, bool locked) { + if (!initialized || !badge_name || !badge_name[0]) return NULL; + + if (badge_mutex) SDL_LockMutex(badge_mutex); + + BadgeCacheEntry* entry = find_or_create_entry(badge_name, locked); + SDL_Surface* result = NULL; + + if (entry) { + if (entry->state == RA_BADGE_STATE_CACHED && entry->surface) { + result = entry->surface; + } else if (entry->state == RA_BADGE_STATE_UNKNOWN) { + // Trigger download + start_download(badge_name, locked); + } + } + + if (badge_mutex) SDL_UnlockMutex(badge_mutex); + + return result; +} + +SDL_Surface* RA_Badges_getNotificationSize(const char* badge_name, bool locked) { + if (!initialized || !badge_name || !badge_name[0]) return NULL; + + if (badge_mutex) SDL_LockMutex(badge_mutex); + + BadgeCacheEntry* entry = find_or_create_entry(badge_name, locked); + SDL_Surface* result = NULL; + + if (entry) { + if (entry->state == RA_BADGE_STATE_CACHED && entry->surface_scaled) { + result = entry->surface_scaled; + } else if (entry->state == RA_BADGE_STATE_UNKNOWN) { + // Trigger download + start_download(badge_name, locked); + } + } + + if (badge_mutex) SDL_UnlockMutex(badge_mutex); + + return result; +} + +RA_BadgeState RA_Badges_getState(const char* badge_name, bool locked) { + if (!initialized || !badge_name || !badge_name[0]) return RA_BADGE_STATE_UNKNOWN; + + if (badge_mutex) SDL_LockMutex(badge_mutex); + + RA_BadgeState state = RA_BADGE_STATE_UNKNOWN; + + for (int i = 0; i < badge_cache_count; i++) { + if (badge_cache[i].locked == locked && + strcmp(badge_cache[i].badge_name, badge_name) == 0) { + state = badge_cache[i].state; + break; + } + } + + if (badge_mutex) SDL_UnlockMutex(badge_mutex); + + return state; +} + +bool RA_Badges_hasPendingDownloads(void) { + if (!initialized) return false; + return pending_downloads > 0; +} + +void RA_Badges_getCachePath(const char* badge_name, bool locked, char* buffer, size_t buffer_size) { + if (locked) { + snprintf(buffer, buffer_size, "%s%s/%s_lock.png", + SDCARD_PATH, RA_BADGE_CACHE_DIR, badge_name); + } else { + snprintf(buffer, buffer_size, "%s%s/%s.png", + SDCARD_PATH, RA_BADGE_CACHE_DIR, badge_name); + } +} + +void RA_Badges_getUrl(const char* badge_name, bool locked, char* buffer, size_t buffer_size) { + if (locked) { + snprintf(buffer, buffer_size, "%s%s_lock.png", RA_BADGE_BASE_URL, badge_name); + } else { + snprintf(buffer, buffer_size, "%s%s.png", RA_BADGE_BASE_URL, badge_name); + } +} diff --git a/workspace/all/common/ra_badges.h b/workspace/all/common/ra_badges.h new file mode 100644 index 000000000..787b911eb --- /dev/null +++ b/workspace/all/common/ra_badges.h @@ -0,0 +1,128 @@ +#ifndef __RA_BADGES_H__ +#define __RA_BADGES_H__ + +#include "sdl.h" +#include +#include + +/** + * RetroAchievements Badge Cache System + * + * Downloads and caches achievement badge images for display in notifications + * and the achievements list. + * + * Cache location: /mnt/SDCARD/.cache/ra/badges/{badge_name}.png + * Badge URLs: https://media.retroachievements.org/Badge/{badge_name}.png + * Locked badges: https://media.retroachievements.org/Badge/{badge_name}_lock.png + */ + +// Badge size for notifications (will be scaled) +#define RA_BADGE_SIZE 64 +#define RA_BADGE_NOTIFY_SIZE 24 // Size for notification icons + +// Cache directory path (under SDCARD_PATH) +#define RA_BADGE_CACHE_DIR "/.cache/ra/badges" + +// Badge state +typedef enum { + RA_BADGE_STATE_UNKNOWN, // Badge not yet requested + RA_BADGE_STATE_DOWNLOADING, // Download in progress + RA_BADGE_STATE_CACHED, // Downloaded and cached locally + RA_BADGE_STATE_FAILED, // Download failed +} RA_BadgeState; + +/** + * Initialize the badge cache system. + * Creates cache directory if needed. + */ +void RA_Badges_init(void); + +/** + * Shutdown the badge cache system. + * Clears any loaded surfaces (but keeps cached files). + */ +void RA_Badges_quit(void); + +/** + * Clear the in-memory badge surface cache. + * Called when unloading a game to free memory. + * Does not delete the on-disk cache. + */ +void RA_Badges_clearMemory(void); + +/** + * Pre-download all badges for the current game's achievements. + * Should be called after game load when achievement list is available. + * Downloads happen asynchronously in background threads. + * + * @param achievements Array of achievement badge names + * @param count Number of achievements + */ +void RA_Badges_prefetch(const char** badge_names, size_t count); + +/** + * Pre-download a single badge asynchronously. + * + * @param badge_name The badge name (e.g., "00234") + * @param locked Whether to download the locked version + */ +void RA_Badges_prefetchOne(const char* badge_name, bool locked); + +/** + * Get a badge surface. Returns cached surface or NULL if not available. + * Downloads the badge if not cached (returns NULL immediately, call again later). + * + * @param badge_name The badge name (e.g., "00234") + * @param locked Whether to get the locked version (_lock suffix) + * @return SDL_Surface* The badge surface, or NULL if not yet cached + * Caller does NOT own the surface - do not free it. + */ +SDL_Surface* RA_Badges_get(const char* badge_name, bool locked); + +/** + * Get a badge surface scaled to notification size. + * + * @param badge_name The badge name (e.g., "00234") + * @param locked Whether to get the locked version + * @return SDL_Surface* The scaled badge surface, or NULL if not available + * Caller does NOT own the surface - do not free it. + */ +SDL_Surface* RA_Badges_getNotificationSize(const char* badge_name, bool locked); + +/** + * Get the state of a badge (whether it's cached, downloading, etc.) + * + * @param badge_name The badge name + * @param locked Whether to check the locked version + * @return RA_BadgeState The current state + */ +RA_BadgeState RA_Badges_getState(const char* badge_name, bool locked); + +/** + * Check if the badge cache system has any pending downloads. + * + * @return true if downloads are in progress + */ +bool RA_Badges_hasPendingDownloads(void); + +/** + * Get the cache file path for a badge. + * + * @param badge_name The badge name + * @param locked Whether to get the locked version path + * @param buffer Output buffer for the path + * @param buffer_size Size of the output buffer + */ +void RA_Badges_getCachePath(const char* badge_name, bool locked, char* buffer, size_t buffer_size); + +/** + * Build the URL for a badge. + * + * @param badge_name The badge name + * @param locked Whether to get the locked version URL + * @param buffer Output buffer for the URL + * @param buffer_size Size of the output buffer + */ +void RA_Badges_getUrl(const char* badge_name, bool locked, char* buffer, size_t buffer_size); + +#endif // __RA_BADGES_H__ diff --git a/workspace/all/minarch/chd_reader.c b/workspace/all/minarch/chd_reader.c new file mode 100644 index 000000000..0c48f723f --- /dev/null +++ b/workspace/all/minarch/chd_reader.c @@ -0,0 +1,467 @@ +/** + * chd_reader.c - CHD file reader for rcheevos hashing + * + * Provides CD reader callbacks for rcheevos to hash CHD disc images. + */ + +#include "chd_reader.h" +#include +#include +#include +#include + +#include +#include + +/***************************************************************************** + * Track info structure + *****************************************************************************/ + +typedef struct { + int type; // CD_TRACK_* type + int frames; // Total frames in track + int pregap_frames; // Pregap frames + int postgap_frames; // Postgap frames + int start_frame; // Starting frame (cumulative) +} chd_track_info_t; + +/***************************************************************************** + * CHD track handle + *****************************************************************************/ + +typedef struct { + chd_file* chd; + uint32_t track_num; + + // Track info + chd_track_info_t tracks[CD_MAX_TRACKS]; + int num_tracks; + + // Current track info + int track_start_frame; // First frame of this track in CHD + int track_frames; // Number of frames in track + int track_type; // CD_TRACK_* type + int track_pregap; // Pregap frames for this track + + // Sector format info (determined from track type) + int sector_header_size; // Bytes to skip to reach raw data (0, 16, or 24) + int raw_data_size; // Size of raw data (2048 or 2352 for audio) + + // Hunk info + uint32_t hunk_bytes; + uint32_t frame_size; // Bytes per frame (typically 2448 or 2352) + int frames_per_hunk; + + // Sector buffer + uint8_t* hunk_buffer; + uint32_t cached_hunk; +} chd_track_handle_t; + +/***************************************************************************** + * Helper: Parse track type string + *****************************************************************************/ + +static int parse_track_type(const char* type_str) { + if (strcmp(type_str, "MODE1") == 0) + return CD_TRACK_MODE1; + if (strcmp(type_str, "MODE1_RAW") == 0 || strcmp(type_str, "MODE1/2352") == 0) + return CD_TRACK_MODE1_RAW; + if (strcmp(type_str, "MODE2") == 0) + return CD_TRACK_MODE2; + if (strcmp(type_str, "MODE2_FORM1") == 0) + return CD_TRACK_MODE2_FORM1; + if (strcmp(type_str, "MODE2_FORM2") == 0) + return CD_TRACK_MODE2_FORM2; + if (strcmp(type_str, "MODE2_FORM_MIX") == 0) + return CD_TRACK_MODE2_FORM_MIX; + if (strcmp(type_str, "MODE2_RAW") == 0 || strcmp(type_str, "MODE2/2352") == 0) + return CD_TRACK_MODE2_RAW; + if (strcmp(type_str, "AUDIO") == 0) + return CD_TRACK_AUDIO; + + return CD_TRACK_MODE1; // Default +} + +/***************************************************************************** + * Helper: Parse CHD track metadata + *****************************************************************************/ + +static int parse_chd_tracks(chd_file* chd, chd_track_info_t* tracks, int* num_tracks) { + char metadata[256]; + uint32_t metadata_size; + int track_idx = 0; + int cumulative_frames = 0; + + // Try CDROM_TRACK_METADATA2 first (newer format with pregap info) + for (int i = 0; i < CD_MAX_TRACKS; i++) { + chd_error err = chd_get_metadata(chd, CDROM_TRACK_METADATA2_TAG, i, + metadata, sizeof(metadata), + &metadata_size, NULL, NULL); + if (err != CHDERR_NONE) { + // Try older format + err = chd_get_metadata(chd, CDROM_TRACK_METADATA_TAG, i, + metadata, sizeof(metadata), + &metadata_size, NULL, NULL); + if (err != CHDERR_NONE) { + // Try GD-ROM format + err = chd_get_metadata(chd, GDROM_TRACK_METADATA_TAG, i, + metadata, sizeof(metadata), + &metadata_size, NULL, NULL); + if (err != CHDERR_NONE) + break; + } + } + + metadata[metadata_size] = '\0'; + + // Parse the metadata string + int track_num, frames, pregap = 0, postgap = 0; + char type_str[32] = {0}; + char subtype_str[32] = {0}; + char pgtype_str[32] = {0}; + char pgsub_str[32] = {0}; + + // Full format 2: "TRACK:%d TYPE:%s SUBTYPE:%s FRAMES:%d PREGAP:%d PGTYPE:%s PGSUB:%s POSTGAP:%d" + int parsed = sscanf(metadata, "TRACK:%d TYPE:%s SUBTYPE:%s FRAMES:%d PREGAP:%d PGTYPE:%s PGSUB:%s POSTGAP:%d", + &track_num, type_str, subtype_str, &frames, &pregap, pgtype_str, pgsub_str, &postgap); + + if (parsed < 4) { + // Try format 1 (no pregap info) + parsed = sscanf(metadata, "TRACK:%d TYPE:%s SUBTYPE:%s FRAMES:%d", + &track_num, type_str, subtype_str, &frames); + pregap = 0; + } + + if (parsed >= 4) { + tracks[track_idx].type = parse_track_type(type_str); + tracks[track_idx].frames = frames; + tracks[track_idx].pregap_frames = pregap; + tracks[track_idx].postgap_frames = postgap; + + // CHD format: 'frames' is the actual data frames, NOT including pregap. + // The cumulative frame_offset points to the first frame of this track's allocation. + tracks[track_idx].start_frame = cumulative_frames; + + // Calculate padding for 4-frame alignment (per CHD spec) + int padding = ((frames + 3) & ~3) - frames; + cumulative_frames += frames + padding; + track_idx++; + } + } + + *num_tracks = track_idx; + return track_idx > 0 ? 0 : -1; +} + +/***************************************************************************** + * Helper: Check if track is data track + *****************************************************************************/ + +static int is_data_track(int type) { + return type != CD_TRACK_AUDIO; +} + +/***************************************************************************** + * Helper: Determine sector header size from track type + * + * Based on rcheevos cdreader.c logic: + * - MODE1/2352 (MODE1_RAW): 16 bytes (12 sync + 4 header) + * - MODE2/2352 (MODE2_RAW): 24 bytes (12 sync + 4 header + 8 subheader) + * - MODE1/2048, MODE2/2048: 0 bytes (cooked) + * - AUDIO: 0 bytes (raw audio) + *****************************************************************************/ + +static void get_sector_format(int track_type, int frame_size, int* header_size, int* data_size) { + // Default: cooked 2048-byte sectors + *data_size = 2048; + *header_size = 0; + + switch (track_type) { + case CD_TRACK_MODE1_RAW: + // MODE1/2352: sync(12) + header(4) + data(2048) + ecc(288) + if (frame_size >= 2352) { + *header_size = 16; + *data_size = 2048; + } + break; + + case CD_TRACK_MODE2_RAW: + case CD_TRACK_MODE2_FORM_MIX: + // MODE2/2352: sync(12) + header(4) + subheader(8) + data(2048) + ecc(280) + if (frame_size >= 2352) { + *header_size = 24; + *data_size = 2048; + } + break; + + case CD_TRACK_MODE2_FORM1: + case CD_TRACK_MODE2_FORM2: + // MODE2 without sync: subheader(8) + data + if (frame_size == 2336) { + *header_size = 8; + *data_size = 2048; + } + break; + + case CD_TRACK_MODE1: + case CD_TRACK_MODE2: + // Cooked sectors, no header + *header_size = 0; + *data_size = 2048; + break; + + case CD_TRACK_AUDIO: + // Audio tracks have no header, all 2352 bytes are data + *header_size = 0; + *data_size = 2352; + break; + + default: + // Unknown type, try to determine from frame size + if (frame_size >= 2352) { + // Assume raw with MODE1 header + *header_size = 16; + *data_size = 2048; + } + break; + } +} + +/***************************************************************************** + * Helper: Find requested track + *****************************************************************************/ + +static int find_track(chd_track_handle_t* handle, uint32_t track_request) { + if (handle->num_tracks == 0) + return -1; + + // Handle special track requests + if (track_request == (uint32_t)-1) { + // RC_HASH_CDTRACK_FIRST_DATA - first data track + for (int i = 0; i < handle->num_tracks; i++) { + if (is_data_track(handle->tracks[i].type)) + return i; + } + return -1; + } + else if (track_request == (uint32_t)-2) { + // RC_HASH_CDTRACK_LAST - last track + return handle->num_tracks - 1; + } + else if (track_request == (uint32_t)-3) { + // RC_HASH_CDTRACK_LARGEST - largest track + int largest_idx = 0; + int largest_frames = 0; + for (int i = 0; i < handle->num_tracks; i++) { + if (handle->tracks[i].frames > largest_frames) { + largest_frames = handle->tracks[i].frames; + largest_idx = i; + } + } + return largest_idx; + } + else if (track_request > 0 && track_request <= (uint32_t)handle->num_tracks) { + // Specific track number (1-based) + return track_request - 1; + } + + return -1; +} + +/***************************************************************************** + * CD Reader callbacks + *****************************************************************************/ + +void* chd_open_track(const char* path, uint32_t track) { + return chd_open_track_iterator(path, track, NULL); +} + +void* chd_open_track_iterator(const char* path, uint32_t track, const void* iterator) { + (void)iterator; // Unused + + // Check if this is a CHD file + const char* ext = strrchr(path, '.'); + if (!ext || strcasecmp(ext, ".chd") != 0) { + return NULL; // Not a CHD file, let default reader handle it + } + + chd_file* chd = NULL; + chd_error err = chd_open(path, CHD_OPEN_READ, NULL, &chd); + if (err != CHDERR_NONE) { + return NULL; + } + + // Allocate handle + chd_track_handle_t* handle = (chd_track_handle_t*)calloc(1, sizeof(chd_track_handle_t)); + if (!handle) { + chd_close(chd); + return NULL; + } + + handle->chd = chd; + handle->track_num = track; + handle->cached_hunk = (uint32_t)-1; + + // Get hunk info + const chd_header* header = chd_get_header(chd); + handle->hunk_bytes = header->hunkbytes; + + // CD frames are typically 2448 bytes (2352 sector + 96 subcode) or 2352 bytes + // Check unit bytes if available, otherwise assume CD_FRAME_SIZE + handle->frame_size = header->unitbytes ? header->unitbytes : CD_FRAME_SIZE; + handle->frames_per_hunk = handle->hunk_bytes / handle->frame_size; + + // Allocate hunk buffer + handle->hunk_buffer = (uint8_t*)malloc(handle->hunk_bytes); + if (!handle->hunk_buffer) { + chd_close(chd); + free(handle); + return NULL; + } + + // Parse track metadata + if (parse_chd_tracks(chd, handle->tracks, &handle->num_tracks) != 0) { + free(handle->hunk_buffer); + chd_close(chd); + free(handle); + return NULL; + } + + // Find requested track + int track_idx = find_track(handle, track); + if (track_idx < 0) { + free(handle->hunk_buffer); + chd_close(chd); + free(handle); + return NULL; + } + + handle->track_num = track_idx + 1; // Convert to 1-based + handle->track_start_frame = handle->tracks[track_idx].start_frame; + handle->track_frames = handle->tracks[track_idx].frames; + handle->track_type = handle->tracks[track_idx].type; + handle->track_pregap = handle->tracks[track_idx].pregap_frames; + + // Determine sector format based on track type + get_sector_format(handle->track_type, handle->frame_size, + &handle->sector_header_size, &handle->raw_data_size); + + return handle; +} + +size_t chd_read_sector(void* track_handle, uint32_t sector, void* buffer, size_t requested_bytes) { + chd_track_handle_t* handle = (chd_track_handle_t*)track_handle; + if (!handle || !handle->chd) + return 0; + + // Convert relative sector number to CHD frame number + // rcheevos calls: read_sector(first_track_sector() + offset) + // Since first_track_sector() returns 0, sector IS the relative offset + // We add track_start_frame to get the CHD frame number + // + // IMPORTANT: CHD allocates frames sequentially including pregap frames. + // Even if PGTYPE='V' (virtual/silence), the frames are still allocated. + // The FRAMES metadata field is the actual data frames AFTER pregap. + // So we must always skip over pregap frames to reach the actual data. + uint32_t frame = handle->track_start_frame + sector; + + // Always skip pregap for data tracks - the pregap frames are allocated + // in the CHD regardless of whether they contain real data or silence. + if (is_data_track(handle->track_type) && handle->track_pregap > 0) { + frame += handle->track_pregap; + } + + // Calculate which hunk contains this frame + uint32_t hunk_num = frame / handle->frames_per_hunk; + uint32_t frame_in_hunk = frame % handle->frames_per_hunk; + + // Read hunk if not cached + if (hunk_num != handle->cached_hunk) { + chd_error err = chd_read(handle->chd, hunk_num, handle->hunk_buffer); + if (err != CHDERR_NONE) { + return 0; + } + handle->cached_hunk = hunk_num; + } + + // Calculate offset into hunk + uint32_t offset = frame_in_hunk * handle->frame_size; + uint8_t* src = handle->hunk_buffer + offset; + + // Use pre-calculated sector format from track type + size_t header_skip = handle->sector_header_size; + size_t data_size = handle->raw_data_size; + + // For raw sectors, verify sync pattern and allow per-sector mode override + // This handles discs with mixed mode sectors + if (handle->frame_size >= 2352 && header_skip > 0) { + // Check for sync pattern: 00 FF FF FF FF FF FF FF FF FF FF 00 + if (src[0] == 0x00 && src[1] == 0xFF && src[11] == 0x00) { + // Sync pattern found at offset 0 - standard layout + int mode = src[15]; + if (mode == 1) { + header_skip = 16; // Sync(12) + Header(4) + data_size = 2048; + } else if (mode == 2) { + header_skip = 24; // Sync(12) + Header(4) + Subheader(8) + data_size = 2048; + } + } else if (handle->frame_size == 2448 && + src[96] == 0x00 && src[97] == 0xFF && src[107] == 0x00) { + // Sync pattern found at offset 96 - subcode-first layout + int mode = src[96 + 15]; + src += 96; // Adjust source pointer to skip subcode + + if (mode == 1) { + header_skip = 16; + data_size = 2048; + } else if (mode == 2) { + header_skip = 24; + data_size = 2048; + } + } + } + + // Copy the data + size_t to_copy = requested_bytes; + if (to_copy > data_size) + to_copy = data_size; + + memcpy(buffer, src + header_skip, to_copy); + + return to_copy; +} + +void chd_close_track(void* track_handle) { + chd_track_handle_t* handle = (chd_track_handle_t*)track_handle; + if (!handle) + return; + + if (handle->hunk_buffer) + free(handle->hunk_buffer); + + if (handle->chd) + chd_close(handle->chd); + + free(handle); +} + +uint32_t chd_first_track_sector(void* track_handle) { + chd_track_handle_t* handle = (chd_track_handle_t*)track_handle; + if (!handle) + return 0; + + // Return 0 to indicate the track starts at relative sector 0. + // The read_sector callback will add track_start_frame to convert + // to CHD frame numbers. + return 0; +} + +/***************************************************************************** + * Integration helper + *****************************************************************************/ + +int chd_reader_is_chd(const char* path) { + const char* ext = strrchr(path, '.'); + return ext && strcasecmp(ext, ".chd") == 0; +} diff --git a/workspace/all/minarch/chd_reader.h b/workspace/all/minarch/chd_reader.h new file mode 100644 index 000000000..1aca00d63 --- /dev/null +++ b/workspace/all/minarch/chd_reader.h @@ -0,0 +1,68 @@ +/** + * chd_reader.h - CHD file reader for rcheevos hashing + * + * Provides CD reader callbacks for rcheevos to hash CHD disc images. + * This allows RetroAchievements to work with PlayStation (and other CD-based) + * games stored in CHD format. + */ + +#ifndef CHD_READER_H +#define CHD_READER_H + +#include +#include + +/** + * Open a track from a CHD file. + * + * @param path Path to the CHD file + * @param track Track number (1-based) or RC_HASH_CDTRACK_* special value + * @return Track handle, or NULL if not a CHD file or on error + */ +void* chd_open_track(const char* path, uint32_t track); + +/** + * Open a track from a CHD file (iterator variant). + * + * @param path Path to the CHD file + * @param track Track number (1-based) or RC_HASH_CDTRACK_* special value + * @param iterator The hash iterator (unused, for API compatibility) + * @return Track handle, or NULL if not a CHD file or on error + */ +void* chd_open_track_iterator(const char* path, uint32_t track, const void* iterator); + +/** + * Read a sector from an open CHD track. + * + * @param track_handle Handle returned by chd_open_track + * @param sector Sector number relative to track start + * @param buffer Buffer to read data into + * @param requested_bytes Number of bytes to read + * @return Number of bytes actually read + */ +size_t chd_read_sector(void* track_handle, uint32_t sector, void* buffer, size_t requested_bytes); + +/** + * Close a CHD track handle. + * + * @param track_handle Handle returned by chd_open_track + */ +void chd_close_track(void* track_handle); + +/** + * Get the first sector number of a track. + * + * @param track_handle Handle returned by chd_open_track + * @return First sector number (always 0 since we handle offset internally) + */ +uint32_t chd_first_track_sector(void* track_handle); + +/** + * Check if a file path points to a CHD file. + * + * @param path File path to check + * @return Non-zero if the file has a .chd extension + */ +int chd_reader_is_chd(const char* path); + +#endif /* CHD_READER_H */ diff --git a/workspace/all/minarch/libchdr.makefile b/workspace/all/minarch/libchdr.makefile new file mode 100644 index 000000000..733ad94f9 --- /dev/null +++ b/workspace/all/minarch/libchdr.makefile @@ -0,0 +1,65 @@ +# Makefile wrapper for libchdr (CMake-based project) +# Builds libchdr as a shared library for the target platform + +PLATFORM ?= tg5040 + +BUILD_DIR = build/$(PLATFORM) + +# Cross-compilation settings (only for non-desktop platforms) +ifneq ($(PLATFORM),desktop) +ifeq ($(PLATFORM),tg5040) +TOOLCHAIN_FILE = $(BUILD_DIR)/toolchain.cmake +CMAKE_EXTRA = -DCMAKE_TOOLCHAIN_FILE=$(TOOLCHAIN_FILE) +endif +ifeq ($(PLATFORM),tg5050) +TOOLCHAIN_FILE = $(BUILD_DIR)/toolchain.cmake +CMAKE_EXTRA = -DCMAKE_TOOLCHAIN_FILE=$(TOOLCHAIN_FILE) +endif +endif + +.PHONY: all build clean + +all: build + +build: $(BUILD_DIR)/libchdr.so + +$(BUILD_DIR)/libchdr.so: | $(BUILD_DIR) +ifeq ($(PLATFORM),tg5040) + @echo "Creating CMake toolchain file for cross-compilation..." + @echo 'set(CMAKE_SYSTEM_NAME Linux)' > $(TOOLCHAIN_FILE) + @echo 'set(CMAKE_SYSTEM_PROCESSOR aarch64)' >> $(TOOLCHAIN_FILE) + @echo 'set(CMAKE_C_COMPILER aarch64-nextui-linux-gnu-gcc)' >> $(TOOLCHAIN_FILE) + @echo 'set(CMAKE_CXX_COMPILER aarch64-nextui-linux-gnu-g++)' >> $(TOOLCHAIN_FILE) + @echo 'set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)' >> $(TOOLCHAIN_FILE) + @echo 'set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)' >> $(TOOLCHAIN_FILE) + @echo 'set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)' >> $(TOOLCHAIN_FILE) +endif +ifeq ($(PLATFORM),tg5050) + @echo "Creating CMake toolchain file for cross-compilation..." + @echo 'set(CMAKE_SYSTEM_NAME Linux)' > $(TOOLCHAIN_FILE) + @echo 'set(CMAKE_SYSTEM_PROCESSOR aarch64)' >> $(TOOLCHAIN_FILE) + @echo 'set(CMAKE_C_COMPILER aarch64-nextui-linux-gnu-gcc)' >> $(TOOLCHAIN_FILE) + @echo 'set(CMAKE_CXX_COMPILER aarch64-nextui-linux-gnu-g++)' >> $(TOOLCHAIN_FILE) + @echo 'set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)' >> $(TOOLCHAIN_FILE) + @echo 'set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)' >> $(TOOLCHAIN_FILE) + @echo 'set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)' >> $(TOOLCHAIN_FILE) +endif + cd $(BUILD_DIR) && cmake ../.. \ + -DBUILD_SHARED_LIBS=ON \ + -DINSTALL_STATIC_LIBS=OFF \ + -DCMAKE_BUILD_TYPE=Release \ + -DWITH_SYSTEM_ZLIB=OFF \ + $(CMAKE_EXTRA) + cd $(BUILD_DIR) && make -j$$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4) + @# Copy the .so to expected location (cmake may put it in a subdir) + @if [ -f $(BUILD_DIR)/libchdr.so ]; then \ + echo "libchdr.so built successfully"; \ + elif [ -f $(BUILD_DIR)/src/libchdr.so ]; then \ + cp $(BUILD_DIR)/src/libchdr.so $(BUILD_DIR)/; \ + fi + +$(BUILD_DIR): + mkdir -p $(BUILD_DIR) + +clean: + rm -rf $(BUILD_DIR) diff --git a/workspace/all/minarch/makefile b/workspace/all/minarch/makefile index e65dee0f6..c905f376b 100644 --- a/workspace/all/minarch/makefile +++ b/workspace/all/minarch/makefile @@ -22,7 +22,17 @@ SDL?=SDL TARGET = minarch PRODUCT= build/$(PLATFORM)/$(TARGET).elf INCDIR = -I. -I./libretro-common/include/ -I../common/ -I../../$(PLATFORM)/platform/ -SOURCE = $(TARGET).c ../common/scaler.c ../common/utils.c ../common/config.c ../common/api.c ../../$(PLATFORM)/platform/platform.c +SOURCE = $(TARGET).c ../common/scaler.c ../common/utils.c ../common/config.c ../common/api.c ../common/notification.c ../common/http.c ../../$(PLATFORM)/platform/platform.c + +# RA support for tg5040, tg5050, and desktop +ifneq (,$(filter $(PLATFORM),tg5040 tg5050 desktop)) +# rcheevos include paths +INCDIR += -I../rcheevos/src/include -I../rcheevos/src/src +# libchdr include path (for CHD disc image support) +INCDIR += -I../libchdr/include +# RA source files +SOURCE += ../common/ra_badges.c ra_integration.c chd_reader.c +endif CC = $(CROSS_COMPILE)gcc CFLAGS += $(OPT) @@ -52,6 +62,29 @@ CFLAGS += -DHAS_SRM LDFLAGS += -lasound endif +ifeq ($(PLATFORM), tg5040) +# rcheevos static library +LDFLAGS += -L../rcheevos/build/$(PLATFORM) -lrcheevos +# libchdr shared library (for CHD disc image hashing) +LDFLAGS += -L../libchdr/build/$(PLATFORM) -lchdr +CFLAGS += -DRC_CLIENT_SUPPORTS_HASH +endif + +ifeq ($(PLATFORM), tg5050) +# rcheevos static library +LDFLAGS += -L../rcheevos/build/$(PLATFORM) -lrcheevos +# libchdr shared library (for CHD disc image hashing) +LDFLAGS += -L../libchdr/build/$(PLATFORM) -lchdr +CFLAGS += -DRC_CLIENT_SUPPORTS_HASH +endif + +ifeq ($(PLATFORM), desktop) +# rcheevos static library (built for desktop) +LDFLAGS += -L../rcheevos/build/$(PLATFORM) -lrcheevos +# libchdr (built from source for desktop) +LDFLAGS += -L../libchdr/build/$(PLATFORM) -lchdr -Wl,-rpath,'$$ORIGIN' +CFLAGS += -DRC_CLIENT_SUPPORTS_HASH +endif # CFLAGS += -Wall -Wno-unused-variable -Wno-unused-function -Wno-format-overflow ifeq ($(PLATFORM), desktop) @@ -73,14 +106,26 @@ BUILD_HASH!=cat ../../hash.txt CFLAGS += -DBUILD_DATE=\"${BUILD_DATE}\" -DBUILD_HASH=\"${BUILD_HASH}\" ifeq ($(PLATFORM), desktop) -all: clean libretro-common $(PREFIX_LOCAL)/include/msettings.h +all: clean libretro-common rcheevos-desktop libchdr-desktop $(PREFIX_LOCAL)/include/msettings.h mkdir -p build/$(PLATFORM) $(CC) $(SOURCE) -o $(PRODUCT) $(CFLAGS) $(LDFLAGS) +else ifneq (,$(filter $(PLATFORM),tg5040 tg5050)) +# Platforms with RA support +all: clean libretro-common libsrm.a rcheevos libchdr $(PREFIX_LOCAL)/include/msettings.h + mkdir -p build/$(PLATFORM) + cp -L $(PREFIX)/lib/libsamplerate.so.* build/$(PLATFORM) + # This is a bandaid fix, needs to be cleaned up if/when we expand to other platforms. + cp -L $(PREFIX)/lib/libzip.so.* build/$(PLATFORM) + cp -L $(PREFIX)/lib/libbz2.so.1.0 build/$(PLATFORM) + cp -L $(PREFIX)/lib/liblzma.so.5 build/$(PLATFORM) + cp -L $(PREFIX)/lib/libzstd.so.1 build/$(PLATFORM) + cp -L ../libchdr/build/$(PLATFORM)/libchdr.so.0.2 build/$(PLATFORM)/libchdr.so.0 + $(CC) $(SOURCE) -o $(PRODUCT) $(CFLAGS) $(LDFLAGS) else +# Other platforms - no RA support all: clean libretro-common libsrm.a $(PREFIX_LOCAL)/include/msettings.h mkdir -p build/$(PLATFORM) cp -L $(PREFIX)/lib/libsamplerate.so.* build/$(PLATFORM) - # This is a bandaid fix, needs to be cleaned up if/when we expand to other platforms. cp -L $(PREFIX)/lib/libzip.so.* build/$(PLATFORM) cp -L $(PREFIX)/lib/libbz2.so.1.0 build/$(PLATFORM) cp -L $(PREFIX)/lib/liblzma.so.5 build/$(PLATFORM) @@ -90,6 +135,25 @@ endif libretro-common: git clone https://github.com/libretro/libretro-common + +rcheevos: + cd ../rcheevos && make build PLATFORM=$(PLATFORM) + +# Desktop rcheevos build (native compiler) +rcheevos-desktop: + cd ../rcheevos && make build PLATFORM=desktop + +../libchdr/CMakeLists.txt: + rm -rf ../libchdr + git clone https://github.com/rtissera/libchdr ../libchdr + cp libchdr.makefile ../libchdr/makefile + +libchdr: ../libchdr/CMakeLists.txt + cd ../libchdr && make build PLATFORM=$(PLATFORM) + +# Desktop libchdr - build from source +libchdr-desktop: ../libchdr/CMakeLists.txt + cd ../libchdr && make build PLATFORM=desktop $(PREFIX_LOCAL)/include/msettings.h: cd ../../$(PLATFORM)/libmsettings && make diff --git a/workspace/all/minarch/minarch.c b/workspace/all/minarch/minarch.c index 7172d46c6..55ec051e6 100644 --- a/workspace/all/minarch/minarch.c +++ b/workspace/all/minarch/minarch.c @@ -25,6 +25,11 @@ #include "api.h" #include "utils.h" #include "scaler.h" +#include "notification.h" +#include "config.h" +#include "ra_integration.h" +#include "ra_badges.h" +#include "rc_client.h" #include #include #include @@ -73,6 +78,13 @@ static int DEVICE_HEIGHT = 0; static int DEVICE_PITCH = 0; static int shader_reset_suppressed = 0; +// FPS tracking variables +static int cpu_ticks = 0; +static int fps_ticks = 0; +static double fps_double = 0; +static double cpu_double = 0; +static uint32_t sec_start = 0; + GFX_Renderer renderer; /////////////////////////////////////// @@ -957,9 +969,17 @@ static void State_getPath(char* filename) { } #define RASTATE_HEADER_SIZE 16 -static void State_read(void) { // from picoarch +static int State_read(void) { // from picoarch + // Block load states in RetroAchievements hardcore mode + if (RA_isHardcoreModeActive()) { + LOG_info("State load blocked - hardcore mode active\n"); + Notification_push(NOTIFICATION_ACHIEVEMENT, "Load states disabled in Hardcore mode", NULL); + return 0; + } + + int success = 0; size_t state_size = core.serialize_size(); - if (!state_size) return; + if (!state_size) return 0; int was_ff = fast_forward; fast_forward = 0; @@ -1008,6 +1028,7 @@ static void State_read(void) { // from picoarch LOG_error("Error restoring save state: %s\n", filename); goto error; } + success = 1; error: if (state) free(state); @@ -1042,17 +1063,27 @@ static void State_read(void) { // from picoarch LOG_error("Error restoring save state: %s\n", filename); goto error; } + success = 1; error: if (state) free(state); if (state_file) fclose(state_file); #endif fast_forward = was_ff; + return success; } -static void State_write(void) { // from picoarch +static int State_write(void) { // from picoarch + // Block save states in RetroAchievements hardcore mode + if (RA_isHardcoreModeActive()) { + LOG_info("State save blocked - hardcore mode active\n"); + Notification_push(NOTIFICATION_ACHIEVEMENT, "Save states disabled in Hardcore mode", NULL); + return 0; + } + + int success = 0; size_t state_size = core.serialize_size(); - if (!state_size) return; + if (!state_size) return 0; int was_ff = fast_forward; fast_forward = 0; @@ -1076,12 +1107,14 @@ static void State_write(void) { // from picoarch LOG_error("rzipstream: Error writing state data to file: %s\n", filename); goto error; } + success = 1; } else { if(!filestream_write_file(filename, state, state_size)) { LOG_error("filestream: Error writing state data to file: %s\n", filename); goto error; } + success = 1; } error: @@ -1096,6 +1129,7 @@ static void State_write(void) { // from picoarch LOG_error("Error writing state data to file: %s (%s)\n", filename, strerror(errno)); goto error; } + success = 1; error: if (state) free(state); if (state_file) fclose(state_file); @@ -1103,6 +1137,7 @@ static void State_write(void) { // from picoarch sync(); fast_forward = was_ff; + return success; } static void State_autosave(void) { @@ -3565,7 +3600,12 @@ static bool environment_callback(unsigned cmd, void *data) { // copied from pico return false; // TODO: tmp break; } - // RETRO_ENVIRONMENT_SET_MEMORY_MAPS (36 | RETRO_ENVIRONMENT_EXPERIMENTAL) + case RETRO_ENVIRONMENT_SET_MEMORY_MAPS: { /* 36 | RETRO_ENVIRONMENT_EXPERIMENTAL */ + // Core is providing its memory map for achievement checking + const struct retro_memory_map* mmap = (const struct retro_memory_map*)data; + RA_setMemoryMap(mmap); + break; + } case RETRO_ENVIRONMENT_GET_LANGUAGE: { /* 39 */ // puts("RETRO_ENVIRONMENT_GET_LANGUAGE"); if (data) *(int *) data = RETRO_LANGUAGE_ENGLISH; @@ -4625,6 +4665,8 @@ static void video_refresh_callback_main(const void *data, unsigned width, unsign return; } + fps_ticks += 1; + // if source has changed size (or forced by dst_p==0) // eg. true src + cropped src + fixed dst + cropped dst if (renderer.dst_p==0 || width!=renderer.true_w || height!=renderer.true_h) { @@ -5615,6 +5657,7 @@ static int OptionShortcuts_openMenu(MenuList* list, int i) { } static void OptionSaveChanges_updateDesc(void); +static void OptionAchievements_updateDesc(void); static int OptionSaveChanges_onConfirm(MenuList* list, int i) { char* message; switch (i) { @@ -5664,6 +5707,15 @@ static int OptionQuicksave_onConfirm(MenuList* list, int i) { static int OptionCheats_optionChanged(MenuList* list, int i) { MenuItem* item = &list->items[i]; struct Cheat *cheat = &cheatcodes.cheats[i]; + + // Block enabling cheats in RetroAchievements hardcore mode + if (RA_isHardcoreModeActive() && item->value) { + LOG_info("Cheat enable blocked - hardcore mode active\n"); + Notification_push(NOTIFICATION_ACHIEVEMENT, "Cheats disabled in Hardcore mode", NULL); + item->value = 0; // Revert the toggle + return MENU_CALLBACK_NOP; + } + cheat->enabled = item->value; Core_applyCheats(&cheatcodes); return MENU_CALLBACK_NOP; @@ -5895,6 +5947,622 @@ static int OptionShaders_openMenu(MenuList* list, int i) { return MENU_CALLBACK_NOP; } +// Achievement menu state (stored so on_confirm handler can access achievement data) +static const rc_client_achievement_list_t* ach_menu_list = NULL; +static const rc_client_achievement_t** ach_menu_achievements = NULL; // Flattened array for index lookup +static int ach_menu_count = 0; +static bool ach_filter_locked_only = false; // Y button toggle: show all or locked only + +// Achievement sorting comparison function +static int ach_compare_unlocked_first(const void* a, const void* b) { + const rc_client_achievement_t* achA = *(const rc_client_achievement_t**)a; + const rc_client_achievement_t* achB = *(const rc_client_achievement_t**)b; + // Unlocked achievements come first + if (achA->unlocked != achB->unlocked) return achB->unlocked - achA->unlocked; + return 0; +} + +static int ach_compare_display_first(const void* a, const void* b) { + const rc_client_achievement_t* achA = *(const rc_client_achievement_t**)a; + const rc_client_achievement_t* achB = *(const rc_client_achievement_t**)b; + // Lower display order first + return (int)achA->id - (int)achB->id; // ID is a reasonable proxy for display order +} + +static int ach_compare_display_last(const void* a, const void* b) { + return -ach_compare_display_first(a, b); +} + +static int ach_compare_won_by_most(const void* a, const void* b) { + const rc_client_achievement_t* achA = *(const rc_client_achievement_t**)a; + const rc_client_achievement_t* achB = *(const rc_client_achievement_t**)b; + // Higher rarity (more common) first + if (achA->rarity != achB->rarity) return (achB->rarity - achA->rarity) > 0 ? 1 : -1; + return 0; +} + +static int ach_compare_won_by_least(const void* a, const void* b) { + return -ach_compare_won_by_most(a, b); +} + +static int ach_compare_points_most(const void* a, const void* b) { + const rc_client_achievement_t* achA = *(const rc_client_achievement_t**)a; + const rc_client_achievement_t* achB = *(const rc_client_achievement_t**)b; + return (int)achB->points - (int)achA->points; +} + +static int ach_compare_points_least(const void* a, const void* b) { + return -ach_compare_points_most(a, b); +} + +static int ach_compare_title_az(const void* a, const void* b) { + const rc_client_achievement_t* achA = *(const rc_client_achievement_t**)a; + const rc_client_achievement_t* achB = *(const rc_client_achievement_t**)b; + return strcmp(achA->title, achB->title); +} + +static int ach_compare_title_za(const void* a, const void* b) { + return -ach_compare_title_az(a, b); +} + +static int ach_compare_type_asc(const void* a, const void* b) { + const rc_client_achievement_t* achA = *(const rc_client_achievement_t**)a; + const rc_client_achievement_t* achB = *(const rc_client_achievement_t**)b; + return (int)achA->type - (int)achB->type; +} + +static int ach_compare_type_desc(const void* a, const void* b) { + return -ach_compare_type_asc(a, b); +} + +static void ach_sort_achievements(const rc_client_achievement_t** achievements, int count) { + int sort_order = CFG_getRAAchievementSortOrder(); + int (*compare)(const void*, const void*) = NULL; + + switch (sort_order) { + case RA_SORT_UNLOCKED_FIRST: compare = ach_compare_unlocked_first; break; + case RA_SORT_DISPLAY_ORDER_FIRST: compare = ach_compare_display_first; break; + case RA_SORT_DISPLAY_ORDER_LAST: compare = ach_compare_display_last; break; + case RA_SORT_WON_BY_MOST: compare = ach_compare_won_by_most; break; + case RA_SORT_WON_BY_LEAST: compare = ach_compare_won_by_least; break; + case RA_SORT_POINTS_MOST: compare = ach_compare_points_most; break; + case RA_SORT_POINTS_LEAST: compare = ach_compare_points_least; break; + case RA_SORT_TITLE_AZ: compare = ach_compare_title_az; break; + case RA_SORT_TITLE_ZA: compare = ach_compare_title_za; break; + case RA_SORT_TYPE_ASC: compare = ach_compare_type_asc; break; + case RA_SORT_TYPE_DESC: compare = ach_compare_type_desc; break; + default: compare = ach_compare_unlocked_first; break; + } + + if (compare && count > 1) { + qsort((void*)achievements, count, sizeof(rc_client_achievement_t*), compare); + } +} + +static int OptionAchievements_showDetail(MenuList* list, int i) { + if (!ach_menu_achievements || i < 0 || i >= ach_menu_count) { + return MENU_CALLBACK_NOP; + } + + const rc_client_achievement_t* ach = ach_menu_achievements[i]; + if (!ach || !ach->title) { + return MENU_CALLBACK_NOP; + } + + GFX_setMode(MODE_MAIN); + int dirty = 1; + int show_detail = 1; + + while (show_detail) { + GFX_startFrame(); + PAD_poll(); + + // Check for input + if (PAD_justPressed(BTN_B)) { + show_detail = 0; + } else if (PAD_justPressed(BTN_X)) { + // Toggle mute for this achievement + RA_toggleAchievementMute(ach->id); + dirty = 1; + } else if (PAD_justPressed(BTN_LEFT) || PAD_justRepeated(BTN_LEFT)) { + // Navigate to previous achievement (with wrap-around) + i = (i - 1 + ach_menu_count) % ach_menu_count; + ach = ach_menu_achievements[i]; + dirty = 1; + } else if (PAD_justPressed(BTN_RIGHT) || PAD_justRepeated(BTN_RIGHT)) { + // Navigate to next achievement (with wrap-around) + i = (i + 1) % ach_menu_count; + ach = ach_menu_achievements[i]; + dirty = 1; + } + + PWR_update(&dirty, NULL, Menu_beforeSleep, Menu_afterSleep); + + if (dirty) { + bool is_muted = RA_isAchievementMuted(ach->id); + + GFX_clear(screen); + + // Layout: badge icon centered at top, then title, then details + int badge_size = SCALE1(64); // 64px badge + int content_y = SCALE1(PADDING) + SCALE1(6); // Extra padding above icon + int center_x = screen->w / 2; + + // Badge icon centered at top + SDL_Surface* badge = RA_Badges_get(ach->badge_name, !ach->unlocked); + if (badge) { + SDL_Rect badge_src = {0, 0, badge->w, badge->h}; + SDL_Rect badge_dst = { + center_x - badge_size / 2, + content_y, + badge_size, badge_size + }; + SDL_BlitScaled(badge, &badge_src, screen, &badge_dst); + content_y += badge_size + SCALE1(6); + } + + // Title centered - wrap to max 2 lines with ellipsis if needed + int max_text_width = screen->w - SCALE1(PADDING * 2); + content_y = GFX_blitWrappedText(font.medium, ach->title, max_text_width, 2, COLOR_WHITE, screen, center_x, content_y); + content_y += SCALE1(2); // Spacing after title + + // Description - unlimited lines + content_y = GFX_blitWrappedText(font.small, ach->description, max_text_width, 0, COLOR_WHITE, screen, center_x, content_y); + content_y += SCALE1(4); // Spacing after description + + // Points (singular/plural) - use tiny font like other metadata + char points_str[32]; + if (ach->points == 1) { + snprintf(points_str, sizeof(points_str), "1 point"); + } else { + snprintf(points_str, sizeof(points_str), "%u points", ach->points); + } + SDL_Surface* points_text = TTF_RenderUTF8_Blended(font.tiny, points_str, COLOR_LIGHT_TEXT); + SDL_BlitSurface(points_text, NULL, screen, &(SDL_Rect){ + center_x - points_text->w / 2, content_y + }); + content_y += points_text->h + SCALE1(2); + SDL_FreeSurface(points_text); + + // Unlock time or progress (smaller font, gray) + if (ach->unlocked && ach->unlock_time > 0) { + struct tm* tm_info = localtime(&ach->unlock_time); + char time_buf[64]; + strftime(time_buf, sizeof(time_buf), "Unlocked %B %d %Y, %I:%M%p", tm_info); + SDL_Surface* time_text = TTF_RenderUTF8_Blended(font.tiny, time_buf, COLOR_LIGHT_TEXT); + SDL_BlitSurface(time_text, NULL, screen, &(SDL_Rect){ + center_x - time_text->w / 2, content_y + }); + content_y += time_text->h + SCALE1(2); + SDL_FreeSurface(time_text); + } else if (ach->measured_progress[0]) { + char progress_buf[64]; + snprintf(progress_buf, sizeof(progress_buf), "Progress: %s", ach->measured_progress); + SDL_Surface* progress_text = TTF_RenderUTF8_Blended(font.tiny, progress_buf, COLOR_LIGHT_TEXT); + SDL_BlitSurface(progress_text, NULL, screen, &(SDL_Rect){ + center_x - progress_text->w / 2, content_y + }); + content_y += progress_text->h + SCALE1(2); + SDL_FreeSurface(progress_text); + } + + // Unlock rate/rarity (smaller font, gray) + if (ach->rarity > 0) { + char rarity_buf[32]; + snprintf(rarity_buf, sizeof(rarity_buf), "%.2f%% unlock rate", ach->rarity); + SDL_Surface* rarity_text = TTF_RenderUTF8_Blended(font.tiny, rarity_buf, COLOR_LIGHT_TEXT); + SDL_BlitSurface(rarity_text, NULL, screen, &(SDL_Rect){ + center_x - rarity_text->w / 2, content_y + }); + content_y += rarity_text->h + SCALE1(2); + SDL_FreeSurface(rarity_text); + } + + // Type tag + const char* type_str = NULL; + switch (ach->type) { + case RC_CLIENT_ACHIEVEMENT_TYPE_MISSABLE: + type_str = "[Missable]"; + break; + case RC_CLIENT_ACHIEVEMENT_TYPE_PROGRESSION: + type_str = "[Progression]"; + break; + case RC_CLIENT_ACHIEVEMENT_TYPE_WIN: + type_str = "[Win Condition]"; + break; + default: + break; + } + if (type_str) { + SDL_Surface* type_text = TTF_RenderUTF8_Blended(font.tiny, type_str, COLOR_LIGHT_TEXT); + SDL_BlitSurface(type_text, NULL, screen, &(SDL_Rect){ + center_x - type_text->w / 2, content_y + }); + content_y += type_text->h + SCALE1(2); + SDL_FreeSurface(type_text); + } + + // Muted status below other info with gap before title + if (is_muted) { + SDL_Surface* mute_text = TTF_RenderUTF8_Blended(font.tiny, "MUTED: Will not show in notifications", COLOR_LIGHT_TEXT); + SDL_BlitSurface(mute_text, NULL, screen, &(SDL_Rect){ + center_x - mute_text->w / 2, content_y + SCALE1(4) + }); + SDL_FreeSurface(mute_text); + } + + // Button hints - update based on current mute state + char* hints[] = {"X", is_muted ? "UNMUTE" : "MUTE", "B", "BACK", NULL}; + GFX_blitButtonGroup(hints, 0, screen, 1); + GFX_flip(screen); + dirty = 0; + } + + hdmimon(); + } + + GFX_setMode(MODE_MENU); + // Return the current index so caller can update selection + return i; +} + +static int OptionAchievements_openMenu(MenuList* list, int i) { + if (!RA_isGameLoaded()) { + Menu_message("No game loaded for achievements", (char*[]){"B","BACK", NULL}); + return MENU_CALLBACK_NOP; + } + + uint32_t unlocked, total; + RA_getAchievementSummary(&unlocked, &total); + + if (total == 0) { + Menu_message("No achievements available for this game", (char*[]){"B","BACK", NULL}); + return MENU_CALLBACK_NOP; + } + + // Clean up any previous achievement list + if (ach_menu_list) { + RA_destroyAchievementList(ach_menu_list); + ach_menu_list = NULL; + } + if (ach_menu_achievements) { + free(ach_menu_achievements); + ach_menu_achievements = NULL; + } + ach_menu_count = 0; + + // Create achievement list grouped by lock state + ach_menu_list = (const rc_client_achievement_list_t*)RA_createAchievementList( + RC_CLIENT_ACHIEVEMENT_CATEGORY_CORE, RC_CLIENT_ACHIEVEMENT_LIST_GROUPING_LOCK_STATE); + + if (!ach_menu_list) { + Menu_message("Failed to load achievements", (char*[]){"B","BACK", NULL}); + return MENU_CALLBACK_NOP; + } + + // Count total achievements across all buckets and build flattened array + int total_achievements = 0; + for (uint32_t b = 0; b < ach_menu_list->num_buckets; b++) { + total_achievements += ach_menu_list->buckets[b].num_achievements; + } + + if (total_achievements == 0) { + RA_destroyAchievementList(ach_menu_list); + ach_menu_list = NULL; + // This can happen with unsupported game versions where pseudo-achievements + // are counted in the summary but not available in the achievement list + Menu_message("Achievement list not available", (char*[]){"B","BACK", NULL}); + return MENU_CALLBACK_NOP; + } + + // Create flattened array of all achievement pointers + const rc_client_achievement_t** all_achievements = calloc(total_achievements, sizeof(rc_client_achievement_t*)); + int idx = 0; + for (uint32_t b = 0; b < ach_menu_list->num_buckets && idx < total_achievements; b++) { + const rc_client_achievement_bucket_t* bucket = &ach_menu_list->buckets[b]; + for (uint32_t a = 0; a < bucket->num_achievements && idx < total_achievements; a++) { + all_achievements[idx++] = bucket->achievements[a]; + } + } + + // Sort achievements according to settings + ach_sort_achievements(all_achievements, total_achievements); + + // Custom menu loop with X (mute) and Y (filter) support + int dirty = 1; + int filter_dirty = 1; // Rebuild filtered list when this is set + int show_menu = 1; + int selected = 0; + int start = 0; + int max_visible = (screen->h - ((SCALE1(PADDING + PILL_SIZE) * 2) + SCALE1(BUTTON_SIZE))) / SCALE1(BUTTON_SIZE); + + // Allocate filtered array once (max size = all achievements) + const rc_client_achievement_t** filtered = calloc(total_achievements, sizeof(rc_client_achievement_t*)); + int filtered_count = 0; + + // Hide "Unknown Emulator" warning (ID 101000001) when hardcore mode is disabled. + // Show it when enabled so users understand why they only get softcore unlocks. + // Note: We intentionally show "Unsupported Game Version" so users know to find a supported ROM. + bool hide_unknown_emulator = !CFG_getRAHardcoreMode(); + + while (show_menu) { + GFX_startFrame(); + PAD_poll(); + + // Rebuild filtered list only when filter changes + if (filter_dirty) { + filtered_count = 0; + for (int j = 0; j < total_achievements; j++) { + // Skip "Unknown Emulator" warning when hardcore mode is disabled + if (hide_unknown_emulator && all_achievements[j]->id == 101000001) { + continue; + } + if (!ach_filter_locked_only || !all_achievements[j]->unlocked) { + filtered[filtered_count++] = all_achievements[j]; + } + } + + if (filtered_count == 0) { + if (ach_filter_locked_only) { + // No locked achievements when filter is on - revert filter + ach_filter_locked_only = false; + continue; // Will rebuild with filter off + } + // No achievements at all after filtering - exit menu + free(all_achievements); + free(filtered); + RA_destroyAchievementList(ach_menu_list); + ach_menu_list = NULL; + ach_menu_achievements = NULL; + ach_menu_count = 0; + Menu_message("No achievements found", (char*[]){"B","BACK", NULL}); + return MENU_CALLBACK_NOP; + } + + // Store for detail view + ach_menu_achievements = filtered; + ach_menu_count = filtered_count; + + // Reset scroll position when filter changes + if (selected >= filtered_count) selected = filtered_count - 1; + if (selected < 0) selected = 0; + start = 0; + + filter_dirty = 0; + dirty = 1; + } + + int end = MIN(start + max_visible, filtered_count); + + if (PAD_justRepeated(BTN_UP)) { + selected--; + if (selected < 0) { + selected = filtered_count - 1; + start = MAX(0, filtered_count - max_visible); + } else if (selected < start) { + start--; + } + dirty = 1; + } else if (PAD_justRepeated(BTN_DOWN)) { + selected++; + if (selected >= filtered_count) { + selected = 0; + start = 0; + } else if (selected >= end) { + start++; + } + dirty = 1; + } else if (PAD_justRepeated(BTN_LEFT)) { + // Page up (move up by max_visible items) + selected -= max_visible; + if (selected < 0) { + selected = 0; + start = 0; + } else { + start = selected; + } + dirty = 1; + } else if (PAD_justRepeated(BTN_RIGHT)) { + // Page down (move down by max_visible items) + selected += max_visible; + if (selected >= filtered_count) { + selected = filtered_count - 1; + start = MAX(0, filtered_count - max_visible); + } else { + start = selected; + } + dirty = 1; + } else if (PAD_justPressed(BTN_B)) { + show_menu = 0; + } else if (PAD_justPressed(BTN_A)) { + // Show detail view (returns updated index after navigation) + selected = OptionAchievements_showDetail(NULL, selected); + // Adjust scroll position if needed + if (selected < start) { + start = selected; + } else if (selected >= start + max_visible) { + start = selected - max_visible + 1; + } + dirty = 1; + } else if (PAD_justPressed(BTN_X)) { + // Toggle mute for selected achievement + if (filtered_count > 0) { + const rc_client_achievement_t* ach = filtered[selected]; + RA_toggleAchievementMute(ach->id); + dirty = 1; + } + } else if (PAD_justPressed(BTN_Y)) { + // Toggle filter: All <-> Locked Only + ach_filter_locked_only = !ach_filter_locked_only; + selected = 0; + start = 0; + filter_dirty = 1; + } + + if (dirty) { + end = MIN(start + max_visible, filtered_count); + + GFX_clear(screen); + GFX_blitHardwareGroup(screen, 0); + + // Layout constants matching MENU_FIXED style + int mw = screen->w - SCALE1(PADDING * 2); + int ox = SCALE1(PADDING); + int row_height = SCALE1(BUTTON_SIZE); + int selected_row = selected - start; + int opt_pad = SCALE1(8); // Local option padding since OPTION_PADDING defined later + + // Status text at top, aligned with hardware pill (not part of centered content) + char status_text[64]; + snprintf(status_text, sizeof(status_text), "%u/%u unlocked", unlocked, total); + SDL_Surface* status_surface = TTF_RenderUTF8_Blended(font.tiny, status_text, COLOR_WHITE); + SDL_BlitSurface(status_surface, NULL, screen, &(SDL_Rect){ + (screen->w - status_surface->w) / 2, + SCALE1(PADDING) + (SCALE1(PILL_SIZE) - status_surface->h) / 2 // Vertically centered with pill + }); + SDL_FreeSurface(status_surface); + + // Calculate vertical centering for list only + int top_margin = SCALE1(PADDING + PILL_SIZE); // Below hardware pill / status text + int bottom_margin = SCALE1(PADDING + PILL_SIZE); // Above button hints + int available_height = screen->h - top_margin - bottom_margin; + + // Content: just the visible rows + int visible_rows = MIN(end - start, filtered_count); + int list_height = visible_rows * row_height; + + // Center list vertically in available space + int oy = top_margin + (available_height - list_height) / 2; + + // Draw achievement list rows + for (int j = start, row = 0; j < end && j < filtered_count; j++, row++) { + const rc_client_achievement_t* ach = filtered[j]; + bool is_muted = RA_isAchievementMuted(ach->id); + bool is_selected = (row == selected_row); + SDL_Color text_color = COLOR_WHITE; + + if (is_selected) { + // Gray pill background for full row width (like MENU_FIXED selected) + GFX_blitPillLight(ASSET_BUTTON, screen, &(SDL_Rect){ + ox, oy + SCALE1(row * BUTTON_SIZE), mw, row_height + }); + } + + // Draw ">" on the right side (always white) + SDL_Surface* arrow = TTF_RenderUTF8_Blended(font.small, ">", COLOR_WHITE); + SDL_BlitSurface(arrow, NULL, screen, &(SDL_Rect){ + ox + mw - arrow->w - opt_pad, + oy + SCALE1((row * BUTTON_SIZE) + 3) + }); + SDL_FreeSurface(arrow); + + if (is_selected) { + // White pill for the text area with icon (like MENU_FIXED selected text pill) + // Calculate width needed for: badge + spacing + title + mute indicator + padding + int badge_display_size = SCALE1(BUTTON_SIZE - 4); // Badge sized to fit in row + int title_width = 0; + TTF_SizeUTF8(font.small, ach->title, &title_width, NULL); + int mute_width = 0; + if (is_muted) { + TTF_SizeUTF8(font.tiny, "[M]", &mute_width, NULL); + mute_width += SCALE1(4); // spacing + } + int pill_width = opt_pad + badge_display_size + SCALE1(6) + title_width + mute_width + opt_pad; + + GFX_blitPillDark(ASSET_BUTTON, screen, &(SDL_Rect){ + ox, oy + SCALE1(row * BUTTON_SIZE), pill_width, row_height + }); + text_color = uintToColour(THEME_COLOR5_255); + + // Badge icon inside the white pill + SDL_Surface* badge = RA_Badges_get(ach->badge_name, !ach->unlocked); + if (badge) { + SDL_Rect badge_src = {0, 0, badge->w, badge->h}; + SDL_Rect badge_dst = { + ox + opt_pad, + oy + SCALE1(row * BUTTON_SIZE) + (row_height - badge_display_size) / 2, + badge_display_size, badge_display_size + }; + SDL_BlitScaled(badge, &badge_src, screen, &badge_dst); + } + + // Title text + SDL_Surface* title_text = TTF_RenderUTF8_Blended(font.small, ach->title, text_color); + SDL_BlitSurface(title_text, NULL, screen, &(SDL_Rect){ + ox + opt_pad + badge_display_size + SCALE1(6), + oy + SCALE1((row * BUTTON_SIZE) + 1) + }); + SDL_FreeSurface(title_text); + + // Mute indicator inside the pill + if (is_muted) { + SDL_Surface* mute_text = TTF_RenderUTF8_Blended(font.tiny, "[M]", text_color); + SDL_BlitSurface(mute_text, NULL, screen, &(SDL_Rect){ + ox + opt_pad + badge_display_size + SCALE1(6) + title_width + SCALE1(4), + oy + SCALE1((row * BUTTON_SIZE) + 3) + }); + SDL_FreeSurface(mute_text); + } + } else { + // Unselected row - just badge + title + mute indicator, no pills + int badge_display_size = SCALE1(BUTTON_SIZE - 4); + + // Badge icon + SDL_Surface* badge = RA_Badges_get(ach->badge_name, !ach->unlocked); + if (badge) { + SDL_Rect badge_src = {0, 0, badge->w, badge->h}; + SDL_Rect badge_dst = { + ox + opt_pad, + oy + SCALE1(row * BUTTON_SIZE) + (row_height - badge_display_size) / 2, + badge_display_size, badge_display_size + }; + SDL_BlitScaled(badge, &badge_src, screen, &badge_dst); + } + + // Title text (theme color for unselected) + SDL_Surface* title_text = TTF_RenderUTF8_Blended(font.small, ach->title, COLOR_WHITE); + SDL_BlitSurface(title_text, NULL, screen, &(SDL_Rect){ + ox + opt_pad + badge_display_size + SCALE1(6), + oy + SCALE1((row * BUTTON_SIZE) + 1) + }); + SDL_FreeSurface(title_text); + + // Mute indicator + if (is_muted) { + SDL_Surface* mute_text = TTF_RenderUTF8_Blended(font.tiny, "[M]", COLOR_WHITE); + int title_width = 0; + TTF_SizeUTF8(font.small, ach->title, &title_width, NULL); + SDL_BlitSurface(mute_text, NULL, screen, &(SDL_Rect){ + ox + opt_pad + badge_display_size + SCALE1(6) + title_width + SCALE1(4), + oy + SCALE1((row * BUTTON_SIZE) + 3) + }); + SDL_FreeSurface(mute_text); + } + } + } + + // Button hints at bottom with dynamic Y button text + char* hints[] = {"Y", ach_filter_locked_only ? "SHOW ALL" : "SHOW LOCKED", "X", "MUTE", NULL}; + GFX_blitButtonGroup(hints, 0, screen, 1); + + GFX_flip(screen); + dirty = 0; + } + } + + // Cleanup + free(all_achievements); + free(filtered); // This was assigned to ach_menu_achievements + ach_menu_achievements = NULL; + ach_menu_count = 0; + if (ach_menu_list) { + RA_destroyAchievementList(ach_menu_list); + ach_menu_list = NULL; + } + + return MENU_CALLBACK_NOP; +} + static MenuList options_menu = { .type = MENU_LIST, .items = (MenuItem[]) { @@ -5903,7 +6571,8 @@ static MenuList options_menu = { {"Shaders",.on_confirm=OptionShaders_openMenu}, {"Cheats",.on_confirm=OptionCheats_openMenu}, {"Controls",.on_confirm=OptionControls_openMenu}, - {"Shortcuts",.on_confirm=OptionShortcuts_openMenu}, + {"Shortcuts",.on_confirm=OptionShortcuts_openMenu}, + {"Achievements",.on_confirm=OptionAchievements_openMenu}, {"Save Changes",.on_confirm=OptionSaveChanges_openMenu}, {NULL}, {NULL}, @@ -5911,7 +6580,22 @@ static MenuList options_menu = { }; static void OptionSaveChanges_updateDesc(void) { - options_menu.items[4].desc = getSaveDesc(); + options_menu.items[7].desc = getSaveDesc(); +} + +// Update the Achievements menu item to show count +static char ach_desc_buffer[64] = {0}; +static void OptionAchievements_updateDesc(void) { + if (RA_isGameLoaded()) { + uint32_t unlocked, total; + RA_getAchievementSummary(&unlocked, &total); + if (total > 0) { + snprintf(ach_desc_buffer, sizeof(ach_desc_buffer), "%u / %u unlocked", unlocked, total); + options_menu.items[6].desc = ach_desc_buffer; + return; + } + } + options_menu.items[6].desc = NULL; } #define OPTION_PADDING 8 @@ -6023,6 +6707,7 @@ static int Menu_options(MenuList* list) { int visible_rows = end; OptionSaveChanges_updateDesc(); + OptionAchievements_updateDesc(); int defer_menu = false; while (show_options) { @@ -6636,6 +7321,11 @@ static void Menu_screenshot(void) { args->path = SDL_strdup(png_path); SDL_WaitThread(screenshotsavethread, NULL); screenshotsavethread = SDL_CreateThread(save_screenshot_thread, "SaveScreenshotThread", args); + + // Show notification if enabled + if (CFG_getNotifyScreenshot()) { + Notification_push(NOTIFICATION_SETTING, "Screenshot saved", NULL); + } } static void Menu_saveState(void) { // LOG_info("Menu_saveState\n"); @@ -6666,7 +7356,15 @@ static void Menu_saveState(void) { state_slot = menu.slot; putInt(menu.slot_path, menu.slot); - State_write(); + int success = State_write(); + + // Show notification if enabled + if (CFG_getNotifyManualSave()) { + char msg[NOTIFICATION_MAX_MESSAGE]; + // User-facing slots are 1-8 (internal 0-7) + snprintf(msg, sizeof(msg), success ? "State Saved - Slot %d" : "Save Failed - Slot %d", menu.slot + 1); + Notification_push(NOTIFICATION_SAVE_STATE, msg, NULL); + } } static void Menu_loadState(void) { Menu_updateState(); @@ -6688,7 +7386,15 @@ static void Menu_loadState(void) { state_slot = menu.slot; putInt(menu.slot_path, menu.slot); - State_read(); + int success = State_read(); + + // Show notification if enabled + if (CFG_getNotifyLoad()) { + char msg[NOTIFICATION_MAX_MESSAGE]; + // User-facing slots are 1-8 (internal 0-7) + snprintf(msg, sizeof(msg), success ? "State Loaded - Slot %d" : "Load Failed - Slot %d", menu.slot + 1); + Notification_push(NOTIFICATION_LOAD_STATE, msg, NULL); + } } } @@ -7088,6 +7794,19 @@ static void limitFF(void) { last_time = now; } +static void trackFPS(void) { + cpu_ticks += 1; + uint32_t now = SDL_GetTicks(); + if (now - sec_start >= 1000) { + double last_time = (double)(now - sec_start) / 1000; + fps_double = fps_ticks / last_time; + cpu_double = cpu_ticks / last_time; + sec_start = now; + cpu_ticks = 0; + fps_ticks = 0; + } +} + #define PWR_UPDATE_FREQ 5 #define PWR_UPDATE_FREQ_INGAME 20 @@ -7186,8 +7905,18 @@ int main(int argc , char* argv[]) { Core_init(); + // Initialize RetroAchievements after core.init() but before Core_load() + // Set up memory accessors for achievement memory reading + RA_setMemoryAccessors(core.get_memory_data, core.get_memory_size); + RA_init(); + + // TODO: find a better place to do this + // mixing static and loaded data is messy + // why not move to Core_init()? + // ah, because it's defined before options_menu... options_menu.items[1].desc = (char*)core.version; Core_load(); + Input_init(NULL); Config_readOptions(); // but others load and report options later (eg. nes) Config_readControls(); // restore controls (after the core has reported its defaults) @@ -7198,6 +7927,15 @@ int main(int argc , char* argv[]) { SND_registerDeviceWatcher(onAudioSinkChanged); InitSettings(); // after we initialize audio Menu_init(); + Notification_init(); + + // Load game for RetroAchievements tracking (must be after Notification_init) + // Pass ROM data if available, otherwise just path (for cores that load from file) + { + char* rom_path_for_ra = game.tmp_path[0] ? game.tmp_path : game.path; + RA_loadGame(rom_path_for_ra, game.data, game.size, core.tag); + } + State_resume(); Menu_initState(); // make ready for state shortcuts @@ -7233,7 +7971,52 @@ int main(int argc , char* argv[]) { GFX_startFrame(); core.run(); - limitFF(); + + // Process RetroAchievements for this frame + RA_doFrame(); + + limitFF(); + trackFPS(); + + // Update and render notifications overlay + Notification_update(SDL_GetTicks()); + + // Poll for volume/brightness/colortemp changes and show system indicators + { + static int last_volume = -1; + static int last_brightness = -1; + static int last_colortemp = -1; + + int cur_volume = GetVolume(); + int cur_brightness = GetBrightness(); + int cur_colortemp = GetColortemp(); + + if (last_volume == -1) { + // First frame - just initialize cached values, don't show indicator + last_volume = cur_volume; + last_brightness = cur_brightness; + last_colortemp = cur_colortemp; + } else { + // Check for changes + if (cur_volume != last_volume) { + last_volume = cur_volume; + if (CFG_getNotifyAdjustments()) + Notification_showSystemIndicator(SYSTEM_INDICATOR_VOLUME); + } + if (cur_brightness != last_brightness) { + last_brightness = cur_brightness; + if (CFG_getNotifyAdjustments()) + Notification_showSystemIndicator(SYSTEM_INDICATOR_BRIGHTNESS); + } + if (cur_colortemp != last_colortemp) { + last_colortemp = cur_colortemp; + if (CFG_getNotifyAdjustments()) + Notification_showSystemIndicator(SYSTEM_INDICATOR_COLORTEMP); + } + } + } + + Notification_renderToLayer(5); // Always call - handles cleanup when inactive if (has_pending_opt_change) { has_pending_opt_change = 0; @@ -7248,6 +8031,8 @@ int main(int argc , char* argv[]) { if (show_menu) { PWR_updateFrequency(PWR_UPDATE_FREQ,1); Menu_loop(); + // Process RA async operations while menu is shown + RA_idle(); PWR_updateFrequency(PWR_UPDATE_FREQ_INGAME,0); has_pending_opt_change = config.core.changed; chooseSyncRef(); @@ -7280,10 +8065,15 @@ int main(int argc , char* argv[]) { PLAT_clearTurbo(); Menu_quit(); + Notification_quit(); QuitSettings(); finish: + // Unload game and shutdown RetroAchievements before Core_quit + RA_unloadGame(); + RA_quit(); + Game_close(); Core_unload(); Core_quit(); diff --git a/workspace/all/minarch/ra_consoles.h b/workspace/all/minarch/ra_consoles.h new file mode 100644 index 000000000..46d7e00a0 --- /dev/null +++ b/workspace/all/minarch/ra_consoles.h @@ -0,0 +1,106 @@ +#ifndef __RA_CONSOLES_H__ +#define __RA_CONSOLES_H__ + +/** + * RetroAchievements Console ID Mapping for NextUI + * + * Maps NextUI EMU_TAGs to rcheevos RC_CONSOLE_* constants. + * This is used to identify games when loading them for achievement tracking. + */ + +#include "rc_consoles.h" +#include + +/** + * Get the RetroAchievements console ID for a given EMU tag. + * @param emu_tag The NextUI emulator tag (e.g., "GB", "SFC", "PS") + * @return The RC_CONSOLE_* constant, or RC_CONSOLE_UNKNOWN if not supported + */ +static inline int RA_getConsoleId(const char* emu_tag) { + if (!emu_tag || !*emu_tag) { + return RC_CONSOLE_UNKNOWN; + } + + // Nintendo + if (strcmp(emu_tag, "FC") == 0) return RC_CONSOLE_NINTENDO; // Famicom/NES + if (strcmp(emu_tag, "FDS") == 0) return RC_CONSOLE_FAMICOM_DISK_SYSTEM; // Famicom Disk System + if (strcmp(emu_tag, "SFC") == 0) return RC_CONSOLE_SUPER_NINTENDO; // Super Famicom/SNES + if (strcmp(emu_tag, "SUPA") == 0) return RC_CONSOLE_SUPER_NINTENDO; // Super Famicom (Supafaust) + if (strcmp(emu_tag, "GB") == 0) return RC_CONSOLE_GAMEBOY; // Game Boy + if (strcmp(emu_tag, "GBC") == 0) return RC_CONSOLE_GAMEBOY_COLOR; // Game Boy Color + if (strcmp(emu_tag, "SGB") == 0) return RC_CONSOLE_GAMEBOY; // Super Game Boy + if (strcmp(emu_tag, "GBA") == 0) return RC_CONSOLE_GAMEBOY_ADVANCE; // Game Boy Advance + if (strcmp(emu_tag, "MGBA") == 0) return RC_CONSOLE_GAMEBOY_ADVANCE; // GBA (mGBA) + if (strcmp(emu_tag, "VB") == 0) return RC_CONSOLE_VIRTUAL_BOY; // Virtual Boy + if (strcmp(emu_tag, "PKM") == 0) return RC_CONSOLE_POKEMON_MINI; // Pokemon Mini + + // Sega + if (strcmp(emu_tag, "MD") == 0) return RC_CONSOLE_MEGA_DRIVE; // Mega Drive/Genesis + if (strcmp(emu_tag, "32X") == 0) return RC_CONSOLE_SEGA_32X; // Sega 32X + if (strcmp(emu_tag, "SEGACD") == 0) return RC_CONSOLE_SEGA_CD; // Sega CD + if (strcmp(emu_tag, "SMS") == 0) return RC_CONSOLE_MASTER_SYSTEM; // Master System + if (strcmp(emu_tag, "GG") == 0) return RC_CONSOLE_GAME_GEAR; // Game Gear + if (strcmp(emu_tag, "SG1000") == 0) return RC_CONSOLE_SG1000; // SG-1000 + + // Sony + if (strcmp(emu_tag, "PS") == 0) return RC_CONSOLE_PLAYSTATION; // PlayStation + if (strcmp(emu_tag, "PSX") == 0) return RC_CONSOLE_PLAYSTATION; // PlayStation (SwanStation) + + // NEC + if (strcmp(emu_tag, "PCE") == 0) return RC_CONSOLE_PC_ENGINE; // TurboGrafx-16/PC Engine + + // Atari + if (strcmp(emu_tag, "A2600") == 0) return RC_CONSOLE_ATARI_2600; // Atari 2600 + if (strcmp(emu_tag, "A5200") == 0) return RC_CONSOLE_ATARI_5200; // Atari 5200 + if (strcmp(emu_tag, "A7800") == 0) return RC_CONSOLE_ATARI_7800; // Atari 7800 + if (strcmp(emu_tag, "LYNX") == 0) return RC_CONSOLE_ATARI_LYNX; // Atari Lynx + + // SNK + if (strcmp(emu_tag, "NGP") == 0) return RC_CONSOLE_NEOGEO_POCKET; // Neo Geo Pocket + if (strcmp(emu_tag, "NGPC") == 0) return RC_CONSOLE_NEOGEO_POCKET; // Neo Geo Pocket Color + + // Arcade + if (strcmp(emu_tag, "FBN") == 0) return RC_CONSOLE_ARCADE; // FinalBurn Neo (varies) + + // Home Computers + if (strcmp(emu_tag, "C64") == 0) return RC_CONSOLE_COMMODORE_64; // Commodore 64 + if (strcmp(emu_tag, "C128") == 0) return RC_CONSOLE_COMMODORE_64; // Commodore 128 (uses C64 for RA) + if (strcmp(emu_tag, "VIC") == 0) return RC_CONSOLE_VIC20; // Commodore VIC-20 + if (strcmp(emu_tag, "PET") == 0) return RC_CONSOLE_UNKNOWN; // Commodore PET (no RA support) + if (strcmp(emu_tag, "PLUS4") == 0) return RC_CONSOLE_UNKNOWN; // Commodore Plus/4 (no RA support) + if (strcmp(emu_tag, "CPC") == 0) return RC_CONSOLE_AMSTRAD_PC; // Amstrad CPC + if (strcmp(emu_tag, "MSX") == 0) return RC_CONSOLE_MSX; // MSX + if (strcmp(emu_tag, "PUAE") == 0) return RC_CONSOLE_AMIGA; // Amiga (PUAE) + + // Other + if (strcmp(emu_tag, "COLECO") == 0) return RC_CONSOLE_COLECOVISION; // ColecoVision + if (strcmp(emu_tag, "P8") == 0) return RC_CONSOLE_PICO; // PICO-8 (fantasy console) + if (strcmp(emu_tag, "PRBOOM") == 0) return RC_CONSOLE_UNKNOWN; // PrBoom (DOOM - no RA) + + return RC_CONSOLE_UNKNOWN; +} + +/** + * Check if achievements are supported for a given EMU tag. + * @param emu_tag The NextUI emulator tag + * @return 1 if supported, 0 if not + */ +static inline int RA_isConsoleSupported(const char* emu_tag) { + return RA_getConsoleId(emu_tag) != RC_CONSOLE_UNKNOWN; +} + +/** + * Get a display name for the console. + * Uses rcheevos rc_console_name() internally. + * @param emu_tag The NextUI emulator tag + * @return Human-readable console name, or "Unknown" if not supported + */ +static inline const char* RA_getConsoleName(const char* emu_tag) { + int console_id = RA_getConsoleId(emu_tag); + if (console_id == RC_CONSOLE_UNKNOWN) { + return "Unknown"; + } + return rc_console_name(console_id); +} + +#endif // __RA_CONSOLES_H__ diff --git a/workspace/all/minarch/ra_integration.c b/workspace/all/minarch/ra_integration.c new file mode 100644 index 000000000..9ff5d7862 --- /dev/null +++ b/workspace/all/minarch/ra_integration.c @@ -0,0 +1,1471 @@ +#include "ra_integration.h" +#include "ra_consoles.h" +#include "rc_client.h" +#include "rc_libretro.h" +#include "rc_hash.h" +#include "chd_reader.h" +#include "config.h" +#include "http.h" +#include "notification.h" +#include "ra_badges.h" +#include "defines.h" +#include "api.h" + +#include +#include +#include +#include +#include +#include +#include + +// Logging macro +#define RA_LOG(fmt, ...) printf("[RA] " fmt, ##__VA_ARGS__) + +/***************************************************************************** + * Static state + *****************************************************************************/ + +static rc_client_t* ra_client = NULL; +static bool ra_game_loaded = false; +static bool ra_logged_in = false; + +// Current game hash (for mute file path) +static char ra_game_hash[64] = {0}; + +// Muted achievements tracking +#define RA_MAX_MUTED_ACHIEVEMENTS 1024 +static uint32_t ra_muted_achievements[RA_MAX_MUTED_ACHIEVEMENTS]; +static int ra_muted_count = 0; +static bool ra_muted_dirty = false; // Track if mute state needs saving + +// Memory access function pointers (set by minarch) +static RA_GetMemoryFunc ra_get_memory_data = NULL; +static RA_GetMemorySizeFunc ra_get_memory_size = NULL; + +// Memory map from core (via RETRO_ENVIRONMENT_SET_MEMORY_MAPS) +// We store a deep copy because the core's data may be on the stack or freed +static struct retro_memory_map* ra_memory_map = NULL; +static struct retro_memory_descriptor* ra_memory_map_descriptors = NULL; + +// Memory regions for rcheevos (initialized per-game based on console type) +static rc_libretro_memory_regions_t ra_memory_regions; +static bool ra_memory_regions_initialized = false; + +// Pending game load storage (for async login race condition) +#define RA_MAX_PATH 512 +static char pending_rom_path[RA_MAX_PATH]; +static uint8_t* pending_rom_data = NULL; +static size_t pending_rom_size = 0; +static char pending_emu_tag[16]; +static bool pending_game_load = false; + +// Login retry state +static int ra_login_retry_count = 0; +#define RA_LOGIN_MAX_RETRIES 5 +static uint32_t ra_login_retry_time = 0; // SDL_GetTicks() timestamp for next retry +static bool ra_login_retry_pending = false; +static bool ra_login_notified_connecting = false; // Track if we showed "Connecting..." notification + +// Wifi wait config +#define RA_WIFI_WAIT_MAX_MS 3000 // 3 seconds max blocking wait +#define RA_WIFI_WAIT_POLL_MS 500 // Check every 500ms + +/***************************************************************************** + * Thread-safe response queue + * + * HTTP callbacks are invoked from worker threads, but rcheevos callbacks + * and our integration code access shared state that isn't thread-safe. + * We queue HTTP responses and process them on the main thread in RA_idle(). + *****************************************************************************/ + +typedef struct { + char* body; // Owned copy of response body + size_t body_length; + int http_status_code; + rc_client_server_callback_t callback; + void* callback_data; +} RA_QueuedResponse; + +#define RA_RESPONSE_QUEUE_SIZE 16 +static RA_QueuedResponse ra_response_queue[RA_RESPONSE_QUEUE_SIZE]; +static volatile int ra_response_queue_count = 0; +static SDL_mutex* ra_queue_mutex = NULL; + +// Forward declarations for queue functions +static void ra_queue_init(void); +static void ra_queue_quit(void); +static bool ra_queue_push(const char* body, size_t body_length, int http_status, + rc_client_server_callback_t callback, void* callback_data); +static bool ra_queue_pop(RA_QueuedResponse* out); +static void ra_process_queued_responses(void); + +// Forward declarations for helper functions +static void ra_clear_pending_game(void); +static void ra_do_load_game(const char* rom_path, const uint8_t* rom_data, size_t rom_size, const char* emu_tag); +static void ra_load_muted_achievements(void); +static void ra_save_muted_achievements(void); +static void ra_clear_muted_achievements(void); +static void ra_reset_login_state(void); +static void ra_start_login(void); +static uint32_t ra_get_retry_delay_ms(int attempt); +static void ra_login_callback(int result, const char* error_message, rc_client_t* client, void* userdata); + +/***************************************************************************** + * CHD (compressed hunks of data) reader support for disc images + * + * The default rcheevos CD reader only supports CUE/BIN and ISO formats. + * We wrap the CD reader callbacks to try CHD first, then fall back to default. + * + * We use a wrapper handle to track whether a handle came from CHD or default + * reader, so we can route subsequent calls to the correct implementation. + *****************************************************************************/ + +// Store default CD reader callbacks for fallback +static rc_hash_cdreader_t ra_default_cdreader; + +// Wrapper handle to distinguish CHD vs default reader handles +#define RA_CDHANDLE_MAGIC 0x43484448 // "CHDH" +typedef struct { + uint32_t magic; // Magic number to identify our wrapper + bool is_chd; // true = CHD handle, false = default reader handle + void* inner_handle; // The actual handle from CHD or default reader +} ra_cdreader_handle_t; + +// Helper to create a wrapper handle +static void* ra_cdreader_wrap_handle(void* inner_handle, bool is_chd) { + if (!inner_handle) return NULL; + + ra_cdreader_handle_t* wrapper = (ra_cdreader_handle_t*)malloc(sizeof(ra_cdreader_handle_t)); + if (!wrapper) { + // Failed to allocate wrapper - close the inner handle + if (is_chd) { + chd_close_track(inner_handle); + } else if (ra_default_cdreader.close_track) { + ra_default_cdreader.close_track(inner_handle); + } + return NULL; + } + + wrapper->magic = RA_CDHANDLE_MAGIC; + wrapper->is_chd = is_chd; + wrapper->inner_handle = inner_handle; + return wrapper; +} + +// Helper to validate and unwrap handle +static ra_cdreader_handle_t* ra_cdreader_unwrap(void* handle) { + if (!handle) return NULL; + ra_cdreader_handle_t* wrapper = (ra_cdreader_handle_t*)handle; + if (wrapper->magic != RA_CDHANDLE_MAGIC) return NULL; + return wrapper; +} + +// Wrapper: Try CHD first, then default +static void* ra_cdreader_open_track(const char* path, uint32_t track) { + // Try CHD reader first + void* handle = chd_open_track(path, track); + if (handle) { + return ra_cdreader_wrap_handle(handle, true); + } + // Fall back to default reader + if (ra_default_cdreader.open_track) { + handle = ra_default_cdreader.open_track(path, track); + if (handle) { + return ra_cdreader_wrap_handle(handle, false); + } + } + return NULL; +} + +static void* ra_cdreader_open_track_iterator(const char* path, uint32_t track, const rc_hash_iterator_t* iterator) { + // Try CHD reader first + void* handle = chd_open_track_iterator(path, track, iterator); + if (handle) { + return ra_cdreader_wrap_handle(handle, true); + } + // Fall back to default reader + if (ra_default_cdreader.open_track_iterator) { + handle = ra_default_cdreader.open_track_iterator(path, track, iterator); + if (handle) { + return ra_cdreader_wrap_handle(handle, false); + } + } + if (ra_default_cdreader.open_track) { + handle = ra_default_cdreader.open_track(path, track); + if (handle) { + return ra_cdreader_wrap_handle(handle, false); + } + } + return NULL; +} + +static size_t ra_cdreader_read_sector(void* track_handle, uint32_t sector, void* buffer, size_t requested_bytes) { + ra_cdreader_handle_t* wrapper = ra_cdreader_unwrap(track_handle); + if (!wrapper) return 0; + + if (wrapper->is_chd) { + return chd_read_sector(wrapper->inner_handle, sector, buffer, requested_bytes); + } else if (ra_default_cdreader.read_sector) { + return ra_default_cdreader.read_sector(wrapper->inner_handle, sector, buffer, requested_bytes); + } + return 0; +} + +static void ra_cdreader_close_track(void* track_handle) { + ra_cdreader_handle_t* wrapper = ra_cdreader_unwrap(track_handle); + if (!wrapper) return; + + if (wrapper->is_chd) { + chd_close_track(wrapper->inner_handle); + } else if (ra_default_cdreader.close_track) { + ra_default_cdreader.close_track(wrapper->inner_handle); + } + + // Clear magic and free wrapper + wrapper->magic = 0; + free(wrapper); +} + +static uint32_t ra_cdreader_first_track_sector(void* track_handle) { + ra_cdreader_handle_t* wrapper = ra_cdreader_unwrap(track_handle); + if (!wrapper) return 0; + + if (wrapper->is_chd) { + return chd_first_track_sector(wrapper->inner_handle); + } else if (ra_default_cdreader.first_track_sector) { + return ra_default_cdreader.first_track_sector(wrapper->inner_handle); + } + return 0; +} + +// Initialize CHD-aware CD reader callbacks +static void ra_init_cdreader(void) { + // Get default callbacks to use as fallback + rc_hash_get_default_cdreader(&ra_default_cdreader); + + RA_LOG("Initializing CHD-aware CD reader\n"); +} + +/***************************************************************************** + * Helper: Get retry delay for login attempts + *****************************************************************************/ +static uint32_t ra_get_retry_delay_ms(int attempt) { + // Delays: 1s, 2s, 4s, 8s, 8s + uint32_t delays[] = {1000, 2000, 4000, 8000, 8000}; + int idx = (attempt < 5) ? attempt : 4; + return delays[idx]; +} + +/***************************************************************************** + * Helper: Reset login retry state + *****************************************************************************/ +static void ra_reset_login_state(void) { + ra_login_retry_count = 0; + ra_login_retry_pending = false; + ra_login_retry_time = 0; + ra_login_notified_connecting = false; +} + +/***************************************************************************** + * Helper: Start a login attempt + *****************************************************************************/ +static void ra_start_login(void) { + RA_LOG("Attempting login (attempt %d/%d)...\n", + ra_login_retry_count + 1, RA_LOGIN_MAX_RETRIES); + rc_client_begin_login_with_token(ra_client, + CFG_getRAUsername(), CFG_getRAToken(), + ra_login_callback, NULL); +} + +/***************************************************************************** + * Response queue implementation + * + * Thread-safe circular queue for HTTP responses. Worker threads push, + * main thread pops and processes in RA_idle(). + *****************************************************************************/ + +static void ra_queue_init(void) { + if (!ra_queue_mutex) { + ra_queue_mutex = SDL_CreateMutex(); + } + ra_response_queue_count = 0; + memset(ra_response_queue, 0, sizeof(ra_response_queue)); +} + +static void ra_queue_quit(void) { + // Drain any pending responses + if (ra_queue_mutex) { + SDL_LockMutex(ra_queue_mutex); + for (int i = 0; i < ra_response_queue_count; i++) { + free(ra_response_queue[i].body); + ra_response_queue[i].body = NULL; + } + ra_response_queue_count = 0; + SDL_UnlockMutex(ra_queue_mutex); + + SDL_DestroyMutex(ra_queue_mutex); + ra_queue_mutex = NULL; + } +} + +// Called from worker thread - enqueue a response for main thread processing +static bool ra_queue_push(const char* body, size_t body_length, int http_status, + rc_client_server_callback_t callback, void* callback_data) { + if (!ra_queue_mutex) { + return false; + } + + bool success = false; + SDL_LockMutex(ra_queue_mutex); + + if (ra_response_queue_count < RA_RESPONSE_QUEUE_SIZE) { + RA_QueuedResponse* resp = &ra_response_queue[ra_response_queue_count]; + + // Copy the body data (caller will free original) + if (body && body_length > 0) { + resp->body = (char*)malloc(body_length + 1); + if (resp->body) { + memcpy(resp->body, body, body_length); + resp->body[body_length] = '\0'; + resp->body_length = body_length; + } else { + resp->body_length = 0; + } + } else { + resp->body = NULL; + resp->body_length = 0; + } + + resp->http_status_code = http_status; + resp->callback = callback; + resp->callback_data = callback_data; + + ra_response_queue_count++; + success = true; + } else { + RA_LOG("Warning: Response queue full, dropping response\n"); + } + + SDL_UnlockMutex(ra_queue_mutex); + return success; +} + +// Called from main thread - dequeue a response for processing +static bool ra_queue_pop(RA_QueuedResponse* out) { + if (!ra_queue_mutex || !out) { + return false; + } + + bool has_item = false; + SDL_LockMutex(ra_queue_mutex); + + if (ra_response_queue_count > 0) { + // Copy first item to output + *out = ra_response_queue[0]; + + // Shift remaining items down + for (int i = 0; i < ra_response_queue_count - 1; i++) { + ra_response_queue[i] = ra_response_queue[i + 1]; + } + ra_response_queue_count--; + + // Clear the last slot + memset(&ra_response_queue[ra_response_queue_count], 0, sizeof(RA_QueuedResponse)); + + has_item = true; + } + + SDL_UnlockMutex(ra_queue_mutex); + return has_item; +} + +// Called from main thread in RA_idle() - process all queued responses +static void ra_process_queued_responses(void) { + RA_QueuedResponse resp; + + while (ra_queue_pop(&resp)) { + // Build the server response structure + rc_api_server_response_t server_response; + memset(&server_response, 0, sizeof(server_response)); + + server_response.body = resp.body; + server_response.body_length = resp.body_length; + server_response.http_status_code = resp.http_status_code; + + // Invoke the rcheevos callback on the main thread + if (resp.callback) { + resp.callback(&server_response, resp.callback_data); + } + + // Free our copy of the body + free(resp.body); + } +} + +/***************************************************************************** + * Helper: Muted achievements file path + *****************************************************************************/ +static void ra_get_mute_file_path(char* path, size_t path_size) { + snprintf(path, path_size, USERDATA_PATH "/ra/muted/%s.txt", ra_game_hash); +} + +/***************************************************************************** + * Helper: Ensure mute directory exists + *****************************************************************************/ +static void ra_ensure_mute_dir(void) { + char dir_path[512]; + snprintf(dir_path, sizeof(dir_path), USERDATA_PATH "/ra"); + mkdir(dir_path, 0755); + snprintf(dir_path, sizeof(dir_path), USERDATA_PATH "/ra/muted"); + mkdir(dir_path, 0755); +} + +/***************************************************************************** + * Helper: Load muted achievements from file + *****************************************************************************/ +static void ra_load_muted_achievements(void) { + ra_clear_muted_achievements(); + + if (ra_game_hash[0] == '\0') { + return; + } + + char path[512]; + ra_get_mute_file_path(path, sizeof(path)); + + FILE* f = fopen(path, "r"); + if (!f) { + return; // No mute file yet, that's okay + } + + char line[32]; + while (fgets(line, sizeof(line), f) && ra_muted_count < RA_MAX_MUTED_ACHIEVEMENTS) { + uint32_t id = (uint32_t)strtoul(line, NULL, 10); + if (id > 0) { + ra_muted_achievements[ra_muted_count++] = id; + } + } + + fclose(f); + RA_LOG("Loaded %d muted achievements for game %s\n", ra_muted_count, ra_game_hash); +} + +/***************************************************************************** + * Helper: Save muted achievements to file + *****************************************************************************/ +static void ra_save_muted_achievements(void) { + if (ra_game_hash[0] == '\0') { + return; + } + + if (!ra_muted_dirty) { + return; // Nothing changed + } + + ra_ensure_mute_dir(); + + char path[512]; + ra_get_mute_file_path(path, sizeof(path)); + + // If no muted achievements, remove the file + if (ra_muted_count == 0) { + remove(path); + ra_muted_dirty = false; + return; + } + + FILE* f = fopen(path, "w"); + if (!f) { + RA_LOG("Error: Failed to save mute file: %s\n", path); + return; + } + + for (int i = 0; i < ra_muted_count; i++) { + fprintf(f, "%u\n", ra_muted_achievements[i]); + } + + fclose(f); + ra_muted_dirty = false; + RA_LOG("Saved %d muted achievements for game %s\n", ra_muted_count, ra_game_hash); +} + +/***************************************************************************** + * Helper: Clear muted achievements list + *****************************************************************************/ +static void ra_clear_muted_achievements(void) { + ra_muted_count = 0; + ra_muted_dirty = false; +} + +/***************************************************************************** + * Helper: Get core memory info callback for rc_libretro + * + * This callback is used by rc_libretro_memory_init to query memory regions + * from the libretro core when no memory map is provided. + *****************************************************************************/ + +static void ra_get_core_memory_info(uint32_t id, rc_libretro_core_memory_info_t* info) { + if (ra_get_memory_data && ra_get_memory_size) { + info->data = (uint8_t*)ra_get_memory_data(id); + info->size = ra_get_memory_size(id); + } else { + info->data = NULL; + info->size = 0; + } +} + +/***************************************************************************** + * Callback: Memory read + * + * rcheevos calls this to read emulator memory for achievement checking. + * We use rc_libretro_memory_read which handles memory maps properly. + *****************************************************************************/ + +static uint32_t ra_read_memory(uint32_t address, uint8_t* buffer, uint32_t num_bytes, rc_client_t* client) { + (void)client; // unused + + // Use the properly initialized memory regions + if (ra_memory_regions_initialized) { + return rc_libretro_memory_read(&ra_memory_regions, address, buffer, num_bytes); + } + + // Fallback for cases where memory regions aren't initialized yet + // This shouldn't happen in normal operation, but provides backwards compatibility + if (!ra_get_memory_data || !ra_get_memory_size) { + return 0; + } + + // RETRO_MEMORY_SYSTEM_RAM = 0 + void* mem_data = ra_get_memory_data(0); + size_t mem_size = ra_get_memory_size(0); + + if (!mem_data || address + num_bytes > mem_size) { + // Try save RAM as fallback (some cores expose different memory types) + // RETRO_MEMORY_SAVE_RAM = 1 + mem_data = ra_get_memory_data(1); + mem_size = ra_get_memory_size(1); + + if (!mem_data || address + num_bytes > mem_size) { + return 0; + } + } + + memcpy(buffer, (uint8_t*)mem_data + address, num_bytes); + return num_bytes; +} + +/***************************************************************************** + * Callback: Server call (HTTP) + * + * rcheevos calls this for all server communication. + * We use our HTTP wrapper to make async requests. + * + * IMPORTANT: HTTP callbacks are invoked from worker threads. We queue the + * responses and process them on the main thread in RA_idle() to avoid + * race conditions with shared state (pending_game_load, notifications, etc). + *****************************************************************************/ + +typedef struct { + rc_client_server_callback_t callback; + void* callback_data; +} RA_ServerCallData; + +static void ra_http_callback(HTTP_Response* response, void* userdata) { + RA_ServerCallData* data = (RA_ServerCallData*)userdata; + + // Extract response info before freeing + const char* body = NULL; + size_t body_length = 0; + int http_status = RC_API_SERVER_RESPONSE_CLIENT_ERROR; + + if (response && response->data && !response->error) { + body = response->data; + body_length = response->size; + http_status = response->http_status; + } else { + // Error case + if (response && response->error) { + RA_LOG("HTTP error: %s\n", response->error); + } + } + + // Queue the response for main thread processing + // The queue makes a copy of the body, so we can free the response after + if (!ra_queue_push(body, body_length, http_status, data->callback, data->callback_data)) { + // Queue failed (full or not initialized) - log but don't crash + RA_LOG("Warning: Failed to queue HTTP response\n"); + } + + // Cleanup - safe to free now since queue copied the data + if (response) { + HTTP_freeResponse(response); + } + free(data); +} + +static void ra_server_call(const rc_api_request_t* request, + rc_client_server_callback_t callback, + void* callback_data, rc_client_t* client) { + (void)client; // unused + + // Allocate data structure to pass through to callback + RA_ServerCallData* data = (RA_ServerCallData*)malloc(sizeof(RA_ServerCallData)); + if (!data) { + // Out of memory - call callback with error + rc_api_server_response_t error_response; + memset(&error_response, 0, sizeof(error_response)); + error_response.http_status_code = RC_API_SERVER_RESPONSE_CLIENT_ERROR; + callback(&error_response, callback_data); + return; + } + + data->callback = callback; + data->callback_data = callback_data; + + // Make async HTTP request + if (request->post_data && strlen(request->post_data) > 0) { + HTTP_postAsync(request->url, request->post_data, request->content_type, + ra_http_callback, data); + } else { + HTTP_getAsync(request->url, ra_http_callback, data); + } +} + +/***************************************************************************** + * Callback: Logging + *****************************************************************************/ + +static void ra_log_message(const char* message, const rc_client_t* client) { + (void)client; + RA_LOG("%s\n", message); +} + +/***************************************************************************** + * Callback: Event handler + * + * Called by rcheevos when achievements are unlocked, leaderboards triggered, etc. + *****************************************************************************/ + +static void ra_event_handler(const rc_client_event_t* event, rc_client_t* client) { + (void)client; + char message[NOTIFICATION_MAX_MESSAGE]; + SDL_Surface* badge_icon = NULL; + + switch (event->type) { + case RC_CLIENT_EVENT_ACHIEVEMENT_TRIGGERED: + // Hide "Unknown Emulator" notification when hardcore mode is disabled + if (!CFG_getRAHardcoreMode() && event->achievement->id == 101000001) { + RA_LOG("Skipping Unknown Emulator notification (not in hardcore mode)\n"); + break; + } + snprintf(message, sizeof(message), "Achievement Unlocked: %s", + event->achievement->title); + // Get the unlocked badge icon (not locked) + badge_icon = RA_Badges_getNotificationSize(event->achievement->badge_name, false); + Notification_push(NOTIFICATION_ACHIEVEMENT, message, badge_icon); + RA_LOG("Achievement unlocked: %s (%d points)\n", + event->achievement->title, event->achievement->points); + break; + + case RC_CLIENT_EVENT_ACHIEVEMENT_CHALLENGE_INDICATOR_SHOW: + RA_LOG("Challenge started: %s\n", event->achievement->title); + break; + + case RC_CLIENT_EVENT_ACHIEVEMENT_CHALLENGE_INDICATOR_HIDE: + RA_LOG("Challenge ended: %s\n", event->achievement->title); + break; + + case RC_CLIENT_EVENT_ACHIEVEMENT_PROGRESS_INDICATOR_SHOW: + // Skip progress indicators if disabled (duration=0) + if (CFG_getRAProgressNotificationDuration() == 0) { + break; + } + // Skip progress indicators for muted achievements + if (RA_isAchievementMuted(event->achievement->id)) { + break; + } + // Show progress indicator with badge icon + badge_icon = RA_Badges_getNotificationSize(event->achievement->badge_name, false); + Notification_showProgressIndicator( + event->achievement->title, + event->achievement->measured_progress, + badge_icon + ); + break; + + case RC_CLIENT_EVENT_ACHIEVEMENT_PROGRESS_INDICATOR_UPDATE: + // Skip progress indicators if disabled (duration=0) + if (CFG_getRAProgressNotificationDuration() == 0) { + break; + } + // Skip progress indicators for muted achievements + if (RA_isAchievementMuted(event->achievement->id)) { + break; + } + // Update progress indicator with new value + badge_icon = RA_Badges_getNotificationSize(event->achievement->badge_name, false); + Notification_showProgressIndicator( + event->achievement->title, + event->achievement->measured_progress, + badge_icon + ); + break; + + case RC_CLIENT_EVENT_ACHIEVEMENT_PROGRESS_INDICATOR_HIDE: + Notification_hideProgressIndicator(); + break; + + case RC_CLIENT_EVENT_LEADERBOARD_STARTED: + snprintf(message, sizeof(message), "Leaderboard: %s", + event->leaderboard->title); + Notification_push(NOTIFICATION_ACHIEVEMENT, message, NULL); + RA_LOG("Leaderboard started: %s\n", event->leaderboard->title); + break; + + case RC_CLIENT_EVENT_LEADERBOARD_FAILED: + RA_LOG("Leaderboard failed: %s\n", event->leaderboard->title); + break; + + case RC_CLIENT_EVENT_LEADERBOARD_SUBMITTED: + snprintf(message, sizeof(message), "Submitted %s to %s", + event->leaderboard->tracker_value, event->leaderboard->title); + Notification_push(NOTIFICATION_ACHIEVEMENT, message, NULL); + RA_LOG("Leaderboard submitted: %s - %s\n", + event->leaderboard->title, event->leaderboard->tracker_value); + break; + + case RC_CLIENT_EVENT_GAME_COMPLETED: + Notification_push(NOTIFICATION_ACHIEVEMENT, "Game Mastered!", NULL); + RA_LOG("Game mastered!\n"); + break; + + case RC_CLIENT_EVENT_RESET: + RA_LOG("Reset requested (hardcore mode enabled)\n"); + break; + + case RC_CLIENT_EVENT_SERVER_ERROR: + RA_LOG("Server error: %s\n", + event->server_error ? event->server_error->error_message : "unknown"); + // Show notification for server errors + snprintf(message, sizeof(message), "RA Server Error: %s", + event->server_error ? event->server_error->error_message : "unknown"); + Notification_push(NOTIFICATION_ACHIEVEMENT, message, NULL); + break; + + case RC_CLIENT_EVENT_DISCONNECTED: + RA_LOG("Disconnected - unlocks pending\n"); + Notification_push(NOTIFICATION_ACHIEVEMENT, "RetroAchievements: Offline mode", NULL); + break; + + case RC_CLIENT_EVENT_RECONNECTED: + RA_LOG("Reconnected - pending unlocks submitted\n"); + Notification_push(NOTIFICATION_ACHIEVEMENT, "RetroAchievements: Reconnected", NULL); + break; + + default: + RA_LOG("Unhandled event type: %d\n", event->type); + break; + } +} + +/***************************************************************************** + * Callback: Login callback + *****************************************************************************/ + +static void ra_login_callback(int result, const char* error_message, + rc_client_t* client, void* userdata) { + (void)userdata; + + if (result == RC_OK) { + // Success - reset retry state + ra_reset_login_state(); + ra_logged_in = true; + + const rc_client_user_t* user = rc_client_get_user_info(client); + RA_LOG("Logged in as %s (score: %u)\n", + user ? user->display_name : "unknown", + user ? user->score : 0); + + // Check if we have a pending game to load + if (pending_game_load) { + RA_LOG("Processing deferred game load: %s\n", pending_rom_path); + ra_do_load_game(pending_rom_path, pending_rom_data, pending_rom_size, pending_emu_tag); + ra_clear_pending_game(); + } + } else { + // Failure - attempt retry or give up + ra_logged_in = false; + RA_LOG("Login failed: %s\n", error_message ? error_message : "unknown error"); + + if (ra_login_retry_count < RA_LOGIN_MAX_RETRIES) { + // Schedule retry + uint32_t delay = ra_get_retry_delay_ms(ra_login_retry_count); + ra_login_retry_time = SDL_GetTicks() + delay; + ra_login_retry_pending = true; + ra_login_retry_count++; + + RA_LOG("Scheduling retry %d/%d in %ums\n", + ra_login_retry_count, RA_LOGIN_MAX_RETRIES, delay); + + // Show "Connecting..." notification on first retry only + if (ra_login_retry_count == 1 && !ra_login_notified_connecting) { + ra_login_notified_connecting = true; + Notification_push(NOTIFICATION_ACHIEVEMENT, + "Connecting to RetroAchievements...", NULL); + } + } else { + // All retries exhausted + RA_LOG("All login retries exhausted\n"); + Notification_push(NOTIFICATION_ACHIEVEMENT, + "RetroAchievements: Connection failed", NULL); + ra_reset_login_state(); + ra_clear_pending_game(); + } + } +} + +/***************************************************************************** + * Helper: Prefetch all achievement badges for the loaded game + *****************************************************************************/ +static void ra_prefetch_badges(rc_client_t* client) { + // Get the achievement list + rc_client_achievement_list_t* list = rc_client_create_achievement_list(client, + RC_CLIENT_ACHIEVEMENT_CATEGORY_CORE_AND_UNOFFICIAL, + RC_CLIENT_ACHIEVEMENT_LIST_GROUPING_LOCK_STATE); + + if (!list) { + RA_LOG("Failed to get achievement list for badge prefetch\n"); + return; + } + + // Collect all unique badge names + int badge_count = 0; + for (uint32_t b = 0; b < list->num_buckets; b++) { + badge_count += list->buckets[b].num_achievements; + } + + if (badge_count == 0) { + rc_client_destroy_achievement_list(list); + return; + } + + const char** badge_names = (const char**)malloc(badge_count * sizeof(const char*)); + if (!badge_names) { + rc_client_destroy_achievement_list(list); + return; + } + + int idx = 0; + for (uint32_t b = 0; b < list->num_buckets; b++) { + for (uint32_t a = 0; a < list->buckets[b].num_achievements; a++) { + const rc_client_achievement_t* ach = list->buckets[b].achievements[a]; + if (ach->badge_name[0] != '\0') { + badge_names[idx++] = ach->badge_name; + } + } + } + + // Prefetch all badges + RA_Badges_prefetch(badge_names, idx); + + free(badge_names); + rc_client_destroy_achievement_list(list); + + RA_LOG("Prefetching %d achievement badges\n", idx); +} + +/***************************************************************************** + * Callback: Game load callback + *****************************************************************************/ + +static void ra_game_loaded_callback(int result, const char* error_message, + rc_client_t* client, void* userdata) { + (void)userdata; + + if (result == RC_OK) { + const rc_client_game_t* game = rc_client_get_game_info(client); + ra_game_loaded = true; + + if (game && game->id != 0) { + RA_LOG("Game loaded: %s (ID: %u)\n", game->title, game->id); + + // Store game hash for mute file path + if (game->hash && game->hash[0] != '\0') { + strncpy(ra_game_hash, game->hash, sizeof(ra_game_hash) - 1); + ra_game_hash[sizeof(ra_game_hash) - 1] = '\0'; + } else { + // Fallback to game ID if no hash available + snprintf(ra_game_hash, sizeof(ra_game_hash), "%u", game->id); + } + + // Load muted achievements for this game + ra_load_muted_achievements(); + + // Initialize badge cache and prefetch achievement badges + RA_Badges_init(); + ra_prefetch_badges(client); + + // Show achievement summary + rc_client_user_game_summary_t summary; + rc_client_get_user_game_summary(client, &summary); + + uint32_t display_unlocked = summary.num_unlocked_achievements; + uint32_t display_total = summary.num_core_achievements; + + // Hide "Unknown Emulator" warning (ID 101000001) when hardcore mode is disabled. + // Note: We intentionally show "Unsupported Game Version" so users know to find a supported ROM. + if (!CFG_getRAHardcoreMode()) { + rc_client_achievement_list_t* list = rc_client_create_achievement_list( + client, RC_CLIENT_ACHIEVEMENT_CATEGORY_CORE, + RC_CLIENT_ACHIEVEMENT_LIST_GROUPING_LOCK_STATE); + if (list) { + bool found = false; + for (uint32_t b = 0; b < list->num_buckets && !found; b++) { + for (uint32_t a = 0; a < list->buckets[b].num_achievements && !found; a++) { + const rc_client_achievement_t* ach = list->buckets[b].achievements[a]; + if (ach->id == 101000001) { + // Subtract from total + if (display_total > 0) display_total--; + // If it's unlocked, subtract from unlocked count too + if (ach->unlocked && display_unlocked > 0) display_unlocked--; + found = true; + } + } + } + rc_client_destroy_achievement_list(list); + } + } + + char message[NOTIFICATION_MAX_MESSAGE]; + snprintf(message, sizeof(message), "%s - %u/%u achievements", + game->title, display_unlocked, display_total); + Notification_push(NOTIFICATION_ACHIEVEMENT, message, NULL); + } else { + RA_LOG("Game not recognized by RetroAchievements\n"); + } + } else { + ra_game_loaded = false; + RA_LOG("Game load failed: %s\n", error_message ? error_message : "unknown error"); + } +} + +/***************************************************************************** + * Public API + *****************************************************************************/ + +void RA_init(void) { + if (!CFG_getRAEnable()) { + RA_LOG("RetroAchievements disabled in settings\n"); + return; + } + + if (ra_client) { + RA_LOG("Already initialized\n"); + return; + } + + // Check wifi state before attempting to connect + if (!PLAT_wifiEnabled()) { + RA_LOG("WiFi disabled - cannot connect to RetroAchievements\n"); + Notification_push(NOTIFICATION_ACHIEVEMENT, + "RetroAchievements requires WiFi", NULL); + return; + } + + // Wait for wifi to connect (handles wake-from-sleep scenario) + if (!PLAT_wifiConnected()) { + RA_LOG("WiFi enabled but not connected, waiting up to %dms...\n", RA_WIFI_WAIT_MAX_MS); + uint32_t start = SDL_GetTicks(); + while (!PLAT_wifiConnected() && + (SDL_GetTicks() - start) < RA_WIFI_WAIT_MAX_MS) { + SDL_Delay(RA_WIFI_WAIT_POLL_MS); + } + + if (!PLAT_wifiConnected()) { + RA_LOG("WiFi did not connect within %dms\n", RA_WIFI_WAIT_MAX_MS); + Notification_push(NOTIFICATION_ACHIEVEMENT, + "RetroAchievements requires WiFi", NULL); + return; + } + RA_LOG("WiFi connected after %ums\n", SDL_GetTicks() - start); + } + + RA_LOG("Initializing...\n"); + + // Initialize the response queue (must be before any HTTP requests) + ra_queue_init(); + + // Create rc_client with our callbacks + ra_client = rc_client_create(ra_read_memory, ra_server_call); + if (!ra_client) { + RA_LOG("Failed to create rc_client\n"); + return; + } + + // Configure logging + rc_client_enable_logging(ra_client, RC_CLIENT_LOG_LEVEL_INFO, ra_log_message); + + // Set event handler + rc_client_set_event_handler(ra_client, ra_event_handler); + + // Initialize and register CHD-aware CD reader for disc game hashing + ra_init_cdreader(); + { + rc_hash_callbacks_t hash_callbacks; + memset(&hash_callbacks, 0, sizeof(hash_callbacks)); + + // Set up CHD-aware CD reader callbacks + hash_callbacks.cdreader.open_track = ra_cdreader_open_track; + hash_callbacks.cdreader.open_track_iterator = ra_cdreader_open_track_iterator; + hash_callbacks.cdreader.read_sector = ra_cdreader_read_sector; + hash_callbacks.cdreader.close_track = ra_cdreader_close_track; + hash_callbacks.cdreader.first_track_sector = ra_cdreader_first_track_sector; + + rc_client_set_hash_callbacks(ra_client, &hash_callbacks); + RA_LOG("CHD disc image support enabled\n"); + } + + // Configure hardcore mode from settings + rc_client_set_hardcore_enabled(ra_client, CFG_getRAHardcoreMode() ? 1 : 0); + + // Reset login state before attempting + ra_reset_login_state(); + + // Attempt login with stored token + if (CFG_getRAAuthenticated() && strlen(CFG_getRAToken()) > 0) { + RA_LOG("Logging in with stored token...\n"); + ra_start_login(); + } else { + RA_LOG("No stored token - user needs to authenticate in settings\n"); + } +} + +void RA_quit(void) { + // Clear any pending game data + ra_clear_pending_game(); + + // Reset login retry state + ra_reset_login_state(); + + // Clean up badge cache + RA_Badges_quit(); + + // Clean up memory regions + if (ra_memory_regions_initialized) { + rc_libretro_memory_destroy(&ra_memory_regions); + ra_memory_regions_initialized = false; + } + + // Free our deep-copied memory map + if (ra_memory_map_descriptors) { + free(ra_memory_map_descriptors); + ra_memory_map_descriptors = NULL; + } + if (ra_memory_map) { + free(ra_memory_map); + ra_memory_map = NULL; + } + + if (ra_client) { + RA_LOG("Shutting down...\n"); + rc_client_destroy(ra_client); + ra_client = NULL; + } + + // Clean up the response queue (after rc_client is destroyed) + ra_queue_quit(); + + ra_game_loaded = false; + ra_logged_in = false; +} + +void RA_setMemoryAccessors(RA_GetMemoryFunc get_data, RA_GetMemorySizeFunc get_size) { + ra_get_memory_data = get_data; + ra_get_memory_size = get_size; +} + +void RA_setMemoryMap(const void* mmap) { + // Free any existing memory map copy + if (ra_memory_map_descriptors) { + free(ra_memory_map_descriptors); + ra_memory_map_descriptors = NULL; + } + if (ra_memory_map) { + free(ra_memory_map); + ra_memory_map = NULL; + } + + if (!mmap) { + RA_LOG("Memory map cleared\n"); + return; + } + + // Deep copy the memory map since the core's data may be on the stack or freed later + const struct retro_memory_map* src = (const struct retro_memory_map*)mmap; + + if (src->num_descriptors == 0 || !src->descriptors) { + RA_LOG("Memory map has no descriptors\n"); + return; + } + + // Allocate our copy of the memory map structure + ra_memory_map = (struct retro_memory_map*)malloc(sizeof(struct retro_memory_map)); + if (!ra_memory_map) { + RA_LOG("Failed to allocate memory map\n"); + return; + } + + // Allocate and copy the descriptors array + size_t desc_size = src->num_descriptors * sizeof(struct retro_memory_descriptor); + ra_memory_map_descriptors = (struct retro_memory_descriptor*)malloc(desc_size); + if (!ra_memory_map_descriptors) { + free(ra_memory_map); + ra_memory_map = NULL; + RA_LOG("Failed to allocate memory map descriptors\n"); + return; + } + + memcpy(ra_memory_map_descriptors, src->descriptors, desc_size); + ra_memory_map->num_descriptors = src->num_descriptors; + ra_memory_map->descriptors = ra_memory_map_descriptors; + + RA_LOG("Memory map set by core: %u descriptors (deep copied)\n", ra_memory_map->num_descriptors); +} + +void RA_initMemoryRegions(uint32_t console_id) { + // Clean up any existing regions + if (ra_memory_regions_initialized) { + rc_libretro_memory_destroy(&ra_memory_regions); + ra_memory_regions_initialized = false; + } + + // Initialize memory regions based on console type and available memory info + memset(&ra_memory_regions, 0, sizeof(ra_memory_regions)); + + int result = rc_libretro_memory_init(&ra_memory_regions, ra_memory_map, + ra_get_core_memory_info, console_id); + + if (result) { + ra_memory_regions_initialized = true; + RA_LOG("Memory regions initialized: %u regions, %zu total bytes\n", + ra_memory_regions.count, ra_memory_regions.total_size); + } else { + RA_LOG("Warning: Failed to initialize memory regions for console %u\n", console_id); + } +} + +/***************************************************************************** + * Helper: Clear pending game data + *****************************************************************************/ +static void ra_clear_pending_game(void) { + if (pending_rom_data) { + free(pending_rom_data); + pending_rom_data = NULL; + } + pending_rom_size = 0; + pending_rom_path[0] = '\0'; + pending_emu_tag[0] = '\0'; + pending_game_load = false; +} + +/***************************************************************************** + * Helper: Check if a file extension indicates a CD image + *****************************************************************************/ +static int ra_is_cd_extension(const char* path) { + if (!path) return 0; + + const char* ext = strrchr(path, '.'); + if (!ext) return 0; + ext++; // skip the dot + + // Common CD image extensions + return (strcasecmp(ext, "chd") == 0 || + strcasecmp(ext, "cue") == 0 || + strcasecmp(ext, "ccd") == 0 || + strcasecmp(ext, "toc") == 0 || + strcasecmp(ext, "m3u") == 0); +} + +/***************************************************************************** + * Helper: Actually load the game (internal, assumes logged in) + *****************************************************************************/ +static void ra_do_load_game(const char* rom_path, const uint8_t* rom_data, size_t rom_size, const char* emu_tag) { + int console_id = RA_getConsoleId(emu_tag); + if (console_id == RC_CONSOLE_UNKNOWN) { + RA_LOG("Unknown console for tag '%s' - achievements disabled\n", emu_tag); + return; + } + + // Handle consoles that have separate CD variants + // PCE tag is used for both HuCard and CD games in NextUI + if (console_id == RC_CONSOLE_PC_ENGINE && ra_is_cd_extension(rom_path)) { + console_id = RC_CONSOLE_PC_ENGINE_CD; + RA_LOG("Detected PC Engine CD image, using console ID %d\n", console_id); + } + // MD tag is used for both cartridge and Sega CD games in NextUI + else if (console_id == RC_CONSOLE_MEGA_DRIVE && ra_is_cd_extension(rom_path)) { + console_id = RC_CONSOLE_SEGA_CD; + RA_LOG("Detected Sega CD image, using console ID %d\n", console_id); + } + + RA_LOG("Loading game: %s (console: %s, ID: %d)\n", + rom_path, rc_console_name(console_id), console_id); + + // Initialize memory regions for this console type BEFORE loading the game + // This ensures rcheevos can read memory correctly when checking achievements + RA_initMemoryRegions((uint32_t)console_id); + + // Use rc_client_begin_identify_and_load_game which hashes and identifies the ROM +#ifdef RC_CLIENT_SUPPORTS_HASH + rc_client_begin_identify_and_load_game(ra_client, console_id, + rom_path, rom_data, rom_size, + ra_game_loaded_callback, NULL); +#else + // Fallback for builds without hash support + RA_LOG("Hash support not compiled in - cannot identify game\n"); +#endif +} + +void RA_loadGame(const char* rom_path, const uint8_t* rom_data, size_t rom_size, const char* emu_tag) { + if (!ra_client || !CFG_getRAEnable()) { + return; + } + + // If not logged in yet, store the game info for deferred loading + if (!ra_logged_in) { + RA_LOG("Login in progress - deferring game load for: %s\n", rom_path); + + // Clear any previous pending game + ra_clear_pending_game(); + + // Store the path + strncpy(pending_rom_path, rom_path, RA_MAX_PATH - 1); + pending_rom_path[RA_MAX_PATH - 1] = '\0'; + + // Store the emu tag + strncpy(pending_emu_tag, emu_tag, sizeof(pending_emu_tag) - 1); + pending_emu_tag[sizeof(pending_emu_tag) - 1] = '\0'; + + // Copy ROM data if provided (some cores need it) + if (rom_data && rom_size > 0) { + pending_rom_data = (uint8_t*)malloc(rom_size); + if (pending_rom_data) { + memcpy(pending_rom_data, rom_data, rom_size); + pending_rom_size = rom_size; + } else { + RA_LOG("Warning: Failed to allocate memory for pending ROM data\n"); + pending_rom_size = 0; + } + } + + pending_game_load = true; + return; + } + + // Already logged in - load immediately + ra_do_load_game(rom_path, rom_data, rom_size, emu_tag); +} + +void RA_unloadGame(void) { + if (!ra_client) { + return; + } + + if (ra_game_loaded) { + RA_LOG("Unloading game\n"); + + // Save any pending muted achievements + ra_save_muted_achievements(); + ra_clear_muted_achievements(); + ra_game_hash[0] = '\0'; + + // Clear badge cache memory (keeps disk cache) + RA_Badges_clearMemory(); + + // Clean up memory regions for this game + if (ra_memory_regions_initialized) { + rc_libretro_memory_destroy(&ra_memory_regions); + ra_memory_regions_initialized = false; + } + + // Clear the memory map (will be set fresh when next game loads) + // Note: We don't free here - the core may still be loaded and the map + // will be needed if the same core loads another game. The memory is + // freed in RA_quit() or overwritten in RA_setMemoryMap(). + + rc_client_unload_game(ra_client); + ra_game_loaded = false; + } +} + +void RA_doFrame(void) { + // Process any pending HTTP responses before checking achievements + // This ensures game load completes and achievements are active + ra_process_queued_responses(); + + if (ra_client && ra_game_loaded) { + rc_client_do_frame(ra_client); + } +} + +void RA_idle(void) { + // Process queued HTTP responses on main thread + // This must happen even if ra_client is NULL (e.g., during shutdown) + // to avoid memory leaks from pending responses + ra_process_queued_responses(); + + if (!ra_client) { + return; + } + + // Check for pending login retry + if (ra_login_retry_pending && SDL_GetTicks() >= ra_login_retry_time) { + ra_login_retry_pending = false; + ra_start_login(); + } + + rc_client_idle(ra_client); + + // Process any responses that arrived during rc_client_idle() + // This ensures callbacks from login/game load complete promptly + ra_process_queued_responses(); +} + +bool RA_isGameLoaded(void) { + return ra_game_loaded; +} + +bool RA_isHardcoreModeActive(void) { + if (!ra_client || !ra_game_loaded) { + return false; + } + return rc_client_get_hardcore_enabled(ra_client) != 0; +} + +bool RA_isLoggedIn(void) { + return ra_logged_in; +} + +const char* RA_getUserDisplayName(void) { + if (!ra_client || !ra_logged_in) { + return NULL; + } + const rc_client_user_t* user = rc_client_get_user_info(ra_client); + return user ? user->display_name : NULL; +} + +const char* RA_getGameTitle(void) { + if (!ra_client || !ra_game_loaded) { + return NULL; + } + const rc_client_game_t* game = rc_client_get_game_info(ra_client); + return game ? game->title : NULL; +} + +void RA_getAchievementSummary(uint32_t* unlocked, uint32_t* total) { + if (!ra_client || !ra_game_loaded) { + if (unlocked) *unlocked = 0; + if (total) *total = 0; + return; + } + + // Get counts from the actual achievement list to ensure consistency + // between displayed count and what's shown in the achievements menu + rc_client_achievement_list_t* list = rc_client_create_achievement_list( + ra_client, RC_CLIENT_ACHIEVEMENT_CATEGORY_CORE, + RC_CLIENT_ACHIEVEMENT_LIST_GROUPING_LOCK_STATE); + + uint32_t unlocked_count = 0; + uint32_t total_count = 0; + + if (list) { + bool hide_unknown_emulator = !CFG_getRAHardcoreMode(); + + for (uint32_t b = 0; b < list->num_buckets; b++) { + for (uint32_t a = 0; a < list->buckets[b].num_achievements; a++) { + const rc_client_achievement_t* ach = list->buckets[b].achievements[a]; + // Skip "Unknown Emulator" warning when hardcore mode is disabled + if (hide_unknown_emulator && ach->id == 101000001) { + continue; + } + total_count++; + if (ach->unlocked) { + unlocked_count++; + } + } + } + rc_client_destroy_achievement_list(list); + } + + if (unlocked) *unlocked = unlocked_count; + if (total) *total = total_count; +} + +const void* RA_createAchievementList(int category, int grouping) { + if (!ra_client || !ra_game_loaded) { + return NULL; + } + return rc_client_create_achievement_list(ra_client, category, grouping); +} + +void RA_destroyAchievementList(const void* list) { + if (list) { + rc_client_destroy_achievement_list((rc_client_achievement_list_t*)list); + } +} + +const char* RA_getGameHash(void) { + if (!ra_game_loaded || ra_game_hash[0] == '\0') { + return NULL; + } + return ra_game_hash; +} + +bool RA_isAchievementMuted(uint32_t achievement_id) { + for (int i = 0; i < ra_muted_count; i++) { + if (ra_muted_achievements[i] == achievement_id) { + return true; + } + } + return false; +} + +bool RA_toggleAchievementMute(uint32_t achievement_id) { + if (RA_isAchievementMuted(achievement_id)) { + RA_setAchievementMuted(achievement_id, false); + return false; + } else { + RA_setAchievementMuted(achievement_id, true); + return true; + } +} + +void RA_setAchievementMuted(uint32_t achievement_id, bool muted) { + if (muted) { + // Add to muted list if not already there + if (!RA_isAchievementMuted(achievement_id)) { + if (ra_muted_count < RA_MAX_MUTED_ACHIEVEMENTS) { + ra_muted_achievements[ra_muted_count++] = achievement_id; + ra_muted_dirty = true; + RA_LOG("Achievement %u muted\n", achievement_id); + } else { + RA_LOG("Warning: Max muted achievements reached, cannot mute %u\n", achievement_id); + } + } + } else { + // Remove from muted list + for (int i = 0; i < ra_muted_count; i++) { + if (ra_muted_achievements[i] == achievement_id) { + // Shift remaining elements down + for (int j = i; j < ra_muted_count - 1; j++) { + ra_muted_achievements[j] = ra_muted_achievements[j + 1]; + } + ra_muted_count--; + ra_muted_dirty = true; + RA_LOG("Achievement %u unmuted\n", achievement_id); + break; + } + } + } +} diff --git a/workspace/all/minarch/ra_integration.h b/workspace/all/minarch/ra_integration.h new file mode 100644 index 000000000..83eaa94eb --- /dev/null +++ b/workspace/all/minarch/ra_integration.h @@ -0,0 +1,176 @@ +#ifndef __RA_INTEGRATION_H__ +#define __RA_INTEGRATION_H__ + +#include +#include +#include + +/** + * RetroAchievements Integration for minarch + * + * This module provides the glue between minarch (libretro frontend) and + * rcheevos (RetroAchievements library). It handles: + * - rc_client initialization and lifecycle + * - Memory read callbacks for achievement checking + * - HTTP server callbacks for API communication + * - Event handling for achievement unlocks/notifications + */ + +/** + * Initialize the RetroAchievements client. + * Should be called once at startup, after config is loaded. + * Does nothing if RA is disabled in settings. + */ +void RA_init(void); + +/** + * Shut down the RetroAchievements client. + * Should be called at shutdown before exiting. + */ +void RA_quit(void); + +/** + * Load a game for achievement tracking. + * Should be called after a game ROM is loaded and the core is initialized. + * + * @param rom_path Path to the ROM file + * @param rom_data Pointer to ROM data in memory (can be NULL if core loads from file) + * @param rom_size Size of ROM data in bytes + * @param emu_tag The emulator tag (e.g., "GB", "SFC", "PS") for console identification + */ +void RA_loadGame(const char* rom_path, const uint8_t* rom_data, size_t rom_size, const char* emu_tag); + +/** + * Unload the current game from achievement tracking. + * Should be called when a game is closed/unloaded. + */ +void RA_unloadGame(void); + +/** + * Process achievements for the current frame. + * Should be called once per frame after core.run() completes. + */ +void RA_doFrame(void); + +/** + * Process the periodic queue (for async operations). + * Should be called when emulation is paused but we still want to process + * server responses and other async operations. + */ +void RA_idle(void); + +/** + * Check if a game is currently loaded and being tracked. + * @return true if a game is loaded and RA is active + */ +bool RA_isGameLoaded(void); + +/** + * Check if hardcore mode is currently active. + * Use this to block save states, cheats, etc. + * @return true if hardcore mode is active + */ +bool RA_isHardcoreModeActive(void); + +/** + * Check if the user is logged in. + * @return true if logged in + */ +bool RA_isLoggedIn(void); + +/** + * Get the current user's display name. + * @return Display name, or NULL if not logged in + */ +const char* RA_getUserDisplayName(void); + +/** + * Get the current game's title from RA database. + * @return Game title, or NULL if no game loaded + */ +const char* RA_getGameTitle(void); + +/** + * Get achievement summary for current game. + * @param unlocked Output: number of achievements unlocked + * @param total Output: total number of achievements + */ +void RA_getAchievementSummary(uint32_t* unlocked, uint32_t* total); + +/** + * Get the achievement list for the current game. + * @param category Achievement category (RC_CLIENT_ACHIEVEMENT_CATEGORY_CORE, etc.) + * @param grouping List grouping (RC_CLIENT_ACHIEVEMENT_LIST_GROUPING_LOCK_STATE, etc.) + * @return Allocated achievement list, or NULL if no game loaded. Must be freed with RA_destroyAchievementList. + */ +const void* RA_createAchievementList(int category, int grouping); + +/** + * Destroy an achievement list created by RA_createAchievementList. + * @param list The list to destroy + */ +void RA_destroyAchievementList(const void* list); + +/** + * Get the current game's hash (for mute file storage). + * @return Game hash string, or NULL if no game loaded + */ +const char* RA_getGameHash(void); + +/** + * Check if an achievement is muted. + * @param achievement_id The achievement ID to check + * @return true if muted + */ +bool RA_isAchievementMuted(uint32_t achievement_id); + +/** + * Toggle the mute state of an achievement. + * @param achievement_id The achievement ID to toggle + * @return New mute state (true = muted) + */ +bool RA_toggleAchievementMute(uint32_t achievement_id); + +/** + * Set the mute state of an achievement. + * @param achievement_id The achievement ID to set + * @param muted Whether to mute (true) or unmute (false) + */ +void RA_setAchievementMuted(uint32_t achievement_id, bool muted); + +/** + * Typedef for memory read function pointer. + * This allows minarch to provide its memory access function. + */ +typedef void* (*RA_GetMemoryFunc)(unsigned id); +typedef size_t (*RA_GetMemorySizeFunc)(unsigned id); + +/** + * Set the memory access functions. + * These should point to the libretro core's memory access functions. + * Must be called before RA_loadGame(). + * + * @param get_data Function to get memory pointer (core.get_memory_data) + * @param get_size Function to get memory size (core.get_memory_size) + */ +void RA_setMemoryAccessors(RA_GetMemoryFunc get_data, RA_GetMemorySizeFunc get_size); + +/** + * Set the memory map from the libretro core. + * Some cores (e.g., NES, SNES) use RETRO_ENVIRONMENT_SET_MEMORY_MAPS + * instead of simple retro_get_memory_data/size calls. + * + * @param mmap Pointer to the retro_memory_map structure (can be NULL to clear) + */ +void RA_setMemoryMap(const void* mmap); + +/** + * Initialize memory regions for achievement checking. + * Should be called after a game is loaded and the console ID is known. + * This uses rc_libretro to properly map memory based on the console type. + * + * @param console_id The rcheevos console ID for the loaded game + */ +void RA_initMemoryRegions(uint32_t console_id); + +#endif // __RA_INTEGRATION_H__ diff --git a/workspace/all/rcheevos/makefile b/workspace/all/rcheevos/makefile new file mode 100644 index 000000000..36513ca9a --- /dev/null +++ b/workspace/all/rcheevos/makefile @@ -0,0 +1,126 @@ +########################################################### +# rcheevos static library build for NextUI +########################################################### + +ifeq (,$(PLATFORM)) +PLATFORM=$(UNION_PLATFORM) +endif + +ifeq (,$(PLATFORM)) + $(error please specify PLATFORM, eg. PLATFORM=tg5040 make) +endif + +# Desktop builds use native compiler +ifneq ($(PLATFORM), desktop) +ifeq (,$(CROSS_COMPILE)) + $(error missing CROSS_COMPILE for this toolchain) +endif + +ifeq (,$(PREFIX)) + $(error missing PREFIX for this toolchain) +endif +endif + +########################################################### + +include ../../$(PLATFORM)/platform/makefile.env + +########################################################### + +TARGET = rcheevos +SRCDIR = src/src +INCDIR = src/include +OBJDIR = build/$(PLATFORM)/obj + +ifeq ($(PLATFORM), desktop) +CC = gcc +AR = ar +else +CC = $(CROSS_COMPILE)gcc +AR = $(CROSS_COMPILE)ar +endif + +# rcheevos source files +SOURCES = \ + $(SRCDIR)/rc_client.c \ + $(SRCDIR)/rc_compat.c \ + $(SRCDIR)/rc_util.c \ + $(SRCDIR)/rc_version.c \ + $(SRCDIR)/rc_libretro.c \ + $(SRCDIR)/rcheevos/alloc.c \ + $(SRCDIR)/rcheevos/condition.c \ + $(SRCDIR)/rcheevos/condset.c \ + $(SRCDIR)/rcheevos/consoleinfo.c \ + $(SRCDIR)/rcheevos/format.c \ + $(SRCDIR)/rcheevos/lboard.c \ + $(SRCDIR)/rcheevos/memref.c \ + $(SRCDIR)/rcheevos/operand.c \ + $(SRCDIR)/rcheevos/richpresence.c \ + $(SRCDIR)/rcheevos/runtime.c \ + $(SRCDIR)/rcheevos/runtime_progress.c \ + $(SRCDIR)/rcheevos/trigger.c \ + $(SRCDIR)/rcheevos/value.c \ + $(SRCDIR)/rcheevos/rc_validate.c \ + $(SRCDIR)/rapi/rc_api_common.c \ + $(SRCDIR)/rapi/rc_api_editor.c \ + $(SRCDIR)/rapi/rc_api_info.c \ + $(SRCDIR)/rapi/rc_api_runtime.c \ + $(SRCDIR)/rapi/rc_api_user.c \ + $(SRCDIR)/rhash/aes.c \ + $(SRCDIR)/rhash/cdreader.c \ + $(SRCDIR)/rhash/hash.c \ + $(SRCDIR)/rhash/hash_disc.c \ + $(SRCDIR)/rhash/hash_encrypted.c \ + $(SRCDIR)/rhash/hash_rom.c \ + $(SRCDIR)/rhash/hash_zip.c \ + $(SRCDIR)/rhash/md5.c + +# Object files go in platform-specific directory +OBJECTS = $(patsubst $(SRCDIR)/%.c,$(OBJDIR)/%.o,$(SOURCES)) + +CFLAGS += $(OPT) -fomit-frame-pointer +CFLAGS += -I$(INCDIR) -I$(SRCDIR) -std=gnu99 +# libretro.h is in minarch/libretro-common/include +CFLAGS += -I../minarch/libretro-common/include +CFLAGS += -DRC_DISABLE_LUA +CFLAGS += -DRC_CLIENT_SUPPORTS_HASH +CFLAGS += -fPIC + +PRODUCT = build/$(PLATFORM)/lib$(TARGET).a + +# rcheevos version to use (can be overridden) +RCHEEVOS_VERSION ?= 40d916de00fe757bab40fb4db41a7912193a48e3 + +.PHONY: build clean install clone + +# Clone rcheevos source if not present +clone: + @if [ ! -f "src/include/rc_client.h" ]; then \ + echo "Cloning rcheevos..."; \ + rm -rf src; \ + git clone https://github.com/RetroAchievements/rcheevos.git src; \ + cd src && git checkout $(RCHEEVOS_VERSION); \ + fi + +# Build target: first clone, then compile (recursive make ensures sources exist) +build: clone + $(MAKE) $(PRODUCT) PLATFORM=$(PLATFORM) + +$(PRODUCT): $(OBJECTS) + mkdir -p build/$(PLATFORM) + $(AR) rcs $@ $(OBJECTS) + +$(OBJDIR)/%.o: $(SRCDIR)/%.c + mkdir -p $(dir $@) + $(CC) $(CFLAGS) -c -o $@ $< + +install: $(PRODUCT) + mkdir -p "$(PREFIX_LOCAL)/include/rcheevos" + mkdir -p "$(PREFIX_LOCAL)/lib" + cp $(INCDIR)/*.h "$(PREFIX_LOCAL)/include/rcheevos/" + cp $(PRODUCT) "$(PREFIX_LOCAL)/lib/" + +clean: + rm -rf build + rm -f $(PREFIX_LOCAL)/lib/lib$(TARGET).a + rm -rf $(PREFIX_LOCAL)/include/rcheevos diff --git a/workspace/all/settings/keyboardprompt.hpp b/workspace/all/settings/keyboardprompt.hpp index e1f68a7e1..c70beda5d 100644 --- a/workspace/all/settings/keyboardprompt.hpp +++ b/workspace/all/settings/keyboardprompt.hpp @@ -73,6 +73,12 @@ class KeyboardPrompt : public MenuList InputReactionHint handleInput(int &dirty, int &quit) override; + // Set the initial text to display in the keyboard input field + void setInitialText(const std::string &text) { + state.keyboard.initial_text = text; + state.keyboard.current_text = text; + } + private: // handle_keyboard_input interprets keyboard input events and mutates app state void handleKeyboardInput(AppState &state); diff --git a/workspace/all/settings/makefile b/workspace/all/settings/makefile index 30201f6df..32cbbce79 100644 --- a/workspace/all/settings/makefile +++ b/workspace/all/settings/makefile @@ -21,9 +21,9 @@ SDL?=SDL TARGET = settings INCDIR = -I. -I../common/ -I../../$(PLATFORM)/platform/ -SOURCE = -c ../common/utils.c ../common/api.c ../common/config.c ../common/scaler.c ../../$(PLATFORM)/platform/platform.c +SOURCE = -c ../common/utils.c ../common/api.c ../common/config.c ../common/scaler.c ../common/http.c ../common/ra_auth.c ../../$(PLATFORM)/platform/platform.c CXXSOURCE = $(TARGET).cpp menu.cpp wifimenu.cpp btmenu.cpp keyboardprompt.cpp -CXXSOURCE += build/$(PLATFORM)/utils.o build/$(PLATFORM)/api.o build/$(PLATFORM)/config.o build/$(PLATFORM)/scaler.o build/$(PLATFORM)/platform.o +CXXSOURCE += build/$(PLATFORM)/utils.o build/$(PLATFORM)/api.o build/$(PLATFORM)/config.o build/$(PLATFORM)/scaler.o build/$(PLATFORM)/http.o build/$(PLATFORM)/ra_auth.o build/$(PLATFORM)/platform.o CC = $(CROSS_COMPILE)gcc CXX = $(CROSS_COMPILE)g++ @@ -50,7 +50,7 @@ PRODUCT= build/$(PLATFORM)/$(TARGET).elf all: $(PREFIX_LOCAL)/include/msettings.h mkdir -p build/$(PLATFORM) $(CC) $(SOURCE) $(CFLAGS) $(LDFLAGS) - mv utils.o api.o config.o scaler.o platform.o build/$(PLATFORM) + mv utils.o api.o config.o scaler.o http.o ra_auth.o platform.o build/$(PLATFORM) $(CXX) $(CXXSOURCE) -o $(PRODUCT) $(CXXFLAGS) $(LDFLAGS) -lstdc++ clean: rm -f $(PRODUCT) diff --git a/workspace/all/settings/menu.cpp b/workspace/all/settings/menu.cpp index 1ff349887..880165ff1 100644 --- a/workspace/all/settings/menu.cpp +++ b/workspace/all/settings/menu.cpp @@ -264,6 +264,32 @@ bool MenuItem::prev(int n) /////////////////////////////////////////////////////////// +InputReactionHint TextInputMenuItem::handleInput(int &dirty) { + // Handle deferred state - route input to keyboard submenu + if (deferred) { + assert(submenu); + int subMenuJustClosed = 0; + auto hint = submenu->handleInput(dirty, subMenuJustClosed); + if (subMenuJustClosed) { + defer(false); + dirty = 1; + // Don't propagate Exit up - just stay on current menu + return NoOp; + } + return hint; + } + + // Open keyboard when A is pressed + if (PAD_justPressed(BTN_A)) { + if (on_confirm) { + return on_confirm(*this); + } + } + return Unhandled; +} + +/////////////////////////////////////////////////////////// + MenuList::MenuList(MenuItemType type, const std::string &descp, std::vector items, MenuListCallback on_change, MenuListCallback on_confirm) : type(type), desc(descp), items(items), on_change(on_change), on_confirm(on_confirm) { diff --git a/workspace/all/settings/menu.hpp b/workspace/all/settings/menu.hpp index ed4c2fbd9..7e433b4f3 100644 --- a/workspace/all/settings/menu.hpp +++ b/workspace/all/settings/menu.hpp @@ -255,6 +255,37 @@ class MenuItem : public AbstractMenuItem const std::vector getLabels() const override { return labels; } }; +// A menu item for text input that shows the current value and opens a keyboard when pressed. +// The label getter is called each time to get the current display value. +class TextInputMenuItem : public AbstractMenuItem +{ + MenuList* keyboardSubmenu; + +public: + TextInputMenuItem(const std::string &name, const std::string &desc, + ValueGetCallback labelGetter, MenuListCallback on_confirm, + MenuList *submenu) + : AbstractMenuItem(ListItemType::Generic, name, desc, labelGetter, nullptr, nullptr, on_confirm, submenu), + keyboardSubmenu(submenu) {} + + const std::any getValue() const override { + // Return a dummy value so the rendering system shows the label + return std::string(""); + } + + const std::string getLabel() const override { + if (on_get) { + auto val = on_get(); + if (val.has_value()) { + return std::any_cast(val); + } + } + return ""; + } + + InputReactionHint handleInput(int &dirty) override; +}; + class MenuList { protected: diff --git a/workspace/all/settings/settings.cpp b/workspace/all/settings/settings.cpp index 441216610..ba7bc5455 100644 --- a/workspace/all/settings/settings.cpp +++ b/workspace/all/settings/settings.cpp @@ -5,6 +5,7 @@ extern "C" #include "defines.h" #include "api.h" #include "utils.h" +#include "ra_auth.h" } #include @@ -79,6 +80,42 @@ static const std::vector on_off = {"Off", "On"}; static const std::vector scaling_strings = {"Fullscreen", "Fit", "Fill"}; static const std::vector scaling = {(int)GFX_SCALE_FULLSCREEN, (int)GFX_SCALE_FIT, (int)GFX_SCALE_FILL}; +// Notification duration options (in seconds) +static const std::vector notify_duration_values = {1, 2, 3, 4, 5}; +static const std::vector notify_duration_labels = {"1s", "2s", "3s", "4s", "5s"}; + +// Progress notification duration options (in seconds, 0 = disabled) +static const std::vector progress_duration_values = {0, 1, 2, 3, 4, 5}; +static const std::vector progress_duration_labels = {"Off", "1s", "2s", "3s", "4s", "5s"}; + +// RetroAchievements sort order options +static const std::vector ra_sort_values = { + (int)RA_SORT_UNLOCKED_FIRST, + (int)RA_SORT_DISPLAY_ORDER_FIRST, + (int)RA_SORT_DISPLAY_ORDER_LAST, + (int)RA_SORT_WON_BY_MOST, + (int)RA_SORT_WON_BY_LEAST, + (int)RA_SORT_POINTS_MOST, + (int)RA_SORT_POINTS_LEAST, + (int)RA_SORT_TITLE_AZ, + (int)RA_SORT_TITLE_ZA, + (int)RA_SORT_TYPE_ASC, + (int)RA_SORT_TYPE_DESC +}; +static const std::vector ra_sort_labels = { + "Unlocked First", + "Display Order (First)", + "Display Order (Last)", + "Won By (Most)", + "Won By (Least)", + "Points (Most)", + "Points (Least)", + "Title (A-Z)", + "Title (Z-A)", + "Type (Asc)", + "Type (Desc)" +}; + namespace { std::string execCommand(const char* cmd) { std::array buffer; @@ -475,6 +512,132 @@ int main(int argc, char *argv[]) // TODO: check BT_supported(), hide menu otherwise auto btMenu = new Bluetooth::Menu(appQuit, ctx.dirty); + auto notificationsMenu = new MenuList(MenuItemType::Fixed, "Notifications", + { + new MenuItem{ListItemType::Generic, "Save states", "Show notification when saving game state", {false, true}, on_off, + []() -> std::any { return CFG_getNotifyManualSave(); }, + [](const std::any &value) { CFG_setNotifyManualSave(std::any_cast(value)); }, + []() { CFG_setNotifyManualSave(CFG_DEFAULT_NOTIFY_MANUAL_SAVE);}}, + new MenuItem{ListItemType::Generic, "Load states", "Show notification when loading game state", {false, true}, on_off, + []() -> std::any { return CFG_getNotifyLoad(); }, + [](const std::any &value) { CFG_setNotifyLoad(std::any_cast(value)); }, + []() { CFG_setNotifyLoad(CFG_DEFAULT_NOTIFY_LOAD);}}, + new MenuItem{ListItemType::Generic, "Screenshots", "Show notification when taking a screenshot", {false, true}, on_off, + []() -> std::any { return CFG_getNotifyScreenshot(); }, + [](const std::any &value) { CFG_setNotifyScreenshot(std::any_cast(value)); }, + []() { CFG_setNotifyScreenshot(CFG_DEFAULT_NOTIFY_SCREENSHOT);}}, + new MenuItem{ListItemType::Generic, "Vol / Display Adjustments", "Show overlay for volume, brightness,\nand color temp adjustments", {false, true}, on_off, + []() -> std::any { return CFG_getNotifyAdjustments(); }, + [](const std::any &value) { CFG_setNotifyAdjustments(std::any_cast(value)); }, + []() { CFG_setNotifyAdjustments(CFG_DEFAULT_NOTIFY_ADJUSTMENTS);}}, + new MenuItem{ListItemType::Generic, "Duration", "How long notifications stay on screen", notify_duration_values, notify_duration_labels, + []() -> std::any { return CFG_getNotifyDuration(); }, + [](const std::any &value) { CFG_setNotifyDuration(std::any_cast(value)); }, + []() { CFG_setNotifyDuration(CFG_DEFAULT_NOTIFY_DURATION);}}, + new MenuItem{ListItemType::Button, "Reset to defaults", "Resets all options in this menu to their default values.", ResetCurrentMenu}, + }); + + // RetroAchievements keyboard prompts + auto raUsernamePrompt = new KeyboardPrompt("Enter Username", [](AbstractMenuItem &item) -> InputReactionHint { + CFG_setRAUsername(item.getName().c_str()); + return Exit; + }); + + auto raPasswordPrompt = new KeyboardPrompt("Enter Password", [](AbstractMenuItem &item) -> InputReactionHint { + CFG_setRAPassword(item.getName().c_str()); + return Exit; + }); + + auto retroAchievementsMenu = new MenuList(MenuItemType::Fixed, "RetroAchievements", + { + new MenuItem{ListItemType::Generic, "Enable Achievements", "Enable RetroAchievements integration", {false, true}, on_off, + []() -> std::any { return CFG_getRAEnable(); }, + [](const std::any &value) { CFG_setRAEnable(std::any_cast(value)); }, + []() { CFG_setRAEnable(CFG_DEFAULT_RA_ENABLE);}}, + new TextInputMenuItem{"Username", "RetroAchievements username", + []() -> std::any { + std::string username = CFG_getRAUsername(); + return username.empty() ? std::string("(not set)") : username; + }, + [raUsernamePrompt](AbstractMenuItem &item) -> InputReactionHint { + raUsernamePrompt->setInitialText(CFG_getRAUsername()); + item.defer(true); + return NoOp; + }, raUsernamePrompt}, + new TextInputMenuItem{"Password", "RetroAchievements password", + []() -> std::any { + std::string password = CFG_getRAPassword(); + return password.empty() ? std::string("(not set)") : std::string("********"); + }, + [raPasswordPrompt](AbstractMenuItem &item) -> InputReactionHint { + raPasswordPrompt->setInitialText(CFG_getRAPassword()); + item.defer(true); + return NoOp; + }, raPasswordPrompt}, + new MenuItem{ListItemType::Button, "Authenticate", "Test credentials and retrieve API token", + [](AbstractMenuItem &item) -> InputReactionHint { + const char* username = CFG_getRAUsername(); + const char* password = CFG_getRAPassword(); + + if (!username || strlen(username) == 0 || !password || strlen(password) == 0) { + item.setDesc("Error: Username and password required"); + return NoOp; + } + + item.setDesc("Authenticating..."); + + RA_AuthResponse response; + RA_AuthResult result = RA_authenticateSync(username, password, &response); + + if (result == RA_AUTH_SUCCESS) { + CFG_setRAToken(response.token); + CFG_setRAAuthenticated(true); + std::string desc = "Authenticated as " + std::string(response.display_name); + item.setDesc(desc); + } else { + CFG_setRAToken(""); + CFG_setRAAuthenticated(false); + std::string desc = "Error: " + std::string(response.error_message); + item.setDesc(desc); + } + return NoOp; + }}, + new StaticMenuItem{ListItemType::Generic, "Status", "Authentication status", + []() -> std::any { + if (CFG_getRAAuthenticated() && strlen(CFG_getRAToken()) > 0) { + return std::string("Authenticated"); + } + return std::string("Not authenticated"); + }}, + new MenuItem{ListItemType::Generic, "Hardcore Mode", "Disable save states and cheats for achievements", {false, true}, on_off, + []() -> std::any { return CFG_getRAHardcoreMode(); }, + [](const std::any &value) { CFG_setRAHardcoreMode(std::any_cast(value)); }, + []() { CFG_setRAHardcoreMode(CFG_DEFAULT_RA_HARDCOREMODE);}}, + new MenuItem{ListItemType::Generic, "Show Notifications", "Show achievement unlock notifications", {false, true}, on_off, + []() -> std::any { return CFG_getRAShowNotifications(); }, + [](const std::any &value) { CFG_setRAShowNotifications(std::any_cast(value)); }, + []() { CFG_setRAShowNotifications(CFG_DEFAULT_RA_SHOW_NOTIFICATIONS);}}, + new MenuItem{ListItemType::Generic, "Notification Duration", "How long achievement notifications stay on screen", notify_duration_values, notify_duration_labels, + []() -> std::any { return CFG_getRANotificationDuration(); }, + [](const std::any &value) { CFG_setRANotificationDuration(std::any_cast(value)); }, + []() { CFG_setRANotificationDuration(CFG_DEFAULT_RA_NOTIFICATION_DURATION);}}, + new MenuItem{ListItemType::Generic, "Progress Duration", "Duration for progress updates (top-left). Off to disable.", progress_duration_values, progress_duration_labels, + []() -> std::any { return CFG_getRAProgressNotificationDuration(); }, + [](const std::any &value) { CFG_setRAProgressNotificationDuration(std::any_cast(value)); }, + []() { CFG_setRAProgressNotificationDuration(CFG_DEFAULT_RA_PROGRESS_NOTIFICATION_DURATION);}}, + new MenuItem{ListItemType::Generic, "Achievement Sort Order", "How achievements are sorted in the in-game menu", ra_sort_values, ra_sort_labels, + []() -> std::any { return CFG_getRAAchievementSortOrder(); }, + [](const std::any &value) { CFG_setRAAchievementSortOrder(std::any_cast(value)); }, + []() { CFG_setRAAchievementSortOrder(CFG_DEFAULT_RA_ACHIEVEMENT_SORT_ORDER);}}, + new MenuItem{ListItemType::Button, "Reset to defaults", "Resets all options in this menu to their default values.", ResetCurrentMenu}, + }); + + auto minarchMenu = new MenuList(MenuItemType::List, "In-Game", + { + new MenuItem{ListItemType::Generic, "Notifications", "Save state notifications", {}, {}, nullptr, nullptr, DeferToSubmenu, notificationsMenu}, + new MenuItem{ListItemType::Generic, "RetroAchievements", "Achievement tracking settings", {}, {}, nullptr, nullptr, DeferToSubmenu, retroAchievementsMenu}, + }); + auto aboutMenu = new MenuList(MenuItemType::Fixed, "About", { new StaticMenuItem{ListItemType::Generic, "NextUI version", "", @@ -511,6 +674,7 @@ int main(int argc, char *argv[]) new MenuItem{ListItemType::Generic, "Display", "", {}, {}, nullptr, nullptr, DeferToSubmenu, displayMenu}, new MenuItem{ListItemType::Generic, "System", "", {}, {}, nullptr, nullptr, DeferToSubmenu, systemMenu}, new MenuItem{ListItemType::Generic, "FN switch", "FN switch settings", {}, {}, nullptr, nullptr, DeferToSubmenu, muteMenu}, + new MenuItem{ListItemType::Generic, "In-Game", "In-game settings for MinArch", {}, {}, nullptr, nullptr, DeferToSubmenu, minarchMenu}, new MenuItem{ListItemType::Generic, "Network", "", {}, {}, nullptr, nullptr, DeferToSubmenu, networkMenu}, new MenuItem{ListItemType::Generic, "Bluetooth", "", {}, {}, nullptr, nullptr, DeferToSubmenu, btMenu}, new MenuItem{ListItemType::Generic, "About", "", {}, {}, nullptr, nullptr, DeferToSubmenu, aboutMenu}, diff --git a/workspace/desktop/libmsettings/msettings.c b/workspace/desktop/libmsettings/msettings.c index 32d5e340a..d5d0fa039 100644 --- a/workspace/desktop/libmsettings/msettings.c +++ b/workspace/desktop/libmsettings/msettings.c @@ -345,12 +345,28 @@ int InitializedSettings(void){ // not implemented here -int GetBrightness(void) { return 0; } -int GetColortemp(void) { return 0; } +// Desktop test helpers - read values from /tmp/desktop_settings_* files +static int read_setting_file(const char* name, int default_value) { + char path[256]; + snprintf(path, sizeof(path), "/tmp/desktop_settings_%s", name); + FILE* f = fopen(path, "r"); + if (f) { + int value; + if (fscanf(f, "%d", &value) == 1) { + fclose(f); + return value; + } + fclose(f); + } + return default_value; +} + +int GetBrightness(void) { return read_setting_file("brightness", 5); } +int GetColortemp(void) { return read_setting_file("colortemp", 10); } int GetContrast(void) { return 0; } int GetSaturation(void) { return 0; } int GetExposure(void) { return 0; } -int GetVolume(void) { return 0; } +int GetVolume(void) { return read_setting_file("volume", 10); } int GetMutedBrightness(void) { return 0; } int GetMutedColortemp(void) { return 0; } From c7129b39147842947455b52e39e8196390e93dd6 Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Tue, 3 Feb 2026 18:13:57 -0500 Subject: [PATCH 02/26] In CI, disable debug symbols to avoid dsymutil memory issues on macOS --- workspace/desktop/platform/makefile.env | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/workspace/desktop/platform/makefile.env b/workspace/desktop/platform/makefile.env index 7a5205f73..ce3ed79b2 100644 --- a/workspace/desktop/platform/makefile.env +++ b/workspace/desktop/platform/makefile.env @@ -1,5 +1,10 @@ # desktop +# In CI, disable debug symbols to avoid dsymutil memory issues on macOS +ifdef CI +OPT = +else OPT = -g +endif CFLAGS = -flto=auto SDL = SDL2 GL = GL From 46573b96d9833c001faa68c84a275606971068ad Mon Sep 17 00:00:00 2001 From: Clint Beacock Date: Wed, 4 Feb 2026 09:39:14 -0500 Subject: [PATCH 03/26] Update makefile Co-authored-by: frysee --- makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/makefile b/makefile index 7a01c92d0..ece9abea8 100644 --- a/makefile +++ b/makefile @@ -18,7 +18,7 @@ endif ########################################################### BUILD_HASH:=$(shell git rev-parse --short HEAD) -BUILD_BRANCH:=$(shell git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --short HEAD) +BUILD_BRANCH:=$(shell (git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --short HEAD) | sed 's/\//-/g') RELEASE_TIME:=$(shell TZ=GMT date +%Y%m%d) ifeq ($(BUILD_BRANCH),main) RELEASE_BETA := From de6c2ab2f83ee9c3cb68075598fec3c04bed114d Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Thu, 5 Feb 2026 11:42:37 -0500 Subject: [PATCH 04/26] Fix notification artifacts persisting after dismissal When notifications end, trigger 3 frames of glClear() to fully clear the framebuffer including areas outside the game's viewport. --- workspace/all/common/generic_video.c | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/workspace/all/common/generic_video.c b/workspace/all/common/generic_video.c index eb5a1490a..c84edcb76 100644 --- a/workspace/all/common/generic_video.c +++ b/workspace/all/common/generic_video.c @@ -116,6 +116,7 @@ static int notification_y = 0; static int notification_dirty = 0; static GLuint notification_tex = 0; static int notif_tex_w = 0, notif_tex_h = 0; +static int notification_clear_frames = 0; // Frames to clear framebuffer after notification ends void PLAT_setNotificationSurface(SDL_Surface* surface, int x, int y) { notification_surface = surface; @@ -126,7 +127,8 @@ void PLAT_setNotificationSurface(SDL_Surface* surface, int x, int y) { void PLAT_clearNotificationSurface(void) { notification_surface = NULL; - notification_dirty = 1; + notification_dirty = 0; // Nothing to update since surface is NULL + notification_clear_frames = 3; // Clear for 3 frames (triple buffering safety) } @@ -1950,8 +1952,10 @@ void PLAT_GL_Swap() { static int lastframecount = 0; if (reloadShaderTextures) lastframecount = frame_count; - if (frame_count < lastframecount + 3) + if (frame_count < lastframecount + 3 || notification_clear_frames > 0) { glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + if (notification_clear_frames > 0) notification_clear_frames--; + } SDL_Rect dst_rect = {0, 0, device_width, device_height}; setRectToAspectRatio(&dst_rect); From 81297e3be8b48c393e3c6c0c6e1629dc1fdf1cbf Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Thu, 5 Feb 2026 12:22:16 -0500 Subject: [PATCH 05/26] Address PR #633 feedback: API cleanup and code improvements GFX API refinements: - Remove center_x param from GFX_blitWrappedText (calculates internally) - Rename screen to surface for clarity - Add IndicatorType enum (BRIGHTNESS, VOLUME, COLORTEMP) - Use GFX_blitPillLight instead of GFX_blitPillColor - Use SDL_SWSURFACE instead of 0 in GFX_createScreenFormatSurface RetroAchievements cleanup: - Refactor ra_consoles.h to use lookup table instead of if/strcmp chain - Replace RA_LOG with leveled macros (DEBUG, INFO, WARN, ERROR) mapping to NextUI's native LOG_* functions Video/Desktop fixes: - Add framebuffer clearing for 3 frames when notifications end - Simplify desktop msettings.c with hardcoded defaults --- workspace/all/common/api.c | 26 ++-- workspace/all/common/api.h | 12 +- workspace/all/common/notification.c | 2 +- workspace/all/minarch/makefile | 2 +- workspace/all/minarch/minarch.c | 4 +- workspace/all/minarch/ra_consoles.h | 150 +++++++++++++-------- workspace/all/minarch/ra_integration.c | 131 +++++++++--------- workspace/desktop/libmsettings/msettings.c | 24 +--- 8 files changed, 192 insertions(+), 159 deletions(-) diff --git a/workspace/all/common/api.c b/workspace/all/common/api.c index 848fcb973..2c3b06636 100644 --- a/workspace/all/common/api.c +++ b/workspace/all/common/api.c @@ -923,11 +923,13 @@ int GFX_wrapText(TTF_Font *font, char *str, int max_width, int max_lines) return max_line_width; } -int GFX_blitWrappedText(TTF_Font *font, const char *text, int max_width, int max_lines, SDL_Color color, SDL_Surface *screen, int center_x, int y) +int GFX_blitWrappedText(TTF_Font *font, const char *text, int max_width, int max_lines, SDL_Color color, SDL_Surface *surface, int y) { if (!text || !text[0]) return y; + int center_x = surface->w / 2; + char *text_copy = strdup(text); if (!text_copy) return y; @@ -962,7 +964,7 @@ int GFX_blitWrappedText(TTF_Font *font, const char *text, int max_width, int max // Render line and continue to next SDL_Surface *line_surface = TTF_RenderUTF8_Blended(font, line, color); if (line_surface) { - SDL_BlitSurface(line_surface, NULL, screen, &(SDL_Rect){ + SDL_BlitSurface(line_surface, NULL, surface, &(SDL_Rect){ center_x - line_surface->w / 2, y }); y += line_surface->h; @@ -976,7 +978,7 @@ int GFX_blitWrappedText(TTF_Font *font, const char *text, int max_width, int max snprintf(truncated, sizeof(truncated), "%s...", line); SDL_Surface *line_surface = TTF_RenderUTF8_Blended(font, truncated, color); if (line_surface) { - SDL_BlitSurface(line_surface, NULL, screen, &(SDL_Rect){ + SDL_BlitSurface(line_surface, NULL, surface, &(SDL_Rect){ center_x - line_surface->w / 2, y }); y += line_surface->h; @@ -994,7 +996,7 @@ int GFX_blitWrappedText(TTF_Font *font, const char *text, int max_width, int max if (line[0] != '\0') { SDL_Surface *line_surface = TTF_RenderUTF8_Blended(font, line, color); if (line_surface) { - SDL_BlitSurface(line_surface, NULL, screen, &(SDL_Rect){ + SDL_BlitSurface(line_surface, NULL, surface, &(SDL_Rect){ center_x - line_surface->w / 2, y }); y += line_surface->h; @@ -1870,8 +1872,7 @@ void GFX_blitBatteryAtPosition(SDL_Surface *dst, int x, int y) // Helper function to render a hardware indicator (volume/brightness/colortemp) at a specific position. // This is the reusable core extracted from GFX_blitHardwareGroup for use in notifications. -// indicator_type values: 1=brightness, 2=volume, 3=colortemp (matches show_setting from PWR_update) -int GFX_blitHardwareIndicator(SDL_Surface *dst, int x, int y, int indicator_type) +int GFX_blitHardwareIndicator(SDL_Surface *dst, int x, int y, IndicatorType indicator_type) { int setting_value; int setting_min; @@ -1883,25 +1884,24 @@ int GFX_blitHardwareIndicator(SDL_Surface *dst, int x, int y, int indicator_type int oy = y; // Draw the pill background - GFX_blitPillColor(ASSET_WHITE_PILL, dst, &(SDL_Rect){ox, oy, ow, SCALE1(PILL_SIZE)}, THEME_COLOR2, RGB_WHITE); + GFX_blitPillLight(ASSET_WHITE_PILL, dst, &(SDL_Rect){ox, oy, ow, SCALE1(PILL_SIZE)}); // Determine which setting to display - // 1=brightness, 2=volume, 3=colortemp - if (indicator_type == 1) // brightness + if (indicator_type == INDICATOR_BRIGHTNESS) { setting_value = GetBrightness(); setting_min = BRIGHTNESS_MIN; setting_max = BRIGHTNESS_MAX; asset = ASSET_BRIGHTNESS; } - else if (indicator_type == 3) // colortemp + else if (indicator_type == INDICATOR_COLORTEMP) { setting_value = GetColortemp(); setting_min = COLORTEMP_MIN; setting_max = COLORTEMP_MAX; asset = ASSET_COLORTEMP; } - else // volume (2 or any other value) + else // INDICATOR_VOLUME { setting_value = GetVolume(); setting_min = VOLUME_MIN; @@ -1939,7 +1939,7 @@ SDL_Surface* GFX_createScreenFormatSurface(int width, int height) { if (!gfx.screen) return NULL; return SDL_CreateRGBSurfaceWithFormat( - 0, width, height, + SDL_SWSURFACE, width, height, gfx.screen->format->BitsPerPixel, gfx.screen->format->format ); @@ -1957,7 +1957,7 @@ int GFX_blitHardwareGroup(SDL_Surface *dst, int show_setting) ow = SCALE1(PILL_SIZE + SETTINGS_WIDTH + 10 + 4); ox = dst->w - SCALE1(PADDING) - ow; oy = SCALE1(PADDING); - GFX_blitHardwareIndicator(dst, ox, oy, show_setting); + GFX_blitHardwareIndicator(dst, ox, oy, (IndicatorType)show_setting); } else { diff --git a/workspace/all/common/api.h b/workspace/all/common/api.h index 7e0c1c561..79a87d5aa 100644 --- a/workspace/all/common/api.h +++ b/workspace/all/common/api.h @@ -312,7 +312,7 @@ void GFX_scrollTextSurface(TTF_Font* font, const char* in_name, SDL_Surface** ou int GFX_getTextWidth(TTF_Font* font, const char* in_name, char* out_name, int max_width, int padding); // returns final width int GFX_getTextHeight(TTF_Font* font, const char* in_name, char* out_name, int max_width, int padding); // returns final width int GFX_wrapText(TTF_Font* font, char* str, int max_width, int max_lines); -int GFX_blitWrappedText(TTF_Font* font, const char* text, int max_width, int max_lines, SDL_Color color, SDL_Surface* screen, int center_x, int y); // returns new y position +int GFX_blitWrappedText(TTF_Font* font, const char* text, int max_width, int max_lines, SDL_Color color, SDL_Surface* surface, int y); // returns new y position #define GFX_getScaler PLAT_getScaler // scaler_t:(GFX_Renderer* renderer) #define GFX_blitRenderer PLAT_blitRenderer // void:(GFX_Renderer* renderer) @@ -354,16 +354,22 @@ void GFX_blitMessage(TTF_Font* font, char* msg, SDL_Surface* dst, SDL_Rect* dst_ int GFX_blitHardwareGroup(SDL_Surface* dst, int show_setting); void GFX_blitHardwareHints(SDL_Surface* dst, int show_setting); +typedef enum { + INDICATOR_BRIGHTNESS = 1, + INDICATOR_VOLUME = 2, + INDICATOR_COLORTEMP = 3, +} IndicatorType; + /** * Render a hardware indicator (volume/brightness/colortemp) at a specific position. * This is the reusable helper extracted from GFX_blitHardwareGroup for in-game use. * @param dst The destination surface * @param x X position for the indicator * @param y Y position for the indicator - * @param indicator_type 1=brightness, 2=volume, 3=colortemp (matches show_setting values) + * @param indicator_type Which indicator to display * @return The width of the rendered indicator */ -int GFX_blitHardwareIndicator(SDL_Surface* dst, int x, int y, int indicator_type); +int GFX_blitHardwareIndicator(SDL_Surface* dst, int x, int y, IndicatorType indicator_type); /** * Create a surface with the same pixel format as gfx.screen. diff --git a/workspace/all/common/notification.c b/workspace/all/common/notification.c index d645d2b58..0fc49ac21 100644 --- a/workspace/all/common/notification.c +++ b/workspace/all/common/notification.c @@ -294,7 +294,7 @@ void Notification_renderToLayer(int layer) { SDL_FillRect(indicator_surface, NULL, 0); // Render the indicator at (0,0) on the temp surface - GFX_blitHardwareIndicator(indicator_surface, 0, 0, system_indicator_type); + GFX_blitHardwareIndicator(indicator_surface, 0, 0, (IndicatorType)system_indicator_type); // Convert to RGBA for the notification overlay SDL_Surface* converted = SDL_ConvertSurfaceFormat(indicator_surface, SDL_PIXELFORMAT_ABGR8888, 0); diff --git a/workspace/all/minarch/makefile b/workspace/all/minarch/makefile index c905f376b..fea7f83ab 100644 --- a/workspace/all/minarch/makefile +++ b/workspace/all/minarch/makefile @@ -139,7 +139,7 @@ libretro-common: rcheevos: cd ../rcheevos && make build PLATFORM=$(PLATFORM) -# Desktop rcheevos build (native compiler) +# Desktop rcheevos build - uses native gcc instead of cross-compiler rcheevos-desktop: cd ../rcheevos && make build PLATFORM=desktop diff --git a/workspace/all/minarch/minarch.c b/workspace/all/minarch/minarch.c index 55ec051e6..1571c107a 100644 --- a/workspace/all/minarch/minarch.c +++ b/workspace/all/minarch/minarch.c @@ -6103,11 +6103,11 @@ static int OptionAchievements_showDetail(MenuList* list, int i) { // Title centered - wrap to max 2 lines with ellipsis if needed int max_text_width = screen->w - SCALE1(PADDING * 2); - content_y = GFX_blitWrappedText(font.medium, ach->title, max_text_width, 2, COLOR_WHITE, screen, center_x, content_y); + content_y = GFX_blitWrappedText(font.medium, ach->title, max_text_width, 2, COLOR_WHITE, screen, content_y); content_y += SCALE1(2); // Spacing after title // Description - unlimited lines - content_y = GFX_blitWrappedText(font.small, ach->description, max_text_width, 0, COLOR_WHITE, screen, center_x, content_y); + content_y = GFX_blitWrappedText(font.small, ach->description, max_text_width, 0, COLOR_WHITE, screen, content_y); content_y += SCALE1(4); // Spacing after description // Points (singular/plural) - use tiny font like other metadata diff --git a/workspace/all/minarch/ra_consoles.h b/workspace/all/minarch/ra_consoles.h index 46d7e00a0..9356e0f6b 100644 --- a/workspace/all/minarch/ra_consoles.h +++ b/workspace/all/minarch/ra_consoles.h @@ -11,6 +11,96 @@ #include "rc_consoles.h" #include +/** + * Mapping entry from EMU tag to RetroAchievements console ID + */ +typedef struct { + const char* emu_tag; + int console_id; +} RA_ConsoleMapping; + +/** + * Lookup table mapping NextUI EMU tags to RC_CONSOLE_* constants. + * Sorted alphabetically by emu_tag for readability/maintainability. + */ +static const RA_ConsoleMapping ra_console_table[] = { + // Atari + { "A2600", RC_CONSOLE_ATARI_2600 }, + { "A5200", RC_CONSOLE_ATARI_5200 }, + { "A7800", RC_CONSOLE_ATARI_7800 }, + // Sega 32X + { "32X", RC_CONSOLE_SEGA_32X }, + // Commodore + { "C128", RC_CONSOLE_COMMODORE_64 }, // Uses C64 for RA + { "C64", RC_CONSOLE_COMMODORE_64 }, + // ColecoVision + { "COLECO", RC_CONSOLE_COLECOVISION }, + // Amstrad + { "CPC", RC_CONSOLE_AMSTRAD_PC }, + // Nintendo + { "FC", RC_CONSOLE_NINTENDO }, + // FinalBurn Neo + { "FBN", RC_CONSOLE_ARCADE }, + // Famicom Disk System + { "FDS", RC_CONSOLE_FAMICOM_DISK_SYSTEM }, + // Game Boy + { "GB", RC_CONSOLE_GAMEBOY }, + // Game Boy Advance + { "GBA", RC_CONSOLE_GAMEBOY_ADVANCE }, + // Game Boy Color + { "GBC", RC_CONSOLE_GAMEBOY_COLOR }, + // Game Gear + { "GG", RC_CONSOLE_GAME_GEAR }, + // Atari Lynx + { "LYNX", RC_CONSOLE_ATARI_LYNX }, + // Mega Drive/Genesis + { "MD", RC_CONSOLE_MEGA_DRIVE }, + // GBA (mGBA) + { "MGBA", RC_CONSOLE_GAMEBOY_ADVANCE }, + // MSX + { "MSX", RC_CONSOLE_MSX }, + // Neo Geo Pocket + { "NGP", RC_CONSOLE_NEOGEO_POCKET }, + // Neo Geo Pocket Color + { "NGPC", RC_CONSOLE_NEOGEO_POCKET }, + // PICO-8 + { "P8", RC_CONSOLE_PICO }, + // PC Engine + { "PCE", RC_CONSOLE_PC_ENGINE }, + // Not supported (no RA) + { "PET", RC_CONSOLE_UNKNOWN }, + // Pokemon Mini + { "PKM", RC_CONSOLE_POKEMON_MINI }, + // Not supported (no RA) + { "PLUS4", RC_CONSOLE_UNKNOWN }, + // PrBoom (no RA) + { "PRBOOM", RC_CONSOLE_UNKNOWN }, + // PlayStation + { "PS", RC_CONSOLE_PLAYSTATION }, + // PlayStation (SwanStation) + { "PSX", RC_CONSOLE_PLAYSTATION }, + // Amiga + { "PUAE", RC_CONSOLE_AMIGA }, + // Sega CD + { "SEGACD", RC_CONSOLE_SEGA_CD }, + // Super Famicom/SNES + { "SFC", RC_CONSOLE_SUPER_NINTENDO }, + // SG-1000 + { "SG1000", RC_CONSOLE_SG1000 }, + // Super Game Boy + { "SGB", RC_CONSOLE_GAMEBOY }, + // Master System + { "SMS", RC_CONSOLE_MASTER_SYSTEM }, + // Super Famicom (Supafaust) + { "SUPA", RC_CONSOLE_SUPER_NINTENDO }, + // Virtual Boy + { "VB", RC_CONSOLE_VIRTUAL_BOY }, + // VIC-20 + { "VIC", RC_CONSOLE_VIC20 }, +}; + +#define RA_CONSOLE_TABLE_SIZE (sizeof(ra_console_table) / sizeof(ra_console_table[0])) + /** * Get the RetroAchievements console ID for a given EMU tag. * @param emu_tag The NextUI emulator tag (e.g., "GB", "SFC", "PS") @@ -21,61 +111,11 @@ static inline int RA_getConsoleId(const char* emu_tag) { return RC_CONSOLE_UNKNOWN; } - // Nintendo - if (strcmp(emu_tag, "FC") == 0) return RC_CONSOLE_NINTENDO; // Famicom/NES - if (strcmp(emu_tag, "FDS") == 0) return RC_CONSOLE_FAMICOM_DISK_SYSTEM; // Famicom Disk System - if (strcmp(emu_tag, "SFC") == 0) return RC_CONSOLE_SUPER_NINTENDO; // Super Famicom/SNES - if (strcmp(emu_tag, "SUPA") == 0) return RC_CONSOLE_SUPER_NINTENDO; // Super Famicom (Supafaust) - if (strcmp(emu_tag, "GB") == 0) return RC_CONSOLE_GAMEBOY; // Game Boy - if (strcmp(emu_tag, "GBC") == 0) return RC_CONSOLE_GAMEBOY_COLOR; // Game Boy Color - if (strcmp(emu_tag, "SGB") == 0) return RC_CONSOLE_GAMEBOY; // Super Game Boy - if (strcmp(emu_tag, "GBA") == 0) return RC_CONSOLE_GAMEBOY_ADVANCE; // Game Boy Advance - if (strcmp(emu_tag, "MGBA") == 0) return RC_CONSOLE_GAMEBOY_ADVANCE; // GBA (mGBA) - if (strcmp(emu_tag, "VB") == 0) return RC_CONSOLE_VIRTUAL_BOY; // Virtual Boy - if (strcmp(emu_tag, "PKM") == 0) return RC_CONSOLE_POKEMON_MINI; // Pokemon Mini - - // Sega - if (strcmp(emu_tag, "MD") == 0) return RC_CONSOLE_MEGA_DRIVE; // Mega Drive/Genesis - if (strcmp(emu_tag, "32X") == 0) return RC_CONSOLE_SEGA_32X; // Sega 32X - if (strcmp(emu_tag, "SEGACD") == 0) return RC_CONSOLE_SEGA_CD; // Sega CD - if (strcmp(emu_tag, "SMS") == 0) return RC_CONSOLE_MASTER_SYSTEM; // Master System - if (strcmp(emu_tag, "GG") == 0) return RC_CONSOLE_GAME_GEAR; // Game Gear - if (strcmp(emu_tag, "SG1000") == 0) return RC_CONSOLE_SG1000; // SG-1000 - - // Sony - if (strcmp(emu_tag, "PS") == 0) return RC_CONSOLE_PLAYSTATION; // PlayStation - if (strcmp(emu_tag, "PSX") == 0) return RC_CONSOLE_PLAYSTATION; // PlayStation (SwanStation) - - // NEC - if (strcmp(emu_tag, "PCE") == 0) return RC_CONSOLE_PC_ENGINE; // TurboGrafx-16/PC Engine - - // Atari - if (strcmp(emu_tag, "A2600") == 0) return RC_CONSOLE_ATARI_2600; // Atari 2600 - if (strcmp(emu_tag, "A5200") == 0) return RC_CONSOLE_ATARI_5200; // Atari 5200 - if (strcmp(emu_tag, "A7800") == 0) return RC_CONSOLE_ATARI_7800; // Atari 7800 - if (strcmp(emu_tag, "LYNX") == 0) return RC_CONSOLE_ATARI_LYNX; // Atari Lynx - - // SNK - if (strcmp(emu_tag, "NGP") == 0) return RC_CONSOLE_NEOGEO_POCKET; // Neo Geo Pocket - if (strcmp(emu_tag, "NGPC") == 0) return RC_CONSOLE_NEOGEO_POCKET; // Neo Geo Pocket Color - - // Arcade - if (strcmp(emu_tag, "FBN") == 0) return RC_CONSOLE_ARCADE; // FinalBurn Neo (varies) - - // Home Computers - if (strcmp(emu_tag, "C64") == 0) return RC_CONSOLE_COMMODORE_64; // Commodore 64 - if (strcmp(emu_tag, "C128") == 0) return RC_CONSOLE_COMMODORE_64; // Commodore 128 (uses C64 for RA) - if (strcmp(emu_tag, "VIC") == 0) return RC_CONSOLE_VIC20; // Commodore VIC-20 - if (strcmp(emu_tag, "PET") == 0) return RC_CONSOLE_UNKNOWN; // Commodore PET (no RA support) - if (strcmp(emu_tag, "PLUS4") == 0) return RC_CONSOLE_UNKNOWN; // Commodore Plus/4 (no RA support) - if (strcmp(emu_tag, "CPC") == 0) return RC_CONSOLE_AMSTRAD_PC; // Amstrad CPC - if (strcmp(emu_tag, "MSX") == 0) return RC_CONSOLE_MSX; // MSX - if (strcmp(emu_tag, "PUAE") == 0) return RC_CONSOLE_AMIGA; // Amiga (PUAE) - - // Other - if (strcmp(emu_tag, "COLECO") == 0) return RC_CONSOLE_COLECOVISION; // ColecoVision - if (strcmp(emu_tag, "P8") == 0) return RC_CONSOLE_PICO; // PICO-8 (fantasy console) - if (strcmp(emu_tag, "PRBOOM") == 0) return RC_CONSOLE_UNKNOWN; // PrBoom (DOOM - no RA) + for (size_t i = 0; i < RA_CONSOLE_TABLE_SIZE; i++) { + if (strcmp(emu_tag, ra_console_table[i].emu_tag) == 0) { + return ra_console_table[i].console_id; + } + } return RC_CONSOLE_UNKNOWN; } diff --git a/workspace/all/minarch/ra_integration.c b/workspace/all/minarch/ra_integration.c index 9ff5d7862..68a53887c 100644 --- a/workspace/all/minarch/ra_integration.c +++ b/workspace/all/minarch/ra_integration.c @@ -19,8 +19,11 @@ #include #include -// Logging macro -#define RA_LOG(fmt, ...) printf("[RA] " fmt, ##__VA_ARGS__) +// Logging macros - use NextUI log levels +#define RA_LOG_DEBUG(fmt, ...) LOG_debug("[RA] " fmt, ##__VA_ARGS__) +#define RA_LOG_INFO(fmt, ...) LOG_info("[RA] " fmt, ##__VA_ARGS__) +#define RA_LOG_WARN(fmt, ...) LOG_warn("[RA] " fmt, ##__VA_ARGS__) +#define RA_LOG_ERROR(fmt, ...) LOG_error("[RA] " fmt, ##__VA_ARGS__) /***************************************************************************** * Static state @@ -244,7 +247,7 @@ static void ra_init_cdreader(void) { // Get default callbacks to use as fallback rc_hash_get_default_cdreader(&ra_default_cdreader); - RA_LOG("Initializing CHD-aware CD reader\n"); + RA_LOG_DEBUG("Initializing CHD-aware CD reader\n"); } /***************************************************************************** @@ -271,7 +274,7 @@ static void ra_reset_login_state(void) { * Helper: Start a login attempt *****************************************************************************/ static void ra_start_login(void) { - RA_LOG("Attempting login (attempt %d/%d)...\n", + RA_LOG_DEBUG("Attempting login (attempt %d/%d)...\n", ra_login_retry_count + 1, RA_LOGIN_MAX_RETRIES); rc_client_begin_login_with_token(ra_client, CFG_getRAUsername(), CFG_getRAToken(), @@ -344,7 +347,7 @@ static bool ra_queue_push(const char* body, size_t body_length, int http_status, ra_response_queue_count++; success = true; } else { - RA_LOG("Warning: Response queue full, dropping response\n"); + RA_LOG_WARN("Warning: Response queue full, dropping response\n"); } SDL_UnlockMutex(ra_queue_mutex); @@ -448,7 +451,7 @@ static void ra_load_muted_achievements(void) { } fclose(f); - RA_LOG("Loaded %d muted achievements for game %s\n", ra_muted_count, ra_game_hash); + RA_LOG_DEBUG("Loaded %d muted achievements for game %s\n", ra_muted_count, ra_game_hash); } /***************************************************************************** @@ -477,7 +480,7 @@ static void ra_save_muted_achievements(void) { FILE* f = fopen(path, "w"); if (!f) { - RA_LOG("Error: Failed to save mute file: %s\n", path); + RA_LOG_ERROR("Error: Failed to save mute file: %s\n", path); return; } @@ -487,7 +490,7 @@ static void ra_save_muted_achievements(void) { fclose(f); ra_muted_dirty = false; - RA_LOG("Saved %d muted achievements for game %s\n", ra_muted_count, ra_game_hash); + RA_LOG_DEBUG("Saved %d muted achievements for game %s\n", ra_muted_count, ra_game_hash); } /***************************************************************************** @@ -586,7 +589,7 @@ static void ra_http_callback(HTTP_Response* response, void* userdata) { } else { // Error case if (response && response->error) { - RA_LOG("HTTP error: %s\n", response->error); + RA_LOG_ERROR("HTTP error: %s\n", response->error); } } @@ -594,7 +597,7 @@ static void ra_http_callback(HTTP_Response* response, void* userdata) { // The queue makes a copy of the body, so we can free the response after if (!ra_queue_push(body, body_length, http_status, data->callback, data->callback_data)) { // Queue failed (full or not initialized) - log but don't crash - RA_LOG("Warning: Failed to queue HTTP response\n"); + RA_LOG_WARN("Warning: Failed to queue HTTP response\n"); } // Cleanup - safe to free now since queue copied the data @@ -638,7 +641,7 @@ static void ra_server_call(const rc_api_request_t* request, static void ra_log_message(const char* message, const rc_client_t* client) { (void)client; - RA_LOG("%s\n", message); + RA_LOG_DEBUG("%s\n", message); } /***************************************************************************** @@ -656,7 +659,7 @@ static void ra_event_handler(const rc_client_event_t* event, rc_client_t* client case RC_CLIENT_EVENT_ACHIEVEMENT_TRIGGERED: // Hide "Unknown Emulator" notification when hardcore mode is disabled if (!CFG_getRAHardcoreMode() && event->achievement->id == 101000001) { - RA_LOG("Skipping Unknown Emulator notification (not in hardcore mode)\n"); + RA_LOG_DEBUG("Skipping Unknown Emulator notification (not in hardcore mode)\n"); break; } snprintf(message, sizeof(message), "Achievement Unlocked: %s", @@ -664,16 +667,16 @@ static void ra_event_handler(const rc_client_event_t* event, rc_client_t* client // Get the unlocked badge icon (not locked) badge_icon = RA_Badges_getNotificationSize(event->achievement->badge_name, false); Notification_push(NOTIFICATION_ACHIEVEMENT, message, badge_icon); - RA_LOG("Achievement unlocked: %s (%d points)\n", + RA_LOG_INFO("Achievement unlocked: %s (%d points)\n", event->achievement->title, event->achievement->points); break; case RC_CLIENT_EVENT_ACHIEVEMENT_CHALLENGE_INDICATOR_SHOW: - RA_LOG("Challenge started: %s\n", event->achievement->title); + RA_LOG_DEBUG("Challenge started: %s\n", event->achievement->title); break; case RC_CLIENT_EVENT_ACHIEVEMENT_CHALLENGE_INDICATOR_HIDE: - RA_LOG("Challenge ended: %s\n", event->achievement->title); + RA_LOG_DEBUG("Challenge ended: %s\n", event->achievement->title); break; case RC_CLIENT_EVENT_ACHIEVEMENT_PROGRESS_INDICATOR_SHOW: @@ -720,32 +723,32 @@ static void ra_event_handler(const rc_client_event_t* event, rc_client_t* client snprintf(message, sizeof(message), "Leaderboard: %s", event->leaderboard->title); Notification_push(NOTIFICATION_ACHIEVEMENT, message, NULL); - RA_LOG("Leaderboard started: %s\n", event->leaderboard->title); + RA_LOG_INFO("Leaderboard started: %s\n", event->leaderboard->title); break; case RC_CLIENT_EVENT_LEADERBOARD_FAILED: - RA_LOG("Leaderboard failed: %s\n", event->leaderboard->title); + RA_LOG_INFO("Leaderboard failed: %s\n", event->leaderboard->title); break; case RC_CLIENT_EVENT_LEADERBOARD_SUBMITTED: snprintf(message, sizeof(message), "Submitted %s to %s", event->leaderboard->tracker_value, event->leaderboard->title); Notification_push(NOTIFICATION_ACHIEVEMENT, message, NULL); - RA_LOG("Leaderboard submitted: %s - %s\n", + RA_LOG_INFO("Leaderboard submitted: %s - %s\n", event->leaderboard->title, event->leaderboard->tracker_value); break; case RC_CLIENT_EVENT_GAME_COMPLETED: Notification_push(NOTIFICATION_ACHIEVEMENT, "Game Mastered!", NULL); - RA_LOG("Game mastered!\n"); + RA_LOG_INFO("Game mastered!\n"); break; case RC_CLIENT_EVENT_RESET: - RA_LOG("Reset requested (hardcore mode enabled)\n"); + RA_LOG_WARN("Reset requested (hardcore mode enabled)\n"); break; case RC_CLIENT_EVENT_SERVER_ERROR: - RA_LOG("Server error: %s\n", + RA_LOG_ERROR("Server error: %s\n", event->server_error ? event->server_error->error_message : "unknown"); // Show notification for server errors snprintf(message, sizeof(message), "RA Server Error: %s", @@ -754,17 +757,17 @@ static void ra_event_handler(const rc_client_event_t* event, rc_client_t* client break; case RC_CLIENT_EVENT_DISCONNECTED: - RA_LOG("Disconnected - unlocks pending\n"); + RA_LOG_WARN("Disconnected - unlocks pending\n"); Notification_push(NOTIFICATION_ACHIEVEMENT, "RetroAchievements: Offline mode", NULL); break; case RC_CLIENT_EVENT_RECONNECTED: - RA_LOG("Reconnected - pending unlocks submitted\n"); + RA_LOG_INFO("Reconnected - pending unlocks submitted\n"); Notification_push(NOTIFICATION_ACHIEVEMENT, "RetroAchievements: Reconnected", NULL); break; default: - RA_LOG("Unhandled event type: %d\n", event->type); + RA_LOG_DEBUG("Unhandled event type: %d\n", event->type); break; } } @@ -783,20 +786,20 @@ static void ra_login_callback(int result, const char* error_message, ra_logged_in = true; const rc_client_user_t* user = rc_client_get_user_info(client); - RA_LOG("Logged in as %s (score: %u)\n", + RA_LOG_INFO("Logged in as %s (score: %u)\n", user ? user->display_name : "unknown", user ? user->score : 0); // Check if we have a pending game to load if (pending_game_load) { - RA_LOG("Processing deferred game load: %s\n", pending_rom_path); + RA_LOG_DEBUG("Processing deferred game load: %s\n", pending_rom_path); ra_do_load_game(pending_rom_path, pending_rom_data, pending_rom_size, pending_emu_tag); ra_clear_pending_game(); } } else { // Failure - attempt retry or give up ra_logged_in = false; - RA_LOG("Login failed: %s\n", error_message ? error_message : "unknown error"); + RA_LOG_ERROR("Login failed: %s\n", error_message ? error_message : "unknown error"); if (ra_login_retry_count < RA_LOGIN_MAX_RETRIES) { // Schedule retry @@ -805,7 +808,7 @@ static void ra_login_callback(int result, const char* error_message, ra_login_retry_pending = true; ra_login_retry_count++; - RA_LOG("Scheduling retry %d/%d in %ums\n", + RA_LOG_DEBUG("Scheduling retry %d/%d in %ums\n", ra_login_retry_count, RA_LOGIN_MAX_RETRIES, delay); // Show "Connecting..." notification on first retry only @@ -816,7 +819,7 @@ static void ra_login_callback(int result, const char* error_message, } } else { // All retries exhausted - RA_LOG("All login retries exhausted\n"); + RA_LOG_ERROR("All login retries exhausted\n"); Notification_push(NOTIFICATION_ACHIEVEMENT, "RetroAchievements: Connection failed", NULL); ra_reset_login_state(); @@ -835,7 +838,7 @@ static void ra_prefetch_badges(rc_client_t* client) { RC_CLIENT_ACHIEVEMENT_LIST_GROUPING_LOCK_STATE); if (!list) { - RA_LOG("Failed to get achievement list for badge prefetch\n"); + RA_LOG_WARN("Failed to get achievement list for badge prefetch\n"); return; } @@ -872,7 +875,7 @@ static void ra_prefetch_badges(rc_client_t* client) { free(badge_names); rc_client_destroy_achievement_list(list); - RA_LOG("Prefetching %d achievement badges\n", idx); + RA_LOG_DEBUG("Prefetching %d achievement badges\n", idx); } /***************************************************************************** @@ -888,7 +891,7 @@ static void ra_game_loaded_callback(int result, const char* error_message, ra_game_loaded = true; if (game && game->id != 0) { - RA_LOG("Game loaded: %s (ID: %u)\n", game->title, game->id); + RA_LOG_INFO("Game loaded: %s (ID: %u)\n", game->title, game->id); // Store game hash for mute file path if (game->hash && game->hash[0] != '\0') { @@ -942,11 +945,11 @@ static void ra_game_loaded_callback(int result, const char* error_message, game->title, display_unlocked, display_total); Notification_push(NOTIFICATION_ACHIEVEMENT, message, NULL); } else { - RA_LOG("Game not recognized by RetroAchievements\n"); + RA_LOG_WARN("Game not recognized by RetroAchievements\n"); } } else { ra_game_loaded = false; - RA_LOG("Game load failed: %s\n", error_message ? error_message : "unknown error"); + RA_LOG_ERROR("Game load failed: %s\n", error_message ? error_message : "unknown error"); } } @@ -956,18 +959,18 @@ static void ra_game_loaded_callback(int result, const char* error_message, void RA_init(void) { if (!CFG_getRAEnable()) { - RA_LOG("RetroAchievements disabled in settings\n"); + RA_LOG_DEBUG("RetroAchievements disabled in settings\n"); return; } if (ra_client) { - RA_LOG("Already initialized\n"); + RA_LOG_DEBUG("Already initialized\n"); return; } // Check wifi state before attempting to connect if (!PLAT_wifiEnabled()) { - RA_LOG("WiFi disabled - cannot connect to RetroAchievements\n"); + RA_LOG_WARN("WiFi disabled - cannot connect to RetroAchievements\n"); Notification_push(NOTIFICATION_ACHIEVEMENT, "RetroAchievements requires WiFi", NULL); return; @@ -975,7 +978,7 @@ void RA_init(void) { // Wait for wifi to connect (handles wake-from-sleep scenario) if (!PLAT_wifiConnected()) { - RA_LOG("WiFi enabled but not connected, waiting up to %dms...\n", RA_WIFI_WAIT_MAX_MS); + RA_LOG_DEBUG("WiFi enabled but not connected, waiting up to %dms...\n", RA_WIFI_WAIT_MAX_MS); uint32_t start = SDL_GetTicks(); while (!PLAT_wifiConnected() && (SDL_GetTicks() - start) < RA_WIFI_WAIT_MAX_MS) { @@ -983,15 +986,15 @@ void RA_init(void) { } if (!PLAT_wifiConnected()) { - RA_LOG("WiFi did not connect within %dms\n", RA_WIFI_WAIT_MAX_MS); + RA_LOG_WARN("WiFi did not connect within %dms\n", RA_WIFI_WAIT_MAX_MS); Notification_push(NOTIFICATION_ACHIEVEMENT, "RetroAchievements requires WiFi", NULL); return; } - RA_LOG("WiFi connected after %ums\n", SDL_GetTicks() - start); + RA_LOG_DEBUG("WiFi connected after %ums\n", SDL_GetTicks() - start); } - RA_LOG("Initializing...\n"); + RA_LOG_INFO("Initializing...\n"); // Initialize the response queue (must be before any HTTP requests) ra_queue_init(); @@ -999,7 +1002,7 @@ void RA_init(void) { // Create rc_client with our callbacks ra_client = rc_client_create(ra_read_memory, ra_server_call); if (!ra_client) { - RA_LOG("Failed to create rc_client\n"); + RA_LOG_ERROR("Failed to create rc_client\n"); return; } @@ -1023,7 +1026,7 @@ void RA_init(void) { hash_callbacks.cdreader.first_track_sector = ra_cdreader_first_track_sector; rc_client_set_hash_callbacks(ra_client, &hash_callbacks); - RA_LOG("CHD disc image support enabled\n"); + RA_LOG_DEBUG("CHD disc image support enabled\n"); } // Configure hardcore mode from settings @@ -1034,10 +1037,10 @@ void RA_init(void) { // Attempt login with stored token if (CFG_getRAAuthenticated() && strlen(CFG_getRAToken()) > 0) { - RA_LOG("Logging in with stored token...\n"); + RA_LOG_INFO("Logging in with stored token...\n"); ra_start_login(); } else { - RA_LOG("No stored token - user needs to authenticate in settings\n"); + RA_LOG_WARN("No stored token - user needs to authenticate in settings\n"); } } @@ -1068,7 +1071,7 @@ void RA_quit(void) { } if (ra_client) { - RA_LOG("Shutting down...\n"); + RA_LOG_INFO("Shutting down...\n"); rc_client_destroy(ra_client); ra_client = NULL; } @@ -1097,7 +1100,7 @@ void RA_setMemoryMap(const void* mmap) { } if (!mmap) { - RA_LOG("Memory map cleared\n"); + RA_LOG_DEBUG("Memory map cleared\n"); return; } @@ -1105,14 +1108,14 @@ void RA_setMemoryMap(const void* mmap) { const struct retro_memory_map* src = (const struct retro_memory_map*)mmap; if (src->num_descriptors == 0 || !src->descriptors) { - RA_LOG("Memory map has no descriptors\n"); + RA_LOG_WARN("Memory map has no descriptors\n"); return; } // Allocate our copy of the memory map structure ra_memory_map = (struct retro_memory_map*)malloc(sizeof(struct retro_memory_map)); if (!ra_memory_map) { - RA_LOG("Failed to allocate memory map\n"); + RA_LOG_ERROR("Failed to allocate memory map\n"); return; } @@ -1122,7 +1125,7 @@ void RA_setMemoryMap(const void* mmap) { if (!ra_memory_map_descriptors) { free(ra_memory_map); ra_memory_map = NULL; - RA_LOG("Failed to allocate memory map descriptors\n"); + RA_LOG_ERROR("Failed to allocate memory map descriptors\n"); return; } @@ -1130,7 +1133,7 @@ void RA_setMemoryMap(const void* mmap) { ra_memory_map->num_descriptors = src->num_descriptors; ra_memory_map->descriptors = ra_memory_map_descriptors; - RA_LOG("Memory map set by core: %u descriptors (deep copied)\n", ra_memory_map->num_descriptors); + RA_LOG_DEBUG("Memory map set by core: %u descriptors (deep copied)\n", ra_memory_map->num_descriptors); } void RA_initMemoryRegions(uint32_t console_id) { @@ -1148,10 +1151,10 @@ void RA_initMemoryRegions(uint32_t console_id) { if (result) { ra_memory_regions_initialized = true; - RA_LOG("Memory regions initialized: %u regions, %zu total bytes\n", + RA_LOG_DEBUG("Memory regions initialized: %u regions, %zu total bytes\n", ra_memory_regions.count, ra_memory_regions.total_size); } else { - RA_LOG("Warning: Failed to initialize memory regions for console %u\n", console_id); + RA_LOG_WARN("Warning: Failed to initialize memory regions for console %u\n", console_id); } } @@ -1193,7 +1196,7 @@ static int ra_is_cd_extension(const char* path) { static void ra_do_load_game(const char* rom_path, const uint8_t* rom_data, size_t rom_size, const char* emu_tag) { int console_id = RA_getConsoleId(emu_tag); if (console_id == RC_CONSOLE_UNKNOWN) { - RA_LOG("Unknown console for tag '%s' - achievements disabled\n", emu_tag); + RA_LOG_WARN("Unknown console for tag '%s' - achievements disabled\n", emu_tag); return; } @@ -1201,15 +1204,15 @@ static void ra_do_load_game(const char* rom_path, const uint8_t* rom_data, size_ // PCE tag is used for both HuCard and CD games in NextUI if (console_id == RC_CONSOLE_PC_ENGINE && ra_is_cd_extension(rom_path)) { console_id = RC_CONSOLE_PC_ENGINE_CD; - RA_LOG("Detected PC Engine CD image, using console ID %d\n", console_id); + RA_LOG_DEBUG("Detected PC Engine CD image, using console ID %d\n", console_id); } // MD tag is used for both cartridge and Sega CD games in NextUI else if (console_id == RC_CONSOLE_MEGA_DRIVE && ra_is_cd_extension(rom_path)) { console_id = RC_CONSOLE_SEGA_CD; - RA_LOG("Detected Sega CD image, using console ID %d\n", console_id); + RA_LOG_DEBUG("Detected Sega CD image, using console ID %d\n", console_id); } - RA_LOG("Loading game: %s (console: %s, ID: %d)\n", + RA_LOG_INFO("Loading game: %s (console: %s, ID: %d)\n", rom_path, rc_console_name(console_id), console_id); // Initialize memory regions for this console type BEFORE loading the game @@ -1223,7 +1226,7 @@ static void ra_do_load_game(const char* rom_path, const uint8_t* rom_data, size_ ra_game_loaded_callback, NULL); #else // Fallback for builds without hash support - RA_LOG("Hash support not compiled in - cannot identify game\n"); + RA_LOG_ERROR("Hash support not compiled in - cannot identify game\n"); #endif } @@ -1234,7 +1237,7 @@ void RA_loadGame(const char* rom_path, const uint8_t* rom_data, size_t rom_size, // If not logged in yet, store the game info for deferred loading if (!ra_logged_in) { - RA_LOG("Login in progress - deferring game load for: %s\n", rom_path); + RA_LOG_DEBUG("Login in progress - deferring game load for: %s\n", rom_path); // Clear any previous pending game ra_clear_pending_game(); @@ -1254,7 +1257,7 @@ void RA_loadGame(const char* rom_path, const uint8_t* rom_data, size_t rom_size, memcpy(pending_rom_data, rom_data, rom_size); pending_rom_size = rom_size; } else { - RA_LOG("Warning: Failed to allocate memory for pending ROM data\n"); + RA_LOG_WARN("Failed to allocate memory for pending ROM data\n"); pending_rom_size = 0; } } @@ -1273,7 +1276,7 @@ void RA_unloadGame(void) { } if (ra_game_loaded) { - RA_LOG("Unloading game\n"); + RA_LOG_INFO("Unloading game\n"); // Save any pending muted achievements ra_save_muted_achievements(); @@ -1448,9 +1451,9 @@ void RA_setAchievementMuted(uint32_t achievement_id, bool muted) { if (ra_muted_count < RA_MAX_MUTED_ACHIEVEMENTS) { ra_muted_achievements[ra_muted_count++] = achievement_id; ra_muted_dirty = true; - RA_LOG("Achievement %u muted\n", achievement_id); + RA_LOG_DEBUG("Achievement %u muted\n", achievement_id); } else { - RA_LOG("Warning: Max muted achievements reached, cannot mute %u\n", achievement_id); + RA_LOG_WARN("Max muted achievements reached, cannot mute %u\n", achievement_id); } } } else { @@ -1463,7 +1466,7 @@ void RA_setAchievementMuted(uint32_t achievement_id, bool muted) { } ra_muted_count--; ra_muted_dirty = true; - RA_LOG("Achievement %u unmuted\n", achievement_id); + RA_LOG_DEBUG("Achievement %u unmuted\n", achievement_id); break; } } diff --git a/workspace/desktop/libmsettings/msettings.c b/workspace/desktop/libmsettings/msettings.c index d5d0fa039..ffad64125 100644 --- a/workspace/desktop/libmsettings/msettings.c +++ b/workspace/desktop/libmsettings/msettings.c @@ -345,28 +345,12 @@ int InitializedSettings(void){ // not implemented here -// Desktop test helpers - read values from /tmp/desktop_settings_* files -static int read_setting_file(const char* name, int default_value) { - char path[256]; - snprintf(path, sizeof(path), "/tmp/desktop_settings_%s", name); - FILE* f = fopen(path, "r"); - if (f) { - int value; - if (fscanf(f, "%d", &value) == 1) { - fclose(f); - return value; - } - fclose(f); - } - return default_value; -} - -int GetBrightness(void) { return read_setting_file("brightness", 5); } -int GetColortemp(void) { return read_setting_file("colortemp", 10); } +int GetBrightness(void) { return 0; } +int GetColortemp(void) { return 0; } int GetContrast(void) { return 0; } int GetSaturation(void) { return 0; } int GetExposure(void) { return 0; } -int GetVolume(void) { return read_setting_file("volume", 10); } +int GetVolume(void) { return 0; } int GetMutedBrightness(void) { return 0; } int GetMutedColortemp(void) { return 0; } @@ -421,4 +405,4 @@ void SetAudioSink(int value) {} int GetHDMI(void) { return 0; } void SetHDMI(int value) {} -int GetMute(void) { return 0; } \ No newline at end of file +int GetMute(void) { return 0; } From 2bc7602f23509ba7cdd6511ab654118046c1fbec Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Thu, 5 Feb 2026 12:41:24 -0500 Subject: [PATCH 06/26] Move RA data to shared userdata directory Store RetroAchievements data (muted achievements, badge cache) in SHARED_USERDATA_PATH/.ra instead of per-platform USERDATA_PATH/ra. --- workspace/all/common/ra_badges.c | 21 ++++++++------------- workspace/all/common/ra_badges.h | 4 ++-- workspace/all/minarch/ra_integration.c | 6 +++--- 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/workspace/all/common/ra_badges.c b/workspace/all/common/ra_badges.c index 18b6b8349..de0cb9bb9 100644 --- a/workspace/all/common/ra_badges.c +++ b/workspace/all/common/ra_badges.c @@ -76,17 +76,12 @@ static BadgeCacheEntry* find_or_create_entry(const char* badge_name, bool locked static void ensure_cache_dir(void) { char path[MAX_PATH]; - // Create .cache directory - snprintf(path, sizeof(path), "%s/.cache", SDCARD_PATH); + // Create .ra directory + snprintf(path, sizeof(path), SHARED_USERDATA_PATH "/.ra"); mkdir(path, 0755); - // Create .cache/ra directory - snprintf(path, sizeof(path), "%s/.cache/ra", SDCARD_PATH); - mkdir(path, 0755); - - // Create .cache/ra/badges directory - snprintf(path, sizeof(path), "%s%s", SDCARD_PATH, RA_BADGE_CACHE_DIR); - mkdir(path, 0755); + // Create .ra/badges directory + mkdir(RA_BADGE_CACHE_DIR, 0755); } // Check if cache file exists @@ -399,11 +394,11 @@ bool RA_Badges_hasPendingDownloads(void) { void RA_Badges_getCachePath(const char* badge_name, bool locked, char* buffer, size_t buffer_size) { if (locked) { - snprintf(buffer, buffer_size, "%s%s/%s_lock.png", - SDCARD_PATH, RA_BADGE_CACHE_DIR, badge_name); + snprintf(buffer, buffer_size, "%s/%s_lock.png", + RA_BADGE_CACHE_DIR, badge_name); } else { - snprintf(buffer, buffer_size, "%s%s/%s.png", - SDCARD_PATH, RA_BADGE_CACHE_DIR, badge_name); + snprintf(buffer, buffer_size, "%s/%s.png", + RA_BADGE_CACHE_DIR, badge_name); } } diff --git a/workspace/all/common/ra_badges.h b/workspace/all/common/ra_badges.h index 787b911eb..344a46314 100644 --- a/workspace/all/common/ra_badges.h +++ b/workspace/all/common/ra_badges.h @@ -11,7 +11,7 @@ * Downloads and caches achievement badge images for display in notifications * and the achievements list. * - * Cache location: /mnt/SDCARD/.cache/ra/badges/{badge_name}.png + * Cache location: SHARED_USERDATA_PATH/.ra/badges/{badge_name}.png * Badge URLs: https://media.retroachievements.org/Badge/{badge_name}.png * Locked badges: https://media.retroachievements.org/Badge/{badge_name}_lock.png */ @@ -21,7 +21,7 @@ #define RA_BADGE_NOTIFY_SIZE 24 // Size for notification icons // Cache directory path (under SDCARD_PATH) -#define RA_BADGE_CACHE_DIR "/.cache/ra/badges" +#define RA_BADGE_CACHE_DIR SHARED_USERDATA_PATH "/.ra/badges" // Badge state typedef enum { diff --git a/workspace/all/minarch/ra_integration.c b/workspace/all/minarch/ra_integration.c index 68a53887c..1e7360b79 100644 --- a/workspace/all/minarch/ra_integration.c +++ b/workspace/all/minarch/ra_integration.c @@ -410,7 +410,7 @@ static void ra_process_queued_responses(void) { * Helper: Muted achievements file path *****************************************************************************/ static void ra_get_mute_file_path(char* path, size_t path_size) { - snprintf(path, path_size, USERDATA_PATH "/ra/muted/%s.txt", ra_game_hash); + snprintf(path, path_size, SHARED_USERDATA_PATH "/.ra/muted/%s.txt", ra_game_hash); } /***************************************************************************** @@ -418,9 +418,9 @@ static void ra_get_mute_file_path(char* path, size_t path_size) { *****************************************************************************/ static void ra_ensure_mute_dir(void) { char dir_path[512]; - snprintf(dir_path, sizeof(dir_path), USERDATA_PATH "/ra"); + snprintf(dir_path, sizeof(dir_path), SHARED_USERDATA_PATH "/.ra"); mkdir(dir_path, 0755); - snprintf(dir_path, sizeof(dir_path), USERDATA_PATH "/ra/muted"); + snprintf(dir_path, sizeof(dir_path), SHARED_USERDATA_PATH "/.ra/muted"); mkdir(dir_path, 0755); } From 1edbda15e15a89bd3e2c6ae7176c7547717e3fbd Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Thu, 5 Feb 2026 12:58:36 -0500 Subject: [PATCH 07/26] Hide Achievements menu when RetroAchievements disabled Dynamically adjust options menu based on CFG_getRAEnable() state. When RA is disabled, the Achievements item is hidden and Save Changes moves up to fill the gap. --- workspace/all/minarch/minarch.c | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/workspace/all/minarch/minarch.c b/workspace/all/minarch/minarch.c index 1571c107a..52ad1e771 100644 --- a/workspace/all/minarch/minarch.c +++ b/workspace/all/minarch/minarch.c @@ -6579,13 +6579,38 @@ static MenuList options_menu = { } }; +// Track the index of Save Changes menu item (changes based on RA visibility) +static int save_changes_index = 7; + +// Update options menu visibility based on RA enable state +static void Options_updateVisibility(void) { + if (CFG_getRAEnable()) { + // RA enabled: show Achievements at index 6, Save Changes at index 7 + options_menu.items[6].name = "Achievements"; + options_menu.items[6].on_confirm = OptionAchievements_openMenu; + options_menu.items[7].name = "Save Changes"; + options_menu.items[7].on_confirm = OptionSaveChanges_openMenu; + save_changes_index = 7; + } else { + // RA disabled: hide Achievements, move Save Changes to index 6 + options_menu.items[6].name = "Save Changes"; + options_menu.items[6].desc = NULL; + options_menu.items[6].on_confirm = OptionSaveChanges_openMenu; + options_menu.items[7].name = NULL; + options_menu.items[7].on_confirm = NULL; + save_changes_index = 6; + } +} + static void OptionSaveChanges_updateDesc(void) { - options_menu.items[7].desc = getSaveDesc(); + options_menu.items[save_changes_index].desc = getSaveDesc(); } -// Update the Achievements menu item to show count +// Update the Achievements menu item to show count (only when RA enabled) static char ach_desc_buffer[64] = {0}; static void OptionAchievements_updateDesc(void) { + if (!CFG_getRAEnable()) return; + if (RA_isGameLoaded()) { uint32_t unlocked, total; RA_getAchievementSummary(&unlocked, &total); @@ -7554,6 +7579,7 @@ static void Menu_loop(void) { } else { int old_scaling = screen_scaling; + Options_updateVisibility(); Menu_options(&options_menu); if (screen_scaling!=old_scaling) { selectScaler(renderer.true_w,renderer.true_h,renderer.src_p); From 31735b2f9f755002d3652528cd6f3793dcfab436 Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Thu, 5 Feb 2026 14:54:18 -0500 Subject: [PATCH 08/26] Optimize badge downloading with rate-limited queue and lazy loading - Add download queue with max 8 concurrent requests to prevent network/disk thrashing that caused gameplay stuttering - Lazy load badge images on-demand instead of during prefetch (only save PNG to disk during prefetch, decode when displayed) - Show persistent "Loading achievement badges..." indicator during download that auto-hides when complete - Fix progress indicator to omit colon when progress string is empty - Remove unused RA_Badges_hasPendingDownloads() --- workspace/all/common/notification.c | 20 ++- workspace/all/common/notification.h | 8 + workspace/all/common/ra_badges.c | 240 ++++++++++++++++++++-------- workspace/all/common/ra_badges.h | 7 - 4 files changed, 201 insertions(+), 74 deletions(-) diff --git a/workspace/all/common/notification.c b/workspace/all/common/notification.c index 0fc49ac21..bfb5105b1 100644 --- a/workspace/all/common/notification.c +++ b/workspace/all/common/notification.c @@ -50,6 +50,7 @@ static SDL_Surface* progress_indicator_icon = NULL; static uint32_t progress_indicator_start_time = 0; static int progress_indicator_active = 0; static int progress_indicator_dirty = 0; +static int progress_indicator_persistent = 0; /////////////////////////////// // Rounded rectangle drawing @@ -186,8 +187,8 @@ void Notification_update(uint32_t now) { } } - // Update progress indicator timeout - if (progress_indicator_active) { + // Update progress indicator timeout (skip if persistent) + if (progress_indicator_active && !progress_indicator_persistent) { uint32_t elapsed = now - progress_indicator_start_time; int duration_seconds = CFG_getRAProgressNotificationDuration(); if (duration_seconds > 0 && elapsed >= (uint32_t)(duration_seconds * 1000)) { @@ -315,9 +316,15 @@ void Notification_renderToLayer(int layer) { SDL_Color bg_color_sdl = uintToColour(THEME_COLOR2_255); // Primary Accent Color // Format: "Title: Progress" (e.g., "Coin Collector: 50/100") + // Or just "Title" if progress is empty char progress_text[PROGRESS_TITLE_MAX + PROGRESS_STRING_MAX + 4]; - snprintf(progress_text, sizeof(progress_text), "%s: %s", - progress_indicator_title, progress_indicator_progress); + if (progress_indicator_progress[0] != '\0') { + snprintf(progress_text, sizeof(progress_text), "%s: %s", + progress_indicator_title, progress_indicator_progress); + } else { + snprintf(progress_text, sizeof(progress_text), "%s", + progress_indicator_title); + } // Calculate text size using tiny font int text_w = 0, text_h = 0; @@ -560,11 +567,16 @@ void Notification_hideProgressIndicator(void) { if (progress_indicator_active) { progress_indicator_active = 0; + progress_indicator_persistent = 0; progress_indicator_icon = NULL; progress_indicator_dirty = 1; } } +void Notification_setProgressIndicatorPersistent(bool persistent) { + progress_indicator_persistent = persistent ? 1 : 0; +} + bool Notification_hasProgressIndicator(void) { return initialized && progress_indicator_active; } diff --git a/workspace/all/common/notification.h b/workspace/all/common/notification.h index 978450cc4..54a3bade0 100644 --- a/workspace/all/common/notification.h +++ b/workspace/all/common/notification.h @@ -141,6 +141,14 @@ void Notification_showProgressIndicator(const char* title, const char* progress, */ void Notification_hideProgressIndicator(void); +/** + * Set the progress indicator to persistent mode. + * When persistent, the indicator won't auto-hide after the timeout. + * Call hideProgressIndicator to dismiss it. + * @param persistent true to keep visible until explicitly hidden + */ +void Notification_setProgressIndicatorPersistent(bool persistent); + /** * Check if a progress indicator is currently being displayed. * @return true if a progress indicator is active diff --git a/workspace/all/common/ra_badges.c b/workspace/all/common/ra_badges.c index de0cb9bb9..384aef6a3 100644 --- a/workspace/all/common/ra_badges.c +++ b/workspace/all/common/ra_badges.c @@ -1,7 +1,9 @@ #include "ra_badges.h" #include "http.h" #include "defines.h" +#include "api.h" #include "sdl.h" +#include "notification.h" #include #include @@ -10,8 +12,11 @@ #include #include -// Logging macro -#define BADGE_LOG(fmt, ...) printf("[RA_BADGES] " fmt, ##__VA_ARGS__) +// Logging macros using NextUI's LOG_* infrastructure +#define BADGE_LOG_DEBUG(fmt, ...) LOG_debug("[RA_BADGES] " fmt, ##__VA_ARGS__) +#define BADGE_LOG_INFO(fmt, ...) LOG_info("[RA_BADGES] " fmt, ##__VA_ARGS__) +#define BADGE_LOG_WARN(fmt, ...) LOG_warn("[RA_BADGES] " fmt, ##__VA_ARGS__) +#define BADGE_LOG_ERROR(fmt, ...) LOG_error("[RA_BADGES] " fmt, ##__VA_ARGS__) /***************************************************************************** * Constants @@ -20,6 +25,8 @@ #define RA_BADGE_BASE_URL "https://media.retroachievements.org/Badge/" #define MAX_BADGE_NAME 32 #define MAX_CACHED_BADGES 256 +#define MAX_CONCURRENT_DOWNLOADS 8 +#define MAX_QUEUED_DOWNLOADS 512 /***************************************************************************** * Badge cache entry @@ -33,6 +40,15 @@ typedef struct { SDL_Surface* surface_scaled; // Pre-scaled for notifications } BadgeCacheEntry; +/***************************************************************************** + * Download queue entry + *****************************************************************************/ + +typedef struct { + char badge_name[MAX_BADGE_NAME]; + bool locked; +} QueuedDownload; + /***************************************************************************** * Static state *****************************************************************************/ @@ -43,6 +59,13 @@ static SDL_mutex* badge_mutex = NULL; static int pending_downloads = 0; static bool initialized = false; +// Download queue for rate limiting +static QueuedDownload download_queue[MAX_QUEUED_DOWNLOADS]; +static int queue_head = 0; +static int queue_tail = 0; +static int queued_count = 0; +static int active_downloads = 0; + /***************************************************************************** * Internal helpers *****************************************************************************/ @@ -59,7 +82,7 @@ static BadgeCacheEntry* find_or_create_entry(const char* badge_name, bool locked // Create new entry if space available if (badge_cache_count >= MAX_CACHED_BADGES) { - BADGE_LOG("Cache full, cannot add badge %s\n", badge_name); + BADGE_LOG_WARN("Cache full, cannot add badge %s\n", badge_name); return NULL; } @@ -94,7 +117,7 @@ static bool cache_file_exists(const char* path) { static bool save_to_cache(const char* path, const char* data, size_t size) { FILE* f = fopen(path, "wb"); if (!f) { - BADGE_LOG("Failed to open cache file for writing: %s\n", path); + BADGE_LOG_ERROR("Failed to open cache file for writing: %s\n", path); return false; } @@ -102,7 +125,7 @@ static bool save_to_cache(const char* path, const char* data, size_t size) { fclose(f); if (written != size) { - BADGE_LOG("Failed to write cache file: %s\n", path); + BADGE_LOG_ERROR("Failed to write cache file: %s\n", path); unlink(path); return false; } @@ -114,7 +137,7 @@ static bool save_to_cache(const char* path, const char* data, size_t size) { static SDL_Surface* load_from_cache(const char* path) { SDL_Surface* surface = IMG_Load(path); if (!surface) { - BADGE_LOG("Failed to load badge image: %s - %s\n", path, IMG_GetError()); + BADGE_LOG_WARN("Failed to load badge image: %s - %s\n", path, IMG_GetError()); return NULL; } return surface; @@ -161,39 +184,123 @@ typedef struct { char cache_path[MAX_PATH]; } DownloadContext; +// Forward declarations +static void process_download_queue(void); +static void badge_download_callback(HTTP_Response* response, void* userdata); + +// Queue a download for later processing (must hold mutex) +static void queue_download(const char* badge_name, bool locked) { + if (queued_count >= MAX_QUEUED_DOWNLOADS) { + BADGE_LOG_WARN("Download queue full, dropping badge %s\n", badge_name); + return; + } + + QueuedDownload* item = &download_queue[queue_tail]; + strncpy(item->badge_name, badge_name, MAX_BADGE_NAME - 1); + item->badge_name[MAX_BADGE_NAME - 1] = '\0'; + item->locked = locked; + + queue_tail = (queue_tail + 1) % MAX_QUEUED_DOWNLOADS; + queued_count++; +} + +// Dequeue and start a download (must hold mutex) +static bool dequeue_and_start_download(void) { + if (queued_count == 0) return false; + + QueuedDownload* item = &download_queue[queue_head]; + queue_head = (queue_head + 1) % MAX_QUEUED_DOWNLOADS; + queued_count--; + + // Get entry and check if still needs download + BadgeCacheEntry* entry = find_or_create_entry(item->badge_name, item->locked); + if (!entry) return false; + + // Skip if already cached (might have been cached while queued) + if (entry->state == RA_BADGE_STATE_CACHED) { + return false; + } + + // Build URL and cache path + char url[512]; + char cache_path[MAX_PATH]; + RA_Badges_getUrl(item->badge_name, item->locked, url, sizeof(url)); + RA_Badges_getCachePath(item->badge_name, item->locked, cache_path, sizeof(cache_path)); + + // Check if already cached on disk + if (cache_file_exists(cache_path)) { + entry->state = RA_BADGE_STATE_CACHED; + return false; + } + + // Start download + DownloadContext* ctx = (DownloadContext*)malloc(sizeof(DownloadContext)); + if (!ctx) return false; + + strncpy(ctx->badge_name, item->badge_name, MAX_BADGE_NAME - 1); + ctx->badge_name[MAX_BADGE_NAME - 1] = '\0'; + ctx->locked = item->locked; + strncpy(ctx->cache_path, cache_path, MAX_PATH - 1); + ctx->cache_path[MAX_PATH - 1] = '\0'; + + entry->state = RA_BADGE_STATE_DOWNLOADING; + active_downloads++; + pending_downloads++; + + HTTP_getAsync(url, badge_download_callback, ctx); + return true; +} + +// Process queued downloads up to the concurrency limit (must hold mutex) +static void process_download_queue(void) { + while (active_downloads < MAX_CONCURRENT_DOWNLOADS && queued_count > 0) { + if (!dequeue_and_start_download()) { + // Item was skipped (already cached), try next + continue; + } + } +} + static void badge_download_callback(HTTP_Response* response, void* userdata) { DownloadContext* ctx = (DownloadContext*)userdata; + bool success = false; + + // Just save to disk - don't load into memory during prefetch + // Images will be loaded lazily when actually needed for display + if (response && response->data && response->http_status == 200 && !response->error) { + success = save_to_cache(ctx->cache_path, response->data, response->size); + if (!success) { + BADGE_LOG_WARN("Failed to save badge %s%s to cache\n", + ctx->badge_name, ctx->locked ? "_lock" : ""); + } + } else { + BADGE_LOG_WARN("Failed to download badge %s%s: %s\n", + ctx->badge_name, ctx->locked ? "_lock" : "", + response && response->error ? response->error : "HTTP error"); + } + + // Only hold mutex briefly to update state if (badge_mutex) SDL_LockMutex(badge_mutex); + active_downloads--; + if (active_downloads < 0) active_downloads = 0; + pending_downloads--; if (pending_downloads < 0) pending_downloads = 0; BadgeCacheEntry* entry = find_or_create_entry(ctx->badge_name, ctx->locked); + if (entry) { + // Mark as cached (on disk) - surfaces will be loaded lazily + entry->state = success ? RA_BADGE_STATE_CACHED : RA_BADGE_STATE_FAILED; + } - if (response && response->data && response->http_status == 200 && !response->error) { - // Save to cache - if (save_to_cache(ctx->cache_path, response->data, response->size)) { - // Load the image - if (entry) { - entry->surface = load_from_cache(ctx->cache_path); - if (entry->surface) { - entry->surface_scaled = scale_surface(entry->surface, RA_BADGE_NOTIFY_SIZE); - entry->state = RA_BADGE_STATE_CACHED; - } else { - entry->state = RA_BADGE_STATE_FAILED; - } - } - } else if (entry) { - entry->state = RA_BADGE_STATE_FAILED; - } - } else { - BADGE_LOG("Failed to download badge %s%s: %s\n", - ctx->badge_name, ctx->locked ? "_lock" : "", - response && response->error ? response->error : "HTTP error"); - if (entry) { - entry->state = RA_BADGE_STATE_FAILED; - } + // Start next queued download(s) + process_download_queue(); + + // Hide progress indicator when all downloads complete + if (pending_downloads == 0 && queued_count == 0) { + Notification_hideProgressIndicator(); } if (badge_mutex) SDL_UnlockMutex(badge_mutex); @@ -204,7 +311,7 @@ static void badge_download_callback(HTTP_Response* response, void* userdata) { free(ctx); } -// Start downloading a badge +// Request a badge download - queues if at concurrency limit static void start_download(const char* badge_name, bool locked) { if (!initialized) return; @@ -217,36 +324,16 @@ static void start_download(const char* badge_name, bool locked) { return; } - // Build URL and cache path - char url[512]; + // Check if already cached on disk char cache_path[MAX_PATH]; - RA_Badges_getUrl(badge_name, locked, url, sizeof(url)); RA_Badges_getCachePath(badge_name, locked, cache_path, sizeof(cache_path)); - - // Check if already cached on disk if (cache_file_exists(cache_path)) { - entry->surface = load_from_cache(cache_path); - if (entry->surface) { - entry->surface_scaled = scale_surface(entry->surface, RA_BADGE_NOTIFY_SIZE); - entry->state = RA_BADGE_STATE_CACHED; - return; - } + entry->state = RA_BADGE_STATE_CACHED; + return; } - // Need to download - DownloadContext* ctx = (DownloadContext*)malloc(sizeof(DownloadContext)); - if (!ctx) return; - - strncpy(ctx->badge_name, badge_name, MAX_BADGE_NAME - 1); - ctx->badge_name[MAX_BADGE_NAME - 1] = '\0'; - ctx->locked = locked; - strncpy(ctx->cache_path, cache_path, MAX_PATH - 1); - ctx->cache_path[MAX_PATH - 1] = '\0'; - - entry->state = RA_BADGE_STATE_DOWNLOADING; - pending_downloads++; - - HTTP_getAsync(url, badge_download_callback, ctx); + // Queue the download - state will be set when download actually starts + queue_download(badge_name, locked); } /***************************************************************************** @@ -259,6 +346,10 @@ void RA_Badges_init(void) { badge_mutex = SDL_CreateMutex(); badge_cache_count = 0; pending_downloads = 0; + queue_head = 0; + queue_tail = 0; + queued_count = 0; + active_downloads = 0; memset(badge_cache, 0, sizeof(badge_cache)); ensure_cache_dir(); @@ -306,12 +397,21 @@ void RA_Badges_prefetch(const char** badge_names, size_t count) { for (size_t i = 0; i < count; i++) { if (badge_names[i] && badge_names[i][0]) { - // Download both locked and unlocked versions + // Queue both locked and unlocked versions start_download(badge_names[i], false); start_download(badge_names[i], true); } } + // Show progress indicator if downloads were queued + if (queued_count > 0) { + Notification_setProgressIndicatorPersistent(true); + Notification_showProgressIndicator("Loading achievement badges...", "", NULL); + + // Start processing the queue (up to MAX_CONCURRENT_DOWNLOADS) + process_download_queue(); + } + if (badge_mutex) SDL_UnlockMutex(badge_mutex); } @@ -320,6 +420,7 @@ void RA_Badges_prefetchOne(const char* badge_name, bool locked) { if (badge_mutex) SDL_LockMutex(badge_mutex); start_download(badge_name, locked); + process_download_queue(); if (badge_mutex) SDL_UnlockMutex(badge_mutex); } @@ -332,7 +433,16 @@ SDL_Surface* RA_Badges_get(const char* badge_name, bool locked) { SDL_Surface* result = NULL; if (entry) { - if (entry->state == RA_BADGE_STATE_CACHED && entry->surface) { + if (entry->state == RA_BADGE_STATE_CACHED) { + // Lazy load from disk if not in memory + if (!entry->surface) { + char cache_path[MAX_PATH]; + RA_Badges_getCachePath(badge_name, locked, cache_path, sizeof(cache_path)); + entry->surface = load_from_cache(cache_path); + if (entry->surface) { + entry->surface_scaled = scale_surface(entry->surface, RA_BADGE_NOTIFY_SIZE); + } + } result = entry->surface; } else if (entry->state == RA_BADGE_STATE_UNKNOWN) { // Trigger download @@ -354,7 +464,16 @@ SDL_Surface* RA_Badges_getNotificationSize(const char* badge_name, bool locked) SDL_Surface* result = NULL; if (entry) { - if (entry->state == RA_BADGE_STATE_CACHED && entry->surface_scaled) { + if (entry->state == RA_BADGE_STATE_CACHED) { + // Lazy load from disk if not in memory + if (!entry->surface_scaled) { + char cache_path[MAX_PATH]; + RA_Badges_getCachePath(badge_name, locked, cache_path, sizeof(cache_path)); + entry->surface = load_from_cache(cache_path); + if (entry->surface) { + entry->surface_scaled = scale_surface(entry->surface, RA_BADGE_NOTIFY_SIZE); + } + } result = entry->surface_scaled; } else if (entry->state == RA_BADGE_STATE_UNKNOWN) { // Trigger download @@ -387,11 +506,6 @@ RA_BadgeState RA_Badges_getState(const char* badge_name, bool locked) { return state; } -bool RA_Badges_hasPendingDownloads(void) { - if (!initialized) return false; - return pending_downloads > 0; -} - void RA_Badges_getCachePath(const char* badge_name, bool locked, char* buffer, size_t buffer_size) { if (locked) { snprintf(buffer, buffer_size, "%s/%s_lock.png", diff --git a/workspace/all/common/ra_badges.h b/workspace/all/common/ra_badges.h index 344a46314..f97e01329 100644 --- a/workspace/all/common/ra_badges.h +++ b/workspace/all/common/ra_badges.h @@ -98,13 +98,6 @@ SDL_Surface* RA_Badges_getNotificationSize(const char* badge_name, bool locked); */ RA_BadgeState RA_Badges_getState(const char* badge_name, bool locked); -/** - * Check if the badge cache system has any pending downloads. - * - * @return true if downloads are in progress - */ -bool RA_Badges_hasPendingDownloads(void); - /** * Get the cache file path for a badge. * From d151f78717a2d1c538638e6be3bee03ee9a82d36 Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Thu, 5 Feb 2026 15:22:08 -0500 Subject: [PATCH 09/26] Remove obsolete FPS tracking code --- workspace/all/minarch/minarch.c | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/workspace/all/minarch/minarch.c b/workspace/all/minarch/minarch.c index 12269c784..0d510fe3f 100644 --- a/workspace/all/minarch/minarch.c +++ b/workspace/all/minarch/minarch.c @@ -107,13 +107,6 @@ static int DEVICE_HEIGHT = 0; static int DEVICE_PITCH = 0; static int shader_reset_suppressed = 0; -// FPS tracking variables -static int cpu_ticks = 0; -static int fps_ticks = 0; -static double fps_double = 0; -static double cpu_double = 0; -static uint32_t sec_start = 0; - GFX_Renderer renderer; /////////////////////////////////////// @@ -5735,8 +5728,6 @@ static void video_refresh_callback_main(const void *data, unsigned width, unsign return; } - fps_ticks += 1; - // if source has changed size (or forced by dst_p==0) // eg. true src + cropped src + fixed dst + cropped dst if (renderer.dst_p==0 || width!=renderer.true_w || height!=renderer.true_h) { @@ -8894,19 +8885,6 @@ static void limitFF(void) { last_time = now; } -static void trackFPS(void) { - cpu_ticks += 1; - uint32_t now = SDL_GetTicks(); - if (now - sec_start >= 1000) { - double last_time = (double)(now - sec_start) / 1000; - fps_double = fps_ticks / last_time; - cpu_double = cpu_ticks / last_time; - sec_start = now; - cpu_ticks = 0; - fps_ticks = 0; - } -} - static void Rewind_run_frame(void) { // if rewind is toggled, fast-forward toggle must stay off; fast-forward hold pauses rewind int do_rewind = (rewind_pressed || rewind_toggle) && !(rewind_toggle && ff_hold_active); @@ -9144,8 +9122,6 @@ int main(int argc , char* argv[]) { // Process RetroAchievements for this frame RA_doFrame(); - trackFPS(); - // Update and render notifications overlay Notification_update(SDL_GetTicks()); From dfc52a56259d5d69006f5655fa33a66f58154aa1 Mon Sep 17 00:00:00 2001 From: Clint Beacock Date: Thu, 5 Feb 2026 15:28:49 -0500 Subject: [PATCH 10/26] Update workspace/all/common/config.c Co-authored-by: frysee --- workspace/all/common/config.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workspace/all/common/config.c b/workspace/all/common/config.c index f19843dda..621709193 100644 --- a/workspace/all/common/config.c +++ b/workspace/all/common/config.c @@ -870,7 +870,7 @@ int CFG_getNotifyDuration(void) void CFG_setNotifyDuration(int seconds) { - settings.notifyDuration = (seconds < 1) ? 1 : (seconds > 3) ? 3 : seconds; + settings.notifyDuration = clamp(seconds, 1, 3); CFG_sync(); } From 616e104f87d784a80a38751c4c83ff3c243a2080 Mon Sep 17 00:00:00 2001 From: Clint Beacock Date: Thu, 5 Feb 2026 15:31:22 -0500 Subject: [PATCH 11/26] Update workspace/all/common/config.c Co-authored-by: frysee --- workspace/all/common/config.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workspace/all/common/config.c b/workspace/all/common/config.c index 621709193..5b7ff6e02 100644 --- a/workspace/all/common/config.c +++ b/workspace/all/common/config.c @@ -973,7 +973,7 @@ int CFG_getRANotificationDuration(void) void CFG_setRANotificationDuration(int seconds) { - settings.raNotificationDuration = (seconds < 1) ? 1 : (seconds > 5) ? 5 : seconds; + settings.raNotificationDuration = clamp(seconds, 1, 5); CFG_sync(); } From 89b3d8bde11de50af9d172bbb21c04503e3cc49c Mon Sep 17 00:00:00 2001 From: Clint Beacock Date: Thu, 5 Feb 2026 15:31:45 -0500 Subject: [PATCH 12/26] Update workspace/all/common/config.c Co-authored-by: frysee --- workspace/all/common/config.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workspace/all/common/config.c b/workspace/all/common/config.c index 5b7ff6e02..fb8c5c657 100644 --- a/workspace/all/common/config.c +++ b/workspace/all/common/config.c @@ -984,7 +984,7 @@ int CFG_getRAProgressNotificationDuration(void) void CFG_setRAProgressNotificationDuration(int seconds) { - settings.raProgressNotificationDuration = (seconds < 0) ? 0 : (seconds > 5) ? 5 : seconds; + settings.raProgressNotificationDuration = clamp(seconds, 0, 5); CFG_sync(); } From fbde380290d72e8d607c3cb7592bac8d02fc5872 Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Thu, 5 Feb 2026 16:29:21 -0500 Subject: [PATCH 13/26] Refactor RA code for readability per PR feedback - Group related static variables into structs: - ra_integration.c: RAPendingLoad and RALoginRetry structs - notification.c: ProgressIndicatorState struct (renamed to progress_state) - ra_badges.c: DownloadQueueState struct - Extract PLAT_initNotificationTexture() from PLAT_initShaders() - Add rcheevos integration guide references to header files: - ra_integration.h, ra_auth.h, ra_badges.h - Simplify/clarify comments in http.c and ra_auth.c --- workspace/all/common/api.h | 1 + workspace/all/common/generic_video.c | 2 + workspace/all/common/http.c | 3 + workspace/all/common/notification.c | 88 ++++++++++++----------- workspace/all/common/ra_auth.c | 4 +- workspace/all/common/ra_auth.h | 8 +-- workspace/all/common/ra_badges.c | 50 +++++++------ workspace/all/common/ra_badges.h | 12 +--- workspace/all/minarch/minarch.c | 1 + workspace/all/minarch/ra_integration.c | 99 ++++++++++++++------------ workspace/all/minarch/ra_integration.h | 11 +-- 11 files changed, 140 insertions(+), 139 deletions(-) diff --git a/workspace/all/common/api.h b/workspace/all/common/api.h index 79a87d5aa..836671da9 100644 --- a/workspace/all/common/api.h +++ b/workspace/all/common/api.h @@ -666,6 +666,7 @@ void PLAT_resetShaders(); void PLAT_clearShaders(); void PLAT_updateShader(int i, const char *filename, int *scale, int *filter, int *scaletype, int *inputtype); void PLAT_initShaders(); +void PLAT_initNotificationTexture(void); ShaderParam* PLAT_getShaderPragmas(int i); int PLAT_supportsOverscan(void); diff --git a/workspace/all/common/generic_video.c b/workspace/all/common/generic_video.c index c84edcb76..85a94abc2 100644 --- a/workspace/all/common/generic_video.c +++ b/workspace/all/common/generic_video.c @@ -451,7 +451,9 @@ void PLAT_initShaders() { g_noshader = link_program(vertex, fragment,"noshader.glsl"); LOG_info("default shaders loaded, %i\n\n",g_shader_default); +} +void PLAT_initNotificationTexture(void) { // Pre-allocate notification texture to avoid frame skip on first notification glGenTextures(1, ¬ification_tex); glBindTexture(GL_TEXTURE_2D, notification_tex); diff --git a/workspace/all/common/http.c b/workspace/all/common/http.c index e24131b40..16397f0b9 100644 --- a/workspace/all/common/http.c +++ b/workspace/all/common/http.c @@ -7,6 +7,9 @@ #include #include +// SDL is used here only for threading (SDL_CreateThread/SDL_DetachThread) +// to run HTTP requests asynchronously without blocking the main loop. +// HTTP communication itself uses curl via popen(). #include "sdl.h" // Build version info (defined in makefile) diff --git a/workspace/all/common/notification.c b/workspace/all/common/notification.c index bfb5105b1..ecacf0f29 100644 --- a/workspace/all/common/notification.c +++ b/workspace/all/common/notification.c @@ -44,13 +44,17 @@ static int last_system_indicator_type = SYSTEM_INDICATOR_NONE; #define PROGRESS_TITLE_MAX 48 #define PROGRESS_STRING_MAX 16 -static char progress_indicator_title[PROGRESS_TITLE_MAX]; -static char progress_indicator_progress[PROGRESS_STRING_MAX]; -static SDL_Surface* progress_indicator_icon = NULL; -static uint32_t progress_indicator_start_time = 0; -static int progress_indicator_active = 0; -static int progress_indicator_dirty = 0; -static int progress_indicator_persistent = 0; +typedef struct { + char title[PROGRESS_TITLE_MAX]; + char progress[PROGRESS_STRING_MAX]; + SDL_Surface* icon; + uint32_t start_time; + int active; + int dirty; + int persistent; +} ProgressIndicatorState; + +static ProgressIndicatorState progress_state = {0}; /////////////////////////////// // Rounded rectangle drawing @@ -188,12 +192,12 @@ void Notification_update(uint32_t now) { } // Update progress indicator timeout (skip if persistent) - if (progress_indicator_active && !progress_indicator_persistent) { - uint32_t elapsed = now - progress_indicator_start_time; + if (progress_state.active && !progress_state.persistent) { + uint32_t elapsed = now - progress_state.start_time; int duration_seconds = CFG_getRAProgressNotificationDuration(); if (duration_seconds > 0 && elapsed >= (uint32_t)(duration_seconds * 1000)) { - progress_indicator_active = 0; - progress_indicator_dirty = 1; + progress_state.active = 0; + progress_state.dirty = 1; } } @@ -229,7 +233,7 @@ void Notification_renderToLayer(int layer) { int has_notifications = notification_count > 0; int has_system_indicator = system_indicator_type != SYSTEM_INDICATOR_NONE; - int has_progress_indicator = progress_indicator_active; + int has_progress_indicator = progress_state.active; if (!has_notifications && !has_system_indicator && !has_progress_indicator) { // When all notifications and indicators are gone, render one final transparent frame @@ -240,7 +244,7 @@ void Notification_renderToLayer(int layer) { needs_clear_frame = 0; render_dirty = 0; system_indicator_dirty = 0; - progress_indicator_dirty = 0; + progress_state.dirty = 0; last_system_indicator_type = SYSTEM_INDICATOR_NONE; return; } @@ -257,7 +261,7 @@ void Notification_renderToLayer(int layer) { // Check if anything changed int notifications_changed = render_dirty || notification_count != last_notification_count; int indicator_changed = system_indicator_dirty || system_indicator_type != last_system_indicator_type; - int progress_changed = progress_indicator_dirty; + int progress_changed = progress_state.dirty; if (!notifications_changed && !indicator_changed && !progress_changed) { // Nothing changed, just keep the existing surface @@ -318,12 +322,12 @@ void Notification_renderToLayer(int layer) { // Format: "Title: Progress" (e.g., "Coin Collector: 50/100") // Or just "Title" if progress is empty char progress_text[PROGRESS_TITLE_MAX + PROGRESS_STRING_MAX + 4]; - if (progress_indicator_progress[0] != '\0') { + if (progress_state.progress[0] != '\0') { snprintf(progress_text, sizeof(progress_text), "%s: %s", - progress_indicator_title, progress_indicator_progress); + progress_state.title, progress_state.progress); } else { snprintf(progress_text, sizeof(progress_text), "%s", - progress_indicator_title); + progress_state.title); } // Calculate text size using tiny font @@ -334,9 +338,9 @@ void Notification_renderToLayer(int layer) { int icon_w = 0; int icon_h = 0; int icon_total_w = 0; - if (progress_indicator_icon) { + if (progress_state.icon) { icon_h = text_h; // Match text height - icon_w = (progress_indicator_icon->w * icon_h) / progress_indicator_icon->h; + icon_w = (progress_state.icon->w * icon_h) / progress_state.icon->h; icon_total_w = icon_w + notif_icon_gap; } @@ -365,10 +369,10 @@ void Notification_renderToLayer(int layer) { int content_x = notif_padding_x; // Draw icon if present - if (progress_indicator_icon && icon_w > 0 && icon_h > 0) { + if (progress_state.icon && icon_w > 0 && icon_h > 0) { SDL_Rect icon_dst = {content_x, notif_padding_y, icon_w, icon_h}; - SDL_SetSurfaceBlendMode(progress_indicator_icon, SDL_BLENDMODE_BLEND); - SDL_BlitScaled(progress_indicator_icon, NULL, progress_surface, &icon_dst); + SDL_SetSurfaceBlendMode(progress_state.icon, SDL_BLENDMODE_BLEND); + SDL_BlitScaled(progress_state.icon, NULL, progress_surface, &icon_dst); content_x += icon_total_w; } @@ -483,7 +487,7 @@ void Notification_renderToLayer(int layer) { render_dirty = 0; last_notification_count = notification_count; system_indicator_dirty = 0; - progress_indicator_dirty = 0; + progress_state.dirty = 0; last_system_indicator_type = system_indicator_type; } @@ -493,10 +497,10 @@ bool Notification_isActive(void) { void Notification_clear(void) { notification_count = 0; - progress_indicator_active = 0; - progress_indicator_icon = NULL; + progress_state.active = 0; + progress_state.icon = NULL; render_dirty = 1; - progress_indicator_dirty = 1; + progress_state.dirty = 1; PLAT_clearNotificationSurface(); if (gl_notification_surface) { SDL_FreeSurface(gl_notification_surface); @@ -507,7 +511,7 @@ void Notification_clear(void) { void Notification_quit(void) { Notification_clear(); system_indicator_type = SYSTEM_INDICATOR_NONE; - progress_indicator_active = 0; + progress_state.active = 0; initialized = 0; } @@ -547,36 +551,36 @@ void Notification_showProgressIndicator(const char* title, const char* progress, if (!CFG_getRAShowNotifications()) return; // Copy the title and progress strings - strncpy(progress_indicator_title, title, PROGRESS_TITLE_MAX - 1); - progress_indicator_title[PROGRESS_TITLE_MAX - 1] = '\0'; + strncpy(progress_state.title, title, PROGRESS_TITLE_MAX - 1); + progress_state.title[PROGRESS_TITLE_MAX - 1] = '\0'; - strncpy(progress_indicator_progress, progress, PROGRESS_STRING_MAX - 1); - progress_indicator_progress[PROGRESS_STRING_MAX - 1] = '\0'; + strncpy(progress_state.progress, progress, PROGRESS_STRING_MAX - 1); + progress_state.progress[PROGRESS_STRING_MAX - 1] = '\0'; // Store icon reference (caller retains ownership) - progress_indicator_icon = icon; + progress_state.icon = icon; // Activate and reset timer - progress_indicator_active = 1; - progress_indicator_start_time = SDL_GetTicks(); - progress_indicator_dirty = 1; + progress_state.active = 1; + progress_state.start_time = SDL_GetTicks(); + progress_state.dirty = 1; } void Notification_hideProgressIndicator(void) { if (!initialized) return; - if (progress_indicator_active) { - progress_indicator_active = 0; - progress_indicator_persistent = 0; - progress_indicator_icon = NULL; - progress_indicator_dirty = 1; + if (progress_state.active) { + progress_state.active = 0; + progress_state.persistent = 0; + progress_state.icon = NULL; + progress_state.dirty = 1; } } void Notification_setProgressIndicatorPersistent(bool persistent) { - progress_indicator_persistent = persistent ? 1 : 0; + progress_state.persistent = persistent ? 1 : 0; } bool Notification_hasProgressIndicator(void) { - return initialized && progress_indicator_active; + return initialized && progress_state.active; } diff --git a/workspace/all/common/ra_auth.c b/workspace/all/common/ra_auth.c index c687afdd1..862f86ba4 100644 --- a/workspace/all/common/ra_auth.c +++ b/workspace/all/common/ra_auth.c @@ -9,9 +9,7 @@ // RetroAchievements API endpoints #define RA_API_URL "https://retroachievements.org/dorequest.php" -// Simple JSON parsing helpers (minimal implementation for RA responses) -// RA login response format: {"Success":true,"User":"username","Token":"token","Score":0,...} -// RA error format: {"Success":false,"Error":"error message"} +// Minimal JSON helpers for RA login responses static const char* find_json_string(const char* json, const char* key, char* out, size_t out_size) { if (!json || !key || !out || out_size == 0) return NULL; diff --git a/workspace/all/common/ra_auth.h b/workspace/all/common/ra_auth.h index e6d88e3e0..90ae88267 100644 --- a/workspace/all/common/ra_auth.h +++ b/workspace/all/common/ra_auth.h @@ -1,12 +1,8 @@ #ifndef __RA_AUTH_H__ #define __RA_AUTH_H__ -/** - * RetroAchievements Authentication Helper - * - * Provides functions to authenticate with RetroAchievements servers - * and manage API tokens. - */ +// Handles RA login outside of rc_client for use before rc_client initialization. +// See: https://github.com/RetroAchievements/rcheevos/wiki/rc_client-integration#login /** * Authentication result codes diff --git a/workspace/all/common/ra_badges.c b/workspace/all/common/ra_badges.c index 384aef6a3..fb05a0d2c 100644 --- a/workspace/all/common/ra_badges.c +++ b/workspace/all/common/ra_badges.c @@ -60,11 +60,15 @@ static int pending_downloads = 0; static bool initialized = false; // Download queue for rate limiting -static QueuedDownload download_queue[MAX_QUEUED_DOWNLOADS]; -static int queue_head = 0; -static int queue_tail = 0; -static int queued_count = 0; -static int active_downloads = 0; +typedef struct { + QueuedDownload items[MAX_QUEUED_DOWNLOADS]; + int head; + int tail; + int count; + int active; +} DownloadQueueState; + +static DownloadQueueState download_queue = {0}; /***************************************************************************** * Internal helpers @@ -190,27 +194,27 @@ static void badge_download_callback(HTTP_Response* response, void* userdata); // Queue a download for later processing (must hold mutex) static void queue_download(const char* badge_name, bool locked) { - if (queued_count >= MAX_QUEUED_DOWNLOADS) { + if (download_queue.count >= MAX_QUEUED_DOWNLOADS) { BADGE_LOG_WARN("Download queue full, dropping badge %s\n", badge_name); return; } - QueuedDownload* item = &download_queue[queue_tail]; + QueuedDownload* item = &download_queue.items[download_queue.tail]; strncpy(item->badge_name, badge_name, MAX_BADGE_NAME - 1); item->badge_name[MAX_BADGE_NAME - 1] = '\0'; item->locked = locked; - queue_tail = (queue_tail + 1) % MAX_QUEUED_DOWNLOADS; - queued_count++; + download_queue.tail = (download_queue.tail + 1) % MAX_QUEUED_DOWNLOADS; + download_queue.count++; } // Dequeue and start a download (must hold mutex) static bool dequeue_and_start_download(void) { - if (queued_count == 0) return false; + if (download_queue.count == 0) return false; - QueuedDownload* item = &download_queue[queue_head]; - queue_head = (queue_head + 1) % MAX_QUEUED_DOWNLOADS; - queued_count--; + QueuedDownload* item = &download_queue.items[download_queue.head]; + download_queue.head = (download_queue.head + 1) % MAX_QUEUED_DOWNLOADS; + download_queue.count--; // Get entry and check if still needs download BadgeCacheEntry* entry = find_or_create_entry(item->badge_name, item->locked); @@ -244,7 +248,7 @@ static bool dequeue_and_start_download(void) { ctx->cache_path[MAX_PATH - 1] = '\0'; entry->state = RA_BADGE_STATE_DOWNLOADING; - active_downloads++; + download_queue.active++; pending_downloads++; HTTP_getAsync(url, badge_download_callback, ctx); @@ -253,7 +257,7 @@ static bool dequeue_and_start_download(void) { // Process queued downloads up to the concurrency limit (must hold mutex) static void process_download_queue(void) { - while (active_downloads < MAX_CONCURRENT_DOWNLOADS && queued_count > 0) { + while (download_queue.active < MAX_CONCURRENT_DOWNLOADS && download_queue.count > 0) { if (!dequeue_and_start_download()) { // Item was skipped (already cached), try next continue; @@ -283,8 +287,8 @@ static void badge_download_callback(HTTP_Response* response, void* userdata) { // Only hold mutex briefly to update state if (badge_mutex) SDL_LockMutex(badge_mutex); - active_downloads--; - if (active_downloads < 0) active_downloads = 0; + download_queue.active--; + if (download_queue.active < 0) download_queue.active = 0; pending_downloads--; if (pending_downloads < 0) pending_downloads = 0; @@ -299,7 +303,7 @@ static void badge_download_callback(HTTP_Response* response, void* userdata) { process_download_queue(); // Hide progress indicator when all downloads complete - if (pending_downloads == 0 && queued_count == 0) { + if (pending_downloads == 0 && download_queue.count == 0) { Notification_hideProgressIndicator(); } @@ -346,10 +350,10 @@ void RA_Badges_init(void) { badge_mutex = SDL_CreateMutex(); badge_cache_count = 0; pending_downloads = 0; - queue_head = 0; - queue_tail = 0; - queued_count = 0; - active_downloads = 0; + download_queue.head = 0; + download_queue.tail = 0; + download_queue.count = 0; + download_queue.active = 0; memset(badge_cache, 0, sizeof(badge_cache)); ensure_cache_dir(); @@ -404,7 +408,7 @@ void RA_Badges_prefetch(const char** badge_names, size_t count) { } // Show progress indicator if downloads were queued - if (queued_count > 0) { + if (download_queue.count > 0) { Notification_setProgressIndicatorPersistent(true); Notification_showProgressIndicator("Loading achievement badges...", "", NULL); diff --git a/workspace/all/common/ra_badges.h b/workspace/all/common/ra_badges.h index f97e01329..ba93d55a8 100644 --- a/workspace/all/common/ra_badges.h +++ b/workspace/all/common/ra_badges.h @@ -5,16 +5,8 @@ #include #include -/** - * RetroAchievements Badge Cache System - * - * Downloads and caches achievement badge images for display in notifications - * and the achievements list. - * - * Cache location: SHARED_USERDATA_PATH/.ra/badges/{badge_name}.png - * Badge URLs: https://media.retroachievements.org/Badge/{badge_name}.png - * Locked badges: https://media.retroachievements.org/Badge/{badge_name}_lock.png - */ +// Implements the badge download/caching that the integration guide leaves to the emulator. +// See: https://github.com/RetroAchievements/rcheevos/wiki/rc_client-integration#showing-the-game-placard // Badge size for notifications (will be scaled) #define RA_BADGE_SIZE 64 diff --git a/workspace/all/minarch/minarch.c b/workspace/all/minarch/minarch.c index 0d510fe3f..8fd0bb250 100644 --- a/workspace/all/minarch/minarch.c +++ b/workspace/all/minarch/minarch.c @@ -9021,6 +9021,7 @@ int main(int argc , char* argv[]) { // initialize default shaders GFX_initShaders(); + PLAT_initNotificationTexture(); PAD_init(); DEVICE_WIDTH = screen->w; diff --git a/workspace/all/minarch/ra_integration.c b/workspace/all/minarch/ra_integration.c index 1e7360b79..ddf503723 100644 --- a/workspace/all/minarch/ra_integration.c +++ b/workspace/all/minarch/ra_integration.c @@ -57,18 +57,26 @@ static bool ra_memory_regions_initialized = false; // Pending game load storage (for async login race condition) #define RA_MAX_PATH 512 -static char pending_rom_path[RA_MAX_PATH]; -static uint8_t* pending_rom_data = NULL; -static size_t pending_rom_size = 0; -static char pending_emu_tag[16]; -static bool pending_game_load = false; +typedef struct { + char rom_path[RA_MAX_PATH]; + uint8_t* rom_data; + size_t rom_size; + char emu_tag[16]; + bool active; +} RAPendingLoad; + +static RAPendingLoad ra_pending_load = {0}; // Login retry state -static int ra_login_retry_count = 0; #define RA_LOGIN_MAX_RETRIES 5 -static uint32_t ra_login_retry_time = 0; // SDL_GetTicks() timestamp for next retry -static bool ra_login_retry_pending = false; -static bool ra_login_notified_connecting = false; // Track if we showed "Connecting..." notification +typedef struct { + int count; + uint32_t next_time; // SDL_GetTicks() timestamp for next retry + bool pending; + bool notified_connecting; // Track if we showed "Connecting..." notification +} RALoginRetry; + +static RALoginRetry ra_login_retry = {0}; // Wifi wait config #define RA_WIFI_WAIT_MAX_MS 3000 // 3 seconds max blocking wait @@ -264,10 +272,10 @@ static uint32_t ra_get_retry_delay_ms(int attempt) { * Helper: Reset login retry state *****************************************************************************/ static void ra_reset_login_state(void) { - ra_login_retry_count = 0; - ra_login_retry_pending = false; - ra_login_retry_time = 0; - ra_login_notified_connecting = false; + ra_login_retry.count = 0; + ra_login_retry.pending = false; + ra_login_retry.next_time = 0; + ra_login_retry.notified_connecting = false; } /***************************************************************************** @@ -275,7 +283,7 @@ static void ra_reset_login_state(void) { *****************************************************************************/ static void ra_start_login(void) { RA_LOG_DEBUG("Attempting login (attempt %d/%d)...\n", - ra_login_retry_count + 1, RA_LOGIN_MAX_RETRIES); + ra_login_retry.count + 1, RA_LOGIN_MAX_RETRIES); rc_client_begin_login_with_token(ra_client, CFG_getRAUsername(), CFG_getRAToken(), ra_login_callback, NULL); @@ -791,9 +799,10 @@ static void ra_login_callback(int result, const char* error_message, user ? user->score : 0); // Check if we have a pending game to load - if (pending_game_load) { - RA_LOG_DEBUG("Processing deferred game load: %s\n", pending_rom_path); - ra_do_load_game(pending_rom_path, pending_rom_data, pending_rom_size, pending_emu_tag); + if (ra_pending_load.active) { + RA_LOG_DEBUG("Processing deferred game load: %s\n", ra_pending_load.rom_path); + ra_do_load_game(ra_pending_load.rom_path, ra_pending_load.rom_data, + ra_pending_load.rom_size, ra_pending_load.emu_tag); ra_clear_pending_game(); } } else { @@ -801,19 +810,19 @@ static void ra_login_callback(int result, const char* error_message, ra_logged_in = false; RA_LOG_ERROR("Login failed: %s\n", error_message ? error_message : "unknown error"); - if (ra_login_retry_count < RA_LOGIN_MAX_RETRIES) { + if (ra_login_retry.count < RA_LOGIN_MAX_RETRIES) { // Schedule retry - uint32_t delay = ra_get_retry_delay_ms(ra_login_retry_count); - ra_login_retry_time = SDL_GetTicks() + delay; - ra_login_retry_pending = true; - ra_login_retry_count++; + uint32_t delay = ra_get_retry_delay_ms(ra_login_retry.count); + ra_login_retry.next_time = SDL_GetTicks() + delay; + ra_login_retry.pending = true; + ra_login_retry.count++; RA_LOG_DEBUG("Scheduling retry %d/%d in %ums\n", - ra_login_retry_count, RA_LOGIN_MAX_RETRIES, delay); + ra_login_retry.count, RA_LOGIN_MAX_RETRIES, delay); // Show "Connecting..." notification on first retry only - if (ra_login_retry_count == 1 && !ra_login_notified_connecting) { - ra_login_notified_connecting = true; + if (ra_login_retry.count == 1 && !ra_login_retry.notified_connecting) { + ra_login_retry.notified_connecting = true; Notification_push(NOTIFICATION_ACHIEVEMENT, "Connecting to RetroAchievements...", NULL); } @@ -1162,14 +1171,14 @@ void RA_initMemoryRegions(uint32_t console_id) { * Helper: Clear pending game data *****************************************************************************/ static void ra_clear_pending_game(void) { - if (pending_rom_data) { - free(pending_rom_data); - pending_rom_data = NULL; - } - pending_rom_size = 0; - pending_rom_path[0] = '\0'; - pending_emu_tag[0] = '\0'; - pending_game_load = false; + if (ra_pending_load.rom_data) { + free(ra_pending_load.rom_data); + ra_pending_load.rom_data = NULL; + } + ra_pending_load.rom_size = 0; + ra_pending_load.rom_path[0] = '\0'; + ra_pending_load.emu_tag[0] = '\0'; + ra_pending_load.active = false; } /***************************************************************************** @@ -1243,26 +1252,26 @@ void RA_loadGame(const char* rom_path, const uint8_t* rom_data, size_t rom_size, ra_clear_pending_game(); // Store the path - strncpy(pending_rom_path, rom_path, RA_MAX_PATH - 1); - pending_rom_path[RA_MAX_PATH - 1] = '\0'; + strncpy(ra_pending_load.rom_path, rom_path, RA_MAX_PATH - 1); + ra_pending_load.rom_path[RA_MAX_PATH - 1] = '\0'; // Store the emu tag - strncpy(pending_emu_tag, emu_tag, sizeof(pending_emu_tag) - 1); - pending_emu_tag[sizeof(pending_emu_tag) - 1] = '\0'; + strncpy(ra_pending_load.emu_tag, emu_tag, sizeof(ra_pending_load.emu_tag) - 1); + ra_pending_load.emu_tag[sizeof(ra_pending_load.emu_tag) - 1] = '\0'; // Copy ROM data if provided (some cores need it) if (rom_data && rom_size > 0) { - pending_rom_data = (uint8_t*)malloc(rom_size); - if (pending_rom_data) { - memcpy(pending_rom_data, rom_data, rom_size); - pending_rom_size = rom_size; + ra_pending_load.rom_data = (uint8_t*)malloc(rom_size); + if (ra_pending_load.rom_data) { + memcpy(ra_pending_load.rom_data, rom_data, rom_size); + ra_pending_load.rom_size = rom_size; } else { RA_LOG_WARN("Failed to allocate memory for pending ROM data\n"); - pending_rom_size = 0; + ra_pending_load.rom_size = 0; } } - pending_game_load = true; + ra_pending_load.active = true; return; } @@ -1323,8 +1332,8 @@ void RA_idle(void) { } // Check for pending login retry - if (ra_login_retry_pending && SDL_GetTicks() >= ra_login_retry_time) { - ra_login_retry_pending = false; + if (ra_login_retry.pending && SDL_GetTicks() >= ra_login_retry.next_time) { + ra_login_retry.pending = false; ra_start_login(); } diff --git a/workspace/all/minarch/ra_integration.h b/workspace/all/minarch/ra_integration.h index 83eaa94eb..9353a5c97 100644 --- a/workspace/all/minarch/ra_integration.h +++ b/workspace/all/minarch/ra_integration.h @@ -5,16 +5,7 @@ #include #include -/** - * RetroAchievements Integration for minarch - * - * This module provides the glue between minarch (libretro frontend) and - * rcheevos (RetroAchievements library). It handles: - * - rc_client initialization and lifecycle - * - Memory read callbacks for achievement checking - * - HTTP server callbacks for API communication - * - Event handling for achievement unlocks/notifications - */ +// See: https://github.com/RetroAchievements/rcheevos/wiki/rc_client-integration /** * Initialize the RetroAchievements client. From 19bb6397d46e1982d094d76f60820cd354cdf62b Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Thu, 5 Feb 2026 16:33:07 -0500 Subject: [PATCH 14/26] Extract magic numbers to named constants in notification.c Pull layout values (padding, margins, gaps) to #defines at top of file for better readability and maintainability. --- workspace/all/common/notification.c | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/workspace/all/common/notification.c b/workspace/all/common/notification.c index ecacf0f29..18602df98 100644 --- a/workspace/all/common/notification.c +++ b/workspace/all/common/notification.c @@ -5,6 +5,19 @@ #include #include +/////////////////////////////// +// Layout constants (unscaled) +/////////////////////////////// + +#define NOTIF_PADDING_X 8 // Horizontal padding inside pill +#define NOTIF_PADDING_Y 4 // Vertical padding inside pill +#define NOTIF_MARGIN 12 // Margin from screen edge +#define NOTIF_STACK_GAP 6 // Gap between stacked notifications +#define NOTIF_ICON_GAP 4 // Gap between icon and text + +// System indicator sizing (must match GFX_blitHardwareIndicator dimensions) +#define SYS_INDICATOR_EXTRA_PAD 4 // Extra padding for indicator pill + /////////////////////////////// // Internal state /////////////////////////////// @@ -129,11 +142,11 @@ void Notification_init(void) { memset(notifications, 0, sizeof(notifications)); // Initialize scaled visual constants (compact pills) - notif_padding_x = SCALE1(8); - notif_padding_y = SCALE1(4); - notif_margin = SCALE1(12); - notif_stack_gap = SCALE1(6); - notif_icon_gap = SCALE1(4); // Gap between icon and text + notif_padding_x = SCALE1(NOTIF_PADDING_X); + notif_padding_y = SCALE1(NOTIF_PADDING_Y); + notif_margin = SCALE1(NOTIF_MARGIN); + notif_stack_gap = SCALE1(NOTIF_STACK_GAP); + notif_icon_gap = SCALE1(NOTIF_ICON_GAP); // Store screen dimensions for layer rendering screen_width = FIXED_WIDTH; @@ -284,7 +297,7 @@ void Notification_renderToLayer(int layer) { // Render system indicator in top-right if active if (has_system_indicator) { // Calculate position: top-right corner with padding - int indicator_width = SCALE1(PILL_SIZE + SETTINGS_WIDTH + 10 + 4); + int indicator_width = SCALE1(PILL_SIZE + SETTINGS_WIDTH + PADDING + SYS_INDICATOR_EXTRA_PAD); int indicator_height = SCALE1(PILL_SIZE); int indicator_x = screen_width - SCALE1(PADDING) - indicator_width; int indicator_y = SCALE1(PADDING); @@ -537,7 +550,7 @@ int Notification_getSystemIndicatorWidth(void) { if (!initialized || system_indicator_type == SYSTEM_INDICATOR_NONE) { return 0; } - return SCALE1(PILL_SIZE + SETTINGS_WIDTH + 10 + 4); + return SCALE1(PILL_SIZE + SETTINGS_WIDTH + PADDING + SYS_INDICATOR_EXTRA_PAD); } /////////////////////////////// From 614218b190d09b9481f2d5acb49640b9a68416fa Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Thu, 5 Feb 2026 16:44:07 -0500 Subject: [PATCH 15/26] Break up Notification_renderToLayer() into smaller helpers Extract rendering logic into focused helper functions: - render_system_indicator() for hardware indicator (top-right) - render_progress_indicator() for progress pill (top-left) - render_notification_pill() for individual notification rendering - render_notification_stack() for stacked notifications (bottom-left) --- workspace/all/common/notification.c | 369 +++++++++++++--------------- 1 file changed, 175 insertions(+), 194 deletions(-) diff --git a/workspace/all/common/notification.c b/workspace/all/common/notification.c index 18602df98..64056c536 100644 --- a/workspace/all/common/notification.c +++ b/workspace/all/common/notification.c @@ -236,6 +236,177 @@ void Notification_update(uint32_t now) { static SDL_Surface* gl_notification_surface = NULL; static int needs_clear_frame = 0; +// Render system indicator (top-right) +static void render_system_indicator(void) { + int indicator_width = SCALE1(PILL_SIZE + SETTINGS_WIDTH + PADDING + SYS_INDICATOR_EXTRA_PAD); + int indicator_height = SCALE1(PILL_SIZE); + int indicator_x = screen_width - SCALE1(PADDING) - indicator_width; + int indicator_y = SCALE1(PADDING); + + // Create a temporary surface with the SAME format as gfx.screen + // This is critical because theme colors (THEME_COLOR2, etc.) were mapped + // using SDL_MapRGB(gfx.screen->format, ...), so they only work correctly + // on surfaces with that same pixel format. + SDL_Surface* indicator_surface = GFX_createScreenFormatSurface(indicator_width, indicator_height); + if (indicator_surface) { + SDL_FillRect(indicator_surface, NULL, 0); + GFX_blitHardwareIndicator(indicator_surface, 0, 0, (IndicatorType)system_indicator_type); + + // Convert to RGBA for the notification overlay + SDL_Surface* converted = SDL_ConvertSurfaceFormat(indicator_surface, SDL_PIXELFORMAT_ABGR8888, 0); + if (converted) { + SDL_SetSurfaceBlendMode(converted, SDL_BLENDMODE_NONE); + SDL_Rect dst_rect = {indicator_x, indicator_y, indicator_width, indicator_height}; + SDL_BlitSurface(converted, NULL, gl_notification_surface, &dst_rect); + SDL_FreeSurface(converted); + } + SDL_FreeSurface(indicator_surface); + } +} + +// Render progress indicator pill (top-left) +static void render_progress_indicator(void) { + SDL_Color text_color = uintToColour(THEME_COLOR1_255); + SDL_Color bg_color_sdl = uintToColour(THEME_COLOR2_255); + + // Format: "Title: Progress" or just "Title" + char progress_text[PROGRESS_TITLE_MAX + PROGRESS_STRING_MAX + 4]; + if (progress_state.progress[0] != '\0') { + snprintf(progress_text, sizeof(progress_text), "%s: %s", + progress_state.title, progress_state.progress); + } else { + snprintf(progress_text, sizeof(progress_text), "%s", progress_state.title); + } + + int text_w = 0, text_h = 0; + TTF_SizeUTF8(font.tiny, progress_text, &text_w, &text_h); + + // Calculate icon dimensions if present + int icon_w = 0, icon_h = 0, icon_total_w = 0; + if (progress_state.icon) { + icon_h = text_h; + icon_w = (progress_state.icon->w * icon_h) / progress_state.icon->h; + icon_total_w = icon_w + notif_icon_gap; + } + + int pill_w = icon_total_w + text_w + (notif_padding_x * 2); + int pill_h = text_h + (notif_padding_y * 2); + int corner_radius = pill_h / 2; + int x = notif_margin; + int y = notif_margin; + + SDL_Surface* progress_surface = SDL_CreateRGBSurfaceWithFormat( + 0, pill_w, pill_h, 32, SDL_PIXELFORMAT_ABGR8888 + ); + if (!progress_surface) return; + + SDL_FillRect(progress_surface, NULL, 0); + Uint32 bg_color = SDL_MapRGBA(progress_surface->format, + bg_color_sdl.r, bg_color_sdl.g, bg_color_sdl.b, 255); + draw_rounded_rect(progress_surface, 0, 0, pill_w, pill_h, corner_radius, bg_color); + + int content_x = notif_padding_x; + + if (progress_state.icon && icon_w > 0 && icon_h > 0) { + SDL_Rect icon_dst = {content_x, notif_padding_y, icon_w, icon_h}; + SDL_SetSurfaceBlendMode(progress_state.icon, SDL_BLENDMODE_BLEND); + SDL_BlitScaled(progress_state.icon, NULL, progress_surface, &icon_dst); + content_x += icon_total_w; + } + + SDL_Surface* text_surf = TTF_RenderUTF8_Blended(font.tiny, progress_text, text_color); + if (text_surf) { + SDL_SetSurfaceBlendMode(text_surf, SDL_BLENDMODE_BLEND); + SDL_Rect text_dst = {content_x, notif_padding_y, text_surf->w, text_surf->h}; + SDL_BlitSurface(text_surf, NULL, progress_surface, &text_dst); + SDL_FreeSurface(text_surf); + } + + SDL_SetSurfaceBlendMode(progress_surface, SDL_BLENDMODE_NONE); + SDL_Rect dst_rect = {x, y, pill_w, pill_h}; + SDL_BlitSurface(progress_surface, NULL, gl_notification_surface, &dst_rect); + SDL_FreeSurface(progress_surface); +} + +// Render a single notification pill +static void render_notification_pill(Notification* n, int x, int y, SDL_Color text_color, SDL_Color bg_color_sdl) { + int text_w = 0, text_h = 0; + TTF_SizeUTF8(font.tiny, n->message, &text_w, &text_h); + + int icon_w = 0, icon_h = 0, icon_total_w = 0; + if (n->icon) { + icon_h = text_h; + icon_w = (n->icon->w * icon_h) / n->icon->h; + icon_total_w = icon_w + notif_icon_gap; + } + + int pill_w = icon_total_w + text_w + (notif_padding_x * 2); + int pill_h = text_h + (notif_padding_y * 2); + int corner_radius = pill_h / 2; + + SDL_Surface* notif_surface = SDL_CreateRGBSurfaceWithFormat( + 0, pill_w, pill_h, 32, SDL_PIXELFORMAT_ABGR8888 + ); + if (!notif_surface) return; + + SDL_FillRect(notif_surface, NULL, 0); + Uint32 bg_color = SDL_MapRGBA(notif_surface->format, bg_color_sdl.r, bg_color_sdl.g, bg_color_sdl.b, 255); + draw_rounded_rect(notif_surface, 0, 0, pill_w, pill_h, corner_radius, bg_color); + + int content_x = notif_padding_x; + + if (n->icon && icon_w > 0 && icon_h > 0) { + SDL_Rect icon_dst = {content_x, notif_padding_y, icon_w, icon_h}; + SDL_SetSurfaceBlendMode(n->icon, SDL_BLENDMODE_BLEND); + SDL_BlitScaled(n->icon, NULL, notif_surface, &icon_dst); + content_x += icon_total_w; + } + + SDL_Surface* text_surf = TTF_RenderUTF8_Blended(font.tiny, n->message, text_color); + if (text_surf) { + SDL_SetSurfaceBlendMode(text_surf, SDL_BLENDMODE_BLEND); + SDL_Rect text_dst = {content_x, notif_padding_y, text_surf->w, text_surf->h}; + SDL_BlitSurface(text_surf, NULL, notif_surface, &text_dst); + SDL_FreeSurface(text_surf); + } + + SDL_SetSurfaceBlendMode(notif_surface, SDL_BLENDMODE_NONE); + SDL_Rect dst_rect = {x, y, pill_w, pill_h}; + SDL_BlitSurface(notif_surface, NULL, gl_notification_surface, &dst_rect); + SDL_FreeSurface(notif_surface); +} + +// Render notification stack (bottom-left, stacking upward) +static void render_notification_stack(void) { + SDL_Color text_color = uintToColour(THEME_COLOR1_255); + SDL_Color bg_color_sdl = uintToColour(THEME_COLOR2_255); + + int base_x = notif_margin; + int base_y = screen_height - notif_margin; + + for (int i = 0; i < notification_count; i++) { + Notification* n = ¬ifications[i]; + + int text_h = 0; + TTF_SizeUTF8(font.tiny, n->message, NULL, &text_h); + int pill_h = text_h + (notif_padding_y * 2); + + // Calculate stack offset (how far up from base) + int stack_offset = 0; + for (int j = i + 1; j < notification_count; j++) { + int other_text_h = 0; + TTF_SizeUTF8(font.tiny, notifications[j].message, NULL, &other_text_h); + int other_pill_h = other_text_h + (notif_padding_y * 2); + stack_offset += other_pill_h + notif_stack_gap; + } + + int x = base_x; + int y = base_y - pill_h - stack_offset; + + render_notification_pill(n, x, y, text_color, bg_color_sdl); + } +} + void Notification_renderToLayer(int layer) { (void)layer; // unused now, kept for API compatibility @@ -277,7 +448,6 @@ void Notification_renderToLayer(int layer) { int progress_changed = progress_state.dirty; if (!notifications_changed && !indicator_changed && !progress_changed) { - // Nothing changed, just keep the existing surface return; } @@ -294,204 +464,15 @@ void Notification_renderToLayer(int layer) { // Clear to transparent SDL_FillRect(gl_notification_surface, NULL, 0); - // Render system indicator in top-right if active + // Render each element type if (has_system_indicator) { - // Calculate position: top-right corner with padding - int indicator_width = SCALE1(PILL_SIZE + SETTINGS_WIDTH + PADDING + SYS_INDICATOR_EXTRA_PAD); - int indicator_height = SCALE1(PILL_SIZE); - int indicator_x = screen_width - SCALE1(PADDING) - indicator_width; - int indicator_y = SCALE1(PADDING); - - // Create a temporary surface with the SAME format as gfx.screen - // This is critical because theme colors (THEME_COLOR2, etc.) were mapped - // using SDL_MapRGB(gfx.screen->format, ...), so they only work correctly - // on surfaces with that same pixel format. - SDL_Surface* indicator_surface = GFX_createScreenFormatSurface(indicator_width, indicator_height); - if (indicator_surface) { - // Clear to transparent/black - SDL_FillRect(indicator_surface, NULL, 0); - - // Render the indicator at (0,0) on the temp surface - GFX_blitHardwareIndicator(indicator_surface, 0, 0, (IndicatorType)system_indicator_type); - - // Convert to RGBA for the notification overlay - SDL_Surface* converted = SDL_ConvertSurfaceFormat(indicator_surface, SDL_PIXELFORMAT_ABGR8888, 0); - if (converted) { - SDL_SetSurfaceBlendMode(converted, SDL_BLENDMODE_NONE); - SDL_Rect dst_rect = {indicator_x, indicator_y, indicator_width, indicator_height}; - SDL_BlitSurface(converted, NULL, gl_notification_surface, &dst_rect); - SDL_FreeSurface(converted); - } - SDL_FreeSurface(indicator_surface); - } + render_system_indicator(); } - - // Render progress indicator in top-left if active if (has_progress_indicator) { - // Get theme colors - SDL_Color text_color = uintToColour(THEME_COLOR1_255); // Main Color - SDL_Color bg_color_sdl = uintToColour(THEME_COLOR2_255); // Primary Accent Color - - // Format: "Title: Progress" (e.g., "Coin Collector: 50/100") - // Or just "Title" if progress is empty - char progress_text[PROGRESS_TITLE_MAX + PROGRESS_STRING_MAX + 4]; - if (progress_state.progress[0] != '\0') { - snprintf(progress_text, sizeof(progress_text), "%s: %s", - progress_state.title, progress_state.progress); - } else { - snprintf(progress_text, sizeof(progress_text), "%s", - progress_state.title); - } - - // Calculate text size using tiny font - int text_w = 0, text_h = 0; - TTF_SizeUTF8(font.tiny, progress_text, &text_w, &text_h); - - // Calculate icon dimensions if present - int icon_w = 0; - int icon_h = 0; - int icon_total_w = 0; - if (progress_state.icon) { - icon_h = text_h; // Match text height - icon_w = (progress_state.icon->w * icon_h) / progress_state.icon->h; - icon_total_w = icon_w + notif_icon_gap; - } - - // Calculate pill dimensions - int pill_w = icon_total_w + text_w + (notif_padding_x * 2); - int pill_h = text_h + (notif_padding_y * 2); - int corner_radius = pill_h / 2; - - // Position: top-left corner with padding - int x = notif_margin; - int y = notif_margin; - - // Create temporary surface for the progress pill - SDL_Surface* progress_surface = SDL_CreateRGBSurfaceWithFormat( - 0, pill_w, pill_h, 32, SDL_PIXELFORMAT_ABGR8888 - ); - if (progress_surface) { - // Clear to transparent - SDL_FillRect(progress_surface, NULL, 0); - - // Draw rounded pill background - Uint32 bg_color = SDL_MapRGBA(progress_surface->format, - bg_color_sdl.r, bg_color_sdl.g, bg_color_sdl.b, 255); - draw_rounded_rect(progress_surface, 0, 0, pill_w, pill_h, corner_radius, bg_color); - - int content_x = notif_padding_x; - - // Draw icon if present - if (progress_state.icon && icon_w > 0 && icon_h > 0) { - SDL_Rect icon_dst = {content_x, notif_padding_y, icon_w, icon_h}; - SDL_SetSurfaceBlendMode(progress_state.icon, SDL_BLENDMODE_BLEND); - SDL_BlitScaled(progress_state.icon, NULL, progress_surface, &icon_dst); - content_x += icon_total_w; - } - - // Draw text - SDL_Surface* text_surf = TTF_RenderUTF8_Blended(font.tiny, progress_text, text_color); - if (text_surf) { - SDL_SetSurfaceBlendMode(text_surf, SDL_BLENDMODE_BLEND); - SDL_Rect text_dst = {content_x, notif_padding_y, text_surf->w, text_surf->h}; - SDL_BlitSurface(text_surf, NULL, progress_surface, &text_dst); - SDL_FreeSurface(text_surf); - } - - // Blit to the full notification surface - SDL_SetSurfaceBlendMode(progress_surface, SDL_BLENDMODE_NONE); - SDL_Rect dst_rect = {x, y, pill_w, pill_h}; - SDL_BlitSurface(progress_surface, NULL, gl_notification_surface, &dst_rect); - - SDL_FreeSurface(progress_surface); - } + render_progress_indicator(); } - - // Render text notifications (bottom-left by default) if (has_notifications) { - // Get theme colors - SDL_Color text_color = uintToColour(THEME_COLOR1_255); // Main Color - SDL_Color bg_color_sdl = uintToColour(THEME_COLOR2_255); // Primary Accent Color - - int base_x = notif_margin; - int base_y = screen_height - notif_margin; - - for (int i = 0; i < notification_count; i++) { - Notification* n = ¬ifications[i]; - - // Calculate text size using tiny font - int text_w = 0, text_h = 0; - TTF_SizeUTF8(font.tiny, n->message, &text_w, &text_h); - - // Calculate icon dimensions if present - int icon_w = 0; - int icon_h = 0; - int icon_total_w = 0; // icon width + gap - if (n->icon) { - // Scale icon to fit notification height - icon_h = text_h; // Match text height - icon_w = (n->icon->w * icon_h) / n->icon->h; - icon_total_w = icon_w + notif_icon_gap; - } - - // Calculate pill dimensions (icon + text) - int pill_w = icon_total_w + text_w + (notif_padding_x * 2); - int pill_h = text_h + (notif_padding_y * 2); - int corner_radius = pill_h / 2; // Fully rounded ends (pill shape) - - // Position: stack upward from bottom - int stack_offset = 0; - for (int j = i + 1; j < notification_count; j++) { - int other_text_h = 0; - TTF_SizeUTF8(font.tiny, notifications[j].message, NULL, &other_text_h); - int other_icon_h = notifications[j].icon ? other_text_h : 0; - int other_pill_h = (other_text_h > other_icon_h ? other_text_h : other_icon_h) + (notif_padding_y * 2); - stack_offset += other_pill_h + notif_stack_gap; - } - - int x = base_x; - int y = base_y - pill_h - stack_offset; - - // Create temporary surface for this notification pill - SDL_Surface* notif_surface = SDL_CreateRGBSurfaceWithFormat( - 0, pill_w, pill_h, 32, SDL_PIXELFORMAT_ABGR8888 - ); - if (!notif_surface) continue; - - // Clear to transparent first - SDL_FillRect(notif_surface, NULL, 0); - - // Draw rounded pill background with accent color (fully opaque) - Uint32 bg_color = SDL_MapRGBA(notif_surface->format, bg_color_sdl.r, bg_color_sdl.g, bg_color_sdl.b, 255); - draw_rounded_rect(notif_surface, 0, 0, pill_w, pill_h, corner_radius, bg_color); - - int content_x = notif_padding_x; - - // Draw icon if present - if (n->icon && icon_w > 0 && icon_h > 0) { - // Scale and blit icon - SDL_Rect icon_dst = {content_x, notif_padding_y, icon_w, icon_h}; - SDL_SetSurfaceBlendMode(n->icon, SDL_BLENDMODE_BLEND); - SDL_BlitScaled(n->icon, NULL, notif_surface, &icon_dst); - content_x += icon_total_w; - } - - // Draw text with main color - SDL_Surface* text_surf = TTF_RenderUTF8_Blended(font.tiny, n->message, text_color); - if (text_surf) { - SDL_SetSurfaceBlendMode(text_surf, SDL_BLENDMODE_BLEND); - SDL_Rect text_dst = {content_x, notif_padding_y, text_surf->w, text_surf->h}; - SDL_BlitSurface(text_surf, NULL, notif_surface, &text_dst); - SDL_FreeSurface(text_surf); - } - - // Blit to the full notification surface - SDL_SetSurfaceBlendMode(notif_surface, SDL_BLENDMODE_NONE); - SDL_Rect dst_rect = {x, y, pill_w, pill_h}; - SDL_BlitSurface(notif_surface, NULL, gl_notification_surface, &dst_rect); - - SDL_FreeSurface(notif_surface); - } + render_notification_stack(); } // Set the notification surface for GL rendering From d1b5b7464114c05d916ff1d700e28764153fa01e Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Thu, 5 Feb 2026 16:47:23 -0500 Subject: [PATCH 16/26] Consolidate duplicate RA makefile blocks for tg5040/tg5050/desktop Use ifneq filter pattern to combine identical rcheevos/libchdr linking for all RA-enabled platforms, with nested conditional for desktop rpath. --- workspace/all/minarch/makefile | 23 +++++------------------ 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/workspace/all/minarch/makefile b/workspace/all/minarch/makefile index 60fb2aaef..c587ec782 100644 --- a/workspace/all/minarch/makefile +++ b/workspace/all/minarch/makefile @@ -63,28 +63,15 @@ CFLAGS += -DHAS_SRM LDFLAGS += -lasound endif -ifeq ($(PLATFORM), tg5040) -# rcheevos static library -LDFLAGS += -L../rcheevos/build/$(PLATFORM) -lrcheevos -# libchdr shared library (for CHD disc image hashing) -LDFLAGS += -L../libchdr/build/$(PLATFORM) -lchdr -CFLAGS += -DRC_CLIENT_SUPPORTS_HASH -endif - -ifeq ($(PLATFORM), tg5050) -# rcheevos static library +# RA support: rcheevos and libchdr linking for tg5040, tg5050, and desktop +ifneq (,$(filter $(PLATFORM),tg5040 tg5050 desktop)) LDFLAGS += -L../rcheevos/build/$(PLATFORM) -lrcheevos -# libchdr shared library (for CHD disc image hashing) LDFLAGS += -L../libchdr/build/$(PLATFORM) -lchdr CFLAGS += -DRC_CLIENT_SUPPORTS_HASH -endif - ifeq ($(PLATFORM), desktop) -# rcheevos static library (built for desktop) -LDFLAGS += -L../rcheevos/build/$(PLATFORM) -lrcheevos -# libchdr (built from source for desktop) -LDFLAGS += -L../libchdr/build/$(PLATFORM) -lchdr -Wl,-rpath,'$$ORIGIN' -CFLAGS += -DRC_CLIENT_SUPPORTS_HASH +# Desktop needs rpath for local shared library lookup +LDFLAGS += -Wl,-rpath,'$$ORIGIN' +endif endif # CFLAGS += -Wall -Wno-unused-variable -Wno-unused-function -Wno-format-overflow From 17f0afc1d596ac05e2e169890a5c67739ad52acb Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Thu, 5 Feb 2026 17:31:21 -0500 Subject: [PATCH 17/26] Move http.c/http.h from common/ to minarch/ Per PR feedback, HTTP client is only used by minarch (RA integration). Update include paths in ra_auth.c and ra_badges.c accordingly. --- workspace/all/common/ra_auth.c | 2 +- workspace/all/common/ra_badges.c | 2 +- workspace/all/{common => minarch}/http.c | 0 workspace/all/{common => minarch}/http.h | 0 workspace/all/minarch/makefile | 2 +- 5 files changed, 3 insertions(+), 3 deletions(-) rename workspace/all/{common => minarch}/http.c (100%) rename workspace/all/{common => minarch}/http.h (100%) diff --git a/workspace/all/common/ra_auth.c b/workspace/all/common/ra_auth.c index 862f86ba4..c11e89493 100644 --- a/workspace/all/common/ra_auth.c +++ b/workspace/all/common/ra_auth.c @@ -1,5 +1,5 @@ #include "ra_auth.h" -#include "http.h" +#include "../minarch/http.h" #include "defines.h" #include diff --git a/workspace/all/common/ra_badges.c b/workspace/all/common/ra_badges.c index fb05a0d2c..ab8c4ba83 100644 --- a/workspace/all/common/ra_badges.c +++ b/workspace/all/common/ra_badges.c @@ -1,5 +1,5 @@ #include "ra_badges.h" -#include "http.h" +#include "../minarch/http.h" #include "defines.h" #include "api.h" #include "sdl.h" diff --git a/workspace/all/common/http.c b/workspace/all/minarch/http.c similarity index 100% rename from workspace/all/common/http.c rename to workspace/all/minarch/http.c diff --git a/workspace/all/common/http.h b/workspace/all/minarch/http.h similarity index 100% rename from workspace/all/common/http.h rename to workspace/all/minarch/http.h diff --git a/workspace/all/minarch/makefile b/workspace/all/minarch/makefile index c587ec782..1c0967cf7 100644 --- a/workspace/all/minarch/makefile +++ b/workspace/all/minarch/makefile @@ -22,7 +22,7 @@ SDL?=SDL TARGET = minarch PRODUCT= build/$(PLATFORM)/$(TARGET).elf INCDIR = -I. -I./libretro-common/include/ -I../common/ -I../../$(PLATFORM)/platform/ -SOURCE = $(TARGET).c ../common/scaler.c ../common/utils.c ../common/config.c ../common/api.c ../common/notification.c ../common/http.c ../../$(PLATFORM)/platform/platform.c +SOURCE = $(TARGET).c ../common/scaler.c ../common/utils.c ../common/config.c ../common/api.c ../common/notification.c http.c ../../$(PLATFORM)/platform/platform.c # RA support for tg5040, tg5050, and desktop ifneq (,$(filter $(PLATFORM),tg5040 tg5050 desktop)) From b13e02f05210b93861b50c7ec7faea5231c076b1 Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Thu, 5 Feb 2026 17:32:51 -0500 Subject: [PATCH 18/26] Use container-provided toolchain for libchdr cross-compilation Remove duplicate toolchain file generation for tg5040/tg5050. The build container already provides CMAKE_TOOLCHAIN_FILE. --- workspace/all/minarch/libchdr.makefile | 30 ++------------------------ 1 file changed, 2 insertions(+), 28 deletions(-) diff --git a/workspace/all/minarch/libchdr.makefile b/workspace/all/minarch/libchdr.makefile index 733ad94f9..c93db66a0 100644 --- a/workspace/all/minarch/libchdr.makefile +++ b/workspace/all/minarch/libchdr.makefile @@ -6,15 +6,9 @@ PLATFORM ?= tg5040 BUILD_DIR = build/$(PLATFORM) # Cross-compilation settings (only for non-desktop platforms) +# Uses the toolchain file provided by the build container ifneq ($(PLATFORM),desktop) -ifeq ($(PLATFORM),tg5040) -TOOLCHAIN_FILE = $(BUILD_DIR)/toolchain.cmake -CMAKE_EXTRA = -DCMAKE_TOOLCHAIN_FILE=$(TOOLCHAIN_FILE) -endif -ifeq ($(PLATFORM),tg5050) -TOOLCHAIN_FILE = $(BUILD_DIR)/toolchain.cmake -CMAKE_EXTRA = -DCMAKE_TOOLCHAIN_FILE=$(TOOLCHAIN_FILE) -endif +CMAKE_EXTRA = -DCMAKE_TOOLCHAIN_FILE=$(CMAKE_TOOLCHAIN_FILE) endif .PHONY: all build clean @@ -24,26 +18,6 @@ all: build build: $(BUILD_DIR)/libchdr.so $(BUILD_DIR)/libchdr.so: | $(BUILD_DIR) -ifeq ($(PLATFORM),tg5040) - @echo "Creating CMake toolchain file for cross-compilation..." - @echo 'set(CMAKE_SYSTEM_NAME Linux)' > $(TOOLCHAIN_FILE) - @echo 'set(CMAKE_SYSTEM_PROCESSOR aarch64)' >> $(TOOLCHAIN_FILE) - @echo 'set(CMAKE_C_COMPILER aarch64-nextui-linux-gnu-gcc)' >> $(TOOLCHAIN_FILE) - @echo 'set(CMAKE_CXX_COMPILER aarch64-nextui-linux-gnu-g++)' >> $(TOOLCHAIN_FILE) - @echo 'set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)' >> $(TOOLCHAIN_FILE) - @echo 'set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)' >> $(TOOLCHAIN_FILE) - @echo 'set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)' >> $(TOOLCHAIN_FILE) -endif -ifeq ($(PLATFORM),tg5050) - @echo "Creating CMake toolchain file for cross-compilation..." - @echo 'set(CMAKE_SYSTEM_NAME Linux)' > $(TOOLCHAIN_FILE) - @echo 'set(CMAKE_SYSTEM_PROCESSOR aarch64)' >> $(TOOLCHAIN_FILE) - @echo 'set(CMAKE_C_COMPILER aarch64-nextui-linux-gnu-gcc)' >> $(TOOLCHAIN_FILE) - @echo 'set(CMAKE_CXX_COMPILER aarch64-nextui-linux-gnu-g++)' >> $(TOOLCHAIN_FILE) - @echo 'set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)' >> $(TOOLCHAIN_FILE) - @echo 'set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)' >> $(TOOLCHAIN_FILE) - @echo 'set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)' >> $(TOOLCHAIN_FILE) -endif cd $(BUILD_DIR) && cmake ../.. \ -DBUILD_SHARED_LIBS=ON \ -DINSTALL_STATIC_LIBS=OFF \ From faddf04ada02dc46461bbc440e12161e6f6328cf Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Thu, 5 Feb 2026 17:34:50 -0500 Subject: [PATCH 19/26] Move http.c/http.h from common/ to minarch/ --- workspace/all/settings/makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workspace/all/settings/makefile b/workspace/all/settings/makefile index 32cbbce79..cdd08c28c 100644 --- a/workspace/all/settings/makefile +++ b/workspace/all/settings/makefile @@ -21,7 +21,7 @@ SDL?=SDL TARGET = settings INCDIR = -I. -I../common/ -I../../$(PLATFORM)/platform/ -SOURCE = -c ../common/utils.c ../common/api.c ../common/config.c ../common/scaler.c ../common/http.c ../common/ra_auth.c ../../$(PLATFORM)/platform/platform.c +SOURCE = -c ../common/utils.c ../common/api.c ../common/config.c ../common/scaler.c ../minarch/http.c ../common/ra_auth.c ../../$(PLATFORM)/platform/platform.c CXXSOURCE = $(TARGET).cpp menu.cpp wifimenu.cpp btmenu.cpp keyboardprompt.cpp CXXSOURCE += build/$(PLATFORM)/utils.o build/$(PLATFORM)/api.o build/$(PLATFORM)/config.o build/$(PLATFORM)/scaler.o build/$(PLATFORM)/http.o build/$(PLATFORM)/ra_auth.o build/$(PLATFORM)/platform.o From b160957f5f14182a4a380390339f54d8eca44924 Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Thu, 5 Feb 2026 17:42:21 -0500 Subject: [PATCH 20/26] Hide hardcore mode setting from UI Keep underlying code intact for future use, but hide menu item until feature is ready. Default remains OFF. --- workspace/all/settings/settings.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/workspace/all/settings/settings.cpp b/workspace/all/settings/settings.cpp index 17da296ee..a038144f1 100644 --- a/workspace/all/settings/settings.cpp +++ b/workspace/all/settings/settings.cpp @@ -614,10 +614,11 @@ int main(int argc, char *argv[]) } return std::string("Not authenticated"); }}, - new MenuItem{ListItemType::Generic, "Hardcore Mode", "Disable save states and cheats for achievements", {false, true}, on_off, - []() -> std::any { return CFG_getRAHardcoreMode(); }, - [](const std::any &value) { CFG_setRAHardcoreMode(std::any_cast(value)); }, - []() { CFG_setRAHardcoreMode(CFG_DEFAULT_RA_HARDCOREMODE);}}, + // TODO: Hardcore mode hidden until feature is fully implemented and ready for the emulator approval process done by the RetroAchievements team + // new MenuItem{ListItemType::Generic, "Hardcore Mode", "Disable save states and cheats for achievements", {false, true}, on_off, + // []() -> std::any { return CFG_getRAHardcoreMode(); }, + // [](const std::any &value) { CFG_setRAHardcoreMode(std::any_cast(value)); }, + // []() { CFG_setRAHardcoreMode(CFG_DEFAULT_RA_HARDCOREMODE);}}, new MenuItem{ListItemType::Generic, "Show Notifications", "Show achievement unlock notifications", {false, true}, on_off, []() -> std::any { return CFG_getRAShowNotifications(); }, [](const std::any &value) { CFG_setRAShowNotifications(std::any_cast(value)); }, From aff5d9b0b3b8a607c967657ccc15516d07626449 Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Thu, 5 Feb 2026 18:09:45 -0500 Subject: [PATCH 21/26] Refactor notification overlay statics into struct (generic_video.c) Group 7 related notification state variables into NotificationOverlay struct for improved readability per PR feedback. --- workspace/all/common/generic_video.c | 62 +++++++++++++++------------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/workspace/all/common/generic_video.c b/workspace/all/common/generic_video.c index 85a94abc2..ba7d378b6 100644 --- a/workspace/all/common/generic_video.c +++ b/workspace/all/common/generic_video.c @@ -109,26 +109,30 @@ static uint32_t SDL_transparentBlack = 0; static char* overlay_path = NULL; -// Notification surface for RA achievements overlay -static SDL_Surface* notification_surface = NULL; -static int notification_x = 0; -static int notification_y = 0; -static int notification_dirty = 0; -static GLuint notification_tex = 0; -static int notif_tex_w = 0, notif_tex_h = 0; -static int notification_clear_frames = 0; // Frames to clear framebuffer after notification ends +// Notification overlay state for RA achievements +typedef struct { + SDL_Surface* surface; + int x; + int y; + int dirty; + GLuint tex; + int tex_w, tex_h; + int clear_frames; // Frames to clear framebuffer after notification ends +} NotificationOverlay; + +static NotificationOverlay notif = {0}; void PLAT_setNotificationSurface(SDL_Surface* surface, int x, int y) { - notification_surface = surface; - notification_x = x; - notification_y = y; - notification_dirty = 1; + notif.surface = surface; + notif.x = x; + notif.y = y; + notif.dirty = 1; } void PLAT_clearNotificationSurface(void) { - notification_surface = NULL; - notification_dirty = 0; // Nothing to update since surface is NULL - notification_clear_frames = 3; // Clear for 3 frames (triple buffering safety) + notif.surface = NULL; + notif.dirty = 0; // Nothing to update since surface is NULL + notif.clear_frames = 3; // Clear for 3 frames (triple buffering safety) } @@ -455,16 +459,16 @@ void PLAT_initShaders() { void PLAT_initNotificationTexture(void) { // Pre-allocate notification texture to avoid frame skip on first notification - glGenTextures(1, ¬ification_tex); - glBindTexture(GL_TEXTURE_2D, notification_tex); + glGenTextures(1, ¬if.tex); + glBindTexture(GL_TEXTURE_2D, notif.tex); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); // Allocate full-screen texture with transparent pixels glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, device_width, device_height, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL); - notif_tex_w = device_width; - notif_tex_h = device_height; + notif.tex_w = device_width; + notif.tex_h = device_height; } static void sdl_log_stdout( @@ -1954,9 +1958,9 @@ void PLAT_GL_Swap() { static int lastframecount = 0; if (reloadShaderTextures) lastframecount = frame_count; - if (frame_count < lastframecount + 3 || notification_clear_frames > 0) { + if (frame_count < lastframecount + 3 || notif.clear_frames > 0) { glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); - if (notification_clear_frames > 0) notification_clear_frames--; + if (notif.clear_frames > 0) notif.clear_frames--; } SDL_Rect dst_rect = {0, 0, device_width, device_height}; @@ -2203,19 +2207,19 @@ void PLAT_GL_Swap() { } // Render notification overlay if present (texture pre-allocated in PLAT_initShaders) - if (notification_dirty && notification_surface) { - glBindTexture(GL_TEXTURE_2D, notification_tex); - glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, notification_surface->w, notification_surface->h, GL_RGBA, GL_UNSIGNED_BYTE, notification_surface->pixels); - notification_dirty = 0; + if (notif.dirty && notif.surface) { + glBindTexture(GL_TEXTURE_2D, notif.tex); + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, notif.surface->w, notif.surface->h, GL_RGBA, GL_UNSIGNED_BYTE, notif.surface->pixels); + notif.dirty = 0; } - if (notification_tex && notification_surface) { + if (notif.tex && notif.surface) { runShaderPass( - notification_tex, + notif.tex, g_shader_overlay, NULL, - notification_x, notification_y, notif_tex_w, notif_tex_h, - &(Shader){.srcw = notif_tex_w, .srch = notif_tex_h, .texw = notif_tex_w, .texh = notif_tex_h}, + notif.x, notif.y, notif.tex_w, notif.tex_h, + &(Shader){.srcw = notif.tex_w, .srch = notif.tex_h, .texw = notif.tex_w, .texh = notif.tex_h}, 1, GL_NONE ); } From 18385e1fa15f33fbd2338319f486f005294eae90 Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Thu, 5 Feb 2026 18:12:07 -0500 Subject: [PATCH 22/26] Move findFileInDir from generic_video.c to utils.c - Rename PLAT_findFileInDir to findFileInDir (now in utils) - Remove duplicate static version from minarch.c (uses utils version with improved best-match logic) - Change LOG_info to LOG_debug for match logging (debugging info, not user-facing) - Update api.h, utils.h declarations --- workspace/all/common/api.h | 1 - workspace/all/common/generic_video.c | 64 --------------------------- workspace/all/common/utils.c | 65 ++++++++++++++++++++++++++++ workspace/all/common/utils.h | 2 + workspace/all/minarch/minarch.c | 46 -------------------- 5 files changed, 67 insertions(+), 111 deletions(-) diff --git a/workspace/all/common/api.h b/workspace/all/common/api.h index 836671da9..3d5bdf097 100644 --- a/workspace/all/common/api.h +++ b/workspace/all/common/api.h @@ -595,7 +595,6 @@ void PLAT_initPlatform(void); // *actual* platform-specific init FILE *PLAT_OpenSettings(const char *filename); FILE *PLAT_WriteSettings(const char *filename); -char* PLAT_findFileInDir(const char *directory, const char *filename); void PLAT_initInput(void); void PLAT_updateInput(const SDL_Event *event); void PLAT_quitInput(void); diff --git a/workspace/all/common/generic_video.c b/workspace/all/common/generic_video.c index ba7d378b6..ab4d1e851 100644 --- a/workspace/all/common/generic_video.c +++ b/workspace/all/common/generic_video.c @@ -489,70 +489,6 @@ void PLAT_resetShaders() { shaderResetRequested = 1; } -char* PLAT_findFileInDir(const char *directory, const char *filename) { - char *filename_copy = strdup(filename); - if (!filename_copy) { - perror("strdup"); - return NULL; - } - - // Strip extension from filename - char *dot_pos = strrchr(filename_copy, '.'); - if (dot_pos) { - *dot_pos = '\0'; - } - - DIR *dir = opendir(directory); - if (!dir) { - perror("opendir"); - free(filename_copy); - return NULL; - } - - struct dirent *entry; - char *full_path = NULL; - - // Track the best (shortest) match to avoid prefix collisions. - // e.g., searching for "Advance Wars" should match "Advance Wars (USA).gba" - // over "Advance Wars 2 - Black Hole Rising (USA).gba" - char *best_match_name = NULL; - size_t best_match_len = SIZE_MAX; - - while ((entry = readdir(dir)) != NULL) { - // Strip extension from entry for comparison - char *entry_base = strdup(entry->d_name); - if (!entry_base) continue; - - char *entry_dot = strrchr(entry_base, '.'); - if (entry_dot) *entry_dot = '\0'; - - if (strstr(entry_base, filename_copy) == entry_base) { - // Prefer shorter matches (closer to exact match) - size_t entry_len = strlen(entry_base); - if (entry_len < best_match_len) { - free(best_match_name); - best_match_name = strdup(entry->d_name); - best_match_len = entry_len; - } - } - free(entry_base); - } - - closedir(dir); - - if (best_match_name) { - full_path = (char *)malloc(strlen(directory) + strlen(best_match_name) + 2); - if (full_path) { - snprintf(full_path, strlen(directory) + strlen(best_match_name) + 2, "%s/%s", directory, best_match_name); - LOG_info("PLAT_findFileInDir: matched '%s' for search '%s'\n", best_match_name, filename_copy); - } - free(best_match_name); - } - - free(filename_copy); - return full_path; -} - SDL_Surface* PLAT_initVideo(void) { #if NEXTUI_TSAN diff --git a/workspace/all/common/utils.c b/workspace/all/common/utils.c index c4315885c..d3e69813b 100644 --- a/workspace/all/common/utils.c +++ b/workspace/all/common/utils.c @@ -8,6 +8,8 @@ #include #include #include +#include +#include #include #include "defines.h" #include "utils.h" @@ -517,3 +519,66 @@ double clampd(double x, double lower, double upper) { return min(upper, max(x, lower)); } + +char* findFileInDir(const char *directory, const char *filename) { + char *filename_copy = strdup(filename); + if (!filename_copy) { + perror("strdup"); + return NULL; + } + + // Strip extension from filename + char *dot_pos = strrchr(filename_copy, '.'); + if (dot_pos) { + *dot_pos = '\0'; + } + + DIR *dir = opendir(directory); + if (!dir) { + perror("opendir"); + free(filename_copy); + return NULL; + } + + struct dirent *entry; + char *full_path = NULL; + + // Track the best (shortest) match to avoid prefix collisions. + // e.g., searching for "Advance Wars" should match "Advance Wars (USA).gba" + // over "Advance Wars 2 - Black Hole Rising (USA).gba" + char *best_match_name = NULL; + size_t best_match_len = SIZE_MAX; + + while ((entry = readdir(dir)) != NULL) { + // Strip extension from entry for comparison + char *entry_base = strdup(entry->d_name); + if (!entry_base) continue; + + char *entry_dot = strrchr(entry_base, '.'); + if (entry_dot) *entry_dot = '\0'; + + if (strstr(entry_base, filename_copy) == entry_base) { + // Prefer shorter matches (closer to exact match) + size_t entry_len = strlen(entry_base); + if (entry_len < best_match_len) { + free(best_match_name); + best_match_name = strdup(entry->d_name); + best_match_len = entry_len; + } + } + free(entry_base); + } + + closedir(dir); + + if (best_match_name) { + full_path = (char *)malloc(strlen(directory) + strlen(best_match_name) + 2); + if (full_path) { + snprintf(full_path, strlen(directory) + strlen(best_match_name) + 2, "%s/%s", directory, best_match_name); + } + free(best_match_name); + } + + free(filename_copy); + return full_path; +} diff --git a/workspace/all/common/utils.h b/workspace/all/common/utils.h index b7dbd8ff8..9444164ea 100644 --- a/workspace/all/common/utils.h +++ b/workspace/all/common/utils.h @@ -47,4 +47,6 @@ uint64_t getMicroseconds(void); int clamp(int x, int lower, int upper); double clampd(double x, double lower, double upper); +char* findFileInDir(const char *directory, const char *filename); + #endif diff --git a/workspace/all/minarch/minarch.c b/workspace/all/minarch/minarch.c index 8fd0bb250..0ce92f1be 100644 --- a/workspace/all/minarch/minarch.c +++ b/workspace/all/minarch/minarch.c @@ -159,7 +159,6 @@ static struct Core { int extract_zip(char** extensions); static bool getAlias(char* path, char* alias); -static char* findFileInDir(const char *directory, const char *filename); static struct Game { char path[MAX_PATH]; @@ -7731,51 +7730,6 @@ static bool getAlias(char* path, char* alias) { return is_alias; } -static char* findFileInDir(const char *directory, const char *filename) { - char *filename_copy = strdup(filename); - if (!filename_copy) { - perror("strdup"); - return NULL; - } - - // Strip extension from filename - char *dot_pos = strrchr(filename_copy, '.'); - if (dot_pos) { - *dot_pos = '\0'; - } - - DIR *dir = opendir(directory); - if (!dir) { - perror("opendir"); - free(filename_copy); - return NULL; - } - - struct dirent *entry; - char *full_path = NULL; - - while ((entry = readdir(dir)) != NULL) { - if (strstr(entry->d_name, filename_copy) == entry->d_name) { - full_path = (char *)malloc(strlen(directory) + strlen(entry->d_name) + 2); // +1 for slash, +1 for '\0' - if (!full_path) { - perror("malloc"); - closedir(dir); - free(filename_copy); - return NULL; - } - - snprintf(full_path, strlen(directory) + strlen(entry->d_name) + 2, "%s/%s", directory, entry->d_name); - closedir(dir); - free(filename_copy); - return full_path; - } - } - - closedir(dir); - free(filename_copy); - return NULL; -} - static int Menu_options(MenuList* list) { MenuItem* items = list->items; int type = list->type; From ff6ae66c727b4eb759747afb968bfa13f3302b78 Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Thu, 5 Feb 2026 18:15:18 -0500 Subject: [PATCH 23/26] Clean up notification.c per PR feedback - Move gl_notification_surface to top with other static variables - Add comment explaining why draw_rounded_rect is separate from GFX_blitPill* functions (different rendering context: RGBA for GL overlay vs theme assets for screen format) --- workspace/all/common/notification.c | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/workspace/all/common/notification.c b/workspace/all/common/notification.c index 64056c536..446cccc1c 100644 --- a/workspace/all/common/notification.c +++ b/workspace/all/common/notification.c @@ -26,6 +26,10 @@ static Notification notifications[NOTIFICATION_MAX_QUEUE]; static int notification_count = 0; static int initialized = 0; +// Persistent surface for GL rendering +static SDL_Surface* gl_notification_surface = NULL; +static int needs_clear_frame = 0; + // Screen dimensions for layer rendering static int screen_width = 0; static int screen_height = 0; @@ -73,7 +77,11 @@ static ProgressIndicatorState progress_state = {0}; // Rounded rectangle drawing /////////////////////////////// -// Draw a filled rounded rectangle (pill shape) on an RGBA surface +// Draw a filled rounded rectangle directly to RGBA pixel buffer. +// This is separate from GFX_blitPill* functions in api.c because: +// 1. Notifications render to an RGBA surface for GL overlay compositing +// 2. GFX_blitPill* use pre-made theme assets requiring screen format surfaces +// 3. Direct pixel manipulation avoids format conversion overhead during animation static void draw_rounded_rect(SDL_Surface* surface, int x, int y, int w, int h, int radius, Uint32 color) { if (!surface || w <= 0 || h <= 0) return; @@ -232,10 +240,6 @@ void Notification_update(uint32_t now) { } } -// Persistent surface for GL rendering -static SDL_Surface* gl_notification_surface = NULL; -static int needs_clear_frame = 0; - // Render system indicator (top-right) static void render_system_indicator(void) { int indicator_width = SCALE1(PILL_SIZE + SETTINGS_WIDTH + PADDING + SYS_INDICATOR_EXTRA_PAD); From 5b560ff2c0f3969abf89168da73098bb7b50ed49 Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Mon, 9 Feb 2026 08:36:07 -0500 Subject: [PATCH 24/26] Remove unneeded dirent.h include --- workspace/all/common/generic_video.c | 1 - 1 file changed, 1 deletion(-) diff --git a/workspace/all/common/generic_video.c b/workspace/all/common/generic_video.c index ab4d1e851..c022e7e4c 100644 --- a/workspace/all/common/generic_video.c +++ b/workspace/all/common/generic_video.c @@ -20,7 +20,6 @@ #include "utils.h" #include #include -#include #include #if defined(__has_feature) From 882a1f83a945922056aebc39dbf15bc4388bc54b Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Mon, 9 Feb 2026 08:44:40 -0500 Subject: [PATCH 25/26] Revert "Move http.c/http.h from common/ to minarch/" Move http.c and http.h back to workspace/all/common/ to avoid minarch-relative include paths in common files (ra_auth.c, ra_badges.c). --- workspace/all/{minarch => common}/http.c | 0 workspace/all/{minarch => common}/http.h | 0 workspace/all/common/ra_auth.c | 2 +- workspace/all/common/ra_badges.c | 2 +- workspace/all/minarch/makefile | 2 +- workspace/all/settings/makefile | 2 +- 6 files changed, 4 insertions(+), 4 deletions(-) rename workspace/all/{minarch => common}/http.c (100%) rename workspace/all/{minarch => common}/http.h (100%) diff --git a/workspace/all/minarch/http.c b/workspace/all/common/http.c similarity index 100% rename from workspace/all/minarch/http.c rename to workspace/all/common/http.c diff --git a/workspace/all/minarch/http.h b/workspace/all/common/http.h similarity index 100% rename from workspace/all/minarch/http.h rename to workspace/all/common/http.h diff --git a/workspace/all/common/ra_auth.c b/workspace/all/common/ra_auth.c index c11e89493..862f86ba4 100644 --- a/workspace/all/common/ra_auth.c +++ b/workspace/all/common/ra_auth.c @@ -1,5 +1,5 @@ #include "ra_auth.h" -#include "../minarch/http.h" +#include "http.h" #include "defines.h" #include diff --git a/workspace/all/common/ra_badges.c b/workspace/all/common/ra_badges.c index ab8c4ba83..fb05a0d2c 100644 --- a/workspace/all/common/ra_badges.c +++ b/workspace/all/common/ra_badges.c @@ -1,5 +1,5 @@ #include "ra_badges.h" -#include "../minarch/http.h" +#include "http.h" #include "defines.h" #include "api.h" #include "sdl.h" diff --git a/workspace/all/minarch/makefile b/workspace/all/minarch/makefile index 1c0967cf7..c587ec782 100644 --- a/workspace/all/minarch/makefile +++ b/workspace/all/minarch/makefile @@ -22,7 +22,7 @@ SDL?=SDL TARGET = minarch PRODUCT= build/$(PLATFORM)/$(TARGET).elf INCDIR = -I. -I./libretro-common/include/ -I../common/ -I../../$(PLATFORM)/platform/ -SOURCE = $(TARGET).c ../common/scaler.c ../common/utils.c ../common/config.c ../common/api.c ../common/notification.c http.c ../../$(PLATFORM)/platform/platform.c +SOURCE = $(TARGET).c ../common/scaler.c ../common/utils.c ../common/config.c ../common/api.c ../common/notification.c ../common/http.c ../../$(PLATFORM)/platform/platform.c # RA support for tg5040, tg5050, and desktop ifneq (,$(filter $(PLATFORM),tg5040 tg5050 desktop)) diff --git a/workspace/all/settings/makefile b/workspace/all/settings/makefile index cdd08c28c..32cbbce79 100644 --- a/workspace/all/settings/makefile +++ b/workspace/all/settings/makefile @@ -21,7 +21,7 @@ SDL?=SDL TARGET = settings INCDIR = -I. -I../common/ -I../../$(PLATFORM)/platform/ -SOURCE = -c ../common/utils.c ../common/api.c ../common/config.c ../common/scaler.c ../minarch/http.c ../common/ra_auth.c ../../$(PLATFORM)/platform/platform.c +SOURCE = -c ../common/utils.c ../common/api.c ../common/config.c ../common/scaler.c ../common/http.c ../common/ra_auth.c ../../$(PLATFORM)/platform/platform.c CXXSOURCE = $(TARGET).cpp menu.cpp wifimenu.cpp btmenu.cpp keyboardprompt.cpp CXXSOURCE += build/$(PLATFORM)/utils.o build/$(PLATFORM)/api.o build/$(PLATFORM)/config.o build/$(PLATFORM)/scaler.o build/$(PLATFORM)/http.o build/$(PLATFORM)/ra_auth.o build/$(PLATFORM)/platform.o From 3510c8b9e4455b6469017250f699cdc36c3c46a6 Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Wed, 11 Feb 2026 11:47:00 -0500 Subject: [PATCH 26/26] fix(retroachievements): Add 15s timeout to badge loading notification Prevents the "Loading badges" notification from showing indefinitely when downloads stall due to poor network conditions. --- workspace/all/common/ra_badges.c | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/workspace/all/common/ra_badges.c b/workspace/all/common/ra_badges.c index fb05a0d2c..3411a8554 100644 --- a/workspace/all/common/ra_badges.c +++ b/workspace/all/common/ra_badges.c @@ -27,6 +27,7 @@ #define MAX_CACHED_BADGES 256 #define MAX_CONCURRENT_DOWNLOADS 8 #define MAX_QUEUED_DOWNLOADS 512 +#define NOTIFICATION_TIMEOUT_MS 15000 /***************************************************************************** * Badge cache entry @@ -58,6 +59,7 @@ static int badge_cache_count = 0; static SDL_mutex* badge_mutex = NULL; static int pending_downloads = 0; static bool initialized = false; +static uint32_t notification_start_time = 0; // Download queue for rate limiting typedef struct { @@ -302,9 +304,14 @@ static void badge_download_callback(HTTP_Response* response, void* userdata) { // Start next queued download(s) process_download_queue(); - // Hide progress indicator when all downloads complete + // Check if we should hide the notification + // Hide when all downloads complete, or when notification timeout is reached + uint32_t elapsed = SDL_GetTicks() - notification_start_time; if (pending_downloads == 0 && download_queue.count == 0) { Notification_hideProgressIndicator(); + } else if (elapsed >= NOTIFICATION_TIMEOUT_MS) { + // Force hide after notification timeout elapses, even if downloads aren't complete + Notification_hideProgressIndicator(); } if (badge_mutex) SDL_UnlockMutex(badge_mutex); @@ -411,6 +418,7 @@ void RA_Badges_prefetch(const char** badge_names, size_t count) { if (download_queue.count > 0) { Notification_setProgressIndicatorPersistent(true); Notification_showProgressIndicator("Loading achievement badges...", "", NULL); + notification_start_time = SDL_GetTicks(); // Start processing the queue (up to MAX_CONCURRENT_DOWNLOADS) process_download_queue();