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 5f485c662..5388beeed 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 := @@ -103,7 +103,6 @@ endif cp ./workspace/all/clock/build/$(PLATFORM)/clock.elf ./build/EXTRAS/Tools/$(PLATFORM)/Clock.pak/ cp ./workspace/all/minput/build/$(PLATFORM)/minput.elf ./build/EXTRAS/Tools/$(PLATFORM)/Input.pak/ cp ./workspace/all/settings/build/$(PLATFORM)/settings.elf ./build/EXTRAS/Tools/$(PLATFORM)/Settings.pak/ - ifneq (,$(filter $(PLATFORM),tg5040 tg5050)) cp ./workspace/all/ledcontrol/build/$(PLATFORM)/ledcontrol.elf ./build/EXTRAS/Tools/$(PLATFORM)/LedControl.pak/ cp ./workspace/all/bootlogo/build/$(PLATFORM)/bootlogo.elf ./build/EXTRAS/Tools/$(PLATFORM)/Bootlogo.pak/ @@ -120,6 +119,9 @@ endif 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/ + ifeq ($(PLATFORM), tg5040) # liblz4 for Rewind support cp -L ./workspace/all/minarch/build/$(PLATFORM)/liblz4.so.1 ./build/SYSTEM/$(PLATFORM)/lib/ diff --git a/workspace/all/common/api.c b/workspace/all/common/api.c index daa4dd6d3..2c3b06636 100644 --- a/workspace/all/common/api.c +++ b/workspace/all/common/api.c @@ -923,6 +923,91 @@ 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 *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; + + 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, surface, &(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, surface, &(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, surface, &(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 +1870,94 @@ 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. +int GFX_blitHardwareIndicator(SDL_Surface *dst, int x, int y, IndicatorType 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_blitPillLight(ASSET_WHITE_PILL, dst, &(SDL_Rect){ox, oy, ow, SCALE1(PILL_SIZE)}); + + // Determine which setting to display + if (indicator_type == INDICATOR_BRIGHTNESS) + { + setting_value = GetBrightness(); + setting_min = BRIGHTNESS_MIN; + setting_max = BRIGHTNESS_MAX; + asset = ASSET_BRIGHTNESS; + } + else if (indicator_type == INDICATOR_COLORTEMP) + { + setting_value = GetColortemp(); + setting_min = COLORTEMP_MIN; + setting_max = COLORTEMP_MAX; + asset = ASSET_COLORTEMP; + } + else // INDICATOR_VOLUME + { + 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( + SDL_SWSURFACE, 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, (IndicatorType)show_setting); } else { diff --git a/workspace/all/common/api.h b/workspace/all/common/api.h index b518f2664..3d5bdf097 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* 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) @@ -352,6 +353,34 @@ 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 Which indicator to display + * @return The width of the rendered indicator + */ +int GFX_blitHardwareIndicator(SDL_Surface* dst, int x, int y, IndicatorType 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); @@ -566,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); @@ -589,6 +617,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, @@ -632,6 +665,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/config.c b/workspace/all/common/config.c index 6914fbbdb..fb8c5c657 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 = clamp(seconds, 1, 3); + 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 = clamp(seconds, 1, 5); + CFG_sync(); +} + +int CFG_getRAProgressNotificationDuration(void) +{ + return settings.raProgressNotificationDuration; +} + +void CFG_setRAProgressNotificationDuration(int seconds) +{ + settings.raProgressNotificationDuration = clamp(seconds, 0, 5); + 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..c022e7e4c 100644 --- a/workspace/all/common/generic_video.c +++ b/workspace/all/common/generic_video.c @@ -20,6 +20,7 @@ #include "utils.h" #include #include +#include #if defined(__has_feature) #if __has_feature(thread_sanitizer) @@ -107,6 +108,32 @@ static uint32_t SDL_transparentBlack = 0; static char* overlay_path = NULL; +// 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) { + notif.surface = surface; + notif.x = x; + notif.y = y; + notif.dirty = 1; +} + +void PLAT_clearNotificationSurface(void) { + notif.surface = NULL; + notif.dirty = 0; // Nothing to update since surface is NULL + notif.clear_frames = 3; // Clear for 3 frames (triple buffering safety) +} + #define MAX_SHADERLINE_LENGTH 512 int extractPragmaParameters(const char *shaderSource, ShaderParam *params, int maxParams) { @@ -429,6 +456,20 @@ void PLAT_initShaders() { 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, ¬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; +} + static void sdl_log_stdout( void *userdata, int category, @@ -1852,8 +1893,10 @@ void PLAT_GL_Swap() { static int lastframecount = 0; if (reloadShaderTextures) lastframecount = frame_count; - if (frame_count < lastframecount + 3) + if (frame_count < lastframecount + 3 || notif.clear_frames > 0) { glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + if (notif.clear_frames > 0) notif.clear_frames--; + } SDL_Rect dst_rect = {0, 0, device_width, device_height}; setRectToAspectRatio(&dst_rect); @@ -2098,6 +2141,24 @@ void PLAT_GL_Swap() { ); } + // Render notification overlay if present (texture pre-allocated in PLAT_initShaders) + 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 (notif.tex && notif.surface) { + runShaderPass( + notif.tex, + g_shader_overlay, + NULL, + 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 + ); + } + 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..16397f0b9 --- /dev/null +++ b/workspace/all/common/http.c @@ -0,0 +1,396 @@ +#include "http.h" +#include "defines.h" + +#include +#include +#include +#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) +#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..446cccc1c --- /dev/null +++ b/workspace/all/common/notification.c @@ -0,0 +1,584 @@ +#include "notification.h" +#include "defines.h" +#include "api.h" +#include "config.h" +#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 +/////////////////////////////// + +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; + +// 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 + +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 +/////////////////////////////// + +// 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; + + // 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(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; + 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 (skip if persistent) + 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_state.active = 0; + progress_state.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); + } + } +} + +// 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 + + 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_state.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_state.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_state.dirty; + + if (!notifications_changed && !indicator_changed && !progress_changed) { + 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 each element type + if (has_system_indicator) { + render_system_indicator(); + } + if (has_progress_indicator) { + render_progress_indicator(); + } + if (has_notifications) { + render_notification_stack(); + } + + // 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_state.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_state.active = 0; + progress_state.icon = NULL; + render_dirty = 1; + progress_state.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_state.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 + PADDING + SYS_INDICATOR_EXTRA_PAD); +} + +/////////////////////////////// +// 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_state.title, title, PROGRESS_TITLE_MAX - 1); + progress_state.title[PROGRESS_TITLE_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_state.icon = icon; + + // Activate and reset timer + progress_state.active = 1; + progress_state.start_time = SDL_GetTicks(); + progress_state.dirty = 1; +} + +void Notification_hideProgressIndicator(void) { + if (!initialized) return; + + 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_state.persistent = persistent ? 1 : 0; +} + +bool Notification_hasProgressIndicator(void) { + return initialized && progress_state.active; +} diff --git a/workspace/all/common/notification.h b/workspace/all/common/notification.h new file mode 100644 index 000000000..54a3bade0 --- /dev/null +++ b/workspace/all/common/notification.h @@ -0,0 +1,158 @@ +#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); + +/** + * 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 + */ +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..862f86ba4 --- /dev/null +++ b/workspace/all/common/ra_auth.c @@ -0,0 +1,269 @@ +#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" + +// 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; + + // 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..90ae88267 --- /dev/null +++ b/workspace/all/common/ra_auth.h @@ -0,0 +1,59 @@ +#ifndef __RA_AUTH_H__ +#define __RA_AUTH_H__ + +// 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 + */ +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..3411a8554 --- /dev/null +++ b/workspace/all/common/ra_badges.c @@ -0,0 +1,537 @@ +#include "ra_badges.h" +#include "http.h" +#include "defines.h" +#include "api.h" +#include "sdl.h" +#include "notification.h" + +#include +#include +#include +#include +#include +#include + +// 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 + *****************************************************************************/ + +#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 +#define NOTIFICATION_TIMEOUT_MS 15000 + +/***************************************************************************** + * 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; + +/***************************************************************************** + * Download queue entry + *****************************************************************************/ + +typedef struct { + char badge_name[MAX_BADGE_NAME]; + bool locked; +} QueuedDownload; + +/***************************************************************************** + * 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; +static uint32_t notification_start_time = 0; + +// Download queue for rate limiting +typedef struct { + QueuedDownload items[MAX_QUEUED_DOWNLOADS]; + int head; + int tail; + int count; + int active; +} DownloadQueueState; + +static DownloadQueueState download_queue = {0}; + +/***************************************************************************** + * 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_WARN("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 .ra directory + snprintf(path, sizeof(path), SHARED_USERDATA_PATH "/.ra"); + mkdir(path, 0755); + + // Create .ra/badges directory + mkdir(RA_BADGE_CACHE_DIR, 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_ERROR("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_ERROR("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_WARN("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; + +// 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 (download_queue.count >= MAX_QUEUED_DOWNLOADS) { + BADGE_LOG_WARN("Download queue full, dropping badge %s\n", badge_name); + return; + } + + 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; + + 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 (download_queue.count == 0) return false; + + 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); + 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; + download_queue.active++; + 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 (download_queue.active < MAX_CONCURRENT_DOWNLOADS && download_queue.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); + + download_queue.active--; + if (download_queue.active < 0) download_queue.active = 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; + } + + // Start next queued download(s) + process_download_queue(); + + // 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); + + if (response) { + HTTP_freeResponse(response); + } + free(ctx); +} + +// Request a badge download - queues if at concurrency limit +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; + } + + // Check if already cached on disk + char cache_path[MAX_PATH]; + RA_Badges_getCachePath(badge_name, locked, cache_path, sizeof(cache_path)); + if (cache_file_exists(cache_path)) { + entry->state = RA_BADGE_STATE_CACHED; + return; + } + + // Queue the download - state will be set when download actually starts + queue_download(badge_name, locked); +} + +/***************************************************************************** + * Public API + *****************************************************************************/ + +void RA_Badges_init(void) { + if (initialized) return; + + badge_mutex = SDL_CreateMutex(); + badge_cache_count = 0; + pending_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(); + + 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]) { + // 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 (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(); + } + + 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); + process_download_queue(); + 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) { + // 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 + 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) { + // 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 + 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; +} + +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", + RA_BADGE_CACHE_DIR, badge_name); + } else { + snprintf(buffer, buffer_size, "%s/%s.png", + 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..ba93d55a8 --- /dev/null +++ b/workspace/all/common/ra_badges.h @@ -0,0 +1,113 @@ +#ifndef __RA_BADGES_H__ +#define __RA_BADGES_H__ + +#include "sdl.h" +#include +#include + +// 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 +#define RA_BADGE_NOTIFY_SIZE 24 // Size for notification icons + +// Cache directory path (under SDCARD_PATH) +#define RA_BADGE_CACHE_DIR SHARED_USERDATA_PATH "/.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); + +/** + * 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/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/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..c93db66a0 --- /dev/null +++ b/workspace/all/minarch/libchdr.makefile @@ -0,0 +1,39 @@ +# 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) +# Uses the toolchain file provided by the build container +ifneq ($(PLATFORM),desktop) +CMAKE_EXTRA = -DCMAKE_TOOLCHAIN_FILE=$(CMAKE_TOOLCHAIN_FILE) +endif + +.PHONY: all build clean + +all: build + +build: $(BUILD_DIR)/libchdr.so + +$(BUILD_DIR)/libchdr.so: | $(BUILD_DIR) + 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 56b3f7138..c587ec782 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) @@ -53,6 +63,16 @@ CFLAGS += -DHAS_SRM LDFLAGS += -lasound endif +# RA support: rcheevos and libchdr linking for tg5040, tg5050, and desktop +ifneq (,$(filter $(PLATFORM),tg5040 tg5050 desktop)) +LDFLAGS += -L../rcheevos/build/$(PLATFORM) -lrcheevos +LDFLAGS += -L../libchdr/build/$(PLATFORM) -lchdr +CFLAGS += -DRC_CLIENT_SUPPORTS_HASH +ifeq ($(PLATFORM), desktop) +# 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 ifeq ($(PLATFORM), desktop) @@ -74,14 +94,27 @@ 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 $(PREFIX)/lib/liblz4.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) @@ -92,6 +125,25 @@ endif libretro-common: git clone https://github.com/libretro/libretro-common + +rcheevos: + cd ../rcheevos && make build PLATFORM=$(PLATFORM) + +# Desktop rcheevos build - uses native gcc instead of cross-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 cb7e6e06d..0ce92f1be 100644 --- a/workspace/all/minarch/minarch.c +++ b/workspace/all/minarch/minarch.c @@ -26,6 +26,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 @@ -154,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]; @@ -986,9 +990,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; @@ -1037,6 +1049,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); @@ -1071,17 +1084,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; @@ -1105,12 +1128,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: @@ -1125,6 +1150,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); @@ -1132,6 +1158,7 @@ static void State_write(void) { // from picoarch sync(); fast_forward = was_ff; + return success; } static void State_autosave(void) { @@ -4635,7 +4662,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; @@ -6688,6 +6720,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) { @@ -6737,6 +6770,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; @@ -6968,6 +7010,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, 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, 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[]) { @@ -6976,15 +7634,56 @@ 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}, } }; +// 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[4].desc = getSaveDesc(); + options_menu.items[save_changes_index].desc = getSaveDesc(); +} + +// 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); + 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 @@ -7031,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; @@ -7096,6 +7750,7 @@ static int Menu_options(MenuList* list) { int visible_rows = end; OptionSaveChanges_updateDesc(); + OptionAchievements_updateDesc(); int defer_menu = false; while (show_options) { @@ -7709,6 +8364,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"); @@ -7739,7 +8399,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(); @@ -7761,8 +8429,16 @@ static void Menu_loadState(void) { state_slot = menu.slot; putInt(menu.slot_path, menu.slot); - State_read(); + int success = State_read(); Rewind_on_state_change(); + + // 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); + } } } @@ -7922,6 +8598,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); @@ -8298,6 +8975,7 @@ int main(int argc , char* argv[]) { // initialize default shaders GFX_initShaders(); + PLAT_initNotificationTexture(); PAD_init(); DEVICE_WIDTH = screen->w; @@ -8327,8 +9005,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) @@ -8339,6 +9027,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 @@ -8376,6 +9073,49 @@ int main(int argc , char* argv[]) { GFX_startFrame(); Rewind_run_frame(); + + // Process RetroAchievements for this frame + RA_doFrame(); + + // 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; @@ -8389,6 +9129,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(); @@ -8421,10 +9163,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(); Rewind_free(); Core_unload(); diff --git a/workspace/all/minarch/ra_consoles.h b/workspace/all/minarch/ra_consoles.h new file mode 100644 index 000000000..9356e0f6b --- /dev/null +++ b/workspace/all/minarch/ra_consoles.h @@ -0,0 +1,146 @@ +#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 + +/** + * 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") + * @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; + } + + 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; +} + +/** + * 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..ddf503723 --- /dev/null +++ b/workspace/all/minarch/ra_integration.c @@ -0,0 +1,1483 @@ +#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 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 + *****************************************************************************/ + +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 +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 +#define RA_LOGIN_MAX_RETRIES 5 +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 +#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_DEBUG("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.next_time = 0; + ra_login_retry.notified_connecting = false; +} + +/***************************************************************************** + * Helper: Start a login attempt + *****************************************************************************/ +static void ra_start_login(void) { + 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(), + 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_WARN("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, SHARED_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), SHARED_USERDATA_PATH "/.ra"); + mkdir(dir_path, 0755); + snprintf(dir_path, sizeof(dir_path), SHARED_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_DEBUG("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("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_DEBUG("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_ERROR("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_WARN("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_DEBUG("%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_DEBUG("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_INFO("Achievement unlocked: %s (%d points)\n", + event->achievement->title, event->achievement->points); + break; + + case RC_CLIENT_EVENT_ACHIEVEMENT_CHALLENGE_INDICATOR_SHOW: + RA_LOG_DEBUG("Challenge started: %s\n", event->achievement->title); + break; + + case RC_CLIENT_EVENT_ACHIEVEMENT_CHALLENGE_INDICATOR_HIDE: + RA_LOG_DEBUG("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_INFO("Leaderboard started: %s\n", event->leaderboard->title); + break; + + case RC_CLIENT_EVENT_LEADERBOARD_FAILED: + 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_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_INFO("Game mastered!\n"); + break; + + case RC_CLIENT_EVENT_RESET: + RA_LOG_WARN("Reset requested (hardcore mode enabled)\n"); + break; + + case RC_CLIENT_EVENT_SERVER_ERROR: + 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", + event->server_error ? event->server_error->error_message : "unknown"); + Notification_push(NOTIFICATION_ACHIEVEMENT, message, NULL); + break; + + case RC_CLIENT_EVENT_DISCONNECTED: + RA_LOG_WARN("Disconnected - unlocks pending\n"); + Notification_push(NOTIFICATION_ACHIEVEMENT, "RetroAchievements: Offline mode", NULL); + break; + + case RC_CLIENT_EVENT_RECONNECTED: + RA_LOG_INFO("Reconnected - pending unlocks submitted\n"); + Notification_push(NOTIFICATION_ACHIEVEMENT, "RetroAchievements: Reconnected", NULL); + break; + + default: + RA_LOG_DEBUG("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_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 (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 { + // Failure - attempt retry or give up + 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) { + // Schedule retry + 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); + + // Show "Connecting..." notification on first retry only + 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); + } + } else { + // All retries exhausted + RA_LOG_ERROR("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_WARN("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_DEBUG("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_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') { + 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_WARN("Game not recognized by RetroAchievements\n"); + } + } else { + ra_game_loaded = false; + RA_LOG_ERROR("Game load failed: %s\n", error_message ? error_message : "unknown error"); + } +} + +/***************************************************************************** + * Public API + *****************************************************************************/ + +void RA_init(void) { + if (!CFG_getRAEnable()) { + RA_LOG_DEBUG("RetroAchievements disabled in settings\n"); + return; + } + + if (ra_client) { + RA_LOG_DEBUG("Already initialized\n"); + return; + } + + // Check wifi state before attempting to connect + if (!PLAT_wifiEnabled()) { + RA_LOG_WARN("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_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) { + SDL_Delay(RA_WIFI_WAIT_POLL_MS); + } + + if (!PLAT_wifiConnected()) { + 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_DEBUG("WiFi connected after %ums\n", SDL_GetTicks() - start); + } + + RA_LOG_INFO("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_ERROR("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_DEBUG("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_INFO("Logging in with stored token...\n"); + ra_start_login(); + } else { + RA_LOG_WARN("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_INFO("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_DEBUG("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_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_ERROR("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_ERROR("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_DEBUG("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_DEBUG("Memory regions initialized: %u regions, %zu total bytes\n", + ra_memory_regions.count, ra_memory_regions.total_size); + } else { + RA_LOG_WARN("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 (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; +} + +/***************************************************************************** + * 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_WARN("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_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_DEBUG("Detected Sega CD image, using console ID %d\n", console_id); + } + + 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 + // 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_ERROR("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_DEBUG("Login in progress - deferring game load for: %s\n", rom_path); + + // Clear any previous pending game + ra_clear_pending_game(); + + // Store the path + 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(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) { + 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"); + ra_pending_load.rom_size = 0; + } + } + + ra_pending_load.active = 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_INFO("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.next_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_DEBUG("Achievement %u muted\n", achievement_id); + } else { + RA_LOG_WARN("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_DEBUG("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..9353a5c97 --- /dev/null +++ b/workspace/all/minarch/ra_integration.h @@ -0,0 +1,167 @@ +#ifndef __RA_INTEGRATION_H__ +#define __RA_INTEGRATION_H__ + +#include +#include +#include + +// See: https://github.com/RetroAchievements/rcheevos/wiki/rc_client-integration + +/** + * 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 5566e3912..d4952966b 100644 --- a/workspace/all/settings/menu.cpp +++ b/workspace/all/settings/menu.cpp @@ -272,6 +272,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 4ca964d60..ead45e0e7 100644 --- a/workspace/all/settings/menu.hpp +++ b/workspace/all/settings/menu.hpp @@ -262,6 +262,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 3e15c289b..984452224 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 @@ -84,6 +85,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; @@ -480,6 +517,133 @@ 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"); + }}, + // 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)); }, + []() { 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}, + }); + // We need to alert the user about potential issues if the // stock OS was modified in way that are known to cause issues std::string bbver = extractBusyBoxVersion(execCommand("cat --help")); @@ -523,6 +687,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..ffad64125 100644 --- a/workspace/desktop/libmsettings/msettings.c +++ b/workspace/desktop/libmsettings/msettings.c @@ -405,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; } 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